All examples
Model loading
Load three glTF models and inspect their loading state, progress and memory estimates. Switch between models, then release the ones you no longer need. The panel shows which assets the example still holds.
Off airLivePaused
Model loading
Models · 3D

WebGPU: checking…See browser support
Try this
- Switch models and watch the loading status in the panel.
- Press Release the others and watch the Held count decrease.
- Turn the grid off to read the silhouette, then drag to orbit and scroll to zoom.
Show source code
Source
import { Camera, Environment, MODEL_ASSET_TYPE, Model } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { percent } from "../_kit/format.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, button, readout, select, toggle } from "../_kit/panel.ts";import { createGridGround, createLightRig, loadEnvironment } from "../_kit/stage.ts";import { fit, START_SUBJECT, SUBJECT_RADIUS, SUBJECTS, TARGET_HEIGHT } from "./subjects.ts";import type { AssetHandle, ModelAsset } from "ignifx";/** * Loading a model is asking for a handle and assigning it. `app.assets.load` answers at once with a * handle that is still loading; a `Model` given one shows nothing until it arrives, then shows it on * that frame. The panel reads the handle's state and progress while that happens. * * Every load is paired with a release. An asset nobody holds is collected `gcDelay` seconds later, * or at once with `gc()`, which is what "Release the others" does. The opening model is awaited * before `app.start()`, so the first frame already has it; a model asked for later arrives at the * start of a frame, in `PreUpdate`. *//** Bytes in a kibibyte, for the Held readout. */const BYTES_PER_KIB = 1024;/** The exposure the studio probe is graded at, chosen so the grid reads without flattening a metal. */const EXPOSURE = 1.15;bootExample({ title: "Model loading", settings: { rendering: { // The site's dark `--bg` a shade deeper. The studio probe loads with its skybox off, so this // colour is all that shows behind the subject. clearColor: { r: 0.043, g: 0.059, b: 0.094, a: 1 }, // Read once, when `app.start()` registers the scene; asking afterwards is `IGX-0704`. features: { shadows: true }, }, }, async setup({ app, panel }) { const focus = { x: 0, y: TARGET_HEIGHT / 2, z: 0 }; const eye = app.world.createEntity("Main Camera"); eye.addComponent(Camera, { near: 0.05, far: 200, fov: 38 }); const orbit = attachOrbit(app, eye, { yaw: 34, // Steeper than half the 38-degree field of view, so the floor fills the frame behind the subject. pitch: 24, minDistance: 0.9, maxDistance: 12, idleDegreesPerSecond: 6, }); orbit.frame({ center: focus, radius: SUBJECT_RADIUS }, 1.15); createLightRig(app, { focus, keyIntensity: 2.2, rimIntensity: 0.9, shadowDarkness: 0.28 }); // Both loads are awaited before the loop runs, so neither needs a frame pumped to settle. The // `.env` also pulls the BRDF table Lite requires, from the `rendering.brdfLut` default address. const first: AssetHandle<ModelAsset> = app.assets.load(SUBJECTS[START_SUBJECT]?.address ?? "", { type: MODEL_ASSET_TYPE, }); const environment = loadEnvironment(app, "studio"); await Promise.all([first.promise, environment.promise]); const sky = app.world .createEntity("Environment") // `studio.environment.json` loads with its skybox off, so the component says the same rather // than being reported as `IGX-0711`. .addComponent(Environment, { environment, skybox: { enabled: false, size: 20 } }); sky.imageProcessing.toneMapping = "aces"; sky.imageProcessing.exposure = EXPOSURE; // After the `Environment`, so the fade reuses it rather than making a second one. const ground = await createGridGround(app, { fade: true }); const entity = app.world.createEntity("Subject"); const subject = entity.addComponent(Model, { castShadows: true, receiveShadows: true }); /** Every handle this example is holding, by the label that loaded it. One release each. */ const held = new Map<string, AssetHandle<ModelAsset>>([[START_SUBJECT, first]]); let current = START_SUBJECT; /** * Shows one subject, loading it the first time it is asked for. * * @param label - A key of `SUBJECTS`. */ const show = (label: string): void => { const next = SUBJECTS[label]; if (next === undefined) { return; } // Swapping a model is one assignment: `Model` compares the loaded asset with the one it // instantiated and rebuilds its subtree on the next sync. A handle asked for the first time // is still loading when it is assigned, so the model appears on the frame its delivery lands // in — the asset lifetime, visible. const handle = held.get(label) ?? app.assets.load<ModelAsset>(next.address, { type: MODEL_ASSET_TYPE }); held.set(label, handle); current = label; subject.model = handle; const placement = fit(next, TARGET_HEIGHT); entity.transform.localPosition.set(placement.offset.x, placement.offset.y, placement.offset.z); entity.transform.localScale.set(placement.scale, placement.scale, placement.scale); }; show(START_SUBJECT); /** Drops every handle but the one on screen, and collects now rather than in `gcDelay` seconds. */ const releaseOthers = (): void => { for (const [label, handle] of held) { if (label === current) { continue; } handle.release(); held.delete(label); } // Without this the values live for `assets.gcDelay` seconds (five by default) in case // something asks for them again, which is the right default and the wrong demonstration. app.assets.gc(); app.log.info("model-loading: holding", held.size, "model handles"); }; /** * How many bytes the manifest says the held models weigh. * * @returns The sum over every held address, from `AssetManifestEntry.bytes`. */ const heldBytes = (): number => { let total = 0; for (const handle of held.values()) { const entry = app.assets.manifest.entries.find((candidate) => candidate.address === handle.address); total += entry?.bytes ?? 0; } return total; }; /** * How many of the held handles have not arrived yet. * * @returns The count; zero except in the frames right after a swap to a model this page has * not asked for before. */ const loadingCount = (): number => { let count = 0; for (const handle of held.values()) { if (handle.state === "loading") { count += 1; } } return count; }; panel({ title: "Model loading", groups: [ { label: "Subject", controls: [ select("Model", Object.keys(SUBJECTS), { value: START_SUBJECT, change: show }), button("Release the others", releaseOthers), // `active = false` hides an entity, `destroy()` removes it (`skills/ignifx/SKILL.md` // gotcha 6). The grid is switched off to look at a silhouette, not rebuilt. toggle("Grid", bind(ground.entity, "active")), ], }, { label: "Handle", controls: [ readout("Address", (): string => held.get(current)?.address ?? "—"), readout("State", (): string => held.get(current)?.state ?? "released"), readout("Progress", (): string => percent(held.get(current)?.progress ?? 0)), readout("Holders", (): string => String(held.get(current)?.refCount ?? 0)), readout( "Held", (): string => `${String(held.size)} model${held.size === 1 ? "" : "s"} · ${(heldBytes() / BYTES_PER_KIB).toFixed(0)} KiB`, ), ], }, { label: "Frame", collapsed: true, controls: [ readout("Draw calls", (): string => String(app.renderer.drawCalls)), readout("Still loading", (): string => String(loadingCount())), ], }, ], }); },});/** * The three models `model-loading` swaps between, and the arithmetic that stands each one on the * floor at the same height. * * @remarks * Borrowed art arrives at whatever scale and origin its author worked in, and these three are as * far apart as sample models get: the avocado is 63 millimetres tall with its origin on its base, * the water bottle is 260 millimetres tall with its origin in its middle, and the lantern is a * 25-unit street lamp whose origin is under the *post* while the lamp itself hangs out at * `x = 9.6`. Every figure below was printed by `_tools/compress-model.ts` and re-measured from the * committed `.glb` with `@gltf-transform/core`'s `getBounds` on 2026-09-08, which is why the table * is data rather than three guessed numbers: {@link fit} turns it into the one scale and the one * offset that make the model {@link TARGET_HEIGHT} tall, centred over the origin, standing on the * grid. The camera then never has to move, so the example is about the loading and not about the * framing. *//** A three-component point in glTF units, as the bounds tables below record one. */export interface Extent { /** Along X. */ readonly x: number; /** Along Y. */ readonly y: number; /** Along Z. */ readonly z: number;}/** One model the panel's select can show. */export interface Subject { /** The address, under the asset root the examples build points the plugin at. */ readonly address: string; /** The lowest corner of the model's own bounding box, in the units the file is authored in. */ readonly min: Extent; /** The highest corner of the same box. */ readonly max: Extent;}/** How the entity is placed so a subject stands centred on the floor. */export interface Placement { /** The uniform scale to draw the model at. */ readonly scale: number; /** Where the entity's origin goes, in metres. */ readonly offset: Extent;}/** * How tall every subject is drawn, in metres. * * @remarks * Chosen against the grid the kit's ground draws, which is one cell per metre: at 0.9 m a subject * is most of a cell tall, so the frame says how big it is without a ruler in it — and all three * subjects being one height is what lets a single camera pose frame the avocado and the street lamp * equally well. */export const TARGET_HEIGHT = 0.9;/** * The subjects, by the label the panel shows, in ascending file size. * * @remarks * The order is the committed file sizes — 364 960, 570 720 and 851 508 bytes — so the select reads * as a cost as well as a list. All three are CC0; `assets/ATTRIBUTION.md` records each licence, its * digest and the day it was confirmed. */export const SUBJECTS: Readonly<Record<string, Subject>> = { Avocado: { address: "models/avocado.glb", min: { x: -0.021_281, y: -0.000_048, z: -0.013_809 }, max: { x: 0.021_281, y: 0.062_848, z: 0.013_809 }, }, "Water bottle": { address: "models/water-bottle.glb", min: { x: -0.054_45, y: -0.130_22, z: -0.054_45 }, max: { x: 0.054_45, y: 0.130_22, z: 0.054_45 }, }, Lantern: { address: "models/lantern.glb", min: { x: -3.9224, y: 0.1839, z: -2.3157 }, max: { x: 11.5688, y: 25.8481, z: 2.3157 }, },};/** * The subject the example opens on, and the one every capture shows. * * @remarks * The lantern rather than the first item of the select: it is the one whose silhouette still reads * at the size the gallery draws a poster, and it is the one whose authored scale is most obviously * not metres. */export const START_SUBJECT = "Lantern";/** * The radius of the sphere the camera frames, in metres. * * @remarks * Half of {@link TARGET_HEIGHT} would be the sphere around a subject centred on the origin, but a * subject *stands* on the floor, so the sphere the camera has to contain is centred half way up and * has to reach the widest subject's corners. The lantern is the widest: 15.49 units across against * 25.66 tall, which is 0.543 m across once it is 0.9 m tall. */export const SUBJECT_RADIUS = 0.62;/** * Turns a subject's authored bounds into the scale and offset that stand it on the floor. * * @remarks * Three lines of arithmetic, and they are the reason this file exists: a `Model` instantiates the * glTF's own node tree under its entity, so the only handles on where the art ends up are the * entity's scale and position. Height sets the scale; the box's horizontal centre and its lowest * point set the offset. Nothing here asks the file to have been authored sensibly. * * @param subject - The subject, with the bounds measured from its committed `.glb`. * @param height - How tall the model should be drawn, in metres. * @returns The uniform scale, and where to put the entity's origin. */export function fit(subject: Subject, height: number): Placement { const spanY = subject.max.y - subject.min.y; const scale = spanY <= 0 ? 1 : height / spanY; return { scale, offset: { x: (-(subject.min.x + subject.max.x) / 2) * scale, y: -subject.min.y * scale, z: (-(subject.min.z + subject.max.z) / 2) * scale, }, };}