fix(webgl): createPattern cache-key collision + Canvas/WebGL no-arg parity (#1448)#1488
Merged
Conversation
…arity (#1448) Two `createPattern(image, repeat)` calls on the same source image with different repeat modes used to silently share one GL texture unit, so the second call's wrap state trampled the first call's pattern handle. Root cause was in `TextureCache.getUnit(texture)` — it keyed the units map by `source` alone. The `cache.has(image) / deleteTexture2D(...)` "leak prevention" band-aid in `createPattern` (added for #1278) made it worse by actively freeing the still-live first-pattern texture before uploading the new one. `TextureCache` now keys units by `(source, repeat)` via a nested `Map<source, Map<repeat, unit>>`. Each distinct `(source, repeat)` combination gets its own unit, so two `createPattern` calls return fully independent patterns. The `getUnit` / `peekUnit` / `freeTextureUnit` external API is unchanged — they still take a `texture` and the `repeat` is read off `texture.repeat` (default `"no-repeat"`, matching atlas.js:162's existing fallback). The band-aid in `createPattern` is gone. Same-`(source, repeat)` re-uploads now short-circuit via `uploadTexture`'s existing `boundTextures[unit]` check (no leak, no allocation), and different-`repeat` calls each get their own unit (no collision). Invisible to every existing TextureAtlas user — sprites, atlases, tilemaps, lights, meshes all use the default `"no-repeat"`, so their composite key is `(source, "no-repeat")` and they hit the same unit they had before. Side-effect tweak — Canvas/WebGL parity on the no-arg call form. `renderer.createPattern(image)` (no `repeat`) used to throw a DOM `TypeError` under Canvas because `CanvasRenderingContext2D.create Pattern` requires the second argument, while WebGL silently defaulted to `"no-repeat"` via TextureAtlas's internal fallback. Both renderers now default `repeat` to `"no-repeat"` at the engine entry point, and the JSDoc reflects the optional shape (`[repeat="no-repeat"]`). Tests: - New `tests/createPattern_repeat_parity.spec.js` runs the same scenario under each renderer with explicit `video.init(..., { renderer: video.<MODE> })`; asserts user-visible invariants (distinct handles, `usedUnits.size` grows by 1 per call on WebGL, no-arg form tolerated on both). - Existing `texture.spec.js` "should clean up previous pattern when repeat mode changes" was pinning the buggy delete-and-replace behaviour in place. Renamed + flipped to assert the fix shape (`usedUnits.size` grows by 1 across different repeat modes); kept as a focused regression guard. Closes #1448. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a WebGL texture-unit collision affecting createPattern(image, repeat) when the same source image is used with multiple repeat modes, and aligns Canvas/WebGL behavior for the no-argument createPattern(image) form.
Changes:
- Update
TextureCachetexture-unit allocation to key by(source, repeat)instead ofsourcealone, preventing wrap-mode collisions. - Default
repeatto"no-repeat"in both Canvas and WebGL renderers forcreatePattern(image, repeat?), matching behavior across renderers. - Add/adjust regression tests to cover repeat-mode independence and no-arg parity; document the fix in the changelog.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/melonjs/src/video/texture/cache.js | Switch texture-unit mapping to nested (source → repeat → unit) allocation. |
| packages/melonjs/src/video/webgl/webgl_renderer.js | Default repeat param and remove prior delete-and-replace cleanup behavior. |
| packages/melonjs/src/video/canvas/canvas_renderer.js | Default repeat param to avoid DOM TypeError and ensure parity. |
| packages/melonjs/tests/texture.spec.js | Update regression expectation for unit allocation across repeat modes. |
| packages/melonjs/tests/createPattern_repeat_parity.spec.js | Add parity/regression coverage for repeat collisions and no-arg behavior. |
| packages/melonjs/CHANGELOG.md | Document the collision fix and the Canvas/WebGL parity change. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
99
to
110
| freeTextureUnit(texture) { | ||
| const source = texture.sources.get(texture.activeAtlas); | ||
| const unit = this.units.get(source); | ||
| const { source, repeat } = this._unitKey(texture); | ||
| const perRepeat = this.units.get(source); | ||
| const unit = perRepeat?.get(repeat); | ||
| // was a texture unit allocated ? | ||
| if (typeof unit !== "undefined") { | ||
| this.usedUnits.delete(unit); | ||
| this.units.delete(source); | ||
| perRepeat.delete(repeat); | ||
| if (perRepeat.size === 0) { | ||
| this.units.delete(source); | ||
| } | ||
| } |
Comment on lines
+20
to
+27
| // `units` keys each (source, repeat-mode) pair to a distinct GL | ||
| // texture unit. Keying by source alone (pre-19.7.0) collided | ||
| // when the same image was used as both a sprite/atlas AND a | ||
| // pattern, or as multiple patterns with different repeat modes — | ||
| // see https://github.com/melonjs/melonJS/issues/1448. The nested | ||
| // Map keeps the outer key the source object (preserves the | ||
| // implicit "GC the source → drop the units" hygiene) and adds | ||
| // the wrap mode as the inner discriminator. |
Comment on lines
+14
to
+19
| * Tests below run the same `createPattern(image, "repeat-x") + | ||
| * createPattern(image, "repeat-y")` scenario under each renderer with | ||
| * forced `video.init(..., { renderer: video.<MODE> })`, then assert the | ||
| * same user-visible invariants in both blocks. Today the Canvas block | ||
| * passes and the WebGL block fails; after the fix both pass and the | ||
| * behaviour matches. |
…1448) Three review nits from Copilot, one a real follow-on bug: 1. **TextureCache.delete(image) leaked units for non-first repeats** — substantive find. With the new `(source, repeat)` keying, an image can have N atlases registered under one cache bucket. The old delete path called `freeTextureUnit` only on `cache.get(image)[0]`, then ran `ArrayMultimap.delete(image)` which wiped the entire bucket — so every repeat past the first had its unit stranded in `usedUnits` forever. `delete()` now iterates the full multimap bucket and frees each unit before the bucket wipe. Regression test added that allocates two distinct `(source, repeat)` units, calls `cache.delete(image)`, and asserts both units come back. 2. **GC-hygiene comment was wrong.** `Map` keys are strong references; the cache's `(source, repeat)` map doesn't get freed when the source goes out of scope user-side. Reworded the `units`-field comment to describe the actual eviction model (explicit `delete(image)` / `clear()`). 3. **Spec file header was stale.** Said "Today the Canvas block passes and the WebGL block fails" — true while the fix was a WIP, wrong now that the fix is applied. Past-tense'd it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…semantics (#1448) Five new tests in `texture.spec.js > TextureCache` pinning behaviour the #1448 fix introduced but that nothing yet verified: - `peekUnit` lifecycle: returns -1 before `getUnit`, the allocated unit after, and -1 again after `freeTextureUnit`. - `getUnit` distinctness: distinct `(source, repeat)` pairs from the same source get distinct units. - `getUnit` idempotence: same `(source, repeat)` returns the same unit on repeat calls. - Inner-Map cleanup: freeing the last repeat under a source removes the source entry from the outer `units` Map (otherwise the map leaks empty inner Maps as users churn through repeat combinations). - Partial-free hygiene: freeing one repeat keeps the source entry alive for any remaining repeats. A small `makeFakeTexture(source, repeat)` helper builds the minimal TextureAtlas-shaped stub the three cache methods require (`sources` Map + `activeAtlas` + `repeat`) — keeps the tests focused on the cache contract rather than dragging in real atlas construction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five new tests in `createPattern_repeat_parity.spec.js` that try to break the new cache structure: - **Sprite + pattern on the same image.** The real-world scenario that drove #1448 — a Sprite's atlas (`cache.get(image)`, default no-repeat) and a `createPattern(image, "repeat-x")` over the same source. Pre-fix they shared a unit, so the sprite's draws silently used the pattern's wrap mode. Test pins that both retain their own units and that the sprite's lookup still resolves to its original unit after the pattern allocates. - **Unit exhaustion under multi-repeat pressure.** Squeezes `max_size` to 2, then asks for 4 distinct (source, repeat) units. Forces `allocateTextureUnit`'s exhaustion path (which calls `units.clear()` and wipes the nested map). Verifies every call returns a defined unit and the cache stays usable for subsequent `getUnit` calls. - **Mutating texture.repeat after getUnit reroutes lookups.** Pins the contract that `getUnit` / `peekUnit` re-read `texture.repeat` every call. A consumer mutating `.repeat` on a live TextureAtlas switches the lookup to a different `(source, repeat)` slot; reverting the field brings the original unit back. Intentional behaviour, but without a test nothing would catch a future "let's cache the unit on the texture" change. - **cache.clear() mid-pattern-lifecycle wipes every entry.** `clear()` between two createPattern calls must leave both the outer `units` map and `usedUnits` empty; a fresh createPattern after must allocate from scratch. Catches any leftover nested- Map state. - **freeTextureUnit on a never-allocated texture is a silent no-op.** Guards the new `perRepeat?.get(repeat)` chain. Both the never- allocated and the double-free case must no-op silently rather than throw or corrupt `usedUnits`. Test count: 4003 total (3990 passing, 13 silent-skipped). Up +5 from the previous adversarial-pass-light state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
packages/melonjs/src/video/texture/cache.js:219
TextureCache.delete(image)now frees texture units for every cached atlas underimage, but it still only updates cache bookkeeping. When called fromMaterialBatcher.deleteTexture2D(TextureAtlas), the batcher deletes/unbinds only the one GL texture for the passed-in atlas and then callscache.delete(texture.getTexture()), which will now free additional repeat-mode units without deleting/unbinding their correspondingboundTextures[unit]. That can leave orphaned GL textures and staleboundTexturesentries for freed units (later re-use can bind the wrong texture or skip upload).
delete(image) {
if (this.cache.has(image)) {
// Free every atlas registered under this image, not just the
// first one. Post-#1448 (units keyed by (source, repeat))
// 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.
for (const texture of this.cache.get(image)) {
this.freeTextureUnit(texture);
}
this.cache.delete(image);
}
Comment on lines
+45
to
+49
| _unitKey(texture) { | ||
| return { | ||
| source: texture.sources.get(texture.activeAtlas), | ||
| repeat: texture.repeat || "no-repeat", | ||
| }; |
…ying (#1448) Pushing harder against the new structure surfaced one real finding (documented in test #4) and four contract pins for behaviour the single-shot tests didn't exercise: 1. **Same `(source, repeat)` from two createPattern calls share one unit.** Two distinct TextureAtlas handles, one GL unit — freeing either drops the unit, the other's `peekUnit` then returns -1. Pre-fix AND post-fix this is the inherent contract of unit-per- `(source, repeat)`; pinning prevents a future "let's give each wrapper its own unit" refactor from silently changing the sharing semantics. 2. **`resetUnitAssignments` wipes units but leaves the atlas multimap intact.** After the call, `peekUnit` returns -1 on every previously-cached atlas, BUT `cache.has(image)` stays true and `getUnit` re-allocates cleanly. End-to-end: callers (typically `uploadTexture` post-`GPU_TEXTURE_CACHE_RESET`) keep working without any manual cleanup. 3. **`usedUnits.size` matches the sum of inner-Map sizes in `units`.** Invariant test across `createPattern` + `freeTexture Unit` + `delete(image)` mixed ops. Any drift means a unit leaked into `usedUnits` without a corresponding `units` entry (or vice versa) — a class of bug the single-call tests can't catch. 4. **`cache.get(image, {framewidth, frameheight})` with multiple atlases per image.** Surfaced a real subtlety while writing: the framewidth/frameheight refinement loop compares `_atlas.width` / `_atlas.height` (top-level properties), but pattern atlases produced by `createAtlas` store their dimensions under `meta.size`, not top-level — so for pattern-only entries the refinement matches nothing and the `cache.get(image)[0]` fallback wins. Test pins the no-crash + defined-entry contract and documents the structural quirk for future readers. 5. **`freeTextureUnit` on a texture whose `sources` Map is empty silently no-ops.** Defensive guard around the `_unitKey` helper + `perRepeat?.get(repeat)` chain — the `Map<undefined, perRepeat>` lookup short-circuits cleanly without throwing or corrupting `usedUnits`. `peekUnit` likewise returns -1. Net for this PR: 4008 total tests (3995 passing), up +5 from 1ef04ce's adversarial-pass-light state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…w callbacks (#1448) CI's `pnpm build` lint pass enforces `arrow-body-style: always` (block body required); the IDE-side `pnpm lint` doesn't. Six new arrow callbacks in the two test files I added across this PR were firing the rule at the build gate. `eslint --fix` auto-converted them, but the result was the ugly single-line `(args) => {return X}` form — kept the build green but reads badly. Tidied by hand: - `texture.spec.js` `makeFakeTexture` arrow → a named `function` declaration (clearer intent for a helper, and reads more naturally in a multi-line return). - Four `expect(() => {return cache.X(tex)}).not.toThrow()` calls → `expect(() => { cache.X(tex); }).not.toThrow()` — drop the spurious `return` (we don't care about the return value of the side-effect call, just that it doesn't throw). Per project rule (`memory/feedback_no_lint_skip.md`): run `eslint --fix` and address style issues directly rather than bypassing the build's lint gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`.1448-repro.html` + `.1448-screenshot.mjs` were a local-only visual verification harness for the #1448 fix (an HTML page served via `http-server` rendering two `createPattern` calls over one source, plus a Playwright screenshot script). Useful for one-shot manual verification but not for the PR — got swept into 6ab7593's `git add -A` by mistake.
Comment on lines
+45
to
+50
| _unitKey(texture) { | ||
| return { | ||
| source: texture.sources.get(texture.activeAtlas), | ||
| repeat: texture.repeat || "no-repeat", | ||
| }; | ||
| } |
Comment on lines
+215
to
218
| for (const texture of this.cache.get(image)) { | ||
| this.freeTextureUnit(texture); | ||
| } | ||
| this.cache.delete(image); |
…#1448) Three real findings on commit 144de99: 1. **`MaterialBatcher.deleteTexture2D` only cleaned up the called texture's bound GL handle, not the rest of the image's repeats** — the substantive bug. Pre-#1448 it was correct (one atlas per image, one bound texture). Post-fix, `cache.delete(image)` frees every (source, repeat) unit but only the caller's `boundTextures [unit]` got `gl.deleteTexture`'d + unbound. The OTHER repeats' GL textures stayed live at units the cache now considered free — stale binds + leaked GL textures when those units got reallocated. Loop now iterates every cached atlas for the image and tears down each one's bound GL texture before `cache.delete(image)` reclaims the units. **New integration test** in `createPattern_repeat_parity.spec.js` probes `boundTextures` directly. Verified it FAILS against the reverted source (the bug repros cleanly) and PASSES with the fix. This is the test I should have written in the first pass — my earlier `cache.delete(image)` test was cache-level only and missed the cache↔batcher boundary. 2. **`_unitKey` allocated on the hot path** — `getUnit` / `peekUnit` / `freeTextureUnit` are called per-draw, per-texture. The `{source, repeat}` returned by the helper was a fresh object each call: avoidable GC pressure during steady-state rendering. Removed `_unitKey`; inlined the lookup in each method with a short comment explaining the hot-path rationale. 3. **Typo'd repeat values would leak texture units** — the cache used the raw `texture.repeat` string. A user-built repeat string like `"repat-x"` clamps to no-repeat at the GL wrap-mode mapping but pre-normalization would allocate its own (source, "repat-x") unit. Added `VALID_REPEATS` set + `normalizeRepeat()` that collapses unknowns to `"no-repeat"` (the same fallback `|| "no-repeat"` already had for missing values, just now applied to bad values too). Used in all three hot-path methods. New defensive test pins the contract: distinct typo strings share a unit with `"no-repeat"` rather than allocating their own. Net: 3997 tests passing (was 3995, +2 new tests). Build green, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original entry described the core (source, repeat) cache keying but predated the cache↔batcher boundary fix + delete-multimap-walk + typo normalization that landed in 49769f4. Folded all three into the same Fixed bullet (they share #1448 ownership) so the changelog matches what's actually in the diff. No code change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| } | ||
|
|
||
| // No band-aid texture cleanup here anymore — the cache's | ||
| // `(source, repeat)` unit keying (see TextureCache._unitKey) |
…emoved _unitKey helper Copilot caught a stale reference. The earlier draft inlined `(source, repeat)` lookups into `getUnit` / `peekUnit` / `freeTextureUnit` (perf nit), removing the `_unitKey` helper — but the `webgl_renderer.js` comment still pointed at it. Comment now names the actual methods that carry the keying logic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1448.
The bug
Two
createPattern(image, repeat)calls on the same source image with different repeat modes used to silently share one GL texture unit. The second call's wrap state trampled the first pattern's handle:Same outcome whenever two
ImageLayers shared a source image with different repeat modes.Root cause (deeper than the issue suggested)
It wasn't just the
createPatterncache-key — the actual collision was inTextureCache.getUnit(texture):unitskeyed bysourcealone → two TextureAtlas instances built from the same image (but different wrap modes) collided on the same unit, shared the same GL texture, last upload won.The
cache.has(image) / deleteTexture2D(...)band-aid increatePatternwas added for #1278's leak; it made #1448 worse by actively freeing the still-live first-pattern texture before the new upload.Fix
Key the units map by
(source, repeat)via a nestedMap<source, Map<repeat, unit>>.Same external API (
getUnit(texture)in, unit number out) — only the internal map structure changes.freeTextureUnitandpeekUnitget the same treatment.The band-aid in
createPatternis removed:(source, repeat)re-uploads short-circuit viauploadTexture's existingboundTextures[unit]check — no leak, no allocation.repeatcalls each get their own unit — no collision.Side effect: Canvas/WebGL parity on the no-arg form
renderer.createPattern(image)(norepeat) used to throw a DOMTypeErrorunder Canvas while WebGL silently defaulted to"no-repeat"via TextureAtlas's internal fallback. Both renderers now defaultrepeat = "no-repeat"at the engine entry point. JSDoc updated to[repeat="no-repeat"].What's NOT touched
createPattern(image, repeat)/drawPattern(...)signatures.TextureAtlasshape — still exposes.image,.repeat,.sources, etc.getUnit/peekUnit/freeTextureUnitexternal signatures (still take a texture).repeat = "no-repeat"→ identical composite key → identical unit allocation).Tests
tests/createPattern_repeat_parity.spec.js(4 tests) — runs the same scenario under each renderer with explicitvideo.init(..., { renderer: video.<MODE> }). Asserts user-visible invariants: distinct handles,usedUnits.sizegrows by 1 per call on WebGL, no-arg form tolerated identically on both.tests/texture.spec.js"should clean up previous pattern when repeat mode changes" → "allocates a separate texture unit per (image, repeat) pair (WebGL: createPattern cache-key collision invalidates earlier pattern handles for the same image #1448)". The old assertion (usedUnits.sizestays flat) was actively pinning the bug in place. Kept as a focused regression guard.Test plan
🤖 Generated with Claude Code