Skip to content

fix(mesh): per-mesh textureRepeat — stop mutating the shared per-image TextureAtlas#1537

Merged
obiot merged 2 commits into
masterfrom
fix/mesh-texture-repeat
Jul 4, 2026
Merged

fix(mesh): per-mesh textureRepeat — stop mutating the shared per-image TextureAtlas#1537
obiot merged 2 commits into
masterfrom
fix/mesh-texture-repeat

Conversation

@obiot

@obiot obiot commented Jul 3, 2026

Copy link
Copy Markdown
Member

Closes #1503.

The bug

Mesh applied its textureRepeat setting by mutating this.texture.repeat — and this.texture is the TextureAtlas shared per source image (renderer.cache.get(image)). The wrap mode therefore leaked image-globally: two meshes (or a mesh and a sprite/pattern) pointing at the same image with different wrap needs were last-writer-wins, and the loser silently sampled with the wrong wrap. Preventative today (glTF, the only in-tree textureRepeat producer, decodes private per-asset images), but unguarded shared-state mutation waiting to bite.

The fix (option A from the ticket)

The wrap now lives on the mesh (mesh.textureRepeat) and is threaded through MeshBatcher.addMeshuploadTextureTextureCache.getUnit at draw time — sampler state per use, the same texture/sampler separation modern GPU APIs enforce (GL sampler objects, GPUSampler; Godot 4 made the same per-use move for texture_repeat). The heavy lifting already existed: the unit cache keys GL textures by (source, repeat) since #1448, so per-mesh wraps coexist on one image with distinct GL textures. The shared atlas is never written.

Also fixed alongside: TextureCache.delete(image) now sweeps the units of all the source's wrap-mode variants (new freeAllUnits helper). The granular freeTextureUnit only freed the unit matching the atlas's current repeat field, pinning the other variants in units/usedUnits until a full cache reset. freeTextureUnit itself deliberately stays granular — freeing one live consumer (e.g. one of two patterns over the same image) must not evict the other's unit, and tests/texture.spec.js guards exactly that.

Out of scope, left for #1410: textureFilter has the same shared-atlas mutation, but a true per-mesh filter needs the unit-cache key to discriminate by filter — noted in a code comment.

Tests — written failing-first

tests/mesh-texture-repeat.spec.js (4 tests) was committed red against the pre-fix code (all 4 failed) and is green with the fix:

  1. constructing a Mesh with textureRepeat leaves the shared atlas untouched
  2. two meshes sharing one image each draw with their own wrap (distinct (source, repeat) units and both TEXTURE_WRAP_S values reach the GL sampler — pre-fix, only the last constructor's wrap was ever uploaded)
  3. pixel readback: on one shared image in one frame, the "repeat" mesh tiles (wrapped UV lands red) while the "no-repeat" mesh clamps (edge texel blue)
  4. cache.delete(image) frees every per-repeat unit (pre-fix leak)

Two existing tests that asserted the old mutation behavior (mesh.spec.js, gltf_model.spec.js) were updated to assert the per-mesh model, and the white-pixel adversarial test now also checks the override is dropped for the fallback.

Full suite: 4569 passed / 15 skipped / 0 failed. eslint + biome + build clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A

…e TextureAtlas

Mesh applied its textureRepeat setting by writing to this.texture.repeat,
but that TextureAtlas is shared per source image (renderer.cache.get), so
the wrap mode leaked image-globally: two meshes (or a mesh and a
sprite/pattern) using one image with different wrap needs were
last-writer-wins, and the loser silently sampled with the wrong wrap.

The wrap now lives on the mesh (mesh.textureRepeat) and is threaded
through MeshBatcher.addMesh → uploadTexture → TextureCache.getUnit at
draw time — sampler state per use, the separation modern GPU APIs
(sampler objects / GPUSampler) enforce. The unit cache's (source, repeat)
keying (#1448) already gives each wrap its own GL texture, so per-mesh
wraps coexist on a single image.

Also fixed alongside: TextureCache.delete(image) now sweeps the texture
units of ALL the source's wrap-mode variants (new freeAllUnits helper) —
the granular freeTextureUnit only freed the unit matching the atlas's
current repeat field, pinning the rest until a full cache reset.
freeTextureUnit itself stays granular so freeing one live consumer never
evicts another's unit.

New tests/mesh-texture-repeat.spec.js (4 tests) written failing-first
against the pre-fix code: constructor mutation, per-mesh wrap at draw
time (unit map + GL sampler params), pixel-level repeat-vs-clamp readback
on one shared image, and the delete-time unit sweep.

Closes #1503

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A
Copilot AI review requested due to automatic review settings July 3, 2026 05:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes #1503 by making Mesh.textureRepeat a per-mesh sampler override (threaded through MeshBatcher.addMeshuploadTextureTextureCache.getUnit) instead of mutating the shared per-image TextureAtlas.repeat, preventing wrap-mode leakage across consumers sharing the same image.

Changes:

  • Introduce per-use wrap override plumbing: Mesh.textureRepeatMaterialBatcher.uploadTexture(..., repeat)TextureCache.getUnit(texture, repeat).
  • Add TextureCache.freeAllUnits and update cache.delete(image) to free units across all repeat variants for a source.
  • Add/adjust tests to validate shared-atlas immutability, per-mesh wrap correctness (including GL state + pixel readback), and cache cleanup.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/melonjs/src/renderable/mesh.js Stores wrap override on the mesh (textureRepeat) instead of mutating the shared atlas.
packages/melonjs/src/video/webgl/batchers/mesh_batcher.js Passes mesh.textureRepeat through to uploadTexture for per-draw sampler state.
packages/melonjs/src/video/webgl/batchers/material_batcher.js Extends uploadTexture to accept a per-use repeat override and keys unit lookup by (source, repeat).
packages/melonjs/src/video/texture/cache.js Adds per-use repeat override to getUnit/peekUnit and introduces freeAllUnits used by delete(image).
packages/melonjs/tests/mesh-texture-repeat.spec.js New regression tests covering shared-atlas immutability, per-mesh wrap behavior, pixel readback, and cache cleanup.
packages/melonjs/tests/mesh.spec.js Updates expectations to assert wrap is per-mesh and atlas repeat remains unchanged.
packages/melonjs/tests/gltf_model.spec.js Updates expectations to assert glTF wrap is forwarded onto mesh.textureRepeat (not atlas mutation).
packages/melonjs/CHANGELOG.md Documents the fix and the improved unit cleanup behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/melonjs/src/video/webgl/batchers/material_batcher.js
Comment thread packages/melonjs/tests/mesh-texture-repeat.spec.js Outdated
Copilot caught a real hazard the per-use override introduced:
deleteTexture2D located the GL texture to delete via peekUnit(atlas) —
one unit, keyed on the atlas's current repeat field — while a single
atlas can now own several (source, repeat) units. cache.delete(image)
freed them all, but the extra variants' WebGLTextures stayed in
boundTextures; a later allocation reusing such a unit would treat the
stale texture as already-uploaded and bind the wrong texture.

deleteTexture2D now iterates cache.peekAllUnits(atlas) (new @ignore
helper returning every unit for the texture's source) and
deletes+unbinds each. Regression test added (revert-checked: fails
against the single-peek code with a stale WebGLTexture on the freed
unit). Also reworked the delete-leak test to set up its two units via
the per-use uploadTexture override instead of mutating atlas.repeat —
the anti-pattern this PR removes (second review comment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019t8kP8vp58ZrvaD2FqJU6A
@obiot obiot merged commit 5f28165 into master Jul 4, 2026
6 checks passed
@obiot obiot deleted the fix/mesh-texture-repeat branch July 4, 2026 09:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mesh.textureRepeat mutates the shared per-image TextureAtlas.repeat (wrap mode is image-global)

2 participants