ignifx
All examples

Physically based rendering

Rendering3D

  • Mouse
  • Touch
  • Gamepad

Explore a 3D ship rendered with physically based materials. Environment lighting creates reflections on the hull, while bloom gives the engines their glow. Adjust the lighting and materials in the panel, or switch models to compare the results.

A detailed metal spaceship crossing the frame nose-down, seen from slightly below and in front: a warm key light picks out its worn hull plating, a rim light behind it burns the wing edges, its engines glow, and behind it a faint pool of light falls away to black at the corners.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Drag to orbit and scroll to zoom. Watch the reflections change as you move.
  • Choose Corset in the Model menu to compare a different model.
  • Rotate the environment or add blur to change the reflections on the ship.

Read the guide

Show source code

Source

main.ts
import { Camera, Environment, MODEL_ASSET_TYPE, Model, PostProcessStack } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, readout, select, slider, toggle } from "../_kit/panel.ts";import { createBackdrop, createStudioFloor, createStudioRig, loadEnvironment } from "../_kit/stage.ts";import { CURVES, GROUND, SHOT, START_CURVE, START_SUBJECT, SUBJECT_RADIUS, SUBJECTS } from "./shot.ts";import type { AssetHandle, ModelAsset } from "ignifx";/** * The render path every 3D ignifx game uses: a glTF model with physically based materials, * image-based lighting from a prefiltered environment, and bloom over ACES tone mapping. * * The order of the steps is the lesson. Build the cheap parts; `load` every asset and **await it * before `app.start()`**, where a completed load settles at once rather than waiting for a frame's * `PreUpdate`; attach the components that need it; then `app.start()`, and only then switch the * effects on — a bloom task recorded before the scene is registered samples the swapchain, which * WebGPU rejects. `bootExample` owns that last bit of timing, through `afterStart`. * * The composition — which model, where it stands, the lens, the grading, the floor and backdrop * tones — is `shot.ts`, next to this file and shown beside it on the example's page. Splitting it * out is what keeps this file about the engine: every number in it was measured against a rendered * candidate, and none of it is a lesson about ignifx. */bootExample({  title: "Physically based rendering",  settings: {    rendering: {      clearColor: GROUND.clear,      msaaSamples: 4,      // Both features are read once, when `app.start()` registers the scene; asking afterwards is      // `IGX-0704`. `postProcessing` is the one people forget — it renders the scene into an      // offscreen target so an effect has something it is allowed to sample. Without it a      // `PostProcessStack` logs `IGX-0710` and does nothing at all.      features: { shadows: true, postProcessing: true },    },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, panel, afterStart }) {    const start = SUBJECTS[START_SUBJECT] ?? { address: "", scale: 1, height: 0 };    const focus = { x: 0, y: start.height, z: 0 };    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.02, far: 600, fov: SHOT.fov });    // One `frame()` call sets the distance at which a sphere of this radius subtends the camera's    // own field of view, times `padding`.    const orbit = attachOrbit(app, eye, {      yaw: SHOT.yaw,      pitch: SHOT.pitch,      minDistance: 0.6,      maxDistance: 8,      idleDegreesPerSecond: SHOT.idle,    });    orbit.frame({ center: focus, radius: SUBJECT_RADIUS }, SHOT.padding);    const rig = createStudioRig(app, {      focus,      keyIntensity: SHOT.key.intensity,      keyPosition: SHOT.key.at,      fillIntensity: SHOT.fill.intensity,      fillPosition: SHOT.fill.at,      fillColor: SHOT.fill.color,      rimIntensity: SHOT.rim.intensity,      rimPosition: SHOT.rim.at,    });    rig.key.color = SHOT.key.color;    rig.rim.color = SHOT.rim.color;    const floor = createStudioFloor(app, { size: GROUND.floorSize, color: GROUND.floor });    // The two lamps that shape the subject are kept off the floor: their spill would pool behind it    // and give the composition a second subject, and the backdrop is what the eye should find    // there. `Light.exclude` is one assignment, matched on the entity, with no layer to set up.    rig.rim.exclude = [floor.entity];    rig.fill.exclude = [floor.entity];    // The backdrop's pool of light is painted at the centre of its texture, and a sphere's `u` is    // longitude — so turning the sphere is what puts the pool behind the subject instead of    // wherever `u = 0` landed. The angle was found by sweeping it and looking, because which    // meridian Lite's sphere calls `u = 0` is not something to depend on.    const backdrop = await createBackdrop(app, { diameter: GROUND.backdropDiameter });    backdrop.entity.transform.localEulerAngles = { x: 0, y: SHOT.backdropYaw, z: 0 };    // 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(start.address, { type: MODEL_ASSET_TYPE });    const environment = loadEnvironment(app, "studio");    await Promise.all([first.promise, environment.promise]);    // One `Environment` per world; a second logs `IGX-0705`. Its `imageProcessing` record is the    // exposure/tone-mapping path the PBR shaders themselves compile, which is why changing the    // curve recompiles a pipeline and changing the exposure is nearly free. `blur` is what stops a    // roughness-mapped metal hull reading as chrome: a little softens the probe's softboxes into    // reflections you can follow, and zero mirrors the room.    const sky = app.world      .createEntity("Environment")      .addComponent(Environment, { environment, clearColor: GROUND.clear });    sky.imageProcessing.toneMapping = CURVES[START_CURVE] ?? "aces";    sky.imageProcessing.exposure = SHOT.exposure;    sky.blur = SHOT.blur;    const entity = app.world.createEntity("Subject");    const subject = entity.addComponent(Model, { castShadows: true, receiveShadows: false });    const handles = new Map<string, AssetHandle<ModelAsset>>([[START_SUBJECT, first]]);    // 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 new model appears on the frame its delivery lands    // in — the asset lifetime, visible.    const swap = (label: string): void => {      const placement = SUBJECTS[label];      if (placement === undefined) {        return;      }      const handle = handles.get(label) ?? app.assets.load<ModelAsset>(placement.address, { type: MODEL_ASSET_TYPE });      handles.set(label, handle);      subject.model = handle;      entity.transform.localPosition.set(0, placement.height, 0);      entity.transform.localScale.set(placement.scale, placement.scale, placement.scale);      entity.transform.localEulerAngles = placement.pose;      // `active = false` hides, `destroy()` removes (`skills/ignifx/SKILL.md` gotcha 6): the floor      // is switched, not rebuilt, so swapping back costs nothing.      floor.entity.active = placement.floor;    };    swap(START_SUBJECT);    // The chain is built once and switched with `enabled`: a frame graph cannot have a task    // removed. `afterStart` is when the effects go on; see the module comment.    const post = eye.addComponent(PostProcessStack);    post.bloom.threshold = SHOT.bloomThreshold;    post.bloom.weight = SHOT.bloomWeight;    post.bloom.kernel = SHOT.bloomKernel;    post.smaa.threshold = 0.05;    afterStart((): void => {      post.bloom.enabled = true;      post.smaa.enabled = true;    });    panel({      title: "Physically based rendering",      groups: [        {          label: "Subject",          controls: [select("Model", Object.keys(SUBJECTS), { value: START_SUBJECT, change: swap })],        },        {          label: "Grading",          controls: [            slider("Exposure", { min: 0.2, max: 3, step: 0.05 }, bind(sky.imageProcessing, "exposure")),            // Not `bind`: the panel shows the industry's spellings and the field takes the engine's.            select("Tone mapping", Object.keys(CURVES), {              value: START_CURVE,              change: (label: string): void => {                sky.imageProcessing.toneMapping = CURVES[label] ?? "none";              },            }),          ],        },        {          label: "Environment",          controls: [            // Rotating the probe moves every reflection at once: the cheapest lighting control a            // game can offer, and blurring it is the second.            slider("Rotation", { min: 0, max: 360, step: 1 }, bind(sky, "rotation")),            slider("Blur", { min: 0, max: 1, step: 0.02 }, bind(sky, "blur")),          ],        },        {          label: "Bloom",          controls: [            // The component's `enabled`, not `bloom.enabled`: the chain stays recorded and is skipped,            // which is a branch a frame rather than a rebuild per click — and it reads `true` here, during            // `setup`, while `bloom.enabled` is still false until `afterStart`. The threshold slider is            // live: the recorded task's uniforms are re-uploaded the frame after it moves.            toggle("Enabled", bind(post, "enabled")),            slider("Threshold", { min: 0, max: 1.5, step: 0.02 }, bind(post.bloom, "threshold")),          ],        },        {          label: "Frame",          collapsed: true,          controls: [            readout("Draw calls", (): string => String(app.renderer.drawCalls)),            readout("Post-process tasks", (): string => String(post.taskCount)),          ],        },      ],    });  },});
shot.ts
/** * The composition: what `pbr-model` shows, where it stands, how it is graded, and what the frame is * cleared to. Everything a designer tunes, in one file, so `main.ts` is only about the engine. * * @remarks * Every number here was chosen against rendered candidates rather than guessed * (`website/plan/08-execution.md` §4.4), and several of them are measurements — the two subjects' * authored extents, the pose that puts the nose where the frame wants it, the lamp ratios, and the * bloom's cost in clipped pixels. The viewer page shows this file beside `main.ts`, which is the * point: it is the part a reader is most likely to want to change. Two numbers here are load * bearing outside it: `exposure` and `pitch`, which `_tools/make-backdrop-texture.ts` solves its * bytes and centres its pool of light for. */import type { ToneMappingCurve } from "ignifx";/** A rotation in degrees about each axis, as the engine's public API takes one. */export interface Euler {  /** Pitch, in degrees. */  readonly x: number;  /** Yaw, in degrees. */  readonly y: number;  /** Roll, in degrees. */  readonly z: number;}/** One subject the Model select can show. */export interface Subject {  /** The model's address, under the asset root the examples build points the plugin at. */  readonly address: string;  /** The uniform scale the entity is drawn at. */  readonly scale: number;  /** How high the entity's origin sits above the floor, in metres. */  readonly height: number;  /** The pose the subject is placed in, in degrees. */  readonly pose: Euler;  /**   * Whether the studio floor is shown under this subject. The ship is key art — a ship in flight   * has no floor, and any lit floor puts a horizon across a frame whose edges are meant to be   * quiet — while the corset is a physical object that wants one to stand on.   */  readonly floor: boolean;}/** * The subjects, by the label the panel shows. The ship is the mascot and the default; the corset is * here because swapping a model at runtime is one assignment, and that is worth showing. * * @remarks * The two are authored at wildly different scales, which is the normal state of borrowed art: the * ship spans 0.488 x 0.504 x 0.980 metres and needs none, the Khronos Corset spans * 0.0390 x 0.0578 x 0.0390 and needs 16, and `_tools/compress-model.ts` prints both extents on * every run. `height` floats the ship clear of the floor, so it reads as a ship rather than as a * model on a shelf. * * `pose` is the ship's attitude, and it is what makes a static mesh read as something in motion: * fourteen degrees of nose-down and eight of bank, with the hull turned fifteen degrees off the * camera's axis so the frame gets a front three-quarter — the nose out of the frame's lower left * and towards the viewer, the length of the hull running back up to the right, and the engines * highest. The ship's nose is its `-Z` end (measured: the cross-section tapers from 1,757 square * millimetres at `+Z` to 43 at `-Z`), and the pose is applied in Babylon's order, `Y * X * Z`, so * the three numbers were solved rather than swept: against this camera they put the nose 0.74 of a * unit to the left of the frame's centre, 0.15 down, and 0.65 towards the viewer — 49 degrees off * the camera's axis, which is what a three-quarter is — with the deck tipped 0.09 away, so the * frame gets a sliver of the belly. */export const SUBJECTS: Readonly<Record<string, Subject>> = {  Ship: { address: "models/ignifx-ship.glb", scale: 1, height: 0.7, pose: { x: -14, y: 15, z: 8 }, floor: false },  Corset: { address: "models/corset.glb", scale: 16, height: 0.46, pose: { x: 0, y: 0, z: 0 }, floor: true },};/** The subject the example opens on, and the one every capture shows. */export const START_SUBJECT = "Ship";/** The radius of the sphere the camera frames, in metres. Both subjects fit inside it. */export const SUBJECT_RADIUS = 0.62;/** * The opening shot: the pose and the grading every capture is taken from — the viewer page opens on * it, the poster and the golden are it, and the first input moves away from all of it. * * @remarks * Key art, not a turntable. A long lens (26 degrees) flattens the perspective and lets the hull * fill the frame from further away; the camera sits *below* the ship's centre line and looks a * little upward (`pitch` is negative), which is what shows the profile and the belly rather than * the deck; `padding` crops into the bounding sphere, because a ship's silhouette is a long * diagonal and not a ball. `idle` is zero: a still is a still — the field is in `orbit.ts` for * anything that wants a turntable. * * The lighting is three lamps, all directional, so their positions are only directions and no * intensity falls off with distance: a warm key from the front upper left, a cooler fill from the * opposite side so a panel line has two edges, and a warm-orange rim from behind and to the right, * which is what burns the wing edges and the engine housings out of the dark. The ratios are * measured rather than conventional. The fill is 0.42 of the key rather than the usual third, * because the subject is dark, worn metal and a third left its shadow side unreadable at the size * a poster is seen at; the rim is 1.5 times the key rather than the two to three a product shot * would use, because at 2.4 times — rendered, compared side by side — it flared the whole upper * silhouette into one line and took the panel work with it. * * **The lamps are bright and the exposure is low, and that is deliberate.** Exposure multiplies * everything in the frame, the unlit backdrop included, so raising it to brighten the ship raises * the backdrop with it and the frame stops being key art. Raising the lamps instead brightens only * what they touch, which is why the ship can be the brightest thing in the frame by a wide margin. * The low exposure has a second benefit worth knowing before changing it: it leaves * `_tools/make-backdrop-texture.ts` a wide range of bytes to paint the vignette's falloff with — * that file solves its bytes for this number and says so. * * The bloom is small on purpose, and how small was measured rather than judged: at the threshold * and weight below, 1,485 pixels of a 1280x720 frame reach 250 or brighter, against 797 with * `bloomWeight` at zero. So the effect is 700 pixels — the glints on the engine housings, the rim * along the wing edges, and nothing else. A threshold above about 0.6 does nothing at all, because * it is compared against the *linear* offscreen target rather than the graded frame. */export const SHOT = {  fov: 26,  yaw: 35,  pitch: -8,  padding: 0.6,  idle: 0,  exposure: 0.8,  blur: 0.08,  /** The backdrop sphere's own yaw, which is what puts its pool of light behind the subject. */  backdropYaw: 54,  /** The warm key, from the front upper left. */  key: { intensity: 24, at: { x: -0.56, y: 1.6, z: -2.34 }, color: { r: 1, g: 0.851, b: 0.659, a: 1 } },  /** The cooler directional fill, opposite the key at a third of its intensity. */  fill: { intensity: 10, at: { x: 2.44, y: 0.8, z: 0 }, color: { r: 0.68, g: 0.79, b: 1, a: 1 } },  /** The rim, behind the ship and to the right, at two and a half times the key. */  rim: { intensity: 36, at: { x: -0.12, y: 1.1, z: 2.6 }, color: { r: 1, g: 0.72, b: 0.44, a: 1 } },  bloomThreshold: 0.35,  bloomWeight: 0.24,  bloomKernel: 48,} as const;/** * The floor and the backdrop, in the site's own dark tones (`02-design-system.md` §2.3: `--bg` is * `#0D1015`), because the page wraps the frame in its ember glow. * * @remarks * `clear` is what shows where the backdrop sphere does not reach, and it is the same near-black the * backdrop's own edges are painted to render as. `floor` — the corset's floor, not the ship's — * is dark and bluish on purpose: the floor is lit, the probe's neutral bounce desaturates it, and * what it has to do is disappear into the backdrop's near-black edges while still catching the * subject's shadow. The backdrop itself is a soft pool of light behind the subject falling to * near-black at the frame's edges: the engine has no vignette pass, so the falloff is painted into * `textures/backdrop.png` — which solves its own bytes for the tones it has to *render* as — and * turned to face the camera by `main.ts`. */export const GROUND = {  clear: { r: 0.008, g: 0.01, b: 0.016, a: 1 },  floor: { r: 0.004, g: 0.02, b: 0.075, a: 1 },  floorSize: 16,  backdropDiameter: 20,} as const;/** * The tone-mapping curves, by the label the panel shows; the engine's own names are the lower-case * values (`TONE_MAPPING_NAMES`). ACES is what most engines call "filmic" and rolls a bright * highlight off instead of clipping it to white. */export const CURVES: Readonly<Record<string, ToneMappingCurve>> = {  None: "none",  Standard: "standard",  ACES: "aces",  Neutral: "neutral",};/** The label {@link CURVES} opens on. */export const START_CURVE = "ACES";

Uses:ModelEnvironmentPostProcessStackapp.assets.loadLight.excludeCamera

Assets:ignifx mascot ship — © 2026 Astrum Forge Studios Pty Ltd, Astrum Forge StudiosCorset — CC0 1.0, MicrosoftStudio environment — CC-BY 4.0, Babylon.js contributors