@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 +86 -4
- package/README.md +13 -2
- package/examples/README.md +8 -0
- package/examples/pick.tsx +98 -0
- package/examples/scene-background.tsx +43 -0
- package/package.json +3 -3
- package/src/bvh.ts +258 -0
- package/src/components.tsx +59 -5
- package/src/geometry.ts +29 -0
- package/src/index.ts +2 -2
- package/src/material.ts +37 -0
- package/src/math.ts +42 -0
- package/src/scene.ts +395 -6
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
|
-
|
|
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/).
|
package/examples/README.md
CHANGED
|
@@ -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.
|
|
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-
|
|
20
|
-
"@solidrt/core": "0.0.
|
|
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
|
+
}
|