From 97c75855c78b24a7a753b5d1a13b1e06b0a66bc3 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:01:38 -0400 Subject: [PATCH 1/6] feat: improve tex packer speed by like 3x --- .vscode/settings.json | 13 +- src/gfx/TexPacker.ts | 247 ++++++++++++++++++++++------- tests/playtests/textureoverload.js | 19 ++- 3 files changed, 217 insertions(+), 62 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 985ff4196..b33179ee9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,12 +6,23 @@ "cSpell.words": [ "anims", "Bbox", + "beant", + "bobo", "gopt", "kaplay", "lajbel", "lerp", + "lshoulder", + "lstick", + "ltrigger", "minver", "mult", - "verts" + "rshoulder", + "rstick", + "rtrigger", + "skuller", + "sukomi", + "verts", + "zombean" ] } diff --git a/src/gfx/TexPacker.ts b/src/gfx/TexPacker.ts index 9f3656bce..ed0e8f1a0 100644 --- a/src/gfx/TexPacker.ts +++ b/src/gfx/TexPacker.ts @@ -6,27 +6,126 @@ import { type GfxCtx, Texture } from "./gfx"; export type Frame = { tex: Texture; q: Quad; id: number }; -enum UsedCorner { - TOPRIGHT = 1, - BOTTOMLEFT = 2, +class TexMap { + constructor( + public tex: Texture, + public el: HTMLCanvasElement, + public ctx: CanvasRenderingContext2D, + ) {} + hg = new PackerHashGrid(); + fch: RectNode | undefined = undefined; + fct: RectNode | undefined = undefined; + _addRect(node: RectNode) { + if (this.fch === undefined) { + this.fch = this.fct = node; + node.prev = undefined; + node.next = undefined; + } + else { + node.prev = undefined; + node.next = this.fch; + this.fch!.prev = node; + this.fch = node; + } + } + _removeRect(node: RectNode) { + if (node.prev) { + node.prev.next = node.next; + } + else { + this.fch = node.next; + } + + if (node.next) { + node.next.prev = node.prev; + } + else { + this.fct = node.prev; + } + node.next = node.prev = undefined; + } + _isInFreeList(node: RectNode) { + return node.prev !== undefined || node.next !== undefined + || this.fch === node; + } } -interface TexMap { +interface RectNode { + id: number; + rect: Rect; tex: Texture; - el: HTMLCanvasElement; - ctx: CanvasRenderingContext2D; + parent?: RectNode; + childTR?: RectNode; + childBL?: RectNode; + prev?: RectNode; + next?: RectNode; +} + +const floor = Math.floor; +class PackerHashGrid { + private g: Partial>> = {}; + + constructor(private c = 64) {} + + _insert(node: RectNode) { + const { rect: { pos: { x, y }, width, height } } = node; + const cs = this.c; + // Get all cells the rectangle overlaps + const minCellX = floor(x / cs); + const minCellY = floor(y / cs); + const maxCellX = floor((x + width) / cs); + const maxCellY = floor((y + height) / cs); + + for (let x = minCellX; x <= maxCellX; x++) { + for (let y = minCellY; y <= maxCellY; y++) { + (this.g[`${x},${y}`] ??= new Set()).add(node); + } + } + } + + _remove(node: RectNode) { + const { rect: { pos: { x, y }, width, height } } = node; + const cs = this.c; + // Get all cells the rectangle overlaps + const minCellX = floor(x / cs); + const minCellY = floor(y / cs); + const maxCellX = floor((x + width) / cs); + const maxCellY = floor((y + height) / cs); + + for (let x = minCellX; x <= maxCellX; x++) { + for (let y = minCellY; y <= maxCellY; y++) { + this.g[`${x},${y}`]?.delete(node); + } + } + } + + _fitCheck(rect: Rect): boolean { + const { pos: { x, y }, width, height } = rect; + const cs = this.c; + // Get all cells the rectangle overlaps + const minCellX = floor(x / cs); + const minCellY = floor(y / cs); + const maxCellX = floor((x + width) / cs); + const maxCellY = floor((y + height) / cs); + + let fits = true; + for (let x = minCellX; x <= maxCellX && fits; x++) { + for (let y = minCellY; y <= maxCellY && fits; y++) { + this.g[`${x},${y}`]?.forEach(node => { + if (testRectRect(rect, node.rect)) fits = false; + }); + } + } + return fits; + } } class FilterTexPacker { _textures: Texture[] = []; - _big: Frame[] = []; - _used = new Map(); - _curMap: TexMap = null as any; - _tex2Map = new Map(); + #big: Frame[] = []; + #used = new Map(); + _curMap!: TexMap; + #tex2Map = new Map(); constructor( private _p: TexPacker, @@ -36,11 +135,11 @@ class FilterTexPacker { private _h: number, private _pad: number, ) { - this._newTexture(); + this.#newTexture(); } - private _hasPendingRefresh = false; - private _newTexture(): TexMap { + #hasPendingRefresh = false; + #newTexture(): TexMap { this._sync(); const el = document.createElement("canvas"); el.width = this._w; @@ -51,13 +150,13 @@ class FilterTexPacker { const ctx = el.getContext("2d"); if (!ctx) throw new Error("Failed to get 2d context"); - this._tex2Map.set(tex, this._curMap = { tex, el, ctx }); + this.#tex2Map.set(tex, this._curMap = new TexMap(tex, el, ctx)); return this._curMap; } _sync() { - if (this._hasPendingRefresh) { + if (this.#hasPendingRefresh) { this._curMap.tex.update(this._curMap.el); - this._hasPendingRefresh = false; + this.#hasPendingRefresh = false; } } @@ -67,7 +166,7 @@ class FilterTexPacker { Texture.fromImage(this._gfx, img, { filter: this._f }), new Quad(0, 0, 1, 1), ); - this._big.push(f); + this.#big.push(f); return f; } @@ -80,9 +179,9 @@ class FilterTexPacker { imgWidth: number, imgHeight: number, ) { - const curCtx = this._curMap.ctx; + const m = this._curMap; drawImageSourceAt( - curCtx, + m.ctx, img, x, y, @@ -91,7 +190,7 @@ class FilterTexPacker { imgWidth, imgHeight, ); - this._hasPendingRefresh = true; + this.#hasPendingRefresh = true; } _add(img: ImageSource, chopQuad: Quad | undefined): Frame { @@ -102,7 +201,8 @@ class FilterTexPacker { const pad = this._pad; const paddedWidth = imgWidth + pad; const paddedHeight = imgHeight + pad; - let { el: curEl, ctx: curCtx, tex: curTex } = this._curMap; + let m = this._curMap; + let { el: curEl, tex: curTex } = m; const maxX = curEl.width, maxY = curEl.height; const rectToAdd = new Rect(new Vec2(), paddedWidth, paddedHeight); @@ -114,39 +214,44 @@ class FilterTexPacker { } // find position - let x = pad, y = pad, found = false; + let x = pad, y = pad; const doesitfit = () => { // goes offscreen? if (x + paddedWidth > maxX || y + paddedHeight > maxY) return false; // try it p.set(x, y); - for (let { rect, tex } of this._used.values()) { - if (curTex !== tex) continue; - if (testRectRect(rect, rectToAdd)) return false; - } - return found = true; + return m.hg._fitCheck(rectToAdd); }; // initial check for (0, 0) - if (!doesitfit()) { - for (let entry of this._used.values()) { - const { tex, rect: { pos, width, height }, used } = entry; - if (curTex !== tex) continue; - // try to the right - if ((used & UsedCorner.TOPRIGHT) === 0) { + let found = false, + foundParentNode: RectNode | undefined, + foundCornerIsTopRight: boolean; + if (doesitfit()) { + found = true; + } + else { + for (let node = m.fch; node !== undefined; node = node.next) { + const { rect: { pos, width, height }, childBL, childTR } = node; + if (!childTR) { x = pos.x + width; y = pos.y; if (doesitfit()) { - entry.used |= UsedCorner.TOPRIGHT; + foundParentNode = node; + found = foundCornerIsTopRight = true; + if (childBL) m._removeRect(node); break; } } // try below - if ((used & UsedCorner.BOTTOMLEFT) === 0) { + if (!childBL) { x = pos.x; y = pos.y + height; if (doesitfit()) { - entry.used |= UsedCorner.BOTTOMLEFT; + foundParentNode = node; + found = true; + foundCornerIsTopRight = false; + if (childTR) m._removeRect(node); break; } } @@ -160,7 +265,7 @@ class FilterTexPacker { p.x = p.y = pad; - ({ tex: curTex, ctx: curCtx, el: curEl } = this._newTexture()); + ({ tex: curTex, el: curEl } = m = this.#newTexture()); } this._blit(img, x, y, chopX, chopY, imgWidth, imgHeight); @@ -171,11 +276,21 @@ class FilterTexPacker { new Quad(x / maxX, y / maxY, imgWidth / maxX, imgHeight / maxY), ); - this._used.set(f.id, { + const newNode: RectNode = { + id: f.id, rect: rectToAdd, tex: curTex, - used: 0, - }); + parent: foundParentNode, + }; + + if (foundParentNode) { + foundParentNode[foundCornerIsTopRight! ? "childTR" : "childBL"] = + newNode; + } + + this.#used.set(f.id, newNode); + m.hg._insert(newNode); + m._addRect(newNode); return f; } @@ -191,28 +306,45 @@ class FilterTexPacker { } _free() { this._textures.forEach(tex => tex.free()); - this._big.forEach(f => f.tex.free()); - this._used.clear(); - this._big = []; + this.#big.forEach(f => f.tex.free()); + this.#used.clear(); + this.#big = []; } _remove(packerId: number) { - const entry = this._used.get(packerId); + const node = this.#used.get(packerId); - if (!entry) { - const big = this._big.findIndex(f => f.id === packerId); + if (!node) { + const big = this.#big.findIndex(f => f.id === packerId); if (big < 0) { throw new Error( "Image not found in packer (was this loaded via a prepacked spritesheet?)", ); } - this._big.splice(big, 1)[0]!.tex.free(); + this.#big.splice(big, 1)[0]!.tex.free(); return; } - const { rect: { pos: { x, y }, width, height }, tex } = entry; - this._tex2Map.get(tex)!.ctx.clearRect(x, y, width, height); - this._used.delete(packerId); - this._hasPendingRefresh = true; + const { rect: { pos: { x, y }, width, height }, tex } = node; + const m = this.#tex2Map.get(tex)!; + m.ctx.clearRect(x, y, width, height); + + m.hg._remove(node); + if (m._isInFreeList(node)) m._removeRect(node); + + // free parent corner + const parent = node.parent; + if (parent) { + if (parent.childTR === node) { + parent.childTR = undefined; + } + if (parent.childBL === node) { + parent.childBL = undefined; + } + if (!m._isInFreeList(parent)) m._addRect(parent); + } + + this.#used.delete(packerId); + this.#hasPendingRefresh = true; } } @@ -260,11 +392,10 @@ export class TexPacker { 1, ); packer._blit(whitePixel, width - 1, height - 1, 0, 0, 1, 1); - packer._sync(); - packer._used.set(-1, { + packer._curMap.hg._insert({ rect: new Rect(new Vec2(width - 1, height - 1), 1, 1), tex: tex, - used: UsedCorner.BOTTOMLEFT | UsedCorner.TOPRIGHT, // it's bottom right so nothing can go right or below it + id: -1, }); return { tex, diff --git a/tests/playtests/textureoverload.js b/tests/playtests/textureoverload.js index 6de48c441..62b2ae273 100644 --- a/tests/playtests/textureoverload.js +++ b/tests/playtests/textureoverload.js @@ -14,17 +14,30 @@ const friends = [ "mark", "kat", "tga", + "lightening", + "heart", + "key", + "meat", + "sword", + "watermelon", ].sort(); +var delta = 0, friendCount = 0; load((async () => { + const start = performance.now(); while (_k.assets.packer._getPacker("nearest")._textures.length < 2) { - for (let friend of friends) { - await loadSprite(friend, `/crew/${friend}.png`); - } + const friend = friends[friendCount % friends.length]; + await loadSprite(friend, `/crew/${friend}.png`); + friendCount++; } + const end = performance.now(); + delta = end - start; })()); onLoad(() => { + add([ + text(delta + " ms to fill the atlas with " + friendCount + " friends"), + ]); for (let friend of friends) { add([ sprite(friend), From 3c527744cf303623eb38f214d0b0c531c6ef8f53 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:51:22 -0400 Subject: [PATCH 2/6] changeloged --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4daff334b..9a30fb582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ So your change should look like: ### Fixed +- Made the texture packer 3x faster (#1102) - @dragoncoder047 - Fixed `TimerController.timeLeft` returning elapsed time instead of remaining time (#1082) - @nojaf - Fixed mouse coordinates not being calculated properly when canvas is resized From 514da59e48dbe9a83d3cb225b1b4fb0cbd2b2e37 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:35:26 -0400 Subject: [PATCH 3/6] fix: initial sync for when only primitives are drawn and no sprites (also fixes loading screen lol) --- src/gfx/TexPacker.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/gfx/TexPacker.ts b/src/gfx/TexPacker.ts index ed0e8f1a0..17c2ae364 100644 --- a/src/gfx/TexPacker.ts +++ b/src/gfx/TexPacker.ts @@ -138,7 +138,7 @@ class FilterTexPacker { this.#newTexture(); } - #hasPendingRefresh = false; + _hasPendingRefresh = false; #newTexture(): TexMap { this._sync(); const el = document.createElement("canvas"); @@ -154,9 +154,9 @@ class FilterTexPacker { return this._curMap; } _sync() { - if (this.#hasPendingRefresh) { + if (this._hasPendingRefresh) { this._curMap.tex.update(this._curMap.el); - this.#hasPendingRefresh = false; + this._hasPendingRefresh = false; } } @@ -190,7 +190,7 @@ class FilterTexPacker { imgWidth, imgHeight, ); - this.#hasPendingRefresh = true; + this._hasPendingRefresh = true; } _add(img: ImageSource, chopQuad: Quad | undefined): Frame { @@ -344,7 +344,7 @@ class FilterTexPacker { } this.#used.delete(packerId); - this.#hasPendingRefresh = true; + this._hasPendingRefresh = true; } } @@ -397,6 +397,8 @@ export class TexPacker { tex: tex, id: -1, }); + packer._hasPendingRefresh = true; + packer._sync(); return { tex, q: new Quad( From 7da55ac607bb3f6a3a7d8b0bb594f5f205340b3f Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:47:41 -0400 Subject: [PATCH 4/6] 10-20% faster --- src/gfx/TexPacker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gfx/TexPacker.ts b/src/gfx/TexPacker.ts index 17c2ae364..43a1d2649 100644 --- a/src/gfx/TexPacker.ts +++ b/src/gfx/TexPacker.ts @@ -112,7 +112,7 @@ class PackerHashGrid { for (let x = minCellX; x <= maxCellX && fits; x++) { for (let y = minCellY; y <= maxCellY && fits; y++) { this.g[`${x},${y}`]?.forEach(node => { - if (testRectRect(rect, node.rect)) fits = false; + if (fits && testRectRect(rect, node.rect)) fits = false; }); } } From 20d9e8d1368c3eeeb69c636dfa6722448d0464b3 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:00:47 -0400 Subject: [PATCH 5/6] fix aseprite loader --- .vscode/settings.json | 1 + CHANGELOG.md | 2 ++ src/assets/aseprite.ts | 9 ++++----- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index b33179ee9..1ee8dd880 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "dprint.path": "./node_modules/dprint/dprint", "cSpell.words": [ "anims", + "Aseprite", "Bbox", "beant", "bobo", diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a30fb582..cda0f2abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,8 @@ So your change should look like: ### Fixed +- Fixed the Aseprite loader using the wrong frames sizes (#1102) - + @dragoncoder047 - Made the texture packer 3x faster (#1102) - @dragoncoder047 - Fixed `TimerController.timeLeft` returning elapsed time instead of remaining time (#1082) - @nojaf diff --git a/src/assets/aseprite.ts b/src/assets/aseprite.ts index 94d35dc34..11ff81331 100644 --- a/src/assets/aseprite.ts +++ b/src/assets/aseprite.ts @@ -48,13 +48,12 @@ export function loadAseprite( return _k.assets.sprites.add( name, resolveJSON.then((data: AsepriteData) => { - const size = data.meta.size; const frames = data.frames.map((f: any) => { return new Quad( - f.frame.x / size.w, - f.frame.y / size.h, - f.frame.w / size.w, - f.frame.h / size.h, + f.frame.x, + f.frame.y, + f.frame.w, + f.frame.h, ); }); const anims: Record = {}; From 7485e03a68b1117797af7fc0a258b43964028781 Mon Sep 17 00:00:00 2001 From: dragoncoder047 <101021094+dragoncoder047@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:13:55 -0400 Subject: [PATCH 6/6] refactor to use destructuring (shorter, possibly faster) --- src/assets/aseprite.ts | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/assets/aseprite.ts b/src/assets/aseprite.ts index 11ff81331..00e0a95ae 100644 --- a/src/assets/aseprite.ts +++ b/src/assets/aseprite.ts @@ -19,7 +19,6 @@ export type AsepriteData = { }; }>; meta: { - size: { w: number; h: number }; frameTags: Array<{ name: string; from: number; @@ -47,33 +46,27 @@ export function loadAseprite( return _k.assets.sprites.add( name, - resolveJSON.then((data: AsepriteData) => { - const frames = data.frames.map((f: any) => { - return new Quad( - f.frame.x, - f.frame.y, - f.frame.w, - f.frame.h, - ); - }); - const anims: Record = {}; + resolveJSON.then(({ meta: { frameTags }, frames }: AsepriteData) => { + const anims: Record = {}; - for (const anim of data.meta.frameTags) { - if (anim.from === anim.to) { - anims[anim.name] = anim.from; + for (const { name, from, to, direction } of frameTags) { + if (from === to) { + anims[name] = from; } else { - anims[anim.name] = { - from: anim.from, - to: anim.to, + anims[name] = { + from, + to, speed: 10, loop: true, - pingpong: anim.direction === "pingpong", + pingpong: direction === "pingpong", }; } } return SpriteData.fromSpriteSrc(imgSrc, { - frames: frames, + frames: frames.map(({ frame: { x, y, w, h } }) => + new Quad(x, y, w, h) + ), anims: anims, repack: false, });