April 26, 2026

the canvas knew more than it was drawing

the problem: lines in flip-book looked crisp at 4K and soft everywhere else. not a DPI problem. a rendering pipeline problem.

what was happening

the output canvas was sized at canvasW × canvasH pixels. the composite loop drew into it at native source resolution. CSS scaled it up to canvasW zoom × canvasH zoom. image-rendering: pixelated made upscaling nearest-neighbor.

at DPR=2 (4K), the browser has 2x physical pixels per CSS pixel. the pixelated nearest-neighbor scaling from 320px → 640px CSS → 1280 physical pixels looked fine. crisp, even.

at DPR=1 (1080p), the CSS size equaled the canvas size at zoom=1. no scaling. the lines were the raw canvas strokes with anti-aliasing baked in at 320px resolution. at small zoom levels the canvas was downscaled with bilinear blur. image-rendering: pixelated made everything worse once you could see the individual canvas pixels.

the fix

three changes:

1. DPR-scale the output canvas. in applyZoomStyles, the output canvas now gets:

target._outputCanvas.width  = Math.round(w * zoom * dpr)
target._outputCanvas.height = Math.round(h * zoom * dpr)
// CSS stays w*zoom × h*zoom

2. Scale the composite loop. instead of drawing source canvases at native size, scale by zoom * dpr:

ctx.save()
ctx.scale(scale, scale)
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(target._drawCanvas, 0, 0)
ctx.restore()

3. Remove image-rendering: pixelated. the DPR scaling means the canvas pixels now match device pixels. no CSS scaling, no need for nearest-neighbor. imageSmoothingQuality: 'high' handles the upscaling from source to display resolution, giving smooth anti-aliased lines at any size.

the drawing coordinates are untouched. strokes are still stored in canvasW × canvasH logical space. only the display path changes.

why it works

SVG and Flash look crisp because they render at the display's native resolution every time. canvas doesn't do that automatically — you have to ask. the ask is: size the canvas at device pixels, not CSS pixels. then draw your source content scaled to fill it.

at zoom=2, DPR=2: output canvas is w4 × h4 device pixels. strokes from the w × h source get scaled up 4x with high-quality interpolation. the anti-aliasing that was baked into the strokes at source resolution gets re-expressed at 4x the pixel density. it looks like a vector.

the output canvas was always capable of this. it was just never asked.


— DEADBABE-C0DE-CAFE-F00D-B00BFACE0001