GameTech/URP 渲染实战
CHAPTER 03第 3 章

第一个 URP Shader

从一个只会输出纯色的十几行 Shader 出发,每一步只加一样东西:纹理、顶点色、透明度裁剪、雾、SRP Batcher、阴影与深度、关键字开关。九步走完,你手里就有一个能放进正式项目的 Unlit Shader,也认识了 URP Shader 的每一块骨头。

本章你会学到
ShaderLab 的层次结构与每个 Tag 的作用
HLSL 的类型、向量运算、swizzle 与语义
Properties 的类型与常用 Attribute
纹理声明、采样与 Tiling/Offset 的来龙去脉
接入 URP 的雾、兼容 SRP Batcher
手写 ShadowCaster 与 DepthOnly Pass
multi_compile 与 shader_feature 做功能开关
Built-in 写法迁移对照与常见编译错误

上一章我们从“管线”的高度看了 URP 一帧里发生的事情,这一章落到最小的单位:一个 Shader 文件。读者里一定有人写过 Built-in 管线的 Shader,也一定有人一行都没写过。为了照顾后者,本章不跳步——每个案例都是一个独立的、能编译能用的 Shader 文件,下一个案例只在上一个的基础上加一样东西。为了照顾前者,每一步都会说明“Built-in 里是怎么写的、URP 为什么改成这样”,章末还有一张完整的迁移对照表。

配套工程里本章的文件都在 Assets/Cookbook/Ch03/,文件名按步骤编号(C03_01_SolidColor.shader 到 C03_09_ScrollMigrated.shader),Shader 名统一是 Cookbook/Ch03/…。建议你在一个空场景里放一个球、一个立方体,每读完一个案例就把材质的 Shader 切过去看看,再动手改几个数字。

3.1 一个 URP Shader 长什么样

Unity 的 Shader 文件(.shader)其实是两种语言的拼盘:外层是 Unity 自己的描述语言 ShaderLab,负责告诉引擎“这个 Shader 有哪些参数、在什么管线下用哪段代码、用什么渲染状态画”;内层夹在 HLSLPROGRAM … ENDHLSL 之间的才是真正跑在 GPU 上的程序,用的是微软的着色语言 HLSL(High-Level Shading Language)。Unity 会把 HLSL 交叉编译成各平台需要的形式——Metal、Vulkan 的 SPIR-V、OpenGL ES 的 GLSL 等,所以你只写一份。

Shader "Cookbook/Ch03/SolidColor"名字决定它在材质下拉框里的路径 Properties { … }材质面板上的参数:颜色、贴图、滑条……(可选) SubShader { … }可以有多个,引擎挑第一个“能用”的 Tags { "RenderPipeline"="UniversalPipeline" "RenderType"="Opaque" "Queue"="Geometry" } Pass(颜色) Tags { "LightMode"="UniversalForward" } 渲染状态:Cull / ZWrite / Blend … HLSLPROGRAM#pragma / #include / 结构体 / 函数ENDHLSL Pass(阴影)ShadowCaster只写深度到 Shadow Map Pass(深度)DepthOnly深度预渲染时写深度图 FallBack / CustomEditor都不能用时退回哪个 Shader;自定义材质面板(可选)
图 3-1ShaderLab 的层次:Shader ⊃ Properties + SubShader ⊃ Tags + 若干 Pass ⊃ 渲染状态 + HLSL 程序

按从外到内的顺序过一遍:

  • Shader "路径/名字":名字里的斜杠会变成材质 Shader 下拉框的菜单层级。C# 里 Shader.Find("Cookbook/Ch03/SolidColor") 用的也是这个名字,而不是文件名。
  • Properties:声明哪些参数暴露在材质面板上。它只是“面板 + 默认值 + 序列化”,真正在 HLSL 里使用还得再声明一次同名变量,两者靠名字对上。3.3 节细讲。
  • SubShader:一个 Shader 可以写多个 SubShader,比如一个给高端机、一个给低端机。Unity 从上往下找第一个“当前硬件和当前渲染管线都能跑”的来用。URP 下最关键的是 SubShader 上的 "RenderPipeline"="UniversalPipeline" 标签:带了它,这个 SubShader 只在 URP 激活时被选中;同一个文件里还可以再写一个不带这个标签(或标成别的管线)的 SubShader 给 Built-in 管线用。
  • RenderType / Queue:Queue 决定绘制顺序,Geometry=2000(不透明)、AlphaTest=2450(镂空)、Transparent=3000(半透明),也可以写 Geometry+10 这样的偏移。RenderType 只是一个分类字符串,给替换着色(Replacement Shader)和一些工具识别用,URP 本身并不靠它决定画法。
  • Pass:一次完整的“绘制”。一个物体用这个 Shader 时,URP 会在一帧的不同阶段分别挑出不同 Pass 来画:画颜色时找 UniversalForward,画阴影图时找 ShadowCaster,做深度预渲染时找 DepthOnly……挑哪个 Pass 完全靠 LightMode 标签。Pass 里还可以写渲染状态(Cull、ZWrite、ZTest、Blend、ColorMask 等),它们不是 HLSL 代码,而是配置 GPU 固定功能单元的开关。
  • FallBack:所有 SubShader 都不能用时退回的 Shader。注意 URP 下 FallBack 不会帮你“借”阴影 Pass 之类的东西——这是 Built-in 时代的常见做法,在 URP 里要自己写,案例 3.7 专门讲。

本章会用到的 LightMode 标签如下(完整列表和每个 Pass 在一帧里的位置见第 2 章与附录 C):

LightMode谁会画它本章用途
UniversalForwardForward / Forward+ 路径的不透明与透明物体绘制;Deferred 路径下只用于透明物体案例 3.1–3.7 的颜色 Pass
UniversalForwardOnly三种路径都画;Deferred 下在 G-Buffer 之后单独画案例 3.8、3.9 的颜色 Pass(原因见 3.4 节)
SRPDefaultUnlit没写 LightMode 的 Pass 会被当成它;Forward 与 Deferred 都会画—
ShadowCaster主光源 / 附加光源阴影图的渲染案例 3.7 起
DepthOnly深度预渲染(Depth Prepass)生成 _CameraDepthTexture案例 3.7 起
别同时写两种URP 的源码注释里特别提醒:同一个 Shader 里既有不带 LightMode 的 Pass(即 SRPDefaultUnlit)又有 UniversalForward/UniversalForwardOnly Pass,物体会被画两遍。初学时最常见的就是复制粘贴后忘了给第二个 Pass 写 Tags。

3.2 HLSL 基础:类型、向量与语义

HLSL 的语法和 C 很像:有结构体、函数、if/for,以分号结尾。真正需要适应的是三件事:向量是一等公民、精度要自己选、数据靠“语义”在阶段之间流动。

标量、向量与矩阵

类型含义说明
float32 位浮点位置、UV、深度等对精度敏感的量一律用它
half16 位浮点(移动端)颜色、法线、方向等范围小的量。URP 的 Common.hlsl 在移动平台上把 half 定义为 min16float;桌面平台上它通常就按 32 位算
int / uint32 位整数循环计数、位运算(第 4 章的哈希函数会大量使用)
bool布尔少用;分支条件通常直接写比较表达式
float2/3/4、half4…2~4 维向量位置 float3、齐次坐标 float4、颜色 half4
float4x4、float3x3…矩阵mul(M, v) 是“矩阵左乘列向量”,Unity 的变换矩阵都按这个约定

Built-in 时代常见的 fixed(11 位定点)在 URP 的 HLSL 里不存在,直接写会报 unrecognized identifier 'fixed4',统一换成 half。

分量访问与 swizzle

向量的分量可以用 .xyzw 或 .rgba 访问,而且能任意组合、重复、换序,这叫 swizzle:

float4 p = float4(1, 2, 3, 4);
float3 a = p.xyz;        // (1,2,3)
float2 b = p.wx;         // (4,1)  换序
float4 c = p.xxxx;       // (1,1,1,1)  重复,等价于 p.x 的“广播”
half4  col = half4(0.2, 0.5, 0.9, 1);
half3  rgb = col.rgb;    // rgba 与 xyzw 是同一组分量的两套名字,但不能混用:col.xg 是错的
p.xy = float2(0, 0);     // 也可以写入部分分量
float3 d = a * 2 + 1;    // 标量与向量运算:逐分量

向量之间的 * 是逐分量相乘,不是点乘也不是叉乘;点乘写 dot(a, b),叉乘写 cross(a, b),矩阵乘法写 mul。把 float3 赋给 float4 会报 cannot implicitly convert,反过来把 float4 赋给 float3 只是一个“隐式截断”警告——编译器会悄悄丢掉 w 分量,这类警告最好也清理掉。

语义:数据从哪来、到哪去

GPU 管线是分阶段的:顶点着色器(Vertex Shader)读取网格数据,输出裁剪空间位置和一堆“要带给片元的值”;光栅化器把三角形拆成片元(Fragment),并把这些值在三角形内部插值;片元着色器(Fragment Shader)拿到插值结果,输出颜色。每个阶段的输入输出变量后面都要挂一个语义(Semantic),告诉 GPU 这个变量对应哪一路数据。

网格 Mesh verticesuvcolorsnormals Attributes : POSITION: TEXCOORD0: COLOR: NORMAL vert逐顶点 Varyings : SV_POSITION: TEXCOORD0: COLOR光栅化 + 插值 frag SV_Target SV_POSITION 必须是裁剪空间齐次坐标;TEXCOORDn / COLOR 只是插值器“槽位”的名字,装什么由你决定
图 3-2一个最简单的 Pass 里,数据从网格经过顶点着色器、插值器流到片元着色器
语义用在含义
POSITION顶点输入模型空间顶点位置(Mesh.vertices)
NORMAL / TANGENT顶点输入模型空间法线 / 切线(切线 w 存镜像符号,见第 7 章)
TEXCOORD0~7顶点输入 / 插值器输入时是第 n 套 UV;用在 Varyings 里就是通用插值槽
COLOR顶点输入 / 插值器顶点色(Mesh.colors)
SV_POSITION顶点输出 / 片元输入输出:裁剪空间坐标;片元里读到的是屏幕像素坐标(xy 为像素中心,如 0.5、1.5)
SV_Target片元输出写入的颜色;多渲染目标时用 SV_Target0/1/…

URP 的命名习惯是:顶点输入结构叫 Attributes,顶点到片元的结构叫 Varyings;变量名后缀表示坐标空间——OS 模型空间(Object Space)、WS 世界空间、VS 观察空间、CS 裁剪空间(第 1 章讲过整条变换链)。本书全部沿用这套命名,这样你读 URP 源码时毫无障碍。

3.3 Properties:类型与 Attribute

Properties 块里每一行的格式是 [Attribute] _名字 ("面板显示名", 类型) = 默认值。常用类型:

类型默认值写法HLSL 里怎么声明
Float / Int= 1float _X;(Int 属性在底层也是浮点,需要整数时自己转换)
Range(min, max)= 0.5float 或 half,面板上显示为滑条
Color= (1, 1, 1, 1)half4。线性空间工程里 Unity 会先把面板上的 sRGB 颜色转成线性值再传给 Shader
Vector= (0, 0, 0, 0)float4,原样传入,不做颜色转换
2D / 3D / Cube / 2DArray= "white" {}TEXTURE2D(_X); SAMPLER(sampler_X); 等,见案例 3.2

2D 贴图的默认值字符串可以是 "white"、"black"、"gray"、"bump"(法线贴图的“平”值)等,含义就是没指定贴图时用一张 1×1 的该颜色贴图顶替。选对默认值很重要:颜色贴图默认 white,乘上去不改变结果;遮罩、自发光一类默认 black 或 white 要按“不起作用”的方向选。

方括号里的 Attribute 是给材质面板的提示,常用的有:

Attribute作用
[MainTexture] / [MainColor]指定 material.mainTexture / material.color 对应哪个属性。URP 把主贴图命名为 _BaseMap,不加这个标记时,这两个 C# API 会去找老名字 _MainTex / _Color
[NoScaleOffset]隐藏贴图的 Tiling/Offset 输入框(这张图不打算用 _ST)
[HDR]颜色允许超过 1,面板显示强度(Intensity),用于自发光、发光边缘
[Toggle(关键字)]显示为勾选框,勾上时在材质上启用该关键字;不写括号里的关键字时默认是 _属性名大写_ON
[ToggleUI]只显示勾选框、只改数值,不碰关键字(适合运行时 if 判断的开关)
[KeywordEnum(A, B, C)]下拉框,选中项对应关键字 _属性名_A 等(全大写)
[Enum(名字, 值, …)] / [Enum(C#枚举类型)]下拉框,只改数值。常与渲染状态配合,如 [Enum(UnityEngine.Rendering.CullMode)] _Cull
[IntRange] / [PowerSlider(3)]整数滑条 / 非线性滑条
[Header(文字)] / [Space] / [HideInInspector]面板分组标题 / 空行 / 隐藏
[Normal] / [Gamma]提示这张贴图应是法线贴图 / 这个 Float 是 sRGB 值

Properties 里的值除了填进 HLSL 变量,还能直接用在渲染状态里:Cull [_Cull]、ZWrite [_ZWrite]、Blend [_SrcBlend] [_DstBlend]。URP 自带的 Lit/Unlit 就是这样让“表面类型”“双面”在材质面板上可调的。

没写在 Properties 里的变量HLSL 里声明的 uniform 变量不一定非得出现在 Properties 里。没出现的变量不会显示在面板上、不会随材质保存,但仍可用 material.SetFloat 或 Shader.SetGlobalFloat 赋值。反过来,Properties 里有、HLSL 里没用到的属性也没关系,只是面板上多一项。但要注意:凡是出现在 Properties 里、又在 HLSL 里用到的属性,都必须放进 UnityPerMaterial,否则 SRP Batcher 会拒绝这个 Shader——案例 3.6 会看到工具给出的原话。

案例 3.1最小可运行:纯色 Unlit入门单独打开 ↗

目标:写出能在 URP 下编译、把物体涂成一个纯色的 Shader,并看懂每一行。

Assets/Cookbook/Ch03/C03_01_SolidColor.shader
// 第 1 步:最小可运行的 URP Shader —— 把物体涂成一个纯色。
// 故意保持最少的代码:一个 Pass、一个颜色属性、顶点变换 + 输出颜色。
Shader "Cookbook/Ch03/SolidColor"
{
    Properties
    {
        // 名字 ("Inspector 显示名", 类型) = 默认值
        _BaseColor ("Base Color", Color) = (1, 0.5, 0.2, 1)
    }

    SubShader
    {
        // RenderPipeline 标签:只有 URP 激活时才使用这个 SubShader
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        Pass
        {
            Name "Unlit"
            // UniversalForward:URP 前向渲染的主 Pass(不写 LightMode 时按 SRPDefaultUnlit 处理)
            Tags { "LightMode"="UniversalForward" }

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            // URP 的“地基”:矩阵、坐标变换函数、平台宏都在这里
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            // 顶点着色器的输入:由网格提供,语义 POSITION 告诉 GPU 取哪一路顶点数据
            struct Attributes
            {
                float4 positionOS : POSITION;   // OS = Object Space,模型空间
            };

            // 顶点 → 片元的数据:SV_POSITION 是光栅化必须的齐次裁剪空间坐标
            struct Varyings
            {
                float4 positionCS : SV_POSITION; // CS = Clip Space,裁剪空间
            };

            // 第 1 步先不放 CBUFFER,第 6 步再讲为什么必须放进去
            half4 _BaseColor;

            Varyings vert (Attributes input)
            {
                Varyings output;
                // 模型空间 → 世界空间 → 观察空间 → 裁剪空间,一次做完
                output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
                return output;
            }

            half4 frag (Varyings input) : SV_Target
            {
                return _BaseColor;
            }
            ENDHLSL
        }
    }
}
// 第 1 步:最小可运行的 URP Shader —— 把物体涂成一个纯色。
// 故意保持最少的代码:一个 Pass、一个颜色属性、顶点变换 + 输出颜色。
Shader "Cookbook/Ch03/SolidColor"
{
    SubShader
    {
        // RenderPipeline 标签:只有 URP 激活时才使用这个 SubShader
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        Pass
        {
            Name "Unlit"
            // UniversalForward:URP 前向渲染的主 Pass(不写 LightMode 时按 SRPDefaultUnlit 处理)
            Tags { "LightMode"="UniversalForward" }

            GLSLPROGRAM
            // Core.glsl(URP 的“地基”:矩阵、坐标变换函数)网页引擎会自动包含

            // 顶点着色器的输入:由网格提供,GLSL 里是固定名字的 in 变量 positionOS(OS = Object Space,模型空间)
            // 顶点 → 片元的数据:光栅化必须的齐次裁剪空间坐标写进内置变量 gl_Position(CS = Clip Space,裁剪空间)

            // 第 1 步先不放 CBUFFER,第 6 步再讲为什么必须放进去
            uniform vec4 _BaseColor;

            #ifdef VERTEX
            void main()
            {
                // 模型空间 → 世界空间 → 观察空间 → 裁剪空间,一次做完
                gl_Position = TransformObjectToHClip(positionOS.xyz);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                fragColor = _BaseColor;
            }
            #endif
            ENDGLSL
        }
    }
}

从上往下逐行看:

  • Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }:这是一个只给 URP 用的、不透明的、在 2000 队列绘制的 SubShader。
  • Name "Unlit":Pass 的名字,Frame Debugger 里能看到;UsePass 引用别的 Shader 的 Pass 时也靠它。不影响渲染。
  • #pragma vertex vert / #pragma fragment frag:告诉编译器顶点、片元着色器的入口函数叫什么。名字随意,URP 自带 Shader 用 UnlitPassVertex 这样的长名字。
  • #include ".../ShaderLibrary/Core.hlsl":URP Shader 的“地基”。它会依次引入 core 包的 Common.hlsl(数学常量、平台宏、half/real 精度定义)、URP 的 Input.hlsl(相机、时间、矩阵等全局变量)、SpaceTransforms.hlsl(坐标变换函数)等。注意路径以 Packages/ 开头,这是 Unity 对包内文件的虚拟路径,不是硬盘上的真实路径。
Assets/Cookbook/Ch03/C03_01_SolidColor.shader(节选:io)
// 顶点着色器的输入:由网格提供,语义 POSITION 告诉 GPU 取哪一路顶点数据
struct Attributes
{
    float4 positionOS : POSITION;   // OS = Object Space,模型空间
};

// 顶点 → 片元的数据:SV_POSITION 是光栅化必须的齐次裁剪空间坐标
struct Varyings
{
    float4 positionCS : SV_POSITION; // CS = Clip Space,裁剪空间
};
// 顶点着色器的输入:由网格提供,GLSL 里是固定名字的 in 变量 positionOS(OS = Object Space,模型空间)
// 顶点 → 片元的数据:光栅化必须的齐次裁剪空间坐标写进内置变量 gl_Position(CS = Clip Space,裁剪空间)

两个结构体对应图 3-2 的左右两半。positionOS 用 float4 接收,网格提供的是三维坐标,GPU 会自动把 w 补成 1。

Assets/Cookbook/Ch03/C03_01_SolidColor.shader(节选:vertfrag)
Varyings vert (Attributes input)
{
    Varyings output;
    // 模型空间 → 世界空间 → 观察空间 → 裁剪空间,一次做完
    output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
    return output;
}

half4 frag (Varyings input) : SV_Target
{
    return _BaseColor;
}
#ifdef VERTEX
void main()
{
    // 模型空间 → 世界空间 → 观察空间 → 裁剪空间,一次做完
    gl_Position = TransformObjectToHClip(positionOS.xyz);
}
#endif

#ifdef FRAGMENT
void main()
{
    fragColor = _BaseColor;
}
#endif

顶点着色器只做一件事:把模型空间坐标变换到裁剪空间。TransformObjectToHClip 来自 core 包的 SpaceTransforms.hlsl:

// 摘自 core 14.0.12 ShaderLibrary/SpaceTransforms.hlsl
float4 TransformObjectToHClip(float3 positionOS)
{
    // More efficient than computing M*VP matrix product
    return mul(GetWorldToHClipMatrix(), mul(GetObjectToWorldMatrix(), float4(positionOS, 1.0)));
}

它先乘模型矩阵(Object→World),再乘观察投影矩阵(World→Clip)。源码注释说明了为什么不先把 M 和 VP 乘成一个矩阵:那样每个顶点要多做一次矩阵乘矩阵,而现在只是两次矩阵乘向量。片元着色器直接返回颜色,返回值上的 SV_Target 表示“写到当前渲染目标”。

图 3-3案例 3.1 的效果:左边三个物体用 SolidColor,右边是同色的 URP Lit 球作对照。纯色 Unlit 没有任何明暗,立体感完全消失——光照要到第 5 章才登场

还有一处故意的“不规范”:half4 _BaseColor; 直接声明在全局,没有放进 CBUFFER_START(UnityPerMaterial)。它能正常工作,但不兼容 SRP Batcher。我们先把功能做出来,到案例 3.6 再集中解决这个问题,那时你会更清楚 CBUFFER 到底解决了什么。

动手把 return _BaseColor; 改成 return half4(input.positionCS.xy / _ScreenParams.xy, 0, 1);,保存后物体会显示一个屏幕空间的红绿渐变——这验证了“片元着色器里的 SV_POSITION 是像素坐标”。_ScreenParams 是 URP 提供的全局变量,xy 是渲染目标的宽高。
粉色 / 不显示如果材质是洋红色,说明 Shader 编译失败或没有可用的 SubShader(看 Console 里的报错,或选中 Shader 资源在 Inspector 里看错误列表);如果物体完全不显示,先检查 Pass 的 LightMode 是否拼错——拼错的 Pass 不会被 URP 的任何阶段选中,也不会报错。

案例 3.2纹理采样与 Tiling/Offset入门单独打开 ↗

目标:给物体贴一张图,并让材质面板上的 Tiling、Offset 生效。

Assets/Cookbook/Ch03/C03_02_Texture.shader
// 第 2 步:采样一张贴图,支持材质面板上的 Tiling / Offset。
Shader "Cookbook/Ch03/Textured"
{
    Properties
    {
        [MainTexture] _BaseMap ("Base Map", 2D) = "white" {}
        [MainColor]   _BaseColor ("Base Color", Color) = (1, 1, 1, 1)
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        Pass
        {
            Name "Unlit"
            Tags { "LightMode"="UniversalForward" }

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct Attributes
            {
                float4 positionOS : POSITION;
                float2 uv         : TEXCOORD0;  // 第 0 套 UV
            };

            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float2 uv         : TEXCOORD0;  // 插值器:TEXCOORDn 只是“通用槽位”的名字
            };

            // 纹理对象和采样器分开声明(DX11 风格),宏会替各平台展开成正确的写法
            TEXTURE2D(_BaseMap);
            SAMPLER(sampler_BaseMap);    // 命名为 sampler + 纹理名,Unity 会自动使用该纹理的过滤/寻址设置

            float4 _BaseMap_ST;          // Unity 自动填充:xy = Tiling,zw = Offset
            half4  _BaseColor;

            Varyings vert (Attributes input)
            {
                Varyings output;
                output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
                // 等价于 input.uv * _BaseMap_ST.xy + _BaseMap_ST.zw
                output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
                return output;
            }

            half4 frag (Varyings input) : SV_Target
            {
                half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
                return tex * _BaseColor;
            }
            ENDHLSL
        }
    }
}
// 第 2 步:采样一张贴图,支持材质面板上的 Tiling / Offset。
Shader "Cookbook/Ch03/Textured"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        Pass
        {
            Name "Unlit"
            Tags { "LightMode"="UniversalForward" }

            GLSLPROGRAM
            // 顶点输入 texcoord0 是第 0 套 UV
            VARYING vec2 uv;   // 插值器:HLSL 里 TEXCOORDn 只是“通用槽位”的名字,GLSL 直接按变量名对应

            // GLSL 里纹理和采样器是一个对象 sampler2D,过滤/寻址设置跟着纹理走
            uniform sampler2D _BaseMap;

            uniform vec4 _BaseMap_ST;     // Unity 自动填充:xy = Tiling,zw = Offset
            uniform vec4 _BaseColor;

            #ifdef VERTEX
            void main()
            {
                gl_Position = TransformObjectToHClip(positionOS.xyz);
                // 等价于 uv * _BaseMap_ST.xy + _BaseMap_ST.zw(HLSL 的 TRANSFORM_TEX)
                uv = TransformTex(texcoord0.xy, _BaseMap_ST);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                vec4 tex = texture(_BaseMap, uv);
                fragColor = tex * _BaseColor;
            }
            #endif
            ENDGLSL
        }
    }
}

和上一步相比多了三样东西:顶点输入里的 UV、Varyings 里传给片元的 UV、贴图的声明与采样。

Assets/Cookbook/Ch03/C03_02_Texture.shader(节选:decl)
// 纹理对象和采样器分开声明(DX11 风格),宏会替各平台展开成正确的写法
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);    // 命名为 sampler + 纹理名,Unity 会自动使用该纹理的过滤/寻址设置

float4 _BaseMap_ST;          // Unity 自动填充:xy = Tiling,zw = Offset
half4  _BaseColor;
// GLSL 里纹理和采样器是一个对象 sampler2D,过滤/寻址设置跟着纹理走
uniform sampler2D _BaseMap;

uniform vec4 _BaseMap_ST;     // Unity 自动填充:xy = Tiling,zw = Offset
uniform vec4 _BaseColor;

Built-in 时代写 sampler2D _MainTex; 和 tex2D(_MainTex, uv),纹理和采样器捆在一起。URP 采用 DX11 以来的“纹理对象与采样器分离”模型:TEXTURE2D(_BaseMap) 声明纹理,SAMPLER(sampler_BaseMap) 声明采样器,SAMPLE_TEXTURE2D(纹理, 采样器, uv) 采样。它们都是 core 包 API/ 目录下按平台定义的宏,例如 Metal 上:

// 摘自 core 14.0.12 ShaderLibrary/API/Metal.hlsl
#define TEXTURE2D(textureName)                Texture2D textureName
#define SAMPLER(samplerName)                  SamplerState samplerName
#define SAMPLE_TEXTURE2D(textureName, samplerName, coord2)  PLATFORM_SAMPLE_TEXTURE2D(textureName, samplerName, coord2)

用宏而不是直接写 Texture2D,是为了让同一份代码在 OpenGL ES 这类不支持分离采样器的平台上也能展开成正确的写法。另外 URP 的 Core.hlsl 会在支持的平台上重定义 SAMPLE_TEXTURE2D,自动加上全局 Mip 偏移 _GlobalMipBias,这是为了配合渲染缩放(Render Scale)和超分辨率时选到合适的 Mip 层级——这也是“别绕过宏直接调用 .Sample()”的一个理由。

采样器的名字有讲究:写成 sampler + 纹理名,Unity 会自动把这张贴图导入设置里的过滤模式(Point/Bilinear/Trilinear)和寻址模式(Repeat/Clamp)绑定上去。你也可以声明名字里带关键词的“内联采样器”,如 SAMPLER(sampler_linear_clamp),它会按名字固定使用线性过滤 + Clamp,不受贴图设置影响,适合多张贴图共用一个采样器(某些平台对采样器数量有限制)。

_ST 与 TRANSFORM_TEX

材质面板上每张贴图下面的 Tiling 和 Offset,会被 Unity 自动打包进一个名为 贴图名 + _ST 的 float4:xy 是 Tiling(Scale),zw 是 Offset(Translate),“ST”就是这两个词的首字母。只要你声明了这个变量,它就会被赋值。TRANSFORM_TEX 是 core 包 Macros.hlsl 里的一行宏:

// 摘自 core 14.0.12 ShaderLibrary/Macros.hlsl
#define TRANSFORM_TEX(tex, name) ((tex.xy) * name##_ST.xy + name##_ST.zw)
FTiling (1,1)Offset (0,0)01 FFFFTiling (2,2)uv × 2 → 重复两次 FFOffset (0.5,0)uv + 0.5 → 图案左移半格 寻址模式为 Repeat 时,超出 [0,1] 的 UV 会回绕;Clamp 时会拉伸边缘像素
图 3-4Tiling 缩放 UV、Offset 平移 UV。注意平移的是“采样坐标”,所以 Offset 为正时图案看起来是往反方向移动

为什么在顶点着色器里做而不是片元里?因为它是 UV 的线性变换,在三角形内插值前做和插值后做结果完全一样,放在顶点里每个顶点算一次,比每个像素算一次便宜。这是一条通用原则:对插值结果线性的计算,尽量前移到顶点阶段。

图 3-5uv_grid 贴图。左:Tiling (1,1);中:Tiling (2,2),每个面重复 2×2 次;右:Offset (0.5, 0.25)。球体的 UV 是经纬展开,所以 U 方向环绕一圈、V 方向从南极到北极
[MainTexture] 的用处加上 [MainTexture] 后,C# 的 material.mainTexture、material.mainTextureScale 就会操作 _BaseMap;SpriteRenderer、UI 等组件读取“主贴图”时也认这个标记。

案例 3.3顶点色入门单独打开 ↗

目标:读取网格自带的顶点色,与贴图和颜色相乘。顶点色是最便宜的“逐顶点数据通道”:美术可以在 DCC 软件里刷上 AO、渐变、草的摆动权重、溶解遮罩,第 17 章的植被风动就会用它。

Assets/Cookbook/Ch03/C03_03_VertexColor.shader(节选:io)
struct Attributes
{
    float4 positionOS : POSITION;
    float2 uv         : TEXCOORD0;
    half4  color      : COLOR;      // 顶点色:Mesh.colors,没有顶点色的网格读到 (1,1,1,1)
};

struct Varyings
{
    float4 positionCS : SV_POSITION;
    float2 uv         : TEXCOORD0;
    half4  color      : COLOR;      // 光栅化时在三角形内部线性插值
};
// 顶点输入 color:顶点色 Mesh.colors,没有顶点色的网格读到 (1,1,1,1)
VARYING vec2 uv;
VARYING vec4 vColor;      // 光栅化时在三角形内部线性插值

输入端用 COLOR 语义接收 Mesh.colors。如果网格根本没有顶点色,读到的是 (1,1,1,1),乘上去不改变结果,所以这个 Shader 对没有顶点色的模型也是安全的。

Assets/Cookbook/Ch03/C03_03_VertexColor.shader(节选:vertfrag)
Varyings vert (Attributes input)
{
    Varyings output;
    output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
    output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
    output.color = input.color;     // 原样传下去,插值交给硬件
    return output;
}

half4 frag (Varyings input) : SV_Target
{
    half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
    // lerp(1, 顶点色, 强度):强度为 0 时乘以白色,等于不起作用
    half4 vc = lerp(half4(1, 1, 1, 1), input.color, _VertexColorStrength);
    return tex * _BaseColor * vc;
}
#ifdef VERTEX
void main()
{
    gl_Position = TransformObjectToHClip(positionOS.xyz);
    uv = TransformTex(texcoord0.xy, _BaseMap_ST);
    vColor = color;     // 原样传下去,插值交给硬件
}
#endif

#ifdef FRAGMENT
void main()
{
    vec4 tex = texture(_BaseMap, uv);
    // lerp(1, 顶点色, 强度):强度为 0 时乘以白色,等于不起作用
    vec4 vc = mix(vec4(1, 1, 1, 1), vColor, _VertexColorStrength);
    fragColor = tex * _BaseColor * vc;
}
#endif

顶点色从顶点着色器原样输出,光栅化器会把三个顶点的颜色在三角形内部做透视校正的线性插值(第 1 章讲过为什么要透视校正)。所以顶点色的精细程度取决于网格的密度:一个只有 8 个顶点的立方体,每个面只能表现一个双线性渐变。

lerp(a, b, t) 是线性插值 a + (b - a) * t,这里用 _VertexColorStrength 在“白色(不起作用)”与“顶点色”之间过渡。第 4 章会把 lerp、smoothstep 这一族函数画成曲线细讲。

图 3-6球和环面纽结的顶点色由脚本按位置刷上(色相绕 Y 轴变化、亮度随高度变化)。左:强度 0,只剩白色;中:强度 1;右:顶点色 × 棋盘格贴图
顶点色与色彩空间线性空间工程里,Unity 会把材质面板上的 Color 属性从 sRGB 转成线性值,但不会对网格的顶点色做这种转换(以实际版本为准)。如果美术在 DCC 里按“看起来的颜色”刷顶点色,在 Shader 里当作颜色用时会偏亮,可以用 core 包 Color.hlsl 里的 SRGBToLinear 转一下;当作权重、遮罩这类“数据”用时则不需要转换。

案例 3.4透明度裁剪 Alpha Clip入门单独打开 ↗

目标:让贴图 alpha 低于阈值的像素“消失”,做出镂空的树叶、栅栏、铁丝网。

Assets/Cookbook/Ch03/C03_04_AlphaClip.shader
// 第 4 步:透明度裁剪(Alpha Clip / Alpha Test)。alpha 低于阈值的像素直接丢弃。
Shader "Cookbook/Ch03/AlphaClip"
{
    Properties
    {
        [MainTexture] _BaseMap ("Base Map", 2D) = "white" {}
        [MainColor]   _BaseColor ("Base Color", Color) = (1, 1, 1, 1)
        _Cutoff ("Alpha Cutoff", Range(0, 1)) = 0.5
        // 剪掉的地方能看到背面,所以通常关掉背面剔除:0=Off 1=Front 2=Back
        [Enum(UnityEngine.Rendering.CullMode)] _Cull ("Cull", Float) = 0
    }

    SubShader
    {
        // AlphaTest 队列(2450)排在普通不透明物体之后,RenderType 让替换着色/工具识别它
        Tags { "RenderType"="TransparentCutout" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" }

        Pass
        {
            Name "Unlit"
            Tags { "LightMode"="UniversalForward" }
            Cull [_Cull]                         // 渲染状态可以引用属性,用方括号

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct Attributes
            {
                float4 positionOS : POSITION;
                float2 uv         : TEXCOORD0;
                half4  color      : COLOR;
            };

            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float2 uv         : TEXCOORD0;
                half4  color      : COLOR;
            };

            TEXTURE2D(_BaseMap);
            SAMPLER(sampler_BaseMap);
            float4 _BaseMap_ST;
            half4  _BaseColor;
            half   _Cutoff;

            Varyings vert (Attributes input)
            {
                Varyings output;
                output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
                output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
                output.color = input.color;
                return output;
            }

            half4 frag (Varyings input) : SV_Target
            {
                half4 col = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor * input.color;
                // clip(x):x < 0 时丢弃当前片元,不写颜色也不写深度
                clip(col.a - _Cutoff);
                return half4(col.rgb, 1);        // 不透明物体输出 alpha=1
            }
            ENDHLSL
        }
    }
}
// 第 4 步:透明度裁剪(Alpha Clip / Alpha Test)。alpha 低于阈值的像素直接丢弃。
Shader "Cookbook/Ch03/AlphaClip"
{
    SubShader
    {
        // AlphaTest 队列(2450)排在普通不透明物体之后,RenderType 让替换着色/工具识别它
        Tags { "RenderType"="TransparentCutout" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" }

        Pass
        {
            Name "Unlit"
            Tags { "LightMode"="UniversalForward" }
            Cull [_Cull]                         // 渲染状态可以引用属性,用方括号

            GLSLPROGRAM
            VARYING vec2 uv;
            VARYING vec4 vColor;

            uniform sampler2D _BaseMap;
            uniform vec4  _BaseMap_ST;
            uniform vec4  _BaseColor;
            uniform float _Cutoff;

            #ifdef VERTEX
            void main()
            {
                gl_Position = TransformObjectToHClip(positionOS.xyz);
                uv = TransformTex(texcoord0.xy, _BaseMap_ST);
                vColor = color;
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                vec4 col = texture(_BaseMap, uv) * _BaseColor * vColor;
                // clip(x):x < 0 时丢弃当前片元,不写颜色也不写深度。GLSL 里写成 discard
                if (col.a - _Cutoff < 0.0) discard;
                fragColor = vec4(col.rgb, 1);        // 不透明物体输出 alpha=1
            }
            #endif
            ENDGLSL
        }
    }
}

核心只有一行 clip(col.a - _Cutoff)。clip(x) 是 HLSL 内置函数:参数的任一分量小于 0 时,丢弃(discard)当前片元——不写颜色,也不写深度,就像这个像素从没被画过。其余几处配套改动同样重要:

  • Queue 改成 AlphaTest(2450):让镂空物体在普通不透明物体之后画。原因与 Early-Z 有关:GPU 通常在片元着色器之前就做深度测试,把被挡住的像素提前剔掉;但含 clip/discard 的着色器无法在执行前确定深度是否要写,部分硬件会因此关闭提前深度写入。先画完“确定写深度”的普通物体,再画镂空物体,能让后者也从前者写好的深度里受益。
  • RenderType 改成 TransparentCutout:给依赖 RenderType 的工具一个正确分类。
  • Cull [_Cull]:镂空后能透过孔看到物体背面,所以默认关闭背面剔除(Cull Off)。渲染状态可以用方括号引用属性值,配合 [Enum(UnityEngine.Rendering.CullMode)] 就能在面板上选 Off/Front/Back。
  • 返回的 alpha 写成 1:不透明物体的 alpha 一般没人读,但如果开启了某些依赖 alpha 通道的后处理或截图,写 1 更安全。URP 用 OutputAlpha 函数处理这件事。
图 3-7贴图的 alpha 是每格中心为 1、向外递减到 0 的圆形渐变。左:Cutoff 0.15,留下的圆很大;中:0.5;右:0.8,只剩中心的小点。球体的背面透过孔可见,因为关闭了背面剔除

URP 自带 Shader 并不直接写 clip,而是用 ShaderVariablesFunctions.hlsl 里的 AlphaDiscard,并且只有在 _ALPHATEST_ON 关键字开启时才裁剪:

// 摘自 URP 14.0.12 ShaderLibrary/ShaderVariablesFunctions.hlsl
real AlphaDiscard(real alpha, real cutoff, real offset = real(0.0))
{
#if defined(_ALPHATEST_ON)
    if (IsAlphaDiscardEnabled())
        alpha = AlphaClip(alpha, cutoff + offset);
#endif
    return alpha;
}

用关键字包起来的原因是:只要着色器里出现了 discard,哪怕运行时从不触发,也可能影响 Early-Z 和移动端 TBDR 架构上的隐藏面剔除效率。所以不需要镂空的材质,最好连这条指令都不要编译进去。我们会在案例 3.8 用关键字实现同样的开关;它对性能的具体影响在第 29 章讨论,Alpha to Coverage 等让镂空边缘更柔和的技巧在第 16 章。

案例 3.5接入雾效入门单独打开 ↗

目标:让 Shader 响应 Window › Rendering › Lighting › Environment › Fog 的设置,远处的物体逐渐融入雾色。

URP 支持 Built-in 时代的三种雾模式。设物体到相机的深度为 z(沿视线方向的距离),雾因子 f 表示“保留多少原色”(1 = 完全不受雾影响,0 = 完全是雾色):

Linear:f=end−zend−startExponential:f=e−density·zExponential Squared:f=e−(density·z)2最终颜色:color=lerp(fogColor,color,f)
到相机的深度 zf=10 start end Linear Exponential Exp Squared
图 3-8三种雾因子随距离的变化(示意)。线性雾在 start 之前完全清晰、end 之后完全是雾;指数雾从相机处就开始衰减;平方指数雾近处更清晰、到一定距离后迅速变浓

落实到代码只需要三处改动:

Assets/Cookbook/Ch03/C03_05_Fog.shader(节选:pragma)
// 生成 无雾 / FOG_LINEAR / FOG_EXP / FOG_EXP2 四个变体,引擎按 Lighting 设置选用
#pragma multi_compile_fog
// 生成 无雾 / FOG_LINEAR / FOG_EXP / FOG_EXP2 四个变体,引擎按 Lighting 设置选用
// (网页版:雾模式由 uniform unity_FogMode 在运行时选择,不需要变体,这一行留着只为和 HLSL 对照)
#pragma multi_compile_fog

multi_compile_fog 是 Unity 提供的快捷写法,等价于 #pragma multi_compile _ FOG_LINEAR FOG_EXP FOG_EXP2,编译出“无雾 + 三种雾”四个变体,运行时引擎根据 Lighting 窗口的设置自动启用对应的关键字。“关键字”与“变体”的完整解释在案例 3.8,这里先知道它在起作用即可。

Assets/Cookbook/Ch03/C03_05_Fog.shader(节选:varyings)
struct Varyings
{
    float4 positionCS : SV_POSITION;
    float2 uv         : TEXCOORD0;
    half4  color      : COLOR;
    float  fogFactor  : TEXCOORD1;  // 每个顶点算一次雾因子,交给插值器
};
VARYING vec2 uv;
VARYING vec4 vColor;
VARYING float fogFactor;  // 每个顶点算一次雾因子,交给插值器
Assets/Cookbook/Ch03/C03_05_Fog.shader(节选:vertfrag)
Varyings vert (Attributes input)
{
    Varyings output;
    output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
    output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
    output.color = input.color;
    // 用裁剪空间 z 换算出“离相机多远”,再按雾模式算出因子
    output.fogFactor = ComputeFogFactor(output.positionCS.z);
    return output;
}

half4 frag (Varyings input) : SV_Target
{
    half4 col = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor * input.color;
    clip(col.a - _Cutoff);
    // 按因子把颜色往 unity_FogColor 方向混;没开雾时这一行什么都不做
    col.rgb = MixFog(col.rgb, input.fogFactor);
    return half4(col.rgb, 1);
}
#ifdef VERTEX
void main()
{
    gl_Position = TransformObjectToHClip(positionOS.xyz);
    uv = TransformTex(texcoord0.xy, _BaseMap_ST);
    vColor = color;
    // 用裁剪空间 z 换算出“离相机多远”,再按雾模式算出因子
    fogFactor = ComputeFogFactor(gl_Position.z);
}
#endif

#ifdef FRAGMENT
void main()
{
    vec4 col = texture(_BaseMap, uv) * _BaseColor * vColor;
    if (col.a - _Cutoff < 0.0) discard;
    // 按因子把颜色往 unity_FogColor 方向混;没开雾时这一行什么都不做
    col.rgb = MixFog(col.rgb, fogFactor);
    fragColor = vec4(col.rgb, 1);
}
#endif

ComputeFogFactor 和 MixFog 都在 URP 的 ShaderVariablesFunctions.hlsl:

// 摘自 URP 14.0.12 ShaderLibrary/ShaderVariablesFunctions.hlsl(节选)
real ComputeFogFactorZ0ToFar(float z)
{
    #if defined(FOG_LINEAR)
    // factor = (end-z)/(end-start) = z * (-1/(end-start)) + (end/(end-start))
    float fogFactor = saturate(z * unity_FogParams.z + unity_FogParams.w);
    return real(fogFactor);
    #elif defined(FOG_EXP) || defined(FOG_EXP2)
    return real(unity_FogParams.x * z);
    #else
        return real(0.0);
    #endif
}

real ComputeFogFactor(float zPositionCS)
{
    float clipZ_0Far = UNITY_Z_0_FAR_FROM_CLIPSPACE(zPositionCS);
    return ComputeFogFactorZ0ToFar(clipZ_0Far);
}

half3 MixFog(half3 fragColor, half fogFactor)
{
    return MixFogColor(fragColor, unity_FogColor.rgb, fogFactor);
}

读这段源码能学到几件事。第一,UNITY_Z_0_FAR_FROM_CLIPSPACE 把各平台不同的裁剪空间 z(D3D/Metal 是反向 Z,OpenGL 是 [-w, w])统一换算成“0 到远平面”的距离,这类平台差异宏在第 1 章介绍过。第二,Unity 把雾的起止距离、密度预先折算进 unity_FogParams,线性雾被改写成一次乘加,省掉了除法。第三,指数雾在顶点里只算到 density·z 这一步,真正的 exp 放在片元的 MixFog 里(内部用 exp2 实现):指数函数不是线性的,先插值“指数”再求指数,比直接插值指数结果更准确。

图 3-9线性雾,start=3、end=26。左:案例 3.4 的 Shader(不支持雾),远处的柱子颜色和近处一样鲜艳,在雾中格外突兀;右:案例 3.5 的 Shader,柱子和地面(URP Lit)一起融入雾中
URP 14 自己怎么做细心的读者会发现,URP 14.0.12 的 ShaderVariablesFunctions.hlsl 里有一行 #define _FOG_FRAGMENT 1,URP 自带的 Unlit/Lit 因此会把观察空间深度传到片元、在片元里逐像素计算雾因子(InitializeInputDataFog)。逐像素更精确,尤其是对很大的三角形(比如一整块地面只有几个顶点时);逐顶点更省。本书的 Unlit 采用逐顶点因子,它对普通密度的网格足够了;如果你在大面片上看到雾的“条带”,就换成逐像素的写法。
天空盒不受雾影响URP 不会给天空盒加雾。雾色和天空差别大时,远处物体会融成一片与天空不同的颜色,所以图 3-9 用了与雾色相同的纯色背景。实际项目里通常让雾色接近地平线天空色,或使用带高度雾的天空方案。

案例 3.6兼容 SRP Batcher入门单独打开 ↗

目标:把材质属性放进 UnityPerMaterial 常量缓冲区,让这个 Shader 能被 SRP Batcher 加速;并写一个小工具批量检查兼容性。

先简单回顾 SRP Batcher 做了什么(原理见第 2 章)。传统流程里,每画一个物体,CPU 都要把这个物体的变换矩阵和材质参数重新打包、上传给 GPU,Draw Call 一多,CPU 就耗在这些“设置状态”的工作上。SRP Batcher 的思路是:每个材质的参数常驻在 GPU 显存里的一块固定区域,只有材质被修改时才更新;每个物体的矩阵等数据也放在一块大缓冲区里。画连续多个使用同一个 Shader 变体的物体时,CPU 只需要告诉 GPU“这次用第几块材质数据、第几块物体数据”,即使它们的材质各不相同。

GPU 显存(常驻) UnityPerMaterial(每个材质一块) 材质 A_BaseMap_ST_BaseColor… 材质 B同样的布局 材质 C… UnityPerDraw(每个物体一块) 物体 1矩阵/SH… 物体 2 物体 3 一个 SRP Batch(同一 Shader 变体) Draw 物体1 → 绑定 材质A 块 + 物体1 块Draw 物体2 → 绑定 材质B 块 + 物体2 块 Draw 物体3 → 绑定 材质A 块 + 物体3 块只切换偏移,不重新上传数据
图 3-10SRP Batcher 的数据组织:材质数据和物体数据都常驻显存,绘制时只切换“用哪一块”

要做到“每个材质一块固定布局的数据”,Shader 必须满足两个条件:

  1. 所有引擎内置的逐物体变量(unity_ObjectToWorld 等)在名为 UnityPerDraw 的常量缓冲区里。这一条 URP 的 Core.hlsl(经由 UnityInput.hlsl)已经替你做好了。
  2. 所有材质属性放在名为 UnityPerMaterial 的常量缓冲区里,而且同一个 SubShader 的所有 Pass、所有变体里,这个缓冲区的内容和顺序必须完全一致。这一条要你自己保证。
Assets/Cookbook/Ch03/C03_06_SRPBatcher.shader(节选:hlslinclude)
// HLSLINCLUDE 里的代码会被自动拼到本 SubShader 每个 Pass 的 HLSLPROGRAM 前面。
// 把 CBUFFER 写在这里,所有 Pass 的 UnityPerMaterial 布局天然一致。
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

CBUFFER_START(UnityPerMaterial)
    float4 _BaseMap_ST;
    half4  _BaseColor;
    half   _Cutoff;
CBUFFER_END

// 纹理和采样器不是“数值”,不能也不需要放进 CBUFFER
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);
ENDHLSL
// GLSLINCLUDE(对应 HLSLINCLUDE)里的代码会被自动拼到本 SubShader 每个 Pass 的 GLSLPROGRAM 前面。
// 把 CBUFFER 写在这里,所有 Pass 的 UnityPerMaterial 布局天然一致。
GLSLINCLUDE
// CBUFFER_START(UnityPerMaterial)
uniform vec4  _BaseMap_ST;
uniform vec4  _BaseColor;
uniform float _Cutoff;
// CBUFFER_END

// 纹理和采样器不是“数值”,不能也不需要放进 CBUFFER
uniform sampler2D _BaseMap;
ENDGLSL

这里用了一个 ShaderLab 特性:写在 SubShader 里、Pass 外面的 HLSLINCLUDE … ENDHLSL,会被自动拼接到这个 SubShader 每一个 Pass 的 HLSLPROGRAM 之前。把 #include、CBUFFER 和贴图声明放在这里,后面加 ShadowCaster、DepthOnly Pass 时,“所有 Pass 布局一致”就自动成立了。这也是 URP 自带 Shader 把属性声明集中写在 LitInput.hlsl、UnlitInput.hlsl 里,然后每个 Pass 都 include 同一个文件的原因。

几个细节:

  • 贴图与采样器不进 CBUFFER。它们不是数值,绑定方式不同。但贴图的 _ST、_TexelSize 是 float4 数值,要放进去。
  • CBUFFER 里不要写 #if。哪怕某个属性只在某个关键字开启时才用到,也要无条件声明,否则不同变体的缓冲区大小不同,SRP Batcher 就失效了。
  • 全局变量不进 CBUFFER。用 Shader.SetGlobalXXX 设置的变量(如某个全局风场强度)应该声明在 CBUFFER 外面,并且不出现在 Properties 里。

批量检查兼容性的小工具

选中 Shader 资源时,Inspector 里会显示一行 “SRP Batcher: compatible / not compatible” 及原因。项目里 Shader 多了,一个个点很累,我们写一个菜单命令批量检查。Unity 没有公开这个查询接口,但 Shader Inspector 内部调用的两个函数可以通过反射拿到:

Assets/Cookbook/Ch03/Editor/SRPBatcherCheck.cs(节选:check)
// Unity 没有公开这两个函数,Shader Inspector 内部就是调用它们显示 “SRP Batcher: compatible / not compatible”
static readonly MethodInfo s_GetCode = typeof(ShaderUtil).GetMethod("GetSRPBatcherCompatibilityCode",
    BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
static readonly MethodInfo s_GetReason = typeof(ShaderUtil).GetMethod("GetSRPBatcherCompatibilityIssueReason",
    BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);

/// 返回 null 表示兼容,否则返回不兼容的原因
public static string Check(Shader shader, int subShader = 0)
{
    if (s_GetCode == null || s_GetReason == null) return "(当前 Unity 版本找不到内部 API)";
    int code = (int)s_GetCode.Invoke(null, new object[] { shader, subShader });
    if (code == 0) return null;
    return (string)s_GetReason.Invoke(null, new object[] { shader, subShader, code });
}

在 Project 窗口选中 Ch03 文件夹,执行 Cookbook › Ch03 › Check SRP Batcher Compatibility,Console 里就会逐个打印结果。下表是在 Unity 2022.3.62f3 上对本章 Shader 实际运行的输出:

Shader结果原因(工具原样输出)
SolidColor(案例 3.1)不兼容Material property is found in another cbuffer than "UnityPerMaterial" (_BaseColor)
Textured / VertexColor / AlphaClip / Fog不兼容同上,括号里分别报出第一个“漏网”的属性
SRPBatcher(案例 3.6)及之后所有兼容—

为了让你认识另外两种常见的不兼容原因,我们还临时写过两个反面例子(不在配套工程里):一个在 CBUFFER 里用 #ifdef _EXTRA_ON 包了一个属性,另一个让 ForwardPass 和 DepthOnly Pass 的 CBUFFER 少一个成员。工具给出的原因分别是:

UnityPerMaterial CBuffer inconsistent size inside a SubShader (<Unnamed Pass 0>)
UnityPerMaterial CBuffer inconsistent size inside a SubShader (<Unnamed Pass 1>)

看到 “inconsistent size” 就去对比各 Pass 和各 #if 分支里的 CBUFFER。

图 3-1160 个立方体,每个都有自己独立的材质(颜色不同),全部使用案例 3.6 的 Shader。在 Frame Debugger 里它们会被合进少数几个 SRP Batch,而不是 60 次完整的状态设置
怎么确认真的合批了打开 Window › Analysis › Frame Debugger,展开 DrawOpaqueObjects,能看到 “SRP Batch” 条目以及每个批次包含的绘制次数;没能合批时,Frame Debugger 会写出原因(比如“与上一个绘制使用了不同的 Shader 变体”)。第 29 章会系统对比 SRP Batcher、GPU Instancing 与静态/动态合批。

案例 3.7投射阴影与写入深度:ShadowCaster 与 DepthOnly进阶单独打开 ↗

目标:让物体能投射阴影(包括镂空形状的阴影),并能出现在深度图 _CameraDepthTexture 里。

到上一步为止,我们的 Shader 只有一个画颜色的 Pass。URP 在一帧里还会做别的绘制:渲染阴影图时,会以光源为“相机”把场景画一遍,只找带 ShadowCaster 标签的 Pass;需要深度预渲染(Depth Prepass)时,会再画一遍深度,只找 DepthOnly 标签的 Pass。一个 Shader 没有这些 Pass,物体就既不投影,也不会出现在预渲染的深度图里——不会报错,只是“悄悄缺席”。

ShadowCaster Pass

Assets/Cookbook/Ch03/C03_07_ShadowDepth.shader(节选:shadow)
// ---------------- 2. 阴影投射 Pass:把物体画进光源的 Shadow Map ----------------
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On
    ZTest LEqual
    ColorMask 0          // 只要深度,不写颜色
    Cull [_Cull]

    HLSLPROGRAM
    #pragma vertex ShadowVert
    #pragma fragment ShadowFrag
    // 点光/聚光的阴影要用 _LightPosition 逐顶点求光方向,方向光用 _LightDirection
    #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
    // Shadows.hlsl 用到了 CommonMaterial.hlsl 里的 LerpWhiteTo,单独引用时要先包含它
    #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl"

    // 由 URP 在渲染阴影前设置(ShadowUtils.SetupShadowCasterConstantBuffer)
    float3 _LightDirection;
    float3 _LightPosition;

    struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; };
    struct Varyings   { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; };

    Varyings ShadowVert (Attributes input)
    {
        Varyings output;
        float3 positionWS = TransformObjectToWorld(input.positionOS.xyz);
        float3 normalWS   = TransformObjectToWorldNormal(input.normalOS);
    #if defined(_CASTING_PUNCTUAL_LIGHT_SHADOW)
        float3 lightDirectionWS = normalize(_LightPosition - positionWS);
    #else
        float3 lightDirectionWS = _LightDirection;
    #endif
        // 沿光方向和法线方向各推一点,减轻自阴影条纹(Shadow Acne),细节见第 6 章
        positionWS = ApplyShadowBias(positionWS, normalWS, lightDirectionWS);
        output.positionCS = TransformWorldToHClip(positionWS);
        // 把超出近平面的投射体压回近平面,避免被裁掉导致阴影缺块
    #if UNITY_REVERSED_Z
        output.positionCS.z = min(output.positionCS.z, UNITY_NEAR_CLIP_VALUE);
    #else
        output.positionCS.z = max(output.positionCS.z, UNITY_NEAR_CLIP_VALUE);
    #endif
        output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
        return output;
    }

    half4 ShadowFrag (Varyings input) : SV_Target
    {
        ClipByAlpha(input.uv);   // 镂空的地方不投影
        return 0;
    }
    ENDHLSL
}
// ---------------- 2. 阴影投射 Pass:把物体画进光源的 Shadow Map ----------------
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On
    ZTest LEqual
    ColorMask 0          // 只要深度,不写颜色
    Cull [_Cull]

    GLSLPROGRAM
    // 点光/聚光的阴影要用 _LightPosition 逐顶点求光方向,方向光用 _LightDirection。
    // 网页版里 _CASTING_PUNCTUAL_LIGHT_SHADOW 不是关键字,而是 uniform _CastingPunctualLightShadow;
    // _LightDirection / _LightPosition / ApplyShadowBias 都已由 Lighting.glsl 提供(对应 Shadows.hlsl)

    VARYING vec2 uv;

    #ifdef VERTEX
    void main()
    {
        vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
        vec3 normalWS   = TransformObjectToWorldNormal(normalOS);
        vec3 lightDirectionWS = _CastingPunctualLightShadow > 0.5
            ? normalize(_LightPosition - positionWS)
            : _LightDirection;
        // 沿光方向和法线方向各推一点,减轻自阴影条纹(Shadow Acne),细节见第 6 章
        positionWS = ApplyShadowBias(positionWS, normalWS, lightDirectionWS);
        gl_Position = TransformWorldToHClip(positionWS);
        // 把超出近平面的投射体压回近平面,避免被裁掉导致阴影缺块
    #if UNITY_REVERSED_Z
        gl_Position.z = min(gl_Position.z, UNITY_NEAR_CLIP_VALUE);
    #else
        gl_Position.z = max(gl_Position.z, UNITY_NEAR_CLIP_VALUE);
    #endif
        uv = TransformTex(texcoord0.xy, _BaseMap_ST);
    }
    #endif

    #ifdef FRAGMENT
    void main()
    {
        ClipByAlpha(uv);   // 镂空的地方不投影
        fragColor = vec4(0.0);
    }
    #endif
    ENDGLSL
}

逐段解释:

  • 渲染状态:ZWrite On、ZTest LEqual,ColorMask 0 表示不写任何颜色通道——阴影图只关心深度。Cull 与颜色 Pass 保持一致,否则双面的镂空物体只有一面会投影。
  • _LightDirection / _LightPosition:URP 在渲染每张阴影图前,由 C# 端的 ShadowUtils.SetupShadowCasterConstantBuffer 设置这两个全局变量(URP 自带的 ShadowCasterPass.hlsl 里有同样的注释)。方向光用方向,点光源和聚光灯的光方向在每个顶点都不同,所以用位置现算。_CASTING_PUNCTUAL_LIGHT_SHADOW 关键字由 URP 在画点光/聚光阴影时开启,我们只在顶点阶段需要它,所以用 multi_compile_vertex。
  • ApplyShadowBias:阴影图的分辨率有限,表面直接拿自己的深度和阴影图比较,会因为精度误差一半判成“在阴影里”,出现条纹状的自阴影(Shadow Acne)。URP 的做法是在画阴影图时,把顶点沿光方向推远一点(Depth Bias)、沿法线往里缩一点(Normal Bias):
// 摘自 URP 14.0.12 ShaderLibrary/Shadows.hlsl
float3 ApplyShadowBias(float3 positionWS, float3 normalWS, float3 lightDirection)
{
    float invNdotL = 1.0 - saturate(dot(lightDirection, normalWS));
    float scale = invNdotL * _ShadowBias.y;

    // normal bias is negative since we want to apply an inset normal offset
    positionWS = lightDirection * _ShadowBias.xxx + positionWS;
    positionWS = normalWS * scale.xxx + positionWS;
    return positionWS;
}

_ShadowBias 来自 URP Asset 和 Light 组件上的 Depth Bias / Normal Bias 设置。法线偏移乘了 1 - N·L:表面越是斜对光源,越容易出现 Acne,偏移就越大。偏移过大则会让阴影和物体“脱开”(Peter-Panning),这些调参问题留到第 6 章用对比图细讲。

  • 近平面钳制:方向光的阴影相机只覆盖一个有限的范围,位于其近平面之外(更靠近光源)的投射体会被裁掉,导致阴影缺一块。把超出的 z 压回近平面上(UNITY_NEAR_CLIP_VALUE 在反向 Z 平台上是 1,否则是 -1),这些物体就被“压扁”在近平面上继续投影。
  • 镂空:片元里调用与颜色 Pass 相同的裁剪逻辑,所以栅栏的阴影也有孔。我们把它抽成了 HLSLINCLUDE 里的 ClipByAlpha 函数,保证三个 Pass 的形状一模一样;颜色 Pass 也改成只按“贴图 alpha × 颜色 alpha”裁剪,顶点色只影响 rgb。
一个真实的坑:LerpWhiteTo第一次写这个 Pass 时,我们只 include 了 Shadows.hlsl,结果编译报错:undeclared identifier 'LerpWhiteTo',位置在 Shadows.hlsl(298)。原因是 URP 14 的 Shadows.hlsl 用到了 core 包 CommonMaterial.hlsl 里的 LerpWhiteTo,却没有自己 include 它——URP 自带 Shader 不会遇到这个问题,因为它们的 LitInput.hlsl → SurfaceInput.hlsl 已经先引入了 CommonMaterial.hlsl。解决办法就是在 Shadows.hlsl 之前补一行 include,正如上面代码里那样。
图 3-12左:案例 3.6 的 Shader(没有 ShadowCaster),栅栏和球都不投影;右:案例 3.7,两者都有阴影,栅栏的阴影保留了镂空形状。两边的物体本身都不接收阴影——接收阴影需要在颜色 Pass 里采样阴影图,那是第 6 章的内容

DepthOnly Pass

Assets/Cookbook/Ch03/C03_07_ShadowDepth.shader(节选:depth)
// ---------------- 3. 深度 Pass:深度预渲染(Depth Prepass)时把物体写进 _CameraDepthTexture ----------------
Pass
{
    Name "DepthOnly"
    Tags { "LightMode"="DepthOnly" }
    ZWrite On
    ColorMask R          // URP 自带 Shader 的写法;深度写进深度缓冲,颜色通道基本用不到
    Cull [_Cull]

    HLSLPROGRAM
    #pragma vertex DepthVert
    #pragma fragment DepthFrag

    struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; };
    struct Varyings   { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; };

    Varyings DepthVert (Attributes input)
    {
        Varyings output;
        output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
        output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
        return output;
    }

    half DepthFrag (Varyings input) : SV_Target
    {
        ClipByAlpha(input.uv);
        return input.positionCS.z;
    }
    ENDHLSL
}
// ---------------- 3. 深度 Pass:深度预渲染(Depth Prepass)时把物体写进 _CameraDepthTexture ----------------
Pass
{
    Name "DepthOnly"
    Tags { "LightMode"="DepthOnly" }
    ZWrite On
    ColorMask R          // URP 自带 Shader 的写法;深度写进深度缓冲,颜色通道基本用不到
    Cull [_Cull]

    GLSLPROGRAM
    VARYING vec2 uv;

    #ifdef VERTEX
    void main()
    {
        gl_Position = TransformObjectToHClip(positionOS.xyz);
        uv = TransformTex(texcoord0.xy, _BaseMap_ST);
    }
    #endif

    #ifdef FRAGMENT
    void main()
    {
        ClipByAlpha(uv);
        fragColor = vec4(gl_FragCoord.z);
    }
    #endif
    ENDGLSL
}

这个 Pass 只是把物体按相机的视角再画一遍、写进深度缓冲。它什么时候会被用到?URP 获取 _CameraDepthTexture 有两种方式:一是在画完不透明物体后,直接复制相机的深度缓冲(CopyDepth);二是在画不透明物体之前,用所有物体的 DepthOnly Pass 预先画一遍深度(Depth Prepass)。哪种情况走哪条路由 URP 根据平台能力、MSAA、Renderer Feature 的需求等决定。举例来说,屏幕空间环境光遮蔽(SSAO)需要在不透明物体之前就拿到深度,就会触发预渲染。

走复制路线时,没有 DepthOnly Pass 的物体照样在深度图里(因为它的颜色 Pass 写了深度缓冲);走预渲染路线时,它就消失了。为了把这一点拍下来,我们写了一个什么都不画、只“声明需要在不透明物体之前使用深度”的 Renderer Feature 来强制触发预渲染:

Assets/Cookbook/Ch03/DepthPrepassFeature.cs
// 演示用 Renderer Feature:声明“我在不透明物体之前就要用深度图”,从而让 URP 走深度预渲染(Depth Prepass)。
// 真实项目里 SSAO(Depth 源)、某些自定义效果都会这样触发 Depth Prepass。
// 这时 _CameraDepthTexture 由各物体的 DepthOnly Pass 画出来——没有 DepthOnly Pass 的物体就不在深度图里。
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

namespace Cookbook.Ch03
{
    public class DepthPrepassFeature : ScriptableRendererFeature
    {
        class RequestDepthPass : ScriptableRenderPass
        {
            public RequestDepthPass()
            {
                renderPassEvent = RenderPassEvent.BeforeRenderingOpaques;
                ConfigureInput(ScriptableRenderPassInput.Depth);
            }
            // 什么都不画,只负责“提需求”
            public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { }
        }

        RequestDepthPass m_Pass;

        public override void Create() { m_Pass = new RequestDepthPass(); }

        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
        {
            renderer.EnqueuePass(m_Pass);
        }
    }
}
// 演示用 Renderer Feature(JS 版,对应 Ch03/DepthPrepassFeature.cs):
// 声明“我在不透明物体之前就要用深度图”,从而让 URP 走深度预渲染(Depth Prepass)。
// 这时 _CameraDepthTexture 由各物体的 DepthOnly Pass 画出来——没有 DepthOnly Pass 的物体就不在深度图里。
// C# 版的 Pass 本身什么都不画,只用 ConfigureInput(Depth) 提需求;网页引擎没有“按需求自动加 Prepass”的机制,
// 所以这里把 URP 因此插入的 DepthPrepass 一起做了:在 BeforeRenderingPrePasses 时把不透明物体的 DepthOnly Pass 画进一张深度图。
import { RenderPassEvent } from '../engine/features.js';

export default class DepthPrepassFeature {
  constructor(engine) { this.engine = engine; }

  addPasses(frame, enqueue) {
    const E = this.engine, gl = E.gl;
    enqueue(RenderPassEvent.BeforeRenderingPrePasses, (fr) => {
      const rt = E.rt('cookbook:depthPrepass', fr.width, fr.height, { color: null, depth: 'DEPTH24' });
      E.bindTarget(rt);
      gl.depthMask(true); gl.clearDepth(1); gl.clear(gl.DEPTH_BUFFER_BIT);
      fr.drawRenderers({ queue: 'opaque', lightModes: ['DepthOnly'] });
      E.setGlobal('_CameraDepthTexture', rt.depthTexture);
      // 有了 Prepass,URP 就不再在不透明物体之后 CopyDepth
      fr.requireDepth = false;
      fr.bindCamera();
    }, 'DepthPrepass');
  }
}

它利用了 URP 14 UniversalRenderer 里的规则:如果某个 Pass 通过 ConfigureInput(ScriptableRenderPassInput.Depth) 声明需要深度,并且它的事件早于不透明物体的绘制,就必须做深度预渲染。Renderer Feature 的写法第 25 章会系统讲。

图 3-13开启深度预渲染时的对比。左:正常画面,橙球用案例 3.6 的 Shader(无 DepthOnly),蓝球用案例 3.7;右:在相机前放一张面片,用调试 Shader Cookbook/Ch03/Debug/DepthView 把 _CameraDepthTexture 显示为灰度(越近越亮)——橙球在深度图里不存在

深度图里缺了物体,后果是所有依赖深度的效果都会对它“视而不见”:SSAO 在它周围不产生遮蔽,软粒子、水面的深度交界泡沫、第 18 章护盾的交接高亮都会穿帮。所以只要是不透明物体,就给它配上 DepthOnly Pass。另一个相关的 Pass 是 DepthNormals,它同时输出法线,供 SSAO 等效果使用(URP 的 SSAO 可选择从深度重建法线,也可以要求 DepthNormals),它需要顶点法线,放到第 6 章与光照一起写。

能不能直接复用 URP 的 Pass?有两种“偷懒”写法:一是 UsePass "Universal Render Pipeline/Lit/SHADOWCASTER"(Pass 名要大写),二是在自己的 Pass 里 #include ".../Shaders/ShadowCasterPass.hlsl"。两者都能工作,但都依赖 URP 的约定:属性必须叫 _BaseMap、_BaseColor、_Cutoff,镂空必须用 _ALPHATEST_ON 关键字,第二种还要求你按 SurfaceInput.hlsl 的方式声明贴图(它会自己声明 _BaseMap,你再声明一次就会报 redefinition of '_BaseMap')。你的 Shader 一旦加了顶点动画、换了 UV 来源,借来的 Pass 不会跟着变,阴影就和外形对不上了。本书统一手写,代码量也不过二十几行。

案例 3.8关键字:用 multi_compile 与 shader_feature 做开关进阶单独打开 ↗

目标:让顶点色、Alpha Clip、UV 来源都能在材质面板上开关,而且关掉的功能不产生任何运行时开销。

GPU 不擅长分支。虽然现代 GPU 对“所有像素都走同一边”的统一分支(uniform branch)处理得不错,但分支里用到的寄存器、插值器、贴图采样仍会占用资源,clip 更是只要存在就可能影响 Early-Z。Unity 的解决方案是着色器变体(Shader Variant):用预处理器 #if 写出不同功能的代码,用关键字(Keyword)标记,编译时把每种关键字组合各编译一份,运行时按材质上启用的关键字挑选对应的那份。

Assets/Cookbook/Ch03/C03_08_Keywords.shader(节选:props)
// [Toggle(关键字)]:勾选时启用该关键字,属性值同时变成 1
[Toggle(_VERTEX_COLOR_ON)] _UseVertexColor ("Use Vertex Color", Float) = 0
[Toggle(_ALPHATEST_ON)]    _AlphaClip ("Alpha Clip", Float) = 0
_Cutoff ("Alpha Cutoff", Range(0, 1)) = 0.5
// [KeywordEnum(A, B)]:生成关键字 _属性名大写_选项大写,即 _UVSOURCE_MESH / _UVSOURCE_WORLDXZ
[KeywordEnum(Mesh, WorldXZ)] _UVSource ("UV Source", Float) = 0
_WorldUVScale ("World UV Scale", Float) = 0.5
[Enum(UnityEngine.Rendering.CullMode)] _Cull ("Cull", Float) = 2
// 第 8 步:用关键字(Keyword)做功能开关 —— 一份源码,按需编译出多个变体。
// 这是本章的“完成版”:纹理、顶点色、Alpha Clip、雾、SRP Batcher、阴影、深度全部具备。
Shader "Cookbook/Ch03/Keywords"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        // CBUFFER_START(UnityPerMaterial)
        uniform vec4  _BaseMap_ST;
        uniform vec4  _BaseColor;
        uniform float _Cutoff;
        uniform float _WorldUVScale;
        // CBUFFER_END

        uniform sampler2D _BaseMap;

        // 根据关键字选择 UV 来源。#if 在编译期决定,没被选中的分支根本不会进入这个变体
        vec2 ComputeUV(vec2 meshUV, vec3 positionWS)
        {
        #if defined(_UVSOURCE_WORLDXZ)
            vec2 uv = positionWS.xz * _WorldUVScale;   // 俯视投影:相邻物体的纹理无缝衔接
        #else
            vec2 uv = meshUV;
        #endif
            return TransformTex(uv, _BaseMap_ST);
        }

        #ifdef FRAGMENT
        void ClipByAlpha(vec2 uv)
        {
        #if defined(_ALPHATEST_ON)
            float a = texture(_BaseMap, uv).a * _BaseColor.a;
            if (a - _Cutoff < 0.0) discard;
        #endif
        }
        #endif
        ENDGLSL

        Pass
        {
            Name "Unlit"
            // 不需要 GBuffer 的 Unlit:用 UniversalForwardOnly,Forward / Forward+ / Deferred 三种路径都会画它
            Tags { "LightMode"="UniversalForwardOnly" }
            Cull [_Cull]

            GLSLPROGRAM
            // 材质开关:shader_feature —— 只编译“有材质用到”的组合;_local 不占用全局关键字名额
            #pragma shader_feature_local _VERTEX_COLOR_ON
            #pragma shader_feature_local_fragment _ALPHATEST_ON
            #pragma shader_feature_local _UVSOURCE_MESH _UVSOURCE_WORLDXZ
            // 全局开关:multi_compile —— 运行时可能切换,所有组合都要编译进包
            #pragma multi_compile_fog

            VARYING vec2 uv;
            VARYING float fogFactor;
        #if defined(_VERTEX_COLOR_ON)
            VARYING vec4 vColor;      // 不用顶点色的变体连插值器都省掉
        #endif

            #ifdef VERTEX
            void main()
            {
                VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz);
                gl_Position = pos.positionCS;
                uv = ComputeUV(texcoord0.xy, pos.positionWS);
                fogFactor = ComputeFogFactor(pos.positionCS.z);
            #if defined(_VERTEX_COLOR_ON)
                vColor = color;
            #endif
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                vec4 tex = texture(_BaseMap, uv);
            #if defined(_ALPHATEST_ON)
                if (tex.a * _BaseColor.a - _Cutoff < 0.0) discard;
            #endif
                vec3 rgb = tex.rgb * _BaseColor.rgb;
            #if defined(_VERTEX_COLOR_ON)
                rgb *= vColor.rgb;
            #endif
                rgb = MixFog(rgb, fogFactor);
                fragColor = vec4(rgb, 1);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On
            ZTest LEqual
            ColorMask 0
            Cull [_Cull]

            GLSLPROGRAM
            #pragma shader_feature_local _ALPHATEST_ON
            #pragma shader_feature_local _UVSOURCE_MESH _UVSOURCE_WORLDXZ
            // _CASTING_PUNCTUAL_LIGHT_SHADOW 在网页版是 uniform _CastingPunctualLightShadow;ApplyShadowBias 等由 Lighting.glsl 提供

            VARYING vec2 uv;

            #ifdef VERTEX
            void main()
            {
                vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
                vec3 normalWS   = TransformObjectToWorldNormal(normalOS);
                vec3 lightDirectionWS = _CastingPunctualLightShadow > 0.5
                    ? normalize(_LightPosition - positionWS)
                    : _LightDirection;
                gl_Position = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS));
            #if UNITY_REVERSED_Z
                gl_Position.z = min(gl_Position.z, UNITY_NEAR_CLIP_VALUE);
            #else
                gl_Position.z = max(gl_Position.z, UNITY_NEAR_CLIP_VALUE);
            #endif
                uv = ComputeUV(texcoord0.xy, positionWS);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                ClipByAlpha(uv);
                fragColor = vec4(0.0);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On
            ColorMask R
            Cull [_Cull]

            GLSLPROGRAM
            #pragma shader_feature_local _ALPHATEST_ON
            #pragma shader_feature_local _UVSOURCE_MESH _UVSOURCE_WORLDXZ

            VARYING vec2 uv;

            #ifdef VERTEX
            void main()
            {
                vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
                gl_Position = TransformWorldToHClip(positionWS);
                uv = ComputeUV(texcoord0.xy, positionWS);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                ClipByAlpha(uv);
                fragColor = vec4(gl_FragCoord.z);
            }
            #endif
            ENDGLSL
        }
    }
}
Assets/Cookbook/Ch03/C03_08_Keywords.shader(节选:pragmas)
// 材质开关:shader_feature —— 只编译“有材质用到”的组合;_local 不占用全局关键字名额
#pragma shader_feature_local _VERTEX_COLOR_ON
#pragma shader_feature_local_fragment _ALPHATEST_ON
#pragma shader_feature_local _UVSOURCE_MESH _UVSOURCE_WORLDXZ
// 全局开关:multi_compile —— 运行时可能切换,所有组合都要编译进包
#pragma multi_compile_fog
// 材质开关:shader_feature —— 只编译“有材质用到”的组合;_local 不占用全局关键字名额
#pragma shader_feature_local _VERTEX_COLOR_ON
#pragma shader_feature_local_fragment _ALPHATEST_ON
#pragma shader_feature_local _UVSOURCE_MESH _UVSOURCE_WORLDXZ
// 全局开关:multi_compile —— 运行时可能切换,所有组合都要编译进包
#pragma multi_compile_fog

#pragma 有两大类:

multi_compileshader_feature
编译哪些变体列出的所有组合都编译进包只编译“构建时有材质用到”的组合
适合运行时由代码或引擎切换的全局状态:雾、阴影、光照贴图、渲染路径材质面板上的功能开关,美术设好后基本不变
风险变体数随关键字数指数增长,包体和编译时间膨胀运行时用脚本开启一个“没有材质用过”的关键字,构建版本里找不到对应变体,会退回到最接近的变体(效果不对)

两者都可以加后缀:_local 表示关键字只在本 Shader 内有效(Unity 对全局关键字总数有上限,材质开关一律用 local);_vertex、_fragment 表示只有该阶段的代码用到它,编译器可以让另一个阶段共享同一份程序,减少实际编译量。我们的 _ALPHATEST_ON 只在片元里用,所以颜色 Pass 写 shader_feature_local_fragment;但阴影和深度 Pass 也要裁剪,它们各自声明自己的 shader_feature_local _ALPHATEST_ON——每个 Pass 的关键字是独立声明的,没有在某个 Pass 里声明的关键字,在那个 Pass 里永远是未定义。

单个关键字的 shader_feature_local _VERTEX_COLOR_ON 是 shader_feature_local _ _VERTEX_COLOR_ON 的简写,下划线 _ 代表“都不开”。多个互斥关键字写在同一行,如 _UVSOURCE_MESH _UVSOURCE_WORLDXZ,同一时刻只会有一个生效。来算一下颜色 Pass 最多有多少变体:顶点色 2 × Alpha Clip 2 × UV 来源 2 × 雾 4 = 32 个。再加几个开关就会上百——这就是所谓的“变体爆炸”,管理方法在第 29 章。

Assets/Cookbook/Ch03/C03_08_Keywords.shader(节选:uv)
// 根据关键字选择 UV 来源。#if 在编译期决定,没被选中的分支根本不会进入这个变体
float2 ComputeUV(float2 meshUV, float3 positionWS)
{
#if defined(_UVSOURCE_WORLDXZ)
    float2 uv = positionWS.xz * _WorldUVScale;   // 俯视投影:相邻物体的纹理无缝衔接
#else
    float2 uv = meshUV;
#endif
    return TRANSFORM_TEX(uv, _BaseMap);
}

void ClipByAlpha(float2 uv)
{
#if defined(_ALPHATEST_ON)
    half a = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv).a * _BaseColor.a;
    clip(a - _Cutoff);
#endif
}
// 根据关键字选择 UV 来源。#if 在编译期决定,没被选中的分支根本不会进入这个变体
vec2 ComputeUV(vec2 meshUV, vec3 positionWS)
{
#if defined(_UVSOURCE_WORLDXZ)
    vec2 uv = positionWS.xz * _WorldUVScale;   // 俯视投影:相邻物体的纹理无缝衔接
#else
    vec2 uv = meshUV;
#endif
    return TransformTex(uv, _BaseMap_ST);
}

#ifdef FRAGMENT
void ClipByAlpha(vec2 uv)
{
#if defined(_ALPHATEST_ON)
    float a = texture(_BaseMap, uv).a * _BaseColor.a;
    if (a - _Cutoff < 0.0) discard;
#endif
}
#endif
Assets/Cookbook/Ch03/C03_08_Keywords.shader(节选:frag)
half4 frag (Varyings input) : SV_Target
{
    half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
#if defined(_ALPHATEST_ON)
    clip(tex.a * _BaseColor.a - _Cutoff);
#endif
    half3 rgb = tex.rgb * _BaseColor.rgb;
#if defined(_VERTEX_COLOR_ON)
    rgb *= input.color.rgb;
#endif
    rgb = MixFog(rgb, input.fogFactor);
    return half4(rgb, 1);
}
#ifdef FRAGMENT
void main()
{
    vec4 tex = texture(_BaseMap, uv);
#if defined(_ALPHATEST_ON)
    if (tex.a * _BaseColor.a - _Cutoff < 0.0) discard;
#endif
    vec3 rgb = tex.rgb * _BaseColor.rgb;
#if defined(_VERTEX_COLOR_ON)
    rgb *= vColor.rgb;
#endif
    rgb = MixFog(rgb, fogFactor);
    fragColor = vec4(rgb, 1);
}
#endif

代码里用 #if defined(...) 判断关键字。被排除的分支在这个变体里根本不存在:不用顶点色的变体,Attributes 和 Varyings 里连 color 成员都没有,省下了一个插值器。世界空间俯视投影(_UVSOURCE_WORLDXZ)用 positionWS.xz 当 UV,相邻的物体会共用同一套坐标,纹理在接缝处无缝衔接,常用于地面拼块和关卡白模;代价是竖直面上会被拉成条纹(下图右的立方体侧面),第 20 章的三平面映射(Triplanar)会解决这个问题。

图 3-14同一个 Shader 的三种关键字组合。左:全部关闭(网格 UV);中:开启 _VERTEX_COLOR_ON,贴图乘上顶点色;右:再开启 _ALPHATEST_ON 与 _UVSOURCE_WORLDXZ,两个箱子顶面的波点连成一片,侧面被拉成条纹,镂空的阴影也正确

从 C# 设置关键字

在材质面板上勾选 [Toggle(_VERTEX_COLOR_ON)],Unity 会同时改属性值和关键字。但用脚本 material.SetFloat("_UseVertexColor", 1) 只改数值,不改关键字,这是新手最常踩的坑。脚本里要显式设置关键字:

// 方式一:字符串(简单)
material.EnableKeyword("_VERTEX_COLOR_ON");
material.DisableKeyword("_ALPHATEST_ON");
// 方式二:LocalKeyword(2021.2 起可用,类型安全,可缓存)
var kw = new UnityEngine.Rendering.LocalKeyword(material.shader, "_ALPHATEST_ON");
material.SetKeyword(kw, true);
material.SetFloat("_AlphaClip", 1);              // 让面板显示保持一致
material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.AlphaTest;  // 镂空时顺便调整队列

最后一行也值得注意:本 Shader 的 SubShader 写的是 Queue=Geometry,开启镂空后应把材质的渲染队列改为 AlphaTest。URP 自带 Shader 是靠自定义材质面板(CustomEditor)在勾选时自动完成这些联动的。

3.4 LightMode 的一个隐蔽陷阱:Deferred 下消失的 Unlit

案例 3.8 的颜色 Pass 悄悄换了一个标签:UniversalForwardOnly。原因藏在 URP 14 的 UniversalRenderer.cs 里。在 Deferred 渲染路径下,不透明物体由 G-Buffer Pass(找 UniversalGBuffer)写入几何信息,再统一计算光照;那些不能走延迟光照的材质(Unlit、特殊材质)由一个“Forward Only”绘制补画,它只认下面这几个标签:

// 摘自 URP 14.0.12 Runtime/UniversalRenderer.cs(节选)
// - If a material can be rendered either forward or deferred, then it should declare a UniversalForward and a UniversalGBuffer pass.
// - If a material cannot be lit in deferred (unlit, bakedLit, special material such as hair, skin shader), then it should declare UniversalForwardOnly pass
ShaderTagId[] forwardOnlyShaderTagIds = new ShaderTagId[]
{
    new ShaderTagId("UniversalForwardOnly"),
    new ShaderTagId("SRPDefaultUnlit"),
    new ShaderTagId("LightweightForward")
};

也就是说,一个只有 UniversalForward 颜色 Pass、没有 UniversalGBuffer Pass 的不透明 Shader,在 Deferred 下不会被画出来——但它的 ShadowCaster 仍然工作,于是你会看到“只有影子没有物体”的灵异现象:

图 3-15左:Forward+ 路径;右:同一场景切到 Deferred。橙球用案例 3.1(LightMode=UniversalForward),在 Deferred 下消失;蓝球用案例 3.8(UniversalForwardOnly),两种路径都正常。蓝球的阴影证明它的 ShadowCaster 在两边都工作

结论很简单:Unlit 类 Shader 的颜色 Pass 用 UniversalForwardOnly;需要光照的 Shader 用 UniversalForward,如果项目会用 Deferred,再补一个 UniversalGBuffer Pass(第 11 章的手写 PBR 会讲到)。本书默认使用 Forward+,所以前几个案例保留了最常见的 UniversalForward 写法,让你看到问题的来龙去脉。

案例 3.9迁移实战:把 Built-in 的滚动贴图 Shader 搬到 URP进阶单独打开 ↗

目标:拿一个典型的 Built-in 管线 Unlit Shader(滚动贴图 + 顶点色 + 雾),逐行改成 URP 写法。很多项目升级 URP 时,最多的工作量就是这类“小 Shader”。下面是 Built-in 原版(它在 URP 工程里不能正确工作,仅作对照):

Built-in 原版(对照用)
Shader "Legacy/ScrollUnlit"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Color ("Tint", Color) = (1,1,1,1)
        _ScrollSpeed ("Scroll Speed (xy)", Vector) = (0.2, 0, 0, 0)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fog
            #include "UnityCG.cginc"

            struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; };
            struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; UNITY_FOG_COORDS(1) };

            sampler2D _MainTex; float4 _MainTex_ST;
            fixed4 _Color; float4 _ScrollSpeed;

            v2f vert (appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex) + _ScrollSpeed.xy * _Time.y;
                o.color = v.color;
                UNITY_TRANSFER_FOG(o, o.pos);
                return o;
            }
            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv) * _Color * i.color;
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
    Fallback "Unlit/Texture"
}

URP 版本的完整文件是 Ch03/C03_09_ScrollMigrated.shader,颜色 Pass 如下,每一处改动都在注释里标出了对应的 Built-in 写法:

Assets/Cookbook/Ch03/C03_09_ScrollMigrated.shader(节选:pass)
Pass
{
    Name "Unlit"
    // Unlit 用 UniversalForwardOnly:切到 Deferred 渲染路径也不会消失(见正文 3.4 节)
    Tags { "LightMode"="UniversalForwardOnly" }

    HLSLPROGRAM                                  // CGPROGRAM → HLSLPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma multi_compile_fog

    struct Attributes                            // appdata → Attributes
    {
        float4 positionOS : POSITION;            // vertex → positionOS
        float2 uv         : TEXCOORD0;
        half4  color      : COLOR;               // fixed4 → half4(URP 不再有 fixed)
    };

    struct Varyings                              // v2f → Varyings
    {
        float4 positionCS : SV_POSITION;
        float2 uv         : TEXCOORD0;
        half4  color      : COLOR;
        float  fogFactor  : TEXCOORD1;           // UNITY_FOG_COORDS(1) → 自己声明
    };

    Varyings vert (Attributes input)
    {
        Varyings output;
        // UnityObjectToClipPos → TransformObjectToHClip
        output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
        // _Time.y 的含义与 Built-in 相同:游戏开始后的秒数
        float2 scroll = _ScrollSpeed.xy * _Time.y + _ScrollOffset.xy;
        output.uv = TRANSFORM_TEX(input.uv, _BaseMap) + scroll;
        output.color = input.color;
        // UNITY_TRANSFER_FOG(o, o.pos) → ComputeFogFactor
        output.fogFactor = ComputeFogFactor(output.positionCS.z);
        return output;
    }

    half4 frag (Varyings input) : SV_Target
    {
        // tex2D(_MainTex, uv) → SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv)
        half4 col = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor * input.color;
        // UNITY_APPLY_FOG(i.fogCoord, col) → MixFog
        col.rgb = MixFog(col.rgb, input.fogFactor);
        return half4(col.rgb, 1);
    }
    ENDHLSL
}
Pass
{
    Name "Unlit"
    // Unlit 用 UniversalForwardOnly:切到 Deferred 渲染路径也不会消失(见正文 3.4 节)
    Tags { "LightMode"="UniversalForwardOnly" }

    GLSLPROGRAM                                  // CGPROGRAM → HLSLPROGRAM(网页版:GLSLPROGRAM)
    #pragma multi_compile_fog

    // appdata → Attributes(网页版:固定名字的顶点输入 positionOS / texcoord0 / color)
    // v2f → Varyings(网页版:逐个 VARYING)
    VARYING vec2 uv;
    VARYING vec4 vColor;                         // fixed4 → half4(URP 不再有 fixed)
    VARYING float fogFactor;                     // UNITY_FOG_COORDS(1) → 自己声明

    #ifdef VERTEX
    void main()
    {
        // UnityObjectToClipPos → TransformObjectToHClip
        gl_Position = TransformObjectToHClip(positionOS.xyz);
        // _Time.y 的含义与 Built-in 相同:游戏开始后的秒数
        vec2 scroll = _ScrollSpeed.xy * _Time.y + _ScrollOffset.xy;
        uv = TransformTex(texcoord0.xy, _BaseMap_ST) + scroll;
        vColor = color;
        // UNITY_TRANSFER_FOG(o, o.pos) → ComputeFogFactor
        fogFactor = ComputeFogFactor(gl_Position.z);
    }
    #endif

    #ifdef FRAGMENT
    void main()
    {
        // tex2D(_MainTex, uv) → SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv)(网页版:texture)
        vec4 col = texture(_BaseMap, uv) * _BaseColor * vColor;
        // UNITY_APPLY_FOG(i.fogCoord, col) → MixFog
        col.rgb = MixFog(col.rgb, fogFactor);
        fragColor = vec4(col.rgb, 1);
    }
    #endif
    ENDGLSL
}

除了逐行替换,还有四处“结构性”改动:

  1. SubShader 加上 RenderPipeline 标签,Pass 加上 LightMode(Unlit 用 UniversalForwardOnly,见 3.4 节)。
  2. 属性改名为 _BaseMap / _BaseColor 并加 [MainTexture]/[MainColor]。这不是语法要求,但遵循 URP 命名后,材质在 URP 自带 Shader 与你的 Shader 之间切换时贴图和颜色能自动保留,URP 的材质升级工具和许多插件也按这个名字找属性。如果项目里有大量旧材质,也可以保留 _MainTex 这个名字,换取零迁移成本——两种选择都可以,团队统一即可。
  3. 所有属性进 UnityPerMaterial,放在 HLSLINCLUDE 里供三个 Pass 共享。
  4. 删掉 Fallback,手写 ShadowCaster 和 DepthOnly。Built-in 版本靠 Fallback 借用别的 Shader 的阴影 Pass;URP 下应该自己提供。

另外,为了能在书里拍静帧,我们加了一个 _ScrollOffset 参数:_Time 在编辑器截图时接近 0,用它可以手动指定“滚到哪了”。实际使用时 _ScrollSpeed 驱动自动滚动,_ScrollOffset 也可以交给动画曲线或脚本控制,做出“按事件推进”的传送带。

图 3-16迁移后的 Shader 用在一个圆柱上,_ScrollOffset.x 分别为 0、0.25、0.5,贴图沿 U 方向绕圆柱滚动

3.5 Built-in → URP 写法对照表

下表汇总了迁移时最常遇到的替换。函数名都已在 URP 14.0.12 / core 14.0.12 源码中核对过;附录 D 给出了更完整的版本。

Built-inURP 14备注
CGPROGRAM … ENDCGHLSLPROGRAM … ENDHLSLCGPROGRAM 会自动包含 Built-in 的头文件
CGINCLUDE … ENDCGHLSLINCLUDE … ENDHLSL
#include "UnityCG.cginc"#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"二者不能同时包含,会大量重定义
Lighting.cginc / AutoLight.cgincShaderLibrary/Lighting.hlsl / Shadows.hlsl第 5、6 章
fixed / fixed4half / half4
UnityObjectToClipPos(v.vertex)TransformObjectToHClip(v.positionOS.xyz)参数是 float3
mul(unity_ObjectToWorld, v.vertex).xyzTransformObjectToWorld(positionOS)或 GetVertexPositionInputs 一次拿到 WS/VS/CS/NDC
UnityObjectToWorldNormal(n)TransformObjectToWorldNormal(n)或 GetVertexNormalInputs
WorldSpaceViewDir(v)GetWorldSpaceViewDir(positionWS) / GetWorldSpaceNormalizeViewDir
sampler2D _MainTex;TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
tex2D(_MainTex, uv)SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv)
tex2Dlod(_MainTex, float4(uv,0,lod))SAMPLE_TEXTURE2D_LOD(tex, sampler, uv, lod)顶点着色器里采样必须用 LOD 版本
UNITY_FOG_COORDS(n) / UNITY_TRANSFER_FOG / UNITY_APPLY_FOGfloat fogFactor : TEXCOORDn / ComputeFogFactor / MixFog案例 3.5
UNITY_INITIALIZE_OUTPUT(v2f, o)ZERO_INITIALIZE(Varyings, o) 或 Varyings o = (Varyings)0;
ComputeScreenPos(pos)GetVertexPositionInputs(...).positionNDC;片元里 GetNormalizedScreenSpaceUV(positionCS)URP 14 仍保留 ComputeScreenPos,但已标为 deprecated
_LightColor0、_WorldSpaceLightPos0Light l = GetMainLight(); 的 l.color、l.direction第 5 章
LightMode ForwardBase + ForwardAdd一个 UniversalForward Pass 内循环所有光源第 6 章
GrabPass { }_CameraOpaqueTexture(DeclareOpaqueTexture.hlsl 的 SampleSceneColor)需在 URP Asset 开启 Opaque Texture
SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv) + LinearEyeDepth(d)SampleSceneDepth(uv)(DeclareDepthTexture.hlsl)+ LinearEyeDepth(d, _ZBufferParams)需开启 Depth Texture
#pragma surface surf Standard不支持表面着色器手写顶点/片元,或用 Shader Graph(第 24 章)
Fallback / UsePass 借阴影自己写 ShadowCaster、DepthOnly Pass案例 3.7
_Time、_WorldSpaceCameraPos、_ScreenParams同名,含义不变URP 另有 _TimeParameters(t, sin t, cos t)

3.6 常见编译错误排查

为了给出准确的报错原文,我们在配套工程里临时写了一批“故意写错”的 Shader,在 Unity 2022.3.62f3 + Metal 下编译,收集了 Console 里的真实输出。遇到同样的报错时,可以直接对号入座:

报错原文原因与修法
undeclared identifier 'UnityObjectToClipPos'用了 Built-in 的函数。按 3.5 节对照表替换成 URP 函数
redefinition of 'SafePositivePow_half'(以及一长串 redefinition)同时 include 了 UnityCG.cginc 和 URP 的 Core.hlsl。删掉 UnityCG,或检查是否误用了 CGPROGRAM(它会隐式包含 Built-in 头文件)
'vert': function return value missing semantics
'frag': input parameter 'i' missing semantics
Varyings 里的成员没写语义,最常见的是忘了 : SV_POSITION
redefinition of '_BaseMap'include 了 SurfaceInput.hlsl(或 URP 的 xxxInput.hlsl),它已经声明过 _BaseMap,你又声明了一次。要么不 include 它,要么不要自己声明
cannot implicitly convert from 'const float3' to 'float4'例如 float4 x = TransformObjectToWorld(...):URP 的变换函数多数返回 float3,补全 w 或改变量类型
cannot implicitly convert from 'half3' to 'half4'片元返回 half4 却 return col.rgb;,改成 return half4(col.rgb, 1);
syntax error: unexpected token 'struct'上一个结构体定义结尾 } 后面漏了分号——报错行是“下一个”结构体,往上找
undeclared identifier 'sampler_MainTex'采样器名字和声明不一致(改了贴图名忘改采样器名)
unrecognized identifier 'fixed4'URP 没有 fixed 类型,换成 half4
undeclared identifier 'LerpWhiteTo'(位于 Shadows.hlsl)单独 include Shadows.hlsl 时缺 CommonMaterial.hlsl,见案例 3.7

读报错时注意两点。一是行号:报错行号对应的是 Unity 把 HLSLINCLUDE、include 文件拼接后的结果,有时会指向 include 文件内部(如 Shadows.hlsl(298)),这时要回头看自己在 include 之前漏了什么。二是只看第一条:一个语法错误常常引发一串连锁报错,修掉第一条再重新编译。

图 3-17“粉色材质”的经典场面。左:Built-in 管线的 Standard 材质放进 URP 工程,渲染成洋红色;右:同色的 URP Lit。URP 在画完不透明物体后,会把 LightMode 属于 Built-in 专用标签(ForwardBase、Vertex 等,见 RenderingUtils.cs 里的 m_LegacyShaderPassNames)的物体用 FallbackError 材质再画一遍,这就是洋红色的来源;它仍投下阴影,是因为 Standard 的 ShadowCaster Pass 标签与 URP 的相同

除了编译错误,还有几类“不报错但不对”的问题值得列一张检查单:

  • 物体不显示:LightMode 拼错;Deferred 下只有 UniversalForward(3.4 节);Queue 与 Renderer 的 Layer Mask / 队列范围不匹配。
  • 没有阴影、深度效果穿帮:缺 ShadowCaster / DepthOnly Pass(案例 3.7);URP Asset 里关了阴影或 Depth Texture。
  • SRP Batcher 不兼容:用案例 3.6 的工具查原因。
  • 脚本开了功能没反应:只 SetFloat 没 EnableKeyword;或打包后变体被剥离(案例 3.8)。
  • 颜色偏亮或偏暗:色彩空间问题——Color 属性会转线性、Vector 不会、顶点色不会(案例 3.3),第 9 章与第 13 章会系统讲线性工作流。

本章小结

  • URP Shader = ShaderLab 外壳 + HLSL 程序。SubShader 的 RenderPipeline 标签决定在哪个管线下使用,Pass 的 LightMode 决定在一帧的哪个阶段被画。
  • HLSL 里向量是一等公民:swizzle、逐分量运算、dot/cross/mul;语义把数据从网格经顶点着色器、插值器送到片元着色器。
  • 贴图用 TEXTURE2D/SAMPLER/SAMPLE_TEXTURE2D 宏,Tiling/Offset 存在 贴图名_ST 里,用 TRANSFORM_TEX 在顶点阶段应用。
  • clip() 实现镂空,配合 AlphaTest 队列和关键字开关;multi_compile_fog + ComputeFogFactor + MixFog 接入雾。
  • 所有材质属性放进 UnityPerMaterial、各 Pass 布局一致,才能兼容 SRP Batcher;HLSLINCLUDE 是保证一致的最简单办法。
  • 不透明物体要有 ShadowCaster(投影)和 DepthOnly(深度预渲染)Pass;Unlit 颜色 Pass 用 UniversalForwardOnly 以兼容 Deferred。
  • shader_feature 做材质开关、multi_compile 做全局状态,优先用 _local 与 _vertex/_fragment 后缀;脚本里要显式设置关键字。

练习

  1. 给案例 3.8 的 Shader 增加一个 [KeywordEnum(Mesh, WorldXZ, WorldXY)] 选项,实现竖直墙面用的 XY 投影,并想一想阴影 Pass 和深度 Pass 需要同步改哪些地方。
  2. 把案例 3.5 改成逐像素雾:顶点里传观察空间深度 positionVS.z,片元里参考 URP 的 InitializeInputDataFog 计算雾因子。在一块只有 4 个顶点、长 200 米的地面上比较两种写法。
  3. 在案例 3.6 的 CBUFFER 里用 #ifdef 包住 _Cutoff,用本章的检查工具观察兼容性变化,再改回来。
  4. 为案例 3.9 的 Shader 增加第二层贴图(如流动的云影),两层用不同的滚动速度相乘叠加,并保持 SRP Batcher 兼容。
  5. 用 Frame Debugger 观察开启 SSAO(Renderer Feature 里添加 Screen Space Ambient Occlusion,Source 选 Depth)前后,渲染流程里是否多出了 DepthPrepass,以及你的物体是否出现在其中。