渲染系统这个词听起来很唬人但拆开看无非就是两件事怎么把光算对以及怎么把算对的光画得好看。前者是PBR的活儿后者是NPR和调色的地盘。我做过几套从零搭起来的小型渲染管线也接手过别人写了一半的烂摊子踩过的坑比读过的论文还多。这篇东西不打算复述图形学教材而是把一套风格化渲染系统从架构到落地的完整思路摊开讲——包括PBR和NPR怎么在同一个管线里共存、LUT到底该放在哪一步、Shader的模块化怎么设计才不至于三个月后自己都看不懂。适合有一定图形基础、想动手搭一套能跑又能看的渲染系统的朋友也适合正在被项目里那堆散装Shader折磨的同行。1. 先想清楚风格化渲染到底在解决什么问题很多人一上来就急着写Shader结果写到一半发现光照模型和后期调色打架改一个地方崩三个地方。问题出在没想清楚这套系统要服务什么。1.1 真实感与风格化的本质分歧PBR那套东西的核心目标是物理正确。能量守恒、微表面理论、BRDF全都是为了让渲染结果在物理上说得通。它的好处是稳定、可预测换个光照环境不用重新调参。但风格化要的恰恰是物理不正确——我要卡通描边、要色块分明、要那种手绘的笔触感这些东西在物理框架里是异类。我见过最蠢的做法是把NPR效果硬塞进PBR的BRDF里试图通过改参数来调出卡通感。结果就是光照稍微一变整个画面就崩了。正确的思路是PBR负责基础光照的稳定性NPR在光照计算之后、后期处理之前做风格化转换。两者是流水线上的不同工位不是互相替代的关系。具体来说我的管线里光照计算阶段统一用PBR的BRDF输出的是线性的、物理合理的颜色值。然后在这个基础上叠加NPR的着色逻辑——比如把连续的光照强度量化成几个阶梯或者用法线点乘视线方向来生成描边。这样做的好处是即使风格化参数调得很夸张底层光照的稳定性还在不会出现换个角度就全黑的情况。1.2 一套系统要覆盖哪些渲染路径风格化渲染系统不是只做卡通渲染就完事了。实际项目里你至少要覆盖三种路径纯PBR路径用于写实场景、材质预览、需要物理正确的场合纯NPR路径用于角色、UI元素、需要强风格化的场景混合路径同一个场景里既有写实物体又有风格化元素这是最常见的需求混合路径是最麻烦的。你不能简单地用两个Pass分别渲染再叠加因为光照和阴影是共享的。我的做法是在材质层面做区分——每个材质有一个renderMode标记Shader里根据这个标记走不同的着色分支。光照计算只做一次NPR的转换在光照结果上做。这样阴影、反射这些共享的光照信息不会重复计算性能也可控。注意混合路径下NPR物体的阴影投射要特别处理。如果直接用PBR的阴影贴图卡通物体的阴影边缘会太软失去风格感。我的方案是给NPR物体单独生成一张硬边阴影贴图在Shader里根据renderMode选择采样哪张。1.3 为什么LUT是风格化系统的标配LUT这个东西本质上就是一张颜色查找表把输入颜色映射到输出颜色。它在风格化渲染里的价值在于你可以把复杂的调色逻辑烘焙成一张贴图运行时只需要一次纹理采样。我试过纯用Shader做调色写了一堆曲线和色相偏移结果就是参数多到自己都记不住而且每次微调都要重新编译。后来改成LUT方案美术在DaVinci或者Photoshop里调好颜色导出成LUT贴图Shader里就三行代码搞定。效率提升不是一点半点。但LUT有个坑它是在特定色彩空间下工作的。如果你的渲染管线是线性空间LUT贴图也是在线性空间生成的那没问题。但如果LUT是在sRGB空间调的直接采样就会偏色。我的做法是统一在线性空间做LUT导出时用工具转换Shader里不做任何额外的色彩空间转换。这样最不容易出错。2. PBR和NPR在同一个管线里怎么共存这是整套系统最核心的技术难点。我见过太多项目把PBR和NPR做成两套完全独立的管线结果就是光照不统一、阴影对不上、性能翻倍。正确的做法是在光照计算层面统一在着色输出层面分叉。2.1 光照计算的统一层设计我的管线里光照计算是一个独立的函数库输入是法线、视线方向、光源方向、材质参数输出是线性的光照颜色。这个函数库不关心你最终要PBR还是NPR它只负责算物理正确的光照。// 统一光照计算函数 float3 CalculateLighting(float3 N, float3 V, float3 L, MaterialParams mat) { float3 H normalize(L V); float NdotL saturate(dot(N, L)); float NdotV saturate(dot(N, V)); float NdotH saturate(dot(N, H)); // Cook-Torrance BRDF float D DistributionGGX(NdotH, mat.roughness); float G GeometrySmith(NdotV, NdotL, mat.roughness); float3 F FresnelSchlick(saturate(dot(H, V)), mat.f0); float3 specular (D * G * F) / (4.0 * NdotV * NdotL 0.001); float3 diffuse (1.0 - F) * mat.albedo / PI; return (diffuse specular) * lightColor * NdotL; }这个函数算出来的结果是物理正确的但它同时也是NPR着色的基础。NPR要做的不是重新算光照而是在这个结果上做转换。2.2 NPR着色层的转换策略NPR转换层接收PBR的输出然后根据风格化需求做处理。我常用的转换策略有三种第一种是光照量化。把连续的光照强度映射到有限的几个阶梯上。比如把NdotL从[0,1]映射到{0.2, 0.5, 0.8, 1.0}四个值。这样出来的效果就是色块分明的卡通感。实现上用一个简单的阶梯函数就行float ToonStep(float x, float steps) { return floor(x * steps) / (steps - 1.0); }第二种是边缘光增强。用1 - NdotV来生成边缘光再乘以一个颜色和强度参数。这个在卡通渲染里特别常用能让角色从背景里跳出来。第三种是法线扰动。在NPR着色时对法线做轻微的扰动模拟手绘的不规则感。这个要小心扰动太大会导致光照闪烁。这三种策略可以叠加使用但要注意顺序。我的经验是先做光照量化再加边缘光最后做法线扰动。顺序反了效果会不对。2.3 材质系统怎么标记渲染模式材质系统需要给每个材质一个明确的渲染模式标记。我的做法是在材质资产里加一个枚举字段渲染模式标记值光照计算着色转换典型用途PBR0完整BRDF无写实场景、金属、玻璃NPR1完整BRDF量化边缘光角色、卡通场景Hybrid2完整BRDF按遮罩混合混合场景Hybrid模式是最灵活的。它用一个遮罩贴图来控制哪些区域走PBR、哪些区域走NPR。比如一个角色皮肤走NPR但身上的金属配件走PBR。遮罩贴图的R通道控制NPR权重G通道控制边缘光强度B通道控制量化步数。这个设计的代价是Shader变体变多编译时间变长。我的优化方案是把三种模式写在一个Shader里用#pragma multi_compile生成变体运行时根据材质标记切换。这样比写三个独立Shader好维护得多。3. LUT在管线中的正确插入位置LUT用错位置是风格化渲染里最常见的错误之一。我见过有人在光照计算之前就上LUT结果就是光照信息全丢了画面平得像贴纸。3.1 为什么LUT必须在光照之后LUT的本质是颜色映射它假设输入颜色已经包含了所有的光照信息。如果你在光照之前上LUT那LUT映射的是材质的反照率光照加上去之后颜色又变了LUT的效果就完全不对了。正确的顺序是光照计算 → NPR转换 → LUT调色 → 后期处理。LUT在NPR转换之后是因为NPR转换会改变颜色的分布LUT需要在这个最终颜色上做映射。LUT在后期处理之前是因为后期处理比如Bloom、景深会引入新的颜色信息这些信息不应该被LUT影响。提示如果你的管线有多个LUT比如白天一个、夜晚一个切换逻辑要放在LUT采样之前根据时间或场景状态选择采样哪张LUT贴图。不要在Shader里做LUT混合那样性能很差。3.2 LUT贴图的格式与采样方式LUT贴图的标准格式是Hald CLUT一张NxNxN的颜色立方体展开成2D贴图。常见的尺寸是16x16x16、32x32x32、64x64x64。尺寸越大颜色过渡越细腻但内存占用也越大。我的经验是32x32x32足够用了。16的会有明显的色阶64的没必要肉眼几乎看不出区别。内存占用上32x32x32的LUT如果存成RGBA8大概是128KB完全可以接受。采样方式上标准做法是用三个通道的值作为三维坐标去查找float3 ApplyLUT(float3 color, Texture2D lutTex, SamplerState lutSampler) { float lutSize 32.0; float sliceSize 1.0 / lutSize; float slicePixelSize sliceSize / lutSize; float sliceInnerSize slicePixelSize * (lutSize - 1.0); float zSlice0 floor(color.b * (lutSize - 1.0)); float zSlice1 min(zSlice0 1.0, lutSize - 1.0); float zOffset zSlice0 * sliceSize; float2 uv0 float2( slicePixelSize * 0.5 color.r * sliceInnerSize, zOffset slicePixelSize * 0.5 color.g * sliceInnerSize ); float2 uv1 float2(uv0.x, uv0.y sliceSize); float3 lut0 lutTex.Sample(lutSampler, uv0).rgb; float3 lut1 lutTex.Sample(lutSampler, uv1).rgb; float zLerp frac(color.b * (lutSize - 1.0)); return lerp(lut0, lut1, zLerp); }这段代码的关键是sliceInnerSize的计算。很多人直接用color.r * sliceSize结果就是采样位置偏了半个像素颜色会有细微的偏差。加上slicePixelSize * 0.5的偏移才能采到正确的像素中心。3.3 多LUT混合与动态切换实际项目里往往需要多张LUT来应对不同场景。比如白天、黄昏、夜晚各一张或者不同情绪场景各一张。我的做法是用一个LUT数组在Shader里根据全局参数选择索引。但这里有个性能陷阱如果在Shader里用动态索引去采样纹理数组在某些移动端GPU上会退化成循环采样性能极差。我的解决方案是用lerp做混合同时只采样两张LUTfloat3 ApplyLUTBlend(float3 color, Texture2D lutA, Texture2D lutB, float blend) { float3 colA ApplyLUT(color, lutA, linearSampler); float3 colB ApplyLUT(color, lutB, linearSampler); return lerp(colA, colB, blend); }这样最多采样两次性能可控。切换时用blend参数做过渡还能做出LUT渐变的效果。4. Shader的模块化组织与变体管理Shader写多了最大的痛苦不是算法难而是维护难。三个月后回头看自己写的Shader如果是一坨几千行的代码那基本等于重写。我的做法是从一开始就做模块化。4.1 按功能拆分的Shader库结构我的Shader库目录结构是这样的Shaders/ ├── Common/ │ ├── Lighting.hlsl // 光照计算函数 │ ├── BRDF.hlsl // BRDF函数 │ ├── NPR.hlsl // NPR转换函数 │ ├── LUT.hlsl // LUT采样函数 │ └── Utils.hlsl // 通用工具函数 ├── Surface/ │ ├── PBR_Surface.hlsl // PBR表面着色 │ └── NPR_Surface.hlsl // NPR表面着色 ├── PostProcess/ │ ├── Bloom.hlsl │ └── ColorGrading.hlsl └── Main/ ├── StylizedLit.shader // 主Shader └── StylizedUnlit.shader // 无光照Shader每个文件只负责一个功能域通过#include组合。这样改光照算法只需要动Lighting.hlsl改NPR效果只需要动NPR.hlsl不会互相影响。4.2 变体爆炸的预防与处理模块化带来的问题是变体数量爆炸。一个Shader如果有5个功能开关每个开关2种状态那就是32个变体。如果再加上平台差异、质量等级轻松上百。我的控制策略是合并互斥的开关比如USE_NPR和USE_PBR是互斥的用一个枚举代替两个bool用#pragma multi_compile而不是shader_feature对于运行时可能切换的功能用multi_compile对于只在编辑器里切换的用shader_feature剥离不用的变体在构建时用IPreprocessShaders接口剥离掉实际不用的变体实测下来一个功能完整的风格化Shader变体数量控制在50以内是可以做到的。超过这个数就要检查是不是有冗余的开关。4.3 参数传递的规范与性能考量Shader参数传递有个容易被忽略的性能点常量缓冲区Constant Buffer的对齐。HLSL里常量缓冲区的成员是按16字节对齐的如果你把float3和float混在一起会有隐式的padding浪费带宽。我的做法是把参数按类型分组cbuffer MaterialParams : register(b0) { float4 albedo; // 16 bytes float4 specular; // 16 bytes float roughness; // 4 bytes float metallic; // 4 bytes float nprSteps; // 4 bytes float rimIntensity; // 4 bytes // 总共48 bytes无padding };这样每个成员都对齐到16字节边界没有浪费。如果参数更多就拆成多个常量缓冲区按更新频率分组——每帧更新的放一个每材质更新的放一个每DrawCall更新的放一个。这样能减少CPU到GPU的数据传输量。5. 实测中踩过的坑与排查思路理论说再多不如踩一次坑。下面这几个问题都是我实际项目中遇到过的排查过程比结论更有价值。5.1 描边在特定角度消失的问题排查现象是角色在旋转到某个角度时描边会突然消失或者闪烁。一开始以为是描边宽度的问题调了半天没效果。排查过程是这样的先确认描边是在哪个Pass生成的。我的描边是在NPR转换层用1 - NdotV生成的那问题可能出在法线上。把法线可视化出来一看发现角色背面的法线在特定角度下和视线方向几乎垂直NdotV接近01 - NdotV接近1按理说描边应该最强才对。再仔细看发现是法线贴图的问题。角色的法线贴图在UV接缝处有断裂导致法线方向突变NdotV的计算结果跳变。解决方案是在采样法线贴图后做一次平滑或者直接用几何法线来算描边不用法线贴图的法线。这个坑的教训是NPR效果对法线的连续性很敏感任何法线突变都会导致效果闪烁。如果非要用切线空间法线至少要在接缝处做平滑处理。5.2 LUT导致的色带问题与解决方案用LUT调色后在渐变色区域出现了明显的色带。一开始以为是LUT精度不够从16换到32色带减轻了但没完全消失。后来用示波器看颜色分布发现色带出现在LUT的切片边界上。原因是LUT采样时蓝色通道的插值在切片边界处不连续。标准的三线性插值在切片边界处会有微小的不连续因为切片之间的过渡是线性的但颜色变化可能不是线性的。解决方案有两个一是用更高精度的LUT格式比如RGBA16F让每个通道有更多的量化级别二是在采样时加一点抖动dithering用噪声打散色带。我两个都用了效果很好。抖动噪声用蓝噪声贴图比白噪声更不容易被察觉。注意抖动会增加噪点如果项目对画质要求极高可以只在暗部区域加抖动亮部不加。因为人眼对暗部的色带更敏感。5.3 移动端性能优化的实际数据移动端做风格化渲染最大的瓶颈是带宽和ALU。我实测过几个优化手段的效果优化手段帧率提升画质损失适用场景LUT从32降到168%轻微色带中低端机型NPR量化步数从8降到45%色块更明显风格化强的场景关闭边缘光12%角色立体感下降性能优先法线贴图从RGBA降到RG6%法线精度下降移动端通用阴影贴图从2048降到102415%阴影边缘变糊中低端机型这些数据是在骁龙865上测的不同机型会有差异。我的建议是做一个质量等级系统根据设备性能动态调整这些参数。高端机全开中端机降LUT和阴影低端机再降NPR步数和边缘光。5.4 Shader编译时间过长的优化项目后期Shader变体多了之后编译时间从几秒涨到了几分钟。每次改一行代码都要等半天效率极低。优化手段有几个一是用#pragma skip_variants跳过不需要的变体组合二是把不常改的Shader预编译成二进制缓存三是用Shader编译服务器的分布式编译。我用了前两个编译时间从3分钟降到了20秒左右。还有一个技巧是把Shader拆成多个小的.hlsl文件每个文件单独编译。这样改一个文件只编译那个文件不用全量编译。Unity的Shader编译器支持这个但需要手动配置依赖关系。6. 从零搭建一套可用的风格化渲染系统前面讲的都是零件这一节把零件组装起来给一个完整的搭建流程。6.1 环境准备与基础管线搭建首先确定你的渲染管线。如果是UnityURP和HDRP都支持自定义Shader但URP更轻量适合移动端和风格化项目。HDRP的PBR更完整但NPR支持需要自己写。我的建议是URP起步因为它的Shader结构更清晰改起来方便。基础管线搭建步骤创建URP项目配置好渲染管线资产设置色彩空间为Linear这是LUT正确工作的前提配置好阴影和光照确保PBR部分能正常工作创建一个基础的Lit Shader验证PBR光照正确这一步不要急着加NPR和LUT先把PBR跑通。PBR不对的话后面NPR转换的基础就是错的。6.2 光照模块与NPR转换模块的对接PBR跑通后开始加NPR转换层。在Lit Shader的光照计算之后插入NPR转换函数// 光照计算 float3 lighting CalculateLighting(N, V, L, mat); // NPR转换 float3 nprColor ApplyNPR(lighting, N, V, nprParams); // 最终颜色 float3 finalColor lerp(lighting, nprColor, nprWeight);nprWeight从材质参数里来0就是纯PBR1就是纯NPR中间值就是混合。这样一套Shader同时支持三种模式。对接时要注意NPR转换的输入是光照颜色不是材质反照率。很多人搞混这一点把反照率拿去做NPR转换结果就是光照信息全丢了。6.3 LUT调色模块的接入与调试NPR转换跑通后接入LUT模块。在最终颜色输出之前插入LUT采样// LUT调色 float3 gradedColor ApplyLUT(finalColor, lutTexture, lutSampler); // 输出 return float4(gradedColor, alpha);调试LUT时先放一张identity LUT输入等于输出的LUT确认采样逻辑正确。identity LUT的颜色应该和输入完全一致如果有偏差说明采样代码有问题。确认采样正确后再换成实际的LUT贴图。调LUT时建议在DaVinci里调它的调色工具比Photoshop专业得多。调好后导出Hald CLUT格式直接给Shader用。6.4 完整系统的性能验收标准系统搭好后需要一套性能验收标准。我的标准是PC端1080p分辨率中端显卡GTX 1660级别稳定60帧移动端720p分辨率中端机型骁龙7系稳定30帧DrawCall单个角色不超过5个DrawCallShader变体单个Shader不超过50个变体内存占用LUT贴图不超过512KB法线贴图不超过2MB达不到这些标准就要优化。优化的优先级是先降LUT精度再降阴影分辨率最后降NPR效果。因为LUT和阴影对画质的影响相对较小NPR效果是风格化的核心尽量保留。7. 一些零散但重要的经验最后分享几个零散的经验都是实际项目中总结出来的不一定系统但都管用。关于法线NPR效果对法线质量极其敏感。如果发现NPR效果不对劲第一件事就是可视化法线看看。法线贴图的压缩格式、UV接缝、切线空间的计算任何一个环节出问题都会导致NPR效果异常。关于色彩空间整个管线必须统一色彩空间。我的建议是全程Linear只在最终输出时转到sRGB。LUT贴图也必须在Linear空间生成如果在sRGB空间调的LUT导入时要记得转换。关于参数暴露Shader参数不要暴露太多给美术。我见过一个Shader暴露了50多个参数美术根本不知道怎么调。我的做法是只暴露最核心的5-8个参数其他的用预设值或者根据场景自动计算。关于版本管理Shader文件一定要用版本管理工具。我吃过亏改了一版Shader效果很好结果被同事覆盖了找都找不回来。现在我的Shader目录是单独的Git仓库每次改动都有记录。关于测试风格化渲染的测试不能只看静态截图。一定要在动态光照下测试因为很多问题比如描边闪烁、LUT色带只在动态下才会暴露。我的测试场景是一个旋转的光源加一个旋转的角色跑一圈下来基本能发现大部分问题。关于文档每个Shader模块都要写注释说明输入输出和依赖关系。我现在的习惯是每个.hlsl文件头部写一段注释说明这个文件的功能、依赖的其他文件、以及使用示例。这样即使过了半年回头看也能快速理解。关于性能分析不要凭感觉优化。用GPU Profiler看实际的耗时分布找到真正的瓶颈再优化。我见过有人花了一周优化一个只占2%耗时的函数而真正的瓶颈一个占40%的阴影计算完全没动。关于跨平台不同GPU对Shader的支持有差异。比如某些移动端GPU不支持动态循环某些不支持纹理数组。写Shader时要查目标平台的特性支持表避免用了不支持的特性。我的做法是先在PC上开发然后定期在移动端真机上测试发现问题及时改。关于LUT的制作LUT不是万能的。它只能做颜色映射不能做亮度映射。如果场景的曝光变化很大LUT会失效。这种情况下需要在LUT之前做一次自动曝光把亮度归一化到LUT的工作范围。关于NPR的量化步数量化步数不是越多越好。步数太多就失去了卡通感步数太少色块太硬。我的经验是4-6步最适合卡通风格8步以上就接近写实了。具体步数要根据项目风格定没有标准答案。关于边缘光的颜色边缘光不要用纯白色。纯白色的边缘光会让角色看起来像塑料。用比主光颜色稍暖或稍冷的颜色效果更自然。我常用的是主光颜色的互补色强度控制在0.3-0.5之间。关于阴影NPR物体的阴影要单独处理。PBR的软阴影用在卡通物体上会很违和。我的做法是给NPR物体生成硬边阴影边缘不做滤波。如果项目允许还可以给阴影加一点手绘的抖动模拟手绘阴影的不规则感。关于反射NPR物体通常不需要精确的反射。如果场景里有反射探针NPR物体的反射可以用一个简单的环境色代替不需要采样反射贴图。这样能省不少性能。关于抗锯齿风格化渲染对锯齿特别敏感因为色块边界很清晰锯齿会非常明显。我的建议是至少开4x MSAA如果性能允许用TAA更好。但TAA在NPR物体上可能会有拖影需要调整参数。关于后处理Bloom在风格化渲染里要慎用。过强的Bloom会让色块边界模糊失去风格感。我的做法是Bloom强度控制在0.2以下阈值调高只让最亮的部分产生Bloom。关于材质预设给美术提供几个材质预设比如卡通皮肤、卡通金属、卡通布料每个预设调好参数。美术直接选预设不用从零调。这样能保证风格一致性也能减少美术的学习成本。关于迭代速度风格化渲染的调参过程很依赖直觉所以迭代速度很重要。我的做法是把所有风格化参数放在一个ScriptableObject里运行时可以实时调整不用重新编译Shader。这样调参效率能提升好几倍。关于参考做风格化渲染一定要有参考。找一些你喜欢的风格化游戏或者动画截图分析它们的颜色分布、光照处理、描边方式。我的参考库里有几十张截图每次调参都会翻出来对比。关于团队协作如果团队里有多个TAShader的命名规范要统一。我的规范是功能_类型_变体比如NPR_Surface_Toon、PBR_Surface_Metal。这样找文件很快也不容易冲突。关于学习路径风格化渲染涉及的知识面很广从图形学基础到色彩理论到Shader编程。我的建议是先搞定PBR理解光照计算的原理然后再学NPR。PBR是基础NPR是在PBR之上的风格化转换。跳过PBR直接学NPR很多问题会理解不透。关于工具链LUT的制作工具我推荐DaVinci Resolve免费版就够用。Shader开发用VSCode加Shader插件比在Unity里写舒服得多。性能分析用RenderDoc或者Unity的Profiler看GPU耗时分布。关于心态风格化渲染没有标准答案同一个效果有无数种实现方式。不要追求正确的实现追求适合你项目的实现。我见过有人为了追求物理正确的NPR写了半年Shader结果效果还不如别人用简单方法做的。实用主义在风格化渲染里比理论完美更重要。