Fix TilemapGPULayer bleeding neighbouring tiles on flipped tiles - #7360
Open
moufmouf wants to merge 1 commit into
Open
Fix TilemapGPULayer bleeding neighbouring tiles on flipped tiles#7360moufmouf wants to merge 1 commit into
moufmouf wants to merge 1 commit into
Conversation
TilemapGPULayer can sample a texel that lies outside the tile's own frame in the
tileset, drawing a one-pixel line of an unrelated tile along tile edges:
horizontal lines for flipY, vertical for flipX.
getLayerData() builds the in-tile sampling coordinate with fract(), so `uv` spans
a half-open range and the frame's far edge is never addressed - sampling stays
inside the frame by construction. The flip flags then mirror it:
if (flipX) { uv.x = 1.0 - uv.x; }
if (flipY) { uv.y = 1.0 - uv.y; }
which turns that half-open range into a closed one. A fragment landing exactly on
a tile boundary, where fract() is 0, now yields `uv` at the far edge on the
flipped axis, and `frameCorner + tile.uv` addresses frameCorner +/- tileSize: the
first texel of the neighbouring frame in the atlas. Under NEAREST that is a whole
row or column of an unrelated tile.
Two things make it conspicuous rather than a stray pixel. A whole screen row
shares one interpolated outTexCoord, so the bleed is a full-width line; and the
tile grid is regular, so when the camera's sub-pixel offset lines tile boundaries
up with fragment centres, every boundary bleeds at once. The result is a grid of
lines over the layer that appears and disappears as the camera scrolls, which is
how this was first reported. Unflipped tiles are unaffected, since `uv` never
reaches the far edge. The CPU TilemapLayer is correct throughout: it emits one
quad per tile with per-tile UVs.
Inset the sample by half a texel, which is what the existing FEATURE_BORDERFILTER
branch already does for LINEAR filtering. Under NEAREST this cannot move a sample
out of the texel it already lands in, so it is a no-op for every fragment except
the out-of-frame one. The BORDERFILTER path is deliberately left alone: it
measures the overshoot itself in order to blend with the neighbouring tile, and
clamps separately.
Verified two ways. A shader-level harness compiles this file as shipped and
renders a synthetic layer through a quad offset half a pixel: on stock 4.2.x
flipY bleeds rows 0,32,64,... and flipX bleeds the same columns, while unflipped
tiles and the FEATURE_BORDERFILTER path pass; with this change every case passes.
Through the engine, a GPU layer of one flipped tile at zoom 1, swept over 256
sub-pixel camera offsets, bleeds on all 20 tile boundaries at scrollY 0.5 before
and on none of them after.
Regenerates the bundled frag shader.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 31, 2026
moufmouf
added a commit
to workadventure/workadventure
that referenced
this pull request
Aug 31, 2026
…dges
Maps rendered with a TilemapGPULayer show a grid of one-pixel lines every 32px
that appear and disappear as the woka moves: horizontal for some players,
vertical for others. Only the GPU path is affected, so this arrived with the
Phaser 4 migration.
The tilemap fragment shader builds the in-tile sampling coordinate with fract(),
so `uv` spans [0, 1) and the frame's far edge is never addressed. Our flip and
rotation handling then mirrors it:
if (rotQuad > 0.5) { ...rotate uv about the tile centre... }
if (flipX) { uv.x = 1.0 - uv.x; }
if (flipY) { uv.y = 1.0 - uv.y; }
which turns that half-open range into a closed one. A fragment landing exactly on
a tile boundary, where fract() is 0, then gets uv == 1.0 on the mirrored axis, and
`frameCorner + uv * tileSize` addresses the first texel of the NEXT frame in the
tileset. Under NEAREST - we run antialias: false - that is a whole row or column
of an unrelated tile drawn along the tile edge.
A whole screen row shares one interpolated texture coordinate, so the bleed is a
full-width line rather than a stray pixel; and because the tile grid is regular,
when the camera's sub-pixel offset lines tile boundaries up with fragment centres
every boundary bleeds at once. Which axis is hit depends on how the tiles in view
are flipped or rotated, which is why the same map shows horizontal lines to one
player and vertical lines to another. Unflipped, unrotated tiles are unaffected.
Inset the sample by half a texel, which is what the shader's FEATURE_BORDERFILTER
branch already does for LINEAR filtering. Under NEAREST this cannot move a sample
out of the texel it already lands in, so it is a no-op for every fragment except
the out-of-frame one. The BORDERFILTER path keeps the raw coordinate: it measures
the overshoot itself in order to blend with the neighbouring tile.
Verified with a harness that compiles the patched shader and renders a synthetic
layer through a quad offset half a pixel, so boundaries land on fragment centres.
Before: flipY and rot90 bleed rows 0,32,64,...; flipX and rot180 bleed the same
columns. After: every case clean, with the BORDERFILTER path unchanged throughout.
Submitted upstream as phaserjs/phaser#7360; drop this hunk from the patch once it
lands. Refs #6452.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
This PR (delete as applicable)
This issue happens ONLY when using
TilemapGPULayeron flipped assets. When using flipped assets,TilemapGPULayercan sample a texel that lies outside the tile's own frame in the tileset, drawing a one-pixel line of an unrelated tile along tile edges: horizontal lines forflipY, vertical ones forflipX. This does not happen if the tile is not flipped in the Tiled map.This PR fixes this bug.
I opened an example here: phaserjs/examples#411
Before (the bleeding can appear only briefly when loading the page):
(see the colored lines?)
After:
Why? (disclaimer: the explanation below is by Claude):
getLayerData()builds the in-tile sampling coordinate withfract(), souvspans a half-open range and the frame's far edge is never addressed — sampling stays inside the frame by construction. The flip flags then mirror it:which turns that half-open range into a closed one. A fragment landing exactly on a tile boundary, where
fract()is 0, now yieldsuvat the far edge on the flipped axis, andframeCorner + tile.uvaddressesframeCorner ± tileSize: the first texel of the neighbouring frame in the atlas. UnderNEARESTthat is a whole row or column of an unrelated tile.Two things make it conspicuous rather than a stray pixel. A whole screen row shares one interpolated
outTexCoord, so the bleed is a full-width line; and the tile grid is regular, so when the camera's sub-pixel offset lines tile boundaries up with fragment centres, every boundary bleeds at once. The result is a grid of lines over the layer that appears and disappears as the camera scrolls — which is how this was first reported to us, on a map where players walking around made lines flicker in and out every 32px.Unflipped tiles are unaffected, since
uvnever reaches the far edge. The CPUTilemapLayeris correct throughout: it emits one quad per tile with per-tile UVs.The fix: inset the sample by half a texel, which is what the existing
FEATURE_BORDERFILTERbranch already does forLINEARfiltering. UnderNEARESTthis cannot move a sample out of the texel it already lands in, so it is a no-op for every fragment except the out-of-frame one. TheBORDERFILTERpath is deliberately left alone: it measures the overshoot itself in order to blend with the neighbouring tile, and clamps separately.Verified two ways.
A shader-level harness compiles
TilemapGPULayer.fragas shipped (pragmas stripped) and renders a synthetic layer through a quad offset half a pixel, so boundaries land on fragment centres:Through the engine, the example below flips one tile four ways and sweeps 512 sub-pixel camera offsets: 6 bleeding rows (the
flipYquadrants) and 8 bleeding columns (theflipXquadrants) before, none of either after.Note that whether a given fragment lands exactly on a boundary depends on how the driver interpolates the varying, so the triggering sub-pixel phase is machine-specific — but the out-of-frame address is in the shader either way.