Loading screens
Show what a Voxolith page is loading with makeLoadTracker, the engine's per-phase load events, time the load with formatTimeline, and mark the first frame.
A big scene takes a while to load: shaders link, models are generated or read from the cache, instances are uploaded and indexed, the ground streams in. The engine reports what is loading; it draws nothing. There is no loading screen in the engine, no weighting of one phase against another, and no display strings: the app decides what to show, and whether to show anything.
Everything goes through one object, a LoadTracker
from makeLoadTracker(). The engine's
loaders report into it when you hand it to them, your own steps report into it the same way, and
the same records read back as a load timeline for measuring.
This page is about seeing the load. Changing it (warming pipelines up, encoding and placing on a worker, ordering and cancelling generation, managing the model cache) is Managing loading.
What is reported
Work is grouped into phases, plain strings. The engine's own loaders use the names in
LOAD_PHASES:
| phase | reported by | one item is | notes |
|---|---|---|---|
shaders | the renderer, through trackRenderer | the WESL link in createRenderer | cached for every renderer on the page, so short after the first |
pipelines | the renderer, through trackRenderer | the shader module or one pipeline variant (the label names it, e.g. "fragment+instances") | variants a later frame needs compile then, and reopen the phase; prepare compiles them ahead |
models | makeGeneratorPool({ load }) | one generation request (the label is the generator id) | cached counts results the worker's model cache served; an aborted request leaves the total |
upload | makeInstanceLayer(renderer, { load }) (or makeModelLibrary) | a model uploaded the first time it is seen; with a scene worker, one brick of it | on the main thread, uploads in one synchronous run form one span. With a scene worker, each model is one task from its encode request to its add, weighted by its bricks (millions at 100 voxels per metre), counting up with the encode and held one short until the add; a cancelled or failed encode is discarded. An encoding the scene cache served ticks its bricks as cached at its add |
placement | makeInstanceLayer(renderer, { load }) | one static placement | commit() ticks it all at once; with commitAsync it is one task from the bake request to its apply, and counts up with the worker's progress (one short of the total until the apply). A superseded or aborted bake is discarded, so done can drop. A bake the scene cache served ticks every placement as cached at its apply |
ground | makeChunkedWorld({ load }) | one chunk | focus adds what it queues, step ticks what it builds |
Each change reaches listeners as a LoadEvent:
the phase, a kind (start when the phase goes from idle to busy, progress when a count
changes, end when its last open task ends), the phase's running done, total and cached
counts (totals over its whole life, not deltas), an optional label for the item that just
finished, and t, milliseconds since the tracker was made.
A renderer linking its shaders and making its shader module, then a pool generating two trees (one from the model cache), sends this (times shortened):
load.on((e) => console.log(e));
// { phase: "shaders", kind: "start", done: 0, total: 0, cached: 0, t: 0 }
// { phase: "shaders", kind: "progress", done: 0, total: 1, cached: 0, t: 0 }
// { phase: "shaders", kind: "progress", done: 1, total: 1, cached: 0, t: 41 }
// { phase: "shaders", kind: "end", done: 1, total: 1, cached: 0, t: 41 }
// { phase: "pipelines", kind: "start", done: 0, total: 0, cached: 0, t: 42 }
// { phase: "pipelines", kind: "progress", done: 0, total: 1, cached: 0, t: 42 }
// { phase: "pipelines", kind: "progress", done: 1, total: 1, cached: 0, t: 44, label: "module" }
// { phase: "pipelines", kind: "end", done: 1, total: 1, cached: 0, t: 44 }
// { phase: "models", kind: "start", done: 0, total: 1, cached: 0, t: 52 }
// { phase: "models", kind: "progress", done: 0, total: 2, cached: 0, t: 52 }
// { phase: "models", kind: "progress", done: 1, total: 2, cached: 1, t: 61, label: "voxolith/tree.broadleaf" }
// { phase: "models", kind: "progress", done: 2, total: 2, cached: 1, t: 1830, label: "voxolith/tree.broadleaf" }
// { phase: "models", kind: "end", done: 2, total: 2, cached: 1, t: 1830 }The renderer's phases start with no count and add one item per step, while the pool's requests
each open with one; either way the totals are running totals, and end always reads
done === total.
Phases count different things (one shader link, forty models, three thousand placements), so the
tracker never adds them into one percentage for you. snapshot().done and .total are plain sums;
if you want a single bar, weigh the phases yourself.
Wiring it up
One tracker per page, passed to every loader that should report:
import { createRenderer } from "@voxolith/renderer";
import { makeChunkedWorld, makeInstanceLayer, makeLoadTracker, trackRenderer } from "@voxolith/engine";
import { makeGeneratorPool } from "@voxolith/engine/worker";
const load = makeLoadTracker();
const renderer = await createRenderer(gpu, scene, { onLoad: trackRenderer(load) });
const pool = makeGeneratorPool({ spawn, load });
const layer = makeInstanceLayer(renderer, { load });
const world = makeChunkedWorld({ target: renderer, size, chunk: 64, seed, generate, load });The renderer does not depend on the engine, so it takes a plain callback,
RendererOptions.onLoad;
trackRenderer(load) turns the tracker into
one.
Steps of your own are phases too. Open a task, tick it, end it:
const layout = load.task("layout"); // no total: just busy until it ends
planVillage();
layout.end();
const houses = load.task("houses", sites.length); // a known total
for (const site of sites) {
build(site);
houses.tick(1, { label: site.kind });
}
houses.end();A LoadTask can add(n) to what it expects as it
learns more, and tick past its total simply grows it. end() drops anything it expected but
never ticked, so a cancelled load counts only what it finished and an idle phase always reads
done === total. Several tasks may share a phase (two pools both reporting models): their
counts add up and the phase is busy while any of them is open.
discard() closes a task and takes back what it ticked as well, as if it had never run: for work
whose progress was shown but whose result is thrown away. The instance layer does this with a
superseded or aborted placement bake, so its placement row's done can go down. A display
that assumes counts only grow (an animation that never reverses) should allow for that.
A consumer
The smallest useful loading display: one row per phase, with its counts and a bar. Replace it with whatever your game looks like; nothing in the engine assumes this shape.
import type { LoadTracker } from "@voxolith/engine";
/** One row per phase in `list`, updated on every event. Returns an unsubscribe. */
export function showPhases(load: LoadTracker, list: HTMLElement, names: Record<string, string> = {}): () => void {
const rows = new Map<string, { text: HTMLElement; bar: HTMLProgressElement }>();
return load.on((e) => {
let row = rows.get(e.phase);
if (!row) {
const li = list.appendChild(document.createElement("li"));
row = { text: li.appendChild(document.createElement("span")), bar: li.appendChild(document.createElement("progress")) };
rows.set(e.phase, row);
}
const cached = e.cached > 0 ? ` (${e.cached} cached)` : "";
row.text.textContent = `${names[e.phase] ?? e.phase} ${e.done}/${e.total}${cached}`;
if (e.total > 0) {
row.bar.max = e.total;
row.bar.value = e.done;
} else row.bar.removeAttribute("value"); // no count yet: an indeterminate bar
});
}Phase names are identifiers, so map them to your own words ({ models: "Growing trees" }) rather
than showing them raw. snapshot() gives every phase at once, if you would rather redraw the whole
list from scratch on each event.
Knowing when it is done
load.idle() resolves when every phase is idle; load.idle("models") waits for one. It resolves
at once when nothing is busy, including when nothing has started yet, so call it after the
load has been kicked off:
const trees = pool.generateMany(specs); // queued: `models` is busy now
await load.idle(); // waits for it
const forest = await trees;The tracker only knows about work that has been reported. Between two steps (the models are in and
the layout has not opened its task yet) every phase is idle, and an idle() called then resolves.
So your own code is what knows the load is over; idle() is for waiting on the loaders you have
already started. The examples wait for idle(), then two frames (without
prepare, the first frame compiles
pipelines lazily, which reopens pipelines), then idle() again, before they report the load.
Growing totals and reopening phases
Totals are what has been asked for so far, not a promise of what is coming. A generator pool adds
one item per request as it is queued, so its total grows while you queue more. A chunked world adds
the chunks each focus newly queues; freed chunks are not counted back, and revisiting one queues
and counts it again. When the queue empties, ground ends; when the camera moves on, it starts
again with a new start event and its counts carry on from where they were.
load.on((e) => e.phase === "ground" && console.log(e.kind, `${e.done}/${e.total}`));
// 64-voxel chunks; a radius of 192 voxels queues 32 of them.
world.focus(256, 256, 192); // start 0/0, progress 0/32
while (world.step(8) > 0); // progress 1/32 ... 32/32, end 32/32
world.focus(448, 256, 192); // the camera moved: start 32/32, progress 32/50 (18 new chunks)
while (world.step(8) > 0); // progress 33/50 ... 50/50, end 50/50A page streaming in its frame loop calls step once a frame instead of looping, and the same
events arrive spread over those frames.
So a phase that ended is not finished for good. A display that should go away after the first
load unsubscribes (the function on returns) once the page is up, rather than waiting for every
phase to end forever; streaming ground after that is the page's business, not the loading screen's.
The main thread stalls
Events are sent synchronously, from inside the loader's own call. A step that blocks the main
thread (an upload, the static placement, a pipeline compile, a layout of your own) still sends its
start and its end, but the page only repaints after it has finished: a "placing..." line
written from the start event appears together with the end, or not at all.
To get a message on screen before a blocking step, open its phase, let the browser paint, then run the step:
/** Resolves after the next frame has been painted. */
const painted = () => new Promise<void>((r) => requestAnimationFrame(() => setTimeout(r, 0)));
const scenery = load.task("scenery");
await painted(); // the "scenery" line is on screen now
layer.setStatic(statics);
layer.commit(); // uploads and placement: blocks for seconds at fine scales
scenery.end();One animation frame is not enough
Code that resumes from a requestAnimationFrame callback still runs before that frame is
painted, so await new Promise(requestAnimationFrame) followed by a blocking step paints the
message only after the step. Wait for the next task after the frame (setTimeout inside the
callback, as above) or for a second frame. Measured in headless Chrome with a 600 ms stall: text
written before a single frame's wait was first painted after the stall; with either of the two
waits it was painted as the stall began.
An app phase around several blocking engine steps (as scenery wraps upload and placement
above) is the way to put one message up for all of them. This is a generic pattern; the examples
no longer need it for uploads or placement (below).
Two of those steps need not block, if the app opts in (see Managing loading):
- Uploads and placement. With a scene worker,
setStaticonly sends the models it has not seen to be encoded, andawait layer.commitAsync()adds them a few per frame and bakes the static set off the main thread. The page keeps painting throughout, so theuploadandplacementrows count up like any other, and neither needs the paint-first wait. With the older placement worker ({ placement }) only the bake moves:setStaticstill uploads on the main thread, so keep the wait in front of it. - Pipelines. Without
prepare, thepipelinesphase times only the synchronouscreateShaderModuleandcreate*Pipelinecalls. Chrome compiles a pipeline when it is first used, so the long stall of a first frame lands in that frame and is not inpipelines: expect a pause after the last phase ends and before the first picture, and hold the loading screen until the first frame is drawn if the pause matters. WithdeferPipelinesandawait renderer.prepare(), the compile happens behind the loading screen andpipelinesmeasures it, from each call to the pipeline being ready.
Measuring
The same records are a load timeline. timeline()
returns one TimelineEntry per phase, in the
order they first started: its first start, latest end, how long it was actually busy, in how
many spans (a reopened phase stretches one entry), and its counts. Marks
are rows of the same timeline.
formatTimeline prints it as a table for a
log:
import { formatTimeline } from "@voxolith/engine";
await load.idle();
console.log(formatTimeline(load.timeline()));
// phase start took done/total cached
// shaders 0 ms 41 ms 1/1
// models 52 ms 1830 ms 12/12 12
// first-frame 1904 ms markIn the examples, the pages with a perf overlay (world, valley, nightwood,
creature, instances and swarm) log that table to the console under ?perf and put the total load
time, the longest phase and "first frame at X s" in the overlay's label. Every example page
exposes window.loadTimeline() once it has loaded, for benches and scripts, and
window.loadOrigin beside it (the tracker's origin, below).
Marks: first frame and converged
Phases say how long each step took; they do not say when the user first saw something. A
mark records that moment: load.mark(name, t?)
stores a name and a time, in the tracker's time base (ms since it was made, as
load.now() reads it). The engine never
records one itself: when a page counts as drawn or settled is the page's call.
LOAD_MARKS names the two the examples use:
| mark | name | when |
|---|---|---|
LOAD_MARKS.firstFrame | first-frame | the page's first picture of its scene has been submitted, where the loading screen lifts |
LOAD_MARKS.converged | converged | after the first frame, the picture has stopped converging for the first time (renderer.converging() first reads false): what is on screen is the settled image |
A mark is not a phase. It never makes the tracker busy, never shows in snapshot(), and does not
reach on() listeners, so a loading screen that redraws on every event and waits on idle() is
unchanged by it. Listen with onMark,
and read them back with marks() or as
rows of timeline(), where kind is "mark", start and end are its time and the counts
are 0:
import { LOAD_MARKS } from "@voxolith/engine";
// Where the page draws its first picture:
renderer.render();
screen.ready();
load.mark(LOAD_MARKS.firstFrame);
const poll = () => {
if (renderer.converging()) requestAnimationFrame(poll);
else load.mark(LOAD_MARKS.converged);
};
requestAnimationFrame(poll);
// Anywhere else:
const off = load.onMark((m) => {
if (m.name === LOAD_MARKS.firstFrame) console.info(`first frame at ${(m.t / 1000).toFixed(1)} s`);
if (m.name === LOAD_MARKS.converged) off();
});Pass t for a moment noticed later than it happened (load.mark("playable", load.now() - 16)).
A name may be marked more than once, and every mark is kept. Marks are plain strings, so add your
own ("playable") beside these. The converged poll needs the page's loop to keep rendering
while converging() holds (makeFrameLoop's converging option, or a continuous loop), or the
mark waits for the next redraw; a view that never settles never marks it.
Interactive at
The first frame is when the page looks ready. It is interactive once the main thread is free as
well: a long task after the first frame (a lazily compiled pipeline, a blocking upload) is a
stall the user feels as a frozen first drag. Long tasks come from a PerformanceObserver in
performance.now() time; load.origin
is the performance.now() at which the tracker was made, so subtracting it puts them on the
timeline:
import { LOAD_MARKS } from "@voxolith/engine";
// Start it early, before the load, so no long task is missed.
const longTasks: { start: number; ms: number }[] = [];
new PerformanceObserver((list) => {
for (const e of list.getEntries()) longTasks.push({ start: e.startTime, ms: e.duration });
}).observe({ type: "longtask", buffered: true });
// Later, once the load has settled:
const timeline = load.timeline();
const first = timeline.find((e) => e.kind === "mark" && e.phase === LOAD_MARKS.firstFrame);
if (first) {
const after = longTasks.map((t) => ({ start: t.start - load.origin, ms: t.ms })).filter((t) => t.start >= first.start);
const interactive = Math.max(first.start, ...after.map((t) => t.start + t.ms));
console.info(`first frame ${(first.start / 1000).toFixed(2)} s, interactive ${(interactive / 1000).toFixed(2)} s`);
}That definition (the end of the last long task after the first frame) is one choice, and the
longtask entry type is Chromium's only. In the workspace, bun run gpu-bench <app> --load <pages>
does the same from outside the page, with window.loadTimeline and window.loadOrigin: it
prints the timeline, the long tasks, and "first frame at X s; after it N long task(s)" with the
longest.
The examples' loading screen
The example pages are one fuller way to consume these events. It is the
examples' own code, not an engine feature: copy it, restyle it or replace it. It lives in
examples/src/shared/:
loading-screen.tsdraws the screen from a tracker and the steps a page lists, and holdsSTEPS, the examples' words for the phases every page shares;loading.tshasnextFrame(a frame, then a task: the wait from above),step(load, phase, fn), which opens an app phase, lets a frame paint, runsfnand always closes the phase,prepared(renderer.prepare(needs)), which logs a failed asynchronous compile and leaves it to the first frame so a page never hangs on its screen,markFrames(load, renderer), called besidescreen.ready(), which marks the first frame and convergence, andreportLoadTimelinefor measuring;placement.tshassceneWorker(), the page's one scene worker (its file,placement.worker.ts, callsserveScene()), andcommitStatic(layer), which awaitslayer.commitAsync()and, if an encode or the bake fails, logs a warning, uploads what is missing and places synchronously withcommit().
Every page creates its renderer with deferPipelines: true and awaits
prepared(renderer.prepare(needs)) before its first frame, so the pipelines row is the real
compile. The pages with static scenery (valley, nightwood, instances) give their instance layer
the scene worker, call setStatic directly (it no longer blocks) and commit with
commitStatic, so neither the upload nor the placement blocks the main thread. Their upload row
is STEPS.encodedUpload.
export const SHOW_AFTER_MS = 150;
export interface LoadingStep { phase: string; label: string; background?: boolean; count?: boolean }
export const STEPS; // device, shaders, pipelines, models, upload, encodedUpload, placement, ground
export interface LoadingScreenOptions { title: string; blurb?: string; steps: readonly LoadingStep[]; parent?: HTMLElement }
export interface LoadingScreen { ready(): void; fail(err: unknown): void; setSteps(steps: readonly LoadingStep[]): void; dispose(): void }
export const NO_SCREEN: LoadingScreen;
export function showLoadingScreen(load: LoadTracker, opts: LoadingScreenOptions): LoadingScreen;Each page lists its steps
The screen shows what the page says it does, in the page's order and words, next to the page's
loading code. A LoadingStep
names a tracker phase and the label to show for it. Two optional fields change how it shows:
background: true for work the page does not wait for (below), and
count: false, which hides the done/total numbers on its row and in the corner pill and keeps
the bar, the cached count and the time. That is for a phase counted in units nobody reads, such
as an upload encoded on a scene worker, which the engine counts in bricks; its cached count then
reads as a share, cached when all of it came from a cache, else 30% cached. STEPS has one for
each phase the pages share:
| step | phase | label |
|---|---|---|
STEPS.device | device (from boot) | Starting WebGPU |
STEPS.shaders | shaders | Linking shaders |
STEPS.pipelines | pipelines | Compiling pipelines |
STEPS.models | models | Generating models |
STEPS.upload | upload | Uploading models |
STEPS.encodedUpload | upload, with count: false | Uploading models (the bar only) |
STEPS.placement | placement | Placing instances |
STEPS.ground | ground | Building the ground |
A page lists them as they are, spreads one to change it ({ ...STEPS.ground, background: true }),
or writes its own for its app phases. The valley's list, for example:
const loadingSteps = (rats: boolean, coarse: boolean): LoadingStep[] => [
STEPS.device,
{ phase: "terrain", label: "Shaping the terrain" },
{ phase: "layout", label: "Laying out the valley" },
coarse ? COARSE_STEPS.models : STEPS.models,
...(rats ? [{ phase: "creatures", label: "Generating rats" }] : []),
STEPS.shaders,
STEPS.pipelines,
coarse ? COARSE_STEPS.upload : STEPS.encodedUpload,
STEPS.placement,
{ ...STEPS.ground, background: true },
...(coarse ? FINE_STEPS : []),
];
const app = await boot("valley", { blurb: "The valley, refined and streamed", steps: loadingSteps(false, false) });
// ... later, once the scale (and so the rats and the coarse stage) is known:
if (RAT_COUNT || KC > 1) screen.setSteps(loadingSteps(RAT_COUNT > 0, KC > 1));On a desktop at 50 and 100 voxels per metre the valley and nightwood load
coarse first. COARSE_STEPS (from the
examples' shared/detail.ts) are the shared steps named for the coarse stage ("Generating coarse
models", "Uploading coarse models"), and FINE_STEPS are three background rows for the fine
stage, "Generating detail", "Uploading detail" (count: false) and "Placing detail", on the
phases fine.models, fine.upload and fine.placement. The page's tracker is wrapped
(stagedLoad) so that the loaders' tasks for the fine stage are filed under those phases, and the
page marks fine once the swap has applied.
Other pages list what they do. Nightwood: device, models (or its coarse row), shaders,
pipelines, encodedUpload (or its coarse row), placement, ground, and the fine rows with
the coarse stage. Instances: device, terrain ("Shaping the terrain"), trees ("Growing
trees"), creatures ("Generating rats"), shaders, pipelines, ground, then encodedUpload and
placement, or with ?stamp a "Stamping the trees" row instead of those two (that mode uses no
worker).
Whether the valley has rats depends on its scale, and the scale on the GPU (a phone gets a lighter
profile), so it boots with the list without them and calls setSteps once it knows. Rows of
phases already under way keep their progress.
The pages' shared startup, boot(name, { blurb?, steps }), makes the tracker and the screen and
returns them with the GPU context: { gpu, canvas, info, load, screen }. A page passes load to
its loaders and calls screen.ready() when its first picture is up (the valley calls it once its
scenery is placed and the ground within 18 m of the view is built, and streams the rest behind
it). A page that waits for the user
before loading anything (minecraft-region) passes boot(name, { loading: false }), gets
NO_SCREEN, and opens a screen of its own for each dropped file.
What it shows
- Nothing, for a quick page. The overlay covers the canvas but only fades in after
SHOW_AFTER_MS(150 ms), so a light page is up before it would appear. - One row per listed step, in the listed order. A step whose phase has not started yet shows
as pending: dimmed, with no bar. A counted phase has a bar,
done/total(left out withcount: false),n cached(cachedor30% cachedwithcount: false) and its time; an uncounted one (total0) has a sliding bar. Finished rows are dimmed, with a tick. - Only what is listed. A phase the page does not list is not shown, but it is still on the
tracker, so it stays in the timeline for
window.loadTimeline()and?perf. In development builds,ready()warns about listed foreground steps that never started, since the list is then out of step with the page. - Background steps. A step marked
backgroundis work the page does not wait for (the valley's streamed ground). Its row carries a "continues in the background" note, it does not count towards the overall bar, and afterready()it moves to the corner pill. - An overall bar, with no number: the unweighted mean of the foreground steps'
done / total, pending ones at 0. It only moves backwards when a counted phase's total grows. That is deliberate: it reports what is known rather than guessing a weighting, and it shows no percentage because a mean of unlike phases would claim a precision it lacks. - At
ready(), the overlay stops taking input at once and fades out. A small corner pill, which never blocks input, then lists the listed steps still busy for more than 150 ms (streaming ground, say) and goes away when they are idle. - Errors: an uncaught error or unhandled rejection before
ready(), or a call tofail(err), shows "Loading failed: …" on the overlay (or in the pill afterready()).
It uses only the brand tokens, so it follows the dark and light themes, and it respects
prefers-reduced-motion.
See also
- Managing loading for the controls: pipeline warm-up, placement on a worker, the generation queue and the model cache
- Engine: load events
- Workers and caching for the
modelsphase and its cache hits - Streaming for the
groundphase - Instances for
uploadandplacement - Performance for making the load shorter
- API:
makeLoadTracker,LoadTracker,LoadTask,LoadEvent,LoadSnapshot,trackRenderer,formatTimeline,LOAD_MARKS,LoadMark,TimelineEntry,RendererLoadHook
Workers and caching
Run generators on a pool of Web Workers with makeGeneratorPool and serveGenerators, and keep generated models in IndexedDB between visits.
Managing loading
The controls a Voxolith app has over its load: awaitable pipeline warm-up with prepare, model encoding and static placement on a scene worker with commitAsync, a generation queue with priorities, aborts and pausing, a model cache and a scene cache sized by the game, coarse models first with one swap to the fine ones, and ground filled on workers with readiness around the camera.