diff --git a/packages/examples/src/examples/aseprite/ExampleAseprite.tsx b/packages/examples/src/examples/aseprite/ExampleAseprite.tsx
index de52d88969..dbe6ec2bd5 100644
--- a/packages/examples/src/examples/aseprite/ExampleAseprite.tsx
+++ b/packages/examples/src/examples/aseprite/ExampleAseprite.tsx
@@ -16,11 +16,33 @@ export const ExampleAseprite = () => {
}, []);
return (
-
+ // float above the fixed #screen overlay (see index.css), like the
+ // tiledMapLoader / spine selectors — in normal flow the game surface
+ // paints over the controls
+
Animation:
{
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
(paladin.renderable as me.Sprite).setCurrentAnimation(
diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md
index 235f29018a..4189aab102 100644
--- a/packages/melonjs/CHANGELOG.md
+++ b/packages/melonjs/CHANGELOG.md
@@ -47,6 +47,19 @@
- **`me.audio.position()` / `stereo()` getters returned `null` for sounds never positioned/panned** — Howler keeps the group state at `null` until first set, and the getters passed that through while typed as a tuple / number. They now return the neutral defaults (`[0, 0, 0]` / `0`).
- **`me.audio.resume()` without an id could spawn a new instance instead of resuming** — Howler's bare `play()` only auto-resumes when *exactly one* instance is paused; with two or more (e.g. after `pause()` without an id, which pauses the whole group) it started a brand-new playback from 0 and left the paused instances stuck forever. `resume()` now explicitly resumes every paused instance, as its documentation always promised.
- **`me.audio.unload()` left `getCurrentTrack()` pointing at the unloaded track** — the current-track pointer is now cleared when the unloaded clip is the active track, so `pauseTrack()`/`resumeTrack()` don't silently act on a ghost.
+- **aseprite atlas JSON: trim, rotation and pivot silently ignored; animations played at arbitrary speeds** — the texture parser read `trimmed`/`spriteSourceSize`/`sourceSize`/`pivot`/`rotated` off the frame *rect* instead of the frame *entry* (they are siblings of `frame` in aseprite's export, same layout TexturePacker uses — and the TexturePacker parser reads them correctly), so every one of them came back `undefined`: frames exported with "Trim Sprite" drew without their trim offsets (sprites shifted/jittered per animation frame) and `sourceSize` was missing entirely. Animation tags fared no better: the parser synthesized `speed = 10 × (frameCount − 1)` ms/frame — a 2-frame tag ran at 100 fps, an 11-frame tag at 10 fps — while the authored per-frame `duration` values sat unread in the JSON. Tags now build frame objects carrying each frame's own `duration` as its delay.
+- **The atlas coordinate-alias cache poisoned full-image draws and never hit for sub-regions** — the alias key recorded for every frame (the #1281 lookup workaround) was built from the *texture* dimensions instead of the *region* dimensions. Consequence one: every legitimate coordinate lookup (`drawImage` with source coordinates) missed and silently created a duplicate ad-hoc region per frame. Consequence two: any frame sitting at offset `(0, 0)` — nearly universal — registered the alias `"0,0,imgW,imgH"` pointing at *itself*, so a later full-image draw of the same image returned that frame's UVs and drew frame 0 stretched over the destination instead of the whole image.
+- **No-argument `createAnimationFromName()` / `getAnimationSettings()` built corrupted animations** — the documented "add every entry in the atlas" form iterated the raw atlas dictionary, which also contains the coordinate-alias keys (every frame was added twice — visually a half-speed animation with doubled frames) and, for aseprite atlases, the `anims` bookkeeping entry (a pseudo-region with `NaN` frame dimensions that broke the sprite). Dictionary iteration now skips anything that isn't a real region.
+- **`TextureAtlas.addRegion()` on a video source produced `Infinity`/`NaN` UVs** — an unsized `HTMLVideoElement` reports `width/height = 0`, and `addRegion` was the one code path without the `videoWidth`/`videoHeight` fallback the upload and cache paths already had, so a video sprite with `framewidth`/`frameheight` rendered an invisible/garbage quad under WebGL (Canvas was fine — renderer parity bug).
+- **Padded (non-divisible) spritesheets sampled shifted frames under WebGL** — when a sheet isn't divisible by its cell size the parser "truncated" the effective size, but that truncated size fed the UV divisor while the GL texture is uploaded at the image's physical size, scaling and shifting every frame's UVs (Canvas was unaffected — another renderer parity bug). The frame *grid* is still truncated; UVs are now always computed against the physical texture size.
+- **A malformed atlas object constructed an empty, broken `TextureAtlas` instead of throwing** — the "format not supported" guard checked `.length` on a `Map` (always `undefined`), so the error was unreachable and the failure surfaced later as confusing `undefined` errors far from the cause.
+- **Lit rendering: normal maps and color textures collided on the same GL texture units** — the lit batcher binds each sprite's normal map at a fixed offset (`maxBatchTextures + slot`, units 8–15 on 16-unit hardware) by pure arithmetic, while the renderer-wide texture-unit allocator handed those same units to color textures (an unlit sprite's, a mesh's, a pattern's — units are sticky, so the 9th distinct texture ever drawn was enough). Each batcher tracks its bindings per instance, so whichever bound second silently clobbered the other: unlit sprites rendered *showing the normal map*, or lit sprites shaded with *color data as normals*. Activating the lit batcher now reserves its normal range in the unit allocator (lazily, on first lit draw — unlit games keep their full pool), and `ShaderEffect.setTexture` extra samplers skip units reserved by others when claiming theirs.
+- **A texture re-upload could land on another batcher's texture** — each batcher tracked the globally-shared `gl.activeTexture` state in a per-instance field, so after another batcher (or an FBO pass) moved the active unit, the stale copy let `bindTexture2D` skip the `gl.activeTexture` call and the following `texImage2D` wrote into whatever texture was really active. Concretely: a video sprite's per-frame force-re-upload after a 3D-mesh pass overwrote the mesh's texture with video frames while the video froze. The active unit is now tracked once per renderer (shared by all batchers), and uploads force-activate their target unit outright.
+- **Camera post-effects could blank textures owned by non-current batchers** — the post-effect FBO setup and the end-of-frame blit both null GL unit 0's binding, but only the *current* batcher's bookkeeping was invalidated; a texture drawn exclusively through another batcher (a normal-mapped-only atlas, a mesh texture) that had been assigned unit 0 skipped its re-bind and sampled an unbound texture — opaque black for as long as the effect was active. Unit-0 invalidation now reaches every batcher.
+- **Filled shapes larger than the vertex buffer silently vanished** — `PrimitiveBatcher.drawVertices` had no chunking, and the vertex buffer's typed-array writes past capacity are silently dropped while the draw call still used the full count: a filled ellipse at radius ≳ 435 px (`fillEllipse`/`fillArc`, or a large triangulated `fill()`) raised GL errors and rendered nothing — including everything else batched in the same flush. Oversized shapes are now split into buffer-sized chunks on primitive boundaries (strip/fan/loop modes stitch chunks with overlapped vertices), and thick-line expansion checks capacity per line pair.
+- **`NoiseTexture2d.destroy()` leaked its GPU normal-map texture** — the lit batcher caches one GL texture per normal-map source in a strong-keyed map that was only emptied on a full renderer reset, so a per-level noise normal map leaked its GL texture *and* its baked canvas on every level swap. `Texture2d.destroy()` now broadcasts the new `event.TEXTURE2D_DESTROYED` with the backing source, and the lit batcher evicts the cached texture (deleting the GL object) in response.
+- **The mesh depth-clear flag was shared between renderer instances** — the lazy "clear depth once per target" state lived at module scope, so with two WebGL Applications on one page (or any interleaved rendering) one renderer's frame start could re-arm or steal the other's depth clear mid-frame, breaking inter-mesh occlusion. The flag now lives on the renderer, and `event.RENDER_TARGET_CHANGED` carries the emitting renderer so subscribers ignore foreign broadcasts.
+- **GL buffers leaked on every reset — and mesh index/vertex buffers went dead after a context restore** — the quad batchers recreated their static index buffer on every `GAME_RESET` without deleting the old one (an orphaned ~12 KB GL buffer per reset per batcher); the mesh batchers had the opposite problem: their reset only cleared counters, keeping GL buffer objects that belong to the *lost* context after a `webglcontextrestored`, so every subsequent mesh upload silently failed. Both families now delete and recreate their GL buffers on reset.
## [19.8.0] (melonJS 2) - _2026-06-26_
diff --git a/packages/melonjs/src/system/event.ts b/packages/melonjs/src/system/event.ts
index 11529e0702..08347a927b 100644
--- a/packages/melonjs/src/system/event.ts
+++ b/packages/melonjs/src/system/event.ts
@@ -8,6 +8,7 @@ import { Vector2d } from "../math/vector2d.ts";
import { Draggable } from "../renderable/draggable.js";
import type Stage from "../state/stage.ts";
import Renderer from "../video/renderer.js";
+import type { Texture2dSource } from "../video/texture/texture2d.ts";
import { EventEmitter } from "./eventEmitter.js";
/**
@@ -379,11 +380,26 @@ export const GPU_TEXTURE_CACHE_RESET = "renderer.gpu.texturecachereset";
* `RenderPassEncoder` (clear loadOps replace `clear()` calls in WebGPU's
* declarative pass descriptors).
*
- * Data passed: none
+ * Data passed: the emitting renderer. Subscribers holding per-renderer
+ * state should ignore broadcasts from other renderer instances (several
+ * Applications can coexist on one page); a missing argument (legacy
+ * emitters) should be treated as "mine".
* @see event.on
*/
export const RENDER_TARGET_CHANGED = "renderer.target.changed";
+/**
+ * Fired when a {@link Texture2d} asset is destroyed, with its backing
+ * drawable source. GPU-side caches keyed by source image — e.g. the lit
+ * batcher's per-image normal-map textures — subscribe to release the GL
+ * texture and drop their entry, so destroying a texture (a per-level
+ * {@link NoiseTexture2d}, for instance) doesn't leak GPU memory.
+ *
+ * Data passed: the destroyed texture's backing canvas/image
+ * @see event.on
+ */
+export const TEXTURE2D_DESTROYED = "texture2d.destroyed";
+
interface Events {
[DOM_READY]: () => void;
[BOOT]: () => void;
@@ -436,7 +452,8 @@ interface Events {
[ONCONTEXT_LOST]: (renderer: Renderer) => void;
[ONCONTEXT_RESTORED]: (renderer: Renderer) => void;
[GPU_TEXTURE_CACHE_RESET]: () => void;
- [RENDER_TARGET_CHANGED]: () => void;
+ [RENDER_TARGET_CHANGED]: (renderer?: object) => void;
+ [TEXTURE2D_DESTROYED]: (source: Texture2dSource) => void;
}
const eventEmitter = new EventEmitter();
diff --git a/packages/melonjs/src/video/texture/atlas.js b/packages/melonjs/src/video/texture/atlas.js
index 5e2e335179..4037b712cf 100644
--- a/packages/melonjs/src/video/texture/atlas.js
+++ b/packages/melonjs/src/video/texture/atlas.js
@@ -250,8 +250,8 @@ export class TextureAtlas extends Texture2d {
this.activeAtlas = this.atlases.keys().next().value;
}
- // if format not recognized
- if (this.atlases.length === 0) {
+ // if format not recognized (`atlases` is a Map — `.size`, not `.length`)
+ if (this.atlases.size === 0) {
throw new Error("texture atlas format not supported");
}
@@ -368,8 +368,11 @@ export class TextureAtlas extends Texture2d {
addRegion(name, x, y, w, h) {
const source = this.getTexture();
const atlas = this.getAtlas();
- const dw = source.width;
- const dh = source.height;
+ // an unsized HTMLVideoElement reports width/height = 0 — its real
+ // pixel dimensions are videoWidth/videoHeight (same fallback as
+ // `uploadTexture` and `TextureCache.get`)
+ const dw = source.width || source.videoWidth;
+ const dh = source.height || source.videoHeight;
atlas[name] = {
name: name,
@@ -475,8 +478,15 @@ export class TextureAtlas extends Texture2d {
]);
// Cache source coordinates
// see https://github.com/melonjs/melonJS/issues/1281
- const key = `${s.x},${s.y},${w},${h}`;
- atlas[key] = atlas[name];
+ // keyed by the REGION rect (x, y, width, height) — the same key shape
+ // `getUVs(sx, sy, sw, sh)` builds for coordinate lookups. (Keying by
+ // the texture dimensions instead made the alias unmatchable for any
+ // sub-region AND poisoned full-image lookups whenever a frame sat at
+ // (0,0): "0,0,texW,texH" pointed at that frame, not the whole image.)
+ const key = `${s.x},${s.y},${sw},${sh}`;
+ if (key !== name) {
+ atlas[key] = atlas[name];
+ }
return atlas[name].uvs;
}
@@ -592,11 +602,20 @@ export class TextureAtlas extends Texture2d {
// first pass: collect regions and compute max dimensions
const regions = [];
for (const i in names) {
- const name = Array.isArray(names) ? names[i] : i;
+ const isArray = Array.isArray(names);
+ const name = isArray ? names[i] : i;
+ // in dictionary mode, only iterate real regions: a region's `name`
+ // matches its dictionary key. This skips both the coordinate-alias
+ // keys added by `addUVs` (#1281 — they'd duplicate every frame) and
+ // non-region entries like the aseprite parser's `anims` dictionary
+ // (whose value has no `name`, and which `getRegion` would otherwise
+ // happily return as a NaN-sized pseudo-region).
+ if (!isArray && (names[i] == null || names[i].name !== i)) {
+ continue;
+ }
const region = this.getRegion(name);
- // skip non-region keys (e.g. "anims" added by Aseprite parser)
if (region == null) {
- if (Array.isArray(names)) {
+ if (isArray) {
throw new Error("Texture - region for " + name + " not found");
}
continue;
@@ -649,7 +668,13 @@ export class TextureAtlas extends Texture2d {
}
for (const i in names) {
- const name = Array.isArray(names) ? names[i] : i;
+ const isArray = Array.isArray(names);
+ const name = isArray ? names[i] : i;
+ // dictionary mode: skip coordinate-alias keys and non-region
+ // entries (see getAnimationSettings for the full rationale)
+ if (!isArray && (names[i] == null || names[i].name !== i)) {
+ continue;
+ }
const region = this.getRegion(name);
if (region == null) {
throw new Error("Texture - region for " + name + " not found");
diff --git a/packages/melonjs/src/video/texture/noise_texture2d.js b/packages/melonjs/src/video/texture/noise_texture2d.js
index c7a5189090..de0220c2bc 100644
--- a/packages/melonjs/src/video/texture/noise_texture2d.js
+++ b/packages/melonjs/src/video/texture/noise_texture2d.js
@@ -341,6 +341,10 @@ class NoiseTexture2d extends Texture2d {
* Release the baked canvas. The texture must not be used after destroy.
*/
destroy() {
+ // broadcasts TEXTURE2D_DESTROYED with the baked canvas so the lit
+ // batcher's normal-map cache frees its GL texture — before the
+ // canvas reference is dropped below
+ super.destroy();
this._canvas = null;
this._imageData = null;
this._heights = null;
diff --git a/packages/melonjs/src/video/texture/parser/aseprite.js b/packages/melonjs/src/video/texture/parser/aseprite.js
index 6297061b64..524e7f3e83 100644
--- a/packages/melonjs/src/video/texture/parser/aseprite.js
+++ b/packages/melonjs/src/video/texture/parser/aseprite.js
@@ -11,29 +11,38 @@ import { Vector2d } from "../../../math/vector2d.ts";
export function parseAseprite(data, textureAtlas) {
const atlas = {};
+ // per-frame durations in frame order, consumed by the frameTags loop
+ // below to build per-frame animation delays
+ const frameDurations = [];
+
const frames = data.frames;
for (const name in frames) {
- const frame = frames[name].frame;
- const trimmed = !!frame.trimmed;
+ // in aseprite's JSON export (hash and array forms alike), `trimmed`,
+ // `spriteSourceSize`, `sourceSize`, `rotated`, `pivot` and `duration`
+ // are SIBLINGS of the `frame` rect on each entry — same layout as
+ // TexturePacker's (see texturepacker.js)
+ const entry = frames[name];
+ const frame = entry.frame;
+ const trimmed = !!entry.trimmed;
let trim;
if (trimmed) {
trim = {
- x: frame.spriteSourceSize.x,
- y: frame.spriteSourceSize.y,
- w: frame.spriteSourceSize.w,
- h: frame.spriteSourceSize.h,
+ x: entry.spriteSourceSize.x,
+ y: entry.spriteSourceSize.y,
+ w: entry.spriteSourceSize.w,
+ h: entry.spriteSourceSize.h,
};
}
let originX;
let originY;
// Pixel-based offset origin from the top-left of the source frame
- const hasTextureAnchorPoint = frame.sourceSize && frame.pivot;
+ const hasTextureAnchorPoint = entry.sourceSize && entry.pivot;
if (hasTextureAnchorPoint) {
- originX = frame.sourceSize.w * frame.pivot.x - (trimmed ? trim.x : 0);
- originY = frame.sourceSize.h * frame.pivot.y - (trimmed ? trim.y : 0);
+ originX = entry.sourceSize.w * entry.pivot.x - (trimmed ? trim.x : 0);
+ originY = entry.sourceSize.h * entry.pivot.y - (trimmed ? trim.y : 0);
}
atlas[name] = {
@@ -47,26 +56,34 @@ export function parseAseprite(data, textureAtlas) {
trim: trim,
width: frame.w,
height: frame.h,
- angle: frame.rotated === true ? -ETA : 0,
+ sourceSize: entry.sourceSize || { w: frame.w, h: frame.h },
+ angle: entry.rotated === true ? -ETA : 0,
};
+ frameDurations.push(entry.duration);
textureAtlas.addUVs(atlas, name, data.meta.size.w, data.meta.size.h);
}
const anims = {};
for (const name in data.meta.frameTags) {
const anim = data.meta.frameTags[name];
- // aseprite provide a range from [from] to [to], so build the corresponding index array
+ // aseprite provides a [from..to] frame range plus a per-frame
+ // `duration` (ms) on each frame — build frame objects carrying each
+ // frame's own delay so the animation plays at its authored timing
+ // (100 ms is aseprite's default duration, used as a safety net)
const indexArray = Array.from(
{ length: anim.to - anim.from + 1 },
(_, i) => {
- return anim.from + i;
+ const idx = anim.from + i;
+ return {
+ name: idx,
+ delay:
+ typeof frameDurations[idx] === "number" ? frameDurations[idx] : 100,
+ };
},
);
anims[name] = {
name: anim.name,
index: indexArray,
- // aseprite provide animation speed between frame, melonJS expect total duration for a given animation
- speed: 10 * (indexArray.length - 1),
// only "forward" is supported for now
direction: anim.direction,
};
diff --git a/packages/melonjs/src/video/texture/parser/spritesheet.js b/packages/melonjs/src/video/texture/parser/spritesheet.js
index cde9e87fb0..aa40c1e3dc 100644
--- a/packages/melonjs/src/video/texture/parser/spritesheet.js
+++ b/packages/melonjs/src/video/texture/parser/spritesheet.js
@@ -13,8 +13,8 @@ export function parseSpriteSheet(data, textureAtlas) {
const spacing = data.spacing || 0;
const margin = data.margin || 0;
- let width = image.width;
- let height = image.height;
+ const width = image.width;
+ const height = image.height;
// calculate the sprite count (line, col)
const spritecount = vector2dPool.get(
@@ -22,7 +22,10 @@ export function parseSpriteSheet(data, textureAtlas) {
~~((height - margin + spacing) / (data.frameheight + spacing)),
);
- // verifying the texture size
+ // verifying the texture size. The frame GRID is implicitly truncated by
+ // the floored spritecount above; the UV divisor below must stay the
+ // PHYSICAL image size — the GPU texture is uploaded at full size, so
+ // dividing by a truncated size would scale/shift every frame's UVs.
if (
width % (data.framewidth + spacing) !== 0 ||
height % (data.frameheight + spacing) !== 0
@@ -33,9 +36,6 @@ export function parseSpriteSheet(data, textureAtlas) {
computed_width - width !== spacing &&
computed_height - height !== spacing
) {
- // "truncate size" if delta is different from the spacing size
- width = computed_width;
- height = computed_height;
// warning message
console.warn(
"Spritesheet Texture for image: " +
@@ -44,10 +44,10 @@ export function parseSpriteSheet(data, textureAtlas) {
(data.framewidth + spacing) +
"x" +
(data.frameheight + spacing) +
- ", truncating effective size to " +
- width +
+ ", truncating the frame grid to " +
+ computed_width +
"x" +
- height,
+ computed_height,
);
}
}
diff --git a/packages/melonjs/src/video/texture/texture2d.ts b/packages/melonjs/src/video/texture/texture2d.ts
index bce214492b..0e10bc0dfd 100644
--- a/packages/melonjs/src/video/texture/texture2d.ts
+++ b/packages/melonjs/src/video/texture/texture2d.ts
@@ -1,3 +1,5 @@
+import { emit, TEXTURE2D_DESTROYED } from "../../system/event.ts";
+
/**
* Compile-time-only brand key; no runtime value can hold it.
* @ignore
@@ -79,8 +81,14 @@ export default abstract class Texture2d {
/**
* Release any GPU/CPU resources held by this texture. The texture must not
* be used after calling destroy.
- * No-op by default — subclasses override when they own resources (a baked
- * canvas, a cached GL texture, a pooled render target).
+ *
+ * Broadcasts {@link event.TEXTURE2D_DESTROYED} with the backing source so
+ * GPU-side caches keyed by source image (e.g. the lit batcher's normal-map
+ * textures) release what they hold for it. Subclasses that own additional
+ * resources override this and call `super.destroy()` FIRST — the source
+ * must still be resolvable when the event fires.
*/
- destroy(): void {}
+ destroy(): void {
+ emit(TEXTURE2D_DESTROYED, this.getTexture());
+ }
}
diff --git a/packages/melonjs/src/video/webgl/batchers/batcher.js b/packages/melonjs/src/video/webgl/batchers/batcher.js
index a10227f344..42cf1c4783 100644
--- a/packages/melonjs/src/video/webgl/batchers/batcher.js
+++ b/packages/melonjs/src/video/webgl/batchers/batcher.js
@@ -186,7 +186,17 @@ export class Batcher {
this.vertexData.clear();
if (this.useIndexBuffer) {
- this.indexBuffer.clear();
+ // re-create the GL buffers rather than just clearing counters: on
+ // context restore the old buffer objects belong to the LOST
+ // context, and every upload through them would silently fail
+ // (mesh draws vanish). `deleteBuffer` on a lost-context object is
+ // a harmless no-op, so plain resets stay leak-free too. The
+ // quad-family batchers (non-indexed, static pattern) re-create
+ // their own index buffer in their reset() override.
+ const gl = this.gl;
+ gl.deleteBuffer(this.glVertexBuffer);
+ this.glVertexBuffer = gl.createBuffer();
+ this.indexBuffer.recreate();
}
}
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 91f763891a..9440ca3ef2 100644
--- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js
+++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js
@@ -1,3 +1,4 @@
+import { off, on, TEXTURE2D_DESTROYED } from "../../../system/event.ts";
import { MAX_LIGHTS } from "../lighting/constants.ts";
import { buildLitMultiTextureFragment } from "./../shaders/multitexture-lit.js";
import quadMultiLitVertex from "./../shaders/quad-multi-lit.vert";
@@ -128,6 +129,84 @@ export default class LitQuadBatcher extends QuadBatcher {
this._maxLights = MAX_LIGHTS;
this.defaultShader.setUniform("uLightCount", 0);
this.defaultShader.setUniform("uAmbient", [0, 0, 0]);
+
+ // release the cached GL texture when a Texture2d normal-map source
+ // (e.g. a per-level NoiseTexture2d) is destroyed — `normalMapTextures`
+ // keys sources strongly and is otherwise only emptied on full reset
+ if (!this._onTexture2dDestroyed) {
+ this._onTexture2dDestroyed = (source) => {
+ this.evictNormalMap(source);
+ };
+ on(TEXTURE2D_DESTROYED, this._onTexture2dDestroyed);
+ }
+ }
+
+ /**
+ * Unsubscribe the `TEXTURE2D_DESTROYED` listener; delegates to
+ * `MaterialBatcher.destroy()` for the texture-cache-reset one.
+ * @ignore
+ */
+ destroy() {
+ if (this._onTexture2dDestroyed) {
+ off(TEXTURE2D_DESTROYED, this._onTexture2dDestroyed);
+ this._onTexture2dDestroyed = null;
+ }
+ super.destroy();
+ }
+
+ /**
+ * 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).
+ * @ignore
+ */
+ bind() {
+ super.bind();
+ 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();
+ }
+ }
+
+ /**
+ * Release the cached GL texture uploaded for the given normal-map source
+ * and clear any slot bookkeeping referencing it. Safe to call for images
+ * that were never bound.
+ * @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);
+ for (let i = 0; i < this.boundNormalMaps.length; i++) {
+ if (this.boundNormalMaps[i] === image) {
+ this.boundNormalMaps[i] = null;
+ this.boundNormalVersions[i] = -1;
+ }
+ }
+ }
}
/**
@@ -486,10 +565,11 @@ export default class LitQuadBatcher extends QuadBatcher {
this.flush();
+ // see QuadBatcher.blitTexture — unit 0 is nulled renderer-wide
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, null);
this.currentTextureUnit = -1;
- delete this.boundTextures[0];
+ this.renderer.invalidateTextureUnit(0);
this.useShader(this.defaultShader);
}
diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js
index a8360082c0..2cbfe6490b 100644
--- a/packages/melonjs/src/video/webgl/batchers/material_batcher.js
+++ b/packages/melonjs/src/video/webgl/batchers/material_batcher.js
@@ -20,10 +20,8 @@ export class MaterialBatcher extends Batcher {
init(renderer, settings) {
super.init(renderer, settings);
- /**
- * the current active texture unit
- * @ignore
- */
+ // invalidate the active-unit tracking (see the currentTextureUnit
+ // accessor — the state lives on the renderer, shared by all batchers)
this.currentTextureUnit = -1;
/**
@@ -75,6 +73,26 @@ export class MaterialBatcher extends Batcher {
this.currentSamplerUnit = -1;
}
+ /**
+ * The GL texture unit currently active (`gl.activeTexture`). Tracked on
+ * the RENDERER, not per batcher: the active unit is global GL state
+ * shared by every batcher instance, and a per-batcher copy desyncs as
+ * soon as another batcher (or a blit, or an FBO pass) moves the active
+ * unit — the stale copy then lets `bindTexture2D` skip `gl.activeTexture`
+ * and bind (or upload) a texture onto whatever unit is REALLY active.
+ * Symptom was video force-re-uploads overwriting a mesh texture with
+ * video frames after a mesh pass.
+ * @type {number}
+ * @ignore
+ */
+ get currentTextureUnit() {
+ return this.renderer._activeTextureUnit;
+ }
+
+ set currentTextureUnit(unit) {
+ this.renderer._activeTextureUnit = unit;
+ }
+
/**
* Free resources used by the batcher. Currently unsubscribes the
* texture-cache-reset listener so a discarded batcher doesn't keep
@@ -167,6 +185,15 @@ export class MaterialBatcher extends Batcher {
this.bindTexture2D(currentTexture, unit, flush);
+ // `bindTexture2D` skips the GL calls entirely when its bookkeeping says
+ // the texture is already bound and active — but the `texImage2D` below
+ // writes through whatever unit/binding is REALLY active in GL. Force
+ // both (uploads are a cold path) so a re-upload can never land on a
+ // foreign texture even if the tracked state went stale.
+ gl.activeTexture(gl.TEXTURE0 + unit);
+ gl.bindTexture(gl.TEXTURE_2D, currentTexture);
+ this.currentTextureUnit = unit;
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, rs);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, rt);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js
index 29cebcc597..99e2c5426b 100644
--- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js
+++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js
@@ -45,13 +45,16 @@ function ensureRemapCapacity(vertexCount) {
_remapStamp = new Int32Array(cap); // zero-filled; _stamp is always ≥ 1 in use
}
-// Shared lazy-depth-clear state for the mesh-mode pass. Module-level (not
-// per-instance) so the unlit `MeshBatcher` and the `LitMeshBatcher` — which
-// extends it and inherits `bind()` — coordinate on a SINGLE depth clear per
-// target. If each kept its own flag, switching between the two mid-frame would
-// re-clear the shared depth buffer and break inter-mesh occlusion. The first
-// `bind()` of either clears + marks clean; `RENDER_TARGET_CHANGED` re-arms it.
-let _meshDepthDirty = true;
+// The lazy-depth-clear state for the mesh-mode pass lives on the RENDERER
+// (`renderer._meshDepthDirty`), not per batcher instance: the unlit
+// `MeshBatcher` and the `LitMeshBatcher` — which extends it and inherits
+// `bind()` — must coordinate on a SINGLE depth clear per target (per-instance
+// flags would re-clear the shared depth buffer when switching between the two
+// mid-frame and break inter-mesh occlusion), while two coexisting renderers
+// must NOT share it (a module-level flag let one Application's `clear()`
+// re-arm — or steal — the other's depth clear mid-frame). The first `bind()`
+// of either batcher clears + marks clean; `RENDER_TARGET_CHANGED` from the
+// owning renderer re-arms it.
// shared zero emissive, passed to the shader when a mesh has no emission so the
// `uEmissive` add is a no-op. Never mutated.
@@ -132,15 +135,23 @@ export default class MeshBatcher extends MaterialBatcher {
this.currentEmissiveG = -1;
this.currentEmissiveB = -1;
+ // arm the (renderer-owned) lazy depth clear for the first mesh pass
+ renderer._meshDepthDirty = true;
+
// Subscribe to the renderer's target-changed broadcast so we re-arm the
- // shared lazy depth clear (`_meshDepthDirty`) whenever the active
+ // lazy depth clear (`renderer._meshDepthDirty`) whenever the active
// framebuffer's attachments change identity (FBO bind/unbind for
// post-effects, frame-start `clear()`). Same pattern as
// `MaterialBatcher`'s `GPU_TEXTURE_CACHE_RESET` subscription — only
// batchers that care subscribe.
if (!this._onTargetChanged) {
- this._onTargetChanged = () => {
- _meshDepthDirty = true;
+ this._onTargetChanged = (emitter) => {
+ // the event is global — ignore broadcasts from OTHER renderer
+ // instances (several Applications can coexist on one page);
+ // a missing emitter (legacy/manual emit) is treated as ours
+ if (emitter === undefined || emitter === this.renderer) {
+ this.renderer._meshDepthDirty = true;
+ }
};
on(RENDER_TARGET_CHANGED, this._onTargetChanged);
}
@@ -254,10 +265,10 @@ export default class MeshBatcher extends MaterialBatcher {
gl.depthFunc(gl.LEQUAL);
gl.depthMask(true);
gl.disable(gl.BLEND);
- if (_meshDepthDirty) {
+ if (this.renderer._meshDepthDirty) {
gl.clearDepth(1.0);
gl.clear(gl.DEPTH_BUFFER_BIT);
- _meshDepthDirty = false;
+ this.renderer._meshDepthDirty = false;
}
}
diff --git a/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js b/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js
index 146e8e146f..cafefff3ef 100644
--- a/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js
+++ b/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js
@@ -102,7 +102,6 @@ export default class PrimitiveBatcher extends Batcher {
return;
}
- const viewMatrix = this.viewMatrix;
const vertexData = this.vertexData;
const alpha = this.renderer.getGlobalAlpha();
const colorUint32 = this.renderer.currentColor.toUint32(alpha);
@@ -111,17 +110,42 @@ export default class PrimitiveBatcher extends Batcher {
// primitives don't have per-vertex depth.
const z = this.renderer.currentDepth;
- if (vertexData.isFull(vertexCount)) {
- // is the vertex buffer full if we add more vertices
- this.flush();
- }
-
// flush if drawing vertices with a different drawing mode
if (mode !== this.mode) {
this.flush(this.mode);
this.mode = mode;
}
+ if (vertexCount < vertexData.maxVertex) {
+ // fast path: the shape fits in one batch
+ if (vertexData.isFull(vertexCount)) {
+ // is the vertex buffer full if we add more vertices
+ this.flush();
+ }
+ this.#pushRange(verts, 0, vertexCount, colorUint32, z);
+ } else {
+ // a single shape larger than the whole vertex buffer (a filled
+ // ellipse already exceeds it at radius ≳ 435 px) — split it into
+ // buffer-sized chunks; without this, the typed-array writes past
+ // the buffer end are silently dropped while the draw call still
+ // uses the full count, raising GL errors and rendering nothing
+ this.#drawVerticesChunked(mode, verts, vertexCount, colorUint32, z);
+ }
+
+ // force flush for primitive using LINE_STRIP or LINE_LOOP
+ if (this.mode === this.gl.LINE_STRIP || this.mode === this.gl.LINE_LOOP) {
+ this.flush(this.mode);
+ }
+ }
+
+ /**
+ * Push `verts[start..end)` into the vertex buffer, transformed by the
+ * current view matrix. The caller guarantees the range fits.
+ * @ignore
+ */
+ #pushRange(verts, start, end, colorUint32, z) {
+ const viewMatrix = this.viewMatrix;
+ const vertexData = this.vertexData;
if (!viewMatrix.isIdentity()) {
// Full 3D transform including the z column (m[8] / m[9] /
// m[10] / m[14]) so Camera3d's view matrix (X/Y-axis
@@ -129,7 +153,7 @@ export default class PrimitiveBatcher extends Batcher {
// matrices those slots are identity, so output (x, y, z)
// is bit-identical to the legacy 2D-only multiply.
const m = viewMatrix.val;
- for (let i = 0; i < vertexCount; i++) {
+ for (let i = start; i < end; i++) {
const vert = verts[i];
const x = vert.x;
const y = vert.y;
@@ -143,15 +167,86 @@ export default class PrimitiveBatcher extends Batcher {
);
}
} else {
- for (let i = 0; i < vertexCount; i++) {
+ for (let i = start; i < end; i++) {
const vert = verts[i];
vertexData.push(vert.x, vert.y, z, 0, 0, colorUint32);
}
}
+ }
- // force flush for primitive using LINE_STRIP or LINE_LOOP
- if (this.mode === this.gl.LINE_STRIP || this.mode === this.gl.LINE_LOOP) {
- this.flush(this.mode);
+ /**
+ * Draw an over-capacity vertex list as a sequence of buffer-sized chunks,
+ * split on primitive boundaries so every triangle/line stays whole:
+ *
+ * - TRIANGLES / LINES chunk on multiples of 3 / 2, POINTS anywhere.
+ * - LINE_STRIP re-pushes the boundary vertex so the connecting segment
+ * is preserved; LINE_LOOP additionally appends the first vertex at the
+ * very end (drawn as open strips, the final duplicate closes the loop).
+ * - TRIANGLE_STRIP overlaps 2 boundary vertices; TRIANGLE_FAN re-anchors
+ * every chunk on `verts[0]` and overlaps 1. (Strip chunk parity can
+ * flip triangle winding at a boundary — irrelevant here, the primitive
+ * pipeline never enables face culling.)
+ * @ignore
+ */
+ #drawVerticesChunked(mode, verts, vertexCount, colorUint32, z) {
+ const gl = this.gl;
+ // stay one below maxVertex to match isFull()'s `>=` convention
+ const capacity = this.vertexData.maxVertex - 1;
+ let step = capacity;
+ let overlap = 0;
+ let anchor = false;
+ let drawMode = mode;
+ switch (mode) {
+ case gl.TRIANGLES:
+ step = capacity - (capacity % 3);
+ break;
+ case gl.LINES:
+ step = capacity - (capacity % 2);
+ break;
+ case gl.LINE_STRIP:
+ overlap = 1;
+ break;
+ case gl.LINE_LOOP:
+ // chunks are open strips; the loop is closed explicitly below
+ overlap = 1;
+ drawMode = gl.LINE_STRIP;
+ break;
+ case gl.TRIANGLE_STRIP:
+ overlap = 2;
+ break;
+ case gl.TRIANGLE_FAN:
+ overlap = 1;
+ anchor = true;
+ break;
+ default:
+ // POINTS: any split works
+ break;
+ }
+ this.mode = drawMode;
+
+ let start = 0;
+ while (start < vertexCount) {
+ // each chunk starts on an empty buffer
+ this.flush(drawMode);
+ const anchored = anchor && start > 0;
+ if (anchored) {
+ this.#pushRange(verts, 0, 1, colorUint32, z);
+ }
+ const count = Math.min(vertexCount - start, step - (anchored ? 1 : 0));
+ this.#pushRange(verts, start, start + count, colorUint32, z);
+ start += count;
+ if (start < vertexCount) {
+ start -= overlap;
+ }
+ }
+
+ if (mode === gl.LINE_LOOP) {
+ // close the loop: duplicate the first vertex after the last one
+ if (this.vertexData.isFull(1)) {
+ this.flush(drawMode);
+ this.#pushRange(verts, vertexCount - 1, vertexCount, colorUint32, z);
+ }
+ this.#pushRange(verts, 0, 1, colorUint32, z);
}
}
@@ -173,13 +268,6 @@ export default class PrimitiveBatcher extends Batcher {
// consumed by perspective (Camera3d).
const z = this.renderer.currentDepth;
- // each line pair (2 verts) expands to 2 triangles (6 verts)
- const expandedCount = (vertexCount / 2) * 6;
-
- if (vertexData.isFull(expandedCount)) {
- this.flush();
- }
-
// switch to TRIANGLES mode
if (this.mode !== this.gl.TRIANGLES) {
this.flush(this.mode);
@@ -192,6 +280,15 @@ export default class PrimitiveBatcher extends Batcher {
const from = verts[i];
const to = verts[i + 1];
+ // each line pair expands to 2 triangles (6 vertices) — check
+ // capacity per pair, so a dashed/long thick-line path larger than
+ // the whole buffer flushes mid-shape instead of silently dropping
+ // the out-of-range writes (pairs are independent quads, so a
+ // mid-shape flush is invisible)
+ if (vertexData.isFull(6)) {
+ this.flush();
+ }
+
// apply view matrix to base positions without mutating
// inputs. Includes the z column for parity with the simple-
// line path and Vector3d quad batcher — Camera3d's view
diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js
index cb17c4af95..a2f37b96f8 100644
--- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js
+++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js
@@ -100,6 +100,12 @@ export default class QuadBatcher extends MaterialBatcher {
* @ignore
*/
createIndexBuffer() {
+ // free the previous GL buffer first (reset / context-restore path) —
+ // recreating without deleting orphaned one ~12 KB ELEMENT_ARRAY
+ // buffer per reset until GC collected the wrapper
+ if (this.indexBuffer) {
+ this.indexBuffer.destroy();
+ }
const maxQuads = this.vertexData.maxVertex / 4;
this.indexBuffer = new IndexBuffer(
this.gl,
@@ -249,12 +255,14 @@ export default class QuadBatcher extends MaterialBatcher {
this.flush();
- // unbind the texture to prevent feedback loop on next frame, and
- // drop the unit-0 cache slot so the next bind re-issues activeTexture.
+ // unbind the texture to prevent feedback loop on next frame, and drop
+ // EVERY batcher's unit-0 slot — unit 0's GL binding is now null, and a
+ // non-current batcher trusting its stale record would skip the re-bind
+ // and sample an unbound texture (opaque black)
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, null);
this.currentTextureUnit = -1;
- delete this.boundTextures[0];
+ this.renderer.invalidateTextureUnit(0);
// restore the default shader (also re-enables multi-texture batching)
this.useShader(this.defaultShader);
diff --git a/packages/melonjs/src/video/webgl/buffer/index.js b/packages/melonjs/src/video/webgl/buffer/index.js
index 2013dcc126..abd9911d3d 100644
--- a/packages/melonjs/src/video/webgl/buffer/index.js
+++ b/packages/melonjs/src/video/webgl/buffer/index.js
@@ -50,4 +50,26 @@ export default class WebGLIndexBuffer extends IndexBuffer {
bind() {
this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.buffer);
}
+
+ /**
+ * Replace the underlying GL buffer with a fresh one, deleting the old.
+ * Needed on context restore — the old buffer object belongs to the lost
+ * context and every upload through it would silently fail. Also keeps
+ * plain resets leak-free (`deleteBuffer` on a lost-context object is a
+ * harmless no-op).
+ */
+ recreate() {
+ this.gl.deleteBuffer(this.buffer);
+ this.buffer = this.gl.createBuffer();
+ this.length = 0;
+ }
+
+ /**
+ * Delete the underlying GL buffer. The index buffer must not be used
+ * after calling destroy.
+ */
+ destroy() {
+ this.gl.deleteBuffer(this.buffer);
+ this.buffer = null;
+ }
}
diff --git a/packages/melonjs/src/video/webgl/shadereffect.js b/packages/melonjs/src/video/webgl/shadereffect.js
index dcf6747db3..080ebacf45 100644
--- a/packages/melonjs/src/video/webgl/shadereffect.js
+++ b/packages/melonjs/src/video/webgl/shadereffect.js
@@ -331,6 +331,13 @@ export default class ShaderEffect {
let nextUnit = 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)
+ while (nextUnit >= 1 && cache.reservedUnits.has(nextUnit)) {
+ nextUnit--;
+ }
if (nextUnit < 1) {
// more extra textures than the batcher can hold beside
// uSampler — bind what fits, warn once, skip the rest
diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js
index c9ac21cd82..ab34df72a1 100644
--- a/packages/melonjs/src/video/webgl/webgl_renderer.js
+++ b/packages/melonjs/src/video/webgl/webgl_renderer.js
@@ -193,6 +193,16 @@ export default class WebGLRenderer extends Renderer {
*/
this.currentProgram = undefined;
+ /**
+ * the GL texture unit currently active (`gl.activeTexture`) — global
+ * GL state, so tracked once per renderer and shared by every batcher
+ * through the `MaterialBatcher.currentTextureUnit` accessor. `-1`
+ * forces the next bind to re-issue `gl.activeTexture`.
+ * @type {number}
+ * @ignore
+ */
+ this._activeTextureUnit = -1;
+
/**
* The list of active batchers
* @type {Map}
@@ -284,6 +294,10 @@ export default class WebGLRenderer extends Renderer {
this.cache.units.clear();
this.cache.usedUnits.clear();
+ // the restored context is back at TEXTURE0 — invalidate the
+ // shared active-unit tracking so the next bind re-issues it
+ this._activeTextureUnit = -1;
+
this.isContextValid = true;
emit(ONCONTEXT_RESTORED, this);
},
@@ -1055,10 +1069,12 @@ export default class WebGLRenderer extends Renderer {
this._effectPassDepth++;
const rt = this._renderTargetPool.begin(isCamera, effects.length, w, h);
- // FBO creation/resize uses TEXTURE0 — invalidate the batcher's cache for that unit
- if (this.currentBatcher && this.currentBatcher.boundTextures) {
- delete this.currentBatcher.boundTextures[0];
- }
+ // FBO creation/resize binds (then nulls) TEXTURE0 — invalidate EVERY
+ // batcher's cache for that unit, not just the current one: a batcher
+ // that isn't current right now would otherwise keep trusting its stale
+ // `boundTextures[0]` and skip the re-bind, sampling an unbound texture
+ // (opaque black) for as long as the post effect is active
+ this.invalidateTextureUnit(0);
rt.bind();
this.setViewport(0, 0, w, h);
this.disableScissor();
@@ -1120,7 +1136,7 @@ export default class WebGLRenderer extends Renderer {
parentRT.bind();
}
this.setViewport(0, 0, w, h);
- emit(RENDER_TARGET_CHANGED);
+ emit(RENDER_TARGET_CHANGED, this);
if (effects.length === 1) {
this.blitEffect(rt1.texture, 0, 0, w, h, effects[0], keepBlend);
@@ -1252,7 +1268,7 @@ export default class WebGLRenderer extends Renderer {
const gl = this.gl;
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
- emit(RENDER_TARGET_CHANGED);
+ emit(RENDER_TARGET_CHANGED, this);
}
/**
@@ -1327,7 +1343,7 @@ export default class WebGLRenderer extends Renderer {
// — currently only `MeshBatcher`) is cleared lazily on the first
// draw of its mode via the `onTargetChanged()` → `bind()` path.
gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
- emit(RENDER_TARGET_CHANGED);
+ emit(RENDER_TARGET_CHANGED, this);
}
/**
diff --git a/packages/melonjs/tests/texture-atlas-parsers.spec.js b/packages/melonjs/tests/texture-atlas-parsers.spec.js
new file mode 100644
index 0000000000..0a729aded2
--- /dev/null
+++ b/packages/melonjs/tests/texture-atlas-parsers.spec.js
@@ -0,0 +1,234 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { TextureAtlas } from "../src/index.js";
+import { ETA } from "../src/math/math.ts";
+
+/**
+ * Regression tests for the 2026-07 batchers + texture-cache bug hunt,
+ * atlas/parser cluster. Every atlas here is constructed with the legacy
+ * `cache = false` third argument so no renderer/boot is required — all of
+ * the code under test is pure JS (parsers, UV math, dictionary handling).
+ */
+
+// a standard aseprite JSON export (hash form): `trimmed`, `spriteSourceSize`,
+// `sourceSize`, `rotated`, `duration` and `pivot` are SIBLINGS of the `frame`
+// rect on each frames[name] entry — NOT properties of the rect itself.
+const makeAsepriteJSON = () => {
+ return {
+ frames: {
+ "hero 0.png": {
+ frame: { x: 0, y: 0, w: 28, h: 30 },
+ rotated: false,
+ trimmed: true,
+ spriteSourceSize: { x: 2, y: 1, w: 28, h: 30 },
+ sourceSize: { w: 32, h: 32 },
+ duration: 100,
+ },
+ "hero 1.png": {
+ frame: { x: 28, y: 0, w: 32, h: 32 },
+ rotated: false,
+ trimmed: false,
+ spriteSourceSize: { x: 0, y: 0, w: 32, h: 32 },
+ sourceSize: { w: 32, h: 32 },
+ pivot: { x: 0.5, y: 1 },
+ duration: 200,
+ },
+ "hero 2.png": {
+ frame: { x: 60, y: 0, w: 32, h: 32 },
+ rotated: true,
+ trimmed: false,
+ spriteSourceSize: { x: 0, y: 0, w: 32, h: 32 },
+ sourceSize: { w: 32, h: 32 },
+ duration: 300,
+ },
+ },
+ meta: {
+ app: "https://www.aseprite.org/",
+ image: "hero.png",
+ size: { w: 128, h: 64 },
+ frameTags: [{ name: "walk", from: 0, to: 2, direction: "forward" }],
+ },
+ };
+};
+
+const fakeImage = (width, height, src = "fake.png") => {
+ return { width, height, src };
+};
+
+describe("aseprite texture parser", () => {
+ it("reads trim data from the frame entry (not the frame rect)", () => {
+ const atlas = new TextureAtlas(
+ makeAsepriteJSON(),
+ fakeImage(128, 64),
+ false,
+ );
+ const region = atlas.getRegion("hero 0.png");
+ expect(region.trimmed).toBe(true);
+ expect(region.trim).toEqual({ x: 2, y: 1, w: 28, h: 30 });
+ });
+
+ it("exposes sourceSize so animation sizing uses the untrimmed frame", () => {
+ const atlas = new TextureAtlas(
+ makeAsepriteJSON(),
+ fakeImage(128, 64),
+ false,
+ );
+ const region = atlas.getRegion("hero 0.png");
+ expect(region.sourceSize).toEqual({ w: 32, h: 32 });
+ // trimmed cell keeps its packed dimensions
+ expect(region.width).toBe(28);
+ expect(region.height).toBe(30);
+ });
+
+ it("computes the anchorPoint from a frame-level pivot", () => {
+ const atlas = new TextureAtlas(
+ makeAsepriteJSON(),
+ fakeImage(128, 64),
+ false,
+ );
+ const region = atlas.getRegion("hero 1.png");
+ expect(region.anchorPoint).not.toBe(null);
+ expect(region.anchorPoint.x).toBeCloseTo(0.5);
+ expect(region.anchorPoint.y).toBeCloseTo(1);
+ });
+
+ it("honors the frame-level rotated flag", () => {
+ const atlas = new TextureAtlas(
+ makeAsepriteJSON(),
+ fakeImage(128, 64),
+ false,
+ );
+ expect(atlas.getRegion("hero 2.png").angle).toBe(-ETA);
+ expect(atlas.getRegion("hero 0.png").angle).toBe(0);
+ });
+
+ it("builds animations from the authored per-frame durations", () => {
+ const atlas = new TextureAtlas(
+ makeAsepriteJSON(),
+ fakeImage(128, 64),
+ false,
+ );
+ const anims = atlas.getAtlas().anims;
+ const walk = Object.values(anims).find((a) => {
+ return a.name === "walk";
+ });
+ expect(walk).toBeDefined();
+ // index entries are frame objects carrying each frame's own delay,
+ // straight from the JSON's `duration` values
+ expect(walk.index).toEqual([
+ { name: 0, delay: 100 },
+ { name: 1, delay: 200 },
+ { name: 2, delay: 300 },
+ ]);
+ // no synthesized flat speed overriding the per-frame delays
+ expect(walk.speed).toBeUndefined();
+ });
+});
+
+describe("no-arg atlas iteration (getAnimationSettings)", () => {
+ it("iterates real frames only — no coordinate aliases, no anims entry", () => {
+ const atlas = new TextureAtlas(
+ makeAsepriteJSON(),
+ fakeImage(128, 64),
+ false,
+ );
+ const settings = atlas.getAnimationSettings();
+ // one entry per actual frame — aliases would double it, the aseprite
+ // `anims` dict would add a bogus NaN-sized region
+ expect(settings.atlas.length).toBe(3);
+ expect(Object.keys(settings.atlasIndices).sort()).toEqual([
+ "hero 0.png",
+ "hero 1.png",
+ "hero 2.png",
+ ]);
+ expect(Number.isNaN(settings.framewidth)).toBe(false);
+ expect(settings.framewidth).toBe(32);
+ expect(settings.frameheight).toBe(32);
+ });
+});
+
+describe("UV coordinate-alias cache (#1281 workaround)", () => {
+ it("a full-image lookup is not poisoned by a frame at (0,0)", () => {
+ const atlas = new TextureAtlas(
+ { framewidth: 32, frameheight: 32 },
+ fakeImage(64, 64, "sheet.png"),
+ false,
+ );
+ // frame 0 sits at (0,0,32,32); asking for the WHOLE image must not
+ // return frame 0's UVs
+ const uvs = atlas.getUVs(0, 0, 64, 64);
+ expect(Array.from(uvs)).toEqual([0, 0, 1, 1]);
+ });
+
+ it("a sub-region lookup hits the alias instead of duplicating the region", () => {
+ const atlas = new TextureAtlas(
+ { framewidth: 32, frameheight: 32 },
+ fakeImage(64, 64, "sheet.png"),
+ false,
+ );
+ const frame1uvs = atlas.getAtlas()["1"].uvs;
+ // frame "1" is at (32,0,32,32) — the coordinate lookup must resolve
+ // to the SAME uvs array (cache hit), not an ad-hoc duplicate region
+ expect(atlas.getUVs(32, 0, 32, 32)).toBe(frame1uvs);
+ });
+});
+
+describe("addRegion on a video source", () => {
+ it("falls back to videoWidth/videoHeight for the UV divisor", () => {
+ // an unsized HTMLVideoElement reports width/height = 0; its real
+ // pixel dimensions are videoWidth/videoHeight
+ const videoLike = {
+ width: 0,
+ height: 0,
+ videoWidth: 320,
+ videoHeight: 240,
+ };
+ const atlas = new TextureAtlas(
+ {
+ meta: { app: "melonJS", size: { w: 320, h: 240 } },
+ frames: [
+ { filename: "default", frame: { x: 0, y: 0, w: 320, h: 240 } },
+ ],
+ },
+ videoLike,
+ false,
+ );
+ const region = atlas.addRegion("clip", 0, 0, 64, 48);
+ expect(region.uvs[2]).toBeCloseTo(64 / 320);
+ expect(region.uvs[3]).toBeCloseTo(48 / 240);
+ for (const v of region.uvs) {
+ expect(Number.isFinite(v)).toBe(true);
+ }
+ });
+});
+
+describe("TextureAtlas format validation", () => {
+ it("throws on an unrecognized atlas object instead of constructing empty", () => {
+ expect(() => {
+ return new TextureAtlas({ bogus: true }, fakeImage(1, 1), false);
+ }).toThrow(/format not supported/);
+ });
+});
+
+describe("spritesheet parser on a non-divisible (padded) sheet", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("computes UVs against the physical texture size, not the truncated grid", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ // 70x70 image of 32x32 cells: the frame grid truncates to 2x2, but the
+ // GL texture is still uploaded at 70x70 — the UV divisor must match it
+ const atlas = new TextureAtlas(
+ { framewidth: 32, frameheight: 32 },
+ fakeImage(70, 70, "padded.png"),
+ false,
+ );
+ expect(warn).toHaveBeenCalled();
+ const frame0 = atlas.getAtlas()["0"];
+ expect(frame0.uvs[2]).toBeCloseTo(32 / 70);
+ expect(frame0.uvs[3]).toBeCloseTo(32 / 70);
+ // second column starts at x=32 → u0 = 32/70
+ const frame1 = atlas.getAtlas()["1"];
+ expect(frame1.uvs[0]).toBeCloseTo(32 / 70);
+ });
+});
diff --git a/packages/melonjs/tests/webgl_batcher_state.spec.js b/packages/melonjs/tests/webgl_batcher_state.spec.js
new file mode 100644
index 0000000000..be603958a5
--- /dev/null
+++ b/packages/melonjs/tests/webgl_batcher_state.spec.js
@@ -0,0 +1,265 @@
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import {
+ boot,
+ NoiseTexture2d,
+ ShaderEffect,
+ video,
+ WebGLRenderer,
+} from "../src/index.js";
+import { emit, RENDER_TARGET_CHANGED } from "../src/system/event.ts";
+
+/**
+ * Regression tests for the 2026-07 batchers + texture-cache bug hunt,
+ * GL-state cluster: texture-unit collisions between the lit batcher's fixed
+ * normal-map range and the renderer-wide unit allocator, per-batcher tracking
+ * of global GL state, unit-0 invalidation reach, primitive-batcher buffer
+ * overflow, normal-map GPU eviction, per-renderer mesh depth state, and GL
+ * buffer lifetime across reset/context-restore.
+ */
+describe("batcher GL state", () => {
+ let renderer;
+
+ beforeAll(async () => {
+ await boot();
+ try {
+ video.init(128, 128, {
+ parent: "screen",
+ renderer: video.WEBGL,
+ // headless chromium software GL trips the performance-caveat
+ // check — opt out so the tests run instead of skipping
+ failIfMajorPerformanceCaveat: false,
+ });
+ } catch {
+ // genuine WebGL absence — tests skip below
+ }
+ if (video.renderer instanceof WebGLRenderer) {
+ renderer = video.renderer;
+ }
+ });
+
+ afterAll(() => {
+ try {
+ video.init(128, 128, {
+ parent: "screen",
+ renderer: video.AUTO,
+ });
+ } catch {
+ // ignore — nothing to restore if init never succeeded
+ }
+ });
+
+ const requireWebGL = (ctx) => {
+ if (renderer === undefined) {
+ ctx.skip("WebGL renderer not available in this environment");
+ }
+ };
+
+ it("activating the lit batcher reserves the paired normal-map unit range", (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);
+ }
+
+ // the allocator must never hand a reserved (normal-map) unit to a
+ // color texture — drain it past exhaustion to cover the reset path too
+ renderer.cache.resetUnitAssignments();
+ const handed = new Set();
+ for (let i = 0; i < renderer.maxTextures * 2; i++) {
+ handed.add(renderer.cache.allocateTextureUnit());
+ }
+ for (const unit of handed) {
+ expect(unit < half || unit >= half * 2).toBe(true);
+ }
+ renderer.cache.resetUnitAssignments();
+ });
+
+ 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");
+
+ const fx = new ShaderEffect(
+ renderer,
+ "vec4 apply(vec4 color, vec2 uv) { return color; }",
+ );
+ // the trivial fragment declares no extra sampler — stub the uniform
+ // upload so only the unit-claiming logic under test runs
+ vi.spyOn(fx._shader, "setUniform").mockImplementation(() => {});
+ fx.setTexture("uNoise", video.createCanvas(8, 8));
+ 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);
+ fx.destroy();
+ });
+
+ it("tracks the active texture unit renderer-wide, not per batcher", (ctx) => {
+ requireWebGL(ctx);
+ const quad = renderer.batchers.get("quad");
+ const mesh = renderer.batchers.get("mesh");
+ quad.currentTextureUnit = 3;
+ expect(mesh.currentTextureUnit).toBe(3);
+ expect(renderer._activeTextureUnit).toBe(3);
+ mesh.currentTextureUnit = -1;
+ expect(quad.currentTextureUnit).toBe(-1);
+ });
+
+ it("createTexture2D re-activates its unit even when tracking says it's current", (ctx) => {
+ requireWebGL(ctx);
+ const gl = renderer.gl;
+ const quad = renderer.batchers.get("quad");
+ const canvas = video.createCanvas(8, 8);
+
+ // upload once at unit 2 — tracking now says "tex bound at 2, unit 2 active"
+ const tex = quad.createTexture2D(
+ 2,
+ canvas,
+ gl.NEAREST,
+ "no-repeat",
+ 8,
+ 8,
+ true,
+ false,
+ undefined,
+ false,
+ );
+ // a foreign gl.activeTexture move the batcher's bookkeeping can't see
+ // (another batcher instance, an FBO pass, user GL code)
+ gl.activeTexture(gl.TEXTURE0 + 5);
+
+ // force a re-upload into the same handle at unit 2 — the upload must
+ // land on unit 2, not on whatever unit is really active
+ quad.createTexture2D(
+ 2,
+ canvas,
+ gl.NEAREST,
+ "no-repeat",
+ 8,
+ 8,
+ true,
+ false,
+ tex,
+ false,
+ );
+ expect(gl.getParameter(gl.ACTIVE_TEXTURE)).toBe(gl.TEXTURE0 + 2);
+ });
+
+ it("unit-0 invalidation reaches every batcher, not just the current one", (ctx) => {
+ requireWebGL(ctx);
+ const gl = renderer.gl;
+ const quad = renderer.batchers.get("quad");
+ const mesh = renderer.batchers.get("mesh");
+ const lit = renderer.batchers.get("litQuad");
+ const fake = gl.createTexture();
+
+ quad.boundTextures[0] = fake;
+ mesh.boundTextures[0] = fake;
+ lit.boundTextures[0] = fake;
+ renderer.invalidateTextureUnit(0);
+ expect(0 in quad.boundTextures).toBe(false);
+ expect(0 in mesh.boundTextures).toBe(false);
+ expect(0 in lit.boundTextures).toBe(false);
+
+ // integration: a post-effect blit through the QUAD batcher nulls GL
+ // unit 0 — the MESH batcher's record must not survive it. Blits are
+ // always driven by a ShaderEffect (see WebGLRenderer.blitEffect).
+ const fx = new ShaderEffect(
+ renderer,
+ "vec4 apply(vec4 color, vec2 uv) { return color; }",
+ );
+ mesh.boundTextures[0] = fake;
+ renderer.setBatcher("quad");
+ quad.blitTexture(fake, 0, 0, 16, 16, fx);
+ expect(0 in mesh.boundTextures).toBe(false);
+
+ fx.destroy();
+ gl.deleteTexture(fake);
+ });
+
+ it("fills a shape larger than the vertex buffer without GL errors", (ctx) => {
+ requireWebGL(ctx);
+ const gl = renderer.gl;
+ // drain any pre-existing error flags
+ while (gl.getError() !== gl.NO_ERROR) {
+ // keep draining
+ }
+
+ renderer.setBatcher("primitive");
+ renderer.setColor("#ff0000");
+ // π(w+h)/arcResolution segments × 3 fan vertices ≈ 4712 — beyond the
+ // 4096-vertex buffer, so this only renders if drawVertices chunks
+ renderer.fillEllipse(64, 64, 500, 500);
+ renderer.flush();
+
+ expect(gl.getError()).toBe(gl.NO_ERROR);
+ const px = new Uint8Array(4);
+ gl.readPixels(64, 64, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);
+ expect(px[0]).toBe(255);
+ expect(px[1]).toBe(0);
+ expect(px[2]).toBe(0);
+ });
+
+ it("destroying a NoiseTexture2d releases its cached normal-map GL texture", (ctx) => {
+ requireWebGL(ctx);
+ const lit = renderer.batchers.get("litQuad");
+ const nm = new NoiseTexture2d({ width: 8, height: 8, asNormalMap: true });
+ const source = nm.getTexture();
+
+ lit.bindNormalMap(source, lit.maxBatchTextures);
+ expect(lit.normalMapTextures.has(source)).toBe(true);
+ const tex = lit.normalMapTextures.get(source).tex;
+
+ nm.destroy();
+ expect(lit.normalMapTextures.has(source)).toBe(false);
+ expect(renderer.gl.isTexture(tex)).toBe(false);
+ });
+
+ it("RENDER_TARGET_CHANGED re-arms the mesh depth clear only for its own renderer", (ctx) => {
+ requireWebGL(ctx);
+ renderer._meshDepthDirty = false;
+ // another renderer instance's broadcast must not re-arm ours
+ emit(RENDER_TARGET_CHANGED, { not: "this renderer" });
+ expect(renderer._meshDepthDirty).toBe(false);
+ // our own broadcast re-arms
+ emit(RENDER_TARGET_CHANGED, renderer);
+ expect(renderer._meshDepthDirty).toBe(true);
+ // legacy no-argument emit is treated as "mine" (back-compat)
+ renderer._meshDepthDirty = false;
+ emit(RENDER_TARGET_CHANGED);
+ expect(renderer._meshDepthDirty).toBe(true);
+ });
+
+ it("reset() replaces GL index/vertex buffers without leaking the old ones", (ctx) => {
+ requireWebGL(ctx);
+ const gl = renderer.gl;
+ const quad = renderer.batchers.get("quad");
+ const mesh = renderer.batchers.get("mesh");
+
+ // bind each buffer once so gl.isBuffer can observe deletion
+ quad.indexBuffer.bind();
+ mesh.indexBuffer.bind();
+ gl.bindBuffer(gl.ARRAY_BUFFER, mesh.glVertexBuffer);
+ const oldQuadIdx = quad.indexBuffer.buffer;
+ const oldMeshIdx = mesh.indexBuffer.buffer;
+ const oldMeshVbo = mesh.glVertexBuffer;
+
+ renderer.reset();
+
+ expect(quad.indexBuffer.buffer).not.toBe(oldQuadIdx);
+ expect(gl.isBuffer(oldQuadIdx)).toBe(false);
+ expect(mesh.indexBuffer.buffer).not.toBe(oldMeshIdx);
+ expect(gl.isBuffer(oldMeshIdx)).toBe(false);
+ expect(mesh.glVertexBuffer).not.toBe(oldMeshVbo);
+ expect(gl.isBuffer(oldMeshVbo)).toBe(false);
+ });
+});