@solidrt/3d 0.0.47 → 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,20 +17,40 @@
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"
22
- import { getOwner, onCleanup } from "@solidjs/signals"
23
- import { compose, copy, lookAt, mat4, multiply, normalMatrix, perspective, transformPoint } from "./math.ts"
24
- import type { Mat4, Vec3, Vec4 } from "./math.ts"
25
- import { geometryBuffers } from "./geometry.ts"
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
+ import { getOwner, onCleanup } from "@solidrt/core"
23
+ import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
24
+ // The scene's lookAt() aims a node; math's builds a camera's view matrix -
25
+ // the same pairing (and the same name) as Three's Object3D/Matrix4.
26
+ import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quatFromEuler, quatFromFrame, quatNormalize, transformPoint, transformVector } from "./math.ts"
27
+ import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
28
+ import { geometryBounds, geometryBuffers } from "./geometry.ts"
26
29
  import type { Geometry } from "./geometry.ts"
30
+ import { backgroundPipeline } from "./material.ts"
27
31
  import type { Material } from "./material.ts"
32
+ import { createBvh, rayBoxDistance } from "./bvh.ts"
28
33
 
29
34
  const IDENTITY = mat4()
30
35
  const RESOLVED = Promise.resolve()
36
+ // lookAt()'s default roll reference. Read-only: quatFromFrame never
37
+ // writes its inputs, so one shared vector is safe.
38
+ const WORLD_UP: Vec3 = [0, 1, 0]
31
39
  // Param values are snapshotted at the FFI boundary (addDraw shares
32
40
  // IDENTITY the same way), so one scratch serves every uNormal write.
33
41
  let normalScratch = mat4()
42
+ // lookAt()/worldPosition() scratch: the ancestor walk recomputes worlds
43
+ // without touching node state, so nothing here outlives a single call.
44
+ let worldScratch = mat4()
45
+ let localScratch = mat4()
46
+ let pointScratch: Vec4 = [0, 0, 0, 0]
47
+ let aimScratch: Vec3 = [0, 0, 0]
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]
34
54
 
35
55
  // The scene half a node needs to reach: attach/detach entries and schedule
36
56
  // a sync. Kept separate from the public Scene type so internals stay off
@@ -50,10 +70,23 @@ export type SceneNode = {
50
70
  children: SceneNode[]
51
71
  /** Read freely; write through setTransform/setVisible so changes sync. */
52
72
  position: Vec3
53
- /** Euler radians, applied x then y then z. */
54
- rotation: Vec3
73
+ /** The stored rotation, always a UNIT quaternion. Euler triples convert
74
+ * on the way in (setTransform's `rotation`) and out (getRotation) - there
75
+ * is no second rotation field to fall out of step with this one. */
76
+ quaternion: Quat
55
77
  scale: Vec3
56
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
57
90
  _localDirty: boolean
58
91
  _local: Mat4
59
92
  _world: Mat4
@@ -68,6 +101,57 @@ export type Mesh = SceneNode & {
68
101
  _hidden: boolean
69
102
  _fresh: boolean
70
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
71
155
  }
72
156
 
73
157
  export type CameraUpdate = {
@@ -82,6 +166,9 @@ export type CameraUpdate = {
82
166
 
83
167
  export type SceneOptions = {
84
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
85
172
  label?: string
86
173
  /** `autoFree: false` opts out of owner-scoped auto-dispose (then call dispose yourself). */
87
174
  autoFree?: boolean
@@ -97,6 +184,20 @@ export type Scene = {
97
184
  /** Partial camera update; absent keys keep their current value. */
98
185
  setCamera(update: CameraUpdate): void
99
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
100
201
  /**
101
202
  * Project a world point to scene pixels: origin top-left, y down - the
102
203
  * output texture's own coordinate space, ready for overlay layout (HUD
@@ -109,6 +210,45 @@ export type Scene = {
109
210
  /** The camera's view-projection matrix, copied into `out` (or a fresh
110
211
  * mat4). The batch escape hatch; for single points use project(). */
111
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
112
252
  /** Destroy the target (entries die with it). Idempotent. Geometry
113
253
  * buffers and material pipelines are shared and survive - they are
114
254
  * app-lifetime (see geometry.ts / material.ts). */
@@ -121,7 +261,7 @@ function makeNode(kind: "group" | "mesh"): SceneNode {
121
261
  parent: null,
122
262
  children: [],
123
263
  position: [0, 0, 0],
124
- rotation: [0, 0, 0],
264
+ quaternion: [0, 0, 0, 1],
125
265
  scale: [1, 1, 1],
126
266
  visible: true,
127
267
  _localDirty: true,
@@ -143,6 +283,7 @@ export function createMesh(geometry: Geometry, material: Material): Mesh {
143
283
  mesh._hidden = false
144
284
  mesh._fresh = false
145
285
  mesh._params = null
286
+ mesh._pickLeaf = null
146
287
  return mesh
147
288
  }
148
289
 
@@ -183,7 +324,13 @@ function leaveScene(node: SceneNode): void {
183
324
 
184
325
  export type TransformUpdate = {
185
326
  position?: Vec3
327
+ /** Euler radians in XYZ order (x first), Three's `Euler` default -
328
+ * converted to the node's quaternion on write. */
186
329
  rotation?: Vec3
330
+ /** The rotation itself. Normalized on write, so a hand-built or
331
+ * drifted quaternion cannot silently scale the geometry. Passing this
332
+ * together with `rotation` is an error, not a precedence question. */
333
+ quaternion?: Quat
187
334
  /** A number is uniform scale. */
188
335
  scale?: Vec3 | number
189
336
  }
@@ -202,11 +349,12 @@ export function setTransform(node: SceneNode, update: TransformUpdate): void {
202
349
  node.position[2] = p[2]
203
350
  }
204
351
  let r = update.rotation
205
- if (r) {
206
- node.rotation[0] = r[0]
207
- node.rotation[1] = r[1]
208
- node.rotation[2] = r[2]
352
+ let q = update.quaternion
353
+ if (r !== undefined && q !== undefined) {
354
+ throw new Error("Pass rotation or quaternion to setTransform, not both")
209
355
  }
356
+ if (r !== undefined) quatFromEuler(node.quaternion, r)
357
+ else if (q !== undefined) quatNormalize(node.quaternion, q)
210
358
  let s = update.scale
211
359
  if (s !== undefined) {
212
360
  if (typeof s === "number") {
@@ -223,6 +371,109 @@ export function setTransform(node: SceneNode, update: TransformUpdate): void {
223
371
  node._scene?._schedule()
224
372
  }
225
373
 
374
+ /**
375
+ * Aim a node at a WORLD-space point, Three's `Object3D.lookAt`: the node's
376
+ * local +z ends up pointing at `target`, with `up` (world space, default
377
+ * +y) choosing the roll about that axis. Ancestor transforms are undone,
378
+ * so the aim holds under a rotated group - the ancestor chain is brought
379
+ * up to date on the spot rather than waiting for the pending sync.
380
+ *
381
+ * +z because that is the library's own sweep axis (`extrude`, `sweep`,
382
+ * `tube` run along z), so aiming their output needs no correction. For a
383
+ * y-axis solid (`cylinder`, `cone`) reach for `quatFromTo` instead, which
384
+ * takes the axis to aim as an argument.
385
+ *
386
+ * Writes `node.quaternion` - an ordinary rotation afterwards, readable and
387
+ * overwritable by setTransform. To aim along a DIRECTION rather than at a
388
+ * point, add it to the node's world position (`worldPosition`), the same
389
+ * conversion Three asks for.
390
+ *
391
+ * Exact for rotation and uniform scale in the ancestor chain; a
392
+ * non-uniformly scaled ancestor shears the frame and the aim is
393
+ * approximate, exactly as in Three (both read the parent's upper 3x3 as
394
+ * if it were a rotation).
395
+ */
396
+ export function lookAt(node: SceneNode, target: Vec3, up: Vec3 = WORLD_UP): void {
397
+ let parent = node.parent
398
+ if (parent === null) {
399
+ // No ancestors: parent space IS world space, aim straight from the
400
+ // node's own position.
401
+ aimScratch[0] = target[0] - node.position[0]
402
+ aimScratch[1] = target[1] - node.position[1]
403
+ aimScratch[2] = target[2] - node.position[2]
404
+ quatFromFrame(node.quaternion, aimScratch, up)
405
+ } else {
406
+ let world = worldInto(worldScratch, parent)
407
+ transformPoint(pointScratch, world, node.position)
408
+ aimScratch[0] = target[0] - pointScratch[0]
409
+ aimScratch[1] = target[1] - pointScratch[1]
410
+ aimScratch[2] = target[2] - pointScratch[2]
411
+ // World -> parent space for both vectors: rotating forward and up
412
+ // rotates the frame they build, so converting the inputs is the same
413
+ // as converting the resulting rotation, and needs no matrix inverse.
414
+ unrotate(aimScratch, world, aimScratch)
415
+ unrotate(upScratch, world, up)
416
+ quatFromFrame(node.quaternion, aimScratch, upScratch)
417
+ }
418
+ node._localDirty = true
419
+ node._scene?._schedule()
420
+ }
421
+
422
+ /**
423
+ * A node's rotation as Euler radians in XYZ order, copied into `out` (or a
424
+ * fresh Vec3). A convenience for reading and debugging, NOT a peer of
425
+ * `node.quaternion`: the conversion is lossy in the sense that it cannot
426
+ * recover the triple that was written (see eulerFromQuat), only a triple
427
+ * that means the same rotation. Anything composing or interpolating
428
+ * rotations should work with the quaternion.
429
+ */
430
+ export function getRotation(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
431
+ return eulerFromQuat(out, node.quaternion)
432
+ }
433
+
434
+ /**
435
+ * A node's position in world space, copied into `out` (or a fresh Vec3) -
436
+ * Three's `getWorldPosition`. Brings the ancestor chain up to date first,
437
+ * so it is exact before the pending sync has run.
438
+ */
439
+ export function worldPosition(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
440
+ let world = worldInto(worldScratch, node)
441
+ out[0] = world[12]
442
+ out[1] = world[13]
443
+ out[2] = world[14]
444
+ return out
445
+ }
446
+
447
+ /**
448
+ * `out` = node's world matrix, composing any dirty locals up the chain
449
+ * WITHOUT clearing their flags: the pending sync still has to see them to
450
+ * write uModel. One shared local scratch serves any depth - each frame
451
+ * uses it only after its recursive call has returned.
452
+ */
453
+ function worldInto(out: Mat4, node: SceneNode): Mat4 {
454
+ if (node.parent === null) identity(out)
455
+ else worldInto(out, node.parent)
456
+ let local = node._localDirty
457
+ ? compose(localScratch, node.position, node.quaternion, node.scale)
458
+ : node._local
459
+ return multiply(out, out, local)
460
+ }
461
+
462
+ /**
463
+ * `out` = v with m's rotation undone: the transpose of m's upper 3x3 with
464
+ * its columns normalized, so uniform scale divides out. out may alias v.
465
+ */
466
+ function unrotate(out: Vec3, m: Mat4, v: Vec3): Vec3 {
467
+ let x = v[0], y = v[1], z = v[2]
468
+ let l0 = Math.hypot(m[0], m[1], m[2]) || 1
469
+ let l1 = Math.hypot(m[4], m[5], m[6]) || 1
470
+ let l2 = Math.hypot(m[8], m[9], m[10]) || 1
471
+ out[0] = (m[0] * x + m[1] * y + m[2] * z) / l0
472
+ out[1] = (m[4] * x + m[5] * y + m[6] * z) / l1
473
+ out[2] = (m[8] * x + m[9] * y + m[10] * z) / l2
474
+ return out
475
+ }
476
+
226
477
  /** Show or hide a node and its whole subtree (a hidden mesh costs one
227
478
  * `instanceCount: 0` draw range - the entry stays, drawing nothing). */
228
479
  export function setVisible(node: SceneNode, visible: boolean): void {
@@ -287,6 +538,44 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
287
538
  let disposed = false
288
539
  let scheduled = false
289
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
+
290
579
  let fov = 60
291
580
  let near = 0.1
292
581
  let far = 100
@@ -308,7 +597,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
308
597
  cameraDirty = false
309
598
  cameraPending = true
310
599
  perspective(proj, (fov * Math.PI) / 180, width / height, near, far)
311
- lookAt(view, eye, target, up)
600
+ lookAtMatrix(view, eye, target, up)
312
601
  multiply(viewProj, proj, view)
313
602
  }
314
603
 
@@ -326,7 +615,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
326
615
  let walk = (node: SceneNode, parentChanged: boolean, parentVisible: boolean) => {
327
616
  let changed = parentChanged
328
617
  if (node._localDirty) {
329
- compose(node._local, node.position, node.rotation, node.scale)
618
+ compose(node._local, node.position, node.quaternion, node.scale)
330
619
  node._localDirty = false
331
620
  changed = true
332
621
  }
@@ -357,6 +646,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
357
646
  // Moved while hidden: write the fresh matrix on unhide.
358
647
  mesh._fresh = true
359
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)
360
653
  }
361
654
  }
362
655
  for (let c of node.children) walk(c, changed, shown)
@@ -390,19 +683,36 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
390
683
  let seed: ShaderParams = mesh.material.normalMatrix
391
684
  ? { uModel: IDENTITY, uNormal: IDENTITY, ...mesh.material.params, ...mesh._params }
392
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.
393
691
  mesh._entry = addDraw(texture, mesh.material.pipeline(), seed, {
394
692
  buffer: bufs.buffer,
395
693
  indexBuffer: bufs.index,
396
694
  indexFormat: bufs.indexFormat,
397
695
  textures: mesh.material.textures,
696
+ instanceCount: 0,
398
697
  })
399
- mesh._hidden = false
698
+ entryOrder.push(mesh._entry)
699
+ mesh._hidden = true
400
700
  mesh._fresh = true
401
701
  this._schedule()
402
702
  },
403
703
  _detach(mesh) {
404
- 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
+ }
405
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
+ }
406
716
  },
407
717
  _setParams(mesh, params) {
408
718
  if (mesh._entry !== null && !disposed) setDrawParams(texture, mesh._entry, params)
@@ -412,6 +722,127 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
412
722
  let root = makeNode("group")
413
723
  root._scene = hooks
414
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
+
415
846
  let scene: Scene = {
416
847
  texture,
417
848
  root,
@@ -433,6 +864,21 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
433
864
  cameraDirty = true
434
865
  hooks._schedule()
435
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
+ },
436
882
  project(point) {
437
883
  ensureCamera()
438
884
  transformPoint(clip, viewProj, point)
@@ -446,12 +892,79 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
446
892
  ensureCamera()
447
893
  return copy(out ?? mat4(), viewProj)
448
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
+ },
449
954
  dispose() {
450
955
  if (disposed) return
451
956
  disposed = true
452
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
+ }
453
965
  },
454
966
  }
967
+ if (opts?.background !== undefined) scene.setBackground(opts.background)
455
968
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => scene.dispose())
456
969
  return scene
457
970
  }