@solidrt/3d 0.0.54 → 0.0.56
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 +1121 -177
- package/README.md +15 -7
- package/examples/README.md +86 -5
- package/examples/aim.tsx +2 -3
- package/examples/cascades.tsx +20 -5
- package/examples/collision.tsx +250 -0
- package/examples/first-person.tsx +119 -0
- package/examples/fleet.tsx +194 -0
- package/examples/fog.tsx +173 -0
- package/examples/instanced.tsx +72 -32
- package/examples/lamps.tsx +92 -0
- package/examples/lit.tsx +3 -5
- package/examples/materials.tsx +150 -0
- package/examples/model-load.tsx +95 -0
- package/examples/model.glb +0 -0
- package/examples/model.tsx +32 -6
- package/examples/pick.tsx +2 -4
- package/examples/probe.tsx +101 -0
- package/examples/scene-background.tsx +2 -4
- package/examples/scene-basic.tsx +2 -4
- package/examples/scene-views.tsx +32 -3
- package/examples/shadows.tsx +2 -4
- package/examples/sky-lit.tsx +86 -0
- package/examples/skybox.tsx +128 -0
- package/examples/sprites.tsx +4 -6
- package/examples/standard.tsx +132 -0
- package/examples/sweep-paths.tsx +2 -4
- package/package.json +3 -3
- package/src/camera.ts +148 -0
- package/src/clip.ts +96 -0
- package/src/collision.ts +199 -0
- package/src/color.ts +32 -0
- package/src/components.tsx +773 -82
- package/src/environment-bake.ts +492 -0
- package/src/environment.ts +325 -0
- package/src/first-person.ts +306 -0
- package/src/geometry.ts +6 -2
- package/src/glsl.ts +1390 -82
- package/src/gltf.ts +432 -112
- package/src/index.ts +57 -9
- package/src/light.ts +383 -0
- package/src/material.ts +682 -216
- package/src/math.ts +110 -0
- package/src/mesh.ts +728 -0
- package/src/mixer.ts +476 -0
- package/src/model-file.ts +80 -15
- package/src/model.ts +211 -34
- package/src/node.ts +506 -0
- package/src/orbit.ts +97 -48
- package/src/scene-pointer.ts +166 -0
- package/src/scene-shadows.ts +471 -0
- package/src/scene.ts +1304 -1532
- package/tools/environment.ts +69 -0
- package/tools/model.ts +4 -3
package/src/components.tsx
CHANGED
|
@@ -1,52 +1,126 @@
|
|
|
1
1
|
// The Solid face: PascalCase components over context, syncing the retained
|
|
2
|
-
// scene (scene.ts) - no new intrinsic elements, no
|
|
2
|
+
// scene (node/mesh/light/scene.ts) - no new intrinsic elements, no
|
|
3
|
+
// renderer changes. Props
|
|
3
4
|
// follow the Solid 2.0 model (reactive values, no destructuring); effects
|
|
4
5
|
// write into the retained nodes and the runtime's dirty flush renders.
|
|
5
6
|
// Anything moving at frame rate can bypass the declarative layer: grab the
|
|
6
7
|
// node with `ref` and call setTransform from onFrame - signals carry
|
|
7
8
|
// structure and slow state, per-frame motion goes straight to the scene.
|
|
8
9
|
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
10
|
+
import { merge } from "@solidjs/signals"
|
|
11
|
+
import { createContext, createEffect, createSignal, displayScale, getBoundingBoxViewport, getLayoutBox, onCleanup, onFrame, onLayout, setFocus, untrack, useContext } from "@solidrt/core"
|
|
12
|
+
import type { Element, KeyEvent, ParentComponent, PointerEvent, TextureId, VoidComponent, WheelEvent } from "@solidrt/core"
|
|
13
|
+
import { createOrbitCamera } from "./orbit.ts"
|
|
14
|
+
import type { OrbitCamera as OrbitCameraHandle, OrbitCameraOptions, OrbitPose } from "./orbit.ts"
|
|
15
|
+
import { createFirstPersonCamera } from "./first-person.ts"
|
|
16
|
+
import type { FirstPersonCamera as FirstPersonCameraHandle, FirstPersonCameraOptions, FirstPersonPose } from "./first-person.ts"
|
|
17
|
+
import { add, createGroup, remove, setTransform, setTransition, setVisible } from "./node.ts"
|
|
18
|
+
import type { SceneNode, ScenePointerEvent, TransitionEndEvent } from "./node.ts"
|
|
11
19
|
import {
|
|
12
|
-
|
|
13
|
-
createDirectionalLight,
|
|
14
|
-
createGroup,
|
|
15
|
-
createHemisphereLight,
|
|
20
|
+
addInstance,
|
|
16
21
|
createInstancedMesh,
|
|
17
22
|
createMesh,
|
|
18
|
-
|
|
23
|
+
createRecordMesh,
|
|
19
24
|
createSprite,
|
|
20
25
|
disposeInstances,
|
|
21
|
-
|
|
22
|
-
setGeometry,
|
|
23
|
-
setInstanceCount,
|
|
24
|
-
setInstances,
|
|
25
|
-
setLight,
|
|
26
|
+
removeInstance,
|
|
26
27
|
setCastShadow,
|
|
28
|
+
setCulling,
|
|
29
|
+
setGeometry,
|
|
30
|
+
setInstanceStyle,
|
|
31
|
+
setLayers,
|
|
27
32
|
setMaterial,
|
|
28
33
|
setMeshParams,
|
|
34
|
+
setRecordCount,
|
|
35
|
+
setRecords,
|
|
29
36
|
setRenderOrder,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
} from "./
|
|
37
|
+
} from "./mesh.ts"
|
|
38
|
+
import type { InstancedMesh as InstancedMeshNode, InstanceNode, Mesh as MeshNode, RecordMesh as RecordMeshNode } from "./mesh.ts"
|
|
39
|
+
import { createDirectionalLight, createHemisphereLight, createPointLight, createSpotLight, setLight } from "./light.ts"
|
|
40
|
+
import type {
|
|
41
|
+
DirectionalLight as DirectionalLightNode,
|
|
42
|
+
HemisphereLight as HemisphereLightNode,
|
|
43
|
+
PointLight as PointLightNode,
|
|
44
|
+
ShadowOptions,
|
|
45
|
+
SpotLight as SpotLightNode,
|
|
46
|
+
SpotShadowOptions,
|
|
47
|
+
} from "./light.ts"
|
|
48
|
+
import { createScene } from "./scene.ts"
|
|
49
|
+
import type { CameraUpdate } from "./camera.ts"
|
|
50
|
+
import type { EnvironmentOptions, FogOptions, Scene as SceneHandle, SkyboxOptions, ToneMapping } from "./scene.ts"
|
|
33
51
|
import type { ShaderParams } from "@solidrt/core/gpu"
|
|
34
|
-
import type {
|
|
52
|
+
import type { NodeTransition } from "flux:spatial"
|
|
35
53
|
import type { Geometry } from "./geometry.ts"
|
|
36
54
|
import type { Material } from "./material.ts"
|
|
37
55
|
import type { Quat, Vec3 } from "./math.ts"
|
|
38
56
|
|
|
39
|
-
|
|
57
|
+
/** A camera control's input feed - the handlers shape the camera controls
|
|
58
|
+
* expose, every field optional. A listener with key handlers makes the
|
|
59
|
+
* leaf focusable and focused on pointer down, since keys route through
|
|
60
|
+
* the focused node; onBlur tells it the held keys will get no key-up. */
|
|
61
|
+
export type SceneInputListener = {
|
|
62
|
+
onPointerDown?(event: PointerEvent): void
|
|
63
|
+
onPointerMove?(event: PointerEvent): void
|
|
64
|
+
onPointerUp?(event: PointerEvent): void
|
|
65
|
+
onWheel?(event: WheelEvent): void
|
|
66
|
+
onKeyDown?(event: KeyEvent): void
|
|
67
|
+
onKeyUp?(event: KeyEvent): void
|
|
68
|
+
onBlur?(): void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The channel between the element showing the scene and camera-control
|
|
73
|
+
* components (`<OrbitCamera>`, `<FirstPersonCamera>`): the leaf feeds
|
|
74
|
+
* pointer, wheel and key events in, controls listen. The built-in
|
|
75
|
+
* `<Scene>` leaf is wired automatically; a custom `output` leaf spreads
|
|
76
|
+
* `{...useScene().input.handlersFor(layout)}` beside its scene.handlersFor
|
|
77
|
+
* spread, with the same `layout`, and declares itself `focusable` when a
|
|
78
|
+
* key-driven control is in the scene (the spread's onPointerDown focuses
|
|
79
|
+
* it; keys reach only the focused node).
|
|
80
|
+
*/
|
|
81
|
+
export type SceneInput = {
|
|
82
|
+
/** Spreadable pointer + wheel + key handlers for the leaf. `layout`
|
|
83
|
+
* reports the leaf's laid-out size - what viewport-relative controls
|
|
84
|
+
* scale to - and is read per event, so a reactive layout just works.
|
|
85
|
+
* `node` is the leaf itself, focused on pointer down while a key
|
|
86
|
+
* listener is registered. */
|
|
87
|
+
handlersFor(layout: () => { width: number; height: number }, node?: () => { id: number } | undefined): {
|
|
88
|
+
onPointerDown(event: PointerEvent): void
|
|
89
|
+
onPointerMove(event: PointerEvent): void
|
|
90
|
+
onPointerUp(event: PointerEvent): void
|
|
91
|
+
onWheel(event: WheelEvent): void
|
|
92
|
+
onKeyDown(event: KeyEvent): void
|
|
93
|
+
onKeyUp(event: KeyEvent): void
|
|
94
|
+
onBlur(): void
|
|
95
|
+
}
|
|
96
|
+
/** Subscribe a control; returns the unsubscribe. */
|
|
97
|
+
add(listener: SceneInputListener): () => void
|
|
98
|
+
/** The showing leaf's laid-out size, null before one registered a
|
|
99
|
+
* layout (handlersFor supplies it). */
|
|
100
|
+
layout(): { width: number; height: number } | null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type SceneCtx = { scene: SceneHandle; parent: SceneNode; input: SceneInput }
|
|
40
104
|
let SceneContext = createContext<SceneCtx>()
|
|
41
105
|
|
|
42
106
|
/**
|
|
43
|
-
* The enclosing scene
|
|
44
|
-
* a component subtree (throws outside a `<Scene>`).
|
|
107
|
+
* The enclosing scene, parent node and input channel - the imperative
|
|
108
|
+
* escape hatch inside a component subtree (throws outside a `<Scene>`).
|
|
45
109
|
*/
|
|
46
110
|
export function useScene(): SceneCtx {
|
|
47
111
|
return useContext(SceneContext)
|
|
48
112
|
}
|
|
49
113
|
|
|
114
|
+
// The scene context for a node component's children. Solid's provider is
|
|
115
|
+
// a root and two memos over the children, paid even when there are none,
|
|
116
|
+
// so it is built only when the JSX has children: the compiler emits the
|
|
117
|
+
// `children` getter only then, so the test is structural, not a read
|
|
118
|
+
// (probes/3d-instance-mount-bench.tsx has the per-row cost).
|
|
119
|
+
function provide(ctx: SceneCtx, parent: SceneNode, props: { children?: Element }): Element | undefined {
|
|
120
|
+
if (!("children" in props)) return undefined
|
|
121
|
+
return <SceneContext value={{ scene: ctx.scene, parent, input: ctx.input }}>{props.children}</SceneContext>
|
|
122
|
+
}
|
|
123
|
+
|
|
50
124
|
export type TransformProps = {
|
|
51
125
|
position?: Vec3
|
|
52
126
|
/** Euler radians in XYZ order (x first), Three's `Euler` default. */
|
|
@@ -56,6 +130,11 @@ export type TransformProps = {
|
|
|
56
130
|
quaternion?: Quat
|
|
57
131
|
scale?: Vec3 | number
|
|
58
132
|
visible?: boolean
|
|
133
|
+
/** How transform-prop changes animate (see setTransition); the mount
|
|
134
|
+
* transform always snaps. */
|
|
135
|
+
transition?: NodeTransition | string | null
|
|
136
|
+
/** A declared transition settled on one component. */
|
|
137
|
+
onTransitionEnd?: (event: TransitionEndEvent) => void
|
|
59
138
|
}
|
|
60
139
|
|
|
61
140
|
/**
|
|
@@ -76,37 +155,100 @@ export type PointerEventProps = {
|
|
|
76
155
|
}
|
|
77
156
|
|
|
78
157
|
function syncNode(node: SceneNode, props: TransformProps & PointerEventProps): void {
|
|
158
|
+
// One effect for the transform and the handlers: re-assigning six
|
|
159
|
+
// handler fields on a transform write is free, an effect of its own is
|
|
160
|
+
// not (a node component's mount cost is mostly its effects and its
|
|
161
|
+
// context provider, see probes/3d-instance-mount-bench.tsx).
|
|
79
162
|
createEffect(
|
|
80
|
-
() =>
|
|
81
|
-
|
|
163
|
+
() =>
|
|
164
|
+
[
|
|
165
|
+
props.position,
|
|
166
|
+
props.rotation,
|
|
167
|
+
props.quaternion,
|
|
168
|
+
props.scale,
|
|
169
|
+
props.visible,
|
|
170
|
+
props.onPointerDown,
|
|
171
|
+
props.onPointerMove,
|
|
172
|
+
props.onPointerUp,
|
|
173
|
+
props.onPointerEnter,
|
|
174
|
+
props.onPointerLeave,
|
|
175
|
+
props.onTransitionEnd,
|
|
176
|
+
] as const,
|
|
177
|
+
([position, rotation, quaternion, scale, visible, down, move, up, enter, leave, end]) => {
|
|
82
178
|
setTransform(node, { position, rotation, quaternion, scale })
|
|
83
179
|
setVisible(node, visible !== false)
|
|
84
|
-
},
|
|
85
|
-
)
|
|
86
|
-
createEffect(
|
|
87
|
-
() => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onPointerEnter, props.onPointerLeave] as const,
|
|
88
|
-
([down, move, up, enter, leave]) => {
|
|
89
180
|
node.onPointerDown = down
|
|
90
181
|
node.onPointerMove = move
|
|
91
182
|
node.onPointerUp = up
|
|
92
183
|
node.onPointerEnter = enter
|
|
93
184
|
node.onPointerLeave = leave
|
|
185
|
+
node.onTransitionEnd = end
|
|
94
186
|
},
|
|
95
187
|
)
|
|
188
|
+
// After the transform effect, so the mount transform snaps before writes
|
|
189
|
+
// animate. Its own effect: merged above, every transform write would
|
|
190
|
+
// re-send the config to the core.
|
|
191
|
+
createEffect(
|
|
192
|
+
() => props.transition,
|
|
193
|
+
transition => setTransition(node, transition ?? null),
|
|
194
|
+
)
|
|
96
195
|
}
|
|
97
196
|
|
|
98
197
|
export type SceneProps = {
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Target pixels - give both, or neither. Omitted, the scene FILLS: the
|
|
200
|
+
* built-in leaf is laid out at 100% of its parent's box (give it a sized
|
|
201
|
+
* parent, as on the web) and the target tracks the leaf's on-screen size
|
|
202
|
+
* in device pixels - display scale, designSize fits and ancestor
|
|
203
|
+
* transforms included - so a bare `<Scene>` renders at native density on
|
|
204
|
+
* any display, and viewport-relative camera controls scale with the box.
|
|
205
|
+
* Fill or fixed is decided at mount. `output` needs explicit sizes (the
|
|
206
|
+
* target cannot follow a leaf it does not own); the leaf's own
|
|
207
|
+
* width/height are then layout, so render size and display size separate
|
|
208
|
+
* (supersampling).
|
|
209
|
+
*/
|
|
210
|
+
width?: number
|
|
211
|
+
height?: number
|
|
103
212
|
clearColor?: [number, number, number, number]
|
|
104
|
-
/**
|
|
105
|
-
* (scene.
|
|
106
|
-
*
|
|
107
|
-
*
|
|
213
|
+
/**
|
|
214
|
+
* Drive the scene camera declaratively (scene.setCamera as a prop): a
|
|
215
|
+
* partial CameraUpdate, absent keys keep their values - `ortho` included,
|
|
216
|
+
* which `<PerspectiveCamera>` by its name never sets. The prop and the
|
|
217
|
+
* `<PerspectiveCamera>` child write the same scene state, so use one
|
|
218
|
+
* form, not both (last write wins). The 2d layers' `camera` prop, one
|
|
219
|
+
* dimension up.
|
|
220
|
+
*/
|
|
221
|
+
camera?: CameraUpdate
|
|
222
|
+
/** The background drawn behind the meshes, inside the scene's own pass
|
|
223
|
+
* (scene.setBackground): fragment GLSL (vUV/iResolution/fragColor plus
|
|
224
|
+
* the vRay view ray, so a createShaderTexture backdrop ports verbatim
|
|
225
|
+
* and a directional sky is a few lines) or a skybox `{ cube, intensity?,
|
|
226
|
+
* rotation? }`. Reactive - swapping the source replaces the background,
|
|
227
|
+
* a skybox's knobs update in place; undefined removes it. Three's
|
|
108
228
|
* `scene.background = color` is `clearColor` here. */
|
|
109
|
-
background?: string
|
|
229
|
+
background?: string | SkyboxOptions
|
|
230
|
+
/** The cube map reflective `lit` materials mirror (scene.setEnvironment):
|
|
231
|
+
* `{ cube, intensity?, rotation? }`, typically the skybox's own cube
|
|
232
|
+
* turned with it. Reactive; undefined removes it. */
|
|
233
|
+
environment?: EnvironmentOptions
|
|
234
|
+
/** Scene-wide fog (scene.setFog): linear `{ color, near, far }` or exp2
|
|
235
|
+
* `{ color, density }`, optionally thinning above `height` by
|
|
236
|
+
* `heightFalloff`; every standard material fades toward `color` by
|
|
237
|
+
* distance from the camera. Reactive; undefined removes it. Match
|
|
238
|
+
* `color` to clearColor or the background, which is not fogged. */
|
|
239
|
+
fog?: FogOptions
|
|
240
|
+
/** Output tone mapping (scene.setToneMapping): "none" (default) or
|
|
241
|
+
* "aces". Reactive. */
|
|
242
|
+
toneMapping?: ToneMapping
|
|
243
|
+
/** Output exposure (scene.setExposure), default 1. Reactive. */
|
|
244
|
+
exposure?: number
|
|
245
|
+
/** The scene target's layer mask (scene.setLayers as a prop; default 1):
|
|
246
|
+
* the scene draws the meshes whose `layers` intersect it. Reactive. */
|
|
247
|
+
layers?: number
|
|
248
|
+
/** `"texture"` exposes the target's depth as `scene.depthTexture` (a
|
|
249
|
+
* depth-reading post effect's input). Fixed at creation; not with
|
|
250
|
+
* `samples`. */
|
|
251
|
+
depth?: true | "texture"
|
|
110
252
|
label?: string
|
|
111
253
|
/** Multisample count (1, 2, 4 or 8; default 1): anti-aliased mesh edges.
|
|
112
254
|
* Fixed at creation. */
|
|
@@ -137,34 +279,188 @@ export type SceneProps = {
|
|
|
137
279
|
* Children (Mesh/Group/PerspectiveCamera) render nothing themselves - they
|
|
138
280
|
* populate the retained scene through context.
|
|
139
281
|
*/
|
|
282
|
+
// Creation size of a fill-mode target: the first onLayout replaces it
|
|
283
|
+
// before the first paint, so it only has to be a valid texture size.
|
|
284
|
+
const FILL_INITIAL_SIZE = 1
|
|
285
|
+
|
|
140
286
|
export let Scene: ParentComponent<SceneProps> = props => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
)
|
|
287
|
+
// Fill vs fixed target is decided at mount, like `output`: both
|
|
288
|
+
// width and height (fixed pixels) or neither (fill).
|
|
289
|
+
let fill = untrack(() => {
|
|
290
|
+
if ((props.width === undefined) !== (props.height === undefined)) {
|
|
291
|
+
throw new Error("Scene: width and height come together - give both (fixed target) or neither (fill)")
|
|
292
|
+
}
|
|
293
|
+
if (props.width === undefined && props.output) {
|
|
294
|
+
throw new Error("Scene: fill needs the built-in leaf - with output, give width and height")
|
|
295
|
+
}
|
|
296
|
+
return props.width === undefined
|
|
297
|
+
})
|
|
298
|
+
// The initial props seed createScene, so `ref` (below) hands out a
|
|
299
|
+
// configured scene and the first frame draws it whole; the effects
|
|
300
|
+
// after this follow changes only (`defer`).
|
|
301
|
+
let scene = untrack(() => {
|
|
302
|
+
let s = createScene(props.width ?? FILL_INITIAL_SIZE, props.height ?? FILL_INITIAL_SIZE, {
|
|
303
|
+
clearColor: props.clearColor,
|
|
304
|
+
label: props.label,
|
|
305
|
+
samples: props.samples,
|
|
306
|
+
depth: props.depth,
|
|
307
|
+
background: props.background,
|
|
308
|
+
environment: props.environment,
|
|
309
|
+
fog: props.fog,
|
|
310
|
+
toneMapping: props.toneMapping,
|
|
311
|
+
exposure: props.exposure,
|
|
312
|
+
layers: props.layers,
|
|
313
|
+
})
|
|
314
|
+
if (props.camera) s.setCamera(props.camera)
|
|
315
|
+
return s
|
|
316
|
+
})
|
|
144
317
|
createEffect(
|
|
145
318
|
() => [props.width, props.height] as const,
|
|
146
|
-
([w, h]) =>
|
|
319
|
+
([w, h]) => {
|
|
320
|
+
if ((w === undefined) !== (h === undefined) || (w === undefined) !== fill) {
|
|
321
|
+
throw new Error("Scene: fill/fixed is mount-fixed - width/height cannot appear or disappear")
|
|
322
|
+
}
|
|
323
|
+
if (!fill) scene.setSize(w!, h!)
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
createEffect(
|
|
327
|
+
() => props.camera,
|
|
328
|
+
camera => {
|
|
329
|
+
if (camera) scene.setCamera(camera)
|
|
330
|
+
},
|
|
331
|
+
{ defer: true },
|
|
147
332
|
)
|
|
148
333
|
createEffect(
|
|
149
334
|
() => props.background,
|
|
150
335
|
b => scene.setBackground(b ?? null),
|
|
336
|
+
{ defer: true },
|
|
337
|
+
)
|
|
338
|
+
createEffect(
|
|
339
|
+
() => props.environment,
|
|
340
|
+
e => scene.setEnvironment(e ?? null),
|
|
341
|
+
{ defer: true },
|
|
342
|
+
)
|
|
343
|
+
createEffect(
|
|
344
|
+
() => props.fog,
|
|
345
|
+
f => scene.setFog(f ?? null),
|
|
346
|
+
{ defer: true },
|
|
347
|
+
)
|
|
348
|
+
createEffect(
|
|
349
|
+
() => props.toneMapping,
|
|
350
|
+
t => scene.setToneMapping(t ?? "none"),
|
|
351
|
+
{ defer: true },
|
|
352
|
+
)
|
|
353
|
+
createEffect(
|
|
354
|
+
() => props.exposure,
|
|
355
|
+
e => scene.setExposure(e ?? 1),
|
|
356
|
+
{ defer: true },
|
|
357
|
+
)
|
|
358
|
+
createEffect(
|
|
359
|
+
() => props.layers,
|
|
360
|
+
l => scene.setLayers(l ?? 1),
|
|
361
|
+
{ defer: true },
|
|
151
362
|
)
|
|
152
363
|
untrack(() => props.ref)?.(scene)
|
|
364
|
+
// Camera-control input (SceneInput): controls register through context,
|
|
365
|
+
// the leaf dispatches to them. `hasInput` gates the built-in leaf's
|
|
366
|
+
// handlers so a control-less scene with events={false} still costs no
|
|
367
|
+
// pointer routing; ownedWrite because controls add() from their
|
|
368
|
+
// component bodies.
|
|
369
|
+
let listeners = new Set<SceneInputListener>()
|
|
370
|
+
let [hasInput, setHasInput] = createSignal(false, { ownedWrite: true })
|
|
371
|
+
// Whether any control wants keys: the leaf turns focusable and takes
|
|
372
|
+
// focus on pointer down only then, so a pointer-only scene never steals
|
|
373
|
+
// focus from a text field on a click.
|
|
374
|
+
let [hasKeys, setHasKeys] = createSignal(false, { ownedWrite: true })
|
|
375
|
+
let anyKeys = () => [...listeners].some(l => l.onKeyDown || l.onKeyUp)
|
|
376
|
+
let leafLayout: (() => { width: number; height: number }) | null = null
|
|
377
|
+
let input: SceneInput = {
|
|
378
|
+
handlersFor(layout, node) {
|
|
379
|
+
leafLayout = layout
|
|
380
|
+
return {
|
|
381
|
+
onPointerDown: e => {
|
|
382
|
+
let n = node?.()
|
|
383
|
+
if (n && untrack(hasKeys)) setFocus(n.id)
|
|
384
|
+
listeners.forEach(l => l.onPointerDown?.(e))
|
|
385
|
+
},
|
|
386
|
+
onPointerMove: e => listeners.forEach(l => l.onPointerMove?.(e)),
|
|
387
|
+
onPointerUp: e => listeners.forEach(l => l.onPointerUp?.(e)),
|
|
388
|
+
onWheel: e => listeners.forEach(l => l.onWheel?.(e)),
|
|
389
|
+
onKeyDown: e => listeners.forEach(l => l.onKeyDown?.(e)),
|
|
390
|
+
onKeyUp: e => listeners.forEach(l => l.onKeyUp?.(e)),
|
|
391
|
+
onBlur: () => listeners.forEach(l => l.onBlur?.()),
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
add(listener) {
|
|
395
|
+
listeners.add(listener)
|
|
396
|
+
setHasInput(true)
|
|
397
|
+
setHasKeys(anyKeys())
|
|
398
|
+
return () => {
|
|
399
|
+
listeners.delete(listener)
|
|
400
|
+
setHasInput(listeners.size > 0)
|
|
401
|
+
setHasKeys(anyKeys())
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
layout: () => leafLayout?.() ?? null,
|
|
405
|
+
}
|
|
153
406
|
let output = untrack(() => props.output)
|
|
154
407
|
let events = untrack(() => props.events) !== false
|
|
408
|
+
let leafNode: { id: number } | undefined
|
|
409
|
+
// The built-in leaf's laid-out box in ITS units - what pointer
|
|
410
|
+
// localX/localY report in and every handlersFor scales against. Fixed
|
|
411
|
+
// mode lays the leaf out at the target size; fill mode reads the box
|
|
412
|
+
// back (getLayoutBox, the untransformed read, so a designSize fit or
|
|
413
|
+
// ancestor transform never skews events). Zero before the first layout:
|
|
414
|
+
// no event can arrive before one, and a degenerate layout passes
|
|
415
|
+
// coordinates through unscaled. A custom output leaf registers its own
|
|
416
|
+
// layout via handlersFor.
|
|
417
|
+
let builtinLayout = fill
|
|
418
|
+
? () => (leafNode && getLayoutBox(leafNode)) || { width: 0, height: 0 }
|
|
419
|
+
: () => ({ width: props.width!, height: props.height! })
|
|
420
|
+
if (fill) {
|
|
421
|
+
// Fill sizing: after every layout (and on display-scale changes, which
|
|
422
|
+
// lay nothing out) the target follows the leaf's on-screen box in
|
|
423
|
+
// device pixels - getBoundingBoxViewport composes designSize fits and
|
|
424
|
+
// ancestor transforms, so the scene renders at true density wherever
|
|
425
|
+
// it sits. onLayout runs before paint (no frame draws at a stale
|
|
426
|
+
// size); setSize no-ops when nothing changed. apply runs as an
|
|
427
|
+
// onLayout handler and as an effect apply, both untracked scopes, so
|
|
428
|
+
// its scale read is wrapped in an explicit untrack (the effect's
|
|
429
|
+
// compute is what tracks it).
|
|
430
|
+
let apply = () =>
|
|
431
|
+
untrack(() => {
|
|
432
|
+
if (!leafNode) return
|
|
433
|
+
let box = getBoundingBoxViewport(leafNode)
|
|
434
|
+
if (!box) return
|
|
435
|
+
let scale = displayScale()
|
|
436
|
+
scene.setSize(Math.max(1, Math.round(box.width * scale)), Math.max(1, Math.round(box.height * scale)))
|
|
437
|
+
})
|
|
438
|
+
onLayout(apply)
|
|
439
|
+
createEffect(() => displayScale(), apply)
|
|
440
|
+
}
|
|
441
|
+
// Mesh events on the built-in leaf: at target size the plain handlers,
|
|
442
|
+
// in fill mode scaled from the laid-out box.
|
|
443
|
+
let sceneHandlers = fill ? scene.handlersFor(builtinLayout) : scene.handlers
|
|
444
|
+
let leaf = output ? null : input.handlersFor(builtinLayout, () => leafNode)
|
|
155
445
|
return (
|
|
156
|
-
<SceneContext value={{ scene, parent: scene.root }}>
|
|
446
|
+
<SceneContext value={{ scene, parent: scene.root, input }}>
|
|
157
447
|
{output ? (
|
|
158
448
|
untrack(() => output(scene.texture))
|
|
159
449
|
) : (
|
|
160
450
|
<texture
|
|
451
|
+
ref={(n: { id: number }) => (leafNode = n)}
|
|
161
452
|
src={scene.texture}
|
|
162
|
-
width={props.width}
|
|
163
|
-
height={props.height}
|
|
164
|
-
onPointerDown={events ?
|
|
165
|
-
onPointerMove={events ?
|
|
166
|
-
onPointerUp={events ?
|
|
167
|
-
onPointerLeave={events ?
|
|
453
|
+
width={fill ? "100%" : props.width}
|
|
454
|
+
height={fill ? "100%" : props.height}
|
|
455
|
+
onPointerDown={events || hasInput() ? (e: PointerEvent) => { if (events) sceneHandlers.onPointerDown(e); leaf!.onPointerDown(e) } : undefined}
|
|
456
|
+
onPointerMove={events || hasInput() ? (e: PointerEvent) => { if (events) sceneHandlers.onPointerMove(e); leaf!.onPointerMove(e) } : undefined}
|
|
457
|
+
onPointerUp={events || hasInput() ? (e: PointerEvent) => { if (events) sceneHandlers.onPointerUp(e); leaf!.onPointerUp(e) } : undefined}
|
|
458
|
+
onPointerLeave={events ? sceneHandlers.onPointerLeave : undefined}
|
|
459
|
+
onWheel={hasInput() ? leaf!.onWheel : undefined}
|
|
460
|
+
focusable={hasKeys()}
|
|
461
|
+
onKeyDown={hasKeys() ? leaf!.onKeyDown : undefined}
|
|
462
|
+
onKeyUp={hasKeys() ? leaf!.onKeyUp : undefined}
|
|
463
|
+
onBlur={hasKeys() ? leaf!.onBlur : undefined}
|
|
168
464
|
/>
|
|
169
465
|
)}
|
|
170
466
|
{props.children}
|
|
@@ -180,7 +476,7 @@ export let Group: ParentComponent<TransformProps & PointerEventProps & { ref?: (
|
|
|
180
476
|
syncNode(node, props)
|
|
181
477
|
untrack(() => props.ref)?.(node)
|
|
182
478
|
onCleanup(() => remove(node))
|
|
183
|
-
return
|
|
479
|
+
return provide(ctx, node, props)
|
|
184
480
|
}
|
|
185
481
|
|
|
186
482
|
export type MeshProps = TransformProps & PointerEventProps & {
|
|
@@ -195,13 +491,24 @@ export type MeshProps = TransformProps & PointerEventProps & {
|
|
|
195
491
|
/** Explicit draw-order key (setRenderOrder as a prop); default 0. */
|
|
196
492
|
renderOrder?: number
|
|
197
493
|
/** Draw into the scene's shadow map (setCastShadow as a prop); default
|
|
198
|
-
* false. Needs a `castShadow`
|
|
494
|
+
* false. Needs a `castShadow` light to show. */
|
|
199
495
|
castShadow?: boolean
|
|
496
|
+
/** Layer membership bitmask (setLayers as a prop; default 1): a target
|
|
497
|
+
* draws the mesh when its mask intersects this. Not inherited from
|
|
498
|
+
* ancestor Groups. */
|
|
499
|
+
layers?: number
|
|
500
|
+
/** Frustum culling switch (setCulling as a prop; default true). */
|
|
501
|
+
frustumCulled?: boolean
|
|
502
|
+
/** World units the culled box grows by (setCulling as a prop; default 0). */
|
|
503
|
+
cullMargin?: number
|
|
200
504
|
ref?: (mesh: MeshNode) => void
|
|
201
505
|
}
|
|
202
506
|
|
|
203
|
-
// The mesh-side props
|
|
204
|
-
|
|
507
|
+
// The mesh-side props every mesh component shares (Sprite has no
|
|
508
|
+
// geometry, so that one stays with the components that take it); the
|
|
509
|
+
// ref and the cleanup are each component's own (a populated mesh frees
|
|
510
|
+
// its buffers, a plain one is removed).
|
|
511
|
+
function syncMesh(mesh: MeshNode, props: Omit<SpriteProps, "ref">): void {
|
|
205
512
|
createEffect(
|
|
206
513
|
() => props.material,
|
|
207
514
|
m => setMaterial(mesh, m),
|
|
@@ -217,9 +524,15 @@ function syncMesh(mesh: MeshNode, props: SpriteProps): void {
|
|
|
217
524
|
() => props.renderOrder,
|
|
218
525
|
o => setRenderOrder(mesh, o ?? 0),
|
|
219
526
|
)
|
|
527
|
+
createEffect(
|
|
528
|
+
() => props.layers,
|
|
529
|
+
l => setLayers(mesh, l ?? 1),
|
|
530
|
+
)
|
|
531
|
+
createEffect(
|
|
532
|
+
() => [props.frustumCulled, props.cullMargin] as const,
|
|
533
|
+
([culled, margin]) => setCulling(mesh, { frustumCulled: culled !== false, cullMargin: margin ?? 0 }),
|
|
534
|
+
)
|
|
220
535
|
syncNode(mesh, props)
|
|
221
|
-
untrack(() => props.ref)?.(mesh)
|
|
222
|
-
onCleanup(() => remove(mesh))
|
|
223
536
|
}
|
|
224
537
|
|
|
225
538
|
/** One draw entry: geometry drawn with a material at a transform. */
|
|
@@ -237,6 +550,8 @@ export let Mesh: VoidComponent<MeshProps> = props => {
|
|
|
237
550
|
c => setCastShadow(mesh, c === true),
|
|
238
551
|
)
|
|
239
552
|
syncMesh(mesh, props)
|
|
553
|
+
untrack(() => props.ref)?.(mesh)
|
|
554
|
+
onCleanup(() => remove(mesh))
|
|
240
555
|
return null
|
|
241
556
|
}
|
|
242
557
|
|
|
@@ -247,6 +562,12 @@ export type SpriteProps = TransformProps & PointerEventProps & {
|
|
|
247
562
|
params?: ShaderParams
|
|
248
563
|
/** Explicit draw-order key (setRenderOrder as a prop); default 0. */
|
|
249
564
|
renderOrder?: number
|
|
565
|
+
/** Layer membership bitmask (setLayers as a prop; default 1). */
|
|
566
|
+
layers?: number
|
|
567
|
+
/** Frustum culling switch (setCulling as a prop; default true). */
|
|
568
|
+
frustumCulled?: boolean
|
|
569
|
+
/** World units the culled box grows by (setCulling as a prop; default 0). */
|
|
570
|
+
cullMargin?: number
|
|
250
571
|
ref?: (mesh: MeshNode) => void
|
|
251
572
|
}
|
|
252
573
|
|
|
@@ -257,12 +578,111 @@ export let Sprite: VoidComponent<SpriteProps> = props => {
|
|
|
257
578
|
let mesh = untrack(() => createSprite(props.material))
|
|
258
579
|
add(ctx.parent, mesh)
|
|
259
580
|
syncMesh(mesh, props)
|
|
581
|
+
untrack(() => props.ref)?.(mesh)
|
|
582
|
+
onCleanup(() => remove(mesh))
|
|
260
583
|
return null
|
|
261
584
|
}
|
|
262
585
|
|
|
263
|
-
|
|
586
|
+
// The props both populated meshes share with Mesh: everything but the
|
|
587
|
+
// geometry/material pair (documented per component) and the ref.
|
|
588
|
+
type PopulatedMeshProps = Omit<MeshProps, "geometry" | "material" | "ref">
|
|
589
|
+
|
|
590
|
+
export type InstancedMeshProps = PopulatedMeshProps & {
|
|
264
591
|
geometry: Geometry
|
|
265
|
-
/**
|
|
592
|
+
/** An instanced material: a stock one with `instanced` (or
|
|
593
|
+
* `instanceColors`, for a per-instance tint), or a class declaring
|
|
594
|
+
* INSTANCE_MATRIX_ATTRIBUTES in slot 0 and any style layout in slot 1. */
|
|
595
|
+
material: Material
|
|
596
|
+
/** Instance slots reserved up front (default 64; fixed at creation).
|
|
597
|
+
* Past it the buffers double into replacements - amortized, but size it
|
|
598
|
+
* realistically to skip the copies. */
|
|
599
|
+
capacity?: number
|
|
600
|
+
/** LOCAL bounds covering the population ([minX..maxZ]), fixed at
|
|
601
|
+
* creation: the mesh node's own box for the frustum test and the
|
|
602
|
+
* transparent sort. Optional here - instances pick by themselves, and
|
|
603
|
+
* without it the mesh culls by the union of their boxes. */
|
|
604
|
+
bounds?: ArrayLike<number>
|
|
605
|
+
/** Debug label for the record buffers. */
|
|
606
|
+
label?: string
|
|
607
|
+
/** Draw into the scene's shadow map (setCastShadow as a prop); default
|
|
608
|
+
* false. The stock instanced materials cast; a custom class needs a
|
|
609
|
+
* `shadowVertex`. */
|
|
610
|
+
castShadow?: boolean
|
|
611
|
+
ref?: (mesh: InstancedMeshNode) => void
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
let InstancedMeshContext = createContext<InstancedMeshNode | null>(null)
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* One draw entry covering N instance NODES (createInstancedMesh as a
|
|
618
|
+
* component): `<Instance>` children populate it - each a scene node the
|
|
619
|
+
* core places, so their `transition` props spring with zero per-frame
|
|
620
|
+
* JS, and picking, pointer events, overlap and sweep name the instance
|
|
621
|
+
* struck. `<Group>` children between the mesh and its instances are
|
|
622
|
+
* squads (the records stay mesh-relative through them). The record
|
|
623
|
+
* buffers are component-owned and freed on unmount.
|
|
624
|
+
*/
|
|
625
|
+
export let InstancedMesh: ParentComponent<InstancedMeshProps> = props => {
|
|
626
|
+
let ctx = useContext(SceneContext)
|
|
627
|
+
let mesh = untrack(() =>
|
|
628
|
+
createInstancedMesh(props.geometry, props.material, { capacity: props.capacity, bounds: props.bounds, label: props.label }),
|
|
629
|
+
)
|
|
630
|
+
add(ctx.parent, mesh)
|
|
631
|
+
createEffect(
|
|
632
|
+
() => props.geometry,
|
|
633
|
+
g => setGeometry(mesh, g),
|
|
634
|
+
{ defer: true },
|
|
635
|
+
)
|
|
636
|
+
createEffect(
|
|
637
|
+
() => props.castShadow,
|
|
638
|
+
c => setCastShadow(mesh, c === true),
|
|
639
|
+
)
|
|
640
|
+
syncMesh(mesh, props)
|
|
641
|
+
untrack(() => props.ref)?.(mesh)
|
|
642
|
+
onCleanup(() => disposeInstances(mesh))
|
|
643
|
+
return (
|
|
644
|
+
<SceneContext value={{ scene: ctx.scene, parent: mesh, input: ctx.input }}>
|
|
645
|
+
<InstancedMeshContext value={mesh}>{props.children}</InstancedMeshContext>
|
|
646
|
+
</SceneContext>
|
|
647
|
+
)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export type InstanceProps = TransformProps & PointerEventProps & {
|
|
651
|
+
/** The instance's style record (setInstanceStyle as a prop): the
|
|
652
|
+
* floats of the material's slot-1 instance attributes - `[r, g, b, a]`
|
|
653
|
+
* under a stock material's `instanceColors`. Reactive; absent, the
|
|
654
|
+
* instance keeps the material's instanceStyle (white for a tint). */
|
|
655
|
+
style?: ArrayLike<number>
|
|
656
|
+
ref?: (instance: InstanceNode) => void
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* One instance of the enclosing `<InstancedMesh>` (addInstance as a
|
|
661
|
+
* component): a node placed under the nearest `<Group>` or `<Instance>`
|
|
662
|
+
* inside the mesh, with the transform, transition and pointer props of
|
|
663
|
+
* a `<Group>` plus `style`. Children (a `<Mesh>` headlight under a car
|
|
664
|
+
* instance) mount under it. Throws outside an `<InstancedMesh>`.
|
|
665
|
+
*/
|
|
666
|
+
export let Instance: ParentComponent<InstanceProps> = props => {
|
|
667
|
+
let ctx = useContext(SceneContext)
|
|
668
|
+
let mesh = useContext(InstancedMeshContext)
|
|
669
|
+
if (mesh === null) throw new Error("<Instance> must be inside an <InstancedMesh>")
|
|
670
|
+
let instance = addInstance(mesh, undefined, ctx.parent)
|
|
671
|
+
syncNode(instance, props)
|
|
672
|
+
createEffect(
|
|
673
|
+
() => props.style,
|
|
674
|
+
s => {
|
|
675
|
+
if (s !== undefined) setInstanceStyle(instance, s)
|
|
676
|
+
},
|
|
677
|
+
)
|
|
678
|
+
untrack(() => props.ref)?.(instance)
|
|
679
|
+
onCleanup(() => removeInstance(instance))
|
|
680
|
+
return provide(ctx, instance, props)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export type RecordMeshProps = PopulatedMeshProps & {
|
|
684
|
+
geometry: Geometry
|
|
685
|
+
/** Must declare instanceAttributes (shaderMaterialClass), slot 0 only. */
|
|
266
686
|
material: Material
|
|
267
687
|
/** Interleaved per-instance records (stride = the material's instance
|
|
268
688
|
* attributes summed). Reactive; a later array larger than the buffer
|
|
@@ -274,31 +694,32 @@ export type InstancedMeshProps = TransformProps & PointerEventProps & {
|
|
|
274
694
|
* creation. Without them the mesh has no picking leaf, so pointer events
|
|
275
695
|
* never target it. */
|
|
276
696
|
bounds?: ArrayLike<number>
|
|
277
|
-
/**
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
697
|
+
/** Draw into the scene's shadow map (setCastShadow as a prop); default
|
|
698
|
+
* false. Needs a `castShadow` light AND a material class declaring
|
|
699
|
+
* `shadowVertex` (the depth pass with the instance placement) - the
|
|
700
|
+
* shadow views skip a populated mesh without one. */
|
|
701
|
+
castShadow?: boolean
|
|
702
|
+
ref?: (mesh: RecordMeshNode) => void
|
|
282
703
|
}
|
|
283
704
|
|
|
284
|
-
/** One draw entry covering N
|
|
285
|
-
* `records` (
|
|
286
|
-
* component-owned and freed on unmount. */
|
|
287
|
-
export let
|
|
705
|
+
/** One draw entry covering N records: geometry repeated per record of
|
|
706
|
+
* `records` (createRecordMesh as a component, the JS-written population).
|
|
707
|
+
* The record buffer is component-owned and freed on unmount. */
|
|
708
|
+
export let RecordMesh: VoidComponent<RecordMeshProps> = props => {
|
|
288
709
|
let ctx = useContext(SceneContext)
|
|
289
710
|
let mesh = untrack(() =>
|
|
290
|
-
|
|
711
|
+
createRecordMesh(props.geometry, props.material, props.records, props.count, { bounds: props.bounds }),
|
|
291
712
|
)
|
|
292
713
|
add(ctx.parent, mesh)
|
|
293
714
|
createEffect(
|
|
294
715
|
() => props.records,
|
|
295
|
-
r =>
|
|
716
|
+
r => setRecords(mesh, r, untrack(() => props.count)),
|
|
296
717
|
{ defer: true },
|
|
297
718
|
)
|
|
298
719
|
createEffect(
|
|
299
720
|
() => props.count,
|
|
300
721
|
c => {
|
|
301
|
-
if (c !== undefined)
|
|
722
|
+
if (c !== undefined) setRecordCount(mesh, c)
|
|
302
723
|
},
|
|
303
724
|
{ defer: true },
|
|
304
725
|
)
|
|
@@ -308,21 +729,10 @@ export let InstancedMesh: VoidComponent<InstancedMeshProps> = props => {
|
|
|
308
729
|
{ defer: true },
|
|
309
730
|
)
|
|
310
731
|
createEffect(
|
|
311
|
-
() => props.
|
|
312
|
-
|
|
313
|
-
{ defer: true },
|
|
314
|
-
)
|
|
315
|
-
createEffect(
|
|
316
|
-
() => props.params,
|
|
317
|
-
p => {
|
|
318
|
-
if (p !== undefined) setMeshParams(mesh, p)
|
|
319
|
-
},
|
|
320
|
-
)
|
|
321
|
-
createEffect(
|
|
322
|
-
() => props.renderOrder,
|
|
323
|
-
o => setRenderOrder(mesh, o ?? 0),
|
|
732
|
+
() => props.castShadow,
|
|
733
|
+
c => setCastShadow(mesh, c === true),
|
|
324
734
|
)
|
|
325
|
-
|
|
735
|
+
syncMesh(mesh, props)
|
|
326
736
|
untrack(() => props.ref)?.(mesh)
|
|
327
737
|
onCleanup(() => disposeInstances(mesh))
|
|
328
738
|
return null
|
|
@@ -341,7 +751,9 @@ export type PerspectiveCameraProps = {
|
|
|
341
751
|
/**
|
|
342
752
|
* Drives the scene's camera from props (the scene has a default camera, so
|
|
343
753
|
* this component is optional). The camera is scene state, not a tree node:
|
|
344
|
-
* to orbit it, update `position`/`lookAt`.
|
|
754
|
+
* to orbit it, update `position`/`lookAt`. The Scene `camera` prop drives
|
|
755
|
+
* the same state (a partial CameraUpdate, ortho included) - use one form,
|
|
756
|
+
* not both.
|
|
345
757
|
*/
|
|
346
758
|
export let PerspectiveCamera: VoidComponent<PerspectiveCameraProps> = props => {
|
|
347
759
|
let ctx = useContext(SceneContext)
|
|
@@ -353,6 +765,186 @@ export let PerspectiveCamera: VoidComponent<PerspectiveCameraProps> = props => {
|
|
|
353
765
|
return null
|
|
354
766
|
}
|
|
355
767
|
|
|
768
|
+
// Cap on the auto-orbit's per-frame dt in seconds, so the first tick after
|
|
769
|
+
// a suspended stretch (a resumed app, a reload) cannot leap the pose.
|
|
770
|
+
const MAX_ORBIT_DT = 0.1
|
|
771
|
+
|
|
772
|
+
export type OrbitCameraProps = OrbitCameraOptions & {
|
|
773
|
+
/** The control's handle (pose()/set()/eye()/orbiting()/active() - also
|
|
774
|
+
* the debug command shape). This handle's set() pushes the pose itself,
|
|
775
|
+
* so a caller never touches update(). */
|
|
776
|
+
ref?: (orbit: OrbitCameraHandle) => void
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* createOrbitCamera as a Scene child: drives the enclosing scene's camera
|
|
781
|
+
* and takes its input from the scene's leaf through context - no ref
|
|
782
|
+
* plumbing, no handler spreads, no onFrame of your own (with a custom
|
|
783
|
+
* `output`, spread `useScene().input.handlersFor(layout)` on your leaf).
|
|
784
|
+
* The pose props (target, azimuth, elevation, distance) are initial
|
|
785
|
+
* values - change the pose at runtime through `ref`'s set(); every other
|
|
786
|
+
* prop is live, forwarded to the control as a getter and read where it
|
|
787
|
+
* applies, so a clamp, a rate, an anchor callback or `viewport` follows
|
|
788
|
+
* its prop, and a clamp change re-clamps the pose at once. `viewport`
|
|
789
|
+
* defaults to the leaf's laid-out size plus the scene camera's fov, so
|
|
790
|
+
* rotation is viewport-relative and two-finger pan works out of the box
|
|
791
|
+
* (pass your own to override). Auto-orbit runs a frame loop only while
|
|
792
|
+
* `active()` (orbiting with a non-zero `orbitSpeed`); a paused or
|
|
793
|
+
* drag-only camera leaves the app demand-driven idle.
|
|
794
|
+
*/
|
|
795
|
+
export let OrbitCamera: VoidComponent<OrbitCameraProps> = props => {
|
|
796
|
+
let ctx = useContext(SceneContext)
|
|
797
|
+
let leafViewport = () => {
|
|
798
|
+
let layout = ctx.input.layout()
|
|
799
|
+
return layout === null ? null : { height: layout.height, fov: ctx.scene.camera().fov }
|
|
800
|
+
}
|
|
801
|
+
// The control keeps this object and reads each option where it applies,
|
|
802
|
+
// so the props go through as getters (merge), never a spread: a spread
|
|
803
|
+
// would snapshot every prop once and a change could only remount.
|
|
804
|
+
let options: OrbitCameraOptions = merge(props, {
|
|
805
|
+
get viewport() {
|
|
806
|
+
return props.viewport ?? leafViewport
|
|
807
|
+
},
|
|
808
|
+
})
|
|
809
|
+
let orbit = untrack(() => createOrbitCamera(ctx.scene, options))
|
|
810
|
+
// Input pushes the pose synchronously (update(0)), so a drag needs no
|
|
811
|
+
// frame loop and the next paint carries the new camera.
|
|
812
|
+
onCleanup(
|
|
813
|
+
ctx.input.add({
|
|
814
|
+
onPointerDown: e => {
|
|
815
|
+
orbit.handlers.onPointerDown(e)
|
|
816
|
+
orbit.update(0)
|
|
817
|
+
},
|
|
818
|
+
onPointerMove: e => {
|
|
819
|
+
orbit.handlers.onPointerMove(e)
|
|
820
|
+
orbit.update(0)
|
|
821
|
+
},
|
|
822
|
+
onPointerUp: e => orbit.handlers.onPointerUp(e),
|
|
823
|
+
onWheel: e => {
|
|
824
|
+
orbit.handlers.onWheel(e)
|
|
825
|
+
orbit.update(0)
|
|
826
|
+
},
|
|
827
|
+
}),
|
|
828
|
+
)
|
|
829
|
+
createEffect(
|
|
830
|
+
() => orbit.active(),
|
|
831
|
+
on => {
|
|
832
|
+
if (!on) return
|
|
833
|
+
let last: number | null = null
|
|
834
|
+
return onFrame(tick => {
|
|
835
|
+
let now = tick / 1000
|
|
836
|
+
let dt = last === null ? 0 : Math.min(now - last, MAX_ORBIT_DT)
|
|
837
|
+
last = now
|
|
838
|
+
orbit.update(dt)
|
|
839
|
+
})
|
|
840
|
+
},
|
|
841
|
+
)
|
|
842
|
+
// A clamp prop change re-clamps the pose at once (set({}) applies the
|
|
843
|
+
// clamps, update(0) pushes the result) - the update() call a Three
|
|
844
|
+
// OrbitControls app makes after setting minDistance, done by the prop.
|
|
845
|
+
// Deferred: the creation already clamped and pushed the initial pose.
|
|
846
|
+
// Untracked: the control reads the clamps through the getters while it
|
|
847
|
+
// applies them, and the apply wants the values of that moment (the
|
|
848
|
+
// compute above is what tracks them).
|
|
849
|
+
createEffect(
|
|
850
|
+
() => [options.minDistance, options.maxDistance, options.minElevation, options.maxElevation],
|
|
851
|
+
() =>
|
|
852
|
+
untrack(() => {
|
|
853
|
+
orbit.set({})
|
|
854
|
+
orbit.update(0)
|
|
855
|
+
}),
|
|
856
|
+
{ defer: true },
|
|
857
|
+
)
|
|
858
|
+
untrack(() => props.ref)?.({
|
|
859
|
+
...orbit,
|
|
860
|
+
set: (pose: OrbitPose) => {
|
|
861
|
+
orbit.set(pose)
|
|
862
|
+
orbit.update(0)
|
|
863
|
+
},
|
|
864
|
+
})
|
|
865
|
+
return null
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Cap on the first-person loop's per-frame dt in seconds, the same guard
|
|
869
|
+
// as the orbit's: a resumed app must not teleport the walker.
|
|
870
|
+
const MAX_WALK_DT = 0.1
|
|
871
|
+
|
|
872
|
+
export type FirstPersonCameraProps = FirstPersonCameraOptions & {
|
|
873
|
+
/** The control's handle (pose()/set()/eye()/forward()/active() - also
|
|
874
|
+
* the debug command shape). This handle's set() pushes the pose itself,
|
|
875
|
+
* so a caller never touches update(). */
|
|
876
|
+
ref?: (camera: FirstPersonCameraHandle) => void
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* createFirstPersonCamera as a Scene child: drives the enclosing scene's
|
|
881
|
+
* camera and takes its input from the scene's leaf through context. The
|
|
882
|
+
* leaf becomes focusable and takes focus on pointer down, which is what
|
|
883
|
+
* routes WASD to the control (keys go to the focused node; a click on the
|
|
884
|
+
* scene is the same gesture a web canvas needs). With a custom `output`,
|
|
885
|
+
* spread `useScene().input.handlersFor(layout, node)` on your leaf and
|
|
886
|
+
* mark it `focusable`. Pointer lock stays yours: call `lockPointer(true)`
|
|
887
|
+
* from a click and `lockPointer(false)` from Escape. The pose props
|
|
888
|
+
* (position, yaw, pitch) are initial values - change the pose at runtime
|
|
889
|
+
* through `ref`'s set(); every other prop is live, forwarded to the
|
|
890
|
+
* control as a getter and read where it applies: `fly={flying()}` swaps
|
|
891
|
+
* walk and fly on the running control (pose and held keys carry over, no
|
|
892
|
+
* remount), `moveSpeed`, `lookSpeed` and `clampPosition` follow their
|
|
893
|
+
* props, and a pitch clamp change re-clamps at once. `viewport` defaults
|
|
894
|
+
* to the leaf's laid-out size, so a drag is viewport-relative. A frame
|
|
895
|
+
* loop runs only while `active()` - a key held or a stick deflected - so
|
|
896
|
+
* a still scene stays demand-driven idle.
|
|
897
|
+
*/
|
|
898
|
+
export let FirstPersonCamera: VoidComponent<FirstPersonCameraProps> = props => {
|
|
899
|
+
let ctx = useContext(SceneContext)
|
|
900
|
+
let leafViewport = () => {
|
|
901
|
+
let layout = ctx.input.layout()
|
|
902
|
+
return layout === null ? null : { height: layout.height }
|
|
903
|
+
}
|
|
904
|
+
// Getters, not a spread: see OrbitCamera.
|
|
905
|
+
let options: FirstPersonCameraOptions = merge(props, {
|
|
906
|
+
get viewport() {
|
|
907
|
+
return props.viewport ?? leafViewport
|
|
908
|
+
},
|
|
909
|
+
})
|
|
910
|
+
let camera = untrack(() => createFirstPersonCamera(ctx.scene, options))
|
|
911
|
+
// Look input applies synchronously inside the control's handlers, so a
|
|
912
|
+
// mouse move under lock needs no frame loop; only movement integrates.
|
|
913
|
+
onCleanup(ctx.input.add(camera.handlers))
|
|
914
|
+
createEffect(
|
|
915
|
+
() => camera.active(),
|
|
916
|
+
on => {
|
|
917
|
+
if (!on) return
|
|
918
|
+
let last: number | null = null
|
|
919
|
+
return onFrame(tick => {
|
|
920
|
+
let now = tick / 1000
|
|
921
|
+
let dt = last === null ? 0 : Math.min(now - last, MAX_WALK_DT)
|
|
922
|
+
last = now
|
|
923
|
+
camera.update(dt)
|
|
924
|
+
})
|
|
925
|
+
},
|
|
926
|
+
)
|
|
927
|
+
// A pitch clamp change re-clamps the pose at once; deferred and
|
|
928
|
+
// untracked as OrbitCamera's.
|
|
929
|
+
createEffect(
|
|
930
|
+
() => [options.minPitch, options.maxPitch],
|
|
931
|
+
() =>
|
|
932
|
+
untrack(() => {
|
|
933
|
+
camera.set({})
|
|
934
|
+
camera.update(0)
|
|
935
|
+
}),
|
|
936
|
+
{ defer: true },
|
|
937
|
+
)
|
|
938
|
+
untrack(() => props.ref)?.({
|
|
939
|
+
...camera,
|
|
940
|
+
set: (pose: FirstPersonPose) => {
|
|
941
|
+
camera.set(pose)
|
|
942
|
+
camera.update(0)
|
|
943
|
+
},
|
|
944
|
+
})
|
|
945
|
+
return null
|
|
946
|
+
}
|
|
947
|
+
|
|
356
948
|
export type HemisphereLightProps = { sky?: Vec3; ground?: Vec3; intensity?: number; ref?: (light: HemisphereLightNode) => void }
|
|
357
949
|
|
|
358
950
|
/** The scene's ambient term as a node (createHemisphereLight); one per
|
|
@@ -408,3 +1000,102 @@ export let DirectionalLight: VoidComponent<DirectionalLightProps> = props => {
|
|
|
408
1000
|
onCleanup(() => remove(light))
|
|
409
1001
|
return null
|
|
410
1002
|
}
|
|
1003
|
+
|
|
1004
|
+
export type SpotLightProps = TransformProps & {
|
|
1005
|
+
/** Aim direction in the node's local space; default [0, -1, 0], a
|
|
1006
|
+
* lamp pointing straight down. */
|
|
1007
|
+
direction?: Vec3
|
|
1008
|
+
color?: Vec3
|
|
1009
|
+
intensity?: number
|
|
1010
|
+
/** Falloff cutoff in world units (0 = no cutoff). */
|
|
1011
|
+
distance?: number
|
|
1012
|
+
/** Cone half-angle in DEGREES, (0, 90]; default 60 (degrees like
|
|
1013
|
+
* camera fov; Three's radians convert as `angle * 180 / PI`). */
|
|
1014
|
+
angle?: number
|
|
1015
|
+
/** 0..1 fraction of the cone fading to the rim; default 0. */
|
|
1016
|
+
penumbra?: number
|
|
1017
|
+
/** Falloff exponent; default 2 (inverse square). */
|
|
1018
|
+
decay?: number
|
|
1019
|
+
/** Render a shadow map from this light (a perspective map of its
|
|
1020
|
+
* cone; one shadow slot). */
|
|
1021
|
+
castShadow?: boolean
|
|
1022
|
+
/** Shadow-map options (mapSize, bias, normalBias, near), merged key
|
|
1023
|
+
* by key. */
|
|
1024
|
+
shadow?: SpotShadowOptions
|
|
1025
|
+
ref?: (light: SpotLightNode) => void
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** A spot light node (createSpotLight): a cone from the node's world
|
|
1029
|
+
* position along its local `direction` - give it a `position`, and aim
|
|
1030
|
+
* it with `direction` or a parent's rotation. Counts against MAX_LIGHTS
|
|
1031
|
+
* with the other non-ambient lights. */
|
|
1032
|
+
export let SpotLight: VoidComponent<SpotLightProps> = props => {
|
|
1033
|
+
let ctx = useContext(SceneContext)
|
|
1034
|
+
let light = untrack(() =>
|
|
1035
|
+
createSpotLight({
|
|
1036
|
+
direction: props.direction,
|
|
1037
|
+
color: props.color,
|
|
1038
|
+
intensity: props.intensity,
|
|
1039
|
+
distance: props.distance,
|
|
1040
|
+
angle: props.angle,
|
|
1041
|
+
penumbra: props.penumbra,
|
|
1042
|
+
decay: props.decay,
|
|
1043
|
+
castShadow: props.castShadow,
|
|
1044
|
+
shadow: props.shadow,
|
|
1045
|
+
}),
|
|
1046
|
+
)
|
|
1047
|
+
add(ctx.parent, light)
|
|
1048
|
+
syncNode(light, props)
|
|
1049
|
+
createEffect(
|
|
1050
|
+
() =>
|
|
1051
|
+
[props.direction, props.color, props.intensity, props.distance, props.angle, props.penumbra, props.decay, props.castShadow, props.shadow] as const,
|
|
1052
|
+
([direction, color, intensity, distance, angle, penumbra, decay, castShadow, shadow]) =>
|
|
1053
|
+
setLight(light, { direction, color, intensity, distance, angle, penumbra, decay, castShadow, shadow }),
|
|
1054
|
+
)
|
|
1055
|
+
untrack(() => props.ref)?.(light)
|
|
1056
|
+
onCleanup(() => remove(light))
|
|
1057
|
+
return null
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
export type PointLightProps = TransformProps & {
|
|
1061
|
+
color?: Vec3
|
|
1062
|
+
intensity?: number
|
|
1063
|
+
/** Falloff cutoff in world units (0 = no cutoff). */
|
|
1064
|
+
distance?: number
|
|
1065
|
+
/** Falloff exponent; default 2 (inverse square). */
|
|
1066
|
+
decay?: number
|
|
1067
|
+
/** Render shadow maps from this light (six 90-degree face maps, one
|
|
1068
|
+
* per cube face; six shadow slots). Far = `distance`, so give a
|
|
1069
|
+
* casting bulb one. */
|
|
1070
|
+
castShadow?: boolean
|
|
1071
|
+
/** Shadow-map options (mapSize, bias, normalBias, near), merged key
|
|
1072
|
+
* by key. */
|
|
1073
|
+
shadow?: SpotShadowOptions
|
|
1074
|
+
ref?: (light: PointLightNode) => void
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** A point light node (createPointLight): light in every direction from
|
|
1078
|
+
* the node's world position - give it a `position`; rotation does not
|
|
1079
|
+
* matter. Counts against MAX_LIGHTS with the other non-ambient lights. */
|
|
1080
|
+
export let PointLight: VoidComponent<PointLightProps> = props => {
|
|
1081
|
+
let ctx = useContext(SceneContext)
|
|
1082
|
+
let light = untrack(() =>
|
|
1083
|
+
createPointLight({
|
|
1084
|
+
color: props.color,
|
|
1085
|
+
intensity: props.intensity,
|
|
1086
|
+
distance: props.distance,
|
|
1087
|
+
decay: props.decay,
|
|
1088
|
+
castShadow: props.castShadow,
|
|
1089
|
+
shadow: props.shadow,
|
|
1090
|
+
}),
|
|
1091
|
+
)
|
|
1092
|
+
add(ctx.parent, light)
|
|
1093
|
+
syncNode(light, props)
|
|
1094
|
+
createEffect(
|
|
1095
|
+
() => [props.color, props.intensity, props.distance, props.decay, props.castShadow, props.shadow] as const,
|
|
1096
|
+
([color, intensity, distance, decay, castShadow, shadow]) => setLight(light, { color, intensity, distance, decay, castShadow, shadow }),
|
|
1097
|
+
)
|
|
1098
|
+
untrack(() => props.ref)?.(light)
|
|
1099
|
+
onCleanup(() => remove(light))
|
|
1100
|
+
return null
|
|
1101
|
+
}
|