From f7a5c91bcad20a9fcb056fcc0ef4a7b6a67d3b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Mon, 31 Aug 2026 11:52:11 +0200 Subject: [PATCH] Add flipped GPU tiles example TilemapGPULayer resolves tiles from the tileset in a fragment shader, so a flipped tile has to keep sampling inside its own frame even when a fragment lands exactly on a tile boundary. Fills a layer with one asymmetric tile flipped four ways - unflipped, flipX, flipY and both, one per quadrant - and surrounds that tile in the tileset with colours the map never uses: orange directly above it, green directly to its right. The camera creeps through sub-pixel offsets, which is when tile boundaries line up with fragment centres. Nothing on screen should ever be orange or green. The flips are set on the tiles rather than encoded in the map data on purpose: Tiled packs flips into the high bits of a GID, but ParseGID folds those three bits into `rotation` plus a single `flipped` flag, so a map file can only ever produce `tile.flipX`. Co-Authored-By: Claude Opus 5 --- .../tilemap gpu layer/flipped gpu tiles.js | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 public/src/tilemaps/tilemap gpu layer/flipped gpu tiles.js diff --git a/public/src/tilemaps/tilemap gpu layer/flipped gpu tiles.js b/public/src/tilemaps/tilemap gpu layer/flipped gpu tiles.js new file mode 100644 index 000000000..21bec5ede --- /dev/null +++ b/public/src/tilemaps/tilemap gpu layer/flipped gpu tiles.js @@ -0,0 +1,133 @@ +// TilemapGPULayer resolves a tile's pixels from the tileset in a fragment shader, so a +// flipped tile has to keep sampling inside its own frame even when a fragment lands +// exactly on a tile boundary. +// +// This example fills a layer with a single asymmetric tile and flips it differently in +// each quadrant: unflipped, flipX, flipY, and both. The tile is surrounded in the tileset +// by colours the map never uses - orange directly above it, green directly to its right - +// so a sample straying outside the frame is obvious. The camera creeps through sub-pixel +// offsets, which is when tile boundaries line up with fragment centres. +// +// Nothing on screen should ever be orange or green. +// +// Note the flips are set on the tiles rather than encoded in the map data: Tiled packs +// flips into the high bits of a GID, but ParseGID folds those three bits into `rotation` +// plus a single `flipped` flag, so a map file can only ever produce `tile.flipX`. + +const TILE = 32; +const COLS = 16; +const ROWS = 16; + +const mapData = { + width: COLS, height: ROWS, tilewidth: TILE, tileheight: TILE, + orientation: 'orthogonal', renderorder: 'right-down', infinite: false, + type: 'map', version: '1.10', tiledversion: '1.10', + layers: [ { + name: 'flipped', type: 'tilelayer', width: COLS, height: ROWS, x: 0, y: 0, + opacity: 1, visible: true, + // Every cell is local tile id 2, so GID firstgid (1) + 2. + data: new Array(COLS * ROWS).fill(3) + } ], + tilesets: [ { + name: 'fliptiles', firstgid: 1, tilewidth: TILE, tileheight: TILE, + tilecount: 4, columns: 2, image: 'fliptiles', + imagewidth: TILE * 2, imageheight: TILE * 2, margin: 0, spacing: 0 + } ] +}; + +class Example extends Phaser.Scene +{ + preload () + { + this.cache.tilemap.add('flipmap', { format: Phaser.Tilemaps.Formats.TILED_JSON, data: mapData }); + } + + create () + { + // A 2x2 tileset. Tile 2 (bottom left) is the only one the map uses: a quiet blue + // checker with a corner marker and a bar, so each flip is visibly different. Its + // neighbours in the atlas are deliberately garish, so a sample that leaves the + // frame stands out: orange sits above it, green to its right. + const g = this.add.graphics(); + + g.fillStyle(0xe08a2e, 1).fillRect(0, 0, TILE, TILE); // 0: orange, above + g.fillStyle(0xd13fb0, 1).fillRect(TILE, 0, TILE, TILE); // 1: magenta + g.fillStyle(0x3fa34d, 1).fillRect(TILE, TILE, TILE, TILE); // 3: green, right + + g.fillStyle(0x2b3f6b, 1).fillRect(0, TILE, TILE, TILE); // 2: under test + g.fillStyle(0x33497a, 1); + + for (let y = 0; y < TILE; y += 8) + { + for (let x = 0; x < TILE; x += 8) + { + if (((x + y) / 8) % 2 === 0) + { + g.fillRect(x, TILE + y, 8, 8); + } + } + } + + // Asymmetric on both axes, so all four flip combinations look different. + g.fillStyle(0x9fd0ff, 1).fillRect(3, TILE + 3, 16, 5); + g.fillStyle(0xf2f2f2, 1).fillRect(3, TILE + 3, 5, 12); + + g.generateTexture('fliptiles', TILE * 2, TILE * 2); + g.destroy(); + + const map = this.make.tilemap({ key: 'flipmap' }); + + map.addTilesetImage('fliptiles', 'fliptiles'); + + // One quadrant per flip combination. + map.forEachTile((tile) => { + tile.flipX = tile.x >= COLS / 2; + tile.flipY = tile.y >= ROWS / 2; + }); + + map.createLayer('flipped', 'fliptiles', 0, 0, true); + + const label = (x, y, text) => this.add.text(x, y, text, + { fontSize: '13px', color: '#ffffff', backgroundColor: '#000000cc', padding: 4 } + ).setScrollFactor(0); + + label(6, 6, 'no flip'); + label(COLS * TILE / 2 + 6, 6, 'flipX'); + label(6, ROWS * TILE / 2 + 6, 'flipY'); + label(COLS * TILE / 2 + 6, ROWS * TILE / 2 + 6, 'flipX + flipY'); + + label(6, ROWS * TILE - 46, + 'One tile, flipped four ways. The camera creeps through sub-pixel\n' + + 'offsets. No orange or green should ever appear: those are the\n' + + 'tile\'s neighbours in the tileset.'); + + this.phase = 0; + this.cameras.main.setScroll(0.5, 0.5); + } + + update () + { + // Creep through sub-pixel camera offsets: tile boundaries land on fragment centres + // at some of them, which is when a frame-edge sampling error would show. + this.phase = (this.phase + 1) % 512; + + const offset = Math.floor(this.phase / 16) / 32; + + this.cameras.main.setScroll(0.5 + offset, 0.5 + offset); + } +} + +const config = { + type: Phaser.WEBGL, + width: COLS * TILE, + height: ROWS * TILE, + backgroundColor: '#2d2d2d', + parent: 'phaser-example', + render: { + antialias: false, + roundPixels: false + }, + scene: Example +}; + +const game = new Phaser.Game(config);