
1. 风格化渲染系统的整体架构与设计取舍1.1 从PBR到NPR为什么需要一套独立的风格化管线做实时渲染的人绕不开一个根本矛盾物理正确的渲染管线PBR追求的是能量守恒、微表面分布、菲涅尔效应这些可量化的物理规律而风格化渲染NPR追求的是“看起来对”——手绘感、色块分明、边缘线清晰、高光可以违背物理规律地出现在任何地方。这两套逻辑从根子上就是冲突的。我在搭建这套系统之前试过直接在Unity的Standard Shader或者URP的Lit Shader上做修改试图通过叠加后处理来实现风格化。结果很糟糕PBR的间接光、环境反射、阴影衰减会不断“污染”风格化的色块你调好的三段式明暗交界线会被环境光晕开你想要的硬边高光会被粗糙度贴图打散。所以第一件事就是下定决心——风格化渲染必须走独立的Shader管线不能寄生在PBR上。这套系统的核心思路是用PBR的几何和光照数据作为输入但用NPR的逻辑重新组织输出。具体来说法线、深度、光照方向这些数据照常计算但最终的颜色合成不走BRDF而是走一套自定义的色阶映射和边缘检测。这样做的好处是既能利用引擎已有的阴影、反射探针等基础设施又能完全掌控最终的视觉表现。1.2 系统分层几何层、光照层、风格层、后处理层整个系统我分成了四层每层职责明确层与层之间通过明确定义的接口通信。几何层负责输出法线、切线、深度、世界坐标这些基础数据。这一层跟PBR管线完全一致不需要做任何特殊处理。唯一需要注意的是法线的精度——风格化渲染对法线突变非常敏感因为色阶映射会把法线的微小变化放大成明显的色块跳变。所以我在几何层额外做了一步法线平滑对低模的法线进行加权平均避免出现难看的色块碎裂。光照层负责计算直接光照和间接光照的强度但输出的是标量亮度而不是颜色。这一步很关键PBR输出的是RGB三通道的辐射度而NPR只需要一个亮度值来做色阶映射。我把光照层的输出定义为float lightIntensity范围归一化到0到1后续所有风格化操作都基于这个值。风格层是核心包含色阶映射、边缘检测、高光塑形、材质特征四个模块。色阶映射把连续的lightIntensity离散化成有限个色阶边缘检测在法线突变和深度突变处生成轮廓线高光塑形用自定义的衰减曲线替代GGX分布材质特征则处理布料、金属、皮肤等不同材质的风格化表现。后处理层负责最终的画面调色包括LUT应用、色调分离、颗粒感叠加等。这一层是可选的但强烈建议保留因为LUT是统一画面风格最高效的手段。1.3 为什么选择LUT作为调色核心LUT在这个系统里扮演的是“风格胶水”的角色。风格层输出的画面虽然色块分明但不同材质、不同光照条件下的色温可能不统一直接看起来会有点“散”。LUT的作用就是把整个画面的色彩空间映射到一个统一的风格坐标系里。我试过用曲线调整、色阶工具、色彩平衡节点来做后处理调色效果都不如LUT稳定。原因很简单曲线和色阶是逐通道操作的容易把风格层精心设计的色相关系打乱而LUT是三维映射可以同时保持色相、饱和度、明度的相对关系只做整体偏移。举个例子你想把画面从“冷峻科幻”调到“温暖手绘”用LUT只需要换一个文件用曲线调色则要重新调十几条曲线。LUT的另一个好处是可以在引擎外制作。我通常会在DaVinci Resolve或者Photoshop里用参考图调好风格导出为.cube文件然后在Shader里用三线性插值采样。这样美术人员不需要懂Shader就能参与风格定义大大降低了协作成本。2. 核心Shader实现与关键参数解析2.1 色阶映射从连续光照到离散色块色阶映射是风格化渲染最核心的一步。PBR的光照是连续的从暗到亮有无数个过渡NPR要把这个连续统切成有限个色阶通常是2到5个。切的方式决定了画面的“硬朗程度”。我用的方案是可配置阈值的阶梯函数而不是简单的floor(lightIntensity * N) / N。原因在于简单的均分阶梯会让暗部色阶过窄、亮部色阶过宽不符合手绘动画里暗部层次少、亮部层次多的习惯。我的做法是给每个色阶定义一个阈值数组比如float thresholds[4] {0.15, 0.35, 0.6, 0.85}然后通过比较来确定当前像素属于哪个色阶。float3 StylizedRamp(float lightIntensity, float3 shadowColor, float3 midColor, float3 lightColor, float4 thresholds) { float3 result shadowColor; result lerp(result, midColor, step(thresholds.x, lightIntensity)); result lerp(result, lightColor, step(thresholds.y, lightIntensity)); // 可继续扩展更多色阶 return result; }这里有个细节step函数会产生硬边如果你想要稍微柔和一点的过渡可以用smoothstep替代但过渡区间要控制在0.02以内否则色块感会消失。我实测下来过渡区间超过0.05就会明显看到渐变风格化效果大打折扣。注意色阶阈值不要写死在Shader里一定要暴露成材质参数。不同场景、不同光照条件下最优阈值是不同的写死会导致换个场景就要改代码。2.2 边缘检测法线突变与深度突变的双重判据风格化渲染的轮廓线有两种做法一种是基于几何的描边背面膨胀法一种是基于屏幕空间的边缘检测。我选择后者因为屏幕空间边缘检测不依赖模型拓扑对任何模型都适用而且线宽可以随距离动态调整。边缘检测的核心是计算相邻像素的法线差异和深度差异。法线差异用点积判断dot(normalCenter, normalNeighbor) threshold说明法线突变可能是轮廓。深度差异用差值判断abs(depthCenter - depthNeighbor) depthThreshold说明深度突变可能是物体边界。float EdgeDetect(float2 uv, float3 normalCenter, float depthCenter) { float3 normalNeighbor SampleNormal(uv float2(_ScreenParams.z, 0)); float depthNeighbor SampleDepth(uv float2(_ScreenParams.z, 0)); float normalDiff 1.0 - dot(normalCenter, normalNeighbor); float depthDiff abs(depthCenter - depthNeighbor) / depthCenter; float edge step(_NormalThreshold, normalDiff) step(_DepthThreshold, depthDiff); return saturate(edge); }这里的关键参数是_NormalThreshold和_DepthThreshold。法线阈值我一般设在0.3到0.5之间太低会把曲面细分产生的微小法线变化也当成边缘太高会漏掉一些真正的轮廓。深度阈值跟相机远近有关我通常用_DepthThreshold _ProjectionParams.w * 0.01来让阈值随远平面自动缩放。实操心得边缘检测最好在相机空间做不要在屏幕空间做。屏幕空间的深度是非线性的近处精度高远处精度低直接用屏幕空间深度做阈值判断会导致远处的边缘时有时无。转成相机空间的线性深度再判断稳定性会好很多。2.3 高光塑形用自定义衰减替代GGXPBR的高光用GGX分布特点是高光面积随粗糙度变化边缘有柔和的衰减。NPR的高光要“假”得多——通常是一个固定大小的色块边缘可以很硬位置可以偏离物理正确的位置。我的做法是用pow(saturate(dot(normal, halfDir)), _SpecularPower)作为基础但把结果通过一个自定义的曲线重新映射。_SpecularPower控制高光的集中程度值越大高光越小越亮。然后我用一个step函数把高光切成硬边或者用smoothstep保留一点过渡。float specular pow(saturate(dot(normal, halfDir)), _SpecularPower); specular smoothstep(_SpecularThreshold - _SpecularSoftness, _SpecularThreshold _SpecularSoftness, specular); float3 specularColor _SpecularTint * specular * _SpecularIntensity;_SpecularThreshold决定高光的大小_SpecularSoftness决定边缘的柔和程度。我一般把_SpecularSoftness设在0.02到0.05之间既能保持硬边感又不会出现锯齿。还有一个技巧给高光加一个位置偏移。在计算halfDir之前把光源方向绕法线旋转一个小角度高光就会偏离物理正确的位置。这个偏移量可以做成参数让美术人员微调高光的“手感”。2.4 材质特征布料、金属、皮肤的差异化处理同一套风格化逻辑不能套所有材质。布料的高光应该宽而弱金属的高光应该窄而强皮肤需要次表面散射的模拟。我在风格层里给每种材质定义了一组预设参数通过材质ID或者贴图通道来切换。材质类型高光强度高光范围边缘光色阶数布料0.2宽弱3金属1.5窄强2皮肤0.4中中4头发0.8各向异性强3边缘光Rim Light是风格化渲染的另一个利器。在物体边缘叠加一层亮色可以增强轮廓感让物体从背景中“跳”出来。边缘光的计算很简单rim 1 - saturate(dot(normal, viewDir))然后pow(rim, _RimPower)控制边缘光的宽度。注意边缘光不要在所有物体上都开。背景物体开边缘光会让画面显得很“脏”只有主体和需要突出的物体才开。我通常用Layer或者Stencil来控制哪些物体接收边缘光。3. 完整实操流程从零搭建一套风格化渲染管线3.1 项目初始化与渲染管线选择第一步是选渲染管线。Unity里我推荐URP因为URP的Shader Graph和自定义Render Feature机制让风格化后处理非常方便。Built-in管线虽然更灵活但后处理要自己写Command Buffer上手门槛高。HDRP就算了它的PBR绑定太深改造成NPR的成本太高。创建URP项目后先关掉一些不需要的功能屏幕空间环境光遮蔽SSAO会污染色块关掉屏幕空间反射SSR对NPR没用关掉抗锯齿用FXAA而不是TAA因为TAA会在色块边缘产生抖动。保留阴影和深度纹理这两个是边缘检测必需的。然后创建一个新的Shader文件我命名为StylizedLit.shader。结构上分成PassForwardLit Pass负责主渲染Outline Pass负责描边如果用法线膨胀法的话DepthOnly Pass和ShadowCaster Pass照常写。3.2 法线平滑与几何数据准备低模的法线往往很“碎”直接做色阶映射会出现大量小色块。我的解决方案是在导入模型时开启“Calculate Normals”并设置平滑角度为60度然后在Shader里再做一次法线平滑。法线平滑的做法是采样周围像素的法线取平均值然后跟原始法线做lerp。平滑强度用参数控制我一般设在0.3到0.5之间。float3 SmoothNormal(float2 uv, float3 normal) { float3 normalSum 0; float2 texelSize _ScreenParams.zw * _NormalSmoothRadius; normalSum SampleNormal(uv float2(texelSize.x, 0)); normalSum SampleNormal(uv - float2(texelSize.x, 0)); normalSum SampleNormal(uv float2(0, texelSize.y)); normalSum SampleNormal(uv - float2(0, texelSize.y)); normalSum normalize(normalSum * 0.25); return normalize(lerp(normal, normalSum, _NormalSmoothStrength)); }这一步会增加4次纹理采样对性能有一定影响。如果目标平台是移动端可以跳过法线平滑改为在建模阶段就把法线做平滑。3.3 光照计算与色阶映射的完整实现光照计算我走的是简化版主光源用Lambert漫反射额外加一个半球环境光。不计算PBR的BRDF因为风格化不需要。float3 CalculateStylizedLighting(Light mainLight, float3 normal, float3 viewDir, float3 albedo) { float NdotL dot(normal, mainLight.direction); float lightIntensity NdotL * 0.5 0.5; // 重映射到0-1 // 环境光贡献 float ambient dot(normal, float3(0, 1, 0)) * 0.5 0.5; lightIntensity saturate(lightIntensity ambient * _AmbientStrength); // 色阶映射 float3 rampColor StylizedRamp(lightIntensity, _ShadowColor, _MidColor, _LightColor, _Thresholds); // 高光 float3 halfDir normalize(mainLight.direction viewDir); float specular pow(saturate(dot(normal, halfDir)), _SpecularPower); specular smoothstep(_SpecularThreshold - _SpecularSoftness, _SpecularThreshold _SpecularSoftness, specular); // 边缘光 float rim pow(1.0 - saturate(dot(normal, viewDir)), _RimPower); float3 finalColor albedo * rampColor _SpecularTint * specular * _SpecularIntensity _RimColor * rim * _RimIntensity; return finalColor; }这里_ShadowColor、_MidColor、_LightColor是三个色阶的颜色通常从暗到亮。我一般让_ShadowColor偏冷比如深蓝紫_LightColor偏暖比如浅黄这样画面会有自然的冷暖对比。3.4 后处理LUT的集成与调色LUT的集成在URP里通过Render Feature实现。创建一个LutRenderPass在RenderPassEvent.AfterRenderingPostProcessing时机执行。Shader里用SAMPLE_TEXTURE2D采样LUT纹理把原始颜色作为UV的x和y蓝色通道作为z做三线性插值。float3 ApplyLUT(float3 color, TEXTURE2D_PARAM(lutTex, lutSampler)) { float3 uv; uv.x (color.r * (lutSize - 1) 0.5) / lutSize; uv.y (color.g * (lutSize - 1) 0.5) / lutSize; uv.z color.b * (lutSize - 1); float4 color1 SAMPLE_TEXTURE2D(lutTex, lutSampler, uv.xy); float4 color2 SAMPLE_TEXTURE2D(lutTex, lutSampler, uv.xy float2(1.0 / lutSize, 0)); return lerp(color1.rgb, color2.rgb, frac(uv.z)); }LUT的制作我通常在DaVinci Resolve里做导入一张参考图调色到满意然后导出为.cube文件。Unity里用TextureImporter把.cube导入为Texture2D设置Filter Mode为BilinearWrap Mode为Clamp。实操心得LUT的强度不要开满一般0.6到0.8就够了。开满会让画面失去细节暗部死黑亮部死白。我通常用lerp(originalColor, lutColor, _LutStrength)来控制混合比例。4. 常见问题排查与性能优化实录4.1 色块边缘出现锯齿或闪烁这是最常见的问题原因通常是色阶映射的阈值正好落在光照变化的敏感区域导致相邻像素在色阶之间反复跳变。解决方案有三个一是把step换成smoothstep给过渡区间留0.01到0.02的余量二是开启MSAA用几何抗锯齿来平滑边缘三是在后处理里加一个轻微的模糊但模糊半径要控制在1像素以内。我实测下来最有效的组合是smoothstep过渡区间0.015加上4x MSAA。这样既保持了色块的硬朗感又消除了锯齿。4.2 边缘检测在远处失效前面提到过屏幕空间深度是非线性的远处的深度值精度很低导致深度阈值判断失效。解决方案是把深度转成相机空间的线性深度再判断。float LinearDepth(float depth) { float near _ProjectionParams.y; float far _ProjectionParams.z; return (2.0 * near * far) / (far near - depth * (far - near)); }转成线性深度后阈值就可以用世界空间的单位来定义比如“深度差超过0.5米算边缘”这样远近都一致。4.3 移动端性能瓶颈与降级方案这套系统在PC上跑没问题但在移动端上边缘检测的多次纹理采样和LUT的三线性插值会比较吃性能。我的降级方案是边缘检测从4方向采样降到2方向只检测水平和垂直LUT从三线性降到双线性忽略蓝色通道的插值法线平滑直接关掉。功能PC移动端高端移动端中低端边缘检测方向数422LUT插值三线性双线性双线性法线平滑开关关色阶数432边缘光开开关这样降级后移动端中低端也能跑到30帧以上。如果还不够可以把整个风格化渲染改成顶点着色器实现牺牲一些精度换性能。4.4 常见问题速查表问题现象可能原因排查方法解决方案色块边缘锯齿色阶过渡太硬检查smoothstep区间增大过渡区间到0.015远处边缘消失深度非线性检查深度计算转线性深度高光位置不对halfDir计算错误检查光源方向确保光源方向归一化LUT偏色LUT纹理导入设置错误检查sRGB选项关闭sRGB用Linear移动端掉帧采样次数过多用Profiler看降级边缘检测和LUT法线平滑过度平滑强度太高检查_NormalSmoothStrength降到0.3以下最后分享一个小技巧风格化渲染的参数调优最好在运行时做。我通常会写一个Editor脚本把色阶阈值、高光参数、边缘检测阈值都暴露成Slider在Play模式下实时调整调好了再写回材质。这样比反复改代码、重新编译快得多。这套系统我前后迭代了三个版本第一版直接改PBR Shader效果很糟糕第二版做了独立的NPR管线但没加LUT画面风格不统一第三版加上LUT和后处理调色后才真正达到了可用的状态。如果你也在做风格化渲染我的建议是先把色阶映射和边缘检测做扎实这两个是骨架LUT和高光塑形是血肉骨架不稳的话后面怎么调都别扭。