WEBKT

GPU-Driven与Bindless渲染管线架构设计与落地实践

26 0 0 0

在传统的 CPU-Driven 渲染管线中,CPU 需要在每一帧中承担大量的任务:视锥体裁剪、LOD 计算、更新常量缓冲区、绑定材质资源(Texture/Buffer),以及提交数以千计的 Draw Call。随着场景复杂度的飙升,CPU 提交线程往往会成为严重的性能瓶颈。

为了彻底释放硬件潜力,现代图形 API(Direct3D 12 和 Vulkan)引入了 Bindless(无绑定) 特性。结合 GPU-Driven(GPU 驱动) 的思想,我们可以将原本由 CPU 主导的裁剪、LOD 抉择、Draw Call 命令行生成全部移至 GPU(通过 Compute Shader 实现)。CPU 每帧只需提交极少数的调度指令(如 ExecuteIndirect / glMultiDrawElementsIndirect),从而实现海量高精模场景的超高性能渲染。

本文将深入探讨一套完全基于 GPU-Driven 与 Bindless 的渲染管线的设计架构与核心实现细则。


一、 核心架构图景

整套管线的设计目标是:Data-Oriented(面向数据)

CPU 在初始化和场景加载时,将所有的网格数据、材质参数、贴图资源一次性送入 GPU 的全局大 Buffer 与描述符堆中。在运行时,CPU 不再频繁切换管线状态和绑定资源,而是充当一个“启动器”。

+-----------------------------------------------------------------------------+
|                                  CPU                                        |
|  1. 收集相机参数与视锥数据                                                   |
|  2. 提交单个 Compute Shader 调度命令 (Dispatch)                              |
|  3. 提交单个间接绘制命令 (ExecuteIndirect / MultiDrawIndirect)                |
+-----------------------------------------------------------------------------+
                                       |
                                       v (GPU Pipeline)
+-----------------------------------------------------------------------------+
|                            Compute Shader (Culling)                         |
|  - 视锥裁剪 (Frustum Culling)                                                |
|  - 遮挡裁剪 (Occlusion Culling / Hi-Z Test)                                  |
|  - 计算每个 Instance 的 LOD,填充 DrawArgumentsBuffer 和 VisibleInstanceBuffer |
+-----------------------------------------------------------------------------+
                                       |
                                       v
+-----------------------------------------------------------------------------+
|                         Indirect Draw Call Execution                         |
|  - 依据 GPU 写入的 DrawArgumentsBuffer,直接在显卡端发起绘制                  |
+-----------------------------------------------------------------------------+
                                       |
                                       v
+-----------------------------------------------------------------------------+
|                            Vertex & Pixel Shader                            |
|  - 顶点着色器通过 InstanceID 索引全局 InstanceBuffer 获取矩阵                  |
|  - 像素着色器通过 MaterialID 从 Bindless Descriptor Heap 索引贴图并采样      |
+-----------------------------------------------------------------------------+

二、 Bindless 资源管理设计

在传统管线中,我们需要为每个 Mesh 绑定特定的 Texture2D。而在 Bindless 管线中,所有资源都存放于全局描述符堆(Descriptor Heap / Descriptor Array)中,着色器通过一个简单的 uint 索引来动态寻址。

1. 材质与贴图的无绑定表示

在着色器端,我们需要声明一个全局的贴图数组,并允许无边界(Unbounded)索引。

HLSL (Shader Model 6.6+) 实现:

// 全局无绑定纹理堆
Texture2D g_BindlessTextures[] : register(t0, space1);
SamplerState g_BindlessSamplers[] : register(s0, space2);

// 材质属性结构体(不包含复杂的绑定状态,仅包含索引和基础标量)
struct MaterialData
{
    uint DiffuseTexIndex;
    uint NormalTexIndex;
    uint RoughnessMetallicTexIndex;
    uint Padding;
    float4 BaseColorFactor;
};

// 全局材质缓冲区
StructuredBuffer<MaterialData> g_Materials : register(t0, space0);

2. CPU 侧的描述符生命周期与分配

CPU 需要维护一个虚拟的 Descriptor Allocator

  • 静态资源(如关卡贴图):在加载时分配,获取固定的 Heap Index,直到关卡卸载才释放。
  • 动态资源(如运行时生成的 RT、UI、虚拟纹理页):使用环形缓冲区(Ring Buffer)或空闲链表(Free List)算法进行动态分配与回收。
  • 更新机制:利用 CopyDescriptors (DX12) 或 vkUpdateDescriptorSets (Vulkan) 将具体的资源视图写入全局堆对应的槽位中。

三、 GPU-Driven 核心:数据布局与裁剪流水线

GPU-Driven 的核心在于:不向 GPU 隐瞒任何场景树结构,让显卡自己遍历和筛选物体

1. GPU 显存数据结构设计

我们需要在显卡中常驻以下几个关键缓冲区:

  • InstanceBuffer(实例缓冲区):存放场景中所有物件实例的 Transform 矩阵、Bounding Box、材质 ID、LOD 级数等信息。
  • VisibleInstanceBuffer(可见实例索引缓冲区):这是一个空缓冲区,Compute Shader 裁剪通过后,会将通过测试的 InstanceID 写入其中。
  • DrawArgumentsBuffer(间接绘制参数缓冲区):存放 D3D12_DRAW_INDEXED_ARGUMENTSVkDrawIndexedIndirectCommand。其内容将由 Compute Shader 动态写入。
struct InstanceData
{
    float4x4 LocalToWorld;
    float3   BoundingBoxCenter;
    float3   BoundingBoxExtent;
    uint     MaterialID;
    uint     MeshID; // 用于寻址网格几何信息(顶点/索引偏移)
    uint     Padding[3];
};

struct DrawIndexedArguments
{
    uint IndexCountPerInstance;
    uint InstanceCount;         // Compute Shader 将递增此值
    uint StartIndexLocation;
    int  BaseVertexLocation;
    uint StartInstanceLocation;
};

2. 两阶段 GPU 裁剪与 LOD 决策

为了实现绝对极致的裁剪效率(尤其是在海量草地、植被、城市场景中),我们采用 Two-Phase Culling(两阶段裁剪)

Phase 1:上一帧深度(Hi-Z Buffer)重投影与保守裁剪

  1. 视锥裁剪 (Frustum Culling):利用 Instance 的 Bounding Box 与当前帧摄像机视锥体的 6 个平面进行相交测试。
  2. 遮挡裁剪 (Occlusion Culling)
    • 利用上一帧生成的 Z-Buffer,构建一套 Hi-Z Pyramid(分级高程图)。
    • 将当前 Instance 的 Bounding Box 投影到屏幕空间,计算其包围盒占用的屏幕 Mip 级别。
    • 采样该 Mip 级别的 Hi-Z 深度值,如果物体的最近深度大于 Hi-Z 的最深深度,则说明该物体被遮挡,不进行绘制。

Phase 2:当前帧遮挡裁剪与 Late Latch

对于在 Phase 1 中由于相机移动而被误判为遮挡,但实际上已经暴露出来的物体,在主 Pass 渲染完毕后,进行一次补漏(Second Pass),利用当前帧刚写好的新深度图重新测试那些被剔除的物体,确保画面无破绽。

Compute Shader 裁剪核示例代码:

#define THREAD_GROUP_SIZE 64

struct CameraParams
{
    float4x4 ViewProjection;
    float4   FrustumPlanes[6];
    float3   CameraPosition;
};

ConstantBuffer<CameraParams> g_Camera : register(b0);
StructuredBuffer<InstanceData> g_AllInstances : register(t1);
RWStructuredBuffer<uint> g_VisibleInstanceIndices : register(u0);
RWStructuredBuffer<DrawIndexedArguments> g_IndirectArgs : register(u1);

// 简化的视锥体裁剪函数
bool IsInFrustum(InstanceData instance)
{
    float3 center = mul(instance.LocalToWorld, float4(instance.BoundingBoxCenter, 1.0f)).xyz;
    // 简化处理:此处需根据 LocalToWorld 的缩放调整 Extent
    float3 extent = instance.BoundingBoxExtent; 

    for (int i = 0; i < 6; ++i)
    {
        float4 plane = g_Camera.FrustumPlanes[i];
        float d = dot(center, plane.xyz) + plane.w;
        float r = dot(extent, abs(plane.xyz));
        if (d + r < 0.0f) return false;
    }
    return true;
}

[numthreads(THREAD_GROUP_SIZE, 1, 1)]
void CSMain(uint3 dispatchThreadID : SV_DispatchThreadID)
{
    uint instanceIndex = dispatchThreadID.x;
    if (instanceIndex >= g_TotalInstanceCount) return;

    InstanceData instance = g_AllInstances[instanceIndex];

    // 1. 视锥裁剪
    if (IsInFrustum(instance))
    {
        // 2. TODO: 在此处加入 Hi-Z 遮挡测试
        
        // 3. 写入可见性缓冲区,并原子递增 Draw Call 的 InstanceCount
        uint visibleOffset;
        InterlockedAdd(g_IndirectArgs[instance.MeshID].InstanceCount, 1, visibleOffset);
        
        // 将可见实例的实际索引记录下来,后续 Vertex Shader 提取数据使用
        g_VisibleInstanceIndices[g_IndirectArgs[instance.MeshID].StartInstanceLocation + visibleOffset] = instanceIndex;
    }
}

四、 极简的绘制阶段(Draw Loop)

在 GPU-Driven 模式下,CPU 侧的绘制循环变得异常干净。再也没有了对各种 Mesh.Bind()Material.SetTexture() 的迭代,取而代之的是一次性、高并发的间接调用。

D3D12 伪代码示例:

// 1. 准备工作:清除 Indirect 缓冲区中的 InstanceCount 为 0
m_CommandList->CopyBufferRegion(g_IndirectArgsBuffer, 0, g_ResetBuffer, 0, sizeof(DrawIndexedArguments) * m_MeshCount);

// 2. 调度 Compute Shader 执行裁剪与参数填充
m_CommandList->SetComputeRootSignature(m_CullingRootSignature);
m_CommandList->SetComputeRootConstantBufferView(0, m_CameraCBV);
m_CommandList->SetComputeRootShaderResourceView(1, m_AllInstancesBuffer->GetGPUVirtualAddress());
m_CommandList->SetComputeRootDescriptorTable(2, m_UAVDescriptorTable); // 绑定 VisibleBuffer 与 ArgsBuffer
m_CommandList->Dispatch(DIV_CEIL(m_TotalInstanceCount, 64), 1, 1);

// 插入资源屏障,确保 Compute Shader 写入完成
D3D12_RESOURCE_BARRIER barrier = CD3DX12_RESOURCE_BARRIER::Transition(
    g_IndirectArgsBuffer, D3D12_RESOURCE_STATE_UNORDERED_ACCESS, D3D12_RESOURCE_STATE_INDIRECT_ARGUMENT);
m_CommandList->ResourceBarrier(1, &barrier);

// 3. 发起超级间接绘制 (ExecuteIndirect)
m_CommandList->SetGraphicsRootSignature(m_RenderRootSignature);
// 绑定全局 Bindless 资源堆
ID3D12DescriptorHeap* heaps[] = { m_GlobalDescriptorHeap.Get() };
m_CommandList->SetDescriptorHeaps(_countof(heaps), heaps);

// 设置管线状态 (PSO)
m_CommandList->SetPipelineState(m_RenderPSO);
m_CommandList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);

// 执行间接绘制:一个 API 调用绘制整张地图的所有 Mesh
m_CommandList->ExecuteIndirect(
    m_CommandSignature.Get(),   // 包含 DrawIndexed 参数布局的签名
    m_MeshCount,                // 绘制的最大批次(通常对应不同的几何体)
    g_IndirectArgsBuffer,       // 参数缓冲
    0,                          // 偏移量
    g_IndirectArgsBuffer,       // 计数缓冲(可选,用于动态裁剪批次数)
    m_MeshCount * sizeof(DrawIndexedArguments)
);

在着色器端,Vertex Shader 通过系统的 SV_InstanceID 间接寻址,找到正确的实例属性:

struct VSInput
{
    float3 Position : POSITION;
    float2 UV       : TEXCOORD0;
};

struct VSOutput
{
    float4 Position  : SV_Position;
    float2 UV        : TEXCOORD0;
    uint   MaterialID: BLENDINDICES0;
};

StructuredBuffer<uint> g_VisibleInstanceIndices : register(t2);

VSOutput VSMain(VSInput input, uint rawInstanceID : SV_InstanceID)
{
    VSOutput output;
    
    // 从可见列表中还原出真实的 InstanceID
    uint actualInstanceID = g_VisibleInstanceIndices[rawInstanceID];
    InstanceData instance = g_AllInstances[actualInstanceID];
    
    float4 worldPos = mul(instance.LocalToWorld, float4(input.Position, 1.0f));
    output.Position = mul(g_Camera.ViewProjection, worldPos);
    output.UV = input.UV;
    output.MaterialID = instance.MaterialID;
    
    return output;
}

像素着色器(Pixel Shader)拿到 MaterialID 后,直接定位材质参数,并根据索引去 Bindless 堆中进行采样:

float4 PSMain(VSOutput input) : SV_Target
{
    MaterialData mat = g_Materials[input.MaterialID];
    
    // 利用无绑定特性,根据索引动态获取贴图和采样器
    Texture2D diffuseTex = g_BindlessTextures[mat.DiffuseTexIndex];
    SamplerState texSampler = g_BindlessSamplers[mat.NormalTexIndex]; // 亦可使用全局统一的 Sampler
    
    float4 albedo = diffuseTex.Sample(texSampler, input.UV) * mat.BaseColorFactor;
    return albedo;
}

五、 落地实践中的“深坑”与解决方案

尽管完全 GPU-Driven + Bindless 的架构拥有令人兴奋的理论性能,但在实际落地中会面临不少工程挑战:

1. 资源生命周期悬挂问题 (Resource Lifetime & Aliasing)

  • 痛点:因为资源是在 Shader 里动态索引的,CPU 无法通过传统的绑定轨迹感知哪些纹理“正在被使用”。如果不加克制地在 CPU 侧释放(Release)贴图,极易引发 GPU 崩溃或页错误(Page Fault)。
  • 解决方案:建立基于 Frame Index 的延迟释放队列。当一个场景物体被销毁,其引用的 Bindless Index 并不能立即回收,而是标记为“在当前帧 $+ N$(例如 $N=3$,对应渲染延迟帧数)后安全释放”。

2. 材质变体(Shader Permutations)爆炸

  • 痛点:如果不同的材质需要不同的 Shader Code(比如有些有各向异性,有些需要三向投影),这会破坏我们“一个 Draw Call 解决一切”的设想,因为 ExecuteIndirect 无法在批处理内切换 Pipeline State Object (PSO)。
  • 解决方案
    • 超级着色器 (Megashader):将常见的材质特性整合进一个大型着色器中,内部使用分支逻辑。现代显卡在同个 Warp 内执行相同分支时几乎无开销。
    • 按 PSO 进行多批次 indirect 绘制:将材质分类。例如:不透明、半透明、特殊材质。在 CPU 端对 InstanceBuffer 进行归类,针对 3-4 个主 PSO 分别进行一次 ExecuteIndirect

3. 硬件兼容性与分级机制

  • 痛点:并非所有运行环境都支持最高阶的 Bindless 特性。例如,老旧硬件不支持 ResourceBindingLimits.MaxDescriptorSetUpdateAfterBind,或者不支持 Wave Intrinsics。
  • 分级策略
    • Tier 1 (基础兼容):对于老显卡,退回到传统的实例化合并(Instancing)和基于多套 Descriptor Set 的静态划分。
    • Tier 2 (主流标准):使用基本 Bindless,通过 Compute Shader 进行基础视锥裁剪,由 CPU 读取计数器并进行普通绘制。
    • Tier 3 (极致性能):全套 GPU-Driven。启用 ExecuteIndirect(DX12)或 vkCmdDrawIndexedIndirectCount(Vulkan 1.2+),支持 Hi-Z 遮挡裁剪。

六、 架构总结与落地收益

设计这套管线绝不仅仅是为了技术上的优雅,其在工程上的收益是颠覆性的:

指标 传统 CPU-Driven + Bind 现代 GPU-Driven + Bindless
CPU 占用 极高(受制于大量的 Draw API 提交线程) 极低(只发出一组 Dispatch 和 Draw 调用)
Draw Call 数量 几千到上万个独立的 Draw Call 压缩到十几个合并的大范围 Indirect Draw
材质切换开销 极高(每次切换材质都要重新 Bind 描述符) 无开销(仅需传递一个 uint 材质索引值)
裁剪精度 粗糙(通常是基于 CPU 场景树的 AABB) 像素级(基于 GPU 遮挡裁剪与 Hi-Z)

完全基于 GPU-Driven 与 Bindless 的架构是迈向现代高端游戏渲染的必经之路。虽然前期架构重构和显存管理器的编写成本较高,但一旦成型,引擎将获得不可估量的吞吐量优势,为后续落地海量植被、微多边形渲染(类似 Nanite 架构)打下最坚实的底层基石。

拓扑视界 GPU驱动渲染渲染管线设计

评论点评