Skip to content

Fix TilemapGPULayer bleeding neighbouring tiles on flipped tiles - #7360

Open
moufmouf wants to merge 1 commit into
phaserjs:masterfrom
moufmouf:fix/tilemapgpulayer-atlas-bleed-on-flipped-tiles
Open

Fix TilemapGPULayer bleeding neighbouring tiles on flipped tiles#7360
moufmouf wants to merge 1 commit into
phaserjs:masterfrom
moufmouf:fix/tilemapgpulayer-atlas-bleed-on-flipped-tiles

Conversation

@moufmouf

@moufmouf moufmouf commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

This PR (delete as applicable)

  • Fixes a bug

This issue happens ONLY when using TilemapGPULayer on flipped assets. When using flipped assets, 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 ones for flipX. 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):

image

(see the colored lines?)

After:

image

Why? (disclaimer: the explanation below is by Claude):

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 to us, on a map where players walking around made lines flicker in and out every 32px.

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.

The fix: 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 TilemapGPULayer.frag as shipped (pragmas stripped) and renders a synthetic layer through a quad offset half a pixel, so boundaries land on fragment centres:

stock 4.2.x                             patched
=== default (NEAREST path) ===          === default (NEAREST path) ===
tile 0, no flip        pass             tile 0, no flip        pass
tile 3, flipY          FAIL             tile 3, flipY          pass
   bleeding rows: 0,32,64,96,...
tile 2, flipX          FAIL             tile 2, flipX          pass
   bleeding cols: 0,32,64,96,...
tile 3, flipX + flipY  FAIL             tile 3, flipX + flipY  pass

=== FEATURE_BORDERFILTER ===            === FEATURE_BORDERFILTER ===
all four               pass             all four               pass

Through the engine, the example below flips one tile four ways and sweeps 512 sub-pixel camera offsets: 6 bleeding rows (the flipY quadrants) and 8 bleeding columns (the flipX quadrants) 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.

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>
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>
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.

1 participant