perf(canvas): rAF-coalesced render, dirty imageData, viewport-bounded zoom/pan

Mobile lag root cause: every pan/zoom tick ran putImageData(imageData, 0, 0),
a ~67MB copy into the offscreen. On a phone, that dominates the frame budget.

- CanvasRenderer: split render() into a deferred scheduler + doRender().
  requestAnimationFrame coalesces redundant calls within one frame. An
  imageDataDirty flag gates putImageData — only re-upload when pixels
  actually changed (setPixelRgba / loadCanvas). Pan+zoom renders are now
  pure compositor work.
- Zoom clamp: minZoom = min(viewportW/CW, viewportH/CH). Computed in
  App.svelte from a resize-tracked viewport, passed to CanvasRenderer
  (wheel + pinch use it) and CanvasControls (zoom-out button disables at
  bound). Replaces the hardcoded 0.25 that let phones see empty void.
- Pan clamp: clampPan() runs before every paint. When canvas >= viewport,
  canvas always covers the screen. When canvas < viewport (never happens
  at minZoom, but safe), canvas stays inside.
This commit is contained in:
tiennm99 committed 2026-04-18 17:10:56 +07:00
1 parent 6bce69adc8
commit 088ed291a8
3 files changed
+86 -15

No files matched your search

+28 -7
View File
@@ -1,5 +1,5 @@
<script>
import { MAX_BATCH_SIZE, REQUEST_COOLDOWN_SEC } from '../lib/constants.js';
import { MAX_BATCH_SIZE, REQUEST_COOLDOWN_SEC, CANVAS_WIDTH, CANVAS_HEIGHT } from '../lib/constants.js';
import CanvasRenderer from './components/CanvasRenderer.svelte';
import ColorPicker from './components/ColorPicker.svelte';
import CanvasControls from './components/CanvasControls.svelte';
@@ -10,6 +10,25 @@
let selectedColor = $state(0);
let cursorPos = $state({ x: 0, y: 0 });
let zoom = $state(1);
// Viewport-aware zoom bounds. minZoom = "canvas fits in viewport" — any
// further and the canvas would shrink past the screen, leaving empty
// void space. Recomputed on window resize.
let viewport = $state({
w: typeof window !== 'undefined' ? window.innerWidth : 1920,
h: typeof window !== 'undefined' ? window.innerHeight : 1080,
});
const MAX_ZOOM = 64;
const minZoom = $derived(Math.min(viewport.w / CANVAS_WIDTH, viewport.h / CANVAS_HEIGHT));
const clampZoom = (z) => Math.max(minZoom, Math.min(MAX_ZOOM, z));
$effect(() => {
if (typeof window === 'undefined') return;
const onResize = () => { viewport = { w: window.innerWidth, h: window.innerHeight }; };
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
});
// After viewport or zoom-bound changes, pull current zoom into range.
$effect(() => { const z = clampZoom(zoom); if (z !== zoom) zoom = z; });
let mode = $state('paint');
let submitting = $state(false);
let bufferState = $state({ canUndo: false, canRedo: false, pixelCount: 0 });
@@ -150,11 +169,11 @@
return;
case 'q': case 'Q':
e.preventDefault();
zoom = Math.min(zoom * 2, 64);
zoom = clampZoom(zoom * 2);
return;
case 'e': case 'E':
e.preventDefault();
zoom = Math.max(zoom / 2, 0.25);
zoom = clampZoom(zoom / 2);
return;
case 'w': case 'W':
e.preventDefault(); canvasRenderer?.panBy(0, PAN_STEP); return;
@@ -240,20 +259,22 @@
bind:this={canvasRenderer}
{selectedColor}
{zoom}
{minZoom}
{mode}
pickActive={!!activePick}
onPick={handleCanvasPick}
onEyedrop={handleEyedrop}
onZoomChange={(z) => zoom = z}
onZoomChange={(z) => zoom = clampZoom(z)}
onCursorMove={(pos) => cursorPos = pos}
onBufferChange={(s) => bufferState = s}
onBufferFull={handleBufferFull}
/>
<CanvasControls
{zoom}
onZoomIn={() => zoom = Math.min(zoom * 2, 64)}
onZoomOut={() => zoom = Math.max(zoom / 2, 0.25)}
onResetZoom={() => zoom = 1}
{minZoom}
onZoomIn={() => zoom = clampZoom(zoom * 2)}
onZoomOut={() => zoom = clampZoom(zoom / 2)}
onResetZoom={() => zoom = clampZoom(1)}
onGoto={(x, y) => canvasRenderer?.gotoPoint(x, y)}
{cursorPos}
{wsState}
+12 -4
View File
@@ -1,9 +1,12 @@
<script>
import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../../lib/constants.js';
let { zoom, onZoomIn, onZoomOut, onResetZoom, onGoto, cursorPos,
let { zoom, minZoom = 0.25, onZoomIn, onZoomOut, onResetZoom, onGoto, cursorPos,
wsState = 'connecting', onHelp } = $props();
const canZoomOut = $derived(zoom > minZoom + 1e-6);
const canZoomIn = $derived(zoom < 64 - 1e-6);
const wsLabel = $derived({
open: 'Live',
connecting: 'Connecting…',
@@ -34,9 +37,10 @@
<div class="controls">
<span class="ws {wsState}" title={wsLabel} aria-label="Connection: {wsLabel}"></span>
<div class="zoom">
<button onclick={onZoomOut} title="Zoom out (E)">−</button>
<button onclick={onZoomOut} disabled={!canZoomOut}
title={canZoomOut ? 'Zoom out (E)' : 'At min zoom — canvas fits the viewport'}>−</button>
<span class="level">{zoomLabel}</span>
<button onclick={onZoomIn} title="Zoom in (Q)">+</button>
<button onclick={onZoomIn} disabled={!canZoomIn} title="Zoom in (Q)">+</button>
<button onclick={onResetZoom} title="Reset zoom">⟲</button>
</div>
<div class="coords">({cursorPos.x}, {cursorPos.y})</div>
@@ -90,9 +94,13 @@
justify-content: center;
}
.zoom button:hover {
.zoom button:hover:not(:disabled) {
background: #555;
}
.zoom button:disabled {
opacity: 0.35;
cursor: default;
}
.level {
min-width: 40px;
+46 -4
View File
@@ -6,7 +6,7 @@
import { paletteToRgba } from '../../lib/image-to-palette.js';
let { selectedColor, zoom, onZoomChange, onCursorMove, mode, onBufferChange, onBufferFull,
pickActive = false, onPick, onEyedrop } = $props();
pickActive = false, onPick, onEyedrop, minZoom = 0.25 } = $props();
let canvasEl;
let imageData = null;
@@ -36,15 +36,55 @@
let touchStartTime = 0;
let touchMoved = false;
// --- Render coalescing ---
// Any state change calls render(). Multiple calls within a frame collapse
// to one actual paint via requestAnimationFrame. imageDataDirty gates the
// expensive putImageData (16 MB copy): only re-upload when pixels changed.
let rafPending = false;
let imageDataDirty = true;
let pendingEffZoom = null;
function render(effZoom = zoom) {
pendingEffZoom = effZoom;
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
const z = pendingEffZoom;
pendingEffZoom = null;
doRender(z);
});
}
function clampPan(effZoom = zoom) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const dispW = CANVAS_WIDTH * effZoom;
const dispH = CANVAS_HEIGHT * effZoom;
// If canvas >= viewport on an axis, pan range is [vw - dispW, 0] — canvas
// always covers viewport. If canvas < viewport, range is [0, vw - dispW] —
// canvas always stays inside (centered at midpoint).
const minX = Math.min(0, vw - dispW);
const maxX = Math.max(0, vw - dispW);
const minY = Math.min(0, vh - dispH);
const maxY = Math.max(0, vh - dispH);
pan.x = Math.max(minX, Math.min(maxX, pan.x));
pan.y = Math.max(minY, Math.min(maxY, pan.y));
}
function doRender(effZoom) {
if (!canvasEl || !imageData) return;
clampPan(effZoom);
const dpr = window.devicePixelRatio || 1;
const ctx = canvasEl.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.setTransform(1, 0, 0, 1, 0, 0); // reset before clear
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvasEl.width, canvasEl.height);
offCtx.putImageData(imageData, 0, 0);
if (imageDataDirty) {
offCtx.putImageData(imageData, 0, 0);
imageDataDirty = false;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // CSS pixels → device pixels
ctx.translate(pan.x, pan.y);
ctx.scale(effZoom, effZoom);
@@ -71,6 +111,7 @@
imageData.data[off + 1] = rgba[1];
imageData.data[off + 2] = rgba[2];
imageData.data[off + 3] = rgba[3];
imageDataDirty = true;
}
function notifyBuffer() {
@@ -302,7 +343,7 @@
function handleWheel(e) {
e.preventDefault();
const factor = e.deltaY < 0 ? 2 : 0.5;
const newZoom = Math.max(0.25, Math.min(64, zoom * factor));
const newZoom = Math.max(minZoom, Math.min(64, zoom * factor));
pan.x = e.clientX - (e.clientX - pan.x) * (newZoom / zoom);
pan.y = e.clientY - (e.clientY - pan.y) * (newZoom / zoom);
if (newZoom !== zoom) onZoomChange(newZoom);
@@ -365,7 +406,7 @@
const dist = getTouchDist(e.touches);
const center = getTouchCenter(e.touches);
const scale = dist / lastTouchDist;
const newZoom = Math.max(0.25, Math.min(64, zoom * scale));
const newZoom = Math.max(minZoom, Math.min(64, zoom * scale));
pan.x = center.x - (center.x - pan.x) * (newZoom / zoom);
pan.y = center.y - (center.y - pan.y) * (newZoom / zoom);
pan.x += center.x - lastMouse.x;
@@ -418,6 +459,7 @@
committedColors = new Uint8Array(indices); // replace pre-allocated zero array
const rgba = indicesToRgba(indices);
imageData = new ImageData(rgba, CANVAS_WIDTH, CANVAS_HEIGHT);
imageDataDirty = true;
render();
} catch (err) {
console.error('Failed to load canvas:', err);