跳到主要内容
Unity URP 与 HLSL 约 6 分钟

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

规则很严格:

  1. 所有材质属性必须在同一个名为 UnityPerMaterial 的 CBUFFER 里;
  2. 一个着色器的所有 Pass 里,这个 CBUFFER 的布局必须完全一致(顺序、类型、成员都要一样);
  3. 不能把非材质属性(例如每帧变化的光照数据)放进去。

满足这三条,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” 里可以直接看到变体数量和编译结果,这是排查「某个平台跑不起来」的第一站。