ignifx
GitHubnpm · soon
All examples

Light types

Lighting3D

  • Mouse
  • Touch
  • Gamepad

A light is invisible, so each of the four here carries a small unlit shape that shows what it is: an arrow for the directional light's direction, a glowing orb and a ring at its range for the point light, a cone for the spot, and a two-tone marker for the hemispheric light's sky and ground colours. Every gizmo is a child entity of the light, so it inherits the pose and nothing keeps the two in step by hand. Switch a lamp off and change its colour, its intensity, its range and its cone, and watch which parts of the scene answer.

Five pale primitives on a dark grid floor, lit from the upper left by a warm sun whose arrow gizmo hangs above them, with a blue point light orbiting inside a wide range ring and an orange spot light throwing a visible cone across the right of the frame.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Turn every lamp off but one, then bring the others back one at a time.
  • Widen the spot light's cone angle and soften its edge; the cone gizmo follows both.
  • Drag the point light's range and watch the ring on the floor grow with it.
Show source

Source

main.ts
import { Camera, Light } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, color, readout, slider, toggle } from "../_kit/panel.ts";import { createArrow, createCone, createOrb, createRing, createSkyMarker } from "./gizmos.ts";import {  createLightSwitch,  createSubjects,  degrees,  degreesPerSecond,  FOCUS,  fromHex,  groundColor,  LAMP,  lightColor,  metres,  paints,  SHADOW_MAP_SIZE,  SKY,  Spinner,  SPOT,  SUN,} from "./scene.ts";/** * The four lights ignifx has, in one scene, each drawn with a gizmo so you can see what you are * changing. * * A `Light` is described by two things: its own fields, and **its entity's transform**. A * directional light shines along the entity's `+Z` and has no position at all; a point light sits * at the entity's origin and fades to nothing at `range`; a spot light does both and adds a cone; a * hemispheric light has neither, only a sky direction — the entity's `+Y` — and two colours it * mixes between. So every lamp below is placed, then aimed with `lookAt`, and each gizmo is simply * a child entity: it inherits the pose, and nothing has to keep the two in step. * * Two of the four can cast. Shadows come from a directional or a spot light only; a point or * hemispheric light that asks for them is refused with `IGX-0703` * (`skills/ignifx/references/concepts/rendering.md` §1). What the gizmos are made of, and what the * numbers are, is `gizmos.ts` and `scene.ts` beside this file. */bootExample({  title: "Light types",  settings: {    rendering: {      clearColor: { r: 0.043, g: 0.055, b: 0.078, a: 1 },      msaaSamples: 4,      // Read once, when `app.start()` registers the scene; asking afterwards is `IGX-0704`. Without      // it a light's `shadows.enabled` is a no-op with a logged warning.      features: { shadows: true },    },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, panel }) {    app.registerComponents([Spinner]);    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.1, far: 200, fov: 45 });    attachOrbit(app, eye, { yaw: 26, pitch: 29, distance: 10.5, target: FOCUS, minDistance: 4, maxDistance: 30 });    await createSubjects(app);    // ── Directional: a direction, and no position. `lookAt` is what sets it. ──────────────    const sunEntity = app.world.createEntity("Sun", { position: SUN.at });    sunEntity.transform.lookAt(FOCUS);    const sun = sunEntity.addComponent(Light, { type: "directional", intensity: SUN.intensity });    sun.color = fromHex(SUN.color);    sun.shadows.enabled = true;    sun.shadows.mapSize = SHADOW_MAP_SIZE;    // A PCF-only offset along the surface normal, without which a map this size stripes a curved    // surface with its own shadow.    sun.shadows.normalBias = 0.02;    sun.shadows.darkness = 0.28;    const sunArrow = createArrow(app, { parent: sunEntity, size: SUN.arrow, color: fromHex(SUN.color) });    const sunSwitch = createLightSwitch(sun, SUN.intensity, [sunArrow.entity]);    // ── Point: a position and a range. The pivot is what walks it around the scene. ───────    const pivot = app.world.createEntity("Lamp Pivot");    const lampEntity = app.world.createEntity("Lamp", { parent: pivot, position: LAMP.at });    const lamp = lampEntity.addComponent(Light, { type: "point", intensity: LAMP.intensity, range: LAMP.range });    lamp.color = fromHex(LAMP.color);    const lampOrb = createOrb(app, { parent: lampEntity, size: 0.22, color: fromHex(LAMP.color) });    const lampRing = createRing(app, { parent: lampEntity, size: LAMP.range, color: fromHex(LAMP.color) });    const lampSwitch = createLightSwitch(lamp, LAMP.intensity, [lampOrb.entity, lampRing.entity]);    const spinner = pivot.addComponent(Spinner, { speed: LAMP.spin });    // The ring is the range, so the two move together.    const setRange = (value: number): void => {      lamp.range = value;      lampRing.setRadius(value);    };    // ── Spot: a position, a direction, a range and a cone. ────────────────────────────────    const spotEntity = app.world.createEntity("Spot", { position: SPOT.at });    spotEntity.transform.lookAt(SPOT.aim);    const spot = spotEntity.addComponent(Light, {      type: "spot",      intensity: SPOT.intensity,      range: SPOT.range,      spotAngle: SPOT.angle,      spotExponent: SPOT.exponent,    });    spot.color = fromHex(SPOT.color);    // Lite gives a spot light one shadow generator, PCF, and ignores whatever `technique` says.    spot.shadows.enabled = true;    spot.shadows.mapSize = SHADOW_MAP_SIZE;    spot.shadows.normalBias = 0.02;    spot.shadows.darkness = 0.28;    const spotOrb = createOrb(app, { parent: spotEntity, size: 0.2, color: fromHex(SPOT.color) });    const cone = { parent: spotEntity, length: SPOT.cone, angleDegrees: SPOT.angle, color: fromHex(SPOT.color) };    const spotCone = createCone(app, cone);    const spotSwitch = createLightSwitch(spot, SPOT.intensity, [spotOrb.entity, spotCone.entity]);    // The cone gizmo is the `spotAngle`, drawn.    const setAngle = (value: number): void => {      spot.spotAngle = value;      spotCone.setAngle(value);    };    // ── Hemispheric: two colours and an up axis. Ambient, and it cannot cast. ─────────────    const skyEntity = app.world.createEntity("Ambient", { position: SKY.at });    const ambient = skyEntity.addComponent(Light, { type: "hemispheric", intensity: SKY.intensity });    ambient.color = fromHex(SKY.color);    ambient.groundColor = fromHex(SKY.ground);    const skyMarker = createSkyMarker(app, { parent: skyEntity, size: 0.8, color: fromHex(SKY.color) });    skyMarker.setGroundColor(fromHex(SKY.ground));    const skySwitch = createLightSwitch(ambient, SKY.intensity, [skyMarker.entity]);    panel({      title: "Light types",      groups: [        {          label: "Lamps",          controls: [            toggle("Directional", { value: true, change: sunSwitch.setOn }),            toggle("Point", { value: true, change: lampSwitch.setOn }),            toggle("Spot", { value: true, change: spotSwitch.setOn }),            toggle("Hemispheric", { value: true, change: skySwitch.setOn }),          ],        },        {          label: "Directional",          collapsed: true,          controls: [            slider("Intensity", { min: 0, max: 6, step: 0.1 }, { value: SUN.intensity, change: sunSwitch.setLevel }),            color("Colour", { value: SUN.color, change: paints(lightColor(sun), sunArrow.setColor) }),            toggle("Casts shadows", bind(sun.shadows, "enabled")),          ],        },        {          label: "Point",          collapsed: true,          controls: [            slider("Intensity", { min: 0, max: 30, step: 0.5 }, { value: LAMP.intensity, change: lampSwitch.setLevel }),            color("Colour", {              value: LAMP.color,              change: paints(lightColor(lamp), lampOrb.setColor, lampRing.setColor),            }),            slider("Range", { min: 1, max: 16, step: 0.5, format: metres }, { value: LAMP.range, change: setRange }),            slider("Orbit", { min: 0, max: 60, step: 2, format: degreesPerSecond }, bind(spinner, "speed")),          ],        },        {          label: "Spot",          collapsed: true,          controls: [            slider("Intensity", { min: 0, max: 60, step: 1 }, { value: SPOT.intensity, change: spotSwitch.setLevel }),            color("Colour", {              value: SPOT.color,              change: paints(lightColor(spot), spotOrb.setColor, spotCone.setColor),            }),            slider(              "Cone angle",              { min: 6, max: 120, step: 1, format: degrees },              { value: SPOT.angle, change: setAngle },            ),            slider("Edge falloff", { min: 0, max: 8, step: 0.1 }, bind(spot, "spotExponent")),            toggle("Casts shadows", bind(spot.shadows, "enabled")),          ],        },        {          label: "Hemispheric",          collapsed: true,          controls: [            slider("Intensity", { min: 0, max: 2, step: 0.05 }, { value: SKY.intensity, change: skySwitch.setLevel }),            color("Sky", { value: SKY.color, change: paints(lightColor(ambient), skyMarker.setSkyColor) }),            color("Ground", { value: SKY.ground, change: paints(groundColor(ambient), skyMarker.setGroundColor) }),          ],        },        {          label: "Frame",          collapsed: true,          controls: [            readout("Draw calls", (): string => String(app.renderer.drawCalls)),            readout("Shadow maps", (): string => String(Number(sun.isCastingShadows) + Number(spot.isCastingShadows))),          ],        },      ],    });  },});
gizmos.ts
/** * The light gizmos `lights` draws: the small unlit shapes that show where a light is, which way it * points, how far it reaches and how wide its cone is. * * @remarks * A light is invisible. That is the whole difficulty of a lighting example — you can see what a * light does and not what it is, so a slider moves and nothing on screen says *which* lamp moved. * Every editor answers that with gizmos, and this file is the handful of primitives that draw them, * built from `MeshAsset` factories in code so the example still loads no assets. * * ## Why the gizmos are `unlit`, not emissive * * A material's `emissive` is fixed when the material is built: `MaterialAsset` exposes * `setBaseColor`, `setMetallicRoughness` and `setAlpha`, and nothing for emissive. A gizmo has to * repaint itself whenever the visitor moves a colour picker, so it is `unlit: true` instead — an * unlit surface draws its base colour and nothing else, whatever the lights do, and `setBaseColor` * is one uniform write with no pipeline rebuild. * * ## The one rotation worth stating * * `MeshAsset.cylinder` stands along **+Y**, and a light points along its entity's **+Z** * (`skills/ignifx/references/concepts/rendering.md` §1). So every gizmo that has to lie along the * light's direction — the arrow and the cone — is parented to the light's entity and pitched by * {@link ALIGN_Y_TO_Z} about X, which maps the mesh's +Y onto the parent's +Z. ignifx is * left-handed, so that rotation is `+90` and not `-90`. */import { createMaterialAsset, degToRad, MeshAsset, MeshRenderer, pbrMaterialDefinition } from "ignifx";import type { App, AssetHandle, ColorLike, Entity, MaterialAsset } from "ignifx";/** The X rotation, in degrees, that puts a `MeshAsset.cylinder`'s +Y axis along its parent's +Z. */const ALIGN_Y_TO_Z = 90;/** How much of an arrow's length is shaft; the rest is the head. */const SHAFT_FRACTION = 0.72;/** An arrow's shaft diameter, as a fraction of its length. */const SHAFT_THICKNESS = 0.045;/** An arrow's head diameter, as a fraction of its length. */const HEAD_THICKNESS = 0.16;/** How many radial segments a gizmo cone or disc is built from. Enough that no facet shows. */const SEGMENTS = 24;/** How opaque a spot light's cone is drawn. Low: it is a hint, and the subject is behind it. */const CONE_ALPHA = 0.16;/** The tube thickness of a range ring, in metres, before the entity's scale is applied. */const RING_THICKNESS = 0.02;/** How opaque a range ring is drawn: a hint on the floor, not a pipe around the scene. */const RING_ALPHA = 0.5;/** A hemispheric marker's ball, as a fraction of the marker's width. */const SKY_BALL_FRACTION = 0.5;/** How far the ball floats above the disc, as a fraction of the marker's width. */const SKY_BALL_LIFT = 0.3;/** The ground disc's thickness, as a fraction of the marker's width. */const SKY_DISC_THICKNESS = 0.06;/** A gizmo: some geometry under one entity, and one material the example repaints. */export interface Gizmo {  /** The gizmo's root entity, so an example can hide it with `active = false`. */  readonly entity: Entity;  /**   * Repaints the gizmo, so it keeps matching the light it stands for.   *   * @param color - The new sRGB colour.   */  readonly setColor: (color: ColorLike) => void;}/** A range ring, which also follows its light's `range`. */export interface RingGizmo extends Gizmo {  /**   * Resizes the ring.   *   * @param radius - The new radius, in metres.   */  readonly setRadius: (radius: number) => void;}/** A spot light's cone, which also follows its `spotAngle`. */export interface ConeGizmo extends Gizmo {  /**   * Reshapes the cone.   *   * @param angleDegrees - The light's full cone angle, in degrees.   */  readonly setAngle: (angleDegrees: number) => void;}/** A hemispheric light's two-tone marker, which has a colour above and a colour below. */export interface SkyGizmo {  /** The marker's root entity. */  readonly entity: Entity;  /**   * Repaints the upper disc.   *   * @param color - The light's sRGB sky colour.   */  readonly setSkyColor: (color: ColorLike) => void;  /**   * Repaints the lower disc.   *   * @param color - The light's sRGB ground colour.   */  readonly setGroundColor: (color: ColorLike) => void;}/** What the orb, arrow, ring and sky-marker factories take. */export interface GizmoOptions {  /** The entity the gizmo hangs under — the light's entity, so it inherits its pose. */  readonly parent: Entity;  /** The gizmo's size in metres: an orb's diameter, an arrow's length, a ring's radius. */  readonly size: number;  /** The colour it opens on, in sRGB. */  readonly color: ColorLike;}/** What {@link createCone} takes. */export interface ConeGizmoOptions {  /** The spot light's entity. */  readonly parent: Entity;  /** How far down the beam the cone is drawn, in metres. */  readonly length: number;  /** The light's full cone angle, in degrees. */  readonly angleDegrees: number;  /** The colour it opens on, in sRGB. */  readonly color: ColorLike;}/** * Builds one unlit material, which is what makes a gizmo read as a lamp rather than as an object. * * @param app - The app the material belongs to. * @param name - The material's name, which the devtools inspector shows. * @param color - The sRGB colour. * @param alpha - The opacity; below 1 the material is alpha-blended and double-sided. * @returns The material's handle. */function unlitMaterial(app: App, name: string, color: ColorLike, alpha = 1): AssetHandle<MaterialAsset> {  return createMaterialAsset(    app,    pbrMaterialDefinition({      name,      baseColor: { r: color.r, g: color.g, b: color.b, a: alpha },      metallic: 0,      roughness: 1,      unlit: true,      alpha,      // A cone is a hint drawn over the scene, so it blends and shows both of its faces; an orb or      // an arrow is a solid object and neither.      alphaMode: alpha < 1 ? "blend" : "opaque",      doubleSided: alpha < 1,    }),    [],  );}/** * Adds one gizmo mesh to an entity: never a caster, never a receiver, never pickable. * * @param entity - The entity to draw it on. * @param mesh - The geometry's handle. * @param material - The material's handle. */function addGizmoMesh(entity: Entity, mesh: AssetHandle<MeshAsset>, material: AssetHandle<MaterialAsset>): void {  entity.addComponent(MeshRenderer, {    mesh,    materials: [material],    castShadows: false,    receiveShadows: false,    pickable: false,  });}/** * A small glowing ball where a light sits: the gizmo for a point or spot light. * * @param app - The app the entities and assets belong to. * @param options - The parent, the diameter and the colour. * @returns The gizmo. * * @example * ```ts * const orb = createOrb(app, { parent: lamp, size: 0.18, color: { r: 1, g: 0.8, b: 0.5, a: 1 } }); * orb.setColor({ r: 0.4, g: 0.7, b: 1, a: 1 }); * ``` */export function createOrb(app: App, options: GizmoOptions): Gizmo {  const material = unlitMaterial(app, "gizmo/orb", options.color);  const entity = app.world.createEntity("Light Orb", { parent: options.parent });  addGizmoMesh(entity, MeshAsset.sphere(app, { diameter: options.size, segments: 16 }), material);  return {    entity,    setColor: (color: ColorLike): void => {      material.value.setBaseColor(color);    },  };}/** * An arrow along its parent's forward axis: the gizmo for a directional light, which has a * direction and no position at all. * * @param app - The app the entities and assets belong to. * @param options - The parent, the total length and the colour. * @returns The gizmo. * * @example * ```ts * createArrow(app, { parent: sunEntity, size: 1.4, color: { r: 1, g: 0.95, b: 0.86, a: 1 } }); * ``` */export function createArrow(app: App, options: GizmoOptions): Gizmo {  const length = options.size;  const material = unlitMaterial(app, "gizmo/arrow", options.color);  // The root carries the one rotation this file exists to state: mesh +Y onto the parent's +Z.  const entity = app.world.createEntity("Light Arrow", { parent: options.parent });  entity.transform.localEulerAngles = { x: ALIGN_Y_TO_Z, y: 0, z: 0 };  const shaft = app.world.createEntity("Arrow Shaft", { parent: entity });  shaft.transform.localPosition.set(0, (length * SHAFT_FRACTION) / 2, 0);  addGizmoMesh(    shaft,    MeshAsset.cylinder(app, {      height: length * SHAFT_FRACTION,      diameter: length * SHAFT_THICKNESS,      tessellation: 10,    }),    material,  );  const head = app.world.createEntity("Arrow Head", { parent: entity });  head.transform.localPosition.set(0, length * (1 - (1 - SHAFT_FRACTION) / 2), 0);  addGizmoMesh(    head,    MeshAsset.cylinder(app, {      height: length * (1 - SHAFT_FRACTION),      diameterTop: 0,      diameterBottom: length * HEAD_THICKNESS,      tessellation: 12,    }),    material,  );  return {    entity,    setColor: (color: ColorLike): void => {      material.value.setBaseColor(color);    },  };}/** * A horizontal ring at a light's `range`: the distance at which a point or spot light reaches zero. * * @remarks * The mesh is built at unit radius and the entity is **scaled**, so dragging the range slider * resizes the ring with one write and no new geometry. `MeshAsset.torus` lies in the XZ plane, and * `localScale` is applied before the entity's rotation, so scaling X and Z is scaling the radius. * * @param app - The app the entities and assets belong to. * @param options - The parent, the starting radius and the colour. * @returns The gizmo, with {@link RingGizmo.setRadius}. * * @example * ```ts * const ring = createRing(app, { parent: lamp, size: light.range, color: warm }); * ring.setRadius(12); * ``` */export function createRing(app: App, options: GizmoOptions): RingGizmo {  const material = unlitMaterial(app, "gizmo/ring", options.color, RING_ALPHA);  const entity = app.world.createEntity("Range Ring", { parent: options.parent });  // `diameter` is the ring's outer diameter, so 2 is a radius of 1 — the unit the scale multiplies.  addGizmoMesh(    entity,    MeshAsset.torus(app, { diameter: 2, thickness: RING_THICKNESS, tessellation: SEGMENTS * 2 }),    material,  );  const setRadius = (radius: number): void => {    // Y is left at 1: a thicker tube would read as a change in the range, which it is not.    entity.transform.localScale.set(radius, 1, radius);  };  setRadius(options.size);  return {    entity,    setColor: (color: ColorLike): void => {      material.value.setBaseColor(color);    },    setRadius,  };}/** * The cone a spot light throws: apex at the lamp, opening along the light's forward axis. * * @remarks * Built at unit base radius over `length` metres, and reshaped by scaling X and Z to * `length × tan(spotAngle / 2)` — the radius the cone's mouth actually has at that distance. The * apex sits on the lamp because the mesh is pushed back half its length along the rotated axis; a * cone centred on the lamp instead of starting there points the wrong way half the time. * * @param app - The app the entities and assets belong to. * @param options - The parent, the cone's length, its starting angle and the colour. * @returns The gizmo, with {@link ConeGizmo.setAngle}. * * @example * ```ts * const cone = createCone(app, { parent: lamp, length: 4, angleDegrees: 34, color: warm }); * cone.setAngle(spot.spotAngle); * ``` */export function createCone(app: App, options: ConeGizmoOptions): ConeGizmo {  const length = options.length;  const material = unlitMaterial(app, "gizmo/cone", options.color, CONE_ALPHA);  const entity = app.world.createEntity("Spot Cone", { parent: options.parent });  entity.transform.localEulerAngles = { x: ALIGN_Y_TO_Z, y: 0, z: 0 };  const shape = app.world.createEntity("Cone Shape", { parent: entity });  // `diameterTop: 2, diameterBottom: 0` puts the apex at the mesh's **-Y** end, and the mesh spans  // -length/2 to +length/2 about its own origin — so pushing it half its length **along** the  // rotated axis puts the apex on the lamp and the mouth `length` metres down the beam.  shape.transform.localPosition.set(0, length / 2, 0);  addGizmoMesh(    shape,    MeshAsset.cylinder(app, { height: length, diameterTop: 2, diameterBottom: 0, tessellation: SEGMENTS }),    material,  );  const setAngle = (angleDegrees: number): void => {    const radius = length * Math.tan(degToRad(angleDegrees) / 2);    shape.transform.localScale.set(radius, 1, radius);  };  setAngle(options.angleDegrees);  return {    entity,    setColor: (color: ColorLike): void => {      material.value.setBaseColor(color);    },    setAngle,  };}/** * The marker for a hemispheric light: a ball in the sky colour sitting on a disc in the ground * colour. * * @remarks * A hemispheric light has **no position** — only a sky direction, which is its entity's +Y — so * this marker is a legend rather than a location: the two colours the light mixes between, stacked * along that axis. The disc is wider than the ball so both read from a camera looking down at it, * which is where an example's camera usually is. Moving its entity changes nothing about the light * except which way "up" is. * * @param app - The app the entities and assets belong to. * @param options - The parent, the disc's diameter and the starting sky colour. * @returns The gizmo. * * @example * ```ts * const sky = createSkyMarker(app, { parent: ambient, size: 0.8, color: skyBlue }); * sky.setGroundColor({ r: 0.3, g: 0.22, b: 0.16, a: 1 }); * ``` */export function createSkyMarker(app: App, options: GizmoOptions): SkyGizmo {  const upper = unlitMaterial(app, "gizmo/sky", options.color);  const lower = unlitMaterial(app, "gizmo/ground", { r: 0.18, g: 0.15, b: 0.13, a: 1 });  const entity = app.world.createEntity("Hemispheric Marker", { parent: options.parent });  const ball = app.world.createEntity("Sky Ball", { parent: entity });  ball.transform.localPosition.set(0, options.size * SKY_BALL_LIFT, 0);  addGizmoMesh(ball, MeshAsset.sphere(app, { diameter: options.size * SKY_BALL_FRACTION, segments: 16 }), upper);  const disc = app.world.createEntity("Ground Disc", { parent: entity });  addGizmoMesh(    disc,    MeshAsset.cylinder(app, {      height: options.size * SKY_DISC_THICKNESS,      diameter: options.size,      tessellation: SEGMENTS,    }),    lower,  );  return {    entity,    setSkyColor: (color: ColorLike): void => {      upper.value.setBaseColor(color);    },    setGroundColor: (color: ColorLike): void => {      lower.value.setBaseColor(color);    },  };}
scene.ts
/** * Everything in `lights` that is not a light: the numbers each lamp is tuned to, the shapes the * light falls on, the script that walks the lamp around them, and the plumbing the parameter panel * needs to drive a light from a slider and a colour picker. * * @remarks * Split out for the reason `pbr-model/shot.ts` is: what a reader wants from `main.ts` is the four * lights and their fields, not a scene file with four lights somewhere in it. Every number here is * a composition choice and none of it is a lesson about ignifx. */import { createMaterialAsset, f32, MeshAsset, MeshRenderer, pbrMaterialDefinition, Script } from "ignifx";import { createGridGround } from "../_kit/stage.ts";import type { App, AssetHandle, ColorLike, Entity, Light, ScriptCallbacks } from "ignifx";/** The point the directional and spot lights are aimed at, and what the camera looks at. */export const FOCUS = { x: 0, y: 0.4, z: 0 } as const;/** The directional light: a warm sun from the front upper left, and the one that casts by default. */export const SUN = { color: "#ffe9c4", intensity: 2, at: { x: -3.6, y: 4.4, z: -3.6 }, arrow: 1.6 } as const;/** The point light: a cool lamp that walks a circle around the subjects. */export const LAMP = { color: "#7fb8ff", intensity: 22, range: 5, at: { x: 3.1, y: 1.5, z: 0 }, spin: 16 } as const;/** The spot light: warm, tight, from above and to the right, aimed just off the centre sphere. */export const SPOT = {  color: "#ffb04a",  intensity: 250,  range: 14,  angle: 34,  exponent: 2,  at: { x: 3.9, y: 3.5, z: 1 },  aim: { x: 1.9, y: 0, z: -2.8 },  /** How far down the beam the cone gizmo is drawn: just short of the floor. */  cone: 5.2,} as const;/** The hemispheric light: pale sky above, warm bounce below, and no position that matters. */export const SKY = { color: "#8fb6ff", ground: "#5a4632", intensity: 0.3, at: { x: -4.4, y: 1.9, z: 3.2 } } as const;/** The shadow map both casters use, in texels per side. `shadows` is the example that tunes it. */export const SHADOW_MAP_SIZE = 1024;/** The matte off-white every subject is drawn in, so the light is the only thing that varies. */const SUBJECT_COLOR: ColorLike = { r: 0.79, g: 0.8, b: 0.83, a: 1 };/** How rough the subjects are: high enough that the shape reads, low enough to catch a highlight. */const SUBJECT_ROUGHNESS = 0.52;/** The ground plane's edge length, in metres. One grid cell per metre. */const GROUND_SIZE = 44;/** How many radial segments a subject is built from. */const SUBJECT_SEGMENTS = 32;/** The radix an `#rrggbb` string is parsed in. */const HEX_RADIX = 16;/** The largest value one 8-bit colour channel can hold. */const CHANNEL_MAX = 255;/** * 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`. This is the whole conversion, and it lives in * an example rather than in the kit because only a panel with a colour picker in it needs one. * * @param hex - The colour, as `#rrggbb`. * @returns The colour, opaque. */export function fromHex(hex: string): ColorLike {  const value = Number.parseInt(hex.slice(1), HEX_RADIX);  return {    r: ((value >> 16) & CHANNEL_MAX) / CHANNEL_MAX,    g: ((value >> 8) & CHANNEL_MAX) / CHANNEL_MAX,    b: (value & CHANNEL_MAX) / CHANNEL_MAX,    a: 1,  };}/** * Turns one colour picker into a write to each thing that has to change colour with it. * * @remarks * A lamp and its gizmo are two objects with one colour between them, and the panel should not have * to say so four times. This is what makes each `color` row in `main.ts` a single line. * * @param setters - Everything to repaint, in any order. * @returns The `change` callback a `color` control takes. * * @example * ```ts * color("Colour", { value: SUN.color, change: paints(lightColor(sun), arrow.setColor) }); * ``` */export function paints(...setters: readonly ((color: ColorLike) => void)[]): (hex: string) => void {  return (hex: string): void => {    const value = fromHex(hex);    for (const set of setters) {      set(value);    }  };}/** * A setter that writes one light's own colour, for {@link paints}. * * @param light - The light to repaint. * @returns The setter. */export function lightColor(light: Light): (color: ColorLike) => void {  return (color: ColorLike): void => {    light.color = color;  };}/** * A setter that writes one hemispheric light's `groundColor` — the colour it lights from below. * * @param light - The hemispheric light. * @returns The setter. */export function groundColor(light: Light): (color: ColorLike) => void {  return (color: ColorLike): void => {    light.groundColor = color;  };}/** * Writes a slider's value as a distance. * * @param value - Metres. * @returns The text for the slider's value cell. */export function metres(value: number): string {  return `${value.toFixed(1)} m`;}/** * Writes a slider's value as an angle. * * @param value - Degrees. * @returns The text for the slider's value cell. */export function degrees(value: number): string {  return `${String(value)}°`;}/** * Writes a slider's value as a rate. * * @param value - Degrees per second. * @returns The text for the slider's value cell. */export function degreesPerSecond(value: number): string {  return `${String(value)}°/s`;}/** Walks its entity around the world Y axis, in degrees per second. */export class Spinner extends Script.define({ speed: f32(0) }) implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "lights/Spinner";  /** Reused so the per-frame path allocates nothing (coding standards §7). */  readonly #step = { x: 0, y: 0, z: 0 };  /**   * Advances the rotation.   *   * @param dt - Seconds since the previous frame, already scaled by `time.timeScale`. Under   * `?static=1` the scale is zero, so the lamp holds the angle it was authored at.   */  update(dt: number): void {    this.#step.y = this.speed * dt;    this.transform.rotate(this.#step);  }}/** One lamp the panel can switch off and set a level for. */export interface LightSwitch {  /**   * Switches the lamp on or off, and its gizmos with it.   *   * @param on - Whether the lamp should light the scene.   */  readonly setOn: (on: boolean) => void;  /**   * Sets the intensity the lamp uses while it is on.   *   * @param level - The new intensity.   */  readonly setLevel: (level: number) => void;}/** * Wires a light and its gizmos to one on/off state and one intensity. * * @remarks * **`intensity = 0` is the switch, not `enabled = false`.** `Light` is the one render component * with no visibility path: the `PreRender` sync reads `isEnabledInHierarchy` for a `MeshRenderer`, * a `Model`, a `Camera` and an `Environment`, and calls `Light.sync` unconditionally * (`packages/core/src/render/render-sync-system.ts`), so a light on a deactivated entity keeps * shading the scene. Zero intensity is what turns one off today. The gizmos are meshes and do * follow `active`, so they are hidden the ordinary way. * * @param light - The light to switch. * @param level - The intensity it opens on and returns to. * @param gizmos - The gizmo roots to hide with it. * @returns The switch the panel drives. * * @example * ```ts * const sun = createLightSwitch(light, 2.2, [arrow.entity]); * sun.setOn(false); * ``` */export function createLightSwitch(light: Light, level: number, gizmos: readonly Entity[]): LightSwitch {  let isOn = true;  let intensity = level;  return {    setOn: (on: boolean): void => {      isOn = on;      light.intensity = on ? intensity : 0;      for (const gizmo of gizmos) {        gizmo.active = on;      }    },    setLevel: (next: number): void => {      intensity = next;      if (isOn) {        light.intensity = next;      }    },  };}/** * Builds the ground and the five primitives the lights fall on. * * @remarks * Five different shapes on purpose: a sphere shows a highlight's shape, a box shows three faces at * three brightnesses, a cylinder shows a gradient wrapping away from the light, a torus shows a * surface shadowing itself, and a capsule shows both at once. Every one of them is a `MeshAsset` * factory call, so the example loads no geometry. * * The ground is the kit's grid, which is the one texture here, and it is awaited before * `app.start()` because `createMaterialAsset` binds its textures once and a still-loading handle * binds as none at all (`_kit/stage.ts` says so at length). * * @param app - The app the entities and assets belong to. * @returns A promise that resolves once the ground's texture has loaded. * * @example * ```ts * await createSubjects(app); * ``` */export async function createSubjects(app: App): Promise<void> {  await createGridGround(app, { size: GROUND_SIZE });  const matte = createMaterialAsset(    app,    pbrMaterialDefinition({      name: "lights/matte",      baseColor: SUBJECT_COLOR,      metallic: 0.04,      roughness: SUBJECT_ROUGHNESS,      // No environment is loaded here — the four lights are the whole of the lighting — so this      // only states the intent: nothing in this scene is lit by a probe.      environmentIntensity: 0,    }),    [],  );  const place = (name: string, mesh: AssetHandle<MeshAsset>, x: number, y: number, z: number): void => {    const entity = app.world.createEntity(name, { position: { x, y, z } });    entity.addComponent(MeshRenderer, { mesh, materials: [matte], castShadows: true, receiveShadows: true });  };  const segments = SUBJECT_SEGMENTS;  place("Sphere", MeshAsset.sphere(app, { diameter: 1.3, segments }), 0, 0.65, 0);  place("Box", MeshAsset.box(app, { width: 1, height: 1.7, depth: 1 }), -2.4, 0.85, 0.7);  place("Cylinder", MeshAsset.cylinder(app, { height: 1.5, diameter: 0.95, tessellation: segments }), 2.4, 0.75, 0.7);  place("Torus", MeshAsset.torus(app, { diameter: 1.7, thickness: 0.34, tessellation: segments }), 0, 0.34, -2.7);  place("Capsule", MeshAsset.capsule(app, { height: 1.7, radius: 0.36, tessellation: segments }), 3.8, 0.85, -1.8);}

Uses:LightLight.shadowsMeshAssetcreateMaterialAssetScript.defineCamera

Assets:everything in this example is created in code.