All examples
Tone mapping and exposure
- Mouse
- Touch
- Gamepad
A renderer works in linear light with no ceiling and a display has a ceiling of one; a tone-mapping curve is the function between them, and in ignifx it lives on `Environment.imageProcessing` and is compiled into the PBR shaders. The ramp of six emissive spheres is the instrument: raising the exposure walks them past the ceiling from the right, and what each curve does with the ones that went past is the whole difference. A world renders through one camera, so this is an A-against-B flip rather than a split screen.

WebGPU: checking…See browser support
Try this
- Raise the exposure to three and flip the comparison on: none clips the ramp flat, ACES rolls it off.
- Set the comparison to Neutral and flip: it keeps more of the amber than ACES does.
- Drop the exposure to a half and the curves converge, because nothing is near the ceiling any more.
Show source
Source
import { Camera, Environment, ENVIRONMENT_ASSET_TYPE, MODEL_ASSET_TYPE, Model } 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 { createLightRig, createStudioFloor } from "../_kit/stage.ts";import { COMPARE_CURVE, createEmissiveRamp, CURVES, FOCUS, GRADING, KEY_INTENSITY, START_CURVE, SUBJECT, twoPlaces,} from "./scene.ts";import type { AssetHandle, EnvironmentAsset, ModelAsset } from "ignifx";/** * Tone mapping and exposure: the last thing that happens to a frame, and the one a game puts in its * settings screen. * * A renderer works in linear light with no ceiling — a metal highlight or an emissive surface is * routinely several times brighter than white — and a display has a ceiling of one. A tone-mapping * curve is the function that maps the first onto the second, and `Environment.imageProcessing` is * where ignifx keeps it: `exposure` multiplies the scene before the curve, `contrast` steepens it, * and `toneMapping` picks the curve. **It is compiled into the PBR shaders**, not applied as a * pass, which is why changing the curve recompiles the scene's pipelines and changing the exposure * is nearly free. (`PostProcessStack.imageProcessing` is the other path, as a real pass over the * finished frame; the two do the same arithmetic in different places.) * * The instrument is the ramp of six emissive spheres: their emissive climbs to exactly one, so * raising the exposure walks them past the display's ceiling from the right, and what each curve * does with the ones that went past is the whole difference between the curves. The corset is there * because a curve has to be judged on a real surface too — cloth, leather and a metal clasp. * * ## Why this is a flip and not a split screen * * A world renders through **one** camera: the enabled `Camera` with the highest `priority` becomes * `scene.camera` and the others draw nothing, so two viewports side by side is not something ignifx * can do today. And the curve is scene state in any case — it is compiled into the materials, not * chosen per camera — so even two views would show the same one. The panel therefore holds two * curves and a toggle that flips between them, which is the honest version of the same comparison. */bootExample({ title: "Tone mapping and exposure", settings: { rendering: { clearColor: { r: 0.02, g: 0.024, b: 0.032, a: 1 }, msaaSamples: 4, }, time: { fixedDeltaTime: 1 / 60 }, }, async setup({ app, panel }) { const eye = app.world.createEntity("Main Camera"); eye.addComponent(Camera, { near: 0.02, far: 200, fov: 34 }); attachOrbit(app, eye, { yaw: 8, pitch: 10, distance: 2.9, target: FOCUS, minDistance: 1.2, maxDistance: 12 }); // 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 model: AssetHandle<ModelAsset> = app.assets.load(SUBJECT.address, { type: MODEL_ASSET_TYPE }); const environment: AssetHandle<EnvironmentAsset> = app.assets.load("environments/studio.environment.json", { type: ENVIRONMENT_ASSET_TYPE, }); await Promise.all([model.promise, environment.promise]); const sky = app.world.createEntity("Environment").addComponent(Environment, { environment }); sky.blur = 0.1; sky.imageProcessing.exposure = GRADING.exposure; sky.imageProcessing.contrast = GRADING.contrast; sky.imageProcessing.toneMapping = CURVES[START_CURVE] ?? "aces"; createStudioFloor(app, { size: 160, environmentIntensity: 0.3 }); // No shadow, and no `features.shadows` above with it: `shadows` is the example that is about // shadow maps, and a cast shadow here would only be one more thing the curve is not doing. createLightRig(app, { focus: FOCUS, shadows: false, keyIntensity: KEY_INTENSITY, keyPosition: { x: -1.1, y: 4.2, z: -1.6 }, rimIntensity: 2.4, }); const subject = app.world.createEntity("Corset"); subject.transform.localPosition.set(0, SUBJECT.height, 0); subject.transform.localScale.set(SUBJECT.scale, SUBJECT.scale, SUBJECT.scale); subject.addComponent(Model, { model, castShadows: true, receiveShadows: false }); createEmissiveRamp(app); // The A/B: two labels, and a flag saying which one the scene is compiled with right now. let curveA = START_CURVE; let curveB = COMPARE_CURVE; let isShowingB = false; /** Applies whichever of the two curves is selected. One assignment; a pipeline rebuild follows. */ const apply = (): void => { sky.imageProcessing.toneMapping = CURVES[isShowingB ? curveB : curveA] ?? "none"; }; panel({ title: "Tone mapping and exposure", groups: [ { label: "Curve", controls: [ select("Tone mapping", Object.keys(CURVES), { value: START_CURVE, change: (label: string): void => { curveA = label; isShowingB = false; apply(); }, }), select("Compare with", Object.keys(CURVES), { value: COMPARE_CURVE, change: (label: string): void => { curveB = label; isShowingB = true; apply(); }, }), toggle("Show the comparison", { value: false, change: (on: boolean): void => { isShowingB = on; apply(); }, }), ], }, { label: "Grading", controls: [ // Exposure multiplies the scene *before* the curve, so it is what decides how much of // the frame the curve has to deal with. Push it up and the ramp clips from the right. slider( "Exposure", { min: 0.2, max: 4, step: 0.05, format: twoPlaces }, bind(sky.imageProcessing, "exposure"), ), slider( "Contrast", { min: 0.5, max: 2, step: 0.05, format: twoPlaces }, bind(sky.imageProcessing, "contrast"), ), ], }, { label: "Frame", collapsed: true, controls: [ readout("Showing", (): string => (isShowingB ? curveB : curveA)), readout("Draw calls", (): string => String(app.renderer.drawCalls)), ], }, ], }); },});/** * The numbers `tone-mapping` is composed with, and the ramp of emissive spheres the curves are read * off. * * @remarks * Split out for the reason `pbr-model/shot.ts` is: what a reader wants from `main.ts` is the * `imageProcessing` record and the four curves, not six material declarations. Every number here is * a composition choice. */import { createMaterialAsset, MeshAsset, MeshRenderer, pbrMaterialDefinition } from "ignifx";import type { App, ColorLike, ToneMappingCurve } from "ignifx";/** The subject: the Khronos Corset, authored at 4 cm and drawn at sixteen times that. */export const SUBJECT = { address: "models/corset.glb", scale: 16, height: 0.46 } as const;/** What the camera looks at, and what the light rig is aimed at. */export const FOCUS = { x: 0, y: 0.54, z: 0 } as const;/** The exposure and contrast the example opens on. */export const GRADING = { exposure: 1.9, contrast: 1.05 } as const;/** The key light, bright enough to put a clipping highlight on the corset's metal. */export const KEY_INTENSITY = 5.2;/** * The tone-mapping curves, by the label the panel shows; the values are `TONE_MAPPING_NAMES`. * * @remarks * `none` clips: anything above one becomes white and every colour in it goes with it. `standard` is * Babylon's own curve. `aces` is what most engines call filmic — it rolls a bright highlight off * towards white instead of cutting it, and desaturates as it goes. `neutral` is the Khronos PBR * neutral curve, which keeps hue and saturation much closer to the input and is what a product shot * usually wants. */export const CURVES: Readonly<Record<string, ToneMappingCurve>> = { None: "none", Standard: "standard", ACES: "aces", Neutral: "neutral",};/** The curve the example opens on, and the one every capture shows. */export const START_CURVE = "ACES";/** The curve the comparison toggle flips to. */export const COMPARE_CURVE = "None";/** How many spheres the emissive ramp has. */const RAMP_COUNT = 6;/** The ramp's geometry: sphere size, spacing, height and how far behind the subject it sits. */const RAMP = { diameter: 0.32, spacing: 0.52, height: 0.52, z: 1.6 } as const;/** How many segments a ramp sphere is built from. */const RAMP_SEGMENTS = 24;/** The ramp's hue: a warm amber, so a clipped step reads as white and an unclipped one as amber. */const RAMP_HUE: ColorLike = { r: 1, g: 0.66, b: 0.3, a: 1 };/** The lowest emissive level in the ramp, in sRGB. */const RAMP_FLOOR = 0.3;/** What a ramp sphere's own surface is: near-black, so only its emissive term shows. */const RAMP_BODY: ColorLike = { r: 0.02, g: 0.02, b: 0.02, a: 1 };/** * Writes a slider's value with two decimals. * * @param value - The multiplier. * @returns The text for the slider's value cell. */export function twoPlaces(value: number): string { return value.toFixed(2);}/** * Builds the ramp of emissive spheres a tone-mapping curve is read off. * * @remarks * Six spheres whose `emissive` climbs from {@link RAMP_FLOOR} to full, standing behind the subject * and drawn on a near-black surface so nothing but the emissive term shows. That is the whole instrument: the exposure * multiplies every one of them, so raising it walks the ramp past one from the right, and what each * curve does with the steps that went past is the difference between the curves. Under `none` they * turn flat white one after another and the amber goes with them; under ACES and Neutral they keep * climbing and keep some of their hue. * * A material's `emissive` is sRGB and clamped into `0…1` when it is decoded * (`Color.srgbToLinear`), so the brightest step here is exactly one — anything past that comes from * the exposure, which is the control the panel gives you. * * @param app - The app the entities and assets belong to. * * @example * ```ts * createEmissiveRamp(app); * ``` */export function createEmissiveRamp(app: App): void { const mesh = MeshAsset.sphere(app, { diameter: RAMP.diameter, segments: RAMP_SEGMENTS }); for (let index = 0; index < RAMP_COUNT; index += 1) { const level = RAMP_FLOOR + ((1 - RAMP_FLOOR) * index) / (RAMP_COUNT - 1); const material = createMaterialAsset( app, pbrMaterialDefinition({ name: `tone-mapping/step-${String(index)}`, baseColor: RAMP_BODY, metallic: 0, roughness: 0.9, emissive: { r: RAMP_HUE.r * level, g: RAMP_HUE.g * level, b: RAMP_HUE.b * level, a: 1 }, environmentIntensity: 0, }), [], ); const x = (index - (RAMP_COUNT - 1) / 2) * RAMP.spacing; const entity = app.world.createEntity(`Step ${String(index)}`, { position: { x, y: RAMP.height, z: RAMP.z } }); entity.addComponent(MeshRenderer, { mesh, materials: [material], castShadows: false, receiveShadows: false }); }}