@solidrt/3d 0.0.48 → 0.0.50

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
@@ -3,10 +3,12 @@
3
3
  // component boundary (components.tsx). A scene compiles to one draw
4
4
  // target: every mesh is one draw entry whose uModel (and, for materials
5
5
  // declaring it, uNormal) this module keeps in step with the tree, and the
6
- // camera is the target's SHARED uViewProj + uCamPos - one setTargetParams
7
- // per camera move, not one write per mesh. uCamPos rides unconditionally:
8
- // shared params tolerate zero coverage (stored and skipped until a
9
- // declaring material arrives), so no bookkeeping tracks who reads it.
6
+ // camera is the target's SHARED uViewProj + uCamPos + uCamRight/uCamUp -
7
+ // one setTargetParams per camera move, not one write per mesh. The
8
+ // non-matrix names ride unconditionally: shared params tolerate zero
9
+ // coverage (stored and skipped until a declaring material arrives), so no
10
+ // bookkeeping tracks who reads them. scene.setParams merges app-owned
11
+ // names into the same set.
10
12
  // Mutations batch to a microtask, so a burst of writes (a whole subtree
11
13
  // moved, many effects in one flush) syncs once.
12
14
  //
@@ -17,16 +19,20 @@
17
19
  // each write lands here, the microtask syncs the affected uModels, and the
18
20
  // flush renders once that frame.
19
21
 
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"
22
+ import { addDraw, createDrawTarget, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawOrder, setDrawParams, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
23
+ import type { DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
22
24
  import { getOwner, onCleanup } from "@solidrt/core"
25
+ import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
23
26
  // The scene's lookAt() aims a node; math's builds a camera's view matrix -
24
27
  // 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"
28
+ import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quat, quatFromEuler, quatFromFrame, quatNormalize, transformPoint, transformVector } from "./math.ts"
26
29
  import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
27
- import { geometryBuffers } from "./geometry.ts"
30
+ import { geometryBounds, geometryBuffers } from "./geometry.ts"
28
31
  import type { Geometry } from "./geometry.ts"
32
+ import { backgroundPipeline } from "./material.ts"
33
+ import { orderEntries } from "./order.ts"
29
34
  import type { Material } from "./material.ts"
35
+ import { createBvh, rayBoxDistance } from "./bvh.ts"
30
36
 
31
37
  const IDENTITY = mat4()
32
38
  const RESOLVED = Promise.resolve()
@@ -43,6 +49,14 @@ let localScratch = mat4()
43
49
  let pointScratch: Vec4 = [0, 0, 0, 0]
44
50
  let aimScratch: Vec3 = [0, 0, 0]
45
51
  let upScratch: Vec3 = [0, 0, 0]
52
+ // Picking narrowphase scratch: one candidate is tested at a time, so one
53
+ // set serves every raycast.
54
+ let pickInv = mat4()
55
+ let pickOrigin: Vec4 = [0, 0, 0, 0]
56
+ let pickDir: Vec3 = [0, 0, 0]
57
+ // setTransform's rotation compare happens AFTER conversion, so an euler and
58
+ // the quaternion it produces are the same write. Nothing outlives the call.
59
+ let rotScratch = quat()
46
60
 
47
61
  // The scene half a node needs to reach: attach/detach entries and schedule
48
62
  // a sync. Kept separate from the public Scene type so internals stay off
@@ -54,6 +68,7 @@ type SceneHooks = {
54
68
  _attach(mesh: Mesh): void
55
69
  _detach(mesh: Mesh): void
56
70
  _setParams(mesh: Mesh, params: ShaderParams): void
71
+ _reorder(): void
57
72
  }
58
73
 
59
74
  export type SceneNode = {
@@ -68,6 +83,17 @@ export type SceneNode = {
68
83
  quaternion: Quat
69
84
  scale: Vec3
70
85
  visible: boolean
86
+ /** Pointer event handlers - plain fields, assign freely (they touch no
87
+ * GPU state, so they need no setTransform-style write path; components
88
+ * sync their props here). Down/move/up dispatch on the hit mesh and
89
+ * bubble through its ancestors (stopPropagation stops the walk);
90
+ * enter/leave fire on the mesh alone. Events flow once the element
91
+ * showing the scene carries `scene.handlers`. */
92
+ onPointerDown?: (event: ScenePointerEvent) => void
93
+ onPointerMove?: (event: ScenePointerEvent) => void
94
+ onPointerUp?: (event: ScenePointerEvent) => void
95
+ onPointerEnter?: (event: ScenePointerEvent) => void
96
+ onPointerLeave?: (event: ScenePointerEvent) => void
71
97
  _localDirty: boolean
72
98
  _local: Mat4
73
99
  _world: Mat4
@@ -78,10 +104,72 @@ export type Mesh = SceneNode & {
78
104
  kind: "mesh"
79
105
  geometry: Geometry
80
106
  material: Material
107
+ /** Explicit draw-order key (default 0), Three's name: lower draws first.
108
+ * Sorts within the opaque group and within the transparent group; the
109
+ * transparent group always follows the opaque one. Set with setRenderOrder. */
110
+ renderOrder: number
81
111
  _entry: DrawId | null
112
+ /** material.transparent as of the last attach - the entry's actual
113
+ * pipeline state, and what _detach counts against (setMaterial swaps
114
+ * mesh.material before the rebuild). */
115
+ _transparent: boolean
116
+ /** World-space center of the geometry bounds, kept by the sync walk
117
+ * beside the picking leaf: the transparent sort key. */
118
+ _center: Vec3
82
119
  _hidden: boolean
83
120
  _fresh: boolean
84
121
  _params: ShaderParams | null
122
+ _pickLeaf: number | null
123
+ }
124
+
125
+ /** One picking intersection: the mesh, the camera-ray distance in world
126
+ * units, and the world-space point - Three's intersect result minus the
127
+ * triangle fields (`face`, `uv`), which cannot exist at the volume tier. */
128
+ export type Hit = {
129
+ mesh: Mesh
130
+ distance: number
131
+ point: Vec3
132
+ }
133
+
134
+ /**
135
+ * The event a mesh (or ancestor group) handler receives: the element
136
+ * pointer vocabulary carried over, plus the 3D fields. `point`/`distance`
137
+ * are null exactly when the ray misses the dispatch mesh - which happens
138
+ * only during a captured drag or on a leave.
139
+ */
140
+ export type ScenePointerEvent = {
141
+ /** The mesh the event is about (the hit, or the captured mesh during a
142
+ * drag) - constant while the event bubbles. */
143
+ mesh: Mesh
144
+ /** Node whose handler is running; changes as the event bubbles. */
145
+ currentTarget: SceneNode
146
+ /** World-space hit point on `mesh`, or null when the ray misses it. */
147
+ point: Vec3 | null
148
+ /** Camera-ray distance to `point` in world units, or null with it. */
149
+ distance: number | null
150
+ /** Pointer position in scene pixels - project()'s coordinate space. */
151
+ x: number
152
+ y: number
153
+ pointerId: number
154
+ pointerType: string
155
+ button?: number
156
+ shiftKey: boolean
157
+ ctrlKey: boolean
158
+ altKey: boolean
159
+ metaKey: boolean
160
+ /** Stops the bubble walk after the current handler. */
161
+ stopPropagation(): void
162
+ }
163
+
164
+ /** Element handlers wiring a scene's pointer events: spread onto whatever
165
+ * element shows `scene.texture` (the built-in `<Scene>` leaf wires them
166
+ * automatically). `scene.handlers` expects the leaf laid out at the target
167
+ * size; a split-resolution leaf (supersampling) uses scene.handlersFor. */
168
+ export type SceneHandlers = {
169
+ onPointerDown(event: ElementPointerEvent): void
170
+ onPointerMove(event: ElementPointerEvent): void
171
+ onPointerUp(event: ElementPointerEvent): void
172
+ onPointerLeave(event: ElementPointerEvent): void
85
173
  }
86
174
 
87
175
  export type CameraUpdate = {
@@ -96,6 +184,9 @@ export type CameraUpdate = {
96
184
 
97
185
  export type SceneOptions = {
98
186
  clearColor?: [number, number, number, number]
187
+ /** Fragment GLSL drawn behind the meshes, inside the scene's own pass -
188
+ * see setBackground. */
189
+ background?: string
99
190
  label?: string
100
191
  /** `autoFree: false` opts out of owner-scoped auto-dispose (then call dispose yourself). */
101
192
  autoFree?: boolean
@@ -111,6 +202,29 @@ export type Scene = {
111
202
  /** Partial camera update; absent keys keep their current value. */
112
203
  setCamera(update: CameraUpdate): void
113
204
  setSize(width: number, height: number): void
205
+ /**
206
+ * Scene-wide uniforms: merge app-owned names into the target's SHARED
207
+ * params, beside the standard uViewProj/uCamPos/uCamRight/uCamUp the
208
+ * camera writes. One write per frame however many meshes read the name
209
+ * (a clock, a sun direction, fog) - the per-mesh channel is
210
+ * setMeshParams. Merge semantics, no unset; a material that does not
211
+ * declare a name simply skips it. Frame-rate-safe like setTransform.
212
+ */
213
+ setParams(params: ShaderParams): void
214
+ /**
215
+ * Set, replace, or remove (null) the scene's background: fragment GLSL
216
+ * drawn as the FIRST entry of the scene's own pass - one target, no
217
+ * second texture layer, no separate resize plumbing. The fragment gets
218
+ * the shader-target contract exactly (vUV 0..1 top-left origin,
219
+ * iResolution, fragColor; no `#version` line means the standard
220
+ * preamble), so a source written for createShaderTexture ports verbatim.
221
+ * It draws with depth off before every mesh and covers the whole target,
222
+ * so the clearColor stops being visible. Three's `scene.background =
223
+ * color` is `clearColor` here; the texture form can arrive later as a
224
+ * non-breaking widening. No app-driven uniforms in v1 - a background is
225
+ * static art (anything animated is a mesh's own shaderMaterial).
226
+ */
227
+ setBackground(source: string | null): void
114
228
  /**
115
229
  * Project a world point to scene pixels: origin top-left, y down - the
116
230
  * output texture's own coordinate space, ready for overlay layout (HUD
@@ -123,6 +237,45 @@ export type Scene = {
123
237
  /** The camera's view-projection matrix, copied into `out` (or a fresh
124
238
  * mat4). The batch escape hatch; for single points use project(). */
125
239
  viewProj(out?: Mat4): Mat4
240
+ /**
241
+ * Cast the camera ray through a scene pixel (top-left origin, y down -
242
+ * project()'s space, the inverse direction) and return every visible
243
+ * mesh it hits, nearest first. The volume tier: hits test the mesh's
244
+ * bounding box, transformed exactly (any node transform, including
245
+ * non-uniform scale), so a hit through a concave gap - a knot's hole -
246
+ * still reports. Broadphase runs over a BVH kept in step by the sync
247
+ * walk: a query costs O(log meshes), not O(meshes). Reflects pending
248
+ * setTransform/add writes immediately (the sync is flushed).
249
+ */
250
+ pick(x: number, y: number): Hit[]
251
+ /** pick()'s world-space half: the same query along an arbitrary ray.
252
+ * `direction` need not be normalized; distances are world units. */
253
+ raycast(origin: Vec3, direction: Vec3): Hit[]
254
+ /**
255
+ * Element pointer handlers driving the mesh event fields
256
+ * (onPointerDown/Move/Up/Enter/Leave on nodes): spread onto the element
257
+ * that shows `scene.texture`. The `<Scene>` component's built-in leaf
258
+ * carries them automatically; with `output` (or imperative use), spread
259
+ * them yourself: `<texture src={scene.texture} {...scene.handlers} />`.
260
+ * Semantics mirror element pointer events: nearest hit wins, down/move/
261
+ * up bubble mesh -> ancestors, pointer-down captures the mesh until up
262
+ * (moves keep flowing to it off-mesh, the platform's captured-drag
263
+ * rule), enter/leave pair on hover changes. Hover reacts to pointer
264
+ * MOTION - a mesh animating under a still pointer fires nothing until
265
+ * the pointer moves (the element hit-test has the same limit).
266
+ *
267
+ * Coordinates assume the leaf is LAID OUT at the target size - true for
268
+ * the built-in leaf and a d-texture at natural size, under any ancestor
269
+ * transforms or viewBox fits (the hit test undoes them). A leaf laid out
270
+ * at a different size needs handlersFor instead.
271
+ */
272
+ handlers: SceneHandlers
273
+ /** handlers for a leaf whose LAYOUT size differs from the target size -
274
+ * the supersampling pattern, where the target renders larger than the
275
+ * box showing it. `layout` is read per event, so a resize-reactive
276
+ * layout just works: `scene.handlersFor(() => ({ width: w(), height:
277
+ * h() }))`. */
278
+ handlersFor(layout: () => { width: number; height: number }): SceneHandlers
126
279
  /** Destroy the target (entries die with it). Idempotent. Geometry
127
280
  * buffers and material pipelines are shared and survive - they are
128
281
  * app-lifetime (see geometry.ts / material.ts). */
@@ -153,10 +306,14 @@ export function createMesh(geometry: Geometry, material: Material): Mesh {
153
306
  let mesh = makeNode("mesh") as Mesh
154
307
  mesh.geometry = geometry
155
308
  mesh.material = material
309
+ mesh.renderOrder = 0
156
310
  mesh._entry = null
311
+ mesh._transparent = false
312
+ mesh._center = [0, 0, 0]
157
313
  mesh._hidden = false
158
314
  mesh._fresh = false
159
315
  mesh._params = null
316
+ mesh._pickLeaf = null
160
317
  return mesh
161
318
  }
162
319
 
@@ -213,33 +370,55 @@ export type TransformUpdate = {
213
370
  * Values are copied in; absent keys keep their current value. This is also
214
371
  * the frame-rate escape hatch: call it from onFrame on a node grabbed via
215
372
  * `ref`, bypassing signals entirely.
373
+ *
374
+ * A write that changes nothing schedules nothing, so driving every node
375
+ * unconditionally from onFrame costs only the compare for the nodes that
376
+ * did not move. Rotation is compared after conversion, so passing an euler
377
+ * equal to the node's current quaternion is also a no-op.
216
378
  */
217
379
  export function setTransform(node: SceneNode, update: TransformUpdate): void {
380
+ let r = update.rotation
381
+ let q = update.quaternion
382
+ if (r !== undefined && q !== undefined) {
383
+ throw new Error("Pass rotation or quaternion to setTransform, not both")
384
+ }
385
+ // A no-op write costs nothing: driving every node from onFrame is the
386
+ // intended shape, and most nodes did not move. Exact compares, like
387
+ // setVisible - a value that survives a float round trip unchanged is the
388
+ // same value, and an epsilon would need a scale-dependent one anyway.
389
+ let changed = false
218
390
  let p = update.position
219
- if (p) {
391
+ if (p && (p[0] !== node.position[0] || p[1] !== node.position[1] || p[2] !== node.position[2])) {
220
392
  node.position[0] = p[0]
221
393
  node.position[1] = p[1]
222
394
  node.position[2] = p[2]
395
+ changed = true
223
396
  }
224
- let r = update.rotation
225
- let q = update.quaternion
226
- if (r !== undefined && q !== undefined) {
227
- throw new Error("Pass rotation or quaternion to setTransform, not both")
397
+ if (r !== undefined) quatFromEuler(rotScratch, r)
398
+ else if (q !== undefined) quatNormalize(rotScratch, q)
399
+ if (r !== undefined || q !== undefined) {
400
+ let n = node.quaternion
401
+ if (rotScratch[0] !== n[0] || rotScratch[1] !== n[1] || rotScratch[2] !== n[2] || rotScratch[3] !== n[3]) {
402
+ n[0] = rotScratch[0]
403
+ n[1] = rotScratch[1]
404
+ n[2] = rotScratch[2]
405
+ n[3] = rotScratch[3]
406
+ changed = true
407
+ }
228
408
  }
229
- if (r !== undefined) quatFromEuler(node.quaternion, r)
230
- else if (q !== undefined) quatNormalize(node.quaternion, q)
231
409
  let s = update.scale
232
410
  if (s !== undefined) {
233
- if (typeof s === "number") {
234
- node.scale[0] = s
235
- node.scale[1] = s
236
- node.scale[2] = s
237
- } else {
238
- node.scale[0] = s[0]
239
- node.scale[1] = s[1]
240
- node.scale[2] = s[2]
411
+ let sx = typeof s === "number" ? s : s[0]
412
+ let sy = typeof s === "number" ? s : s[1]
413
+ let sz = typeof s === "number" ? s : s[2]
414
+ if (sx !== node.scale[0] || sy !== node.scale[1] || sz !== node.scale[2]) {
415
+ node.scale[0] = sx
416
+ node.scale[1] = sy
417
+ node.scale[2] = sz
418
+ changed = true
241
419
  }
242
420
  }
421
+ if (!changed) return
243
422
  node._localDirty = true
244
423
  node._scene?._schedule()
245
424
  }
@@ -355,8 +534,15 @@ export function setVisible(node: SceneNode, visible: boolean): void {
355
534
  node._scene?._schedule()
356
535
  }
357
536
 
358
- /** Swap a mesh's geometry: its draw entry is rebuilt (appended last -
359
- * order is irrelevant while every entry is opaque and depth-tested). */
537
+ /** Set a mesh's explicit draw-order key (see Mesh.renderOrder). */
538
+ export function setRenderOrder(mesh: Mesh, order: number): void {
539
+ if (mesh.renderOrder === order) return
540
+ mesh.renderOrder = order
541
+ mesh._scene?._reorder()
542
+ }
543
+
544
+ /** Swap a mesh's geometry: its draw entry is rebuilt (the scene re-sorts
545
+ * the list, so the mesh keeps its place). */
360
546
  export function setGeometry(mesh: Mesh, geometry: Geometry): void {
361
547
  if (mesh.geometry === geometry) return
362
548
  mesh.geometry = geometry
@@ -411,6 +597,61 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
411
597
  let disposed = false
412
598
  let scheduled = false
413
599
 
600
+ // Picking state: the broadphase tree over world boxes, kept current by
601
+ // the sync walk (the meshes it touches are exactly the leaves to move),
602
+ // and the pointer bookkeeping behind scene.handlers.
603
+ let bvh = createBvh<Mesh>()
604
+ let capture = new Map<number, Mesh>()
605
+ let hover = new Map<number, Mesh>()
606
+
607
+ // Live meshes (those holding a draw entry) in add order; the background
608
+ // entry never joins this list. Draw order is derived from it by
609
+ // orderEntries (order.ts) whenever orderDirty. Camera moves and
610
+ // transparent-mesh moves only dirty the order when two or more transparent
611
+ // meshes exist - fewer cannot change relative order.
612
+ let meshes: Mesh[] = []
613
+ let transparentCount = 0
614
+ let orderDirty = false
615
+ // The order last handed to the engine: a resort that lands on the same
616
+ // permutation (the common case under a moving camera) issues nothing.
617
+ let lastOrder: DrawId[] = []
618
+ let background: { entry: DrawId; pipeline: RenderPipelineId; program: ProgramId } | null = null
619
+ let sortEntries = () => {
620
+ orderDirty = false
621
+ let order = orderEntries(meshes, view, background?.entry)
622
+ if (order.length === lastOrder.length && order.every((id, i) => id === lastOrder[i])) return
623
+ lastOrder = order
624
+ setDrawOrder(texture, order)
625
+ }
626
+
627
+ // Reinsert or refit a mesh's broadphase leaf from its fresh world matrix:
628
+ // the local box's center/extents carried through the absolute matrix (the
629
+ // standard tight-AABB-of-a-transformed-AABB construction).
630
+ let updateLeaf = (mesh: Mesh): void => {
631
+ let b = geometryBounds(mesh.geometry)
632
+ let m = mesh._world
633
+ let cx = (b[0]! + b[3]!) / 2
634
+ let cy = (b[1]! + b[4]!) / 2
635
+ let cz = (b[2]! + b[5]!) / 2
636
+ let ex = (b[3]! - b[0]!) / 2
637
+ let ey = (b[4]! - b[1]!) / 2
638
+ let ez = (b[5]! - b[2]!) / 2
639
+ let wx = m[0] * cx + m[4] * cy + m[8] * cz + m[12]
640
+ let wy = m[1] * cx + m[5] * cy + m[9] * cz + m[13]
641
+ let wz = m[2] * cx + m[6] * cy + m[10] * cz + m[14]
642
+ mesh._center[0] = wx
643
+ mesh._center[1] = wy
644
+ mesh._center[2] = wz
645
+ let rx = Math.abs(m[0]) * ex + Math.abs(m[4]) * ey + Math.abs(m[8]) * ez
646
+ let ry = Math.abs(m[1]) * ex + Math.abs(m[5]) * ey + Math.abs(m[9]) * ez
647
+ let rz = Math.abs(m[2]) * ex + Math.abs(m[6]) * ey + Math.abs(m[10]) * ez
648
+ if (mesh._pickLeaf === null) {
649
+ mesh._pickLeaf = bvh.insert(mesh, wx - rx, wy - ry, wz - rz, wx + rx, wy + ry, wz + rz)
650
+ } else {
651
+ bvh.update(mesh._pickLeaf, wx - rx, wy - ry, wz - rz, wx + rx, wy + ry, wz + rz)
652
+ }
653
+ }
654
+
414
655
  let fov = 60
415
656
  let near = 0.1
416
657
  let far = 100
@@ -445,7 +686,16 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
445
686
  // holds. Entries are untouched - uModel is camera-independent, and
446
687
  // uCamPos is stored even when no current material declares it.
447
688
  cameraPending = false
448
- setTargetParams(texture, { uViewProj: viewProj, uCamPos: eye })
689
+ // The camera basis rides along: the view matrix's first two rows are
690
+ // the camera's world-space right and up (no clip flip - that lives in
691
+ // the projection), so a billboard needs no reconstruction from uViewProj.
692
+ setTargetParams(texture, {
693
+ uViewProj: viewProj,
694
+ uCamPos: eye,
695
+ uCamRight: [view[0], view[4], view[8]],
696
+ uCamUp: [view[1], view[5], view[9]],
697
+ })
698
+ if (transparentCount > 1) orderDirty = true
449
699
  }
450
700
  let walk = (node: SceneNode, parentChanged: boolean, parentVisible: boolean) => {
451
701
  let changed = parentChanged
@@ -467,6 +717,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
467
717
  mesh._hidden = !shown
468
718
  if (shown) mesh._fresh = true
469
719
  }
720
+ if (changed && mesh._transparent && transparentCount > 1) orderDirty = true
470
721
  if (!mesh._hidden && (changed || mesh._fresh)) {
471
722
  if (mesh.material.normalMatrix) {
472
723
  setDrawParams(texture, mesh._entry, {
@@ -481,11 +732,16 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
481
732
  // Moved while hidden: write the fresh matrix on unhide.
482
733
  mesh._fresh = true
483
734
  }
735
+ // The broadphase leaf follows the world matrix - hidden meshes
736
+ // included (they stay in the tree and are skipped at query time,
737
+ // so unhiding never picks against a stale box).
738
+ if (changed || mesh._pickLeaf === null) updateLeaf(mesh)
484
739
  }
485
740
  }
486
741
  for (let c of node.children) walk(c, changed, shown)
487
742
  }
488
743
  walk(root, false, true)
744
+ if (orderDirty) sortEntries()
489
745
  }
490
746
 
491
747
  let hooks: SceneHooks = {
@@ -514,28 +770,175 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
514
770
  let seed: ShaderParams = mesh.material.normalMatrix
515
771
  ? { uModel: IDENTITY, uNormal: IDENTITY, ...mesh.material.params, ...mesh._params }
516
772
  : { uModel: IDENTITY, ...mesh.material.params, ...mesh._params }
773
+ // The entry starts switched off: it has no world matrix yet - the walk
774
+ // in sync() computes one - and _schedule() defers that to a microtask,
775
+ // so added live it would draw at the seeded identity until then. The
776
+ // mismatch branch in sync() turns it on in the same pass that writes
777
+ // uModel.
517
778
  mesh._entry = addDraw(texture, mesh.material.pipeline(), seed, {
518
779
  buffer: bufs.buffer,
519
780
  indexBuffer: bufs.index,
520
781
  indexFormat: bufs.indexFormat,
521
782
  textures: mesh.material.textures,
783
+ instanceCount: 0,
522
784
  })
523
- mesh._hidden = false
785
+ meshes.push(mesh)
786
+ mesh._transparent = mesh.material.transparent === true
787
+ if (mesh._transparent) transparentCount++
788
+ orderDirty = true
789
+ mesh._hidden = true
524
790
  mesh._fresh = true
525
791
  this._schedule()
526
792
  },
527
793
  _detach(mesh) {
528
- if (mesh._entry !== null && !disposed) removeDraw(texture, mesh._entry)
794
+ if (mesh._entry !== null) {
795
+ if (!disposed) removeDraw(texture, mesh._entry)
796
+ let i = meshes.indexOf(mesh)
797
+ if (i >= 0) meshes.splice(i, 1)
798
+ if (mesh._transparent) transparentCount--
799
+ orderDirty = true
800
+ }
529
801
  mesh._entry = null
802
+ // The leaf goes with the entry: a geometry swap rebuilds the entry,
803
+ // and re-inserting is what picks up the new local bounds.
804
+ if (mesh._pickLeaf !== null) {
805
+ bvh.remove(mesh._pickLeaf)
806
+ mesh._pickLeaf = null
807
+ }
530
808
  },
531
809
  _setParams(mesh, params) {
532
810
  if (mesh._entry !== null && !disposed) setDrawParams(texture, mesh._entry, params)
533
811
  },
812
+ _reorder() {
813
+ orderDirty = true
814
+ this._schedule()
815
+ },
534
816
  }
535
817
 
536
818
  let root = makeNode("group")
537
819
  root._scene = hooks
538
820
 
821
+ // --- Pointer event dispatch (behind scene.handlers) ---
822
+
823
+ type BubbleName = "onPointerDown" | "onPointerMove" | "onPointerUp"
824
+ type InternalEvent = ScenePointerEvent & { _stopped: boolean }
825
+
826
+ let makeEvent = (e: ElementPointerEvent, mesh: Mesh, x: number, y: number, point: Vec3 | null, distance: number | null): InternalEvent => {
827
+ let event: InternalEvent = {
828
+ mesh,
829
+ currentTarget: mesh,
830
+ point,
831
+ distance,
832
+ x,
833
+ y,
834
+ pointerId: e.pointerId,
835
+ pointerType: e.pointerType,
836
+ button: e.button,
837
+ shiftKey: e.shiftKey,
838
+ ctrlKey: e.ctrlKey,
839
+ altKey: e.altKey,
840
+ metaKey: e.metaKey,
841
+ _stopped: false,
842
+ stopPropagation() {
843
+ event._stopped = true
844
+ },
845
+ }
846
+ return event
847
+ }
848
+
849
+ let bubble = (name: BubbleName, event: InternalEvent): void => {
850
+ for (let n: SceneNode | null = event.mesh; n !== null && !event._stopped; n = n.parent) {
851
+ let handler = n[name]
852
+ if (handler) {
853
+ event.currentTarget = n
854
+ handler(event)
855
+ }
856
+ }
857
+ }
858
+
859
+ // The captured mesh's own hit, if the ray still strikes it.
860
+ let hitOn = (mesh: Mesh, x: number, y: number): Hit | null => {
861
+ for (let h of scene.pick(x, y)) if (h.mesh === mesh) return h
862
+ return null
863
+ }
864
+
865
+ // localX/localY arrive in the leaf's LAYOUT frame (the hit test undoes
866
+ // every transform above it, viewBox fits included), so a leaf laid out at
867
+ // the target size - the built-in <Scene> leaf, a d-texture at natural
868
+ // size - is already in scene pixels. Only a leaf deliberately laid out at
869
+ // a DIFFERENT size (the supersampling pattern) needs the ratio, and only
870
+ // the app knows that layout: handlersFor takes it.
871
+ let makeHandlers = (layout: (() => { width: number; height: number }) | null): SceneHandlers => {
872
+ let eventX = 0
873
+ let eventY = 0
874
+ let toScene = (e: ElementPointerEvent): void => {
875
+ if (layout === null) {
876
+ eventX = e.localX
877
+ eventY = e.localY
878
+ return
879
+ }
880
+ let l = layout()
881
+ eventX = e.localX * (l.width > 0 ? width / l.width : 1)
882
+ eventY = e.localY * (l.height > 0 ? height / l.height : 1)
883
+ }
884
+ return {
885
+ onPointerDown(e) {
886
+ toScene(e)
887
+ let hit = scene.pick(eventX, eventY)[0]
888
+ if (hit === undefined) return
889
+ capture.set(e.pointerId, hit.mesh)
890
+ bubble("onPointerDown", makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
891
+ },
892
+ onPointerMove(e) {
893
+ toScene(e)
894
+ let captured = capture.get(e.pointerId)
895
+ if (captured !== undefined) {
896
+ let hit = hitOn(captured, eventX, eventY)
897
+ bubble("onPointerMove", makeEvent(e, captured, eventX, eventY, hit ? hit.point : null, hit ? hit.distance : null))
898
+ return
899
+ }
900
+ let hit = scene.pick(eventX, eventY)[0]
901
+ let prev = hover.get(e.pointerId)
902
+ if (prev !== hit?.mesh) {
903
+ if (prev !== undefined) {
904
+ hover.delete(e.pointerId)
905
+ prev.onPointerLeave?.(makeEvent(e, prev, eventX, eventY, null, null))
906
+ }
907
+ if (hit !== undefined) {
908
+ hover.set(e.pointerId, hit.mesh)
909
+ hit.mesh.onPointerEnter?.(makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
910
+ }
911
+ }
912
+ if (hit !== undefined) {
913
+ bubble("onPointerMove", makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
914
+ }
915
+ },
916
+ onPointerUp(e) {
917
+ toScene(e)
918
+ let captured = capture.get(e.pointerId)
919
+ if (captured !== undefined) {
920
+ capture.delete(e.pointerId)
921
+ let hit = hitOn(captured, eventX, eventY)
922
+ bubble("onPointerUp", makeEvent(e, captured, eventX, eventY, hit ? hit.point : null, hit ? hit.distance : null))
923
+ return
924
+ }
925
+ let hit = scene.pick(eventX, eventY)[0]
926
+ if (hit !== undefined) {
927
+ bubble("onPointerUp", makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
928
+ }
929
+ },
930
+ onPointerLeave(e) {
931
+ let prev = hover.get(e.pointerId)
932
+ if (prev !== undefined) {
933
+ hover.delete(e.pointerId)
934
+ toScene(e)
935
+ prev.onPointerLeave?.(makeEvent(e, prev, eventX, eventY, null, null))
936
+ }
937
+ },
938
+ }
939
+ }
940
+ let handlers = makeHandlers(null)
941
+
539
942
  let scene: Scene = {
540
943
  texture,
541
944
  root,
@@ -557,6 +960,24 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
557
960
  cameraDirty = true
558
961
  hooks._schedule()
559
962
  },
963
+ setParams(params) {
964
+ if (!disposed) setTargetParams(texture, params)
965
+ },
966
+ setBackground(source) {
967
+ if (disposed) return
968
+ if (background !== null) {
969
+ removeDraw(texture, background.entry)
970
+ destroyRenderPipeline(background.pipeline)
971
+ destroyProgram(background.program)
972
+ background = null
973
+ }
974
+ if (source === null) return
975
+ let built = backgroundPipeline(source, (opts?.label ?? "scene") + "-background")
976
+ // First in list order: inserted before the first mesh entry, and every
977
+ // later sort keeps it there.
978
+ let entry = addDraw(texture, built.pipeline, null, { vertexCount: 3, before: meshes[0]?._entry ?? undefined })
979
+ background = { entry, pipeline: built.pipeline, program: built.program }
980
+ },
560
981
  project(point) {
561
982
  ensureCamera()
562
983
  transformPoint(clip, viewProj, point)
@@ -570,12 +991,79 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
570
991
  ensureCamera()
571
992
  return copy(out ?? mat4(), viewProj)
572
993
  },
994
+ pick(x, y) {
995
+ ensureCamera()
996
+ // The camera-frame ray through the pixel, inverting project()'s
997
+ // mapping: the baked y-down clip flip is why pixel y converts with
998
+ // no negation there and one here.
999
+ let f = 1 / Math.tan(((fov * Math.PI) / 180) / 2)
1000
+ let cx = (((x / width) * 2 - 1) * (width / height)) / f
1001
+ let cy = -((y / height) * 2 - 1) / f
1002
+ // The view's upper 3x3 rows are the camera axes, so its transpose
1003
+ // carries the camera-space direction (cx, cy, -1) to world.
1004
+ pickDir[0] = cx * view[0] + cy * view[1] - view[2]
1005
+ pickDir[1] = cx * view[4] + cy * view[5] - view[6]
1006
+ pickDir[2] = cx * view[8] + cy * view[9] - view[10]
1007
+ return scene.raycast(eye, pickDir)
1008
+ },
1009
+ raycast(origin, direction) {
1010
+ // Flush pending writes: picking sees the tree as the app just wrote
1011
+ // it, the same immediacy contract as lookAt()/project(). (The queued
1012
+ // microtask still runs and finds nothing dirty - harmless.)
1013
+ if (scheduled) sync()
1014
+ let dx = direction[0]
1015
+ let dy = direction[1]
1016
+ let dz = direction[2]
1017
+ let len = Math.hypot(dx, dy, dz)
1018
+ if (len === 0 || disposed) return []
1019
+ dx /= len
1020
+ dy /= len
1021
+ dz /= len
1022
+ let ox = origin[0]
1023
+ let oy = origin[1]
1024
+ let oz = origin[2]
1025
+ let hits: Hit[] = []
1026
+ bvh.raycast(ox, oy, oz, dx, dy, dz, mesh => {
1027
+ if (mesh._hidden || mesh._entry === null) return
1028
+ // Narrowphase: the ray in the mesh's local frame against its tight
1029
+ // local box - exact under any affine world transform. The local
1030
+ // direction stays unnormalized on purpose: an affine map preserves
1031
+ // the ray parameter, so t is world units as-is.
1032
+ invertAffine(pickInv, mesh._world)
1033
+ transformPoint(pickOrigin, pickInv, origin)
1034
+ pickDir[0] = dx
1035
+ pickDir[1] = dy
1036
+ pickDir[2] = dz
1037
+ transformVector(pickDir, pickInv, pickDir)
1038
+ let b = geometryBounds(mesh.geometry)
1039
+ let t = rayBoxDistance(
1040
+ pickOrigin[0], pickOrigin[1], pickOrigin[2],
1041
+ pickDir[0], pickDir[1], pickDir[2],
1042
+ b[0]!, b[1]!, b[2]!, b[3]!, b[4]!, b[5]!,
1043
+ )
1044
+ if (t >= 0) hits.push({ mesh, distance: t, point: [ox + dx * t, oy + dy * t, oz + dz * t] })
1045
+ })
1046
+ hits.sort((a, b) => a.distance - b.distance)
1047
+ return hits
1048
+ },
1049
+ handlers,
1050
+ handlersFor(layout) {
1051
+ return makeHandlers(layout)
1052
+ },
573
1053
  dispose() {
574
1054
  if (disposed) return
575
1055
  disposed = true
576
1056
  destroyTexture(texture)
1057
+ if (background !== null) {
1058
+ // The entry died with the target; the pipeline and program are the
1059
+ // scene's own (unlike shared material pipelines), so they go too.
1060
+ destroyRenderPipeline(background.pipeline)
1061
+ destroyProgram(background.program)
1062
+ background = null
1063
+ }
577
1064
  },
578
1065
  }
1066
+ if (opts?.background !== undefined) scene.setBackground(opts.background)
579
1067
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => scene.dispose())
580
1068
  return scene
581
1069
  }