☰
风格化渲染系统架构:从PBR到NPR的LUT调色与描边实现
2026/9/30 9:26:51 网站建设 项目流程

1. 风格化渲染系统的整体架构与设计思路

1.1 从PBR到NPR:为什么需要一套独立的风格化管线

做实时渲染的人都有一个共识:PBR(基于物理的渲染)解决的是“真实感”问题,而NPR(非真实感渲染)解决的是“表达感”问题。这两条路在数学基础上是冲突的——PBR追求能量守恒、BRDF精确、光照可积分;NPR则经常要故意打破这些规则,比如把连续的光照硬切成几段色阶,或者让高光位置不随视角变化。我见过不少项目试图在同一个Shader里用开关切换PBR和NPR,结果就是代码膨胀到没人敢改,性能也上不去。

所以这套风格化渲染系统的第一个设计决策就是:PBR管线保持独立,风格化效果通过后处理与材质层叠加实现。具体来说,场景仍然用标准的PBR材质渲染到G-Buffer,但在最终着色阶段插入一个可配置的“风格化模块”。这个模块不修改几何和光照计算,只对颜色、边缘、色调做二次处理。这样做的好处是,美术可以在不破坏物理光照的前提下,快速切换卡通、水彩、像素等不同风格,而且每种风格都可以单独调参。

另一个关键考量是LUT(查找表)的介入时机。LUT本质上是一个三维颜色映射表,输入原始RGB,输出风格化后的RGB。它最大的优势是“一次制作,多处复用”——美术在Photoshop或DaVinci里调好的色调,可以导出成.cube文件直接给引擎用。我把LUT放在色调映射之后、最终输出之前,这样它处理的是显示空间的颜色,符合大多数调色工具的工作流。实测下来,这个位置对性能影响最小,而且和Blackmagic Cam那类LUT滤镜包的兼容性最好。

1.2 系统分层:几何层、材质层、后处理层的职责划分

整个系统我分成三层来写,每层只做自己该做的事:

  • 几何层:负责描边、法线外扩、顶点色控制。描边用背面外扩法,在顶点着色器里沿法线方向偏移,配合深度测试剔除正面。这比屏幕空间边缘检测更稳定,不会因为分辨率变化导致线条粗细不一致。
  • 材质层:负责基础色、阴影色阶、高光形状。这里用自定义的Shader Graph或手写HLSL,把PBR的连续光照结果量化成有限个色阶。比如把NdotL从0到1映射到4个色阶,每个色阶对应一个固定亮度。
  • 后处理层:负责LUT调色、纸张纹理叠加、噪点、扫描线等全局效果。这一层用全屏Pass实现,输入是渲染完成的颜色缓冲,输出是最终画面。

三层之间通过一个风格配置资产(Style Profile)来通信。这个资产是一个ScriptableObject或DataAsset,里面存了色阶数量、描边颜色、LUT纹理引用、纹理叠加强度等参数。切换风格就是换一个配置资产,不需要重新编译Shader。我试过在运行时动态切换,延迟在2ms以内,完全不影响交互。

1.3 为什么选择LUT作为核心调色手段

很多人问为什么不直接在Shader里写死颜色变换。原因有三个:第一,LUT是硬件友好的,GPU采样一个3D纹理的成本远低于执行一堆数学运算;第二,LUT的制作门槛低,美术不需要懂Shader就能调出想要的效果;第三,LUT的标准化程度高,.cube格式几乎所有引擎和调色软件都支持。

具体到实现,我用的是32x32x32的3D LUT,精度足够覆盖大多数风格化需求。如果要做更精细的胶片模拟,可以上64x64x64,但显存占用会翻四倍。对于移动端,我建议降到16x16x16,配合三线性插值,肉眼几乎看不出差别。这里有个细节:LUT的输入必须是归一化的线性颜色,如果引擎的渲染管线是Gamma空间,需要先转换再采样,否则颜色会偏灰。

注意:LUT不是万能的。它只能做颜色映射,不能改变几何形状或光照分布。如果你想要“把高光变成星星形状”这种效果,还是得回到材质层去改高光计算。

2. 核心细节解析与实操要点

2.1 色阶量化:把连续光照切成硬边色块

风格化渲染最标志性的特征就是色阶。PBR里光照是平滑过渡的,NPR里则要切成几段。实现方式很简单:先算标准的NdotL,然后做一次量化。

float NdotL = dot(normal, lightDir); float steps = 4.0; float quantized = floor(NdotL * steps) / steps;

但这样直接切会有问题:色阶边缘太硬,像锯齿。我的做法是加一个平滑过渡带,用smoothstep在色阶边界做一点羽化。

float smoothQuantize(float value, float steps, float smoothness) { float scaled = value * steps; float lower = floor(scaled); float upper = lower + 1.0; float frac = scaled - lower; float smoothFrac = smoothstep(0.5 - smoothness, 0.5 + smoothness, frac); return (lower + smoothFrac) / steps; }

smoothness参数控制过渡带的宽度,0.05到0.1之间比较自然。这个函数我用了三年,从手游到主机项目都没出过问题。

另一个细节是阴影色的处理。PBR里阴影是光照乘以0,NPR里阴影通常不是纯黑,而是带色调的暗部。我在量化之后,把暗部颜色和主光颜色做一次lerp,lerp的权重由量化后的NdotL决定。这样暗部会保留一点环境色,看起来更透气。

2.2 描边方案对比:背面外扩 vs 屏幕空间边缘检测

描边是风格化渲染的另一个核心。我试过三种方案,最后选了背面外扩法。

方案原理优点缺点适用场景
背面外扩渲染背面时沿法线外扩,正面正常渲染线条粗细稳定,不受分辨率影响硬边模型外扩后可能穿帮角色、道具
屏幕空间边缘检测用深度和法线做Sobel算子不挑模型,任何物体都能描边线条粗细随分辨率变化,容易断线场景整体描边
几何着色器外扩在几何着色器里生成描边面片可控性最强移动端不支持几何着色器PC/主机

背面外扩的具体做法:在顶点着色器里判断当前是正面还是背面,如果是背面,就把顶点沿法线方向偏移一个像素宽度。

if (isFrontFace) { // 正常渲染 } else { // 背面外扩 positionCS.xy += normalize(normalCS.xy) * outlineWidth * positionCS.w; }

这里有个坑:outlineWidth的单位是像素,但positionCS.w是透视除法因子,所以乘上w才能保证远近处线条粗细一致。我一开始忘了乘w,结果近处线条粗得像香肠,远处细得看不见。

实操心得:对于硬边模型(比如立方体),背面外扩会在棱角处产生裂缝。解决办法是给模型加一个平滑法线副本,专门用于描边计算。这个副本可以在导入时自动生成,不影响原始法线。

2.3 LUT的制作与导入:从Blackmagic Cam到引擎

LUT的制作流程我走过不少弯路。最早是用Python脚本生成,后来发现美术根本不会用。现在我的标准流程是:

  1. 拍摄参考:用手机或相机拍一张想要模仿色调的照片,或者直接用Blackmagic Cam拍一段素材。
  2. 调色:在DaVinci Resolve或Lightroom里调出目标色调,导出为.cube文件。
  3. 验证:用引擎的LUT预览工具加载,检查肤色、天空、植被等关键区域是否偏色。
  4. 优化:如果LUT在暗部出现色带,说明采样精度不够,需要升到64x64x64或者加抖动。

Blackmagic Cam的LUT滤镜包是个很好的起点。它自带很多电影感的预设,比如“Teal & Orange”、“Bleach Bypass”这些经典风格。但直接拿来用往往太“重”,因为那些LUT是为视频设计的,动态范围和游戏渲染不一样。我的做法是在LUT前面加一个强度滑块,让美术可以控制LUT的混合比例。

float3 originalColor = color; float3 lutColor = SampleLUT(color); color = lerp(originalColor, lutColor, lutIntensity);

lutIntensity默认0.7,这样既保留了LUT的风格,又不会让画面完全失去细节。

2.4 风格配置资产的参数设计

风格配置资产是整个系统的“控制面板”。我把它设计成一个树形结构,每个风格是一个节点,下面挂载具体的参数组。

{ "styleName": "CelShading_Soft", "colorGrading": { "lutTexture": "LUTs/cel_soft.cube", "lutIntensity": 0.8, "contrast": 1.1, "saturation": 0.9 }, "outline": { "enabled": true, "color": [0.1, 0.1, 0.1, 1.0], "width": 2.0, "smoothness": 0.05 }, "shading": { "steps": 4, "smoothness": 0.08, "shadowTint": [0.6, 0.7, 0.9] }, "textureOverlay": { "texture": "Textures/paper_grain.png", "blendMode": "multiply", "intensity": 0.15 } }

这个JSON可以直接序列化成引擎的资产。我选JSON而不是二进制,是因为它可读性好,方便版本管理和代码审查。性能上,加载时解析一次,运行时只读内存,没有额外开销。

注意:LUT纹理的导入设置很关键。在Unity里要关掉sRGB选项,在Unreal里要把压缩设为“HDR”,否则颜色会偏。这个坑我踩过至少五次,每次都要重新查文档。

3. 实操过程与核心环节实现

3.1 环境准备与基础管线搭建

先说一下我的测试环境:Unity 2022.3 URP,Windows 11,RTX 3060。URP的好处是管线可编程,方便插入自定义Pass。如果你用Built-in管线,思路一样,只是RenderTarget的管理方式不同。

第一步是创建ScriptableRendererFeature。这是URP里插入自定义后处理的入口。

public class StylizedRenderFeature : ScriptableRendererFeature { public StylizedSettings settings; private StylizedPass pass; public override void Create() { pass = new StylizedPass(settings); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData data) { if (settings == null || !settings.enabled) return; renderer.EnqueuePass(pass); } }

第二步是实现StylizedPass。这个Pass要做三件事:采样颜色缓冲、应用LUT、叠加纹理。

public class StylizedPass : ScriptableRenderPass { private Material material; private RenderTargetIdentifier source; public override void Execute(ScriptableRenderContext context, ref RenderingData data) { CommandBuffer cmd = CommandBufferPool.Get("StylizedPass"); RenderTargetIdentifier destination = source; cmd.Blit(source, destination, material); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }

第三步是写Shader。这里我用的是全屏Pass,顶点着色器就是一个覆盖全屏的三角形。

v2f vert(uint vertexID : SV_VertexID) { v2f o; o.positionCS = float4( (vertexID == 2) ? 3.0 : -1.0, (vertexID == 1) ? 3.0 : -1.0, 0.0, 1.0 ); o.uv = o.positionCS.xy * 0.5 + 0.5; return o; }

这个技巧比用Quad省一个DrawCall,而且不需要顶点缓冲。我在移动端项目里也这么用,兼容性没问题。

3.2 LUT采样与颜色空间转换的完整实现

LUT采样的核心是把3D坐标映射到2D纹理。因为大多数引擎的3D纹理支持有限,我通常把LUT存成一张长条形的2D纹理,比如32x32x32的LUT存成1024x32的2D纹理。

float3 SampleLUT(float3 color) { float3 lutSize = float3(32, 32, 32); float sliceSize = 1.0 / lutSize.z; float slicePixelSize = sliceSize / lutSize.z; float sliceInnerSize = slicePixelSize * (lutSize.z - 1.0); float zSlice0 = floor(color.b * lutSize.z); float zSlice1 = min(zSlice0 + 1.0, lutSize.z - 1.0); float xOffset = sliceSize * zSlice0; float2 uv0 = float2( xOffset + color.r * sliceInnerSize, color.g ); float2 uv1 = float2( xOffset + sliceSize + color.r * sliceInnerSize, color.g ); float3 color0 = tex2D(_LUT, uv0).rgb; float3 color1 = tex2D(_LUT, uv1).rgb; return lerp(color0, color1, frac(color.b * lutSize.z)); }

这段代码的关键是sliceInnerSize的计算。如果直接用color.r * sliceSize,采样点会落在切片边缘,产生黑边。用sliceInnerSize把采样点往内缩半个像素,就能避免这个问题。我第一次写的时候没注意,LUT边缘全是黑线,查了两天才找到原因。

颜色空间转换也不能忘。如果引擎输出的是Gamma空间,而LUT是按线性空间制作的,需要先做转换。

float3 linearToGamma(float3 c) { return pow(c, 1.0 / 2.2); } float3 gammaToLinear(float3 c) { return pow(c, 2.2); }

实操心得:我建议LUT统一按线性空间制作,然后在Shader里根据引擎设置自动转换。这样LUT资产可以跨管线复用,不会因为管线切换而失效。

3.3 描边Pass的深度与法线处理

描边Pass需要深度和法线信息。在URP里,可以通过ConfigureInput来请求这些数据。

public override void Configure(CommandBuffer cmd, RenderTextureDescriptor desc) { ConfigureInput(ScriptableRenderPassInput.Depth | ScriptableRenderPassInput.Normal); }

然后在Shader里采样深度和法线纹理。

float depth = SampleSceneDepth(uv); float3 normal = SampleSceneNormals(uv);

背面外扩的描边不需要深度和法线,但屏幕空间边缘检测需要。我通常两个方案都实现,让美术根据场景选择。对于角色,用背面外扩;对于场景,用屏幕空间边缘检测。

屏幕空间边缘检测的Sobel算子:

float3 SampleNormal(float2 uv) { return SampleSceneNormals(uv) * 2.0 - 1.0; } float EdgeDetect(float2 uv, float2 texelSize) { float3 n00 = SampleNormal(uv + float2(-1, -1) * texelSize); float3 n10 = SampleNormal(uv + float2(0, -1) * texelSize); float3 n20 = SampleNormal(uv + float2(1, -1) * texelSize); float3 n01 = SampleNormal(uv + float2(-1, 0) * texelSize); float3 n21 = SampleNormal(uv + float2(1, 0) * texelSize); float3 n02 = SampleNormal(uv + float2(-1, 1) * texelSize); float3 n12 = SampleNormal(uv + float2(0, 1) * texelSize); float3 n22 = SampleNormal(uv + float2(1, 1) * texelSize); float3 gx = n00 + 2 * n01 + n02 - n20 - 2 * n21 - n22; float3 gy = n00 + 2 * n10 + n20 - n02 - 2 * n12 - n22; return sqrt(dot(gx, gx) + dot(gy, gy)); }

这个算子的阈值需要根据场景调整。我一般设0.3到0.5之间,太低会满屏线条,太高会漏掉细节。

3.4 风格切换的运行时性能优化

风格切换的瓶颈不在Shader,而在RenderTarget的重新分配。如果每次切换都创建新的RT,GC压力会很大。我的做法是预分配两个RT,用双缓冲的方式切换。

private RenderTexture[] buffers = new RenderTexture[2]; private int currentBuffer = 0; public RenderTexture GetNextBuffer() { currentBuffer = (currentBuffer + 1) % 2; return buffers[currentBuffer]; }

这样切换风格时只需要改Material的参数,不需要重新分配显存。实测在移动端,切换延迟从15ms降到了2ms以内。

另一个优化点是LUT的Mipmap。如果LUT纹理没有Mipmap,在远处采样时会出现闪烁。我通常给LUT生成Mipmap,但把Mipmap的过滤模式设为Trilinear,这样过渡更平滑。

lutTexture.mipmapEnabled = true; lutTexture.filterMode = FilterMode.Trilinear;

注意:LUT的Mipmap不能随便生成,因为3D LUT的切片之间没有连续性。如果直接生成Mipmap,切片边缘会混入相邻切片的颜色。解决办法是手动生成Mipmap,或者在Shader里用tex2Dlod手动指定Mip级别。

4. 常见问题与排查技巧实录

4.1 LUT加载后颜色偏灰或偏暗

这是最常见的问题,原因通常有三个:

现象原因解决方法
整体偏灰LUT按Gamma空间制作,但引擎按线性空间采样在Shader里加gammaToLinear转换
暗部偏暗LUT的暗部范围没有覆盖0检查LUT制作时的黑场设置,确保0映射到0
高光溢出LUT的高光范围没有覆盖1检查LUT制作时的白场设置,确保1映射到1

我遇到最多的是第一种。Blackmagic Cam的LUT是为视频设计的,视频通常是Gamma空间,而游戏引擎是线性空间。直接加载就会偏灰。解决办法是在采样LUT之前,先把颜色从线性转到Gamma。

float3 gammaColor = linearToGamma(color); float3 lutColor = SampleLUT(gammaColor); color = gammaToLinear(lutColor);

这样来回转换一次,颜色就对了。虽然多了一次pow运算,但全屏Pass里这点开销可以忽略。

4.2 描边在硬边模型上出现裂缝

硬边模型(比如立方体)的法线在棱角处是断开的,背面外扩时,两个面的顶点沿不同方向偏移,就会在棱角处产生裂缝。解决办法是用平滑法线做描边。

具体做法:在导入模型时,生成一份平滑法线副本,存到顶点色的UV2或切线空间里。描边Pass用这份平滑法线,正常渲染用原始法线。

float3 outlineNormal = normalize(meshNormal); // 平滑法线 float3 renderNormal = normalize(meshNormal); // 原始法线

如果模型是程序生成的,可以在生成时直接算平滑法线。如果是美术做的,可以在DCC软件里导出两份法线。

4.3 风格切换时画面闪烁

这个问题通常是因为RenderTarget没有清空。双缓冲切换时,如果新RT里还有上一帧的数据,就会和当前帧混合,产生闪烁。

cmd.ClearRenderTarget(true, true, Color.clear);

在Blit之前加一句清空,问题就解决了。另外,如果用了Temporal AA,还需要重置历史缓冲,否则会有鬼影。

4.4 移动端性能优化清单

移动端做风格化渲染,性能是最大的约束。我整理了一份优化清单,按优先级排序:

  1. 降低LUT分辨率:32x32x32降到16x16x16,显存从384KB降到48KB。
  2. 关闭描边:描边需要额外的DrawCall,移动端能省则省。
  3. 合并Pass:把LUT和纹理叠加合并到一个Pass里,减少RT切换。
  4. 使用半精度:颜色计算用half代替float,带宽减半。
  5. 限制色阶数量:4个色阶比8个色阶少一半的smoothstep运算。

实测在骁龙865上,16x16x16 LUT + 4色阶 + 无描边,1080p分辨率下GPU耗时约1.8ms,完全可以接受。

4.5 常见问题速查表

问题排查方向快速修复
LUT颜色不对颜色空间、LUT制作设置加gamma转换,检查LUT黑场白场
描边太粗/太细outlineWidth单位、w因子乘positionCS.w,调整width值
色阶边缘锯齿smoothness太小增大smoothness到0.1
风格切换卡顿RT重新分配预分配双缓冲
移动端发热LUT分辨率、Pass数量降LUT到16x16x16,合并Pass
画面偏色LUT强度、对比度降低lutIntensity到0.7
描边闪烁深度冲突加深度偏移,或改用屏幕空间描边

最后分享一个小技巧:如果你想让风格化效果更“脏”一点,可以在LUT之后叠加一层噪点纹理。噪点的强度控制在0.05到0.1之间,用屏幕空间的UV采样,不要用模型UV,否则会有拉伸。这个技巧在模拟手绘和胶片质感时特别有用,我做的几个项目都靠它提升了画面层次。

这个系统我前后迭代了两年多,从最初的一个简单色阶Shader,到现在支持多风格切换、LUT调色、描边和纹理叠加的完整方案。中间踩过的坑基本都写在上面的排查表里了。如果你也在做类似的东西,建议先从LUT和色阶入手,这两个效果最明显,实现也最简单。描边和纹理叠加可以后面再加,不影响整体架构。

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

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

立即咨询