All examples
Draco-compressed model
A .glb with KHR_draco_mesh_compression loads like any other model: the loader sees the extension and decodes the mesh with Google's Draco decoder, which @ignifx/vite-plugin serves from the game's own origin and fetches only when a model needs it. The same corset is here twice, plain and Draco-compressed. Draco shrinks the mesh data by four fifths and leaves the textures alone; the panel shows both sizes and both load times.
Off airLivePaused
Draco-compressed model
Models · 3D

WebGPU: checking…See browser support
Try this
- Switch File to Plain and back: the corset looks the same, from a file a third smaller.
- Compare the mesh sizes with the file sizes: the textures, not the mesh, are most of what is left.
- Read the Draco load time: the first one includes fetching and compiling the decoder.
Show source code
Source
import { Camera, Environment, MODEL_ASSET_TYPE, Model } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, readout, select, toggle } from "../_kit/panel.ts";import { createGridGround, createLightRig, loadEnvironment } from "../_kit/stage.ts";import type { App, AssetHandle, ModelAsset } from "ignifx";/** * A Draco-compressed glTF model, decoded in the browser by the decoder the game serves itself. * * `KHR_draco_mesh_compression` stores a mesh's vertices and indices as a Draco bitstream instead of * plain arrays. The file is smaller on the wire; the price is a WebAssembly decoder and a decode * step when the model loads. Since 0.16.0, `@ignifx/core` ships Google's Draco decoder and * `@ignifx/vite-plugin` serves it from the game's own origin, like the KTX2 and meshopt decoders, * so nothing is fetched from a CDN. It is fetched only when a model needs it: load the plain file * first and no Draco byte crosses the network. * * The code is the same either way. `app.assets.load` with the address, a `Model` given the handle — * the loader sees the extension and runs the decoder. The two files here are the same corset: * `models/corset.glb` is the Khronos sample after `_tools/compress-model.ts`, and * `models/corset.draco.glb` is that file through `gltf-transform draco` with its defaults (14-bit * positions, 10-bit normals, 12-bit texture coordinates). Draco touches geometry only, so the three * textures, 935 KB of the 1.6 MB, are byte for byte the same in both; the mesh data shrinks from * 662 KB to 123 KB. The quantisation is lossy and, at this size, invisible. *//** The two files, by the label the select shows. */const FILES = { Draco: "models/corset.draco.glb", Plain: "models/corset.glb",} as const;/** One of {@link FILES}' labels. */type FileLabel = keyof typeof FILES;/** * The mesh data in each file, in bytes: every buffer view that is not an image. Measured from the * committed files' GLB JSON chunk (`examples/assets/ATTRIBUTION.md` records how), because a loaded * model no longer knows how it was stored. */const MESH_BYTES: Readonly<Record<FileLabel, number>> = { Draco: 122_846, Plain: 662_184 };/** The decoder's two files, as the manifest lists them. */const DECODER_FILES = ["draco_decoder.js", "draco_decoder_gltf.wasm"] as const;/** The corset is authored in centimetres: 0.0578 units tall, its base on its origin. */const AUTHORED_HEIGHT = 0.057842;/** How tall the corset stands, in metres. */const TARGET_HEIGHT = 1.3;/** The exposure the studio probe is graded at. */const EXPOSURE = 1.15;/** Bytes in a kibibyte. */const KIB = 1024;/** * Writes a byte count in KiB. * * @param bytes - The count. * @returns E.g. `"1,035 KiB"`. */function kib(bytes: number): string { return `${Math.round(bytes / KIB).toLocaleString("en")} KiB`;}/** * Reads a file's size from the asset manifest the plugin wrote at build time. * * @param app - The app. * @param address - The address. * @returns The size in bytes, or `0` when the manifest does not list it. */function manifestBytes(app: App, address: string): number { return app.assets.manifest.entries.find((entry) => entry.address === address)?.bytes ?? 0;}/** One file's load: its handle, and how long it took from asking to having it. */interface Load { readonly handle: AssetHandle<ModelAsset>; /** Milliseconds from `load` to the handle settling; `null` while it is still loading. */ ms: number | null;}bootExample({ title: "Draco model", settings: { rendering: { clearColor: { r: 0.043, g: 0.059, b: 0.094, a: 1 }, 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: 28, pitch: 14, minDistance: 0.8, maxDistance: 10, idleDegreesPerSecond: 8, }); orbit.frame({ center: focus, radius: TARGET_HEIGHT * 0.6 }, 1.1); createLightRig(app, { focus, keyIntensity: 2.2, rimIntensity: 0.9, shadowDarkness: 0.28 }); const loads = new Map<FileLabel, Load>(); /** * Starts loading one file, timing it, unless it is already loading or loaded. * * @param label - Which file. * @returns The load. */ const load = (label: FileLabel): Load => { const known = loads.get(label); if (known !== undefined) { return known; } const started = performance.now(); const entry: Load = { handle: app.assets.load<ModelAsset>(FILES[label], { type: MODEL_ASSET_TYPE }), ms: null }; // The first Draco load also fetches and compiles the decoder; the timing includes that. entry.handle.promise.then( (): void => { entry.ms = performance.now() - started; }, (): void => { entry.ms = Number.NaN; }, ); loads.set(label, entry); return entry; }; // Only the compressed file is asked for at start: the plain one, and its bytes, wait for the select. const first = load("Draco"); const studio = loadEnvironment(app, "studio"); await Promise.all([first.handle.promise, studio.promise]); const sky = app.world .createEntity("Environment") .addComponent(Environment, { environment: studio, skybox: { enabled: false, size: 20 } }); sky.imageProcessing.toneMapping = "aces"; sky.imageProcessing.exposure = EXPOSURE; const ground = await createGridGround(app, { fade: true }); const scale = TARGET_HEIGHT / AUTHORED_HEIGHT; const entity = app.world.createEntity("Corset"); entity.transform.localScale.set(scale, scale, scale); const corset = entity.addComponent(Model, { model: first.handle, castShadows: true, receiveShadows: true }); let shown: FileLabel = "Draco"; /** * Shows one file. The same assignment either way: `Model` does not care how the mesh was stored. * * @param label - The select's label. */ const show = (label: string): void => { if (label !== "Draco" && label !== "Plain") { return; } shown = label; corset.model = load(label).handle; }; /** * Writes one file's load time. * * @param label - Which file. * @returns E.g. `"84 ms"`, or `"not loaded"`. */ const timing = (label: FileLabel): string => { const entry = loads.get(label); if (entry === undefined) { return "not loaded"; } if (entry.ms === null) { return "loading"; } return Number.isNaN(entry.ms) ? "failed" : `${Math.round(entry.ms).toLocaleString("en")} ms`; }; const decoderBytes = DECODER_FILES.reduce((sum, file) => sum + manifestBytes(app, file), 0); panel({ title: "Draco model", groups: [ { label: "Model", controls: [ select("File", Object.keys(FILES), { value: shown, change: show }), toggle("Grid", bind(ground.entity, "active")), ], }, { label: "On the wire", controls: [ readout("Draco file", (): string => kib(manifestBytes(app, FILES.Draco))), readout("Plain file", (): string => kib(manifestBytes(app, FILES.Plain))), readout("Draco mesh", (): string => kib(MESH_BYTES.Draco)), readout("Plain mesh", (): string => kib(MESH_BYTES.Plain)), readout("Decoder", (): string => `${kib(decoderBytes)}, once`), ], }, { label: "Load time", controls: [ readout("Draco", (): string => timing("Draco")), readout("Plain", (): string => timing("Plain")), readout("Showing", (): string => FILES[shown]), ], }, { label: "Frame", collapsed: true, controls: [readout("Draw calls", (): string => String(app.renderer.drawCalls))], }, ], }); },});