案例 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)。