World preview: switch to streaming tile architecture#2281
Conversation
…ith zoom buttons, loading badge top-left
Sort dev build files by name using numeric localeCompare and choose the first entry so the newest versioned chunker-cli jar is launched when multiple jars are present (prevents launching an older build due to OS file ordering). Modified app/electron/src/session.js.
|
Current state of the UI:
Also, note why it's still in draft: it currently only works for Java source worlds. @FormallyMyles also was wondering if this would be something the team would be fine with maintaining, or not worth working on |
At some point we had a test internally with a streaming like system (we did conversion with specific co-ordinates to allow the streaming to happen), but we found the speed-up wasn't that good and put it on hold. I think we would consider it if it seems maintainable, I would say in the current state this PR definitely touches a lot of files for what it's doing. It would be ideal if there was a way to make it work on a generic level, do you find generating LODs to be much more performant that just generating the tiles? |
For big worlds, this allows skipping the 1-2 minutes wait before having an actual preview. But the reason I've been working on this was mostly because of crashes in huge worlds, and to have a stable way to de-zoom and keep the whole world in view, regardless of size. |
Each preview region opens the world's LevelDB afresh, since the converter frees (and so closes) the reader after every region. A brief overlap with the previous reader's not-yet-released handle, or a stale db/LOCK left by a crashed run, makes iq80 throw "Unable to acquire lock" and the tile is lost. Add PreviewLevelDb.withLockRetry, which clears a stale LOCK and retries on lock-acquisition failures, and wrap the per-region Bedrock reads with it.
The scan read the sub-chunk Y byte as the chunk type and skipped the real type byte, so SUB_CHUNK_PREFIX keys were discarded. The on-disk layout is [x][z][(dim)][type][y] (see LevelDBKey) with the type before the Y, so read the type directly. Also open the database through PreviewLevelDb so the metadata pass survives a transient lock instead of leaving the preview with no map.bin. The test built its keys with the Y and type swapped, matching the old bug; correct it to the real layout.


Note
Scope: preview-only UI/rendering.
This description was written with the help of AI 🫶
Summary
The goal is to make the world preview usable on very large worlds. The previous upfront-render path produced one 512x512 PNG per region during the conversion pass (at native LOD 0 only, no aggregation) and the client served them through a stock
L.tileLayer. Past a certain world size, rendering every region before showing anything stops being practical.This PR replaces that path with an on-demand streaming tile pipeline. Java now does a fast metadata-only scan up front, then a per-session worker pool generates 512x512 PNG tiles in response to client requests, including aggregated lower LODs. The UI renders them through a custom Leaflet
L.GridLayerwith a full LOD pyramid, default-view pre-loading, and adjacent-LOD warming on every move.User-visible changes
SpawnX/SpawnZat fixed zoom 2). Worlds with content built far from spawn now show their actual content on first open.High-level flow
map.bin: per-world bounds plus a region-presence bitmap. No chunk decompression.Backend (
cli/)Added
PreviewMapBin: compact binary listing world bounds + region-presence bits, written once after the metadata pass and consumed by both Java and the client to short-circuit empty regions.PreviewMetadataReader: fast metadata-only scan. Reads only the.mcaheader sector table on Anvil; iterates LevelDB keys without decoding values on Bedrock.PreviewTileService: long-lived per-session worker pool that generates tile PNGs on demand. Owns a bounded LRU of decoded ARGB region buffers, aninFlightdedup set, an LOD pyramid viaPreviewTileDownsampler(alpha-aware 2x2), and awrittenTilesset so re-requested tiles emittile_readystraight from disk. Tiles are written via a sibling.tmpfile andFiles.move(..., ATOMIC_MOVE)-d into place sotile_readycannot race a partial write.ChunkerWorldRegionRgbaSource: production region source. Each call constructs a freshWorldConverterconstrained to a single 32x32 chunk box viaPruningConfig, with all non-block processing disabled, and captures the result into anint[262144]ARGB array viaRegionRgbaCapturingWriter. A per-sourcesynchronizedblock onloadRegionkeeps only oneBedrockLevelReaderopening the LevelDB at a time, since the JVM refuses to acquire thedb/LOCKfile twice (OverlappingFileLockException). Anvil is unaffected by the lock but the serialization cost there is negligible because the heavy work runs outside the locked section.RequestPreviewTilesRequest,CancelPreviewTilesRequest,TileReadyResponse,TileErrorResponse.Removed
PreviewWorldWriter/PreviewLevelWriter/PreviewColumnWriter: the upfront-render path is gone.Format-specific notes (Anvil vs Bedrock)
The per-pixel rendering algorithm is format-agnostic. Both Anvil and LevelDB chunks land in the same intermediate
ChunkerColumnvia the existing reader pipeline, andRegionRgbaCapturingWriterruns the samegetHighestBlock(_, _, ChunkerBlockIdentifier::hasRGBColor)lookup that the oldPreviewColumnWriterused. Where Java Edition and Bedrock differ in this PR:PreviewMetadataReader.readJavaWorldonly reads the 8 KiB sector-offset header of each.mcafile to detect which chunks are present. No NBT decompression for the metadata pass.PreviewMetadataReader.readBedrockWorldopens the LevelDB directly (iq80) and iterates its keys, filtering on the LevelDBChunkType bytes that mark chunk presence (DATA_3D,DATA_2D,SUB_CHUNK_PREFIX,BLOCK_ENTITY,ENTITY). Values are never decoded.BedrockLevelReader. Anvil happily tolerates multiple readers in parallel, but Bedrock's LevelDBdb/LOCKfile is rejected by the JVM'sFileLockTableon the second concurrenttryLock(theOverlappingFileLockExceptionalready mentioned above). The per-sourcesynchronizedmutex onChunkerWorldRegionRgbaSource.loadRegionexists exclusively for this reason: it serialises the open-decode-close cycle on Bedrock; on Anvil the lock is contention-free because each tile reads independent.mcafiles, and the heavy work (PNG encoding, LOD aggregation) runs outside the locked section so the throughput cost is negligible there.Frontend (
app/ui/)New components
mapBin.js: client parser forPreviewMapBinplusisTileEmpty(lod, tx, tz), used bycreateTileto skip empty regions without a round-trip.autoFit.js:computeMinZoom(bounds)with a-12floor matching the layer'sminNativeZoom.clientTileCache.js: bounded LRU keyed byworld|lod|tx|tz, owned byAppso the pre-loader and the Map share a single instance.chunkerPreviewLayer.js: customL.GridLayer:createTilefilters viaisTileEmpty, hits the shared cache before placeholding, and registers pending tiles for a batched dispatch.PADDING_FACTOR = 1.5off-viewport prefetch._warmAdjacentLodsonmoveendissues request rects forcurrentLod +/- 1over the same world area, clamped tominNativeZoom/maxNativeZoom. Results populate the shared cache, so the next zoom step paints from a warm cache.centerCoordsControl.js: editable X/Z input pinned next to the zoom buttons; pans on commit, clamps to world bounds.zoomIndicator.js: interactive vertical track + fill in the bottom-left.loadingIndicator.js: top-left "Loading tiles…" badge driven by everyGridLayer'sloading/loadevents.Pre-load with cancellable progress
In
App:previewTileProgressis aProgressTrackerdriving a "Loading map tiles" bar; its cancel-fn is wired tocancelTilePreload.preloadDefaultWorldTiles(parsedMapBin, sessionId)runs aftermap.binarrives:MAX_TRACKED = 120). This gives 80 to 120 progress events on any world size while costing no extra region work, because Java'sPreviewTileCacheshares pyramid results across the requested LODs.request_preview_tilesrect per LOD. Trackstile_ready/tile_error, populates the shared cache, advances a target progress.cancelTilePreload():cancel_preview_tilesfor the tracked world; Java drops every in-flight key across the LODs we enqueued.progressat its last value.ProgressTracker.cancel()has already setcancelled: true, soProgressComponentrenders the standard cancelled message and the Map never mounts.Loading UI
PreviewTabis a passthrough that always rendersPreviewComponent.PreviewComponentwalks four sequential branches under an identicalmain_content main_content_progresswrapper: settings progress, then generation progress, then tile-load progress, then Map. The wrapper class never changes between phases.Tests
TDD coverage for the new Java units:
PreviewTileKeyTests,PreviewTileDownsamplerTests,PreviewTileCacheTests,PreviewMapBinTests,PreviewMetadataReaderTests,PreviewTileServiceTests,ChunkerWorldRegionRgbaSourceTests.WorldConversionIntegrationTeststrimmed for the removed legacy writers.Notes for review
synchronizedinChunkerWorldRegionRgbaSource.loadRegionis per-source, not global; independent preview sessions run in parallel.cancel_preview_tilesdrains the pending queue but doesn't abort a worker mid-conversion; that's bounded by the 30 sTIMEOUT_SECONDSinChunkerWorldRegionRgbaSource.clientTileCachestoressession://URLs only. The PNG bytes live on disk in the session's preview folder and are served via the existing session protocol handler, so the client cache never pins large image buffers.