Skip to content

fix(imagelayer): converge Canvas/WebGL output on repeat-x / repeat-y / no-repeat#1447

Merged
obiot merged 1 commit into
masterfrom
fix/imagelayer-repeat-canvas-webgl-parity
May 12, 2026
Merged

fix(imagelayer): converge Canvas/WebGL output on repeat-x / repeat-y / no-repeat#1447
obiot merged 1 commit into
masterfrom
fix/imagelayer-repeat-canvas-webgl-parity

Conversation

@obiot
Copy link
Copy Markdown
Member

@obiot obiot commented May 12, 2026

Closes #1290.

Summary

ImageLayer with repeat: "repeat-x" (or "repeat-y" or "no-repeat") produced visibly different output on Canvas vs WebGL whenever the layer's draw rectangle extended past the source image on the non-tiling axis.

  • Canvas (HTML spec): CanvasPattern with repeat-x only tiles in X. Past the source height, pixels stay transparent.
  • WebGL: gl.REPEAT on the tiling axis, gl.CLAMP_TO_EDGE on the other. Past the source the GPU samples the nearest edge pixel and stretches it — e.g. a 1-pixel red bottom row of a horizon-strip image painting hundreds of pixels of red below the strip.

Same ImageLayer({ repeat: "repeat-x" }) config, same source image, two visibly different outputs depending on which renderer video.AUTO picked.

Fix

Three lines in imagelayer.js. The draw extent is clamped to the source dimensions on any axis that isn't tiling:

-renderer.drawPattern(
-    this._pattern,
-    0, 0,
-    viewport.width * 2,
-    viewport.height * 2,
-);
+const drawW = this.repeatX ? viewport.width * 2 : width;
+const drawH = this.repeatY ? viewport.height * 2 : height;
+renderer.drawPattern(this._pattern, 0, 0, drawW, drawH);
  • The tiling axis still over-provisions with viewport × 2 — the wrap unit tiles infinitely and any overdraw is scissor-clipped, harmless.
  • The non-tiling axis stops at the source extent, so neither renderer is asked to fill past where the source has pixels — neither enters its overflow path, neither diverges.

Effectively matches Pixi's TilingSprite mental model: the rectangle is the rectangle, no separate "what to do at the edge" semantic. No renderer changes — both Canvas and WebGL converge naturally once ImageLayer stops asking them to overdraw.

Behavior table

Repeat mode Before (Canvas) Before (WebGL) After (both)
repeat tile both axes tile both axes unchanged
repeat-x tile X, transparent past source Y tile X, red edge stretched down past source Y tile X, strip exactly source.height tall
repeat-y tile Y, transparent past source X tile Y, red edge stretched right past source X tile Y, strip exactly source.width wide
no-repeat single image, transparent rest single image, edges stretched to fill single image at source.width × source.height

Test plan

  • pnpm vitest run — 3062 tests pass, 12 skipped, no new failures
  • Visual repro side-by-side on the platformer example: Canvas and WebGL2 now produce identical output (no stretched-edge wash on WebGL, no visual change on Canvas)
  • Existing games using repeat (both axes) — unchanged code path

🤖 Generated with Claude Code

… the source on the non-tiling axis

`ImageLayer.draw` asked the renderer to fill `viewport.width * 2` ×
`viewport.height * 2` regardless of repeat mode, then leaned on each
renderer's overflow behavior on the non-tiling axis. The two renderers
answered differently:

- Canvas (HTML spec): `CanvasPattern` with `repeat-x` only tiles in X.
  Past the source height, pixels stay transparent.
- WebGL: `gl.REPEAT` on the tiling axis, `gl.CLAMP_TO_EDGE` on the
  other. Past the source the GPU samples the nearest edge pixel,
  producing a visible stretch (e.g. the bottom row of a horizon-strip
  image painting hundreds of pixels of red below the strip).

For `repeat-x`, `repeat-y`, and `no-repeat` the result was the same
`ImageLayer` configuration producing visibly different output between
the two renderers. Closes #1290.

The fix clamps the draw extent to the source dimensions on any axis
that isn't tiling. The tiling axis still over-provisions with
`viewport * 2` because the wrap unit tiles infinitely and the
overdraw is harmless (scissor-clipped). The non-tiling axis stops at
the source extent, so neither renderer is asked to fill past where
the source has pixels — neither enters its overflow path, neither
diverges. Matches Pixi's `TilingSprite` mental model: the rectangle
is the rectangle, no separate "what to do at the edge" semantic.

Existing usage: visually identical on Canvas (transparent past source
→ not drawn past source; same on a transparent canvas), and removes
the stretched-edge wash on WebGL. The platformer example renders
identically in both renderers after the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 12, 2026 08:27
@obiot obiot merged commit 1225b56 into master May 12, 2026
9 checks passed
@obiot obiot deleted the fix/imagelayer-repeat-canvas-webgl-parity branch May 12, 2026 08:31
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 aims to make ImageLayer rendering consistent between Canvas and WebGL for repeat-x, repeat-y, and no-repeat, by avoiding renderer-specific “overflow past source” behavior that leads to transparent fill on Canvas and edge-pixel stretching on WebGL.

Changes:

  • Clamp ImageLayer.draw()’s pattern draw extent on any non-tiling axis (instead of always drawing viewport * 2 on both axes).
  • Add a detailed in-code comment explaining why the clamp is needed (issue #1290).
  • Document the fix in the melonjs changelog.

Reviewed changes

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

File Description
packages/melonjs/src/renderable/imagelayer.js Adjusts pattern draw rectangle sizing based on repeat mode to converge Canvas/WebGL output.
packages/melonjs/CHANGELOG.md Adds a “Fixed” entry describing the ImageLayer repeat-mode rendering convergence.

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

Comment on lines +336 to +337
const drawW = this.repeatX ? viewport.width * 2 : width;
const drawH = this.repeatY ? viewport.height * 2 : height;
- WebGL: `MaterialBatcher.uploadTexture` was using its `w` and `h` parameters (the destination quad size, not the texture's) for the `isPOT` check, which drives both the wrap-mode fallback and the `generateMipmap` gate. Visible as a `GL_INVALID_OPERATION` from `gl.generateMipmap` on WebGL 1; silent wasted work (unnecessary mipmaps, wrong `isPOT`-derived state) on WebGL 2. Texture dimensions are now derived from the source itself.
- SAT: ellipse collisions silently failed whenever the body's ancestor container had a non-zero absolute position (the typical case: `level.load` auto-centers the level container when the viewport is larger than the map, setting `container.pos` to a non-zero offset). `testEllipseEllipse` and `testPolygonEllipse` built the relative-position vector by *adding* `a.ancestor.getAbsolutePosition()` where they should have subtracted it, shifting the circle by `2 * ancestor.absPos`. The polygon/polygon path is unaffected — it builds two absolute positions and lets `isSeparatingAxis` do the subtraction. Latent because every existing SAT unit test wired the mock ancestor to `(0, 0)`, where the sign error is arithmetically invisible.
- TMX: static children of an auto-centered level container kept stale absolute bounds. `TMXTileMap.addTo` sets `container.pos` *after* adding children, so each child's cached absolute bounds (computed at `addChild` time) didn't include the centering offset. Children that moved on their own refreshed via the `pos` observer, but TMX layers, Tiled collision shapes, triggers, and decorative sprites stayed stuck at their pre-centering bounds — visible as debug overlay shapes drawn at the wrong screen position, and as broken viewport culling for anything outside the pre-centering box. `_setBounds` now walks the container subtree and refreshes absolute bounds after the position actually moves (both initial load and viewport resize).
- ImageLayer: `repeat-x` / `repeat-y` / `no-repeat` produced different visual output on Canvas vs WebGL (issue #1290). `ImageLayer.draw` was asking the renderer to fill `viewport.width * 2` × `viewport.height * 2` regardless of repeat mode, then leaning on each renderer's overflow behavior on the non-tiling axis — Canvas leaves the overflow transparent (HTML spec), WebGL stretches the bottom row / right column via `GL_CLAMP_TO_EDGE`. The draw extent is now clamped to the source dimensions on any axis that isn't tiling, so neither renderer enters its overflow path and both produce the same strip-shaped output. Matches Pixi's `TilingSprite` mental model (no `repeat-x` / `repeat-y` flags — the tile rectangle is the tile rectangle).
@obiot obiot mentioned this pull request May 12, 2026
3 tasks
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.

Canvas: drawPattern repeat-x/repeat-y doesn't match WebGL behavior

2 participants