1. 卡通渲染在UE里到底难在哪
很多人第一次在Unreal里尝试卡通风格化渲染,都会经历一个相似的困惑:材质编辑器里明明调出了不错的色块效果,一放进场景就发现光影完全不对味。要么是明暗交界线糊成一团,要么是描边粗细随距离疯狂变化,再或者角色一动起来,高光位置就跟着乱跑。这些问题的根源,其实不在于某个节点连错了,而在于卡通渲染和UE默认的PBR管线在底层逻辑上就是两套东西。
UE的默认光照模型是基于物理的,它追求的是能量守恒和真实感。漫反射用Lambert,高光用GGX,阴影用级联阴影贴图加距离场环境光遮蔽。这套东西做写实场景非常强,但卡通渲染要的是“人为控制的色阶”和“清晰的轮廓线”,它不追求物理正确,追求的是视觉上的干净和可控。所以做卡通渲染的第一步,不是去调材质,而是先想清楚:你要在哪一层去干预UE的渲染管线。
常见的干预层级有三个。最浅的一层是纯后处理,用PostProcessMaterial做边缘检测和颜色量化,好处是不动原始材质,坏处是描边容易受深度和法线精度影响,细碎物体上全是噪点。中间一层是自定义光照模型,通过Material里的ShadingModel或者LightFunction去改漫反射和高光的计算方式,这是目前最主流的做法,灵活度高,性能也可控。最深的一层是改渲染管线本身,用CustomMeshPass或者SceneViewExtension去插入自己的绘制阶段,这适合做大规模的风格化场景,但门槛高,调试成本也大。
我个人的经验是,百分之八十的卡通渲染需求,在自定义光照模型这一层就能解决。你不需要去动引擎源码,也不需要写复杂的全局着色器,只要把材质的ShadingModel改成Unlit,然后在Emissive通道里自己算光照,就能得到非常干净的色阶效果。这个思路听起来简单,但它绕开了UE默认光照模型里那些为写实服务的复杂计算,让你对最终画面的控制力强了很多。
当然,Unlit方案也有它的代价。你失去了引擎自带的阴影投射和接收能力,需要自己用LightVector和ShadowMap去算阴影。而且Unlit材质不参与延迟渲染的GBuffer写入,如果你后面还要做SSR或者SSAO,就得额外处理。所以到底选哪条路,取决于你的项目是角色特写为主,还是大场景漫游为主。角色特写用Unlit加自定义阴影完全够用,大场景的话,还是建议在默认光照模型基础上做改造。
2. 色阶分层与明暗交界线的控制逻辑
卡通渲染最核心的视觉特征,就是明暗之间不是平滑过渡,而是被切成几段明确的色阶。这个切分动作,在数学上就是一个阶梯函数。UE材质编辑器里没有现成的Step节点,但你可以用Floor或者Ceil配合除法来实现。具体做法是:先把光照结果归一化到0到1之间,然后乘以你想要的色阶层数,取整之后再除回去。比如你要三层色阶,就是Floor(LightResult * 3) / 3。这样得到的结果就只有0、0.33、0.66、1这四个值,明暗交界线就出来了。
但这里有个细节很多人会忽略:直接对光照结果做阶梯化,在明暗交界处会产生很硬的锯齿。因为光照是逐像素计算的,相邻像素的光照值可能刚好跨过阶梯阈值,一个像素在亮部,旁边一个像素就掉到暗部,视觉上就是一条狗牙边。解决办法是在阶梯化之前,先对光照结果做一个平滑处理,或者用SmoothStep在阈值附近做一个窄范围的过渡。我通常会在阶梯化之后,再用一个Lerp把原始光照和阶梯化结果按一定比例混合,混合系数控制在0.1到0.2之间,既能保留色阶感,又能让边缘不那么刺眼。
明暗交界线的位置控制,是另一个需要精细调节的地方。在真实光照里,交界线位置由光源方向和表面法线决定,但在卡通渲染里,你往往希望交界线能按照美术的意图偏移。比如角色脸部,你希望交界线刚好落在颧骨下方,而不是被鼻子的阴影打断。这时候就需要引入一张控制贴图,用它的R通道去偏移光照计算的阈值。这张贴图可以是手绘的,也可以用顶点色烘焙。我见过一些项目直接用角色的法线贴图反相来做偏移,效果也不错,但可控性不如手绘贴图。
还有一个容易被忽视的点是环境光的处理。UE的默认环境光会参与漫反射计算,导致暗部不够暗,色阶感被冲淡。做卡通渲染时,通常要把环境光对漫反射的贡献降到很低,或者干脆在材质里把环境光项去掉,只保留一个固定的暗部颜色。这个暗部颜色不要用纯黑,用主色调的补色或者深紫色会更有卡通感。我在一个项目里试过用深蓝色做暗部,角色看起来就像被月光照着,氛围感一下子就出来了。
色阶的数量也不是越多越好。两层色阶适合极简风格,三层是最常见的,四层以上就开始接近写实了,卡通感反而会减弱。如果你做的是日式动画风格,三层色阶加一个高光层基本就够了。高光层单独用一张贴图控制形状,不要用光照计算出来的高光,因为计算出来的高光形状太随机,不符合动画里高光要“画”出来的逻辑。
3. 描边方案的取舍:法线外扩还是后处理
描边是卡通渲染的另一半灵魂。没有描边的卡通角色,看起来就像没勾线的草稿。UE里做描边,主流方案就两种:法线外扩和后处理边缘检测。这两种方案各有各的坑,选哪个取决于你的模型精度和性能预算。
法线外扩的原理很简单:把模型顶点沿着法线方向往外推一点,用一个只渲染背面且不写深度的材质画出来,就形成了一圈轮廓。这个方案的优点是描边粗细均匀,不受场景深度影响,而且可以很方便地用法线贴图或者顶点色去控制局部粗细。缺点是遇到硬边模型会裂开,因为硬边的顶点法线是分裂的,外扩之后每个面各走各的,描边就断了。解决办法是在建模阶段就把法线平滑好,或者在引擎里用SmoothNormal工具重新计算一套平滑法线存到UV通道里。我一般会在导入模型时勾选“Recompute Normals”,然后用一个简单的材质函数把平滑法线写进顶点色,描边材质读顶点色而不是读原始法线。
法线外扩的另一个坑是描边在屏幕空间里的粗细会随距离变化。离相机远的时候,外扩的顶点在屏幕上移动的距离变小,描边就变细了。如果你想要恒定屏幕粗细的描边,需要在材质里根据相机距离去动态调整外扩量。具体做法是用相机到顶点的距离除以一个系数,再乘到外扩方向上。这个系数需要根据你的相机FOV和分辨率去调,没有万能值。我通常会在材质里暴露一个参数,让美术在场景里实时调。
后处理描边的原理是在屏幕空间做边缘检测,通常用Sobel算子或者Roberts算子去检测深度和法线的突变。这个方案的优点是描边不会裂,而且可以做出粗细变化丰富的效果,比如根据深度调整粗细,近处粗远处细。缺点是容易在细碎物体上产生噪点,比如树叶、毛发、栅栏,这些地方深度和法线变化剧烈,边缘检测会疯狂触发。解决办法是加一个深度阈值和法线阈值,只有超过阈值的边缘才画描边。但阈值调高了会漏掉一些该有的描边,调低了又全是噪点,需要反复试。
我个人的选择是:角色和主要道具用法线外扩,场景和背景用后处理描边。这样角色描边干净可控,场景描边又有深度变化。两者结合的时候要注意渲染顺序,法线外扩的描边材质要设置成不写深度、不读深度,后处理描边要在所有不透明物体之后执行。如果场景里有半透明物体,后处理描边还要决定要不要对半透明物体生效,这个就看具体需求了。
还有一个取巧的做法,是用UE的MeshDrawCommand或者CustomDepth通道来做描边。把需要描边的物体渲染到CustomDepth,然后在后处理里对这个深度图做边缘检测。这个方案的好处是描边和物体本身解耦,你可以自由控制哪些物体有描边哪些没有。坏处是CustomDepth的精度有限,远距离物体的描边会闪烁。我一般会把CustomDepth的精度调到最高,然后配合一个距离衰减来缓解闪烁。
4. 高光与边缘光的风格化处理
卡通渲染里的高光,和写实渲染里的高光完全是两码事。写实高光是光源在粗糙表面上的物理反射,形状由法线和粗糙度决定。卡通高光则是美术画上去的,它要的是形状可控、位置可控、大小可控。所以做卡通高光,不要用UE默认的Specular通道,那个是给物理高光用的。你应该在Emissive通道里自己算一个高光形状出来。
最常用的卡通高光算法是Blinn-Phong的变体:先算半角向量,然后和法线点乘,得到一个高光强度,再用一个阈值把它切成硬边。这个阈值可以用一张高光贴图来控制,贴图上画什么形状,高光就是什么形状。贴图的UV通常用模型的UV,但有时候为了高光跟着相机走,也会用屏幕空间UV或者视角空间UV。我见过一些项目用MatCap来做高光,效果很稳定,因为MatCap本身就是一张预渲染的球面光照图,高光形状完全由贴图决定,不受场景光照影响。
边缘光,也叫Rim Light,是卡通渲染里用来增强轮廓感的另一个手段。它的原理是当法线垂直于视线时,边缘光最强。计算方法是1 - dot(Normal, ViewDir),然后取幂次控制边缘光的宽度。边缘光通常加在Emissive通道里,颜色可以和环境光互补,也可以和主光同色。我一般会把边缘光和高光分开控制,高光用高光贴图,边缘光用顶点色或者单独的一张遮罩贴图。这样美术可以单独调整每个部分的边缘光强度,比如头发边缘光强一点,身体边缘光弱一点。
边缘光的一个常见问题是会在角色正面也出现,因为正面法线和视线夹角小,按理说边缘光应该弱,但如果模型法线不平滑,正面也会出现边缘光。解决办法是用一个Fresnel节点,把边缘光限制在法线变化剧烈的区域。或者用一张遮罩贴图,把正面区域遮掉。我通常会在材质里加一个Saturate(dot(Normal, ViewDir))的项,用它去乘边缘光,这样正面边缘光自然就弱了。
高光和边缘光的颜色,不要用纯白。纯白在卡通渲染里会显得很突兀,像贴上去的。用主色调的浅色,或者带一点环境色倾向的白色,会自然很多。比如角色主色是红色,高光可以用浅粉色;主色是蓝色,高光可以用浅青色。边缘光同理,用主色的补色或者邻近色,能增强画面的色彩层次。我在一个项目里试过用暖色边缘光配冷色主光,角色看起来就像被夕阳照着,氛围感很强。
还有一个细节是,高光和边缘光要不要受阴影影响。在写实渲染里,阴影里的高光会被遮挡,但在卡通渲染里,高光通常是画上去的,不应该被阴影影响。所以计算高光时,不要乘阴影因子。边缘光也一样,它本身就是用来强调轮廓的,如果被阴影遮掉就失去意义了。这一点在材质里要注意,不要把阴影计算的结果混进高光和边缘光的通道里。
5. 材质函数封装与性能优化
当你把色阶、描边、高光、边缘光都调通之后,下一步就是把这些东西封装成可复用的材质函数。这一步很重要,因为卡通渲染的参数很多,如果每个材质都从头连一遍,不仅效率低,而且容易出错。封装成材质函数之后,美术只需要暴露几个关键参数,比如色阶层数、描边颜色、高光强度、边缘光宽度,就能快速应用到新模型上。
封装的时候要注意参数的命名和分组。我通常会把参数分成三组:基础色组、光照组、描边组。基础色组包括主色、暗部色、高光色;光照组包括色阶层数、明暗阈值、边缘光宽度;描边组包括描边颜色、描边粗细、描边距离衰减。每组参数在材质实例里会折叠在一起,美术调起来很直观。参数命名不要用缩写,用完整的英文单词,比如BaseColor、ShadowColor、RimLightWidth,这样别人一看就懂。
性能优化是卡通渲染里容易被忽视的一环。因为卡通渲染通常用Unlit材质,很多人就觉得Unlit一定比Lit快。其实不一定。Unlit材质虽然省掉了光照计算,但如果你在Emissive里塞了太多纹理采样和数学运算,它的开销可能比默认的Lit材质还大。我见过一个项目,角色材质里采了八张纹理,每张纹理都做了一遍UV变换,最后在移动端上帧率直接掉到二十几。后来把纹理合并成两张图集,UV变换也简化了,帧率就回到了六十。
纹理采样的优化原则是:能合并就合并,能不用就不用。色阶控制贴图、高光贴图、边缘光遮罩,这三张图完全可以合并到一张RGB贴图里,R通道做色阶控制,G通道做高光,B通道做边缘光遮罩。这样一次采样就能拿到三个信息,比采三次省了三分之二的纹理带宽。UV变换也一样,如果三张图用的是同一套UV,就不要分别做变换,先变换UV再采样,或者干脆不变换。
数学运算的优化,主要是减少依赖纹理读取的运算。比如dot(Normal, LightDir)这种计算,如果LightDir是固定的,可以在顶点着色器里算好,传给像素着色器。如果Normal是固定的,可以烘焙到顶点色里。UE的材质编辑器里,顶点着色器和像素着色器的分界不是很明显,但你可以用VertexInterpolator节点把一些计算强制放到顶点阶段。这个节点在移动端上特别有用,因为移动端的像素着色器开销比顶点着色器大得多。
还有一个性能陷阱是描边的Overdraw。法线外扩的描边材质虽然简单,但它是在物体背面渲染的,如果场景里有很多重叠的物体,描边的Overdraw会很高。解决办法是给描边材质加一个深度测试,只渲染没有被遮挡的部分。或者用Stencil Buffer把描边限制在特定区域。UE里可以用Allow Custom Depth Writes配合后处理来做,但配置起来比较麻烦。我通常会在项目设置里把描边的渲染队列调后一点,让它在不透明物体之后渲染,这样深度测试就能自然剔除掉被遮挡的描边。
6. 从单角色到全场景的扩展思路
单个角色的卡通渲染调好之后,下一步就是把它扩展到整个场景。这一步的难度不在于技术,而在于一致性。角色上的色阶、描边、高光参数,放到场景物件上可能就不合适了。比如角色的描边粗细是2个像素,放到一棵树上,2个像素的描边可能细得看不见;放到一栋建筑上,2个像素又粗得像马克笔画的。所以场景物件的卡通渲染参数,需要根据物件的尺寸和距离单独调整。
我的做法是给场景物件分三类:近景、中景、远景。近景物件用和角色一样的参数,描边粗细2到3个像素,色阶三层。中景物件描边减到1到2个像素,色阶减到两层,因为中景物件细节少,三层色阶反而显得脏。远景物件干脆不要描边,色阶也只用一层,靠颜色和雾效来区分层次。这样分下来,整个场景的卡通感是统一的,但每个距离段又有自己的细节层次。
场景物件的材质,不要每个都单独调。用Material Instance和参数集来管理。UE的Material Parameter Collection可以跨材质共享参数,比如全局的描边颜色、全局的色阶层数。这样你改一个参数,所有用了这个参数集的材质都会跟着变。我通常会把描边颜色、暗部色、高光色这三个参数放到参数集里,色阶层数和描边粗细则根据物件类型单独设。这样既保证了大色调的统一,又保留了单个物件的调整空间。
光照环境的一致性也很重要。角色在室内和室外的卡通渲染效果应该是不一样的,因为环境光变了。但如果你用的是Unlit材质,环境光根本不影响材质,角色在室内室外看起来一模一样,这就很假。解决办法是在材质里加一个环境光探针的采样,用探针的颜色去影响暗部色。UE的SkyLight节点可以拿到环境光颜色,把它和暗部色做Lerp,就能让角色在不同光照环境下有色调变化。这个变化不需要很强烈,一点点就够了,但有了它,角色就和场景融合了。
最后是后处理链的配合。卡通渲染通常需要配合一些后处理来增强风格感,比如颜色分级、暗角、噪点。颜色分级可以把整体色调往暖色或者冷色偏,暗角可以聚焦视线,噪点可以增加手绘感。这些后处理不要加得太重,一点点就够了。我见过一些项目后处理加得太猛,画面糊成一团,卡通感反而没了。后处理的顺序也有讲究,颜色分级要在描边之前,暗角和噪点要在描边之后,这样描边不会被颜色分级影响,暗角和噪点又能覆盖整个画面。
还有一个扩展方向是动态天气和时间变化。卡通渲染的场景如果一直是同一个光照,会显得很死板。你可以用UE的Time of Day系统去驱动主光方向和颜色,然后让材质的暗部色和高光色跟着主光颜色走。这样早上是暖色光配冷色暗部,傍晚是冷色光配暖色暗部,场景就有了时间感。这个做法在开放世界项目里很常见,技术难度不大,但效果提升很明显。
7. 调试工具与常见问题排查
卡通渲染的调试,比写实渲染要麻烦,因为很多问题是“看起来不对”但说不清哪里不对。我一般会准备几个调试视图,用来快速定位问题。第一个是光照结果视图,把材质的Emissive直接输出成光照计算的原始结果,看看色阶切分之前的数值分布。第二个是法线视图,把世界空间法线输出成颜色,检查法线有没有异常。第三个是描边视图,把描边材质单独渲染出来,看看描边有没有断裂或者过粗。
色阶切分不干净,是最常见的问题。表现是明暗交界处有噪点或者渐变。原因通常是光照结果在阈值附近抖动。解决办法是在阶梯化之前,先对光照结果做一个模糊,或者用SmoothStep在阈值附近做一个窄过渡。如果还是不行,就检查一下光照计算里有没有用到屏幕空间导数,比如ddx、ddy,这些在移动端上精度很低,容易产生噪点。把导数计算换成解析计算,或者干脆去掉,通常能解决。
描边断裂,通常发生在硬边模型上。检查一下模型的法线是不是分裂的,如果是,就在建模软件里把法线平滑好,或者在UE里用SmoothNormal重新计算。如果模型法线没问题,就检查描边材质的外扩方向是不是用了顶点法线。有些模型导入UE后法线会被重新计算,导致和建模软件里的不一致。可以在导入设置里把Normal Import Method改成Import Normals,保持和建模软件一致。
描边闪烁,通常发生在后处理描边上。原因是深度图精度不够,远距离物体的深度值变化很小,边缘检测就检测不到。解决办法是提高CustomDepth的精度,或者给后处理描边加一个距离衰减,远距离物体不画描边。如果闪烁发生在法线外扩描边上,那可能是外扩量太小,顶点在屏幕上移动的距离不到一个像素。把外扩量调大一点,或者根据相机距离动态调整外扩量,就能解决。
高光位置不对,通常是高光计算里用了错误的向量。检查一下半角向量是不是用LightDir + ViewDir算的,法线是不是世界空间法线。如果高光跟着相机转,那可能是用了视角空间法线,改成世界空间法线就好。如果高光形状不对,检查一下高光贴图的UV是不是和模型UV匹配,贴图有没有被压缩或者拉伸。
性能问题,通常出在纹理采样和Overdraw上。用UE的Shader Complexity视图看一下,红色区域就是开销大的地方。如果角色材质是红色的,就检查纹理采样次数,能合并的合并,能去掉的去掉。如果场景是红色的,就检查描边的Overdraw,给描边材质加深度测试,或者用Stencil Buffer限制描边区域。移动端上还要注意材质里的数学运算,尽量用VertexInterpolator把计算放到顶点阶段。
还有一个隐蔽的问题是材质实例的编译。卡通渲染的材质函数通常很复杂,如果每个材质实例都单独编译,编译时间会很长。解决办法是把材质函数里的分支和静态开关都用StaticSwitch节点,这样不同的配置会编译成不同的Shader变体,运行时切换不需要重新编译。但StaticSwitch用多了会导致Shader变体爆炸,编译时间反而更长。我一般只把最影响性能的开关做成StaticSwitch,比如描边开不开、色阶层数变不变,其他的都用动态分支或者参数控制。
最后说一个我踩过的坑:卡通渲染的材质在编辑器里预览正常,打包之后效果不对。原因通常是材质里用了编辑器专用的节点,比如DebugScalar、DebugVector,这些节点在打包后会被优化掉。或者用了SceneTexture节点,但打包后SceneTexture的配置和编辑器里不一样。解决办法是在打包前把所有调试节点去掉,SceneTexture的配置在项目设置里统一好。打包之后一定要在真机上跑一遍,编辑器里的效果和真机上的效果经常有差异,尤其是移动端。