Skip to content

fix(webgl): createPattern cache-key collision + Canvas/WebGL no-arg parity (#1448)#1488

Merged
obiot merged 10 commits into
masterfrom
fix/1448-createpattern-cache-key
Jun 4, 2026
Merged

fix(webgl): createPattern cache-key collision + Canvas/WebGL no-arg parity (#1448)#1488
obiot merged 10 commits into
masterfrom
fix/1448-createpattern-cache-key

Conversation

@obiot
Copy link
Copy Markdown
Member

@obiot obiot commented Jun 4, 2026

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:

const horiz = renderer.createPattern(sky, "repeat-x");
const vert  = renderer.createPattern(sky, "repeat-y");
renderer.drawPattern(horiz, 0, 0, 800, 64);   // ← actually sampled "repeat-y"

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 createPattern cache-key — the actual collision was in TextureCache.getUnit(texture):

// cache.js — before
getUnit(texture) {
    const source = texture.sources.get(texture.activeAtlas);
    if (!this.units.has(source)) {
        this.units.set(source, this.allocateTextureUnit());
    }
    return this.units.get(source);
}

units keyed by source alone → 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 in createPattern was 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 nested Map<source, Map<repeat, unit>>.

// cache.js — after
_unitKey(texture) {
    return {
        source: texture.sources.get(texture.activeAtlas),
        repeat: texture.repeat || "no-repeat",
    };
}

getUnit(texture) {
    const { source, repeat } = this._unitKey(texture);
    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());
    }
    return perRepeat.get(repeat);
}

Same external API (getUnit(texture) in, unit number out) — only the internal map structure changes. freeTextureUnit and peekUnit get the same treatment.

The band-aid in createPattern is removed:

  • Same-(source, repeat) re-uploads short-circuit via uploadTexture's existing boundTextures[unit] check — no leak, no allocation.
  • Different-repeat calls each get their own unit — no collision.

Side effect: Canvas/WebGL parity on the no-arg form

renderer.createPattern(image) (no repeat) used to throw a DOM TypeError under Canvas while WebGL silently defaulted to "no-repeat" via TextureAtlas's internal fallback. Both renderers now default repeat = "no-repeat" at the engine entry point. JSDoc updated to [repeat="no-repeat"].

What's NOT touched

  • Public createPattern(image, repeat) / drawPattern(...) signatures.
  • TextureAtlas shape — still exposes .image, .repeat, .sources, etc.
  • getUnit / peekUnit / freeTextureUnit external signatures (still take a texture).
  • All existing TextureAtlas users (sprites, atlases, tilemaps, lights, meshes use repeat = "no-repeat" → identical composite key → identical unit allocation).

Tests

  • New tests/createPattern_repeat_parity.spec.js (4 tests) — 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 identically on both.
  • Flipped 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.size stays flat) was actively pinning the bug in place. Kept as a focused regression guard.

Test plan

  • `pnpm test:types` clean
  • `pnpm vitest run` — 3979 / 13 skipped / 0 failed (+4 new parity tests)
  • `pnpm build` — lint + types clean

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings June 4, 2026 08:29
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 TextureCache texture-unit allocation to key by (source, repeat) instead of source alone, preventing wrap-mode collisions.
  • Default repeat to "no-repeat" in both Canvas and WebGL renderers for createPattern(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.
obiot and others added 2 commits June 4, 2026 16:40
…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>
Copilot AI review requested due to automatic review settings June 4, 2026 08:43
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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 under image, but it still only updates cache bookkeeping. When called from MaterialBatcher.deleteTexture2D(TextureAtlas), the batcher deletes/unbinds only the one GL texture for the passed-in atlas and then calls cache.delete(texture.getTexture()), which will now free additional repeat-mode units without deleting/unbinding their corresponding boundTextures[unit]. That can leave orphaned GL textures and stale boundTextures entries 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",
};
obiot and others added 2 commits June 4, 2026 16:56
…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>
Copilot AI review requested due to automatic review settings June 4, 2026 09:57
`.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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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

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);
obiot and others added 2 commits June 4, 2026 18:21
…#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>
Copilot AI review requested due to automatic review settings June 4, 2026 10:29
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

}

// 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>
@obiot obiot merged commit 3a57c2e into master Jun 4, 2026
6 checks passed
@obiot obiot deleted the fix/1448-createpattern-cache-key branch June 4, 2026 10:56
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.

WebGL: createPattern cache-key collision invalidates earlier pattern handles for the same image

2 participants