首页 / 资讯中心 / 文章详情

Unity全息效果透明Shader实战:从原理到避坑的完整指南

Unity全息效果透明Shader实战:从原理到避坑的完整指南 ★ FEATURED ARTICLE
全息投影这个词这几年被用得有点泛滥从展会上的炫酷展示到游戏里的UI特效到处都能看到它的影子。但真到自己动手在Unity里实现一个像样的全息效果很多人第一反应是找个现成的Shader抄一抄结果跑起来要么透明得看不清要么叠了一堆半透明物体之后画面糊成一团。我当初也是这么过来的踩了不少坑才慢慢摸清楚这里面的门道。这篇内容就围绕**全息效果透明**这个主题把我在实际项目里反复调试后沉淀下来的思路和做法完整拆一遍从原理到代码到避坑尽量让刚接触Shader的朋友也能跟着做出来同时给已经上手的朋友一些可以深挖的细节。全息效果本质上是在模拟一种“投影出来的、非实体的、带扫描线和边缘光”的视觉语言。它要解决的核心问题是让一个实体模型看起来像是用光编织出来的既有体积感又有通透感同时还要在透明混合的渲染管线里不打架。适合谁看如果你已经写过一两个最简单的Shader知道顶点着色器和片元着色器大概在干什么那这篇会非常对路如果你完全没碰过Shader但愿意跟着敲代码也能走下来我会把每个参数为什么这么设讲清楚。1. 全息效果的整体设计与思路拆解1.1 为什么全息效果必须走透明渲染路线先想清楚一件事全息效果和普通不透明材质的根本区别在哪。普通材质是“挡住后面”的渲染时写深度、做深度测试谁在前面谁显示。全息效果要的是“透过去还能看到后面”这就意味着它必须参与透明混合也就是把当前片元的颜色和已经存在于颜色缓冲区里的颜色按一定比例混合。在Unity的渲染管线里这对应的是Queue Transparent和Blend SrcAlpha OneMinusSrcAlpha这类设置。但这里有个容易被忽略的点一旦你开了透明混合深度写入ZWrite通常要关掉否则半透明物体之间会出现“谁先画谁就挡住谁”的诡异现象。我见过不少新手写的全息Shader单个物体看没问题场景里放两个重叠的全息模型立刻出现一块块硬边就是因为ZWrite On没关。那关掉ZWrite之后又带来新问题物体自身的正面和背面谁先渲染如果不管背面可能盖在正面上看起来像内部翻出来了。所以全息效果通常还要配合Cull Off双面渲染加上对背面法线的处理让正反面的光照表现有区分。这一套组合拳下来才是全息透明效果能站得住的基础。1.2 扫描线、边缘光、菲涅尔三件套的分工全息效果之所以一眼能认出来靠的是几个标志性视觉元素。我在项目里总结下来核心就三样水平扫描线、边缘菲涅尔光、整体透明度控制。扫描线负责制造“信号不稳定”的科技感通常用世界坐标或者屏幕坐标的Y分量做周期性函数比如frac(worldPos.y * _ScanLineDensity - _Time.y * _ScanLineSpeed)取一个窄区间做亮带。这里密度和速度是两个要调的参数密度太高会变成噪点太低又看不出扫描感我一般从20到60之间试。边缘菲涅尔光解决的是“体积感”问题。全息物体如果只有均匀的半透明色看起来就像一块彩色玻璃没有“光在边缘聚集”的感觉。菲涅尔项用视线方向和法线方向的点积来算1 - saturate(dot(normal, viewDir))边缘处接近1正面接近0再乘上一个边缘颜色就能让轮廓亮起来。这个做法在URP和内置管线里都通用区别只是获取视线方向的方式。整体透明度则是把所有元素捏合在一起的胶水。我习惯用一个_Alpha总控参数再让扫描线和菲涅尔光在这个基础上做加减保证调一个值就能整体变淡或变浓不用逐个改。1.3 内置管线与URP的选型考量这里得说清楚一个现实问题现在新项目大多用URP但网上大量全息Shader教程还是内置管线的写法。两者的核心数学是一样的差别在于获取光照、相机方向、时间变量的方式。内置管线里_WorldSpaceCameraPos直接可用URP里则要通过GetWorldSpaceViewDir或者自己算。我建议不管用哪个管线先把数学逻辑写对再套管线的壳子。如果你是从零开始的新项目直接上URP用Shader Graph或者手写HLSL都行。如果是维护老项目内置管线的那套UnityCG.cginc里的宏也够用。下面我给的代码会以通用HLSL为主关键地方标注两种管线的差异你按自己的环境取用。2. 核心细节解析与实操要点2.1 顶点着色器里要准备哪些数据顶点着色器的任务不只是把顶点从模型空间转到裁剪空间对全息效果来说还要把几样东西传给片元世界坐标下的位置、世界坐标下的法线、以及视线方向。世界位置用于扫描线计算世界法线用于菲涅尔视线方向可以在这里算好也可以到片元里算。我习惯在顶点阶段把世界法线转好因为全息效果经常要双面渲染背面法线需要取反。具体做法是在顶点着色器里判断当前是正面还是背面——用FRONT_FACE语义或者直接根据Cull Off后法线的朝向处理。一个稳妥的写法是输出两个法线一个原始世界法线片元里根据SV_IsFrontFace决定是否取反。这样正反面的菲涅尔表现都能正确。还有一点全息效果经常需要模型有比较均匀的法线分布如果模型法线是硬边比如立方体菲涅尔会在棱角处跳变。这不是Shader的错是模型本身的问题。遇到这种情况要么接受这种风格化效果要么在建模软件里平滑法线。我在一个机械零件的全息展示里就遇到过最后是让美术重新导出了一版平滑法线的模型才解决。2.2 扫描线的频率与相位控制扫描线写起来简单调好却不容易。核心公式是拿一个随时间变化的值去对某个空间坐标取小数部分然后判断是否落在亮带区间内。这里有个细节用世界坐标的Y还是用模型局部坐标的Y用世界坐标的话物体移动时扫描线是“固定在世界里”的物体穿过扫描面用局部坐标的话扫描线跟着物体走。两种都有人用我倾向世界坐标因为更像“外部投影设备在扫描”。频率参数_ScanLineDensity控制条纹疏密速度参数_ScanLineSpeed控制流动快慢。实测下来密度在30左右、速度在1到3之间比较自然。亮带的宽度用smoothstep做软过渡比硬step好看很多因为硬边在移动时会有明显的锯齿闪烁。注意扫描线的计算如果放在片元着色器里逐像素做开销可以接受但如果你场景里有几十个全息物体可以考虑把扫描线的相位在顶点阶段算个近似值传下来片元里只做插值。不过大多数项目没必要优化到这一步先保证效果对。2.3 菲涅尔的强度与颜色搭配菲涅尔项本身就是一个pow函数调出来的曲线pow(1 - saturate(dot(N, V)), _FresnelPower)。_FresnelPower越大边缘光越窄越锐利越小边缘光越宽越柔和。全息效果我一般用2到4之间太锐利会显得像描边太柔和又失去轮廓感。颜色上边缘光通常用比主体色更亮、更偏青蓝的色调模拟光在边缘的散射。主体色用偏暗的蓝绿或者青色透明度给到0.3到0.5这样叠上边缘光之后整体有层次。这里有个配色经验主体色和边缘色不要用同一个色相稍微拉开一点比如主体偏青、边缘偏蓝白出来的效果更有“光感”。2.4 透明混合模式的选择与深度处理前面提过要关ZWrite但具体怎么写还是有讲究。标准的透明混合是Blend SrcAlpha OneMinusSrcAlpha源颜色的alpha决定混合比例。全息效果里最终alpha是主体透明度、扫描线亮度、菲涅尔亮度综合出来的要保证它落在0到1之间别溢出。深度方面除了ZWrite Off还要考虑ZTest。默认ZTest LEqual意味着被不透明物体挡住的部分不渲染这是对的。但如果你希望全息物体能“透”过某些东西显示那就得改ZTest Always不过那样会破坏场景遮挡关系慎用。我在一个AR展示的项目里用过ZTest Always因为要让全息模型浮在真实画面上方但那是特殊需求常规场景还是保持LEqual。另外双面渲染时正反面的渲染顺序会影响最终颜色。Unity默认按物体中心到相机距离排序单个物体内部的正反面顺序不保证。如果发现背面盖住正面可以在Shader里用两个Pass分别渲染背面和正面背面Pass先画正面Pass后画这样正面总是叠在背面之上。代价是多一个Pass的开销但对单个全息物体来说可以接受。3. 实操过程与核心环节实现3.1 完整Shader代码结构与逐段说明下面这份代码是内置管线风格的写法URP用户把UnityCG.cginc换成对应的include、把_WorldSpaceCameraPos的获取方式调整一下即可。我把它拆成属性块、SubShader标签、Pass结构三部分讲。Shader Custom/HologramTransparent { Properties { _MainColor (主体颜色, Color) (0.1, 0.6, 0.8, 1) _MainAlpha (主体透明度, Range(0, 1)) 0.4 _FresnelColor (边缘光颜色, Color) (0.5, 0.9, 1, 1) _FresnelPower (边缘光强度, Range(0.5, 8)) 3.0 _FresnelIntensity (边缘光倍率, Range(0, 3)) 1.5 _ScanLineColor (扫描线颜色, Color) (0.6, 1, 1, 1) _ScanLineDensity (扫描线密度, Range(1, 100)) 35 _ScanLineSpeed (扫描线速度, Range(0, 10)) 2.0 _ScanLineWidth (扫描线宽度, Range(0.01, 0.5)) 0.08 } SubShader { Tags { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float3 worldPos : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 viewDir : TEXCOORD2; }; fixed4 _MainColor; float _MainAlpha; fixed4 _FresnelColor; float _FresnelPower; float _FresnelIntensity; fixed4 _ScanLineColor; float _ScanLineDensity; float _ScanLineSpeed; float _ScanLineWidth; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.worldNormal UnityObjectToWorldNormal(v.normal); o.viewDir normalize(_WorldSpaceCameraPos - o.worldPos); return o; } fixed4 frag (v2f i, fixed facing : VFACE) : SV_Target { float3 N normalize(i.worldNormal); if (facing 0) N -N; float3 V normalize(i.viewDir); float fresnel pow(1.0 - saturate(dot(N, V)), _FresnelPower); fresnel * _FresnelIntensity; float scanPhase i.worldPos.y * _ScanLineDensity - _Time.y * _ScanLineSpeed; float scanFrac frac(scanPhase); float scanLine smoothstep(0.0, _ScanLineWidth, scanFrac) * smoothstep(1.0, 1.0 - _ScanLineWidth, scanFrac); scanLine 1.0 - scanLine; fixed3 finalColor _MainColor.rgb; finalColor _FresnelColor.rgb * fresnel; finalColor _ScanLineColor.rgb * scanLine * 0.5; float finalAlpha _MainAlpha fresnel * 0.5 scanLine * 0.3; finalAlpha saturate(finalAlpha); return fixed4(finalColor, finalAlpha); } ENDCG } } }属性块里每个参数都给了默认值方便你挂上去就能看到效果。_MainAlpha默认0.4是主体通透感的来源。_FresnelIntensity单独拎出来是因为菲涅尔算出来的值可能偏小需要一个倍率放大。扫描线的三个参数密度、速度、宽度前面已经说过取值范围。顶点着色器里VFACE语义是判断当前片元属于正面还是背面facing 0时把法线取反这样背面也能正确计算菲涅尔。这个语义在内置管线和URP里都支持写法略有差异但思路一致。片元里扫描线的计算用了两个smoothstep相减再取反得到的是一个中间亮、两边软的亮带。scanLine最终值在亮带处接近1其他地方接近0。然后把它乘0.5加到颜色上乘0.3加到alpha上让扫描线既提亮又稍微增加不透明度看起来更像“光带”。3.2 参数调试的实操记录代码挂上去之后第一版效果往往不是太淡就是太糊。我记录一下自己调参的顺序你可以照着走。先调_MainAlpha从0.4开始如果模型看起来像实心的就往下调到0.25左右如果太透明看不清结构就往上到0.5。这一步定的是整体基调。然后调_FresnelPower和_FresnelIntensity。先把倍率设1power从3开始试看边缘光宽度是否合适。如果边缘光太宽盖住了主体把power往上加到4或5如果边缘光细得看不见往下调到2。宽度合适后再调倍率一般1.5到2之间能让边缘明显但不刺眼。最后调扫描线。密度35、速度2、宽度0.08是起点。如果扫描线太密像噪点密度降到20如果流动太快晃眼速度降到1如果亮带太宽像条纹宽度降到0.05。这一步很主观多试几组值找到自己顺眼的。提示调参时把场景背景设成深色全息效果在深色背景上对比最明显浅色背景上容易看不清扫描线。实际项目里也要考虑背景色必要时给全息物体加一个微弱的自发光底色保证可见度。3.3 多物体叠加时的排序处理单个全息物体调好之后往场景里再放一个问题就来了两个物体重叠的部分颜色会叠加变亮而且谁盖谁不确定。这是透明渲染的固有特性因为ZWrite Off之后深度缓冲不记录全息物体的深度后画的直接叠在前面的结果上。解决办法有两个方向。一是接受这种叠加因为全息效果本身就有“光叠加变亮”的物理直觉两个全息面重叠处更亮反而合理。二是如果叠加太夸张可以降低单个物体的_MainAlpha让叠加后的亮度落在可接受范围。我在一个展示场景里放了五个全息零件最后是把_MainAlpha从0.4降到0.2叠加起来才不刺眼。如果确实需要严格的前后遮挡那就得回到ZWrite On但那样单个物体内部的正反面又会打架。折中方案是给每个全息物体单独一个渲染队列偏移手动控制绘制顺序但这在物体动态移动时很难维护。我的建议是全息效果就接受透明叠加的特性把它当成风格的一部分别硬追求不透明物体那种严格遮挡。3.4 在URP下的适配改动URP下这份代码不能直接跑需要几处改动。include换成UniversalRenderPipeline.cginc相关_WorldSpaceCameraPos在URP里仍然可用但推荐用GetWorldSpaceViewDir。VFACE在URP里用FRONT_FACE语义写法是fixed facing : FRONT_FACE判断逻辑一样。另外URP的透明物体默认不写深度标签里Queue和RenderType保持一样即可。如果你用Shader Graph菲涅尔节点、时间节点、世界位置节点都有现成的扫描线可以用Fraction节点加Time节点搭出来逻辑和代码版完全一致。Shader Graph的好处是调参直观坏处是复杂逻辑多了之后节点图会很乱我一般简单效果用Graph需要精细控制的地方还是手写HLSL。4. 常见问题与排查技巧实录4.1 效果不显示或全黑全白最常见的问题是Shader编译报错但没注意看控制台挂上去显示粉色。先确认include路径对不对内置管线用UnityCG.cgincURP用对应的。如果编译通过但物体全黑检查_MainColor的alpha是不是被设成了0或者finalAlpha算出来是0。全白则可能是颜色值溢出finalColor累加之后超过了1虽然fixed4会自动截断但看起来就是一片白把各颜色分量的贡献调小即可。还有一种情况是物体在场景里但完全看不见检查Queue是不是设成了Transparent以及相机的裁剪面是否把物体裁掉了。我遇到过一次是模型缩放为0折腾了半天Shader最后发现是Transform的问题。4.2 扫描线闪烁或锯齿严重扫描线闪烁通常是因为frac函数在亮带边界处跳变加上时间变化后相邻帧的相位不连续。用smoothstep做软过渡能缓解大部分如果还有检查_ScanLineWidth是不是太小宽度小于0.02时软过渡几乎失效。锯齿严重则是分辨率问题可以尝试在扫描线计算里加一点基于屏幕空间导数的抗锯齿或者干脆接受这种风格化的硬边。另一个隐蔽的原因是模型顶点密度不够。扫描线是按世界Y算的如果模型在Y方向上的面片很大插值出来的世界坐标在片元里是线性的但frac是非线性的会在面片中间产生扭曲。解决办法是让模型在Y方向有足够的分段或者改用屏幕空间Y坐标算扫描线后者不受模型拓扑影响但扫描线会固定在屏幕上不随物体移动。4.3 边缘光在硬边模型上断裂前面提过法线问题。硬边模型比如立方体的顶点法线在棱角处是分裂的菲涅尔计算时相邻面片的边缘光不连续看起来像断开的描边。这不是Shader能完全修复的因为法线数据本身就是断的。可行的缓解办法是把_FresnelPower调低让边缘光变宽变柔断裂感会弱一些或者用顶点色或者额外的平滑法线贴图来提供连续的法线信息。最彻底的还是让美术导出平滑法线的模型。4.4 性能开销与优化方向全息Shader本身的计算量不大主要开销在透明混合和双面渲染带来的overdraw。如果场景里全息物体很多或者屏幕占比很大overdraw会明显拖累帧率。优化方向有几个降低_MainAlpha减少混合的视觉权重不减少实际开销但能让你少画几层、把扫描线从逐像素改成逐顶点近似、以及最有效的——减少全息物体的屏幕覆盖面积。我在一个移动端项目里做过对比同一个全息模型在屏幕上占三分之一面积时双面透明渲染比单面不透明渲染多出约40%的GPU时间。这个数字因设备而异但趋势是明确的透明加双面就是费。所以移动端上全息效果要克制使用或者用单面渲染加背面剔除来省一半开销代价是看不到内部结构。4.5 常见问题速查表现象可能原因排查与解决物体显示粉色Shader编译错误看控制台报错检查include和语法完全看不见alpha为0或Queue不对检查_MainAlpha和Tags设置全白一片颜色累加溢出降低各颜色分量贡献扫描线闪烁亮带边界跳变加大_ScanLineWidth用smoothstep扫描线扭曲模型Y向分段不足增加分段或改用屏幕空间Y边缘光断裂硬边法线不连续降低FresnelPower或平滑法线多物体叠加过亮透明叠加特性降低单个_MainAlpha正反面顺序错乱单Pass双面渲染顺序不保证拆成两个Pass分别渲染正反面移动端卡顿透明双面overdraw减少屏幕占比或改单面渲染这张表是我自己排查时整理的大部分问题对着查能快速定位。实际遇到的情况可能更复杂但思路是先确认Shader编译没问题再确认参数没设错最后才怀疑渲染顺序和性能。4.6 几个容易被忽略的实操心得第一个心得全息效果的颜色不要用纯色。纯青或者纯蓝看起来像塑料稍微混一点白或者降低饱和度更像“光”。我一般在主体色里混10%到20%的白边缘光用接近白的浅蓝出来的质感明显好一截。第二个心得扫描线的方向和物体朝向的关系。如果物体是竖直放置的水平扫描线很自然如果物体是横躺的水平扫描线会显得别扭这时候可以把扫描线改成沿物体局部Y轴方向或者干脆用径向扫描。这个要看具体展示需求没有标准答案。第三个心得给全息效果加一点微弱的顶点扰动会更有“信号不稳”的感觉。做法是在顶点着色器里根据时间和顶点位置加一个很小的偏移幅度控制在0.01以内大了就变形了。这个扰动不是必须的但加上之后动态效果会生动很多尤其是配合扫描线一起动的时候。第四个心得如果全息物体需要接收场景光照那这套纯自发光式的写法就不够了得把光照计算加进来再和全息元素混合。但大多数全息效果追求的是“不受场景光照影响的投影感”所以纯自发光反而更对味。这个取舍要看项目需求别默认全息就一定要自发光也别默认一定要受光。我在实际项目里做全息效果最深的体会是参数调试比代码编写花的时间多得多。代码框架搭好之后百分之七十的时间都在微调颜色、透明度、扫描线密度这些数值而且每换一个模型、换一个背景色可能都要重新调一轮。所以别指望一套参数走天下把参数暴露出来、调参流程理顺比追求一个“万能数值”实际得多。另外全息效果在静态截图里和动态运行时的观感差别很大扫描线的流动、透明度的层次都是动起来才看得准调参时一定要在运行状态下看别对着Scene视图的静态画面调。
阅读完成 · 觉得有帮助?
咨询建站