各位做像素风游戏的朋友,应该都有过这样的体验:模型在建模软件里怎么看怎么顺眼,贴图也画得规规矩矩,可是一放进游戏场景,整只角色就变得灰扑扑、软绵绵,跟二维的像素立绘放在一起完全不是同一个画风。问题往往不在模型本身,而是缺少一条确定的轮廓线。二维像素画天然有硬朗的边缘,而 3D 像素模型一旦进入光照环境,就很容易变成普通的低模卡通,丢失那种“像素味”。
这篇文章就来专门解决这个问题:如何为 3D 像素艺术添加轮廓线,也就是游戏圈常说的 Outline / 描边效果。我会先讲清楚轮廓线解决的是哪些视觉问题,接着对比几种主流实现方案,然后重点拆解“法线外扩描边”的原理,最后给出一份可以运行的 Unity Shader 完整教程,包括动态调参脚本、像素风低分辨率渲染的配合方案,以及常见问题和工程建议。如果你用的是 Godot、Three.js,核心思路也可以直接迁移过去。
1. 3D像素艺术的轮廓线:是什么,解决什么问题
1.1 先理解“3D像素艺术”的特殊性
3D 像素艺术通常指以体素(Voxel)或低多边形模型为载体、配合像素风贴图和低分辨率渲染的画面风格,典型游戏如《我的世界》衍生作品、《Terraria》的 3D 化项目、《Crossy Road》等。它的视觉核心并不是“模型精度”,而是“像素颗粒感”和“色块边界”。
这种风格下,模型表面通常有以下特点:
- 表面平整,法线方向基本都是轴对齐的,比如只朝 +X、-X、+Y、-Y、+Z、-Z 六个方向。
- 颜色以大色块为主,贴图分辨率低,很少出现细腻渐变。
- 光照计算如果太重,反而会破坏色块的平整感。
也就是说,3D 像素艺术的魅力恰恰在于“简化”?只有把模型轮廓明确地勾勒出来,让每个体素单元在视觉上形成清晰的剪影,画面才会回归那种干净的像素游戏质感。
1.2 为什么模型放进场景后“散”了
没加轮廓线之前,3D 场景依赖光照、阴影和深度来区分物体。但像素风格模型本来就是低精度、低贴图细节的,一旦光一打,角色和背景之间的边界就会模糊。尤其是遇到以下场景:
- 角色的深色贴图和暗色背景混在一起,玩家分不清前后物体。
- 多个像素角色站在一起时,彼此之间没有边界,变成一团色块。
- 想让敌人被选中、被锁定、被高亮时,缺少一个统一的表现手段。
轮廓线就是在这些场景下把“图形边缘”重新捞回来的手段。它相当于给模型加了一圈清晰的剪影涂层,让物体在任何光照下都保持独立、明确的存在感。这也是 2D 像素画天然具备、但 3D 模型容易丢失的视觉要素。
1.3 轮廓线在像素游戏里的典型用途
轮廓线在 3D 像素艺术项目中常见于以下几个需求:
- 为角色、怪物、NPC 增加统一的卡通描边,强化像素手绘感。
- 在做选中反馈时,通过描边颜色变化来表现“锁定”或“高亮”状态。
- 在低分辨率画风下,用粗描边代替高精度阴影,降低渲染压力。
- 在俯视角或固定视角的游戏中,利用轮廓线保证角色从任何角度都清晰可辨。
后面我们会看到,上述需求大多数都能用同一种描边技术解决,只是参数和表现层略有差异。
2. 轮廓线实现的三大主流方案
实现描边效果的方案很多,不同方案适用场景也不同。先做横向对比,对理解后面的实战很重要。
2.1 Inverted Hull:法线外扩描边
这是游戏开发中最经典、最常用的描边方式,思路可以概括成一句话:“画一个放大的黑色背面,再把模型正面盖在上面。”
具体做法是:
- 渲染两遍同一个模型。
- 第一遍不剔除背面,或者专门剔除正面,把顶点沿着法线方向向外挤出一定距离,整层涂成轮廓色(通常是黑色)。
- 第二遍正常渲染模型正面。
因为第二遍的正面向外覆盖,后画的模型会把前面的黑色背面遮住,最终边缘溢出的黑色部分就构成了一道描边。
它的优点非常明显:
- 不需要屏幕空间后处理,开销较低。
- 描边跟随物体本身旋转、缩放、移动,不会出现边缘漂移。
- 可以非常方便地动态调整粗细和颜色。
缺点主要是:
- 如果模型有向内凹陷的结构,描边可能会漏进凹陷区域,形成不自然的黑色嵌线。
- 顶点共用情况下,法线外扩方向可能不理想,这在体素模型上尤其值得注意。
2.2 深度加法线后处理描边
另一种思路是在屏幕空间做边缘检测。引擎先渲染一张包含深度信息和法线信息的缓冲,然后在后处理阶段逐像素判断“前后左右相邻像素的深度或法线是否发生突变”,如果发生了突变,就把该像素视为轮廓,覆盖成描边色。
这种方法的最大优势是:描边是基于最终画面的边缘,不会出现模型内部凹陷漏线的问题,而且可以做到整帧统一粗细,闭会感特别强。
缺点也很明显:
- 需要额外的后处理通道,移动端和低端设备上有性能压力。
- 边缘粗细受屏幕分辨率影响,想调成规范像素笔触需要额外处理。
- 后处理方案是和相机绑定的,不适合做“单物体选中高亮”。
我做项目时一般会按需求来选:如果只是主角和怪物的描边,优先用 Inverted Hull;如果是全场景统一卡通渲染,才考虑深度法线后处理。
2.3 菲涅尔边缘发光
菲涅尔(Fresnel)效果是“边缘亮、正面暗”的光照现象。利用视角方向和法线方向的夹角,可以给模型边缘叠加一圈颜色。
这个方案实现最简单,但通常产生的是柔和渐变边缘,不是像素游戏想要的“硬朗线条”。除非项目本身是柔和卡通风格,否则不适合作为主方案,更适合用来做“边缘发光”的辅助效果。
2.4 三个方案选哪个?
| 对比维度 | Inverted Hull | 深度法线后处理 | 菲涅尔边缘 |
|---|---|---|---|
| 实现难度 | 低 | 高 | 最低 |
| 性能开销 | 低 | 中高 | 低 |
| 线条风格 | 硬朗,适合像素 | 硬朗,可控制 | 柔和渐变 |
| 是否依赖相机 | 否 | 是 | 否 |
| 是否适合单个高亮 | 非常合适 | 不适合 | 勉强 |
| 体素模型适配 | 需处理法线问题 | 优秀 | 一般 |
本文的实战部分采用 Inverted Hull,因为 3D 像素艺术需要的是干脆利落的轮廓,而且这个方案最容易通过 Shader 参数实现动态控制。
3. 环境准备与示例项目结构
3.1 引擎与渲染管线说明
本文的示例代码基于 Unity 制作,但不会依赖某个特定版本的私有 API。读者使用的 Unity 版本如果和下面描述不同,只要依然是内置渲染管线(Built-in Render Pipeline),写法和结果都基本一致。
如果你正在用 URP(Universal Render Pipeline),也不要直接照抄屏幕后处理部分,但 Inverted Hull 这个思路是可以通过 Shader Graph 或者手写 HLSL 实现的。第五节最后我会补一段 URP 的适配思路。
3.2 示例场景与模型要求
为了测试描边效果,我们需要准备一个 3D 像素风格模型。最简单的办法是使用 Unity 自带的 Cube,把它缩放成“一个小人”的形状,或者直接使用任意体素模型。注意事项如下:
- 模型必须是封闭网格,尽量保证法线正确。
- 描边 Pass 会在顶点层面工作,所以模型不需要细分,低模即可。
- 如果模型已经导入了像素风贴图,也可以一起应用在材质上。
3.3 项目文件结构
按下面的方式组织场景和脚本:
Assets/ ├── Scenes/ │ └── OutlineDemo.unity ├── Shaders/ │ └── PixelArtOutline.shader ├── Materials/ │ └── PixelCharMat.mat ├── Scripts/ │ └── OutlineController.cs │ └── PixelizeCamera.cs └── Models/ └── PixelCharacter.fbx后面每个文件的代码我都会单独给出,大家按路径对应创建即可。
4. 核心原理拆解:法线外扩描边是怎么工作的
4.1 顶点法线与外扩方向
在 Inverted Hull 方案中,最重要的操作是“把顶点沿法线方向外扩”。这里要回到一个基础概念:顶点法线。
法线(Normal)是垂直于模型表面的方向向量。对于简单的平面,法线方向就是面的朝向。对于低多边形模型,模型导入引擎时通常会做顶点法线平滑,让同一个顶点如果被多个面共用,法线变为多个面法线的平均值。
描边 Pass 的做法,就是获取每个顶点的法线,把它当作“外扩方向”,然后把顶点位置向外移动:
外扩后的顶点位置 = 原始顶点位置 + 法线方向 × 描边宽度这个操作可以在顶点着色器里完成,不需要修改网格数据。这就是 Inverted Hull 高效的根本原因。
4.2 用 Cull Front 渲染背面
我们需要理解图形渲染中的“剔除(Culling)”概念。默认情况下,渲染管线为了性能会剔除背面,只绘制对着相机的那一面。但在描边 Pass 中,我们反而需要让模型背面参与绘制。
所以关键代码是:
Cull Front加上这一句之后,渲染器只绘制模型的背面。然后我们在顶点着色器里把模型沿法线外扩,相当于得到一个“比原模型大一圈的壳”,并且只在这个壳上输出轮廓色。
随后再进入正常的渲染 Pass,绘制模型的正面。因为正面足够大,它会覆盖住后画的那层壳的正面部分,只有壳从模型边缘溢出的那一圈会露出来,这就是轮廓线。
4.3 描边宽度与像素风格的关系
描边宽度的控制,通常归一化到模型自身尺寸。比如一个高 2 米的角色,描边宽度取 0.02 到 0.05 就比较合适。在像素风格项目里,描边宽度和“像素颗粒感”之间需要保持比例:
- 太窄:轮廓线细碎,低分辨率下容易闪烁。
- 太宽:角色显得臃肿,像被黑色描边吞掉。
建议在项目初期就锁死纹理分辨率和渲染分辨率,然后在固定分辨率下调整描边宽度,这样效果最可控。
4.4 体素模型法线的特殊性
这里有个 3D 像素艺术特有的坑:体素模型经常是多个立方体拼出来的,比如一个角色的头部是一个 Cube,手臂是几个 Cube。如果不做合并处理,每个 Cube 的法线是干净的轴对齐方向,外扩方向也很规则。
但如果你把体素模型合并成一个网格,顶点会被多个三角面共用,Unity 计算出的平滑法线就会变成倾斜方向。结果就是描边外扩方向变得歪歪扭扭,不够规则。
针对这个问题,通常有三种解决思路:
- 第一种:模型的描边网格单独生成,只保留原始立方体的轴对齐法线。
- 第二种:不合并模型,保持多个 Cube 组件,每个 Cube 单独挂材质。
- 第三种:写一个编辑器工具,为模型重新计算六方向法线。
在项目早期做好这一步,后面调效果才不会反复返工。这部分内容我也会在第六节的常见问题里单独再强调一次。
5. 完整实战:为 3D 像素角色添加可调节轮廓线
下面进入本文的核心部分。我们的目标很明确:在 Unity 中写一个支持轮廓线开关、宽度和颜色动态调节的 Shader,并把它应用到一个 3D 像素角色上,再做低分辨率像素化渲染,得到最终效果。
5.1 创建自定义 Shader
在 Unity 项目里创建 Shader,菜单路径是:
Assets -> Create -> Shader -> Standard Surface Shader然后把文件命名为 PixelArtOutline.shader,用任意代码编辑器打开,替换为以下内容。
// 文件路径:Assets/Shaders/PixelArtOutline.shader Shader "PixelArt/Outline" { Properties { _MainTex ("主纹理", 2D) = "white" {} _Color ("主颜色", Color) = (1, 1, 1, 1) _OutlineColor ("轮廓颜色", Color) = (0, 0, 0, 1) _OutlineWidth ("轮廓宽度", Float) = 0.03 [Toggle(USE_OUTLINE)] _UseOutline ("启用轮廓线", Float) = 1 } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } LOD 100 // Pass 1:轮廓壳 // 只绘制背面,沿法线外扩顶点,涂上轮廓色 Pass { Name "OUTLINE" Tags { "LightMode"="ForwardBase" } Cull Front ZWrite On CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma shader_feature_local USE_OUTLINE #include "UnityCG.cginc" float _OutlineWidth; fixed4 _OutlineColor; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert (appdata v) { v2f o; // 如果关闭轮廓线,直接输出原位置,相当于这一层不可见 #ifdef USE_OUTLINE float3 worldNormal = UnityObjectToWorldNormal(v.normal); float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; worldPos += normalize(worldNormal) * _OutlineWidth; o.pos = UnityWorldToClipPos(worldPos); #else o.pos = UnityObjectToClipPos(v.vertex); #endif return o; } fixed4 frag (v2f i) : SV_Target { return _OutlineColor; } ENDCG } // Pass 2:正常模型 // 正常采样贴图,输出模型本色 Pass { Name "BODY" Tags { "LightMode"="ForwardBase" } Cull Back ZWrite On CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; }; 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" }下面对这个 Shader 的关键部分做逐段解释。
Properties 部分定义了四个基础参数:主纹理、主颜色、轮廓颜色、轮廓宽度。最后一个[Toggle(USE_OUTLINE)]是一个开关,用来快速开启或关闭描边效果。这个开关在后期调试时非常有用,可以不改材质直接对比“有轮廓”和“无轮廓”两种效果。
Pass 1 是轮廓壳。Cull Front是关键,它让 GPU 只绘制背面。在顶点着色器中,我们获取顶点的法线,转换到世界空间,再把顶点位置沿法线方向推出_OutlineWidth的距离。这里我特意使用世界空间计算,因为描边宽度预期是一个与模型大小相关的绝对世界单位值,这样在场景中缩放物体时,描边粗细不会跟着模型一起缩放。如果你希望描边跟随模型缩放,可以把外扩操作放在模型空间完成,也就是直接用v.vertex.xyz + v.normal * _OutlineWidth,然后用UnityObjectToClipPos转换到裁剪空间。
Pass 2 是正常模型。它的代码非常普通,就是采样贴图乘上主颜色,保证模型本身以原始画面呈现。渲染顺序上,Unity 会先执行 Pass 1 再执行 Pass 2,所以正常模型的正面会覆盖掉轮廓壳的正面,只有溢出的边缘部分露出来形成轮廓。
5.2 创建材质并应用
创建材质文件的菜单路径:
Assets -> Create -> Material将材质命名为 PixelCharMat,然后在 Shader 下拉框中选择PixelArt/Outline。
把材质拖到场景中像素角色的 MeshRenderer 上。如果模型贴图已经在 Unity 里准备好,可以直接拖到材质的主纹理槽中。
此时你可以先把 Scene 视图中的光照调暗一些,对比“启用轮廓线”和“关闭轮廓线”的差异。开启时,角色应该被一圈纯黑色描边包裹,关闭时描边立刻消失。这个开关效果就是 Properties 中 Toggle 参数的作用。
5.3 使用 C# 脚本动态调节描边参数
实际开发中,我们经常需要在运行时动态改变描边颜色和宽度,比如敌人被锁定时描边从黑变红,主角受击时描边瞬间变宽再恢复。这些都可以通过 Material 的 SetFloat 和 SetColor 完成。
创建脚本:
Assets -> Create -> C# Script命名为 OutlineController.cs,内容如下:
// 文件路径:Assets/Scripts/OutlineController.cs using UnityEngine; public class OutlineController : MonoBehaviour { [Header("材质与参数")] public Material targetMaterial; [Range(0f, 0.1f)] public float outlineWidth = 0.03f; public Color outlineColor = Color.black; private static readonly int WidthID = Shader.PropertyToID("_OutlineWidth"); private static readonly int ColorID = Shader.PropertyToID("_OutlineColor"); private static readonly int UseOutlineID = Shader.PropertyToID("_UseOutline"); private void Awake() { if (targetMaterial == null) { Renderer renderer = GetComponent<Renderer>(); if (renderer != null) { targetMaterial = renderer.material; } } } private void Update() { if (targetMaterial == null) { return; } targetMaterial.SetFloat(WidthID, outlineWidth); targetMaterial.SetColor(ColorID, outlineColor); targetMaterial.SetFloat(UseOutlineID, 1f); } public void SetOutlineWidth(float width) { outlineWidth = Mathf.Clamp(width, 0f, 0.1f); } public void SetOutlineColor(Color color) { outlineColor = color; } public void DisableOutline() { if (targetMaterial != null) { targetMaterial.SetFloat(UseOutlineID, 0f); } } public void EnableOutline() { if (targetMaterial != null) { targetMaterial.SetFloat(UseOutlineID, 1f); } } }脚本里的Shader.PropertyToID是一个性能优化做法。字符串形式的Material.SetFloat在 Unity 内部每次都会做哈希运算,提前缓存 ID 可以避免每帧重复计算。虽然单次差距很小,但在有大量角色和描边参数的场景中,这个习惯能减少一些 CPU 开销。
把脚本挂到角色物体上,将材质赋给脚本上的 targetMaterial 字段。运行场景后,在 Inspector 面板里拖动 outlineWidth 滑动条,角色的描边宽度会实时变化,这会让你快速找到最适合当前场景的描边值。
如果你想让描边只出现在部分物体上,可以通过一个物体上的多个 Material 元素配合材质数组来实现,也可以把材质实例化后单独管理。这里要注意,不要直接修改通过GetComponent<Renderer>().sharedMaterial获得的共享材质,否则会影响到其他相同模型。
5.4 低分辨率渲染强化像素感
描边只是让模型边缘清晰起来了,但要让画面真正变成“像素游戏”,还要把整个画面分辨率降下来,并配合最近邻采样,让每个像素呈现出可见颗粒。
Unity 中可以用 RenderTexture 轻松实现这个效果。创建一个新脚本 PixelizeCamera.cs:
// 文件路径:Assets/Scripts/PixelizeCamera.cs using UnityEngine; [RequireComponent(typeof(Camera))] public class PixelizeCamera : MonoBehaviour { [Header("像素分辨率")] public int pixelWidth = 640; public int pixelHeight = 360; private Camera targetCamera; private RenderTexture renderTexture; private void Awake() { targetCamera = GetComponent<Camera>(); } private void Start() { renderTexture = RenderTexture.GetTemporary(pixelWidth, pixelHeight, 24); renderTexture.filterMode = FilterMode.Point; targetCamera.targetTexture = renderTexture; } private void OnDestroy() { if (renderTexture != null) { // 注意:如果还需要在其他地方使用,先解除相机的 targetTexture targetCamera.targetTexture = null; RenderTexture.ReleaseTemporary(renderTexture); renderTexture = null; } } public void UpdateResolution(int width, int height) { pixelWidth = width; pixelHeight = height; if (renderTexture != null) { RenderTexture.ReleaseTemporary(renderTexture); } renderTexture = RenderTexture.GetTemporary(pixelWidth, pixelHeight, 24); renderTexture.filterMode = FilterMode.Point; targetCamera.targetTexture = renderTexture; } }这个脚本的原理很简单:把相机的输出目标从屏幕改成一张低分辨率的 RenderTexture,并让纹理过滤模式为 Point(最近邻)。最终效果是画面像素块变大,轮廓线也跟着变成锯齿色块,这种锯齿感正是像素风格重要的视觉组成。
低分辨率数值建议从 640x360 起步。如果项目输出竖屏游戏,可以试试 360x640。分辨率越低,像素颗粒越大。注意分辨率不要设得过低,低于 320x240 时角色细节会丢失得很严重,轮廓线也可能因为太粗而吞掉面部表情。
5.5 URP 与 Shader Graph 的适配思路
如果你在使用 URP,上面的内置管线 Shader 不能直接使用。适配可以走两条路线:
第一条路线是直接用 Shader Graph 重建同样的效果。在 Shader Graph 中创建两个 Sub Graph 或者两个 Pass 并不像内置管线那样直观,所以更推荐使用 UPR 的 HLSL 手写 Shader。
第二条路线是手写 HLSL Shader,把 Surface Shader 改成 Lit 或者 Unlit 管线。URP 下的顶点着色器需要包含 URP 的相关库,比如:
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"然后使用TransformObjectToWorld、TransformWorldToHClip等函数来替代旧版内置函数的调用。整体逻辑和第五节的内置 Shader 是完全一致的:一个 Pass 做背面外扩,一个 Pass 做正面渲染。
如果你是初学者,当前最优先的建议是先把内置管线版本跑通,理解这个流程后再去对照 URP 的官方模板迁移。一次牵扯太多种细节容易打击信心。
6. 常见问题与排查思路
6.1 描边显示不出来
最直观的现象是:材质已经应用了,但模型完全没有黑色描边。
排查顺序如下:
- 检查 Shader 的 Pass 1 是否存在。如果删掉了 Pass 1,就只会有正常模型渲染。
- 检查
Cull Front是否被误写为Cull Back。如果写错,外扩的正面会被直接遮挡,看起来没有效果。 - 检查描边宽度是否过小。0.001 这种数量级在视觉上几乎看不见,往大了调试试。
- 检查是否把材质的
_UseOutline开关关掉了。Toggle 参数在关闭状态下会直接走原位置输出。
6.2 描边粗细不均匀
这个问题在低多边形模型上很常见。原因是顶点法线的平滑结果不一致。比如一个长方体的角点,如果法线是三个面平均得到的,外扩方向就是对角线方向,导致角部外扩距离比平面区域更大,描边看起来“角重边轻”。
处理办法有几种:
- 如果是体素模型,拆开成独立 Cube,并给每个 Cube 设置不同的法线,让它们保持轴对齐方向。
- 如果模型已经合并,可以单独制作一个只用于描边的“外扩网格”,把外扩方向预先写死在顶点数据里。
- 接受部分不均匀,并把它当成风格的一部分。一些像素游戏反而会利用这种不均匀表现手绘感。
6.3 描边被其他物体遮挡或穿透模型
描边本质上是膨胀的模型壳,有一定厚度,所以可能出现以下情况:
- 两个角色贴近时,一方的描边壳嵌进另一方的模型里。
- 角色贴在墙边时,墙会直接切掉描边,导致轮廓线忽隐忽现。
解决方案包括:
- 使用描边层分离渲染,把轮廓壳放到单独层,让它在深度测试上不和普通物体竞争。
- 让描边壳的深度写入关闭,这样它只会画在模型表面之外,不会影响后方物体。
- 在相机后处理里用膨胀深度值,让描边基于屏幕边缘检测而不是模型外扩。
对于像素风格项目,最推荐的是第一种“单独层”思路,因为场景简单,成本最低。
6.4 低分辨率渲染下描边闪烁
闪烁通常由两个原因造成:一是描边宽度太窄,在像素化后轮廓线宽度不到一个像素,时有时无;二是相机和目标物体的相对移动导致轮廓线在像素网格上跳变。
解决思路:
- 增大
_OutlineWidth数值,至少让描边在目标分辨率下占到 2 到 4 个物理像素。 - 控制像素渲染分辨率不要过低,避免边缘信息丢失。
- 如果是实际玩家角色,尽量让相机保持平滑跟随,减少高频抖动。
下表做一个快速排查参考:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 描边不显示 | Pass 缺失或 Cull 方向错误 | 检查 Shader 两个 Pass 和剔除方向 |
| 描边消失于暗背景 | 描边颜色贴近背景颜色 | 替换更明显的轮廓色 |
| 描边粗细不均 | 体素模型法线平滑 | 拆模型或单独生成描边网格 |
| 低分辨率下闪烁 | 描边宽度小于像素粒度 | 增大宽度,降低分辨率调整幅度 |
| 描边穿透到模型内部 | 多个描边壳叠加 | 分层渲染,控制深度测试 |
6.5 描边颜色没有办法通过代码修改
这个问题通常是材质实例化导致的。场景中使用共享材质时,targetMaterial.SetColor会修改到所有同材质物体。如果你只希望某个角色的描边变色,必须调用renderer.material,让 Unity 自动创建一个材质实例,每次返回的都是独立实例。但要注意,频繁访问renderer.material会创建新材质实例,要提前缓存。
7. 工程优化与风格化进阶
7.1 用顶点色控制局部描边加权
在最基础的 Inverted Hull Shader 中,所有顶点共用同一个描边宽度。但我们经常希望角色某些部分描边粗一点、某些部分细一点,比如头发轮廓更粗、手指描边更细。
一个可行的方案是利用顶点色(Vertex Color):
在 Shader 属性里增加:
_OutlineWidthScale ("描边缩放", Float) = 1然后在顶点着色器中读取顶点色:
float scale = v.color.r * _OutlineWidthScale; worldPos += normalize(worldNormal) * _OutlineWidth * scale;顶点色可以在建模软件里绘制,也可以在 Unity 里通过第三方网格工具设置。这样同一个材质就能在不同模型部位产生不同描边效果,非常适合像素角色差异化表现。
7.2 使用后处理制作描边发光
如果需要“被选中单位外发光”的效果,可以在现有描边之上叠加一个全屏后处理,利用屏幕空间的颜色和深度信息,把发光效果限制在描边区域。常见做法是:先用后处理采样深度缓冲,找出边缘,然后在边缘像素上叠加一个 emit 色。
在像素风格游戏里,这种发光可以做成“格子状”的辉光,和普通现代 3D 的泛光不同,不建议直接使用带有大量模糊的 Bloom,那样会破坏像素颗粒感。
7.3 透明物体的描边问题
半透明材质(比如水晶、能量体、隐形敌人)加描边时,描边壳和模型半透正面会产生叠影。最简单的方法是把描边 Pass 也设为半透明:
- 在 Pass 1 增加
Blend SrcAlpha OneMinusSrcAlpha。 - 把
_OutlineColor的 alpha 通道作为轮廓透明度。 - 渲染队列设为
Transparent。
不过透明描边在复杂场景中非常容易有排序问题,实际项目中建议单独验证,不要一上来就统一改。
7.4 性能与批处理建议
法线外扩描边本身效率很高,一次 Draw Call 额外多渲染一遍模型壳即可。但有两个隐藏的性能坑需要注意:
- 如果场景中有大量角色,批量渲染时描边壳会增加顶点处理压力。可以只在玩家和重要可交互物体上开描边,普通装饰物不加。
- 使用
renderer.material实例化后,材质之间如果参数不同,无法合批。可通过打图集、合并网格降低 Draw Call。
内存方面,RenderTexture.GetTemporary建议不要每帧创建。PixelizeCamera 的脚本里我只是在 Start 创建一次,如果要做动态分辨率,也要避免每帧释放再创建,尽量只在分辨率变化时更新。
7.5 Godot 与 Three.js 工程中的思路迁移
Unity 之外,很多 3D 像素游戏也使用 Godot 或 Web 端渲染。Inverted Hull 的思路同样适用。
在 Godot 中,描边可以通过 SpatialMaterial 的“反照率 + 背面渲染”方式实现,也可以用 Shader 的NORMAL外扩顶点:
shader_type spatial; render_mode cull_front; void vertex() { VERTEX += NORMAL * 0.03; } void fragment() { ALBEDO = vec3(0.0, 0.0, 0.0); }在 Three.js 中,可以在 CustomShader 的 vertex shader 里按法线挤出顶点,再关闭背面剔除,然后用一个黑色材质单独渲染壳层。核心表达式也是同一个:
transformed += normalize(objectNormal) * outlineWidth;如果你是在做 Web 端像素渲染,像素感还需要配合后期采样THREE.RenderTarget和textureFilter = NearestFilter实现,思路与 Unity 的低分辨率 RenderTexture 完全一致。
8. 小结与扩展学习方向
本文从 3D 像素艺术的实际视觉痛点出发,介绍了轮廓线的必要性,对比了 Inverted Hull、深度法线后处理描边、菲涅尔边缘三种实现方式,重点拆解了法线外扩描边的原理,并给出完整的 Unity Shader 代码、材质创建流程、动态调节脚本以及低分辨率像素化渲染的配套方案。如果你只是想在项目里快速实现描边,可以直接把第五节的 Shader 拿去修改参数;如果你需要适配体素模型,第六章的排查思路和第七章的顶点色加权方案应该能帮你少走很多弯路。
接下来可以继续深入的方向包括:学习 Shader Graph 制作可复用描边节点;研究深度法线后处理在卡通渲染中的统一描边用法;尝试把描边宽度写入纹理贴图,让不同材质区域自动匹配粗细;以及把轮廓线系统扩展到敌人锁定、队友高亮、剧情对话焦点等交互反馈中。3D 像素艺术的视觉风格非常依赖这些小细节的打磨,把描边逻辑做成一个独立模块,后面的项目和场景复用起来会轻松很多。建议你先在测试场景里跑通这套流程,再逐步往正式项目中迁移。