GameTech/URP 渲染实战

案例 1.5屏幕 UV:除法放在顶点还是片元进阶

目标:搞清楚为什么 URP 的 positionNDC(以及旧的 ComputeScreenPos)返回的是“没除 w”的值,必须在片元里再除。

原理:屏幕 UV 是 (x/w,y/w),它在屏幕空间里是线性的。默认的透视校正插值会对插值量“除以 w 插值再乘回来”,对已经除过 w 的量再做一次透视校正,结果就错了。三种写法对比:

Assets/Cookbook/Ch01/ScreenUV.shader(节选:code)
struct Varyings
{
    float4 positionCS  : SV_POSITION;
    float4 positionNDC : TEXCOORD0;                  // 齐次屏幕坐标 (x, y, z, w),xy 在 [0, w]
    float2 uvDivided   : TEXCOORD1;                  // 顶点里已经除过 w
    noperspective float2 uvDividedNP : TEXCOORD2;    // 同上,但按屏幕空间线性插值
};

Varyings vert(Attributes input)
{
    Varyings output;
    VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz);
    output.positionCS  = pos.positionCS;
    output.positionNDC = pos.positionNDC;
    output.uvDivided   = pos.positionNDC.xy / pos.positionNDC.w;
    output.uvDividedNP = pos.positionNDC.xy / pos.positionNDC.w;
    return output;
}

float Checker(float2 uv)
{
    float aspect = _ScreenParams.x / _ScreenParams.y;
    float2 cell = floor(uv * float2(_Cells * aspect, _Cells));
    return fmod(abs(cell.x + cell.y), 2.0);
}

half4 frag(Varyings input) : SV_Target
{
    float2 uv;
    int mode = (int)_Mode;
    if (mode == 0)      uv = input.positionNDC.xy / input.positionNDC.w;   // 插值之后再除
    else if (mode == 1) uv = input.uvDivided;
    else if (mode == 2) uv = input.uvDividedNP;
    else                uv = GetNormalizedScreenSpaceUV(input.positionCS);

    float k = Checker(uv);
    half3 col = lerp(half3(0.92, 0.92, 0.94), half3(0.12, 0.42, 0.56), k);
    return half4(col, 1);
}
VARYING vec4 vPositionNDC;     // 齐次屏幕坐标 (x, y, z, w),xy 在 [0, w]
VARYING vec2 vUvDivided;       // 顶点里已经除过 w
VARYING vec2 vUvDividedNPw;    // 同上,但按屏幕空间线性插值(noperspective 的模拟:先乘 w)
VARYING float vClipW;

#ifdef VERTEX
void main()
{
    VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz);
    gl_Position   = pos.positionCS;
    vPositionNDC  = pos.positionNDC;
    vUvDivided    = pos.positionNDC.xy / pos.positionNDC.w;
    vUvDividedNPw = pos.positionNDC.xy / pos.positionNDC.w * pos.positionCS.w;
    vClipW        = pos.positionCS.w;
}
#endif

#ifdef FRAGMENT
float Checker(vec2 uv)
{
    float aspect = _ScreenParams.x / _ScreenParams.y;
    vec2 cell = floor(uv * vec2(_Cells * aspect, _Cells));
    float s = abs(cell.x + cell.y);
    return s - 2.0 * trunc(s / 2.0);   // fmod(s, 2)
}

void main()
{
    vec2 uv;
    int mode = int(_Mode);
    if (mode == 0)      uv = vPositionNDC.xy / vPositionNDC.w;   // 插值之后再除
    else if (mode == 1) uv = vUvDivided;
    else if (mode == 2) uv = vUvDividedNPw / vClipW;
    else                uv = GetNormalizedScreenSpaceUV(gl_FragCoord);

    float k = Checker(uv);
    vec3 col = mix(vec3(0.92, 0.92, 0.94), vec3(0.12, 0.42, 0.56), k);
    fragColor = vec4(col, 1);
}
#endif
图 1-22用屏幕 UV 铺棋盘格。左:顶点输出未除的 positionNDC,片元里再除(正确);中:顶点里先除 w,再默认插值(错误);右:顶点里先除 w,但用 noperspective 插值(也正确)

正确的屏幕空间棋盘格应该像一张贴在屏幕上的格子纸,和三维几何毫无关系(左、右)。中图的格子被几何“带歪了”:每个三角形内部的格子沿三角形发生了透视变形,地面两个三角形的交界处还能看到断裂。

为什么左图正确?透视校正插值对 positionNDC 的 xy 和 w 分别做正确的插值,片元里再相除,得到的就是这个像素真实的 x/w。为什么右图也正确?x/w 在屏幕空间本来就是线性的,noperspective 的屏幕空间线性插值恰好吻合。实践中最简单的写法是第四种:直接在片元里用 GetNormalizedScreenSpaceUV(input.positionCS),由像素坐标除以屏幕尺寸得到 UV,连 Varyings 都省了(Shader 里的 _Mode = 3,结果与左图相同)。

✓ 推荐
片元里 GetNormalizedScreenSpaceUV(positionCS);或顶点输出 positionNDC、片元里 xy / w。
✗ 避免
顶点里先做 xy / w 再交给默认插值。小三角形上误差不明显,大三角形(地面、水面、天空)上会出现可见的扭曲。