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().
| reporter | option | phase |
|---|---|---|
| the renderer | createRenderer(gpu, scene, { onLoad: trackRenderer(load) }) | shaders, pipelines |
makeGeneratorPool | load in GeneratorPoolOptions | models (with cached) |
makeInstanceLayer | load in InstanceLayerOptions | upload (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 |
makeModelLibrary | load in ModelLibraryOptions | upload |
makeChunkedWorld | load in ChunkedWorldOptions | ground (with fill, one item per chunk, ticked when its bricks are applied; a chunk dropped before that leaves the total) |
| the app | load.task("layout") | any name of its own |
The engine's phase names are LOAD_PHASES.
makeLoadTracker()makes the tracker.task(phase, total?)opens aLoadTask(add,tick,end, anddiscard, which also takes back what the task ticked, so a phase'sdonecan drop);on(fn)subscribes to everyLoadEvent;snapshot()is every phase now (LoadSnapshot);idle(phase?)resolves when nothing (or that phase) is busy;timeline()gives oneTimelineEntryper phase and per mark (kindtells them apart).now()is the tracker's time base andorigintheperformance.now()it started at.- Marks are moments, not work:
mark(name, t?)records one,marks()lists them andonMark(fn)hears them (LoadMark).LOAD_MARKSnamesfirstFrameandconverged(LoadMarkName); the engine never records them itself, since when a page counts as drawn is the page's call. They do not go throughon(): a mark is not a phase, so it never makes the tracker busy or shows insnapshot(), and a loading screen written against phase events sees no change. trackRenderer(load)adapts the tracker to the renderer'sonLoadcallback, 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 amarkline 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.
| control | API | what it gives |
|---|---|---|
| pipeline warm-up | Renderer.prepare, deferPipelines | compile the variants a scene needs ahead of its first frame, awaitable, with pipelines timing the real compile |
| scene worker | makeSceneWorker, serveScene, InstanceLayer.commitAsync, uploadBudgetMs | the 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 queue | GenerateOptions, GeneratorPool pause / resume / reprioritise, respawnOnAbort | priorities, AbortSignal, per-request cache: false, pausing and re-sorting the queue |
| model cache | openModelCacheControls, ServeCacheOptions | list, measure, clear and trim the cache, and cap its size by last use |
| cache caps | maxBytes in ServeCacheOptions and ServeSceneCacheOptions, CACHE_CAP_FLOOR | a 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 first | EntityPlacement.scale, keepUnused, PrepareNeeds.scaled | coarse 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 readiness | ChunkedWorld pendingWithin / readyAround / whenReady | the chunks still missing around a point, and a promise for when they are in; the queue re-ranked when the focus moves |
| ground on workers | ChunkedWorldOptions fill / boxes / onError, makeChunkFillPool, serveChunks, columnBoxes | chunks 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.
Workers
Running Voxolith generators off the main thread with makeGeneratorPool and serveGenerators: packed, transferred results, queue priorities and aborts, the IndexedDB model cache and its controls, the scene worker that encodes models and bakes static placements, its cache of encodings and bakes, cache caps a game sizes itself, and chunk fill workers for a streamed ground.
Input
@voxolith/engine/input: one input per surface, gestures decided on total travel, actions over keys, pad and touch, touch controls, orbit and look cameras.