读回与同步陷阱
GPU 异步执行。你在 CPU 上读像素或 map 缓冲区,等于说「现在就等」。热路径上的一次全屏 readPixels 能把 60fps 打成十几。拾取、截图、直方图都要先问:能不能 不读回。
1. readPixels 会 flush
对默认 framebuffer:preserveDrawingBuffer 默认 false,合成后内容可被清空。读默认缓冲必须在 同一帧 draw* 之后、rAF 返回之前。对着 FBO 读不受合成清空约束,但仍是 CPU–GPU 同步。
export function readRgba(
gl: WebGL2RenderingContext,
x: number,
y: number,
w: number,
h: number,
): Uint8Array {
const pixels = new Uint8Array(w * h * 4);
gl.readPixels(x, y, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
return pixels;
}
原点在 左下。CSS 左上点 (cssX, cssY) 对应:
export function cssToReadOrigin(
cssX: number,
cssY: number,
cssWidth: number,
cssHeight: number,
drawingWidth: number,
drawingHeight: number,
): { x: number; y: number } {
const x = Math.floor((cssX / cssWidth) * drawingWidth);
const y = Math.floor((1 - cssY / cssHeight) * drawingHeight);
return { x, y };
}
适用:离线导出、偶发调试、1px 拾取。
不适用:每帧直方图、每帧全屏截图、用读回反推场景对象。场景图在 CPU,见 00。
getBufferSubData、fenceSync + 立刻 clientWaitSync 同属同步点。需要异步完成再用 SYNC_GPU_COMMANDS_COMPLETE 隔帧查,或把工作挪到 WebGPU mapAsync。
2. GPU 拾取:只读 1px
不要读整张 ID 图。开一张与视图同尺寸的 FBO 只为「看起来方便」,每帧 readPixels(0,0,w,h) 是反模式。正确路径:
- 指针 CSS → drawing buffer 像素(上式)。
scissor成 1×1,或单独 1×1 FBO + 只画候选。- 编码 object id 到 RGBA,
readPixels1 个像素。 - 能用 CPU 空间索引命中的,不要上 GPU 拾取。
export function pickId(
gl: WebGL2RenderingContext,
drawingX: number,
drawingY: number,
): number {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
const px = new Uint8Array(4);
gl.readPixels(drawingX, drawingY, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);
return px[0]! | (px[1]! << 8) | (px[2]! << 16) | (px[3]! << 24);
}
生产应绑 ID FBO,而不是读屏幕颜色。1px 仍然可能 stall;拖拽时节流到 pointerup 或每 N 帧一次。细节见 03 拾取章。
3. WebGPU:mapAsync 不要当帧内 await
export async function readbackColor(
device: GPUDevice,
src: GPUBuffer,
size: number,
): Promise<Uint8Array> {
const staging = device.createBuffer({
size,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
const encoder = device.createCommandEncoder();
encoder.copyBufferToBuffer(src, 0, staging, 0, size);
device.queue.submit([encoder.finish()]);
await staging.mapAsync(GPUMapMode.READ);
const copy = new Uint8Array(staging.getMappedRange().slice(0));
staging.unmap();
staging.destroy();
return copy;
}
mapAsync 在 GPU 写完前 pending。在 rAF 里 await 它,等于这一帧画完再等读回,下一帧才继续——输入延迟直接坏。做法:双 staging、隔帧 map、或把读回移出动画循环。getMappedRange 在 unmap 后失效,必须 slice 拷走。
4. 禁止热路径 toDataURL
canvas.toDataURL() 同步编码整张 drawing buffer,强制读回 + 压缩,常还依赖「缓冲还在」。录屏、缩略图、分享预览都不要放进 rAF。
| 需求 | 做法 |
|---|---|
| 用户点一次导出 | 本帧画完立刻 readPixels / toBlob,或临时 preserveDrawingBuffer 仅这一次 |
| 缩略图 | 离屏小 FBO / 小 canvas,不要缩放大视图读回 |
| 连续帧 | captureStream 或 Worker 编码;不要逐帧 toDataURL |
| 2D 叠字导出 | 见 Canvas 离屏合成,不要每帧从 GL 读到 2D 再编码 |
toBlob 相对异步,仍贵。导出走 08。污染画布读回失败见安全章,不要在热路径试探。
5. 失败形态
| 症状 | 原因 |
|---|---|
| 一拾取就卡 | 读了整张 ID 或默认缓冲跨帧已清空 |
| 截图全透明 / 全黑 | 未 preserve 且读回发生在合成后 |
| 拾取 Y 反了 | 当 CSS 左上当 readPixels 原点 |
| WebGPU 动画一读就顿 | rAF 里 await mapAsync |
| 内存涨 | 每帧 createBuffer staging 不 destroy |
| 分享按钮卡死主线程 | toDataURL 全屏 JPEG |
权威资料
核对日期:2026-08-26