ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity全息透明效果Shader实战:从透明混合到扫描线扰动

Unity全息透明效果Shader实战:从透明混合到扫描线扰动

全息效果大概是每个做Unity的人都会经历的一道坎。刚接触Shader那会儿,我看到游戏里那种半透明、带扫描线、边缘发光的角色投影,第一反应是"这得写多少代码",结果拆开一看,核心逻辑其实就三件事:让物体半透明、给边缘加光、让表面动起来。但真正动手做的时候,问题全冒出来了——透明排序乱了、深度写入导致背面消失、扫描线在模型上扭曲变形、发光边缘在斜视角下糊成一片。这篇就围绕全息透明效果这个主题,把我从踩坑到跑通的全过程拆开讲,包括每个参数为什么这么设、哪些地方容易翻车、以及怎么用最少的代码量做出能直接放进项目的效果。适合刚学完基础Shader语法、想找个完整案例练手的读者,也适合做过透明效果但被排序问题折磨过的朋友。

1. 全息效果到底在模拟什么物理现象

1.1 从科幻电影里的投影说起

全息效果在视觉上要传达的核心信息是"这个东西不是实体的"。你回想一下科幻片里的全息投影,它有几个共同特征:你能透过它看到后面的东西,说明它是半透明的;它的边缘比中间亮,因为投影设备的能量集中在轮廓上;它的表面有横向或纵向的扫描纹理,模拟信号逐行刷新的过程;它偶尔会闪烁或者抖动,暗示信号不稳定。这四个特征对应到Shader里,就是透明混合、菲涅尔边缘光、扫描线纹理、时间扰动。

很多人做全息效果时只做了透明加边缘光,看起来像一块半透明的塑料,缺少"投影感"。问题就出在扫描线和扰动这两个细节上——它们才是让眼睛相信"这是投影"的关键。我在第一个版本里也忽略了扫描线,调了半天颜色都觉得不对劲,后来加上横向条纹,瞬间就对了。

1.2 透明混合的本质:颜色怎么叠上去

Unity里做透明效果,核心是Blend命令。默认的不透明渲染是Blend Off,片元颜色直接覆盖屏幕像素。透明渲染需要把当前片元的颜色和屏幕上已有的颜色按比例混合,公式是:最终颜色 = 源颜色 × 源系数 + 目标颜色 × 目标系数。全息效果常用的配置是:

Blend SrcAlpha OneMinusSrcAlpha

意思是源颜色的alpha值决定它占多少权重,剩下的权重留给背景。当alpha=0.3时,你看到的是30%的全息颜色加70%的背景。这里有个容易忽略的点:alpha值不是随便设的,它要和菲涅尔边缘光配合。如果整体alpha设得太高(比如0.8),边缘光再亮也看不出层次;设得太低(比如0.1),模型轮廓就糊了。我实测下来,基础alpha在0.2到0.4之间比较舒服,边缘光区域可以额外叠加到0.6左右。

1.3 菲涅尔效应:为什么边缘要比中间亮

菲涅尔效应描述的是光线在不同角度入射时反射率的变化。简单说,你正视一个表面时反射弱,斜着看时反射强。全息投影的"能量集中在轮廓"这个感觉,正好可以用菲涅尔来模拟——模型边缘的法线和视线夹角大,菲涅尔值高,叠加更多亮度;正对镜头的面法线和视线平行,菲涅尔值低,保持透明。

计算菲涅尔最常用的方式是拿法线方向和视线方向做点积,然后取反:

float fresnel = 1.0 - saturate(dot(normalDir, viewDir));

这里normalDir是世界空间法线,viewDir是从表面指向摄像机的方向。点积结果越接近1(正对),fresnel越接近0;点积越接近0(侧对),fresnel越接近1。实际使用时通常还要加个幂次来控制边缘的锐利程度:

fresnel = pow(fresnel, _FresnelPower);

_FresnelPower越大,边缘光越窄越锐利;越小,边缘光越宽越柔和。做全息效果我一般设在2到4之间,太大会变成一条细线,太小整个模型都在发光。

2. 透明渲染的排序陷阱与深度写入取舍

2.1 为什么你的全息模型背面消失了

这是做透明效果遇到的第一个大坑。Unity默认的渲染队列里,不透明物体(Geometry,队列2000)先渲染并写入深度缓冲,透明物体(Transparent,队列3000)后渲染且默认不写深度。问题在于:透明物体自己不写深度,但会读深度。如果一个全息模型自己遮挡自己,前面的面渲染完后没有写入深度,后面的面在渲染时深度测试仍然通过,于是背面和正面叠在一起——看起来就是一团乱。

更糟的情况是模型内部的面互相穿插,因为渲染顺序不确定,会出现闪烁。解决办法有两个方向:要么关闭深度测试(ZTest Always),让所有面都画出来,靠混合顺序碰运气;要么强制写入深度(ZWrite On),让前面的面挡住后面的面。但ZWrite On在透明渲染里有个副作用——它会挡住后面其他透明物体的渲染,因为深度缓冲被占了。

我的做法是分情况处理。如果是单个全息模型、不需要和别的透明物体交互,用ZWrite On加队列调低(比如2999)效果最干净,正面挡住背面,轮廓清晰。如果场景里有多个透明物体需要互相透视,那就ZWrite Off加ZTest LEqual,接受一定程度的排序瑕疵,靠alpha值压低来减少视觉干扰。

2.2 渲染队列的微调技巧

Unity的Transparent队列是3000,所有透明物体默认都在这个队列里按距离排序。但距离排序是按物体中心点算的,一个大模型和一个小模型穿插时,排序结果可能反直觉。我遇到过全息角色站在全息面板前面的场景,面板的包围盒中心比角色近,结果面板先渲染,角色后渲染时被面板的alpha混合盖住,看起来角色在面板后面。

这种问题的解法是手动指定Queue值。比如角色用3001,面板用3000,保证面板先画、角色后画。RenderQueue的数值越小越先渲染,后渲染的会叠在先渲染的结果上。代价是你要自己管理层级关系,不能依赖自动排序。在Shader里这样写:

Tags { "Queue"="Transparent+1" "RenderType"="Transparent" }

Transparent+1就是3001。注意这个技巧只适合固定层级的场景,如果两个物体的前后关系会随摄像机移动变化,手动队列反而会出错。

2.3 深度写入开启后的边缘锯齿

ZWrite On还有一个副作用:模型边缘会出现硬切。因为深度测试是逐像素的布尔判断,要么通过要么不通过,没有中间过渡。全息效果本身是半透明的,边缘突然被切断会很突兀。缓解办法是在片元着色器里根据深度差做一个软过渡,但这需要访问深度纹理,复杂度上升不少。

更实用的方案是接受这个锯齿,但用边缘光把它盖住。菲涅尔边缘光在模型轮廓处最亮,正好遮住了深度切断的位置。我试过把_FresnelPower调到3左右,边缘光宽度刚好覆盖锯齿区域,视觉上就看不出硬边了。这也是为什么全息效果几乎必备边缘光——它不只是好看,还在帮你遮丑。

3. 扫描线与噪波:让表面"动"起来的实现细节

3.1 扫描线的UV选择与拉伸问题

扫描线最直觉的做法是用模型UV的v坐标做sin函数:

float scanline = sin(uv.y * _ScanlineCount + _Time.y * _ScanlineSpeed);

但这里有个问题:模型UV的分布不均匀。一个圆柱体的侧面UV是均匀拉伸的,但顶面和底面的UV是圆形映射,扫描线在上面会变成同心圆而不是横线。角色模型的UV更是乱七八糟,扫描线会跟着UV走,看起来像贴图在动而不是投影在刷新。

解决办法是改用屏幕空间坐标或者世界空间坐标。屏幕空间的做法是用ComputeScreenPos拿到屏幕UV,用它的y坐标做扫描线,这样不管模型什么形状,扫描线永远是水平的、均匀的。世界空间的做法是用世界坐标的y值,扫描线跟着世界高度走,模型移动时扫描线不动,更像"投影在场里"的感觉。我一般用屏幕空间,因为全息投影的扫描线本质是显示设备的刷新,跟屏幕对齐更合理。

float4 screenPos = ComputeScreenPos(o.vertex); float2 screenUV = screenPos.xy / screenPos.w; float scanline = sin(screenUV.y * _ScanlineCount + _Time.y * _ScanlineSpeed);

注意除以w这一步不能省,否则透视投影下扫描线会变形。

3.2 扫描线的对比度控制

sin函数的输出是-1到1,直接拿来用的话亮暗过渡太柔和,看起来像渐变而不是条纹。实际需要的是接近方波的形状——亮的地方亮、暗的地方暗、过渡窄。有两种做法:一种是用smoothstep做阈值切割:

float scanline = sin(screenUV.y * _ScanlineCount + _Time.y * _ScanlineSpeed); scanline = smoothstep(-0.5, 0.5, scanline);

这样-0.5以下压到0,0.5以上抬到1,中间过渡。另一种是取绝对值再pow:

scanline = pow(abs(scanline), _ScanlineSharpness);

_ScanlineSharpness越大,条纹越细越锐。我倾向第二种,参数调节更直观,而且pow的计算量比smoothstep小。实测_ScanlineSharpness在0.3到0.6之间条纹感比较自然,太小会糊成一片,太大变成细密网格。

3.3 噪波扰动:让全息"不稳定"

全息效果如果完全静止,看起来就像一块半透明玻璃。加一点噪波扰动,模拟信号不稳的感觉,真实感立刻上来。最简单的做法是用时间做偏移,对UV加一个sin扰动:

float noise = sin(_Time.y * _NoiseSpeed + uv.y * _NoiseFrequency) * _NoiseAmount; screenUV.x += noise;

这样扫描线会左右轻微抖动。_NoiseAmount控制抖动幅度,一般设0.002到0.005就够了,太大整个画面会晃得头晕。_NoiseSpeed控制抖动频率,8到15之间比较像电子信号的不稳定感。

更高级的做法是用噪声纹理采样,但那样需要额外贴图资源。对于全息效果来说,sin扰动已经足够,因为观众不会盯着看细节,只要感觉到"在动"就行。我在项目里试过用Perlin噪声,效果确实更自然,但性能开销和资源管理成本不值得,最后还是回到了sin。

4. 完整Shader代码拆解与参数调优

4.1 顶点着色器的坐标变换链

先把完整的顶点着色器过一遍,每一步的作用和容易出错的地方都标出来:

v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; o.screenPos = ComputeScreenPos(o.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; }

UnityObjectToClipPos把模型空间顶点转到裁剪空间,这是必须的。UnityObjectToWorldNormal把法线转到世界空间,注意法线变换要用逆转置矩阵,Unity的这个内置函数已经处理好了,不要自己拿模型矩阵乘。worldPos用于计算视线方向。screenPos用于扫描线。TRANSFORM_TEX处理贴图的平铺偏移,如果不用主贴图可以省掉。

有个细节:ComputeScreenPos要在裁剪空间坐标上算,所以必须放在UnityObjectToClipPos之后。如果先算screenPos再算clipPos,结果会错。这个顺序我踩过一次坑,扫描线位置完全对不上,查了半天才发现是顺序问题。

4.2 片元着色器的分层叠加逻辑

片元着色器是全息效果的核心,逻辑是分层叠加:基础色打底、菲涅尔边缘光提亮、扫描线调制透明度、噪波扰动位置。按这个顺序写:

fixed4 frag (v2f i) : SV_Target { float3 normalDir = normalize(i.worldNormal); float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos); float fresnel = 1.0 - saturate(dot(normalDir, viewDir)); fresnel = pow(fresnel, _FresnelPower); float2 screenUV = i.screenPos.xy / i.screenPos.w; float noise = sin(_Time.y * _NoiseSpeed + i.uv.y * _NoiseFrequency) * _NoiseAmount; screenUV.x += noise; float scanline = sin(screenUV.y * _ScanlineCount + _Time.y * _ScanlineSpeed); scanline = pow(abs(scanline), _ScanlineSharpness); float alpha = _BaseAlpha + fresnel * _FresnelIntensity; alpha *= lerp(1.0, scanline, _ScanlineIntensity); fixed3 color = _HologramColor.rgb + fresnel * _FresnelColor.rgb; return fixed4(color, saturate(alpha)); }

逐行解释关键点。fresnel计算用世界空间法线和视线方向,注意viewDir是表面指向摄像机,不是摄像机指向表面,方向反了菲涅尔会内外颠倒。alpha的基础值加菲涅尔贡献,再用扫描线做乘法调制——用lerp而不是直接乘,是为了控制扫描线的影响强度,_ScanlineIntensity=0时扫描线完全不影响透明度。最后saturate防止alpha超过1导致混合异常。

4.3 参数面板的合理默认值

Properties块里的参数不是随便填的,每个都有实测过的合理范围:

Properties { _HologramColor ("Hologram Color", Color) = (0.2, 0.8, 1.0, 1.0) _FresnelColor ("Fresnel Color", Color) = (0.5, 1.0, 1.0, 1.0) _BaseAlpha ("Base Alpha", Range(0, 1)) = 0.25 _FresnelPower ("Fresnel Power", Range(0.5, 8)) = 3.0 _FresnelIntensity ("Fresnel Intensity", Range(0, 2)) = 0.8 _ScanlineCount ("Scanline Count", Range(10, 200)) = 80 _ScanlineSpeed ("Scanline Speed", Range(-10, 10)) = 3.0 _ScanlineSharpness ("Scanline Sharpness", Range(0.1, 2)) = 0.4 _ScanlineIntensity ("Scanline Intensity", Range(0, 1)) = 0.6 _NoiseSpeed ("Noise Speed", Range(0, 30)) = 10 _NoiseFrequency ("Noise Frequency", Range(0, 50)) = 20 _NoiseAmount ("Noise Amount", Range(0, 0.02)) = 0.003 }

颜色选青蓝色系是因为它最像"投影光",暖色系看起来像火焰或者能量护盾,不像全息。_BaseAlpha默认0.25,配合_FresnelIntensity 0.8,边缘处alpha能到0.5左右,层次刚好。_ScanlineCount 80在1080p屏幕上大约每13像素一条线,肉眼能分辨但不刺眼。_NoiseAmount上限设0.02是因为再大画面就开始明显晃动了。

5. 实测中遇到的五个具体问题与解法

5.1 模型缩放后菲涅尔失效

把全息Shader挂到一个缩放过的模型上,发现边缘光位置不对——本该在轮廓处亮的地方,亮到了模型内部。原因是菲涅尔计算依赖法线方向,而UnityObjectToWorldNormal在非均匀缩放时会出问题。虽然这个函数内部用了逆转置矩阵处理,但如果模型的Transform缩放是负数(比如x=-1做镜像),法线方向会翻转,菲涅尔就内外颠倒了。

解法是在顶点着色器里手动处理法线变换,或者干脆在导入设置里把模型的缩放Apply掉。我倾向后者,因为运行时缩放带来的问题不止法线一个,包围盒、碰撞体都会受影响。如果确实需要运行时缩放,在Shader里加一句判断:

#ifdef UNITY_ASSUME_UNIFORM_SCALING o.worldNormal = UnityObjectToWorldNormal(v.normal); #else o.worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject)); #endif

这是Unity内置宏的写法,非均匀缩放时用世界到模型的逆转置来变换法线。

5.2 扫描线在UI上不显示

把全息材质用在UI Image上时,扫描线完全看不到。原因是UI的渲染走的是Canvas渲染流程,ComputeScreenPos拿到的屏幕坐标和UI的顶点坐标体系不一致。UI的顶点已经是屏幕空间的了,再算一次screenPos等于重复变换。

解法是UI场景下直接用UV的y坐标做扫描线,或者用顶点颜色的alpha通道传屏幕位置。更简单的做法是给UI单独写一个变体,用i.uv.y代替screenUV.y。我实际项目里是做了个开关Keyword,UI材质勾上就切到UV模式,3D模型用屏幕空间模式。

5.3 多个全息物体叠加时颜色过曝

场景里放了三个全息面板叠在一起,交叠区域亮到发白。这是透明混合的固有问题:每次混合都在做颜色加法,三层0.3alpha叠起来,交叠处相当于0.3+0.3×0.7+0.3×0.49≈0.66的覆盖率,颜色接近不透明。如果颜色本身亮度高,叠加后就会过曝。

缓解办法有两个:降低单个物体的_BaseAlpha,让叠加后的总覆盖率可控;或者在颜色上做预乘,让亮色区域的alpha自动降低。我用了第一种,把_BaseAlpha从0.25降到0.15,三层叠加后交叠区大约0.4,还能看出层次。第二种需要改混合模式为Blend One OneMinusSrcAlpha(预乘alpha),对美术资源的要求更高,不适合快速迭代。

5.4 移动端上帧率骤降

在PC上跑得好好的全息效果,打包到移动端后帧率掉了一半。排查下来是两个原因:一是pow和sin在移动端GPU上开销比PC大,片元着色器里用了三次pow(菲涅尔、扫描线锐化)和两次sin(扫描线、噪波);二是透明混合本身在移动端就是带宽杀手,overdraw严重。

优化手段:把扫描线的pow(abs(x), n)换成x*x的近似(当n≈0.5时误差可接受),噪波的sin换成用顶点颜色或UV的预计算扰动,菲涅尔保留pow因为它的精度要求高。另外把_ScanlineCount降低到40,减少高频条纹带来的采样压力。改完之后中端机从30帧回到55帧左右,画质损失肉眼几乎看不出。

5.5 阴影投射异常

全息模型默认会投射实心阴影,看起来非常违和——一个半透明的东西投出浓重的黑影。在Shader里加一行:

FallBack "Transparent/Cutout/VertexLit"

或者干脆在MeshRenderer上关掉Cast Shadows。但关掉阴影后模型像飘在空中,缺少落地感。折中方案是用一个假的圆形阴影贴片放在脚下,透明度调低,模拟全息投影的底座光晕。这个技巧在VR项目里特别常用,因为VR对阴影的性能开销很敏感。

6. 从全息效果延伸出去的几个变体方向

6.1 故障风全息:加RGB分离

在全息基础上做RGB通道偏移,就有了故障艺术的感觉。做法是在采样颜色时对R、G、B三个通道分别用不同的UV偏移:

float offset = _GlitchAmount * sin(_Time.y * _GlitchSpeed); fixed r = tex2D(_MainTex, i.uv + float2(offset, 0)).r; fixed g = tex2D(_MainTex, i.uv).g; fixed b = tex2D(_MainTex, i.uv - float2(offset, 0)).b;

偏移量随时间变化,偶尔来一下大的(用step函数做随机触发),就有信号干扰的效果。这个变体适合做Boss出场、传送门、数据流之类的场景。

6.2 溶解全息:用噪波控制alpha裁剪

给全息效果加一个溶解进度参数,用噪声纹理的采样值和进度比较,小于阈值的片元直接clip掉。配合边缘发光,溶解边缘会有一圈亮边。这个效果常用于角色死亡、物体传送消失。关键点是clip的阈值要配合噪声的分布调整,否则溶解会一块一块跳变而不是平滑推进。

6.3 力场护盾:全息加顶点扰动

把全息效果套在球体上,顶点着色器里根据法线方向做sin波动,就有了能量护盾的感觉。扰动幅度和受击位置关联,被打中的地方波动加剧,就是常见的护盾受击反馈。这个变体的难点在于顶点扰动后法线要重新计算,否则光照会错。简单做法是扰动幅度小的时候忽略法线变化,幅度大的时候用有限差分近似重算法线。

全息透明效果这个案例,代码量不大,但把透明渲染的几个核心问题都串起来了:混合模式、深度写入、渲染队列、屏幕空间坐标、菲涅尔计算。把这几个点吃透,后面做溶解、护盾、故障风都是在这个骨架上加东西。我个人建议是先跑通最基础的全息版本,把每个参数都拖一遍看变化,建立直觉之后再往上叠效果。参数范围我在上面给了参考值,但不同模型、不同场景光照下最舒服的数值可能不一样,多试几组比死记参数有用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进