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 渲染模式下正确工作。

相关推荐
魔士于安4 小时前
unity 低多边形 无人小村 木质建筑 晾衣架 盆子手推车,桌子椅子,罐子,水井
游戏·unity·游戏引擎·贴图·模型
RReality4 小时前
【Unity Shader URP】简易卡通着色(Simple Toon)实战教程
ui·unity·游戏引擎·图形渲染·材质
魔士于安5 小时前
unity 骷髅人 连招 武器 刀光 扭曲空气
游戏·unity·游戏引擎·贴图·模型
洛阳吕工6 小时前
从 micro-ROS 到 px4_ros2:ROS2 无人机集成开发实战指南
游戏引擎·无人机·cocos2d
风酥糖7 小时前
Godot游戏练习01-第29节-游戏导出
游戏·游戏引擎·godot
瑞瑞小安7 小时前
Unity功能篇:文本框随文字内容动态调整
ui·unity
南無忘码至尊8 小时前
Unity学习90天-第7天-学习委托与事件(简化版)
学习·unity·游戏引擎
君莫愁。8 小时前
【Unity】解决UGUI的Button无法点击/点击无反应的排查方案
unity·c#·游戏引擎·解决方案·ugui·按钮·button
南無忘码至尊19 小时前
Unity学习90天 - 第 6天 - 学习协程 Coroutine并实现每隔 2 秒生成一波敌人
学习·unity·c#·游戏引擎