溶解、全息与护盾
游戏里最常被点名的那几种“特效 Shader”,拆开看都是同一套积木:一张遮罩决定哪里有效果,一条边带决定效果从哪里开始,一个 HDR 颜色让它发光,再加一个随时间推进的参数。本章用十个案例把这套积木玩熟。
_CameraDepthTexture 做交接高亮,读取 _CameraOpaqueTexture 做扭曲隐身18.1 效果 Shader 的共同结构
先别急着写代码。把本章的效果摆在一起看,你会发现它们几乎都能拆成四步:
- 遮罩(Mask):一个 0~1 的数,回答“这个像素有没有效果、效果有多强”。溶解里它是噪声值,护盾里它是“离别的物体有多近”,冰冻里它是“法线有多朝上”。
- 边带(Edge Band):拿遮罩和一个阈值比较,阈值附近的一条窄带就是“效果的前沿”。溶解的火边、扫描线的亮线、受击涟漪的光环,都是边带。
- 着色:边带内给一个 HDR 颜色(分量可以大于 1),配合后处理 Bloom 让它“溢光”;边带外按正常光照或特殊样式着色。
- 推进:一个随时间变化的参数(进度、相位、扫描高度)推动阈值移动,效果就动起来了。
理解了这个结构,本章十个案例其实是在回答“遮罩从哪来”。还有两件基础工作先交代清楚。
一个够用的简易光照
本章的重点是效果,不是光照模型。为了让代码短一些,所有需要“正常着色”的 Shader 都调用同一个 SimpleLighting:主光的 Lambert 漫反射 + Blinn-Phong 高光 + 实时阴影 + 球谐环境光。它用到的 GetMainLight(、TransformWorldToShadowCoord、SampleSH 都是 URP 14 Lighting.hlsl 一系列头文件里的现成函数,在第 5、6、8 章已经逐个讲过。真实项目里你可以把它换成 UniversalFragmentPBR(第 10 章),效果部分的代码一行都不用改。
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_。
为什么要把“时间”做成参数
几乎每个特效都在动:溶解要推进,全息要闪烁,护盾要呼吸。最省事的写法是直接用 _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 时可能还剩下几个“顽固像素”。解决办法是把进度区间稍微外扩:
// 把进度从 [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 都包含它:
// 溶解效果的核心:材质参数 + “溶解值”计算。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:
// 案例 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 在绘制阴影前设置,_ 区分方向光和点光/聚光(第 6 章)。有一个坑:URP 的 Shadows.hlsl 用到了 CommonMaterial.hlsl 里的 LerpWhiteTo,而 Lit 是通过 LitInput.hlsl 间接包含它的。自己写阴影 Pass 只包含 Shadows.hlsl 会报 “undeclared identifier 'LerpWhiteTo'”,需要先包含 Packages/com.。
③ 双面渲染。物体被挖出洞后,你会透过洞看到它的内壁。默认 Cull Back 会把内壁剔掉,看起来像一张纸壳;本例把 _Cull 做成属性,配图里设为 Off,并在片元里用 FRONT_FACE_SEMANTIC 判断正反面:背面翻转法线、压暗颜色,看起来像实心物体被掏空。

常见坑与调参
- 噪声 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,令
则 d=0 处 edge=1,d≥w 处 edge=0,中间线性过渡。用 edge 去查一张 Ramp 贴图(公共贴图 ramp_fire:左边暗紫,右边亮黄白),越靠近前沿越亮、越黄,往里逐渐变暗红——正好是燃烧的颜色分布。
片元函数如下:
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.3方向溶解与球形溶解进阶单独打开 ↗
目标:让溶解有方向感——比如角色从头到脚被“传送”走;或者从一个点向外扩散——比如被魔法击中的位置先开始瓦解。
原理:换一种溶解值
clip 和边带的逻辑完全不变,只换溶解值 v 的来源:
- 方向溶解:把对象空间位置投影到一个方向上,h = dot(positionOS, dir),再按物体在这个方向上的范围 [min, max] 归一化到 0~1。投影值小的一端先消失。
- 球形溶解:v = distance(positionWS, center) / R。离球心越近越先消失,看起来像一个看不见的球从 center 处胀开,把物体“吃掉”。
纯几何的溶解值会得到一条笔直的切割线或者一个完美的圆,太“干净”了。所以再用 lerp( 掺一点噪声,切割线就变成了不规则的锯齿边。
// 返回 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( 生成三个关键字 _,Shader 里用 #pragma shader_ 声明——它们是材质级别的选择,不会在运行时频繁切换,shader_feature 只编译实际用到的变体。注意三个 Pass 都要声明同样的关键字,否则阴影 Pass 会走默认分支,影子的溶解形状就和物体对不上了。
为什么方向溶解用对象空间、球形溶解用世界空间
方向溶解描述的是“物体自身从头到脚”,角色转身、倒地,头脚关系不变,所以用对象空间;球心通常来自游戏逻辑(被击中的点、爆炸中心),它天然是世界坐标,所以用世界空间。如果方向溶解想用世界方向(比如永远从上往下),在 C# 里用 transform. 换算成对象空间再传进来即可。

从游戏逻辑驱动
球形溶解最常见的用法是“击中点开始瓦解”。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 近似写作
特效里的“菲涅尔”借用的是它的形状,而不是物理含义:从相机看过去,物体正对你的地方法线 N 与视线方向 V 几乎平行,N·V≈1;越靠近轮廓,表面越“侧”过去,N·V 越接近 0。所以 pow(1 − N·V, power) 在中间是 0、在轮廓处是 1,天然就是一圈“轮廓遮罩”。指数 power 控制这圈有多宽:power 越大,亮边越窄越锐利。
// 经验菲涅尔:视线与法线越垂直(轮廓处)值越大。power 越大,亮边越窄。 half FresnelRim(half3 normalWS, half3 viewWS, half power) { return pow(1.0h - saturate(dot(normalWS, viewWS)), power); }
边缘光叠加在正常光照之上。一个常见的改进是只在“背光侧”显示边缘光:受光面已经够亮了,再加亮边会发白;背光面加一圈亮边,正好勾出轮廓,这就是影视布光里的“轮廓光(Rim Light)”。实现上用主光方向的 N·L 做一个遮罩:
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); }


常见坑
- 平面没有轮廓:一块平板的法线处处相同,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 自动共享同一份声明。
// 本章不透明 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 章)在这里不存在。 - 不需要光照:光不会被光照亮。全息的“明暗”全部来自菲涅尔与图案。
在此之上,四个图层逐个叠加。
① 菲涅尔光壳
只输出 _:正对相机的部分只有很淡的底色,轮廓最亮。中空的“光壳”感一下就出来了——这也是全息和普通半透明材质最大的区别:普通半透明是中间实、边缘虚,全息反过来。
② 世界空间扫描线
扫描线用世界坐标 Y 而不是 UV:frac( 得到一个 0~1 的锯齿波,取中间一段为亮线。用世界坐标的好处是条纹在所有物体上间距一致、方向一致(永远水平),物体旋转时条纹不跟着转,更像投影仪在一层层“扫描”出来的。另加一条用指数衰减算出的宽亮带,相对物体原点从下往上循环移动。
// 细扫描线:沿世界 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% 是亮线,边缘柔化防锯齿 }
③ 闪烁与故障
闪烁就是一个随时间随机跳变的亮度系数:把时间按 1/20 秒取整 floor(t * 20),再用哈希函数映射成 0~1 的随机数,同一个 1/20 秒内值不变,下一段跳到另一个值,很像电压不稳。故障(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 个周期最明显。
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:
// 案例 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 } } }


常见坑与扩展
- 看起来“糊成一团”:加法混合下,物体前后两层壳会叠加。复杂模型(比如角色)重叠层太多会过曝。一种改法是先用一个只写深度的 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( 就得到一条宽度为 width 米的交接遮罩。
URP 14 里读取深度图
URP 提供了现成的头文件 DeclareDepthTexture.hlsl,摘录如下(URP 14 ShaderLibrary/DeclareDepthTexture.):
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( 转成观察空间深度。屏幕 UV 用 GetNormalizedScreenSpaceUV(——在片元着色器里,SV_POSITION 的 xy 是像素坐标,这个函数除以屏幕尺寸并处理了平台的 UV 翻转。
护盾自身的深度在顶点着色器里算:-TransformWorldToView(。观察空间是右手系,相机看向 −Z,所以取负。也可以用 Common.hlsl 里的重载 LinearEyeDepth(,结果相同(第 19 章就用了这个写法)。
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); }

常见坑
- 没有效果 / 全屏都亮:先确认 URP Asset(或相机)开启了 Depth Texture。没开时
_CameraDepthTexture是一张默认贴图,交接遮罩会变成全 0 或全 1。 - 透明物体不参与:深度图只包含不透明物体(以及写深度的 Alpha Test 物体)。护盾和别的透明物体(比如另一个护盾、粒子)之间不会有交接线。
- 正交相机:
LinearEyeDepth按透视投影的公式线性化。正交相机的深度本来就是线性的,要用LinearDepthToEyeDepth(URP 14ShaderVariablesFunctions.),并根据hlsl unity_OrthoParams.w判断投影类型。 - 双面:本例
Cull Off,护盾的内壁也参与计算,所以从外面能透过前壁看到后壁与地面的交线。内壁亮度乘了 0.45,制造前后层次。
案例 18.7能量护盾(下):六边形网格与受击涟漪高阶单独打开 ↗
目标:给护盾加上蜂窝状六边形网格,每个格子有自己的明暗呼吸;被击中时,从命中点扩散出一圈光环,光环经过的格子被点亮,护盾表面同时鼓起一圈波纹。
六边形网格的数学
正六边形可以密铺平面,它的中心构成一个“错位的矩形网格”:把平面看成两套矩形网格的叠加,格子尺寸都是 1 × √3,第二套相对第一套偏移 (0.5, √3/2)。对任意点 p,分别求出它在两套网格里所属格子的中心,离哪个中心更近,p 就属于哪个六边形。
// 六边形网格:返回到最近六边形中心的局部坐标(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. 在两极附近把六边形淡出。半球护盾的极点在顶上,从正常视角看占的面积很小,这个处理已经足够;如果护盾需要从正上方看,可以改用立方体球(Cube Sphere)的网格与 UV。
受击涟漪
涟漪是另一种“距离遮罩 + 边带”:以命中点为圆心,到命中点的距离为 d,环的半径随进度增长 r = progress × R,1 − |d − r| / width 就是一条环形带。环越往外越淡(乘 1 − progress),命中点本身再加一个快速消退的亮斑。
// 受击涟漪:以命中点为圆心向外扩散的一圈环,越往外越淡 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:
// 案例 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 } } }


从 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. 提供的 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; }
偏移量怎么定?最自然的是用法线:把世界法线转到观察空间,取 xy。物体正对相机的中央,观察空间法线几乎是 (0, 0, 1),xy 很小,几乎不扭曲;越靠近轮廓,法线越“横”过来,xy 越大,扭得越厉害——这和真实的玻璃球很像(轮廓处折射最强)。再叠一层流动噪声,运动时就有了热浪般的抖动。
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 方向一致(平台差异已经由它处理),所以偏移可以直接加。完整代码:
// 案例 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 } } }

常见坑
- 前景泄漏:如果隐身物体前面挡着一个不透明物体,扭曲后的 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 章会把三平面映射讲全(包括法线贴图的三平面混合),这里用最精简的版本。
// 极简三平面采样:按法线三个分量的权重,混合 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; }
// 覆盖遮罩:法线越朝上越先结冰,噪声把分界线打散。_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 版本。
// 冰层有厚度:被覆盖的顶点沿法线外推。阴影和深度 Pass 也要做同样的外推,否则阴影对不上 float3 ThickenIce(float3 positionWS, half3 normalWS) { return positionWS + normalWS * FrostMask(positionWS, normalWS) * _Thickness; }
和溶解一样,顶点变形必须在所有 Pass 里同步:ShadowCaster 和 DepthOnly 也要调用 ThickenIce,否则影子还是原来的瘦轮廓,深度图里的轮廓和屏幕上的也对不上(SSAO、后处理描边都会出错)。
冰的外观
片元里做四件事:① 冰色在浅蓝和深蓝之间按噪声变化,Worley 噪声(公共贴图 noise_cells)的细胞边界很亮,取边界做白色裂纹;② 光滑度提高到 0.9,冰面有锐利的高光;③ 冷色菲涅尔,模拟光在冰层里散射后从边缘透出来;④ 闪点:把世界坐标量化成很小的格子,每个格子一个哈希值,只保留最亮的千分之三,再乘一个视角相关的高光项,转动视角时会一闪一闪。
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 与公共部分:
// 冰冻覆盖的公共部分:材质参数、三平面噪声、覆盖遮罩、顶点加厚。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
// 案例 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 } } }

常见坑与扩展
- 硬边模型会裂开:立方体这类硬边模型,同一个角上有三个法线不同的顶点,沿各自法线外推后会分开,出现裂缝。解决办法是用平滑法线做外推(第 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 像素,远近一致,几乎没有锯齿。
// 世界空间网格线:三个轴各算一次“离最近整格线有多近”,用 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)); }
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:
// 案例 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 } } }

让所有物体共用一个扫描高度
演示里每个材质单独设置 _ScanHeight。实际项目中扫描是一个全局事件(雷达、技能),更好的做法是把它做成全局变量:从 CBUFFER 里移出来,单独声明 float _ScanHeight;,C# 用 Shader. 每帧设置一次,所有材质同时生效。注意全局变量不能留在 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. 读取,而不是 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 才会“发光”,没有后处理时会被截断成白色。
练习
- 给溶解加一个“灰烬飘散”:在溶解前沿附近用顶点着色器把顶点沿法线和向上方向偏移,偏移量随 edge 增大。注意 ShadowCaster 也要同步。
- 把护盾的受击点改成最多 4 个(
float4 _Hits[4]),写一个 C# 组件管理一个环形缓冲区,每次命中占用最旧的槽位。 - 给全息投影加一个“只画最前面一层”的预深度 Pass,对比加之前和加之后的视觉差异与 Overdraw(用 Rendering Debugger 的 Overdraw 模式观察)。
- 把冰冻覆盖改成积雪:去掉裂纹和闪点,改用白色漫反射与较低的光滑度,并让积雪的阈值受一张世界空间的“遮挡图”影响(屋檐下不积雪)。
- 把扫描线的
_ScanHeight改为全局变量,写一个 C# 脚本每帧用Shader.SetGlobalFloat推进它,让场景里所有物体被同一道光扫过。