All examples
Quality tiers
rendering.quality picks a tier: low runs ambient occlusion at half resolution and nothing else; medium adds HDR, TAA and contact shadows; high runs AO at full resolution and allows SSGI; custom, the default, leaves every effect as written. A field the project writes itself wins over the tier. The panel shows what the running tier turned on and why. The tier is read at start, so changing it reloads.
Off airLivePaused
Quality tiers
Lighting & GI · 3D

WebGPU: checking…See browser support
Try this
- Switch from Medium to Low and look at the fence wire and the rods: TAA and contact shadows are gone.
- Pick High and watch the red wall bounce onto the white ball and the floor beside it.
- At High, set Write TAA to off: the tier still runs everything else, without TAA.
Show source code
Source
// `ignifx` does not re-export the tier list (0.16.0), so it comes from `@ignifx/core`, which does.import { RENDERING_QUALITIES } from "@ignifx/core";import { Camera, Environment, PostProcessStack } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { twoPlaces } from "../_kit/format.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, readout, slider, toggle } from "../_kit/panel.ts";import { queryChoice, reloadSelect } from "../_kit/reload.ts";import { loadEnvironment } from "../_kit/stage.ts";import { createSet, FOCUS, SHOT, START_EXPOSURE } from "./scene.ts";import type { PanelBinding } from "../_kit/panel.ts";import type { RenderingQuality } from "@ignifx/core";/** * `rendering.quality`: one project setting that turns the GI Phase 1 effects on or off together, * to fit a GPU budget. * * `"custom"`, the default, changes nothing: every effect is exactly as the project wrote it. A named * tier fills four fields the project did not write — `hdr`, `taa.enabled`, * `ambientOcclusion.enabled` and `ambientOcclusion.quality` — and caps which screen-space effects a * `PostProcessStack` may run. Bloom, tone mapping and auto exposure stay as authored. * * | Tier | HDR | TAA | Ambient occlusion | Contact shadows | SSGI | * | -------- | --- | --- | ----------------- | --------------- | ---- | * | `low` | off | off | half resolution | capped | capped | * | `medium` | on | on | half resolution | allowed | capped | * | `high` | on | on | full resolution | allowed | allowed | * * The table is `@ignifx/core`'s own (`QUALITY_PRESETS` in `src/render/rendering-settings.ts`, * which the package does not export; `RENDERING_QUALITIES`, the list of tiers, it does). {@link * TIERS} below repeats it so the panel can say what the running tier turned on. * * A field the project writes itself wins over the tier: `{ quality: "high", taa: { enabled: false * } }` is `"high"` without TAA. The three override rows write exactly that. The tier is read once, * at `app.start()`, so the tier and the overrides reload the frame; the stack's two toggles are * live, and a toggle the tier caps stays off however it is set. *//** What one named tier turns on: `QUALITY_PRESETS` in `@ignifx/core`'s `rendering-settings.ts`. */interface Tier { /** `rendering.hdr`. */ readonly hdr: boolean; /** `rendering.taa.enabled`. */ readonly taa: boolean; /** `rendering.ambientOcclusion.enabled`. */ readonly ambientOcclusion: boolean; /** `rendering.ambientOcclusion.quality`. */ readonly ambientOcclusionQuality: "half" | "full"; /** Whether a stack's `contactShadows` may run. */ readonly contactShadows: boolean; /** Whether a stack's `ssgi` may run. */ readonly ssgi: boolean;}/** The three named tiers. `"custom"` has none. */const TIERS: Readonly<Record<Exclude<RenderingQuality, "custom">, Tier>> = { low: { hdr: false, taa: false, ambientOcclusion: true, ambientOcclusionQuality: "half", contactShadows: false, ssgi: false }, medium: { hdr: true, taa: true, ambientOcclusion: true, ambientOcclusionQuality: "half", contactShadows: true, ssgi: false }, high: { hdr: true, taa: true, ambientOcclusion: true, ambientOcclusionQuality: "full", contactShadows: true, ssgi: true },};/** An override row: leave the field to the tier, or write it on or off. */const OVERRIDES = ["tier", "on", "off"] as const;/** One of {@link OVERRIDES}. */type Override = (typeof OVERRIDES)[number];/** The tier this page load runs at; `?quality=` picks another. */const QUALITY: RenderingQuality = queryChoice<RenderingQuality>("quality", RENDERING_QUALITIES, "medium");/** The three fields a visitor may write over the tier, from the query. */const WRITTEN = { hdr: queryChoice("hdr", OVERRIDES, "tier"), taa: queryChoice("taa", OVERRIDES, "tier"), ao: queryChoice("ao", OVERRIDES, "tier"),} as const;/** The tier's preset, or `null` for `"custom"`. */const TIER: Tier | null = QUALITY === "custom" ? null : TIERS[QUALITY];/** * Resolves one field the way the engine does: what the project wrote, else the tier, else the * section's default. * * @param written - The override row. * @param fromTier - The tier's value, or `undefined` for `"custom"`. * @returns The value the renderer runs with, and where it came from. */function resolve(written: Override, fromTier: boolean | undefined): { readonly on: boolean; readonly why: string } { if (written !== "tier") { return { on: written === "on", why: "written" }; } return fromTier === undefined ? { on: false, why: "default" } : { on: fromTier, why: "tier" };}/** What the renderer runs with on this page load. */const EFFECTIVE = { hdr: resolve(WRITTEN.hdr, TIER?.hdr), taa: resolve(WRITTEN.taa, TIER?.taa), ao: resolve(WRITTEN.ao, TIER?.ambientOcclusion), // A written `ambientOcclusion` record leaves `quality` to the tier: only `enabled` is written here. aoQuality: TIER?.ambientOcclusionQuality ?? "half",} as const;/** * Whether this page lets the stack ask for SSGI: only when HDR runs. With SSGI on and HDR off, * ignifx 0.16.0 presents a black frame (WebGPU reports the scene colour texture destroyed before * submit), so the example does not ask for it then. A tier never pairs SSGI with HDR off; only a * written `hdr: false` does. */const SSGI_CAN_RUN = EFFECTIVE.hdr.on;/** The project's `rendering` section: the tier, plus only the fields a visitor chose to write. */const RENDERING = { clearColor: { r: 0.04, g: 0.045, b: 0.055, a: 1 }, features: { postProcessing: true, shadows: true }, quality: QUALITY, ...(WRITTEN.hdr === "tier" ? {} : { hdr: WRITTEN.hdr === "on" }), ...(WRITTEN.taa === "tier" ? {} : { taa: { enabled: WRITTEN.taa === "on" } }), ...(WRITTEN.ao === "tier" ? {} : { ambientOcclusion: { enabled: WRITTEN.ao === "on" } }),} as const;/** * Writes one effect's state for the "Running" group. * * @param state - Whether it is on, and where that came from. * @returns E.g. `"on · tier"`. */function describe(state: { readonly on: boolean; readonly why: string }): string { return `${state.on ? "on" : "off"} · ${state.why}`;}/** * Writes a stack effect's state: what the stack asks for, and whether the tier lets it run. * * @param requested - The record's `enabled`. * @param allowed - Whether the tier allows it; `undefined` under `"custom"`. * @returns E.g. `"capped by low"`. */function describeStack(requested: boolean, allowed: boolean | undefined): string { if (!requested) { return "off · stack"; } return allowed === false ? `capped by ${QUALITY}` : "on · stack";}bootExample({ title: "Quality tiers", settings: { rendering: RENDERING }, async setup({ app, panel, afterStart }) { const eye = app.world.createEntity("Main Camera"); eye.addComponent(Camera, { near: 0.05, far: 100, fov: SHOT.fov }); attachOrbit(app, eye, { yaw: SHOT.yaw, pitch: SHOT.pitch, distance: SHOT.distance, target: FOCUS, minDistance: 1.5, maxDistance: 12, }); // Ambient occlusion darkens the environment's light only, so the room needs a probe. const studio = loadEnvironment(app, "studio"); await studio.promise; const sky = app.world.createEntity("Environment").addComponent(Environment, { environment: studio }); sky.imageProcessing.exposure = START_EXPOSURE; sky.imageProcessing.toneMapping = "agx"; createSet(app); // The stack asks for both screen-space effects at every tier; the tier decides which run. SSGI // at sixteen samples and full resolution, as in `screen-space-gi`: the engine's defaults (eight, // half) are visibly grainy, since nothing denoises the bounce over time. const post = eye.addComponent(PostProcessStack); post.ssgi.intensity = 0.6; post.ssgi.samples = 16; post.ssgi.halfResolution = false; afterStart((): void => { post.contactShadows.enabled = true; post.ssgi.enabled = SSGI_CAN_RUN; }); /** * Binds a toggle to a stack record's `enabled`, opening on: the record reads `false` until * `afterStart` switches it on. * * @param record - The `contactShadows` or `ssgi` record. * @param start - The value the page opens with. * @param allowed - Whether the record may be switched on at all on this page load. * @returns The binding. */ const enabled = (record: { enabled: boolean }, start: boolean, allowed = true): PanelBinding<boolean> => ({ value: start, change: (on: boolean): void => { record.enabled = on && allowed; }, read: (): boolean => record.enabled, }); // Per-task GPU timing, so the Frame group can show what the tier costs where the device allows it. app.renderer.profileTasks = true; panel({ title: "Quality tiers", groups: [ { label: "Project settings", controls: [ reloadSelect("Quality", "quality", RENDERING_QUALITIES, QUALITY), reloadSelect("Write HDR", "hdr", OVERRIDES, WRITTEN.hdr), reloadSelect("Write TAA", "taa", OVERRIDES, WRITTEN.taa), reloadSelect("Write AO", "ao", OVERRIDES, WRITTEN.ao), ], }, { label: "Running", controls: [ readout("HDR", (): string => describe(EFFECTIVE.hdr)), readout("TAA", (): string => (EFFECTIVE.taa.on && !EFFECTIVE.hdr.on ? "needs HDR" : describe(EFFECTIVE.taa))), readout("AO", (): string => (EFFECTIVE.ao.on ? `${EFFECTIVE.aoQuality} · ${EFFECTIVE.ao.why}` : describe(EFFECTIVE.ao))), readout("Contact shadows", (): string => describeStack(post.contactShadows.enabled, TIER?.contactShadows)), readout("SSGI", (): string => (SSGI_CAN_RUN ? describeStack(post.ssgi.enabled, TIER?.ssgi) : "needs HDR")), ], }, { label: "Stack", controls: [ toggle("Contact shadows", enabled(post.contactShadows, true)), toggle("SSGI (beta)", enabled(post.ssgi, SSGI_CAN_RUN, SSGI_CAN_RUN)), slider("Exposure", { min: 0.2, max: 3, step: 0.05, format: twoPlaces }, bind(sky.imageProcessing, "exposure")), ], }, { label: "Frame", collapsed: true, controls: [ readout("Draw calls", (): string => String(app.renderer.drawCalls)), readout("GPU frame", (): string => { const ms = app.renderer.gpuFrameTimeMs; return ms > 0 ? `${ms.toFixed(2)} ms` : "no GPU timer"; }), readout("GPU tasks", (): string => { const timings = app.renderer.taskTimings(); return timings.status === "available" ? String(timings.tasks.length) : timings.status; }), ], }, ], }); },});/** * The set `quality-tiers` looks at: one corner of a sunlit room with something in it for every * effect a tier can turn on. * * @remarks * Each prop is there for one effect. The crates stacked into the corner are what ambient occlusion * darkens; the steel rods lying on the floor are what contact shadows pin down; the red wall in full * sun is what SSGI bounces onto the floor and the white wall; the picket fence and its wire are * what TAA stops crawling. HDR has no prop of its own: it is what TAA and SSGI run on. */import { Light, MeshAsset } from "ignifx";import { paint, place } from "../_kit/props.ts";import type { Point3 } from "../_kit/stage.ts";import type { App } from "ignifx";/** The point the camera orbits. */export const FOCUS: Point3 = { x: -0.7, y: 0.6, z: 0.9 };/** The opening shot. */export const SHOT = { fov: 44, yaw: 30, pitch: 16, distance: 5.6 } as const;/** The exposure the example opens on. */export const START_EXPOSURE = 0.9;/** Where the walls stand, in metres from the origin. */const ROOM = 2.6;/** How tall the walls are, in metres. */const WALL_HEIGHT = 3.6;/** The crates in the corner: centre and edge length, in metres. Stacked, so their creases meet. */const CRATES: readonly (readonly [number, number, number, number])[] = [ [-2.0, 0.3, 2.0, 0.6], [-1.4, 0.25, 2.15, 0.5], [-2.05, 0.85, 2.05, 0.5], [-2.1, 0.25, 1.4, 0.5],];/** * The fence along the white wall: where it starts and ends along X, its Z, the gap between * pickets, its height and a picket's thickness, in metres. */const FENCE = { from: -0.4, to: 2.4, z: 1.8, gap: 0.12, height: 0.9, thickness: 0.025 } as const;/** * Builds the floor, the two walls, the props and the sun. * * @param app - The app the entities and the assets belong to. * @returns The sun, which contact shadows march towards. */export function createSet(app: App): Light { const box = MeshAsset.box(app, { size: 1 }); const floor = paint(app, "tiers/floor", { r: 0.85, g: 0.83, b: 0.78, a: 1 }, { roughness: 0.85 }); const red = paint(app, "tiers/red-wall", { r: 0.82, g: 0.12, b: 0.08, a: 1 }, { roughness: 0.9 }); const white = paint(app, "tiers/white-wall", { r: 0.86, g: 0.86, b: 0.84, a: 1 }, { roughness: 0.9 }); place(app, "Floor", box, floor, { at: { x: ROOM, y: -0.05, z: 0 }, scale: { x: ROOM * 6, y: 0.1, z: ROOM * 4 }, castShadows: false, }); place(app, "Red wall", box, red, { at: { x: -ROOM - 0.05, y: WALL_HEIGHT / 2, z: 0 }, scale: { x: 0.1, y: WALL_HEIGHT, z: ROOM * 2 }, }); place(app, "White wall", box, white, { at: { x: ROOM, y: WALL_HEIGHT / 2, z: ROOM + 0.05 }, scale: { x: ROOM * 4, y: WALL_HEIGHT, z: 0.1 }, }); // Ambient occlusion: crates pushed into the corner, so wall, floor and crate all meet in creases. const crate = paint(app, "tiers/crate", { r: 0.72, g: 0.6, b: 0.44, a: 1 }, { roughness: 0.8 }); for (const [x, y, z, size] of CRATES) { place(app, "Crate", box, crate, { at: { x, y, z }, scale: { x: size, y: size, z: size } }); } // SSGI: a white ball in the sun beside the red wall, to catch its colour on the shadow side. const matte = paint(app, "tiers/matte", { r: 0.9, g: 0.9, b: 0.9, a: 1 }, { roughness: 0.7 }); place(app, "Ball", MeshAsset.sphere(app, { diameter: 1, segments: 32 }), matte, { at: { x: -1.2, y: 0.4, z: 0.2 }, scale: { x: 0.8, y: 0.8, z: 0.8 }, }); // Contact shadows: rods lying flat, whose contact is finer than any shadow map texel. const rod = MeshAsset.cylinder(app, { height: 1, diameter: 0.06, tessellation: 16 }); const steel = paint(app, "tiers/steel", { r: 0.5, g: 0.52, b: 0.55, a: 1 }, { metallic: 1, roughness: 0.35 }); for (const [x, z, yaw] of [ [0.2, 0.0, 20], [0.6, -0.35, 70], [-0.4, 0.2, -35], ] as const) { // A cylinder stands along Y: a quarter turn about Z lays it on the floor. place(app, "Rod", rod, steel, { at: { x, y: 0.03, z }, rotation: { x: 0, y: yaw, z: 90 } }); } // TAA: a picket fence and one wire, thinner than a pixel a few metres away. const wood = paint(app, "tiers/picket", { r: 0.86, g: 0.84, b: 0.78, a: 1 }, { roughness: 0.8 }); for (let x = FENCE.from; x <= FENCE.to; x += FENCE.gap) { place(app, "Picket", box, wood, { at: { x, y: FENCE.height / 2, z: FENCE.z }, scale: { x: FENCE.thickness, y: FENCE.height, z: FENCE.thickness }, }); } const length = FENCE.to - FENCE.from; place(app, "Rail", box, wood, { at: { x: FENCE.from + length / 2, y: FENCE.height * 0.7, z: FENCE.z - 0.03 }, scale: { x: length, y: 0.05, z: 0.02 }, }); const wire = paint(app, "tiers/wire", { r: 0.2, g: 0.2, b: 0.22, a: 1 }, { metallic: 1, roughness: 0.4 }); place(app, "Wire", box, wire, { at: { x: FENCE.from + length / 2, y: FENCE.height + 0.15, z: FENCE.z }, scale: { x: length, y: 0.008, z: 0.008 }, castShadows: false, }); // A low sun from the open side, onto the floor and the red wall. const sunEntity = app.world.createEntity("Sun", { position: { x: 5, y: 5, z: -2.5 } }); sunEntity.transform.lookAt({ x: -1.5, y: 0, z: 1 }); const sun = sunEntity.addComponent(Light, { type: "directional", intensity: 3, color: { r: 1, g: 0.93, b: 0.82, a: 1 }, }); sun.shadows.enabled = true; sun.shadows.technique = "pcf"; sun.shadows.mapSize = 2048; sun.shadows.normalBias = 0.02; return sun;}