Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion packages/examples/src/examples/aseprite/ExampleAseprite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,33 @@ export const ExampleAseprite = () => {
}, []);

return (
<div>
// float above the fixed #screen overlay (see index.css), like the
// tiledMapLoader / spine selectors — in normal flow the game surface
// paints over the controls
<div
style={{
position: "absolute",
top: 44,
left: 16,
zIndex: 1000,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<div>Animation:</div>
<select
name="animation_name"
id="animation_name"
defaultValue="run front"
style={{
padding: "6px 12px",
fontSize: 14,
background: "#1a1a1a",
color: "#e0e0e0",
border: "1px solid #444",
borderRadius: 4,
}}
onChange={(event) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
(paladin.renderable as me.Sprite).setCurrentAnimation(
Expand Down
13 changes: 13 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_

Expand Down
21 changes: 19 additions & 2 deletions packages/melonjs/src/system/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Events>();
Expand Down
45 changes: 35 additions & 10 deletions packages/melonjs/src/video/texture/atlas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions packages/melonjs/src/video/texture/noise_texture2d.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 31 additions & 14 deletions packages/melonjs/src/video/texture/parser/aseprite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand All @@ -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,
};
Expand Down
18 changes: 9 additions & 9 deletions packages/melonjs/src/video/texture/parser/spritesheet.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@ 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(
~~((width - margin + spacing) / (data.framewidth + spacing)),
~~((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
Expand All @@ -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: " +
Expand All @@ -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,
);
}
}
Expand Down
Loading
Loading