GameTech/URP 渲染实战

案例 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);
}
图 1-16左:物体空间(每个物体各有一套坐标,网格随物体旋转、缩放);中:世界空间(网格在物体之间连续,红线为世界 X 轴、蓝线为 Z 轴);右:观察空间(白色等深线每 1 米一条,全部与成像平面平行)

三张图对照着看:

图 1-17左:NDC 的 xy(右上为 +x+y,白色十字为 x=0、y=0);中:裁剪空间 w,橙色等值线每 1 米一条;右:片元中的 SV_POSITION.xy,每 40 像素一格

第二组图更有意思。左图里三个物体“消失”了——NDC 的 xy 只取决于这个点落在屏幕的什么位置,和它属于哪个物体、离多远都无关,所以整个画面就是一张平滑的渐变,四个象限被白色十字分开。代码里 ndc.y *= _ProjectionParams.x 这一行如果删掉,在渲染到 RenderTexture 的情况下(例如 URP 默认开启的中间缓冲),上下两半的颜色会对调。中图证明了 1.13 节的结论:w 的等值线与右上图观察空间的等深线完全一致,透视投影下 clip.w 就是观察空间深度。右图则是片元阶段的 SV_POSITION:规整的像素格子,与三维几何毫无关系。

提示这个 Shader 的输出是“数值当颜色”。在线性色彩空间的工程里,颜色写入 sRGB 帧缓冲时会经过 sRGB 编码,暗部会被明显提亮,所以屏幕上看到的灰度并不等于数值本身。只做定性观察没关系;要定量读数时,见案例 1.3 的处理方法。