Unity URP Shader 结构解析:ShaderLab、HLSLPROGRAM 与 CBUFFER
逐层拆解一个 URP 着色器文件的组成:ShaderLab 外壳、Properties 与材质面板、Pass 与 LightMode、URP 的头文件体系、UnityPerMaterial 与 SRP Batcher、宏与语义,以及 _Time、GetMainLight 这些每帧数据的来源。
本文目录
一个 URP 着色器文件里有几层东西
.shader 文件不是 HLSL。它是 Unity 自己的 ShaderLab 描述语言,里面嵌套着若干段真正的 HLSL。第一次接触 URP 着色器的人,报错信息经常来自 ShaderLab 层,而实际错误在 HLSL 层,反之亦然,所以先分清层次很重要:
Shader "分类/名字" <- ShaderLab
{
Properties { ... } <- ShaderLab:材质面板
SubShader <- ShaderLab:一组 Pass
{
Tags { ... } <- ShaderLab:渲染管线与队列匹配
Pass
{
Name / Tags <- ShaderLab:LightMode 决定这个 Pass 被谁调用
HLSLPROGRAM <- HLSL 开始
...
ENDHLSL <- HLSL 结束
}
}
FallBack "..." <- ShaderLab
}
Properties:材质面板与默认值
Properties
{
[MainTexture] _BaseMap("Base Map", 2D) = "white" {}
[MainColor] _BaseColor("Base Color", Color) = (1, 1, 1, 1)
_Smoothness("Smoothness", Range(0, 1)) = 0.5
[Normal] _NormalMap("Normal Map", 2D) = "bump" {}
[HDR] _RimColor("Rim Color", Color) = (1, 1, 1, 1)
[Toggle(_RIMLIGHT_ON)] _RimToggle("Enable Rim", Float) = 1
}
几个容易忽略的点:
[MainTexture]/[MainColor]是 URP 约定的标记,Material.GetTexture("_MainTex")这类通用 API 以及后处理、粒子系统取材质主色时依赖它。注意 URP 的主贴图叫_BaseMap而不是内置管线的_MainTex。2D属性的默认值写在引号里:"white"、"black"、"bump"(一张 (0.5, 0.5, 1, 1) 的平法线)、"gray"。[Normal]会让 Unity 在导入时提醒你把贴图的 sRGB 关掉——法线贴图是数据不是颜色。[HDR]允许颜色分量大于 1,配合 Bloom 使用;但要注意它并不改变存储精度,只是解除了面板上的 0~1 限制。[Toggle(KEYWORD)]会生成一个shader_feature关键字,编译出两个变体。变体数量是乘法关系,滥用会显著拖慢构建和运行时。
SubShader 与 Tags
SubShader
{
Tags
{
"RenderType" = "Opaque"
"RenderPipeline" = "UniversalPipeline"
"Queue" = "Geometry"
"IgnoreProjector" = "True"
}
LOD 300
...
}
"RenderPipeline" = "UniversalPipeline"是这个 SubShader 只在 URP 下生效的开关。漏掉它,在 URP 项目里可能仍然被渲染,但行为不可预测。"Queue"决定绘制顺序:Background(1000) /Geometry(2000) /AlphaTest(2450) /Transparent(3000) /Overlay(4000)。透明物体必须放在Transparent且通常要ZWrite Off。"RenderType"是给替换渲染(replacement shader)与相机深度纹理用的分类标签,和Queue不是一回事。LOD配合Shader.maximumLOD做画质分级。
Pass 的 LightMode
Pass
{
Name "ForwardLit"
Tags { "LightMode" = "UniversalForward" }
...
}
URP 靠 LightMode 决定在哪个阶段调用哪个 Pass:
| LightMode | 用途 |
|---|---|
UniversalForward | 前向渲染主 Pass |
UniversalForwardOnly | 与延迟渲染共存时的前向 Pass |
UniversalGBuffer | 延迟渲染的 GBuffer Pass |
ShadowCaster | 投射阴影,输出深度 |
DepthOnly | 深度预 pass / 相机深度纹理 |
DepthNormals | 深度 + 法线纹理(SSAO、描边常用) |
Meta | 烘焙光照贴图时提取 albedo 与自发光 |
一个「完整」的 URP 着色器通常需要 UniversalForward + ShadowCaster + DepthOnly(可能还有 DepthNormals)。少了 ShadowCaster 就不会投影;少了 DepthOnly 在开启深度纹理或某些后处理时会消失。
HLSLPROGRAM 里的标准骨架
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 3.5
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE
#pragma multi_compile_fragment _ _SHADOWS_SOFT
#pragma multi_compile_fragment _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
CBUFFER_START(UnityPerMaterial)
half4 _BaseMap_ST;
half4 _BaseColor;
half _Smoothness;
CBUFFER_END
struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; };
struct Varyings { float4 positionCS : SV_POSITION; float3 positionWS : TEXCOORD0; float3 normalWS : TEXCOORD1; float2 uv : TEXCOORD2; };
ENDHLSL
头文件层次
Core.hlsl:空间变换(TransformObjectToWorld、TransformWorldToHClip、TransformObjectToWorldNormal)、GetVertexPositionInputs/GetVertexNormalInputs、平台无关的纹理宏、_Time等内建变量声明。Lighting.hlsl:GetMainLight()、GetAdditionalLight()、LightingPhysicallyBased、UniversalFragmentPBR、BRDF 数据结构的初始化。Shadows.hlsl:TransformWorldToShadowCoord、阴影采样与关键字处理。SurfaceInput.hlsl:_BaseMap/_BaseColor的标准声明与SampleAlbedoAlpha等辅助函数(写 Lit 类着色器时直接包含它更省事)。
包含顺序有依赖关系,Core.hlsl 必须在 Lighting.hlsl 之前。
纹理宏
不要直接写 sampler2D _BaseMap; 和 tex2D(...)。跨平台(DX11/DX12、Metal、Vulkan、GLES)的写法是:
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
half4 c = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv);
配套的还有 TEXTURE2D_ARRAY / SAMPLE_TEXTURE2D_LOD / SAMPLE_TEXTURE2D_BIAS 等。分离 texture 与 sampler 是现代图形 API 的要求,老写法在部分平台上会编译失败或性能下降。
TRANSFORM_TEX
OUT.uv = TRANSFORM_TEX(IN.uv, _BaseMap);
它展开成 IN.uv * _BaseMap_ST.xy + _BaseMap_ST.zw,因此 CBUFFER 里必须声明 half4 _BaseMap_ST;,否则 tiling/offset 面板改了没反应。
CBUFFER 与 SRP Batcher
CBUFFER_START(UnityPerMaterial)
half4 _BaseColor;
half _Smoothness;
CBUFFER_END
规则很严格:
- 所有材质属性必须在同一个名为
UnityPerMaterial的 CBUFFER 里; - 一个着色器的所有 Pass 里,这个 CBUFFER 的布局必须完全一致(顺序、类型、成员都要一样);
- 不能把非材质属性(例如每帧变化的光照数据)放进去。
满足这三条,URP 的 SRP Batcher 才能把材质数据常驻在 GPU 上,切换材质时只更新一个很小的偏移,从而把大量 draw call 合并成一次批处理。在 Frame Debugger 里能看到 SRP Batch 字样就说明生效了。
一个常见的坑:不同 Pass 引用了不同的 .hlsl 输入文件,导致 UnityPerMaterial 布局不一致,SRP Batcher 静默失效,性能掉下来却没有任何报错。
语义(semantics)
HLSL 靠语义把结构和管线阶段对接:
- 输入:
POSITION、NORMAL、TANGENT、TEXCOORD0..n、COLOR; - 顶点输出 / 片元输入:
TEXCOORD0..n(只是编号,和真实 UV 无关)、SV_POSITION(裁剪空间坐标,在片元阶段变成像素坐标)、COLOR0..n; - 片元输出:
SV_Target(可写SV_Target0..n做 MRT)、SV_Depth。
后缀约定也很重要:positionOS(object space)、positionWS(world)、positionVS(view)、positionCS(clip)、positionNDC(归一化设备坐标)。URP 的代码通篇用这套命名,跟着写能少很多空间搞混的错误。
每帧数据从哪里来
_Time:float4(t/20, t, t*2, t*3),所以动画一般用_Time.y(秒)。它是不可暂停的,编辑器暂停播放时依然推进。_WorldSpaceCameraPos:相机世界坐标。_MainLightPosition/_MainLightColor:由 URP 每帧填充,通过GetMainLight()读取。方向光时xyz是「从表面指向光源」的方向,w是距离衰减标志。GetMainLight(shadowCoord)会额外返回shadowAttenuation与distanceAttenuation。- 附加光源:
GetAdditionalLight(index, positionWS),配合_ADDITIONAL_LIGHTS关键字与GetPerObjectLightIndex。 - 环境光 / 探针:
SampleSH(normalWS),这是 URP 里漫反射间接光的来源。
这一整套数据在浏览器里都不存在。 这正是「把 URP 着色器搬到 WebGL」最麻烦的地方:不是语法翻译,而是要为这些每帧数据找替代来源。本站的 URP RimLit 移植示例 把它们全部提升为显式 uniform,并给出了逐项映射表和无法一致的部分。
half 还是 float
移动端 GPU(尤其是 Mali、Adreno)上 half 真的只有 16 位,能显著降低寄存器压力和带宽;桌面 GPU 上 half 会被当作 float,没有性能收益。URP 的官方着色器大量使用 half,并且对位置、UV 这类需要高精度的量使用 float。经验法则:
- 坐标、UV、
SV_POSITION:float; - 颜色、法线、光照结果、材质参数:
half; - 时间相关的大数值(例如
_Time.y累积到几千秒)参与计算时要小心,half的最大值只有 65504,且精度在小数点后迅速衰减。
变体与编译时间
multi_compile 会为每个组合生成一个变体,shader_feature 只为项目中实际用到的组合生成。经验做法:
- 与全局渲染状态相关的(阴影、雾、附加光、Forward+)用
multi_compile,因为它们可能在运行时切换; - 与材质本身相关的(某个开关、某个模式)用
shader_feature_local,避免污染全局关键字空间。
在 Shader 面板的 “Compile and show code” 里可以直接看到变体数量和编译结果,这是排查「某个平台跑不起来」的第一站。