@forgeax/engine-picking
v0.2.1
Published
Screen-to-entity + vertex-level + tile-cell picking free functions for forgeax-engine (Tier 2.2 -- extracted from @forgeax/engine-runtime).
Readme
@forgeax/engine-picking
Screen-to-entity, vertex-level, and tile-cell picking as free functions. Tier 2.2
package extracted from @forgeax/engine-runtime
(feat-20260705-runtime-tier2-decomposition M2) so an AI user loads only the
picking concept surface — not the whole renderer — when the task is "turn a
screen coordinate into an entity / vertex / tile". First runtime-downstream
engine package: @forgeax/engine-picking depends on @forgeax/engine-runtime,
never the reverse.
30-second self-introduction
pick(world, cameraEntity, screenX, screenY, viewportWidth, viewportHeight)— unprojects a viewport-relative screen coordinate into a world-space ray through the camera, walks every renderable archetype, ray-AABB tests each pickable mesh's world-space bounding box, and returns the nearestPickHit { entity, point, distance }(orundefinedon a miss). AABB granularity is intentional; usepickTrianglewhen exact surface and occlusion ordering are required.viewportToWorld(world, cameraEntity, screenX, screenY, viewportWidth, viewportHeight)— exposes the same camera unprojection as a world-spaceRayfor cursor placement, gizmos, and custom plane/triangle queries.pickDisplay/computeDisplayScreenRay— explicit display-space entrypoints. They consume the effectiveBarrelDistortionMappingfrom the submitted frame, map the displayed physical pixel once, then use the existing unwarped camera math. The submitted mapping is mandatory, including its identity case; absent, retired, lost, or zero-size frame contexts return a miss. LegacypickandviewportToWorldremain unwarped APIs.pickVertexDisplay/pickVertexOnEntityDisplay— display-space vertex queries. They project each candidate back through the same scene-to-display inverse before applying the optional physical-pixel radius and sorting, so a nonlinear warp cannot change which vertex is closest merely by correcting the pointer.
[!WARNING] An omitted
barrelDistortionfield means that no accepted submitted display context is available. Treat it as a miss and wait for a new frame. An identity query is valid only with an explicit submitted mapping whosestrengthis0.
pickTriangle— exact static CPU triangle query for a screen ray. It transforms indexed or non-indexed triangle-list vertices into world space, returns the nearest world point, distance, barycentric weights, entity, triangle index, and optional Catalog GUID, or reportsunavailablewhen CPU geometry or the current skinned pose cannot be tested.pickVertexOnEntity/pickVertex— per-triangle vertex-level queries (editor vertex-snapping workflow). Three-state static-dispatch overload: without options ->VertexHit | undefined; with{ limit: N }->VertexHit[]sorted byscreenDist.pickVertexOnEntityqueries one entity;pickVertexwalks the whole scene (AABB coarse cull, then per-entity vertex collect).pickTile(world, tilemapEntity, worldX, worldY)— cell-level Tilemap query: converts world coordinates through the full inverse of the propagatedGlobalTransform.worldaffine matrix, walks childTileLayers in descendinglayerOrder, and returnsResult.ok(PickTileHit { layerEntity, cellX, cellY, tileId })for the topmost non-zero cell,Result.ok(null)for empty / out-of-bounds, orResult.err(PickTileError)for a structural break.PickError/PickErrorCode— closed single-member error union ('camera-component-missing'); the SSOT for the picking error surface. AcameraEntitywithout aCameracomponent throwsPickError; ordinary "ray hit nothing" outcomes returnundefined/[](error channel physically separated from the miss channel, charter P3).pick-core(internal) — the shared skeleton (camera validation ->view = invert(GlobalTransform.world)-> projection branch ->screenToRay->readWorldMatrix) thatpickandpickVertex*both consume. Single source of truth (architecture-principles §2); the AI user never imports it directly.
30s hands-on example
import { pick, type PickHit } from '@forgeax/engine-picking';
import { MeshRenderer } from '@forgeax/engine-render';
import { propagateTransforms } from '@forgeax/engine-scene';
// Caller resolves GlobalTransform.world for the current frame first (D-9 contract):
propagateTransforms(world);
const hit: PickHit | undefined = pick(
world,
cameraEntity,
pointerX, pointerY, // viewport-relative, y-down, top-left origin
canvas.width, canvas.height,
);
if (hit) {
// hit.entity: the picked EntityHandle; hit.point: world-space AABB entry;
// hit.distance: entry distance along the ray (>= 0)
world.set(hit.entity, MeshRenderer, { materials: [highlight] });
}API surface
Screen-to-entity (pick)
| Function | Signature | Return |
|:--|:--|:--|
| pick | (world, cameraEntity, screenX, screenY, viewportWidth, viewportHeight) | PickHit \| undefined (nearest hit, or undefined on miss) |
PickHit = { entity: EntityHandle; point: Vec3Like; distance: number }. No
face / uv / normal — AABB picking has no triangle resolution, so those
would be a lie (use pickTriangle or pickVertex for precise geometry). Both perspective
and orthographic camera projections are supported. Reads the resolved
GlobalTransform.world mat4 directly (feat-20260601 D-3), so the camera + candidates
must have propagated transforms for the current frame.
Display-space interaction
import { pickDisplay } from '@forgeax/engine-picking';
const hit = pickDisplay(
world,
outputPixelX,
outputPixelY,
submittedReceipt.barrelDistortion,
outputWidth,
outputHeight,
);Coordinates are continuous physical output pixels. The host first converts the
CSS pointer through the actual canvas/viewport rectangle and output extent; it
does not add a half-pixel offset. Out-of-viewport and crop misses return
undefined before the math layer's normal screen clamp. Reuse the same receipt
mapping for labels, crosshairs, and display-space queries so a newer ECS camera
component cannot disagree with the frame on screen. The App
subscribeBrowserFrameSubmitted(canvas, listener) helper forwards the
deep-frozen mapping and frame identity from the accepted browser submission;
unsubscribe it when the canvas or renderer is retired. A lost or zero-size
context must be discarded and reacquired through the existing App/Renderer
recovery path. The width and height arguments are an explicit check against
mapping.width and mapping.height; a mismatch, an undefined mapping, or an
identity guess without a submitted mapping returns a miss. Display picking
never falls back to the live World camera.
Screen-to-world (viewportToWorld)
| Function | Signature | Return |
|:--|:--|:--|
| viewportToWorld | (world, cameraEntity, screenX, screenY, viewportWidth, viewportHeight) | Ray \| undefined |
The returned ray uses the same top-left/y-down viewport coordinates as pick.
It is the low-level cursor-to-world front door: intersect it with the game
surface you own, then place an entity or debug primitive at the result.
[!IMPORTANT] Viewport validity — if either viewport dimension is zero, negative,
NaN, orInfinity,pick,viewportToWorld,pickVertex, andpickVertexOnEntityreturn their ordinary no-ray/no-hit shape (undefinedor[]). They do not delegate an invalid viewport to a fabricated origin ray. Restore positive, finite dimensions and retry on the same World; the normal mesh, vertex, and ray results recover without rebuilding the scene.
Vertex-level (pickVertex / pickVertexOnEntity)
| Function | Signature | Return |
|:--|:--|:--|
| pickVertexOnEntity | (world, cameraEntity, screenX, screenY, vpW, vpH, entity, options?) | Without options: VertexHit \| undefined |
| pickVertexOnEntity | (..., entity, { limit }) | VertexHit[] (sorted by screenDist asc, empty on miss) |
| pickVertex | (world, cameraEntity, screenX, screenY, vpW, vpH, options?) | Without options: VertexHit \| undefined |
| pickVertex | (..., { limit }) | VertexHit[] (globally sorted by screenDist asc, empty on miss) |
VertexHit = { entity, vertexIndex, worldPos: Vec3Like, screenDist, worldDist, deformed }.
Only triangle-list submeshes participate; skinned meshes report
deformed=true with rest-pose worldPos. Behind-camera vertices are excluded.
[!IMPORTANT]
propagateTransformsprecondition (D-9) — callpropagateTransforms(world)(exported from@forgeax/engine-runtime) for the current frame beforepick/pickVertex*. These functions readGlobalTransform.worldcolumn-major mat4 directly; they never re-propagate. The contract is identical acrosspickandpickVertex*.
Exact triangle (pickTriangle)
| Function | Signature | Return |
|:--|:--|:--|
| pickTriangle | (world, cameraEntity, screenX, screenY, vpW, vpH, options?) | TrianglePickResult |
TrianglePickResult is a closed three-state result: hit contains the
nearest TriangleHit; miss means every candidate was tested and no triangle
intersects the ray; unavailable names intersecting entities whose CPU
geometry, current skinned pose, or explicit instance transforms cannot be
tested. For an entity carrying Instances, the picker reads World-owned
Instances.transforms; a hit includes the zero-based instanceIndex.
No Renderer or collection resolver is needed. Pass
AssetRegistry.guidOf as assetGuidOf when provenance is needed. The query
does not mutate the World or own an asset registry.
Tile-cell (pickTile)
| Function | Signature | Return |
|:--|:--|:--|
| pickTile | (world, tilemapEntity, worldX, worldY) | Result<PickTileHit \| null, PickTileError> |
PickTileHit = { layerEntity, cellX, cellY, tileId }. Callers propagate the
World before picking so GlobalTransform.world is current; an entity without a
Transform retains the origin-default path. Result.ok(null) = empty cell or
out-of-bounds. A dead handle returns tilemap-not-found; a live entity without
Tilemap returns tilemap-component-missing. PickTileError is a closed
two-member discriminated union, runtime-local (not exported through
@forgeax/engine-types). Singular transforms use the shared mat4.invert
identity fallback deterministically, without widening the error union.
Error model
PickError owns the package-local precondition code in src/pick-errors.ts, and
PickErrorCode derives from PickError['code']. The declaration proof at
src/__tests__/pick-errors.test-d.ts checks that owner relationship, the closed
surface, invalid literals, and exhaustive switching.
When the cameraEntity passed to pick or pickVertex* has no Camera
component, the functions throw PickError: no view/projection matrix can be
built, and the error carries .expected, .hint, and .detail.cameraEntity.
Attach a Camera with world.set as directed by .hint, then retry. Ordinary
ray misses still return undefined / []; PickTileError remains a separate
two-member union returned (not thrown) through Result.
Package boundary
Depends on @forgeax/engine-runtime (components: Camera / Transform /
MeshFilter / MeshRenderer / ChildOf / TileLayer / Tilemap;
propagateTransforms), @forgeax/engine-assets-runtime (resolveAssetHandle
for MeshAsset.aabb), @forgeax/engine-ecs, @forgeax/engine-math, and
@forgeax/engine-types. @forgeax/engine-runtime does not import this
package (no reverse edge — picking is a leaf consumer).
Visible acceptance: apps/hello/picking (click a cube to highlight) +
structural-only dawn-node smoke (asserts pick returns the expected entity + a
miss returns undefined).
Source anchors
src/pick.ts—pick+PickHitsrc/pick-vertex.ts—pickVertex/pickVertexOnEntity+VertexHitsrc/pick-tile.ts—pickTile+PickTileHit/PickTileErrorsrc/pick-triangle.ts— exact triangle/occlusion query + unavailable statesrc/pick-errors.ts—PickError/PickErrorCode(error SSOT)src/pick-core.ts— shared camera->ray skeleton (internal)
