@solidrt/3d 0.0.55 → 0.0.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AGENTS.md +1083 -183
  2. package/README.md +15 -7
  3. package/examples/README.md +79 -5
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +250 -0
  7. package/examples/first-person.tsx +119 -0
  8. package/examples/fleet.tsx +194 -0
  9. package/examples/fog.tsx +2 -3
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +32 -6
  17. package/examples/pick.tsx +2 -4
  18. package/examples/probe.tsx +101 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +32 -3
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +86 -0
  24. package/examples/skybox.tsx +128 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +132 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +199 -0
  32. package/src/color.ts +32 -0
  33. package/src/components.tsx +763 -82
  34. package/src/environment-bake.ts +492 -0
  35. package/src/environment.ts +325 -0
  36. package/src/first-person.ts +306 -0
  37. package/src/geometry.ts +6 -2
  38. package/src/glsl.ts +1343 -82
  39. package/src/gltf.ts +432 -112
  40. package/src/index.ts +57 -9
  41. package/src/light.ts +383 -0
  42. package/src/material.ts +667 -232
  43. package/src/math.ts +110 -0
  44. package/src/mesh.ts +728 -0
  45. package/src/mixer.ts +476 -0
  46. package/src/model-file.ts +80 -15
  47. package/src/model.ts +204 -35
  48. package/src/node.ts +506 -0
  49. package/src/orbit.ts +97 -48
  50. package/src/scene-pointer.ts +166 -0
  51. package/src/scene-shadows.ts +471 -0
  52. package/src/scene.ts +1242 -1557
  53. package/tools/environment.ts +69 -0
  54. package/tools/model.ts +4 -3
@@ -1,52 +1,126 @@
1
1
  // The Solid face: PascalCase components over context, syncing the retained
2
- // scene (scene.ts) - no new intrinsic elements, no renderer changes. Props
2
+ // scene (node/mesh/light/scene.ts) - no new intrinsic elements, no
3
+ // renderer changes. Props
3
4
  // follow the Solid 2.0 model (reactive values, no destructuring); effects
4
5
  // write into the retained nodes and the runtime's dirty flush renders.
5
6
  // Anything moving at frame rate can bypass the declarative layer: grab the
6
7
  // node with `ref` and call setTransform from onFrame - signals carry
7
8
  // structure and slow state, per-frame motion goes straight to the scene.
8
9
 
9
- import { createContext, createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
10
- import type { Element, ParentComponent, TextureId, VoidComponent } from "@solidrt/core"
10
+ import { merge } from "@solidjs/signals"
11
+ import { createContext, createEffect, createSignal, displayScale, getBoundingBoxViewport, getLayoutBox, onCleanup, onFrame, onLayout, setFocus, untrack, useContext } from "@solidrt/core"
12
+ import type { Element, KeyEvent, ParentComponent, PointerEvent, TextureId, VoidComponent, WheelEvent } from "@solidrt/core"
13
+ import { createOrbitCamera } from "./orbit.ts"
14
+ import type { OrbitCamera as OrbitCameraHandle, OrbitCameraOptions, OrbitPose } from "./orbit.ts"
15
+ import { createFirstPersonCamera } from "./first-person.ts"
16
+ import type { FirstPersonCamera as FirstPersonCameraHandle, FirstPersonCameraOptions, FirstPersonPose } from "./first-person.ts"
17
+ import { add, createGroup, remove, setTransform, setTransition, setVisible } from "./node.ts"
18
+ import type { SceneNode, ScenePointerEvent, TransitionEndEvent } from "./node.ts"
11
19
  import {
12
- add,
13
- createDirectionalLight,
14
- createGroup,
15
- createHemisphereLight,
20
+ addInstance,
16
21
  createInstancedMesh,
17
22
  createMesh,
18
- createScene,
23
+ createRecordMesh,
19
24
  createSprite,
20
25
  disposeInstances,
21
- remove,
22
- setGeometry,
23
- setInstanceCount,
24
- setInstances,
25
- setLight,
26
+ removeInstance,
26
27
  setCastShadow,
28
+ setCulling,
29
+ setGeometry,
30
+ setInstanceStyle,
31
+ setLayers,
27
32
  setMaterial,
28
33
  setMeshParams,
34
+ setRecordCount,
35
+ setRecords,
29
36
  setRenderOrder,
30
- setTransform,
31
- setVisible,
32
- } from "./scene.ts"
37
+ } from "./mesh.ts"
38
+ import type { InstancedMesh as InstancedMeshNode, InstanceNode, Mesh as MeshNode, RecordMesh as RecordMeshNode } from "./mesh.ts"
39
+ import { createDirectionalLight, createHemisphereLight, createPointLight, createSpotLight, setLight } from "./light.ts"
40
+ import type {
41
+ DirectionalLight as DirectionalLightNode,
42
+ HemisphereLight as HemisphereLightNode,
43
+ PointLight as PointLightNode,
44
+ ShadowOptions,
45
+ SpotLight as SpotLightNode,
46
+ SpotShadowOptions,
47
+ } from "./light.ts"
48
+ import { createScene } from "./scene.ts"
49
+ import type { CameraUpdate } from "./camera.ts"
50
+ import type { EnvironmentOptions, FogOptions, Scene as SceneHandle, SkyboxOptions, ToneMapping } from "./scene.ts"
33
51
  import type { ShaderParams } from "@solidrt/core/gpu"
34
- import type { DirectionalLight as DirectionalLightNode, FogOptions, HemisphereLight as HemisphereLightNode, InstancedMesh as InstancedMeshNode, Mesh as MeshNode, Scene as SceneHandle, SceneNode, ScenePointerEvent, ShadowOptions } from "./scene.ts"
52
+ import type { NodeTransition } from "flux:spatial"
35
53
  import type { Geometry } from "./geometry.ts"
36
54
  import type { Material } from "./material.ts"
37
55
  import type { Quat, Vec3 } from "./math.ts"
38
56
 
39
- type SceneCtx = { scene: SceneHandle; parent: SceneNode }
57
+ /** A camera control's input feed - the handlers shape the camera controls
58
+ * expose, every field optional. A listener with key handlers makes the
59
+ * leaf focusable and focused on pointer down, since keys route through
60
+ * the focused node; onBlur tells it the held keys will get no key-up. */
61
+ export type SceneInputListener = {
62
+ onPointerDown?(event: PointerEvent): void
63
+ onPointerMove?(event: PointerEvent): void
64
+ onPointerUp?(event: PointerEvent): void
65
+ onWheel?(event: WheelEvent): void
66
+ onKeyDown?(event: KeyEvent): void
67
+ onKeyUp?(event: KeyEvent): void
68
+ onBlur?(): void
69
+ }
70
+
71
+ /**
72
+ * The channel between the element showing the scene and camera-control
73
+ * components (`<OrbitCamera>`, `<FirstPersonCamera>`): the leaf feeds
74
+ * pointer, wheel and key events in, controls listen. The built-in
75
+ * `<Scene>` leaf is wired automatically; a custom `output` leaf spreads
76
+ * `{...useScene().input.handlersFor(layout)}` beside its scene.handlersFor
77
+ * spread, with the same `layout`, and declares itself `focusable` when a
78
+ * key-driven control is in the scene (the spread's onPointerDown focuses
79
+ * it; keys reach only the focused node).
80
+ */
81
+ export type SceneInput = {
82
+ /** Spreadable pointer + wheel + key handlers for the leaf. `layout`
83
+ * reports the leaf's laid-out size - what viewport-relative controls
84
+ * scale to - and is read per event, so a reactive layout just works.
85
+ * `node` is the leaf itself, focused on pointer down while a key
86
+ * listener is registered. */
87
+ handlersFor(layout: () => { width: number; height: number }, node?: () => { id: number } | undefined): {
88
+ onPointerDown(event: PointerEvent): void
89
+ onPointerMove(event: PointerEvent): void
90
+ onPointerUp(event: PointerEvent): void
91
+ onWheel(event: WheelEvent): void
92
+ onKeyDown(event: KeyEvent): void
93
+ onKeyUp(event: KeyEvent): void
94
+ onBlur(): void
95
+ }
96
+ /** Subscribe a control; returns the unsubscribe. */
97
+ add(listener: SceneInputListener): () => void
98
+ /** The showing leaf's laid-out size, null before one registered a
99
+ * layout (handlersFor supplies it). */
100
+ layout(): { width: number; height: number } | null
101
+ }
102
+
103
+ type SceneCtx = { scene: SceneHandle; parent: SceneNode; input: SceneInput }
40
104
  let SceneContext = createContext<SceneCtx>()
41
105
 
42
106
  /**
43
- * The enclosing scene and parent node - the imperative escape hatch inside
44
- * a component subtree (throws outside a `<Scene>`).
107
+ * The enclosing scene, parent node and input channel - the imperative
108
+ * escape hatch inside a component subtree (throws outside a `<Scene>`).
45
109
  */
46
110
  export function useScene(): SceneCtx {
47
111
  return useContext(SceneContext)
48
112
  }
49
113
 
114
+ // The scene context for a node component's children. Solid's provider is
115
+ // a root and two memos over the children, paid even when there are none,
116
+ // so it is built only when the JSX has children: the compiler emits the
117
+ // `children` getter only then, so the test is structural, not a read
118
+ // (probes/3d-instance-mount-bench.tsx has the per-row cost).
119
+ function provide(ctx: SceneCtx, parent: SceneNode, props: { children?: Element }): Element | undefined {
120
+ if (!("children" in props)) return undefined
121
+ return <SceneContext value={{ scene: ctx.scene, parent, input: ctx.input }}>{props.children}</SceneContext>
122
+ }
123
+
50
124
  export type TransformProps = {
51
125
  position?: Vec3
52
126
  /** Euler radians in XYZ order (x first), Three's `Euler` default. */
@@ -56,6 +130,11 @@ export type TransformProps = {
56
130
  quaternion?: Quat
57
131
  scale?: Vec3 | number
58
132
  visible?: boolean
133
+ /** How transform-prop changes animate (see setTransition); the mount
134
+ * transform always snaps. */
135
+ transition?: NodeTransition | string | null
136
+ /** A declared transition settled on one component. */
137
+ onTransitionEnd?: (event: TransitionEndEvent) => void
59
138
  }
60
139
 
61
140
  /**
@@ -76,43 +155,100 @@ export type PointerEventProps = {
76
155
  }
77
156
 
78
157
  function syncNode(node: SceneNode, props: TransformProps & PointerEventProps): void {
158
+ // One effect for the transform and the handlers: re-assigning six
159
+ // handler fields on a transform write is free, an effect of its own is
160
+ // not (a node component's mount cost is mostly its effects and its
161
+ // context provider, see probes/3d-instance-mount-bench.tsx).
79
162
  createEffect(
80
- () => [props.position, props.rotation, props.quaternion, props.scale, props.visible] as const,
81
- ([position, rotation, quaternion, scale, visible]) => {
163
+ () =>
164
+ [
165
+ props.position,
166
+ props.rotation,
167
+ props.quaternion,
168
+ props.scale,
169
+ props.visible,
170
+ props.onPointerDown,
171
+ props.onPointerMove,
172
+ props.onPointerUp,
173
+ props.onPointerEnter,
174
+ props.onPointerLeave,
175
+ props.onTransitionEnd,
176
+ ] as const,
177
+ ([position, rotation, quaternion, scale, visible, down, move, up, enter, leave, end]) => {
82
178
  setTransform(node, { position, rotation, quaternion, scale })
83
179
  setVisible(node, visible !== false)
84
- },
85
- )
86
- createEffect(
87
- () => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onPointerEnter, props.onPointerLeave] as const,
88
- ([down, move, up, enter, leave]) => {
89
180
  node.onPointerDown = down
90
181
  node.onPointerMove = move
91
182
  node.onPointerUp = up
92
183
  node.onPointerEnter = enter
93
184
  node.onPointerLeave = leave
185
+ node.onTransitionEnd = end
94
186
  },
95
187
  )
188
+ // After the transform effect, so the mount transform snaps before writes
189
+ // animate. Its own effect: merged above, every transform write would
190
+ // re-send the config to the core.
191
+ createEffect(
192
+ () => props.transition,
193
+ transition => setTransition(node, transition ?? null),
194
+ )
96
195
  }
97
196
 
98
197
  export type SceneProps = {
99
- /** Target pixels. With `output`, the leaf's own width/height are layout,
100
- * so render size and display size separate (supersampling). */
101
- width: number
102
- height: number
198
+ /**
199
+ * Target pixels - give both, or neither. Omitted, the scene FILLS: the
200
+ * built-in leaf is laid out at 100% of its parent's box (give it a sized
201
+ * parent, as on the web) and the target tracks the leaf's on-screen size
202
+ * in device pixels - display scale, designSize fits and ancestor
203
+ * transforms included - so a bare `<Scene>` renders at native density on
204
+ * any display, and viewport-relative camera controls scale with the box.
205
+ * Fill or fixed is decided at mount. `output` needs explicit sizes (the
206
+ * target cannot follow a leaf it does not own); the leaf's own
207
+ * width/height are then layout, so render size and display size separate
208
+ * (supersampling).
209
+ */
210
+ width?: number
211
+ height?: number
103
212
  clearColor?: [number, number, number, number]
104
- /** Fragment GLSL drawn behind the meshes, inside the scene's own pass
105
- * (scene.setBackground): vUV/iResolution/fragColor contract, so a
106
- * createShaderTexture backdrop ports verbatim. Reactive - swapping the
107
- * source replaces the background; undefined removes it. Three's
213
+ /**
214
+ * Drive the scene camera declaratively (scene.setCamera as a prop): a
215
+ * partial CameraUpdate, absent keys keep their values - `ortho` included,
216
+ * which `<PerspectiveCamera>` by its name never sets. The prop and the
217
+ * `<PerspectiveCamera>` child write the same scene state, so use one
218
+ * form, not both (last write wins). The 2d layers' `camera` prop, one
219
+ * dimension up.
220
+ */
221
+ camera?: CameraUpdate
222
+ /** The background drawn behind the meshes, inside the scene's own pass
223
+ * (scene.setBackground): fragment GLSL (vUV/iResolution/fragColor plus
224
+ * the vRay view ray, so a createShaderTexture backdrop ports verbatim
225
+ * and a directional sky is a few lines) or a skybox `{ cube, intensity?,
226
+ * rotation? }`. Reactive - swapping the source replaces the background,
227
+ * a skybox's knobs update in place; undefined removes it. Three's
108
228
  * `scene.background = color` is `clearColor` here. */
109
- background?: string
229
+ background?: string | SkyboxOptions
230
+ /** The cube map reflective `lit` materials mirror (scene.setEnvironment):
231
+ * `{ cube, intensity?, rotation? }`, typically the skybox's own cube
232
+ * turned with it. Reactive; undefined removes it. */
233
+ environment?: EnvironmentOptions
110
234
  /** Scene-wide fog (scene.setFog): linear `{ color, near, far }` or exp2
111
235
  * `{ color, density }`, optionally thinning above `height` by
112
236
  * `heightFalloff`; every standard material fades toward `color` by
113
237
  * distance from the camera. Reactive; undefined removes it. Match
114
238
  * `color` to clearColor or the background, which is not fogged. */
115
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"
116
252
  label?: string
117
253
  /** Multisample count (1, 2, 4 or 8; default 1): anti-aliased mesh edges.
118
254
  * Fixed at creation. */
@@ -143,38 +279,188 @@ export type SceneProps = {
143
279
  * Children (Mesh/Group/PerspectiveCamera) render nothing themselves - they
144
280
  * populate the retained scene through context.
145
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
+
146
286
  export let Scene: ParentComponent<SceneProps> = props => {
147
- let scene = untrack(() =>
148
- createScene(props.width, props.height, { clearColor: props.clearColor, label: props.label, samples: props.samples }),
149
- )
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
+ })
150
317
  createEffect(
151
318
  () => [props.width, props.height] as const,
152
- ([w, h]) => scene.setSize(w, h),
319
+ ([w, h]) => {
320
+ if ((w === undefined) !== (h === undefined) || (w === undefined) !== fill) {
321
+ throw new Error("Scene: fill/fixed is mount-fixed - width/height cannot appear or disappear")
322
+ }
323
+ if (!fill) scene.setSize(w!, h!)
324
+ },
325
+ )
326
+ createEffect(
327
+ () => props.camera,
328
+ camera => {
329
+ if (camera) scene.setCamera(camera)
330
+ },
331
+ { defer: true },
153
332
  )
154
333
  createEffect(
155
334
  () => props.background,
156
335
  b => scene.setBackground(b ?? null),
336
+ { defer: true },
337
+ )
338
+ createEffect(
339
+ () => props.environment,
340
+ e => scene.setEnvironment(e ?? null),
341
+ { defer: true },
157
342
  )
158
343
  createEffect(
159
344
  () => props.fog,
160
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 },
161
362
  )
162
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
+ }
163
406
  let output = untrack(() => props.output)
164
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)
165
445
  return (
166
- <SceneContext value={{ scene, parent: scene.root }}>
446
+ <SceneContext value={{ scene, parent: scene.root, input }}>
167
447
  {output ? (
168
448
  untrack(() => output(scene.texture))
169
449
  ) : (
170
450
  <texture
451
+ ref={(n: { id: number }) => (leafNode = n)}
171
452
  src={scene.texture}
172
- width={props.width}
173
- height={props.height}
174
- onPointerDown={events ? scene.handlers.onPointerDown : undefined}
175
- onPointerMove={events ? scene.handlers.onPointerMove : undefined}
176
- onPointerUp={events ? scene.handlers.onPointerUp : undefined}
177
- onPointerLeave={events ? scene.handlers.onPointerLeave : undefined}
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}
178
464
  />
179
465
  )}
180
466
  {props.children}
@@ -190,7 +476,7 @@ export let Group: ParentComponent<TransformProps & PointerEventProps & { ref?: (
190
476
  syncNode(node, props)
191
477
  untrack(() => props.ref)?.(node)
192
478
  onCleanup(() => remove(node))
193
- return <SceneContext value={{ scene: ctx.scene, parent: node }}>{props.children}</SceneContext>
479
+ return provide(ctx, node, props)
194
480
  }
195
481
 
196
482
  export type MeshProps = TransformProps & PointerEventProps & {
@@ -205,13 +491,24 @@ export type MeshProps = TransformProps & PointerEventProps & {
205
491
  /** Explicit draw-order key (setRenderOrder as a prop); default 0. */
206
492
  renderOrder?: number
207
493
  /** Draw into the scene's shadow map (setCastShadow as a prop); default
208
- * false. Needs a `castShadow` DirectionalLight to show. */
494
+ * false. Needs a `castShadow` light to show. */
209
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
210
504
  ref?: (mesh: MeshNode) => void
211
505
  }
212
506
 
213
- // The mesh-side props Mesh and Sprite share (Sprite has no geometry).
214
- function syncMesh(mesh: MeshNode, props: SpriteProps): void {
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 {
215
512
  createEffect(
216
513
  () => props.material,
217
514
  m => setMaterial(mesh, m),
@@ -227,9 +524,15 @@ function syncMesh(mesh: MeshNode, props: SpriteProps): void {
227
524
  () => props.renderOrder,
228
525
  o => setRenderOrder(mesh, o ?? 0),
229
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
+ )
230
535
  syncNode(mesh, props)
231
- untrack(() => props.ref)?.(mesh)
232
- onCleanup(() => remove(mesh))
233
536
  }
234
537
 
235
538
  /** One draw entry: geometry drawn with a material at a transform. */
@@ -247,6 +550,8 @@ export let Mesh: VoidComponent<MeshProps> = props => {
247
550
  c => setCastShadow(mesh, c === true),
248
551
  )
249
552
  syncMesh(mesh, props)
553
+ untrack(() => props.ref)?.(mesh)
554
+ onCleanup(() => remove(mesh))
250
555
  return null
251
556
  }
252
557
 
@@ -257,6 +562,12 @@ export type SpriteProps = TransformProps & PointerEventProps & {
257
562
  params?: ShaderParams
258
563
  /** Explicit draw-order key (setRenderOrder as a prop); default 0. */
259
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
260
571
  ref?: (mesh: MeshNode) => void
261
572
  }
262
573
 
@@ -267,12 +578,111 @@ export let Sprite: VoidComponent<SpriteProps> = props => {
267
578
  let mesh = untrack(() => createSprite(props.material))
268
579
  add(ctx.parent, mesh)
269
580
  syncMesh(mesh, props)
581
+ untrack(() => props.ref)?.(mesh)
582
+ onCleanup(() => remove(mesh))
270
583
  return null
271
584
  }
272
585
 
273
- export type InstancedMeshProps = TransformProps & PointerEventProps & {
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 & {
274
684
  geometry: Geometry
275
- /** Must declare instanceAttributes (shaderMaterialClass). */
685
+ /** Must declare instanceAttributes (shaderMaterialClass), slot 0 only. */
276
686
  material: Material
277
687
  /** Interleaved per-instance records (stride = the material's instance
278
688
  * attributes summed). Reactive; a later array larger than the buffer
@@ -284,31 +694,32 @@ export type InstancedMeshProps = TransformProps & PointerEventProps & {
284
694
  * creation. Without them the mesh has no picking leaf, so pointer events
285
695
  * never target it. */
286
696
  bounds?: ArrayLike<number>
287
- /** Per-mesh uniforms, merge semantics - as on Mesh. */
288
- params?: ShaderParams
289
- /** Explicit draw-order key (setRenderOrder as a prop); default 0. */
290
- renderOrder?: number
291
- ref?: (mesh: InstancedMeshNode) => void
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
292
703
  }
293
704
 
294
- /** One draw entry covering N instances: geometry repeated per record of
295
- * `records` (createInstancedMesh as a component). The record buffer is
296
- * component-owned and freed on unmount. */
297
- export let InstancedMesh: VoidComponent<InstancedMeshProps> = props => {
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 => {
298
709
  let ctx = useContext(SceneContext)
299
710
  let mesh = untrack(() =>
300
- createInstancedMesh(props.geometry, props.material, props.records, props.count, { bounds: props.bounds }),
711
+ createRecordMesh(props.geometry, props.material, props.records, props.count, { bounds: props.bounds }),
301
712
  )
302
713
  add(ctx.parent, mesh)
303
714
  createEffect(
304
715
  () => props.records,
305
- r => setInstances(mesh, r, untrack(() => props.count)),
716
+ r => setRecords(mesh, r, untrack(() => props.count)),
306
717
  { defer: true },
307
718
  )
308
719
  createEffect(
309
720
  () => props.count,
310
721
  c => {
311
- if (c !== undefined) setInstanceCount(mesh, c)
722
+ if (c !== undefined) setRecordCount(mesh, c)
312
723
  },
313
724
  { defer: true },
314
725
  )
@@ -318,21 +729,10 @@ export let InstancedMesh: VoidComponent<InstancedMeshProps> = props => {
318
729
  { defer: true },
319
730
  )
320
731
  createEffect(
321
- () => props.material,
322
- m => setMaterial(mesh, m),
323
- { defer: true },
324
- )
325
- createEffect(
326
- () => props.params,
327
- p => {
328
- if (p !== undefined) setMeshParams(mesh, p)
329
- },
330
- )
331
- createEffect(
332
- () => props.renderOrder,
333
- o => setRenderOrder(mesh, o ?? 0),
732
+ () => props.castShadow,
733
+ c => setCastShadow(mesh, c === true),
334
734
  )
335
- syncNode(mesh, props)
735
+ syncMesh(mesh, props)
336
736
  untrack(() => props.ref)?.(mesh)
337
737
  onCleanup(() => disposeInstances(mesh))
338
738
  return null
@@ -351,7 +751,9 @@ export type PerspectiveCameraProps = {
351
751
  /**
352
752
  * Drives the scene's camera from props (the scene has a default camera, so
353
753
  * this component is optional). The camera is scene state, not a tree node:
354
- * to orbit it, update `position`/`lookAt`.
754
+ * to orbit it, update `position`/`lookAt`. The Scene `camera` prop drives
755
+ * the same state (a partial CameraUpdate, ortho included) - use one form,
756
+ * not both.
355
757
  */
356
758
  export let PerspectiveCamera: VoidComponent<PerspectiveCameraProps> = props => {
357
759
  let ctx = useContext(SceneContext)
@@ -363,6 +765,186 @@ export let PerspectiveCamera: VoidComponent<PerspectiveCameraProps> = props => {
363
765
  return null
364
766
  }
365
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
+
366
948
  export type HemisphereLightProps = { sky?: Vec3; ground?: Vec3; intensity?: number; ref?: (light: HemisphereLightNode) => void }
367
949
 
368
950
  /** The scene's ambient term as a node (createHemisphereLight); one per
@@ -418,3 +1000,102 @@ export let DirectionalLight: VoidComponent<DirectionalLightProps> = props => {
418
1000
  onCleanup(() => remove(light))
419
1001
  return null
420
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
+ }