@solidrt/3d 0.0.48 → 0.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/scene.ts CHANGED
@@ -17,16 +17,19 @@
17
17
  // each write lands here, the microtask syncs the affected uModels, and the
18
18
  // flush renders once that frame.
19
19
 
20
- import { addDraw, createDrawTarget, destroyTexture, removeDraw, setDrawParams, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
21
- import type { DrawId, FilterMode, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
20
+ import { addDraw, createDrawTarget, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawParams, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
21
+ import type { DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
22
22
  import { getOwner, onCleanup } from "@solidrt/core"
23
+ import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
23
24
  // The scene's lookAt() aims a node; math's builds a camera's view matrix -
24
25
  // the same pairing (and the same name) as Three's Object3D/Matrix4.
25
- import { compose, copy, eulerFromQuat, identity, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quatFromEuler, quatFromFrame, quatNormalize, transformPoint } from "./math.ts"
26
+ import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quatFromEuler, quatFromFrame, quatNormalize, transformPoint, transformVector } from "./math.ts"
26
27
  import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
27
- import { geometryBuffers } from "./geometry.ts"
28
+ import { geometryBounds, geometryBuffers } from "./geometry.ts"
28
29
  import type { Geometry } from "./geometry.ts"
30
+ import { backgroundPipeline } from "./material.ts"
29
31
  import type { Material } from "./material.ts"
32
+ import { createBvh, rayBoxDistance } from "./bvh.ts"
30
33
 
31
34
  const IDENTITY = mat4()
32
35
  const RESOLVED = Promise.resolve()
@@ -43,6 +46,11 @@ let localScratch = mat4()
43
46
  let pointScratch: Vec4 = [0, 0, 0, 0]
44
47
  let aimScratch: Vec3 = [0, 0, 0]
45
48
  let upScratch: Vec3 = [0, 0, 0]
49
+ // Picking narrowphase scratch: one candidate is tested at a time, so one
50
+ // set serves every raycast.
51
+ let pickInv = mat4()
52
+ let pickOrigin: Vec4 = [0, 0, 0, 0]
53
+ let pickDir: Vec3 = [0, 0, 0]
46
54
 
47
55
  // The scene half a node needs to reach: attach/detach entries and schedule
48
56
  // a sync. Kept separate from the public Scene type so internals stay off
@@ -68,6 +76,17 @@ export type SceneNode = {
68
76
  quaternion: Quat
69
77
  scale: Vec3
70
78
  visible: boolean
79
+ /** Pointer event handlers - plain fields, assign freely (they touch no
80
+ * GPU state, so they need no setTransform-style write path; components
81
+ * sync their props here). Down/move/up dispatch on the hit mesh and
82
+ * bubble through its ancestors (stopPropagation stops the walk);
83
+ * enter/leave fire on the mesh alone. Events flow once the element
84
+ * showing the scene carries `scene.handlers`. */
85
+ onPointerDown?: (event: ScenePointerEvent) => void
86
+ onPointerMove?: (event: ScenePointerEvent) => void
87
+ onPointerUp?: (event: ScenePointerEvent) => void
88
+ onPointerEnter?: (event: ScenePointerEvent) => void
89
+ onPointerLeave?: (event: ScenePointerEvent) => void
71
90
  _localDirty: boolean
72
91
  _local: Mat4
73
92
  _world: Mat4
@@ -82,6 +101,57 @@ export type Mesh = SceneNode & {
82
101
  _hidden: boolean
83
102
  _fresh: boolean
84
103
  _params: ShaderParams | null
104
+ _pickLeaf: number | null
105
+ }
106
+
107
+ /** One picking intersection: the mesh, the camera-ray distance in world
108
+ * units, and the world-space point - Three's intersect result minus the
109
+ * triangle fields (`face`, `uv`), which cannot exist at the volume tier. */
110
+ export type Hit = {
111
+ mesh: Mesh
112
+ distance: number
113
+ point: Vec3
114
+ }
115
+
116
+ /**
117
+ * The event a mesh (or ancestor group) handler receives: the element
118
+ * pointer vocabulary carried over, plus the 3D fields. `point`/`distance`
119
+ * are null exactly when the ray misses the dispatch mesh - which happens
120
+ * only during a captured drag or on a leave.
121
+ */
122
+ export type ScenePointerEvent = {
123
+ /** The mesh the event is about (the hit, or the captured mesh during a
124
+ * drag) - constant while the event bubbles. */
125
+ mesh: Mesh
126
+ /** Node whose handler is running; changes as the event bubbles. */
127
+ currentTarget: SceneNode
128
+ /** World-space hit point on `mesh`, or null when the ray misses it. */
129
+ point: Vec3 | null
130
+ /** Camera-ray distance to `point` in world units, or null with it. */
131
+ distance: number | null
132
+ /** Pointer position in scene pixels - project()'s coordinate space. */
133
+ x: number
134
+ y: number
135
+ pointerId: number
136
+ pointerType: string
137
+ button?: number
138
+ shiftKey: boolean
139
+ ctrlKey: boolean
140
+ altKey: boolean
141
+ metaKey: boolean
142
+ /** Stops the bubble walk after the current handler. */
143
+ stopPropagation(): void
144
+ }
145
+
146
+ /** Element handlers wiring a scene's pointer events: spread onto whatever
147
+ * element shows `scene.texture` (the built-in `<Scene>` leaf wires them
148
+ * automatically). `scene.handlers` expects the leaf laid out at the target
149
+ * size; a split-resolution leaf (supersampling) uses scene.handlersFor. */
150
+ export type SceneHandlers = {
151
+ onPointerDown(event: ElementPointerEvent): void
152
+ onPointerMove(event: ElementPointerEvent): void
153
+ onPointerUp(event: ElementPointerEvent): void
154
+ onPointerLeave(event: ElementPointerEvent): void
85
155
  }
86
156
 
87
157
  export type CameraUpdate = {
@@ -96,6 +166,9 @@ export type CameraUpdate = {
96
166
 
97
167
  export type SceneOptions = {
98
168
  clearColor?: [number, number, number, number]
169
+ /** Fragment GLSL drawn behind the meshes, inside the scene's own pass -
170
+ * see setBackground. */
171
+ background?: string
99
172
  label?: string
100
173
  /** `autoFree: false` opts out of owner-scoped auto-dispose (then call dispose yourself). */
101
174
  autoFree?: boolean
@@ -111,6 +184,20 @@ export type Scene = {
111
184
  /** Partial camera update; absent keys keep their current value. */
112
185
  setCamera(update: CameraUpdate): void
113
186
  setSize(width: number, height: number): void
187
+ /**
188
+ * Set, replace, or remove (null) the scene's background: fragment GLSL
189
+ * drawn as the FIRST entry of the scene's own pass - one target, no
190
+ * second texture layer, no separate resize plumbing. The fragment gets
191
+ * the shader-target contract exactly (vUV 0..1 top-left origin,
192
+ * iResolution, fragColor; no `#version` line means the standard
193
+ * preamble), so a source written for createShaderTexture ports verbatim.
194
+ * It draws with depth off before every mesh and covers the whole target,
195
+ * so the clearColor stops being visible. Three's `scene.background =
196
+ * color` is `clearColor` here; the texture form can arrive later as a
197
+ * non-breaking widening. No app-driven uniforms in v1 - a background is
198
+ * static art (anything animated is a mesh's own shaderMaterial).
199
+ */
200
+ setBackground(source: string | null): void
114
201
  /**
115
202
  * Project a world point to scene pixels: origin top-left, y down - the
116
203
  * output texture's own coordinate space, ready for overlay layout (HUD
@@ -123,6 +210,45 @@ export type Scene = {
123
210
  /** The camera's view-projection matrix, copied into `out` (or a fresh
124
211
  * mat4). The batch escape hatch; for single points use project(). */
125
212
  viewProj(out?: Mat4): Mat4
213
+ /**
214
+ * Cast the camera ray through a scene pixel (top-left origin, y down -
215
+ * project()'s space, the inverse direction) and return every visible
216
+ * mesh it hits, nearest first. The volume tier: hits test the mesh's
217
+ * bounding box, transformed exactly (any node transform, including
218
+ * non-uniform scale), so a hit through a concave gap - a knot's hole -
219
+ * still reports. Broadphase runs over a BVH kept in step by the sync
220
+ * walk: a query costs O(log meshes), not O(meshes). Reflects pending
221
+ * setTransform/add writes immediately (the sync is flushed).
222
+ */
223
+ pick(x: number, y: number): Hit[]
224
+ /** pick()'s world-space half: the same query along an arbitrary ray.
225
+ * `direction` need not be normalized; distances are world units. */
226
+ raycast(origin: Vec3, direction: Vec3): Hit[]
227
+ /**
228
+ * Element pointer handlers driving the mesh event fields
229
+ * (onPointerDown/Move/Up/Enter/Leave on nodes): spread onto the element
230
+ * that shows `scene.texture`. The `<Scene>` component's built-in leaf
231
+ * carries them automatically; with `output` (or imperative use), spread
232
+ * them yourself: `<texture src={scene.texture} {...scene.handlers} />`.
233
+ * Semantics mirror element pointer events: nearest hit wins, down/move/
234
+ * up bubble mesh -> ancestors, pointer-down captures the mesh until up
235
+ * (moves keep flowing to it off-mesh, the platform's captured-drag
236
+ * rule), enter/leave pair on hover changes. Hover reacts to pointer
237
+ * MOTION - a mesh animating under a still pointer fires nothing until
238
+ * the pointer moves (the element hit-test has the same limit).
239
+ *
240
+ * Coordinates assume the leaf is LAID OUT at the target size - true for
241
+ * the built-in leaf and a d-texture at natural size, under any ancestor
242
+ * transforms or viewBox fits (the hit test undoes them). A leaf laid out
243
+ * at a different size needs handlersFor instead.
244
+ */
245
+ handlers: SceneHandlers
246
+ /** handlers for a leaf whose LAYOUT size differs from the target size -
247
+ * the supersampling pattern, where the target renders larger than the
248
+ * box showing it. `layout` is read per event, so a resize-reactive
249
+ * layout just works: `scene.handlersFor(() => ({ width: w(), height:
250
+ * h() }))`. */
251
+ handlersFor(layout: () => { width: number; height: number }): SceneHandlers
126
252
  /** Destroy the target (entries die with it). Idempotent. Geometry
127
253
  * buffers and material pipelines are shared and survive - they are
128
254
  * app-lifetime (see geometry.ts / material.ts). */
@@ -157,6 +283,7 @@ export function createMesh(geometry: Geometry, material: Material): Mesh {
157
283
  mesh._hidden = false
158
284
  mesh._fresh = false
159
285
  mesh._params = null
286
+ mesh._pickLeaf = null
160
287
  return mesh
161
288
  }
162
289
 
@@ -411,6 +538,44 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
411
538
  let disposed = false
412
539
  let scheduled = false
413
540
 
541
+ // Picking state: the broadphase tree over world boxes, kept current by
542
+ // the sync walk (the meshes it touches are exactly the leaves to move),
543
+ // and the pointer bookkeeping behind scene.handlers.
544
+ let bvh = createBvh<Mesh>()
545
+ let capture = new Map<number, Mesh>()
546
+ let hover = new Map<number, Mesh>()
547
+
548
+ // Live mesh entries in list (draw) order, so a background set after
549
+ // meshes exist can insert BEFORE the first one. Mesh entries append;
550
+ // rebuilds re-append; the background entry never joins this list.
551
+ let entryOrder: DrawId[] = []
552
+ let background: { entry: DrawId; pipeline: RenderPipelineId; program: ProgramId } | null = null
553
+
554
+ // Reinsert or refit a mesh's broadphase leaf from its fresh world matrix:
555
+ // the local box's center/extents carried through the absolute matrix (the
556
+ // standard tight-AABB-of-a-transformed-AABB construction).
557
+ let updateLeaf = (mesh: Mesh): void => {
558
+ let b = geometryBounds(mesh.geometry)
559
+ let m = mesh._world
560
+ let cx = (b[0]! + b[3]!) / 2
561
+ let cy = (b[1]! + b[4]!) / 2
562
+ let cz = (b[2]! + b[5]!) / 2
563
+ let ex = (b[3]! - b[0]!) / 2
564
+ let ey = (b[4]! - b[1]!) / 2
565
+ let ez = (b[5]! - b[2]!) / 2
566
+ let wx = m[0] * cx + m[4] * cy + m[8] * cz + m[12]
567
+ let wy = m[1] * cx + m[5] * cy + m[9] * cz + m[13]
568
+ let wz = m[2] * cx + m[6] * cy + m[10] * cz + m[14]
569
+ let rx = Math.abs(m[0]) * ex + Math.abs(m[4]) * ey + Math.abs(m[8]) * ez
570
+ let ry = Math.abs(m[1]) * ex + Math.abs(m[5]) * ey + Math.abs(m[9]) * ez
571
+ let rz = Math.abs(m[2]) * ex + Math.abs(m[6]) * ey + Math.abs(m[10]) * ez
572
+ if (mesh._pickLeaf === null) {
573
+ mesh._pickLeaf = bvh.insert(mesh, wx - rx, wy - ry, wz - rz, wx + rx, wy + ry, wz + rz)
574
+ } else {
575
+ bvh.update(mesh._pickLeaf, wx - rx, wy - ry, wz - rz, wx + rx, wy + ry, wz + rz)
576
+ }
577
+ }
578
+
414
579
  let fov = 60
415
580
  let near = 0.1
416
581
  let far = 100
@@ -481,6 +646,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
481
646
  // Moved while hidden: write the fresh matrix on unhide.
482
647
  mesh._fresh = true
483
648
  }
649
+ // The broadphase leaf follows the world matrix - hidden meshes
650
+ // included (they stay in the tree and are skipped at query time,
651
+ // so unhiding never picks against a stale box).
652
+ if (changed || mesh._pickLeaf === null) updateLeaf(mesh)
484
653
  }
485
654
  }
486
655
  for (let c of node.children) walk(c, changed, shown)
@@ -514,19 +683,36 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
514
683
  let seed: ShaderParams = mesh.material.normalMatrix
515
684
  ? { uModel: IDENTITY, uNormal: IDENTITY, ...mesh.material.params, ...mesh._params }
516
685
  : { uModel: IDENTITY, ...mesh.material.params, ...mesh._params }
686
+ // The entry starts switched off: it has no world matrix yet - the walk
687
+ // in sync() computes one - and _schedule() defers that to a microtask,
688
+ // so added live it would draw at the seeded identity until then. The
689
+ // mismatch branch in sync() turns it on in the same pass that writes
690
+ // uModel.
517
691
  mesh._entry = addDraw(texture, mesh.material.pipeline(), seed, {
518
692
  buffer: bufs.buffer,
519
693
  indexBuffer: bufs.index,
520
694
  indexFormat: bufs.indexFormat,
521
695
  textures: mesh.material.textures,
696
+ instanceCount: 0,
522
697
  })
523
- mesh._hidden = false
698
+ entryOrder.push(mesh._entry)
699
+ mesh._hidden = true
524
700
  mesh._fresh = true
525
701
  this._schedule()
526
702
  },
527
703
  _detach(mesh) {
528
- if (mesh._entry !== null && !disposed) removeDraw(texture, mesh._entry)
704
+ if (mesh._entry !== null) {
705
+ if (!disposed) removeDraw(texture, mesh._entry)
706
+ let i = entryOrder.indexOf(mesh._entry)
707
+ if (i >= 0) entryOrder.splice(i, 1)
708
+ }
529
709
  mesh._entry = null
710
+ // The leaf goes with the entry: a geometry swap rebuilds the entry,
711
+ // and re-inserting is what picks up the new local bounds.
712
+ if (mesh._pickLeaf !== null) {
713
+ bvh.remove(mesh._pickLeaf)
714
+ mesh._pickLeaf = null
715
+ }
530
716
  },
531
717
  _setParams(mesh, params) {
532
718
  if (mesh._entry !== null && !disposed) setDrawParams(texture, mesh._entry, params)
@@ -536,6 +722,127 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
536
722
  let root = makeNode("group")
537
723
  root._scene = hooks
538
724
 
725
+ // --- Pointer event dispatch (behind scene.handlers) ---
726
+
727
+ type BubbleName = "onPointerDown" | "onPointerMove" | "onPointerUp"
728
+ type InternalEvent = ScenePointerEvent & { _stopped: boolean }
729
+
730
+ let makeEvent = (e: ElementPointerEvent, mesh: Mesh, x: number, y: number, point: Vec3 | null, distance: number | null): InternalEvent => {
731
+ let event: InternalEvent = {
732
+ mesh,
733
+ currentTarget: mesh,
734
+ point,
735
+ distance,
736
+ x,
737
+ y,
738
+ pointerId: e.pointerId,
739
+ pointerType: e.pointerType,
740
+ button: e.button,
741
+ shiftKey: e.shiftKey,
742
+ ctrlKey: e.ctrlKey,
743
+ altKey: e.altKey,
744
+ metaKey: e.metaKey,
745
+ _stopped: false,
746
+ stopPropagation() {
747
+ event._stopped = true
748
+ },
749
+ }
750
+ return event
751
+ }
752
+
753
+ let bubble = (name: BubbleName, event: InternalEvent): void => {
754
+ for (let n: SceneNode | null = event.mesh; n !== null && !event._stopped; n = n.parent) {
755
+ let handler = n[name]
756
+ if (handler) {
757
+ event.currentTarget = n
758
+ handler(event)
759
+ }
760
+ }
761
+ }
762
+
763
+ // The captured mesh's own hit, if the ray still strikes it.
764
+ let hitOn = (mesh: Mesh, x: number, y: number): Hit | null => {
765
+ for (let h of scene.pick(x, y)) if (h.mesh === mesh) return h
766
+ return null
767
+ }
768
+
769
+ // localX/localY arrive in the leaf's LAYOUT frame (the hit test undoes
770
+ // every transform above it, viewBox fits included), so a leaf laid out at
771
+ // the target size - the built-in <Scene> leaf, a d-texture at natural
772
+ // size - is already in scene pixels. Only a leaf deliberately laid out at
773
+ // a DIFFERENT size (the supersampling pattern) needs the ratio, and only
774
+ // the app knows that layout: handlersFor takes it.
775
+ let makeHandlers = (layout: (() => { width: number; height: number }) | null): SceneHandlers => {
776
+ let eventX = 0
777
+ let eventY = 0
778
+ let toScene = (e: ElementPointerEvent): void => {
779
+ if (layout === null) {
780
+ eventX = e.localX
781
+ eventY = e.localY
782
+ return
783
+ }
784
+ let l = layout()
785
+ eventX = e.localX * (l.width > 0 ? width / l.width : 1)
786
+ eventY = e.localY * (l.height > 0 ? height / l.height : 1)
787
+ }
788
+ return {
789
+ onPointerDown(e) {
790
+ toScene(e)
791
+ let hit = scene.pick(eventX, eventY)[0]
792
+ if (hit === undefined) return
793
+ capture.set(e.pointerId, hit.mesh)
794
+ bubble("onPointerDown", makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
795
+ },
796
+ onPointerMove(e) {
797
+ toScene(e)
798
+ let captured = capture.get(e.pointerId)
799
+ if (captured !== undefined) {
800
+ let hit = hitOn(captured, eventX, eventY)
801
+ bubble("onPointerMove", makeEvent(e, captured, eventX, eventY, hit ? hit.point : null, hit ? hit.distance : null))
802
+ return
803
+ }
804
+ let hit = scene.pick(eventX, eventY)[0]
805
+ let prev = hover.get(e.pointerId)
806
+ if (prev !== hit?.mesh) {
807
+ if (prev !== undefined) {
808
+ hover.delete(e.pointerId)
809
+ prev.onPointerLeave?.(makeEvent(e, prev, eventX, eventY, null, null))
810
+ }
811
+ if (hit !== undefined) {
812
+ hover.set(e.pointerId, hit.mesh)
813
+ hit.mesh.onPointerEnter?.(makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
814
+ }
815
+ }
816
+ if (hit !== undefined) {
817
+ bubble("onPointerMove", makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
818
+ }
819
+ },
820
+ onPointerUp(e) {
821
+ toScene(e)
822
+ let captured = capture.get(e.pointerId)
823
+ if (captured !== undefined) {
824
+ capture.delete(e.pointerId)
825
+ let hit = hitOn(captured, eventX, eventY)
826
+ bubble("onPointerUp", makeEvent(e, captured, eventX, eventY, hit ? hit.point : null, hit ? hit.distance : null))
827
+ return
828
+ }
829
+ let hit = scene.pick(eventX, eventY)[0]
830
+ if (hit !== undefined) {
831
+ bubble("onPointerUp", makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
832
+ }
833
+ },
834
+ onPointerLeave(e) {
835
+ let prev = hover.get(e.pointerId)
836
+ if (prev !== undefined) {
837
+ hover.delete(e.pointerId)
838
+ toScene(e)
839
+ prev.onPointerLeave?.(makeEvent(e, prev, eventX, eventY, null, null))
840
+ }
841
+ },
842
+ }
843
+ }
844
+ let handlers = makeHandlers(null)
845
+
539
846
  let scene: Scene = {
540
847
  texture,
541
848
  root,
@@ -557,6 +864,21 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
557
864
  cameraDirty = true
558
865
  hooks._schedule()
559
866
  },
867
+ setBackground(source) {
868
+ if (disposed) return
869
+ if (background !== null) {
870
+ removeDraw(texture, background.entry)
871
+ destroyRenderPipeline(background.pipeline)
872
+ destroyProgram(background.program)
873
+ background = null
874
+ }
875
+ if (source === null) return
876
+ let built = backgroundPipeline(source, (opts?.label ?? "scene") + "-background")
877
+ // First in list order: meshes append behind it forever (rebuilds
878
+ // re-append too), so pinning happens once, here.
879
+ let entry = addDraw(texture, built.pipeline, null, { vertexCount: 3, before: entryOrder[0] })
880
+ background = { entry, pipeline: built.pipeline, program: built.program }
881
+ },
560
882
  project(point) {
561
883
  ensureCamera()
562
884
  transformPoint(clip, viewProj, point)
@@ -570,12 +892,79 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
570
892
  ensureCamera()
571
893
  return copy(out ?? mat4(), viewProj)
572
894
  },
895
+ pick(x, y) {
896
+ ensureCamera()
897
+ // The camera-frame ray through the pixel, inverting project()'s
898
+ // mapping: the baked y-down clip flip is why pixel y converts with
899
+ // no negation there and one here.
900
+ let f = 1 / Math.tan(((fov * Math.PI) / 180) / 2)
901
+ let cx = (((x / width) * 2 - 1) * (width / height)) / f
902
+ let cy = -((y / height) * 2 - 1) / f
903
+ // The view's upper 3x3 rows are the camera axes, so its transpose
904
+ // carries the camera-space direction (cx, cy, -1) to world.
905
+ pickDir[0] = cx * view[0] + cy * view[1] - view[2]
906
+ pickDir[1] = cx * view[4] + cy * view[5] - view[6]
907
+ pickDir[2] = cx * view[8] + cy * view[9] - view[10]
908
+ return scene.raycast(eye, pickDir)
909
+ },
910
+ raycast(origin, direction) {
911
+ // Flush pending writes: picking sees the tree as the app just wrote
912
+ // it, the same immediacy contract as lookAt()/project(). (The queued
913
+ // microtask still runs and finds nothing dirty - harmless.)
914
+ if (scheduled) sync()
915
+ let dx = direction[0]
916
+ let dy = direction[1]
917
+ let dz = direction[2]
918
+ let len = Math.hypot(dx, dy, dz)
919
+ if (len === 0 || disposed) return []
920
+ dx /= len
921
+ dy /= len
922
+ dz /= len
923
+ let ox = origin[0]
924
+ let oy = origin[1]
925
+ let oz = origin[2]
926
+ let hits: Hit[] = []
927
+ bvh.raycast(ox, oy, oz, dx, dy, dz, mesh => {
928
+ if (mesh._hidden || mesh._entry === null) return
929
+ // Narrowphase: the ray in the mesh's local frame against its tight
930
+ // local box - exact under any affine world transform. The local
931
+ // direction stays unnormalized on purpose: an affine map preserves
932
+ // the ray parameter, so t is world units as-is.
933
+ invertAffine(pickInv, mesh._world)
934
+ transformPoint(pickOrigin, pickInv, origin)
935
+ pickDir[0] = dx
936
+ pickDir[1] = dy
937
+ pickDir[2] = dz
938
+ transformVector(pickDir, pickInv, pickDir)
939
+ let b = geometryBounds(mesh.geometry)
940
+ let t = rayBoxDistance(
941
+ pickOrigin[0], pickOrigin[1], pickOrigin[2],
942
+ pickDir[0], pickDir[1], pickDir[2],
943
+ b[0]!, b[1]!, b[2]!, b[3]!, b[4]!, b[5]!,
944
+ )
945
+ if (t >= 0) hits.push({ mesh, distance: t, point: [ox + dx * t, oy + dy * t, oz + dz * t] })
946
+ })
947
+ hits.sort((a, b) => a.distance - b.distance)
948
+ return hits
949
+ },
950
+ handlers,
951
+ handlersFor(layout) {
952
+ return makeHandlers(layout)
953
+ },
573
954
  dispose() {
574
955
  if (disposed) return
575
956
  disposed = true
576
957
  destroyTexture(texture)
958
+ if (background !== null) {
959
+ // The entry died with the target; the pipeline and program are the
960
+ // scene's own (unlike shared material pipelines), so they go too.
961
+ destroyRenderPipeline(background.pipeline)
962
+ destroyProgram(background.program)
963
+ background = null
964
+ }
577
965
  },
578
966
  }
967
+ if (opts?.background !== undefined) scene.setBackground(opts.background)
579
968
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => scene.dispose())
580
969
  return scene
581
970
  }