@solidrt/3d 0.0.56 → 0.0.57
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 +199 -117
- package/README.md +2 -1
- package/examples/README.md +13 -8
- package/examples/collision.tsx +17 -4
- package/examples/first-person.tsx +20 -10
- package/examples/fleet.tsx +11 -5
- package/examples/model.tsx +3 -3
- package/examples/pick.tsx +144 -34
- package/examples/probe.tsx +10 -4
- package/examples/scene-views.tsx +49 -58
- package/examples/sky-lit.tsx +10 -4
- package/examples/skybox.tsx +10 -4
- package/examples/standard.tsx +10 -4
- package/package.json +2 -2
- package/src/collision.ts +14 -193
- package/src/components/context.tsx +35 -0
- package/src/components/directional-light.tsx +48 -0
- package/src/components/first-person-camera.tsx +72 -0
- package/src/components/group.tsx +18 -0
- package/src/components/hemisphere-light.tsx +24 -0
- package/src/components/index.ts +46 -0
- package/src/components/instanced-mesh.tsx +105 -0
- package/src/components/mesh.tsx +92 -0
- package/src/components/node-props.ts +90 -0
- package/src/components/orbit-camera.tsx +75 -0
- package/src/components/perspective-camera.tsx +32 -0
- package/src/components/point-light.tsx +52 -0
- package/src/components/record-mesh.tsx +68 -0
- package/src/components/scene.tsx +281 -0
- package/src/components/spot-light.tsx +65 -0
- package/src/components/sprite.tsx +38 -0
- package/src/components/view3d.tsx +152 -0
- package/src/first-person.ts +140 -188
- package/src/index.ts +13 -9
- package/src/input.ts +70 -0
- package/src/node.ts +98 -25
- package/src/orbit.ts +266 -235
- package/src/scene-pointer.ts +229 -101
- package/src/scene.ts +250 -76
- package/src/components.tsx +0 -1101
package/src/components.tsx
DELETED
|
@@ -1,1101 +0,0 @@
|
|
|
1
|
-
// The Solid face: PascalCase components over context, syncing the retained
|
|
2
|
-
// scene (node/mesh/light/scene.ts) - no new intrinsic elements, no
|
|
3
|
-
// renderer changes. Props
|
|
4
|
-
// follow the Solid 2.0 model (reactive values, no destructuring); effects
|
|
5
|
-
// write into the retained nodes and the runtime's dirty flush renders.
|
|
6
|
-
// Anything moving at frame rate can bypass the declarative layer: grab the
|
|
7
|
-
// node with `ref` and call setTransform from onFrame - signals carry
|
|
8
|
-
// structure and slow state, per-frame motion goes straight to the scene.
|
|
9
|
-
|
|
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"
|
|
19
|
-
import {
|
|
20
|
-
addInstance,
|
|
21
|
-
createInstancedMesh,
|
|
22
|
-
createMesh,
|
|
23
|
-
createRecordMesh,
|
|
24
|
-
createSprite,
|
|
25
|
-
disposeInstances,
|
|
26
|
-
removeInstance,
|
|
27
|
-
setCastShadow,
|
|
28
|
-
setCulling,
|
|
29
|
-
setGeometry,
|
|
30
|
-
setInstanceStyle,
|
|
31
|
-
setLayers,
|
|
32
|
-
setMaterial,
|
|
33
|
-
setMeshParams,
|
|
34
|
-
setRecordCount,
|
|
35
|
-
setRecords,
|
|
36
|
-
setRenderOrder,
|
|
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"
|
|
51
|
-
import type { ShaderParams } from "@solidrt/core/gpu"
|
|
52
|
-
import type { NodeTransition } from "flux:spatial"
|
|
53
|
-
import type { Geometry } from "./geometry.ts"
|
|
54
|
-
import type { Material } from "./material.ts"
|
|
55
|
-
import type { Quat, Vec3 } from "./math.ts"
|
|
56
|
-
|
|
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 }
|
|
104
|
-
let SceneContext = createContext<SceneCtx>()
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* The enclosing scene, parent node and input channel - the imperative
|
|
108
|
-
* escape hatch inside a component subtree (throws outside a `<Scene>`).
|
|
109
|
-
*/
|
|
110
|
-
export function useScene(): SceneCtx {
|
|
111
|
-
return useContext(SceneContext)
|
|
112
|
-
}
|
|
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
|
-
|
|
124
|
-
export type TransformProps = {
|
|
125
|
-
position?: Vec3
|
|
126
|
-
/** Euler radians in XYZ order (x first), Three's `Euler` default. */
|
|
127
|
-
rotation?: Vec3
|
|
128
|
-
/** The rotation as a quaternion - what the node stores. Pass this or
|
|
129
|
-
* `rotation`, not both. */
|
|
130
|
-
quaternion?: Quat
|
|
131
|
-
scale?: Vec3 | number
|
|
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
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Mesh pointer events, the element vocabulary one tree deeper: the nearest
|
|
142
|
-
* hit mesh receives the event, down/move/up bubble to ancestor Groups
|
|
143
|
-
* (stopPropagation stops the walk), enter/leave pair on the mesh alone.
|
|
144
|
-
* Events flow while the element showing the scene carries scene.handlers -
|
|
145
|
-
* the built-in <Scene> leaf does (opt out with events={false}); an `output`
|
|
146
|
-
* leaf spreads them itself.
|
|
147
|
-
*/
|
|
148
|
-
export type PointerEventProps = {
|
|
149
|
-
onPointerDown?: (event: ScenePointerEvent) => void
|
|
150
|
-
onPointerMove?: (event: ScenePointerEvent) => void
|
|
151
|
-
onPointerUp?: (event: ScenePointerEvent) => void
|
|
152
|
-
/** Meshes only: a Group never receives enter/leave. */
|
|
153
|
-
onPointerEnter?: (event: ScenePointerEvent) => void
|
|
154
|
-
onPointerLeave?: (event: ScenePointerEvent) => void
|
|
155
|
-
}
|
|
156
|
-
|
|
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).
|
|
162
|
-
createEffect(
|
|
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]) => {
|
|
178
|
-
setTransform(node, { position, rotation, quaternion, scale })
|
|
179
|
-
setVisible(node, visible !== false)
|
|
180
|
-
node.onPointerDown = down
|
|
181
|
-
node.onPointerMove = move
|
|
182
|
-
node.onPointerUp = up
|
|
183
|
-
node.onPointerEnter = enter
|
|
184
|
-
node.onPointerLeave = leave
|
|
185
|
-
node.onTransitionEnd = end
|
|
186
|
-
},
|
|
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
|
-
)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export type SceneProps = {
|
|
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
|
|
212
|
-
clearColor?: [number, number, number, number]
|
|
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
|
|
228
|
-
* `scene.background = color` is `clearColor` here. */
|
|
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"
|
|
252
|
-
label?: string
|
|
253
|
-
/** Multisample count (1, 2, 4 or 8; default 1): anti-aliased mesh edges.
|
|
254
|
-
* Fixed at creation. */
|
|
255
|
-
samples?: 1 | 2 | 4 | 8
|
|
256
|
-
ref?: (scene: SceneHandle) => void
|
|
257
|
-
/**
|
|
258
|
-
* Compose the output yourself: called once (untracked) with the scene's
|
|
259
|
-
* texture id, and its return renders in place of the built-in `<texture>`
|
|
260
|
-
* leaf - a `<d-texture>`, a leaf carrying paint/pointer/layout props, or
|
|
261
|
-
* a post-effect chain (a shader target sampling the id; created in the
|
|
262
|
-
* callback it disposes with the Scene). Return null to render no leaf.
|
|
263
|
-
* Mesh pointer events then need the scene's handlers on your leaf:
|
|
264
|
-
* `<texture src={texture} {...useScene().scene.handlers} />`.
|
|
265
|
-
*/
|
|
266
|
-
output?: (texture: TextureId) => Element
|
|
267
|
-
/**
|
|
268
|
-
* Mesh pointer events (default on): the built-in leaf carries
|
|
269
|
-
* scene.handlers, so Mesh/Group onPointer* props receive events. `false`
|
|
270
|
-
* detaches them - the leaf then costs no pointer routing at all.
|
|
271
|
-
*/
|
|
272
|
-
events?: boolean
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Owns a draw target and composites it as an ordinary `<texture>` leaf, so
|
|
277
|
-
* the output takes layout, transforms, blendMode, and pointer events like
|
|
278
|
-
* any element - or hand `output` the texture id and compose it yourself.
|
|
279
|
-
* Children (Mesh/Group/PerspectiveCamera) render nothing themselves - they
|
|
280
|
-
* populate the retained scene through context.
|
|
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
|
-
|
|
286
|
-
export let Scene: ParentComponent<SceneProps> = props => {
|
|
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
|
-
})
|
|
317
|
-
createEffect(
|
|
318
|
-
() => [props.width, props.height] as const,
|
|
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 },
|
|
332
|
-
)
|
|
333
|
-
createEffect(
|
|
334
|
-
() => props.background,
|
|
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 },
|
|
362
|
-
)
|
|
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
|
-
}
|
|
406
|
-
let output = untrack(() => props.output)
|
|
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)
|
|
445
|
-
return (
|
|
446
|
-
<SceneContext value={{ scene, parent: scene.root, input }}>
|
|
447
|
-
{output ? (
|
|
448
|
-
untrack(() => output(scene.texture))
|
|
449
|
-
) : (
|
|
450
|
-
<texture
|
|
451
|
-
ref={(n: { id: number }) => (leafNode = n)}
|
|
452
|
-
src={scene.texture}
|
|
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}
|
|
464
|
-
/>
|
|
465
|
-
)}
|
|
466
|
-
{props.children}
|
|
467
|
-
</SceneContext>
|
|
468
|
-
)
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/** A transform node: children inherit its position/rotation/scale. */
|
|
472
|
-
export let Group: ParentComponent<TransformProps & PointerEventProps & { ref?: (node: SceneNode) => void }> = props => {
|
|
473
|
-
let ctx = useContext(SceneContext)
|
|
474
|
-
let node = createGroup()
|
|
475
|
-
add(ctx.parent, node)
|
|
476
|
-
syncNode(node, props)
|
|
477
|
-
untrack(() => props.ref)?.(node)
|
|
478
|
-
onCleanup(() => remove(node))
|
|
479
|
-
return provide(ctx, node, props)
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
export type MeshProps = TransformProps & PointerEventProps & {
|
|
483
|
-
geometry: Geometry
|
|
484
|
-
material: Material
|
|
485
|
-
/** Per-mesh uniforms for a custom material (setMeshParams as a prop).
|
|
486
|
-
* Keys merge - a key that disappears keeps its old value; there is no
|
|
487
|
-
* unset. Names must be declared by the material's shaders. For values
|
|
488
|
-
* changing every frame prefer `ref` + setMeshParams from onFrame, the
|
|
489
|
-
* same split as setTransform. */
|
|
490
|
-
params?: ShaderParams
|
|
491
|
-
/** Explicit draw-order key (setRenderOrder as a prop); default 0. */
|
|
492
|
-
renderOrder?: number
|
|
493
|
-
/** Draw into the scene's shadow map (setCastShadow as a prop); default
|
|
494
|
-
* false. Needs a `castShadow` light to show. */
|
|
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
|
|
504
|
-
ref?: (mesh: MeshNode) => void
|
|
505
|
-
}
|
|
506
|
-
|
|
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 {
|
|
512
|
-
createEffect(
|
|
513
|
-
() => props.material,
|
|
514
|
-
m => setMaterial(mesh, m),
|
|
515
|
-
{ defer: true },
|
|
516
|
-
)
|
|
517
|
-
createEffect(
|
|
518
|
-
() => props.params,
|
|
519
|
-
p => {
|
|
520
|
-
if (p !== undefined) setMeshParams(mesh, p)
|
|
521
|
-
},
|
|
522
|
-
)
|
|
523
|
-
createEffect(
|
|
524
|
-
() => props.renderOrder,
|
|
525
|
-
o => setRenderOrder(mesh, o ?? 0),
|
|
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
|
-
)
|
|
535
|
-
syncNode(mesh, props)
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/** One draw entry: geometry drawn with a material at a transform. */
|
|
539
|
-
export let Mesh: VoidComponent<MeshProps> = props => {
|
|
540
|
-
let ctx = useContext(SceneContext)
|
|
541
|
-
let mesh = untrack(() => createMesh(props.geometry, props.material))
|
|
542
|
-
add(ctx.parent, mesh)
|
|
543
|
-
createEffect(
|
|
544
|
-
() => props.geometry,
|
|
545
|
-
g => setGeometry(mesh, g),
|
|
546
|
-
{ defer: true },
|
|
547
|
-
)
|
|
548
|
-
createEffect(
|
|
549
|
-
() => props.castShadow,
|
|
550
|
-
c => setCastShadow(mesh, c === true),
|
|
551
|
-
)
|
|
552
|
-
syncMesh(mesh, props)
|
|
553
|
-
untrack(() => props.ref)?.(mesh)
|
|
554
|
-
onCleanup(() => remove(mesh))
|
|
555
|
-
return null
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
export type SpriteProps = TransformProps & PointerEventProps & {
|
|
559
|
-
/** A `sprite()` material (any material draws, only a sprite one turns). */
|
|
560
|
-
material: Material
|
|
561
|
-
/** Per-mesh uniforms, merge semantics - as on Mesh. */
|
|
562
|
-
params?: ShaderParams
|
|
563
|
-
/** Explicit draw-order key (setRenderOrder as a prop); default 0. */
|
|
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
|
|
571
|
-
ref?: (mesh: MeshNode) => void
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
/** A camera-facing unit quad (createSprite as a component): no geometry
|
|
575
|
-
* prop, `scale` is its world size, rotation is ignored. */
|
|
576
|
-
export let Sprite: VoidComponent<SpriteProps> = props => {
|
|
577
|
-
let ctx = useContext(SceneContext)
|
|
578
|
-
let mesh = untrack(() => createSprite(props.material))
|
|
579
|
-
add(ctx.parent, mesh)
|
|
580
|
-
syncMesh(mesh, props)
|
|
581
|
-
untrack(() => props.ref)?.(mesh)
|
|
582
|
-
onCleanup(() => remove(mesh))
|
|
583
|
-
return null
|
|
584
|
-
}
|
|
585
|
-
|
|
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 & {
|
|
591
|
-
geometry: Geometry
|
|
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. */
|
|
686
|
-
material: Material
|
|
687
|
-
/** Interleaved per-instance records (stride = the material's instance
|
|
688
|
-
* attributes summed). Reactive; a later array larger than the buffer
|
|
689
|
-
* grows it (capacity doubles into a replacement buffer). */
|
|
690
|
-
records: Float32Array
|
|
691
|
-
/** How many records draw; default all of the latest `records`. */
|
|
692
|
-
count?: number
|
|
693
|
-
/** LOCAL bounds covering every instance ([minX..maxZ]), fixed at
|
|
694
|
-
* creation. Without them the mesh has no picking leaf, so pointer events
|
|
695
|
-
* never target it. */
|
|
696
|
-
bounds?: ArrayLike<number>
|
|
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
|
|
703
|
-
}
|
|
704
|
-
|
|
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 => {
|
|
709
|
-
let ctx = useContext(SceneContext)
|
|
710
|
-
let mesh = untrack(() =>
|
|
711
|
-
createRecordMesh(props.geometry, props.material, props.records, props.count, { bounds: props.bounds }),
|
|
712
|
-
)
|
|
713
|
-
add(ctx.parent, mesh)
|
|
714
|
-
createEffect(
|
|
715
|
-
() => props.records,
|
|
716
|
-
r => setRecords(mesh, r, untrack(() => props.count)),
|
|
717
|
-
{ defer: true },
|
|
718
|
-
)
|
|
719
|
-
createEffect(
|
|
720
|
-
() => props.count,
|
|
721
|
-
c => {
|
|
722
|
-
if (c !== undefined) setRecordCount(mesh, c)
|
|
723
|
-
},
|
|
724
|
-
{ defer: true },
|
|
725
|
-
)
|
|
726
|
-
createEffect(
|
|
727
|
-
() => props.geometry,
|
|
728
|
-
g => setGeometry(mesh, g),
|
|
729
|
-
{ defer: true },
|
|
730
|
-
)
|
|
731
|
-
createEffect(
|
|
732
|
-
() => props.castShadow,
|
|
733
|
-
c => setCastShadow(mesh, c === true),
|
|
734
|
-
)
|
|
735
|
-
syncMesh(mesh, props)
|
|
736
|
-
untrack(() => props.ref)?.(mesh)
|
|
737
|
-
onCleanup(() => disposeInstances(mesh))
|
|
738
|
-
return null
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
export type PerspectiveCameraProps = {
|
|
742
|
-
/** Vertical field of view in DEGREES (default 60). */
|
|
743
|
-
fov?: number
|
|
744
|
-
near?: number
|
|
745
|
-
far?: number
|
|
746
|
-
position?: Vec3
|
|
747
|
-
lookAt?: Vec3
|
|
748
|
-
up?: Vec3
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
/**
|
|
752
|
-
* Drives the scene's camera from props (the scene has a default camera, so
|
|
753
|
-
* this component is optional). The camera is scene state, not a tree node:
|
|
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.
|
|
757
|
-
*/
|
|
758
|
-
export let PerspectiveCamera: VoidComponent<PerspectiveCameraProps> = props => {
|
|
759
|
-
let ctx = useContext(SceneContext)
|
|
760
|
-
createEffect(
|
|
761
|
-
() => [props.fov, props.near, props.far, props.position, props.lookAt, props.up] as const,
|
|
762
|
-
([fov, near, far, position, lookAt, up]) =>
|
|
763
|
-
ctx.scene.setCamera({ fov, near, far, position, target: lookAt, up }),
|
|
764
|
-
)
|
|
765
|
-
return null
|
|
766
|
-
}
|
|
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
|
-
|
|
948
|
-
export type HemisphereLightProps = { sky?: Vec3; ground?: Vec3; intensity?: number; ref?: (light: HemisphereLightNode) => void }
|
|
949
|
-
|
|
950
|
-
/** The scene's ambient term as a node (createHemisphereLight); one per
|
|
951
|
-
* scene, the last mounted wins. */
|
|
952
|
-
export let HemisphereLight: VoidComponent<HemisphereLightProps> = props => {
|
|
953
|
-
let ctx = useContext(SceneContext)
|
|
954
|
-
let light = untrack(() => createHemisphereLight({ sky: props.sky, ground: props.ground, intensity: props.intensity }))
|
|
955
|
-
add(ctx.parent, light)
|
|
956
|
-
createEffect(
|
|
957
|
-
() => [props.sky, props.ground, props.intensity] as const,
|
|
958
|
-
([sky, ground, intensity]) => setLight(light, { sky, ground, intensity }),
|
|
959
|
-
)
|
|
960
|
-
untrack(() => props.ref)?.(light)
|
|
961
|
-
onCleanup(() => remove(light))
|
|
962
|
-
return null
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
export type DirectionalLightProps = TransformProps & {
|
|
966
|
-
/** Travel direction in the node's local space; default [0, -1, 0]. */
|
|
967
|
-
direction?: Vec3
|
|
968
|
-
color?: Vec3
|
|
969
|
-
intensity?: number
|
|
970
|
-
/** Render a shadow map from this light (any directional light may;
|
|
971
|
-
* each is a pass). Its shadow camera sits at the light's WORLD
|
|
972
|
-
* position, so give a casting light a `position` above the scene. */
|
|
973
|
-
castShadow?: boolean
|
|
974
|
-
/** Shadow-map options (mapSize, bias, normalBias, camera frustum,
|
|
975
|
-
* cascades, distance), merged key by key. */
|
|
976
|
-
shadow?: ShadowOptions
|
|
977
|
-
ref?: (light: DirectionalLightNode) => void
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
/** A directional light node (createDirectionalLight): a parent Group's
|
|
981
|
-
* rotation turns it; up to MAX_LIGHTS per scene, in mount order. */
|
|
982
|
-
export let DirectionalLight: VoidComponent<DirectionalLightProps> = props => {
|
|
983
|
-
let ctx = useContext(SceneContext)
|
|
984
|
-
let light = untrack(() =>
|
|
985
|
-
createDirectionalLight({
|
|
986
|
-
direction: props.direction,
|
|
987
|
-
color: props.color,
|
|
988
|
-
intensity: props.intensity,
|
|
989
|
-
castShadow: props.castShadow,
|
|
990
|
-
shadow: props.shadow,
|
|
991
|
-
}),
|
|
992
|
-
)
|
|
993
|
-
add(ctx.parent, light)
|
|
994
|
-
syncNode(light, props)
|
|
995
|
-
createEffect(
|
|
996
|
-
() => [props.direction, props.color, props.intensity, props.castShadow, props.shadow] as const,
|
|
997
|
-
([direction, color, intensity, castShadow, shadow]) => setLight(light, { direction, color, intensity, castShadow, shadow }),
|
|
998
|
-
)
|
|
999
|
-
untrack(() => props.ref)?.(light)
|
|
1000
|
-
onCleanup(() => remove(light))
|
|
1001
|
-
return null
|
|
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
|
-
}
|