NoiseFloor 发表于 2022-12-19 17:02

Unity 和 Unreal 渲染中的坐标变换和跨平台兼容

一、HLSL和GLSL语法的不同

数学中的矩阵表示

常用的数学方式中,我们说矩阵的行和列时,是行在前,列在后。比如说2x3矩阵,指的是一个2行3列的矩阵。我们说M_{23}\时,指的是第二行,第三列处的元素的值。
考虑一个矩阵和一个向量的乘法。在数学中,我们习惯使用列向量,矩阵和向量相乘时,习惯使用矩阵左乘。v\是一个列向量,矩阵和向量相乘表示为:
\begin{gather}v = \begin{pmatrix} v_{x}\\ v_{y}\\ v_z\end{pmatrix}= \begin{pmatrix} v_x,v_{y,}v_z\end{pmatrix}^T\\ v' = M v\end {gather}\\
这个乘法和下面的矩阵右乘等价:
v'^T = v^{T}M^T\\
在 HLSL 和 GLSL 中,当一个矩阵和向量相乘时,如果向量放在右边,会将该向量视为列向量,如果向量在左边,会将该向量视为行向量,这些都是自动完成的。
在 DirectX/HLSL 中,我们习惯使用矩阵右乘。在 OpenGL/GLSL 中,我们习惯使用矩阵左乘。
在实际写代码时,使用左乘和右乘都是可以的,左乘更加符合数学习惯,右乘更能能体现从左往右依次实现变换的顺序。
在 Unity 中,基本都是遵循 OpenGL 的风格,因此尽管是使用HLSL作为Shader语法,但是依然是使用矩阵左乘。
Row-major 和 Column-major

计算机中的矩阵,有主序的概念。主序有两个含义,一个是索引主序,一个是存储主序。索引主序决定我们访问矩阵中元素的索引方式,存储主序决定矩阵在内存中存储的格式。无论是哪种主序,都不会影响矩阵本身,矩阵本身是确定的。
设现在有如下这个矩阵M:
M = \begin{pmatrix} 1 & 2 & 3 \\ 4 & 5 & 6\end{pmatrix}\\
索引主序,指的是我们访问矩阵中某个位置的索引方式和创建矩阵时的写法。
对于上面的矩阵M,使用行主序/Row-major索引时,各个元素索引如下:
\begin{pmatrix} M_{00} & M_{01} & M_{02}\\ M_{10}& M_{11}& M_{12} \end{pmatrix}\\
当我们使用列主序/Column-major时,矩阵M的索引方式为:
\begin{pmatrix} M_{00} & M_{10} & M_{20}\\ M_{01}& M_{11}& M_{21} \end{pmatrix}\\
存储主序,指的是矩阵在内存中排列的方式,如上的矩阵M,使用行主序存储时,内存中排列方式如下:
(1, 2, 3, 4, 5, 6)\\
使用列主序存储时,内存中排列方式如下:
(1,4,2,5,3,6)\\
在C/C++中,可以使用二维数组来表示矩阵。由于C/C++中并没有原生的矩阵类型,因此这个二维数组,既可以视为索引主序和存储主序均为行主序的矩阵,也可以视为二者均为列主序的矩阵。
在HLSL和GLSL等自带矩阵类型的语言中,矩阵的索引主序是固定的。在HLSL中,存储主序是可以自由设置的。
不同编程环境下,矩阵的主序不同,这里列出表格如下:
API矩阵索引主序矩阵存储主序矩阵乘法方向glm列主序列主序左乘GLSL列主序列主序左乘DirectXMath行主序行主序右乘HLSL行主序列主序右乘Unity行主序列主序左乘Unity 中的HLSL行主序列主序左乘Unreal行主序行主序右乘Unreal中的HLSL行主序行主序右乘其中 HLSL 和 GLSL 的矩阵乘法方向是可选的,HLSL 中的矩阵存储主序也是可以设置的。
上面表格中,有两个需要特别注意的地方:

[*]只有在 OpenGL/glm/glsl 中,才会使用索引列主序。
[*]HLSL 的默认存储主序和 DirectXMath 中的存储主序方式不同。使用 DirectXMath 作为数学库时,向默认矩阵主序的 shader 中传入矩阵时,需要先进行转置。
在后面的部分,我们按照Unity中的风格来描述矩阵及其运算,和数学中常用的描述方式相同。
二、Unity 在 OpenGL 模式下有哪些空间

包含:

[*]LocalSpace
[*]WorldSpace
[*]CameraSpace,ViewSpace
[*]TangentSpace
[*]ClipSpace
[*]NDCSpace,Normalized Device Coordinates
[*]ScreenSpace,ViewportSpace


除了 CameraSpace 和 TangentSpace 是右手系外,其余均为左手系
在 Unity 中,各个空间方向和 UV 方向等,都是按照 OpenGL 的标准来的。
三、从 Local Space 到 World Space

一个 transform 由如下部分组成


平移/Translation

平移\textbf t\:
{p' = p + t}\\
旋转/Rotation

绕XYZ轴旋转某个角度\theta\时,通过矩阵表示变换为:
\begin{gather}{R_{x}}(\phi) = \begin{pmatrix} 1&0&0 \\0&\cos\phi& -\sin\phi \\0&\sin\phi&\cos\phi \end{pmatrix} \\    {R_{y}}(\phi) = \begin{pmatrix} \cos\phi&0&\sin\phi \\0&1& 0 \\ -\sin\phi&0&\cos\phi \end{pmatrix} \\ {R_{z}}(\phi) = \begin{pmatrix} \cos\phi&\sin\phi&0 \\ \sin\phi&\cos\phi&0 \\0&1& 0\end{pmatrix} \\ {p' = Rp} \end{gather}\\\
平移和旋转合并起来,称为正交变换(orthogonal mapping),正交变换保持两个点之间的距离不变。正交变换写成:{p' = Rp + v}\\
使用轴方向和原点位置确定变换矩阵

正交变换可以分解为旋转和平移。从 Local Space 到 World Space,如果变换一个向量,只需要应用旋转,如果变换一个点,就需要应用旋转和平移。
已知 Local Space 中的的三个轴的方向在 World Space 下表示为\alpha_{x},\alpha_{y},\alpha_{z}\。则从 Local Space 到 World Space 下的旋转变换可以确定。对于一个向量p\应用旋转:
p' = \begin{pmatrix} \alpha_{x},\alpha_{y},\alpha_{z} \end{pmatrix} p\\
Local Space 的原点位置在 World Space 下表示为v\。则可以确定整个正交变换,对于一个点p\应用旋转和平移:
p' = \begin{pmatrix} \alpha_{x},\alpha_{y},\alpha_{z} \end{pmatrix} p + v\\
这种构造变换矩阵的方式非常简单实用。
欧拉角表示旋转

前面讲过了绕XYZ旋转的变换矩阵,要绕过原点的任意一条直线旋转,可以将此旋转分解成,分别绕XYZ轴旋转的复合。这也是我们在Tranform面板中看到的设置项。
因为按照不同的顺序,绕着XYZ轴旋转后,得到的结果不同。因此我们需要选择一个固定的顺序。欧拉角表示旋转时,我们通常是按照yaw/head(航向角)、pitch(俯仰角)、roll(滚转角) 的顺序依次旋转,这个顺序是最符合人对旋转角度的感知的,也是表示飞行器角度的方式。
万向锁会在中间的旋转角为 90 度时发生,由于 pitch 角很少会出现90度的情况,将 pitch 角放在中间,可以尽量避免万向锁的问题。游戏中的相机系统通常会使用欧拉角来实现,pitch 角不会允许到达 90 度或者 -90 度,也就是不允许我们镜头朝向垂直向上或者垂直向下方向。



在Unity中,yaw、pitch、roll对应的坐标轴方向分别是Y、X、Z。因此旋转的顺序是Y、X、Z轴。
我们这里所讲的欧拉角定义的旋转,叫做动态/内旋欧拉角,即所有旋转都是沿着物体的局部坐标系轴,前面的旋转,会影响后面旋转的轴的变化。
另外一种观点是按照世界坐标轴对物体进行旋转,叫做静态/外旋欧拉角。在此观点下,所有的旋转都是绕着世界坐标系的轴进行。
静态欧拉角和动态欧拉角描述的旋转是等价的,只是观察的角度不同。一个按照YXZ轴顺序旋转的动态欧拉角,等价于按照相反顺序ZXY轴顺序旋转且角度相同的静态欧拉角。
在Unity中,欧拉角表示的旋转矩阵,为三个旋转矩阵的乘积。由于我们的旋转矩阵,表示的是绕世界坐标轴的旋转。因此这里的旋转矩阵,根据静态欧拉角的顺序给出:
{R = R_{y}R_{x}R_{z}}\\
使用欧拉角表示旋转,有以下的缺点:
1、欧拉角数值依赖绕XYZ轴旋转的顺序;
2、插值时,如果按照欧拉角插值,物体的旋转轨迹不是球面上最短路径;
3、在某些情况下,会出现数值不稳定;
4、出现万向锁问题。
四元数表示旋转

使用欧拉角表示旋转,会出现以上我们说的问题,因此我们还可以使用四元数/Quaternion来表示一个旋转。
四元数的原理比较复杂,这里不会深度分析。我们只需要知道大致的计算方式,以及知道四元数和旋转矩阵能够互相转换即可。
四元数表示四维空间中的一个点,表示形式为q = w+xi+yj+zk\,两个四元数相乘时,会按照类似多项式乘法来将各项分别相乘并并相加,ijk的乘法规则为:
\begin{gather} ij = k\\ jk = i \\ki = j\\ ijk = -1\end{gather}\\
注意四元数的乘法是不满足交换律的。
现在要绕向量{v} = (v_{x}, v_{y}, v_{z})^T\旋转\theta\角,可得到需要用到的变换的四元数为:
{q = \cos \theta+ \sin \theta (\frac{v_{x}i + v_{y}j + v_{z}k}{||v||})}\\
这个用于表示旋转的四元数,满足w^{2} + x^{2}+ y^{2}+z^{2} = 1\,是一个单位四元数。
现在要对三维空间中任意一点p\应用四元数旋转,计算方式如下:
1、将该点写成纯四元数形式,即w分量为0:
{p = p_{x}i+p_{y}j+p_{z}k}\\
2、找到用于旋转的四元数q\的共轭四元数
{q^{-1} = \cos \theta- \sin \theta (\frac{v_{x}i + v_{y}j + v_{z}k}{||v||})}\\
3、进行如下的运算,得到一个新的四元数,新的四元数必然仍是纯四元数
p' = q pq^{-1}\\
4、将得到的新的纯四元数,w分量去掉,即可得到旋转后的点坐标。
四元数的重要应用之一就是实现平滑的插值,按照欧拉角进行插值,坐标轴旋转的路径,很可能不是沿着最短路径。因此在做插值动画时,往往使用四元数来进行插值。
常见的几种四元数插值方式如下,后面的方法开销会更大,结果也会更加精确:
1、Lerp,直接按照分量插值,得到的很可能不是单位四元数。
2、Nlerp,按照分量线性插值后,再进行归一化。旋转角度很大时,会导致角度旋转速度不稳定。
3、Slerp,在球面上的插值,先算出两个四元数之间的夹角,再根据夹角插值,效果最好的做法,开销也很大。


四元数和旋转矩阵之间,可以通过固定的公式互相转换。
Transform中的缩放

讲完了平移和旋转,我们再来看下Transform中的缩放设置。
缩放矩阵比较简单,表示为:
S = \begin{pmatrix}s_{x} & 0& 0\\0 & s_{y} & 0\\0 &0& s_{z} \end{pmatrix}\\
在数学中,缩放、旋转和平移,统称为仿射变换,仿射变换保持两条直线的平行性质不变。
有了三种变换的变换矩阵,下一步就是将它们组合起来。为了将不同种类的变换统一,以及和后面的透视矩阵保持统一,我们将所有的变换都写成4x4矩阵的形式:
\begin{gather}   T = \begin{pmatrix} 1 & 0 & 0 & t_{x}\\0& 1 & 0 & t_{y}\\0 & 0 & 1 & t_{z}\\ 0 & 0 & 0 & 1 \\ \end{pmatrix}\\ R = \begin{pmatrix} R & 0 \\ 0 & 1 \end{pmatrix}\\S = \begin{pmatrix}s_{x} & 0& 0 & 0\\0 &s_{y} & 0 & 0\\0 &0& s_{z} & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \end{gather}\\
Transform的变换,可以视为依次应用缩放、旋转和平移。注意这个顺序是固定的,由此我们得到了变换矩阵/Transform Matrix。
\begin{gather} M = TRS \\\begin{pmatrix} p' \\ 1 \end{pmatrix} = M \begin{pmatrix} p \\ 1\end{pmatrix} \end{gather}\\
注意我们的变换矩阵的第四行是固定的(0, 0, 0, 1)\,因此矩阵有效的部分是3x4的,某些情况下我们会将Transform Matrix保存成3x4矩阵,来节省内存占用:
Transform的叠加

在游戏场景中,各个节点直接,按照层级/Hierarchical的方式组织,一个父节点下挂着很多子节点,父节点的tranform属性,会影响子节点的transform属性。
因此,要得到一个节点相对世界坐标的transform,需要沿着当前的节点,向上遍历所有父节点,将每个父节点的transform矩阵得到,并依次相乘,直到根节点位置,就得到了从节点的LocalSpace到WorldSpace的变换矩阵。
在Unity中,通过 transform.localToWorldMatrix可以直接得到从Local Space到World Space的矩阵,也叫做ModelMatrix,通过 transform.worldToLocalMatrix得到从World Space到Local Space的矩阵。二者互为逆矩阵。
在Unity Shader中,这两个矩阵叫做 unity_ObjectToWorld/UNITY_MATRIX_I_M 和 unity_ObjectToWorld/UNITY_MATRIX_M。
在进行渲染时,每个物体的 localToWorldMatrix都是不同的,需要单独设置,这类属性是 PerDraw的。而后面我们将要讲到的 WorldSpace到CameraSpace的矩阵,对于每个物体都是相同的,这类属性是 Global的。
我们这里的Transform Matrix,实现的是对点的坐标变换。若想要对一个向量/方向进行变换,只需要去掉 transofm中平移部分,也就是取4x4矩阵左上的3x3矩阵,进行变换即可:
float3 TransformObjectToWorld(float3 positionOS)
{
        return mul(GetObjectToWorldMatrix(), float4(positionOS, 1.0)).xyz;
}

float3 TransformObjectToWorldDir(float3 dirOS)
{
        return mul((float3x3)GetObjectToWorldMatrix(), dirOS);
}
Transform中对非均匀缩放的特殊处理

我们使用 localToWorldMatrix这个矩阵来对LocalSpace中的点和向量做变换。然而当我们物体的缩放值时三个分量不相等时,形成了非均匀缩放。在非均匀缩放下,如果仍然使用这个矩阵来变换法线,会造成错误的效果。


如果想要得到正确的法线,需要对矩阵进行一些处理。
设一个 localToWorldMatrix的矩阵为M\,我们将这个矩阵的左上角3x3部分记为M_{33}\,前面我们讲过,变换一个方向是不需要做平移的。
对于需要变换的法线n\,可以得到和此法线垂直的一个平面,在此平面上任取一个方向v\,可得n \cdot v = 0\,即n^T v=0\。平面上的方向经过变换到,得到一个新的方向v' = M_{33} v\,若要使变换后的法线n'\方向正确,就需要n' \cdot v' = 0\:
\begin{gather} n'^{T} v' = 0 \\ n'^{T} M_{33}v = 0 \\ n^{T}v = 0\\ n'^{T} M_{33}= n^{T}\\ n'^{T} = n^{T} M_{33}^{-1} \\ n'^{T} = n^{T} M_{33}^{-1} \\ n' = M_{33}^{-T}n \end{gather}\\
也就是说,变换法线,需要用到原始变换矩阵的转置的逆。在缩放均匀时,矩阵的转置的逆等于它自己,因此不会出现问题。
Unity中代码如下:
float3 TransformObjectToWorldNormal(float3 normalOS, bool doNormalize = true)
{
// 均匀缩放时,视为一个方向进行变换
#ifdef UNITY_ASSUME_UNIFORM_SCALING
    return TransformObjectToWorldDir(normalOS, doNormalize);
#else
    // 注意这里,取WorldToObject,相当于取逆矩阵。然后是将矩阵和向量位置交换,相当是对矩阵做了转置。
    float3 normalWS = mul(normalOS, (float3x3)GetWorldToObjectMatrix());
    // 变换后法线长度可能不为1,因此要做归一化处理
    if (doNormalize)
      return SafeNormalize(normalWS);

    return normalWS;
#endif
}三、从 Tangent Space 到 World Space

Tangent Space 和 Normal 的计算

在物体模型中,我们需要使用 Normalmap 来补充表面的细节。Normalmap 通常定义在 Tangent Space 中。
Tangent Space 是这样一个空间:X 轴沿着模型 UV 的 U 方向,Y 轴大致沿着模型 UV 的 V 方向,Z 轴大致沿着模型的法线向外。
这里不详细描述Tangent Space的生成过程,我们只需要知道,TangentSpace是一个正交空间(即XYZ轴两两垂直且单位向量长度为1),且 Normalmap 是定义在 Tangent Space 中的。


我们在 Normalmap 中保存 normal 中的方向向量时,需要将分量值 * 0.5 + 0.5,这样将 -11的值范围映射到 01。normal 的方向总是在 Z 轴附近的,因此保存在 normalmap 中的值,总是在(0.5, 0.5, 1)附近,normalmap 看起来总是呈蓝紫色。


Tangent Space 中的 XYZ 轴,分别叫做 tangent、bitangent、和 normal。tangent 和 normal(注意这个normal和前面说的不是同一个) 的方向,会直接保存在模型的顶点数据中,bitangent 方向,从 tangent 和 normal 的叉乘计算得出。
得到三个向量方向后,就可以作为一个 Tangent Space 的基,形成 TBN 矩阵,表示从 Tangent Space 到 World Space 的变换矩阵。由于在这里只需要变换方向而不要变换位置,所以只需要使用3X3矩阵。
float3 bitangent = cross(input.normalWS.xyz, input.tangentWS.xyz);

half3x3 tangentToWorld = half3x3(input.tangentWS.xyz, bitangent.xyz, input.normalWS.xyz);

half3 normalTS = SAMPLE_TEXTURE2D(bumpMap, sampler_bumpMap, uv).xy * 2 - 1;

// 调换矩阵和向量位置,实现矩阵转置的技巧
half3 normalWS = mul(normalTS,tangentToWorld, );模型缩放为负数时的特殊处理

当物体的transform的缩放值中,有奇数个负值时,物体的 Local Space 和 Tangent Space 手系会发生改变。Unity会自动进行一些特殊处理,来保证最终结果正确。
第一个处理是将模型的 CullFace 取反,因为模型翻转后,正反面会翻转;
第二个处理是求解TBN矩阵时的特殊处理,在求解TBN矩阵时,我们是使用 normal 和 tangent 的叉乘来计算bitangent,手系翻转后,就需要将叉乘的结果取反,才能得到正确的 bitangent。
下图是一个将模型沿着 tangent 方向翻转后,Tangent space的变化示例。


三、从 World Space 到 Camera Space

Camera Space 到 World Space 的变换矩阵

Unity中的Camera Space,使用朝向屏幕外作为正Z轴方向。
观察 Unity 中的各个空间,发现 Camera Space 是非常特殊的,这是一个历史遗留问题,是 Unity当初在 Camera Space 中使用了 OpenGL 中的常见的右手坐标系,而且在后来没有修正这个问题。


现在我们来看下如何推导从 World Space 到 Camera Space 的矩阵。考虑到从 World Space 到 Camera Space 是一个正交变换,我们可以先使用Camera Space的基在World Space中的坐标,求出 Camera Space到World Space 的矩阵,再求其逆矩阵。
设摄影机的Transform,在World Space中,其XYZ轴的方向分别为{\alpha_{x}, \alpha_{y}, \alpha_{z}}\,世界坐标位置为{t}\。则 Camera Space 的基在 World Space的方向表示为{\alpha_{x},\alpha_{y}, -\alpha_{z}}\,坐标原点为{t}\。
因此从 CameraSpace 到 WorldSpace的变换矩阵为:
\begin{gather} M = TR = \begin{pmatrix} I & t\\ 0 & 1 \\ \end{pmatrix} \begin{pmatrix} \alpha_{x} & \alpha_{y} & -\alpha_{z} & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \\= \begin{pmatrix} \alpha_{x} & \alpha_{y} & -\alpha_{z} & t \\ 0 & 0 & 0 & 1 \end{pmatrix}\end{gather}\\
这里,我们就得到了 camera.cameraToWorldMatrix 矩阵。
对此矩阵求逆,即可得到 camera.worldToCameraMatrix矩阵,也就是ViewMatrix。
\begin{gather} M^{-1} = R^{-1}T^{-1} =\begin{pmatrix} \alpha_{x}^{T}& 0 \\ \alpha_{y}^{T} & 0 \\-\alpha_{z}^{T} & 0 \\ 0 & 1 \end{pmatrix} \begin{pmatrix} I & -t\\ 0 & 1\end{pmatrix} \\= \begin{pmatrix} \alpha_{x}^{T}& -\alpha_{x}^{T}t \\ \alpha_{y}^{T} & -\alpha_{y}^{T}t \\ -\alpha_{z}^{T} & \alpha_{z}^{T}t \\ 0 & 1 \end{pmatrix}\end{gather}\\
Unity Shader中,两个矩阵的名字为 unity_MatrixInvV/UNITY_MATRIX_I_V和 UNITY_MATRIX_V/unity_MatrixV。
四、Clip Space 和 NDC Space

在进行最终的渲染之前,我们需要将 Camera Space 中的物体投影/Projection到 NDC Space 中,NDC Space是一个轴对齐的XYZ范围均为-1~1的方形区域,只有在该区域内的物体会执行后续的光栅化,PixelShader等步骤。
Unity中有两种摄影机,分别使用两种不同模式的投影方式。
正交投影/Orthographic Projection

正交相机/Orthographic Camera(注意这里的正交,和正交变换中的正交,不是一个东西)将一个轴对齐长方形区域内的物体,投影到NDC Space中。这个投影,是可以使用仿射变换来实现的,投影矩阵的推导也比较简单,可以直接视为平移和缩放的复合,也可以使用线性方程算出。


\begin{gather} P_{o}= \begin{pmatrix} \frac{2}{r-l} & 0 & 0 & -\frac{r+l}{r-l} \\ 0 & \frac{2}{t-b} & 0 & -\frac{t+b}{t-b} \\ 0 & 0 & \frac{-2}{f-n} & -\frac{f+n}{f-n} \\ 0 & 0 & 0 & 1\end{pmatrix}\end{gather}\\
大部分情况下,我们有r=-l,t=-b\,得:
\begin{gather} P_{o} =\begin{pmatrix} \frac{1}{r} & 0 & 0 & 0 \\ 0 & \frac{1}{t} & 0 & 0 \\ 0 & 0 & \frac{-2}{f-n} & -\frac{f+n}{f-n} \\ 0 & 0 & 0 & 1\end{pmatrix}\end{gather}\\
透视投影/Perspective Projection

透视相机的透视投影,是更加符合摄影机和人眼感知的相机,是一般相机的实现方式。透视投影,会将一个四棱台区域,投影到 NDC Space 的方形区域内。
前面我们说过,仿射变换会将平行的直线映射成平行直线。在这里,仿射变换就无法实现透视投影了,因此我们要引入齐次坐标/homogeneous coordinates,并且使用射影变换/projective transformation。
坐标为(x, y, z, w)^{T}\的齐次坐标,对应点为(\frac{x}{w},\frac{y}{w},\frac{z}{w})\的普通坐标,当w=0\时,表示一个沿着(x, y, z)\的无穷远点。
为了对透视除法前后的坐标进行区分,我们将透视除法之前的空间,叫做 Clip Space,使用齐次坐标。透视除法之后坐标所在的空间,叫做 NDC Space,使用普通坐标。
透视投影,将一个四棱台,投影到 NDC 长方体中:


透视投影矩阵的推导过程如下:
设 Camera Space 中一个点(x_{e}, y_{e},z_{e})\,和摄影机连线,连线和 near plane 相交于点(x_{p}, y_{p},z_{p})\。
(x_{e}, y_{e},z_{e})\在 Clip Space 中的坐标为(x_{c}, y_{c},z_{c},w_{c})\,在 NDC Space 中的坐标为(x_{n}, y_{n},z_{n})\。
已知(x_{e}, y_{e},z_{e})\和(x_{p}, y_{p},z_{p})\,位于同一条从摄影机出发的射线上,因此在 NDC Space 下 xy 坐标值是相同的,因此(x_{p}, y_{p},z_{p})\投影到 NDC Space 中的坐标为(x_{n}, y_{n},-1)\。
已知我们要计算的投影矩阵得到这样的结果:
\begin{pmatrix}x_{c} \\ y_{c}\\ z_{c}\\ w_{c}\end{pmatrix} = M_{projection} \begin{pmatrix}x_{e} \\ y_{e}\\ z_{e}\\ w_{e}\end{pmatrix} \begin{pmatrix}x_{n} \\ y_{n}\\ z_{n}\end{pmatrix}= \begin{pmatrix}x_{c} / w_{c}\\ y_{c}/ w_{c}\\ z_{c} / w_{c}\end{pmatrix}\\
根据顶视图和侧视图,以及三角形相似原理,可得


(x_{p},y_{p},z_{p})=(\frac{nx_e}{-z_{e}}, \frac{ny_e}{-z_{e}}, n)\\
已知这两个点在 NDC Space 下 xy 坐标相同,观察这两点的坐标,我们发现,如果将其 xy 坐标,除以 -z 坐标,得到的值是相同的。因此我们可以说 Camera Space 中的(x, y, z)\点,投影到 Clip Space 中为(?,?,?,-z)\。这样就确定了投影矩阵的最后一行:
\begin{pmatrix}x_{c} \\ y_{c}\\ z_{c}\\ w_{c}\end{pmatrix} = \begin{pmatrix} ? & ? & ? & ? \\ ? & ? & ? & ? \\ ? & ? & ? & ? \\0 & 0 & -1 & 0\end{pmatrix}\begin{pmatrix}x_{e} \\ y_{e}\\ z_{e}\\ 1 \end{pmatrix}\\
这样,w_{c} = -z_{e}\。
已知四棱台近平面处的上下左右点,分别构成 \Rightarrow [-1, 1]\space \space \Rightarrow [-1, 1]\的线性映射,由此可算出映射关系为:
\begin{gather} x_{n}= \frac{2 x_p}{r-l}-\frac{r+l}{r-l} = \frac{2 nx_e}{-z_e(r-l)}-\frac{r+l}{r-l} \\y_{n}= \frac{2 y_p}{t-b}-\frac{t+b}{t-b} = \frac{2 ny_e}{-z_{e}(t-b)}-\frac{t+b}{t-b}\end{gather}\\
NDC Space 下的坐标是除以 Clip Space 下的 w 值得到的,且(x_{e}, y_{e},z_{e})\在Clip Space 下的 w 值为-z_{e}\,因此可将上式中的\frac{1}{-z_{e}}\提出来,得到在 Clip Space 下的坐标。
\begin{gather} x_{n}= (\frac{2 n}{r-l}x_{e}+\frac{r+l}{r-l}z_{e})/-z_e\\y_{n}= (\frac{2 n}{t-b}y_e+\frac{t+b}{t-b}z_e)/-z_e\end{gather}\\
这样一来,我们确定了投影矩阵的第一二行:
\begin{pmatrix}x_{c} \\ y_{c}\\ z_{c}\\ w_{c}\end{pmatrix} = \begin{pmatrix} \frac{2 n}{r-l} & 0 & \frac{r+l}{r-l} & 0 \\ 0 & \frac{2 n}{t-b} & \frac{t+b}{t-b} & 0 \\ ? & ? & ? & ? \\0 & 0 & -1 & 0\end{pmatrix}\begin{pmatrix}x_{e} \\ y_{e}\\ z_{e}\\ 1\end{pmatrix}\\
接下来就是计算这个矩阵的第三行,第三行决定了我们输出到 Clip Space 中坐标的 z 值。显然这个 z 值和 Camera Space 中的 xy 坐标值是没有关系的,因此前两个元素是0。设第三个元素为 A,第四个元素为 B。可得
\begin{pmatrix}x_{c} \\ y_{c}\\ z_{c}\\ w_{c}\end{pmatrix} = \begin{pmatrix} \frac{2 n}{r-l} & 0 & \frac{r+l}{r-l} & 0 \\ 0 & \frac{2 n}{t-b} & \frac{t+b}{t-b} & 0 \\ 0 & 0 & A & B \\ 0 & 0 & -1 & 0\end{pmatrix}\begin{pmatrix}x_{e} \\ y_{e}\\ z_{e}\\ 1\end{pmatrix} z_{n}= \frac{z_{c}}{ w_{c}}= \frac{Az_{c}+ B}{-z_{e}}\\
已知 near plane 和 far plane 平面上点,映射到 NDC Space 下的点 z 值分别是 -1 和 1,即\Rightarrow [-1, 1]\,我们可以列出方程得:
\begin{gather}\begin{cases} \frac{-An+B}{n} = -1\\ \frac{-Af+b}{f}= 1 \end{cases} \rightarrow \begin{cases} {-An+B} = -n\\ {-Af+b}= f \end{cases}\\ A = -\frac{f+n}{f-n} \space, B = -\frac{2fn}{f-n} \end{gather}\\
到此我们得出整个 Projection Matrix 为:
\begin{pmatrix}x_{c} \\ y_{c}\\ z_{c}\\ w_{c}\end{pmatrix} = \begin{pmatrix} \frac{2 n}{r-l} & 0 & \frac{r+l}{r-l} & 0 \\ 0 & \frac{2 n}{t-b} & \frac{t+b}{t-b} & 0 \\ 0 & 0 & -\frac{f+n}{f-n}& -\frac{2fn}{f-n} \\ 0 & 0 & -1 & 0\end{pmatrix}\begin{pmatrix}x_{e} \\ y_{e}\\ z_{e}\\ 1\end{pmatrix}\\
当r=-l,t=-b\,得
P_p = \begin{pmatrix} \frac{n}{r} & 0 & 0 & 0 \\ 0 & \frac{ n}{t} & 0 & 0 \\ 0 & 0 & -\frac{f+n}{f-n}& -\frac{2fn}{f-n} \\ 0 & 0 & -1 & 0\end{pmatrix}\\
在 Vertex Shader 中,我们直接输出 Clip Space 的值。显卡硬件会在 Clip Space 下进行插值,然后在每个像素点处,进行齐次除法。
六、Screen Space

Screen Space

Screen Space/Viewport Space 表示屏幕空间的坐标,xy值为当前的像素坐标,z值表示保存到 Depth Buffer 中的值,是我们整个坐标变换的最后一步,也是光栅化发生的地方。
从 NDC Space 到 Screen Space 这步是由硬件自动完成的,我们不需要进行任何处理。
在 OpenGL 中,Screen Space 从左下角开始,左下角的像素坐标是(0,0)。
NDC Space 中的 z 值范围是 -1~1,而 Depth Buffer 常常使用 NORM 格式的 TextureBuffer,因此 Screen Space 中的 z 值的范围是 0~1,因此需要如下的转换:
\begin{align} z_{ss} = z_{ndc} * 0.5 - 0.5 \\ z_{ndc} = z_{ss} * 2 - 1 \end{align}\\
OpenGL 下的 gl_FragCoord

在 Pixel Shader 中访问 Screen Space 坐标的方式,是比较特殊的。
我们先来看下原生的 OpenGL 中,访问 Screen Space 坐标的方式:
在 Vertex Shader 中,我们使用 gl_Position 来输出 NDC Space 的齐次坐标。
在 Fragement Shader 中,使用 gl_FragCoord,就可以获取 Screen Space 的坐标。其中 xy 坐标,就是 Screen Space 的像素坐标,加上 0.5,而 z 值,也等于 gl_FragDepth 的值,就是我们即将保存到 Depth Buffer 中的深度值。w 分量的值,比较特殊,等于 NDC Space 齐次坐标 w 值的倒数。
HLSL 下的 SV_POSITION

尽管 Unity 遵循 OpenGL 的规范,但是却使用 HLSL 作为 shader 语言,因此在 Shader 里使用的也是 HLSL 的标准,HLSL 下,和 GLSL 中访问 Screen Space 坐标的方式有很大不同。在 Unity 中,仍然需要使用 HLSL 的规范。
在 Vertex Shader 中,使用 SV_POSITION 作为 NDC Space 的齐次坐标输出,很多人想当然地以为,在 Pixel Shader 中,可以使用 SV_POSITION 来访问 NDC Space 的坐标。 但是 HLSL 中有特殊的定义,在 Pixel Shader 中,SV_POSITION 输出的是 Screen Space 的坐标。
比如这样一段代码:
struct Varyings
{
    float2 uv                     : TEXCOORD0;   
    float3 positionWS               : TEXCOORD1;
    half3 normalWS               : TEXCOORD2;
    half4 tangentWS                : TEXCOORD3;
    float4 positionCS               : SV_POSITION;
}

Varyings LitPassVertex(Attributes input)
{
        Varyings output = (Varyings)0;
        ...
        output.positionCS = vertexInput.positionCS;
}

half4 LitPassFragment(Varyings input) : SV_Target
{
// NDC Space 坐标
        input.positionCS
}在 DirectX10 及以后,Pixel Shader 中 SV_POSITION 的输出坐标的语义,xy 坐标表示 Screen Space 的像素坐标 +0.5。SV_POSITION.z 的值是等于 SV_DEPTH的值的。SV_DEPTH 的值,也就是 Screen Space 的 z 值,即将写入到DepthBuffer中的深度值。这个和 gl_FragCoord 意义是相同的,不同的地方在于,SV_POSITION.w 的值,是 NDC Space 中插值后的 w 值,而在 OpenGL 中,gl_FragCoord.w 表示 NDC Space 中的 1/w。在 Unity 中,会遵循 SV_POSITION 的 w 值的含义,在 OpenGL平台下,SV_POSITION.w 会自动翻译成 1.0/gl_FragCoord.w;
七、Unity在类 DirectX 平台下的兼容

图形 API 的分类

在 Unity 中,图形 API 被分为两类。一个是 OpenGL 平台,包括 OpenGL、OpenGLES,另外一个是 DirectX-like,包括除了 OpenGL 的其他平台,包含 DirectX、Vulkan 等。
DirectX 和 OpenGL 明显的不同处有这样几样:
(1)NDC Space ,在 DirectX 和 OpenGL 下,坐标手系相同,方向也相同,但是 DirectX 的 z 值范围是 0~1,而 OpenGL 中的 z 值范围是 -1~1。注意,在 Screen Space 下,DirectX 和 OpenGL 中的 z 值范围都是 0~1。也就是说,在 DirectX 下是不需要将 z 值进行额外转换的。
(2)DirectX 下,贴图的 UV 方向,以及 Screen Space 的方向,都是从左上角开始。OpenGL 下,贴图的 UV 方向,以及 Screen Space 方向,是从左下角开始。请注意,DirectX 中,UV 的上下方向,和 NDC Space 方向是相反的。
在 Metal 中,各个坐标系方向及范围和 DirectX 中的相同。
在 Vulkan 中,UV 方向和 DirectX 中相同,但是 NDC Space 的上下方向 和 DirectX 以及 OpenGL 中都不相同。不过 Vulkan 中有这样一个扩展 VK_KHR_Maintenance1,在 Vulkan 1.1 中成为正式标准。这个扩展允许我们将 viewport 的 y 值设置为负值,这样来实现 NDC Space 的上下翻转,这样就和 DirectX 下保持统一了。
这样,Metal、Vulcan、DirectX 下的 NDC Space 方向和 UV 方向是相同的,和 OpenGL 下不同,形成两个分支。
为了保证 shader 代码,能尽可能地照顾到 DirectX 和 OpenGL 下的兼容性,Unity 在类 DirectX 下渲染时,会将整个 FrameBuffer 进行上下翻转,并且将需要用到的贴图上下翻转。这样在两种平台下,我们就可以使用尽量相同的 shader 代码。
Unity 中使用 OpenGL 风格,但是仍然使用 HLSL 作为 shader 语言。Unity 会将 HLSL 的 shader 代码,使用 HLSLCC 翻译成其他语言。
Reverse-Z

在 DirectX-like 平台下时,还会有一个额外的处理,就是使用 reverse -z。
根据前面我们推导的透视投影的变换矩阵,可以求出在 OpenGL 中,从 View Space 到 NDC Space 中的 z 值映射公式为:
z' = \frac{f+n}{f-n} + \frac{2fn}{(f-n)z}\\
观察这个公式,我们会发现这是一个倒数函数,且当fn\的值越小,函数曲线就越陡峭。
现在考虑 near 和 far 值对 Depth Buffer 的影响。当 far 值变小时,函数更加陡峭,但是我们需要表示的深度范围也变小了,精度基本是不变的。而当 near 值改变时,就会极大地影响远处物体的深度值的精度。当 near 值过小时,就会出现深度值精度不足,导致 Z-fighting 问题。


在 DirectX-like 平台下,NDC Space 的 z 值范围是 0~1,因此我们就可以使用 Reverse-Z,来提高远处物体的深度值精度。
Depth Buffer 中的深度值,往往是使用浮点数格式来保存,而浮点数格式的特点,就是在值越接近0,精度值越高。Reverse-Z 将得到的深度值,进行取反,这样近处物体的深度值在 1 附近,远处物体的深度值在 0 附近,这样就非常巧妙地实现了精度的提升。


Reverse-Z 的实现方式非常简单,只需要在计算透视矩阵时,将 near 和 far 的值进行对调。这样做之后我们将近处和远处的映射关系也会翻转,因此我们的深度测试模式也需要取反。
而在 OpenGL 下,NDC Space 下 z 值范围是 -1~1,Reverse-Z 的精度提升效果不大。因此在 OpenGL 下就不会使用 Reverse-Z。
写代码时的平台判断

Unity 中提供了一些 API,来帮助我们实现跨平台特性:
通过上面的分析可知,不同图形 API 下,需要做特殊处理的,其实就是从 Camera Space 到 NDC Space 这一步的 Projection Matrix 不一样,其他地方基本是一样的。
使用 SystemInfo.usesReversedZBuffer,可以用于判断当前是否在 DirectX-like 平台下运行。
在 Unity 的 C# 代码中,通过 camera.projectionMatrix,可以获取到 OpenGL 模式下的 Projection Matrix。当我们使用 Unity 中的 API cmd.SetViewProjectionMatrices 时,传入的是这个 OpenGL 模式下的 Projection Matrix。当我们要自己计算一个 Projection Matrix 时,需要使用 GL.GetGPUProjectionMatrix(camera.projectionMatrix, true) 来获取相应平台下的 Projection Matrix。
在 Unity Shader 中,我们可以使用宏 #if UNITY_UV_STARTS_AT_TOP 以及 #if UNITY_USE_REVERSED_Z 来判断当前是否在 DirectX-like 平台下运行。注意不要使用 #if defined 。
八、 实践应用举例

应用举例一:访问 SSAO 贴图

SSAO 是游戏渲染中的常用功能,我们在 SSAO 的 Pass 中,算出一张 AO 图,在计算环境光照时,需要将计算出来的环境光照乘以 AO 图中采样得到的 AO 值。
如果我们的 AO 图,和我们的 Frame Buffer 大小是相同的,则可以通过如下的方式来访问 AO 图上的 AO 系数。
我们在 Pixel Shader 中这样来访问 AO 贴图:
half4 Fragement(Varyings varyings) : SV_Target
{
        float4 positonCS = varying.positionCS;
        float AO = _AOTex.Load(int3(positionCS.xy, 0));
        ...
}当 AO 图的大小和 Frame Buffer 的大小不一致时,就需要使用 UV 来访问 AO 图:
half4 Fragement(Varyings varyings) : SV_Target
{
        float4 positonCS = varying.positionCS;
        float2 uv = positionCS.xy * _ScreenSize.zw;
        float AO = _AOTex.SampleLevel(sample_LinearClamp, uv, 0);
        ...
}应用举例二:从 Depth Buffer 反算世界坐标

在后处理中,通过 Depth Buffer,来反算出世界坐标,是非常常见的一种应用。计算出来的世界坐标,可以用于后续的后处理。
我们使用一个全屏 PASS,来输入全屏幕的 UV,这里的 UV,相当于将整个 Frame Buffer 作为一张贴图来访问时的 UV,也就是 Screen Space 的 UV 坐标。
half4 Fragement(Varyings varyings) : SV_Target
{
        float deviceDepth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, input.uv.xy).r;
#if !UNITY_REVERSED_Z
        // 在 OPENGL下,从 NDC Space到Screen Space的z值转换
        deviceDepth = deviceDepth * 2.0 - 1.0;
#endif

        // 从 Screen Space 下的UV坐标,转到 NDC Space 的坐标
        float4 positionCS = float4(varyings.uv * 2.0f - 1.0f, deviceDepth, 1.0f);

#if UNITY_UV_STARTS_AT_TOP
        // 在 DirectX-like下,NDC Space和Screen Space 的上下方向不同,因此需要翻转一下
    positionCS.y = -positionCS.y;
#endif

        float4 hpositionWS = mul(invViewProjMatrix, positionCS);
        // 同样是需要进行一次齐次除法
        float3 positionWS =hpositionWS.xyz / hpositionWS.w;
        ...九、Unreal引擎中的跨平台兼容

在 Unreal Engine 中,按照 DirectX 作为默认标准,使用 DirectX 中常用的右乘,实现兼容的方式,和 Unity 中的方式大致相同。
不同的地方在于,在 Unity 中,在 DirectX-like 平台下 Frame Buffer 是上下翻转的,而在 Unreal 中,则是在 OpenGL 平台下上下翻转。
此外,由于在 Unreal 下和 Unity 下 UV 方向上下不同,导致 bitangent 方向相反。因此在两个平台下,Normalmap 是无法互相通用的,DDC 软件中会有相关的导出设置。已经导出的 Normalmap,只需要将 G 通道中颜色取反,就能应用于另外一个平台上。
参考


[*]^https://en.wikipedia.org/wiki/Row-_and_column-major_order
[*]^https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/dx-graphics-hlsl-per-component-math?redirectedfrom=MSDN
[*]^https://en.wikipedia.org/wiki/Euler_angles#Tait%E2%80%93Bryan_angles
[*]^https://en.wikipedia.org/wiki/Gimbal_lock
[*]^https://en.wikipedia.org/wiki/Quaternion
[*]^https://github.com/Krasjet/quaternion
[*]^http://www.songho.ca/opengl/gl_projectionmatrix.html
[*]^https://developer.nvidia.com/content/depth-precision-visualized

FeastSC 发表于 2022-12-19 17:09

写的很好!

DomDomm 发表于 2022-12-19 17:15

太全了
页: [1]
查看完整版本: Unity 和 Unreal 渲染中的坐标变换和跨平台兼容