@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.
Files changed (40) hide show
  1. package/AGENTS.md +199 -117
  2. package/README.md +2 -1
  3. package/examples/README.md +13 -8
  4. package/examples/collision.tsx +17 -4
  5. package/examples/first-person.tsx +20 -10
  6. package/examples/fleet.tsx +11 -5
  7. package/examples/model.tsx +3 -3
  8. package/examples/pick.tsx +144 -34
  9. package/examples/probe.tsx +10 -4
  10. package/examples/scene-views.tsx +49 -58
  11. package/examples/sky-lit.tsx +10 -4
  12. package/examples/skybox.tsx +10 -4
  13. package/examples/standard.tsx +10 -4
  14. package/package.json +2 -2
  15. package/src/collision.ts +14 -193
  16. package/src/components/context.tsx +35 -0
  17. package/src/components/directional-light.tsx +48 -0
  18. package/src/components/first-person-camera.tsx +72 -0
  19. package/src/components/group.tsx +18 -0
  20. package/src/components/hemisphere-light.tsx +24 -0
  21. package/src/components/index.ts +46 -0
  22. package/src/components/instanced-mesh.tsx +105 -0
  23. package/src/components/mesh.tsx +92 -0
  24. package/src/components/node-props.ts +90 -0
  25. package/src/components/orbit-camera.tsx +75 -0
  26. package/src/components/perspective-camera.tsx +32 -0
  27. package/src/components/point-light.tsx +52 -0
  28. package/src/components/record-mesh.tsx +68 -0
  29. package/src/components/scene.tsx +281 -0
  30. package/src/components/spot-light.tsx +65 -0
  31. package/src/components/sprite.tsx +38 -0
  32. package/src/components/view3d.tsx +152 -0
  33. package/src/first-person.ts +140 -188
  34. package/src/index.ts +13 -9
  35. package/src/input.ts +70 -0
  36. package/src/node.ts +98 -25
  37. package/src/orbit.ts +266 -235
  38. package/src/scene-pointer.ts +229 -101
  39. package/src/scene.ts +250 -76
  40. package/src/components.tsx +0 -1101
@@ -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
- }