From de9a2ce009869bb426c1482aec8a20b182f5d291 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Tue, 12 May 2026 16:27:19 +0800 Subject: [PATCH] fix(imagelayer): converge Canvas/WebGL output by not overdrawing past the source on the non-tiling axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/renderable/imagelayer.js | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 96a2be5c3..aa7cf8fa2 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -13,6 +13,7 @@ - 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). ### Changed - WebGL 1: removed the unconditional `[Texture] ... is not a POT texture` warning. The engine handles NPOT correctly (clamp wrap, non-mipmapped filters). A targeted warning now fires only when `repeat: "repeat*"` is requested on an NPOT texture under WebGL 1, the one case where the user's intent is silently downgraded. diff --git a/packages/melonjs/src/renderable/imagelayer.js b/packages/melonjs/src/renderable/imagelayer.js index eed42439a..c4ce56b11 100644 --- a/packages/melonjs/src/renderable/imagelayer.js +++ b/packages/melonjs/src/renderable/imagelayer.js @@ -324,13 +324,18 @@ export default class ImageLayer extends Sprite { renderer.setMask(this.mask); } - renderer.drawPattern( - this._pattern, - 0, - 0, - viewport.width * 2, - viewport.height * 2, - ); + // Clamp the draw extent to the source dimensions on any axis we + // are NOT tiling. Asking the renderer to fill past the source on + // a non-tiling axis is what makes Canvas (transparent fill per + // HTML spec) and WebGL (GL_CLAMP_TO_EDGE stretches the edge + // pixel) diverge — Canvas leaves the overflow transparent while + // WebGL stretches the bottom row (repeat-x) or right column + // (repeat-y) to fill it. By drawing exactly the source extent on + // the non-tiling axis, neither renderer enters its overflow path + // and the two outputs converge (issue #1290). + const drawW = this.repeatX ? viewport.width * 2 : width; + const drawH = this.repeatY ? viewport.height * 2 : height; + renderer.drawPattern(this._pattern, 0, 0, drawW, drawH); } // called when the layer is removed from the game world or a container