@solidrt/3d 0.0.56 → 0.0.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +199 -117
- package/README.md +2 -1
- package/examples/README.md +13 -8
- package/examples/collision.tsx +17 -4
- package/examples/first-person.tsx +20 -10
- package/examples/fleet.tsx +11 -5
- package/examples/model.tsx +3 -3
- package/examples/pick.tsx +144 -34
- package/examples/probe.tsx +10 -4
- package/examples/scene-views.tsx +49 -58
- package/examples/sky-lit.tsx +10 -4
- package/examples/skybox.tsx +10 -4
- package/examples/standard.tsx +10 -4
- package/package.json +2 -2
- package/src/collision.ts +14 -193
- package/src/components/context.tsx +35 -0
- package/src/components/directional-light.tsx +48 -0
- package/src/components/first-person-camera.tsx +72 -0
- package/src/components/group.tsx +18 -0
- package/src/components/hemisphere-light.tsx +24 -0
- package/src/components/index.ts +46 -0
- package/src/components/instanced-mesh.tsx +105 -0
- package/src/components/mesh.tsx +92 -0
- package/src/components/node-props.ts +90 -0
- package/src/components/orbit-camera.tsx +75 -0
- package/src/components/perspective-camera.tsx +32 -0
- package/src/components/point-light.tsx +52 -0
- package/src/components/record-mesh.tsx +68 -0
- package/src/components/scene.tsx +281 -0
- package/src/components/spot-light.tsx +65 -0
- package/src/components/sprite.tsx +38 -0
- package/src/components/view3d.tsx +152 -0
- package/src/first-person.ts +140 -188
- package/src/index.ts +13 -9
- package/src/input.ts +70 -0
- package/src/node.ts +98 -25
- package/src/orbit.ts +266 -235
- package/src/scene-pointer.ts +229 -101
- package/src/scene.ts +250 -76
- package/src/components.tsx +0 -1101
package/src/collision.ts
CHANGED
|
@@ -1,199 +1,20 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
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, "
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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">
|