GameTech/URP 渲染实战

案例 14.5裁剪空间等宽描边进阶

目标:实现背面外扩描边,比较物体空间外扩与裁剪空间外扩的线宽差异,并理解为什么一个 Pass 用 SRPDefaultUnlit 就能被 URP 自动绘制。

描边 Pass 放在哪

URP 的不透明绘制(DrawObjectsPass)会收集三种 LightMode 的 Pass:UniversalForward、UniversalForwardOnly 和 SRPDefaultUnlit(没有写 LightMode 的 Pass 也视为后者)。所以只要把描边 Pass 的 LightMode 设为 SRPDefaultUnlit,它就会和着色 Pass 一起被画出来,不需要任何 Renderer Feature。限制是:一个 Shader 里同一个 LightMode 只会被画一次,所以只能有一个描边 Pass;如果需要更多 Pass(例如额外的发光层),就得用 Renderer Feature 的 RenderObjects 或自定义 ShaderTagId(第 25 章)。

顺便说一下这种多 Pass 写法与 SRP Batcher 的关系:两个 Pass 用同一个 UnityPerMaterial 常量缓冲区(描边颜色、线宽也放在里面),SRP Batcher 依然兼容;但每个物体会多一次 Draw Call,场景里描边物体很多时要注意。

Assets/Cookbook/Ch14/ToonOutline.shader(节选:outlinepass)
Pass
{
    Name "Outline"
    // URP 的不透明队列会画 UniversalForward 和 SRPDefaultUnlit 两种 LightMode,
    // 所以第二个 Pass 用 SRPDefaultUnlit 就能自动被画到,不需要 Renderer Feature
    Tags { "LightMode"="SRPDefaultUnlit" }
    Cull Front      // 只画背面
    ZWrite On

    HLSLPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma shader_feature_local_vertex _OUTLINESPACE_OBJECT _OUTLINESPACE_CLIP
    #pragma shader_feature_local_vertex _OUTLINENORMAL_MESH _OUTLINENORMAL_TANGENT _OUTLINENORMAL_VERTEXCOLOR
    #pragma multi_compile_fog
    #pragma multi_compile_instancing
    #include "Assets/Cookbook/Ch14/ToonCommon.hlsl"

    struct OutlineVaryings
    {
        float4 positionCS : SV_POSITION;
        float  fogFactor  : TEXCOORD0;
        UNITY_VERTEX_INPUT_INSTANCE_ID
    };

    // 取“用来外扩的法线”:原始法线 / 烘焙在切线里的平滑法线 / 烘焙在顶点色里的切线空间平滑法线
    float3 GetOutlineNormalOS(ToonAttributes v)
    {
    #if defined(_OUTLINENORMAL_TANGENT)
        return v.tangentOS.xyz;                               // 物体空间平滑法线
    #elif defined(_OUTLINENORMAL_VERTEXCOLOR)
        float3 nTS = v.color.rgb * 2.0 - 1.0;                  // 切线空间平滑法线
        float3 bitangentOS = cross(v.normalOS, v.tangentOS.xyz) * v.tangentOS.w;
        return normalize(nTS.x * v.tangentOS.xyz + nTS.y * bitangentOS + nTS.z * v.normalOS);
    #else
        return v.normalOS;
    #endif
    }

    OutlineVaryings vert(ToonAttributes v)
    {
        OutlineVaryings o;
        UNITY_SETUP_INSTANCE_ID(v);
        UNITY_TRANSFER_INSTANCE_ID(v, o);
        float3 n = GetOutlineNormalOS(v);
    #if defined(_OUTLINESPACE_OBJECT)
        o.positionCS = OutlinePositionCS_ObjectSpace(v.positionOS.xyz, n, _OutlineWidthOS);
    #else
        o.positionCS = OutlinePositionCS(v.positionOS.xyz, n, _OutlineWidth);
    #endif
        o.fogFactor = ComputeFogFactor(o.positionCS.z);
        return o;
    }

    half4 frag(OutlineVaryings i) : SV_Target
    {
        return half4(MixFog(_OutlineColor.rgb, i.fogFactor), 1);
    }
    ENDHLSL
}
Pass
{
    Name "Outline"
    // URP 的不透明队列会画 UniversalForward 和 SRPDefaultUnlit 两种 LightMode,
    // 所以第二个 Pass 用 SRPDefaultUnlit 就能自动被画到,不需要 Renderer Feature
    Tags { "LightMode"="SRPDefaultUnlit" }
    Cull Front      // 只画背面
    ZWrite On

    GLSLPROGRAM
    #pragma shader_feature_local_vertex _OUTLINESPACE_OBJECT _OUTLINESPACE_CLIP
    #pragma shader_feature_local_vertex _OUTLINENORMAL_MESH _OUTLINENORMAL_TANGENT _OUTLINENORMAL_VERTEXCOLOR
    #include "Ch14/ToonCommon.glsl"

    VARYING float fogFactor;

    #ifdef VERTEX
    // 取“用来外扩的法线”:原始法线 / 烘焙在切线里的平滑法线 / 烘焙在顶点色里的切线空间平滑法线
    vec3 GetOutlineNormalOS()
    {
    #if defined(_OUTLINENORMAL_TANGENT)
        return tangentOS.xyz;                               // 物体空间平滑法线
    #elif defined(_OUTLINENORMAL_VERTEXCOLOR)
        vec3 nTS = color.rgb * 2.0 - 1.0;                    // 切线空间平滑法线
        vec3 bitangentOS = cross(normalOS, tangentOS.xyz) * tangentOS.w;
        return normalize(nTS.x * tangentOS.xyz + nTS.y * bitangentOS + nTS.z * normalOS);
    #else
        return normalOS;
    #endif
    }

    void main()
    {
        vec3 n = GetOutlineNormalOS();
    #if defined(_OUTLINESPACE_OBJECT)
        gl_Position = OutlinePositionCS_ObjectSpace(positionOS.xyz, n, _OutlineWidthOS);
    #else
        gl_Position = OutlinePositionCS(positionOS.xyz, n, _OutlineWidth);
    #endif
        fogFactor = ComputeFogFactor(gl_Position.z);
    }
    #endif

    #ifdef FRAGMENT
    void main() { fragColor = vec4(MixFog(_OutlineColor.rgb, fogFactor), 1.0); }
    #endif
    ENDGLSL
}

外扩的数学

最直观的写法是在物体空间里推:positionOS + normalOS * width。问题是这个“宽度”是世界单位,经过透视投影后,近处的线粗、远处的线细,物体缩放后线宽也跟着变——这有时是想要的(近大远小更有空间感),但大多数卡通项目希望线宽在屏幕上保持恒定。

恒定像素宽度的做法是在裁剪空间里推。顶点经过 MVP 变换后得到 pCS,之后 GPU 会做透视除法 pNDC=pCS.xy/pCS.w。NDC 的 [−1, 1] 对应整个屏幕,所以一个像素在 NDC 里的大小是 2/屏幕尺寸。我们想在 NDC 里偏移 d·2/屏幕尺寸(d 为像素数),那么在除法之前就要偏移这个量乘以 w:

pCS.xy+=n^·d·2屏幕尺寸·pCS.w
Assets/Cookbook/Ch14/ToonCommon.hlsl(节选:outline)
// 描边外扩:在裁剪空间沿法线方向推开固定的像素宽度。
//   normalOS:用于外扩的法线(可以是平滑后的法线);widthPx:线宽(像素)
float4 OutlinePositionCS(float3 positionOS, float3 normalOS, float widthPx)
{
    float4 positionCS = TransformObjectToHClip(positionOS);
    float3 normalWS = TransformObjectToWorldNormal(normalOS);
    float3 normalCS = TransformWorldToHClipDir(normalWS, true);   // 方向向量只受 VP 的 3×3 部分影响
    // 先把方向换到“像素空间”再归一化:NDC 的 x、y 一个单位对应的像素数不同(宽高比),
    // 直接 normalize(normalCS.xy) 会让横向和纵向的线宽不一致
    float2 dirPx = normalize(normalCS.xy * _ScreenParams.xy + 1e-6);
    // NDC 的 [-1,1] 对应整个屏幕,所以 1 像素 = 2 / 屏幕尺寸;乘 w 是为了抵消之后的透视除法
    float2 offset = dirPx * widthPx * 2.0 / _ScreenParams.xy;
    positionCS.xy += offset * positionCS.w;
    return positionCS;
}

// 对照组:物体空间外扩(线宽随距离变化,近粗远细)
float4 OutlinePositionCS_ObjectSpace(float3 positionOS, float3 normalOS, float width)
{
    return TransformObjectToHClip(positionOS + normalize(normalOS) * width);
}
// 描边外扩:在裁剪空间沿法线方向推开固定的像素宽度。
//   normalOS:用于外扩的法线(可以是平滑后的法线);widthPx:线宽(像素)
vec4 OutlinePositionCS(vec3 posOS, vec3 nrmOS, float widthPx)
{
    vec4 positionCS = TransformObjectToHClip(posOS);
    vec3 nWS = TransformObjectToWorldNormal(nrmOS);
    vec3 normalCS = mat3(unity_MatrixVP) * nWS;   // 方向向量只受 VP 的 3×3 部分影响(= TransformWorldToHClipDir)
    // 先把方向换到“像素空间”再归一化:NDC 的 x、y 一个单位对应的像素数不同(宽高比),
    // 直接 normalize(normalCS.xy) 会让横向和纵向的线宽不一致
    vec2 dirPx = normalize(normalCS.xy * _ScreenParams.xy + 1e-6);
    // NDC 的 [-1,1] 对应整个屏幕,所以 1 像素 = 2 / 屏幕尺寸;乘 w 是为了抵消之后的透视除法
    vec2 offset = dirPx * widthPx * 2.0 / _ScreenParams.xy;
    positionCS.xy += offset * positionCS.w;
    return positionCS;
}

// 对照组:物体空间外扩(线宽随距离变化,近粗远细)
vec4 OutlinePositionCS_ObjectSpace(vec3 posOS, vec3 nrmOS, float width)
{
    return TransformObjectToHClip(posOS + normalize(nrmOS) * width);
}

方向 n^ 来自法线在裁剪空间里的 xy 分量。这里有个常被忽略的细节:NDC 的 x 和 y 方向“一个单位等于多少像素”不同(宽高比),如果直接 normalize(normalCS.xy),宽屏上横向的线会比纵向的粗。正确的做法是先乘屏幕尺寸换到像素空间再归一化,最后再除回去。另外,法线是方向向量,变换到裁剪空间只用 VP 矩阵的 3×3 部分,对应 URP 的 TransformWorldToHClipDir。

图 14-10由近及远的五个胶囊。左:物体空间外扩,近处的线很粗、远处几乎看不见;右:裁剪空间外扩,所有胶囊的线宽都是 2.5 像素
调参建议完全等宽在近景特写时会显得线太细、远景时又显得太粗,很多项目会在两者之间折中:按相机距离把线宽乘一个钳制过的系数(例如 clamp(distance / 参考距离, 0.5, 1.5)),或者按 FOV 修正。顶点色的 A 通道常用来做“线宽遮罩”:在鼻尖、指尖、衣服褶皱内侧把线宽刷成 0,避免内部出现多余的线。描边颜色也不一定是黑色,用固有色压暗、偏色后的颜色,画面会柔和很多。