GameTech/URP 渲染实战

案例 15.3半调网点(Halftone)进阶

目标:模拟报纸和漫画印刷的网点效果:单色版用黑色圆点表现明暗,彩色版用 C、M、Y、K 四色网点叠印出彩色。

原理:印刷机只能“有墨”或“没墨”,没法印出 50% 的灰。传统的调幅加网(AM Screening)的办法是:把画面分成规则的网格,每格印一个圆点,圆点的面积占格子面积的比例就是这一格的墨量。远看时人眼把点和空白平均起来,就感知到了灰度。

设格子边长为 1,墨量为 c(0..1),圆点半径 r 满足 πr² = c,所以:

r=cπ

注意是平方根关系:墨量从 0.25 涨到 1,半径只涨了一倍。直接拿墨量当半径,会让中间调整体偏暗。当 c 超过 π/4 ≈ 0.785 时,圆的半径超过半个格子,相邻圆点开始连在一起,格子四角还剩一点留白——这正是真实印刷品暗部的样子。

彩色印刷用青(C)、品红(M)、黄(Y)三色油墨做减色混合,再加一个黑(K)版。四个色版的网格要旋转不同的角度(经典网角是 C 15°、M 75°、Y 0°、K 45°),否则几层规则网格叠在一起会产生摩尔纹(Moiré)。另外,三色等量叠印出来的“灰”既费墨又发浑,所以印刷前会做黑版生成(Gray Component Replacement):把三色共有的那部分换成黑墨。

每格一个点,面积 = 墨量 各色版网格旋转不同角度 C 15°M 75°Y 0°K 45°
图 15-7左:调幅网点,圆点面积随墨量变化;右:四个色版使用不同网角,避免规则网格互相干涉产生摩尔纹

完整代码:

Assets/Cookbook/Ch15/Halftone.shader
// 半调网点(Halftone):在屏幕上铺一张旋转过的网格,每格画一个圆点,
// 圆点面积 = 该处“墨量”。单色模式只用黑墨;CMYK 模式四色各自用不同网角叠印。
Shader "Cookbook/Ch15/Halftone"
{
    Properties
    {
        _BaseColor ("Base Color", Color) = (0.85, 0.3, 0.25, 1)
        [KeywordEnum(Off, Mono, CMYK)] _Style ("Style", Float) = 1
        _CellSize ("Cell Size (pixels)", Range(3, 32)) = 8
        _PaperColor ("Paper Color", Color) = (0.97, 0.95, 0.9, 1)
        _BlackGen ("Black Generation", Range(0, 1)) = 0.8
        _Contrast ("Tone Contrast", Range(0.5, 2)) = 1.15
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor, _PaperColor;
            float _CellSize;
            half  _BlackGen, _Contrast;
        CBUFFER_END
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            HLSLPROGRAM
            #pragma vertex NprVert
            #pragma fragment frag
            #pragma shader_feature_local_fragment _STYLE_OFF _STYLE_MONO _STYLE_CMYK
            #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"

            // 返回当前像素被网点覆盖的比例(0..1,已抗锯齿)
            // pixel:屏幕像素坐标;cell:网格边长(像素);angle:网角(度);ink:该处墨量 0..1
            half HalftoneDot(float2 pixel, float cell, float angle, half ink)
            {
                float s, c;
                sincos(radians(angle), s, c);
                float2 p = mul(float2x2(c, -s, s, c), pixel) / cell;   // 旋转后的网格坐标
                float2 local = frac(p) - 0.5;                           // 格子内坐标,中心为 0
                float d = length(local);
                // 圆面积 πr² 与格子面积 1 之比 = 墨量,所以半径与墨量的平方根成正比
                float r = sqrt(saturate(ink) / PI);
                float aa = max(fwidth(d), 1e-4);                        // 约等于一个像素宽
                return 1.0 - smoothstep(r - aa, r + aa, d);
            }

            half4 frag(NprVaryings i) : SV_Target
            {
                float3 N = normalize(i.normalWS);
                NprLight L = GetNprLight(i.positionWS, N);
                half3 lit = _BaseColor.rgb * (saturate(L.ndotl) * L.shadow * L.color + SampleSH(N));
                lit = saturate(lit);
            #if defined(_STYLE_OFF)
                return half4(lit, 1);
            #else
                // 加一点对比度,印刷品的网点通常比连续色调“硬”
                lit = saturate((lit - 0.5h) * _Contrast + 0.5h);
                float2 px = i.positionCS.xy;
              #if defined(_STYLE_MONO)
                // 单色版只表现明暗:用光照本身的亮度,本色只轻微参与,否则深色物体会糊成一片灰
                half3 lighting = saturate(L.ndotl) * L.shadow * L.color + SampleSH(N);
                half tone = Luminance(saturate(lighting)) * lerp(1.0h, Luminance(_BaseColor.rgb), 0.3h);
                half ink = 1.0h - saturate((tone - 0.5h) * _Contrast + 0.5h);
                half cover = HalftoneDot(px, _CellSize, 45, ink);
                return half4(lerp(_PaperColor.rgb, half3(0.08, 0.08, 0.1), cover), 1);
              #else
                // RGB → CMY,再把三色共同的“灰”部分交给黑墨(K),减少用墨、让暗部更干净
                half3 cmy = 1.0h - lit;
                half k = min(cmy.r, min(cmy.g, cmy.b)) * _BlackGen;
                cmy = (cmy - k) / max(1.0h - k, 1e-3h);

                // 经典网角:C 15°、M 75°、Y 0°、K 45°,错开角度避免摩尔纹
                half c  = HalftoneDot(px, _CellSize, 15, cmy.r);
                half m  = HalftoneDot(px, _CellSize, 75, cmy.g);
                half y  = HalftoneDot(px, _CellSize,  0, cmy.b);
                half kk = HalftoneDot(px, _CellSize, 45, k);

                // 减色叠印:每种墨把纸的颜色乘上自己的透射色
                half3 col = _PaperColor.rgb;
                col *= lerp(1.0h, half3(0.0, 0.66, 0.92), c);
                col *= lerp(1.0h, half3(0.92, 0.1, 0.55), m);
                col *= lerp(1.0h, half3(1.0, 0.92, 0.05), y);
                col *= lerp(1.0h, half3(0.1, 0.1, 0.12), kk);
                return half4(col, 1);
              #endif
            #endif
            }
            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
        }
    }
}
// 半调网点(Halftone):在屏幕上铺一张旋转过的网格,每格画一个圆点,(GLSL 版,对应 Ch15/Halftone.shader)
// 圆点面积 = 该处“墨量”。单色模式只用黑墨;CMYK 模式四色各自用不同网角叠印。
Shader "Cookbook/Ch15/Halftone"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _BaseColor, _PaperColor;
        uniform float _CellSize;
        uniform float _BlackGen, _Contrast;
        ENDGLSL

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

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

            #ifdef FRAGMENT
            // 返回当前像素被网点覆盖的比例(0..1,已抗锯齿)
            // pixel:屏幕像素坐标;cell:网格边长(像素);angle:网角(度);ink:该处墨量 0..1
            float HalftoneDot(vec2 pixel, float cell, float angle, float ink)
            {
                float s = sin(radians(angle)), c = cos(radians(angle));
                // HLSL 的 mul(float2x2(c, -s, s, c), p):float2x2 按行构造,GLSL 的 mat2 按列,所以写成 p * mat2(...)
                vec2 p = (pixel * mat2(c, -s, s, c)) / cell;   // 旋转后的网格坐标
                vec2 local = fract(p) - 0.5;                    // 格子内坐标,中心为 0
                float d = length(local);
                // 圆面积 πr² 与格子面积 1 之比 = 墨量,所以半径与墨量的平方根成正比
                float r = sqrt(saturate(ink) / PI);
                float aa = max(fwidth(d), 1e-4);                // 约等于一个像素宽
                return 1.0 - smoothstep(r - aa, r + aa, d);
            }

            vec4 frag()
            {
                vec3 N = normalize(normalWS);
                NprLight L = GetNprLight(positionWS, N);
                vec3 lit = _BaseColor.rgb * (saturate(L.ndotl) * L.shadow * L.color + SampleSH(N));
                lit = saturate(lit);
            #if defined(_STYLE_OFF)
                return vec4(lit, 1.0);
            #else
                // 加一点对比度,印刷品的网点通常比连续色调“硬”
                lit = saturate((lit - 0.5) * _Contrast + 0.5);
                vec2 px = gl_FragCoord.xy;                      // = HLSL 的 i.positionCS.xy(像素坐标)
              #if defined(_STYLE_MONO)
                // 单色版只表现明暗:用光照本身的亮度,本色只轻微参与,否则深色物体会糊成一片灰
                vec3 lighting = saturate(L.ndotl) * L.shadow * L.color + SampleSH(N);
                float tone = Luminance(saturate(lighting)) * mix(1.0, Luminance(_BaseColor.rgb), 0.3);
                float ink = 1.0 - saturate((tone - 0.5) * _Contrast + 0.5);
                float cover = HalftoneDot(px, _CellSize, 45.0, ink);
                return vec4(mix(_PaperColor.rgb, vec3(0.08, 0.08, 0.1), cover), 1.0);
              #else
                // RGB → CMY,再把三色共同的“灰”部分交给黑墨(K),减少用墨、让暗部更干净
                vec3 cmy = 1.0 - lit;
                float k = min(cmy.r, min(cmy.g, cmy.b)) * _BlackGen;
                cmy = (cmy - k) / max(1.0 - k, 1e-3);

                // 经典网角:C 15°、M 75°、Y 0°、K 45°,错开角度避免摩尔纹
                float c  = HalftoneDot(px, _CellSize, 15.0, cmy.r);
                float m  = HalftoneDot(px, _CellSize, 75.0, cmy.g);
                float y  = HalftoneDot(px, _CellSize,  0.0, cmy.b);
                float kk = HalftoneDot(px, _CellSize, 45.0, k);

                // 减色叠印:每种墨把纸的颜色乘上自己的透射色
                vec3 col = _PaperColor.rgb;
                col *= mix(vec3(1.0), vec3(0.0, 0.66, 0.92), c);
                col *= mix(vec3(1.0), vec3(0.92, 0.1, 0.55), m);
                col *= mix(vec3(1.0), vec3(1.0, 0.92, 0.05), y);
                col *= mix(vec3(1.0), vec3(0.1, 0.1, 0.12), kk);
                return vec4(col, 1.0);
              #endif
            #endif
            }
            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/Halftone.shader(节选:dot)
// 返回当前像素被网点覆盖的比例(0..1,已抗锯齿)
// pixel:屏幕像素坐标;cell:网格边长(像素);angle:网角(度);ink:该处墨量 0..1
half HalftoneDot(float2 pixel, float cell, float angle, half ink)
{
    float s, c;
    sincos(radians(angle), s, c);
    float2 p = mul(float2x2(c, -s, s, c), pixel) / cell;   // 旋转后的网格坐标
    float2 local = frac(p) - 0.5;                           // 格子内坐标,中心为 0
    float d = length(local);
    // 圆面积 πr² 与格子面积 1 之比 = 墨量,所以半径与墨量的平方根成正比
    float r = sqrt(saturate(ink) / PI);
    float aa = max(fwidth(d), 1e-4);                        // 约等于一个像素宽
    return 1.0 - smoothstep(r - aa, r + aa, d);
}
// 返回当前像素被网点覆盖的比例(0..1,已抗锯齿)
// pixel:屏幕像素坐标;cell:网格边长(像素);angle:网角(度);ink:该处墨量 0..1
float HalftoneDot(vec2 pixel, float cell, float angle, float ink)
{
    float s = sin(radians(angle)), c = cos(radians(angle));
    // HLSL 的 mul(float2x2(c, -s, s, c), p):float2x2 按行构造,GLSL 的 mat2 按列,所以写成 p * mat2(...)
    vec2 p = (pixel * mat2(c, -s, s, c)) / cell;   // 旋转后的网格坐标
    vec2 local = fract(p) - 0.5;                    // 格子内坐标,中心为 0
    float d = length(local);
    // 圆面积 πr² 与格子面积 1 之比 = 墨量,所以半径与墨量的平方根成正比
    float r = sqrt(saturate(ink) / PI);
    float aa = max(fwidth(d), 1e-4);                // 约等于一个像素宽
    return 1.0 - smoothstep(r - aa, r + aa, d);
}
Assets/Cookbook/Ch15/Halftone.shader(节选:frag)
half4 frag(NprVaryings i) : SV_Target
{
    float3 N = normalize(i.normalWS);
    NprLight L = GetNprLight(i.positionWS, N);
    half3 lit = _BaseColor.rgb * (saturate(L.ndotl) * L.shadow * L.color + SampleSH(N));
    lit = saturate(lit);
#if defined(_STYLE_OFF)
    return half4(lit, 1);
#else
    // 加一点对比度,印刷品的网点通常比连续色调“硬”
    lit = saturate((lit - 0.5h) * _Contrast + 0.5h);
    float2 px = i.positionCS.xy;
  #if defined(_STYLE_MONO)
    // 单色版只表现明暗:用光照本身的亮度,本色只轻微参与,否则深色物体会糊成一片灰
    half3 lighting = saturate(L.ndotl) * L.shadow * L.color + SampleSH(N);
    half tone = Luminance(saturate(lighting)) * lerp(1.0h, Luminance(_BaseColor.rgb), 0.3h);
    half ink = 1.0h - saturate((tone - 0.5h) * _Contrast + 0.5h);
    half cover = HalftoneDot(px, _CellSize, 45, ink);
    return half4(lerp(_PaperColor.rgb, half3(0.08, 0.08, 0.1), cover), 1);
  #else
    // RGB → CMY,再把三色共同的“灰”部分交给黑墨(K),减少用墨、让暗部更干净
    half3 cmy = 1.0h - lit;
    half k = min(cmy.r, min(cmy.g, cmy.b)) * _BlackGen;
    cmy = (cmy - k) / max(1.0h - k, 1e-3h);

    // 经典网角:C 15°、M 75°、Y 0°、K 45°,错开角度避免摩尔纹
    half c  = HalftoneDot(px, _CellSize, 15, cmy.r);
    half m  = HalftoneDot(px, _CellSize, 75, cmy.g);
    half y  = HalftoneDot(px, _CellSize,  0, cmy.b);
    half kk = HalftoneDot(px, _CellSize, 45, k);

    // 减色叠印:每种墨把纸的颜色乘上自己的透射色
    half3 col = _PaperColor.rgb;
    col *= lerp(1.0h, half3(0.0, 0.66, 0.92), c);
    col *= lerp(1.0h, half3(0.92, 0.1, 0.55), m);
    col *= lerp(1.0h, half3(1.0, 0.92, 0.05), y);
    col *= lerp(1.0h, half3(0.1, 0.1, 0.12), kk);
    return half4(col, 1);
  #endif
#endif
}
vec4 frag()
{
    vec3 N = normalize(normalWS);
    NprLight L = GetNprLight(positionWS, N);
    vec3 lit = _BaseColor.rgb * (saturate(L.ndotl) * L.shadow * L.color + SampleSH(N));
    lit = saturate(lit);
#if defined(_STYLE_OFF)
    return vec4(lit, 1.0);
#else
    // 加一点对比度,印刷品的网点通常比连续色调“硬”
    lit = saturate((lit - 0.5) * _Contrast + 0.5);
    vec2 px = gl_FragCoord.xy;                      // = HLSL 的 i.positionCS.xy(像素坐标)
  #if defined(_STYLE_MONO)
    // 单色版只表现明暗:用光照本身的亮度,本色只轻微参与,否则深色物体会糊成一片灰
    vec3 lighting = saturate(L.ndotl) * L.shadow * L.color + SampleSH(N);
    float tone = Luminance(saturate(lighting)) * mix(1.0, Luminance(_BaseColor.rgb), 0.3);
    float ink = 1.0 - saturate((tone - 0.5) * _Contrast + 0.5);
    float cover = HalftoneDot(px, _CellSize, 45.0, ink);
    return vec4(mix(_PaperColor.rgb, vec3(0.08, 0.08, 0.1), cover), 1.0);
  #else
    // RGB → CMY,再把三色共同的“灰”部分交给黑墨(K),减少用墨、让暗部更干净
    vec3 cmy = 1.0 - lit;
    float k = min(cmy.r, min(cmy.g, cmy.b)) * _BlackGen;
    cmy = (cmy - k) / max(1.0 - k, 1e-3);

    // 经典网角:C 15°、M 75°、Y 0°、K 45°,错开角度避免摩尔纹
    float c  = HalftoneDot(px, _CellSize, 15.0, cmy.r);
    float m  = HalftoneDot(px, _CellSize, 75.0, cmy.g);
    float y  = HalftoneDot(px, _CellSize,  0.0, cmy.b);
    float kk = HalftoneDot(px, _CellSize, 45.0, k);

    // 减色叠印:每种墨把纸的颜色乘上自己的透射色
    vec3 col = _PaperColor.rgb;
    col *= mix(vec3(1.0), vec3(0.0, 0.66, 0.92), c);
    col *= mix(vec3(1.0), vec3(0.92, 0.1, 0.55), m);
    col *= mix(vec3(1.0), vec3(1.0, 0.92, 0.05), y);
    col *= mix(vec3(1.0), vec3(0.1, 0.1, 0.12), kk);
    return vec4(col, 1.0);
  #endif
#endif
}

彩色版先把线性 RGB 当成“反射率”转成 CMY 墨量(1 − RGB),提取三者的最小值乘以 _BlackGen 作为黑墨,再把剩余部分重新归一化。叠印时,每种墨按覆盖率把纸面颜色乘上自己的“透射色”——这就是减色混合:青墨吸收红光、品红吸收绿光、黄墨吸收蓝光。代码里的四种墨色故意没用纯 (0,1,1) 这类理想值,而是偏向真实油墨的色相,叠出来更像印刷品。

单色版另有一点讲究:如果直接用最终颜色的亮度算墨量,红色物体本身亮度就低,整个物体会糊成一片灰点,明暗反而看不出来。所以单色版只让本色轻微参与(30%),主要用光照本身的亮度。这是“风格化需要的不是物理正确,而是可读性”的又一个例子。

图 15-8左:普通光照(_STYLE_OFF);中:单色网点,网角 45°;右:CMYK 四色网点叠印,网格 7 像素
提示这个 Shader 用 [KeywordEnum(Off, Mono, CMYK)] 声明风格,它会自动生成 _STYLE_OFF、_STYLE_MONO、_STYLE_CMYK 三个关键字,配合 #pragma shader_feature_local_fragment 只在片元阶段编译变体。在 C# 里切换时,除了 SetFloat("_Style", …),还要手动 EnableKeyword/DisableKeyword——Inspector 会替你做这件事,脚本不会。
常见坑网点大小以“像素”为单位,所以同一个材质在 720p 和 4K 屏幕上,网点占画面的比例完全不同。如果希望网点相对画面大小恒定,把 _CellSize 乘上 _ScreenParams.y / 1080 之类的比例。另外,网格太小(小于 4 像素)时圆点已经无法分辨形状,会退化成噪点;太大则丢失细节。对整个画面统一做半调,更适合放在后处理里(第 26 章),这里的做法优势在于可以只对某些物体生效。