URP 渲染实战 / 第 1 章 从像素说起:GPU 渲染管线与坐标空间 / 案例 1.5
案例 1.5屏幕 UV:除法放在顶点还是片元进阶
目标:搞清楚为什么 URP 的 positionNDC(以及旧的 ComputeScreenPos)返回的是“没除 w”的值,必须在片元里再除。
原理:屏幕 UV 是 ,它在屏幕空间里是线性的。默认的透视校正插值会对插值量“除以 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

正确的屏幕空间棋盘格应该像一张贴在屏幕上的格子纸,和三维几何毫无关系(左、右)。中图的格子被几何“带歪了”:每个三角形内部的格子沿三角形发生了透视变形,地面两个三角形的交界处还能看到断裂。
为什么左图正确?透视校正插值对 positionNDC 的 xy 和 w 分别做正确的插值,片元里再相除,得到的就是这个像素真实的 x/w。为什么右图也正确?x/w 在屏幕空间本来就是线性的,noperspective 的屏幕空间线性插值恰好吻合。实践中最简单的写法是第四种:直接在片元里用 GetNormalizedScreenSpaceUV(input.positionCS),由像素坐标除以屏幕尺寸得到 UV,连 Varyings 都省了(Shader 里的 _Mode = 3,结果与左图相同)。
✓ 推荐
片元里GetNormalizedScreenSpaceUV(positionCS);或顶点输出 positionNDC、片元里 xy / w。✗ 避免
顶点里先做xy / w 再交给默认插值。小三角形上误差不明显,大三角形(地面、水面、天空)上会出现可见的扭曲。本案例出自《URP 渲染实战》第 1 章「从像素说起:GPU 渲染管线与坐标空间」。在章节中阅读上下文 →