All examples
Picking
- Mouse
- Touch
- Gamepad
Seven generated shapes on a grid, and every click resolves the same pixel twice. `app.renderer.pickAsync` draws an id buffer and reads one pixel back from the device; `world.raycastRender` walks the scene's meshes on the CPU and answers in the same call. Both report the entity they hit, both skip a renderer whose `pickable` is false, and the two rows in the panel agree on every click — while their timings do not, because one of them waits for the device. Both take backing-store pixels, which is why the click arrives as an `@ignifx/input` action rather than as a DOM event.

WebGPU: checking…See browser support
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
Source
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";/** * Picking, both ways, at the same pixel. Click a shape and it is resolved twice: * * - `app.renderer.pickAsync(x, y)` — the **GPU** path. Lite draws an id buffer and reads one pixel * back from the device, so it is exact for anything the GPU can draw and it costs a round trip. * It is a promise for that reason, not for tidiness, and calls are serialized per picker. * - `world.raycastRender(camera.screenToRay(x, y))` — the **CPU** path. It walks the scene's meshes, * tests each one's bounding box and then its triangles, and answers in the same call. A mesh that * kept no CPU positions is invisible to it, and a hidden mesh still blocks it — only `pickable` * and the filter take a mesh out of either path. * * Both answer `{ entity, component, distance }` or `null`, so the two rows in the panel should * agree on every click. The timings should not: the GPU figure includes the readback wait and the * CPU figure includes no wait at all. * * **Both take backing-store pixels** — `canvas.width`/`canvas.height`, the space * `<Pointer>/position` reports in and `Camera.worldToScreen` answers in — not CSS pixels. That is * why the click arrives through `@ignifx/input` (`click-to-pick.ts`) rather than off a DOM event: * the action already speaks the right units, on a mouse and on a finger alike. *//** 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)), ], }, ], }); },});/** * 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]; } }, };}/** * 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); } }}