
1. 项目概述为什么我们需要一个“好”的描边Shader在Unity里做项目尤其是涉及到角色、场景交互或者需要突出视觉重点的时候描边效果几乎是绕不开的一环。你可能试过用Outline组件或者在网上找一段现成的Shader代码直接套用但结果往往不尽人意要么性能开销大移动端扛不住要么效果僵硬边缘锯齿严重在复杂模型上表现糟糕要么就是无法与场景光影、后处理完美融合看起来像个廉价的贴纸。这就是为什么我们需要深入理解并亲手实现一个“从原理到实战”的描边Shader。它不仅仅是为了画一个边而是要解决一系列工程问题如何高效地识别模型边缘如何在保证效果平滑的同时控制性能如何让描边具备艺术可控性比如实现渐变、发光、甚至动画网上现成的代码往往只解决了“有没有”的问题而一个成熟的方案需要解决“好不好”、“稳不稳”、“能不能定制”的问题。今天我就结合自己踩过的无数个坑从最基础的数学原理讲起带你一步步搭建一个高性能、高可控性的Unity描边Shader无论是用于角色高亮、场景交互提示还是实现特定的卡通渲染风格这套方案都能给你一个扎实的起点。2. 描边核心原理深度解析不止是法线外扩提到描边很多人的第一反应是“把模型沿着法线方向放大一圈再渲染”。这个思路没错但它只是众多实现方案中的一种而且有很明显的局限性。我们需要从更本质的图形学角度来理解“边缘”。2.1 边缘的三种定义与对应算法在计算机图形学中定义“边缘”通常有三种主流思路它们各有优劣直接决定了Shader的实现路径和最终效果。2.1.1 基于法线Normal-Based的外扩这是最直观、也是最常见的方法。其核心思想是在顶点着色器中将每个顶点沿着其法线Normal方向向外移动一段距离。这样一个被“吹胀”的模型就会包裹住原模型我们只渲染这个膨胀体的背面或特定部分就形成了描边。优点实现简单计算量相对较小与模型拓扑结构无关。缺点均匀性问题在模型表面曲率变化大的地方如硬边、尖角法线方向不连续会导致描边粗细不均甚至断裂。想象一下一个立方体每个面的法线方向是垂直向外的单纯外扩会在棱角处产生难看的接缝。背面剔除依赖通常需要两个Pass第一个Pass渲染放大的背面作为描边第二个Pass正常渲染模型正面。这增加了Draw Call。轮廓精度对于某些结构如凹陷部分法线外扩可能无法正确产生轮廓。2.1.2 基于屏幕空间深度/法线Screen-Space的后处理这种方法完全跳出模型本身。它在所有物体渲染完成后对最终的屏幕图像进行处理。通过对比相邻像素之间的深度Depth值或法线Normal值的差异来判断是否为边缘。优点效果统一无论场景中有多少个物体、什么形状都能获得风格一致的描边完美解决模型间交叉处的描边问题。与场景复杂度解耦描边的计算开销与屏幕分辨率相关而与场景中模型的面数无关。缺点性能开销大需要对全屏纹理进行多次采样和卷积计算如使用Sobel算子对带宽和填充率要求高。信息丢失依赖于G-Buffer深度、法线纹理如果场景中有透明物体或特殊的渲染路径可能需要额外处理。难以做艺术化调整对描边的颜色、宽度进行基于物体或材质的差异化控制比较困难。2.1.3 基于几何着色器Geometry Shader的轮廓边检测这是一种更加“底层”的方法。在几何着色器中我们可以访问一个三角面的所有信息。通过检测相邻三角面之间的法线夹角是否超过某个阈值来判断它们的公共边是否为“轮廓边”。如果是则动态生成一个沿着这条边挤出的四边形来绘制描边。优点理论上能产生最精确的、基于模型几何的轮廓线常用于技术绘图或风格化渲染。缺点平台支持与性能移动端对几何着色器支持不友好且动态生成几何体本身开销较大。实现复杂算法相对复杂需要处理大量的边界情况。实操心得对于绝大多数游戏项目尤其是移动端或需要兼顾性能与效果的场合基于法线外扩的改进方案是性价比最高的选择。我们接下来的实战也将以此为基础但会重点解决其“均匀性”和“灵活性”的痛点。2.2 法线外扩方案的数学优化解决均匀性问题单纯的法线外扩v.vertex.xyz v.normal * _OutlineWidth;问题在于它是在模型空间Object Space或切线空间进行的。当模型进行非均匀缩放Scale的x, y, z值不同时法线方向会变形导致描边严重失真。解决方案在观察空间View Space或裁剪空间Clip Space进行计算。这是关键一步。观察空间是以摄像机为原点的坐标系在此空间处理能有效规避模型自身缩放和旋转带来的影响。我们的优化外扩公式核心步骤如下将顶点变换到观察空间在顶点着色器中使用UnityObjectToViewPos将模型顶点从模型空间转换到观察空间。获取观察空间法线同样将法线从模型空间转换到观察空间。注意法线是方向向量需要用逆转置矩阵即UNITY_MATRIX_IT_MV来变换以保持正确方向。Unity提供了内置函数UnityObjectToWorldNormal可以先转到世界空间但为了精度我们常直接使用mul((float3x3)UNITY_MATRIX_IT_MV, v.normal)。在观察空间进行外扩viewPos normalize(viewNormal) * _OutlineWidth;。这里的normalize很重要确保外扩方向是纯法线方向不受模型缩放影响。将外扩后的顶点转换到裁剪空间并输出o.pos UnityViewToClipPos(viewPos);。// 顶点着色器核心代码示例 (CG/HLSL) v2f vert (appdata v) { v2f o; // 常规顶点变换 float4 pos UnityObjectToClipPos(v.vertex); // 计算观察空间法线 float3 viewNormal mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); viewNormal normalize(viewNormal); // 计算观察空间顶点位置 float3 viewPos UnityObjectToViewPos(v.vertex); // 在观察空间沿法线外扩 viewPos viewNormal * _OutlineWidth; // 转换回裁剪空间 o.pos UnityViewToClipPos(viewPos); // 传递其他数据如UV、颜色 o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; }通过这种方式描边的宽度在屏幕空间上看起来会更加均匀受模型变形的影响大大减小。3. 完整实战构建一个高性能、可定制的描边Shader理解了原理我们开始动手搭建。我们的目标是创建一个双Pass的Shader第一个PassOutLine渲染描边第二个PassBase渲染模型本身。我们将使用URPUniversal Render Pipeline作为示例因为这是Unity当前和未来的主流。3.1 工程准备与属性定义首先在Unity中创建一个新的Unlit Shader或基于URP的Lit Shader修改命名为CustomOutline。在Properties块中定义我们需要的参数这些参数将暴露在材质面板上Properties { // 基础颜色纹理 [MainTexture] _BaseMap(Albedo, 2D) white {} [MainColor] _BaseColor(Color, Color) (1, 1, 1, 1) // 描边属性 _OutlineColor(Outline Color, Color) (0, 0, 0, 1) _OutlineWidth(Outline Width, Range(0.0, 0.1)) 0.03 _OutlineSmoothness(Outline Smoothness, Range(0.0, 1.0)) 0.5 // 用于抗锯齿 [Toggle(USE_SCREEN_SPACE_WIDTH)] _UseScreenSpace(Use Screen Space Width, Float) 1 // 宽度是否随屏幕分辨率变化 }_OutlineWidth描边宽度。注意我们给了它一个很小的范围0-0.1因为在观察空间或裁剪空间很小的值就能产生明显的屏幕空间效果。_OutlineSmoothness这是一个重要的抗锯齿参数。纯色描边在边缘会有锯齿我们可以通过让边缘透明度渐变来平滑它。_UseScreenSpace一个开关。当开启时我们会让描边宽度考虑屏幕分辨率保证在不同分辨率下视觉粗细一致。这是专业Shder的细节。3.2 顶点着色器实现优化的观察空间外扩我们将编写两个顶点着色器函数一个用于描边Pass一个用于基础渲染Pass。描边Pass的顶点着色器 (vertOutline) 这里我们实现上面提到的优化方案。同时我们加入屏幕空间自适应的逻辑。Varyings vertOutline(Attributes input) { Varyings output (Varyings)0; // 获取模型空间法线 float3 normalWS TransformObjectToWorldNormal(input.normalOS); // 获取世界空间顶点位置 float3 positionWS TransformObjectToWorld(input.positionOS); // 计算观察空间法线更精确的方式 float3 viewNormal mul((float3x3)UNITY_MATRIX_V, normalWS); viewNormal normalize(viewNormal); // 计算观察空间顶点位置 float3 viewPos TransformWorldToView(positionWS); // 处理屏幕空间自适应宽度 float outlineWidth _OutlineWidth; #ifdef USE_SCREEN_SPACE_WIDTH // 将宽度乘以一个与屏幕高度相关的因子使在不同分辨率下视觉宽度近似 // _ScreenParams.y 是屏幕像素高度 outlineWidth * (_ScreenParams.y / 1080.0); // 以1080p为基准 #endif // 在观察空间进行外扩 viewPos viewNormal * outlineWidth; // 转换到裁剪空间和齐次裁剪空间 output.positionCS TransformViewToHClip(viewPos); output.positionWS positionWS; // 传递世界坐标可能用于后续计算 output.normalWS normalWS; output.uv TRANSFORM_TEX(input.uv, _BaseMap); return output; }关键点解析TransformObjectToWorldNormal和TransformWorldToView是URP内置函数比直接使用矩阵乘法更安全、易读。屏幕空间自适应 (USE_SCREEN_SPACE_WIDTH)这是一个非常实用的技巧。_ScreenParams是一个内置变量_ScreenParams.y代表屏幕像素高度。我们以1080p为基准进行缩放这样无论在4K屏幕还是720p屏幕上_OutlineWidth设置为0.03时视觉上的描边粗细是接近的。这避免了在高端设备上描边过细在低端设备上描边过粗的问题。3.3 片元着色器颜色、平滑与高级控制片元着色器负责计算每个像素最终的颜色。对于描边Pass我们通常只需要输出一个固定的颜色但我们可以做得更精致。基础描边片元着色器 (fragOutline)half4 fragOutline(Varyings input) : SV_Target { // 直接返回描边颜色 return _OutlineColor; }这已经能工作了。但边缘可能会有锯齿。我们可以利用fwidth函数和屏幕空间导数来做一个简单的软边抗锯齿。带抗锯齿的增强版片元着色器half4 fragOutlineSmooth(Varyings input) : SV_Target { // 计算屏幕空间深度或位置的导数用于边缘检测 float2 screenUv input.positionCS.xy / input.positionCS.w; // 使用fwidth计算屏幕空间的变化率 float edgeFactor fwidth(screenUv.x) * 500 * (1.0 - _OutlineSmoothness); // 调整系数 // 一个简单的边缘软化可选更复杂的可能需要基于深度差 // 这里我们直接返回颜色软化可以通过混合透明度实现但描边Pass通常关闭混合。 // 另一种思路在片元着色器里微调外扩距离这比较复杂。 // 对于抗锯齿更常见的做法是开启MSAA或多采样或者在后期处理中处理。 // 因此对于单Pass描边保持简单即可。抗锯齿主要依靠后处理或渲染设置。 return _OutlineColor; }注意事项在实际项目中对于这种几何体外扩的描边开启Unity的MSAA多重采样抗锯齿是解决锯齿最直接、最有效的方法性能代价也相对可接受。在片元着色器里做复杂的边缘平滑可能得不偿失。我们的_OutlineSmoothness参数可以作为一个预留接口或者用于另一种“渐变描边”效果即描边从边缘到内部颜色渐变。基础渲染Pass的片元着色器 这个就是常规的着色器可以简单显示纹理颜色也可以接入URP的PBR光照模型。half4 fragBase(Varyings input) : SV_Target { half2 uv input.uv; half4 texColor SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv) * _BaseColor; // 这里可以添加简单的兰伯特光照计算 float3 lightDir _MainLightPosition.xyz; float NdotL saturate(dot(input.normalWS, lightDir)); float3 diffuse texColor.rgb * NdotL * _MainLightColor.rgb; return half4(diffuse, texColor.a); }3.4 ShaderLab Pass配置与渲染状态这是控制渲染流程的关键写在SubShader块内。SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline QueueGeometry} // Pass 0: 描边Pass Pass { Name OutLine Tags { LightModeUniversalForward } // 仍使用前向渲染路径 Cull Front // 关键剔除正面只渲染背面即膨胀出来的部分 ZWrite On ZTest LEqual Blend One Zero // 通常不混合覆盖式渲染 // Blend SrcAlpha OneMinusSrcAlpha // 如果需要透明描边开启此混合模式 HLSLPROGRAM #pragma vertex vertOutline #pragma fragment fragOutline #pragma multi_compile _ USE_SCREEN_SPACE_WIDTH // 编译变体 #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl ENDHLSL } // Pass 1: 基础颜色Pass Pass { Name Base Tags { LightModeUniversalForward } Cull Back // 剔除背面渲染正面 ZWrite On ZTest LEqual HLSLPROGRAM #pragma vertex vertBase #pragma fragment fragBase #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl ENDHLSL } }核心渲染状态解析Cull Front描边Pass这是法线外扩法的精髓。因为我们把模型顶点沿法线外扩了这个“膨胀体”的正面面向摄像机的面实际上是原模型的背面。我们通过Cull Front剔除正面只渲染这个膨胀体的背面从而在原模型外围形成一层“壳”。这个壳就是我们的描边。Cull Back基础Pass正常渲染模型时我们剔除背面只渲染正面。ZWrite On和ZTest LEqual深度写入开启深度测试为“小于等于”。确保描边Pass先渲染到深度缓冲区基础Pass再渲染时位于描边内部的像素会因为深度测试失败而被丢弃避免了描边覆盖模型本身。Tags { LightModeUniversalForward }两个Pass都标记为前向渲染路径确保它们都能被URP的渲染器正确调用。#pragma multi_compile _ USE_SCREEN_SPACE_WIDTH这是一个编译指令它会根据材质面板上_UseScreenSpace开关的状态生成两个不同的Shader变体。这样开关功能才能真正生效而不会产生运行时分支的额外开销。4. 进阶技巧与常见问题深度排查一个基础的描边Shader搭建完成后我们会遇到各种实际项目中的挑战。下面分享一些进阶技巧和踩坑记录。4.1 性能优化减少Draw Call与Shader变体我们的双Pass方案意味着每个使用此材质的物体至少会增加1个Draw Call因为多了一个Pass。对于大量需要描边的物体如一群小兵这是不可接受的。优化方案1使用Command Buffer或Renderer Feature进行批量后处理描边这是针对大量物体的终极解决方案。思路是将所有需要描边的物体渲染到一张单独的深度/法线纹理中。在图像后处理阶段通过屏幕空间方法一次性对所有物体进行描边计算。优点Draw Call不随物体数量增加性能最优。缺点实现复杂需要处理物体间的遮挡关系艺术化控制个别物体的描边属性较难。优化方案2将描边作为模型的一个材质球利用GPU Instancing如果必须使用几何外扩法确保你的Shader支持GPU Instancing。在URP中需要包含正确的Instancing设置宏并在属性块中使用[PerRendererData]等标签。这样相同材质、相同网格的多个实例可以合并渲染大幅降低Draw Call。// 在Properties中颜色等每实例不同的属性可以这样声明 // [PerRendererData] _OutlineColor(Outline Color, Color) (0,0,0,1) // 在CGPROGRAM/HLSLPROGRAM上方添加 #pragma multi_compile_instancing #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/UnityInput.hlsl // 通常已包含 // 在顶点着色器输入结构体和函数中处理实例ID优化方案3严格控制Shader变体我们使用了#pragma multi_compile来支持屏幕空间宽度开关。每个变体都会增加包体大小和内存中的Shader数量。如果项目中有大量类似的特性开关如是否启用雾效、是否接收阴影等变体数量会呈指数级增长2^n。务必在项目后期使用Unity的Shader Variant Collection工具来收集和剥离未使用的变体。4.2 效果增强实现艺术化描边纯色描边有时显得呆板。我们可以通过一些技巧让描边更具艺术感。1. 渐变描边在片元着色器中根据像素在描边“壳”上的位置比如从模型中心到边缘的距离来混合颜色。这需要我们在顶点着色器中传递一些额外信息比如顶点的原始位置外扩前到外扩后位置的方向向量在片元着色器中计算一个衰减因子。2. 纹理化/扰动描边让描边颜色不再单一。我们可以采样一张噪声纹理根据UV或世界坐标来扰动_OutlineWidth或_OutlineColor实现手绘质感、水墨风格或者能量涌动的效果。// 片元着色器中 float2 noiseUV input.worldPos.xz * _NoiseScale _Time.y * _NoiseSpeed; float noise SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, noiseUV).r; float dynamicWidth _OutlineWidth * (1.0 noise * _NoiseStrength); // 但注意宽度是在顶点着色器计算的动态宽度需要在顶点着色器采样纹理这很昂贵。 // 更可行的方案是扰动颜色 half4 outlineColor _OutlineColor * (0.8 0.2 * noise);3. 基于深度的宽度衰减模拟一种透视感让距离摄像机越远的物体描边越细。这需要在顶点着色器中获取顶点在观察空间或世界空间的深度z值并用它来缩放_OutlineWidth。// 在顶点着色器中 float depth -viewPos.z; // 观察空间中摄像机前方为负取反得正值深度 float depthScale saturate(depth / _DepthFadeDistance); // 计算一个基于深度的缩放因子 outlineWidth * lerp(1.0, _DepthMinScale, depthScale); // 进行插值4.3 常见问题排查实录问题1描边在某些角度或特定模型上闪烁Z-Fighting现象描边和模型本体交替出现快速闪烁。原因描边Pass和基础Pass渲染的像素深度值过于接近由于深度缓冲的精度限制产生了深度冲突。解决方案轻微偏移在描边Pass的顶点着色器中在外扩后将裁剪空间坐标的.z分量稍微向摄像机方向偏移一点点减小确保描边永远在模型本体之前一点点被渲染。output.positionCS.z - _ZBias * output.positionCS.w; // _ZBias是一个很小的值如0.0001调整渲染队列确保描边材质的渲染队列 (Queue) 在模型材质之前。但我们的双Pass在同一个Shader中已经通过Pass顺序保证了描边先渲染。检查模型检查模型本身是否有重合的面或法线错误。问题2描边在硬边如立方体边缘断裂现象立方体的棱角处描边不连续有缺口。原因这是法线外扩法的固有缺陷。立方体每个面的法线方向不同外扩后相邻面在棱角处无法平滑连接。解决方案平滑法线Smooth Normals在3D建模软件中导出模型时使用平滑法线即使模型是硬边。这样顶点法线是相邻面法线的平均值外扩后会在棱角处产生平滑过渡的“圆角”描边。这可能会改变模型本身的渲染光影需要权衡。使用轮廓边检测高级如前所述改用几何着色器方案但这在移动端不现实。接受风格化对于低多边形Low Poly风格的游戏这种断裂有时可以被接受为艺术风格的一部分。问题3透明物体描边异常现象给一个半透明的模型如玻璃加描边描边也被渲染成半透明或者排序错误。原因透明渲染需要从后往前排序绘制而我们的描边Pass的深度写入和测试可能会干扰这个顺序。解决方案处理透明物体的描边非常棘手。通常需要将描边Pass的ZWrite设置为Off并可能调整ZTest。使用单独的渲染队列如QueueTransparent100并确保描边在模型之后渲染。可能需要将描边也渲染成透明混合并仔细处理颜色混合。这通常需要针对具体的透明效果进行特殊设计和调试没有通用解。问题4描边在移动设备上性能差现象在手机上帧率明显下降。原因额外的Pass带来了额外的顶点变换和片元着色开销特别是对于高面数模型。解决方案LOD层次细节为远处或次要的物体使用更简单的网格或者完全关闭描边。简化Shader关闭屏幕空间自适应、抗锯齿等高级特性。使用更简单的计算。减少使用范围只为关键角色或交互物体开启描边。终极方案如前所述考虑屏幕空间后处理方案其开销与物体数量无关。5. 从Shader到生产管线工程化实践在真实项目中写好一个Shader只是第一步。如何将它集成到美术工作流和性能预算中才是更大的挑战。5.1 制作可复用的Shader Graph如果你和团队更习惯可视化编辑可以将上述原理在Shader Graph中复现。核心节点包括Position节点获取顶点位置。Normal节点获取法线。Transform节点将法线转换到View空间。Add节点将外扩后的法线加到View空间位置上。使用Cull节点设置背面剔除。 你需要创建两个SubGraph或Master Stack分别对应描边和基础渲染然后通过Graph的Pass设置来控制。Shader Graph的优势是迭代快、易理解但实现某些底层优化如精确的屏幕空间宽度计算可能不如代码灵活。5.2 材质参数动画与脚本控制描边的颜色、宽度等参数通常需要根据游戏状态动态变化如角色被选中时描边变亮、受伤时变红。在C#脚本中你可以通过MaterialPropertyBlock来高效地修改单个渲染器的材质属性而不创建新的材质实例。MaterialPropertyBlock mpb new MaterialPropertyBlock(); renderer.GetPropertyBlock(mpb); mpb.SetColor(_OutlineColor, Color.red); mpb.SetFloat(_OutlineWidth, 0.05f); renderer.SetPropertyBlock(mpb);结合Unity的Animation System或Timeline可以驱动这些参数实现复杂的动画效果。5.3 性能分析与调试使用Frame Debugger逐帧查看每个Draw Call确认你的描边Pass是否正确渲染没有多余的渲染状态切换。使用Profiler在GPU Profiler中观察你的Shader在GPU上的执行时间。重点关注顶点着色器Vertex和片元着色器Fragment的耗时。如果片元着色器开销大检查是否有全屏性的复杂计算如不必要的纹理采样。检查Shader变体在Editor Log中搜索“Shader stripping”查看变体剥离情况。使用ShaderUtil.GetShaderGlobalKeywords等API仅在Editor下来检查运行时实际加载的变体数量。5.4 应对不同渲染管线本文示例基于URP。如果你在使用Built-in Render Pipeline内置渲染管线代码结构会有所不同例如顶点变换矩阵的名称、内置函数不同但核心原理完全一致。你需要将UnityObjectToClipPos替换为UnityObjectToClipPos(内置管线也有同名函数但注意空间)并手动处理观察空间变换。如果未来迁移到HDRPHDRP有更复杂的材质系统和光照模型可能需要将描边集成到它的Decal系统或自定义渲染通道中。描边效果虽小却串联起了从图形学原理、Shader编程、渲染管线到项目性能优化的完整知识链。我个人的体会是不要满足于“能用”的代码多问几个“为什么”和“怎么样更好”把每一个细节都琢磨透这个过程积累下来的经验远比实现一个特效本身更有价值。当你再遇到类似的需求时你脑子里浮现的将不再是一段模糊的代码而是一个清晰的、包含多种选项和权衡的解决方案图景。