@solidrt/3d 0.0.60 → 0.0.62

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.
@@ -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 and the shadow distance
15
- // (`{ count, distance }`; the range the cascades split, the camera's far
16
- // by default - pulling it in sharpens every cascade) and `fly` parks the
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; the ring
7
- // claims the wheel to spin itself; a tap on empty space un-pops
8
- // everything (the scene's own onTap with `mesh` null, the deselect
9
- // idiom). The Group hears the cone's downs through bubbling (the crate's
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).
@@ -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
- // The spinning cube is the reference solid; the sprites' `scale` is their
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.60",
3
+ "version": "0.0.62",
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.8",
22
- "@solidrt/core": "0.0.60"
22
+ "@solidjs/signals": "2.0.0-rc.9",
23
+ "@solidrt/core": "0.0.62"
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() and size()). */
9
- export type CameraTarget = { setCamera(update: CameraUpdate): void; camera(): CameraState; size(): { width: number; height: number } }
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 `rise` actions, or the ones `actions` names. Live: a new
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). */
@@ -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
- export type InstancedMeshProps = PopulatedMeshProps & {
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(
@@ -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
- export type InstancedLodProps = PopulatedMeshProps & {
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 `pan` actions, or the ones `actions` names. Live: a new
14
- * map reconnects. Without one the control moves only through `ref`. */
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` or an anchor callback follows its prop,
34
- * and a clamp change (`clampPose` included) re-clamps the pose at once.
35
- * The frame loop runs only while `active()` (auto-orbit on, a glide or
36
- * damped wheel notch in flight, or an axis rate driving); a camera moved
37
- * by drags alone leaves the app demand-driven idle.
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
- let options: OrbitCameraOptions = merge(props, {})
45
- let orbit = untrack(() => createOrbitCamera(ctx.viewport, options))
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 (set({}) applies the
64
- // clamps and pushes, and writes no pose field, so a glide in flight
65
- // keeps running) - the update() call a Three OrbitControls app makes
66
- // after setting minDistance, done by the prop. Deferred: the creation
67
- // already clamped and pushed the initial pose. Untracked: the control
68
- // reads the clamps through the getters while it applies them, and the
69
- // apply wants the values of that moment (the compute above is what
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
+ }