@solidrt/3d 0.0.54 → 0.0.56

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