ignifx
GitHubnpm · soon
All examples

Roughness and metalness

Models3D

  • Mouse
  • Touch
  • Gamepad

The two numbers that decide what a physically based surface looks like, laid out so the whole parameter space is on screen at once. Metalness runs left to right and roughness bottom to top; the sliders move each axis's two ends, so you can compress the grid onto any patch of the space and compare. The light is a prefiltered studio probe with a modest key and fill behind it, because a metal has no diffuse term and shows only its surroundings — the soft highlights down the right-hand column are the probe's own softboxes, and roughness is what blurs them. Nothing else is loaded: the mesh is `MeshAsset.sphere` and the thirty-six materials are `pbrMaterialDefinition` records edited live, which costs uniform writes and no shader work.

A six-by-six grid of spheres on a dark blue-grey ground: mirror-like spheres reflecting two soft studio panels along the bottom row, evenly diffuse ones along the top, pale plastic on the left and dark metal on the right.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Set both metalness ends to one: every sphere becomes a metal, and the base colour now tints the reflection instead of the surface.
  • Pull Top row down to 0.3 and the roughness axis compresses onto the range a real product is authored in.
  • Rotate the probe, then blur it: a metal has nothing to show but what is around it, so blurring the probe is what turns the bottom-right mirror into satin.
Show source

Source

main.ts
import { Camera, createMaterialAsset, Environment, MeshAsset, MeshRenderer, pbrMaterialDefinition } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, color, readout, slider } from "../_kit/panel.ts";import { createLightRig, loadEnvironment } from "../_kit/stage.ts";import type { PanelSliderControl } from "../_kit/panel.ts";import type { AssetHandle, ColorLike, MaterialAsset } from "ignifx";/** Which end of which axis a slider writes. */type RangeEnd = "metalHigh" | "metalLow" | "roughHigh" | "roughLow";/** * The two numbers that decide what a physically based surface looks like, laid out as a grid: one * mesh, thirty-six materials, and nothing loaded but the environment probe. * * Metalness runs left to right and roughness runs bottom to top, so every square of the grid is a * different surface and the whole of the parameter space is on screen at once. Reading it is the * lesson: * * - **Down the metal side (right), the base colour tints the reflection.** A metal has no diffuse *   term at all: what you see is the environment, coloured by the metal. Turn the base colour green *   and only the metals go green. * - **Down the dielectric side (left), the base colour is the diffuse albedo** and the reflection *   is a white highlight on top of it. That is why a plastic has a white shine whatever colour it *   is. * - **Roughness spreads the reflection.** At zero the probe is mirrored; at one it is averaged into *   an even sheen. A fully rough metal is the one combination that looks like nothing in *   particular, which is why real materials rarely sit there. * - **A metal with no environment is black.** The grid is lit by a prefiltered studio probe for *   exactly that reason — the two soft highlights down the metal column are the probe's own softbox *   panels, not lamps — with a modest key and fill behind it so the rough dielectrics keep a *   recognisable shape. Rotate the probe and every reflection moves at once; blur it to one and the *   top-right corner loses its shape altogether. * * Every material is `pbrMaterialDefinition` — the same record a `.material.json` file holds — built * with `createMaterialAsset` and then edited live through `MaterialAsset.setMetallicRoughness` and * `setBaseColor`, which is what the sliders drive. Nothing here rebuilds a shader: the two numbers * are uniforms. *//** How many columns of metalness, and rows of roughness. Thirty-six materials, one mesh. */const GRID = 6;/** Each sphere's diameter, in metres. */const SPHERE_DIAMETER = 0.5;/** Segments per sphere. Enough that a mirror surface shows no facet at the size the grid is seen. */const SPHERE_SEGMENTS = 24;/** Centre-to-centre spacing, in metres: a tenth of a diameter of air between neighbours. */const SPACING = 0.55;/** The base colour every material starts with: a neutral light grey, so metal and plastic differ only by their two numbers. */const START_COLOR = "#c8cad2";/** * The ground the grid is seen against: the site's own `--surface`, `#14181F`, **as it renders**. * * @remarks * Not black, and the reason is the right-hand column. A smooth metal shows nothing but its * surroundings, and the probe here is a dim softbox room, so the metals are the darkest things in * the frame — against black they lost their silhouettes and the column read as a few white * highlights floating in the void. Against `--surface` the darkest sphere is still darker than the * ground behind it, so its outline survives, which is what a grid whose whole job is comparison * needs. * * The numbers look three times too bright for `#14181F`, and that is deliberate: the clear colour * is **decoded from sRGB and presented without being re-encoded**, so what reaches the frame is * `srgbToLinear(value)`. Measured against this scene at 1280x720 rather than derived — `0.078, * 0.094, 0.122` rendered as bytes `2, 2, 4` (2026-09-08), which is `srgbToLinear` to within a * rounding step — so the values below are the inverse: `linearToSrgb(0x14 / 255)` and its two * siblings, which render as exactly `20, 24, 31`. It is the same trap * `_tools/make-backdrop-texture.ts` documents from the other direction, and it is why the hero * paints its backdrop into a texture instead. */const CLEAR_COLOR = { r: 0.3103, g: 0.3391, b: 0.3835, a: 1 };/** * The probe's exposure. * * @remarks * Chosen from three rendered candidates rather than guessed. At 0.9 the metal column was almost * unlit — honest, since a metal shows only what is around it and this probe is a dim grey room, * but it hid the roughness ramp the column exists to show. At 1.8 the dielectric rows above * roughness 0.6 washed into one flat white and *their* ramp went instead. At 1.25 both ramps read: * in the committed poster the grid's own 500 by 480 pixels mean 110 of 255, the brightest plastic * sits at 185 and the darkest metal at 30, against a ground of 20. */const EXPOSURE = 1.25;/** * Reads an `#rrggbb` string as an ignifx colour. * * @remarks * Every colour in the engine's public API is sRGB in `0…1` * (`references/formats/material.md`), and an `<input type="color">` reports sRGB in `0…255`. * * @param hex - The colour, as `#rrggbb`. * @returns The colour, opaque. */function fromHex(hex: string): ColorLike {  const value = Number.parseInt(hex.slice(1), 16);  return {    r: ((value >> 16) & 0xff) / 255,    g: ((value >> 8) & 0xff) / 255,    b: (value & 0xff) / 255,    a: 1,  };}/** * Interpolates one axis of the grid. * * @param low - The value in the first row or column. * @param high - The value in the last one. * @param index - Which row or column, from `0` to `GRID - 1`. * @returns The value for that row or column. */function ramp(low: number, high: number, index: number): number {  return low + ((high - low) * index) / (GRID - 1);}bootExample({  title: "Roughness and metalness",  settings: {    rendering: {      clearColor: CLEAR_COLOR,      msaaSamples: 4,      // No `shadows`: the grid has no floor to cast onto, and a shadow would only obscure the one      // thing being compared. The features record is read once, at `createApp`.      features: {},    },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, panel }) {    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.05, far: 100, fov: 40 });    // Yaw and pitch at zero: the grid is a flat wall in the XY plane and the opening shot is    // straight at it, which is the only view in which every square is comparable. Orbiting away is    // how the specular is read — a reflection that does not move with the eye is not a reflection.    const orbit = attachOrbit(app, eye, { yaw: 0, pitch: 0, minDistance: 1.5, maxDistance: 14 });    orbit.frame({ center: { x: 0, y: 0, z: 0 }, radius: (SPACING * GRID) / 2 }, 1.1);    // Three lamps at low intensity, and no shadows. The probe does most of the lighting; the lamps    // are what put a recognisable highlight on the rough dielectrics, which the probe alone leaves    // flat.    createLightRig(app, {      shadows: false,      keyIntensity: 1.4,      fillIntensity: 0.25,      rimIntensity: 0.6,      focus: { x: 0, y: 0, z: 0 },    });    const environment = loadEnvironment(app, "studio");    await environment.promise;    const sky = app.world      .createEntity("Environment")      // `skybox` is decided when the `.env` loads, not here: `studio.environment.json` declares      // `skyboxEnabled: false`, so the component is told the same thing rather than left at its      // default and reported as `IGX-0711`. The background is the clear colour.      .addComponent(Environment, { environment, clearColor: CLEAR_COLOR, skybox: { enabled: false, size: 20 } });    sky.imageProcessing.toneMapping = "aces";    sky.imageProcessing.exposure = EXPOSURE;    // One mesh for all thirty-six spheres: a `MeshAsset` is an asset, so assigning the same handle    // to every renderer shares the geometry rather than copying it.    const sphere = MeshAsset.sphere(app, { diameter: SPHERE_DIAMETER, segments: SPHERE_SEGMENTS });    /** Every material, in row-major order: `materials[row * GRID + column]`. */    const materials: AssetHandle<MaterialAsset>[] = [];    const offset = (SPACING * (GRID - 1)) / 2;    for (let row = 0; row < GRID; row += 1) {      for (let column = 0; column < GRID; column += 1) {        const material = createMaterialAsset(          app,          pbrMaterialDefinition({            name: `grid/${String(row)}-${String(column)}`,            baseColor: fromHex(START_COLOR),            metallic: ramp(0, 1, column),            roughness: ramp(0, 1, row),          }),          [],        );        materials.push(material);        const entity = app.world.createEntity(`Sphere ${String(row)}-${String(column)}`);        entity.transform.localPosition.set(column * SPACING - offset, row * SPACING - offset, 0);        entity.addComponent(MeshRenderer, { mesh: sphere, materials: [material], castShadows: false });      }    }    /** The live ranges the four sliders write, and the colour the swatch writes. */    const ranges = { metalLow: 0, metalHigh: 1, roughLow: 0, roughHigh: 1, base: fromHex(START_COLOR) };    /**     * Rewrites every material from {@link ranges}.     *     * @remarks     * Thirty-six `setMetallicRoughness` calls on a slider drag, which sounds like a lot and is not:     * both are uniforms on an already-compiled pipeline, so the cost is thirty-six writes and no     * shader work at all. A change that *did* need a recompile — an alpha mode, a texture slot —     * would be a different material rather than a different number.     */    const apply = (): void => {      for (let row = 0; row < GRID; row += 1) {        for (let column = 0; column < GRID; column += 1) {          const material = materials[row * GRID + column]?.value;          if (material === undefined) {            continue;          }          material.setMetallicRoughness(            ramp(ranges.metalLow, ranges.metalHigh, column),            ramp(ranges.roughLow, ranges.roughHigh, row),          );          material.setBaseColor(ranges.base);        }      }    };    /**     * One end of one axis, as a slider.     *     * @param label - Which row or column it moves.     * @param key - The field of {@link ranges} it writes.     * @returns The control.     */    const end = (label: string, key: RangeEnd): PanelSliderControl =>      slider(        label,        { min: 0, max: 1, step: 0.01 },        {          value: ranges[key],          change: (value: number): void => {            ranges[key] = value;            apply();          },        },      );    panel({      title: "Roughness and metalness",      groups: [        {          label: "Metalness, left to right",          controls: [end("Left column", "metalLow"), end("Right column", "metalHigh")],        },        {          label: "Roughness, bottom to top",          controls: [end("Bottom row", "roughLow"), end("Top row", "roughHigh")],        },        {          label: "Surface",          controls: [            color("Base colour", {              value: START_COLOR,              change: (hex: string): void => {                ranges.base = fromHex(hex);                apply();              },            }),            // Rotating the probe moves every reflection at once, and blurring it is what separates            // a mirror from a satin. Neither touches a material: both are the environment.            slider("Probe rotation", { min: 0, max: 360, step: 1 }, bind(sky, "rotation")),            slider("Probe blur", { min: 0, max: 1, step: 0.02 }, bind(sky, "blur")),          ],        },        {          label: "Frame",          collapsed: true,          controls: [            readout("Draw calls", (): string => String(app.renderer.drawCalls)),            readout("Materials", (): string => String(materials.length)),          ],        },      ],    });  },});

Uses:pbrMaterialDefinitioncreateMaterialAssetMaterialAsset.setMetallicRoughnessMeshAsset.sphereEnvironment

Assets:Studio environment — CC-BY 4.0, Babylon.js contributors