ignifx
GitHubnpm · soon
All examples

Tone mapping and exposure

Lighting3D

  • 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.

A leather and cloth corset on a dressmaker's stand, lit from above on a dark studio floor, with a row of six glowing amber spheres behind it climbing from near-black to a pale rolled-off yellow.
Open standalone

` devtools

WebGPU: checking…

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

main.ts
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)),          ],        },      ],    });  },});
scene.ts
/** * 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 });  }}

Uses:Environment.imageProcessingToneMappingCurveModelapp.assets.loadcreateLightRig

Assets:Corset — CC0 1.0, MicrosoftStudio environment — CC-BY 4.0, Babylon.js contributors