@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/src/scene.ts
CHANGED
|
@@ -17,16 +17,19 @@
|
|
|
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"
|
|
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
22
|
import { getOwner, onCleanup } from "@solidrt/core"
|
|
23
|
+
import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
|
|
23
24
|
// The scene's lookAt() aims a node; math's builds a camera's view matrix -
|
|
24
25
|
// the same pairing (and the same name) as Three's Object3D/Matrix4.
|
|
25
|
-
import { compose, copy, eulerFromQuat, identity, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quatFromEuler, quatFromFrame, quatNormalize, transformPoint } from "./math.ts"
|
|
26
|
+
import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quatFromEuler, quatFromFrame, quatNormalize, transformPoint, transformVector } from "./math.ts"
|
|
26
27
|
import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
|
|
27
|
-
import { geometryBuffers } from "./geometry.ts"
|
|
28
|
+
import { geometryBounds, geometryBuffers } from "./geometry.ts"
|
|
28
29
|
import type { Geometry } from "./geometry.ts"
|
|
30
|
+
import { backgroundPipeline } from "./material.ts"
|
|
29
31
|
import type { Material } from "./material.ts"
|
|
32
|
+
import { createBvh, rayBoxDistance } from "./bvh.ts"
|
|
30
33
|
|
|
31
34
|
const IDENTITY = mat4()
|
|
32
35
|
const RESOLVED = Promise.resolve()
|
|
@@ -43,6 +46,11 @@ let localScratch = mat4()
|
|
|
43
46
|
let pointScratch: Vec4 = [0, 0, 0, 0]
|
|
44
47
|
let aimScratch: Vec3 = [0, 0, 0]
|
|
45
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]
|
|
46
54
|
|
|
47
55
|
// The scene half a node needs to reach: attach/detach entries and schedule
|
|
48
56
|
// a sync. Kept separate from the public Scene type so internals stay off
|
|
@@ -68,6 +76,17 @@ export type SceneNode = {
|
|
|
68
76
|
quaternion: Quat
|
|
69
77
|
scale: Vec3
|
|
70
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
|
|
71
90
|
_localDirty: boolean
|
|
72
91
|
_local: Mat4
|
|
73
92
|
_world: Mat4
|
|
@@ -82,6 +101,57 @@ export type Mesh = SceneNode & {
|
|
|
82
101
|
_hidden: boolean
|
|
83
102
|
_fresh: boolean
|
|
84
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
|
|
85
155
|
}
|
|
86
156
|
|
|
87
157
|
export type CameraUpdate = {
|
|
@@ -96,6 +166,9 @@ export type CameraUpdate = {
|
|
|
96
166
|
|
|
97
167
|
export type SceneOptions = {
|
|
98
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
|
|
99
172
|
label?: string
|
|
100
173
|
/** `autoFree: false` opts out of owner-scoped auto-dispose (then call dispose yourself). */
|
|
101
174
|
autoFree?: boolean
|
|
@@ -111,6 +184,20 @@ export type Scene = {
|
|
|
111
184
|
/** Partial camera update; absent keys keep their current value. */
|
|
112
185
|
setCamera(update: CameraUpdate): void
|
|
113
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
|
|
114
201
|
/**
|
|
115
202
|
* Project a world point to scene pixels: origin top-left, y down - the
|
|
116
203
|
* output texture's own coordinate space, ready for overlay layout (HUD
|
|
@@ -123,6 +210,45 @@ export type Scene = {
|
|
|
123
210
|
/** The camera's view-projection matrix, copied into `out` (or a fresh
|
|
124
211
|
* mat4). The batch escape hatch; for single points use project(). */
|
|
125
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
|
|
126
252
|
/** Destroy the target (entries die with it). Idempotent. Geometry
|
|
127
253
|
* buffers and material pipelines are shared and survive - they are
|
|
128
254
|
* app-lifetime (see geometry.ts / material.ts). */
|
|
@@ -157,6 +283,7 @@ export function createMesh(geometry: Geometry, material: Material): Mesh {
|
|
|
157
283
|
mesh._hidden = false
|
|
158
284
|
mesh._fresh = false
|
|
159
285
|
mesh._params = null
|
|
286
|
+
mesh._pickLeaf = null
|
|
160
287
|
return mesh
|
|
161
288
|
}
|
|
162
289
|
|
|
@@ -411,6 +538,44 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
411
538
|
let disposed = false
|
|
412
539
|
let scheduled = false
|
|
413
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
|
+
|
|
414
579
|
let fov = 60
|
|
415
580
|
let near = 0.1
|
|
416
581
|
let far = 100
|
|
@@ -481,6 +646,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
481
646
|
// Moved while hidden: write the fresh matrix on unhide.
|
|
482
647
|
mesh._fresh = true
|
|
483
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)
|
|
484
653
|
}
|
|
485
654
|
}
|
|
486
655
|
for (let c of node.children) walk(c, changed, shown)
|
|
@@ -514,19 +683,36 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
514
683
|
let seed: ShaderParams = mesh.material.normalMatrix
|
|
515
684
|
? { uModel: IDENTITY, uNormal: IDENTITY, ...mesh.material.params, ...mesh._params }
|
|
516
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.
|
|
517
691
|
mesh._entry = addDraw(texture, mesh.material.pipeline(), seed, {
|
|
518
692
|
buffer: bufs.buffer,
|
|
519
693
|
indexBuffer: bufs.index,
|
|
520
694
|
indexFormat: bufs.indexFormat,
|
|
521
695
|
textures: mesh.material.textures,
|
|
696
|
+
instanceCount: 0,
|
|
522
697
|
})
|
|
523
|
-
mesh.
|
|
698
|
+
entryOrder.push(mesh._entry)
|
|
699
|
+
mesh._hidden = true
|
|
524
700
|
mesh._fresh = true
|
|
525
701
|
this._schedule()
|
|
526
702
|
},
|
|
527
703
|
_detach(mesh) {
|
|
528
|
-
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
|
+
}
|
|
529
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
|
+
}
|
|
530
716
|
},
|
|
531
717
|
_setParams(mesh, params) {
|
|
532
718
|
if (mesh._entry !== null && !disposed) setDrawParams(texture, mesh._entry, params)
|
|
@@ -536,6 +722,127 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
536
722
|
let root = makeNode("group")
|
|
537
723
|
root._scene = hooks
|
|
538
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
|
+
|
|
539
846
|
let scene: Scene = {
|
|
540
847
|
texture,
|
|
541
848
|
root,
|
|
@@ -557,6 +864,21 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
557
864
|
cameraDirty = true
|
|
558
865
|
hooks._schedule()
|
|
559
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
|
+
},
|
|
560
882
|
project(point) {
|
|
561
883
|
ensureCamera()
|
|
562
884
|
transformPoint(clip, viewProj, point)
|
|
@@ -570,12 +892,79 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
570
892
|
ensureCamera()
|
|
571
893
|
return copy(out ?? mat4(), viewProj)
|
|
572
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
|
+
},
|
|
573
954
|
dispose() {
|
|
574
955
|
if (disposed) return
|
|
575
956
|
disposed = true
|
|
576
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
|
+
}
|
|
577
965
|
},
|
|
578
966
|
}
|
|
967
|
+
if (opts?.background !== undefined) scene.setBackground(opts.background)
|
|
579
968
|
if (opts?.autoFree !== false && getOwner()) onCleanup(() => scene.dispose())
|
|
580
969
|
return scene
|
|
581
970
|
}
|