@solidrt/3d 0.0.56 → 0.0.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENTS.md +199 -117
  2. package/README.md +2 -1
  3. package/examples/README.md +13 -8
  4. package/examples/collision.tsx +17 -4
  5. package/examples/first-person.tsx +20 -10
  6. package/examples/fleet.tsx +11 -5
  7. package/examples/model.tsx +3 -3
  8. package/examples/pick.tsx +144 -34
  9. package/examples/probe.tsx +10 -4
  10. package/examples/scene-views.tsx +49 -58
  11. package/examples/sky-lit.tsx +10 -4
  12. package/examples/skybox.tsx +10 -4
  13. package/examples/standard.tsx +10 -4
  14. package/package.json +2 -2
  15. package/src/collision.ts +14 -193
  16. package/src/components/context.tsx +35 -0
  17. package/src/components/directional-light.tsx +48 -0
  18. package/src/components/first-person-camera.tsx +72 -0
  19. package/src/components/group.tsx +18 -0
  20. package/src/components/hemisphere-light.tsx +24 -0
  21. package/src/components/index.ts +46 -0
  22. package/src/components/instanced-mesh.tsx +105 -0
  23. package/src/components/mesh.tsx +92 -0
  24. package/src/components/node-props.ts +90 -0
  25. package/src/components/orbit-camera.tsx +75 -0
  26. package/src/components/perspective-camera.tsx +32 -0
  27. package/src/components/point-light.tsx +52 -0
  28. package/src/components/record-mesh.tsx +68 -0
  29. package/src/components/scene.tsx +281 -0
  30. package/src/components/spot-light.tsx +65 -0
  31. package/src/components/sprite.tsx +38 -0
  32. package/src/components/view3d.tsx +152 -0
  33. package/src/first-person.ts +140 -188
  34. package/src/index.ts +13 -9
  35. package/src/input.ts +70 -0
  36. package/src/node.ts +98 -25
  37. package/src/orbit.ts +266 -235
  38. package/src/scene-pointer.ts +229 -101
  39. package/src/scene.ts +250 -76
  40. package/src/components.tsx +0 -1101
package/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
+ }
@@ -0,0 +1,48 @@
1
+ import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { VoidComponent } from "@solidrt/core"
3
+ import { SceneContext } from "./context.tsx"
4
+ import { syncNode } from "./node-props.ts"
5
+ import type { TransformProps } from "./node-props.ts"
6
+ import { add, remove } from "../node.ts"
7
+ import { createDirectionalLight, setLight } from "../light.ts"
8
+ import type { DirectionalLight as DirectionalLightNode, ShadowOptions } from "../light.ts"
9
+ import type { Vec3 } from "../math.ts"
10
+
11
+ export type DirectionalLightProps = TransformProps & {
12
+ /** Travel direction in the node's local space; default [0, -1, 0]. */
13
+ direction?: Vec3
14
+ color?: Vec3
15
+ intensity?: number
16
+ /** Render a shadow map from this light (any directional light may;
17
+ * each is a pass). Its shadow camera sits at the light's WORLD
18
+ * position, so give a casting light a `position` above the scene. */
19
+ castShadow?: boolean
20
+ /** Shadow-map options (mapSize, bias, normalBias, camera frustum,
21
+ * cascades, distance), merged key by key. */
22
+ shadow?: ShadowOptions
23
+ ref?: (light: DirectionalLightNode) => void
24
+ }
25
+
26
+ /** A directional light node (createDirectionalLight): a parent Group's
27
+ * rotation turns it; up to MAX_LIGHTS per scene, in mount order. */
28
+ export let DirectionalLight: VoidComponent<DirectionalLightProps> = props => {
29
+ let ctx = useContext(SceneContext)
30
+ let light = untrack(() =>
31
+ createDirectionalLight({
32
+ direction: props.direction,
33
+ color: props.color,
34
+ intensity: props.intensity,
35
+ castShadow: props.castShadow,
36
+ shadow: props.shadow,
37
+ }),
38
+ )
39
+ add(ctx.parent, light)
40
+ syncNode(light, props)
41
+ createEffect(
42
+ () => [props.direction, props.color, props.intensity, props.castShadow, props.shadow] as const,
43
+ ([direction, color, intensity, castShadow, shadow]) => setLight(light, { direction, color, intensity, castShadow, shadow }),
44
+ )
45
+ untrack(() => props.ref)?.(light)
46
+ onCleanup(() => remove(light))
47
+ return null
48
+ }
@@ -0,0 +1,72 @@
1
+ import { createEffect, merge, onFrame, untrack, useContext } from "@solidrt/core"
2
+ import type { InputMap, VoidComponent } from "@solidrt/core"
3
+ import { SceneContext } from "./context.tsx"
4
+ import { createFirstPersonCamera } from "../first-person.ts"
5
+ import type { FirstPersonAxes, FirstPersonCamera as FirstPersonCameraHandle, FirstPersonCameraOptions } from "../first-person.ts"
6
+
7
+ // Cap on the frame loop's per-frame dt in seconds, the same guard as the
8
+ // orbit's: a resumed app must not teleport the walker.
9
+ const MAX_WALK_DT = 0.1
10
+
11
+ export type FirstPersonCameraProps = FirstPersonCameraOptions & {
12
+ /** The input map driving the control (InputMap.drive): its `look`,
13
+ * `move` and `rise` actions, or the ones `actions` names. Live: a new
14
+ * map reconnects. Without one the control moves only through `ref`. */
15
+ input?: InputMap<any>
16
+ /** Action names per axis when the map's differ (null skips an axis). */
17
+ actions?: Partial<Record<keyof FirstPersonAxes, string | null>>
18
+ /** The control's handle (pose()/set()/eye()/forward()/active(), the
19
+ * verbs, `axes` - also the debug command shape). */
20
+ ref?: (camera: FirstPersonCameraHandle) => void
21
+ }
22
+
23
+ /**
24
+ * createFirstPersonCamera as a Scene (or View3d) child: drives the
25
+ * enclosing camera - the scene's, or inside a `<View3d>` that view's -
26
+ * through context, and takes its input from the map in `input`, nothing
27
+ * else (ARCHITECTURE.md: the app binds keys, a pointer feed, a pad or
28
+ * anything else to the map, and spreads the map's key handlers where the
29
+ * keys should be caught). Pointer lock stays the app's: call
30
+ * `lockPointer(true)` from a click and `lockPointer(false)` from Escape;
31
+ * the pointer feed's `mouseDelta` source looks around while locked. The
32
+ * pose props (position, yaw, pitch) are initial values - change the pose
33
+ * at runtime through `ref`'s set() or the verbs; every other prop is
34
+ * live, forwarded to the control as a getter and read where it applies:
35
+ * `fly={flying()}` swaps walk and fly on the running control (pose
36
+ * carries over, no remount), `moveSpeed`, `lookSpeed` and
37
+ * `clampPosition` follow their props, and a pitch clamp change re-clamps
38
+ * at once. A frame loop runs only while `active()` - a rate driving - so
39
+ * a still scene stays demand-driven idle.
40
+ */
41
+ export let FirstPersonCamera: VoidComponent<FirstPersonCameraProps> = props => {
42
+ let ctx = useContext(SceneContext)
43
+ // Getters, not a spread: see OrbitCamera.
44
+ let options: FirstPersonCameraOptions = merge(props, {})
45
+ let camera = untrack(() => createFirstPersonCamera(ctx.viewport, options))
46
+ createEffect(
47
+ () => props.input,
48
+ input => (input ? input.drive(camera.axes, untrack(() => props.actions)) : undefined),
49
+ )
50
+ createEffect(
51
+ () => camera.active(),
52
+ on => {
53
+ if (!on) return
54
+ let last: number | null = null
55
+ return onFrame(tick => {
56
+ let now = tick / 1000
57
+ let dt = last === null ? 0 : Math.min(now - last, MAX_WALK_DT)
58
+ last = now
59
+ camera.update(dt)
60
+ })
61
+ },
62
+ )
63
+ // A pitch clamp change re-clamps the pose at once; deferred and
64
+ // untracked as OrbitCamera's.
65
+ createEffect(
66
+ () => [options.minPitch, options.maxPitch],
67
+ () => untrack(() => camera.set({})),
68
+ { defer: true },
69
+ )
70
+ untrack(() => props.ref)?.(camera)
71
+ return null
72
+ }
@@ -0,0 +1,18 @@
1
+ import { onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { ParentComponent } from "@solidrt/core"
3
+ import { SceneContext, provide } from "./context.tsx"
4
+ import { syncNode } from "./node-props.ts"
5
+ import type { TransformProps, PointerEventProps } from "./node-props.ts"
6
+ import { add, createGroup, remove } from "../node.ts"
7
+ import type { SceneNode } from "../node.ts"
8
+
9
+ /** A transform node: children inherit its position/rotation/scale. */
10
+ export let Group: ParentComponent<TransformProps & PointerEventProps & { ref?: (node: SceneNode) => void }> = props => {
11
+ let ctx = useContext(SceneContext)
12
+ let node = createGroup()
13
+ add(ctx.parent, node)
14
+ syncNode(node, props)
15
+ untrack(() => props.ref)?.(node)
16
+ onCleanup(() => remove(node))
17
+ return provide(ctx, node, props)
18
+ }
@@ -0,0 +1,24 @@
1
+ import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { VoidComponent } from "@solidrt/core"
3
+ import { SceneContext } from "./context.tsx"
4
+ import { add, remove } from "../node.ts"
5
+ import { createHemisphereLight, setLight } from "../light.ts"
6
+ import type { HemisphereLight as HemisphereLightNode } from "../light.ts"
7
+ import type { Vec3 } from "../math.ts"
8
+
9
+ export type HemisphereLightProps = { sky?: Vec3; ground?: Vec3; intensity?: number; ref?: (light: HemisphereLightNode) => void }
10
+
11
+ /** The scene's ambient term as a node (createHemisphereLight); one per
12
+ * scene, the last mounted wins. */
13
+ export let HemisphereLight: VoidComponent<HemisphereLightProps> = props => {
14
+ let ctx = useContext(SceneContext)
15
+ let light = untrack(() => createHemisphereLight({ sky: props.sky, ground: props.ground, intensity: props.intensity }))
16
+ add(ctx.parent, light)
17
+ createEffect(
18
+ () => [props.sky, props.ground, props.intensity] as const,
19
+ ([sky, ground, intensity]) => setLight(light, { sky, ground, intensity }),
20
+ )
21
+ untrack(() => props.ref)?.(light)
22
+ onCleanup(() => remove(light))
23
+ return null
24
+ }
@@ -0,0 +1,46 @@
1
+ // The Solid face: PascalCase components over context, syncing the retained
2
+ // scene (node/mesh/light/scene.ts) - no new intrinsic elements, no
3
+ // renderer changes. Props
4
+ // follow the Solid 2.0 model (reactive values, no destructuring); effects
5
+ // write into the retained nodes and the runtime's dirty flush renders.
6
+ // Anything moving at frame rate can bypass the declarative layer: grab the
7
+ // node with `ref` and call setTransform from onFrame - signals carry
8
+ // structure and slow state, per-frame motion goes straight to the scene.
9
+ //
10
+ // One file per component (the lights, cameras and meshes each their own),
11
+ // with the shared pieces beside them: context.tsx (the scene context and
12
+ // useScene), node-props.ts (the transform and pointer
13
+ // props every node component syncs) and mesh.tsx (the mesh-side props the
14
+ // populated meshes share). This index is the package's face: index.ts at
15
+ // the root re-exports it, nothing outside imports the files directly.
16
+
17
+ export { useScene } from "./context.tsx"
18
+ export type { CameraTarget } from "./context.tsx"
19
+ export type { PointerEventProps, TransformProps } from "./node-props.ts"
20
+ export { Scene } from "./scene.tsx"
21
+ export type { ScenePointerProps, SceneProps } from "./scene.tsx"
22
+ export { View3d } from "./view3d.tsx"
23
+ export type { View3dProps } from "./view3d.tsx"
24
+ export { Group } from "./group.tsx"
25
+ export { Mesh } from "./mesh.tsx"
26
+ export type { MeshProps } from "./mesh.tsx"
27
+ export { Sprite } from "./sprite.tsx"
28
+ export type { SpriteProps } from "./sprite.tsx"
29
+ export { Instance, InstancedMesh } from "./instanced-mesh.tsx"
30
+ export type { InstancedMeshProps, InstanceProps } from "./instanced-mesh.tsx"
31
+ export { RecordMesh } from "./record-mesh.tsx"
32
+ export type { RecordMeshProps } from "./record-mesh.tsx"
33
+ export { PerspectiveCamera } from "./perspective-camera.tsx"
34
+ export type { PerspectiveCameraProps } from "./perspective-camera.tsx"
35
+ export { OrbitCamera } from "./orbit-camera.tsx"
36
+ export type { OrbitCameraProps } from "./orbit-camera.tsx"
37
+ export { FirstPersonCamera } from "./first-person-camera.tsx"
38
+ export type { FirstPersonCameraProps } from "./first-person-camera.tsx"
39
+ export { HemisphereLight } from "./hemisphere-light.tsx"
40
+ export type { HemisphereLightProps } from "./hemisphere-light.tsx"
41
+ export { DirectionalLight } from "./directional-light.tsx"
42
+ export type { DirectionalLightProps } from "./directional-light.tsx"
43
+ export { SpotLight } from "./spot-light.tsx"
44
+ export type { SpotLightProps } from "./spot-light.tsx"
45
+ export { PointLight } from "./point-light.tsx"
46
+ export type { PointLightProps } from "./point-light.tsx"
@@ -0,0 +1,105 @@
1
+ import { createContext, createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { ParentComponent } from "@solidrt/core"
3
+ import { SceneContext, provide } from "./context.tsx"
4
+ import { syncNode } from "./node-props.ts"
5
+ import type { TransformProps, PointerEventProps } from "./node-props.ts"
6
+ import { syncMesh } from "./mesh.tsx"
7
+ import type { PopulatedMeshProps } from "./mesh.tsx"
8
+ import { add } from "../node.ts"
9
+ import { addInstance, createInstancedMesh, disposeInstances, removeInstance, setCastShadow, setGeometry, setInstanceStyle } from "../mesh.ts"
10
+ import type { InstancedMesh as InstancedMeshNode, InstanceNode } from "../mesh.ts"
11
+ import type { Geometry } from "../geometry.ts"
12
+ import type { Material } from "../material.ts"
13
+
14
+ export type InstancedMeshProps = PopulatedMeshProps & {
15
+ geometry: Geometry
16
+ /** An instanced material: a stock one with `instanced` (or
17
+ * `instanceColors`, for a per-instance tint), or a class declaring
18
+ * INSTANCE_MATRIX_ATTRIBUTES in slot 0 and any style layout in slot 1. */
19
+ material: Material
20
+ /** Instance slots reserved up front (default 64; fixed at creation).
21
+ * Past it the buffers double into replacements - amortized, but size it
22
+ * realistically to skip the copies. */
23
+ capacity?: number
24
+ /** LOCAL bounds covering the population ([minX..maxZ]), fixed at
25
+ * creation: the mesh node's own box for the frustum test and the
26
+ * transparent sort. Optional here - instances pick by themselves, and
27
+ * without it the mesh culls by the union of their boxes. */
28
+ bounds?: ArrayLike<number>
29
+ /** Debug label for the record buffers. */
30
+ label?: string
31
+ /** Draw into the scene's shadow map (setCastShadow as a prop); default
32
+ * false. The stock instanced materials cast; a custom class needs a
33
+ * `shadowVertex`. */
34
+ castShadow?: boolean
35
+ ref?: (mesh: InstancedMeshNode) => void
36
+ }
37
+
38
+ let InstancedMeshContext = createContext<InstancedMeshNode | null>(null)
39
+
40
+ /**
41
+ * One draw entry covering N instance NODES (createInstancedMesh as a
42
+ * component): `<Instance>` children populate it - each a scene node the
43
+ * core places, so their `transition` props spring with zero per-frame
44
+ * JS, and picking, pointer events, overlap and sweep name the instance
45
+ * struck. `<Group>` children between the mesh and its instances are
46
+ * squads (the records stay mesh-relative through them). The record
47
+ * buffers are component-owned and freed on unmount.
48
+ */
49
+ export let InstancedMesh: ParentComponent<InstancedMeshProps> = props => {
50
+ let ctx = useContext(SceneContext)
51
+ let mesh = untrack(() =>
52
+ createInstancedMesh(props.geometry, props.material, { capacity: props.capacity, bounds: props.bounds, label: props.label }),
53
+ )
54
+ add(ctx.parent, mesh)
55
+ createEffect(
56
+ () => props.geometry,
57
+ g => setGeometry(mesh, g),
58
+ { defer: true },
59
+ )
60
+ createEffect(
61
+ () => props.castShadow,
62
+ c => setCastShadow(mesh, c === true),
63
+ )
64
+ syncMesh(mesh, props)
65
+ untrack(() => props.ref)?.(mesh)
66
+ onCleanup(() => disposeInstances(mesh))
67
+ return (
68
+ <SceneContext value={{ scene: ctx.scene, parent: mesh, viewport: ctx.viewport, pointer: ctx.pointer }}>
69
+ <InstancedMeshContext value={mesh}>{props.children}</InstancedMeshContext>
70
+ </SceneContext>
71
+ )
72
+ }
73
+
74
+ export type InstanceProps = TransformProps & PointerEventProps & {
75
+ /** The instance's style record (setInstanceStyle as a prop): the
76
+ * floats of the material's slot-1 instance attributes - `[r, g, b, a]`
77
+ * under a stock material's `instanceColors`. Reactive; absent, the
78
+ * instance keeps the material's instanceStyle (white for a tint). */
79
+ style?: ArrayLike<number>
80
+ ref?: (instance: InstanceNode) => void
81
+ }
82
+
83
+ /**
84
+ * One instance of the enclosing `<InstancedMesh>` (addInstance as a
85
+ * component): a node placed under the nearest `<Group>` or `<Instance>`
86
+ * inside the mesh, with the transform, transition and pointer props of
87
+ * a `<Group>` plus `style`. Children (a `<Mesh>` headlight under a car
88
+ * instance) mount under it. Throws outside an `<InstancedMesh>`.
89
+ */
90
+ export let Instance: ParentComponent<InstanceProps> = props => {
91
+ let ctx = useContext(SceneContext)
92
+ let mesh = useContext(InstancedMeshContext)
93
+ if (mesh === null) throw new Error("<Instance> must be inside an <InstancedMesh>")
94
+ let instance = addInstance(mesh, undefined, ctx.parent)
95
+ syncNode(instance, props)
96
+ createEffect(
97
+ () => props.style,
98
+ s => {
99
+ if (s !== undefined) setInstanceStyle(instance, s)
100
+ },
101
+ )
102
+ untrack(() => props.ref)?.(instance)
103
+ onCleanup(() => removeInstance(instance))
104
+ return provide(ctx, instance, props)
105
+ }
@@ -0,0 +1,92 @@
1
+ import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { VoidComponent } from "@solidrt/core"
3
+ import { SceneContext } from "./context.tsx"
4
+ import { syncNode } from "./node-props.ts"
5
+ import type { TransformProps, PointerEventProps } from "./node-props.ts"
6
+ import { add, remove } from "../node.ts"
7
+ import { createMesh, setCastShadow, setCulling, setGeometry, setLayers, setMaterial, setMeshParams, setRenderOrder } from "../mesh.ts"
8
+ import type { Mesh as MeshNode } from "../mesh.ts"
9
+ import type { ShaderParams } from "@solidrt/core/gpu"
10
+ import type { Geometry } from "../geometry.ts"
11
+ import type { Material } from "../material.ts"
12
+
13
+ export type MeshProps = TransformProps & PointerEventProps & {
14
+ geometry: Geometry
15
+ material: Material
16
+ /** Per-mesh uniforms for a custom material (setMeshParams as a prop).
17
+ * Keys merge - a key that disappears keeps its old value; there is no
18
+ * unset. Names must be declared by the material's shaders. For values
19
+ * changing every frame prefer `ref` + setMeshParams from onFrame, the
20
+ * same split as setTransform. */
21
+ params?: ShaderParams
22
+ /** Explicit draw-order key (setRenderOrder as a prop); default 0. */
23
+ renderOrder?: number
24
+ /** Draw into the scene's shadow map (setCastShadow as a prop); default
25
+ * false. Needs a `castShadow` light to show. */
26
+ castShadow?: boolean
27
+ /** Layer membership bitmask (setLayers as a prop; default 1): a target
28
+ * draws the mesh when its mask intersects this. Not inherited from
29
+ * ancestor Groups. */
30
+ layers?: number
31
+ /** Frustum culling switch (setCulling as a prop; default true). */
32
+ frustumCulled?: boolean
33
+ /** World units the culled box grows by (setCulling as a prop; default 0). */
34
+ cullMargin?: number
35
+ ref?: (mesh: MeshNode) => void
36
+ }
37
+
38
+ // The mesh-side props every mesh component shares (Sprite has no
39
+ // geometry and casts no shadow, so those stay with the components that
40
+ // take them); the
41
+ // ref and the cleanup are each component's own (a populated mesh frees
42
+ // its buffers, a plain one is removed).
43
+ export function syncMesh(mesh: MeshNode, props: Omit<MeshProps, "geometry" | "castShadow" | "ref">): void {
44
+ createEffect(
45
+ () => props.material,
46
+ m => setMaterial(mesh, m),
47
+ { defer: true },
48
+ )
49
+ createEffect(
50
+ () => props.params,
51
+ p => {
52
+ if (p !== undefined) setMeshParams(mesh, p)
53
+ },
54
+ )
55
+ createEffect(
56
+ () => props.renderOrder,
57
+ o => setRenderOrder(mesh, o ?? 0),
58
+ )
59
+ createEffect(
60
+ () => props.layers,
61
+ l => setLayers(mesh, l ?? 1),
62
+ )
63
+ createEffect(
64
+ () => [props.frustumCulled, props.cullMargin] as const,
65
+ ([culled, margin]) => setCulling(mesh, { frustumCulled: culled !== false, cullMargin: margin ?? 0 }),
66
+ )
67
+ syncNode(mesh, props)
68
+ }
69
+
70
+ /** One draw entry: geometry drawn with a material at a transform. */
71
+ export let Mesh: VoidComponent<MeshProps> = props => {
72
+ let ctx = useContext(SceneContext)
73
+ let mesh = untrack(() => createMesh(props.geometry, props.material))
74
+ add(ctx.parent, mesh)
75
+ createEffect(
76
+ () => props.geometry,
77
+ g => setGeometry(mesh, g),
78
+ { defer: true },
79
+ )
80
+ createEffect(
81
+ () => props.castShadow,
82
+ c => setCastShadow(mesh, c === true),
83
+ )
84
+ syncMesh(mesh, props)
85
+ untrack(() => props.ref)?.(mesh)
86
+ onCleanup(() => remove(mesh))
87
+ return null
88
+ }
89
+
90
+ // The props both populated meshes share with Mesh: everything but the
91
+ // geometry/material pair (documented per component) and the ref.
92
+ export type PopulatedMeshProps = Omit<MeshProps, "geometry" | "material" | "ref">