Unity 3D像素风角色轮廓线方案:从Inverted Hull到后处理描边

发布时间:2026/10/7 23:20:00
Unity 3D像素风角色轮廓线方案:从Inverted Hull到后处理描边 做3D像素风格游戏的时候最容易被忽略但最能定调的一个细节就是“轮廓线”。刚开始我做了一批低多边形角色模型颜色和比例都没问题可放进场景里总觉得糊成一团尤其在远处看脸、手、道具的边界全部黏在一起。后来补上了轮廓线视觉辨识度立刻上来了。本文就把这套从原理到实战的3D像素艺术轮廓线方案完整拆一遍包含可复制的 Unity Shader、后处理脚本和美术资源注意事项方便大家直接用到自己的像素风项目里。1. 轮廓线在 3D 像素艺术里到底是什么1.1 从 2D 像素画的“勾线”说起玩过像素游戏的朋友应该对《Metal Slug》《Celeste》这类作品的勾线不陌生。2D 像素画里的角色通常会先用深色描边轮廓再填充内部色块这样即使分辨率很低玩家也能一眼认出角色边界。轮廓线在低分辨率画面里承担着“信息分隔”的作用它告诉眼睛这里是头部那里是手背景和角色从这条线开始分割。3D 像素艺术其实面临同样的问题。虽然场景是三维的但最终输出到屏幕上时如果走的是低分辨率、块状像素感渲染模型边缘会变得非常“脏”。尤其当多个模型的漫反射颜色接近时轮廓线几乎不可见。所以 3D 像素艺术里的轮廓线并不是美术人的偏好而是低分辨率渲染下保证可读性的必要手段。1.2 模型轮廓线与内部硬边线在 3D 渲染里轮廓线通常分两类第一类是模型轮廓线也就是模型在最外层的剪影边缘。一个球体在屏幕上只有一个外圆轮廓这条线对应的是模型的 silhouette。第二类是内部硬边线比如机器人装甲上的拼接缝、方块体素的棱线这些线不会出现在剪影上只会出现在内部相邻面的转折处。对于 3D 像素艺术来说内部硬边线尤其重要。体素风格本身就是方块棱线组成的世界如果所有棱线都不画出来画面就成了大色块拼图。所以只做单纯的模型外轮廓还不够还需要考虑内部细节轮廓。1.3 轮廓线能带来什么具体收益从之前的项目经验来看轮廓线带来的收益非常直接角色与环境分离度更高小尺寸下依然可辨识。风格统一哪怕是不同美术师做的模型统一线宽和线色后视觉一致性会明显提高。氛围表达更方便黑色线是卡通感亮色线是赛博感棕色线则有手绘绘本感。遮挡关系更清楚前后物体如果轮廓线清晰深度感会显著增强。当然了也不是所有像素风格都要重描边像《我的世界》默认就是无描边的体素风格它靠方块网格和高对比材质制造辨识度。所以我们要先明确一点轮廓线是一种风格化选择而不是渲染必选项。它适合的是“像素画质感更强”“角色更突出”的那类 3D 游戏。2. 环境准备与素材设置2.1 运行环境与版本建议本文的演示以 Unity 的 Built-in Render Pipeline内置渲染管线为例。之所以用内置管线是因为 OnRenderImage 后处理接口在内置管线里依然可用写起来最直观适合理解原理。URP 使用环境虽然更现代但后处理流程已经改为 Volume 体系代码会分散到 RendererFeature 中对新手理解核心算法会有干扰。版本方面不需要刻意追新Unity 2021 LTS 或 Unity 2022 LTS 都可以跑通本文代码。如果你的项目已经在 URP 或 HDRP 里建议先积累内置管线的算法知识再迁移到 ScriptableRendererFeature思路完全一样只是挂载方式不同。2.2 3D 模型的前置规范轮廓线效果对模型本身有一定依赖不是随便拿一个高模就能出好效果。根据制作经验要注意下面几点模型最好使用低多边形体素风格或低模风格面数可控。用于 Inverted Hull 描边的模型法线需要连续不能出现过锐的硬边否则背面扩展后会断裂。如果一定要保留硬边可以选择在后处理阶段做边缘检测而不是用 Inverted Hull。模型导入 Unity 时检查 Read/Write如果要在运行时动态修改网格需要开启一般情况下可以关闭。如果模型有多个材质球建议合并贴图或使用 Submesh避免后处理阶段多个 drawcall 造成额外排序问题。2.3 像素风渲染基础设置3D 像素艺术的轮廓线不能脱离像素化环境单独讨论。如果我们的画面还是全分辨率平滑抗锯齿输出那么再好的轮廓线也会失去像素感。所以通常需要配合下面这些设置摄像机开启后处理将画面渲染到低分辨率 RenderTexture再点采样放大。材质纹理使用 Point 采样最近邻插值关闭 MipMap 或限制 MipMap 层级避免放大时发糊。关闭 MSAA 或者至少保证后处理阶段不被抗锯齿干扰因为像素风格本身就不追求平滑边缘。相机投影可以先使用透视投影保证立体感如果追求体素游戏常见的正投影感也可以用正交投影这点按项目风格决定。纹理采样参数对最终效果的影响很大。下面是一个常用的纹理导入设置建议Texture Type: Default Generate Mip Maps: On如果像素风格建议 Off Filter Mode: Point (no bilinear) Wrap Mode: Clamp Compression: None / High Quality按平台定3. 轮廓线的核心实现思路拆解3.1 Inverted Hull 反向轮廓体Inverted Hull 是 3D 风格化渲染里最常见的一种描边方案。它的思路是在渲染正常模型之外多渲染一次模型背面并把背面顶点沿着法线方向向外扩一点然后填充成一个纯色轮廓。听起来有点绕实际操作是这样正常渲染模型本身使用美术颜色。再渲染一次模型但开启 Cull Front也就是剔除正面只画背面。在顶点着色器里把背面顶点沿着法线方向向外偏移。背面偏移后形成的区域就变成了包裹在模型外面的一圈轮廓。为什么要剔除正面因为如果不剔除背面和正面重叠会把整个模型盖住。剔除正面后只有模型背面的外扩区域可见正好形成一条围绕模型外轮廓的描边。Inverted Hull 的优点是不需要额外的深度信息Shader 结构简单性能开销小缺点是它只能生成整条外轮廓无法单独控制内部硬边而且偏移量是均匀的模型非均匀缩放时线宽可能会出问题。3.2 深度边缘检测后处理如果要给场景中所有物体统一描边更合适的是后处理边缘检测。它不修改模型本身而是通过分析画面里的深度信息找出深度发生突变的位置把这些位置涂成轮廓线颜色。常见做法是 Sobel 算子。Sobel 算子会计算图像横纵两个方向的梯度差值如果像素与周围像素深度差距明显就认为这里是边缘。简单解释一个角色站在背景前角色的深度与背景深度不同深度图上就会形成一圈剧烈变化的边界边缘检测算法把这一圈检测出来再用指定颜色替换。深度边缘检测的好处是它对整个场景统一生效轮廓线不会因为模型法线不连续而断裂。缺点是阈值不好调阈值太大轮廓线内部会出现裂痕阈值太小场景噪声也会变成线条。所以实际项目通常会结合深度和法线两种信息深度用于捕捉不同物体之间的边界法线用于捕捉物体内部的棱线。3.3 颜色边缘检测还有一种思路是直接基于颜色差异做边缘检测。这种方案不需要深度纹理只要输入一张低分辨率画面就能在颜色变化明显的地方生成轮廓线。优点是简单适合纯 2D 风格画面缺点是当角色和背景颜色接近时边缘完全检测不出来而且阴影、渐变也会被识别成线条控制难度比较大。在实际项目里颜色边缘更适合做“辅助线”而不是主力轮廓线。主力轮廓线还是建议使用深度法线结合的方式颜色差异可以作为补充用来捕捉深度和法线都捕捉不到的边缘。3.4 三种方案的选型对比方案实现难度性能优点缺点Inverted Hull低低局部模型可控性强Shader 简洁只适合连续法线模型内部棱线无法描深度边缘检测中中全局统一不被颜色干扰阈值难控制深度精度不足时容易闪烁颜色边缘检测低中最简单不依赖相机深度对颜色接近区域无效阴影噪声干扰大如果项目里既有需要外轮廓的角色又有需要内部硬边的场景道具那么工程上最常见的组合是角色用 Inverted Hull 做外轮廓后处理用深度法线做全场景边缘检测二者叠加效果互补。4. 完整实战Unity 中为 3D 像素角色添加轮廓线4.1 项目文件结构本文的示例包含两个 Shader 和一个 C# 脚本。所有文件可以从头新建Assets/ ├── Shaders/ │ ├── PixelArt_InvertedHull.shader │ └── PixelArt_ScreenOutline.shader ├── Scripts/ │ └── PixelOutlineEffect.cs └── Materials/ └── Mat_Character.mat如果项目有用到后处理还需要在场景主摄像机上挂载PixelOutlineEffect脚本。4.2 编写 Inverted Hull 轮廓 Shader先来实现第一个方案Inverted Hull。下面的 Shader 适合挂在角色模型上让角色在保留自身颜色的同时向外扩出一圈黑色描边。Shader PixelArt/InvertedHull { Properties { _MainTex (Main Texture, 2D) white {} _Color (Main Color, Color) (1,1,1,1) _OutlineColor (Outline Color, Color) (0,0,0,1) _OutlineWidth (Outline Width, Float) 0.04 } SubShader { Tags { Queue Transparent RenderType Transparent IgnoreProjector True } Pass { Name OUTLINE Cull Front ZWrite Off Blend SrcAlpha OneMinusSrcAlpha CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; }; sampler2D _MainTex; float4 _Color; float4 _OutlineColor; float _OutlineWidth; v2f vert(appdata v) { v2f o; // 将顶点转换到世界空间 float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; float3 worldNormal UnityObjectToWorldNormal(v.normal); // 沿法线方向外扩 worldPos worldNormal * _OutlineWidth; o.pos mul(UNITY_MATRIX_VP, float4(worldPos, 1.0)); return o; } fixed4 frag(v2f i) : SV_Target { return _OutlineColor; } ENDCG } Pass { Name BASE Cull Back ZWrite On 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 _Color; v2f vert(appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag(v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * _Color; return col; } ENDCG } } }代码里有两个 Pass。第一个 Pass 渲染轮廓第二个 Pass 渲染模型本体。材质球上可以分别调整_OutlineColor和_OutlineWidth比如游戏里选中敌人时可以把轮廓线调成红色视觉反馈非常直接。需要注意Inverted Hull 对非均匀缩放非常敏感。如果模型在 x 轴拉长了 2 倍但 y 轴没有拉长法线外扩后的轮廓线会变得一边粗一边细。遇到这种情况要么在导入时统一模型缩放要么改用屏幕空间裁剪坐标偏移。4.3 编写基于深度的后处理轮廓 Shader接下来是后处理方案。这个 Shader 检测深度变化并把变化剧烈的地方替换为轮廓颜色。这样全场景都能得到描边尤其适合需要保留内部棱线的环境道具。Shader PixelArt/ScreenOutline { Properties { _MainTex (Source Texture, 2D) white {} _OutlineColor (Outline Color, Color) (0,0,0,1) _Thickness (Line Thickness, Range(1, 8)) 2 _DepthThreshold (Depth Threshold, Range(0, 1)) 0.1 } SubShader { Tags { RenderType Opaque } Cull Off ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; sampler2D _CameraDepthTexture; float4 _MainTex_TexelSize; float4 _OutlineColor; float _Thickness; float _DepthThreshold; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; }; v2f vert(appdata_base v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.texcoord; return o; } float GetDepth(float2 uv) { float depth UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, uv)); return LinearEyeDepth(depth); } fixed4 frag(v2f i) : SV_Target { float2 uv i.uv; float centerDepth GetDepth(uv); float h _Thickness * _MainTex_TexelSize.x; float v _Thickness * _MainTex_TexelSize.y; // Sobel 模板 float tl GetDepth(uv float2(-h, v)); float t GetDepth(uv float2( 0, v)); float tr GetDepth(uv float2( h, v)); float l GetDepth(uv float2(-h, 0)); float r GetDepth(uv float2( h, 0)); float bl GetDepth(uv float2(-h, -v)); float b GetDepth(uv float2( 0, -v)); float br GetDepth(uv float2( h, -v)); // 横向梯度 float dX (tr 2.0 * r br) - (tl 2.0 * l bl); // 纵向梯度 float dY (bl 2.0 * b br) - (tl 2.0 * t tr); float sobel sqrt(dX * dX dY * dY); float isEdge step(_DepthThreshold, sobel); float4 sourceColor tex2D(_MainTex, uv); float4 color lerp(sourceColor, _OutlineColor, isEdge); return color; } ENDCG } } }代码里的_Thickness是 Sobel 采样的范围数值越大参与边缘判断的像素越多线条越粗。_DepthThreshold则是边缘灵敏度数值越小检测越敏感但也更容易把模型褶皱、阴影杂物识别为边缘。需要特别提醒这个 Shader 依赖_CameraDepthTexture但_CameraDepthTexture不会自动出现在所有渲染环境下。必须在 C# 脚本的OnEnable里设置相机深度模式。4.4 编写相机后处理脚本下面是挂载在主摄像机上的脚本。它会先把画面渲染到低分辨率 RenderTexture再调用上面的后处理 Shader最终输出到屏幕。低分辨率这一步是保证“像素艺术感”的关键如果不做低分辨率化Sobel 边缘线会是非常细且平滑的直线完全不像像素画的粗描边。using UnityEngine; [RequireComponent(typeof(Camera))] public class PixelOutlineEffect : MonoBehaviour { [Range(2, 16)] public int pixelDownscale 4; public Material outlineMaterial; private Camera _camera; void OnEnable() { _camera GetComponentCamera(); // 告诉相机我们需要生成深度纹理 _camera.depthTextureMode | DepthTextureMode.Depth; } void OnRenderImage(RenderTexture source, RenderTexture destination) { if (outlineMaterial null) { Graphics.Blit(source, destination); return; } int rtW Mathf.Max(64, source.width / pixelDownscale); int rtH Mathf.Max(64, source.height / pixelDownscale); // 创建低分辨率目标纹理 RenderTexture lowRes RenderTexture.GetTemporary(rtW, rtH, 16, RenderTextureFormat.ARGB32); lowRes.filterMode FilterMode.Point; // 先把场景整体缩小到 lowRes Graphics.Blit(source, lowRes); // 再对 lowRes 执行后处理描边结果输出到屏幕 outlineMaterial.SetTexture(_MainTex, lowRes); Graphics.Blit(lowRes, destination, outlineMaterial); RenderTexture.ReleaseTemporary(lowRes); } }在 Inspector 上把outlineMaterial指向PixelArt/ScreenOutline材质即可。pixelDownscale4表示把屏幕宽高各缩小到四分之一。如果游戏本来屏幕就是 1920x1080那么实际描边检测画面是 480x270再放大回全屏像素块感就出来了。在实际项目里这个脚本可以扩展出很多玩法。比如在OnRenderImage里同时采样多张低分辨率 RT把描边材质改成可以修改线宽、线色、让描边随时间闪烁或者让玩家被敌人击中时出现红色描边反馈。4.5 运行验证与结果检查把脚本挂到相机上配置好材质后运行游戏预期效果是这样所有物体的深度边缘出现统一黑色轮廓线。角色模型如果挂载了 Inverted Hull 材质外轮廓会额外增加一圈更明显的线条。画面整体呈像素块状线条边缘没有过度抗锯齿。远处物体和近处物体的轮廓不存在忽粗忽细的闪烁线宽比较稳定。如果看到的效果是全屏被轮廓颜色填满多半是_DepthThreshold设置过小或者深度纹理没有正确生成。如果某条边缘断成一截一截说明相邻物体的深度差比较小需要降低_DepthThreshold。5. 常见问题与排查思路5.1 问题速查表问题现象常见原因解决思路轮廓线断成碎片深度边缘阈值太高低分辨率下深度差被放大降低_DepthThreshold或提高像素 RT 分辨率全屏变成黑色深度纹理未生成检查脚本里depthTextureMode是否开启Inverted Hull 描边断裂模型法线不连续硬边处背面不闭合重新计算平滑法线或改用后处理轮廓线条随相机距离忽粗忽细在世界空间做法线外扩改成屏幕空间扩展或对轮廓宽度做距离补偿线条像锯齿但不像素化后处理输出时没有点采样把 lowRes 的filterMode改为PointURP 下没有效果OnRenderImage 在 URP 默认不生效封装为 ScriptableRendererFeature 后处理 Pass5.2 深度边缘闪烁像素风格的深度边缘检测经常会出现闪烁。原因很简单场景渲染到低分辨率后深度信息也被降采样远处的两个物体深度差只占几个像素甚至不到一个像素算法很难稳定判断边界。排查思路确认目标平台是否支持 32 位深度缓冲必要时把 RenderTexture 的 depth 从 16 提升到 24 或 32。检查场景里是否存在靠近摄像机的半透明物体半透明物体会干扰深度图。对于上下两帧深度变化大的物体考虑对 Sobel 结果做时间方向上的平滑。5.3 Inverted Hull 轮廓线穿模由于 Inverted Hull 是向外扩张的背面模型它本质上会比原模型大一圈。如果模型动作幅度大比如手臂贴近身体扩展后的手臂轮廓可能会插入躯体内部导致描边被遮挡。解决办法是在材质上把 ZWrite Off并让轮廓 Pass 在透明队列中优先渲染这样能减轻遮挡问题。当然彻底解决还是要控制轮廓宽度不要让描边半径超过模型身体结构的间距。5.4 像素化模糊很多同学效果出来后发现角色边缘是糊的像开了模糊滤镜。这通常不是因为 Shader 写错而是纹理采样设置不对。像素风格要求所有纹理和 RenderTexture 都使用 Point 采样如果保留了 Bilinear低分辨率放大后就会自动插值造成模糊。检查三处模型贴图导入设置、相机后处理 RT 的 filterMode、UI 或 Sprite 图集的采样方式。6. 工程化最佳实践6.1 不要把轮廓线参数写死在 Shader 里轮廓线的颜色、宽度、阈值这些参数在项目迭代过程中几乎一定会频繁调整。所以我建议轮廓线颜色提取到材质球属性里不要直接硬编码 float4。全局描边宽度使用脚本统一赋值而不是每个模型单独调整。后处理阈值提供多档预设比如“远景模式”“近战模式”“UI 模式”。使用 Shader 变体或者 MaterialPropertyBlock 控制指定角色的描边颜色方便做选中反馈。6.2 控制深度纹理开销深度纹理在移动端的开销并不低。如果你只做 Inverted Hull完全不需要开启相机深度纹理。只有使用后处理边缘检测时才开启depthTextureMode并且要确保不是所有相机都开深度只给需要描边的相机开即可。项目里如果有多台相机比如主相机和 UI 相机UI 相机就别挂描边脚本了。6.3 角色描边和场景描边分离一个容易踩坑的地方是把所有描边都放在同一套后处理里。角色往往需要粗且清晰的描边背景建筑只要极细的轮廓如果统一阈值角色和场景总有一个不合适。我建议的效果分层方式是角色专用材质使用 Inverted Hull粗线、黑色、高优先级。场景家具和地面使用后处理深度边缘检测细线、低灵敏度。可交互物品额外启用颜色边缘检测通过亮色轮廓强调。这样每一类物体都有独立的描边控制权后续调风格时也不用全局重做。6.4 性能预算与 Draw Call 控制Inverted Hull 会让一个模型渲染两次Draw Call 翻一倍。如果角色数量很多比如 RTS 游戏里几百个单位同时出现这个开销就非常可观。优化思路有几个只对主角、敌人等关键单位启用描边 Shader小单位省略。用 GPU Instancing 合并同材质单位减少重复 Draw Call。后处理模式下把小单位交给深度边缘检测不单独开 Inverted Hull。在 LOD 切换时同步降低描边宽度因为远距离模型只需要很少的描边像素。6.5 跟美术配合好纹理和模型规范轮廓线最终能不能出效果有一半取决于美术制作规范。作为程序或技术美术最好把这些写进项目规范文档模型角色必须导出统一朝向Z 轴正前方缩放为 1:1:1。需要 Inverted Hull 描边的模型必须开启自动平滑法线平滑角度根据硬边需要设置。像素风格贴图必须 Point 过滤非必要不生成 MipMap。颜色贴图避免大面积纯黑因为黑色区域容易和默认描边色混淆导致边缘丢失。7. 值得继续深入的方向文中的两种做法已经能覆盖大多数 3D 像素项目的轮廓需求但如果你想要更高级的表现可以考虑下面几个方向。7.1 基于法线的内部棱线检测深度边缘检测能很好地抓到物体之间的边界但抓不到同一个物体上颜色相同但转折明显的面。如果要给低模方块角色补上内部棱线可以在后处理 Shader 里增加_CameraNormalsTexture对法线方向做 Sobel。思路和深度 Sobel 完全一致只要把采样的值从深度换成法线点积即可。法线边缘检测对模型布线非常敏感法线不平滑的地方会出现大量细碎线建议对法线贴图先做模糊再做 Sobel。7.2 轮廓线颜色与场景光联动静态黑色轮廓线是最稳妥的但它会显得缺乏生气。更进阶的做法是让轮廓线颜色参与场景光照例如在暗处让轮廓线变暗在霓虹灯区域让轮廓线带一点环境色。这个可以通过后处理拿到全局光照概率图或者光照贴图采样结果再与基础轮廓色进行混合。模式上更像是“描边也受光照影响”效果会非常有质感。7.3 遮罩贴图控制局部描边有时候我们只想描边角色头巾但不想描边披风内侧这时候通篇统一的 Shader 就不好用了。可以给模型增加第二套 UV绘制一张遮罩贴图白色表示需要描边黑色表示不描边。Inverted Hull 的偏移量乘以遮罩值就能实现局部描边宽度控制。这个方法很实用也是商业项目里常见的操作。7.4 从 Built-in 管线迁移到 URP如果你的项目终稿确定要走 URP那 OnRenderImage 这段代码就需要迁移。URP 里通常把后处理逻辑写在 ScriptableRendererFeature 中用 CommandBuffer 处理中间 RenderTexture材质属性依然复用本文 Shader 里的核心算法。迁移时需要注意的是深度纹理名称会有差异比如_CameraDepthTexture在 URP 里可能变为_CameraDepthAttachment或通过cameraDepth参数传入细节要按照你使用的 URP 版本来查。等到你能把轮廓线的阈值、线宽、颜色、分层、顶点遮罩这些都自由组合的时候3D 像素艺术的风格化渲染基本就掌握了。做工程时我最大的体会是轮廓线不是一条线而是一套渲染规则。你可以先跑通核心示例再慢慢往里面加遮罩、加颜色变化、加 URP 迁移。等做到第二、第三个项目时就会积累出自己团队的轮廓线参数模板新角色导入后两步就能匹配整套风格不用再从零调起。如果你在配置过程中遇到文章里没提到的描边断裂、深度闪烁或像素化模糊问题可以把你用的渲染管线和 Shader 版本信息一起写在评论区这种手动调参的问题通常讨论一轮就能找到答案。