第4章 学习Shader所需的数学基础-学习Shader所需的数学基础-答疑解惑
注意:图片的来源基本来自作者冯乐乐的GitHub,感谢作者分享
https://github.com/candycat1992/Unity_Shaders_Book
3×3矩阵 还是 4×4矩阵:
对于线性变换(例如旋转和缩放),仅使用 3×3矩阵 就足够表示所有的变换了。
如果存在平移变换,就需要使用 4×4变换矩阵。
点坐标 在变换前需要把转换成 齐次坐标 的表示(把顶点的 w分量 设置为1)
方向矢量 直接使用 3×3矩阵 足够(平移变换对方向矢量没有影响)
Cg 中的矢量和矩阵类型:
在 Unity Shader 中使用Cg作为着色器编程语言,使用 Cg 变量类型进行数学运算
在 Cg 中矩阵类型是由 float3×3 float4×4 等关键词进行声明和定义
float3、float4 等类型的变量(取决于运算的种类和它们在运算中的位置):
1、可以当成一个矢量,
2、也可以当成一个 1×n 的行矩阵
3、或者当成一个 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 中访问一个矩阵中的元素时,也是按照行索引的:
//按 行优先 的方式初始化矩阵:
float3x3 M = float4x4(
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 在 脚本中提供的 Matrix4x4,这种矩阵类型 采用的是 列优先 的方式。
与 Cg 采用的 行优先 不同
Unity 中的 屏幕坐标:ComputeScreenPos / VPOS / WPOS
在 顶点/片元 着色器中,有两种方式获得 片元的屏幕坐标:
方式一:在 片元着色器 的输入中声明 VPOS 或者 WPOS 语义(VPOS 是 HLSL 中对屏幕坐标的语义,WPOS 是 Cg 中对屏幕坐标的语义,两者在 Unity Shader 中是等价的)
在 HLSL/Cg 中通过语义的方式来 定义 顶点/片元着色器 的默认输入,而不需要自己定义输入输出的数据结构。在 片元着色器 中这么写:
fixed4 frag( float4 sp : VPOS ) : SV_Target {
//用屏幕坐标除以屏幕分辨率 _ScreenParams.xy ,得到视口空间中的坐标
return fixed4( sp.xy / _ScreenParams.xy , 0.0 , 1.0 );
}
VPOS/WPOS 定义的输入是一个 float4 类型的变量:
VPOS/WPOS 的 xy 值代表了在屏幕空间中的像素坐标。
如果屏幕分辨率为 400x300。那么 x 的范围就是 [ 0.5 , 400.5 ] ,y 的范围就是 [ 0.5 , 300.5 ]
注意:这里的像素坐标不是整数值,因为 OpenGL 和 DirectX 10 以后的版本都认为 像素中心对应的是浮点值中的0.5。
在 Unity 中, VPOS/WPOS 的 z分量 范围 是 [ 0 , 1 ]
在摄像机的近裁剪平面处,z值为0,在远裁剪平面处,z值为1。
对于 w分量 ,需要考虑摄像机的投影的类型:
1、透视投影 的 w分量 的范围是:
Near 对应了在 Camera组件 中设置的近裁剪平面 距离摄像机的 近
Far 对应了在 Camera组件 中设置的远裁剪平面 距离摄像机的 远
2、正交投影 的 w分量 的值恒为1
w分量的值 是经过对投影矩阵变换后的 w分量 取倒数后 得到的
把屏幕空间 除以 屏幕分辨率 得到 视口空间 中的坐标
视口坐标:把屏幕坐标归一化,这样屏幕左下角就是 ( 0, 0 ),右上角就是 ( 1, 1 )
如果已知屏幕空间,就只需要把 xy值 除以屏幕分辨率 即可
方式二:使用 Unity 提供的 ComputeScreenPos 函数(在 UnityCG.cginc 被定义)
步骤1:在 顶点着色器 中将 ComputeScreenPos 的结果保存在输出结构体中
步骤2:在 片元着色器 中进行一个 齐次除法运算 后得到 视口空间下的坐标
这种方式手动实现了 屏幕映射 的过程,而且得到的坐标直接就是视口空间中的坐标
struct vertOut {
float4 pos : SV_POSITION;
float4 scrPos : TEXCOORD0;
};
vertOut vert( appdate_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 );
}
得到视口空间中的坐标。对裁剪空间下的坐标进行齐次除法,得到范围在 [ -1 , 1 ] 的 NDC,然后再将其映射到范围在 [ 0 , 1 ] 的视口空间下的坐标。
公式:
在 UnityCG.cginc 中找到 ComputeScreenPos 函数的定义:输入参数 pos 是经过 MVP 矩阵变换后在裁剪空间中的顶点坐标。UNITY_HALF_TEXEL_OFFSET 是 Unity 在某些 DirectX 平台上使用的宏。_ProjectionParams.x 在默认情况下是1(如果使用了一个翻转的投影矩阵的话就是 -1)
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;
}
上述代码实际输出:
这里的 xy 并不是真正的视口空间下的坐标。因此需要在片元着色器中进一步处理,除以裁剪坐标的 w分量。至此完成整个映射的过程。
因此,ComputeScreenPos 函数计算后,还需要在片元着色器除以它的 w分量,才能得到最后真正的视口空间中的位置。之所以让开发者自己除以 w分量,是因为 Unity 如果在顶点着色器中自己除以w分量的话会破坏插值的结果。
除法操作后就可以得到该片元在视口空间中的坐标,也就是一个 xy 范围在 [ 0 , 1 ] 之间的值
从 顶点着色器 到 片元着色器 的过程 实际会有一个插值的过程。
如果不在 顶点着色器 中进行这个除法,保留 x、y 和 w分量,那么它们在插值后再进行这个除法,那么得到的就是正确的。
但是如果直接在 顶点着色器 中进行这个除法,那么就需要对 除后的结果进行插值。这样得到的插值会不准确,原因是:不可以在投影空间中进行插值,因为投影空间不是一个线性空间,而插值往往是线性的。
zw值的计算:
在顶点着色器中,直接把 裁剪空间的 zw值存进了输出结构体中,因此 片元着色器 输入的就是这些插值后的裁剪空间中的 zw值。这意味着:
如果使用的是 透视投影,那么 z值 的范围是 [ -Near, Far ]
如果使用的是 正交投影,那么 z值 的范围是 [ -1, 1 ],而 w值 恒为 1