@solidrt/3d 0.0.58 → 0.0.60
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 +734 -264
- package/README.md +4 -2
- package/examples/README.md +50 -4
- package/examples/bloom.tsx +113 -0
- package/examples/cascades.tsx +4 -4
- package/examples/collision.tsx +6 -6
- package/examples/exits.tsx +96 -0
- package/examples/first-person.tsx +5 -5
- package/examples/fleet.tsx +4 -4
- package/examples/fog.tsx +5 -5
- package/examples/instanced.tsx +70 -50
- package/examples/lamps.tsx +4 -4
- package/examples/lod.tsx +125 -0
- package/examples/materials.tsx +9 -9
- package/examples/morph.tsx +174 -0
- package/examples/normals.tsx +118 -0
- package/examples/{lit.tsx → phong.tsx} +9 -9
- package/examples/polyhedra.tsx +61 -0
- package/examples/scene-atlas.tsx +70 -0
- package/examples/scene-views.tsx +5 -5
- package/examples/shadows.tsx +9 -9
- package/examples/sky-lit.tsx +3 -3
- package/examples/skybox.tsx +5 -5
- package/examples/standard.tsx +2 -2
- package/examples/streams.tsx +122 -0
- package/examples/sweep-paths.tsx +5 -5
- package/examples/wireframe.tsx +24 -2
- package/package.json +3 -3
- package/src/clip.ts +18 -5
- package/src/color.ts +11 -2
- package/src/components/context.tsx +11 -2
- package/src/components/directional-light.tsx +2 -2
- package/src/components/first-person-camera.tsx +4 -4
- package/src/components/group.tsx +9 -5
- package/src/components/hemisphere-light.tsx +2 -2
- package/src/components/index.ts +4 -1
- package/src/components/instanced-mesh.tsx +28 -10
- package/src/components/lod.tsx +102 -0
- package/src/components/mesh.tsx +24 -6
- package/src/components/node-props.ts +35 -5
- package/src/components/orbit-camera.tsx +15 -12
- package/src/components/point-light.tsx +2 -2
- package/src/components/record-mesh.tsx +12 -7
- package/src/components/scene.tsx +55 -2
- package/src/components/spot-light.tsx +2 -2
- package/src/components/sprite.tsx +2 -2
- package/src/components/view3d.tsx +18 -1
- package/src/environment.ts +9 -8
- package/src/first-person.ts +125 -38
- package/src/geometry-gpu.ts +172 -39
- package/src/geometry.ts +1573 -239
- package/src/glsl.ts +398 -113
- package/src/gltf.ts +363 -141
- package/src/index.ts +31 -21
- package/src/light.ts +7 -8
- package/src/lod.ts +148 -0
- package/src/material.ts +316 -202
- package/src/mesh.ts +490 -256
- package/src/mixer.ts +27 -14
- package/src/model-file.ts +54 -15
- package/src/model.ts +86 -21
- package/src/motion.ts +17 -0
- package/src/node.ts +421 -22
- package/src/orbit.ts +378 -116
- package/src/profile.ts +4 -25
- package/src/resolve.ts +181 -0
- package/src/scene-shadows.ts +94 -93
- package/src/scene.ts +605 -185
- package/src/sweep.ts +27 -8
- package/tools/model.ts +3 -1
package/src/components/scene.tsx
CHANGED
|
@@ -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,
|
|
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(() =>
|
|
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,
|
|
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(() =>
|
|
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 ? (
|
package/src/environment.ts
CHANGED
|
@@ -262,14 +262,15 @@ export type Prefilter = {
|
|
|
262
262
|
dispose(): void
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
/** The format of
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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
|
|
272
|
-
export function
|
|
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:
|
|
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 } })
|
package/src/first-person.ts
CHANGED
|
@@ -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
|
|
25
|
-
// frame-loop gate, true while any rate reads non-zero (a
|
|
26
|
-
// deflected stick), so a still scene
|
|
27
|
-
// returns whether the pose changed since
|
|
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.
|
|
89
|
-
*
|
|
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():
|
|
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
|
-
/**
|
|
111
|
-
*
|
|
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
|
|
114
|
-
* returns whether the pose changed since the previous
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
184
|
-
|
|
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: () =>
|
|
274
|
+
eye: () => copy(position),
|
|
212
275
|
forward,
|
|
213
|
-
pose: () => ({ position:
|
|
214
|
-
active
|
|
276
|
+
pose: () => ({ position: copy(position), yaw, pitch }),
|
|
277
|
+
active,
|
|
215
278
|
axes,
|
|
216
279
|
set(pose) {
|
|
217
|
-
|
|
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
|
}
|