@solidrt/3d 0.0.48 → 0.0.50
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 +162 -13
- package/README.md +19 -5
- 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 +66 -5
- package/src/geometry.ts +29 -0
- package/src/index.ts +5 -5
- package/src/material.ts +163 -55
- package/src/math.ts +42 -0
- package/src/order.ts +51 -0
- package/src/scene.ts +516 -28
package/src/scene.ts
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// component boundary (components.tsx). A scene compiles to one draw
|
|
4
4
|
// target: every mesh is one draw entry whose uModel (and, for materials
|
|
5
5
|
// declaring it, uNormal) this module keeps in step with the tree, and the
|
|
6
|
-
// camera is the target's SHARED uViewProj + uCamPos
|
|
7
|
-
// per camera move, not one write per mesh.
|
|
8
|
-
// shared params tolerate zero
|
|
9
|
-
// declaring material arrives), so no
|
|
6
|
+
// camera is the target's SHARED uViewProj + uCamPos + uCamRight/uCamUp -
|
|
7
|
+
// one setTargetParams per camera move, not one write per mesh. The
|
|
8
|
+
// non-matrix names ride unconditionally: shared params tolerate zero
|
|
9
|
+
// coverage (stored and skipped until a declaring material arrives), so no
|
|
10
|
+
// bookkeeping tracks who reads them. scene.setParams merges app-owned
|
|
11
|
+
// names into the same set.
|
|
10
12
|
// Mutations batch to a microtask, so a burst of writes (a whole subtree
|
|
11
13
|
// moved, many effects in one flush) syncs once.
|
|
12
14
|
//
|
|
@@ -17,16 +19,20 @@
|
|
|
17
19
|
// each write lands here, the microtask syncs the affected uModels, and the
|
|
18
20
|
// flush renders once that frame.
|
|
19
21
|
|
|
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 { addDraw, createDrawTarget, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawOrder, setDrawParams, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
|
|
23
|
+
import type { DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
|
|
22
24
|
import { getOwner, onCleanup } from "@solidrt/core"
|
|
25
|
+
import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
|
|
23
26
|
// The scene's lookAt() aims a node; math's builds a camera's view matrix -
|
|
24
27
|
// 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"
|
|
28
|
+
import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quat, quatFromEuler, quatFromFrame, quatNormalize, transformPoint, transformVector } from "./math.ts"
|
|
26
29
|
import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
|
|
27
|
-
import { geometryBuffers } from "./geometry.ts"
|
|
30
|
+
import { geometryBounds, geometryBuffers } from "./geometry.ts"
|
|
28
31
|
import type { Geometry } from "./geometry.ts"
|
|
32
|
+
import { backgroundPipeline } from "./material.ts"
|
|
33
|
+
import { orderEntries } from "./order.ts"
|
|
29
34
|
import type { Material } from "./material.ts"
|
|
35
|
+
import { createBvh, rayBoxDistance } from "./bvh.ts"
|
|
30
36
|
|
|
31
37
|
const IDENTITY = mat4()
|
|
32
38
|
const RESOLVED = Promise.resolve()
|
|
@@ -43,6 +49,14 @@ let localScratch = mat4()
|
|
|
43
49
|
let pointScratch: Vec4 = [0, 0, 0, 0]
|
|
44
50
|
let aimScratch: Vec3 = [0, 0, 0]
|
|
45
51
|
let upScratch: Vec3 = [0, 0, 0]
|
|
52
|
+
// Picking narrowphase scratch: one candidate is tested at a time, so one
|
|
53
|
+
// set serves every raycast.
|
|
54
|
+
let pickInv = mat4()
|
|
55
|
+
let pickOrigin: Vec4 = [0, 0, 0, 0]
|
|
56
|
+
let pickDir: Vec3 = [0, 0, 0]
|
|
57
|
+
// setTransform's rotation compare happens AFTER conversion, so an euler and
|
|
58
|
+
// the quaternion it produces are the same write. Nothing outlives the call.
|
|
59
|
+
let rotScratch = quat()
|
|
46
60
|
|
|
47
61
|
// The scene half a node needs to reach: attach/detach entries and schedule
|
|
48
62
|
// a sync. Kept separate from the public Scene type so internals stay off
|
|
@@ -54,6 +68,7 @@ type SceneHooks = {
|
|
|
54
68
|
_attach(mesh: Mesh): void
|
|
55
69
|
_detach(mesh: Mesh): void
|
|
56
70
|
_setParams(mesh: Mesh, params: ShaderParams): void
|
|
71
|
+
_reorder(): void
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
export type SceneNode = {
|
|
@@ -68,6 +83,17 @@ export type SceneNode = {
|
|
|
68
83
|
quaternion: Quat
|
|
69
84
|
scale: Vec3
|
|
70
85
|
visible: boolean
|
|
86
|
+
/** Pointer event handlers - plain fields, assign freely (they touch no
|
|
87
|
+
* GPU state, so they need no setTransform-style write path; components
|
|
88
|
+
* sync their props here). Down/move/up dispatch on the hit mesh and
|
|
89
|
+
* bubble through its ancestors (stopPropagation stops the walk);
|
|
90
|
+
* enter/leave fire on the mesh alone. Events flow once the element
|
|
91
|
+
* showing the scene carries `scene.handlers`. */
|
|
92
|
+
onPointerDown?: (event: ScenePointerEvent) => void
|
|
93
|
+
onPointerMove?: (event: ScenePointerEvent) => void
|
|
94
|
+
onPointerUp?: (event: ScenePointerEvent) => void
|
|
95
|
+
onPointerEnter?: (event: ScenePointerEvent) => void
|
|
96
|
+
onPointerLeave?: (event: ScenePointerEvent) => void
|
|
71
97
|
_localDirty: boolean
|
|
72
98
|
_local: Mat4
|
|
73
99
|
_world: Mat4
|
|
@@ -78,10 +104,72 @@ export type Mesh = SceneNode & {
|
|
|
78
104
|
kind: "mesh"
|
|
79
105
|
geometry: Geometry
|
|
80
106
|
material: Material
|
|
107
|
+
/** Explicit draw-order key (default 0), Three's name: lower draws first.
|
|
108
|
+
* Sorts within the opaque group and within the transparent group; the
|
|
109
|
+
* transparent group always follows the opaque one. Set with setRenderOrder. */
|
|
110
|
+
renderOrder: number
|
|
81
111
|
_entry: DrawId | null
|
|
112
|
+
/** material.transparent as of the last attach - the entry's actual
|
|
113
|
+
* pipeline state, and what _detach counts against (setMaterial swaps
|
|
114
|
+
* mesh.material before the rebuild). */
|
|
115
|
+
_transparent: boolean
|
|
116
|
+
/** World-space center of the geometry bounds, kept by the sync walk
|
|
117
|
+
* beside the picking leaf: the transparent sort key. */
|
|
118
|
+
_center: Vec3
|
|
82
119
|
_hidden: boolean
|
|
83
120
|
_fresh: boolean
|
|
84
121
|
_params: ShaderParams | null
|
|
122
|
+
_pickLeaf: number | null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One picking intersection: the mesh, the camera-ray distance in world
|
|
126
|
+
* units, and the world-space point - Three's intersect result minus the
|
|
127
|
+
* triangle fields (`face`, `uv`), which cannot exist at the volume tier. */
|
|
128
|
+
export type Hit = {
|
|
129
|
+
mesh: Mesh
|
|
130
|
+
distance: number
|
|
131
|
+
point: Vec3
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The event a mesh (or ancestor group) handler receives: the element
|
|
136
|
+
* pointer vocabulary carried over, plus the 3D fields. `point`/`distance`
|
|
137
|
+
* are null exactly when the ray misses the dispatch mesh - which happens
|
|
138
|
+
* only during a captured drag or on a leave.
|
|
139
|
+
*/
|
|
140
|
+
export type ScenePointerEvent = {
|
|
141
|
+
/** The mesh the event is about (the hit, or the captured mesh during a
|
|
142
|
+
* drag) - constant while the event bubbles. */
|
|
143
|
+
mesh: Mesh
|
|
144
|
+
/** Node whose handler is running; changes as the event bubbles. */
|
|
145
|
+
currentTarget: SceneNode
|
|
146
|
+
/** World-space hit point on `mesh`, or null when the ray misses it. */
|
|
147
|
+
point: Vec3 | null
|
|
148
|
+
/** Camera-ray distance to `point` in world units, or null with it. */
|
|
149
|
+
distance: number | null
|
|
150
|
+
/** Pointer position in scene pixels - project()'s coordinate space. */
|
|
151
|
+
x: number
|
|
152
|
+
y: number
|
|
153
|
+
pointerId: number
|
|
154
|
+
pointerType: string
|
|
155
|
+
button?: number
|
|
156
|
+
shiftKey: boolean
|
|
157
|
+
ctrlKey: boolean
|
|
158
|
+
altKey: boolean
|
|
159
|
+
metaKey: boolean
|
|
160
|
+
/** Stops the bubble walk after the current handler. */
|
|
161
|
+
stopPropagation(): void
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Element handlers wiring a scene's pointer events: spread onto whatever
|
|
165
|
+
* element shows `scene.texture` (the built-in `<Scene>` leaf wires them
|
|
166
|
+
* automatically). `scene.handlers` expects the leaf laid out at the target
|
|
167
|
+
* size; a split-resolution leaf (supersampling) uses scene.handlersFor. */
|
|
168
|
+
export type SceneHandlers = {
|
|
169
|
+
onPointerDown(event: ElementPointerEvent): void
|
|
170
|
+
onPointerMove(event: ElementPointerEvent): void
|
|
171
|
+
onPointerUp(event: ElementPointerEvent): void
|
|
172
|
+
onPointerLeave(event: ElementPointerEvent): void
|
|
85
173
|
}
|
|
86
174
|
|
|
87
175
|
export type CameraUpdate = {
|
|
@@ -96,6 +184,9 @@ export type CameraUpdate = {
|
|
|
96
184
|
|
|
97
185
|
export type SceneOptions = {
|
|
98
186
|
clearColor?: [number, number, number, number]
|
|
187
|
+
/** Fragment GLSL drawn behind the meshes, inside the scene's own pass -
|
|
188
|
+
* see setBackground. */
|
|
189
|
+
background?: string
|
|
99
190
|
label?: string
|
|
100
191
|
/** `autoFree: false` opts out of owner-scoped auto-dispose (then call dispose yourself). */
|
|
101
192
|
autoFree?: boolean
|
|
@@ -111,6 +202,29 @@ export type Scene = {
|
|
|
111
202
|
/** Partial camera update; absent keys keep their current value. */
|
|
112
203
|
setCamera(update: CameraUpdate): void
|
|
113
204
|
setSize(width: number, height: number): void
|
|
205
|
+
/**
|
|
206
|
+
* Scene-wide uniforms: merge app-owned names into the target's SHARED
|
|
207
|
+
* params, beside the standard uViewProj/uCamPos/uCamRight/uCamUp the
|
|
208
|
+
* camera writes. One write per frame however many meshes read the name
|
|
209
|
+
* (a clock, a sun direction, fog) - the per-mesh channel is
|
|
210
|
+
* setMeshParams. Merge semantics, no unset; a material that does not
|
|
211
|
+
* declare a name simply skips it. Frame-rate-safe like setTransform.
|
|
212
|
+
*/
|
|
213
|
+
setParams(params: ShaderParams): void
|
|
214
|
+
/**
|
|
215
|
+
* Set, replace, or remove (null) the scene's background: fragment GLSL
|
|
216
|
+
* drawn as the FIRST entry of the scene's own pass - one target, no
|
|
217
|
+
* second texture layer, no separate resize plumbing. The fragment gets
|
|
218
|
+
* the shader-target contract exactly (vUV 0..1 top-left origin,
|
|
219
|
+
* iResolution, fragColor; no `#version` line means the standard
|
|
220
|
+
* preamble), so a source written for createShaderTexture ports verbatim.
|
|
221
|
+
* It draws with depth off before every mesh and covers the whole target,
|
|
222
|
+
* so the clearColor stops being visible. Three's `scene.background =
|
|
223
|
+
* color` is `clearColor` here; the texture form can arrive later as a
|
|
224
|
+
* non-breaking widening. No app-driven uniforms in v1 - a background is
|
|
225
|
+
* static art (anything animated is a mesh's own shaderMaterial).
|
|
226
|
+
*/
|
|
227
|
+
setBackground(source: string | null): void
|
|
114
228
|
/**
|
|
115
229
|
* Project a world point to scene pixels: origin top-left, y down - the
|
|
116
230
|
* output texture's own coordinate space, ready for overlay layout (HUD
|
|
@@ -123,6 +237,45 @@ export type Scene = {
|
|
|
123
237
|
/** The camera's view-projection matrix, copied into `out` (or a fresh
|
|
124
238
|
* mat4). The batch escape hatch; for single points use project(). */
|
|
125
239
|
viewProj(out?: Mat4): Mat4
|
|
240
|
+
/**
|
|
241
|
+
* Cast the camera ray through a scene pixel (top-left origin, y down -
|
|
242
|
+
* project()'s space, the inverse direction) and return every visible
|
|
243
|
+
* mesh it hits, nearest first. The volume tier: hits test the mesh's
|
|
244
|
+
* bounding box, transformed exactly (any node transform, including
|
|
245
|
+
* non-uniform scale), so a hit through a concave gap - a knot's hole -
|
|
246
|
+
* still reports. Broadphase runs over a BVH kept in step by the sync
|
|
247
|
+
* walk: a query costs O(log meshes), not O(meshes). Reflects pending
|
|
248
|
+
* setTransform/add writes immediately (the sync is flushed).
|
|
249
|
+
*/
|
|
250
|
+
pick(x: number, y: number): Hit[]
|
|
251
|
+
/** pick()'s world-space half: the same query along an arbitrary ray.
|
|
252
|
+
* `direction` need not be normalized; distances are world units. */
|
|
253
|
+
raycast(origin: Vec3, direction: Vec3): Hit[]
|
|
254
|
+
/**
|
|
255
|
+
* Element pointer handlers driving the mesh event fields
|
|
256
|
+
* (onPointerDown/Move/Up/Enter/Leave on nodes): spread onto the element
|
|
257
|
+
* that shows `scene.texture`. The `<Scene>` component's built-in leaf
|
|
258
|
+
* carries them automatically; with `output` (or imperative use), spread
|
|
259
|
+
* them yourself: `<texture src={scene.texture} {...scene.handlers} />`.
|
|
260
|
+
* Semantics mirror element pointer events: nearest hit wins, down/move/
|
|
261
|
+
* up bubble mesh -> ancestors, pointer-down captures the mesh until up
|
|
262
|
+
* (moves keep flowing to it off-mesh, the platform's captured-drag
|
|
263
|
+
* rule), enter/leave pair on hover changes. Hover reacts to pointer
|
|
264
|
+
* MOTION - a mesh animating under a still pointer fires nothing until
|
|
265
|
+
* the pointer moves (the element hit-test has the same limit).
|
|
266
|
+
*
|
|
267
|
+
* Coordinates assume the leaf is LAID OUT at the target size - true for
|
|
268
|
+
* the built-in leaf and a d-texture at natural size, under any ancestor
|
|
269
|
+
* transforms or viewBox fits (the hit test undoes them). A leaf laid out
|
|
270
|
+
* at a different size needs handlersFor instead.
|
|
271
|
+
*/
|
|
272
|
+
handlers: SceneHandlers
|
|
273
|
+
/** handlers for a leaf whose LAYOUT size differs from the target size -
|
|
274
|
+
* the supersampling pattern, where the target renders larger than the
|
|
275
|
+
* box showing it. `layout` is read per event, so a resize-reactive
|
|
276
|
+
* layout just works: `scene.handlersFor(() => ({ width: w(), height:
|
|
277
|
+
* h() }))`. */
|
|
278
|
+
handlersFor(layout: () => { width: number; height: number }): SceneHandlers
|
|
126
279
|
/** Destroy the target (entries die with it). Idempotent. Geometry
|
|
127
280
|
* buffers and material pipelines are shared and survive - they are
|
|
128
281
|
* app-lifetime (see geometry.ts / material.ts). */
|
|
@@ -153,10 +306,14 @@ export function createMesh(geometry: Geometry, material: Material): Mesh {
|
|
|
153
306
|
let mesh = makeNode("mesh") as Mesh
|
|
154
307
|
mesh.geometry = geometry
|
|
155
308
|
mesh.material = material
|
|
309
|
+
mesh.renderOrder = 0
|
|
156
310
|
mesh._entry = null
|
|
311
|
+
mesh._transparent = false
|
|
312
|
+
mesh._center = [0, 0, 0]
|
|
157
313
|
mesh._hidden = false
|
|
158
314
|
mesh._fresh = false
|
|
159
315
|
mesh._params = null
|
|
316
|
+
mesh._pickLeaf = null
|
|
160
317
|
return mesh
|
|
161
318
|
}
|
|
162
319
|
|
|
@@ -213,33 +370,55 @@ export type TransformUpdate = {
|
|
|
213
370
|
* Values are copied in; absent keys keep their current value. This is also
|
|
214
371
|
* the frame-rate escape hatch: call it from onFrame on a node grabbed via
|
|
215
372
|
* `ref`, bypassing signals entirely.
|
|
373
|
+
*
|
|
374
|
+
* A write that changes nothing schedules nothing, so driving every node
|
|
375
|
+
* unconditionally from onFrame costs only the compare for the nodes that
|
|
376
|
+
* did not move. Rotation is compared after conversion, so passing an euler
|
|
377
|
+
* equal to the node's current quaternion is also a no-op.
|
|
216
378
|
*/
|
|
217
379
|
export function setTransform(node: SceneNode, update: TransformUpdate): void {
|
|
380
|
+
let r = update.rotation
|
|
381
|
+
let q = update.quaternion
|
|
382
|
+
if (r !== undefined && q !== undefined) {
|
|
383
|
+
throw new Error("Pass rotation or quaternion to setTransform, not both")
|
|
384
|
+
}
|
|
385
|
+
// A no-op write costs nothing: driving every node from onFrame is the
|
|
386
|
+
// intended shape, and most nodes did not move. Exact compares, like
|
|
387
|
+
// setVisible - a value that survives a float round trip unchanged is the
|
|
388
|
+
// same value, and an epsilon would need a scale-dependent one anyway.
|
|
389
|
+
let changed = false
|
|
218
390
|
let p = update.position
|
|
219
|
-
if (p) {
|
|
391
|
+
if (p && (p[0] !== node.position[0] || p[1] !== node.position[1] || p[2] !== node.position[2])) {
|
|
220
392
|
node.position[0] = p[0]
|
|
221
393
|
node.position[1] = p[1]
|
|
222
394
|
node.position[2] = p[2]
|
|
395
|
+
changed = true
|
|
223
396
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (r !== undefined
|
|
227
|
-
|
|
397
|
+
if (r !== undefined) quatFromEuler(rotScratch, r)
|
|
398
|
+
else if (q !== undefined) quatNormalize(rotScratch, q)
|
|
399
|
+
if (r !== undefined || q !== undefined) {
|
|
400
|
+
let n = node.quaternion
|
|
401
|
+
if (rotScratch[0] !== n[0] || rotScratch[1] !== n[1] || rotScratch[2] !== n[2] || rotScratch[3] !== n[3]) {
|
|
402
|
+
n[0] = rotScratch[0]
|
|
403
|
+
n[1] = rotScratch[1]
|
|
404
|
+
n[2] = rotScratch[2]
|
|
405
|
+
n[3] = rotScratch[3]
|
|
406
|
+
changed = true
|
|
407
|
+
}
|
|
228
408
|
}
|
|
229
|
-
if (r !== undefined) quatFromEuler(node.quaternion, r)
|
|
230
|
-
else if (q !== undefined) quatNormalize(node.quaternion, q)
|
|
231
409
|
let s = update.scale
|
|
232
410
|
if (s !== undefined) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
node.scale[
|
|
239
|
-
node.scale[
|
|
240
|
-
|
|
411
|
+
let sx = typeof s === "number" ? s : s[0]
|
|
412
|
+
let sy = typeof s === "number" ? s : s[1]
|
|
413
|
+
let sz = typeof s === "number" ? s : s[2]
|
|
414
|
+
if (sx !== node.scale[0] || sy !== node.scale[1] || sz !== node.scale[2]) {
|
|
415
|
+
node.scale[0] = sx
|
|
416
|
+
node.scale[1] = sy
|
|
417
|
+
node.scale[2] = sz
|
|
418
|
+
changed = true
|
|
241
419
|
}
|
|
242
420
|
}
|
|
421
|
+
if (!changed) return
|
|
243
422
|
node._localDirty = true
|
|
244
423
|
node._scene?._schedule()
|
|
245
424
|
}
|
|
@@ -355,8 +534,15 @@ export function setVisible(node: SceneNode, visible: boolean): void {
|
|
|
355
534
|
node._scene?._schedule()
|
|
356
535
|
}
|
|
357
536
|
|
|
358
|
-
/**
|
|
359
|
-
|
|
537
|
+
/** Set a mesh's explicit draw-order key (see Mesh.renderOrder). */
|
|
538
|
+
export function setRenderOrder(mesh: Mesh, order: number): void {
|
|
539
|
+
if (mesh.renderOrder === order) return
|
|
540
|
+
mesh.renderOrder = order
|
|
541
|
+
mesh._scene?._reorder()
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Swap a mesh's geometry: its draw entry is rebuilt (the scene re-sorts
|
|
545
|
+
* the list, so the mesh keeps its place). */
|
|
360
546
|
export function setGeometry(mesh: Mesh, geometry: Geometry): void {
|
|
361
547
|
if (mesh.geometry === geometry) return
|
|
362
548
|
mesh.geometry = geometry
|
|
@@ -411,6 +597,61 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
411
597
|
let disposed = false
|
|
412
598
|
let scheduled = false
|
|
413
599
|
|
|
600
|
+
// Picking state: the broadphase tree over world boxes, kept current by
|
|
601
|
+
// the sync walk (the meshes it touches are exactly the leaves to move),
|
|
602
|
+
// and the pointer bookkeeping behind scene.handlers.
|
|
603
|
+
let bvh = createBvh<Mesh>()
|
|
604
|
+
let capture = new Map<number, Mesh>()
|
|
605
|
+
let hover = new Map<number, Mesh>()
|
|
606
|
+
|
|
607
|
+
// Live meshes (those holding a draw entry) in add order; the background
|
|
608
|
+
// entry never joins this list. Draw order is derived from it by
|
|
609
|
+
// orderEntries (order.ts) whenever orderDirty. Camera moves and
|
|
610
|
+
// transparent-mesh moves only dirty the order when two or more transparent
|
|
611
|
+
// meshes exist - fewer cannot change relative order.
|
|
612
|
+
let meshes: Mesh[] = []
|
|
613
|
+
let transparentCount = 0
|
|
614
|
+
let orderDirty = false
|
|
615
|
+
// The order last handed to the engine: a resort that lands on the same
|
|
616
|
+
// permutation (the common case under a moving camera) issues nothing.
|
|
617
|
+
let lastOrder: DrawId[] = []
|
|
618
|
+
let background: { entry: DrawId; pipeline: RenderPipelineId; program: ProgramId } | null = null
|
|
619
|
+
let sortEntries = () => {
|
|
620
|
+
orderDirty = false
|
|
621
|
+
let order = orderEntries(meshes, view, background?.entry)
|
|
622
|
+
if (order.length === lastOrder.length && order.every((id, i) => id === lastOrder[i])) return
|
|
623
|
+
lastOrder = order
|
|
624
|
+
setDrawOrder(texture, order)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Reinsert or refit a mesh's broadphase leaf from its fresh world matrix:
|
|
628
|
+
// the local box's center/extents carried through the absolute matrix (the
|
|
629
|
+
// standard tight-AABB-of-a-transformed-AABB construction).
|
|
630
|
+
let updateLeaf = (mesh: Mesh): void => {
|
|
631
|
+
let b = geometryBounds(mesh.geometry)
|
|
632
|
+
let m = mesh._world
|
|
633
|
+
let cx = (b[0]! + b[3]!) / 2
|
|
634
|
+
let cy = (b[1]! + b[4]!) / 2
|
|
635
|
+
let cz = (b[2]! + b[5]!) / 2
|
|
636
|
+
let ex = (b[3]! - b[0]!) / 2
|
|
637
|
+
let ey = (b[4]! - b[1]!) / 2
|
|
638
|
+
let ez = (b[5]! - b[2]!) / 2
|
|
639
|
+
let wx = m[0] * cx + m[4] * cy + m[8] * cz + m[12]
|
|
640
|
+
let wy = m[1] * cx + m[5] * cy + m[9] * cz + m[13]
|
|
641
|
+
let wz = m[2] * cx + m[6] * cy + m[10] * cz + m[14]
|
|
642
|
+
mesh._center[0] = wx
|
|
643
|
+
mesh._center[1] = wy
|
|
644
|
+
mesh._center[2] = wz
|
|
645
|
+
let rx = Math.abs(m[0]) * ex + Math.abs(m[4]) * ey + Math.abs(m[8]) * ez
|
|
646
|
+
let ry = Math.abs(m[1]) * ex + Math.abs(m[5]) * ey + Math.abs(m[9]) * ez
|
|
647
|
+
let rz = Math.abs(m[2]) * ex + Math.abs(m[6]) * ey + Math.abs(m[10]) * ez
|
|
648
|
+
if (mesh._pickLeaf === null) {
|
|
649
|
+
mesh._pickLeaf = bvh.insert(mesh, wx - rx, wy - ry, wz - rz, wx + rx, wy + ry, wz + rz)
|
|
650
|
+
} else {
|
|
651
|
+
bvh.update(mesh._pickLeaf, wx - rx, wy - ry, wz - rz, wx + rx, wy + ry, wz + rz)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
414
655
|
let fov = 60
|
|
415
656
|
let near = 0.1
|
|
416
657
|
let far = 100
|
|
@@ -445,7 +686,16 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
445
686
|
// holds. Entries are untouched - uModel is camera-independent, and
|
|
446
687
|
// uCamPos is stored even when no current material declares it.
|
|
447
688
|
cameraPending = false
|
|
448
|
-
|
|
689
|
+
// The camera basis rides along: the view matrix's first two rows are
|
|
690
|
+
// the camera's world-space right and up (no clip flip - that lives in
|
|
691
|
+
// the projection), so a billboard needs no reconstruction from uViewProj.
|
|
692
|
+
setTargetParams(texture, {
|
|
693
|
+
uViewProj: viewProj,
|
|
694
|
+
uCamPos: eye,
|
|
695
|
+
uCamRight: [view[0], view[4], view[8]],
|
|
696
|
+
uCamUp: [view[1], view[5], view[9]],
|
|
697
|
+
})
|
|
698
|
+
if (transparentCount > 1) orderDirty = true
|
|
449
699
|
}
|
|
450
700
|
let walk = (node: SceneNode, parentChanged: boolean, parentVisible: boolean) => {
|
|
451
701
|
let changed = parentChanged
|
|
@@ -467,6 +717,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
467
717
|
mesh._hidden = !shown
|
|
468
718
|
if (shown) mesh._fresh = true
|
|
469
719
|
}
|
|
720
|
+
if (changed && mesh._transparent && transparentCount > 1) orderDirty = true
|
|
470
721
|
if (!mesh._hidden && (changed || mesh._fresh)) {
|
|
471
722
|
if (mesh.material.normalMatrix) {
|
|
472
723
|
setDrawParams(texture, mesh._entry, {
|
|
@@ -481,11 +732,16 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
481
732
|
// Moved while hidden: write the fresh matrix on unhide.
|
|
482
733
|
mesh._fresh = true
|
|
483
734
|
}
|
|
735
|
+
// The broadphase leaf follows the world matrix - hidden meshes
|
|
736
|
+
// included (they stay in the tree and are skipped at query time,
|
|
737
|
+
// so unhiding never picks against a stale box).
|
|
738
|
+
if (changed || mesh._pickLeaf === null) updateLeaf(mesh)
|
|
484
739
|
}
|
|
485
740
|
}
|
|
486
741
|
for (let c of node.children) walk(c, changed, shown)
|
|
487
742
|
}
|
|
488
743
|
walk(root, false, true)
|
|
744
|
+
if (orderDirty) sortEntries()
|
|
489
745
|
}
|
|
490
746
|
|
|
491
747
|
let hooks: SceneHooks = {
|
|
@@ -514,28 +770,175 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
514
770
|
let seed: ShaderParams = mesh.material.normalMatrix
|
|
515
771
|
? { uModel: IDENTITY, uNormal: IDENTITY, ...mesh.material.params, ...mesh._params }
|
|
516
772
|
: { uModel: IDENTITY, ...mesh.material.params, ...mesh._params }
|
|
773
|
+
// The entry starts switched off: it has no world matrix yet - the walk
|
|
774
|
+
// in sync() computes one - and _schedule() defers that to a microtask,
|
|
775
|
+
// so added live it would draw at the seeded identity until then. The
|
|
776
|
+
// mismatch branch in sync() turns it on in the same pass that writes
|
|
777
|
+
// uModel.
|
|
517
778
|
mesh._entry = addDraw(texture, mesh.material.pipeline(), seed, {
|
|
518
779
|
buffer: bufs.buffer,
|
|
519
780
|
indexBuffer: bufs.index,
|
|
520
781
|
indexFormat: bufs.indexFormat,
|
|
521
782
|
textures: mesh.material.textures,
|
|
783
|
+
instanceCount: 0,
|
|
522
784
|
})
|
|
523
|
-
mesh
|
|
785
|
+
meshes.push(mesh)
|
|
786
|
+
mesh._transparent = mesh.material.transparent === true
|
|
787
|
+
if (mesh._transparent) transparentCount++
|
|
788
|
+
orderDirty = true
|
|
789
|
+
mesh._hidden = true
|
|
524
790
|
mesh._fresh = true
|
|
525
791
|
this._schedule()
|
|
526
792
|
},
|
|
527
793
|
_detach(mesh) {
|
|
528
|
-
if (mesh._entry !== null
|
|
794
|
+
if (mesh._entry !== null) {
|
|
795
|
+
if (!disposed) removeDraw(texture, mesh._entry)
|
|
796
|
+
let i = meshes.indexOf(mesh)
|
|
797
|
+
if (i >= 0) meshes.splice(i, 1)
|
|
798
|
+
if (mesh._transparent) transparentCount--
|
|
799
|
+
orderDirty = true
|
|
800
|
+
}
|
|
529
801
|
mesh._entry = null
|
|
802
|
+
// The leaf goes with the entry: a geometry swap rebuilds the entry,
|
|
803
|
+
// and re-inserting is what picks up the new local bounds.
|
|
804
|
+
if (mesh._pickLeaf !== null) {
|
|
805
|
+
bvh.remove(mesh._pickLeaf)
|
|
806
|
+
mesh._pickLeaf = null
|
|
807
|
+
}
|
|
530
808
|
},
|
|
531
809
|
_setParams(mesh, params) {
|
|
532
810
|
if (mesh._entry !== null && !disposed) setDrawParams(texture, mesh._entry, params)
|
|
533
811
|
},
|
|
812
|
+
_reorder() {
|
|
813
|
+
orderDirty = true
|
|
814
|
+
this._schedule()
|
|
815
|
+
},
|
|
534
816
|
}
|
|
535
817
|
|
|
536
818
|
let root = makeNode("group")
|
|
537
819
|
root._scene = hooks
|
|
538
820
|
|
|
821
|
+
// --- Pointer event dispatch (behind scene.handlers) ---
|
|
822
|
+
|
|
823
|
+
type BubbleName = "onPointerDown" | "onPointerMove" | "onPointerUp"
|
|
824
|
+
type InternalEvent = ScenePointerEvent & { _stopped: boolean }
|
|
825
|
+
|
|
826
|
+
let makeEvent = (e: ElementPointerEvent, mesh: Mesh, x: number, y: number, point: Vec3 | null, distance: number | null): InternalEvent => {
|
|
827
|
+
let event: InternalEvent = {
|
|
828
|
+
mesh,
|
|
829
|
+
currentTarget: mesh,
|
|
830
|
+
point,
|
|
831
|
+
distance,
|
|
832
|
+
x,
|
|
833
|
+
y,
|
|
834
|
+
pointerId: e.pointerId,
|
|
835
|
+
pointerType: e.pointerType,
|
|
836
|
+
button: e.button,
|
|
837
|
+
shiftKey: e.shiftKey,
|
|
838
|
+
ctrlKey: e.ctrlKey,
|
|
839
|
+
altKey: e.altKey,
|
|
840
|
+
metaKey: e.metaKey,
|
|
841
|
+
_stopped: false,
|
|
842
|
+
stopPropagation() {
|
|
843
|
+
event._stopped = true
|
|
844
|
+
},
|
|
845
|
+
}
|
|
846
|
+
return event
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
let bubble = (name: BubbleName, event: InternalEvent): void => {
|
|
850
|
+
for (let n: SceneNode | null = event.mesh; n !== null && !event._stopped; n = n.parent) {
|
|
851
|
+
let handler = n[name]
|
|
852
|
+
if (handler) {
|
|
853
|
+
event.currentTarget = n
|
|
854
|
+
handler(event)
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// The captured mesh's own hit, if the ray still strikes it.
|
|
860
|
+
let hitOn = (mesh: Mesh, x: number, y: number): Hit | null => {
|
|
861
|
+
for (let h of scene.pick(x, y)) if (h.mesh === mesh) return h
|
|
862
|
+
return null
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// localX/localY arrive in the leaf's LAYOUT frame (the hit test undoes
|
|
866
|
+
// every transform above it, viewBox fits included), so a leaf laid out at
|
|
867
|
+
// the target size - the built-in <Scene> leaf, a d-texture at natural
|
|
868
|
+
// size - is already in scene pixels. Only a leaf deliberately laid out at
|
|
869
|
+
// a DIFFERENT size (the supersampling pattern) needs the ratio, and only
|
|
870
|
+
// the app knows that layout: handlersFor takes it.
|
|
871
|
+
let makeHandlers = (layout: (() => { width: number; height: number }) | null): SceneHandlers => {
|
|
872
|
+
let eventX = 0
|
|
873
|
+
let eventY = 0
|
|
874
|
+
let toScene = (e: ElementPointerEvent): void => {
|
|
875
|
+
if (layout === null) {
|
|
876
|
+
eventX = e.localX
|
|
877
|
+
eventY = e.localY
|
|
878
|
+
return
|
|
879
|
+
}
|
|
880
|
+
let l = layout()
|
|
881
|
+
eventX = e.localX * (l.width > 0 ? width / l.width : 1)
|
|
882
|
+
eventY = e.localY * (l.height > 0 ? height / l.height : 1)
|
|
883
|
+
}
|
|
884
|
+
return {
|
|
885
|
+
onPointerDown(e) {
|
|
886
|
+
toScene(e)
|
|
887
|
+
let hit = scene.pick(eventX, eventY)[0]
|
|
888
|
+
if (hit === undefined) return
|
|
889
|
+
capture.set(e.pointerId, hit.mesh)
|
|
890
|
+
bubble("onPointerDown", makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
|
|
891
|
+
},
|
|
892
|
+
onPointerMove(e) {
|
|
893
|
+
toScene(e)
|
|
894
|
+
let captured = capture.get(e.pointerId)
|
|
895
|
+
if (captured !== undefined) {
|
|
896
|
+
let hit = hitOn(captured, eventX, eventY)
|
|
897
|
+
bubble("onPointerMove", makeEvent(e, captured, eventX, eventY, hit ? hit.point : null, hit ? hit.distance : null))
|
|
898
|
+
return
|
|
899
|
+
}
|
|
900
|
+
let hit = scene.pick(eventX, eventY)[0]
|
|
901
|
+
let prev = hover.get(e.pointerId)
|
|
902
|
+
if (prev !== hit?.mesh) {
|
|
903
|
+
if (prev !== undefined) {
|
|
904
|
+
hover.delete(e.pointerId)
|
|
905
|
+
prev.onPointerLeave?.(makeEvent(e, prev, eventX, eventY, null, null))
|
|
906
|
+
}
|
|
907
|
+
if (hit !== undefined) {
|
|
908
|
+
hover.set(e.pointerId, hit.mesh)
|
|
909
|
+
hit.mesh.onPointerEnter?.(makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (hit !== undefined) {
|
|
913
|
+
bubble("onPointerMove", makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
|
|
914
|
+
}
|
|
915
|
+
},
|
|
916
|
+
onPointerUp(e) {
|
|
917
|
+
toScene(e)
|
|
918
|
+
let captured = capture.get(e.pointerId)
|
|
919
|
+
if (captured !== undefined) {
|
|
920
|
+
capture.delete(e.pointerId)
|
|
921
|
+
let hit = hitOn(captured, eventX, eventY)
|
|
922
|
+
bubble("onPointerUp", makeEvent(e, captured, eventX, eventY, hit ? hit.point : null, hit ? hit.distance : null))
|
|
923
|
+
return
|
|
924
|
+
}
|
|
925
|
+
let hit = scene.pick(eventX, eventY)[0]
|
|
926
|
+
if (hit !== undefined) {
|
|
927
|
+
bubble("onPointerUp", makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
|
|
928
|
+
}
|
|
929
|
+
},
|
|
930
|
+
onPointerLeave(e) {
|
|
931
|
+
let prev = hover.get(e.pointerId)
|
|
932
|
+
if (prev !== undefined) {
|
|
933
|
+
hover.delete(e.pointerId)
|
|
934
|
+
toScene(e)
|
|
935
|
+
prev.onPointerLeave?.(makeEvent(e, prev, eventX, eventY, null, null))
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
let handlers = makeHandlers(null)
|
|
941
|
+
|
|
539
942
|
let scene: Scene = {
|
|
540
943
|
texture,
|
|
541
944
|
root,
|
|
@@ -557,6 +960,24 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
557
960
|
cameraDirty = true
|
|
558
961
|
hooks._schedule()
|
|
559
962
|
},
|
|
963
|
+
setParams(params) {
|
|
964
|
+
if (!disposed) setTargetParams(texture, params)
|
|
965
|
+
},
|
|
966
|
+
setBackground(source) {
|
|
967
|
+
if (disposed) return
|
|
968
|
+
if (background !== null) {
|
|
969
|
+
removeDraw(texture, background.entry)
|
|
970
|
+
destroyRenderPipeline(background.pipeline)
|
|
971
|
+
destroyProgram(background.program)
|
|
972
|
+
background = null
|
|
973
|
+
}
|
|
974
|
+
if (source === null) return
|
|
975
|
+
let built = backgroundPipeline(source, (opts?.label ?? "scene") + "-background")
|
|
976
|
+
// First in list order: inserted before the first mesh entry, and every
|
|
977
|
+
// later sort keeps it there.
|
|
978
|
+
let entry = addDraw(texture, built.pipeline, null, { vertexCount: 3, before: meshes[0]?._entry ?? undefined })
|
|
979
|
+
background = { entry, pipeline: built.pipeline, program: built.program }
|
|
980
|
+
},
|
|
560
981
|
project(point) {
|
|
561
982
|
ensureCamera()
|
|
562
983
|
transformPoint(clip, viewProj, point)
|
|
@@ -570,12 +991,79 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
570
991
|
ensureCamera()
|
|
571
992
|
return copy(out ?? mat4(), viewProj)
|
|
572
993
|
},
|
|
994
|
+
pick(x, y) {
|
|
995
|
+
ensureCamera()
|
|
996
|
+
// The camera-frame ray through the pixel, inverting project()'s
|
|
997
|
+
// mapping: the baked y-down clip flip is why pixel y converts with
|
|
998
|
+
// no negation there and one here.
|
|
999
|
+
let f = 1 / Math.tan(((fov * Math.PI) / 180) / 2)
|
|
1000
|
+
let cx = (((x / width) * 2 - 1) * (width / height)) / f
|
|
1001
|
+
let cy = -((y / height) * 2 - 1) / f
|
|
1002
|
+
// The view's upper 3x3 rows are the camera axes, so its transpose
|
|
1003
|
+
// carries the camera-space direction (cx, cy, -1) to world.
|
|
1004
|
+
pickDir[0] = cx * view[0] + cy * view[1] - view[2]
|
|
1005
|
+
pickDir[1] = cx * view[4] + cy * view[5] - view[6]
|
|
1006
|
+
pickDir[2] = cx * view[8] + cy * view[9] - view[10]
|
|
1007
|
+
return scene.raycast(eye, pickDir)
|
|
1008
|
+
},
|
|
1009
|
+
raycast(origin, direction) {
|
|
1010
|
+
// Flush pending writes: picking sees the tree as the app just wrote
|
|
1011
|
+
// it, the same immediacy contract as lookAt()/project(). (The queued
|
|
1012
|
+
// microtask still runs and finds nothing dirty - harmless.)
|
|
1013
|
+
if (scheduled) sync()
|
|
1014
|
+
let dx = direction[0]
|
|
1015
|
+
let dy = direction[1]
|
|
1016
|
+
let dz = direction[2]
|
|
1017
|
+
let len = Math.hypot(dx, dy, dz)
|
|
1018
|
+
if (len === 0 || disposed) return []
|
|
1019
|
+
dx /= len
|
|
1020
|
+
dy /= len
|
|
1021
|
+
dz /= len
|
|
1022
|
+
let ox = origin[0]
|
|
1023
|
+
let oy = origin[1]
|
|
1024
|
+
let oz = origin[2]
|
|
1025
|
+
let hits: Hit[] = []
|
|
1026
|
+
bvh.raycast(ox, oy, oz, dx, dy, dz, mesh => {
|
|
1027
|
+
if (mesh._hidden || mesh._entry === null) return
|
|
1028
|
+
// Narrowphase: the ray in the mesh's local frame against its tight
|
|
1029
|
+
// local box - exact under any affine world transform. The local
|
|
1030
|
+
// direction stays unnormalized on purpose: an affine map preserves
|
|
1031
|
+
// the ray parameter, so t is world units as-is.
|
|
1032
|
+
invertAffine(pickInv, mesh._world)
|
|
1033
|
+
transformPoint(pickOrigin, pickInv, origin)
|
|
1034
|
+
pickDir[0] = dx
|
|
1035
|
+
pickDir[1] = dy
|
|
1036
|
+
pickDir[2] = dz
|
|
1037
|
+
transformVector(pickDir, pickInv, pickDir)
|
|
1038
|
+
let b = geometryBounds(mesh.geometry)
|
|
1039
|
+
let t = rayBoxDistance(
|
|
1040
|
+
pickOrigin[0], pickOrigin[1], pickOrigin[2],
|
|
1041
|
+
pickDir[0], pickDir[1], pickDir[2],
|
|
1042
|
+
b[0]!, b[1]!, b[2]!, b[3]!, b[4]!, b[5]!,
|
|
1043
|
+
)
|
|
1044
|
+
if (t >= 0) hits.push({ mesh, distance: t, point: [ox + dx * t, oy + dy * t, oz + dz * t] })
|
|
1045
|
+
})
|
|
1046
|
+
hits.sort((a, b) => a.distance - b.distance)
|
|
1047
|
+
return hits
|
|
1048
|
+
},
|
|
1049
|
+
handlers,
|
|
1050
|
+
handlersFor(layout) {
|
|
1051
|
+
return makeHandlers(layout)
|
|
1052
|
+
},
|
|
573
1053
|
dispose() {
|
|
574
1054
|
if (disposed) return
|
|
575
1055
|
disposed = true
|
|
576
1056
|
destroyTexture(texture)
|
|
1057
|
+
if (background !== null) {
|
|
1058
|
+
// The entry died with the target; the pipeline and program are the
|
|
1059
|
+
// scene's own (unlike shared material pipelines), so they go too.
|
|
1060
|
+
destroyRenderPipeline(background.pipeline)
|
|
1061
|
+
destroyProgram(background.program)
|
|
1062
|
+
background = null
|
|
1063
|
+
}
|
|
577
1064
|
},
|
|
578
1065
|
}
|
|
1066
|
+
if (opts?.background !== undefined) scene.setBackground(opts.background)
|
|
579
1067
|
if (opts?.autoFree !== false && getOwner()) onCleanup(() => scene.dispose())
|
|
580
1068
|
return scene
|
|
581
1069
|
}
|