From f0d2c6d55b1e00901477afe7964b343768211ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Mon, 31 Aug 2026 11:46:21 +0200 Subject: [PATCH] Fix TilemapGPULayer sampling outside the tile frame on flipped tiles 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 --- .../webgl/shaders/TilemapGPULayer-frag.js | 8 +++++++ .../webgl/shaders/src/TilemapGPULayer.frag | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/renderer/webgl/shaders/TilemapGPULayer-frag.js b/src/renderer/webgl/shaders/TilemapGPULayer-frag.js index 862dfd8564..24d0f08aad 100644 --- a/src/renderer/webgl/shaders/TilemapGPULayer-frag.js +++ b/src/renderer/webgl/shaders/TilemapGPULayer-frag.js @@ -142,7 +142,15 @@ module.exports = [ ' }', ' #endif', ' vec2 frameCorner = getFrameCorner(index);', + ' #ifdef FEATURE_BORDERFILTER', ' return frameCorner + tile.uv;', + ' #else', + ' return clamp(', + ' frameCorner + tile.uv,', + ' frameCorner + 0.5,', + ' frameCorner + uTileWidthHeightMarginSpacing.xy - 0.5', + ' );', + ' #endif', '}', '#pragma phaserTemplate(fragmentHeader)', 'struct Samples {', diff --git a/src/renderer/webgl/shaders/src/TilemapGPULayer.frag b/src/renderer/webgl/shaders/src/TilemapGPULayer.frag index 8430963a03..c0b4e53735 100644 --- a/src/renderer/webgl/shaders/src/TilemapGPULayer.frag +++ b/src/renderer/webgl/shaders/src/TilemapGPULayer.frag @@ -188,7 +188,29 @@ vec2 getTileTexelCoord (Tile tile) #endif vec2 frameCorner = getFrameCorner(index); + + #ifdef FEATURE_BORDERFILTER return frameCorner + tile.uv; + #else + // Keep the sample inside its own frame. + // + // `tile.uv` comes from fract(), so it spans [0, 1) - the far edge is never reached. But the + // flip flags mirror it into (0, 1]. A fragment landing exactly on a tile boundary, where + // fract() is 0, therefore gets uv 1.0 on a flipped axis, and `frameCorner + uv * tileSize` + // addresses the first texel of the next frame in the tileset. Under NEAREST that is a whole + // row or column of an unrelated tile drawn along the tile edge. + // + // Half a texel of inset is a no-op for every other fragment under NEAREST, since it cannot + // move the sample out of the texel it already lands in. + // + // The BORDERFILTER path deliberately keeps the raw coordinate: it measures the overshoot + // itself in order to blend with the neighbouring tile, and clamps separately. + return clamp( + frameCorner + tile.uv, + frameCorner + 0.5, + frameCorner + uTileWidthHeightMarginSpacing.xy - 0.5 + ); + #endif } #pragma phaserTemplate(fragmentHeader)