Unity描边技术全解析:从法线到SDF的五种实现方案
1. 为什么描边不是“加一圈线”那么简单——从美术需求倒推技术本质在Unity或Cocos这类引擎里当策划说“这个角色需要描边”美术说“边缘要干净利落、不发虚、不闪烁”程序第一反应往往是不就是用片元着色器fragment shader里判断下法线朝向或者深度差然后涂个颜色吗我试过也这么干过。但很快就会发现同一套代码在角色正面看着挺精神转到侧面就断断续续像锯齿放大看边缘毛刺明显缩小时又糊成一片更别提遇到透明材质、粒子特效、后处理抗锯齿开启时描边直接消失或错位。这不是代码写错了而是我们一开始就把问题想窄了——描边从来不是“画一条线”而是“定义一个区域的边界”。这个“区域”是什么它可能是模型表面几何体的物理轮廓silhouette也可能是屏幕空间中像素值突变的视觉边界edge还可能是基于距离场预计算出的精确拓扑轮廓SDF contour。每种定义方式对应完全不同的数学基础、性能开销和视觉表现上限。比如用法线点乘视角向量判断轮廓原理是cosθ≈0时法线与视线垂直属于几何层面的silhouette detection它天然抗缩放但对低模拓扑极其敏感——一个面数不够的球体描边会呈多边形块状而用Sobel算子在屏幕空间对颜色做梯度检测本质是图像处理对模型拓扑无感但缩放时边缘粗细会随分辨率变化且无法区分“模型自身边缘”和“贴图纹理噪点”。关键词里反复出现的SDFSigned Distance Field有符号距离场正是解决这个矛盾的关键桥梁。它把复杂的三维几何边界压缩成一张二维纹理或一个数学函数每个采样点存储的是该点到最近模型表面的距离内部为负外部为正。有了这张“距离地图”描边就变成了“在距离为0.5像素的位置画一条带”无论模型怎么旋转缩放只要距离场精度够描边就稳如磐石。这也是为什么Magicavoxel、Shader Graph甚至CSS字体描边text-shadow本质是离散SDF采样都在用这套逻辑——它把“几何问题”转化成了“标量场采样问题”而后者在GPU上跑得飞快。所以当你看到标题“在shader中实现五种描边方法”别只盯着“五种”这个数字。真正要拆解的是这五种方法分别在哪个抽象层级工作它们各自妥协了什么又守护了什么是牺牲一点性能换绝对稳定还是放弃复杂模型支持只为移动端零负担下面我们就按这个思路一层层剥开每种方法的内核不只告诉你“怎么写”更要讲清“为什么必须这么写”。2. 方法一基于世界空间法线的Silhouette Outline——最经典却最易翻车的方案这是教科书里第一个登场的描边方案也是Unity Standard Shader里Outline Pass的底层逻辑。它的核心思想极简在顶点着色器vertex shader中把顶点法线变换到世界空间再与世界空间的视线方向做点积当点积值接近0即法线⊥视线时认为该顶点处于轮廓线上然后在片元着色器中根据这个值插值得到的轮廓强度向外“膨胀”一个固定像素宽度并填充颜色。2.1 顶点着色器里的关键计算为什么必须用世界空间很多人初学时会直接用模型空间法线和模型空间视线向量点积结果发现描边随模型旋转疯狂抖动。原因在于模型空间法线不随模型缩放而归一化假设你有一个沿X轴拉伸2倍的长方体其模型空间法线x分量会被放大点积结果就失真。而世界空间法线经过normalize(worldNormal)后长度恒为1与同样归一化的世界空间视线向量点积得到的cosθ才真实反映角度关系。这段代码看似简单却是整个方案稳定的基石// Unity HLSL 片段 struct v2f { float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD0; float3 worldViewDir : TEXCOORD1; }; v2f vert(appdata_base v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); // 关键法线和视线都转到世界空间并归一化 o.worldNormal normalize(mul(v.normal, (float3x3)unity_WorldToObject)); o.worldViewDir normalize(_WorldSpaceCameraPos - mul(unity_ObjectToWorld, v.vertex).xyz); return o; }提示unity_WorldToObject是世界到物体的逆矩阵用于将世界空间法线转回模型空间再变换——这是Unity官方推荐的法线变换方式比直接用unity_ObjectToWorld的逆转置更鲁棒尤其在非均匀缩放时。2.2 片元着色器中的轮廓强度计算从cosθ到描边宽度的映射点积得到的dot(worldNormal, worldViewDir)值域是[-1,1]轮廓线理论上在0处。但实际渲染中由于插值和浮点精度直接判断abs(dot) 0.01会导致描边过细且闪烁。工业级做法是引入一个“软阈值”soft thresholdfloat edgeFactor 1.0 - abs(dot(o.worldNormal, o.worldViewDir)); // 使用smoothstep实现平滑过渡避免硬边闪烁 float outlineWidth 0.05; // 世界空间宽度需根据模型尺寸调整 float edge smoothstep(0.0, outlineWidth, edgeFactor);这里smoothstep(a,b,t)函数在ta时返回0tb时返回1atb时返回三次插值完美模拟了“越靠近轮廓中心描边越浓”的自然衰减。但注意outlineWidth是世界空间单位若模型在场景中尺寸是2米0.05意味着描边宽5厘米——这在远距离时可能看不见近距离又太粗。因此真正的工程实践必须将宽度与相机距离挂钩float distanceFromCamera length(_WorldSpaceCameraPos - mul(unity_ObjectToWorld, v.vertex).xyz); float screenScale 1.0 / distanceFromCamera; // 距离越远系数越小 float adaptiveWidth outlineWidth * screenScale * _ScreenParams.x; // 乘以屏幕宽度归一化2.3 实战踩坑低模与硬边的致命组合我曾在一个Q版游戏中用此方案给低多边形机器人描边结果转到侧面时描边断裂成几段。排查发现低模的面数太少相邻顶点法线差异极大插值后edgeFactor在面上剧烈跳变smoothstep根本来不及平滑。解决方案只有两个一是给模型加“硬边”Hard Edge标记让建模软件导出时在锐利边缘处分裂顶点保证法线不插值二是改用“背面剔除背面偏移”方案见方法二彻底绕过法线插值问题。前者治本但需美术配合后者治标但立刻见效——这就是为什么老项目里常见“描边用两个Pass一个正向渲染一个背面加粗渲染”。3. 方法二背面剔除Z-Offset描边——最稳定、最省心的“暴力美学”当法线方案在复杂拓扑前频频失效工程师的本能反应是既然正面轮廓难算那就把背面“撑大一圈”让它从正面透出来形成天然描边。这就是背面剔除Backface CullingZ-Offset方案的核心逻辑。它不依赖任何法线计算纯粹靠GPU光栅化阶段的深度偏移Depth Offset和面剔除Face Culling机制因此稳定性极高几乎不受模型拓扑、缩放、旋转影响。3.1 深度偏移Z-Offset的物理意义不是“往前推”而是“往深推”很多新手误以为Offset 0, -1是把背面顶点往摄像机方向移动实则相反。Z-Offset的本质是修改顶点的gl_Position.z值在深度测试前人为加大其深度值即让背面“显得更靠后”从而在深度缓冲区Z-Buffer中背面像素的深度值比正面像素略大。当启用ZTest LEqual深度小于等于时通过时正面像素先写入Z-Buffer背面像素因深度更大被深度测试拒绝——除非我们给背面加一个负的Offset让它“看起来更近”。但Unity的Offset指令参数是(factor, units)其中units是固定偏移量factor与斜率相关。真正可靠的写法是直接在顶点着色器中手动偏移v2f vert(appdata_base v) { v2f o; float4 worldPos mul(unity_ObjectToWorld, v.vertex); float4 clipPos UnityObjectToClipPos(v.vertex); // 关键仅对背面顶点应用偏移 float3 worldNormal normalize(mul(v.normal, (float3x3)unity_WorldToObject)); float facing dot(worldNormal, normalize(_WorldSpaceCameraPos - worldPos.xyz)); if (facing 0.0) { // 是背面 clipPos.z 0.001; // 微调z值让背面在深度测试中“胜出” } o.pos clipPos; return o; }注意clipPos.z的微调必须极小0.001量级因为NDC空间z值范围是[-1,1]调太大会导致背面完全覆盖正面变成实心黑块。3.2 渲染顺序与Pass管理为什么必须两个Pass此方案必须拆成两个独立的SubShader Pass第一个Pass正常渲染正面Cull Front第二个Pass渲染加粗的背面Cull Back Offset。顺序不能颠倒否则背面会遮挡正面。在Unity中还需确保第二个Pass的ZWrite Off关闭深度写入否则背面会污染深度缓冲导致后续物体被错误裁剪Pass { Name Outline Cull Back // 只渲染背面 ZWrite Off // 关键避免破坏深度缓冲 Blend SrcAlpha OneMinusSrcAlpha CGPROGRAM #pragma vertex vert #pragma fragment frag // ... 着色器代码 ENDCG }3.3 性能与精度的平衡术偏移量如何动态计算固定偏移量如0.001在不同分辨率、不同远近下效果不一。实测发现在1080p下0.001偏移在远处描边过细近处又过粗。最优解是将偏移量与屏幕空间像素大小挂钩。Unity内置的_ProjectionParams.x是否翻转Z轴和_ScreenParams可帮我们估算// 在顶点着色器中计算屏幕空间偏移 float2 screenUV o.pos.xy / o.pos.w * 0.5 0.5; // 归一化到[0,1] float2 pixelSize 1.0 / _ScreenParams.xy; // 单个像素大小 float offsetScale 2.0; // 描边宽度为2像素 float4 clipPos UnityObjectToClipPos(v.vertex); clipPos.xy pixelSize * offsetScale * (clipPos.w * 0.5); // w补偿透视畸变这段代码的精妙在于clipPos.w是齐次坐标的w分量代表深度乘以它可抵消透视投影造成的XY坐标压缩确保偏移量在屏幕空间中是均匀的。我在线上项目中用此公式无论模型在画面中占10%还是90%描边粗细始终稳定在2像素这才是“所见即所得”的工程标准。4. 方法三屏幕空间Sobel边缘检测——纯图像处理的“后处理流派”前两种方法都依赖模型几何信息而Sobel方案彻底抛弃几何只看最终渲染出的屏幕图像。它把整个屏幕当作一张灰度图用Sobel算子一种3×3卷积核计算每个像素在X、Y方向的梯度强度梯度大即为边缘。这种方案最大的优势是它对任何渲染结果都有效——模型、UI、粒子、后处理特效全都能描边。游戏迷雾Fog of War的轮廓线、UI按钮悬停高亮都是它的典型应用场景。4.1 Sobel算子的数学本质为什么是这两个矩阵Sobel算子由两个卷积核组成Gx [-1 0 1] Gy [-1 -2 -1] [-2 0 2] [ 0 0 0] [-1 0 1] [1 2 1]Gx检测水平方向左右的亮度变化Gy检测垂直方向上下的变化。对屏幕纹理采样时取当前像素及周围8个像素的亮度值通常用luminance dot(color.rgb, float3(0.299, 0.587, 0.114))代入Gx、Gy计算得到梯度向量(Gx, Gy)其模长sqrt(Gx²Gy²)即为边缘强度。这段计算在片元着色器中只需10行代码但GPU并行执行效率极高float luminance(float3 color) { return dot(color, float3(0.299, 0.587, 0.114)); } float4 frag(v2f i) : SV_Target { float2 uv i.uv; float2 texelSize 1.0 / _ScreenParams.xy; // 采样3×3邻域 float3 c00 luminance(tex2D(_MainTex, uv float2(-1,-1)*texelSize).rgb); float3 c10 luminance(tex2D(_MainTex, uv float2( 0,-1)*texelSize).rgb); // ... 其他8个采样点 float gx -c00 c20 -2.0*c01 2.0*c21 -c02 c22; float gy -c00 -2.0*c10 -c20 c02 2.0*c12 c22; float edge sqrt(gx*gx gy*gy); return lerp(_Color, _OutlineColor, saturate(edge * _EdgeThreshold)); }4.2 抗锯齿与性能的终极妥协为什么必须降采样Sobel对高频噪声极度敏感。一张未抗锯齿的原始渲染图边缘锯齿本身就会被识别为“强边缘”导致描边毛刺。解决方案是先对输入纹理做一次双线性模糊Bilinear Blur再计算梯度。但模糊会增加采样次数影响性能。我们的实测数据如下RTX 30601080p方案采样次数GPU耗时ms边缘质量原图Sobel90.8毛刺严重3×3高斯模糊原图Sobel181.5边缘平滑但细节丢失半分辨率模糊Sobel9降采样后9模糊180.9质量接近全分辨率性能提升40%因此工业级实现必走“Render Texture降采样→模糊→升采样→Sobel”流水线。Unity的Graphics.Blit()可高效完成关键是降采样时用Bilinear滤波而非Point避免马赛克。4.3 迷雾系统Fog of War的实战集成如何只描边“可见区域”游戏迷雾常以一张黑白Mask纹理表示白色为已探索区域黑色为未知。Sobel方案可直接对此Mask纹理操作但问题在于迷雾边缘往往有羽化FeatheringSobel会把羽化过渡带也识别为边缘导致描边虚化。我们的解法是在生成迷雾Mask时额外输出一张“硬边Mask”——用step(0.5, mask)将羽化区域二值化再对此硬边Mask做Sobel。这样描边永远锐利而羽化效果保留在主Mask中用于视觉呈现。这个技巧被《文明VI》等策略游戏广泛采用是“功能分离设计”的典范。5. 方法四基于SDF的轮廓生成——精度与性能的“黄金分割点”当项目进入高品质阶段美术开始要求“描边必须像素级精准且支持动态缩放”SDFSigned Distance Field方案就成了不可替代的选择。它不实时计算几何而是预先将模型轮廓烘焙成一张距离场纹理Distance Field Texture运行时只需采样该纹理判断当前像素到轮廓的距离即可决定是否描边。Magicavoxel的像素艺术、Unity TextMeshPro的字体描边全是此原理。5.1 SDF纹理的生成原理从模型网格到距离场的三步转换生成一张可用的SDF纹理绝非一键操作。以Unity为例完整流程是网格体素化Voxelization将模型三角面片转换为三维体素网格Voxel Grid分辨率决定精度常用64³或128³距离场计算Distance Transform对每个体素计算其到最近三角面片的距离。算法用“扫描线法”Scanline或“并行泛洪”Parallel Flood Fill耗时最长切片与压缩Slice Quantize将3D体素切片为2D纹理如128×128的R通道存距离并量化为0-255的整数节省显存。关键点在于SDF的“符号”Sign决定了内外。若体素在模型内部距离存为负值外部为正值。这样abs(distance)给出到边界的距离sign(distance)可区分内外实现“内描边”或“外描边”。我们曾用自研工具烘焙一个中等复杂度角色64³体素耗时23秒128³则需142秒——这就是SDF的代价离线计算重运行时极轻。5.2 Shader中的SDF采样与描边一行代码定乾坤运行时Shader代码简洁到令人惊讶。核心就是sampleSDF函数它接收屏幕UV和SDF纹理返回归一化的距离值-1~1float sdf tex2D(_SDFTexture, uv).r; // 采样距离值已归一化 float edge smoothstep(_OutlineWidth - _OutlineSoftness, _OutlineWidth _OutlineSoftness, abs(sdf)); // abs(sdf)即到轮廓的距离 return lerp(_BaseColor, _OutlineColor, edge);这里_OutlineWidth是归一化后的描边宽度如0.02_OutlineSoftness控制边缘柔化程度。所有复杂度都被封装在SDF纹理中Shader只是优雅的消费者。我们在VR项目中用此方案即使用户把头盔凑到模型前10厘米描边依然锐利无锯齿这是其他方案无法企及的。5.3 动态SDF的可行性探讨实时生成的陷阱与曙光“能否实时生成SDF”这是常被问到的问题。答案是理论可行工程极难。实时体素化需每帧重建网格GPU压力巨大而基于GPU的并行距离变换如Jump Flooding Algorithm虽快但仅适用于2D图像如字体3D模型仍需CPU预计算。目前唯一实用的动态方案是用低分辨率SDF如32³做粗略描边配合屏幕空间法线方案做精细修正。我们在一个AR应用中验证过混合方案在骁龙888上帧率稳定在58FPS视觉质量损失小于5%——这是工程思维的胜利不追求绝对完美而求“足够好”的性价比。6. 方法五深度/法线纹理的组合边缘检测——为复杂材质定制的“外科手术刀”前述四种方法各有短板法线方案怕低模背面方案怕透明Sobel怕噪声SDF怕动态。当项目遇到半透明角色、玻璃材质、粒子特效叠加等复杂场景时必须祭出终极方案同时采样深度纹理Depth Texture和法线纹理Normal Texture用多维度信息交叉验证边缘。这就像医生做手术单一影像CT或MRI不够必须CTMRI超声三者比对。6.1 深度不连续Depth Discontinuity检测识别“物理边界”深度纹理记录每个像素的深度值Z值。若相邻像素深度差超过阈值如0.1说明此处存在物理遮挡即模型与背景、或模型不同部件间的交界。计算公式简单float depth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv); float depthRight SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv float2(texelSize.x, 0)); float depthDown SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv float2(0, texelSize.y)); float depthDiff max(abs(depth - depthRight), abs(depth - depthDown)); float depthEdge step(_DepthThreshold, depthDiff);但此方案会把“深度渐变区域”如雾效、景深模糊也识别为边缘。因此必须与法线纹理联立。6.2 法线不连续Normal Discontinuity检测识别“几何转折”法线纹理存储每个像素的表面法线。若相邻像素法线夹角大于阈值如cos30°0.866说明此处是模型自身的锐利转折如立方体的棱角。计算需先采样法线再点积float3 normal decodeNormal(SAMPLE_TEXTURE2D(_CameraNormalsTexture, uv)); float3 normalRight decodeNormal(SAMPLE_TEXTURE2D(_CameraNormalsTexture, uv float2(texelSize.x, 0))); float normalDot dot(normal, normalRight); float normalEdge step(_NormalThreshold, 1.0 - normalDot); // 1-dot即夹角余弦差6.3 多源融合策略权重分配的艺术最终边缘强度 depthEdge * depthWeight normalEdge * normalWeight (1 - depthWeight - normalWeight) * sobelEdge。权重并非固定而是根据当前像素的深度、法线长度动态调整深度值小近处→depthWeight提高因近处深度差更可信法线长度接近1表面平整→normalWeight提高两者都弱 → fallback到Sobel。我们在一个写实风格游戏中用此方案成功解决了“玻璃窗窗外风景窗框”的三层描边难题窗框用法线边缘玻璃用深度边缘窗外风景用Sobel三者无缝融合。这不再是“一种方法”而是“一套决策系统”。7. 五种方法的选型决策树你的项目该用哪一种面对五种方案程序员常陷入选择困难。我们总结了一张实战决策树基于三个核心维度项目阶段、性能预算、美术需求帮你5秒锁定最优解。决策维度选项推荐方案理由项目阶段原型验证1周内出效果方法二背面剔除代码最少10分钟可集成无需美术配合失败风险最低中期开发需兼顾多平台方法一法线Silhouette平衡性最好Unity社区资源丰富iOS/Android均可流畅运行商业发布品质要求严苛方法四SDF或方法五多源融合SDF提供像素级精度多源融合应对极端复杂场景是品质护城河性能预算移动端中低端机方法二背面剔除零额外采样GPU压力最小实测比Sobel快3倍PC/主机RTX 3060方法四SDF或方法五多源融合显存充足可加载高精度SDF多源融合的计算开销可承受VR/AR高刷新率方法一法线Silhouette避免后处理带来的延迟法线方案为单Pass管线最短美术需求“只要看得见就行”方法二背面剔除不挑模型不挑材质连UI都能描边“边缘必须锐利不许闪烁”方法四SDFSDF的数学稳定性是唯一能承诺“永不闪烁”的方案“要描边内发光阴影”方法五多源融合多纹理采样为后续效果预留接口架构可扩展性强注意没有“万能方案”。我们曾在一个AR教育App中对静态3D模型用SDF保证精度对动态手部追踪模型用法线方案保证实时性对UI层用背面剔除保证兼容性——混合使用才是工业级项目的常态。最后分享一个血泪教训某次上线前夜美术临时要求给所有角色加“霓虹描边”程序匆忙用Sobel方案替换原有法线方案结果在iOS设备上帧率暴跌20FPS。复盘发现Sobel的9次纹理采样触发了Metal的纹理缓存失效而法线方案仅2次采样。技术选型不是比谁“高级”而是比谁“懂你的约束”。下次当你打开Shader编辑器先问自己我的模型拓扑如何我的目标平台是什么我的美术最不能妥协的是什么答案自然浮现。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →