渲染系统这个词听起来很唬人,但拆开看无非就是两件事:怎么把光算对,以及怎么把算对的光画得好看。前者是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 材质系统怎么标记渲染模式
材质系统需要给每个材质一个明确的渲染模式标记。我的做法是在材质资产里加一个枚举字段:
| 渲染模式 | 标记值 | 光照计算 | 着色转换 | 典型用途 |
|---|---|---|---|---|
| PBR | 0 | 完整BRDF | 无 | 写实场景、金属、玻璃 |
| NPR | 1 | 完整BRDF | 量化+边缘光 | 角色、卡通场景 |
| Hybrid | 2 | 完整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做混合,同时只采样两张LUT:
float3 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接近0,1 - NdotV接近1,按理说描边应该最强才对。
再仔细看,发现是法线贴图的问题。角色的法线贴图在UV接缝处有断裂,导致法线方向突变,NdotV的计算结果跳变。解决方案是在采样法线贴图后做一次平滑,或者直接用几何法线来算描边,不用法线贴图的法线。
这个坑的教训是:NPR效果对法线的连续性很敏感,任何法线突变都会导致效果闪烁。如果非要用切线空间法线,至少要在接缝处做平滑处理。
5.2 LUT导致的色带问题与解决方案
用LUT调色后,在渐变色区域出现了明显的色带。一开始以为是LUT精度不够,从16换到32,色带减轻了但没完全消失。
后来用示波器看颜色分布,发现色带出现在LUT的切片边界上。原因是LUT采样时,蓝色通道的插值在切片边界处不连续。标准的三线性插值在切片边界处会有微小的不连续,因为切片之间的过渡是线性的,但颜色变化可能不是线性的。
解决方案有两个:一是用更高精度的LUT格式,比如RGBA16F,让每个通道有更多的量化级别;二是在采样时加一点抖动(dithering),用噪声打散色带。我两个都用了,效果很好。抖动噪声用蓝噪声贴图,比白噪声更不容易被察觉。
注意:抖动会增加噪点,如果项目对画质要求极高,可以只在暗部区域加抖动,亮部不加。因为人眼对暗部的色带更敏感。
5.3 移动端性能优化的实际数据
移动端做风格化渲染最大的瓶颈是带宽和ALU。我实测过几个优化手段的效果:
| 优化手段 | 帧率提升 | 画质损失 | 适用场景 |
|---|---|---|---|
| LUT从32降到16 | 8% | 轻微色带 | 中低端机型 |
| NPR量化步数从8降到4 | 5% | 色块更明显 | 风格化强的场景 |
| 关闭边缘光 | 12% | 角色立体感下降 | 性能优先 |
| 法线贴图从RGBA降到RG | 6% | 法线精度下降 | 移动端通用 |
| 阴影贴图从2048降到1024 | 15% | 阴影边缘变糊 | 中低端机型 |
这些数据是在骁龙865上测的,不同机型会有差异。我的建议是做一个质量等级系统,根据设备性能动态调整这些参数。高端机全开,中端机降LUT和阴影,低端机再降NPR步数和边缘光。
5.4 Shader编译时间过长的优化
项目后期Shader变体多了之后,编译时间从几秒涨到了几分钟。每次改一行代码都要等半天,效率极低。
优化手段有几个:一是用#pragma skip_variants跳过不需要的变体组合;二是把不常改的Shader预编译成二进制缓存;三是用Shader编译服务器的分布式编译。我用了前两个,编译时间从3分钟降到了20秒左右。
还有一个技巧是把Shader拆成多个小的.hlsl文件,每个文件单独编译。这样改一个文件只编译那个文件,不用全量编译。Unity的Shader编译器支持这个,但需要手动配置依赖关系。
6. 从零搭建一套可用的风格化渲染系统
前面讲的都是零件,这一节把零件组装起来,给一个完整的搭建流程。
6.1 环境准备与基础管线搭建
首先确定你的渲染管线。如果是Unity,URP和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就是纯PBR,1就是纯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个DrawCall
- Shader变体:单个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。这样调参效率能提升好几倍。
关于参考:做风格化渲染一定要有参考。找一些你喜欢的风格化游戏或者动画,截图分析它们的颜色分布、光照处理、描边方式。我的参考库里有几十张截图,每次调参都会翻出来对比。
关于团队协作:如果团队里有多个TA,Shader的命名规范要统一。我的规范是功能_类型_变体,比如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,结果效果还不如别人用简单方法做的。实用主义在风格化渲染里比理论完美更重要。