@solidrt/3d 0.0.56 → 0.0.58

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 (48) hide show
  1. package/AGENTS.md +1052 -639
  2. package/README.md +2 -1
  3. package/examples/README.md +19 -8
  4. package/examples/collision.tsx +17 -4
  5. package/examples/first-person.tsx +20 -10
  6. package/examples/fleet.tsx +11 -5
  7. package/examples/model.tsx +3 -3
  8. package/examples/pick.tsx +144 -34
  9. package/examples/probe.tsx +10 -4
  10. package/examples/scene-views.tsx +49 -58
  11. package/examples/sky-lit.tsx +10 -4
  12. package/examples/skybox.tsx +10 -4
  13. package/examples/standard.tsx +10 -4
  14. package/examples/wireframe.tsx +107 -0
  15. package/package.json +2 -2
  16. package/src/collision.ts +14 -193
  17. package/src/components/context.tsx +35 -0
  18. package/src/components/directional-light.tsx +48 -0
  19. package/src/components/first-person-camera.tsx +72 -0
  20. package/src/components/group.tsx +18 -0
  21. package/src/components/hemisphere-light.tsx +24 -0
  22. package/src/components/index.ts +46 -0
  23. package/src/components/instanced-mesh.tsx +105 -0
  24. package/src/components/mesh.tsx +92 -0
  25. package/src/components/node-props.ts +90 -0
  26. package/src/components/orbit-camera.tsx +75 -0
  27. package/src/components/perspective-camera.tsx +32 -0
  28. package/src/components/point-light.tsx +52 -0
  29. package/src/components/record-mesh.tsx +68 -0
  30. package/src/components/scene.tsx +281 -0
  31. package/src/components/spot-light.tsx +65 -0
  32. package/src/components/sprite.tsx +38 -0
  33. package/src/components/view3d.tsx +152 -0
  34. package/src/first-person.ts +140 -188
  35. package/src/geometry-gpu.ts +45 -13
  36. package/src/geometry.ts +186 -5
  37. package/src/gltf.ts +97 -16
  38. package/src/index.ts +15 -10
  39. package/src/input.ts +79 -0
  40. package/src/material.ts +26 -8
  41. package/src/mesh.ts +5 -6
  42. package/src/model.ts +65 -33
  43. package/src/node.ts +100 -26
  44. package/src/orbit.ts +266 -235
  45. package/src/scene-pointer.ts +229 -101
  46. package/src/scene.ts +256 -79
  47. package/src/skeleton.ts +211 -0
  48. package/src/components.tsx +0 -1101
@@ -1,20 +1,21 @@
1
1
  // One scene, three renderings: the built-in leaf from a perspective
2
- // camera, plus two `scene.createView` targets - a top-down ORTHOGRAPHIC
3
- // map (`ortho` on setCamera) and a side silhouette drawn with an
2
+ // camera, plus two `<View3d>`s - a top-down ORTHOGRAPHIC map (`ortho` in
3
+ // the view's `camera`) and a side silhouette drawn with an
4
4
  // `overrideMaterial`. The group spins from ONE signal: each mesh is one
5
5
  // core node whose flush writes its world matrix into every target's entry
6
6
  // at once, so the extra views cost the app nothing per frame (and the
7
7
  // light set fans out to them too - the map is lit like the main view).
8
- // Views die with the scene.
8
+ // A view is a Scene child: it renders its own `<texture>` leaf where it
9
+ // sits, so the two stack in the column beside the scene's leaf, and it
10
+ // dies with the scene.
9
11
  //
10
12
  // Layers and per-view fog: the marker disc is on layer bit 2, so the
11
13
  // main render (mask 1) and the silhouette never draw it - only the map,
12
- // whose mask admits both bits. The scene is fogged; `fog: null` on the
14
+ // whose mask admits both bits. The scene is fogged; `fog={null}` on the
13
15
  // map keeps the top-down view clear (its fog names are view-owned, so
14
16
  // scene-wide fog writes never clobber them).
15
17
  import { createSignal, onFrame, pct, render } from "@solidrt/core"
16
- import { box, circle, DirectionalLight, Group, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit } from "@solidrt/3d"
17
- import type { SceneHandle } from "@solidrt/3d"
18
+ import { box, circle, DirectionalLight, Group, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit, View3d } from "@solidrt/3d"
18
19
 
19
20
  // Layer bits: the world everything defaults to, and the map-only markers.
20
21
  const WORLD_LAYER = 1
@@ -31,67 +32,16 @@ function App() {
31
32
  let floor = plane({ width: 6, height: 6, label: "floor" })
32
33
  let ball = sphere({ radius: 0.35 })
33
34
  let marker = circle({ radius: 0.3, label: "marker" })
34
- // `ref` runs before `output`, so the handle is set when the views are
35
- // created there; both callbacks run once, untracked - no signals needed.
36
- let scene!: SceneHandle
37
35
 
38
36
  return (
39
37
  <window>
40
- <view width={pct(100)} height={pct(100)} designSize={[MAIN + SIDE, MAIN]}>
38
+ <view width={pct(100)} height={pct(100)} designSize={[MAIN + SIDE, MAIN]} flexDirection="row">
41
39
  <Scene
42
40
  width={MAIN}
43
41
  height={MAIN}
44
42
  clearColor={[0.07, 0.07, 0.1, 1]}
45
43
  fog={{ color: [0.07, 0.07, 0.1], near: 2.5, far: 9 }}
46
44
  label="scene-views"
47
- ref={s => (scene = s)}
48
- output={tex => {
49
- // The map: straight down from y = 10, world -z toward the top of
50
- // the leaf, seven world units across at any depth. Its mask also
51
- // admits the marker layer, and `fog: null` keeps it clear while
52
- // the main view fades to the horizon.
53
- let map = scene.createView({
54
- width: SIDE,
55
- height: SIDE,
56
- clearColor: [0.05, 0.08, 0.06, 1],
57
- layers: WORLD_LAYER | MARKER_LAYER,
58
- fog: null,
59
- label: "map",
60
- })
61
- map.setCamera({
62
- position: [0, 10, 0],
63
- target: [0, 0, 0],
64
- up: [0, 0, -1],
65
- ortho: { left: -3.5, right: 3.5, top: 3.5, bottom: -3.5 },
66
- near: 0.1,
67
- far: 20,
68
- })
69
- // The silhouette: every mesh drawn flat amber, from the side. The
70
- // floor is edge-on here, a single line at the bottom.
71
- let side = scene.createView({
72
- width: SIDE,
73
- height: SIDE,
74
- clearColor: [0.1, 0.06, 0.05, 1],
75
- overrideMaterial: unlit({ color: [0.95, 0.75, 0.3] }),
76
- label: "silhouette",
77
- })
78
- side.setCamera({
79
- position: [6, 1, 0],
80
- target: [0, 0.6, 0],
81
- ortho: { left: -2.4, right: 2.4, top: 2.4, bottom: -2.4 },
82
- near: 0.1,
83
- far: 20,
84
- })
85
- return (
86
- <view flexDirection="row" width={MAIN + SIDE} height={MAIN}>
87
- <texture src={tex} width={MAIN} height={MAIN} />
88
- <view flexDirection="column" width={SIDE} height={MAIN}>
89
- <texture src={map.texture} width={SIDE} height={SIDE} />
90
- <texture src={side.texture} width={SIDE} height={SIDE} />
91
- </view>
92
- </view>
93
- )
94
- }}
95
45
  >
96
46
  <PerspectiveCamera fov={55} position={[0, 1.6, 3.6]} lookAt={[0, 0.3, 0]} />
97
47
  <HemisphereLight sky={[0.6, 0.7, 0.9]} ground={[0.25, 0.2, 0.15]} intensity={0.6} />
@@ -115,6 +65,47 @@ function App() {
115
65
  rotation={[-Math.PI / 2, 0, 0]}
116
66
  />
117
67
  </Group>
68
+ {/* The two views' leaves stack beside the scene's: a laid-out
69
+ element among Scene children renders where it sits, and the
70
+ views inside it stay Scene children through context. */}
71
+ <view flexDirection="column" width={SIDE} height={MAIN}>
72
+ {/* The map: straight down from y = 10, world -z toward the top
73
+ of the leaf, seven world units across at any depth. Its mask
74
+ also admits the marker layer, and `fog={null}` keeps it clear
75
+ while the main view fades to the horizon. */}
76
+ <View3d
77
+ width={SIDE}
78
+ height={SIDE}
79
+ clearColor={[0.05, 0.08, 0.06, 1]}
80
+ layers={WORLD_LAYER | MARKER_LAYER}
81
+ fog={null}
82
+ label="map"
83
+ camera={{
84
+ position: [0, 10, 0],
85
+ target: [0, 0, 0],
86
+ up: [0, 0, -1],
87
+ ortho: { left: -3.5, right: 3.5, top: 3.5, bottom: -3.5 },
88
+ near: 0.1,
89
+ far: 20,
90
+ }}
91
+ />
92
+ {/* The silhouette: every mesh drawn flat amber, from the side.
93
+ The floor is edge-on here, a single line at the bottom. */}
94
+ <View3d
95
+ width={SIDE}
96
+ height={SIDE}
97
+ clearColor={[0.1, 0.06, 0.05, 1]}
98
+ overrideMaterial={unlit({ color: [0.95, 0.75, 0.3] })}
99
+ label="silhouette"
100
+ camera={{
101
+ position: [6, 1, 0],
102
+ target: [0, 0.6, 0],
103
+ ortho: { left: -2.4, right: 2.4, top: 2.4, bottom: -2.4 },
104
+ near: 0.1,
105
+ far: 20,
106
+ }}
107
+ />
108
+ </view>
118
109
  </Scene>
119
110
  </view>
120
111
  </window>
@@ -9,9 +9,9 @@
9
9
  // gradient. The sky is a scene param (uSunDir), so the bake sees the same
10
10
  // sun the backdrop draws. Drag to look around.
11
11
 
12
- import { pct, render } from "@solidrt/core"
12
+ import { createInputMap, createPointerFeed, gamepad, pct, render } from "@solidrt/core"
13
13
  import { glsl } from "@solidrt/core/gpu"
14
- import { Mesh, OrbitCamera, Scene, sphere, standard } from "@solidrt/3d"
14
+ import { Mesh, OrbitCamera, orbitActions, orbitBindings, Scene, sphere, standard } from "@solidrt/3d"
15
15
  import type { SceneHandle } from "@solidrt/3d"
16
16
  import { normalize } from "@solidrt/3d/math"
17
17
  import type { Vec3 } from "@solidrt/3d/math"
@@ -55,6 +55,12 @@ const SKY = glsl`
55
55
  `
56
56
 
57
57
  function App() {
58
+ // The camera's input: the scene leaf's gestures and any pad, bound to the
59
+ // orbit control's actions - the app wires devices, the component takes
60
+ // the map (ARCHITECTURE.md).
61
+ let pointer = createPointerFeed()
62
+ let input = createInputMap(orbitActions)
63
+ input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
58
64
  let spheres: { position: Vec3; material: ReturnType<typeof standard> }[] = []
59
65
  for (let i = 0; i < COUNT; i++) {
60
66
  let roughness = i / (COUNT - 1)
@@ -72,8 +78,8 @@ function App() {
72
78
  return (
73
79
  <window>
74
80
  <view width={pct(100)} height={pct(100)}>
75
- <Scene background={SKY} toneMapping="aces" samples={4} label="sky-lit" ref={lit}>
76
- <OrbitCamera target={[0, 0, 0]} azimuth={0.3} elevation={0.15} distance={7} />
81
+ <Scene background={SKY} toneMapping="aces" samples={4} label="sky-lit" ref={lit} pointer={pointer}>
82
+ <OrbitCamera input={input} target={[0, 0, 0]} azimuth={0.3} elevation={0.15} distance={7} />
77
83
  {spheres.map(s => (
78
84
  <Mesh geometry={geometry} material={s.material} position={s.position} />
79
85
  ))}
@@ -11,9 +11,9 @@
11
11
  // equirectToCube on a panorama. The sky turns slowly and the sun light
12
12
  // turns with it: `rotation` is a reactive prop the scene updates in
13
13
  // place, no recompile. Drag to look around, wheel to zoom.
14
- import { createSignal, onFrame, pct, render } from "@solidrt/core"
14
+ import { createInputMap, createPointerFeed, createSignal, gamepad, onFrame, pct, render } from "@solidrt/core"
15
15
  import { createCubeTexture } from "@solidrt/core/gpu"
16
- import { box, DirectionalLight, HemisphereLight, lit, Mesh, OrbitCamera, plane, Scene, sphere, torusKnot } from "@solidrt/3d"
16
+ import { box, DirectionalLight, HemisphereLight, lit, Mesh, OrbitCamera, orbitActions, orbitBindings, plane, Scene, sphere, torusKnot } from "@solidrt/3d"
17
17
  import { normalize } from "@solidrt/3d/math"
18
18
  import type { Vec3 } from "@solidrt/3d/math"
19
19
 
@@ -92,6 +92,12 @@ function turnedSun(angle: number): Vec3 {
92
92
  }
93
93
 
94
94
  function App() {
95
+ // The camera's input: the scene leaf's gestures and any pad, bound to the
96
+ // orbit control's actions - the app wires devices, the component takes
97
+ // the map (ARCHITECTURE.md).
98
+ let pointer = createPointerFeed()
99
+ let input = createInputMap(orbitActions)
100
+ input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
95
101
  let cube = createCubeTexture(bakeSky(), FACE, { format: "rgba8-srgb", mipmap: true, label: "sky" })
96
102
  let [turn, setTurn] = createSignal(0)
97
103
  let last = 0
@@ -111,8 +117,8 @@ function App() {
111
117
  return (
112
118
  <window>
113
119
  <view width={pct(100)} height={pct(100)}>
114
- <Scene background={{ cube, rotation: turn() }} environment={{ cube, rotation: turn() }} label="skybox-demo">
115
- <OrbitCamera azimuth={0.4} elevation={0.12} distance={7} />
120
+ <Scene background={{ cube, rotation: turn() }} environment={{ cube, rotation: turn() }} label="skybox-demo" pointer={pointer}>
121
+ <OrbitCamera input={input} azimuth={0.4} elevation={0.12} distance={7} />
116
122
  <HemisphereLight sky={[0.5, 0.6, 0.8]} ground={[0.3, 0.27, 0.24]} />
117
123
  <DirectionalLight direction={[-sun()[0], -sun()[1], -sun()[2]]} color={SUN_COLOR} intensity={0.9} />
118
124
  <Mesh geometry={plane({ width: 12, height: 12 })} material={ground} rotation={[-Math.PI / 2, 0, 0]} />
@@ -12,9 +12,9 @@
12
12
  // Light intensities read as lit's: 1 lights a white matte surface to 1.
13
13
  // Drag to look around.
14
14
 
15
- import { pct, render } from "@solidrt/core"
15
+ import { createInputMap, createPointerFeed, gamepad, pct, render } from "@solidrt/core"
16
16
  import { createCubeTexture } from "@solidrt/core/gpu"
17
- import { DirectionalLight, HemisphereLight, Mesh, OrbitCamera, plane, Scene, sphere, standard } from "@solidrt/3d"
17
+ import { DirectionalLight, HemisphereLight, Mesh, OrbitCamera, orbitActions, orbitBindings, plane, Scene, sphere, standard } from "@solidrt/3d"
18
18
  import { normalize } from "@solidrt/3d/math"
19
19
  import type { Vec3 } from "@solidrt/3d/math"
20
20
 
@@ -91,6 +91,12 @@ function bakeSky(): Uint8Array[] {
91
91
  }
92
92
 
93
93
  function App() {
94
+ // The camera's input: the scene leaf's gestures and any pad, bound to the
95
+ // orbit control's actions - the app wires devices, the component takes
96
+ // the map (ARCHITECTURE.md).
97
+ let pointer = createPointerFeed()
98
+ let input = createInputMap(orbitActions)
99
+ input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
94
100
  let cube = createCubeTexture(bakeSky(), FACE, { format: "rgba8-srgb", mipmap: true, label: "sky" })
95
101
  let ball = sphere({ radius: RADIUS, widthSegments: 48, heightSegments: 32 })
96
102
  let ground = standard({ color: [0.32, 0.33, 0.3], roughness: 0.9 })
@@ -109,8 +115,8 @@ function App() {
109
115
  return (
110
116
  <window>
111
117
  <view width={pct(100)} height={pct(100)}>
112
- <Scene background={{ cube }} environment={{ cube }} samples={4} label="standard-demo">
113
- <OrbitCamera target={center} azimuth={0.25} elevation={0.15} distance={9} />
118
+ <Scene background={{ cube }} environment={{ cube }} samples={4} label="standard-demo" pointer={pointer}>
119
+ <OrbitCamera input={input} target={center} azimuth={0.25} elevation={0.15} distance={9} />
114
120
  <HemisphereLight sky={[0.5, 0.6, 0.8]} ground={[0.3, 0.27, 0.24]} intensity={0.5} />
115
121
  <DirectionalLight
116
122
  position={[SUN[0] * SUN_DISTANCE, SUN[1] * SUN_DISTANCE, SUN[2] * SUN_DISTANCE]}
@@ -0,0 +1,107 @@
1
+ // The rover drawn solid, as a wireframe or as its feature edges. Both line
2
+ // forms are GEOMETRY: wireframeGeometry lists every edge of a triangle
3
+ // geometry once, edgesGeometry only the edges where two faces meet at the
4
+ // threshold angle or more (plus the open borders), each as a "lines"
5
+ // geometry over the source's OWN vertex array. The topology rides on the
6
+ // geometry, so the plain unlit material draws it as lines, and the vertex
7
+ // upload is shared, so a mode costs an index buffer, not a second model.
8
+ // The swap is setGeometry + setMaterial on the live mesh: the node, its
9
+ // transform and its pointer handlers are untouched. Space, a tap on the
10
+ // rover or the `wire` debug command ({ mode }) cycles the modes.
11
+
12
+ import { createSignal, onFrame, pct, render } from "@solidrt/core"
13
+ import { createTexture } from "@solidrt/core/gpu"
14
+ import type { TextureId } from "@solidrt/core/gpu"
15
+ import { registerDebug } from "srt:dev"
16
+ import { add, createModel, DirectionalLight, edgesGeometry, equirectToCube, Group, parseGltf, PerspectiveCamera, Scene, setGeometry, setMaterial, unlit, wireframeGeometry } from "@solidrt/3d"
17
+ import type { SceneNode } from "@solidrt/3d"
18
+ import modelBytes from "./model.glb" with { type: "binary" }
19
+
20
+ const MODES = ["solid", "wireframe", "edges"] as const
21
+ type Mode = (typeof MODES)[number]
22
+ // Feature-edge threshold in degrees: above the facet angle of the rover's
23
+ // rounded parts, so their seams stay hidden and only rims and creases draw.
24
+ const EDGE_ANGLE = 20
25
+ const LINE_COLOR: [number, number, number] = [0.85, 0.95, 1]
26
+ // Face edge of the gradient environment cube: a gradient needs no detail.
27
+ const ENV_FACE = 16
28
+ // The gradient, zenith to nadir, sRGB bytes: sky, horizon, ground, below.
29
+ const ENV_ROWS: [number, number, number][] = [
30
+ [96, 122, 168],
31
+ [176, 190, 210],
32
+ [120, 105, 90],
33
+ [60, 52, 45],
34
+ ]
35
+
36
+ // A tiny equirectangular panorama turned into the cube the solid rover's
37
+ // standard materials light by (see model.tsx).
38
+ function gradientEnvironment(): TextureId {
39
+ let px = new Uint8Array(2 * ENV_ROWS.length * 4)
40
+ ENV_ROWS.forEach((c, y) => {
41
+ for (let x = 0; x < 2; x++) px.set([c[0], c[1], c[2], 255], (y * 2 + x) * 4)
42
+ })
43
+ let panorama = createTexture(px, 2, ENV_ROWS.length, { format: "rgba8-srgb", filter: "linear" })
44
+ return equirectToCube(panorama, ENV_FACE, { format: "rgba8-srgb", mipmap: true, label: "gradient-env" })
45
+ }
46
+
47
+ function App() {
48
+ let cube = gradientEnvironment()
49
+ let [t, setT] = createSignal(0)
50
+ onFrame(tick => setT(tick / 1000))
51
+
52
+ let model = createModel(parseGltf(modelBytes), { label: "rover" })
53
+ let b = model.bounds
54
+ let center: [number, number, number] = [(b[0]! + b[3]!) / 2, (b[1]! + b[4]!) / 2, (b[2]! + b[5]!) / 2]
55
+ let radius = Math.hypot(b[3]! - b[0]!, b[4]! - b[1]!, b[5]! - b[2]!) / 2
56
+
57
+ // One line material for every part and both line modes. Per part, what
58
+ // each mode swaps in: the line geometries share the solid's vertices,
59
+ // so building both up front costs their index buffers only.
60
+ let lines = unlit({ color: LINE_COLOR })
61
+ let looks = model.parts.map(part => ({
62
+ mesh: part.mesh,
63
+ solid: { geometry: part.mesh.geometry, material: part.mesh.material },
64
+ wireframe: wireframeGeometry(part.mesh.geometry),
65
+ edges: edgesGeometry(part.mesh.geometry, EDGE_ANGLE),
66
+ }))
67
+ let mode: Mode = "solid"
68
+ let apply = (next: Mode): void => {
69
+ mode = next
70
+ for (let look of looks) {
71
+ // Each call rebuilds the draw entry, so the intermediate state is
72
+ // always the line material over the solid's triangles - a pipeline
73
+ // unlit has anyway - never the solid material over lines.
74
+ if (next === "solid") {
75
+ setGeometry(look.mesh, look.solid.geometry)
76
+ setMaterial(look.mesh, look.solid.material)
77
+ } else {
78
+ setMaterial(look.mesh, lines)
79
+ setGeometry(look.mesh, look[next])
80
+ }
81
+ }
82
+ }
83
+ let cycle = (): void => apply(MODES[(MODES.indexOf(mode) + 1) % MODES.length]!)
84
+ for (let part of model.parts) part.mesh.onTap = cycle
85
+ registerDebug("wire", (args?: Record<string, unknown>) => {
86
+ if (typeof args?.mode === "string" && (MODES as readonly string[]).includes(args.mode)) apply(args.mode as Mode)
87
+ else cycle()
88
+ })
89
+
90
+ return (
91
+ <window
92
+ onKeyDown={e => {
93
+ if (e.key === " ") cycle()
94
+ }}
95
+ >
96
+ <view width={pct(100)} height={pct(100)}>
97
+ <Scene clearColor={[0.1, 0.11, 0.14, 1]} environment={{ cube }} samples={4} label="wireframe">
98
+ <PerspectiveCamera fov={40} position={[center[0] + radius * 1.4, center[1] + radius * 1.1, center[2] + radius * 2.2]} lookAt={center} />
99
+ <DirectionalLight direction={[0.5, -0.8, 0.3]} color={[1, 0.95, 0.85]} intensity={0.9} />
100
+ <Group rotation={[0, t() / 3, 0]} ref={(g: SceneNode) => add(g, model)} />
101
+ </Scene>
102
+ </view>
103
+ </window>
104
+ )
105
+ }
106
+
107
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.56",
3
+ "version": "0.0.58",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -19,6 +19,6 @@
19
19
  ],
20
20
  "peerDependencies": {
21
21
  "@solidjs/signals": "2.0.0-rc.6",
22
- "@solidrt/core": "0.0.56"
22
+ "@solidrt/core": "0.0.58"
23
23
  }
24
24
  }
package/src/collision.ts CHANGED
@@ -1,199 +1,20 @@
1
- // A kinematic character move over the scene's volume queries: Godot's
2
- // CharacterBody3D.move_and_slide and Unity's CharacterController.Move as
3
- // one pure function. Sweep the body along its motion, stop a skin short
4
- // of the first contact, slide the rest along the contact plane, repeat a
5
- // few times - with the two passes both engines wrap around that loop: a
6
- // depenetration first (a body that starts inside something is pushed out
7
- // before it moves) and a floor snap after (a body walking down a ramp is
8
- // pulled back onto it instead of stepping into the air). Per body per
9
- // frame that is the input read and this one call; the geometry runs in
10
- // the spatial core.
11
- //
12
- // Pure on purpose: no node, no velocity state. It takes a volume where the
13
- // body IS and the motion it WANTS, and returns the motion it gets plus
14
- // what it touched - the first-person camera (which has no node) composes
15
- // it in clampPosition, and a node-driven body applies the result with
16
- // setTransform. Gravity is the caller's: fold it into `motion`; a walkable
17
- // floor absorbs the vertical part (a body does not creep down a slope it
18
- // can stand on), a steep one slides it.
19
-
1
+ // The kinematic character move, Godot's CharacterBody3D.move_and_slide
2
+ // and Unity's CharacterController.Move as one pure function: no node, no
3
+ // velocity state. It takes a volume where the body IS and the motion it
4
+ // WANTS, and returns the motion it gets plus what it touched - the
5
+ // first-person camera (which has no node) composes it in clampPosition,
6
+ // a node-driven body applies the result with setTransform. The loop
7
+ // itself (depenetration, sweep, slide, floor snap) runs in the spatial
8
+ // core as scene.moveAndSlide; this is its free-function spelling.
9
+
10
+ import type { MoveOptions, MoveResult, Scene, Volume } from "./scene.ts"
20
11
  import type { Vec3 } from "./math.ts"
21
- import type { Impact, QueryOptions, Scene, Volume } from "./scene.ts"
22
-
23
- /** Gap the body keeps from every surface, world units (Unity's skinWidth,
24
- * Godot's safe_margin): what keeps float error from sinking a resting body
25
- * into its floor. */
26
- const SKIN = 0.01
27
- /** Contacts within this angle of `up` are floor: 45 degrees, Godot's
28
- * floor_max_angle and Unity's slopeLimit. */
29
- const FLOOR_MAX_ANGLE = Math.PI / 4
30
- /** Most contacts one call slides along before it stops where it is
31
- * (Godot's max_slides). */
32
- const MAX_SLIDES = 6
33
- /** How far below the body a floor is pulled to while it is not rising
34
- * (Godot's floor_snap_length). */
35
- const FLOOR_SNAP = 0.1
36
- /** Passes of the depenetration loop, one contact set pushed out per pass
37
- * (Godot's recovery attempts). */
38
- const MAX_RECOVERIES = 4
39
- /** Motion below which a slide stops: nothing left worth sweeping. */
40
- const MIN_MOVE = 1e-6
41
- /** Floor of the approach cosine the skin is divided by to retreat along
42
- * the motion: a grazing contact retreats a few skins, not the whole step. */
43
- const SKIN_MIN_COS = 0.1
44
-
45
- const UP: Vec3 = [0, 1, 0]
46
12
 
47
13
  /** What moveAndSlide needs of a scene. */
48
- export type MoveScene = Pick<Scene, "sweep" | "overlap">
49
-
50
- export type MoveOptions = QueryOptions & {
51
- /** The direction floors face (default [0, 1, 0]). */
52
- up?: Vec3
53
- /** Largest angle (radians) between a contact normal and `up` that still
54
- * counts as floor (default 45 degrees). Steeper contacts are walls, and
55
- * the body slides down them. */
56
- floorMaxAngle?: number
57
- /** Most contacts one call slides along (default 6). */
58
- maxSlides?: number
59
- /** Gap kept from every surface, world units (default 0.01). */
60
- skin?: number
61
- /** How far below the body a floor is pulled to when the motion does not
62
- * rise (default 0.1; 0 disables): what keeps a walker on a ramp going
63
- * down, and what decides `floor` at the end of the move - a body that
64
- * ends higher than this above its floor is airborne, whatever it
65
- * touched on the way. A rising motion (a jump) never snaps. */
66
- floorSnap?: number
67
- }
14
+ export type MoveScene = Pick<Scene, "moveAndSlide">
68
15
 
69
- export type MoveResult = {
70
- /** The displacement the body gets: add it to the body's position. */
71
- motion: Vec3
72
- /** The unit normal of the floor the body ends the move on - within
73
- * `floorSnap` below it, snapped onto - else null (airborne, or on a
74
- * slope too steep to stand on). With `floorSnap: 0` it is a floor met
75
- * during the move instead. */
76
- floor: Vec3 | null
77
- /** Whether a wall (a contact steeper than a floor and flatter than a
78
- * ceiling) or a ceiling was met. */
79
- wall: boolean
80
- ceiling: boolean
81
- /** Every contact met, in order, the floor snap's last. */
82
- hits: Impact[]
83
- }
84
-
85
- /**
86
- * Move a body `motion` through the scene's colliders, sliding along what
87
- * it hits (Godot's move_and_slide, Unity's CharacterController.Move): a
88
- * capsule for a character, a sphere for a ball, a box for a crate. The
89
- * body first pushes out of anything it starts inside, then sweeps and
90
- * slides up to `maxSlides` times, then, unless the motion rises, snaps
91
- * down onto a floor within `floorSnap` - the floor it reports is the one
92
- * it ends on. Colliders are whatever `opts`
93
- * selects (`layers`/`meshes`, as for sweep): pass the collision layer,
94
- * or the body's own mesh answers for its own walls.
95
- *
96
- * Gravity is the caller's: fold the fall into `motion` each frame, zero
97
- * it while `floor` is set (the snap keeps reporting the floor while the
98
- * body stands still), and a walkable floor absorbs the vertical part -
99
- * `motion` only ever moves the body along a floor it stands on, never
100
- * down it.
101
- */
16
+ /** Move a body `motion` through the scene's colliders, sliding along what
17
+ * it hits: scene.moveAndSlide as a function (see it for the contract). */
102
18
  export function moveAndSlide(scene: MoveScene, volume: Volume, motion: Vec3, opts: MoveOptions = {}): MoveResult {
103
- let up = opts.up ?? UP
104
- let floorCos = Math.cos(opts.floorMaxAngle ?? FLOOR_MAX_ANGLE)
105
- let maxSlides = opts.maxSlides ?? MAX_SLIDES
106
- let skin = opts.skin ?? SKIN
107
- let snap = opts.floorSnap ?? FLOOR_SNAP
108
- let query: QueryOptions = { layers: opts.layers, meshes: opts.meshes }
109
- let offset: Vec3 = [0, 0, 0]
110
- let hits: Impact[] = []
111
- let floor: Vec3 | null = null
112
- let wall = false
113
- let ceiling = false
114
-
115
- // Depenetration: every contact's push-out, each only as far as the
116
- // pushes so far have not already covered along its normal, so two
117
- // walls of a corner do not push twice.
118
- for (let pass = 0; pass < MAX_RECOVERIES; pass++) {
119
- let push: Vec3 = [0, 0, 0]
120
- let any = false
121
- for (let c of scene.overlap(shifted(volume, offset), query)) {
122
- if (c.depth <= 0) continue
123
- any = true
124
- let extra = c.depth + skin - dot(push, c.normal)
125
- if (extra > 0) push = add(push, scale(c.normal, extra))
126
- }
127
- if (!any) break
128
- offset = add(offset, push)
129
- }
130
-
131
- let remaining: Vec3 = [motion[0], motion[1], motion[2]]
132
- for (let slide = 0; slide < maxSlides; slide++) {
133
- let len = Math.hypot(remaining[0], remaining[1], remaining[2])
134
- if (len < MIN_MOVE) break
135
- let hit = scene.sweep(shifted(volume, offset), remaining, query)[0]
136
- if (hit === undefined) {
137
- offset = add(offset, remaining)
138
- break
139
- }
140
- hits.push(hit)
141
- let dir = scale(remaining, 1 / len)
142
- let n = hit.normal
143
- // Stop where the gap to the surface, measured along its normal, is
144
- // one skin; the sweep guarantees the approach is positive.
145
- let retreat = skin / Math.max(-dot(dir, n), SKIN_MIN_COS)
146
- let advance = Math.max(hit.time * len - retreat, 0)
147
- offset = add(offset, scale(dir, advance))
148
- remaining = scale(dir, len - advance)
149
- let rise = dot(n, up)
150
- if (rise >= floorCos) {
151
- // A floor takes the vertical part (the fall, a landing) and passes
152
- // the horizontal part along its plane.
153
- floor = n
154
- let level = sub(remaining, scale(up, dot(remaining, up)))
155
- remaining = sub(level, scale(n, dot(level, n)))
156
- } else {
157
- if (rise <= -floorCos) ceiling = true
158
- else wall = true
159
- remaining = sub(remaining, scale(n, dot(remaining, n)))
160
- }
161
- }
162
-
163
- // Where the body ends decides the floor: a contact met on the way
164
- // (a ramp's top before a big step past its crest) is not one to stand
165
- // on unless it is still within reach below.
166
- if (snap > 0 && dot(motion, up) <= 0) {
167
- floor = null
168
- let hit = scene.sweep(shifted(volume, offset), scale(up, -snap), query)[0]
169
- if (hit !== undefined && dot(hit.normal, up) >= floorCos) {
170
- hits.push(hit)
171
- let retreat = skin / Math.max(dot(hit.normal, up), SKIN_MIN_COS)
172
- offset = add(offset, scale(up, -Math.max(hit.time * snap - retreat, 0)))
173
- floor = hit.normal
174
- }
175
- }
176
- return { motion: offset, floor, wall, ceiling, hits }
177
- }
178
-
179
- /** The volume displaced by `d`. */
180
- function shifted(volume: Volume, d: Vec3): Volume {
181
- if ("center" in volume) return { ...volume, center: add(volume.center, d) }
182
- return { ...volume, a: add(volume.a, d), b: add(volume.b, d) }
183
- }
184
-
185
- function add(a: Vec3, b: Vec3): Vec3 {
186
- return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
187
- }
188
-
189
- function sub(a: Vec3, b: Vec3): Vec3 {
190
- return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
191
- }
192
-
193
- function scale(v: Vec3, s: number): Vec3 {
194
- return [v[0] * s, v[1] * s, v[2] * s]
195
- }
196
-
197
- function dot(a: Vec3, b: Vec3): number {
198
- return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
19
+ return scene.moveAndSlide(volume, motion, opts)
199
20
  }
@@ -0,0 +1,35 @@
1
+ import { createContext, useContext } from "@solidrt/core"
2
+ import type { Element, PointerFeed } from "@solidrt/core"
3
+ import type { SceneNode } from "../node.ts"
4
+ import type { Scene as SceneHandle } from "../scene.ts"
5
+ import type { CameraState, CameraUpdate } from "../camera.ts"
6
+
7
+ /** The camera state a camera control drives: the scene, or a view (both
8
+ * expose setCamera and camera()). */
9
+ export type CameraTarget = { setCamera(update: CameraUpdate): void; camera(): CameraState }
10
+
11
+ export type SceneCtx = { scene: SceneHandle; parent: SceneNode; viewport: CameraTarget; pointer: PointerFeed | null }
12
+ export let SceneContext = createContext<SceneCtx>()
13
+
14
+ /**
15
+ * The enclosing scene, parent node, viewport and pointer feed - the
16
+ * imperative escape hatch inside a component subtree (throws outside a
17
+ * `<Scene>`). `viewport` and `pointer` are the nearest OWNER's: the
18
+ * scene, or inside a `<View3d>` that view - what the camera-control
19
+ * components drive, and the feed the owner's `pointer` prop handed in
20
+ * (null when none was: the owner's leaf then feeds no gestures). The
21
+ * same shape as @solidrt/2d's useSpriteLayer.
22
+ */
23
+ export function useScene(): SceneCtx {
24
+ return useContext(SceneContext)
25
+ }
26
+
27
+ // The scene context for a node component's children. Solid's provider is
28
+ // a root and two memos over the children, paid even when there are none,
29
+ // so it is built only when the JSX has children: the compiler emits the
30
+ // `children` getter only then, so the test is structural, not a read
31
+ // (probes/3d-instance-mount-bench.tsx has the per-row cost).
32
+ export function provide(ctx: SceneCtx, parent: SceneNode, props: { children?: Element }): Element | undefined {
33
+ if (!("children" in props)) return undefined
34
+ return <SceneContext value={{ scene: ctx.scene, parent, viewport: ctx.viewport, pointer: ctx.pointer }}>{props.children}</SceneContext>
35
+ }