1. 为什么需要转换顶点法线空间?
在3D图形编程中,顶点法线(Vertex Normal)是描述模型表面朝向的重要属性。当我们把一个模型从建模软件导入到游戏引擎时,法线数据最初是存储在模型空间(Model Space)中的。但在实际渲染过程中,光照计算通常需要在世界空间(World Space)中进行,这就产生了空间转换的需求。
举个生活中的例子:假设你手里拿着一个玩具汽车模型,车头朝向你。此时模型空间下的法线方向是相对于这个局部坐标系的。当你把汽车放在房间的某个角落(相当于应用了模型的世界变换),法线方向需要相应调整才能正确反映模型在世界中的真实朝向。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 空间转换的数学基础
2.1 模型空间与世界空间的关系
模型到世界的变换通常由平移(Translation)、旋转(Rotation)和缩放(Scale)组成,可以用一个4x4的变换矩阵表示。对于顶点位置,我们直接用这个矩阵进行变换:
code复制worldPosition = modelMatrix * modelPosition
但法线的变换有所不同。因为法线本质上是方向向量(没有位置信息),且需要保持与表面垂直的特性,所以不能简单应用相同的变换矩阵。
2.2 法线变换的特殊性
法线变换需要保持以下性质:
- 方向正确性:变换后的法线仍应与变换后的表面垂直
- 归一化:变换后的法线长度应为1(单位向量)
旋转操作会自然地保持这些性质,但缩放操作会导致问题。想象一下:如果模型在X轴缩放2倍,表面被拉伸,法线若同样缩放2倍,将不再与表面垂直。
3. 正确的法线变换方法
3.1 逆转置矩阵的原理
数学上,保持法线垂直性的正确做法是使用模型矩阵的逆转置矩阵(Inverse Transpose Matrix)。推导过程如下:
设表面切线向量为T,法线为N,根据垂直性有:
code复制T · N = 0
应用变换矩阵M后:
code复制M*T · ? = 0
我们需要找到一个变换矩阵G,使得:
code复制M*T · G*N = 0
将其表示为矩阵乘法:
code复制(M*T)^T * (G*N) = 0
=> T^T * M^T * G * N = 0
由于原始有T^T * N = 0,因此需要:
code复制M^T * G = I
=> G = (M^T)^-1 = (M^-1)^T
这就是逆转置矩阵的由来。
3.2 Unity中的实际应用
在Unity Shader中,我们通常使用unity_WorldToObject矩阵(世界到模型的变换矩阵的逆转置)来处理法线变换。核心代码如下:
c复制// 在顶点着色器中
v2f vert(appdata v) {
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
// 法线变换
o.normal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
return o;
}
这里需要注意几点:
- 使用mul函数的参数顺序:因为unity_WorldToObject是逆转置矩阵,所以法线应作为左乘向量
- 必须进行归一化(normalize):因为数值计算可能导致长度不为1
- 转换为float3x3:因为我们只需要旋转和缩放部分,忽略平移
4. 常见问题与优化方案
4.1 性能考量
逆转置矩阵的计算开销较大,在性能敏感的场景可以考虑以下优化:
- 如果确定模型只有均匀缩放(uniform scale),可以直接用模型矩阵的左上3x3部分,然后归一化:
c复制o.normal = normalize(mul((float3x3)unity_ObjectToWorld, v.normal));
- 对于静态物体,可以预计算逆转置矩阵,避免实时计算
4.2 法线贴图的情况
当使用法线贴图时,切线空间法线需要额外处理:
- 先构建切线空间到世界空间的变换矩阵(TBN矩阵)
- 然后用法线贴图采样结果转换到世界空间
典型代码:
c复制float3 normalTS = UnpackNormal(tex2D(_BumpMap, uv));
float3x3 TBN = float3x3(
normalize(mul(unity_ObjectToWorld, v.tangent.xyz)),
normalize(mul(unity_ObjectToWorld, v.bitangent.xyz)),
normalize(mul(v.normal, (float3x3)unity_WorldToObject))
);
o.normal = mul(TBN, normalTS);
4.3 非均匀缩放的陷阱
当模型存在非均匀缩放时,特别需要注意:
- 直接使用模型矩阵变换法线会导致错误的光照效果
- 逆转置矩阵是唯一正确的解决方案
- 在Shader中忘记归一化会导致光照强度异常
5. 实际案例:Toon Shader中的法线处理
以流行的YSA Toon Anime Shader为例,其法线处理有几个特点:
- 强调风格化表现,有时会故意修改法线方向增强卡通效果
- 仍然遵循基本的空间转换原则
- 增加了边缘光等特效,对法线精度要求更高
核心代码片段:
c复制// 世界空间法线计算
float3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
// 用于边缘光计算
float rim = 1.0 - saturate(dot(worldNormal, viewDir));
这种Shader通常会:
- 严格处理法线变换确保基础光照正确
- 在后续处理中艺术化调整法线效果
- 保持变换过程的性能效率
6. 调试与验证技巧
在开发过程中,验证法线是否正确变换的方法:
- 可视化法线方向:
c复制// 在片段着色器中直接输出法线作为颜色
return float4(normal * 0.5 + 0.5, 1.0);
- 检查光照响应:
- 旋转模型时,光照应平滑变化
- 缩放模型时,光照效果不应改变
- 使用Unity的Frame Debugger:
- 检查实际传入着色器的法线数据
- 对比模型空间和世界空间的值
7. 移动平台的特别考虑
在移动设备上,逆转置矩阵计算可能成为性能瓶颈。可以考虑:
- 在CPU端预计算并传入Shader:
c复制Matrix4x4 normalMatrix = transform.worldToLocalMatrix.transpose;
material.SetMatrix("_NormalMatrix", normalMatrix);
-
如果使用SRP Batcher,确保矩阵作为全局变量正确设置
-
对于大量相同材质的物体,共享计算好的法线变换矩阵
8. 数学推导的完整过程
让我们更严谨地推导一下法线变换的数学原理:
给定一个表面点P,附近另一个点Q,法线N垂直于向量PQ。
在模型空间:
code复制(P-Q) · N = 0
应用变换M后:
code复制(M(P)-M(Q)) · N' = 0
=> (M(P-Q)) · N' = 0
设v = P-Q,则:
code复制Mv · N' = 0
我们希望找到N'与N的关系。根据点积性质:
code复制(Mv)^T N' = 0
=> v^T M^T N' = 0
由于v · N = v^T N = 0,因此需要:
code复制M^T N' = kN (k为任意标量)
取k=1,则:
code复制N' = (M^T)^-1 N = (M^-1)^T N
这就是为什么需要使用逆转置矩阵进行法线变换。当M只包含旋转时,M^T = M^-1,因此可以直接用M变换法线。但当存在非均匀缩放时,必须使用完整的逆转置矩阵。
