@solidrt/3d 0.0.56 → 0.0.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENTS.md +199 -117
  2. package/README.md +2 -1
  3. package/examples/README.md +13 -8
  4. package/examples/collision.tsx +17 -4
  5. package/examples/first-person.tsx +20 -10
  6. package/examples/fleet.tsx +11 -5
  7. package/examples/model.tsx +3 -3
  8. package/examples/pick.tsx +144 -34
  9. package/examples/probe.tsx +10 -4
  10. package/examples/scene-views.tsx +49 -58
  11. package/examples/sky-lit.tsx +10 -4
  12. package/examples/skybox.tsx +10 -4
  13. package/examples/standard.tsx +10 -4
  14. package/package.json +2 -2
  15. package/src/collision.ts +14 -193
  16. package/src/components/context.tsx +35 -0
  17. package/src/components/directional-light.tsx +48 -0
  18. package/src/components/first-person-camera.tsx +72 -0
  19. package/src/components/group.tsx +18 -0
  20. package/src/components/hemisphere-light.tsx +24 -0
  21. package/src/components/index.ts +46 -0
  22. package/src/components/instanced-mesh.tsx +105 -0
  23. package/src/components/mesh.tsx +92 -0
  24. package/src/components/node-props.ts +90 -0
  25. package/src/components/orbit-camera.tsx +75 -0
  26. package/src/components/perspective-camera.tsx +32 -0
  27. package/src/components/point-light.tsx +52 -0
  28. package/src/components/record-mesh.tsx +68 -0
  29. package/src/components/scene.tsx +281 -0
  30. package/src/components/spot-light.tsx +65 -0
  31. package/src/components/sprite.tsx +38 -0
  32. package/src/components/view3d.tsx +152 -0
  33. package/src/first-person.ts +140 -188
  34. package/src/index.ts +13 -9
  35. package/src/input.ts +70 -0
  36. package/src/node.ts +98 -25
  37. package/src/orbit.ts +266 -235
  38. package/src/scene-pointer.ts +229 -101
  39. package/src/scene.ts +250 -76
  40. package/src/components.tsx +0 -1101
package/AGENTS.md CHANGED
@@ -21,7 +21,7 @@ blendMode and pointer events like any element.
21
21
  change, however many meshes. World matrices live in the core only:
22
22
  `worldPosition`/`lookAt`/picking read them back (`worldMatrix`, pending
23
23
  writes included). See okf/backlog/spatial-core.md for what still runs
24
- in JS and why. The component face (`Scene`/`Group`/`Mesh`/
24
+ in JS and why. The component face (`Scene`/`View3d`/`Group`/`Mesh`/
25
25
  `PerspectiveCamera`) syncs props into that core over context and renders
26
26
  nothing itself.
27
27
  - Node lifecycle: `add(parent, child)` attaches (re-parenting detaches
@@ -42,8 +42,9 @@ blendMode and pointer events like any element.
42
42
  structure, per-frame motion goes straight to the scene).
43
43
  - VIEWS: `scene.createView({ width, height, overrideMaterial?, depth?,
44
44
  clearColor?, ... })` renders the same scene into a second target from
45
- its own camera (`view.setCamera`, the scene's CameraUpdate shape). Each
46
- mesh gets one entry in the view's target bound as one more draw sink of
45
+ its own camera (`view.setCamera`, the scene's CameraUpdate shape; in a
46
+ component tree `<View3d>` is the same as a Scene child, see Components).
47
+ Each mesh gets one entry in the view's target bound as one more draw sink of
47
48
  its CORE node, so the one flush writes every target - the app writes
48
49
  nothing per view. Geometry buffers and (without an override) materials
49
50
  are shared; the light set and `scene.setParams` names fan out to every
@@ -228,8 +229,8 @@ blendMode and pointer events like any element.
228
229
  GPU-free BY DESIGN so they can be checked headless (and, for the two
229
230
  model modules, run under bun in `tools/model.ts`); keep them that way.
230
231
  The rigs under `checks/`
231
- (`geometry-check`, `sweep-check`, `pick-check`, `order-check`,
232
- `gltf-check`) run on
232
+ (`geometry-check`, `sweep-check`, `pick-check`, `dispatch-check`,
233
+ `order-check`, `gltf-check`) run on
233
234
  flux from the repo root: `bunx srt bundle -f --stdout
234
235
  packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
235
236
  touching what you changed. `raycast-check.tsx` and
@@ -247,7 +248,8 @@ blendMode and pointer events like any element.
247
248
 
248
249
  | Component | Props |
249
250
  | --- | --- |
250
- | `Scene` | `width?`, `height?` (target pixels - both, or neither = FILL, below), `clearColor?`, `camera?` (partial CameraUpdate, `ortho` included - the declarative scene.setCamera; same state as `PerspectiveCamera`, use one form), `background?` (fragment GLSL, or a skybox `{ cube, intensity?, rotation? }`), `environment?` (`{ cube, intensity?, rotation? }`, the cube reflective materials mirror), `fog?` (`{ color, near, far }`, linear by camera distance), `toneMapping?` (`"none"` default or `"aces"`), `exposure?` (default 1), `layers?` (target mask, default 1), `depth?` (`"texture"` exposes scene.depthTexture; not with samples), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
251
+ | `Scene` | `width?`, `height?` (target pixels - both, or neither = FILL, below), `clearColor?`, `camera?` (partial CameraUpdate, `ortho` included - the declarative scene.setCamera; same state as `PerspectiveCamera`, use one form), `background?` (fragment GLSL, or a skybox `{ cube, intensity?, rotation? }`), `environment?` (`{ cube, intensity?, rotation? }`, the cube reflective materials mirror), `fog?` (`{ color, near, far }`, linear by camera distance), `toneMapping?` (`"none"` default or `"aces"`), `exposure?` (default 1), `layers?` (target mask, default 1), `depth?` (`"texture"` exposes scene.depthTexture; not with samples), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (pointer events, default on), `pointer?` (the leaf's pointer feed, fed from the scene's root), `onPointerDown/Move/Up?`, `onWheel?`, `onTap?` (the scene's own handlers, the last stop of the walk - `event.mesh` null over empty space) |
252
+ | `View3d` | a Scene child rendering the scene again from a camera of its own (scene.createView as a component): `width`, `height` (target pixels, live; fixed-size only for now), `x?`, `y?` (the tile's top-left in `into`, live), `into?` (tile an app-owned draw target - one pass for every view into it; fixed at creation), `camera?` (partial CameraUpdate on the view's camera, live; same state as a `PerspectiveCamera` child), `layers?` (the view's mask, live), `clearColor?`, `label?`, `overrideMaterial?`, `fog?` (FogOptions, or null for none), `depth?`, `samples?`, `filter?`, `wrap?` (createView's, fixed), `ref?(view)`, `output?(texture)` (else a built-in `<texture>` leaf at the target size, a tile shown through srcX/srcY), `events?`, `pointer?` (the view leaf's feed, fed from the view's root), `onPointerDown/Move/Up?`, `onWheel?`, `onTap?` (the view's own handlers); camera-control children drive the VIEW (inside, `useScene()` reports the view as `viewport` and the view's feed as `pointer`); node children mount to the scene as outside, and under the view's leaf get their ordinary pointer handlers, picked through the view's camera (`view.pick`), the view as the root of that walk |
251
253
  | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
252
254
  | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), `renderOrder?`, `castShadow?`, `layers?` (membership bitmask, default 1), pointer events (below), `ref?(mesh)` |
253
255
  | `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
@@ -276,9 +278,10 @@ while the target follows the leaf's on-screen size in DEVICE pixels -
276
278
  display scale, `designSize` fits and ancestor transforms included
277
279
  (getBoundingBoxViewport x displayScale, applied from onLayout, so no
278
280
  frame draws at a stale size). A bare `<Scene>` in a pane renders at
279
- native density on any display, mesh events and `<OrbitCamera>` input are
280
- wired automatically (event scaling reads the leaf's untransformed box
281
- back with `getLayoutBox`), and the camera aspect follows the box. Fill
281
+ native density on any display, mesh events are wired automatically
282
+ (event scaling reads the leaf's untransformed box back with
283
+ `getLayoutBox`), the `pointer` feed you hand in is spread on the leaf,
284
+ and the camera aspect follows the box. Fill
282
285
  or fixed is decided at mount; `output` requires explicit sizes (the
283
286
  target cannot follow a leaf it does not own), and giving exactly one of
284
287
  width/height throws.
@@ -293,98 +296,139 @@ examples fill; scene-views and scene-post-effect keep fixed sizes to
293
296
  show multi-view composition and supersampling). A custom `output` leaf
294
297
  whose layout differs from the target
295
298
  takes `handlersFor` (below), not `handlers`; `useScene()` works inside
296
- `output` because it runs in the scene context. With an `<OrbitCamera>`
297
- (or any SceneInput listener) in the scene, also spread
298
- `{...useScene().input.handlersFor(layout)}` on the leaf - the mesh-event
299
- and control-input channels are separate spreads with the same layout.
299
+ `output` because it runs in the scene context. One spread carries
300
+ everything: the `pointer` feed listens at the scene's root, so
301
+ `{...useScene().scene.handlers}` feeds the nodes, the scene's own
302
+ handlers and the feed alike (a detached leaf gives its feed a `layout`,
303
+ it has no layout box to normalize by).
304
+
305
+ Input, the rule (ARCHITECTURE.md): a camera control consumes a
306
+ device-free abstraction and never handles events or reads a device
307
+ itself. The controls expose `axes` (core's createAxes contract - rates
308
+ sampled per frame, immediate deltas in device-free units) and pose verbs;
309
+ an input map (`createInputMap`, core AGENTS.md) drives the axes by action
310
+ name, and the APP binds devices to the map: the scene leaf's pointer feed
311
+ (`createPointerFeed()`, handed to `<Scene pointer>` / `<View3d pointer>`,
312
+ where it listens at the root of the pointer walk, so a mesh that claims
313
+ its press keeps the control out of that drag; an imperative scene or
314
+ view feeds it with `feedPointer(scene, feed)`), a pad (`gamepad(0)`, or `gamepad.next()`
315
+ for the next pad to press a button), the keyboard (`keyboard`, its key
316
+ events through `input.handlers` on the window).
317
+ Nothing binds by default: `<OrbitCamera />` without `input` moves only
318
+ through its handle. The standard wiring is a preset, plain bindings the
319
+ app applies and edits:
320
+
321
+ ```tsx
322
+ let pointer = createPointerFeed()
323
+ let input = createInputMap(orbitActions)
324
+ input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
325
+ <Scene pointer={pointer}>
326
+ <OrbitCamera input={input} distance={7} />
327
+ ```
328
+
329
+ The vocabulary is shared with @solidrt/2d, one kind and unit per word:
330
+ `rotate` vec2 (orbit turns), `zoom` axis (octaves, positive in), `pan`
331
+ vec2 (element heights), `look` vec2 (turns), `move` vec2 ([right,
332
+ forward], forward = -y in the screen convention), `rise` axis. Keys and
333
+ sticks move the CAMERA where a drag moves the content, so the presets
334
+ bind them to `rotate`/`pan` through `invert()`; `look` is the exception,
335
+ a drag and a stick both turn the eye.
300
336
 
301
337
  Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
302
338
  distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
303
- zoomSpeed?, zoomAnchor?, rotateAnchor?, panSpeed?, viewport?, clampTarget? })`
304
- - drag-to-rotate, pinch- and wheel-to-zoom, two-finger pan, optional
305
- auto-orbit. The first argument is anything with the scene's `setCamera`: a
306
- Scene, or a View to drive one view's camera independently (one orbit per
307
- view, each handed the handlers of its own viewport element). Input runs on
308
- core's `createTransform` recognizer, so drag and pinch arbitrate in the
309
- app-wide gesture arena (a viewport inside a scroller
310
- does not double-handle) and rotation starts after the recognizer's slop;
311
- `zoomSpeed` weights both wheel and pinch. Two-finger translation pans (the
312
- scene tracks the fingers 1:1 at target depth, weighted by `panSpeed`) when
313
- `viewport()` supplies `{ height, fov }` for the pixel-to-world mapping -
314
- without it, it rotates like one finger; `clampTarget(target)` bounds where
315
- a pan may put the pivot. Zoom aims
316
- at the target unless `zoomAnchor(x, y, {eye, target})` maps the pinch focal
317
- / wheel cursor to a world point (ground hit, target-depth plane, ...) - then
318
- that point stays pinned under the pointer and the target slides toward it;
319
- only the app can build that mapping, since fov and aspect are app state
320
- (the point arrives in the input element's own pixels). Pair it with `rotateAnchor({eye, target})`: called at gesture
321
- start, its point is projected onto the view axis and re-seats the pivot
322
- without moving the picture, so a drag after an anchored zoom orbits what the
323
- camera looks at, not wherever the zoom left the target. Spread
324
- `orbit.handlers` onto the input-owning element, call `orbit.update(dt)`
325
- from your onFrame (no frame loop of its own), and use its return - true
326
- when the pose changed - to gate per-frame dependents like reprojecting
327
- HUD overlays. `orbiting()` (the auto-orbit switch) and `active()` (the
328
- frame-loop gate: orbiting with a non-zero rate - the predicate the 2d
329
- camera and the first-person camera share) are reactive (HUD-safe); the
330
- pose is plain state via `pose()`/`set()` (also the debug-command shape). It drives position and
331
- target only; fov/near/far stay on scene.setCamera (or the Scene `camera`
332
- prop).
339
+ zoomSpeed?, panSpeed?, clampTarget?, zoomAnchor?, rotateAnchor? })` -
340
+ azimuth/elevation/distance around a target with optional auto-orbit. The
341
+ first argument is anything with the scene's `setCamera` and `camera()`
342
+ (the fov maps pan travel to world): a Scene, or a View to drive one view's
343
+ camera independently. Its `axes` are `rotate` (a delta of one element
344
+ height sweeps one full turn, Three's OrbitControls convention, so a drag
345
+ feels the same on a phone and a 4k window; a rate turns at 0.5 turn/s at
346
+ full deflection), `zoom` (octaves: a delta of 1 halves the distance, a
347
+ rate of 1 halves it per second) and `pan` (the target slides so the scene
348
+ tracks the fingers 1:1 at the target's depth, three.js DOLLY_PAN, weighted
349
+ by `panSpeed`; `clampTarget(target)` bounds where a pan may put the
350
+ pivot). The verbs: `rotateBy(azimuth, elevation)` radians, `zoomBy(factor,
351
+ anchor?)` (factor > 1 in, as the 2d camera's zoomAt), `panBy(right, up)`
352
+ world units, `setPivot(point)`, `set(pose)`; every one pushes the pose at
353
+ once. Zoom aims at the target unless `zoomAnchor(focal, {eye, target})`
354
+ maps the gesture's focal point - a FRACTION of the element, [0..1, 0..1] -
355
+ to a world point (ground hit, target-depth plane, ...): then that point
356
+ stays pinned under the pointer and the target slides toward it; only the
357
+ app can build that mapping, since it needs the projection
358
+ (scene.unproject over the scene's size). A pinch holds one anchor for its
359
+ whole gesture (the fingers' interleaved events make the span oscillate,
360
+ and re-anchoring per delta turns that into a crawl); the wheel, arriving
361
+ unbracketed, anchors per notch. Pair it with `rotateAnchor({eye, target})`:
362
+ called when a rotate gesture begins, its point is projected onto the
363
+ view axis and re-seats the pivot without moving the picture, so a drag
364
+ after an anchored zoom orbits what the camera looks at, not wherever the
365
+ zoom left the target. Call `orbit.update(dt)` from your onFrame to
366
+ integrate the rates and the auto-orbit (no frame loop of its own), and
367
+ use its return - true when the pose changed since the previous update,
368
+ nudges included - to gate per-frame dependents like reprojecting HUD
369
+ overlays. `orbiting()` (the auto-orbit switch) and `active()` (the
370
+ frame-loop gate: orbiting with a non-zero rate, or any rate driving - the
371
+ predicate every camera control shares) are reactive (HUD-safe); the pose
372
+ is plain state via `pose()`/`set()` (also the debug-command shape). It
373
+ drives position and target only; fov/near/far stay on scene.setCamera
374
+ (or the Scene `camera` prop). The auto-orbit pauses while a gesture is
375
+ open.
333
376
 
334
- In a component tree, skip the wiring: `<OrbitCamera azimuth={1.2}
335
- distance={7} />` as a Scene child reaches the scene through context,
336
- receives input from the scene's leaf (the built-in one automatically; a
337
- custom `output` leaf spreads `{...useScene().input.handlersFor(layout)}`
338
- beside its scene.handlersFor spread, same `layout`), defaults `viewport`
339
- to the leaf's laid-out size plus the scene camera's fov, and pushes input
340
- poses synchronously - no ref plumbing, no onFrame. Auto-orbit runs a
341
- frame loop only while `active()`, so a paused camera keeps the app
342
- demand-driven idle. The pose props are
343
- initial values: runtime pose changes (and the debug-command hookup) go
344
- through `ref`'s handle, whose set() also pushes the pose. Every other
345
- prop is live - forwarded to the control as a getter and read where it
346
- applies, never snapshotted - so clamps, rates, anchors and `viewport`
347
- follow their props without a remount, and a clamp change re-clamps the
348
- pose at once.
377
+ In a component tree, skip the wiring: `<OrbitCamera input={input}
378
+ azimuth={1.2} distance={7} />` as a Scene child reaches the scene through
379
+ context (as a `<View3d>` child, that view: the context's `viewport` is
380
+ the nearest owner's), drives from the map in `input` (its `rotate`,
381
+ `zoom`, `pan` actions, or the names in `actions`; live - a new map
382
+ reconnects) and nothing else, and runs a frame loop only while
383
+ `active()`, so a camera moved by drags alone keeps the app demand-driven
384
+ idle. The pose props are initial values: runtime pose changes (and the
385
+ debug-command hookup) go through `ref`'s handle, whose set() and verbs
386
+ push the pose. Every other prop is live - forwarded to the control as a
387
+ getter and read where it applies, never snapshotted - so clamps, rates
388
+ and anchors follow their props without a remount, and a clamp change
389
+ re-clamps the pose at once.
349
390
 
350
391
  First-person control: `createFirstPersonCamera(scene, { position?, yaw?,
351
- pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?, viewport?,
352
- clampPosition? })` - a position plus yaw/pitch (yaw 0 faces -z, positive
353
- turns left; pitch positive looks up), Unity's FirstPersonController shape
354
- (look AND move in one control) where Three splits PointerLockControls
355
- from a hand-written key loop. Look comes from pointer-move
356
- movementX/movementY while `pointerLocked()`, from a one-finger drag
357
- (arena-arbitrated through createTransform, viewport-relative with
358
- `viewport`) while not, and from the right stick; move from WASD/arrows
359
- (physical codes and logical keys both), the left stick, and Q/E for
360
- down/up - bound always, inert unless `fly` is on. Walking (the default)
361
- flattens the heading onto the ground plane at fixed height. Every option
362
- but the initial pose is read where it applies (`fly` per update,
363
- `clampPosition` per move, the rates and pitch clamps per input), so a
364
- field changed on the options object takes effect on the next move: walk
365
- and fly are one control. The control NEVER calls `lockPointer`
366
- - click-to-lock and Escape-to-release are the app's window-level
367
- decisions (see `examples/first-person.tsx`) - and has no collision of
368
- its own: `clampPosition(next, current)` is the whole hook - bounds, a
369
- floor height, or `moveAndSlide` over `next - current` against the
370
- collision layer (see `examples/collision.tsx`). Spread `handlers` (pointer + key +
371
- onBlur; keys reach only the FOCUSED node, so that element must hold
372
- focus or be the window), call `update(dt)` from onFrame; `active()` is
373
- reactive - a key held or a stick deflected - and gates the loop. Keys
374
- cannot be polled (core has no key-state accessor), so held keys are
375
- tracked from the down/up pair and `onBlur` drops them.
392
+ pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?, clampPosition? })` -
393
+ a position plus yaw/pitch (yaw 0 faces -z, positive turns left; pitch
394
+ positive looks up), Unity's FirstPersonController shape (look AND move in
395
+ one control) where Three splits PointerLockControls from a hand-written
396
+ key loop. Its `axes`: `look` (a delta of one element height sweeps half a
397
+ turn - a drag across the screen turns the walker around - and the
398
+ pointer feed's `mouseDelta` delivers mouse motion under pointer lock in
399
+ the same unit; a rate turns at 0.4 turn/s), `move` ([right, forward],
400
+ forward = -y: a stick pushed up or W reads [0, -1]; a rate walks at
401
+ `moveSpeed`, a delta is a step in world units, diagonals clamped to unit
402
+ length so they walk no faster) and `rise` (world up at `moveSpeed`, fly
403
+ mode only). Walking (the default) flattens the heading onto the ground
404
+ plane at fixed height; `fly` moves along the view. The verbs:
405
+ `lookBy(yaw, pitch)` radians, `moveBy(right, forward, up?)` in the
406
+ walker's frame, `set(pose)`. Every option but the initial pose is read
407
+ where it applies (`fly` per step, `clampPosition` per move, the rates and
408
+ pitch clamps per input), so a field changed on the options object takes
409
+ effect on the next move: walk and fly are one control. The control NEVER
410
+ calls `lockPointer` - click-to-lock and Escape-to-release are the app's
411
+ window-level decisions (see `examples/first-person.tsx`) - and has no
412
+ collision of its own: `clampPosition(next, current)` is the whole hook -
413
+ bounds, a floor height, or `moveAndSlide` over `next - current` against
414
+ the collision layer (see `examples/collision.tsx`, whose `jump` is one
415
+ more action on the same map: Space or the pad's south button, read by
416
+ name). Call `update(dt)` from onFrame; `active()` is reactive - a rate
417
+ driving (a held key, a deflected stick) - and gates the loop. The map
418
+ tracks held keys from the down/up pair through `input.handlers`; spread
419
+ its `onBlur` too, since the up never arrives once focus has left.
376
420
 
377
- `<FirstPersonCamera>` as a Scene child wires all of it: with a
378
- key-driven control registered, the built-in leaf becomes `focusable`
379
- and takes focus on pointer down (the web canvas gesture), which routes
380
- the keys to the control; a pointer-only scene (`<OrbitCamera>`) never
381
- steals focus. A custom `output` leaf spreads
382
- `useScene().input.handlersFor(layout, () => node)` and declares
383
- `focusable` itself. The frame loop runs only while `active()`. Every prop
421
+ `<FirstPersonCamera input={input}>` as a Scene child wires the control
422
+ to the scene and the map, nothing else: the app spreads
423
+ `input.handlers` on the window (keys bubble there from wherever focus
424
+ is, so a click on the scene needs no focus dance) and hands the scene
425
+ its `pointer` feed. The frame loop runs only while `active()`. Every prop
384
426
  but the initial pose is live: `fly={flying()}` toggles walk/fly on the
385
- running control (pose and held keys carry over, no remount, and
386
- `clampPosition` may swap with it), `moveSpeed`/`lookSpeed` follow their
387
- props, and a pitch clamp change re-clamps at once.
427
+ running control (pose carries over, no remount, and `clampPosition` may
428
+ swap with it), `moveSpeed`/`lookSpeed` follow their props, and a pitch
429
+ clamp change re-clamps at once. checks/orbit-check.ts and
430
+ checks/first-person-check.ts pin both controls headless (they import
431
+ `@solidrt/core/input` only).
388
432
 
389
433
  Overlay projection: `scene.project(point)` maps a world point to scene
390
434
  pixels (top-left origin, y down - the output texture's own space; `w` is
@@ -453,12 +497,21 @@ closed mesh with no triangle in reach touches nothing - the trimesh rule
453
497
  in every engine), and a sweep from a volume already in contact reports
454
498
  time 0 only while the motion closes in; leaving or sliding along the
455
499
  contact is no hit, which is what lets a slide along a wall proceed. A
456
- zero motion touches nothing.
457
- `moveAndSlide(scene, volume, motion, opts?)` is the controller over
458
- them: Godot's CharacterBody3D.move_and_slide and Unity's
459
- CharacterController.Move as one PURE function - no node, no velocity;
460
- the first-person camera composes it in `clampPosition`, a node-driven
461
- body applies `result.motion` with setTransform. It pushes the body out
500
+ zero motion touches nothing. The filters run in the core: every query
501
+ hands `flux:spatial` a `{ root, layers, nodes }` filter (the scene root,
502
+ the mask, the `meshes` include-list as node ids - an instanced mesh
503
+ contributing every instance node), and a mesh's `layers` is written to
504
+ its core node (`setLayers`), so a query never sees another scene's or a
505
+ 2d layer's nodes and JS only maps hits back to meshes.
506
+ `scene.moveAndSlide(volume, motion, opts?)` (and the free
507
+ `moveAndSlide(scene, ...)` spelling of it) is the controller over them:
508
+ Godot's CharacterBody3D.move_and_slide and Unity's
509
+ CharacterController.Move as one PURE call - no node, no velocity; the
510
+ first-person camera composes it in `clampPosition`, a node-driven body
511
+ applies `result.motion` with setTransform. The loop runs in the spatial
512
+ core (`alloy/src/spatial/mover.rs`, one FFI call per body per frame; the
513
+ 2d layer's mover is the same loop), the JS side only packs the volume
514
+ and maps hits back to meshes. It pushes the body out
462
515
  of anything it starts inside, sweeps, stops a skin short, slides the
463
516
  rest along the contact plane up to `maxSlides` times, then snaps down
464
517
  onto a floor within `floorSnap` unless the motion rises - and the
@@ -478,25 +531,54 @@ one overlap per move. Deliberately absent and additive when asked: a
478
531
  step offset (Unity's stepOffset; Godot has none either) and a cylinder
479
532
  volume (Godot only).
480
533
 
481
- Mesh pointer events - the element vocabulary one tree deeper:
482
- `onPointerDown/Move/Up/Enter/Leave` as plain fields on any node (and as
483
- Mesh/Group props). The nearest hit mesh is the target; down/move/up
484
- bubble mesh -> ancestor groups (`stopPropagation()` stops the walk);
485
- enter/leave fire on the mesh alone, pairing on hover changes. A
486
- pointer-down CAPTURES its mesh until the up: moves and the up keep
487
- dispatching to it off-mesh (the platform's captured-drag rule), with
488
- `point`/`distance` null while the ray misses it. The event carries the
489
- element fields (pointerId, pointerType, button, modifiers) plus `mesh`,
490
- `currentTarget`, `point`, `distance`, and `x`/`y` in scene pixels.
491
- Wiring: the built-in `<Scene>` leaf carries `scene.handlers`
492
- automatically (opt out: `events={false}`); an `output` leaf or
493
- imperative composition spreads `{...scene.handlers}` onto the element
494
- showing the texture. `scene.handlers` assumes that leaf is LAID OUT at
495
- the target size - true for the built-in leaf and a d-texture at natural
496
- size, under any ancestor transforms or design-size fits (the hit test
497
- undoes them; localX/localY arrive in the leaf's layout frame). A leaf
498
- laid out at a different size (the supersampling pattern) uses
534
+ Pointer events (scene-pointer.ts): the element event model one tree
535
+ deeper, with the SCENE as the root of the walk (under a `<View3d>` leaf:
536
+ the view). `onPointerDown/Move/Up/Enter/Leave/Wheel/Tap` are plain
537
+ fields on any node (and Mesh/Group/Instance props); the nearest hit is
538
+ the target - the struck instance of an instanced mesh, else the mesh -
539
+ and down/move/up/wheel dispatch there, bubble through the ancestors and
540
+ END AT THE SCENE's listeners (`scene.listen({...})`, the `<Scene>`
541
+ `onPointer*`/`onWheel`/`onTap` props); over empty space the walk is the
542
+ scene alone, with `event.mesh` null (a tap there is the deselect idiom).
543
+ `event.mesh` stays the hit (`NodePointerEvent` in the chain,
544
+ `ScenePointerEvent` at the root with `mesh` nullable), `currentTarget`
545
+ the node whose handler runs (the scene at the end), `stopPropagation()`
546
+ stops the walk, and a stopped DOWN claims the whole press: that
547
+ pointer's move, up and tap never reach the scene either (the chain still
548
+ bubbles). That one rule is how a mesh drags itself under an
549
+ `<OrbitCamera>` without the view turning: stop the down, own the
550
+ captured moves - the control's pointer feed listens at the root
551
+ (`<Scene pointer>` does it, `feedPointer(scene, feed)` by hand) and sees
552
+ only what the nodes let through. Capture is per pointerId to the press
553
+ target, the scene included (a drag from empty space keeps delivering to
554
+ the root as it crosses meshes; a drag from a mesh keeps naming it, with
555
+ `point`/`distance` null while the ray misses it). Enter/leave fire on
556
+ the struck node alone - a group never receives them - while the root
557
+ sees every move, so "hovering empty space" is a root move with `mesh`
558
+ null. Taps are synthesized by the dispatch (DOM click, Unity's click
559
+ handler): `onTap` fires after the up when the press released on the
560
+ target it pressed within the slop (8 window px, core's recognizer slop,
561
+ so a press is never both a tap and a drag), was the only pointer down
562
+ for its whole press (a pinch never taps), with `tapCount` counting
563
+ repeats within 300 ms and 20 px on the same target (a double tap is
564
+ `tapCount === 2`); a release on another instance of the same mesh is no
565
+ tap. Wheel walks like a move with `deltaX/deltaY` (a node stopping it
566
+ keeps the zoom out). Every event carries `native`, the leaf's element
567
+ event, for core's recognizers, and the element fields (pointerId,
568
+ pointerType, button, modifiers) plus `x`/`y` in scene pixels
569
+ (screenRay's input - a drag plane is one intersection away). Root
570
+ listeners all run, in registration order (the root is the last stop,
571
+ nothing is left to claim). Wiring: the built-in `<Scene>` leaf carries
572
+ `scene.handlers` automatically (opt out: `events={false}`); an `output`
573
+ leaf or imperative composition spreads `{...scene.handlers}` onto the
574
+ element showing the texture. `scene.handlers` assumes that leaf is LAID
575
+ OUT at the target size - true for the built-in leaf and a d-texture at
576
+ natural size, under any ancestor transforms or design-size fits (the hit
577
+ test undoes them; localX/localY arrive in the leaf's layout frame). A
578
+ leaf laid out at a different size (the supersampling pattern) uses
499
579
  `scene.handlersFor(() => ({ width, height }))` with its layout size.
580
+ checks/dispatch-check.ts pins the walk, claiming, capture, hover, wheel
581
+ and tap rules headless; examples/pick.tsx is the live guard.
500
582
 
501
583
  Geometry generators take ONE options object, every field optional with
502
584
  a default, named as Three names them: `box({ width, height, depth })`
package/README.md CHANGED
@@ -97,7 +97,8 @@ named channel to a geometry's interleave, `withColors` is the `aColor`
97
97
  vec4 case - tint, baked AO, any four scalars - and materials read channels
98
98
  by name), one perspective camera
99
99
  with an orbit control (`<OrbitCamera>` as a Scene child, or
100
- `createOrbitCamera` imperatively: drag, pinch/wheel zoom, auto-orbit),
100
+ `createOrbitCamera` imperatively; drag, pinch/wheel zoom and a pad reach
101
+ it through an input map the app binds, auto-orbit),
101
102
  mesh picking with pointer events, scene backgrounds, transparency, and
102
103
  lights (`lit` material with hemisphere ambient, up to four directional
103
104
  light nodes, Blinn-Phong highlight and triplanar mapping).
@@ -21,10 +21,14 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
21
21
  target: `lookAt` for a +z solid, `quatFromTo` for aiming a y-axis cone,
22
22
  and a `quatSlerp` damped follow that visibly lags; all driven from
23
23
  onFrame through refs, no per-frame signals.
24
- - `pick.tsx` - mesh pointer events: hover tints (enter/leave), click
25
- pops (down), a Group hearing its children's clicks through bubbling
26
- and one mesh stopping the walk; a STATIC scene rendered only when an
27
- event changes something, with hit testing over the scene's BVH.
24
+ - `pick.tsx` - mesh pointer events under an `<OrbitCamera>`, the scene
25
+ as the root of the walk: hover tints (enter/leave), a tap pops, a drag
26
+ on the crate slides it over the floor without orbiting (its down claims
27
+ the press), a drag elsewhere orbits and a wheel zooms through the feed
28
+ listening at the root, the ring claims the wheel to spin, a tap on
29
+ nothing un-pops (the scene's own `onTap` with `mesh` null), and a
30
+ Group hears its children's downs through bubbling; a STATIC scene
31
+ rendered only when an event changes something.
28
32
  - `scene-background.tsx` - a fragment-GLSL background drawn inside the
29
33
  scene's own pass (`<Scene background>`): one target, no stacked
30
34
  backdrop texture, no resize plumbing; the source is shader-target
@@ -85,10 +89,11 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
85
89
  Drag to orbit; the `formation` and `state` debug commands drive it
86
90
  headlessly.
87
91
  - `first-person.tsx` - a first-person walk: `<FirstPersonCamera>` over
88
- a walled courtyard of shadow-casting pillars, WASD/arrows and the pad
89
- sticks to walk, mouse look under pointer lock (click locks, Escape
90
- releases - the app's calls, not the control's), a drag to look on
91
- touch; `clampPosition` keeps the walker inside the walls, the whole of
92
+ a walled courtyard of shadow-casting pillars, driven by an input map
93
+ the app binds (`firstPersonBindings`: WASD/arrows and the pad sticks
94
+ to walk, the scene's drag to look on touch, mouse motion while the
95
+ pointer is locked - click locks, Escape releases, the app's calls, not
96
+ the control's); `clampPosition` keeps the walker inside the walls, the whole of
92
97
  the collision a camera control offers; the `pose` debug command reads
93
98
  and sets the pose headlessly.
94
99
  - `collision.tsx` - collision without a physics engine: the same walker
@@ -11,13 +11,15 @@
11
11
  // click locks the pointer, Escape releases it. Debug commands: `pose`
12
12
  // (read/set), `walk` ({ dx, dz } through the collision), `jump`, `fall`
13
13
  // ({ dt }: one airborne step by hand, for headless checks), `state`.
14
- import { createSignal, lockPointer, onFrame, pct, pointerLocked, render } from "@solidrt/core"
14
+ import { createInputMap, createPointerFeed, createSignal, gamepad, keyboard, lockPointer, onFrame, pct, pointerLocked, render } from "@solidrt/core"
15
15
  import type { PointerEvent } from "@solidrt/core"
16
16
  import {
17
17
  box,
18
18
  cylinder,
19
19
  DirectionalLight,
20
20
  FirstPersonCamera,
21
+ firstPersonActions,
22
+ firstPersonBindings,
21
23
  HemisphereLight,
22
24
  lit,
23
25
  Mesh,
@@ -68,6 +70,14 @@ let feetOf = (eye: Vec3): Vec3 => [eye[0], eye[1] - EYE, eye[2]]
68
70
  function App() {
69
71
  let scene!: SceneHandle
70
72
  let camera!: FirstPersonCameraHandle
73
+ // The walker's input plus the game's own `jump` action: the standard
74
+ // first-person bindings, and Space or the pad's south button for the
75
+ // jump - one map, read by name (ARCHITECTURE.md).
76
+ let pointer = createPointerFeed()
77
+ let pad = gamepad()
78
+ let input = createInputMap({ ...firstPersonActions, jump: "button" })
79
+ input.bind(firstPersonBindings({ pointer, gamepad: pad, keyboard }))
80
+ input.bind("jump", keyboard.key("Space"), pad.button("south"))
71
81
  let [hud, setHud] = createSignal("")
72
82
  let [near, setNear] = createSignal<Set<number>>(new Set())
73
83
  // The same set, readable synchronously (a signal read right after its
@@ -135,6 +145,7 @@ function App() {
135
145
  grounded = false
136
146
  fall()
137
147
  }
148
+ input.onPress("jump", jump)
138
149
 
139
150
  let lastHud = 0
140
151
  onFrame(tick => {
@@ -204,9 +215,11 @@ function App() {
204
215
  return (
205
216
  <window
206
217
  onKeyDown={e => {
218
+ input.handlers.onKeyDown(e)
207
219
  if (e.key === "Escape") lockPointer(false)
208
- if (e.key === " " || e.code === "Space") jump()
209
220
  }}
221
+ onKeyUp={input.handlers.onKeyUp}
222
+ onBlur={input.handlers.onBlur}
210
223
  >
211
224
  <view
212
225
  width={pct(100)}
@@ -215,9 +228,9 @@ function App() {
215
228
  if (e.pointerType === "mouse") lockPointer(true)
216
229
  }}
217
230
  >
218
- <Scene ref={s => (scene = s)} clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="collision">
231
+ <Scene ref={s => (scene = s)} clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="collision" pointer={pointer}>
219
232
  <PerspectiveCamera fov={70} near={0.1} far={80} />
220
- <FirstPersonCamera ref={c => (camera = c)} position={[0, EYE, 10]} clampPosition={clamp} />
233
+ <FirstPersonCamera input={input} ref={c => (camera = c)} position={[0, EYE, 10]} clampPosition={clamp} />
221
234
  <HemisphereLight sky={[0.5, 0.56, 0.68]} ground={[0.25, 0.22, 0.18]} />
222
235
  <DirectionalLight color={[1, 0.95, 0.85]} intensity={0.9} position={[8, 14, 6]} direction={[-8, -14, -6]} castShadow shadow={{ mapSize: 2048, normalBias: 0.02, cascades: 3, distance: 40 }} />
223
236
  <Mesh geometry={plane({ width: FLOOR * 2 + 2, height: FLOOR * 2 + 2 })} material={ground} rotation={[-Math.PI / 2, 0, 0]} layers={1 | COLLIDER} />
@@ -1,16 +1,17 @@
1
1
  // A first-person walk: `<FirstPersonCamera>` driving the scene camera from
2
- // WASD/arrows and the gamepad sticks, mouse look under pointer lock and
3
- // drag-to-look without it. Click the scene to lock the pointer (the scene
4
- // leaf takes focus on the same click, which routes the keys to the
5
- // control), Escape releases it; on a touch screen a drag looks around and
6
- // the left stick of a pad walks. Movement runs a frame loop only while a
7
- // key is held or a stick deflected - a still scene renders nothing new.
2
+ // an input map the app wires - WASD/arrows and the sticks through the
3
+ // standard bindings, the scene leaf's drag to look around on touch, and
4
+ // mouse motion while the pointer is locked. Click the scene to lock the
5
+ // pointer, Escape releases it; the keys reach the map through the window
6
+ // (`input.handlers`), so nothing needs focus. Movement runs a frame loop
7
+ // only while a key is held or a stick deflected - a still scene renders
8
+ // nothing new.
8
9
  // `clampPosition` keeps the walker inside the courtyard walls, the whole
9
10
  // of the collision an app gets from a camera control. The `pose` debug
10
11
  // command reads and sets the pose for headless checks.
11
- import { createSignal, lockPointer, onFrame, pct, pointerLocked, render } from "@solidrt/core"
12
+ import { createInputMap, createPointerFeed, createSignal, gamepad, keyboard, lockPointer, onFrame, pct, pointerLocked, render } from "@solidrt/core"
12
13
  import type { PointerEvent } from "@solidrt/core"
13
- import { box, cylinder, DirectionalLight, FirstPersonCamera, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene } from "@solidrt/3d"
14
+ import { box, cylinder, DirectionalLight, FirstPersonCamera, firstPersonActions, firstPersonBindings, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene } from "@solidrt/3d"
14
15
  import type { FirstPersonCameraHandle, Vec3 } from "@solidrt/3d"
15
16
  import { registerDebug } from "srt:dev"
16
17
 
@@ -43,6 +44,12 @@ let inside = (p: Vec3): Vec3 => [clampNum(p[0], -COURT + WALL_GAP, COURT - WALL_
43
44
 
44
45
  function App() {
45
46
  let camera!: FirstPersonCameraHandle
47
+ // The walker's input: the scene leaf's gestures, the keyboard and any
48
+ // pad, bound to the control's actions (ARCHITECTURE.md: the app wires
49
+ // devices, the component takes the map).
50
+ let pointer = createPointerFeed()
51
+ let input = createInputMap(firstPersonActions)
52
+ input.bind(firstPersonBindings({ pointer, gamepad: gamepad(), keyboard }))
46
53
  let [hud, setHud] = createSignal("")
47
54
  let lastHud = 0
48
55
  onFrame(tick => {
@@ -77,8 +84,11 @@ function App() {
77
84
  return (
78
85
  <window
79
86
  onKeyDown={e => {
87
+ input.handlers.onKeyDown(e)
80
88
  if (e.key === "Escape") lockPointer(false)
81
89
  }}
90
+ onKeyUp={input.handlers.onKeyUp}
91
+ onBlur={input.handlers.onBlur}
82
92
  >
83
93
  <view
84
94
  width={pct(100)}
@@ -87,9 +97,9 @@ function App() {
87
97
  if (e.pointerType === "mouse") lockPointer(true)
88
98
  }}
89
99
  >
90
- <Scene clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="first-person">
100
+ <Scene clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="first-person" pointer={pointer}>
91
101
  <PerspectiveCamera fov={70} near={0.1} far={60} />
92
- <FirstPersonCamera ref={c => (camera = c)} position={[0, EYE, 7]} clampPosition={inside} />
102
+ <FirstPersonCamera input={input} ref={c => (camera = c)} position={[0, EYE, 7]} clampPosition={inside} />
93
103
  <HemisphereLight sky={[0.5, 0.56, 0.68]} ground={[0.25, 0.22, 0.18]} />
94
104
  <DirectionalLight
95
105
  color={[1, 0.95, 0.85]}