voxolith

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:

phasereported byone item isnotes
shadersthe renderer, through trackRendererthe WESL link in createRenderercached for every renderer on the page, so short after the first
pipelinesthe renderer, through trackRendererthe 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
modelsmakeGeneratorPool({ 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
uploadmakeInstanceLayer(renderer, { load }) (or makeModelLibrary)a model uploaded the first time it is seen; with a scene worker, one brick of iton 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
placementmakeInstanceLayer(renderer, { load })one static placementcommit() 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
groundmakeChunkedWorld({ load })one chunkfocus 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/50

A 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, setStatic only sends the models it has not seen to be encoded, and await layer.commitAsync() adds them a few per frame and bakes the static set off the main thread. The page keeps painting throughout, so the upload and placement rows count up like any other, and neither needs the paint-first wait. With the older placement worker ({ placement }) only the bake moves: setStatic still uploads on the main thread, so keep the wait in front of it.
  • Pipelines. Without prepare, the pipelines phase times only the synchronous createShaderModule and create*Pipeline calls. Chrome compiles a pipeline when it is first used, so the long stall of a first frame lands in that frame and is not in pipelines: 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. With deferPipelines and await renderer.prepare(), the compile happens behind the loading screen and pipelines measures 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     mark

In 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:

marknamewhen
LOAD_MARKS.firstFramefirst-framethe page's first picture of its scene has been submitted, where the loading screen lifts
LOAD_MARKS.convergedconvergedafter 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.ts draws the screen from a tracker and the steps a page lists, and holds STEPS, the examples' words for the phases every page shares;
  • loading.ts has nextFrame (a frame, then a task: the wait from above), step(load, phase, fn), which opens an app phase, lets a frame paint, runs fn and 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 beside screen.ready(), which marks the first frame and convergence, and reportLoadTimeline for measuring;
  • placement.ts has sceneWorker(), the page's one scene worker (its file, placement.worker.ts, calls serveScene()), and commitStatic(layer), which awaits layer.commitAsync() and, if an encode or the bake fails, logs a warning, uploads what is missing and places synchronously with commit().

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:

stepphaselabel
STEPS.devicedevice (from boot)Starting WebGPU
STEPS.shadersshadersLinking shaders
STEPS.pipelinespipelinesCompiling pipelines
STEPS.modelsmodelsGenerating models
STEPS.uploaduploadUploading models
STEPS.encodedUploadupload, with count: falseUploading models (the bar only)
STEPS.placementplacementPlacing instances
STEPS.groundgroundBuilding 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:

examples/src/valley/main.ts
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 with count: false), n cached (cached or 30% cached with count: false) and its time; an uncounted one (total 0) 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 background is 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 after ready() 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 to fail(err), shows "Loading failed: …" on the overlay (or in the pill after ready()).

It uses only the brand tokens, so it follows the dark and light themes, and it respects prefers-reduced-motion.

See also

On this page