ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Unity URP风格化渲染系统实战:从Toon Shader到NPR全流程

Unity URP风格化渲染系统实战:从Toon Shader到NPR全流程 聊一聊我最近在搭的一套风格化渲染系统。项目做到中期美术那边明确提了要求所有角色的卡通感不能只靠模型贴图撑着光照一变就露馅。于是我把之前散落在各个材质里的Toon Shader、描边、后处理效果全部收拢统一做成了一套可配置的渲染系统。这篇文章我会从为什么需要一套独立系统、总体架构怎么设计、核心技术点如何拆解、实测踩坑以及性能预算这几个部分展开适合正在做NPR方向或者想在Unity SRP里落地风格化渲染效果的开发者参考。我自己主要是在Unity URP下做的这套东西不过其中大部分思路换到UE、Godot或者其他自研引擎也能复用。1. 为什么风格化效果不能靠单个Shader扛下来1.1 风格化是系统问题不是材质问题很多人一提风格化渲染第一反应就是写一个Toon Shader把NdotL做一个step再加一层描边。这套东西作为原型很快跑起来也好看但放进一个完整项目里马上会碰到问题。原因很简单风格化是对整帧画面的约定——光照怎么分阶、边缘怎么勾勒、暗部怎么偏色、高光怎么切割、画面整体色调怎么统一这些必须跨材质、跨模型、跨相机视角一起决策。单个Shader管不到相邻物体的轮廓管不到雾和阴影的衔接更管不到整帧画面的色彩倾向。打个比方单材质思路像给每件家具单独刷漆刷完发现客厅的沙发是亮黄、茶几是暗紫、墙还发绿屋子整体没法看。风格化渲染系统更像一个装修方案先定主色板、副色板、线条宽度、布料质感再让每件家具共享同一套规则。这样角色身上的皮肤、头发、衣服、配饰才能看起来是同一个世界、同一种光照的画风而不是几张贴图硬拼在一起。1.2 我遇到的三类典型痛点第一类是参数散落。刚开始做原型时每个材质里都挂着_OutlineWidth、_ToonStep、_RampColor这些参数美术在50多个材质里手工填结果全场景有13种不同的描边宽度和暗部阈值。稍微改一下光照方向有的角色暗部已经压到很深有的还亮着整个画面割裂感极强。参数没有一个统一入口本质上是没法维护的。第二类是特性互相打架。独立加描边的Shader和主光照Shader混在一起后描边的剔除状态、深度写入跟半透明材质冲突后处理做的描边又和角色自身边缘叠加成双线。我记得最早版本里角色戴的围巾边缘出现了一条莫名其妙的亮线查了半天才发现是几何描边写了深度、后处理边缘检测又把这条深度断裂识别成轮廓两边叠加出来的。第三类是光照一致性。一个角色往往切成多个材质头发、皮肤、衣服各自用不同的Ramp贴图光照分部方式也不完全一样但色阶切换的位置必须对齐。如果不统一管理角色身上就会出现皮肤已经进入暗部衣服还在亮部的分裂感。美术管这个叫阴阳脸做二次元项目绝对不能忍。所以做系统之前需求其实很清楚需要全局统一的风格化配置ColorRamp资产、描边强度、阴影色、后处理阈值每个材质只保留局部覆盖能力渲染管线的光照、描边、后处理三个阶段要能共享同一套参数同时还得留出性能档位方便移动端降级。2. 这套系统的总体架构和渲染链路2.1 分层光照层、材质层、后处理层我把整个系统分成三层。材质层负责物体自身属性Ramp贴图、阴影色、高光形状、描边宽度、局部噪声强度。光照层负责所有物体共享的光照预算主光源方向、主光颜色、环境光强度、阴影衰减和阴影色。后处理层负责整个画面的统一风格屏幕空间描边检测、色调映射、纸纹噪声、颜色分级。分层的好处是职责清晰。材质层不用关心光源在哪它只需要回答我的阴影色偏蓝紫、我的高光是菱形光照层也不用关心物体材质它只负责今天的光照角度、色温、阴影软硬后处理层不关心具体角色它只管整帧深度/法线在哪产生轮廓、整体色调要不要偏暖。如果某天美术想把所有角色的阴影从冷色改成暖色只需要改光照层的一个参数而不是逐个材质去翻。2.2 风格化参数怎么在渲染管线里流转在Unity SRP里这套系统以一组RenderFeature为核心。先写一个StylizedProfile资产里面存放全局默认配置比如ColorRamp贴图、全局描边宽度、阴影色、后处理检测阈值、阴影平滑度等。每个材质只放局部覆盖参数渲染物体时代码把全局配置和物体局部参数合并再通过MaterialPropertyBlock传给Shader。var block new MaterialPropertyBlock(); float matOutline mat.GetFloat(_OutlineWidth); bool hasLocalOverride matOutline 0f; block.SetFloat(_OutlineWidth, hasLocalOverride ? matOutline : profile.outlineWidth); block.SetFloat(_ShadowSmoothness, mat.GetFloat(_ShadowSmoothness)); renderer.SetPropertyBlock(block);这里有一个关键选择为什么用MaterialPropertyBlock而不是全局Shader.SetGlobal因为不同物体对描边宽度、阴影色确实需要局部差异全局参数只适合放整体强度整体色偏这类东西。MaterialPropertyBlock在SRP Batcher下基本不增加额外DrawCall性价比很高。整个渲染链路的顺序也很讲究BasePass先做色阶光照和阴影后处理描边放在管线最后。只有让后处理阶段拿到完整场景的深度和法线才能做整帧轮廓检测而且不会被半透明UI和屏幕空间光效破坏。2.3 为什么用SRP的RenderFeature而不是独立管线其实也有朋友问过为什么不干脆写一个完整的独立渲染管线。我的态度是没有这个必要。团队里大家已经熟悉SRP框架Shadow、SRP Batcher、GPU Instancing都能直接复用。RenderFeature的粒度正好够用——插入一个绘制描边的Pass、插入一个全屏后处理的Pass都不需要动引擎底层性能和可维护性都在可接受范围。如果是UE项目可以用Subpass或PostProcess Volume的机制做同样的事自研引擎通常就是CommandBuffer里插入回调。关键是分层参数流转这套骨架引擎只是载体。3. 核心算法拆解色阶光照、描边、高光阴影3.1 色阶光照从NdotL到可控色带最标准的做法是计算NdotL把它映射到Ramp贴图的U坐标采样出离散色带。下面是一个精简版的片段着色器逻辑half4 frag(Varyings IN) : SV_Target { float3 N normalize(IN.normalWS); float3 L normalize(_MainLightPosition.xyz); // 半兰伯特映射让暗面不至于直接clip也方便ramp控制 float ndl saturate(dot(N, L)); float rampU ndl * 0.5 0.5; // 2D Ramp贴图U方向控制光照分阶V方向可以塞阴影过渡或高光辅助信息 float band SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(rampU, 0.5)).r; half3 albedo SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv).rgb; half3 ambient SampleSH(N) * _AmbientStrength; half3 directLight _MainLightColor.rgb * band; half3 color albedo * (directLight ambient); return half4(StylizedTonemap(color), 1.0); }为什么用Ramp贴图而不是直接用step函数因为step只能给固定阈值写成代码之后美术完全没法调。Ramp贴图能直接被美术编辑想画成两段、三段、五段都行还能精确控制过渡软硬。一张2D Ramp其实把两条1D曲线光照阶梯和阴影形状打包进一张图采样开销只有一次但可调性高得多。这里还要注意半兰伯特的作用。直接NdotL是-1到1映射到0到1之后暗部边缘容易有数值截断半兰伯特先做0到1的压缩暗部过渡更安全。Ramp贴图的U方向是按0到1设计的半兰伯特配合起来正好。3.2 描边几何外扩和屏幕空间检测的实际取舍描边方案我做了对比两种主流方式各有明显偏向方案优点缺点几何外扩Inverted Hull粗细可控、可以和材质充分融合、可以逐顶点控制模型接缝处会撕裂、凹角会堆叠、渲染需要多一遍DrawCall屏幕空间后处理边缘检测全场景覆盖、边缘连续、不受模型拓扑影响难以逐物体控制粗细、吃显存带宽、需要额外深度法线RT几何外扩的做法是把模型渲染两遍第一遍用背面绘制顶点沿法线方向往外推一点输出单色描边第二遍正常绘制正面盖住中间区域露出来的一圈就是轮廓线。实现上我建议在视图空间计算外扩方向而不是模型空间。原因是非均匀缩放会让模型空间法线变形视图空间的法线转换会经过逆转置矩阵结果更稳定。struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 color : COLOR; }; v2f vert(appdata v) { v2f o; float3 viewNormal mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); float2 screenOffset TransformViewToProjection(viewNormal.xy); // 顶点色R通道作为宽度权重_OutlineWidth是屏幕比例单位 float width _OutlineWidth * saturate(v.color.r 0.001); o.pos UnityObjectToClipPos(v.vertex); o.pos.xy screenOffset * width * o.pos.w; return o; }注意最后乘了o.pos.w这是让描边宽度在屏幕空间近似恒定像素宽度的关键。如果不乘w同一个_OutlineWidth在不同景深下宽窄变化会非常明显。屏幕空间后处理描边则是在全屏Pass里同时采样深度和法线用Sobel或者Roberts算子检测相邻像素的深度差、法线夹角差超过阈值就算边缘。这个方案更适合场景和远景轮廓因为它不会受模型接缝影响。缺点是没法单独给某个角色加粗描边所有边缘一视同仁。我项目里最终用的是混合方案角色主模型用几何外扩配顶点色控制各部位粗细场景和远景用屏幕空间后处理补轮廓。两者之间用距离衰减做平滑过渡近处角色几何描边权重高远处场景轮廓权重高。这个折中方案修掉了大量边界情况代价只是多一次全屏Pass的GPU消耗。3.3 卡通高光与阴影过渡的控制逻辑卡通高光不能直接用BlinnPhong的连续高光那样看起来太塑料。正确做法是把NdotH做成一个连续值然后用smoothstep做阈值截断保留一小段过渡渐变。核心参数是_SpecThreshold和_SpecSmoothness前者控制高光范围多大后者控制边缘多硬。float nh saturate(dot(N, H)); float specMask SAMPLE_TEXTURE2D(_SpecMask, sampler_SpecMask, IN.uv).r; float spec smoothstep(_SpecThreshold - _SpecSmoothness, _SpecThreshold _SpecSmoothness, nh) * specMask;加了specMask之后高光形状就不只是圆形了。美术可以在贴图里画出细长的、菱形的、甚至是两道平行的高光这是日系三渲二常用的手段。想要更极端的赛璐璐风格还可以在高光后面再加一层半色调颗粒让高光边缘带一点网点感。阴影过渡这边我强调一个容易忽略的点主光源的ShadowMap输出的是0到1的衰减值但在风格化渲染里不能直接拿这个灰度当阴影因为真实阴影贴图有大量噪声和精度误差直接乘上去色阶边界会抖。正确做法是把Shadow Attenuation送到Ramp贴图的V方向用它和色阶信息做混合让阴影过渡变成可控的半阴影带。4. 让画面活着的细节暗部色彩、边缘光、手绘质感4.1 暗部颜色不是纯黑而是偏色很多初版卡通渲染的暗部是黑色或者是一个纯灰贴图。这样出来的画面很死像纸片堆在一起。风格化系统里的暗部应该由两张图协同Ramp贴图控制明度阶梯阴影色贴图控制色相偏移。太阳底下暗部偏蓝紫室内暗部偏暖棕这是美术非常常见的要求。实现上我会给Ramp贴图单独配一个_ShadowTint颜色在Shader里跟ramp结果做一次乘加。这个tint不是固定的而是随光照色温动态调整。比如中午主光偏白阴影Tint就偏冷色黄昏主光偏暖阴影Tint就偏暗红褐色。这里有个我自己的经验亮部用略带暖意的白暗部用冷色两个色板形成的冷暖对比比单纯的黑白灰度更有二次元通透感。4.2 边缘光的风格化处理PBR里的菲涅尔边缘光是物理现象用来模拟光掠射物质表面的效果。风格化渲染的边缘光完全不是一回事它是一道明确的轮廓强调带明显装饰性。算法还是那套fresnel pow(1 - saturate(NdotV), _FresnelPower)但参数选型很不一样。PBR常见_FresnelPower是1到3过渡很宽像一层蜡风格化我通常调到4到8让过渡非常窄接近一条光带。这样角色受光面边缘会有一条白亮的轮廓把身体从背景里撬出来。再进阶一点的做法是双边缘光上半身边缘光受主光颜色影响偏暖下半身边缘光受天光颜色影响偏冷混合出来的轮廓线层次比单色丰富得多。还有一个实用的变体按模型高度给边缘光强度做Mask这样角色走动时边缘光不会因为NdotV变化突然闪烁。4.3 噪声和笔触扰动怎么加才不脏手绘感最容易翻车的地方是噪声加太猛画面变成老电视雪花。问题不在噪声本身而在频率选择。高频噪声直接扰动UV会让色阶边界变成一堆像素碎点正确思路是低频优先用3到5张大面积平铺的0到1噪声图对UV做2%到5%幅度的扰动让色阶边界不规则但不碎。float2 wobbleUV IN.uv (SAMPLE_TEXTURE2D(_PaperNoise, sampler_PaperNoise, IN.uv * 0.3).rg - 0.5) * _WobbleStrength; float band SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(rampU, 0.25)).r;另一种办法是扰动光照边界而不是UV本身把rampU的输入加上低频噪声色带边界会变成毛边很像手绘笔触扫过去的边缘。注意不要在金属、皮肤这类需要干净质感的区域叠加太强噪声最好用一张Mask贴图按区域控制噪声强度。我见过一个项目把噪声权重做得很好角色衣服是细腻的画布纹理面部皮肤几乎无噪声头盔上则有明显的笔触刷痕看起来就是一张立绘而不是一坨滤镜。5. 实测中绕不开的坑与排查思路5.1 描边在相机距离变化时的粗细抖动现象最经典角色站远之后描边变成一条粗黑线走近又细得看不见。根因不在模型而在外扩偏移量没有正确处理投影空间的深度衰减。我的排查链路是这样走的先确认外扩方向是在视图空间算的而不是模型空间。非均匀缩放的模型用模型空间法线外扩描边会变形出奇怪的凸起。再检查偏移量有没有乘clip坐标的w。之前代码里o.pos.xy screenOffset * width * o.pos.w就是干这个事的少了w远处描边会指数放大。最后检查描边Pass的Cull Front和ZWrite有没有被其他状态污染。如果描边Pass不小心开了ZWrite Off又排在透明队列会出现描边被背景穿透的情况。修好之后用屏幕空间恒定像素宽度的目标去验证相机从1米退到50米描边宽度变化应该控制在15%以内。剩下的偏差可以用一个手动距离系数补偿比如_OutlineDistanceFix lerp(1.0, 0.6, saturate(distance / 50))。5.2 色阶色带边缘的闪烁和拉链现象是硬色阶边界在移动端会抖动出现一条一条的拉链状像素按帧闪。这个坑我排查了很久最后定位到三个层次首先是Ramp贴图本身。贴图如果开启了mipmap色带边界在低分辨率mip层会混合出中间过渡色运动时采样结果抖动。解决方法是让美术在Ramp贴图里把色带边界画出1到2像素的过渡渐变不要从0直接硬切到1。这个渐变成本很低但能大幅缓解锯齿和闪烁。其次是阴影贴图精度。如果主光ShadowMap的Cascade跨度设置太大远景阴影数值精度不足送到Ramp采样时会来回抖。建议减小Cascade跨度或者给阴影采样加一层浅度抖动dither。最后是Shader采样的SRGB/Linear设置。Ramp贴图如果被引擎当成sRGB纹理解码而场景又跑在Linear色彩空间色带边界会出现暗部和亮部同时偏色的问题。把Ramp贴图改成Linear采样后闪烁基本消失。这一步很小但很多人不会往贴图格式上想。5.3 后处理描边和深度范围纠缠不清后处理描边最头疼的问题人物靠墙时人物和墙之间的深度差会产生一条很硬的粗线把角色和背景焊在一起远处山脉远看轮廓也全是碎线。问题根源是深度差在不同距离下的敏感度不一样近处深度值变化大远处深度值变化小。直接把深度差套阈值必然导致近处过强、远处过弱。我的排查方法是把变量拆开单独调。先关掉法线差异项只保留深度项把_EdgeThreshold调到近处边缘刚好成立再关掉深度项只看法线项观察物体轮廓是否清晰。最后两个阈值做加权合成float edge saturate(depthDiff * _DepthScale normalDiff * _NormalScale); edge pow(edge, _EdgePower); edge smoothstep(_EdgeThreshold, _EdgeThreshold _EdgeSmooth, edge);实际操作中深度项和法线项的敏感度最好分别给到两个不同精度级别深度阈值负责大色块的边界法线阈值负责小转角连续边缘。另外边缘检测前对深度和法线做一次3x3模糊可以大幅压低碎线噪声这个模糊的开销不大但边缘连续性提升非常明显。5.4 半透明物体在风格化管线里的排序陷阱头发、玻璃、水面这类半透明物体在风格化管线下最容易出问题。现象是描边一会儿在前面一会儿消失或者半透明物的描边把背后物体盖住像描边穿模。排查后发现根因有三个一是描边Pass通常是ZWrite On而半透明主Pass多半ZWrite Off同一个物体两遍Pass的深度写入状态不一致二是描边的渲染队列往往在Opaque队列半透明物体靠后深度信息互相干扰三是后处理描边检测深度断裂时半透明物和背景之间的深度跳变会被识别成轮廓但实际看过去根本不应该有边。修复策略我是分材质处理的。头发这类需要最后呈现为完整半透明块的材质用深度预写再绘制的双Pass方案第一个Pass只写深度不输出颜色第二个Pass做正常半透明混合。描边Pass放在第一个深度预写Pass之后保证描边的深度正确。玻璃这类需要透出背景的材质干脆关掉描边只靠后处理的法线边缘补一点轮廓。对于后处理描边误检用Stencil是个好办法先给需要描边标记的半透明物体写一个Stencil值后处理只对Stencil标记区域做边缘检测其余区域全部跳过。6. 性能预算、移动端适配与最终交付6.1 各个风格化特性的GPU成本盘点做性能盘点时我用的测试基准是一个6500面角色加全屏场景在移动端中端机Adreno 650级别和PC上分别跑。风格化渲染有个反直觉的地方色阶光照Shader本身比PBR的Lit Shader便宜得多因为不需要GGX高光、多次采样和复杂的BRDF计算。真正的开销反而出在描边和全屏后处理上。特性主要开销来源预估成本等级色阶光照Ramp一次Ramp采样 常规光照运算低几何描边多一遍绘制顶点数翻倍光栅化面积小低到中屏幕空间边缘检测全屏3x3采样深度法线需要额外RT中到高噪声/笔触扰动多一张噪声采样 少量数学运算低全屏色调映射一次全屏LUT查找低移动端最危险的组合是屏幕空间边缘检测 全屏色调映射 纸纹叠加三个全屏Pass连续跑带宽很容易被打爆。我最终的落地配置是中端机只保留两个全屏Pass把边缘检测和色调映射合并到一个Pass里减少一次RT切换。6.2 性能分级与多平台配置因为整个系统是参数化的我在RenderFeature里做了一个质量档位接口按设备能力动态切换。大致分三档Low单Ramp光照 几何描边 简单的颜色压缩不启用任何屏幕空间后处理。MediumLow基础上加边缘光、低频噪声扰动、全屏LUT色调映射。HighMedium基础上加屏幕空间边缘检测、纸纹叠加、颜色分级。档位切换最好在Renderer里动态开关Feature而不是重新编译Shader。每个Feature绑定一个布尔开关代码里在构建质量等级配置时决定哪些Pass被加到渲染列表。这样做到不同档位之间不需要重新加载资源切换延迟可以控制在1帧以内。switch (stylizedQuality) { case StylizedQuality.Low: screenSpaceOutline.SetActive(false); colorGradingPass.SetActive(false); break; case StylizedQuality.Medium: screenSpaceOutline.SetActive(false); colorGradingPass.SetActive(true); break; case StylizedQuality.High: screenSpaceOutline.SetActive(true); colorGradingPass.SetActive(true); break; }这套等级配置还有一个隐性好处测试时可以按档位A/B对比画面美术和QA能快速判断这个效果是必须的还是锦上添花的。项目后期我们就是靠这个切档方案把中端机的帧率从25帧拉回满帧代价只是远景少了屏幕轮廓线近景角色完全不受影响。6.3 落地过程中的一点经验和后续扩展方向这套系统跑通之后我最大的体会是风格化渲染的瓶颈往往不在算法而在参数规范的统一。Ramp贴图一定要做统一制作规范源文件在项目里统一维护不要让每个角色各做各的。否则全局切换色温、阴影色的时候全场景会因为贴图风格不统一而崩掉。美术和TA之间需要有一个风格化配色表定义清楚亮部色、暗部色、描边色三组基准色再让每个物体在这套基准上做局部偏移。后续可以扩展的方向其实不少针对赛璐璐、厚涂、三渲二手绘这些不同的二次元风格做多套Ramp和描边预设项目里按场景一键切换接入光照探针和IBL时要刻意压低环境光的物理感否则环境反射会把风格化拉回写实LOD和裁剪优化做完之后还要再测一遍描边的稳定性LOD切换瞬间描边粗细变化是最容易露馅的环节。这些内容如果大家感兴趣我后面可以再单独写一篇文章把每一块展开讲。
RELATED READING

延伸阅读

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