perf(canvas): port renderer to PixiJS (GPU pan/zoom) + cap DPR at 2

Replace Canvas2D render path with pixi.js v8. Canvas stays authored
as CPU RGBA on an OffscreenCanvas; pixi uses that as a texture source
and uploads on demand. Pan/zoom become pure GPU transforms on a
Container — no CPU work per frame. Overlay is a second sprite in the
same container so it tracks pan/zoom for free.

Key points:
- app.ticker.stop() + manual app.renderer.render() on rAF: idle frames
  cost 0, active frames cost one GPU draw.
- mainTexture.source.update() called only when imageDataDirty (same
  gating as before). Writes stay cheap.
- resolution: Math.min(devicePixelRatio, 2). iPhone 3x → 2x = 44%
  fewer fragments; pixel art gains nothing from 3x.
- Public API preserved: applyUpdates, undo/redo, setOverlay, panBy,
  gotoPoint, getCommittedColor, getPendingPixels, commitPending,
  refetchCanvas, cancelStrokeIfAny all unchanged.
- Overlay sprite rebuilt on setOverlay() with destroy({texture,
  textureSource}) to avoid GPU leaks.

Bundle: +200KB main, +~100KB pixi chunks, ~70KB gz total. Tradeoff
accepted for mobile perf parity with wplace.
This commit is contained in:
tiennm99 committed 2026-04-18 17:51:53 +07:00
1 parent 088ed291a8
commit ce05024858
3 files changed
+228 -47

No files matched your search

+103 -1
View File
@@ -7,7 +7,8 @@
"name": "rplace",
"dependencies": {
"@upstash/redis": "^1.34.3",
"hono": "^4.7.6"
"hono": "^4.7.6",
"pixi.js": "^8.18.1"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
@@ -1252,6 +1253,12 @@
"debug": "^4.1.1"
}
},
"node_modules/@pixi/colord": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
"license": "MIT"
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1834,6 +1841,12 @@
"@types/ssh2": "*"
}
},
"node_modules/@types/earcut": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2017,6 +2030,21 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -2851,6 +2879,12 @@
"node": ">=6"
}
},
"node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -2989,6 +3023,12 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -3106,6 +3146,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gifuct-js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
"license": "MIT",
"dependencies": {
"js-binary-schema-parser": "^2.0.3"
}
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -3244,6 +3293,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/ismobilejs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
"license": "MIT"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -3260,6 +3315,12 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/js-binary-schema-parser": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
"license": "MIT"
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -3527,6 +3588,12 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -3588,6 +3655,32 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pixi.js": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.18.1.tgz",
"integrity": "sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==",
"license": "MIT",
"workspaces": [
"examples",
"playground"
],
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/earcut": "^3.0.0",
"@webgpu/types": "^0.1.69",
"@xmldom/xmldom": "^0.8.12",
"earcut": "^3.0.2",
"eventemitter3": "^5.0.1",
"gifuct-js": "^2.1.2",
"ismobilejs": "^1.1.1",
"parse-svg-path": "^0.1.2",
"tiny-lru": "^11.4.7"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/pixijs"
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
@@ -4281,6 +4374,15 @@
"b4a": "^1.6.4"
}
},
"node_modules/tiny-lru": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+2 -1
View File
@@ -15,7 +15,8 @@
},
"dependencies": {
"@upstash/redis": "^1.34.3",
"hono": "^4.7.6"
"hono": "^4.7.6",
"pixi.js": "^8.18.1"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
+123 -45
View File
@@ -1,5 +1,6 @@
<script>
import { onMount } from 'svelte';
import { Application, Sprite, Container, Texture } from 'pixi.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA, MAX_BATCH_SIZE } from '../../lib/constants.js';
import { decodeCanvas, indicesToRgba } from '../../lib/canvas-decoder.js';
import { createPixelBuffer } from '../../lib/pixel-buffer.js';
@@ -24,11 +25,25 @@
let currentStrokeKeys = new Set();
const buffer = createPixelBuffer();
// Offscreen canvas holds the "committed+pending" RGBA image. PIXI uses it
// as a texture source — we tell PIXI to re-upload when it changes.
const offscreen = new OffscreenCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
const offCtx = offscreen.getContext('2d');
/** Overlay state: image-importer preview rendered on top of committed+pending.
* { x, y, canvas: OffscreenCanvas, alpha } or null. */
// PIXI state — initialized asynchronously in onMount.
/** @type {Application|null} */
let app = null;
/** @type {Container|null} */
let worldContainer = null; // holds both sprites; pan via position, zoom via scale
/** @type {Sprite|null} */
let mainSprite = null;
/** @type {Sprite|null} */
let overlaySprite = null;
/** @type {Texture|null} */
let mainTexture = null;
/** Overlay state mirrored outside of pixi so setOverlay() can defer to init. */
let overlayState = null;
// Touch state
@@ -36,23 +51,19 @@
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.
// --- Render scheduling ---
// PIXI's own ticker is stopped; we render manually, coalesced per frame.
// imageDataDirty gates the 16 MB putImageData — only re-upload when pixels
// actually changed (setPixelRgba / loadCanvas).
let rafPending = false;
let imageDataDirty = true;
let pendingEffZoom = null;
function render(effZoom = zoom) {
pendingEffZoom = effZoom;
function render() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
const z = pendingEffZoom;
pendingEffZoom = null;
doRender(z);
doRender();
});
}
@@ -72,28 +83,18 @@
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);
if (imageDataDirty) {
function doRender() {
if (!app || !worldContainer) return;
clampPan(zoom);
// Flush pixel edits to the offscreen, then tell pixi its texture is stale.
if (imageDataDirty && imageData) {
offCtx.putImageData(imageData, 0, 0);
mainTexture?.source?.update();
imageDataDirty = false;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // CSS pixels → device pixels
ctx.translate(pan.x, pan.y);
ctx.scale(effZoom, effZoom);
ctx.drawImage(offscreen, 0, 0);
if (overlayState) {
ctx.globalAlpha = overlayState.alpha;
ctx.drawImage(overlayState.canvas, overlayState.x, overlayState.y);
ctx.globalAlpha = 1;
}
worldContainer.position.set(pan.x, pan.y);
worldContainer.scale.set(zoom);
app.renderer.render(app.stage);
}
function screenToCanvas(clientX, clientY) {
@@ -213,6 +214,12 @@
/** Set or clear the preview overlay drawn on top of the canvas.
* @param {{x: number, y: number, width: number, height: number, indices: Int16Array|number[], alpha?: number}|null} overlay */
export function setOverlay(overlay) {
// Tear down any previous overlay sprite + texture.
if (overlaySprite) {
worldContainer?.removeChild(overlaySprite);
overlaySprite.destroy({ texture: true, textureSource: true });
overlaySprite = null;
}
if (!overlay || !overlay.indices || overlay.width <= 0 || overlay.height <= 0) {
overlayState = null;
render();
@@ -221,9 +228,16 @@
const { x, y, width, height, indices, alpha = 0.6 } = overlay;
const rgba = paletteToRgba(indices, width, height);
const oc = new OffscreenCanvas(width, height);
const octx = oc.getContext('2d');
octx.putImageData(new ImageData(rgba, width, height), 0, 0);
overlayState = { x, y, canvas: oc, alpha };
oc.getContext('2d').putImageData(new ImageData(rgba, width, height), 0, 0);
overlayState = { x, y, width, height, canvas: oc, alpha };
if (worldContainer) {
const tex = Texture.from(oc);
tex.source.scaleMode = 'nearest';
overlaySprite = new Sprite(tex);
overlaySprite.position.set(x, y);
overlaySprite.alpha = alpha;
worldContainer.addChild(overlaySprite);
}
render();
}
@@ -347,7 +361,7 @@
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);
render(newZoom); // explicit — covers the case where zoom was clamped
render();
}
// --- Touch handlers ---
@@ -414,7 +428,7 @@
lastTouchDist = dist;
lastMouse = center;
if (newZoom !== zoom) onZoomChange(newZoom);
render(newZoom);
render();
}
}
@@ -469,22 +483,86 @@
}
}
async function initPixi() {
// Pre-fill the offscreen with a zero-image so the initial texture upload
// is valid before loadCanvas() finishes (prevents a one-frame black flash).
imageData = new ImageData(
new Uint8ClampedArray(CANVAS_WIDTH * CANVAS_HEIGHT * 4),
CANVAS_WIDTH, CANVAS_HEIGHT,
);
offCtx.putImageData(imageData, 0, 0);
imageDataDirty = false;
app = new Application();
// Cap DPR at 2 — pixel art gains nothing from 3× and mobile GPUs hate it.
const resolution = Math.min(window.devicePixelRatio || 1, 2);
await app.init({
canvas: canvasEl,
width: window.innerWidth,
height: window.innerHeight,
backgroundColor: 0x1a1a1a,
resolution,
autoDensity: true,
antialias: false,
});
// Stop auto-ticker — we render on demand (via our rAF coalescer) so idle
// frames don't burn GPU cycles.
app.ticker.stop();
mainTexture = Texture.from(offscreen);
mainTexture.source.scaleMode = 'nearest';
mainSprite = new Sprite(mainTexture);
worldContainer = new Container();
worldContainer.addChild(mainSprite);
app.stage.addChild(worldContainer);
// If an overlay was set before init finished, materialize it now.
if (overlayState && !overlaySprite) {
const tex = Texture.from(overlayState.canvas);
tex.source.scaleMode = 'nearest';
overlaySprite = new Sprite(tex);
overlaySprite.position.set(overlayState.x, overlayState.y);
overlaySprite.alpha = overlayState.alpha;
worldContainer.addChild(overlaySprite);
}
}
onMount(() => {
let destroyed = false;
function resize() {
const dpr = window.devicePixelRatio || 1;
canvasEl.width = window.innerWidth * dpr;
canvasEl.height = window.innerHeight * dpr;
canvasEl.style.width = `${window.innerWidth}px`;
canvasEl.style.height = `${window.innerHeight}px`;
if (!app) return;
app.renderer.resize(window.innerWidth, window.innerHeight);
render();
}
resize();
window.addEventListener('resize', resize);
// Async load runs in background — onMount cleanup must be sync to avoid leaking listener.
loadCanvas();
// Chain pixi init → initial canvas fetch → first paint.
(async () => {
try {
await initPixi();
if (destroyed) { app?.destroy(false, { children: true, texture: true, textureSource: true }); return; }
await loadCanvas();
if (destroyed) return;
render();
} catch (err) {
console.error('Renderer init failed:', err);
loadError = err.message || String(err);
}
})();
return () => window.removeEventListener('resize', resize);
return () => {
destroyed = true;
window.removeEventListener('resize', resize);
// `removeView: false` so Svelte keeps managing the <canvas> element.
app?.destroy(false, { children: true, texture: true, textureSource: true });
app = null;
worldContainer = null;
mainSprite = null;
mainTexture = null;
overlaySprite = null;
};
});
</script>