Skip to content

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

@obiot

Description

@obiot

Description

WebGLRenderer.createPattern keys its texture cache by the source image reference. When called twice for the same image with different repeat modes, the second call deletes the first call's GPU texture before uploading the new one — leaving the first pattern handle pointing at a freed/replaced texture.

The deletion is intentional (fix for #1278, which originally leaked the prior texture). The follow-on issue is that the cache key is too coarse: image alone doesn't distinguish between patterns with different wrap modes, so the "leak fix" trampled the still-live earlier pattern.

Location: packages/melonjs/src/video/webgl/webgl_renderer.jscreatePattern(), lines 584-588:

// clean up any previous pattern texture for this image
// see https://github.com/melonjs/melonJS/issues/1278
if (this.cache.has(image)) {
    this.currentBatcher.deleteTexture2D(this.cache.get(image));
}

How to trigger

const horiz = renderer.createPattern(sky, "repeat-x");
const vert  = renderer.createPattern(sky, "repeat-y");

// `horiz` now references a deleted/replaced texture.
// Subsequent draws using `horiz` either fail GL validation or
// render the wrap mode of `vert` (the last call wins).
renderer.drawPattern(horiz, 0, 0, 800, 64);   // ← uses vert's texture
renderer.drawPattern(vert,  0, 0, 64, 600);   // ← also vert's texture (correct, but coincidentally)

Same outcome whenever two ImageLayer instances share the same image with different repeat modes:

const sky = loader.getImage(\"clouds\");
const horizonStrip = new ImageLayer({ image: sky, repeat: \"repeat-x\" });
const sideStrip    = new ImageLayer({ image: sky, repeat: \"repeat-y\" });
app.world.addChild(horizonStrip);
app.world.addChild(sideStrip);
// Whichever ImageLayer is created second wins; the first
// renders with the second's wrap mode.

Canvas mode is unaffected — CanvasRenderer.createPattern returns a fresh CanvasPattern per call and doesn't cache textures.

Why it isn't visible today

Typical melonJS usage creates one pattern per image:

  • Each ImageLayer has its own image; the constructor calls createPattern once.
  • The repeat-x / repeat-y / "repeat" config is usually picked once at level design time, not toggled at runtime.
  • Most games don't use the same source image across multiple ImageLayers with different repeat modes — they pick a different asset per layer.

The collision only surfaces in three scenarios:

  1. Two or more ImageLayers sharing one image with different repeat modes (uncommon — most parallax setups use distinct images).
  2. User code calling renderer.createPattern directly with the same image and different repeat modes (this is what surfaced the bug while building a visual repro for Canvas: drawPattern repeat-x/repeat-y doesn't match WebGL behavior #1290).
  3. Runtime mutation: changing an existing layer's repeat and re-calling createPattern while another reference to the original handle is still live.

None of those are typical use, which is why the bug has been latent since the #1278 fix landed.

Proposed fix

Either:

A. Key the cache by (image, repeat) — minimal change, retains the leak-prevention behavior of #1278 within each (image, repeat) slot:

const key = { image, repeat };  // or a composite key

B. Keep one cache entry per image but reconfigure the wrap mode in place instead of delete-and-replace — calls into MaterialBatcher.createTexture2D with the new wrap params on the existing GL texture, without re-uploading the pixels. Avoids the re-upload cost too.

Either approach preserves the no-leak invariant from #1278.

Relationship to #1281

Loosely thematic — both issues are "cache key too narrow for the data it indexes":

Different code paths, different caches, different fixes. Worth noting because the underlying design lesson is the same: cache keys must include every dimension the cache entry depends on.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions