墨舟的AI笔记头像
WebGPU 绘制管线与计算管线的异步协同调度封面图

WebGPU 绘制管线与计算管线的异步协同调度

WebGPU 绘制管线与计算管线的异步协同调度

封面信息图

在现代 Web 3D 引擎与高密度粒子可视化中,WebGL 最令人诟病的瓶颈之一就是“计算与渲染脱节”:在 CPU 侧用 JS 计算百万级粒子的物理轨迹,再通过 gl.bufferSubData 慢吞吞地拷贝进 GPU,或者用复杂易错的 GPGPU 帧缓冲贴图(Framebuffer Texture)做 Ping-Pong 换步。

WebGPU 的出现彻底重塑了这种范式。它原生支持**计算管线(Compute Pipeline)与绘制管线(Render Pipeline)**的同构共存。

粒子位置、流体速度场等高并发物理模拟完全在 GPU 的 Compute Shader 中并行执行,并直接将计算结果停留在 GPU 显存(Storage Buffer / Vertex Buffer)中,紧接着无缝交给 Render Pipeline 绘制上屏,全程实现 Zero-Copy(零 CPU 拷贝)。

计算与绘制协同的拓扑架构

在一个标准的仿真渲染循环中,计算 Pass 与渲染 Pass 共享同一块显存资源。为了防止读写竞争(Data Hazard),必须在同一个 GPUCommandEncoder 中编排先后顺序。

                    ┌────────────────────────────┐
                    │      GPU 显存缓冲区池        │
                    │   (GPUBuffer: STORAGE |    │
                    │    VERTEX | COPY_DST)      │
                    └─────────────┬──────────────┘
                                  │
          ┌───────────────────────┴───────────────────────┐
          │ (写入更新)                                     │ (读取渲染)
          ▼                                               ▼
┌───────────────────┐    GPU 内部流水线栅栏      ┌───────────────────┐
│ Compute Pipeline  │ ───────────────────────► │  Render Pipeline  │
│ (WGSL Compute)    │   (Implicit Barrier)     │ (WGSL Vert/Frag)  │
└───────────────────┘                          └───────────────────┘

WGSL 着色器双向契约

首先定义计算着色器。它读取粒子前一帧的位置与速度,施加重力与中心引力,并更新位置。

// compute_particle.wgsl
struct Particle {
  position : vec2<f32>,
  velocity : vec2<f32>,
  life     : f32,
  maxLife  : f32,
};

@group(0) @binding(0) var<storage, read_write> particles : array<Particle>;

struct SimParams {
  deltaTime : f32,
  gravity   : vec2<f32>,
  decay     : f32,
};
@group(0) @binding(1) var<uniform> params : SimParams;

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

  var p = particles[index];
  p.life = p.life - params.deltaTime;

  if (p.life <= 0.0) {
    // 粒子重生
    p.life = p.maxLife;
    p.position = vec2<f32>(0.0, 0.0);
    p.velocity = vec2<f32>(
      sin(f32(index) * 0.1) * 0.8,
      cos(f32(index) * 0.1) * 0.8
    );
  } else {
    p.velocity = p.velocity + params.gravity * params.deltaTime;
    p.position = p.position + p.velocity * params.deltaTime;
  }

  particles[index] = p;
}

紧接着是渲染管线的顶点着色器。得益于 WebGPU 的缓冲区复用特性,存储缓冲区可直接作为顶点属性绑定,或者在顶点着色器中以 Storage Buffer 形式索引:

// render_particle.wgsl
struct VertexOutput {
  @builtin(position) position : vec4<f32>,
  @location(0) color : vec4<f32>,
};

struct Particle {
  position : vec2<f32>,
  velocity : vec2<f32>,
  life     : f32,
  maxLife  : f32,
};
@group(0) @binding(0) var<storage, read> particles : array<Particle>;

@vertex
fn vs_main(@builtin(vertex_index) v_idx : u32, @builtin(instance_index) i_idx : u32) -> VertexOutput {
  let p = particles[i_idx];
  let alpha = p.life / p.maxLife;
  
  // 简单的点精灵扩展
  var output : VertexOutput;
  output.position = vec4<f32>(p.position, 0.0, 1.0);
  output.color = vec4<f32>(0.2, 0.6, 0.9, alpha);
  return output;
}

@fragment
fn fs_main(@location(0) color : vec4<f32>) -> @location(0) vec4<f32> {
  return color;
}

调度器集成与 CommandEncoder 编排

在 JavaScript / TypeScript 侧,必须将缓冲区用途声明为 GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX。在每一帧渲染循环中,先开启 beginComputePass,结束后紧跟 beginRenderPass:

export class ParticleSimulationEngine {
  private device: GPUDevice;
  private context: GPUCanvasContext;
  private computePipeline: GPUComputePipeline;
  private renderPipeline: GPURenderPipeline;
  private particleBuffer: GPUBuffer;
  private computeBindGroup: GPUBindGroup;
  private renderBindGroup: GPUBindGroup;
  private particleCount: number = 200000; // 20万粒子

  constructor(
    device: GPUDevice,
    context: GPUCanvasContext,
    computePipeline: GPUComputePipeline,
    renderPipeline: GPURenderPipeline,
    particleBuffer: GPUBuffer,
    computeBindGroup: GPUBindGroup,
    renderBindGroup: GPUBindGroup
  ) {
    this.device = device;
    this.context = context;
    this.computePipeline = computePipeline;
    this.renderPipeline = renderPipeline;
    this.particleBuffer = particleBuffer;
    this.computeBindGroup = computeBindGroup;
    this.renderBindGroup = renderBindGroup;
  }

  public frame(deltaTime: number) {
    const commandEncoder = this.device.createCommandEncoder({
      label: 'Simulation_and_Render_Encoder'
    });

    // 1. 计算阶段:物理模拟
    const computePass = commandEncoder.beginComputePass({
      label: 'Compute_Physics_Pass'
    });
    computePass.setPipeline(this.computePipeline);
    computePass.setBindGroup(0, this.computeBindGroup);
    // 64 为 workgroup_size
    const workgroupCount = Math.ceil(this.particleCount / 64);
    computePass.dispatchWorkgroups(workgroupCount);
    computePass.end();

    // 2. 渲染阶段:上屏绘制
    const renderPass = commandEncoder.beginRenderPass({
      label: 'Render_Display_Pass',
      colorAttachments: [
        {
          view: this.context.getCurrentTexture().createView(),
          clearValue: { r: 0.05, g: 0.05, b: 0.07, a: 1.0 },
          loadOp: 'clear',
          storeOp: 'store'
        }
      ]
    });
    renderPass.setPipeline(this.renderPipeline);
    renderPass.setBindGroup(0, this.renderBindGroup);
    // 绘制 20 万个粒子实例
    renderPass.draw(1, this.particleCount, 0, 0);
    renderPass.end();

    // 提交单次命令队列,GPU 驱动层自动维护 Pass 间显存依赖栅栏
    this.device.queue.submit([commandEncoder.finish()]);
  }
}

显存屏障与多 Pass 性能陷阱

不少初学者在进行复杂渲染时,喜欢在 Compute Pass 之后调用 await device.queue.onSubmittedWorkDone() 或者 mapAsync 读取数据进行 CPU 校验。这种行为会强行触发 GPU 管线排空(Pipeline Stall),瞬间将 120 帧拖死到个位数。

WebGPU 规范明确规定:在同一个 CommandEncoder 内,先后记录的 Compute Pass 与 Render Pass,如果访问了同一个 Storage Buffer,底层驱动(如 Vulkan、DirectX 12 或 Metal)会自动在 Pass 边界插入最轻量级的执行依赖与内存屏障(Pipeline Barrier),无需应用层显式插入 CPU 等待。

这种让计算归于算力芯片、让渲染无缝承接显存的同构管线调度,正是现代前端图形学从“搬运工”迈向“原生算力驾驭者”的基石。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2301_77785315/article/details/164949459

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--