ignifx
GitHubnpm · soon
All examples

Physics playground

Physics3D

  • Mouse
  • Touch
  • Keyboard
  • Gamepad

Rigid bodies on Havok. Click the floor to drop the selected shape, or press the button; the friction and bounce sliders are a physics material, which a collider reads when its shape is built, so they reach the next body you drop. The simulation runs in `FixedUpdate` on its own headless scene at a fixed rate, whatever the frame rate does, and a body that stops moving is put to sleep and costs nothing until something wakes it — the dimmed ones in the pit are the ones this example has measured at rest. Every shape is a `MeshAsset` factory, so nothing is fetched.

A settled pile of dark orange crates, teal balls and sand-coloured capsules resting on a grid floor inside a low kerbed pit, lit from the upper left and casting soft shadows.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Click anywhere on the floor: the click is a ray, and the ray is where the body lands.
  • Raise Bounce to 0.8, then press Reset — the whole pile is re-dropped on the new surface.
  • Watch a body dim as it settles, then drop a crate on it and watch the colour come back.
Show source

Source

main.ts
import { Camera, clamp, createMaterialAsset, pbrMaterialDefinition, physics } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { button, readout, select, slider } from "../_kit/panel.ts";import { createLightRig } from "../_kit/stage.ts";import { ARENA_HALF, buildArena, PILE, SHAPES } from "./arena.ts";import { buildLooks, spawnBody } from "./bodies.ts";import { DropControls, PLAYGROUND_ACTIONS } from "./controls.ts";import type { ShapeName, Triple } from "./arena.ts";import type { Body, SpawnRequest } from "./bodies.ts";import type { ColorLike } from "ignifx";/** * Rigid bodies on Havok: three collider shapes, a physics material you can change between drops, * and a tint that marks every body the simulation has stopped moving. * * Physics runs in `FixedUpdate`, on its own headless scene, at a rate the frame rate cannot move. * So everything that touches a body does it in `fixedUpdate` (`bodies.ts`) and everything that * reads a pointer does it in `update` (`controls.ts`), where the frame's input was captured. A * collider with no `Rigidbody` — the floor and the four kerbs — is placed once as a **static** body; * a collider with one falls. * * The opening pile is authored settled rather than dropped, which is what makes `?static=1` — the * clock stopped before `app.start()`, so no fixed step ever runs — a reproducible frame. `arena.ts` * holds that layout and the pit it stands in. *//** The clear colour: the site's dark `--bg`, so the pit sits on the page's own ground. */const CLEAR: ColorLike = { r: 0.051, g: 0.063, b: 0.082, a: 1 };/** The kerb's colour, a shade of the site's `--sunk`. */const KERB_COLOR: ColorLike = { r: 0.145, g: 0.169, b: 0.208, a: 1 };/** The grid floor's tint. */const FLOOR_COLOR: ColorLike = { r: 0.26, g: 0.28, b: 0.33, a: 1 };/** Where the orbit camera looks, in metres: the middle of the pile rather than the floor. */const FOCUS: Triple = { x: 0, y: 0.7, z: 0 };/** How high a dropped body starts, in metres. */const DROP_HEIGHT = 4.2;/** How far a dropped body is scattered from where it was asked for, in metres. */const DROP_SCATTER = 0.22;/** How far from the centre the Drop button and the drop key place a body, in metres. */const KEY_DROP_SPREAD = 3;/** How many bodies the pit holds before the oldest is destroyed to make room. */const BODY_CAP = 48;/** * Where Havok's WebAssembly binary is served from. * * @remarks * `@ignifx/physics` declares the file in `ignifx.assets.public`, so the Vite plugin copies it * **unhashed, by base name** into the public asset path — `/examples/assets/HavokPhysics.wasm` for * this build. It is named here rather than left at `havokWasm: "auto"` because `auto` resolves the * bare file name through the asset manifest, whose `root` is the *relative* path `assets`: on a * page served from `/examples/<slug>/run/` that resolves against the document and asks for * `/examples/<slug>/run/assets/HavokPhysics.wasm`, which is a 404 and then `IGX-0903`. Vite's own * `BASE_URL` is the base the plugin wrote its URLs from, so this is right in `dev` and in `build`. */const HAVOK_WASM = `${import.meta.env.BASE_URL}assets/HavokPhysics.wasm`;/** The friction the panel opens on: a dry surface a crate does not slide across. */const START_FRICTION = 0.55;/** The restitution the panel opens on. Zero is a dead landing; one would never stop. */const START_BOUNCE = 0.1;/** * Writes a slider's value with two decimals, the resolution a physics material is authored at. * * @param value - The value. * @returns The text for the slider's value cell. */function twoPlaces(value: number): string {  return value.toFixed(2);}bootExample({  title: "Physics playground",  extensions: [physics()],  settings: {    rendering: {      clearColor: CLEAR,      msaaSamples: 4,      // Read once, when `app.start()` registers the scene; asking afterwards is `IGX-0704`.      features: { shadows: true },    },    // The rate the simulation advances at, whatever the frame rate does.    time: { fixedDeltaTime: 1 / 60 },    physics: { havokWasm: HAVOK_WASM },  },  async setup({ app, panel, random }) {    app.registerComponents([DropControls]);    app.input.loadActions(PLAYGROUND_ACTIONS);    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.1, far: 200, fov: 46 });    attachOrbit(app, eye, { yaw: 32, pitch: 33, distance: 6.6, target: FOCUS, minDistance: 3, maxDistance: 26 });    createLightRig(app, { focus: FOCUS, keyIntensity: 2.9, shadowMapSize: 2048 });    const kerb = createMaterialAsset(      app,      pbrMaterialDefinition({ name: "kerb", baseColor: KERB_COLOR, metallic: 0, roughness: 0.85 }),      [],    );    await buildArena(app, { kerb, floor: FLOOR_COLOR });    const looks = buildLooks(app);    // The one object the two sliders edit, and the one every spawn copies. Mutable, so it is a    // plain object literal rather than the readonly `PhysicsMaterialValues` a collider takes.    const material = { friction: START_FRICTION, staticFriction: START_FRICTION, restitution: START_BOUNCE };    const bodies: Body[] = [];    let shape: ShapeName = "Box";    const add = (request: SpawnRequest): void => {      bodies.push(spawnBody(app, looks, material, request));      // A rolling window rather than a refusal: the pit never fills up, and the frame's cost has a      // ceiling a visitor cannot lift.      while (bodies.length > BODY_CAP) {        bodies.shift()?.entity.destroy();      }    };    const drop = (x: number, z: number): void => {      // The kit's seeded generator, never `Math.random`: two loads of one URL scatter alike.      const scatter = (): number => (random() - 0.5) * 2 * DROP_SCATTER;      add({        shape,        at: {          x: clamp(x + scatter(), -ARENA_HALF, ARENA_HALF),          y: DROP_HEIGHT,          z: clamp(z + scatter(), -ARENA_HALF, ARENA_HALF),        },        turn: { x: random() * 360, y: random() * 360, z: random() * 360 },        asleep: false,      });    };    const dropAnywhere = (): void => {      drop((random() - 0.5) * KEY_DROP_SPREAD, (random() - 0.5) * KEY_DROP_SPREAD);    };    const reset = (): void => {      for (const body of bodies) {        body.entity.destroy();      }      bodies.length = 0;      for (const piled of PILE) {        // `exactOptionalPropertyTypes` is on, so `turn` is either set or absent, never `undefined`.        add({          shape: piled.shape,          at: piled.at,          asleep: true,          ...(piled.turn === undefined ? {} : { turn: piled.turn }),        });      }    };    reset();    // Space and the gamepad's south button drop one too, so the example is playable with no    // pointer at all.    const controls = eye.addComponent(DropControls);    controls.onFloorClick = drop;    controls.onDropKey = dropAnywhere;    panel({      title: "Physics playground",      groups: [        {          label: "Drop",          controls: [            select("Shape", SHAPES, {              value: shape,              change: (value: string): void => {                shape = SHAPES.find((name: ShapeName) => name === value) ?? "Box";              },            }),            button("Drop one", dropAnywhere),            button("Reset", reset),            readout("Bodies", (): string => `${String(bodies.length)} / ${String(BODY_CAP)}`),            readout("At rest", (): string =>              String(bodies.reduce((total: number, body: Body) => total + (body.tint.atRest ? 1 : 0), 0)),            ),          ],        },        {          label: "Material",          controls: [            slider(              "Friction",              { min: 0, max: 1.2, step: 0.05, format: twoPlaces },              {                value: START_FRICTION,                change: (value: number): void => {                  material.friction = value;                  material.staticFriction = value;                },              },            ),            slider(              "Bounce",              { min: 0, max: 0.9, step: 0.05, format: twoPlaces },              {                value: START_BOUNCE,                change: (value: number): void => {                  material.restitution = value;                },              },            ),            // A collider reads its material when its shape is built, so a slider reaches the next            // body. Reset re-drops the pile, which is how you see a change on all of them at once.            readout("Applies to", (): string => "the next body"),          ],        },        {          label: "Frame",          collapsed: true,          controls: [            readout("Draw calls", (): string => String(app.renderer.drawCalls)),            readout("Scripts", (): string => String(app.diagnostics.frame.scriptsUpdated)),          ],        },      ],    });  },});
bodies.ts
/** * One dynamic body: a mesh, a collider, a `Rigidbody`, and the script that tints it when it stops. * * This is the physics lesson of the example, kept beside `main.ts` because `main.ts` is the world * and the panel. Three things in here are worth reading twice. * * **A body's entity must be a root entity.** Havok writes the scene node's *local* pose, so a * parented body would be simulated in its parent's space — `IGX-0907` says so out loud. * * **Bodies and shapes are built at the start of the next fixed step**, never mid-frame, so the * transform written at spawn time is the pose the simulation starts from and body creation order * follows entity creation order. * * **`inlineMaterial` is read when the shape is built.** `Collider.resolveMaterial` prefers a * `.physicsmaterial.json` asset, then these inline values, then the world's `defaultMaterial`. So a * change to the panel's sliders reaches the next body rather than the settled pile, which is what * Reset re-drops the pile for. */import {  assertNever,  bool,  BoxCollider,  CapsuleCollider,  createMaterialAsset,  MeshAsset,  MeshRenderer,  pbrMaterialDefinition,  Rigidbody,  Script,  SphereCollider,  Vec3,} from "ignifx";import { BOX_SIZE, CAPSULE_HEIGHT, CAPSULE_RADIUS, SPHERE_RADIUS } from "./arena.ts";import type { ShapeName, Triple } from "./arena.ts";import type {  App,  AssetHandle,  ColorLike,  Entity,  MaterialAsset,  PhysicsMaterialValues,  ScriptCallbacks,} from "ignifx";/** A dropped body's mass, in kilograms. */const BODY_MASS = 2;/** Below this speed, in metres per second, a body counts as still. */const REST_SPEED = 0.04;/** Below this rate, in radians per second, a body counts as still. */const REST_SPIN = 0.12;/** How many consecutive still fixed steps mark a body at rest. Twenty is a third of a second. */const REST_STEPS = 20;/** The colour each shape is drawn in while it is moving. */const SHAPE_COLORS: Readonly<Record<ShapeName, ColorLike>> = {  Box: { r: 0.878, g: 0.412, b: 0.169, a: 1 },  Sphere: { r: 0.247, g: 0.663, b: 0.627, a: 1 },  Capsule: { r: 0.788, g: 0.635, b: 0.153, a: 1 },};/** How much of its colour a body keeps once it has come to rest. */const ASLEEP_FACTOR = 0.45;/** * Scales a colour towards black. * * @param color - The colour to dim. * @param factor - How much of it to keep. * @returns The dimmed colour, opaque. */function dim(color: ColorLike, factor: number): ColorLike {  return { r: color.r * factor, g: color.g * factor, b: color.b * factor, a: 1 };}/** * Tints its body when it stops moving, and reports whether it is at rest. * * @remarks * Havok puts a resting body to sleep and stops integrating it, which is why a pit of settled crates * costs almost nothing to keep on screen. `@babylonjs/[email protected]` has no way to *ask* a body * whether it is asleep — it only accepts `startAsleep` when the body is created — so this measures * the thing a sleep test measures: a body whose linear and angular speeds have both stayed under a * threshold for {@link REST_STEPS} consecutive fixed steps is at rest, and is tinted. * * The velocities are read in `fixedUpdate` because that is where the authoritative pose and * velocity live; `update` and `PreRender` see the interpolated display pose. */export class RestTint extends Script.define({ atRest: bool(false) }) implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "physics-playground/RestTint";  /** The material shown while the body is moving. Assigned by {@link spawnBody}. */  moving: AssetHandle<MaterialAsset> | null = null;  /** The material shown once the body is at rest. Assigned by {@link spawnBody}. */  resting: AssetHandle<MaterialAsset> | null = null;  /** The body this watches, found once. */  #body: Rigidbody | null = null;  /** The renderer whose first material is swapped. */  #renderer: MeshRenderer | null = null;  /** How many consecutive fixed steps the body has been still for. */  #stillSteps = 0;  /** Reused so the per-step path allocates nothing (coding standards §7). */  readonly #velocity = new Vec3();  /** Finds the body and the renderer, and seeds the counter from the authored state. */  awake(): void {    this.#body = this.entity.getComponent(Rigidbody);    this.#renderer = this.entity.getComponent(MeshRenderer);    this.#stillSteps = this.atRest ? REST_STEPS : 0;  }  /** Measures the body's speed and swaps its material when the verdict changes. */  fixedUpdate(): void {    const body = this.#body;    if (body === null) {      return;    }    body.linearVelocityToRef(this.#velocity);    const speed = this.#velocity.length();    body.angularVelocityToRef(this.#velocity);    const still = speed < REST_SPEED && this.#velocity.length() < REST_SPIN;    this.#stillSteps = still ? this.#stillSteps + 1 : 0;    const atRest = this.#stillSteps >= REST_STEPS;    if (atRest === this.atRest) {      return;    }    this.atRest = atRest;    const material = atRest ? this.resting : this.moving;    // `MeshRenderer` re-reads `materials[0]` on every `PreRender` sync, so one assignment is the    // whole swap: nothing is rebuilt and no pipeline is recompiled.    if (this.#renderer !== null && material !== null) {      this.#renderer.materials[0] = material;    }  }}/** The mesh and the two materials every body of one shape shares. */export interface ShapeLook {  /** The mesh, cloned per body by `MeshRenderer`. */  readonly mesh: AssetHandle<MeshAsset>;  /** The colour while the body moves. */  readonly moving: AssetHandle<MaterialAsset>;  /** The colour once the body is at rest. */  readonly resting: AssetHandle<MaterialAsset>;}/** One live body, and the script that tints it. */export interface Body {  /** The body's entity. */  readonly entity: Entity;  /** Its tint script, which the "At rest" readout counts. */  readonly tint: RestTint;}/** The look of every shape, by name. */export type ShapeLooks = Readonly<Record<ShapeName, ShapeLook>>;/** * Builds one mesh and two materials per shape, shared by every body of that shape. * * @remarks * Every shape comes from a `MeshAsset` factory — `box`, `sphere`, `capsule` — so the example fetches * nothing. A capsule's `height` is its **total** height, caps included, in both the mesh factory * and the collider, so one pair of numbers describes the drawing and the shape. * * @param app - The running app. * @returns The look of each shape. The caller owns the handles. */export function buildLooks(app: App): ShapeLooks {  const look = (shape: ShapeName, mesh: AssetHandle<MeshAsset>): ShapeLook => {    const color = SHAPE_COLORS[shape];    return {      mesh,      moving: createMaterialAsset(        app,        pbrMaterialDefinition({ name: `${shape}/moving`, baseColor: color, metallic: 0.05, roughness: 0.45 }),        [],      ),      resting: createMaterialAsset(        app,        pbrMaterialDefinition({          name: `${shape}/resting`,          baseColor: dim(color, ASLEEP_FACTOR),          metallic: 0.05,          roughness: 0.7,        }),        [],      ),    };  };  return {    Box: look("Box", MeshAsset.box(app, { size: BOX_SIZE })),    Sphere: look("Sphere", MeshAsset.sphere(app, { diameter: SPHERE_RADIUS * 2, segments: 18 })),    Capsule: look("Capsule", MeshAsset.capsule(app, { height: CAPSULE_HEIGHT, radius: CAPSULE_RADIUS })),  };}/** * Adds one shape's collider, with the surface it presents to Havok. * * @param entity - The body's entity. * @param shape - Which collider to add. * @param material - The friction and bounce this body is built with; copied, not held. */function addCollider(entity: Entity, shape: ShapeName, material: PhysicsMaterialValues): void {  const inlineMaterial: PhysicsMaterialValues = { ...material };  switch (shape) {    case "Box": {      entity.addComponent(BoxCollider, { size: { x: BOX_SIZE, y: BOX_SIZE, z: BOX_SIZE }, inlineMaterial });      break;    }    case "Sphere": {      entity.addComponent(SphereCollider, { radius: SPHERE_RADIUS, inlineMaterial });      break;    }    case "Capsule": {      entity.addComponent(CapsuleCollider, {        radius: CAPSULE_RADIUS,        height: CAPSULE_HEIGHT,        direction: "y",        inlineMaterial,      });      break;    }    default: {      // Every shape is handled above; this branch makes adding one a type error rather than a      // silently invisible body (coding standards §5.2).      assertNever(shape, "body shape");    }  }}/** What {@link spawnBody} takes. */export interface SpawnRequest {  /** Which shape. */  readonly shape: ShapeName;  /** Where its centre starts, in metres. */  readonly at: Triple;  /** Its Euler angles, in degrees. Omitted means axis-aligned. */  readonly turn?: Triple;  /** Whether Havok starts it asleep, which the authored pile does because it is already resting. */  readonly asleep: boolean;}/** * Builds one dynamic body. * * @param app - The running app. * @param looks - The shared meshes and materials. * @param material - The friction and bounce to build the collider with. * @param request - The shape, the pose, and whether it starts asleep. * @returns The body and its tint script. * * @example * ```ts * spawnBody(app, looks, { friction: 0.55, staticFriction: 0.55, restitution: 0.1 }, { *   shape: "Sphere", *   at: { x: 0, y: 4, z: 0 }, *   asleep: false, * }); * ``` */export function spawnBody(app: App, looks: ShapeLooks, material: PhysicsMaterialValues, request: SpawnRequest): Body {  const look = looks[request.shape];  const entity = app.world.createEntity(request.shape);  entity.transform.localPosition.set(request.at.x, request.at.y, request.at.z);  if (request.turn !== undefined) {    entity.transform.localEulerAngles = request.turn;  }  entity.addComponent(MeshRenderer, {    mesh: look.mesh,    materials: [request.asleep ? look.resting : look.moving],    castShadows: true,  });  addCollider(entity, request.shape, material);  entity.addComponent(Rigidbody, { mass: BODY_MASS, startAsleep: request.asleep });  const tint = entity.addComponent(RestTint, { atRest: request.asleep });  tint.moving = look.moving;  tint.resting = look.resting;  return { entity, tint };}
controls.ts
/** * The playground's own input: a click on the floor, and a key that drops a body wherever. * * It is `@ignifx/input` actions rather than DOM listeners for the reason the whole kit is: a * visitor reading this sees what a game writes, and one binding on `<Pointer>` covers a mouse, a * pen and a finger with no branch per device. * * The map is the example's own, so the kit orbit camera's `KitOrbit` map is untouched — * `loadActions` merges documents by map name. */import { Camera, clamp, createRay, defineInputActions, Script } from "ignifx";import { ARENA_HALF } from "./arena.ts";import type { InputActionsDefinition, ScriptCallbacks } from "ignifx";/** How far a pointer may travel between press and release and still count as a click, in pixels. */const CLICK_SLOP = 6;/** * How steeply a ray must aim down to be resolved against the floor. * * @remarks * A ray along the horizon meets the `y = 0` plane a kilometre away, or behind the camera. Refusing * anything flatter than this is what keeps a click on the sky from dropping a crate at the edge of * the pit. */const MIN_DOWNWARD = 0.05;/** The actions the playground binds: a pointer press, the pointer's position, and a drop key. */export const PLAYGROUND_ACTIONS: InputActionsDefinition = defineInputActions({  maps: [    {      name: "Playground",      actions: [        { name: "dropPress", type: "button", bindings: [{ path: "<Pointer>/press" }] },        { name: "dropPoint", type: "vector2", bindings: [{ path: "<Pointer>/position" }] },        {          name: "dropKey",          type: "button",          bindings: [{ path: "<Keyboard>/space" }, { path: "<Gamepad>/buttonSouth" }],        },      ],    },  ],});/** * Turns a click on the canvas into a point on the floor, and a key press into a drop. * * @remarks * A click and a camera drag start with the same `<Pointer>/press`, so this waits for the release * and only reports a point when the pointer travelled less than {@link CLICK_SLOP} pixels — which * is what stops an orbit from also dropping a crate. A press that lands on the parameter panel is * ignored through `app.input.uiHasPointer`, the flag `@ignifx/ui` raises for exactly this. * * Pointer positions are **backing-store pixels** (the canvas's `width`/`height`), which is the * space `Camera.screenToRay` reads, so no device-pixel-ratio conversion appears anywhere here. * * @example * ```ts * const controls = cameraEntity.addComponent(DropControls); * controls.onFloorClick = drop; * ``` */export class DropControls extends Script implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "physics-playground/DropControls";  /** Called with the floor point a click landed on, in metres. Assigned after `addComponent`. */  onFloorClick: ((x: number, z: number) => void) | null = null;  /** Called when the drop key, or the gamepad's south button, was pressed. */  onDropKey: (() => void) | null = null;  /** Where the current gesture started, in backing-store pixels. */  #pressX = 0;  /** Where the current gesture started, in backing-store pixels. */  #pressY = 0;  /** Whether this gesture has travelled far enough to be a drag rather than a click. */  #dragged = true;  /** Reused so the per-frame path allocates nothing. */  readonly #ray = createRay();  /** Reads the drop key, then the press, the travel and the release of a pointer gesture. */  update(): void {    if (this.app.input.actions.find("dropKey")?.wasPressedThisFrame === true) {      this.onDropKey?.();    }    const press = this.app.input.actions.find("dropPress");    const point = this.app.input.actions.find("dropPoint");    const camera = this.entity.getComponent(Camera);    if (press === null || point === null || camera === null) {      return;    }    if (press.wasPressedThisFrame) {      this.#pressX = point.vector.x;      this.#pressY = point.vector.y;      this.#dragged = this.app.input.uiHasPointer;    }    if (Math.hypot(point.vector.x - this.#pressX, point.vector.y - this.#pressY) > CLICK_SLOP) {      this.#dragged = true;    }    if (!press.wasReleasedThisFrame || this.#dragged) {      return;    }    const ray = camera.screenToRay(point.vector.x, point.vector.y, this.#ray);    if (ray === null || ray.direction.y > -MIN_DOWNWARD) {      return;    }    const along = -ray.origin.y / ray.direction.y;    this.onFloorClick?.(      clamp(ray.origin.x + ray.direction.x * along, -ARENA_HALF, ARENA_HALF),      clamp(ray.origin.z + ray.direction.z * along, -ARENA_HALF, ARENA_HALF),    );  }}
arena.ts
/** * The playground's staging: the pit the bodies fall into, and the pile they start settled in. * * It is next to `main.ts` rather than in it for the reason `pbr-model`'s `shot.ts` is: none of it * is a lesson about ignifx. The floor's half-extent, the height of the kerb and the exact centre of * every crate in the opening pyramid are composition, and `main.ts` is easier to read as physics * without them. * * ## Why the opening pile is a table of exact numbers * * `?static=1` stops the clock **before** `app.start()`, so no fixed step ever runs and no body ever * falls: the poster and the golden are exactly the transforms authored here. A 0.6 m crate resting * on the floor has its centre at 0.3 m, the row above it at 0.9 m, and the row above that at 1.5 m * — so the pyramid below is a settled pile by construction, not by simulation, and it is the same * pile whether or not the clock is running. Every body in it is created `startAsleep`, which is * what Havok would do to it a third of a second later anyway. */import { BoxCollider, MeshAsset, MeshRenderer } from "ignifx";import { createGridGround } from "../_kit/stage.ts";import type { App, AssetHandle, ColorLike, Entity, MaterialAsset } from "ignifx";/** Half the pit's inner width, in metres: the floor a body can land on spans `[-4.5, 4.5]`. */export const ARENA_HALF = 4.5;/** The visible ground plane's edge length, in metres. Wider than the pit, so it runs out of frame. */const GROUND_SIZE = 80;/** How high the kerb around the pit stands, in metres. */const KERB_HEIGHT = 0.5;/** How thick the kerb is, in metres. */const KERB_THICKNESS = 0.4;/** How deep the floor's collision box is, in metres. Its top face sits at `y = 0`. */const FLOOR_DEPTH = 1;/** The edge of a crate, in metres. */export const BOX_SIZE = 0.6;/** A ball's radius, in metres. */export const SPHERE_RADIUS = 0.35;/** A capsule's radius, in metres. */export const CAPSULE_RADIUS = 0.28;/** * A capsule's total height, caps included, in metres — the convention both the mesh factory and the * collider use, so one pair of numbers describes the drawing and the shape. */export const CAPSULE_HEIGHT = 1.1;/** The three shapes the playground drops, in the order the panel lists them. */export const SHAPES = ["Box", "Sphere", "Capsule"] as const;/** One of {@link SHAPES}. */export type ShapeName = (typeof SHAPES)[number];/** A three-component point, in metres or in degrees. */export interface Triple {  /** X. */  readonly x: number;  /** Y. */  readonly y: number;  /** Z. */  readonly z: number;}/** One body of the opening pile. */export interface PiledBody {  /** Which shape to build. */  readonly shape: ShapeName;  /** Where its centre sits, in metres. */  readonly at: Triple;  /** Its Euler angles, in degrees. Omitted means axis-aligned. */  readonly turn?: Triple;}/** * The opening pile: a three-two-one pyramid of crates, two balls, and a capsule on its side. * * @remarks * Every height is the shape's own resting height, so the pile is settled the instant it exists — * see the module comment. The horizontal placements are hand-picked rather than scattered, because * a poster is a composition and a seeded scatter is only reproducible, not good. */export const PILE: readonly PiledBody[] = Object.freeze([  { shape: "Box", at: { x: -BOX_SIZE, y: BOX_SIZE / 2, z: 0 } },  { shape: "Box", at: { x: 0, y: BOX_SIZE / 2, z: 0 } },  { shape: "Box", at: { x: BOX_SIZE, y: BOX_SIZE / 2, z: 0 } },  { shape: "Box", at: { x: -BOX_SIZE / 2, y: BOX_SIZE * 1.5, z: 0 } },  { shape: "Box", at: { x: BOX_SIZE / 2, y: BOX_SIZE * 1.5, z: 0 } },  { shape: "Box", at: { x: 0, y: BOX_SIZE * 2.5, z: 0 } },  // A second, lower stack, so the pit reads as a pile rather than as one tidy monument.  { shape: "Box", at: { x: -2.1, y: BOX_SIZE / 2, z: -1.35 } },  { shape: "Box", at: { x: -2.1, y: BOX_SIZE * 1.5, z: -1.35 }, turn: { x: 0, y: 18, z: 0 } },  { shape: "Sphere", at: { x: -1.5, y: SPHERE_RADIUS, z: 0.95 } },  { shape: "Sphere", at: { x: 1.35, y: SPHERE_RADIUS, z: -1.25 } },  { shape: "Sphere", at: { x: 0.55, y: SPHERE_RADIUS, z: 1.75 } },  // Rolled onto its side: the mesh and the collider both stand along Y, so turning the entity a  // quarter turn about X lays the drawing and the shape down together.  { shape: "Capsule", at: { x: 1.55, y: CAPSULE_RADIUS, z: 1.15 }, turn: { x: 90, y: 0, z: 0 } },  { shape: "Capsule", at: { x: -0.95, y: CAPSULE_RADIUS, z: -1.9 }, turn: { x: 90, y: 55, z: 0 } },  // One left standing on a cap: stable while it is asleep, and the first thing a landing crate  // knocks over.  { shape: "Capsule", at: { x: 2.15, y: CAPSULE_HEIGHT / 2, z: 0.55 } },]);/** The meshes and materials the arena is built from; `main.ts` owns their lifetimes. */export interface ArenaLook {  /** The kerb's material. */  readonly kerb: AssetHandle<MaterialAsset>;  /** The tint the grid floor is given. */  readonly floor: ColorLike;}/** What {@link buildArena} produced. */export interface Arena {  /** The floor entity, which also carries the floor's static collider. */  readonly ground: Entity;  /** Releases every handle the arena holds. */  release(): void;}/** * Builds the pit: a grid floor with a static box under it, and four kerbs to keep the bodies in. * * @remarks * A collider with no `Rigidbody` is placed once as a **static** body, which is exactly right for * scenery — and moving it afterwards would log `IGX-0901`. The floor's box is sunk by half its * depth through the collider's own `center`, so its top face is the `y = 0` the pile is authored * against and the entity's transform stays at the origin. * * @param app - The running app. * @param look - The kerb material and the floor's tint. * @returns The floor entity and the release for the handles this created. */export async function buildArena(app: App, look: ArenaLook): Promise<Arena> {  const ground = await createGridGround(app, { size: GROUND_SIZE, color: look.floor });  ground.entity.addComponent(BoxCollider, {    size: { x: GROUND_SIZE, y: FLOOR_DEPTH, z: GROUND_SIZE },    center: { x: 0, y: -FLOOR_DEPTH / 2, z: 0 },  });  // One mesh for four kerbs: two of them are the same box turned a quarter turn about Y, and a  // collider is authored in local units, so it turns with the entity and needs no second size.  const span = ARENA_HALF * 2 + KERB_THICKNESS * 2;  const kerbMesh = MeshAsset.box(app, { width: span, height: KERB_HEIGHT, depth: KERB_THICKNESS });  const offset = ARENA_HALF + KERB_THICKNESS / 2;  const kerbs: readonly { readonly x: number; readonly z: number; readonly turn: number }[] = [    { x: 0, z: offset, turn: 0 },    { x: 0, z: -offset, turn: 0 },    { x: offset, z: 0, turn: 90 },    { x: -offset, z: 0, turn: 90 },  ];  for (const [index, kerb] of kerbs.entries()) {    const entity = app.world.createEntity(`Kerb ${String(index + 1)}`);    entity.transform.localPosition.set(kerb.x, KERB_HEIGHT / 2, kerb.z);    entity.transform.localEulerAngles = { x: 0, y: kerb.turn, z: 0 };    entity.addComponent(MeshRenderer, { mesh: kerbMesh, materials: [look.kerb], castShadows: true });    entity.addComponent(BoxCollider, { size: { x: span, y: KERB_HEIGHT, z: KERB_THICKNESS } });  }  return {    ground: ground.entity,    release(): void {      kerbMesh.release();      ground.release();    },  };}

Uses:physicsRigidbodyBoxColliderSphereColliderCapsuleColliderCollider.inlineMaterialMeshAsset.boxCamera.screenToRay

Assets:everything in this example is created in code.