Unity URP卡通着色实战:从原理到Shader Graph实现
1. 项目概述:为什么要在URP里折腾卡通着色?
做游戏开发这些年,卡通渲染(Toon Shading)一直是个让人又爱又恨的话题。爱它,是因为它能瞬间赋予游戏独特的视觉风格,从《塞尔达传说:旷野之息》的清新水彩到《原神》的二次元幻想,都离不开它的加持。恨它,是因为在Unity里,尤其是从内置管线切换到通用渲染管线(URP)后,很多老教程和Shader代码直接“报废”,网上资料又七零八落,自己摸索起来特别费劲。
这个“简易卡通着色(Simple Toon)”项目,就是针对这个痛点来的。它不追求《罪恶装备》那种极致复杂的多层描边和光照模型,而是聚焦于在Unity URP框架下,快速实现一个效果扎实、性能友好、易于理解和定制的卡通着色基础方案。说白了,就是帮你用最小的学习成本,在URP项目里搭起一个卡通渲染的“四梁八柱”,让你的人物、场景立刻有内味。
它的核心价值在于“实战”和“适配”。我们不会空谈理论,而是会一步步拆解如何在URP中编写Shader Graph节点,如何与URP的后处理、灯光系统配合,如何处理阴影和边缘光。无论你是想为自己独立游戏的角色增添风格,还是为某个场景制作特殊的卡通滤镜,这个教程都能提供一个清晰的起点和可扩展的蓝图。
2. 卡通着色核心原理与URP管线适配
2.1 卡通着色的视觉本质:离散化与风格化
传统基于物理的渲染(PBR)追求的是模拟真实世界光线连续、平滑的过渡。而卡通着色的精髓恰恰相反,它通过“离散化”和“风格化”处理,刻意打破这种连续性,营造出绘画般的质感。主要手段有三个:
- 色阶化漫反射(Ramp Shading):这是最核心的一步。我们不直接使用
NdotL(法线点乘光方向)这个连续值作为漫反射强度,而是将其映射到一个预设的渐变纹理(Ramp Texture)或通过阶梯函数进行量化。比如,将连续的光照强度映射为“亮部”、“中间调”、“暗部”三个明确的色块,从而形成清晰的明暗交界线,这是卡通感的主要来源。 - 高光风格化:卡通风格的高光通常不是柔和的光斑,而是形状锐利、位置固定的亮块。我们常通过计算
RdotV(反射光方向点乘视线方向),并同样进行阈值化处理,得到一个非0即1或阶梯状的高光区域。 - 描边(Outline):描边并非卡通着色的必须项,但它能极大地强化轮廓,增强物体的“剪纸”或“漫画”感。实现方法多样,如基于法线外扩、基于后处理的边缘检测等。
2.2 URP管线下的特殊考量
在Unity内置管线中,我们可以相对自由地编写Surface Shader。但在URP中,渲染流程被标准化和优化,我们需要遵循它的规则:
- Shader编写方式:URP强烈推荐使用Shader Graph(可视化着色器编辑器)或编写URP Lit/Unlit Shader模板代码。对于卡通着色这种对光照有自定义需求的,通常采用自定义光照模型(Custom Lighting Function)的方式,在片元着色器中覆盖URP的默认光照计算。
- 灯光数据获取:URP是前向渲染器,支持多光源,但每个对象受一个主光(Directional Light)和多个附加逐顶点光源影响。我们需要通过
GetMainLight()和GetAdditionalLights()函数来获取灯光信息,这比内置管线直接访问_WorldSpaceLightPos0要规范。 - 阴影处理:URP有独立的阴影管线。我们需要采样阴影贴图(
SHADOWMAP)并与自己的光照计算结合。卡通阴影往往也需要风格化,比如让阴影边缘更硬。 - 后处理集成:全屏描边等效果,更适合放在URP的Renderer Feature中作为全屏后处理实现,与物体自身的Shader解耦,更灵活且性能可控。
理解这些差异,是避免踩坑的关键。我们的Simple Toon Shader将主要采用Shader Graph + 自定义函数的方式,兼顾可视化操作的便利性和代码控制的灵活性。
3. 实战构建:一步步创建Simple Toon Shader
3.1 环境准备与项目设置
首先,确保你有一个使用URP的项目。如果还没有,在Unity Hub创建项目时选择“Universal RP”模板是最快的方式。如果是在现有项目升级,需要通过Package Manager安装“Universal RP”包,并创建一个URP Asset(通常命名为UniversalRP-HighQuality或MyProjectURP)并分配给项目的Graphics Settings。
接下来,我们创建核心的着色器:
- 在Project窗口右键 -> Create -> Shader Graph -> URP -> Lit Shader Graph,命名为“SimpleToon”。
- 双击打开这个Shader Graph。默认的Master Stack(主堆栈)节点是PBR光照模型,我们需要对其进行大刀阔斧的改造。
3.2 核心节点网络:色阶化漫反射与高光
我们首先聚焦于实现最关键的色阶化漫反射光照。
第一步:构建基础光照信息
- 删除Master Stack上默认连接的PBR节点。我们将从零搭建。
- 添加一个
Fragment Normal Vector节点获取片元法线(已转换到世界空间)。 - 添加一个
Lighting Model自定义函数节点。实际上,更直接的方法是使用Sample Main Light和Sample Additional Lights节点组。我们先处理主光。 Sample Main Light节点需要世界空间位置和阴影坐标。添加Position节点(设置为World空间)和Screen Position节点(用于生成阴影坐标)。- 将
Sample Main Light的输出结构体展开,我们最关心的是Direction(光方向)和Color(光颜色和强度)。 - 计算
NdotL:使用Dot Product节点,连接法线和光方向。这个值范围在[-1, 1]。 - 将
NdotL从[-1, 1]重映射到[0, 1]:(NdotL * 0.5 + 0.5)。这个值代表了基础的受光强度。
第二步:实现色阶化(Ramp)这是卡通感的灵魂。有两种主流方法:
- 方法A:使用渐变纹理(Ramp Texture):
- 创建一个1xN像素的纹理,在水平方向上从左到右绘制你想要的暗部到亮部颜色过渡。例如,最左边纯黑,中间深灰,最右边亮色。
- 在Shader Graph中,添加一个
Sample Texture 2D节点,将上一步重映射后的NdotL值作为UV的X坐标(Y坐标给0),去采样这张Ramp贴图。采样结果就是经过风格化后的漫反射颜色系数。
注意:这种方法艺术控制力强,可以通过绘制不同的Ramp图快速切换风格(如冷调、暖调、多色阶)。但需要额外管理一张纹理。
- 方法B:使用阶梯函数(Step/Smoothstep):
- 这是我们本次实战采用的方法,更轻量,无需外部纹理。
- 假设我们想要亮、暗两个色阶。定义一个阈值(Threshold),比如0.5。
- 使用
Step节点:Step(Threshold, NdotL)。当NdotL > Threshold时输出1(亮部),否则输出0(暗部)。这样会产生非常硬的边缘。 - 为了让明暗交界线稍微柔和一点,可以使用
Smoothstep节点:Smoothstep(Threshold - Softness, Threshold + Softness, NdotL)。其中Softness是一个很小的值,如0.05。这会在阈值附近产生一个平滑过渡区,视觉上更自然。 - 将
Smoothstep的输出(一个0到1的系数)与亮部颜色、暗部颜色进行线性插值(Lerp),得到最终的漫反射颜色。
第三步:添加风格化高光
- 计算半角向量(Half Vector):
(LightDir + ViewDir) / 2。在Shader Graph中,有现成的View Direction节点和已有的Light Direction。 - 计算
NdotH:法线与半角向量的点积。 - 同样对
NdotH进行阶梯化处理:Step(SpecularThreshold, NdotH)。SpecularThreshold通常很高,比如0.9到0.95,让高光只出现在最亮的区域。 - 将得到的系数(0或1)与一个高光颜色(通常是白色或光源色)相乘,加到最终的漫反射颜色上。
第四步:组合与输出
- 将风格化后的漫反射颜色与主光颜色相乘,得到受光部分的颜色。
- 与环境光(
Ambient节点)相加。 - 处理阴影:
Sample Main Light节点输出的Shadow Attenuation(阴影衰减值)通常直接乘到光照贡献上。为了卡通化,我们可以对阴影衰减也做一次Step处理,让阴影边缘变硬。 - 将最终颜色连接到Master Stack的
Base Color。Metallic和Smoothness设为0,因为我们完全接管了光照计算。
3.3 在材质与模型上应用效果
Shader编写完成后,点击Save Asset保存。
- 创建一个新的材质(Material),将Shader选择为我们刚创建的
SimpleToon。 - 将材质拖拽到场景中的模型上。
- 在材质Inspector面板,你会看到我们暴露出来的参数,如
_BaseColor、_ShadowColor、_RampThreshold、_SpecularThreshold、_SpecularColor等。调整这些参数,立刻就能看到模型从PBR质感变为卡通质感。
实操心得一:阈值参数的动态调整_RampThreshold(漫反射阈值)是控制明暗分布的关键。对于主要受顶光照射的角色,阈值可以设低一些(如0.3),让亮部区域更大;对于侧光强烈的戏剧化场景,阈值可以调高(如0.6)。这个值需要结合场景主光方向动态调整,而不是一个固定值。
4. 效果增强:描边、阴影与后处理集成
4.1 实现基于法线外扩的描边
物体自身的描边,最稳定高效的方法是背面渲染(Backface Rendering)或法线外扩(Normal Extrusion)。我们在Shader Graph中实现后者:
- 在Shader Graph中,新增一个
SubGraph或直接在顶点着色器阶段处理。 - 添加一个
Position节点(Object Space)。 - 添加一个
Normal Vector节点(Object Space)。 - 将法线向量归一化后,乘以一个可控的
_OutlineWidth(轮廓宽度)参数。这个参数通常很小,比如0.01到0.05。 - 将外扩后的法线加到物体空间顶点位置上:
Position + Normal * _OutlineWidth。 - 将这个结果连接到
Position输出。 - 我们需要一个开关来控制何时外扩。通常的做法是创建一个布尔参数
_Outline,并使用Branch节点。或者更常见的,直接为描边Pass单独写一个简化的Shader或SubShader,只做外扩和输出纯色。在URP中,可以通过创建一个Renderer Feature,用另一个只画轮廓的材质再画一遍模型来实现,这样更清晰。
实操心得二:描边宽度与透视校正直接外扩法线在透视相机下会出现“近大远小”的描边,有时远景的描边会太细。一个改进方案是在外扩前,将顶点变换到裁剪空间(Clip Space)再进行外扩,或者对外扩量做一个基于顶点深度(Z值)的衰减,可以让描边在透视上更均匀。
4.2 风格化阴影与屏幕空间阴影
URP的阴影本身是柔和的。为了匹配卡通风格,我们需要让它“硬”起来。
- 硬阴影:在片元着色器中,当我们从
Sample Main Light获取到shadowAttenuation(一个0到1的连续值)后,对其应用一个Step函数:step(_ShadowHardness, shadowAttenuation)。_ShadowHardness可以设为0.5,这样低于0.5的阴影值都被视为完全在阴影中(0),高于0.5的则视为完全不在阴影中(1)。 - 屏幕空间阴影色调:卡通阴影的颜色往往不是简单的变暗,而是会赋予一个特定的色调(比如冷蓝色)。我们可以在计算阴影衰减后,不是简单地将光照乘上去,而是用阴影衰减系数在“受光颜色”和“阴影颜色”之间进行
Lerp。这个“阴影颜色”可以是一个用户定义的参数。
4.3 使用Renderer Feature添加全屏后处理效果
对于某些风格的卡通渲染,如更抽象的水墨边缘或全场统一的颜色量化,使用后处理是更好的选择。
- 在URP Asset配置文件(Renderer)中,添加一个
Render Objects类型的Renderer Feature。 - 将其配置为在渲染不透明物体之后,在一个特定的
Render Pass中执行。 - 创建一个专门用于后处理的全屏Shader。这个Shader可以使用
Sobel或Roberts Cross等算子对深度/法线纹理进行边缘检测,将检测到的边缘输出为描边颜色。 - 将这个后处理材质赋给
Render ObjectsFeature。 - 这种方法的优点是独立于单个物体,对所有物体生效,风格统一。缺点是性能开销相对较大,且对透明物体的处理可能复杂。
常见问题一:为什么我的卡通Shader在某些灯光下不生效或变黑?这通常是灯光数据获取不正确导致的。请检查:
- 确保场景中有激活的Directional Light(主方向光)。
- 在Shader Graph中,检查
Sample Main Light节点的输入位置是否正确(应为世界空间位置)。 - 如果使用了附加光源,确保
Sample Additional Lights节点被正确启用,并且物体的Renderer组件上“Receive Shadows”和“Cast Shadows”设置合理。 - 检查相机的渲染路径设置,确保是Forward Rendering。
5. 性能优化与进阶技巧
5.1 性能分析与优化点
卡通Shader虽然关闭了复杂的PBR计算,但引入的Step、Smoothstep、额外的纹理采样(如果用了Ramp图)以及可能的描边Pass,仍需要关注性能。
- 减少纹理采样:如果使用Ramp纹理,确保它尺寸足够小(如128x1),并开启Mipmap和压缩。考虑将Ramp信息编码到一张小贴图的RGBA通道,一次采样获取多个参数。
- 简化描边:法线外扩描边会使得模型顶点数翻倍(如果单独Pass)。对于低模可以接受,对于高模角色,考虑使用基于边缘检测的后处理描边,或者只在主角/重要物体上使用。
- Shader变体管理:如果你的Shader有很多可开关的特性(如描边开关、高光开关),会产生大量的Shader变体,增加编译时间和包体。合理使用
shader_feature而非multi_compile,并将不常变化的特性打包成不同的Shader文件。 - 利用URP的SRP Batcher:确保你的Shader符合SRP Batcher的要求(如使用正确的CBUFFER声明)。这能大幅提升使用同一Shader的不同材质的渲染效率。
5.2 进阶风格化扩展
基础卡通着色搭建好后,可以在此基础上添加更多风格化元素:
- 边缘光(Rim Light):计算
fresnel = 1.0 - saturate(dot(normal, viewDir)),然后对其取幂(pow(fresnel, _RimPower))并做阶梯化处理,得到一个在物体轮廓边缘发光的效应。常用于突出角色轮廓,增强立体感。 - 贴图色散/颜色偏移:对UV进行轻微扰动,让不同颜色通道的采样位置有微小偏移,可以模拟手绘或印刷中的错版效果,增加艺术感。
- 自定义光照方向:有时美术希望光照方向不完全依赖于场景灯光,而是有一个固定的“美术光方向”。可以在Shader中暴露一个
_FixedLightDir向量,并与法线计算NdotL,给美术更大的控制权。
实操心得三:与动画和特效的配合卡通着色角色在运动时,硬阴影可能会产生“抖动”或“跳变”。一个缓解方法是使用Smoothstep替代Step,引入一个很小的平滑区间。对于特效(如技能光效),可能需要单独的非卡通Shader,或者让卡通Shader包含一个“非卡通渲染”的混合模式,通过一个参数控制,让特效部分保持发光和透明效果。
6. 常见问题排查与调试实录
在开发过程中,你几乎一定会遇到下面这些问题。这里记录了我的排查思路和解决方法。
问题1:模型表面出现不规则的块状或条纹状明暗。
- 排查:这通常是法线信息问题。首先检查模型导入设置中的“法线”选项,确保是“Calculate”或“Import”。在Shader Graph中,检查
Fragment Normal Vector节点是否正确。尝试将法线空间从World切换到View或Tangent看看问题是否变化。 - 解决:最常见的原因是模型自身的法线没有平滑(Smooth)。在3D建模软件中或Unity的Mesh导入设置里,勾选“平滑法线”。如果问题依旧,在Shader中尝试对采样到的法线进行归一化(Normalize)操作。
问题2:描边粗细不一致,在模型某些部位断裂或过粗。
- 排查:法线外扩式描边对模型拓扑结构很敏感。检查模型这些部位的顶点法线是否均匀。使用一个显示法线的调试工具(如Unity的Scene视图下拉菜单->Override->Normal)。
- 解决:
- 均匀拓扑:在建模时尽量使用均匀的四边面布线。
- 顶点法线平滑:确保模型顶点法线是平滑的。
- 使用裁剪空间外扩:如前所述,将顶点变换到裁剪空间后再外扩,可以有效改善透视导致的粗细不均。
- 后处理描边:如果模型问题难以解决,考虑切换到屏幕空间的后处理描边方案。
问题3:移动端设备上效果很差或帧率很低。
- 排查:使用Unity的Frame Debugger或Universal RP的Render Pipeline Debugger查看Draw Call和渲染状态。检查是否因Shader变体过多导致编译卡顿。
- 解决:
- 简化计算:用
step代替smoothstep,用简单的颜色混合代替复杂的纹理采样。 - 减少Pass:评估是否真的需要描边Pass,或者能否用更廉价的方法(如顶点色)模拟。
- 精度优化:在片元着色器中,将部分计算移到顶点着色器(如计算
NdotL),虽然会牺牲一些精度,但能提升性能。对于移动端,考虑使用half精度变量。 - LOD分级:为不同性能的设备准备不同复杂度的Shader变体,通过Quality Settings进行切换。
- 简化计算:用
问题速查表
| 问题现象 | 可能原因 | 快速检查点 | 解决方案 |
|---|---|---|---|
| 模型全黑/全白 | 光照数据未获取 | 1. 场景有无激活主光? 2. Shader中 Sample Main Light输入位置是否正确? | 确保主光存在,检查位置节点空间设置(应为World) |
| 明暗交界线闪烁 | 阈值设置过于激进或模型精度低 | 调整_RampThreshold和_RampSmoothness | 增加平滑度,或使用smoothstep替代step |
| 阴影边缘有锯齿 | 阴影贴图分辨率不足或硬阴影过渡太陡 | 检查URP Asset中的阴影分辨率设置 | 提高阴影分辨率,或在硬阴影计算中加入微量平滑 |
| 描边内部有重叠 | 法线外扩方向错误或模型有重叠面 | 检查外扩计算是加还是减法 | 确保是沿顶点法线外扩(加法)。检查模型有无多余面片 |
| 高光区域位置奇怪 | 视线方向或半角向量计算错误 | 检查View Direction节点空间 | 确保View Direction和Light Direction在同一空间(通常为World)计算 |
最后,我想分享一点个人体会。卡通渲染从来不是“正确”的渲染,而是“合适”的渲染。这个Simple Toon Shader项目给你的是一个高度可定制的工具箱,而不是一个固定的答案。参数没有最佳值,只有最适合你项目艺术风格的值。多和你的美术同事沟通,参考你喜欢的游戏,用这个Shader作为基底,不断调试、叠加效果,直到找到属于你们项目的独特视觉语言。记住,技术服务于艺术,看得舒服,比写得漂亮更重要。