All examples

Temporal anti-aliasing

Lighting & GI3D
  • Mouse
  • Touch
  • Gamepad

Thin pickets and wires fall below a pixel as they recede, and shimmer as the camera turns. Temporal anti-aliasing renders each frame from a slightly different sub-pixel offset and blends them. It runs on the HDR scene colour, so it needs rendering.hdr. A camera-cut button jumps to a second view and drops the history.

Off airLivePaused

Temporal anti-aliasing

Lighting & GI · 3D

A pale picket fence with posts and two dark wires across a dark green field under a grey-blue sky, and a longer fence of the same kind running around the field into the distance behind it.
Open standalone

WebGPU: checking…

Try this

  1. Turn TAA off and watch the far fence crawl as the camera turns, then turn it back on.
  2. Turn Reset on cut off, press Camera cut, and watch the old view smear into the new one.
  3. Set MSAA to 4 with TAA off and compare: the near edges smooth, the far wires still break up.
Show source code
main.ts
import { Camera, Environment } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { degreesPerSecond, percent, twoPlaces } from "../_kit/format.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, button, readout, slider, toggle } from "../_kit/panel.ts";import { queryFlag, queryNumber, reloadSelect, reloadSlider, reloadToggle } from "../_kit/reload.ts";import { createFences, FOCUS, MAX_DISTANCE, TURN_SPEED, VIEWS } from "./scene.ts";/** * Temporal anti-aliasing: each frame is rendered from a camera nudged by a different sub-pixel * offset, and the frames are blended over time, so thin geometry that a single sample per pixel * catches or misses at random converges on a stable, smooth image. * * `rendering.taa` is a project setting and needs `rendering.hdr`: TAA runs on the linear HDR scene * colour, between the scene pass and the output pass, and with HDR off it logs a warning and stays * off. It is read when the app starts, so its rows reload the frame. MSAA is off by default here * (`msaaSamples: 1`), which is what makes the shimmer obvious when TAA is off; set it to 4× to * compare the two anti-aliasing methods. * * History reprojection follows the camera, and a jump far enough (`cutDistance`, or a turn over * 60° in one frame) drops the history by itself. A cut smaller than that — the button below jumps * a few metres to a second view — is the case `app.renderer.resetTemporalHistory()` is for: turn * "Reset on cut" off and the old view smears into the new one for a few frames. *//** TAA and MSAA, from the query. Blend and sharpness default to the engine's defaults. */const AA = {  taa: queryFlag("taa", true),  blend: queryNumber("blend", 0.1, 0.02, 1),  sharpness: queryNumber("sharpness", 0, 0, 1),  msaa: queryNumber("msaa", 1, 1, 4) >= 4 ? 4 : 1,} as const;bootExample({  title: "Temporal anti-aliasing",  settings: {    rendering: {      clearColor: { r: 0.52, g: 0.62, b: 0.74, a: 1 },      features: { shadows: true },      msaaSamples: AA.msaa,      hdr: true,      taa: { enabled: AA.taa, blend: AA.blend, sharpness: AA.sharpness },    },  },  setup({ app, panel }) {    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.05, far: 200, fov: 45 });    const [first, second] = VIEWS;    const orbit = attachOrbit(app, eye, {      ...first,      target: FOCUS,      minDistance: 2,      maxDistance: MAX_DISTANCE,      idleDegreesPerSecond: TURN_SPEED,    });    createFences(app);    const grade = app.world.createEntity("Environment").addComponent(Environment);    grade.imageProcessing.toneMapping = "aces";    let isSecondView = false;    let isResetOnCut = true;    /** Jumps straight to the other view, with no damping, and optionally drops the TAA history. */    const cut = (): void => {      isSecondView = !isSecondView;      const view = isSecondView ? second : first;      orbit.yaw = view.yaw;      orbit.pitch = view.pitch;      // `frame` snaps the smoothed pose onto the new one on the next update instead of easing there.      orbit.frame({ center: FOCUS, radius: 1 });      orbit.distance = view.distance;      if (isResetOnCut) {        app.renderer.resetTemporalHistory();      }    };    panel({      title: "Temporal anti-aliasing",      groups: [        {          label: "Project settings",          controls: [            // `rendering.taa` and `msaaSamples` are read when the app starts: each row reloads.            reloadToggle("TAA", "taa", AA.taa),            reloadSlider("Blend", "blend", { min: 0.02, max: 1, step: 0.02, format: twoPlaces }, AA.blend),            reloadSlider("Sharpness", "sharpness", { min: 0, max: 1, step: 0.05, format: percent }, AA.sharpness),            reloadSelect("MSAA", "msaa", ["1", "4"], String(AA.msaa)),          ],        },        {          label: "Camera",          controls: [            button("Camera cut", cut),            toggle("Reset on cut", {              value: isResetOnCut,              change: (on: boolean): void => {                isResetOnCut = on;              },            }),            slider(              "Turntable",              { min: 0, max: 20, step: 1, format: degreesPerSecond },              bind(orbit, "idleDegreesPerSecond"),            ),          ],        },        {          label: "Frame",          collapsed: true,          controls: [            readout("Anti-aliasing", (): string => {              const msaa = AA.msaa === 4 ? "MSAA 4×" : "no MSAA";              return AA.taa ? `TAA, ${msaa}` : msaa;            }),            readout("Draw calls", (): string => String(app.renderer.drawCalls)),          ],        },      ],    });  },});
scene.ts
/** * The fences `taa` orbits: rows of thin pickets, rails and wires that fall below a pixel wide as * they recede, which is where aliasing lives. * * @remarks * Geometry thinner than a pixel is either hit by a pixel's one sample or missed, and which of the * two changes as the camera moves: a fence crawls, a wire breaks into dashes. MSAA takes more * samples on edges; TAA takes one sample per frame at a different sub-pixel offset and blends the * frames, so a still or slowly moving view converges on the supersampled image. */import { Light, MeshAsset } from "ignifx";import { paint, place } from "../_kit/props.ts";import type { Point3 } from "../_kit/stage.ts";import type { App, AssetHandle, MaterialAsset, MeshAsset as Mesh } from "ignifx";/** The point the camera orbits. */export const FOCUS: Point3 = { x: 0, y: 0.6, z: 0 };/** The opening shot, and the second view the camera-cut button jumps to and back from. */export const VIEWS = [  { yaw: 28, pitch: 9, distance: 7 },  { yaw: -24, pitch: 16, distance: 5.5 },] as const;/** The idle turntable, in degrees per second: slow, so the shimmer is the thing that moves. */export const TURN_SPEED = 3;/** One fence: where it starts, which way it runs, how long it is, and its picket spacing. */interface FenceRun {  readonly from: Point3;  /** Direction along the ground, in degrees about Y; 0 runs along +X, -90 along +Z. */  readonly yaw: number;  readonly length: number;  readonly spacing: number;}/** Half the side of the square paddock, in metres; larger than the camera's orbit. */const PADDOCK = 10;/** * A short fence through the middle of the shot, and a square paddock around the whole orbit, so * the camera never passes through a fence and always has one receding into the distance. */const FENCES: readonly FenceRun[] = [  { from: { x: -2.6, y: 0, z: 0.4 }, yaw: 0, length: 5.2, spacing: 0.16 },  { from: { x: -PADDOCK, y: 0, z: -PADDOCK }, yaw: 0, length: PADDOCK * 2, spacing: 0.3 },  { from: { x: PADDOCK, y: 0, z: -PADDOCK }, yaw: -90, length: PADDOCK * 2, spacing: 0.3 },  { from: { x: PADDOCK, y: 0, z: PADDOCK }, yaw: 180, length: PADDOCK * 2, spacing: 0.3 },  { from: { x: -PADDOCK, y: 0, z: PADDOCK }, yaw: 90, length: PADDOCK * 2, spacing: 0.3 },];/** The furthest the visitor can pull the camera back, so it stays inside the paddock. */export const MAX_DISTANCE = 9;/** Picket width and depth, in metres: a couple of centimetres, a pixel or less at a few metres. */const PICKET_THICKNESS = 0.025;/** Picket height, in metres. */const PICKET_HEIGHT = 1.1;/** Metres between the posts that carry the rails and the wires. */const POST_SPACING = 2.5;/** Post height, in metres: just above the upper wire. */const POST_HEIGHT = 1.85;/** Shared assets for every fence. */interface FenceKit {  readonly box: AssetHandle<Mesh>;  readonly wood: AssetHandle<MaterialAsset>;  readonly wire: AssetHandle<MaterialAsset>;}/** * Builds one fence run: pickets, two rails, and a wire strung above it. * * @param app - The app the entities belong to. * @param kit - The shared mesh and materials. * @param run - Where it stands. */function createFence(app: App, kit: FenceKit, run: FenceRun): void {  // A parent turned to the run's direction, so every part is placed along its own local X.  const fence = app.world.createEntity("Fence");  fence.transform.localPosition.set(run.from.x, run.from.y, run.from.z);  fence.transform.localEulerAngles = { x: 0, y: run.yaw, z: 0 };  const count = Math.floor(run.length / run.spacing);  for (let index = 0; index <= count; index += 1) {    place(app, "Picket", kit.box, kit.wood, {      parent: fence,      at: { x: index * run.spacing, y: PICKET_HEIGHT / 2, z: 0 },      scale: { x: PICKET_THICKNESS, y: PICKET_HEIGHT, z: PICKET_THICKNESS },    });  }  // Posts every couple of metres, tall enough to carry the wires.  const posts = Math.max(1, Math.round(run.length / POST_SPACING));  for (let index = 0; index <= posts; index += 1) {    place(app, "Post", kit.box, kit.wood, {      parent: fence,      at: { x: (index * run.length) / posts, y: POST_HEIGHT / 2, z: -PICKET_THICKNESS },      scale: { x: 0.07, y: POST_HEIGHT, z: 0.07 },    });  }  for (const [height, thickness, material] of [    [0.3, 0.03, kit.wood],    [0.85, 0.03, kit.wood],    [1.6, 0.008, kit.wire],    [1.75, 0.008, kit.wire],  ] as const) {    place(app, "Rail", kit.box, material, {      parent: fence,      at: { x: run.length / 2, y: height, z: -PICKET_THICKNESS },      scale: { x: run.length, y: thickness, z: thickness },      castShadows: thickness > 0.01,    });  }}/** * Builds the fences, the ground and the two lights. * * @param app - The app the entities and the assets belong to. */export function createFences(app: App): void {  const box = MeshAsset.box(app, { size: 1 });  const kit: FenceKit = {    box,    wood: paint(app, "taa/picket", { r: 0.86, g: 0.84, b: 0.78, a: 1 }, { roughness: 0.6 }),    wire: paint(app, "taa/wire", { r: 0.1, g: 0.1, b: 0.11, a: 1 }, { metallic: 1, roughness: 0.3 }),  };  for (const run of FENCES) {    createFence(app, kit, run);  }  place(app, "Ground", MeshAsset.ground(app, { width: 120, height: 120, subdivisions: 1 }), paint(    app,    "taa/ground",    { r: 0.2, g: 0.24, b: 0.17, a: 1 },    { roughness: 0.95 },  ), { at: { x: 0, y: 0, z: 0 }, castShadows: false });  const sunEntity = app.world.createEntity("Sun", { position: { x: -6, y: 9, z: -4 } });  sunEntity.transform.lookAt(FOCUS);  const sun = sunEntity.addComponent(Light, {    type: "directional",    intensity: 2.4,    color: { r: 1, g: 0.95, b: 0.86, a: 1 },  });  sun.shadows.enabled = true;  sun.shadows.technique = "pcf";  sun.shadows.mapSize = 2048;  sun.shadows.normalBias = 0.02;  app.world.createEntity("Sky").addComponent(Light, {    type: "hemispheric",    intensity: 0.5,    color: { r: 0.72, g: 0.8, b: 1, a: 1 },  });}
Uses
rendering.taarendering.hdrrenderer.resetTemporalHistoryrendering.msaaSamplesLight.shadows
Assets
Everything in this example is created in code.