@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/AGENTS.md CHANGED
@@ -49,9 +49,9 @@ blendMode and pointer events like any element. Design rationale:
49
49
 
50
50
  | Component | Props |
51
51
  | --- | --- |
52
- | `Scene` | `width`, `height` (target pixels), `clearColor?`, `label?`, `ref?(scene)`, `output?(texture)` |
53
- | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, `ref?(node)` |
54
- | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), `ref?(mesh)` |
52
+ | `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
53
+ | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
54
+ | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), pointer events (below), `ref?(mesh)` |
55
55
  | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
56
56
 
57
57
  Output composition: without `output`, `Scene` emits a minimal
@@ -102,6 +102,40 @@ immediately, so set-then-project in one tick is exact. `scene.viewProj(out?)`
102
102
  copies the view-projection matrix for batch work. Never rebuild the
103
103
  camera matrices by hand for a HUD.
104
104
 
105
+ Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
106
+ through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
107
+ world units, nearest first; every hit along the ray, not just the front
108
+ one). `scene.raycast(origin, direction)` is the world-space primitive
109
+ under it. The volume tier: hits test each mesh's local bounding box,
110
+ transformed exactly under any node transform (non-uniform scale
111
+ included), so results are conservative - a ray through a knot's hole
112
+ still hits (no `face`/`uv` fields until a triangle tier exists).
113
+ Broadphase is a dynamic AABB tree (BVH) the sync walk keeps current from
114
+ its own dirty set - maintenance is O(changed), a query O(log meshes) -
115
+ so per-pointer-move picking puts no ceiling on scene size. Both methods
116
+ flush pending writes first (the lookAt/project immediacy contract), and
117
+ both skip invisible meshes.
118
+
119
+ Mesh pointer events - the element vocabulary one tree deeper:
120
+ `onPointerDown/Move/Up/Enter/Leave` as plain fields on any node (and as
121
+ Mesh/Group props). The nearest hit mesh is the target; down/move/up
122
+ bubble mesh -> ancestor groups (`stopPropagation()` stops the walk);
123
+ enter/leave fire on the mesh alone, pairing on hover changes. A
124
+ pointer-down CAPTURES its mesh until the up: moves and the up keep
125
+ dispatching to it off-mesh (the platform's captured-drag rule), with
126
+ `point`/`distance` null while the ray misses it. The event carries the
127
+ element fields (pointerId, pointerType, button, modifiers) plus `mesh`,
128
+ `currentTarget`, `point`, `distance`, and `x`/`y` in scene pixels.
129
+ Wiring: the built-in `<Scene>` leaf carries `scene.handlers`
130
+ automatically (opt out: `events={false}`); an `output` leaf or
131
+ imperative composition spreads `{...scene.handlers}` onto the element
132
+ showing the texture. `scene.handlers` assumes that leaf is LAID OUT at
133
+ the target size - true for the built-in leaf and a d-texture at natural
134
+ size, under any ancestor transforms or viewBox fits (the hit test
135
+ undoes them; localX/localY arrive in the leaf's layout frame). A leaf
136
+ laid out at a different size (the supersampling pattern) uses
137
+ `scene.handlersFor(() => ({ width, height }))` with its layout size.
138
+
105
139
  Geometry: `box(w?, h?, d?)`; `plane(w?, h?)`, `circle(radius?, seg?)` and
106
140
  `ring(inner?, outer?, seg?)` (XY, facing +z - rotate `[-Math.PI/2, 0, 0]`
107
141
  for a floor); `sphere(radius?, wSeg?, hSeg?)`;
@@ -175,6 +209,21 @@ Materials:
175
209
  disappears from the object keeps its old value; for per-frame values
176
210
  prefer `ref` + setMeshParams from onFrame, the setTransform split).
177
211
 
212
+ Background: `scene.setBackground(source | null)`, the `background` option
213
+ on createScene, and the reactive `Scene` prop. Fragment GLSL drawn as the
214
+ FIRST entry of the scene's own pass (attributeless fullscreen triangle,
215
+ depth off) - one target instead of a backdrop texture stacked under the
216
+ scene, with no separate resize plumbing. The source gets the
217
+ shader-target fragment contract exactly (vUV 0..1 top-left origin,
218
+ iResolution, fragColor; no `#version` line = the standard preamble), so
219
+ a `createShaderTexture` backdrop ports verbatim. Three's
220
+ `scene.background = color` is `clearColor` here; a texture-id form can
221
+ widen the signature later (a branded TextureId is a number, so
222
+ `string | TextureId` disambiguates at runtime). No app-driven uniforms:
223
+ a background is static art - anything animated is a mesh's own
224
+ shaderMaterial (or, until blend factors land, a separate shader texture
225
+ underneath, which translucent grounds also still need).
226
+
178
227
  Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
179
228
  into shaderMaterial sources with plain template literals - `LIT_VERTEX`
180
229
  (the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
@@ -197,7 +246,10 @@ system.
197
246
  and show mesh interiors.
198
247
  - `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
199
248
  cheap off switch). Hidden meshes skip uModel writes; the fresh matrix is
200
- written on unhide.
249
+ written on unhide. A freshly attached entry starts off the same way and
250
+ sync() turns it on when it writes uModel - never add one live: it has no
251
+ world matrix yet, and drawn before the sync microtask it flashes at the
252
+ world origin for a frame.
201
253
  - Alpha does not blend in v1: pipelines are opaque (`blend: "none"`), a
202
254
  translucent color overwrites. Transparency waits on blend factors +
203
255
  sorting (research note, staging step 4).
@@ -304,3 +356,33 @@ system.
304
356
  vertex source - a comment counts - selects the "colored" layout, and
305
357
  the material then rejects standard geometry at add(). Do not mention
306
358
  aColor you do not read.
359
+ - Picking is the VOLUME tier: a hit means the ray crossed the mesh's
360
+ transformed bounding box, not its surface. Never present `point` as a
361
+ surface point (it is the box-entry point), and never add a
362
+ triangle-accurate path in JS - per-triangle rays at mesh scale are
363
+ interpreter-hostile; that tier is core work (BVH descent per the
364
+ differentiators ladder).
365
+ - `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
366
+ LAYOUT frame (every ancestor transform and viewBox fit is already
367
+ undone by the element hit test). `handlers` therefore assumes leaf
368
+ layout == target pixels; scaling by `getBoundingBox` would be WRONG -
369
+ the box composes transforms, and it would double-correct the built-in
370
+ leaf under a viewBox. Only a leaf whose layout size deliberately
371
+ differs from the target (supersampling) needs `handlersFor`, fed the
372
+ layout size the app itself set.
373
+ - Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
374
+ under a still pointer fires nothing until the next move - the same
375
+ limit the element hit test has (hit-test-per-frame is an open platform
376
+ item). Do not poll pick() per frame to fake it.
377
+ - Geometry local bounds cache on the Geometry (like its GPU buffers):
378
+ geometry is immutable after creation. Mutating `vertices` after a mesh
379
+ used them leaves stale bounds AND a stale GPU buffer - make a new
380
+ Geometry instead.
381
+ - The background covers the whole target with depth off, drawn first: it
382
+ REPLACES the clearColor visually (the clear still runs; you just never
383
+ see it), and a translucent mesh does not blend over it in-pass (blend
384
+ is none|add today - the fade-over-backdrop look still needs the
385
+ two-layer composition until blend factors land).
386
+ - The background pipeline/program are SCENE-OWNED (unlike shared
387
+ material pipelines): setBackground(null), replacement, and dispose()
388
+ destroy them. Do not hand the background's pipeline to anything else.
package/README.md CHANGED
@@ -51,6 +51,16 @@ Three's `Euler` default, and `getRotation(node)` reads one back. The
51
51
  verbs: `quatFromAxisAngle`, `quatMultiply`, and `quatSlerp` (smooth
52
52
  tracking, damped follows) round out `quatFromTo`; `examples/aim.tsx`
53
53
  shows each aiming style live.
54
+
55
+ Meshes take pointer events like elements do: `onPointerDown/Move/Up/
56
+ Enter/Leave` props on `<Mesh>` and `<Group>`, with bubbling, capture on
57
+ drag, and hover enter/leave pairs - hit testing runs over a BVH the
58
+ scene maintains incrementally, so events put no ceiling on scene size.
59
+ Underneath sit `scene.pick(x, y)` (the camera ray through a pixel,
60
+ `project()`'s inverse) and `scene.raycast(origin, direction)`; hits are
61
+ bounding-box accurate in v1. A scene also takes a `background` - fragment
62
+ GLSL drawn inside its own pass behind the meshes, replacing the stacked
63
+ backdrop-texture pattern.
54
64
  Custom materials get a standard uniform set - per-mesh `uModel`/`uNormal`,
55
65
  shared `uViewProj`/`uCamPos`, each written once per change - plus your own
56
66
  uniforms per mesh, declaratively via the `params` prop on `<Mesh>` or
@@ -67,8 +77,9 @@ mitred joints, flat `shape`, with `fillet`/`roundRect`/`triangulate`
67
77
  helpers), a per-vertex data channel
68
78
  (`withColors` adds an `aColor` vec4 - tint, baked AO, any four scalars -
69
79
  to any geometry, for materials that read it), one perspective camera
70
- with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit).
71
- Lights, transparency, model loading and picking are
80
+ with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit),
81
+ mesh picking with pointer events, and scene backgrounds.
82
+ Lights, transparency and model loading are
72
83
  staged next - see `okf/research/scene-graph-3d.md` for the roadmap. Full
73
84
  usage notes and traps: [AGENTS.md](AGENTS.md); runnable examples:
74
85
  [examples/](examples/).
@@ -15,3 +15,11 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
15
15
  target: `lookAt` for a +z solid, `quatFromTo` for aiming a y-axis cone,
16
16
  and a `quatSlerp` damped follow that visibly lags; all driven from
17
17
  onFrame through refs, no per-frame signals.
18
+ - `pick.tsx` - mesh pointer events: hover tints (enter/leave), click
19
+ pops (down), a Group hearing its children's clicks through bubbling
20
+ and one mesh stopping the walk; a STATIC scene rendered only when an
21
+ event changes something, with hit testing over the scene's BVH.
22
+ - `scene-background.tsx` - a fragment-GLSL background drawn inside the
23
+ scene's own pass (`<Scene background>`): one target, no stacked
24
+ backdrop texture, no resize plumbing; the source is shader-target
25
+ compatible verbatim.
@@ -0,0 +1,98 @@
1
+ // Mesh pointer events: hover to tint (enter/leave), click to pop (down),
2
+ // with a Group seeing its children's clicks through bubbling and one mesh
3
+ // stopping the walk. The scene is STATIC - no onFrame - so it renders only
4
+ // when an event changes something: picking is demand-driven like
5
+ // everything else. Events flow because the built-in <Scene> leaf carries
6
+ // scene.handlers (default on); hit testing runs over the scene's BVH, so a
7
+ // pointer move costs O(log meshes).
8
+ //
9
+ // The volume tier is honest about its shape: hits test each mesh's
10
+ // bounding box, so hovering just outside the ball's silhouette (inside its
11
+ // box corners) still counts. Triangle-accurate hits are a later tier.
12
+
13
+ import { createSignal, pct, render } from "@solidrt/core"
14
+ import { box, cone, Group, Mesh, PerspectiveCamera, plane, Scene, setMeshParams, setTransform, sphere, torus, unlit } from "@solidrt/3d"
15
+ import type { Geometry, ScenePointerEvent, Vec3 } from "@solidrt/3d"
16
+
17
+ const SIZE = 720
18
+
19
+ type Color = [number, number, number]
20
+
21
+ let [hovered, setHovered] = createSignal("nothing")
22
+ let popped = new Set<object>()
23
+
24
+ /** A pickable mesh: unlit color that brightens on hover, scale-pop on
25
+ * click. setMeshParams writes raw uniform values, so the tint premultiplies
26
+ * here (alpha 1: the rgb passes through). */
27
+ function Pickable(p: { name: string; color: Color; geometry: Geometry; position: Vec3; scale?: number; stop?: boolean }) {
28
+ let lift = (up: number): [number, number, number, number] => [
29
+ Math.min(p.color[0] + up, 1),
30
+ Math.min(p.color[1] + up, 1),
31
+ Math.min(p.color[2] + up, 1),
32
+ 1,
33
+ ]
34
+ return (
35
+ <Mesh
36
+ geometry={p.geometry}
37
+ material={unlit({ color: p.color })}
38
+ position={p.position}
39
+ scale={p.scale}
40
+ onPointerEnter={(e: ScenePointerEvent) => {
41
+ setHovered(p.name)
42
+ setMeshParams(e.mesh, { uColor: lift(0.25) })
43
+ console.log(`enter ${p.name}`)
44
+ }}
45
+ onPointerLeave={(e: ScenePointerEvent) => {
46
+ setHovered("nothing")
47
+ setMeshParams(e.mesh, { uColor: lift(0) })
48
+ console.log(`leave ${p.name}`)
49
+ }}
50
+ onPointerDown={(e: ScenePointerEvent) => {
51
+ let pop = !popped.has(e.mesh)
52
+ if (pop) popped.add(e.mesh)
53
+ else popped.delete(e.mesh)
54
+ setTransform(e.mesh, { scale: pop ? (p.scale ?? 1) * 1.25 : (p.scale ?? 1) })
55
+ let pt = e.point!
56
+ console.log(`down ${p.name} at ${pt[0].toFixed(2)},${pt[1].toFixed(2)},${pt[2].toFixed(2)} d=${e.distance!.toFixed(2)}`)
57
+ // The cone demonstrates stopping the bubble: its group never hears it.
58
+ if (p.stop) e.stopPropagation()
59
+ }}
60
+ />
61
+ )
62
+ }
63
+
64
+ function App() {
65
+ let floor = plane(8, 8, "floor")
66
+ let crate = box()
67
+ let ball = sphere(0.45)
68
+ let spike = cone(0.45, 1)
69
+ let ring = torus(0.4, 0.14)
70
+
71
+ return (
72
+ <window>
73
+ <view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
74
+ <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="pick">
75
+ <PerspectiveCamera fov={55} position={[0, 2.6, 5]} lookAt={[0, 0.4, 0]} />
76
+ <Mesh geometry={floor} material={unlit({ color: [0.15, 0.16, 0.2] })} rotation={[-Math.PI / 2, 0, 0]} />
77
+ {/* The group hears every child click that is not stopped. */}
78
+ <Group onPointerDown={() => console.log("group saw down")}>
79
+ <Pickable name="crate" color={[0.85, 0.3, 0.3]} geometry={crate} position={[-1.6, 0.4, 0]} scale={0.8} />
80
+ <Pickable name="cone" color={[0.35, 0.65, 0.9]} geometry={spike} position={[1.6, 0.5, 0]} stop />
81
+ </Group>
82
+ <Pickable name="ball" color={[0.9, 0.8, 0.35]} geometry={ball} position={[0, 0.45, 0]} />
83
+ <Pickable name="ring" color={[0.5, 0.85, 0.5]} geometry={ring} position={[0.9, 0.35, -1.6]} />
84
+ </Scene>
85
+ <view position="absolute" x={0} y={0} padding={16} gap={4}>
86
+ <text color="#eef4ff" fontSize={22} fontWeight={700}>
87
+ {`hover: ${hovered()}`}
88
+ </text>
89
+ <text color="#8fa6c8" fontSize={13}>
90
+ hover tints, click pops - a static scene, rendered only on change
91
+ </text>
92
+ </view>
93
+ </view>
94
+ </window>
95
+ )
96
+ }
97
+
98
+ render(() => <App />)
@@ -0,0 +1,43 @@
1
+ // A scene background: fragment GLSL drawn as the first entry of the
2
+ // scene's own pass - no second target, no stacked texture layers, no
3
+ // resize plumbing. The source below uses only the shader-target contract
4
+ // (vUV, iResolution, fragColor), so it would work unchanged in
5
+ // createShaderTexture; here it costs nothing extra - the one scene pass
6
+ // paints backdrop and meshes together, and a static scene still renders
7
+ // zero passes when idle.
8
+
9
+ import { pct, render } from "@solidrt/core"
10
+ import { glsl } from "@solidrt/core/gpu"
11
+ import { box, Mesh, PerspectiveCamera, Scene, sphere, torusKnot, unlit } from "@solidrt/3d"
12
+
13
+ const SIZE = 720
14
+
15
+ // A radial night-sky gradient with hash grain so the ramp does not band.
16
+ let BACKDROP = glsl`
17
+ void main() {
18
+ float d = distance(vUV, vec2(0.5, 0.35));
19
+ vec3 near = vec3(0.10, 0.13, 0.22);
20
+ vec3 far = vec3(0.02, 0.03, 0.06);
21
+ vec3 col = mix(near, far, smoothstep(0.05, 0.9, d));
22
+ float n = fract(sin(dot(vUV * iResolution, vec2(12.9898, 78.233))) * 43758.5453);
23
+ col += (n - 0.5) * 0.012;
24
+ fragColor = vec4(col, 1.0);
25
+ }
26
+ `
27
+
28
+ function App() {
29
+ return (
30
+ <window>
31
+ <view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
32
+ <Scene width={SIZE} height={SIZE} background={BACKDROP} label="backdrop-demo">
33
+ <PerspectiveCamera fov={55} position={[0, 1.8, 4.4]} lookAt={[0, 0.4, 0]} />
34
+ <Mesh geometry={torusKnot(0.7, 0.2, 128, 16)} material={unlit({ color: [0.85, 0.55, 0.25] })} position={[0, 0.9, 0]} />
35
+ <Mesh geometry={box()} material={unlit({ color: [0.3, 0.5, 0.8] })} position={[-1.5, 0.4, -0.5]} scale={0.8} />
36
+ <Mesh geometry={sphere(0.4)} material={unlit({ color: [0.4, 0.75, 0.45] })} position={[1.5, 0.4, -0.5]} />
37
+ </Scene>
38
+ </view>
39
+ </window>
40
+ )
41
+ }
42
+
43
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.48",
3
+ "version": "0.0.49",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -16,7 +16,7 @@
16
16
  "AGENTS.md"
17
17
  ],
18
18
  "peerDependencies": {
19
- "@solidjs/signals": "2.0.0-beta.31",
20
- "@solidrt/core": "0.0.48"
19
+ "@solidjs/signals": "2.0.0-rc.0",
20
+ "@solidrt/core": "0.0.49"
21
21
  }
22
22
  }
package/src/bvh.ts ADDED
@@ -0,0 +1,258 @@
1
+ // A dynamic bounding-volume hierarchy - the physics-broadphase AABB tree
2
+ // (Box2D/Bullet lineage): a binary tree over the items' world boxes, NOT a
3
+ // subdivision of space. Leaves store FAT boxes (a margin around the tight
4
+ // bounds) so an item moving a little refits nothing; an item escaping its
5
+ // fat box is removed and re-inserted along the cheapest path (least area
6
+ // growth). A ray query walks O(log n) nodes instead of testing every item,
7
+ // which is what keeps per-pointer-move picking off the O(meshes) path.
8
+ //
9
+ // The scene keeps the tree current from its own sync walk: transforms that
10
+ // changed are exactly the leaves to update, so maintenance is O(changed)
11
+ // per frame - the same delta discipline as rendering, and a static scene
12
+ // pays nothing. Storage is flat parallel arrays indexed by node id (no
13
+ // per-node objects, no allocation at steady state past tree growth).
14
+ //
15
+ // Pure module by design: no engine imports, so the differential check rig
16
+ // (checks/pick-check.ts) runs it under plain bun against a linear oracle.
17
+
18
+ /** Fat-margin fraction of a leaf's largest extent. Bigger = fewer
19
+ * re-inserts while moving, worse query pruning; 5% is the usual trade. */
20
+ const MARGIN = 0.05
21
+
22
+ type Visit<T> = (item: T) => void
23
+
24
+ export type Bvh<T> = {
25
+ /** Insert an item with its tight world box; returns the leaf handle. */
26
+ insert(item: T, minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): number
27
+ /** Update a leaf's tight box. Free while it stays inside the fat box;
28
+ * otherwise the leaf re-inserts. Returns true when it moved. */
29
+ update(leaf: number, minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): boolean
30
+ /** Remove a leaf (the handle is dead afterwards). */
31
+ remove(leaf: number): void
32
+ /** Visit every item whose FAT box the ray hits (t >= 0). Broadphase
33
+ * only: the caller narrowphases against its own tight volumes. */
34
+ raycast(ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, visit: Visit<T>): void
35
+ }
36
+
37
+ /**
38
+ * Entry distance of a ray against a box: the smallest t >= 0 with
39
+ * origin + t * direction inside [min, max] (0 when the origin starts
40
+ * inside), or -1 for a miss. The direction need not be normalized - t is
41
+ * in units of its length, which is what keeps a ray transformed into a
42
+ * mesh's local space reporting world distances. Shared by the tree's
43
+ * broadphase and the scene's narrowphase.
44
+ */
45
+ export function rayBoxDistance(
46
+ ox: number, oy: number, oz: number,
47
+ dx: number, dy: number, dz: number,
48
+ minX: number, minY: number, minZ: number,
49
+ maxX: number, maxY: number, maxZ: number,
50
+ ): number {
51
+ let tNear = 0
52
+ let tFar = Infinity
53
+ // Per axis: a zero direction component never crosses the slab, so the
54
+ // origin must already be inside it (the multiply-by-inverse shortcut
55
+ // turns that case into NaN, hence the explicit branch).
56
+ if (dx === 0) {
57
+ if (ox < minX || ox > maxX) return -1
58
+ } else {
59
+ let inv = 1 / dx
60
+ let t1 = (minX - ox) * inv
61
+ let t2 = (maxX - ox) * inv
62
+ if (t1 > t2) { let t = t1; t1 = t2; t2 = t }
63
+ if (t1 > tNear) tNear = t1
64
+ if (t2 < tFar) tFar = t2
65
+ }
66
+ if (dy === 0) {
67
+ if (oy < minY || oy > maxY) return -1
68
+ } else {
69
+ let inv = 1 / dy
70
+ let t1 = (minY - oy) * inv
71
+ let t2 = (maxY - oy) * inv
72
+ if (t1 > t2) { let t = t1; t1 = t2; t2 = t }
73
+ if (t1 > tNear) tNear = t1
74
+ if (t2 < tFar) tFar = t2
75
+ }
76
+ if (dz === 0) {
77
+ if (oz < minZ || oz > maxZ) return -1
78
+ } else {
79
+ let inv = 1 / dz
80
+ let t1 = (minZ - oz) * inv
81
+ let t2 = (maxZ - oz) * inv
82
+ if (t1 > t2) { let t = t1; t1 = t2; t2 = t }
83
+ if (t1 > tNear) tNear = t1
84
+ if (t2 < tFar) tFar = t2
85
+ }
86
+ return tFar >= tNear ? tNear : -1
87
+ }
88
+
89
+ export function createBvh<T>(): Bvh<T> {
90
+ // Node storage, 6 bounds floats per node; child1 === -1 marks a leaf.
91
+ let bounds: number[] = []
92
+ let parent: number[] = []
93
+ let child1: number[] = []
94
+ let child2: number[] = []
95
+ let items: (T | undefined)[] = []
96
+ let free: number[] = []
97
+ let root = -1
98
+ // Traversal stack, reused across queries.
99
+ let stack: number[] = []
100
+
101
+ let allocate = (): number => {
102
+ let node = free.pop()
103
+ if (node === undefined) {
104
+ node = parent.length
105
+ bounds.push(0, 0, 0, 0, 0, 0)
106
+ parent.push(-1)
107
+ child1.push(-1)
108
+ child2.push(-1)
109
+ items.push(undefined)
110
+ }
111
+ return node
112
+ }
113
+
114
+ // Half the surface area - the SAH cost of a node's box. Degenerate
115
+ // (flat) boxes still cost via their other faces, which is what makes
116
+ // the heuristic sane for planes.
117
+ let area = (n: number): number => {
118
+ let i = n * 6
119
+ let w = bounds[i + 3]! - bounds[i]!
120
+ let h = bounds[i + 4]! - bounds[i + 1]!
121
+ let d = bounds[i + 5]! - bounds[i + 2]!
122
+ return w * (h + d) + h * d
123
+ }
124
+
125
+ let unionArea = (n: number, minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): number => {
126
+ let i = n * 6
127
+ let w = Math.max(bounds[i + 3]!, maxX) - Math.min(bounds[i]!, minX)
128
+ let h = Math.max(bounds[i + 4]!, maxY) - Math.min(bounds[i + 1]!, minY)
129
+ let d = Math.max(bounds[i + 5]!, maxZ) - Math.min(bounds[i + 2]!, minZ)
130
+ return w * (h + d) + h * d
131
+ }
132
+
133
+ // Recompute an internal node's box as the union of its children.
134
+ let refit = (n: number): void => {
135
+ let a = child1[n]! * 6
136
+ let b = child2[n]! * 6
137
+ let i = n * 6
138
+ bounds[i] = Math.min(bounds[a]!, bounds[b]!)
139
+ bounds[i + 1] = Math.min(bounds[a + 1]!, bounds[b + 1]!)
140
+ bounds[i + 2] = Math.min(bounds[a + 2]!, bounds[b + 2]!)
141
+ bounds[i + 3] = Math.max(bounds[a + 3]!, bounds[b + 3]!)
142
+ bounds[i + 4] = Math.max(bounds[a + 4]!, bounds[b + 4]!)
143
+ bounds[i + 5] = Math.max(bounds[a + 5]!, bounds[b + 5]!)
144
+ }
145
+
146
+ let insertLeaf = (leaf: number): void => {
147
+ if (root === -1) {
148
+ root = leaf
149
+ parent[leaf] = -1
150
+ return
151
+ }
152
+ let i = leaf * 6
153
+ let minX = bounds[i]!, minY = bounds[i + 1]!, minZ = bounds[i + 2]!
154
+ let maxX = bounds[i + 3]!, maxY = bounds[i + 4]!, maxZ = bounds[i + 5]!
155
+ // Descend toward the sibling that grows the least (the classic
156
+ // incremental surface-area heuristic).
157
+ let sibling = root
158
+ while (child1[sibling] !== -1) {
159
+ let a = child1[sibling]!
160
+ let b = child2[sibling]!
161
+ let costA = unionArea(a, minX, minY, minZ, maxX, maxY, maxZ) - area(a)
162
+ let costB = unionArea(b, minX, minY, minZ, maxX, maxY, maxZ) - area(b)
163
+ sibling = costA < costB ? a : b
164
+ }
165
+ let oldParent = parent[sibling]!
166
+ let newParent = allocate()
167
+ parent[newParent] = oldParent
168
+ child1[newParent] = sibling
169
+ child2[newParent] = leaf
170
+ items[newParent] = undefined
171
+ parent[sibling] = newParent
172
+ parent[leaf] = newParent
173
+ if (oldParent === -1) {
174
+ root = newParent
175
+ } else if (child1[oldParent] === sibling) {
176
+ child1[oldParent] = newParent
177
+ } else {
178
+ child2[oldParent] = newParent
179
+ }
180
+ for (let n = newParent; n !== -1; n = parent[n]!) refit(n)
181
+ }
182
+
183
+ let removeLeaf = (leaf: number): void => {
184
+ if (leaf === root) {
185
+ root = -1
186
+ return
187
+ }
188
+ let p = parent[leaf]!
189
+ let sibling = child1[p] === leaf ? child2[p]! : child1[p]!
190
+ let grand = parent[p]!
191
+ parent[sibling] = grand
192
+ if (grand === -1) {
193
+ root = sibling
194
+ } else {
195
+ if (child1[grand] === p) child1[grand] = sibling
196
+ else child2[grand] = sibling
197
+ for (let n = grand; n !== -1; n = parent[n]!) refit(n)
198
+ }
199
+ child1[p] = -1
200
+ items[p] = undefined
201
+ free.push(p)
202
+ }
203
+
204
+ let setFat = (leaf: number, minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): void => {
205
+ let m = MARGIN * Math.max(maxX - minX, maxY - minY, maxZ - minZ)
206
+ let i = leaf * 6
207
+ bounds[i] = minX - m
208
+ bounds[i + 1] = minY - m
209
+ bounds[i + 2] = minZ - m
210
+ bounds[i + 3] = maxX + m
211
+ bounds[i + 4] = maxY + m
212
+ bounds[i + 5] = maxZ + m
213
+ }
214
+
215
+ return {
216
+ insert(item, minX, minY, minZ, maxX, maxY, maxZ) {
217
+ let leaf = allocate()
218
+ items[leaf] = item
219
+ setFat(leaf, minX, minY, minZ, maxX, maxY, maxZ)
220
+ insertLeaf(leaf)
221
+ return leaf
222
+ },
223
+ update(leaf, minX, minY, minZ, maxX, maxY, maxZ) {
224
+ let i = leaf * 6
225
+ if (
226
+ bounds[i]! <= minX && bounds[i + 1]! <= minY && bounds[i + 2]! <= minZ &&
227
+ bounds[i + 3]! >= maxX && bounds[i + 4]! >= maxY && bounds[i + 5]! >= maxZ
228
+ ) {
229
+ return false
230
+ }
231
+ removeLeaf(leaf)
232
+ setFat(leaf, minX, minY, minZ, maxX, maxY, maxZ)
233
+ insertLeaf(leaf)
234
+ return true
235
+ },
236
+ remove(leaf) {
237
+ removeLeaf(leaf)
238
+ items[leaf] = undefined
239
+ free.push(leaf)
240
+ },
241
+ raycast(ox, oy, oz, dx, dy, dz, visit) {
242
+ if (root === -1) return
243
+ stack.length = 0
244
+ stack.push(root)
245
+ while (stack.length > 0) {
246
+ let n = stack.pop()!
247
+ let i = n * 6
248
+ let t = rayBoxDistance(ox, oy, oz, dx, dy, dz, bounds[i]!, bounds[i + 1]!, bounds[i + 2]!, bounds[i + 3]!, bounds[i + 4]!, bounds[i + 5]!)
249
+ if (t < 0) continue
250
+ if (child1[n] === -1) {
251
+ visit(items[n] as T)
252
+ } else {
253
+ stack.push(child1[n]!, child2[n]!)
254
+ }
255
+ }
256
+ },
257
+ }
258
+ }