voxolith

Load events

makeLoadTracker: the engine's loaders report their phases (shaders, pipelines, models, upload, placement, ground) into one tracker, for an app's loading screen and a load timeline; and the opt-in controls an app steers its load with.

The engine's loaders can say what they are doing. Give them a LoadTracker and they report work by phase, with running counts; an app reads the same tracker to draw its own loading screen and, afterwards, to time the load. The tracker draws nothing, weighs nothing and holds no display strings. It is in the barrel and runtime-safe: no DOM, no GPU, only performance.now().

reporteroptionphase
the renderercreateRenderer(gpu, scene, { onLoad: trackRenderer(load) })shaders, pipelines
makeGeneratorPoolload in GeneratorPoolOptionsmodels (with cached)
makeInstanceLayerload in InstanceLayerOptionsupload (one per model; with a scene worker, in bricks per model from encode request to add), placement (with commitAsync, one task from bake request to apply that counts up with the worker's progress); both tick as cached what a scene cache served
makeModelLibraryload in ModelLibraryOptionsupload
makeChunkedWorldload in ChunkedWorldOptionsground (with fill, one item per chunk, ticked when its bricks are applied; a chunk dropped before that leaves the total)
the appload.task("layout")any name of its own

The engine's phase names are LOAD_PHASES.

  • makeLoadTracker() makes the tracker. task(phase, total?) opens a LoadTask (add, tick, end, and discard, which also takes back what the task ticked, so a phase's done can drop); on(fn) subscribes to every LoadEvent; snapshot() is every phase now (LoadSnapshot); idle(phase?) resolves when nothing (or that phase) is busy; timeline() gives one TimelineEntry per phase and per mark (kind tells them apart). now() is the tracker's time base and origin the performance.now() it started at.
  • Marks are moments, not work: mark(name, t?) records one, marks() lists them and onMark(fn) hears them (LoadMark). LOAD_MARKS names firstFrame and converged (LoadMarkName); the engine never records them itself, since when a page counts as drawn is the page's call. They do not go through on(): a mark is not a phase, so it never makes the tracker busy or shows in snapshot(), and a loading screen written against phase events sees no change.
  • trackRenderer(load) adapts the tracker to the renderer's onLoad callback, which is typed by shape (RendererLoadCallback) so the engine does not need a particular renderer version.
  • formatTimeline(entries) prints the timeline as a plain-text table for logs, with a mark line per mark.

Events are synchronous, sent from inside the reporting call, so a step that blocks the main thread reaches the screen only after it has finished. How to work with that, what idle() does and does not promise, a small consumer, and reading "first frame" and "interactive at" off the timeline are in the manual's Loading screens.

Loading controls

The engine also gives an app controls over its load, and picks no policy with them: each is opt-in, and without them everything loads as before (a first-in, first-out generation queue, an unbounded model cache, model uploads, placement, ground fills and pipeline compiles on the main thread). What to load, in what order and what to wait for stay the game's decisions.

controlAPIwhat it gives
pipeline warm-upRenderer.prepare, deferPipelinescompile the variants a scene needs ahead of its first frame, awaitable, with pipelines timing the real compile
scene workermakeSceneWorker, serveScene, InstanceLayer.commitAsync, uploadBudgetMsthe static set's model encodes and its bake off the main thread, the adds paced over frames after one pool reservation; the newest set wins, and an aborted one leaves the static set unsent (makePlacementWorker / servePlacement are the deprecated, bake-only names)
generation queueGenerateOptions, GeneratorPool pause / resume / reprioritise, respawnOnAbortpriorities, AbortSignal, per-request cache: false, pausing and re-sorting the queue
model cacheopenModelCacheControls, ServeCacheOptionslist, measure, clear and trim the cache, and cap its size by last use
cache capsmaxBytes in ServeCacheOptions and ServeSceneCacheOptions, CACHE_CAP_FLOORa cap the game sizes by measuring; nonsense throws, and a cap under 16 MiB, above the storage quota or smaller than an entry warns once
coarse firstEntityPlacement.scale, keepUnused, PrepareNeeds.scaledcoarse models drawn enlarged as placeholders, swapped for the fine set in one apply by setStatic + commitAsync, the replaced models released once the new set draws
ground readinessChunkedWorld pendingWithin / readyAround / whenReadythe chunks still missing around a point, and a promise for when they are in; the queue re-ranked when the focus moves
ground on workersChunkedWorldOptions fill / boxes / onError, makeChunkFillPool, serveChunks, columnBoxeschunks filled on workers from a description sent once, the main thread only copying bricks in

A few lines each, together:

import { makeInstanceLayer } from "@voxolith/engine";
import { makeGeneratorPool, makeSceneWorker, openModelCacheControls } from "@voxolith/engine/worker";

// Pipeline warm-up, on a renderer made with { deferPipelines: true, onLoad: trackRenderer(load) }.
await renderer.prepare({ instances: true });

// Encoding and placement on a worker; the worker file calls serveScene().
const worker = makeSceneWorker({ spawn: () => new Worker(new URL("./scene.worker.ts", import.meta.url), { type: "module" }) });
const layer = makeInstanceLayer(renderer, { load, worker });
layer.setStatic(scenery);  // returns at once
await layer.commitAsync(); // encodes, adds a few models per frame, bakes

// The generation queue.
const pool = makeGeneratorPool({ spawn, load });
const leave = new AbortController();
const hero = pool.generate(heroSpec, { priority: 10 });                 // ahead of the rest
const forest = pool.generateMany(treeSpecs, { signal: leave.signal });  // leave.abort() cancels it
const preview = pool.generate(previewSpec, { cache: false });           // never cached
pool.pause();                                                           // hold the queue for a cutscene,
pool.resume();                                                          // then let it go again
pool.reprioritise((spec) => -distanceTo(spec));                         // re-sort: nearest first

// Coarse first: 10 vox/m models drawn 10 times enlarged, then the fine set in one swap.
layer.setStatic(sites.map((s) => ({ model: coarse[s.variant].model, scale: 10, x: s.x, y: s.y, z: s.z, base: s.base })));
await layer.commitAsync();
layer.setStatic(sites.map((s) => ({ model: fine[s.variant].model, x: s.x, y: s.y, z: s.z, base: s.base })));
await layer.commitAsync(); // same task as the setStatic; the coarse models are released after

// The ground around the camera, before the first picture (the frame loop keeps stepping).
await world.whenReady(camera.x, camera.z, 1800);

// The model cache; the worker's serveGenerators({ cache: { name, maxBytes } }) caps it.
const cache = await openModelCacheControls("my-game-models");
if (cache) {
  const { entries, bytes } = await cache.usage();
  if (bytes > 100e6) await cache.trim(50e6);
  console.log(`${entries} models cached`);
}

How each behaves, with examples, is in the manual's Managing loading.

On this page