mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
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:
1 parent
6bce69adc8
commit
088ed291a8
3 files changed
+86
-15
No files matched your search
+28
-7
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user