Unity 单通道立体渲染(Single Pass Instanced)对 Shader 顶点布局的特殊要求

深入理解 Unity XR 渲染管线中,实例化立体渲染如何改变顶点数据的传递方式,以及你的 Shader 必须做出的适配。

1. 为什么需要单通道实例化渲染

在 AR/VR 应用中,左右眼需要各自看到略有不同的画面(视差效果)。最朴素的做法是分别渲染两遍场景------这就是"多通道渲染(Multi-Pass)"。但多通道渲染意味着 Draw Call 数量翻倍,CPU 开销直接 ×2,对移动端 XR 设备来说难以承受。

单通道实例化渲染(Single Pass Instanced Rendering, SPI) 是 Unity 为 XR 场景提供的核心优化方案:在一次 Draw Call 中,利用 GPU Instancing 同时渲染左右两眼的数据。每个实例(Instance)对应一只眼睛,GPU 通过 SV_InstanceID 区分当前绘制的是哪只眼,从而选取对应的 View / Projection 矩阵。

💡 关键认知

一旦启用 SPI,GPU 就不再是"每个顶点做一次 MVP 变换",而是**"每个实例的每个顶点做一次变换"**。你的 Shader 必须显式处理实例化索引,才能拿到正确的矩阵。

2. 渲染模式对比:多通道 vs 单通道实例化

对比维度 多通道 (Multi-Pass) 单通道实例化 (SPI)
Draw Call 数量 N × 2(每只眼各一次) N × 1(一次提交两眼)
CPU 提交开销 高,翻倍 低,减半
顶点变换次数 每个顶点 1 次(但跑两遍) 每个顶点 2 次(实例化并行)
Shader 兼容性 无需修改,通用 必须添加实例化宏
VRAM 带宽 顶点数据读取两遍 顶点数据复用,带宽更优

3. 核心机制:SV_InstanceID 与立体眼索引

要理解 SPI 对 Shader 的要求,首先要理解 GPU Instancing 的底层工作方式。

3.1 普通渲染的顶点流程

在没有实例化的情况下,顶点着色器接收的数据很简单:

cs 复制代码
struct appdata

{

    float4 vertex : POSITION;      // 模型空间顶点位置

    float3 normal : NORMAL;        // 法线

    float2 uv     : TEXCOORD0;     // 纹理坐标

};

每个顶点独立地通过 MVP 矩阵变换到裁剪空间。Shader 不需要知道"我是第几次被绘制的"。

3.2 SPI 模式下的变化

启用 SPI 后,Unity 会要求每个 Draw Call 绘制 2 个实例 (左眼 = Instance 0,右眼 = Instance 1)。GPU 自动为每个顶点注入一个 SV_InstanceID 值。

⚠️ 关键点

SV_InstanceID 不会自动出现在你的 Shader 中。你必须在顶点结构体中显式声明 一个字段来接收它,这就是 UNITY_VERTEX_INPUT_INSTANCE_ID 宏的作用。

3.3 宏展开:UNITY_VERTEX_INPUT_INSTANCE_ID 做了什么

Unity 提供了一套宏来封装实例化相关的细节。在 SPI 模式下(定义了 UNITY_STEREO_INSTANCING_ENABLED),UNITY_VERTEX_INPUT_INSTANCE_ID 会展开为:

cs 复制代码
// 当 UNITY_STEREO_INSTANCING_ENABLED 被定义时:

#define UNITY_VERTEX_INPUT_INSTANCE_ID \

    uint instanceID : SV_InstanceID


// 当未启用立体渲染时:

#define UNITY_VERTEX_INPUT_INSTANCE_ID

可以看到,在 SPI 模式下,它声明了一个 uint instanceID : SV_InstanceID 语义字段;在非 XR 模式下,它展开为空------这意味着你的 Shader 代码在普通平台和 XR 平台之间无需条件编译即可兼容。

4. 顶点着色器中的完整流程

在顶点着色器中,SPI 要求你完成三个关键步骤。下面用一个 URP Unlit Shader 的顶点函数来演示:

4.1 步骤一:在 appdata 中声明 InstanceID

cs 复制代码
struct appdata

{

    float4 vertex : POSITION;

    float2 uv     : TEXCOORD0;

    UNITY_VERTEX_INPUT_INSTANCE_ID  // ← 声明 instanceID : SV_InstanceID

};

4.2 步骤二:在顶点函数入口处 SETUP InstanceID

cs 复制代码
v2f vert (appdata v)

{

    v2f o;


    UNITY_SETUP_INSTANCE_ID(v);    // ← 从输入提取 instanceID

                               //   并设置全局实例化状态

UNITY_SETUP_INSTANCE_ID 宏会做两件事:

  • 从输入结构体中提取 instanceID,设置到 Shader 的全局上下文中
  • 后续的 unity_ObjectToWorldunity_WorldToObject 等矩阵会自动使用对应实例的版本
cs 复制代码
struct v2f

{

    float4 pos : SV_POSITION;

    float2 uv  : TEXCOORD0;

    UNITY_VERTEX_INPUT_INSTANCE_ID  // ← v2f 也需要这个字段

};


// 在 vert 函数中:

    UNITY_TRANSFER_INSTANCE_ID(v, o); // ← 将 v.instanceID 拷贝到 o

UNITY_TRANSFER_INSTANCE_ID 的作用是把输入结构体中的 instanceID 复制到输出结构体(v2f)中,以便片元着色器能继续使用。

📌 为什么片元着色器也需要 InstanceID?

如果你的片元着色器需要访问 UNITY_ACCESS_INSTANCED_PROP(Per-Material 实例化属性)或做 ComputeScreenPos 等操作,就必须知道当前是哪个实例。即使你的片元着色器什么都不做,也建议保留这个字段,避免未来添加功能时遗漏。

5. 片元着色器中的实例化支持

片元着色器中的处理相对简单,核心只有两步:

cs 复制代码
half4 frag (v2f i) : SV_Target

{

    UNITY_SETUP_INSTANCE_ID(i);    // ← 从 v2f 恢复实例化上下文


    // 如果有 Per-Material 实例化属性:

    half4 tint = UNITY_ACCESS_INSTANCED_PROP(_Props, _Color);


    half4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv) * tint;

    return col;

}

这里 UNITY_SETUP_INSTANCE_ID(i) 从插值后的 v2f 中恢复实例化上下文。注意参数是 i(v2f 结构),而不是顶点阶段的 v(appdata 结构)。

🔴 常见错误

如果在片元着色器中忘记调用 UNITY_SETUP_INSTANCE_ID,直接使用 UNITY_ACCESS_INSTANCED_PROP,在非 XR 平台可能侥幸工作,但在 SPI 模式下会读到错误的属性值(总是读到 Instance 0 的数据),导致左右眼显示异常。

6. URP Unlit Shader 完整示例

下面是一个完整的、SPI 兼容的 URP Unlit Shader。所有实例化宏的位置都已标注:

cs 复制代码
Shader "Custom/URP_SPI_Unlit"

{

    Properties

    {

        _MainTex ("Texture", 2D)          = "white" {}

        _Color   ("Tint",   Color)          = (1,1,1,1)

    }


    SubShader

    {

        Tags

        {

            "RenderPipeline" = "UniversalPipeline"

            "RenderType"     = "Opaque"

        }


        Pass

        {

            HLSLPROGRAM

            #pragma vertex vert

            #pragma fragment frag

            #pragma multi_compile_instancing   // ← 必须添加!启用 GPU Instancing


            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"


            CBUFFER_START(UnityPerMaterial)

                float4 _MainTex_ST;

                half4  _Color;

            CBUFFER_END


            TEXTURE2D(_MainTex);

            SAMPLER(sampler_MainTex);


            // ── 顶点输入结构体 ──

            struct appdata

            {

                float4 vertex : POSITION;

                float2 uv     : TEXCOORD0;

                UNITY_VERTEX_INPUT_INSTANCE_ID // ★ SPI 关键宏 #1

            };


            // ── 顶点→片元传递结构体 ──

            struct v2f

            {

                float4 pos : SV_POSITION;

                float2 uv  : TEXCOORD0;

                UNITY_VERTEX_INPUT_INSTANCE_ID // ★ SPI 关键宏 #2

            };


            // ── 顶点着色器 ──

            v2f vert (appdata v)

            {

                v2f o;


                UNITY_SETUP_INSTANCE_ID(v);      // ★ SPI 关键宏 #3

                UNITY_TRANSFER_INSTANCE_ID(v, o);  // ★ SPI 关键宏 #4


                o.pos = TransformObjectToHClip(v.vertex.xyz);

                o.uv  = TRANSFORM_TEX(v.uv, _MainTex);


                return o;

            }


            // ── 片元着色器 ──

            half4 frag (v2f i) : SV_Target

            {

                UNITY_SETUP_INSTANCE_ID(i);      // ★ SPI 关键宏 #5


                half4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);

                col *= _Color;


                return col;

            }

            ENDHLSL

        }

    }

}

6.1 必须的 5 个关键宏 --- 速查表

# 宏名称 位置 作用
1 UNITY_VERTEX_INPUT_INSTANCE_ID appdata 结构体末尾 声明 instanceID : SV_InstanceID
2 UNITY_VERTEX_INPUT_INSTANCE_ID v2f 结构体末尾 为片元传递声明 instanceID 字段
3 UNITY_SETUP_INSTANCE_ID(v) vert() 函数第一行 提取 instanceID 并设置全局上下文
4 UNITY_TRANSFER_INSTANCE_ID(v, o) vert() 函数中,在 SETUP 之后 将 instanceID 从 appdata 复制到 v2f
5 UNITY_SETUP_INSTANCE_ID(i) frag() 函数第一行 从 v2f 恢复实例化上下文

6.2 不要忘记 multi_compile_instancing

cs 复制代码
#pragma multi_compile_instancing  // ← 必须在 Pass 中添加此行


// 如果项目使用了 XR,通常还需要:

#pragma multi_compile _ STEREO_INSTANCING_ON

// URP 默认已包含此变体,但自定义 Shader 需要确认

✅ 好消息

在 URP 中,如果你使用的是 URP Shader Library(Core.hlslLighting.hlsl 等),multi_compile_instancing 通常已经隐式处理了立体渲染变体。但对于完全自定义的 Shader,务必显式添加此 pragma。

7. Shader Graph 中的注意事项

使用 URP Shader Graph 时,大部分实例化工作由 Unity 自动处理。但以下场景仍需手动干预:

7.1 Custom Function 节点

如果你在 Shader Graph 中使用 Custom Function 节点来编写 HLSL 代码,并且该代码需要访问 Per-Material 属性或变换矩阵,你必须在 Custom Function 的 HLSL 中手动添加实例化宏

cs 复制代码
// Custom Function 的 HLSL 代码体

void MyCustomFunction_float(

    float3 PositionOS,

    float2 UV,

    out float3 Result)

{

    // 在 Custom Function 中通常不需要手动处理

    // UNITY_SETUP_INSTANCE_ID,因为 Shader Graph

    // 的生成代码已经在外层处理了。

    // 但如果需要访问实例化属性,使用:


    Result = PositionOS; // 你的逻辑

}

7.2 Shader Graph 检查清单

  • Shader Graph 的 Graph Inspector → Target 确保选择了 Universal
  • Material Inspector 中勾选了 Enable GPU Instancing
  • 如果使用了 Custom Function,确保不与实例化宏冲突
  • Project Settings → XR Plug-in Management → Stereo Rendering Mode 设为 Single Pass Instanced

8. 常见错误与排查清单

8.1 排查清单

  1. 检查 pragma 指令 :确认 Shader 中包含 multi_compile_instancing。没有它,所有实例化宏都不会生效。
  2. 检查 appdata 结构体 :确认末尾有 UNITY_VERTEX_INPUT_INSTANCE_ID。没有这个字段,GPU 的 SV_InstanceID 无法传递到 Shader。
  3. 检查 vert() 第一行 :必须是 UNITY_SETUP_INSTANCE_ID(v)。位置错误会导致后续矩阵获取到默认值。
  4. 检查 TRANSFER :确认 UNITY_TRANSFER_INSTANCE_ID(v, o) 存在,且在 SETUP 之后调用。忘记 TRANSFER 会导致片元着色器拿到错误的实例上下文。
  5. 检查 frag() 第一行 :如果片元着色器中访问了实例化属性,必须调用 UNITY_SETUP_INSTANCE_ID(i)
  6. 检查 v2f 结构体 :确认也包含 UNITY_VERTEX_INPUT_INSTANCE_ID,否则 TRANSFER 无法写入。
  7. 检查 Project Settings :确认 XR Stereo Rendering Mode 为 Single Pass Instanced,而非 Multi-Pass。

8.2 典型症状

症状 可能原因
左右眼画面完全相同(无视差) 缺少 UNITY_SETUP_INSTANCE_ID,矩阵没有根据眼索引切换
物体位置偏移/抖动 使用了旧的 UnityObjectToClipPos 而非 TransformObjectToHClip,或者 SETUP 位置不对
材质颜色不正确 片元中使用了 UNITY_ACCESS_INSTANCED_PROP 但忘记 UNITY_SETUP_INSTANCE_ID
Shader 编译错误 UNITY_TRANSFER_INSTANCE_IDUNITY_SETUP_INSTANCE_ID 之前调用
Frame Debugger 显示无实例化 缺少 multi_compile_instancing pragma,或 Material 未启用 GPU Instancing

9. 性能影响与最佳实践

9.1 SPI 的性能收益

启用单通道实例化后,性能收益主要体现在以下几个方面:

  • CPU Draw Call 减半:这是最大的收益来源。在复杂场景中(数百个 Draw Call),CPU 端的节省尤为显著。
  • 顶点着色器利用率提升:GPU 端两个实例共享同一个 Vertex Buffer,减少了显存带宽的重复读取。
  • Frame Pacing 更稳定:CPU 提交减少意味着帧间波动更小,对 VR 的低延迟要求更友好。

9.2 注意事项

⚠️ 光栅化负担并未减少

SPI 减少的是 CPU 端的 Draw Call 开销,但 GPU 仍需为两只眼各光栅化一次像素。像素填充率(Fill Rate)并没有降低。如果性能瓶颈在像素阶段,SPI 不会带来显著改善。

9.3 最佳实践总结

实践建议 说明
所有 XR Shader 都添加实例化宏 即使当前不使用 SPI,添加宏在非 XR 模式下零开销,为未来兼容性留余地
使用 URP 内置变换函数 优先使用 TransformObjectToHClipTransformWorldToHClip 等 URP 函数,而非手动拼接矩阵。这些函数内部已处理实例化。
宏调用顺序严格遵循 SETUP → TRANSFER 的顺序不可颠倒。先提取,后传递。
在 Frame Debugger 中验证 使用 Window → Analysis → Frame Debugger,确认 Draw Call 确实显示了 Instanced 标记。
注意与 SRP Batcher 的共存 URP 的 SRP Batcher 与 GPU Instancing 可以共存,但 SRP Batcher 优先级更高。如果 Shader 完全兼容 SRP Batcher,且场景中没有大量相同材质的物体,Instancing 的收益可能不明显。

✅ 总结

单通道实例化渲染是 Unity XR 应用的标配优化。对 Shader 开发者来说,核心工作就是在顶点和片元结构体中添加 UNITY_VERTEX_INPUT_INSTANCE_ID,并在着色器函数中按正确顺序调用 UNITY_SETUP_INSTANCE_IDUNITY_TRANSFER_INSTANCE_ID。掌握这三个宏的用法,就能确保你的 Shader 在所有 XR 渲染模式下正确工作。

相关推荐
l1t12 小时前
DeepSeek总结的Delta 成长记:写入、Unity Catalog 和时间旅行
数据库·人工智能·unity
年少无知且疯狂13 小时前
【Unity】Mirror网络框架
unity
顾温13 小时前
协程结束——实测
开发语言·unity·c#
小白学鸿蒙1 天前
Unity 3D 2023解压安装,配置安卓运行环境后打包安卓应用(踩坑无数之差点放弃)
android·unity·游戏引擎
__water2 天前
【关于unity打包Android失败问题】
android·unity
mascon2 天前
Unity 编辑器扩展
unity·编辑器·游戏引擎
程序员正茂2 天前
Unity3d使用MQTT异步连接服务端
mqtt·unity·异步
mxwin2 天前
在unity shader中,通过pass产生阴影,通过主pass的光照 接收阴影!那么问题来了,是先产生阴影吗?还是先接收阴影,执行顺序是啥呢
数码相机·unity·游戏引擎·shader
小贺儿开发2 天前
《唐朝诡事录之长安》——盛世马球
人工智能·unity·ai·shader·绘画·影视·互动
蒙双眼看世界2 天前
Unity结合ECharts图表及网页插件EmbeddedBrowser的应用开发
unity·游戏引擎·echarts