
1. 项目概述为什么我们需要轮廓外发光在游戏开发里想让一个物体在特定时刻“亮”起来吸引玩家注意或者单纯为了酷炫轮廓外发光Outline/Glow是个高频需求。想想看角色被选中时、敌人进入警戒状态时、可交互物品高亮时那个环绕物体的光边是不是瞬间就让交互反馈清晰了十倍这个效果用传统的3D模型或者粒子系统来做要么性能开销大要么效果不自然。而Shader作为直接操作GPU渲染管线的手段就成了实现这个效果最优雅、最高效的路径。我见过不少新手开发者一听到Shader就头疼觉得是图形程序员的专属领域。其实不然像轮廓外发光这种经典效果其核心思路非常清晰代码结构也相对固定。今天我就带你用5分钟的核心思路彻底搞懂它并附上能直接扔进项目里用的完整代码。更重要的是我会把那些官方文档里不会写、但实际开发中一定会踩的坑一个个给你点明。无论你是想快速实现一个功能还是想借此入门Shader编程这篇文章都能让你少走很多弯路。2. 核心原理拆解轮廓是怎么“画”出来的在深入代码之前我们必须先搞清楚Shader是怎么“看见”物体轮廓的。这不是魔法而是基于一个非常巧妙的几何观察轮廓线出现在模型表面法线方向与观察方向近乎垂直的地方。想象一下你用手电筒照一个球。正对着球心的地方最亮因为光线垂直射入法线与视线方向平行。而球的边缘光线几乎是擦着球面过去的法线与视线方向垂直那里就是轮廓。在Shader里我们通过计算顶点或片元的法线方向与视线方向的点积Dot Product来量化这个“垂直程度”。点积结果越接近0说明越接近轮廓边缘。但是单纯依赖这个“法线-视线”方法在复杂模型上效果不稳定且无法控制发光宽度。因此工业级方案普遍采用更稳定、可控的“双Pass渲染”技术第一个Pass轮廓Pass将模型沿着顶点法线方向“膨胀”一定距离渲染成一个纯色比如你想要的发光颜色如红色、蓝色。这个膨胀后的模型就像一个比原模型大一圈的“壳”。第二个Pass正常Pass正常渲染原本的模型覆盖掉“壳”的内部只留下边缘那一圈从而形成发光轮廓。这个“双Pass”法是实现轮廓外发光的基石它不依赖于复杂的后处理性能可控效果稳定。接下来我们就基于这个核心思路一步步构建Shader。2.1 关键参数膨胀、颜色与深度在动手写代码前先明确三个核心控制参数它们直接决定了最终效果_OutlineWidth轮廓宽度控制模型沿法线膨胀的距离。单位通常与世界空间或观察空间相关值一般在0到0.1之间。太小看不见太大会穿帮。_OutlineColor轮廓颜色发光的颜色。通常使用RGBA格式Alpha通道可以控制透明度实现半透明发光。ZTest与ZWrite深度测试与写入这是避坑的关键轮廓Pass的深度测试通常设置为Less当轮廓比背景物体更近时显示并且不写入深度ZWrite Off以防止轮廓遮挡住后面正常渲染的模型本身。而正常Pass则使用默认的深度处理。3. 完整代码实现与逐行解析下面是一个基于Unity内置渲染管线Built-in RP的完整Shader代码。我将它分为轮廓Pass和正常Pass两部分并加上详细注释。Shader Custom/Outline { Properties { // 定义在材质面板中可调节的属性 _MainTex (主纹理, 2D) white {} _Color (主颜色, Color) (1,1,1,1) _OutlineWidth (轮廓宽度, Range(0, 0.1)) 0.03 _OutlineColor (轮廓颜色, Color) (1, 0, 0, 1) // 默认红色 } SubShader { Tags { RenderTypeOpaque } // 标签表示这是一个不透明物体着色器 // --- 第一个Pass渲染轮廓 --- Pass { Name OUTLINE Cull Front // 剔除正面只渲染背面。这是关键让膨胀的“壳”只在外围显示。 ZWrite Off // 关闭深度写入避免轮廓写入深度缓冲影响后续Pass // 深度测试设置为 Less确保轮廓在它比背景物体近时才渲染 // 但通常为了轮廓始终可见我们会用 LEqual 或直接关闭深度测试Always // 这里使用一个更常见的组合关闭深度写入并用 Always 确保轮廓绘制 ZTest Always CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; // 我们需要法线来计算膨胀方向 }; struct v2f { float4 pos : SV_POSITION; }; float _OutlineWidth; float4 _OutlineColor; v2f vert (appdata v) { v2f o; // 将顶点和法线变换到观察空间相机空间 float4 viewPos mul(UNITY_MATRIX_MV, v.vertex); float3 viewNormal mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); // 将法线归一化并沿其方向在观察空间中进行膨胀 viewNormal normalize(viewNormal); viewPos.xyz viewNormal * _OutlineWidth; // 将膨胀后的观察空间坐标变换到裁剪空间输出给片元着色器 o.pos mul(UNITY_MATRIX_P, viewPos); return o; } fixed4 frag (v2f i) : SV_Target { // 片元着色器非常简单直接返回轮廓颜色 return _OutlineColor; } ENDCG } // --- 第二个Pass正常渲染模型本身 --- Pass { Name MAIN Cull Back // 剔除背面渲染正面与轮廓Pass相反 CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; float4 _Color; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); // 标准顶点变换 o.uv TRANSFORM_TEX(v.uv, _MainTex); // 处理纹理偏移和缩放 return o; } fixed4 frag (v2f i) : SV_Target { // 采样主纹理并乘以颜色 fixed4 col tex2D(_MainTex, i.uv) * _Color; return col; } ENDCG } } FallBack Diffuse // 备选着色器 }代码核心要点解析Cull Front/Back剔除正面/背面这是实现“壳”效果的精髓。轮廓Pass中Cull Front意味着我们丢弃模型正面从相机看过去的那一面只渲染背面。当模型沿法线膨胀后其背面就构成了我们看到的轮廓。正常Pass则用Cull Back渲染正面覆盖内部。观察空间膨胀我们在顶点着色器中将顶点和法线变换到观察空间UNITY_MATRIX_MV再进行膨胀。这样做的好处是轮廓宽度_OutlineWidth在屏幕上的表现是相对均匀的不受模型距离相机远近的剧烈影响。如果在模型空间或世界空间膨胀远处的物体轮廓会细得看不见。法线变换矩阵注意法线的变换使用的是逆转置矩阵UNITY_MATRIX_IT_MV。这是因为法线是方向向量如果和顶点一样用普通模型-观察矩阵变换当模型存在非均匀缩放时法线方向会错误。IT就是Inverse Transpose的缩写。深度处理ZWrite Off, ZTest Always轮廓Pass关闭深度写入并设置深度测试为始终通过。这是一个常见的“技巧”目的是让轮廓无论如何都能被画出来避免被场景中其他物体不恰当地遮挡。但这也带来了新的问题我们会在“避坑指南”里详细讨论。注意这段代码是一个清晰的教学版本。在实际项目中你可能需要根据渲染队列、是否接受阴影等需求调整Tags和Pass的设置。4. 避坑指南从理论到实战的血泪教训代码能跑起来只是第一步让它能在各种复杂场景下稳定、好看地工作才是真正的挑战。下面是我在实际项目中总结的几个关键坑点及其解决方案。4.1 轮廓断裂与接缝问题问题现象在模型的硬边特别是低模的硬边处轮廓光会出现难看的断裂或缝隙。问题根源顶点法线不连续。在低多边形模型中一个顶点通常被多个三角面共享但每个面都有自己的面法线。默认情况下该顶点只有一个法线通常是相邻面法线的平均值当沿这个“平均法线”膨胀时在硬边处就会产生分裂。解决方案导入模型时处理在3D建模软件中导出模型时确保勾选“平滑组”或“导出顶点法线”相关选项。在Unity的模型导入设置中可以尝试开启“平滑组”Smoothing Groups或调整“法线计算模式”。Shader层面解决推荐使用更健壮的轮廓计算方法。上述基础方法在观察空间膨胀对硬边模型不友好。可以改用在裁剪空间Clip Space膨胀的方法。原理是在顶点着色器输出最终的裁剪空间坐标后直接向屏幕的XY方向扩张而不是沿法线方向。裁剪空间膨胀顶点着色器代码片段v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); // 先得到标准裁剪坐标 float3 viewNormal mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); // 将法线变换到裁剪空间更准确说是NDC前的空间 float3 clipNormal mul((float3x3)UNITY_MATRIX_P, viewNormal); // 直接在裁剪坐标的xy平面上进行扩张忽略z方向 o.pos.xy normalize(clipNormal.xy) * _OutlineWidth * o.pos.w; // 乘以pos.w进行齐次除法补偿 return o; }这种方法生成的轮廓始终在屏幕空间均匀扩展能极大缓解硬边导致的断裂问题。参数o.pos.w是齐次坐标的w分量乘以它是为了确保轮廓宽度在不同深度下表现一致。4.2 轮廓被自身或其他物体错误遮挡问题现象轮廓时有时无或者被模型自己的某一部分挡住。问题根源深度测试ZTest和深度写入ZWrite设置不当。我们之前的代码设置了ZTest Always和ZWrite Off这虽然保证了轮廓总能被绘制但也意味着它完全无视深度关系可能画在它本不该出现的地方比如被墙挡住的物体轮廓却透墙而出。解决方案需要更精细的深度策略。一个更优的方案是轮廓PassZWrite Off但ZTest设置为LEqual小于等于。这意味着轮廓只会在其深度值小于等于深度缓冲区中已有值时才会被绘制。但因为我们关闭了深度写入轮廓的绘制不会影响后续物体的深度判断。配合渲染队列Queue将整个SubShader的渲染队列设置为Geometry1即比不透明物体晚一点渲染。这样轮廓会在所有标准不透明物体画完之后再画避免了与场景中其他物体的深度冲突同时又能基于它们产生的深度缓冲进行正确的LEqual测试。SubShader { Tags { QueueGeometry1 RenderTypeOpaque } // ... Passes ... }这个组合拳下来轮廓既能相对于背景物体正确显示/隐藏又不会错误地遮挡模型自身。4.3 性能考量与多模型叠加问题现象场景中大量物体使用轮廓Shader帧率下降明显或者多个发光物体叠在一起时轮廓交叉处颜色加深、效果混乱。性能优化减少Overdraw轮廓Pass会绘制整个膨胀后的模型表面即使大部分最终会被正常Pass覆盖。对于复杂模型这依然是额外的开销。务必在项目后期进行性能剖析Profiler如果成为瓶颈考虑是否能用后处理如基于法线/深度的屏幕空间效果替代或者仅在需要时如被选中启用该Shader。使用简单的LOD对于远处的物体可以减小_OutlineWidth甚至完全关闭轮廓效果。叠加处理当多个发光物体重叠时它们的轮廓Pass会相互混合Blending导致交叉区域颜色叠加变深。如果这不是你想要的效果通常不是可以在轮廓Pass的片元着色器中使用clip函数或深度测试来尝试丢弃被遮挡的部分但这实现起来较复杂。更实用的方法是从美术设计上避免发光物体大量紧密重叠或者接受这种叠加作为一种风格。4.4 适配URP/HDRP渲染管线问题上述代码是针对Unity内置渲染管线Built-in RP的。如果你使用的是URP通用渲染管线或HDRP高清渲染管线Shader的写法有根本不同。URP适配核心改动Shader头与库使用HLSLPROGRAM代替CGPROGRAM包含Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl。顶点变换使用TransformObjectToHClip代替UnityObjectToClipPos。渲染状态URP使用RenderState块CullZWriteZTest和HLSLINCLUDE来共享代码。轮廓效果通常通过编写一个RenderObjects渲染器特性Renderer Feature来实现这比修改物体材质更灵活可以统一管理所有需要轮廓的物体。Shader Graph对于不习惯写代码的开发者URP的Shader Graph可以通过“Position”节点偏移和“背面剔除”策略来模拟类似效果但灵活性和性能控制不如手写Shader。实操心得在项目初期就确定渲染管线。如果中途从Built-in迁移到URP所有自定义Shader几乎都要重写工作量巨大。对于轮廓效果这种全屏后处理也能实现的功能在URP中优先考虑用Renderer Feature实现屏幕空间方案它不受物体数量影响性能更可预测。5. 效果增强与扩展思路基础轮廓跑通后你可以考虑以下增强效果让发光更出彩渐变轮廓在轮廓Pass的片元着色器中根据片元在轮廓“壳”上的位置比如通过顶点到膨胀中心的距离来混合颜色实现从边缘到内部的渐变效果。噪声扰动采样一张噪声纹理用其值扰动_OutlineWidth或轮廓颜色可以做出火焰燃烧、电弧闪烁风格的动态轮廓。基于深度的宽度控制让离相机越远的物体轮廓越细增强场景的透视感。可以在顶点着色器中根据观察空间的深度viewPos.z来动态调整膨胀距离。交互式轮廓将轮廓宽度或颜色与游戏逻辑绑定。例如敌人血量越低轮廓红色越深、闪烁越快可拾取物品根据玩家距离远近改变轮廓强度。实现这些扩展关键在于理解数据流在顶点着色器中你能拿到模型顶点、法线信息可以计算空间位置在片元着色器中你能拿到屏幕坐标、深度可以采样纹理。结合时间_Time.y等Unity内置变量就能创造出丰富的动态效果。最后调试Shader离不开Frame Debugger和Shader Inspector。用Frame Debugger可以一步步查看每个Draw Call的结果清晰看到轮廓Pass和正常Pass分别画了什么。在Shader Inspector里实时调节属性值是找到最佳视觉参数的必备过程。别怕试错多调参数多观察你对Shader的理解就会在这些实践中飞速增长。