URP 渲染实战 / 第 1 章 从像素说起:GPU 渲染管线与坐标空间 / 案例 1.1
案例 1.1坐标空间“调色板”:把各个空间画出来入门
目标:写一个调试 Shader,把一个点在物体空间、世界空间、观察空间、NDC、裁剪空间 w、像素坐标下的值“涂”在物体表面上,用眼睛直观地感受每个空间的特点。
原理:顶点着色器里用 GetVertexPositionInputs 一次算出所有空间的位置,统统放进 Varyings,交给光栅化器插值;片元着色器按 _Mode 选择把哪一个映射成颜色。网格线用 frac 找整数位置、用 fwidth 控制线宽(第 4 章详细讲)。有一点要特别注意:片元里的 SV_POSITION 已经变成像素坐标,想在片元里拿到裁剪坐标,得另外用一个 TEXCOORD 传一份拷贝。
Assets/Cookbook/Ch01/SpaceViz.shader(节选:varyings)
struct Varyings { float4 positionCS : SV_POSITION; // 交给光栅化器:裁剪空间坐标(到了片元里会变成像素坐标) float3 positionOS : TEXCOORD0; // 物体空间 float3 positionWS : TEXCOORD1; // 世界空间 float3 positionVS : TEXCOORD2; // 观察空间(右手系,相机看向 -Z) float4 positionHCS : TEXCOORD3; // 裁剪空间的一份拷贝:SV_POSITION 在片元阶段已被改写,想要原值得自己传 }; Varyings vert(Attributes input) { Varyings output; // URP 一次性算好各个空间的位置(ShaderVariablesFunctions.hlsl) VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz); output.positionCS = pos.positionCS; output.positionOS = input.positionOS.xyz; output.positionWS = pos.positionWS; output.positionVS = pos.positionVS; output.positionHCS = pos.positionCS; return output; }
// 交给光栅化器的裁剪空间坐标写进 gl_Position(到了片元里会变成像素坐标 gl_FragCoord) VARYING vec3 vPositionOS; // 物体空间 VARYING vec3 vPositionWS; // 世界空间 VARYING vec3 vPositionVS; // 观察空间(右手系,相机看向 -Z) VARYING vec4 vPositionHCS; // 裁剪空间的一份拷贝:gl_FragCoord 在片元阶段已被改写,想要原值得自己传 #ifdef VERTEX void main() { // URP 一次性算好各个空间的位置(ShaderVariablesFunctions.hlsl) VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz); gl_Position = pos.positionCS; vPositionOS = positionOS.xyz; vPositionWS = pos.positionWS; vPositionVS = pos.positionVS; vPositionHCS = pos.positionCS; } #endif
Assets/Cookbook/Ch01/SpaceViz.shader(节选:frag)
half4 frag(Varyings input) : SV_Target { float3 col = 0; int mode = (int)_Mode; if (mode == 0) // 物体空间:坐标跟着物体走,旋转缩放物体,图案跟着转 { col = saturate(input.positionOS + 0.5); col = lerp(col, 0.05, GridLine(input.positionOS, 0.25 * _GridSize)); } else if (mode == 1) // 世界空间:所有物体共用一套坐标,网格线在物体之间连续 { float3 cell = frac(input.positionWS / _GridSize); col = 0.25 + 0.6 * cell; col = lerp(col, 0.05, GridLine(input.positionWS, _GridSize)); // 世界原点处的 X 轴(红)、Z 轴(蓝) float ax = 1.0 - saturate(abs(input.positionWS.z) / (2.0 * fwidth(input.positionWS.z))); float az = 1.0 - saturate(abs(input.positionWS.x) / (2.0 * fwidth(input.positionWS.x))); col = lerp(col, float3(1, 0.1, 0.1), ax); col = lerp(col, float3(0.1, 0.3, 1), az); } else if (mode == 2) // 观察空间:x 向右、y 向上,深度 = -z { float depth = -input.positionVS.z; col.rg = saturate(input.positionVS.xy * 0.25 + 0.5); col.b = saturate(depth / _DepthScale); // 等深线:每 1 米一条,它们都与相机的成像平面平行 float iso = abs(frac(depth - 0.5) - 0.5) / max(fwidth(depth), 1e-5); col = lerp(col, 1.0, 1.0 - saturate(iso - 0.5)); } else if (mode == 3) // NDC:透视除法之后,可见范围 x、y 都在 [-1, 1] { float2 ndc = input.positionHCS.xy / input.positionHCS.w; ndc.y *= _ProjectionParams.x; // 渲染到 RT 时投影矩阵被上下翻转过,这里翻回来,让 +y 永远朝上 col = float3(saturate(ndc.x), saturate(ndc.y), saturate(-ndc.x) * 0.7 + saturate(-ndc.y) * 0.3); col = lerp(col, 0.0, GridLine2(ndc, 0.25)); col = lerp(col, 1.0, GridLine2(ndc, 1.0)); // 中心十字(x=0、y=0) } else if (mode == 4) // 裁剪空间的 w:透视投影下它就是观察空间深度 { float w = input.positionHCS.w; col = saturate(w / _DepthScale).xxx; float iso = abs(frac(w - 0.5) - 0.5) / max(fwidth(w), 1e-5); col = lerp(col, float3(1, 0.45, 0.2), 1.0 - saturate(iso - 0.5)); } else // 片元阶段的 SV_POSITION.xy:像素中心坐标(如 100.5, 37.5) { float2 cell = floor(input.positionCS.xy / _PixelCell); float k = fmod(cell.x + cell.y, 2.0); col = lerp(float3(0.9, 0.9, 0.92), float3(0.25, 0.45, 0.6), k); } return half4(col, 1); }
void main() { vec3 col = vec3(0.0); int mode = int(_Mode); if (mode == 0) // 物体空间:坐标跟着物体走,旋转缩放物体,图案跟着转 { col = saturate(vPositionOS + 0.5); col = mix(col, vec3(0.05), GridLine(vPositionOS, 0.25 * _GridSize)); } else if (mode == 1) // 世界空间:所有物体共用一套坐标,网格线在物体之间连续 { vec3 cell = fract(vPositionWS / _GridSize); col = 0.25 + 0.6 * cell; col = mix(col, vec3(0.05), GridLine(vPositionWS, _GridSize)); // 世界原点处的 X 轴(红)、Z 轴(蓝) float ax = 1.0 - saturate(abs(vPositionWS.z) / (2.0 * fwidth(vPositionWS.z))); float az = 1.0 - saturate(abs(vPositionWS.x) / (2.0 * fwidth(vPositionWS.x))); col = mix(col, vec3(1, 0.1, 0.1), ax); col = mix(col, vec3(0.1, 0.3, 1), az); } else if (mode == 2) // 观察空间:x 向右、y 向上,深度 = -z { float depth = -vPositionVS.z; col.rg = saturate(vPositionVS.xy * 0.25 + 0.5); col.b = saturate(depth / _DepthScale); // 等深线:每 1 米一条,它们都与相机的成像平面平行 float iso = abs(fract(depth - 0.5) - 0.5) / max(fwidth(depth), 1e-5); col = mix(col, vec3(1.0), 1.0 - saturate(iso - 0.5)); } else if (mode == 3) // NDC:透视除法之后,可见范围 x、y 都在 [-1, 1] { vec2 ndc = vPositionHCS.xy / vPositionHCS.w; ndc.y *= _ProjectionParams.x; // 渲染到 RT 时投影矩阵被上下翻转过,这里翻回来,让 +y 永远朝上(WebGL 里恒为 1) col = vec3(saturate(ndc.x), saturate(ndc.y), saturate(-ndc.x) * 0.7 + saturate(-ndc.y) * 0.3); col = mix(col, vec3(0.0), GridLine2(ndc, 0.25)); col = mix(col, vec3(1.0), GridLine2(ndc, 1.0)); // 中心十字(x=0、y=0) } else if (mode == 4) // 裁剪空间的 w:透视投影下它就是观察空间深度 { float w = vPositionHCS.w; col = vec3(saturate(w / _DepthScale)); float iso = abs(fract(w - 0.5) - 0.5) / max(fwidth(w), 1e-5); col = mix(col, vec3(1, 0.45, 0.2), 1.0 - saturate(iso - 0.5)); } else // 片元阶段的 gl_FragCoord.xy:像素中心坐标(如 100.5, 37.5) { // 注意:Unity(D3D)的像素坐标原点在左上角,WebGL 在左下角,棋盘格的起点会差半格 vec2 cell = floor(gl_FragCoord.xy / _PixelCell); float s = cell.x + cell.y; float k = s - 2.0 * trunc(s / 2.0); // fmod(s, 2) col = mix(vec3(0.9, 0.9, 0.92), vec3(0.25, 0.45, 0.6), k); } fragColor = vec4(col, 1); }

三张图对照着看:
- 物体空间里,立方体和圆柱体的网格贴着各自的表面走,圆柱被旋转了 15°,网格也跟着歪;地面的网格间距和立方体不同,因为地面(Unity 自带的 Plane)在物体空间里是 10×10 的,被缩放了 6 倍。物体空间的坐标不关心物体在世界里的位置和姿态,所以顶点动画、物体内的程序化图案通常在这里做。
- 世界空间里,所有物体共用一套网格,网格线从地面“爬”上物体表面也不断开。被旋转的圆柱体上,网格依然是横平竖直的。世界坐标贴图、三平面映射(第 20 章)利用的正是这一点。
- 观察空间的等深线全部是“横线”——它们是一组与相机成像平面平行的切片。这直观地说明了“观察空间深度 = 到相机所在平面的距离”,而不是到相机那个点的欧氏距离。

第二组图更有意思。左图里三个物体“消失”了——NDC 的 xy 只取决于这个点落在屏幕的什么位置,和它属于哪个物体、离多远都无关,所以整个画面就是一张平滑的渐变,四个象限被白色十字分开。代码里 ndc.y *= _ProjectionParams.x 这一行如果删掉,在渲染到 RenderTexture 的情况下(例如 URP 默认开启的中间缓冲),上下两半的颜色会对调。中图证明了 1.13 节的结论:w 的等值线与右上图观察空间的等深线完全一致,透视投影下 clip.w 就是观察空间深度。右图则是片元阶段的 SV_POSITION:规整的像素格子,与三维几何毫无关系。
提示这个 Shader 的输出是“数值当颜色”。在线性色彩空间的工程里,颜色写入 sRGB 帧缓冲时会经过 sRGB 编码,暗部会被明显提亮,所以屏幕上看到的灰度并不等于数值本身。只做定性观察没关系;要定量读数时,见案例 1.3 的处理方法。
本案例出自《URP 渲染实战》第 1 章「从像素说起:GPU 渲染管线与坐标空间」。在章节中阅读上下文 →