Xenon 2

How it works · write-up

HD wall tiles: the seam problem

xenondoc/HD_WALL_TILE_SEAMS.md · 8 KB · updated 2026-09-22

Status 2026-09-22: open, mosaic shipped. The HD atlases use the map-context ("mosaic") upscale: compared side by side with the per-tile upscale (clamp-, wrap- or mirror-padded) its continuous texture wins even though it has hard seams where the real neighbour differs from the chosen one. --no-tile-mosaic rebuilds the per-tile version. This note records what the problem is, what has been measured, and what would fix it.

What the game does

Level walls are a 20-column tilemap of 16x16 tiles (ST_DrawGeneratedBackgroundFromTileMap_FUN_00001dd0), plus a few compiler-emitted composite grids for bosses. The tile index is the only thing the draw knows; the same index is placed all over the map next to different neighbours. With HIRES SPRITES on, the web renderer swaps every tile draw for the matching rect of a 4x atlas (spriteatlas_level_0N_hd.raw), one rect per tile index -- so one HD version of a tile has to look right in every place the map uses it.

Why 4x tiles show seams

The 4x atlas is produced by running the packed 1x atlas through a super-resolution model (chaiNNer, 4x_PixelPerfectV4) as one image. The model shades whatever it sees as an object with an edge, which is wrong for tiles that are meant to continue into their neighbours:

  1. Dark rim (fixed, 7d920679). The packer leaves 4 px of transparent black around every sprite, so every tile edge was an "image edge" and came back dark and partly transparent (source edge rows at brightness ~110-180 became 0-30). pad_packed_atlas_edges in xenon_tools/build_level_atlases.py now clamp-replicates each sprite's outer pixels into that border before the upscale. The dark grid is gone.

  2. Pillow shading (open). Even edge-padded, each tile is still upscaled in isolation and a wall of tiles reads as a grid of soft pillows. Measured on level 1 it is not a brightness falloff (mean HD/1x luminance on the outer ring 0.998 vs 0.999 inside) but a lack of detail: the clamped border is a flat streak, so the outermost source row/column comes back as a smooth 4-texel band (within-block detail 7.2 on the edge ring vs 12.6 inside, with a 15.8 halo one ring in). Padding the wall tiles with texture instead (--tile-padding wrap: the tile continues with itself; mirror: reflected interior) restores the edge detail (14.2 / 9.0) and visibly weakens the grid, without inventing a neighbour. A per-tile luminance gain map would do nothing.

  3. Content seams (what the mosaic trades it for). build_tile_mosaic lays the level's tiles out as the map does, upscales that as one image and cuts the cells back into the atlas (apply_tile_mosaic), so the model sees real neighbours and the pillow disappears -- in the neighbourhood that was chosen. Everywhere else the HD tile now carries a continuation of the wrong neighbour's shapes, cut off hard at the tile edge, which is worse than a soft pillow.

Measured (map grids, xenon_tools/level_capture)

choose_tile_placements picks, per tile index, the 8-neighbour signature that occurs most often. How often that is the cell actually being drawn:

level cells distinct tiles distinct (tile, 4 nbrs) distinct adjacent pairs h / v cells whose 4-nbrs = chosen context edges where the chosen neighbour is the real one
1 1447 197 1044 346 / 536 32 % 58 %
2 1829 210 1374 503 / 633 31 % 53 %
3 2295 182 1454 400 / 568 25 % 50 %
4 1734 351 1128 526 / 617 41 % 62 %
5 2085 157 1412 440 / 506 24 % 56 %

Two conclusions:

  • No placement rule can do much better: the number of distinct (tile, neighbourhood) combinations is 5-9x the number of tiles and not far below the number of cells. The maps reuse tiles like a construction kit.
  • Frequency alone does not identify "the few combinations that matter": covering 80 % of all edges still needs 400-600 distinct adjacent pairs per level, and only 2-14 % of edges are a tile next to itself. Picking a small subset has to be driven by how visible a seam is, not by how often it occurs.

Options

A. Per-context variants (the complete fix). Give the HD atlas one rect per (tile, 4-neighbour) combination -- ~1050-1450 extra 64x64 rects per level, +4.3-6 Mpx, about +20-27 MB of uncompressed .raw per level (~+120 MB on the web bundle). The capture side (DrawCommandStream_OnTileDrawInstructionFetch, src/drawCommandStream.c) already knows the cell's screen position and the tilemap is in ST RAM, so it can read the four neighbours' indices at draw time and emit the variant's rect, falling back to the base tile for boss grids and unknown contexts. Nothing else in the renderer changes: it is just a different atlas rect. The cost is the bundle size unless the variant bank ships compressed and is decoded at load.

B. Variants only where a seam is visible. Same mechanism as A, but the atlas only gets variants for edges whose seam is noticeable. Needs a visibility score that has not been built yet; the natural one is computed offline from the two upscales the tool already produces:

  1. For every adjacent pair (a, b) in the map (horizontal and vertical), take the isolated HD tiles from the per-tile atlas and measure the luminance step across the shared edge (last 4x column/row of a against the first of b), and compare it with the same step in the 1x source scaled up. The excess is the artefact.
  2. Rank pairs by excess x occurrences; emit variants (cut from the mosaic upscale, which the tool can render for every occurrence, not one per tile) for the top N until the residual excess is below a threshold.

The unknown is how many pairs that leaves. Plain metal-plate walls are likely to be a handful of pairs with a large excess (uniform texture makes the pillow obvious); busy pipework tiles hide it. If most of the visible seam energy is in a few hundred pairs per level the cost drops to a few MB.

C. Textured padding instead of context (tried 2026-09-22). --tile-padding wrap -- see "Pillow shading" above. No atlas growth, no runtime change, 13 s of chaiNNer per level. Weaker grid than clamp, but next to the mosaic it still reads as tiles; the mosaic's continuous texture was preferred despite its seams.

D. Mosaic with per-side consensus and wrap fallback (untried). Lay each tile out in its own 3x3 island: on each of its four sides the neighbour that is most common in the map for that side, but only where that neighbour accounts for at least half of the side's occurrences (46 % of level-1 edges, 33-51 % on the others), otherwise the tile itself (wrap). Keeps the mosaic look where a neighbour dominates and neutral padding where it does not, instead of a wrong neighbour. Note that per-side consensus alone matches barely more edges than the single chosen cell (level 1: 59 % vs 58 %), so the gain is in what the mismatching sides show, not in how many match.

E. Ship the mosaic as is (current state).

Tooling in place

  • python xenon_tools/build_level_atlases.py xenon_tools/level_capture rebuilds the atlases with the map-context (mosaic) wall tiles; add --no-tile-mosaic (or regenerate_atlases.ps1 -NoTileMosaic) for the per-tile upscale, and --tile-padding wrap|mirror to change how the per-tile wall tiles are padded for the upscaler (default clamp).
  • tile_grids / choose_tile_placements / build_tile_mosaic / apply_tile_mosaic in the same file; tile_mosaic.json next to each level's atlas records which cell each tile was cut from; the mosaic PNGs land in <output>/tile-mosaics/level-0N/ and are the pictures to look at when judging which neighbours a tile was upscaled with.
  • Comparison technique for a paused frame in the browser (hook Module.hatariBridge.onSpriteStreamFrame, replay the captured quads against an alternative atlas written with Module.FS.writeFile and onSpriteAtlasConfigurationChanged): see plan-atlas-upscale-and-frame-interpolation.md.