Resize a WebGL (or WebGPU) canvas from a ResizeObserver observing device-pixel-content-box: set canvas.width/height to the exact device-pixel size (capped to a pixel budget), then update gl.viewport and the camera's aspect ratio and render once — all inside the callback, so the new size and the new frame appear together.
Problem / Scenario Context
A product configurator renders a 3D model in a WebGL canvas that fills a resizable panel. The original code checks canvas.clientWidth against canvas.width at the top of every animation frame and resizes if they differ. It mostly works, but dragging the panel divider shows a stretched model for a frame, then a black flash, then the correct image; on a 4K laptop at 250% scaling, the canvas's drawing buffer is so large that frame rate drops to 20 fps. When the panel is hidden in a tab, the render loop keeps running at full cost.
WebGL canvases have the same sizing requirements as 2D canvases — covered in resizing a canvas with ResizeObserver without blurring — plus GPU-specific ones: the viewport, the projection, and a fill-rate budget.
Mechanics Explanation
A canvas has two sizes. Its CSS size is how big it appears; its drawing buffer size (canvas.width/height) is how many pixels the GPU renders. If they differ, the browser scales the buffer to the CSS size — blurry if the buffer is smaller, wasted work if larger.
Setting canvas.width or height clears the drawing buffer (to transparent black) and resizes it. If you resize and do not draw in the same frame, the next paint shows the cleared buffer: the black flash. If you draw with the old viewport after resizing, the image is stretched or cropped.
WebGL does not track the canvas size automatically: gl.viewport(0, 0, w, h) must be updated, and any projection matrix built from the aspect ratio must be recomputed. WebGPU is similar: the context's current texture follows the canvas size, but depth textures and projection must be recreated.
ResizeObserver callbacks run after layout and before paint, so resize + viewport update + render inside the callback produces a correctly sized, correctly drawn frame in the same paint. The device-pixel-content-box option gives the exact device-pixel size the browser will composite, avoiding off-by-one blur on fractional DPRs.
Pixel count grows with the square of DPR. A 1200×800 CSS canvas is 0.96 MP at 1×, 3.84 MP at 2×, 6 MP at 2.5×. Fragment shaders run per pixel, so on high-DPR laptops the GPU may simply not keep up; capping the buffer and letting the browser upscale slightly is usually the right trade.
Comparison Table: Sizing Strategies
| Strategy | Stretch or flash on resize | Exact device pixels | Works when hidden | Cost when idle |
|---|---|---|---|---|
Check clientWidth every rAF |
yes, one frame | no | runs anyway | reads layout every frame |
window.resize listener |
yes; misses panel resizes | no | n/a | none |
RO + contentBoxSize × dpr |
no | nearly | no entries while hidden | none |
RO + devicePixelContentBoxSize |
no | yes | no entries while hidden | none |
| RO + device pixels + pixel budget | no | capped | no entries while hidden | none |
Minimal Reproducible Example
function frame(): void {
const dpr = devicePixelRatio;
const w = Math.round(canvas.clientWidth * dpr), h = Math.round(canvas.clientHeight * dpr);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w; canvas.height = h; // clears the buffer, one frame late
gl.viewport(0, 0, w, h);
}
render();
requestAnimationFrame(frame);
}
declare const canvas: HTMLCanvasElement; declare const gl: WebGL2RenderingContext;
declare function render(): void;
Production-Safe Solution
interface GLView {
canvas: HTMLCanvasElement;
gl: WebGL2RenderingContext;
render: (w: number, h: number) => void; // draws one frame for the given buffer size
maxPixels?: number; // fill-rate budget, e.g. 4 MP
}
export function attachGLResize({ canvas, gl, render, maxPixels = 4_000_000 }: GLView): () => void {
const apply = (dpW: number, dpH: number): void => {
// Cap the pixel count, preserving aspect ratio.
const scale = Math.min(1, Math.sqrt(maxPixels / (dpW * dpH)));
const w = Math.max(1, Math.round(dpW * scale));
const h = Math.max(1, Math.round(dpH * scale));
if (canvas.width === w && canvas.height === h) return;
canvas.width = w; // clears the buffer…
canvas.height = h;
gl.viewport(0, 0, w, h);
render(w, h); // …so draw immediately, before paint
};
const ro = new ResizeObserver(([e]) => {
const dp = e.devicePixelContentBoxSize?.[0];
if (dp) return apply(dp.inlineSize, dp.blockSize);
const css = e.contentBoxSize[0];
apply(Math.round(css.inlineSize * devicePixelRatio), Math.round(css.blockSize * devicePixelRatio));
});
try { ro.observe(canvas, { box: 'device-pixel-content-box' }); }
catch { ro.observe(canvas, { box: 'content-box' }); }
return () => ro.disconnect();
}
The render loop no longer checks sizes; it only draws when the scene changes or animates. Resizing happens exclusively in the observer callback, which also renders, so the first painted frame after any layout change is correct. The budget keeps fill rate bounded on very high-DPR screens; render(w, h) should rebuild the projection matrix from w / h.
Pair it with an IntersectionObserver to stop the animation loop when the canvas is off-screen — the observer delivers nothing while the panel is hidden, but a rAF loop would otherwise keep rendering an invisible canvas:
let running = false;
const vis = new IntersectionObserver(([e]) => {
if (e.isIntersecting && !running) { running = true; requestAnimationFrame(loop); }
if (!e.isIntersecting) running = false;
});
vis.observe(canvas);
function loop(): void { if (!running) return; drawAnimatedFrame(); requestAnimationFrame(loop); }
declare function drawAnimatedFrame(): void;
WebGPU Notes
WebGPU follows the same principles with different calls. The canvas context's texture follows canvas.width/height on the next getCurrentTexture(), but any depth or multisample textures you created at the old size must be recreated, and render pass descriptors that reference them updated. Recreating textures on every pixel of a drag-resize is expensive; quantising the buffer size to multiples of 8 or 16 device pixels reduces reallocations with no visible cost, because the browser's final upscale absorbs the difference.
const q = (n: number) => Math.max(8, Math.round(n / 8) * 8); // quantise to 8 device pixels
Apply the same quantisation in WebGL if framebuffer attachments (post-processing chains, shadow maps sized from the canvas) are expensive to recreate.
Verification Steps
- Drag the panel divider slowly and quickly; there should be no black flash or stretched frame.
- Test on a fractional-DPR display (125% or 150% scaling) and check thin lines for blur.
- Check frame rate on a high-DPR laptop with and without the pixel budget.
- Hide the panel in a tab and confirm the render loop stops.
- Zoom the page and confirm the device-pixel observer (where supported) resizes the buffer.
Common Mistakes to Avoid
- Resizing in the render loop. The size check lags a frame behind layout.
- Resizing without rendering. Setting
width/heightclears the buffer. - Forgetting
gl.viewport. Drawing uses the old viewport and crops or stretches. - Unbounded DPR scaling. Fill rate on high-DPR screens can halve the frame rate.
FAQ
Why does resizing the canvas cause a black flash?
Setting canvas.width or canvas.height clears the drawing buffer. If the page paints before you draw again, the cleared buffer is visible. Rendering in the same ResizeObserver callback avoids that.
Should I use preserveDrawingBuffer to avoid the flash?
No. It does not survive a resize, which always reallocates the buffer, and it disables optimisations for every frame. Rendering immediately after resizing is the fix.
Is it wasteful to render inside the resize callback when the loop will render anyway?
It renders one extra frame per resize, which is negligible. The alternative is at least one visibly wrong frame.
What pixel budget should I use?
Two to four megapixels suits most interactive 3D scenes on laptops. Heavy post-processing may need less; simple scenes can afford more. Measure frame time on your slowest supported device.
Does this work with three.js?
Yes. Call renderer.setSize(w, h, false) with the buffer size — the false keeps three.js from setting CSS size — update camera.aspect and call camera.updateProjectionMatrix(), then render, all inside the observer callback.
Why not simply set the buffer to the CSS size times devicePixelRatio?
On fractional device pixel ratios, that product can differ by a pixel from what the browser composites, producing slight blur. devicePixelContentBoxSize gives the exact value where supported.
Related
- Using devicePixelContentBox for Crisp Canvas — the device-pixel box in depth
- ResizeObserver Box Options Browser Support — where the device-pixel box works
- Pausing Background Video to Save Battery — stopping work off-screen
↑ Back to Responsive Canvas & Chart Resizing