风格化渲染系统架构:从PBR到NPR的LUT调色与描边实现
发布时间:2026/9/30 9:26:53来源:尧图网络
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里阴影是光照乘以0NPR里阴影通常不是纯黑而是带色调的暗部。我在量化之后把暗部颜色和主光颜色做一次lerplerp的权重由量化后的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脚本生成后来发现美术根本不会用。现在我的标准流程是拍摄参考用手机或相机拍一张想要模仿色调的照片或者直接用Blackmagic Cam拍一段素材。调色在DaVinci Resolve或Lightroom里调出目标色调导出为.cube文件。验证用引擎的LUT预览工具加载检查肤色、天空、植被等关键区域是否偏色。优化如果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 URPWindows 11RTX 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的重新分配。如果每次切换都创建新的RTGC压力会很大。我的做法是预分配两个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 移动端性能优化清单移动端做风格化渲染性能是最大的约束。我整理了一份优化清单按优先级排序降低LUT分辨率32x32x32降到16x16x16显存从384KB降到48KB。关闭描边描边需要额外的DrawCall移动端能省则省。合并Pass把LUT和纹理叠加合并到一个Pass里减少RT切换。使用半精度颜色计算用half代替float带宽减半。限制色阶数量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和色阶入手这两个效果最明显实现也最简单。描边和纹理叠加可以后面再加不影响整体架构。
网站建设高端定制企业官网