ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

3D像素艺术轮廓线:先像素化再做边缘检测的稳定方案

3D像素艺术轮廓线:先像素化再做边缘检测的稳定方案 我在给一个3D像素风项目做美术升级时第一次认真研究轮廓线这件事。当时项目里所有角色和场景都是方块堆出来的体素风格光照和贴图都调得差不多了但整体画面总觉得散的慌角色和背景糊在一起没有那种像素游戏该有的利落感。后来给所有物体加了轮廓线画面瞬间收紧了该突出的层次一下就出来了。这篇就聊聊我在3D像素艺术里加轮廓线踩过的坑、试过的方案以及最后稳定落地的那套思路。先说结论为3D像素艺术加轮廓线最稳的做法不是去改模型也不是简单的屏幕后处理描边而是先像素化、再在低分辨率空间里做边缘检测。这个方案既能保证轮廓线和色块对齐又能避免传统描边在像素网格上产生一堆乱七八糟的断线。下面我一步步拆开讲。1. 先搞清楚我们要的轮廓线到底是什么1.1 轮廓线的两种来源很多教程会把轮廓线统称为描边或者Outline但实际操作中轮廓线其实有两种完全不同的来源。一种是几何轮廓它来自物体本身的形状边界。比如一个球体的剪影那条最外圈的弧线就是几何轮廓它是由视线方向和表面法线的相对关系决定的。另一种是材质/颜色轮廓它来自相邻区域的颜色差异或明暗差异。比如一个红色方块紧挨着一个蓝色方块它们之间的分界线就是颜色轮廓。游戏渲染里常见的描边方案要么走几何路线要么走图像路线。几何路线很好理解把模型沿法线方向微微胀大一圈然后反向渲染背面或者直接做一个视线方向与法线方向夹角接近90度就涂黑的判断。图像路线则是把摄像机渲染出来的画面当成一张普通照片在后处理阶段用sobel这类卷积算子找出画面里深度或颜色变化剧烈的地方涂成黑色。这两种路线本身都不新鲜真正麻烦的是它们在3D像素艺术里都会出问题。1.2 3D像素艺术的反常规之处3D像素艺术和普通3D游戏的最大区别在于最终呈现在屏幕上的并不是渲染器的原始输出而是一个被像素化过的采样结果。也就是说无论你的模型有多精细画面最终会被降采样到很低的像素网格上每个格子内部颜色统一呈现出类似马赛克或点阵的效果。这就带来一个问题普通的深度或颜色边缘检测是在原始分辨率的画面里做的检测出来的边缘像素宽度是1个原始像素。而3D像素艺术的画面本来就是一格一格的大像素如果直接在原始分辨率上描边再把描边结果一起像素化你会发现边缘线在格子内部出现、粗细不均匀、还会偏移。就好比你在一张高清照片上用红色描了边然后整张照片被打成马赛克那条红线会碎成一段一段的有的格子红、有的格子不红看起来像得了皮肤病。另外3D像素艺术的表面大多由方块拼成法线变化非常剧烈。相邻两个面如果朝向不同法线夹角很容易超过45度如果用默认的法线阈值去做边缘检测会把每一条体素拼缝都当成轮廓线画出来。结果就是整个场景像被一张蜘蛛网罩住完全失去了该有的简洁感。所以做3D像素艺术轮廓线第一步不是选描边算法而是想清楚描边应该发生在像素化之前还是像素化之后。2. 方案选型三条路线我为什么最终选了这一条2.1 路线一几何轮廓背面放大法最终选择之前我试过几何轮廓方案。做法很标准准备一个专门画轮廓的Pass把物体沿法线方向向外扩张一点然后只输出纯色再用ZTest设置为Greater把被原物体挡住的背面画出来。因为这个Pass画的是背面像素正面物体遮挡的部分会被剔除剩下的就是一圈外扩的轮廓。这套方案在普通3D游戏里效果很好边缘干净而且完全不受屏幕分辨率影响。但在3D像素艺术里有两个问题特别棘手。首先是轮廓宽度不能精确控制外扩量是在世界空间做的但像素化之后每个屏幕像素覆盖的世界空间大小是变化的同一个外扩量在不同距离上可能占用2个像素也可能只占1个像素有时干脆把一整格像素都盖掉导致轮廓时粗时细。其次是体素模型的拓扑很碎一个看似简单的方块角色实际由几十个小立方体拼成沿法线外扩时每个小立方体各自膨胀交界的边缘会撕裂或互相穿透描边看起来脏。所以几何轮廓方案我只做了一版demo就放弃了。它适合模型连续、表面光滑的角色但不太适合我们这种一堆方块摞起来的体素艺术。2.2 路线二常规后处理Sobel描边第二套方案更主流在后处理阶段拿到深度纹理和法线纹理对每个像素做sobel卷积检测深度或法线的梯度梯度超过阈值就输出轮廓色。网上能搜到的描边Shader基本全是这个套路代码也就几十行。这套方案在普通3D场景里非常成熟但放到3D像素艺术里有个致命伤就是前面说的边缘对齐问题。因为后处理是在渲染画面的原始分辨率下执行的而你最终要像素化中间存在一个分辨率转换。只要这个转换发生边缘线就不可能和像素格子完全对齐。我实测下来最直接的表现是轮廓线出现在色块边缘的内部或中间而不是刚好压在两个色块的分界线上总差那么半格像素。对于追求整齐划一的像素风来说这种半格偏移非常扎眼。2.3 路线三先像素化再做边缘检测最终选择真正敲定下来的方案其实是一个顺序上的调整先把画面降采样到目标分辨率让所有画面内容变成一格一格的像素色块然后在这个低分辨率画面上做边缘检测。这样做有几个立竿见影的好处后处理检测到的边缘天然就是1个目标像素宽和像素格子完美对齐。深度阈值和法线阈值的判断对象是格子而不是原始像素相当于自动消除了原始画面里的高频噪声边缘检测结果非常干净。不管远处近处的物体描边都以固定的格数呈现符合像素艺术的硬边特征。代价是要多维护一张低分辨率深度纹理和法线纹理并且边缘检测的卷积采样只能在低分辨率缓冲上进行。但这点开销在像素化游戏里完全可接受因为我们本来就是故意降分辨率来营造风格的额外的计算都在低分辨率上跑反而比高分辨率后处理更省。3. 实操从渲染管线改造到核心Shader实现3.1 第一步把渲染管线拆成像素化前和像素化后要落地方案三首先得明确我们说的像素化发生在哪一步。我项目用的是类URP结构简化的流程是场景正常渲染一张高分辨率画面A分辨率等于屏幕分辨率。增加两个低分辨率缓冲一个存场景的颜色一个存场景的深度和法线。颜色缓冲直接通过降采样得到低分辨率颜色图深度法线缓冲在低分辨率下重新采样一次G-Buffer数据得到。这里有个关键点像素化一定用最近邻采样不能双线性。双线性会在色块之间插值出过渡色破坏像素艺术一格一色的硬边感。我见过很多项目在降采样阶段不小心开了双线性过滤结果场景看起来像马赛克糊了层雾其实就是采样模式不对。然后是法线纹理。如果引擎没提供低分辨率法线G-Buffer最简单的办法是在全屏Pass里把深度纹理解包成世界坐标再用屏幕空间差分算出法线方向。公式大概是float3 normal; float2 texel 1.0 / _LowResScreenSize; float3 worldPosCenter ReconstructWorldPos(uv, depth); float3 worldPosRight ReconstructWorldPos(uv float2(texel.x, 0.0), depth); float3 worldPosUp ReconstructWorldPos(uv float2(0.0, texel.y), depth); normal normalize(cross(worldPosRight - worldPosCenter, worldPosUp - worldPosCenter));用屏幕空间差分重建的法线在普通模型上会有点糙但对3D像素艺术来说刚刚好——因为在低分辨率下一个像素格子内部本来就是平面差分出来的法线正好代表这个格子的朝向不会出现子像素噪声。这就是先像素化再算法线的另一个好处法线天然被平滑成一个格子一个值。3.2 第二步边缘检测Shader怎么写有了低分辨率颜色图、深度图、法线图之后核心描边Shader就不复杂了。我的实现思路是对一个目标像素采样它周围8个邻居的深度和法线如果深度差超过阈值或者法线夹角超过阈值就判定该像素为轮廓像素混合输出轮廓色和原色。下面是一段可以作为参考的伪Shader代码注释里我标了关键参数// 输入低分辨率颜色图 _MainTex、低分辨率深度图 _CameraDepthTexture、 // 低分辨率法线图 _CameraNormalsTexture或差分重建 // 参数 // _PixelSize目标像素对应的UV步长等于 1.0 / 低分辨率尺寸 // _DepthThreshold深度差阈值建议按线性深度差值调试 // _NormalThreshold法线夹角阈值单位度建议 20~60 // _OutlineStrength描边强度线性混合系数 // _OutlineColor描边颜色 float4 OutlinePass(VertexOutput input) : SV_Target { float2 uv input.uv; // 中心像素深度线性深度 float centerDepth LinearEyeDepth(SAMPLE_DEPTH(uv)); float3 centerNormal SampleNormal(uv); float edge 0.0; // 3x3邻域用两个for循环展开这里简化成8个偏移 float2 offsets[8] { float2(-_PixelSize.x, -_PixelSize.y), float2(0.0, -_PixelSize.y), float2(_PixelSize.x, -_PixelSize.y), float2(-_PixelSize.x, 0.0), float2(_PixelSize.x, 0.0), float2(-_PixelSize.x, _PixelSize.y), float2(0.0, _PixelSize.y), float2(_PixelSize.x, _PixelSize.y) }; for (int i 0; i 8; i) { float2 sampleUV uv offsets[i]; // 深度差判断 float neighborDepth LinearEyeDepth(SAMPLE_DEPTH(sampleUV)); float depthDiff abs(neighborDepth - centerDepth); edge max(edge, step(_DepthThreshold, depthDiff)); // 法线夹角判断 float3 neighborNormal SampleNormal(sampleUV); float cosine saturate(dot(centerNormal, neighborNormal)); float angle acos(cosine) * 57.29578; // 弧度转角度 edge max(edge, step(_NormalThreshold, angle)); } half3 color tex2D(_MainTex, uv).rgb; half3 finalColor lerp(color, _OutlineColor.rgb, edge * _OutlineStrength); return half4(finalColor, 1.0); }注意这里我用的是max而不是平均这是刻意的。边缘检测是二值决定只要8个邻居里有任何一个的深度或法线差异超过阈值这个像素就应当被视为轮廓点不能因为其他7个邻居都很相似就把它平均成非边缘。用max可以保证强边缘一定被保留下来。深度阈值和法线阈值在草稿阶段可以先各给一个保守值深度阈值0.03法线阈值45度。0.03是以线性深度差为单位的实际项目中最好换算成相机近远裁剪面下的归一化差值或者干脆存一份世界空间深度再比较。至于法线阈值45度很适合体素风格——一个正方体相邻面的法线夹角就是90度两个对角面夹角更大45度阈值可以很从容地把面与面的转折和同一平面上微小的凹凸区分开。3.3 第三步轮廓颜色、宽度与Z轴方向的控制后处理描边有一个天然优势轮廓颜色可以随意改不必依赖模型材质。我在项目里把轮廓色做成一个全局参数根据场景氛围动态调整。像素风日常给黑色夜晚场景给深蓝洞穴里给偏红的暗调这样不需要每个材质都改一遍。轮廓宽度也很有讲究。我建议把宽度控制在1个低分辨率像素也就是描边刚好占一个格子。这是最像素艺术的宽度看起来像漫画里的硬勾线。如果你想要更粗的2格轮廓可以在检测出边缘后再做一次膨胀把轮廓像素的周围也涂成轮廓色。膨胀次数和轮廓像素数成正比但注意膨胀会让所有轮廓变粗包括某些你不想突出的内部细节所以一开始先别贪宽。还有个容易忽略的坑高度落差大的物体轮廓可能互相遮挡。比如一个方块后面紧挨着另一个方块两者深度差很小深度阈值设得不够灵敏描边就不会把前后两层分开。这时候单靠深度不够必须配合法线差。反过来说如果深度阈值设得太大又会把本来连着的表面也割开。经验做法是先调深度阈值把前后景分离做对再用法线阈值去补同一平面上的转折两者分工不同不能混用。4. 参数调优与可视化验证4.1 到底哪几个参数最影响观感很多新手一上来就盯着描边颜色调半天其实颜色是最不重要的。最影响观感的三个参数依次是深度阈值、法线阈值、像素化分辨率。深度阈值决定前后层次的分离强度。阈值越小前后物体分得越开但小到一定程度同一次渲染中的雾效、小台阶、轻微地形起伏都会变成轮廓画面会很吵。法线阈值决定转折面的分离强度。阈值越小描边的面越多适合风格化明显的漫画感场景阈值大则只保留最明显的转折画面更素净。像素化分辨率则决定整张画面的格子大小格子越大像素感越强但轮廓线能表达细节的能力也越弱。这三者是一个联动关系比如你提高了像素化分辨率格子变小同样的深度阈值可能就会把更多细节框出来需要重新平衡。4.2 参数配置速查表下表是我在几个典型场景里调出来的一组起点值以低分辨率画面尺寸为980x540左右、相机FOV约60度为基准。你有类似设置时可以先套用这一组再微调。场景类型深度阈值法线阈值轮廓宽度轮廓颜色户外白天0.0345度1格黑色透明度50%洞穴/室内0.0235度1格深棕色透明度60%夜晚街景0.0450度1格深蓝色透明度40%主城大场景0.0240度1格近黑透明度45%透明度的实现很简单不要用纯色直接把原色覆盖掉而是让轮廓色和原色进行lerp混合。纯黑全覆盖在像素风里会很死板稍微透一点让底色透出来轮廓线会更有手绘勾线的味道。我一般把透明度压在40%到60%之间特殊场景才会到100%。4.3 验证方法一张测试场景快速判断调参时最忌讳在正式场景里反复改来改去因为正式场景信息量太大你根本分不清哪些线条是符合预期的。我习惯做一个固定测试场景一个红色正方体放在浅灰色地面中央正方体旁边放一个球体地面随机铺十几块大小不一的立方体背景是天空盒。正方体本身应该有一圈清晰的轮廓四条竖直棱线明显顶面边缘也要完整。球体应该呈现一圈平滑的轮廓但球体表面本身的明暗变化不会造成碎线。如果你的方案里球体表面出现了很多鳞片状短线说明法线阈值太低或者像素化前的高频信息没滤干净。地面上那些大小不一的立方体每块都应该有独立的轮廓同时相邻块的高度差很小的话不应被深度边缘误判成轮廓。这四类检查点覆盖了轮廓线最常见的四类问题主体轮廓、曲面轮廓、细节轮廓、层级轮廓。全部通过再回到正式场景里微调颜色和透明度效率会高很多。5. 常见问题与排查技巧实录5.1 轮廓线抖动/闪烁轮廓线在动态镜头里闪烁是像素风格游戏最常遇到的坑。原因往往不是描边算法本身而是降采样时深度或法线的采样点出现了半像素偏移。所谓半像素偏移就是你在低分辨率画面上采样某个格子时因为UV坐标落在了格子边界上导致采样结果在相邻两个格子之间反复横跳进而让边缘检测结果不稳定。解决办法有两个都亲测有效。一是确保低分辨率纹理的采样UV按(像素坐标 0.5) / 分辨率来计算让每个采样点准确落在格子中心。二是在深度比较前加一个极小的epsilon容差比如0.0005消除浮点精度造成的随机跳动。如果做完了还是闪多半是深度纹理精度不够把深度缓冲从16位换成24位或32位浮点基本能根治。5.2 轮廓线断层与粗细不均很多入门教程里出现过的经典问题远处物体轮廓线能看清近处物体轮廓线反而非常粗走到跟前甚至糊成一团。这是因为后处理描边是基于屏幕空间像素的而屏幕空间像素在近处投射到世界空间时覆盖的面积小所以在同样的深度阈值下近处物体深度梯度大容易被判定为边缘远处物体深度梯度小边缘检测就不敏感。针对这个问题我的方案是把深度阈值改成非线性。简单做法是先把线性深度做个归一化depth01 saturate((linearDepth - near) / (far - near))然后乘以一个距离系数让近处用更小的阈值、远处用更大的阈值。当然更好的办法是引入像素到相机的距离作为权重让描边的世界空间厚度近似恒定。但说实话在3D像素艺术里我反而更推荐保持屏幕空间固定宽度因为像素风格本来就是格子化的视觉语言近大远小不会太违和反而是画面的风格特征。5.3 描边与雾效、透明物体的冲突像素风游戏普遍喜欢用雾效营造氛围。但雾效会让深度图变得不可靠——远处物体被雾染白前后景的深度差在颜色上看不出来但深度值还是存在的描边依然会出现。结果就是远处雾里的物体轮廓像一圈黑色水印浮在雾气上很突兀。我建议在雾效渲染之后再做描边。既然深度已经拿到了描边判断的是几何深度差不受雾的颜色影响。但如果你想雾越深、轮廓越淡可以在描边Shader里采样雾的浓度用浓度来衰减_OutlineStrength。这样远处物体在雾里若有若无轮廓也若有若无视觉上非常舒服。透明物体则更麻烦。如果透明物体也写入深度buffer描边会把透明物体和后面物体的轮廓混在一起如果不写入深度又无法对齐。我的经验是透明物体统一走一个只做颜色、不参与边缘检测的Pass他们可以有颜色叠加但不产生轮廓。这样至少不会出现大面积透明玻璃上密密麻麻全是描边的奇怪画面。5.4 性能优化经验最后说说性能。3D像素艺术本来就有低分辨率这个天然优势描边检测又跑在低分辨率缓冲上开销并不大。8个邻居的采样在低分辨率下完全跑得起。但如果你的像素化分辨率很高比如要支持4K下两倍像素化那么可以把3x3卷积换成十字形采样只采样上下左右4个邻居视觉上差异很小性能却省了近一半。另外深度图和法线图如果不需要每帧都更新比如场景完全静止或者描边是作为角色专用风格可以缓存起来隔几帧算一次。不过动态游戏里不建议这么干因为描边延迟会明显。最重要的是别在描边Shader里做循环展开前忘了[unroll]指令编译器如果没把循环展开动态索引会在GPU上拖后腿。写到这里差不多把方案、代码、调参、踩坑都过了一遍。最后分享一个我个人一直保留的调试习惯每次调整参数我都会截一张对比图存档并记下场景的时间、天气和相机距离。因为像素风格的轮廓线非常依赖场景整体密度同一个参数在开阔地形和狭窄巷道里的观感完全不同。攒上两周截图回看你会很清楚自己偏好的描边手感和浓度方向这比临时到现场改参数要稳得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进