@solidrt/3d 0.0.55 → 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 (72) hide show
  1. package/AGENTS.md +1206 -224
  2. package/README.md +16 -7
  3. package/examples/README.md +88 -9
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +263 -0
  7. package/examples/first-person.tsx +129 -0
  8. package/examples/fleet.tsx +200 -0
  9. package/examples/fog.tsx +2 -3
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +35 -9
  17. package/examples/pick.tsx +145 -37
  18. package/examples/probe.tsx +107 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +68 -48
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +92 -0
  24. package/examples/skybox.tsx +134 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +138 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +20 -0
  32. package/src/color.ts +32 -0
  33. package/src/components/context.tsx +35 -0
  34. package/src/components/directional-light.tsx +48 -0
  35. package/src/components/first-person-camera.tsx +72 -0
  36. package/src/components/group.tsx +18 -0
  37. package/src/components/hemisphere-light.tsx +24 -0
  38. package/src/components/index.ts +46 -0
  39. package/src/components/instanced-mesh.tsx +105 -0
  40. package/src/components/mesh.tsx +92 -0
  41. package/src/components/node-props.ts +90 -0
  42. package/src/components/orbit-camera.tsx +75 -0
  43. package/src/components/perspective-camera.tsx +32 -0
  44. package/src/components/point-light.tsx +52 -0
  45. package/src/components/record-mesh.tsx +68 -0
  46. package/src/components/scene.tsx +281 -0
  47. package/src/components/spot-light.tsx +65 -0
  48. package/src/components/sprite.tsx +38 -0
  49. package/src/components/view3d.tsx +152 -0
  50. package/src/environment-bake.ts +492 -0
  51. package/src/environment.ts +325 -0
  52. package/src/first-person.ts +258 -0
  53. package/src/geometry.ts +6 -2
  54. package/src/glsl.ts +1343 -82
  55. package/src/gltf.ts +432 -112
  56. package/src/index.ts +61 -9
  57. package/src/input.ts +70 -0
  58. package/src/light.ts +383 -0
  59. package/src/material.ts +667 -232
  60. package/src/math.ts +110 -0
  61. package/src/mesh.ts +728 -0
  62. package/src/mixer.ts +476 -0
  63. package/src/model-file.ts +80 -15
  64. package/src/model.ts +204 -35
  65. package/src/node.ts +579 -0
  66. package/src/orbit.ts +292 -212
  67. package/src/scene-pointer.ts +294 -0
  68. package/src/scene-shadows.ts +471 -0
  69. package/src/scene.ts +1435 -1576
  70. package/tools/environment.ts +69 -0
  71. package/tools/model.ts +4 -3
  72. package/src/components.tsx +0 -420
package/README.md CHANGED
@@ -15,7 +15,7 @@ function App() {
15
15
  onFrame(tick => setSpin(tick / 2000))
16
16
  return (
17
17
  <window>
18
- <Scene width={720} height={720}>
18
+ <Scene>
19
19
  <PerspectiveCamera position={[0, 1.5, 3]} lookAt={[0, 0, 0]} />
20
20
  <Mesh geometry={box()} material={unlit({ color: [0.9, 0.3, 0.3] })} rotation={[0, spin(), 0]} />
21
21
  </Scene>
@@ -30,7 +30,9 @@ per mesh, one shared pipeline per material class, cross-mesh occlusion
30
30
  from the shared depth buffer. A static scene costs zero GPU passes - the
31
31
  runtime re-renders the target only when something changes - and a moved
32
32
  mesh costs one uniform write. By default `<Scene>` composites the target
33
- as a plain `<texture>` leaf; the `output` prop receives the texture id
33
+ as a plain `<texture>` leaf that fills its parent's box, rendered at the
34
+ leaf's on-screen device-pixel size (give `width`/`height` for a fixed
35
+ target instead); the `output` prop receives the texture id
34
36
  and replaces that leaf - place a `<d-texture>`, add paint or pointer
35
37
  props, chain a post-effect shader target, or return null and composite
36
38
  `scene.texture` yourself.
@@ -68,10 +70,15 @@ however many meshes read it), or per mesh, declaratively via the `params`
68
70
  prop on `<Mesh>` or imperatively via `setMeshParams`. `shaderMaterialClass`
69
71
  compiles one program and hands out `instance()` materials that differ only
70
72
  in params/textures - and with `instanceAttributes` it makes an instanced
71
- material: `<InstancedMesh records>` (or `createInstancedMesh`) then draws
72
- the geometry once per interleaved record as ONE draw entry, the shape for
73
- forests, particles, and every fleet whose per-copy data is a few floats
74
- (`examples/instanced.tsx`). And
73
+ material. Populations come in two forms: `<InstancedMesh>` with
74
+ `<Instance>` children (or `createInstancedMesh`/`addInstance`) draws the
75
+ geometry once per instance NODE - each a scene node the spatial core
76
+ places, so transitions, picking and pointer events reach every copy with
77
+ no per-frame JS, and the stock materials' `instanced`/`instanceColors`
78
+ give it lighting, shadows and a per-instance tint without GLSL
79
+ (`examples/fleet.tsx`); `<RecordMesh records>` (or `createRecordMesh`)
80
+ draws once per interleaved JS-written record, the shape for particles and
81
+ every fleet only JS can step (`examples/instanced.tsx`). And
75
82
  `@solidrt/3d/glsl` exports the lighting pieces (hemisphere, lambert,
76
83
  blinn, fresnel, a standard vertex stage) to compose your own lit looks
77
84
  from plain template literals.
@@ -89,7 +96,9 @@ one mesh per material), open vertex layouts (`withAttribute` appends any
89
96
  named channel to a geometry's interleave, `withColors` is the `aColor`
90
97
  vec4 case - tint, baked AO, any four scalars - and materials read channels
91
98
  by name), one perspective camera
92
- with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit),
99
+ with an orbit control (`<OrbitCamera>` as a Scene child, or
100
+ `createOrbitCamera` imperatively; drag, pinch/wheel zoom and a pad reach
101
+ it through an input map the app binds, auto-orbit),
93
102
  mesh picking with pointer events, scene backgrounds, transparency, and
94
103
  lights (`lit` material with hemisphere ambient, up to four directional
95
104
  light nodes, Blinn-Phong highlight and triplanar mapping).
@@ -21,23 +21,89 @@ 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
31
35
  compatible verbatim.
36
+ - `skybox.tsx` - a cube-map skybox (`<Scene background={{ cube,
37
+ rotation }}>`) baked in JS at startup (horizon gradient, sun disc and
38
+ glow, no image assets), and the same cube as the environment
39
+ (`environment={{ cube, rotation }}`) mirrored by a chrome sphere
40
+ (`lit({ reflectivity: 1 })`) and, blurred by shininess, a glossy knot.
41
+ The sky turns and the sun light turns with it, the `rotation` knobs
42
+ updating in place; drag to look around.
43
+ - `standard.tsx` - the `standard` material, the sphere grid every PBR
44
+ engine opens with: metalness down the rows, roughness across the
45
+ columns, under the skybox example's baked sky as background and
46
+ environment and a warm sun casting shadows; a bare-metal row shows
47
+ the environment as tinted reflections, the dielectric row the same
48
+ sky as a faint face-on gloss. Drag to look around.
49
+ - `environment.tsx` - a baked HDR environment lighting the scene ALONE
50
+ (no lights, no hemisphere): `loadEnvironment` reads the .srte that
51
+ `bunx srt tool 3d/environment <panorama>.hdr -o assets/environment.srte`
52
+ bakes from any equirectangular .hdr (Poly Haven's are CC0; the asset
53
+ is not committed, bake one first), used as background and environment
54
+ with ACES tone mapping. A metal row and a red dielectric row, roughness
55
+ 0 to 1 across: the metals are the room, sharp to blurred. Drag to
56
+ look around.
57
+ - `probe.tsx` - a reflection probe (`scene.createReflectionProbe`): the
58
+ scene rendered into a cube map from the center of a satin chrome ball,
59
+ every frame, prefiltered into the roughness chain and set as the
60
+ environment the ball reflects - six colored walls and four orbiting
61
+ spheres show in it, blurred by the ball's roughness. Layers keep the
62
+ ball out of its own probe. Drag to look around.
63
+ - `sky-lit.tsx` - a sky-lit scene (`scene.bakeBackground`): a procedural
64
+ GLSL sky is the background AND, baked into a prefiltered cube, the
65
+ environment - no light nodes. Two rows of `standard` spheres, metal and
66
+ dielectric, roughness 0 to 1 across; the sun disc bakes at 40x (the
67
+ probe format is half float where the device renders it), so the rough
68
+ metals carry its energy as a broad highlight. Drag to look around.
32
69
  - `sprites.tsx` - sprites: a ring of `"full"` billboard glows that stay
33
70
  flat to the screen and `"fixed-y"` cutout trees that only yaw toward
34
71
  the camera and stay upright as it climbs, both turned in the vertex
35
72
  stage while the camera circles - no per-sprite JS per frame.
36
- - `instanced.tsx` - instanced meshes: one material class declaring
37
- `instanceAttributes`, two `<InstancedMesh>` fleets (400 scattered
38
- rocks, a ring of pines) each ONE draw entry and ONE uModel, a spinning
39
- group moving both with two matrix writes, and `setInstanceCount` from
40
- onFrame breathing the pine population.
73
+ - `instanced.tsx` - record meshes, the JS-written population: one
74
+ material class declaring `instanceAttributes`, two `<RecordMesh>`
75
+ fleets (400 scattered rocks, a ring of pines) each ONE draw entry and
76
+ ONE uModel, a spinning group moving both with two matrix writes, and
77
+ `setRecordCount` from onFrame breathing the pine population. The class
78
+ also declares `shadowVertex` (the placement math alone), so both
79
+ fleets `castShadow` onto the lit ground - the pines' shadows breathe
80
+ with them.
81
+ - `fleet.tsx` - instanced meshes, the node-backed population: one
82
+ `<InstancedMesh>` of a thousand `<Instance>` nodes under the stock
83
+ `lit` material with `instanceColors` (lighting, shadows, fog and a
84
+ per-instance tint with no GLSL). Every few seconds a signal picks the
85
+ next formation and the core springs every instance to its place
86
+ through its `transition` - one signal write, zero JS per frame after
87
+ it; a tap flips the struck instance's tint (pointer events name the
88
+ instance; every style write between two frames is one buffer write).
89
+ Drag to orbit; the `formation` and `state` debug commands drive it
90
+ headlessly.
91
+ - `first-person.tsx` - a first-person walk: `<FirstPersonCamera>` over
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
97
+ the collision a camera control offers; the `pose` debug command reads
98
+ and sets the pose headlessly.
99
+ - `collision.tsx` - collision without a physics engine: the same walker
100
+ as a capsule through `moveAndSlide` over the scene's sweep queries,
101
+ in a level of walls, a ramp, a platform with a ledge and pillars whose
102
+ drawn meshes carry the collider layer bit too; gravity as a frame loop
103
+ that runs only while airborne (Space jumps), pickups lit by one
104
+ `overlap` per move. Debug commands `walk` (frame-sized steps through
105
+ the collision), `jump`, `fall` (one airborne step by hand) and `state`
106
+ drive it headlessly.
41
107
  - `scene-views.tsx` - scene views: one scene rendered three times, the
42
108
  built-in perspective leaf plus two `scene.createView` targets - a
43
109
  top-down ORTHOGRAPHIC map (`ortho` on setCamera) and a side silhouette
@@ -58,6 +124,12 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
58
124
  the plain box widened to cover the field: one map's texels spread over
59
125
  it, blocky everywhere) and the `cascades`/`fly` debug commands set the
60
126
  count and the shadow distance and park the flight.
127
+ - `lamps.tsx` - spot and point lights in a dark courtyard: a warm SOFT
128
+ spot (penumbra 0.4) swinging from a parent group, a fixed HARD spot
129
+ (penumbra 0.05) aimed by `direction` at a knot on a pedestal, and a
130
+ blue point bulb orbiting the crates; both spots `castShadow` (a
131
+ perspective map each), and the header notes the decay-2 intensity
132
+ scale (candela-like: a lamp 5 units up wants ~40, not ~2).
61
133
  - `fog.tsx` - scene fog over a valley of pines between two ridges: a
62
134
  click cycles LINEAR (`{ near, far }`, a clear band then a fade to the
63
135
  far plane), EXP2 (`{ density }`, thickening from the first metre) and
@@ -70,3 +142,10 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
70
142
  transparent dome and a mesh without normals) parsed with `parseGltf`
71
143
  from a binary import and built by `createModel` into a Group of named
72
144
  parts; clicking a part hides it, clicking the body restores all.
145
+ - `model-load.tsx` - the same rover loaded ASYNC with `loadModel` from
146
+ `assets/` under a `<Loading>` boundary: the async read lives in a memo,
147
+ a second memo derives the scene JSX after that read, and the shell
148
+ stays above the boundary - the two rules that avoid
149
+ PENDING_ASYNC_UNTRACKED_READ and the suspend-retry element leak. Needs
150
+ the asset baked into the running app:
151
+ `bunx srt tool 3d/model examples/model.glb -o assets/model.srtm`.
package/examples/aim.tsx CHANGED
@@ -25,7 +25,6 @@ import {
25
25
  } from "@solidrt/3d"
26
26
  import type { MeshNode, Vec3 } from "@solidrt/3d"
27
27
 
28
- const SIZE = 720
29
28
  const Y_AXIS: Vec3 = [0, 1, 0]
30
29
 
31
30
  function App() {
@@ -79,8 +78,8 @@ function App() {
79
78
 
80
79
  return (
81
80
  <window>
82
- <view width={pct(100)} height={pct(100)} designSize={[SIZE, SIZE]}>
83
- <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="aim">
81
+ <view width={pct(100)} height={pct(100)}>
82
+ <Scene clearColor={[0.07, 0.07, 0.1, 1]} label="aim">
84
83
  <PerspectiveCamera fov={55} position={[0, 2.6, 4.6]} lookAt={[0, 0.7, 0]} />
85
84
  <Mesh
86
85
  geometry={plane({ width: 7, height: 7, label: "floor" })}
@@ -23,7 +23,6 @@ import { registerDebug } from "srt:dev"
23
23
  import { box, DirectionalLight, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene, sphere } from "@solidrt/3d"
24
24
  import type { Geometry, Vec3 } from "@solidrt/3d"
25
25
 
26
- const SIZE = 720
27
26
  const FIELD = 260
28
27
  const FAR = 200
29
28
  // The flight: a circle over the field, looking ahead along it.
@@ -97,10 +96,8 @@ function App() {
97
96
 
98
97
  return (
99
98
  <window>
100
- <view width={pct(100)} height={pct(100)} designSize={[SIZE, SIZE]} onPointerDown={() => setCascades(c => (c % 4) + 1)}>
99
+ <view width={pct(100)} height={pct(100)} onPointerDown={() => setCascades(c => (c % 4) + 1)}>
101
100
  <Scene
102
- width={SIZE}
103
- height={SIZE}
104
101
  clearColor={[SKY[0], SKY[1], SKY[2], 1]}
105
102
  fog={{ color: SKY, near: FOG_NEAR, far: FOG_FAR }}
106
103
  samples={4}
@@ -0,0 +1,263 @@
1
+ // Collision without a physics engine: a capsule-controlled first-person
2
+ // walker in a level of walls, a ramp, a platform with a ledge and a few
3
+ // pillars, all colliding through `moveAndSlide` over the scene's sweep
4
+ // queries. The level meshes carry the COLLIDER layer bit beside the drawn
5
+ // one, so the same geometry draws and collides (a low-poly stand-in would
6
+ // carry COLLIDER alone and stay undrawn); the pickups do not, so the
7
+ // walker passes through them, and one `overlap` per move lights the ones
8
+ // within reach. Walking goes through the camera's `clampPosition` hook;
9
+ // gravity runs a frame loop only while airborne (a jump, a ledge), so a
10
+ // standing scene renders nothing new. WASD/arrows walk, Space jumps,
11
+ // click locks the pointer, Escape releases it. Debug commands: `pose`
12
+ // (read/set), `walk` ({ dx, dz } through the collision), `jump`, `fall`
13
+ // ({ dt }: one airborne step by hand, for headless checks), `state`.
14
+ import { createInputMap, createPointerFeed, createSignal, gamepad, keyboard, lockPointer, onFrame, pct, pointerLocked, render } from "@solidrt/core"
15
+ import type { PointerEvent } from "@solidrt/core"
16
+ import {
17
+ box,
18
+ cylinder,
19
+ DirectionalLight,
20
+ FirstPersonCamera,
21
+ firstPersonActions,
22
+ firstPersonBindings,
23
+ HemisphereLight,
24
+ lit,
25
+ Mesh,
26
+ moveAndSlide,
27
+ PerspectiveCamera,
28
+ plane,
29
+ Scene,
30
+ sphere,
31
+ } from "@solidrt/3d"
32
+ import type { FirstPersonCameraHandle, MeshNode, MoveResult, SceneHandle, Vec3, Volume } from "@solidrt/3d"
33
+ import { registerDebug } from "srt:dev"
34
+
35
+ // Layer bit the level geometry adds to its drawn layer: what every
36
+ // collision query selects, so pickups (drawn only) never block the walker.
37
+ const COLLIDER = 2
38
+ // The walker: eye height over the feet, the capsule's radius, and where
39
+ // its segment sits relative to the eye (feet + radius up to a hand under
40
+ // the eye).
41
+ const EYE = 1.6
42
+ const RADIUS = 0.3
43
+ const BODY_LOW = -EYE + RADIUS
44
+ const BODY_HIGH = -0.2
45
+ // Vertical dynamics, world units per second (squared for gravity).
46
+ const GRAVITY = 12
47
+ const JUMP_SPEED = 5
48
+ // Largest frame step the fall integrates: a stall must not tunnel the
49
+ // walker through a floor.
50
+ const MAX_FALL_DT = 0.05
51
+ // A pickup lights up within this distance of the feet.
52
+ const REACH = 1.5
53
+ // Level dimensions: floor half extent, wall height, the platform's top
54
+ // and footprint, the ramp's run.
55
+ const FLOOR = 15
56
+ const WALL_HEIGHT = 3
57
+ const PLATFORM_TOP = 1.5
58
+ const PLATFORM_HALF = 3
59
+ const RAMP_RUN = 6
60
+ const RAMP_THICKNESS = 0.3
61
+ // HUD refresh interval in ms.
62
+ const HUD_MS = 100
63
+ // One frame of walking at the default speed, the stride the `walk` debug
64
+ // command takes so its checks match live play.
65
+ const WALK_STEP = 0.05
66
+
67
+ let bodyAt = (eye: Vec3): Volume => ({ a: [eye[0], eye[1] + BODY_LOW, eye[2]], b: [eye[0], eye[1] + BODY_HIGH, eye[2]], radius: RADIUS })
68
+ let feetOf = (eye: Vec3): Vec3 => [eye[0], eye[1] - EYE, eye[2]]
69
+
70
+ function App() {
71
+ let scene!: SceneHandle
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"))
81
+ let [hud, setHud] = createSignal("")
82
+ let [near, setNear] = createSignal<Set<number>>(new Set())
83
+ // The same set, readable synchronously (a signal read right after its
84
+ // write still returns the old value): what the debug commands report.
85
+ let nearNow = new Set<number>()
86
+ let grounded = true
87
+ let fallSpeed = 0
88
+ let stopFall: (() => void) | null = null
89
+ let pickups: MeshNode[] = []
90
+
91
+ let dim = lit({ color: [0.55, 0.45, 0.2] })
92
+ let bright = lit({ color: [1, 0.85, 0.3], specular: 0.8, shininess: 60 })
93
+ let ground = lit({ color: [0.48, 0.52, 0.46] })
94
+ let stone = lit({ color: [0.72, 0.7, 0.66] })
95
+ let brick = lit({ color: [0.6, 0.34, 0.28] })
96
+
97
+ // Every move ends here: the walker's new eye, the ground state, and the
98
+ // pickups in reach of its feet (an include-list query, so the level
99
+ // itself never reports).
100
+ let settle = (eye: Vec3, floor: Vec3 | null) => {
101
+ grounded = floor !== null
102
+ let hit = new Set<number>()
103
+ for (let o of scene.overlap({ center: feetOf(eye), radius: REACH }, { meshes: pickups })) hit.add(pickups.indexOf(o.mesh))
104
+ nearNow = hit
105
+ setNear(hit)
106
+ }
107
+ // Walking: the camera asks for `next` from `eye`, the collision answers.
108
+ let clamp = (next: Vec3, eye: Vec3): Vec3 => {
109
+ let r = moveAndSlide(scene, bodyAt(eye), [next[0] - eye[0], next[1] - eye[1], next[2] - eye[2]], { layers: COLLIDER })
110
+ let moved: Vec3 = [eye[0] + r.motion[0], eye[1] + r.motion[1], eye[2] + r.motion[2]]
111
+ settle(moved, r.floor)
112
+ if (!grounded) fall()
113
+ return moved
114
+ }
115
+ // One airborne step: gravity into the vertical speed, the drop through
116
+ // the collision, a ceiling bonk kills the rise, a floor ends the fall.
117
+ let fallStep = (dt: number) => {
118
+ fallSpeed -= GRAVITY * dt
119
+ let eye = camera.pose().position
120
+ let r = moveAndSlide(scene, bodyAt(eye), [0, fallSpeed * dt, 0], { layers: COLLIDER })
121
+ let moved: Vec3 = [eye[0] + r.motion[0], eye[1] + r.motion[1], eye[2] + r.motion[2]]
122
+ camera.set({ position: moved })
123
+ if (r.ceiling && fallSpeed > 0) fallSpeed = 0
124
+ settle(moved, r.floor)
125
+ if (grounded) {
126
+ fallSpeed = 0
127
+ stopFall?.()
128
+ stopFall = null
129
+ }
130
+ }
131
+ // Airborne: step the fall frame by frame until a floor takes it.
132
+ let fall = () => {
133
+ if (stopFall !== null) return
134
+ let last: number | null = null
135
+ stopFall = onFrame(tick => {
136
+ let now = tick / 1000
137
+ let dt = last === null ? 0 : Math.max(0, Math.min(now - last, MAX_FALL_DT))
138
+ last = now
139
+ fallStep(dt)
140
+ })
141
+ }
142
+ let jump = () => {
143
+ if (!grounded) return
144
+ fallSpeed = JUMP_SPEED
145
+ grounded = false
146
+ fall()
147
+ }
148
+ input.onPress("jump", jump)
149
+
150
+ let lastHud = 0
151
+ onFrame(tick => {
152
+ if (tick - lastHud < HUD_MS) return
153
+ lastHud = tick
154
+ let p = camera.pose().position
155
+ setHud(
156
+ `${pointerLocked() ? "locked - Escape releases" : "click to lock"} WASD walk, Space jump x ${p[0].toFixed(1)} y ${(p[1] - EYE).toFixed(2)} z ${p[2].toFixed(1)} ${grounded ? "grounded" : "airborne"} pickups ${near().size}`,
157
+ )
158
+ })
159
+ registerDebug("pose", (args?: Record<string, unknown>) => {
160
+ if (args) camera.set(args as Parameters<typeof camera.set>[0])
161
+ return camera.pose()
162
+ })
163
+ registerDebug("walk", (args?: { dx?: number; dz?: number; steps?: number }) => {
164
+ // Frame-sized steps by default: a level's ramps and snap distances
165
+ // are tuned for them, not for one long stride.
166
+ let steps = args?.steps ?? Math.max(1, Math.ceil(Math.hypot(args?.dx ?? 0, args?.dz ?? 0) / WALK_STEP))
167
+ let last: MoveResult | null = null
168
+ for (let i = 0; i < steps; i++) {
169
+ let eye = camera.pose().position
170
+ let r = moveAndSlide(scene, bodyAt(eye), [(args?.dx ?? 0) / steps, 0, (args?.dz ?? 0) / steps], { layers: COLLIDER })
171
+ let moved: Vec3 = [eye[0] + r.motion[0], eye[1] + r.motion[1], eye[2] + r.motion[2]]
172
+ settle(moved, r.floor)
173
+ camera.set({ position: moved })
174
+ last = r
175
+ }
176
+ if (!grounded) fall()
177
+ let p = camera.pose().position
178
+ return { position: p, feet: p[1] - EYE, grounded, wall: last?.wall, ceiling: last?.ceiling, hits: last?.hits.length, pickups: [...nearNow].sort() }
179
+ })
180
+ registerDebug("jump", () => {
181
+ jump()
182
+ return { grounded, fallSpeed }
183
+ })
184
+ registerDebug("fall", (args?: { dt?: number }) => {
185
+ if (!grounded) fallStep(args?.dt ?? 1 / 60)
186
+ return { grounded, fallSpeed, position: camera.pose().position }
187
+ })
188
+ registerDebug("state", () => ({ grounded, fallSpeed, pickups: [...nearNow].sort() }))
189
+
190
+ // The ramp: a thin box whose top face runs from the floor at z = +run/2
191
+ // up to the platform's top at z = -run/2, so its rotation about x is the
192
+ // slope and its center sits half a thickness under the top face.
193
+ let slope = Math.atan2(PLATFORM_TOP, RAMP_RUN)
194
+ let rampLength = Math.hypot(RAMP_RUN, PLATFORM_TOP)
195
+ let rampCenter: Vec3 = [0, PLATFORM_TOP / 2 - Math.cos(slope) * (RAMP_THICKNESS / 2), -PLATFORM_HALF - RAMP_RUN / 2 - Math.sin(slope) * (RAMP_THICKNESS / 2)]
196
+ let walls = [
197
+ { position: [0, WALL_HEIGHT / 2, -FLOOR - 0.5] as Vec3, size: [FLOOR * 2 + 2, WALL_HEIGHT, 1] as Vec3 },
198
+ { position: [0, WALL_HEIGHT / 2, FLOOR + 0.5] as Vec3, size: [FLOOR * 2 + 2, WALL_HEIGHT, 1] as Vec3 },
199
+ { position: [-FLOOR - 0.5, WALL_HEIGHT / 2, 0] as Vec3, size: [1, WALL_HEIGHT, FLOOR * 2] as Vec3 },
200
+ { position: [FLOOR + 0.5, WALL_HEIGHT / 2, 0] as Vec3, size: [1, WALL_HEIGHT, FLOOR * 2] as Vec3 },
201
+ ]
202
+ let pillars: Vec3[] = [
203
+ [6, WALL_HEIGHT / 2, 4],
204
+ [-6, WALL_HEIGHT / 2, 4],
205
+ [8, WALL_HEIGHT / 2, -8],
206
+ ]
207
+ let gems: Vec3[] = [
208
+ [3, 0.4, 6],
209
+ [-3, 0.4, 2],
210
+ [7, 0.4, -2],
211
+ [-8, 0.4, -6],
212
+ [0, PLATFORM_TOP + 0.4, -PLATFORM_HALF - RAMP_RUN - PLATFORM_HALF],
213
+ ]
214
+
215
+ return (
216
+ <window
217
+ onKeyDown={e => {
218
+ input.handlers.onKeyDown(e)
219
+ if (e.key === "Escape") lockPointer(false)
220
+ }}
221
+ onKeyUp={input.handlers.onKeyUp}
222
+ onBlur={input.handlers.onBlur}
223
+ >
224
+ <view
225
+ width={pct(100)}
226
+ height={pct(100)}
227
+ onPointerDown={(e: PointerEvent) => {
228
+ if (e.pointerType === "mouse") lockPointer(true)
229
+ }}
230
+ >
231
+ <Scene ref={s => (scene = s)} clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="collision" pointer={pointer}>
232
+ <PerspectiveCamera fov={70} near={0.1} far={80} />
233
+ <FirstPersonCamera input={input} ref={c => (camera = c)} position={[0, EYE, 10]} clampPosition={clamp} />
234
+ <HemisphereLight sky={[0.5, 0.56, 0.68]} ground={[0.25, 0.22, 0.18]} />
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 }} />
236
+ <Mesh geometry={plane({ width: FLOOR * 2 + 2, height: FLOOR * 2 + 2 })} material={ground} rotation={[-Math.PI / 2, 0, 0]} layers={1 | COLLIDER} />
237
+ {walls.map(w => (
238
+ <Mesh geometry={box({ width: w.size[0], height: w.size[1], depth: w.size[2] })} material={brick} position={w.position} layers={1 | COLLIDER} castShadow />
239
+ ))}
240
+ <Mesh
241
+ geometry={box({ width: PLATFORM_HALF * 2, height: PLATFORM_TOP, depth: PLATFORM_HALF * 2 })}
242
+ material={stone}
243
+ position={[0, PLATFORM_TOP / 2, -PLATFORM_HALF - RAMP_RUN - PLATFORM_HALF]}
244
+ layers={1 | COLLIDER}
245
+ castShadow
246
+ />
247
+ <Mesh geometry={box({ width: 2.5, height: RAMP_THICKNESS, depth: rampLength })} material={stone} position={rampCenter} rotation={[slope, 0, 0]} layers={1 | COLLIDER} castShadow />
248
+ {pillars.map(p => (
249
+ <Mesh geometry={cylinder({ radiusTop: 0.35, radiusBottom: 0.4, height: WALL_HEIGHT })} material={stone} position={p} layers={1 | COLLIDER} castShadow />
250
+ ))}
251
+ {gems.map((g, i) => (
252
+ <Mesh ref={m => (pickups[i] = m)} geometry={sphere({ radius: 0.25 })} material={near().has(i) ? bright : dim} position={g} castShadow />
253
+ ))}
254
+ </Scene>
255
+ <text position="absolute" left={12} top={12} color="#ffffff" fontSize={14}>
256
+ {hud()}
257
+ </text>
258
+ </view>
259
+ </window>
260
+ )
261
+ }
262
+
263
+ render(() => <App />)
@@ -0,0 +1,129 @@
1
+ // A first-person walk: `<FirstPersonCamera>` driving the scene camera from
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.
9
+ // `clampPosition` keeps the walker inside the courtyard walls, the whole
10
+ // of the collision an app gets from a camera control. The `pose` debug
11
+ // command reads and sets the pose for headless checks.
12
+ import { createInputMap, createPointerFeed, createSignal, gamepad, keyboard, lockPointer, onFrame, pct, pointerLocked, render } from "@solidrt/core"
13
+ import type { PointerEvent } from "@solidrt/core"
14
+ import { box, cylinder, DirectionalLight, FirstPersonCamera, firstPersonActions, firstPersonBindings, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene } from "@solidrt/3d"
15
+ import type { FirstPersonCameraHandle, Vec3 } from "@solidrt/3d"
16
+ import { registerDebug } from "srt:dev"
17
+
18
+ // Half extent of the walkable courtyard in world units; the walls stand
19
+ // just outside it.
20
+ const COURT = 9
21
+ // How far inside the walls the eye may go.
22
+ const WALL_GAP = 0.6
23
+ // Standing eye height.
24
+ const EYE = 1.6
25
+ // Pillar ring radius and count.
26
+ const RING = 5
27
+ const PILLARS = 8
28
+ // The sun's shadow is cascaded: a first-person eye sits 1.6 units over
29
+ // the floor, where one box map over the whole courtyard (32 units / 2048
30
+ // texels = 1.6 cm) shows every texel as a 10-pixel stair. Three cascades
31
+ // fitted to slices of the view give the near slice millimetre texels;
32
+ // the distance caps the far one at the courtyard's diagonal.
33
+ const SHADOW_CASCADES = 3
34
+ const SHADOW_DISTANCE = 30
35
+ // PCF radius in map texels: the 3x3 hardware-tap kernel that hides the
36
+ // remaining stair of the near cascade's texels.
37
+ const SHADOW_RADIUS = 2
38
+ // HUD refresh interval in ms: the pose readout is slow UI state, not a
39
+ // per-frame binding.
40
+ const HUD_MS = 100
41
+
42
+ let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
43
+ let inside = (p: Vec3): Vec3 => [clampNum(p[0], -COURT + WALL_GAP, COURT - WALL_GAP), EYE, clampNum(p[2], -COURT + WALL_GAP, COURT - WALL_GAP)]
44
+
45
+ function App() {
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 }))
53
+ let [hud, setHud] = createSignal("")
54
+ let lastHud = 0
55
+ onFrame(tick => {
56
+ if (tick - lastHud < HUD_MS) return
57
+ lastHud = tick
58
+ let p = camera.pose()
59
+ setHud(
60
+ `${pointerLocked() ? "locked - Escape releases" : "click to lock"} x ${p.position[0].toFixed(1)} z ${p.position[2].toFixed(1)} yaw ${p.yaw.toFixed(2)} pitch ${p.pitch.toFixed(2)}`,
61
+ )
62
+ })
63
+ registerDebug("pose", (args?: Record<string, unknown>) => {
64
+ if (args) camera.set(args as Parameters<typeof camera.set>[0])
65
+ return camera.pose()
66
+ })
67
+
68
+ let ground = lit({ color: [0.5, 0.52, 0.48] })
69
+ let stone = lit({ color: [0.75, 0.72, 0.66] })
70
+ let brick = lit({ color: [0.6, 0.32, 0.26] })
71
+ let gold = lit({ color: [0.9, 0.75, 0.3], specular: 0.5, shininess: 40 })
72
+
73
+ let pillars = Array.from({ length: PILLARS }, (_, i) => {
74
+ let a = (i / PILLARS) * Math.PI * 2
75
+ return [Math.sin(a) * RING, Math.cos(a) * RING] as const
76
+ })
77
+ let walls = [
78
+ { position: [0, 1.5, -COURT - 0.5] as Vec3, size: [COURT * 2 + 2, 3, 1] as Vec3 },
79
+ { position: [0, 1.5, COURT + 0.5] as Vec3, size: [COURT * 2 + 2, 3, 1] as Vec3 },
80
+ { position: [-COURT - 0.5, 1.5, 0] as Vec3, size: [1, 3, COURT * 2] as Vec3 },
81
+ { position: [COURT + 0.5, 1.5, 0] as Vec3, size: [1, 3, COURT * 2] as Vec3 },
82
+ ]
83
+
84
+ return (
85
+ <window
86
+ onKeyDown={e => {
87
+ input.handlers.onKeyDown(e)
88
+ if (e.key === "Escape") lockPointer(false)
89
+ }}
90
+ onKeyUp={input.handlers.onKeyUp}
91
+ onBlur={input.handlers.onBlur}
92
+ >
93
+ <view
94
+ width={pct(100)}
95
+ height={pct(100)}
96
+ onPointerDown={(e: PointerEvent) => {
97
+ if (e.pointerType === "mouse") lockPointer(true)
98
+ }}
99
+ >
100
+ <Scene clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="first-person" pointer={pointer}>
101
+ <PerspectiveCamera fov={70} near={0.1} far={60} />
102
+ <FirstPersonCamera input={input} ref={c => (camera = c)} position={[0, EYE, 7]} clampPosition={inside} />
103
+ <HemisphereLight sky={[0.5, 0.56, 0.68]} ground={[0.25, 0.22, 0.18]} />
104
+ <DirectionalLight
105
+ color={[1, 0.95, 0.85]}
106
+ intensity={0.9}
107
+ position={[6, 10, 4]}
108
+ direction={[-6, -10, -4]}
109
+ castShadow
110
+ shadow={{ mapSize: 2048, normalBias: 0.02, radius: SHADOW_RADIUS, cascades: SHADOW_CASCADES, distance: SHADOW_DISTANCE }}
111
+ />
112
+ <Mesh geometry={plane({ width: COURT * 2 + 2, height: COURT * 2 + 2 })} material={ground} rotation={[-Math.PI / 2, 0, 0]} />
113
+ {walls.map(w => (
114
+ <Mesh geometry={box({ width: w.size[0], height: w.size[1], depth: w.size[2] })} material={brick} position={w.position} castShadow />
115
+ ))}
116
+ {pillars.map(([x, z]) => (
117
+ <Mesh geometry={cylinder({ radiusTop: 0.3, radiusBottom: 0.35, height: 3 })} material={stone} position={[x, 1.5, z]} castShadow />
118
+ ))}
119
+ <Mesh geometry={box({ width: 1.2, height: 1.2, depth: 1.2 })} material={gold} position={[0, 0.6, 0]} castShadow />
120
+ </Scene>
121
+ <text position="absolute" left={12} top={12} color="#ffffff" fontSize={14}>
122
+ {hud()}
123
+ </text>
124
+ </view>
125
+ </window>
126
+ )
127
+ }
128
+
129
+ render(() => <App />)