Summary
In atlas mode, the vertex shader derives a tileset's column count with round, while the rest of the ecosystem (and this crate's own CPU-side code) uses floor. For any tileset image whose width is not an exact multiple of tile_size.x + spacing.x, the shader computes one column too many and every tile index resolves to the wrong art.
src/render/shaders/tilemap_vertex.wgsl:61:
let columns: u32 = u32(round((tilemap_data.texture_size.x - tilemap_data.spacing.x) / (tilemap_data.tile_size.x + tilemap_data.spacing.x)));
The crate disagrees with itself about this. src/render/extract.rs:93-94 floors:
let tile_count_x = ((texture_size.x) / (tile_size.x + tile_spacing.x)).floor();
let tile_count_y = ((texture_size.y) / (tile_size.y + tile_spacing.y)).floor();
Tiled also floors, so a .tmx authored against a tileset with any trailing slack renders as garbage.
Reproduction
Any tileset image whose width isn't an exact multiple of the tile width. Concretely, from a real Tiled map:
| tileset |
image |
tile |
Tiled columns |
shader round() |
oryx_world.png |
1024x1024 |
24 |
42 |
43 ✗ |
oryx_16bit_fantasy_world_trans.png |
1366x1007 |
24 |
56 |
57 ✗ |
oryx_creatures.png |
432x648 |
24 |
18 |
18 ✓ |
oryx_items.png |
352x128 |
16 |
22 |
22 ✓ |
1024 / 24 = 42.67. Tiled floors to 42 columns and encodes tile ids on that basis; the shader rounds to 43 and unpacks them as (id % 43, id / 43). The row shifts left by one column per row, so the error compounds the deeper into the sheet a tile lives.
The two tilesets that divide exactly are unaffected, which makes this look intermittent — sprites are fine, terrain is scrambled.
Screenshot
Same layer, same .tmx, same PNG, rendered with 42 vs 43 columns:
I have a side-by-side render of the same layer decoded from the .tmx and composited
two ways -- 42 columns vs 43 columns -- against the same source PNG. The 42-column
version matches the Tiled editor exactly; the 43-column version is unrecognisable
(wrong terrain, pots and chests where grass should be). I'll attach it in a comment
below.
Cause
The round was introduced deliberately in #367 ("Fix incorrect tile UVs when using atlas feature on AMD hardware"), which replaced an implicit floor to work around floating-point imprecision:
It seems that there's some sort of floating point imprecision thing going on where var columns ends up as 2 rather than the correct 3 after being cast as u32 / implicitly floored.
That's a real bug and the fix works for it, but round also "corrects" divisions that are legitimately fractional. 42.67 isn't imprecision — it's a tileset with 16px of unused slack on the right edge — and rounding it up to 43 is wrong.
So the current behaviour trades one failure mode for another: before #367, exact divisions could floor short on some hardware; after it, inexact divisions round up on all hardware.
Suggested fix
Tolerate the imprecision without swallowing a genuine fractional remainder — an epsilon-nudged floor rather than a round:
let raw = (tilemap_data.texture_size.x - tilemap_data.spacing.x) / (tilemap_data.tile_size.x + tilemap_data.spacing.x);
let columns: u32 = u32(floor(raw + 1e-3));
An epsilon well under one whole column still absorbs the 2.9999997 case from #367 while leaving 42.67 alone. Alternatively, compute the count once on the CPU (where extract.rs already does it correctly) and pass it through TilemapUniformData, which would remove the duplicated, divergent logic entirely.
Workaround
Crop the tileset image so its width is an exact multiple of the tile width, which makes round and floor agree. This is lossless when the slack contains no tiles (as it did here — tilecount and columns are unchanged after cropping, so existing gids stay valid).
Switching off the atlas feature also avoids it, since the texture-array path floors correctly in both texture_array_cache.rs:81-82 and :286. That isn't always available though: the array path needs one array layer per tile, and a 2296-tile tileset exceeds the 2048 array-layer limit on some hardware.
Environment
bevy_ecs_tilemap 0.19.0, atlas feature enabled
bevy 0.19.1
- macOS 13.7.8, Metal, AMD Radeon Pro 560
The offending line is byte-identical on main and as far back as v0.12.0, so this is not specific to a recent release.
Summary
In
atlasmode, the vertex shader derives a tileset's column count withround, while the rest of the ecosystem (and this crate's own CPU-side code) usesfloor. For any tileset image whose width is not an exact multiple oftile_size.x + spacing.x, the shader computes one column too many and every tile index resolves to the wrong art.src/render/shaders/tilemap_vertex.wgsl:61:The crate disagrees with itself about this.
src/render/extract.rs:93-94floors:Tiled also floors, so a
.tmxauthored against a tileset with any trailing slack renders as garbage.Reproduction
Any tileset image whose width isn't an exact multiple of the tile width. Concretely, from a real Tiled map:
columnsround()oryx_world.pngoryx_16bit_fantasy_world_trans.pngoryx_creatures.pngoryx_items.png1024 / 24 = 42.67. Tiled floors to 42 columns and encodes tile ids on that basis; the shader rounds to 43 and unpacks them as(id % 43, id / 43). The row shifts left by one column per row, so the error compounds the deeper into the sheet a tile lives.The two tilesets that divide exactly are unaffected, which makes this look intermittent — sprites are fine, terrain is scrambled.
Screenshot
Same layer, same
.tmx, same PNG, rendered with 42 vs 43 columns:I have a side-by-side render of the same layer decoded from the
.tmxand compositedtwo ways -- 42 columns vs 43 columns -- against the same source PNG. The 42-column
version matches the Tiled editor exactly; the 43-column version is unrecognisable
(wrong terrain, pots and chests where grass should be). I'll attach it in a comment
below.
Cause
The
roundwas introduced deliberately in #367 ("Fix incorrect tile UVs when using atlas feature on AMD hardware"), which replaced an implicitfloorto work around floating-point imprecision:That's a real bug and the fix works for it, but
roundalso "corrects" divisions that are legitimately fractional.42.67isn't imprecision — it's a tileset with 16px of unused slack on the right edge — and rounding it up to 43 is wrong.So the current behaviour trades one failure mode for another: before #367, exact divisions could floor short on some hardware; after it, inexact divisions round up on all hardware.
Suggested fix
Tolerate the imprecision without swallowing a genuine fractional remainder — an epsilon-nudged floor rather than a round:
An epsilon well under one whole column still absorbs the
2.9999997case from #367 while leaving42.67alone. Alternatively, compute the count once on the CPU (whereextract.rsalready does it correctly) and pass it throughTilemapUniformData, which would remove the duplicated, divergent logic entirely.Workaround
Crop the tileset image so its width is an exact multiple of the tile width, which makes
roundandflooragree. This is lossless when the slack contains no tiles (as it did here —tilecountandcolumnsare unchanged after cropping, so existing gids stay valid).Switching off the
atlasfeature also avoids it, since the texture-array path floors correctly in bothtexture_array_cache.rs:81-82and:286. That isn't always available though: the array path needs one array layer per tile, and a 2296-tile tileset exceeds the 2048 array-layer limit on some hardware.Environment
bevy_ecs_tilemap0.19.0,atlasfeature enabledbevy0.19.1The offending line is byte-identical on
mainand as far back as v0.12.0, so this is not specific to a recent release.