@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.
- package/AGENTS.md +199 -117
- package/README.md +2 -1
- package/examples/README.md +13 -8
- package/examples/collision.tsx +17 -4
- package/examples/first-person.tsx +20 -10
- package/examples/fleet.tsx +11 -5
- package/examples/model.tsx +3 -3
- package/examples/pick.tsx +144 -34
- package/examples/probe.tsx +10 -4
- package/examples/scene-views.tsx +49 -58
- package/examples/sky-lit.tsx +10 -4
- package/examples/skybox.tsx +10 -4
- package/examples/standard.tsx +10 -4
- package/package.json +2 -2
- package/src/collision.ts +14 -193
- package/src/components/context.tsx +35 -0
- package/src/components/directional-light.tsx +48 -0
- package/src/components/first-person-camera.tsx +72 -0
- package/src/components/group.tsx +18 -0
- package/src/components/hemisphere-light.tsx +24 -0
- package/src/components/index.ts +46 -0
- package/src/components/instanced-mesh.tsx +105 -0
- package/src/components/mesh.tsx +92 -0
- package/src/components/node-props.ts +90 -0
- package/src/components/orbit-camera.tsx +75 -0
- package/src/components/perspective-camera.tsx +32 -0
- package/src/components/point-light.tsx +52 -0
- package/src/components/record-mesh.tsx +68 -0
- package/src/components/scene.tsx +281 -0
- package/src/components/spot-light.tsx +65 -0
- package/src/components/sprite.tsx +38 -0
- package/src/components/view3d.tsx +152 -0
- package/src/first-person.ts +140 -188
- package/src/index.ts +13 -9
- package/src/input.ts +70 -0
- package/src/node.ts +98 -25
- package/src/orbit.ts +266 -235
- package/src/scene-pointer.ts +229 -101
- package/src/scene.ts +250 -76
- 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
|
|
46
|
-
|
|
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`, `
|
|
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?` (
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
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.
|
|
297
|
-
|
|
298
|
-
`{...useScene().
|
|
299
|
-
and
|
|
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?,
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
Scene, or a View to drive one view's
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
`
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
when
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
335
|
-
distance={7} />` as a Scene child reaches the scene through
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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?,
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
`
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
`
|
|
489
|
-
|
|
490
|
-
`
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
|
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).
|
package/examples/README.md
CHANGED
|
@@ -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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
package/examples/collision.tsx
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// key is held or a stick deflected - a still scene renders
|
|
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]}
|