WEBKT

WebGPU无成吨Draw Call时代:高并发GPU-Driven渲染与Multi-Draw Indirect优化实战

7 0 0 0

在传统Web3D引擎(如基于WebGL 1或WebGL 2的引擎)中,当场景中有成千上万个独立物体(如森林中的树木、城市中的建筑、大规模粒子)时,CPU往往会成为致命的瓶颈。即使每个物体的网格和材质完全相同,单纯由于CPU提交Draw Call(绘制调用)带来的上下文切换与驱动开销,就会让帧率急剧下降。

WebGL 2 虽然引入了实例化(Instancing),但它要求所有实例共享相同的网格数据。如果场景中存在几百种不同拓扑结构的物体,实例化就显得无能为力。

WebGPU 的“间接绘制”(Indirect Draw)和“多重间接绘制”(Multi-Draw Indirect, 简称 MDI)则是终结这一瓶颈的核心武器。

本文将深入探讨如何在WebGPU中实现类似WebGL 2(或Vulkan/D3D12级)的MDI优化,彻底将剔除(Culling)、动态提交的计算压力转移至GPU,实现真正的 GPU-Driven Rendering(GPU驱动渲染)


一、 什么是 Multi-Draw Indirect (MDI)?

在常规绘制中,CPU需要显式调用 drawIndexed(indexCount, instanceCount, ...)。CPU必须明确知道要画多少个顶点、多少个实例。

而在**间接绘制(Indirect Draw)**中,绘制参数不从CPU传入,而是存储在一个GPU Buffer(称为 Indirect Buffer)中。CPU只发出一个指令:“去读这个Buffer,里面写了多少你就画多少”。

**多重间接绘制(MDI)**则是这一概念的终极进化版:它允许在单个API调用中,批量读取一个包含多个绘制命令的Buffer,一次性绘制成百上千个不同的网格。

常规绘制 (CPU 驱动):
CPU 循环 -> 视锥剔除计算 -> 逐个发送 Draw Call -> GPU 渲染

MDI 绘制 (GPU 驱动):
CPU 发送单一 Compute 任务 -> GPU 计算剔除并填充 Indirect Buffer -> GPU 触发一次 Multi-Draw -> GPU 渲染

这种架构下,CPU不需要知道哪些物体是可见的,也不需要逐个提交绘制命令。


二、 WebGPU 中的 MDI 特性现状

WebGPU标准中,基础的间接绘制 drawIndirect()drawIndexedIndirect() 是核心标准的一部分,所有支持WebGPU的设备都原生支持。

然而,一次性提交整个命令缓冲区的 multi-draw-indirect 功能,在WebGPU中是一个可选扩展特性(Optional Feature)

1. 开启 WebGPU MDI 扩展

在请求 GPU Device 时,我们需要明确检测并请求 "multi-draw-indirect" 特性:

const adapter = await navigator.gpu.requestAdapter();

// 检测是否支持 MDI
const supportsMDI = adapter.features.has("multi-draw-indirect");

const device = await adapter.requestDevice({
    requiredFeatures: supportsMDI ? ["multi-draw-indirect"] : []
});

2. 优雅降级策略(Fallback)

如果用户的硬件或浏览器不支持 "multi-draw-indirect" 扩展,我们依然可以通过在Render Pass中循环调用普通的 drawIndexedIndirect 来实现。由于所有参数都已经在GPU Buffer中,CPU端仅仅是发送极轻量级的指针偏移指令,其开销仍然远远低于传统的WebGL Draw Call。


三、 实战:构建 GPU-Driven MDI 管线

要实现一个完整的MDI管线,我们需要完成以下三个核心步骤:

  1. 定义间接缓冲区(Indirect Buffer)的内存布局
  2. 编写 Compute Shader 进行 GPU 视锥剔除并写入参数
  3. 在 Render Pipeline 中执行 Multi-Draw

1. 间接缓冲区的内存布局

在WebGPU中,drawIndexedIndirect 对应的结构体在显存中占 20个字节(5个 32位 字段)。特别需要注意的是,baseVertex 在WebGPU规范中是 i32(有符号整型),其余皆为 u32

我们在 WGSL 中定义对应的结构体:

struct DrawIndexedIndirectCmd {
    indexCount: u32,
    instanceCount: u32,
    firstIndex: u32,
    baseVertex: i32,    // 注意:是有符号整型
    firstInstance: u32,
}

@group(0) @binding(0) var<storage, read_write> drawCommands: array<DrawIndexedIndirectCmd>;

在 JS/TS 端,我们创建一个能够容纳 $N$ 个物体的 Storage Buffer,并赋予其 INDIRECT 标志:

const totalObjects = 1000; // 假设有1000个独立渲染对象
const commandBufferSize = totalObjects * 20; // 每个命令20字节

const indirectBuffer = device.createBuffer({
    size: commandBufferSize,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST,
});

2. 编写 Compute Shader 实现 GPU 剔除与动态计数

在GPU-Driven管线中,CPU预先在 indirectBuffer 中写入每个网格的默认参数(如 indexCountfirstIndex 等),但将 instanceCount 初始化为 0

随后,Compute Shader 会并发地对所有物体进行视锥剔除(Frustum Culling)。如果物体在视锥体内,则将对应的 instanceCount 设为 1(或者利用原子操作累加实例数)。

这是一个经典的 GPU 视锥剔除 Compute Shader 示例:

struct ObjectData {
    matrix: mat4x4<f32>,
    boundingRadius: f32,
    // 后面可以有其他自定义材质参数
}

struct DrawIndexedIndirectCmd {
    indexCount: u32,
    instanceCount: atomic<u32>, // 使用原子操作避免并发冲突
    firstIndex: u32,
    baseVertex: i32,
    firstInstance: u32,
}

@group(0) @binding(0) var<storage, read> objects: array<ObjectData>;
@group(0) @binding(1) var<storage, read_write> drawCommands: array<DrawIndexedIndirectCmd>;
@group(0) @binding(2) var<uniform> viewProjection: mat4x4<f32>;

// 简化的视锥体剔除函数
fn isVisible(pos: vec3<f32>, radius: f32) -> bool {
    // 实际项目中这里应转换为6个裁剪平面的点积判断
    // 此处仅作原理演示,默认通过测试
    return true; 
}

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
    let index = id.x;
    if (index >= arrayLength(&objects)) {
        return;
    }

    let obj = objects[index];
    let position = obj.matrix[3].xyz;

    if (isVisible(position, obj.boundingRadius)) {
        // 物体可见,将该绘制命令的 instanceCount 设为 1
        atomicStore(&drawCommands[index].instanceCount, 1u);
    } else {
        // 物体不可见,不进行绘制
        atomicStore(&drawCommands[index].instanceCount, 0u);
    }
}

3. 执行 MDI 渲染

当 Compute Shader 执行完毕后,GPU 显存中的 indirectBuffer 已经实时更新。我们直接开启 Render Pass,通知 GPU 读取该 Buffer 进行绘制。

情况 A:支持原生 multi-draw-indirect 的情况

const commandEncoder = device.createCommandEncoder();

// 1. 运行 Compute Pass 计算剔除
const computePass = commandEncoder.beginComputePass();
computePass.setPipeline(cullingPipeline);
computePass.setBindGroup(0, computeBindGroup);
computePass.dispatchWorkgroups(Math.ceil(totalObjects / 64));
computePass.end();

// 2. 运行 Render Pass 进行多重间接绘制
const renderPass = commandEncoder.beginRenderPass(renderPassDescriptor);
renderPass.setPipeline(renderPipeline);
renderPass.setVertexBuffer(0, vertexBuffer);
renderPass.setIndexBuffer(indexBuffer, "uint32");

// 使用 multiDrawIndexedIndirect 扩展接口
renderPass.multiDrawIndexedIndirect(
    indirectBuffer,  // 包含命令的 GPUBuffer
    0,               // 缓冲区起始偏移量
    totalObjects,    // 最大绘制次数
    null,            // (可选)包含实际绘制计数的 buffer
    0
);

renderPass.end();
device.queue.submit([commandEncoder.finish()]);

情况 B:不支持原生扩展时的优雅降级(以 Loop 模拟)

如果用户设备不支持原生 MDI,不要惊慌。我们可以使用一个轻量级的循环在 CPU 端发出 drawIndexedIndirect。因为没有数据在 CPU 和 GPU 之间往返(Round-trip),这种方式依旧比传统的 WebGL 渲染快得不可思议。

const renderPass = commandEncoder.beginRenderPass(renderPassDescriptor);
renderPass.setPipeline(renderPipeline);
renderPass.setVertexBuffer(0, vertexBuffer);
renderPass.setIndexBuffer(indexBuffer, "uint32");

// 降级方案:循环分发间接绘制
const byteStride = 20; // 5 * 4 bytes
for (let i = 0; i < totalObjects; i++) {
    renderPass.drawIndexedIndirect(indirectBuffer, i * byteStride);
}

renderPass.end();

四、 核心优化与避坑指南

在实际生产中落地 WebGPU MDI 架构时,有几个非常隐蔽的硬件与规范陷阱需要注意:

1. 严格的内存对齐(Memory Alignment)

WebGPU 规范对间接缓冲区的偏移量有极其严格的要求:

  • drawIndexedIndirectindirectOffset 参数必须是 4 的倍数
  • 如果你打算将顶点缓冲区、索引缓冲区和间接命令存放在同一个大 Buffer 中(常用于 1-Buffer 架构),必须确保每个命令段的偏移遵循 4 字节或 256 字节(对于 Uniform/Storage 绑定)对齐。

2. GPU-Driven 中的多路依赖与屏障 (Barriers)

在同一个 CommandBuffer 中,Compute Pass 写入了 indirectBuffer,紧接着 Render Pass 读取了同一个 indirectBuffer

在 WebGPU 中,你不需要像 Vulkan 那样手动编写复杂的显存屏障(Memory Barriers)。WebGPU 的资源追踪器(Resource Tracker)检测到同一个 Buffer 在上一个 Pass 是 write 状态(Storage Buffer),在下一个 Pass 变为了 read 状态(Indirect Buffer),它会自动在底层驱动中插入最安全的管线栅栏。

但这有一个前提:这两个 Pass 必须提交在同一个 submit() 调用中,否则自动屏障可能会失效,导致渲染出现一帧的延迟(使用了上一帧的剔除数据)。

3. 合理处理无索引网格

如果有些几何体没有索引缓冲区,应改用 drawIndirectmultiDrawIndirect,其命令结构体少了一个 firstIndex,大小变为 16 字节(4个 u32 字段):

  1. vertexCount
  2. instanceCount
  3. firstVertex
  4. firstInstance

五、 总结与性能飞跃

通过引入 Multi-Draw Indirect 架构,WebGPU 彻底打破了前端 3D 渲染的“CPU 限制咒语”。

维度 传统 WebGL / 早期 WebGPU (CPU 驱动) WebGPU MDI 架构 (GPU 驱动)
CPU 负载 随渲染物体数量线性增加($O(N)$),易发生主线程卡顿 接近恒定的极低负载($O(1)$),仅需提交一次指令
视锥剔除 在 CPU 中单线程或利用 Web Worker 计算,传输回显存延迟高 在 GPU Compute Shader 中千核并行计算,零拷贝无延迟
Draw Call 数量 成百上千次独立的 API 交互 1次 统一的 multiDrawIndexedIndirect

对于现代网页游戏、大规模数字孪生、智慧城市大屏项目而言,掌握 MDI 和 GPU-Driven 管线的设计,是迈向次世代 3D Web 引擎技术专家的必经之路。

极客墨刃 WebGPUGPU驱动渲染图形学性能优化

评论点评