@solidrt/3d 0.0.57 → 0.0.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/AGENTS.md +1479 -678
  2. package/README.md +4 -2
  3. package/examples/README.md +56 -4
  4. package/examples/bloom.tsx +113 -0
  5. package/examples/cascades.tsx +4 -4
  6. package/examples/collision.tsx +6 -6
  7. package/examples/exits.tsx +96 -0
  8. package/examples/first-person.tsx +5 -5
  9. package/examples/fleet.tsx +4 -4
  10. package/examples/fog.tsx +5 -5
  11. package/examples/instanced.tsx +70 -50
  12. package/examples/lamps.tsx +4 -4
  13. package/examples/lod.tsx +125 -0
  14. package/examples/materials.tsx +9 -9
  15. package/examples/morph.tsx +174 -0
  16. package/examples/normals.tsx +118 -0
  17. package/examples/{lit.tsx → phong.tsx} +9 -9
  18. package/examples/polyhedra.tsx +61 -0
  19. package/examples/scene-atlas.tsx +70 -0
  20. package/examples/scene-views.tsx +5 -5
  21. package/examples/shadows.tsx +9 -9
  22. package/examples/sky-lit.tsx +3 -3
  23. package/examples/skybox.tsx +5 -5
  24. package/examples/standard.tsx +2 -2
  25. package/examples/streams.tsx +122 -0
  26. package/examples/sweep-paths.tsx +5 -5
  27. package/examples/wireframe.tsx +129 -0
  28. package/package.json +3 -3
  29. package/src/clip.ts +18 -5
  30. package/src/color.ts +11 -2
  31. package/src/components/context.tsx +11 -2
  32. package/src/components/directional-light.tsx +2 -2
  33. package/src/components/first-person-camera.tsx +4 -4
  34. package/src/components/group.tsx +9 -5
  35. package/src/components/hemisphere-light.tsx +2 -2
  36. package/src/components/index.ts +4 -1
  37. package/src/components/instanced-mesh.tsx +28 -10
  38. package/src/components/lod.tsx +102 -0
  39. package/src/components/mesh.tsx +24 -6
  40. package/src/components/node-props.ts +35 -5
  41. package/src/components/orbit-camera.tsx +15 -12
  42. package/src/components/point-light.tsx +2 -2
  43. package/src/components/record-mesh.tsx +12 -7
  44. package/src/components/scene.tsx +55 -2
  45. package/src/components/spot-light.tsx +2 -2
  46. package/src/components/sprite.tsx +2 -2
  47. package/src/components/view3d.tsx +18 -1
  48. package/src/environment.ts +9 -8
  49. package/src/first-person.ts +125 -38
  50. package/src/geometry-gpu.ts +187 -22
  51. package/src/geometry.ts +1718 -203
  52. package/src/glsl.ts +398 -113
  53. package/src/gltf.ts +457 -154
  54. package/src/index.ts +32 -21
  55. package/src/input.ts +11 -2
  56. package/src/light.ts +7 -8
  57. package/src/lod.ts +148 -0
  58. package/src/material.ts +322 -190
  59. package/src/mesh.ts +492 -259
  60. package/src/mixer.ts +27 -14
  61. package/src/model-file.ts +54 -15
  62. package/src/model.ts +151 -54
  63. package/src/motion.ts +17 -0
  64. package/src/node.ts +423 -23
  65. package/src/orbit.ts +378 -116
  66. package/src/profile.ts +4 -25
  67. package/src/resolve.ts +181 -0
  68. package/src/scene-shadows.ts +94 -93
  69. package/src/scene.ts +608 -185
  70. package/src/skeleton.ts +211 -0
  71. package/src/sweep.ts +27 -8
  72. package/tools/model.ts +3 -1
@@ -15,8 +15,9 @@ export type OrbitCameraProps = OrbitCameraOptions & {
15
15
  input?: InputMap<any>
16
16
  /** Action names per axis when the map's differ (null skips an axis). */
17
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). */
18
+ /** The control's handle (pose()/set()/glideTo()/fit()/eye()/
19
+ * orbiting()/active(), the verbs, `axes` - also the debug command
20
+ * shape). */
20
21
  ref?: (orbit: OrbitCameraHandle) => void
21
22
  }
22
23
 
@@ -29,9 +30,10 @@ export type OrbitCameraProps = OrbitCameraOptions & {
29
30
  * (target, azimuth, elevation, distance) are initial values - change the
30
31
  * pose at runtime through `ref`'s set() or the verbs; every other prop is
31
32
  * 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
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
35
37
  * by drags alone leaves the app demand-driven idle.
36
38
  */
37
39
  export let OrbitCamera: VoidComponent<OrbitCameraProps> = props => {
@@ -59,14 +61,15 @@ export let OrbitCamera: VoidComponent<OrbitCameraProps> = props => {
59
61
  },
60
62
  )
61
63
  // 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).
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).
68
71
  createEffect(
69
- () => [options.minDistance, options.maxDistance, options.minElevation, options.maxElevation],
72
+ () => [options.minDistance, options.maxDistance, options.minElevation, options.maxElevation, options.clampPose],
70
73
  () => untrack(() => orbit.set({})),
71
74
  { defer: true },
72
75
  )
@@ -3,7 +3,7 @@ import type { VoidComponent } from "@solidrt/core"
3
3
  import { SceneContext } from "./context.tsx"
4
4
  import { syncNode } from "./node-props.ts"
5
5
  import type { TransformProps } from "./node-props.ts"
6
- import { add, remove } from "../node.ts"
6
+ import { add, destroy } from "../node.ts"
7
7
  import { createPointLight, setLight } from "../light.ts"
8
8
  import type { PointLight as PointLightNode, SpotShadowOptions } from "../light.ts"
9
9
  import type { Vec3 } from "../math.ts"
@@ -47,6 +47,6 @@ export let PointLight: VoidComponent<PointLightProps> = props => {
47
47
  ([color, intensity, distance, decay, castShadow, shadow]) => setLight(light, { color, intensity, distance, decay, castShadow, shadow }),
48
48
  )
49
49
  untrack(() => props.ref)?.(light)
50
- onCleanup(() => remove(light))
50
+ onCleanup(() => destroy(light))
51
51
  return null
52
52
  }
@@ -3,7 +3,7 @@ import type { VoidComponent } from "@solidrt/core"
3
3
  import { SceneContext } from "./context.tsx"
4
4
  import { syncMesh } from "./mesh.tsx"
5
5
  import type { PopulatedMeshProps } from "./mesh.tsx"
6
- import { add } from "../node.ts"
6
+ import { add, destroy } from "../node.ts"
7
7
  import { createRecordMesh, disposeInstances, setCastShadow, setGeometry, setRecordCount, setRecords } from "../mesh.ts"
8
8
  import type { RecordMesh as RecordMeshNode } from "../mesh.ts"
9
9
  import type { Geometry } from "../geometry.ts"
@@ -11,12 +11,14 @@ import type { Material } from "../material.ts"
11
11
 
12
12
  export type RecordMeshProps = PopulatedMeshProps & {
13
13
  geometry: Geometry
14
- /** Must declare instanceAttributes (shaderMaterialClass), slot 0 only. */
14
+ /** Must declare instanceBuffers (shaderMaterialClass); the first is
15
+ * the records' layout. */
15
16
  material: Material
16
- /** Interleaved per-instance records (stride = the material's instance
17
- * attributes summed). Reactive; a later array larger than the buffer
18
- * grows it (capacity doubles into a replacement buffer). */
19
- records: Float32Array
17
+ /** The records, laid out in the material's first instance layout (a
18
+ * Float32Array over an all-float layout, bytes otherwise; see
19
+ * setRecords). Reactive; a later value larger than the buffer grows it
20
+ * (capacity doubles into a replacement buffer). */
21
+ records: ArrayBufferView
20
22
  /** How many records draw; default all of the latest `records`. */
21
23
  count?: number
22
24
  /** LOCAL bounds covering every instance ([minX..maxZ]), fixed at
@@ -63,6 +65,9 @@ export let RecordMesh: VoidComponent<RecordMeshProps> = props => {
63
65
  )
64
66
  syncMesh(mesh, props)
65
67
  untrack(() => props.ref)?.(mesh)
66
- onCleanup(() => disposeInstances(mesh))
68
+ onCleanup(() => {
69
+ destroy(mesh)
70
+ disposeInstances(mesh)
71
+ })
67
72
  return null
68
73
  }
@@ -1,9 +1,9 @@
1
- import { createEffect, displayScale, getBoundingBoxViewport, getLayoutBox, onCleanup, onLayout, untrack } from "@solidrt/core"
1
+ import { createEffect, createRenderEffect, createSignal, displayScale, getBoundingBoxViewport, getLayoutBox, onCleanup, onLayout, untrack } from "@solidrt/core"
2
2
  import type { Element, ParentComponent, PointerFeed, TextureId } from "@solidrt/core"
3
3
  import { SceneContext } from "./context.tsx"
4
4
  import { createScene } from "../scene.ts"
5
5
  import { feedPointer } from "../scene-pointer.ts"
6
- import type { EnvironmentOptions, FogOptions, Scene as SceneHandle, SkyboxOptions, ToneMapping } from "../scene.ts"
6
+ import type { BloomOptions, EnvironmentOptions, FogOptions, ResolveInput, Scene as SceneHandle, SkyboxOptions, ToneMapping } from "../scene.ts"
7
7
  import type { ScenePointerEvent, SceneTapEvent, SceneWheelEvent } from "../node.ts"
8
8
  import type { CameraUpdate } from "../camera.ts"
9
9
 
@@ -59,6 +59,11 @@ export type SceneProps = ScenePointerProps & {
59
59
  * `{ cube, intensity?, rotation? }`, typically the skybox's own cube
60
60
  * turned with it. Reactive; undefined removes it. */
61
61
  environment?: EnvironmentOptions
62
+ /** Stagger (ms) for the nodes straight under the scene root: their
63
+ * enters and exits beginning in one frame are spaced by `index *
64
+ * stagger`, the whole-scene form of `<Group transition={{ stagger }}>`
65
+ * (see SceneOptions.stagger). Set at creation. */
66
+ stagger?: number
62
67
  /** Scene-wide fog (scene.setFog): linear `{ color, near, far }` or exp2
63
68
  * `{ color, density }`, optionally thinning above `height` by
64
69
  * `heightFalloff`; every standard material fades toward `color` by
@@ -94,6 +99,16 @@ export type SceneProps = ScenePointerProps & {
94
99
  * feed, which listens at the scene's root.
95
100
  */
96
101
  output?: (texture: TextureId) => Element
102
+ /**
103
+ * The scene's resolve (SceneOptions.resolve): the source, `{ source,
104
+ * textures }`, or a function of the buffer id `scene.hdrTexture` so a
105
+ * radiance-reading chain (a bloom) is built over it right there and its
106
+ * result bound. Fixed at creation, like `output`.
107
+ */
108
+ resolve?: ResolveInput
109
+ /** The stock bloom (scene.setBloom as a prop): `{ threshold?,
110
+ * intensity?, radius? }`, reactive; undefined turns it off. */
111
+ bloom?: BloomOptions
97
112
  /**
98
113
  * Pointer events (default on): the built-in leaf carries scene.handlers,
99
114
  * so nodes, the scene's own handlers and a camera control's feed
@@ -138,6 +153,14 @@ export let Scene: ParentComponent<SceneProps> = props => {
138
153
  }
139
154
  return props.width === undefined
140
155
  })
156
+ // A budget error the scene reports from its sync (a microtask, outside
157
+ // the tree) is rethrown inside it, so the app's error boundary shows
158
+ // it: the handler parks the error and bumps a signal, the render effect
159
+ // below takes and throws it. Taking it first means the boundary's reset
160
+ // finds nothing to throw and returns to the scene, which kept rendering
161
+ // with what fits.
162
+ let pendingError: Error | null = null
163
+ let [errorTick, setErrorTick] = createSignal(0)
141
164
  // The initial props seed createScene, so `ref` (below) hands out a
142
165
  // configured scene and the first frame draws it whole; the effects
143
166
  // after this follow changes only (`defer`).
@@ -152,11 +175,28 @@ export let Scene: ParentComponent<SceneProps> = props => {
152
175
  fog: props.fog,
153
176
  toneMapping: props.toneMapping,
154
177
  exposure: props.exposure,
178
+ resolve: props.resolve,
179
+ bloom: props.bloom,
155
180
  layers: props.layers,
181
+ stagger: props.stagger,
182
+ onError: e => {
183
+ pendingError = e
184
+ setErrorTick(errorTick() + 1)
185
+ },
156
186
  })
157
187
  if (props.camera) s.setCamera(props.camera)
158
188
  return s
159
189
  })
190
+ createRenderEffect(
191
+ () => {
192
+ errorTick()
193
+ let e = pendingError
194
+ pendingError = null
195
+ if (e !== null) throw e
196
+ return null
197
+ },
198
+ () => {},
199
+ )
160
200
  createEffect(
161
201
  () => [props.width, props.height] as const,
162
202
  ([w, h]) => {
@@ -198,6 +238,11 @@ export let Scene: ParentComponent<SceneProps> = props => {
198
238
  e => scene.setExposure(e ?? 1),
199
239
  { defer: true },
200
240
  )
241
+ createEffect(
242
+ () => props.bloom,
243
+ b => scene.setBloom(b ?? null),
244
+ { defer: true },
245
+ )
201
246
  createEffect(
202
247
  () => props.layers,
203
248
  l => scene.setLayers(l ?? 1),
@@ -222,6 +267,14 @@ export let Scene: ParentComponent<SceneProps> = props => {
222
267
  )
223
268
  let output = untrack(() => props.output)
224
269
  let events = untrack(() => props.events) !== false
270
+ // A feed on a built-in leaf carrying no handlers listens at a root no
271
+ // event reaches: a contradiction, so it throws (the dev validation
272
+ // policy) instead of feeding nothing.
273
+ if (pointer !== null && !events && !output) {
274
+ throw new Error(
275
+ "Scene: a pointer feed with events={false} receives nothing - the feed listens at the scene's root, which only the leaf's handlers reach; keep events on, or compose the leaf with output and spread scene.handlers on it",
276
+ )
277
+ }
225
278
  let leafNode: { id: number } | undefined
226
279
  // The built-in leaf's laid-out box in ITS units - what pointer
227
280
  // localX/localY report in and every handlersFor scales against. Fixed
@@ -3,7 +3,7 @@ import type { VoidComponent } from "@solidrt/core"
3
3
  import { SceneContext } from "./context.tsx"
4
4
  import { syncNode } from "./node-props.ts"
5
5
  import type { TransformProps } from "./node-props.ts"
6
- import { add, remove } from "../node.ts"
6
+ import { add, destroy } from "../node.ts"
7
7
  import { createSpotLight, setLight } from "../light.ts"
8
8
  import type { SpotLight as SpotLightNode, SpotShadowOptions } from "../light.ts"
9
9
  import type { Vec3 } from "../math.ts"
@@ -60,6 +60,6 @@ export let SpotLight: VoidComponent<SpotLightProps> = props => {
60
60
  setLight(light, { direction, color, intensity, distance, angle, penumbra, decay, castShadow, shadow }),
61
61
  )
62
62
  untrack(() => props.ref)?.(light)
63
- onCleanup(() => remove(light))
63
+ onCleanup(() => destroy(light))
64
64
  return null
65
65
  }
@@ -3,7 +3,7 @@ import type { VoidComponent } from "@solidrt/core"
3
3
  import { SceneContext } from "./context.tsx"
4
4
  import type { TransformProps, PointerEventProps } from "./node-props.ts"
5
5
  import { syncMesh } from "./mesh.tsx"
6
- import { add, remove } from "../node.ts"
6
+ import { add, destroy } from "../node.ts"
7
7
  import { createSprite } from "../mesh.ts"
8
8
  import type { Mesh as MeshNode } from "../mesh.ts"
9
9
  import type { ShaderParams } from "@solidrt/core/gpu"
@@ -33,6 +33,6 @@ export let Sprite: VoidComponent<SpriteProps> = props => {
33
33
  add(ctx.parent, mesh)
34
34
  syncMesh(mesh, props)
35
35
  untrack(() => props.ref)?.(mesh)
36
- onCleanup(() => remove(mesh))
36
+ onCleanup(() => destroy(mesh))
37
37
  return null
38
38
  }
@@ -6,7 +6,7 @@ import type { ViewHandle, ViewOptions } from "../scene.ts"
6
6
  import type { CameraUpdate } from "../camera.ts"
7
7
  import type { ScenePointerProps } from "./scene.tsx"
8
8
 
9
- export type View3dProps = Pick<ViewOptions, "clearColor" | "label" | "overrideMaterial" | "fog" | "depth" | "samples" | "filter" | "wrap" | "into"> &
9
+ export type View3dProps = Pick<ViewOptions, "clearColor" | "label" | "overrideMaterial" | "fog" | "depth" | "samples" | "filter" | "wrap" | "into" | "resolve" | "bloom"> &
10
10
  ScenePointerProps & {
11
11
  /** Target pixels, live: the view resizes, or its tile moves. Fixed
12
12
  * only, for now: a fill mode like Scene's is a later, additive step. */
@@ -83,6 +83,8 @@ export let View3d: ParentComponent<View3dProps> = props => {
83
83
  filter: props.filter,
84
84
  wrap: props.wrap,
85
85
  into: props.into,
86
+ resolve: props.resolve,
87
+ bloom: props.bloom,
86
88
  })
87
89
  if (props.camera) v.setCamera(props.camera)
88
90
  return v
@@ -107,6 +109,13 @@ export let View3d: ParentComponent<View3dProps> = props => {
107
109
  l => view.setLayers(l ?? 1),
108
110
  { defer: true },
109
111
  )
112
+ // A bloom prop that changes claims the view's own (undefined = off);
113
+ // one never given leaves the view following the scene's.
114
+ createEffect(
115
+ () => props.bloom,
116
+ b => view.setBloom(b ?? null),
117
+ { defer: true },
118
+ )
110
119
  untrack(() => props.ref)?.(view)
111
120
  onCleanup(() => view.dispose())
112
121
  // The pointer feed the app handed in (see Scene) listens at the view's
@@ -126,6 +135,14 @@ export let View3d: ParentComponent<View3dProps> = props => {
126
135
  )
127
136
  let output = untrack(() => props.output)
128
137
  let events = untrack(() => props.events) !== false
138
+ // A feed on a built-in leaf carrying no handlers listens at a root no
139
+ // event reaches: a contradiction, so it throws (the dev validation
140
+ // policy) instead of feeding nothing.
141
+ if (pointer !== null && !events && !output) {
142
+ throw new Error(
143
+ "View3d: a pointer feed with events={false} receives nothing - the feed listens at the view's root, which only the leaf's handlers reach; keep events on, or compose the leaf with output and spread view.handlers on it",
144
+ )
145
+ }
129
146
  return (
130
147
  <SceneContext value={{ scene: ctx.scene, parent: ctx.parent, viewport: view, pointer }}>
131
148
  {output ? (
@@ -262,14 +262,15 @@ export type Prefilter = {
262
262
  dispose(): void
263
263
  }
264
264
 
265
- /** The format of a rendered radiance cube (a probe's faces, its chain, a
266
- * baked sky): half float where the device renders it, so the range of a
267
- * sun or an emissive survives into reflections as in every engine's HDR
268
- * probe, else 8-bit and clamped - the picture degrades, the app runs. Not
269
- * a knob: the renderer decides for all probes (Godot), HDR by default
265
+ /** The format of every buffer the scene renders light into - a scene's
266
+ * or view's buffer, a probe's faces, its chain, a baked sky: half float
267
+ * where the device renders it, so the range of a sun or an emissive
268
+ * survives into the resolve and into reflections as in every engine's HDR
269
+ * path, else 8-bit and clamped - the picture degrades, the app runs. Not
270
+ * a knob: the renderer decides for every target (Godot), HDR by default
270
271
  * (Unity). */
271
- export type ProbeFormat = "rgba8" | "rgba16f"
272
- export function probeFormat(): ProbeFormat {
272
+ export type BufferFormat = "rgba8" | "rgba16f"
273
+ export function bufferFormat(): BufferFormat {
273
274
  return limits.halfFloatRenderable ? "rgba16f" : "rgba8"
274
275
  }
275
276
 
@@ -282,7 +283,7 @@ export function probeFormat(): ProbeFormat {
282
283
  * convolution, on the same roughness-to-level rule as the .srte chain
283
284
  * (levelRoughness), so `standard` reads both alike.
284
285
  */
285
- export function createPrefilter(size: number, source: TextureId, format: ProbeFormat, label: string): Prefilter {
286
+ export function createPrefilter(size: number, source: TextureId, format: BufferFormat, label: string): Prefilter {
286
287
  let pass: ReturnType<typeof facePipeline> | null = facePipeline(PREFILTER_FACE, label + "-prefilter")
287
288
  let cube = createCubeDrawTarget(size, null, { mipmap: true, format, autoFree: false, label })
288
289
  let entry = addDraw(cube, pass.pipeline, { uFace: 0, uRoughness: 0 }, { vertexCount: 3, textures: { uSource: source } })
@@ -19,15 +19,25 @@
19
19
  // forward vector onto the ground plane at fixed height; flying moves
20
20
  // along the view direction.
21
21
  //
22
+ // The pose moves on its own in one way only: toward a goal pose set by
23
+ // glideTo, eased by update(dt) (motion.ts, the orbit camera's motion).
24
+ // Any input drops the glide - a walker who touches the controls has the
25
+ // camera back - and a pose write through set() lands at once and drops
26
+ // it too. There is no damping here: this control's unbracketed delta is
27
+ // mouse motion under pointer lock, and easing THAT is the thing a
28
+ // first-person player will not forgive.
29
+ //
22
30
  // Pose is plain mutable state; a nudge or a verb pushes it at once (a
23
31
  // mouse move under lock needs no frame loop), and update(dt) integrates
24
- // the rates from the app's own onFrame. Only `active` is reactive: the
25
- // frame-loop gate, true while any rate reads non-zero (a held key, a
26
- // deflected stick), so a still scene renders nothing new. update()
27
- // returns whether the pose changed since the previous update.
32
+ // the rates and the glide from the app's own onFrame. Only `active` is
33
+ // reactive: the frame-loop gate, true while any rate reads non-zero (a
34
+ // held key, a deflected stick) or a glide is in flight, so a still scene
35
+ // renders nothing new. update() returns whether the pose changed since
36
+ // the previous update.
28
37
  //
29
38
  // Collision is deliberately absent: a camera control cannot know the
30
- // level. Clamp or reject positions through `clampPosition`.
39
+ // level. Clamp or reject positions through `clampPosition`, which a
40
+ // glide consults every frame as a walk does every step.
31
41
  //
32
42
  // Options are read where they apply, not copied out: `fly`, `moveSpeed`,
33
43
  // `lookSpeed`, the pitch clamps and `clampPosition` are re-read from the
@@ -37,11 +47,12 @@
37
47
  // control, not two mounts. Only the initial pose is copied at creation;
38
48
  // later pose changes go through set() and the verbs.
39
49
 
40
- import { untrack } from "@solidjs/signals"
50
+ import { createMemo, createSignal, untrack } from "@solidjs/signals"
41
51
  import { createAxes } from "@solidrt/core/input"
42
52
  import type { Axes, Vec2 } from "@solidrt/core/input"
43
53
  import type { CameraUpdate } from "./camera.ts"
44
54
  import type { Vec3 } from "./math.ts"
55
+ import { easeStep, GLIDE_EASE, GLIDE_EPSILON } from "./motion.ts"
45
56
 
46
57
  // One element height of drag sweeps this many turns of look (half a turn:
47
58
  // a drag across the screen turns the walker around).
@@ -57,18 +68,17 @@ const PITCH_LIMIT = Math.PI / 2 - 0.01
57
68
  /** What a first-person camera drives: a Scene, or one of its Views. */
58
69
  export type FirstPersonTarget = { setCamera(update: CameraUpdate): void }
59
70
 
71
+ /** A full pose: what pose() returns. */
72
+ export type FirstPersonPoseState = { position: Vec3; yaw: number; pitch: number }
73
+
74
+ /** A partial pose: what set() and glideTo() take. */
75
+ export type FirstPersonPose = Partial<FirstPersonPoseState>
76
+
60
77
  /** The pose fields (position, yaw, pitch) are initial values, copied at
61
78
  * creation and changed through set() afterwards. Every other field is
62
79
  * live: read from this object where it applies, so a change takes effect
63
80
  * on the next input or update. */
64
- export type FirstPersonCameraOptions = {
65
- /** Initial eye position (default [0, 1.6, 0]: standing height at the
66
- * origin). */
67
- position?: Vec3
68
- /** Initial look, radians. Yaw 0 faces -z (the camera default), positive
69
- * turns left; pitch 0 is level, positive looks up. */
70
- yaw?: number
71
- pitch?: number
81
+ export type FirstPersonCameraOptions = FirstPersonPose & {
72
82
  /** Pitch clamps, radians; the defaults stop just short of the poles. */
73
83
  minPitch?: number
74
84
  maxPitch?: number
@@ -85,17 +95,11 @@ export type FirstPersonCameraOptions = {
85
95
  /** Constrain where a move may put the eye: called with the eye the
86
96
  * move asks for and the eye it starts from (both fresh arrays), returns
87
97
  * the position to use - a level's bounds, a floor height, a collision
88
- * controller's `moveAndSlide` over the difference. Look does not
89
- * consult it. */
98
+ * controller's `moveAndSlide` over the difference. A glide consults it
99
+ * every frame. Look does not. */
90
100
  clampPosition?: (next: Vec3, current: Vec3) => Vec3
91
101
  }
92
102
 
93
- export type FirstPersonPose = {
94
- position?: Vec3
95
- yaw?: number
96
- pitch?: number
97
- }
98
-
99
103
  export type FirstPersonAxes = { look: "vec2"; move: "vec2"; rise: "axis" }
100
104
 
101
105
  export type FirstPersonCamera = {
@@ -104,15 +108,22 @@ export type FirstPersonCamera = {
104
108
  /** Unit look direction for the current pose (a fresh array per call). */
105
109
  forward(): Vec3
106
110
  /** Pose snapshot - the shape debug commands return and set() takes. */
107
- pose(): { position: Vec3; yaw: number; pitch: number }
108
- /** Merge a pose in (clamps apply) and push it. */
111
+ pose(): FirstPersonPoseState
112
+ /** Merge a pose in (clamps apply) and push it: a snap, dropping a glide
113
+ * in flight when it writes a pose field. */
109
114
  set(pose: FirstPersonPose): void
110
- /** Whether any axis rate reads non-zero - what a frame loop should run
111
- * on. Reactive. */
115
+ /** Ease to a pose (the fields given, the rest as they are; the pitch
116
+ * clamp applies, `clampPosition` every frame) inside update(dt).
117
+ * Dropped by any input and by a set() that writes a pose field; a new
118
+ * glideTo retargets. Yaw eases to the number given, not the shortest
119
+ * turn: pass the turn you mean. */
120
+ glideTo(pose: FirstPersonPose): void
121
+ /** Whether update(dt) has work: any axis rate non-zero, or a glide in
122
+ * flight. The frame-loop gate, reactive. */
112
123
  active(): boolean
113
- /** Integrate the axis rates over dt seconds and push any pose change;
114
- * returns whether the pose changed since the previous update (nudges
115
- * and verbs included). */
124
+ /** Integrate the axis rates and the glide over dt seconds and push any
125
+ * pose change; returns whether the pose changed since the previous
126
+ * update (nudges and verbs included). */
116
127
  update(dt: number): boolean
117
128
  /** The input abstraction: `look` (vec2, element heights of drag / turns
118
129
  * per second), `move` (vec2 [right, forward], forward = -y; world units
@@ -127,12 +138,25 @@ export type FirstPersonCamera = {
127
138
  moveBy(right: number, forward: number, up?: number): void
128
139
  }
129
140
 
141
+ // The goal pose a glide eases toward (see the header).
142
+ type Motion = FirstPersonPoseState & {
143
+ /** The fraction of the initial gap still open. */
144
+ remaining: number
145
+ }
146
+
130
147
  let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
148
+ let copy = (v: Vec3): Vec3 => [v[0], v[1], v[2]]
131
149
 
132
150
  function finite(what: string, v: number): void {
133
151
  if (!Number.isFinite(v)) throw new Error(`createFirstPersonCamera: ${what} must be a finite number, got ${v}`)
134
152
  }
135
153
 
154
+ function checkPose(verb: string, pose: FirstPersonPose): void {
155
+ if (pose.yaw !== undefined) finite(`${verb} yaw`, pose.yaw)
156
+ if (pose.pitch !== undefined) finite(`${verb} pitch`, pose.pitch)
157
+ if (pose.position) for (let i = 0; i < 3; i++) finite(`${verb} position[${i}]`, pose.position[i]!)
158
+ }
159
+
136
160
  /**
137
161
  * Create a first-person camera driving `camera`'s position and target,
138
162
  * where `camera` is a Scene or one of its Views (fov, near, and far stay
@@ -143,16 +167,31 @@ function finite(what: string, v: number): void {
143
167
  */
144
168
  export function createFirstPersonCamera(camera: FirstPersonTarget, options: FirstPersonCameraOptions = {}): FirstPersonCamera {
145
169
  if (!camera || typeof camera.setCamera !== "function") throw new Error("createFirstPersonCamera: the target needs setCamera() (a Scene or a View)")
146
- let position: Vec3 = options.position ? [options.position[0], options.position[1], options.position[2]] : [0, 1.6, 0]
170
+ checkPose("initial", options)
171
+ let position: Vec3 = options.position ? copy(options.position) : [0, 1.6, 0]
147
172
  let yaw = options.yaw ?? 0
148
173
  let pitch = options.pitch ?? 0
149
174
  // Everything below the pose is read from `options` where it applies.
150
175
  let lookSpeed = () => options.lookSpeed ?? 1
151
176
  let moveSpeed = () => options.moveSpeed ?? MOVE_SPEED
177
+ let clampedPitch = (v: number) => clampNum(v, options.minPitch ?? -PITCH_LIMIT, options.maxPitch ?? PITCH_LIMIT)
152
178
  let clampPitch = () => {
153
- pitch = clampNum(pitch, options.minPitch ?? -PITCH_LIMIT, options.maxPitch ?? PITCH_LIMIT)
179
+ pitch = clampedPitch(pitch)
154
180
  }
181
+ let clampedPosition = (next: Vec3): Vec3 => (options.clampPosition ? options.clampPosition(next, copy(position)) : next)
155
182
  let changed = false
183
+ // The glide in flight, and its half of active(): a signal every entry
184
+ // that starts or drops it refreshes (ownedWrite: entries run from
185
+ // component bodies and handlers alike).
186
+ let motion: Motion | null = null
187
+ let [motionActive, setMotionActive] = createSignal(false, { ownedWrite: true })
188
+ let notify = () => {
189
+ let now = motion !== null
190
+ if (now !== untrack(motionActive)) setMotionActive(now)
191
+ }
192
+ let interrupt = () => {
193
+ motion = null
194
+ }
156
195
 
157
196
  let forward = (): Vec3 => {
158
197
  let cp = Math.cos(pitch)
@@ -161,7 +200,7 @@ export function createFirstPersonCamera(camera: FirstPersonTarget, options: Firs
161
200
  let push = () => {
162
201
  changed = true
163
202
  let f = forward()
164
- camera.setCamera({ position: [position[0], position[1], position[2]], target: [position[0] + f[0], position[1] + f[1], position[2] + f[2]] })
203
+ camera.setCamera({ position: copy(position), target: [position[0] + f[0], position[1] + f[1], position[2] + f[2]] })
165
204
  }
166
205
  let look = (dYaw: number, dPitch: number) => {
167
206
  yaw += dYaw
@@ -180,14 +219,36 @@ export function createFirstPersonCamera(camera: FirstPersonTarget, options: Firs
180
219
  let rx = Math.cos(yaw)
181
220
  let rz = -Math.sin(yaw)
182
221
  let rise = fly ? up : 0
183
- let next: Vec3 = [position[0] + fx * ahead + rx * right, position[1] + fy * ahead + rise, position[2] + fz * ahead + rz * right]
184
- position = options.clampPosition ? options.clampPosition(next, [position[0], position[1], position[2]]) : next
222
+ position = clampedPosition([position[0] + fx * ahead + rx * right, position[1] + fy * ahead + rise, position[2] + fz * ahead + rz * right])
223
+ }
224
+ // One frame of the glide: the ease closes the gap by the same fraction
225
+ // on every component, the last fraction snapping to the goal.
226
+ let glideStep = (dt: number) => {
227
+ let m = motion!
228
+ let k = easeStep(GLIDE_EASE, dt)
229
+ m.remaining *= 1 - k
230
+ if (m.remaining < GLIDE_EPSILON) {
231
+ yaw = m.yaw
232
+ pitch = m.pitch
233
+ position = clampedPosition(copy(m.position))
234
+ motion = null
235
+ } else {
236
+ yaw += (m.yaw - yaw) * k
237
+ pitch += (m.pitch - pitch) * k
238
+ position = clampedPosition([position[0] + (m.position[0] - position[0]) * k, position[1] + (m.position[1] - position[1]) * k, position[2] + (m.position[2] - position[2]) * k])
239
+ }
240
+ clampPitch()
185
241
  }
186
242
 
187
243
  let axes = createAxes<FirstPersonAxes>(
188
244
  { look: "vec2", move: "vec2", rise: "axis" },
189
245
  {
246
+ onBegin: () => {
247
+ interrupt()
248
+ notify()
249
+ },
190
250
  onNudge: (name, delta) => {
251
+ interrupt()
191
252
  if (name === "look") {
192
253
  let d = delta as Vec2
193
254
  let rel = DRAG_TURNS * 2 * Math.PI * lookSpeed()
@@ -199,26 +260,41 @@ export function createFirstPersonCamera(camera: FirstPersonTarget, options: Firs
199
260
  step(0, 0, delta as number)
200
261
  }
201
262
  push()
263
+ notify()
202
264
  },
203
265
  },
204
266
  )
267
+ let active = createMemo(() => motionActive() || axes.active())
205
268
 
206
269
  clampPitch()
207
270
  push()
208
271
  changed = false
209
272
 
210
273
  return {
211
- eye: () => [position[0], position[1], position[2]],
274
+ eye: () => copy(position),
212
275
  forward,
213
- pose: () => ({ position: [position[0], position[1], position[2]], yaw, pitch }),
214
- active: axes.active,
276
+ pose: () => ({ position: copy(position), yaw, pitch }),
277
+ active,
215
278
  axes,
216
279
  set(pose) {
217
- if (pose.position) position = [pose.position[0], pose.position[1], pose.position[2]]
280
+ checkPose("set", pose)
281
+ if (pose.position || pose.yaw !== undefined || pose.pitch !== undefined) interrupt()
282
+ if (pose.position) position = copy(pose.position)
218
283
  if (pose.yaw !== undefined) yaw = pose.yaw
219
284
  if (pose.pitch !== undefined) pitch = pose.pitch
220
285
  clampPitch()
221
286
  push()
287
+ notify()
288
+ },
289
+ glideTo(pose) {
290
+ checkPose("glideTo", pose)
291
+ motion = {
292
+ position: pose.position ? copy(pose.position) : copy(position),
293
+ yaw: pose.yaw ?? yaw,
294
+ pitch: clampedPitch(pose.pitch ?? pitch),
295
+ remaining: 1,
296
+ }
297
+ notify()
222
298
  },
223
299
  update(dt) {
224
300
  finite("update dt", dt)
@@ -226,6 +302,7 @@ export function createFirstPersonCamera(camera: FirstPersonTarget, options: Firs
226
302
  let [lx, ly] = untrack(() => axes.rate("look"))
227
303
  if (lx !== 0 || ly !== 0) {
228
304
  let rate = LOOK_RATE * 2 * Math.PI * lookSpeed() * dt
305
+ interrupt()
229
306
  look(-lx * rate, -ly * rate)
230
307
  moved = true
231
308
  }
@@ -233,10 +310,16 @@ export function createFirstPersonCamera(camera: FirstPersonTarget, options: Firs
233
310
  let rise = untrack(() => axes.rate("rise"))
234
311
  if (mx !== 0 || my !== 0 || rise !== 0) {
235
312
  let speed = moveSpeed() * dt
313
+ interrupt()
236
314
  step(mx * speed, -my * speed, rise * speed)
237
315
  moved = true
238
316
  }
317
+ if (motion !== null && dt > 0) {
318
+ glideStep(dt)
319
+ moved = true
320
+ }
239
321
  if (moved) push()
322
+ notify()
240
323
  let result = changed
241
324
  changed = false
242
325
  return result
@@ -244,15 +327,19 @@ export function createFirstPersonCamera(camera: FirstPersonTarget, options: Firs
244
327
  lookBy(dYaw, dPitch) {
245
328
  finite("lookBy yaw", dYaw)
246
329
  finite("lookBy pitch", dPitch)
330
+ interrupt()
247
331
  look(dYaw, dPitch)
248
332
  push()
333
+ notify()
249
334
  },
250
335
  moveBy(right, ahead, up = 0) {
251
336
  finite("moveBy right", right)
252
337
  finite("moveBy forward", ahead)
253
338
  finite("moveBy up", up)
339
+ interrupt()
254
340
  step(right, ahead, up)
255
341
  push()
342
+ notify()
256
343
  },
257
344
  }
258
345
  }