Unity Custom Interpolators与半透明阴影的原理与实战

深入剖析 URP 渲染管线中两个容易被忽略的关键问题: 插值寄存器(Interpolator)的数量瓶颈与打包技巧,以及半透明阴影的底层限制与三种可用的 workaround。 本文包含完整的 HLSL 代码示例与原理示意图。

Part 01Custom Interpolators

插值寄存器是什么?

在 GPU 渲染管线中,顶点着色器(Vertex Shader)与片元着色器(Fragment Shader) 之间有一段固定宽度的数据通道------即插值寄存器(Interpolator / Varying)。 顶点阶段写入的每一个值,GPU 在光栅化时会对三角形面片做重心插值, 最终将插好的结果传递给每个片元。

ℹ️

硬件限制: DirectX Shader Model 4/5(对应 PC 桌面端)定义了最多 16 个 float4 的插值语义(TEXCOORD0 ~ TEXCOORD15); 移动端 OpenGL ES 3.0 通常只有 8 个,部分 Mali/PowerVR 芯片更少。 超出限制会直接导致编译报错或运行时黑屏。

HLSL/GLSL 里,插值语义通常写在结构体的字段上:

Part 02Pack / Unpack

寄存器打包技术

当需要传递的数据量接近或超过寄存器上限时, 最常用的手段是将多个语义相近、精度要求低于 float4 的数据 打包进同一个 float4xyzw 分量, 在片元着色器再按约定拆包。

典型打包组合

⚠️

**精度注意:**打包前请确认分量的值域。UV 通常在 [0, 1],法线分量在 [-1, 1], 顶点色在 [0, 1]------这些都可以安全共存于同一 float4,不会相互干扰。 但若有数量级差距(如世界坐标 vs. UV),不建议强行打包。

Part 03HLSL 代码

完整 Pack / Unpack 实现

① 顶点输出结构(Varyings)

把原本需要 5 个语义的数据,压缩到 3 个 float4 中:

cs 复制代码
// 精简后的顶点输出结构,节省插值寄存器
struct Varyings
{
    float4 positionCS   : SV_POSITION;  // 裁剪空间坐标(系统语义,不占 TEXCOORD)
    float4 packed0      : TEXCOORD0;   // .xyz = normal(OS)   .w = uv1.x
    float4 packed1      : TEXCOORD1;   // .xy  = uv1.y/uv2.x  .zw = uv2.y/tangentSign
    float4 packed2      : TEXCOORD2;   // .xyzw = vertexColor
    // 如需世界坐标,再加一个:
    float3 positionWS   : TEXCOORD3;   // 世界坐标(光照计算用)
};  // 共 4 个 float4 + SV_POSITION,比原始方案节省约 3 个寄存器

② 顶点着色器:打包写入

cs 复制代码
Varyings LitPassVertex(Attributes input)
{
    Varyings output = (Varyings)0;
    // ── 基础变换 ──────────────────────────────
    VertexPositionInputs posInput = GetVertexPositionInputs(input.positionOS);
    output.positionCS  = posInput.positionCS;
    output.positionWS  = posInput.positionWS;
    // ── PACK: packed0 --- 法线 xyz + UV1.x ─────
    float3 normalOS     = TransformObjectToWorldNormal(input.normalOS);
    output.packed0.xyz = normalOS;                   // 法线 x y z → .xyz
    output.packed0.w   = input.texcoord.x;           // UV1.x     → .w
    // ── PACK: packed1 --- UV1.y / UV2 / 切线符号 ─
    output.packed1.x   = input.texcoord.y;           // UV1.y     → .x
    output.packed1.yz  = input.texcoord2.xy;          // UV2.xy    → .yz
    output.packed1.w   = input.tangentOS.w;           // 切线手性 → .w (值为 ±1)
    // ── PACK: packed2 --- 顶点色 ────────────────
    output.packed2      = input.color;               // rgba → xyzw(直接赋值)
    return output;
}

③ 片元着色器:解包读取

cs 复制代码
half4 LitPassFragment(Varyings input) : SV_Target
{
    // ── UNPACK packed0 ───────────────────────
    float3 normalWS    = normalize(input.packed0.xyz);  // 插值后重新归一化!
    float2 uv1         = float2(input.packed0.w,   // UV1.x 来自 packed0.w
                                  input.packed1.x);  // UV1.y 来自 packed1.x
    // ── UNPACK packed1 ───────────────────────
    float2 uv2         = input.packed1.yz;
    float  tangentSign = input.packed1.w;          // ±1,用于重建副法线
    // ── UNPACK packed2 ───────────────────────
    half4  vertexColor = half4(input.packed2);       // xyzw → rgba
    // ── 后续正常使用 ──────────────────────────
    half4 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv1);
    albedo.rgb *= vertexColor.rgb;  // 与顶点色相乘
    // ... 其余光照计算
    return albedo;
}

💡

插值后必须 normalize: 法线在光栅化阶段被线性插值,结果不再是单位向量。 在片元着色器中读取后,必须调用 normalize() 才能用于光照计算,否则会出现亮度异常。


Part 04半透明阴影

URP 半透明阴影的底层限制

Unity URP 的阴影系统基于 Shadow Map 技术: 在主光源方向渲染一张深度贴图(Shadow Map), 随后在正常渲染通道中把当前像素的深度与 Shadow Map 对比,判断是否在阴影中。

URP 的 ShadowCaster Pass 要求写入深度(ZWrite On), 而半透明渲染通道通常关闭深度写入(ZWrite Off)并依赖 Alpha Blend。 两者的技术前提本质冲突:

属性 不透明物体 半透明物体 兼容性
ZWrite On Off 不兼容
Blend Mode Off(完全替换) SrcAlpha OneMinusSrcAlpha 不兼容
ShadowCaster Pass 自带,正常工作 缺失或禁用 需手动添加
渲染队列 Geometry (2000) Transparent (3000) 顺序依赖

Workaround 1Alpha Test + Alpha-to-Coverage

Alpha Test + Alpha-to-Coverage

这是最常见也是效果最自然的方案。核心思路: 不走透明混合,改走裁剪(Clip), 让物体仍属于不透明渲染队列,可以正常写入深度与阴影。

Alpha Test 工作原理

在片元着色器中调用 clip(alpha - _Cutoff), 当 alpha 低于阈值时丢弃当前片元(相当于完全透明), 高于阈值时当作完全不透明处理。 渲染队列设为 AlphaTest(2450),仍走 ZWrite。

Alpha-to-Coverage(MSAA 模式)

Alpha Test 的硬边缘会产生明显的锯齿,开启 MSAA 后可搭配 [AlphaToMask On] 利用 MSAA 的多重采样点来模拟平滑边缘,效果接近半透明。

cs 复制代码
URP 的 ShadowCaster Pass 要求写入深度(ZWrite On), 而半透明渲染通道通常关闭深度写入(ZWrite Off)并依赖 Alpha Blend。 两者的技术前提本质冲突:

属性	不透明物体	半透明物体	兼容性
ZWrite	On	Off	不兼容
Blend Mode	Off(完全替换)	SrcAlpha OneMinusSrcAlpha	不兼容
ShadowCaster Pass	自带,正常工作	缺失或禁用	需手动添加
渲染队列	Geometry (2000)	Transparent (3000)	顺序依赖
Workaround 1
Alpha Test + Alpha-to-Coverage
Alpha Test + Alpha-to-Coverage
这是最常见也是效果最自然的方案。核心思路: 不走透明混合,改走裁剪(Clip), 让物体仍属于不透明渲染队列,可以正常写入深度与阴影。

Alpha Test 工作原理
在片元着色器中调用 clip(alpha - _Cutoff), 当 alpha 低于阈值时丢弃当前片元(相当于完全透明), 高于阈值时当作完全不透明处理。 渲染队列设为 AlphaTest(2450),仍走 ZWrite。

Alpha-to-Coverage(MSAA 模式)
Alpha Test 的硬边缘会产生明显的锯齿,开启 MSAA 后可搭配 [AlphaToMask On] 利用 MSAA 的多重采样点来模拟平滑边缘,效果接近半透明。

AlphaTestShadow.shader
ShaderLab
Shader "Custom/AlphaTestWithShadow"
{
    Properties
    {
        _BaseMap  ("Albedo", 2D) = "white" {}
        _Cutoff   ("Alpha Cutoff", Range(0,1)) = 0.5
    }
    SubShader
    {
        // 关键:渲染队列仍是 AlphaTest,属于不透明队列
        Tags { "RenderType"="TransparentCutout" "Queue"="AlphaTest" }
        Pass
        {
            AlphaToMask On   // 需要 MSAA;开启后边缘更平滑
            ZWrite On       // 写入深度 → ShadowMap 可用
            // ... HLSLPROGRAM ...
        }
        // ShadowCaster Pass:也要做 clip,否则镂空处会投射实心阴影
        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode" = "ShadowCaster" }
            ZWrite On
            HLSLPROGRAM
            // 在 frag 里执行相同的 clip:
            half alpha = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv).a;
            clip(alpha - _Cutoff);  // 镂空区域不写深度 → 阴影形状正确
            ENDHLSL
        }
    }
}

Workaround 2Dither 透明 + 阴影

Dithering 抖动透明

Dithering(有序抖动)的思路:用空间上的像素开关 来模拟视觉透明度------某个区域 50% 的像素被 clip 掉, 远看就像 50% 透明,同时每个未被裁剪的像素仍然是完全不透明的, 可以正常写入深度和阴影。

常用的抖动矩阵是 4×4 Bayer 矩阵, 将屏幕坐标对 4 取余得到矩阵索引,再与 Alpha 比较决定是否 clip:

cs 复制代码
Workaround 2
Dither 透明 + 阴影
Dithering 抖动透明
Dithering(有序抖动)的思路:用空间上的像素开关 来模拟视觉透明度------某个区域 50% 的像素被 clip 掉, 远看就像 50% 透明,同时每个未被裁剪的像素仍然是完全不透明的, 可以正常写入深度和阴影。

常用的抖动矩阵是 4×4 Bayer 矩阵, 将屏幕坐标对 4 取余得到矩阵索引,再与 Alpha 比较决定是否 clip:

DitherTransparent.hlsl
HLSL
// ── 4×4 Bayer 有序抖动矩阵 ─────────────────────────────────────
static const float BayerMatrix4x4[4][4] =
{
    { 0.0/16.0,  8.0/16.0,  2.0/16.0, 10.0/16.0 },
    { 12.0/16.0,  4.0/16.0, 14.0/16.0,  6.0/16.0 },
    {  3.0/16.0, 11.0/16.0,  1.0/16.0,  9.0/16.0 },
    { 15.0/16.0,  7.0/16.0, 13.0/16.0,  5.0/16.0 },
};
// ── 片元着色器中的抖动裁剪 ──────────────────────────────────────
half4 DitherFrag(Varyings input) : SV_Target
{
    half4 color  = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
    half  alpha  = color.a * _Color.a;
    // 取屏幕坐标(像素整数坐标),对 4 取余得矩阵索引
    uint2 pixelCoord = (uint2)input.positionCS.xy;
    float threshold  = BayerMatrix4x4[pixelCoord.x % 4][pixelCoord.y % 4];
    // alpha < 矩阵阈值 → clip 掉;剩余像素完全不透明
    clip(alpha - threshold);
    return half4(color.rgb, 1.0);  // 输出不透明颜色
}

ℹ️

ShadowCaster Pass 同理: 在 ShadowCaster 的片元着色器里 执行完全相同的抖动裁剪,阴影边缘就会与物体本身的抖动图案一致, 产生视觉上"半透明阴影"的效果。

Dithering 效果的视觉示意

Part 07方案选型

方案对比与选型建议

方案 适用场景 阴影质量 性能 依赖
Alpha Test 硬边裁剪 树叶、铁丝网、布料镂空 良好 极低
Alpha Test + A2C 平滑边缘 植被、草丛、头发(MSAA 场景) 优秀 MSAA 开启
Dithering 渐变透明 幽灵、全息、渐隐特效 可接受 建议 TAA/高分辨率
原生透明 Alpha Blend 玻璃、水面、UI 元素 无阴影 ---

决策流程

  • 1

    确认是否需要阴影

    纯 UI 元素、粒子特效通常不需要投射阴影,直接用 Alpha Blend 即可,性能最佳。

  • 2

    判断透明类型

    如果边缘是硬裁剪型 (树叶、镂空图案)→ Alpha Test; 如果是渐变透明(幽灵效果、消散动画)→ Dithering。

  • 3

    检查渲染管线配置

    项目开启了 MSAA?→ 在 Alpha Test 基础上加 AlphaToMask On,边缘质量大幅提升。 使用 TAA 或 DLSS?→ Dithering 的噪点会被时域积累抑制,效果更佳。

  • 4

    ShadowCaster Pass 别忘了同步

    无论选哪种方案,ShadowCaster Pass 里必须执行相同的裁剪逻辑, 否则会出现"物体透明但阴影是实心"的穿帮效果。

相关推荐
晴夏。3 小时前
UE5第三人称模板实现及相关引擎源码分析
unity·ue5·游戏引擎·ue
HAPPY酷3 小时前
解决 Unreal Engine 编译报错 MSB4018:三个核心排查方向
游戏引擎·虚幻
晴夏。7 小时前
UE原生MovementBase实现分析
游戏引擎·ue·3c
天人合一peng8 小时前
Unity工程发布hololens需安装, MRTK安装
unity·游戏引擎·hololens
weixin_409383129 小时前
godot 调用class方法得用实例 不能用脚本引用
游戏引擎·godot
风酥糖9 小时前
Godot游戏练习01-第32节-国际化
游戏·游戏引擎·godot
魔士于安10 小时前
Unity类似博物馆场景
前端·unity·游戏引擎·贴图·模型
小拉达不是臭老鼠10 小时前
Unity数据持久化_XML
学习·unity
RReality10 小时前
【Unity Shader URP】模板遮罩 / 传送门 实战教程
ui·unity·游戏引擎·图形渲染·材质