@solidrt/3d 0.0.59 → 0.0.61
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 +436 -139
- package/examples/cascades.tsx +10 -4
- package/examples/pick.tsx +6 -4
- package/examples/sprites.tsx +7 -1
- package/package.json +4 -3
- package/src/components/context.tsx +13 -2
- package/src/components/first-person-camera.tsx +1 -1
- package/src/components/index.ts +2 -0
- package/src/components/instanced-mesh.tsx +25 -6
- package/src/components/lod.tsx +3 -1
- package/src/components/orbit-camera.tsx +105 -20
- package/src/components/shots.tsx +93 -0
- package/src/first-person.ts +48 -16
- package/src/gltf.ts +316 -62
- package/src/index.ts +8 -3
- package/src/input.ts +61 -17
- package/src/light.ts +25 -2
- package/src/material.ts +101 -25
- package/src/math.ts +11 -0
- package/src/mesh.ts +68 -21
- package/src/model-data.ts +27 -0
- package/src/model-file.ts +112 -37
- package/src/model.ts +90 -29
- package/src/node.ts +1 -1
- package/src/orbit.ts +526 -108
- package/src/scene-shadows.ts +4 -3
- package/src/scene.ts +201 -189
- package/src/shots.ts +63 -0
- package/src/skeleton.ts +39 -7
- package/tools/environment.ts +1 -0
- package/tools/model.ts +4 -3
- package/tools/node-env.d.ts +25 -0
- package/src/motion.ts +0 -17
- package/src/order.ts +0 -58
package/examples/cascades.tsx
CHANGED
|
@@ -11,9 +11,12 @@
|
|
|
11
11
|
// tile of the scene's one atlas, so the pass count is unchanged.
|
|
12
12
|
//
|
|
13
13
|
// A click cycles 1..4 cascades (1 = the box, widened over the field).
|
|
14
|
-
// The `cascades` debug command sets the count
|
|
15
|
-
// (`{ count, distance }`; the range the cascades
|
|
16
|
-
// by default - pulling it in sharpens every
|
|
14
|
+
// The `cascades` debug command sets the count, the shadow distance and
|
|
15
|
+
// the splits (`{ count, distance, splits }`; the range the cascades
|
|
16
|
+
// split, the camera's far by default - pulling it in sharpens every
|
|
17
|
+
// cascade; `splits` the count - 1 fractions of it, null for the
|
|
18
|
+
// practical split - pulling the first in sharpens the pillars at the
|
|
19
|
+
// camera's feet at the horizon's cost) and `fly` parks the
|
|
17
20
|
// flight (`{ t: seconds }`), so a capture repeats. The field is fogged
|
|
18
21
|
// toward the sky color from FOG_NEAR to FOG_FAR, inside the camera's far
|
|
19
22
|
// plane, so the far pillars sink into the horizon instead of clipping
|
|
@@ -38,6 +41,7 @@ const FOG_FAR = 150
|
|
|
38
41
|
|
|
39
42
|
let [cascades, setCascades] = createSignal(3)
|
|
40
43
|
let [distance, setDistance] = createSignal<number | null>(null)
|
|
44
|
+
let [splits, setSplits] = createSignal<number[] | null>(null)
|
|
41
45
|
// The flight clock, in seconds; `parked` holds it.
|
|
42
46
|
let [time, setTime] = createSignal(0)
|
|
43
47
|
let parked: number | null = null
|
|
@@ -45,8 +49,9 @@ let parked: number | null = null
|
|
|
45
49
|
registerDebug("cascades", (args?: Record<string, unknown>) => {
|
|
46
50
|
if (typeof args?.count === "number") setCascades(args.count)
|
|
47
51
|
if (typeof args?.distance === "number" || args?.distance === null) setDistance(args.distance)
|
|
52
|
+
if (Array.isArray(args?.splits) || args?.splits === null) setSplits(args.splits as number[] | null)
|
|
48
53
|
flush()
|
|
49
|
-
return { cascades: cascades(), distance: distance() }
|
|
54
|
+
return { cascades: cascades(), distance: distance(), splits: splits() }
|
|
50
55
|
})
|
|
51
56
|
registerDebug("fly", (args?: Record<string, unknown>) => {
|
|
52
57
|
if (typeof args?.t === "number") {
|
|
@@ -116,6 +121,7 @@ function App() {
|
|
|
116
121
|
normalBias: 0.08,
|
|
117
122
|
cascades: cascades(),
|
|
118
123
|
distance: distance(),
|
|
124
|
+
splits: splits(),
|
|
119
125
|
// The box tier's frustum, when cascades is 1: the whole field.
|
|
120
126
|
camera: { left: -FIELD / 2, right: FIELD / 2, top: FIELD / 2, bottom: -FIELD / 2, near: 1, far: 400 },
|
|
121
127
|
}}
|
package/examples/pick.tsx
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// of an orbit drag, so the pop waits for the release), a drag on the
|
|
4
4
|
// crate slides it over the floor and never orbits - its down claims the
|
|
5
5
|
// press, so the orbit control's feed, listening at the root, sees none
|
|
6
|
-
// of it - while a drag anywhere else orbits and a wheel zooms
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// of it - while a drag anywhere else orbits and a wheel zooms toward
|
|
7
|
+
// what is under the cursor (the component's default `anchor="pick"`: the
|
|
8
|
+
// scene under the pointer, else the plane at the target's depth; a
|
|
9
|
+
// double tap glides the pivot to the mesh under it); the ring claims the
|
|
10
|
+
// wheel to spin itself; a tap on empty space un-pops everything (the
|
|
11
|
+
// scene's own onTap with `mesh` null, the deselect idiom). The Group hears the cone's downs through bubbling (the crate's
|
|
10
12
|
// claim stops the walk at the crate). The scene is STATIC - no onFrame - so it renders only when an event changes
|
|
11
13
|
// something, and the orbit runs frames only while it glides; hit testing
|
|
12
14
|
// runs over the scene's BVH, so a pointer move costs O(log meshes).
|
package/examples/sprites.tsx
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// billboard modes side by side: "full" glows that stay flat to the screen
|
|
4
4
|
// whatever the camera does, and "fixed-y" trees that only yaw to follow
|
|
5
5
|
// the camera and stay upright as it climbs - the classic upright sprite.
|
|
6
|
-
//
|
|
6
|
+
// Two mapless puffs over the cube use the radial shape instead of a
|
|
7
|
+
// texture. The spinning cube is the reference solid; the sprites' `scale` is their
|
|
7
8
|
// world size, and their (ignored) rotation is never set.
|
|
8
9
|
import { createSignal, onFrame, pct, render } from "@solidrt/core"
|
|
9
10
|
import { createTexture } from "@solidrt/core/gpu"
|
|
@@ -59,6 +60,9 @@ function App() {
|
|
|
59
60
|
|
|
60
61
|
let glows = sprite({ map: glow(), color: [1, 0.85, 0.5] })
|
|
61
62
|
let trees = sprite({ map: tree(), billboard: "fixed-y" })
|
|
63
|
+
// The same soft disc with no texture at all: the radial shape, a soft
|
|
64
|
+
// puff at falloff 2, glowing additively.
|
|
65
|
+
let puffs = sprite({ shape: "radial", falloff: 2, color: [0.45, 0.7, 1], blend: "add" })
|
|
62
66
|
let ringPositions = Array.from({ length: 8 }, (_, i) => {
|
|
63
67
|
let a = (i / 8) * Math.PI * 2
|
|
64
68
|
return [Math.cos(a) * 1.4, 0.9 + Math.sin(a * 2) * 0.3, Math.sin(a) * 1.4] as [number, number, number]
|
|
@@ -84,6 +88,8 @@ function App() {
|
|
|
84
88
|
<Sprite material={trees} position={[2.4, 0.9, 0.5]} scale={[1.4, 1.8, 1]} />
|
|
85
89
|
<Sprite material={trees} position={[0.8, 0.7, -2.6]} scale={[1, 1.4, 1]} />
|
|
86
90
|
<Sprite material={trees} position={[-1.5, 0.6, 2.2]} scale={[0.9, 1.2, 1]} />
|
|
91
|
+
<Sprite material={puffs} position={[0, 1.6, 0]} scale={0.9} />
|
|
92
|
+
<Sprite material={puffs} position={[-0.6, 2.1, 0.4]} scale={0.6} />
|
|
87
93
|
</Scene>
|
|
88
94
|
</view>
|
|
89
95
|
</window>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/3d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.61",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"main": "src/index.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./src/index.ts",
|
|
11
|
+
"./model": "./src/model-data.ts",
|
|
11
12
|
"./math": "./src/math.ts",
|
|
12
13
|
"./glsl": "./src/glsl.ts"
|
|
13
14
|
},
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
"AGENTS.md"
|
|
19
20
|
],
|
|
20
21
|
"peerDependencies": {
|
|
21
|
-
"@solidjs/signals": "2.0.0-rc.
|
|
22
|
-
"@solidrt/core": "0.0.
|
|
22
|
+
"@solidjs/signals": "2.0.0-rc.9",
|
|
23
|
+
"@solidrt/core": "0.0.61"
|
|
23
24
|
}
|
|
24
25
|
}
|
|
@@ -3,10 +3,21 @@ import type { Element, PointerFeed } from "@solidrt/core"
|
|
|
3
3
|
import type { SceneNode } from "../node.ts"
|
|
4
4
|
import type { Scene as SceneHandle } from "../scene.ts"
|
|
5
5
|
import type { CameraState, CameraUpdate } from "../camera.ts"
|
|
6
|
+
import type { Hit, QueryOptions, ScreenRay } from "../scene.ts"
|
|
7
|
+
import type { Vec3 } from "../math.ts"
|
|
6
8
|
|
|
7
9
|
/** The camera state a camera control drives: the scene, or a view (both
|
|
8
|
-
* expose setCamera, camera()
|
|
9
|
-
|
|
10
|
+
* expose setCamera, camera(), size(), and the projection the control
|
|
11
|
+
* components build their anchor, focus and occlusion hooks from). */
|
|
12
|
+
export type CameraTarget = {
|
|
13
|
+
setCamera(update: CameraUpdate): void
|
|
14
|
+
camera(): CameraState
|
|
15
|
+
size(): { width: number; height: number }
|
|
16
|
+
pick(x: number, y: number): Hit[]
|
|
17
|
+
unproject(x: number, y: number, w: number, out?: Vec3): Vec3
|
|
18
|
+
screenRay(x: number, y: number): ScreenRay
|
|
19
|
+
raycast(origin: Vec3, direction: Vec3, opts?: QueryOptions): Hit[]
|
|
20
|
+
}
|
|
10
21
|
|
|
11
22
|
export type SceneCtx = { scene: SceneHandle; parent: SceneNode; viewport: CameraTarget; pointer: PointerFeed | null }
|
|
12
23
|
export let SceneContext = createContext<SceneCtx>()
|
|
@@ -10,7 +10,7 @@ const MAX_WALK_DT = 0.1
|
|
|
10
10
|
|
|
11
11
|
export type FirstPersonCameraProps = FirstPersonCameraOptions & {
|
|
12
12
|
/** The input map driving the control (InputMap.drive): its `look`,
|
|
13
|
-
* `move` and `
|
|
13
|
+
* `move`, `rise` and `boost` actions, or the ones `actions` names. Live: a new
|
|
14
14
|
* map reconnects. Without one the control moves only through `ref`. */
|
|
15
15
|
input?: InputMap<any>
|
|
16
16
|
/** Action names per axis when the map's differ (null skips an axis). */
|
package/src/components/index.ts
CHANGED
|
@@ -39,6 +39,8 @@ export { OrbitCamera } from "./orbit-camera.tsx"
|
|
|
39
39
|
export type { OrbitCameraProps } from "./orbit-camera.tsx"
|
|
40
40
|
export { FirstPersonCamera } from "./first-person-camera.tsx"
|
|
41
41
|
export type { FirstPersonCameraProps } from "./first-person-camera.tsx"
|
|
42
|
+
export { Shot, Shots } from "./shots.tsx"
|
|
43
|
+
export type { ShotProps, ShotsProps } from "./shots.tsx"
|
|
42
44
|
export { HemisphereLight } from "./hemisphere-light.tsx"
|
|
43
45
|
export type { HemisphereLightProps } from "./hemisphere-light.tsx"
|
|
44
46
|
export { DirectionalLight } from "./directional-light.tsx"
|
|
@@ -6,13 +6,16 @@ import type { TransformProps, PointerEventProps } from "./node-props.ts"
|
|
|
6
6
|
import { syncMesh } from "./mesh.tsx"
|
|
7
7
|
import type { PopulatedMeshProps } from "./mesh.tsx"
|
|
8
8
|
import { add, destroy, setMorphWeights } from "../node.ts"
|
|
9
|
-
import type { MorphWeights } from "../node.ts"
|
|
9
|
+
import type { MorphWeights, SceneNode } from "../node.ts"
|
|
10
10
|
import { addInstance, createInstancedMesh, disposeInstances, setCastShadow, setGeometry, setInstanceStyle } from "../mesh.ts"
|
|
11
11
|
import type { InstancedMesh as InstancedMeshNode, InstanceNode } from "../mesh.ts"
|
|
12
12
|
import type { Geometry } from "../geometry.ts"
|
|
13
13
|
import type { Material } from "../material.ts"
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// The mesh's own node is never a hit target (its instances are the
|
|
16
|
+
// leaves that pick, its bounds cull only), so like a Group it bubbles
|
|
17
|
+
// and omits the hover pair.
|
|
18
|
+
export type InstancedMeshProps = Omit<PopulatedMeshProps, "onPointerEnter" | "onPointerLeave"> & {
|
|
16
19
|
geometry: Geometry
|
|
17
20
|
/** An instanced material: a stock one with `instanced` (or
|
|
18
21
|
* `instanceColors`, for a per-instance tint), or a class declaring
|
|
@@ -29,6 +32,14 @@ export type InstancedMeshProps = PopulatedMeshProps & {
|
|
|
29
32
|
bounds?: ArrayLike<number>
|
|
30
33
|
/** Debug label for the record buffers. */
|
|
31
34
|
label?: string
|
|
35
|
+
/** The node the instance records are relative to (default the mesh;
|
|
36
|
+
* fixed at creation): an ANCESTOR the mesh sits under at identity, so
|
|
37
|
+
* `<Instance mesh={...}>` children may live under any node of that
|
|
38
|
+
* ancestor's subtree instead of inside this element - a population
|
|
39
|
+
* whose copies ride a hierarchy the mesh is not the root of. Take the
|
|
40
|
+
* ancestor from its `ref`; the chain between it and the mesh must stay
|
|
41
|
+
* identity (see createInstancedMesh's `anchor`). */
|
|
42
|
+
anchor?: SceneNode
|
|
32
43
|
/** Draw into the scene's shadow map (setCastShadow as a prop); default
|
|
33
44
|
* false. The stock instanced materials cast; a custom class needs a
|
|
34
45
|
* `shadowVertex`. */
|
|
@@ -52,7 +63,7 @@ export let InstancedMeshContext = createContext<InstancedMeshNode | null>(null)
|
|
|
52
63
|
export let InstancedMesh: ParentComponent<InstancedMeshProps> = props => {
|
|
53
64
|
let ctx = useContext(SceneContext)
|
|
54
65
|
let mesh = untrack(() =>
|
|
55
|
-
createInstancedMesh(props.geometry, props.material, { capacity: props.capacity, bounds: props.bounds, label: props.label }),
|
|
66
|
+
createInstancedMesh(props.geometry, props.material, { capacity: props.capacity, bounds: props.bounds, anchor: props.anchor, label: props.label }),
|
|
56
67
|
)
|
|
57
68
|
add(ctx.parent, mesh)
|
|
58
69
|
createEffect(
|
|
@@ -89,6 +100,12 @@ export type InstanceProps = TransformProps & PointerEventProps & {
|
|
|
89
100
|
* material: by name (keys merge) or every weight in target order; a
|
|
90
101
|
* `weights` entry in `transition` animates each change. */
|
|
91
102
|
morphWeights?: MorphWeights
|
|
103
|
+
/** The population this instance belongs to when it is NOT nested inside
|
|
104
|
+
* its `<InstancedMesh>` (fixed at creation): a mesh created with an
|
|
105
|
+
* `anchor`, whose instances may sit under any node of the anchor's
|
|
106
|
+
* subtree - take it from the mesh's `ref`. Absent, the enclosing
|
|
107
|
+
* `<InstancedMesh>` is the population. */
|
|
108
|
+
mesh?: InstancedMeshNode
|
|
92
109
|
ref?: (instance: InstanceNode) => void
|
|
93
110
|
}
|
|
94
111
|
|
|
@@ -97,12 +114,14 @@ export type InstanceProps = TransformProps & PointerEventProps & {
|
|
|
97
114
|
* component): a node placed under the nearest `<Group>` or `<Instance>`
|
|
98
115
|
* inside the mesh, with the transform, transition and pointer props of
|
|
99
116
|
* a `<Group>` plus `style`. Children (a `<Mesh>` headlight under a car
|
|
100
|
-
* instance) mount under it. Throws outside an `<InstancedMesh
|
|
117
|
+
* instance) mount under it. Throws outside an `<InstancedMesh>` unless
|
|
118
|
+
* `mesh` names one (an anchored population's instance placed elsewhere
|
|
119
|
+
* in the anchor's subtree).
|
|
101
120
|
*/
|
|
102
121
|
export let Instance: ParentComponent<InstanceProps> = props => {
|
|
103
122
|
let ctx = useContext(SceneContext)
|
|
104
|
-
let mesh = useContext(InstancedMeshContext)
|
|
105
|
-
if (mesh === null) throw new Error("<Instance> must be inside an <InstancedMesh
|
|
123
|
+
let mesh = untrack(() => props.mesh) ?? useContext(InstancedMeshContext)
|
|
124
|
+
if (mesh === null) throw new Error("<Instance> must be inside an <InstancedMesh>, or name its population with `mesh`")
|
|
106
125
|
let instance = addInstance(mesh, undefined, ctx.parent)
|
|
107
126
|
syncNode(instance, props)
|
|
108
127
|
createEffect(
|
package/src/components/lod.tsx
CHANGED
|
@@ -59,7 +59,9 @@ export let Lod: ParentComponent<LodProps> = props => {
|
|
|
59
59
|
return <LodContext value={registry}>{provide(ctx, node, props)}</LodContext>
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
// Bubbling pointer props only, as InstancedMesh: the group's node is not
|
|
63
|
+
// a hit target.
|
|
64
|
+
export type InstancedLodProps = Omit<PopulatedMeshProps, "onPointerEnter" | "onPointerLeave"> & {
|
|
63
65
|
/** The levels nearest first, each what the population draws at it and
|
|
64
66
|
* the per-instance projected size below which it hands over (see
|
|
65
67
|
* createInstancedLod); fixed at creation. */
|
|
@@ -1,21 +1,50 @@
|
|
|
1
1
|
import { createEffect, merge, onFrame, untrack, useContext } from "@solidrt/core"
|
|
2
2
|
import type { InputMap, VoidComponent } from "@solidrt/core"
|
|
3
|
+
import type { Vec2 } from "@solidrt/core/input"
|
|
3
4
|
import { SceneContext } from "./context.tsx"
|
|
4
5
|
import { createOrbitCamera } from "../orbit.ts"
|
|
5
6
|
import type { OrbitAxes, OrbitCamera as OrbitCameraHandle, OrbitCameraOptions } from "../orbit.ts"
|
|
7
|
+
import type { Vec3 } from "../math.ts"
|
|
8
|
+
import type { Mesh } from "../mesh.ts"
|
|
6
9
|
|
|
7
10
|
// Cap on the frame loop's per-frame dt in seconds, so the first tick after
|
|
8
11
|
// a suspended stretch (a resumed app, a reload) cannot leap the pose.
|
|
9
12
|
const MAX_ORBIT_DT = 0.1
|
|
13
|
+
// Default occlusion radius, world units: the sphere the eye keeps clear
|
|
14
|
+
// of an obstacle (Babylon's collisionRadius is 0.5; a near plane's worth).
|
|
15
|
+
const OCCLUSION_RADIUS = 0.2
|
|
10
16
|
|
|
11
17
|
export type OrbitCameraProps = OrbitCameraOptions & {
|
|
18
|
+
/** How the zoom and `focus` map a screen point to the world (the
|
|
19
|
+
* built-in `zoomAnchor`; a `zoomAnchor` prop overrides it): "pick"
|
|
20
|
+
* (the default) picks the scene under the point and falls back to the
|
|
21
|
+
* plane at the target's depth when nothing is hit, "plane" uses that
|
|
22
|
+
* plane only (cheaper; a scene whose geometry is not what the user
|
|
23
|
+
* zooms at), false zooms toward the target. */
|
|
24
|
+
anchor?: "pick" | "plane" | false
|
|
25
|
+
/** Re-seat the pivot at what the view centre looks at when a drag
|
|
26
|
+
* begins (the built-in `rotateAnchor`, a pick; a `rotateAnchor` prop
|
|
27
|
+
* overrides it): Blender's auto-depth. Off by default - a drag orbits
|
|
28
|
+
* the target as it is, and a double tap (`focus`) re-seats it
|
|
29
|
+
* explicitly - because a re-seat changes `distance` on every press,
|
|
30
|
+
* which a viewer reading the pose does not expect. */
|
|
31
|
+
repivot?: boolean
|
|
32
|
+
/** The occlusion constraint (the built-in `occluder`): keep the eye
|
|
33
|
+
* `radius` clear of whatever the scene raycast finds between the
|
|
34
|
+
* target and the eye - a wall behind a third-person camera. Hits
|
|
35
|
+
* within `minDistance` of the target are ignored (default: the
|
|
36
|
+
* radius), so the followed character's own mesh, which the ray starts
|
|
37
|
+
* inside, does not pull the camera in (Cinemachine's rule); `layers`
|
|
38
|
+
* and `meshes` filter the query further (scene.raycast's options). */
|
|
39
|
+
occlusion?: { radius?: number; minDistance?: number; layers?: number; meshes?: Mesh[] }
|
|
12
40
|
/** The input map driving the control (InputMap.drive): its `rotate`,
|
|
13
|
-
* `zoom` and `
|
|
14
|
-
* map reconnects. Without one the control moves only through
|
|
41
|
+
* `zoom`, `pan` and `focus` actions, or the ones `actions` names. Live:
|
|
42
|
+
* a new map reconnects. Without one the control moves only through
|
|
43
|
+
* `ref`. */
|
|
15
44
|
input?: InputMap<any>
|
|
16
45
|
/** Action names per axis when the map's differ (null skips an axis). */
|
|
17
46
|
actions?: Partial<Record<keyof OrbitAxes, string | null>>
|
|
18
|
-
/** The control's handle (pose()/set()/glideTo()/fit()/eye()/
|
|
47
|
+
/** The control's handle (pose()/set()/glideTo()/fit()/follow()/eye()/
|
|
19
48
|
* orbiting()/active(), the verbs, `axes` - also the debug command
|
|
20
49
|
* shape). */
|
|
21
50
|
ref?: (orbit: OrbitCameraHandle) => void
|
|
@@ -30,19 +59,75 @@ export type OrbitCameraProps = OrbitCameraOptions & {
|
|
|
30
59
|
* (target, azimuth, elevation, distance) are initial values - change the
|
|
31
60
|
* pose at runtime through `ref`'s set() or the verbs; every other prop is
|
|
32
61
|
* live, forwarded to the control as a getter and read where it applies,
|
|
33
|
-
* so a clamp, a rate, `damping
|
|
34
|
-
* and a clamp change (`clampPose` included) re-clamps
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
62
|
+
* so a clamp, a rate, `damping`, the follow's zones or an anchor callback
|
|
63
|
+
* follows its prop, and a clamp change (`clampPose` included) re-clamps
|
|
64
|
+
* the pose at once. The projection-dependent hooks are built here from
|
|
65
|
+
* the owner's pick, unproject and raycast (`anchor`, `repivot`,
|
|
66
|
+
* `occlusion`), since the component sits where the projection is. The frame loop runs only
|
|
67
|
+
* while `active()` (auto-orbit on, a glide, follow, shake or occlusion
|
|
68
|
+
* return in flight, or an axis rate driving); a camera moved by drags
|
|
69
|
+
* alone leaves the app demand-driven idle.
|
|
38
70
|
*/
|
|
39
71
|
export let OrbitCamera: VoidComponent<OrbitCameraProps> = props => {
|
|
40
72
|
let ctx = useContext(SceneContext)
|
|
73
|
+
let viewport = ctx.viewport
|
|
74
|
+
let mode = () => props.anchor ?? "pick"
|
|
75
|
+
// The world point under a focal (a fraction of the owner's size): the
|
|
76
|
+
// nearest pick, else the point on the plane at the target's depth.
|
|
77
|
+
let pointAt = (focal: Vec2, view: { eye: Vec3; target: Vec3 }): Vec3 | null => {
|
|
78
|
+
let m = mode()
|
|
79
|
+
if (m === false) return null
|
|
80
|
+
let size = viewport.size()
|
|
81
|
+
let x = focal[0] * size.width
|
|
82
|
+
let y = focal[1] * size.height
|
|
83
|
+
if (m === "pick") {
|
|
84
|
+
let hit = viewport.pick(x, y)[0]
|
|
85
|
+
if (hit) return hit.point
|
|
86
|
+
}
|
|
87
|
+
let depth = Math.hypot(view.target[0] - view.eye[0], view.target[1] - view.eye[1], view.target[2] - view.eye[2])
|
|
88
|
+
return viewport.unproject(x, y, depth)
|
|
89
|
+
}
|
|
90
|
+
let zoomAnchor = (focal: Vec2, view: { eye: Vec3; target: Vec3 }): Vec3 | null => pointAt(focal, view)
|
|
91
|
+
// The pivot re-seat: what the view centre looks at, a pick.
|
|
92
|
+
let rotateAnchor = (): Vec3 | null => {
|
|
93
|
+
if (!props.repivot) return null
|
|
94
|
+
let size = viewport.size()
|
|
95
|
+
let hit = viewport.pick(size.width / 2, size.height / 2)[0]
|
|
96
|
+
return hit ? hit.point : null
|
|
97
|
+
}
|
|
98
|
+
let occluder = (target: Vec3, eye: Vec3): number | null => {
|
|
99
|
+
let o = props.occlusion
|
|
100
|
+
if (!o) return null
|
|
101
|
+
let dx = eye[0] - target[0]
|
|
102
|
+
let dy = eye[1] - target[1]
|
|
103
|
+
let dz = eye[2] - target[2]
|
|
104
|
+
let len = Math.hypot(dx, dy, dz)
|
|
105
|
+
if (!(len > 0)) return null
|
|
106
|
+
let radius = o.radius ?? OCCLUSION_RADIUS
|
|
107
|
+
let near = o.minDistance ?? radius
|
|
108
|
+
for (let hit of viewport.raycast(target, [dx / len, dy / len, dz / len], { layers: o.layers, meshes: o.meshes })) {
|
|
109
|
+
if (hit.distance < near) continue
|
|
110
|
+
if (hit.distance >= len) return null
|
|
111
|
+
return Math.max(hit.distance - radius, 0)
|
|
112
|
+
}
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
41
115
|
// The control keeps this object and reads each option where it applies,
|
|
42
116
|
// so the props go through as getters (merge), never a spread: a spread
|
|
43
|
-
// would snapshot every prop once and a change could only remount.
|
|
44
|
-
|
|
45
|
-
let
|
|
117
|
+
// would snapshot every prop once and a change could only remount. A
|
|
118
|
+
// hook prop overrides the built-in one.
|
|
119
|
+
let options: OrbitCameraOptions = merge(props, {
|
|
120
|
+
get zoomAnchor() {
|
|
121
|
+
return props.zoomAnchor ?? zoomAnchor
|
|
122
|
+
},
|
|
123
|
+
get rotateAnchor() {
|
|
124
|
+
return props.rotateAnchor ?? rotateAnchor
|
|
125
|
+
},
|
|
126
|
+
get occluder() {
|
|
127
|
+
return props.occluder ?? occluder
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
let orbit = untrack(() => createOrbitCamera(viewport, options))
|
|
46
131
|
createEffect(
|
|
47
132
|
() => props.input,
|
|
48
133
|
input => (input ? input.drive(orbit.axes, untrack(() => props.actions)) : undefined),
|
|
@@ -60,16 +145,16 @@ export let OrbitCamera: VoidComponent<OrbitCameraProps> = props => {
|
|
|
60
145
|
})
|
|
61
146
|
},
|
|
62
147
|
)
|
|
63
|
-
// A clamp prop change re-clamps the pose at once
|
|
64
|
-
// clamps and
|
|
65
|
-
// keeps running) - the update() call a Three
|
|
66
|
-
// after setting minDistance, done by the prop.
|
|
67
|
-
// already clamped and pushed the initial pose.
|
|
68
|
-
// reads the
|
|
69
|
-
// apply wants the values of that moment (the
|
|
70
|
-
// tracks them).
|
|
148
|
+
// A clamp or lane prop change re-clamps the pose and pushes at once
|
|
149
|
+
// (set({}) applies the clamps and the lanes, and writes no pose field,
|
|
150
|
+
// so a glide in flight keeps running) - the update() call a Three
|
|
151
|
+
// OrbitControls app makes after setting minDistance, done by the prop.
|
|
152
|
+
// Deferred: the creation already clamped and pushed the initial pose.
|
|
153
|
+
// Untracked: the control reads the options through the getters while
|
|
154
|
+
// it applies them, and the apply wants the values of that moment (the
|
|
155
|
+
// compute above is what tracks them).
|
|
71
156
|
createEffect(
|
|
72
|
-
() => [options.minDistance, options.maxDistance, options.minElevation, options.maxElevation, options.clampPose],
|
|
157
|
+
() => [options.minDistance, options.maxDistance, options.minElevation, options.maxElevation, options.clampPose, options.offset, options.occluder],
|
|
73
158
|
() => untrack(() => orbit.set({})),
|
|
74
159
|
{ defer: true },
|
|
75
160
|
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createContext, createEffect, onCleanup, onFrame, untrack, useContext } from "@solidrt/core"
|
|
2
|
+
import type { Element, ParentComponent } from "@solidrt/core"
|
|
3
|
+
import { SceneContext } from "./context.tsx"
|
|
4
|
+
import { createShots } from "../shots.ts"
|
|
5
|
+
import type { ShotsHandle } from "../shots.ts"
|
|
6
|
+
|
|
7
|
+
// Cap on the blend loop's per-frame dt in seconds, so the first tick
|
|
8
|
+
// after a suspended stretch cannot leap a blend to its end.
|
|
9
|
+
const MAX_BLEND_DT = 0.1
|
|
10
|
+
|
|
11
|
+
let ShotsContext = createContext<ShotsHandle>()
|
|
12
|
+
|
|
13
|
+
export type ShotsProps = {
|
|
14
|
+
/** The default blend time in seconds when the live shot changes
|
|
15
|
+
* (0.5); 0 cuts. */
|
|
16
|
+
blend?: number
|
|
17
|
+
/** The blender's handle (activate/deactivate/live/camera). */
|
|
18
|
+
ref?: (shots: ShotsHandle) => void
|
|
19
|
+
children?: Element
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ShotProps = {
|
|
23
|
+
name: string
|
|
24
|
+
/** The enabled shot with the highest priority is live (default 0;
|
|
25
|
+
* the most recently enabled wins a tie). Read at mount. */
|
|
26
|
+
priority?: number
|
|
27
|
+
/** Whether the shot takes part (default true); live: false withdraws
|
|
28
|
+
* it and the next by priority goes live, blended. */
|
|
29
|
+
active?: boolean
|
|
30
|
+
/** This shot's own blend time when it goes live or withdraws
|
|
31
|
+
* (default: the container's). */
|
|
32
|
+
blend?: number
|
|
33
|
+
children?: Element
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* createShots as a Scene (or View3d) child: owns the enclosing camera
|
|
38
|
+
* and blends between its `<Shot>` children (Cinemachine's virtual
|
|
39
|
+
* cameras), each of which provides a context whose `viewport` is the
|
|
40
|
+
* shot's recording target - so an `<OrbitCamera>` or
|
|
41
|
+
* `<FirstPersonCamera>` inside a `<Shot>` drives that shot, not the
|
|
42
|
+
* scene. The blend runs on frames only while one is in flight. A shot's
|
|
43
|
+
* picks and raycasts (`anchor`, `occlusion`) go through the owner's
|
|
44
|
+
* actual camera, which is the shot's own only while it is live and at
|
|
45
|
+
* rest.
|
|
46
|
+
*/
|
|
47
|
+
export let Shots: ParentComponent<ShotsProps> = props => {
|
|
48
|
+
let ctx = useContext(SceneContext)
|
|
49
|
+
let shots = untrack(() => createShots(ctx.viewport, { blend: props.blend }))
|
|
50
|
+
createEffect(
|
|
51
|
+
() => shots.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_BLEND_DT)
|
|
58
|
+
last = now
|
|
59
|
+
shots.update(dt)
|
|
60
|
+
})
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
untrack(() => props.ref)?.(shots)
|
|
64
|
+
return <ShotsContext value={shots}>{props.children}</ShotsContext>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export let Shot: ParentComponent<ShotProps> = props => {
|
|
68
|
+
let shots = useContext(ShotsContext)
|
|
69
|
+
let ctx = useContext(SceneContext)
|
|
70
|
+
let owner = ctx.viewport
|
|
71
|
+
let name = untrack(() => props.name)
|
|
72
|
+
let target = untrack(() => shots.shot(name, { priority: props.priority }))
|
|
73
|
+
// The shot's context: its own camera, the owner's projection.
|
|
74
|
+
let viewport = {
|
|
75
|
+
setCamera: target.setCamera,
|
|
76
|
+
camera: target.camera,
|
|
77
|
+
size: () => owner.size(),
|
|
78
|
+
pick: (x: number, y: number) => owner.pick(x, y),
|
|
79
|
+
unproject: (x: number, y: number, w: number, out?: [number, number, number]) => owner.unproject(x, y, w, out),
|
|
80
|
+
screenRay: (x: number, y: number) => owner.screenRay(x, y),
|
|
81
|
+
raycast: (origin: [number, number, number], direction: [number, number, number], opts?: Parameters<typeof owner.raycast>[2]) => owner.raycast(origin, direction, opts),
|
|
82
|
+
}
|
|
83
|
+
createEffect(
|
|
84
|
+
() => props.active ?? true,
|
|
85
|
+
on => {
|
|
86
|
+
let blend = untrack(() => props.blend)
|
|
87
|
+
if (on) shots.activate(name, { blend })
|
|
88
|
+
else shots.deactivate(name, { blend })
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
onCleanup(() => shots.remove(name, { blend: untrack(() => props.blend) }))
|
|
92
|
+
return <SceneContext value={{ scene: ctx.scene, parent: ctx.parent, viewport, pointer: ctx.pointer }}>{props.children}</SceneContext>
|
|
93
|
+
}
|