下班后突然想把手头一个偏写实风格的 Demo 转成卡通渲染,又不想去动角色材质、灯光和场景资产,第一反应就是做一张后处理材质。UE4 的 Post Process 材质本质上就是全屏滤镜,画面渲染完成后,在最终输出前对每一个像素做一次颜色重写;用它来做卡通风格,等于给整个场景无差别地套了一套“漫画滤镜”,无论是石头、树木还是角色,都会被统一成同一种语言。这篇文章就拆解一下我做的这个卡通 Post Process 材质球,从思路到落地的全过程,包含色阶分离、边缘描边、颜色调整的节点实现,以及几个踩坑以后才搞明白的细节。如果你刚接触 UE4 材质编辑器,想快速做出一个能直接放进项目的卡通风格画面,这篇应该能帮到你。
1. 为什么卡通感能靠一张全屏材质做出来
1.1 后处理材质到底在渲染流程的哪个位置
UE4 的渲染流程大致是:场景中的所有物体先被渲染到一张或者多张全屏 Render Target 上,然后经过光照、阴影、雾效、抗锯齿,最后再交给后处理阶段。后处理阶段做的事情包括色调映射、泛光、颜色校正这些。Post Process 材质就是在这个阶段插入一段完全自定义的处理逻辑,它拿到上一步输出的屏幕颜色,对每个像素重新计算一遍,再输出到下一环节。
在材质编辑器里,这一步的入口就是PostProcessInput0节点。它的值可以理解为“当前屏幕上已经画好的颜色”,你后续做的所有处理,都是基于这个颜色再加工。比如把颜色乘以 2,画面就会整体变亮;把颜色压到几个固定色阶,就会出现类似卡通渲染里那种大色块明暗关系。
这里有个新手容易混淆的点:普通材质作用于物体表面,输出的是“这张皮长什么样”;后处理材质作用于整块屏幕,输出的是“这个画面最终长什么样”。所以它天然适合做全局风格化,而不是单个物体的效果。
1.2 模型卡通着色和后期滤镜的取舍
要做卡通渲染,模型本身改光照模型当然是最“正宗”的路子。比如日系卡通常用 NdotL 加 Ramp 贴图的方式,把光照分成亮部、暗部两个或三个色带;边缘描边则通过背面扩大加反向法线来做。这些方案的好处是边缘干净、线条稳定、可控性强,坏处是改动量大,一个场景里的几十上百个材质都要统一改,如果项目已经开发到一半,临时想转卡通风格,几乎等于重做美术资源。
后期滤镜方案就是在最终画面上“作弊”:我不关心物体用什么光照模型,也不管材质有多少层,直接把最终颜色的渐变压成色阶,再用深度或法线在屏幕空间里找轮廓,画上线条。这样做的最大优点是一点都不碰场景资产,一个材质球拖到 Post Process Volume 里立刻全局生效。对原型验证、视频处理、或者整个项目风格统一来说,性价比非常高。
缺点也有:屏幕空间的边缘检测是像素级估算,做不到模型描边那种精确的矢量线条;光照信息到后处理这一步时已经被压过了,想单独控制某个物体很难。但卡通风格的视觉核心其实就是“少色阶 + 黑边 + 高饱和颜色”,这三样后处理材质都能给到,所以绝大多数情况够用。
1.3 第一个项目配置:后期体积和材质域
开始之前先把这个工程配置搞定,后面所有步骤都依赖它。
在关卡里放置一个 Post Process Volume(后期处理体积),右侧 Details 面板里勾选Unbound。这个勾选的意思是“这个后期体积影响整个场景”,如果不勾,那么只有相机在体积内部时效果才会生效,新手特别容易漏掉这一步,导致材质明明做好了,进游戏却什么都看不到。然后把 Priority 数值设高一点,防止场景里其他 Post Process Volume 覆盖你的设置。最后在 Post Process Materials 数组里点加号,把做好的材质球拖进去,就完成了最基本的接入。
材质球的创建也很简单:新建材质,把 Material Domain(材质域)改成Post Process,Blendable Location 选After Tonemapping。关于这个 Location 的含义,简单说就是你的材质是在色调映射之前还是之后执行。色调映射会把 HDR 颜色压到 LDR 显示范围,所以 Before Tonemapping 阶段处理的是高动态范围数据,辅助以线性空间;After Tonemapping 阶段处理的是已经适合屏幕显示的颜色,语义更直观。对于卡通后处理我推荐先用 After Tonemapping,因为你想做的是颜色量化,直接在最终显示颜色上做最可控。
还有一点,后处理材质不需要连接 Base Color、Metallic 这些输出,正常情况下你只需要把最终颜色连到 Emissive Color 上。如果看到材质球是黑的,先检查是不是忘了连这个口。
2. 卡通效果的三个视觉支柱及它们的材质实现
卡通风格的画面,拆开来看无非就三样东西:颜色的色阶感、物体的轮廓线、整体颜色倾向。下面分别说每个效果在材质里是怎么实现的。
2.1 色阶分离:把渐变拆成色块
连续渐变意味着柔和过渡,卡通画面恰恰相反,它要求亮部和暗部之间有明显的台阶。最简单的做法就是 Posterize(色调分离),数学公式是:
quantized = floor(color * steps) / stepssteps是量化级数,比如 4,那么颜色值会被分成 4 个档位,0 到 0.25 全部变成 0,0.25 到 0.5 全部变成 0.25,以此类推。这样原本的平滑渐变就会被替换成一个台阶一个台阶的色带。
UE4 材质编辑器里没有直接的 Posterize 节点,但用基础节点拼出来非常快:PostProcessInput0输出屏幕颜色,乘一个Scalar Parameter(命名为 Steps 的浮点参数,比如设成 6),接Floor节点取整,再除以同一个 Steps,最后连到 Emissive Color。
用 Floor 而不是 Round 是因为向下取整更符合“暗部压缩到更暗”的直觉。如果你想要四舍五入的效果,可以在 Floor 之前加 0.5,也就是floor(x + 0.5)。
Steps 越小,色块感越强,画面越像漫画;Steps 越大,越接近原始画面。一般建议在 4 到 8 之间调。有一个隐蔽问题是,全屏颜色整体乘 Steps 再 Floor 会导致暗部细节大量被吞掉,黑成一团。可以把这个色阶分离放在 Luminance 的缩放下进行,或者直接把原始颜色和量化颜色按比例混合,这个后面参数化那节再展开。
2.2 屏幕边缘描边:漫画线条的来源
描边是卡通最关键的部分。一张照片无论压多少色阶,都不会有漫画感,但加上轮廓线以后,画面立刻就有了手绘味道。屏幕空间描边的本质是检测“像素和周围像素的属性发生剧烈变化”的位置。一般用两类属性来检测:
第一类是场景深度(SceneDepth)。物体轮廓处前景和背景深度不连续,所以求深度在屏幕空间的变化率,变化大的就是边缘。第二类是法线(WorldNormal)。物体表面的朝向发生急转(比如球体的边缘、人物的鼻梁、衣服褶皱),法线变化大,也可以作为边缘。
在后处理材质里,最简单的深度边缘检测用 DDX 和 DDY 两个导数节点:
edge = length(float2(ddx(SceneDepth), ddy(SceneDepth)))DDX/DDY 是 GPU 提供的屏幕空间偏导数,含义是当前像素在水平方向和垂直方向上,目标值变化了多少。如果相邻像素深度差大,导数就大,说明这里大概率是轮廓。这种方式非常快,几乎是零额外采样开销。
不过 DDX/DDY 对噪声敏感,如果你想要更稳定的线条,可以用多偏移采样法。后面实操章节我会给出完整的节点链,这里先理解原理就行。
深度边缘适合勾出物体大轮廓,法线边缘适合勾出内部细节。实际做的时候最好两个都保留,后面进阶部分会讲怎么结合。
2.3 颜色重映射:让卡通感更完整
色阶和描边出来之后,画面其实已经有一点卡通味了,但还差一层“颜色审美”。大多数卡通渲染作品的颜色饱和度比写实画面高,明暗过渡更扁平,有时还会带整体色调偏向。
饱和度可以直接用一个 Desaturation 节点,输入颜色,控制饱和度系数;或者用基础节点自己拼:把颜色转成亮度值,再用 Lerp 在原始颜色和亮度灰阶之间插值。
float3 luminance = dot(color, float3(0.299, 0.587, 0.114)); float3 finalColor = lerp(luminance, color, Saturation);对比度可以用SmoothStep节点做 S 型曲线,也可以用Power节点对颜色做 Gamma 调整。比如pow(color, 1.2)会让亮部更亮、暗部更暗,画面更“硬”。
冷暖色调倾向更简单:准备一个暖色系的目标颜色,用亮度值作为 Mask,把高光区域往暖色拉,阴影区域保留冷色。做法是:
color = lerp(color, warmTint, highlightMask * tintStrength);高光 Mask 可以用 Brightness 节点的输出,或者自己算dot(color, float3(0.299, 0.587, 0.114))。这步纯粹看审美,没有绝对标准,但建议不要拉得太狠,否则色阶分离后的色板会被破坏。
3. 实操:构建一个可用的卡通后处理材质
3.1 最小版本:只有色阶分离
先做一个最简单的能跑起来的版本,验证整个管线没问题。
新建材质M_CartoonPP,在 Details 里把 Material Domain 改成Post Process,Blendable Location 保持After Tonemapping。打开材质图,右键搜索添加这几个节点:
PostProcessInput0Scalar Parameter,命名为 Steps,默认值 6.0MultiplyFloorDivideSaturate(可选,防止输出超出 0-1 范围)Material Output(自动存在,把结果连到 Emissive Color)
连接方式:PostProcessInput0的输出连到Multiply的 A,Steps 连到 Multiply 的 B;Multiply 输出到 Floor;Floor 输出到 Divide 的 A,Steps 连到 Divide 的 B;Divide 输出到 Saturate,Saturate 输出到 Emissive Color。
编译保存后,创建一个材质实例MI_CartoonPP,放进 Post Process Volume 的 Post Process Materials 数组。运行游戏,你应该能看到画面变成了一块一块的色阶效果,原始渐变消失。
这个最小版本已经能当“一键卡通滤镜”用了。如果只在某个区域生效,把 Post Process Volume 的 Unbound 取消勾选,调整体积大小和 Blend Radius,就能做出局部卡通区域。这个用途很常见。
3.2 加入深度边缘描边:从 SceneDepth 到黑色线条
在最小版本的基础上加深度边缘检测。这里我采用的是四方向偏移采样方式,虽然比 DDX/DDY 多几次采样,但稳定性更好,适合入门理解。
需要增加以下节点:
SceneTexture节点,Scene Texture Id 选SceneDepthTexCoord节点,提供屏幕 UVAppend节点或用Constant2Vector表示偏移量- 若干
Add、Subtract Abs、Max或AddSmoothStepOneMinus
具体思路:
- 用
TexCoord拿到当前像素的屏幕 UV,范围 0 到 1。 - 设置一个很小的偏移量 Offset,比如 0.002。这个值大约是 1080p 下两个像素的 UV 跨度。
- 对 SceneTexture 进行四次偏移采样:中心 UV 分别向左、右、上、下偏移 Offset,得到四个深度值 DepthL、DepthR、DepthU、DepthD。
- 计算水平方向边缘强度:
HEdge = abs(Depth - DepthL) + abs(Depth - DepthR),其中 Depth 是中心深度。 - 计算垂直方向:
VEdge = abs(Depth - DepthU) + abs(Depth - DepthD)。 - 最终边缘强度:
Edge = saturate(HEdge + VEdge)。这个值越大,越可能是轮廓线。 - 为了让线条边缘不那么生硬,加一个
SmoothStep:把Edge小于 0.02 的区域压成 0(不是边缘),大于 0.15 的区域压成 1(是边缘),中间做平滑过渡。 - 用
OneMinus把边缘值反转,得到非边缘区域,然后作为混合因子把色阶后的颜色和黑色线条混合:FinalColor = lerp(LineColor, QuantizedColor, nonEdge)。
把混合后的结果连到 Emissive Color,运行后就能看到物体轮廓有黑边了。四方向偏移采样对轮廓线的敏感度比 DDX/DDY 高,但性能开销也大,如果项目跑在低端设备上,建议后面换成导数法。
3.3 用 Custom 节点把数学压成一段 HLSL
节点图搭出来往往很庞杂,尤其是多个偏移采样。如果赶时间,可以直接把核心算法写进 Custom 节点。Custom 节点是 UE4 材质编辑器里专门写 HLSL 的地方,双击节点打开编辑器。
我建议的做法是:不要直接在 Custom 节点里试图访问 SceneDepth,那样不方便,而是把五个深度采样结果(中心、左、右、上、下)作为输入传入节点。这样 Custom 节点内部只需要做数学运算,Sampling 交给外部材质节点去做,逻辑清晰,也好调试。
Custom 节点输入配置大致如下:
- Color:连接
PostProcessInput0 - DepthC:SceneDepth 中心采样
- DepthL:SceneDepth 左偏移采样
- DepthR:SceneDepth 右偏移采样
- DepthU:SceneDepth 上偏移采样
- DepthD:SceneDepth 下偏移采样
- Steps:标量参数
- EdgeStrength:标量参数
HLSL 代码可以这样写:
float3 quantized = floor(Color * Steps) / Steps; float hEdge = abs(DepthC - DepthL) + abs(DepthC - DepthR); float vEdge = abs(DepthC - DepthU) + abs(DepthC - DepthD); float edge = saturate(hEdge + vEdge); float line = 1.0 - smoothstep(0.02, 0.15, edge * EdgeStrength); return lerp(float3(0.0, 0.0, 0.0), quantized, line);第一行量化,第二三行算边缘,第四行 smoothstep 得到线条遮罩,最后一行把黑色线条和量化颜色混合。把 Custom 节点的输出连到 Emissive Color,效果和全节点图方案一致,但节点面板清爽很多。
3.4 参数化:把关键数值暴露成材质实例参数
做后处理材质,最忌讳把所有数值钉死在材质里。哪怕当前只是验证效果,也建议把关键数值都设成参数:
- Steps:色阶分离级数,建议范围 3 ~ 10
- EdgeStrength:描边灵敏度,建议范围 1 ~ 10,或者直接作为 SmoothStep 的权重
- EdgeOffset:UV 偏移量,控制线条粗细
- EdgeThresholdMin / EdgeThresholdMax:smoothstep 的上下限,控制线条的干净程度
- LineColor:线条颜色,默认黑色
- Saturation:饱和度
- TintColor / TintStrength:冷暖色调介入程度
- CartoonAmount:最终混合权重,等于原始颜色和卡通颜色的 lerp 系数
把这些参数在材质里设成Scalar Parameter/Vector Parameter以后,材质实例里就能直接拖滑块调。尤其 CartoonAmount 这个参数非常实用:想让画面从完全写实到完全卡通平滑过渡,只需要在蓝图里对这个值做插值就能得到过渡动画。运行时做这种转换,比切 Post Process Volume 方便多了,而且不会闪。
4. 边缘检测的四个实际坑和应对
4.1 远处场景的边缘检测噪声大
SceneDepth 在投影变换后不是线性深度,近处精度很高,远处精度急剧下降。做边缘检测时,如果直接用原始深度差值,近处的物体边缘很容易检出,但远处物体两个相邻像素的深度值因为精度不够,非常容易出现抖动,表现就是远山、远处建筑的边缘会闪烁,或者出现细碎的白色噪点。
我当时排查了很久才发现是深度精度问题。解决办法有两个方向:一是把深度做线性化再参与计算,但线性化需要知道相机的投影参数,在材质里写起来稍复杂;二是利用深度值的特性做衰减,比如把边缘强度乘以一个基于深度的缩放系数,让远处边缘的敏感度降低:
float depthFactor = 1.0 / (1.0 + DepthC * 0.1); edge *= depthFactor;这样近处物体仍然能检测出轮廓,远处物体的噪声被压下去。如果场景里有很远的天空球或背景板,它们本身也不应该描边,这个衰减顺带把这个问题解决了。
4.2 线条粗细和分辨率的关系
用固定偏移量做边缘检测时,1080p 下看起来正常的线条,到 4K 分辨率下会明显变细。因为 UV 偏移量对应的像素数量变了:1080p 下 0.002 的 UV 偏移大约对应 2 个像素,但 4K 下同样 0.002 只对应约 1 个像素。这个问题在桌面端可能不明显,但如果你发布到不同平台,或者玩家分辨率跨度大,就会出现线条粗细不一致。
想精确控制线条像素宽度,最好的方式是获取屏幕分辨率。在 Custom 节点 HLSL 里可以直接用内置变量:
float2 ScreenSize = View.ViewSizeAndInvSize.xy; float2 InvScreenSize = View.ViewSizeAndInvSize.zw; float2 OffsetUV = InvScreenSize * EdgePixelWidth;EdgePixelWidth就是你想要的线条宽度,单位是屏幕像素,比如 1.5。这样无论分辨率怎么变,线条在屏幕上看起来都是同样粗细。如果坚持不用 Custom 节点,那就只能用固定参数,然后提醒项目方在改动分辨率时重新调。
4.3 材质放进后期体积却不生效的排查链
这个坑几乎每个新手都会踩一次。如果你把材质实例丢进 Post Process Volume,运行后画面没有任何变化,按下面顺序排查:
- 材质域有没有改对。材质详情面板里 Material Domain 必须是
Post Process,不是 Surface。 - 输出有没有连到 Emissive Color。后处理材质只认 Emissive Color。
- Post Process Volume 的 Unbound 是否勾选,或者相机是否在体积范围内。
- Post Process Materials 数组里添加的是不是材质实例。如果把母材质拖进去,运行时改参数不方便,但应该也能生效。
- Priority 是否被别的后期覆盖。场景里通常不止一个 Post Process Volume,比如游戏模式自带的后期会混进去。你把当前 Volume 的 Priority 设高一点,或者把其他 Volume 的 Priority 调低。
- 材质有没有编译成功。打开材质编辑器看一眼右下角有没有编译错误或警告。
这里的核心经验是:后处理材质不是放进数组就一定生效,它受体积范围、优先级、引擎设置三层约束。
4.4 后处理材质的性能开销不能忽略
一张全屏后处理材质,意味着屏幕上的每一个像素都要执行一遍材质逻辑。Surface 材质还能靠 Distance Field、LOD 等机制减少计算量,后处理材质没有这些优化。尤其是偏移采样,每多一个 SceneTexture 采样,就多一次全屏纹理读取,移动端 GPU 很容易被打满。
如果你对性能敏感,优先把四方向偏移采样改成 DDX/DDY 导数法。导数法不需要额外采样,GPU 直接用相邻像素的结果算梯度,开销几乎可以忽略。代价是线条稳定性和可控性略差。如果仍然需要多采样,可以沿用可分离滤波的思路:先做水平方向边缘,再做垂直方向边缘,两次叠加,而不是单次 Pass 里做四方向采样。虽然总采样次数没变,但单帧指令数分布更均匀,实际帧率更稳。
还有一个容易被忽略的地方:Post Process 材质里不要写动态分支或循环次数很多的 HLSL,尽量用简单的数学表达式。因为 GPU 是并行执行的,全屏像素量大,任何复杂语句都会被放大几百万倍。
5. 进阶:让卡通效果更耐看的小技巧
5.1 给描边加蒙版,避免把不该描的东西描上
直接对整张屏幕做深度边缘检测,会毫无分别地把远处山体、烟雾、透明粒子都勾上黑边,画面容易显得脏。一种常见做法是给描边加一个距离蒙版:超过某个距离就不描边。用 SceneTexture 采样出 SceneDepth,和 DistanceMask 参数比较,大于阈值时输出 0,小于阈值时保留边缘。
节点思路:
float mask = 1.0 - smoothstep(NearDistance, FarDistance, DepthC); edge *= mask;这样描边只出现在设定距离内的物体上,远处背景干干净净。同理,如果某些物体不想被描边(比如地面的贴花、半透明特效),这个蒙版思路也能扩展成基于 Gameplay 标签的方案,不过那就需要借助 Custom Depth 或渲染通道来做了。
5.2 深度边缘和法线边缘结合,卡通线条更完整
纯深度边缘检测不到“表面朝向突变”的边缘。比如一个低模角色,脸部侧面和正面之间没有深度断差,但法线方向明显不同,这时候漫画里那种勾出鼻子、下颌线细节,就需要法线边缘。
法线边缘实现和深度类似,但数据源换成 SceneTexture 的 WorldNormal。可以采样WorldNormal的偏移值,计算两个方向归一化法线的夹角:
float angle = 1.0 - dot(normalize(NormalC), normalize(NormalL));夹角越大,说明表面弯折越明显。把法线边缘和深度边缘取 Max 合并,然后一起进 SmoothStep,就能得到“外轮廓 + 内结构线”的组合。注意法线边缘对地面这种缓坡也很敏感,容易把大片地面勾成密集线条,参数需要反复调。
5.3 色阶分离后加抖动,消除色带边界闪烁
色阶分离虽然把渐变变成了色块,但色块之间的边界在动画播放时可能会跳来跳去。因为同一帧里,边缘像素的颜色恰好落在两个色阶之间,下一帧光照或相机角度稍微变化,它就会跳到另一个色阶,人眼看起来就是色带边界在闪烁。
解决这个问题最常用的方式是加一点噪声做抖动(Dithering)。在量化之前,给颜色加一个非常小的随机值:
float noise = frac(sin(dot(uv, float2(12.9898, 78.233))) * 43758.5453); float dither = noise * 0.08; float3 quantized = floor((Color + dither) * Steps) / Steps;噪声强度控制在 0.05 到 0.1 之间,肉眼几乎感知不到,但足够打破色阶边界的稳定性,让闪烁变成一种类似笔触的颗粒感。如果需要更高质量的抖动,可以改用蓝色噪声贴图采样,不过对于大部分卡通项目,这个 8 行 HLSL 的 hash 函数已经够用。
5.4 局部卡通化和动态过度
Post Process Volume 本身支持Blend Radius和Blend Weight,你把体积缩放到某个区域,它就只影响体积内的画面。利用这一点,可以做“进入卡通区域”的过渡效果。比如把卡通材质放进一个很小的 Post Process Volume,玩家角色走进这个区域时,画面会平滑地变成卡通渲染,走出区域又恢复写实。这个实用性很强,尤其适合演示场景、剧情开关或技能释放瞬间。
另一种动态过度是用参数控制,也就是前面提到的 CartoonAmount。定义最终输出为:
return lerp(OriginalColor, CartoonFinalColor, CartoonAmount);CartoonAmount 为 0 时完全不加卡通,为 1 时完全卡通化,中间值则是两者的过渡混合。配合时间轴节点做蓝图动画,几秒钟内就能让整张画面的风格发生渐变,效果非常抓眼。
我在实际项目里做卡通后处理时,通常会把深度边缘检测和法线边缘检测分别封装成两个 Material Function,然后在一个整合材质里组合。这样做的好处是后续换项目,只需要把这两个函数复制过去,重新调整参数就能复用。新手入门我不建议一上来就追求大而全,先把最小版本的色阶分离跑通,再慢慢加描边,最后再调颜色和性能。这样每一步的变化看得见、能控制,排查问题也不会像无头苍蝇一样乱撞。