WEBKT

WebGL与WebGPU跨上下文资源共享中的显存同步与防撕裂方案

2 0 0 0

在当前的Web图形开发中,我们正处于一个过渡期:WebGL拥有庞大的存量生态(如 Three.js、Babylon.js 的旧版本,以及大量的Web GIS应用),而WebGPU则凭借现代GPU架构的显式控制、多线程友好和强大的Compute Shader(计算着色器)成为未来的方向。

在许多复杂的工业级应用中,我们不得不让两者并存。例如:利用 WebGL 渲染现有的 3D 场景与 UI,同时利用 WebGPU 强大的计算能力进行物理粒子模拟,并将模拟结果实时传递回 WebGL 进行渲染。

然而,在这种跨上下文(Cross-Context)共享资源(如 Texture 或 Buffer)的场景下,开发者面临的最大技术挑战就是显存同步与防撕裂


为什么会出现画面撕裂与闪烁?

WebGL 和 WebGPU 虽然可能运行在同一个物理 GPU 上,但在浏览器内部,它们拥有完全不同的指令队列(Command Queues)同步机制

  1. WebGL 的“隐式同步”:WebGL 基于 OpenGL ES 规范。驱动程序会在幕后帮你处理依赖关系。你调用 gl.drawElements,WebGL 会隐式确保之前的贴图上传已经完成。
  2. WebGPU 的“显式同步”:WebGPU 贴近现代底层 API(Vulkan/Metal/D3D12)。所有指令被录制进 GPUCommandBuffer,然后提交给 GPUQueue。GPU 何时真正执行完这些指令,完全是异步的,需要开发者显式控制。

当你在 WebGL 中渲染了一张 Canvas 贴图,并立即在 WebGPU 中通过 copyExternalImageToTexture 读取它时,由于 WebGL 的渲染指令可能还在 GPU 的流水线(Pipeline)中排队,WebGPU 就已经启动了读取。这种读写冲突(Read-Write Hazard)会导致 WebGPU 读取到不完整的帧数据,在屏幕上表现为画面撕裂、剧烈闪烁,或者渲染出上一帧的残留数据


核心同步机制:跨 API 的时间线对齐

在 Web 端,由于安全沙箱限制,我们无法直接在 WebGL 和 WebGPU 之间传递底层的硬件 Fence(栅栏)指针。因此,我们需要通过主线程(CPU)作为中介,利用各自 API 的异步通知机制来对齐时间线

1. WebGL 写入,WebGPU 读取(防撕裂方案)

当 WebGL 作为生产者(如渲染场景到 FBO),WebGPU 作为消费者(如读取该纹理进行后处理)时,必须确保 WebGL 的绘制指令在 GPU 端完全执行完毕,WebGPU 才能发起读取。

千万不要使用 gl.finish()。虽然它能强制等待 GPU 完成,但它会阻塞 CPU 主线程,导致浏览器帧率骤降。

正确的做法是使用 WebGL 2.0 的 **Fence Sync(栅栏同步)**进行异步轮询:

// 【WebGL 侧】渲染完毕后插入栅栏
function renderWebGLAndCreateFence(gl) {
  // 1. 执行 WebGL 绘制
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
  gl.drawElements(...); 

  // 2. 插入 Fence
  const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
  gl.flush(); // 强制将指令缓存推送到 GPU 队列,不可省略

  return sync;
}

// 【中介侧】异步等待 Fence 信号
function waitForWebGLSync(gl, sync) {
  return new Promise((resolve, reject) => {
    function check() {
      // clientWaitSync 参数:flags=0, timeout=0 表示立即返回状态而不阻塞主线程
      const status = gl.clientWaitSync(sync, 0, 0);
      if (status === gl.ALREADY_SIGNALED || status === gl.CONDITION_SATISFIED) {
        gl.deleteSync(sync);
        resolve(); // GPU 已完成写入
      } else if (status === gl.WAIT_FAILED) {
        reject(new Error("WebGL sync failed"));
      } else {
        // 未完成,下一帧或微任务中继续轮询
        requestAnimationFrame(check);
      }
    }
    check();
  });
}

Promise resolve 后,再触发 WebGPU 的读取和渲染逻辑:

async function tick() {
  const sync = renderWebGLAndCreateFence(gl);
  await waitForWebGLSync(gl, sync);

  // 此时 WebGL 显存已安全写入,WebGPU 可以安全读取
  device.queue.copyExternalImageToTexture(
    { source: webglCanvas },
    { texture: webgpuTexture },
    [width, height]
  );
  
  submitWebGPUPipeline();
}

2. WebGPU 写入,WebGL 读取(防撕裂方案)

当 WebGPU 负责计算或生成纹理,WebGL 负责将其作为贴图渲染时,我们需要等待 WebGPU 的 Queue 完成工作。

WebGPU 提供了极其优雅的异步等待机制:GPUQueue.onSubmittedWorkDone()。它返回一个 Promise,该 Promise 会在当前已提交的所有 GPU 指令执行完毕后 resolve。

// 【WebGPU 侧】提交计算任务
device.queue.submit([commandBuffer]);

// 等待 GPU 执行完毕
device.queue.onSubmittedWorkDone().then(() => {
  // 此时 WebGPU 已经将数据完整写入 VRAM
  // 【WebGL 侧】可以安全地绑定纹理并进行绘制
  gl.bindTexture(gl.TEXTURE_2D, webglTexture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, webgpuCanvas);
  
  gl.drawElements(...);
});

终极防撕裂架构:双缓冲区(Ping-Pong Buffering)

上述的“等待-执行”链条虽然安全,但存在一个致命缺陷:GPU 利用率低。当 WebGL 在等待 WebGPU 时,WebGL 对应的 GPU 硬件处于闲置状态,反之亦然。这限制了帧率的上限。

为了实现真正的零撕裂且满帧运行,我们需要引入**双缓冲区(Double Buffering / Ping-Pong Buffering)**机制。

原理

准备两个共享介质(例如两个离屏 Canvas,或者两个 WebGPU 纹理):Buffer_ABuffer_B

  • 当前帧(Frame N):WebGL 向 Buffer_A 写入数据;同时,WebGPU 从 Buffer_B(上一帧已经写完的数据)中读取。
  • 下一帧(Frame N+1):交换角色。WebGL 向 Buffer_B 写入数据;WebGPU 从 Buffer_A 中读取。
时间轴 --->
[ Frame 1 ]  WebGL -> 写 Buffer_A  |  WebGPU -> 读 空
[ Frame 2 ]  WebGL -> 写 Buffer_B  |  WebGPU -> 读 Buffer_A
[ Frame 3 ]  WebGL -> 写 Buffer_A  |  WebGPU -> 读 Buffer_B

虽然这种做法会引入 1 帧的延迟(Latency),但它彻底解耦了 WebGL 和 WebGPU 的时间线。由于读写在物理上隔离在不同的显存地址,即使两个 API 同时运行,也绝对不会产生画面撕裂,并且能够最大化 GPU 的并行吞吐量。

双缓冲伪代码实现

class DoubleBufferedTexture {
  constructor(width, height) {
    this.textures = [
      createWebGLTexture(width, height), // WebGL 纹理 A
      createWebGLTexture(width, height)  // WebGL 纹理 B
    ];
    this.writeIndex = 0;
  }

  get writeTarget() {
    return this.textures[this.writeIndex];
  }

  get readTarget() {
    return this.textures[1 - this.writeIndex];
  }

  swap() {
    this.writeIndex = 1 - this.writeIndex;
  }
}

// 渲染循环
const dbTex = new DoubleBufferedTexture(1024, 1024);

function loop() {
  // 1. WebGL 写入 writeTarget
  renderToWebGLTexture(dbTex.writeTarget);
  const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
  gl.flush();

  // 2. WebGPU 读取 readTarget (上一帧写入的内容)
  // 因为上一帧的 Fence 在上一轮循环中已经 verify 过了,可以直接读取,无需等待本帧 WebGL 结束
  processWebGPU(dbTex.readTarget);

  // 3. 异步确认本帧的写入,为下一帧做准备
  waitForWebGLSync(gl, sync).then(() => {
    dbTex.swap(); // 只有当写入真正完成后,才允许交换缓冲区
  });

  requestAnimationFrame(loop);
}

总结与工程建议

在 WebGL 与 WebGPU 混用的前沿阵地,确保显存同步需要遵循以下原则:

  1. 绝对避免同步阻塞:严禁使用 gl.finish(),坚持使用 WebGL2 fenceSync 配合 clientWaitSync(..., 0, 0) 进行异步轻量化轮询。
  2. 利用 WebGPU 原生优势:善用 device.queue.onSubmittedWorkDone() 来做 WebGPU 向 WebGL 的单向同步。
  3. 高吞吐量优先选双缓冲:对于高频渲染(如 60fps 以上的游戏或 GIS 引擎),双缓冲(Ping-Pong)是兼顾“无撕裂”与“高帧率”的唯一标准解。
  4. 注意内存回收:跨上下文频繁创建和销毁 Fence 对象会引起严重的 JavaScript GC(垃圾回收)卡顿,务必在 Promise resolve 后立即调用 gl.deleteSync(sync) 释放 GPU 资源。
极客飞天 WebGLWebGPU显存同步

评论点评