ignifx
GitHubnpm · soon
All examples

Model loading

Models3D

  • Mouse
  • Touch
  • Gamepad

Three sample models of wildly different sizes, on one metre of grid per metre of world. `app.assets.load` answers with a handle straight away — still loading, with a progress between zero and one — and the Handle group is that handle's own fields, read four times a second: its address, its state, its progress, how many holders it has, and how many bytes the models it is holding weigh. Every load needs one release, and Release the others is what that looks like.

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 the model: a handle asked for the first time is still loading when it is assigned, so the model lands a frame or two later.
  • Press Release the others and watch the Held readout drop: the bytes go back when the last holder lets go.
  • Turn the grid off to read the silhouette, then drag to orbit and scroll to zoom.

Read the guide

Show source

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";/** * Loading a glTF, watching it arrive, swapping it for another, and handing the memory back. * * The asset service is reference counted, and this example is the whole of what that means in * practice. `app.assets.load` answers with an `AssetHandle` **immediately** — still `"loading"`, * with a `progress` between 0 and 1 — so a game assigns it and carries on; the panel's Handle group * is that handle's own fields, read four times a second. Assigning a still-loading handle to * `Model.model` is allowed and is the normal thing to do: the model appears on the frame its * delivery lands in. * * The two rules worth taking away: * * 1. **Every `load` needs one `release`.** Two loads of one address answer with the same handle and *    a higher `refCount`, and the value is unloaded when the count reaches zero — after *    `assets.gcDelay` seconds, or at once when something calls `assets.gc()`. "Release the others" *    below does exactly that, and the Held readout is the bytes it gave back. * 2. **Await before `start()`, assign after it.** A load that finishes before the loop is running *    settles at once, so the opening subject is on screen in the first frame and a capture of it is *    reproducible. A load that finishes afterwards settles in `PreUpdate`, which is what makes the *    progress readout worth having. * * The measured bounds of the three models, and the arithmetic that stands each of them on the floor * at one height, are in `subjects.ts` beside this file. *//** 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