Draw the map from the palette, not from every block position - #158
Conversation
A region took 14 seconds to render, not the 180 ms every comment in the file claimed. Measured on a 4 MB region of 1024 fully-sectioned chunks: inflate 83 ms 0.6% nbt.parseUncompressed 515 ms 3.7% tileFromChunk 13305 ms 95.7% The 180 ms was the decompress and the NBT parse. Nobody had measured the surface extraction, which is the other 96%. tileFromChunk did all per-name work per BLOCK POSITION instead of per palette entry: a regex to strip the namespace, a Set lookup for air, seeThrough (a Set miss then ten endsWith calls) and blockColour with a second regex inside it — 4096 times a section, for a palette of at most a few dozen entries. Above the surface a chunk is about fifteen single-entry air sections, and each one ran all 4096 positions to rediscover that air is air, roughly 53k string operations per chunk. Resolved once per palette entry instead, with an all-invisible section skipped before anything is unpacked or allocated. unpackIndices then dominated, so it moved off BigInt onto the two 32-bit halves of each long, converted per long rather than per index; and it became a prepared reader rather than an eager array, because the scan stops as soon as every column has an answer and the other thirteen layers were being unpacked for nothing. one region, cold 13962 ms -> 1442 ms four regions, as queued 38348 ms -> 6129 ms longest uninterrupted 584 ms -> 41 ms The cached path is untouched at ~13 ms, and the rendered output is unchanged: 6144 chunk renders across an overworld-shaped and a nether-shaped fixture, in all three dimensions, hash identical to the previous implementation. No TILE_CACHE_VERSION bump, so existing caches stay valid. SLICE_SLOTS drops 32 -> 8. It was picked to keep a slice near 6 ms against the wrong 180 ms figure; at the real cost those slices were blocking for about 600 ms, ten times the limit the smoke asserts. Two gates, both proved failable before being trusted: - The WORLDS fixture wrote chunks with NO SECTIONS, so tileFromChunk returned null on its first line and the existing "no slice over 60 ms" assertion measured an empty parse. Given real sections it fails on the parent commit at 661 ms and passes here at 45 ms. It now also asserts a full surface was rendered, so it cannot go vacuous the same way again. - unpackIndices is fuzzed against a verbatim copy of the pre-change implementation over 1500 trials x both packings x 4..31 bits, counting that half-boundary straddles, long-spanning indices and sign-bit longs were actually reached. Deleting the straddle branch fails it by name. Closes #157
There was a problem hiding this comment.
Pull request overview
This PR optimizes region/chunk surface extraction so map tiles are derived from section palettes (and lazily-read indices) rather than doing expensive string/rule work per block position, substantially reducing worst-case region render time and main-thread blocking.
Changes:
- Refactors
tileFromChunkto precompute per-paletteinvisible/colourarrays and scan lazily (early-exit once all 256 columns are resolved), avoiding full 4096-index unpacking in common cases. - Reworks
unpackIndicesto use a prepared index reader with 32-bit halves (with a BigInt fallback for wide palettes) and adds targeted fuzzing to ensure parity with the prior implementation. - Adjusts region parse slicing (
SLICE_SLOTS32 → 8) and strengthens smoke fixtures so timing gates exercise real surface extraction work.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| src/shared/tileCache.ts | Updates cache rationale/comments to reflect measured region parse + surface extraction costs. |
| src/shared/regionFormat.ts | Adds prepared/lazy index reading (prepareIndices/indexAt) and refactors unpackIndices to use it for performance. |
| src/main/smoke.ts | Adds fuzz verification against the pre-change unpacking reference and fixes the worlds fixture to include real sections/surfaces. |
| src/main/core/worldTiles.ts | Implements palette-driven surface extraction, lazy index reads, and reduces parse slice size to limit main-thread stalls. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Two things the self-review turned up. The all-invisible skip claimed to happen "before anything is allocated" and did not: `packedColour` was built above it, so every air section — the majority of them — still allocated an array and called `blockColour` for each palette entry before being thrown away. Moved below the skip. Output re-verified identical on both fixtures. SLICE_SLOTS 8 -> 4. The WORLDS gate failed once at 61 ms against its 60 ms limit, which turned out not to be a flake: the smoke parses exactly one region, so it always measures the FIRST parse in the process, and that one blocks for 40 ms where every later one blocks for 15-19 — V8 has not optimised these loops yet. The first parse is also the one an operator meets, since they open the map and it is the only parse that has happened. Sized for that: 28 ms cold and 8-10 ms steady, and three consecutive gate runs at 25/41/43 ms.
Self reviewRead the diff back against the measurements. Two things worth changing, both now on the branch ( 1. The all-invisible skip did not skip as much as its comment claimedconst packedColour = names.map(...) // ← blockColour per palette entry
if (invisible.every(Boolean)) { ...; continue }The comment said the section is skipped "before the index array is unpacked and before anything is allocated". It was not: Moved below the guard. Output re-verified identical on both fixtures. 2. The 61 ms gate failure was not a flake
The first parse in a process costs 2-3x every later one, because V8 has not optimised these loops yet. The smoke parses exactly one region, so it always measures that first parse — and so does an operator, who opens the map when no parse has happened yet. So the number the gate was reporting is the honest one and 8 slots was sized for the average rather than for the case anyone experiences.
Residual, stated rather than tuned away43 ms against a 60 ms bar is about 28% headroom, and the remaining variance looks like GC and JIT rather than slice work (loop-lag p99 was 21 ms on the cold run against 6.5 ms steady, so halving again would not buy much). On a machine several times slower this gate will go red — which is what it is for. The worst block observed has gone 661 ms → 43 ms. Checked and fine
|
Correction: the "output identical" claim was narrower than I statedRe-reading my own evidence:
At 4 bits every offset is a multiple of 4 and The unit fuzz did cover both (3572 straddles, 3571 spans, widths 4..31), so I do not believe anything was hiding there. But "identical at 4 bits" is not the claim I made, and this is exactly the trap this project has been caught by before — a fixture that never crosses the boundary in the code passes with the boundary handling deleted. Widened and re-runBoth fixtures now carry palettes past the 16-entry line (20 entries → 5 bits, where index 6 runs from bit 30 to 34) and half their chunks at Hashes, before vs after, 6144 renders: Identical on both fixtures. The nether hash still differs from the other two, so the under-roof path is genuinely being exercised and not just agreeing trivially. Scope, stated preciselyThe differential render hashes The permanent guards in the tree are the MODUPDATE fuzz (bit math, widths 4..31, both packings, proved failable) and the existing MODUPDATE The WORLDS fixture stays at a 16-entry palette deliberately — it is a timing gate, and widening it would move the number it asserts against for coverage the fuzz already owns. |
Fixes the map taking up to a minute to appear. Measured, not guessed.
What was wrong
A region took 14 seconds to render, not the 180 ms every comment in
worldTiles.tsclaimed. On a 4 MB fixture of 1024 fully-sectioned chunks, built to match a real world's ~5 MB/811:The 180 ms was the decompress and the NBT parse. Nobody had measured the surface extraction, which is the other 96%.
tileFromChunkdid all per-name work per block position instead of per palette entry — a regex to strip the namespace, a Set lookup for air,seeThrough(a Set miss then tenendsWithcalls), andblockColourwith a second regex inside it. 4096 times a section, for a palette of at most a few dozen entries. Above the surface a chunk is ~15 single-entry air sections, and each ran all 4096 positions to rediscover that air is air: ~53k string operations per chunk.That is why big worlds and slower machines were worse: both mean more generated regions, each costing 14 s, and the queue serialises them behind a 250 ms gap.
What changed
invisible[]and onecolour[]per section. A section a map sees nothing in is skipped before the indices are unpacked and before anything is allocated — under a roof it still records the air gap, in one pass over 256 columns instead of 4096 positions.unpackIndicesoff BigInt. Each long split into two 32-bit halves once per long instead of a shift/mask/Number()per index.SLICE_SLOTS32 → 8. Picked to keep a slice near 6 ms against the wrong 180 ms figure; at the real cost those slices were blocking the main thread — the one that answers every IPC call — for ~600 ms each.Result
Verification
The rendered output is unchanged. 6144 chunk renders — an overworld-shaped and a nether-shaped fixture, each 1024 chunks, in all three dimensions — hash identical to the previous implementation, built from
git show HEAD:so it is the real old code and not a paraphrase. The nether fixture was added after noticing the first one hashed the same in all three dimensions, i.e. it never exercised the under-roof path at all, which is the riskiest part of this change. NoTILE_CACHE_VERSIONbump: existing caches stay valid.Two gates, both proved failable before being trusted (
MSMS_SMOKE_WORLDS,MSMS_SMOKE_MODUPDATE, both by exit code):tileFromChunkreturned null on its first line and the existing "no slice over 60 ms" assertion was timing an empty parse. Given real sections it fails on the parent commit at 661 ms and passes here at 45 ms. It now also asserts a full surface was rendered, so it cannot go vacuous the same way again.unpackIndicesis fuzzed against a verbatim copy of the pre-change implementation — 1500 trials x both packings x bits 4..31, with longs whose sign bit is set. It counts that half-boundary straddles, long-spanning indices and sign-bit longs were actually reached, because a fuzz that never hits the interesting case passes with the interesting case deleted. Removing the straddle branch fails it by name:unpackIndices differs from the reference: bits=29 packing=padded index=1 reference=499939792 got=0.Deliberately not in this PR
The profile has changed shape —
nbt.parseUncompressedis now 37.6% of a parse, where it was 3.7%. These are worth measuring against the new numbers rather than the old ones, and each is independently revertable:blockColourreads module-leveltextureColourspopulated at runtime from the client jar, so a worker without that table renders different colours andwriteCachedRegionpersists them.Closes #157