Unity Shader入门精要第四章:Unity Shader 的内置变量(数学篇)

Unity系列文章目录

前言

使用Unity 写Shader 的一个好处在于,它提供了很多内置的参数,这使得我们不再需要自己
手动计算一些值。本节将给出Unity 内置的用于空间变换和摄像机以及屏幕参数的内置变量。这
些内置变量可以在UnityShaderVariables.cginc 文件中找到定义和说明。

一、4.8.1 变换矩阵

首先是用于坐标空间变换的矩阵。表4.2 给出了Unity 5.2 版本提供的所有内置变换矩阵。下
面所有的矩阵都是float4x4 类型的。
读者:为什么在我的Unity 中,有些变量不存在呢?
我们:可能是由于你使用的Unity 版本和本书使用的版本不同。在写本书时,我们使用的Unity
版本是最新的5.2。而在4.x 版本中,一些内置变量可能会与之不同。

在这里插入图片描述
表4.2 给出了这些矩阵的常用用法。但读者可以根据需求来达到不同的目的,例如我们可以
提取坐标空间的坐标轴,方法可回顾4.6.2 节。
其中有一个矩阵比较特殊,即UNITY_MATRIX_T_MV 矩阵。很多对数学不了解的读者不理
解这个矩阵有什么用处。如果读者认真看过矩阵一节的知识,应该还会记得一种非常吸引人的矩
阵类型—正交矩阵。对于正交矩阵来说, 它的逆矩阵就是转置矩阵。因此, 如果
UNITY_MATRIX_MV 是一个正交矩阵的话,那么UNITY_MATRIX_T_MV 就是它的逆矩阵,也
就是说,我们可以使用UNITY_MATRIX_T_MV 把顶点和方向矢量从观察空间变换到模型空间。
那么问题是,UNITY_MATRIX_MV 什么时候是一个正交矩阵呢?读者可以从4.5 节找到答案。
总结一下,如果我们只考虑旋转、平移和缩放这3 种变换的话,如果一个模型的变换只包括旋转,
那么UNITY_MATRIX_MV 就是一个正交矩阵。这个条件似乎有些苛刻,我们可以把条件再放宽
一些,如果只包括旋转和统一缩放(假设缩放系数是k),那么UNITY_MATRIX_MV 就几乎是一
个正交矩阵了。为什么是几乎呢?因为统一缩放可能会导致每一行(或每一列)的矢量长度不为
1,而是k,这不符合正交矩阵的特性,但我们可以通过除以这个统一缩放系数,来把它变成正交
矩阵。在这种情况下,UNITY_MATRIX_MV 的逆矩阵就是
1
k
UNITY_MATRIX_T_MV。而且,
如果我们只是对方向矢量进行变换的话,条件可以放得更宽,即不用考虑有没有平移变换,因为
平移对方向矢量没有影响。因此,我们可以截取UNITY_MATRIX_T_MV 的前3 行前3 列来把方
向矢量从观察空间变换到模型空间(前提是只存在旋转变换和统一缩放)。对于方向矢量,我们可
以在使用前对它们进行归一化处理,来消除统一缩放的影响。
还有一个矩阵需要说明一下,那就是UNITY_MATRIX_IT_MV 矩阵。我们在4.7 节已经知道,
法线的变换需要使用原变换矩阵的逆转置矩阵。因此UNITY_MATRIX_IT_MV 可以把法线从模型
空间变换到观察空间。但只要我们做一点手脚,它也可以用于直接得到UNITY_MATRIX_MV 的
逆矩阵—我们只需要对它进行转置就可以了。因此,为了把顶点或方向矢量从观察空间变换到
模型空间,我们可以使用类似下面的代码:
// 方法一:使用transpose 函数对UNITY_MATRIX_IT_MV 进行转置,
// 得到UNITY_MATRIX_MV 的逆矩阵,然后进行列矩阵乘法,
// 把观察空间中的点或方向矢量变换到模型空间中
float4 modelPos = mul(transpose(UNITY_MATRIX_IT_MV), viewPos);
// 方法二:不直接使用转置函数transpose,而是交换mul 参数的位置,使用行矩阵乘法
// 本质和方法一是完全一样的
float4 modelPos = mul(viewPos, UNITY_MATRIX_IT_MV);
关于mul 函数参数位置导致的不同,在4.9.2 节中我们会继续讲到。

二、4.8.2 摄像机和屏幕参数

Unity 提供了一些内置变量来让我们访问当前正在渲染的摄像机的参数信息。这些参数对应
了摄像机上的Camera 组件中的属性值。表4.3 给出了Unity 5.2 版本提供的这些变量。
在这里插入图片描述
在这里插入图片描述

4.9 答疑解惑

恭喜你已经几乎完成了本书所有的数学训练!我们希望你能从上面的内容中得到很多收获和
启发。但是,我们也相信在读完上面的内容后你可能对某些概念仍然感到迷惑。不要担心,答疑
解惑一节就可以帮你跨过一些障碍。
4.9.1 使用3×3 还是4×4 的变换矩阵
对于线性变换(例如旋转和缩放)来说,仅使用3×3 的矩阵就足够表示所有的变换了。但如果存在
平移变换,我们就需要使用4×4 的矩阵。因此,在对顶点的变换中,我们通常使用4×4 的变换矩阵。当
然,在变换前我们需要把点坐标转换成齐次坐标的表示,即把顶点的w 分量设为1。而在对方向矢量的
变换中,我们通常使用3×3 的矩阵就足够了,这是因为平移变换对方向矢量是没有影响的。
4.9.2 Cg 中的矢量和矩阵类型
我们通常在Unity Shader 中使用Cg 作为着色器编程语言。在Cg 中变量类型有很多种,但在
本节我们是想解释如何使用这些类型进行数学运算。因此,我们只以float 家族的变量来做说明。
在Cg 中,矩阵类型是由float3x3、float4x4 等关键词进行声明和定义的。而对于float3、float4
等类型的变量,我们既可以把它当成一个矢量,也可以把它当成是一个1×n 的行矩阵或者一个n×1
的列矩阵。这取决于运算的种类和它们在运算中的位置。例如,当我们进行点积操作时,两个操
作数就被当成矢量类型,如下:
float4 a = float4(1.0, 2.0, 3.0, 4.0);
float4 b = float4(1.0, 2.0, 3.0, 4.0);
// 对两个矢量进行点积操作
float result = dot(a, b);
但在进行矩阵乘法时,参数的位置将决定是按列矩阵还是行矩阵进行乘法。在Cg 中,矩阵
乘法是通过mul 函数实现的。例如:
float4 v = float4(1.0, 2.0, 3.0, 4.0);
float4x4 M = float4x4(1.0, 0.0, 0.0, 0.0,
0.0, 2.0, 0.0, 0.0,
0.0, 0.0, 3.0, 0.0,
0.0, 0.0, 0.0, 4.0);
// 把v 当成列矩阵和矩阵M 进行右乘
float4 column_mul_result = mul(M, v);
// 把v 当成行矩阵和矩阵M 进行左乘
float4 row_mul_result = mul(v, M);
// 注意:column_mul_result 不等于row_mul_result,而是:
// mul(M,v) == mul(v, tranpose(M))
// mul(v,M) == mul(tranpose(M), v)

因此,参数的位置会直接影响结果值。通常在变换顶点时,我们都是使用右乘的方式来按列
矩阵进行乘法。这是因为,Unity 提供的内置矩阵(如UNITY_MATRIX_MVP 等)都是按列存储
的。但有时,我们也会使用左乘的方式,这是因为可以省去对矩阵转置的操作。
需要注意的一点是,Cg 对矩阵类型中元素的初始化和访问顺序。在Cg 中,对float4x4 等类
型的变量是按行优先的方式进行填充的。什么意思呢?我们知道,想要填充一个矩阵需要给定一
串数字,例如,如果需要声明一个3×4 的矩阵,我们需要提供12 个数字。那么,这串数字是一
行一行地填充矩阵还是一列一列地填充矩阵呢?这两种方式得到的矩阵是不同的。例如,我们使
用(1, 2, 3, 4, 5, 6, 7, 8, 9)去填充一个3×3 的矩阵,如果是按照行优先的方式,得到的矩阵是:

在这里插入图片描述
Cg 使用的是行优先的方法,即是一行一行地填充矩阵的。因此,如果读者需要自己定义一个
矩阵时(例如,自己构建用于空间变换的矩阵),就要注意这里的初始化方式。
类似地,当我们在Cg 中访问一个矩阵中的元素时,也是按行来索引的。例如:
// 按行优先的方式初始化矩阵M
float3x3 M = float3x3(1.0, 2.0, 3.0,
4.0, 5.0, 6.0,
7.0, 8.0, 9.0);
// 得到M 的第一行,即(1.0, 2.0, 3.0)
float3 row = M[0];
// 得到M 的第2 行第1 列的元素,即4.0
float ele = M[1][0];
之所以Unity Shader 中的矩阵类型满足上述规则,是因为使用的是Cg 语言。换句话说,上面
的特性都是Cg 的规定。
如果读者熟悉Unity 的API,可能知道Unity 在脚本中提供了一种矩阵类型—Matrix4x4。
脚本中的这个矩阵类型则是采用列优先的方式。这与Unity Shader 中的规定不一样,希望读者在
遇到时不会感到困惑。
4.9.3 Unity 中的屏幕坐标:ComputeScreenPos/VPOS/WPOS
我们在4.6.8 节中讲了屏幕空间的转换细节。在写Shader 的过程中,我们有时候希望能够获
得片元在屏幕上的像素位置。
在顶点/片元着色器中,有两种方式来获得片元的屏幕坐标。
一种是在片元着色器的输入中声明VPOS 或WPOS 语义(关于什么是语义,可参见5.4 节)。
VPOS 是HLSL 中对屏幕坐标的语义,而WPOS 是Cg 中对屏幕坐标的语义。两者在Unity Shader
中是等价的。我们可以在HLSL/Cg 中通过语义的方式来定义顶点/片元着色器的默认输入,而不
需要自己定义输入输出的数据结构。这里的内容有一些超前,因为我们还没有具体讲解顶点/片元
着色器的写法,读者在这里可以只关注VPOS 和WPOS 的语义。使用这种方法,可以在片元着色
器中这样写:
fixed4 frag(float4 sp : VPOS) : SV_Target {
// 用屏幕坐标除以屏幕分辨率_ScreenParams.xy,得到视口空间中的坐标
return fixed4(sp.xy/_ScreenParams.xy,0.0,1.0);
}
得到的效果如图4.49 所示。
VPOS/WPOS 语义定义的输入是一个float4 类型的
变量。我们已经知道它的xy 值代表了在屏幕空间中的
像素坐标。如果屏幕分辨率为400 x 300,那么x 的范
围就是[0.5,400.5],y 的范围是[0.5,300.5]。注意,这里
的像素坐标并不是整数值,这是因为OpenGL 和
DirectX 10 以后的版本认为像素中心对应的是浮点值
中的0.5。那么,它的zw 分量是什么呢?在Unity 中,
VPOS/WPOS 的z 分量范围是[0,1],在摄像机的近裁剪
平面处,z 值为0,在远裁剪平面处,z 值为1。对于w
分量,我们需要考虑摄像机的投影类型。如果使用的是透视投影,那么w 分量的范围是
1 , 1
Near Far
 
 
,Near 和Far 对应了在Camera 组件中设置的近裁剪平面和远裁剪平面距离摄像机的
远近;如果使用的是正交投影,那么w 分量的值恒为1。这些值是通过对经过投影矩阵变换后的
w 分量取倒数后得到的。在代码的最后,我们把屏幕空间除以屏幕分辨率来得到视口空间
(viewport space)中的坐标。视口坐标很简单,就是把屏幕坐标归一化,这样屏幕左下角就是(0,
0),右上角就是(1, 1)。如果已知屏幕坐标的话,我们只需要把xy 值除以屏幕分辨率即可。
另一种方式是通过Unity 提供的ComputeScreenPos 函数。这个函数在UnityCG.cginc 里被定
义。通常的用法需要两个步骤,首先在顶点着色器中将ComputeScreenPos 的结果保存在输出结构
体中,然后在片元着色器中进行一个齐次除法运算后得到视口空间下的坐标。例如:
struct vertOut {
float4 pos:SV_POSITION;
float4 scrPos : TEXCOORD0;
};
vertOut vert(appdata_base v) {
vertOut o;
o.pos = mul (UNITY_MATRIX_MVP, v.vertex);
// 第一步:把ComputeScreenPos 的结果保存到scrPos 中
o.scrPos = ComputeScreenPos(o.pos);
return o;
}
fixed4 frag(vertOut i) : SV_Target {
// 第二步:用scrPos.xy 除以scrPos.w 得到视口空间中的坐标
float2 wcoord = (i.scrPos.xy/i.scrPos.w);
return fixed4(wcoord,0.0,1.0);
}
上面代码的实现效果和图4.49 中的一样。我们现在来看一下这种方式的实现细节。这种方法
实际上是手动实现了屏幕映射的过程,而且它得到的坐标直接就是视口空间中的坐标。我们在
4.6.8 节中已经看到了如何将裁剪坐标空间中的点映射到屏幕坐标中。据此,我们可以得到视口空
间中的坐标,公式如下:
在这里插入图片描述
在这里插入图片描述
上面公式的思想就是,首先对裁剪空间下的坐标进行齐次除法,得到范围在[−1, 1]的NDC,
然后再将其映射到范围在[0, 1]的视口空间下的坐标。那么ComputeScreenPos 究竟是如何做到的
呢?我们可以在UnityCG.cginc 文件中找到ComputeScreenPos 函数的定义。如下:
inline float4 ComputeScreenPos (float4 pos) {
float4 o = pos * 0.5f;
#if defined(UNITY_HALF_TEXEL_OFFSET)
o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w * _ScreenParams.zw;
#else
o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;
#endif
o.zw = pos.zw;
return o;
}
ComputeScreenPos 的输入参数pos 是经过MVP 矩阵变换后在裁剪空间中的顶点坐标。
UNITY_HALF_TEXEL_OFFSET 是Unity 在某些DirectX 平台上使用的宏,在这里我们可以忽略
它。这样,我们可以只关注#else 的部分。_ProjectionParams.x 在默认情况下是1(如果我们使用
了一个翻转的投影矩阵的话就是−1,但这种情况很少见)。那么上述代码的过程实际是输出了:
在这里插入图片描述
可以看出,这里的xy 并不是真正的视口空间下的坐标。因此,我们在片元着色器中再进行一
步处理,即除以裁剪坐标的w 分量。至此,完成整个映射的过程。因此,虽然ComputeScreenPos
的函数名字似乎意味着会直接得到屏幕空间中的位置,但并不是这样的,我们仍需在片元着色器
中除以它的w 分量来得到真正的视口空间中的位置。那么, 为什么Unity 不直接在
ComputeScreenPos 中为我们进行除以w分量的这个步骤呢?为什么还需要我们来进行这个除法?
这是因为,如果Unity 在顶点着色器中这么做的话,就会破坏插值的结果。我们知道,从顶点着
色器到片元着色器的过程实际会有一个插值的过程(如果你忘了的话,可以回顾2.3.6 小节)。如
果不在顶点着色器中进行这个除法,保留x、y 和w 分量,那么它们在插值后再进行这个除法,
得到的x
w
和y
w
就是正确的(我们可以认为是除法抵消了插值的影响)。但如果我们直接在顶点着
色器中进行这个除法,那么就需要对x
w
和y
w
直接进行插值,这样得到的插值结果就会不准确。原
因是,我们不可以在投影空间中进行插值,因为这并不是一个线性空间,而插值往往是线性的。
经过除法操作后,我们就可以得到该片元在视口空间中的坐标了,也就是一个xy 范围都在[0,
1]之间的值。那么它的zw 值是什么呢?可以看出,我们在顶点着色器中直接把裁剪空间的zw 值
存进了输出结构体中,因此片元着色器输入的就是这些插值后的裁剪空间中的zw 值。这意味着,
如果使用的是透视投影,那么z 值的范围是[-Near, Far],w 值的范围是[Near, Far];如果使用的是
正交投影,那么z 值范围是[−1, 1],而w 值恒为1。

扩展阅读

计算机图形学使用的数学还有很多,本书仅涵盖了其中非常小的一部分。如果读者想要深入
学习这些知识的话,书籍[1][2]是非常好的图形学数学学习资料,读者可以在那里找到更多类型的变
换及其数学表示。关于如何从左手坐标系转换到右手坐标系同时又保持视觉效果一样,可以参考
资料[3]。关于如何得到线性的深度值可以参考资料[4]。
[1] Fletcher Dunn, Ian Parberry. 3D Math Primer for Graphics and Game Development (2nd
Edition). November 2, 2011 by A K Peters/CRC Press。
[2] Eric Lengyel. Mathematics for 3D game programming and computer graphics (3rd Edition).
2011 by Charles River Media。
[3] David Eberly. Conversion of Left-Handed Coordinates to Right-Handed Coordinates。
[4] http://www.humus.name/temp/Linearize%20depth.txt。

参考

Unity Shader入门精要
作者:冯乐乐

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值