ignifx
All examples

Picking

Rendering3D

  • Mouse
  • Touch
  • Gamepad

Select shapes on a grid using two methods: GPU picking and CPU raycasting. The panel shows which object each method finds and how long it takes. Toggle whether the floor can be selected, then try again.

Seven grey shapes — a box, a sphere, a cylinder, a capsule, a torus, a cone and a slab — standing in two rows on a lit grid floor, with the cylinder at the centre of the frame picked out in orange.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Click a shape, then the floor between two of them: both paths report the same hit and the same miss.
  • Read the two timings. The CPU row is the ray test alone; the GPU row includes the readback.
  • Turn on “Ground is pickable” and click the grid — one flag, and both paths start finding it.
Show source code

Source

main.ts
import { Camera, createRay, defineInputActions, MeshRenderer, Vec3 } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { button, readout, toggle } from "../_kit/panel.ts";import { createGridGround, createLightRig } from "../_kit/stage.ts";import { ClickToPick, PICK_ACTIONS } from "./click-to-pick.ts";import { CLEAR_COLOR, createPickableScene, SHOT } from "./scene.ts";import type { Picker } from "./click-to-pick.ts";import type { RenderPick } from "ignifx";/** * Compare GPU picking with a CPU render raycast at the same backing-store pixel. * GPU timing includes readback; CPU picking needs retained mesh positions. Both use `pickable` * and filters, so hiding a mesh alone does not exclude it. * Input pointer positions already use the pixel space these APIs expect. *//** What one resolved pick is remembered as, for the readouts. */interface PickResult {  /** What was hit, or a dash for a miss. */  readonly label: string;  /** How long the call took, in milliseconds; `0` before either path has answered. */  readonly ms: number;}/** Neither path has answered yet. */const UNPICKED: PickResult = { label: "click a shape", ms: 0 };/** * Writes one result as `Sphere · 0.42 ms`. * * @param result - The result to write. * @returns The text for a readout cell. */function describe(result: PickResult): string {  return result.ms === 0 ? result.label : `${result.label} · ${result.ms.toFixed(2)} ms`;}/** * Names what a pick found. * * @param hit - The pick, or `null` for a miss. * @returns The entity's name, or a dash. */function nameOf(hit: RenderPick | null): string {  return hit === null ? "— nothing" : hit.entity.name;}bootExample({  title: "Picking",  settings: {    rendering: { clearColor: CLEAR_COLOR, msaaSamples: 4, features: { shadows: true } },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, canvas, panel, afterStart }) {    app.registerComponents([ClickToPick]);    // `loadActions` merges by map name, so the orbit camera's own map is untouched.    app.input.loadActions(defineInputActions(PICK_ACTIONS));    const eye = app.world.createEntity("Main Camera");    const camera = eye.addComponent(Camera, { near: 0.1, far: 200, fov: SHOT.fov });    attachOrbit(app, eye, {      yaw: SHOT.yaw,      pitch: SHOT.pitch,      distance: SHOT.distance,      target: SHOT.target,      minDistance: 2,      maxDistance: 14,    });    createLightRig(app, { focus: SHOT.target, shadows: true, shadowDarkness: 0.25 });    // Darker than the kit's default so a grey shape reads against it, and so the one orange shape    // is the only thing in the frame the eye goes to.    const ground = await createGridGround(app, { size: 24, color: { r: 0.17, g: 0.19, b: 0.23, a: 1 } });    const scene = createPickableScene(app);    let cpu = UNPICKED;    let gpu = UNPICKED;    let agree = "";    // One ray, reused: `screenToRay` writes into what it is given, so a pick allocates nothing    // (coding standards §7).    const ray = createRay();    const picker: Picker = {      pickAt(x: number, y: number): void {        // The CPU path first, because it is synchronous and it is what sets the highlight — so a        // capture is the same frame whatever the device does with the GPU pick.        const cpuStart = performance.now();        const filled = camera.screenToRay(x, y, ray);        const cpuHit = filled === null ? null : app.world.raycastRender(filled);        cpu = { label: nameOf(cpuHit), ms: performance.now() - cpuStart };        scene.highlight(cpuHit?.entity ?? null);        const gpuStart = performance.now();        agree = "waiting for the device";        void app.renderer          .pickAsync(x, y)          .then((gpuHit: RenderPick | null): void => {            gpu = { label: nameOf(gpuHit), ms: performance.now() - gpuStart };            agree = gpuHit?.entity === cpuHit?.entity ? "yes" : "no — read the two rows above";          })          .catch((error: unknown): void => {            gpu = { label: "the pick failed", ms: 0 };            app.log.warn("pickAsync:", error);          });      },    };    app.world.createEntity("Pointer").addComponent(ClickToPick).picker = picker;    // One pick before the first frame is called settled, so the example opens on an answer rather    // than on an instruction — and so a `?static=1` capture shows the highlight. Where to pick    // comes from `worldToScreen`, which is `screenToRay`'s inverse and answers in the same    // backing-store pixels the pick calls take: project a shape's centre, then pick that pixel.    const projected = new Vec3();    afterStart((): void => {      camera.worldToScreen(scene.opening.transform.position, projected);      picker.pickAt(projected.x, projected.y);    });    panel({      title: "Picking",      groups: [        {          label: "The same pixel, twice",          controls: [            readout("GPU pickAsync", (): string => describe(gpu)),            readout("CPU raycastRender", (): string => describe(cpu)),            readout("Same entity", (): string => agree),            button("Pick the centre", (): void => {              picker.pickAt(canvas.width / 2, canvas.height / 2);            }),            button("Clear", (): void => {              scene.highlight(null);              cpu = UNPICKED;              gpu = UNPICKED;              agree = "";            }),          ],        },        {          label: "Scene",          collapsed: true,          controls: [            // `pickable` is what both paths skip, which is what makes "pick only the pickups" a            // property of the mesh rather than a filter written at every call site.            toggle("Ground is pickable", {              value: false,              change: (on: boolean): void => {                const renderer = ground.entity.getComponent(MeshRenderer);                if (renderer !== null) {                  renderer.pickable = on;                }              },            }),            readout("Draw calls", (): string => String(app.renderer.drawCalls)),            readout("Pickable shapes", (): string => String(scene.count)),          ],        },      ],    });  },});
scene.ts
/** * The scene `picking` picks in: the seven shapes, their two materials, and the material swap that * shows which one was hit. * * @remarks * A separate file for the reason `pbr-model/shot.ts` is: none of it is a lesson about picking. * `main.ts` is then the two pick calls, the click that triggers them and the readouts that compare * them, which is all a reader came for. * * Every mesh is built in code, so the example fetches nothing and the whole scene is reproducible * from the numbers below. Two rows rather than one, and seven shapes rather than three, because * the comparison only means something when a click can miss: the gaps are where both paths answer * `null`, and the torus has a hole a bounding box would fill and neither of these paths does. */import { createMaterialAsset, MeshAsset, MeshRenderer, pbrMaterialDefinition } from "ignifx";import type { App, AssetHandle, ColorLike, Entity } from "ignifx";/** Where one shape stands, and which mesh it is. */interface Shape {  /** The entity's name, which is what the readouts show when it is hit. */  readonly name: string;  /** Metres along X from the scene's centre. */  readonly x: number;  /** How high the origin sits so the shape rests on the ground, in metres. */  readonly y: number;  /** Metres along Z; negative is towards the camera at a yaw of zero. */  readonly z: number;  /**   * Builds the mesh.   *   * @param app - The app the mesh belongs to.   * @returns The handle, with one holder — the renderer that is about to take it.   */  readonly mesh: (app: App) => AssetHandle<MeshAsset>;}/** The colour a shape is drawn in until it is picked. */const IDLE_COLOR: ColorLike = { r: 0.42, g: 0.45, b: 0.52, a: 1 };/** The colour the picked shape is drawn in: the site's ember, so a hit is unmistakable. */const PICKED_COLOR: ColorLike = { r: 0.93, g: 0.42, b: 0.16, a: 1 };/** The near-black the frame is cleared to, matching the site's dark background. */export const CLEAR_COLOR: ColorLike = { r: 0.043, g: 0.059, b: 0.094, a: 1 };/** The opening shot: high enough to see both rows and the gaps between them. */export const SHOT = { fov: 42, yaw: 14, pitch: 30, distance: 5.4, target: { x: 0, y: 0.28, z: 0 } } as const;/** The scene, in the order it is built. */const SHAPES: readonly Shape[] = [  { name: "Box", x: -1.85, y: 0.33, z: -1.15, mesh: (app: App) => MeshAsset.box(app, { size: 0.66 }) },  { name: "Sphere", x: -0.62, y: 0.35, z: -1.15, mesh: (app: App) => MeshAsset.sphere(app, { diameter: 0.7 }) },  {    name: "Cylinder",    x: 0.62,    y: 0.4,    z: -1.15,    mesh: (app: App) => MeshAsset.cylinder(app, { height: 0.8, diameter: 0.6, tessellation: 24 }),  },  {    name: "Capsule",    x: 1.85,    y: 0.45,    z: -1.15,    mesh: (app: App) => MeshAsset.capsule(app, { height: 0.9, radius: 0.22, tessellation: 16 }),  },  {    name: "Torus",    x: -1.25,    y: 0.12,    z: 0.95,    mesh: (app: App) => MeshAsset.torus(app, { diameter: 0.85, thickness: 0.24, tessellation: 24 }),  },  {    name: "Cone",    // A cone is a cylinder with one end closed to a point; ignifx has no separate factory.    x: 0.1,    y: 0.4,    z: 0.95,    mesh: (app: App) => MeshAsset.cylinder(app, { height: 0.8, diameterTop: 0, diameterBottom: 0.7 }),  },  {    name: "Slab",    x: 1.5,    y: 0.14,    z: 0.95,    mesh: (app: App) => MeshAsset.box(app, { width: 0.95, height: 0.28, depth: 0.62 }),  },];/** The built shapes, and the two things `main.ts` needs from them. */export interface PickableScene {  /** How many shapes there are, for the panel's readout. */  readonly count: number;  /** The shape the example opens picked, so a capture shows an answer rather than an instruction. */  readonly opening: Entity;  /**   * Draws one shape as the picked one and the rest as they were.   *   * @param entity - What a pick found, or `null` to clear the highlight.   */  readonly highlight: (entity: Entity | null) => void;}/** * Builds the shapes and the highlight. * * @remarks * The highlight is a material swap and nothing else. `MeshRenderer.materials` is a plain array of * handles, assigning it is the whole change, and it is on screen the next frame — no second pass, * no outline shader, no per-entity state to keep in step. * * @param app - The app the entities and the assets belong to. * @returns The scene's count and its highlight function. * * @example * ```ts * const scene = createPickableScene(app); * scene.highlight(hit?.entity ?? null); * ``` */export function createPickableScene(app: App): PickableScene {  const idle = createMaterialAsset(    app,    pbrMaterialDefinition({ name: "picking/idle", baseColor: IDLE_COLOR, metallic: 0.1, roughness: 0.55 }),    [],  );  const hot = createMaterialAsset(    app,    pbrMaterialDefinition({ name: "picking/picked", baseColor: PICKED_COLOR, metallic: 0.1, roughness: 0.35 }),    [],  );  const renderers = new Map<Entity, MeshRenderer>();  for (const shape of SHAPES) {    const entity = app.world.createEntity(shape.name);    entity.transform.localPosition.set(shape.x, shape.y, shape.z);    renderers.set(      entity,      entity.addComponent(MeshRenderer, { mesh: shape.mesh(app), materials: [idle], castShadows: true }),    );  }  let selected: Entity | null = null;  // The cylinder: middle of the front row, and the tallest thing near the frame's centre.  const opening = [...renderers.keys()].find((entity: Entity): boolean => entity.name === "Cylinder");  return {    count: renderers.size,    opening: opening ?? app.world.createEntity("Nothing"),    highlight(entity: Entity | null): void {      const previous = selected === null ? undefined : renderers.get(selected);      if (previous !== undefined) {        previous.materials = [idle];      }      selected = entity;      const next = entity === null ? undefined : renderers.get(entity);      if (next !== undefined) {        next.materials = [hot];      }    },  };}
click-to-pick.ts
/** * The click: an action map, and the `Script` that turns a press-and-release on the canvas into one * pick. * * @remarks * A script on `@ignifx/input` actions rather than a DOM listener, because that is what a game * writes: the same class works with a mouse, with a finger and with a gamepad-driven cursor, and * `<Pointer>/position` already reports **backing-store pixels**, which is the space both pick * calls take. A DOM `pointerup` would have to be multiplied by the device pixel ratio first. * * Two details are what make it feel right rather than nearly right. `app.input.uiHasPointer` is * checked on the press, so a drag that starts on the parameter panel never picks. And the press * position is remembered, so the orbit camera's own gesture — a drag across the canvas — is not * read as a click on whatever happens to be under the release. */import { Script } from "ignifx";import type { InputActionsInput, ScriptCallbacks } from "ignifx";/** The action map this example loads. Its own map, so the orbit camera's is untouched. */export const PICK_ACTION_MAP = "Picking";/** How far the pointer may travel between press and release and still count as a click, in pixels. */export const CLICK_SLOP_PIXELS = 6;/** The two actions the click needs, as a document `app.input.loadActions` takes. */export const PICK_ACTIONS: InputActionsInput = {  maps: [    {      name: PICK_ACTION_MAP,      actions: [        { name: "pickPress", type: "button", bindings: [{ path: "<Pointer>/press" }] },        { name: "pickPosition", type: "vector2", bindings: [{ path: "<Pointer>/position" }] },      ],    },  ],};/** Runs both picks at one pixel. `main.ts` implements it; the script only calls it. */export interface Picker {  /**   * Picks the pixel twice and updates the readouts and the highlight.   *   * @param x - The backing-store pixel x, from the canvas's left edge.   * @param y - The backing-store pixel y, from the canvas's top edge.   */  pickAt(x: number, y: number): void;}/** * Picks on a click that did not drag. * * @example * ```ts * app.registerComponents([ClickToPick]); * app.world.createEntity("Pointer").addComponent(ClickToPick).picker = picker; * ``` */export class ClickToPick extends Script implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "picking/ClickToPick";  /**   * What a click is handed to. Assigned in code rather than declared as a schema field, because a   * picker is a closure over the scene and not something a scene file could carry.   */  picker: Picker | null = null;  /** Where the pointer went down, in backing-store pixels. */  #pressX = 0;  /** Where the pointer went down, in backing-store pixels. */  #pressY = 0;  /** Whether the press in flight started on the canvas rather than on the parameter panel. */  #onCanvas = false;  /** Reads the frame's pointer state and picks on a release that stayed put. */  update(): void {    const press = this.app.input.actions.find("pickPress");    const at = this.app.input.actions.find("pickPosition");    if (press === null || at === null) {      return;    }    if (press.wasPressedThisFrame) {      this.#pressX = at.vector.x;      this.#pressY = at.vector.y;      this.#onCanvas = !this.app.input.uiHasPointer;    }    if (!press.wasReleasedThisFrame || !this.#onCanvas) {      return;    }    this.#onCanvas = false;    if (Math.hypot(at.vector.x - this.#pressX, at.vector.y - this.#pressY) <= CLICK_SLOP_PIXELS) {      this.picker?.pickAt(at.vector.x, at.vector.y);    }  }}

Uses:app.renderer.pickAsyncworld.raycastRenderCameraMeshRendererMeshAssetScript

Assets:everything in this example is created in code.