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 矩阵。 关键认知一旦启用 SPIGPU 就不再是每个顶点做一次 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 普通渲染的顶点流程在没有实例化的情况下顶点着色器接收的数据很简单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_ENABLEDUNITY_VERTEX_INPUT_INSTANCE_ID会展开为// 当 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 中声明 InstanceIDstruct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID // ← 声明 instanceID : SV_InstanceID };4.2 步骤二在顶点函数入口处 SETUP InstanceIDv2f vert (appdata v) { v2f o; UNITY_SETUP_INSTANCE_ID(v); // ← 从输入提取 instanceID // 并设置全局实例化状态UNITY_SETUP_INSTANCE_ID宏会做两件事从输入结构体中提取instanceID设置到 Shader 的全局上下文中后续的unity_ObjectToWorld、unity_WorldToObject等矩阵会自动使用对应实例的版本struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID // ← v2f 也需要这个字段 }; // 在 vert 函数中 UNITY_TRANSFER_INSTANCE_ID(v, o); // ← 将 v.instanceID 拷贝到 oUNITY_TRANSFER_INSTANCE_ID的作用是把输入结构体中的instanceID复制到输出结构体v2f中以便片元着色器能继续使用。 为什么片元着色器也需要 InstanceID如果你的片元着色器需要访问UNITY_ACCESS_INSTANCED_PROPPer-Material 实例化属性或做ComputeScreenPos等操作就必须知道当前是哪个实例。即使你的片元着色器什么都不做也建议保留这个字段避免未来添加功能时遗漏。5. 片元着色器中的实例化支持片元着色器中的处理相对简单核心只有两步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 中恢复实例化上下文。注意参数是iv2f 结构而不是顶点阶段的vappdata 结构。 常见错误如果在片元着色器中忘记调用UNITY_SETUP_INSTANCE_ID直接使用UNITY_ACCESS_INSTANCED_PROP在非 XR 平台可能侥幸工作但在 SPI 模式下会读到错误的属性值总是读到 Instance 0 的数据导致左右眼显示异常。6. URP Unlit Shader 完整示例下面是一个完整的、SPI 兼容的 URP Unlit Shader。所有实例化宏的位置都已标注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 个关键宏 — 速查表#宏名称位置作用1UNITY_VERTEX_INPUT_INSTANCE_IDappdata结构体末尾声明instanceID : SV_InstanceID2UNITY_VERTEX_INPUT_INSTANCE_IDv2f结构体末尾为片元传递声明 instanceID 字段3UNITY_SETUP_INSTANCE_ID(v)vert() 函数第一行提取 instanceID 并设置全局上下文4UNITY_TRANSFER_INSTANCE_ID(v, o)vert() 函数中在 SETUP 之后将 instanceID 从 appdata 复制到 v2f5UNITY_SETUP_INSTANCE_ID(i)frag() 函数第一行从 v2f 恢复实例化上下文6.2 不要忘记 multi_compile_instancing#pragma multi_compile_instancing // ← 必须在 Pass 中添加此行 // 如果项目使用了 XR通常还需要 #pragma multi_compile _ STEREO_INSTANCING_ON // URP 默认已包含此变体但自定义 Shader 需要确认✅ 好消息在 URP 中如果你使用的是 URP Shader LibraryCore.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 中手动添加实例化宏。// 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确保选择了UniversalMaterialInspector 中勾选了Enable GPU Instancing如果使用了 Custom Function确保不与实例化宏冲突Project Settings → XR Plug-in Management → Stereo Rendering Mode 设为Single Pass Instanced8. 常见错误与排查清单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_IDShader 编译错误UNITY_TRANSFER_INSTANCE_ID在UNITY_SETUP_INSTANCE_ID之前调用Frame Debugger 显示无实例化缺少multi_compile_instancingpragma或 Material 未启用 GPU Instancing9. 性能影响与最佳实践9.1 SPI 的性能收益启用单通道实例化后性能收益主要体现在以下几个方面CPU Draw Call 减半这是最大的收益来源。在复杂场景中数百个 Draw CallCPU 端的节省尤为显著。顶点着色器利用率提升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 渲染模式下正确工作。