GameTech/URP 渲染实战

案例 15.2素描排线(Hatching)进阶

目标:用 TAM 把受光强度变成笔触疏密,支持物体 UV 与屏幕空间两种贴法,并可给纸面上色。

原理:设像素亮度为 I(1 最亮,0 最暗),先把它映射成一个 0~6 的“级别”值 L,然后给每一级一个帐篷形(三角形)权重:

L=6(1−I),wk=max(0,1−|L−k|),k=0,1,…,6

第 0 级是纯白纸面。对任意 L,最多只有相邻两级的权重非零,且它们的和恰好为 1——这就是线性插值,只不过写成了“对所有级别求和”的形式,GPU 上不需要任何分支。

10 0123456L L = 2.45:w₂ = 0.55,w₃ = 0.45 纸白
图 15-5帐篷形权重:任意级别值只激活相邻两级,权重和恒为 1

完整代码:

Assets/Cookbook/Ch15/Hatching.shader
// 素描排线(Hatching):按受光强度在 6 级笔触贴图(TAM)之间混合。
// 两张 RGB 贴图共 6 个通道,一次采样拿到 3 级,2 次采样完成全部混合。
Shader "Cookbook/Ch15/Hatching"
{
    Properties
    {
        [NoScaleOffset] _Hatch0 ("Hatch 0 (levels 1-3)", 2D) = "white" {}
        [NoScaleOffset] _Hatch1 ("Hatch 1 (levels 4-6)", 2D) = "white" {}
        _HatchScale ("Hatch Tiling", Float) = 4
        [Toggle] _ScreenSpace ("Screen Space UV", Float) = 0
        _ScreenTileSize ("Screen Tile Size (pixels)", Float) = 160
        _InkColor ("Ink Color", Color) = (0.08, 0.07, 0.1, 1)
        _PaperColor ("Paper Color", Color) = (0.96, 0.94, 0.88, 1)
        _BaseColor ("Base Color", Color) = (1, 1, 1, 1)
        _Tint ("Base Color Tint", Range(0, 1)) = 0
        _Ambient ("Ambient Tone", Range(0, 1)) = 0.12
        _Exposure ("Exposure", Range(0.2, 2)) = 1
        _OutlineWidth ("Outline Width", Range(0, 0.05)) = 0.008
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            float _HatchScale, _ScreenSpace, _ScreenTileSize;
            half4 _InkColor, _PaperColor, _BaseColor;
            half  _Tint, _Ambient, _Exposure;
            float _OutlineWidth;
        CBUFFER_END
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            HLSLPROGRAM
            #pragma vertex NprVert
            #pragma fragment frag
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
            #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"

            TEXTURE2D(_Hatch0); SAMPLER(sampler_Hatch0);
            TEXTURE2D(_Hatch1); SAMPLER(sampler_Hatch1);

            // intensity:1 = 最亮(纸白),0 = 最暗(第 6 级)
            half Hatch(float2 uv, half intensity)
            {
                half3 h0 = SAMPLE_TEXTURE2D(_Hatch0, sampler_Hatch0, uv).rgb;
                half3 h1 = SAMPLE_TEXTURE2D(_Hatch1, sampler_Hatch1, uv).rgb;

                // 把亮度映射到 0..6:0 = 纸白,k = 第 k 级笔触
                half level = (1.0h - intensity) * 6.0h;
                // 帐篷形权重:离 level 最近的两级各分一部分,总和恒为 1
                half3 w0 = saturate(1.0h - abs(level - half3(1, 2, 3)));
                half3 w1 = saturate(1.0h - abs(level - half3(4, 5, 6)));
                half  wWhite = saturate(1.0h - level);

                return wWhite + dot(h0, w0) + dot(h1, w1);
            }

            half4 frag(NprVaryings i) : SV_Target
            {
                float3 N = normalize(i.normalWS);
                NprLight L = GetNprLight(i.positionWS, N);

                // 只关心“明暗”,不关心光的颜色:先算一个标量亮度
                half light = saturate(L.ndotl) * L.shadow * Luminance(L.color);
                half intensity = saturate((light * (1.0h - _Ambient) + _Ambient) * _Exposure);

                // 两种 UV:物体 UV(笔触贴在表面上)/ 屏幕 UV(笔触固定在画面上,每 _ScreenTileSize 像素重复一次)
                float2 screenUV = i.positionCS.xy / _ScreenTileSize;
                float2 uv = lerp(i.uv * _HatchScale, screenUV, _ScreenSpace);

                half h = Hatch(uv, intensity);
                half3 paper = _PaperColor.rgb * lerp(1.0h, _BaseColor.rgb, _Tint);
                half3 col = lerp(_InkColor.rgb, paper, h);
                return half4(col, 1);
            }
            ENDHLSL
        }

        Pass
        {
            Name "Outline"
            Tags { "LightMode"="SRPDefaultUnlit" }
            Cull Front
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            struct A { float4 positionOS : POSITION; float3 normalOS : NORMAL; };
            float4 vert(A v) : SV_POSITION
            {
                float4 positionCS = TransformObjectToHClip(v.positionOS.xyz);
                float3 normalCS = mul((float3x3)UNITY_MATRIX_VP, TransformObjectToWorldNormal(v.normalOS));
                float2 offset = normalize(normalCS.xy) * _OutlineWidth * positionCS.w;
                offset.x *= _ScreenParams.y / _ScreenParams.x;
                positionCS.xy += offset;
                return positionCS;
            }
            half4 frag() : SV_Target { return _InkColor; }
            ENDHLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            HLSLPROGRAM
            #pragma vertex NprShadowVert
            #pragma fragment NprNullFrag
            #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex NprDepthVert
            #pragma fragment NprNullFrag
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"
            ENDHLSL
        }
    }
}
// 素描排线(Hatching):按受光强度在 6 级笔触贴图(TAM)之间混合。(GLSL 版,对应 Ch15/Hatching.shader)
// 两张 RGB 贴图共 6 个通道,一次采样拿到 3 级,2 次采样完成全部混合。
Shader "Cookbook/Ch15/Hatching"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform float _HatchScale, _ScreenSpace, _ScreenTileSize;
        uniform vec4 _InkColor, _PaperColor, _BaseColor;
        uniform float _Tint, _Ambient, _Exposure;
        uniform float _OutlineWidth;
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            GLSLPROGRAM
            #define NPR_VARYINGS
            #include "Ch15/NPRCommon.glsl"

            uniform sampler2D _Hatch0;
            uniform sampler2D _Hatch1;

            #ifdef VERTEX
            void main() { NprVert(); }
            #endif

            #ifdef FRAGMENT
            // intensity:1 = 最亮(纸白),0 = 最暗(第 6 级)
            float Hatch(vec2 huv, float intensity)
            {
                vec3 h0 = texture(_Hatch0, huv).rgb;
                vec3 h1 = texture(_Hatch1, huv).rgb;

                // 把亮度映射到 0..6:0 = 纸白,k = 第 k 级笔触
                float level = (1.0 - intensity) * 6.0;
                // 帐篷形权重:离 level 最近的两级各分一部分,总和恒为 1
                vec3 w0 = saturate(1.0 - abs(level - vec3(1, 2, 3)));
                vec3 w1 = saturate(1.0 - abs(level - vec3(4, 5, 6)));
                float wWhite = saturate(1.0 - level);

                return wWhite + dot(h0, w0) + dot(h1, w1);
            }

            vec4 frag()
            {
                vec3 N = normalize(normalWS);
                NprLight L = GetNprLight(positionWS, N);

                // 只关心“明暗”,不关心光的颜色:先算一个标量亮度
                float light = saturate(L.ndotl) * L.shadow * Luminance(L.color);
                float intensity = saturate((light * (1.0 - _Ambient) + _Ambient) * _Exposure);

                // 两种 UV:物体 UV(笔触贴在表面上)/ 屏幕 UV(笔触固定在画面上,每 _ScreenTileSize 像素重复一次)
                vec2 screenUV = gl_FragCoord.xy / _ScreenTileSize;
                vec2 huv = mix(uv * _HatchScale, screenUV, _ScreenSpace);

                float h = Hatch(huv, intensity);
                vec3 paper = _PaperColor.rgb * mix(vec3(1.0), _BaseColor.rgb, _Tint);
                vec3 col = mix(_InkColor.rgb, paper, h);
                return vec4(col, 1.0);
            }
            void main() { fragColor = frag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "Outline"
            Tags { "LightMode"="SRPDefaultUnlit" }
            Cull Front
            GLSLPROGRAM
            #ifdef VERTEX
            void main()
            {
                vec4 positionCS = TransformObjectToHClip(positionOS.xyz);
                vec3 normalCS = mat3(UNITY_MATRIX_VP) * TransformObjectToWorldNormal(normalOS);
                vec2 offset = normalize(normalCS.xy) * _OutlineWidth * positionCS.w;
                offset.x *= _ScreenParams.y / _ScreenParams.x;
                positionCS.xy += offset;
                gl_Position = positionCS;
            }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = _InkColor; }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            GLSLPROGRAM
            #define NPR_SHADOW_PASS
            #include "Ch15/NPRCommon.glsl"
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #define NPR_DEPTH_PASS
            #include "Ch15/NPRCommon.glsl"
            ENDGLSL
        }
    }
}

关键代码讲解:先看混合函数。w0、w1 各是一个 half3,正好和两张贴图的 RGB 对应,用 dot 一次算完三级的加权和:

Assets/Cookbook/Ch15/Hatching.shader(节选:hatch)
// intensity:1 = 最亮(纸白),0 = 最暗(第 6 级)
half Hatch(float2 uv, half intensity)
{
    half3 h0 = SAMPLE_TEXTURE2D(_Hatch0, sampler_Hatch0, uv).rgb;
    half3 h1 = SAMPLE_TEXTURE2D(_Hatch1, sampler_Hatch1, uv).rgb;

    // 把亮度映射到 0..6:0 = 纸白,k = 第 k 级笔触
    half level = (1.0h - intensity) * 6.0h;
    // 帐篷形权重:离 level 最近的两级各分一部分,总和恒为 1
    half3 w0 = saturate(1.0h - abs(level - half3(1, 2, 3)));
    half3 w1 = saturate(1.0h - abs(level - half3(4, 5, 6)));
    half  wWhite = saturate(1.0h - level);

    return wWhite + dot(h0, w0) + dot(h1, w1);
}
// intensity:1 = 最亮(纸白),0 = 最暗(第 6 级)
float Hatch(vec2 huv, float intensity)
{
    vec3 h0 = texture(_Hatch0, huv).rgb;
    vec3 h1 = texture(_Hatch1, huv).rgb;

    // 把亮度映射到 0..6:0 = 纸白,k = 第 k 级笔触
    float level = (1.0 - intensity) * 6.0;
    // 帐篷形权重:离 level 最近的两级各分一部分,总和恒为 1
    vec3 w0 = saturate(1.0 - abs(level - vec3(1, 2, 3)));
    vec3 w1 = saturate(1.0 - abs(level - vec3(4, 5, 6)));
    float wWhite = saturate(1.0 - level);

    return wWhite + dot(h0, w0) + dot(h1, w1);
}

再看片元函数。这里只需要“明暗”,光的颜色被 Luminance 压成标量;_Ambient 给最暗处留一点底,否则阴影里会是一片纯黑的第 6 级:

Assets/Cookbook/Ch15/Hatching.shader(节选:frag)
half4 frag(NprVaryings i) : SV_Target
{
    float3 N = normalize(i.normalWS);
    NprLight L = GetNprLight(i.positionWS, N);

    // 只关心“明暗”,不关心光的颜色:先算一个标量亮度
    half light = saturate(L.ndotl) * L.shadow * Luminance(L.color);
    half intensity = saturate((light * (1.0h - _Ambient) + _Ambient) * _Exposure);

    // 两种 UV:物体 UV(笔触贴在表面上)/ 屏幕 UV(笔触固定在画面上,每 _ScreenTileSize 像素重复一次)
    float2 screenUV = i.positionCS.xy / _ScreenTileSize;
    float2 uv = lerp(i.uv * _HatchScale, screenUV, _ScreenSpace);

    half h = Hatch(uv, intensity);
    half3 paper = _PaperColor.rgb * lerp(1.0h, _BaseColor.rgb, _Tint);
    half3 col = lerp(_InkColor.rgb, paper, h);
    return half4(col, 1);
}
vec4 frag()
{
    vec3 N = normalize(normalWS);
    NprLight L = GetNprLight(positionWS, N);

    // 只关心“明暗”,不关心光的颜色:先算一个标量亮度
    float light = saturate(L.ndotl) * L.shadow * Luminance(L.color);
    float intensity = saturate((light * (1.0 - _Ambient) + _Ambient) * _Exposure);

    // 两种 UV:物体 UV(笔触贴在表面上)/ 屏幕 UV(笔触固定在画面上,每 _ScreenTileSize 像素重复一次)
    vec2 screenUV = gl_FragCoord.xy / _ScreenTileSize;
    vec2 huv = mix(uv * _HatchScale, screenUV, _ScreenSpace);

    float h = Hatch(huv, intensity);
    vec3 paper = _PaperColor.rgb * mix(vec3(1.0), _BaseColor.rgb, _Tint);
    vec3 col = mix(_InkColor.rgb, paper, h);
    return vec4(col, 1.0);
}

屏幕空间模式直接用 SV_POSITION.xy——在片元着色器里它就是以像素为单位的屏幕坐标——除以 _ScreenTileSize,笔触贴图每 160 像素重复一次,大小与物体远近无关。上色模式不改笔触,只把纸面颜色乘上物体本色,墨色换成深蓝紫,就有了彩铅的感觉。

图 15-6左:物体 UV 空间排线,笔触沿表面走,纽结管身上的排线顺着管子方向;中:屏幕空间排线,笔触大小恒定,但所有物体共用一张“画纸”;右:UV 空间 + 纸面着色 + 彩色墨水

对比左、中两图:UV 空间的笔触能“描述形体”,纽结的管身、球的经线都被排线勾勒出来,但球的两极和方块各面的 UV 拼接处能看出接缝;屏幕空间的笔触没有接缝,阴影在地面上的表现也更自然,但物体一动,笔触就像印在玻璃上一样不跟随(静态图看不出来,运行时非常明显)。

常见坑① 笔触的粗细会随距离变化:UV 空间的笔触远处变细变密、经过 mip 后变成灰色,近处又变得很粗。原论文的解决办法是根据屏幕上的纹素密度在两套缩放的 TAM 之间再混合一次(类似 mip 的思路),这里留作练习。② 极端拉伸的 UV(比如图中纽结的 U 方向重复了 8 次)会让笔触严重变形,必要时改用三平面映射(Triplanar,见第 20 章)在物体空间投影笔触。③ TAM 一定要开 mipmap 和各向异性过滤,否则掠射角的地面会出现明显的摩尔纹。
深入严格说,“6 级、两两混合”在每个像素上只用到了两张贴图,但我们采样了全部六级(两张 RGBA 贴图)。在移动端如果带宽紧张,可以先算出 L 落在哪两级,再只采样那两级——但那需要把 6 级拆成 6 张单通道贴图或贴图数组,采样次数虽少了,却引入了依赖分支。两次 RGB 采样在大多数平台上已经足够便宜。