fix(imagelayer): converge Canvas/WebGL output on repeat-x / repeat-y / no-repeat#1447
Merged
Merged
Conversation
… 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>
Contributor
There was a problem hiding this comment.
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 drawingviewport * 2on both axes). - Add a detailed in-code comment explaining why the clamp is needed (issue #1290).
- Document the fix in the
melonjschangelog.
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). |
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 #1290.
Summary
ImageLayerwithrepeat: "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.CanvasPatternwithrepeat-xonly tiles in X. Past the source height, pixels stay transparent.gl.REPEATon the tiling axis,gl.CLAMP_TO_EDGEon 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 renderervideo.AUTOpicked.Fix
Three lines in
imagelayer.js. The draw extent is clamped to the source dimensions on any axis that isn't tiling:viewport × 2— the wrap unit tiles infinitely and any overdraw is scissor-clipped, harmless.Effectively matches Pixi's
TilingSpritemental model: the rectangle is the rectangle, no separate "what to do at the edge" semantic. No renderer changes — both Canvas and WebGL converge naturally onceImageLayerstops asking them to overdraw.Behavior table
repeatrepeat-xrepeat-yno-repeatTest plan
pnpm vitest run— 3062 tests pass, 12 skipped, no new failuresrepeat(both axes) — unchanged code path🤖 Generated with Claude Code