
1. 风格化渲染系统的整体架构与设计取舍1.1 从PBR到NPR为什么我要做一套混合渲染管线做渲染这行的朋友大概都有体会PBR基于物理的渲染这套东西现在已经非常成熟了Unity的URP、HDRPUnreal的NaniteLumen基本把真实感渲染的门槛拉到了“开箱即用”的程度。但问题也恰恰出在这里——当所有人都能轻松做出照片级画面的时候风格化反而成了更稀缺的能力。我最初做这套风格化渲染系统的动机很简单手上有一个偏卡通调性的项目但美术团队既想要PBR的材质质感又想要NPR非真实感渲染的描边和色块感市面上现成的方案要么太“纯卡通”导致金属、布料这些材质完全失去细节要么太“写实”导致风格不统一。所以我决定自己搭一套混合管线核心思路是在PBR的光照计算基础上对漫反射项做阶梯化处理对高光项做风格化重塑同时叠加基于法线和深度的描边Pass。这样既能保留PBR在金属度、粗糙度上的物理正确性又能通过后处理式的色调映射和LUT查找表把整体画面拉到统一的风格化色调里。这里要解释一下为什么我不选择完全抛弃PBR。很多NPR方案会直接用Lambert或者Half-Lambert做漫反射然后手动指定明暗交界线的位置。这种做法在纯卡通场景里没问题但一旦场景里有金属、玻璃、皮肤这些需要高光细节的材质就会显得非常“平”。而PBR的GGX高光模型本身是物理正确的我只需要在最后一步把高光强度做一下阈值化或者色相偏移就能得到既有质感又有风格的效果。1.2 系统模块划分与数据流向整套系统我分成了四个核心模块数据流向是从几何到光照再到后处理最后统一做色调映射。具体来说几何Pass正常渲染场景的深度和法线输出到两张RTRender Texture上。深度图用于后续的边缘检测法线图用于描边和光照计算。光照Pass在PBR的BRDF计算基础上插入一个“风格化因子”参数。这个因子控制漫反射的阶梯层数、高光的锐利程度、以及边缘光的强度。描边Pass基于深度和法线的Roberts Cross算子做边缘检测然后在屏幕空间做描边。这里我不用传统的背面膨胀法因为背面膨胀在硬表面模型上容易出现断裂而且对蒙皮网格不友好。后处理Pass包括色调映射、LUT调色、以及可选的颗粒感和色差。LUT这块我后面会详细讲因为这是整个风格化调性的“最后一锤子”。数据流向是线性的但每个模块之间通过一个全局的StyleProfile资产来共享参数。这个StyleProfile是一个ScriptableObject里面存了阶梯层数、描边颜色、描边宽度、LUT纹理引用等。这样做的好处是美术可以在编辑器里实时调参不用改代码。1.3 为什么选择屏幕空间描边而不是几何描边描边方案的选择上我纠结了很久。几何描边背面膨胀的优点是线条粗细均匀不受分辨率影响但缺点也很明显硬表面模型的硬边处容易断裂蒙皮网格需要额外的法线平滑处理而且对透明物体基本无解。屏幕空间描边的优点是实现简单对所有物体一视同仁而且可以方便地做线条颜色渐变和宽度随深度变化。缺点是线条粗细会受分辨率影响而且在深度不连续的地方容易产生误判。最终我选了屏幕空间方案原因有三点第一我的项目里透明物体不多深度不连续的问题可以通过法线阈值来缓解第二屏幕空间描边可以很方便地和后处理LUT叠加线条颜色可以直接被LUT影响保证整体色调统一第三性能上屏幕空间方案只需要一个全屏Pass比每个物体多渲染一遍背面要划算得多。注意屏幕空间描边在相机快速移动时容易出现闪烁这是因为深度和法线RT的采样精度不够。解决办法是把深度和法线RT的格式设为RFloat和RGB111110Float不要用默认的ARGB32。2. 核心Shader实现与关键参数解析2.1 漫反射阶梯化从连续到离散的数学处理PBR的漫反射项是NdotL的连续函数要做成阶梯化最直接的方法是把NdotL的值域映射到有限个离散层级上。我用的公式是float steppedNdotL floor(NdotL * _StepCount) / _StepCount;其中_StepCount是阶梯层数一般设3到5层。但这样直接做会有两个问题一是阶梯边缘太硬看起来像色带二是暗部会丢失细节因为NdotL接近0的区域全被压到了第一层。为了解决第一个问题我在阶梯边缘加了一个平滑过渡float rawStep NdotL * _StepCount; float stepped floor(rawStep); float fracPart rawStep - stepped; float smoothFactor smoothstep(0.0, _EdgeSoftness, fracPart); float finalNdotL (stepped smoothFactor) / _StepCount;_EdgeSoftness控制过渡的柔和程度一般设0.1到0.3。这样阶梯之间就有了一个平滑的过渡带既保留了色块感又不会出现硬边。第二个问题的解决办法是引入一个“暗部提亮”参数。我把finalNdotL再做一个重映射finalNdotL lerp(_ShadowLift, 1.0, finalNdotL);_ShadowLift一般设0.15到0.25这样暗部不会死黑能保留一些细节。这个参数在卡通渲染里非常关键因为纯黑的暗部会让画面显得很“脏”。2.2 高光风格化阈值化与色相偏移PBR的高光项是GGX分布本身是连续且物理正确的。要做风格化我的做法是先把高光强度做一次阈值化然后再做色相偏移。阈值化的公式是float specThreshold smoothstep(_SpecCutoff, _SpecCutoff _SpecSoftness, specular); specular specular * specThreshold;_SpecCutoff控制高光的“门槛”一般设0.3到0.5。低于这个值的高光会被压掉高于这个值的会被保留并增强。_SpecSoftness控制过渡的柔和度一般设0.05到0.1。色相偏移是为了让高光带一点风格化的颜色比如暖色高光或者冷色高光。我用的方法是把高光颜色从白色偏移到指定的色调float3 specColor lerp(float3(1,1,1), _SpecTint, _SpecTintStrength); specular * specColor;_SpecTint一般选一个偏暖的橙色或者偏冷的青色_SpecTintStrength设0.3到0.5。这样高光就不会是死白而是带有一点风格化的倾向。2.3 边缘光与Rim Light的叠加逻辑边缘光在风格化渲染里几乎是标配但我发现很多实现方式都太“粗暴”直接用法线和视线方向的点积做阈值结果就是边缘光要么太宽要么太窄而且在不同曲率的表面上表现不一致。我的做法是用法线和视线方向的点积但加一个基于粗糙度的调制float rim 1.0 - saturate(dot(normal, viewDir)); rim pow(rim, _RimPower); rim * smoothstep(_RimCutoff, _RimCutoff 0.1, rim); float3 rimColor _RimTint * rim * _RimIntensity;_RimPower控制边缘光的宽度一般设2到4。_RimCutoff控制边缘光的起始位置一般设0.5到0.7。关键的是我把粗糙度也乘进去了rim * lerp(1.0, 0.5, roughness);这样粗糙的表面边缘光会弱一些光滑的表面边缘光会强一些更符合物理直觉。2.4 LUT调色的集成方式与采样优化LUTLook-Up Table是整套系统里最“出效果”的部分。我用的是标准的3D LUT尺寸是32x32x32以2D条带的形式存储。采样的时候需要把3D坐标映射到2D UV上float3 lutCoord saturate(color.rgb); float lutSize 32.0; float sliceSize 1.0 / lutSize; float slicePixelSize sliceSize / lutSize; float sliceInnerSize slicePixelSize * (lutSize - 1.0); float zSlice0 floor(lutCoord.z * (lutSize - 1.0)); float zSlice1 min(zSlice0 1.0, lutSize - 1.0); float xOffset slicePixelSize * 0.5 lutCoord.x * sliceInnerSize; float yPos lutCoord.y * sliceSize; float2 uv0 float2(xOffset zSlice0 * sliceSize, yPos); float2 uv1 float2(xOffset zSlice1 * sliceSize, yPos); float4 slice0Color tex2D(_LUT, uv0); float4 slice1Color tex2D(_LUT, uv1); float zLerp frac(lutCoord.z * (lutSize - 1.0)); float3 lutColor lerp(slice0Color.rgb, slice1Color.rgb, zLerp);这段代码看起来有点绕但核心思想就是把蓝色的值映射到2D条带的水平偏移上然后对相邻的两个切片做插值。这样做的好处是只需要一张2D纹理就能存储3D LUT兼容性最好。提示LUT纹理的导入设置里一定要把sRGB选项关掉Filter Mode设为BilinearWrap Mode设为Clamp。否则采样出来的颜色会偏。2.5 双面材质在风格化管线中的处理双面材质在风格化渲染里是个容易被忽略的坑。Unity默认的Shader是不支持双面渲染的需要手动加Cull Off。但加了Cull Off之后法线方向就成了问题——背面渲染的时候法线是反的光照会完全错乱。我的解决办法是在顶点着色器里判断当前渲染的是正面还是背面如果是背面就把法线翻转#ifdef UNITY_CULL_OFF if (!frontFace) { normal -normal; } #endif在Unity里可以用VFACE语义来判断但更通用的做法是用SV_IsFrontFace。这样双面材质的光照就正确了而且描边Pass也能正确处理双面物体的边缘。3. 完整实操流程与关键环节实现3.1 项目环境搭建与Render Pipeline配置我用的Unity版本是2022.3 LTS渲染管线是URP。为什么不选HDRP因为HDRP的自定义Pass流程太复杂而且对移动端支持不好。URP的ScriptableRendererFeature机制足够灵活性能也更好。第一步是创建URP Asset然后在Renderer里添加自定义的RendererFeature。我创建了三个FeatureDepthNormalFeature、OutlineFeature、PostProcessFeature。DepthNormalFeature负责在渲染不透明物体之后、透明物体之前把深度和法线输出到两张RT上。OutlineFeature在透明物体之后执行做屏幕空间描边。PostProcessFeature在最后执行做LUT调色和色调映射。Render Pipeline的配置里要注意几点Depth Texture和Opaque Texture都要勾上MSAA建议开到4x因为描边对边缘锯齿很敏感。HDR建议开启因为LUT调色在HDR空间下做会更准确。3.2 深度法线RT的生成与精度控制深度法线RT的生成是在DepthNormalFeature里做的。我创建了两张RTvar depthDesc new RenderTextureDescriptor(camera.pixelWidth, camera.pixelHeight, RenderTextureFormat.RFloat, 0); var normalDesc new RenderTextureDescriptor(camera.pixelWidth, camera.pixelHeight, RenderTextureFormat.ARGB2101010, 0);深度用RFloat法线用ARGB2101010。为什么不用ARGB32因为ARGB32的法线精度只有8位做描边的时候会出现明显的阶梯状锯齿。ARGB2101010有10位精度足够用了。在Shader里深度法线Pass的写法是struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct Varyings { float4 positionCS : SV_POSITION; float3 normalWS : TEXCOORD0; float4 positionSS : TEXCOORD1; }; Varyings DepthNormalVertex(Attributes input) { Varyings output; output.positionCS TransformObjectToHClip(input.positionOS.xyz); output.normalWS TransformObjectToWorldNormal(input.normalOS); output.positionSS ComputeScreenPos(output.positionCS); return output; } float4 DepthNormalFragment(Varyings input) : SV_Target { float depth input.positionCS.z; float3 normal normalize(input.normalWS); return float4(normal * 0.5 0.5, depth); }这里我把深度存在了Alpha通道里法线存在了RGB通道里。注意法线要编码到0到1的范围所以做了*0.50.5的映射。3.3 屏幕空间描边的Roberts Cross算子实现描边的核心是边缘检测。我用的Roberts Cross算子因为它比Sobel算子更轻量而且对细线条更敏感。Roberts Cross的卷积核是两个2x2的矩阵Gx [1, 0; 0, -1] Gy [0, 1; -1, 0]在Shader里的实现是float RobertsCross(float2 uv, float2 texelSize) { float2 uv0 uv; float2 uv1 uv float2(texelSize.x, texelSize.y); float2 uv2 uv float2(texelSize.x, 0); float2 uv3 uv float2(0, texelSize.y); float depth0 SampleDepth(uv0); float depth1 SampleDepth(uv1); float depth2 SampleDepth(uv2); float depth3 SampleDepth(uv3); float gx depth0 - depth1; float gy depth2 - depth3; return sqrt(gx * gx gy * gy); }法线的边缘检测类似但要把法线从0到1解码回-1到1然后做点积float3 normal0 SampleNormal(uv0) * 2 - 1; float3 normal1 SampleNormal(uv1) * 2 - 1; float normalDiff 1 - saturate(dot(normal0, normal1));最后把深度边缘和法线边缘合并float edge max(depthEdge * _DepthThreshold, normalEdge * _NormalThreshold); edge smoothstep(_EdgeThreshold, _EdgeThreshold 0.01, edge); float3 outlineColor lerp(sceneColor, _OutlineColor, edge * _OutlineIntensity);_DepthThreshold和_NormalThreshold分别控制深度和法线对边缘的贡献一般设1.0和0.5。_EdgeThreshold控制边缘的灵敏度一般设0.1到0.2。3.4 LUT纹理的制作与导入设置LUT纹理的制作我一般用两种方式一种是在Photoshop里用渐变映射调好颜色然后导出为CUBE文件再用工具转成2D条带另一种是直接用Python脚本生成。这里我贴一个生成中性LUT的Python脚本import numpy as np from PIL import Image lut_size 32 lut_data np.zeros((lut_size * lut_size, lut_size, 3), dtypenp.uint8) for z in range(lut_size): for y in range(lut_size): for x in range(lut_size): r int(x / (lut_size - 1) * 255) g int(y / (lut_size - 1) * 255) b int(z / (lut_size - 1) * 255) lut_data[y z * lut_size, x] [r, g, b] img Image.fromarray(lut_data) img.save(neutral_lut.png)这个脚本生成的是一个中性LUT也就是输入等于输出。然后你可以把这个PNG丢到Photoshop里用曲线、色阶、渐变映射这些工具调色调完之后再存回PNG。注意调色的时候不要改变图像的尺寸和布局否则采样会错位。导入Unity的时候Texture Type设为DefaultsRGB关掉Filter Mode设为BilinearWrap Mode设为ClampMip Maps关掉。这些设置一个都不能错否则LUT采样出来的颜色会偏。3.5 风格化因子的参数联动与预设管理整套系统里有十几个参数如果每次调风格都要手动改一遍美术肯定要疯。所以我做了一个StyleProfile资产把所有参数打包在一起然后通过一个全局的Volume组件来控制。StyleProfile里包含的参数有参数名类型默认值说明StepCountint4漫反射阶梯层数EdgeSoftnessfloat0.2阶梯过渡柔和度ShadowLiftfloat0.2暗部提亮强度SpecCutofffloat0.4高光阈值SpecTintColor(1, 0.9, 0.8)高光色调RimPowerfloat3.0边缘光宽度RimIntensityfloat0.5边缘光强度OutlineColorColor(0.1, 0.1, 0.1)描边颜色OutlineWidthfloat1.5描边宽度LUTTextureTexture3DnullLUT纹理在Volume组件里我重写了OnValidate方法当参数改变时自动更新Shader的全局变量。这样美术在Scene视图里就能实时看到效果不用反复点运行。4. 常见问题与排查技巧实录4.1 描边断裂与深度精度问题的排查描边断裂是最常见的问题表现是线条在某些角度下突然断开或者出现虚线。我排查下来主要有三个原因第一个原因是深度RT的精度不够。如果你用的是ARGB32深度值只有8位在远距离或者大场景下精度完全不够。解决办法是换成RFloat如果平台不支持RFloat就用RGHalf。我实测下来RFloat在PC和主机上都没问题移动端用RGHalf也够用。第二个原因是法线RT的编码问题。法线从-1到1编码到0到1的时候如果精度不够解码回来就会有误差。特别是在法线接近垂直的时候误差会被放大。解决办法是用ARGB2101010或者ARGBHalf不要用ARGB32。第三个原因是采样偏移的问题。Roberts Cross算子的采样偏移是texelSize但如果你的RT尺寸和屏幕尺寸不一致偏移量就会算错。解决办法是在Shader里用_ScreenParams来计算texelSize而不是用RT的尺寸。注意如果你的项目用了动态分辨率或者DLSS/FSR深度法线RT的尺寸可能会和屏幕尺寸不一致这时候描边的宽度会发生变化。解决办法是在后处理阶段把描边Pass的输出重新采样到屏幕尺寸。4.2 LUT调色偏色与色域映射的坑LUT调色偏色是我踩过最大的坑。表现是调好的颜色在游戏里看起来和Photoshop里完全不一样要么偏暗要么偏饱和。排查下来主要有两个原因第一个原因是色彩空间的问题。Unity的Project Settings里有一个Color Space选项默认是Gamma但URP建议用Linear。如果你在Linear空间下做LUT调色但LUT纹理是在Gamma空间下制作的颜色就会偏。解决办法是确保LUT纹理的sRGB选项关掉然后在Shader里手动做Gamma到Linear的转换。第二个原因是HDR和LDR的映射问题。如果你的相机开启了HDR场景颜色是HDR的但LUT是LDR的直接采样就会出问题。解决办法是在采样LUT之前先把HDR颜色做一次色调映射映射到LDR范围再采样LUT。我用的色调映射是ACES的近似实现float3 ACESToneMapping(float3 color) { float a 2.51; float b 0.03; float c 2.43; float d 0.59; float e 0.14; return saturate((color * (a * color b)) / (color * (c * color d) e)); }4.3 双面材质法线翻转导致的照明错误双面材质在风格化管线里最容易出的问题是背面照明错误。表现是物体的背面要么全黑要么光照方向完全反了。原因很简单Unity默认只渲染正面法线是朝外的。当你加了Cull Off之后背面也被渲染了但法线还是朝外的所以光照计算就错了。解决办法是在片元着色器里判断当前是正面还是背面float4 frag(Varyings input, bool isFrontFace : SV_IsFrontFace) : SV_Target { float3 normal normalize(input.normalWS); if (!isFrontFace) { normal -normal; } // 后续光照计算用normal }SV_IsFrontFace是HLSL的内置语义Unity的URP是支持的。注意这个语义只能在片元着色器里用顶点着色器里用不了。4.4 性能优化Draw Call合并与RT带宽控制风格化渲染的性能开销主要来自三个方面深度法线RT的生成、描边Pass的全屏采样、以及LUT调色的全屏采样。我实测下来在1080p分辨率下整套系统的开销大约是2到3毫秒在移动端大约是5到8毫秒。优化的第一个点是深度法线RT的生成。如果你的场景里有很多小物件每个物件都渲染一遍深度法线会很浪费。解决办法是用RenderObjectsFeature只对需要描边的大物件渲染深度法线小物件直接跳过。第二个点是描边Pass的采样次数。Roberts Cross算子需要采样4次深度和4次法线加起来8次采样。如果RT尺寸是1080p8次全屏采样的带宽开销不小。解决办法是把深度和法线合并到一张RT里这样只需要4次采样。我前面说的把深度存在Alpha通道里就是这个目的。第三个点是LUT调色的采样次数。标准的3D LUT采样需要2次纹理采样加上插值计算开销不大。但如果你用了高分辨率的LUT比如64x64x64纹理缓存命中率会下降。解决办法是用32x32x32的LUT配合双线性插值效果足够好。4.5 常见问题速查表问题现象可能原因排查方法解决方案描边断裂深度RT精度不够检查RT格式换成RFloat或RGHalf描边闪烁法线RT精度不够检查RT格式换成ARGB2101010LUT偏色色彩空间不匹配检查sRGB选项关掉LUT的sRGBLUT偏暗HDR未做色调映射检查相机HDR加ACES色调映射背面全黑法线未翻转检查Cull Off用SV_IsFrontFace翻转边缘光太宽RimPower太小调整参数增大RimPower高光太散SpecCutoff太小调整参数增大SpecCutoff暗部死黑ShadowLift太小调整参数增大ShadowLift性能开销大RT带宽过高用Profiler看合并RT降低分辨率移动端发热全屏Pass太多用Profiler看合并Pass降低LUT分辨率4.6 实操心得与避坑建议最后分享几个我在实际项目中总结出来的心得。第一个心得是不要一开始就追求完美。我最初做这套系统的时候花了大量时间在描边算法上试了各种边缘检测算子结果发现美术最在意的其实是LUT调色。所以建议你先搭一个能跑通的框架然后让美术用起来根据反馈再迭代。第二个心得是参数不要太多。我一开始给美术开放了二十多个参数结果他们根本不知道怎么调。后来我精简到了十个核心参数每个参数都配了预设值美术的接受度立刻高了很多。第三个心得是LUT是风格化的灵魂。同样的几何和光照换一张LUT就是完全不同的风格。所以建议你把LUT的制作流程标准化让美术在Photoshop里调好颜色然后一键导出。我甚至写了一个Editor脚本可以直接在Unity里预览LUT的效果不用反复导入导出。第四个心得是移动端要慎用全屏Pass。我在移动端项目里把描边和LUT调色合并到了一个Pass里虽然代码复杂了一点但性能提升很明显。另外移动端的RT格式尽量用RGHalf而不是RFloat因为很多移动GPU对RFloat的支持不好。这套系统我前后迭代了大概三个月从最初的纯卡通到现在的混合风格化中间踩了不少坑但也积累了很多经验。如果你也在做类似的东西希望这些内容能帮你少走一些弯路。