GameTech/URP 渲染实战
CHAPTER 15第 15 章

更多风格化(NPR)

卡通渲染只是非真实感渲染的一个分支。这一章把视野放宽:用冷暖色代替明暗、用笔触和网点代替灰度、用颜料和纸张代替光照,再把几十年前的硬件缺陷变成一种审美。你会发现,所有风格化手法都在回答同一个问题——“光照算出来的那个数,最后要用什么介质画出来”。

本章你会学到
NPR 的通用结构:光照原料 → 色调映射 → 介质(笔触、网点、颜料)→ 线条
Gooch 冷暖着色、素描排线(TAM)、半调网点(含 CMYK 叠印)、水彩与纸张感的原理和实现
PS1 风格的顶点吸附、仿射纹理映射、抖动量化;Matcap 的生成与透视修正
用 ddx/ddy 求面法线做低多边形,用烘焙重心坐标做线框与全息效果

15.1 非真实感渲染在做什么

前面十几章一直在追求“像真的”:辐射度量学、微表面、能量守恒,每一步都在逼近物理。非真实感渲染(Non-Photorealistic Rendering,NPR)把目标换成了“像画的”——像铅笔素描、像报纸印刷、像水彩、像二十多年前的游戏机。目标变了,但管线没变:顶点还是那些顶点,光照也还是那个光照,只是最后一步“把光照结果变成颜色”的方式不同。

把本章所有效果拆开,几乎都能套进同一个结构:

  1. 光照原料:N·L、阴影衰减、视线方向 V、高光项。和写实渲染一样算,但通常不急着 saturate——背光面的 N·L 是负数,这个负数对 Gooch 这类效果很有用。
  2. 色调映射:把原料压成一个或几个“色调值”。卡通是阶梯函数,Gooch 是冷暖插值,素描是“该用第几级笔触”。
  3. 介质:用什么把色调画出来。笔触贴图、网点、颜料透射率、低色深的抖动图案……这一步决定了风格的“质感”。
  4. 线条:轮廓线、线框、交界线。第 14 章讲过背面外扩描边,这一章补充重心坐标线框;基于深度/法线的屏幕空间描边放在第 26 章。
光照原料N·L(不截断)阴影、V、高光 色调映射阶梯 / 冷暖插值笔触级别 / 墨量 介质笔触、网点、颜料低色深 + 抖动 线条外扩描边线框 / 屏幕描边 同一套几何与光照,换掉后三步就换了一种画风 介质放在“物体空间”还是“屏幕空间”,是每种风格都要做的选择
图 15-1NPR 的通用结构

物体空间还是屏幕空间

介质(笔触、网点、纸纹)要贴在哪里,是 NPR 里最重要的设计决策之一:

  • 物体空间(或 UV 空间):笔触“长在”表面上,物体转动时笔触跟着转,透视下近大远小。问题是 UV 接缝、拉伸、极点收缩都会暴露出来,而且远处笔触会被 mip 糊掉或变得过密。
  • 屏幕空间:笔触“印在画面上”,大小恒定、没有接缝,很像真正画在纸上的效果。问题是物体移动时,笔触不跟着动,像隔着一层印花玻璃看世界——这在 NPR 文献里叫浴帘效应(Shower-door Effect)。

没有绝对正确的答案。半调网点、纸张纹理这类“介质本身就属于纸面”的东西,放在屏幕空间更自然;素描排线这类“描述形体走向”的笔触,通常放在物体空间更合理。下面的案例会两种都演示。

本章的公共代码

这一章的大部分 Shader 共用一个 include 文件 NPRCommon.hlsl:顶点结构、ShadowCaster/DepthOnly 的通用实现,以及一个只返回“原料”的取光函数。注意 ndotl 没有 saturate——要不要截断、怎么截断,交给具体的风格去决定:

Assets/Cookbook/Ch15/NPRCommon.hlsl(节选:npr-light)
// 风格化着色只需要“原料”:未截断的 N·L(-1..1)、阴影衰减、光的颜色和方向。
// 怎么把它们变成颜色,交给各个 Shader 自己决定。
struct NprLight
{
    float3 direction;
    float3 color;
    float  ndotl;      // 未 saturate,Gooch/Hatching 需要背光面的信息
    float  shadow;     // 0 = 完全在阴影里,1 = 受光
};

NprLight GetNprLight(float3 positionWS, float3 normalWS)
{
    float4 shadowCoord = TransformWorldToShadowCoord(positionWS);  // 逐像素算,级联阴影也正确
    Light l = GetMainLight(shadowCoord);
    NprLight o;
    o.direction = l.direction;
    o.color     = l.color;
    o.ndotl     = dot(normalWS, l.direction);
    o.shadow    = l.shadowAttenuation;
    return o;
}
// 风格化着色只需要“原料”:未截断的 N·L(-1..1)、阴影衰减、光的颜色和方向。
// 怎么把它们变成颜色,交给各个 Shader 自己决定。
struct NprLight
{
    vec3  direction;
    vec3  color;
    float ndotl;      // 未 saturate,Gooch/Hatching 需要背光面的信息
    float shadow;     // 0 = 完全在阴影里,1 = 受光
};

NprLight GetNprLight(vec3 posWS, vec3 nrmWS)
{
    vec4 shadowCoord = TransformWorldToShadowCoord(posWS);  // 逐像素算,级联阴影也正确
    Light l = GetMainLight(shadowCoord);
    NprLight o;
    o.direction = l.direction;
    o.color     = l.color;
    o.ndotl     = dot(nrmWS, l.direction);
    o.shadow    = l.shadowAttenuation;
    return o;
}

TransformWorldToShadowCoord 在片元里逐像素调用,这样在开启级联阴影(_MAIN_LIGHT_SHADOWS_CASCADE)时也能选到正确的级联;阴影投射 Pass 的写法和第 6 章完全一致,用 ApplyShadowBias 加偏移、再把裁剪空间 z 夹到近平面以内,这里不再重复。

为什么颜色 Pass 用 UniversalForwardOnly本章(以及第 16、17 章)的不透明 Shader,颜色 Pass 的 LightMode 都写成 UniversalForwardOnly 而不是 UniversalForward。在 Forward/Forward+ 路径下两者没有区别;但在 Deferred 路径下,URP 只通过 GBuffer Pass 绘制不透明物体,只有 UniversalForward 而没有 GBuffer Pass 的 Shader 根本不会被画出来。风格化着色本来就无法用延迟光照的统一 BRDF 表达,标成 UniversalForwardOnly 后,URP 会在延迟管线里用单独的“Render Opaques Forward Only”Pass 以前向方式绘制它们。

案例 15.1Gooch 冷暖着色入门单独打开 ↗

目标:在技术插图风格里,让背光面依然清楚可读——不是变黑,而是变“冷”。

原理:1998 年 Gooch 等人在研究技术插图时发现,插画师很少用纯黑表现背光面,因为黑色会和轮廓线、阴影混在一起,形体反而看不清。他们的做法是用色相变化表达朝向:受光面偏暖(黄、橙),背光面偏冷(蓝、紫)。人眼对冷暖的感知天然带有空间感——暖色前进、冷色后退——所以只靠色相也能读出体积。

模型本身非常简单。先构造冷暖两个端点颜色:纯冷色 b(蓝)加上一部分物体本色 kd,纯暖色 y(黄)加上一部分本色:

kcool=b+αkd,kwarm=y+βkd

再用重映射到 0..1 的 N·L 在两者之间插值:

t=1+N·L2,I=tkwarm+(1−t)kcool

这里的 (1 + N·L)/2 和 Half-Lambert 长得一样,但目的不同:Half-Lambert 是为了让暗部不死黑,Gooch 是为了让整个 −1..1 的范围都有意义——完全背光(N·L = −1)的点取纯冷色,完全受光的点取纯暖色,中间是一条连续的色相过渡。这也是为什么 GetNprLight 不截断 N·L:一旦 saturate,背光面全部变成 0,冷色端就丢了一半信息。

Lambert Gooch N·L = −1N·L = 0N·L = 1 Lambert:左半段全是 0(黑)
图 15-2同一个 N·L 范围,Lambert 只用了一半(背光面全黑),Gooch 用满了整个区间,暗部变成冷色

完整代码:

Assets/Cookbook/Ch15/Gooch.shader
// Gooch 冷暖着色:用“冷→暖”的色相变化代替“暗→亮”的明度变化来表达朝向,
// 暗部不再是死黑,形体在技术插图里依旧清楚。附带白色高光与背面外扩描边。
Shader "Cookbook/Ch15/Gooch"
{
    Properties
    {
        _BaseColor ("Base Color (k_d)", Color) = (0.8, 0.25, 0.2, 1)
        _CoolColor ("Cool Color (b)", Color) = (0.0, 0.05, 0.45, 1)
        _WarmColor ("Warm Color (y)", Color) = (0.45, 0.4, 0.0, 1)
        _Alpha ("Alpha (cool mix)", Range(0, 1)) = 0.25
        _Beta ("Beta (warm mix)", Range(0, 1)) = 0.55
        _SpecPower ("Specular Power", Range(4, 256)) = 48
        _SpecStrength ("Specular Strength", Range(0, 1)) = 0.8
        _ShadowCool ("Shadow pushes to cool", Range(0, 1)) = 0.6
        _OutlineWidth ("Outline Width", Range(0, 0.05)) = 0.012
        _OutlineColor ("Outline Color", Color) = (0.05, 0.05, 0.08, 1)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor, _CoolColor, _WarmColor, _OutlineColor;
            half  _Alpha, _Beta, _SpecPower, _SpecStrength, _ShadowCool;
            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
            #pragma multi_compile_fog
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"

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

                // 1) 冷暖两端:纯冷/暖色 + 一部分物体本色
                half3 kCool = _CoolColor.rgb + _Alpha * _BaseColor.rgb;
                half3 kWarm = _WarmColor.rgb + _Beta  * _BaseColor.rgb;

                // 2) 用 (1 + N·L) / 2 在两端之间插值:背光面不是“没有光”,而是“偏冷”
                half t = (1.0h + L.ndotl) * 0.5h;
                // 落在投影里的部分往冷端推一些(原始 Gooch 模型没有阴影,这是工程上的补充)
                t *= lerp(1.0h - _ShadowCool, 1.0h, L.shadow);
                half3 col = lerp(kCool, kWarm, t);

                // 3) 白色高光:Blinn-Phong,只在受光面出现
                float3 H = normalize(L.direction + V);
                half spec = pow(saturate(dot(N, H)), _SpecPower) * _SpecStrength * L.shadow * step(0, L.ndotl);
                col += spec;

                col = MixFog(col, i.fogFactor);
                return half4(col, 1);
            }
            ENDHLSL
        }

        // 背面外扩描边(第 14 章讲过的方法,这里只作为技术插图的黑色轮廓线)
        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));
                // 在裁剪空间沿法线外扩,乘 w 抵消透视除法,使线宽与距离无关
                float2 offset = normalize(normalCS.xy) * _OutlineWidth * positionCS.w;
                offset.x *= _ScreenParams.y / _ScreenParams.x;   // 纠正宽高比
                positionCS.xy += offset;
                return positionCS;
            }
            half4 frag() : SV_Target { return _OutlineColor; }
            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
        }
    }
}
// Gooch 冷暖着色:用“冷→暖”的色相变化代替“暗→亮”的明度变化来表达朝向,(GLSL 版,对应 Ch15/Gooch.shader)
// 暗部不再是死黑,形体在技术插图里依旧清楚。附带白色高光与背面外扩描边。
Shader "Cookbook/Ch15/Gooch"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _BaseColor, _CoolColor, _WarmColor, _OutlineColor;
        uniform float _Alpha, _Beta, _SpecPower, _SpecStrength, _ShadowCool;
        uniform float _OutlineWidth;
        ENDGLSL

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

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

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

                // 1) 冷暖两端:纯冷/暖色 + 一部分物体本色
                vec3 kCool = _CoolColor.rgb + _Alpha * _BaseColor.rgb;
                vec3 kWarm = _WarmColor.rgb + _Beta  * _BaseColor.rgb;

                // 2) 用 (1 + N·L) / 2 在两端之间插值:背光面不是“没有光”,而是“偏冷”
                float t = (1.0 + L.ndotl) * 0.5;
                // 落在投影里的部分往冷端推一些(原始 Gooch 模型没有阴影,这是工程上的补充)
                t *= mix(1.0 - _ShadowCool, 1.0, L.shadow);
                vec3 col = mix(kCool, kWarm, t);

                // 3) 白色高光:Blinn-Phong,只在受光面出现
                vec3 H = normalize(L.direction + V);
                float spec = pow(saturate(dot(N, H)), _SpecPower) * _SpecStrength * L.shadow * step(0.0, L.ndotl);
                col += spec;

                col = MixFog(col, fogFactor);
                return vec4(col, 1.0);
            }
            void main() { fragColor = frag(); }
            #endif
            ENDGLSL
        }

        // 背面外扩描边(第 14 章讲过的方法,这里只作为技术插图的黑色轮廓线)
        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);
                // 在裁剪空间沿法线外扩,乘 w 抵消透视除法,使线宽与距离无关
                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 = _OutlineColor; }
            #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/Gooch.shader(节选:frag)
half4 frag(NprVaryings i) : SV_Target
{
    float3 N = normalize(i.normalWS);
    float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    NprLight L = GetNprLight(i.positionWS, N);

    // 1) 冷暖两端:纯冷/暖色 + 一部分物体本色
    half3 kCool = _CoolColor.rgb + _Alpha * _BaseColor.rgb;
    half3 kWarm = _WarmColor.rgb + _Beta  * _BaseColor.rgb;

    // 2) 用 (1 + N·L) / 2 在两端之间插值:背光面不是“没有光”,而是“偏冷”
    half t = (1.0h + L.ndotl) * 0.5h;
    // 落在投影里的部分往冷端推一些(原始 Gooch 模型没有阴影,这是工程上的补充)
    t *= lerp(1.0h - _ShadowCool, 1.0h, L.shadow);
    half3 col = lerp(kCool, kWarm, t);

    // 3) 白色高光:Blinn-Phong,只在受光面出现
    float3 H = normalize(L.direction + V);
    half spec = pow(saturate(dot(N, H)), _SpecPower) * _SpecStrength * L.shadow * step(0, L.ndotl);
    col += spec;

    col = MixFog(col, i.fogFactor);
    return half4(col, 1);
}
vec4 frag()
{
    vec3 N = normalize(normalWS);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    NprLight L = GetNprLight(positionWS, N);

    // 1) 冷暖两端:纯冷/暖色 + 一部分物体本色
    vec3 kCool = _CoolColor.rgb + _Alpha * _BaseColor.rgb;
    vec3 kWarm = _WarmColor.rgb + _Beta  * _BaseColor.rgb;

    // 2) 用 (1 + N·L) / 2 在两端之间插值:背光面不是“没有光”,而是“偏冷”
    float t = (1.0 + L.ndotl) * 0.5;
    // 落在投影里的部分往冷端推一些(原始 Gooch 模型没有阴影,这是工程上的补充)
    t *= mix(1.0 - _ShadowCool, 1.0, L.shadow);
    vec3 col = mix(kCool, kWarm, t);

    // 3) 白色高光:Blinn-Phong,只在受光面出现
    vec3 H = normalize(L.direction + V);
    float spec = pow(saturate(dot(N, H)), _SpecPower) * _SpecStrength * L.shadow * step(0.0, L.ndotl);
    col += spec;

    col = MixFog(col, fogFactor);
    return vec4(col, 1.0);
}
  • 原始的 Gooch 模型没有投影。如果直接用上面的公式,落在别的物体阴影里的表面依然是暖色,看起来像“没有阴影”。代码里用 t *= lerp(1 - _ShadowCool, 1, shadow) 把阴影区往冷端推一截,这是一个工程上的补充,不属于原论文。
  • 高光用的是普通 Blinn-Phong,但颜色固定为白色、只出现在受光面(step(0, ndotl))。技术插图里的高光是“告诉你这是个光滑曲面”的符号,不需要物理正确。
  • 第二个 Pass 的描边沿用第 14 章的背面外扩法,LightMode 是 SRPDefaultUnlit,URP 的不透明绘制会自动画它。
图 15-3左:URP Lit(相同本色);中:Gooch 冷暖着色,无高光无描边;右:Gooch + 白色高光 + 黑色轮廓线。注意中、右两图的背光面没有发黑,纽结内侧的走向依然清晰
调参建议冷暖端点不必是蓝和黄。想要“傍晚”的感觉可以用紫—橙,想要清冷的科技感可以用青—白。α、β 越大,本色越占主导,冷暖感越弱;两者都很小时,所有物体会变成同一种冷暖渐变,本色几乎消失。实践中 α 取 0.2~0.4、β 取 0.4~0.6 比较平衡。
常见坑立方体的描边在棱角处会断开(图 15-3 右图的方块)。原因是立方体每个面的顶点各有一套法线,同一个角上的三个顶点沿不同方向外扩,就裂开了。第 14 章的“平滑法线烘焙”就是为了解决这个问题。

15.2 色调艺术贴图(TAM)

素描的明暗不是靠“灰度”画出来的,而是靠笔触的疏密:亮处稀疏的单向排线,暗处叠上交叉线,最暗处密密麻麻。要在 Shader 里模拟这件事,最经典的方案来自 Praun 等人 2001 年的实时素描论文:预先准备一组笔触贴图,每张对应一个色调级别,这组贴图叫色调艺术贴图(Tonal Art Map,TAM)。渲染时按像素的亮度在相邻两级之间混合。

好的 TAM 必须满足两个条件:

  • 色调嵌套:深一级贴图里的笔触必须包含浅一级的全部笔触,只是再加上新笔触。否则在两级之间混合时,浅级的笔触会“淡出”、深级的另一组笔触“淡入”,画面上就是笔触在闪烁、游动。
  • 分辨率嵌套:每一级的各个 mip 层要保持相同的平均灰度。原论文为每个 mip 层单独生成笔触;我们这里用程序生成后交给 GPU 自动生成 mip——自动 mip 是平均滤波,平均灰度天然不变,只是远处的笔触会糊成灰色,这是可以接受的简化。

本书不用任何外部素材,所以 TAM 也用 C# 程序生成。思路非常直接:维护一张“累计墨量”缓冲区,按级别依次往上画笔触;每画完一级,就把当前的累计结果拷贝一份作为这一级的贴图——嵌套性是自动满足的:

Assets/Cookbook/Ch15/HatchTextures.cs(节选:tam)
/// 返回 6 张单通道色阶(1 = 纸白,0 = 墨黑),每张 size×size,可无缝平铺
public static float[][] BuildLevels(int size = 256, int seed = 1234)
{
    var rnd = new System.Random(seed);
    var levels = new float[Levels][];
    var ink = new float[size * size];                 // 当前累计的“墨量”
    for (int i = 0; i < ink.Length; i++) ink[i] = 0f;

    // 每一级新增的笔触:数量、主方向(度)。前三级只有横向排线,后三级加交叉线
    int[] count = { 45, 55, 70, 50, 80, 130 };
    float[] angle = { 8, 10, 12, 98, 52, -40 };

    for (int lv = 0; lv < Levels; lv++)
    {
        for (int k = 0; k < count[lv]; k++)
        {
            float cx = (float)rnd.NextDouble() * size, cy = (float)rnd.NextDouble() * size;
            float len = size * (0.18f + 0.22f * (float)rnd.NextDouble());
            float a = (angle[lv] + ((float)rnd.NextDouble() - 0.5f) * 10f) * Mathf.Deg2Rad;
            float width = 0.9f + 0.6f * (float)rnd.NextDouble();
            float dark = 0.55f + 0.35f * (float)rnd.NextDouble();
            Stroke(ink, size, cx, cy, Mathf.Cos(a), Mathf.Sin(a), len, width, dark);
        }
        var tone = new float[size * size];
        for (int i = 0; i < tone.Length; i++) tone[i] = Mathf.Clamp01(1f - ink[i]);
        levels[lv] = tone;                             // 拷贝当前累计结果:天然嵌套
    }
    return levels;
}

// 画一条两端渐细的软边线段;坐标对 size 取模,所以贴图可以无缝平铺
static void Stroke(float[] ink, int size, float cx, float cy, float dx, float dy, float len, float width, float dark)
{
    int steps = Mathf.CeilToInt(len * 2f);
    for (int s = 0; s <= steps; s++)
    {
        float t = (float)s / steps - 0.5f;             // -0.5..0.5
        float taper = 1f - Mathf.Pow(Mathf.Abs(t) * 2f, 4f);   // 笔触两头变细、变淡
        float px = cx + dx * t * len, py = cy + dy * t * len;
        int r = Mathf.CeilToInt(width + 1);
        for (int oy = -r; oy <= r; oy++)
            for (int ox = -r; ox <= r; ox++)
            {
                int x = Mathf.FloorToInt(px) + ox, y = Mathf.FloorToInt(py) + oy;
                float d = Mathf.Sqrt((x + 0.5f - px) * (x + 0.5f - px) + (y + 0.5f - py) * (y + 0.5f - py));
                float cover = Mathf.Clamp01(width * taper - d + 0.5f);
                if (cover <= 0) continue;
                int idx = ((y % size + size) % size) * size + ((x % size + size) % size);
                // 用 max 而不是累加:同一条线的相邻采样点不会越叠越黑
                ink[idx] = Mathf.Max(ink[idx], Mathf.Min(1f, ink[idx] * 0.35f + cover * dark));
            }
    }
}
  • 前三级只画接近水平的排线(角度在 8°~12° 附近抖动),后三级依次加入竖直、正斜、反斜方向的交叉线。每条笔触的长度、宽度、深浅都带随机,两头用 1 - |2t|⁴ 收细,像真正的铅笔起笔、收笔。
  • 坐标对贴图尺寸取模,笔触越过右边界会从左边接着画,所以贴图可以无缝平铺。
  • 同一条笔触的相邻采样点会覆盖同一个像素多次,所以墨量合成用的是 max 而不是累加,避免线条中段越叠越黑。

六级贴图打包进两张 RGB 贴图:Hatch0 的 RGB 存第 1~3 级,Hatch1 存第 4~6 级。这样两次采样就拿到全部六级。注意贴图以线性方式创建(linear: true):它存的是“覆盖率”这种数据,不是给人看的颜色,不应该经过 sRGB 解码。

图 15-4程序生成的 6 级 TAM(从左到右由浅到深)。前三级是单向排线,后三级逐步加入交叉线;每一级都包含前一级的全部笔触

案例 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 采样在大多数平台上已经足够便宜。

案例 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);
}
  • 先把像素坐标旋转、再除以格子大小,得到网格坐标;frac(p) - 0.5 是格子内以中心为原点的坐标。
  • fwidth(d) 是 d 在相邻像素间的变化量,约等于“一个像素对应多少个格子单位”。用它做 smoothstep 的过渡宽度,圆点边缘就有一个像素宽的抗锯齿,且与格子大小无关。这是 Shader 里画任何程序化图形都适用的抗锯齿套路(第 4 章的 SDF 也是同样的思路)。
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 章),这里的做法优势在于可以只对某些物体生效。

案例 15.4水彩与纸张感进阶单独打开 ↗

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

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

  • 颜料是透明的。水彩不像油画那样遮盖底色,而是像有色玻璃一样让光穿过、被吸收一部分、再从白纸上反射回来。颜料越浓,吸收越多。严格的模型是 Kubelka-Munk 理论,我们用它的一个粗略近似——Beer–Lambert 吸收定律:把颜料色当成“单位浓度的透射率”,浓度为 d 时透射率是它的 d 次方。
  • 色阶是一层层罩染出来的,边界不规则。用噪声扰动明暗值,再分层量化,就有了“晕开”的形状。
  • 边缘颜料堆积。水彩干燥时颜料被水流带到湿区边缘,形成深色的“水边线”(Edge Darkening);物体轮廓处同样会堆积颜料。
  • 颗粒沉淀。颜料颗粒沉在纸纹的凹处,让平涂区域呈现细小的颗粒感(Granulation)。
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 换成物体空间(或世界空间)坐标采样,代价是不同物体的晕染图案会有接缝感。

案例 15.5复古像素风:PS1 的顶点抖动与仿射贴图进阶单独打开 ↗

目标:复刻初代 PlayStation 的画面特征:顶点在屏幕上一格一格地跳、地面贴图随视角扭曲、贴图是硬边的大像素、颜色有明显的抖动网点。

原理:这些“特征”其实都是当年硬件的局限:

现象当年的原因我们的模拟方法
顶点抖动(Jitter)光栅化器只接受整数像素坐标,没有子像素精度;几何运算是定点数把裁剪空间位置吸附到虚拟分辨率的像素格上
贴图扭曲没有透视校正插值,UV 在屏幕空间线性插值(仿射映射)传入 uv·w 和 w,让透视校正“自我抵消”
大像素贴图贴图很小(常见 64×64、128×128),且多为最近点采样32×32 程序贴图 + 点采样
抖动色块帧缓冲常用 15 位色(每通道 5 位),用有序抖动掩盖色带Bayer 4×4 抖动 + 降低每通道位数
多边形明暗块只在顶点上计算光照(Gouraud)在顶点着色器里算 Lambert

仿射映射是怎么来的

光栅化插值顶点属性时,GPU 默认做透视校正插值(第 1 章讲过):屏幕空间里线性的不是属性 A 本身,而是 A/w 和 1/w。设三角形三个顶点的屏幕空间重心坐标为 λi,片元得到的属性是:

A=∑iλiAi/wi∑iλi/wi

如果我们传入的不是 A,而是 A·w,那么插值结果是 ΣλiAi / Σ(λi/wi);同时再传一个 w,它的插值结果是 Σλi / Σ(λi/wi) = 1 / Σ(λi/wi)(因为 Σλi = 1)。两者相除,分母抵消,剩下 ΣλiAi——正是屏幕空间的线性插值,也就是 PS1 的仿射映射。HLSL 里也有 noperspective 插值修饰符可以直接关闭透视校正;上面的除法技巧则不依赖任何插值修饰符,只用到“透视校正插值”这一所有平台共同的行为,原理也更直观。

透视校正:近处格子大、远处格子小 仿射映射:格线在对角线处折弯
图 15-10一个四边形(两个三角形)在透视下的贴图。仿射映射在每个三角形内部线性插值,两个三角形的“错误方式”不同,格线在对角线(虚线)处折弯

完整代码:

Assets/Cookbook/Ch15/RetroPS1.shader
// 复古像素风(PS1 风格):顶点吸附到低分辨率网格、仿射纹理映射(不做透视校正)、
// 逐顶点光照、点采样,以及“抖动 + 色深量化”。每一项都能单独开关,方便对比。
Shader "Cookbook/Ch15/RetroPS1"
{
    Properties
    {
        [MainTexture] _BaseMap ("Base Map", 2D) = "white" {}
        [MainColor] _BaseColor ("Base Color", Color) = (1, 1, 1, 1)
        _VirtualRes ("Virtual Resolution (xy)", Vector) = (320, 240, 0, 0)
        [Toggle] _VertexSnap ("Vertex Snap", Float) = 1
        [Toggle] _Affine ("Affine Texture Mapping", Float) = 1
        [Toggle] _PointSample ("Point Sampling", Float) = 1
        [Toggle] _Quantize ("Dither + Quantize", Float) = 1
        _ColorBits ("Color Bits per Channel", Range(2, 8)) = 5
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            float4 _BaseMap_ST;
            half4  _BaseColor;
            float4 _VirtualRes;
            float  _VertexSnap, _Affine, _PointSample, _Quantize, _ColorBits;
        CBUFFER_END

        // 把顶点吸附到 _VirtualRes 分辨率的像素格上。
        // 先除 w 得到 NDC,取整后再乘回 w,保持深度与透视不变,只让 xy 跳格。
        float4 SnapToPixelGrid(float4 positionCS, float2 res)
        {
            float2 halfRes = res * 0.5;
            float2 ndc = positionCS.xy / positionCS.w;
            ndc = floor(ndc * halfRes + 0.5) / halfRes;
            positionCS.xy = ndc * positionCS.w;
            return positionCS;
        }
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

            TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);

            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; };
            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float2 uv         : TEXCOORD0;   // 正常的(透视校正的)UV
                float3 uvAffine   : TEXCOORD1;   // (uv * w, w),用来抵消透视校正
                half3  lighting   : TEXCOORD2;   // 逐顶点光照(Gouraud)
            };


            Varyings vert(Attributes v)
            {
                Varyings o;
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                float4 positionCS = TransformWorldToHClip(positionWS);
                if (_VertexSnap > 0.5) positionCS = SnapToPixelGrid(positionCS, _VirtualRes.xy);
                o.positionCS = positionCS;

                o.uv = TRANSFORM_TEX(v.uv, _BaseMap);
                // 透视校正插值对属性 A 实际做的是:插值 A/w 和 1/w 再相除。
                // 传入 A·w 和 w,两者都被“校正”后相除,w 抵消,剩下的就是屏幕空间线性插值的 A。
                o.uvAffine = float3(o.uv * positionCS.w, positionCS.w);

                // 逐顶点 Lambert + 环境光:当年的硬件只在顶点上算光
                float3 N = TransformObjectToWorldNormal(v.normalOS);
                Light L = GetMainLight();
                o.lighting = saturate(dot(N, L.direction)) * L.color + SampleSH(N);
                return o;
            }

            static const float kBayer4[16] = {
                 0,  8,  2, 10,
                12,  4, 14,  6,
                 3, 11,  1,  9,
                15,  7, 13,  5 };

            // 先加一个有序抖动偏移,再截断到 bits 位:渐变变成规则的网点而不是色带
            half3 DitherQuantize(half3 c, float2 pixel, float bits)
            {
                uint2 p = (uint2)pixel % 4;
                float threshold = (kBayer4[p.y * 4 + p.x] + 0.5) / 16.0 - 0.5;   // -0.5..0.5
                float levels = exp2(bits) - 1.0;
                return floor(saturate(c) * levels + 0.5 + threshold) / levels;
            }

            half4 frag(Varyings i) : SV_Target
            {
                float2 uv = lerp(i.uv, i.uvAffine.xy / i.uvAffine.z, _Affine);
                half4 texLinear = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv);
                half4 texPoint  = SAMPLE_TEXTURE2D(_BaseMap, sampler_PointRepeat, uv);
                half4 tex = lerp(texLinear, texPoint, _PointSample);

                half3 col = tex.rgb * _BaseColor.rgb * i.lighting;
                if (_Quantize > 0.5)
                {
                    // 抖动图案也按“虚拟分辨率”放大,模拟低分辨率帧缓冲上的抖动
                    float2 virtualPixel = floor(i.positionCS.xy * _VirtualRes.xy / _ScreenParams.xy);
                    // 在 sRGB(显示)空间量化,色阶才均匀
                    col = SRGBToLinear(DitherQuantize(LinearToSRGB(col), virtualPixel, _ColorBits));
                }
                return half4(col, 1);
            }
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // 深度也要按同样方式吸附,否则深度图与颜色错位
            float4 vert(float4 positionOS : POSITION) : SV_POSITION
            {
                float4 positionCS = TransformObjectToHClip(positionOS.xyz);
                if (_VertexSnap > 0.5) positionCS = SnapToPixelGrid(positionCS, _VirtualRes.xy);
                return positionCS;
            }
            half4 frag() : SV_Target { return 0; }
            ENDHLSL
        }
    }
}
// 复古像素风(PS1 风格):顶点吸附到低分辨率网格、仿射纹理映射(不做透视校正)、(GLSL 版,对应 Ch15/RetroPS1.shader)
// 逐顶点光照、点采样,以及“抖动 + 色深量化”。每一项都能单独开关,方便对比。
Shader "Cookbook/Ch15/RetroPS1"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4  _BaseMap_ST;
        uniform vec4  _BaseColor;
        uniform vec4  _VirtualRes;
        uniform float _VertexSnap, _Affine, _PointSample, _Quantize, _ColorBits;

        // 把顶点吸附到 _VirtualRes 分辨率的像素格上。
        // 先除 w 得到 NDC,取整后再乘回 w,保持深度与透视不变,只让 xy 跳格。
        vec4 SnapToPixelGrid(vec4 positionCS, vec2 res)
        {
            vec2 halfRes = res * 0.5;
            vec2 ndc = positionCS.xy / positionCS.w;
            ndc = floor(ndc * halfRes + 0.5) / halfRes;
            positionCS.xy = ndc * positionCS.w;
            return positionCS;
        }
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            GLSLPROGRAM
            uniform sampler2D _BaseMap;

            VARYING vec2 uv;          // 正常的(透视校正的)UV
            VARYING vec3 uvAffine;    // (uv * w, w),用来抵消透视校正
            VARYING vec3 lighting;    // 逐顶点光照(Gouraud)

            #ifdef VERTEX
            void main()
            {
                vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
                vec4 positionCS = TransformWorldToHClip(positionWS);
                if (_VertexSnap > 0.5) positionCS = SnapToPixelGrid(positionCS, _VirtualRes.xy);
                gl_Position = positionCS;

                uv = TransformTex(texcoord0.xy, _BaseMap_ST);
                // 透视校正插值对属性 A 实际做的是:插值 A/w 和 1/w 再相除。
                // 传入 A·w 和 w,两者都被“校正”后相除,w 抵消,剩下的就是屏幕空间线性插值的 A。
                uvAffine = vec3(uv * positionCS.w, positionCS.w);

                // 逐顶点 Lambert + 环境光:当年的硬件只在顶点上算光
                vec3 N = TransformObjectToWorldNormal(normalOS);
                Light L = GetMainLight();
                lighting = saturate(dot(N, L.direction)) * L.color + SampleSH(N);
            }
            #endif

            #ifdef FRAGMENT
            const float kBayer4[16] = float[16](
                 0.0,  8.0,  2.0, 10.0,
                12.0,  4.0, 14.0,  6.0,
                 3.0, 11.0,  1.0,  9.0,
                15.0,  7.0, 13.0,  5.0);

            // 先加一个有序抖动偏移,再截断到 bits 位:渐变变成规则的网点而不是色带
            vec3 DitherQuantize(vec3 c, vec2 pixel, float bits)
            {
                ivec2 p = ivec2(mod(pixel, 4.0));
                float threshold = (kBayer4[p.y * 4 + p.x] + 0.5) / 16.0 - 0.5;   // -0.5..0.5
                float levels = exp2(bits) - 1.0;
                return floor(saturate(c) * levels + 0.5 + threshold) / levels;
            }

            // HLSL 版用内联采样器 sampler_PointRepeat;GLSL 不能按调用换采样器,
            // 这里手动做“最近邻 + 最近 mip + 重复寻址”:先由导数估算 mip 级别,再 texelFetch
            vec4 SamplePointRepeat(sampler2D tex, vec2 st)
            {
                vec2 size0 = vec2(textureSize(tex, 0));
                vec2 dx = dFdx(st * size0), dy = dFdy(st * size0);
                float lod = 0.5 * log2(max(max(dot(dx, dx), dot(dy, dy)), 1e-8));
                int maxLod = int(log2(max(size0.x, size0.y)));
                int level = clamp(int(floor(lod + 0.5)), 0, maxLod);
                ivec2 size = textureSize(tex, level);
                ivec2 p = ivec2(fract(st) * vec2(size));
                return texelFetch(tex, clamp(p, ivec2(0), size - 1), level);
            }

            vec4 frag()
            {
                vec2 st = mix(uv, uvAffine.xy / uvAffine.z, _Affine);
                vec4 texLinear = texture(_BaseMap, st);
                vec4 texPoint  = SamplePointRepeat(_BaseMap, st);
                vec4 tex = mix(texLinear, texPoint, _PointSample);

                vec3 col = tex.rgb * _BaseColor.rgb * lighting;
                if (_Quantize > 0.5)
                {
                    // 抖动图案也按“虚拟分辨率”放大,模拟低分辨率帧缓冲上的抖动
                    vec2 virtualPixel = floor(gl_FragCoord.xy * _VirtualRes.xy / _ScreenParams.xy);
                    // 在 sRGB(显示)空间量化,色阶才均匀
                    col = SRGBToLinear(DitherQuantize(LinearToSRGB(col), virtualPixel, _ColorBits));
                }
                return vec4(col, 1.0);
            }
            void main() { fragColor = frag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            // 深度也要按同样方式吸附,否则深度图与颜色错位
            #ifdef VERTEX
            void main()
            {
                vec4 positionCS = TransformObjectToHClip(positionOS.xyz);
                if (_VertexSnap > 0.5) positionCS = SnapToPixelGrid(positionCS, _VirtualRes.xy);
                gl_Position = positionCS;
            }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = vec4(0.0); }
            #endif
            ENDGLSL
        }
    }
}

关键代码讲解:顶点吸附先除 w 到 NDC,按虚拟分辨率取整,再乘回 w。只动 xy,不动 z 和 w,所以深度和透视都不受影响;吸附函数写在 HLSLINCLUDE 里,DepthOnly Pass 也用同样的吸附,保证深度图和颜色对齐:

Assets/Cookbook/Ch15/RetroPS1.shader(节选:snap)
// 把顶点吸附到 _VirtualRes 分辨率的像素格上。
// 先除 w 得到 NDC,取整后再乘回 w,保持深度与透视不变,只让 xy 跳格。
float4 SnapToPixelGrid(float4 positionCS, float2 res)
{
    float2 halfRes = res * 0.5;
    float2 ndc = positionCS.xy / positionCS.w;
    ndc = floor(ndc * halfRes + 0.5) / halfRes;
    positionCS.xy = ndc * positionCS.w;
    return positionCS;
}
// 把顶点吸附到 _VirtualRes 分辨率的像素格上。
// 先除 w 得到 NDC,取整后再乘回 w,保持深度与透视不变,只让 xy 跳格。
vec4 SnapToPixelGrid(vec4 positionCS, vec2 res)
{
    vec2 halfRes = res * 0.5;
    vec2 ndc = positionCS.xy / positionCS.w;
    ndc = floor(ndc * halfRes + 0.5) / halfRes;
    positionCS.xy = ndc * positionCS.w;
    return positionCS;
}
Assets/Cookbook/Ch15/RetroPS1.shader(节选:affine-vert)
// 透视校正插值对属性 A 实际做的是:插值 A/w 和 1/w 再相除。
// 传入 A·w 和 w,两者都被“校正”后相除,w 抵消,剩下的就是屏幕空间线性插值的 A。
o.uvAffine = float3(o.uv * positionCS.w, positionCS.w);
// 透视校正插值对属性 A 实际做的是:插值 A/w 和 1/w 再相除。
// 传入 A·w 和 w,两者都被“校正”后相除,w 抵消,剩下的就是屏幕空间线性插值的 A。
uvAffine = vec3(uv * positionCS.w, positionCS.w);

量化部分用经典的 4×4 Bayer 矩阵。矩阵里 0~15 的排列保证了任意 2×2、4×4 子块内阈值都尽量分散,所以抖动出的网点均匀、没有明显的条纹:

Assets/Cookbook/Ch15/RetroPS1.shader(节选:quantize)
static const float kBayer4[16] = {
     0,  8,  2, 10,
    12,  4, 14,  6,
     3, 11,  1,  9,
    15,  7, 13,  5 };

// 先加一个有序抖动偏移,再截断到 bits 位:渐变变成规则的网点而不是色带
half3 DitherQuantize(half3 c, float2 pixel, float bits)
{
    uint2 p = (uint2)pixel % 4;
    float threshold = (kBayer4[p.y * 4 + p.x] + 0.5) / 16.0 - 0.5;   // -0.5..0.5
    float levels = exp2(bits) - 1.0;
    return floor(saturate(c) * levels + 0.5 + threshold) / levels;
}
const float kBayer4[16] = float[16](
     0.0,  8.0,  2.0, 10.0,
    12.0,  4.0, 14.0,  6.0,
     3.0, 11.0,  1.0,  9.0,
    15.0,  7.0, 13.0,  5.0);

// 先加一个有序抖动偏移,再截断到 bits 位:渐变变成规则的网点而不是色带
vec3 DitherQuantize(vec3 c, vec2 pixel, float bits)
{
    ivec2 p = ivec2(mod(pixel, 4.0));
    float threshold = (kBayer4[p.y * 4 + p.x] + 0.5) / 16.0 - 0.5;   // -0.5..0.5
    float levels = exp2(bits) - 1.0;
    return floor(saturate(c) * levels + 0.5 + threshold) / levels;
}

两个细节:① 抖动坐标也按虚拟分辨率放大(floor(positionCS.xy * _VirtualRes / _ScreenParams.xy)),所以抖动网点是“大像素”的,和低分辨率帧缓冲的感觉一致;② 量化在 sRGB 空间做(先 LinearToSRGB,量化后再 SRGBToLinear),因为当年的 5 位色是对显示值的量化。如果直接在线性空间量化,暗部会只有寥寥几级、亮部却很细腻,色阶分布完全不对。

图 15-11左:现代渲染(透视校正、双线性过滤);中:仿射映射 + 点采样,地面和墙面的砖缝在三角形对角线处明显折弯;右:再加上顶点吸附(虚拟分辨率 128×96)与 3 位色抖动量化
常见坑① 仿射扭曲的程度取决于三角形在屏幕上的大小和深度跨度。现代模型的三角形又小又密,仿射映射几乎看不出来——要复刻 PS1 的味道,地面和墙面必须用大三角形(本例的地板只有 8 个三角形)。② 顶点吸附在静态截图里不明显,它的真正效果是镜头移动时顶点“一格一格地跳”。③ 想要整个画面都是大像素,正确做法是渲染到低分辨率 RT 再点采样放大(第 26 章的像素化后处理),而不是在每个物体的 Shader 里模拟。本例的做法适合“只让某些物体复古”,或与低分辨率 RT 配合使用。

案例 15.6Matcap 风格化入门单独打开 ↗

目标:用一张“材质球照片”决定物体的全部外观;程序化生成几种风格的 Matcap,并修正 Matcap 在画面边缘的透视畸变。

原理:Matcap(Material Capture,也叫 Lit Sphere)的想法是:一个球面上包含了所有朝向的法线。如果把“某种材质在某种光照下的球”拍成一张图,那么任何物体上的任意一点,只要找到球上法线相同的那一点,取它的颜色就行了。具体到 Shader:把法线变换到观察空间,正对相机的半球投影到圆盘上,法线的 xy 分量就是圆盘上的坐标:

uv=0.5+0.5·Nview.xy

第 8 章把 Matcap 当作环境光照的一个对照;这一章关注它在风格化里的用法——陶土、黏土动画、卡通、金属漆,换一张图就换一种风格,而且一次采样就完成全部“光照”,在移动端几乎是最便宜的着色方式。

程序化生成 Matcap

既然 Matcap 是“球面上每个法线对应的颜色”,生成它只需要反过来:对圆盘内的每个像素反推出法线 n = (x, y, √(1 − x² − y²)),然后用任意着色函数算颜色:

Assets/Cookbook/Ch15/MatcapGenerator.cs(节选:gen)
public static Texture2D Build(int size, Func<Vector3, Color> shade, string name = "MatCap")
{
    var tex = new Texture2D(size, size, TextureFormat.RGBA32, true, false)
    { name = name, wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Trilinear };
    var px = new Color[size * size];
    for (int y = 0; y < size; y++)
        for (int x = 0; x < size; x++)
        {
            float u = (x + 0.5f) / size * 2f - 1f, v = (y + 0.5f) / size * 2f - 1f;
            float r2 = u * u + v * v;
            // 圆盘外的像素也给一个合理值(取边缘法线),防止双线性/mip 把黑边混进来
            float k = r2 > 1f ? 1f / Mathf.Sqrt(r2) : 1f;
            var n = new Vector3(u * k, v * k, Mathf.Sqrt(Mathf.Max(0f, 1f - r2 * k * k)));
            px[y * size + x] = shade(n);
        }
    tex.SetPixels(px); tex.Apply(true);
    return tex;
}

圆盘外的像素也给了“边缘法线”的颜色,因为双线性过滤和 mip 会采到圆盘外一点点,如果那里是黑色,物体轮廓就会出现一圈黑边。MatcapGenerator 里写了三种着色函数:陶土(柔和漫反射 + 冷色底部反光)、卡通(三阶色 + 硬高光 + 亮边)、金属漆(用反射向量在“天空/地平线/地面”之间取色,再染成金色)。你可以把任何第 5、9 章学过的光照模型写成这样的函数,“冻结”进一张贴图。

透视修正

朴素的 Matcap 有一个缺点:它假设相机是正交的。在透视相机下,画面边缘的物体,相机是“斜着”看它的,可是查表用的仍然是观察空间的法线——于是边缘的球看起来像被拧了一下,高光和地平线位置都不对。修正办法是:对每个像素,以“从这个点指向相机”的方向 V 作为局部的 z 轴,重新构造 x、y 轴,再把法线投影到这组轴上。这等于把每个像素都当成“正对相机的那一点”来查表。

相机 观察方向 −z 朴素:仍以 −z 为“正对” 修正:以 V 为“正对” 画面中心两者一致 X′ = normalize(Up − V(V·Up)) × V Y′ = normalize(Up − V(V·Up)) uv = 0.5 + 0.49(N·X′, N·Y′)
图 15-12Matcap 的透视修正:边缘物体用“指向相机的方向 V”作为查表的正前方
Assets/Cookbook/Ch15/MatcapStylized.shader(节选:matcap-uv)
float2 MatcapUV(float3 positionWS, float3 normalWS)
{
    float3 normalVS = normalize(TransformWorldToViewDir(normalWS));
    // 朴素做法:直接取观察空间法线的 xy
    float2 naive = normalVS.xy;

    // 修正:以“从该点指向相机”的方向 V 为 z 轴,重新构造 x/y 轴,
    // 等于把每个像素都当成“正对相机”的那一点来查表,画面边缘不再被拉扯
    float3 V = normalize(-TransformWorldToView(positionWS));   // 观察空间里相机在原点
    float3 up = normalize(float3(0, 1, 0) - V * V.y);          // 把世界“上”投影到垂直于 V 的平面
    float3 right = cross(up, V);
    float2 corrected = float2(dot(normalVS, right), dot(normalVS, up));

    float2 n = lerp(naive, corrected, _PerspectiveFix);
    return n * 0.49 + 0.5;    // 0.49 而非 0.5:避开贴图最外圈的边缘像素
}
vec2 MatcapUV(vec3 posWS, vec3 nrmWS)
{
    vec3 normalVS = normalize(TransformWorldToViewDir(nrmWS));
    // 朴素做法:直接取观察空间法线的 xy
    vec2 naive = normalVS.xy;

    // 修正:以“从该点指向相机”的方向 V 为 z 轴,重新构造 x/y 轴,
    // 等于把每个像素都当成“正对相机”的那一点来查表,画面边缘不再被拉扯
    vec3 V = normalize(-TransformWorldToView(posWS));        // 观察空间里相机在原点
    vec3 up = normalize(vec3(0, 1, 0) - V * V.y);            // 把世界“上”投影到垂直于 V 的平面
    vec3 right = cross(up, V);
    vec2 corrected = vec2(dot(normalVS, right), dot(normalVS, up));

    vec2 n = mix(naive, corrected, _PerspectiveFix);
    return n * 0.49 + 0.5;    // 0.49 而非 0.5:避开贴图最外圈的边缘像素
}

Unity 的观察空间是右手系、相机朝 −z 看,所以观察空间里的点坐标取反就是“点 → 相机”的方向。up 是把世界“上”方向(这里直接用观察空间的 (0,1,0))投影到垂直于 V 的平面上再归一化,right = cross(up, V)。在画面正中,V = (0,0,1),up = (0,1,0),right = (1,0,0),修正版和朴素版完全一致。缩放系数用 0.49 而不是 0.5,是为了离圆盘边缘留一点余量。

完整代码:

Assets/Cookbook/Ch15/MatcapStylized.shader
// Matcap(材质捕获):把“一个球在某种光照下的样子”存成贴图,用观察空间法线去查表。
// 修正了透视畸变(以视线方向为基准建立局部坐标系),并可选地叠加场景阴影。
Shader "Cookbook/Ch15/MatcapStylized"
{
    Properties
    {
        [NoScaleOffset] _MatCap ("MatCap", 2D) = "gray" {}
        _BaseColor ("Tint", Color) = (1, 1, 1, 1)
        [Toggle] _PerspectiveFix ("Perspective Correction", Float) = 1
        _ShadowStrength ("Receive Shadow Strength", 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;
            float _PerspectiveFix;
            half  _ShadowStrength;
        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(_MatCap); SAMPLER(sampler_MatCap);

            float2 MatcapUV(float3 positionWS, float3 normalWS)
            {
                float3 normalVS = normalize(TransformWorldToViewDir(normalWS));
                // 朴素做法:直接取观察空间法线的 xy
                float2 naive = normalVS.xy;

                // 修正:以“从该点指向相机”的方向 V 为 z 轴,重新构造 x/y 轴,
                // 等于把每个像素都当成“正对相机”的那一点来查表,画面边缘不再被拉扯
                float3 V = normalize(-TransformWorldToView(positionWS));   // 观察空间里相机在原点
                float3 up = normalize(float3(0, 1, 0) - V * V.y);          // 把世界“上”投影到垂直于 V 的平面
                float3 right = cross(up, V);
                float2 corrected = float2(dot(normalVS, right), dot(normalVS, up));

                float2 n = lerp(naive, corrected, _PerspectiveFix);
                return n * 0.49 + 0.5;    // 0.49 而非 0.5:避开贴图最外圈的边缘像素
            }

            half4 frag(NprVaryings i) : SV_Target
            {
                float3 N = normalize(i.normalWS);
                half3 col = SAMPLE_TEXTURE2D(_MatCap, sampler_MatCap, MatcapUV(i.positionWS, N)).rgb * _BaseColor.rgb;
                // Matcap 不认识场景里的灯,阴影只能“乘”上去:这是风格化里常见的折中
                NprLight L = GetNprLight(i.positionWS, N);
                col *= lerp(1.0h, L.shadow, _ShadowStrength);
                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
        }
    }
}
// Matcap(材质捕获):把“一个球在某种光照下的样子”存成贴图,用观察空间法线去查表。(GLSL 版,对应 Ch15/MatcapStylized.shader)
// 修正了透视畸变(以视线方向为基准建立局部坐标系),并可选地叠加场景阴影。
Shader "Cookbook/Ch15/MatcapStylized"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _BaseColor;
        uniform float _PerspectiveFix;
        uniform float _ShadowStrength;
        ENDGLSL

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

            uniform sampler2D _MatCap;

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

            #ifdef FRAGMENT
            vec2 MatcapUV(vec3 posWS, vec3 nrmWS)
            {
                vec3 normalVS = normalize(TransformWorldToViewDir(nrmWS));
                // 朴素做法:直接取观察空间法线的 xy
                vec2 naive = normalVS.xy;

                // 修正:以“从该点指向相机”的方向 V 为 z 轴,重新构造 x/y 轴,
                // 等于把每个像素都当成“正对相机”的那一点来查表,画面边缘不再被拉扯
                vec3 V = normalize(-TransformWorldToView(posWS));        // 观察空间里相机在原点
                vec3 up = normalize(vec3(0, 1, 0) - V * V.y);            // 把世界“上”投影到垂直于 V 的平面
                vec3 right = cross(up, V);
                vec2 corrected = vec2(dot(normalVS, right), dot(normalVS, up));

                vec2 n = mix(naive, corrected, _PerspectiveFix);
                return n * 0.49 + 0.5;    // 0.49 而非 0.5:避开贴图最外圈的边缘像素
            }

            vec4 frag()
            {
                vec3 N = normalize(normalWS);
                vec3 col = texture(_MatCap, MatcapUV(positionWS, N)).rgb * _BaseColor.rgb;
                // Matcap 不认识场景里的灯,阴影只能“乘”上去:这是风格化里常见的折中
                NprLight L = GetNprLight(positionWS, N);
                col *= mix(1.0, L.shadow, _ShadowStrength);
                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
        }
    }
}
图 15-13同一个 Shader、三张程序生成的 Matcap(左上角为贴图本身):陶土、卡通、金色金属漆。地面阴影通过乘法叠加到 Matcap 上
图 15-1490° 广角下的 15 个球。左:朴素 Matcap,边缘的球的地平线和高光被“拧”向画面中心;右:透视修正后,每个球看起来都像是被正对着观察
常见坑① Matcap 的光照是“锁在相机上”的:相机绕物体转一圈,高光永远在同一个位置。这在风格化里通常可以接受,但对于需要“光从窗户照进来”的场景,就必须配合真实光照。② Matcap 不认识场景里的灯,阴影只能像本例这样乘上去,这会让暗部发脏,可以改成 lerp(col, col * shadowColor, 1 - shadow) 指定一个阴影色。③ 叠加法线贴图时,必须先用 TBN 把切线空间法线转到世界/观察空间,再查 Matcap,否则凹凸方向全乱。

案例 15.7低多边形 flat shading:用 ddx/ddy 求面法线入门单独打开 ↗

目标:不修改网格,让光滑物体呈现出“一面一色”的低多边形(Low Poly)风格。

原理:常规做法是把网格拆开,让每个三角形独占三个顶点、写入相同的面法线。这会把顶点数变成三角形数的三倍,而且必须在建模或导入阶段处理。另一种做法完全在片元着色器里完成:GPU 以 2×2 的像素块(Quad)为单位执行片元着色器,ddx(v) 返回同一块中右邻像素与当前像素的 v 之差,ddy(v) 返回下(或上)邻像素之差。对世界坐标求导:

∂P∂x≈ddx(P),∂P∂y≈ddy(P),N=normalize(ddy(P)×ddx(P))

两个差向量都是“沿屏幕横向/纵向走一个像素时,世界坐标移动了多少”,它们都躺在三角形所在的平面内,叉乘就得到平面的法线。而世界坐标在一个三角形内部是线性插值的(透视校正后依然在同一平面上),所以无论在三角形哪个位置求,方向都一样——整面常量,正是 flat shading 需要的。

P(x,y)P(x+1,y)P(x,y+1) 一个 2×2 像素块(Quad) ddx(P) = P(x+1,y) − P(x,y) ddy(P) = P(x,y+1) − P(x,y) 同一块里的 4 个像素共享这两个差值 ddx ddy 两者都在三角形平面内
图 15-15屏幕空间导数:ddx/ddy 是相邻像素的差,叉乘得到三角形平面的法线

叉乘的方向是这里唯一的坑。ddy 是“屏幕 y 增大的方向”,而屏幕 y 轴到底朝上还是朝下,取决于平台(D3D/Metal 的像素坐标原点在左上,OpenGL 在左下)以及当前是否在往 RT 里渲染(URP 会翻转投影矩阵,见第 1 章的 _ProjectionParams.x)。与其逐一判断,不如利用一个简单的事实:我们看到的正面,法线一定朝向相机。算出叉乘后,和视线方向点乘,如果是负的就翻过来:

Assets/Cookbook/Ch15/FlatShading.shader(节选:facenormal)
float3 FaceNormal(float3 positionWS)
{
    // ddx/ddy:相邻像素之间世界坐标的差,两者都躺在三角形平面内,叉乘就是面法线。
    // 同一个 2×2 像素块里共享导数,而三角形内部世界坐标是线性的,所以结果是“整面常量”。
    float3 n = normalize(cross(ddy(positionWS), ddx(positionWS)));
    // 屏幕 y 轴朝上还是朝下因平台/是否渲染到 RT 而异,叉乘方向可能反过来;
    // 正面一定朝向相机,所以用视线方向把它翻正,最省心
    float3 V = _WorldSpaceCameraPos - positionWS;
    return dot(n, V) < 0 ? -n : n;
}
vec3 FaceNormal(vec3 posWS)
{
    // ddx/ddy:相邻像素之间世界坐标的差,两者都躺在三角形平面内,叉乘就是面法线。
    // 同一个 2×2 像素块里共享导数,而三角形内部世界坐标是线性的,所以结果是“整面常量”。
    vec3 n = normalize(cross(dFdy(posWS), dFdx(posWS)));
    // 屏幕 y 轴朝上还是朝下因平台/是否渲染到 RT 而异,叉乘方向可能反过来;
    // 正面一定朝向相机,所以用视线方向把它翻正,最省心
    vec3 V = _WorldSpaceCameraPos - posWS;
    return dot(n, V) < 0.0 ? -n : n;
}

演示场景是一块在顶点着色器里用噪声抬高的地形:顶点按世界 xz 采样噪声得到高度,用有限差分求出平滑法线(下一章顶点动画会详细讲这个方法),低于水位的部分压平。颜色按高度和坡度分带——注意代码里的颜色常量最后经过了 SRGBToLinear:在线性空间的工程里,Shader 里写死的颜色常量会被当作线性值直接参与运算,而我们挑颜色时是按“显示器上看起来”的 sRGB 值挑的,不转换的话所有颜色都会偏亮偏灰。材质面板上的 Color 属性 Unity 会自动转换,写死的常量不会。

Assets/Cookbook/Ch15/FlatShading.shader
// 低多边形(Low Poly)flat shading:不改网格,在片元里用 ddx/ddy 求三角形的面法线。
// 演示场景是一块在顶点着色器里用噪声抬高的地形,可以在“平滑法线 / 面法线”之间切换。
Shader "Cookbook/Ch15/FlatShading"
{
    Properties
    {
        _BaseColor ("Base Color (non-terrain)", Color) = (0.9, 0.55, 0.3, 1)
        [Toggle] _Flat ("Flat Shading (ddx/ddy)", Float) = 1
        [Toggle] _Terrain ("Terrain Mode", Float) = 0
        [NoScaleOffset] _HeightTex ("Height Noise", 2D) = "gray" {}
        _HeightScale ("Height", Range(0, 4)) = 1.6
        _NoiseTiling ("Noise Tiling", Float) = 0.08
        _WaterLevel ("Water Level", Float) = -0.6
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor;
            float _Flat, _Terrain, _HeightScale, _NoiseTiling, _WaterLevel;
        CBUFFER_END
        TEXTURE2D(_HeightTex); SAMPLER(sampler_HeightTex);

        // 地形高度:世界 xz 采样噪声。顶点着色器里必须用 LOD 版本采样(没有导数可用)
        float TerrainHeight(float2 xz)
        {
            float h = SAMPLE_TEXTURE2D_LOD(_HeightTex, sampler_HeightTex, xz * _NoiseTiling, 0).r;
            h = (h - 0.45) * _HeightScale * 2.0;
            return max(h, _WaterLevel);     // 低于水位的部分压平成水面
        }

        // 所有 Pass 共用的顶点变形:地形模式下抬高顶点,并用有限差分算出平滑法线
        void Deform(inout float3 positionWS, inout float3 normalWS)
        {
            if (_Terrain < 0.5) return;
            const float e = 0.05;
            float h  = TerrainHeight(positionWS.xz);
            float hx = TerrainHeight(positionWS.xz + float2(e, 0));
            float hz = TerrainHeight(positionWS.xz + float2(0, e));
            positionWS.y += h;
            normalWS = normalize(float3(h - hx, e, h - hz));
        }
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            HLSLPROGRAM
            #pragma vertex vert
            #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"

            NprVaryings vert(NprAttributes v)
            {
                NprVaryings o;
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                float3 normalWS = TransformObjectToWorldNormal(v.normalOS);
                Deform(positionWS, normalWS);
                o.positionWS = positionWS;
                o.normalWS = normalWS;
                o.positionCS = TransformWorldToHClip(positionWS);
                o.uv = v.uv; o.color = v.color; o.fogFactor = 0;
                return o;
            }

            float3 FaceNormal(float3 positionWS)
            {
                // ddx/ddy:相邻像素之间世界坐标的差,两者都躺在三角形平面内,叉乘就是面法线。
                // 同一个 2×2 像素块里共享导数,而三角形内部世界坐标是线性的,所以结果是“整面常量”。
                float3 n = normalize(cross(ddy(positionWS), ddx(positionWS)));
                // 屏幕 y 轴朝上还是朝下因平台/是否渲染到 RT 而异,叉乘方向可能反过来;
                // 正面一定朝向相机,所以用视线方向把它翻正,最省心
                float3 V = _WorldSpaceCameraPos - positionWS;
                return dot(n, V) < 0 ? -n : n;
            }

            half3 TerrainColor(float y, float3 N)
            {
                half3 c = half3(0.10, 0.32, 0.62);                                         // 水
                c = lerp(c, half3(0.80, 0.68, 0.42), step(_WaterLevel + 0.02, y));         // 沙
                c = lerp(c, half3(0.30, 0.55, 0.18), step(_WaterLevel + 0.25, y));         // 草
                c = lerp(c, half3(0.42, 0.37, 0.33), step(0.3, y) * step(N.y, 0.9));      // 陡坡变岩石
                c = lerp(c, half3(0.92, 0.93, 0.96), step(1.3, y));                        // 雪
                // 上面这些常量是按“显示器上看到的颜色”(sRGB)挑的,线性空间工程里要先转成线性值
                return SRGBToLinear(c);
            }

            half4 frag(NprVaryings i) : SV_Target
            {
                float3 N = _Flat > 0.5 ? FaceNormal(i.positionWS) : normalize(i.normalWS);
                NprLight L = GetNprLight(i.positionWS, N);
                half3 albedo = _Terrain > 0.5 ? TerrainColor(i.positionWS.y, N) : _BaseColor.rgb;
                half3 col = albedo * (saturate(L.ndotl) * L.shadow * L.color + SampleSH(N));
                return half4(col, 1);
            }
            ENDHLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment NprNullFrag
            #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"
            float4 vert(NprShadowAttributes v) : SV_POSITION
            {
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                float3 normalWS = TransformObjectToWorldNormal(v.normalOS);
                Deform(positionWS, normalWS);   // 阴影要和颜色 Pass 用同样的变形
                float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, _LightDirection));
            #if UNITY_REVERSED_Z
                positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE);
            #else
                positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE);
            #endif
                return positionCS;
            }
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment NprNullFrag
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"
            float4 vert(NprShadowAttributes v) : SV_POSITION
            {
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                float3 normalWS = TransformObjectToWorldNormal(v.normalOS);
                Deform(positionWS, normalWS);
                return TransformWorldToHClip(positionWS);
            }
            ENDHLSL
        }
    }
}
// 低多边形(Low Poly)flat shading:不改网格,在片元里用 ddx/ddy 求三角形的面法线。(GLSL 版,对应 Ch15/FlatShading.shader)
// 演示场景是一块在顶点着色器里用噪声抬高的地形,可以在“平滑法线 / 面法线”之间切换。
Shader "Cookbook/Ch15/FlatShading"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _BaseColor;
        uniform float _Flat, _Terrain, _HeightScale, _NoiseTiling, _WaterLevel;
        uniform sampler2D _HeightTex;

        // 地形高度:世界 xz 采样噪声。顶点着色器里必须用 LOD 版本采样(没有导数可用)
        float TerrainHeight(vec2 xz)
        {
            float h = textureLod(_HeightTex, xz * _NoiseTiling, 0.0).r;
            h = (h - 0.45) * _HeightScale * 2.0;
            return max(h, _WaterLevel);     // 低于水位的部分压平成水面
        }

        // 所有 Pass 共用的顶点变形:地形模式下抬高顶点,并用有限差分算出平滑法线
        void Deform(inout vec3 posWS, inout vec3 nrmWS)
        {
            if (_Terrain < 0.5) return;
            const float e = 0.05;
            float h  = TerrainHeight(posWS.xz);
            float hx = TerrainHeight(posWS.xz + vec2(e, 0));
            float hz = TerrainHeight(posWS.xz + vec2(0, e));
            posWS.y += h;
            nrmWS = normalize(vec3(h - hx, e, h - hz));
        }
        ENDGLSL

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

            #ifdef VERTEX
            void main()
            {
                vec3 posWS = TransformObjectToWorld(positionOS.xyz);
                vec3 nrmWS = TransformObjectToWorldNormal(normalOS);
                Deform(posWS, nrmWS);
                positionWS = posWS;
                normalWS = nrmWS;
                gl_Position = TransformWorldToHClip(posWS);
                uv = texcoord0.xy; vertexColor = color; fogFactor = 0.0;
            }
            #endif

            #ifdef FRAGMENT
            vec3 FaceNormal(vec3 posWS)
            {
                // ddx/ddy:相邻像素之间世界坐标的差,两者都躺在三角形平面内,叉乘就是面法线。
                // 同一个 2×2 像素块里共享导数,而三角形内部世界坐标是线性的,所以结果是“整面常量”。
                vec3 n = normalize(cross(dFdy(posWS), dFdx(posWS)));
                // 屏幕 y 轴朝上还是朝下因平台/是否渲染到 RT 而异,叉乘方向可能反过来;
                // 正面一定朝向相机,所以用视线方向把它翻正,最省心
                vec3 V = _WorldSpaceCameraPos - posWS;
                return dot(n, V) < 0.0 ? -n : n;
            }

            vec3 TerrainColor(float y, vec3 N)
            {
                vec3 c = vec3(0.10, 0.32, 0.62);                                           // 水
                c = mix(c, vec3(0.80, 0.68, 0.42), step(_WaterLevel + 0.02, y));           // 沙
                c = mix(c, vec3(0.30, 0.55, 0.18), step(_WaterLevel + 0.25, y));           // 草
                c = mix(c, vec3(0.42, 0.37, 0.33), step(0.3, y) * step(N.y, 0.9));        // 陡坡变岩石
                c = mix(c, vec3(0.92, 0.93, 0.96), step(1.3, y));                          // 雪
                // 上面这些常量是按“显示器上看到的颜色”(sRGB)挑的,线性空间工程里要先转成线性值
                return SRGBToLinear(c);
            }

            vec4 frag()
            {
                vec3 N = _Flat > 0.5 ? FaceNormal(positionWS) : normalize(normalWS);
                NprLight L = GetNprLight(positionWS, N);
                vec3 albedo = _Terrain > 0.5 ? TerrainColor(positionWS.y, N) : _BaseColor.rgb;
                vec3 col = albedo * (saturate(L.ndotl) * L.shadow * L.color + SampleSH(N));
                return vec4(col, 1.0);
            }
            void main() { fragColor = frag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            GLSLPROGRAM
            #include "Ch15/NPRCommon.glsl"
            #ifdef VERTEX
            void main()
            {
                vec3 posWS = TransformObjectToWorld(positionOS.xyz);
                vec3 nrmWS = TransformObjectToWorldNormal(normalOS);
                Deform(posWS, nrmWS);   // 阴影要和颜色 Pass 用同样的变形
                vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(posWS, nrmWS, _LightDirection));
                positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE * positionCS.w);   // WebGL 不反转深度
                gl_Position = positionCS;
            }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = NprNullFrag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #include "Ch15/NPRCommon.glsl"
            #ifdef VERTEX
            void main()
            {
                vec3 posWS = TransformObjectToWorld(positionOS.xyz);
                vec3 nrmWS = TransformObjectToWorldNormal(normalOS);
                Deform(posWS, nrmWS);
                gl_Position = TransformWorldToHClip(posWS);
            }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = vec4(gl_FragCoord.z); }
            #endif
            ENDGLSL
        }
    }
}
图 15-16同一块地形网格、同一个 Shader。左:插值的平滑法线;右:片元里用 ddx/ddy 求出的面法线,每个三角形一种明暗,右上角的二十面体球也变成了多面体
常见坑① 导数是在 2×2 像素块里算的,三角形边缘的像素块可能有一部分像素落在三角形外(GPU 会为它们执行“辅助像素”以便求导),它们的世界坐标是外插出来的,但仍在同一平面上,所以面法线依旧正确。② 在移动端,如果 positionWS 用 half 精度插值,远离原点的物体世界坐标精度不够,ddx 的结果会抖动成噪点。面法线计算一定要用 float,必要时改用相对相机的坐标(positionWS − _WorldSpaceCameraPos)求导。③ 用面法线时,顶点法线就只在阴影偏移(ShadowCaster 的 Normal Bias)里起作用了,两者不一致可能导致少量阴影痤疮,适当调大 Depth Bias 即可。
✓ 适合 ddx/ddy
运行时决定是否 flat(切换风格、调试);不想增加顶点数;程序化生成或顶点动画的网格(变形后的面法线自动正确)。
✗ 更适合拆网格
需要在顶点阶段就用到面法线(例如沿面法线外扩);目标平台对片元导数精度不友好;需要逐面的顶点色或 UV。

案例 15.8重心坐标线框与全息效果进阶单独打开 ↗

目标:在不使用几何着色器的前提下画出恒定像素宽度的三角形线框,支持隐藏四边形对角线,并做成发光的全息风格。

原理:三角形内任意一点 P 都可以写成三个顶点的加权和 P = λ0P0 + λ1P1 + λ2P2,权重 (λ0, λ1, λ2) 非负且和为 1,这就是重心坐标。在 P1P2 这条边上 λ0 = 0,另外两条边同理。所以只要片元拿到自己的重心坐标,min(λ0, λ1, λ2) 接近 0 的地方就是边。

怎么让片元拿到重心坐标?如果三个顶点分别带上 (1,0,0)、(0,1,0)、(0,0,1) 这样的属性,光栅化插值出来的就正好是重心坐标。常见教程用几何着色器(Geometry Shader)在运行时给三角形的三个顶点分配这三个值,但 Metal 不支持几何着色器,很多移动 GPU 上它也非常慢,URP 的 Lit、Unlit 等主力 Shader 都不依赖它。替代方案是把重心坐标预先烘焙进顶点色。

(1,0,0)(0,1,0)(0,0,1) 内部:三个分量都 > 0 λ₀ = 0 四边形 = 两个三角形 对角线:两个三角形的“最长边”是同一条 把对面顶点那一维在三个顶点上都设为 1
图 15-17左:顶点色里的重心坐标,边上对应分量为 0;右:隐藏四边形对角线的思路

烘焙必须拆开共享顶点:一个被 6 个三角形共享的顶点,在不同三角形里扮演不同的“角”,需要不同的顶点色。所以烘焙后的网格顶点数 = 三角形数 × 3。对于线框/全息这种通常用在少量物体上的效果,这个代价可以接受。

Assets/Cookbook/Ch15/Editor/BarycentricBaker.cs(节选:bake)
public static Mesh Bake(Mesh src, bool hideQuadDiagonals)
{
    var srcV = src.vertices; var srcN = src.normals; var srcUV = src.uv;
    var tris = src.triangles;
    int triCount = tris.Length / 3;

    // 每个三角形里“最长边”的对面顶点(0/1/2);对四边形拆出的三角形,最长边就是对角线
    var longestOpp = new int[triCount];
    var diagonalOf = new Dictionary<(Vector3, Vector3), List<int>>();
    for (int t = 0; t < triCount; t++)
    {
        float best = -1; int opp = 0;
        for (int k = 0; k < 3; k++)
        {
            Vector3 a = srcV[tris[t * 3 + (k + 1) % 3]], b = srcV[tris[t * 3 + (k + 2) % 3]];
            float len = (a - b).sqrMagnitude;
            if (len > best) { best = len; opp = k; }
        }
        longestOpp[t] = opp;
        var key = EdgeKey(srcV[tris[t * 3 + (opp + 1) % 3]], srcV[tris[t * 3 + (opp + 2) % 3]]);
        if (!diagonalOf.TryGetValue(key, out var list)) diagonalOf[key] = list = new List<int>();
        list.Add(t);
    }

    var v = new Vector3[tris.Length]; var n = new Vector3[tris.Length];
    var uv = new Vector2[tris.Length]; var col = new Color[tris.Length];
    var idx = new int[tris.Length];
    for (int t = 0; t < triCount; t++)
    {
        // 两个相邻三角形的最长边是同一条边 → 它俩拼成一个四边形,这条边是对角线
        int opp = longestOpp[t];
        var key = EdgeKey(srcV[tris[t * 3 + (opp + 1) % 3]], srcV[tris[t * 3 + (opp + 2) % 3]]);
        bool hide = hideQuadDiagonals && diagonalOf[key].Count == 2;

        for (int k = 0; k < 3; k++)
        {
            int i = t * 3 + k, s = tris[i];
            v[i] = srcV[s];
            n[i] = srcN.Length > 0 ? srcN[s] : Vector3.up;
            uv[i] = srcUV.Length > 0 ? srcUV[s] : Vector2.zero;
            var c = new Color(k == 0 ? 1 : 0, k == 1 ? 1 : 0, k == 2 ? 1 : 0, 1);
            // 对角线上“对面顶点那一维”恒为 0;把这一维在三个顶点上都设成 1,它就永远不会接近 0
            if (hide) c[opp] = 1;
            col[i] = c;
            idx[i] = i;
        }
    }
    var m = new Mesh { name = src.name + "_Bary", indexFormat = IndexFormat.UInt32 };
    m.vertices = v; m.normals = n; m.uv = uv; m.colors = col; m.triangles = idx;
    m.RecalculateBounds();
    return m;
}

// 无向边的键:用量化后的位置,避免硬边处“同位置不同顶点”导致找不到邻居
static (Vector3, Vector3) EdgeKey(Vector3 a, Vector3 b)
{
    a = Quant(a); b = Quant(b);
    return (a.x < b.x || (a.x == b.x && (a.y < b.y || (a.y == b.y && a.z < b.z)))) ? (a, b) : (b, a);
}
static Vector3 Quant(Vector3 p) => new Vector3(Mathf.Round(p.x * 1e4f), Mathf.Round(p.y * 1e4f), Mathf.Round(p.z * 1e4f));

隐藏对角线:很多模型(立方体、UV 球、地面网格)在建模软件里是四边形,导入后被拆成两个三角形,线框里会多出一条斜线。判断方法是:一个三角形的最长边,如果恰好也是相邻三角形的最长边,那这两个三角形拼成了一个四边形,这条边就是对角线。要让对角线不画出来,只需让“对面顶点那一维”在整个三角形里恒为 1(原来是顶点处 1、对边上 0),这一维就永远不会接近 0。边的匹配用量化后的位置做键,因为立方体这类硬边模型在同一位置有多个顶点(法线不同),按索引是找不到邻居的。

Shader 部分很短。关键是用 fwidth 把阈值换算成“像素”单位:

Assets/Cookbook/Ch15/BaryWireframe.shader(节选:wire)
// 返回 0..1 的“线框覆盖度”。bary 来自顶点色插值。
float WireFactor(float3 bary, float widthPixels)
{
    float3 d = fwidth(bary);                    // 每个分量在一个像素内的变化量
    float3 a = smoothstep(d * (widthPixels - 0.5), d * (widthPixels + 0.5), bary);
    return 1.0 - min(a.x, min(a.y, a.z));       // 任一分量接近 0 → 在边上
}
// 返回 0..1 的“线框覆盖度”。bary 来自顶点色插值。
float WireFactor(vec3 bary, float widthPixels)
{
    vec3 d = fwidth(bary);                      // 每个分量在一个像素内的变化量
    vec3 a = smoothstep(d * (widthPixels - 0.5), d * (widthPixels + 0.5), bary);
    return 1.0 - min(a.x, min(a.y, a.z));       // 任一分量接近 0 → 在边上
}

fwidth(bary) = |ddx| + |ddy|,表示重心坐标在一个像素内变化多少。bary / fwidth(bary) 就是“到边的像素距离”,在它等于 _WireWidth 附近做一个一像素宽的 smoothstep,线宽就与距离、三角形大小都无关,而且自带抗锯齿。

全息版本换成叠加混合(Blend One One)、关闭深度写入、双面渲染,背面的法线用 IS_FRONT_VFACE 翻转后再算菲涅尔。扫描线是世界 y 方向上的周期亮带,相位由 _Time.y * _ScanSpeed + _Phase 决定——运行时随时间滚动,拍静帧时用 _Phase 摆位置:

Assets/Cookbook/Ch15/HoloWireframe.shader(节选:frag)
half4 frag(NprVaryings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target
{
    // 双面渲染时,背面的法线要翻过来,菲涅尔才对
    float3 N = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0, -1.0);
    float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    half fresnel = pow(1.0h - saturate(dot(N, V)), _FresnelPower);

    float wire = WireFactor(i.color.rgb, _WireWidth);
    // 扫描线:世界 Y 方向的周期亮带;静帧用 _Phase,运行时随 _Time 移动
    float t = _Time.y * _ScanSpeed + _Phase;
    half scan = pow(saturate(sin((i.positionWS.y - t) * _ScanDensity) * 0.5 + 0.5), 6);

    half3 col = _WireColor.rgb * wire + _FillColor.rgb * (fresnel + 0.15h * scan) + _WireColor.rgb * scan * 0.08h;
    col *= IS_FRONT_VFACE(face, 1.0, _BackFade);   // 背面暗一些,增加层次
    return half4(col, 1);
}
vec4 frag()
{
    // 双面渲染时,背面的法线要翻过来,菲涅尔才对(HLSL 的 IS_FRONT_VFACE → gl_FrontFacing)
    vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    float fresnel = pow(1.0 - saturate(dot(N, V)), _FresnelPower);

    float wire = WireFactor(vertexColor.rgb, _WireWidth);
    // 扫描线:世界 Y 方向的周期亮带;静帧用 _Phase,运行时随 _Time 移动
    float t = _Time.y * _ScanSpeed + _Phase;
    float scan = pow(saturate(sin((positionWS.y - t) * _ScanDensity) * 0.5 + 0.5), 6.0);

    vec3 col = _WireColor.rgb * wire + _FillColor.rgb * (fresnel + 0.15 * scan) + _WireColor.rgb * scan * 0.08;
    col *= gl_FrontFacing ? 1.0 : _BackFade;   // 背面暗一些,增加层次
    return vec4(col, 1.0);
}
图 15-18左:烘焙重心坐标后的三角形线框(UV 球、立方体、二十面体球);中:隐藏四边形对角线后的“四边形线框”;右:全息风格——叠加混合、双面、菲涅尔填充、扫描线,并开启 Bloom 后处理
常见坑① 顶点色被占用后,原来依赖顶点色的功能(比如第 14 章烘焙的平滑法线)要换到别的 UV 通道;重心坐标本身也可以放进任意一个 UV 通道,不一定非用 COLOR。② 拆开共享顶点后,蒙皮网格同样可用(每个新顶点复制原顶点的骨骼权重即可),但本例的烘焙器没处理骨骼权重。③ 全息效果不写深度,排在透明队列里,和其他透明物体之间有排序问题,下一章会详细讨论。
提示在 Editor 里使用烘焙器:选中一个网格资源或带 MeshFilter 的物体,执行 Tools › Cookbook › Ch15 › Bake Barycentric Mesh,保存成 .asset 后替换原网格即可。

本章小结

  • 大多数 NPR 效果都可以拆成“光照原料 → 色调映射 → 介质 → 线条”,风格的差异主要在后三步;原料阶段尽量保留未截断的 N·L 等信息。
  • 介质放在物体空间还是屏幕空间是核心取舍:前者能描述形体但有接缝和拉伸,后者大小恒定但有浴帘效应。
  • Gooch 用冷暖插值代替明暗,让背光面保持可读;TAM 用嵌套的笔触贴图加帐篷形权重实现连续的素描色调。
  • 半调网点的半径与墨量的平方根成正比,CMYK 各版旋转不同网角避免摩尔纹;fwidth 是程序化图形抗锯齿的通用工具。
  • 水彩可以用 Beer–Lambert 式的“透射率^浓度”做减色混合,再叠加噪声晕染、水边线、轮廓堆积和颗粒感。
  • PS1 风格来自硬件局限:顶点吸附模拟无子像素精度,传 uv·w 与 w 再相除抵消透视校正,sRGB 空间里的 Bayer 抖动量化模拟低色深。
  • Matcap 一次采样完成“光照”,透视修正以每个像素指向相机的方向为基准重建查表坐标。
  • ddx/ddy 叉乘得到面法线,用“正面朝向相机”来统一方向;重心坐标烘焙进顶点色可以替代几何着色器画线框。

练习

  1. 给 Gooch Shader 加一个 _ShadowTint,让投影区域不只是“偏冷”,而是混入指定的阴影色;比较它与原实现在冷暖端点很接近时的差异。
  2. 为素描 Shader 实现“笔触粗细随距离补偿”:用 fwidth(uv) 估计屏幕上的纹素密度,在 uv 和 uv × 2 两套平铺之间按 log2 密度混合,使屏幕上的笔触宽度大致恒定。
  3. 把半调网点改成“线条网”(Line Screen):每格画一条宽度随墨量变化的直线,而不是圆点。思考:线宽与墨量应该是什么关系?
  4. 给水彩 Shader 增加“干刷”效果:在纸纹凸起处(grain 高的位置)随机让颜料浓度降到 0,模拟笔刷擦过粗糙纸面时留下的白点。
  5. 在 RetroPS1 Shader 里加入 PS1 式的“顶点雾”:在顶点着色器里按距离计算雾因子并插值,再与量化结合,观察雾在大三角形上的色带。
  6. 修改 BarycentricBaker,让它把重心坐标写进 UV3 而不是顶点色,并保留原网格的顶点色;同时为 BaryWireframe 增加“只在背面画虚线”的效果。