☰
从PBR到NPR:风格化渲染管线架构与Shader实现全解析
2026/9/30 13:02:21 网站建设 项目流程

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 高光塑形:用自定义衰减替代GGX

PBR的高光用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。结构上分成Pass:ForwardLit 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为Bilinear,Wrap 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移动端高端移动端中低端
边缘检测方向数422
LUT插值三线性双线性双线性
法线平滑开关关
色阶数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和高光塑形是血肉,骨架不稳的话后面怎么调都别扭。

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

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

立即咨询