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
+85
-14
No files matched your search
+28
-7
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<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 CanvasRenderer from './components/CanvasRenderer.svelte';
|
||||||
import ColorPicker from './components/ColorPicker.svelte';
|
import ColorPicker from './components/ColorPicker.svelte';
|
||||||
import CanvasControls from './components/CanvasControls.svelte';
|
import CanvasControls from './components/CanvasControls.svelte';
|
||||||
@@ -10,6 +10,25 @@
|
|||||||
let selectedColor = $state(0);
|
let selectedColor = $state(0);
|
||||||
let cursorPos = $state({ x: 0, y: 0 });
|
let cursorPos = $state({ x: 0, y: 0 });
|
||||||
let zoom = $state(1);
|
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 mode = $state('paint');
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let bufferState = $state({ canUndo: false, canRedo: false, pixelCount: 0 });
|
let bufferState = $state({ canUndo: false, canRedo: false, pixelCount: 0 });
|
||||||
@@ -150,11 +169,11 @@
|
|||||||
return;
|
return;
|
||||||
case 'q': case 'Q':
|
case 'q': case 'Q':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
zoom = Math.min(zoom * 2, 64);
|
zoom = clampZoom(zoom * 2);
|
||||||
return;
|
return;
|
||||||
case 'e': case 'E':
|
case 'e': case 'E':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
zoom = Math.max(zoom / 2, 0.25);
|
zoom = clampZoom(zoom / 2);
|
||||||
return;
|
return;
|
||||||
case 'w': case 'W':
|
case 'w': case 'W':
|
||||||
e.preventDefault(); canvasRenderer?.panBy(0, PAN_STEP); return;
|
e.preventDefault(); canvasRenderer?.panBy(0, PAN_STEP); return;
|
||||||
@@ -240,20 +259,22 @@
|
|||||||
bind:this={canvasRenderer}
|
bind:this={canvasRenderer}
|
||||||
{selectedColor}
|
{selectedColor}
|
||||||
{zoom}
|
{zoom}
|
||||||
|
{minZoom}
|
||||||
{mode}
|
{mode}
|
||||||
pickActive={!!activePick}
|
pickActive={!!activePick}
|
||||||
onPick={handleCanvasPick}
|
onPick={handleCanvasPick}
|
||||||
onEyedrop={handleEyedrop}
|
onEyedrop={handleEyedrop}
|
||||||
onZoomChange={(z) => zoom = z}
|
onZoomChange={(z) => zoom = clampZoom(z)}
|
||||||
onCursorMove={(pos) => cursorPos = pos}
|
onCursorMove={(pos) => cursorPos = pos}
|
||||||
onBufferChange={(s) => bufferState = s}
|
onBufferChange={(s) => bufferState = s}
|
||||||
onBufferFull={handleBufferFull}
|
onBufferFull={handleBufferFull}
|
||||||
/>
|
/>
|
||||||
<CanvasControls
|
<CanvasControls
|
||||||
{zoom}
|
{zoom}
|
||||||
onZoomIn={() => zoom = Math.min(zoom * 2, 64)}
|
{minZoom}
|
||||||
onZoomOut={() => zoom = Math.max(zoom / 2, 0.25)}
|
onZoomIn={() => zoom = clampZoom(zoom * 2)}
|
||||||
onResetZoom={() => zoom = 1}
|
onZoomOut={() => zoom = clampZoom(zoom / 2)}
|
||||||
|
onResetZoom={() => zoom = clampZoom(1)}
|
||||||
onGoto={(x, y) => canvasRenderer?.gotoPoint(x, y)}
|
onGoto={(x, y) => canvasRenderer?.gotoPoint(x, y)}
|
||||||
{cursorPos}
|
{cursorPos}
|
||||||
{wsState}
|
{wsState}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../../lib/constants.js';
|
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();
|
wsState = 'connecting', onHelp } = $props();
|
||||||
|
|
||||||
|
const canZoomOut = $derived(zoom > minZoom + 1e-6);
|
||||||
|
const canZoomIn = $derived(zoom < 64 - 1e-6);
|
||||||
|
|
||||||
const wsLabel = $derived({
|
const wsLabel = $derived({
|
||||||
open: 'Live',
|
open: 'Live',
|
||||||
connecting: 'Connecting…',
|
connecting: 'Connecting…',
|
||||||
@@ -34,9 +37,10 @@
|
|||||||
<div class="controls">
|
<div class="controls">
|
||||||
<span class="ws {wsState}" title={wsLabel} aria-label="Connection: {wsLabel}"></span>
|
<span class="ws {wsState}" title={wsLabel} aria-label="Connection: {wsLabel}"></span>
|
||||||
<div class="zoom">
|
<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>
|
<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>
|
<button onclick={onResetZoom} title="Reset zoom">⟲</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="coords">({cursorPos.x}, {cursorPos.y})</div>
|
<div class="coords">({cursorPos.x}, {cursorPos.y})</div>
|
||||||
@@ -90,9 +94,13 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoom button:hover {
|
.zoom button:hover:not(:disabled) {
|
||||||
background: #555;
|
background: #555;
|
||||||
}
|
}
|
||||||
|
.zoom button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.level {
|
.level {
|
||||||
min-width: 40px;
|
min-width: 40px;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { paletteToRgba } from '../../lib/image-to-palette.js';
|
import { paletteToRgba } from '../../lib/image-to-palette.js';
|
||||||
|
|
||||||
let { selectedColor, zoom, onZoomChange, onCursorMove, mode, onBufferChange, onBufferFull,
|
let { selectedColor, zoom, onZoomChange, onCursorMove, mode, onBufferChange, onBufferFull,
|
||||||
pickActive = false, onPick, onEyedrop } = $props();
|
pickActive = false, onPick, onEyedrop, minZoom = 0.25 } = $props();
|
||||||
|
|
||||||
let canvasEl;
|
let canvasEl;
|
||||||
let imageData = null;
|
let imageData = null;
|
||||||
@@ -36,15 +36,55 @@
|
|||||||
let touchStartTime = 0;
|
let touchStartTime = 0;
|
||||||
let touchMoved = false;
|
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) {
|
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;
|
if (!canvasEl || !imageData) return;
|
||||||
|
clampPan(effZoom);
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const ctx = canvasEl.getContext('2d');
|
const ctx = canvasEl.getContext('2d');
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0); // reset before clear
|
ctx.setTransform(1, 0, 0, 1, 0, 0); // reset before clear
|
||||||
ctx.fillStyle = '#1a1a1a';
|
ctx.fillStyle = '#1a1a1a';
|
||||||
ctx.fillRect(0, 0, canvasEl.width, canvasEl.height);
|
ctx.fillRect(0, 0, canvasEl.width, canvasEl.height);
|
||||||
|
if (imageDataDirty) {
|
||||||
offCtx.putImageData(imageData, 0, 0);
|
offCtx.putImageData(imageData, 0, 0);
|
||||||
|
imageDataDirty = false;
|
||||||
|
}
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // CSS pixels → device pixels
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // CSS pixels → device pixels
|
||||||
ctx.translate(pan.x, pan.y);
|
ctx.translate(pan.x, pan.y);
|
||||||
ctx.scale(effZoom, effZoom);
|
ctx.scale(effZoom, effZoom);
|
||||||
@@ -71,6 +111,7 @@
|
|||||||
imageData.data[off + 1] = rgba[1];
|
imageData.data[off + 1] = rgba[1];
|
||||||
imageData.data[off + 2] = rgba[2];
|
imageData.data[off + 2] = rgba[2];
|
||||||
imageData.data[off + 3] = rgba[3];
|
imageData.data[off + 3] = rgba[3];
|
||||||
|
imageDataDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifyBuffer() {
|
function notifyBuffer() {
|
||||||
@@ -302,7 +343,7 @@
|
|||||||
function handleWheel(e) {
|
function handleWheel(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const factor = e.deltaY < 0 ? 2 : 0.5;
|
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.x = e.clientX - (e.clientX - pan.x) * (newZoom / zoom);
|
||||||
pan.y = e.clientY - (e.clientY - pan.y) * (newZoom / zoom);
|
pan.y = e.clientY - (e.clientY - pan.y) * (newZoom / zoom);
|
||||||
if (newZoom !== zoom) onZoomChange(newZoom);
|
if (newZoom !== zoom) onZoomChange(newZoom);
|
||||||
@@ -365,7 +406,7 @@
|
|||||||
const dist = getTouchDist(e.touches);
|
const dist = getTouchDist(e.touches);
|
||||||
const center = getTouchCenter(e.touches);
|
const center = getTouchCenter(e.touches);
|
||||||
const scale = dist / lastTouchDist;
|
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.x = center.x - (center.x - pan.x) * (newZoom / zoom);
|
||||||
pan.y = center.y - (center.y - pan.y) * (newZoom / zoom);
|
pan.y = center.y - (center.y - pan.y) * (newZoom / zoom);
|
||||||
pan.x += center.x - lastMouse.x;
|
pan.x += center.x - lastMouse.x;
|
||||||
@@ -418,6 +459,7 @@
|
|||||||
committedColors = new Uint8Array(indices); // replace pre-allocated zero array
|
committedColors = new Uint8Array(indices); // replace pre-allocated zero array
|
||||||
const rgba = indicesToRgba(indices);
|
const rgba = indicesToRgba(indices);
|
||||||
imageData = new ImageData(rgba, CANVAS_WIDTH, CANVAS_HEIGHT);
|
imageData = new ImageData(rgba, CANVAS_WIDTH, CANVAS_HEIGHT);
|
||||||
|
imageDataDirty = true;
|
||||||
render();
|
render();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load canvas:', err);
|
console.error('Failed to load canvas:', err);
|
||||||
|
|||||||
Reference in new issue
Block a user