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
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- **Animated `NineSliceSprite` collapsed to its frame size** (#1115) — a `NineSliceSprite` driven by an animation (or any per-frame texture change) had its user-specified "expanded" `width`/`height` overwritten by each frame's source dimensions, shrinking the 9-slice panel to a single frame after the first animation step. Static nine-slices were unaffected because the frame is applied only once (during construction, before the expanded size is set). `NineSliceSprite` now applies a new frame by swapping only the source sub-texture, leaving the expanded size and bounds intact. First-ever test coverage for `NineSliceSprite` (the class previously had none).
- **SVG `A`/`H`/`V` commands opening a new sub-path drew from the wrong origin** — the arc (`A`), horizontal-line (`H`) and vertical-line (`V`) parsers used the previous sub-path's last point as the current pen position instead of the point set by the preceding `M`. A circular hole authored with arcs (e.g. a donut) therefore degenerated into a spiral with a radial slit. They now track the pen position correctly. Latent since these commands were added; surfaced by the new multi-sub-path holes support (#1253).
- **2D sprites could silently vanish when using a large `pos.z` sort key** (regression since 19.7) — since the depth pipeline landed, a sprite's `depth`/`pos.z` is written into the vertex `z` and participates in clip-space. Under a 2D orthographic camera `pos.z` is only a painter's sort key (the world is CPU-sorted on it), so a large value — a `baseZ + pos.y` Y-sort, or `Container.autoDepth` on a big world — pushed the vertex past the camera far plane (`Camera2d.far`, 1e6) and the GPU clip-culled the sprite (it disappeared, or rendered incorrectly on some drivers). `Renderer.setDepth` now feeds depth into the vertex stream only under a perspective projection (`Camera3d`, which genuinely uses z for parallax/depth); orthographic cameras keep depth out of clip-space, so a sort key can never clip a sprite. `Camera3d` / mesh depth is unaffected.
- **`Mesh.textureRepeat` leaked its wrap mode to every consumer of the same image** (#1503) — the setting was applied by mutating the per-image `TextureAtlas` shared by everything drawing that image, so two meshes (or a mesh and a sprite/pattern) pointing at one image with different wrap needs were last-writer-wins: the loser silently sampled with the wrong wrap. The wrap now lives on the mesh and is threaded to the batcher at draw time — sampler state per use, the same separation modern GPU APIs enforce — and the texture-unit cache's `(source, repeat)` keying gives each wrap its own GL texture, so per-mesh wraps coexist on one image. Also fixed alongside: unloading an image now frees the texture units of **all** its wrap-mode variants — previously only the unit matching the atlas's current `repeat` field was freed, pinning the rest until a full cache reset.

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

Expand Down
49 changes: 30 additions & 19 deletions packages/melonjs/src/renderable/mesh.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export default class Mesh extends Renderable {
* @param {boolean} [settings.normalize=true] - fit the source geometry into a `[-0.5, 0.5]` unit cube before scaling, so `width`/`height` behave like a Sprite. Set `false` to keep the geometry's real-world coordinates — required when several meshes share one coordinate space (e.g. nodes of an imported glTF scene) so their relative scale and layout are preserved.
* @param {number} [settings.scale] - world-space scale (pixels per source unit) for the Camera3d path; defaults to `width`. Set this when `width`/`height` describe the renderable's world bounds (frustum culling) rather than the geometry scale — see {@link Mesh#meshScale}.
* @param {boolean} [settings.rightHanded=false] - treat the source as right-handed (Y-up, e.g. glTF) under the `Camera3d` world path. The default Y-up→Y-down bridge negates Y only (a reflection, which mirrors the scene left/right); `true` negates Y **and** Z (a rotation) so chirality is preserved and the result matches the authoring tool. See {@link Mesh#rightHanded}.
* @param {string} [settings.textureRepeat] - texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) applied to the resolved texture. Use `"repeat"` when the geometry's UVs fall outside the `[0, 1]` range and rely on the texture tiling (e.g. glTF assets, whose default sampler wrap is REPEAT) — otherwise the texture clamps to its edge texels and looks flat. Ignored for the white-pixel fallback. Note: REPEAT on a non-power-of-two texture requires WebGL 2.
* @param {string} [settings.textureRepeat] - texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) this mesh samples its texture with (per-mesh — it does not modify the shared texture, so other meshes/sprites using the same image are unaffected). Use `"repeat"` when the geometry's UVs fall outside the `[0, 1]` range and rely on the texture tiling (e.g. glTF assets, whose default sampler wrap is REPEAT) — otherwise the texture clamps to its edge texels and looks flat. Ignored for the white-pixel fallback. Note: REPEAT on a non-power-of-two texture requires WebGL 2.
* @param {string} [settings.textureFilter] - texture magnification filter (`"nearest"` for crisp pixel-art upscaling, `"linear"` for smooth) applied to the resolved texture. Omit to keep the renderer's global `antiAlias` default. WebGL only (ignored by the Canvas renderer).
* @param {number} [settings.alphaCutoff=0] - alpha cutout threshold. Fragments whose final alpha is below this value are discarded (hard-edged cutout — foliage, fences, decals — with no blending or sorting). `0` disables the cutout. Set automatically by the glTF loader from a material's `alphaMode: "MASK"`. WebGL mesh path only.
* @param {number[]|Float32Array} [settings.emissive] - emissive (self-illumination) color `[r, g, b]` (0..1, may exceed 1 for HDR glow) added on top of the lit/unlit color so the surface glows regardless of scene lights (neon, lava, screens). Omit / all-zero for no emission. Set automatically by the glTF loader (`emissiveFactor`) and OBJ loader (MTL `Ke`). WebGL mesh path only.
Expand Down Expand Up @@ -509,28 +509,39 @@ export default class Mesh extends Renderable {
settings.frameheight,
);

// Optional texture wrap mode. Some assets author UVs outside the
// `[0, 1]` range and rely on the sampler repeating the texture (this is
// the glTF default sampler behavior); the mesh would otherwise clamp to
// the edge texels and look flat / untextured. Applied only to a real
// texture — never the shared white-pixel fallback, which is global and
// must stay `"no-repeat"`. One of `"repeat"` / `"repeat-x"` /
// `"repeat-y"` / `"no-repeat"`.
//
// NOTE: `cache.get(image)` returns a TextureAtlas shared per source
// image, so this sets the wrap mode IMAGE-GLOBALLY — every consumer of
// the same image object samples with this wrap. Harmless for glTF (each
// asset decodes its own image objects), but don't point two meshes that
// need different wrap modes at the same image. (Tracked in #1503, to be
// fixed with the #1410 TextureCache refactor.)
if (hasRealTexture && typeof settings.textureRepeat === "string") {
this.texture.repeat = settings.textureRepeat;
}
/**
* Per-mesh texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"`
* / `"no-repeat"`), or `undefined` to sample with the texture's own
* wrap. Some assets author UVs outside the `[0, 1]` range and rely on
* the sampler repeating the texture (this is the glTF default sampler
* behavior); the mesh would otherwise clamp to the edge texels and
* look flat / untextured.
*
* Kept on the mesh and threaded to the batcher at draw time — sampler
* state per use, like a GL sampler object — so it never mutates the
* per-image `TextureAtlas` shared with every other consumer of the
* same image (#1503). Two meshes (or a mesh and a sprite) can point
* at one image with different wrap modes; the texture cache keys GL
* units by `(source, repeat)` so each wrap gets its own GL texture.
* Applied only to a real texture — never the shared white-pixel
* fallback, which is global and must stay `"no-repeat"`.
* @type {string|undefined}
*/
this.textureRepeat =
hasRealTexture && typeof settings.textureRepeat === "string"
? settings.textureRepeat
: undefined;

// Optional texture magnification filter (`"nearest"` for crisp pixel-art
// upscaling, `"linear"` for smooth). When omitted the texture keeps the
// renderer's global `antiAlias` default. WebGL only — the Canvas renderer
// ignores it. Same image-global caveat as `textureRepeat` above (#1503).
// ignores it.
//
// NOTE: unlike `textureRepeat` above, this still mutates the shared
// per-image atlas — the texture-unit cache doesn't discriminate by
// filter, so a true per-mesh filter needs the unit-key change planned
// with the #1410 TextureCache refactor. Two consumers of one image
// wanting different filters is last-writer-wins until then.
if (
hasRealTexture &&
typeof settings.textureFilter === "string" &&
Expand Down
84 changes: 70 additions & 14 deletions packages/melonjs/src/video/texture/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,13 @@ class TextureCache {
/**
* @ignore
*
* Hot-path note: `getUnit` / `peekUnit` / `freeTextureUnit` are
* called per-texture per-draw, so the `(source, repeat)` lookup is
* inlined here rather than going through a helper that allocates a
* `{source, repeat}` object on every call.
* Frees the single `(source, repeat)` unit matching the texture's
* current `repeat` field — deliberately granular, so freeing one live
* consumer (e.g. one of two patterns with different repeats over the
* same image) never evicts the other's unit. When a SOURCE goes away
* entirely, use {@link freeAllUnits} instead — a source can hold units
* under several repeat modes (per-mesh `textureRepeat` overrides,
* #1503) that this granular free would leave pinned.
*/
freeTextureUnit(texture) {
const source = texture.sources.get(texture.activeAtlas);
Expand All @@ -157,30 +160,78 @@ class TextureCache {

/**
* @ignore
*
* Free every texture unit allocated for the texture's source, across
* ALL repeat modes. A single source can hold several `(source, repeat)`
* units — patterns with different repeats over one image, or meshes
* sampling one image with per-mesh `textureRepeat` overrides (#1503) —
* and the source going away invalidates all of them at once.
*/
getUnit(texture) {
freeAllUnits(texture) {
const source = texture.sources.get(texture.activeAtlas);
const repeat = normalizeRepeat(texture.repeat);
const perRepeat = this.units.get(source);
if (typeof perRepeat !== "undefined") {
for (const unit of perRepeat.values()) {
this.usedUnits.delete(unit);
}
this.units.delete(source);
}
}

/**
* @ignore
* @param {string} [repeat] - overrides the texture's own `repeat` for the
* unit lookup — sampler state per use (a mesh's `textureRepeat`), so one
* source can be sampled with several wrap modes without mutating the
* shared atlas (#1503). Omit to use `texture.repeat`.
*
* Hot-path note: `getUnit` / `peekUnit` are called per-texture per-draw,
* so the `(source, repeat)` lookup is inlined here rather than going
* through a helper that allocates a `{source, repeat}` object per call.
*/
getUnit(texture, repeat) {
const source = texture.sources.get(texture.activeAtlas);
const wrap = normalizeRepeat(
typeof repeat === "string" ? repeat : texture.repeat,
);
let perRepeat = this.units.get(source);
if (perRepeat === undefined) {
perRepeat = new Map();
this.units.set(source, perRepeat);
}
if (!perRepeat.has(repeat)) {
perRepeat.set(repeat, this.allocateTextureUnit());
if (!perRepeat.has(wrap)) {
perRepeat.set(wrap, this.allocateTextureUnit());
}
return perRepeat.get(repeat);
return perRepeat.get(wrap);
}

/**
* @ignore
* return every texture unit allocated for the given texture's source,
* across ALL repeat modes — the unload-time counterpart of the per-use
* `getUnit(texture, repeat)` override (#1503): a single source can hold
* one unit per wrap mode it was sampled with, and per-repeat `peekUnit`
* lookups would only ever find the one matching the texture's current
* `repeat` field. Not a hot path (allocates the result array).
*/
peekAllUnits(texture) {
const source = texture.sources.get(texture.activeAtlas);
const perRepeat = this.units.get(source);
return typeof perRepeat !== "undefined" ? [...perRepeat.values()] : [];
}

/**
* @ignore
* return the texture unit for the given texture, or -1 if not allocated
* @param {string} [repeat] - same per-use wrap override as {@link getUnit}
*/
peekUnit(texture) {
peekUnit(texture, repeat) {
const source = texture.sources.get(texture.activeAtlas);
const repeat = normalizeRepeat(texture.repeat);
const wrap = normalizeRepeat(
typeof repeat === "string" ? repeat : texture.repeat,
);
const perRepeat = this.units.get(source);
return perRepeat?.has(repeat) ? perRepeat.get(repeat) : -1;
return perRepeat?.has(wrap) ? perRepeat.get(wrap) : -1;
}

/**
Expand Down Expand Up @@ -254,9 +305,14 @@ class TextureCache {
// multiple atlases can coexist for one image — freeing only
// `cache.get(image)[0]` would leak the remaining repeats'
// texture units after `this.cache.delete(image)` wipes the
// entire multimap bucket.
// entire multimap bucket. Deleting an image means the SOURCE
// is going away, so sweep ALL repeat modes per atlas
// (`freeAllUnits`) — one atlas can hold several per-repeat
// units via per-mesh `textureRepeat` overrides (#1503), which
// the granular `freeTextureUnit` (keyed on the atlas's current
// `repeat` field) would leave pinned in `units`/`usedUnits`.
for (const texture of this.cache.get(image)) {
this.freeTextureUnit(texture);
this.freeAllUnits(texture);
}
this.cache.delete(image);
}
Expand Down
31 changes: 19 additions & 12 deletions packages/melonjs/src/video/webgl/batchers/material_batcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,19 +265,20 @@ export class MaterialBatcher extends Batcher {
deleteTexture2D(texture) {
if (typeof texture.getTexture === "function") {
// Iterate every atlas registered under this image — post-#1448,
// the multimap can hold multiple atlases per image (one per
// repeat mode), each bound to its own GL texture in
// `boundTextures`. Without this loop the OTHER repeats' GL
// textures would be orphaned: `cache.delete(image)` frees
// every (source, repeat) unit, but only THIS texture's
// `boundTextures[unit]` would be deleted+unbound, leaving
// stale binds on the freed units until something overwrites.
// the multimap can hold multiple atlases per image — and, per
// atlas, EVERY (source, repeat) unit: a single atlas can own
// several per-repeat units via the per-use wrap override
// (meshes' `textureRepeat`, #1503), which a per-repeat
// `peekUnit` lookup (keyed on the atlas's current `repeat`
// field) would miss. `cache.delete(image)` below frees all of
// those units; any GL texture left behind in
// `boundTextures[unit]` would make a later allocation of the
// same unit look "already uploaded" and bind a stale texture.
const image = texture.getTexture();
const cache = this.renderer.cache;
if (cache.has(image)) {
for (const atlas of cache.cache.get(image)) {
const unit = cache.peekUnit(atlas);
if (unit !== -1) {
for (const unit of cache.peekAllUnits(atlas)) {
const texture2D = this.boundTextures[unit];
if (typeof texture2D !== "undefined") {
this.gl.deleteTexture(texture2D);
Expand Down Expand Up @@ -357,9 +358,15 @@ export class MaterialBatcher extends Batcher {
* @param {number} [h] - same as `w`.
* @param {boolean} [force=false]
* @param {boolean} [flush=true]
* @param {string} [repeat] - per-use wrap-mode override (a mesh's
* `textureRepeat`, #1503) — sampled with this wrap without mutating
* the shared atlas's `repeat`. The texture-unit cache keys by
* `(source, repeat)`, so each wrap gets its own unit + GL texture.
* Omit to use `texture.repeat`.
*/
uploadTexture(texture, w, h, force = false, flush = true) {
const unit = this.renderer.cache.getUnit(texture);
uploadTexture(texture, w, h, force = false, flush = true, repeat) {
const wrap = typeof repeat === "string" ? repeat : texture.repeat;
const unit = this.renderer.cache.getUnit(texture, wrap);
const texture2D = this.boundTextures[unit];
Comment thread
obiot marked this conversation as resolved.

if (typeof texture2D === "undefined" || force) {
Expand Down Expand Up @@ -392,7 +399,7 @@ export class MaterialBatcher extends Batcher {
unit,
source,
filter,
texture.repeat,
wrap,
texW,
texH,
texture.premultipliedAlpha,
Expand Down
15 changes: 13 additions & 2 deletions packages/melonjs/src/video/webgl/batchers/mesh_batcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,19 @@ export default class MeshBatcher extends MaterialBatcher {
const indices = mesh.indices;
const vertexColors = mesh.vertexColors;

// upload and activate the texture
const unit = this.uploadTexture(mesh.texture);
// upload and activate the texture. The mesh's own `textureRepeat`
// (when set) is threaded through as a per-use wrap override — sampler
// state per mesh, never a mutation of the shared per-image atlas
// (#1503). The unit cache keys by `(source, repeat)`, so meshes with
// different wraps over one image coexist on distinct GL textures.
const unit = this.uploadTexture(
mesh.texture,
undefined,
undefined,
false,
true,
mesh.textureRepeat,
);
if (unit !== this.currentSamplerUnit) {
this.currentShader.setUniform("uSampler", unit);
this.currentSamplerUnit = unit;
Expand Down
9 changes: 6 additions & 3 deletions packages/melonjs/tests/gltf_model.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,12 @@ describe("GLTFModel", () => {

it("forwards the glTF texture wrap mode onto each part mesh", () => {
const mesh = childOf(makeModel());
// PRIM() carries textureRepeat:"repeat" + a real texture → the atlas
// must end up REPEAT-wrapped (tiling UVs sample correctly vs clamping)
expect(mesh.texture.repeat).toBe("repeat");
// PRIM() carries textureRepeat:"repeat" + a real texture → the part
// mesh must sample REPEAT-wrapped (tiling UVs vs clamping). The wrap
// is per-mesh (threaded to the batcher at draw time) — the shared
// per-image atlas stays untouched (#1503).
expect(mesh.textureRepeat).toBe("repeat");
expect(mesh.texture.repeat).toBe("no-repeat");
});

it("poses to the bind/rest pose on construction (parent at origin → child at its local x)", () => {
Expand Down
Loading
Loading