GameTech/URP 渲染实战

案例 15.4水彩与纸张感进阶

目标:在不做后处理的前提下,让物体看起来像是用水彩一层层罩染在纸上的。

原理:水彩的视觉特征可以拆成几条,每一条都能对应到一个简单的数学操作:

Cout=Cpaper·Tpigmentd

这个公式有个很好的性质:d = 0 时输出就是纸色,d 增大时颜色向颜料色、再向更深更饱和的颜色变化,永远不会出现“发灰”的情况——而用 lerp(paper, pigment, d) 做混合,深色区会直接走向颜料色本身而无法更深。叠两层浓度为 d 的颜料等于一层 2d,这也和真实罩染一致。

完整代码:

Assets/Cookbook/Ch15/Watercolor.shader
// 水彩感:颜料按“透射率^浓度”叠在纸上(Beer–Lambert 式减色),
// 用噪声扰动色阶边界模拟水渍晕开,边界和轮廓处颜料堆积变深,纸纹让颜料沉淀出颗粒感。
Shader "Cookbook/Ch15/Watercolor"
{
    Properties
    {
        _BaseColor ("Pigment Color", Color) = (0.85, 0.35, 0.3, 1)
        _ShadowPigment ("Shadow Pigment", Color) = (0.35, 0.35, 0.75, 1)
        [NoScaleOffset] _NoiseTex ("Noise (fBm)", 2D) = "gray" {}
        _PaperColor ("Paper Color", Color) = (0.97, 0.95, 0.9, 1)
        _PaperScale ("Paper Grain Scale", Float) = 3
        _Bands ("Wash Bands", Range(1, 5)) = 3
        _Wobble ("Edge Wobble", Range(0, 0.5)) = 0.18
        _Density ("Pigment Density", Range(0, 3)) = 1.1
        _EdgeDarken ("Edge Darkening", Range(0, 2)) = 0.8
        _Granulation ("Granulation", Range(0, 1)) = 0.5
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor, _ShadowPigment, _PaperColor;
            float _PaperScale;
            half  _Bands, _Wobble, _Density, _EdgeDarken, _Granulation;
        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(_NoiseTex); SAMPLER(sampler_NoiseTex);

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

                // 纸纹和水渍噪声都固定在画面上(纸不会跟着物体动)
                float2 suv = GetNormalizedScreenSpaceUV(i.positionCS) * float2(_ScreenParams.x / _ScreenParams.y, 1);
                half wash  = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, suv * 1.3).r - 0.5h;   // 低频:晕染
                half grain = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, suv * _PaperScale * 8).r; // 高频:纸纹

                // 1) 受光 + 噪声扰动,再分成几层“罩染”
                half light = saturate(L.ndotl) * L.shadow + wash * _Wobble;
                half f = saturate(light) * _Bands;
                half fr = frac(f);
                half tone = (floor(f) + smoothstep(0.35h, 0.65h, fr)) / _Bands;   // 0 = 最暗,1 = 最亮

                // 2) 色阶交界处颜料干后堆积(“水边线”)
                half edge = exp(-pow((fr - 0.5h) / 0.12h, 2)) * step(0.02h, f) * step(f, _Bands - 0.02h);

                // 3) 轮廓处的颜料堆积:N·V 越小越靠近轮廓,加噪声让边缘不规则
                half rim = smoothstep(0.55h, 0.95h, 1.0h - saturate(dot(N, V)) + wash * 0.4h);

                // 4) 浓度:暗处颜料多,再叠加边缘堆积和纸纹沉淀
                half density = _Density * lerp(0.25h, 1.0h, 1.0h - tone);
                density += (edge * 0.6h + rim) * _EdgeDarken;
                density *= lerp(1.0h, 0.6h + 0.8h * grain, _Granulation);

                // 5) 暗部换一种偏冷的颜料,然后按 Beer–Lambert:透射率 = 颜料色^浓度
                half3 pigment = lerp(_BaseColor.rgb, _BaseColor.rgb * _ShadowPigment.rgb * 1.6h, saturate(1.0h - tone) * 0.7h);
                half3 paper = _PaperColor.rgb * (0.94h + 0.06h * grain);
                half3 col = paper * pow(saturate(pigment), max(density, 0.0h));
                return half4(col, 1);
            }
            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
        }
    }
}
// 水彩感:颜料按“透射率^浓度”叠在纸上(Beer–Lambert 式减色),(GLSL 版,对应 Ch15/Watercolor.shader)
// 用噪声扰动色阶边界模拟水渍晕开,边界和轮廓处颜料堆积变深,纸纹让颜料沉淀出颗粒感。
Shader "Cookbook/Ch15/Watercolor"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _BaseColor, _ShadowPigment, _PaperColor;
        uniform float _PaperScale;
        uniform float _Bands, _Wobble, _Density, _EdgeDarken, _Granulation;
        ENDGLSL

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

            uniform sampler2D _NoiseTex;

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

            #ifdef FRAGMENT
            vec4 frag()
            {
                vec3 N = normalize(normalWS);
                vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
                NprLight L = GetNprLight(positionWS, N);

                // 纸纹和水渍噪声都固定在画面上(纸不会跟着物体动)
                vec2 suv = GetNormalizedScreenSpaceUV(gl_FragCoord) * vec2(_ScreenParams.x / _ScreenParams.y, 1.0);
                float wash  = texture(_NoiseTex, suv * 1.3).r - 0.5;                 // 低频:晕染
                float grain = texture(_NoiseTex, suv * _PaperScale * 8.0).r;         // 高频:纸纹

                // 1) 受光 + 噪声扰动,再分成几层“罩染”
                float light = saturate(L.ndotl) * L.shadow + wash * _Wobble;
                float f = saturate(light) * _Bands;
                float fr = fract(f);
                float tone = (floor(f) + smoothstep(0.35, 0.65, fr)) / _Bands;   // 0 = 最暗,1 = 最亮

                // 2) 色阶交界处颜料干后堆积(“水边线”)
                float edge = exp(-pow((fr - 0.5) / 0.12, 2.0)) * step(0.02, f) * step(f, _Bands - 0.02);

                // 3) 轮廓处的颜料堆积:N·V 越小越靠近轮廓,加噪声让边缘不规则
                float rim = smoothstep(0.55, 0.95, 1.0 - saturate(dot(N, V)) + wash * 0.4);

                // 4) 浓度:暗处颜料多,再叠加边缘堆积和纸纹沉淀
                float density = _Density * mix(0.25, 1.0, 1.0 - tone);
                density += (edge * 0.6 + rim) * _EdgeDarken;
                density *= mix(1.0, 0.6 + 0.8 * grain, _Granulation);

                // 5) 暗部换一种偏冷的颜料,然后按 Beer–Lambert:透射率 = 颜料色^浓度
                vec3 pigment = mix(_BaseColor.rgb, _BaseColor.rgb * _ShadowPigment.rgb * 1.6, saturate(1.0 - tone) * 0.7);
                vec3 paper = _PaperColor.rgb * (0.94 + 0.06 * grain);
                vec3 col = paper * pow(saturate(pigment), vec3(max(density, 0.0)));
                return vec4(col, 1.0);
            }
            void main() { fragColor = frag(); }
            #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
        }
    }
}

关键代码讲解:

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

    // 纸纹和水渍噪声都固定在画面上(纸不会跟着物体动)
    float2 suv = GetNormalizedScreenSpaceUV(i.positionCS) * float2(_ScreenParams.x / _ScreenParams.y, 1);
    half wash  = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, suv * 1.3).r - 0.5h;   // 低频:晕染
    half grain = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, suv * _PaperScale * 8).r; // 高频:纸纹

    // 1) 受光 + 噪声扰动,再分成几层“罩染”
    half light = saturate(L.ndotl) * L.shadow + wash * _Wobble;
    half f = saturate(light) * _Bands;
    half fr = frac(f);
    half tone = (floor(f) + smoothstep(0.35h, 0.65h, fr)) / _Bands;   // 0 = 最暗,1 = 最亮

    // 2) 色阶交界处颜料干后堆积(“水边线”)
    half edge = exp(-pow((fr - 0.5h) / 0.12h, 2)) * step(0.02h, f) * step(f, _Bands - 0.02h);

    // 3) 轮廓处的颜料堆积:N·V 越小越靠近轮廓,加噪声让边缘不规则
    half rim = smoothstep(0.55h, 0.95h, 1.0h - saturate(dot(N, V)) + wash * 0.4h);

    // 4) 浓度:暗处颜料多,再叠加边缘堆积和纸纹沉淀
    half density = _Density * lerp(0.25h, 1.0h, 1.0h - tone);
    density += (edge * 0.6h + rim) * _EdgeDarken;
    density *= lerp(1.0h, 0.6h + 0.8h * grain, _Granulation);

    // 5) 暗部换一种偏冷的颜料,然后按 Beer–Lambert:透射率 = 颜料色^浓度
    half3 pigment = lerp(_BaseColor.rgb, _BaseColor.rgb * _ShadowPigment.rgb * 1.6h, saturate(1.0h - tone) * 0.7h);
    half3 paper = _PaperColor.rgb * (0.94h + 0.06h * grain);
    half3 col = paper * pow(saturate(pigment), max(density, 0.0h));
    return half4(col, 1);
}
vec4 frag()
{
    vec3 N = normalize(normalWS);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    NprLight L = GetNprLight(positionWS, N);

    // 纸纹和水渍噪声都固定在画面上(纸不会跟着物体动)
    vec2 suv = GetNormalizedScreenSpaceUV(gl_FragCoord) * vec2(_ScreenParams.x / _ScreenParams.y, 1.0);
    float wash  = texture(_NoiseTex, suv * 1.3).r - 0.5;                 // 低频:晕染
    float grain = texture(_NoiseTex, suv * _PaperScale * 8.0).r;         // 高频:纸纹

    // 1) 受光 + 噪声扰动,再分成几层“罩染”
    float light = saturate(L.ndotl) * L.shadow + wash * _Wobble;
    float f = saturate(light) * _Bands;
    float fr = fract(f);
    float tone = (floor(f) + smoothstep(0.35, 0.65, fr)) / _Bands;   // 0 = 最暗,1 = 最亮

    // 2) 色阶交界处颜料干后堆积(“水边线”)
    float edge = exp(-pow((fr - 0.5) / 0.12, 2.0)) * step(0.02, f) * step(f, _Bands - 0.02);

    // 3) 轮廓处的颜料堆积:N·V 越小越靠近轮廓,加噪声让边缘不规则
    float rim = smoothstep(0.55, 0.95, 1.0 - saturate(dot(N, V)) + wash * 0.4);

    // 4) 浓度:暗处颜料多,再叠加边缘堆积和纸纹沉淀
    float density = _Density * mix(0.25, 1.0, 1.0 - tone);
    density += (edge * 0.6 + rim) * _EdgeDarken;
    density *= mix(1.0, 0.6 + 0.8 * grain, _Granulation);

    // 5) 暗部换一种偏冷的颜料,然后按 Beer–Lambert:透射率 = 颜料色^浓度
    vec3 pigment = mix(_BaseColor.rgb, _BaseColor.rgb * _ShadowPigment.rgb * 1.6, saturate(1.0 - tone) * 0.7);
    vec3 paper = _PaperColor.rgb * (0.94 + 0.06 * grain);
    vec3 col = paper * pow(saturate(pigment), vec3(max(density, 0.0)));
    return vec4(col, 1.0);
}
  1. 两组噪声都来自同一张 noise_fbm,用屏幕坐标采样:低频(×1.3)的 wash 扰动明暗,制造晕染;高频的 grain 是纸纹。屏幕 UV 乘上宽高比,噪声在横向不会被拉伸。
  2. 色阶用 floor(f) + smoothstep(0.35, 0.65, frac(f)) 计算:每一层内部大部分是平的,只在中间 30% 的区间里过渡,比硬阶梯柔和、又比连续渐变“有层次”。
  3. 水边线 edge 是以色阶交界(frac = 0.5)为中心的高斯形尖峰,宽度由 0.12 控制;两个 step 防止在最亮、最暗两端也画出边线。
  4. 轮廓堆积用 1 − N·V 再加噪声,所以轮廓的深色边是“不规则”的,不会像描边那样整齐。
  5. 浓度按明暗分配后,再乘上纸纹调制的颗粒因子,最后进入 Beer–Lambert 公式。暗部还额外把颜料色往 _ShadowPigment(偏蓝紫)靠,这是水彩画家常用的“冷色画阴影”的习惯,和 Gooch 的思路如出一辙。
图 15-9左:URP Lit 的普通着色;右:水彩着色——分层罩染、色阶交界的水边线、轮廓处颜料堆积、纸纹颗粒,地面上的投影也成了一层淡淡的冷色罩染
深入真正高质量的实时水彩(例如学术界的 MNPR 框架)大多在屏幕空间完成:先渲染出颜色、深度、法线等缓冲,再在后处理里做颜料扩散(Bleeding)、边缘检测加深、纸张高度图引起的颜料流动等。逐物体的 Shader 做不到“颜色渗到隔壁物体上”,这是它的天花板。本例的价值在于:所有计算都在一个 Pass 里完成,可以只用于部分物体,在移动端也负担得起。
提示纸纹放在屏幕空间,所以镜头移动时纸纹是静止的——这恰好符合“画在纸上”的直觉。但晕染噪声 wash 也在屏幕空间,物体移动时色阶边界会“流动”。如果不喜欢,可以把 wash 换成物体空间(或世界空间)坐标采样,代价是不同物体的晕染图案会有接缝感。