截图与离屏导出
用户点「保存当前画面」时,GPU 表面不一定还在。默认 preserveDrawingBuffer: false:浏览器合成到屏幕后,可以把 drawing buffer 清掉。下一帧再 toBlob / readPixels,读到的是空的或未定义内容,不是你刚才看见的那帧。
1. 机制:三条合法路径
| 路径 | 做什么 | 代价 |
|---|---|---|
| 同帧读 FBO / color attachment | 场景本就画到纹理,截图读那张,不碰 canvas 交换链 | 架构要对:主相机输出是离屏目标 |
preserveDrawingBuffer: true | 合成后保留默认缓冲,随后 toBlob 也能撞上 | 每帧多占带宽 / 显存 |
| blit 到 2D 离屏 | 同帧 drawImage(glCanvas) 再 convertToBlob | 多一次拷;Y 与颜色空间要自己对齐 |
适用: 产品需要导出、分享图、缩略图、测试金标。路径按「这帧是否本来就有离屏目标」选,不要三种叠着用。
不适用: 每帧截图做业务(直方图、拾取、自动对焦)。那是热路径读回,见 06。拾取用 ID 缓冲或 CPU 空间索引,不要 readPixels 整张色缓冲。
2. 同帧读 FBO(首选)
场景已经画进 FBO 时,截图是读那张纹理,和「交换链被不清掉」解耦:
export function readFboRgba(
gl: WebGL2RenderingContext,
fbo: WebGLFramebuffer,
width: number,
height: number,
): Uint8Array {
const pixels = new Uint8Array(width * height * 4);
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return pixels;
}
export function flipRowsRgba(
pixels: Uint8Array,
width: number,
height: number,
): Uint8Array {
const stride = width * 4;
const out = new Uint8Array(pixels.byteLength);
for (let y = 0; y < height; y += 1) {
const src = (height - 1 - y) * stride;
out.set(pixels.subarray(src, src + stride), y * stride);
}
return out;
}
readPixels 原点在左下。要当 PNG / ImageData(左上)必须翻行。WebGPU:在 同一 submit 里 copyTextureToBuffer,再 mapAsync;getCurrentTexture() 不能留到下一帧。
3. preserve 与 blit 到 2D
export function captureGlToBlob(glCanvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
glCanvas.toBlob((blob) => {
if (!blob) {
reject(new Error("toBlob returned null"));
return;
}
resolve(blob);
}, "image/png");
});
}
export async function blitGlToOffscreen(
glCanvas: HTMLCanvasElement,
cssW: number,
cssH: number,
dpr: number,
): Promise<Blob> {
const dest = new OffscreenCanvas(
Math.max(1, Math.round(cssW * dpr)),
Math.max(1, Math.round(cssH * dpr)),
);
const ctx = dest.getContext("2d");
if (!ctx) throw new Error("2d unavailable");
ctx.drawImage(glCanvas, 0, 0, dest.width, dest.height);
return dest.convertToBlob({ type: "image/png" });
}
toBlob / drawImage(glCanvas) 必须发生在 本帧 GL 提交之后、合成清空之前。典型写法:requestAnimationFrame 里先 draw*,同一回调末尾导出。不要 setTimeout(0) 再截。
preserveDrawingBuffer: true 只在「必须随时截默认缓冲、又没有 FBO」时开。开启后移动端带宽和内存都涨。需要截图的产品优先 FBO,而不是全局 preserve。
toDataURL 同步编码 + base64,4K × DPR 会卡主线程。导出一律 toBlob / convertToBlob。
GL canvas 若已 taint,两条路径都会 SecurityError,见 污染。2D 离屏也会被脏。
4. 离屏规格,不要借视口 canvas
导出封面、缩略图、打印图是 离线光栅:单独 FBO / OffscreenCanvas,相机 fit 到规格,不要借用正在交互的那张表面。多规格循环渲染,不要先导出一张再 CSS 拉伸。印刷尺寸用英寸 × dpi,dpr = 1,见 Canvas 缩略图。
5. 失败形态
| 症状 | 原因 |
|---|---|
| 截到黑图 / 透明 | 下一帧才 toBlob,默认缓冲已清 |
| 上下颠倒 | readPixels 当 PNG 没翻行 |
| 导出卡 2 秒 | toDataURL 同步 4K |
| 预览正常导出 SecurityError | 纹理跨源无 CORS |
| 开了截图掉一半帧 | 全局 preserveDrawingBuffer + 每帧读回 |
| WebGPU 截到空 | getCurrentTexture() 用到了下一帧 |
权威资料
- HTML — preserveDrawingBuffer
- WebGLRenderingContext.readPixels()
- HTMLCanvasElement.toBlob()
- GPUCommandEncoder.copyTextureToBuffer
核对日期:2026-08-26