@solidrt/3d 0.0.51 → 0.0.52

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/src/scene.ts CHANGED
@@ -1,8 +1,12 @@
1
- // The retained scene: plain objects and dirty flags, no signals - the hot
2
- // path (a moved node) is flat imperative code, and reactivity stays at the
3
- // component boundary (components.tsx). A scene compiles to one draw
4
- // target: every mesh is one draw entry whose uModel (and, for materials
5
- // declaring it, uNormal) this module keeps in step with the tree, and the
1
+ // The retained scene: plain objects, no signals - the hot path (a moved
2
+ // node) is flat imperative code, and reactivity stays at the component
3
+ // boundary (components.tsx). The transform hierarchy itself lives in the
4
+ // spatial core (flux:spatial): every node in a scene has a core node, JS
5
+ // keeps the LOCAL transform as the readable source of truth and forwards
6
+ // each write, and the core's flush recomputes only the moved subtrees and
7
+ // writes each mesh entry's uModel (and, for materials declaring it,
8
+ // uNormal) - so a move costs its subtree, never the scene. A scene
9
+ // compiles to one draw target: every mesh is one draw entry, and the
6
10
  // camera is the target's SHARED uViewProj + uCamPos + uCamRight/uCamUp -
7
11
  // one setTargetParams per camera move, not one write per mesh. The
8
12
  // non-matrix names ride unconditionally: shared params tolerate zero
@@ -16,45 +20,76 @@
16
20
  // `render: "auto"` draw target that re-renders when its entries change, so
17
21
  // a static scene costs zero passes and this module registers no frame
18
22
  // loop. Continuous animation is the app's onFrame writing transforms -
19
- // each write lands here, the microtask syncs the affected uModels, and the
20
- // flush renders once that frame.
23
+ // each write lands in the core, the microtask flushes it, and the frame
24
+ // renders once.
25
+ //
26
+ // Still in JS this stage (see okf/backlog/spatial-core.md): the picking
27
+ // broadphase and its leaves, the transparent sort's centers and the light
28
+ // params. They read world matrices back from the core, and only for the
29
+ // subtrees that moved since they last looked.
21
30
 
22
- import { addDraw, createBuffer, createDrawTarget, destroyBuffer, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawOrder, setDrawParams, setDrawRange, setTargetParams, setTargetSize, writeBuffer } from "@solidrt/core/gpu"
31
+ import { addDraw, createBuffer, createDrawTarget, createTexture, depthTexture, destroyBuffer, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawBuffers, setDrawOrder, setDrawParams, setTargetParams, setTargetSize, setTargetTextures, writeBuffer } from "@solidrt/core/gpu"
32
+ import * as spatial from "flux:spatial"
33
+ import type { NodeId, NodeTransition } from "flux:spatial"
34
+ import { on } from "srt:events"
23
35
  import type { BufferId, DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, VertexAttribute, WrapMode } from "@solidrt/core/gpu"
24
36
  import { getOwner, onCleanup } from "@solidrt/core"
25
37
  import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
26
38
  // The scene's lookAt() aims a node; math's builds a camera's view matrix -
27
39
  // the same pairing (and the same name) as Three's Object3D/Matrix4.
28
- import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quat, quatFromFrame, transformPoint, transformVector, updateRotation, updateScale } from "./math.ts"
40
+ import { compose, copy, eulerFromQuat, identity, lookAt as lookAtMatrix, mat4, multiply, orthographic, perspective, quat, quatFromFrame, transformPoint, transformVector, updateRotation, updateScale } from "./math.ts"
29
41
  import type { Mat4, Quat, TransformUpdate, Vec3, Vec4 } from "./math.ts"
30
- import { geometryBounds } from "./geometry.ts"
42
+ import { MAX_LIGHTS } from "./glsl.ts"
43
+ import { geometryBounds, layoutKey, plane, validateGeometry } from "./geometry.ts"
31
44
  import { acquireGeometryBuffers, releaseGeometryBuffers } from "./geometry-gpu.ts"
32
45
  import type { GeometryBuffers } from "./geometry-gpu.ts"
33
46
  import type { Geometry } from "./geometry.ts"
34
- import { backgroundPipeline } from "./material.ts"
47
+ import { backgroundPipeline, missingAttributes, shadowDepthMaterial } from "./material.ts"
35
48
  import { orderEntries } from "./order.ts"
36
49
  import type { Material } from "./material.ts"
37
- import { createBvh, rayBoxDistance } from "./bvh.ts"
38
50
 
39
51
  const IDENTITY = mat4()
40
52
  const RESOLVED = Promise.resolve()
41
53
  // lookAt()'s default roll reference. Read-only: quatFromFrame never
42
54
  // writes its inputs, so one shared vector is safe.
43
55
  const WORLD_UP: Vec3 = [0, 1, 0]
44
- // Param values are snapshotted at the FFI boundary (addDraw shares
45
- // IDENTITY the same way), so one scratch serves every uNormal write.
46
- let normalScratch = mat4()
47
- // lookAt()/worldPosition() scratch: the ancestor walk recomputes worlds
48
- // without touching node state, so nothing here outlives a single call.
56
+ // The FFI carriers: one transform write (position, quaternion, scale) and
57
+ // one world-matrix read. Values are copied at the boundary, so one of each
58
+ // serves every call.
59
+ let transformScratch = new Float32Array(10)
60
+ let worldRead = new Float32Array(16)
61
+ // lookAt()/worldPosition() scratch: nothing here outlives a single call.
49
62
  let worldScratch = mat4()
50
63
  let localScratch = mat4()
64
+ let rayOriginScratch = new Float32Array(3)
65
+ let rayDirScratch = new Float32Array(3)
51
66
  let pointScratch: Vec4 = [0, 0, 0, 0]
67
+
68
+ // Settle routing: the core's "spatialTransitionEnd" event carries the node
69
+ // id, so nodes with a transition DECLARED (only those can settle) are
70
+ // indexed by their core id while in a scene, and one lazy subscription,
71
+ // started at the first declaration, routes to the node's onTransitionEnd.
72
+ // Target-only, like the element transitions.
73
+ let declared = new Map<NodeId, SceneNode>()
74
+ let subscribed = false
75
+
76
+ function declare(id: NodeId, node: SceneNode): void {
77
+ declared.set(id, node)
78
+ if (subscribed) return
79
+ subscribed = true
80
+ on("spatialTransitionEnd", (event: { node: NodeId; component: TransitionEndEvent["component"] }) => {
81
+ let node = declared.get(event.node)
82
+ if (!node) return
83
+ try {
84
+ node.onTransitionEnd?.({ component: event.component })
85
+ } catch (err) {
86
+ console.error("Error in onTransitionEnd handler:", err)
87
+ }
88
+ })
89
+ }
52
90
  let aimScratch: Vec3 = [0, 0, 0]
53
91
  let upScratch: Vec3 = [0, 0, 0]
54
- // Picking narrowphase scratch: one candidate is tested at a time, so one
55
- // set serves every raycast.
56
- let pickInv = mat4()
57
- let pickOrigin: Vec4 = [0, 0, 0, 0]
92
+ // pick()'s camera-ray scratch.
58
93
  let pickDir: Vec3 = [0, 0, 0]
59
94
  // setTransform's rotation compare happens AFTER conversion, so an euler and
60
95
  // the quaternion it produces are the same write. Nothing outlives the call.
@@ -70,13 +105,24 @@ type SceneHooks = {
70
105
  _schedule(): void
71
106
  _attach(mesh: Mesh): void
72
107
  _detach(mesh: Mesh): void
108
+ _attachLight(light: Light): void
109
+ _detachLight(light: Light): void
110
+ _lightChanged(): void
73
111
  _setParams(mesh: Mesh, params: ShaderParams): void
74
112
  _setCount(mesh: Mesh): void
113
+ /** Re-point the mesh's entry at its (replaced) instance buffer. */
114
+ _setBuffer(mesh: Mesh): void
115
+ /** The mesh's castShadow flag changed: re-evaluate the filtered views. */
116
+ _setCast(mesh: Mesh): void
117
+ /** A light's castShadow/shadow options changed. */
118
+ _shadowChanged(light: DirectionalLight): void
75
119
  _reorder(): void
120
+ /** The node's transform changed (for the sort and light bookkeeping). */
121
+ _moved(node: SceneNode): void
76
122
  }
77
123
 
78
124
  export type SceneNode = {
79
- kind: "group" | "mesh"
125
+ kind: "group" | "mesh" | "light"
80
126
  parent: SceneNode | null
81
127
  children: SceneNode[]
82
128
  /** Read freely; write through setTransform/setVisible so changes sync. */
@@ -98,12 +144,70 @@ export type SceneNode = {
98
144
  onPointerUp?: (event: ScenePointerEvent) => void
99
145
  onPointerEnter?: (event: ScenePointerEvent) => void
100
146
  onPointerLeave?: (event: ScenePointerEvent) => void
101
- _localDirty: boolean
102
- _local: Mat4
103
- _world: Mat4
147
+ /** A declared transition (setTransition) settled naturally on one
148
+ * component; a cancel, snap or scene leave never fires. */
149
+ onTransitionEnd?: (event: TransitionEndEvent) => void
150
+ /** The core node while in a scene (created at add, freed at remove). */
151
+ _node: NodeId | null
152
+ _moved: boolean
104
153
  _scene: SceneHooks | null
154
+ /** The declared transition, re-applied on every scene enter. */
155
+ _transition: NodeTransition | string | null
156
+ }
157
+
158
+ /** The orthographic frustum a casting light renders its shadow map from,
159
+ * in the light's own space (x right, y up, looking along its direction),
160
+ * Three's DirectionalLightShadow camera. Everything outside it is lit. */
161
+ export type ShadowCamera = { left: number; right: number; top: number; bottom: number; near: number; far: number }
162
+
163
+ export type ShadowOptions = {
164
+ /** Shadow map resolution in texels, square (default 1024). */
165
+ mapSize?: number
166
+ /** Depth bias against acne, in the map's 0..1 depth (default 0). */
167
+ bias?: number
168
+ /** Offset a receiving point along its normal before the lookup, in
169
+ * world units (default 0) - the acne fix that keeps contact shadows. */
170
+ normalBias?: number
171
+ /** The light frustum; absent keys keep the defaults +-5, 0.5..500. */
172
+ camera?: Partial<ShadowCamera>
173
+ }
174
+
175
+ /** A directional light node: parallel rays travelling along `direction`
176
+ * in the node's LOCAL space, so a parent's rotation turns the light with
177
+ * it (the default `[0, -1, 0]` is a sun straight overhead; the length is
178
+ * ignored). Scale does not affect it, and neither does position UNLESS
179
+ * it casts: a casting light's shadow camera sits at its WORLD position
180
+ * looking along its world direction (Three's rule), so place a casting
181
+ * sun above the scene. Write through setLight. */
182
+ export type DirectionalLight = SceneNode & {
183
+ kind: "light"
184
+ type: "directional"
185
+ direction: Vec3
186
+ /** Linear [r, g, b] 0..1. */
187
+ color: Vec3
188
+ intensity: number
189
+ /** Render a shadow map from this light (any directional light may;
190
+ * each map is a full extra pass over the casting meshes); meshes with
191
+ * `castShadow` draw into it, `lit` materials read it (unless
192
+ * `receiveShadow: false`). */
193
+ castShadow: boolean
194
+ /** The resolved shadow options (read; write through setLight). */
195
+ shadow: { mapSize: number; bias: number; normalBias: number; camera: ShadowCamera }
196
+ }
197
+
198
+ /** The ambient term: a sky/ground gradient by the WORLD normal's
199
+ * vertical tilt (fixed to world up, not the node's). One per scene - the
200
+ * last attached wins. Write through setLight. */
201
+ export type HemisphereLight = SceneNode & {
202
+ kind: "light"
203
+ type: "hemisphere"
204
+ sky: Vec3
205
+ ground: Vec3
206
+ intensity: number
105
207
  }
106
208
 
209
+ export type Light = DirectionalLight | HemisphereLight
210
+
107
211
  export type Mesh = SceneNode & {
108
212
  kind: "mesh"
109
213
  geometry: Geometry
@@ -112,6 +216,10 @@ export type Mesh = SceneNode & {
112
216
  * Sorts within the opaque group and within the transparent group; the
113
217
  * transparent group always follows the opaque one. Set with setRenderOrder. */
114
218
  renderOrder: number
219
+ /** Draw into the scene's shadow map (default false, Three's default).
220
+ * Set with setCastShadow. A casting instanced mesh is skipped (the
221
+ * depth pass cannot know its record layout). */
222
+ castShadow: boolean
115
223
  _entry: DrawId | null
116
224
  /** The geometry-buffer reference the entry was built from, acquired at
117
225
  * attach and what _detach releases - like _transparent, a snapshot,
@@ -121,16 +229,16 @@ export type Mesh = SceneNode & {
121
229
  * pipeline state, and what _detach counts against (setMaterial swaps
122
230
  * mesh.material before the rebuild). */
123
231
  _transparent: boolean
124
- /** World-space center of the geometry bounds, kept by the sync walk
125
- * beside the picking leaf: the transparent sort key. */
232
+ /** World-space center of the local bounds, refreshed at sort time: the
233
+ * transparent sort key. */
126
234
  _center: Vec3
127
- _hidden: boolean
128
- _fresh: boolean
129
235
  _params: ShaderParams | null
130
- _pickLeaf: number | null
131
236
  /** Instance state when the mesh was made by createInstancedMesh; null on
132
237
  * an ordinary mesh. */
133
238
  _instances: MeshInstances | null
239
+ /** True for a createSprite mesh: its quad faces the camera in the
240
+ * vertex stage, so it picks by a unit box instead of its flat triangles. */
241
+ _sprite: boolean
134
242
  }
135
243
 
136
244
  /** The per-mesh half of instancing: the record buffer and its bookkeeping.
@@ -141,9 +249,11 @@ export type MeshInstances = {
141
249
  buffer: BufferId
142
250
  /** Floats per record - the material's instanceAttributes summed. */
143
251
  stride: number
144
- /** Records the buffer has room for; fixed at creation, like every GPU
145
- * buffer's byte size. */
252
+ /** Records the buffer has room for; doubles when setInstances writes
253
+ * more (a replacement buffer, never a resize). */
146
254
  capacity: number
255
+ /** The buffer label, carried to replacement buffers on growth. */
256
+ label: string | undefined
147
257
  /** Records currently drawn (the entry's instanceCount while visible). */
148
258
  count: number
149
259
  /** Explicit LOCAL bounds covering the whole population ([minX, minY,
@@ -157,13 +267,26 @@ export type MeshInstances = {
157
267
  * `instances.count` copies of the geometry, one record each. */
158
268
  export type InstancedMesh = Mesh & { _instances: MeshInstances }
159
269
 
160
- /** One picking intersection: the mesh, the camera-ray distance in world
161
- * units, and the world-space point - Three's intersect result minus the
162
- * triangle fields (`face`, `uv`), which cannot exist at the volume tier. */
270
+ /** One picking intersection, Three's intersect result: the mesh, the
271
+ * camera-ray distance in world units, the world-space point, and for a
272
+ * triangle hit (every ordinary mesh - the test is per triangle, so a ray
273
+ * through a knot's hole misses) the world-space geometric `normal` facing
274
+ * the ray, the triangle index `face` and the interpolated texture `uv`.
275
+ * An instanced mesh is picked by its explicit population box and a sprite
276
+ * by a unit box around its center, so those three are absent on their
277
+ * hits. */
163
278
  export type Hit = {
164
279
  mesh: Mesh
165
280
  distance: number
166
281
  point: Vec3
282
+ normal?: Vec3
283
+ face?: number
284
+ uv?: [number, number]
285
+ }
286
+
287
+ /** The settled component of a node transition. */
288
+ export type TransitionEndEvent = {
289
+ component: "position" | "rotation" | "scale"
167
290
  }
168
291
 
169
292
  /**
@@ -207,6 +330,10 @@ export type SceneHandlers = {
207
330
  onPointerLeave(event: ElementPointerEvent): void
208
331
  }
209
332
 
333
+ /** An orthographic projection's view-space extents, in world units (the
334
+ * same box at every depth). */
335
+ export type OrthoExtent = { left: number; right: number; top: number; bottom: number }
336
+
210
337
  export type CameraUpdate = {
211
338
  /** Vertical field of view in DEGREES (default 60). */
212
339
  fov?: number
@@ -215,6 +342,11 @@ export type CameraUpdate = {
215
342
  position?: Vec3
216
343
  target?: Vec3
217
344
  up?: Vec3
345
+ /** An orthographic projection with these extents (`fov` is then
346
+ * ignored); null returns to perspective. Three's OrthographicCamera as
347
+ * a camera option: a top-down map, an isometric view, a shadow-map
348
+ * light. */
349
+ ortho?: OrthoExtent | null
218
350
  }
219
351
 
220
352
  export type SceneOptions = {
@@ -227,6 +359,49 @@ export type SceneOptions = {
227
359
  autoFree?: boolean
228
360
  filter?: FilterMode
229
361
  wrap?: WrapMode
362
+ /** Multisample count of the target (1, 2, 4 or 8; default 1). Storage-only
363
+ * anti-aliasing of mesh edges; see createDrawTarget. */
364
+ samples?: 1 | 2 | 4 | 8
365
+ }
366
+
367
+ export type ViewOptions = {
368
+ width: number
369
+ height: number
370
+ /**
371
+ * Every mesh draws with this material instead of its own (Three's
372
+ * `scene.overrideMaterial`, scoped to the view): a depth pass, a normal
373
+ * or id visualizer. The view then carries none of the meshes' own
374
+ * bindings or params, and instanced meshes are skipped (the override's
375
+ * vertex stage cannot know their record layout). An overridden view
376
+ * draws in add order (no renderOrder or transparent sort).
377
+ */
378
+ overrideMaterial?: Material
379
+ /** The view target's depth storage: true (default) for a buffer,
380
+ * "texture" for a sampleable one exposed as `view.depthTexture`. */
381
+ depth?: true | "texture"
382
+ clearColor?: [number, number, number, number]
383
+ samples?: 1 | 2 | 4 | 8
384
+ filter?: FilterMode
385
+ wrap?: WrapMode
386
+ label?: string
387
+ }
388
+
389
+ /** A second rendering of a scene from its own camera; see Scene.createView. */
390
+ export type View = {
391
+ /** The view's output, an ordinary texture id. */
392
+ texture: TextureId
393
+ /** The view target's depth as a sampler-only texture id when created
394
+ * with `depth: "texture"` (the shadow-map input), else null. */
395
+ depthTexture: TextureId | null
396
+ /** Partial camera update, exactly scene.setCamera. */
397
+ setCamera(update: CameraUpdate): void
398
+ setSize(width: number, height: number): void
399
+ /** View-owned shared params on the view's target (the scene's own
400
+ * setParams names fan out to every view already). */
401
+ setParams(params: ShaderParams): void
402
+ /** Destroy the view's target (its entries die with it). Idempotent;
403
+ * views also die with their scene. */
404
+ dispose(): void
230
405
  }
231
406
 
232
407
  export type Scene = {
@@ -301,7 +476,7 @@ export type Scene = {
301
476
  *
302
477
  * Coordinates assume the leaf is LAID OUT at the target size - true for
303
478
  * the built-in leaf and a d-texture at natural size, under any ancestor
304
- * transforms or viewBox fits (the hit test undoes them). A leaf laid out
479
+ * transforms or design-size fits (the hit test undoes them). A leaf laid out
305
480
  * at a different size needs handlersFor instead.
306
481
  */
307
482
  handlers: SceneHandlers
@@ -311,6 +486,19 @@ export type Scene = {
311
486
  * layout just works: `scene.handlersFor(() => ({ width: w(), height:
312
487
  * h() }))`. */
313
488
  handlersFor(layout: () => { width: number; height: number }): SceneHandlers
489
+ /**
490
+ * A second rendering of this scene: its own draw target and camera,
491
+ * the same meshes and lights. Each mesh gets one entry in the view's
492
+ * target, bound as one more draw sink of the mesh's core node, so a
493
+ * move feeds every target from the one flush and the app writes
494
+ * nothing per view. Views share the scene's geometry buffers and
495
+ * (unless `overrideMaterial`) its materials; the light set and
496
+ * scene.setParams names fan out to every view, view.setParams is the
497
+ * view's own channel. The scene's background is not mirrored (a view's
498
+ * backdrop is its clearColor), and a view has no picking or pointer
499
+ * events. Views die with the scene; `view.dispose()` drops one early.
500
+ */
501
+ createView(opts: ViewOptions): View
314
502
  /** Destroy the target (entries die with it). Idempotent. Material
315
503
  * pipelines are shared and survive (app-lifetime, see material.ts);
316
504
  * geometry buffers are reference-counted and freed with their last
@@ -318,7 +506,115 @@ export type Scene = {
318
506
  dispose(): void
319
507
  }
320
508
 
321
- function makeNode(kind: "group" | "mesh"): SceneNode {
509
+ // A camera: the scene's own and one per view, the same state and the same
510
+ // one-shared-write contract. `dirty` = the matrices need recomputing (a
511
+ // setCamera or a resize), `pending` = the GPU write is owed to the next
512
+ // sync. The recompute is split from the sync so project()/viewProj() see a
513
+ // fresh matrix right after setCamera, before the microtask runs.
514
+ type Camera = {
515
+ fov: number
516
+ near: number
517
+ far: number
518
+ eye: Vec3
519
+ target: Vec3
520
+ up: Vec3
521
+ ortho: OrthoExtent | null
522
+ dirty: boolean
523
+ pending: boolean
524
+ proj: Mat4
525
+ view: Mat4
526
+ viewProj: Mat4
527
+ }
528
+
529
+ function makeCamera(): Camera {
530
+ return {
531
+ fov: 60,
532
+ near: 0.1,
533
+ far: 100,
534
+ eye: [0, 0, 3],
535
+ target: [0, 0, 0],
536
+ up: [0, 1, 0],
537
+ ortho: null,
538
+ dirty: true,
539
+ pending: false,
540
+ proj: mat4(),
541
+ view: mat4(),
542
+ viewProj: mat4(),
543
+ }
544
+ }
545
+
546
+ function updateCamera(cam: Camera, update: CameraUpdate): void {
547
+ if (update.fov !== undefined) cam.fov = update.fov
548
+ if (update.near !== undefined) cam.near = update.near
549
+ if (update.far !== undefined) cam.far = update.far
550
+ if (update.position) cam.eye = [update.position[0], update.position[1], update.position[2]]
551
+ if (update.target) cam.target = [update.target[0], update.target[1], update.target[2]]
552
+ if (update.up) cam.up = [update.up[0], update.up[1], update.up[2]]
553
+ if (update.ortho !== undefined) {
554
+ let o = update.ortho
555
+ cam.ortho = o === null ? null : { left: o.left, right: o.right, top: o.top, bottom: o.bottom }
556
+ }
557
+ cam.dirty = true
558
+ }
559
+
560
+ function ensureCamera(cam: Camera, width: number, height: number): void {
561
+ if (!cam.dirty) return
562
+ cam.dirty = false
563
+ cam.pending = true
564
+ let o = cam.ortho
565
+ if (o === null) perspective(cam.proj, (cam.fov * Math.PI) / 180, width / height, cam.near, cam.far)
566
+ else orthographic(cam.proj, o.left, o.right, o.top, o.bottom, cam.near, cam.far)
567
+ lookAtMatrix(cam.view, cam.eye, cam.target, cam.up)
568
+ multiply(cam.viewProj, cam.proj, cam.view)
569
+ }
570
+
571
+ // The camera is target state: one shared write, whatever the target holds.
572
+ // Entries are untouched - uModel is camera-independent, and uCamPos is
573
+ // stored even when no current material declares it. The camera basis rides
574
+ // along: the view matrix's first two rows are the camera's world-space
575
+ // right and up (no clip flip - that lives in the projection), so a
576
+ // billboard needs no reconstruction from uViewProj.
577
+ function cameraParams(cam: Camera): ShaderParams {
578
+ let v = cam.view
579
+ return { uViewProj: cam.viewProj, uCamPos: cam.eye, uCamRight: [v[0], v[4], v[8]], uCamUp: [v[1], v[5], v[9]] }
580
+ }
581
+
582
+ // A material reads attributes by name; the geometry's layout must carry
583
+ // every one it declares (the pipeline is built for that layout, so a
584
+ // missing channel would have no home) - an error, like the rest of the
585
+ // strict entry path. Extra channels are fine.
586
+ function checkLayout(material: Material, geometry: Geometry, what: string): void {
587
+ let missing = missingAttributes(material, geometry.layout)
588
+ if (missing.length > 0) {
589
+ throw new Error(
590
+ what + " reads attributes the geometry layout (" + layoutKey(geometry.layout) + ") lacks: " +
591
+ missing.map(a => a.name + " " + a.format).join(", ") +
592
+ " - add the channel with withAttribute()/withColors(), or use a material that does not read it",
593
+ )
594
+ }
595
+ }
596
+
597
+ // An entry's initial params. The uNormal seed keys off the material flag
598
+ // because entry params validate strictly - and a material declaring
599
+ // uNormal without using it therefore throws right here, at add().
600
+ function entrySeed(material: Material, params: ShaderParams | null): ShaderParams {
601
+ return material.normalMatrix
602
+ ? { uModel: IDENTITY, uNormal: IDENTITY, ...material.params, ...params }
603
+ : { uModel: IDENTITY, ...material.params, ...params }
604
+ }
605
+
606
+ // The uShadowMap<i> binding of a light slot that does not cast: one white
607
+ // texel (depth 1, never shadowed), shared by every scene for the app.
608
+ let placeholder: TextureId | undefined
609
+
610
+ function shadowPlaceholder(): TextureId {
611
+ if (placeholder === undefined) {
612
+ placeholder = createTexture(new Uint8Array([255, 255, 255, 255]), 1, 1, { autoFree: false, label: "scene-shadow-none" })
613
+ }
614
+ return placeholder
615
+ }
616
+
617
+ function makeNode(kind: SceneNode["kind"]): SceneNode {
322
618
  return {
323
619
  kind,
324
620
  parent: null,
@@ -327,10 +623,10 @@ function makeNode(kind: "group" | "mesh"): SceneNode {
327
623
  quaternion: [0, 0, 0, 1],
328
624
  scale: [1, 1, 1],
329
625
  visible: true,
330
- _localDirty: true,
331
- _local: mat4(),
332
- _world: mat4(),
626
+ _node: null,
627
+ _moved: false,
333
628
  _scene: null,
629
+ _transition: null,
334
630
  }
335
631
  }
336
632
 
@@ -338,28 +634,119 @@ export function createGroup(): SceneNode {
338
634
  return makeNode("group")
339
635
  }
340
636
 
637
+ export type DirectionalLightOptions = {
638
+ direction?: Vec3
639
+ color?: Vec3
640
+ intensity?: number
641
+ castShadow?: boolean
642
+ /** Shadow-map options, merged key by key (setLight keeps unmentioned ones). */
643
+ shadow?: ShadowOptions
644
+ }
645
+ export type HemisphereLightOptions = { sky?: Vec3; ground?: Vec3; intensity?: number }
646
+
647
+ /** Every directional light may cast: shadow slot i is light i's, so the
648
+ * cap is MAX_LIGHTS (each map is a sampler unit and a full extra pass). */
649
+ export const MAX_SHADOWS = MAX_LIGHTS
650
+
651
+ function mergeShadow(into: DirectionalLight["shadow"], update: ShadowOptions): void {
652
+ if (update.mapSize !== undefined) into.mapSize = update.mapSize
653
+ if (update.bias !== undefined) into.bias = update.bias
654
+ if (update.normalBias !== undefined) into.normalBias = update.normalBias
655
+ if (update.camera !== undefined) Object.assign(into.camera, update.camera)
656
+ }
657
+
658
+ export function createDirectionalLight(opts: DirectionalLightOptions = {}): DirectionalLight {
659
+ let light = makeNode("light") as DirectionalLight
660
+ light.type = "directional"
661
+ light.direction = [...(opts.direction ?? [0, -1, 0])] as Vec3
662
+ light.color = [...(opts.color ?? [1, 1, 1])] as Vec3
663
+ light.intensity = opts.intensity ?? 1
664
+ light.castShadow = opts.castShadow === true
665
+ light.shadow = { mapSize: 1024, bias: 0, normalBias: 0, camera: { left: -5, right: 5, top: 5, bottom: -5, near: 0.5, far: 500 } }
666
+ if (opts.shadow !== undefined) mergeShadow(light.shadow, opts.shadow)
667
+ return light
668
+ }
669
+
670
+ export function createHemisphereLight(opts: HemisphereLightOptions = {}): HemisphereLight {
671
+ let light = makeNode("light") as HemisphereLight
672
+ light.type = "hemisphere"
673
+ light.sky = [...(opts.sky ?? [1, 1, 1])] as Vec3
674
+ light.ground = [...(opts.ground ?? [0.2, 0.2, 0.2])] as Vec3
675
+ light.intensity = opts.intensity ?? 1
676
+ return light
677
+ }
678
+
679
+ /** The write path for a light's own fields (color, intensity, direction
680
+ * or sky/ground); absent keys keep their value. Its placement goes
681
+ * through setTransform like any node. Frame-rate-safe. */
682
+ export function setLight(light: DirectionalLight, update: DirectionalLightOptions): void
683
+ export function setLight(light: HemisphereLight, update: HemisphereLightOptions): void
684
+ export function setLight(light: Light, update: DirectionalLightOptions & HemisphereLightOptions): void {
685
+ if (update.intensity !== undefined) light.intensity = update.intensity
686
+ if (light.type === "directional") {
687
+ if (update.direction !== undefined) light.direction = [...update.direction] as Vec3
688
+ if (update.color !== undefined) light.color = [...update.color] as Vec3
689
+ let shadowChanged = false
690
+ if (update.castShadow !== undefined && update.castShadow !== light.castShadow) {
691
+ light.castShadow = update.castShadow
692
+ shadowChanged = true
693
+ }
694
+ if (update.shadow !== undefined) {
695
+ mergeShadow(light.shadow, update.shadow)
696
+ shadowChanged = true
697
+ }
698
+ if (shadowChanged) light._scene?._shadowChanged(light)
699
+ } else {
700
+ if (update.sky !== undefined) light.sky = [...update.sky] as Vec3
701
+ if (update.ground !== undefined) light.ground = [...update.ground] as Vec3
702
+ }
703
+ light._scene?._lightChanged()
704
+ }
705
+
341
706
  export function createMesh(geometry: Geometry, material: Material): Mesh {
342
707
  let mesh = makeNode("mesh") as Mesh
343
708
  mesh.geometry = geometry
344
709
  mesh.material = material
345
710
  mesh.renderOrder = 0
711
+ mesh.castShadow = false
346
712
  mesh._entry = null
347
713
  mesh._buffers = null
348
714
  mesh._transparent = false
349
715
  mesh._center = [0, 0, 0]
350
- mesh._hidden = false
351
- mesh._fresh = false
352
716
  mesh._params = null
353
- mesh._pickLeaf = null
354
717
  mesh._instances = null
718
+ mesh._sprite = false
719
+ return mesh
720
+ }
721
+
722
+ // Every sprite draws the same unit quad, built once: geometry is data
723
+ // and its GPU buffers are acquired per mesh, so one shared value is the
724
+ // normal sharing story. The box is the quad's extent at any facing.
725
+ let spriteQuad: Geometry | undefined
726
+ const SPRITE_BOUNDS = new Float32Array([-0.5, -0.5, -0.5, 0.5, 0.5, 0.5])
727
+
728
+ /**
729
+ * A camera-facing quad, Three's `Sprite`: a unit plane drawn with a
730
+ * `sprite()` material (any material works, but only a sprite material
731
+ * turns the quad; there is no `geometry` argument). Size it with `scale` -
732
+ * a scale of [2, 1, 1] is a 2 x 1 world-unit quad - and place it like any
733
+ * mesh; its rotation is ignored, the camera decides the facing. Picking
734
+ * is by a unit box around the center (the quad's reach at any facing, an
735
+ * approximation), so hits carry no normal/face/uv.
736
+ */
737
+ export function createSprite(material: Material): Mesh {
738
+ if (spriteQuad === undefined) spriteQuad = plane({ label: "sprite" })
739
+ let mesh = createMesh(spriteQuad, material)
740
+ mesh._sprite = true
355
741
  return mesh
356
742
  }
357
743
 
358
744
  /** The local box picking and sorting work from: explicit instance bounds
359
745
  * when the mesh is instanced (null without them - no leaf, no hits), the
360
- * geometry's own bounds otherwise. */
746
+ * unit box for a sprite, the geometry's own bounds otherwise. */
361
747
  function localBounds(mesh: Mesh): Float32Array | null {
362
- return mesh._instances !== null ? mesh._instances.bounds : geometryBounds(mesh.geometry)
748
+ if (mesh._instances !== null) return mesh._instances.bounds
749
+ return mesh._sprite ? SPRITE_BOUNDS : geometryBounds(mesh.geometry)
363
750
  }
364
751
 
365
752
  const ATTRIBUTE_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
@@ -389,9 +776,9 @@ export type InstancedMeshOptions = {
389
776
  * vertex buffer. The material must declare `instanceAttributes`
390
777
  * (shaderMaterialClass); its vertex stage reads each record through those
391
778
  * `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.
779
+ * the attributes' floats summed) and is uploaded here; its length is the
780
+ * buffer's initial capacity, which setInstances grows past on demand.
781
+ * `count` limits how many records draw (default all), up to capacity.
395
782
  *
396
783
  * The result is an ordinary Mesh: add/remove, setTransform (uModel places
397
784
  * the whole population), setVisible (hiding zeroes the drawn count,
@@ -432,6 +819,7 @@ export function createInstancedMesh(
432
819
  buffer: createBuffer(records, { autoFree: false, label: opts?.label }),
433
820
  stride,
434
821
  capacity,
822
+ label: opts?.label,
435
823
  count: Math.max(0, Math.min(Math.floor(count ?? capacity), capacity)),
436
824
  bounds,
437
825
  }
@@ -442,9 +830,12 @@ export function createInstancedMesh(
442
830
  * Overwrite an instanced mesh's records from the start of its buffer and
443
831
  * (by default) draw exactly the records written - pass `count` to draw
444
832
  * 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.
833
+ * rewrite. More records than the buffer holds grow it: capacity doubles
834
+ * (or jumps to the records written when that is more), a new buffer is
835
+ * created and written, the mesh's entry is re-pointed at it and the old
836
+ * buffer freed - so a population grows without a new mesh, with the
837
+ * copies amortized like any dynamic array (size the initial records to
838
+ * skip them). Frame-rate-safe like setMeshParams when no growth happens.
448
839
  */
449
840
  export function setInstances(mesh: InstancedMesh, records: Float32Array, count?: number): void {
450
841
  let inst = mesh._instances
@@ -453,9 +844,11 @@ export function setInstances(mesh: InstancedMesh, records: Float32Array, count?:
453
844
  }
454
845
  let written = records.length / inst.stride
455
846
  if (written > inst.capacity) {
456
- throw new Error(
457
- "setInstances: " + written + " records exceed the buffer's capacity of " + inst.capacity + " (fixed at creation)",
458
- )
847
+ let previous = inst.buffer
848
+ inst.capacity = Math.max(written, inst.capacity * 2)
849
+ inst.buffer = createBuffer(inst.capacity * inst.stride * 4, { autoFree: false, label: inst.label })
850
+ mesh._scene?._setBuffer(mesh)
851
+ destroyBuffer(previous)
459
852
  }
460
853
  writeBuffer(inst.buffer, records)
461
854
  setInstanceCount(mesh, count ?? written)
@@ -490,7 +883,6 @@ export function add(parent: SceneNode, child: SceneNode): void {
490
883
  if (child.parent !== null) remove(child)
491
884
  child.parent = parent
492
885
  parent.children.push(child)
493
- child._localDirty = true
494
886
  if (parent._scene) enterScene(child, parent._scene)
495
887
  }
496
888
 
@@ -507,8 +899,16 @@ export function remove(child: SceneNode): void {
507
899
 
508
900
  function enterScene(node: SceneNode, scene: SceneHooks): void {
509
901
  node._scene = scene
510
- node._localDirty = true
902
+ node._node = spatial.createNode(fillTransform(node), node.visible)
903
+ if (node._transition !== null) {
904
+ spatial.setTransition(node._node, node._transition)
905
+ declare(node._node, node)
906
+ }
907
+ // The parent is in the scene already (add() enters the child only then),
908
+ // and the scene root is the one node without a parent.
909
+ if (node.parent !== null && node.parent._node !== null) spatial.setParent(node._node, node.parent._node)
511
910
  if (node.kind === "mesh") scene._attach(node as Mesh)
911
+ else if (node.kind === "light") scene._attachLight(node as Light)
512
912
  for (let c of node.children) enterScene(c, scene)
513
913
  scene._schedule()
514
914
  }
@@ -516,12 +916,60 @@ function enterScene(node: SceneNode, scene: SceneHooks): void {
516
916
  function leaveScene(node: SceneNode): void {
517
917
  let scene = node._scene
518
918
  if (scene && node.kind === "mesh") scene._detach(node as Mesh)
919
+ else if (scene && node.kind === "light") scene._detachLight(node as Light)
519
920
  node._scene = null
520
921
  for (let c of node.children) leaveScene(c)
922
+ if (node._node !== null) {
923
+ declared.delete(node._node)
924
+ spatial.destroyNode(node._node)
925
+ node._node = null
926
+ }
927
+ }
928
+
929
+ /** The node's local transform in the FFI carrier. */
930
+ function fillTransform(node: SceneNode): Float32Array {
931
+ let t = transformScratch
932
+ t[0] = node.position[0]; t[1] = node.position[1]; t[2] = node.position[2]
933
+ t[3] = node.quaternion[0]; t[4] = node.quaternion[1]; t[5] = node.quaternion[2]; t[6] = node.quaternion[3]
934
+ t[7] = node.scale[0]; t[8] = node.scale[1]; t[9] = node.scale[2]
935
+ return t
936
+ }
937
+
938
+ /** Forward a changed local transform to the core (no-op outside a scene:
939
+ * entering pushes the whole transform). */
940
+ function pushTransform(node: SceneNode): void {
941
+ if (node._node === null || node._scene === null) return
942
+ spatial.writeTransform(node._node, fillTransform(node))
943
+ node._scene._moved(node)
521
944
  }
522
945
 
523
946
  export type { TransformUpdate } from "./math.ts"
524
947
 
948
+ /**
949
+ * Declare (or with null clear) how the node's transform writes animate:
950
+ * once set, setTransform writes are TARGETS the core animates toward
951
+ * (position/scale per lane, rotation along the quaternion geodesic - a
952
+ * spring keeps its velocity through retargets, the pursuit-safe shape),
953
+ * so JS writes once per target change instead of once per frame. A spec
954
+ * per component (position, rotation, scale) plus `all`; each
955
+ * `{ duration, bounce? }` (a spring, the default) / `{ duration, curve }`
956
+ * (a tween) / a shorthand string like "300ms ease-out". The declaration
957
+ * lives on the node and re-applies whenever it enters a scene; the pose
958
+ * it enters with always snaps. Clearing cancels running tracks in place
959
+ * (the node keeps its mid-flight transform) and later writes snap. Each
960
+ * natural settle calls the node's `onTransitionEnd` with the component
961
+ * (the raw "spatialTransitionEnd" engine event on srt:events stays for
962
+ * flux:spatial consumers; it carries the core id, `_node`).
963
+ */
964
+ export function setTransition(node: SceneNode, transition: NodeTransition | string | null): void {
965
+ node._transition = transition
966
+ if (node._node !== null) {
967
+ spatial.setTransition(node._node, transition)
968
+ if (transition === null) declared.delete(node._node)
969
+ else declare(node._node, node)
970
+ }
971
+ }
972
+
525
973
  /**
526
974
  * The one write path for node transforms (so the scene knows to sync).
527
975
  * Values are copied in; absent keys keep their current value. This is also
@@ -566,8 +1014,7 @@ export function setTransform(node: SceneNode, update: TransformUpdate): void {
566
1014
  }
567
1015
  }
568
1016
  if (!changed) return
569
- node._localDirty = true
570
- node._scene?._schedule()
1017
+ pushTransform(node)
571
1018
  }
572
1019
 
573
1020
  /**
@@ -614,8 +1061,7 @@ export function lookAt(node: SceneNode, target: Vec3, up: Vec3 = WORLD_UP): void
614
1061
  unrotate(upScratch, world, up)
615
1062
  quatFromFrame(node.quaternion, aimScratch, upScratch)
616
1063
  }
617
- node._localDirty = true
618
- node._scene?._schedule()
1064
+ pushTransform(node)
619
1065
  }
620
1066
 
621
1067
  /**
@@ -644,18 +1090,22 @@ export function worldPosition(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
644
1090
  }
645
1091
 
646
1092
  /**
647
- * `out` = node's world matrix, composing any dirty locals up the chain
648
- * WITHOUT clearing their flags: the pending sync still has to see them to
649
- * write uModel. One shared local scratch serves any depth - each frame
650
- * uses it only after its recursive call has returned.
1093
+ * `out` = node's world matrix as the tree stands now. In a scene that is
1094
+ * one core read (pending writes included, nothing cleared); outside one
1095
+ * the chain is composed here. Scene membership is subtree-closed, so the
1096
+ * recursion meets a core node at the first in-scene ancestor at the
1097
+ * latest. One shared local scratch serves any depth - each frame uses it
1098
+ * only after its recursive call has returned.
651
1099
  */
652
1100
  function worldInto(out: Mat4, node: SceneNode): Mat4 {
1101
+ if (node._node !== null) {
1102
+ spatial.worldMatrix(node._node, worldRead)
1103
+ for (let i = 0; i < 16; i++) out[i] = worldRead[i]!
1104
+ return out
1105
+ }
653
1106
  if (node.parent === null) identity(out)
654
1107
  else worldInto(out, node.parent)
655
- let local = node._localDirty
656
- ? compose(localScratch, node.position, node.quaternion, node.scale)
657
- : node._local
658
- return multiply(out, out, local)
1108
+ return multiply(out, out, compose(localScratch, node.position, node.quaternion, node.scale))
659
1109
  }
660
1110
 
661
1111
  /**
@@ -678,7 +1128,10 @@ function unrotate(out: Vec3, m: Mat4, v: Vec3): Vec3 {
678
1128
  export function setVisible(node: SceneNode, visible: boolean): void {
679
1129
  if (node.visible === visible) return
680
1130
  node.visible = visible
681
- node._scene?._schedule()
1131
+ if (node._node !== null) {
1132
+ spatial.setVisible(node._node, visible)
1133
+ node._scene?._schedule()
1134
+ }
682
1135
  }
683
1136
 
684
1137
  /** Set a mesh's explicit draw-order key (see Mesh.renderOrder). */
@@ -688,9 +1141,17 @@ export function setRenderOrder(mesh: Mesh, order: number): void {
688
1141
  mesh._scene?._reorder()
689
1142
  }
690
1143
 
1144
+ /** Draw the mesh into the scene's shadow map, or stop (see Mesh.castShadow). */
1145
+ export function setCastShadow(mesh: Mesh, cast: boolean): void {
1146
+ if (mesh.castShadow === cast) return
1147
+ mesh.castShadow = cast
1148
+ mesh._scene?._setCast(mesh)
1149
+ }
1150
+
691
1151
  /** Swap a mesh's geometry: its draw entry is rebuilt (the scene re-sorts
692
1152
  * the list, so the mesh keeps its place). */
693
1153
  export function setGeometry(mesh: Mesh, geometry: Geometry): void {
1154
+ if (mesh._sprite) throw new Error("setGeometry: a sprite draws the shared unit quad and takes no geometry")
694
1155
  if (mesh.geometry === geometry) return
695
1156
  mesh.geometry = geometry
696
1157
  rebuildEntry(mesh)
@@ -738,16 +1199,21 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
738
1199
  clearColor: opts?.clearColor,
739
1200
  filter: opts?.filter,
740
1201
  wrap: opts?.wrap,
1202
+ samples: opts?.samples,
741
1203
  label: opts?.label ?? "scene",
742
1204
  autoFree: false,
743
1205
  })
744
1206
  let disposed = false
745
1207
  let scheduled = false
746
1208
 
747
- // Picking state: the broadphase tree over world boxes, kept current by
748
- // the sync walk (the meshes it touches are exactly the leaves to move),
749
- // and the pointer bookkeeping behind scene.handlers.
750
- let bvh = createBvh<Mesh>()
1209
+ // Picking: the index and the narrowphase live in the spatial core; this
1210
+ // map turns a hit's core node back into the mesh. The pointer
1211
+ // bookkeeping behind scene.handlers follows.
1212
+ let byNode = new Map<NodeId, Mesh>()
1213
+ // Nodes whose transform changed since the last sync (deduped by the
1214
+ // _moved flag): what the light and transparent-order bookkeeping
1215
+ // reacts to, since which meshes moved is the core's knowledge now.
1216
+ let moved: SceneNode[] = []
751
1217
  let capture = new Map<number, Mesh>()
752
1218
  let hover = new Map<number, Mesh>()
753
1219
 
@@ -758,6 +1224,85 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
758
1224
  // meshes exist - fewer cannot change relative order.
759
1225
  let meshes: Mesh[] = []
760
1226
  let transparentCount = 0
1227
+ // Attached lights in attach order (= light index); any change to the
1228
+ // set, a light's fields, or a light's world matrix rewrites the shared
1229
+ // light params at the end of the sync - one write, however many meshes.
1230
+ let lights: Light[] = []
1231
+ let lightsDirty = false
1232
+ // uLightDir is CORE-DRIVEN: each directional light's slot is a
1233
+ // shared-slot sink (bindDirectionSlot) following the node's world
1234
+ // rotation, with -direction as the local vector (the shader wants the
1235
+ // vector TOWARD the light) - so a light that merely moves costs no JS.
1236
+ // This rewrite runs on attach/detach/field changes (and a new view)
1237
+ // only and owns the rest: colors, count, hemisphere. The light set is
1238
+ // scene state, so it lands on the scene target and every view target.
1239
+ let vecScratch = new Float32Array(3)
1240
+ let writeLights = () => {
1241
+ lightsDirty = false
1242
+ let sky: Vec3 = [0, 0, 0]
1243
+ let ground: Vec3 = [0, 0, 0]
1244
+ let colors: number[] = []
1245
+ let count = 0
1246
+ for (let light of lights) {
1247
+ if (light.type === "hemisphere") {
1248
+ let k = light.intensity
1249
+ sky = [light.sky[0] * k, light.sky[1] * k, light.sky[2] * k]
1250
+ ground = [light.ground[0] * k, light.ground[1] * k, light.ground[2] * k]
1251
+ continue
1252
+ }
1253
+ vecScratch[0] = -light.direction[0]
1254
+ vecScratch[1] = -light.direction[1]
1255
+ vecScratch[2] = -light.direction[2]
1256
+ spatial.bindDirectionSlot(light._node!, texture, "uLightDir", MAX_LIGHTS * 3, count, vecScratch)
1257
+ for (let v of views) spatial.bindDirectionSlot(light._node!, v.texture, "uLightDir", MAX_LIGHTS * 3, count, vecScratch)
1258
+ let c = light.color
1259
+ let k = light.intensity
1260
+ colors.push(c[0] * k, c[1] * k, c[2] * k)
1261
+ count++
1262
+ }
1263
+ for (let i = count; i < MAX_LIGHTS; i++) colors.push(0, 0, 0)
1264
+ // The shadow set rides with the lights, slot i = directional light i:
1265
+ // whether it casts (0 = a receiving material draws that light plain),
1266
+ // its biases, and its map bound as uShadowMap<i> - the light's depth
1267
+ // id when it casts, else the white placeholder, so every receiving
1268
+ // target always has all MAX_LIGHTS samplers bound.
1269
+ let cast: number[] = []
1270
+ let bias: number[] = []
1271
+ let normalBias: number[] = []
1272
+ let maps: Record<string, TextureId> = {}
1273
+ let i = 0
1274
+ for (let light of lights) {
1275
+ if (light.type !== "directional") continue
1276
+ let shadow = shadows.get(light)
1277
+ cast.push(shadow !== undefined ? 1 : 0)
1278
+ bias.push(light.shadow.bias)
1279
+ normalBias.push(light.shadow.normalBias)
1280
+ maps["uShadowMap" + i] = shadow !== undefined ? depthTexture(shadow.view.texture) : shadowPlaceholder()
1281
+ i++
1282
+ }
1283
+ for (; i < MAX_LIGHTS; i++) {
1284
+ cast.push(0)
1285
+ bias.push(0)
1286
+ normalBias.push(0)
1287
+ maps["uShadowMap" + i] = shadowPlaceholder()
1288
+ }
1289
+ let params: ShaderParams = {
1290
+ uHemiSky: sky,
1291
+ uHemiGround: ground,
1292
+ uLightCount: count,
1293
+ uLightColor: colors,
1294
+ uShadowCast: cast,
1295
+ uShadowBias: bias,
1296
+ uShadowNormalBias: normalBias,
1297
+ }
1298
+ receivingTargets(t => {
1299
+ setTargetParams(t, params)
1300
+ setTargetTextures(t, maps)
1301
+ })
1302
+ // A slot change (a light attached, detached or reordered) moves every
1303
+ // matrix too: rewrite the whole array once.
1304
+ shadowMatricesDirty = true
1305
+ }
761
1306
  let orderDirty = false
762
1307
  // The order last handed to the engine: a resort that lands on the same
763
1308
  // permutation (the common case under a moving camera) issues nothing.
@@ -765,140 +1310,272 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
765
1310
  let background: { entry: DrawId; pipeline: RenderPipelineId; program: ProgramId } | null = null
766
1311
  let sortEntries = () => {
767
1312
  orderDirty = false
768
- let order = orderEntries(meshes, view, background?.entry)
1313
+ let order = orderEntries(meshes, camera.view, background?.entry)
769
1314
  if (order.length === lastOrder.length && order.every((id, i) => id === lastOrder[i])) return
770
1315
  lastOrder = order
771
1316
  setDrawOrder(texture, order)
772
1317
  }
773
1318
 
774
- // Reinsert or refit a mesh's broadphase leaf from its fresh world matrix:
775
- // the local box's center/extents carried through the absolute matrix (the
776
- // standard tight-AABB-of-a-transformed-AABB construction).
777
- let updateLeaf = (mesh: Mesh): void => {
778
- let b = localBounds(mesh)
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
- }
789
- let cx = (b[0]! + b[3]!) / 2
790
- let cy = (b[1]! + b[4]!) / 2
791
- let cz = (b[2]! + b[5]!) / 2
792
- let ex = (b[3]! - b[0]!) / 2
793
- let ey = (b[4]! - b[1]!) / 2
794
- let ez = (b[5]! - b[2]!) / 2
795
- let wx = m[0] * cx + m[4] * cy + m[8] * cz + m[12]
796
- let wy = m[1] * cx + m[5] * cy + m[9] * cz + m[13]
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
801
- let rx = Math.abs(m[0]) * ex + Math.abs(m[4]) * ey + Math.abs(m[8]) * ez
802
- let ry = Math.abs(m[1]) * ex + Math.abs(m[5]) * ey + Math.abs(m[9]) * ez
803
- let rz = Math.abs(m[2]) * ex + Math.abs(m[6]) * ey + Math.abs(m[10]) * ez
804
- if (mesh._pickLeaf === null) {
805
- mesh._pickLeaf = bvh.insert(mesh, wx - rx, wy - ry, wz - rz, wx + rx, wy + ry, wz + rz)
806
- } else {
807
- bvh.update(mesh._pickLeaf, wx - rx, wy - ry, wz - rz, wx + rx, wy + ry, wz + rz)
1319
+ // The transparent sort keys: each transparent mesh's local-bounds
1320
+ // center carried through its world matrix (read from the core), at
1321
+ // sort time only - opaque meshes never need one.
1322
+ let refreshCenters = () => {
1323
+ if (transparentCount < 2) return
1324
+ for (let mesh of meshes) {
1325
+ if (!mesh._transparent || mesh._node === null) continue
1326
+ let b = localBounds(mesh)
1327
+ let m = worldInto(worldScratch, mesh)
1328
+ let cx = 0, cy = 0, cz = 0
1329
+ if (b !== null) {
1330
+ cx = (b[0]! + b[3]!) / 2
1331
+ cy = (b[1]! + b[4]!) / 2
1332
+ cz = (b[2]! + b[5]!) / 2
1333
+ }
1334
+ mesh._center[0] = m[0] * cx + m[4] * cy + m[8] * cz + m[12]
1335
+ mesh._center[1] = m[1] * cx + m[5] * cy + m[9] * cz + m[13]
1336
+ mesh._center[2] = m[2] * cx + m[6] * cy + m[10] * cz + m[14]
808
1337
  }
809
1338
  }
810
-
811
- let fov = 60
812
- let near = 0.1
813
- let far = 100
814
- let eye: Vec3 = [0, 0, 3]
815
- let target: Vec3 = [0, 0, 0]
816
- let up: Vec3 = [0, 1, 0]
817
- let cameraDirty = true
818
- let cameraPending = false
819
- let proj = mat4()
820
- let view = mat4()
821
- let viewProj = mat4()
1339
+ let camera = makeCamera()
822
1340
  let clip: Vec4 = [0, 0, 0, 0]
1341
+ let pickOrigin: Vec3 = [0, 0, 0]
823
1342
 
824
- // Matrix recompute, split from sync so project()/viewProj() see a fresh
825
- // matrix right after setCamera, before the microtask runs. cameraPending
826
- // keeps the GPU write owed to the next sync.
827
- let ensureCamera = () => {
828
- if (!cameraDirty) return
829
- cameraDirty = false
830
- cameraPending = true
831
- perspective(proj, (fov * Math.PI) / 180, width / height, near, far)
832
- lookAtMatrix(view, eye, target, up)
833
- multiply(viewProj, proj, view)
1343
+ // Views (scene.createView): more targets drawing the same meshes from
1344
+ // their own cameras. A view holds one entry per mesh in its target,
1345
+ // bound as one more draw sink of the mesh's core node, so the flush
1346
+ // that writes the scene's entry writes the view's too. Sorted like the
1347
+ // scene (view-space keys from the view's own camera); an overridden
1348
+ // view is not sorted at all.
1349
+ type ViewRecord = {
1350
+ texture: TextureId
1351
+ width: number
1352
+ height: number
1353
+ override: Material | null
1354
+ /** Which meshes the view draws (null = all): the shadow view's
1355
+ * caster set. Re-evaluated per mesh by _setCast. */
1356
+ filter: ((mesh: Mesh) => boolean) | null
1357
+ camera: Camera
1358
+ entries: Map<Mesh, DrawId>
1359
+ orderDirty: boolean
1360
+ lastOrder: DrawId[]
1361
+ disposed: boolean
1362
+ }
1363
+ let views: ViewRecord[] = []
1364
+ // Every name scene.setParams has merged so far, replayed on a new view.
1365
+ let sceneParams: ShaderParams = {}
1366
+ // The shadows, one per casting directional light: the internal view
1367
+ // rendering its map (depth texture, depth override, casting meshes
1368
+ // only). `lastWorld` is the light's world matrix the shadow camera was
1369
+ // last placed from; `dirty` forces a re-place (options changed).
1370
+ type Shadow = { light: DirectionalLight; view: ViewRecord; lastWorld: Mat4; dirty: boolean }
1371
+ let shadows = new Map<DirectionalLight, Shadow>()
1372
+ let shadowDir: Vec3 = [0, 0, 0]
1373
+ // uShadowMatrix is one array param (the engine writes whole arrays), so
1374
+ // any shadow camera move rewrites all MAX_LIGHTS matrices, identity in
1375
+ // the slots that do not cast.
1376
+ let shadowMatrices: number[] = new Array(MAX_LIGHTS * 16).fill(0)
1377
+ let shadowMatricesDirty = false
1378
+ // Every target a receiving material can draw into: the scene's and each
1379
+ // view's but the shadow views (binding a target's own depth into it
1380
+ // would be same-pass feedback).
1381
+ let receivingTargets = (fn: (target: TextureId) => void) => {
1382
+ fn(texture)
1383
+ for (let v of views) if (v.filter === null) fn(v.texture)
1384
+ }
1385
+ let attachView = (v: ViewRecord, mesh: Mesh) => {
1386
+ let inst = mesh._instances
1387
+ if (v.override !== null && inst !== null) return
1388
+ if (v.filter !== null && !v.filter(mesh)) return
1389
+ let material = v.override ?? mesh.material
1390
+ let bufs = mesh._buffers!
1391
+ let entry = addDraw(v.texture, material.pipeline(mesh.geometry.layout), entrySeed(material, v.override !== null ? null : mesh._params), {
1392
+ buffer: bufs.buffer,
1393
+ indexBuffer: bufs.index,
1394
+ indexFormat: bufs.indexFormat,
1395
+ textures: material.textures,
1396
+ instanceBuffer: inst !== null ? inst.buffer : undefined,
1397
+ instanceCount: 0,
1398
+ })
1399
+ spatial.bindDraw(mesh._node!, v.texture, entry, material.normalMatrix === true, inst !== null ? inst.count : 1)
1400
+ v.entries.set(mesh, entry)
1401
+ v.orderDirty = true
1402
+ }
1403
+ let detachView = (v: ViewRecord, mesh: Mesh) => {
1404
+ let entry = v.entries.get(mesh)
1405
+ if (entry === undefined) return
1406
+ v.entries.delete(mesh)
1407
+ if (mesh._node !== null) spatial.unbindDraw(mesh._node, v.texture)
1408
+ if (!v.disposed) removeDraw(v.texture, entry)
1409
+ v.orderDirty = true
1410
+ }
1411
+ let sortView = (v: ViewRecord) => {
1412
+ v.orderDirty = false
1413
+ if (v.override !== null) return
1414
+ let order = orderEntries(meshes, v.camera.view, undefined, m => v.entries.get(m as Mesh) ?? null)
1415
+ if (order.length === v.lastOrder.length && order.every((id, i) => id === v.lastOrder[i])) return
1416
+ v.lastOrder = order
1417
+ setDrawOrder(v.texture, order)
1418
+ }
1419
+ let disposeView = (v: ViewRecord) => {
1420
+ if (v.disposed) return
1421
+ v.disposed = true
1422
+ for (let mesh of v.entries.keys()) if (mesh._node !== null) spatial.unbindDraw(mesh._node, v.texture)
1423
+ v.entries.clear()
1424
+ for (let light of lights) if (light.type === "directional" && light._node !== null) spatial.unbindSlot(light._node, v.texture)
1425
+ // Drain the zeroed direction slots while the target still exists.
1426
+ spatial.flush()
1427
+ destroyTexture(v.texture)
1428
+ let i = views.indexOf(v)
1429
+ if (i >= 0) views.splice(i, 1)
1430
+ }
1431
+ // A view record: the target, seeded with everything the scene target
1432
+ // already holds (the light set - rewritten for every target, the simple
1433
+ // write - the merged scene params, the shadow map binding), then one
1434
+ // entry per mesh the filter admits.
1435
+ let makeView = (vopts: ViewOptions, filter: ((mesh: Mesh) => boolean) | null): ViewRecord => {
1436
+ let override = vopts.overrideMaterial ?? null
1437
+ if (override !== null) {
1438
+ for (let mesh of meshes) if (mesh._instances === null) checkLayout(override, mesh.geometry, "View override material")
1439
+ }
1440
+ let v: ViewRecord = {
1441
+ texture: createDrawTarget(vopts.width, vopts.height, null, {
1442
+ depth: vopts.depth ?? true,
1443
+ clearColor: vopts.clearColor,
1444
+ filter: vopts.filter,
1445
+ wrap: vopts.wrap,
1446
+ samples: vopts.samples,
1447
+ label: vopts.label ?? (opts?.label ?? "scene") + "-view",
1448
+ autoFree: false,
1449
+ }),
1450
+ width: vopts.width,
1451
+ height: vopts.height,
1452
+ override,
1453
+ filter,
1454
+ camera: makeCamera(),
1455
+ entries: new Map(),
1456
+ orderDirty: true,
1457
+ lastOrder: [],
1458
+ disposed: false,
1459
+ }
1460
+ views.push(v)
1461
+ // The light rewrite seeds the shadow set (maps, casts, biases,
1462
+ // matrices) on the new target too.
1463
+ lightsDirty = true
1464
+ setTargetParams(v.texture, sceneParams)
1465
+ for (let mesh of meshes) attachView(v, mesh)
1466
+ hooks._schedule()
1467
+ return v
1468
+ }
1469
+ // A shadow view: a square depth-texture target drawing the casting
1470
+ // meshes with the depth override from the light's frustum. The light
1471
+ // rewrite binds its map in the light's slot on every receiving target.
1472
+ let createShadow = (light: DirectionalLight) => {
1473
+ let size = light.shadow.mapSize
1474
+ let view = makeView(
1475
+ {
1476
+ width: size,
1477
+ height: size,
1478
+ depth: "texture",
1479
+ overrideMaterial: shadowDepthMaterial(),
1480
+ clearColor: [1, 1, 1, 1],
1481
+ label: (opts?.label ?? "scene") + "-shadow",
1482
+ },
1483
+ m => m.castShadow,
1484
+ )
1485
+ shadows.set(light, { light, view, lastWorld: mat4(), dirty: true })
1486
+ lightsDirty = true
1487
+ }
1488
+ let destroyShadow = (light: DirectionalLight) => {
1489
+ let shadow = shadows.get(light)
1490
+ if (shadow === undefined) return
1491
+ shadows.delete(light)
1492
+ disposeView(shadow.view)
1493
+ lightsDirty = true
1494
+ hooks._schedule()
1495
+ }
1496
+ // Place a shadow camera from its light's world matrix: at its world
1497
+ // position, looking along its world direction, the light frustum as the
1498
+ // orthographic extents. Compared against the matrix it was last placed
1499
+ // from, so a scene animating elsewhere rewrites nothing here.
1500
+ let placeShadowCamera = (shadow: Shadow) => {
1501
+ let light = shadow.light
1502
+ let m = worldInto(worldScratch, light)
1503
+ if (!shadow.dirty && m.every((x, i) => x === shadow.lastWorld[i])) return
1504
+ shadow.dirty = false
1505
+ copy(shadow.lastWorld, m)
1506
+ transformVector(shadowDir, m, light.direction)
1507
+ let len = Math.hypot(shadowDir[0], shadowDir[1], shadowDir[2]) || 1
1508
+ let d: Vec3 = [shadowDir[0] / len, shadowDir[1] / len, shadowDir[2] / len]
1509
+ let c = light.shadow.camera
1510
+ updateCamera(shadow.view.camera, {
1511
+ position: [m[12], m[13], m[14]],
1512
+ target: [m[12] + d[0], m[13] + d[1], m[14] + d[2]],
1513
+ // A sun straight down is the common case and the degenerate one for
1514
+ // world up: roll about z then (the map's orientation is invisible).
1515
+ up: Math.abs(d[1]) > 0.99 ? [0, 0, 1] : [0, 1, 0],
1516
+ ortho: { left: c.left, right: c.right, top: c.top, bottom: c.bottom },
1517
+ near: c.near,
1518
+ far: c.far,
1519
+ })
834
1520
  }
835
1521
 
836
1522
  let sync = () => {
837
1523
  scheduled = false
838
1524
  if (disposed) return
839
- ensureCamera()
840
- if (cameraPending) {
841
- // The camera is target state: one shared write, whatever the scene
842
- // holds. Entries are untouched - uModel is camera-independent, and
843
- // uCamPos is stored even when no current material declares it.
844
- cameraPending = false
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
- })
1525
+ ensureCamera(camera, width, height)
1526
+ if (camera.pending) {
1527
+ camera.pending = false
1528
+ setTargetParams(texture, cameraParams(camera))
854
1529
  if (transparentCount > 1) orderDirty = true
855
1530
  }
856
- let walk = (node: SceneNode, parentChanged: boolean, parentVisible: boolean) => {
857
- let changed = parentChanged
858
- if (node._localDirty) {
859
- compose(node._local, node.position, node.quaternion, node.scale)
860
- node._localDirty = false
861
- changed = true
1531
+ for (let shadow of shadows.values()) placeShadowCamera(shadow)
1532
+ for (let v of views) {
1533
+ ensureCamera(v.camera, v.width, v.height)
1534
+ if (v.camera.pending) {
1535
+ v.camera.pending = false
1536
+ setTargetParams(v.texture, cameraParams(v.camera))
1537
+ if (transparentCount > 1) v.orderDirty = true
1538
+ if (v.filter !== null) shadowMatricesDirty = true
862
1539
  }
863
- if (changed) {
864
- multiply(node._world, node.parent ? node.parent._world : IDENTITY, node._local)
1540
+ }
1541
+ // Light bookkeeping first, so a fresh direction-slot bind is seeded
1542
+ // by the flush below in the same sync.
1543
+ if (lightsDirty) writeLights()
1544
+ // The matrices that render the maps are the ones receivers look up
1545
+ // with: one array to every receiving target per shadow-camera move.
1546
+ if (shadowMatricesDirty) {
1547
+ shadowMatricesDirty = false
1548
+ let i = 0
1549
+ for (let light of lights) {
1550
+ if (light.type !== "directional") continue
1551
+ let shadow = shadows.get(light)
1552
+ let m = shadow !== undefined ? shadow.view.camera.viewProj : IDENTITY
1553
+ for (let k = 0; k < 16; k++) shadowMatrices[i * 16 + k] = m[k]!
1554
+ i++
865
1555
  }
866
- let shown = parentVisible && node.visible
867
- if (node.kind === "mesh") {
868
- let mesh = node as Mesh
869
- if (mesh._entry !== null) {
870
- if (mesh._hidden === shown) {
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 })
874
- mesh._hidden = !shown
875
- if (shown) mesh._fresh = true
876
- }
877
- if (changed && mesh._transparent && transparentCount > 1) orderDirty = true
878
- if (!mesh._hidden && (changed || mesh._fresh)) {
879
- if (mesh.material.normalMatrix) {
880
- setDrawParams(texture, mesh._entry, {
881
- uModel: mesh._world,
882
- uNormal: normalMatrix(normalScratch, mesh._world),
883
- })
884
- } else {
885
- setDrawParams(texture, mesh._entry, { uModel: mesh._world })
886
- }
887
- mesh._fresh = false
888
- } else if (changed) {
889
- // Moved while hidden: write the fresh matrix on unhide.
890
- mesh._fresh = true
891
- }
892
- // The broadphase leaf follows the world matrix - hidden meshes
893
- // included (they stay in the tree and are skipped at query time,
894
- // so unhiding never picks against a stale box).
895
- if (changed || mesh._pickLeaf === null) updateLeaf(mesh)
896
- }
1556
+ for (; i < MAX_LIGHTS; i++) for (let k = 0; k < 16; k++) shadowMatrices[i * 16 + k] = IDENTITY[k]!
1557
+ let params: ShaderParams = { uShadowMatrix: shadowMatrices }
1558
+ receivingTargets(t => setTargetParams(t, params))
1559
+ }
1560
+ // The core recomputes the moved subtrees and writes every entry's
1561
+ // uModel/uNormal, visibility switch and direction slots.
1562
+ spatial.flush()
1563
+ if (moved.length > 0) {
1564
+ // Which meshes moved is the core's knowledge now, so any move with
1565
+ // two or more transparent meshes re-sorts (sortEntries issues nothing
1566
+ // when the permutation is unchanged).
1567
+ if (transparentCount > 1) {
1568
+ orderDirty = true
1569
+ for (let v of views) v.orderDirty = true
897
1570
  }
898
- for (let c of node.children) walk(c, changed, shown)
1571
+ for (let n of moved) n._moved = false
1572
+ moved.length = 0
899
1573
  }
900
- walk(root, false, true)
1574
+ // The sort keys are world-space and camera-independent: refreshed once
1575
+ // for every sort this sync.
1576
+ if (orderDirty || views.some(v => v.orderDirty)) refreshCenters()
901
1577
  if (orderDirty) sortEntries()
1578
+ for (let v of views) if (v.orderDirty) sortView(v)
902
1579
  }
903
1580
 
904
1581
  let hooks: SceneHooks = {
@@ -907,18 +1584,64 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
907
1584
  scheduled = true
908
1585
  RESOLVED.then(sync)
909
1586
  },
1587
+ _attachLight(light) {
1588
+ if (disposed) return
1589
+ if (light.type === "directional" && lights.filter(l => l.type === "directional").length >= MAX_LIGHTS) {
1590
+ throw new Error("A scene takes at most " + MAX_LIGHTS + " directional lights")
1591
+ }
1592
+ lights.push(light)
1593
+ lightsDirty = true
1594
+ if (light.type === "directional" && light.castShadow) createShadow(light)
1595
+ },
1596
+ _detachLight(light) {
1597
+ let i = lights.indexOf(light)
1598
+ if (i >= 0) lights.splice(i, 1)
1599
+ lightsDirty = true
1600
+ if (light.type === "directional") destroyShadow(light)
1601
+ hooks._schedule()
1602
+ },
1603
+ _shadowChanged(light) {
1604
+ if (disposed) return
1605
+ let shadow = shadows.get(light)
1606
+ if (shadow !== undefined) {
1607
+ if (!light.castShadow) {
1608
+ destroyShadow(light)
1609
+ return
1610
+ }
1611
+ let size = light.shadow.mapSize
1612
+ if (size !== shadow.view.width) {
1613
+ shadow.view.width = size
1614
+ shadow.view.height = size
1615
+ setTargetSize(shadow.view.texture, size, size)
1616
+ }
1617
+ shadow.dirty = true
1618
+ lightsDirty = true
1619
+ hooks._schedule()
1620
+ } else if (light.castShadow) {
1621
+ createShadow(light)
1622
+ }
1623
+ },
1624
+ _setCast(mesh) {
1625
+ if (mesh._entry === null || disposed) return
1626
+ for (let v of views) {
1627
+ if (v.filter === null) continue
1628
+ if (v.filter(mesh)) attachView(v, mesh)
1629
+ else detachView(v, mesh)
1630
+ }
1631
+ hooks._schedule()
1632
+ },
1633
+ _lightChanged() {
1634
+ lightsDirty = true
1635
+ hooks._schedule()
1636
+ },
910
1637
  _attach(mesh) {
911
1638
  if (disposed) return
912
- // Layout is stride: a mismatched pair would not miss a channel, it
913
- // would read garbage - so it is an error here, like the rest of the
914
- // strict entry path.
915
- let geoLayout = mesh.geometry.layout ?? "standard"
916
- let matLayout = mesh.material.layout ?? "standard"
917
- if (geoLayout !== matLayout) {
918
- throw new Error(
919
- "Mesh geometry layout '" + geoLayout + "' does not match its material's '" + matLayout +
920
- "' - a material reading aColor needs withColors() geometry, and colored geometry needs such a material",
921
- )
1639
+ validateGeometry(mesh.geometry)
1640
+ checkLayout(mesh.material, mesh.geometry, "Mesh material")
1641
+ // Every check before any mutation, so a rejected mesh is attached
1642
+ // nowhere - the views' override materials included.
1643
+ for (let v of views) {
1644
+ if (v.override !== null && mesh._instances === null) checkLayout(v.override, mesh.geometry, "View override material")
922
1645
  }
923
1646
  // Instancing pairs the same way layout does: the pipeline's instance
924
1647
  // attributes describe the mesh's record buffer, so one without the
@@ -944,18 +1667,12 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
944
1667
  }
945
1668
  let bufs = acquireGeometryBuffers(mesh.geometry)
946
1669
  mesh._buffers = bufs
947
- // The uNormal seed keys off the material flag because entry params
948
- // validate strictly - and a material declaring uNormal without using
949
- // it therefore throws right here, at add().
950
- let seed: ShaderParams = mesh.material.normalMatrix
951
- ? { uModel: IDENTITY, uNormal: IDENTITY, ...mesh.material.params, ...mesh._params }
952
- : { uModel: IDENTITY, ...mesh.material.params, ...mesh._params }
953
1670
  // The entry starts switched off: it has no world matrix yet - the walk
954
1671
  // in sync() computes one - and _schedule() defers that to a microtask,
955
1672
  // so added live it would draw at the seeded identity until then. The
956
1673
  // mismatch branch in sync() turns it on in the same pass that writes
957
1674
  // uModel.
958
- mesh._entry = addDraw(texture, mesh.material.pipeline(), seed, {
1675
+ mesh._entry = addDraw(texture, mesh.material.pipeline(mesh.geometry.layout), entrySeed(mesh.material, mesh._params), {
959
1676
  buffer: bufs.buffer,
960
1677
  indexBuffer: bufs.index,
961
1678
  indexFormat: bufs.indexFormat,
@@ -963,16 +1680,33 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
963
1680
  instanceBuffer: inst !== null ? inst.buffer : undefined,
964
1681
  instanceCount: 0,
965
1682
  })
1683
+ // The core turns the entry on (with the world matrix) at the next
1684
+ // flush, and off again whenever the node or an ancestor hides.
1685
+ spatial.bindDraw(mesh._node!, texture, mesh._entry, mesh.material.normalMatrix === true, inst !== null ? inst.count : 1)
1686
+ for (let v of views) attachView(v, mesh)
1687
+ // Picking: the local box puts the node in the core index; an
1688
+ // ordinary mesh also gets its geometry's triangle shape, an
1689
+ // instanced one is box-only (records are opaque, and without
1690
+ // explicit bounds it is not picked at all), as is a sprite (its
1691
+ // triangles lie wherever the camera is, not where the geometry says).
1692
+ spatial.setBounds(mesh._node!, localBounds(mesh))
1693
+ spatial.setShape(mesh._node!, inst === null && !mesh._sprite ? bufs.shape : null)
1694
+ byNode.set(mesh._node!, mesh)
966
1695
  meshes.push(mesh)
967
1696
  mesh._transparent = mesh.material.transparent === true
968
1697
  if (mesh._transparent) transparentCount++
969
1698
  orderDirty = true
970
- mesh._hidden = true
971
- mesh._fresh = true
972
1699
  this._schedule()
973
1700
  },
974
1701
  _detach(mesh) {
975
1702
  if (mesh._entry !== null) {
1703
+ for (let v of views) detachView(v, mesh)
1704
+ if (mesh._node !== null) {
1705
+ spatial.setShape(mesh._node, null)
1706
+ spatial.setBounds(mesh._node, null)
1707
+ spatial.unbindDraw(mesh._node, texture)
1708
+ byNode.delete(mesh._node)
1709
+ }
976
1710
  if (!disposed) removeDraw(texture, mesh._entry)
977
1711
  if (mesh._buffers !== null) releaseGeometryBuffers(mesh._buffers)
978
1712
  mesh._buffers = null
@@ -982,30 +1716,58 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
982
1716
  orderDirty = true
983
1717
  }
984
1718
  mesh._entry = null
985
- // The leaf goes with the entry: a geometry swap rebuilds the entry,
986
- // and re-inserting is what picks up the new local bounds.
987
- if (mesh._pickLeaf !== null) {
988
- bvh.remove(mesh._pickLeaf)
989
- mesh._pickLeaf = null
990
- }
991
1719
  },
992
1720
  _setParams(mesh, params) {
993
- if (mesh._entry !== null && !disposed) setDrawParams(texture, mesh._entry, params)
1721
+ if (mesh._entry === null || disposed) return
1722
+ setDrawParams(texture, mesh._entry, params)
1723
+ // A view drawing the mesh's own material carries its params too; an
1724
+ // overridden view has none of them.
1725
+ for (let v of views) {
1726
+ let entry = v.entries.get(mesh)
1727
+ if (entry !== undefined && v.override === null) setDrawParams(v.texture, entry, params)
1728
+ }
994
1729
  },
995
1730
  _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 })
1731
+ // The core composes the count with the visibility switch: a hidden
1732
+ // entry stays at 0 and the unhide restores the new count.
1733
+ if (mesh._entry !== null && mesh._node !== null && !disposed && mesh._instances !== null) {
1734
+ spatial.setDrawCount(mesh._node, mesh._instances.count)
1735
+ }
1736
+ },
1737
+ _setBuffer(mesh) {
1738
+ // The entry keeps its range (at most the old capacity, so the larger
1739
+ // buffer always passes the swap's bounds check); the caller destroys
1740
+ // the old buffer after this, which the entry held alive until now.
1741
+ if (mesh._entry !== null && !disposed && mesh._instances !== null) {
1742
+ setDrawBuffers(texture, mesh._entry, { instanceBuffer: mesh._instances.buffer })
1743
+ for (let v of views) {
1744
+ let entry = v.entries.get(mesh)
1745
+ if (entry !== undefined) setDrawBuffers(v.texture, entry, { instanceBuffer: mesh._instances.buffer })
1746
+ }
999
1747
  }
1000
1748
  },
1001
1749
  _reorder() {
1002
1750
  orderDirty = true
1751
+ for (let v of views) v.orderDirty = true
1752
+ this._schedule()
1753
+ },
1754
+ _moved(node) {
1755
+ if (!node._moved) {
1756
+ node._moved = true
1757
+ moved.push(node)
1758
+ }
1003
1759
  this._schedule()
1004
1760
  },
1005
1761
  }
1006
1762
 
1007
1763
  let root = makeNode("group")
1008
1764
  root._scene = hooks
1765
+ root._node = spatial.createNode(fillTransform(root), true)
1766
+ // The first light rewrite seeds the (empty) light set and the shadow
1767
+ // slots - placeholders, no casts - so receivers draw plain from the
1768
+ // first frame.
1769
+ lightsDirty = true
1770
+ hooks._schedule()
1009
1771
 
1010
1772
  // --- Pointer event dispatch (behind scene.handlers) ---
1011
1773
 
@@ -1052,7 +1814,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1052
1814
  }
1053
1815
 
1054
1816
  // localX/localY arrive in the leaf's LAYOUT frame (the hit test undoes
1055
- // every transform above it, viewBox fits included), so a leaf laid out at
1817
+ // every transform above it, design-size fits included), so a leaf laid out at
1056
1818
  // the target size - the built-in <Scene> leaf, a d-texture at natural
1057
1819
  // size - is already in scene pixels. Only a leaf deliberately laid out at
1058
1820
  // a DIFFERENT size (the supersampling pattern) needs the ratio, and only
@@ -1132,13 +1894,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1132
1894
  texture,
1133
1895
  root,
1134
1896
  setCamera(update) {
1135
- if (update.fov !== undefined) fov = update.fov
1136
- if (update.near !== undefined) near = update.near
1137
- if (update.far !== undefined) far = update.far
1138
- if (update.position) eye = [update.position[0], update.position[1], update.position[2]]
1139
- if (update.target) target = [update.target[0], update.target[1], update.target[2]]
1140
- if (update.up) up = [update.up[0], update.up[1], update.up[2]]
1141
- cameraDirty = true
1897
+ updateCamera(camera, update)
1142
1898
  hooks._schedule()
1143
1899
  },
1144
1900
  setSize(w, h) {
@@ -1146,11 +1902,14 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1146
1902
  width = w
1147
1903
  height = h
1148
1904
  setTargetSize(texture, w, h)
1149
- cameraDirty = true
1905
+ camera.dirty = true
1150
1906
  hooks._schedule()
1151
1907
  },
1152
1908
  setParams(params) {
1153
- if (!disposed) setTargetParams(texture, params)
1909
+ if (disposed) return
1910
+ Object.assign(sceneParams, params)
1911
+ setTargetParams(texture, params)
1912
+ for (let v of views) setTargetParams(v.texture, params)
1154
1913
  },
1155
1914
  setBackground(source) {
1156
1915
  if (disposed) return
@@ -1168,88 +1927,122 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1168
1927
  background = { entry, pipeline: built.pipeline, program: built.program }
1169
1928
  },
1170
1929
  project(point) {
1171
- ensureCamera()
1172
- transformPoint(clip, viewProj, point)
1930
+ ensureCamera(camera, width, height)
1931
+ transformPoint(clip, camera.viewProj, point)
1173
1932
  let w = clip[3]
1174
1933
  if (w < 1e-6) return null
1175
1934
  // perspective() bakes the y-down clip flip, so NDC maps straight to
1176
- // top-left-origin pixels with no negation here.
1935
+ // top-left-origin pixels with no negation here. (An orthographic
1936
+ // camera has w = 1 everywhere: every point projects.)
1177
1937
  return { x: ((clip[0] / w) * 0.5 + 0.5) * width, y: ((clip[1] / w) * 0.5 + 0.5) * height, w }
1178
1938
  },
1179
1939
  viewProj(out) {
1180
- ensureCamera()
1181
- return copy(out ?? mat4(), viewProj)
1940
+ ensureCamera(camera, width, height)
1941
+ return copy(out ?? mat4(), camera.viewProj)
1182
1942
  },
1183
1943
  pick(x, y) {
1184
- ensureCamera()
1185
- // The camera-frame ray through the pixel, inverting project()'s
1186
- // mapping: the baked y-down clip flip is why pixel y converts with
1187
- // no negation there and one here.
1188
- let f = 1 / Math.tan(((fov * Math.PI) / 180) / 2)
1189
- let cx = (((x / width) * 2 - 1) * (width / height)) / f
1190
- let cy = -((y / height) * 2 - 1) / f
1191
- // The view's upper 3x3 rows are the camera axes, so its transpose
1192
- // carries the camera-space direction (cx, cy, -1) to world.
1193
- pickDir[0] = cx * view[0] + cy * view[1] - view[2]
1194
- pickDir[1] = cx * view[4] + cy * view[5] - view[6]
1195
- pickDir[2] = cx * view[8] + cy * view[9] - view[10]
1196
- return scene.raycast(eye, pickDir)
1944
+ ensureCamera(camera, width, height)
1945
+ let v = camera.view
1946
+ let o = camera.ortho
1947
+ if (o === null) {
1948
+ // The camera-frame ray through the pixel, inverting project()'s
1949
+ // mapping: the baked y-down clip flip is why pixel y converts with
1950
+ // no negation there and one here.
1951
+ let f = 1 / Math.tan(((camera.fov * Math.PI) / 180) / 2)
1952
+ let cx = (((x / width) * 2 - 1) * (width / height)) / f
1953
+ let cy = -((y / height) * 2 - 1) / f
1954
+ // The view's upper 3x3 rows are the camera axes, so its transpose
1955
+ // carries the camera-space direction (cx, cy, -1) to world.
1956
+ pickDir[0] = cx * v[0] + cy * v[1] - v[2]
1957
+ pickDir[1] = cx * v[4] + cy * v[5] - v[6]
1958
+ pickDir[2] = cx * v[8] + cy * v[9] - v[10]
1959
+ return scene.raycast(camera.eye, pickDir)
1960
+ }
1961
+ // Orthographic: every ray runs along the camera's forward axis; the
1962
+ // pixel picks where on the camera plane it starts (top row = top).
1963
+ let cx = o.left + (x / width) * (o.right - o.left)
1964
+ let cy = o.top + (y / height) * (o.bottom - o.top)
1965
+ pickOrigin[0] = camera.eye[0] + cx * v[0] + cy * v[1]
1966
+ pickOrigin[1] = camera.eye[1] + cx * v[4] + cy * v[5]
1967
+ pickOrigin[2] = camera.eye[2] + cx * v[8] + cy * v[9]
1968
+ pickDir[0] = -v[2]
1969
+ pickDir[1] = -v[6]
1970
+ pickDir[2] = -v[10]
1971
+ return scene.raycast(pickOrigin, pickDir)
1197
1972
  },
1198
1973
  raycast(origin, direction) {
1199
1974
  // Flush pending writes: picking sees the tree as the app just wrote
1200
1975
  // it, the same immediacy contract as lookAt()/project(). (The queued
1201
1976
  // microtask still runs and finds nothing dirty - harmless.)
1202
1977
  if (scheduled) sync()
1203
- let dx = direction[0]
1204
- let dy = direction[1]
1205
- let dz = direction[2]
1206
- let len = Math.hypot(dx, dy, dz)
1207
- if (len === 0 || disposed) return []
1208
- dx /= len
1209
- dy /= len
1210
- dz /= len
1211
- let ox = origin[0]
1212
- let oy = origin[1]
1213
- let oz = origin[2]
1978
+ if (disposed) return []
1214
1979
  let hits: Hit[] = []
1215
- bvh.raycast(ox, oy, oz, dx, dy, dz, mesh => {
1216
- if (mesh._hidden || mesh._entry === null) return
1217
- // Narrowphase: the ray in the mesh's local frame against its tight
1218
- // local box - exact under any affine world transform. The local
1219
- // direction stays unnormalized on purpose: an affine map preserves
1220
- // the ray parameter, so t is world units as-is.
1221
- invertAffine(pickInv, mesh._world)
1222
- transformPoint(pickOrigin, pickInv, origin)
1223
- pickDir[0] = dx
1224
- pickDir[1] = dy
1225
- pickDir[2] = dz
1226
- transformVector(pickDir, pickInv, pickDir)
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
1231
- let t = rayBoxDistance(
1232
- pickOrigin[0], pickOrigin[1], pickOrigin[2],
1233
- pickDir[0], pickDir[1], pickDir[2],
1234
- b[0]!, b[1]!, b[2]!, b[3]!, b[4]!, b[5]!,
1235
- )
1236
- if (t >= 0) hits.push({ mesh, distance: t, point: [ox + dx * t, oy + dy * t, oz + dz * t] })
1237
- })
1238
- hits.sort((a, b) => a.distance - b.distance)
1980
+ rayOriginScratch[0] = origin[0]
1981
+ rayOriginScratch[1] = origin[1]
1982
+ rayOriginScratch[2] = origin[2]
1983
+ rayDirScratch[0] = direction[0]
1984
+ rayDirScratch[1] = direction[1]
1985
+ rayDirScratch[2] = direction[2]
1986
+ for (let h of spatial.raycast(rayOriginScratch, rayDirScratch)) {
1987
+ let mesh = byNode.get(h.node)
1988
+ if (mesh === undefined) continue
1989
+ let hit: Hit = { mesh, distance: h.distance, point: h.point }
1990
+ if (h.normal !== undefined) hit.normal = h.normal
1991
+ if (h.face !== undefined) hit.face = h.face
1992
+ if (h.uv !== undefined) hit.uv = h.uv
1993
+ hits.push(hit)
1994
+ }
1239
1995
  return hits
1240
1996
  },
1241
1997
  handlers,
1242
1998
  handlersFor(layout) {
1243
1999
  return makeHandlers(layout)
1244
2000
  },
2001
+ createView(vopts) {
2002
+ if (disposed) throw new Error("createView: the scene is disposed")
2003
+ let v = makeView(vopts, null)
2004
+ return {
2005
+ texture: v.texture,
2006
+ depthTexture: vopts.depth === "texture" ? depthTexture(v.texture) : null,
2007
+ setCamera(update) {
2008
+ updateCamera(v.camera, update)
2009
+ hooks._schedule()
2010
+ },
2011
+ setSize(w, h) {
2012
+ if (v.disposed || (w === v.width && h === v.height)) return
2013
+ v.width = w
2014
+ v.height = h
2015
+ setTargetSize(v.texture, w, h)
2016
+ v.camera.dirty = true
2017
+ hooks._schedule()
2018
+ },
2019
+ setParams(params) {
2020
+ if (!v.disposed) setTargetParams(v.texture, params)
2021
+ },
2022
+ dispose() {
2023
+ disposeView(v)
2024
+ },
2025
+ }
2026
+ },
1245
2027
  dispose() {
1246
2028
  if (disposed) return
1247
2029
  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)
2030
+ // Full tree-side teardown, not just the target: every node leaves
2031
+ // the scene (entries' geometry-buffer references and pick leaves
2032
+ // dropped, core nodes freed), so a disposed scene leaves no
2033
+ // bookkeeping behind and the JS tree survives as plain data.
2034
+ for (let c of root.children.slice()) leaveScene(c)
2035
+ root._scene = null
2036
+ if (root._node !== null) {
2037
+ spatial.destroyNode(root._node)
2038
+ root._node = null
2039
+ }
2040
+ // Drain the zeroed direction slots the teardown queued while the
2041
+ // targets still exist; afterwards their groups are gone.
2042
+ spatial.flush()
1252
2043
  destroyTexture(texture)
2044
+ shadows.clear()
2045
+ for (let v of views.slice()) disposeView(v)
1253
2046
  if (background !== null) {
1254
2047
  // The entry died with the target; the pipeline and program are the
1255
2048
  // scene's own (unlike shared material pipelines), so they go too.