GameTech/URP 渲染实战
CHAPTER 24第 24 章

Shader Graph

前面二十多章我们一直在手写 HLSL。这一章换个视角:Shader Graph 是 URP 官方的可视化 Shader 编辑器,美术和技术美术用得最多。懂手写 Shader 的人学 Shader Graph 很快,难点不在连线,而在于知道它背后生成了什么代码、什么时候需要用 Custom Function 回到 HLSL。本章以 Shader Graph 14.0.12 为准,讲清 Targets、Blackboard、Keywords、Sub Graph、Custom Function,并用 Custom Function 在图里拿到主光、阴影和附加光,最后用 Shader Graph 重做卡通着色和溶解。

本章你会学到
Shader Graph 生成代码的方式,以及每种 Target / Material 类型会生成哪些 Pass
Blackboard 属性的 Reference、Exposed、Shader Declaration 与 SRP Batcher 的关系
Keyword 的三种 Definition、两种 Scope 及生成的 #pragma,Sub Graph 的封装与复用
Custom Function 的 File / String 模式、_float/_half 后缀、预览分支,以及在 Unlit Graph 里接入主光阴影与附加光
关于本章的配图Shader Graph 资源(.shadergraph)是一份结构复杂的 JSON,里面有节点 GUID、插槽 ID、序列化版本号等大量内部数据,不适合在书里手写,也无法在批处理中可靠生成。因此本章用文字、表格和节点连线示意图说明 Graph 的搭法;Custom Function 用到的 HLSL 文件是真实可用的;每个案例另写了一个“与 Graph 逐节点等价”的手写 Shader,既用来让 HLSL 文件在普通 Shader 中真实编译通过,也用来渲染效果图。本章所有效果图均由等价手写 Shader 渲染。

24.1 Shader Graph 生成了什么

Shader Graph 资源导入时,ShaderGraphImporter 会根据图中的 Target 设置,把节点网络翻译成一个完整的 .shader 源码,再交给 Unity 的 Shader 编译器。所以 Shader Graph 不是“另一种 Shader”,它最终和你手写的 ShaderLab + HLSL 是同一个东西。在 Project 窗口选中一个 Shader Graph 资源,Inspector 上的 View Generated Shader 可以看到生成的完整代码。第一次打开时你会发现它很长:每个 Pass 都包含大量宏、结构体(Attributes、Varyings、SurfaceDescriptionInputs、VertexDescriptionInputs)和 include。你连的节点会变成两个函数:

  • VertexDescriptionFunction:对应 Master Stack 的 Vertex 上下文,输出 Position、Normal、Tangent;
  • SurfaceDescriptionFunction:对应 Fragment 上下文,输出 Base Color、Normal、Metallic、Smoothness、Emission、Alpha 等。

Target 负责把这两个函数“套”进对应的 Pass 模板里:Lit 的 Forward Pass 会把 Surface Description 填进 SurfaceData,再调用和 URP Lit 相同的 UniversalFragmentPBR;Unlit 的 Forward Pass 则直接把 Base Color 输出。理解了这一点,很多问题就有了答案:为什么 Lit Graph 的光照和 Lit 材质一致(因为调用的是同一套函数);为什么改了 Alpha Clip 后阴影也跟着被裁剪(因为 ShadowCaster Pass 里也调用了同一个 Surface Description 函数);为什么用了某个节点后变体数暴增(因为节点或 Keyword 生成了 #pragma multi_compile)。

Shader Graph手写 Shader
上手门槛低,所见即所得,节点预览即时反馈高,需要懂 ShaderLab、HLSL、URP 库
Pass 完整性自动生成 ShadowCaster、DepthOnly、DepthNormals、Meta、GBuffer 等每个 Pass 都要自己写,容易漏
与 URP 升级Target 随 URP 升级,生成的代码自动跟进要自己跟进 include、关键字的变化
多 Pass、模板、特殊混合有限(Target 设置里能调的才能调)完全自由
自定义光照需要 Unlit + Custom Function直接写
变体与性能控制间接(通过 Keyword 设置、Precision)精确

生成代码导读:Lit Graph 的 Forward Pass

以 Lit Target 为例,Forward Pass 的模板在 URP 包的 Editor/ShaderGraph/Includes/PBRForwardPass.hlsl。顶点函数先调用 BuildVaryings(内部执行你的 Vertex Description,再做常规的空间变换),把结果打包成 PackedVaryings;片元函数的主干是这样的(节选):

// 摘自 URP 14 Editor/ShaderGraph/Includes/PBRForwardPass.hlsl(节选)
SurfaceDescription surfaceDescription = BuildSurfaceDescription(unpacked);  // ← 你的节点网络
#if defined(_ALPHATEST_ON)
    half alpha = AlphaDiscard(surfaceDescription.Alpha, surfaceDescription.AlphaClipThreshold);
#endif
InputData inputData;
InitializeInputData(unpacked, surfaceDescription, inputData);
SurfaceData surface;
surface.albedo     = surfaceDescription.BaseColor;
surface.metallic   = saturate(metallic);
surface.smoothness = saturate(surfaceDescription.Smoothness),
surface.occlusion  = surfaceDescription.Occlusion,
surface.emission   = surfaceDescription.Emission,
// …… 之后与 URP Lit 相同:UniversalFragmentPBR(inputData, surface)

这几行把整件事说清楚了:Master Stack 的每个 Block 就是 SurfaceDescription 结构体的一个字段,Target 把它们原样填进 SurfaceData,后面的光照、GI、雾、贴花(DBuffer)、Rendering Layers 全部由 URP 负责。你在图里做的事情,相当于手写 Lit 时重写了 InitializeStandardLitSurfaceData(第 10 章)。也正因为如此,Lit Graph 里的 Metallic、Smoothness 会被 saturate,超出 0~1 的值没有意义;Emission 不会被限制,可以输出 HDR 值配合 Bloom。

实际项目里两者是混用的:通用的表面材质、特效用 Shader Graph 让美术自己迭代;需要特殊 Pass、模板、极致性能的 Shader 手写;两者之间用 Custom Function 和 Sub Graph 共享 HLSL 代码。

24.2 界面与 Master Stack

在 Project 窗口右键 Create › Shader Graph › URP,可以看到 Lit、Unlit、Sprite Lit、Sprite Unlit、Sprite Custom Lit、Decal、Fullscreen 七种 Shader Graph 模板,它们只是预先选好了 Target 和 Material 类型,之后都可以在 Graph Settings 里改。双击打开后,窗口由几块组成:

Blackboard属性 Properties关键字 Keywords下拉 Dropdowns(Sub Graph) Main Preview整体预览球 工作区:节点与连线 节点 节点 Vertex 上下文PositionNormal / Tangent Fragment 上下文Base ColorNormal / MetallicSmoothness / AOEmission / Alpha Master Stack Graph InspectorGraph Settings 页:Precision、Targets、Material 类型与渲染设置Node Settings 页:选中节点 / 属性的详细设置
图 24-1Shader Graph 窗口的主要区域。Master Stack 的每一格(Block)对应生成代码里 Vertex/Surface Description 结构体的一个字段

Master Stack 上出现哪些 Block,取决于 Target 和设置。Shader Graph 14 中常用的 Block 如下(名称与 BlockFields.cs 一致):

上下文Block说明
VertexPosition / Normal / Tangent对象空间,用于顶点动画;不连就使用网格原值
FragmentBase ColorLit 中是 albedo,Unlit 中就是最终颜色
FragmentNormal (Tangent Space) / (Object Space) / (World Space)由 Graph Settings 的 Fragment Normal Space 决定用哪一个
FragmentMetallic 或 Specular Color由 Workflow Mode(Metallic / Specular)决定
FragmentSmoothness / Ambient Occlusion / Emission与 Lit 的 SurfaceData 字段一一对应
FragmentAlpha / Alpha Clip Threshold透明或开启 Alpha Clipping 时出现
FragmentCoat Mask / Coat Smoothness开启 Clear Coat 时出现(对应 Complex Lit)

节点之间的连线带有类型:Float、Vector2/3/4、Color、Texture2D、SamplerState、Boolean、Matrix 等。不同维度的向量相连时会自动截断或补齐(Vector4 连 Vector2 取 xy;Float 连 Vector3 会复制成三份),这和 HLSL 的隐式转换一致。每个节点右上角可以展开预览,预览是 Shader Graph 为这个节点单独生成并编译的一个小 Shader,这一点在 24.6 节讲 Custom Function 时很重要。

24.3 Targets 与 Material 类型

Graph Settings 页的 Active Targets 列表决定这张图为哪个管线生成代码。URP 项目里一般只有 Universal 一个 Target;也可以同时加上 Built-In,生成同时支持两条管线的 Shader(多 SubShader)。Shader Graph 14 本身还提供 Custom Render Texture 等 Target(Create › Shader Graph › Custom Render Texture)。Universal Target 下的 Material 下拉框决定具体的子类型:

Material用途主要生成的 Pass
LitPBR 表面,等价于 URP Lit / Complex LitUniversalForward(部分配置下为 UniversalForwardOnly)、UniversalGBuffer、ShadowCaster、DepthOnly、DepthNormals、Meta、Universal2D
Unlit不受光照或自定义光照Universal Forward(不带 LightMode 标签,按 SRPDefaultUnlit 绘制)、ShadowCaster、DepthOnly、DepthNormalsOnly、UniversalGBuffer
Sprite Lit / Sprite Unlit / Sprite Custom Lit2D Renderer 下的精灵Universal2D、SpriteForward 等
Decal配合 Decal Projector 或网格贴花DBuffer、屏幕空间贴花、GBuffer 贴花等(第 22 章)
Fullscreen配合 Full Screen Pass Renderer Feature 的全屏效果全屏绘制 Pass(第 25 章)

Lit 与 Unlit 共有的渲染设置(来自 UniversalTarget.cs)如下,括号里是它在生成代码中的落点:

设置作用
Surface TypeOpaque / Transparent,决定渲染队列、ZWrite 默认值和 _SURFACE_TYPE_TRANSPARENT
Blending ModeAlpha / Premultiply / Additive / Multiply,生成对应的 Blend 状态
Render FaceFront / Back / Both,生成 Cull 状态
Depth Write / Depth TestZWrite 与 ZTest
Alpha Clipping出现 Alpha Clip Threshold Block,所有 Pass 里调用 clip()(包括 ShadowCaster、DepthOnly)
Cast Shadows / Receive Shadows是否生成 ShadowCaster Pass、是否定义 _RECEIVE_SHADOWS_OFF
Supports LOD Cross Fade加入 LOD_FADE_CROSSFADE 变体
Allow Material Override把上面这些设置暴露成材质属性,美术可以逐材质修改(会多出变体与属性)
Support VFX Graph让 Graph 可以作为 VFX Graph 的输出 Shader
Custom Editor GUI指定材质的自定义 Inspector 类名

Lit 还有 Workflow Mode(Metallic / Specular)、Fragment Normal Space、Clear Coat 等。一个常见的误区是:在 Unlit Graph 里想“顺便”接收阴影,于是勾上 Receive Shadows——Unlit 的 Forward Pass 根本不计算光照,也就没有阴影可接收。要在 Unlit 图里用光照和阴影,就得自己写 Custom Function(24.6 节)。

Fullscreen 与 Decal 目标

Fullscreen 是 URP 14(Unity 2022.2 起)新增的 Material 类型,生成的 Shader 专门给 Full Screen Pass Renderer Feature 用:它画一个覆盖全屏的三角形,Master Stack 只有 Base Color 和 Alpha。它的 Graph Settings 和表面类目标完全不同:没有光照相关选项,取而代之的是 Blend Mode(含自定义 Src/Dst 混合因子和混合运算)、Depth Test、Depth Write Mode,以及一整组模板设置(Enable Stencil、Reference、Read Mask、Write Mask、Comparison、Pass、Fail、Depth Fail)——这是 Shader Graph 里少有的能直接写模板状态的地方。配合 URP 专用的 URP Sample Buffer 节点,可以读取三种缓冲:BlitSource(当前相机颜色,也就是全屏 Pass 的输入)、NormalWorldSpace(法线缓冲)、MotionVectors(运动矢量)。用 Fullscreen Graph 做灰度、描边、扫描线这类效果非常快,第 25、26 章会从手写的角度实现同类效果,读者可以对照着看两种做法。

Decal 目标生成给 Decal Projector 或网格贴花用的 Shader,Master Stack 是 Base Color、Normal、Metallic、Smoothness、Ambient Occlusion、Emission 和各自的 Alpha 等,Graph Settings 里用 Affect BaseColor、Affect Normal、Affect MAOS(金属度/AO/光滑度)、Affect Emission 等开关决定贴花写入哪些缓冲,Angle Fade 控制按角度淡出,这部分在第 22 章已经结合 DBuffer 和屏幕空间两种技术讲过。

24.4 Blackboard:属性

Blackboard 左上角的 + 号可以添加属性(Property)。属性对应 ShaderLab 的 Properties 块和 HLSL 里的变量声明。Shader Graph 14 支持的类型有:Float、Vector2/3/4、Color、Boolean、Gradient、Texture2D、Texture2D Array、Texture3D、Cubemap、Virtual Texture、Matrix2/3/4、SamplerState。选中一个属性,Node Settings 页里有这些关键字段:

字段说明
Name / ReferenceName 是显示名;Reference 是代码里的变量名(如 _BaseColor)。C# 里 material.SetColor 用的是 Reference,改名后脚本要跟着改
Default默认值;纹理还可以选默认的 white/black/grey/bump
Mode(Float)Default / Slider / Integer / Enum,分别生成普通数值、Range、Integer、[Enum] 属性
Exposed是否出现在材质 Inspector 上。不 Exposed 的属性不进 Properties 块,只能用脚本或全局变量赋值
Override Property Declaration / Shader Declaration勾选后可选 Global、Per Material、Hybrid Per Instance(还有 Do Not Declare)。默认情况下暴露的属性声明在 UnityPerMaterial 常量缓冲里(Per Material),这是 SRP Batcher 兼容的前提
Precision属性自身的精度,可继承图的设置

SRP Batcher 与声明位置。SRP Batcher 要求所有材质属性都放在名为 UnityPerMaterial 的常量缓冲里,而且同一个 Shader 的所有材质缓冲布局一致(第 2、29 章)。Shader Graph 默认就这样生成。但如果你把某个属性的 Shader Declaration 改成 Global,它就变成全局变量,只能用 Shader.SetGlobalXxx 赋值——这正是用 Shader Graph 读取“全局风向”“全局时间缩放”这类数据的方法:Blackboard 建一个属性,取消 Exposed,Reference 填 _GlobalWindDir,Declaration 设为 Global,C# 里每帧 Shader.SetGlobalVector("_GlobalWindDir", …)。

Blackboard 里的属性可以分组(Category),也可以拖进工作区变成一个属性节点。Texture2D 属性节点通常连到 Sample Texture 2D 节点;如果需要控制采样器(Clamp、Point),可以再加一个 SamplerState 属性或 Sampler State 节点接到 Sample Texture 2D 的 Sampler 输入口。

24.5 Keywords:在图里做变体

Keyword 对应 #pragma multi_compile / shader_feature 和 #if。在 Blackboard 添加一个 Keyword,再把它拖进工作区,就得到一个 Keyword 节点:它有若干输入口和一个输出口,编译某个变体时只有对应的输入口会被使用。生成代码就是一组 #if defined(...)。

设置可选值含义
TypeBoolean / EnumBoolean 有 On/Off 两个分支;Enum 有若干个条目
DefinitionShader Feature / Multi Compile / Predefined分别生成 shader_feature、multi_compile;Predefined 不生成 pragma,用于读取别处已经定义的宏(如 URP 的 _MAIN_LIGHT_SHADOWS 在 Lit Target 里已定义)
ScopeLocal / GlobalLocal 生成 _local 后缀的 pragma,只能用材质开关;Global 可以用 Shader.EnableKeyword 全局开关
StagesAll / Vertex / Fragment生成 _vertex / _fragment 后缀,只在一个阶段产生变体,减少变体数
Reference如 _USE_RIM_ONBoolean 的 Reference 以 _ON 结尾时,暴露到材质上会生成 [Toggle] 属性

Shader Graph 生成 pragma 的规则在 KeywordUtil.cs 里:Boolean 关键字生成 _ 引用名 两项;Enum 关键字的每个条目生成 引用名_条目名,条目的 Reference 为空时生成 _(即“都不开”的那个变体)。URP 的 Target 自己声明主光阴影关键字时就利用了这一点:一个 Reference 为空的 Enum,条目分别是空、MAIN_LIGHT_SHADOWS、MAIN_LIGHT_SHADOWS_CASCADE、MAIN_LIGHT_SHADOWS_SCREEN,于是生成 #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN,和 Lit.shader 里手写的那行一模一样。

案例 24.1Enum 与 Boolean Keyword入门单独打开 ↗

目标:一张图里放三种程序化图案(条纹、圆点、棋盘),用 Enum Keyword 在材质上切换;再用一个 Boolean Keyword 控制是否叠加边缘光。

  1. Blackboard 添加 Keyword › Enum,命名 Pattern,Reference 改为 _PATTERN;Entries 里添加三项,Reference 分别是 STRIPES、DOTS、CHECKER;Definition 选 Shader Feature,Scope 选 Local。
  2. 添加 Keyword › Boolean,命名 Use Rim,Reference _USE_RIM_ON,Definition Shader Feature,Scope Local。
  3. 把 Pattern 拖进工作区,它有 Stripes、Dots、Checker 三个输入口:分别接三种图案的计算结果(UV × Tiling 后用 Fraction、Step、Distance 等节点算出 0/1 遮罩),输出接 Lerp 的 T,在两种颜色之间插值。
  4. Lerp 结果乘上光照后接到 Use Rim 节点的 Off 口;同一结果加上 Fresnel Effect × 颜色后接 On 口;Use Rim 的输出连 Base Color。

这张图生成的属性和 pragma,与下面这段手写代码逐字相同:

// Properties
[KeywordEnum(Stripes, Dots, Checker)] _PATTERN ("Pattern", Float) = 0
[Toggle] _USE_RIM ("Use Rim", Float) = 0
Assets/Cookbook/Ch24/SGKeywordEquivalent.shader(节选:pragmas)
#pragma shader_feature_local _PATTERN_STRIPES _PATTERN_DOTS _PATTERN_CHECKER
#pragma shader_feature_local _ _USE_RIM_ON
#pragma shader_feature_local _PATTERN_STRIPES _PATTERN_DOTS _PATTERN_CHECKER
#pragma shader_feature_local _ _USE_RIM_ON

Keyword 节点在生成的 Surface Description 函数里就是预处理分支:

Assets/Cookbook/Ch24/SGKeywordEquivalent.shader(节选:keyword)
half4 frag(Varyings i) : SV_Target
{
    float2 uv = i.uv * _Tiling;
    half mask;
    // 一个 Enum Keyword 节点 = 一组 #if/#elif,每个分支对应节点上的一个输入口
#if defined(_PATTERN_STRIPES)
    mask = step(0.5, frac(uv.x + uv.y));
#elif defined(_PATTERN_DOTS)
    mask = step(length(frac(uv) - 0.5), 0.3);
#else // _PATTERN_CHECKER
    float2 c = floor(uv);
    mask = fmod(c.x + c.y, 2.0);
#endif
    half3 albedo = lerp(_ColorA.rgb, _ColorB.rgb, mask);

    half3 n = normalize(i.normalWS);
    Light l = GetMainLight();
    half3 color = albedo * (l.color * saturate(dot(n, l.direction)) + SampleSH(n));

    // Boolean Keyword 节点 = 一个 #if,On / Off 两个输入口
#if defined(_USE_RIM_ON)
    half3 v = GetWorldSpaceNormalizeViewDir(i.positionWS);
    color += pow(1 - saturate(dot(n, v)), 3) * half3(1.0, 0.9, 0.7) * 1.5;
#endif
    return half4(color, 1);
}
vec4 frag()
{
    vec2 uv = uv0 * _Tiling;
    float mask;
    // 一个 Enum Keyword 节点 = 一组 #if/#elif,每个分支对应节点上的一个输入口
#if defined(_PATTERN_STRIPES)
    mask = step(0.5, fract(uv.x + uv.y));
#elif defined(_PATTERN_DOTS)
    mask = step(length(fract(uv) - 0.5), 0.3);
#else // _PATTERN_CHECKER
    vec2 c = floor(uv);
    float s = c.x + c.y;
    mask = s - 2.0 * trunc(s / 2.0);   // HLSL 的 fmod(s, 2)(负数时结果为负,与 GLSL 的 mod 不同)
#endif
    vec3 albedo = mix(_ColorA.rgb, _ColorB.rgb, mask);

    vec3 n = normalize(normalWS);
    Light l = GetMainLight();
    vec3 color = albedo * (l.color * saturate(dot(n, l.direction)) + SampleSH(n));

    // Boolean Keyword 节点 = 一个 #if,On / Off 两个输入口
#if defined(_USE_RIM_ON)
    vec3 v = GetWorldSpaceNormalizeViewDir(positionWS);
    color += pow(1.0 - saturate(dot(n, v)), 3.0) * vec3(1.0, 0.9, 0.7) * 1.5;
#endif
    return vec4(color, 1);
}
图 24-2同一张图的三个变体(由等价手写 Shader 渲染)。左:_PATTERN_STRIPES;中:_PATTERN_DOTS;右:_PATTERN_CHECKER + _USE_RIM_ON

变体数:这张图在这两个 Keyword 上产生 3 × 2 = 6 个变体,再乘以 URP Target 自带的那些 multi_compile(阴影、光照贴图、Forward+、雾……)。Keyword 是变体爆炸的主要来源,几条经验:只在材质上切换的开关用 Shader Feature + Local,未被任何材质使用的 shader_feature 变体打包时会被剔除;只影响片元的开关把 Stages 设为 Fragment;能用一个 Float 属性 + 分支(或 Lerp)解决、分支两侧开销又不大的,就不要用 Keyword。

注意用 C# 代码创建或修改材质时,KeywordEnum 的切换要手动处理关键字:先 DisableKeyword 同组的其他关键字,再 EnableKeyword 目标关键字。只改 Float 属性值不会同步关键字(材质 Inspector 会替你同步,脚本不会);同组多个关键字同时开着时,选中哪个变体是不确定的。本书的配图脚本就踩过这个坑。

24.6 Sub Graph:封装与复用

Sub Graph 是“节点组成的函数”。右键 Create › Shader Graph › Sub Graph 新建,或者在一张图里框选若干节点,右键 Convert To › Sub-graph 直接提取。Sub Graph 的输入在它自己的 Blackboard 上定义(属性即参数),输出由 Output 节点上添加的端口决定。保存后,它会作为一个节点出现在所有图的创建菜单里(可以在 Graph Inspector 里设置它在菜单中的路径)。

Sub Graph:Rim(边缘光) 输入(Blackboard)Power : FloatColor : ColorMask : Float Fresnel EffectNormal / ViewPower → Out MultiplyA × B × Mask OutputRim : Vector3 在主图里它就是一个节点:Rim(Power, Color, Mask) → Rim
图 24-3一个简单的边缘光 Sub Graph。Fresnel Effect 节点内部是 pow(1 − saturate(dot(N, V)), Power)

Sub Graph 的几个细节:一是精度,Sub Graph 的 Graph Settings 里 Precision 可以设为 Switchable(随调用它的节点决定精度),这也是它内部 Custom Function 会分别调用 _float 或 _half 版本的原因;二是 Sub Graph 的 Blackboard 也可以放 Keyword 和 Dropdown(Dropdown 是 Sub Graph 专用的“编译期选项”,每次使用时在节点上选一项,不产生变体);三是 Sub Graph 输入可以设置“Use Custom Binding”,配合 Branch On Input Connection 节点判断调用者有没有连这个口,没连时用默认值(比如没连 UV 就用网格 UV0)。

Sub Graph 适合封装“纯计算”的节点组:三平面映射、法线混合、色阶化、溶解遮罩、风场噪声。需要读取管线数据(灯光、阴影)的部分,则交给下一节的 Custom Function。

24.7 Custom Function:回到 HLSL

Custom Function 节点让你在图里调用一段 HLSL。在节点的 Node Settings 里:

  • Inputs / Outputs:用 + 号逐个添加端口,设置名字和类型。端口顺序就是生成的函数参数顺序:所有 Inputs 在前,所有 Outputs 在后(作为 out 参数)。
  • Type:String 或 File。String 模式直接在节点里写函数体;File 模式指定一个 .hlsl(或 .cginc/.cg)文件,再填函数名。
  • Name:函数名,不带后缀。

Shader Graph 生成调用时,会在你填的名字后加上 _$precision,按节点最终精度替换成 _float 或 _half(源码 CustomFunctionNode.hlslFunctionName:m_FunctionName + "_$precision")。所以 File 模式下,HLSL 文件里的函数必须带后缀:Name 填 MainLight,文件里要有 void MainLight_float(...);如果这个节点可能以 half 精度编译(图的精度设为 Half,或者在 Switchable 的 Sub Graph 里),还必须提供 MainLight_half,否则会报“未定义”。String 模式由 Shader Graph 自己生成函数头,不用操心后缀。

File 模式下,Shader Graph 把这个文件 #include 进生成的 Shader,同一个文件被多个节点引用也只 include 一次;但为了安全,文件本身最好加 include 保护(#ifndef … #define … #endif)。文件放在 Assets 下任意位置都可以,节点里引用的是资源本身,移动文件不会断。

端口类型与 HLSL 参数类型

端口类型决定生成的参数类型。数值类端口在 _float 版本里是 float/float2/float3/float4,在 _half 版本里是对应的 half 类型;Boolean 是 bool;Matrix 是 float3x3/float4x4。资源类端口需要特别注意:从 Shader Graph 12 开始,Texture2D 端口传进函数的不是裸的 Texture2D,而是 core 包 Texture.hlsl 里定义的 UnityTexture2D 结构体(里面打包了纹理、配套采样器、texelSize 和 Tiling/Offset),SamplerState 端口是 UnitySamplerState。在函数里可以这样采样:

void SampleTwice_float(UnityTexture2D Tex, UnitySamplerState SS, float2 UV, out float4 Out)
{
    // 写法一:用结构体自带的成员函数,采样器用传入的 SS
    float4 a = Tex.Sample(SS, UV);
    // 写法二:用结构体里打包的纹理和它自己的采样器,再套 URP 的宏
    float4 b = SAMPLE_TEXTURE2D(Tex.tex, Tex.samplerstate, Tex.GetTransformedUV(UV));
    Out = (a + b) * 0.5;
}

端口类型下拉框里还有 BareTexture2D 这类“裸资源”选项,用于兼容旧版本写法(参数生成 TEXTURE2D(name))。新写的函数建议统一用结构体类型,它的好处是 Tiling/Offset 和 texelSize 一起传了进来,不需要额外开端口。

String 模式

String 模式适合三五行的小计算。节点会根据端口自动生成函数头,你只写函数体。例如端口为输入 In(Float)、Steps(Float),输出 Out(Float),函数体写一行 Out = floor(In * Steps) / Steps;,生成的代码就是:

void Posterize_float(float In, float Steps, out float Out)
{
    Out = floor(In * Steps) / Steps;
}

String 模式的代码存在图的 JSON 里,无法被别的图或手写 Shader 复用,也没法做版本对比,所以超过几行就应该改成 File 模式。

预览为什么会报错:SHADERGRAPH_PREVIEW

节点预览和 Main Preview 不是用 URP 的 Target 生成的,而是 Shader Graph 自己的 PreviewTarget。查看 Editor/Generation/Targets/PreviewTarget.cs 可以看到它只 include 了 core 包的 Common.hlsl、Packing.hlsl、Color.hlsl 等和 Shader Graph 自己的库,不包含 URP 的 Lighting.hlsl。所以 Custom Function 里一旦调用 GetMainLight、TransformWorldToShadowCoord,节点预览就会编译失败(节点变成粉色并报未定义标识符),而真正的材质却是好的。预览编译时 Shader Graph 会定义 SHADERGRAPH_PREVIEW 宏(Fields.IsPreview),我们用它给预览一个替身。

案例 24.2在 Graph 中读取主光与阴影进阶单独打开 ↗

目标:写一个 File 模式的 Custom Function,输入世界坐标,输出主光方向、颜色、距离衰减和阴影衰减,可以同时用于 Lit 和 Unlit 图。

Assets/Cookbook/Ch24/CustomLighting.hlsl(节选:mainlight)
void MainLight_float(float3 WorldPos, out float3 Direction, out float3 Color,
                     out float DistanceAtten, out float ShadowAtten)
{
#if defined(SHADERGRAPH_PREVIEW)
    // 预览球:给一个固定的斜上方光,不算阴影
    Direction = normalize(float3(0.5, 0.5, -0.3));
    Color = float3(1, 1, 1);
    DistanceAtten = 1;
    ShadowAtten = 1;
#else
    // 在片元阶段逐像素求阴影坐标:同时兼容无级联、级联和屏幕空间阴影三种关键字
    float4 shadowCoord = TransformWorldToShadowCoord(WorldPos);
    // 带 positionWS 的重载会顺带做“阴影距离淡出”,与 URP Lit 行为一致
    Light light = GetMainLight(shadowCoord, WorldPos, half4(1, 1, 1, 1));
    Direction = light.direction;
    Color = light.color;
    DistanceAtten = light.distanceAttenuation;
    ShadowAtten = light.shadowAttenuation;
#endif
}

void MainLight_half(half3 WorldPos, out half3 Direction, out half3 Color,
                    out half DistanceAtten, out half ShadowAtten)
{
    // half 版本直接转调 float 版本。注意:世界坐标一旦以 half 传进来就已经丢了精度(离原点远时阴影会抖),
    // 所以实际项目里建议把这个节点的 Precision 设成 Single,让 Shader Graph 调用 _float 版本
    float3 d, c; float da, sa;
    MainLight_float(WorldPos, d, c, da, sa);
    Direction = d; Color = c; DistanceAtten = da; ShadowAtten = sa;
}
void MainLight_float(vec3 WorldPos, out vec3 Direction, out vec3 Color,
                     out float DistanceAtten, out float ShadowAtten)
{
#if defined(SHADERGRAPH_PREVIEW)
    // 预览球:给一个固定的斜上方光,不算阴影
    Direction = normalize(vec3(0.5, 0.5, -0.3));
    Color = vec3(1, 1, 1);
    DistanceAtten = 1.0;
    ShadowAtten = 1.0;
#else
    // 在片元阶段逐像素求阴影坐标:同时兼容无级联、级联和屏幕空间阴影三种关键字
    vec4 shadowCoord = TransformWorldToShadowCoord(WorldPos);
    // 带 positionWS 的重载会顺带做“阴影距离淡出”,与 URP Lit 行为一致
    Light light = GetMainLight(shadowCoord, WorldPos, vec4(1, 1, 1, 1));
    Direction = light.direction;
    Color = light.color;
    DistanceAtten = light.distanceAttenuation;
    ShadowAtten = light.shadowAttenuation;
#endif
}

void MainLight_half(vec3 WorldPos, out vec3 Direction, out vec3 Color,
                    out float DistanceAtten, out float ShadowAtten)
{
    // half 版本直接转调 float 版本。注意:世界坐标一旦以 half 传进来就已经丢了精度(离原点远时阴影会抖),
    // 所以实际项目里建议把这个节点的 Precision 设成 Single,让 Shader Graph 调用 _float 版本
    vec3 d, c; float da, sa;
    MainLight_float(WorldPos, d, c, da, sa);
    Direction = d; Color = c; DistanceAtten = da; ShadowAtten = sa;
}

节点设置:Type = File,Source 选这个 hlsl,Name = MainLight;Inputs 一个 WorldPos(Vector3);Outputs 依次是 Direction(Vector3)、Color(Vector3)、DistanceAtten(Float)、ShadowAtten(Float)。WorldPos 接 Position 节点(Space = World)。

PositionSpace: World Out Custom Function: MainLight WorldPosDirectionColorDistanceAttenShadowAtten Dot ProductA: Normal(World)B: Direction MultiplyColor × Shadow× Saturate(N·L) Base Color
图 24-4最简单的自定义 Lambert:MainLight 节点的输出与 Normal Vector 做点积,再乘灯光颜色和阴影

阴影坐标在片元阶段逐像素计算:Custom Function 默认被调用在 Surface Description 函数里,也就是片元着色器中。这样无论级联与否、是否屏幕空间阴影都能得到正确结果(23.4 节解释过为什么不能在顶点阶段插值级联阴影坐标)。

Unlit 图要手动加关键字。Lit Target 的 Forward Pass 已经声明了全部光照关键字;Unlit Target 的 Forward Pass 则只有光照贴图、SSAO、DBuffer 等少数几个(见 UniversalUnlitSubTarget.cs 的 UnlitKeywords.Forward)。在 Unlit 图里用上面的函数,_MAIN_LIGHT_SHADOWS 之类的宏全都没定义,MainLightRealtimeShadow 会直接返回 1——不报错,只是没阴影。解决办法是在 Blackboard 里添加以下 Keyword(全部 Multi Compile、Global、不 Exposed):

Keyword(Reference)类型作用
_MAIN_LIGHT_SHADOWS、_MAIN_LIGHT_SHADOWS_CASCADE、_MAIN_LIGHT_SHADOWS_SCREENBoolean ×3主光阴影(单级联 / 级联 / 屏幕空间)
_SHADOWS_SOFTBoolean,Stages = Fragment软阴影(每灯档位)
_ADDITIONAL_LIGHTSBoolean附加光逐像素
_ADDITIONAL_LIGHT_SHADOWSBoolean,Stages = Fragment附加光阴影
_FORWARD_PLUSBooleanForward+ 路径的分簇光照

用 Boolean 而不是一个 Enum 声明三个阴影关键字会多出一些永远用不到的组合(2³ 而不是 4),变体数更多;追求精简可以照 URP Target 的做法用 Enum,但 Blackboard 上的 Enum Reference 不能留空,需要接受一个类似 _MAIN_LIGHT_SHADOWS_OFF 的占位条目。本章等价 Shader 的这几行就是 Blackboard 上这些 Keyword 生成出来的东西:

#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
#pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
#pragma multi_compile _ _ADDITIONAL_LIGHTS
#pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
#pragma multi_compile _ _FORWARD_PLUS
图 24-5同一个卡通 Shader 的两个分支(由等价手写 Shader 渲染)。左:打开 SHADERGRAPH_PREVIEW 时的替身分支,固定光向、没有阴影,相当于你在节点预览里看到的;右:场景中的真实结果,主光从右后方来,光向和阴影都来自 URP

预览分支给的值要“看起来合理”即可,它只影响编辑器里的预览球,不会进入打包后的 Shader。有些作者喜欢在预览分支里返回明显的颜色(如纯品红),提醒自己“这个节点在预览里是假的”,也是个好习惯。

案例 24.3附加光:兼容 Forward 与 Forward+高阶单独打开 ↗

附加光需要遍历光源列表。Forward 路径下是普通的 for 循环;Forward+ 路径下要用 LIGHT_LOOP_BEGIN/END 遍历当前像素所在的光源簇,而这对宏要求作用域里有一个名为 inputData、填好 positionWS 和 normalizedScreenSpaceUV 的 InputData 变量(见 URP 14 RealtimeLights.hlsl 第 36 行的宏定义)。另外 Forward+ 把额外的方向光放在索引最前面,不参与分簇,要像 UniversalFragmentPBR 那样单独循环一次。

Assets/Cookbook/Ch24/CustomLighting.hlsl(节选:additional)
// 所有附加光(点光、聚光)的 Lambert 漫反射之和。
// ScreenUV 接 Screen Position 节点(Default 模式),Forward+ 的分簇查询需要它。
void AdditionalLightsLambert_float(float3 WorldPos, float3 WorldNormal, float2 ScreenUV, out float3 Diffuse)
{
    Diffuse = 0;
#if !defined(SHADERGRAPH_PREVIEW)
    // LIGHT_LOOP_BEGIN 在 Forward+ 下会读取名为 inputData 的变量,所以这里按约定造一个
    InputData inputData = (InputData)0;
    inputData.positionWS = WorldPos;
    inputData.normalizedScreenSpaceUV = ScreenUV;

    uint lightCount = GetAdditionalLightsCount();
    #if USE_FORWARD_PLUS
    // Forward+ 把额外的方向光放在索引最前面,单独循环
    for (uint lightIndex = 0; lightIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); lightIndex++)
    {
        Light light = GetAdditionalLight(lightIndex, WorldPos, half4(1, 1, 1, 1));
        Diffuse += light.color * light.distanceAttenuation * light.shadowAttenuation * saturate(dot(WorldNormal, light.direction));
    }
    #endif
    LIGHT_LOOP_BEGIN(lightCount)
        Light light = GetAdditionalLight(lightIndex, WorldPos, half4(1, 1, 1, 1));
        Diffuse += light.color * light.distanceAttenuation * light.shadowAttenuation * saturate(dot(WorldNormal, light.direction));
    LIGHT_LOOP_END
#endif
}

void AdditionalLightsLambert_half(half3 WorldPos, half3 WorldNormal, half2 ScreenUV, out half3 Diffuse)
{
    float3 d;
    AdditionalLightsLambert_float(WorldPos, WorldNormal, ScreenUV, d);
    Diffuse = d;
}
// 所有附加光(点光、聚光)的 Lambert 漫反射之和。
// ScreenUV 接 Screen Position 节点(Default 模式),Forward+ 的分簇查询需要它(网页版不分簇,用不到)。
void AdditionalLightsLambert_float(vec3 WorldPos, vec3 WorldNormal, vec2 ScreenUV, out vec3 Diffuse)
{
    Diffuse = vec3(0.0);
#if !defined(SHADERGRAPH_PREVIEW)
    // HLSL 版在 Forward+ 下要单独循环方向光,并用 LIGHT_LOOP_BEGIN/END;网页版就是一个普通循环
    int lightCount = GetAdditionalLightsCount();
    for (int lightIndex = 0; lightIndex < lightCount; lightIndex++)
    {
        Light light = GetAdditionalLight(lightIndex, WorldPos, vec4(1, 1, 1, 1));
        Diffuse += light.color * light.distanceAttenuation * light.shadowAttenuation * saturate(dot(WorldNormal, light.direction));
    }
#endif
}

void AdditionalLightsLambert_half(vec3 WorldPos, vec3 WorldNormal, vec2 ScreenUV, out vec3 Diffuse)
{
    vec3 d;
    AdditionalLightsLambert_float(WorldPos, WorldNormal, ScreenUV, d);
    Diffuse = d;
}

节点上加一个 ScreenUV(Vector2)输入,接 Screen Position 节点(Mode = Default,输出就是 0~1 的归一化屏幕坐标)的 xy。等价 Shader 里对应的是 GetNormalizedScreenSpaceUV(positionCS)。

24.8 节点与手写代码对照

熟悉手写 Shader 的读者,最快的上手方法是建立“节点 ↔ 函数”的对应。下表列出最常用的一批节点在 URP 14 生成代码中的大致对应(以 View Generated Shader 中看到的实现为准):

节点对应的手写代码说明
Position(Object/World/View/Tangent/Absolute World)positionOS、positionWS 等URP 不做相机相对渲染,World 与 Absolute World 相同;HDRP 中二者不同
Normal Vector / Tangent Vector / View DirectionnormalWS、GetWorldSpaceNormalizeViewDir在片元阶段使用时是插值后的值;View Direction 自 2021.2 起输出归一化向量,需要未归一化的“相机到像素”向量用 View Vector 节点
UV / Vertex Colortexcoord0~3、color用到才会被加入 Attributes/Varyings
Sample Texture 2DSAMPLE_TEXTURE2DType 选 Normal 时自动解包法线(UnpackNormal);只能用在片元阶段
Sample Texture 2D LODSAMPLE_TEXTURE2D_LOD指定 mip,可用于顶点阶段(顶点动画读高度图)
Fresnel Effectpow(1 − saturate(dot(N, V)), Power)边缘光、护盾、水面反射权重
Lerp / Step / Smoothstep / Remaplerp、step、smoothstep、线性重映射与第 4 章的数学工具一一对应
Time_Time、_SinTime、_CosTime、unity_DeltaTimeTime 输出即 _Time.y
Screen PositionGetNormalizedScreenSpaceUV 等Default 模式为归一化 0~1;Raw 为未除 w 的裁剪坐标
Scene DepthSampleSceneDepth + Linear01Depth/LinearEyeDepthLinear01 / Raw / Eye 三种模式;需要 URP Asset 开启 Depth Texture
Scene ColorSampleSceneColor(_CameraOpaqueTexture)需要开启 Opaque Texture,且只对透明物体有意义(第 18、19 章)
Main Light Direction_MainLightPosition.xyz只有方向,没有颜色和阴影;需要阴影时用本章的 Custom Function
Baked GI / Ambient / Reflection ProbeSampleSH/光照贴图、环境色、GlossyEnvironmentReflection在 Unlit 自定义光照里补间接光
TransformTransformObjectToWorld 等Type 选 Position/Direction/Normal,对应是否带平移、是否用逆转置
Normal Blend / Normal From Height / Normal StrengthRNM/UDN 混合、由高度求法线、法线强度与第 7 章对应
Triplanar三平面采样与第 20 章的 Triplanar 对应,Type 为 Normal 时做法线三平面

顶点阶段、片元阶段与 Custom Interpolator

连到 Vertex 上下文的节点在顶点着色器里执行,连到 Fragment 上下文的在片元着色器里执行,同一个节点被两边都用到时会被生成两份。有些节点只能在片元阶段用:Sample Texture 2D(隐式求 mip 需要屏幕空间导数)、DDX/DDY、Is Front Face 等,连到 Vertex 上下文时连线会变红。顶点里读贴图要用 Sample Texture 2D LOD。

反过来,“在顶点阶段算好、插值后在片元使用”的需求(比如逐顶点的风场权重、逐顶点光照),在 Shader Graph 10 及以前只能借用 UV 通道;Shader Graph 12 起提供了 Custom Interpolator:在 Vertex 上下文上右键 Add Block Node › Custom Interpolator,给它起名、选类型并连上顶点阶段的计算结果,然后在工作区就能创建同名的 Custom Interpolator 节点在片元阶段读取。它对应的就是手写 Shader 里 Varyings 结构体中自己加的一个 TEXCOORDn。插值器的数量有硬件上限(Shader Graph 的 Project Settings 里可以设置警告阈值),一个 float4 占一个插值器,尽量把多个标量打包进同一个向量。

脚本与材质

Shader Graph 材质与手写 Shader 材质在脚本里没有任何区别:用属性的 Reference 名调用 SetFloat/SetColor/SetTexture,用关键字的 Reference 调用 EnableKeyword。几个容易出错的地方:Reference 默认是自动生成的(如 _Float、Vector1_xxxxx),一定要在建属性时就改成有意义的名字;Color 属性的 Mode 选 HDR 时,材质面板会显示强度滑条,脚本里赋值要自己乘强度(线性空间下,Unity 上传颜色时是否做 Gamma→Linear 换算只看属性有没有 HDR 标记:普通 Color 属性会换算,HDR 的 Color 属性原样上传,所以要写线性 HDR 值就把属性的 Mode 设成 HDR;用 SetColor 还是 SetVector 结果一样);用 MaterialPropertyBlock 修改 Shader Graph 材质同样会让 Renderer 退出 SRP Batcher,大量实例时考虑 GPU Instancing 或 DOTS Instancing(Hybrid Per Instance 声明)。

24.9 用 Shader Graph 重做卡通着色

案例 24.4Unlit Graph + Custom Function 的卡通 Shader进阶单独打开 ↗

目标:用 Unlit Graph 做一个三色阶的卡通 Shader:主光决定明暗分级,阴影并入暗部,受光侧有边缘光,附加光提供彩色补光。思路与第 14 章手写的卡通 Shader 相同,这里看它在 Graph 里怎么搭。

色阶化和边缘光是纯数学,放在另一个 hlsl 文件里(预览里也能正常工作,不需要预览分支):

Assets/Cookbook/Ch24/StylizedFunctions.hlsl(节选:toon)
// 把 N·L 与阴影合成的“受光量”量化成 Steps 级色阶。
// Smooth 控制每一级台阶边缘的过渡宽度(0 = 硬边)。
void ToonRamp_float(float NdotL, float Shadow, float Steps, float Smooth, out float Ramp)
{
    // 先把 [-1,1] 的 N·L 半兰伯特化到 [0,1]。
    // 阴影不直接乘上去,而是把受光量“压”到明暗交界线(0.5)以下:背光面本来就暗,
    // 阴影贴图在交界线附近的锯齿就不会被色阶量化放大成一圈难看的齿边
    float lit = min(saturate(NdotL * 0.5 + 0.5), lerp(0.45, 1.0, Shadow));
    float x = lit * Steps;
    float level = floor(x);
    // 每一级台阶的上沿用 smoothstep 过渡到下一级;Smooth 取个下限,避免 smoothstep 两端相等时除零
    float blend = smoothstep(1.0 - max(Smooth, 1e-3), 1.0, frac(x));
    // 除以 (Steps-1):Steps = 3 时输出 0 / 0.5 / 1 三个色阶
    Ramp = saturate((level + blend) / max(Steps - 1.0, 1.0));
}

void ToonRamp_half(half NdotL, half Shadow, half Steps, half Smooth, out half Ramp)
{
    float r;
    ToonRamp_float(NdotL, Shadow, Steps, Smooth, r);
    Ramp = r;
}
// 把 N·L 与阴影合成的“受光量”量化成 Steps 级色阶。
// Smooth 控制每一级台阶边缘的过渡宽度(0 = 硬边)。
void ToonRamp_float(float NdotL, float Shadow, float Steps, float Smooth, out float Ramp)
{
    // 先把 [-1,1] 的 N·L 半兰伯特化到 [0,1]。
    // 阴影不直接乘上去,而是把受光量“压”到明暗交界线(0.5)以下:背光面本来就暗,
    // 阴影贴图在交界线附近的锯齿就不会被色阶量化放大成一圈难看的齿边
    float lit = min(saturate(NdotL * 0.5 + 0.5), mix(0.45, 1.0, Shadow));
    float x = lit * Steps;
    float level = floor(x);
    // 每一级台阶的上沿用 smoothstep 过渡到下一级;Smooth 取个下限,避免 smoothstep 两端相等时除零
    float blend = smoothstep(1.0 - max(Smooth, 1e-3), 1.0, fract(x));
    // 除以 (Steps-1):Steps = 3 时输出 0 / 0.5 / 1 三个色阶
    Ramp = saturate((level + blend) / max(Steps - 1.0, 1.0));
}

void ToonRamp_half(float NdotL, float Shadow, float Steps, float Smooth, out float Ramp)
{
    float r;
    ToonRamp_float(NdotL, Shadow, Steps, Smooth, r);
    Ramp = r;
}

色阶函数里有一个值得记住的处理:阴影不是直接乘到受光量上,而是用 min(halfLambert, lerp(0.45, 1, shadow)) 把受光量“压”到明暗交界线以下。如果直接相乘,球体背光面附近阴影贴图的锯齿边缘会被色阶量化放大,在交界线旁出现一圈锯齿状的暗带;压到 0.45 以后,背光面本来就低于这个值,阴影在那里不起作用,锯齿自然消失。卡通渲染里“自阴影的锯齿”是常见问题,这是最便宜的一种解决办法。

Assets/Cookbook/Ch24/StylizedFunctions.hlsl(节选:rim)
// 边缘光:视线与法线越垂直越亮;只在受光一侧出现时更像“轮廓被照亮”
void RimLight_float(float3 Normal, float3 ViewDir, float3 LightDir, float Power, float LightSideOnly, out float Rim)
{
    float fresnel = pow(1.0 - saturate(dot(normalize(Normal), normalize(ViewDir))), Power);
    float side = lerp(1.0, saturate(dot(Normal, LightDir) * 2.0 + 0.5), LightSideOnly);
    Rim = fresnel * side;
}

void RimLight_half(half3 Normal, half3 ViewDir, half3 LightDir, half Power, half LightSideOnly, out half Rim)
{
    float r;
    RimLight_float(Normal, ViewDir, LightDir, Power, LightSideOnly, r);
    Rim = r;
}
// 边缘光:视线与法线越垂直越亮;只在受光一侧出现时更像“轮廓被照亮”
void RimLight_float(vec3 Normal, vec3 ViewDir, vec3 LightDir, float Power, float LightSideOnly, out float Rim)
{
    float fresnel = pow(1.0 - saturate(dot(normalize(Normal), normalize(ViewDir))), Power);
    float side = mix(1.0, saturate(dot(Normal, LightDir) * 2.0 + 0.5), LightSideOnly);
    Rim = fresnel * side;
}

void RimLight_half(vec3 Normal, vec3 ViewDir, vec3 LightDir, float Power, float LightSideOnly, out float Rim)
{
    float r;
    RimLight_float(Normal, ViewDir, LightDir, Power, LightSideOnly, r);
    Rim = r;
}
Position (World)Normal (World)View Dir (World)Screen Position ƒ MainLightDir/Color/ShadowDot Productƒ RimLightPower, LightSideƒ AdditionalLightsLambert ƒ ToonRampSteps, Smooth Lerp暗部色→固有色× Light ColorAdd(叠加各项) Base Color
图 24-6卡通 Unlit Graph 的节点连线示意。ƒ 表示 Custom Function;Lerp 的 A 口是“固有色 × 暗部色”、B 口是固有色、T 口接 ToonRamp 的输出;环境光可再用 Ambient 节点(或 Baked GI 节点)加进 Add

整张图的数据流,与下面这段等价手写代码一一对应(注释里的方括号标出了对应的节点):

Assets/Cookbook/Ch24/SGToonEquivalent.shader(节选:graph)
half4 frag(Varyings i) : SV_Target
{
    // [Position 节点 World] [Normal Vector 节点 World] [View Direction 节点 World] [Screen Position 节点 Default]
    float3 positionWS = i.positionWS;
    float3 normalWS = normalize(i.normalWS);
    float3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
    float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);

    // [Custom Function: MainLight]
    float3 lightDir, lightColor; float distAtten, shadowAtten;
    if (_UseHalf > 0.5)
    {
        half3 d, c; half da, sa;
        MainLight_half(positionWS, d, c, da, sa);
        lightDir = d; lightColor = c; distAtten = da; shadowAtten = sa;
    }
    else MainLight_float(positionWS, lightDir, lightColor, distAtten, shadowAtten);

    // [Dot Product] → [Custom Function: ToonRamp]
    float NdotL = dot(normalWS, lightDir);
    float ramp;
    if (_UseHalf > 0.5) { half r; ToonRamp_half(NdotL, shadowAtten, _Steps, _Smooth, r); ramp = r; }
    else ToonRamp_float(NdotL, shadowAtten, _Steps, _Smooth, ramp);

    // [Lerp] 阴影色 ↔ 固有色,再乘主光颜色
    float3 albedo = _BaseColor.rgb;
    float3 toon = lerp(albedo * _ShadowTint.rgb, albedo, ramp) * lightColor;

    // [Custom Function: RimLight] 只在受光侧出现
    float rim;
    RimLight_float(normalWS, viewWS, lightDir, _RimPower, 1, rim);
    rim *= step(0.5, ramp);   // 阴影里不出边缘光

    // [Custom Function: AdditionalLightsLambert] 点光、聚光
    float3 extra = 0;
    if (_AddLights > 0.5)
    {
        AdditionalLightsLambert_float(positionWS, normalWS, screenUV, extra);
        half3 extraHalf; AdditionalLightsLambert_half(positionWS, normalWS, screenUV, extraHalf);
        extra = _UseHalf > 0.5 ? (float3)extraHalf : extra;
    }

    // [Ambient 节点(Equator/Sky)] 这里用等价的 SampleSH
    #if defined(SHADERGRAPH_PREVIEW)
        float3 ambient = 0.2;
    #else
        float3 ambient = SampleSH(normalWS);
    #endif

    float3 color = toon + albedo * (ambient * 0.6 + extra) + rim * _RimColor.rgb;
    return half4(color, 1);   // → Master Stack 的 Base Color
}
vec4 frag()
{
    // [Position 节点 World] [Normal Vector 节点 World] [View Direction 节点 World] [Screen Position 节点 Default]
    vec3 posWS = positionWS;
    vec3 nWS = normalize(normalWS);
    vec3 viewWS = GetWorldSpaceNormalizeViewDir(posWS);
    vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);

    // [Custom Function: MainLight]
    vec3 lightDir, lightColor; float distAtten, shadowAtten;
    if (_UseHalf > 0.5) MainLight_half(posWS, lightDir, lightColor, distAtten, shadowAtten);
    else MainLight_float(posWS, lightDir, lightColor, distAtten, shadowAtten);

    // [Dot Product] → [Custom Function: ToonRamp]
    float NdotL = dot(nWS, lightDir);
    float ramp;
    if (_UseHalf > 0.5) ToonRamp_half(NdotL, shadowAtten, _Steps, _Smooth, ramp);
    else ToonRamp_float(NdotL, shadowAtten, _Steps, _Smooth, ramp);

    // [Lerp] 阴影色 ↔ 固有色,再乘主光颜色
    vec3 albedo = _BaseColor.rgb;
    vec3 toon = mix(albedo * _ShadowTint.rgb, albedo, ramp) * lightColor;

    // [Custom Function: RimLight] 只在受光侧出现
    float rim;
    RimLight_float(nWS, viewWS, lightDir, _RimPower, 1.0, rim);
    rim *= step(0.5, ramp);   // 阴影里不出边缘光

    // [Custom Function: AdditionalLightsLambert] 点光、聚光
    vec3 extra = vec3(0.0);
    if (_AddLights > 0.5)
    {
        AdditionalLightsLambert_float(posWS, nWS, screenUV, extra);
        vec3 extraHalf; AdditionalLightsLambert_half(posWS, nWS, screenUV, extraHalf);
        extra = _UseHalf > 0.5 ? extraHalf : extra;
    }

    // [Ambient 节点(Equator/Sky)] 这里用等价的 SampleSH
#if defined(SHADERGRAPH_PREVIEW)
    vec3 ambient = vec3(0.2);
#else
    vec3 ambient = SampleSH(nWS);
#endif

    vec3 color = toon + albedo * (ambient * 0.6 + extra) + rim * _RimColor.rgb;
    return vec4(color, 1);   // → Master Stack 的 Base Color
}
图 24-7卡通 Shader(由等价手写 Shader 渲染)。左:两级色阶,无边缘光;中:三级色阶 + 受光侧边缘光(这一格调用的是 _half 版本的函数,结果与 _float 一致);右:再加入一粉一蓝两盏点光,由 AdditionalLightsLambert 提供彩色补光

Unlit 图要投射阴影,需要在 Graph Settings 勾上 Cast Shadows(生成 ShadowCaster Pass);要被 SSAO、景深等效果正确识别,还需要 DepthOnly/DepthNormals Pass——Unlit Target 会自动生成它们,这是 Shader Graph 相对手写的一大便利(等价 Shader 里这些 Pass 是手写的,复用了第 23 章的 Ch23Common.hlsl)。

24.10 用 Shader Graph 重做溶解

案例 24.5Lit Graph 的溶解效果入门单独打开 ↗

目标:保留 URP Lit 的完整 PBR 光照,只在表面上做噪声溶解和边缘发光。这类“表面效果 + 标准光照”正是 Lit Graph 最擅长的:不用管任何光照代码,阴影、GI、雾、Forward+ 全部自动正确。

  1. 新建 Lit Shader Graph,Graph Settings 勾选 Alpha Clipping,Master Stack 出现 Alpha 和 Alpha Clip Threshold。
  2. Blackboard 添加 Noise(Texture2D)、Progress(Float,Slider 0~1)、Edge Width(Float)、Edge Color(Color,HDR 模式)。
  3. Sample Texture 2D 采样 Noise 取 R,与 Progress、Edge Width 一起接入 Custom Function Dissolve(也可以用 Subtract、Step、Smoothstep 节点拼出来)。
  4. Dissolve 的 AlphaOut 接 Alpha,Alpha Clip Threshold 填 0.5;Edge × Edge Color 接 Emission;Base Color 乘以 (1 − Edge),让发光边缘不再叠加漫反射。
Assets/Cookbook/Ch24/StylizedFunctions.hlsl(节选:dissolve)
// 溶解:Noise 小于 Progress 的部分被裁掉;在裁剪边界外侧 EdgeWidth 范围内输出发光遮罩。
// AlphaOut 直接接 Master Stack 的 Alpha,Alpha Clip Threshold 填 0.5。
void Dissolve_float(float Noise, float Progress, float EdgeWidth, out float AlphaOut, out float Edge)
{
    // 让 Progress = 0 时完全不溶解、= 1 时完全消失:把阈值区间扩展到 [-EdgeWidth, 1]
    float threshold = Progress * (1.0 + EdgeWidth) - EdgeWidth;
    float d = Noise - threshold;           // > 0 保留,< 0 裁掉
    AlphaOut = step(0.0, d);
    Edge = 1.0 - smoothstep(0.0, EdgeWidth, d);   // 越靠近裁剪边界越接近 1
    Edge *= AlphaOut;
}

void Dissolve_half(half Noise, half Progress, half EdgeWidth, out half AlphaOut, out half Edge)
{
    float a, e;
    Dissolve_float(Noise, Progress, EdgeWidth, a, e);
    AlphaOut = a; Edge = e;
}
// 溶解:Noise 小于 Progress 的部分被裁掉;在裁剪边界外侧 EdgeWidth 范围内输出发光遮罩。
// AlphaOut 直接接 Master Stack 的 Alpha,Alpha Clip Threshold 填 0.5。
void Dissolve_float(float Noise, float Progress, float EdgeWidth, out float AlphaOut, out float Edge)
{
    // 让 Progress = 0 时完全不溶解、= 1 时完全消失:把阈值区间扩展到 [-EdgeWidth, 1]
    float threshold = Progress * (1.0 + EdgeWidth) - EdgeWidth;
    float d = Noise - threshold;           // > 0 保留,< 0 裁掉
    AlphaOut = step(0.0, d);
    Edge = 1.0 - smoothstep(0.0, EdgeWidth, d);   // 越靠近裁剪边界越接近 1
    Edge *= AlphaOut;
}

void Dissolve_half(float Noise, float Progress, float EdgeWidth, out float AlphaOut, out float Edge)
{
    float a, e;
    Dissolve_float(Noise, Progress, EdgeWidth, a, e);
    AlphaOut = a; Edge = e;
}

Progress 映射到 [−EdgeWidth, 1] 的阈值区间,保证 Progress = 0 时一点都不溶解(连发光边都没有)、Progress = 1 时完全消失。Lit Graph 的 Forward Pass 最终就是把 Master Stack 的各个 Block 填进 SurfaceData 再交给 UniversalFragmentPBR,等价代码如下:

Assets/Cookbook/Ch24/SGDissolveEquivalent.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float alpha, edge;
    DissolveGraph(i.uv, alpha, edge);
    clip(alpha - 0.5);                         // Alpha Clip Threshold = 0.5

    SurfaceData surface = (SurfaceData)0;      // ← Master Stack 的各个 Block
    surface.albedo     = _BaseColor.rgb * (1 - edge);
    surface.metallic   = _Metallic;
    surface.smoothness = _Smoothness;
    surface.occlusion  = 1;
    surface.emission   = edge * _EdgeColor.rgb;
    surface.alpha      = 1;
    surface.normalTS   = half3(0, 0, 1);

    InputData input = (InputData)0;
    input.positionWS = i.positionWS;
    input.normalWS = normalize(i.normalWS);
    input.viewDirectionWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
    input.shadowCoord = TransformWorldToShadowCoord(i.positionWS);
    input.bakedGI = SampleSH(input.normalWS);
    input.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(i.positionCS);
    input.shadowMask = half4(1, 1, 1, 1);
    return UniversalFragmentPBR(input, surface);
}
vec4 frag()
{
    float alpha, edge;
    DissolveGraph(uv, alpha, edge);
    if (alpha - 0.5 < 0.0) discard;              // Alpha Clip Threshold = 0.5

    SurfaceData surface = InitSurfaceData();     // ← Master Stack 的各个 Block
    surface.albedo     = _BaseColor.rgb * (1.0 - edge);
    surface.metallic   = _Metallic;
    surface.smoothness = _Smoothness;
    surface.occlusion  = 1.0;
    surface.emission   = edge * _EdgeColor.rgb;
    surface.alpha      = 1.0;
    surface.normalTS   = vec3(0, 0, 1);

    InputData inputData = InitInputData();
    inputData.positionWS = positionWS;
    inputData.normalWS = normalize(normalWS);
    inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(positionWS);
    inputData.shadowCoord = TransformWorldToShadowCoord(positionWS);
    inputData.bakedGI = SampleSH(inputData.normalWS);
    inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
    inputData.shadowMask = vec4(1, 1, 1, 1);
    return UniversalFragmentPBR(inputData, surface);
}

手写时最容易忘的是:溶解掉的部分不应该再投影。Shader Graph 会自动把 Alpha Clip 带进 ShadowCaster 和 DepthOnly Pass;手写就必须在每个 Pass 里重复同样的裁剪:

Assets/Cookbook/Ch24/SGDissolveEquivalent.shader(节选:caster)
Pass
{
    // Shader Graph 会自动把 Alpha Clip 带进 ShadowCaster / DepthOnly;手写时最容易忘
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0
    HLSLPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
    #include "Assets/Cookbook/Ch23/Ch23Common.hlsl"
    struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; };
    struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; };
    Varyings vert(Attributes v)
    {
        Varyings o;
        CasterAttributes c; c.positionOS = v.positionOS; c.normalOS = v.normalOS;
        o.positionCS = Ch23ShadowCasterVert(c);
        o.uv = v.uv;
        return o;
    }
    half4 frag(Varyings i) : SV_Target
    {
        float alpha, edge;
        DissolveGraph(i.uv, alpha, edge);
        clip(alpha - 0.5);
        return 0;
    }
    ENDHLSL
}
Pass
{
    // Shader Graph 会自动把 Alpha Clip 带进 ShadowCaster / DepthOnly;手写时最容易忘
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0
    GLSLPROGRAM
    #include "Ch23/Ch23Common.glsl"
    VARYING vec2 uv;
    #ifdef VERTEX
    void main()
    {
        gl_Position = Ch23ShadowCasterVert();
        uv = texcoord0.xy;
    }
    #endif
    #ifdef FRAGMENT
    void main()
    {
        float alpha, edge;
        DissolveGraph(uv, alpha, edge);
        if (alpha - 0.5 < 0.0) discard;
        fragColor = vec4(0.0);
    }
    #endif
    ENDGLSL
}
图 24-8溶解进度 0.15 / 0.45 / 0.75(由等价手写 Shader 渲染,开启 Bloom 与 Neutral 色调映射)。注意地面上的影子随溶解同步出现孔洞——ShadowCaster 里做了同样的裁剪

动画方面,拍图时 _Time 不可控,所以这里的 Progress 是材质参数;实际使用时可以在图里用 Time 节点驱动,或者由脚本 material.SetFloat("_Progress", …) 控制,后者更容易与游戏逻辑同步。

24.11 调试、精度与性能

看生成代码。遇到“图里看着对、场景里不对”的问题,第一步永远是 View Generated Shader,找到你的 Custom Function 调用和 Keyword 的 #if,确认分支和关键字是否如你所想。也可以用 Frame Debugger 查看某次绘制实际使用的关键字组合。

Preview 节点。在任意两节点之间插一个 Preview 节点,可以看到这条连线上的中间值;把中间结果直接连到 Base Color(Unlit)是 Shader Graph 版的“输出调试色”。

精度。Graph Settings 的 Precision 可以选 Single 或 Half,单个节点也可以覆盖(Inherit / Single / Half / Graph,Graph 表示跟随图的设置)。在支持 half 的移动 GPU 上,颜色、法线、光照计算用 half 能省寄存器、提高吞吐;但世界坐标、UV 大范围偏移、深度这类数值用 half 会出现明显的精度问题(远离原点时抖动、纹理滑动)。Custom Function 的 half 版本因此不一定是“把 float 换成 half”那么简单,像本章 MainLight_half 这样内部仍用 float 计算、只在接口处转换,是稳妥的做法。

性能。Shader Graph 生成的代码在逻辑上和手写等价,编译器会把没用到的计算优化掉,所以“节点多”本身不等于慢;真正要注意的是采样次数、数学复杂度(pow、三角函数、除法)和变体数。Lit Graph 的光照部分与 URP Lit 完全相同,如果你的图只是“贴图 + 少量数学”,它的开销和 Lit 材质几乎一样。和手写相比,Shader Graph 的主要劣势在于 Pass 层面:不能自由加 Pass、不能写模板测试(模板只能靠 RenderObjects Feature 覆盖)、Unlit 自定义光照要手动补关键字。遇到这些情况,就用 Custom Function 把核心逻辑写进 hlsl,同一份 hlsl 再给手写 Shader 用——本章的等价 Shader 就是这种组织方式的示范。

✓ 推荐
Custom Function 用 File 模式,hlsl 文件加 include 保护,同时提供 _float 与 _half;引用 URP 光照函数的部分用 SHADERGRAPH_PREVIEW 包一个替身;为这个 hlsl 写一个普通 Shader 做编译验证和性能对比。
✗ 避免
在 String 模式里写几十行代码(无法复用、难以调试);在 Unlit 图里用光照函数却不加阴影关键字;为了材质上的小开关滥用 Multi Compile + Global 的 Keyword。

24.12 把手写 Shader 迁移到 Shader Graph

团队里常见的需求是:程序先手写验证了一个效果,再交给美术用 Shader Graph 维护。迁移的思路可以按下面的顺序来:

  1. 先判断 Target。效果的光照部分如果和 URP Lit 一致(只是改了表面属性),用 Lit Graph,把手写代码里填 SurfaceData 的部分翻译成节点即可;如果光照是自定义的(卡通、阴影着色、NPR),用 Unlit Graph + Custom Function。
  2. 把“需要 URP 库”的代码抽成 hlsl。凡是调用 GetMainLight、GetAdditionalLight、SampleSceneDepth 这类函数、或者写起来超过十行的计算,都放进 File 模式的 Custom Function,并加上 SHADERGRAPH_PREVIEW 替身。手写 Shader 也 include 同一个文件,两边共享一份实现。
  3. 属性逐个搬到 Blackboard,Reference 保持与手写版相同(如 _BaseColor、_BaseMap),这样旧材质切换 Shader 后数值能保留,脚本也不用改。贴图属性如果需要材质上的 Tiling/Offset,要在属性设置里勾上 Use Tiling and Offset(不勾时生成的属性带 [NoScaleOffset],材质上没有这两个字段),并确认采样用的 UV 经过了这组参数的变换(可以用 Split Texture Transform 节点取出 Tiling/Offset,再接 Tiling And Offset 节点)。
  4. multi_compile 搬成 Keyword。手写版里 URP 管线相关的关键字,Lit Graph 已经自带,不要重复声明;Unlit Graph 需要按 24.7 节的表补上;效果自己的开关按 24.5 节的经验选 Shader Feature + Local。
  5. 多 Pass 与渲染状态。描边、平面阴影这类需要第二个 Pass 的效果,Shader Graph 做不到“在一个 Shader 里加 Pass”,通常拆成两个材质(第二个材质挂在同一个 Renderer 的第二个材质槽上),或者用 RenderObjects Renderer Feature 用覆盖材质再画一遍;模板测试也走 RenderObjects 的 Stencil 覆盖设置。
  6. 对比验证。把手写版和 Graph 版的材质放在同一场景、同样参数下截图对比,再用 Frame Debugger 比较两者的关键字和 Pass。像本章这样保留一个等价的手写 Shader 做“对照组”,能帮你在升级 URP 后快速发现差异。

常见问题速查

现象常见原因
材质整体是粉色Graph 没有 Universal Target(只有 Built-In),或生成的 Shader 编译失败;看 Inspector 上的错误和 Console
节点预览是粉色,场景里正常Custom Function 用了 URP 光照库函数,没有 SHADERGRAPH_PREVIEW 替身
报 “undeclared identifier MainLight_half”节点以 half 精度编译,hlsl 里只写了 _float 版本
Unlit 自定义光照没有阴影没有在 Blackboard 添加 _MAIN_LIGHT_SHADOWS 等关键字
开了 Screen Space Shadows 后阴影消失缺 _MAIN_LIGHT_SHADOWS_SCREEN 关键字(见第 23 章)
脚本 SetFloat 没反应用的是属性显示名而不是 Reference;或属性没有 Exposed 且声明为 Global
Frame Debugger 显示 SRP Batcher 不兼容Renderer 上用了 MaterialPropertyBlock,或属性被声明到了 UnityPerMaterial 之外
溶解后阴影仍是完整的没有勾 Alpha Clipping,而是用 Alpha + 透明来“隐藏”像素;阴影 Pass 不处理透明度
构建后某个效果不见了Shader Feature 关键字在构建时没有任何材质使用,对应变体被剔除;运行时 EnableKeyword 也找不到它

本章小结

  • Shader Graph 把节点翻译成 Vertex/Surface Description 两个函数,由 Target 套进各个 Pass 模板;View Generated Shader 能看到全部代码。
  • Universal Target 的 Material 有 Lit、Unlit、Sprite 系列、Decal、Fullscreen;Lit 的光照与 URP Lit 是同一套函数。
  • Blackboard 属性默认声明在 UnityPerMaterial 中以兼容 SRP Batcher;Shader Declaration 设为 Global 可以读取全局变量。
  • Keyword 的 Definition 对应 shader_feature / multi_compile / 不生成,Scope 对应 _local,Stages 对应 _vertex/_fragment;Enum 条目生成“引用名_条目名”。
  • Custom Function 的函数名会被追加 _float/_half 后缀;节点预览由 PreviewTarget 生成,不含 URP 光照库,需用 SHADERGRAPH_PREVIEW 提供替身。
  • Unlit 图做自定义光照要在 Blackboard 手动添加主光阴影、软阴影、附加光、Forward+ 等关键字。

练习

  1. 把本章的 RimLight 函数做成一个 Sub Graph,输入 Power、Color、LightSideOnly,内部用 Normal Vector、View Direction 节点和 MainLight Custom Function 获取所需向量。
  2. 为 CustomLighting.hlsl 增加一个 MainLightSpecular_float,输出 Blinn-Phong 高光(输入 WorldPos、WorldNormal、ViewDir、Smoothness),并在等价 Shader 里调用它验证编译。
  3. 在卡通 Graph 中用 Baked GI 节点替换示意图里的 Ambient,比较两者在有光照探针的场景里的差别。
  4. 用 Enum Keyword(Reference _MAIN_LIGHT,条目 SHADOWS_OFF/SHADOWS/SHADOWS_CASCADE/SHADOWS_SCREEN)替换三个 Boolean 阴影关键字,用 View Generated Shader 比较两种写法生成的 pragma 和变体数。
  5. 把溶解改成“按世界空间高度从下往上溶解”:在 Dissolve 前把噪声与 Position(World).y 混合,观察 ShadowCaster 中阴影的变化。