From aef1eba9171c02625a5a23ddf6dc361944b0dc59 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 24 Sep 2026 07:26:46 +0200 Subject: [PATCH] Stop thin lines between tiles of a nearest tilemap at odd zooms At zoom levels that do not land quarter edges on whole screen pixels, a tilemap whose texture uses Nearest showed one-pixel lines in the wrong colour. The float uv of a pixel centre right at a quad edge could round onto the first texel of the neighbouring quarter in the sheet, which is usually a different tile. Terrain sheets showed it most, because every tile is four quads there. - Nearest now pulls the uvs 0.02 texel inside each quarter. That is far below half a texel, so every picked texel stays the same and pixel art keeps its size. - Linear keeps its half-texel inset. - The first draft used 0.01. The user raised it to 0.02 after checking the result in the browser. Tests: - ./check.sh: 156 files, 2299 tests, CHECK EXIT 0 (before 2298). - New unit test: every nearest uv sits strictly inside its quarter, by less than 0.05 texel. Two older tests now expect the inset. - Counter-check: with no inset for nearest the new test and the inset test fail. - No e2e check. The user checked the result in the browser. Co-Authored-By: Claude Opus 5.5 --- src/viewport/tilemapMesh.ts | 8 ++++++-- tests/core/tilemap.test.ts | 39 ++++++++++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/viewport/tilemapMesh.ts b/src/viewport/tilemapMesh.ts index c0524099..50adb008 100644 --- a/src/viewport/tilemapMesh.ts +++ b/src/viewport/tilemapMesh.ts @@ -52,8 +52,12 @@ export function tileMesh( const uvs = new Float32Array(positions.length); const indices = new Uint32Array(tiles.length * 4 * 6); const half = tile / 2; - const insetX = sheet.smooth ? 0.5 / sheet.textureWidth : 0; - const insetY = sheet.smooth ? 0.5 / sheet.textureHeight : 0; + // Linear sampling needs half a texel so the blend never reaches the neighbour. Nearest needs only + // a sliver: at odd zooms a pixel centre on the quad edge can round onto the neighbour's first + // texel and draw a line. 1/50 texel keeps every picked texel the same. + const inset = sheet.smooth ? 0.5 : 0.02; + const insetX = inset / sheet.textureWidth; + const insetY = inset / sheet.textureHeight; let v = 0; let i = 0; for (const drawn of tiles) { diff --git a/tests/core/tilemap.test.ts b/tests/core/tilemap.test.ts index 41c5faf5..fb38c486 100644 --- a/tests/core/tilemap.test.ts +++ b/tests/core/tilemap.test.ts @@ -500,8 +500,10 @@ describe('the mesh a layer becomes', () => { expect([positions[0], positions[1]]).toEqual([48, 0]); expect([positions[2], positions[3]]).toEqual([72, 0]); // Quadrant (0, 0) of a 96 × 144 sheet cut into 24 px quarters is the first quarter of the uvs. - expect([uvs[0], uvs[1]]).toEqual([0, 0]); - expect([uvs[2], uvs[3]]).toEqual([24 / 96, 0]); + expect(uvs[0]).toBeCloseTo(0, 3); + expect(uvs[1]).toBeCloseTo(0, 3); + expect(uvs[2]).toBeCloseTo(24 / 96, 3); + expect(uvs[3]).toBeCloseTo(0, 3); }); it('pulls the uvs half a texel in when the picture is sampled smoothly', () => { @@ -525,9 +527,40 @@ describe('the mesh a layer becomes', () => { ]; const hard = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false }); const soft = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: true }); - expect(hard.uvs[0]).toBe(0); + expect(hard.uvs[0]).toBeCloseTo(0.02 / 96, 6); expect(soft.uvs[0]).toBeCloseTo(0.5 / 96, 6); // The positions are untouched: only what is sampled moves, never where it lands. expect([...soft.positions]).toEqual([...hard.positions]); }); + + it('keeps nearest uvs a sliver inside the quarter so odd zooms never pick the neighbour', () => { + const tiles = [ + { + x: 0, + y: 0, + tileId: 'a', + quadrants: [ + { x: 1, y: 1 }, + { x: 1, y: 1 }, + { x: 1, y: 1 }, + { x: 1, y: 1 }, + ] as [ + { x: number; y: number }, + { x: number; y: number }, + { x: number; y: number }, + { x: number; y: number }, + ], + }, + ]; + const { uvs } = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false }); + // Quarter (1, 1) spans texels 24..48 on both axes. Every corner sits strictly inside it, + // but by far less than half a texel, so the texel picked at any pixel centre stays the same. + const us = [uvs[0]!, uvs[2]!, uvs[4]!, uvs[6]!].map((u) => u * 96); + const vs = [uvs[1]!, uvs[3]!, uvs[5]!, uvs[7]!].map((v) => v * 144); + for (const t of [...us, ...vs]) { + expect(t).toBeGreaterThan(24); + expect(t).toBeLessThan(48); + expect(Math.min(t - 24, 48 - t)).toBeLessThan(0.05); + } + }); });