深入理解 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_ObjectToWorld、unity_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.hlsl、Lighting.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 排查清单
- 检查 pragma 指令 :确认 Shader 中包含
multi_compile_instancing。没有它,所有实例化宏都不会生效。 - 检查 appdata 结构体 :确认末尾有
UNITY_VERTEX_INPUT_INSTANCE_ID。没有这个字段,GPU 的SV_InstanceID无法传递到 Shader。 - 检查 vert() 第一行 :必须是
UNITY_SETUP_INSTANCE_ID(v)。位置错误会导致后续矩阵获取到默认值。 - 检查 TRANSFER :确认
UNITY_TRANSFER_INSTANCE_ID(v, o)存在,且在 SETUP 之后调用。忘记 TRANSFER 会导致片元着色器拿到错误的实例上下文。 - 检查 frag() 第一行 :如果片元着色器中访问了实例化属性,必须调用
UNITY_SETUP_INSTANCE_ID(i)。 - 检查 v2f 结构体 :确认也包含
UNITY_VERTEX_INPUT_INSTANCE_ID,否则 TRANSFER 无法写入。 - 检查 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_ID 在 UNITY_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 内置变换函数 | 优先使用 TransformObjectToHClip、TransformWorldToHClip 等 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_ID 和 UNITY_TRANSFER_INSTANCE_ID。掌握这三个宏的用法,就能确保你的 Shader 在所有 XR 渲染模式下正确工作。