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

溶解、全息与护盾

游戏里最常被点名的那几种“特效 Shader”,拆开看都是同一套积木:一张遮罩决定哪里有效果,一条边带决定效果从哪里开始,一个 HDR 颜色让它发光,再加一个随时间推进的参数。本章用十个案例把这套积木玩熟。

本章你会学到
用噪声、方向、距离三种“溶解值”做 Alpha Test 溶解,让屏幕、阴影、深度三处的镂空完全一致
菲涅尔边缘光的原理与经验写法,以及它在全息、护盾、冰面里的复用
在透明 Pass 中读取 _CameraDepthTexture 做交接高亮,读取 _CameraOpaqueTexture 做扭曲隐身
六边形网格、受击涟漪、世界空间扫描线、三平面噪声覆盖等常用图案的数学
HDR 发光与 Bloom 的关系,以及这类效果在移动端的性能注意事项

18.1 效果 Shader 的共同结构

先别急着写代码。把本章的效果摆在一起看,你会发现它们几乎都能拆成四步:

  1. 遮罩(Mask):一个 0~1 的数,回答“这个像素有没有效果、效果有多强”。溶解里它是噪声值,护盾里它是“离别的物体有多近”,冰冻里它是“法线有多朝上”。
  2. 边带(Edge Band):拿遮罩和一个阈值比较,阈值附近的一条窄带就是“效果的前沿”。溶解的火边、扫描线的亮线、受击涟漪的光环,都是边带。
  3. 着色:边带内给一个 HDR 颜色(分量可以大于 1),配合后处理 Bloom 让它“溢光”;边带外按正常光照或特殊样式着色。
  4. 推进:一个随时间变化的参数(进度、相位、扫描高度)推动阈值移动,效果就动起来了。
遮罩 Mask 噪声/距离/法线 阈值与边带 clip / smoothstep 着色 HDR 颜色 + 光照 混合与后处理 AlphaTest/加法/Bloom 进度 / 相位 / 时间 一张遮罩 → 一条边带 → 一个发光色 → 一个推进参数
图 18-1本章所有效果的共同结构。换一种遮罩,就换了一种效果。

理解了这个结构,本章十个案例其实是在回答“遮罩从哪来”。还有两件基础工作先交代清楚。

一个够用的简易光照

本章的重点是效果,不是光照模型。为了让代码短一些,所有需要“正常着色”的 Shader 都调用同一个 SimpleLighting:主光的 Lambert 漫反射 + Blinn-Phong 高光 + 实时阴影 + 球谐环境光。它用到的 GetMainLight(shadowCoord)、TransformWorldToShadowCoord、SampleSH 都是 URP 14 Lighting.hlsl 一系列头文件里的现成函数,在第 5、6、8 章已经逐个讲过。真实项目里你可以把它换成 UniversalFragmentPBR(第 10 章),效果部分的代码一行都不用改。

Assets/Cookbook/Ch18/Ch18Common.hlsl(节选:lighting)
half3 SimpleLighting(half3 albedo, float3 positionWS, half3 normalWS, half smoothness)
{
    float4 shadowCoord = TransformWorldToShadowCoord(positionWS);
    Light mainLight = GetMainLight(shadowCoord);
    half3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);

    half NdotL = saturate(dot(normalWS, mainLight.direction));
    half3 halfDir = SafeNormalize(mainLight.direction + viewWS);
    // 光滑度 0~1 映射到高光指数 2~2048,乘 smoothness 让粗糙表面的高光更暗
    half specPow = exp2(10.0 * smoothness + 1.0);
    half spec = pow(saturate(dot(normalWS, halfDir)), specPow) * smoothness * NdotL;

    half atten = mainLight.distanceAttenuation * mainLight.shadowAttenuation;
    half3 direct = (albedo * NdotL + spec) * mainLight.color * atten;
    half3 ambient = SampleSH(normalWS) * albedo;
    return direct + ambient;
}
vec3 SimpleLighting(vec3 albedo, vec3 positionWS, vec3 normalWS, float smoothness)
{
    vec4 shadowCoord = TransformWorldToShadowCoord(positionWS);
    Light mainLight = GetMainLight(shadowCoord);
    vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);

    float NdotL = saturate(dot(normalWS, mainLight.direction));
    vec3 halfDir = SafeNormalize(mainLight.direction + viewWS);
    // 光滑度 0~1 映射到高光指数 2~2048,乘 smoothness 让粗糙表面的高光更暗
    float specPow = exp2(10.0 * smoothness + 1.0);
    float spec = pow(saturate(dot(normalWS, halfDir)), specPow) * smoothness * NdotL;

    float atten = mainLight.distanceAttenuation * mainLight.shadowAttenuation;
    vec3 direct = (albedo * NdotL + spec) * mainLight.color * atten;
    vec3 ambient = SampleSH(normalWS) * albedo;
    return direct + ambient;
}

注意它只处理主光。如果你的特效物体需要被附加光照亮,按第 6 章的写法补上附加光循环即可——Forward+ 下要用 LIGHT_LOOP_BEGIN/END,并保留 #pragma multi_compile _ _FORWARD_PLUS。

为什么要把“时间”做成参数

几乎每个特效都在动:溶解要推进,全息要闪烁,护盾要呼吸。最省事的写法是直接用 _Time.y,但这有两个问题:一是游戏逻辑通常需要精确控制效果,比如“角色死亡后 1.5 秒溶解完”“护盾在被击中的那一刻开始涟漪”,这些都是事件驱动的,而不是从游戏启动开始的全局时间;二是做配图、录视频、做单元测试时,你需要把效果停在某一帧。所以本章的 Shader 都提供两条路:一个 _AutoPlay 开关走 _Time,否则用材质参数 _Progress 或 _Phase。本书的配图全部是固定参数渲染的。

提示运行时修改单个物体的参数,优先用 MaterialPropertyBlock 或者给每个物体独立的材质实例。注意:MaterialPropertyBlock 会让该物体退出 SRP Batcher 的合批(第 29 章有详细说明),物体数量少的特效可以接受,大量物体同时溶解时更推荐材质实例。

案例 18.1噪声溶解:用 clip 挖洞入门单独打开 ↗

目标:给物体一个 0~1 的进度参数,进度增加时物体表面按噪声图案一块块消失,并且它投在地上的影子同步“溶掉”。

原理:溶解就是带阈值的 Alpha Test

给表面每个点分配一个“溶解值” v(最简单的就是从噪声贴图采样),再给一个全局进度 p。规则只有一条:v < p 的像素丢掉。进度从 0 涨到 1,越来越多的像素满足条件,物体就从“噪声值最小的地方”开始破洞,最后完全消失。HLSL 里丢弃像素用 clip(x):参数小于 0 时,这个片元不写颜色、不写深度、也不参与后续混合。

溶解后的物体仍然是“不透明”的——每个像素要么完整存在,要么完全不存在,没有半透明。所以它不需要透明队列,也不需要排序,用 "Queue"="AlphaTest"(2450)就行。把 Alpha Test 物体排在普通不透明物体之后,是为了让完全不透明的物体先把深度写好,更多被遮挡的溶解像素能被 Early-Z 提前剔除(带 clip 的 Shader 自己无法享受 Early-Z 的写深度优化,见第 29 章)。

这里有个容易忽略的细节:噪声贴图的取值很少真正达到 0 或 1。如果直接 clip(v - p),进度为 0 时可能已经有几个像素被裁掉,进度为 1 时可能还剩下几个“顽固像素”。解决办法是把进度区间稍微外扩:

Assets/Cookbook/Ch18/DissolveCore.hlsl(节选:clip)
// 把进度从 [0,1] 稍微外扩,保证 0 时完全可见、1 时完全消失(噪声图很少真正到 0 或 1)
half DissolveClip(half value, out half edge)
{
    half p = GetProgress() * (1.0h + _EdgeWidth + 0.02h) - _EdgeWidth - 0.01h;
    half d = value - p;          // d < 0:已经溶掉
    clip(d);
    // 刚好在消失前沿附近的一条带子:d 从 0 到 EdgeWidth,edge 从 1 降到 0
    edge = 1.0h - saturate(d / max(_EdgeWidth, 1e-4h));
    return d;
}
#ifdef FRAGMENT   // discard 只能出现在片元着色器里(HLSL 的 clip 也是)
// 把进度从 [0,1] 稍微外扩,保证 0 时完全可见、1 时完全消失(噪声图很少真正到 0 或 1)
float DissolveClip(float value, out float edge)
{
    float p = GetProgress() * (1.0 + _EdgeWidth + 0.02) - _EdgeWidth - 0.01;
    float d = value - p;          // d < 0:已经溶掉
    if (d < 0.0) discard;         // HLSL 的 clip(d)
    // 刚好在消失前沿附近的一条带子:d 从 0 到 EdgeWidth,edge 从 1 降到 0
    edge = 1.0 - saturate(d / max(_EdgeWidth, 1e-4));
    return d;
}
#endif

外扩量里还包含了下一个案例要用的边带宽度 _EdgeWidth:保证进度为 0 时连边带都不出现。函数同时输出 edge,它是“离溶解前沿有多近”的 0~1 值,案例 18.2 用它发光。

完整代码

溶解的材质参数与计算放在一个公共 HLSL 里,三个 Pass 都包含它:

Assets/Cookbook/Ch18/DissolveCore.hlsl
// 溶解效果的核心:材质参数 + “溶解值”计算。Forward、ShadowCaster、DepthOnly 三个 Pass 共用,
// 保证屏幕上的镂空、阴影里的镂空、深度图里的镂空完全一致。
#ifndef COOKBOOK_CH18_DISSOLVE_CORE_INCLUDED
#define COOKBOOK_CH18_DISSOLVE_CORE_INCLUDED

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

TEXTURE2D(_BaseMap);  SAMPLER(sampler_BaseMap);
TEXTURE2D(_NoiseMap); SAMPLER(sampler_NoiseMap);
TEXTURE2D(_EdgeRamp); SAMPLER(sampler_EdgeRamp);

// 所有 Pass 的 UnityPerMaterial 必须一模一样,SRP Batcher 才认
CBUFFER_START(UnityPerMaterial)
    float4 _BaseMap_ST;
    half4  _BaseColor;
    half   _Smoothness;
    float4 _NoiseMap_ST;
    half   _Progress;
    half   _AutoPlay;
    half   _EdgeWidth;
    half4  _EdgeColor;
    half   _UseRamp;
    half   _NoiseStrength;
    float4 _DissolveDir;     // xyz:对象空间方向
    float4 _DirRange;        // x/y:沿方向的最小/最大投影(对象空间)
    float4 _SphereCenter;    // xyz:世界空间球心,w:最大半径
CBUFFER_END

half GetProgress()
{
    // 运行时可以勾选 AutoPlay 让它自己循环;书中拍图时 _Time 不可控,用 _Progress 手动给进度
    return _AutoPlay > 0.5 ? frac(_Time.y * 0.25) : _Progress;
}

// 返回 0~1 的“溶解值”:值越小越早消失。进度 p 时,值 < p 的像素被裁掉。
half DissolveValue(float2 uv, float3 positionOS, float3 positionWS)
{
    half noise = SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, uv * _NoiseMap_ST.xy + _NoiseMap_ST.zw).r;
#if defined(_MODE_DIRECTION)
    // 方向溶解:沿对象空间某方向的投影,归一化到 0~1,再掺一点噪声让边缘不规则
    float h = dot(positionOS, normalize(_DissolveDir.xyz));
    half t = saturate((h - _DirRange.x) / (_DirRange.y - _DirRange.x));
    return lerp(t, noise, _NoiseStrength);
#elif defined(_MODE_SPHERE)
    // 球形溶解:离球心越近越早消失,像被一个不断变大的球“吞掉”
    half t = saturate(distance(positionWS, _SphereCenter.xyz) / _SphereCenter.w);
    return lerp(t, noise, _NoiseStrength);
#else
    return noise;
#endif
}

// 把进度从 [0,1] 稍微外扩,保证 0 时完全可见、1 时完全消失(噪声图很少真正到 0 或 1)
half DissolveClip(half value, out half edge)
{
    half p = GetProgress() * (1.0h + _EdgeWidth + 0.02h) - _EdgeWidth - 0.01h;
    half d = value - p;          // d < 0:已经溶掉
    clip(d);
    // 刚好在消失前沿附近的一条带子:d 从 0 到 EdgeWidth,edge 从 1 降到 0
    edge = 1.0h - saturate(d / max(_EdgeWidth, 1e-4h));
    return d;
}

#endif
// 溶解效果的核心:材质参数 + “溶解值”计算。Forward、ShadowCaster、DepthOnly 三个 Pass 共用,
// 保证屏幕上的镂空、阴影里的镂空、深度图里的镂空完全一致。(GLSL 版,对应 DissolveCore.hlsl)
#ifndef COOKBOOK_CH18_DISSOLVE_CORE_INCLUDED
#define COOKBOOK_CH18_DISSOLVE_CORE_INCLUDED

uniform sampler2D _BaseMap;
uniform sampler2D _NoiseMap;
uniform sampler2D _EdgeRamp;

// HLSL 版要求所有 Pass 的 UnityPerMaterial 一模一样(SRP Batcher);GLSL 里就是普通 uniform
uniform vec4  _BaseMap_ST;
uniform vec4  _BaseColor;
uniform float _Smoothness;
uniform vec4  _NoiseMap_ST;
uniform float _Progress;
uniform float _AutoPlay;
uniform float _EdgeWidth;
uniform vec4  _EdgeColor;
uniform float _UseRamp;
uniform float _NoiseStrength;
uniform vec4  _DissolveDir;     // xyz:对象空间方向
uniform vec4  _DirRange;        // x/y:沿方向的最小/最大投影(对象空间)
uniform vec4  _SphereCenter;    // xyz:世界空间球心,w:最大半径

float GetProgress()
{
    // 运行时可以勾选 AutoPlay 让它自己循环;书中拍图时 _Time 不可控,用 _Progress 手动给进度
    return _AutoPlay > 0.5 ? fract(_Time.y * 0.25) : _Progress;
}

// 返回 0~1 的“溶解值”:值越小越早消失。进度 p 时,值 < p 的像素被裁掉。
float DissolveValue(vec2 uv, vec3 positionOS, vec3 positionWS)
{
    float noise = texture(_NoiseMap, uv * _NoiseMap_ST.xy + _NoiseMap_ST.zw).r;
#if defined(_MODE_DIRECTION)
    // 方向溶解:沿对象空间某方向的投影,归一化到 0~1,再掺一点噪声让边缘不规则
    float h = dot(positionOS, normalize(_DissolveDir.xyz));
    float t = saturate((h - _DirRange.x) / (_DirRange.y - _DirRange.x));
    return mix(t, noise, _NoiseStrength);
#elif defined(_MODE_SPHERE)
    // 球形溶解:离球心越近越早消失,像被一个不断变大的球“吞掉”
    float t = saturate(distance(positionWS, _SphereCenter.xyz) / _SphereCenter.w);
    return mix(t, noise, _NoiseStrength);
#else
    return noise;
#endif
}

#ifdef FRAGMENT   // discard 只能出现在片元着色器里(HLSL 的 clip 也是)
// 把进度从 [0,1] 稍微外扩,保证 0 时完全可见、1 时完全消失(噪声图很少真正到 0 或 1)
float DissolveClip(float value, out float edge)
{
    float p = GetProgress() * (1.0 + _EdgeWidth + 0.02) - _EdgeWidth - 0.01;
    float d = value - p;          // d < 0:已经溶掉
    if (d < 0.0) discard;         // HLSL 的 clip(d)
    // 刚好在消失前沿附近的一条带子:d 从 0 到 EdgeWidth,edge 从 1 降到 0
    edge = 1.0 - saturate(d / max(_EdgeWidth, 1e-4));
    return d;
}
#endif

#endif

Shader 主体如下。Forward Pass 负责着色;ShadowCaster 与 DepthOnly Pass 只做一件事——用同一个函数算溶解值然后 clip:

Assets/Cookbook/Ch18/Dissolve.shader
// 案例 18.1~18.3:溶解。噪声 clip + 边缘发光 + 三种溶解形状(噪声 / 方向 / 球形)。
Shader "Cookbook/Ch18/Dissolve"
{
    Properties
    {
        [MainTexture] _BaseMap ("Base Map", 2D) = "white" {}
        [MainColor] _BaseColor ("Base Color", Color) = (1,1,1,1)
        _Smoothness ("Smoothness", Range(0,1)) = 0.4
        [Header(Dissolve)]
        [NoScaleOffset] _NoiseMap ("Noise (R)", 2D) = "gray" {}
        _NoiseMap_ST ("Noise Tiling/Offset", Vector) = (1,1,0,0)
        _Progress ("Progress", Range(0,1)) = 0.3
        [Toggle] _AutoPlay ("Auto Play (_Time)", Float) = 0
        [KeywordEnum(Noise, Direction, Sphere)] _Mode ("Mode", Float) = 0
        _NoiseStrength ("Noise Strength (Dir/Sphere)", Range(0,1)) = 0.25
        _DissolveDir ("Direction (Object)", Vector) = (0,1,0,0)
        _DirRange ("Direction Range (min,max)", Vector) = (-0.5,0.5,0,0)
        _SphereCenter ("Sphere Center(xyz) MaxRadius(w)", Vector) = (0,0,0,2)
        [Header(Edge)]
        _EdgeWidth ("Edge Width", Range(0,0.3)) = 0.08
        [HDR] _EdgeColor ("Edge Color", Color) = (4,1.6,0.4,1)
        [Toggle] _UseRamp ("Use Ramp", Float) = 1
        [NoScaleOffset] _EdgeRamp ("Edge Ramp", 2D) = "white" {}
        [Enum(UnityEngine.Rendering.CullMode)] _Cull ("Cull", Float) = 0
    }

    SubShader
    {
        // 溶解本质是 Alpha Test:用 AlphaTest 队列,让 Early-Z 友好的不透明物体先画
        Tags { "RenderType"="TransparentCutout" "Queue"="AlphaTest" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }
            Cull [_Cull]

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma shader_feature_local _MODE_NOISE _MODE_DIRECTION _MODE_SPHERE
            #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 _ _FORWARD_PLUS
            #pragma multi_compile_fog

            #include "Assets/Cookbook/Ch18/DissolveCore.hlsl"
            #include "Assets/Cookbook/Ch18/Ch18Common.hlsl"

            struct Attributes
            {
                float4 positionOS : POSITION;
                float3 normalOS   : NORMAL;
                float2 uv         : TEXCOORD0;
            };

            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float2 uv         : TEXCOORD0;
                float3 positionWS : TEXCOORD1;
                float3 positionOS : TEXCOORD2;
                half3  normalWS   : TEXCOORD3;
                half   fogFactor  : TEXCOORD4;
            };

            Varyings vert(Attributes v)
            {
                Varyings o;
                VertexPositionInputs p = GetVertexPositionInputs(v.positionOS.xyz);
                o.positionCS = p.positionCS;
                o.positionWS = p.positionWS;
                o.positionOS = v.positionOS.xyz;
                o.normalWS = TransformObjectToWorldNormal(v.normalOS);
                o.uv = v.uv;
                o.fogFactor = ComputeFogFactor(p.positionCS.z);
                return o;
            }

            half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target
            {
                half edge;
                DissolveClip(DissolveValue(i.uv, i.positionOS, i.positionWS), edge);

                half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv * _BaseMap_ST.xy + _BaseMap_ST.zw).rgb * _BaseColor.rgb;
                // 双面渲染时,背面(物体内壁)把法线翻过来,再压暗一点,看起来像“空心的内部”
                half3 normalWS = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0h, -1.0h);
                albedo *= IS_FRONT_VFACE(face, 1.0h, 0.35h);

                // 前沿附近先“烧焦”变暗,再在最前沿发光
                albedo *= 1.0h - 0.85h * smoothstep(0.0h, 0.6h, edge);
                half3 color = SimpleLighting(albedo, i.positionWS, normalWS, _Smoothness);

                half3 glow = _EdgeColor.rgb;
                if (_UseRamp > 0.5)
                    glow *= SAMPLE_TEXTURE2D(_EdgeRamp, sampler_EdgeRamp, float2(edge, 0.5)).rgb;
                // edge^2:让发光集中在最前沿,向内快速衰减
                color += glow * edge * edge;

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

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            Cull [_Cull]

            HLSLPROGRAM
            #pragma vertex vertShadow
            #pragma fragment fragClip
            #pragma shader_feature_local _MODE_NOISE _MODE_DIRECTION _MODE_SPHERE
            #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
            #include "Assets/Cookbook/Ch18/DissolveCore.hlsl"
            #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl"

            float3 _LightDirection;   // 由 URP 的 ShadowUtils 在画阴影前设置
            float3 _LightPosition;

            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; };
            struct Varyings  { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionOS : TEXCOORD1; float3 positionWS : TEXCOORD2; };

            Varyings vertShadow(Attributes v)
            {
                Varyings o;
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                float3 normalWS = TransformObjectToWorldNormal(v.normalOS);
            #if _CASTING_PUNCTUAL_LIGHT_SHADOW
                float3 lightDirWS = normalize(_LightPosition - positionWS);
            #else
                float3 lightDirWS = _LightDirection;
            #endif
                o.positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirWS));
            #if UNITY_REVERSED_Z
                o.positionCS.z = min(o.positionCS.z, UNITY_NEAR_CLIP_VALUE);
            #else
                o.positionCS.z = max(o.positionCS.z, UNITY_NEAR_CLIP_VALUE);
            #endif
                o.uv = v.uv; o.positionOS = v.positionOS.xyz; o.positionWS = positionWS;
                return o;
            }

            half4 fragClip(Varyings i) : SV_Target
            {
                half edge;
                DissolveClip(DissolveValue(i.uv, i.positionOS, i.positionWS), edge);
                return 0;
            }
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            Cull [_Cull]

            HLSLPROGRAM
            #pragma vertex vertDepth
            #pragma fragment fragClip
            #pragma shader_feature_local _MODE_NOISE _MODE_DIRECTION _MODE_SPHERE
            #include "Assets/Cookbook/Ch18/DissolveCore.hlsl"

            struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; };
            struct Varyings  { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionOS : TEXCOORD1; float3 positionWS : TEXCOORD2; };

            Varyings vertDepth(Attributes v)
            {
                Varyings o;
                o.positionWS = TransformObjectToWorld(v.positionOS.xyz);
                o.positionCS = TransformWorldToHClip(o.positionWS);
                o.uv = v.uv; o.positionOS = v.positionOS.xyz;
                return o;
            }

            half4 fragClip(Varyings i) : SV_Target
            {
                half edge;
                DissolveClip(DissolveValue(i.uv, i.positionOS, i.positionWS), edge);
                return i.positionCS.z;
            }
            ENDHLSL
        }
    }
}
// 案例 18.1~18.3:溶解。噪声 clip + 边缘发光 + 三种溶解形状(噪声 / 方向 / 球形)。(GLSL 版,对应 Ch18/Dissolve.shader)
Shader "Cookbook/Ch18/Dissolve"
{
    SubShader
    {
        // 溶解本质是 Alpha Test:用 AlphaTest 队列,让 Early-Z 友好的不透明物体先画
        Tags { "RenderType"="TransparentCutout" "Queue"="AlphaTest" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }
            Cull [_Cull]

            GLSLPROGRAM
            #pragma shader_feature_local _MODE_NOISE _MODE_DIRECTION _MODE_SPHERE
            #pragma multi_compile_fog

            #include "Ch18/DissolveCore.glsl"
            #include "Ch18/Ch18Common.glsl"

            VARYING vec2  uv;
            VARYING vec3  positionWS;
            VARYING vec3  posOS;        // 对象空间位置(HLSL 版的 positionOS 字段;GLSL 里这个名字已被顶点输入占用)
            VARYING vec3  normalWS;
            VARYING float fogFactor;

            #ifdef VERTEX
            void main()
            {
                VertexPositionInputs p = GetVertexPositionInputs(positionOS.xyz);
                gl_Position = p.positionCS;
                positionWS = p.positionWS;
                posOS = positionOS.xyz;
                normalWS = TransformObjectToWorldNormal(normalOS);
                uv = texcoord0.xy;
                fogFactor = ComputeFogFactor(p.positionCS.z);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                float edge;
                DissolveClip(DissolveValue(uv, posOS, positionWS), edge);

                vec3 albedo = texture(_BaseMap, uv * _BaseMap_ST.xy + _BaseMap_ST.zw).rgb * _BaseColor.rgb;
                // 双面渲染时,背面(物体内壁)把法线翻过来,再压暗一点,看起来像“空心的内部”
                vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0);
                albedo *= gl_FrontFacing ? 1.0 : 0.35;

                // 前沿附近先“烧焦”变暗,再在最前沿发光
                albedo *= 1.0 - 0.85 * smoothstep(0.0, 0.6, edge);
                vec3 color = SimpleLighting(albedo, positionWS, N, _Smoothness);

                vec3 glow = _EdgeColor.rgb;
                if (_UseRamp > 0.5)
                    glow *= texture(_EdgeRamp, vec2(edge, 0.5)).rgb;
                // edge^2:让发光集中在最前沿,向内快速衰减
                color += glow * edge * edge;

                color = MixFog(color, fogFactor);
                fragColor = vec4(color, 1.0);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            Cull [_Cull]

            GLSLPROGRAM
            #pragma shader_feature_local _MODE_NOISE _MODE_DIRECTION _MODE_SPHERE
            #include "Ch18/DissolveCore.glsl"
            // _LightDirection / _LightPosition 由引擎在画阴影前设置(Lighting.glsl 里已声明)

            VARYING vec2 uv;
            VARYING vec3 posOS;
            VARYING vec3 positionWS;

            #ifdef VERTEX
            void main()
            {
                positionWS = TransformObjectToWorld(positionOS.xyz);
                vec3 normalWS = TransformObjectToWorldNormal(normalOS);
                vec3 lightDirWS = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection;
                vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirWS));
                positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE * positionCS.w);
                gl_Position = positionCS;
                uv = texcoord0.xy; posOS = positionOS.xyz;
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                float edge;
                DissolveClip(DissolveValue(uv, posOS, positionWS), edge);
                fragColor = vec4(0.0);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            Cull [_Cull]

            GLSLPROGRAM
            #pragma shader_feature_local _MODE_NOISE _MODE_DIRECTION _MODE_SPHERE
            #include "Ch18/DissolveCore.glsl"

            VARYING vec2 uv;
            VARYING vec3 posOS;
            VARYING vec3 positionWS;

            #ifdef VERTEX
            void main()
            {
                positionWS = TransformObjectToWorld(positionOS.xyz);
                gl_Position = TransformWorldToHClip(positionWS);
                uv = texcoord0.xy; posOS = positionOS.xyz;
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                float edge;
                DissolveClip(DissolveValue(uv, posOS, positionWS), edge);
                fragColor = vec4(gl_FragCoord.z);
            }
            #endif
            ENDGLSL
        }
    }
}

关键代码讲解

① 三个 Pass 必须一起裁剪。只在 Forward Pass 里 clip,屏幕上的物体会破洞,但地上的影子还是完整的——因为阴影贴图是 ShadowCaster Pass 画的;如果项目开了 SSAO 或者用到了深度图(比如本章后面的护盾),深度图里也会留下一个完整的“幽灵”。所以溶解值的计算必须写成公共函数,所有写深度的 Pass 都调用它。这也是为什么 CBUFFER 要放在公共文件里:同一个 Shader 所有 Pass 的 UnityPerMaterial 布局必须完全一致,否则材质会被 SRP Batcher 判为不兼容。

② ShadowCaster 的写法。阴影 Pass 的顶点函数和 URP 的 ShadowCasterPass.hlsl 一样:用 ApplyShadowBias 沿法线和光方向偏移,_LightDirection/_LightPosition 由 URP 在绘制阴影前设置,_CASTING_PUNCTUAL_LIGHT_SHADOW 区分方向光和点光/聚光(第 6 章)。有一个坑:URP 的 Shadows.hlsl 用到了 CommonMaterial.hlsl 里的 LerpWhiteTo,而 Lit 是通过 LitInput.hlsl 间接包含它的。自己写阴影 Pass 只包含 Shadows.hlsl 会报 “undeclared identifier 'LerpWhiteTo'”,需要先包含 Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl。

③ 双面渲染。物体被挖出洞后,你会透过洞看到它的内壁。默认 Cull Back 会把内壁剔掉,看起来像一张纸壳;本例把 _Cull 做成属性,配图里设为 Off,并在片元里用 FRONT_FACE_SEMANTIC 判断正反面:背面翻转法线、压暗颜色,看起来像实心物体被掏空。

图 18-2噪声溶解,进度从左到右依次为 0.15、0.4、0.6、0.8(此图关闭了边缘发光)。注意地面上的阴影与物体同步镂空,背面内壁被压暗。

常见坑与调参

  • 噪声 UV 拉伸:溶解图案跟着模型 UV 走。UV 拉伸的地方(比如本例纽结的长条方向)斑块也会被拉长。想要均匀的斑块,可以改用三平面采样(案例 18.9)或者对象空间的 3D 噪声。
  • 噪声分布决定节奏:fBm 噪声的值大多集中在中间,所以进度在 0.3~0.7 之间变化最快、两头最慢。想要匀速的视觉效果,可以对噪声做直方图均衡,或者干脆在 C# 里用曲线(AnimationCurve)驱动进度。
  • Mipmap 让边缘变糊:远处采样到低级 mip,噪声被平均成一片灰,溶解边缘会突然整体消失。噪声贴图可以关闭 mipmap,或者在 Shader 里用 SAMPLE_TEXTURE2D_LOD 限制 mip 级别。
  • 锯齿:clip 是硬边,MSAA 也救不了它(MSAA 只对几何边缘多采样)。开 MSAA 的项目可以用 Alpha to Coverage(第 16 章)来柔化,或者靠后处理 AA。

案例 18.2边缘发光:让溶解有“燃烧”的前沿入门单独打开 ↗

目标:在溶解的前沿加一条发光的边,可以是纯色硬边(科幻风格的“数字化消散”),也可以是从暗红到亮黄的渐变火边(燃烧)。

原理:阈值之上再划一条带

记 d = v − p:d < 0 的像素已经被裁掉;d 刚好大于 0 的像素“即将被溶掉”,它们就是前沿。取一个宽度 w,令

edge=1−saturate(dw)

则 d=0 处 edge=1,d≥w 处 edge=0,中间线性过渡。用 edge 去查一张 Ramp 贴图(公共贴图 ramp_fire:左边暗紫,右边亮黄白),越靠近前沿越亮、越黄,往里逐渐变暗红——正好是燃烧的颜色分布。

v v < p:clip 掉 边带:0 ≤ d < w 正常着色 阈值 p p + w edge = 1 edge = 0 进度 p 增大 → 阈值右移 → 边带扫过整个表面
图 18-3沿溶解值轴看:阈值左边被裁掉,阈值右边宽度 w 的一段是发光边带,edge 从 1 线性降到 0,用来查 Ramp。

片元函数如下:

Assets/Cookbook/Ch18/Dissolve.shader(节选:frag)
half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target
{
    half edge;
    DissolveClip(DissolveValue(i.uv, i.positionOS, i.positionWS), edge);

    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv * _BaseMap_ST.xy + _BaseMap_ST.zw).rgb * _BaseColor.rgb;
    // 双面渲染时,背面(物体内壁)把法线翻过来,再压暗一点,看起来像“空心的内部”
    half3 normalWS = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0h, -1.0h);
    albedo *= IS_FRONT_VFACE(face, 1.0h, 0.35h);

    // 前沿附近先“烧焦”变暗,再在最前沿发光
    albedo *= 1.0h - 0.85h * smoothstep(0.0h, 0.6h, edge);
    half3 color = SimpleLighting(albedo, i.positionWS, normalWS, _Smoothness);

    half3 glow = _EdgeColor.rgb;
    if (_UseRamp > 0.5)
        glow *= SAMPLE_TEXTURE2D(_EdgeRamp, sampler_EdgeRamp, float2(edge, 0.5)).rgb;
    // edge^2:让发光集中在最前沿,向内快速衰减
    color += glow * edge * edge;

    color = MixFog(color, i.fogFactor);
    return half4(color, 1);
}
void main()
{
    float edge;
    DissolveClip(DissolveValue(uv, posOS, positionWS), edge);

    vec3 albedo = texture(_BaseMap, uv * _BaseMap_ST.xy + _BaseMap_ST.zw).rgb * _BaseColor.rgb;
    // 双面渲染时,背面(物体内壁)把法线翻过来,再压暗一点,看起来像“空心的内部”
    vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0);
    albedo *= gl_FrontFacing ? 1.0 : 0.35;

    // 前沿附近先“烧焦”变暗,再在最前沿发光
    albedo *= 1.0 - 0.85 * smoothstep(0.0, 0.6, edge);
    vec3 color = SimpleLighting(albedo, positionWS, N, _Smoothness);

    vec3 glow = _EdgeColor.rgb;
    if (_UseRamp > 0.5)
        glow *= texture(_EdgeRamp, vec2(edge, 0.5)).rgb;
    // edge^2:让发光集中在最前沿,向内快速衰减
    color += glow * edge * edge;

    color = MixFog(color, fogFactor);
    fragColor = vec4(color, 1.0);
}

两处细节值得说。第一,发光项乘的是 edge * edge 而不是 edge:平方让亮度集中在最前沿、往里快速衰减,看起来更像一条细亮线而不是一片光晕。第二,在前沿后方先把反照率压暗(albedo *= 1 - 0.85 * smoothstep(0, 0.6, edge)),模拟纸张燃烧时火线前面先“烤焦”的那一圈,这比单纯加一条亮边可信得多。

HDR 颜色与 Bloom

_EdgeColor 用 [HDR] 标记,分量可以填到 4、5。URP 默认开启 HDR 时,相机颜色缓冲是浮点格式,能存下大于 1 的值;后处理 Bloom 会把超过阈值的部分提取出来模糊、叠回画面,产生“溢光”。没有后处理时,大于 1 的颜色会在最终输出时被截断成白色,你只能看到一条刺眼的白边——这是很多人“发光颜色怎么调都发白”的原因。

图 18-4边缘发光。左:无边带(EdgeWidth=0);中:纯色硬边(青色 HDR,宽 0.04);右:Ramp 火边(宽 0.1),并开启 Bloom + Tonemapping 后处理——溢光效果来自后处理,不是 Shader 本身。
注意本书配图默认关闭后处理,以便看清 Shader 的原始输出;凡是标注“开启 Bloom”的图,都是在 Volume 里加了 Bloom(阈值约 1)和 Neutral Tonemapping。Bloom 的阈值、散射等参数见第 27 章。

案例 18.3方向溶解与球形溶解进阶单独打开 ↗

目标:让溶解有方向感——比如角色从头到脚被“传送”走;或者从一个点向外扩散——比如被魔法击中的位置先开始瓦解。

原理:换一种溶解值

clip 和边带的逻辑完全不变,只换溶解值 v 的来源:

  • 方向溶解:把对象空间位置投影到一个方向上,h = dot(positionOS, dir),再按物体在这个方向上的范围 [min, max] 归一化到 0~1。投影值小的一端先消失。
  • 球形溶解:v = distance(positionWS, center) / R。离球心越近越先消失,看起来像一个看不见的球从 center 处胀开,把物体“吃掉”。

纯几何的溶解值会得到一条笔直的切割线或者一个完美的圆,太“干净”了。所以再用 lerp(t, noise, _NoiseStrength) 掺一点噪声,切割线就变成了不规则的锯齿边。

Assets/Cookbook/Ch18/DissolveCore.hlsl(节选:dissolveValue)
// 返回 0~1 的“溶解值”:值越小越早消失。进度 p 时,值 < p 的像素被裁掉。
half DissolveValue(float2 uv, float3 positionOS, float3 positionWS)
{
    half noise = SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, uv * _NoiseMap_ST.xy + _NoiseMap_ST.zw).r;
#if defined(_MODE_DIRECTION)
    // 方向溶解:沿对象空间某方向的投影,归一化到 0~1,再掺一点噪声让边缘不规则
    float h = dot(positionOS, normalize(_DissolveDir.xyz));
    half t = saturate((h - _DirRange.x) / (_DirRange.y - _DirRange.x));
    return lerp(t, noise, _NoiseStrength);
#elif defined(_MODE_SPHERE)
    // 球形溶解:离球心越近越早消失,像被一个不断变大的球“吞掉”
    half t = saturate(distance(positionWS, _SphereCenter.xyz) / _SphereCenter.w);
    return lerp(t, noise, _NoiseStrength);
#else
    return noise;
#endif
}
// 返回 0~1 的“溶解值”:值越小越早消失。进度 p 时,值 < p 的像素被裁掉。
float DissolveValue(vec2 uv, vec3 positionOS, vec3 positionWS)
{
    float noise = texture(_NoiseMap, uv * _NoiseMap_ST.xy + _NoiseMap_ST.zw).r;
#if defined(_MODE_DIRECTION)
    // 方向溶解:沿对象空间某方向的投影,归一化到 0~1,再掺一点噪声让边缘不规则
    float h = dot(positionOS, normalize(_DissolveDir.xyz));
    float t = saturate((h - _DirRange.x) / (_DirRange.y - _DirRange.x));
    return mix(t, noise, _NoiseStrength);
#elif defined(_MODE_SPHERE)
    // 球形溶解:离球心越近越早消失,像被一个不断变大的球“吞掉”
    float t = saturate(distance(positionWS, _SphereCenter.xyz) / _SphereCenter.w);
    return mix(t, noise, _NoiseStrength);
#else
    return noise;
#endif
}

三种模式用 [KeywordEnum(Noise, Direction, Sphere)] _Mode 生成三个关键字 _MODE_NOISE / _MODE_DIRECTION / _MODE_SPHERE,Shader 里用 #pragma shader_feature_local 声明——它们是材质级别的选择,不会在运行时频繁切换,shader_feature 只编译实际用到的变体。注意三个 Pass 都要声明同样的关键字,否则阴影 Pass 会走默认分支,影子的溶解形状就和物体对不上了。

为什么方向溶解用对象空间、球形溶解用世界空间

方向溶解描述的是“物体自身从头到脚”,角色转身、倒地,头脚关系不变,所以用对象空间;球心通常来自游戏逻辑(被击中的点、爆炸中心),它天然是世界坐标,所以用世界空间。如果方向溶解想用世界方向(比如永远从上往下),在 C# 里用 transform.InverseTransformDirection(Vector3.down) 换算成对象空间再传进来即可。

图 18-5同一进度 0.45 下的三种溶解。左:噪声溶解;中:方向溶解(对象空间自上而下,NoiseStrength=0.22);右:球形溶解(球心在纽结左上方,最大半径 1.9 米)。均开启 Bloom。

从游戏逻辑驱动

球形溶解最常见的用法是“击中点开始瓦解”。C# 侧只需要在命中时记录世界坐标,然后用协程推进进度:

// 命中时调用:从 hitPoint 开始,duration 秒内溶解完
IEnumerator DissolveFrom(Renderer r, Vector3 hitPoint, float radius, float duration)
{
    var mat = r.material;                       // 材质实例,只影响这一个物体
    mat.EnableKeyword("_MODE_SPHERE");
    mat.SetVector("_SphereCenter", new Vector4(hitPoint.x, hitPoint.y, hitPoint.z, radius));
    for (float t = 0; t < duration; t += Time.deltaTime)
    {
        mat.SetFloat("_Progress", t / duration);
        yield return null;
    }
    r.gameObject.SetActive(false);
}
注意运行时 EnableKeyword 打开一个 shader_feature 关键字,前提是这个变体被打进了包。如果构建时没有任何材质用到 _MODE_SPHERE,变体会被剥离,运行时打开关键字也不会生效。解决办法是让工程里至少有一个材质使用它,或者改用 multi_compile_local(变体数更多),详见第 29 章。

案例 18.4菲涅尔边缘光入门单独打开 ↗

目标:让物体轮廓处出现一圈亮边。这是“被选中高亮”“能量体”“背光轮廓”最常用的手法,也是本章后面全息、护盾、冰面的组成部分。

原理:轮廓处 N·V 趋近于 0

在第 9 章我们讲过真实的菲涅尔效应:光以掠射角照到表面时,反射率会急剧升高。Schlick 近似写作

F=F0+(1−F0)(1−N·V)5

特效里的“菲涅尔”借用的是它的形状,而不是物理含义:从相机看过去,物体正对你的地方法线 N 与视线方向 V 几乎平行,N·V≈1;越靠近轮廓,表面越“侧”过去,N·V 越接近 0。所以 pow(1 − N·V, power) 在中间是 0、在轮廓处是 1,天然就是一圈“轮廓遮罩”。指数 power 控制这圈有多宽:power 越大,亮边越窄越锐利。

视线 N·V≈1 N·V≈0(轮廓) N·V≈0.7 N·Vrim 01 power=1power=3power=8
图 18-6左:球面上不同位置的 N·V;右:rim = (1−N·V)power 随 N·V 的变化,power 越大,只有越靠近轮廓的地方才亮。
Assets/Cookbook/Ch18/Ch18Common.hlsl(节选:fresnel)
// 经验菲涅尔:视线与法线越垂直(轮廓处)值越大。power 越大,亮边越窄。
half FresnelRim(half3 normalWS, half3 viewWS, half power)
{
    return pow(1.0h - saturate(dot(normalWS, viewWS)), power);
}
// 经验菲涅尔:视线与法线越垂直(轮廓处)值越大。power 越大,亮边越窄。
float FresnelRim(vec3 normalWS, vec3 viewWS, float power)
{
    return pow(1.0 - saturate(dot(normalWS, viewWS)), power);
}

边缘光叠加在正常光照之上。一个常见的改进是只在“背光侧”显示边缘光:受光面已经够亮了,再加亮边会发白;背光面加一圈亮边,正好勾出轮廓,这就是影视布光里的“轮廓光(Rim Light)”。实现上用主光方向的 N·L 做一个遮罩:

Assets/Cookbook/Ch18/FresnelRim.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half3 normalWS = normalize(i.normalWS);
    half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
    half3 color = SimpleLighting(_BaseColor.rgb, i.positionWS, normalWS, _Smoothness);

    half rim = FresnelRim(normalWS, viewWS, _RimPower);
    // 可选:只在背光一侧显示亮边(经典的“轮廓光”打法),受光侧交给正常光照
    half NdotL = dot(normalWS, GetMainLight().direction);
    half lightMask = lerp(1.0h, smoothstep(0.2h, -0.4h, NdotL), _RimLightMask);
    color += _RimColor.rgb * rim * _RimIntensity * lightMask;
    return half4(color, 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
    vec3 color = SimpleLighting(_BaseColor.rgb, positionWS, N, _Smoothness);

    float rim = FresnelRim(N, viewWS, _RimPower);
    // 可选:只在背光一侧显示亮边(经典的“轮廓光”打法),受光侧交给正常光照
    float NdotL = dot(N, GetMainLight().direction);
    float lightMask = mix(1.0, smoothstep(0.2, -0.4, NdotL), _RimLightMask);
    color += _RimColor.rgb * rim * _RimIntensity * lightMask;
    fragColor = vec4(color, 1.0);
}
图 18-7菲涅尔边缘光的指数,从左到右 power = 1、2、4、8。power=1 时几乎整个球都在发光,power=8 时只剩细细一圈。
图 18-8光从右侧低角度照来。左:边缘光一圈都有;右:用 N·L 遮罩后只保留背光(左侧)的轮廓光,受光侧交给正常光照。

常见坑

  • 平面没有轮廓:一块平板的法线处处相同,N·V 只随视角整体变化,边缘光会“整块亮起来”或者“整块消失”。菲涅尔边缘光只适合有曲率的模型;硬边的箱子可以用法线贴图、或者改用后处理描边(第 26 章)。
  • 法线没有归一化:顶点插值后的法线长度小于 1,N·V 偏小,边缘光整体偏亮。片元里先 normalize。
  • 和法线贴图配合:用扰动后的法线算菲涅尔,亮边会出现细碎的纹理,有时正是想要的效果(比如冰面);想要干净的轮廓就用几何法线。
  • Shader 结构:这个 Shader 的 ShadowCaster/DepthOnly 没有用 UsePass "Universal Render Pipeline/Lit/ShadowCaster" 偷懒。UsePass 借来的 Pass 带着 Lit 自己的 UnityPerMaterial,与本 Shader 的 CBUFFER 不一致,会让材质失去 SRP Batcher 兼容性。本章把无顶点动画的阴影/深度 Pass 写成公共文件 Ch18ShadowPass.hlsl,CBUFFER 放在 SubShader 级的 HLSLINCLUDE 里,所有 Pass 自动共享同一份声明。
Assets/Cookbook/Ch18/Ch18ShadowPass.hlsl
// 本章不透明 Shader 共用的 ShadowCaster / DepthOnly 顶点与片元函数(无顶点动画、无裁剪)。
// 用法:在 SubShader 的 HLSLINCLUDE 里声明好 UnityPerMaterial,各 Pass 再 include 本文件。
// 这样所有 Pass 的 CBUFFER 完全一致,SRP Batcher 兼容;而 UsePass 借用 Lit 的 Pass 会带来
// 另一套 UnityPerMaterial,导致材质不兼容 SRP Batcher。
#ifndef COOKBOOK_CH18_SHADOW_PASS_INCLUDED
#define COOKBOOK_CH18_SHADOW_PASS_INCLUDED

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// Shadows.hlsl 用到 CommonMaterial.hlsl 里的 LerpWhiteTo,URP 的 Lit 是经由 LitInput 间接包含的
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl"

float3 _LightDirection;
float3 _LightPosition;

struct ShadowAttributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; };
struct ShadowVaryings  { float4 positionCS : SV_POSITION; };

ShadowVaryings ShadowVert(ShadowAttributes v)
{
    ShadowVaryings o;
    float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
    float3 normalWS = TransformObjectToWorldNormal(v.normalOS);
#if _CASTING_PUNCTUAL_LIGHT_SHADOW
    float3 lightDirWS = normalize(_LightPosition - positionWS);
#else
    float3 lightDirWS = _LightDirection;
#endif
    o.positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirWS));
#if UNITY_REVERSED_Z
    o.positionCS.z = min(o.positionCS.z, UNITY_NEAR_CLIP_VALUE);
#else
    o.positionCS.z = max(o.positionCS.z, UNITY_NEAR_CLIP_VALUE);
#endif
    return o;
}

half4 ShadowFrag(ShadowVaryings i) : SV_Target { return 0; }

ShadowVaryings DepthVert(ShadowAttributes v)
{
    ShadowVaryings o;
    o.positionCS = TransformObjectToHClip(v.positionOS.xyz);
    return o;
}

half4 DepthFrag(ShadowVaryings i) : SV_Target { return i.positionCS.z; }

#endif
// 本章不透明 Shader 共用的 ShadowCaster / DepthOnly 顶点与片元函数(无顶点动画、无裁剪)。(GLSL 版,对应 Ch18ShadowPass.hlsl)
// 用法:在 Pass 的 GLSLPROGRAM 里先 #define SHADOW_PASS 或 DEPTH_PASS,再 #include 本文件。
// HLSL 版强调所有 Pass 的 CBUFFER 一致(SRP Batcher);WebGL 没有这个概念,这里只保留顶点/片元逻辑。
// _LightDirection / _LightPosition 由引擎在画阴影前设置(Lighting.glsl 里已声明)。

#ifdef SHADOW_PASS
#ifdef VERTEX
void main()
{
    vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
    vec3 normalWS = TransformObjectToWorldNormal(normalOS);
    // _CASTING_PUNCTUAL_LIGHT_SHADOW 在 GLSL 版里是个 uniform
    vec3 lightDirWS = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection;
    vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirWS));
    // WebGL 深度不反转(UNITY_REVERSED_Z = 0),走 max 分支
    positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE * positionCS.w);
    gl_Position = positionCS;
}
#endif
#ifdef FRAGMENT
void main() { fragColor = vec4(0.0); }
#endif
#endif

#ifdef DEPTH_PASS
#ifdef VERTEX
void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); }
#endif
#ifdef FRAGMENT
void main() { fragColor = vec4(gl_FragCoord.z); }
#endif
#endif

案例 18.5全息投影:扫描线、闪烁、故障与色差进阶单独打开 ↗

目标:做一个科幻风格的全息投影:半透明发光的“光壳”,表面有横向扫描线,有一条亮带缓慢扫过,偶尔闪烁、偶尔横向错位(故障),轮廓有 RGB 分离的彩边。

原理:全息是“光”,不是“物”

全息投影在设定上是一团光,它不遮挡后面的东西,叠在一起只会更亮。这决定了它的渲染状态:

  • 透明队列 + 不写深度(ZWrite Off):它不应该挡住后面的透明物体,也不应该被自己前面的部分挡住。
  • 加法混合(Blend One One):输出颜色直接加到屏幕上。加法混合满足交换律,谁先画谁后画结果都一样,透明物体最头疼的排序问题(第 16 章)在这里不存在。
  • 不需要光照:光不会被光照亮。全息的“明暗”全部来自菲涅尔与图案。

在此之上,四个图层逐个叠加。

① 菲涅尔光壳

只输出 _HoloColor * (BaseAlpha + rim):正对相机的部分只有很淡的底色,轮廓最亮。中空的“光壳”感一下就出来了——这也是全息和普通半透明材质最大的区别:普通半透明是中间实、边缘虚,全息反过来。

② 世界空间扫描线

扫描线用世界坐标 Y 而不是 UV:frac(y * density - t * speed) 得到一个 0~1 的锯齿波,取中间一段为亮线。用世界坐标的好处是条纹在所有物体上间距一致、方向一致(永远水平),物体旋转时条纹不跟着转,更像投影仪在一层层“扫描”出来的。另加一条用指数衰减算出的宽亮带,相对物体原点从下往上循环移动。

Assets/Cookbook/Ch18/Hologram.shader(节选:scan)
// 细扫描线:沿世界 Y 的周期条纹。返回 0~1,1 是亮线
half Scanlines(float y, float t)
{
    float s = frac(y * _ScanDensity - t * _ScanSpeed);
    return smoothstep(0.25, 0.15, abs(s - 0.5));   // 每个周期中间约 40% 是亮线,边缘柔化防锯齿
}
// 细扫描线:沿世界 Y 的周期条纹。返回 0~1,1 是亮线
float Scanlines(float y, float t)
{
    float s = fract(y * _ScanDensity - t * _ScanSpeed);
    return smoothstep(0.25, 0.15, abs(s - 0.5));   // 每个周期中间约 40% 是亮线,边缘柔化防锯齿
}

③ 闪烁与故障

闪烁就是一个随时间随机跳变的亮度系数:把时间按 1/20 秒取整 floor(t * 20),再用哈希函数映射成 0~1 的随机数,同一个 1/20 秒内值不变,下一段跳到另一个值,很像电压不稳。故障(Glitch)在顶点着色器里做:同样把时间离散成“拍”,每一拍按概率决定是否触发;触发时把物体按高度切成若干横带,每条横带随机左右平移。因为平移发生在顶点上,被切开的三角形会被拉伸,产生撕裂的细丝——这正是我们想要的数字故障感。

Assets/Cookbook/Ch18/Hologram.shader(节选:glitch)
Varyings vert(Attributes v)
{
    Varyings o;
    float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
    float t = HoloTime();
    // 故障:时间按 1/12 秒取整得到“当前这一拍”,这一拍里按概率决定要不要抖
    float beat = floor(t * 12.0);
    float glitchOn = step(1.0 - _GlitchChance, Hash11(beat)) * _Toggles.z;
    // 把物体按高度切成一条条横带,每条带随机左右平移
    float slice = floor(positionWS.y * 9.0);
    float shift = (Hash21(float2(slice, beat)) - 0.5) * 2.0;
    shift *= step(0.7, Hash21(float2(slice * 1.7, beat + 3.1)));  // 只有部分横带移动
    positionWS.x += shift * _GlitchAmount * glitchOn;

    o.positionWS = positionWS;
    o.positionCS = TransformWorldToHClip(positionWS);
    o.normalWS = TransformObjectToWorldNormal(v.normalOS);
    o.objectY = TransformObjectToWorld(float3(0, 0, 0)).y;
    return o;
}
void main()
{
    vec3 pWS = TransformObjectToWorld(positionOS.xyz);
    float t = HoloTime();
    // 故障:时间按 1/12 秒取整得到“当前这一拍”,这一拍里按概率决定要不要抖
    float beat = floor(t * 12.0);
    float glitchOn = step(1.0 - _GlitchChance, Hash11(beat)) * _Toggles.z;
    // 把物体按高度切成一条条横带,每条带随机左右平移
    float slice = floor(pWS.y * 9.0);
    float shift = (Hash21(vec2(slice, beat)) - 0.5) * 2.0;
    shift *= step(0.7, Hash21(vec2(slice * 1.7, beat + 3.1)));  // 只有部分横带移动
    pWS.x += shift * _GlitchAmount * glitchOn;

    positionWS = pWS;
    gl_Position = TransformWorldToHClip(pWS);
    normalWS = TransformObjectToWorldNormal(normalOS);
    objectY = TransformObjectToWorld(vec3(0.0)).y;
}
提示这里用的 Hash11/Hash21 是常见的“无三角函数哈希”,输入相同输出就相同,在 GPU 上比 frac(sin(x) * 43758.5) 那种写法更稳定(后者在不同 GPU、不同精度下结果可能差异很大)。哈希与噪声的原理见第 4 章。

④ 色差(RGB 分离)

真实投影设备的色差,来自不同波长的光折射角度不同。Shader 里模拟它的思路很简单:同一个图案,R、G、B 三个通道分别在略微错开的位置采样。扫描线 R 通道往上错一点、B 通道往下错一点,条纹边缘就出现红蓝镶边;菲涅尔的 R、B 通道用略微不同的指数,轮廓处也有了彩边。错开量以“一个扫描线周期”为单位:错开整周期就又重合了,0.2~0.35 个周期最明显。

Assets/Cookbook/Ch18/Hologram.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float t = HoloTime();
    half3 normalWS = normalize(i.normalWS);
    half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);

    // 1) 菲涅尔:轮廓亮、正对暗,让中空的“光壳”感出来
    half rim = FresnelRim(normalWS, viewWS, _RimPower);

    // 2) 扫描线 + 一条缓慢上移的宽亮带;色差开启时 R/B 两个通道错开采样
    // 错开量以“一条扫描线周期”为单位:错开整周期就又重合了,0.2~0.35 个周期最明显
    half chroma = _ChromaShift * _Toggles.w;
    float shift = chroma / _ScanDensity;
    half3 lines = half3(Scanlines(i.positionWS.y + shift, t),
                        Scanlines(i.positionWS.y,         t),
                        Scanlines(i.positionWS.y - shift, t));
    lines = lerp(1.0h, lines, _ScanStrength * _Toggles.x);
    float bandPos = frac(t * 0.35) * 2.6 - 0.3;                  // 相对物体原点的高度
    half band = exp(-abs(i.positionWS.y - i.objectY - bandPos) / _BandWidth) * _BandStrength * _Toggles.x;

    // 3) 闪烁:每 1/20 秒随机一个亮度,再叠一个快速正弦,模拟电压不稳
    float flick = 0.55 + 0.45 * Hash11(floor(t * 20.0));
    flick *= 0.9 + 0.1 * sin(t * 90.0);
    half flicker = lerp(1.0h, flick, _FlickerAmount * _Toggles.y);

    // 色差的另一半:R/B 通道的菲涅尔用不同的指数,轮廓处出现彩色镶边
    half3 rimRGB = half3(FresnelRim(normalWS, viewWS, _RimPower * (1.0h - chroma)),
                         rim,
                         FresnelRim(normalWS, viewWS, _RimPower * (1.0h + chroma)));

    half3 intensity = (_BaseAlpha + rimRGB) * lines + band;
    half3 color = _HoloColor.rgb * intensity * flicker;
    return half4(color, 1);
}
void main()
{
    float t = HoloTime();
    vec3 N = normalize(normalWS);
    vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);

    // 1) 菲涅尔:轮廓亮、正对暗,让中空的“光壳”感出来
    float rim = FresnelRim(N, viewWS, _RimPower);

    // 2) 扫描线 + 一条缓慢上移的宽亮带;色差开启时 R/B 两个通道错开采样
    // 错开量以“一条扫描线周期”为单位:错开整周期就又重合了,0.2~0.35 个周期最明显
    float chroma = _ChromaShift * _Toggles.w;
    float shift = chroma / _ScanDensity;
    vec3 lines = vec3(Scanlines(positionWS.y + shift, t),
                      Scanlines(positionWS.y,         t),
                      Scanlines(positionWS.y - shift, t));
    lines = mix(vec3(1.0), lines, _ScanStrength * _Toggles.x);
    float bandPos = fract(t * 0.35) * 2.6 - 0.3;                  // 相对物体原点的高度
    float band = exp(-abs(positionWS.y - objectY - bandPos) / _BandWidth) * _BandStrength * _Toggles.x;

    // 3) 闪烁:每 1/20 秒随机一个亮度,再叠一个快速正弦,模拟电压不稳
    float flick = 0.55 + 0.45 * Hash11(floor(t * 20.0));
    flick *= 0.9 + 0.1 * sin(t * 90.0);
    float flicker = mix(1.0, flick, _FlickerAmount * _Toggles.y);

    // 色差的另一半:R/B 通道的菲涅尔用不同的指数,轮廓处出现彩色镶边
    vec3 rimRGB = vec3(FresnelRim(N, viewWS, _RimPower * (1.0 - chroma)),
                       rim,
                       FresnelRim(N, viewWS, _RimPower * (1.0 + chroma)));

    vec3 intensity = (_BaseAlpha + rimRGB) * lines + band;
    vec3 color = _HoloColor.rgb * intensity * flicker;
    fragColor = vec4(color, 1.0);
}

完整 Shader:

Assets/Cookbook/Ch18/Hologram.shader
// 案例 18.5:全息投影。菲涅尔轮廓 + 世界空间扫描线 + 闪烁 + 故障横移 + 色差(RGB 分离)。
// 透明队列、加法混合、不写深度:全息是“光”,不遮挡后面的东西。
Shader "Cookbook/Ch18/Hologram"
{
    Properties
    {
        [HDR] _HoloColor ("Holo Color", Color) = (0.25,1.1,2.2,1)
        _BaseAlpha ("Base Alpha", Range(0,1)) = 0.12
        _RimPower ("Rim Power", Range(0.5,8)) = 2.2
        [Header(Scanlines)]
        _ScanDensity ("Scanline Density (per meter)", Float) = 60
        _ScanSpeed ("Scanline Speed", Float) = 1.5
        _ScanStrength ("Scanline Strength", Range(0,1)) = 0.6
        _BandWidth ("Scan Band Width", Range(0.01,1)) = 0.25
        _BandStrength ("Scan Band Strength", Range(0,4)) = 1.2
        [Header(Flicker and Glitch)]
        _FlickerAmount ("Flicker Amount", Range(0,1)) = 0.3
        _GlitchAmount ("Glitch Offset (m)", Range(0,0.3)) = 0.06
        _GlitchChance ("Glitch Chance", Range(0,1)) = 0.3
        [Header(Chromatic)]
        _ChromaShift ("RGB Shift (fraction of a scanline)", Range(0,0.5)) = 0.2
        [Header(Time)]
        [Toggle] _AutoPlay ("Auto Play (_Time)", Float) = 0
        _Phase ("Phase (seconds, for screenshots)", Float) = 0
        _Toggles ("Feature Toggles (scan, flicker, glitch, chroma)", Vector) = (1,1,1,1)
    }

    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "Hologram"
            Tags { "LightMode"="UniversalForward" }
            Blend One One           // 加法:越叠越亮,背景不会被盖住
            ZWrite Off
            Cull Back

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Assets/Cookbook/Ch18/Ch18Common.hlsl"

            CBUFFER_START(UnityPerMaterial)
                half4 _HoloColor;
                half  _BaseAlpha;
                half  _RimPower;
                float _ScanDensity;
                float _ScanSpeed;
                half  _ScanStrength;
                half  _BandWidth;
                half  _BandStrength;
                half  _FlickerAmount;
                half  _GlitchAmount;
                half  _GlitchChance;
                half  _ChromaShift;
                half  _AutoPlay;
                float _Phase;
                half4 _Toggles;
            CBUFFER_END

            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; };
            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float3 positionWS : TEXCOORD0;
                half3  normalWS   : TEXCOORD1;
                float  objectY    : TEXCOORD2;   // 物体原点高度,扫描带相对它移动
            };

            float HoloTime() { return _AutoPlay > 0.5 ? _Time.y : _Phase; }

            Varyings vert(Attributes v)
            {
                Varyings o;
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                float t = HoloTime();
                // 故障:时间按 1/12 秒取整得到“当前这一拍”,这一拍里按概率决定要不要抖
                float beat = floor(t * 12.0);
                float glitchOn = step(1.0 - _GlitchChance, Hash11(beat)) * _Toggles.z;
                // 把物体按高度切成一条条横带,每条带随机左右平移
                float slice = floor(positionWS.y * 9.0);
                float shift = (Hash21(float2(slice, beat)) - 0.5) * 2.0;
                shift *= step(0.7, Hash21(float2(slice * 1.7, beat + 3.1)));  // 只有部分横带移动
                positionWS.x += shift * _GlitchAmount * glitchOn;

                o.positionWS = positionWS;
                o.positionCS = TransformWorldToHClip(positionWS);
                o.normalWS = TransformObjectToWorldNormal(v.normalOS);
                o.objectY = TransformObjectToWorld(float3(0, 0, 0)).y;
                return o;
            }

            // 细扫描线:沿世界 Y 的周期条纹。返回 0~1,1 是亮线
            half Scanlines(float y, float t)
            {
                float s = frac(y * _ScanDensity - t * _ScanSpeed);
                return smoothstep(0.25, 0.15, abs(s - 0.5));   // 每个周期中间约 40% 是亮线,边缘柔化防锯齿
            }

            half4 frag(Varyings i) : SV_Target
            {
                float t = HoloTime();
                half3 normalWS = normalize(i.normalWS);
                half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);

                // 1) 菲涅尔:轮廓亮、正对暗,让中空的“光壳”感出来
                half rim = FresnelRim(normalWS, viewWS, _RimPower);

                // 2) 扫描线 + 一条缓慢上移的宽亮带;色差开启时 R/B 两个通道错开采样
                // 错开量以“一条扫描线周期”为单位:错开整周期就又重合了,0.2~0.35 个周期最明显
                half chroma = _ChromaShift * _Toggles.w;
                float shift = chroma / _ScanDensity;
                half3 lines = half3(Scanlines(i.positionWS.y + shift, t),
                                    Scanlines(i.positionWS.y,         t),
                                    Scanlines(i.positionWS.y - shift, t));
                lines = lerp(1.0h, lines, _ScanStrength * _Toggles.x);
                float bandPos = frac(t * 0.35) * 2.6 - 0.3;                  // 相对物体原点的高度
                half band = exp(-abs(i.positionWS.y - i.objectY - bandPos) / _BandWidth) * _BandStrength * _Toggles.x;

                // 3) 闪烁:每 1/20 秒随机一个亮度,再叠一个快速正弦,模拟电压不稳
                float flick = 0.55 + 0.45 * Hash11(floor(t * 20.0));
                flick *= 0.9 + 0.1 * sin(t * 90.0);
                half flicker = lerp(1.0h, flick, _FlickerAmount * _Toggles.y);

                // 色差的另一半:R/B 通道的菲涅尔用不同的指数,轮廓处出现彩色镶边
                half3 rimRGB = half3(FresnelRim(normalWS, viewWS, _RimPower * (1.0h - chroma)),
                                     rim,
                                     FresnelRim(normalWS, viewWS, _RimPower * (1.0h + chroma)));

                half3 intensity = (_BaseAlpha + rimRGB) * lines + band;
                half3 color = _HoloColor.rgb * intensity * flicker;
                return half4(color, 1);
            }
            ENDHLSL
        }
    }
}
// 案例 18.5:全息投影。菲涅尔轮廓 + 世界空间扫描线 + 闪烁 + 故障横移 + 色差(RGB 分离)。
// 透明队列、加法混合、不写深度:全息是“光”,不遮挡后面的东西。(GLSL 版,对应 Ch18/Hologram.shader)
Shader "Cookbook/Ch18/Hologram"
{
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "Hologram"
            Tags { "LightMode"="UniversalForward" }
            Blend One One           // 加法:越叠越亮,背景不会被盖住
            ZWrite Off
            Cull Back

            GLSLPROGRAM
            #include "Ch18/Ch18Common.glsl"

            uniform vec4  _HoloColor;
            uniform float _BaseAlpha;
            uniform float _RimPower;
            uniform float _ScanDensity;
            uniform float _ScanSpeed;
            uniform float _ScanStrength;
            uniform float _BandWidth;
            uniform float _BandStrength;
            uniform float _FlickerAmount;
            uniform float _GlitchAmount;
            uniform float _GlitchChance;
            uniform float _ChromaShift;
            uniform float _AutoPlay;
            uniform float _Phase;
            uniform vec4  _Toggles;

            VARYING vec3  positionWS;
            VARYING vec3  normalWS;
            VARYING float objectY;     // 物体原点高度,扫描带相对它移动

            float HoloTime() { return _AutoPlay > 0.5 ? _Time.y : _Phase; }

            #ifdef VERTEX
            void main()
            {
                vec3 pWS = TransformObjectToWorld(positionOS.xyz);
                float t = HoloTime();
                // 故障:时间按 1/12 秒取整得到“当前这一拍”,这一拍里按概率决定要不要抖
                float beat = floor(t * 12.0);
                float glitchOn = step(1.0 - _GlitchChance, Hash11(beat)) * _Toggles.z;
                // 把物体按高度切成一条条横带,每条带随机左右平移
                float slice = floor(pWS.y * 9.0);
                float shift = (Hash21(vec2(slice, beat)) - 0.5) * 2.0;
                shift *= step(0.7, Hash21(vec2(slice * 1.7, beat + 3.1)));  // 只有部分横带移动
                pWS.x += shift * _GlitchAmount * glitchOn;

                positionWS = pWS;
                gl_Position = TransformWorldToHClip(pWS);
                normalWS = TransformObjectToWorldNormal(normalOS);
                objectY = TransformObjectToWorld(vec3(0.0)).y;
            }
            #endif

            #ifdef FRAGMENT
            // 细扫描线:沿世界 Y 的周期条纹。返回 0~1,1 是亮线
            float Scanlines(float y, float t)
            {
                float s = fract(y * _ScanDensity - t * _ScanSpeed);
                return smoothstep(0.25, 0.15, abs(s - 0.5));   // 每个周期中间约 40% 是亮线,边缘柔化防锯齿
            }

            void main()
            {
                float t = HoloTime();
                vec3 N = normalize(normalWS);
                vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);

                // 1) 菲涅尔:轮廓亮、正对暗,让中空的“光壳”感出来
                float rim = FresnelRim(N, viewWS, _RimPower);

                // 2) 扫描线 + 一条缓慢上移的宽亮带;色差开启时 R/B 两个通道错开采样
                // 错开量以“一条扫描线周期”为单位:错开整周期就又重合了,0.2~0.35 个周期最明显
                float chroma = _ChromaShift * _Toggles.w;
                float shift = chroma / _ScanDensity;
                vec3 lines = vec3(Scanlines(positionWS.y + shift, t),
                                  Scanlines(positionWS.y,         t),
                                  Scanlines(positionWS.y - shift, t));
                lines = mix(vec3(1.0), lines, _ScanStrength * _Toggles.x);
                float bandPos = fract(t * 0.35) * 2.6 - 0.3;                  // 相对物体原点的高度
                float band = exp(-abs(positionWS.y - objectY - bandPos) / _BandWidth) * _BandStrength * _Toggles.x;

                // 3) 闪烁:每 1/20 秒随机一个亮度,再叠一个快速正弦,模拟电压不稳
                float flick = 0.55 + 0.45 * Hash11(floor(t * 20.0));
                flick *= 0.9 + 0.1 * sin(t * 90.0);
                float flicker = mix(1.0, flick, _FlickerAmount * _Toggles.y);

                // 色差的另一半:R/B 通道的菲涅尔用不同的指数,轮廓处出现彩色镶边
                vec3 rimRGB = vec3(FresnelRim(N, viewWS, _RimPower * (1.0 - chroma)),
                                   rim,
                                   FresnelRim(N, viewWS, _RimPower * (1.0 + chroma)));

                vec3 intensity = (_BaseAlpha + rimRGB) * lines + band;
                vec3 color = _HoloColor.rgb * intensity * flicker;
                fragColor = vec4(color, 1.0);
            }
            #endif
            ENDGLSL
        }
    }
}
图 18-9全息的四个图层逐个叠加。从左到右:① 只有菲涅尔光壳;② 加扫描线;③ 加闪烁与故障横移(拍图时强制本拍触发);④ 加 RGB 色差。均开启 Bloom。
图 18-10全息投影台。投影台上的光圈同样使用全息材质(一个细圆环),扫描亮带正好经过物体中部。Bloom 让轮廓光晕染开来。

常见坑与扩展

  • 看起来“糊成一团”:加法混合下,物体前后两层壳会叠加。复杂模型(比如角色)重叠层太多会过曝。一种改法是先用一个只写深度的 Pass(ColorMask 0)把最前面一层的深度写进去,第二个 Pass 用 ZTest LEqual 只画最前面那层——这就是第 16 章讲的“预深度 Pass”。
  • 扫描线闪烁、摩尔纹:扫描线密度高于屏幕像素密度时会产生摩尔纹。远处可以降低密度,或者用 fwidth 做抗锯齿(案例 18.10 的网格线就是这么做的)。
  • 移动端:加法混合的透明物体不能被 Early-Z 剔除,大面积全息会带来可观的 Overdraw(第 29 章)。控制屏幕占比,必要时降低扫描线等计算的精度(half)。

案例 18.6能量护盾(上):深度交接高亮进阶单独打开 ↗

目标:一个半球形能量护盾罩在地面上,护盾与地面、与穿过它的物体相交的地方出现一圈亮线,就像能量场被“挤压”一样。

原理:比较两个深度

护盾是透明物体,它在 Transparent 队列里绘制。到那个时候,所有不透明物体都已经画完了,URP 会把它们的深度复制到 _CameraDepthTexture(需要 URP Asset 里开启 Depth Texture)。于是在护盾的每个像素上,我们知道两个距离:

  • 场景深度:沿这个像素的视线,最近的不透明表面离相机多远——从 _CameraDepthTexture 采样再线性化;
  • 护盾深度:护盾自身这个片元离相机多远——在顶点着色器里算出观察空间深度传下来。

两者之差 gap = 场景深度 − 护盾深度。gap 接近 0,说明护盾表面紧贴着某个不透明表面——这就是“相交处”。用 1 − saturate(gap / width) 就得到一条宽度为 width 米的交接遮罩。

地面(不透明,写入深度图) 护盾(透明,不写深度) 相机 护盾深度 场景深度 gap 较大:不亮 gap ≈ 0:交接线
图 18-11同一条视线上,护盾片元的深度与深度图里的场景深度之差。越靠近护盾与地面的交线,差值越小。

URP 14 里读取深度图

URP 提供了现成的头文件 DeclareDepthTexture.hlsl,摘录如下(URP 14 ShaderLibrary/DeclareDepthTexture.hlsl):

TEXTURE2D_X_FLOAT(_CameraDepthTexture);
SAMPLER(sampler_CameraDepthTexture);

float SampleSceneDepth(float2 uv)
{
    return SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, UnityStereoTransformScreenSpaceTex(uv)).r;
}

它返回的是原始设备深度:非线性,并且在 Metal/D3D/Vulkan 等平台上是反转的(近处为 1,远处为 0,即 Reversed-Z,第 1 章)。要和“米”比较,必须用 LinearEyeDepth(raw, _ZBufferParams) 转成观察空间深度。屏幕 UV 用 GetNormalizedScreenSpaceUV(positionCS)——在片元着色器里,SV_POSITION 的 xy 是像素坐标,这个函数除以屏幕尺寸并处理了平台的 UV 翻转。

护盾自身的深度在顶点着色器里算:-TransformWorldToView(positionWS).z。观察空间是右手系,相机看向 −Z,所以取负。也可以用 Common.hlsl 里的重载 LinearEyeDepth(positionWS, UNITY_MATRIX_V),结果相同(第 19 章就用了这个写法)。

Assets/Cookbook/Ch18/Shield.shader(节选:frag)
half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target
{
    float t = ShieldTime();
    half3 normalWS = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0h, -1.0h);
    half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);

    // 1) 深度交接:场景深度 - 自身深度 越小,说明越贴近别的物体表面
    float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);
    float sceneEye = LinearEyeDepth(SampleSceneDepth(screenUV), _ZBufferParams);
    float gap = sceneEye - i.eyeDepth;
    half intersect = 1.0h - saturate(gap / max(_IntersectWidth, 1e-4h));
    intersect = intersect * intersect * _IntersectStrength;

    // 2) 菲涅尔轮廓(背面也算,球的内壁边缘同样会亮)
    half rim = FresnelRim(normalWS, viewWS, _RimPower);

    // 3) 六边形:球面 U 绕一圈(2πR),V 从南极到北极(πR),
    //    所以 V 方向只铺 U 的一半,格子才不被拉伸;_HexScale 取整数,U 的接缝处就能无缝衔接
    float2 hp = i.uv * float2(_HexScale, _HexScale * 0.5);
    float4 hex = HexGrid(hp);
    half edge = smoothstep(0.5 - _HexLine, 0.5, HexDist(hex.xy));
    // 每个格子一个随机相位,缓慢呼吸明暗
    half cellPulse = 0.5 + 0.5 * sin(t * 2.0 + Hash21(hex.zw) * 6.2831);
    half hexMask = (edge + cellPulse * 0.15h) * _HexStrength * i.poleFade;

    // 4) 受击:环经过的地方六边形整体点亮
    half hit = HitRing(i.positionWS);
    half hitHex = hit * (0.35h + edge) * _HitStrength;

    half back = IS_FRONT_VFACE(face, 1.0h, 0.45h);    // 内壁暗一些,前后有层次
    half intensity = (_BaseAlpha + rim + hexMask * (0.3h + rim)) * back + intersect + hitHex;
    return half4(_ShieldColor.rgb * intensity, 1);
}
void main()
{
    float t = ShieldTime();
    vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0);
    vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);

    // 1) 深度交接:场景深度 - 自身深度 越小,说明越贴近别的物体表面
    vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
    float sceneEye = LinearEyeDepth(SampleSceneDepth(screenUV), _ZBufferParams);
    float gap = sceneEye - eyeDepth;
    float intersect = 1.0 - saturate(gap / max(_IntersectWidth, 1e-4));
    intersect = intersect * intersect * _IntersectStrength;

    // 2) 菲涅尔轮廓(背面也算,球的内壁边缘同样会亮)
    float rim = FresnelRim(N, viewWS, _RimPower);

    // 3) 六边形:球面 U 绕一圈(2πR),V 从南极到北极(πR),
    //    所以 V 方向只铺 U 的一半,格子才不被拉伸;_HexScale 取整数,U 的接缝处就能无缝衔接
    vec2 hp = uv * vec2(_HexScale, _HexScale * 0.5);
    vec4 hex = HexGrid(hp);
    float edge = smoothstep(0.5 - _HexLine, 0.5, HexDist(hex.xy));
    // 每个格子一个随机相位,缓慢呼吸明暗
    float cellPulse = 0.5 + 0.5 * sin(t * 2.0 + Hash21(hex.zw) * 6.2831);
    float hexMask = (edge + cellPulse * 0.15) * _HexStrength * poleFade;

    // 4) 受击:环经过的地方六边形整体点亮
    float hit = HitRing(positionWS);
    float hitHex = hit * (0.35 + edge) * _HitStrength;

    float back = gl_FrontFacing ? 1.0 : 0.45;    // 内壁暗一些,前后有层次
    float intensity = (_BaseAlpha + rim + hexMask * (0.3 + rim)) * back + intersect + hitHex;
    fragColor = vec4(_ShieldColor.rgb * intensity, 1.0);
}
图 18-12交接高亮。左:关闭;中:宽度 0.12 米,一条细亮线;右:宽度 0.6 米,交线处是一片较宽的光晕。注意左侧立方体穿过护盾的地方同样出现了交线。

常见坑

  • 没有效果 / 全屏都亮:先确认 URP Asset(或相机)开启了 Depth Texture。没开时 _CameraDepthTexture 是一张默认贴图,交接遮罩会变成全 0 或全 1。
  • 透明物体不参与:深度图只包含不透明物体(以及写深度的 Alpha Test 物体)。护盾和别的透明物体(比如另一个护盾、粒子)之间不会有交接线。
  • 正交相机:LinearEyeDepth 按透视投影的公式线性化。正交相机的深度本来就是线性的,要用 LinearDepthToEyeDepth(URP 14 ShaderVariablesFunctions.hlsl),并根据 unity_OrthoParams.w 判断投影类型。
  • 双面:本例 Cull Off,护盾的内壁也参与计算,所以从外面能透过前壁看到后壁与地面的交线。内壁亮度乘了 0.45,制造前后层次。

案例 18.7能量护盾(下):六边形网格与受击涟漪高阶单独打开 ↗

目标:给护盾加上蜂窝状六边形网格,每个格子有自己的明暗呼吸;被击中时,从命中点扩散出一圈光环,光环经过的格子被点亮,护盾表面同时鼓起一圈波纹。

六边形网格的数学

正六边形可以密铺平面,它的中心构成一个“错位的矩形网格”:把平面看成两套矩形网格的叠加,格子尺寸都是 1 × √3,第二套相对第一套偏移 (0.5, √3/2)。对任意点 p,分别求出它在两套网格里所属格子的中心,离哪个中心更近,p 就属于哪个六边形。

p ● 第一套网格的格子中心 (1 × √3) ● 第二套网格:偏移 (0.5, √3/2) p 离橙色中心更近 → 属于那个六边形 局部坐标 h = p − 最近中心 HexDist(h):中心 0,边上 0.5 边线 = smoothstep(0.5 − 线宽, 0.5, HexDist)
图 18-13六边形网格 = 两套错开的矩形网格。点 p 取离它最近的那个中心作为所属格子。
Assets/Cookbook/Ch18/Shield.shader(节选:hex)
// 六边形网格:返回到最近六边形中心的局部坐标(xy)与格子 id(zw)
float4 HexGrid(float2 p)
{
    const float2 s = float2(1.0, 1.7320508);           // (1, √3)
    float4 c = floor(float4(p, p - float2(0.5, 1.0)) / s.xyxy) + 0.5;
    float4 h = float4(p - c.xy * s, p - (c.zw + 0.5) * s);
    return dot(h.xy, h.xy) < dot(h.zw, h.zw) ? float4(h.xy, c.xy) : float4(h.zw, c.zw + 0.5);
}

// 六边形的“距离”:中心为 0,边上为 0.5
float HexDist(float2 p)
{
    p = abs(p);
    return max(dot(p, float2(0.5, 0.8660254)), p.x);
}
// 六边形网格:返回到最近六边形中心的局部坐标(xy)与格子 id(zw)
vec4 HexGrid(vec2 p)
{
    const vec2 s = vec2(1.0, 1.7320508);           // (1, √3)
    vec4 c = floor(vec4(p, p - vec2(0.5, 1.0)) / s.xyxy) + 0.5;
    vec4 h = vec4(p - c.xy * s, p - (c.zw + 0.5) * s);
    return dot(h.xy, h.xy) < dot(h.zw, h.zw) ? vec4(h.xy, c.xy) : vec4(h.zw, c.zw + 0.5);
}

// 六边形的“距离”:中心为 0,边上为 0.5
float HexDist(vec2 p)
{
    p = abs(p);
    return max(dot(p, vec2(0.5, 0.8660254)), p.x);
}

HexDist 求的是六边形意义下的“半径”:利用对称性先取绝对值,六边形的边由两组直线围成——竖直边 x = 常数,斜边法线方向是 (0.5, √3/2),取两者最大值就是到中心的“六边形距离”。HexGrid 返回的 zw 是格子 id,用它做哈希,每个格子就能拥有自己的随机相位,实现各自独立的明暗呼吸。

贴到球面上

球体的 UV 是经纬度展开:U 绕赤道一圈(周长 2πR),V 从南极到北极(πR)。要让格子不被拉伸,V 方向的格子数应该是 U 方向的一半;_HexScale 取整数时,U=0 和 U=1 的接缝处正好是网格周期的整数倍,可以无缝衔接。经纬度展开在两极会把整行格子挤成一点,所以用 1 − |normalOS.y|6 在两极附近把六边形淡出。半球护盾的极点在顶上,从正常视角看占的面积很小,这个处理已经足够;如果护盾需要从正上方看,可以改用立方体球(Cube Sphere)的网格与 UV。

受击涟漪

涟漪是另一种“距离遮罩 + 边带”:以命中点为圆心,到命中点的距离为 d,环的半径随进度增长 r = progress × R,1 − |d − r| / width 就是一条环形带。环越往外越淡(乘 1 − progress),命中点本身再加一个快速消退的亮斑。

Assets/Cookbook/Ch18/Shield.shader(节选:hit)
// 受击涟漪:以命中点为圆心向外扩散的一圈环,越往外越淡
half HitRing(float3 positionWS)
{
    float d = distance(positionWS, _HitPos.xyz);
    float r = _HitProgress * _HitRadius;
    float width = 0.12 + 0.25 * _HitProgress;
    half ring = saturate(1.0 - abs(d - r) / width);
    half fade = (1.0 - _HitProgress) * step(0.001, _HitProgress);
    // 命中点本身还有一个快速消退的亮斑
    half spot = saturate(1.0 - d / (0.4 + r * 0.3)) * (1.0 - _HitProgress) * (1.0 - _HitProgress);
    return (ring * ring + spot) * fade;
}
// 受击涟漪:以命中点为圆心向外扩散的一圈环,越往外越淡
float HitRing(vec3 pWS)
{
    float d = distance(pWS, _HitPos.xyz);
    float r = _HitProgress * _HitRadius;
    float width = 0.12 + 0.25 * _HitProgress;
    float ring = saturate(1.0 - abs(d - r) / width);
    float fade = (1.0 - _HitProgress) * step(0.001, _HitProgress);
    // 命中点本身还有一个快速消退的亮斑
    float spot = saturate(1.0 - d / (0.4 + r * 0.3)) * (1.0 - _HitProgress) * (1.0 - _HitProgress);
    return (ring * ring + spot) * fade;
}

同一个函数在顶点着色器里再调用一次,把顶点沿法线推出去一点点,护盾表面就会随光环鼓起一圈波纹。这里用的是世界空间的直线距离(弦长),不是球面上的弧长;涟漪半径不大时两者差别很小,看不出区别。

完整 Shader:

Assets/Cookbook/Ch18/Shield.shader
// 案例 18.6~18.7:能量护盾。深度交接高亮 + 六边形网格 + 菲涅尔 + 受击涟漪。
// 透明队列读取 _CameraDepthTexture(URP Asset 需开启 Depth Texture)。
Shader "Cookbook/Ch18/Shield"
{
    Properties
    {
        [HDR] _ShieldColor ("Shield Color", Color) = (0.2,0.7,1.6,1)
        _BaseAlpha ("Base Intensity", Range(0,1)) = 0.04
        _RimPower ("Rim Power", Range(0.5,8)) = 3
        [Header(Intersection)]
        _IntersectWidth ("Intersect Width (m)", Range(0,1)) = 0.25
        _IntersectStrength ("Intersect Strength", Range(0,8)) = 3
        [Header(Hexagon)]
        _HexScale ("Hex Count (around)", Float) = 16
        _HexLine ("Hex Line Width", Range(0.005,0.2)) = 0.05
        _HexStrength ("Hex Strength", Range(0,2)) = 0.6
        [Header(Hit)]
        _HitPos ("Hit Position (World)", Vector) = (0,1,-2,0)
        _HitProgress ("Hit Progress", Range(0,1)) = 0
        _HitRadius ("Hit Max Radius (m)", Float) = 2.5
        _HitStrength ("Hit Strength", Range(0,10)) = 4
        _HitDisplace ("Hit Displacement (m)", Range(0,0.3)) = 0.06
        [Header(Time)]
        [Toggle] _AutoPlay ("Auto Play (_Time)", Float) = 0
        _Phase ("Phase (seconds)", Float) = 0
    }

    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "Shield"
            Tags { "LightMode"="UniversalForward" }
            Blend One One
            ZWrite Off
            Cull Off            // 双面:从外面也能看到球的内壁,更有“罩子”的体积感

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Assets/Cookbook/Ch18/Ch18Common.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"

            CBUFFER_START(UnityPerMaterial)
                half4  _ShieldColor;
                half   _BaseAlpha;
                half   _RimPower;
                half   _IntersectWidth;
                half   _IntersectStrength;
                float  _HexScale;
                half   _HexLine;
                half   _HexStrength;
                float4 _HitPos;
                half   _HitProgress;
                float  _HitRadius;
                half   _HitStrength;
                half   _HitDisplace;
                half   _AutoPlay;
                float  _Phase;
            CBUFFER_END

            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; };
            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float3 positionWS : TEXCOORD0;
                half3  normalWS   : TEXCOORD1;
                float2 uv         : TEXCOORD2;
                float  eyeDepth   : TEXCOORD3;   // 本片元到相机的观察空间深度(米)
                half   poleFade   : TEXCOORD4;
            };

            float ShieldTime() { return _AutoPlay > 0.5 ? _Time.y : _Phase; }

            // 受击涟漪:以命中点为圆心向外扩散的一圈环,越往外越淡
            half HitRing(float3 positionWS)
            {
                float d = distance(positionWS, _HitPos.xyz);
                float r = _HitProgress * _HitRadius;
                float width = 0.12 + 0.25 * _HitProgress;
                half ring = saturate(1.0 - abs(d - r) / width);
                half fade = (1.0 - _HitProgress) * step(0.001, _HitProgress);
                // 命中点本身还有一个快速消退的亮斑
                half spot = saturate(1.0 - d / (0.4 + r * 0.3)) * (1.0 - _HitProgress) * (1.0 - _HitProgress);
                return (ring * ring + spot) * fade;
            }

            Varyings vert(Attributes v)
            {
                Varyings o;
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                float3 normalWS = TransformObjectToWorldNormal(v.normalOS);
                // 涟漪经过的地方顶点沿法线鼓起一点点
                positionWS += normalWS * HitRing(positionWS) * _HitDisplace;
                o.positionWS = positionWS;
                o.positionCS = TransformWorldToHClip(positionWS);
                o.normalWS = normalWS;
                o.uv = v.uv;
                o.eyeDepth = -TransformWorldToView(positionWS).z;   // 观察空间看向 -Z,取负得正的深度
                o.poleFade = 1.0 - pow(abs(v.normalOS.y), 6.0);      // 球面 UV 在两极挤成一点,六边形淡出
                return o;
            }

            // 六边形网格:返回到最近六边形中心的局部坐标(xy)与格子 id(zw)
            float4 HexGrid(float2 p)
            {
                const float2 s = float2(1.0, 1.7320508);           // (1, √3)
                float4 c = floor(float4(p, p - float2(0.5, 1.0)) / s.xyxy) + 0.5;
                float4 h = float4(p - c.xy * s, p - (c.zw + 0.5) * s);
                return dot(h.xy, h.xy) < dot(h.zw, h.zw) ? float4(h.xy, c.xy) : float4(h.zw, c.zw + 0.5);
            }

            // 六边形的“距离”:中心为 0,边上为 0.5
            float HexDist(float2 p)
            {
                p = abs(p);
                return max(dot(p, float2(0.5, 0.8660254)), p.x);
            }

            half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target
            {
                float t = ShieldTime();
                half3 normalWS = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0h, -1.0h);
                half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);

                // 1) 深度交接:场景深度 - 自身深度 越小,说明越贴近别的物体表面
                float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);
                float sceneEye = LinearEyeDepth(SampleSceneDepth(screenUV), _ZBufferParams);
                float gap = sceneEye - i.eyeDepth;
                half intersect = 1.0h - saturate(gap / max(_IntersectWidth, 1e-4h));
                intersect = intersect * intersect * _IntersectStrength;

                // 2) 菲涅尔轮廓(背面也算,球的内壁边缘同样会亮)
                half rim = FresnelRim(normalWS, viewWS, _RimPower);

                // 3) 六边形:球面 U 绕一圈(2πR),V 从南极到北极(πR),
                //    所以 V 方向只铺 U 的一半,格子才不被拉伸;_HexScale 取整数,U 的接缝处就能无缝衔接
                float2 hp = i.uv * float2(_HexScale, _HexScale * 0.5);
                float4 hex = HexGrid(hp);
                half edge = smoothstep(0.5 - _HexLine, 0.5, HexDist(hex.xy));
                // 每个格子一个随机相位,缓慢呼吸明暗
                half cellPulse = 0.5 + 0.5 * sin(t * 2.0 + Hash21(hex.zw) * 6.2831);
                half hexMask = (edge + cellPulse * 0.15h) * _HexStrength * i.poleFade;

                // 4) 受击:环经过的地方六边形整体点亮
                half hit = HitRing(i.positionWS);
                half hitHex = hit * (0.35h + edge) * _HitStrength;

                half back = IS_FRONT_VFACE(face, 1.0h, 0.45h);    // 内壁暗一些,前后有层次
                half intensity = (_BaseAlpha + rim + hexMask * (0.3h + rim)) * back + intersect + hitHex;
                return half4(_ShieldColor.rgb * intensity, 1);
            }
            ENDHLSL
        }
    }
}
// 案例 18.6~18.7:能量护盾。深度交接高亮 + 六边形网格 + 菲涅尔 + 受击涟漪。
// 透明队列读取 _CameraDepthTexture(URP Asset 需开启 Depth Texture)。(GLSL 版,对应 Ch18/Shield.shader)
Shader "Cookbook/Ch18/Shield"
{
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "Shield"
            Tags { "LightMode"="UniversalForward" }
            Blend One One
            ZWrite Off
            Cull Off            // 双面:从外面也能看到球的内壁,更有“罩子”的体积感

            GLSLPROGRAM
            #include "Ch18/Ch18Common.glsl"
            // _CameraDepthTexture / SampleSceneDepth 在 Core.glsl 里(对应 DeclareDepthTexture.hlsl)

            uniform vec4  _ShieldColor;
            uniform float _BaseAlpha;
            uniform float _RimPower;
            uniform float _IntersectWidth;
            uniform float _IntersectStrength;
            uniform float _HexScale;
            uniform float _HexLine;
            uniform float _HexStrength;
            uniform vec4  _HitPos;
            uniform float _HitProgress;
            uniform float _HitRadius;
            uniform float _HitStrength;
            uniform float _HitDisplace;
            uniform float _AutoPlay;
            uniform float _Phase;

            VARYING vec3  positionWS;
            VARYING vec3  normalWS;
            VARYING vec2  uv;
            VARYING float eyeDepth;   // 本片元到相机的观察空间深度(米)
            VARYING float poleFade;

            float ShieldTime() { return _AutoPlay > 0.5 ? _Time.y : _Phase; }

            // 受击涟漪:以命中点为圆心向外扩散的一圈环,越往外越淡
            float HitRing(vec3 pWS)
            {
                float d = distance(pWS, _HitPos.xyz);
                float r = _HitProgress * _HitRadius;
                float width = 0.12 + 0.25 * _HitProgress;
                float ring = saturate(1.0 - abs(d - r) / width);
                float fade = (1.0 - _HitProgress) * step(0.001, _HitProgress);
                // 命中点本身还有一个快速消退的亮斑
                float spot = saturate(1.0 - d / (0.4 + r * 0.3)) * (1.0 - _HitProgress) * (1.0 - _HitProgress);
                return (ring * ring + spot) * fade;
            }

            #ifdef VERTEX
            void main()
            {
                vec3 pWS = TransformObjectToWorld(positionOS.xyz);
                vec3 nWS = TransformObjectToWorldNormal(normalOS);
                // 涟漪经过的地方顶点沿法线鼓起一点点
                pWS += nWS * HitRing(pWS) * _HitDisplace;
                positionWS = pWS;
                gl_Position = TransformWorldToHClip(pWS);
                normalWS = nWS;
                uv = texcoord0.xy;
                eyeDepth = -TransformWorldToView(pWS).z;   // 观察空间看向 -Z,取负得正的深度
                poleFade = 1.0 - pow(abs(normalOS.y), 6.0); // 球面 UV 在两极挤成一点,六边形淡出
            }
            #endif

            #ifdef FRAGMENT
            // 六边形网格:返回到最近六边形中心的局部坐标(xy)与格子 id(zw)
            vec4 HexGrid(vec2 p)
            {
                const vec2 s = vec2(1.0, 1.7320508);           // (1, √3)
                vec4 c = floor(vec4(p, p - vec2(0.5, 1.0)) / s.xyxy) + 0.5;
                vec4 h = vec4(p - c.xy * s, p - (c.zw + 0.5) * s);
                return dot(h.xy, h.xy) < dot(h.zw, h.zw) ? vec4(h.xy, c.xy) : vec4(h.zw, c.zw + 0.5);
            }

            // 六边形的“距离”:中心为 0,边上为 0.5
            float HexDist(vec2 p)
            {
                p = abs(p);
                return max(dot(p, vec2(0.5, 0.8660254)), p.x);
            }

            void main()
            {
                float t = ShieldTime();
                vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0);
                vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);

                // 1) 深度交接:场景深度 - 自身深度 越小,说明越贴近别的物体表面
                vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
                float sceneEye = LinearEyeDepth(SampleSceneDepth(screenUV), _ZBufferParams);
                float gap = sceneEye - eyeDepth;
                float intersect = 1.0 - saturate(gap / max(_IntersectWidth, 1e-4));
                intersect = intersect * intersect * _IntersectStrength;

                // 2) 菲涅尔轮廓(背面也算,球的内壁边缘同样会亮)
                float rim = FresnelRim(N, viewWS, _RimPower);

                // 3) 六边形:球面 U 绕一圈(2πR),V 从南极到北极(πR),
                //    所以 V 方向只铺 U 的一半,格子才不被拉伸;_HexScale 取整数,U 的接缝处就能无缝衔接
                vec2 hp = uv * vec2(_HexScale, _HexScale * 0.5);
                vec4 hex = HexGrid(hp);
                float edge = smoothstep(0.5 - _HexLine, 0.5, HexDist(hex.xy));
                // 每个格子一个随机相位,缓慢呼吸明暗
                float cellPulse = 0.5 + 0.5 * sin(t * 2.0 + Hash21(hex.zw) * 6.2831);
                float hexMask = (edge + cellPulse * 0.15) * _HexStrength * poleFade;

                // 4) 受击:环经过的地方六边形整体点亮
                float hit = HitRing(positionWS);
                float hitHex = hit * (0.35 + edge) * _HitStrength;

                float back = gl_FrontFacing ? 1.0 : 0.45;    // 内壁暗一些,前后有层次
                float intensity = (_BaseAlpha + rim + hexMask * (0.3 + rim)) * back + intersect + hitHex;
                fragColor = vec4(_ShieldColor.rgb * intensity, 1.0);
            }
            #endif
            ENDGLSL
        }
    }
}
图 18-14受击涟漪,进度从左到右为 0.06、0.25、0.5、0.78:命中亮斑出现 → 光环扩散并点亮经过的六边形 → 光环变大变淡。
图 18-15完整的能量护盾:菲涅尔轮廓 + 六边形网格 + 与地面/立方体/柱子的交接高亮 + 左侧一次受击涟漪。开启 Bloom。

从 C# 发送命中点

护盾通常挂一个碰撞体,子弹命中时把命中点和起始时间交给材质:

void OnCollisionEnter(Collision c)
{
    m_Mat.SetVector("_HitPos", c.GetContact(0).point);
    m_HitStart = Time.time;
}
void Update()
{
    float p = (Time.time - m_HitStart) / hitDuration;   // 0~1,超过 1 即结束
    m_Mat.SetFloat("_HitProgress", p >= 1f ? 0f : p);
}

想同时显示多个命中点,可以把命中点做成数组:在 CBUFFER 里声明 float4 _Hits[4](xyz 位置、w 进度),用 Material.SetVectorArray 设置,Shader 里循环累加。数组长度要固定,并且同一材质所有 Pass 的声明保持一致。

案例 18.8扭曲隐身:采样不透明纹理进阶单独打开 ↗

目标:光学迷彩。物体几乎透明,但它会把身后的画面轻微“弯折”,轮廓处还有一圈淡淡的亮边,运动时有热浪一样的抖动。

原理:把身后的画面拿来扭一扭

透明物体想“透出”身后的东西,有两种做法:一是 Alpha 混合,让 GPU 把自己和已有的画面按比例混合;二是自己去读“身后的画面”,想怎么处理就怎么处理。扭曲只能用第二种:我们需要读取的不是正好在自己身后的那个像素,而是旁边一点的像素。

URP 在不透明物体和天空盒画完之后,会把当时的相机颜色拷贝一份到 _CameraOpaqueTexture(需要 URP Asset 开启 Opaque Texture),这一步在 Frame Debugger 里叫 CopyColor。透明队列里的 Shader 用 DeclareOpaqueTexture.hlsl 提供的 SampleSceneColor(uv) 就能读到它:

// 摘自 URP 14 ShaderLibrary/DeclareOpaqueTexture.hlsl
TEXTURE2D_X(_CameraOpaqueTexture);
SAMPLER(sampler_CameraOpaqueTexture);

float3 SampleSceneColor(float2 uv)
{
    return SAMPLE_TEXTURE2D_X(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, UnityStereoTransformScreenSpaceTex(uv)).rgb;
}
不透明物体 天空盒 CopyColor_CameraOpaqueTexture 透明物体 后处理 在这里读取 → 只看得到不透明物体与天空
图 18-16不透明纹理的拷贝时机。透明物体读到的是“自己之前”的画面,所以看不到其他透明物体。

偏移量怎么定?最自然的是用法线:把世界法线转到观察空间,取 xy。物体正对相机的中央,观察空间法线几乎是 (0, 0, 1),xy 很小,几乎不扭曲;越靠近轮廓,法线越“横”过来,xy 越大,扭得越厉害——这和真实的玻璃球很像(轮廓处折射最强)。再叠一层流动噪声,运动时就有了热浪般的抖动。

Assets/Cookbook/Ch18/Cloak.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float t = _AutoPlay > 0.5 ? _Time.y : _Phase;
    half3 normalWS = normalize(i.normalWS);
    half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
    float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);

    // 法线转到观察空间,取 xy 作为屏幕上的“推开方向”:轮廓处法线几乎平行屏幕,推得最远
    half3 normalVS = TransformWorldToViewDir(normalWS);
    float2 offset = normalVS.xy * _Distortion;
    // 再叠一层流动噪声,让隐身体表面有“热浪”一样的抖动
    half2 n = SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, i.uv + float2(0, t * 0.2)).rg - 0.5h;
    offset += n * _NoiseDistortion;
    // 观察空间 x 向右、y 向上,与 GetNormalizedScreenSpaceUV 得到的 UV 方向一致(平台翻转已由它处理)
    half3 behind = SampleSceneColor(screenUV + offset) * _Tint.rgb;

    half rim = FresnelRim(normalWS, viewWS, _RimPower);
    half3 cloak = behind + _RimColor.rgb * rim;

    // 现形:在隐身和正常着色之间插值(实际项目可以叠一个溶解,见案例 18.1)
    half3 visible = SimpleLighting(_BaseColor.rgb, i.positionWS, normalWS, 0.4h);
    return half4(lerp(cloak, visible, _Reveal), 1);
}
void main()
{
    float t = _AutoPlay > 0.5 ? _Time.y : _Phase;
    vec3 N = normalize(normalWS);
    vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
    vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);

    // 法线转到观察空间,取 xy 作为屏幕上的“推开方向”:轮廓处法线几乎平行屏幕,推得最远
    vec3 normalVS = TransformWorldToViewDir(N);
    vec2 offset = normalVS.xy * _Distortion;
    // 再叠一层流动噪声,让隐身体表面有“热浪”一样的抖动
    vec2 n = texture(_NoiseMap, uv + vec2(0.0, t * 0.2)).rg - 0.5;
    offset += n * _NoiseDistortion;
    // 观察空间 x 向右、y 向上,与屏幕 UV 方向一致(WebGL 不翻转 y)
    vec3 behind = SampleSceneColor(screenUV + offset) * _Tint.rgb;

    float rim = FresnelRim(N, viewWS, _RimPower);
    vec3 cloak = behind + _RimColor.rgb * rim;

    // 现形:在隐身和正常着色之间插值(实际项目可以叠一个溶解,见案例 18.1)
    vec3 visible = SimpleLighting(_BaseColor.rgb, positionWS, N, 0.4);
    fragColor = vec4(mix(cloak, visible, _Reveal), 1.0);
}

观察空间的 x 向右、y 向上,与 GetNormalizedScreenSpaceUV 返回的屏幕 UV 方向一致(平台差异已经由它处理),所以偏移可以直接加。完整代码:

Assets/Cookbook/Ch18/Cloak.shader
// 案例 18.8:扭曲隐身(光学迷彩)。采样 _CameraOpaqueTexture,按法线把屏幕 UV 推开一点,
// 物体就像一块“透明的玻璃”把身后的画面弯折了。URP Asset 需开启 Opaque Texture。
Shader "Cookbook/Ch18/Cloak"
{
    Properties
    {
        _Distortion ("Distortion (screen UV)", Range(0,0.15)) = 0.04
        _NoiseMap ("Ripple Noise", 2D) = "gray" {}
        _NoiseDistortion ("Noise Distortion", Range(0,0.05)) = 0.01
        _Tint ("Tint", Color) = (0.9,0.95,1,1)
        [HDR] _RimColor ("Rim Color", Color) = (0.4,0.9,1.4,1)
        _RimPower ("Rim Power", Range(0.5,8)) = 4
        _Reveal ("Reveal (0=invisible 1=visible)", Range(0,1)) = 0
        _BaseColor ("Visible Base Color", Color) = (0.7,0.3,0.2,1)
        [Toggle] _AutoPlay ("Auto Play (_Time)", Float) = 0
        _Phase ("Phase (seconds)", Float) = 0
    }

    SubShader
    {
        // 透明队列:画它的时候,不透明物体和天空已经拷贝进 _CameraOpaqueTexture 了
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "Cloak"
            Tags { "LightMode"="UniversalForward" }
            ZWrite On           // 写深度:隐身物体自己的前后遮挡仍然正确(它不是半透明混合,而是整块替换)
            Blend Off

            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
            #pragma multi_compile _ _FORWARD_PLUS
            #include "Assets/Cookbook/Ch18/Ch18Common.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareOpaqueTexture.hlsl"

            TEXTURE2D(_NoiseMap); SAMPLER(sampler_NoiseMap);

            CBUFFER_START(UnityPerMaterial)
                half   _Distortion;
                float4 _NoiseMap_ST;
                half   _NoiseDistortion;
                half4  _Tint;
                half4  _RimColor;
                half   _RimPower;
                half   _Reveal;
                half4  _BaseColor;
                half   _AutoPlay;
                float  _Phase;
            CBUFFER_END

            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; };
            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float3 positionWS : TEXCOORD0;
                half3  normalWS   : TEXCOORD1;
                float2 uv         : TEXCOORD2;
            };

            Varyings vert(Attributes v)
            {
                Varyings o;
                o.positionWS = TransformObjectToWorld(v.positionOS.xyz);
                o.positionCS = TransformWorldToHClip(o.positionWS);
                o.normalWS = TransformObjectToWorldNormal(v.normalOS);
                o.uv = TRANSFORM_TEX(v.uv, _NoiseMap);
                return o;
            }

            half4 frag(Varyings i) : SV_Target
            {
                float t = _AutoPlay > 0.5 ? _Time.y : _Phase;
                half3 normalWS = normalize(i.normalWS);
                half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
                float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);

                // 法线转到观察空间,取 xy 作为屏幕上的“推开方向”:轮廓处法线几乎平行屏幕,推得最远
                half3 normalVS = TransformWorldToViewDir(normalWS);
                float2 offset = normalVS.xy * _Distortion;
                // 再叠一层流动噪声,让隐身体表面有“热浪”一样的抖动
                half2 n = SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, i.uv + float2(0, t * 0.2)).rg - 0.5h;
                offset += n * _NoiseDistortion;
                // 观察空间 x 向右、y 向上,与 GetNormalizedScreenSpaceUV 得到的 UV 方向一致(平台翻转已由它处理)
                half3 behind = SampleSceneColor(screenUV + offset) * _Tint.rgb;

                half rim = FresnelRim(normalWS, viewWS, _RimPower);
                half3 cloak = behind + _RimColor.rgb * rim;

                // 现形:在隐身和正常着色之间插值(实际项目可以叠一个溶解,见案例 18.1)
                half3 visible = SimpleLighting(_BaseColor.rgb, i.positionWS, normalWS, 0.4h);
                return half4(lerp(cloak, visible, _Reveal), 1);
            }
            ENDHLSL
        }
    }
}
// 案例 18.8:扭曲隐身(光学迷彩)。采样 _CameraOpaqueTexture,按法线把屏幕 UV 推开一点,
// 物体就像一块“透明的玻璃”把身后的画面弯折了。URP Asset 需开启 Opaque Texture。(GLSL 版,对应 Ch18/Cloak.shader)
Shader "Cookbook/Ch18/Cloak"
{
    SubShader
    {
        // 透明队列:画它的时候,不透明物体和天空已经拷贝进 _CameraOpaqueTexture 了
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "Cloak"
            Tags { "LightMode"="UniversalForward" }
            ZWrite On           // 写深度:隐身物体自己的前后遮挡仍然正确(它不是半透明混合,而是整块替换)
            Blend Off

            GLSLPROGRAM
            #include "Ch18/Ch18Common.glsl"
            // _CameraOpaqueTexture / SampleSceneColor 在 Core.glsl 里(对应 DeclareOpaqueTexture.hlsl)

            uniform sampler2D _NoiseMap;
            uniform float _Distortion;
            uniform vec4  _NoiseMap_ST;
            uniform float _NoiseDistortion;
            uniform vec4  _Tint;
            uniform vec4  _RimColor;
            uniform float _RimPower;
            uniform float _Reveal;
            uniform vec4  _BaseColor;
            uniform float _AutoPlay;
            uniform float _Phase;

            VARYING vec3 positionWS;
            VARYING vec3 normalWS;
            VARYING vec2 uv;

            #ifdef VERTEX
            void main()
            {
                positionWS = TransformObjectToWorld(positionOS.xyz);
                gl_Position = TransformWorldToHClip(positionWS);
                normalWS = TransformObjectToWorldNormal(normalOS);
                uv = TransformTex(texcoord0.xy, _NoiseMap_ST);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                float t = _AutoPlay > 0.5 ? _Time.y : _Phase;
                vec3 N = normalize(normalWS);
                vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
                vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);

                // 法线转到观察空间,取 xy 作为屏幕上的“推开方向”:轮廓处法线几乎平行屏幕,推得最远
                vec3 normalVS = TransformWorldToViewDir(N);
                vec2 offset = normalVS.xy * _Distortion;
                // 再叠一层流动噪声,让隐身体表面有“热浪”一样的抖动
                vec2 n = texture(_NoiseMap, uv + vec2(0.0, t * 0.2)).rg - 0.5;
                offset += n * _NoiseDistortion;
                // 观察空间 x 向右、y 向上,与屏幕 UV 方向一致(WebGL 不翻转 y)
                vec3 behind = SampleSceneColor(screenUV + offset) * _Tint.rgb;

                float rim = FresnelRim(N, viewWS, _RimPower);
                vec3 cloak = behind + _RimColor.rgb * rim;

                // 现形:在隐身和正常着色之间插值(实际项目可以叠一个溶解,见案例 18.1)
                vec3 visible = SimpleLighting(_BaseColor.rgb, positionWS, N, 0.4);
                fragColor = vec4(mix(cloak, visible, _Reveal), 1.0);
            }
            #endif
            ENDGLSL
        }
    }
}
图 18-17扭曲隐身。左:现形状态(Reveal=1,正常着色);中:只有法线扭曲;右:加上菲涅尔亮边与噪声抖动。注意砖缝在轮廓附近被明显弯折。

常见坑

  • 前景泄漏:如果隐身物体前面挡着一个不透明物体,扭曲后的 UV 可能采样到那个前景物体的颜色,看起来像前景“漏”进了隐身体里。轮廓处偏移量大时最明显。解决办法是采样偏移位置的深度、判断它是否比自己更近,详见第 19 章水面折射的“防前景泄漏”。
  • 看不到其他透明物体:如图 18-16,拷贝发生在透明物体之前,隐身体背后的粒子、玻璃、另一个全息都不会出现在扭曲画面里。想让它们也出现,需要在透明物体之后再做一次拷贝(自定义 Renderer Feature,第 25 章),代价是多一次全屏拷贝。
  • Opaque Texture 降采样:URP Asset 里 Opaque Downsampling 默认是 2x Bilinear,读到的是半分辨率画面,隐身体会比周围略糊。追求清晰可以改成 None,代价是带宽。
  • 不投射阴影:本例没有 ShadowCaster Pass——一个隐身的东西在地上留下清晰的影子会穿帮。如果设计上需要“现形时有影子”,可以加阴影 Pass 并按 _Reveal 做抖动透明阴影(第 23 章)。
  • ZWrite On:本例写深度,因为隐身体的每个像素都“完全替换”了背景,不需要和后面的透明物体混合;写深度可以保证它自身前后遮挡正确。

案例 18.9冰冻覆盖:世界空间自上而下结冰进阶单独打开 ↗

目标:一个参数 _Freeze 从 0 到 1,物体从朝上的表面开始结冰,逐渐蔓延到侧面、底面;冰层有厚度、有裂纹、边缘泛蓝白、阳光下有闪点。

原理:法线朝上的程度就是遮罩

霜雪、冰层、苔藓、积灰,这类“覆盖层”都遵循同一个规律:越朝上的面越先覆盖。世界空间法线的 y 分量 normalWS.y 从 −1(朝下)到 1(朝上),本身就是一张遮罩;用阈值去切它,阈值从 1.5 降到 −1.5,覆盖范围就从“只有正顶面”扩展到“全身”。为了让分界线不那么整齐,再加一层噪声扰动。

这里的噪声不能用模型 UV 采样——覆盖是世界空间的现象,几个物体放在一起,冰的纹理应该连续、尺度一致。用世界坐标采样又有一个问题:只用 xz 投影,侧面会被拉成一条条竖线。所以用三平面映射(Triplanar):分别沿 X、Y、Z 三个轴投影采样,再按法线三个分量的权重混合。第 20 章会把三平面映射讲全(包括法线贴图的三平面混合),这里用最精简的版本。

Assets/Cookbook/Ch18/FrozenCore.hlsl(节选:triplanar)
// 极简三平面采样:按法线三个分量的权重,混合 YZ / XZ / XY 三个方向的投影
half TriplanarNoise(TEXTURE2D_PARAM(tex, samp), float3 positionWS, half3 normalWS, float scale)
{
    half3 w = pow(abs(normalWS), 4.0h);
    w /= (w.x + w.y + w.z);
    half x = SAMPLE_TEXTURE2D_LOD(tex, samp, positionWS.zy * scale, 0).r;
    half y = SAMPLE_TEXTURE2D_LOD(tex, samp, positionWS.xz * scale, 0).r;
    half z = SAMPLE_TEXTURE2D_LOD(tex, samp, positionWS.xy * scale, 0).r;
    return x * w.x + y * w.y + z * w.z;
}
// 极简三平面采样:按法线三个分量的权重,混合 YZ / XZ / XY 三个方向的投影
// (GLSL 没有 TEXTURE2D_PARAM,直接传 sampler2D)
float TriplanarNoise(sampler2D tex, vec3 positionWS, vec3 normalWS, float scale)
{
    vec3 w = pow(abs(normalWS), vec3(4.0));
    w /= (w.x + w.y + w.z);
    float x = textureLod(tex, positionWS.zy * scale, 0.0).r;
    float y = textureLod(tex, positionWS.xz * scale, 0.0).r;
    float z = textureLod(tex, positionWS.xy * scale, 0.0).r;
    return x * w.x + y * w.y + z * w.z;
}
Assets/Cookbook/Ch18/FrozenCore.hlsl(节选:mask)
// 覆盖遮罩:法线越朝上越先结冰,噪声把分界线打散。_Freeze 0→1,阈值从“只有正上方”一路降到“全覆盖”
half FrostMask(float3 positionWS, half3 normalWS)
{
    half n = TriplanarNoise(TEXTURE2D_ARGS(_IceNoise, sampler_IceNoise), positionWS, normalWS, _IceNoiseScale);
    half up = normalWS.y + (n - 0.5h) * 0.8h;          // -1.4 ~ 1.4
    half threshold = lerp(1.5h, -1.5h, _Freeze);
    return smoothstep(threshold - 0.1h, threshold + 0.1h, up);
}
// 覆盖遮罩:法线越朝上越先结冰,噪声把分界线打散。_Freeze 0→1,阈值从“只有正上方”一路降到“全覆盖”
float FrostMask(vec3 positionWS, vec3 normalWS)
{
    float n = TriplanarNoise(_IceNoise, positionWS, normalWS, _IceNoiseScale);
    float up = normalWS.y + (n - 0.5) * 0.8;          // -1.4 ~ 1.4
    float threshold = mix(1.5, -1.5, _Freeze);
    return smoothstep(threshold - 0.1, threshold + 0.1, up);
}

冰层的厚度

冰不是贴在表面的一张图,它有厚度。在顶点着色器里把被覆盖的顶点沿法线外推几厘米,轮廓立刻变得“胖”了一圈。顶点着色器里采样纹理必须用 SAMPLE_TEXTURE2D_LOD(顶点阶段没有屏幕导数,无法自动选择 mip),所以三平面函数里统一用了 LOD 版本。

Assets/Cookbook/Ch18/FrozenCore.hlsl(节选:thicken)
// 冰层有厚度:被覆盖的顶点沿法线外推。阴影和深度 Pass 也要做同样的外推,否则阴影对不上
float3 ThickenIce(float3 positionWS, half3 normalWS)
{
    return positionWS + normalWS * FrostMask(positionWS, normalWS) * _Thickness;
}
// 冰层有厚度:被覆盖的顶点沿法线外推。阴影和深度 Pass 也要做同样的外推,否则阴影对不上
vec3 ThickenIce(vec3 positionWS, vec3 normalWS)
{
    return positionWS + normalWS * FrostMask(positionWS, normalWS) * _Thickness;
}

和溶解一样,顶点变形必须在所有 Pass 里同步:ShadowCaster 和 DepthOnly 也要调用 ThickenIce,否则影子还是原来的瘦轮廓,深度图里的轮廓和屏幕上的也对不上(SSAO、后处理描边都会出错)。

冰的外观

片元里做四件事:① 冰色在浅蓝和深蓝之间按噪声变化,Worley 噪声(公共贴图 noise_cells)的细胞边界很亮,取边界做白色裂纹;② 光滑度提高到 0.9,冰面有锐利的高光;③ 冷色菲涅尔,模拟光在冰层里散射后从边缘透出来;④ 闪点:把世界坐标量化成很小的格子,每个格子一个哈希值,只保留最亮的千分之三,再乘一个视角相关的高光项,转动视角时会一闪一闪。

Assets/Cookbook/Ch18/Frozen.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half3 normalWS = normalize(i.normalWS);
    half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
    half mask = FrostMask(i.restWS, normalWS);

    half3 baseAlbedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).rgb * _BaseColor.rgb;

    // 冰的颜色:噪声在浅色与深色之间变化,Worley 的细胞边界做成白色裂纹
    half n = TriplanarNoise(TEXTURE2D_ARGS(_IceNoise, sampler_IceNoise), i.restWS, normalWS, _IceNoiseScale * 3.0);
    half cell = TriplanarNoise(TEXTURE2D_ARGS(_CrackMap, sampler_CrackMap), i.restWS, normalWS, _CrackScale);
    half crack = smoothstep(0.55h, 0.8h, cell) * _CrackStrength;
    half3 ice = lerp(_IceDeepColor.rgb, _IceColor.rgb, saturate(n * 1.2h + crack));
    // 冰层很薄的地方(遮罩边缘)透出一点底色
    half3 albedo = lerp(baseAlbedo, ice, mask * (0.75h + 0.25h * mask));
    half smooth = lerp(_Smoothness, 0.9h, mask);

    half3 color = SimpleLighting(albedo, i.positionWS, normalWS, smooth);

    // 冷色菲涅尔:冰面边缘泛蓝白,模拟光在冰层里散射后从边缘透出来
    half rim = FresnelRim(normalWS, viewWS, 3.0h);
    color += _IceRimColor.rgb * rim * mask;

    // 闪点:极高频哈希,只保留最亮的极少数点;乘上视角相关的项,转动相机时会“闪”
    float3 sp = floor(i.restWS * 160.0);
    half sparkle = step(0.997h, Hash21(sp.xz + sp.y * 17.0));
    half3 halfDir = SafeNormalize(GetMainLight().direction + viewWS);
    sparkle *= pow(saturate(dot(normalWS, halfDir)), 8.0h);
    color += sparkle * _SparkleStrength * mask * GetMainLight().color;
    return half4(color, 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
    float mask = FrostMask(restWS, N);

    vec3 baseAlbedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb;

    // 冰的颜色:噪声在浅色与深色之间变化,Worley 的细胞边界做成白色裂纹
    float n = TriplanarNoise(_IceNoise, restWS, N, _IceNoiseScale * 3.0);
    float cell = TriplanarNoise(_CrackMap, restWS, N, _CrackScale);
    float crack = smoothstep(0.55, 0.8, cell) * _CrackStrength;
    vec3 ice = mix(_IceDeepColor.rgb, _IceColor.rgb, saturate(n * 1.2 + crack));
    // 冰层很薄的地方(遮罩边缘)透出一点底色
    vec3 albedo = mix(baseAlbedo, ice, mask * (0.75 + 0.25 * mask));
    float smooth_ = mix(_Smoothness, 0.9, mask);

    vec3 color = SimpleLighting(albedo, positionWS, N, smooth_);

    // 冷色菲涅尔:冰面边缘泛蓝白,模拟光在冰层里散射后从边缘透出来
    float rim = FresnelRim(N, viewWS, 3.0);
    color += _IceRimColor.rgb * rim * mask;

    // 闪点:极高频哈希,只保留最亮的极少数点;乘上视角相关的项,转动相机时会“闪”
    vec3 sp = floor(restWS * 160.0);
    float sparkle = step(0.997, Hash21(sp.xz + sp.y * 17.0));
    vec3 halfDir = SafeNormalize(GetMainLight().direction + viewWS);
    sparkle *= pow(saturate(dot(N, halfDir)), 8.0);
    color += sparkle * _SparkleStrength * mask * GetMainLight().color;
    fragColor = vec4(color, 1.0);
}

完整 Shader 与公共部分:

Assets/Cookbook/Ch18/FrozenCore.hlsl
// 冰冻覆盖的公共部分:材质参数、三平面噪声、覆盖遮罩、顶点加厚。Forward/Shadow/Depth 三个 Pass 共用。
#ifndef COOKBOOK_CH18_FROZEN_CORE_INCLUDED
#define COOKBOOK_CH18_FROZEN_CORE_INCLUDED

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

TEXTURE2D(_BaseMap);  SAMPLER(sampler_BaseMap);
TEXTURE2D(_IceNoise); SAMPLER(sampler_IceNoise);
TEXTURE2D(_CrackMap); SAMPLER(sampler_CrackMap);

CBUFFER_START(UnityPerMaterial)
    float4 _BaseMap_ST;
    half4  _BaseColor;
    half   _Smoothness;
    half   _Freeze;
    half4  _IceColor;
    half4  _IceDeepColor;
    float  _IceNoiseScale;
    float  _CrackScale;
    half   _CrackStrength;
    half   _Thickness;
    half4  _IceRimColor;
    half   _SparkleStrength;
CBUFFER_END

// 极简三平面采样:按法线三个分量的权重,混合 YZ / XZ / XY 三个方向的投影
half TriplanarNoise(TEXTURE2D_PARAM(tex, samp), float3 positionWS, half3 normalWS, float scale)
{
    half3 w = pow(abs(normalWS), 4.0h);
    w /= (w.x + w.y + w.z);
    half x = SAMPLE_TEXTURE2D_LOD(tex, samp, positionWS.zy * scale, 0).r;
    half y = SAMPLE_TEXTURE2D_LOD(tex, samp, positionWS.xz * scale, 0).r;
    half z = SAMPLE_TEXTURE2D_LOD(tex, samp, positionWS.xy * scale, 0).r;
    return x * w.x + y * w.y + z * w.z;
}

// 覆盖遮罩:法线越朝上越先结冰,噪声把分界线打散。_Freeze 0→1,阈值从“只有正上方”一路降到“全覆盖”
half FrostMask(float3 positionWS, half3 normalWS)
{
    half n = TriplanarNoise(TEXTURE2D_ARGS(_IceNoise, sampler_IceNoise), positionWS, normalWS, _IceNoiseScale);
    half up = normalWS.y + (n - 0.5h) * 0.8h;          // -1.4 ~ 1.4
    half threshold = lerp(1.5h, -1.5h, _Freeze);
    return smoothstep(threshold - 0.1h, threshold + 0.1h, up);
}

// 冰层有厚度:被覆盖的顶点沿法线外推。阴影和深度 Pass 也要做同样的外推,否则阴影对不上
float3 ThickenIce(float3 positionWS, half3 normalWS)
{
    return positionWS + normalWS * FrostMask(positionWS, normalWS) * _Thickness;
}

#endif
// 冰冻覆盖的公共部分:材质参数、三平面噪声、覆盖遮罩、顶点加厚。Forward/Shadow/Depth 三个 Pass 共用。
// (GLSL 版,对应 FrozenCore.hlsl)
#ifndef COOKBOOK_CH18_FROZEN_CORE_INCLUDED
#define COOKBOOK_CH18_FROZEN_CORE_INCLUDED

uniform sampler2D _BaseMap;
uniform sampler2D _IceNoise;
uniform sampler2D _CrackMap;

uniform vec4  _BaseMap_ST;
uniform vec4  _BaseColor;
uniform float _Smoothness;
uniform float _Freeze;
uniform vec4  _IceColor;
uniform vec4  _IceDeepColor;
uniform float _IceNoiseScale;
uniform float _CrackScale;
uniform float _CrackStrength;
uniform float _Thickness;
uniform vec4  _IceRimColor;
uniform float _SparkleStrength;

// 极简三平面采样:按法线三个分量的权重,混合 YZ / XZ / XY 三个方向的投影
// (GLSL 没有 TEXTURE2D_PARAM,直接传 sampler2D)
float TriplanarNoise(sampler2D tex, vec3 positionWS, vec3 normalWS, float scale)
{
    vec3 w = pow(abs(normalWS), vec3(4.0));
    w /= (w.x + w.y + w.z);
    float x = textureLod(tex, positionWS.zy * scale, 0.0).r;
    float y = textureLod(tex, positionWS.xz * scale, 0.0).r;
    float z = textureLod(tex, positionWS.xy * scale, 0.0).r;
    return x * w.x + y * w.y + z * w.z;
}

// 覆盖遮罩:法线越朝上越先结冰,噪声把分界线打散。_Freeze 0→1,阈值从“只有正上方”一路降到“全覆盖”
float FrostMask(vec3 positionWS, vec3 normalWS)
{
    float n = TriplanarNoise(_IceNoise, positionWS, normalWS, _IceNoiseScale);
    float up = normalWS.y + (n - 0.5) * 0.8;          // -1.4 ~ 1.4
    float threshold = mix(1.5, -1.5, _Freeze);
    return smoothstep(threshold - 0.1, threshold + 0.1, up);
}

// 冰层有厚度:被覆盖的顶点沿法线外推。阴影和深度 Pass 也要做同样的外推,否则阴影对不上
vec3 ThickenIce(vec3 positionWS, vec3 normalWS)
{
    return positionWS + normalWS * FrostMask(positionWS, normalWS) * _Thickness;
}

#endif
Assets/Cookbook/Ch18/Frozen.shader
// 案例 18.9:冰冻 / 结晶覆盖。世界空间“自上而下”覆盖一层冰:加厚、冰裂纹、高光滑度、冷色边缘与闪点。
Shader "Cookbook/Ch18/Frozen"
{
    Properties
    {
        [MainTexture] _BaseMap ("Base Map", 2D) = "white" {}
        [MainColor] _BaseColor ("Base Color", Color) = (1,1,1,1)
        _Smoothness ("Base Smoothness", Range(0,1)) = 0.3
        _Freeze ("Freeze", Range(0,1)) = 0.5
        _IceColor ("Ice Color", Color) = (0.78,0.9,1,1)
        _IceDeepColor ("Ice Deep Color", Color) = (0.25,0.5,0.75,1)
        [NoScaleOffset] _IceNoise ("Ice Noise", 2D) = "gray" {}
        _IceNoiseScale ("Ice Noise Scale", Float) = 0.6
        [NoScaleOffset] _CrackMap ("Crack (Worley)", 2D) = "black" {}
        _CrackScale ("Crack Scale", Float) = 1.2
        _CrackStrength ("Crack Strength", Range(0,2)) = 0.8
        _Thickness ("Thickness (m)", Range(0,0.1)) = 0.025
        [HDR] _IceRimColor ("Ice Rim Color", Color) = (0.6,0.9,1.5,1)
        _SparkleStrength ("Sparkle", Range(0,20)) = 6
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }

            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
            #pragma multi_compile _ _FORWARD_PLUS
            #include "Assets/Cookbook/Ch18/FrozenCore.hlsl"
            #include "Assets/Cookbook/Ch18/Ch18Common.hlsl"

            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; };
            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float3 positionWS : TEXCOORD0;
                half3  normalWS   : TEXCOORD1;
                float2 uv         : TEXCOORD2;
                float3 restWS     : TEXCOORD3;   // 加厚之前的位置,用来采样噪声,避免纹理随厚度“游动”
            };

            Varyings vert(Attributes v)
            {
                Varyings o;
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                half3 normalWS = TransformObjectToWorldNormal(v.normalOS);
                o.restWS = positionWS;
                o.positionWS = ThickenIce(positionWS, normalWS);
                o.positionCS = TransformWorldToHClip(o.positionWS);
                o.normalWS = normalWS;
                o.uv = TRANSFORM_TEX(v.uv, _BaseMap);
                return o;
            }

            half4 frag(Varyings i) : SV_Target
            {
                half3 normalWS = normalize(i.normalWS);
                half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
                half mask = FrostMask(i.restWS, normalWS);

                half3 baseAlbedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).rgb * _BaseColor.rgb;

                // 冰的颜色:噪声在浅色与深色之间变化,Worley 的细胞边界做成白色裂纹
                half n = TriplanarNoise(TEXTURE2D_ARGS(_IceNoise, sampler_IceNoise), i.restWS, normalWS, _IceNoiseScale * 3.0);
                half cell = TriplanarNoise(TEXTURE2D_ARGS(_CrackMap, sampler_CrackMap), i.restWS, normalWS, _CrackScale);
                half crack = smoothstep(0.55h, 0.8h, cell) * _CrackStrength;
                half3 ice = lerp(_IceDeepColor.rgb, _IceColor.rgb, saturate(n * 1.2h + crack));
                // 冰层很薄的地方(遮罩边缘)透出一点底色
                half3 albedo = lerp(baseAlbedo, ice, mask * (0.75h + 0.25h * mask));
                half smooth = lerp(_Smoothness, 0.9h, mask);

                half3 color = SimpleLighting(albedo, i.positionWS, normalWS, smooth);

                // 冷色菲涅尔:冰面边缘泛蓝白,模拟光在冰层里散射后从边缘透出来
                half rim = FresnelRim(normalWS, viewWS, 3.0h);
                color += _IceRimColor.rgb * rim * mask;

                // 闪点:极高频哈希,只保留最亮的极少数点;乘上视角相关的项,转动相机时会“闪”
                float3 sp = floor(i.restWS * 160.0);
                half sparkle = step(0.997h, Hash21(sp.xz + sp.y * 17.0));
                half3 halfDir = SafeNormalize(GetMainLight().direction + viewWS);
                sparkle *= pow(saturate(dot(normalWS, halfDir)), 8.0h);
                color += sparkle * _SparkleStrength * mask * GetMainLight().color;
                return half4(color, 1);
            }
            ENDHLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            HLSLPROGRAM
            #pragma vertex vertShadow
            #pragma fragment fragShadow
            #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
            #include "Assets/Cookbook/Ch18/FrozenCore.hlsl"
            #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl"
            float3 _LightDirection;
            float3 _LightPosition;
            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; };
            float4 vertShadow(Attributes v) : SV_POSITION
            {
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                half3 normalWS = TransformObjectToWorldNormal(v.normalOS);
                positionWS = ThickenIce(positionWS, normalWS);
            #if _CASTING_PUNCTUAL_LIGHT_SHADOW
                float3 lightDirWS = normalize(_LightPosition - positionWS);
            #else
                float3 lightDirWS = _LightDirection;
            #endif
                float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirWS));
            #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;
            }
            half4 fragShadow() : SV_Target { return 0; }
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex vertDepth
            #pragma fragment fragDepth
            #include "Assets/Cookbook/Ch18/FrozenCore.hlsl"
            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; };
            float4 vertDepth(Attributes v) : SV_POSITION
            {
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                return TransformWorldToHClip(ThickenIce(positionWS, TransformObjectToWorldNormal(v.normalOS)));
            }
            half4 fragDepth(float4 positionCS : SV_POSITION) : SV_Target { return positionCS.z; }
            ENDHLSL
        }
    }
}
// 案例 18.9:冰冻 / 结晶覆盖。世界空间“自上而下”覆盖一层冰:加厚、冰裂纹、高光滑度、冷色边缘与闪点。
// (GLSL 版,对应 Ch18/Frozen.shader)
Shader "Cookbook/Ch18/Frozen"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }

            GLSLPROGRAM
            #include "Ch18/FrozenCore.glsl"
            #include "Ch18/Ch18Common.glsl"

            VARYING vec3 positionWS;
            VARYING vec3 normalWS;
            VARYING vec2 uv;
            VARYING vec3 restWS;   // 加厚之前的位置,用来采样噪声,避免纹理随厚度“游动”

            #ifdef VERTEX
            void main()
            {
                vec3 pWS = TransformObjectToWorld(positionOS.xyz);
                vec3 nWS = TransformObjectToWorldNormal(normalOS);
                restWS = pWS;
                positionWS = ThickenIce(pWS, nWS);
                gl_Position = TransformWorldToHClip(positionWS);
                normalWS = nWS;
                uv = TransformTex(texcoord0.xy, _BaseMap_ST);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                vec3 N = normalize(normalWS);
                vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
                float mask = FrostMask(restWS, N);

                vec3 baseAlbedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb;

                // 冰的颜色:噪声在浅色与深色之间变化,Worley 的细胞边界做成白色裂纹
                float n = TriplanarNoise(_IceNoise, restWS, N, _IceNoiseScale * 3.0);
                float cell = TriplanarNoise(_CrackMap, restWS, N, _CrackScale);
                float crack = smoothstep(0.55, 0.8, cell) * _CrackStrength;
                vec3 ice = mix(_IceDeepColor.rgb, _IceColor.rgb, saturate(n * 1.2 + crack));
                // 冰层很薄的地方(遮罩边缘)透出一点底色
                vec3 albedo = mix(baseAlbedo, ice, mask * (0.75 + 0.25 * mask));
                float smooth_ = mix(_Smoothness, 0.9, mask);

                vec3 color = SimpleLighting(albedo, positionWS, N, smooth_);

                // 冷色菲涅尔:冰面边缘泛蓝白,模拟光在冰层里散射后从边缘透出来
                float rim = FresnelRim(N, viewWS, 3.0);
                color += _IceRimColor.rgb * rim * mask;

                // 闪点:极高频哈希,只保留最亮的极少数点;乘上视角相关的项,转动相机时会“闪”
                vec3 sp = floor(restWS * 160.0);
                float sparkle = step(0.997, Hash21(sp.xz + sp.y * 17.0));
                vec3 halfDir = SafeNormalize(GetMainLight().direction + viewWS);
                sparkle *= pow(saturate(dot(N, halfDir)), 8.0);
                color += sparkle * _SparkleStrength * mask * GetMainLight().color;
                fragColor = vec4(color, 1.0);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            GLSLPROGRAM
            #include "Ch18/FrozenCore.glsl"
            #ifdef VERTEX
            void main()
            {
                vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
                vec3 normalWS = TransformObjectToWorldNormal(normalOS);
                positionWS = ThickenIce(positionWS, normalWS);
                vec3 lightDirWS = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection;
                vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirWS));
                positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE * positionCS.w);
                gl_Position = positionCS;
            }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = vec4(0.0); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #include "Ch18/FrozenCore.glsl"
            #ifdef VERTEX
            void main()
            {
                vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
                gl_Position = TransformWorldToHClip(ThickenIce(positionWS, TransformObjectToWorldNormal(normalOS)));
            }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = vec4(gl_FragCoord.z); }
            #endif
            ENDGLSL
        }
    }
}
图 18-18冰冻覆盖,Freeze 从左到右为 0、0.35、0.62、0.9。三个物体使用不同的底色贴图,但冰的纹理在世界空间里连续,顶面最先结冰,最后蔓延到底部。

常见坑与扩展

  • 硬边模型会裂开:立方体这类硬边模型,同一个角上有三个法线不同的顶点,沿各自法线外推后会分开,出现裂缝。解决办法是用平滑法线做外推(第 14 章描边里烘焙平滑法线的工具可以直接复用),或者把厚度设得很小。
  • 蒙皮角色:法线要用蒙皮后的世界法线,这在 URP 里是自动的(蒙皮在 GPU 上先于顶点着色器完成);但如果你在 C# 里用烘焙的静态法线做遮罩,角色弯腰时冰会“滑动”。
  • 积雪、苔藓、灰尘:把冰的外观换成白色漫反射、绿色苔藓、灰色粉尘,遮罩逻辑完全通用。积雪可以把阈值和噪声换成更柔和的过渡,并配合法线贴图让雪面蓬松。

案例 18.10X 光扫描线进阶单独打开 ↗

目标:一道水平光带从下往上扫过场景里的物体,光带下方紧跟着一段“X 光”样式的拖尾:暗色底、亮轮廓、世界空间网格线;拖尾之后物体恢复正常。

原理:世界高度上的两条带

令 d = 扫描高度 − 世界 y。扫描线是 |d| 很小的一条带,用指数衰减 exp(−|d| / width) 得到一条两侧柔和的亮线;拖尾是 0 < d < 拖尾长度的一段,1 − d / length 从前沿的 1 降到末端的 0。两者都只依赖世界坐标,所以场景里所有使用这个材质的物体会被同一道光扫过——前提是它们的扫描高度相同。

屏幕空间等宽的网格线

X 光样式里的网格线是世界坐标的整数线:abs(frac(g − 0.5) − 0.5) 是点到最近整数的距离。问题是“多近算在线上”:用固定阈值,近处的线很粗、远处的线细到消失并产生闪烁。正确的做法是用 fwidth(g)——它是 g 在相邻像素间的变化量,也就是“一个像素对应多少个 g 单位”。用距离除以 fwidth,就把距离换算成了像素数,线宽在屏幕上恒定约 1 像素,远近一致,几乎没有锯齿。

Assets/Cookbook/Ch18/ScanLine.shader(节选:grid)
// 世界空间网格线:三个轴各算一次“离最近整格线有多近”,用 fwidth 保持线宽恒定为约 1 像素以上
half WorldGrid(float3 p, float density)
{
    float3 g = p * density;
    float3 dist = abs(frac(g - 0.5) - 0.5);          // 到最近整数的距离
    float3 w = fwidth(g) * 1.2;
    float3 lines = 1.0 - saturate(dist / max(w, 1e-4));
    return max(lines.x, max(lines.y, lines.z));
}
// 世界空间网格线:三个轴各算一次“离最近整格线有多近”,用 fwidth 保持线宽恒定为约 1 像素以上
float WorldGrid(vec3 p, float density)
{
    vec3 g = p * density;
    vec3 dist = abs(fract(g - 0.5) - 0.5);          // 到最近整数的距离
    vec3 w = fwidth(g) * 1.2;
    vec3 lines = 1.0 - saturate(dist / max(w, vec3(1e-4)));
    return max(lines.x, max(lines.y, lines.z));
}
Assets/Cookbook/Ch18/ScanLine.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half3 normalWS = normalize(i.normalWS);
    half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
    half3 lit = SimpleLighting(_BaseColor.rgb, i.positionWS, normalWS, _Smoothness);

    float d = _ScanHeight - i.positionWS.y;           // > 0:在扫描线下方(已经被扫过)
    half scanLine = exp(-abs(d) / _ScanWidth);        // 前沿亮线,指数衰减
    half trail = (d > 0.0) ? saturate(1.0 - d / max(_TrailLength, 1e-4)) : 0.0h;

    // X 光样式:暗底 + 轮廓 + 网格
    half rim = FresnelRim(normalWS, viewWS, 2.0h);
    half grid = WorldGrid(i.positionWS, _GridDensity);
    half3 xray = _XRayTint.rgb + _ScanColor.rgb * (rim * 0.8h + grid * 0.5h);

    // 拖尾越靠近前沿越“X 光”,远处渐渐恢复正常着色
    half3 color = lerp(lit, xray, smoothstep(0.0h, 0.35h, trail));
    color += _ScanColor.rgb * scanLine * 2.0h;
    return half4(color, 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
    vec3 lit = SimpleLighting(_BaseColor.rgb, positionWS, N, _Smoothness);

    float d = _ScanHeight - positionWS.y;             // > 0:在扫描线下方(已经被扫过)
    float scanLine = exp(-abs(d) / _ScanWidth);       // 前沿亮线,指数衰减
    float trail = (d > 0.0) ? saturate(1.0 - d / max(_TrailLength, 1e-4)) : 0.0;

    // X 光样式:暗底 + 轮廓 + 网格
    float rim = FresnelRim(N, viewWS, 2.0);
    float grid = WorldGrid(positionWS, _GridDensity);
    vec3 xray = _XRayTint.rgb + _ScanColor.rgb * (rim * 0.8 + grid * 0.5);

    // 拖尾越靠近前沿越“X 光”,远处渐渐恢复正常着色
    vec3 color = mix(lit, xray, smoothstep(0.0, 0.35, trail));
    color += _ScanColor.rgb * scanLine * 2.0;
    fragColor = vec4(color, 1.0);
}

完整 Shader:

Assets/Cookbook/Ch18/ScanLine.shader
// 案例 18.10:X 光扫描线。一道水平光带沿世界 Y 扫过物体,光带后方的“拖尾”区域变成 X 光样式:
// 暗色底 + 菲涅尔轮廓 + 世界空间网格线。全部基于世界坐标,多个物体共用一个扫描高度就能连成一片。
Shader "Cookbook/Ch18/ScanLine"
{
    Properties
    {
        [MainColor] _BaseColor ("Base Color", Color) = (0.7,0.7,0.7,1)
        _Smoothness ("Smoothness", Range(0,1)) = 0.4
        [HDR] _ScanColor ("Scan Color", Color) = (0.3,1.6,1.2,1)
        _ScanHeight ("Scan Height (World Y)", Float) = 1
        _ScanWidth ("Scan Line Width (m)", Range(0.005,0.3)) = 0.03
        _TrailLength ("Trail Length (m)", Range(0,3)) = 0.8
        _GridDensity ("Grid Lines per Meter", Float) = 10
        _XRayTint ("X-Ray Base", Color) = (0.02,0.06,0.08,1)
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry" "RenderPipeline"="UniversalPipeline" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor;
            half  _Smoothness;
            half4 _ScanColor;
            float _ScanHeight;
            half  _ScanWidth;
            half  _TrailLength;
            float _GridDensity;
            half4 _XRayTint;
        CBUFFER_END
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }

            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
            #pragma multi_compile _ _FORWARD_PLUS
            #include "Assets/Cookbook/Ch18/Ch18Common.hlsl"

            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; };
            struct Varyings  { float4 positionCS : SV_POSITION; float3 positionWS : TEXCOORD0; half3 normalWS : TEXCOORD1; };

            Varyings vert(Attributes v)
            {
                Varyings o;
                o.positionWS = TransformObjectToWorld(v.positionOS.xyz);
                o.positionCS = TransformWorldToHClip(o.positionWS);
                o.normalWS = TransformObjectToWorldNormal(v.normalOS);
                return o;
            }

            // 世界空间网格线:三个轴各算一次“离最近整格线有多近”,用 fwidth 保持线宽恒定为约 1 像素以上
            half WorldGrid(float3 p, float density)
            {
                float3 g = p * density;
                float3 dist = abs(frac(g - 0.5) - 0.5);          // 到最近整数的距离
                float3 w = fwidth(g) * 1.2;
                float3 lines = 1.0 - saturate(dist / max(w, 1e-4));
                return max(lines.x, max(lines.y, lines.z));
            }

            half4 frag(Varyings i) : SV_Target
            {
                half3 normalWS = normalize(i.normalWS);
                half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
                half3 lit = SimpleLighting(_BaseColor.rgb, i.positionWS, normalWS, _Smoothness);

                float d = _ScanHeight - i.positionWS.y;           // > 0:在扫描线下方(已经被扫过)
                half scanLine = exp(-abs(d) / _ScanWidth);        // 前沿亮线,指数衰减
                half trail = (d > 0.0) ? saturate(1.0 - d / max(_TrailLength, 1e-4)) : 0.0h;

                // X 光样式:暗底 + 轮廓 + 网格
                half rim = FresnelRim(normalWS, viewWS, 2.0h);
                half grid = WorldGrid(i.positionWS, _GridDensity);
                half3 xray = _XRayTint.rgb + _ScanColor.rgb * (rim * 0.8h + grid * 0.5h);

                // 拖尾越靠近前沿越“X 光”,远处渐渐恢复正常着色
                half3 color = lerp(lit, xray, smoothstep(0.0h, 0.35h, trail));
                color += _ScanColor.rgb * scanLine * 2.0h;
                return half4(color, 1);
            }
            ENDHLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            HLSLPROGRAM
            #pragma vertex ShadowVert
            #pragma fragment ShadowFrag
            #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
            #include "Assets/Cookbook/Ch18/Ch18ShadowPass.hlsl"
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex DepthVert
            #pragma fragment DepthFrag
            #include "Assets/Cookbook/Ch18/Ch18ShadowPass.hlsl"
            ENDHLSL
        }
    }
}
// 案例 18.10:X 光扫描线。一道水平光带沿世界 Y 扫过物体,光带后方的“拖尾”区域变成 X 光样式:
// 暗色底 + 菲涅尔轮廓 + 世界空间网格线。全部基于世界坐标,多个物体共用一个扫描高度就能连成一片。
// (GLSL 版,对应 Ch18/ScanLine.shader)
Shader "Cookbook/Ch18/ScanLine"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry" "RenderPipeline"="UniversalPipeline" }

        GLSLINCLUDE
        uniform vec4  _BaseColor;
        uniform float _Smoothness;
        uniform vec4  _ScanColor;
        uniform float _ScanHeight;
        uniform float _ScanWidth;
        uniform float _TrailLength;
        uniform float _GridDensity;
        uniform vec4  _XRayTint;
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }

            GLSLPROGRAM
            #include "Ch18/Ch18Common.glsl"

            VARYING vec3 positionWS;
            VARYING vec3 normalWS;

            #ifdef VERTEX
            void main()
            {
                positionWS = TransformObjectToWorld(positionOS.xyz);
                gl_Position = TransformWorldToHClip(positionWS);
                normalWS = TransformObjectToWorldNormal(normalOS);
            }
            #endif

            #ifdef FRAGMENT
            // 世界空间网格线:三个轴各算一次“离最近整格线有多近”,用 fwidth 保持线宽恒定为约 1 像素以上
            float WorldGrid(vec3 p, float density)
            {
                vec3 g = p * density;
                vec3 dist = abs(fract(g - 0.5) - 0.5);          // 到最近整数的距离
                vec3 w = fwidth(g) * 1.2;
                vec3 lines = 1.0 - saturate(dist / max(w, vec3(1e-4)));
                return max(lines.x, max(lines.y, lines.z));
            }

            void main()
            {
                vec3 N = normalize(normalWS);
                vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
                vec3 lit = SimpleLighting(_BaseColor.rgb, positionWS, N, _Smoothness);

                float d = _ScanHeight - positionWS.y;             // > 0:在扫描线下方(已经被扫过)
                float scanLine = exp(-abs(d) / _ScanWidth);       // 前沿亮线,指数衰减
                float trail = (d > 0.0) ? saturate(1.0 - d / max(_TrailLength, 1e-4)) : 0.0;

                // X 光样式:暗底 + 轮廓 + 网格
                float rim = FresnelRim(N, viewWS, 2.0);
                float grid = WorldGrid(positionWS, _GridDensity);
                vec3 xray = _XRayTint.rgb + _ScanColor.rgb * (rim * 0.8 + grid * 0.5);

                // 拖尾越靠近前沿越“X 光”,远处渐渐恢复正常着色
                vec3 color = mix(lit, xray, smoothstep(0.0, 0.35, trail));
                color += _ScanColor.rgb * scanLine * 2.0;
                fragColor = vec4(color, 1.0);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            GLSLPROGRAM
            #define SHADOW_PASS
            #include "Ch18/Ch18ShadowPass.glsl"
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #define DEPTH_PASS
            #include "Ch18/Ch18ShadowPass.glsl"
            ENDGLSL
        }
    }
}
图 18-19X 光扫描线,扫描高度从左到右为 0.35、0.95、1.6 米。亮线下方是 0.8 米长的 X 光拖尾,拖尾末端渐渐恢复正常着色。开启 Bloom。

让所有物体共用一个扫描高度

演示里每个材质单独设置 _ScanHeight。实际项目中扫描是一个全局事件(雷达、技能),更好的做法是把它做成全局变量:从 CBUFFER 里移出来,单独声明 float _ScanHeight;,C# 用 Shader.SetGlobalFloat("_ScanHeight", h) 每帧设置一次,所有材质同时生效。注意全局变量不能留在 UnityPerMaterial 里:CBUFFER 里的变量由材质提供数值,Shader.SetGlobalFloat 设置的全局值对它不起作用;同时也要把它从 Properties 块里删掉,避免在检视面板里出现一个“改了没用”的滑条。

如果想做“以某点为中心的环形扫描波,扫过整个地面和所有物体”,在每个物体的 Shader 里做就不合适了——地面、草、角色都要改 Shader。那是屏幕空间技术的典型场景:用深度图重建世界坐标,在一个全屏 Pass 里统一画,第 28 章会实现它。

18.2 性能与移动端

本章的效果单个看开销都不大,但特效往往成批出现:一场战斗里十几个角色同时溶解、满屏的护盾和全息。几条经验:

效果主要开销建议
溶解(clip)Alpha Test 破坏 Early-Z 优化;在 TBDR 移动 GPU 上,含 discard 的 Shader 会让硬件的隐藏面剔除失效或退化只在溶解进行时切换到溶解材质,平时用不带 clip 的普通材质;AlphaTest 队列排在不透明之后
全息、护盾(加法透明)Overdraw:每一层都要完整执行片元着色器控制屏幕占比;双面护盾的内壁可以简化计算;图案计算用 half
交接高亮(读深度图)需要深度图:额外的 CopyDepth 或深度预 Pass多个效果共用一张深度图,开销只付一次;不需要时关掉相机的 Depth Texture
扭曲隐身(读不透明纹理)CopyColor 全屏拷贝 + 带宽保持 Opaque Downsampling 为 2x;小屏占比的效果可以接受
冰冻(顶点 + 三平面)顶点阶段多次采样;片元三平面 = 3 倍采样三平面可以按权重跳过很小的投影;远处 LOD 去掉闪点和裂纹

另外,[Toggle]、_AutoPlay 这类教学用的运行时分支(if (_UseRamp > 0.5))在材质层面是统一的布尔值,GPU 上不会产生分歧,代价很小;但上线前把确定不用的分支删掉或改成 shader_feature,仍然是好习惯。

和 Built-in 管线相比,本章的写法有几个 URP 特有的地方:深度图和不透明纹理通过 URP Asset 统一开关,用 DeclareDepthTexture.hlsl/DeclareOpaqueTexture.hlsl 读取,而不是 Built-in 的 GrabPass(URP 不支持 GrabPass);溶解阴影要自己写 ShadowCaster Pass 而不是 Fallback "Diffuse" 继承;全息和护盾放在 Transparent 队列,URP 会按距离从远到近排序绘制,加法混合则对顺序不敏感。

本章小结

  • 大多数特效 Shader = 遮罩 + 阈值边带 + HDR 着色 + 推进参数。换遮罩的来源(噪声、投影、距离、法线、深度差),就换了效果。
  • 溶解是带阈值的 Alpha Test。所有写深度的 Pass(Forward、ShadowCaster、DepthOnly)必须调用同一个溶解函数,CBUFFER 也必须一致。
  • 菲涅尔边缘光借用了 (1−N·V)power 的形状做轮廓遮罩,是全息、护盾、冰面的公共组件。
  • 透明 Pass 可以用 SampleSceneDepth + LinearEyeDepth 与自身深度比较做交接高亮,用 SampleSceneColor 读取身后画面做扭曲;两者分别依赖 URP Asset 的 Depth Texture 与 Opaque Texture。
  • 加法混合满足交换律,不需要排序;Alpha Test 物体排在 AlphaTest 队列,利于 Early-Z。
  • 世界空间图案(扫描线、三平面覆盖)让多个物体的效果自然连续;fwidth 能让程序化线条保持屏幕空间等宽。
  • HDR 颜色要配合 Bloom 才会“发光”,没有后处理时会被截断成白色。

练习

  1. 给溶解加一个“灰烬飘散”:在溶解前沿附近用顶点着色器把顶点沿法线和向上方向偏移,偏移量随 edge 增大。注意 ShadowCaster 也要同步。
  2. 把护盾的受击点改成最多 4 个(float4 _Hits[4]),写一个 C# 组件管理一个环形缓冲区,每次命中占用最旧的槽位。
  3. 给全息投影加一个“只画最前面一层”的预深度 Pass,对比加之前和加之后的视觉差异与 Overdraw(用 Rendering Debugger 的 Overdraw 模式观察)。
  4. 把冰冻覆盖改成积雪:去掉裂纹和闪点,改用白色漫反射与较低的光滑度,并让积雪的阈值受一张世界空间的“遮挡图”影响(屋檐下不积雪)。
  5. 把扫描线的 _ScanHeight 改为全局变量,写一个 C# 脚本每帧用 Shader.SetGlobalFloat 推进它,让场景里所有物体被同一道光扫过。