第一个 URP Shader
从一个只会输出纯色的十几行 Shader 出发,每一步只加一样东西:纹理、顶点色、透明度裁剪、雾、SRP Batcher、阴影与深度、关键字开关。九步走完,你手里就有一个能放进正式项目的 Unlit Shader,也认识了 URP Shader 的每一块骨头。
上一章我们从“管线”的高度看了 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 "路径/名字":名字里的斜杠会变成材质 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 | 谁会画它 | 本章用途 |
|---|---|---|
| UniversalForward | Forward / 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 起 |
3.2 HLSL 基础:类型、向量与语义
HLSL 的语法和 C 很像:有结构体、函数、if/for,以分号结尾。真正需要适应的是三件事:向量是一等公民、精度要自己选、数据靠“语义”在阶段之间流动。
标量、向量与矩阵
| 类型 | 含义 | 说明 |
|---|---|---|
| float | 32 位浮点 | 位置、UV、深度等对精度敏感的量一律用它 |
| half | 16 位浮点(移动端) | 颜色、法线、方向等范围小的量。URP 的 Common.hlsl 在移动平台上把 half 定义为 min16float;桌面平台上它通常就按 32 位算 |
| int / uint | 32 位整数 | 循环计数、位运算(第 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 这个变量对应哪一路数据。
| 语义 | 用在 | 含义 |
|---|---|---|
| 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 | = 1 | float _X;(Int 属性在底层也是浮点,需要整数时自己转换) |
| Range(min, max) | = 0.5 | float 或 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 就是这样让“表面类型”“双面”在材质面板上可调的。
material.SetFloat 或 Shader.SetGlobalFloat 赋值。反过来,Properties 里有、HLSL 里没用到的属性也没关系,只是面板上多一项。但要注意:凡是出现在 Properties 里、又在 HLSL 里用到的属性,都必须放进 UnityPerMaterial,否则 SRP Batcher 会拒绝这个 Shader——案例 3.6 会看到工具给出的原话。案例 3.1最小可运行:纯色 Unlit入门单独打开 ↗
目标:写出能在 URP 下编译、把物体涂成一个纯色的 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 对包内文件的虚拟路径,不是硬盘上的真实路径。
// 顶点着色器的输入:由网格提供,语义 POSITION 告诉 GPU 取哪一路顶点数据 struct Attributes { float4 positionOS : POSITION; // OS = Object Space,模型空间 }; // 顶点 → 片元的数据:SV_POSITION 是光栅化必须的齐次裁剪空间坐标 struct Varyings { float4 positionCS : SV_POSITION; // CS = Clip Space,裁剪空间 };
两个结构体对应图 3-2 的左右两半。positionOS 用 float4 接收,网格提供的是三维坐标,GPU 会自动把 w 补成 1。
Varyings vert (Attributes input) { Varyings output; // 模型空间 → 世界空间 → 观察空间 → 裁剪空间,一次做完 output.positionCS = TransformObjectToHClip(input.positionOS.xyz); return output; } half4 frag (Varyings input) : SV_Target { return _BaseColor; }
顶点着色器只做一件事:把模型空间坐标变换到裁剪空间。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 表示“写到当前渲染目标”。

还有一处故意的“不规范”: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 是渲染目标的宽高。案例 3.2纹理采样与 Tiling/Offset入门单独打开 ↗
目标:给物体贴一张图,并让材质面板上的 Tiling、Offset 生效。
// 第 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、贴图的声明与采样。
// 纹理对象和采样器分开声明(DX11 风格),宏会替各平台展开成正确的写法 TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); // 命名为 sampler + 纹理名,Unity 会自动使用该纹理的过滤/寻址设置 float4 _BaseMap_ST; // Unity 自动填充:xy = Tiling,zw = Offset half4 _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)
为什么在顶点着色器里做而不是片元里?因为它是 UV 的线性变换,在三角形内插值前做和插值后做结果完全一样,放在顶点里每个顶点算一次,比每个像素算一次便宜。这是一条通用原则:对插值结果线性的计算,尽量前移到顶点阶段。

[MainTexture] 后,C# 的 material.mainTexture、material.mainTextureScale 就会操作 _BaseMap;SpriteRenderer、UI 等组件读取“主贴图”时也认这个标记。案例 3.3顶点色入门单独打开 ↗
目标:读取网格自带的顶点色,与贴图和颜色相乘。顶点色是最便宜的“逐顶点数据通道”:美术可以在 DCC 软件里刷上 AO、渐变、草的摆动权重、溶解遮罩,第 17 章的植被风动就会用它。
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),乘上去不改变结果,所以这个 Shader 对没有顶点色的模型也是安全的。
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 这一族函数画成曲线细讲。

Color.hlsl 里的 SRGBToLinear 转一下;当作权重、遮罩这类“数据”用时则不需要转换。案例 3.4透明度裁剪 Alpha Clip入门单独打开 ↗
目标:让贴图 alpha 低于阈值的像素“消失”,做出镂空的树叶、栅栏、铁丝网。
// 第 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函数处理这件事。

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 响应 的设置,远处的物体逐渐融入雾色。
URP 支持 Built-in 时代的三种雾模式。设物体到相机的深度为 z(沿视线方向的距离),雾因子 f 表示“保留多少原色”(1 = 完全不受雾影响,0 = 完全是雾色):
落实到代码只需要三处改动:
multi_compile_fog 是 Unity 提供的快捷写法,等价于 #pragma multi_compile _ FOG_LINEAR FOG_EXP FOG_EXP2,编译出“无雾 + 三种雾”四个变体,运行时引擎根据 Lighting 窗口的设置自动启用对应的关键字。“关键字”与“变体”的完整解释在案例 3.8,这里先知道它在起作用即可。
struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; half4 color : COLOR; float fogFactor : TEXCOORD1; // 每个顶点算一次雾因子,交给插值器 };
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 实现):指数函数不是线性的,先插值“指数”再求指数,比直接插值指数结果更准确。

ShaderVariablesFunctions.hlsl 里有一行 #define _FOG_FRAGMENT 1,URP 自带的 Unlit/Lit 因此会把观察空间深度传到片元、在片元里逐像素计算雾因子(InitializeInputDataFog)。逐像素更精确,尤其是对很大的三角形(比如一整块地面只有几个顶点时);逐顶点更省。本书的 Unlit 采用逐顶点因子,它对普通密度的网格足够了;如果你在大面片上看到雾的“条带”,就换成逐像素的写法。案例 3.6兼容 SRP Batcher入门单独打开 ↗
目标:把材质属性放进 UnityPerMaterial 常量缓冲区,让这个 Shader 能被 SRP Batcher 加速;并写一个小工具批量检查兼容性。
先简单回顾 SRP Batcher 做了什么(原理见第 2 章)。传统流程里,每画一个物体,CPU 都要把这个物体的变换矩阵和材质参数重新打包、上传给 GPU,Draw Call 一多,CPU 就耗在这些“设置状态”的工作上。SRP Batcher 的思路是:每个材质的参数常驻在 GPU 显存里的一块固定区域,只有材质被修改时才更新;每个物体的矩阵等数据也放在一块大缓冲区里。画连续多个使用同一个 Shader 变体的物体时,CPU 只需要告诉 GPU“这次用第几块材质数据、第几块物体数据”,即使它们的材质各不相同。
要做到“每个材质一块固定布局的数据”,Shader 必须满足两个条件:
- 所有引擎内置的逐物体变量(
unity_ObjectToWorld等)在名为UnityPerDraw的常量缓冲区里。这一条 URP 的Core.hlsl(经由UnityInput.hlsl)已经替你做好了。 - 所有材质属性放在名为
UnityPerMaterial的常量缓冲区里,而且同一个 SubShader 的所有 Pass、所有变体里,这个缓冲区的内容和顺序必须完全一致。这一条要你自己保证。
// 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 内部调用的两个函数可以通过反射拿到:
// 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 文件夹,执行 ,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.7投射阴影与写入深度:ShadowCaster 与 DepthOnly进阶单独打开 ↗
目标:让物体能投射阴影(包括镂空形状的阴影),并能出现在深度图 _CameraDepthTexture 里。
到上一步为止,我们的 Shader 只有一个画颜色的 Pass。URP 在一帧里还会做别的绘制:渲染阴影图时,会以光源为“相机”把场景画一遍,只找带 ShadowCaster 标签的 Pass;需要深度预渲染(Depth Prepass)时,会再画一遍深度,只找 DepthOnly 标签的 Pass。一个 Shader 没有这些 Pass,物体就既不投影,也不会出现在预渲染的深度图里——不会报错,只是“悄悄缺席”。
ShadowCaster Pass
// ---------------- 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。
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,正如上面代码里那样。
DepthOnly Pass
// ---------------- 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 来强制触发预渲染:
// 演示用 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 章会系统讲。

Cookbook/Ch03/Debug/DepthView 把 _CameraDepthTexture 显示为灰度(越近越亮)——橙球在深度图里不存在深度图里缺了物体,后果是所有依赖深度的效果都会对它“视而不见”:SSAO 在它周围不产生遮蔽,软粒子、水面的深度交界泡沫、第 18 章护盾的交接高亮都会穿帮。所以只要是不透明物体,就给它配上 DepthOnly Pass。另一个相关的 Pass 是 DepthNormals,它同时输出法线,供 SSAO 等效果使用(URP 的 SSAO 可选择从深度重建法线,也可以要求 DepthNormals),它需要顶点法线,放到第 6 章与光照一起写。
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)标记,编译时把每种关键字组合各编译一份,运行时按材质上启用的关键字挑选对应的那份。
// [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 } } }
// 材质开关: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_compile | shader_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 章。
// 根据关键字选择 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
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)会解决这个问题。

_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 仍然工作,于是你会看到“只有影子没有物体”的灵异现象:

结论很简单: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 工程里不能正确工作,仅作对照):
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 写法:
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 }
除了逐行替换,还有四处“结构性”改动:
- SubShader 加上
RenderPipeline标签,Pass 加上 LightMode(Unlit 用UniversalForwardOnly,见 3.4 节)。 - 属性改名为
_BaseMap/_BaseColor并加[MainTexture]/[MainColor]。这不是语法要求,但遵循 URP 命名后,材质在 URP 自带 Shader 与你的 Shader 之间切换时贴图和颜色能自动保留,URP 的材质升级工具和许多插件也按这个名字找属性。如果项目里有大量旧材质,也可以保留_MainTex这个名字,换取零迁移成本——两种选择都可以,团队统一即可。 - 所有属性进
UnityPerMaterial,放在 HLSLINCLUDE 里供三个 Pass 共享。 - 删掉 Fallback,手写 ShadowCaster 和 DepthOnly。Built-in 版本靠 Fallback 借用别的 Shader 的阴影 Pass;URP 下应该自己提供。
另外,为了能在书里拍静帧,我们加了一个 _ScrollOffset 参数:_Time 在编辑器截图时接近 0,用它可以手动指定“滚到哪了”。实际使用时 _ScrollSpeed 驱动自动滚动,_ScrollOffset 也可以交给动画曲线或脚本控制,做出“按事件推进”的传送带。

_ScrollOffset.x 分别为 0、0.25、0.5,贴图沿 U 方向绕圆柱滚动3.5 Built-in → URP 写法对照表
下表汇总了迁移时最常遇到的替换。函数名都已在 URP 14.0.12 / core 14.0.12 源码中核对过;附录 D 给出了更完整的版本。
| Built-in | URP 14 | 备注 |
|---|---|---|
CGPROGRAM … ENDCG | HLSLPROGRAM … ENDHLSL | CGPROGRAM 会自动包含 Built-in 的头文件 |
CGINCLUDE … ENDCG | HLSLINCLUDE … ENDHLSL | |
#include "UnityCG.cginc" | #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" | 二者不能同时包含,会大量重定义 |
Lighting.cginc / AutoLight.cginc | ShaderLibrary/Lighting.hlsl / Shadows.hlsl | 第 5、6 章 |
fixed / fixed4 | half / half4 | |
UnityObjectToClipPos(v.vertex) | TransformObjectToHClip(v.positionOS.xyz) | 参数是 float3 |
mul(unity_ObjectToWorld, v.vertex).xyz | TransformObjectToWorld(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_FOG | float 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、_WorldSpaceLightPos0 | Light 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 之前漏了什么。二是只看第一条:一个语法错误常常引发一串连锁报错,修掉第一条再重新编译。

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