@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/AGENTS.md +178 -14
- package/README.md +32 -6
- package/examples/README.md +16 -0
- package/examples/aim.tsx +119 -0
- package/examples/pick.tsx +98 -0
- package/examples/scene-background.tsx +43 -0
- package/examples/sweep-paths.tsx +79 -0
- package/package.json +3 -3
- package/src/bvh.ts +258 -0
- package/src/components.tsx +81 -10
- package/src/geometry.ts +35 -0
- package/src/index.ts +10 -6
- package/src/material.ts +37 -0
- package/src/math.ts +336 -19
- package/src/profile.ts +43 -248
- package/src/scene.ts +530 -17
- package/src/sweep.ts +460 -0
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 "@
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
/**
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
}
|