mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
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:
1 parent
088ed291a8
commit
ce05024858
3 files changed
+228
-47
No files matched your search
Generated
+103
-1
@@ -7,7 +7,8 @@
|
|||||||
"name": "rplace",
|
"name": "rplace",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@upstash/redis": "^1.34.3",
|
"@upstash/redis": "^1.34.3",
|
||||||
"hono": "^4.7.6"
|
"hono": "^4.7.6",
|
||||||
|
"pixi.js": "^8.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
@@ -1252,6 +1253,12 @@
|
|||||||
"debug": "^4.1.1"
|
"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": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -1834,6 +1841,12 @@
|
|||||||
"@types/ssh2": "*"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -2017,6 +2030,21 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"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": {
|
"node_modules/abort-controller": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
@@ -2851,6 +2879,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/eastasianwidth": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
@@ -2989,6 +3023,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
@@ -3106,6 +3146,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/glob": {
|
||||||
"version": "10.5.0",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
@@ -3244,6 +3293,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/jackspeak": {
|
||||||
"version": "3.4.3",
|
"version": "3.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
@@ -3260,6 +3315,12 @@
|
|||||||
"@pkgjs/parseargs": "^0.11.0"
|
"@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": {
|
"node_modules/kleur": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||||
@@ -3527,6 +3588,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0"
|
"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": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -3588,6 +3655,32 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.10",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||||
@@ -4281,6 +4374,15 @@
|
|||||||
"b4a": "^1.6.4"
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@upstash/redis": "^1.34.3",
|
"@upstash/redis": "^1.34.3",
|
||||||
"hono": "^4.7.6"
|
"hono": "^4.7.6",
|
||||||
|
"pixi.js": "^8.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
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 { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA, MAX_BATCH_SIZE } from '../../lib/constants.js';
|
||||||
import { decodeCanvas, indicesToRgba } from '../../lib/canvas-decoder.js';
|
import { decodeCanvas, indicesToRgba } from '../../lib/canvas-decoder.js';
|
||||||
import { createPixelBuffer } from '../../lib/pixel-buffer.js';
|
import { createPixelBuffer } from '../../lib/pixel-buffer.js';
|
||||||
@@ -24,11 +25,25 @@
|
|||||||
let currentStrokeKeys = new Set();
|
let currentStrokeKeys = new Set();
|
||||||
|
|
||||||
const buffer = createPixelBuffer();
|
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 offscreen = new OffscreenCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
|
||||||
const offCtx = offscreen.getContext('2d');
|
const offCtx = offscreen.getContext('2d');
|
||||||
|
|
||||||
/** Overlay state: image-importer preview rendered on top of committed+pending.
|
// PIXI state — initialized asynchronously in onMount.
|
||||||
* { x, y, canvas: OffscreenCanvas, alpha } or null. */
|
/** @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;
|
let overlayState = null;
|
||||||
|
|
||||||
// Touch state
|
// Touch state
|
||||||
@@ -36,23 +51,19 @@
|
|||||||
let touchStartTime = 0;
|
let touchStartTime = 0;
|
||||||
let touchMoved = false;
|
let touchMoved = false;
|
||||||
|
|
||||||
// --- Render coalescing ---
|
// --- Render scheduling ---
|
||||||
// Any state change calls render(). Multiple calls within a frame collapse
|
// PIXI's own ticker is stopped; we render manually, coalesced per frame.
|
||||||
// to one actual paint via requestAnimationFrame. imageDataDirty gates the
|
// imageDataDirty gates the 16 MB putImageData — only re-upload when pixels
|
||||||
// expensive putImageData (16 MB copy): only re-upload when pixels changed.
|
// actually changed (setPixelRgba / loadCanvas).
|
||||||
let rafPending = false;
|
let rafPending = false;
|
||||||
let imageDataDirty = true;
|
let imageDataDirty = true;
|
||||||
let pendingEffZoom = null;
|
|
||||||
|
|
||||||
function render(effZoom = zoom) {
|
function render() {
|
||||||
pendingEffZoom = effZoom;
|
|
||||||
if (rafPending) return;
|
if (rafPending) return;
|
||||||
rafPending = true;
|
rafPending = true;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
rafPending = false;
|
rafPending = false;
|
||||||
const z = pendingEffZoom;
|
doRender();
|
||||||
pendingEffZoom = null;
|
|
||||||
doRender(z);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,28 +83,18 @@
|
|||||||
pan.y = Math.max(minY, Math.min(maxY, pan.y));
|
pan.y = Math.max(minY, Math.min(maxY, pan.y));
|
||||||
}
|
}
|
||||||
|
|
||||||
function doRender(effZoom) {
|
function doRender() {
|
||||||
if (!canvasEl || !imageData) return;
|
if (!app || !worldContainer) return;
|
||||||
clampPan(effZoom);
|
clampPan(zoom);
|
||||||
const dpr = window.devicePixelRatio || 1;
|
// Flush pixel edits to the offscreen, then tell pixi its texture is stale.
|
||||||
const ctx = canvasEl.getContext('2d');
|
if (imageDataDirty && imageData) {
|
||||||
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) {
|
|
||||||
offCtx.putImageData(imageData, 0, 0);
|
offCtx.putImageData(imageData, 0, 0);
|
||||||
|
mainTexture?.source?.update();
|
||||||
imageDataDirty = false;
|
imageDataDirty = false;
|
||||||
}
|
}
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // CSS pixels → device pixels
|
worldContainer.position.set(pan.x, pan.y);
|
||||||
ctx.translate(pan.x, pan.y);
|
worldContainer.scale.set(zoom);
|
||||||
ctx.scale(effZoom, effZoom);
|
app.renderer.render(app.stage);
|
||||||
ctx.drawImage(offscreen, 0, 0);
|
|
||||||
if (overlayState) {
|
|
||||||
ctx.globalAlpha = overlayState.alpha;
|
|
||||||
ctx.drawImage(overlayState.canvas, overlayState.x, overlayState.y);
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function screenToCanvas(clientX, clientY) {
|
function screenToCanvas(clientX, clientY) {
|
||||||
@@ -213,6 +214,12 @@
|
|||||||
/** Set or clear the preview overlay drawn on top of the canvas.
|
/** 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 */
|
* @param {{x: number, y: number, width: number, height: number, indices: Int16Array|number[], alpha?: number}|null} overlay */
|
||||||
export function setOverlay(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) {
|
if (!overlay || !overlay.indices || overlay.width <= 0 || overlay.height <= 0) {
|
||||||
overlayState = null;
|
overlayState = null;
|
||||||
render();
|
render();
|
||||||
@@ -221,9 +228,16 @@
|
|||||||
const { x, y, width, height, indices, alpha = 0.6 } = overlay;
|
const { x, y, width, height, indices, alpha = 0.6 } = overlay;
|
||||||
const rgba = paletteToRgba(indices, width, height);
|
const rgba = paletteToRgba(indices, width, height);
|
||||||
const oc = new OffscreenCanvas(width, height);
|
const oc = new OffscreenCanvas(width, height);
|
||||||
const octx = oc.getContext('2d');
|
oc.getContext('2d').putImageData(new ImageData(rgba, width, height), 0, 0);
|
||||||
octx.putImageData(new ImageData(rgba, width, height), 0, 0);
|
overlayState = { x, y, width, height, canvas: oc, alpha };
|
||||||
overlayState = { x, y, 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();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +361,7 @@
|
|||||||
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);
|
||||||
render(newZoom); // explicit — covers the case where zoom was clamped
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Touch handlers ---
|
// --- Touch handlers ---
|
||||||
@@ -414,7 +428,7 @@
|
|||||||
lastTouchDist = dist;
|
lastTouchDist = dist;
|
||||||
lastMouse = center;
|
lastMouse = center;
|
||||||
if (newZoom !== zoom) onZoomChange(newZoom);
|
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(() => {
|
onMount(() => {
|
||||||
|
let destroyed = false;
|
||||||
|
|
||||||
function resize() {
|
function resize() {
|
||||||
const dpr = window.devicePixelRatio || 1;
|
if (!app) return;
|
||||||
canvasEl.width = window.innerWidth * dpr;
|
app.renderer.resize(window.innerWidth, window.innerHeight);
|
||||||
canvasEl.height = window.innerHeight * dpr;
|
|
||||||
canvasEl.style.width = `${window.innerWidth}px`;
|
|
||||||
canvasEl.style.height = `${window.innerHeight}px`;
|
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
resize();
|
|
||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
// Async load runs in background — onMount cleanup must be sync to avoid leaking listener.
|
// Chain pixi init → initial canvas fetch → first paint.
|
||||||
loadCanvas();
|
(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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user