GameTech/URP 渲染实战

案例 1.2法线可视化与非均匀缩放的坑入门

目标:把物体空间、世界空间、观察空间的法线分别画出来;再用“正确”和“错误”两种方式变换法线并计算漫反射,对比非均匀缩放下的差异。

原理:法线分量在 [−1, 1],乘 0.5 加 0.5 映射到 [0, 1] 就能当颜色显示——这也是法线贴图的编码方式,所以“法线图”看起来偏蓝紫(朝向 +Z 的分量大)。错误做法用 TransformObjectToWorldDir(只乘 M 的 3×3),正确做法用 TransformObjectToWorldNormal(乘逆转置)。

Assets/Cookbook/Ch01/NormalViz.shader(节选:vert)
Varyings vert(Attributes input)
{
    Varyings output;
    output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
    output.normalOS   = input.normalOS;

    // 正确:乘“模型矩阵的逆转置”。URP 的实现是 mul(normalOS, (float3x3)WorldToObject),
    // 行向量左乘逆矩阵,等价于列向量左乘逆矩阵的转置(SpaceTransforms.hlsl)
    output.normalWS = TransformObjectToWorldNormal(input.normalOS);

    // 错误:把法线当成普通方向,直接乘模型矩阵。均匀缩放时没问题,非均匀缩放时法线不再垂直于表面
    output.normalWSWrong = TransformObjectToWorldDir(input.normalOS);

    // 观察空间法线:方向只受旋转影响,用 3x3 的观察矩阵即可(相机矩阵不含缩放)
    output.normalVS = TransformWorldToViewDir(output.normalWS);
    return output;
}
#ifdef VERTEX
void main()
{
    gl_Position = TransformObjectToHClip(positionOS.xyz);
    vNormalOS   = normalOS;

    // 正确:乘“模型矩阵的逆转置”。URP 的实现是 mul(normalOS, (float3x3)WorldToObject),
    // 行向量左乘逆矩阵,等价于列向量左乘逆矩阵的转置(SpaceTransforms.hlsl)
    vNormalWS = TransformObjectToWorldNormal(normalOS);

    // 错误:把法线当成普通方向,直接乘模型矩阵。均匀缩放时没问题,非均匀缩放时法线不再垂直于表面
    vNormalWSWrong = TransformObjectToWorldDir(normalOS);

    // 观察空间法线:方向只受旋转影响,用 3x3 的观察矩阵即可(相机矩阵不含缩放)
    vNormalVS = TransformWorldToViewDir(vNormalWS);
}
#endif
Assets/Cookbook/Ch01/NormalViz.shader(节选:frag)
half4 frag(Varyings input) : SV_Target
{
    // 插值后的法线长度会小于 1,必须重新归一化
    float3 nOS = normalize(input.normalOS);
    float3 nWS = normalize(input.normalWS);
    float3 nWrong = normalize(input.normalWSWrong);
    float3 nVS = normalize(input.normalVS);

    int mode = (int)_Mode;
    half3 col;
    if (mode == 0)      col = nOS * 0.5 + 0.5;   // [-1,1] 映射到 [0,1] 才能当颜色看
    else if (mode == 1) col = nWS * 0.5 + 0.5;
    else if (mode == 2) col = nVS * 0.5 + 0.5;
    else if (mode == 3) col = Lambert(nWS);
    else if (mode == 4) col = Lambert(nWrong);
    else                col = abs(Lambert(nWS) - Lambert(nWrong)) * 4.0;
    return half4(col, 1);
}
void main()
{
    // 插值后的法线长度会小于 1,必须重新归一化
    vec3 nOS = normalize(vNormalOS);
    vec3 nWS = normalize(vNormalWS);
    vec3 nWrong = normalize(vNormalWSWrong);
    vec3 nVS = normalize(vNormalVS);

    int mode = int(_Mode);
    vec3 col;
    if (mode == 0)      col = nOS * 0.5 + 0.5;   // [-1,1] 映射到 [0,1] 才能当颜色看
    else if (mode == 1) col = nWS * 0.5 + 0.5;
    else if (mode == 2) col = nVS * 0.5 + 0.5;
    else if (mode == 3) col = Lambert(nWS);
    else if (mode == 4) col = Lambert(nWrong);
    else                col = abs(Lambert(nWS) - Lambert(nWrong)) * 4.0;
    fragColor = vec4(col, 1);
}
图 1-18左:物体空间法线;中:世界空间法线;右:观察空间法线。同一个旋转过的立方体,三种空间下各面颜色不同

看立方体:它被旋转过,物体空间里它的正面法线是 (0,0,−1),世界空间里却朝向别处,所以左、中两图同一个面颜色不同。地面没有旋转,物体空间和世界空间法线都是 (0,1,0),所以都是同一种绿色;到了观察空间,“向上”在相机看来是略微朝向观察者的,颜色就带上了蓝色——观察空间里 +Z 指向观察者(1.12 节)。

图 1-19非均匀缩放的球与纽结。左:直接乘模型矩阵变换法线(错误);中:乘逆转置矩阵(正确);右:两者光照差异 ×4

左、中两图乍看都“像那么回事”,但明暗交界线的位置不同:错误的法线被拉向了缩放倍数大的方向,压扁的球顶部本应接近正对上方,却被算成了偏向侧面。右图把差异放大 4 倍,白色区域就是光照算错的地方,面积相当可观。实际项目里,这类错误通常表现为“某个被拉伸过的模型光照怪怪的”,很难第一时间想到是法线变换的问题——用 URP 的 TransformObjectToWorldNormal 或 GetVertexNormalInputs,就不会踩这个坑。