
1. 项目概述为什么我们需要一个URP下的卡通着色器如果你正在用Unity的通用渲染管线URP做项目尤其是想做二次元、赛璐璐或者任何带点卡通、风格化感觉的游戏那你大概率遇到过这个头疼的问题URP自带的那些PBR基于物理的渲染着色器做出来的效果太“真实”、太“油腻”了跟想要的干净、色块分明的卡通感完全不搭边。网上一搜要么是Built-in管线的老教程要么是HDRP的复杂方案真正针对URP、能免费拿来就用的卡通着色器Toon Lit Shader例子少之又少。我自己在做独立游戏和接一些风格化项目时被这个问题折磨了很久。Shader Graph看着直观但早期版本对自定义光照和阴影的支持简直是个坑就像搜索内容里那位老哥说的感觉全是“hacks”拼拼凑凑勉强能用但离“完美”还差得远。直接改HLSL代码对很多美术和初学程序的开发者来说门槛又太高。所以我花了不少时间把几种主流方案都折腾了一遍最终整合出了一个在URP下免费、完整、支持阴影和额外光源的卡通着色器实现方案。这个方案不依赖付费插件核心思路清晰你可以通过Shader Graph可视化搭建也可以直接使用我提供的代码片段进行深度定制。这个教程的目标很明确给你一个能直接跑起来的、效果不错的URP卡通着色器范例并掰开揉碎讲清楚背后的每一个步骤和原理。无论你是想快速应用到项目里还是想借此深入学习URP的着色器与光照架构这篇文章都能给你实实在在的帮助。2. 核心思路拆解在URP框架内“改造”光照模型在开始动手前我们必须先理解URP卡通着色器的核心挑战在哪里。URP是一个“黑盒”化比较严重的渲染管线它把很多光照计算都封装在了内部的Lighting.hlsl等核心库文件中。默认的Lit着色器使用的是复杂的PBR光照模型而我们想要的卡通渲染其光照模型通常是高度简化的。2.1 卡通光照的核心特征卡通着色或者说卡通渲染Toon Shading/Cel Shading其视觉效果核心可以归结为以下几点色阶化漫反射物体表面的漫反射亮度不是平滑过渡的而是被“量化”成几个明显的色阶。通常通过一个阶梯函数如smoothstep或直接step来处理法线向量与光照方向的点积NdotL来实现。高光形状可控高光区域通常是清晰的、形状特定的比如圆形、菱形而不是PBR那种基于粗糙度的模糊光斑。这可以通过对视角向量与反射向量的点积VdotR进行阈值处理来实现。轮廓边也就是所谓的“描边”Outline。这通常通过法线外扩、背面渲染或者基于深度的边缘检测等技术实现。本篇主要聚焦光照模型描边会作为进阶部分提及。阴影硬边投影阴影的边界往往比较锐利虽然这更多取决于光源和阴影设置但着色器对阴影的接收也可以做风格化处理。2.2 URP下的实现路径选择根据网络上的讨论和我自己的实践在URP下实现卡通光照主要有三条路各有利弊路径一纯Shader Graph拼接初期方案问题多这是最直观的想法。在Shader Graph里自己用节点计算NdotL然后接上一个Smoothstep或Posterize节点来创建色阶再自己组合输出到PBR Master或Unlit Master。但正如搜索内容中tonycoculuzzi在2019年抱怨的早期URP的Shader Graph对自定义光照模型的支持非常弱特别是阴影。你很难让一个自己拼出来的光照逻辑正确接收到来自场景的动态阴影。这个方法适合做完全无光照的卡通色块但一旦涉及动态光影就力不从心了。路径二自定义主节点Custom Master Node搜索内容中whoisj提到的方案。通过修改URP的源码包创建一个支持阴影投射和接收的自定义主节点。这给了Shader Graph最大的灵活性但操作复杂且与Unity版本强绑定。你需要手动合并GitHub上的PR每次Unity或URP版本升级都可能需要重新适配维护成本高不适合大多数寻求稳定方案的团队。路径三修改URP内置光照函数推荐方案这是搜索内容里ChrisAbra提出的思路也是我认为目前最平衡、最稳健的方案。它的核心思想不是另起炉灶而是**“改造”URP已有的Lit着色器**。URP的Lit.shader内部其实调用了一系列HLSL函数来计算光照这些函数定义在Packages/com.unity.render-pipelines.universal/Shaders/Lighting.hlsl等文件中。我们只需要复制一份Lit着色器然后找到关键的光照计算函数主要是LightingPhysicallyBased将其中的漫反射和高光计算模型替换成我们想要的卡通风格公式。这样做的好处巨大继承完整管线功能阴影投射与接收、全局光照GI、多光源支持Additional Lights、渲染队列、深度写入等所有URP管线的特性全部自动继承无需自己重新实现。这解决了纯Shader Graph方案的最大痛点。性能可控你只是在修改光照模型的计算部分而不是在默认的完整计算之上再叠加一层。ChrisAbra说得很好这“only as expensive as every line you add”而不是在默认PBR算出一堆复杂结果后你又把它覆盖掉。维护性相对好虽然也需要接触代码但改动点集中在几个关键函数上结构清晰。相比于自定义主节点对URP核心的侵入性更小。本教程将主要采用第三条路径并会展示如何将关键步骤映射到Shader Graph中让你既能理解底层原理也能享受可视化编辑的便利。3. 环境准备与项目设置在开始写代码或连节点之前我们需要一个正确的URP项目环境。这一步没做好后面全是徒劳。3.1 创建URP项目与导入必要资源新建项目打开Unity Hub创建一个新的3D项目Core或URP模板均可。如果选择的是Core模板我们需要手动安装URP。安装URP包通过Window - Package Manager打开包管理器。在Unity Registry中搜索Universal RP选择稳定的LTS版本如2022.3 LTS对应的URP 14.x进行安装。强烈建议使用LTS版本以避免API频繁变动带来的麻烦。创建URP资产在项目窗口中右键Create - Rendering - URP Asset (with Universal Renderer)。这会产生两个文件一个UniversalRP-Asset管线设置和一个UniversalRenderer渲染器设置。通常将它们放在Settings文件夹下。应用URP管线打开Edit - Project Settings - Graphics在Scriptable Render Pipeline Settings栏位中拖入你刚刚创建的UniversalRP-Asset。准备测试模型与场景找一个简单的模型比如Unity自带的Capsule或一个角色模型和一个平面作为地面接收阴影。创建一个新的场景摆放好模型、平面、一个方向光Directional Light和一个摄像机。确保方向光开启了Cast Shadows。注意很多卡通着色器教程效果不佳第一步就错了——他们没在URP管线环境下测试。确保你的项目Graphics设置里已经正确指定了URP Asset这是所有后续工作的基础。3.2 理解URP的着色器文件结构为了后续的修改我们需要知道URP的着色器代码藏在哪里。它们不是散落在项目里而是作为Package形式存在。在Package Manager中找到已安装的Universal RP包点击其名称右侧的小箭头选择View in File Explorer或在macOS的Finder中显示。这会打开该包在本地的安装目录。我们关心的核心着色器文件通常在Shaders文件夹下Lit.shader这是我们主要的模仿和修改对象。SimpleLit.shader一个更简化的光照模型有时作为修改起点更简单。Includes文件夹包含关键的HLSL头文件如Lighting.hlsl、SurfaceInput.hlsl等。我们不直接修改这些包内的文件而是将需要改动的部分复制到我们自己的着色器中。正确的做法是在项目的Assets目录下例如Assets/Shaders/Toon创建我们自己的着色器文件和HLSL包含文件。通过#include指令我们可以让自定义着色器优先使用我们修改过的HLSL代码同时仍能引用包内其他未修改的通用功能。4. 实战创建自定义URP卡通着色器我们将采取“复制-修改”的策略。首先复制URP的Lit着色器框架然后替换其核心光照计算。4.1 创建基础着色器框架在Assets/Shaders/Toon下创建一个新的文本文件命名为ToonLit.shader。你可以直接从Package中的Lit.shader复制全部内容过来作为起点。修改着色器第一行的名字避免混淆Shader Universal Render Pipeline/Toon Lit这个原始的Lit.shader结构非常清晰它定义了属性块、SubShader、多个Pass如ForwardLit、ShadowCaster、DepthOnly等。我们绝大部分的修改都只发生在ForwardLit Pass中特别是其HLSLPROGRAM部分。4.2 解剖关键HLSL代码与修改点打开你复制过来的ToonLit.shader找到ForwardLitPass中的HLSLPROGRAM部分。你会看到一系列#include语句。我们的目标是修改光照计算而光照计算发生在Lighting.hlsl中定义的函数里。更准确地说Lit.shader使用了一个叫UniversalFragmentPBR的函数在LitInput.hlsl或LitForwardPass.hlsl中定义来整合所有光照。这个函数内部会调用LightingPhysicallyBased。我们需要创建一个自己的版本。步骤一创建自定义光照函数与其直接修改复杂的UniversalFragmentPBR不如在着色器内部或一个单独的自定义HLSL文件中重写关键的计算函数。我们在ToonLit.shader的HLSLPROGRAM部分在#include语句之后自己编写函数。首先找到计算漫反射的核心。在PBR中漫反射是NdotL表面法线与光方向的点积乘以光的颜色。我们要将其卡通化。// 在 #include Packages/com.unity.render-pipelines.universal/Shaders/LitForwardPass.hlsl 之后添加 // 自定义的卡通漫反射计算函数 half3 ToonDiffuse(half3 lightColor, half3 normalWS, half3 lightDirWS, half rampSmooth, half2 rampThreshold) { half NdotL dot(normalize(normalWS), normalize(lightDirWS)); // 将NdotL从[-1, 1]映射到[0, 1]并应用ramp half diffuseTerm smoothstep(rampThreshold.x, rampThreshold.y, NdotL * 0.5 0.5); // 另一种更硬朗的阶梯效果使用step // half diffuseTerm step(_ToonThreshold, NdotL * 0.5 0.5); return lightColor * diffuseTerm; }这里引入了两个参数rampSmooth用于smoothstep的平滑度可以先固定为0.01和rampThreshold一个二维向量比如(0.3, 0.35)。smoothstep会在阈值区间内产生平滑过渡而step会产生硬切边。rampThreshold.x是过渡开始点rampThreshold.y是过渡结束点两者非常接近时就是硬边效果。步骤二替换原始光照调用这是最核心的一步。我们需要找到原始着色器中计算最终颜色的地方并用自己的函数替换掉默认的PBR光照结果。一个更系统的方法是复制并修改Lighting.hlsl中的LightingPhysicallyBased函数。在项目里创建一个新的HLSL文件比如CustomLighting.hlsl。从URP包的Lighting.hlsl中复制LightingPhysicallyBased函数到你的文件里然后进行修改。为了节省篇幅这里展示一个极度简化的、专注于卡通漫反射的修改思路// CustomLighting.hlsl #ifndef CUSTOM_LIGHTING_INCLUDED #define CUSTOM_LIGHTING_INCLUDED // 复制必要的结构体和函数声明... #include Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl // 定义卡通着色需要的属性变量这些会在Shader中声明并传递过来 half _ToonRampSmooth; half2 _ToonRampThreshold; // 重写或创建新的光照计算函数 half4 UniversalFragmentToon(InputData inputData, SurfaceData surfaceData) { // 1. 初始化颜色结果为环境光自发光 half4 color half4(surfaceData.albedo * inputData.bakedGI surfaceData.emission, surfaceData.alpha); // 2. 获取主光源信息URP提供了这个函数 Light mainLight GetMainLight(inputData.shadowCoord, inputData.positionWS, inputData.shadowMask); // 3. 应用卡通漫反射 half NdotL dot(inputData.normalWS, mainLight.direction); half toonDiffuse smoothstep(_ToonRampThreshold.x, _ToonRampThreshold.y, NdotL * 0.5 0.5); half3 diffuseColor mainLight.color * toonDiffuse * surfaceData.albedo; // 4. 叠加主光源颜色这里简化了实际还应考虑距离衰减、阴影等 color.rgb diffuseColor * mainLight.distanceAttenuation * mainLight.shadowAttenuation; // 5. 处理额外光源循环遍历 uint pixelLightCount GetAdditionalLightsCount(); for (uint lightIndex 0; lightIndex pixelLightCount; lightIndex) { Light light GetAdditionalLight(lightIndex, inputData.positionWS, inputData.shadowMask); half addNdotL dot(inputData.normalWS, light.direction); half addToonDiffuse smoothstep(_ToonRampThreshold.x, _ToonRampThreshold.y, addNdotL * 0.5 0.5); color.rgb light.color * addToonDiffuse * surfaceData.albedo * light.distanceAttenuation * light.shadowAttenuation; } return color; } #endif步骤三整合到Shader中回到ToonLit.shader文件。在Properties块中声明我们需要的属性_ToonRampSmooth (Ramp Smoothness, Range(0, 0.5)) 0.01 _ToonRampThreshold (Ramp Threshold (Min, Max), Vector) (0.3, 0.35, 0, 0)在HLSLPROGRAM中包含我们自定义的照明文件并替换掉原来的片段着色器函数调用。// 原来可能是 // #include Packages/com.unity.render-pipelines.universal/Shaders/LitForwardPass.hlsl // ... 然后调用 UniversalFragmentPBR(inputData, surfaceData) // 修改为 #include 你的路径/CustomLighting.hlsl // ... 在片段着色器中调用 UniversalFragmentToon(inputData, surfaceData)你需要仔细对照原LitForwardPass.hlsl的结构找到frag函数并将其返回结果改为调用我们自己的UniversalFragmentToon。这要求你对URP的着色器代码结构有一定的阅读能力。实操心得第一次做可能会被URP着色器里大量的宏和结构体搞晕。一个取巧且安全的方法是不要直接修改复杂的UniversalFragmentPBR调用链。而是在SurfaceData和InputData准备好之后在片段着色器末尾覆盖最终的计算结果。例如在调用UniversalFragmentPBR得到color之后再根据我们自己的卡通光照公式重新计算漫反射部分并覆盖color.rgb。这样能确保阴影、深度等所有其他通道的计算依然被正确执行我们只干预了颜色计算。虽然不够“优雅”但作为入门和调试非常有效。4.3 在Shader Graph中实现相同逻辑如果你更倾向于可视化操作可以在Shader Graph中部分实现这个逻辑但需要配合一个自定义函数节点来调用我们修改过的光照计算或者使用“Custom Function”节点来嵌入HLSL代码块。创建一个新的Shader Graph模板选择URP - Lit。删除默认的PBR Master节点因为我们要自定义光照。实际上更简单的方法是利用现有的Lit Master节点但通过自定义函数修改其输入。关键思路是我们仍然使用URP提供的Fragment Position、Normal、View Direction等节点获取数据。然后自己计算NdotL。添加一个Dot Product节点连接Normal和Light Direction节点。Light Direction节点需要从Lighting类别中拖出。对Dot Product的结果进行重映射Remap节点从[-1,1]到[0,1]。添加一个Smoothstep节点。将重映射后的值作为输入并创建两个Float属性如_RampStart,_RampEnd作为边缘阈值连接到Smoothstep的Edge1和Edge2。Smoothstep的输出就是一个0或1或中间平滑值的卡通化亮度系数。将这个系数与Light Color节点相乘再与Albedo颜色相乘得到卡通漫反射颜色。但是如何将这个颜色应用到主节点上这里有个技巧你可以将计算出的卡通漫反射颜色连接到PBR Master节点的Diffuse或Color端口如果存在但更通用的方法是连接到Emission端口并将Emission的权重调高。同时必须将PBR Master节点的Specular和Metallic设置为0以关闭PBR高光。这不是最物理正确的方式但在Shader Graph限制下能快速看到卡通漫反射效果。重要提示纯Shader Graph方案在接收动态对象投射的阴影上依然可能存在问题。为了实现完整的阴影接收你通常需要启用Shadow Matte或深度相关的节点这非常复杂。这再次印证了修改HLSL代码方案的优越性——阴影是自动处理的。5. 效果优化与功能扩展一个基础的卡通漫反射只是开始。要让效果更出色我们还需要添加一些风格化元素。5.1 添加风格化高光卡通高光通常是锐利且形状特殊的。我们可以在CustomLighting.hlsl中添加高光计算。// 在CustomLighting.hlsl中添加 half3 ToonSpecular(half3 lightColor, half3 normalWS, half3 lightDirWS, half3 viewDirWS, half specularSize, half specularSmooth) { half3 halfVec normalize(lightDirWS viewDirWS); half NdotH dot(normalize(normalWS), halfVec); // 使用step或smoothstep创建锐利高光区域 half specularTerm smoothstep(1.0 - specularSize, 1.0 - specularSize specularSmooth, NdotH); // 也可以使用power函数模拟更传统的高光但控制性不如step // half specularTerm pow(max(0, NdotH), _SpecularPower); return lightColor * specularTerm; }然后在UniversalFragmentToon函数中将高光项加到最终颜色里。你可以通过一个_SpecularColor属性来控制高光颜色。5.2 实现轮廓边描边描边是卡通渲染的标志。这里介绍两种在URP中常见的、与着色器结合的描边方法方法一背面外扩Backface Expansion这是最经典的方法。需要两个Pass。第一个Pass描边Cull Front剔除正面渲染背面将顶点沿法线方向挤出vertex.xyz normal * _OutlineWidth并输出一个纯色如黑色。第二个Pass正面着色Cull Back剔除背面渲染正面使用我们上面写的卡通着色逻辑。这种方法实现简单效果稳定但对于硬表面模型在轮廓转折处可能不均匀且无法处理内部轮廓。方法二基于屏幕空间的后处理Screen-Space Edge Detection这不是在物体着色器内完成而是通过一个全屏后处理效果检测深度或法线的突变来绘制边缘。效果更全面能捕捉到所有对象的边缘包括内部轮廓。实现起来更复杂需要编写一个Renderer Feature并添加到URP Renderer中。性能开销也比方法一高。对于初学者建议从背面外扩法开始。在你的ToonLit.shader中在ForwardLitPass之前添加一个OutlinePass。5.3 控制阴影颜色与硬度默认的阴影可能太“实”或颜色不对。我们可以修改阴影接收部分的计算。 在UniversalFragmentToon函数中我们使用了mainLight.shadowAttenuation。这是一个0到1的值1表示完全照亮0表示完全在阴影中。我们可以对这个值也进行卡通化处理half shadowTerm step(_ShadowThreshold, mainLight.shadowAttenuation); // 或者用smoothstep获得软阴影过渡 // half shadowTerm smoothstep(_ShadowThreshold - _ShadowSmooth, _ShadowThreshold _ShadowSmooth, mainLight.shadowAttenuation); diffuseColor * shadowTerm;同时可以定义一个_ShadowColor当shadowTerm为0时将漫反射颜色与阴影颜色混合而不是简单地乘以0。6. 常见问题与调试技巧实录在实现过程中你肯定会遇到各种问题。这里记录一些我踩过的坑和解决方法。6.1 着色器编译错误问题复制HLSL代码后出现大量“未定义的标识符”错误。排查这几乎总是因为#include路径错误或缺少必要的结构体定义。URP使用了很多宏来管理不同平台和特性。确保你的自定义.hlsl文件开头正确定义了保护宏#ifndef ... #define并且包含了最基础的Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl。最稳妥的方法是在你自定义的HLSL文件里首先包含原版Lighting.hlsl然后再重写其中的特定函数。因为Cg/HLSL允许函数重载如果签名不同或重新定义如果完全相同后定义的会覆盖先定义的。6.2 没有阴影或阴影异常问题模型不投射阴影或不接收阴影。排查确保ShadowCaster Pass存在我们复制的Lit.shader自带ShadowCasterPass。确保你的ToonLit.shader中没有删除它。这个Pass负责将深度信息写入阴影贴图。检查光源设置方向光必须开启Cast Shadows。在URP Asset中检查Shadows设置下的Max Distance和Depth Bias等参数是否合理。检查InputData中的shadowCoord在UniversalFragmentToon函数中inputData.shadowCoord必须被正确初始化并传递给GetMainLight函数。这个坐标用于在阴影贴图中采样。如果inputData.shadowCoord计算错误比如依赖于不正确的屏幕空间UV阴影就会失效。对照原版LitInput.hlsl中的InitializeInputData函数确保你复制了所有必要的步骤。6.3 额外光源点光源、聚光灯不生效问题只有主方向光有卡通效果场景中的点光源看起来还是PBR效果或者完全没效果。排查确认循环代码正确检查GetAdditionalLightsCount()和GetAdditionalLight的调用是否正确。确保在循环体内你对每个额外光源都应用了相同的卡通漫反射公式。检查光源范围与强度点光源和聚光灯有衰减范围。确保你的测试光源强度足够大且物体会在其范围内。可以在场景视图中查看光源的Gizmo范围。URP管线设置在URP Asset中Lighting设置下有一个Per Object Limit每物体光源数量。默认可能是4或8。如果你的场景中光源很多确保这个数值足够大并且你的着色器循环上限pixelLightCount与之匹配。GetAdditionalLightsCount()会自动处理这个限制。6.4 性能优化提示卡通着色器本身计算量通常比PBR小但仍有优化空间减少纹理采样如果不需要复杂的纹理尽量使用纯色或简单的顶点颜色。简化光照循环如果确定场景中额外光源不多可以硬编码处理少量光源而不是使用动态循环。虽然现代GPU循环开销不大但在低端设备上仍有差别。使用Shader LOD为你的卡通着色器设置不同的LOD级别。在远距离或低端设备上使用更少的光源计算或更简单的色阶。合批Batching确保使用相同的材质和着色器变体的物体能够动态合批。避免在着色器中使用过多导致材质实例化的属性如不同的_RampThreshold值。7. 整合与最终材质设置当你完成了着色器代码的编写和调试后最后一步是创建材质并应用到模型上。创建材质在项目窗口中右键Create - Material将其命名为Mat_ToonLit。指定着色器在材质的Inspector面板顶部点击Shader下拉菜单选择Universal Render Pipeline/Toon Lit就是你修改后着色器中定义的路径。调整属性现在你应该能看到之前在Properties块中定义的_ToonRampSmooth、_ToonRampThreshold等参数。调整它们在场景视图中实时观察效果。_ToonRampThreshold调整它来改变明暗分界线的位置。值越小亮部区域越大。_ToonRampSmooth调整过渡的平滑度。设为0或接近0得到硬边卡通效果增大它会得到更柔和的渐变适合某些特定的风格。_BaseMap和_BaseColor用于设置物体的主纹理和颜色。测试不同光照环境将材质球拖到你的测试模型上。尝试旋转方向光观察明暗变化是否符合卡通色阶。在场景中添加一个点光源或聚光灯移动它观察额外光源的卡通效果是否正确应用。测试阴影确保在方向光下模型能在平面上投射出阴影并且模型自身也能接收来自其他物体的阴影可以放一个方块在模型和光源之间。整个过程下来你可能需要反复在代码、Shader Graph和Unity编辑器之间切换调试。不要怕麻烦每一个问题的解决都会让你对URP的渲染流程理解更深一层。最终当你看到一个模型在URP管线中呈现出干净利落的卡通光影时那种成就感是非常棒的。这个自定义的ToonLit着色器可以作为你风格化项目的坚实基础你可以在此基础上继续扩展比如添加更多色阶Ramp Texture、实现更复杂的高光形状、或者整合各向异性光照等创造出属于你自己的独特视觉风格。