Skip to content

World preview: switch to streaming tile architecture#2281

Draft
Fabrimat wants to merge 43 commits into
HiveGamesOSS:developfrom
Fabrimat:world-preview-streaming
Draft

World preview: switch to streaming tile architecture#2281
Fabrimat wants to merge 43 commits into
HiveGamesOSS:developfrom
Fabrimat:world-preview-streaming

Conversation

@Fabrimat

Copy link
Copy Markdown
Contributor

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.GridLayer with a full LOD pyramid, default-view pre-loading, and adjacent-LOD warming on every move.

User-visible changes

  • The preview opens auto-fit on the world's bounding box (instead of SpawnX/SpawnZ at fixed zoom 2). Worlds with content built far from spawn now show their actual content on first open.
  • A single progress UI walks the user through three phases (settings fetch, then preview generation, then tile pre-load) before the Map mounts.
  • The tile pre-load can be cancelled by the user; cancellation also aborts in-flight Java tile work.
  • Zoom in/out within the displayed area is instant: adjacent LODs are background-rendered on every pan/zoom.
  • An editable X/Z center control, an interactive vertical zoom indicator, and a top-left "Loading tiles…" badge are available on the map.

High-level flow

  1. Metadata pass produces map.bin: per-world bounds plus a region-presence bitmap. No chunk decompression.
  2. Tile pre-load: the client requests tiles for the auto-fit LOD (and a few neighbouring LODs) covering the world bounds, with a real progress bar and a cancel button.
  3. Map mounts after the pre-load. From there, on-demand tiles stream in as the user pans/zooms; adjacent LODs are warmed in the background so subsequent zoom steps paint from a warm cache.

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 .mca header 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, an inFlight dedup set, an LOD pyramid via PreviewTileDownsampler (alpha-aware 2x2), and a writtenTiles set so re-requested tiles emit tile_ready straight from disk. Tiles are written via a sibling .tmp file and Files.move(..., ATOMIC_MOVE)-d into place so tile_ready cannot race a partial write.
  • ChunkerWorldRegionRgbaSource: production region source. Each call constructs a fresh WorldConverter constrained to a single 32x32 chunk box via PruningConfig, with all non-block processing disabled, and captures the result into an int[262144] ARGB array via RegionRgbaCapturingWriter. A per-source synchronized block on loadRegion keeps only one BedrockLevelReader opening the LevelDB at a time, since the JVM refuses to acquire the db/LOCK file twice (OverlappingFileLockException). Anvil is unaffected by the lock but the serialization cost there is negligible because the heavy work runs outside the locked section.
  • New messenger types: 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 ChunkerColumn via the existing reader pipeline, and RegionRgbaCapturingWriter runs the same getHighestBlock(_, _, ChunkerBlockIdentifier::hasRGBColor) lookup that the old PreviewColumnWriter used. Where Java Edition and Bedrock differ in this PR:

  • Anvil metadata: PreviewMetadataReader.readJavaWorld only reads the 8 KiB sector-offset header of each .mca file to detect which chunks are present. No NBT decompression for the metadata pass.
  • Bedrock metadata: PreviewMetadataReader.readBedrockWorld opens 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.
  • Bedrock tile pass: each region request constructs a fresh BedrockLevelReader. Anvil happily tolerates multiple readers in parallel, but Bedrock's LevelDB db/LOCK file is rejected by the JVM's FileLockTable on the second concurrent tryLock (the OverlappingFileLockException already mentioned above). The per-source synchronized mutex on ChunkerWorldRegionRgbaSource.loadRegion exists 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 .mca files, 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 for PreviewMapBin plus isTileEmpty(lod, tx, tz), used by createTile to skip empty regions without a round-trip.
  • autoFit.js: computeMinZoom(bounds) with a -12 floor matching the layer's minNativeZoom.
  • clientTileCache.js: bounded LRU keyed by world|lod|tx|tz, owned by App so the pre-loader and the Map share a single instance.
  • chunkerPreviewLayer.js: custom L.GridLayer:
    • createTile filters via isTileEmpty, hits the shared cache before placeholding, and registers pending tiles for a batched dispatch.
    • One padded rect request per LOD with a 50 ms debounce, PADDING_FACTOR = 1.5 off-viewport prefetch.
    • _warmAdjacentLods on moveend issues request rects for currentLod +/- 1 over the same world area, clamped to minNativeZoom/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 every GridLayer's loading/load events.

Pre-load with cancellable progress

In App:

  • previewTileProgress is a ProgressTracker driving a "Loading map tiles" bar; its cancel-fn is wired to cancelTilePreload.
  • preloadDefaultWorldTiles(parsedMapBin, sessionId) runs after map.bin arrives:
    • Computes the default world's auto-fit LOD, then walks LODs from there toward 0 collecting non-empty tiles. Stops once the cumulative count would exceed a soft cap (MAX_TRACKED = 120). This gives 80 to 120 progress events on any world size while costing no extra region work, because Java's PreviewTileCache shares pyramid results across the requested LODs.
    • One request_preview_tiles rect per LOD. Tracks tile_ready/tile_error, populates the shared cache, advances a target progress.
    • A 100 ms interval lerps the displayed progress toward target (factor 0.15) so the bar moves continuously between bursty events.
  • cancelTilePreload():
    • Emits cancel_preview_tiles for the tracked world; Java drops every in-flight key across the LODs we enqueued.
    • Tears down event listeners and the lerp timer.
    • Leaves progress at its last value. ProgressTracker.cancel() has already set cancelled: true, so ProgressComponent renders the standard cancelled message and the Map never mounts.

Loading UI

PreviewTab is a passthrough that always renders PreviewComponent. PreviewComponent walks four sequential branches under an identical main_content main_content_progress wrapper: 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. WorldConversionIntegrationTests trimmed for the removed legacy writers.

Notes for review

  • The synchronized in ChunkerWorldRegionRgbaSource.loadRegion is per-source, not global; independent preview sessions run in parallel.
  • cancel_preview_tiles drains the pending queue but doesn't abort a worker mid-conversion; that's bounded by the 30 s TIMEOUT_SECONDS in ChunkerWorldRegionRgbaSource.
  • clientTileCache stores session:// 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.

clankstar and others added 30 commits April 1, 2026 14:56
@Fabrimat

Fabrimat commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Current state of the UI:

image

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

@FormallyMyles

Copy link
Copy Markdown
Collaborator

Current state of the UI:

image 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?

@Fabrimat

Fabrimat commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

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.

Fabrimat added 2 commits June 5, 2026 14:27
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants