@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.
- package/AGENTS.md +1052 -639
- package/README.md +2 -1
- package/examples/README.md +19 -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/examples/wireframe.tsx +107 -0
- 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/geometry-gpu.ts +45 -13
- package/src/geometry.ts +186 -5
- package/src/gltf.ts +97 -16
- package/src/index.ts +15 -10
- package/src/input.ts +79 -0
- package/src/material.ts +26 -8
- package/src/mesh.ts +5 -6
- package/src/model.ts +65 -33
- package/src/node.ts +100 -26
- package/src/orbit.ts +266 -235
- package/src/scene-pointer.ts +229 -101
- package/src/scene.ts +256 -79
- package/src/skeleton.ts +211 -0
- package/src/components.tsx +0 -1101
|
@@ -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">
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createEffect } from "@solidrt/core"
|
|
2
|
+
import { setTransform, setTransition, setVisible } from "../node.ts"
|
|
3
|
+
import type { NodePointerEvent, NodeTapEvent, NodeWheelEvent, SceneNode, TransitionEndEvent } from "../node.ts"
|
|
4
|
+
import type { NodeTransition } from "flux:spatial"
|
|
5
|
+
import type { Quat, Vec3 } from "../math.ts"
|
|
6
|
+
|
|
7
|
+
export type TransformProps = {
|
|
8
|
+
position?: Vec3
|
|
9
|
+
/** Euler radians in XYZ order (x first), Three's `Euler` default. */
|
|
10
|
+
rotation?: Vec3
|
|
11
|
+
/** The rotation as a quaternion - what the node stores. Pass this or
|
|
12
|
+
* `rotation`, not both. */
|
|
13
|
+
quaternion?: Quat
|
|
14
|
+
scale?: Vec3 | number
|
|
15
|
+
visible?: boolean
|
|
16
|
+
/** How transform-prop changes animate (see setTransition); the mount
|
|
17
|
+
* transform always snaps. */
|
|
18
|
+
transition?: NodeTransition | string | null
|
|
19
|
+
/** A declared transition settled on one component. */
|
|
20
|
+
onTransitionEnd?: (event: TransitionEndEvent) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Node pointer events, the element vocabulary one tree deeper: the nearest
|
|
25
|
+
* hit (the struck instance, else the mesh) receives the event,
|
|
26
|
+
* down/move/up/wheel/tap bubble to ancestor Groups and end at the
|
|
27
|
+
* `<Scene>`'s own handlers (stopPropagation stops the walk; a stopped
|
|
28
|
+
* down claims the whole press, so a mesh that drags itself keeps an
|
|
29
|
+
* `<OrbitCamera>` still), enter/leave pair on the struck node alone.
|
|
30
|
+
* Events flow while the element showing the scene carries scene.handlers -
|
|
31
|
+
* the built-in <Scene> leaf does (opt out with events={false}); an `output`
|
|
32
|
+
* leaf spreads them itself.
|
|
33
|
+
*/
|
|
34
|
+
export type PointerEventProps = {
|
|
35
|
+
onPointerDown?: (event: NodePointerEvent) => void
|
|
36
|
+
onPointerMove?: (event: NodePointerEvent) => void
|
|
37
|
+
onPointerUp?: (event: NodePointerEvent) => void
|
|
38
|
+
/** Meshes and instances only: a Group never receives enter/leave. */
|
|
39
|
+
onPointerEnter?: (event: NodePointerEvent) => void
|
|
40
|
+
onPointerLeave?: (event: NodePointerEvent) => void
|
|
41
|
+
/** The wheel over the node: `deltaX`/`deltaY`. */
|
|
42
|
+
onWheel?: (event: NodeWheelEvent) => void
|
|
43
|
+
/** A press released on the node within the slop, alone for its whole
|
|
44
|
+
* press: DOM's click, with `tapCount` for repeats. */
|
|
45
|
+
onTap?: (event: NodeTapEvent) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function syncNode(node: SceneNode, props: TransformProps & PointerEventProps): void {
|
|
49
|
+
// One effect for the transform and the handlers: re-assigning eight
|
|
50
|
+
// handler fields on a transform write is free, an effect of its own is
|
|
51
|
+
// not (a node component's mount cost is mostly its effects and its
|
|
52
|
+
// context provider, see probes/3d-instance-mount-bench.tsx).
|
|
53
|
+
createEffect(
|
|
54
|
+
() =>
|
|
55
|
+
[
|
|
56
|
+
props.position,
|
|
57
|
+
props.rotation,
|
|
58
|
+
props.quaternion,
|
|
59
|
+
props.scale,
|
|
60
|
+
props.visible,
|
|
61
|
+
props.onPointerDown,
|
|
62
|
+
props.onPointerMove,
|
|
63
|
+
props.onPointerUp,
|
|
64
|
+
props.onPointerEnter,
|
|
65
|
+
props.onPointerLeave,
|
|
66
|
+
props.onWheel,
|
|
67
|
+
props.onTap,
|
|
68
|
+
props.onTransitionEnd,
|
|
69
|
+
] as const,
|
|
70
|
+
([position, rotation, quaternion, scale, visible, down, move, up, enter, leave, wheel, tap, end]) => {
|
|
71
|
+
setTransform(node, { position, rotation, quaternion, scale })
|
|
72
|
+
setVisible(node, visible !== false)
|
|
73
|
+
node.onPointerDown = down
|
|
74
|
+
node.onPointerMove = move
|
|
75
|
+
node.onPointerUp = up
|
|
76
|
+
node.onPointerEnter = enter
|
|
77
|
+
node.onPointerLeave = leave
|
|
78
|
+
node.onWheel = wheel
|
|
79
|
+
node.onTap = tap
|
|
80
|
+
node.onTransitionEnd = end
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
// After the transform effect, so the mount transform snaps before writes
|
|
84
|
+
// animate. Its own effect: merged above, every transform write would
|
|
85
|
+
// re-send the config to the core.
|
|
86
|
+
createEffect(
|
|
87
|
+
() => props.transition,
|
|
88
|
+
transition => setTransition(node, transition ?? null),
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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 { createOrbitCamera } from "../orbit.ts"
|
|
5
|
+
import type { OrbitAxes, OrbitCamera as OrbitCameraHandle, OrbitCameraOptions } from "../orbit.ts"
|
|
6
|
+
|
|
7
|
+
// Cap on the frame loop's per-frame dt in seconds, so the first tick after
|
|
8
|
+
// a suspended stretch (a resumed app, a reload) cannot leap the pose.
|
|
9
|
+
const MAX_ORBIT_DT = 0.1
|
|
10
|
+
|
|
11
|
+
export type OrbitCameraProps = OrbitCameraOptions & {
|
|
12
|
+
/** The input map driving the control (InputMap.drive): its `rotate`,
|
|
13
|
+
* `zoom` and `pan` 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 OrbitAxes, string | null>>
|
|
18
|
+
/** The control's handle (pose()/set()/eye()/orbiting()/active(), the
|
|
19
|
+
* verbs, `axes` - also the debug command shape). */
|
|
20
|
+
ref?: (orbit: OrbitCameraHandle) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* createOrbitCamera as a Scene (or View3d) child: drives the enclosing
|
|
25
|
+
* camera - the scene's, or inside a `<View3d>` that view's - through
|
|
26
|
+
* context, and takes its input from the map in `input`, nothing else
|
|
27
|
+
* (ARCHITECTURE.md: no device wiring in a component; the app binds a
|
|
28
|
+
* pointer feed, a pad or anything else to the map). The pose props
|
|
29
|
+
* (target, azimuth, elevation, distance) are initial values - change the
|
|
30
|
+
* pose at runtime through `ref`'s set() or the verbs; every other prop is
|
|
31
|
+
* live, forwarded to the control as a getter and read where it applies,
|
|
32
|
+
* so a clamp, a rate or an anchor callback follows its prop, and a clamp
|
|
33
|
+
* change re-clamps the pose at once. The frame loop runs only while
|
|
34
|
+
* `active()` (auto-orbit on, or an axis rate driving); a camera moved
|
|
35
|
+
* by drags alone leaves the app demand-driven idle.
|
|
36
|
+
*/
|
|
37
|
+
export let OrbitCamera: VoidComponent<OrbitCameraProps> = props => {
|
|
38
|
+
let ctx = useContext(SceneContext)
|
|
39
|
+
// The control keeps this object and reads each option where it applies,
|
|
40
|
+
// so the props go through as getters (merge), never a spread: a spread
|
|
41
|
+
// would snapshot every prop once and a change could only remount.
|
|
42
|
+
let options: OrbitCameraOptions = merge(props, {})
|
|
43
|
+
let orbit = untrack(() => createOrbitCamera(ctx.viewport, options))
|
|
44
|
+
createEffect(
|
|
45
|
+
() => props.input,
|
|
46
|
+
input => (input ? input.drive(orbit.axes, untrack(() => props.actions)) : undefined),
|
|
47
|
+
)
|
|
48
|
+
createEffect(
|
|
49
|
+
() => orbit.active(),
|
|
50
|
+
on => {
|
|
51
|
+
if (!on) return
|
|
52
|
+
let last: number | null = null
|
|
53
|
+
return onFrame(tick => {
|
|
54
|
+
let now = tick / 1000
|
|
55
|
+
let dt = last === null ? 0 : Math.min(now - last, MAX_ORBIT_DT)
|
|
56
|
+
last = now
|
|
57
|
+
orbit.update(dt)
|
|
58
|
+
})
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
// A clamp prop change re-clamps the pose at once (set({}) applies the
|
|
62
|
+
// clamps and pushes) - the update() call a Three OrbitControls app
|
|
63
|
+
// makes after setting minDistance, done by the prop. Deferred: the
|
|
64
|
+
// creation already clamped and pushed the initial pose. Untracked: the
|
|
65
|
+
// control reads the clamps through the getters while it applies them,
|
|
66
|
+
// and the apply wants the values of that moment (the compute above is
|
|
67
|
+
// what tracks them).
|
|
68
|
+
createEffect(
|
|
69
|
+
() => [options.minDistance, options.maxDistance, options.minElevation, options.maxElevation],
|
|
70
|
+
() => untrack(() => orbit.set({})),
|
|
71
|
+
{ defer: true },
|
|
72
|
+
)
|
|
73
|
+
untrack(() => props.ref)?.(orbit)
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createEffect, useContext } from "@solidrt/core"
|
|
2
|
+
import type { VoidComponent } from "@solidrt/core"
|
|
3
|
+
import { SceneContext } from "./context.tsx"
|
|
4
|
+
import type { Vec3 } from "../math.ts"
|
|
5
|
+
|
|
6
|
+
export type PerspectiveCameraProps = {
|
|
7
|
+
/** Vertical field of view in DEGREES (default 60). */
|
|
8
|
+
fov?: number
|
|
9
|
+
near?: number
|
|
10
|
+
far?: number
|
|
11
|
+
position?: Vec3
|
|
12
|
+
lookAt?: Vec3
|
|
13
|
+
up?: Vec3
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Drives the enclosing camera from props - the scene's, or inside a
|
|
18
|
+
* `<View3d>` that view's (both have a default camera, so this component is
|
|
19
|
+
* optional). The camera is target state, not a tree node: to orbit it,
|
|
20
|
+
* update `position`/`lookAt`. The Scene and View3d `camera` props drive the
|
|
21
|
+
* same state (a partial CameraUpdate, ortho included) - use one form, not
|
|
22
|
+
* both.
|
|
23
|
+
*/
|
|
24
|
+
export let PerspectiveCamera: VoidComponent<PerspectiveCameraProps> = props => {
|
|
25
|
+
let ctx = useContext(SceneContext)
|
|
26
|
+
createEffect(
|
|
27
|
+
() => [props.fov, props.near, props.far, props.position, props.lookAt, props.up] as const,
|
|
28
|
+
([fov, near, far, position, lookAt, up]) =>
|
|
29
|
+
ctx.viewport.setCamera({ fov, near, far, position, target: lookAt, up }),
|
|
30
|
+
)
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { createPointLight, setLight } from "../light.ts"
|
|
8
|
+
import type { PointLight as PointLightNode, SpotShadowOptions } from "../light.ts"
|
|
9
|
+
import type { Vec3 } from "../math.ts"
|
|
10
|
+
|
|
11
|
+
export type PointLightProps = TransformProps & {
|
|
12
|
+
color?: Vec3
|
|
13
|
+
intensity?: number
|
|
14
|
+
/** Falloff cutoff in world units (0 = no cutoff). */
|
|
15
|
+
distance?: number
|
|
16
|
+
/** Falloff exponent; default 2 (inverse square). */
|
|
17
|
+
decay?: number
|
|
18
|
+
/** Render shadow maps from this light (six 90-degree face maps, one
|
|
19
|
+
* per cube face; six shadow slots). Far = `distance`, so give a
|
|
20
|
+
* casting bulb one. */
|
|
21
|
+
castShadow?: boolean
|
|
22
|
+
/** Shadow-map options (mapSize, bias, normalBias, near), merged key
|
|
23
|
+
* by key. */
|
|
24
|
+
shadow?: SpotShadowOptions
|
|
25
|
+
ref?: (light: PointLightNode) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A point light node (createPointLight): light in every direction from
|
|
29
|
+
* the node's world position - give it a `position`; rotation does not
|
|
30
|
+
* matter. Counts against MAX_LIGHTS with the other non-ambient lights. */
|
|
31
|
+
export let PointLight: VoidComponent<PointLightProps> = props => {
|
|
32
|
+
let ctx = useContext(SceneContext)
|
|
33
|
+
let light = untrack(() =>
|
|
34
|
+
createPointLight({
|
|
35
|
+
color: props.color,
|
|
36
|
+
intensity: props.intensity,
|
|
37
|
+
distance: props.distance,
|
|
38
|
+
decay: props.decay,
|
|
39
|
+
castShadow: props.castShadow,
|
|
40
|
+
shadow: props.shadow,
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
add(ctx.parent, light)
|
|
44
|
+
syncNode(light, props)
|
|
45
|
+
createEffect(
|
|
46
|
+
() => [props.color, props.intensity, props.distance, props.decay, props.castShadow, props.shadow] as const,
|
|
47
|
+
([color, intensity, distance, decay, castShadow, shadow]) => setLight(light, { color, intensity, distance, decay, castShadow, shadow }),
|
|
48
|
+
)
|
|
49
|
+
untrack(() => props.ref)?.(light)
|
|
50
|
+
onCleanup(() => remove(light))
|
|
51
|
+
return null
|
|
52
|
+
}
|