@solidrt/3d 0.0.49 → 0.0.51
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 +147 -15
- package/README.md +13 -4
- package/examples/README.md +5 -0
- package/examples/instanced.tsx +158 -0
- package/package.json +3 -2
- package/src/bvh.ts +2 -2
- package/src/components.tsx +80 -1
- package/src/geometry-gpu.ts +86 -0
- package/src/geometry.ts +94 -47
- package/src/index.ts +9 -7
- package/src/material.ts +147 -55
- package/src/math.ts +45 -0
- package/src/order.ts +51 -0
- package/src/scene.ts +354 -59
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,17 +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, 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 { addDraw, createBuffer, createDrawTarget, destroyBuffer, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawOrder, setDrawParams, setDrawRange, setTargetParams, setTargetSize, writeBuffer } from "@solidrt/core/gpu"
|
|
23
|
+
import type { BufferId, DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, VertexAttribute, WrapMode } from "@solidrt/core/gpu"
|
|
22
24
|
import { getOwner, onCleanup } from "@solidrt/core"
|
|
23
25
|
import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
|
|
24
26
|
// The scene's lookAt() aims a node; math's builds a camera's view matrix -
|
|
25
27
|
// 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,
|
|
27
|
-
import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
|
|
28
|
-
import { geometryBounds
|
|
28
|
+
import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quat, quatFromFrame, transformPoint, transformVector, updateRotation, updateScale } from "./math.ts"
|
|
29
|
+
import type { Mat4, Quat, TransformUpdate, Vec3, Vec4 } from "./math.ts"
|
|
30
|
+
import { geometryBounds } from "./geometry.ts"
|
|
31
|
+
import { acquireGeometryBuffers, releaseGeometryBuffers } from "./geometry-gpu.ts"
|
|
32
|
+
import type { GeometryBuffers } from "./geometry-gpu.ts"
|
|
29
33
|
import type { Geometry } from "./geometry.ts"
|
|
30
34
|
import { backgroundPipeline } from "./material.ts"
|
|
35
|
+
import { orderEntries } from "./order.ts"
|
|
31
36
|
import type { Material } from "./material.ts"
|
|
32
37
|
import { createBvh, rayBoxDistance } from "./bvh.ts"
|
|
33
38
|
|
|
@@ -51,6 +56,10 @@ let upScratch: Vec3 = [0, 0, 0]
|
|
|
51
56
|
let pickInv = mat4()
|
|
52
57
|
let pickOrigin: Vec4 = [0, 0, 0, 0]
|
|
53
58
|
let pickDir: Vec3 = [0, 0, 0]
|
|
59
|
+
// setTransform's rotation compare happens AFTER conversion, so an euler and
|
|
60
|
+
// the quaternion it produces are the same write. Nothing outlives the call.
|
|
61
|
+
let rotScratch = quat()
|
|
62
|
+
let scaleScratch: Vec3 = [1, 1, 1]
|
|
54
63
|
|
|
55
64
|
// The scene half a node needs to reach: attach/detach entries and schedule
|
|
56
65
|
// a sync. Kept separate from the public Scene type so internals stay off
|
|
@@ -62,6 +71,8 @@ type SceneHooks = {
|
|
|
62
71
|
_attach(mesh: Mesh): void
|
|
63
72
|
_detach(mesh: Mesh): void
|
|
64
73
|
_setParams(mesh: Mesh, params: ShaderParams): void
|
|
74
|
+
_setCount(mesh: Mesh): void
|
|
75
|
+
_reorder(): void
|
|
65
76
|
}
|
|
66
77
|
|
|
67
78
|
export type SceneNode = {
|
|
@@ -97,13 +108,55 @@ export type Mesh = SceneNode & {
|
|
|
97
108
|
kind: "mesh"
|
|
98
109
|
geometry: Geometry
|
|
99
110
|
material: Material
|
|
111
|
+
/** Explicit draw-order key (default 0), Three's name: lower draws first.
|
|
112
|
+
* Sorts within the opaque group and within the transparent group; the
|
|
113
|
+
* transparent group always follows the opaque one. Set with setRenderOrder. */
|
|
114
|
+
renderOrder: number
|
|
100
115
|
_entry: DrawId | null
|
|
116
|
+
/** The geometry-buffer reference the entry was built from, acquired at
|
|
117
|
+
* attach and what _detach releases - like _transparent, a snapshot,
|
|
118
|
+
* because setGeometry swaps mesh.geometry before the rebuild. */
|
|
119
|
+
_buffers: GeometryBuffers | null
|
|
120
|
+
/** material.transparent as of the last attach - the entry's actual
|
|
121
|
+
* pipeline state, and what _detach counts against (setMaterial swaps
|
|
122
|
+
* mesh.material before the rebuild). */
|
|
123
|
+
_transparent: boolean
|
|
124
|
+
/** World-space center of the geometry bounds, kept by the sync walk
|
|
125
|
+
* beside the picking leaf: the transparent sort key. */
|
|
126
|
+
_center: Vec3
|
|
101
127
|
_hidden: boolean
|
|
102
128
|
_fresh: boolean
|
|
103
129
|
_params: ShaderParams | null
|
|
104
130
|
_pickLeaf: number | null
|
|
131
|
+
/** Instance state when the mesh was made by createInstancedMesh; null on
|
|
132
|
+
* an ordinary mesh. */
|
|
133
|
+
_instances: MeshInstances | null
|
|
105
134
|
}
|
|
106
135
|
|
|
136
|
+
/** The per-mesh half of instancing: the record buffer and its bookkeeping.
|
|
137
|
+
* Read the public fields freely; write through setInstances /
|
|
138
|
+
* setInstanceCount so the draw range follows. */
|
|
139
|
+
export type MeshInstances = {
|
|
140
|
+
/** The GPU record buffer, owned by the mesh (disposeInstances frees it). */
|
|
141
|
+
buffer: BufferId
|
|
142
|
+
/** Floats per record - the material's instanceAttributes summed. */
|
|
143
|
+
stride: number
|
|
144
|
+
/** Records the buffer has room for; fixed at creation, like every GPU
|
|
145
|
+
* buffer's byte size. */
|
|
146
|
+
capacity: number
|
|
147
|
+
/** Records currently drawn (the entry's instanceCount while visible). */
|
|
148
|
+
count: number
|
|
149
|
+
/** Explicit LOCAL bounds covering the whole population ([minX, minY,
|
|
150
|
+
* minZ, maxX, maxY, maxZ]), or null: the mesh then has no picking leaf -
|
|
151
|
+
* records are opaque data, so the library cannot derive where the
|
|
152
|
+
* instances are. */
|
|
153
|
+
bounds: Float32Array | null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A mesh from createInstancedMesh: an ordinary Mesh whose entry draws
|
|
157
|
+
* `instances.count` copies of the geometry, one record each. */
|
|
158
|
+
export type InstancedMesh = Mesh & { _instances: MeshInstances }
|
|
159
|
+
|
|
107
160
|
/** One picking intersection: the mesh, the camera-ray distance in world
|
|
108
161
|
* units, and the world-space point - Three's intersect result minus the
|
|
109
162
|
* triangle fields (`face`, `uv`), which cannot exist at the volume tier. */
|
|
@@ -184,6 +237,15 @@ export type Scene = {
|
|
|
184
237
|
/** Partial camera update; absent keys keep their current value. */
|
|
185
238
|
setCamera(update: CameraUpdate): void
|
|
186
239
|
setSize(width: number, height: number): void
|
|
240
|
+
/**
|
|
241
|
+
* Scene-wide uniforms: merge app-owned names into the target's SHARED
|
|
242
|
+
* params, beside the standard uViewProj/uCamPos/uCamRight/uCamUp the
|
|
243
|
+
* camera writes. One write per frame however many meshes read the name
|
|
244
|
+
* (a clock, a sun direction, fog) - the per-mesh channel is
|
|
245
|
+
* setMeshParams. Merge semantics, no unset; a material that does not
|
|
246
|
+
* declare a name simply skips it. Frame-rate-safe like setTransform.
|
|
247
|
+
*/
|
|
248
|
+
setParams(params: ShaderParams): void
|
|
187
249
|
/**
|
|
188
250
|
* Set, replace, or remove (null) the scene's background: fragment GLSL
|
|
189
251
|
* drawn as the FIRST entry of the scene's own pass - one target, no
|
|
@@ -249,9 +311,10 @@ export type Scene = {
|
|
|
249
311
|
* layout just works: `scene.handlersFor(() => ({ width: w(), height:
|
|
250
312
|
* h() }))`. */
|
|
251
313
|
handlersFor(layout: () => { width: number; height: number }): SceneHandlers
|
|
252
|
-
/** Destroy the target (entries die with it). Idempotent.
|
|
253
|
-
*
|
|
254
|
-
*
|
|
314
|
+
/** Destroy the target (entries die with it). Idempotent. Material
|
|
315
|
+
* pipelines are shared and survive (app-lifetime, see material.ts);
|
|
316
|
+
* geometry buffers are reference-counted and freed with their last
|
|
317
|
+
* entry (see geometry-gpu.ts). */
|
|
255
318
|
dispose(): void
|
|
256
319
|
}
|
|
257
320
|
|
|
@@ -279,14 +342,149 @@ export function createMesh(geometry: Geometry, material: Material): Mesh {
|
|
|
279
342
|
let mesh = makeNode("mesh") as Mesh
|
|
280
343
|
mesh.geometry = geometry
|
|
281
344
|
mesh.material = material
|
|
345
|
+
mesh.renderOrder = 0
|
|
282
346
|
mesh._entry = null
|
|
347
|
+
mesh._buffers = null
|
|
348
|
+
mesh._transparent = false
|
|
349
|
+
mesh._center = [0, 0, 0]
|
|
283
350
|
mesh._hidden = false
|
|
284
351
|
mesh._fresh = false
|
|
285
352
|
mesh._params = null
|
|
286
353
|
mesh._pickLeaf = null
|
|
354
|
+
mesh._instances = null
|
|
287
355
|
return mesh
|
|
288
356
|
}
|
|
289
357
|
|
|
358
|
+
/** The local box picking and sorting work from: explicit instance bounds
|
|
359
|
+
* when the mesh is instanced (null without them - no leaf, no hits), the
|
|
360
|
+
* geometry's own bounds otherwise. */
|
|
361
|
+
function localBounds(mesh: Mesh): Float32Array | null {
|
|
362
|
+
return mesh._instances !== null ? mesh._instances.bounds : geometryBounds(mesh.geometry)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const ATTRIBUTE_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
|
|
366
|
+
|
|
367
|
+
function instanceStride(attributes: VertexAttribute[]): number {
|
|
368
|
+
let stride = 0
|
|
369
|
+
for (let a of attributes) stride += ATTRIBUTE_FLOATS[a.format]
|
|
370
|
+
return stride
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export type InstancedMeshOptions = {
|
|
374
|
+
/** LOCAL bounds covering every instance the records place ([minX, minY,
|
|
375
|
+
* minZ, maxX, maxY, maxZ] - geometryBounds' shape), copied in. Records
|
|
376
|
+
* are opaque data, so only the app knows where its instances are: with
|
|
377
|
+
* bounds the mesh picks and transparent-sorts like any other
|
|
378
|
+
* (conservatively - one box around the whole population); without, it
|
|
379
|
+
* has no picking leaf and pointer events never target it. */
|
|
380
|
+
bounds?: ArrayLike<number>
|
|
381
|
+
/** Debug label for the record buffer. */
|
|
382
|
+
label?: string
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* A mesh drawing `geometry` once per record of `records`: one draw entry,
|
|
387
|
+
* one uModel write, N instances - the shape for forests, particles, and
|
|
388
|
+
* every fleet whose per-copy data is a few floats rather than a merged
|
|
389
|
+
* vertex buffer. The material must declare `instanceAttributes`
|
|
390
|
+
* (shaderMaterialClass); its vertex stage reads each record through those
|
|
391
|
+
* `in` variables. `records` is the interleaved attribute data (stride =
|
|
392
|
+
* the attributes' floats summed) and is uploaded here - the buffer's
|
|
393
|
+
* capacity is fixed at creation, like any GPU buffer. `count` limits how
|
|
394
|
+
* many records draw (default all); grow it later only up to capacity.
|
|
395
|
+
*
|
|
396
|
+
* The result is an ordinary Mesh: add/remove, setTransform (uModel places
|
|
397
|
+
* the whole population), setVisible (hiding zeroes the drawn count,
|
|
398
|
+
* unhiding restores it), setMeshParams and renderOrder all apply. Update
|
|
399
|
+
* records with setInstances, the drawn count with setInstanceCount, and
|
|
400
|
+
* free the record buffer with disposeInstances when done for good.
|
|
401
|
+
*/
|
|
402
|
+
export function createInstancedMesh(
|
|
403
|
+
geometry: Geometry,
|
|
404
|
+
material: Material,
|
|
405
|
+
records: Float32Array,
|
|
406
|
+
count?: number,
|
|
407
|
+
opts?: InstancedMeshOptions,
|
|
408
|
+
): InstancedMesh {
|
|
409
|
+
let attributes = material.instanceAttributes
|
|
410
|
+
if (attributes === undefined) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
"createInstancedMesh: the material declares no instanceAttributes - build it with shaderMaterialClass({ instanceAttributes: [...] })",
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
let stride = instanceStride(attributes)
|
|
416
|
+
if (records.length % stride !== 0) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
"createInstancedMesh: " + records.length + " floats is not a whole number of " + stride + "-float records",
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
let bounds: Float32Array | null = null
|
|
422
|
+
if (opts?.bounds !== undefined) {
|
|
423
|
+
if (opts.bounds.length !== 6) {
|
|
424
|
+
throw new Error("createInstancedMesh: bounds must be [minX, minY, minZ, maxX, maxY, maxZ]")
|
|
425
|
+
}
|
|
426
|
+
bounds = new Float32Array(6)
|
|
427
|
+
for (let i = 0; i < 6; i++) bounds[i] = opts.bounds[i]!
|
|
428
|
+
}
|
|
429
|
+
let capacity = records.length / stride
|
|
430
|
+
let mesh = createMesh(geometry, material) as InstancedMesh
|
|
431
|
+
mesh._instances = {
|
|
432
|
+
buffer: createBuffer(records, { autoFree: false, label: opts?.label }),
|
|
433
|
+
stride,
|
|
434
|
+
capacity,
|
|
435
|
+
count: Math.max(0, Math.min(Math.floor(count ?? capacity), capacity)),
|
|
436
|
+
bounds,
|
|
437
|
+
}
|
|
438
|
+
return mesh
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Overwrite an instanced mesh's records from the start of its buffer and
|
|
443
|
+
* (by default) draw exactly the records written - pass `count` to draw
|
|
444
|
+
* fewer, or to keep more previously written ones alive past a partial
|
|
445
|
+
* rewrite. The buffer's capacity is fixed at creation; more records than
|
|
446
|
+
* capacity throw (make a new mesh for a bigger population). Frame-rate-safe
|
|
447
|
+
* like setMeshParams.
|
|
448
|
+
*/
|
|
449
|
+
export function setInstances(mesh: InstancedMesh, records: Float32Array, count?: number): void {
|
|
450
|
+
let inst = mesh._instances
|
|
451
|
+
if (records.length % inst.stride !== 0) {
|
|
452
|
+
throw new Error("setInstances: " + records.length + " floats is not a whole number of " + inst.stride + "-float records")
|
|
453
|
+
}
|
|
454
|
+
let written = records.length / inst.stride
|
|
455
|
+
if (written > inst.capacity) {
|
|
456
|
+
throw new Error(
|
|
457
|
+
"setInstances: " + written + " records exceed the buffer's capacity of " + inst.capacity + " (fixed at creation)",
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
writeBuffer(inst.buffer, records)
|
|
461
|
+
setInstanceCount(mesh, count ?? written)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Set how many records draw (clamped to [0, capacity]). The visibility
|
|
465
|
+
* switch composes: a hidden mesh stores the count and draws it on unhide. */
|
|
466
|
+
export function setInstanceCount(mesh: InstancedMesh, count: number): void {
|
|
467
|
+
let inst = mesh._instances
|
|
468
|
+
let n = Math.max(0, Math.min(Math.floor(count), inst.capacity))
|
|
469
|
+
if (n === inst.count) return
|
|
470
|
+
inst.count = n
|
|
471
|
+
mesh._scene?._setCount(mesh)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Detach the mesh (if attached) and free its record buffer. The buffer is
|
|
476
|
+
* mesh-owned with no reference count (unlike geometry buffers it is never
|
|
477
|
+
* shared), so this is the one explicit free; the mesh cannot be re-added
|
|
478
|
+
* afterwards.
|
|
479
|
+
*/
|
|
480
|
+
export function disposeInstances(mesh: InstancedMesh): void {
|
|
481
|
+
let inst: MeshInstances | null = mesh._instances
|
|
482
|
+
if (inst === null) return
|
|
483
|
+
if (mesh._scene) remove(mesh)
|
|
484
|
+
destroyBuffer(inst.buffer)
|
|
485
|
+
;(mesh as Mesh)._instances = null
|
|
486
|
+
}
|
|
487
|
+
|
|
290
488
|
/** Attach `child` under `parent` (re-parenting detaches it first). */
|
|
291
489
|
export function add(parent: SceneNode, child: SceneNode): void {
|
|
292
490
|
if (child.parent !== null) remove(child)
|
|
@@ -322,51 +520,52 @@ function leaveScene(node: SceneNode): void {
|
|
|
322
520
|
for (let c of node.children) leaveScene(c)
|
|
323
521
|
}
|
|
324
522
|
|
|
325
|
-
export type TransformUpdate
|
|
326
|
-
position?: Vec3
|
|
327
|
-
/** Euler radians in XYZ order (x first), Three's `Euler` default -
|
|
328
|
-
* converted to the node's quaternion on write. */
|
|
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
|
|
334
|
-
/** A number is uniform scale. */
|
|
335
|
-
scale?: Vec3 | number
|
|
336
|
-
}
|
|
523
|
+
export type { TransformUpdate } from "./math.ts"
|
|
337
524
|
|
|
338
525
|
/**
|
|
339
526
|
* The one write path for node transforms (so the scene knows to sync).
|
|
340
527
|
* Values are copied in; absent keys keep their current value. This is also
|
|
341
528
|
* the frame-rate escape hatch: call it from onFrame on a node grabbed via
|
|
342
529
|
* `ref`, bypassing signals entirely.
|
|
530
|
+
*
|
|
531
|
+
* A write that changes nothing schedules nothing, so driving every node
|
|
532
|
+
* unconditionally from onFrame costs only the compare for the nodes that
|
|
533
|
+
* did not move. Rotation is compared after conversion, so passing an euler
|
|
534
|
+
* equal to the node's current quaternion is also a no-op.
|
|
343
535
|
*/
|
|
344
536
|
export function setTransform(node: SceneNode, update: TransformUpdate): void {
|
|
537
|
+
// A no-op write costs nothing: driving every node from onFrame is the
|
|
538
|
+
// intended shape, and most nodes did not move. Exact compares, like
|
|
539
|
+
// setVisible - a value that survives a float round trip unchanged is the
|
|
540
|
+
// same value, and an epsilon would need a scale-dependent one anyway.
|
|
541
|
+
let changed = false
|
|
345
542
|
let p = update.position
|
|
346
|
-
if (p) {
|
|
543
|
+
if (p && (p[0] !== node.position[0] || p[1] !== node.position[1] || p[2] !== node.position[2])) {
|
|
347
544
|
node.position[0] = p[0]
|
|
348
545
|
node.position[1] = p[1]
|
|
349
546
|
node.position[2] = p[2]
|
|
547
|
+
changed = true
|
|
350
548
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
549
|
+
if (updateRotation(rotScratch, update, "setTransform")) {
|
|
550
|
+
let n = node.quaternion
|
|
551
|
+
if (rotScratch[0] !== n[0] || rotScratch[1] !== n[1] || rotScratch[2] !== n[2] || rotScratch[3] !== n[3]) {
|
|
552
|
+
n[0] = rotScratch[0]
|
|
553
|
+
n[1] = rotScratch[1]
|
|
554
|
+
n[2] = rotScratch[2]
|
|
555
|
+
n[3] = rotScratch[3]
|
|
556
|
+
changed = true
|
|
557
|
+
}
|
|
355
558
|
}
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
node.scale[
|
|
362
|
-
|
|
363
|
-
node.scale[2] = s
|
|
364
|
-
} else {
|
|
365
|
-
node.scale[0] = s[0]
|
|
366
|
-
node.scale[1] = s[1]
|
|
367
|
-
node.scale[2] = s[2]
|
|
559
|
+
if (update.scale !== undefined) {
|
|
560
|
+
updateScale(scaleScratch, update.scale)
|
|
561
|
+
if (scaleScratch[0] !== node.scale[0] || scaleScratch[1] !== node.scale[1] || scaleScratch[2] !== node.scale[2]) {
|
|
562
|
+
node.scale[0] = scaleScratch[0]
|
|
563
|
+
node.scale[1] = scaleScratch[1]
|
|
564
|
+
node.scale[2] = scaleScratch[2]
|
|
565
|
+
changed = true
|
|
368
566
|
}
|
|
369
567
|
}
|
|
568
|
+
if (!changed) return
|
|
370
569
|
node._localDirty = true
|
|
371
570
|
node._scene?._schedule()
|
|
372
571
|
}
|
|
@@ -482,8 +681,15 @@ export function setVisible(node: SceneNode, visible: boolean): void {
|
|
|
482
681
|
node._scene?._schedule()
|
|
483
682
|
}
|
|
484
683
|
|
|
485
|
-
/**
|
|
486
|
-
|
|
684
|
+
/** Set a mesh's explicit draw-order key (see Mesh.renderOrder). */
|
|
685
|
+
export function setRenderOrder(mesh: Mesh, order: number): void {
|
|
686
|
+
if (mesh.renderOrder === order) return
|
|
687
|
+
mesh.renderOrder = order
|
|
688
|
+
mesh._scene?._reorder()
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Swap a mesh's geometry: its draw entry is rebuilt (the scene re-sorts
|
|
692
|
+
* the list, so the mesh keeps its place). */
|
|
487
693
|
export function setGeometry(mesh: Mesh, geometry: Geometry): void {
|
|
488
694
|
if (mesh.geometry === geometry) return
|
|
489
695
|
mesh.geometry = geometry
|
|
@@ -545,18 +751,41 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
545
751
|
let capture = new Map<number, Mesh>()
|
|
546
752
|
let hover = new Map<number, Mesh>()
|
|
547
753
|
|
|
548
|
-
// Live
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
|
|
754
|
+
// Live meshes (those holding a draw entry) in add order; the background
|
|
755
|
+
// entry never joins this list. Draw order is derived from it by
|
|
756
|
+
// orderEntries (order.ts) whenever orderDirty. Camera moves and
|
|
757
|
+
// transparent-mesh moves only dirty the order when two or more transparent
|
|
758
|
+
// meshes exist - fewer cannot change relative order.
|
|
759
|
+
let meshes: Mesh[] = []
|
|
760
|
+
let transparentCount = 0
|
|
761
|
+
let orderDirty = false
|
|
762
|
+
// The order last handed to the engine: a resort that lands on the same
|
|
763
|
+
// permutation (the common case under a moving camera) issues nothing.
|
|
764
|
+
let lastOrder: DrawId[] = []
|
|
552
765
|
let background: { entry: DrawId; pipeline: RenderPipelineId; program: ProgramId } | null = null
|
|
766
|
+
let sortEntries = () => {
|
|
767
|
+
orderDirty = false
|
|
768
|
+
let order = orderEntries(meshes, view, background?.entry)
|
|
769
|
+
if (order.length === lastOrder.length && order.every((id, i) => id === lastOrder[i])) return
|
|
770
|
+
lastOrder = order
|
|
771
|
+
setDrawOrder(texture, order)
|
|
772
|
+
}
|
|
553
773
|
|
|
554
774
|
// Reinsert or refit a mesh's broadphase leaf from its fresh world matrix:
|
|
555
775
|
// the local box's center/extents carried through the absolute matrix (the
|
|
556
776
|
// standard tight-AABB-of-a-transformed-AABB construction).
|
|
557
777
|
let updateLeaf = (mesh: Mesh): void => {
|
|
558
|
-
let b =
|
|
778
|
+
let b = localBounds(mesh)
|
|
559
779
|
let m = mesh._world
|
|
780
|
+
if (b === null) {
|
|
781
|
+
// An instanced mesh without explicit bounds: records are opaque, so
|
|
782
|
+
// there is nothing to build a leaf from - the mesh never picks. Keep
|
|
783
|
+
// the transparent sort key at the node's own world position.
|
|
784
|
+
mesh._center[0] = m[12]
|
|
785
|
+
mesh._center[1] = m[13]
|
|
786
|
+
mesh._center[2] = m[14]
|
|
787
|
+
return
|
|
788
|
+
}
|
|
560
789
|
let cx = (b[0]! + b[3]!) / 2
|
|
561
790
|
let cy = (b[1]! + b[4]!) / 2
|
|
562
791
|
let cz = (b[2]! + b[5]!) / 2
|
|
@@ -566,6 +795,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
566
795
|
let wx = m[0] * cx + m[4] * cy + m[8] * cz + m[12]
|
|
567
796
|
let wy = m[1] * cx + m[5] * cy + m[9] * cz + m[13]
|
|
568
797
|
let wz = m[2] * cx + m[6] * cy + m[10] * cz + m[14]
|
|
798
|
+
mesh._center[0] = wx
|
|
799
|
+
mesh._center[1] = wy
|
|
800
|
+
mesh._center[2] = wz
|
|
569
801
|
let rx = Math.abs(m[0]) * ex + Math.abs(m[4]) * ey + Math.abs(m[8]) * ez
|
|
570
802
|
let ry = Math.abs(m[1]) * ex + Math.abs(m[5]) * ey + Math.abs(m[9]) * ez
|
|
571
803
|
let rz = Math.abs(m[2]) * ex + Math.abs(m[6]) * ey + Math.abs(m[10]) * ez
|
|
@@ -610,7 +842,16 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
610
842
|
// holds. Entries are untouched - uModel is camera-independent, and
|
|
611
843
|
// uCamPos is stored even when no current material declares it.
|
|
612
844
|
cameraPending = false
|
|
613
|
-
|
|
845
|
+
// The camera basis rides along: the view matrix's first two rows are
|
|
846
|
+
// the camera's world-space right and up (no clip flip - that lives in
|
|
847
|
+
// the projection), so a billboard needs no reconstruction from uViewProj.
|
|
848
|
+
setTargetParams(texture, {
|
|
849
|
+
uViewProj: viewProj,
|
|
850
|
+
uCamPos: eye,
|
|
851
|
+
uCamRight: [view[0], view[4], view[8]],
|
|
852
|
+
uCamUp: [view[1], view[5], view[9]],
|
|
853
|
+
})
|
|
854
|
+
if (transparentCount > 1) orderDirty = true
|
|
614
855
|
}
|
|
615
856
|
let walk = (node: SceneNode, parentChanged: boolean, parentVisible: boolean) => {
|
|
616
857
|
let changed = parentChanged
|
|
@@ -627,11 +868,13 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
627
868
|
let mesh = node as Mesh
|
|
628
869
|
if (mesh._entry !== null) {
|
|
629
870
|
if (mesh._hidden === shown) {
|
|
630
|
-
// Mismatch: flip the entry's cheap off switch.
|
|
631
|
-
|
|
871
|
+
// Mismatch: flip the entry's cheap off switch. An instanced
|
|
872
|
+
// mesh's "on" is its own record count, not 1.
|
|
873
|
+
setDrawRange(texture, mesh._entry, { instanceCount: shown ? (mesh._instances !== null ? mesh._instances.count : 1) : 0 })
|
|
632
874
|
mesh._hidden = !shown
|
|
633
875
|
if (shown) mesh._fresh = true
|
|
634
876
|
}
|
|
877
|
+
if (changed && mesh._transparent && transparentCount > 1) orderDirty = true
|
|
635
878
|
if (!mesh._hidden && (changed || mesh._fresh)) {
|
|
636
879
|
if (mesh.material.normalMatrix) {
|
|
637
880
|
setDrawParams(texture, mesh._entry, {
|
|
@@ -655,6 +898,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
655
898
|
for (let c of node.children) walk(c, changed, shown)
|
|
656
899
|
}
|
|
657
900
|
walk(root, false, true)
|
|
901
|
+
if (orderDirty) sortEntries()
|
|
658
902
|
}
|
|
659
903
|
|
|
660
904
|
let hooks: SceneHooks = {
|
|
@@ -676,7 +920,30 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
676
920
|
"' - a material reading aColor needs withColors() geometry, and colored geometry needs such a material",
|
|
677
921
|
)
|
|
678
922
|
}
|
|
679
|
-
|
|
923
|
+
// Instancing pairs the same way layout does: the pipeline's instance
|
|
924
|
+
// attributes describe the mesh's record buffer, so one without the
|
|
925
|
+
// other (or a record stride from a different attribute list) would
|
|
926
|
+
// bind garbage - errors here, at add().
|
|
927
|
+
let inst = mesh._instances
|
|
928
|
+
let instAttrs = mesh.material.instanceAttributes
|
|
929
|
+
if (instAttrs !== undefined && inst === null) {
|
|
930
|
+
throw new Error(
|
|
931
|
+
"Material declares instanceAttributes - create its meshes with createInstancedMesh (records included), not createMesh",
|
|
932
|
+
)
|
|
933
|
+
}
|
|
934
|
+
if (inst !== null) {
|
|
935
|
+
if (instAttrs === undefined) {
|
|
936
|
+
throw new Error("Instanced mesh with a non-instanced material - the material must declare instanceAttributes")
|
|
937
|
+
}
|
|
938
|
+
let stride = instanceStride(instAttrs)
|
|
939
|
+
if (stride !== inst.stride) {
|
|
940
|
+
throw new Error(
|
|
941
|
+
"Instanced mesh records are " + inst.stride + " floats but the material's instanceAttributes take " + stride,
|
|
942
|
+
)
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
let bufs = acquireGeometryBuffers(mesh.geometry)
|
|
946
|
+
mesh._buffers = bufs
|
|
680
947
|
// The uNormal seed keys off the material flag because entry params
|
|
681
948
|
// validate strictly - and a material declaring uNormal without using
|
|
682
949
|
// it therefore throws right here, at add().
|
|
@@ -693,9 +960,13 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
693
960
|
indexBuffer: bufs.index,
|
|
694
961
|
indexFormat: bufs.indexFormat,
|
|
695
962
|
textures: mesh.material.textures,
|
|
963
|
+
instanceBuffer: inst !== null ? inst.buffer : undefined,
|
|
696
964
|
instanceCount: 0,
|
|
697
965
|
})
|
|
698
|
-
|
|
966
|
+
meshes.push(mesh)
|
|
967
|
+
mesh._transparent = mesh.material.transparent === true
|
|
968
|
+
if (mesh._transparent) transparentCount++
|
|
969
|
+
orderDirty = true
|
|
699
970
|
mesh._hidden = true
|
|
700
971
|
mesh._fresh = true
|
|
701
972
|
this._schedule()
|
|
@@ -703,8 +974,12 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
703
974
|
_detach(mesh) {
|
|
704
975
|
if (mesh._entry !== null) {
|
|
705
976
|
if (!disposed) removeDraw(texture, mesh._entry)
|
|
706
|
-
|
|
707
|
-
|
|
977
|
+
if (mesh._buffers !== null) releaseGeometryBuffers(mesh._buffers)
|
|
978
|
+
mesh._buffers = null
|
|
979
|
+
let i = meshes.indexOf(mesh)
|
|
980
|
+
if (i >= 0) meshes.splice(i, 1)
|
|
981
|
+
if (mesh._transparent) transparentCount--
|
|
982
|
+
orderDirty = true
|
|
708
983
|
}
|
|
709
984
|
mesh._entry = null
|
|
710
985
|
// The leaf goes with the entry: a geometry swap rebuilds the entry,
|
|
@@ -717,6 +992,16 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
717
992
|
_setParams(mesh, params) {
|
|
718
993
|
if (mesh._entry !== null && !disposed) setDrawParams(texture, mesh._entry, params)
|
|
719
994
|
},
|
|
995
|
+
_setCount(mesh) {
|
|
996
|
+
// A hidden entry stays at 0; the unhide write restores the count.
|
|
997
|
+
if (mesh._entry !== null && !mesh._hidden && !disposed && mesh._instances !== null) {
|
|
998
|
+
setDrawRange(texture, mesh._entry, { instanceCount: mesh._instances.count })
|
|
999
|
+
}
|
|
1000
|
+
},
|
|
1001
|
+
_reorder() {
|
|
1002
|
+
orderDirty = true
|
|
1003
|
+
this._schedule()
|
|
1004
|
+
},
|
|
720
1005
|
}
|
|
721
1006
|
|
|
722
1007
|
let root = makeNode("group")
|
|
@@ -864,6 +1149,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
864
1149
|
cameraDirty = true
|
|
865
1150
|
hooks._schedule()
|
|
866
1151
|
},
|
|
1152
|
+
setParams(params) {
|
|
1153
|
+
if (!disposed) setTargetParams(texture, params)
|
|
1154
|
+
},
|
|
867
1155
|
setBackground(source) {
|
|
868
1156
|
if (disposed) return
|
|
869
1157
|
if (background !== null) {
|
|
@@ -874,9 +1162,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
874
1162
|
}
|
|
875
1163
|
if (source === null) return
|
|
876
1164
|
let built = backgroundPipeline(source, (opts?.label ?? "scene") + "-background")
|
|
877
|
-
// First in list order:
|
|
878
|
-
//
|
|
879
|
-
let entry = addDraw(texture, built.pipeline, null, { vertexCount: 3, before:
|
|
1165
|
+
// First in list order: inserted before the first mesh entry, and every
|
|
1166
|
+
// later sort keeps it there.
|
|
1167
|
+
let entry = addDraw(texture, built.pipeline, null, { vertexCount: 3, before: meshes[0]?._entry ?? undefined })
|
|
880
1168
|
background = { entry, pipeline: built.pipeline, program: built.program }
|
|
881
1169
|
},
|
|
882
1170
|
project(point) {
|
|
@@ -936,7 +1224,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
936
1224
|
pickDir[1] = dy
|
|
937
1225
|
pickDir[2] = dz
|
|
938
1226
|
transformVector(pickDir, pickInv, pickDir)
|
|
939
|
-
|
|
1227
|
+
// A boundless instanced mesh has no leaf, so the BVH never visits
|
|
1228
|
+
// it; this read is the bounds the leaf was built from.
|
|
1229
|
+
let b = localBounds(mesh)
|
|
1230
|
+
if (b === null) return
|
|
940
1231
|
let t = rayBoxDistance(
|
|
941
1232
|
pickOrigin[0], pickOrigin[1], pickOrigin[2],
|
|
942
1233
|
pickDir[0], pickDir[1], pickDir[2],
|
|
@@ -954,6 +1245,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
954
1245
|
dispose() {
|
|
955
1246
|
if (disposed) return
|
|
956
1247
|
disposed = true
|
|
1248
|
+
// Full mesh-side teardown, not just the target: _detach drops each
|
|
1249
|
+
// entry's geometry-buffer reference and pick leaf and clears _entry,
|
|
1250
|
+
// so a disposed scene leaves no mesh bookkeeping behind.
|
|
1251
|
+
for (let mesh of meshes.slice()) hooks._detach(mesh)
|
|
957
1252
|
destroyTexture(texture)
|
|
958
1253
|
if (background !== null) {
|
|
959
1254
|
// The entry died with the target; the pipeline and program are the
|