做 Unity URP 渲染流程定制,用自定义渲染把角色改成美漫画风,是我接手过看起来最炫、拆开却又最考验管线理解的需求之一。一开始我确实想得很简单:写个 toon shader 不就完了。但真正把材质换上去以后,模型表面确实有了明显的光影分层,整体画面却少了那口关键的气。角色没有描边,暗部边缘糊成一块,亮面缺少漫画网点那种印刷质感,看上去反而像是“没做完的半成品”。
后来我把问题倒过了一遍才发现:美漫风格不是一套 shader,它是一连串对像素和几何的规则性侵入。你真正要控制的,不是某个笔刷,而是模型经过光照后、到达屏幕之前每一步的输出顺序。
这篇文章会围绕一个核心判断来展开:URP 里的美漫风格改造,本质上是一次对渲染流程的重排。你真正要掌握的不是某种神奇的 shader 算法,而是在哪个渲染事件插入哪张 RenderTexture、读取什么输入、输出给谁,并让这些处理与相机最终颜色目标正确衔接。这个流程一旦想通,美漫、水彩、胶片颗粒、甚至简单的漫画网点,都会变成同一套框架下的不同配置。
1. 先说结论:美漫风格不是一套 shader,是一条可干预的渲染链
1.1 你真正想要的是三层视觉特征的叠加
很多人在项目里看到美漫风角色,第一反应是“颜色简单,轮廓明显,好像很卡通”。可一旦动手就会发现问题:只做颜色简化,画面会显得很平;只做描边,角色又像贴纸;只做网点,整体风格会非常突兀。
从视觉拆解来看,美漫风格至少由三层构成:
- 第一层是表面颜色。模型受到光照后不能被连续渐变塞满,而是要被映射成几个有限的色阶,形成类似“亮部、中间调、暗部”的分层。传统叫法很多:Cel Shading、Toon Shader、色阶化、Posterization。
- 第二层是轮廓和边界。角色与背景的边界、脸部轮廓、前后物体的遮挡关系,都要有明确的黑线或深色线。这是美漫风格“看起来像画出来”的关键。
- 第三层是印刷与纹理感。很多美漫或漫画风格并没有真正把画面变得很干净,反而会依赖网点、半调点阵、纸张纹理、轻微的色彩偏移来制造“这是印刷品”的感觉。
只写一个表面色阶 shader,最多只能覆盖第一层。剩下两层不落在材质上,而是落在相机图像和物体几何上。这决定了你必然要干预 URP 的整个流程,而不是只做资源替换。
1.2 URP 为什么比内置管线更适合做这件事
内置渲染管线的渲染流程是接近硬编码的,你要往后处理阶段插东西,要么挂在 OnRenderImage 一类的钩子上,要么修改大量渲染状态。这样不是不行,但很容易出现“自己的效果不知道夹在哪个中间环节”的问题。
URP 给了一个相对清晰的扩展点:ScriptableRendererFeature 和 ScriptableRenderPass。它可以作为插件被挂到 Renderer Data 上,然后把自己的一小段逻辑注入到 URP 已经编排好的渲染事件里。
换句话说,URP 自定义渲染不像内置管线那样是在天花板外面额外搭房间,而是在房间的隔断上预留好了插槽。你需要想清楚的是:你这一层处理,到底该插在哪个插槽里。
这里先给一个非常直观的判断:
- 如果美漫风格只针对角色的表面着色,用自定义 shader 或 Shader Graph 即可,不需要强行写 Feature。
- 如果美漫风格需要一个全屏黑色描边、网点叠加、或画面整体调色,那大概率需要一个 Renderer Feature,在场景渲染完成后插入一次全屏处理。
- 如果角色和背景的风格是两套逻辑,还需要用 Camera Stack 或 LayerMask 把渲染对象拆开,分别走不同流程。
很多项目最后出现问题,并不是因为某个材质表达式不够复杂,而是没有想清楚“我这个效果到底是材质阶段能做的,还是相机后处理阶段才能做的”。这两个阶段错了,效果就会又脏又混乱。
2. URP 自定义渲染入口:Feature、Pass 和注入时机
2.1 先建立一条时间线
URP 会按照特定顺序绘制单相机画面,通常包括:渲染天空盒、渲染不透明物体、渲染透明物体、后处理等。自定义渲染依赖一个枚举:RenderPassEvent。它用来表示你的 Pass 在什么时间点执行。
单独背枚举值没有意义,你要理解的是效果本身需要的“数据就绪时间”。比如:
- 如果要用场景深度法线做描边,你必须在深度和法线纹理已经生成之后执行,而不能插在渲染不透明物体之前。
- 如果要做全屏漫画网点,你最好在透明物体渲染完成之后执行,因为网点要盖住整个最终画面。
- 如果只在某些角色身上做描边,又不能让描边被 UI 干扰,你可能希望它发生在 URP 主相机渲染结束但 UI 画面合成之前。
URP 里负责登记的单元是 Renderer Feature。Feature 在渲染开始时被调用,它会把一个或多个 ScriptableRenderPass 排入队列。实际干活的是 Pass,Pass 里通过 CommandBuffer 发指令:复制图像、采样纹理、绘制全屏三角形或者把颜色目标拷贝回 Camera。
日常讨论中很多人会混淆 Feature 和 Pass。Feature 更像是安排日程的人,Pass 才是真正去执行任务的员工。同一个 Feature 可以注册多个 Pass,这为之后的美漫风格叠层提供了空间。
2.2 一个最小 Renderer Feature 骨架
这里我给一个适合用来验证流程的骨架。下面的代码是一个“全屏效果 Pass”的常见写法,会把当前相机图像送入一个临时 RT,再用材质处理一次后写回相机目标。由于不同 URP 版本对渲染目标的管理有差异,落地前需要先看你项目的 Unity 版本和 URP 包版本。
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public sealed class ComicStyleFeature : ScriptableRendererFeature { public Material effectMaterial; public RenderPassEvent injectionEvent = RenderPassEvent.AfterRenderingTransparents; private ComicStylePass comicPass; public override void Create() { comicPass = new ComicStylePass(); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (effectMaterial == null) { return; } comicPass.Setup(effectMaterial, renderer.cameraColorTarget); comicPass.renderPassEvent = injectionEvent; renderer.EnqueuePass(comicPass); } private class ComicStylePass : ScriptableRenderPass { private Material material; private RenderTargetIdentifier source; private int tempRTId = Shader.PropertyToID("_ComicTempRT"); public void Setup(Material mat, RenderTargetIdentifier sourceTarget) { material = mat; source = sourceTarget; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { CommandBuffer cmd = CommandBufferPool.Get(); RenderTextureDescriptor descriptor = new RenderTextureDescriptor( renderingData.cameraData.camera.pixelWidth, renderingData.cameraData.camera.pixelHeight, RenderTextureFormat.Default, 16); cmd.GetTemporaryRT(tempRTId, descriptor); cmd.Blit(source, tempRTId); cmd.Blit(tempRTId, source, material, 0); cmd.ReleaseTemporaryRT(tempRTId); context.ExecuteCommandBuffer(cmd); cmd.Clear(); CommandBufferPool.Release(cmd); } } }写完以后,在 URP Asset 对应的 Forward Renderer 上点击 Add Renderer Feature,把这个组件挂上去,并把材质拖到 effectMaterial 字段就行。
这段代码本身只会做一个最基础的全屏 Blit,但它把整个自定义渲染的输入输出链路讲清楚了:源是从相机目标拿的,目标是临时 RT,经过材质后再写回相机目标。之后你想加网点、调色、描边,本质上都是在这个骨架里替换或增加材质和 Pass。
2.3 把美漫风格三个特征映射到管线不同阶段
整理一张表会更直观:
| 视觉特征 | 需要的数据 | 推荐插入阶段 | 实现方式 |
|---|---|---|---|
| 表面色调分离 | 模型法线、光源方向 | 物体渲染阶段 | 自定义 Shader / Shader Graph |
| 边缘黑色描边 | 深度、法线、深度法线纹理 | 不透明物体后、透明物体前或相机后效前 | Scene Normal / Depth 边缘检测 |
| 半调网点/纸质纹理 | 最终颜色、屏幕坐标、UV 扰动 | 透明物体之后、相机后处理阶段 | 全屏 Blit 后处理材质 |
| 整体色彩压制与影像感 | 最终颜色 | 所有非 UI 内容之后 | 全屏后处理材质 |
真实项目里,第一种不一定走 Renderer Feature,而是直接改材质;第二和第三种则更依赖渲染流程扩展。把它们放在一条链上看,其实就是“模型先被画成色阶化表面,再被提取出清晰的轮廓线,最后在屏幕上叠加一层印刷质感”。
3. 美漫风格落地拆解:从表面色阶到全屏网点
3.1 第一层:模型表面处理,把光照切成有限档位
表面处理的目标很明确:让漫反射不再连续过渡。
常规光照模型会根据 N 和 L 的点积产生连续明暗,美漫风格需要把这个连续值切段。最常见做法是使用一个 Ramp 图:一张 1D 渐变图或 2D 渐变图,颜色被设计成几个明显台阶。采样结果直接替换漫反射颜色。
如果不想依赖贴图,也可以在 Shader Graph 里用 Step 节点对亮度做分段。比如把亮部、中间调、暗部分成三档,用多次 Step 和 Remap 组合。处理时要注意一点:台阶边界越硬,风格越强,但也会越容易暴露锯齿。尤其是光照方向缓慢转动时,边界如果直接从一个像素跳到另一个像素,画面边缘会出现明显的不稳定。很多项目会用一个很小的 Smoothstep 把台阶做软,让亮度值在 0.1 弧度的范围内完成跳变,这样既保留了手绘感,又不会让边界闪烁。
边缘光在这层要不要保留,取决于你的美漫风格定位。偏欧美漫画的角色通常没有夸张边缘光,偏日系赛璐璐反而会加。先做小步验证,建议不要在一开始就叠加太多后期效果。
3.2 第二层:轮廓与物体边界,使用深度法线而不是后处理裸描边
描边有很多种实现:
- 背面膨胀法:把模型背面的顶点沿法线方向外扩一点,再把正面 Cull 掉,让它只露出边缘一圈黑色。这种方案常用于低模卡通角色,优点是线条干净、跟随网格形状,缺点是模型需要足够细分,否则外扩时会有明显折痕。如果角色由多个子物体拼成,还需要处理接缝问题。
- 屏幕空间深度法线边缘检测:采样场景深度或法线纹理,发现相邻像素的深度差异、法线方向差异超过阈值,就判定为边缘并输出黑色。这种方法适合描画物体与背景之间的轮廓,以及同一物体内部法线变化比较大的转折处。
在 URP 的自定义渲染流程中,第二种方法更常用,因为它不依赖单独的角色网格,只要是相机画面里存在深度不连续或法线变化大的地方,都能被勾勒出来。缺点也很明显:如果法线贴图过于复杂,边缘检测会把大量细节褶皱也当成轮廓,需要额外增加法线阈值或深度阈值来控制。
实际操作上,如果使用全屏后处理材质做描边,通常会声明这个 Pass 需要法线输入。在 Renderer Feature 的 Pass 里可以调用 ConfigureInput(ScriptableRenderPassInput.Normal) 让 URP 生成相机法线纹理。不同版本的 URP 对法线纹理的开关处理有差异,所以最稳妥的检查方式是:先跑一次 frame debugger,确认相机法线纹理有生成,再去调描边参数。如果法线纹理没有生成,描边就会黑一片或者完全没效果。
3.3 第三层:半调网点、印刷质感和颜色修正
漫画风格里那种离远了看是渐变、离近了看是一堆黑点的效果,被称为半调或网点。它本质上是让画面每个像素按自己的亮度,决定是否生成一个网点。越暗的区域,网点覆盖率越高;高亮区域可能网点很小甚至没有。
实现时两件事最关键:
第一,网点图案必须在屏幕空间生成。如果你把网点放在模型 UV 空间里,当相机靠近角色时网点会跟着模型的 UV 放大,看起来像模型表面贴了张重复瓷砖。美漫风格通常要的是“整个画面像一张印刷品”的效果,所以网点应该使用屏幕坐标去生成。
第二,网点尺度要稳定。真实印刷品的网点密度是固定线数,如果按照像素坐标直接算,在不同分辨率下网点大小会变化。很多方案先把屏幕像素坐标除以一个固定密度,再对结果做距离场,得到圆点或菱形网格。
在 URP 里实现通常有两种思路。一种是不改几何体,把网点作为一个后处理材质,通过一个后期 Feature 对相机图像做全屏处理;另一种是希望局部角色有网点、背景没有网点,那就需要在物体材质的 shader 中做一层网点叠加。两者可以同时存在,但除非画面本身就是“整幅漫画”,否则不建议全屏网点开得太满。真实项目里,有一个常用技巧是只让暗部区域出现清晰网点,亮部保持干净,读者视觉上依然能收到“漫画印刷”信号,但画面不会显得脏。
颜色修正放在最后一层做。美漫风格通常需要高一点饱和度,暗部不要死黑,亮部也不要完全过曝。这可以通过简单的颜色曲线、LUT 或 Saturation 节点调整。
3.4 三条 pass 的先后顺序,直接决定画面干不干净
顺序问题非常容易踩坑。
如果先做网点,再做描边,描边会画在网点上面,边缘线会更突出;如果先做描边,再做网点,网点可能覆盖掉部分细线,导致轮廓断掉。这没有绝对标准,取决于你的美术偏好。但从工程稳定性来看,我更建议主流程按“物体色阶化 -> 深度法线描边 -> 网点叠加 -> 最终颜色校正”的顺序组织,理由很直接:深度法线描边依赖的是几何边缘,而不是颜色,所以放在早期的场景纹理上,线条更稳定;网点是纯粹的印刷质感,应该压在已经合成好的画面上;颜色校正作为最后一步,可以统一微调饱和度,不会干扰前面各层。
如果所有东西都塞进一个全屏 Blit 材质里,虽然看起来性能更优,但一旦某个节点输出异常,排查会非常费劲。建议在第一版里拆开阶段,确认每一层单独都对,再考虑合并到同一条 CommandBuffer 里。
4. 实际开发中五个容易翻车的地方和一条排查链路
4.1 先按现象分层,别一上来改 shader
很多时候渲染结果不对,不是 shader 表达式写错,而是输入文件、渲染目标或管线配置的问题。遇到异常,不要第一时间去调 shader 参数,而是按这个顺序排查:
- 看现象:是完全没有效果,还是效果只出现在部分画面,还是边缘有锯齿,还是颜色不对。
- 看数据输入:FullScreen 贴图是否拿到,深度法线是否开启,临时 RT 是否存在,尺寸是否与相机一致。
- 看管线事件:当前 Pass 发生在透明物体前还是后,是否会和不透明物体的 shader 产生状态冲突。
- 看材质参数:阈值、强度、颜色,是否有 NaN、除零或贴图未赋值。
- 看 URP 版本:某些 API 在不同 URP 版本已经改名,字段是否过期。
纯靠脑补很难找到问题。建议打开 Frame Debugger,看渲染事件里你的 Pass 在哪两个事件之间,输入贴图在那一帧长什么样,这样能快速定位是“数据没有生成”还是“数据生成了但处理有问题”。
4.2 要检查 URP Asset 是否开启深度/法线纹理
很多边缘检测效果失败,原因不是算法,而是 URP Asset 没有生成你需要的纹理。不同版本的 URP 会在 Pipeline Asset 上提供 Depth Texture 等选项,如果你需要法线输入,还要在 Renderer Feature 的 Pass 里 ConfigureInput,触发额外的相机法线纹理生成。
这个开关是有代价的。开启后,URP 会在每个相机渲染时多产生一张纹理,增加带宽和内存。移动端、VR、分屏场景尤其明显。因此只有需要描边的相机才开,不要把全项目所有相机都无脑打开。
4.3 Blit 目标、RenderTexture 分辨率和透明区域要提前定
自定义后处理最常见的问题是把源和目标搞混。URP 里相机的颜色目标不一定总是后缓冲,它可能是 HDR RT,也可能已经开启 MSAA,还可能被 Camera Stack 覆盖。如果直接对目标做多次 Blit,要注意临时 RT 的格式和分辨率是否和相机目标匹配。
分辨率不匹配通常会导致画面变模糊或者边缘闪烁。多相机叠加时更麻烦:主相机输出一张 RT,Overlay 相机再合成 UI,你的后处理如果不小心把 UI 相机也处理了,就会出现漫画网点盖住了血条、按钮之类的问题。解决办法是用 LayerMask 或 Camera Stack 分层时,只把场景相机放在风格化链路里,UI 相机保持默认处理。
4.4 边缘检测结果破碎,多半是阈值和滤波问题
屏幕空间边缘检测需要人为设定深度差和法线差阈值。阈值太小,很多本该是平面的区域会被当成边缘,画面会像电路板一样充满细线;阈值太大,真正的角色轮廓又会被漏掉。
一个更可控的做法是分成两步:先对深度和法线做轻微滤波,再计算边缘。轻微滤波主要是去掉深度噪声,尤其在线性深度值比较远的时候,一点深度变化就会被放大成巨大差异。否则相机动一下,描边会在背景物体周围疯狂闪动。
另外,美漫风格描边并不需要把每一帧屏幕上的所有深度跳变都画出来。例如地板相接处、两个角色重叠的微小边界,往往画得越少越像漫画。很多漫画家画轮廓时会主观忽略一部分细碎转折,这个美术判断转化到技术里,就是“对相对平缓的法线变化不画线”。
4.5 移动端性能:所有后处理尽量在一条 Blit 里完成
全屏描边加网点加颜色修正,如果每个效果都是独立 RT 和独立 Blit,Pixel 带宽消耗会明显上升。移动端 GPU 对屏幕分辨率下的多张全屏读写特别敏感。优化时不能牺牲美术判定,但可以减少无效读写。
一般策略是给整个美漫流程设计一个综合材质,内部用多个 Pass 或在同一次采样里完成边缘检测和网点生成。你也可以先用半分辨率做网点,再上采样到全屏。半调网点属于高频纹理,直接缩放会有摩尔纹风险,需要配合低通滤波或抖动。但画面风格本身就是颗粒感很强,轻微特性反而可能强化印刷感,需要和美术一起判断。
5. 从 Demo 到项目化:我建议按这个顺序组织自定义渲染
5.1 使用场景决策清单
不是所有美漫需求都要动整个 URP 渲染流程。做之前可以先按这张清单判断:
| 需求类型 | 推荐处理方式 | 避免方式 |
|---|---|---|
| 仅角色想要色阶化光照 | 角色 Shader / Shader Graph | 全屏后处理做色阶化,会处理背景和 UI |
| 全场景变成美漫画幅 | 相机 Renderer Feature + 全屏后处理材质 | 只改场景材质,物体边缘很难统一 |
| 角色需要有清晰轮廓 | 深度法线后处理描边或角色背面膨胀 | 盲目加 Bloom,会弱化轮廓 |
| 需要漫画网点/纸张质感 | 全屏后处理材质,叠加 Screen Space Pattern | 把网点布料贴图贴在场景模型上 |
| 想要局部的简单卡通造型 | 单材质 + 几何描边 | 投入大量时间写自定义渲染框架 |
如果只是做一个小游戏原型,直接把所有角色套同一个 toon shader,再把相机 RT 做一次 Blit,是最快路径。如果要做成核心视觉卖点,后续可能换更多风格,那么从一开始就按 Feature + Pass 的思路组织,比把效果写死在某个整屏材质里更稳。
5.2 先验证小样本,再进场景,最后再做合并
从工程经验看,我会强烈建议不要跳过下面三步:
- 小样本验证:先拿一个普通 Cube 加标准角色模型,套一个最基础的色阶化 shader,确认表面在半球光、方向光下不会出现明显的瑕疵。
- 单独验证描边:关掉网点,单独调深度法线阈值,确认角色的侧脸、手指、物体遮挡处轮廓符合预期。
- 叠加工艺效果:开启网点,先调密度和半径,再调整体饱和度。完成后看一眼 UI 是否被误处理,再看不同分辨率下网点有没有明显闪烁。
每一步都只调整一个变量。如果直接做满了三层,画面出现问题时会很难分辨是表面 shader 问题、描边问题还是网点问题。
5.3 用配置表管理不同风格变体
URP 自定义渲染的优势是你可以在 Renderer Data 上挂多个 Renderer Feature,比如“美漫描边”“半调网点”“水彩颗粒”。但这些 Feature 不一定每个角色都要全量开启。为了不把场景玩成不可维护的调参现场,可以在一张项目设置表里记录:
- 风格类型是全局还是局部。
- 启用哪些 Pass,哪个 RenderPassEvent 先执行。
- 效果材质里哪个参数会被脚本文本动态覆盖。
- 当前风格变体对哪些 LayerMask 生效。
有了这张表,后期换风格时不需要去改场景里一百个材质球,而是替换 Renderer Data 中的特效串联,或者统一替换一套材质参数。
5.4 后续要长期维护时,至少补充这些开发基建
如果你确定这套自定义渲染会长期存在,请在进入正式生产前补齐以下内容:
- 版本记录:Unity 版本、URP 包版本是哪一套,是否升级过渲染管线。
- 开关开关:每个 Feature 面板上都要有 enabled 和调试模式,方便临时缩掉效果。
- 日志与观测:在编辑器里把 Feature 所用到的 RT id 打出来,Frame Debugger 中能对照。
- 移动端 profile:固定测试机型,记录全屏处理在低端 GPU 上的耗时。
- 灰度预案:如果设备不支持某些纹理采样或深度法线,有没有降级到纯表面色阶的方案。
这些看起来不是视觉效果的一部分,但决定了一个炫酷的 Demo 能不能成为可以让项目长期跑的正式功能。渲染管线越是灵活,越需要约束。
风格化渲染的真正门槛,不在于那几条描边或一个网点公式,而在于你是否能清晰描述一个像素从模型属性到最终相机颜色经历的所有阶段。等你能一次说清楚自己在哪个 RenderPassEvent 之间插入哪张 RT、读的是什么纹理、输出给谁的时候,URP 自定义渲染就不只是一个功能,而是一种可复用的技术能力。美漫风格恰好是一个能帮你把人推到那条理解边界上的最佳练手对象。