不知道你有没有遇到过这种需求策划丢给你一句话角色狂暴状态加个描边身上再带一条来回流动的光带然后留你一个人对着Unity发呆。描边归描边流光归流光这俩听起来是两套完全不相干的东西非得塞进同一个Shader里确实让人头疼。我去年做某个Demo的时候就被这个组合坑过一次。当时第一反应是叠两个Shader一个做外描边一个做UV动画光带。结果光是处理材质球嵌套、渲染顺序和ZWrite冲突就改了一整天最后还是决定自己写一个三合一的Shader方案。这篇文章就是把这个过程里踩过的坑、拆解出的原理和最终能直接用的代码整理出来给同样被这类效果折腾过的朋友一个参考。它能做什么说白了就是三件事模型边缘有一层随时间变化的描边颜色模型本身有一层动态插值的色彩过渡再叠加上一条沿着某个方向循环移动的柔边流光带。三个效果合成一个Material手机上跑也不吃力。适合做Buff状态提示、受击反馈、道具拾取高亮、科技风场景里的隐藏门或开关提示这类需要让人一眼就注意到的场景。1. 先拆需求描边、颜色插值、流光扫描各自到底在渲染什么要写一个不翻车的组合Shader第一步不是着急抄代码而是把效果拆成三个独立的渲染逻辑想清楚它们在GPU里分别干了什么事。很多时候Shader写得乱就是因为这几个概念在脑子里是糊在一起的。1.1 描边改变的是模型的轮廓可见性描边本质上是一个边缘识别轮廓染色的过程。实现方式很多但组合进一个Shader时最友好的方案是法线外扩法把模型的顶点沿着法线方向向外推一点点然后把背面的片元渲染成描边颜色。因为渲染管线默认会剔除背面的三角形所以要翻转Cull设置为Cull Front让被推到外层的背面变成可见的看着就像模型周围镀了一圈边。这个方案的好处是不需要额外屏幕空间特效也不需要后处理相机纯粹在一个Pass里就能完成跟主体Pass天然兼容。我试过在移动端大批量用帧率影响很小前提是描边宽度别拉得太夸张。1.2 颜色插值改变的是片元最终输出的色值路径颜色插值用大白话说就是在GPU里做一个颜色随时间变化的过渡。它不改变模型的形状、不改变贴图采样只是在最终输出颜色之前用当前时间_Tine.y算出两个颜色之间的比例因子t再用lerp混合。反应到画面上的效果就是颜色从一个色调慢慢滑向另一个色调再滑回来循环往复。这里有个容易混淆的点标题里说的颜色插值可以是描边颜色的插值也可以是主体颜色的插值甚至可以同时做。一般做流光扫描效果时插值的目标是流光带的颜色让扫描光带的色调不是死板的纯白而是会渐变而描边颜色的插值则是为了让物体的边界也跟着呼吸视觉上更有生命感。1.3 流光扫描改变的是某一块区域的光强调制流光扫描和颜色插值容易搞混但它们的本质完全不同插值是全片元同步变化的扫描则是有一个位置概念在移动。扫描效果的本质是用一个动态变化的坐标去判断当前片元是否落在那条移动的光带上。实现上通常是计算一个随时间移动的扫描位置_ScanPos然后对当前片元的坐标比如模型空间Y轴、UV的V方向做距离对比距离越小说明越接近光带中心光强越大距离超过一定范围就衰减到0。配合smoothstep可以做出很柔和的边缘不会像一条硬生生划过去的亮线。三件事想清楚之后整个Shader的结构就清晰了Pass 0做描边Pass 1做主体流光。颜色插值作为全局输入同时影响Pass 0的描边颜色和Pass 1的流光颜色。下面我按这个顺序分别把每个部分讲透。2. 描边方案选型法线外扩为什么是我最终的选择描边不是只有法线外扩一种做法。我最早接触描边时用的是屏幕空间边缘检测就是拿深度图和法线图做后处理提取轮廓。这样做出来的描边很平滑不会因为模型面数低而断裂而且描边宽度是像素级的特别稳定。但问题是它是一个后处理效果跟主体流光之间存在天然隔离。想在一个材质球上同时看到后处理描边和模型上的流光带等于要把后处理结果再回传给模型渲染链路复杂不说移动端多叠一张全屏RT的压力也不小。更关键的是边缘检测出的轮廓是所有物体共享的很难只对某一个角色做描边。所以我最终回到法线外扩的方案。法线外扩的核心代码其实就几行在顶点Shader里完成float3 normalDir normalize(mul((float3x3)UNITY_MATRIX_IT_MV, v.normal)); float2 offset TransformViewToProjection(normalDir.xy) * _OutlineWidth; o.pos UnityObjectToClipPos(v.vertex); o.pos.xy offset * o.pos.w * 0.01;注意这段代码里有两个细节。第一法线变换用了UNITY_MATRIX_IT_MV这是逆转置矩阵目的是保证模型在非均匀缩放或旋转之后法线方向依然正确。第二最后的偏移量乘了o.pos.w这个操作叫透视校正它能让描边宽度在远近不同的深度上保持视觉一致不会被透视投影拉得忽粗忽细。法线外扩虽然简单但它有两个死穴写之前必须知道。第一个死穴模型法线不是平滑处理过的或者模型存在硬边UV断开描边会在那些接缝处出现断裂。因为硬边处的法线方向是突变的沿法线外扩时相邻两个面的顶点可能一个往外跑得很远一个几乎没动轮廓就裂开了。解决办法是建模时尽量用平滑组烘焙平滑法线或者在Unity里导入模型时确保法线导入设置正确。第二个死穴非均匀缩放。如果某个物体的Scale是(1, 2, 1)这种非等比缩放直接沿法线外扩会有方向偏差描边会一边厚一边薄。对策是给Shader加一个统一的模型尺寸缩放系数让外扩幅度跟模型的包围盒尺寸匹配而不是使用绝对的0.02这种经验值。描边这块我给两套思路对比方便你按项目情况选方案A法线外扩优点单Shader可完成性能开销低组合进三合一结构最自然缺点低模硬边处可能断裂非均匀缩放时容易变形宽度非像素级稳定方案B后处理边缘检测优点轮廓完整平滑像素级宽度边缘不受模型质量影响缺点需要全屏RT无法单独给某个物体描边组合流光需要额外桥接移动端负载高我实际项目里如果描边只是辅助视觉引导基本都选方案A。只有做全屏统一的游戏世界轮廓风格化效果时才会考虑方案B。3. 颜色插值循环让描边和流光带都活起来颜色插值单独拿出来说是因为它在这篇Shader里承担了两个角色一部分负责描边颜色的呼吸过渡一部分负责流光带颜色的动态变化。两者共用一个时间驱动的插值逻辑但在参数上要分开控制否则就会出现描边和流光颜色永远同步变的尴尬情况。3.1 用_Time驱动循环插值Unity Shader内置了_Time这个float4变量其中_Time.y是从场景加载开始累计的时间秒数。用它做周期性变化最基础也最好用。核心逻辑是取小数部分来做循环float cycle frac(_Time.y * _FlashSpeed); half3 colorA _ColorA; half3 colorB _ColorB; half3 flashColor lerp(colorA, colorB, cycle);这里有个关键点cycle直接等于frac颜色过渡是单向的从A到B然后瞬间跳回A视觉上很生硬。如果想让颜色来回平滑呼吸要用正弦波而不是fracfloat breathe (sin(_Time.y * _FlashSpeed) 1.0) * 0.5; half3 flashColor lerp(_ColorA, _ColorB, breathe);breathe的取值范围是0到1以正弦周期在两端之间平滑往返。这就是呼吸感的来源。我测试时发现描边用breathe方式效果最好流光带里的颜色用breathe也可以但通常要给breathe再加一个速率偏移让两者不同步画面会更耐看。3.2 插值不只是颜色还有强度和位置颜色插值如果只用在最终颜色上效果还是比较单薄。更进阶的玩法是把插值因子同时接到强度系数和扫描位置偏移上。举个例子流光带的位置如果固定扫描看久了就像钟摆一样机械。让扫描带的起始位置伴随颜色插值一起轻微摆动视觉上就变成了光带和颜色一起律动整体感强非常多。做法也不难扫描带的目标位置参数乘以一个略微不同的正弦频率即可。我把这两者的参数分成了两组_FlashColorA / _FlashColorB / _FlashSpeed描边颜色的插值_ScanColorA / _ScanColorB / _ScanSpeed流光带颜色的插值这两组参数独立调节互不干扰。这也是我踩坑之后的结论最初我把它们共用一套颜色和速度结果描边和光带永远同步变色看久了特别呆板。3.3 事件式插值的思路拓展如果项目里需要的是受击瞬间变红然后慢慢恢复这种事件式插值而不是循环呼吸那Shader内部逻辑也不用大改。只需要C#侧在事件触发时把_FlashSpeed临时改大或者直接传一个_FlashStrength进去控制插值比例配合Lerp做一个衰减过渡即可。我提供一个简单的C#侧思路不贴完整代码因为每个人项目的触发方式不同核心就是通过Shader.SetFloat控制一个插值强度属性然后用协程或Update里的Lerp把它递减回0Shader.SetFloat(_FlashStrength, 1.0f);然后Shader里的插值逻辑变成float breathe 0.5 0.5 * sin(_Time.y * _FlashSpeed); half3 flashColor lerp(_ColorA, _ColorB, breathe * _FlashStrength);事件触发时强度拉满颜色明显变化随后强度衰减效果自然消退。这样既保留了循环呼吸的能力又兼容了单次事件触发的需求比单独写两个Shader要灵活得多。4. 流光扫描带从UV扫描切换到物体空间扫描流光扫描是这个效果里最动的部分。我最初的做法是直接拿UV的V坐标做扫描轴因为实现起来最快。但实际在角色模型上跑起来之后问题立刻暴露角色的身体和四肢如果UV拉伸不均匀流光带就会在某些区域变宽、某些区域变窄甚至因为贴图接缝产生明显的跳变。这跟描边断裂是同一个根源问题过度依赖UV而UV不代表模型的实际几何走向。4.1 基础UV扫描的实现先给你看基础的UV扫描写法理解它之后我们再升级float scanPos frac(_Time.y * _ScanSpeed); float scanMask 1.0 - saturate(abs(i.uv.y - scanPos) / _ScanWidth);逻辑很直白取UV的V坐标跟一个随时间变化的scanPos做距离对比距离小于_ScanWidth的部分亮起来外面衰减。为了让边缘更柔和把saturate换成smoothstepfloat d abs(i.uv.y - scanPos); float scanMask 1.0 - smoothstep(0.0, _ScanWidth, d);smoothstep的好处是边缘有渐变过渡不会出现生硬的扫描口。这个版本在平面物体上效果很好比如屏幕、传送门、地板线框这类贴图UV本身就规则的物体。4.2 物体空间扫描解决拉伸问题到了角色身体和复杂模型上真正好用的是物体空间扫描。所谓物体空间就是用模型本身的坐标轴来计算扫描位置而不是UV。这个方案有一个极其明显的优势模型怎么旋转流光带跟着模型几何走不会因为UV拉伸而变形。代码长这样顶点Shader里传物体空间坐标下去float3 objectPos TransformObjectToWorld(v.vertex.xyz); o.objectPosY objectPos.y;片元Shader里float bandCenter frac(_Time.y * _ScanSpeed); float bandDist abs(i.objectPosY - bandCenter); float bandMask 1.0 - smoothstep(0.0, _ScanWidth, bandDist);这里用物体空间的Y轴做扫描方向流光带就会沿模型的上下方向移动。如果你希望扫描带跟世界空间走不受模型旋转影响那就改成TransformObjectToWorld再取WorldPos的Y轴。两种方案我都用过简单总结一下UV扫描适合平面、规则物体代码最简单物体空间Y扫描适合角色、武器等立体模型流动方向跟模型几何一致世界空间Y扫描适合需要跟地面或场景空间对齐的物件比如传送门扫描、地面能量波我这篇文章里的完整Shader用的是物体空间方案因为标题场景角色加状态、道具高亮基本都是立体模型。4.3 柔边与扫描带的衰减设计光有距离计算还不够要让扫描带看起来像流光而不是一条移动的硬框必须做衰减和柔边。柔边用smoothstep解决衰减则是让光带从中心向两侧逐渐减淡保证光带结束的地方不会突然切断。我常用的参数组合里扫描带宽度的关键是用smoothstep的两端值来控制软硬程度。如果想让光带更锋利把_ScanWidth调小比如0.05~0.15想要弥散光效就调大到0.3以上。另外一个容易被忽略的点是流光带的亮度和颜色插值要乘在一起否则流光带会太亮或者太暗抢了模型本身的光照。我最终的做法是让最终的扫描发光量控制在0.5到2.5之间太低了看不清太高了过曝。这个范围你可以根据项目的渲染风格微调。5. 三合一Shader完整Pass结构与可用代码前面三个部分拆开讲完之后组合就是水到渠成的事了。这个Shader我做了两个PassPass 0渲染背面描边Pass 1渲染正面主体加流光扫描。两个Pass共用同一套属性但各自的控制点不同。5.1 Pass 0描边Pass的细节Pass 0的Cull要设成Front这是法线外扩描边的核心。顶点Shader里沿法线外扩后原本被剔除的背面呈现在模型轮廓外围形成描边。这里ZWrite建议保持On这样描边能正确参与深度排序不会被后面的主体完全覆盖。描边颜色直接用颜色插值的结果breathe呼吸因子乘上两个描边颜色之间lerp。这样描边本身就会缓慢变色跟流光带形成双重视觉重点。5.2 Pass 1主体流光Pass的细节Pass 1就是常规的正面渲染Cull Back。先采样主贴图再计算扫描带位置和颜色最后把扫描颜色叠加到主贴图颜色上。如果主贴图是带光照的PBR材质流光叠加要放在光照计算之后这样扫描带才能跟光照方向配合而不是生硬地贴在表面。为避免重叠描边和主体时出现深度冲突两个Pass的ZTest推荐都保持LEqualZWrite都开。5.3 完整Shader代码下面是完整可运行的URP版Shader代码我实际用在Demo里跑过手机端和编辑器下表现一致Shader Custom/StyleScanOutline { Properties { _MainTex (Albedo, 2D) white {} [Header(Outline)] _OutlineWidth (Outline Width, Range(0.0, 0.1)) 0.02 _OutlineColorA (Outline Color A, Color) (1, 1, 1, 1) _OutlineColorB (Outline Color B, Color) (1, 0.5, 0.2, 1) [Header(Scan)] _ScanSpeed (Scan Speed, Range(0.1, 5.0)) 1.0 _ScanWidth (Scan Width, Range(0.01, 0.6)) 0.2 _ScanIntensity (Scan Intensity, Range(0.0, 3.0)) 1.5 _ScanColorA (Scan Color A, Color) (0.0, 1.0, 1.0, 1) _ScanColorB (Scan Color B, Color) (0.2, 0.6, 1.0, 1) [Header(Flash)] _FlashSpeed (Flash Speed, Range(0.1, 5.0)) 1.0 _FlashStrength (Flash Strength, Range(0.0, 1.0)) 1.0 _ColorA (Base Color A, Color) (1, 1, 1, 1) _ColorB (Base Color B, Color) (1, 1, 1, 1) } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline QueueGeometry } Pass { Name OUTLINE Cull Front ZWrite On ZTest LEqual HLSLPROGRAM #pragma vertex vert_outline #pragma fragment frag_outline #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct Varyings { float4 positionCS : SV_POSITION; }; float _OutlineWidth; float4 _OutlineColorA; float4 _OutlineColorB; float _FlashSpeed; float _FlashStrength; Varyings vert_outline (Attributes v) { Varyings o; float3 normalDir normalize(mul((float3x3)UNITY_MATRIX_IT_MV, v.normalOS)); float2 offset TransformViewToProjection(normalDir.xy) * _OutlineWidth; o.positionCS UnityObjectToClipPos(v.positionOS); o.positionCS.xy offset * o.positionCS.w * 0.01; return o; } float4 frag_outline (Varyings i) : SV_Target { float breathe (sin(_Time.y * _FlashSpeed) 1.0) * 0.5; half3 col lerp(_OutlineColorA.rgb, _OutlineColorB.rgb, breathe); return half4(col * _FlashStrength, 1.0); } ENDHLSL } Pass { Name MAIN Cull Back ZWrite On ZTest LEqual HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 objectPosY : TEXCOORD1; }; TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); float4 _MainTex_ST; float _ScanSpeed; float _ScanWidth; float _ScanIntensity; float4 _ScanColorA; float4 _ScanColorB; Varyings vert (Attributes v) { Varyings o; o.positionCS TransformObjectToHClip(v.positionOS.xyz); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.objectPosY TransformObjectToWorld(v.positionOS.xyz).y; return o; } float4 frag (Varyings i) : SV_Target { float4 albedo SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv); float bandCenter frac(_Time.y * _ScanSpeed); float bandDist abs(i.objectPosY - bandCenter); float bandMask 1.0 - smoothstep(0.0, _ScanWidth, bandDist); float flashCycle (sin(_Time.y * _ScanSpeed * 1.7) 1.0) * 0.5; half3 scanColor lerp(_ScanColorA.rgb, _ScanColorB.rgb, flashCycle); half3 finalColor albedo.rgb scanColor * bandMask * _ScanIntensity; return half4(finalColor, 1.0); } ENDHLSL } } }这段代码可以直接创建为Shader文件挂到材质球上。注意改动的几个属性都是项目内需要在面板上微调的我把常用的参数还做成了Header分组方便美术同学使用。6. 实测下来的坑宽度、半透明、缩放与渲染顺序Shader代码能跑只是第一步真正能不能用要看它在实际场景里的表现。这一节记录的坑全都是我一个个踩过来的写出来帮你省时间。6.1 描边宽度随视角变化的问题法线外扩描边的宽度是模型空间偏移量不是屏幕像素宽度。在透视摄像机下物体离摄像机越远描边看起来越细离得越近描边看起来越粗。这个特性在某些情况下是自然的比如远处物体描边弱化符合透视感。但如果你需要的是恒定的像素宽度法线外扩原方案就不够用了。一个折中做法是像我在代码里那样用positionCS.w进行透视校正这能在一定程度上缓解远近差异但做不到像素级恒定。真要做像素级恒定宽度建议转向后处理边缘检测或者单独做一个专用描边Pass。我实测下来对于大部分游戏UI提示和角色状态效果透视校正后的法线外扩已经够用。6.2 半透明物体和描边的深度冲突如果你给Shader启用了透明混合比如想做一个半透明能量体加描边的效果问题就来了主体半透明后渲染顺序会变成透明队列描边Pass如果还按不透明方式处理会出现描边被主体遮挡或者描边浮在主体前面穿透过去的诡异效果。对策是两个Pass都设置相同的Queue和RenderType并且把描边Pass的ZWrite关掉靠模型自身的深度关系来排序。但这样又会带来新问题描边不写深度时两个物体交错的描边可能会互相穿透闪烁。所以我项目里的实际做法是半透明描边效果尽量做成不透明的半透明感即主体依然写深度只是颜色做透明混合或者干脆主体保持Opaque只让流光带模拟半透明感。这样既有了飘渺的视觉观感又不会陷入深度的泥潭。6.3 非均匀缩放时的法线方向扭曲物体Scale不是(1,1,1)时法线外扩的算法很容易翻车。因为法线在未归一化前会随缩放产生方向偏差外扩后的顶点不在正确的轮廓位置上导致描边粗细不一。我在代码里用UNITY_MATRIX_IT_MV处理了法线变换这能解决大部分非均匀缩放的旋转问题但如果你直接在Inspector里把Scale拉成(2, 1, 1)仍然可能出现描边在X方向特别粗或者在侧面消失的情况。最保险的做法是给需要描边的物体使用统一的均匀Scale或者把描边宽度设小一些避免视觉瑕疵被放大。6.4 流光带的方向和模型旋转物体空间Y轴扫描有一个天然特性模型旋转时流光带跟着模型跑不会漂移到世界空间去。如果策划要求流光带必须保持水平扫描不随模型旋转那就必须改用世界空间坐标来计算扫描带位置。在我给的Shader代码里用的是TransformObjectToWorld后的Y坐标其实已经具备跟随模型旋转但以世界Y为轴的特性。想改成完全不跟随模型旋转就直接用worldPos.y想彻底跟随模型几何就用ObjectSpace的Y轴。我做角色Buff效果时通常选世界Y轴但跟随模型位置这个中间态因为角色无论怎么转身光带始终大致水平流动视觉上最协调。7. 参数调优思路从默认值到能直接上线的手感最后一个环节是参数。好的Shader代码只是骨架手感全在数字里。下面这张表是我调试这个效果时整理出的常用范围和含义你可以直接抄走当基线再按项目风格微调。参数默认值常用范围作用与调节建议_OutlineWidth0.020.005~0.05模型越大建议相对缩小值过大会覆盖主体过小则看不见_OutlineColorA白亮色系描边呼吸的低端颜色_OutlineColorB橙互补色描边呼吸的高端颜色跟A要有明显色差才会活_ScanSpeed1.00.3~2.0太快会变成闪烁太慢会像静止高光条_ScanWidth0.20.05~0.5决定流光带宽度想要锐利就调小想要弥散就调大_ScanIntensity1.50.5~3.0流光亮度注意别盖过主体的材质表现_FlashSpeed1.00.2~2.0颜色插值循环速率控制呼吸快慢_FlashStrength1.00.0~1.0事件触发插值的强度入口C#侧可控调优的时候有一个很重要的原则描边和流光的速度不要设成一样的倍数。我在最终版本里刻意让FlashSpeed和ScanSpeed错开一个是1.0一个是1.7这样两个周期叠加起来画面每帧都不重复视觉丰富度立刻上一个档次。如果你把两个速度设成偶数倍就很容易出现拍频现象——光带和描边颜色在同一时刻周期性地撞在一起看起来像节奏卡顿。另外一个常用技巧是给ScanColor的插值套一层非对称速率。就是用不同正弦波频率分别驱动亮度和色相例如亮度用1.0倍频色相用1.7倍频两者不同步。这会让光带在移动过程中同时出现颜色渐变和亮度脉动不仅耐看也远比单纯一条白线流动更有能量感。我实际调试时习惯先把所有Intensity设为0纯看颜色过渡再把Intensity拉起来逐步加ScanWidth直到觉得光带柔和最后才调描边宽度。这样不容易被亮度误导颜色关系看得更准。最后一个实用提醒移动端的Shader复杂度控制这个Shader我一个Pass一个Pass拆开写对移动端来说已经算温和了。但如果你项目有大量同屏物体都挂了这套效果有个更省性能的变体建议把流光带叠加从逐元素加色改成仅采样一次主贴图并做亮度乘法然后在片元输出时保留一个统一的Compare参数让美术可以用材质面板统一控制全局强度而不是每个物体单独调。这样Shader编译出来的指令数更少驱动优化空间更大同屏上限也更高。如果你在做角色受击变身、武器附魔这类需要高频触发的效果建议把_FlashStrength的C#动画控制在0.2~1.0区间避免插值强度过高导致颜色过曝也别低于0.2否则几乎看不出变化。我自己常用的组合是金色描边蓝白流光FlashSpeed 1.2ScanSpeed 0.8三个参数错开后即使同时出现在同一个角色身上也不会互相抢戏。Shader这种东西代码只是骨架真正值钱的是参数和手感之间那点微妙关系。希望这套方案能帮你少走几步弯路把更多时间花在打磨视觉效果本身。