Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Immediate fixes: ~~fix `getChunkAt` to use `Map.get(key)`~~; ~~remove the 4×4×

~~Pick rendered mobs through `world.raycastEntities` and inspect their type and saved position in the world demo.~~

~~Resolve each block state once per placement group and yield to the event loop instead of a timer-driven batch queue.~~ ~~Build section geometry in a browser worker.~~ Region decoding still runs on the main thread; wasm or worker-side chunk parsing remains a scale option once placement stops dominating.
~~Resolve each block state once per placement group and yield to the event loop instead of a timer-driven batch queue.~~ ~~Build section geometry in a browser worker.~~ ~~Group ready streamed columns with a four-column read window and refresh shared borders once per placement group.~~ Region decoding still runs on the main thread; wasm or worker-side chunk parsing remains a scale option once placement stops dominating.

~~Render sloped water/lava surfaces with still/flow textures and waterlogged blocks.~~ ~~Cover intrinsic water in kelp, seagrass, and bubble columns.~~ For remaining fluid parity, obtain vanilla block-state fluid definitions, face-occlusion shapes, and solidity/flow-blocking flags; add water overlays against glass/leaves. Keep 1 block = 16 units.

Expand Down
2 changes: 1 addition & 1 deletion apps/web/benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ The report separates these measurements:

Each stop records loaded, pending, missing, and failed columns, drawing calls and triangles, Three.js geometry and texture counts, and evidence of worker replies or synchronous fallback. Cleanup is timed separately. Garbage collection runs after frame sampling, so its pause is excluded from streaming and frame measurements. Heap samples include shared assets and benchmark records that remain after world cleanup. The runner exits unsuccessfully for source failures or browser errors and preserves completed reports for inspection.

Unloading records one operation per group of columns the streamer removes together, with its `columnCount` and `columns`.
Placement and unloading record one operation per group of columns the streamer handles together, with its `columnCount` and `columns`. Compare total columns as well as operation counts. Source reads can overlap; their summed wall times are not total streaming time.

## Compare runs

Expand Down
8 changes: 8 additions & 0 deletions apps/web/benchmarks/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,14 @@ async function runWorldBenchmark(config: WorldBenchmarkConfig) {
try { await world.placeChunk(chunk); }
finally { operation = null; }
}),
placeChunks: chunks => {
const columns = chunks.map(({ x, z }) => ({ x, z }));
return timed("placement", { columnCount: chunks.length, columns }, async () => {
operation = { id: ++nextOperationId, kind: "placement", columns };
try { await world.placeChunks(chunks); }
finally { operation = null; }
});
},
unloadChunkColumns: columns => timed("unload", {
columnCount: columns.length, columns: columns.map(({ x, z }) => ({ x, z }))
}, async () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/minerender/reference/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,18 @@ For direct decoding, call `AnvilParser.parseEntityChunk(bytes, localX, localZ)`.

Call `updatePosition` after the camera or view center moves. It accepts scene units; `update(x, z)` accepts absolute chunk coordinates.

The streamer aborts obsolete reads and active reads during disposal. Source callbacks must forward the optional signal to cancellable I/O to stop that work.
With `MineRenderWorld`, the streamer reads ahead by at most four columns, counting both pending reads and decoded results. It places the nearest ready columns together and refreshes their shared borders once. An unfinished later read does not delay the ready prefix. Custom world adapters can provide `placeChunks` to use this path; adapters with only `placeChunk` retain one-at-a-time reads and placement.

The streamer aborts obsolete reads and active reads during disposal. Source callbacks must forward the optional signal to cancellable I/O to stop that work. Reads that ignore cancellation occupy their window slots until they settle. `dispose()` waits for them; a placement failure rejects without waiting for them.

`readExternalChunk` handles oversized chunks stored beside their region file. Its coordinates are absolute chunk coordinates, and it reads from the same dimension as the region reader. The source caches region files within its configured limits; external payloads are read on demand without retaining them. A missing external file is a chunk error and can be retried.

Source errors appear in `failedChunks` while other columns continue loading. Call `await stream.retryFailedChunks()` to retry them. Placement or unloading failures reject the update.

A failed placement group can have changed any of its columns. The streamer cleans the whole group before loading it again, and retains ownership if cleanup fails. An active placement finishes before eviction or disposal.

Call `await world.placeChunks(columns)` to replace already decoded columns together. The method validates and copies all column and section coordinates before mutation, preserves input order, and refreshes queued biomes, faces, fluids, saved lighting, and ambient occlusion after the group. If a column fails, later columns are skipped and applied changes refresh before the promise rejects. The first placement error takes precedence over a refresh error. The input's section data remains caller-owned; keep it unchanged until placement finishes.

Use `await world.unloadChunkColumns([{ x: -1, z: 0 }, { x: 0, z: 0 }])` to remove several columns and refresh surviving faces, fluids, saved lighting, ambient occlusion, and biome colors together. Coordinates are absolute chunk columns. The method validates and copies the whole input before removal, ignores duplicates and missing columns, and refreshes surviving neighbors once.

The streamer groups obsolete columns and disposal cleanup through this method. Its world must provide `unloadChunkColumns`; `MineRenderWorld` provides it. A rejected bulk unload keeps its column ownership for retry and invalidates the group's loaded entries, because some columns may already have been removed. The next update finishes that cleanup before loading the latest requested area.
Expand Down
7 changes: 4 additions & 3 deletions packages/minerender/src/util/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ export async function sleep(timeout: number): Promise<void> {
});
}

/** Yields to the next macrotask without timer clamping when the platform supports it. */
/**
* Yields to the next macrotask without timer clamping when the platform supports it.
* Not `scheduler.yield()`: Chrome runs its continuations ahead of rendering, so chained yields delay frames.
*/
export function yieldToEventLoop(): Promise<void> {
const scheduler = (globalThis as typeof globalThis & { scheduler?: { yield?: () => Promise<void> } }).scheduler;
if (scheduler?.yield) return scheduler.yield();
return new Promise(resolve => {
if (typeof MessageChannel !== "undefined") {
const channel = new MessageChannel();
Expand Down
91 changes: 79 additions & 12 deletions packages/minerender/src/world/MineRenderWorld.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { loadBlockCulling } from "./_culling/BlockCulling";
import { logger } from "../util/log";

const log = logger("MineRenderWorld");
const YIELD_INTERVAL_MS = 8;

//TODO: maybe make this an Object3D to add children
/**
Expand Down Expand Up @@ -165,7 +166,7 @@ export class MineRenderWorld<SectionMeshing extends boolean = false> {
failures.set(chunk, error);
if (this._chunks.get(`${chunk.x}_${chunk.y}_${chunk.z}`) === chunk) this.pendingBiomes.set(chunk, undefined);
}
if (performance.now() - sliceStart > 8) {
if (performance.now() - sliceStart > YIELD_INTERVAL_MS) {
await yieldToEventLoop();
sliceStart = performance.now();
}
Expand Down Expand Up @@ -366,7 +367,7 @@ export class MineRenderWorld<SectionMeshing extends boolean = false> {
chunk.y * 16 + Math.floor(index / 256), chunk.z * 16 + Math.floor(index / 16) % 16));
for (const pos of positions) changes.set(`${pos.x},${pos.y},${pos.z}`, pos);
}
if (performance.now() - sliceStart > 8) {
if (performance.now() - sliceStart > YIELD_INTERVAL_MS) {
await yieldToEventLoop();
sliceStart = performance.now();
}
Expand All @@ -376,6 +377,58 @@ export class MineRenderWorld<SectionMeshing extends boolean = false> {

/** Replaces a chunk column's blocks, biomes, and saved light and, with `renderEntities`, its supported saved mobs. */
public async placeChunk(chunk: AnvilChunk, executor?: BatchedExecutor): Promise<void> {
return this.placeColumn(chunk, executor);
}

/**
* Replaces parsed columns in input order, then refreshes neighboring geometry once.
* Duplicate coordinates retain their order. A failed column stops placement; queued refreshes finish before rejection.
*/
public async placeChunks(chunks: readonly AnvilChunk[]): Promise<void> {
const columns: AnvilChunk[] = [];
for (const chunk of chunks) {
const { x, z } = chunk ?? {};
if (![x, z].every(Number.isSafeInteger)) {
throw new RangeError("Chunk column coordinates must be safe integers");
}
const sections = Array.from(chunk.sections, section => {
const { y } = section ?? {};
if (!Number.isSafeInteger(y)) throw new RangeError("Chunk section coordinates must be safe integers");
return { ...section, y };
});
columns.push({ ...chunk, x, z, sections });
}
if (!columns.length) return;
await this.culling;
let failed = false, failure: unknown, sliceStart = performance.now();
try {
for (const column of columns) {
await this.placeColumn(column, undefined, true);
if (performance.now() - sliceStart > YIELD_INTERVAL_MS) {
await yieldToEventLoop();
sliceStart = performance.now();
}
}
} catch (error) {
failed = true;
failure = error;
} finally {
try {
const failures = await this.refreshBiomes(true);
if (failures.size) log.warn("Failed to refresh biome colors", failures.values().next().value);
} catch (error) {
if (!failed) { failed = true; failure = error; }
}
try {
await this.updateCulling([]);
} catch (error) {
if (!failed) { failed = true; failure = error; }
}
}
if (failed) throw failure;
}

private async placeColumn(chunk: AnvilChunk, executor?: BatchedExecutor, deferCulling = false): Promise<void> {
if (![chunk.x, chunk.z].every(Number.isSafeInteger)) {
throw new RangeError("Chunk column coordinates must be safe integers");
}
Expand Down Expand Up @@ -438,7 +491,7 @@ export class MineRenderWorld<SectionMeshing extends boolean = false> {
} finally {
this.markSectionChanged(target);
}
if (performance.now() - sliceStart > 8) {
if (performance.now() - sliceStart > YIELD_INTERVAL_MS) {
await yieldToEventLoop();
sliceStart = performance.now();
}
Expand All @@ -459,9 +512,12 @@ export class MineRenderWorld<SectionMeshing extends boolean = false> {
await placeEntities?.();
} finally {
this.markBiomesChanged(chunk.x, chunk.z, undefined, false);
const failures = await this.refreshBiomes();
if (failures.size) log.warn("Failed to refresh biome colors", failures.values().next().value);
await this.updateCulling([...changes.values()]);
if (deferCulling) await this.updateCulling([...changes.values()], true);
else {
const failures = await this.refreshBiomes();
if (failures.size) log.warn("Failed to refresh biome colors", failures.values().next().value);
await this.updateCulling([...changes.values()]);
}
}
}

Expand Down Expand Up @@ -493,7 +549,7 @@ export class MineRenderWorld<SectionMeshing extends boolean = false> {
for (const { x, z } of columns.values()) {
this.entities?.clearColumn(x, z);
await this.clearChunkColumn(x, z);
if (performance.now() - sliceStart > 8) {
if (performance.now() - sliceStart > YIELD_INTERVAL_MS) {
await yieldToEventLoop();
sliceStart = performance.now();
}
Expand Down Expand Up @@ -639,15 +695,26 @@ export class MineRenderWorld<SectionMeshing extends boolean = false> {
for (const [index, mask] of masks) await chunk.setCullMaskIndex(index, mask);
await chunk.refreshLight(indices === "all" ? undefined : indices);
chunk.refreshAmbientOcclusion(this._ambientOcclusion);
if ((this._ambientOcclusion || this.ambientOcclusionPending) && performance.now() - sliceStart > 8) {
if (performance.now() - sliceStart > YIELD_INTERVAL_MS) {
await yieldToEventLoop();
sliceStart = performance.now();
}
}
const pending: Promise<void>[] = [];
for (const chunk of [...batch.keys()]) {
const build = (async () => {
await chunk.rebuildSectionMesh();
batch.delete(chunk);
})();
// Handle rejections while dispatch yields; all started builds settle before their results are checked.
void build.catch(() => undefined);
pending.push(build);
if (performance.now() - sliceStart > YIELD_INTERVAL_MS) {
await yieldToEventLoop();
sliceStart = performance.now();
}
}
const builds = await Promise.allSettled([...batch.keys()].map(async chunk => {
await chunk.rebuildSectionMesh();
batch.delete(chunk);
}));
const builds = await Promise.allSettled(pending);
const failure = builds.find(result => result.status === "rejected");
if (failure?.status === "rejected") throw failure.reason;
} catch (error) {
Expand Down
Loading
Loading