变换 0. 怎么理解变换? 1. 视图变换 2. 投影变换 3. 视口变换 参考
Transformation 这个东西学了又忘,反复好多次了。每次都没办法很好地复述出来。现在写下总结,希望以后忘了的时候可以快点记起。
实时渲染管线从非常宏观的过程上来说就是在模仿一个拍摄照片的过程。举个例子,要拍一张蛋糕的照片:
- 把蛋糕摆在某个地方,调整好它的角度 => M_model
- 对准蛋糕,摆好相机的角度 => M_view
- 按下快门得到照片 => M_projection
变换通过对顶点应用矩阵来实现。最常见的变换是缩放、旋转和平移。如果一个变换只包含缩放和旋转,那么它叫做线性变换。如果还包含了平移,那么称为仿射变换。这两种变换的区别在于线性变换只能做到:
x' = ax + by
y' = cx + dy
而仿射变换可以做到:
x' = ax + by + e
y' = cx + dy + f
与仿射变换相关的一个重要概念是齐次坐标。齐次坐标用 n+1 维向量来表示 n 维向量。具体来说,如果你有一个4维坐标,你需要把前3个分量都除以最后一个分量,得到的3维坐标是真正代表的坐标。即:
[x, y, z, w] <=> [x/w, y/w, z/w] (w != 0)
有了齐次坐标,最常见的变换都可以统一到4x4的矩阵中来描述。
1. 视图变换
回到蛋糕的例子。蛋糕摆好了(应用过 M_model),要开始摆相机了。这里有一个核心的概念,就是只要蛋糕和相机的相对位置没有改变,那么我移动相机和移动蛋糕是一样的效果。那么我摆好相机之后,我把相机移到原点,再把相机的坐标系跟世界坐标系对齐。只要此过程中给蛋糕作同样的变换,那么我拍出来的照片没有改变。这里就可以看出,视图变换需要平移和旋转变换。
(以下字母表示向量,采用左手坐标系,行向量)
假设相机的位置在 Q,蛋糕的位置在 T,世界坐标系的上是 j
首先以相机位置为基准构建它自己的坐标系:
w = T - Q
u = j × w
v = w × u
以上向量默认归一化操作。下面考虑变换矩阵的构成。首先把相机平移到原点,一个平移矩阵:
1 0 0 0
0 1 0 0 (I)
0 0 1 0
< -Q >1
接下来考虑将相机坐标系对齐到世界坐标系。发现直接找不好找,但是反过来好找,即把世界坐标系对齐到相机坐标系:
— u — 0
— v — 0
— w — 0
0 0 0 1
验证一下正确性,用 x = [1 0 0 0] 乘上述矩阵得到 [ux uy uz 0],表明 x 确实对齐到了 u。同理 y 对齐 v,z 对齐 w。
由于 uvw 都是单位向量,其组成的矩阵又是正交阵。这个矩阵的转置就是它的逆,这样相机坐标系对齐的世界坐标系的变换矩阵就是:
| | | 0
u v w 0 (II)
| | | 0
0 0 0 1
(I)乘(II)就得到最终的 M_view:
| | | 0
u v w 0
| | | 0
-Qu -Qv -Qw 1
实际验证一下。下面的代码:
XMVECTOR Eye = XMVectorSet( 0.0f, 1.0f, -5.0f, 0.0f );
XMVECTOR At = XMVectorSet( 0.0f, 1.0f, 0.0f, 0.0f );
XMVECTOR Up = XMVectorSet( 0.0f, 1.0f, 0.0f, 0.0f );
g_View = XMMatrixLookAtLH( Eye, At, Up );
得出来的 view 矩阵是:
1 0 0 0
0 1 0 0
0 0 1 0
0 -1 5 1
手性
拇指、食指、无名指按照顺序对应 x、y、z 轴,把 z 轴的方向看作视线的方向。左手坐标系下视线方向朝向屏幕内,右手坐标系下往屏幕外戳。叉乘的方向在左手坐标系下用左手螺旋定则判断,在右手坐标系下用右手螺旋定则判断。
2. 投影变换
经过投影变换,就可以知道用3维坐标来表述的物体在屏幕上的2维坐标了。投影分为正交投影和透视投影。二者的区别在于透视投影存在近大远小的现象,而正交投影中物体离相机的远近与其大小无关(也就是丢掉 z 分量)。
-
正交投影:把任意一个立方体(Cuboid)映射到单位立方体(Canonical Cube [-1, 1]^3),即先平移,再缩放。
-
透视投影:可以理解为把视锥体(Frustum)的远平面挤压到和*面一样大小,然后作正交投影。
按照 D3D 的标准,经过透视投影变换后所有的点都满足:
-1 <= x <= 1
-1 <= y <= 1
0 <= z <= 1
2.1 参数解释
-
远*面:设*面离原点距离为 n,远平面离原点距离为 f
-
视口长宽比:aspect ratio = viewport_width(Vw) / viewport_hight(Vh)
-
视野(Field of view, FOV):分为垂直和水平 fov_vert / fov_horiz
- 为什么要引入长宽比参数?原因是如果投影窗口(视口)和后置缓冲区的大小不成比例,渲染出来的图像就要进行非统一比例缩放。这可能会引起失真。
近大远小原理
如上图,观察两条相同长度的灰色线段在投影窗口上的投影长度,红点比绿点高,说明近处的线段投影长度比远处的大。
相互转换
观察下图这几个相似三角形有:
- aspect ratio、fov_horiz 与 fov_vert
tan(fov_horiz/2) = r / d
tan(fov_vert /2) = 1 / d
=> d = cot(fov_vert /2)
=> tan(fov_horiz/2) = r * tan(fov_vert/2)
- n、f 和 Vw、Vh([3]中有提到,意义不明)
因为 r = Vw / Vh。若 x = Vw/2, y = Vh/2 时的 z = n,则有:
tan(fov_horiz/2) = (Vw/2) / n = Vw / 2*n
tan(fov_vert /2) = (Vh/2) / n = Vh / 2*n
2.2 投影矩阵的推导
规定参数
设置投影矩阵,aspect ratio、fov_horiz 和 fov_vert 这三个参数,只需要给出任意两个就够了。
下面的推导按照下列函数的参数设置:
D3DXMATRIX* D3DXMatrixPerspectiveFovLH(
_Inout_ D3DXMATRIX *pOut,
_In_ FLOAT fovy,
_In_ FLOAT Aspect,
_In_ FLOAT zn,
_In_ FLOAT zf
);
即给出 fov_vert、aspect ratio、n 和 f。
推导
视锥体是定义在远*面之间的平头锥体,只有在视锥体里的顶点才可能留下。观察上图中的顶点 v = (x, y, z) 投影后得到 v' = (x', y', z'),根据相似三角形得:
x / z = x' / d => x' = (d*x) / z = x / ( z * tan(fov_horiz/2) ) = x / ( z * r * tan(fov_vert /2) )
y / z = y' / d => y' = (d*y) / z = y / ( z * tan(fov_vert /2) )
写成齐次坐标的形式:
[x, y, z, 1] * M_proj = [x', y', z', 1]
由于齐次坐标最后要除以最后一个分量才得到真实代表的坐标,故投影矩阵可以这么写:
1/(r*tan(fov_vert/2)) 0 ? 0
0 1/tan(fov_vert/2) ? 0 (III)
0 0 ? 1
0 0 ? 0
=> [x, y, z, 1] * (III) = [x/(r*tan(fov_vert/2)),
y/tan(fov_vert/2),
?,
z]
为了推出(III)中的第3列,考虑一些不变的性质。*面上的点变换后z值不变,即:
[x, y, n, 1] * (III):col3 = n
可以看出第3列的前两项肯定为0,因为 x、y 如果参与了就不会结果为 n 了:
(III):col3 = [0 0 A B]T
=> [x, y, z, 1] * (III) = [x/(r*tan(fov_vert/2)),
y/tan(fov_vert/2),
A*z+B,
z]
(除z) => = [x/(z*r*tan(fov_vert/2)),
y/(z*tan(fov_vert/2)),
A+B/z,
1]
再考虑透视投影矩阵的要求:经过变换后所有顶点满足 0 <= z <= 1。即*面 z=0,远平面 z=1。故:
A + B/n = 0
A + B/f = 1
=> A = f/(f-n), B = -nf/(f-n)
1/(r*tan(fov_vert/2)) 0 0 0
=> (III) = 0 1/tan(fov_vert/2) 0 0 = M_proj
0 0 f/(f-n) 1
0 0 -nf/(f-n) 0
给出 fov_vert、fov_horiz、n 和 f 的话……
见[3]:
D3DXMATRIX
ProjectionMatrix(const float near_plane, // Distance to near clipping
// plane
const float far_plane, // Distance to far clipping
// plane
const float fov_horiz, // Horizontal field of view
// angle, in radians
const float fov_vert) // Vertical field of view
// angle, in radians
{
float h, w, Q;
w = (float)1/tan(fov_horiz*0.5); // 1/tan(x) == cot(x)
h = (float)1/tan(fov_vert*0.5); // 1/tan(x) == cot(x)
Q = far_plane/(far_plane - near_plane);
D3DXMATRIX ret;
ZeroMemory(&ret, sizeof(ret));
ret(0, 0) = w;
ret(1, 1) = h;
ret(2, 2) = Q;
ret(3, 2) = -Q*near_plane;
ret(2, 3) = 1;
return ret;
} // End of ProjectionMatrix
实际验证
XM_CONST float XM_PIDIV4 = 0.785398163f;
g_Projection = XMMatrixPerspectiveFovLH( XM_PIDIV4, width / (FLOAT)height, 1.0f, 100.0f ); // width = 800, height = 600
得出的投影矩阵是:
1.81 0 0 0
0 2.41 0 0
0 0 1 1
0 0 -1.01 1
2.3 DX 里的变换
1. Object Space => World Transform
2. World Space => View Transform
3. View Space => Projection Transform
4. Clip Space => Divide By W (Perspective Divide)
5. Normalized Device Coordinates
DX 的管线里把上述的投影矩阵分在了两个步骤。视图变换之后进入相机空间,随后应用投影矩阵,而除 w 放在了下一步。在这个未除 w 的裁剪空间里,还会进行剔除和裁剪的操作。
因为除 w 之后 (Normalized Device Coordinates) 所有坐标满足:
-1 <= x <= 1
-1 <= y <= 1
0 <= z <= 1
所以未除 w 之前,不被剔除和裁切的点应该满足:
-w <= x <= w
-w <= y <= w
0 <= z <= w
3. 视口变换
经过投影变换,所有的二维顶点都在 [-1, 1]^2 内。把它们转换到 [0, viewport_width] x [0, viewport_height] 的视口变换矩阵 M_viewport 为:
viewport_width/2 0 0 0
0 viewport_height/2 0 0
0 0 0 0
viewport_width/2 viewport_height/2 0 0
参考
[1] Introduction to 3D Game Programming with DirectX 11
[2] GAMES101-现代计算机图形学入门-闫令琪 https://www.bilibili.com/video/BV1X7411F744
[3] https://docs.microsoft.com/en-us/windows/win32/direct3d9/projection-transform