DPR与drawingBuffer
1. 申请 backing,viewport 认 drawing buffer
CSS 布局 getBoundingClientRect / style
× DPR
canvas.width / height 你申请的整数 backing
≥(实现可缩小)
drawingBufferWidth / Height gl.viewport 与 readPixels 的真尺寸
export interface GlSurface {
cssWidth: number;
cssHeight: number;
dpr: number;
backingWidth: number;
backingHeight: number;
drawingWidth: number;
drawingHeight: number;
}
export function allocateGlSurface(
canvas: HTMLCanvasElement,
gl: WebGL2RenderingContext,
cssWidth: number,
cssHeight: number,
dpr: number,
): GlSurface {
const backingWidth = Math.max(1, Math.round(cssWidth * dpr));
const backingHeight = Math.max(1, Math.round(cssHeight * dpr));
if (canvas.width !== backingWidth) canvas.width = backingWidth;
if (canvas.height !== backingHeight) canvas.height = backingHeight;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
const drawingWidth = gl.drawingBufferWidth;
const drawingHeight = gl.drawingBufferHeight;
gl.viewport(0, 0, drawingWidth, drawingHeight);
return {
cssWidth,
cssHeight,
dpr,
backingWidth,
backingHeight,
drawingWidth,
drawingHeight,
};
}
不要用 clientWidth / canvas.width 当 viewport。 clientWidth 是 CSS 像素;canvas.width 是申请值。移动端显存紧时 drawingBufferWidth < canvas.width,仍按申请值设 viewport,三角形按错误分辨率光栅,边缘还可能被裁。
写 canvas.width 会 重建并清空 drawing buffer,但 不会 像 2D 那样重置 program / bind 状态。viewport 不会自动改。resize 后必须自己 viewport,并标记全场景 dirty。FBO 若按旧尺寸建,要按 drawing buffer 重建附件。
WebGPU:改 canvas.width 不会 自动改交换链。必须再 configure({ device, format, size })(或不传 size、让实现读 canvas),然后立刻重绘——configure 清成透明黑。
2. ResizeObserver,兼听 DPR
flex/grid 里窗口不变、容器会变。只听 window.resize 不够。优先 device-pixel-content-box;Safari 抛错则回退 content-box × devicePixelRatio。DPR 变化(拖显示器、浏览器缩放)用 matchMedia('(resolution: …dppx)') 并在每次 change 后 换新 query——绑死当时的 dpr,从 2 变到 1.5 不会再通知。
export function observeGlSurface(
canvas: HTMLCanvasElement,
gl: WebGL2RenderingContext,
onMetrics: (m: GlSurface) => void,
): () => void {
const apply = (cssWidth: number, cssHeight: number, dpr: number): void => {
onMetrics(allocateGlSurface(canvas, gl, cssWidth, cssHeight, dpr));
};
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const dprBox = entry.devicePixelContentBoxSize?.[0];
if (dprBox) {
const dpr = window.devicePixelRatio || 1;
apply(dprBox.inlineSize / dpr, dprBox.blockSize / dpr, dpr);
return;
}
const box = entry.contentBoxSize?.[0];
apply(
box?.inlineSize ?? entry.contentRect.width,
box?.blockSize ?? entry.contentRect.height,
window.devicePixelRatio || 1,
);
});
try {
observer.observe(canvas, { box: "device-pixel-content-box" });
} catch {
observer.observe(canvas);
}
let dprQuery: MediaQueryList | undefined;
const onDpr = (): void => {
const rect = canvas.getBoundingClientRect();
apply(rect.width, rect.height, window.devicePixelRatio || 1);
bindDprQuery();
};
const bindDprQuery = (): void => {
dprQuery?.removeEventListener("change", onDpr);
dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
dprQuery.addEventListener("change", onDpr);
};
bindDprQuery();
return () => {
observer.disconnect();
dprQuery?.removeEventListener("change", onDpr);
};
}
visualViewport.scale !== 1 时,命中仍用 clientX/Y 相对 getBoundingClientRect,不要再乘 pinch scale。换算见 NDC。
3. 封顶与缩小
DPR=3 的 1920×1080 ≈ 5760×3240×4 量级再加 MSAA / 多 FBO,移动端会杀进程或直接缩小 drawing buffer。策略:
- 封顶:
Math.min(devicePixelRatio, 2)通常可接受。 - 申请后比较
drawingWidth / backingWidth。明显小于 1:降抗锯齿、降 FBO 档、提示性能模式。 - 看
MAX_RENDERBUFFER_SIZE/MAX_VIEWPORT_DIMS,不要申请超过上限再怪糊。
不要用 CSS transform: scale 放大一张小 canvas 冒充高清——那是拉伸 drawing buffer。清晰度只来自缓冲密度。SVG 图标走 SVG,不要为了「矢量清晰」给图标开一张全屏 GL。
4. 失败形态
| 症状 | 原因 |
|---|---|
| Retina 发糊 | canvas.width 等于 CSS 宽,没乘 DPR |
| 画面被裁 / 纵横比错 | viewport 用了 clientWidth 或 canvas.width |
| 高分屏仍糊、drawing 比 backing 小 | 显存上限,未读 drawingBufferWidth |
| resize 后一块黑 | 写了 canvas.width 清空缓冲,未立刻重绘;或 WebGPU 未 configure |
| 命中全偏 | 指针按 CSS,viewport 按 backing,NDC 公式用错一套 |
| 移动端 OOM / 丢失上下文 | 多层 FBO × 3x DPR 未封顶 |
权威资料
- WebGL — Drawing Buffer
- MDN — WebGLDrawingBuffer
- ResizeObserver device-pixel-content-box
- HTMLCanvasElement.width
- Canvas DPR
核对日期:2026-08-26