Skip to content
Go back

《Unity Shader入门精要》学习笔记

Updated:
Edit page

4.5.3分解基础变换矩阵

基础变换矩阵:表示纯平移、纯旋转和纯缩放的变换矩阵叫做基础变换矩阵。 一个基础变换矩阵可以分解成4个组成部分:

[M3×3t3×101×31]\begin{bmatrix} M_{3 \times 3} & t_{3 \times 1} \\ \mathbf{0}_{1 \times 3} & 1 \end{bmatrix}

其中,M3×3M_{3 \times 3} 表示用于旋转和缩放,t3×1t_{3 \times 1} 用于平移,01×3\mathbf{0}_{1 \times 3} 为零矩阵,右下角为标量1。

4.5.4 平移矩阵

可以用矩阵乘法表示对一个点进行平移变换:

[100tx010ty001tz0001][xyz1]=[x+txy+tyz+tz1]\begin{bmatrix} 1 & 0 & 0 & t_{x} \\ 0 & 1 & 0 & t_{y} \\ 0 & 0 & 1 & t_{z} \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix}= \begin{bmatrix} x + t_{x} \\ y + t_{y} \\ z + t_{z} \\ 1 \end{bmatrix}

平移结果:点的x\mathcal{x}y\mathcal{y}z\mathcal{z} 分量分别增加了一个位置偏移。

而平移变换不会对方向矢量产生任何影响。因为矢量没有位置属性,它可以位于空间中的任意一点,因此对位置的改变(平移)不应该对四维矢量产生影响。 对方向矢量进行平移变换:

[100tx010ty001tz0001][xyz0]=[xyz0]\begin{bmatrix} 1 & 0 & 0 & t_{x} \\ 0 & 1 & 0 & t_{y} \\ 0 & 0 & 1 & t_{z} \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ z \\ 0 \end{bmatrix}= \begin{bmatrix} x \\ y \\ z \\ 0 \end{bmatrix}

平移矩阵的逆矩阵是反向平移得到的矩阵,即:

[100tx010ty001tz0001]\begin{bmatrix} 1 & 0 & 0 & -t_{x} \\ 0 & 1 & 0 & -t_{y} \\ 0 & 0 & 1 & -t_{z} \\ 0 & 0 & 0 & 1 \end{bmatrix}

平移矩阵不是正交矩阵。

4.5.5 缩放矩阵

可以用矩阵乘法对一个模型沿空间的x轴、y轴和z轴进行缩放变换:

[kx0000ky0000kz00001][xyz1]=[kxxkyykzz1]\begin{bmatrix} k_{x} & 0 & 0 & 0 \\ 0 & k_{y} & 0 & 0 \\ 0 & 0 & k_{z} & 0 \\ 0 & 0 & 0 &1 \end{bmatrix} \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix} = \begin{bmatrix} k_{x}x \\ k_{y}y \\ k_{z}z \\ 1 \end{bmatrix}

对方向矢量也可以进行缩放变换:

[kx0000ky0000kz00001][xyz0]=[kxxkyykzz0]\begin{bmatrix} k_{x} & 0 & 0 & 0 \\ 0 & k_{y} & 0 & 0 \\ 0 & 0 & k_{z} & 0 \\ 0 & 0 & 0 &1 \end{bmatrix} \begin{bmatrix} x \\ y \\ z \\ 0 \end{bmatrix} = \begin{bmatrix} k_{x}x \\ k_{y}y \\ k_{z}z \\ 0 \end{bmatrix}

如果缩放系数kx=ky=kzk_{x}=k_{y}=k_{z},则该缩放为统一缩放,否则为非统一缩放。 缩放矩阵的逆矩阵是使用原缩放系数的倒数来对点或方向矢量进行缩放,即:

[1kx00001ky00001kz00001]\begin{bmatrix} \frac{1}{k_{x}} & 0 & 0 & 0 \\ 0 & \frac{1}{k_{y}} & 0 & 0 \\ 0 & 0 & \frac{1}{k_{z}} & 0 \\ 0 & 0 & 0 &1 \end{bmatrix}

以上的矩阵只适用于沿坐标轴方向进行缩放。如果希望在任意方向上进行缩放,则需要使用一个复合变换。其中一种方法的主要思想为:先将缩放轴变换成标准坐标轴,然后进行沿坐标轴的缩放,再使用逆变换得到原来的缩放轴朝向。

缩放矩阵不是正交矩阵。

4.5.6 旋转矩阵

旋转操作需要指定一个旋转轴,但这个旋转轴不一定是空间中的坐标轴,以下旋转指的是绕着空间中的x轴、y轴或z轴进行旋转。 把点绕着x轴旋转θ度:

Rx(θ)=[10000cosθsinθ00sinθcosθ00001]\mathbf{R}_{x}(θ) = \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & cosθ & -sinθ & 0 \\ 0 & sinθ & cosθ & 0 \\ 0 & 0 & 0 &1 \end{bmatrix}

把点绕着y轴旋转θ度:

Ry(θ)=[cosθ0sinθ00100sinθ0cosθ00001]\mathbf{R}_{y}(θ) = \begin{bmatrix} cosθ & 0 & sinθ & 0 \\ 0 & 1 & 0 & 0 \\ -sinθ & 0 & cosθ & 0 \\ 0 & 0 & 0 &1 \end{bmatrix}

把点绕着z轴旋转θ度:

Rz(θ)=[cosθsinθ00sinθcosθ0000100001]\mathbf{R}_{z}(θ) = \begin{bmatrix} cosθ & -sinθ & 0 & 0 \\ sinθ & cosθ & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 &1 \end{bmatrix}

旋转矩阵的逆矩阵是旋转相反角度得到的变换矩阵,以点绕着x轴旋转-θ度为例:

Rx(θ)=[10000cosθsinθ00sinθcosθ00001]\mathbf{R}_{x}(-θ) = \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & cosθ & sinθ & 0 \\ 0 & -sinθ & cosθ & 0 \\ 0 & 0 & 0 &1 \end{bmatrix}

旋转矩阵是正交矩阵,并且多个旋转矩阵之间的串联也是正交的。

4.5.7 复合变换

复合变换可以通过矩阵的串联来实现,绝大多数情况下,复合变换的顺序为先缩放,再旋转,最后平移: 若Pold\mathbf{P}_{old} 为列矩阵,则复合变换为:

Pnew=MtranslationMrotationMscalθPold\mathbf{P}_{new} = \mathbf{M}_{translation} \mathbf{M}_{rotation} \mathbf{M}_{scalθ} \mathbf{P}_{old}

Pold\mathbf{P}_{old} 为行矩阵,则复合变换为:

Pnew=PoldMscalθMrotationMtranslation\mathbf{P}_{new} =\mathbf{P}_{old} \mathbf{M}_{scalθ} \mathbf{M}_{rotation} \mathbf{M}_{translation}

旋转的变换顺序在unity中为zxy\mathcal{z}\mathcal{x}\mathcal{y},即当给定(θx,θy,θz)(\mathbf{θ}_{x},\mathbf{θ}_{y},\mathbf{θ}_{z}) 的旋转顺序时,得到的组合旋转变换矩阵为:

MrotatθZMrotatθXMrotatθY=[cosθzsinθz00sinθzcosθz0000100001][10000cosθxsinθx00sinθxcosθx00001][cosθy0sinθy00100sinθy0cosθy00001]\mathbf{M}_{rotat\mathbf{θ}_{Z}}\mathbf{M}_{rotat\mathbf{θ}_{X}}\mathbf{M}_{rotat\mathbf{θ}_{Y}} = \begin{bmatrix} cosθ_{z} & -sinθ_{z} & 0 & 0 \\ sinθ_{z} & cosθ_{z} & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 &1 \end{bmatrix} \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & cosθ_{x} & -sinθ_{x} & 0 \\ 0 & sinθ_{x} & cosθ_{x} & 0 \\ 0 & 0 & 0 &1 \end{bmatrix} \begin{bmatrix} cosθ_{y} & 0 & sinθ_{y} & 0 \\ 0 & 1 & 0 & 0 \\ -sinθ_{y} & 0 & cosθ_{y} & 0 \\ 0 & 0 & 0 &1 \end{bmatrix}

旋转时使用的坐标系有两种选择: (绕固定坐标系)按YXZ顺序旋转 =(绕自身坐标系)按ZXY顺序旋转

4.6 坐标空间

4.6.2 坐标空间的变换

要想定义一个坐标空间,必须指明其原点位置和3个坐标轴的方向。而这些数值实际上是相对于另一个坐标空间的。也就是说,坐标空间会形成一个层次结构——每个坐标空间都是另一个坐标空间的子空间,即每个空间都有一个父坐标空间。对坐标空间的变换实际上就是在父空间和子空间之间对点和矢量进行变换。 假设现有父坐标空间 P\mathbf{P} 以及一个子坐标空间 C\mathbf{C}。一般有两种需求:把子坐标空间下表示的点或矢量 Ac\mathbf{A}_{c} 转换到父空间坐标下的表示 Ap\mathbf{A}_{p};把父坐标空间下表示的点或矢量 Bp\mathbf{B}_{p} 转换到子坐标空间下的表示 Bc\mathbf{B}_{c},即:

Ap=McpAc\mathbf{A}_{p}=\mathbf{M}_{c\to p}\mathbf{A}_{c} Bc=MpcBp\mathbf{B}_{c}=\mathbf{M}_{p\to c}\mathbf{B}_{p}

其中,Mcp\mathbf{M}_{c\to p} 表示从子坐标空间变换到父坐标空间的变换矩阵,而 Mpc\mathbf{M}_{p\to c} 是其逆矩阵。

求解 Mcp\mathbf{M}_{c\to p}:已知子坐标空间 C\mathbf{C} 的 3 个坐标轴在父坐标空间 P\mathbf{P} 下的表示分别为 xc\mathbf{x}_{c}yc\mathbf{y}_{c}zc\mathbf{z}_{c},以及其原点位置 Oc\mathbf{O}_{c}。给定子坐标空间中的一点 Ac=(a,b,c)\mathbf{A}_{c}=(a,b,c),可通过以下步骤得到其在父坐标空间中的位置 Ap\mathbf{A}_{p}

  1. 从坐标空间原点 Oc\mathbf{O}_{c} 开始。
  2. 向 x 轴方向移动 aa 个单位,得到 Oc+axc\mathbf{O}_{c}+a\mathbf{x}_{c}
  3. 向 y 轴方向移动 bb 个单位,得到 Oc+axc+byc\mathbf{O}_{c}+a\mathbf{x}_{c}+b\mathbf{y}_{c}
  4. 向 z 轴方向移动 cc 个单位,得到 Oc+axc+byc+czc\mathbf{O}_{c}+a\mathbf{x}_{c}+b\mathbf{y}_{c}+c\mathbf{z}_{c}

因此:

Ap=Oc+axc+byc+czc\mathbf{A}_{p}=\mathbf{O}_{c}+a\mathbf{x}_{c}+b\mathbf{y}_{c}+c\mathbf{z}_{c}

alt text

其中“|”表示按列展开。将上式扩展到齐次坐标空间中:

alt text 注:第二行第一个矩阵的第三行第三列元素应为1

即可得到 Mcp\mathbf{M}_{c\to p}

alt text

根据 Mcp\mathbf{M}_{c\to p} 的公式可以知道,该变换矩阵可以通过坐标空间 C\mathbf{C} 在坐标空间 P\mathbf{P} 中的原点和坐标轴矢量构建出来:把 3 个坐标轴依次放入矩阵的前 3 列,把原点矢量放在最后一列,再用 0 和 1 填充最后一行。

因此,若已知从模型空间到世界空间的一个 4×44\times4 变换矩阵,可以提取第一列并进行归一化,得到模型空间的 x 轴在世界空间下的单位矢量,y 轴和 z 轴同理。或者说,变换矩阵 Mcp\mathbf{M}_{c\to p} 可以把一个方向矢量从坐标空间 C\mathbf{C} 变换到坐标空间 P\mathbf{P} 中,则用它来变换坐标空间 C\mathbf{C} 中的 x 轴 (1,0,0,0)(1,0,0,0),即使用矩阵乘法:

Mcp[1000]\mathbf{M}_{c\to p} \begin{bmatrix} 1 \\ 0 \\ 0 \\ 0 \end{bmatrix}

得到的结果即为 Mcp\mathbf{M}_{c\to p} 的第一列。

由于矢量是没有位置的,所以对方向矢量进行坐标空间变换时,原点变换可以忽略。因此,对矢量的坐标空间变换可以使用 3×33\times3 的矩阵来表示:

Mcp=[xcyczc]\mathbf{M}_{c\to p}= \begin{bmatrix} |& |& |\\ \mathbf{x}_{c}& \mathbf{y}_{c}& \mathbf{z}_{c}\\ | & |& | \end{bmatrix}

对法线方向、光照方向进行坐标空间变换时,就可以用上式。 若 Mcp\mathbf{M}_{c\to p} 是一个正交矩阵,则它的逆矩阵就等于它的转置矩阵:

Mpc=[xpypzp]=Mcp1=McpT=[xcyczc]\mathbf{M}_{p\to c}= \begin{bmatrix} |&|&|\\ \mathbf{x}_{p}&\mathbf{y}_{p}&\mathbf{z}_{p}\\ |&|&| \end{bmatrix} = \mathbf{M}^{-1}_{c\to p} = \mathbf{M}^{T}_{c\to p} = \begin{bmatrix} \cdots & \mathbf{x}_{c} & \cdots \\ \cdots & \mathbf{y}_{c} & \cdots \\ \cdots & \mathbf{z}_{c} & \cdots \end{bmatrix}

4.6.4 模型空间

模型空间也被成为对象空间或局部空间,每个模型都有独立的坐标空间。

4.6.5 世界空间

世界空间是游戏世界中最大的空间,以农场游戏为例,这个农场就是世界空间。 世界空间可以被用于描述绝对位置,即在世界坐标系中的位置。通常来说,世界空间的原点放置在游戏空间的中心。 顶点变换的第一步,就是将顶点坐标从模型空间变换到世界空间中。这个变换叫做模型变换:

Pworld=MmodelPmodel\mathbf{P}_{world}=\mathbf{M}_{model}\mathbf{P}_{model}

4.6.6 观察空间

观察空间也被称为摄像机空间。在unity中观察空间的坐标轴选择为:+x轴指向右方,+y轴指向上方,+z轴指向摄像机的后方。这是因为Unity在模型空间和世界空间中选用的都是左手坐标系,而在观察空间中使用的是右手坐标系。

顶点变换的第二步,就是将顶点坐标从世界空间变换到观察空间中。这个变换叫做观察变换。 已知摄像机在世界空间中的变换是先按(30,0,0)旋转,然后按(0,10,-10)进行平移。为了把摄像机重新移到初始状态(即摄像机原点位于世界坐标的原点、坐标轴与世界空间的坐标轴重合),则需要进行你想变换,即先按(0,-10,10)平移,再按(-30,0,0)旋转,可得到变换矩阵:

Mview=[10000cosθsinθ00sinθcosθ00001][100tx010ty001tz0001]=[100000.8660.5000.50.86600001][100001010001100001]=[100000.8660.53.6600.50.86613.660001]\mathbf{M}_{view} = \begin{bmatrix} 1 & 0 & 0 & 0\\ 0 & cosθ & -sinθ & 0 \\ 0 & sinθ & cosθ & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} 1 & 0 & 0 & t_{x} \\ 0 & 1 & 0 & t_{y} \\ 0 & 0 & 1 & t_{z} \\ 0 & 0 & 0 & 1 \end{bmatrix} \\ = \begin{bmatrix} 1 & 0 & 0 & 0\\ 0 & 0.866 & 0.5 & 0 \\ 0 & -0.5 & 0.866 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & -10 \\ 0 & 0 & 1 & 10 \\ 0 & 0 & 0 & 1 \end{bmatrix} \\ = \begin{bmatrix} 1 & 0 & 0 & 0\\ 0 & 0.866 & 0.5 & -3.66 \\ 0 & -0.5 & 0.866 & 13.66 \\ 0 & 0 & 0 & 1 \end{bmatrix}

由于观察坐标使用的是右手坐标系,所以需要用一个特殊矩阵对z分量进行取反操作:

Mview=MnegatezMview=[1000010000100000]=[100000.8660.53.6600.50.86613.660001]=[100000.8660.53.6600.50.86613.660001]\mathbf{M}_{view} = \mathbf{M}_{negate z}\mathbf{M}_{view} = \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & -1 & 0 \\ 0 & 0 & 0 & 0 \end{bmatrix} = \begin{bmatrix} 1 & 0 & 0 & 0\\ 0 & 0.866 & 0.5 & -3.66 \\ 0 & -0.5 & 0.866 & 13.66 \\ 0 & 0 & 0 & 1 \end{bmatrix} = \begin{bmatrix} 1 & 0 & 0 & 0\\ 0 & 0.866 & 0.5 & -3.66 \\ 0 & 0.5 & -0.866 & -13.66 \\ 0 & 0 & 0 & 1 \end{bmatrix}

从世界空间变换到观察空间:

Pview=MviewPworld\mathbf{P}_{view} = \mathbf{M}_{view} \mathbf{P}_{world}

4.6.7 裁剪空间

顶点变换的第三步,从观察空间变换到裁剪空间(齐次裁剪空间)。用于变换的矩阵叫做裁剪矩阵,也称为投影矩阵。

裁剪空间的目标是能够方便地对渲染图元进行裁剪:完全位于这块空间内部的图元会被保留,完全位于该空间外部的图元将会被剔除,而与该空间边界相交的图元会被裁剪。

裁剪空间是由视锥体来决定的。视锥体指的是空间中的一块区域,该区域决定了摄像机可以看到的空间。视锥体由六个平面包围而成,这些平面被称为裁剪平面。视锥体有两种类型,并涉及到两种投影:透视投影和正交投影。

在视锥体的6块裁剪平面中,有两块裁剪平面比较特殊,分别为近裁剪平面和远裁剪平面。它们决定了摄像机可以看到的深度范围。 alt text

由于不同的视锥体需要不同的处理过程,而且对于透视投影的视锥体来说,想要判断一个顶点是否处于一个金字塔内部是比较麻烦的。因此可以通过一个投影矩阵把顶点转换到一个裁剪空间中。

投影矩阵有两个目的: 1.为投影做准备。真正的投影发生在之后的齐次除法过程。经投影矩阵的变换后,顶点的w分量会具有特殊的意义。 2.对x、y、z分量进行缩放。经投影矩阵缩放后,可以使用w分量作为一个范围值,若x、y、z分量都位于这个范围内,则说明该顶点位于裁剪空间内。

1.透视投影

Camera组件的Field of View属性可以改变视锥体竖直方向的张开角度,Clipping Planes中的Near和Far参数可以控制视锥体的近裁剪平面和远裁剪平面距离摄像机的远近,可以求出视锥体近裁剪平面和远裁剪平面的高度:

nearClipPlaneHeight=2NeartanFOV2nearClipPlaneHeight=2·Near·tan\frac{FOV}{2}

farClipPlaneHeight=2FartanFOV2farClipPlaneHeight=2·Far·tan\frac{FOV}{2}

alt text

横向信息可以通过摄像机的横纵比得到。在unity中,一个摄像机的横纵比由game视图的横纵比和Viewport Rect中的W和H属性共同决定。假设当前摄像机的横纵比为Aspect:

Aspect=nearClipPlaneWidthnearClipPlaneHeightAspect=\frac{nearClipPlaneWidth}{nearClipPlaneHeight}

Aspect=farClipPlaneWidthfarClipPlaneHeightAspect=\frac{farClipPlaneWidth}{farClipPlaneHeight}

可得到投影矩阵如下: alt text

顶点与投影矩阵相乘可以从观察空间变换到裁剪空间: alt text

从结果可以看出,投影矩阵其实就是对x、y、z分量进行了缩放(z分量还有平移),缩放的目的是方便裁剪。此时的w分量不再是1,而是原z分量取反的结果。现在就可以按如下不等式来判断一个变换后的顶点是否位于视锥体内。若顶点在视锥体内,那么它变换后的坐标必须满足:

wxw-w\leq x \leq w

wyw-w\leq y \leq w

wzw-w\leq z \leq w

任何不满足上述条件的图元都需要被剔除或裁剪。经过了投影矩阵变换后,视锥体的变化如下: alt text

裁剪矩阵改变了空间的旋向性:空间从右手坐标系变换到了左手坐标系。这时里摄像机越远,z值越大。

2.正交投影

与透视投影类似,但由于正交投影的视锥体是个长方体,因此计算上比透视投影更简单。 alt text

近裁剪平面和远裁剪平面的高度分别为:

nearClipPlaneHeight=2SizenearClipPlaneHeight=2·Size

farClipPlaneHeight=nearClipPlaneHeightfarClipPlaneHeight=nearClipPlaneHeight

假设摄像机的横纵比为Aspect,则:

nearClipPlaneWidth=AspectnearClipPlaneHeightnearClipPlaneWidth=Aspect·nearClipPlaneHeight

farClipPlaneWidth=nearClipPlaneWidthfarClipPlaneWidth=nearClipPlaneWidth

正交投影的裁剪矩阵如下: alt text

顶点与投影矩阵相乘结果如下: alt text 注:Mortho\mathbf{M}_{ortho} 的第三行第三列的分子应为2

使用正交投影的投影矩阵对顶点进行变换后,其w分量仍然为1。本质是因为投影矩阵最后一行的不同,透视投影的投影矩阵最后一行为[0010]\begin{bmatrix} 0 & 0 & -1 & 0 \end{bmatrix},而正交投影的投影矩阵的最后一行是[0001]\begin{bmatrix} 0 & 0 & 0 & 1 \end{bmatrix}。这是为了为齐次除法做准备。

判断变换后的顶点是否位于视锥体内使用的不等式和透视投影中的一样。

4.6.8 屏幕空间

经过投影矩阵的变换后,可以进行裁剪操作。完成所有的裁剪工作后,就可以进行真正的投影了,即把视锥体投影到屏幕空间。经过这一步变换,可以得到真正的像素位置,而不是虚拟的三维坐标。

屏幕空间是一个二维空间,所以必须把顶点从裁剪空间投影到屏幕空间中,生成对应的2D坐标。该过程可以分为两个步骤。

首先需要进行标准齐次除法,也被称为透视除法,就是用齐次坐标系的w分量去除以x、y、z分量。在OpenGL中,这一步得到的坐标叫做归一化的设备坐标(NDC)。 经过透视投影变换后的裁剪空间,经过齐次除法后会变换到一个立方体内。在OpenGL中,这个立方体的x、y、z分量的范围都是[-1,1]。但在DirectX中,z分量的范围是[0,1]。unity选择的是OpenGL的齐次裁剪空间。 alt text

对正交投影来说,它的裁剪空间已经是个立方体了,而且由于经过正交投影矩阵变换后的顶点的w分量是1,因此齐次除法并不会对顶点的x、y、z坐标产生影响。 alt text

经过齐次除法后,透视投影和正交投影的视锥体都变换到一个相同的立方体内。现在可以根据变换后的x和y坐标来映射输出窗口的对应像素坐标。 在Unity中,屏幕空间左下角的像素坐标是(0,0),右上角的像素坐标是(pixelWidth,pixelHeight)。由于当先x和y的取值范围都是[-1,1],因此这个映射的过程就是一个缩放的过程。

齐次除法和屏幕映射的过程可以使用以下公式来总结:

screenx=clipxpixelWidth2clipw+pixelWidth2screen_{x}=\frac{clip_{x}·pixelWidth}{2·clip_{w}}+\frac{pixelWidth}{2}

screeny=clipypixelHeight2clipw+pixelHeight2screen_{y}=\frac{clip_{y}·pixelHeight}{2·clip_{w}}+\frac{pixelHeight}{2}

哈吉米老师的推导过程如下: alt text

z分量通常会被用于深度缓冲。一个传统的方式是把clipzclipw\frac{clip_{z}}{clip_{w}} 的值直接存进深度缓冲中,但也不是必须的。

4.6.9 总结

顶点着色器的最基本的任务就是把顶点坐标从模型空间转换到裁剪空间中,对应了下图中的前三个顶点变换过程: alt text

在unity中,坐标系的旋向性也随着变换发生了改变: alt text

4.7 法线变换

一般来说,点和绝大部分方向矢量都可以用同一个4×4或3×3的变换矩阵MAB\mathbf{M}_{A→B}把其从坐标空间A变换到坐标空间B。但在法线变换时,如果使用同一个变换矩阵,则可能无法确保法线的垂直性。

由于切线是由两个顶点之间的差值计算得到的,所以可以直接用变换顶点的变换矩阵来变换切线。假设使用3×3的变换矩阵MAB\mathbf{M}_{A→B}来变换顶点(因为切线和法线都是方向矢量,不受平移影响,所以变换矩阵用3×3就可以),可由以下公式得到变换后的切线: TB=MABTA\mathbf{T}_{B}=\mathbf{M}_{A→B}\mathbf{T}_{A}

TA\mathbf{T}_{A}TB\mathbf{T}_{B} 分别表示在坐标空间A和B下的切线方向。若直接使用MAB\mathbf{M}_{A→B} 来变换法线,得到的新法线方向可能就不再和表面垂直了。 alt text

alt text alt text

5.2 最简单的顶点/片元着色器

5.2.1 顶点/片元着色器的基本结构

alt text

alt text

5.2.2 模型数据从哪来

alt text

在unity中,填充到POSITION, TANGENT, NORMAL这些语义中的数据是由使用该材质的Mesh Render组件提供的。在每帧调用Draw Call的时候,Mesh Render组件会把它负责渲染的模型数据发送给Unity Shader。

5.2.3 顶点着色器和片元着色器之间如何通信

实践中通常希望从顶点着色器输出一些数据,例如模型的法线、纹理坐标灯传递给片元着色器,这就涉及到了两者之间的通信。 alt text

在以上代码中,声明了一个新的结构体v2f。v2f用于在二者之间传递信息。在顶点着色器的输出结构中,必须包含一个变量,它的语义是SV_POSITION。否则,渲染器会无法得到裁剪空间中的顶点坐标,也就无法把顶点渲染到屏幕上。COLOR0语义中的数据可由用户自行定义,但一般都是存储颜色,例如逐顶点的漫反射颜色或逐顶点的高光反射颜色。类似的还要COLOR1等。

需要注意的是,顶点着色器是逐顶点调用的,而片元着色器是逐片元调用的。片元着色器的输入实际上是把顶点着色器的输出进行插值后得到的结果。

5.2.4 如何使用属性

材质提供了一个可以方便调节Unity Shader中参数的方式,通过这些参数,我们可以随时调整材质的效果。这些参数需要写在Properties语义块中。 alt text

ShaderLab中属性的类型和CG中变量的类型之间的匹配关系如下所示: alt text

有时在CG变量前会有一个uniform关键字,如

uniform fixed4 _Color;

uniform关键词是CG中修饰变量和参数的一种修饰词,仅仅用于提供一些关于该变量的初始值是如何指定和存储的相关信息。在Unity Shader中,uniform关键词是可以省略的。

5.3 Unity提供的内置文件和变量

5.3.1 内置的包含文件

包含文件(include file),是一种类似于C++头文件的文件。在unity中,它们的后缀是.cginc。在编写shader时,可以使用#include指令把这些文件包含进来,这样就可以使用Unity提供的一些有用的变量和帮助函数。

CGIncludes中主要的包含文件以及主要用处: alt text

UnityCG.cginc是最常接触的一个包含文件。该文件提供了很多结构体和函数,一些常见的结构体和包含的变量如下: alt text

上述常用结构体的声明如下: alt text

alt text

除结构体外,UnityCG.cginc也提供了一些常用的帮助函数: alt text

上述常用函数的定义如下: alt text

alt text

alt text

5.3.2 内置的变量

Unity还提供了用于访问时间、光照、雾效和环境光等目的的变量。这些内置变量大多位于UnityShaderVariables.cginc中,与光照有关的内置变量还会位于Lighting.cginc、AutoLight.cginc等文件中。

5.4 Unity提供的CG/HLSL语义

5.4.1 什么是语义

SV_POSITION、POSITION、COLOR0等,是CG/HLSL提供的语义(semantics)。语义实际上就是一个赋给Shader输入和输出的字符串,这个字符串表达了这个参数的含义。即语义可以让Shader知道从哪里读取数据,并把数据输出到哪里。

Unity为了方便对模型数据的传输,对一些语义进行了特别的含义规定。例如,在顶点着色器的输入结构体a2f用TEXCOORD0来描述texcoord,Unity会识别TEXCOORD0语义,以把模型的第一组纹理坐标填充到texcoord中。需要注意的是,即使语义的名称一样,如果出现的位置不同,含义也会不同。例如,TEXCOORD0既可以用于描述顶点着色器的输入结构体a2f,也可以用于描述输出结构体v2f。但在输入结构体a2f中,TEXCOORD0有特别的含义,即把模型的第一组纹理坐标存储在该变量中,而在输出结构体v2f中,TEXCOORD0修饰的变量含义就可以由我们来决定。

在DirectX10以后,有了一种新的语义类型,即系统数值语义(system-value semantics)。这类语义是以SV开头的,SV代表的含义就是系统数值。这些语义在渲染流水线中有特殊的含义。例如在之前的代码中,使用了SV_POSITION语义去修饰顶点着色器的输出变量pos,表示pos包含了可用于光栅化的变换后的顶点坐标(即齐次裁剪空间中的坐标)。用这些语义描述的变量是不可以随便赋值的,因为流水线需要使用它们来完成特定目的,例如渲染引擎会把用SV_POSITION修饰的变量经过光栅化后显示在屏幕上。有时可能会看到同一个变量在不同的shader中使用了不同的语义修饰。例如,一些shader会使用POSITION而非SV_POSITION来修饰顶点着色器的输出。SV_POSITION是DirectX 10中引入的系统数值语义,在绝大多数平台上,它和POSITION是等价的,但在某些平台如索尼PS4上必须使用SV_POSITION来修饰顶点着色器的输出,否则shader无法正常工作。因此,为了让Shader有更好的跨平台性,对于这些有特殊含义的变量最好用以SV开头的语义修饰。

5.4.2 Unity支持的语义

下表总结了从应用阶段传递模型数据给顶点着色器时Unity常用的语义。这些语义虽然没有以SV开头,但unity内部赋予了它们特殊的含义。 alt text

其中 TEXCOORDn\mathbf{TEXCOORD}_{n} 中n的数目是和Shader Model有关的,例如一般在Shader Model2(即Unity默认编译到的Shder Model版本)和Shader Model3中,n等于8;而在Shader Model4和Shader Model5中,n等于16。通常一个模型的纹理坐标组数一般不超过2,即我们往往只使用TEXCOORD0和TEXCOORD1。在Unity内置的数据结构体appdata_full中,它最多使用了6个坐标纹理组。

下表总结了从顶点着色器阶段到片元着色器阶段Unity支持的常用语义: alt text

除SV_POSITION有特别含义外,其他语义对变量的含义没有明确要求,即可以存储任意值到这些语义描述变量中。如果我们需要把一些自定义的数据从顶点着色器传递给片元着色器,一般选择TEXCOORD0等。

下表给出了Unity中支持的片元着色器的输出语义: alt text

5.4.3 如何定义复杂的变量类型

上述语义大多数用于描述标量或矢量类型的变量,如fixed2、float、float4、fixed4等。一个语义可以使用的寄存器只能处理4个浮点数。所以如果想定义矩阵类型,如float3×4、float4×4等变量就需要使用更多的空间。一种方法是,把这些变量拆分成多个变量,对于float4×4的矩阵,可以拆分成4个float4类型的变量,每个变量存储矩阵中的一行数据。

5.5 Debug

5.5.1 使用假彩色图像

假彩色图像指的是用假彩色技术生成的一种图像。与假彩色图像对应的是照片这种真彩色图像。一张假彩色图像可以用于可视化一些数据。

主要思想为:把需要调试的变量映射到[0,1]之间,把它们作为颜色输出到屏幕上,然后通过屏幕上显示的像素颜色来判断这个值是否正确。这种方法得到的调试信息很模糊,能够得到的信息很有限。

由于颜色的分量范围在[0,1],因此需要小心处理需要调试的变量的范围。若已知它的值域范围,可以先将它映射到[0,1]之间再输出。若不知道,就只能不断尝试。一个提示为,颜色分量中任何大于1的数值将会被设置为1,而任何小于0的数值会被设置为0.因此,可以尝试使用不同的映射,直到发现颜色发生了变化,这意味着得到了0~1的值。

如果要调试的数据是一个一维数据,那么可以选择一个单独的颜色分量进行输出,而把其他颜色分量设置为0.如果是多维数据,可以选择对其每一个分量单独调试,或者选择多个颜色分量进行输出。

alt text

5.6 渲染平台的差异

5.6.1 渲染纹理的坐标差异

在OpenGL中,(0,0)对应了屏幕的左下角,而在DirectX中,(0,0)对应了左上角。 alt text

我们不仅可以把渲染结果输出到屏幕上,还可以输出到不同的渲染目标中。这时就需要使用渲染纹理来保存这些渲染结果。

大多数情况下,渲染平台的差异并不会对我们造成影响。但当使用渲染到纹理技术,把屏幕图像渲染到一张渲染纹理中时,如果不采取任何措施的话,就会出现纹理反转的情况。不过Unity在背后为我们处理了这种反转问题——当在DirectX平台上使用渲染到纹理技术时,Unity会为我们反转屏幕图像纹理,以便在不同平台保证一致性。

有一种特殊情况下Unity不会帮助我们进行这个翻转操作,即开启了抗锯齿并在此时使用了渲染到纹理技术。在这种情况下,Unity首先渲染得到屏幕图像,再由硬件进行抗锯齿处理后,得到一张渲染纹理来供我们进行后续处理。此时,在DirectX下,我们得到的输入屏幕图像不会被Unity翻转,即此时对屏幕图像的采样坐标是需要符合DirectX规定的。如果我们的屏幕特效只需要处理一张渲染图像,仍然不需要在意纹理的翻转问题,因为我们在调用Graphics.Blit函数时,Unity已经对屏幕图像的采样坐标进行了处理,我们只需要按正常的采样过程处理屏幕图像即可。但如果我们需要同时处理多张渲染图像(开启了抗锯齿的前提下),例如需要同时处理屏幕图像和法线纹理,这些图像在竖直方向的朝向可能是不同的(仅限于DirectX平台)。这种情况下,我们就需要自己在顶点着色器中翻转某些渲染纹理(例如深度纹理或其他由脚本传递过来的纹理)的纵坐标,使之都符合DirectX平台的规则。例如:

#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
    uv.y = 1 - uv.y;
#endif

UNITY_UV_STARTS_AT_TOP用于判断当前平台是否是DirectX类平台,当在这样的平台下开启了抗锯齿后,主纹理的纹素大小在竖直方向上会变成负值,以便我们对主纹理进行正确的采样。因此可以通过判断_MainTex_TexelSize.y是否小于0来检验是否开启了抗锯齿。如果是,就需要对除主纹理外的其他纹理的采样坐标进行竖直方向上的翻转。

5.7 Shader整洁之道

5.7.1 float、half还是fixed

在CG/HLSL中,有3种精度的数值类型:float、half和fixed。这些精度决定了计算结果的数值范围。 alt text

以上的精度范围并不是绝对正确的,尤其在不同平台和GPU上,它们实际的精度可能和上面给出的范围不一致。通常来说: 1.大多数现代的桌面GPU会把所有计算都按最高的浮点精度,即float、half和fixed在这些平台上实际是等价的。这意味着我们在PC上很难看出因为half和fixed精度而带来的不同。 2.但在移动凭他的GPU上,它们的确会有不同的精度范围,并且不同精度的浮点值的运算速度也会有差异。因此,我们应确保在真正的移动平台上验证Shader。 3.fixed精度实际上只在一些较老的移动平台上有用,在大多数现代GPU上,他们内部把fixed和half当成同等精度来对待。

需要尽可能使用精度较低的类型,这样可以优化Shader的性能,这在移动平台上尤为重要。从大致的值域范围来看,我们可以用fixed类型来存储颜色和单位矢量,用half类型来存储更大范围的数据,最差的情况下再选择float。

5.7.3 避免不必要的计算

如果毫无节制地在Shader(尤其是片元着色器)种进行了大量计算,可能很快就会收到Unity的错误提示:

temporary register limit of 8 exceeded

Arithmetic instruction limit of 64 exceeded; 65 arithmetic instructions needed to compile program

出现这些错误信息意味着我们在Shader中进行了过多的运算,以致于需要的临时寄存器数目或指令数目超过了当前可支持的数目。不同的Shader Target、不同的着色器阶段,我们可使用的临时寄存器和指令数目都是不同的。

我们通常可以指定更高等级的Shader Target来消除这些错误。Unity目前(2019.4)支持的Shader Target如下:

#pragma target 2.0 适用于 Unity 支持的所有平台。DX9 着色器模型 2.0。 有限数量的算术和纹理指令;8 个插值器;没有顶点纹理采样;片元着色器中没有衍生指令;没有显式的 LOD 纹理采样。

#pragma target 2.5(默认值) 几乎与 3.0 目标相同(见下文),例外之处是仍然只有 8 个插值器,并且没有显式的 LOD 纹理采样。 在 Windows Phone 上编译为 DX11 功能级别 9.3。

#pragma target 3.0 DX9 着色器模型 3.0:衍生指令,纹理 LOD 采样,10 个插值器,允许更多的数学/纹理指令。 在 DX11 功能级别 9.x GPU(例如大多数 Windows Phone 设备)上不支持。 某些 OpenGL ES 2.0 设备可能无法完全支持,具体取决于存在的驱动程序扩展和使用的功能。

#pragma target 3.5(或 es3.0) OpenGL ES 3.0 功能(D3D 平台上的 DX10 SM4.0,只是没有几何着色器)。 在 DX11 9.x (WinPhone) 和 OpenGL ES 2.0 上不支持。 在 DX11+、OpenGL 3.2+、OpenGL ES 3+、Metal、Vulkan 和 PS4/XB1 游戏主机上支持。 着色器、纹理数组等中的本机整数运算。

#pragma target 4.0 DX11 着色器模型 4.0。 在 DX11 9.x (WinPhone)、OpenGL ES 2.0/3.0/3.1 和 Metal 上不支持。 在 DX11+、OpenGL 3.2+、OpenGL ES 3.1+AEP、Vulkan 和 PS4/XB1 游戏主机上支持。 具有几何着色器以及 es3.0 目标所具有的一切功能。

#pragma target 4.5(或 es3.1) OpenGL ES 3.1 功能(D3D 平台上的 DX11 SM5.0,只是没有曲面细分着色器)。 在早于 SM5.0 的 DX11、早于 4.3 的 OpenGL(即 Mac)和 OpenGL ES 2.0/3.0 上不支持。 在 DX11+ SM5.0、OpenGL 4.3+、OpenGL ES 3.1、Metal、Vulkan 和 PS4/XB1 游戏主机上支持。 有计算着色器、随机访问纹理写入、原子等。没有几何着色器和曲面细分着色器。

#pragma target 4.6(或 gl4.1) OpenGL 4.1 功能(D3D 平台上的 DX11 SM5.0,只是没有计算着色器)。这基本上是 Mac 支持的最高 OpenGL 级别。 在早于 SM5.0 的 DX11、早于 4.1 的 OpenGL、OpenGL ES 2.0/3.0/3.1 和 Metal 上不支持。 在 DX11+ SM5.0、OpenGL 4.1+、OpenGL ES 3.1+AEP、Vulkan、Metal(不含几何体)和 PS4/XB1 游戏主机上支持。

#pragma target 5.0 DX11 着色器模型 5.0。 在早于 SM5.0 的 DX11、早于 4.3 的 OpenGL(即 Mac)、OpenGL ES 2.0/3.0/3.1 和 Metal 上不支持。 在 DX11+ SM5.0、OpenGL 4.3+、OpenGL ES 3.1+AEP、Vulkan、Metal(不含几何体)和 PS4/XB1 游戏主机上支持。

请注意,所有 OpenGL 类平台(包括移动平台)都被视为“支持着色器模型 3.0”。WP8/WinRT 平台(DX11 功能级别 9.x)被视为仅支持着色器模型 2.5。

5.7.4 慎用分支和循环语句

最初GPU是不支持在顶点着色器和片元着色器中使用流程控制语句的。随着GPU的发展,我们现在已经可以使用if-else、for和while这种流程控制指令了。但这些指令在GPU上的实现和在CPU上有很大不同,在最坏的情况下,我们花在一个分支语句的时间相当于运行了所有分支语句的时间。因此不建议在Shader中使用流程控制语句,这样会降低GPU的并行处理操作。

如果在Shader中使用了大量的流程控制语句,那么这个Shader的性能可能会成倍下降。一个解决方法是,尽量把计算向流水线上端移动,例如把放在片元着色器的计算放到顶点着色器中,或者直接在CPU中进行预计算,再把结果传给Shader。

如果不可避免地要使用分支语句进行运算,建议如下:


Edit page
Share this post on:

Next Post
Adding new posts in AstroPaper theme