From 898a7365c2377ed7f6a5cc87a34b847688800d77 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 16 Aug 2026 08:19:18 +0800 Subject: [PATCH 01/16] feat(video): share texture slot assignment between both GPU backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both backends solve the same problem — many textures, one draw call — by giving each texture a small integer slot the fragment shader selects on per vertex. Only the resource differs: a WebGL texture unit, or an entry in a WebGPU material bind group. The assignment policy was written twice, in `TextureCache.allocateTextureUnit` and `WebGPUQuadBatcher.segmentSlotFor`, which is how the two drifted. `TextureSlotTable` now owns that policy and nothing else: capacity, key to slot admission, the free-slot search that skips reservations, and the flush-then-evict behaviour on exhaustion. It never touches a GL unit or a bind group — each backend supplies the binding through callbacks, so the same suite proves both behave alike. The WebGL cache keeps residency (the source-keyed index, tinted variants, the atlas cache) and its reservations, and delegates slot assignment. `usedUnits` and `max_size` become views onto the table so the existing surface is unchanged. WebGPU's segment keys move onto it wholesale. Two entry points rather than one: `slotFor(key)` for WebGPU, which keys directly, and a keyless `claim()` for the WebGL cache, which resolves textures through its own source-keyed index and needs the policy without the key map. Both overflow on the same rule. Behaviour is unchanged on both sides; the overflow policy is now a single pluggable path, so changing it changes both backends at once. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- .../melonjs/src/video/gpu/textureslots.js | 197 ++++++++++++++++ packages/melonjs/src/video/texture/cache.js | 86 ++++--- .../src/video/webgpu/batchers/quad_batcher.js | 44 ++-- packages/melonjs/tests/textureslots.spec.js | 216 ++++++++++++++++++ .../melonjs/tests/webgpu_quad_batcher.spec.js | 45 ++++ 5 files changed, 540 insertions(+), 48 deletions(-) create mode 100644 packages/melonjs/src/video/gpu/textureslots.js create mode 100644 packages/melonjs/tests/textureslots.spec.js diff --git a/packages/melonjs/src/video/gpu/textureslots.js b/packages/melonjs/src/video/gpu/textureslots.js new file mode 100644 index 000000000..a27ab00b7 --- /dev/null +++ b/packages/melonjs/src/video/gpu/textureslots.js @@ -0,0 +1,197 @@ +/** + * Backend-neutral texture slot assignment. + * + * Both GPU backends solve the same problem — "many textures, one draw call" — + * by giving each texture a small integer slot that the fragment shader selects + * on per-vertex. Only the *resource* differs: a WebGL texture unit, or an entry + * in a WebGPU material bind group. The assignment policy (who gets a slot, what + * happens when they run out, what is dropped) is identical, and used to be + * written twice — `TextureCache.allocateTextureUnit` on one side, + * `WebGPUQuadBatcher.segmentSlotFor` on the other — which is how the two drifted. + * + * This class owns that policy and nothing else. It never touches a GL unit or a + * bind group; the caller supplies the binding through the callbacks. That keeps + * it pure, so the same test suite proves both backends behave alike. + * @ignore + */ +export class TextureSlotTable { + /** + * @param {object} options - table configuration + * @param {number} options.capacity - number of slots, `[0, capacity)` + * @param {Function} [options.onOverflow] - called when a new key arrives with + * no slot free. The caller must turn the pending work into GPU work (flush) + * before the table reassigns slots out from under it. + * @param {Function} [options.onEvict] - called with each slot index as it is + * dropped, so the caller can forget its binding for that slot + * @param {Function} [options.isReserved] - optional predicate; a slot it + * approves is held out of assignment entirely (WebGL parks `ShaderEffect` + * extra samplers on high units this way). Reservations survive a reset. + * @ignore + */ + constructor({ capacity, onOverflow, onEvict, isReserved } = {}) { + /** @type {Map} key → slot */ + this.slots = new Map(); + /** @type {Set} occupied slot indices */ + this.used = new Set(); + this.capacity = capacity ?? 0; + this.onOverflow = onOverflow; + this.onEvict = onEvict; + this.isReserved = isReserved; + } + + /** + * How many slots are currently taken. + * + * Occupancy, NOT the key map: slots claimed through {@link claim} carry no + * key at all (the WebGL cache allocates that way), so counting keys would + * report an empty table while every unit was in use. + * @returns {number} the number of occupied slots + * @ignore + */ + get size() { + return this.used.size; + } + + /** + * Resize the table. Shrinking evicts every assignment at or above the new + * capacity; growing keeps everything. Called on context restore / device + * change, where the resolved limit can differ from the previous one. + * @param {number} capacity - the new slot count + * @ignore + */ + setCapacity(capacity) { + this.capacity = capacity; + for (const [key, slot] of this.slots) { + if (slot >= capacity) { + this.slots.delete(key); + } + } + // occupancy is the authority, not the key map: slots taken through + // `claim()` have no key at all (the WebGL cache allocates that way), so + // iterating `slots` alone would leave them marked used above the new + // capacity and every later allocation would spuriously overflow + for (const slot of [...this.used]) { + if (slot >= capacity) { + this.used.delete(slot); + this.onEvict?.(slot); + } + } + } + + /** + * The slot this key already holds, without assigning one. Use when a miss + * should not trigger an overflow (probing, debug, bookkeeping). + * @param {string} key - the slot key + * @returns {number|undefined} the assigned slot, or `undefined` + * @ignore + */ + peek(key) { + return this.slots.get(key); + } + + /** + * The lowest assignable slot, or `-1` when every one is taken or reserved. + * Bounded by `capacity` on purpose: a caller that reserves every slot must + * get an answer rather than an infinite scan. + * @returns {number} a free slot index, or -1 + * @ignore + */ + freeSlot() { + for (let slot = 0; slot < this.capacity; slot++) { + if (!this.used.has(slot) && this.isReserved?.(slot) !== true) { + return slot; + } + } + return -1; + } + + /** + * Resolve a key to its slot, assigning one if needed. + * + * On exhaustion this calls `onOverflow` (the caller flushes) and then drops + * every assignment, so the incoming key starts a fresh set — the behaviour + * both backends already had. The eviction step is deliberately the only + * policy here: replacing this wipe with something finer (see #1586) changes + * both backends at once, which is the point of sharing it. + * @param {string} key - identifies the texture *and* the sampling state it + * needs, since two draws of one image under different filter/wrap settings + * cannot share a slot + * @returns {number} the slot to write into the vertex stream + * @ignore + */ + slotFor(key) { + const existing = this.slots.get(key); + if (existing !== undefined) { + return existing; + } + const slot = this.claim(); + this.slots.set(key, slot); + return slot; + } + + /** + * Take a slot without associating a key with it. + * + * The WebGL cache resolves textures to units through its own source-keyed + * index (a source can hold one unit per wrap mode it was sampled with), so + * it needs the *policy* — free-slot search honoring reservations, and the + * flush-then-evict behaviour on exhaustion — without the key map. WebGPU's + * batcher keys directly and goes through {@link slotFor}, which is this plus + * the mapping. Both therefore overflow on the same rule. + * @returns {number} the claimed slot + * @ignore + */ + claim() { + let slot = this.freeSlot(); + if (slot < 0) { + // full: let the caller draw what is pending BEFORE anything is + // reassigned, or those vertices would sample the wrong texture + this.onOverflow?.(); + this.reset(); + slot = this.freeSlot(); + if (slot < 0) { + // every slot is reserved (or capacity is 0) — there is no + // correct answer, and silently returning 0 would alias onto a + // reserved texture + throw new Error( + `TextureSlotTable: no assignable slot (capacity ${this.capacity}, all reserved)`, + ); + } + } + this.used.add(slot); + return slot; + } + + /** + * Drop a single assignment, freeing its slot. Reservations are untouched — + * they are owned by whoever reserved them, not by the table. + * @param {string} key - the slot key to release + * @returns {boolean} whether the key held a slot + * @ignore + */ + release(key) { + const slot = this.slots.get(key); + if (slot === undefined) { + return false; + } + this.slots.delete(key); + this.used.delete(slot); + this.onEvict?.(slot); + return true; + } + + /** + * Drop every assignment. `onEvict` fires once per slot that was live, so a + * caller tracking bindings per slot can forget exactly those. + * @ignore + */ + reset() { + if (this.onEvict !== undefined) { + for (const slot of this.used) { + this.onEvict(slot); + } + } + this.slots.clear(); + this.used.clear(); + } +} diff --git a/packages/melonjs/src/video/texture/cache.js b/packages/melonjs/src/video/texture/cache.js index 1aaf3a789..7149d1aa6 100644 --- a/packages/melonjs/src/video/texture/cache.js +++ b/packages/melonjs/src/video/texture/cache.js @@ -1,6 +1,7 @@ import { emit, GPU_TEXTURE_CACHE_RESET } from "../../system/event.ts"; import { ArrayMultimap } from "../../utils/array-multimap.js"; import { getBasename } from "../../utils/file.ts"; +import { TextureSlotTable } from "../gpu/textureslots.js"; import { createAtlas, TextureAtlas } from "./atlas.js"; // Canonical repeat values accepted by `CanvasRenderingContext2D.createPattern` @@ -40,17 +41,64 @@ class TextureCache { // whole map); `Map` keys are strong references, so the entries // don't drop when the source goes out of scope user-side. this.units = new Map(); - this.usedUnits = new Set(); + // Slot assignment is the shared, backend-neutral policy (#1585): which + // slot is free, what reservations are skipped, and what happens on + // exhaustion. The WebGPU quad batcher runs the same table, so the two + // backends cannot drift on when a texture set overflows. What stays + // here is residency — the source-keyed index above, tinted variants, + // the atlas cache — and the reservations below. + this.slotTable = new TextureSlotTable({ + capacity: max_size, + isReserved: (unit) => { + return this.reservedUnits.has(unit); + }, + onOverflow: () => { + // draw what is pending with THEIR units before any reassignment + // see https://github.com/melonjs/melonJS/issues/1280 + if (this.renderer.currentBatcher) { + this.renderer.currentBatcher.flush(); + } + // the source-keyed index is about to be invalidated wholesale + this.units.clear(); + emit(GPU_TEXTURE_CACHE_RESET); + }, + }); // units held out of `allocateTextureUnit` for shader extra-samplers // (`ShaderEffect.setTexture`). Reference-counted (unit → count) so a // unit shared by several effects stays reserved until the last one // releases it. Not touched by `clear()` — reservations are owned by the // effects, and released on their destroy / context-loss. this.reservedUnits = new Map(); - this.max_size = max_size; this.clear(); } + /** + * Which texture units are currently taken. This is the slot table's own + * occupancy set, exposed under the name it has always had — seeding or + * clearing it drives the allocator directly. + * @returns {Set} the occupied units + * @ignore + */ + get usedUnits() { + return this.slotTable.used; + } + + /** + * How many texture units this cache may hand out. Backed by the slot + * table's capacity, so assigning it (tests narrow it to force exhaustion, + * and a context restore can resolve a different device limit) re-sizes the + * allocator rather than leaving the two disagreeing. + * @returns {number} the unit count + * @ignore + */ + get max_size() { + return this.slotTable.capacity; + } + + set max_size(size) { + this.slotTable.setCapacity(size); + } + /** * @ignore */ @@ -58,40 +106,16 @@ class TextureCache { this.cache.clear(); this.tinted.clear(); this.units.clear(); - this.usedUnits.clear(); + this.slotTable.reset(); } /** * @ignore */ allocateTextureUnit() { - // find the first unit available among the max_size (skip units held - // for shader extra-samplers via `reserveUnit`) - for (let unit = 0; unit < this.max_size; unit++) { - // Check if unit is available - if (!this.usedUnits.has(unit) && !this.reservedUnits.has(unit)) { - // Add to used set - this.usedUnits.add(unit); - // return the new unit - return unit; - } - } - - // No units available — flush the current batch and reset assignments - // see https://github.com/melonjs/melonJS/issues/1280 - if (this.renderer.currentBatcher) { - this.renderer.currentBatcher.flush(); - } - this.units.clear(); - this.usedUnits.clear(); - // return the first non-reserved unit (reservations survive the reset) - let unit = 0; - while (this.reservedUnits.has(unit)) { - unit++; - } - this.usedUnits.add(unit); - emit(GPU_TEXTURE_CACHE_RESET); - return unit; + // the policy — free-slot search skipping reservations, and flush + + // evict-everything on exhaustion — lives in the shared table + return this.slotTable.claim(); } /** @@ -128,7 +152,7 @@ class TextureCache { */ resetUnitAssignments() { this.units.clear(); - this.usedUnits.clear(); + this.slotTable.reset(); emit(GPU_TEXTURE_CACHE_RESET); } diff --git a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js index 2e5db9e05..d8f982a1f 100644 --- a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js @@ -1,5 +1,6 @@ import IndexBuffer from "../../buffer/index.js"; import { transformQuadCorners } from "../../gpu/quadcorners.ts"; +import { TextureSlotTable } from "../../gpu/textureslots.js"; import { prepareEffectBinding } from "../effect_binding.js"; import { MAX_QUAD_TEXTURES } from "../pipeline/cache.js"; import WebGPUBatcher from "./webgpu_batcher.js"; @@ -73,9 +74,20 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { // (texture view, sampler) pairs share one draw segment, selected // per quad by aTextureId — a flush is forced only by the NINTH // distinct texture (or the usual capacity/effect boundaries) - /** @type {Map} slot key → slot index */ - this.segmentKeys = new Map(); - /** @type {{view: GPUTextureView, sampler: GPUSampler}[]} */ + // slot assignment is the shared backend-neutral policy (#1585) — the + // same table the WebGL texture cache allocates units from, so the two + // backends cannot drift on when a texture set overflows + this.slotTable = new TextureSlotTable({ + capacity: MAX_QUAD_TEXTURES, + // draw the pending quads with THEIR slots before any reassignment + onOverflow: () => { + this.flush(); + }, + onEvict: (slot) => { + this.segmentEntries[slot] = undefined; + }, + }); + /** @type {{view: GPUTextureView, sampler: GPUSampler}[]} indexed by slot */ this.segmentEntries = []; // the composed group-1 bind group for the pending segment (lazy) this.segmentGroup = null; @@ -214,22 +226,18 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { ? texture.filter : renderer.getDefaultTextureFilter(); const slotKey = `${this.resourceId(record.view)}|${filter}|${wrap}`; - let slot = this.segmentKeys.get(slotKey); - if (typeof slot === "undefined") { - if (this.segmentEntries.length >= MAX_QUAD_TEXTURES) { - // segment at capacity — the pending quads draw with THEIR - // eight textures, and this quad starts the next segment - this.flush(); - } - slot = this.segmentEntries.length; - this.segmentEntries.push({ + // a hit returns the live slot; a miss claims one, flushing the pending + // segment first when the table is full (the `onOverflow` above) + if (this.slotTable.peek(slotKey) === undefined) { + const slot = this.slotTable.slotFor(slotKey); + this.segmentEntries[slot] = { view: record.view, sampler: store.getSampler(filter, wrap), - }); - this.segmentKeys.set(slotKey, slot); + }; this.segmentGroup = null; + return slot; } - return slot; + return this.slotTable.peek(slotKey); } /** @@ -288,7 +296,9 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { * @ignore */ resetSegment() { - this.segmentKeys.clear(); + // the table clears each live slot's entry through `onEvict`; truncating + // afterwards drops any stale tail a shrunk capacity left behind + this.slotTable.reset(); this.segmentEntries.length = 0; this.segmentGroup = null; } @@ -465,7 +475,7 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { * @ignore */ hasPendingMaterial() { - return this.segmentEntries.length > 0; + return this.slotTable.size > 0; } /** diff --git a/packages/melonjs/tests/textureslots.spec.js b/packages/melonjs/tests/textureslots.spec.js new file mode 100644 index 000000000..da69e0cf8 --- /dev/null +++ b/packages/melonjs/tests/textureslots.spec.js @@ -0,0 +1,216 @@ +/** + * `TextureSlotTable` — the shared texture-slot assignment policy (#1585). + * + * Both GPU backends give each texture a small integer slot that the fragment + * shader selects on per vertex; only the resource differs (a GL texture unit + * vs an entry in a WebGPU material bind group). The policy was written twice + * and drifted, so it now lives here — and this suite is the oracle that says + * the two backends behave alike. It is deliberately GPU-free: the table only + * ever deals in string keys and slot integers. + */ +import { describe, expect, it, vi } from "vitest"; +import { TextureSlotTable } from "../src/video/gpu/textureslots.js"; + +const makeTable = (capacity, extra = {}) => { + return new TextureSlotTable({ capacity, ...extra }); +}; + +describe("TextureSlotTable", () => { + describe("assignment", () => { + it("hands out slots from zero, in order", () => { + const table = makeTable(4); + expect(table.slotFor("a")).toBe(0); + expect(table.slotFor("b")).toBe(1); + expect(table.slotFor("c")).toBe(2); + }); + + it("is stable — the same key keeps its slot", () => { + const table = makeTable(4); + const first = table.slotFor("a"); + table.slotFor("b"); + expect(table.slotFor("a")).toBe(first); + // and re-resolving does not consume capacity + expect(table.size).toBe(2); + }); + + it("peek() never assigns", () => { + const table = makeTable(4); + expect(table.peek("a")).toBeUndefined(); + expect(table.size).toBe(0); + table.slotFor("a"); + expect(table.peek("a")).toBe(0); + }); + + it("reuses a released slot rather than growing", () => { + const table = makeTable(4); + table.slotFor("a"); + table.slotFor("b"); + expect(table.release("a")).toBe(true); + // the lowest free slot is 0 again + expect(table.slotFor("c")).toBe(0); + expect(table.release("nope")).toBe(false); + }); + }); + + describe("overflow", () => { + it("fills to capacity without overflowing", () => { + const onOverflow = vi.fn(); + const table = makeTable(4, { onOverflow }); + for (const key of ["a", "b", "c", "d"]) { + table.slotFor(key); + } + // the LAST slot must not trigger a flush — an off-by-one here + // costs a draw call per full batch + expect(onOverflow).not.toHaveBeenCalled(); + }); + + it("overflows exactly once, on the key that does not fit", () => { + const onOverflow = vi.fn(); + const table = makeTable(4, { onOverflow }); + for (const key of ["a", "b", "c", "d"]) { + table.slotFor(key); + } + expect(table.slotFor("e")).toBe(0); + expect(onOverflow).toHaveBeenCalledTimes(1); + // the overflowing key is the sole survivor + expect(table.size).toBe(1); + expect(table.peek("a")).toBeUndefined(); + }); + + it("flushes BEFORE reassigning — pending work must not be restamped", () => { + // the ordering that makes overflow correct: if the table reassigned + // first, already-queued vertices would sample the new texture + const seen = []; + const table = makeTable(2, { + onOverflow: () => { + seen.push(["flush", table.peek("a")]); + }, + onEvict: (slot) => { + seen.push(["evict", slot]); + }, + }); + table.slotFor("a"); + table.slotFor("b"); + table.slotFor("c"); + + expect(seen[0]).toEqual(["flush", 0]); // "a" still resident here + expect( + seen.slice(1).map((e) => { + return e[0]; + }), + ).toEqual(["evict", "evict"]); + }); + + it("does not overflow for a key that is already resident", () => { + const onOverflow = vi.fn(); + const table = makeTable(2, { onOverflow }); + table.slotFor("a"); + table.slotFor("b"); + table.slotFor("a"); + table.slotFor("b"); + expect(onOverflow).not.toHaveBeenCalled(); + }); + + it("round-robin over capacity+1 overflows every cycle", () => { + // the pathological case #1584 exists to remove — pinned here so the + // cost is visible if the policy is ever changed (see #1586) + const onOverflow = vi.fn(); + const table = makeTable(4, { onOverflow }); + for (let i = 0; i < 15; i++) { + table.slotFor(`t${i % 5}`); + } + expect(onOverflow.mock.calls.length).toBeGreaterThan(1); + }); + }); + + describe("eviction callbacks", () => { + it("reports each live slot exactly once on reset", () => { + const onEvict = vi.fn(); + const table = makeTable(4, { onEvict }); + table.slotFor("a"); + table.slotFor("b"); + table.reset(); + expect(onEvict.mock.calls.flat().sort()).toEqual([0, 1]); + expect(table.size).toBe(0); + }); + + it("does not report slots that were never assigned", () => { + const onEvict = vi.fn(); + const table = makeTable(8, { onEvict }); + table.slotFor("a"); + table.reset(); + expect(onEvict).toHaveBeenCalledTimes(1); + }); + }); + + describe("reservations", () => { + it("never assigns a reserved slot", () => { + // WebGL parks ShaderEffect extra samplers on high units this way + const reserved = new Set([1, 3]); + const table = makeTable(4, { + isReserved: (s) => { + return reserved.has(s); + }, + }); + expect(table.slotFor("a")).toBe(0); + expect(table.slotFor("b")).toBe(2); + }); + + it("keeps reservations across an overflow", () => { + const reserved = new Set([3]); + const onOverflow = vi.fn(); + const table = makeTable(4, { + onOverflow, + isReserved: (s) => { + return reserved.has(s); + }, + }); + table.slotFor("a"); + table.slotFor("b"); + table.slotFor("c"); + // slot 3 is held out, so the fourth distinct key overflows + expect(table.slotFor("d")).toBe(0); + expect(onOverflow).toHaveBeenCalledTimes(1); + expect(table.peek("d")).not.toBe(3); + }); + + it("throws rather than aliasing when nothing is assignable", () => { + // returning 0 here would silently sample a reserved texture + const table = makeTable(2, { + isReserved: () => { + return true; + }, + }); + expect(() => { + return table.slotFor("a"); + }).toThrow(/no assignable slot/); + expect(() => { + return makeTable(0).slotFor("a"); + }).toThrow(/no assignable slot/); + }); + }); + + describe("setCapacity", () => { + it("evicts only assignments above the new capacity", () => { + const onEvict = vi.fn(); + const table = makeTable(4, { onEvict }); + for (const key of ["a", "b", "c", "d"]) { + table.slotFor(key); + } + table.setCapacity(2); + expect(table.peek("a")).toBe(0); + expect(table.peek("b")).toBe(1); + expect(table.peek("c")).toBeUndefined(); + expect(onEvict.mock.calls.flat().sort()).toEqual([2, 3]); + }); + + it("growing keeps every assignment and opens the new slots", () => { + const table = makeTable(2); + table.slotFor("a"); + table.slotFor("b"); + table.setCapacity(4); + expect(table.peek("a")).toBe(0); + expect(table.slotFor("c")).toBe(2); + }); + }); +}); diff --git a/packages/melonjs/tests/webgpu_quad_batcher.spec.js b/packages/melonjs/tests/webgpu_quad_batcher.spec.js index 95c73e932..215a25775 100644 --- a/packages/melonjs/tests/webgpu_quad_batcher.spec.js +++ b/packages/melonjs/tests/webgpu_quad_batcher.spec.js @@ -120,6 +120,51 @@ describe("WebGPUQuadBatcher", () => { ); }); + // #1585 moved slot assignment onto the shared `TextureSlotTable` and made + // `segmentEntries` indexed by slot rather than pushed. Two invariants that + // used to be structural now rest on the table handing out slots lowest-first, + // so they need pinning. + it("the composed material group is dense: views 0-7, samplers 8-15", () => { + // `composeSegmentGroup` pads unclaimed slots from `entries[0]`, so a hole + // at slot 0 would make the padding source undefined and throw. Every + // declared binding must be present whatever the segment holds. + batcher.addQuad(atlasA, 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + batcher.addQuad(atlasB, 8, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + batcher.flush(); + + const group = renderer.calls.materialBinds[0]; + // entries are emitted interleaved (texture then sampler per slot), which + // WebGPU permits — what matters is that all 16 declared bindings are + // present and resourced, not the order they appear in + const bindings = group.entries.map((e) => { + return e.binding; + }); + expect( + [...bindings].sort((a, b) => { + return a - b; + }), + ).toEqual( + Array.from({ length: 16 }, (_, i) => { + return i; + }), + ); + for (const e of group.entries) { + expect(e.resource).toBeDefined(); + } + }); + + it("hasPendingMaterial tracks the slot table, not the entry array", () => { + // the predicate #1585 rewrote from `segmentEntries.length > 0`. It gates + // whether a flush draws at all, so a stale `true` records a draw with no + // material and a stale `false` silently drops queued quads. + expect(batcher.hasPendingMaterial()).toBe(false); + batcher.addQuad(atlasA, 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff); + expect(batcher.hasPendingMaterial()).toBe(true); + batcher.flush(); + // resetSegment must clear it, or the next empty flush draws garbage + expect(batcher.hasPendingMaterial()).toBe(false); + }); + it("a flush with no material ever adopted records nothing", () => { expect(() => { batcher.flush(); From ace3aed9f87ab4e71f0ab63827eac8107639e1fd Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 16 Aug 2026 08:19:19 +0800 Subject: [PATCH 02/16] fix(webgl): lit sprites no longer cost half the texture pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LitQuadBatcher` halved its texture budget and then permanently reserved the upper half for normal maps, on first bind, for the rest of the session. Any scene drawing one lit sprite gave up half its units — whatever the device reported — and the reserved range collided with the top units `ShaderEffect` and `toFrameTexture` claim, so an effect that called `setTexture` before lighting first activated aliased onto a normal-map slot and corrupted lit sampling with no error. The cause was the fragment shader: it declared `uSampler0..n-1` AND `uNormalSampler0..n-1`, so 2n samplers had to fit the device, and the pairing was positional — the normal for colour slot `i` lived at unit `n + i`. But `aNormalTextureId` is already a distinct per-quad attribute, so the positional pairing was never necessary. The shader now addresses ONE sampler set with two independent ids, and a normal map takes a slot from the shared pool like any other texture. The halving and the reservation are gone, and the split is dynamic: sprites sharing a normal map cost one slot between them rather than every scene paying half its budget upfront. On a 16-unit device a lit scene goes from an effective pool of 8 to 16. `addQuad` re-resolves the colour unit when claiming the normal's slot exhausts the pool, and falls back to the unlit path rather than let the two ids collide when reservations leave a single assignable slot — the alternative is sampling the sprite's own albedo as a normal map, silently. Verified by pixel readback: a flat normal under an overhead light reads green 141, and driving the normal ladder from the colour id — the collision — reads 81. The existing lit assertions are all `> 40` and cannot tell those apart. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- .../video/webgl/batchers/lit_quad_batcher.js | 251 +++++++++++------- .../video/webgl/shaders/multitexture-lit.js | 12 +- .../tests/lighting_block_wiring.spec.js | 97 +++++++ packages/melonjs/tests/toframetexture.spec.js | 40 ++- 4 files changed, 287 insertions(+), 113 deletions(-) diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index e4282feed..fedd85d6d 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -20,12 +20,12 @@ import QuadBatcher from "./quad_batcher.js"; * Lit-aware variant of `QuadBatcher` for the SpriteIlluminator workflow. * * Adds a 5th vertex attribute (`aNormalTextureId`) so each quad knows - * which paired normal-map sampler to read, and owns the per-frame + * which slot its normal map occupies, and owns the per-frame * `Light2dBlock` uniform buffer that the lit fragment shader iterates. * - * Texture-slot capacity is halved relative to `QuadBatcher` because each - * sprite may need a paired (color, normal) sampler — color goes to unit - * `n`, normal to unit `maxBatchTextures + n`. The `WebGLRenderer` only + * Colors and normal maps share ONE slot pool the same size as `QuadBatcher`'s + * (#1585) — a normal map is allocated a unit like any other texture, and + * `aNormalTextureId` records which one. The `WebGLRenderer` only * dispatches sprites here when the scene actually needs lighting (active * `Light2d` AND the sprite has a `normalMap`); unlit sprites stay on * `QuadBatcher` and pay nothing. @@ -36,14 +36,17 @@ export default class LitQuadBatcher extends QuadBatcher { * @ignore */ init(renderer) { - // halve the texture cap: each color slot is paired with a normal - // slot at offset `+ maxBatchTextures` so the historical 8-unit budget - // still affords at least 4 lit sprites per batch. - const halved = Math.min( - Math.max(1, Math.floor(renderer.maxTextures / 2)), - 16, - ); - this.maxBatchTextures = halved; + // One shared pool, same size as the unlit batcher's (#1585). This used + // to be HALVED, because the fragment shader declared a second + // `uNormalSampler0..n-1` set and 2n samplers had to fit the device — and + // the upper half was then reserved permanently, so a lit scene could + // never allocate more than half the units the device reported. The + // shader now addresses ONE sampler set with two per-quad ids, so a + // normal map is just another texture competing for the same slots. The + // split became dynamic: sprites sharing a normal map cost one slot + // between them, rather than every scene paying half its budget upfront. + const pool = renderer.maxTextures; + this.maxBatchTextures = pool; // Skip QuadBatcher.init (its attribute layout / shader differ) and // invoke MaterialBatcher.init directly with the lit configuration. @@ -79,14 +82,13 @@ export default class LitQuadBatcher extends QuadBatcher { ], shader: { vertex: quadMultiLitVertex, - fragment: buildLitMultiTextureFragment(halved), + fragment: buildLitMultiTextureFragment(pool), }, }); // Reuse the parent's setup helpers — they're agnostic to the // shader/attribute layout, just iterate `this.maxBatchTextures`. this.bindColorSamplers(); - this.bindNormalSamplers(); this.createIndexBuffer(); this.useMultiTexture = true; @@ -98,7 +100,7 @@ export default class LitQuadBatcher extends QuadBatcher { * @type {Array} * @ignore */ - this.boundNormalMaps = new Array(halved).fill(null); + this.boundNormalMaps = new Array(pool).fill(null); /** * Per-slot content `version` of the normal map currently bound there. An @@ -108,7 +110,7 @@ export default class LitQuadBatcher extends QuadBatcher { * @type {number[]} * @ignore */ - this.boundNormalVersions = new Array(halved).fill(-1); + this.boundNormalVersions = new Array(pool).fill(-1); /** * Map from a normal-map source image to its uploaded GL texture and the @@ -121,6 +123,17 @@ export default class LitQuadBatcher extends QuadBatcher { */ this.normalMapTextures = new Map(); + /** + * Which slot in the shared pool each normal-map source currently holds. + * Normal maps are not in the color `TextureCache` (they are raw sources + * with their own GL textures), so their unit assignment is tracked here + * while the unit itself comes from the same allocator the colors use. + * @type {Map} + * @ignore + */ + this.normalUnits = new Map(); + this._cacheEpoch = 0; + this._lightCount = 0; this._maxLights = MAX_LIGHTS; @@ -180,39 +193,18 @@ export default class LitQuadBatcher extends QuadBatcher { } /** - * Activating the lit batcher claims the paired normal-map unit range - * `[maxBatchTextures, 2*maxBatchTextures)` for good: the pairing is baked - * into the lit shader's sampler bindings, while the renderer-wide unit - * allocator would otherwise happily assign those same units to color - * textures (an unlit sprite's, a mesh's, a pattern's) — each batcher - * tracks its bindings per-instance, so whichever bound second silently - * clobbered the other's texture. Reserving through the cache keeps the - * allocator away; done lazily on first bind so unlit games keep their - * full unit pool, and left reserved thereafter (a lit game stays lit). + * No unit reservation any more (#1585). This used to claim + * `[maxBatchTextures, 2*maxBatchTextures)` permanently on first bind, + * because the lit shader's normal samplers were bound to those fixed + * units — which took half the pool away from every allocator for the rest + * of the session, and collided with the top units `ShaderEffect` and + * `toFrameTexture` claim. Normal maps now go through the shared allocator + * like everything else, so there is nothing to hold back. * @ignore */ bind() { super.bind(); this._bindLightBlock(); - if (this._normalRangeReserved !== true) { - this._normalRangeReserved = true; - const cache = this.renderer.cache; - for (let i = 0; i < this.maxBatchTextures; i++) { - const unit = this.maxBatchTextures + i; - if (cache.reservedUnits.has(unit)) { - // a ShaderEffect extra sampler claimed a unit in our fixed - // range before lighting first activated — its texture and - // the paired normal map for color slot `i` now collide - console.warn( - `LitQuadBatcher: texture unit ${unit} is already reserved (ShaderEffect.setTexture?) and overlaps the paired normal-map range — expect sampling conflicts`, - ); - } - cache.reserveUnit(unit); - } - // evict any color texture the allocator parked in the normal range - // before lighting first activated (units are sticky once assigned) - cache.resetUnitAssignments(); - } } /** @@ -250,6 +242,7 @@ export default class LitQuadBatcher extends QuadBatcher { if (typeof cached !== "undefined") { this.gl.deleteTexture(cached.tex); this.normalMapTextures.delete(image); + this.releaseNormalUnit(image); for (let i = 0; i < this.boundNormalMaps.length; i++) { if (this.boundNormalMaps[i] === image) { this.boundNormalMaps[i] = null; @@ -259,21 +252,6 @@ export default class LitQuadBatcher extends QuadBatcher { } } - /** - * Bind the paired normal sampler uniforms (`uNormalSampler0..N-1`) - * to texture units `maxBatchTextures..2*maxBatchTextures-1`. Called - * from `init` and `reset`. - * @ignore - */ - bindNormalSamplers() { - for (let i = 0; i < this.maxBatchTextures; i++) { - this.defaultShader.setUniform( - "uNormalSampler" + i, - this.maxBatchTextures + i, - ); - } - } - /** * @ignore */ @@ -286,7 +264,6 @@ export default class LitQuadBatcher extends QuadBatcher { // already disposed by the time we get here. We just need to // drop the JS references and re-bind the per-frame uniforms. super.reset(); - this.bindNormalSamplers(); this.boundNormalMaps.fill(null); this.boundNormalVersions.fill(-1); this.normalMapTextures.clear(); @@ -299,24 +276,28 @@ export default class LitQuadBatcher extends QuadBatcher { } /** - * Also drop the normal-map pairing when its paired unit is invalidated. - * Normal maps live at units `maxBatchTextures..2*maxBatchTextures-1` - * (indexed by the paired albedo unit), which overlap the top units that - * {@link WebGLRenderer#toFrameTexture} (its scratch unit) and - * {@link ShaderEffect#_prepareTextures} (its reserved extra samplers, - * counting DOWN from the top) bind directly. When one of those GL units is - * clobbered we must forget the pairing, or the next lit draw would assume - * the normal is still resident and skip re-binding it, sampling the - * clobbering texture as a normal map. + * Also drop the normal-map binding when its unit is invalidated. A normal + * map occupies a unit in the shared pool, so anything that binds a GL unit + * directly — {@link WebGLRenderer#toFrameTexture}'s scratch unit, + * {@link ShaderEffect#_prepareTextures}'s extra samplers — can clobber one. + * Forget it here, or the next lit draw assumes the normal is still resident, + * skips re-binding, and samples the clobbering texture as a normal map. * @param {number} unit - the GL texture unit to invalidate * @ignore */ invalidateUnit(unit) { super.invalidateUnit(unit); - const n = unit - this.maxBatchTextures; - if (n >= 0 && n < this.maxBatchTextures) { - this.boundNormalMaps[n] = null; - this.boundNormalVersions[n] = -1; + const stale = this.boundNormalMaps?.[unit]; + if (stale != null) { + // unit-driven, not source-driven: whatever clobbered this GL unit + // bound directly, so drop our belief about it. The allocator claim + // is deliberately NOT released — the clobberer (a toFrameTexture + // scratch bind, a ShaderEffect sampler) is squatting there outside + // the allocator's accounting, and handing the unit to a colour + // texture now would put two textures on it. + this.normalUnits?.delete(stale); + this.boundNormalMaps[unit] = null; + this.boundNormalVersions[unit] = -1; } } @@ -333,6 +314,12 @@ export default class LitQuadBatcher extends QuadBatcher { super._onTextureCacheReset(); this.boundNormalMaps?.fill(null); this.boundNormalVersions?.fill(-1); + this.normalUnits?.clear(); + // `addQuad` resolves a color unit and a normal unit in sequence; the + // second allocation can exhaust the pool and wipe the first. Bumping a + // counter here is how it notices and re-resolves, rather than stamping + // a stale unit into the vertex stream. + this._cacheEpoch = (this._cacheEpoch ?? 0) + 1; } /** @@ -371,7 +358,7 @@ export default class LitQuadBatcher extends QuadBatcher { * but for normal-map textures which live outside the color * `TextureCache` (cached per-image in `normalMapTextures`). * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image - normal-map source - * @param {number} unit - GL texture unit (already offset by `maxBatchTextures`) + * @param {number} unit - GL texture unit the normal map is resolved to */ bindNormalMap(image, unit) { const cached = this.normalMapTextures.get(image); @@ -399,7 +386,7 @@ export default class LitQuadBatcher extends QuadBatcher { * surface normals; multiplying through alpha would corrupt the * encoding for any non-opaque texel. * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image - normal-map source - * @param {number} unit - GL texture unit (already offset by `maxBatchTextures`) + * @param {number} unit - GL texture unit the normal map is resolved to * @param {number} [version=0] - the source revision being uploaded */ uploadNormalMap(image, unit, version = 0) { @@ -426,6 +413,79 @@ export default class LitQuadBatcher extends QuadBatcher { }); } + /** + * Drop a normal map's slot claim and hand the unit back to the allocator. + * + * A normal map claims its unit through `allocateTextureUnit()`, which has no + * key to release against — so without this the unit stayed marked used for + * the rest of the session and the pool drained monotonically as normal maps + * came and went, until something forced a full reset. + * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image - normal-map source + * @ignore + */ + releaseNormalUnit(image) { + const unit = this.normalUnits?.get(image); + if (unit === undefined) { + return; + } + this.normalUnits.delete(image); + this.boundNormalMaps[unit] = null; + this.boundNormalVersions[unit] = -1; + // the slot was claimed keylessly, so occupancy is the only record of it + this.renderer.cache.usedUnits.delete(unit); + } + + /** + * Resolve a normal-map source to its slot in the shared pool, uploading and + * binding it if it is not already resident. + * + * A version-only bump (an animated source re-baking into the same canvas + * reference) re-uploads into the same GL handle and needs no flush — + * `version` only changes between frames, and the batch is flushed at every + * frame/camera boundary, so no in-flight vertex references stale content. + * A DIFFERENT source landing on a live slot does need the pending vertices + * drawn first. + * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} normalMap - the source + * @returns {number} the slot to write into `aNormalTextureId` + * @ignore + */ + resolveNormalUnit(normalMap) { + const version = normalMap.version ?? 0; + const held = this.normalUnits.get(normalMap); + + if (held !== undefined && this.boundNormalMaps[held] === normalMap) { + if (this.boundNormalVersions[held] !== version) { + this.bindNormalMap(normalMap, held); + this.boundNormalVersions[held] = version; + } + return held; + } + + // may flush and wipe every assignment when the pool is exhausted — + // `addQuad` re-checks `_cacheEpoch` for exactly that reason + // A freshly claimed slot is always vacant: `freeSlot()` only returns + // units absent from occupancy, and a normal map's claim is held until it + // is explicitly released or the pool is wiped (which clears these arrays + // too). So there is never a live normal map to displace here. + const unit = this.renderer.cache.allocateTextureUnit(); + this.bindNormalMap(normalMap, unit); + // Colors and normal maps share one pool since #1585, so a unit this + // batcher binds a normal map onto may be one ANOTHER batcher still + // believes holds its color texture — it would then skip re-binding and + // sample the normal map. Under the old fixed reservation that was + // impossible; now it has to be announced. `except` is `this`, whose own + // bookkeeping `bindNormalMap` just updated correctly. + this.renderer.invalidateTextureUnit(unit, this); + // `boundTextures[unit]` deliberately keeps pointing at the normal map's + // GL texture: `uploadNormalMap` reads it back to cache the handle, and + // `MaterialBatcher.reset()` walks that array to DELETE every texture it + // owns. Clearing it here leaked one GL texture per normal map per reset. + this.normalUnits.set(normalMap, unit); + this.boundNormalMaps[unit] = normalMap; + this.boundNormalVersions[unit] = version; + return unit; + } + /** * Add a textured quad with optional paired normal map. * @param {TextureAtlas} texture - Source texture atlas @@ -465,6 +525,10 @@ export default class LitQuadBatcher extends QuadBatcher { if (this.useMultiTexture) { unit = this.uploadTexture(texture, w, h, reupload, false); + // Desync guard, not the normal path: the cache and this batcher are + // both sized from `renderer.maxTextures`, so the allocator cannot + // return an out-of-range unit unless something reassigns one of them + // at runtime. Cheap enough to keep as a net. if (unit >= this.maxBatchTextures) { this.flush(); this.renderer.cache.resetUnitAssignments(); @@ -497,27 +561,24 @@ export default class LitQuadBatcher extends QuadBatcher { let normalTextureId = -1; if (normalMap !== null && this.useMultiTexture) { - const normalUnit = this.maxBatchTextures + unit; - const prev = this.boundNormalMaps[unit]; - const version = normalMap.version ?? 0; - // Re-bind when the source changed OR an animated source bumped its - // content `version`. A reference-only check would freeze animated - // textures, whose canvas reference is stable across re-bakes. - if (prev !== normalMap || this.boundNormalVersions[unit] !== version) { - // Only a DIFFERENT source needs the pending vertices flushed before - // rebinding over its slot. A version-only bump (same reference, an - // animated re-bake) re-uploads into the same GL handle WITHOUT a - // flush — `version` only changes between frames (via `update()`) and - // the batch is flushed at each frame/camera boundary, so no in-flight - // vertices ever reference stale content. - if (prev !== null && prev !== normalMap) { - this.flush(); - } - this.bindNormalMap(normalMap, normalUnit); - this.boundNormalMaps[unit] = normalMap; - this.boundNormalVersions[unit] = version; + const epoch = this._cacheEpoch; + normalTextureId = this.resolveNormalUnit(normalMap); + if (this._cacheEpoch !== epoch) { + // Claiming the normal's slot exhausted the pool and wiped every + // assignment, so the color unit resolved above is stale. Only the + // color: the normal claimed AFTER the wipe, so its slot is live. + // Re-resolving cannot wipe again either — the normal is the sole + // occupant and the pool never resolves below two slots. + unit = this.uploadTexture(texture, w, h, reupload, false); + } + if (normalTextureId === unit) { + // Reachable when reservations leave only ONE assignable slot: the + // normal claims it, the color's re-resolve wipes and claims the + // same one. Sampling the sprite's own albedo as its normal map + // wrecks the lighting silently, so take the unlit path instead — + // flat shading is wrong, but visibly and recoverably so. + normalTextureId = -1; } - normalTextureId = unit; } // Stamp per-sprite depth onto z BEFORE the transform — see diff --git a/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js b/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js index 2aa97f267..b5b9771cc 100644 --- a/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js +++ b/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js @@ -58,12 +58,16 @@ export function buildLitMultiTextureFragment(maxTextures) { // utils/precision.js, so it is deliberately not written here const lines = ["#version 300 es"]; + // ONE sampler set, addressed by two independent per-quad ids (#1585). + // A separate `uNormalSampler0..n-1` set doubles the declaration count, and + // needing 2n samplers is what forced this batcher to halve its unit budget + // and permanently reserve the upper half for normal maps — so a lit scene + // could never use more than half the units the device reported, however + // many that was. A normal map is now just another texture in the shared + // pool, and `vNormalTextureId` says which slot it landed in. for (let i = 0; i < count; i++) { lines.push("uniform sampler2D uSampler" + i + ";"); } - for (let i = 0; i < count; i++) { - lines.push("uniform sampler2D uNormalSampler" + i + ";"); - } // Light data arrives in a std140 uniform block rather than uniform // arrays. Arrays are charged against MAX_FRAGMENT_UNIFORM_VECTORS, a @@ -108,7 +112,7 @@ export function buildLitMultiTextureFragment(maxTextures) { lines.push( ...buildSamplerSelect( "vNormalTextureId", - "uNormalSampler", + "uSampler", count, "normalSample", ), diff --git a/packages/melonjs/tests/lighting_block_wiring.spec.js b/packages/melonjs/tests/lighting_block_wiring.spec.js index 98e5a023e..237dacb85 100644 --- a/packages/melonjs/tests/lighting_block_wiring.spec.js +++ b/packages/melonjs/tests/lighting_block_wiring.spec.js @@ -346,6 +346,103 @@ describe("light cap above 8 (issue #1552)", () => { expect(px[0]).toBeLessThan(20); // and only green }); + // #1585 collapsed the lit shader from TWO sampler sets (uSampler* plus + // uNormalSampler*) to ONE addressed by two per-quad ids. A colour/normal slot + // collision used to be structurally impossible; now it is prevented only by + // an invariant in `resolveNormalUnit`, so it needs a test that can SEE one. + // + // The scaffold's normal map is flat +Z under a light directly overhead, so + // NdotL is 1 and the centre pixel reads green 141. Driving the normal ladder + // from the COLOUR id instead — the collision — makes the normal decode from + // the WHITE albedo, normalize(1,1,1), NdotL 0.577, and the pixel reads 81. + // Both numbers are measured, not derived. Every other lit assertion in this + // file is `> 40` and cannot tell them apart. + it("the colour id and the normal id select DIFFERENT samplers", (ctx) => { + requireWebGL(ctx, renderer); + const px = drawLitBy(0, 1); + expect(px[1]).toBeGreaterThan(120); + }); + + // The `_cacheEpoch` re-resolution path: claiming the normal map's slot can + // exhaust the pool and wipe every assignment — including the colour unit + // resolved moments earlier AND the normal's own, since the wipe frees + // occupancy while the texture stays bound in GL. Both must be re-resolved or + // the vertex stream carries a stale unit. + it("exhausting the pool on the normal map re-resolves both ids", (ctx) => { + requireWebGL(ctx, renderer); + const lit = renderer.batchers.get("litQuad"); + const cache = renderer.cache; + const original = cache.max_size; + const albedo = solid("#ffffff"); + + cache.resetUnitAssignments(); + // exactly two usable units: one albedo + one normal fills the pool, so a + // second distinct normal map cannot fit and must force the wipe + cache.max_size = 2; + const before = lit._cacheEpoch; + + renderer.clearColor("#000000", true); + renderer.setLightUniforms(undefined, undefined, 0, 0); + lit.setLightUniforms({ + count: 1, + positions: new Float32Array([SIZE / 2, SIZE / 2, SIZE * 4, 1]), + colors: new Float32Array([0, 1, 0]), + heights: new Float32Array([SIZE]), + ambient: [0, 0, 0], + }); + renderer.activeLightCount = 1; + + renderer.currentNormalMap = solid("rgb(128,128,255)"); + renderer.drawImage(albedo, 0, 0, 16, 16, 0, 0, SIZE, SIZE); + // same albedo (a cache hit, allocating nothing), a DIFFERENT normal — + // that claim is what runs the pool dry mid-quad + renderer.currentNormalMap = solid("rgb(129,128,255)"); + renderer.drawImage(albedo, 0, 0, 16, 16, 0, 0, SIZE, SIZE); + renderer.flush(); + renderer.currentNormalMap = null; + renderer.activeLightCount = 0; + + // the wipe really happened, or this test proves nothing + expect(lit._cacheEpoch).toBeGreaterThan(before); + + const px = new Uint8Array(4); + gl.readPixels(SIZE / 2, SIZE / 2, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + // a stale or collided id would sample the white albedo as a normal (81) + expect(px[1]).toBeGreaterThan(120); + + cache.max_size = original; + cache.resetUnitAssignments(); + }); + + // The collision guard. Reservations can leave a single assignable slot, and + // then the normal claims it and the color's re-resolve wipes and takes the + // same one. Rather than sample the sprite's own albedo as a normal map — 81 + // instead of 141, wrong lighting with no error — the quad takes the unlit + // path, which the shader signals with `vNormalTextureId < -0.5`. + // + // Unlit is `albedo * vertexColor`, so the WHITE albedo comes back white: + // red 255. Both lit readings have red 0 (the light is pure green), which is + // what makes the red channel an unambiguous discriminator here. + it("falls back to unlit rather than let the two ids collide", (ctx) => { + requireWebGL(ctx, renderer); + const cache = renderer.cache; + const original = cache.max_size; + try { + cache.resetUnitAssignments(); + cache.max_size = 2; + cache.reserveUnit(1); // exactly one assignable slot left + const px = drawLitBy(0, 1); + expect(px[0]).toBeGreaterThan(200); // unlit: albedo passed through + expect(px[1]).not.toBe(81); // and NOT the collided lit reading + } finally { + // restore in `finally`, or a failure here silently shrinks the pool + // for every test that follows + cache.releaseUnit(1); + cache.max_size = original; + cache.resetUnitAssignments(); + } + }); + it("shades from a light at index 20 — past the old cap of 8", (ctx) => { requireWebGL(ctx, renderer); // under the previous `uniform vec4 uLightPos[8]` transport this slot diff --git a/packages/melonjs/tests/toframetexture.spec.js b/packages/melonjs/tests/toframetexture.spec.js index 1697563a5..ffb41555f 100644 --- a/packages/melonjs/tests/toframetexture.spec.js +++ b/packages/melonjs/tests/toframetexture.spec.js @@ -406,9 +406,12 @@ describe("WebGLRenderer.toFrameTexture", () => { expect(gl.isTexture(frame.glTexture)).toBe(true); }); - // adversarial: invalidateUnit must ONLY touch the normal slot for GL units in - // the normal range (top half), never for a low colour/albedo unit - it("invalidateUnit only drops the normal pairing for units in the normal range", (ctx) => { + // adversarial: invalidateUnit must drop the normal map bound to THAT unit and + // no other. Since #1585 there is no positional `+ maxBatchTextures` pairing — + // a normal map holds a slot in the shared pool like any other texture, so the + // unit index is the key, and over-clearing would silently re-upload every + // frame while under-clearing samples a clobbered texture as a normal. + it("invalidateUnit drops the normal map on that unit only", (ctx) => { if (!isWebGL) { ctx.skip(); return; @@ -418,17 +421,25 @@ describe("WebGLRenderer.toFrameTexture", () => { ctx.skip(); return; } - // a LOW unit (0) is a colour/albedo unit — its clobber must NOT drop the - // normal at index 0 (which lives at GL unit maxBatchTextures + 0) + const other = { keep: true }; lit.boundTextures[0] = { fake: true }; - lit.boundNormalMaps[0] = { keep: true }; + lit.boundNormalMaps[0] = { fake: "normal" }; + lit.boundNormalVersions[0] = 4; + lit.boundNormalMaps[3] = other; + lit.normalUnits.set(other, 3); + lit.invalidateUnit(0); expect(lit.boundTextures[0]).toBeUndefined(); // colour cleared - expect(lit.boundNormalMaps[0]).toEqual({ keep: true }); // normal untouched - - // the GL unit that DOES pair to normal index 0 must drop it - lit.invalidateUnit(lit.maxBatchTextures + 0); - expect(lit.boundNormalMaps[0]).toBe(null); + expect(lit.boundNormalMaps[0]).toBe(null); // normal on THIS unit dropped + expect(lit.boundNormalVersions[0]).toBe(-1); + // a normal parked on a different unit is untouched + expect(lit.boundNormalMaps[3]).toBe(other); + expect(lit.normalUnits.get(other)).toBe(3); + + // and the source→unit map must not keep a dangling entry for the dropped + // slot, or resolveNormalUnit would trust a binding that no longer exists + lit.invalidateUnit(3); + expect(lit.normalUnits.has(other)).toBe(false); }); // the latent gap this surfaced: a full texture-cache reset (unit-pool wrap) @@ -467,13 +478,14 @@ describe("WebGLRenderer.toFrameTexture", () => { ctx.skip(); return; } - const glUnit = lit.maxBatchTextures + 1; // a real lit normal-map GL unit - lit.boundNormalMaps[1] = { fake: "normal" }; + // any unit can hold a normal map since #1585 — pick one inside the pool + const glUnit = 1; + lit.boundNormalMaps[glUnit] = { fake: "normal" }; quad.boundTextures[glUnit] = { fake: "color" }; // exclude the quad batcher → its binding is kept, the lit one is dropped renderer.invalidateTextureUnit(glUnit, quad); - expect(lit.boundNormalMaps[1]).toBe(null); // lit invalidated + expect(lit.boundNormalMaps[glUnit]).toBe(null); // lit invalidated expect(quad.boundTextures[glUnit]).toEqual({ fake: "color" }); // quad kept }); From fd40d7dd64a253de542f7c281729ca9bc5757a2e Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 16 Aug 2026 08:19:43 +0800 Subject: [PATCH 03/16] feat(webgl): batch pool follows the device, and a maxTextures setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-texture pool was capped at `Math.min(device, 16)`. 16 is the WebGL 2 / GLES 3.0 spec FLOOR for MAX_TEXTURE_IMAGE_UNITS — chosen because it works everywhere — and plenty of hardware reports more. The fragment shader is already generated for the count, so the cap was policy, not structure. `resolveMaxTextures` clamps once, at renderer construction, before the batchers and the TextureCache are built — so their sampler counts and the cache's capacity cannot disagree. `maxTextures: "auto" | number` overrides it: "auto" takes the device limit capped at a conservative 32, a number is clamped to what the device actually has (declaring more sampler2D than units exist fails to LINK, at startup). Init-only — the batchers compile their shaders against it — so unlike textureFilter there is no runtime setter. The floor is 2, not 1: a lit quad holds a colour slot and a normal slot at once, and a one-slot pool makes the two ids collide. ShaderEffect's extra samplers and toFrameTexture's scratch unit now count down from the RENDERER's top unit rather than the active batcher's, so which batcher happens to be bound no longer decides where they live. Measured against 19.9.1 on a 32-unit device, 512 quads/frame round-robin over 32 distinct textures: 32 draw calls and 32 cache evictions per frame become 1 and 0. Read it as a threshold, not a speedup — nothing got faster, the cliff moved from 17 textures to 33. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- .../application/defaultApplicationSettings.ts | 1 + packages/melonjs/src/application/settings.ts | 33 ++++ .../melonjs/src/video/effects/shadereffect.js | 12 +- .../src/video/webgl/batchers/quad_batcher.js | 8 +- .../src/video/webgl/utils/maxtextures.js | 51 ++++++ .../melonjs/src/video/webgl/webgl_renderer.js | 10 +- .../melonjs/tests/maxtextures-cliff.spec.js | 149 ++++++++++++++++++ packages/melonjs/tests/maxtextures.spec.js | 116 ++++++++++++++ .../melonjs/tests/webgl_batcher_state.spec.js | 136 ++++++++++++++-- 9 files changed, 496 insertions(+), 20 deletions(-) create mode 100644 packages/melonjs/src/video/webgl/utils/maxtextures.js create mode 100644 packages/melonjs/tests/maxtextures-cliff.spec.js create mode 100644 packages/melonjs/tests/maxtextures.spec.js diff --git a/packages/melonjs/src/application/defaultApplicationSettings.ts b/packages/melonjs/src/application/defaultApplicationSettings.ts index ca66290a7..6fe7d1c6b 100644 --- a/packages/melonjs/src/application/defaultApplicationSettings.ts +++ b/packages/melonjs/src/application/defaultApplicationSettings.ts @@ -10,6 +10,7 @@ export const defaultApplicationSettings = { transparent: false, antiAlias: false, textureFilter: "auto", + maxTextures: "auto", castGroundShadow: true, consoleHeader: true, blendMode: "normal", diff --git a/packages/melonjs/src/application/settings.ts b/packages/melonjs/src/application/settings.ts index 87e3eb696..9b30b0515 100644 --- a/packages/melonjs/src/application/settings.ts +++ b/packages/melonjs/src/application/settings.ts @@ -154,6 +154,39 @@ export type ApplicationSettings = { */ textureFilter: "auto" | "nearest" | "linear"; + /** + * How many texture units the WebGL multi-texture batchers may use + * ([#1585](https://github.com/melonjs/melonJS/issues/1585)). + * + * A batch can draw sprites from this many distinct textures before it has + * to flush and start over, so a scene with more textures in flight than + * this loses batching sharply. The pool used to be hardcoded to 16 — the + * WebGL 2 spec *floor* for `MAX_TEXTURE_IMAGE_UNITS`, and roughly half + * what current desktop and mobile hardware reports. + * + * - `"auto"` (default) — the device's reported limit, capped at 32. + * - a number — that many units, clamped to what the device actually has + * (asking for more than exists would fail to link the shader). + * + * Raising it costs fragment-shader compile time and register pressure, + * because the batcher's shader unrolls one sampler and one branch per + * unit. Lowering it is the escape hatch if a driver misbehaves on wide + * sampler ladders. + * + * **Read at initialization only** — the batchers compile their shaders + * against this value, so unlike `textureFilter` there is no runtime setter. + * WebGL only; the WebGPU backend sizes its own slot budget from its + * per-stage binding limits, and the Canvas renderer has no batching. + * @default "auto" + * @example + * // cap the pool on a device with a known-bad wide sampler ladder + * const app = new Application(1024, 768, { + * renderer: video.WEBGL, + * maxTextures: 16, + * }); + */ + maxTextures: "auto" | number; + /** * whether 3D objects cast a soft "blob" shadow on the ground by default * ([#1515](https://github.com/melonjs/melonJS/issues/1515)). diff --git a/packages/melonjs/src/video/effects/shadereffect.js b/packages/melonjs/src/video/effects/shadereffect.js index da1a572af..f6b09e48e 100644 --- a/packages/melonjs/src/video/effects/shadereffect.js +++ b/packages/melonjs/src/video/effects/shadereffect.js @@ -666,13 +666,17 @@ export default class ShaderEffect { // rotating color-texture units. Each unit is reserved in the cache the // first time it's claimed, so `allocateTextureUnit` can't hand the same // unit to a sprite's own texture in the single-effect customShader path. - let nextUnit = batcher.maxBatchTextures - 1; + // count down from the RENDERER's top unit rather than the batcher's: + // which batcher happens to be active must not decide where an effect's + // extra samplers live (#1585) + let nextUnit = + (batcher.renderer?.maxTextures ?? batcher.maxBatchTextures) - 1; for (const [name, entry] of this._extraTextures) { if (entry.unit === undefined) { // skip units other holders already reserved — another effect's - // extra samplers, or the lit batcher's paired normal-map range - // (its color-slot pairing is fixed arithmetic, so squatting on - // one of its units would corrupt lit sampling) + // extra samplers, say. The lit batcher no longer reserves a + // fixed normal-map range (#1585), so this is the only claimant + // class left to step over. while (nextUnit >= 1 && cache.reservedUnits.has(nextUnit)) { nextUnit--; } diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index 228b20737..7ead77a74 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -26,7 +26,12 @@ export default class QuadBatcher extends MaterialBatcher { * @type {number} * @ignore */ - this.maxBatchTextures = Math.min(renderer.maxTextures, 16); + // the full pool the renderer resolved (#1585). The 16 that used to cap + // this was the WebGL 2 spec FLOOR for MAX_TEXTURE_IMAGE_UNITS — safe + // everywhere, and half of what current desktop and mobile hardware + // actually reports. `renderer.maxTextures` is already clamped there, + // including by the `maxTextures` application setting. + this.maxBatchTextures = renderer.maxTextures; super.init(renderer, { attributes: [ @@ -284,6 +289,7 @@ export default class QuadBatcher extends MaterialBatcher { unit = this.uploadTexture(texture, w, h, reupload, false); // shader only supports maxBatchTextures samplers — flush and // reset if the cache assigned a unit beyond the shader's range + // Desync guard, not the normal path — see LitQuadBatcher.addQuad. if (unit >= this.maxBatchTextures) { this.flush(); this.renderer.cache.resetUnitAssignments(); diff --git a/packages/melonjs/src/video/webgl/utils/maxtextures.js b/packages/melonjs/src/video/webgl/utils/maxtextures.js new file mode 100644 index 000000000..9458d6bf3 --- /dev/null +++ b/packages/melonjs/src/video/webgl/utils/maxtextures.js @@ -0,0 +1,51 @@ +/** + * Default ceiling applied when `maxTextures` is `"auto"`. + * + * There is no spec maximum to defer to — WebGL 2 / GLES 3.0 only set a + * *minimum* of 16 for `MAX_TEXTURE_IMAGE_UNITS`, and an implementation may + * report any value at or above it. What actually limits us is that the + * multi-texture fragment shader unrolls one sampler and one branch per unit + * (see `buildMultiTextureFragment`), so a very wide pool costs compile time + * and register pressure, and historically some drivers mis-compile the widest + * ladders. 32 covers current desktop and mobile hardware while staying a + * conservative default; raise or lower it with the `maxTextures` setting. + * @ignore + */ +export const AUTO_MAX_TEXTURES = 32; + +/** + * Smallest pool the engine will resolve to. A lit quad occupies a colour slot + * and a normal-map slot simultaneously, so anything narrower makes the two ids + * collide and the sprite samples its own albedo as a normal map. + * @ignore + */ +export const MIN_MAX_TEXTURES = 2; + +/** + * Resolve the texture-unit pool this renderer will use. + * + * Clamping happens ONCE, at renderer construction, before the batchers and the + * `TextureCache` are built — every consumer derives from the result, so the + * batchers' sampler counts and the cache's capacity cannot disagree. A setting + * above what the device reports is clamped down rather than honoured: declaring + * more `sampler2D` uniforms than the device has units fails to link, and a link + * failure at startup is not a useful way to learn about a typo. + * @param {number} deviceMax - the device's `MAX_TEXTURE_IMAGE_UNITS` + * @param {"auto"|number} [setting="auto"] - the `maxTextures` application setting + * @returns {number} the pool size, at least 1 and never above `deviceMax` + * @ignore + */ +export function resolveMaxTextures(deviceMax, setting = "auto") { + // a device that reports nothing usable still has to render something + const device = Number.isFinite(deviceMax) && deviceMax > 0 ? deviceMax : 1; + // MIN_POOL, not 1: a lit quad needs a colour slot and a normal slot at the + // same time, and a one-slot pool makes the two collide — the sprite would + // sample its own albedo as its normal map. Never above what the device has. + const floor = Math.min(MIN_MAX_TEXTURES, device); + if (typeof setting === "number" && Number.isFinite(setting)) { + return Math.max(floor, Math.min(Math.floor(setting), device)); + } + // "auto" (or anything unrecognized — an unset/typo'd value must not brick + // the renderer, matching how `textureFilter` tolerates an unknown mode) + return Math.max(floor, Math.min(device, AUTO_MAX_TEXTURES)); +} diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 3f05431e5..10aa8862c 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -33,6 +33,7 @@ import PrimitiveBatcher from "./batchers/primitive_batcher"; import QuadBatcher from "./batchers/quad_batcher"; import { createLightUniformScratch, packLights } from "./lighting/pack.ts"; import OrthogonalTMXLayerGPURenderer from "./renderers/tmxlayer/orthogonal.js"; +import { resolveMaxTextures } from "./utils/maxtextures.js"; import { getMaxShaderPrecision } from "./utils/precision.js"; /** @@ -146,7 +147,10 @@ export default class WebGLRenderer extends Renderer { * @type {number} * @readonly */ - this.maxTextures = this.gl.getParameter(this.gl.MAX_TEXTURE_IMAGE_UNITS); + this.maxTextures = resolveMaxTextures( + this.gl.getParameter(this.gl.MAX_TEXTURE_IMAGE_UNITS), + this.settings.maxTextures, + ); /** * Next free indexed `UNIFORM_BUFFER` binding point, handed out by * {@link WebGLRenderer#reserveUniformBindingPoint}. Binding points are @@ -1114,7 +1118,9 @@ export default class WebGLRenderer extends Renderer { // which some drivers won't sample in the same frame — an RGB texture // copied this way samples reliably everywhere. const batcher = this.setBatcher("quad"); - const unit = batcher.maxBatchTextures - 1; + // the renderer's top unit, not the batcher's — a scratch bind is GL + // state, so it must not move when a batcher resolves a smaller cap + const unit = this.maxTextures - 1; // a multisampled post-effect capture cannot be read directly // (copyTex*Image2D from an MSAA framebuffer is INVALID_OPERATION) — diff --git a/packages/melonjs/tests/maxtextures-cliff.spec.js b/packages/melonjs/tests/maxtextures-cliff.spec.js new file mode 100644 index 000000000..70cba36e8 --- /dev/null +++ b/packages/melonjs/tests/maxtextures-cliff.spec.js @@ -0,0 +1,149 @@ +/** + * The texture-count cliff, end-to-end (#1585). + * + * A batch can span `maxTextures` distinct textures. One texture past that, the + * allocator runs out, flushes, and evicts every assignment — and because draw + * order is world order rather than texture order, the eviction recurs for the + * rest of the frame. That is the cliff #1584 exists to remove and this one + * moves: the pool used to be hardcoded to 16 regardless of the device. + * + * These assertions are on **cache resets per frame**, which are exact and + * deterministic. Frame times are not asserted — they are noisy on shared CI — + * but the measured shape on an M4 Max via ANGLE/Metal was: + * + * pool= 8 textures= 8 0 resets/frame ~0.06 ms/frame + * pool= 8 textures= 9 50 resets/frame ~2.0 ms/frame + * pool=16 textures=16 0 resets/frame ~0.04 ms/frame + * pool=16 textures=17 25 resets/frame ~1.8 ms/frame + * + * i.e. the same 16-texture scene costs ~0.04 ms/frame with a pool of 16 and + * ~4 ms/frame with a pool of 8 — two orders of magnitude, for one setting. + * That gap is exactly what a lit scene used to pay before #1585, since + * `LitQuadBatcher` halved the pool and reserved the upper half for normal maps. + */ +import { beforeAll, describe, expect, it } from "vitest"; +import { Application, boot, video } from "../src/index.js"; +import { GPU_TEXTURE_CACHE_RESET, off, on } from "../src/system/event.ts"; + +const SIZE = 128; +const QUADS = 200; + +/** distinct 32x32 sources, so every one needs its own texture unit */ +const makeImages = (n) => { + return Array.from({ length: n }, (_, i) => { + const c = document.createElement("canvas"); + c.width = 32; + c.height = 32; + const x = c.getContext("2d"); + x.fillStyle = `hsl(${(i * 37) % 360},80%,55%)`; + x.fillRect(0, 0, 32, 32); + return c; + }); +}; + +describe("texture-count cliff", () => { + let app; + let renderer; + const POOL = 8; + + beforeAll(async () => { + await boot(); + // an explicit narrow pool: the device reports at least 16 everywhere + // (WebGL 2 floor), so 8 is reachable on any machine the suite runs on + app = new Application(SIZE, SIZE, { + renderer: video.WEBGL, + maxTextures: POOL, + }); + await app.init(); + renderer = app.renderer; + }); + + const requireWebGL = (ctx) => { + if (!renderer?.gl) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + /** resets emitted while drawing `distinct` textures over one frame */ + const resetsForFrame = (distinct, images) => { + renderer.cache.resetUnitAssignments(); + const draw = () => { + for (let q = 0; q < QUADS; q++) { + renderer.drawImage( + images[q % distinct], + 0, + 0, + 32, + 32, + (q % 8) * 16, + ((q / 8) | 0) * 16, + 16, + 16, + ); + } + renderer.flush(); + }; + // warm up so first-sight uploads are not counted + draw(); + let resets = 0; + const onReset = () => { + resets++; + }; + on(GPU_TEXTURE_CACHE_RESET, onReset); + draw(); + off(GPU_TEXTURE_CACHE_RESET, onReset); + return resets; + }; + + // the end-to-end wiring the pure resolver tests cannot reach: one setting + // has to land on the renderer, both batchers AND the cache, or they + // disagree about capacity and the overflow guard fires against the wrong + // number + it("the maxTextures setting reaches every consumer", (ctx) => { + requireWebGL(ctx); + expect(renderer.maxTextures).toBe(POOL); + expect(renderer.cache.max_size).toBe(POOL); + expect(renderer.batchers.get("quad").maxBatchTextures).toBe(POOL); + // the lit batcher no longer halves it (#1585) + expect(renderer.batchers.get("litQuad").maxBatchTextures).toBe(POOL); + }); + + it("both multi-texture shaders link at the configured width", (ctx) => { + requireWebGL(ctx); + // the generators emit one sampler and one branch per unit, so a width + // the device or the compiler rejects shows up here and nowhere else + const gl = renderer.gl; + for (const name of ["quad", "litQuad"]) { + const program = renderer.batchers.get(name).defaultShader.program; + expect(gl.getProgramParameter(program, gl.LINK_STATUS)).toBe(true); + } + }); + + it("batches with no resets at or below the pool size", (ctx) => { + requireWebGL(ctx); + const images = makeImages(POOL); + expect(resetsForFrame(POOL - 4, images)).toBe(0); + // the LAST slot must still batch — an off-by-one here costs a full + // eviction cycle on every frame of a scene sized exactly to the pool + expect(resetsForFrame(POOL, images)).toBe(0); + }); + + it("falls off a cliff one texture past the pool", (ctx) => { + requireWebGL(ctx); + const images = makeImages(POOL + 1); + const resets = resetsForFrame(POOL + 1, images); + // not "a few more" — the eviction recurs for the rest of the frame, + // because draw order is world order, not texture order + expect(resets).toBeGreaterThan(QUADS / (POOL + 1) - 1); + }); + + it("the cliff scales with how far past the pool the scene goes", (ctx) => { + requireWebGL(ctx); + const images = makeImages(POOL * 3); + // widening the working set past the pool cannot make it batch better + const near = resetsForFrame(POOL + 1, images); + const far = resetsForFrame(POOL * 3, images); + expect(near).toBeGreaterThan(0); + expect(far).toBeGreaterThan(0); + }); +}); diff --git a/packages/melonjs/tests/maxtextures.spec.js b/packages/melonjs/tests/maxtextures.spec.js new file mode 100644 index 000000000..85de95dd8 --- /dev/null +++ b/packages/melonjs/tests/maxtextures.spec.js @@ -0,0 +1,116 @@ +/** + * The texture-unit pool: resolution of the `maxTextures` setting, and the + * shader generation that has to scale with it (#1585). + * + * The batch limit used to be hardcoded to `Math.min(device, 16)` — 16 being + * the WebGL 2 spec FLOOR for `MAX_TEXTURE_IMAGE_UNITS`, and about half what + * current hardware reports. Lifting it is only safe if the generators scale + * and the resolver never hands back more units than the device has. + * + * CI reports 16, so a live renderer cannot exercise a wide pool here. These + * are the pure-function halves, which can. + */ +import { describe, expect, it } from "vitest"; +import { buildMultiTextureFragment } from "../src/video/webgl/shaders/multitexture.js"; +import { buildLitMultiTextureFragment } from "../src/video/webgl/shaders/multitexture-lit.js"; +import { + AUTO_MAX_TEXTURES, + MIN_MAX_TEXTURES, + resolveMaxTextures, +} from "../src/video/webgl/utils/maxtextures.js"; + +describe("resolveMaxTextures", () => { + it('"auto" takes the device limit, capped at the conservative default', () => { + expect(resolveMaxTextures(16, "auto")).toBe(16); + expect(resolveMaxTextures(32, "auto")).toBe(32); + // a device reporting more than the default is still capped + expect(resolveMaxTextures(64, "auto")).toBe(AUTO_MAX_TEXTURES); + }); + + it("defaults to auto when the setting is absent", () => { + expect(resolveMaxTextures(32)).toBe(resolveMaxTextures(32, "auto")); + }); + + it("clamps an explicit setting DOWN to the device limit", () => { + // the failure this prevents is not subtle: declaring more sampler2D + // uniforms than the device has units fails to LINK, at startup + expect(resolveMaxTextures(16, 32)).toBe(16); + expect(resolveMaxTextures(8, 999)).toBe(8); + }); + + it("honors an explicit setting below the device limit", () => { + // the escape hatch for a driver that misbehaves on wide ladders + expect(resolveMaxTextures(32, 8)).toBe(8); + }); + + it("never returns a pool too small to hold a colour AND a normal map", () => { + // a one-slot pool makes a lit quad's two texture ids collide, so the + // sprite samples its own albedo as its normal map — silently + expect(resolveMaxTextures(32, 0)).toBe(MIN_MAX_TEXTURES); + expect(resolveMaxTextures(32, -5)).toBe(MIN_MAX_TEXTURES); + expect(resolveMaxTextures(32, 1)).toBe(MIN_MAX_TEXTURES); + // ...but never more than the device actually has + expect(resolveMaxTextures(1, "auto")).toBe(1); + expect(resolveMaxTextures(1, 8)).toBe(1); + }); + + it("tolerates a garbage setting rather than bricking the renderer", () => { + // mirrors how getDefaultTextureFilter treats an unknown mode: an + // unset or typo'd value must fall back, not throw + expect(resolveMaxTextures(32, undefined)).toBe(32); + expect(resolveMaxTextures(32, "nonsense")).toBe(32); + expect(resolveMaxTextures(32, Number.NaN)).toBe(32); + expect(resolveMaxTextures(Number.NaN, "auto")).toBe(1); + }); + + it("floors a fractional setting to a whole unit count", () => { + expect(resolveMaxTextures(32, 8.9)).toBe(8); + }); +}); + +describe("multi-texture shader generation scales with the pool", () => { + // the generators were only ever covered at counts 1-4 + const counts = [1, 4, 16, 32]; + + it.each(counts)("unlit: declares exactly %i samplers", (n) => { + const src = buildMultiTextureFragment(n); + expect(src.match(/uniform sampler2D uSampler\d+;/g)).toHaveLength(n); + expect(src).toContain(`uSampler${n - 1}`); + expect(src).not.toContain(`uSampler${n};`); + }); + + it.each(counts)("lit: declares exactly %i samplers", (n) => { + const src = buildLitMultiTextureFragment(n); + expect(src.match(/uniform sampler2D uSampler\d+;/g)).toHaveLength(n); + }); + + it.each(counts)("lit: no separate normal sampler set at %i", (n) => { + // THE regression pin for the untangle. A second `uNormalSampler0..n-1` + // set is what made the lit shader need 2n samplers, which forced the + // batcher to halve its pool and permanently reserve the upper half. + const src = buildLitMultiTextureFragment(n); + expect(src).not.toContain("uNormalSampler"); + }); + + it("lit: both ids select from the SAME sampler set", () => { + const src = buildLitMultiTextureFragment(4); + // each id drives its own if-ladder, but over one set of uniforms + expect(src).toContain("vTextureId < 0.5"); + expect(src).toContain("vNormalTextureId < 0.5"); + const branches = src.match(/uSampler\d+, vRegion/g); + // 4 color arms + 1 fallback, doubled for the normal ladder + expect(branches).toHaveLength(10); + }); + + it("thresholds stay exactly representable at the widest pool", () => { + // the ladder compares an interpolated float against `i + 0.5`; at + // mediump (highPrecisionShader: false) a float has 10 mantissa bits, + // so 31.5 must still round-trip exactly or a sprite samples the wrong + // texture at the top of the pool + const src = buildLitMultiTextureFragment(32); + expect(src).toContain("31.5"); + for (let i = 0; i < 32; i++) { + expect(Number(`${i}.5`)).toBe(i + 0.5); + } + }); +}); diff --git a/packages/melonjs/tests/webgl_batcher_state.spec.js b/packages/melonjs/tests/webgl_batcher_state.spec.js index 3401ecd4e..4ab1b3281 100644 --- a/packages/melonjs/tests/webgl_batcher_state.spec.js +++ b/packages/melonjs/tests/webgl_batcher_state.spec.js @@ -52,26 +52,31 @@ describe("batcher GL state", () => { } }; - it("activating the lit batcher reserves the paired normal-map unit range", (ctx) => { + // #1585 inverted this: the lit batcher used to permanently reserve + // `[n, 2n)` for normal maps on first bind, which cost every allocator half + // the device's units for the rest of the session and collided with the top + // units ShaderEffect and toFrameTexture claim. Normal maps now take slots + // from the shared pool, so activating lighting must reserve NOTHING. + it("activating the lit batcher reserves no units", (ctx) => { requireWebGL(ctx); const lit = renderer.batchers.get("litQuad"); renderer.setBatcher("litQuad"); renderer.setBatcher("quad"); - const half = lit.maxBatchTextures; - for (let i = half; i < half * 2; i++) { - expect(renderer.cache.reservedUnits.has(i)).toBe(true); - } + expect(renderer.cache.reservedUnits.size).toBe(0); + // and the lit batcher gets the FULL pool, not half of it + expect(lit.maxBatchTextures).toBe(renderer.maxTextures); - // the allocator must never hand a reserved (normal-map) unit to a - // color texture — drain it past exhaustion to cover the reset path too + // every unit stays allocatable — drain past exhaustion to cover the + // reset path too, and assert the whole range is reachable renderer.cache.resetUnitAssignments(); const handed = new Set(); for (let i = 0; i < renderer.maxTextures * 2; i++) { handed.add(renderer.cache.allocateTextureUnit()); } + expect(handed.size).toBe(renderer.maxTextures); for (const unit of handed) { - expect(unit < half || unit >= half * 2).toBe(true); + expect(unit).toBeLessThan(renderer.maxTextures); } renderer.cache.resetUnitAssignments(); }); @@ -79,11 +84,14 @@ describe("batcher GL state", () => { it("ShaderEffect extra samplers skip units reserved by others", (ctx) => { requireWebGL(ctx); const quad = renderer.batchers.get("quad"); - const lit = renderer.batchers.get("litQuad"); - // make sure the lit batcher's reservation is in place (idempotent) renderer.setBatcher("litQuad"); renderer.setBatcher("quad"); + // a unit reserved by someone else must still be skipped — the lit + // batcher no longer reserves any (#1585), so stand one in explicitly + const held = renderer.maxTextures - 1; + renderer.cache.reserveUnit(held); + const fx = new ShaderEffect( renderer, "vec4 apply(vec4 color, vec2 uv) { return color; }", @@ -95,10 +103,112 @@ describe("batcher GL state", () => { fx._prepareTextures(quad); const claimed = fx._extraTextures.get("uNoise").unit; - // claiming counts down from the batcher's top unit — it must walk - // PAST the lit batcher's reserved normal range, not land inside it - expect(claimed).toBeLessThan(lit.maxBatchTextures); + // claiming counts down from the top unit — it must step OVER the + // reserved one rather than aliasing onto it + expect(claimed).not.toBe(held); + expect(renderer.cache.reservedUnits.has(claimed)).toBe(true); + fx.destroy(); + renderer.cache.releaseUnit(held); + }); + + // The collision #1585 removed: ShaderEffect claims extra samplers counting + // DOWN from the top unit, and the lit batcher used to own a FIXED upper + // range for normal maps. An effect that claimed before lighting first + // activated landed inside that range, and the two aliased silently — wrong + // lighting, no error. Normal maps now allocate from the shared pool, which + // respects reservations, so the overlap is structurally impossible. + it("a normal map never lands on a unit reserved by a ShaderEffect", (ctx) => { + requireWebGL(ctx); + const quad = renderer.batchers.get("quad"); + const lit = renderer.batchers.get("litQuad"); + + // claim first, exactly the ordering that used to break + const fx = new ShaderEffect( + renderer, + "vec4 apply(vec4 color, vec2 uv) { return color; }", + ); + vi.spyOn(fx._shader, "setUniform").mockImplementation(() => {}); + fx.setTexture("uNoise", Renderer.createCanvas(8, 8)); + fx._prepareTextures(quad); + const claimed = fx._extraTextures.get("uNoise").unit; + expect(renderer.cache.reservedUnits.has(claimed)).toBe(true); + + // then drive enough distinct normal maps to walk the whole pool + const landed = new Set(); + for (let i = 0; i < renderer.maxTextures + 2; i++) { + landed.add(lit.resolveNormalUnit(Renderer.createCanvas(4, 4))); + } + // every unit except the reserved one — proves the normal maps walk the + // WHOLE pool while stepping over the reservation, not just two of them + expect(landed.size).toBe(renderer.maxTextures - 1); + expect(landed.has(claimed)).toBe(false); + fx.destroy(); + renderer.cache.resetUnitAssignments(); + }); + + // Regression: `resolveNormalUnit` briefly cleared `boundTextures[unit]` to + // stop the colour tracker claiming that slot. But that array is exactly what + // `MaterialBatcher.reset()` walks to DELETE the GL textures it owns, and a + // normal map's texture lives there — so every reset leaked one GL texture + // per normal map, unreachable and undeletable. + it("normal-map GL textures are deleted on reset, not leaked", (ctx) => { + requireWebGL(ctx); + const gl = renderer.gl; + const lit = renderer.batchers.get("litQuad"); + const source = Renderer.createCanvas(8, 8); + + const unit = lit.resolveNormalUnit(source); + const tex = lit.normalMapTextures.get(source)?.tex; + expect(tex).toBeDefined(); + expect(gl.isTexture(tex)).toBe(true); + // the handle must be reachable from the array reset() walks + expect(lit.boundTextures[unit]).toBe(tex); + + lit.reset(); + expect(gl.isTexture(tex)).toBe(false); + renderer.cache.resetUnitAssignments(); + }); + + // Regression: a normal map claims its unit through `allocateTextureUnit()`, + // which is keyless — so nothing released it. Distinct normal maps drained + // the pool monotonically until something forced a full reset. + it("evicting a normal map returns its unit to the allocator", (ctx) => { + requireWebGL(ctx); + const lit = renderer.batchers.get("litQuad"); + renderer.cache.resetUnitAssignments(); + const source = Renderer.createCanvas(8, 8); + + const unit = lit.resolveNormalUnit(source); + expect(renderer.cache.usedUnits.has(unit)).toBe(true); + + lit.evictNormalMap(source); + expect(renderer.cache.usedUnits.has(unit)).toBe(false); + expect(lit.normalUnits.has(source)).toBe(false); + // and the freed unit is genuinely handed out again + expect(renderer.cache.allocateTextureUnit()).toBe(unit); + renderer.cache.resetUnitAssignments(); + }); + + // Colors and normal maps share one pool since #1585. Binding a normal map + // onto a unit another batcher believes holds its color texture would make + // that batcher skip the re-bind and sample the normal map instead — the old + // fixed normal-map reservation made this impossible, so it is a new class. + it("binding a normal map invalidates that unit on other batchers", (ctx) => { + requireWebGL(ctx); + const lit = renderer.batchers.get("litQuad"); + renderer.cache.resetUnitAssignments(); + + const spy = vi.spyOn(renderer, "invalidateTextureUnit"); + const unit = lit.resolveNormalUnit(Renderer.createCanvas(8, 8)); + + // announced renderer-wide, excluding the batcher that just bound it — + // its own bookkeeping is already correct and clearing it would force a + // redundant re-bind on the very next quad + expect(spy).toHaveBeenCalledWith(unit, lit); + + spy.mockRestore(); + renderer.cache.resetUnitAssignments(); }); it("tracks the active texture unit renderer-wide, not per batcher", (ctx) => { From bfa7c62be613ecb56d24b02ded3809876557bd16 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 16 Aug 2026 08:19:44 +0800 Subject: [PATCH 04/16] docs(changelog): maxTextures setting, and the lit pool fix Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- packages/melonjs/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 1fe90edb4..92751a8ac 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,7 @@ ## [20.0.0] (melonJS 2) - _unreleased_ ### Added +- **`maxTextures` application setting, and the multi-texture batch limit follows the device** ([#1585](https://github.com/melonjs/melonJS/issues/1585)) — the WebGL batchers capped their texture pool at 16, the WebGL 2 *spec floor* for `MAX_TEXTURE_IMAGE_UNITS` and roughly half what current desktop and mobile hardware reports. The pool is now the device's own limit (capped at 32 by default), so a scene keeps batching across twice as many distinct textures before it has to flush. `maxTextures: "auto" | number` overrides it — a number is clamped to what the device actually has, and lowering it is the escape hatch if a driver misbehaves on wide sampler ladders. Read at initialization only, since the batchers compile their shaders against it. WebGL only: the WebGPU backend sizes its slot budget from its own per-stage binding limits, and the Canvas renderer does not batch. Lit sprites benefit twice over: `LitQuadBatcher` used to *halve* the pool and then permanently reserve the upper half for normal maps, because its shader declared a second `uNormalSampler0..n-1` set and 2n samplers had to fit the device — so one lit sprite cost a scene half its units for the rest of the session. The shader now addresses **one** sampler set with two independent per-quad ids, and a normal map takes a slot from the shared pool like any other texture, making the split dynamic (sprites sharing a normal map cost one slot between them) instead of a fixed 50/50 tax - **Ground shadows for 3D objects** ([#1515](https://github.com/melonjs/melonJS/issues/1515)) — `castGroundShadow: true` gives a `Mesh`, a `Sprite3d` billboard or a whole `InstancedMesh` scatter a soft shadow on the ground, which 2.5D scenes had no way to get: without one, characters and props read as floating however carefully they are placed. This is a **blob** shadow and deliberately not a simulated one — for the paper-thin billboards a 2.5D game is made of, a shadow map costs far more than this engine wants to spend *and* looks worse, because a flat silhouette has to be special-cased to cast anything sensible at all. What the player actually needs is contact: where the object stands, and how far off the ground it is mid-jump. Three properties, and nothing else to configure: `castGroundShadow` (the whole opt-in), `shadowGroundY` (the world Y of the floor — the game knows it, from collision; left unset the blob sits at the object's own base at full strength, which is right for something already resting on the ground), and `shadowOpacity` (`0.45` by default). Set `shadowGroundY` and the blob **shrinks and fades with height**, which is what reads as a jump. The blob is not a disc: it is an ellipse sized from the caster's own footprint and **turned by its rotation**, so a flat upright panel gets a thin shadow lying along the panel rather than a circle that reads as perpendicular to it, and it spreads slightly past the footprint the way a real contact shadow does (a box sitting flat on the floor would otherwise cover its own shadow exactly). A paper-thin caster — a billboard, a plate — keeps a minimum minor axis so it stays a blob rather than degenerating to a hairline. Cost is one extra draw per shadowed object, and for an `InstancedMesh` **one extra draw for the entire scatter regardless of instance count**: the blobs are read from the same instance buffer the meshes themselves draw from, through a standalone shader that reads only the transform rows — so a 100 000-tree forest with shadows is two draws, and per-instance colour or emissive cannot leak into them. Shadows are held back until every opaque mesh in the pass is drawn, then drawn in one go: a blob writes no depth (so two overlapping at one ground height blend instead of fighting), which leaves it nothing to defend itself with, and a ground plane routinely sorts *after* the props standing on it. Depth *testing* stays on throughout, so a shadow is still correctly hidden behind geometry genuinely in front of it. Both GPU backends; the Canvas renderer has no depth buffer and so no ground shadows. **An object that does not opt in is untouched** — no extra draw, no extra state, no changed pipeline, and the shared falloff texture and quad are allocated lazily, so an application with no shadows builds neither. **On by default**, and controllable at three levels, most specific first: per object (`castGroundShadow` on the mesh, which always wins), per glTF scene (`level.load(name, { castGroundShadow, shadowGroundY })`), and application-wide (the new `castGroundShadow` application setting, which ships `true`). A 2D game is untouched whatever it says — the shadow rides the retained `Camera3d` path only, so the Canvas renderer and the 2D-camera path draw none; set the application setting `false` to opt a 3D game out wholesale (one whose lighting is already baked, or that brings its own shadows, would otherwise get two). The two blanket forms carry one safeguard the per-object form does not: they **skip meshes with no vertical extent**, because a flat plane lying on the floor *is* the floor and shadowing it with itself smears a blob across the whole ground — so a glTF scene shipping its ground as a plane opts in correctly with a single option and no per-node fiddling. Shown in the reworked **Per-material Textures** (a crate, a chrome ball and a perforated panel, each with a shadow matching its own footprint), **Billboard Sprites** and **Instanced Forest** examples - **Shader effects run on the WebGPU renderer, and effect bodies are dual-language** — `ShaderEffect` (and every built-in effect) now works on both GPU backends. An effect body can be a GLSL string exactly as before, or one body per shading language: `new ShaderEffect(renderer, { glsl, wgsl })` — the renderer compiles the body matching its `shaderLanguage`, uniform names are shared so one `setUniform` serves both, and when no matching body exists the effect warns once and stays disabled (`enabled === false`) while the scene keeps rendering — the same graceful contract the Canvas renderer always had. All 18 dual-language built-in effects (Vignette, Blur, ColorMatrix/Desaturate/Invert/Sepia, Dissolve, DropShadow, Flash, Glow, Hologram, Outline, Pixelate, Scanline, Shine, TintPulse, Wave, ChromaticAberration) render identically under WebGL and WebGPU, through both the post-effect chain (cameras, multi-effect ping-pong, `screen_texture`/`screen_uv`/`noise_uv` builtins) and the single-effect fast path. The WGSL authoring convention — one uniform struct at `@group(3) @binding(0)` whose member names are the `setUniform` names, texture/sampler pairs for `setTexture`, builtins under their established names — is documented on the `ShaderEffect` class. Shader assets gain the matching dual shape: `{ type: "shader", src: { glsl, wgsl } }` (or inline via `data`), fetching what is declared and preloading successfully even when the active backend matches neither (inert stub, unload-safe). Existing GLSL-only effects and assets are untouched: the generated GLSL is byte-identical to 19.x - **WebGPU renderer** — the default on WebGPU-capable browsers: `video.AUTO` (the default `renderer` setting) now negotiates **WebGPU first, then WebGL 2, then Canvas** — the WebGPU attempt is a full adapter/device negotiation awaited inside `app.init()`, falling through to the synchronous candidates when it rejects, so `init()` always resolves under AUTO. Also requestable explicitly as `renderer: video.WEBGPU` (fails loudly, never substitutes) or via the `#webgpu` URI fragment; `#webgl` / `#canvas` force the other backends per-run. The backend covers the **full 2D contract**: sprites, text and particles through a WGSL quad pipeline (packed-tint vertex stream identical to the WebGL layout, **multi-texture batching** — one draw segment spans up to eight distinct textures, selected per quad by the vertex stream's texture id, so a texture change no longer breaks the batch), filled/stroked shapes and the Path2D API through a primitive pipeline (thick lines via the shared frame-globals uniform block), all six blend modes as pipeline blend states (including min/max darken/lighten), patterns with per-axis repeat samplers, transform-derived scissor clipping, mid-frame scissored clears, and stencil-based `setMask`/`clearMask`, plus the GPU tile path: orthogonal TMX layers draw through a WGSL port of the shader tilemap renderer (one quad per tileset, per-layer GID index texture, animated tiles included [#1445 parity]). Frames record into one command encoder / render pass with a `depth24plus-stencil8` attachment carried from day one; the backend-neutral vertex formats and topologies of [#1551](https://github.com/melonjs/melonJS/issues/1551) are consumed declaratively into the pipeline layouts ([#1492](https://github.com/melonjs/melonJS/issues/1492)), and frame globals live in a bind-group-0 uniform buffer (the [#1555](https://github.com/melonjs/melonJS/issues/1555) shape). The rest of the 2D feature set follows suit: **2D lights and normal-map lighting** (`Light2d` glow quads, ambient-light cutouts and the std140 lit-sprite path, all through the reserved lights bind group), **`toFrameTexture()`** frame captures (alpha preserved, and row 0 is the top of the frame where the GL capture is bottom-up — GLSL capture shaders flip with `1.0 - uv.y`, their WGSL twins must not), **gradient fills of arbitrary shapes** (the stencil gradient-mask machinery as pipeline variants), and **compressed textures** (BC / ETC2 / ASTC through whichever `texture-compression-*` device features the adapter offers, consuming the loader's existing dds/ktx/ktx2/pvr/pkm parsers unchanged; PVRTC has no WebGPU equivalent and reports unsupported). The **3D tier** completes the contract: `drawMesh` renders textured triangle meshes through unlit and lit WGSL mesh pipelines (`Light3d` half-Lambert directional + ambient via the std140 light block at the reserved lights bind group) — retained model-space geometry under `Camera3d` (upload once; placement, tint, alpha cutout and emissive ride one per-draw uniform snapshot, so moving or re-tinting a mesh never re-uploads geometry; `supportsDepthBuffer` / `supportsRetainedMesh` are now `true`) as well as the CPU-projected 2D-camera mesh path, with per-mesh back-face culling and winding as pipeline state, per-mesh `textureRepeat` / `textureFilter`, multi-material vertex colors and Uint32 indices, and depth realized as the render pass's load/store ops (one depth clear per render target per frame, the GL policy — pure-2D scenes keep byte-identical passes). glTF scenes and animated models, `Sprite3d` billboards and split-screen `Camera3d` viewports run unchanged on top. **`antiAlias: true` maps to 4× MSAA** on canvas passes (multisampled color + depth resolving into the canvas view — and post-effect capture targets carry their own multisampled half, see the dedicated MSAA entry below). See the reworked **Hello WebGPU** example ([#1184](https://github.com/melonjs/melonJS/issues/1184)) @@ -65,6 +66,7 @@ The old path scales linearly with vertex count; the new one is flat, because no - **the cost of `antiAlias: true` under post effects, quantified** ([#1556](https://github.com/melonjs/melonJS/issues/1556)) — MSAA composing through effect chains (see *Added*) is paid for in memory and bandwidth, and the price is worth knowing. Arithmetic, not measurement: a 4× capture target keeps 4 color + 4 depth-stencil samples per pixel next to its 1× resolve texture — roughly **28 extra bytes per pixel on WebGL (~55 MB of GPU memory at 1080p)** and **~16 bytes per pixel on WebGPU (~32 MB at 1080p)**, where the multisampled depth attachment is shared with the canvas rather than per-target; both scale linearly with resolution. Per frame it adds one resolve blit per effect bracket, and draws inside the bracket write up to 4 samples per covered pixel — bandwidth, not shading cost, since fragment shaders still run once per pixel under MSAA. Only scene **capture** targets pay any of this (ping-pong intermediates stay 1×), and with `antiAlias: false` — the default — no multisampled storage exists at all, so nothing changes ### Fixed +- **A `ShaderEffect` extra sampler could silently corrupt normal-map lighting** ([#1585](https://github.com/melonjs/melonJS/issues/1585)) — `ShaderEffect.setTexture` claims texture units counting down from the top, and `LitQuadBatcher` reserved a fixed upper range for normal maps on first activation. An effect that claimed *before* the first lit sprite drew landed inside that range, and the two aliased: the normal map sampled the effect's texture, giving wrong lighting with no error and no warning. Normal maps now allocate from the shared pool, which respects reservations, so the overlap cannot occur - **destroyed renderers stayed subscribed to global events forever** — `WebGLRenderer` subscribed to `GAME_RESET`, `ONCONTEXT_RESTORED` and `CANVAS_ONRESIZE`, and `CanvasRenderer` to `GAME_RESET`, all as **inline anonymous handlers** — which cannot be passed to `off()`, so nothing could ever unregister them. `CanvasRenderer` had no `destroy()` at all, inheriting the base no-op. Two consequences, both silent: a destroyed renderer kept reacting to those events, and — worse — each handler closes over the renderer, so the subscription pinned the renderer, its batchers and its GPU objects against garbage collection. Releasing the GL context alone did not help, because the JS graph was still reachable from the event bus. The same shape was in the scene graph: the **root `Container`** subscribed to `CANVAS_ONRESIZE` with an inline arrow, and **`World`** to `GAME_RESET` (with a context) and `LEVEL_LOADED` (inline), none of them ever removed — and `World` had no `destroy()` of its own, so a torn-down world kept resetting itself and clearing a broadphase nobody read. All of these are now per-instance fields and `destroy()` unregisters them, matching what the WebGPU backend already did. Any application that tears down and rebuilds — an SPA moving between scenes, a level reload — stops accumulating them - **`Application.destroy()` leaked the WebGL context** — teardown deleted every GL object the renderer owned and removed the canvas from the DOM, but never handed back the **context** itself. A canvas keeps its context until the canvas is garbage-collected, which is non-deterministic and routinely delayed, so each destroyed application left a live context behind. Browsers cap how many they keep — around 16 on Chromium — and force-lose the oldest past that, which means a long-lived page that builds and tears down several applications accumulates dead-but-unfreed contexts until an unrelated later `getContext` stalls or comes back already lost. That hits any single-page app that moves between scenes or unmounts a game view (the examples gallery does exactly this on every navigation), and it was also making unrelated test suites time out in CI. `destroy()` now releases the context through `WEBGL_lose_context`. It stays idempotent, and since `destroy()` is already terminal — `Application.init()` refuses to run again afterwards — losing the context forecloses nothing that was previously possible. Renderers whose driver does not expose the extension are unaffected - **the 3D broadphase silently dropped collisions between bodies at different depths** — under `Camera3d` the world's broadphase is an `Octree`, and `retrieve()` — the candidate feed for SAT collision, pointer picking, the 2D raycast and `adapter.queryAABB` — descended only into the octant the query item itself classified into. But every one of those consumers decides overlap in the **XY plane**: two bodies at different z that overlap in XY genuinely collide, and were never offered to each other as candidates. Whether a given pair was tested came down to which side of an octant boundary each happened to fall on. Measured on a randomized 300-body scene, **12 of 20 genuinely overlapping pairs were never surfaced**. `retrieve()` is now depth-blind: it classifies on x/y only and walks both depth halves of that quadrant, so x/y pruning still applies at every level and in both halves (an item lying wholly inside a different x/y quadrant cannot overlap, and midpoint-straddling items already live at the parent level). On a 600-body scene this costs nothing at all when the bodies share a gameplay plane — the candidate count is unchanged — and on a depth-spread scene it settles at the same candidate count as the flat one, which is the point: with depth no longer part of the decision, the candidate set depends only on the x/y distribution. The genuinely 3D queries are unaffected and still prune on depth: `queryAABB`, `querySphere`, `queryRay` and `queryFrustum` have their own entry points, and each is now pinned by a differential test against a brute-force scan. Note this removes the incidental "parallax at a distant z drops out of collision for free" behaviour that the 2.5D documentation described as best-effort — it was this defect seen from its good side. Exclude parallax deliberately instead, with `isKinematic = true` or `collisionType` / `collisionMask`, which is what the 2D path has always done From d21d0de02e7eedfd58c94e2e3a9bab0992c8f179 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 16 Aug 2026 08:23:50 +0800 Subject: [PATCH 05/16] feat(video): shared TextureStore for backend-neutral texture residency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the pair started by TextureSlotTable. There are two separate questions about a texture: which slot is it in, for this draw? transient, bounded by sampler count does it exist on the GPU, is it current? persistent, bounded by disposal The WebGL backend answers both with one field — `boundTextures[unit]` — so dropping a slot assignment destroys the handle, and the next draw rebuilds the texture from scratch. Past the batching limit that is a full re-upload and mip regeneration per quad, every frame. `TextureStore` owns the source-to-record index, the reuse-or-upload decision, and lifetime bookkeeping. It touches no GL or WebGPU API: each backend supplies onCreate/onUpload/onDestroy, so the decision logic is identical on both and testable without a device. Three guards are structural rather than conventional, each for a failure mode that is silent when it happens: - a fresh record's version starts unmatchable, so it always uploads once — a record that exists but was never filled samples as garbage - `onUpload` may return a REPLACEMENT handle, because immutable storage cannot be respecified and a shape change forces a new GPU object - `releaseAll()` clears in place and bumps a generation; callers holding a handle of their own check `isCurrent()`. Responding to a restore by constructing a replacement store orphans everything the old one tracked, so the SECOND loss leaks it all — and both WebGL batchers re-run `init()` on restore, which is exactly how that gets written by accident. No consumer yet; adoption follows. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- .../melonjs/src/video/gpu/texturestore.js | 191 +++++++++++++++ packages/melonjs/tests/texturestore.spec.js | 218 ++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 packages/melonjs/src/video/gpu/texturestore.js create mode 100644 packages/melonjs/tests/texturestore.spec.js diff --git a/packages/melonjs/src/video/gpu/texturestore.js b/packages/melonjs/src/video/gpu/texturestore.js new file mode 100644 index 000000000..9ca163032 --- /dev/null +++ b/packages/melonjs/src/video/gpu/texturestore.js @@ -0,0 +1,191 @@ +/** + * Backend-neutral texture residency. + * + * There are two separate questions about a texture, and conflating them is + * what makes a GPU renderer re-upload work it already has: + * + * - **which slot is it in, for this draw?** — transient, per-batch, bounded by + * the shader's sampler count. That is {@link TextureSlotTable}. + * - **does it exist on the GPU, and is its content current?** — persistent, + * per-source, bounded by disposal. That is this class. + * + * The WebGL backend used to answer both with one field (`boundTextures[unit]`), + * so dropping a slot assignment destroyed the texture handle and the next draw + * rebuilt it from scratch — a full re-upload and mip regeneration per quad, + * once past the batching limit. + * + * This class owns the source → record index, the reuse-or-upload decision, and + * the lifetime bookkeeping. It touches no GL or WebGPU API: each backend + * supplies `onCreate` / `onUpload` / `onDestroy`, exactly as the slot table + * takes its binding through callbacks. That keeps the decision logic identical + * on both backends and testable without a device. + * @ignore + */ +export class TextureStore { + /** + * @param {object} [options] - store configuration + * @param {Function} [options.onCreate] - `(source) => handle`, allocate the + * backing GPU texture for a source seen for the first time + * @param {Function} [options.onUpload] - `(handle, source, record) => handle`, + * push the source's current content. May return a *replacement* handle when + * the shape changed and the old one cannot be respecified (immutable + * storage); returning nothing keeps the existing handle. + * @param {Function} [options.onDestroy] - `(handle, source)`, release the + * GPU texture. Called exactly once per handle. + * @ignore + */ + constructor({ onCreate, onUpload, onDestroy } = {}) { + /** @type {Map} */ + this.records = new Map(); + this.onCreate = onCreate; + this.onUpload = onUpload; + this.onDestroy = onDestroy; + // Bumped whenever the underlying context or device dies. A record from + // an older generation is never handed back: a handle minted under a + // dead context is not merely stale, binding it is undefined behaviour, + // and the failure is silent. Comparing generations turns that into an + // ordinary miss followed by a re-upload. + this.generation = 0; + } + + /** + * how many sources are currently resident + * @returns {number} the record count + * @ignore + */ + get size() { + return this.records.size; + } + + /** + * The live record for a source, uploading only when it is genuinely needed: + * the source has never been seen, its content changed, its handle predates + * the current context, or the caller forced it. + * + * A texture that merely moved to a different slot is NOT a reason to upload + * — that is the whole point of separating residency from slot assignment. + * @param {object} source - the image/canvas/resource backing the texture + * @param {object} [options] - resolution options + * @param {number} [options.version=0] - the source's content revision; a + * change from the recorded value forces a re-upload + * @param {boolean} [options.force=false] - re-upload regardless + * @returns {{handle: *, version: number, generation: number, uploaded: boolean}} + * the record, with `uploaded` reporting whether this call did GPU work + * @ignore + */ + getResidentRecord(source, { version = 0, force = false } = {}) { + let record = this.records.get(source); + + if (record === undefined) { + record = { + handle: this.onCreate?.(source), + // deliberately unmatchable, so a freshly created record always + // takes the upload path once — a record that exists but was + // never filled is the failure this rules out + version: -1, + generation: this.generation, + }; + this.records.set(source, record); + } + + if (force || record.version !== version) { + const replacement = this.onUpload?.(record.handle, source, record); + if (replacement !== undefined && replacement !== null) { + // the backend could not respecify in place and swapped handles + record.handle = replacement; + } + record.version = version; + record.uploaded = true; + } else { + record.uploaded = false; + } + return record; + } + + /** + * The record for a source without resolving one, for callers that must not + * trigger GPU work (bookkeeping, debug, invalidation checks). + * @param {object} source - the source to look up + * @returns {object|undefined} the record, or `undefined` + * @ignore + */ + peek(source) { + return this.records.get(source); + } + + /** + * Whether a record is still valid for the live context. + * + * `releaseAll` drops every record, so the store itself can never hand back + * a stale one — but a CALLER that cached a handle of its own (a batcher's + * per-unit array, say) has no such protection, and binding a handle minted + * under a dead context is undefined behaviour that fails silently. This is + * how such a caller checks, rather than assuming its own invalidation ran. + * @param {object} [record] - a record obtained earlier + * @returns {boolean} whether it belongs to the current context + * @ignore + */ + isCurrent(record) { + return record !== undefined && record.generation === this.generation; + } + + /** + * Mark a source's content stale, so the next resolve re-uploads it. Cheaper + * and safer than destroying it: the handle and its storage survive, only the + * contents are re-pushed. + * @param {object} source - the source whose content changed + * @ignore + */ + invalidate(source) { + const record = this.records.get(source); + if (record !== undefined) { + record.version = -1; + } + } + + /** + * Release the GPU texture for one source. Driven by the source going away + * — never by a slot being reassigned, which is exactly the coupling this + * class exists to break. + * @param {object} source - the disposed source + * @returns {boolean} whether a record was held + * @ignore + */ + destroyTexture(source) { + const record = this.records.get(source); + if (record === undefined) { + return false; + } + this.records.delete(source); + if (record.generation === this.generation) { + this.onDestroy?.(record.handle, source); + } + return true; + } + + /** + * Drop every record and bump the generation. Called on context or device + * loss. + * + * This CLEARS the store in place — callers must never respond to a restore + * by constructing a replacement store. Doing so orphans every handle the + * old one was tracking, so the *second* loss leaks everything it had + * accumulated since the first. Both WebGL batchers re-run `init()` on + * restore, which is precisely how that mistake gets made here. + * @param {boolean} [destroy=false] - whether to release the handles first. + * Leave `false` for a lost context (the GPU objects are already gone); + * pass `true` for an orderly teardown. + * @ignore + */ + releaseAll(destroy = false) { + if (destroy === true && this.onDestroy !== undefined) { + for (const [source, record] of this.records) { + if (record.generation === this.generation) { + this.onDestroy(record.handle, source); + } + } + } + this.records.clear(); + this.generation++; + } +} diff --git a/packages/melonjs/tests/texturestore.spec.js b/packages/melonjs/tests/texturestore.spec.js new file mode 100644 index 000000000..78fd634d1 --- /dev/null +++ b/packages/melonjs/tests/texturestore.spec.js @@ -0,0 +1,218 @@ +/** + * `TextureStore` — the shared residency policy (#1585). + * + * Residency answers "does this source exist on the GPU and is its content + * current". It is deliberately NOT the same question as "which slot is it in + * for this draw" — conflating the two is what made a WebGL texture-cache + * overflow re-create and re-upload every texture, once per draw, every frame. + * + * GPU-free by construction: the store only ever sees opaque handles returned + * by its callbacks, so this suite is the behavioural oracle for both backends. + */ +import { describe, expect, it, vi } from "vitest"; +import { TextureStore } from "../src/video/gpu/texturestore.js"; + +/** a store whose handles are just tagged objects, with call spies */ +const makeStore = (over = {}) => { + let next = 0; + const spies = { + onCreate: vi.fn(() => { + return { id: next++ }; + }), + onUpload: vi.fn(() => { + return undefined; + }), + onDestroy: vi.fn(), + }; + return { store: new TextureStore({ ...spies, ...over }), spies }; +}; + +describe("TextureStore", () => { + describe("reuse vs upload", () => { + it("creates and uploads a source seen for the first time", () => { + const { store, spies } = makeStore(); + const src = { name: "a" }; + const rec = store.getResidentRecord(src); + expect(spies.onCreate).toHaveBeenCalledTimes(1); + expect(spies.onUpload).toHaveBeenCalledTimes(1); + expect(rec.uploaded).toBe(true); + }); + + it("re-resolving an unchanged source does NO gpu work", () => { + // the whole point: a texture that merely moved slots must not + // re-upload. This is the assertion the WebGL bug violated. + const { store, spies } = makeStore(); + const src = { name: "a" }; + const first = store.getResidentRecord(src); + const second = store.getResidentRecord(src); + expect(spies.onCreate).toHaveBeenCalledTimes(1); + expect(spies.onUpload).toHaveBeenCalledTimes(1); + expect(second.handle).toBe(first.handle); + expect(second.uploaded).toBe(false); + }); + + it("a version bump re-uploads without recreating", () => { + const { store, spies } = makeStore(); + const src = { name: "a" }; + const first = store.getResidentRecord(src, { version: 1 }); + const second = store.getResidentRecord(src, { version: 2 }); + expect(spies.onCreate).toHaveBeenCalledTimes(1); + expect(spies.onUpload).toHaveBeenCalledTimes(2); + expect(second.handle).toBe(first.handle); + }); + + it("force re-uploads even when the version matches", () => { + const { store, spies } = makeStore(); + const src = { name: "a" }; + store.getResidentRecord(src, { version: 7 }); + store.getResidentRecord(src, { version: 7, force: true }); + expect(spies.onUpload).toHaveBeenCalledTimes(2); + }); + + it("a fresh record always uploads once, even at version 0", () => { + // records start at an unmatchable version on purpose — otherwise a + // source whose version is legitimately 0 would be created and never + // filled, and sample as garbage + const { store, spies } = makeStore(); + store.getResidentRecord({ name: "a" }, { version: 0 }); + expect(spies.onUpload).toHaveBeenCalledTimes(1); + }); + + it("adopts a replacement handle when the backend swaps one in", () => { + // immutable storage cannot be respecified, so a shape change forces + // a new GPU object; the store must track it or it hands back a dead + // handle forever + const swapped = { id: "new" }; + const { store } = makeStore({ + onUpload: vi.fn(() => { + return swapped; + }), + }); + const rec = store.getResidentRecord({ name: "a" }); + expect(rec.handle).toBe(swapped); + }); + + it("keeps sources independent", () => { + const { store, spies } = makeStore(); + const a = store.getResidentRecord({ name: "a" }); + const b = store.getResidentRecord({ name: "b" }); + expect(a.handle).not.toBe(b.handle); + expect(spies.onCreate).toHaveBeenCalledTimes(2); + expect(store.size).toBe(2); + }); + }); + + describe("invalidate / destroy", () => { + it("invalidate re-uploads but keeps the handle", () => { + const { store, spies } = makeStore(); + const src = { name: "a" }; + const first = store.getResidentRecord(src); + store.invalidate(src); + const second = store.getResidentRecord(src); + expect(second.handle).toBe(first.handle); + expect(spies.onCreate).toHaveBeenCalledTimes(1); + expect(spies.onUpload).toHaveBeenCalledTimes(2); + }); + + it("destroyTexture releases exactly once", () => { + const { store, spies } = makeStore(); + const src = { name: "a" }; + const rec = store.getResidentRecord(src); + expect(store.destroyTexture(src)).toBe(true); + expect(spies.onDestroy).toHaveBeenCalledWith(rec.handle, src); + // a second destroy must not double-free + expect(store.destroyTexture(src)).toBe(false); + expect(spies.onDestroy).toHaveBeenCalledTimes(1); + }); + + it("a destroyed source is rebuilt on the next resolve", () => { + const { store, spies } = makeStore(); + const src = { name: "a" }; + store.getResidentRecord(src); + store.destroyTexture(src); + store.getResidentRecord(src); + expect(spies.onCreate).toHaveBeenCalledTimes(2); + }); + + it("peek never resolves", () => { + const { store, spies } = makeStore(); + const src = { name: "a" }; + expect(store.peek(src)).toBeUndefined(); + expect(spies.onCreate).not.toHaveBeenCalled(); + store.getResidentRecord(src); + expect(store.peek(src)).toBeDefined(); + }); + }); + + describe("context loss", () => { + it("rebuilds from scratch after a loss", () => { + const { store, spies } = makeStore(); + const src = { name: "a" }; + const before = store.getResidentRecord(src).handle; + store.releaseAll(); + const after = store.getResidentRecord(src).handle; + expect(after).not.toBe(before); + expect(spies.onCreate).toHaveBeenCalledTimes(2); + expect(store.peek(src).generation).toBe(store.generation); + }); + + it("isCurrent rejects a record a caller cached across a loss", () => { + // the store drops its own records, but a caller holding a handle in + // its own array has no such protection, and binding a handle from a + // dead context fails silently rather than erroring + const { store } = makeStore(); + const stale = store.getResidentRecord({ name: "a" }); + expect(store.isCurrent(stale)).toBe(true); + store.releaseAll(); + expect(store.isCurrent(stale)).toBe(false); + expect(store.isCurrent(undefined)).toBe(false); + expect(store.isCurrent(store.getResidentRecord({ name: "b" }))).toBe( + true, + ); + }); + + it("does not try to free handles whose context already died", () => { + // the GPU objects went with the context; asking a dead context to + // release them is at best a no-op and at worst throws + const { store, spies } = makeStore(); + store.getResidentRecord({ name: "a" }); + store.releaseAll(); + expect(spies.onDestroy).not.toHaveBeenCalled(); + }); + + it("releases handles on an orderly teardown", () => { + const { store, spies } = makeStore(); + store.getResidentRecord({ name: "a" }); + store.getResidentRecord({ name: "b" }); + store.releaseAll(true); + expect(spies.onDestroy).toHaveBeenCalledTimes(2); + }); + + it("survives a SECOND loss without leaking the first round", () => { + // the failure this guards: responding to a restore by building a + // replacement store orphans everything the old one tracked, so the + // second loss leaks it all. Clearing in place must stay correct + // across repeated cycles — and single-loss tests pass right through + // this, which is why it needs its own case. + const { store, spies } = makeStore(); + for (let cycle = 0; cycle < 3; cycle++) { + store.getResidentRecord({ name: `a${cycle}` }); + store.getResidentRecord({ name: `b${cycle}` }); + expect(store.size).toBe(2); + store.releaseAll(); + expect(store.size).toBe(0); + } + expect(store.generation).toBe(3); + expect(spies.onCreate).toHaveBeenCalledTimes(6); + }); + + it("a stale record is dropped without being double-counted", () => { + const { store } = makeStore(); + const src = { name: "a" }; + store.getResidentRecord(src); + store.releaseAll(); + store.getResidentRecord(src); + expect(store.size).toBe(1); + }); + }); +}); From 1ef6aba744ee0c9f7bd3ca57bc241e52ace03cc7 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 16 Aug 2026 08:33:07 +0800 Subject: [PATCH 06/16] feat(webgl): sampler objects, so wrap/filter leave the texture object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GL bakes wrap and filter into the texture OBJECT. That is why one image drawn at two repeat modes needs two texture units — and, more expensively, two uploads and two storage allocations (#1448 fixed the correctness half and left the cost). WebGL 2 sampler objects move that state onto the unit instead, which is the separation the WebGPU backend already has between GPUTexture and GPUSampler. They are core in GLES 3.0 and unconditionally available since 20.0 dropped WebGL 1; the tree used none. `GLSamplerCache` dedupes by `(filter, wrapS, wrapT, mip)` — deliberately the same key as the WebGPU store's `getSampler`, so the two read alike. A scene has at most two filters times four repeat modes however many textures it loads, so the set stays tiny. Renderer-owned, shared by every batcher, and cleared rather than replaced on context loss. `uploadTexture` now binds the sampler for the variant it resolved. The texture parameters are still set at upload, so any path that binds no sampler behaves exactly as before — a bound sampler simply wins at sample time. This is groundwork: it makes it LEGAL for one texture to serve every variant of a source, which is the precondition for keying residency by source alone. The shared upload is not realized until TextureStore is adopted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- .../video/webgl/batchers/material_batcher.js | 50 ++++--- .../src/video/webgl/utils/samplercache.js | 99 ++++++++++++ .../melonjs/src/video/webgl/webgl_renderer.js | 14 ++ packages/melonjs/tests/samplercache.spec.js | 141 ++++++++++++++++++ 4 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 packages/melonjs/src/video/webgl/utils/samplercache.js create mode 100644 packages/melonjs/tests/samplercache.spec.js diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js index d61a6edc7..48488aba9 100644 --- a/packages/melonjs/src/video/webgl/batchers/material_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/material_batcher.js @@ -475,30 +475,32 @@ export class MaterialBatcher extends WebGLBatcher { const unit = this.renderer.cache.getUnit(texture, wrap); const texture2D = this.boundTextures[unit]; + // honor a resource-specified filter (e.g. tilemap index textures need + // NEAREST regardless of the global setting, or a Mesh's own + // `textureFilter`), otherwise fall back to the renderer-wide default + // (the `textureFilter` setting, decoupled from MSAA — see + // WebGLRenderer#getDefaultTextureFilter). Resolved before the branch + // because the sampler binding below needs it whether or not this call + // uploads. + let filter = + typeof texture.filter !== "undefined" + ? texture.filter + : this.renderer._glTextureFilter(); + // the STRING form ("nearest"/"linear") is what non-GL renderers store + // (the WebGPU texture store consumes it directly) — an atlas that met + // one of those first must still upload correctly here, so map it to the + // GL enum instead of feeding texParameteri a string + if (filter === "nearest") { + filter = this.gl.NEAREST; + } else if (filter === "linear") { + filter = this.gl.LINEAR; + } + if ( typeof texture2D === "undefined" || force || this.dirtyUnits.delete(unit) ) { - // honor a resource-specified filter (e.g. tilemap index textures - // need NEAREST regardless of the global setting, or a Mesh's own - // `textureFilter`), otherwise fall back to the renderer-wide default - // (the `textureFilter` setting, decoupled from MSAA — see - // WebGLRenderer#getDefaultTextureFilter) - let filter = - typeof texture.filter !== "undefined" - ? texture.filter - : this.renderer._glTextureFilter(); - // the STRING form ("nearest"/"linear") is what non-GL renderers - // store (the WebGPU texture store consumes it directly) — an - // atlas that met one of those first must still upload correctly - // here, so map it to the GL enum instead of feeding texParameteri - // a string - if (filter === "nearest") { - filter = this.gl.NEAREST; - } else if (filter === "linear") { - filter = this.gl.LINEAR; - } // `w`/`h` historically came from callers (e.g. `addQuad`) that // passed the DESTINATION quad size, not the texture size. That // broke the downstream POT check — a 480×1216 atlas drawn into @@ -539,6 +541,16 @@ export class MaterialBatcher extends WebGLBatcher { this.bindTexture2D(texture2D, unit, flush); } + // The variant (wrap + filter) rides a sampler object rather than the + // texture's own parameters, so one upload can serve a source drawn at + // several repeat modes. `createTexture2D` still sets the texture + // parameters too, which keeps any path that binds no sampler working + // exactly as before. + this.renderer.samplerCache.bind( + unit, + this.renderer.samplerCache.get(filter, wrap, false), + ); + return flush ? this.currentTextureUnit : unit; } } diff --git a/packages/melonjs/src/video/webgl/utils/samplercache.js b/packages/melonjs/src/video/webgl/utils/samplercache.js new file mode 100644 index 000000000..339743b5d --- /dev/null +++ b/packages/melonjs/src/video/webgl/utils/samplercache.js @@ -0,0 +1,99 @@ +/** + * WebGL 2 sampler objects, deduplicated by their state. + * + * GL bakes wrap and filter into the texture *object*, which is why the same + * image drawn at two repeat modes historically needed two texture units AND + * two uploads (#1448). Sampler objects — core in GLES 3.0, and unconditionally + * available since 20.0 dropped WebGL 1 — move that state out of the texture and + * onto the unit, exactly as the WebGPU backend already separates `GPUTexture` + * from `GPUSampler`. + * + * That separation is what lets texture residency be keyed by **source alone**: + * one upload serves every variant, and the variant rides the sampler. Slot + * assignment stays per `(source, variant)`, because a bound sampler applies to + * a unit and a unit carries one at a time. + * + * Deliberately mirrors `WebGPUTextureStore.getSampler(filter, repeat, mipmaps)` + * — same key, same dedup, so the two backends stay legible side by side. + * @ignore + */ +export class GLSamplerCache { + /** + * @param {WebGL2RenderingContext} gl - the owning context + * @ignore + */ + constructor(gl) { + this.gl = gl; + /** @type {Map} */ + this.samplers = new Map(); + } + + /** + * The sampler for a filter/wrap/mip combination, created once and reused. + * + * There are only a handful of live combinations in any scene — two filters + * times four repeat modes — so this Map stays tiny however many textures a + * game loads. + * @param {number} filter - `gl.NEAREST` or `gl.LINEAR` + * @param {string} [repeat="no-repeat"] - canvas-style repeat mode + * @param {boolean} [mipmap=false] - sample the mip chain (trilinear) + * @returns {WebGLSampler} the shared sampler + * @ignore + */ + get(filter, repeat = "no-repeat", mipmap = false) { + const gl = this.gl; + // same per-axis mapping as `createTexture2D` + const wrapS = /^repeat(-x)?$/.test(repeat) ? gl.REPEAT : gl.CLAMP_TO_EDGE; + const wrapT = /^repeat(-y)?$/.test(repeat) ? gl.REPEAT : gl.CLAMP_TO_EDGE; + // trilinear only over a linear-filtered chain — "nearest" opts out so + // crisp pixel-art keeps hard minification, matching the mesh path's rule + const mip = mipmap === true && filter === gl.LINEAR; + const key = `${filter}|${wrapS}|${wrapT}|${mip ? "mip" : "flat"}`; + + let sampler = this.samplers.get(key); + if (sampler === undefined) { + sampler = gl.createSampler(); + gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, wrapS); + gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrapT); + gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, filter); + gl.samplerParameteri( + sampler, + gl.TEXTURE_MIN_FILTER, + mip ? gl.LINEAR_MIPMAP_LINEAR : filter, + ); + this.samplers.set(key, sampler); + } + return sampler; + } + + /** + * Bind a sampler to a texture unit, or unbind with `null`. + * + * A bound sampler overrides the texture object's own parameters entirely, + * which is what makes one texture usable at several variants at once. + * @param {number} unit - the texture unit + * @param {WebGLSampler|null} sampler - the sampler, or `null` to fall back + * to the texture's own state + * @ignore + */ + bind(unit, sampler) { + this.gl.bindSampler(unit, sampler); + } + + /** + * Delete every sampler. Called on context loss — the GL objects died with + * the context, so this only drops our side of the bookkeeping and lets the + * next `get` mint fresh ones. + * @param {boolean} [destroy=false] - whether to `deleteSampler` first + * (orderly teardown; skip it for a lost context) + * @ignore + */ + releaseAll(destroy = false) { + if (destroy === true) { + for (const sampler of this.samplers.values()) { + this.gl.deleteSampler(sampler); + } + } + this.samplers.clear(); + } +} diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 10aa8862c..9cd66a089 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -35,6 +35,7 @@ import { createLightUniformScratch, packLights } from "./lighting/pack.ts"; import OrthogonalTMXLayerGPURenderer from "./renderers/tmxlayer/orthogonal.js"; import { resolveMaxTextures } from "./utils/maxtextures.js"; import { getMaxShaderPrecision } from "./utils/precision.js"; +import { GLSamplerCache } from "./utils/samplercache.js"; /** * additional import for TypeScript @@ -147,6 +148,16 @@ export default class WebGLRenderer extends Renderer { * @type {number} * @readonly */ + /** + * Sampler objects, deduplicated by state. GL bakes wrap/filter into the + * texture object; a bound sampler overrides that, so one texture can + * serve several variants at once — which is what lets residency be + * keyed by source alone. Renderer-owned so every batcher shares it. + * @type {GLSamplerCache} + * @ignore + */ + this.samplerCache = new GLSamplerCache(this.gl); + this.maxTextures = resolveMaxTextures( this.gl.getParameter(this.gl.MAX_TEXTURE_IMAGE_UNITS), this.settings.maxTextures, @@ -336,6 +347,9 @@ export default class WebGLRenderer extends Renderer { // stale per-source unit assignments — force re-upload on next draw this.cache.units.clear(); this.cache.usedUnits.clear(); + // the samplers died with the context; drop our side so the next + // `get` mints fresh ones against the restored context + this.samplerCache.releaseAll(); // the restored context is back at TEXTURE0 — invalidate the // shared active-unit tracking so the next bind re-issues it diff --git a/packages/melonjs/tests/samplercache.spec.js b/packages/melonjs/tests/samplercache.spec.js new file mode 100644 index 000000000..d3c1b63b9 --- /dev/null +++ b/packages/melonjs/tests/samplercache.spec.js @@ -0,0 +1,141 @@ +/** + * WebGL 2 sampler objects (#1585). + * + * GL bakes wrap and filter into the texture object, which is why one image at + * two repeat modes needed two units and two uploads. Sampler objects move that + * state onto the unit — the same separation the WebGPU backend has between + * `GPUTexture` and `GPUSampler` — which is what allows texture residency to be + * keyed by source alone. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { boot, game, WebGLRenderer } from "../src/index.js"; +import { GLSamplerCache } from "../src/video/webgl/utils/samplercache.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; + +describe("GLSamplerCache", () => { + let renderer; + let gl; + + beforeAll(async () => { + await boot(); + try { + await getWebGLRenderer(64, 64); + } catch { + // genuine WebGL absence — tests skip below + } + if (game.renderer instanceof WebGLRenderer) { + renderer = game.renderer; + gl = renderer.gl; + } + }); + + afterAll(() => { + try { + releaseWebGLRenderer(); + } catch { + // ignore + } + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + it("dedupes by state, so the set stays tiny", (ctx) => { + requireWebGL(ctx); + const cache = new GLSamplerCache(gl); + const a = cache.get(gl.LINEAR, "no-repeat"); + const b = cache.get(gl.LINEAR, "no-repeat"); + expect(b).toBe(a); + // a scene has two filters times four repeat modes at most, however + // many textures it loads + cache.get(gl.NEAREST, "no-repeat"); + cache.get(gl.LINEAR, "repeat"); + expect(cache.samplers.size).toBe(3); + cache.releaseAll(true); + }); + + it("distinguishes every axis that matters", (ctx) => { + requireWebGL(ctx); + const cache = new GLSamplerCache(gl); + const base = cache.get(gl.LINEAR, "no-repeat", false); + expect(cache.get(gl.NEAREST, "no-repeat", false)).not.toBe(base); + expect(cache.get(gl.LINEAR, "repeat", false)).not.toBe(base); + expect(cache.get(gl.LINEAR, "repeat-x", false)).not.toBe(base); + expect(cache.get(gl.LINEAR, "repeat-y", false)).not.toBe(base); + expect(cache.get(gl.LINEAR, "no-repeat", true)).not.toBe(base); + cache.releaseAll(true); + }); + + it("maps repeat per axis, matching createTexture2D", (ctx) => { + requireWebGL(ctx); + const cache = new GLSamplerCache(gl); + const x = cache.get(gl.LINEAR, "repeat-x"); + expect(gl.getSamplerParameter(x, gl.TEXTURE_WRAP_S)).toBe(gl.REPEAT); + expect(gl.getSamplerParameter(x, gl.TEXTURE_WRAP_T)).toBe(gl.CLAMP_TO_EDGE); + const y = cache.get(gl.LINEAR, "repeat-y"); + expect(gl.getSamplerParameter(y, gl.TEXTURE_WRAP_S)).toBe(gl.CLAMP_TO_EDGE); + expect(gl.getSamplerParameter(y, gl.TEXTURE_WRAP_T)).toBe(gl.REPEAT); + cache.releaseAll(true); + }); + + it("only goes trilinear over a linear chain", (ctx) => { + requireWebGL(ctx); + // "nearest" opts out of mip filtering so crisp pixel-art keeps hard + // minification — the same rule the mesh path uses + const cache = new GLSamplerCache(gl); + const linear = cache.get(gl.LINEAR, "no-repeat", true); + expect(gl.getSamplerParameter(linear, gl.TEXTURE_MIN_FILTER)).toBe( + gl.LINEAR_MIPMAP_LINEAR, + ); + const nearest = cache.get(gl.NEAREST, "no-repeat", true); + expect(gl.getSamplerParameter(nearest, gl.TEXTURE_MIN_FILTER)).toBe( + gl.NEAREST, + ); + cache.releaseAll(true); + }); + + it("a bound sampler overrides the texture's own parameters", (ctx) => { + requireWebGL(ctx); + // this is the property the whole approach rests on: one texture can be + // sampled at several variants at once, so residency need not be keyed + // by variant + const cache = new GLSamplerCache(gl); + const tex = gl.createTexture(); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + + cache.bind(0, cache.get(gl.LINEAR, "repeat")); + expect(gl.getParameter(gl.SAMPLER_BINDING)).not.toBeNull(); + // the texture still reports its own state — the sampler simply wins at + // sample time, which is why the texture parameters can stay as a + // fallback for any path that binds no sampler + expect(gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S)).toBe( + gl.CLAMP_TO_EDGE, + ); + + cache.bind(0, null); + expect(gl.getParameter(gl.SAMPLER_BINDING)).toBeNull(); + gl.deleteTexture(tex); + cache.releaseAll(true); + }); + + it("the renderer owns one, and drops it on context loss", (ctx) => { + requireWebGL(ctx); + // renderer-owned so every batcher shares it; cleared rather than + // replaced, so a second loss cannot orphan the first round + expect(renderer.samplerCache).toBeInstanceOf(GLSamplerCache); + renderer.samplerCache.get(gl.LINEAR, "no-repeat"); + expect(renderer.samplerCache.samplers.size).toBeGreaterThan(0); + const identity = renderer.samplerCache; + renderer.samplerCache.releaseAll(); + expect(renderer.samplerCache).toBe(identity); + expect(renderer.samplerCache.samplers.size).toBe(0); + }); +}); From 90554ae7e4f59849fcf80579c42c673ce6acabe1 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 16 Aug 2026 08:56:43 +0800 Subject: [PATCH 07/16] fix(video): a texture-cache overflow no longer re-uploads every texture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Past the batching limit a scene did not re-BIND its textures, it re-BUILT them: 542 createTexture + texStorage2D + texSubImage2D + generateMipmap calls per frame on a 512-quad scene, with the displaced handles dropped unreferenced rather than freed. Frame time ~0.10 ms -> ~2-3 ms. The cause was one over-loaded condition. `uploadTexture` asked a single question — is `boundTextures[unit]` set? — and used it to decide BOTH whether to bind and whether to upload. Since that array was the only reference to the GL handle, a cache reset destroyed the texture, and the next draw rebuilt it from scratch. Those are two independent decisions and they are now written as two: whether this unit already holds the texture decides a bind; whether the source's content is current decides an upload. Residency moves to the renderer-owned `TextureStore`, keyed by SOURCE, so a texture that merely moves units costs a bind. Renderer-owned rather than batcher-owned deliberately: both batchers re-run `init()` on context restore, so a batcher-owned store would be REPLACED there, orphaning every handle it tracked and leaking them on the next loss. It is cleared, never reconstructed. Also re-keys the WebGPU store from unit to source. It was safe there only because that backend builds its cache with no capacity, so units are never recycled — the same coupling, merely unreachable. One consequence: two sources that happened to share a unit no longer share a GPU texture, which was the clobber-then-re-upload mechanism in miniature. `reset()` releases through the store rather than by walking the per-unit array, which would now miss unassigned textures and double-free assigned ones; the lit batcher frees its own normal-map handles for the same reason. Verified by GL-call diff rather than timing: an overflowing frame must add zero create/upload/mipmap calls over one that fits, and no deleteTexture either — trading an upload storm for a delete storm would be no better. Restoring the old conflated condition makes that test report 231 uploads. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- packages/melonjs/CHANGELOG.md | 1 + .../melonjs/src/video/gpu/texturestore.js | 18 ++- .../video/webgl/batchers/lit_quad_batcher.js | 9 ++ .../video/webgl/batchers/material_batcher.js | 107 +++++++------ .../melonjs/src/video/webgl/webgl_renderer.js | 38 ++++- .../melonjs/src/video/webgpu/texture/store.js | 36 +++-- .../melonjs/tests/mesh-texture-repeat.spec.js | 18 ++- .../melonjs/tests/texture-reupload.spec.js | 149 ++++++++++++++++++ .../melonjs/tests/video-upload-gating.spec.js | 13 +- .../tests/webgpu_texture_store.spec.js | 16 +- 10 files changed, 316 insertions(+), 89 deletions(-) create mode 100644 packages/melonjs/tests/texture-reupload.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 92751a8ac..3343a9838 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -66,6 +66,7 @@ The old path scales linearly with vertex count; the new one is flat, because no - **the cost of `antiAlias: true` under post effects, quantified** ([#1556](https://github.com/melonjs/melonJS/issues/1556)) — MSAA composing through effect chains (see *Added*) is paid for in memory and bandwidth, and the price is worth knowing. Arithmetic, not measurement: a 4× capture target keeps 4 color + 4 depth-stencil samples per pixel next to its 1× resolve texture — roughly **28 extra bytes per pixel on WebGL (~55 MB of GPU memory at 1080p)** and **~16 bytes per pixel on WebGPU (~32 MB at 1080p)**, where the multisampled depth attachment is shared with the canvas rather than per-target; both scale linearly with resolution. Per frame it adds one resolve blit per effect bracket, and draws inside the bracket write up to 4 samples per covered pixel — bandwidth, not shading cost, since fragment shaders still run once per pixel under MSAA. Only scene **capture** targets pay any of this (ping-pong intermediates stay 1×), and with `antiAlias: false` — the default — no multisampled storage exists at all, so nothing changes ### Fixed +- **A texture-cache overflow re-created and re-uploaded every texture, once per draw** ([#1585](https://github.com/melonjs/melonJS/issues/1585)) — past the multi-texture batching limit, a WebGL scene did not re-*bind* its textures, it re-*built* them: measured at 542 `createTexture` + `texStorage2D` + `texSubImage2D` + `generateMipmap` calls per frame on a 512-quad scene, with the displaced handles left to GC rather than freed. The GL handle was reachable only through a batcher's per-unit array, so dropping a unit assignment destroyed the texture. Residency is now keyed by **source** and owned by the renderer, so a texture that merely moves units costs a bind — and wrap/filter moved onto WebGL 2 sampler objects, so one upload serves a source drawn at several repeat modes instead of one per mode. Frame time for the overflowing case dropped from ~2-3 ms to the same ~0.1 ms a non-overflowing frame costs - **A `ShaderEffect` extra sampler could silently corrupt normal-map lighting** ([#1585](https://github.com/melonjs/melonJS/issues/1585)) — `ShaderEffect.setTexture` claims texture units counting down from the top, and `LitQuadBatcher` reserved a fixed upper range for normal maps on first activation. An effect that claimed *before* the first lit sprite drew landed inside that range, and the two aliased: the normal map sampled the effect's texture, giving wrong lighting with no error and no warning. Normal maps now allocate from the shared pool, which respects reservations, so the overlap cannot occur - **destroyed renderers stayed subscribed to global events forever** — `WebGLRenderer` subscribed to `GAME_RESET`, `ONCONTEXT_RESTORED` and `CANVAS_ONRESIZE`, and `CanvasRenderer` to `GAME_RESET`, all as **inline anonymous handlers** — which cannot be passed to `off()`, so nothing could ever unregister them. `CanvasRenderer` had no `destroy()` at all, inheriting the base no-op. Two consequences, both silent: a destroyed renderer kept reacting to those events, and — worse — each handler closes over the renderer, so the subscription pinned the renderer, its batchers and its GPU objects against garbage collection. Releasing the GL context alone did not help, because the JS graph was still reachable from the event bus. The same shape was in the scene graph: the **root `Container`** subscribed to `CANVAS_ONRESIZE` with an inline arrow, and **`World`** to `GAME_RESET` (with a context) and `LEVEL_LOADED` (inline), none of them ever removed — and `World` had no `destroy()` of its own, so a torn-down world kept resetting itself and clearing a broadphase nobody read. All of these are now per-instance fields and `destroy()` unregisters them, matching what the WebGPU backend already did. Any application that tears down and rebuilds — an SPA moving between scenes, a level reload — stops accumulating them - **`Application.destroy()` leaked the WebGL context** — teardown deleted every GL object the renderer owned and removed the canvas from the DOM, but never handed back the **context** itself. A canvas keeps its context until the canvas is garbage-collected, which is non-deterministic and routinely delayed, so each destroyed application left a live context behind. Browsers cap how many they keep — around 16 on Chromium — and force-lose the oldest past that, which means a long-lived page that builds and tears down several applications accumulates dead-but-unfreed contexts until an unrelated later `getContext` stalls or comes back already lost. That hits any single-page app that moves between scenes or unmounts a game view (the examples gallery does exactly this on every navigation), and it was also making unrelated test suites time out in CI. `destroy()` now releases the context through `WEBGL_lose_context`. It stays idempotent, and since `destroy()` is already terminal — `Application.init()` refuses to run again afterwards — losing the context forecloses nothing that was previously possible. Renderers whose driver does not expose the extension are unaffected diff --git a/packages/melonjs/src/video/gpu/texturestore.js b/packages/melonjs/src/video/gpu/texturestore.js index 9ca163032..4ee9ee638 100644 --- a/packages/melonjs/src/video/gpu/texturestore.js +++ b/packages/melonjs/src/video/gpu/texturestore.js @@ -24,9 +24,9 @@ export class TextureStore { /** * @param {object} [options] - store configuration - * @param {Function} [options.onCreate] - `(source) => handle`, allocate the - * backing GPU texture for a source seen for the first time - * @param {Function} [options.onUpload] - `(handle, source, record) => handle`, + * @param {Function} [options.onCreate] - `(source, options) => handle`, allocate + * the backing GPU texture for a source seen for the first time + * @param {Function} [options.onUpload] - `(handle, source, record, options) => handle`, * push the source's current content. May return a *replacement* handle when * the shape changed and the old one cannot be respecified (immutable * storage); returning nothing keeps the existing handle. @@ -73,12 +73,13 @@ export class TextureStore { * the record, with `uploaded` reporting whether this call did GPU work * @ignore */ - getResidentRecord(source, { version = 0, force = false } = {}) { + getResidentRecord(source, options = {}) { + const { version = 0, force = false } = options; let record = this.records.get(source); if (record === undefined) { record = { - handle: this.onCreate?.(source), + handle: this.onCreate?.(source, options), // deliberately unmatchable, so a freshly created record always // takes the upload path once — a record that exists but was // never filled is the failure this rules out @@ -89,7 +90,12 @@ export class TextureStore { } if (force || record.version !== version) { - const replacement = this.onUpload?.(record.handle, source, record); + const replacement = this.onUpload?.( + record.handle, + source, + record, + options, + ); if (replacement !== undefined && replacement !== null) { // the backend could not respecify in place and swapped handles record.handle = replacement; diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index fedd85d6d..2ab8e6596 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -264,9 +264,18 @@ export default class LitQuadBatcher extends QuadBatcher { // already disposed by the time we get here. We just need to // drop the JS references and re-bind the per-frame uniforms. super.reset(); + // `MaterialBatcher.reset` releases the renderer's colour store; normal + // maps live outside it, in this batcher's own map, so their GL textures + // are ours to delete. Before #1585 they were freed incidentally, by + // `reset` walking `boundTextures` — which stopped being the sole + // reference to a handle, so freeing has to be explicit now. + for (const cached of this.normalMapTextures.values()) { + this.gl.deleteTexture(cached.tex); + } this.boundNormalMaps.fill(null); this.boundNormalVersions.fill(-1); this.normalMapTextures.clear(); + this.normalUnits.clear(); this._lightCount = 0; // zero the header (count + ambient) and push it, so a reset mid-scene // leaves the shader reading "no lights" rather than the previous diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js index 48488aba9..e25bfffb6 100644 --- a/packages/melonjs/src/video/webgl/batchers/material_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/material_batcher.js @@ -146,12 +146,12 @@ export class MaterialBatcher extends WebGLBatcher { reset() { super.reset(); - for (let i = 0; i < this.renderer.maxTextures; i++) { - const texture2D = this.getTexture2D(i); - if (typeof texture2D !== "undefined") { - this.deleteTexture2D(texture2D); - } - } + // The store owns every colour-texture handle since #1585, so releasing + // them means asking it — walking `boundTextures` would miss any texture + // not currently assigned a unit, and would double-free the ones that are. + this.renderer.textureStore?.releaseAll(true); + this.boundTextures.length = 0; + this.dirtyUnits.clear(); this.currentTextureUnit = -1; this.currentSamplerUnit = -1; } @@ -473,8 +473,6 @@ export class MaterialBatcher extends WebGLBatcher { uploadTexture(texture, w, h, force = false, flush = true, repeat) { const wrap = typeof repeat === "string" ? repeat : texture.repeat; const unit = this.renderer.cache.getUnit(texture, wrap); - const texture2D = this.boundTextures[unit]; - // honor a resource-specified filter (e.g. tilemap index textures need // NEAREST regardless of the global setting, or a Mesh's own // `textureFilter`), otherwise fall back to the renderer-wide default @@ -496,50 +494,55 @@ export class MaterialBatcher extends WebGLBatcher { filter = this.gl.LINEAR; } - if ( - typeof texture2D === "undefined" || - force || - this.dirtyUnits.delete(unit) - ) { - // `w`/`h` historically came from callers (e.g. `addQuad`) that - // passed the DESTINATION quad size, not the texture size. That - // broke the downstream POT check — a 480×1216 atlas drawn into - // a 256×256 quad reported `isPOT=true` and tripped - // `gl.generateMipmap` POT checks historically. Always derive the actual - // texture dimensions from the source, falling back to the - // passed-in values only when the source has none. - const source = texture.getTexture(); - // `HTMLVideoElement` exposes its real pixel dimensions through - // `videoWidth`/`videoHeight`; `width`/`height` default to 0 - // until the element is explicitly sized. Prefer the regular - // width/height when non-zero, otherwise fall back to the - // video-specific properties, and finally to the caller-supplied - // w/h for sources that have neither. - const texW = source.width || source.videoWidth || w; - const texH = source.height || source.videoHeight || h; - // a video with no decoded frame yet (readyState < HAVE_CURRENT_DATA) - // has nothing to upload — texImage2D on it is browser-dependent - // (an exception on some engines, an empty upload plus a GL error - // on others). Allocate a blank texture instead and skip the copy; - // the video path force-re-uploads every frame, so content lands - // the moment a frame exists — same contract as the WebGPU store. - const frameless = - typeof source.videoWidth !== "undefined" && source.readyState < 2; - this.createTexture2D( - unit, - frameless ? null : source, - filter, - wrap, - texW, - texH, - texture.premultipliedAlpha, - undefined, - texture2D, - flush, - ); - } else { - this.bindTexture2D(texture2D, unit, flush); - } + // TWO independent decisions, not one (#1585). Whether this unit already + // holds the texture decides a BIND; whether the source's content is + // current decides an UPLOAD. Conflating them — which is what reading + // `boundTextures[unit]` alone did — meant a texture that merely moved + // units was rebuilt from scratch, because the handle was reachable only + // through that array and a cache reset cleared it. + const source = texture.getTexture(); + // `HTMLVideoElement` exposes its real pixel dimensions through + // `videoWidth`/`videoHeight`; `width`/`height` default to 0 until the + // element is explicitly sized. Prefer the regular width/height when + // non-zero, otherwise fall back to the video-specific properties, and + // finally to the caller-supplied w/h for sources that have neither. + const texW = source.width || source.videoWidth || w; + const texH = source.height || source.videoHeight || h; + // a video with no decoded frame yet (readyState < HAVE_CURRENT_DATA) + // has nothing to upload — texImage2D on it is browser-dependent (an + // exception on some engines, an empty upload plus a GL error on + // others). Allocate a blank texture instead and skip the copy; the + // video path force-re-uploads every frame, so content lands the moment + // a frame exists — same contract as the WebGPU store. + const frameless = + typeof source.videoWidth !== "undefined" && source.readyState < 2; + + // `dirtyUnits` is a per-unit invalidation (something bound over this + // unit behind our back), so it forces a re-upload for this call only + const dirty = this.dirtyUnits.delete(unit); + const record = this.renderer.textureStore.getResidentRecord(source, { + version: source.version ?? 0, + force: force === true || dirty === true, + upload: (handle) => { + return this.createTexture2D( + unit, + frameless ? null : source, + filter, + wrap, + texW, + texH, + texture.premultipliedAlpha, + undefined, + handle, + flush, + ); + }, + }); + + // bind unconditionally — cheap, and the only thing that guarantees this + // unit really holds this texture. `bindTexture2D` no-ops when its + // bookkeeping already agrees. + this.bindTexture2D(record.handle, unit, flush); // The variant (wrap + filter) rides a sampler object rather than the // texture's own parameters, so one upload can serve a source drawn at diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 9cd66a089..a8c206071 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -13,6 +13,7 @@ import { RENDER_TARGET_CHANGED, } from "../../system/event.ts"; import RadialGradientEffect from "../effects/radialGradient.js"; +import { TextureStore } from "./../gpu/texturestore.js"; import { Gradient } from "../gradient.js"; import Renderer from "./../renderer.js"; import RenderTargetPool from "../rendertarget/render_target_pool.js"; @@ -158,6 +159,36 @@ export default class WebGLRenderer extends Renderer { */ this.samplerCache = new GLSamplerCache(this.gl); + /** + * Texture residency, keyed by SOURCE. The GL handle used to be + * reachable only through a batcher's per-unit array, so dropping a unit + * assignment destroyed the texture and the next draw rebuilt it from + * scratch — a full re-upload and mip regeneration per quad once past + * the batching limit. Owning it here, per renderer, means a texture + * that merely moves units costs a bind. + * + * Renderer-owned rather than batcher-owned on purpose: `init()` re-runs + * on context restore, so a batcher-owned store would be REPLACED there, + * orphaning every handle it tracked and leaking them on the next loss. + * @type {TextureStore} + * @ignore + */ + this.textureStore = new TextureStore({ + onCreate: () => { + return this.gl.createTexture(); + }, + // the caller owns the upload — it is the only place the unit, + // filter, wrap and dimensions are all known. It returns the handle, + // which may DIFFER from the one passed in: immutable storage cannot + // be respecified, so a shape change swaps the object outright. + onUpload: (handle, source, record, options) => { + return options.upload(handle); + }, + onDestroy: (handle) => { + this.gl.deleteTexture(handle); + }, + }); + this.maxTextures = resolveMaxTextures( this.gl.getParameter(this.gl.MAX_TEXTURE_IMAGE_UNITS), this.settings.maxTextures, @@ -347,9 +378,12 @@ export default class WebGLRenderer extends Renderer { // stale per-source unit assignments — force re-upload on next draw this.cache.units.clear(); this.cache.usedUnits.clear(); - // the samplers died with the context; drop our side so the next - // `get` mints fresh ones against the restored context + // the samplers and textures died with the context; drop our side + // so the next resolve mints fresh ones against the restored one. + // CLEARED, never replaced: rebuilding these would orphan every + // handle they tracked and leak it all on the next loss. this.samplerCache.releaseAll(); + this.textureStore.releaseAll(); // the restored context is back at TEXTURE0 — invalidate the // shared active-unit tracking so the next bind re-issues it diff --git a/packages/melonjs/src/video/webgpu/texture/store.js b/packages/melonjs/src/video/webgpu/texture/store.js index 3a5b9c2ab..cc7e89230 100644 --- a/packages/melonjs/src/video/webgpu/texture/store.js +++ b/packages/melonjs/src/video/webgpu/texture/store.js @@ -95,9 +95,14 @@ export default class WebGPUTextureStore { typeof options.repeat === "string" ? options.repeat : (texture.repeat ?? "no-repeat"); - const unit = this.renderer.cache.getUnit(texture, wrap); - let record = this.records.get(unit); const source = texture.getTexture(); + // Keyed by SOURCE, not by texture unit (#1585). Unit-keying worked here + // only because this backend builds its TextureCache with no capacity, + // so units are never recycled — the same coupling that made the WebGL + // path destroy a texture whenever its unit was reassigned. Keying by + // source removes the dependence entirely, and with it the need to + // listen for a unit-assignment reset. + let record = this.records.get(source); // A unit number is not a stable identity: the TextureCache recycles // units when sources are unloaded (stage switches free the loading @@ -159,7 +164,7 @@ export default class WebGPUTextureStore { compressed: true, bindGroupBySampler: new Map(), }; - this.records.set(unit, record); + this.records.set(source, record); } record.frameId = this.renderer.frameId; this.lastRecord = record; @@ -229,7 +234,7 @@ export default class WebGPUTextureStore { mipLevelCount, bindGroupBySampler: new Map(), }; - this.records.set(unit, record); + this.records.set(source, record); } else { // same-size unit reuse, not yet drawn this frame (recycled // unit, or a video frame): keep the resident texture + bind @@ -414,10 +419,8 @@ export default class WebGPUTextureStore { }; const wrap = wrapFor(texture); const alphaWrap = wrapFor(alphaTexture); - const record = this.records.get(this.renderer.cache.getUnit(texture, wrap)); - const alphaRecord = this.records.get( - this.renderer.cache.getUnit(alphaTexture, alphaWrap), - ); + const record = this.records.get(texture.getTexture()); + const alphaRecord = this.records.get(alphaTexture.getTexture()); if (record === undefined || alphaRecord === undefined) { // a source that failed to become resident — the caller keeps its // previous binding rather than recording a draw against nothing @@ -535,13 +538,16 @@ export default class WebGPUTextureStore { * @param {object} texture - a TextureAtlas */ destroyTexture(texture) { - const units = this.renderer.cache.peekAllUnits?.(texture) ?? []; - for (const unit of units) { - const record = this.records.get(unit); - if (record) { - this.retire(record.texture); - this.records.delete(unit); - } + // one record per source now, whatever wrap modes it was sampled at — + // the per-unit sweep this used to do exists only in the unit-keyed world + const source = + typeof texture?.getTexture === "function" + ? texture.getTexture() + : texture; + const record = this.records.get(source); + if (record !== undefined) { + this.retire(record.texture); + this.records.delete(source); } } diff --git a/packages/melonjs/tests/mesh-texture-repeat.spec.js b/packages/melonjs/tests/mesh-texture-repeat.spec.js index 217102c64..f587ed24e 100644 --- a/packages/melonjs/tests/mesh-texture-repeat.spec.js +++ b/packages/melonjs/tests/mesh-texture-repeat.spec.js @@ -121,20 +121,25 @@ describe("Mesh textureRepeat vs shared TextureAtlas (issue #1503)", () => { const meshRepeat = new Mesh(0, 0, quadSettings(image, "repeat")); const meshClamp = new Mesh(0, 0, quadSettings(image, "no-repeat")); - // capture the TEXTURE_WRAP_S values actually sent to the GPU + // capture the TEXTURE_WRAP_S values actually sent to the GPU. Since + // #1585 wrap lives on SAMPLER objects rather than the texture, so watch + // samplerParameteri — one shared texture now serves both variants, and + // asserting on texParameteri would only ever see whichever wrap + // happened to upload first. const wrapS = []; - const origTexParameteri = gl.texParameteri.bind(gl); - gl.texParameteri = (target, pname, value) => { + const origSamplerParameteri = gl.samplerParameteri.bind(gl); + gl.samplerParameteri = (sampler, pname, value) => { if (pname === gl.TEXTURE_WRAP_S) { wrapS.push(value); } - return origTexParameteri(target, pname, value); + return origSamplerParameteri(sampler, pname, value); }; try { + renderer.samplerCache.releaseAll(true); renderer.drawMesh(meshRepeat); renderer.drawMesh(meshClamp); } finally { - gl.texParameteri = origTexParameteri; + gl.samplerParameteri = origSamplerParameteri; } // each wrap mode got its own (source, repeat) texture unit … @@ -148,6 +153,9 @@ describe("Mesh textureRepeat vs shared TextureAtlas (issue #1503)", () => { // was ever uploaded) expect(wrapS).toContain(gl.REPEAT); expect(wrapS).toContain(gl.CLAMP_TO_EDGE); + // the two variants share ONE texture now — the upload is paid once and + // the variant rides the sampler, which is the point of the split + expect(renderer.textureStore.size).toBe(1); }); it("pixel readback: repeat tiles and no-repeat clamps on the same source image", (ctx) => { diff --git a/packages/melonjs/tests/texture-reupload.spec.js b/packages/melonjs/tests/texture-reupload.spec.js new file mode 100644 index 000000000..712084755 --- /dev/null +++ b/packages/melonjs/tests/texture-reupload.spec.js @@ -0,0 +1,149 @@ +/** + * A texture-cache overflow must not re-upload textures (#1585). + * + * The GL handle used to be reachable only through a batcher's `boundTextures` + * array, so dropping a unit assignment destroyed it and the next draw rebuilt + * the texture from scratch. Past the batching limit that meant a full + * `createTexture` + `texStorage2D` + `texSubImage2D` + `generateMipmap` PER + * QUAD, every frame — measured at 542 of each per frame on a 512-quad scene. + * + * Residency is keyed by source now and survives a unit reassignment, so an + * overflow costs a flush and some re-binds. + * + * These assertions count GL calls, which is exact — no timing, so nothing here + * is flaky. Frame time was the symptom; call counts are the mechanism. + */ +import { beforeAll, describe, expect, it } from "vitest"; +import { Application, boot, video } from "../src/index.js"; + +const SIZE = 128; +const QUADS = 240; + +describe("texture re-upload on overflow", () => { + let app; + let renderer; + let gl; + let images; + let pool; + + beforeAll(async () => { + await boot(); + app = new Application(SIZE, SIZE, { renderer: video.WEBGL }); + await app.init(); + renderer = app.renderer; + gl = renderer.gl; + pool = renderer.batchers.get("quad").maxBatchTextures; + // one more distinct source than the pool can hold, so the set cannot + // stay resident and the allocator must recycle + images = Array.from({ length: pool + 1 }, (_, i) => { + const c = document.createElement("canvas"); + c.width = 16; + c.height = 16; + const x = c.getContext("2d"); + x.fillStyle = `hsl(${(i * 37) % 360},80%,55%)`; + x.fillRect(0, 0, 16, 16); + return c; + }); + }); + + const requireWebGL = (ctx) => { + if (!renderer?.gl) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + /** GL calls issued while drawing one frame over `n` distinct textures */ + const callsForFrame = (n) => { + const watched = [ + "createTexture", + "texStorage2D", + "texSubImage2D", + "texImage2D", + "generateMipmap", + "deleteTexture", + "drawElements", + ]; + const counts = {}; + const real = {}; + const draw = () => { + for (let q = 0; q < QUADS; q++) { + renderer.drawImage(images[q % n], 0, 0, 16, 16, 0, 0, 16, 16); + } + renderer.flush(); + }; + + renderer.cache.resetUnitAssignments(); + draw(); // warm: first-sight uploads happen here, not in the measurement + gl.finish(); + + for (const name of watched) { + counts[name] = 0; + real[name] = gl[name].bind(gl); + gl[name] = (...a) => { + counts[name]++; + return real[name](...a); + }; + } + draw(); + gl.finish(); + for (const name of watched) { + gl[name] = real[name]; + } + return counts; + }; + + it("an overflowing frame uploads nothing", (ctx) => { + requireWebGL(ctx); + const over = callsForFrame(pool + 1); + + // THE assertion. Every one of these was ~QUADS before the fix. + expect(over.createTexture).toBe(0); + expect(over.texStorage2D).toBe(0); + expect(over.texSubImage2D).toBe(0); + expect(over.texImage2D).toBe(0); + expect(over.generateMipmap).toBe(0); + }); + + it("costs no more uploads than a frame that fits", (ctx) => { + requireWebGL(ctx); + // stated relatively too, so the test still means something if the + // steady state ever legitimately uploads (an animated source, say) + const fits = callsForFrame(pool); + const over = callsForFrame(pool + 1); + for (const key of ["createTexture", "texSubImage2D", "generateMipmap"]) { + expect(over[key]).toBe(fits[key]); + } + // the overflow is real — it still costs extra draw calls, which is the + // part only more slots or texture arrays can remove + expect(over.drawElements).toBeGreaterThan(fits.drawElements); + }); + + it("does not trade the upload storm for a delete storm", (ctx) => { + requireWebGL(ctx); + // the displaced handles used to be dropped unreferenced and left to GC; + // freeing them per overflow instead would be just as bad + const over = callsForFrame(pool + 1); + expect(over.deleteTexture).toBe(0); + }); + + it("a source keeps ONE handle across a unit reassignment", (ctx) => { + requireWebGL(ctx); + const source = images[0]; + renderer.cache.resetUnitAssignments(); + renderer.drawImage(source, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.flush(); + const before = renderer.textureStore.peek( + renderer.cache.get(source).getTexture(), + ); + + renderer.cache.resetUnitAssignments(); + renderer.drawImage(source, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.flush(); + const after = renderer.textureStore.peek( + renderer.cache.get(source).getTexture(), + ); + + expect(after).toBeDefined(); + expect(after.handle).toBe(before.handle); + }); +}); diff --git a/packages/melonjs/tests/video-upload-gating.spec.js b/packages/melonjs/tests/video-upload-gating.spec.js index 1e8768b29..3cb36ba96 100644 --- a/packages/melonjs/tests/video-upload-gating.spec.js +++ b/packages/melonjs/tests/video-upload-gating.spec.js @@ -104,19 +104,22 @@ describe("Video texture upload gating (WebGL)", () => { expect(uploads).toBe(2); }); - it("a texture-unit cache reset re-uploads even with an unchanged version", (ctx) => { + it("a texture-unit cache reset does NOT re-upload an unchanged source", (ctx) => { requireWebGL(ctx); const src = makeVideoLikeCanvas(32, 32); src.version = 0; const uploads = countUploads(() => { renderer.drawImage(src, 0, 0, 32, 32, 0, 0, 32, 32); - // eviction safety: the version gate must not survive a unit - // reassignment — the batcher's per-unit bookkeeping was cleared, - // so the (recreated) GL texture needs actual pixels again + // Inverted by #1585, and this is the whole point: a unit + // reassignment used to destroy the texture handle (it was reachable + // only through the batcher's per-unit array), so the next draw had + // to re-upload real pixels. Residency is keyed by source now and + // survives the reset, so the content is still on the GPU and still + // current — the reset costs a re-BIND, not a re-upload. renderer.cache.resetUnitAssignments(); renderer.drawImage(src, 0, 0, 32, 32, 0, 0, 32, 32); }); - expect(uploads).toBe(2); + expect(uploads).toBe(1); }); }); diff --git a/packages/melonjs/tests/webgpu_texture_store.spec.js b/packages/melonjs/tests/webgpu_texture_store.spec.js index caf05ad2b..b9f7895ad 100644 --- a/packages/melonjs/tests/webgpu_texture_store.spec.js +++ b/packages/melonjs/tests/webgpu_texture_store.spec.js @@ -138,19 +138,27 @@ describe("WebGPUTextureStore", () => { expect(createdTextures[0].size).toEqual([320, 240]); }); - it("a recycled unit serving a same-size NEW source re-uploads in place (next frame)", () => { + it("two same-size sources on one unit stay independent", () => { + // Inverted by #1585. Records are keyed by SOURCE now, not by texture + // unit, so a recycled unit cannot make two sources share a GPU texture. + // That sharing was the clobber-then-re-upload mechanism in disguise: + // the second source overwrote the first's pixels in place, and drawing + // the first again had to upload it all over. On this backend it was + // unreachable anyway — the cache is built with no capacity here, so + // units are never recycled. const first = makeAtlas(makeSource(32, 32), { unit: 3 }); store.getBinding(first); - // stage switch: unit 3 recycled for a different same-size source renderer.frameId = 2; const second = makeAtlas(makeSource(32, 32), { unit: 3 }); store.getBinding(second); - // resident texture reused, but the new pixels were uploaded - expect(createdTextures).toHaveLength(1); + expect(createdTextures).toHaveLength(2); expect(uploads).toHaveLength(2); expect(uploads[1].source).toBe(second.getTexture()); + // and the first is untouched — re-resolving it uploads nothing + store.getBinding(first); + expect(uploads).toHaveLength(2); }); it("a recycled unit with a DIFFERENT-size source gets a fresh texture", () => { From b2da3b6ab9fde9b7ed2e8b0e791f122eb60f1196 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 16 Aug 2026 10:11:37 +0800 Subject: [PATCH 08/16] fix(webgl): deleteTexture2D freed handles the store still tracked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditing the new API for unwired surface turned up a real defect and two untested paths. `deleteTexture2D` deleted the GL texture directly, but the store owns that handle now — so the record outlived the texture and the next resolve for that source handed a DEAD handle to a draw. GL does not error on a deleted texture, it samples black. It now frees through `destroyTexture`, and invalidates the per-unit beliefs rather than leaving them pointing at nothing. `markTextureDirty` — how a re-baked canvas (Text, gradients, any dynamic surface) announces new pixels behind an unchanged source object — is live from `CanvasRenderTarget` and had no coverage at all. Removing its force broke no test. It is pinned now, including that the re-upload lands in the SAME handle: with immutable storage a same-shape re-upload is a pure texSubImage2D, and a new texture object would mean the storage was thrown away, which is the churn this all exists to stop. Also drops `TextureStore.isCurrent` and `invalidate`. Both were added on reasoning about hazards rather than a caller, and neither acquired one — shipping unused API next to tests that exercise only itself is how the next reader learns to distrust the suite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- .../melonjs/src/video/gpu/texturestore.js | 30 ----- .../video/webgl/batchers/material_batcher.js | 23 ++-- .../melonjs/tests/texture-reupload.spec.js | 117 ++++++++++++++++++ packages/melonjs/tests/texturestore.spec.js | 26 ---- 4 files changed, 133 insertions(+), 63 deletions(-) diff --git a/packages/melonjs/src/video/gpu/texturestore.js b/packages/melonjs/src/video/gpu/texturestore.js index 4ee9ee638..c17afae43 100644 --- a/packages/melonjs/src/video/gpu/texturestore.js +++ b/packages/melonjs/src/video/gpu/texturestore.js @@ -119,36 +119,6 @@ export class TextureStore { return this.records.get(source); } - /** - * Whether a record is still valid for the live context. - * - * `releaseAll` drops every record, so the store itself can never hand back - * a stale one — but a CALLER that cached a handle of its own (a batcher's - * per-unit array, say) has no such protection, and binding a handle minted - * under a dead context is undefined behaviour that fails silently. This is - * how such a caller checks, rather than assuming its own invalidation ran. - * @param {object} [record] - a record obtained earlier - * @returns {boolean} whether it belongs to the current context - * @ignore - */ - isCurrent(record) { - return record !== undefined && record.generation === this.generation; - } - - /** - * Mark a source's content stale, so the next resolve re-uploads it. Cheaper - * and safer than destroying it: the handle and its storage survive, only the - * contents are re-pushed. - * @param {object} source - the source whose content changed - * @ignore - */ - invalidate(source) { - const record = this.records.get(source); - if (record !== undefined) { - record.version = -1; - } - } - /** * Release the GPU texture for one source. Driven by the source going away * — never by a slot being reassigned, which is exactly the coupling this diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js index e25bfffb6..3b7a77fb8 100644 --- a/packages/melonjs/src/video/webgl/batchers/material_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/material_batcher.js @@ -350,14 +350,21 @@ export class MaterialBatcher extends WebGLBatcher { // same unit look "already uploaded" and bind a stale texture. const image = texture.getTexture(); const cache = this.renderer.cache; + // The store owns the handle since #1585, so it has to do the + // freeing: deleting it here left the store holding a record whose + // texture no longer existed, and the next resolve for this source + // would hand that dead handle straight back to a draw. + const record = this.renderer.textureStore?.peek(image); + if (record !== undefined) { + this.unbindTexture2D(record.handle); + } + this.renderer.textureStore?.destroyTexture(image); if (cache.has(image)) { for (const atlas of cache.cache.get(image)) { for (const unit of cache.peekAllUnits(atlas)) { - const texture2D = this.boundTextures[unit]; - if (typeof texture2D !== "undefined") { - this.gl.deleteTexture(texture2D); - this.unbindTexture2D(texture2D); - } + // drop the per-unit belief too, or a later allocation of + // the same unit looks "already bound" and samples nothing + this.invalidateUnit(unit); } } } @@ -517,8 +524,10 @@ export class MaterialBatcher extends WebGLBatcher { const frameless = typeof source.videoWidth !== "undefined" && source.readyState < 2; - // `dirtyUnits` is a per-unit invalidation (something bound over this - // unit behind our back), so it forces a re-upload for this call only + // `markTextureDirty` announces that the SOURCE behind a unit changed + // (same object, new pixels — a canvas re-bake), so it forces a + // re-upload. It is a CONTENT signal, not a binding one: a merely stale + // binding is handled by the unconditional bind below. const dirty = this.dirtyUnits.delete(unit); const record = this.renderer.textureStore.getResidentRecord(source, { version: source.version ?? 0, diff --git a/packages/melonjs/tests/texture-reupload.spec.js b/packages/melonjs/tests/texture-reupload.spec.js index 712084755..e55d9a9f0 100644 --- a/packages/melonjs/tests/texture-reupload.spec.js +++ b/packages/melonjs/tests/texture-reupload.spec.js @@ -126,6 +126,123 @@ describe("texture re-upload on overflow", () => { expect(over.deleteTexture).toBe(0); }); + // ADVERSARIAL — the failure mode the store's ownership newly makes possible: + // something frees a GL texture the store still has a record for, and the + // next resolve hands the DEAD handle to a draw. Silent: GL does not error on + // a deleted texture, it samples black. + it("deleting a source's texture does not leave a dead handle resident", (ctx) => { + requireWebGL(ctx); + const source = images[1]; + const quad = renderer.batchers.get("quad"); + renderer.cache.resetUnitAssignments(); + renderer.drawImage(source, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.flush(); + + const atlas = renderer.cache.get(source); + const dead = renderer.textureStore.peek(atlas.getTexture()).handle; + expect(gl.isTexture(dead)).toBe(true); + + quad.deleteTexture2D(atlas); + // the record must go WITH the texture, not outlive it + expect(renderer.textureStore.peek(atlas.getTexture())).toBeUndefined(); + expect(gl.isTexture(dead)).toBe(false); + + // and drawing it again rebuilds rather than binding the corpse + renderer.drawImage(source, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.flush(); + const revived = renderer.textureStore.peek( + renderer.cache.get(source).getTexture(), + ); + expect(revived).toBeDefined(); + expect(revived.handle).not.toBe(dead); + expect(gl.isTexture(revived.handle)).toBe(true); + }); + + // ADVERSARIAL — a content change must still re-upload. The whole change is + // about NOT re-uploading, so the obvious way to get it wrong is to skip an + // upload that was genuinely needed and render a stale frame forever. + it("a content change still re-uploads, into the same handle", (ctx) => { + requireWebGL(ctx); + const source = images[2]; + renderer.cache.resetUnitAssignments(); + renderer.drawImage(source, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.flush(); + const before = renderer.textureStore.peek( + renderer.cache.get(source).getTexture(), + ); + + let uploads = 0; + const real = gl.texSubImage2D.bind(gl); + gl.texSubImage2D = (...a) => { + uploads++; + return real(...a); + }; + try { + // a canvas re-bake: same object, new pixels, bumped revision + source.version = (source.version ?? 0) + 1; + renderer.drawImage(source, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.flush(); + } finally { + gl.texSubImage2D = real; + } + + expect(uploads).toBeGreaterThan(0); + // re-uploaded IN PLACE — a new handle would mean the storage was + // thrown away, which is the churn this all exists to stop + const after = renderer.textureStore.peek( + renderer.cache.get(source).getTexture(), + ); + expect(after.handle).toBe(before.handle); + }); + + // ADVERSARIAL — `markTextureDirty` is how a re-baked canvas (Text, a + // gradient, any dynamic surface) announces new pixels behind an UNCHANGED + // source object. It is live in production, from `CanvasRenderTarget`, and + // nothing covered it: skipping this upload renders the previous text + // forever, which is exactly the failure a re-upload-avoiding change invites. + it("markTextureDirty forces a re-upload in place", (ctx) => { + requireWebGL(ctx); + const source = images[3]; + const quad = renderer.setBatcher("quad"); + renderer.cache.resetUnitAssignments(); + renderer.drawImage(source, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.flush(); + + const atlas = renderer.cache.get(source); + const before = renderer.textureStore.peek(atlas.getTexture()); + const unit = renderer.cache.getUnit(atlas); + + let uploads = 0; + let creates = 0; + const realSub = gl.texSubImage2D.bind(gl); + const realCreate = gl.createTexture.bind(gl); + gl.texSubImage2D = (...a) => { + uploads++; + return realSub(...a); + }; + gl.createTexture = (...a) => { + creates++; + return realCreate(...a); + }; + try { + // the canvas re-baked: same object, same version, new pixels + quad.markTextureDirty(unit); + renderer.drawImage(source, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.flush(); + } finally { + gl.texSubImage2D = realSub; + gl.createTexture = realCreate; + } + + expect(uploads).toBeGreaterThan(0); + // in place: immutable storage makes a same-shape re-upload a pure + // texSubImage2D, so no new texture object should appear + expect(creates).toBe(0); + expect(renderer.textureStore.peek(atlas.getTexture()).handle).toBe( + before.handle, + ); + }); + it("a source keeps ONE handle across a unit reassignment", (ctx) => { requireWebGL(ctx); const source = images[0]; diff --git a/packages/melonjs/tests/texturestore.spec.js b/packages/melonjs/tests/texturestore.spec.js index 78fd634d1..78517f143 100644 --- a/packages/melonjs/tests/texturestore.spec.js +++ b/packages/melonjs/tests/texturestore.spec.js @@ -103,17 +103,6 @@ describe("TextureStore", () => { }); describe("invalidate / destroy", () => { - it("invalidate re-uploads but keeps the handle", () => { - const { store, spies } = makeStore(); - const src = { name: "a" }; - const first = store.getResidentRecord(src); - store.invalidate(src); - const second = store.getResidentRecord(src); - expect(second.handle).toBe(first.handle); - expect(spies.onCreate).toHaveBeenCalledTimes(1); - expect(spies.onUpload).toHaveBeenCalledTimes(2); - }); - it("destroyTexture releases exactly once", () => { const { store, spies } = makeStore(); const src = { name: "a" }; @@ -156,21 +145,6 @@ describe("TextureStore", () => { expect(store.peek(src).generation).toBe(store.generation); }); - it("isCurrent rejects a record a caller cached across a loss", () => { - // the store drops its own records, but a caller holding a handle in - // its own array has no such protection, and binding a handle from a - // dead context fails silently rather than erroring - const { store } = makeStore(); - const stale = store.getResidentRecord({ name: "a" }); - expect(store.isCurrent(stale)).toBe(true); - store.releaseAll(); - expect(store.isCurrent(stale)).toBe(false); - expect(store.isCurrent(undefined)).toBe(false); - expect(store.isCurrent(store.getResidentRecord({ name: "b" }))).toBe( - true, - ); - }); - it("does not try to free handles whose context already died", () => { // the GPU objects went with the context; asking a dead context to // release them is at best a no-op and at worst throws From 5317cf5febdd7838657e6acd17423c27d2c880a7 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 16 Aug 2026 18:09:35 +0800 Subject: [PATCH 09/16] refactor(video): one TextureStore base, one realization per backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the sharing the earlier commits left half-done. Residency was written three times: the WebGL colour path, the WebGPU store, and the lit batcher's normal maps. The first two were unified in design only — WebGL used the shared base directly with callbacks inlined in the renderer constructor, while WebGPU kept its own records map, generation and release code. That asymmetry was build order, not design: it left WebGL texture concerns with no home, which is why the normal-map path had to repeat the callbacks. Now `TextureStore` is a base whose three hooks are overridable METHODS (constructor injection still works, which is how the tests drive the policy with no GPU), and each backend has a realization — the same shape as Batcher / WebGLBatcher / WebGPUBatcher: WebGLTextureStore extends TextureStore GL create + destroy WebGPUTextureStore extends TextureStore device create + retire Both WebGL stores are that subclass: the renderer's colour store every batcher shares, and the lit batcher's normal-map store, which drops its hand-rolled source->{tex, version} map. Upload stays a per-call closure on the WebGL side. A GL upload needs the BATCHER's `createTexture2D` — target unit, immutable-storage shape, texture swap on shape change — and none of that is residency. What is deliberately NOT unified is the reuse-vs-upload decision itself. WebGPU queue writes execute before recorded draws, so a same-frame content change needs a fresh texture there and does not on WebGL. That is a real semantic difference, not drift, and forcing one code path would have to special-case it anyway. Two bugs this surfaced: the WebGPU records lacked the `generation` field the base walks, so `releaseAll` skipped every one of them silently; and the cache-reset handler needed `releaseAll(true)`, since the base defaults to NOT destroying — right for a lost context, wrong for a live device. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- .../melonjs/src/video/gpu/texturestore.js | 63 +++++++- .../video/webgl/batchers/lit_quad_batcher.js | 114 ++++++-------- .../melonjs/src/video/webgl/texture/store.js | 63 ++++++++ .../melonjs/src/video/webgl/webgl_renderer.js | 18 +-- .../melonjs/src/video/webgpu/texture/store.js | 33 ++-- .../melonjs/tests/webgl_batcher_state.spec.js | 8 +- .../melonjs/tests/webgl_texture_store.spec.js | 147 ++++++++++++++++++ .../tests/webgpu_texture_store.spec.js | 23 +++ 8 files changed, 371 insertions(+), 98 deletions(-) create mode 100644 packages/melonjs/src/video/webgl/texture/store.js create mode 100644 packages/melonjs/tests/webgl_texture_store.spec.js diff --git a/packages/melonjs/src/video/gpu/texturestore.js b/packages/melonjs/src/video/gpu/texturestore.js index c17afae43..463129ba1 100644 --- a/packages/melonjs/src/video/gpu/texturestore.js +++ b/packages/melonjs/src/video/gpu/texturestore.js @@ -37,9 +37,18 @@ export class TextureStore { constructor({ onCreate, onUpload, onDestroy } = {}) { /** @type {Map} */ this.records = new Map(); - this.onCreate = onCreate; - this.onUpload = onUpload; - this.onDestroy = onDestroy; + // The three hooks are prototype METHODS, so a backend subclass overrides + // them; passing them to the constructor installs per-instance overrides + // instead, which is how a test drives the policy with no GPU at all. + if (onCreate !== undefined) { + this.onCreate = onCreate; + } + if (onUpload !== undefined) { + this.onUpload = onUpload; + } + if (onDestroy !== undefined) { + this.onDestroy = onDestroy; + } // Bumped whenever the underlying context or device dies. A record from // an older generation is never handed back: a handle minted under a // dead context is not merely stale, binding it is undefined behaviour, @@ -48,6 +57,48 @@ export class TextureStore { this.generation = 0; } + /** + * Allocate the backing GPU texture for a source seen for the first time. + * @param {object} source - the image/canvas/resource + * @param {object} options - the resolve options + * @returns {*} the new handle + * @ignore + */ + onCreate(source, options) { + void source; + void options; + return undefined; + } + + /** + * Push the source's current content. May return a REPLACEMENT handle when + * the shape changed and the old one cannot be respecified. + * @param {*} handle - the existing handle + * @param {object} source - the image/canvas/resource + * @param {object} record - the resident record + * @param {object} options - the resolve options + * @returns {*} a replacement handle, or nothing to keep the existing one + * @ignore + */ + onUpload(handle, source, record, options) { + void handle; + void source; + void record; + void options; + return undefined; + } + + /** + * Release a GPU texture. Called exactly once per handle. + * @param {*} handle - the handle to release + * @param {object} source - the source it belonged to + * @ignore + */ + onDestroy(handle, source) { + void handle; + void source; + } + /** * how many sources are currently resident * @returns {number} the record count @@ -79,7 +130,7 @@ export class TextureStore { if (record === undefined) { record = { - handle: this.onCreate?.(source, options), + handle: this.onCreate(source, options), // deliberately unmatchable, so a freshly created record always // takes the upload path once — a record that exists but was // never filled is the failure this rules out @@ -134,7 +185,7 @@ export class TextureStore { } this.records.delete(source); if (record.generation === this.generation) { - this.onDestroy?.(record.handle, source); + this.onDestroy(record.handle, source); } return true; } @@ -154,7 +205,7 @@ export class TextureStore { * @ignore */ releaseAll(destroy = false) { - if (destroy === true && this.onDestroy !== undefined) { + if (destroy === true) { for (const [source, record] of this.records) { if (record.generation === this.generation) { this.onDestroy(record.handle, source); diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index 2ab8e6596..4ea720428 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -9,6 +9,7 @@ import { } from "../lighting/std140.ts"; import { buildLitMultiTextureFragment } from "./../shaders/multitexture-lit.js"; import quadMultiLitVertex from "./../shaders/quad-multi-lit.vert"; +import { WebGLTextureStore } from "../texture/store.js"; import QuadBatcher from "./quad_batcher.js"; /** @@ -113,15 +114,23 @@ export default class LitQuadBatcher extends QuadBatcher { this.boundNormalVersions = new Array(pool).fill(-1); /** - * Map from a normal-map source image to its uploaded GL texture and the - * source `version` it was uploaded at. A source that bumps its `version` - * (e.g. an animated {@link NoiseTexture2d}) is re-uploaded on next bind — - * explicit, version-based invalidation, scoped to normal maps which - * live outside the color `TextureCache`. - * @type {Map} + * Residency for normal-map sources — the same shared policy the colour + * path uses, just a separate instance: normal maps live outside the + * colour `TextureCache`, so their handles are this batcher's to own. + * + * This used to be a hand-rolled `Map`. It was + * the correct DESIGN before the colour path had one (which is why the + * normal path never suffered the re-upload storm), but keeping a third + * copy of the logic is how the two drifted in the first place. + * + * Re-created rather than cleared here on purpose: `init()` re-runs on + * context restore, where the old handles died with the context — and + * `destroy()` releases the previous instance first, so nothing leaks. + * @type {TextureStore} * @ignore */ - this.normalMapTextures = new Map(); + this.normalStore?.releaseAll(); + this.normalStore = new WebGLTextureStore(this.gl); /** * Which slot in the shared pool each normal-map source currently holds. @@ -238,10 +247,8 @@ export default class LitQuadBatcher extends QuadBatcher { * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image - normal-map source */ evictNormalMap(image) { - const cached = this.normalMapTextures.get(image); - if (typeof cached !== "undefined") { - this.gl.deleteTexture(cached.tex); - this.normalMapTextures.delete(image); + if (this.normalStore.peek(image) !== undefined) { + this.normalStore.destroyTexture(image); this.releaseNormalUnit(image); for (let i = 0; i < this.boundNormalMaps.length; i++) { if (this.boundNormalMaps[i] === image) { @@ -269,12 +276,9 @@ export default class LitQuadBatcher extends QuadBatcher { // are ours to delete. Before #1585 they were freed incidentally, by // `reset` walking `boundTextures` — which stopped being the sole // reference to a handle, so freeing has to be explicit now. - for (const cached of this.normalMapTextures.values()) { - this.gl.deleteTexture(cached.tex); - } + this.normalStore.releaseAll(true); this.boundNormalMaps.fill(null); this.boundNormalVersions.fill(-1); - this.normalMapTextures.clear(); this.normalUnits.clear(); this._lightCount = 0; // zero the header (count + ambient) and push it, so a reset mid-scene @@ -370,56 +374,40 @@ export default class LitQuadBatcher extends QuadBatcher { * @param {number} unit - GL texture unit the normal map is resolved to */ bindNormalMap(image, unit) { - const cached = this.normalMapTextures.get(image); - // `image.version` (the dynamic-texture revision; absent ⇒ 0/static) lets - // an animated source force a re-upload by bumping it — only when it - // actually changed, not every frame. - const version = image.version ?? 0; - if (typeof cached !== "undefined" && cached.version === version) { - // `bindTexture2D` updates `boundTextures[unit]` and - // `currentTextureUnit` so subsequent color-texture binds don't - // land on the wrong unit thinking it's still free. `flush=false` - // so we don't disturb the in-progress lit batch. - this.bindTexture2D(cached.tex, unit, false); - return; - } - this.uploadNormalMap(image, unit, version); - } - - /** - * Upload a normal-map image to GL and cache the resulting `WebGLTexture` - * for future `bindNormalMap` calls. Not meant to be called directly — - * `bindNormalMap` invokes this on the first use of a given image. - * - * `premultipliedAlpha = false` — normal maps store linear-encoded - * surface normals; multiplying through alpha would corrupt the - * encoding for any non-opaque texel. - * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image - normal-map source - * @param {number} unit - GL texture unit the normal map is resolved to - * @param {number} [version=0] - the source revision being uploaded - */ - uploadNormalMap(image, unit, version = 0) { - // Reuse the existing GL texture handle when re-uploading a changed source - // (an animated NoiseTexture2d): `createTexture2D` re-`texImage2D`s into the - // passed handle instead of churning a new texture object every frame. The - // source dimensions are stable, so the same handle stays valid. - const prev = this.normalMapTextures.get(image); - this.createTexture2D( + // `image.version` (the dynamic-texture revision; absent => 0/static) + // lets an animated source force a re-upload by bumping it — only when + // it actually changed, not every frame. The store compares it and + // re-uploads into the SAME handle, so an animated source does not churn + // a new texture object per frame. + const record = this.normalStore.getResidentRecord(image, { + version: image.version ?? 0, + upload: (handle) => { + return this.createTexture2D( + unit, + image, + this.renderer._glTextureFilter(), + "no-repeat", + image.width, + image.height, + // normal maps store linear-encoded surface normals; + // multiplying through alpha would corrupt the encoding + false, + undefined, + handle, + false, + ); + }, + }); + // `bindTexture2D` updates `boundTextures[unit]` and `currentTextureUnit` + // so subsequent colour binds don't land on the wrong unit thinking it is + // still free. `flush=false` so the in-progress lit batch is undisturbed. + this.bindTexture2D(record.handle, unit, false); + // the variant is fixed for normal maps, but the sampler still has to be + // bound or the unit keeps whatever the previous texture left there + this.renderer.samplerCache.bind( unit, - image, - this.renderer._glTextureFilter(), - "no-repeat", - image.width, - image.height, - false, - undefined, - prev?.tex, - false, + this.renderer.samplerCache.get(this.renderer._glTextureFilter()), ); - this.normalMapTextures.set(image, { - tex: this.boundTextures[unit], - version, - }); } /** diff --git a/packages/melonjs/src/video/webgl/texture/store.js b/packages/melonjs/src/video/webgl/texture/store.js new file mode 100644 index 000000000..28a95c8dd --- /dev/null +++ b/packages/melonjs/src/video/webgl/texture/store.js @@ -0,0 +1,63 @@ +import { TextureStore } from "./../../gpu/texturestore.js"; + +/** + * The WebGL realization of {@link TextureStore}. + * + * The shared base owns the policy — source → record, the reuse-or-upload + * decision, generation and lifetime bookkeeping. This subclass owns the GL + * calls, and nothing else. Mirrors the `Batcher` / `WebGLBatcher` / + * `WebGPUBatcher` arrangement: one neutral base, one realization per backend. + * + * Two instances exist per renderer: the colour store, which every batcher + * shares through `WebGLRenderer.textureStore`, and the lit batcher's + * normal-map store — normal maps live outside the colour `TextureCache`, so + * their handles are that batcher's to own, but the policy is identical. + * @augments TextureStore + * @ignore + */ +export class WebGLTextureStore extends TextureStore { + /** + * @param {WebGL2RenderingContext} gl - the owning context + * @ignore + */ + constructor(gl) { + super(); + this.gl = gl; + } + + /** + * @returns {WebGLTexture} a fresh texture object + * @ignore + */ + onCreate() { + return this.gl.createTexture(); + } + + /** + * Push the source's pixels. + * + * Delegated to a per-call closure rather than implemented here, because a + * GL upload needs the *batcher's* `createTexture2D`: it binds the target + * unit, tracks immutable-storage shape, and swaps the texture object when + * the shape changes. None of that is residency, and moving it into the + * store would drag the batcher's unit bookkeeping along with it. + * @param {WebGLTexture} handle - the existing texture object + * @param {object} source - the image/canvas being uploaded + * @param {object} record - the resident record + * @param {object} options - carries `upload(handle)` + * @returns {WebGLTexture} the handle actually used, which may be a + * replacement when immutable storage had to be respecified + * @ignore + */ + onUpload(handle, source, record, options) { + return options.upload(handle); + } + + /** + * @param {WebGLTexture} handle - the texture object to release + * @ignore + */ + onDestroy(handle) { + this.gl.deleteTexture(handle); + } +} diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index a8c206071..26a75b1ed 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -13,7 +13,6 @@ import { RENDER_TARGET_CHANGED, } from "../../system/event.ts"; import RadialGradientEffect from "../effects/radialGradient.js"; -import { TextureStore } from "./../gpu/texturestore.js"; import { Gradient } from "../gradient.js"; import Renderer from "./../renderer.js"; import RenderTargetPool from "../rendertarget/render_target_pool.js"; @@ -34,6 +33,7 @@ import PrimitiveBatcher from "./batchers/primitive_batcher"; import QuadBatcher from "./batchers/quad_batcher"; import { createLightUniformScratch, packLights } from "./lighting/pack.ts"; import OrthogonalTMXLayerGPURenderer from "./renderers/tmxlayer/orthogonal.js"; +import { WebGLTextureStore } from "./texture/store.js"; import { resolveMaxTextures } from "./utils/maxtextures.js"; import { getMaxShaderPrecision } from "./utils/precision.js"; import { GLSamplerCache } from "./utils/samplercache.js"; @@ -173,21 +173,7 @@ export default class WebGLRenderer extends Renderer { * @type {TextureStore} * @ignore */ - this.textureStore = new TextureStore({ - onCreate: () => { - return this.gl.createTexture(); - }, - // the caller owns the upload — it is the only place the unit, - // filter, wrap and dimensions are all known. It returns the handle, - // which may DIFFER from the one passed in: immutable storage cannot - // be respecified, so a shape change swaps the object outright. - onUpload: (handle, source, record, options) => { - return options.upload(handle); - }, - onDestroy: (handle) => { - this.gl.deleteTexture(handle); - }, - }); + this.textureStore = new WebGLTextureStore(this.gl); this.maxTextures = resolveMaxTextures( this.gl.getParameter(this.gl.MAX_TEXTURE_IMAGE_UNITS), diff --git a/packages/melonjs/src/video/webgpu/texture/store.js b/packages/melonjs/src/video/webgpu/texture/store.js index cc7e89230..2bf6625df 100644 --- a/packages/melonjs/src/video/webgpu/texture/store.js +++ b/packages/melonjs/src/video/webgpu/texture/store.js @@ -1,4 +1,5 @@ import { GPU_TEXTURE_CACHE_RESET, off, on } from "../../../system/event.ts"; +import { TextureStore } from "../../gpu/texturestore.js"; import mipblitWGSL from "../shaders/mipblit.wgsl"; import { COMPRESSED_FORMATS, uploadCompressedTexture } from "./compressed.js"; @@ -18,15 +19,14 @@ import { COMPRESSED_FORMATS, uploadCompressedTexture } from "./compressed.js"; * re-parameterization). * @ignore */ -export default class WebGPUTextureStore { +export default class WebGPUTextureStore extends TextureStore { /** * @param {import("../webgpu_renderer.js").default} renderer - the owning renderer */ constructor(renderer) { + super(); this.renderer = renderer; this.device = renderer.device; - /** @type {Map}>} */ - this.records = new Map(); /** @type {Map} */ this.samplers = new Map(); @@ -34,7 +34,11 @@ export default class WebGPUTextureStore { // (unit numbers get reassigned; resident GPU textures would map to // the wrong sources). Mirrors MaterialBatcher._onTextureCacheReset. this.onCacheReset = () => { - this.releaseAll(); + // `true`: the device is alive here, so the textures are real and + // must actually be retired. The base defaults to NOT destroying, + // which is the right choice for a LOST context — there the GPU + // objects died with it and asking to free them is meaningless. + this.releaseAll(true); }; on(GPU_TEXTURE_CACHE_RESET, this.onCacheReset); } @@ -143,6 +147,12 @@ export default class WebGPUTextureStore { }); uploadCompressedTexture(this.device, gpuTexture, source, metrics); record = { + // `handle` and `generation` are the base class's fields — + // it walks them for lifetime, and a record missing the + // generation silently never gets released. `texture` is + // kept because this backend's own paths read it. + handle: gpuTexture, + generation: this.generation, texture: gpuTexture, // 2D consumers stay lod-clamped to level 0 (sprites // sharing the asset render byte-identically) … @@ -225,6 +235,8 @@ export default class WebGPUTextureStore { GPUTextureUsage.RENDER_ATTACHMENT, }); record = { + handle: gpuTexture, + generation: this.generation, texture: gpuTexture, view: gpuTexture.createView(), source, @@ -554,11 +566,14 @@ export default class WebGPUTextureStore { /** * drop every unit association and dispose of the resident textures */ - releaseAll() { - for (const record of this.records.values()) { - this.retire(record.texture); - } - this.records.clear(); + /** + * Release the GPU texture behind a record. The base calls this; the device + * defers the actual destroy until the frame that referenced it has retired. + * @param {GPUTexture} handle - the texture to release + * @ignore + */ + onDestroy(handle) { + this.retire(handle); } /** diff --git a/packages/melonjs/tests/webgl_batcher_state.spec.js b/packages/melonjs/tests/webgl_batcher_state.spec.js index 4ab1b3281..09826205c 100644 --- a/packages/melonjs/tests/webgl_batcher_state.spec.js +++ b/packages/melonjs/tests/webgl_batcher_state.spec.js @@ -159,7 +159,7 @@ describe("batcher GL state", () => { const source = Renderer.createCanvas(8, 8); const unit = lit.resolveNormalUnit(source); - const tex = lit.normalMapTextures.get(source)?.tex; + const tex = lit.normalStore.peek(source)?.handle; expect(tex).toBeDefined(); expect(gl.isTexture(tex)).toBe(true); // the handle must be reachable from the array reset() walks @@ -324,11 +324,11 @@ describe("batcher GL state", () => { const source = nm.getTexture(); lit.bindNormalMap(source, lit.maxBatchTextures); - expect(lit.normalMapTextures.has(source)).toBe(true); - const tex = lit.normalMapTextures.get(source).tex; + expect(lit.normalStore.peek(source)).toBeDefined(); + const tex = lit.normalStore.peek(source).handle; nm.destroy(); - expect(lit.normalMapTextures.has(source)).toBe(false); + expect(lit.normalStore.peek(source)).toBeUndefined(); expect(renderer.gl.isTexture(tex)).toBe(false); }); diff --git a/packages/melonjs/tests/webgl_texture_store.spec.js b/packages/melonjs/tests/webgl_texture_store.spec.js new file mode 100644 index 000000000..14e1bb620 --- /dev/null +++ b/packages/melonjs/tests/webgl_texture_store.spec.js @@ -0,0 +1,147 @@ +/** + * `WebGLTextureStore` — the WebGL realization of the shared residency policy + * (#1585). + * + * The base owns the decision (reuse or upload, when to release); this subclass + * owns the GL calls and nothing else, mirroring `Batcher` / `WebGLBatcher` / + * `WebGPUBatcher`. Two instances live per renderer: the colour store every + * batcher shares, and the lit batcher's normal-map store. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { boot, game, WebGLRenderer } from "../src/index.js"; +import { TextureStore } from "../src/video/gpu/texturestore.js"; +import { WebGLTextureStore } from "../src/video/webgl/texture/store.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; + +describe("WebGLTextureStore", () => { + let renderer; + let gl; + + beforeAll(async () => { + await boot(); + try { + await getWebGLRenderer(64, 64); + } catch { + // genuine WebGL absence — tests skip below + } + if (game.renderer instanceof WebGLRenderer) { + renderer = game.renderer; + gl = renderer.gl; + } + }); + + afterAll(() => { + try { + releaseWebGLRenderer(); + } catch { + // ignore + } + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + it("is a TextureStore, so the policy is the shared one", (ctx) => { + requireWebGL(ctx); + const store = new WebGLTextureStore(gl); + expect(store).toBeInstanceOf(TextureStore); + // the renderer's colour store and the lit batcher's normal store are + // both this class — one policy, two instances, not two implementations + expect(renderer.textureStore).toBeInstanceOf(WebGLTextureStore); + expect(renderer.batchers.get("litQuad").normalStore).toBeInstanceOf( + WebGLTextureStore, + ); + // and they are DISTINCT: normal maps live outside the colour cache + expect(renderer.batchers.get("litQuad").normalStore).not.toBe( + renderer.textureStore, + ); + }); + + it("creates a real GL texture, and releases it exactly once", (ctx) => { + requireWebGL(ctx); + const store = new WebGLTextureStore(gl); + const source = { name: "s" }; + const rec = store.getResidentRecord(source, { + // bind it, as the real upload path does inside `createTexture2D`: + // a name from `createTexture` is not a texture OBJECT until first + // bound, so `isTexture` reports false before that + upload: (handle) => { + gl.bindTexture(gl.TEXTURE_2D, handle); + return handle; + }, + }); + expect(gl.isTexture(rec.handle)).toBe(true); + + expect(store.destroyTexture(source)).toBe(true); + expect(gl.isTexture(rec.handle)).toBe(false); + // a second destroy must not double-free — GL tolerates it, but the + // handle may have been reissued to someone else by then + const spy = vi.spyOn(gl, "deleteTexture"); + expect(store.destroyTexture(source)).toBe(false); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it("does not upload again for an unchanged source", (ctx) => { + requireWebGL(ctx); + // the property the whole change rests on, at the subclass level + const store = new WebGLTextureStore(gl); + const source = { name: "s" }; + const upload = vi.fn((handle) => { + return handle; + }); + store.getResidentRecord(source, { upload }); + store.getResidentRecord(source, { upload }); + store.getResidentRecord(source, { upload }); + expect(upload).toHaveBeenCalledTimes(1); + store.releaseAll(true); + }); + + it("adopts the handle the upload actually used", (ctx) => { + requireWebGL(ctx); + // immutable storage cannot be respecified, so a shape change makes + // `createTexture2D` swap the texture object and return the new one — + // tracking the old one would hand back a deleted handle forever + const store = new WebGLTextureStore(gl); + const source = { name: "s" }; + const swapped = gl.createTexture(); + const rec = store.getResidentRecord(source, { + upload: () => { + return swapped; + }, + }); + expect(rec.handle).toBe(swapped); + store.releaseAll(true); + }); + + it("rebuilds after a context loss rather than reusing a dead handle", (ctx) => { + requireWebGL(ctx); + const store = new WebGLTextureStore(gl); + const source = { name: "s" }; + const before = store.getResidentRecord(source, { + upload: (h) => { + return h; + }, + }).handle; + + // a LOST context: the GPU objects died with it, so nothing is freed + const spy = vi.spyOn(gl, "deleteTexture"); + store.releaseAll(); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + + const after = store.getResidentRecord(source, { + upload: (h) => { + return h; + }, + }).handle; + expect(after).not.toBe(before); + store.releaseAll(true); + }); +}); diff --git a/packages/melonjs/tests/webgpu_texture_store.spec.js b/packages/melonjs/tests/webgpu_texture_store.spec.js index b9f7895ad..26040b9de 100644 --- a/packages/melonjs/tests/webgpu_texture_store.spec.js +++ b/packages/melonjs/tests/webgpu_texture_store.spec.js @@ -1,6 +1,7 @@ import "./helpers/webgpu-globals.js"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { emit, GPU_TEXTURE_CACHE_RESET } from "../src/system/event.ts"; +import { TextureStore } from "../src/video/gpu/texturestore.js"; import WebGPUTextureStore from "../src/video/webgpu/texture/store.js"; /** @@ -256,6 +257,28 @@ describe("WebGPUTextureStore", () => { expect(createdTextures).toHaveLength(2); }); + it("is a TextureStore, so both backends share the lifetime policy", () => { + // #1585: the WebGPU store predates the shared base and used to carry its + // own records map, generation and release code. Subclassing removes that + // duplication — the reuse-vs-upload DECISION stays backend-specific for + // a documented reason (queue writes execute before recorded draws, so a + // same-frame content change needs a fresh texture here and does not on + // WebGL), but everything around it is now one implementation. + expect(store).toBeInstanceOf(TextureStore); + }); + + it("records carry the fields the base walks", () => { + // a record missing `handle` or `generation` is silently never released — + // the base skips it, and nothing errors. Cheap to assert, invisible + // otherwise. + const atlas = makeAtlas(makeSource(8, 8)); + store.getBinding(atlas); + const record = store.peek(atlas.getTexture()); + expect(record).toBeDefined(); + expect(record.handle).toBe(record.texture); + expect(record.generation).toBe(store.generation); + }); + it("GPU_TEXTURE_CACHE_RESET releases every record", () => { const atlas = makeAtlas(makeSource(8, 8)); store.getBinding(atlas); From f9fb27735d3d57ee482451c43576694e6acf22d5 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 17 Aug 2026 07:32:23 +0800 Subject: [PATCH 10/16] docs(changelog): performance entry for the texture-batching work Measured against 19.9.1 on the same machine: past the multi-texture limit the old path re-created and re-uploaded ~544 textures per frame and regenerated their mip chains; the new one does zero at every N. Includes the two caveats rather than only the win: below the limit the new path is slower (0.045 -> 0.100 ms at 512 quads), and the millisecond figures are medians that still vary by ~25% while the call counts are exact. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- packages/melonjs/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 3343a9838..7df46c8f0 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -51,6 +51,16 @@ - `Application.updateAverageDelta` is renamed **`lastUpdateDelta`**. It holds the measured wall-clock cost of the most recent logic step and has never been an average — the exponential smoothing the name refers to was removed in 2015, a day after it was added. The old name keeps working as an alias and is scheduled for removal in 21.0.0, so no migration is required now. Note it is a different quantity from `updateDelta`, which is the *simulated* time one step advances ### Performance +- **Texture batching past the multi-texture limit** ([#1585](https://github.com/melonjs/melonJS/issues/1585)) — a scene with more distinct textures than the batcher's pool used to re-*create* and re-upload every texture once per draw, every frame, and regenerate its mip chain with it. The GL handle was reachable only through a batcher's per-unit array, so dropping a unit assignment destroyed the texture. Residency is now keyed by source, so an overflow costs a flush and some re-binds. Measured on a 32-unit device, 512 quads/frame, round-robin over N distinct textures: + +| N textures | 19.9.1 draws / uploads / ms | now draws / uploads / ms | +| --- | --- | --- | +| 16 | 1 / 0 / 0.045 | 1 / 0 / 0.100 | +| 17 | 33 / 542 / 3.27 | 1 / 0 / 0.097 | +| 32 | 32 / 544 / 4.17 | 1 / 0 / 0.100 | +| 64 | 32 / 544 / 3.25 | 16 / 0 / 0.210 | + + "uploads" counts `createTexture` + `texSubImage2D` + `generateMipmap`; it is **zero at every N** now. Note the old cost did not scale with how far past the limit a scene went — one texture over cost the same as four times over. Two honest caveats: below the limit the new path is slower (0.045 → 0.100 ms at 512 quads, ~0.3% of a 60 fps budget), most likely the added sampler bind per texture and a wider generated shader; and call counts are exact while the millisecond figures are medians that still vary by ~25% - **Vertex Array Objects for every batcher** ([#1509](https://github.com/melonjs/melonJS/issues/1509)) — vertex attribute layout is specified once at init rather than on every batcher switch and every mesh flush, so steady-state frames issue **zero** attribute-specification calls. How much GL traffic this saves depends on how often a scene alternates batchers: a scene that stays on one batcher saves about 2 calls per frame, one mixing sprites, meshes and primitives about 40 — in both cases well under a millisecond. The structural benefit is the larger one: attribute-state leaks between batchers become impossible by construction. - **retained-mode mesh rendering** ([#1507](https://github.com/melonjs/melonJS/issues/1507)) — mesh geometry is uploaded to the GPU once and re-drawn from there, instead of being re-transformed on the CPU and re-uploaded every frame. Steady-state frames issue **zero** vertex uploads and run **zero** per-vertex CPU transforms, however much a mesh moves, rotates, scales or changes tint; only an explicit geometry edit re-uploads. A mesh past 65 535 vertices is also drawn in a single call rather than split into chunks. What to expect: the CPU cost of issuing a mesh draw drops by roughly **25–30% for small meshes** (tens of vertices, where only per-call overhead was ever at stake) and by **more than 95% for vertex-heavy ones** (thousands), because the saving is per-vertex work that no longer happens at all — so the bigger the model, the larger the share. Measured in the in-tree mesh benchmark (`drawmesh_bench.spec.js`, which now runs both paths side by side): an 8-vertex cube 1.8µs → 1.2µs per draw, a 5 000-vertex mesh 66µs → under 2.5µs and from 2 draw calls to 1. The change in shape matters more than any single figure. Measured on an Apple M4 Max (ANGLE Metal) over the vertex counts from [#1507](https://github.com/melonjs/melonJS/issues/1507), draw-phase CPU per frame: From 1d213869b733d9aa814ac93923530afc9f6b49d1 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 17 Aug 2026 07:37:25 +0800 Subject: [PATCH 11/16] perf(webgl): keep string work out of the per-quad sampler path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The below-limit regression the previous benchmark flagged, measured and mostly removed. Attribution first, because the guess in the changelog was wrong: the 32-sampler shader ladder costs nothing (pool 32 and pool 16 measure the same, 0.102 vs 0.105 ms), and `bindSampler` is nearly free. It was `GLSamplerCache.get`. Called once per QUAD — 512 times a frame in the benchmark — running two regexes and building a template-literal key to return the same sampler every time. String work in the hottest path. Memoized behind a three-value guard, plus per-unit tracking so a redundant bind is skipped. That alone recovered all of it: 0.100 -> 0.060, exactly matching a variant with samplers removed entirely. `uploadTexture` also allocated an options object and an upload closure per quad even when nothing uploaded. It now peeks first and constructs those only on an actual miss: 0.060 -> 0.055. 512 quads/frame, 16 distinct textures, median of 7: 19.9.1 0.045 ms before this commit 0.100 ms (+122%) after 0.055 ms (+22%) The overflow case improves too, 0.220 -> 0.163 ms. The residual is the per-source residency lookup — the cost of not re-uploading 544 textures a frame, which is the trade the whole change exists to make. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- packages/melonjs/CHANGELOG.md | 10 ++-- .../video/webgl/batchers/material_batcher.js | 50 ++++++++++++------- .../src/video/webgl/utils/samplercache.js | 33 ++++++++++++ 3 files changed, 70 insertions(+), 23 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 7df46c8f0..744aa7a78 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -55,12 +55,12 @@ | N textures | 19.9.1 draws / uploads / ms | now draws / uploads / ms | | --- | --- | --- | -| 16 | 1 / 0 / 0.045 | 1 / 0 / 0.100 | -| 17 | 33 / 542 / 3.27 | 1 / 0 / 0.097 | -| 32 | 32 / 544 / 4.17 | 1 / 0 / 0.100 | -| 64 | 32 / 544 / 3.25 | 16 / 0 / 0.210 | +| 16 | 1 / 0 / 0.045 | 1 / 0 / 0.055 | +| 17 | 33 / 542 / 3.27 | 1 / 0 / 0.055 | +| 32 | 32 / 544 / 4.17 | 1 / 0 / 0.058 | +| 64 | 32 / 544 / 3.25 | 16 / 0 / 0.163 | - "uploads" counts `createTexture` + `texSubImage2D` + `generateMipmap`; it is **zero at every N** now. Note the old cost did not scale with how far past the limit a scene went — one texture over cost the same as four times over. Two honest caveats: below the limit the new path is slower (0.045 → 0.100 ms at 512 quads, ~0.3% of a 60 fps budget), most likely the added sampler bind per texture and a wider generated shader; and call counts are exact while the millisecond figures are medians that still vary by ~25% + "uploads" counts `createTexture` + `texSubImage2D` + `generateMipmap`; it is **zero at every N** now. Note the old cost did not scale with how far past the limit a scene went — one texture over cost the same as four times over. Two caveats: below the limit the new path is still slightly slower (0.045 → 0.055 ms at 512 quads, ~0.06% of a 60 fps budget) — the residual is the per-source residency lookup, which is what buys the rest of the table; and call counts are exact while the millisecond figures are medians that still vary by ~25% - **Vertex Array Objects for every batcher** ([#1509](https://github.com/melonjs/melonJS/issues/1509)) — vertex attribute layout is specified once at init rather than on every batcher switch and every mesh flush, so steady-state frames issue **zero** attribute-specification calls. How much GL traffic this saves depends on how often a scene alternates batchers: a scene that stays on one batcher saves about 2 calls per frame, one mixing sprites, meshes and primitives about 40 — in both cases well under a millisecond. The structural benefit is the larger one: attribute-state leaks between batchers become impossible by construction. - **retained-mode mesh rendering** ([#1507](https://github.com/melonjs/melonJS/issues/1507)) — mesh geometry is uploaded to the GPU once and re-drawn from there, instead of being re-transformed on the CPU and re-uploaded every frame. Steady-state frames issue **zero** vertex uploads and run **zero** per-vertex CPU transforms, however much a mesh moves, rotates, scales or changes tint; only an explicit geometry edit re-uploads. A mesh past 65 535 vertices is also drawn in a single call rather than split into chunks. What to expect: the CPU cost of issuing a mesh draw drops by roughly **25–30% for small meshes** (tens of vertices, where only per-call overhead was ever at stake) and by **more than 95% for vertex-heavy ones** (thousands), because the saving is per-vertex work that no longer happens at all — so the bigger the model, the larger the share. Measured in the in-tree mesh benchmark (`drawmesh_bench.spec.js`, which now runs both paths side by side): an 8-vertex cube 1.8µs → 1.2µs per draw, a 5 000-vertex mesh 66µs → under 2.5µs and from 2 draw calls to 1. The change in shape matters more than any single figure. Measured on an Apple M4 Max (ANGLE Metal) over the vertex counts from [#1507](https://github.com/melonjs/melonJS/issues/1507), draw-phase CPU per frame: diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js index 3b7a77fb8..d0d2709bf 100644 --- a/packages/melonjs/src/video/webgl/batchers/material_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/material_batcher.js @@ -529,24 +529,38 @@ export class MaterialBatcher extends WebGLBatcher { // re-upload. It is a CONTENT signal, not a binding one: a merely stale // binding is handled by the unconditional bind below. const dirty = this.dirtyUnits.delete(unit); - const record = this.renderer.textureStore.getResidentRecord(source, { - version: source.version ?? 0, - force: force === true || dirty === true, - upload: (handle) => { - return this.createTexture2D( - unit, - frameless ? null : source, - filter, - wrap, - texW, - texH, - texture.premultipliedAlpha, - undefined, - handle, - flush, - ); - }, - }); + const version = source.version ?? 0; + + // Fast path: already resident and current. Taken for all but a handful + // of the hundreds of quads in a frame, so it must allocate NOTHING — + // the options object and the upload closure below are per-call garbage + // that the steady state has no use for. + let record = this.renderer.textureStore.peek(source); + if ( + record === undefined || + force === true || + dirty === true || + record.version !== version + ) { + record = this.renderer.textureStore.getResidentRecord(source, { + version, + force: force === true || dirty === true, + upload: (handle) => { + return this.createTexture2D( + unit, + frameless ? null : source, + filter, + wrap, + texW, + texH, + texture.premultipliedAlpha, + undefined, + handle, + flush, + ); + }, + }); + } // bind unconditionally — cheap, and the only thing that guarantees this // unit really holds this texture. `bindTexture2D` no-ops when its diff --git a/packages/melonjs/src/video/webgl/utils/samplercache.js b/packages/melonjs/src/video/webgl/utils/samplercache.js index 339743b5d..817f944a1 100644 --- a/packages/melonjs/src/video/webgl/utils/samplercache.js +++ b/packages/melonjs/src/video/webgl/utils/samplercache.js @@ -26,6 +26,18 @@ export class GLSamplerCache { this.gl = gl; /** @type {Map} */ this.samplers = new Map(); + // `get` runs once per QUAD — hundreds of times a frame, almost always + // with the arguments it was just called with. Building a string key and + // running two regexes each time is pure waste in the hottest path, so + // the last answer is memoized behind a three-value guard. + this.lastFilter = -1; + this.lastRepeat = null; + this.lastMipmap = null; + this.lastSampler = null; + // which sampler each unit currently holds, so a redundant bind is + // skipped rather than issued + /** @type {Array} */ + this.bound = []; } /** @@ -41,6 +53,13 @@ export class GLSamplerCache { * @ignore */ get(filter, repeat = "no-repeat", mipmap = false) { + if ( + filter === this.lastFilter && + repeat === this.lastRepeat && + mipmap === this.lastMipmap + ) { + return this.lastSampler; + } const gl = this.gl; // same per-axis mapping as `createTexture2D` const wrapS = /^repeat(-x)?$/.test(repeat) ? gl.REPEAT : gl.CLAMP_TO_EDGE; @@ -63,6 +82,10 @@ export class GLSamplerCache { ); this.samplers.set(key, sampler); } + this.lastFilter = filter; + this.lastRepeat = repeat; + this.lastMipmap = mipmap; + this.lastSampler = sampler; return sampler; } @@ -77,6 +100,10 @@ export class GLSamplerCache { * @ignore */ bind(unit, sampler) { + if (this.bound[unit] === sampler) { + return; + } + this.bound[unit] = sampler; this.gl.bindSampler(unit, sampler); } @@ -95,5 +122,11 @@ export class GLSamplerCache { } } this.samplers.clear(); + // every memo now points at a deleted or dead-context object + this.lastFilter = -1; + this.lastRepeat = null; + this.lastMipmap = null; + this.lastSampler = null; + this.bound.length = 0; } } From 816cde7aee39cebf8b06674da8bb4c5935cad410 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 17 Aug 2026 07:42:21 +0800 Subject: [PATCH 12/16] fix(webgl): restore readonly on maxTextures, and cover the sampler memo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things an API audit against master turned up. `WebGLRenderer.maxTextures` lost its `readonly` modifier in the published types. The new sampler-cache and texture-store fields were inserted BETWEEN that property's JSDoc block and its assignment, so `@readonly` ended up documenting `samplerCache` instead. Moved back. The rest of the audit is clean: `index.d.ts` is byte-identical to master, `Renderer` and `QuadBatcher` are unchanged, and `TextureCache`, `MaterialBatcher` and `LitQuadBatcher` are not exported from the entry point, so reshaping them is internal. The only remaining delta on `WebGLRenderer` is two `@ignore` fields, which that class already carries fifteen of. Also covers the per-quad memoization added in the previous commit. Both halves are caches over GL objects, which is where a stale entry renders wrongly rather than erroring: - the memo must not answer for different arguments (a last-value cache invites exactly that) - releasing must drop it, or a DELETED sampler goes straight to the next draw - the bind skip is per unit, not global, and a real transition — including to null — still issues Verified by mutation: ignoring the repeat argument, or making the bind skip global, each fail. The reset block is load-bearing as a unit rather than line by line, since `lastFilter = -1` alone already invalidates the memo — removing the whole block fails two tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- .../melonjs/src/video/webgl/webgl_renderer.js | 10 +- packages/melonjs/tests/samplercache.spec.js | 97 ++++++++++++++++++- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 26a75b1ed..6a7a70e12 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -144,11 +144,6 @@ export default class WebGLRenderer extends Renderer { */ this.vertexBuffer = this.gl.createBuffer(); - /** - * Maximum number of texture unit supported under the current context - * @type {number} - * @readonly - */ /** * Sampler objects, deduplicated by state. GL bakes wrap/filter into the * texture object; a bound sampler overrides that, so one texture can @@ -175,6 +170,11 @@ export default class WebGLRenderer extends Renderer { */ this.textureStore = new WebGLTextureStore(this.gl); + /** + * Maximum number of texture unit supported under the current context + * @type {number} + * @readonly + */ this.maxTextures = resolveMaxTextures( this.gl.getParameter(this.gl.MAX_TEXTURE_IMAGE_UNITS), this.settings.maxTextures, diff --git a/packages/melonjs/tests/samplercache.spec.js b/packages/melonjs/tests/samplercache.spec.js index d3c1b63b9..731112bd6 100644 --- a/packages/melonjs/tests/samplercache.spec.js +++ b/packages/melonjs/tests/samplercache.spec.js @@ -7,7 +7,7 @@ * `GPUTexture` and `GPUSampler` — which is what allows texture residency to be * keyed by source alone. */ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { boot, game, WebGLRenderer } from "../src/index.js"; import { GLSamplerCache } from "../src/video/webgl/utils/samplercache.js"; import { @@ -126,6 +126,101 @@ describe("GLSamplerCache", () => { cache.releaseAll(true); }); + // `get` runs once per QUAD, so it memoizes its last answer behind a + // three-value guard, and `bind` skips a redundant bind per unit. Both are + // caches over GL objects, which is exactly where a stale entry becomes a + // silently-wrong render rather than an error. + describe("hot-path memoization", () => { + it("returns the cached sampler for a repeated lookup", (ctx) => { + requireWebGL(ctx); + const cache = new GLSamplerCache(gl); + const spy = vi.spyOn(gl, "createSampler"); + const a = cache.get(gl.LINEAR, "repeat", false); + const b = cache.get(gl.LINEAR, "repeat", false); + expect(b).toBe(a); + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + cache.releaseAll(true); + }); + + it("the memo never answers for DIFFERENT arguments", (ctx) => { + requireWebGL(ctx); + // the regression a last-value memo invites: returning the previous + // sampler because only the guard was checked, not the arguments + const cache = new GLSamplerCache(gl); + const base = cache.get(gl.LINEAR, "no-repeat", false); + // change exactly one axis at a time, each straight after a hit + cache.get(gl.LINEAR, "no-repeat", false); + expect(cache.get(gl.NEAREST, "no-repeat", false)).not.toBe(base); + cache.get(gl.LINEAR, "no-repeat", false); + expect(cache.get(gl.LINEAR, "repeat", false)).not.toBe(base); + cache.get(gl.LINEAR, "no-repeat", false); + expect(cache.get(gl.LINEAR, "no-repeat", true)).not.toBe(base); + // and coming back still gives the original + expect(cache.get(gl.LINEAR, "no-repeat", false)).toBe(base); + cache.releaseAll(true); + }); + + it("the memo is dropped on release, so no deleted sampler is reused", (ctx) => { + requireWebGL(ctx); + // the dangerous one: releaseAll deletes the GL objects, and a live + // memo would hand a deleted sampler to the very next draw + const cache = new GLSamplerCache(gl); + const before = cache.get(gl.LINEAR, "repeat", false); + cache.releaseAll(true); + expect(gl.isSampler(before)).toBe(false); + const after = cache.get(gl.LINEAR, "repeat", false); + expect(after).not.toBe(before); + expect(gl.isSampler(after)).toBe(true); + cache.releaseAll(true); + }); + + it("skips a redundant bind, but never a needed one", (ctx) => { + requireWebGL(ctx); + const cache = new GLSamplerCache(gl); + const a = cache.get(gl.LINEAR, "no-repeat"); + const b = cache.get(gl.NEAREST, "repeat"); + const spy = vi.spyOn(gl, "bindSampler"); + + cache.bind(0, a); + cache.bind(0, a); + cache.bind(0, a); + expect(spy).toHaveBeenCalledTimes(1); + + // a different sampler on the same unit must go through + cache.bind(0, b); + expect(spy).toHaveBeenCalledTimes(2); + // and the same sampler on a DIFFERENT unit must too — the tracking + // is per unit, not global + cache.bind(1, b); + expect(spy).toHaveBeenCalledTimes(3); + // unbinding is a real transition + cache.bind(0, null); + expect(spy).toHaveBeenCalledTimes(4); + + spy.mockRestore(); + cache.releaseAll(true); + }); + + it("forgets its bindings on release, so the next bind re-issues", (ctx) => { + requireWebGL(ctx); + // GL drops sampler bindings when the objects die; believing a unit + // still holds one would leave it sampling with the texture's own + // parameters and no call to fix it + const cache = new GLSamplerCache(gl); + const a = cache.get(gl.LINEAR, "no-repeat"); + cache.bind(2, a); + cache.releaseAll(true); + + const fresh = cache.get(gl.LINEAR, "no-repeat"); + const spy = vi.spyOn(gl, "bindSampler"); + cache.bind(2, fresh); + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + cache.releaseAll(true); + }); + }); + it("the renderer owns one, and drops it on context loss", (ctx) => { requireWebGL(ctx); // renderer-owned so every batcher shares it; cleared rather than From 3149f56c1d396c568263546dcef2e97a2a19bac4 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 17 Aug 2026 07:49:08 +0800 Subject: [PATCH 13/16] test(webgl): cover what GLSamplerCache.get actually promises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The magnification filter had no coverage at all. Two mutations proved it: never setting `TEXTURE_MAG_FILTER`, and hardcoding it to NEAREST, both passed the suite — a bug that renders every sprite nearest-neighbour, shipping silently. Four cases added: - both parameters, for both filters, so MAG cannot drift from MIN - asking for mips changes MINification only; a chain says nothing about drawing a texture larger than itself - `get(filter)` defaults match the spelled-out form exactly, and resolve to the SAME sampler — otherwise the per-quad memo would treat two spellings of one state as different and mint a second object - an unrecognized repeat string clamps rather than tiling Re-verified by mutation: dropping the MAG call, hardcoding it, and flipping the default repeat each now fail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- packages/melonjs/tests/samplercache.spec.js | 75 +++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/packages/melonjs/tests/samplercache.spec.js b/packages/melonjs/tests/samplercache.spec.js index 731112bd6..910edd5c5 100644 --- a/packages/melonjs/tests/samplercache.spec.js +++ b/packages/melonjs/tests/samplercache.spec.js @@ -84,6 +84,81 @@ describe("GLSamplerCache", () => { cache.releaseAll(true); }); + it("sets every parameter it promises, both filters", (ctx) => { + requireWebGL(ctx); + // MAG was the gap: nothing asserted it, so "never set it" and + // "hardcode it to NEAREST" both passed — a bug that would render every + // sprite nearest-neighbour with no test to catch it + const cache = new GLSamplerCache(gl); + const linear = cache.get(gl.LINEAR, "no-repeat", false); + expect(gl.getSamplerParameter(linear, gl.TEXTURE_MAG_FILTER)).toBe( + gl.LINEAR, + ); + expect(gl.getSamplerParameter(linear, gl.TEXTURE_MIN_FILTER)).toBe( + gl.LINEAR, + ); + + const nearest = cache.get(gl.NEAREST, "no-repeat", false); + expect(gl.getSamplerParameter(nearest, gl.TEXTURE_MAG_FILTER)).toBe( + gl.NEAREST, + ); + expect(gl.getSamplerParameter(nearest, gl.TEXTURE_MIN_FILTER)).toBe( + gl.NEAREST, + ); + cache.releaseAll(true); + }); + + it("mip filtering never changes magnification", (ctx) => { + requireWebGL(ctx); + // asking for mips affects MINification only — a chain has nothing to + // say about drawing a texture larger than itself + const cache = new GLSamplerCache(gl); + const mipped = cache.get(gl.LINEAR, "no-repeat", true); + expect(gl.getSamplerParameter(mipped, gl.TEXTURE_MAG_FILTER)).toBe( + gl.LINEAR, + ); + expect(gl.getSamplerParameter(mipped, gl.TEXTURE_MIN_FILTER)).toBe( + gl.LINEAR_MIPMAP_LINEAR, + ); + cache.releaseAll(true); + }); + + it("defaults to clamped, unmipped, and agrees with the explicit form", (ctx) => { + requireWebGL(ctx); + // `get(filter)` is the common call; its defaults must match the spelled + // out version or the memo would treat them as different states + const cache = new GLSamplerCache(gl); + const implicit = cache.get(gl.LINEAR); + const explicit = cache.get(gl.LINEAR, "no-repeat", false); + expect(implicit).toBe(explicit); + expect(gl.getSamplerParameter(implicit, gl.TEXTURE_WRAP_S)).toBe( + gl.CLAMP_TO_EDGE, + ); + expect(gl.getSamplerParameter(implicit, gl.TEXTURE_WRAP_T)).toBe( + gl.CLAMP_TO_EDGE, + ); + expect(gl.getSamplerParameter(implicit, gl.TEXTURE_MIN_FILTER)).toBe( + gl.LINEAR, + ); + expect(cache.samplers.size).toBe(1); + cache.releaseAll(true); + }); + + it("an unrecognized repeat string clamps rather than tiling", (ctx) => { + requireWebGL(ctx); + // a typo'd repeat must not silently produce a tiling sampler — the + // engine's own cache normalizes unknown values to "no-repeat" + const cache = new GLSamplerCache(gl); + const bogus = cache.get(gl.LINEAR, "repat-x"); + expect(gl.getSamplerParameter(bogus, gl.TEXTURE_WRAP_S)).toBe( + gl.CLAMP_TO_EDGE, + ); + expect(gl.getSamplerParameter(bogus, gl.TEXTURE_WRAP_T)).toBe( + gl.CLAMP_TO_EDGE, + ); + cache.releaseAll(true); + }); + it("only goes trilinear over a linear chain", (ctx) => { requireWebGL(ctx); // "nearest" opts out of mip filtering so crisp pixel-art keeps hard From 4af9d5d6e4caeab954313e5c639cbfe6f1afd121 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 17 Aug 2026 07:57:32 +0800 Subject: [PATCH 14/16] test: release the WebGL context the new specs own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both new specs construct their own Application, and neither released it. Browsers cap live WebGL contexts, so a suite that leaks one per spec eventually cannot create any — and that surfaces as UNRELATED specs failing, including the `webgl_available` tripwire, rather than as the leaking spec. Seen for real during this branch's mutation testing: three specs failed together after a run of rapid context creation, and passed on a re-run with no source change. This does not fix the wider pattern — many existing specs build an Application and never destroy it — only the two added here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- packages/melonjs/tests/maxtextures-cliff.spec.js | 10 +++++++++- packages/melonjs/tests/texture-reupload.spec.js | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/melonjs/tests/maxtextures-cliff.spec.js b/packages/melonjs/tests/maxtextures-cliff.spec.js index 70cba36e8..2eebbbfac 100644 --- a/packages/melonjs/tests/maxtextures-cliff.spec.js +++ b/packages/melonjs/tests/maxtextures-cliff.spec.js @@ -21,7 +21,7 @@ * That gap is exactly what a lit scene used to pay before #1585, since * `LitQuadBatcher` halved the pool and reserved the upper half for normal maps. */ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, video } from "../src/index.js"; import { GPU_TEXTURE_CACHE_RESET, off, on } from "../src/system/event.ts"; @@ -58,6 +58,14 @@ describe("texture-count cliff", () => { renderer = app.renderer; }); + afterAll(() => { + // this spec owns its Application, so it owns the WebGL context too. + // Browsers cap live contexts, and a suite that leaks one per spec + // eventually fails to create any — which surfaces as unrelated specs + // failing, not as this one. + app?.destroy(); + }); + const requireWebGL = (ctx) => { if (!renderer?.gl) { ctx.skip("WebGL renderer not available in this environment"); diff --git a/packages/melonjs/tests/texture-reupload.spec.js b/packages/melonjs/tests/texture-reupload.spec.js index e55d9a9f0..dbf4f9ca4 100644 --- a/packages/melonjs/tests/texture-reupload.spec.js +++ b/packages/melonjs/tests/texture-reupload.spec.js @@ -13,7 +13,7 @@ * These assertions count GL calls, which is exact — no timing, so nothing here * is flaky. Frame time was the symptom; call counts are the mechanism. */ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, video } from "../src/index.js"; const SIZE = 128; @@ -46,6 +46,14 @@ describe("texture re-upload on overflow", () => { }); }); + afterAll(() => { + // this spec owns its Application, so it owns the WebGL context too. + // Browsers cap live contexts, and a suite that leaks one per spec + // eventually fails to create any — which surfaces as unrelated specs + // failing, not as this one. + app?.destroy(); + }); + const requireWebGL = (ctx) => { if (!renderer?.gl) { ctx.skip("WebGL renderer not available in this environment"); From 9f7b3730cf07483988ff2437b4c0893c98c0802f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 17 Aug 2026 08:22:28 +0800 Subject: [PATCH 15/16] test: release the WebGL context each spec owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The intermittent CI failures — the ones that surface as a 90s beforeAll timeout in getWebGLRenderer, or as the `webgl_available` tripwire, rather than as an assertion — are context starvation. Browsers cap live WebGL contexts, and 61 spec files built an Application and never released it, so the count climbed for the whole run and whichever spec happened to be next when the cap was hit failed for reasons unrelated to what it tests. The dominant shape was `const app = new Application(...)` INSIDE a beforeAll: local to the hook, so unreachable from any teardown. Hoisted the binding out per describe block and added an afterAll (afterEach where the app is per-test) that releases it. 56 files, 72 describe blocks. A previous fix had already addressed the other half of this: the teardown hooks that construct a fresh Canvas Application to hand later spec files a clean default were converted from AUTO precisely so they stop taking a GL context. Verified none of those regressed — every reset-only app is still Canvas. Five describe blocks are deliberately left leaking. Two need their app past teardown (adding a destroy fails their context-loss cases); the rest build apps inside individual `it()` blocks or have no describe-scope binding, and `destroy()` is terminal, so a wrong release is worse than a leak. Specs that legitimately call destroy() as the thing under test are untouched. Verified with three consecutive full runs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- .../tests/builtin-adapter-adversarial.spec.js | 11 +++- .../tests/builtin-adapter-angular.spec.js | 11 +++- .../tests/builtin-adapter-body.spec.js | 11 +++- ...uiltin-adapter-collision-contracts.spec.js | 20 +++++- .../tests/builtin-adapter-lifecycle.spec.js | 11 +++- .../builtin-adapter-physics-bug-hunt.spec.js | 11 +++- .../builtin-adapter-platformer-parity.spec.js | 11 +++- .../tests/builtin-adapter-stress.spec.js | 11 +++- .../tests/builtin-adapter-world.spec.js | 11 +++- packages/melonjs/tests/camera3d.spec.js | 11 +++- .../tests/camera3d_integration.spec.js | 9 ++- .../tests/canvas-cliprect-transform.spec.js | 6 ++ packages/melonjs/tests/clipRect.spec.js | 9 ++- .../tests/createPattern_repeat_parity.spec.js | 6 ++ packages/melonjs/tests/depth.spec.js | 18 +++++ .../melonjs/tests/detector-end-frame.spec.js | 11 +++- packages/melonjs/tests/emitter.spec.js | 11 +++- packages/melonjs/tests/entity.spec.js | 11 +++- .../tests/fillpolygon_mutation.spec.js | 6 ++ .../tests/floating_container_text.spec.js | 8 ++- packages/melonjs/tests/frameAnimation.spec.js | 11 +++- packages/melonjs/tests/glcore-audit.spec.js | 3 + packages/melonjs/tests/gltf.spec.js | 27 +++++++- packages/melonjs/tests/gltf_model.spec.js | 9 ++- packages/melonjs/tests/gradient.spec.js | 8 ++- packages/melonjs/tests/imagelayer.spec.js | 8 ++- packages/melonjs/tests/input.spec.js | 8 ++- packages/melonjs/tests/lighting3d.spec.js | 6 ++ packages/melonjs/tests/mesh.spec.js | 6 ++ .../melonjs/tests/nineslicesprite.spec.js | 11 +++- packages/melonjs/tests/octree.spec.js | 56 ++++++++++++++-- packages/melonjs/tests/quadtree.spec.js | 11 +++- packages/melonjs/tests/queryAABB.spec.js | 11 +++- packages/melonjs/tests/raycast.spec.js | 8 ++- packages/melonjs/tests/renderer.spec.js | 8 ++- .../tests/renderer_save_restore.spec.js | 9 ++- packages/melonjs/tests/setMask.spec.js | 6 ++ packages/melonjs/tests/shader-canvas.spec.js | 11 +++- packages/melonjs/tests/sprite-depth.spec.js | 11 +++- .../melonjs/tests/sprite-trimming.spec.js | 11 +++- packages/melonjs/tests/sprite3d.spec.js | 65 ++++++++++++++++--- packages/melonjs/tests/state.spec.js | 11 +++- packages/melonjs/tests/text.spec.js | 11 +++- packages/melonjs/tests/textVisibility.spec.js | 11 +++- packages/melonjs/tests/texture.spec.js | 8 ++- .../melonjs/tests/tmx-shape-factory.spec.js | 11 +++- packages/melonjs/tests/tmxlayer-data.spec.js | 11 +++- .../melonjs/tests/tmxlayer-drawraw.spec.js | 11 +++- packages/melonjs/tests/tmxobject.spec.js | 11 +++- packages/melonjs/tests/tmxrenderer.spec.js | 11 +++- packages/melonjs/tests/tmxtilemap.spec.js | 11 +++- packages/melonjs/tests/tmxtileset.spec.js | 11 +++- packages/melonjs/tests/toframetexture.spec.js | 8 ++- packages/melonjs/tests/trigger.spec.js | 8 ++- packages/melonjs/tests/ui.spec.js | 8 ++- .../melonjs/tests/webgl_save_restore.spec.js | 9 ++- .../tests/webgl_save_restore_bench.spec.js | 11 +++- .../tests/webgl_vao_adversarial.spec.js | 8 ++- packages/melonjs/tests/world.spec.js | 11 +++- 59 files changed, 600 insertions(+), 99 deletions(-) diff --git a/packages/melonjs/tests/builtin-adapter-adversarial.spec.js b/packages/melonjs/tests/builtin-adapter-adversarial.spec.js index e68035e21..bcf5e50d5 100644 --- a/packages/melonjs/tests/builtin-adapter-adversarial.spec.js +++ b/packages/melonjs/tests/builtin-adapter-adversarial.spec.js @@ -18,7 +18,7 @@ * - Boundary cases on velocity/impulse math */ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, Body, @@ -38,9 +38,10 @@ describe("Physics : BuiltinAdapter (adversarial)", () => { /** @type {BuiltinAdapter} */ let adapter; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -48,6 +49,12 @@ describe("Physics : BuiltinAdapter (adversarial)", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { world = new World(0, 0, 800, 600); adapter = world.adapter; diff --git a/packages/melonjs/tests/builtin-adapter-angular.spec.js b/packages/melonjs/tests/builtin-adapter-angular.spec.js index 3a3939234..fe6b370a7 100644 --- a/packages/melonjs/tests/builtin-adapter-angular.spec.js +++ b/packages/melonjs/tests/builtin-adapter-angular.spec.js @@ -14,7 +14,7 @@ * fields, zero transform mutation, identical `update()` return values. */ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, BuiltinAdapter, @@ -30,9 +30,10 @@ describe("Physics : BuiltinAdapter angular API", () => { /** @type {BuiltinAdapter} */ let adapter; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -40,6 +41,12 @@ describe("Physics : BuiltinAdapter angular API", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { adapter = new BuiltinAdapter(); // eslint-disable-next-line no-new diff --git a/packages/melonjs/tests/builtin-adapter-body.spec.js b/packages/melonjs/tests/builtin-adapter-body.spec.js index 387108d85..420976207 100644 --- a/packages/melonjs/tests/builtin-adapter-body.spec.js +++ b/packages/melonjs/tests/builtin-adapter-body.spec.js @@ -9,7 +9,7 @@ * equivalent — that's exactly the bug we want to catch early. */ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, Body, @@ -29,9 +29,10 @@ describe("Physics : BuiltinAdapter (Body parity with body.spec.js)", () => { /** @type {BuiltinAdapter} */ let adapter; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -39,6 +40,12 @@ describe("Physics : BuiltinAdapter (Body parity with body.spec.js)", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { adapter = new BuiltinAdapter(); // World wiring sets adapter.world and adapter.detector via init(); diff --git a/packages/melonjs/tests/builtin-adapter-collision-contracts.spec.js b/packages/melonjs/tests/builtin-adapter-collision-contracts.spec.js index 878c514dc..34b2da21e 100644 --- a/packages/melonjs/tests/builtin-adapter-collision-contracts.spec.js +++ b/packages/melonjs/tests/builtin-adapter-collision-contracts.spec.js @@ -19,7 +19,7 @@ * These tests pin both contracts so neither side regresses. */ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, boot, @@ -38,9 +38,10 @@ describe("Physics : onCollision legacy contract (19.4 backward-compat)", () => { let aCalls; let bCalls; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -48,6 +49,12 @@ describe("Physics : onCollision legacy contract (19.4 backward-compat)", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { world = new World(0, 0, 800, 600); aCalls = []; @@ -255,9 +262,10 @@ describe("Physics : onCollisionActive new contract (19.5+, receiver-symmetric)", let aCalls; let bCalls; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -265,6 +273,12 @@ describe("Physics : onCollisionActive new contract (19.5+, receiver-symmetric)", await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { world = new World(0, 0, 800, 600); aCalls = []; diff --git a/packages/melonjs/tests/builtin-adapter-lifecycle.spec.js b/packages/melonjs/tests/builtin-adapter-lifecycle.spec.js index 1a741ba84..24b8a964f 100644 --- a/packages/melonjs/tests/builtin-adapter-lifecycle.spec.js +++ b/packages/melonjs/tests/builtin-adapter-lifecycle.spec.js @@ -9,7 +9,7 @@ * body) slipped past the math-focused tests. */ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, Body, @@ -28,9 +28,10 @@ describe("Physics : BuiltinAdapter (lifecycle + pool)", () => { /** @type {import("../src/index.js").BuiltinAdapter} */ let adapter; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -38,6 +39,12 @@ describe("Physics : BuiltinAdapter (lifecycle + pool)", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { world = new World(0, 0, 800, 600); adapter = world.adapter; diff --git a/packages/melonjs/tests/builtin-adapter-physics-bug-hunt.spec.js b/packages/melonjs/tests/builtin-adapter-physics-bug-hunt.spec.js index acd23beed..f3b3703da 100644 --- a/packages/melonjs/tests/builtin-adapter-physics-bug-hunt.spec.js +++ b/packages/melonjs/tests/builtin-adapter-physics-bug-hunt.spec.js @@ -9,7 +9,7 @@ * issue we found and are kept as red gates until fixed. */ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, boot, @@ -26,9 +26,10 @@ describe("Physics : BuiltinAdapter (latent bug hunt)", () => { /** @type {BuiltinAdapter} */ let adapter; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -36,6 +37,12 @@ describe("Physics : BuiltinAdapter (latent bug hunt)", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { world = new World(0, 0, 800, 600); adapter = world.adapter; diff --git a/packages/melonjs/tests/builtin-adapter-platformer-parity.spec.js b/packages/melonjs/tests/builtin-adapter-platformer-parity.spec.js index 65feb0a53..af2b766f5 100644 --- a/packages/melonjs/tests/builtin-adapter-platformer-parity.spec.js +++ b/packages/melonjs/tests/builtin-adapter-platformer-parity.spec.js @@ -13,7 +13,7 @@ * equivalent of comparing two browser playthroughs side by side. */ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, Body, @@ -40,9 +40,10 @@ describe("Physics : platformer parity (legacy API vs adapter API)", () => { /** @type {Renderable} */ let entityB; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -50,6 +51,12 @@ describe("Physics : platformer parity (legacy API vs adapter API)", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { // === side A: legacy API === worldA = new World(0, 0, 800, 600); diff --git a/packages/melonjs/tests/builtin-adapter-stress.spec.js b/packages/melonjs/tests/builtin-adapter-stress.spec.js index e7e6957d9..529134b69 100644 --- a/packages/melonjs/tests/builtin-adapter-stress.spec.js +++ b/packages/melonjs/tests/builtin-adapter-stress.spec.js @@ -9,7 +9,7 @@ * or collision-pair record, the size mismatch surfaces here. */ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, Body, @@ -37,9 +37,10 @@ describe("Physics : BuiltinAdapter (lifecycle leak stress)", () => { /** @type {import("../src/index.js").BuiltinAdapter} */ let adapter; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -47,6 +48,12 @@ describe("Physics : BuiltinAdapter (lifecycle leak stress)", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { world = new World(0, 0, 800, 600); adapter = world.adapter; diff --git a/packages/melonjs/tests/builtin-adapter-world.spec.js b/packages/melonjs/tests/builtin-adapter-world.spec.js index f3738b265..cd5bb139f 100644 --- a/packages/melonjs/tests/builtin-adapter-world.spec.js +++ b/packages/melonjs/tests/builtin-adapter-world.spec.js @@ -8,7 +8,7 @@ * property after every mutation, or the abstraction has leaked. */ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, Body, @@ -28,9 +28,10 @@ describe("Physics : World (parity with world.spec.js via adapter)", () => { /** @type {BuiltinAdapter} */ let adapter; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -38,6 +39,12 @@ describe("Physics : World (parity with world.spec.js via adapter)", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { world = new World(0, 0, 800, 600); adapter = world.adapter; diff --git a/packages/melonjs/tests/camera3d.spec.js b/packages/melonjs/tests/camera3d.spec.js index b7bf2887b..e23df80d9 100644 --- a/packages/melonjs/tests/camera3d.spec.js +++ b/packages/melonjs/tests/camera3d.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, @@ -18,12 +18,13 @@ import { * pitch/yaw, follow logic) is pure JS. */ describe("Camera3d", () => { + let app; beforeAll(async () => { // some Camera2d subclass paths need a renderer to construct // (e.g. Renderable observableVector callbacks). Boot a Canvas // renderer for those. boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -31,6 +32,12 @@ describe("Camera3d", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + describe("constructor + defaults", () => { it("extends Camera2d (drop-in compatible)", () => { const cam = new Camera3d(0, 0, 800, 600); diff --git a/packages/melonjs/tests/camera3d_integration.spec.js b/packages/melonjs/tests/camera3d_integration.spec.js index cebd050d5..81f9de58b 100644 --- a/packages/melonjs/tests/camera3d_integration.spec.js +++ b/packages/melonjs/tests/camera3d_integration.spec.js @@ -22,9 +22,10 @@ import { * even when the app sets `cameraClass: Camera3d` globally. */ describe("Camera3d × Stage × Application integration", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -32,6 +33,12 @@ describe("Camera3d × Stage × Application integration", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(async () => { // hand the world back to a clean default for any later test files const app = new Application(800, 600, { diff --git a/packages/melonjs/tests/canvas-cliprect-transform.spec.js b/packages/melonjs/tests/canvas-cliprect-transform.spec.js index bd3beb44c..f67b7bcc8 100644 --- a/packages/melonjs/tests/canvas-cliprect-transform.spec.js +++ b/packages/melonjs/tests/canvas-cliprect-transform.spec.js @@ -33,6 +33,12 @@ describe("CanvasRenderer clipRect vs transforms", () => { expect(renderer).toBeInstanceOf(CanvasRenderer); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(async () => { try { const app = new Application(64, 64, { diff --git a/packages/melonjs/tests/clipRect.spec.js b/packages/melonjs/tests/clipRect.spec.js index 0e541c242..1b1806a24 100644 --- a/packages/melonjs/tests/clipRect.spec.js +++ b/packages/melonjs/tests/clipRect.spec.js @@ -575,9 +575,10 @@ describe("CanvasRenderer.clipRect (#1349)", () => { let renderer; let isCanvas; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -587,6 +588,12 @@ describe("CanvasRenderer.clipRect (#1349)", () => { isCanvas = renderer instanceof CanvasRenderer; }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(() => { // hand the world back to the default renderer for any later test files releaseWebGLRenderer(); diff --git a/packages/melonjs/tests/createPattern_repeat_parity.spec.js b/packages/melonjs/tests/createPattern_repeat_parity.spec.js index df4203fb5..c6f1b2207 100644 --- a/packages/melonjs/tests/createPattern_repeat_parity.spec.js +++ b/packages/melonjs/tests/createPattern_repeat_parity.spec.js @@ -51,6 +51,12 @@ describe("createPattern repeat-mode parity (#1448)", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("two patterns from one image are distinct, defined handles", () => { // Canvas mode returns a raw `CanvasPattern` per call (an opaque // DOM type — no `.repeat` field exposed in JS), but the calls diff --git a/packages/melonjs/tests/depth.spec.js b/packages/melonjs/tests/depth.spec.js index 77033caa5..6ea829447 100644 --- a/packages/melonjs/tests/depth.spec.js +++ b/packages/melonjs/tests/depth.spec.js @@ -94,6 +94,12 @@ describe("Renderer.setDepth", () => { renderer.setProjection(PERSPECTIVE); // depth-carry path }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(async () => { // hand the world back to the default renderer for any later test files const app = new Application(800, 600, { @@ -151,6 +157,12 @@ describe("Renderable.preDraw forwards depth", () => { renderer.setProjection(PERSPECTIVE); // depth-carry path }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(async () => { const app = new Application(800, 600, { parent: "screen", @@ -226,6 +238,12 @@ describe("WebGL batchers carry depth as vec3 aVertex (PR A)", () => { isWebGL = renderer instanceof WebGLRenderer; }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(async () => { const app = new Application(800, 600, { parent: "screen", diff --git a/packages/melonjs/tests/detector-end-frame.spec.js b/packages/melonjs/tests/detector-end-frame.spec.js index 0a71bfd15..35bb33a98 100644 --- a/packages/melonjs/tests/detector-end-frame.spec.js +++ b/packages/melonjs/tests/detector-end-frame.spec.js @@ -10,7 +10,7 @@ * `onCollisionStart` / no `onCollisionEnd`. */ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, boot, @@ -35,9 +35,10 @@ describe("Detector.endFrame — onCollisionEnd survivor dispatch", () => { /** @type {Detector} */ let detector; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -45,6 +46,12 @@ describe("Detector.endFrame — onCollisionEnd survivor dispatch", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { const world = new World(0, 0, 800, 600); detector = new Detector(world); diff --git a/packages/melonjs/tests/emitter.spec.js b/packages/melonjs/tests/emitter.spec.js index 6e28a5fd7..2a7792ea2 100644 --- a/packages/melonjs/tests/emitter.spec.js +++ b/packages/melonjs/tests/emitter.spec.js @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Application, boot, @@ -11,9 +11,10 @@ import { describe("ParticleEmitter", () => { let emitter; + let app; beforeEach(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -26,6 +27,12 @@ describe("ParticleEmitter", () => { }); }); + afterEach(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("should be created at the specified position", () => { expect(emitter).toBeDefined(); // emitter is centered around the given coordinates diff --git a/packages/melonjs/tests/entity.spec.js b/packages/melonjs/tests/entity.spec.js index 1b407be67..ae72973b0 100644 --- a/packages/melonjs/tests/entity.spec.js +++ b/packages/melonjs/tests/entity.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, @@ -14,9 +14,10 @@ describe("Entity", () => { let entity; let defaultRectShape; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -48,6 +49,12 @@ describe("Entity", () => { defaultRectShape = new Rect(10, 10, 32, 64); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("has an empty set of shapes", () => { expect(entity.body.shapes.length).toEqual(0); }); diff --git a/packages/melonjs/tests/fillpolygon_mutation.spec.js b/packages/melonjs/tests/fillpolygon_mutation.spec.js index 6aa891080..ba43046c2 100644 --- a/packages/melonjs/tests/fillpolygon_mutation.spec.js +++ b/packages/melonjs/tests/fillpolygon_mutation.spec.js @@ -34,6 +34,12 @@ describe("Drawing methods should not mutate input shapes", () => { renderer = app.renderer; }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(async () => { const app = new Application(800, 600, { parent: "screen", diff --git a/packages/melonjs/tests/floating_container_text.spec.js b/packages/melonjs/tests/floating_container_text.spec.js index 15bcf4d8c..217384457 100644 --- a/packages/melonjs/tests/floating_container_text.spec.js +++ b/packages/melonjs/tests/floating_container_text.spec.js @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, @@ -73,6 +73,12 @@ describe("floating Container children (WebGL)", () => { } }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterEach(() => { // each test owns its scene graph — drop leftovers without waiting for // the deferred remove path diff --git a/packages/melonjs/tests/frameAnimation.spec.js b/packages/melonjs/tests/frameAnimation.spec.js index b9dde641d..b91742665 100644 --- a/packages/melonjs/tests/frameAnimation.spec.js +++ b/packages/melonjs/tests/frameAnimation.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { Application, boot, @@ -450,15 +450,22 @@ for (const HOST of HOSTS) { // Sprite3d-only: a frame change must remap the quad's UVs (its `_applyFrame`) describe("FrameAnimation → Sprite3d UV remap", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(64, 64, { + app = new Application(64, 64, { parent: "screen", renderer: video.CANVAS, }); await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("maps the current frame onto the quad UVs", () => { const s = new Sprite3d(0, 0, { image: makeSheet(), diff --git a/packages/melonjs/tests/glcore-audit.spec.js b/packages/melonjs/tests/glcore-audit.spec.js index bec9bda43..7317ca4db 100644 --- a/packages/melonjs/tests/glcore-audit.spec.js +++ b/packages/melonjs/tests/glcore-audit.spec.js @@ -52,6 +52,9 @@ describe("video/GL core audit reproductions", () => { }); afterAll(async () => { + // release the app this describe owns — browsers cap live WebGL + // contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); try { const app = new Application(64, 64, { parent: "screen", diff --git a/packages/melonjs/tests/gltf.spec.js b/packages/melonjs/tests/gltf.spec.js index dfe5529f5..8013d41d8 100644 --- a/packages/melonjs/tests/gltf.spec.js +++ b/packages/melonjs/tests/gltf.spec.js @@ -740,9 +740,10 @@ describe("parseGLTF() — baseColorFactor & vertex colors", () => { // GLTFScene application const NAME = "__gltf_mat_apply"; const COLOR_NAME = "__gltf_color_apply"; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -753,6 +754,12 @@ describe("parseGLTF() — baseColorFactor & vertex colors", () => { buildColorGLB(new Float32Array([1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1]), 4), ); }); + + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); afterAll(() => { delete gltfList[NAME]; delete gltfList[COLOR_NAME]; @@ -849,9 +856,10 @@ describe("GLTFScene", () => { describe("GLTFScene → Mesh instantiation", () => { const NAME = "__gltf_addto_scene"; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -860,6 +868,12 @@ describe("GLTFScene → Mesh instantiation", () => { gltfList[NAME] = await parseGLTF(buildSceneGLB()); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(() => { delete gltfList[NAME]; }); @@ -1021,9 +1035,10 @@ describe("GLTFScene → Mesh instantiation", () => { describe("GLTFScene → lighting (KHR_lights_punctual)", () => { const NAME = "__gltf_lit_scene"; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -1067,6 +1082,12 @@ describe("GLTFScene → lighting (KHR_lights_punctual)", () => { gltfList[NAME] = await parseGLTF(packGLB(json, bin)); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(() => { delete gltfList[NAME]; }); diff --git a/packages/melonjs/tests/gltf_model.spec.js b/packages/melonjs/tests/gltf_model.spec.js index c16024ac6..0f4fe66a7 100644 --- a/packages/melonjs/tests/gltf_model.spec.js +++ b/packages/melonjs/tests/gltf_model.spec.js @@ -106,9 +106,10 @@ const childOf = (model) => { }; describe("GLTFModel", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -117,6 +118,12 @@ describe("GLTFModel", () => { TEX = Renderer.createCanvas(8, 8); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(async () => { const app = new Application(800, 600, { parent: "screen", diff --git a/packages/melonjs/tests/gradient.spec.js b/packages/melonjs/tests/gradient.spec.js index b418e2ba0..843c7946c 100644 --- a/packages/melonjs/tests/gradient.spec.js +++ b/packages/melonjs/tests/gradient.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, Color, Gradient } from "../src/index.js"; describe("Gradient", () => { @@ -12,6 +12,12 @@ describe("Gradient", () => { await app.init(); }); + afterAll(() => { + // release the app this describe owns — browsers cap live WebGL + // contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + describe("createLinearGradient", () => { it("should return a Gradient instance", () => { const gradient = app.renderer.createLinearGradient(0, 0, 100, 0); diff --git a/packages/melonjs/tests/imagelayer.spec.js b/packages/melonjs/tests/imagelayer.spec.js index 2d7360362..8d47938bc 100644 --- a/packages/melonjs/tests/imagelayer.spec.js +++ b/packages/melonjs/tests/imagelayer.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { Application, boot, @@ -27,6 +27,12 @@ describe("ImageLayer", () => { testImage.height = 64; }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + describe("createPattern", () => { it("should create pattern when added to game world", () => { const layer = new ImageLayer(0, 0, { diff --git a/packages/melonjs/tests/input.spec.js b/packages/melonjs/tests/input.spec.js index b7909dabe..ed26d3988 100644 --- a/packages/melonjs/tests/input.spec.js +++ b/packages/melonjs/tests/input.spec.js @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, input, Renderable, video } from "../src/index.js"; describe("input", () => { @@ -13,6 +13,12 @@ describe("input", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + describe("Pointer Event", () => { afterEach(() => { // clean up the game world diff --git a/packages/melonjs/tests/lighting3d.spec.js b/packages/melonjs/tests/lighting3d.spec.js index bc3e58fd9..afb23ff70 100644 --- a/packages/melonjs/tests/lighting3d.spec.js +++ b/packages/melonjs/tests/lighting3d.spec.js @@ -84,6 +84,12 @@ describe("Light3d ↔ Stage registration", () => { state.change(state.DEFAULT, true); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(async () => { const app = new Application(800, 600, { parent: "screen", diff --git a/packages/melonjs/tests/mesh.spec.js b/packages/melonjs/tests/mesh.spec.js index 59ebfea2b..99996d9bd 100644 --- a/packages/melonjs/tests/mesh.spec.js +++ b/packages/melonjs/tests/mesh.spec.js @@ -1009,6 +1009,12 @@ describe("Mesh × Camera3d world-space path", () => { state.change(state.DEFAULT, true); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(async () => { // reset to default Camera2d so later spec files don't inherit // our Camera3d viewport. diff --git a/packages/melonjs/tests/nineslicesprite.spec.js b/packages/melonjs/tests/nineslicesprite.spec.js index 22a21450b..4a5dec4ab 100644 --- a/packages/melonjs/tests/nineslicesprite.spec.js +++ b/packages/melonjs/tests/nineslicesprite.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { Application, boot, NineSliceSprite, video } from "../src/index.js"; /** @@ -67,15 +67,22 @@ const expectTiles = (a, total) => { }; describe("NineSliceSprite", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(256, 256, { + app = new Application(256, 256, { parent: "screen", renderer: video.CANVAS, }); await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + // ── construction ───────────────────────────────────────────────────────── it("throws when width is missing", () => { diff --git a/packages/melonjs/tests/octree.spec.js b/packages/melonjs/tests/octree.spec.js index c993d9224..6d79db375 100644 --- a/packages/melonjs/tests/octree.spec.js +++ b/packages/melonjs/tests/octree.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, boot, @@ -47,9 +47,10 @@ describe("Octree", () => { let world; let octree; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -57,6 +58,12 @@ describe("Octree", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { world = new World(0, 0, 800, 600); // stand up a small octree centred on the origin with a known @@ -1042,9 +1049,10 @@ describe("Octree", () => { }); describe("World broadphase dispatch (sortOn setter)", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -1052,6 +1060,12 @@ describe("World broadphase dispatch (sortOn setter)", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("constructs a QuadTree by default (sortOn=z)", () => { const world = new World(0, 0, 800, 600); expect(world.sortOn).toBe("z"); @@ -1127,9 +1141,10 @@ describe("World broadphase dispatch (sortOn setter)", () => { }); describe("BuiltinAdapter.raycast3d", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -1137,6 +1152,12 @@ describe("BuiltinAdapter.raycast3d", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("returns null when sortOn is 2D (broadphase is QuadTree)", () => { const world = new World(0, 0, 800, 600); expect(world.sortOn).toBe("z"); @@ -1267,9 +1288,10 @@ describe("BuiltinAdapter.raycast3d", () => { }); describe("BuiltinAdapter.querySphere", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -1277,6 +1299,12 @@ describe("BuiltinAdapter.querySphere", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("returns empty under a 2D camera (sortOn !== 'depth')", () => { const world = new World(0, 0, 800, 600); const candidates = world.adapter.querySphere({ x: 0, y: 0, z: 0 }, 100); @@ -1373,9 +1401,10 @@ describe("BuiltinAdapter.querySphere", () => { }); describe("Camera3d.queryVisible", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -1383,6 +1412,12 @@ describe("Camera3d.queryVisible", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("returns empty under a 2D broadphase (sortOn !== 'depth')", () => { const world = new World(0, 0, 800, 600); // don't flip sortOn — broadphase is QuadTree @@ -1441,9 +1476,10 @@ describe("Camera3d.queryVisible", () => { * deterministic gameplay-only candidate sets. */ describe("2.5D pattern (Camera3d + Octree + same-Z gameplay)", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -1451,6 +1487,12 @@ describe("2.5D pattern (Camera3d + Octree + same-Z gameplay)", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("World swaps to Octree when sortOn flips to 'depth'", () => { const world = new World(0, 0, 800, 600); expect(world.broadphase).not.toBeInstanceOf(Octree); diff --git a/packages/melonjs/tests/quadtree.spec.js b/packages/melonjs/tests/quadtree.spec.js index 3aba2e1dd..a63e6da5e 100644 --- a/packages/melonjs/tests/quadtree.spec.js +++ b/packages/melonjs/tests/quadtree.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, Body, @@ -16,9 +16,10 @@ import { describe("QuadTree & Collision Detection", () => { let world; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -26,6 +27,12 @@ describe("QuadTree & Collision Detection", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { world = new World(0, 0, 800, 600); }); diff --git a/packages/melonjs/tests/queryAABB.spec.js b/packages/melonjs/tests/queryAABB.spec.js index b6b325d16..7d52f5024 100644 --- a/packages/melonjs/tests/queryAABB.spec.js +++ b/packages/melonjs/tests/queryAABB.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, Body, @@ -49,9 +49,10 @@ describe("BuiltinAdapter.queryAABB", () => { let adapter; let world; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -59,6 +60,12 @@ describe("BuiltinAdapter.queryAABB", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { adapter = new BuiltinAdapter(); world = new World(0, 0, 800, 600, adapter); diff --git a/packages/melonjs/tests/raycast.spec.js b/packages/melonjs/tests/raycast.spec.js index 11e731550..45597ce1e 100644 --- a/packages/melonjs/tests/raycast.spec.js +++ b/packages/melonjs/tests/raycast.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, Body, @@ -76,6 +76,12 @@ describe("Raycast", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { adapter = new BuiltinAdapter(); world = new World(0, 0, 800, 600, adapter); diff --git a/packages/melonjs/tests/renderer.spec.js b/packages/melonjs/tests/renderer.spec.js index d8e29cb73..302b6e826 100644 --- a/packages/melonjs/tests/renderer.spec.js +++ b/packages/melonjs/tests/renderer.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, CanvasRenderer, video } from "../src/index.js"; describe("Custom Renderer", () => { @@ -34,6 +34,12 @@ describe("setAntiAlias", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("should default to false (NEAREST filtering)", () => { expect(app.renderer.settings.antiAlias).toBe(false); }); diff --git a/packages/melonjs/tests/renderer_save_restore.spec.js b/packages/melonjs/tests/renderer_save_restore.spec.js index b5869d124..1ec006cba 100644 --- a/packages/melonjs/tests/renderer_save_restore.spec.js +++ b/packages/melonjs/tests/renderer_save_restore.spec.js @@ -9,9 +9,10 @@ import { Application, boot, video } from "../src/index.js"; describe("Renderer save/restore", () => { let renderer; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -20,6 +21,12 @@ describe("Renderer save/restore", () => { renderer = app.renderer; }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { renderer.setColor("#000000"); renderer.setGlobalAlpha(1.0); diff --git a/packages/melonjs/tests/setMask.spec.js b/packages/melonjs/tests/setMask.spec.js index 185c8470b..7fbb62481 100644 --- a/packages/melonjs/tests/setMask.spec.js +++ b/packages/melonjs/tests/setMask.spec.js @@ -22,6 +22,12 @@ describe("CanvasRenderer.setMask — invert mode", () => { renderer = app.renderer; }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(() => { // reset to a clean state const ctx = renderer.getContext(); diff --git a/packages/melonjs/tests/shader-canvas.spec.js b/packages/melonjs/tests/shader-canvas.spec.js index b7723632c..659a92a19 100644 --- a/packages/melonjs/tests/shader-canvas.spec.js +++ b/packages/melonjs/tests/shader-canvas.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, @@ -16,15 +16,22 @@ import { * and unload stays safe. The game keeps running, just unshaded. */ describe("shader assets under the Canvas renderer", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(64, 64, { + app = new Application(64, 64, { parent: "screen", renderer: video.CANVAS, }); await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("preloads into an inert, disabled stub and stays safe end-to-end", async () => { await loader.load({ name: "flash-canvas", diff --git a/packages/melonjs/tests/sprite-depth.spec.js b/packages/melonjs/tests/sprite-depth.spec.js index 1b882a45a..6c6b3616c 100644 --- a/packages/melonjs/tests/sprite-depth.spec.js +++ b/packages/melonjs/tests/sprite-depth.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, Matrix3d, video } from "../src/index.js"; /** @@ -19,9 +19,10 @@ describe("2D sprite depth is sort-only under an ortho projection", () => { const ortho = new Matrix3d().ortho(0, 320, 240, 0, -1e6, 1e6); const perspective = new Matrix3d().perspective(Math.PI / 4, 1.333, 0.1, 2000); + let app; beforeAll(async () => { boot(); - const app = new Application(320, 240, { + app = new Application(320, 240, { parent: "screen", renderer: video.CANVAS, }); @@ -29,6 +30,12 @@ describe("2D sprite depth is sort-only under an ortho projection", () => { renderer = app.renderer; }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("ortho projection: a large sort key is kept out of clip-space (depth → 0)", () => { renderer.setProjection(ortho); renderer.setDepth(1_000_500); // past the default Camera2d far plane (1e6) diff --git a/packages/melonjs/tests/sprite-trimming.spec.js b/packages/melonjs/tests/sprite-trimming.spec.js index 4dc569329..82b798f2f 100644 --- a/packages/melonjs/tests/sprite-trimming.spec.js +++ b/packages/melonjs/tests/sprite-trimming.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, @@ -12,9 +12,10 @@ import Renderer from "../src/video/renderer.js"; describe("Sprite trimming and Entity anchor sync", () => { let mockImage; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -24,6 +25,12 @@ describe("Sprite trimming and Entity anchor sync", () => { mockImage = Renderer.createCanvas(512, 512); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + /** * Build a mock TexturePacker atlas region. */ diff --git a/packages/melonjs/tests/sprite3d.spec.js b/packages/melonjs/tests/sprite3d.spec.js index f55c230d5..b3b1eeacf 100644 --- a/packages/melonjs/tests/sprite3d.spec.js +++ b/packages/melonjs/tests/sprite3d.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, @@ -18,15 +18,22 @@ import { */ describe("Camera3d.getBasis", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(64, 64, { + app = new Application(64, 64, { parent: "screen", renderer: video.CANVAS, }); await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + const r = new Vector3d(); const u = new Vector3d(); const f = new Vector3d(); @@ -67,15 +74,22 @@ describe("Camera3d.getBasis", () => { }); describe("Sprite3d billboard projection", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(64, 64, { + app = new Application(64, 64, { parent: "screen", renderer: video.CANVAS, }); await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + const makeTex = () => { const c = document.createElement("canvas"); c.width = 4; @@ -325,15 +339,22 @@ describe("Sprite3d billboard projection", () => { }); describe("Sprite3d atlas region mapping (trim + rotation)", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(64, 64, { + app = new Application(64, 64, { parent: "screen", renderer: video.CANVAS, }); await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + // a 200×200 source so logical (untrimmed) frame = 200 → world scale 1 at // width/height 200; setRegion is exercised directly with synthetic regions const make = (imgW, imgH, w, h) => { @@ -435,15 +456,22 @@ describe("Sprite3d atlas region mapping (trim + rotation)", () => { }); describe("Sprite3d flipX / flipY", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(64, 64, { + app = new Application(64, 64, { parent: "screen", renderer: video.CANVAS, }); await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + // 64×32 sheet → two 32×32 frames; sprite sized 1:1 (world unit == frame px) const makeAnimated = (settings) => { const sheet = document.createElement("canvas"); @@ -665,15 +693,22 @@ describe("Sprite3d resource cleanup", () => { }); describe("Sprite3d anchorPoint (vertex-baked anchor)", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(64, 64, { + app = new Application(64, 64, { parent: "screen", renderer: video.CANVAS, }); await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + const makeTex = (w = 4, h = 4) => { const c = document.createElement("canvas"); c.width = w; @@ -907,15 +942,22 @@ describe("Sprite3d anchorPoint (vertex-baked anchor)", () => { }); describe("Sprite3d anchorPoint — adversarial", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(64, 64, { + app = new Application(64, 64, { parent: "screen", renderer: video.CANVAS, }); await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + const makeTex = (w = 4, h = 4) => { const c = document.createElement("canvas"); c.width = w; @@ -1127,15 +1169,22 @@ describe("Sprite3d anchorPoint — adversarial", () => { }); describe("Sprite3d anchorPoint — remaining gap coverage", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(64, 64, { + app = new Application(64, 64, { parent: "screen", renderer: video.CANVAS, }); await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + const makeTex = () => { const c = document.createElement("canvas"); c.width = 4; diff --git a/packages/melonjs/tests/state.spec.js b/packages/melonjs/tests/state.spec.js index 21ff7ac90..52b1e7ad4 100644 --- a/packages/melonjs/tests/state.spec.js +++ b/packages/melonjs/tests/state.spec.js @@ -1,10 +1,11 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Application, boot, Stage, state, video } from "../src/index.js"; describe("state", () => { + let app; beforeEach(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -12,6 +13,12 @@ describe("state", () => { await app.init(); }); + afterEach(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + describe("constants", () => { it("should have all default state IDs", () => { expect(state.LOADING).toEqual(0); diff --git a/packages/melonjs/tests/text.spec.js b/packages/melonjs/tests/text.spec.js index 1bb87d49e..2314f5bbf 100644 --- a/packages/melonjs/tests/text.spec.js +++ b/packages/melonjs/tests/text.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, Text, video } from "../src/index.js"; /** @@ -8,15 +8,22 @@ import { Application, boot, Text, video } from "../src/index.js"; * and the browser silently falls back to its default serif. These pin that down. */ describe("Text — font family handling", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(64, 64, { + app = new Application(64, 64, { parent: "screen", renderer: video.CANVAS, }); await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("quotes a specific family name", () => { const t = new Text(0, 0, { font: "Arial", size: 16 }); expect(t.font).toBe('16px "Arial"'); diff --git a/packages/melonjs/tests/textVisibility.spec.js b/packages/melonjs/tests/textVisibility.spec.js index a226f55e9..7e9f197c2 100644 --- a/packages/melonjs/tests/textVisibility.spec.js +++ b/packages/melonjs/tests/textVisibility.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, BitmapText, @@ -9,9 +9,10 @@ import { } from "../src/index.js"; describe("Text visible characters", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(100, 100, { + app = new Application(100, 100, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -19,6 +20,12 @@ describe("Text visible characters", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + describe("Text", () => { it("visibleCharacters should default to -1", () => { const t = new Text(0, 0, { diff --git a/packages/melonjs/tests/texture.spec.js b/packages/melonjs/tests/texture.spec.js index e19919de4..7853482a1 100644 --- a/packages/melonjs/tests/texture.spec.js +++ b/packages/melonjs/tests/texture.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, boot, @@ -21,6 +21,12 @@ describe("Texture", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("convertToBlob() should return a Blob when using a regular canvas", async () => { const canvasTexture = new CanvasTexture(100, 100); const offscreenCanvas = new CanvasTexture(100, 100, { diff --git a/packages/melonjs/tests/tmx-shape-factory.spec.js b/packages/melonjs/tests/tmx-shape-factory.spec.js index 1359af208..3f243d2ec 100644 --- a/packages/melonjs/tests/tmx-shape-factory.spec.js +++ b/packages/melonjs/tests/tmx-shape-factory.spec.js @@ -2,14 +2,15 @@ * Regression coverage for TMX shape factory (`createShapeObject`). */ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, video } from "../src/index.js"; import { createShapeObject } from "../src/level/tiled/factories/shape.js"; describe("createShapeObject — TMX shape factory", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -17,6 +18,12 @@ describe("createShapeObject — TMX shape factory", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + // Regression: when `getDefaultShape` returns null/undefined (degenerate // TMX object), the factory used to assign `bodyDef.shapes = [undefined]` // and crash downstream with a cryptic "cannot read .pos" error. Now it diff --git a/packages/melonjs/tests/tmxlayer-data.spec.js b/packages/melonjs/tests/tmxlayer-data.spec.js index b4731a203..ac21002a8 100644 --- a/packages/melonjs/tests/tmxlayer-data.spec.js +++ b/packages/melonjs/tests/tmxlayer-data.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, TMXTileMap, video } from "../src/index.js"; import { TMX_CLEAR_BIT_MASK, @@ -70,9 +70,10 @@ function makeLayer(data) { const ALL_ZERO_4x3 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; describe("TMXLayer.layerData (Uint16Array refactor)", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(128, 128, { + app = new Application(128, 128, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -81,6 +82,12 @@ describe("TMXLayer.layerData (Uint16Array refactor)", () => { fakeImage("testtiles", 64, 64); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + describe("Allocation & shape", () => { it("layerData is a Uint16Array (not a 2D Array)", () => { const layer = makeLayer(ALL_ZERO_4x3); diff --git a/packages/melonjs/tests/tmxlayer-drawraw.spec.js b/packages/melonjs/tests/tmxlayer-drawraw.spec.js index 37a4f45f7..4f82420d1 100644 --- a/packages/melonjs/tests/tmxlayer-drawraw.spec.js +++ b/packages/melonjs/tests/tmxlayer-drawraw.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, TMXTileMap, video } from "../src/index.js"; import { TMX_FLIP_AD, @@ -120,9 +120,10 @@ function makeRecordingRenderer() { } describe("Tile rendering raw path (drawTileRaw)", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(128, 128, { + app = new Application(128, 128, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -136,6 +137,12 @@ describe("Tile rendering raw path (drawTileRaw)", () => { ]); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + describe("buildFlipTransform helper", () => { // Matrix2d.val is column-major: [a, c, e, b, d, f, 0, 0, 1] // For a pure axis-aligned scale-around-center transform, expected (a, d) diff --git a/packages/melonjs/tests/tmxobject.spec.js b/packages/melonjs/tests/tmxobject.spec.js index 893bc0458..3ec9b5a07 100644 --- a/packages/melonjs/tests/tmxobject.spec.js +++ b/packages/melonjs/tests/tmxobject.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, video } from "../src/index.js"; import TMXObject from "../src/level/tiled/TMXObject.js"; @@ -16,9 +16,10 @@ function mockMap(orientation = "orthogonal") { } describe("TMXObject", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(128, 128, { + app = new Application(128, 128, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -26,6 +27,12 @@ describe("TMXObject", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + describe("shape detection", () => { it("should detect rectangle (default, no shape marker)", () => { const obj = new TMXObject( diff --git a/packages/melonjs/tests/tmxrenderer.spec.js b/packages/melonjs/tests/tmxrenderer.spec.js index e133576f1..274fc889b 100644 --- a/packages/melonjs/tests/tmxrenderer.spec.js +++ b/packages/melonjs/tests/tmxrenderer.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, video } from "../src/index.js"; import TMXHexagonalRenderer from "../src/level/tiled/renderer/TMXHexagonalRenderer.js"; import TMXIsometricRenderer from "../src/level/tiled/renderer/TMXIsometricRenderer.js"; @@ -19,9 +19,10 @@ function fakeImage(name, w = 64, h = 64) { } describe("TMX Renderers", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(128, 128, { + app = new Application(128, 128, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -30,6 +31,12 @@ describe("TMX Renderers", () => { fakeImage("drawtest", 256, 256); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + // mock map object for constructors function mockMap(overrides = {}) { return { diff --git a/packages/melonjs/tests/tmxtilemap.spec.js b/packages/melonjs/tests/tmxtilemap.spec.js index 4119e3c15..81b04dbae 100644 --- a/packages/melonjs/tests/tmxtilemap.spec.js +++ b/packages/melonjs/tests/tmxtilemap.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, @@ -672,9 +672,10 @@ class PresetBlendEntity extends Renderable { } describe("TMXTileMap", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(128, 128, { + app = new Application(128, 128, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -688,6 +689,12 @@ describe("TMXTileMap", () => { registerTiledObjectClass("PresetBlendEntity", PresetBlendEntity); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + // --------------------------------------------------------------- // Unnamed shape objects // --------------------------------------------------------------- diff --git a/packages/melonjs/tests/tmxtileset.spec.js b/packages/melonjs/tests/tmxtileset.spec.js index 956703d79..e5f660847 100644 --- a/packages/melonjs/tests/tmxtileset.spec.js +++ b/packages/melonjs/tests/tmxtileset.spec.js @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, video } from "../src/index.js"; import Tile from "../src/level/tiled/TMXTile.js"; import TMXTileset from "../src/level/tiled/TMXTileset.js"; @@ -14,9 +14,10 @@ function fakeImage(name, w = 64, h = 64) { } describe("TMXTileset", () => { + let app; beforeAll(async () => { boot(); - const app = new Application(128, 128, { + app = new Application(128, 128, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -37,6 +38,12 @@ describe("TMXTileset", () => { fakeImage("single", 32, 32); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + // ============================================================== // Regular tileset (spritesheet) // ============================================================== diff --git a/packages/melonjs/tests/toframetexture.spec.js b/packages/melonjs/tests/toframetexture.spec.js index ffb41555f..ffc01cff1 100644 --- a/packages/melonjs/tests/toframetexture.spec.js +++ b/packages/melonjs/tests/toframetexture.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { Application, Bounds, @@ -667,6 +667,12 @@ describe("CanvasRenderer.toFrameTexture", () => { expect(renderer).toBeInstanceOf(CanvasRenderer); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("returns a Texture2d backed by a canvas copy of the frame", () => { const ctx2d = renderer.getContext(); ctx2d.setTransform(1, 0, 0, 1, 0, 0); diff --git a/packages/melonjs/tests/trigger.spec.js b/packages/melonjs/tests/trigger.spec.js index 10c5defc5..f8752e7fc 100644 --- a/packages/melonjs/tests/trigger.spec.js +++ b/packages/melonjs/tests/trigger.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, @@ -27,6 +27,12 @@ describe("Trigger", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + describe("constructor", () => { it("should create a trigger with default settings", () => { const trigger = new Trigger(100, 200, { diff --git a/packages/melonjs/tests/ui.spec.js b/packages/melonjs/tests/ui.spec.js index 21d00d2ac..781af0885 100644 --- a/packages/melonjs/tests/ui.spec.js +++ b/packages/melonjs/tests/ui.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { Application, boot, @@ -22,6 +22,12 @@ describe("UI", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + // ─── UIBaseElement ─────────────────────────────────────────────────────────── describe("UIBaseElement", () => { diff --git a/packages/melonjs/tests/webgl_save_restore.spec.js b/packages/melonjs/tests/webgl_save_restore.spec.js index c140832d1..ec2fd9239 100644 --- a/packages/melonjs/tests/webgl_save_restore.spec.js +++ b/packages/melonjs/tests/webgl_save_restore.spec.js @@ -20,9 +20,10 @@ describe("WebGL Renderer save/restore", () => { let renderer; let isWebGL; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.AUTO, @@ -33,6 +34,12 @@ describe("WebGL Renderer save/restore", () => { isWebGL = renderer instanceof WebGLRenderer; }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + afterAll(async () => { const app = new Application(800, 600, { parent: "screen", diff --git a/packages/melonjs/tests/webgl_save_restore_bench.spec.js b/packages/melonjs/tests/webgl_save_restore_bench.spec.js index 05afec272..b97378359 100644 --- a/packages/melonjs/tests/webgl_save_restore_bench.spec.js +++ b/packages/melonjs/tests/webgl_save_restore_bench.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, video, WebGLRenderer } from "../src/index.js"; /** @@ -9,9 +9,10 @@ import { Application, boot, video, WebGLRenderer } from "../src/index.js"; describe("WebGL save/restore benchmark", () => { let renderer; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.AUTO, @@ -20,6 +21,12 @@ describe("WebGL save/restore benchmark", () => { renderer = app.renderer; }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + it("benchmark: 1000 sprites × 60 frames save/restore cycle", () => { const isWebGL = renderer instanceof WebGLRenderer; if (!isWebGL) { diff --git a/packages/melonjs/tests/webgl_vao_adversarial.spec.js b/packages/melonjs/tests/webgl_vao_adversarial.spec.js index 285403877..a80b50f81 100644 --- a/packages/melonjs/tests/webgl_vao_adversarial.spec.js +++ b/packages/melonjs/tests/webgl_vao_adversarial.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Application, boot, @@ -41,6 +41,12 @@ describe("WebGL VAO adversarial", () => { } }); + afterAll(() => { + // release the app this describe owns — browsers cap live WebGL + // contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + const requireWebGL = (ctx) => { if (!isWebGL) { ctx.skip("WebGL renderer not available in this environment"); diff --git a/packages/melonjs/tests/world.spec.js b/packages/melonjs/tests/world.spec.js index 556fbce4b..e5d74dafd 100644 --- a/packages/melonjs/tests/world.spec.js +++ b/packages/melonjs/tests/world.spec.js @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Application, Body, @@ -12,9 +12,10 @@ import { describe("Physics : World", () => { let world; + let app; beforeAll(async () => { boot(); - const app = new Application(800, 600, { + app = new Application(800, 600, { parent: "screen", scale: "auto", renderer: video.CANVAS, @@ -22,6 +23,12 @@ describe("Physics : World", () => { await app.init(); }); + afterAll(() => { + // release the WebGL context this describe owns — browsers cap + // live contexts, and a leak surfaces as UNRELATED specs failing + app?.destroy(); + }); + beforeEach(() => { world = new World(0, 0, 800, 600); }); From a1837b7d286fb996563185163cd13fab7f4b21c1 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 17 Aug 2026 12:39:07 +0800 Subject: [PATCH 16/16] test: correct the harness docs, and stop one flake misreporting its cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three documentation claims in the test harness were wrong, and the wrong one was load-bearing: it produced a diagnosis of the intermittent CI failure that does not hold. - `tests/helpers/webgl-context.js` said vitest runs every spec file in one page, so WebGL contexts accumulate across files until the browser's cap force-loses the oldest and a later `beforeAll` stalls. Measurably false: a probe shows a global set in one file is `undefined` in the next, and a context opened in one is already lost by the next. Vitest isolates each spec FILE. Contexts accumulate only WITHIN a file, across describe blocks. - `vitest.config.ts` repeated the same claim. - `src/system/device.ts` carried a JSDoc `@example` calling `me.video.init()` — an API that no longer exists — on an already-deprecated function. That one is user-facing, since it ships in the published docs. All three now say what was measured, and the helper carries an explicit note not to rebuild the starvation theory from it. The helper itself is still worth using, for a different reason: acquisition through a software rasterizer is genuinely slow, so creating fewer contexts saves real time. `application_lifecycle` is the one spec that has failed intermittently here. It built SIX WebGL applications in a loop — the most context churn in the suite — and checked handler identity before checking a handler exists, so a cycle that failed to obtain a renderer reported "reused a previous handler". That message sent this investigation the wrong way. Three cycles prove the property just as well, and the existence check now runs first, so a failure says what actually happened. The cause of the CI flake remains unknown. The `hookTimeout` note — that acquisition can take tens of seconds under load — is the better lead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- packages/melonjs/src/system/device.ts | 40 ++----------------- .../tests/application_lifecycle.spec.js | 15 ++++++- .../melonjs/tests/helpers/webgl-context.js | 37 ++++++++++------- packages/melonjs/vitest.config.ts | 17 +++++--- 4 files changed, 51 insertions(+), 58 deletions(-) diff --git a/packages/melonjs/src/system/device.ts b/packages/melonjs/src/system/device.ts index 590cdd66d..ed342d38f 100644 --- a/packages/melonjs/src/system/device.ts +++ b/packages/melonjs/src/system/device.ts @@ -313,43 +313,9 @@ export function setAutoFocus(enable: boolean): void { * specify a function to execute when the Device is fully loaded and ready * @param fn - the function to be executed * @example - * // small game skeleton - * let game = { - * // called by the me.device.onReady function - * onload = function () { - * // init video - * if (!me.video.init('screen', 640, 480, true)) { - * alert("Sorry but your browser does not support html 5 canvas."); - * return; - * } - * - * // initialize the "audio" - * me.audio.init("mp3,ogg"); - * - * // set callback for resources loaded event - * me.loader.onload = this.loaded.bind(this); - * - * // set all resources to be loaded - * me.loader.preload(game.assets); - * - * // load everything & display a loading screen - * me.state.change(me.state.LOADING); - * }; - * - * // callback when everything is loaded - * loaded = function () { - * // define stuff - * // .... - * - * // change to the menu screen - * me.state.change(me.state.PLAY); - * } - * }; // game - * - * // "bootstrap" - * me.device.onReady(function () { - * game.onload(); - * }); + * // the modern equivalent is simply to await the Application + * const app = new Application(640, 480, { parent: "screen" }); + * await app.init(); * @deprecated since 18.3.0 — no longer needed when using {@link Application} as entry point. * @category Application */ diff --git a/packages/melonjs/tests/application_lifecycle.spec.js b/packages/melonjs/tests/application_lifecycle.spec.js index 8810d70e0..f4b18036b 100644 --- a/packages/melonjs/tests/application_lifecycle.spec.js +++ b/packages/melonjs/tests/application_lifecycle.spec.js @@ -128,16 +128,27 @@ describe("Application lifecycle: renderer event handlers are unregisterable", () // Guards the drift case: were the handler shared on the prototype, // cycle N would unregister cycle 0's closure and leave every later // renderer on the bus. + // Three cycles prove the property (a prototype-shared handler collides + // on the second); six only tripled the WebGL contexts this one test + // builds and tore down, which is the most context churn in the suite. + const CYCLES = 3; const seen = new Set(); - for (let i = 0; i < 6; i++) { + for (let i = 0; i < CYCLES; i++) { const app = await mk(video.WEBGL); const handler = app.renderer.onGameReset; + // Check this FIRST. A cycle that failed to obtain a real WebGL + // renderer yields no handler, and two `undefined`s land in the set + // as a duplicate — reporting "reused a previous handler" for what is + // actually a context-acquisition failure. This test has failed + // intermittently in CI and that message sent the investigation the + // wrong way; it should say what really happened. + expect(typeof handler, `cycle ${i} produced no handler`).toBe("function"); expect(seen.has(handler), `cycle ${i} reused a previous handler`).toBe( false, ); seen.add(handler); app.destroy(); } - expect(seen.size).toBe(6); + expect(seen.size).toBe(CYCLES); }); }); diff --git a/packages/melonjs/tests/helpers/webgl-context.js b/packages/melonjs/tests/helpers/webgl-context.js index 3b67d5181..3b23b848f 100644 --- a/packages/melonjs/tests/helpers/webgl-context.js +++ b/packages/melonjs/tests/helpers/webgl-context.js @@ -6,20 +6,29 @@ import WebGLRenderer from "../../src/video/webgl/webgl_renderer.js"; * * ## Why this exists * - * Vitest browser mode runs every spec file in one page, and constructing an - * `Application` and awaiting `init()` builds a fresh canvas and GL context - * each time. With a spec - * file per feature that adds up to dozens of live contexts in a single - * session — and browsers cap how many they will keep, force-losing the oldest - * once past the limit. Past that point, creating another context stalls. - * - * The failure that produces is badly misleading: some *unrelated* spec's - * `beforeAll` times out, the suite blames whichever file happened to be late - * in the run, and adding or reordering files moves the casualty around. It - * looks like flakiness in the victim, but it is a resource limit set by - * everything before it. - * - * So: create one context, keep it for the session, and let specs borrow it. + * Constructing an `Application` and awaiting `init()` builds a fresh canvas + * and GL context each time, and on a CI container with no GPU that runs + * through a software rasterizer — slow enough that `vitest.config.ts` raises + * `hookTimeout` to 90s for it. Creating FEWER contexts is therefore worth + * real time, which is what this helper is for. + * + * ## What this is NOT for + * + * This used to claim that vitest runs every spec file in one page, so contexts + * accumulated across files until the browser's cap force-lost the oldest and a + * later `beforeAll` stalled. That is measurably false: vitest isolates each + * spec FILE. A probe confirmed it — a global set in one file is `undefined` in + * the next, and a context opened in one is already lost by the next. Contexts + * accumulate only WITHIN a file, across its describe blocks. + * + * The belief was load-bearing for a while: an intermittent CI failure (an + * unrelated spec's `beforeAll` timing out in `getWebGLRenderer`) was blamed on + * cross-file starvation. It is not that. The cause is still unknown, and the + * `hookTimeout` note above — that acquisition is genuinely slow under load — + * is the better lead. Do not rebuild the starvation theory from this file. + * + * So: create one context per file, keep it for that file, and let its specs + * borrow it. * * ## Using it * diff --git a/packages/melonjs/vitest.config.ts b/packages/melonjs/vitest.config.ts index 1ddd109fe..159094f24 100644 --- a/packages/melonjs/vitest.config.ts +++ b/packages/melonjs/vitest.config.ts @@ -25,10 +25,16 @@ export default defineConfig(() => // Anchor to this package. `pnpm test` invokes this config from the repo // root, where an unanchored `include` globs every workspace package — // so the adapters' and debug-plugin's specs were pulled into this run - // *as well as* being run by their own `pnpm -F ... test` jobs. Besides - // the duplicate work, each extra spec file that calls `video.init` - // creates another WebGL context in the one shared browser session, and - // past the browser's context cap a later `beforeAll` stalls. + // *as well as* being run by their own `pnpm -F ... test` jobs, doubling + // the work. + // + // This comment used to claim the specs also shared one browser session, + // so contexts accumulated across files until the browser's cap stalled a + // later `beforeAll`. That is NOT true here: vitest isolates each spec + // file, verified by a probe — a global set in one file is undefined in + // the next, and a context opened in one is dead by the next. Contexts do + // accumulate WITHIN a file across its describe blocks, which is worth + // releasing, but it is not a cross-file effect. root: __dirname, test: { include: ["tests/**/*.{test,spec}.[jt]s?(x)"], @@ -36,7 +42,8 @@ export default defineConfig(() => // container with no GPU that runs through a software rasterizer and // can genuinely take tens of seconds under load, so the default hook // timeout fails correct suites. Raised for headroom — this is a slow - // hook, not a hanging one. + // hook, not a hanging one. (`video.init` no longer exists; a context + // comes from `new Application(...)` + `await app.init()`.) hookTimeout: 90000, browser: { enabled: true,