尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

为3D像素艺术添加像素对齐轮廓线的实践

为3D像素艺术添加像素对齐轮廓线的实践 在给体素风格的独立游戏项目调渲染效果时我踩过一个大坑模型摆进场景后第一眼“像素感”是有了但总感觉画面发灰、糊成一团尤其是角色站在同色系背景前时轮廓完全消失。后来把轮廓线加上去整个画面才真正“立”起来。这个项目标题虽然叫“为3D像素艺术添加轮廓线”但实际操作下来它牵扯到的核心问题其实是在3D渲染和2D像素网格之间如何让线条既保留像素的硬边气质又不随相机距离和分辨率抖动。我先说结论给3D像素艺术加轮廓线不要直接抄普通低模的描边方案。像素艺术对轮廓的要求很苛刻——线条必须和像素对齐粗细稳定颜色纯净不能在放大后被硬件过滤成半透明渐变。适合的场景不止是体素游戏也包括任何用3D美术模拟像素画风的项目低模场景、MagicaVoxel导出模型、假装8-bit风格的3D动作游戏原型。这篇文章会从方案选型、原理、Unity实操到踩坑记录全部过一遍新手拿去做出自己的轮廓线老手也可以对照检查自己方案里的隐藏问题。1. 为什么3D像素艺术的轮廓线不能照搬普通描边1.1 轮廓线在像素画里是“结构线”而不是“特效”像素画和普通数字绘画有一个明显区别像素画里的轮廓不是后加的装饰它承担了信息分层的功能。你看一张经典RPG的角色精灵图角色之所以能从场景里跳出来靠的就是一圈清晰的深色边界线。这条线告诉眼睛“这里是角色的外边界”。在3D像素艺术里情况完全一样只不过这条线需要从三维场景中实时生成。如果缺失轮廓线3D像素场景常见的观感问题是低分辨率让高频细节丢失物体之间只能靠颜色区分遇到同色系物体或者暗光环境角色就像陷进了背景里。尤其是体素模型很多面是纯色本身阴影又是硬切的像素风通常不做环境光遮蔽画面就会变成“一堆色块叠在一起”。轮廓线在像素画里有一个特征值得注意它是沿着像素格子走的。一个合法好用的3D像素轮廓线投射到屏幕上之后也应该贴合低分辨率像素网格而不是随便一条光滑的矢量线。这就是很多通用描边方案在像素风格下会翻车的根本原因它们把轮廓线当成了几何特效忽略了像素网格本身的约束。1.2 普通低模描边的三个失灵场景先说最常见的“几何外扩”Inverted Hull方案取模型顶点沿法线方向往外推一点点正面剔除渲染成纯色就得到一圈外轮廓。很多卡通风格游戏都用这个因为它便宜、简单一个Pass就能搞定。但在3D像素艺术里这个方案有三个明显问题。第一个问题是轮廓宽度不随像素对齐。外扩距离是模型空间或世界空间的值像素风格场景渲染到低分辨率后再放大同一个世界空间的外扩距离映射到屏幕上可能是2像素、3像素、甚至1.5像素。1.5像素意味着硬件采样时会出现半透明过渡线条变脏画面一抖就闪烁。像素画要求线条宽度是整数像素几何外扩很难保证这一点。第二个问题是体素模型本身会拆台。从MagicaVoxel或类似工具导出的OBJ通常每个立方体都是独立的面片顶点沿棱边切开法线方向只有六个轴向±X、±Y、±Z。沿法线外扩时相邻立方体的接缝处会裂开轮廓线里会出现细小的缝隙远看像模型表面有一堆黑色裂纹。这不是外扩距离调大或调小能解决的是网格结构决定的。第三个问题是内轮廓。像素画允许有内轮廓比如角色衣服和脸的交界。但几何外扩只能表达外轮廓它对“颜色变化引起的内边界”毫无感知。如果场景里两个体素颜色差异很大而在同一个网格连续体上几何外扩不会在它们之间画线反过来如果外扩距离没调好还会把本不该有线的内部面挤出线来。所以我的第一建议是如果做的是严肃的3D像素项目趁早放弃Inverted Hull作为主方案最多拿它当阴影或辅助效果。2. 方案选型几何描边、后处理描边和像素对齐描边2.1 几何外扩Inverted Hull的定位应急和原型当然Inverted Hull也不是完全不能用。我不止一次在Game Jam项目里用它做临时轮廓因为搭建快半天就能看到效果特别适合验证美术方向。用在什么场景下够用呢比如轮廓线要求不高、场景本身是平滑低模而不是硬边体素、相机几乎不旋转时。使用Inverted Hull时有几个必须注意的参数外扩距离需要按模型尺寸归一化不能给所有模型用同一个绝对数值否则大模型轮廓粗、小模型轮廓看不见。材质要设置成只渲染背面ZWrite关闭或者调整深度偏移防止轮廓和模型表面互相穿透。轮廓颜色尽量统一走深灰色而不是纯黑纯黑在低分辨率点采样放大后会显得像一块死斑。但它终究不是为像素艺术准备的方案。一旦项目进入正式场景联调比如角色走进同色系房间、相机开始拉远拉近这套东西就会露出破绽。我的经验是用它做两三天原型够用之后就尽快迁移到后处理方案。2.2 屏幕空间后处理描边方向对了但要注意分辨率主体第二种常见做法是在后处理阶段做边缘检测基于深度、法线或颜色梯度用Roberts或Sobel算子找出边界。这是《塞尔达传说旷野之息》这类作品常用的思路。它比几何外扩灵活得多既能画外轮廓也能画内轮廓粗细通过采样半径控制。但我见过非常多像素风格项目在这里犯错直接在最终输出分辨率比如1920×1080上做边缘检测。结果是什么轮廓线会变成一条很细的光滑线条完全不像像素画里的硬边轮廓。更要命的是场景先渲染成三维再在原始高分辨率上后处理然后才缩放成低分辨率效果这条线会被“像素化”过程再次采样产生严重的摩尔纹或者半透明边缘。方向对了但级别不对。边缘检测应该发生在低分辨率缓冲上不是在成品分辨率屏幕上。也就是说你的场景要先渲染到一张低分辨率RenderTexture对应像素艺术的逻辑分辨率在这张低分辨率缓冲上做轮廓检测然后再把它放大到屏幕上。这样轮廓线从生成时就是和像素网格对齐的。2.3 针对像素艺术的正确姿势低分辨率缓冲加阈值检测前面分析了这么多结论已经很清楚为3D像素艺术添加轮廓线的正确主体是低分辨率渲染缓冲本身。像素艺术的视觉基础就是“先低分辨率再最近邻放大”。轮廓线也必须在这一层生成。具体来说实现分为三步把场景渲染到低分辨率RenderTexture分辨率和你想表达的逻辑像素一致比如128×72、160×90、256×144。在这个低分辨率RT上对每个像素采样它周围像素的颜色和深度计算差异度超过阈值就判定为轮廓像素替换成轮廓色。把处理过的低分辨率RT用点采样Point/Nearest放大到屏幕。这套方案的特点非常贴合像素艺术轮廓线天然是整数像素宽宽度通过采样半径和阈值控制颜色和深度的双信号能同时捕捉外轮廓与内轮廓性能开销固定和场景复杂度几乎无关。接下来我用Unity为例把整个流程实现一遍。3. Unity实操用低分辨率缓冲实现像素对齐轮廓线3.1 搭建低分辨率渲染管线含targetTexture的坑先说一个我刚上手时踩的坑如果相机的targetTexture被设置成低分辨率RTUnity的OnRenderImage不会被调用。这是Unity的既有行为文档里写得很隐蔽很多人查半天查不到原因。所以实现思路要分两段场景渲染到低分辨率RT后处理单独处理这个RT并输出到屏幕。我这里用一个后处理相机来做这件事核心C#脚本如下这是基于常见实践整理的框架你可以直接套用using UnityEngine; public class PixelArtOutlineRenderer : MonoBehaviour { public enum BufferScale { QVGA 160, VGA 320, SVGA 480 } public BufferScale pixelHeight BufferScale.QVGA; public Material outlineMaterial; public bool pointFilter true; public bool enableNormalMappingFallback false; private Camera cam; private RenderTexture lowResRT; private RenderTexture presentRT; void Awake() { cam GetComponentCamera(); cam.depthTextureMode | DepthTextureMode.Depth; } void Start() { CreateLowResRT(); } void CreateLowResRT() { int targetHeight (int)pixelHeight; int targetWidth Mathf.RoundToInt(targetHeight * cam.aspect); if (lowResRT ! null) { lowResRT.Release(); lowResRT null; } lowResRT new RenderTexture(targetWidth, targetHeight, 24, RenderTextureFormat.ARGB32); lowResRT.filterMode pointFilter ? FilterMode.Point : FilterMode.Bilinear; lowResRT.name PixelArtLowResRT; } void OnPreRender() { // 低分辨率RT作为主相机的渲染目标 cam.targetTexture lowResRT; } void OnRenderImage(RenderTexture source, RenderTexture destination) { if (lowResRT null || outlineMaterial null) { Graphics.Blit(source, destination); return; } // 先在低分辨率RT上做轮廓检测 presentRT RenderTexture.GetTemporary(lowResRT.width, lowResRT.height, 0); presentRT.filterMode FilterMode.Point; Graphics.Blit(lowResRT, presentRT, outlineMaterial); // 点采样放大到屏幕 Graphics.Blit(presentRT, destination); RenderTexture.ReleaseTemporary(presentRT); } void OnPostRender() { cam.targetTexture null; } }这里面有个关键点场景渲染到低分辨率RT时所有物体的抗锯齿MSAA要么关闭要么后处理阶段自己处理。像素艺术通常不需要MSAA直接保持1x采样边缘的锯齿就是像素风格的一部分。如果你用URP思路略有不同可以用Camera Stacking或中间纹理但核心逻辑不变——先生成低分辨率缓冲再对缓冲做后处理最后放大。3.2 轮廓检测Shader颜色加深度双信号轮廓检测的核心是用一个Shade着色器对低分辨率RT做全屏处理。原理是对当前像素和上下左右相邻像素做比较如果颜色差异或深度差异超过阈值就把当前像素标记为轮廓。我用的ShaderShaderLab风格如下参考了常见的后处理边缘检测思路并在基础上加入了深度信号适合体素场景Shader PixelArt/PixelOutlinePost { Properties { _MainTex (LowRes Buffer, 2D) white {} _OutlineColor (Outline Color, Color) (0.1, 0.1, 0.15, 1) _ColorThreshold (Color Threshold, Range(0, 1)) 0.15 _DepthThreshold (Depth Threshold, Range(0, 0.5)) 0.02 _OutlineWidth (Outline Width (pixels), Range(1, 3)) 1 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; sampler2D _CameraDepthTexture; fixed4 _OutlineColor; float _ColorThreshold; float _DepthThreshold; int _OutlineWidth; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } float ColorDiff(float2 uvA, float2 uvB) { float3 ca tex2D(_MainTex, uvA).rgb; float3 cb tex2D(_MainTex, uvB).rgb; float3 diff abs(ca - cb); return saturate(diff.r diff.g diff.b); } float DepthDiff(float2 uvA, float2 uvB) { float depthA LinearEyeDepth(tex2D(_CameraDepthTexture, uvA).r); float depthB LinearEyeDepth(tex2D(_CameraDepthTexture, uvB).r); return saturate(abs(depthA - depthB)); } fixed4 frag (v2f i) : SV_Target { float2 o _MainTex_TexelSize.xy * _OutlineWidth; float cDiff 0; cDiff max(cDiff, ColorDiff(i.uv, i.uv float2( o.x, 0))); cDiff max(cDiff, ColorDiff(i.uv, i.uv float2(-o.x, 0))); cDiff max(cDiff, ColorDiff(i.uv, i.uv float2(0, o.y))); cDiff max(cDiff, ColorDiff(i.uv, i.uv float2(0, -o.y))); float dDiff 0; dDiff max(dDiff, DepthDiff(i.uv, i.uv float2( o.x, 0))); dDiff max(dDiff, DepthDiff(i.uv, i.uv float2(-o.x, 0))); dDiff max(dDiff, DepthDiff(i.uv, i.uv float2(0, o.y))); dDiff max(dDiff, DepthDiff(i.uv, i.uv float2(0, -o.y))); float isEdge 0; if (cDiff _ColorThreshold) isEdge 1; if (dDiff _DepthThreshold) isEdge 1; fixed4 origin tex2D(_MainTex, i.uv); return lerp(origin, _OutlineColor, isEdge); } ENDCG } } }这段Shader有几个细节我在调试过程中反复踩坑说明一下_MainTex_TexelSize是当前纹理的像素步长对低分辨率RT而言它正好代表“一个逻辑像素”的UV距离再用_OutlineWidth乘上整数倍可以控制轮廓宽度是1像素、2像素还是3像素。必须对四个方向都采样否则线条只出现在半边斜向边缘会断。LinearEyeDepth把深度缓冲转换为视空间距离体素场景中前景和背景的深度差通常非常明显阈值给0.02左右基本就能区分。颜色差异如果只算RGB亮度容易忽略明度相同但色相不同的边界。我这里用的是分量差之和不是亮度差值。如果你要更精细可以转HSV对比。3.3 参数调节与轮廓粗细控制参数这块值得单独聊因为同样的Shader在不同场景下表现完全不一样。我整理了一张调参经验表是从实际测试里总结的参数取值参考我的观察_ColorThreshold0.1~0.25太低会让像素化纹理内部色块也变成轮廓太高则同色系物体边界丢失_DepthThreshold0.01~0.04配合场景尺度体素场景通常0.02就够场景太大需要调高_OutlineWidth1~2128分辨率以下建议1160以上可以2超过3会丢失像素感_OutlineColor深灰/深棕不要用纯黑像素放大后纯黑容易糊用(0.1, 0.1, 0.15)这种偏色更有手绘感另一个控制轮廓粗细的手段是改变逻辑分辨率本身。同样一个模型128×72分辨率下轮廓线1像素在屏幕上会显得很粗如果用256×144同样的1像素轮廓会细一半。所以不要把轮廓粗细和渲染分辨率割裂开调它们是同一条视觉链路的两个环节。如果你的目标是做“粗描边卡通风”我的建议是逻辑分辨率别超过160然后轮廓宽度设为1放大倍数高一点这样像素块大、线条硬朗。相反想做得精致一些比如要配合UI文字或UI图标可以上到256×144轮廓宽度2效果更克制。3.4 体素模型的建模提示顶点分离、UV与法线Shader写完后模型端的配合同样重要。3D像素艺术大多用体素拼搭网格从工具里导出来时往往带着很多“暗病”。如果模型本身法线混乱或者UV有接缝后处理轮廓线条本身不容易受影响因为方案不依赖法线——这是它相对几何外扩的一个优势。但体素模型还是有三个要注意的地方。第一个是顶点法线不必融合。很多工具导出时会给出每个面独立的法线这没问题后处理不使用法线信息所以完全不必花时间去做平滑法线。反而是那些自动平滑了法线的体素模型着色时会出现渐变过渡和像素风的硬切光照风格相冲突。第二个是UV接缝。像素艺术通常用整张像素纹理体素模型每个面分配成独立的UV块纹理贴图时会出现明显的UV缝隙。如果你计划用颜色阈值检测轮廓这条UV缝隙可能会被识别成一条意外的暗线。解决办法要么体素面不用纹理直接用顶点色要么在纹理外围包一圈padding避免采样到透明区。第三个是网格合并。体素模型三角面数量巨大一块10×10×10的立方体就有1200个三角面场景一多就上百万。建议在导入时开启网格优化和合并或者直接把静态模型Bake成Combined Mesh。后处理轮廓方案不关心网格数量但渲染性能仍然和三角形数直接相关。4. 常见问题与排查实录4.1 轮廓线闪烁和爬行问题轮廓线闪烁通常有两个来源。第一个来源是轮廓检测在低分辨率上产生了亚像素级别的微小变化。当相机移动或物体旋转时低分辨率RT上每个像素覆盖的世界空间面积在变化同一个轮廓边缘有时正好落在一个像素内有时被拆成两半导致轮廓线忽粗忽细。这个问题几乎无法彻底消除但可以缓解限制相机最小移动步长或者在俯视视角时开启像素网格对齐把相机位移量化到像素单位。说白了像素风的动态效果就是允许一点“爬行感”这是风格的一部分——只要不是剧烈闪烁都问题不大。第二个来源是深度阈值和颜色阈值处于临界值附近。深度差异在物体边缘是陡变的但在很远的物体会因为压缩精度出现抖动。建议调大阈值留出裕量让“是边缘”和“非边缘”之间有一个清晰的断层不要恰好卡在某个值上。4.2 同色物体之间的边缘检测不到这是后处理边缘检测天生的盲区如果两个物体颜色一模一样深度又几乎相同比如两面紧贴的墙颜色和深度信号都不变化轮廓自然就会消失。几何外扩方案在此时反而更好因为轮廓独立于视觉信号存在。我的处理方法有三个层面场景层面尽量避免两个不同语义的物体颜色完全一致。像素艺术本来就需要颜色区分稍微调整饱和度或明度既解决轮廓问题也提升可读性。Shader层面如果确实需要边界可以引入法线检测作为第三信号。在低分辨率RT上额外输出一张世界法线缓冲检测相邻像素法线夹角超过阈值也标记为轮廓。接受现实像素画里也不是所有同色物体之间都有轮廓线很多像素游戏靠明暗对比波浪线来表达。所以有时不改方案直接调整配色反而是更像素的做法。4.3 透明物体、粒子与UI的干扰透明材质在后处理里比较麻烦主要是因为透明度不会反映在深度缓冲上。我见过的情况一颗半透明的体素药水轮廓线检测把它内部半透明面当成边缘产生一大堆碎线。应对建议透明物体在低分辨率RT上单独渲染一个“遮罩”通道或者在后处理时跳过透明度低于阈值的像素。如果是粒子特效建议让粒子不参与轮廓检测直接叠加在轮廓线之上否则满屏幕都是线和粒子边缘纠缠。UI则千万不要放到低分辨率RT里去。UI应该保持原生像素级别的清晰渲染放在后处理之后叠加。否则任何缩放的UI文字都会变模糊这是玩家最容易感知到的粗糙感。4.4 性能占用与架构取舍这套方案性能开销非常友好。场景渲染到低分辨率RT时像素填充量大幅下降效果是后期和抗锯齿压力一起减轻。轮廓检测阶段处理的像素数等于逻辑分辨率128×72只有9216个像素哪怕每像素采样5次单帧消耗也可忽略。相比全屏高分辨率后处理这个方案在移动端和低端PC上都能跑得很稳。真正要注意的性能点有两个一是深度纹理的开销开启DepthTextureMode.Depth会多一次深度渲染不过体素场景深度缓冲区本来就要生成所以几乎可以忽略二是放大到屏幕的二次Blit如果用点采样就不涉及额外过滤性能开销就是一个全屏纹理读。如果你使用的是低分辨率RT同时叠加后处理还有一个思路值得推荐把轮廓检测和像素化合并在同一个Pass里把放大和颜色空间转换合并。我在项目里就是主相机出低分辨率RT之后只做一次Blit把轮廓线和最终输出一次搞定。4.5 跨引擎移植Godot、Blender的思路这个方案在Unity里能跑通换到其他引擎思路也一样Godot 4用SubViewport渲染低分辨率场景再把ViewportTexture采样到全屏Quad的Shader中做边缘检测放大使用texture_filter nearest。Blender / EEVEE用Render属性把分辨率调低后面接Compositor的Sobel节点或者用EEVEE的outline pass再配合Compositor做阈值处理适合做单张渲染或动画短片。自研引擎或WebGL只要支持离屏RenderTarget和nearest采样这套逻辑完全通用核心就是“低分辨率缓冲 四邻域差异检测”。实现顺序建议先跑通低分辨率RT再加上深度检测最后调颜色阈值和轮廓宽度。每步都验证一轮这样即使最后效果不对也能定位是渲染链路的哪个环节出错。5. 踩坑总结与个人经验最后分享几个我在实际项目里积累的经验不是文档里能查到的东西。第一个经验是永远不要让轮廓线成为Scene视图里唯一分辨画面的手段。轮廓线能框出边界但替不了光照。3D像素艺术需要配合硬边光照阶梯化光照比如物体的顶面亮一些、侧面暗一些轮廓线和明暗同时存在画面才有真正的立体感。加轮廓线之前我总觉得画面“平”后来发现其实是缺少光照层次轮廓线解决了一部分问题但还需要building内部的像素化阴影。第二个经验是必须把轮廓线看作一种风格决策而不是渲染正确性问题。轮廓粗细、颜色、是否保留内轮廓这些决定塑造的是游戏的美术性格。《Obra Dinn》的轮廓和你自己做的一个可爱体素小镇应该用完全不同的参数。我见过很多开发者把轮廓线调得很深、很粗结果整个游戏像一张线稿玩家注意力全被线条吸走了。合适的做法是先调一版参数把游戏里最主要的几个场景截图看看再决定风格尺度。第三个经验是给团队里的美术同事做一套只暴露语义参数的调参面板。与其让美术去改Shader里的阈值不如把几个关键参数暴露成C#脚本里的Slider比如“轮廓粗细”“边缘敏感度”“轮廓颜色”。美术可以在游戏运行状态拖滑块实时查看效果这比在材质面板改来改去高效得多也避免大家手动改坏Shader。至于要不要做开启/关闭轮廓线的对比视图我自己觉得很有用。把轮廓线拿掉看看画面瞬间变平你会立刻理解这条线在3D像素艺术里的价值。如果后续你想继续深入还有两个方向值得探索一个是把轮廓线配合深度雾一起来做让远景轮廓逐渐被空气透视吞掉适合大场景探索类游戏另一个是做动态轮廓颜色比如角色被击中时轮廓闪红、选中物体时轮廓变金这套低分辨率缓冲方案天然支持把_OutlineColor改成代码动态赋值就行。希望这篇内容能帮你把3D像素艺术的线条立起来少走几步我走过的弯路。
返回列表