有了 【空间变换】齐次坐标【空间变换】齐次坐标
空间变换这一系列笔记,讲的是一个顶点从建模软件里的局部坐标,一路变换到屏幕像素的完整过程,也就是渲染管线里常说的 MVP 变换加上后续的屏幕映射。而这一切的数学地基,是齐次坐标。所以先从它讲起。
为什么需要齐次坐标
顶点变换无非三种:平移、旋转、缩放。旋转和缩放都能写成一个矩阵乘以坐标:
$$P' = M \cdot P$$
但平移不行。在三维笛卡尔坐标下,平移是把每个分量各加一个偏移量:
$$(x, y, z) \rightarrow (x + t_x,\ y + t_y,\ z + t_z)$$
这是一次加法,没法用一个 $3\times3$ 矩阵乘出来。于是就出现了一个别扭的局面:旋转缩放是乘法,平移是加法,两类操作凑不到一起。如果一个顶点既要旋转又要平移,就得写成 $P' = M \cdot P + t$,既有乘又有加。当变换一多,这种混合写法会变得非常难组合。
齐次坐标就是为了解决这件事:给坐标加上第四个分量 $w$,把平移也塞进矩阵里,让平移、旋转、缩放全都变成统一的矩阵乘法。 一旦所有变换都是矩阵,它们就能通过矩阵连乘预先合并成一个矩阵 打底,就可以来看最基础的三种变换:平移、缩放、旋转。它们是模型变换(Model Transformation)的三块积木,把物体从局部空间摆放到世界空间里的正确位置、朝向和大小,靠的就是这三者的组合。
因为统一用了齐次坐标,三种变换都写成 4×4 矩阵,可以直接连乘合并,最后对每个顶点只做一次矩阵乘法。下面逐个推导,并附上在着色器里的写法。
平移变换
平移就是给每个坐标分量加上一个偏移量,借助齐次坐标的第四列实现:
100001000010xyz1PxPyPz1=(Px+x, Py+y, Pz+z, 1)
对应的着色器代码:
float4x4 T = float4x4(
1, 0, 0, _TransForm.x,
0, 1, 0, _TransForm.y,
0, 0, 1, _TransForm.z,
0, 0, 0, 1
);
v.positionOS = mul(T, float4(v.positionOS.xyz, 1.0));
缩放变换
缩放是把各个轴上的坐标各自乘以一个缩放系数,所以缩放系数落在矩阵的对角线上:
Sx0000Sy0000Sz00001PxPyPz1=(PxSx, PySy, PzSz, 1)
代码里额外用 _Scale.w 做了一个整体的统一缩放系数:
float4x4 S = float4x4(
_Scale.x * _Scale.w, 0, 0, 0,
0, _Scale.y * _Scale.w, 0, 0,
0, 0, _Scale.z * _Scale.w, 0,
0, 0, 0, 1
);
v.positionOS = mul(S, float4(v.positionOS.xyz, 1.0));
旋转变换
旋转比前两者复杂,先从二维推起,再推广到三维。
二维旋转

设想有两个坐标系:(x1,y1) 和把它旋转了 θ 角后的 (x2,y2)。已知点 P 在第一个坐标系下的坐标 (Px1,Py1),要求它在第二个坐标系下的坐标 (Px2,Py2)。分别推导两个分量。
推导 Px2:

OC=OB+BC
cosθ=OAOB⟹OB=cosθ⋅OA
sinθ=APAD⟹AD=BC=sinθ⋅AP
OC=cosθ⋅OA+sinθ⋅AP
Px2=cosθ⋅Px1+sinθ⋅Py1
推导 Py2:

OA=OC−AC
cosθ=OBOC⟹OC=cosθ⋅OB
sinθ=BPBD⟹AC=BD=sinθ⋅BP
OA=cosθ⋅OB−sinθ⋅BP
Py2=cosθ⋅Py1−sinθ⋅Px1
于是点 P 在第二个坐标系下的坐标为:
P2=(cosθ⋅Px1+sinθ⋅Py1, cosθ⋅Py1−sinθ⋅Px1)
写成矩阵形式:
[cosθ−sinθsinθcosθ][Px1Py1]=[cosθ⋅Px1+sinθ⋅Py1−sinθ⋅Px1+cosθ⋅Py1]
需要留意的是,这里推的是"坐标系旋转 θ、点不动"的情形,等价于在固定坐标系里把点反向旋转。后面三维的三个轴,都沿用这套同样的符号约定,保持一致即可。
三维旋转
三维旋转的前提是先定住一根轴,绕这根轴旋转。绕某根轴旋转时,该轴上的坐标分量保持不变,另外两个分量按二维旋转的规律变化。
绕 X 轴旋转: x 分量不变,y、z 分量参与旋转。
10000cosθ−sinθ00sinθcosθ00001PxPyPz1=Pxcosθ⋅Py+sinθ⋅Pz−sinθ⋅Py+cosθ⋅Pz1
绕 Y 轴旋转: y 分量不变。
cosθ0−sinθ00100sinθ0cosθ00001PxPyPz1=cosθ⋅Px+sinθ⋅PzPy−sinθ⋅Px+cosθ⋅Pz1
绕 Z 轴旋转: z 分量不变。
cosθ−sinθ00sinθcosθ0000100001PxPyPz1=cosθ⋅Px+sinθ⋅Py−sinθ⋅Px+cosθ⋅PyPz1
对应的着色器代码:
// 绕 X 轴旋转
float4x4 R_X = float4x4(
1, 0, 0, 0,
0, cos(_Angle.x), sin(_Angle.x), 0,
0, -sin(_Angle.x), cos(_Angle.x), 0,
0, 0, 0, 1
);
v.positionOS = mul(R_X, float4(v.positionOS.xyz, 1.0));
// 绕 Y 轴旋转
float4x4 R_Y = float4x4(
cos(_Angle.y), 0, sin(_Angle.y), 0,
0, 1, 0, 0,
-sin(_Angle.y), 0, cos(_Angle.y), 0,
0, 0, 0, 1
);
v.positionOS = mul(R_Y, float4(v.positionOS.xyz, 1.0));
// 绕 Z 轴旋转
float4x4 R_Z = float4x4(
cos(_Angle.z), sin(_Angle.z), 0, 0,
-sin(_Angle.z), cos(_Angle.z), 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
);
v.positionOS = mul(R_Z, float4(v.positionOS.xyz, 1.0));
任意方向的旋转,都可以拆成绕三根轴旋转的组合(即欧拉角),把这三个矩阵按顺序连乘即可。把平移、缩放、旋转三者合并成一个矩阵,就得到完整的模型变换。接下来是把物体从世界空间搬到相机视角下的 【空间变换】观察变换【空间变换】观察变换经过 [[【空间变换】基本变换]] 的模型变换,物体已经摆放在世界空间里。接下来要换个视角看世界——把相机放到合适的位置和角度,这一步叫观察变换(View Transformation),也叫视图变换。
打个拍照的比方:先把要拍的东西摆放好,这是模型变换;再挑一个角度架好相机,这是观察变换;最后按下快门把三维场景拍成二维照片,那是后面的 [[【空间变换】投影变换]]。
观察空间
观察空间(View Space)就是以相机原点为坐标原点建立起来的坐标系。它的三个基向量在世界空间里的取值,描述的正是相机的朝向。
按照惯例,观察空间是右手坐标系,相机看向 $-Z$ 方向。也就是说,相机的视线方向对应的是观察空间的 $-Z$ 轴。如果记相机位置为 $ViewPos$、注视目标为 $ViewTarget$,那么视线方向(gaze)指向目标,而观察空间的 $+Z$ 轴与视线相反:
$$Z_{view} = -(ViewTarget - ViewPos) = ViewPos - ViewTarget$$
要唯一确定一个相机,需要三个量:
1. 相机的位置。
2. 相机看向的方向(gaz。
参考