Unity 渲染底层架构与管线原理笔记

Unity C# and Shader Tutorials

Unity渲染底层架构剖析

1. 渲染架构分层模型 (Layered Architecture)

Unity 引擎的渲染架构采用了典型的分层设计,从底层操作系统到上层 C# 脚本层层封装,以实现跨平台兼容和逻辑解耦。

1.1 系统与窗口层 (System & Window)

  • 最底层:系统窗口 (System Window)
    • 对应操作系统原生的窗口句柄,例如 Windows 的 HWND 或 Android 的 Surface
  • 封装层:窗口管理 (Window Wrapper)
    • 涉及 EGLWGL 等接口,负责创建窗口上下文、管理 Surface 以及处理 Swap Buffer(交换缓冲区)。
    • 商业引擎策略: 商业引擎(如 Unity)通常不直接使用 GLFW、SDL、Qt 等开源封装库,而是自行封装窗口管理类,以直接调用 EGL/WGL 从而获得更精细的控制。

1.2 图形 API 层 (Graphics API)

  • 接口定义 (Headers): 由标准组织或厂商定义的规范。
    • Khronos Group: OpenGL, Vulkan。
    • Microsoft: Direct3D 11, Direct3D 12。
    • Apple: Metal。
  • 驱动实现 (Implementation): 由硬件厂商(NVIDIA, AMD, ARM/Qualcomm)提供的动态链接库(.dll/.so)。
    • 关系类比:API 是头文件(Header),显卡驱动是实现文件(.cpp/impl)。

1.3 渲染硬件接口层 (RHI - Render Hardware Interface)

  • 核心作用: 解决上层逻辑需要针对不同图形 API 编写多套代码的问题。通过统一的抽象接口屏蔽底层 API 差异。
  • Unity 实现: 在 Unity 中,这一层被称为 GfxDevice
  • 功能: 所有的 Draw Call、资源创建等操作都通过 GfxDevice 转发,确保上层业务逻辑(如材质系统、光照系统)是通用的。

1.4 引擎渲染层 (Render Layer - C++)

  • 职责: 连接 RHI 与上层脚本,处理高性能的渲染逻辑。
  • 核心功能:
    • Batching & Sorting: 批处理与渲染排序。
    • Culling: 视锥体剔除等可见性判断。
    • Platform Adaptation: 处理不同平台的特定适配逻辑。

1.5 可编程渲染管线层 (SRP - C#)

  • 位置: 最上层,直接面向开发者。
  • 组成:
    • 官方管线:URP (Universal Render Pipeline), HDRP (High Definition Render Pipeline)。
    • 自定义管线:用户基于 SRP API 开发的 Custom SRP(如二次元渲染、特殊后处理管线)。

2. 渲染管线 (Render Pipeline) 概念辨析

2.1 语义区别

在图形开发中,"Pipeline" 一词常有两种语境,需严格区分:

  • GPU Pipeline (GPU 流水线):
    • 指 GPU 硬件处理单个 Draw Call 的通用流程。
    • 包含阶段:Vertex Shader (顶点着色) Rasterization (光栅化) Pixel/Fragment Shader (片元着色)。
  • Render Pipeline (渲染管线 - 本次重点):
    • 指引擎处理 每一帧 (Frame) 的完整逻辑流程。
    • 它是对一帧画面绘制任务的批量处理组织方式
    • 可视化: 对应 RenderDoc 或 Frame Debugger 中看到的 Pass 列表(如 ShadowMap Pass G-Buffer Pass Lighting Pass)。

3. Built-in 管线 vs. SRP 管线

3.1 Built-in Pipeline (内置管线)

  • 架构特点: 逻辑定死(Hardcoded)。
  • 工作流: 引擎预定义了如 ForwardBaseForwardAdd 等 Pass。
  • 控制方式:
    • 依赖 Shader 中的 Tag(如 LightMode)被动响应。
    • 通过 Camera 上的开关(如是否开启 HDR、深度图)进行有限的配置。
    • 局限性: 开发者无法完全控制管线的执行流,只能在预设的插槽中填空。

3.2 Scriptable Render Pipeline (SRP)

  • 架构特点: 起始状态为空 (Empty Canvas),完全可定制。
  • 工作流:
    • 由开发者在 C# 中显式定义每一帧的执行逻辑。
    • 主动控制: 开发者决定是否渲染天空盒、是否渲染阴影、是否进行深度拷贝。
  • 核心优势:
    • 配置灵活性: 所有的渲染指令(如 DrawingSettingsFilteringSettings)都由代码构建并注入管线。
    • 性能剔除: 如果某个 Pass 不需要(例如特定条件下不渲染半透明物体),在 SRP 中可以直接跳过该逻辑,而不仅仅是渲染全黑。

3.3 SRP 极简实现流程

  1. 资产定义: 创建继承自 RenderPipelineAsset 的可序列化资产。
  2. 实例创建: 资产负责创建 RenderPipeline 实例。
  3. 渲染循环 (Render Loop):
    • Render(ScriptableRenderContext context, ...) 方法中编写逻辑。
    • Context 调用: 使用 context.DrawSkyboxcontext.Submit 等 API 发送指令。
    • 配置参数: 设置剔除参数(Culling)、绘制设置(Drawing Settings,包含 Shader Tag ID 如 UniversalForward)。

SRP 渲染流程与 CommandBuffer 机制详解

1. 渲染对象的绘制方式:从微观到宏观

在 Scriptable Render Pipeline (SRP) 中,绘制物体主要分为两种模式:底层的单物体绘制与高层的场景批量渲染。

1.1 单物体绘制 (Low-Level approach)

类似于 OpenGL/DirectX 的 Demo 写法,适用于绘制特定的辅助几何体或简单的 Debug 图形。

  • 手动流程:
    1. 获取当前 Camera 的数据(View/Projection 矩阵)。
    2. 实例化一个 Mesh(如 Cube, Capsule, Sphere)。
    3. 实例化一个 Material
    4. 通过 CommandBuffer 下发绘制指令:指定位置 (Position)、旋转 (Rotation)、缩放 (Scale)。
    5. 执行 CommandBuffer。
  • 结果: 屏幕上出现一个独立的网格物体。

1.2 场景批量渲染 (Production approach)

在商业引擎(如 Unity)中,不会手动逐个绘制场景物体。引擎通过 “剔除 (Culling) + 过滤 (Filtering)” 的方式来批量管理渲染。

  • 核心逻辑: 开发者只需指定“规则”,引擎负责“收集与执行”。
  • 关键步骤:
    1. Culling (剔除): 获取 CullingResults,剔除视锥体外的物体。
    2. Filtering (过滤): 设置 FilteringSettings,通过以下参数筛选物体:
      • Render Queue Range: 渲染队列范围(如不透明、半透明)。
      • Layer Mask: 层级遮罩。
      • Rendering Layer Mask: 渲染层掩码。
    3. Drawing (绘制): 设置 DrawingSettings,指定 Shader Tag ID(如 UniversalForward)和排序模式。
    4. API 调用: 使用 context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings)
  • 优势: 引擎自动处理成百上千个物体的批处理(Batching)和状态切换。

2. CommandBuffer 与 Context 的底层交互

很多开发者容易混淆 CommandBuffer (命令缓冲区) 与 ScriptableRenderContext (渲染上下文) 的关系,导致渲染逻辑出错。

  • CommandBuffer: 本质是一个 命令列表 (List of Commands)
    • 调用 cmd.DrawMeshcmd.BeginSample 时,只是将命令 添加 到这个列表中,并没有立即执行
  • ScriptableRenderContext: 真正与图形驱动交互的接口。
    • context.ExecuteCommandBuffer(cmd) 的作用是将 CommandBuffer 中的命令列表 复制 到 Context 的执行队列中。
    • 重要细节: ExecuteCommandBuffer 不会自动清空 cmd,通常需要紧接 cmd.Clear()

3. ProfilingScope 的正确用法与常见陷阱 (重点)

在 SRP 开发中,使用 ProfilingScope 配合 using 语法块可以在 Frame Debugger 中生成清晰的层级结构。但如果对底层机制理解不清,极易导致层级错乱。

3.1 核心机制

ProfilingScope 的构造函数和 Dispose 方法本质上是在操作 CommandBuffer 的 Sample 指令。

  • 构造函数 (new ProfilingScope) 调用 cmd.BeginSample("Name")
  • 结束销毁 (Dispose/End using) 调用 cmd.EndSample("Name")

3.2 正确的写法模式

必须确保 BeginSampleEndSample 命令都被 及时提交 到 Context 中,且包围住中间的绘制指令。

// 1. 开启 Scope,cmd 内部记录 BeginSample
using (new ProfilingScope(cmd, scopingSampler)) 
{
    // 2. 【关键】必须先提交 cmd!
    // 此时 context 队列:[...Previous, BeginSample]
    context.ExecuteCommandBuffer(cmd);
    cmd.Clear();
 
    // 3. 执行具体的渲染(直接操作 context)
    // 此时 context 队列:[..., BeginSample, DrawRenderers]
    context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
 
    // 4. 离开作用域,cmd 内部记录 EndSample
}
 
// 5. 【关键】再次提交 cmd!
// 此时 context 队列:[..., BeginSample, DrawRenderers, EndSample]
context.ExecuteCommandBuffer(cmd);
cmd.Clear();

3.3 常见错误案例分析

错误 A:漏写第一个 Execute (Scope 包裹不住绘制)

  • 代码逻辑: using (Begin) context.DrawRenderers End using (End) Execute(cmd)
  • 实际执行流:
    1. Context 收到 DrawRenderers
    2. Context 收到 Execute(cmd),其中包含 BeginSampleEndSample
  • 结果: DrawRenderers 跑到了 Sample 之外,Frame Debugger 中层级平铺,未被折叠。

错误 B:漏写最后一个 Execute (无限嵌套)

  • 代码逻辑: Execute (Begin) context.DrawRenderers End using (End) (无提交)。
  • 实际执行流:
    1. Context 收到 BeginSample
    2. Context 收到 DrawRenderers
    3. EndSample 留在了 cmd 中,从未发给 GPU。
  • 结果: 每一帧都在压栈(Push),从未出栈(Pop)。Frame Debugger 会显示无限层级嵌套,甚至导致报错。

Note: 虽然可以直接手动写 cmd.BeginSamplecmd.EndSample 而不使用 using,但原理相同:必须确保 Begin Draw End 的指令顺序在 Context 队列中是严格线性的。


4. 渲染硬件接口 (RHI) 简介

4.1 概念

RHI (Render Hardware Interface) 是引擎底层对不同图形 API 的抽象封装,位于 Unity 架构的 GfxDevice 层。

4.2 架构设计

  • GfxDevice (基类): 定义了所有渲染所需的通用接口(如 DrawCall, CreateTexture),但只有虚函数定义,没有具体实现。
  • 具体实现类 (Subclasses):
    • GfxDeviceD3D11 / GfxDeviceD3D12 (Windows/Xbox)
    • GfxDeviceVK (Vulkan - Android/Linux)
    • GfxDeviceMetal (iOS/macOS)
    • GfxDeviceGLES (Old Android)
  • 工作流: 上层 C# 调用 API 引擎 C++ 层转发 GfxDevice 调用对应平台的原生图形 API。这使得上层逻辑(Shader, SRP)无需关心底层运行的是 DirectX 还是 Vulkan。

RHI 抽象与 CommandBuffer 执行机制深度解析

1. RHI (Render Hardware Interface) vs. Graphics API

虽然 RHI 基于图形 API (如 OpenGL, DirectX, Vulkan) 构建,但两者在定位与功能上存在本质区别:

1.1 核心区别

  • Graphics API (DX11, Vulkan, etc.): 专注于硬件能力的直接暴露。它不知道什么是“材质 (Material)”或“全局纹理 (Global Texture)”,只认识 Buffer、Texture、Shader State 等底层资源。
  • RHI (GfxDevice): 专注于引擎功能的实现。它不仅是对 API 的封装,还补全了商业引擎所需的上层概念。
    • 概念补全: 引入材质系统、全局着色器变量 (Global Properties) 等 API 不具备的概念。
    • 平台抹平: 对于不支持某些特性(如 DXR 光追)的后端(如 GLES),RHI 会提供空实现 (Stub) 或报错,保证上层代码的统一性。

1.2 材质 (Material) 的本质

对于底层图形 API,不存在 "Material" 类。材质是引擎层面的抽象,本质是以下数据的集合:

  • Shader Code: 着色器程序。
  • Shader State: 深度测试模式、混合模式等渲染状态。
  • Uniform/Texture Data: 渲染所需的纹理和参数数据。

2. CommandBuffer 的底层数据结构

2.1 结构本质:可扩展二进制序列 (Extensible Binary Buffer)

CommandBuffer 在 C++ 底层并非存储对象列表,而是一个紧凑的纯字节数组 (Byte Vector)

  • 存储布局 (Compact Layout):
    • 数据紧密排列,无空隙(或仅有内存对齐填充)。
    • 格式: [CmdType (Enum, 4 bytes)] + [Data Payload] + [CmdType] + [Data Payload] ...
  • 内存特征:
    • CmdType: 标识命令类型(如 SetRenderTarget, ClearRenderTarget)。
    • Data Payload: 紧跟类型之后,长度不固定。
      • GetTemporaryRT: 可能包含 NameID (int), Desc (struct), FilterMode (enum)。
      • SetRenderTarget: 可能包含 ColorBuffer, DepthBuffer, MipLevel 等。
    • 由于数据长度可变,无法通过数组下标直接访问第 N 个命令,必须线性解析。

2.2 数据流向图解

(上图展示了 CommandBuffer 内部紧凑的二进制存储结构,对比 Context 的指针数组结构)


3. ScriptableRenderContext (Context) 的底层结构

3.1 结构本质:指令结构体数组 (Vector of Command Structs)

Context 在底层是一个 std::vector<CommandStruct>,与 CommandBuffer 的紧凑布局不同。

  • 存储布局:
    • Type: 命令类型(如 DrawRenderers, ExecuteCommandBuffer, DrawShadows)。
    • Data Pointer: 指向具体数据的指针
  • 关键特性:
    • ExecuteCommandBuffer 的行为: 当调用 context.ExecuteCommandBuffer(cmd) 时,底层发生的是 深拷贝 (Deep Copy)
    • 数据复制: CommandBuffer 中的二进制数据被完整复制到 Context 管理的内存区域中。
    • 独立性: 因此,Execute 之后立即调用 cmd.Clear() 是安全的,不会影响 Context 中已排队的指令。

4. 渲染指令的执行流程 (Submit & Loop)

当调用 context.Submit() 时,引擎在主线程开始执行指令队列。

4.1 上层循环 (Context Loop)

Context 因为是结构体数组,可以直接遍历:

// 伪代码逻辑
for (auto& cmd : context.commands) {
    switch (cmd.type) {
        case DrawRenderers: 
            // 执行绘制渲染列表逻辑
            break;
        case DrawShadows:
            // 执行绘制阴影逻辑
            break;
        case ExecuteCommandBuffer:
            // 进入 CommandBuffer 解析器
            ExecuteBinaryBuffer(cmd.dataPointer); 
            break;
    }
}

4.2 内部循环 (CommandBuffer Parser)

当遇到 ExecuteCommandBuffer 类型时,进入二级解析器。由于是二进制流,必须通过指针偏移进行迭代:

// 伪代码逻辑:解析二进制流
byte* ptr = buffer.start;
while (ptr < buffer.end) {
    // 1. 读取命令头 (4字节)
    CmdType type = *(CmdType*)ptr;
    ptr += 4;
 
    // 2. 根据类型读取数据并执行
    switch (type) {
        case SetRenderTarget:
            // 假设 SetRT 数据结构大小为 64 字节
            auto data = *(SetRenderTargetData*)ptr;
            GfxDevice->SetRenderTarget(data); // 转发给 RHI
            ptr += 64; // 指针后移
            break;
        case ClearRenderTarget:
            // 假设 ClearRT 数据结构大小为 32 字节
            auto data = *(ClearRenderTargetData*)ptr;
            GfxDevice->Clear(data); 
            ptr += 32;
            break;
    }
}

5. RHI 的具体调用 (Dispatcher)

解析出的命令最终会调用 GfxDevice 接口,并分发到具体平台的实现。

  • 接口层: GfxDevice::Clear(...)
  • 实现层 (虚函数多态):
    • 如果是 DirectX 11: 调用 GfxDeviceD3D11::Clear ID3D11DeviceContext::ClearRenderTargetView
    • 如果是 Vulkan: 调用 GfxDeviceVK::Clear vkCmdClearAttachments

5.1 线程模型 (Thread Model)

  • 上述解析与调用通常发生在 主线程 (Main Thread)
  • 如果在 Project Settings 中开启了 Multithreaded Rendering,则主线程仅负责生成中间指令(GfxCmd),实际的图形 API 调用会发送到 渲染线程 (Render Thread) 执行。

总结:CommandBuffer vs. Context

特性CommandBufferScriptableRenderContext
底层结构紧凑二进制流 (Compact Binary Stream)结构体数组 (Vector of Structs)
存储方式数据直接内联存储 (Inline Data)存储数据指针 (Data Pointers)
访问方式必须线性解析 (Linear Parsing)可随机访问/遍历 (Random Access)
复用性理论上可复用 (如果不 Clear)一次性 (Submit 后即清空)
交互行为Execute = 深拷贝数据到 ContextSubmit = 执行所有指令

Unity 多线程渲染与 Mesh Pipeline 架构

1. 多线程渲染架构 (Multi-threaded Rendering)

Unity 的多线程渲染旨在将逻辑处理与图形 API 调用解耦,避免主线程阻塞。

1.1 开启机制

  • 配置位置: Project Settings Player Other Settings Multithreaded Rendering
  • 生效原理:
    • 该选项并非运行时动态切换,而是构建(Build)时写入 boot.config 配置文件。
    • 应用启动时读取配置,决定是否初始化渲染线程。

1.2 架构实现:生产者-消费者模型

  • 设计模式: 标准的 Producer-Consumer (生产者-消费者) 模型。
  • 核心组件:
    • Main Thread (生产者): 持有一个 Proxy GfxDevice(代理设备,通常称为 ClientGfxDevice)。
      • 它的方法(如 Clear, Draw)不直接调用图形 API,而是将命令序列化并写入 Command Queue (命令队列)。
    • Render Thread (消费者): 这是一个与程序生命周期绑定的死循环线程。
      • 不断从队列中读取命令包。
      • 解析命令类型(Switch Case)。
      • 调用 Real GfxDevice (如 GfxDeviceD3D11, GfxDeviceVK) 执行真正的底层 API(如 ID3D11DeviceContext::ClearRenderTargetView)。

Key: 这种架构使得主线程无需等待 GPU 或驱动层面的同步,只需负责“发号施令”。


2. Mesh Pipeline (网格管线) 抽象

2.1 商业引擎 vs. 原生开发

  • 原生开发 (Raw API): 开发者需要手动管理每一个 Draw Call,手动绑定 VBO/IBO,手动处理状态切换。
  • 引擎抽象 (Mesh Pipeline):
    • 引擎不再关注单个“三角形”或“顶点”,而是管理 Renderer 组件MeshRenderer, SkinnedMeshRenderer, Terrain 等)。
    • 职责下放: 开发者只需指定“渲染范围”和“规则”,引擎负责底层的剔除、排序、合批(Batching)和状态切换(SetPass)。

3. SRP 中的渲染流程控制

在 Scriptable Render Pipeline 中,渲染流程被标准化为 Cull (剔除) Filter (过滤) Sort (排序) Draw (绘制)

3.1 视锥体剔除 (Culling)

  • API: context.Cull(ref cullingParameters)
  • CullingResults:
    • 返回值 CullingResults 本质上是一个 Handle (句柄) 或指针。
    • 异步特性: 实际的剔除计算可能在工作线程(Worker Threads)并行执行,C# 层拿到的只是结果的引用。
  • 内部逻辑:
    • 引擎底层维护了一个包含所有 Renderer 的空间索引结构。
    • 遍历结构,判断物体包围盒(AABB)是否与相机视锥体(Frustum)相交。
    • 输出结果:可见物体的索引列表(例如:Index List [2, 5, 8])。

3.2 过滤与收集 (Filtering & Gathering)

即使物体可见,也不一定在当前 Pass 渲染,需通过 FilteringSettings 进行二次筛选:

  • Render Queue Range: 指定渲染队列范围(如 [2000, 2500] 仅渲染不透明物体)。
  • Layer Mask: 游戏对象的 Layer(如 Default, Water)。
  • Rendering Layer Mask: SRP 特有的高级掩码(可用于实现类似贴花或特定光照组的功能)。

3.3 绘制设置与排列组合 (Drawing & Permutations)

  • API: context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings)
  • Shader Tag ID: 指定渲染哪个 Pass(如 UniversalForwardSRPDefaultUnlit)。
    • 多 Pass 机制 (Multi-Pass expansion): 如果一个材质定义了多个 Pass 且 Tag ID 都匹配(例如同时通过 drawingSettings.SetShaderPassName 添加了多个 Tag),同一个 Mesh 会被绘制多次
  • 排列组合 (Explosion): 最终提交给 GPU 的绘制列表是由以下维度展开的:
    • 即:一个物体如果有 2 个材质,每个材质的 Shader 有 2 个匹配的 Pass,该物体将产生 个 Draw Calls(在未合批前)。

3.4 渲染排序 (Sorting)

  • 目的:
    • Opaque (不透明): 只有 Front-to-Back (从近到远) 排序能利用 Early-Z 优化减少 Overdraw。
    • Transparent (半透明): 必须 Back-to-Front (从远到近) 排序以保证正确的混合(Blending)结果。
    • State Change: 尽可能减少 Shader/Material 的切换。
  • SortingCriteria:DrawingSettings 中指定排序策略。

Mesh Pipeline 的执行细节与 Job System 优化

1. 渲染排列组合 (Permutation) 与合批 (Batching)

在进行实际的 Draw Call 之前,引擎需要对“哪个物体、用哪个材质、画哪个 Pass”进行排列组合,并尝试合并可合并的渲染任务。

1.1 排列组合 (Permutation)

一个 MeshRenderer 不等于一个 Draw Call。最终的绘制列表是基于以下逻辑生成的:

  • LightMode: SRP 指定绘制带有特定 LightMode (如 "UniversalForward") 的 Pass。
  • Permutation: 遍历物体的所有材质 遍历材质的所有 Pass 匹配 LightMode。
    • 如果一个材质有两个 Pass 匹配当前 LightMode(例如描边效果可能通过两个 Pass 实现),该物体会被绘制两次。

1.2 合批 (Batching) 逻辑

引擎将 256 个 Render Node(渲染节点)划分为一个 Job (工作单元) 进行并行处理。在每个 Job 内部,引擎会尝试合并 Draw Call:

  • 条件:
    • 相同的 Mesh (如果启用了 GPU Instancing)。
    • 相同的 Material。
    • 相同的 Shader Pass。
    • 相同的 Shader Keywords / Render State。
  • 结果: 如果相邻的两个物体共享材质和 Pass,它们可能被合并(SRP Batcher 或 GPU Instancing)。

2. 渲染数据的多线程准备 (Preparation)

context.DrawRenderers 这一行代码在 C# 端几乎没有开销(Zero Cost),因为它只是向底层发送了一个“请求”。真正的繁重工作发生在 context.Submit 之后的多线程准备阶段。

2.1 DrawRenderers 的底层行为

  1. 指令入队: 往底层 Command List 中添加一个类型为 DrawRenderers 的指令。
  2. 数据存储: 具体的配置(Culling, Filtering, Sorting Settings)被存储在一个独立的 List 中。
  3. Index 指向: Command List 中的指令仅存储一个指向该 List 的 Index,避免大对象拷贝。
  4. 初始状态: 该任务的状态标记为 Waiting

2.2 Submit 后的多线程处理 (Dispatch)

当调用 context.Submit() 时,主线程遍历指令列表,发现 DrawRenderers 指令处于 Waiting 状态,触发 Dispatch (派遣) 逻辑:

  1. 任务划分 (Job Splitting):
    • 根据 CPU 核心数(大核/小核)计算线程数。
    • 将所有可见物体(Render Nodes)按 256 个一组 划分为多个 Job。
  2. 并行过滤 (Parallel Filtering):
    • 每个 Job 遍历其负责的 256 个物体。
    • Layer Mask & Render Queue: 检查是否符合过滤条件,不符合直接 Continue。
    • Material & Pass: 检查材质是否有效,Pass 是否启用 (Enabled)。
    • 结果收集: 将有效的 (Object, Material, Pass) 组合加入局部列表。
  3. 并行排序 (Parallel Sorting):
    • 多线程对收集到的列表进行排序(Opaque: Front-to-Back, Transparent: Back-to-Front)。
  4. 任务分组 (Grouping):
    • 基于排序结果,将相邻且状态一致的渲染项分组,为合批做准备。
    • 依赖管理: 分组 Job 依赖于排序 Job,排序 Job 依赖于过滤 Job。通过 Job Handle (Fence) 进行同步。

3. Job System 与性能分析 (Profiling)

3.1 Job System 的核心思想

Unity (和 Unreal) 使用 Job System 来充分利用多核 CPU。

  • 抽象: 将独立的逻辑(如剔除、排序、粒子更新)封装为 Job。
  • 依赖 (Dependency): 定义 Job 之间的先后顺序(例如:必须先排序,才能分组)。
  • Steal: 空闲线程可以“窃取”其他线程的任务,保证负载均衡。

3.2 Profiler 视图解读

  • 蓝色 (Scripting): C# 脚本层的调用消耗。
  • 绿色 (Engine): C++ 引擎底层的执行消耗。
  • Culling (剔除):
    • 主线程 (CullResults.Cull) 只做少量准备。
    • 大量工作被分发到 Worker Threads (绿色条块散落在各个线程)。
  • DrawRenderers (准备):
    • Submit 之后,你会看到多个 Worker Thread 同时在处理 DrawRenderers 的准备工作(过滤、排序)。
  • 同步点 (Sync Point):
    • 当真正要执行 GPU 命令时(Render Job),主线程必须等待 (Wait) 所有相关的准备 Job 完成。
    • 如果 Profiler 中主线程出现长时间的 WaitForJobGroupId,说明渲染线程或 Worker 线程压力过大,主线程在空转等待。

4. 总结:Mesh Pipeline 执行流

  1. C# 调用: context.DrawRenderers (仅仅是记录命令,极快)。
  2. Submit 触发: context.Submit 开始执行命令列表。
  3. 预处理 (多线程):
    • 发现 DrawRenderers 指令。
    • 启动 Job System。
    • Worker Threads: 并行执行 Culling Filtering Sorting Batching。
  4. 同步: 主线程等待上述 Job 完成。
  5. 渲染 (Render Job):
    • 主线程(或渲染线程)生成最终的 GfxCmd。
    • 调用 RHI (DirectX/Vulkan) 发送 Draw Call 给 GPU。

Render Job 执行与合批提交 (Batching & Submission)

1. Render Job 的执行环境 (Threading Model)

Render Job 是实际处理渲染逻辑(合批、数据准备)的工作单元。它的执行线程取决于平台和配置。

1.1 基本模式 (OpenGLES / DX11)

  • 执行线程: 通常在 Main Thread (主线程)
  • 流程:
    1. 主线程执行 Job (For-loop)。
    2. 准备数据、合批。
    3. 通过 GfxDevice 接口提交给 Render Thread (渲染线程)
    4. 渲染线程调用底层图形 API (如 ID3D11DeviceContext::Draw).

1.2 多线程渲染模式 (Graphics Jobs Mode)

  • 开关: Project Settings Graphics Jobs (注意与 Multithreaded Rendering 区分)。
  • 执行线程: Worker Threads (工作线程)
  • 流程:
    • 渲染任务被分发到多个 Worker 线程并行执行。
    • 每个 Worker 线程计算完毕后,将结果提交给 Render Thread。

1.3 现代 API 模式 (Vulkan / DX12 / Metal)

现代 API 支持多线程命令录制 (Multi-threaded Command Recording),这彻底改变了渲染流程。

  • 主要区别:
    • DX11/GLES: 只能单线程录制命令。即使在 Worker 线程准备数据,最终必须汇聚到一个线程提交 API 调用。
    • DX12/Vulkan: 支持多个 Command List 并行录制。
  • 优化流程:
    • Render Job (Worker Thread): 不仅准备数据,还直接调用图形 API 录制命令 (如 vkCmdDraw).
    • 提交: 每个 Job 生成一个独立的 Command List。最终只需在 Render Thread 将这些 Command List 提交给 Command Queue 即可。

2. 合批器 (Batcher) 的工作原理

合批的核心是在 Render Job 内部遍历物体,尽可能将它们合并为一个 Batch。

2.1 预筛选 (Pre-filtering)

  • SRP Batcher Compatibility:
    • 在开始合批逻辑前,首先快速扫描 Render Nodes。
    • 检查物体是否兼容 SRP Batcher(例如 shader 是否支持)。
    • 如果当前物体不支持,直接作为单独的 Draw Call 提交,跳过合批逻辑。

2.2 合批状态机 (State Machine)

合批器维护当前 Batch 的状态(Shader, Keywords, Render State 等)。

  • 输入: 待渲染的物体列表 (Iterating Objects)。
  • 比较器 (Comparator): 比较 CurrentObject.State vs LastBatch.State
    • Case A: 状态相同 (Same State)
      • 结果: No Flush (Reason == null)。
      • 操作: 将物体加入当前 Batch。
    • Case B: 状态不同 (Diff State)
      • 结果: Flush Needed (Reason != null,例如 "Variant Changed")。
      • 操作:
        1. Flush: 提交当前累计的 Batch 到渲染队列。
        2. Clear: 清空 Batcher。
        3. Add: 将当前物体加入新的 Batch。
    • Case C: 循环结束
      • 操作: 强制 Flush 剩余的所有物体。

3. 数据提交与内存管理 (Flush & Memory)

3.1 批量提交 (Batch Submission)

Render Job 不会每处理一个物体就调一次 GfxDevice(那样会有巨大的锁开销和函数调用开销)。

  • 策略: 将整个 Batch(包含多个物体)作为一个数据包 (Packet) 提交给 Render Thread。
  • 渲染线程行为: Render Thread 拿到这个 Batch Packet 后,再进行内部循环,逐个执行 SetPass 和 DrawCall。

3.2 内存预分配 (Pre-allocation)

为了避免频繁的 new/malloc 导致的性能劣化(GC 或 堆碎片):

  • Pre-allocate: 提交前,根据 Batch 中的物体数量预估所需内存大小,预留指针。
  • Commit: 实际写入数据时才通过 Commit 锁定并使用内存块。
  • 增量更新 (Delta Update):
    • 在构建提交数据时,检查每个物体的属性(如 Mesh, Texture)。
    • 如果 Texture 与上一个物体相同 不写入 SetTexture 指令。
    • 如果 Texture 不同 写入 新的 SetTexture 指令。
    • 这种“只发送变化量”的策略进一步减少了数据传输带宽。

渲染数据提交与常量缓冲区 (Constant Buffer) 优化

1. Native Graphics Jobs 与命令录制

当使用现代图形 API(DX12, Vulkan, Metal)并开启 Native Graphics Jobs 时,渲染流程发生了根本性变化。

1.1 传统模式 (Main/Render Thread)

  • Flush 操作: 在 Render Job 中仅准备数据(数据包)。
  • Submit: 将数据包发送到渲染线程。
  • Record: 渲染线程单线程调用 API 进行命令录制。

1.2 Native Graphics Jobs 模式

  • Flush 操作: 直接在 Worker Thread (工作线程) 中调用底层的命令录制函数(如 GfxDevice::RecordCommand)。
  • 并行化: 多个 Job 并行录制 Command List。
  • Submit: 主线程或渲染线程仅负责最终的 ExecuteCommandLists 提交,极大降低了渲染线程的瓶颈。

1.3 调试技巧 (Profile Graphics Jobs)

默认情况下,Unity Editor 为了稳定性隐藏了 Graphics Jobs 的 Profiler 数据。若要强制查看:

  1. 找到项目目录下的 ProjectSettings/Boot.config (如果不存在则新建)。
  2. 添加配置行:
    gfx-job-worker-count=4  (示例)
    gfx-enable-native-gfx-jobs=1
  3. 注意: 每次重启 Editor 该文件可能被重置,建议备份或在真机(Android/iOS Build)上调试。

2. UnityPerDraw 常量缓冲区 (Constant Buffer) 机制

UnityPerDraw 是 Unity shader 中最常用的 CBUFFER,存储每个物体特有的数据(如 MVP 矩阵、光照探针数据)。

2.1 数据准备 (Data Preparation)

  • 反射 (Reflection): Shader 编译时,引擎通过反射分析出该 Shader 需要哪些 PerDraw 数据。
    • 引用 unity_ObjectToWorld 开启 Transform Feature。
    • 引用 unity_SHAr 开启 SH Lighting Feature。
  • Feature Mask: 运行时,Render Job 根据 Feature Mask 计算出每个物体所需的 CBUFFER 大小(按 16 字节对齐)。
  • 批量分配:
    • 假设一个 Batch 有 10 个物体。
    • CPU 端分配内存:Size = SingleObjectSize * 10
    • 循环填充:遍历 10 个物体,根据 Mask 将矩阵、SH 系数等写入内存。

2.2 数据上传与绑定 (Upload & Bind)

RHI 层对 CBUFFER 的更新进行了深度优化,隐藏了底层 API 的差异。

策略 A: 环形缓冲区 (Ring Buffer) + 偏移 (Offset)

  • 适用 API: DX11.1+, DX12, Vulkan, Metal, Modern GL.
  • 原理:
    1. 一次上传: 将 10 个物体的数据一次性 Map/Unmap 拷贝到一个大的 GPU Buffer (Ring Buffer) 中。
    2. 多次绑定:
      • Draw Object 1: SetConstantBuffer(Buffer, Offset = 0, Size = 256)
      • Draw Object 2: SetConstantBuffer(Buffer, Offset = 256, Size = 256)
  • 优势: 极少的 CPU-GPU 数据传输次数 (PCIe 传输),极高的性能。

策略 B: 独立上传 (Individual Upload)

  • 适用 API: DX9, Old DX11 (Win7), Legacy GL.
  • 原理: 硬件不支持绑定 Buffer 的即时偏移 (Offset)。
    1. Draw Object 1: Map 小 Buffer Copy Data 1 Draw
    2. Draw Object 2: Map 小 Buffer Copy Data 2 Draw
  • 劣势: 频繁的 Lock/Unlock 和 PCIe 传输,性能较差。

2.3 动态池化 (Dynamic Pooling)

Unity 底层维护了一个 CBUFFER 池,通常按 2 的幂次分级(256B, 512B, ... 128KB)。

  • 如果 Batch 数据量超过 128KB (DX11 限制),引擎会自动拆分 Batch,分多次 Track 上传。

3. 总结:从 Job 到 GPU 的数据流

  1. Render Job (Worker):
    • 计算 Feature Mask。
    • 在 CPU 端分配并填充 UnityPerDraw 数据块(包含 N 个物体)。
  2. RHI Layer:
    • 检查 API 能力(是否支持 Offset)。
    • Modern: 拷贝整块数据到 GPU Ring Buffer。
    • Legacy: 拆分为单次更新。
  3. Command Record:
    • 写入 SetConstantBuffer(Offset) 指令。
    • 写入 DrawIndexed 指令。
  4. GPU 执行: 顶点着色器根据绑定的 CBUFFER 读取当前的 MVP 矩阵进行变换。

Material 数据管理与合批 (Batching) 优化策略

1. Material Constant Buffer (UnityPerMaterial) 的底层机制

UnityPerDraw(每物体数据,高频更新)不同,UnityPerMaterial 存储的是材质属性(如 BaseColor, Smoothness),其更新频率较低,且所有权归材质对象所有。

1.1 数据结构与存储

  • CPU 端: 每个 Material 对象在 CPU 内存中持有一个 Buffer。
  • 数据映射:
    • 引擎维护一个 Property Map,记录 Shader 中属性名(如 _BaseColor)对应的偏移量(Offset)。
    • 当调用 material.SetFloat/SetColor 时,引擎根据 Offset 直接修改 CPU Buffer 中的数据。
  • Dirty Flag: 修改数据会将材质标记为“脏 (Dirty)”。

1.2 数据上传 (CPU to GPU)

  • 时机: 在渲染准备阶段(Culling 后),如果检测到 Material 标脏。
  • 流程:
    1. Check Size: 检查材质属性大小是否发生变化(例如 Shader 变体切换导致 Buffer 变大)。
    2. Allocation:
      • 如果大小变了,释放旧 Buffer,从底层池 (GPU Buffer Pool) 中申请新的 TemporaryBuffer
      • 如果没变且未脏,直接跳过上传。
    3. Update: 调用 GfxDevice.UpdateBuffer,将 CPU 数据拷贝到 GPU。
    4. Persistent: 与 PerDraw 不同,PerMaterial 的 GPU Buffer 是持久化的,直到材质销毁或 Shader 变更,不会每帧销毁。

Key Performance Tip: 避免每帧调用 material.SetFloat,除非数值真的在变。因为这会触发每帧的 PCIe 数据传输带宽消耗。


2. 三种合批技术的对比与原理

在 Unity 中,减少 DrawCall 的核心目的是减少 Render State Change (渲染状态切换),尤其是 SetPass Call

2.1 Static Batching (静态合批)

  • 原理: 将多个使用相同材质的静态物体(Static Flag)合并成一个巨大的 Mesh。
  • 限制:
    • 严格同材质: 必须完全相同的 Material 实例。
    • 内存代价: 合并后的 Mesh 会导致包体和内存占用显著增加(重复的顶点数据)。
    • 不可移动: 物体必须是静态的。
  • 评价: 牺牲内存换 CPU,现代项目中用得越来越谨慎。

2.2 Dynamic Batching (动态合批)

  • 原理: 每一帧在 CPU 端实时变换顶点,将小网格合并。
  • 限制:
    • 严格同材质。
    • 顶点数限制: 只能合并非常小的网格(通常 < 300 顶点)。
    • CPU 开销: 每帧的 CPU 变换和数据上传开销可能超过 DrawCall 节省的开销。
  • 评价: 在现代 CPU/GPU 架构下,通常收益甚微,甚至负优化。

2.3 SRP Batcher (SRP 合批) - 现代标准

  • 原理: 不合并 DrawCall,而是合并 SetPass Call。
    • 多个 DrawCall 如果使用相同的 Shader Variant(即使 Material 实例不同),只需设置一次 Shader State (SetPass)。
    • 每个物体的 Transform 和 Material 数据分别绑定到 CBUFFER (UnityPerDraw, UnityPerMaterial)。
  • 流程:
    1. SetPass (Shader Variant A)
    2. Bind CBUFFER (Material A) Draw (Object A)
    3. Bind CBUFFER (Material B) Draw (Object B)
  • 优势:
    • 无网格合并: 节省内存。
    • 支持动态物体: 只要 Shader 变体一致。
    • 低带宽: 材质数据持久化在 GPU,无需每帧上传。
  • 限制: Shader 必须兼容 SRP Batcher(数据布局需对齐)。

3. Draw Call vs. SetPass Call

我们常说的“减少 Draw Call”其实是不准确的,真正的性能杀手是 SetPass Call

  • Draw Call (命令开销): 仅仅是向 Command Buffer 添加一条 DrawIndexed 指令,开销极小。
  • SetPass Call (状态切换): 切换 GPU 的渲染状态(Blend Mode, Depth Test, Shader Program)。这是昂贵的操作,可能导致流水线停顿(Pipeline Stall)。

SRP Batcher 的核心逻辑: 即使有 1000 个 Draw Call,只要它们共享同一个 Shader Variant,就只有 1 次 SetPass Call。这比传统的合批更高效且灵活。


4. 优化建议:如何利用好 SRP Batcher

  1. Shader 兼容性: 确保自定义 Shader 支持 SRP Batcher(在 Inspector 中查看兼容性提示)。
  2. 减少 Shader Variant (变体):
    • 核心矛盾: 不同的变体 = 不同的 Shader Program = 必须切换 SetPass = 打断合批
    • 优化策略:
      • 避免滥用 shader_feature(静态分支),这会生成不同变体。
      • 权衡:对于由于贴图采样(如 NormalMap)导致的变体,可以考虑统一开启(Force Enable)。虽然增加了一点 GPU 采样开销(采样默认的平坦法线),但消除了 SetPass 切换,通常整体性能更优。
  3. 避免频繁 SetFloat:
    • 脚本中如非必要,不要每帧设置材质属性。
    • 即使是过渡效果(如溶解),数值到达 0 或 1 后应停止 Set 操作,避免触发持续的 GPU 数据上传。
  4. API 验证: 在 Editor 中测试合批时,务必将 Graphics API 切换为与目标平台一致(如 Android 选 OpenGLES/Vulkan),因为不同 API 的 Uniform Buffer 布局规则可能影响合批结果。

Shader 加载机制与变体 (Variant) 管理优化

1. Shader 的生命周期与内存管理

Unity 中 Shader 的加载过程涉及多次数据转换与内存操作,理解这一过程对于优化内存和启动时间至关重要。

1.1 初始化与解压 (Initialization & Decompression)

  • Bundle Data: 打包后的 AssetBundle 中存储的是压缩的 Shader 数据 (LZ4/LZMA)。
  • Awake:
    1. Deserialization: 将 Bundle 中的数据反序列化到内存。
    2. Decompression: 解压为 Track 数据,随后销毁原始 Bundle 数据。
    3. SubPrograms Creation: 创建 SubProgram 对象列表(对应底层的 VS/PS 代码片段)。
    4. Binary Data: 此时,Shader 代码仍以平台相关的二进制数据 (Binary Data) 形式存在于 CPU 内存中(如 DXBC, GLSL Binary, SPIR-V),尚未上传 GPU。
    5. Metadata Cleanup: 创建完 SubPrograms 后,销毁中间层的 Metadata。

1.2 GPU 程序创建 (GPU Program Creation)

  • 触发时机:
    • 首次使用 (First Use): 渲染时遇到了某个变体。
    • 预热 (Warmup): 主动调用 Shader.WarmupAllShadersShaderVariantCollection.Warmup
  • 流程:
    1. 从 CPU 内存中的 Binary Data 读取对应变体的数据。
    2. 调用图形 API 创建实际的 GPU Program (如 glCreateProgram, ID3D11Device::CreateVertexShader)。
    3. 销毁 CPU 数据 (Crucial): 一旦上传成功,该变体对应的 CPU Binary Data 会被销毁,释放系统内存。
  • API 差异:
    • OpenGL/Metal: glLinkProgram / newLibraryWithSource。如果是第二次加载(有缓存),可能直接加载 Binary/Cache。
    • DX11: CreateVertexShader
    • DX12/Vulkan: 不需要创建传统的 Program 对象,而是将 ByteCode 缓存起来,用于后续创建 Pipeline State Object (PSO)。

1.3 内存优化的启示

  1. 变体剔除 (Variant Stripping):
    • 问题: 如果一个变体永远未被用到(也未预热),其 CPU Binary Data 会一直驻留在内存中
    • 后果: 造成“死内存”浪费。
    • 对策: 严格剔除无用变体(使用 IPreprocessShaders)。
  2. 主动预热 (Active Warmup):
    • 现象: 游戏运行一段时间后,ShaderLab 内存占用反而下降
    • 原因: 随着变体被用到,CPU Binary Data 被上传并销毁。
    • 策略: 在 Loading 阶段主动预热常用变体,不仅消除运行时卡顿(Shader 编译/上传耗时),还能更早释放 CPU 内存
  3. 分级预热:
    • 对于极少进入的场景(如隐藏关卡),不要在游戏启动时预热其 Shader,而是推迟到该场景加载时,避免占用宝贵的启动内存。

2. 变体匹配机制 (Variant Matching)

当渲染请求一个变体(例如 Keywords: A B),但该变体未被打包时,引擎如何处理?

2.1 打分算法 (Scoring)

引擎会遍历所有已打包的变体,计算一个“匹配分数”,选择分数最高的那个(通常是 Fallback)。

  • 位掩码 (Bitmask): 变体关键字被映射为 Bitmask。
  • 算法逻辑:
    • Match (匹配项): (Request & Candidate) 计算重合的关键字数量(1 的个数)。
    • Mismatch (不匹配项): (~Request & Candidate) 计算多余的关键字数量。
    • Score: Score = MatchCount - (MismatchCount * 16)
  • 结论: 惩罚因子 (16) 很大,意味着宁可少一个关键字(功能缺失),也不要多一个无关关键字(功能错误)。如果完全无法匹配,可能会渲染成粉色 (Pink) 或使用极其基础的 Fallback。

3. 渲染状态优化与底层 API

3.1 批量资源绑定 (Batch Resource Binding)

  • 现状: 有时 RenderDoc 中会看到连续的 API 调用:
    SetTexture(0, TexA);
    SetTexture(1, TexB);
    SetTexture(2, TexC);
    这导致了多次 API 开销。
  • 优化方向 (需修改引擎/使用 SRP):
    • 现代 API (DX12/Vulkan) 支持 Descriptor TablesBindless
    • 即使是 DX11/GL,也支持 PSSetShaderResources(StartSlot, Count, Views)
    • 理想情况是一次调用设置多个纹理

3.2 Material Buffer 内存类型优化

  • 问题: UnityPerMaterial 使用的是 Host Memory (CPU 内存),通过 PCIe 总线映射给 GPU。
    • 每次 GPU 读取(渲染)都要走 PCIe 总线。
  • 优化思路 (ReBAR / Dedicated Heap):
    • 如果材质数据很少变化,应该将其上传到 Device Local Memory (显存)。虽然更新慢(需要 Staging Buffer),但 GPU 读取极快。
    • Unity 默认策略是为了适应“材质可能频繁变动”的通用情况,但在特定项目中可能非最优。

总结:Shader 优化的核心原则

  1. Strip Hard: 狠心剔除。没用的变体就是内存泄漏。
  2. Warmup Smart: 聪明预热。为了不卡顿,也为了省内存。
  3. Batch Bind: 批量绑定。减少 API 调用次数。

材质内存优化与 GPU Driven 渲染架构

1. 材质系统内存优化策略

在通用引擎架构中,材质数据的更新往往是性能瓶颈。针对 UnityPerMaterial(低频更新数据),可以通过优化内存驻留策略来减少 PCIe 带宽消耗。

1.1 显存驻留优化 (Staging Buffer 机制)

  • 常规模式 (Host Memory):
    • Unity 默认通常申请 CPU/GPU Shared Buffer (Host Visible)。
    • 缺点: 每次 GPU 读取数据(渲染每一帧)都需要跨越 PCIe 总线访问系统内存,或触发每帧的上传操作。
  • 优化模式 (Device Local Memory):
    • 双 Buffer 策略: 申请一份 Staging Buffer (CPU 可写) 和一份 Device Local Buffer (仅 GPU 显存)。
    • 更新流程:
      1. CPU 写入 Staging Buffer。
      2. 执行一次 Copy 操作 (Staging Device)。
      3. 后续帧渲染时,GPU 直接从显存读取数据 (Device Local)。
    • 收益: 极大减少了每帧渲染时的 PCIe 传输耗时,因为数据就在 GPU 芯片旁。

1.2 动态更新策略 (Heuristic Update)

针对材质更新频率的不同,可以设计自适应的底层机制:

  • 高频更新: 如果材质每帧都在变(如动态特效),直接使用 Host Memory,避免双重拷贝的开销。
  • 低频更新: 引入 “老化”计数器 (Aging Counter)
    • 如果某材质连续 帧(如 60 帧)未发生数据变更。
    • 自动卸载 CPU 端 Buffer,重新申请并上传到 GPU Device Local Memory
    • 收益: 在不改变上层业务代码的前提下,自动优化静态物体的渲染性能。

2. 渲染管线架构深度优化

Profiler 分析显示,渲染的主要开销往往集中在两块:

  1. Culling: 视锥体剔除计算。
  2. Render Job: 渲染数据的准备与序列化(将数据 IO 写入到多线程 Buffer 中)。

2.1 通用架构 vs. 专用架构

  • 通用引擎的负担: Unity 为了兼容所有情况(通用剔除、排序、各种 Filter),带来了巨大的维护开销和数据结构冗余。
  • 定制化优化 (Domain Specific):
    • 案例: 渲染百万根草。
    • 通用方案: 引擎对每根草做剔除、排序、合批 CPU 爆炸。
    • 优化方案: 利用 Domain Knowledge (领域知识)。草不需要精确排序,也不需要逐根剔除。可以将 1000 根草打包为一个 Chunk 进行粗粒度剔除。

2.2 GPU Driven Rendering (GPU 驱动渲染)

为了彻底消除 CPU 端的 Culling 和 Data Preparation 开销,可以将管线逻辑移至 GPU。

  • 流程:
    1. Compute Shader 剔除: 在 GPU 上对所有实例进行视锥体/遮挡剔除。
    2. AppendBuffer / Counter: 将可见的实例 ID 写入一个 AppendStructuredBuffer,GPU 会自动维护一个 Counter (计数器,如可见数量 136)。
    3. Indirect Draw: 使用 DrawMeshInstancedIndirect (或 DrawProceduralIndirect)。
      • CPU 不知道也不关心有多少物体可见。
      • CPU 仅提交一个“间接绘制指令”,GPU 读取 buffer 中的 Counter 进行绘制。
  • 收益:
    • (仅需极少的 Dispatch 和 Indirect Draw 调用)。
    • 消除了 CPU 到 worker threads 的数据序列化开销。

3. Native Plugin 原生渲染插件开发

当 Unity 内置功能无法满足需求(如接入特定版本的 DLSS、专有抗锯齿库、视频编解码 AVPro)或需要极致性能时,需要开发 Native Plugin 直接操作底层图形 API。

3.1 核心工作流

  1. C++ 端: 编写动态链接库 (.dll / .so),实现具体的图形逻辑(如 DX12, Vulkan 调用)。
  2. C# 端:
    • Import: 使用 [DllImport] 加载插件。
    • Event: 定义 IssuePluginEvent 接口。
    • Submit: 通过 CommandBuffer.IssuePluginEvent(callback, eventID) 在渲染管线的特定时机触发 C++ 回调。

3.2 资源句柄映射 (Resource Interop)

Unity 提供了接口将上层对象转换为底层 API 的原生指针 (Native Handle)。

  • RenderTexture (RT):
    • 关键点: 必须先调用 rt.Create() 确保底层资源已分配。
    • API: rt.colorBuffer.GetNativeRenderBufferPtr()rt.depthBuffer.GetNativeRenderBufferPtr()
  • Texture: texture.GetNativeTexturePtr()
  • Mesh: 可以获取顶点和索引缓冲区的指针 mesh.GetNativeVertexBufferPtr()
  • ComputeBuffer: buffer.GetNativeBufferPtr()

3.3 代码示例逻辑

// 1. 创建 RT 并确保硬件资源已分配
RenderTexture rt = new RenderTexture(...);
rt.Create(); 
 
// 2. 获取底层指针 (如 DX12 Resource Pointer)
IntPtr nativePtr = rt.colorBuffer.GetNativeRenderBufferPtr();
 
// 3. 传递给 C++ 插件
MyNativePlugin.SetTextureFromUnity(nativePtr);
 
// 4. 在渲染管线中调度
cmd.IssuePluginEvent(GetRenderEventFunc(), eventID);

注意: 不同图形 API (DX11 vs DX12 vs Vulkan) 返回的 IntPtr 含义不同,C++ 端需要根据平台宏进行相应的转换(如强转为 ID3D12Resource*)。


Native Plugin 外部资源创建与 Bindless 架构

1. 外部资源的创建与共享 (External Resources)

在 Native Plugin 中使用 DirectX/Vulkan 创建资源,并让 Unity “视如己出”地使用它们,是高级插件开发的核心能力。

1.1 资源句柄传递 (Handle Passing)

不同图形 API 的资源句柄含义不同,在 C++ 和 C# 之间传递时需注意:

  • OpenGL: GLuint (Texture ID)。
  • DirectX 11: ID3D11ShaderResourceView*
  • DirectX 12: ID3D12Resource*
  • Vulkan: VkImage
  • Metal: id<MTLTexture>

1.2 创建外部纹理 (CreateExternalTexture)

Unity 提供了 Texture2D.CreateExternalTexture 方法来包装底层的原生指针。

// C# Side
IntPtr nativePtr = MyPlugin.GetTexturePointer(); // 从插件获取 C++ 创建的纹理指针
Texture2D tex = Texture2D.CreateExternalTexture(width, height, format, ..., nativePtr);
 
// 现在可以将 tex 赋值给 Material,Unity 会直接使用底层的那个资源,零拷贝
material.mainTexture = tex; 
  • 应用场景: 视频播放器 (AVPro Video) 解码出的纹理直接在 Unity 中渲染,无需 CPU 回读。

1.3 插件生命周期 (Plugin Lifecycle)

Native Plugin 必须遵循特定的初始化与销毁流程,以确保与 Unity 渲染线程的安全交互。

  • UnityPluginLoad: 插件加载时调用,获取 IUnityInterfaces
  • UnityPluginUnload: 插件卸载时清理。
  • OnRenderEvent: 核心回调,响应 GL.IssuePluginEvent

2. 状态追踪与恢复 (State Tracking)

Unity 引擎底层维护了一套复杂的渲染状态(Render State),如果 Native Plugin 擅自修改了这些状态(如修改了 RootSignature, PipelineState),会导致 Unity 后续的渲染出错。

2.1 状态失效标志 (State Invalidation Flags)

在调用 IssuePluginEvent 时,可以通过 flags 告知引擎插件修改了哪些状态,让 Unity 在回调结束后自动恢复。

  • kUnityRenderingExtEventSetStereoTarget: 修改了 VR 目标。
  • kUnityRenderingExtEventUpdateTexture: 仅更新纹理,不改变状态。
  • 无 Flag: 默认情况下,Unity 会假设插件修改了所有状态,因此在回调结束后会执行一次开销较大的 Full State Reset

3. 高级案例:Shader Complexity View (复杂度视图)

这是一个通过 Native Plugin 实现 Unity 缺失功能的经典案例(类似 UE 的 Shader Complexity)。

3.1 实现原理 (Hook & Override)

  1. Hook Creation: 在 C++ 层拦截 Pixel Shader (PS) 的创建。
  2. Instruction Counting: 分析 DXBC/DXIL 字节码,统计每个 PS 的指令数 (Instruction Count)。
  3. Hook DrawCall: 拦截 DrawCall。
  4. Override State:
    • 临时切换 Render Target 到一个累积缓冲区 (Heatmap RT)。
    • 开启 Additive Blending (叠加混合)。
    • 替换 PS 为一个输出固定颜色的 Shader(颜色值 = 指令数权重)。
  5. Restore: 绘制完成后,恢复原始状态,让 Unity 继续正常的渲染流程。

4. Bindless (无绑定) 架构与 Unity

Bindless 是现代图形 API (DX12/Vulkan) 的一项重要特性,允许 Shader 访问几乎无限数量的资源,而无需频繁绑定 Descriptor Table。

4.1 传统绑定 vs. Bindless

  • 传统 (Bound):
    • Shader: Texture2D _MainTex : register(t0);
    • CPU: SetTexture(0, ptrA);
    • 限制: Slot 数量有限 (DX11 只有 128 个),每次切换材质都要重新绑定。
  • Bindless:
    • Shader: Texture2D Textures[] : register(t0, space1);
    • Shader Access: Textures[materialIndex].Sample(...)
    • CPU: 一次性将所有纹理上传到一个巨大的 Descriptor Heap。
    • 优势: DrawCall 之间无需切换纹理绑定,只需传递一个整数索引 (Index)。

4.2 Unity 的支持现状

  • Unity 现状: 默认不支持 Bindless(因为要兼容老旧平台)。
  • Native Plugin 实现:
    • 可以通过插件接管资源的 Descriptor Heap 管理。
    • 在 C++ 层构建 Bindless Table。
    • 通过 ConstantBuffer 传递索引给 Shader。
    • 这使得 Unity 也能享受到 Bindless 带来的 DrawCall 极大优化(尤其是大量不同纹理物体的渲染)。

Bindless 渲染架构与平台适配底层原理

1. Bindless Rendering (无绑定渲染) 与 Visibility Buffer

随着光线追踪(Ray Tracing)和现代 GPU 架构的发展,传统的“绑定槽位(Slot-based Binding)”模式已成为瓶颈。Bindless 技术通过全局堆访问彻底改变了资源管理方式。

1.1 核心理念

  • 传统模式的局限:
    • 每个 Draw Call 必须显式绑定纹理到特定槽位(如 t0, t1)。
    • 槽位限制: DX11 仅支持 128 个纹理,且受限于 Root Signature 的大小。
    • Texture Arrays 限制: 要求数组内所有纹理尺寸、格式一致,灵活性差。
  • Bindless 模式:
    • 全局堆 (Global Heap): 所有纹理资源驻留在一个巨大的描述符堆 (Descriptor Heap) 中。
    • 指针化访问: 资源不再绑定到槽位,而是通过 索引 (Index)GPU 虚拟地址 (Pointer) 直接访问。
    • 类型化资源: 在 Shader 代码中,将资源地址视为一种“类型”,例如:
      // 伪代码:通过地址将其“认证”为 Texture2D
      Texture2D<float4> myTex = ResourceDescriptorHeap[materialData.textureIndex];
      float4 color = myTex.Sample(sampler, uv);

1.2 Visibility Buffer (V-Buffer) 架构

Bindless 是实现 Visibility Buffer 的基础技术:

  1. G-Buffer 瘦身: 屏幕空间不再存储 Albedo/Normal/Roughness 等重数据,而是仅存储 Material IDTriangle ID (或 Primitive ID)。
  2. 延迟解析:
    • 读取当前像素的 Material ID
    • 从全局材质数据缓冲区 (StructuredBuffer) 获取该材质的数据(包含纹理的 Heap Address)。
    • 利用 Bindless 技术,直接根据地址从全局堆中采样纹理。
  3. 优势: 极大地降低了带宽消耗,解决了“贴图过多导致 G-Buffer 膨胀”的问题,同时天然支持 虚拟纹理 (Virtual Texturing)

2. 在 Unity 中 Hack 实现 Bindless (Native Plugin)

由于 Unity 的 RHI 层(尤其是在旧版本或兼容模式下)默认不支持 Bindless,可以通过 Native Plugin 对底层 DX12 进行 Hook 来强行实现。

2.1 实现原理 (Hooking DX12)

插件通过拦截 Unity 底层的 D3D12 调用来注入 Bindless 所需的逻辑:

  1. Hook Root Signature:
    • 拦截管线状态对象 (PSO) 或 Root Signature 的创建过程。
    • 注入 Flag: 强制添加 D3D12_ROOT_SIGNATURE_FLAG_CBV_SRV_UAV_HEAP_DIRECTLY_INDEXED 等标志,开启 Bindless 支持。
  2. Hook Descriptor Heap:
    • 获取 Unity 创建的主 SRV Descriptor Heap 的 基地址 (Base Address)容量 (Capacity)
    • 通常 Unity 会分配一个巨大的堆(例如 200,000 个描述符)。

2.2 内存布局策略 (Collision Avoidance)

  • Unity 的行为: 作为一个“黑盒”,Unity 通常采用线性分配器 (Linear Allocator),每一帧从堆的 头部 (Front) 开始向后分配资源。
  • 插件的策略: 为了不与 Unity 冲突,插件通常从堆的 尾部 (Back) 向前分配 Bindless 资源。
    • C# 接口: 暴露 API 让上层脚本指定索引,在堆的末端创建 SRV。
  • 风险 (Risk):
    • 这依赖于对 Unity 底层行为的经验性假设(Assume 它是每帧重置的线性分配)。
    • 如果 Unity 未来版本改为 Ring Buffer (环形缓冲区) 或其他复杂的分配策略,这种 Hack 方式会立即失效导致冲突。

3. 新平台适配 (New Platform Porting)

当需要将引擎移植到一个全新的操作系统或硬件平台时,核心工作在于 窗口系统 (Window System)图形上下文 (Graphics Context) 的对接。

3.1 适配分层策略

  1. 标准图形接口 (EGL/WGL):
    • 如果新平台支持标准的 EGL(嵌入式图形库),适配工作量较小。
    • 核心任务: 获取原生窗口句柄 传递给 EGL 创建 OpenGL/Vulkan Context。
  2. 自定义图形接口:
    • 如果平台使用私有 API(如 PS4/PS5 GNM, Switch NVN),则需要重写整个 RHI (GfxDevice) 层。

3.2 案例:Android 平台适配流程

  1. Java 层 (SDK):
    • Android Activity 管理生命周期。
    • SurfaceView 创建并持有一个 Surface 对象。
  2. JNI 层 (C++ Bridge):
    • Java 通过 JNI 将 Surface 对象传递给 C++ 层。
    • C++ 调用 ANativeWindow_fromSurface 将 Surface 转换为原生窗口句柄 ANativeWindow
  3. 渲染层 (EGL):
    • ANativeWindow 句柄传递给 eglCreateWindowSurface
    • 此时 EGL 建立了 GPU 与 Android 窗口系统的连接,后续即可进行 eglSwapBuffers

总结: 平台适配的本质是打通 OS Window ManagerGraphics Driver 之间的桥梁,通常最终归结为传递一个 void* 类型的窗口句柄。