Skip to content

Commit 962690d

Browse files
obiotclaude
andauthored
fix(video): batchers + texture-cache bug-hunt batch — 15 confirmed findings (#1546)
* fix(texture): atlas + parser bug batch (aseprite trim/durations, UV aliases, video regions) Seven confirmed findings from the batchers + texture-cache adversarial sweep, atlas/parser cluster — all reproduced by failing-first tests in the new texture-atlas-parsers.spec.js (11 tests): - aseprite parser read trimmed/spriteSourceSize/sourceSize/pivot/rotated off the frame rect instead of the frame entry (siblings, as in the TexturePacker layout) — trim silently ignored, sourceSize missing - aseprite animation speed was 10*(frameCount-1) ms/frame; tags now carry the authored per-frame `duration` as frame-object delays - addUVs coordinate-alias key used texture dims instead of region dims: sub-region lookups never hit (duplicate regions), and a frame at (0,0) poisoned full-image lookups (drew frame 0 stretched) - no-arg createAnimationFromName/getAnimationSettings iterated alias keys (every frame twice) and the aseprite `anims` entry (NaN sizes) - addRegion on an unsized video divided by width=0 → Infinity/NaN UVs (videoWidth fallback, matching uploadTexture/TextureCache.get) - padded spritesheet "truncation" rewrote the UV divisor while the GL texture keeps its physical size — frames sampled shifted under WebGL - `atlases.length === 0` dead guard on a Map — unsupported-format error was unreachable, malformed atlases constructed empty Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A * fix(webgl): batcher GL-state bug batch (unit collisions, upload desync, buffer lifetime) Eight confirmed findings from the batchers + texture-cache adversarial sweep, GL-state cluster — covered by the new webgl_batcher_state.spec.js (9 tests, key fixes verified by mutation): - lit normal maps bound at maxBatchTextures+slot by fixed arithmetic while the unit allocator handed those same units to color textures — whoever bound second clobbered the other (unlit sprites showing the normal map / lit sprites shading with color data). LitQuadBatcher now reserves its normal range on first bind (lazily — unlit games keep the full pool); ShaderEffect extra samplers skip units reserved by others - per-batcher tracking of the global gl.activeTexture state desynced after another batcher/FBO moved it, landing texImage2D re-uploads on foreign textures (video frames overwriting a mesh texture). The active unit now lives on the renderer (shared accessor); createTexture2D force-activates its target unit before uploading - post-effect FBO setup + blitTexture null unit 0's binding but only invalidated the current batcher — other batchers sampled an unbound texture (opaque black). New renderer.invalidateTextureUnit() reaches every batcher - PrimitiveBatcher.drawVertices had no chunking: a filled ellipse at radius ≳ 435px overflowed the 4096-vertex buffer (silently-dropped typed-array writes + GL errors, nothing rendered). Oversized shapes now chunk on primitive boundaries; thick-line expansion checks capacity per pair - NoiseTexture2d.destroy() leaked the lit batcher's cached GL texture + baked canvas (strong-keyed map, only emptied on full reset). New event.TEXTURE2D_DESTROYED emitted by Texture2d.destroy(); the lit batcher evicts in response - module-level mesh depth-clear flag was shared across renderer instances; now per-renderer, and RENDER_TARGET_CHANGED carries the emitting renderer so subscribers ignore foreign broadcasts - quad batchers leaked a GL index buffer on every GAME_RESET (recreate without delete); mesh batchers kept lost-context index/vertex buffers after webglcontextrestored (uploads silently failed). Both families now delete + recreate on reset (WebGLIndexBuffer.destroy/recreate) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A * docs(changelog): 19.9.0 entries for the batcher + texture-cache bug batch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A * fix(types): TEXTURE2D_DESTROYED carries a Texture2dSource (reconcile with #1543) #1543 widened Texture2d.getTexture() to the new Texture2dSource union (GPU-resident backings incl. ImageBitmap); the event signature added in this branch predated that and was too narrow to typecheck. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A * fix(examples): aseprite animation dropdown hidden behind the #screen overlay Since the 19.5 examples-shell restyle (#1454), #screen is a fixed overlay covering everything below the topbar; the tiledMapLoader/spine selectors were floated above it (absolute + zIndex 1000) but the aseprite one stayed in normal flow and has been painted over ever since. Float it the same way, match the house control styling, and default the select to "run front" so it agrees with the animation the entity actually starts with. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent be66f65 commit 962690d

19 files changed

Lines changed: 968 additions & 85 deletions

packages/examples/src/examples/aseprite/ExampleAseprite.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,33 @@ export const ExampleAseprite = () => {
1616
}, []);
1717

1818
return (
19-
<div>
19+
// float above the fixed #screen overlay (see index.css), like the
20+
// tiledMapLoader / spine selectors — in normal flow the game surface
21+
// paints over the controls
22+
<div
23+
style={{
24+
position: "absolute",
25+
top: 44,
26+
left: 16,
27+
zIndex: 1000,
28+
display: "flex",
29+
alignItems: "center",
30+
gap: 8,
31+
}}
32+
>
2033
<div>Animation:</div>
2134
<select
2235
name="animation_name"
2336
id="animation_name"
37+
defaultValue="run front"
38+
style={{
39+
padding: "6px 12px",
40+
fontSize: 14,
41+
background: "#1a1a1a",
42+
color: "#e0e0e0",
43+
border: "1px solid #444",
44+
borderRadius: 4,
45+
}}
2446
onChange={(event) => {
2547
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
2648
(paladin.renderable as me.Sprite).setCurrentAnimation(

packages/melonjs/CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,19 @@
4747
- **`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`).
4848
- **`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.
4949
- **`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.
50+
- **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.
51+
- **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.
52+
- **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.
53+
- **`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).
54+
- **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.
55+
- **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.
56+
- **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.
57+
- **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.
58+
- **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.
59+
- **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.
60+
- **`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.
61+
- **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.
62+
- **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.
5063

5164
## [19.8.0] (melonJS 2) - _2026-06-26_
5265

packages/melonjs/src/system/event.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Vector2d } from "../math/vector2d.ts";
88
import { Draggable } from "../renderable/draggable.js";
99
import type Stage from "../state/stage.ts";
1010
import Renderer from "../video/renderer.js";
11+
import type { Texture2dSource } from "../video/texture/texture2d.ts";
1112
import { EventEmitter } from "./eventEmitter.js";
1213

1314
/**
@@ -379,11 +380,26 @@ export const GPU_TEXTURE_CACHE_RESET = "renderer.gpu.texturecachereset";
379380
* `RenderPassEncoder` (clear loadOps replace `clear()` calls in WebGPU's
380381
* declarative pass descriptors).
381382
*
382-
* Data passed: none
383+
* Data passed: the emitting renderer. Subscribers holding per-renderer
384+
* state should ignore broadcasts from other renderer instances (several
385+
* Applications can coexist on one page); a missing argument (legacy
386+
* emitters) should be treated as "mine".
383387
* @see event.on
384388
*/
385389
export const RENDER_TARGET_CHANGED = "renderer.target.changed";
386390

391+
/**
392+
* Fired when a {@link Texture2d} asset is destroyed, with its backing
393+
* drawable source. GPU-side caches keyed by source image — e.g. the lit
394+
* batcher's per-image normal-map textures — subscribe to release the GL
395+
* texture and drop their entry, so destroying a texture (a per-level
396+
* {@link NoiseTexture2d}, for instance) doesn't leak GPU memory.
397+
*
398+
* Data passed: the destroyed texture's backing canvas/image
399+
* @see event.on
400+
*/
401+
export const TEXTURE2D_DESTROYED = "texture2d.destroyed";
402+
387403
interface Events {
388404
[DOM_READY]: () => void;
389405
[BOOT]: () => void;
@@ -436,7 +452,8 @@ interface Events {
436452
[ONCONTEXT_LOST]: (renderer: Renderer) => void;
437453
[ONCONTEXT_RESTORED]: (renderer: Renderer) => void;
438454
[GPU_TEXTURE_CACHE_RESET]: () => void;
439-
[RENDER_TARGET_CHANGED]: () => void;
455+
[RENDER_TARGET_CHANGED]: (renderer?: object) => void;
456+
[TEXTURE2D_DESTROYED]: (source: Texture2dSource) => void;
440457
}
441458

442459
const eventEmitter = new EventEmitter<Events>();

packages/melonjs/src/video/texture/atlas.js

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,8 @@ export class TextureAtlas extends Texture2d {
250250
this.activeAtlas = this.atlases.keys().next().value;
251251
}
252252

253-
// if format not recognized
254-
if (this.atlases.length === 0) {
253+
// if format not recognized (`atlases` is a Map — `.size`, not `.length`)
254+
if (this.atlases.size === 0) {
255255
throw new Error("texture atlas format not supported");
256256
}
257257

@@ -368,8 +368,11 @@ export class TextureAtlas extends Texture2d {
368368
addRegion(name, x, y, w, h) {
369369
const source = this.getTexture();
370370
const atlas = this.getAtlas();
371-
const dw = source.width;
372-
const dh = source.height;
371+
// an unsized HTMLVideoElement reports width/height = 0 — its real
372+
// pixel dimensions are videoWidth/videoHeight (same fallback as
373+
// `uploadTexture` and `TextureCache.get`)
374+
const dw = source.width || source.videoWidth;
375+
const dh = source.height || source.videoHeight;
373376

374377
atlas[name] = {
375378
name: name,
@@ -475,8 +478,15 @@ export class TextureAtlas extends Texture2d {
475478
]);
476479
// Cache source coordinates
477480
// see https://github.com/melonjs/melonJS/issues/1281
478-
const key = `${s.x},${s.y},${w},${h}`;
479-
atlas[key] = atlas[name];
481+
// keyed by the REGION rect (x, y, width, height) — the same key shape
482+
// `getUVs(sx, sy, sw, sh)` builds for coordinate lookups. (Keying by
483+
// the texture dimensions instead made the alias unmatchable for any
484+
// sub-region AND poisoned full-image lookups whenever a frame sat at
485+
// (0,0): "0,0,texW,texH" pointed at that frame, not the whole image.)
486+
const key = `${s.x},${s.y},${sw},${sh}`;
487+
if (key !== name) {
488+
atlas[key] = atlas[name];
489+
}
480490

481491
return atlas[name].uvs;
482492
}
@@ -592,11 +602,20 @@ export class TextureAtlas extends Texture2d {
592602
// first pass: collect regions and compute max dimensions
593603
const regions = [];
594604
for (const i in names) {
595-
const name = Array.isArray(names) ? names[i] : i;
605+
const isArray = Array.isArray(names);
606+
const name = isArray ? names[i] : i;
607+
// in dictionary mode, only iterate real regions: a region's `name`
608+
// matches its dictionary key. This skips both the coordinate-alias
609+
// keys added by `addUVs` (#1281 — they'd duplicate every frame) and
610+
// non-region entries like the aseprite parser's `anims` dictionary
611+
// (whose value has no `name`, and which `getRegion` would otherwise
612+
// happily return as a NaN-sized pseudo-region).
613+
if (!isArray && (names[i] == null || names[i].name !== i)) {
614+
continue;
615+
}
596616
const region = this.getRegion(name);
597-
// skip non-region keys (e.g. "anims" added by Aseprite parser)
598617
if (region == null) {
599-
if (Array.isArray(names)) {
618+
if (isArray) {
600619
throw new Error("Texture - region for " + name + " not found");
601620
}
602621
continue;
@@ -649,7 +668,13 @@ export class TextureAtlas extends Texture2d {
649668
}
650669

651670
for (const i in names) {
652-
const name = Array.isArray(names) ? names[i] : i;
671+
const isArray = Array.isArray(names);
672+
const name = isArray ? names[i] : i;
673+
// dictionary mode: skip coordinate-alias keys and non-region
674+
// entries (see getAnimationSettings for the full rationale)
675+
if (!isArray && (names[i] == null || names[i].name !== i)) {
676+
continue;
677+
}
653678
const region = this.getRegion(name);
654679
if (region == null) {
655680
throw new Error("Texture - region for " + name + " not found");

packages/melonjs/src/video/texture/noise_texture2d.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,10 @@ class NoiseTexture2d extends Texture2d {
341341
* Release the baked canvas. The texture must not be used after destroy.
342342
*/
343343
destroy() {
344+
// broadcasts TEXTURE2D_DESTROYED with the baked canvas so the lit
345+
// batcher's normal-map cache frees its GL texture — before the
346+
// canvas reference is dropped below
347+
super.destroy();
344348
this._canvas = null;
345349
this._imageData = null;
346350
this._heights = null;

packages/melonjs/src/video/texture/parser/aseprite.js

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,38 @@ import { Vector2d } from "../../../math/vector2d.ts";
1111
export function parseAseprite(data, textureAtlas) {
1212
const atlas = {};
1313

14+
// per-frame durations in frame order, consumed by the frameTags loop
15+
// below to build per-frame animation delays
16+
const frameDurations = [];
17+
1418
const frames = data.frames;
1519
for (const name in frames) {
16-
const frame = frames[name].frame;
17-
const trimmed = !!frame.trimmed;
20+
// in aseprite's JSON export (hash and array forms alike), `trimmed`,
21+
// `spriteSourceSize`, `sourceSize`, `rotated`, `pivot` and `duration`
22+
// are SIBLINGS of the `frame` rect on each entry — same layout as
23+
// TexturePacker's (see texturepacker.js)
24+
const entry = frames[name];
25+
const frame = entry.frame;
26+
const trimmed = !!entry.trimmed;
1827

1928
let trim;
2029

2130
if (trimmed) {
2231
trim = {
23-
x: frame.spriteSourceSize.x,
24-
y: frame.spriteSourceSize.y,
25-
w: frame.spriteSourceSize.w,
26-
h: frame.spriteSourceSize.h,
32+
x: entry.spriteSourceSize.x,
33+
y: entry.spriteSourceSize.y,
34+
w: entry.spriteSourceSize.w,
35+
h: entry.spriteSourceSize.h,
2736
};
2837
}
2938

3039
let originX;
3140
let originY;
3241
// Pixel-based offset origin from the top-left of the source frame
33-
const hasTextureAnchorPoint = frame.sourceSize && frame.pivot;
42+
const hasTextureAnchorPoint = entry.sourceSize && entry.pivot;
3443
if (hasTextureAnchorPoint) {
35-
originX = frame.sourceSize.w * frame.pivot.x - (trimmed ? trim.x : 0);
36-
originY = frame.sourceSize.h * frame.pivot.y - (trimmed ? trim.y : 0);
44+
originX = entry.sourceSize.w * entry.pivot.x - (trimmed ? trim.x : 0);
45+
originY = entry.sourceSize.h * entry.pivot.y - (trimmed ? trim.y : 0);
3746
}
3847

3948
atlas[name] = {
@@ -47,26 +56,34 @@ export function parseAseprite(data, textureAtlas) {
4756
trim: trim,
4857
width: frame.w,
4958
height: frame.h,
50-
angle: frame.rotated === true ? -ETA : 0,
59+
sourceSize: entry.sourceSize || { w: frame.w, h: frame.h },
60+
angle: entry.rotated === true ? -ETA : 0,
5161
};
62+
frameDurations.push(entry.duration);
5263
textureAtlas.addUVs(atlas, name, data.meta.size.w, data.meta.size.h);
5364
}
5465

5566
const anims = {};
5667
for (const name in data.meta.frameTags) {
5768
const anim = data.meta.frameTags[name];
58-
// aseprite provide a range from [from] to [to], so build the corresponding index array
69+
// aseprite provides a [from..to] frame range plus a per-frame
70+
// `duration` (ms) on each frame — build frame objects carrying each
71+
// frame's own delay so the animation plays at its authored timing
72+
// (100 ms is aseprite's default duration, used as a safety net)
5973
const indexArray = Array.from(
6074
{ length: anim.to - anim.from + 1 },
6175
(_, i) => {
62-
return anim.from + i;
76+
const idx = anim.from + i;
77+
return {
78+
name: idx,
79+
delay:
80+
typeof frameDurations[idx] === "number" ? frameDurations[idx] : 100,
81+
};
6382
},
6483
);
6584
anims[name] = {
6685
name: anim.name,
6786
index: indexArray,
68-
// aseprite provide animation speed between frame, melonJS expect total duration for a given animation
69-
speed: 10 * (indexArray.length - 1),
7087
// only "forward" is supported for now
7188
direction: anim.direction,
7289
};

packages/melonjs/src/video/texture/parser/spritesheet.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@ export function parseSpriteSheet(data, textureAtlas) {
1313
const spacing = data.spacing || 0;
1414
const margin = data.margin || 0;
1515

16-
let width = image.width;
17-
let height = image.height;
16+
const width = image.width;
17+
const height = image.height;
1818

1919
// calculate the sprite count (line, col)
2020
const spritecount = vector2dPool.get(
2121
~~((width - margin + spacing) / (data.framewidth + spacing)),
2222
~~((height - margin + spacing) / (data.frameheight + spacing)),
2323
);
2424

25-
// verifying the texture size
25+
// verifying the texture size. The frame GRID is implicitly truncated by
26+
// the floored spritecount above; the UV divisor below must stay the
27+
// PHYSICAL image size — the GPU texture is uploaded at full size, so
28+
// dividing by a truncated size would scale/shift every frame's UVs.
2629
if (
2730
width % (data.framewidth + spacing) !== 0 ||
2831
height % (data.frameheight + spacing) !== 0
@@ -33,9 +36,6 @@ export function parseSpriteSheet(data, textureAtlas) {
3336
computed_width - width !== spacing &&
3437
computed_height - height !== spacing
3538
) {
36-
// "truncate size" if delta is different from the spacing size
37-
width = computed_width;
38-
height = computed_height;
3939
// warning message
4040
console.warn(
4141
"Spritesheet Texture for image: " +
@@ -44,10 +44,10 @@ export function parseSpriteSheet(data, textureAtlas) {
4444
(data.framewidth + spacing) +
4545
"x" +
4646
(data.frameheight + spacing) +
47-
", truncating effective size to " +
48-
width +
47+
", truncating the frame grid to " +
48+
computed_width +
4949
"x" +
50-
height,
50+
computed_height,
5151
);
5252
}
5353
}

0 commit comments

Comments
 (0)