ignifx
All examples

Model loading

Models3D

  • Mouse
  • Touch
  • Gamepad

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.

A wooden street lamp with a glass lantern hanging from its arm, standing on a dark grid floor and casting a long soft shadow to the right.
Open standalone

` devtools

WebGPU: checking…

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.

Read the guide

Show source code

Source

main.ts
import { Camera, Environment, MODEL_ASSET_TYPE, Model } from "ignifx";import { bootExample } from "../_kit/boot.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";/** * Assign loading asset handles to models and display their progress until delivery. * Pair every load with a release; zero-reference assets are collected after `gcDelay` or `gc()`. * Await the opening asset before startup for a settled first frame. Later loads arrive in `PreUpdate`. *//** How the panel writes a fraction: `progress` is `0…1`, bytes-weighted where sizes are known. */const PERCENT = 100;/** Bytes in a kibibyte, for the Held readout. */const BYTES_PER_KIB = 1024;/** * The clear colour, which is the site's dark `--bg` a shade deeper. * * @remarks * `studio.environment.json` loads its `.env` with the skybox off — the probe's cube map is a * mid-grey softbox room, which is right as light and wrong as a background — so what shows behind * the subject is this colour and nothing else. */const CLEAR_COLOR = { r: 0.043, g: 0.059, b: 0.094, a: 1 };/** 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: {      clearColor: CLEAR_COLOR,      msaaSamples: 4,      // Read once, when `app.start()` registers the scene; asking afterwards is `IGX-0704`.      features: { shadows: true },    },    time: { fixedDeltaTime: 1 / 60 },  },  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,      // 24 degrees, not the 16 this started at: with a 38-degree vertical field of view the top of      // the frame is 19 degrees above centre, so any pitch under that leaves the ground plane's      // horizon — and a band of empty clear colour above it — in shot.      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 });    // Wide enough that its far edge is past the vanishing line at this camera pitch, so the frame    // reads as a floor rather than as a tile in the void. One grid cell is one metre at any size.    const ground = await createGridGround(app, { size: 60 });    // 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")      // `skybox` is decided when the `.env` loads, not here: `studio.environment.json` declares      // `skyboxEnabled: false`, so the component is told the same thing rather than left at its      // default and reported as `IGX-0711`. The background is the clear colour.      .addComponent(Environment, { environment, clearColor: CLEAR_COLOR, skybox: { enabled: false, size: 20 } });    sky.imageProcessing.toneMapping = "aces";    sky.imageProcessing.exposure = EXPOSURE;    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 => `${((held.get(current)?.progress ?? 0) * PERCENT).toFixed(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())),          ],        },      ],    });  },});
subjects.ts
/** * 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,    },  };}

Uses:app.assets.loadAssetHandleModelEnvironmentMeshAsset.groundapp.assets.gc

Assets:Lantern — CC0 1.0, sbtron for Microsoft, and Frank GalliganAvocado — CC0 1.0, MicrosoftWater Bottle — CC0 1.0, MicrosoftStudio environment — CC-BY 4.0, Babylon.js contributors