☰
Unity URP风格化渲染系统实战:从Toon Shader到NPR全流程
2026/10/2 10:36:51 网站建设 项目流程

聊一聊我最近在搭的一套风格化渲染系统。项目做到中期,美术那边明确提了要求:所有角色的卡通感不能只靠模型贴图撑着,光照一变就露馅。于是我把之前散落在各个材质里的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 描边在相机距离变化时的粗细抖动

现象最经典:角色站远之后描边变成一条粗黑线,走近又细得看不见。根因不在模型,而在外扩偏移量没有正确处理投影空间的深度衰减。

我的排查链路是这样走的:

  1. 先确认外扩方向是在视图空间算的,而不是模型空间。非均匀缩放的模型用模型空间法线外扩,描边会变形出奇怪的凸起。
  2. 再检查偏移量有没有乘clip坐标的w。之前代码里o.pos.xy += screenOffset * width * o.pos.w就是干这个事的,少了w,远处描边会指数放大。
  3. 最后检查描边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光照 + 几何描边 + 简单的颜色压缩,不启用任何屏幕空间后处理。
  • Medium:Low基础上加边缘光、低频噪声扰动、全屏LUT色调映射。
  • High:Medium基础上加屏幕空间边缘检测、纸纹叠加、颜色分级。

档位切换最好在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切换瞬间描边粗细变化是最容易露馅的环节。这些内容如果大家感兴趣,我后面可以再单独写一篇文章,把每一块展开讲。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询