@solidrt/3d 0.0.51 → 0.0.53

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,83 @@
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, limits, removeDraw, setDrawBuffers, setDrawOrder, setDrawParams, setTargetParams, setTargetRect, 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 { cascadeSplit, compose, copy, eulerFromQuat, frustumSliceSphere, identity, lookAt as lookAtMatrix, mat4, multiply, orthographic, perspective, quat, quatFromFrame, snapToGrid, 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_CASCADES, MAX_LIGHTS, MAX_SHADOW_MAPS } 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()
53
+ // How a cascaded light slices the camera range: 0 uniform, 1
54
+ // logarithmic, halfway the "practical" split (near slices small, far
55
+ // ones not starved).
56
+ const CASCADE_SPLIT_LAMBDA = 0.5
57
+ // |y| of a light direction above this is straight up or down, where
58
+ // world up cannot serve as the shadow map's roll reference.
59
+ const VERTICAL_LIGHT = 0.99
41
60
  // lookAt()'s default roll reference. Read-only: quatFromFrame never
42
61
  // writes its inputs, so one shared vector is safe.
43
62
  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.
63
+ // The FFI carriers: one transform write (position, quaternion, scale) and
64
+ // one world-matrix read. Values are copied at the boundary, so one of each
65
+ // serves every call.
66
+ let transformScratch = new Float32Array(10)
67
+ let worldRead = new Float32Array(16)
68
+ // lookAt()/worldPosition() scratch: nothing here outlives a single call.
49
69
  let worldScratch = mat4()
50
70
  let localScratch = mat4()
71
+ let rayOriginScratch = new Float32Array(3)
72
+ let rayDirScratch = new Float32Array(3)
51
73
  let pointScratch: Vec4 = [0, 0, 0, 0]
74
+
75
+ // Settle routing: the core's "spatialTransitionEnd" event carries the node
76
+ // id, so nodes with a transition DECLARED (only those can settle) are
77
+ // indexed by their core id while in a scene, and one lazy subscription,
78
+ // started at the first declaration, routes to the node's onTransitionEnd.
79
+ // Target-only, like the element transitions.
80
+ let declared = new Map<NodeId, SceneNode>()
81
+ let subscribed = false
82
+
83
+ function declareTransition(id: NodeId, node: SceneNode): void {
84
+ declared.set(id, node)
85
+ if (subscribed) return
86
+ subscribed = true
87
+ on("spatialTransitionEnd", (event: { node: NodeId; component: TransitionEndEvent["component"] }) => {
88
+ let node = declared.get(event.node)
89
+ if (!node) return
90
+ try {
91
+ node.onTransitionEnd?.({ component: event.component })
92
+ } catch (err) {
93
+ console.error("Error in onTransitionEnd handler:", err)
94
+ }
95
+ })
96
+ }
52
97
  let aimScratch: Vec3 = [0, 0, 0]
53
98
  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]
99
+ // pick()'s camera-ray scratch.
58
100
  let pickDir: Vec3 = [0, 0, 0]
59
101
  // setTransform's rotation compare happens AFTER conversion, so an euler and
60
102
  // the quaternion it produces are the same write. Nothing outlives the call.
@@ -70,13 +112,24 @@ type SceneHooks = {
70
112
  _schedule(): void
71
113
  _attach(mesh: Mesh): void
72
114
  _detach(mesh: Mesh): void
115
+ _attachLight(light: Light): void
116
+ _detachLight(light: Light): void
117
+ _lightChanged(): void
73
118
  _setParams(mesh: Mesh, params: ShaderParams): void
74
119
  _setCount(mesh: Mesh): void
120
+ /** Re-point the mesh's entry at its (replaced) instance buffer. */
121
+ _setBuffer(mesh: Mesh): void
122
+ /** The mesh's castShadow flag changed: re-evaluate the filtered views. */
123
+ _setCast(mesh: Mesh): void
124
+ /** A light's castShadow/shadow options changed. */
125
+ _shadowChanged(light: DirectionalLight): void
75
126
  _reorder(): void
127
+ /** The node's transform changed (for the sort and light bookkeeping). */
128
+ _moved(node: SceneNode): void
76
129
  }
77
130
 
78
131
  export type SceneNode = {
79
- kind: "group" | "mesh"
132
+ kind: "group" | "mesh" | "light"
80
133
  parent: SceneNode | null
81
134
  children: SceneNode[]
82
135
  /** Read freely; write through setTransform/setVisible so changes sync. */
@@ -98,12 +151,87 @@ export type SceneNode = {
98
151
  onPointerUp?: (event: ScenePointerEvent) => void
99
152
  onPointerEnter?: (event: ScenePointerEvent) => void
100
153
  onPointerLeave?: (event: ScenePointerEvent) => void
101
- _localDirty: boolean
102
- _local: Mat4
103
- _world: Mat4
154
+ /** A declared transition (setTransition) settled naturally on one
155
+ * component; a cancel, snap or scene leave never fires. */
156
+ onTransitionEnd?: (event: TransitionEndEvent) => void
157
+ /** The core node while in a scene (created at add, freed at remove). */
158
+ _node: NodeId | null
159
+ _moved: boolean
104
160
  _scene: SceneHooks | null
161
+ /** The declared transition, re-applied on every scene enter. */
162
+ _transition: NodeTransition | string | null
105
163
  }
106
164
 
165
+ /** The orthographic frustum a casting light renders its shadow map from,
166
+ * in the light's own space (x right, y up, looking along its direction),
167
+ * Three's DirectionalLightShadow camera. Everything outside it is lit. */
168
+ export type ShadowCamera = { left: number; right: number; top: number; bottom: number; near: number; far: number }
169
+
170
+ export type ShadowOptions = {
171
+ /** Shadow map resolution in texels, square (default 1024). */
172
+ mapSize?: number
173
+ /** Depth bias against acne, in the map's 0..1 depth (default 0). */
174
+ bias?: number
175
+ /** Offset a receiving point along its normal before the lookup, in
176
+ * world units (default 0) - the acne fix that keeps contact shadows. */
177
+ normalBias?: number
178
+ /** The light frustum; absent keys keep the defaults +-5, 0.5..500.
179
+ * Ignored by a cascaded light (its frustums follow the scene camera). */
180
+ camera?: Partial<ShadowCamera>
181
+ /** Split the shadow into this many cascades (1..MAX_CASCADES, default
182
+ * 1 = the `camera` box). With more, the light renders one map per slice
183
+ * of the SCENE camera's frustum (near .. far, tightest first), each
184
+ * `mapSize` texels wide and fitted every time the camera or the light
185
+ * moves, and a receiver samples the tightest one that has the point:
186
+ * sharp contact shadows near the camera and coarser ones toward the
187
+ * horizon, for a scene the box cannot cover at one map's resolution.
188
+ * Views (`scene.createView`) sample the same maps, fitted to the scene
189
+ * camera, not their own. Each cascade is a tile of the atlas. */
190
+ cascades?: number
191
+ /** How far from the scene camera a cascaded light shadows, in world
192
+ * units (default null = the camera's far). The cascades span
193
+ * near..distance and a point past it is lit, so pulling it in sharpens
194
+ * every cascade; it also bounds the maps' depth range, which is what
195
+ * `bias` is measured against. A box light ignores it. */
196
+ distance?: number | null
197
+ }
198
+
199
+ /** A directional light node: parallel rays travelling along `direction`
200
+ * in the node's LOCAL space, so a parent's rotation turns the light with
201
+ * it (the default `[0, -1, 0]` is a sun straight overhead; the length is
202
+ * ignored). Scale does not affect it, and neither does position UNLESS
203
+ * it casts: a casting light's shadow camera sits at its WORLD position
204
+ * looking along its world direction (Three's rule), so place a casting
205
+ * sun above the scene. Write through setLight. */
206
+ export type DirectionalLight = SceneNode & {
207
+ kind: "light"
208
+ type: "directional"
209
+ direction: Vec3
210
+ /** Linear [r, g, b] 0..1. */
211
+ color: Vec3
212
+ intensity: number
213
+ /** Render a shadow map from this light (any directional light may;
214
+ * each map is a full extra pass over the casting meshes); meshes with
215
+ * `castShadow` draw into it, `lit` materials read it (unless
216
+ * `receiveShadow: false`). */
217
+ castShadow: boolean
218
+ /** The resolved shadow options (read; write through setLight). */
219
+ shadow: { mapSize: number; bias: number; normalBias: number; camera: ShadowCamera; cascades: number; distance: number | null }
220
+ }
221
+
222
+ /** The ambient term: a sky/ground gradient by the WORLD normal's
223
+ * vertical tilt (fixed to world up, not the node's). One per scene - the
224
+ * last attached wins. Write through setLight. */
225
+ export type HemisphereLight = SceneNode & {
226
+ kind: "light"
227
+ type: "hemisphere"
228
+ sky: Vec3
229
+ ground: Vec3
230
+ intensity: number
231
+ }
232
+
233
+ export type Light = DirectionalLight | HemisphereLight
234
+
107
235
  export type Mesh = SceneNode & {
108
236
  kind: "mesh"
109
237
  geometry: Geometry
@@ -112,6 +240,10 @@ export type Mesh = SceneNode & {
112
240
  * Sorts within the opaque group and within the transparent group; the
113
241
  * transparent group always follows the opaque one. Set with setRenderOrder. */
114
242
  renderOrder: number
243
+ /** Draw into the scene's shadow map (default false, Three's default).
244
+ * Set with setCastShadow. A casting instanced mesh is skipped (the
245
+ * depth pass cannot know its record layout). */
246
+ castShadow: boolean
115
247
  _entry: DrawId | null
116
248
  /** The geometry-buffer reference the entry was built from, acquired at
117
249
  * attach and what _detach releases - like _transparent, a snapshot,
@@ -121,16 +253,16 @@ export type Mesh = SceneNode & {
121
253
  * pipeline state, and what _detach counts against (setMaterial swaps
122
254
  * mesh.material before the rebuild). */
123
255
  _transparent: boolean
124
- /** World-space center of the geometry bounds, kept by the sync walk
125
- * beside the picking leaf: the transparent sort key. */
256
+ /** World-space center of the local bounds, refreshed at sort time: the
257
+ * transparent sort key. */
126
258
  _center: Vec3
127
- _hidden: boolean
128
- _fresh: boolean
129
259
  _params: ShaderParams | null
130
- _pickLeaf: number | null
131
260
  /** Instance state when the mesh was made by createInstancedMesh; null on
132
261
  * an ordinary mesh. */
133
262
  _instances: MeshInstances | null
263
+ /** True for a createSprite mesh: its quad faces the camera in the
264
+ * vertex stage, so it picks by a unit box instead of its flat triangles. */
265
+ _sprite: boolean
134
266
  }
135
267
 
136
268
  /** The per-mesh half of instancing: the record buffer and its bookkeeping.
@@ -141,9 +273,11 @@ export type MeshInstances = {
141
273
  buffer: BufferId
142
274
  /** Floats per record - the material's instanceAttributes summed. */
143
275
  stride: number
144
- /** Records the buffer has room for; fixed at creation, like every GPU
145
- * buffer's byte size. */
276
+ /** Records the buffer has room for; doubles when setInstances writes
277
+ * more (a replacement buffer, never a resize). */
146
278
  capacity: number
279
+ /** The buffer label, carried to replacement buffers on growth. */
280
+ label: string | undefined
147
281
  /** Records currently drawn (the entry's instanceCount while visible). */
148
282
  count: number
149
283
  /** Explicit LOCAL bounds covering the whole population ([minX, minY,
@@ -157,13 +291,26 @@ export type MeshInstances = {
157
291
  * `instances.count` copies of the geometry, one record each. */
158
292
  export type InstancedMesh = Mesh & { _instances: MeshInstances }
159
293
 
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. */
294
+ /** One picking intersection, Three's intersect result: the mesh, the
295
+ * camera-ray distance in world units, the world-space point, and for a
296
+ * triangle hit (every ordinary mesh - the test is per triangle, so a ray
297
+ * through a knot's hole misses) the world-space geometric `normal` facing
298
+ * the ray, the triangle index `face` and the interpolated texture `uv`.
299
+ * An instanced mesh is picked by its explicit population box and a sprite
300
+ * by a unit box around its center, so those three are absent on their
301
+ * hits. */
163
302
  export type Hit = {
164
303
  mesh: Mesh
165
304
  distance: number
166
305
  point: Vec3
306
+ normal?: Vec3
307
+ face?: number
308
+ uv?: [number, number]
309
+ }
310
+
311
+ /** The settled component of a node transition. */
312
+ export type TransitionEndEvent = {
313
+ component: "position" | "rotation" | "scale"
167
314
  }
168
315
 
169
316
  /**
@@ -207,6 +354,10 @@ export type SceneHandlers = {
207
354
  onPointerLeave(event: ElementPointerEvent): void
208
355
  }
209
356
 
357
+ /** An orthographic projection's view-space extents, in world units (the
358
+ * same box at every depth). */
359
+ export type OrthoExtent = { left: number; right: number; top: number; bottom: number }
360
+
210
361
  export type CameraUpdate = {
211
362
  /** Vertical field of view in DEGREES (default 60). */
212
363
  fov?: number
@@ -215,6 +366,11 @@ export type CameraUpdate = {
215
366
  position?: Vec3
216
367
  target?: Vec3
217
368
  up?: Vec3
369
+ /** An orthographic projection with these extents (`fov` is then
370
+ * ignored); null returns to perspective. Three's OrthographicCamera as
371
+ * a camera option: a top-down map, an isometric view, a shadow-map
372
+ * light. */
373
+ ortho?: OrthoExtent | null
218
374
  }
219
375
 
220
376
  export type SceneOptions = {
@@ -227,6 +383,62 @@ export type SceneOptions = {
227
383
  autoFree?: boolean
228
384
  filter?: FilterMode
229
385
  wrap?: WrapMode
386
+ /** Multisample count of the target (1, 2, 4 or 8; default 1). Storage-only
387
+ * anti-aliasing of mesh edges; see createDrawTarget. */
388
+ samples?: 1 | 2 | 4 | 8
389
+ }
390
+
391
+ export type ViewOptions = {
392
+ width: number
393
+ height: number
394
+ /**
395
+ * Every mesh draws with this material instead of its own (Three's
396
+ * `scene.overrideMaterial`, scoped to the view): a depth pass, a normal
397
+ * or id visualizer. The view then carries none of the meshes' own
398
+ * bindings or params, and instanced meshes are skipped (the override's
399
+ * vertex stage cannot know their record layout). An overridden view
400
+ * draws in add order (no renderOrder or transparent sort).
401
+ */
402
+ overrideMaterial?: Material
403
+ /** The view target's depth storage: true (default) for a buffer,
404
+ * "texture" for a sampleable one exposed as `view.depthTexture`. Not
405
+ * with `into` (the depth is the parent's). */
406
+ depth?: true | "texture"
407
+ clearColor?: [number, number, number, number]
408
+ samples?: 1 | 2 | 4 | 8
409
+ filter?: FilterMode
410
+ wrap?: WrapMode
411
+ label?: string
412
+ /** Render into a rectangle of this draw target (an app-owned atlas)
413
+ * instead of a target of the view's own: every view into one atlas
414
+ * costs ONE pass. `x`/`y` (top-left origin, default 0) place the tile;
415
+ * display it with `<d-texture src={atlas} srcX srcY srcW srcH>`. The
416
+ * atlas carries depth and samples; `view.texture` is then the tile's id
417
+ * (a draw target, not a texture) and `view.depthTexture` is null. */
418
+ into?: TextureId
419
+ x?: number
420
+ y?: number
421
+ }
422
+
423
+ /** A second rendering of a scene from its own camera; see Scene.createView. */
424
+ export type View = {
425
+ /** The view's output, an ordinary texture id. */
426
+ texture: TextureId
427
+ /** The view target's depth as a sampler-only texture id when created
428
+ * with `depth: "texture"` (the shadow-map input), else null. */
429
+ depthTexture: TextureId | null
430
+ /** Partial camera update, exactly scene.setCamera. */
431
+ setCamera(update: CameraUpdate): void
432
+ setSize(width: number, height: number): void
433
+ /** Move and resize a view created `into` an atlas (top-left origin);
434
+ * throws on a view with a target of its own. */
435
+ setRect(rect: { x: number; y: number; width: number; height: number }): void
436
+ /** View-owned shared params on the view's target (the scene's own
437
+ * setParams names fan out to every view already). */
438
+ setParams(params: ShaderParams): void
439
+ /** Destroy the view's target (its entries die with it). Idempotent;
440
+ * views also die with their scene. */
441
+ dispose(): void
230
442
  }
231
443
 
232
444
  export type Scene = {
@@ -301,7 +513,7 @@ export type Scene = {
301
513
  *
302
514
  * Coordinates assume the leaf is LAID OUT at the target size - true for
303
515
  * 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
516
+ * transforms or design-size fits (the hit test undoes them). A leaf laid out
305
517
  * at a different size needs handlersFor instead.
306
518
  */
307
519
  handlers: SceneHandlers
@@ -311,6 +523,19 @@ export type Scene = {
311
523
  * layout just works: `scene.handlersFor(() => ({ width: w(), height:
312
524
  * h() }))`. */
313
525
  handlersFor(layout: () => { width: number; height: number }): SceneHandlers
526
+ /**
527
+ * A second rendering of this scene: its own draw target and camera,
528
+ * the same meshes and lights. Each mesh gets one entry in the view's
529
+ * target, bound as one more draw sink of the mesh's core node, so a
530
+ * move feeds every target from the one flush and the app writes
531
+ * nothing per view. Views share the scene's geometry buffers and
532
+ * (unless `overrideMaterial`) its materials; the light set and
533
+ * scene.setParams names fan out to every view, view.setParams is the
534
+ * view's own channel. The scene's background is not mirrored (a view's
535
+ * backdrop is its clearColor), and a view has no picking or pointer
536
+ * events. Views die with the scene; `view.dispose()` drops one early.
537
+ */
538
+ createView(opts: ViewOptions): View
314
539
  /** Destroy the target (entries die with it). Idempotent. Material
315
540
  * pipelines are shared and survive (app-lifetime, see material.ts);
316
541
  * geometry buffers are reference-counted and freed with their last
@@ -318,7 +543,115 @@ export type Scene = {
318
543
  dispose(): void
319
544
  }
320
545
 
321
- function makeNode(kind: "group" | "mesh"): SceneNode {
546
+ // A camera: the scene's own and one per view, the same state and the same
547
+ // one-shared-write contract. `dirty` = the matrices need recomputing (a
548
+ // setCamera or a resize), `pending` = the GPU write is owed to the next
549
+ // sync. The recompute is split from the sync so project()/viewProj() see a
550
+ // fresh matrix right after setCamera, before the microtask runs.
551
+ type Camera = {
552
+ fov: number
553
+ near: number
554
+ far: number
555
+ eye: Vec3
556
+ target: Vec3
557
+ up: Vec3
558
+ ortho: OrthoExtent | null
559
+ dirty: boolean
560
+ pending: boolean
561
+ proj: Mat4
562
+ view: Mat4
563
+ viewProj: Mat4
564
+ }
565
+
566
+ function makeCamera(): Camera {
567
+ return {
568
+ fov: 60,
569
+ near: 0.1,
570
+ far: 100,
571
+ eye: [0, 0, 3],
572
+ target: [0, 0, 0],
573
+ up: [0, 1, 0],
574
+ ortho: null,
575
+ dirty: true,
576
+ pending: false,
577
+ proj: mat4(),
578
+ view: mat4(),
579
+ viewProj: mat4(),
580
+ }
581
+ }
582
+
583
+ function updateCamera(cam: Camera, update: CameraUpdate): void {
584
+ if (update.fov !== undefined) cam.fov = update.fov
585
+ if (update.near !== undefined) cam.near = update.near
586
+ if (update.far !== undefined) cam.far = update.far
587
+ if (update.position) cam.eye = [update.position[0], update.position[1], update.position[2]]
588
+ if (update.target) cam.target = [update.target[0], update.target[1], update.target[2]]
589
+ if (update.up) cam.up = [update.up[0], update.up[1], update.up[2]]
590
+ if (update.ortho !== undefined) {
591
+ let o = update.ortho
592
+ cam.ortho = o === null ? null : { left: o.left, right: o.right, top: o.top, bottom: o.bottom }
593
+ }
594
+ cam.dirty = true
595
+ }
596
+
597
+ function ensureCamera(cam: Camera, width: number, height: number): void {
598
+ if (!cam.dirty) return
599
+ cam.dirty = false
600
+ cam.pending = true
601
+ let o = cam.ortho
602
+ if (o === null) perspective(cam.proj, (cam.fov * Math.PI) / 180, width / height, cam.near, cam.far)
603
+ else orthographic(cam.proj, o.left, o.right, o.top, o.bottom, cam.near, cam.far)
604
+ lookAtMatrix(cam.view, cam.eye, cam.target, cam.up)
605
+ multiply(cam.viewProj, cam.proj, cam.view)
606
+ }
607
+
608
+ // The camera is target state: one shared write, whatever the target holds.
609
+ // Entries are untouched - uModel is camera-independent, and uCamPos is
610
+ // stored even when no current material declares it. The camera basis rides
611
+ // along: the view matrix's first two rows are the camera's world-space
612
+ // right and up (no clip flip - that lives in the projection), so a
613
+ // billboard needs no reconstruction from uViewProj.
614
+ function cameraParams(cam: Camera): ShaderParams {
615
+ let v = cam.view
616
+ return { uViewProj: cam.viewProj, uCamPos: cam.eye, uCamRight: [v[0], v[4], v[8]], uCamUp: [v[1], v[5], v[9]] }
617
+ }
618
+
619
+ // A material reads attributes by name; the geometry's layout must carry
620
+ // every one it declares (the pipeline is built for that layout, so a
621
+ // missing channel would have no home) - an error, like the rest of the
622
+ // strict entry path. Extra channels are fine.
623
+ function checkLayout(material: Material, geometry: Geometry, what: string): void {
624
+ let missing = missingAttributes(material, geometry.layout)
625
+ if (missing.length > 0) {
626
+ throw new Error(
627
+ what + " reads attributes the geometry layout (" + layoutKey(geometry.layout) + ") lacks: " +
628
+ missing.map(a => a.name + " " + a.format).join(", ") +
629
+ " - add the channel with withAttribute()/withColors(), or use a material that does not read it",
630
+ )
631
+ }
632
+ }
633
+
634
+ // An entry's initial params. The uNormal seed keys off the material flag
635
+ // because entry params validate strictly - and a material declaring
636
+ // uNormal without using it therefore throws right here, at add().
637
+ function entrySeed(material: Material, params: ShaderParams | null): ShaderParams {
638
+ return material.normalMatrix
639
+ ? { uModel: IDENTITY, uNormal: IDENTITY, ...material.params, ...params }
640
+ : { uModel: IDENTITY, ...material.params, ...params }
641
+ }
642
+
643
+ // The uShadowAtlas binding while nothing casts: one white
644
+ // texel (depth 1, never shadowed), shared by every scene for the app.
645
+ let placeholder: TextureId | undefined
646
+
647
+ function shadowPlaceholder(): TextureId {
648
+ if (placeholder === undefined) {
649
+ placeholder = createTexture(new Uint8Array([255, 255, 255, 255]), 1, 1, { autoFree: false, label: "scene-shadow-none" })
650
+ }
651
+ return placeholder
652
+ }
653
+
654
+ function makeNode(kind: SceneNode["kind"]): SceneNode {
322
655
  return {
323
656
  kind,
324
657
  parent: null,
@@ -327,10 +660,10 @@ function makeNode(kind: "group" | "mesh"): SceneNode {
327
660
  quaternion: [0, 0, 0, 1],
328
661
  scale: [1, 1, 1],
329
662
  visible: true,
330
- _localDirty: true,
331
- _local: mat4(),
332
- _world: mat4(),
663
+ _node: null,
664
+ _moved: false,
333
665
  _scene: null,
666
+ _transition: null,
334
667
  }
335
668
  }
336
669
 
@@ -338,28 +671,131 @@ export function createGroup(): SceneNode {
338
671
  return makeNode("group")
339
672
  }
340
673
 
674
+ export type DirectionalLightOptions = {
675
+ direction?: Vec3
676
+ color?: Vec3
677
+ intensity?: number
678
+ castShadow?: boolean
679
+ /** Shadow-map options, merged key by key (setLight keeps unmentioned ones). */
680
+ shadow?: ShadowOptions
681
+ }
682
+ export type HemisphereLightOptions = { sky?: Vec3; ground?: Vec3; intensity?: number }
683
+
684
+ /** Every directional light may cast: the cap is MAX_LIGHTS (every map
685
+ * is a tile of the scene's one shadow atlas, so the pass count does not
686
+ * follow it, the fill does). */
687
+ export const MAX_SHADOWS = MAX_LIGHTS
688
+ export { MAX_CASCADES }
689
+
690
+ function mergeShadow(into: DirectionalLight["shadow"], update: ShadowOptions): void {
691
+ if (update.mapSize !== undefined) into.mapSize = update.mapSize
692
+ if (update.bias !== undefined) into.bias = update.bias
693
+ if (update.normalBias !== undefined) into.normalBias = update.normalBias
694
+ if (update.camera !== undefined) Object.assign(into.camera, update.camera)
695
+ if (update.cascades !== undefined) {
696
+ let n = update.cascades
697
+ if (!Number.isInteger(n) || n < 1 || n > MAX_CASCADES) throw new Error("shadow.cascades must be an integer from 1 to " + MAX_CASCADES)
698
+ into.cascades = n
699
+ }
700
+ if (update.distance !== undefined) {
701
+ let d = update.distance
702
+ if (d !== null && !(d > 0)) throw new Error("shadow.distance must be a positive number or null")
703
+ into.distance = d
704
+ }
705
+ }
706
+
707
+ export function createDirectionalLight(opts: DirectionalLightOptions = {}): DirectionalLight {
708
+ let light = makeNode("light") as DirectionalLight
709
+ light.type = "directional"
710
+ light.direction = [...(opts.direction ?? [0, -1, 0])] as Vec3
711
+ light.color = [...(opts.color ?? [1, 1, 1])] as Vec3
712
+ light.intensity = opts.intensity ?? 1
713
+ light.castShadow = opts.castShadow === true
714
+ light.shadow = { mapSize: 1024, bias: 0, normalBias: 0, camera: { left: -5, right: 5, top: 5, bottom: -5, near: 0.5, far: 500 }, cascades: 1, distance: null }
715
+ if (opts.shadow !== undefined) mergeShadow(light.shadow, opts.shadow)
716
+ return light
717
+ }
718
+
719
+ export function createHemisphereLight(opts: HemisphereLightOptions = {}): HemisphereLight {
720
+ let light = makeNode("light") as HemisphereLight
721
+ light.type = "hemisphere"
722
+ light.sky = [...(opts.sky ?? [1, 1, 1])] as Vec3
723
+ light.ground = [...(opts.ground ?? [0.2, 0.2, 0.2])] as Vec3
724
+ light.intensity = opts.intensity ?? 1
725
+ return light
726
+ }
727
+
728
+ /** The write path for a light's own fields (color, intensity, direction
729
+ * or sky/ground); absent keys keep their value. Its placement goes
730
+ * through setTransform like any node. Frame-rate-safe. */
731
+ export function setLight(light: DirectionalLight, update: DirectionalLightOptions): void
732
+ export function setLight(light: HemisphereLight, update: HemisphereLightOptions): void
733
+ export function setLight(light: Light, update: DirectionalLightOptions & HemisphereLightOptions): void {
734
+ if (update.intensity !== undefined) light.intensity = update.intensity
735
+ if (light.type === "directional") {
736
+ if (update.direction !== undefined) light.direction = [...update.direction] as Vec3
737
+ if (update.color !== undefined) light.color = [...update.color] as Vec3
738
+ let shadowChanged = false
739
+ if (update.castShadow !== undefined && update.castShadow !== light.castShadow) {
740
+ light.castShadow = update.castShadow
741
+ shadowChanged = true
742
+ }
743
+ if (update.shadow !== undefined) {
744
+ mergeShadow(light.shadow, update.shadow)
745
+ shadowChanged = true
746
+ }
747
+ if (shadowChanged) light._scene?._shadowChanged(light)
748
+ } else {
749
+ if (update.sky !== undefined) light.sky = [...update.sky] as Vec3
750
+ if (update.ground !== undefined) light.ground = [...update.ground] as Vec3
751
+ }
752
+ light._scene?._lightChanged()
753
+ }
754
+
341
755
  export function createMesh(geometry: Geometry, material: Material): Mesh {
342
756
  let mesh = makeNode("mesh") as Mesh
343
757
  mesh.geometry = geometry
344
758
  mesh.material = material
345
759
  mesh.renderOrder = 0
760
+ mesh.castShadow = false
346
761
  mesh._entry = null
347
762
  mesh._buffers = null
348
763
  mesh._transparent = false
349
764
  mesh._center = [0, 0, 0]
350
- mesh._hidden = false
351
- mesh._fresh = false
352
765
  mesh._params = null
353
- mesh._pickLeaf = null
354
766
  mesh._instances = null
767
+ mesh._sprite = false
768
+ return mesh
769
+ }
770
+
771
+ // Every sprite draws the same unit quad, built once: geometry is data
772
+ // and its GPU buffers are acquired per mesh, so one shared value is the
773
+ // normal sharing story. The box is the quad's extent at any facing.
774
+ let spriteQuad: Geometry | undefined
775
+ const SPRITE_BOUNDS = new Float32Array([-0.5, -0.5, -0.5, 0.5, 0.5, 0.5])
776
+
777
+ /**
778
+ * A camera-facing quad, Three's `Sprite`: a unit plane drawn with a
779
+ * `sprite()` material (any material works, but only a sprite material
780
+ * turns the quad; there is no `geometry` argument). Size it with `scale` -
781
+ * a scale of [2, 1, 1] is a 2 x 1 world-unit quad - and place it like any
782
+ * mesh; its rotation is ignored, the camera decides the facing. Picking
783
+ * is by a unit box around the center (the quad's reach at any facing, an
784
+ * approximation), so hits carry no normal/face/uv.
785
+ */
786
+ export function createSprite(material: Material): Mesh {
787
+ if (spriteQuad === undefined) spriteQuad = plane({ label: "sprite" })
788
+ let mesh = createMesh(spriteQuad, material)
789
+ mesh._sprite = true
355
790
  return mesh
356
791
  }
357
792
 
358
793
  /** The local box picking and sorting work from: explicit instance bounds
359
794
  * when the mesh is instanced (null without them - no leaf, no hits), the
360
- * geometry's own bounds otherwise. */
795
+ * unit box for a sprite, the geometry's own bounds otherwise. */
361
796
  function localBounds(mesh: Mesh): Float32Array | null {
362
- return mesh._instances !== null ? mesh._instances.bounds : geometryBounds(mesh.geometry)
797
+ if (mesh._instances !== null) return mesh._instances.bounds
798
+ return mesh._sprite ? SPRITE_BOUNDS : geometryBounds(mesh.geometry)
363
799
  }
364
800
 
365
801
  const ATTRIBUTE_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
@@ -389,9 +825,9 @@ export type InstancedMeshOptions = {
389
825
  * vertex buffer. The material must declare `instanceAttributes`
390
826
  * (shaderMaterialClass); its vertex stage reads each record through those
391
827
  * `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.
828
+ * the attributes' floats summed) and is uploaded here; its length is the
829
+ * buffer's initial capacity, which setInstances grows past on demand.
830
+ * `count` limits how many records draw (default all), up to capacity.
395
831
  *
396
832
  * The result is an ordinary Mesh: add/remove, setTransform (uModel places
397
833
  * the whole population), setVisible (hiding zeroes the drawn count,
@@ -432,6 +868,7 @@ export function createInstancedMesh(
432
868
  buffer: createBuffer(records, { autoFree: false, label: opts?.label }),
433
869
  stride,
434
870
  capacity,
871
+ label: opts?.label,
435
872
  count: Math.max(0, Math.min(Math.floor(count ?? capacity), capacity)),
436
873
  bounds,
437
874
  }
@@ -442,9 +879,12 @@ export function createInstancedMesh(
442
879
  * Overwrite an instanced mesh's records from the start of its buffer and
443
880
  * (by default) draw exactly the records written - pass `count` to draw
444
881
  * 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.
882
+ * rewrite. More records than the buffer holds grow it: capacity doubles
883
+ * (or jumps to the records written when that is more), a new buffer is
884
+ * created and written, the mesh's entry is re-pointed at it and the old
885
+ * buffer freed - so a population grows without a new mesh, with the
886
+ * copies amortized like any dynamic array (size the initial records to
887
+ * skip them). Frame-rate-safe like setMeshParams when no growth happens.
448
888
  */
449
889
  export function setInstances(mesh: InstancedMesh, records: Float32Array, count?: number): void {
450
890
  let inst = mesh._instances
@@ -453,9 +893,11 @@ export function setInstances(mesh: InstancedMesh, records: Float32Array, count?:
453
893
  }
454
894
  let written = records.length / inst.stride
455
895
  if (written > inst.capacity) {
456
- throw new Error(
457
- "setInstances: " + written + " records exceed the buffer's capacity of " + inst.capacity + " (fixed at creation)",
458
- )
896
+ let previous = inst.buffer
897
+ inst.capacity = Math.max(written, inst.capacity * 2)
898
+ inst.buffer = createBuffer(inst.capacity * inst.stride * 4, { autoFree: false, label: inst.label })
899
+ mesh._scene?._setBuffer(mesh)
900
+ destroyBuffer(previous)
459
901
  }
460
902
  writeBuffer(inst.buffer, records)
461
903
  setInstanceCount(mesh, count ?? written)
@@ -490,7 +932,6 @@ export function add(parent: SceneNode, child: SceneNode): void {
490
932
  if (child.parent !== null) remove(child)
491
933
  child.parent = parent
492
934
  parent.children.push(child)
493
- child._localDirty = true
494
935
  if (parent._scene) enterScene(child, parent._scene)
495
936
  }
496
937
 
@@ -507,8 +948,16 @@ export function remove(child: SceneNode): void {
507
948
 
508
949
  function enterScene(node: SceneNode, scene: SceneHooks): void {
509
950
  node._scene = scene
510
- node._localDirty = true
951
+ node._node = spatial.createNode(fillTransform(node), node.visible)
952
+ if (node._transition !== null) {
953
+ spatial.setTransition(node._node, node._transition)
954
+ declareTransition(node._node, node)
955
+ }
956
+ // The parent is in the scene already (add() enters the child only then),
957
+ // and the scene root is the one node without a parent.
958
+ if (node.parent !== null && node.parent._node !== null) spatial.setParent(node._node, node.parent._node)
511
959
  if (node.kind === "mesh") scene._attach(node as Mesh)
960
+ else if (node.kind === "light") scene._attachLight(node as Light)
512
961
  for (let c of node.children) enterScene(c, scene)
513
962
  scene._schedule()
514
963
  }
@@ -516,12 +965,60 @@ function enterScene(node: SceneNode, scene: SceneHooks): void {
516
965
  function leaveScene(node: SceneNode): void {
517
966
  let scene = node._scene
518
967
  if (scene && node.kind === "mesh") scene._detach(node as Mesh)
968
+ else if (scene && node.kind === "light") scene._detachLight(node as Light)
519
969
  node._scene = null
520
970
  for (let c of node.children) leaveScene(c)
971
+ if (node._node !== null) {
972
+ declared.delete(node._node)
973
+ spatial.destroyNode(node._node)
974
+ node._node = null
975
+ }
976
+ }
977
+
978
+ /** The node's local transform in the FFI carrier. */
979
+ function fillTransform(node: SceneNode): Float32Array {
980
+ let t = transformScratch
981
+ t[0] = node.position[0]; t[1] = node.position[1]; t[2] = node.position[2]
982
+ t[3] = node.quaternion[0]; t[4] = node.quaternion[1]; t[5] = node.quaternion[2]; t[6] = node.quaternion[3]
983
+ t[7] = node.scale[0]; t[8] = node.scale[1]; t[9] = node.scale[2]
984
+ return t
985
+ }
986
+
987
+ /** Forward a changed local transform to the core (no-op outside a scene:
988
+ * entering pushes the whole transform). */
989
+ function pushTransform(node: SceneNode): void {
990
+ if (node._node === null || node._scene === null) return
991
+ spatial.writeTransform(node._node, fillTransform(node))
992
+ node._scene._moved(node)
521
993
  }
522
994
 
523
995
  export type { TransformUpdate } from "./math.ts"
524
996
 
997
+ /**
998
+ * Declare (or with null clear) how the node's transform writes animate:
999
+ * once set, setTransform writes are TARGETS the core animates toward
1000
+ * (position/scale per lane, rotation along the quaternion geodesic - a
1001
+ * spring keeps its velocity through retargets, the pursuit-safe shape),
1002
+ * so JS writes once per target change instead of once per frame. A spec
1003
+ * per component (position, rotation, scale) plus `all`; each
1004
+ * `{ duration, bounce? }` (a spring, the default) / `{ duration, curve }`
1005
+ * (a tween) / a shorthand string like "300ms ease-out". The declaration
1006
+ * lives on the node and re-applies whenever it enters a scene; the pose
1007
+ * it enters with always snaps. Clearing cancels running tracks in place
1008
+ * (the node keeps its mid-flight transform) and later writes snap. Each
1009
+ * natural settle calls the node's `onTransitionEnd` with the component
1010
+ * (the raw "spatialTransitionEnd" engine event on srt:events stays for
1011
+ * flux:spatial consumers; it carries the core id, `_node`).
1012
+ */
1013
+ export function setTransition(node: SceneNode, transition: NodeTransition | string | null): void {
1014
+ node._transition = transition
1015
+ if (node._node !== null) {
1016
+ spatial.setTransition(node._node, transition)
1017
+ if (transition === null) declared.delete(node._node)
1018
+ else declareTransition(node._node, node)
1019
+ }
1020
+ }
1021
+
525
1022
  /**
526
1023
  * The one write path for node transforms (so the scene knows to sync).
527
1024
  * Values are copied in; absent keys keep their current value. This is also
@@ -566,8 +1063,7 @@ export function setTransform(node: SceneNode, update: TransformUpdate): void {
566
1063
  }
567
1064
  }
568
1065
  if (!changed) return
569
- node._localDirty = true
570
- node._scene?._schedule()
1066
+ pushTransform(node)
571
1067
  }
572
1068
 
573
1069
  /**
@@ -614,8 +1110,7 @@ export function lookAt(node: SceneNode, target: Vec3, up: Vec3 = WORLD_UP): void
614
1110
  unrotate(upScratch, world, up)
615
1111
  quatFromFrame(node.quaternion, aimScratch, upScratch)
616
1112
  }
617
- node._localDirty = true
618
- node._scene?._schedule()
1113
+ pushTransform(node)
619
1114
  }
620
1115
 
621
1116
  /**
@@ -644,18 +1139,22 @@ export function worldPosition(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
644
1139
  }
645
1140
 
646
1141
  /**
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.
1142
+ * `out` = node's world matrix as the tree stands now. In a scene that is
1143
+ * one core read (pending writes included, nothing cleared); outside one
1144
+ * the chain is composed here. Scene membership is subtree-closed, so the
1145
+ * recursion meets a core node at the first in-scene ancestor at the
1146
+ * latest. One shared local scratch serves any depth - each frame uses it
1147
+ * only after its recursive call has returned.
651
1148
  */
652
1149
  function worldInto(out: Mat4, node: SceneNode): Mat4 {
1150
+ if (node._node !== null) {
1151
+ spatial.worldMatrix(node._node, worldRead)
1152
+ for (let i = 0; i < 16; i++) out[i] = worldRead[i]!
1153
+ return out
1154
+ }
653
1155
  if (node.parent === null) identity(out)
654
1156
  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)
1157
+ return multiply(out, out, compose(localScratch, node.position, node.quaternion, node.scale))
659
1158
  }
660
1159
 
661
1160
  /**
@@ -678,7 +1177,10 @@ function unrotate(out: Vec3, m: Mat4, v: Vec3): Vec3 {
678
1177
  export function setVisible(node: SceneNode, visible: boolean): void {
679
1178
  if (node.visible === visible) return
680
1179
  node.visible = visible
681
- node._scene?._schedule()
1180
+ if (node._node !== null) {
1181
+ spatial.setVisible(node._node, visible)
1182
+ node._scene?._schedule()
1183
+ }
682
1184
  }
683
1185
 
684
1186
  /** Set a mesh's explicit draw-order key (see Mesh.renderOrder). */
@@ -688,9 +1190,17 @@ export function setRenderOrder(mesh: Mesh, order: number): void {
688
1190
  mesh._scene?._reorder()
689
1191
  }
690
1192
 
1193
+ /** Draw the mesh into the scene's shadow map, or stop (see Mesh.castShadow). */
1194
+ export function setCastShadow(mesh: Mesh, cast: boolean): void {
1195
+ if (mesh.castShadow === cast) return
1196
+ mesh.castShadow = cast
1197
+ mesh._scene?._setCast(mesh)
1198
+ }
1199
+
691
1200
  /** Swap a mesh's geometry: its draw entry is rebuilt (the scene re-sorts
692
1201
  * the list, so the mesh keeps its place). */
693
1202
  export function setGeometry(mesh: Mesh, geometry: Geometry): void {
1203
+ if (mesh._sprite) throw new Error("setGeometry: a sprite draws the shared unit quad and takes no geometry")
694
1204
  if (mesh.geometry === geometry) return
695
1205
  mesh.geometry = geometry
696
1206
  rebuildEntry(mesh)
@@ -738,16 +1248,21 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
738
1248
  clearColor: opts?.clearColor,
739
1249
  filter: opts?.filter,
740
1250
  wrap: opts?.wrap,
1251
+ samples: opts?.samples,
741
1252
  label: opts?.label ?? "scene",
742
1253
  autoFree: false,
743
1254
  })
744
1255
  let disposed = false
745
1256
  let scheduled = false
746
1257
 
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>()
1258
+ // Picking: the index and the narrowphase live in the spatial core; this
1259
+ // map turns a hit's core node back into the mesh. The pointer
1260
+ // bookkeeping behind scene.handlers follows.
1261
+ let byNode = new Map<NodeId, Mesh>()
1262
+ // Nodes whose transform changed since the last sync (deduped by the
1263
+ // _moved flag): what the light and transparent-order bookkeeping
1264
+ // reacts to, since which meshes moved is the core's knowledge now.
1265
+ let moved: SceneNode[] = []
751
1266
  let capture = new Map<number, Mesh>()
752
1267
  let hover = new Map<number, Mesh>()
753
1268
 
@@ -758,6 +1273,90 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
758
1273
  // meshes exist - fewer cannot change relative order.
759
1274
  let meshes: Mesh[] = []
760
1275
  let transparentCount = 0
1276
+ // Attached lights in attach order (= light index); any change to the
1277
+ // set, a light's fields, or a light's world matrix rewrites the shared
1278
+ // light params at the end of the sync - one write, however many meshes.
1279
+ let lights: Light[] = []
1280
+ let lightsDirty = false
1281
+ // uLightDir is CORE-DRIVEN: each directional light's slot is a
1282
+ // shared-slot sink (bindDirectionSlot) following the node's world
1283
+ // rotation, with -direction as the local vector (the shader wants the
1284
+ // vector TOWARD the light) - so a light that merely moves costs no JS.
1285
+ // This rewrite runs on attach/detach/field changes (and a new view)
1286
+ // only and owns the rest: colors, count, hemisphere. The light set is
1287
+ // scene state, so it lands on the scene target and every view target.
1288
+ let vecScratch = new Float32Array(3)
1289
+ let writeLights = () => {
1290
+ lightsDirty = false
1291
+ let sky: Vec3 = [0, 0, 0]
1292
+ let ground: Vec3 = [0, 0, 0]
1293
+ let colors: number[] = []
1294
+ let bias: number[] = []
1295
+ let normalBias: number[] = []
1296
+ let count = 0
1297
+ for (let light of lights) {
1298
+ if (light.type === "hemisphere") {
1299
+ let k = light.intensity
1300
+ sky = [light.sky[0] * k, light.sky[1] * k, light.sky[2] * k]
1301
+ ground = [light.ground[0] * k, light.ground[1] * k, light.ground[2] * k]
1302
+ continue
1303
+ }
1304
+ vecScratch[0] = -light.direction[0]
1305
+ vecScratch[1] = -light.direction[1]
1306
+ vecScratch[2] = -light.direction[2]
1307
+ spatial.bindDirectionSlot(light._node!, texture, "uLightDir", MAX_LIGHTS * 3, count, vecScratch)
1308
+ for (let v of views) spatial.bindDirectionSlot(light._node!, v.texture, "uLightDir", MAX_LIGHTS * 3, count, vecScratch)
1309
+ let c = light.color
1310
+ let k = light.intensity
1311
+ colors.push(c[0] * k, c[1] * k, c[2] * k)
1312
+ bias.push(light.shadow.bias)
1313
+ normalBias.push(light.shadow.normalBias)
1314
+ count++
1315
+ }
1316
+ for (let i = count; i < MAX_LIGHTS; i++) {
1317
+ colors.push(0, 0, 0)
1318
+ bias.push(0)
1319
+ normalBias.push(0)
1320
+ }
1321
+ // The shadow set rides with the lights. Per directional light i: its
1322
+ // map slots as uShadowFirst[i] + uShadowCount[i] (0 = a receiving
1323
+ // material draws that light plain) and its biases; per map slot j its
1324
+ // tile of the atlas as uShadowRect[j] in atlas UV (the whole map in
1325
+ // an unused slot - never read). The atlas depth binds once as
1326
+ // uShadowAtlas, the white placeholder when nothing casts, so every
1327
+ // receiving target always has the sampler bound.
1328
+ let first: number[] = new Array(MAX_LIGHTS).fill(0)
1329
+ let counts: number[] = new Array(MAX_LIGHTS).fill(0)
1330
+ let rects: number[] = []
1331
+ let atlas = shadowAtlas
1332
+ let maps: Record<string, TextureId> = { uShadowAtlas: atlas !== null ? depthTexture(atlas.texture) : shadowPlaceholder() }
1333
+ forEachShadowSlot((slot, i, shadow, c) => {
1334
+ if (c === 0) first[i] = slot
1335
+ counts[i] = counts[i]! + 1
1336
+ let r = shadow.rects[c]!
1337
+ let a = atlas!
1338
+ rects.push(r.x / a.width, r.y / a.height, r.width / a.width, r.height / a.height)
1339
+ })
1340
+ for (let slot = rects.length / 4; slot < MAX_SHADOW_MAPS; slot++) rects.push(0, 0, 1, 1)
1341
+ let params: ShaderParams = {
1342
+ uHemiSky: sky,
1343
+ uHemiGround: ground,
1344
+ uLightCount: count,
1345
+ uLightColor: colors,
1346
+ uShadowFirst: first,
1347
+ uShadowCount: counts,
1348
+ uShadowBias: bias,
1349
+ uShadowNormalBias: normalBias,
1350
+ uShadowRect: rects,
1351
+ }
1352
+ receivingTargets(t => {
1353
+ setTargetParams(t, params)
1354
+ setTargetTextures(t, maps)
1355
+ })
1356
+ // A slot change (a light attached, detached or reordered) moves every
1357
+ // matrix too: rewrite the whole array once.
1358
+ shadowMatricesDirty = true
1359
+ }
761
1360
  let orderDirty = false
762
1361
  // The order last handed to the engine: a resort that lands on the same
763
1362
  // permutation (the common case under a moving camera) issues nothing.
@@ -765,140 +1364,420 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
765
1364
  let background: { entry: DrawId; pipeline: RenderPipelineId; program: ProgramId } | null = null
766
1365
  let sortEntries = () => {
767
1366
  orderDirty = false
768
- let order = orderEntries(meshes, view, background?.entry)
1367
+ let order = orderEntries(meshes, camera.view, background?.entry)
769
1368
  if (order.length === lastOrder.length && order.every((id, i) => id === lastOrder[i])) return
770
1369
  lastOrder = order
771
1370
  setDrawOrder(texture, order)
772
1371
  }
773
1372
 
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)
1373
+ // The transparent sort keys: each transparent mesh's local-bounds
1374
+ // center carried through its world matrix (read from the core), at
1375
+ // sort time only - opaque meshes never need one.
1376
+ let refreshCenters = () => {
1377
+ if (transparentCount < 2) return
1378
+ for (let mesh of meshes) {
1379
+ if (!mesh._transparent || mesh._node === null) continue
1380
+ let b = localBounds(mesh)
1381
+ let m = worldInto(worldScratch, mesh)
1382
+ let cx = 0, cy = 0, cz = 0
1383
+ if (b !== null) {
1384
+ cx = (b[0]! + b[3]!) / 2
1385
+ cy = (b[1]! + b[4]!) / 2
1386
+ cz = (b[2]! + b[5]!) / 2
1387
+ }
1388
+ mesh._center[0] = m[0] * cx + m[4] * cy + m[8] * cz + m[12]
1389
+ mesh._center[1] = m[1] * cx + m[5] * cy + m[9] * cz + m[13]
1390
+ mesh._center[2] = m[2] * cx + m[6] * cy + m[10] * cz + m[14]
808
1391
  }
809
1392
  }
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()
1393
+ let camera = makeCamera()
822
1394
  let clip: Vec4 = [0, 0, 0, 0]
1395
+ let pickOrigin: Vec3 = [0, 0, 0]
823
1396
 
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)
1397
+ // Views (scene.createView): more targets drawing the same meshes from
1398
+ // their own cameras. A view holds one entry per mesh in its target,
1399
+ // bound as one more draw sink of the mesh's core node, so the flush
1400
+ // that writes the scene's entry writes the view's too. Sorted like the
1401
+ // scene (view-space keys from the view's own camera); an overridden
1402
+ // view is not sorted at all.
1403
+ type ViewRecord = {
1404
+ texture: TextureId
1405
+ width: number
1406
+ height: number
1407
+ override: Material | null
1408
+ /** Which meshes the view draws (null = all): the shadow view's
1409
+ * caster set. Re-evaluated per mesh by _setCast. */
1410
+ filter: ((mesh: Mesh) => boolean) | null
1411
+ camera: Camera
1412
+ entries: Map<Mesh, DrawId>
1413
+ orderDirty: boolean
1414
+ lastOrder: DrawId[]
1415
+ disposed: boolean
1416
+ }
1417
+ let views: ViewRecord[] = []
1418
+ // Every name scene.setParams has merged so far, replayed on a new view.
1419
+ let sceneParams: ShaderParams = {}
1420
+ // The shadows, one per casting directional light: the internal views
1421
+ // rendering its maps (tiles of the shadow atlas, depth override, casting
1422
+ // meshes only; one for a box light, `shadow.cascades` for a cascaded
1423
+ // one, tightest first) and `rects`, each tile's place in the atlas in
1424
+ // texels. `lastWorld` is the light's world matrix the shadow cameras
1425
+ // were last placed from; `dirty` forces a re-place (options changed).
1426
+ type ShadowRect = { x: number; y: number; width: number; height: number }
1427
+ type Shadow = { light: DirectionalLight; views: ViewRecord[]; lastWorld: Mat4; dirty: boolean; rects: ShadowRect[] }
1428
+ let shadows = new Map<DirectionalLight, Shadow>()
1429
+ // The map slots: every casting light's maps in light order, a light's
1430
+ // cascades consecutive and tightest first. The ONE enumeration the
1431
+ // receiving side is dealt by - rects and first/count in writeLights,
1432
+ // matrices in sync - so uShadowFirst/uShadowCount agree with both.
1433
+ // `i` is the light's directional index (its uShadow*[i] slot).
1434
+ let forEachShadowSlot = (fn: (slot: number, i: number, shadow: Shadow, cascade: number) => void): void => {
1435
+ let slot = 0
1436
+ let i = 0
1437
+ for (let light of lights) {
1438
+ if (light.type !== "directional") continue
1439
+ let shadow = shadows.get(light)
1440
+ if (shadow !== undefined) for (let c = 0; c < shadow.views.length; c++) fn(slot++, i, shadow, c)
1441
+ i++
1442
+ }
1443
+ }
1444
+ // The shadow atlas: ONE depth-texture target every casting light's map
1445
+ // is a tile of, so N maps render as one pass and receivers sample one
1446
+ // sampler through per-map rects (uShadowRect). Created with the first
1447
+ // caster, destroyed with the last. Laid out as a grid of cells the
1448
+ // largest mapSize wide, scaled down uniformly when that would exceed
1449
+ // the device's texture size: tile size follows the budget.
1450
+ let shadowAtlas: { texture: TextureId; width: number; height: number } | null = null
1451
+ let shadowLayout = (count: number, maxSize: number) => {
1452
+ let cols = Math.ceil(Math.sqrt(count))
1453
+ let rows = Math.ceil(count / cols)
1454
+ let scale = Math.min(1, limits.maxTextureSize / (cols * maxSize), limits.maxTextureSize / (rows * maxSize))
1455
+ let cell = Math.max(1, Math.floor(maxSize * scale))
1456
+ return { cols, cell, scale, width: cols * cell, height: rows * cell }
1457
+ }
1458
+ // Place every shadow tile for the current caster set plus `adding` (not
1459
+ // yet in `shadows`; its rects are returned for the view creates), in
1460
+ // light order, a light's cascades consecutive. Sizes the atlas, moves
1461
+ // tiles whose place changed, and drops the atlas when nothing casts.
1462
+ // The rects reach receivers through the next light rewrite.
1463
+ let placeShadows = (adding: DirectionalLight | null): ShadowRect[] | null => {
1464
+ let casters: DirectionalLight[] = []
1465
+ for (let l of lights) if (l.type === "directional" && (shadows.has(l) || l === adding)) casters.push(l)
1466
+ if (adding !== null && !casters.includes(adding)) casters.push(adding)
1467
+ lightsDirty = true
1468
+ if (casters.length === 0) {
1469
+ if (shadowAtlas !== null) {
1470
+ destroyTexture(shadowAtlas.texture)
1471
+ shadowAtlas = null
1472
+ }
1473
+ return null
1474
+ }
1475
+ let maxSize = 1
1476
+ let tiles = 0
1477
+ for (let l of casters) {
1478
+ maxSize = Math.max(maxSize, l.shadow.mapSize)
1479
+ tiles += l.shadow.cascades
1480
+ }
1481
+ let lay = shadowLayout(tiles, maxSize)
1482
+ if (shadowAtlas === null) {
1483
+ shadowAtlas = {
1484
+ texture: createDrawTarget(lay.width, lay.height, null, {
1485
+ depth: "texture",
1486
+ clearColor: [1, 1, 1, 1],
1487
+ label: (opts?.label ?? "scene") + "-shadow-atlas",
1488
+ autoFree: false,
1489
+ }),
1490
+ width: lay.width,
1491
+ height: lay.height,
1492
+ }
1493
+ } else if (shadowAtlas.width !== lay.width || shadowAtlas.height !== lay.height) {
1494
+ setTargetSize(shadowAtlas.texture, lay.width, lay.height)
1495
+ shadowAtlas.width = lay.width
1496
+ shadowAtlas.height = lay.height
1497
+ }
1498
+ let placed: ShadowRect[] | null = null
1499
+ let k = 0
1500
+ for (let l of casters) {
1501
+ let size = Math.max(1, Math.floor(l.shadow.mapSize * lay.scale))
1502
+ let shadow = shadows.get(l)
1503
+ for (let c = 0; c < l.shadow.cascades; c++, k++) {
1504
+ let rect: ShadowRect = { x: (k % lay.cols) * lay.cell, y: Math.floor(k / lay.cols) * lay.cell, width: size, height: size }
1505
+ if (shadow === undefined) {
1506
+ if (placed === null) placed = []
1507
+ placed.push(rect)
1508
+ continue
1509
+ }
1510
+ let r = shadow.rects[c]!
1511
+ if (r.x === rect.x && r.y === rect.y && r.width === rect.width && r.height === rect.height) continue
1512
+ shadow.rects[c] = rect
1513
+ let view = shadow.views[c]!
1514
+ setTargetRect(view.texture, rect)
1515
+ view.width = rect.width
1516
+ view.height = rect.height
1517
+ // A tile's texel size moved: the cascade fit snaps to it.
1518
+ shadow.dirty = true
1519
+ }
1520
+ }
1521
+ return placed
1522
+ }
1523
+ let shadowDir: Vec3 = [0, 0, 0]
1524
+ // uShadowMatrix is one array param (the engine writes whole arrays), so
1525
+ // any shadow camera move rewrites all MAX_SHADOW_MAPS matrices, identity
1526
+ // in the slots that are not dealt.
1527
+ let shadowMatrices: number[] = new Array(MAX_SHADOW_MAPS * 16).fill(0)
1528
+ let shadowMatricesDirty = false
1529
+ // Every target a receiving material can draw into: the scene's and each
1530
+ // view's but the shadow views (binding a target's own depth into it
1531
+ // would be same-pass feedback).
1532
+ let receivingTargets = (fn: (target: TextureId) => void) => {
1533
+ fn(texture)
1534
+ for (let v of views) if (v.filter === null) fn(v.texture)
1535
+ }
1536
+ let attachView = (v: ViewRecord, mesh: Mesh) => {
1537
+ let inst = mesh._instances
1538
+ if (v.override !== null && inst !== null) return
1539
+ if (v.filter !== null && !v.filter(mesh)) return
1540
+ let material = v.override ?? mesh.material
1541
+ let bufs = mesh._buffers!
1542
+ let entry = addDraw(v.texture, material.pipeline(mesh.geometry.layout), entrySeed(material, v.override !== null ? null : mesh._params), {
1543
+ buffer: bufs.buffer,
1544
+ indexBuffer: bufs.index,
1545
+ indexFormat: bufs.indexFormat,
1546
+ textures: material.textures,
1547
+ instanceBuffer: inst !== null ? inst.buffer : undefined,
1548
+ instanceCount: 0,
1549
+ })
1550
+ spatial.bindDraw(mesh._node!, v.texture, entry, material.normalMatrix === true, inst !== null ? inst.count : 1)
1551
+ v.entries.set(mesh, entry)
1552
+ v.orderDirty = true
1553
+ }
1554
+ let detachView = (v: ViewRecord, mesh: Mesh) => {
1555
+ let entry = v.entries.get(mesh)
1556
+ if (entry === undefined) return
1557
+ v.entries.delete(mesh)
1558
+ if (mesh._node !== null) spatial.unbindDraw(mesh._node, v.texture)
1559
+ if (!v.disposed) removeDraw(v.texture, entry)
1560
+ v.orderDirty = true
1561
+ }
1562
+ let sortView = (v: ViewRecord) => {
1563
+ v.orderDirty = false
1564
+ if (v.override !== null) return
1565
+ let order = orderEntries(meshes, v.camera.view, undefined, m => v.entries.get(m as Mesh) ?? null)
1566
+ if (order.length === v.lastOrder.length && order.every((id, i) => id === v.lastOrder[i])) return
1567
+ v.lastOrder = order
1568
+ setDrawOrder(v.texture, order)
1569
+ }
1570
+ let disposeView = (v: ViewRecord) => {
1571
+ if (v.disposed) return
1572
+ v.disposed = true
1573
+ for (let mesh of v.entries.keys()) if (mesh._node !== null) spatial.unbindDraw(mesh._node, v.texture)
1574
+ v.entries.clear()
1575
+ for (let light of lights) if (light.type === "directional" && light._node !== null) spatial.unbindSlot(light._node, v.texture)
1576
+ // Drain the zeroed direction slots while the target still exists.
1577
+ spatial.flush()
1578
+ destroyTexture(v.texture)
1579
+ let i = views.indexOf(v)
1580
+ if (i >= 0) views.splice(i, 1)
1581
+ }
1582
+ // A view record: the target, seeded with everything the scene target
1583
+ // already holds (the light set - rewritten for every target, the simple
1584
+ // write - the merged scene params, the shadow map binding), then one
1585
+ // entry per mesh the filter admits.
1586
+ let makeView = (vopts: ViewOptions, filter: ((mesh: Mesh) => boolean) | null): ViewRecord => {
1587
+ let override = vopts.overrideMaterial ?? null
1588
+ if (override !== null) {
1589
+ for (let mesh of meshes) if (mesh._instances === null) checkLayout(override, mesh.geometry, "View override material")
1590
+ }
1591
+ let tiled = vopts.into !== undefined
1592
+ let v: ViewRecord = {
1593
+ texture: createDrawTarget(vopts.width, vopts.height, null, {
1594
+ depth: tiled ? undefined : (vopts.depth ?? true),
1595
+ clearColor: vopts.clearColor,
1596
+ filter: vopts.filter,
1597
+ wrap: vopts.wrap,
1598
+ samples: vopts.samples,
1599
+ label: vopts.label ?? (opts?.label ?? "scene") + "-view",
1600
+ autoFree: false,
1601
+ into: vopts.into,
1602
+ x: vopts.x,
1603
+ y: vopts.y,
1604
+ }),
1605
+ width: vopts.width,
1606
+ height: vopts.height,
1607
+ override,
1608
+ filter,
1609
+ camera: makeCamera(),
1610
+ entries: new Map(),
1611
+ orderDirty: true,
1612
+ lastOrder: [],
1613
+ disposed: false,
1614
+ }
1615
+ views.push(v)
1616
+ // The light rewrite seeds the shadow set (maps, casts, biases,
1617
+ // matrices) on the new target too.
1618
+ lightsDirty = true
1619
+ setTargetParams(v.texture, sceneParams)
1620
+ for (let mesh of meshes) attachView(v, mesh)
1621
+ hooks._schedule()
1622
+ return v
1623
+ }
1624
+ // A shadow's views: one square tile of the shadow atlas per map drawing
1625
+ // the casting meshes with the depth override from that map's frustum.
1626
+ // The light rewrite writes the rects in the light's slots on every
1627
+ // receiving target.
1628
+ let createShadow = (light: DirectionalLight) => {
1629
+ let rects = placeShadows(light)
1630
+ if (rects === null || shadowAtlas === null) return
1631
+ let atlas = shadowAtlas
1632
+ let views = rects.map(rect =>
1633
+ makeView(
1634
+ {
1635
+ width: rect.width,
1636
+ height: rect.height,
1637
+ into: atlas.texture,
1638
+ x: rect.x,
1639
+ y: rect.y,
1640
+ overrideMaterial: shadowDepthMaterial(),
1641
+ clearColor: [1, 1, 1, 1],
1642
+ label: (opts?.label ?? "scene") + "-shadow",
1643
+ },
1644
+ m => m.castShadow,
1645
+ ),
1646
+ )
1647
+ shadows.set(light, { light, views, lastWorld: mat4(), dirty: true, rects })
1648
+ lightsDirty = true
1649
+ }
1650
+ let destroyShadow = (light: DirectionalLight) => {
1651
+ let shadow = shadows.get(light)
1652
+ if (shadow === undefined) return
1653
+ shadows.delete(light)
1654
+ for (let v of shadow.views) disposeView(v)
1655
+ placeShadows(null)
1656
+ lightsDirty = true
1657
+ hooks._schedule()
1658
+ }
1659
+ // Place a shadow's cameras from its light's world matrix. A box light:
1660
+ // at its world position, looking along its world direction, the light
1661
+ // frustum as the orthographic extents. Compared against the matrix it
1662
+ // was last placed from, so a scene animating elsewhere rewrites nothing
1663
+ // here. A cascaded light also follows the scene camera (`cameraMoved`).
1664
+ let cascadeScratch = mat4()
1665
+ let cascadeCenter: Vec3 = [0, 0, 0]
1666
+ let placeShadowCamera = (shadow: Shadow, cameraMoved: boolean) => {
1667
+ let light = shadow.light
1668
+ let m = worldInto(worldScratch, light)
1669
+ let cascaded = shadow.views.length > 1
1670
+ if (!shadow.dirty && !(cascaded && cameraMoved) && m.every((x, i) => x === shadow.lastWorld[i])) return
1671
+ shadow.dirty = false
1672
+ copy(shadow.lastWorld, m)
1673
+ transformVector(shadowDir, m, light.direction)
1674
+ let len = Math.hypot(shadowDir[0], shadowDir[1], shadowDir[2]) || 1
1675
+ let d: Vec3 = [shadowDir[0] / len, shadowDir[1] / len, shadowDir[2] / len]
1676
+ // A sun straight down is the common case and the degenerate one for
1677
+ // world up: roll about z then (the map's orientation is invisible).
1678
+ let up: Vec3 = Math.abs(d[1]) > VERTICAL_LIGHT ? [0, 0, 1] : [0, 1, 0]
1679
+ if (!cascaded) {
1680
+ let c = light.shadow.camera
1681
+ updateCamera(shadow.views[0]!.camera, {
1682
+ position: [m[12], m[13], m[14]],
1683
+ target: [m[12] + d[0], m[13] + d[1], m[14] + d[2]],
1684
+ up,
1685
+ ortho: { left: c.left, right: c.right, top: c.top, bottom: c.bottom },
1686
+ near: c.near,
1687
+ far: c.far,
1688
+ })
1689
+ return
1690
+ }
1691
+ // Cascades: the scene camera's range near..far (far capped by
1692
+ // shadow.distance) sliced by cascadeSplit, each slice's bounding
1693
+ // sphere (frustumSliceSphere) as an orthographic box looking along
1694
+ // the light, its centre snapped to the map's texel grid in light
1695
+ // space (snapToGrid) so the shadow edges do not swim as the camera
1696
+ // moves. The box reaches back toward the light by the whole range,
1697
+ // so a caster outside the slice still casts into it.
1698
+ let n = shadow.views.length
1699
+ let near = camera.near
1700
+ let far = Math.min(camera.far, light.shadow.distance ?? Infinity)
1701
+ if (!(far > near)) far = near + 1
1702
+ // The light's rotation only: rows are its right, up and back axes.
1703
+ let basis = lookAtMatrix(cascadeScratch, [0, 0, 0], d, up)
1704
+ let aspect = width / height
1705
+ let zn = near
1706
+ for (let c = 0; c < n; c++) {
1707
+ let zf = cascadeSplit(near, far, c, n, CASCADE_SPLIT_LAMBDA)
1708
+ let radius = frustumSliceSphere(cascadeCenter, camera, aspect, zn, zf)
1709
+ zn = zf
1710
+ let view = shadow.views[c]!
1711
+ // A texel is 2r / mapSize world units.
1712
+ snapToGrid(cascadeCenter, cascadeCenter, basis, (2 * radius) / view.width)
1713
+ let back = radius + far
1714
+ updateCamera(view.camera, {
1715
+ position: [cascadeCenter[0] - d[0] * back, cascadeCenter[1] - d[1] * back, cascadeCenter[2] - d[2] * back],
1716
+ target: cascadeCenter,
1717
+ up,
1718
+ ortho: { left: -radius, right: radius, top: radius, bottom: -radius },
1719
+ near: 0,
1720
+ far: back + radius,
1721
+ })
1722
+ }
834
1723
  }
835
1724
 
836
1725
  let sync = () => {
837
1726
  scheduled = false
838
1727
  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
- })
1728
+ ensureCamera(camera, width, height)
1729
+ let cameraMoved = camera.pending
1730
+ if (camera.pending) {
1731
+ camera.pending = false
1732
+ setTargetParams(texture, cameraParams(camera))
854
1733
  if (transparentCount > 1) orderDirty = true
855
1734
  }
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
1735
+ for (let shadow of shadows.values()) placeShadowCamera(shadow, cameraMoved)
1736
+ for (let v of views) {
1737
+ ensureCamera(v.camera, v.width, v.height)
1738
+ if (v.camera.pending) {
1739
+ v.camera.pending = false
1740
+ setTargetParams(v.texture, cameraParams(v.camera))
1741
+ if (transparentCount > 1) v.orderDirty = true
1742
+ if (v.filter !== null) shadowMatricesDirty = true
862
1743
  }
863
- if (changed) {
864
- multiply(node._world, node.parent ? node.parent._world : IDENTITY, node._local)
865
- }
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
- }
1744
+ }
1745
+ // Light bookkeeping first, so a fresh direction-slot bind is seeded
1746
+ // by the flush below in the same sync.
1747
+ if (lightsDirty) writeLights()
1748
+ // The matrices that render the maps are the ones receivers look up
1749
+ // with: one array to every receiving target per shadow-camera move.
1750
+ if (shadowMatricesDirty) {
1751
+ shadowMatricesDirty = false
1752
+ let dealt = 0
1753
+ forEachShadowSlot((slot, _i, shadow, c) => {
1754
+ let m = shadow.views[c]!.camera.viewProj
1755
+ for (let k = 0; k < 16; k++) shadowMatrices[slot * 16 + k] = m[k]!
1756
+ dealt = slot + 1
1757
+ })
1758
+ for (let slot = dealt; slot < MAX_SHADOW_MAPS; slot++) for (let k = 0; k < 16; k++) shadowMatrices[slot * 16 + k] = IDENTITY[k]!
1759
+ let params: ShaderParams = { uShadowMatrix: shadowMatrices }
1760
+ receivingTargets(t => setTargetParams(t, params))
1761
+ }
1762
+ // The core recomputes the moved subtrees and writes every entry's
1763
+ // uModel/uNormal, visibility switch and direction slots.
1764
+ spatial.flush()
1765
+ if (moved.length > 0) {
1766
+ // Which meshes moved is the core's knowledge now, so any move with
1767
+ // two or more transparent meshes re-sorts (sortEntries issues nothing
1768
+ // when the permutation is unchanged).
1769
+ if (transparentCount > 1) {
1770
+ orderDirty = true
1771
+ for (let v of views) v.orderDirty = true
897
1772
  }
898
- for (let c of node.children) walk(c, changed, shown)
1773
+ for (let n of moved) n._moved = false
1774
+ moved.length = 0
899
1775
  }
900
- walk(root, false, true)
1776
+ // The sort keys are world-space and camera-independent: refreshed once
1777
+ // for every sort this sync.
1778
+ if (orderDirty || views.some(v => v.orderDirty)) refreshCenters()
901
1779
  if (orderDirty) sortEntries()
1780
+ for (let v of views) if (v.orderDirty) sortView(v)
902
1781
  }
903
1782
 
904
1783
  let hooks: SceneHooks = {
@@ -907,18 +1786,67 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
907
1786
  scheduled = true
908
1787
  RESOLVED.then(sync)
909
1788
  },
1789
+ _attachLight(light) {
1790
+ if (disposed) return
1791
+ if (light.type === "directional" && lights.filter(l => l.type === "directional").length >= MAX_LIGHTS) {
1792
+ throw new Error("A scene takes at most " + MAX_LIGHTS + " directional lights")
1793
+ }
1794
+ lights.push(light)
1795
+ lightsDirty = true
1796
+ if (light.type === "directional" && light.castShadow) createShadow(light)
1797
+ },
1798
+ _detachLight(light) {
1799
+ let i = lights.indexOf(light)
1800
+ if (i >= 0) lights.splice(i, 1)
1801
+ lightsDirty = true
1802
+ if (light.type === "directional") destroyShadow(light)
1803
+ hooks._schedule()
1804
+ },
1805
+ _shadowChanged(light) {
1806
+ if (disposed) return
1807
+ let shadow = shadows.get(light)
1808
+ if (shadow !== undefined) {
1809
+ if (!light.castShadow) {
1810
+ destroyShadow(light)
1811
+ return
1812
+ }
1813
+ // A cascade count change is a different view set: rebuild it. A
1814
+ // mapSize change re-places every tile (the grid cell follows the
1815
+ // largest map).
1816
+ if (shadow.views.length !== light.shadow.cascades) {
1817
+ destroyShadow(light)
1818
+ createShadow(light)
1819
+ return
1820
+ }
1821
+ placeShadows(null)
1822
+ shadow.dirty = true
1823
+ lightsDirty = true
1824
+ hooks._schedule()
1825
+ } else if (light.castShadow) {
1826
+ createShadow(light)
1827
+ }
1828
+ },
1829
+ _setCast(mesh) {
1830
+ if (mesh._entry === null || disposed) return
1831
+ for (let v of views) {
1832
+ if (v.filter === null) continue
1833
+ if (v.filter(mesh)) attachView(v, mesh)
1834
+ else detachView(v, mesh)
1835
+ }
1836
+ hooks._schedule()
1837
+ },
1838
+ _lightChanged() {
1839
+ lightsDirty = true
1840
+ hooks._schedule()
1841
+ },
910
1842
  _attach(mesh) {
911
1843
  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
- )
1844
+ validateGeometry(mesh.geometry)
1845
+ checkLayout(mesh.material, mesh.geometry, "Mesh material")
1846
+ // Every check before any mutation, so a rejected mesh is attached
1847
+ // nowhere - the views' override materials included.
1848
+ for (let v of views) {
1849
+ if (v.override !== null && mesh._instances === null) checkLayout(v.override, mesh.geometry, "View override material")
922
1850
  }
923
1851
  // Instancing pairs the same way layout does: the pipeline's instance
924
1852
  // attributes describe the mesh's record buffer, so one without the
@@ -944,18 +1872,12 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
944
1872
  }
945
1873
  let bufs = acquireGeometryBuffers(mesh.geometry)
946
1874
  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
1875
  // The entry starts switched off: it has no world matrix yet - the walk
954
1876
  // in sync() computes one - and _schedule() defers that to a microtask,
955
1877
  // so added live it would draw at the seeded identity until then. The
956
1878
  // mismatch branch in sync() turns it on in the same pass that writes
957
1879
  // uModel.
958
- mesh._entry = addDraw(texture, mesh.material.pipeline(), seed, {
1880
+ mesh._entry = addDraw(texture, mesh.material.pipeline(mesh.geometry.layout), entrySeed(mesh.material, mesh._params), {
959
1881
  buffer: bufs.buffer,
960
1882
  indexBuffer: bufs.index,
961
1883
  indexFormat: bufs.indexFormat,
@@ -963,16 +1885,33 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
963
1885
  instanceBuffer: inst !== null ? inst.buffer : undefined,
964
1886
  instanceCount: 0,
965
1887
  })
1888
+ // The core turns the entry on (with the world matrix) at the next
1889
+ // flush, and off again whenever the node or an ancestor hides.
1890
+ spatial.bindDraw(mesh._node!, texture, mesh._entry, mesh.material.normalMatrix === true, inst !== null ? inst.count : 1)
1891
+ for (let v of views) attachView(v, mesh)
1892
+ // Picking: the local box puts the node in the core index; an
1893
+ // ordinary mesh also gets its geometry's triangle shape, an
1894
+ // instanced one is box-only (records are opaque, and without
1895
+ // explicit bounds it is not picked at all), as is a sprite (its
1896
+ // triangles lie wherever the camera is, not where the geometry says).
1897
+ spatial.setBounds(mesh._node!, localBounds(mesh))
1898
+ spatial.setShape(mesh._node!, inst === null && !mesh._sprite ? bufs.shape : null)
1899
+ byNode.set(mesh._node!, mesh)
966
1900
  meshes.push(mesh)
967
1901
  mesh._transparent = mesh.material.transparent === true
968
1902
  if (mesh._transparent) transparentCount++
969
1903
  orderDirty = true
970
- mesh._hidden = true
971
- mesh._fresh = true
972
1904
  this._schedule()
973
1905
  },
974
1906
  _detach(mesh) {
975
1907
  if (mesh._entry !== null) {
1908
+ for (let v of views) detachView(v, mesh)
1909
+ if (mesh._node !== null) {
1910
+ spatial.setShape(mesh._node, null)
1911
+ spatial.setBounds(mesh._node, null)
1912
+ spatial.unbindDraw(mesh._node, texture)
1913
+ byNode.delete(mesh._node)
1914
+ }
976
1915
  if (!disposed) removeDraw(texture, mesh._entry)
977
1916
  if (mesh._buffers !== null) releaseGeometryBuffers(mesh._buffers)
978
1917
  mesh._buffers = null
@@ -982,30 +1921,58 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
982
1921
  orderDirty = true
983
1922
  }
984
1923
  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
1924
  },
992
1925
  _setParams(mesh, params) {
993
- if (mesh._entry !== null && !disposed) setDrawParams(texture, mesh._entry, params)
1926
+ if (mesh._entry === null || disposed) return
1927
+ setDrawParams(texture, mesh._entry, params)
1928
+ // A view drawing the mesh's own material carries its params too; an
1929
+ // overridden view has none of them.
1930
+ for (let v of views) {
1931
+ let entry = v.entries.get(mesh)
1932
+ if (entry !== undefined && v.override === null) setDrawParams(v.texture, entry, params)
1933
+ }
994
1934
  },
995
1935
  _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 })
1936
+ // The core composes the count with the visibility switch: a hidden
1937
+ // entry stays at 0 and the unhide restores the new count.
1938
+ if (mesh._entry !== null && mesh._node !== null && !disposed && mesh._instances !== null) {
1939
+ spatial.setDrawCount(mesh._node, mesh._instances.count)
1940
+ }
1941
+ },
1942
+ _setBuffer(mesh) {
1943
+ // The entry keeps its range (at most the old capacity, so the larger
1944
+ // buffer always passes the swap's bounds check); the caller destroys
1945
+ // the old buffer after this, which the entry held alive until now.
1946
+ if (mesh._entry !== null && !disposed && mesh._instances !== null) {
1947
+ setDrawBuffers(texture, mesh._entry, { instanceBuffer: mesh._instances.buffer })
1948
+ for (let v of views) {
1949
+ let entry = v.entries.get(mesh)
1950
+ if (entry !== undefined) setDrawBuffers(v.texture, entry, { instanceBuffer: mesh._instances.buffer })
1951
+ }
999
1952
  }
1000
1953
  },
1001
1954
  _reorder() {
1002
1955
  orderDirty = true
1956
+ for (let v of views) v.orderDirty = true
1957
+ this._schedule()
1958
+ },
1959
+ _moved(node) {
1960
+ if (!node._moved) {
1961
+ node._moved = true
1962
+ moved.push(node)
1963
+ }
1003
1964
  this._schedule()
1004
1965
  },
1005
1966
  }
1006
1967
 
1007
1968
  let root = makeNode("group")
1008
1969
  root._scene = hooks
1970
+ root._node = spatial.createNode(fillTransform(root), true)
1971
+ // The first light rewrite seeds the (empty) light set and the shadow
1972
+ // slots - placeholders, no casts - so receivers draw plain from the
1973
+ // first frame.
1974
+ lightsDirty = true
1975
+ hooks._schedule()
1009
1976
 
1010
1977
  // --- Pointer event dispatch (behind scene.handlers) ---
1011
1978
 
@@ -1052,7 +2019,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1052
2019
  }
1053
2020
 
1054
2021
  // 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
2022
+ // every transform above it, design-size fits included), so a leaf laid out at
1056
2023
  // the target size - the built-in <Scene> leaf, a d-texture at natural
1057
2024
  // size - is already in scene pixels. Only a leaf deliberately laid out at
1058
2025
  // a DIFFERENT size (the supersampling pattern) needs the ratio, and only
@@ -1132,13 +2099,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1132
2099
  texture,
1133
2100
  root,
1134
2101
  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
2102
+ updateCamera(camera, update)
1142
2103
  hooks._schedule()
1143
2104
  },
1144
2105
  setSize(w, h) {
@@ -1146,11 +2107,14 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1146
2107
  width = w
1147
2108
  height = h
1148
2109
  setTargetSize(texture, w, h)
1149
- cameraDirty = true
2110
+ camera.dirty = true
1150
2111
  hooks._schedule()
1151
2112
  },
1152
2113
  setParams(params) {
1153
- if (!disposed) setTargetParams(texture, params)
2114
+ if (disposed) return
2115
+ Object.assign(sceneParams, params)
2116
+ setTargetParams(texture, params)
2117
+ for (let v of views) setTargetParams(v.texture, params)
1154
2118
  },
1155
2119
  setBackground(source) {
1156
2120
  if (disposed) return
@@ -1168,88 +2132,135 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1168
2132
  background = { entry, pipeline: built.pipeline, program: built.program }
1169
2133
  },
1170
2134
  project(point) {
1171
- ensureCamera()
1172
- transformPoint(clip, viewProj, point)
2135
+ ensureCamera(camera, width, height)
2136
+ transformPoint(clip, camera.viewProj, point)
1173
2137
  let w = clip[3]
1174
2138
  if (w < 1e-6) return null
1175
2139
  // perspective() bakes the y-down clip flip, so NDC maps straight to
1176
- // top-left-origin pixels with no negation here.
2140
+ // top-left-origin pixels with no negation here. (An orthographic
2141
+ // camera has w = 1 everywhere: every point projects.)
1177
2142
  return { x: ((clip[0] / w) * 0.5 + 0.5) * width, y: ((clip[1] / w) * 0.5 + 0.5) * height, w }
1178
2143
  },
1179
2144
  viewProj(out) {
1180
- ensureCamera()
1181
- return copy(out ?? mat4(), viewProj)
2145
+ ensureCamera(camera, width, height)
2146
+ return copy(out ?? mat4(), camera.viewProj)
1182
2147
  },
1183
2148
  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)
2149
+ ensureCamera(camera, width, height)
2150
+ let v = camera.view
2151
+ let o = camera.ortho
2152
+ if (o === null) {
2153
+ // The camera-frame ray through the pixel, inverting project()'s
2154
+ // mapping: the baked y-down clip flip is why pixel y converts with
2155
+ // no negation there and one here.
2156
+ let f = 1 / Math.tan(((camera.fov * Math.PI) / 180) / 2)
2157
+ let cx = (((x / width) * 2 - 1) * (width / height)) / f
2158
+ let cy = -((y / height) * 2 - 1) / f
2159
+ // The view's upper 3x3 rows are the camera axes, so its transpose
2160
+ // carries the camera-space direction (cx, cy, -1) to world.
2161
+ pickDir[0] = cx * v[0] + cy * v[1] - v[2]
2162
+ pickDir[1] = cx * v[4] + cy * v[5] - v[6]
2163
+ pickDir[2] = cx * v[8] + cy * v[9] - v[10]
2164
+ return scene.raycast(camera.eye, pickDir)
2165
+ }
2166
+ // Orthographic: every ray runs along the camera's forward axis; the
2167
+ // pixel picks where on the camera plane it starts (top row = top).
2168
+ let cx = o.left + (x / width) * (o.right - o.left)
2169
+ let cy = o.top + (y / height) * (o.bottom - o.top)
2170
+ pickOrigin[0] = camera.eye[0] + cx * v[0] + cy * v[1]
2171
+ pickOrigin[1] = camera.eye[1] + cx * v[4] + cy * v[5]
2172
+ pickOrigin[2] = camera.eye[2] + cx * v[8] + cy * v[9]
2173
+ pickDir[0] = -v[2]
2174
+ pickDir[1] = -v[6]
2175
+ pickDir[2] = -v[10]
2176
+ return scene.raycast(pickOrigin, pickDir)
1197
2177
  },
1198
2178
  raycast(origin, direction) {
1199
2179
  // Flush pending writes: picking sees the tree as the app just wrote
1200
2180
  // it, the same immediacy contract as lookAt()/project(). (The queued
1201
2181
  // microtask still runs and finds nothing dirty - harmless.)
1202
2182
  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]
2183
+ if (disposed) return []
1214
2184
  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)
2185
+ rayOriginScratch[0] = origin[0]
2186
+ rayOriginScratch[1] = origin[1]
2187
+ rayOriginScratch[2] = origin[2]
2188
+ rayDirScratch[0] = direction[0]
2189
+ rayDirScratch[1] = direction[1]
2190
+ rayDirScratch[2] = direction[2]
2191
+ for (let h of spatial.raycast(rayOriginScratch, rayDirScratch)) {
2192
+ let mesh = byNode.get(h.node)
2193
+ if (mesh === undefined) continue
2194
+ let hit: Hit = { mesh, distance: h.distance, point: h.point }
2195
+ if (h.normal !== undefined) hit.normal = h.normal
2196
+ if (h.face !== undefined) hit.face = h.face
2197
+ if (h.uv !== undefined) hit.uv = h.uv
2198
+ hits.push(hit)
2199
+ }
1239
2200
  return hits
1240
2201
  },
1241
2202
  handlers,
1242
2203
  handlersFor(layout) {
1243
2204
  return makeHandlers(layout)
1244
2205
  },
2206
+ createView(vopts) {
2207
+ if (disposed) throw new Error("createView: the scene is disposed")
2208
+ let v = makeView(vopts, null)
2209
+ return {
2210
+ texture: v.texture,
2211
+ depthTexture: vopts.depth === "texture" && vopts.into === undefined ? depthTexture(v.texture) : null,
2212
+ setCamera(update) {
2213
+ updateCamera(v.camera, update)
2214
+ hooks._schedule()
2215
+ },
2216
+ setSize(w, h) {
2217
+ if (v.disposed || (w === v.width && h === v.height)) return
2218
+ v.width = w
2219
+ v.height = h
2220
+ setTargetSize(v.texture, w, h)
2221
+ v.camera.dirty = true
2222
+ hooks._schedule()
2223
+ },
2224
+ setRect(rect) {
2225
+ if (v.disposed) return
2226
+ setTargetRect(v.texture, rect)
2227
+ if (rect.width === v.width && rect.height === v.height) return
2228
+ v.width = rect.width
2229
+ v.height = rect.height
2230
+ v.camera.dirty = true
2231
+ hooks._schedule()
2232
+ },
2233
+ setParams(params) {
2234
+ if (!v.disposed) setTargetParams(v.texture, params)
2235
+ },
2236
+ dispose() {
2237
+ disposeView(v)
2238
+ },
2239
+ }
2240
+ },
1245
2241
  dispose() {
1246
2242
  if (disposed) return
1247
2243
  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)
2244
+ // Full tree-side teardown, not just the target: every node leaves
2245
+ // the scene (entries' geometry-buffer references and pick leaves
2246
+ // dropped, core nodes freed), so a disposed scene leaves no
2247
+ // bookkeeping behind and the JS tree survives as plain data.
2248
+ for (let c of root.children.slice()) leaveScene(c)
2249
+ root._scene = null
2250
+ if (root._node !== null) {
2251
+ spatial.destroyNode(root._node)
2252
+ root._node = null
2253
+ }
2254
+ // Drain the zeroed direction slots the teardown queued while the
2255
+ // targets still exist; afterwards their groups are gone.
2256
+ spatial.flush()
1252
2257
  destroyTexture(texture)
2258
+ shadows.clear()
2259
+ for (let v of views.slice()) disposeView(v)
2260
+ if (shadowAtlas !== null) {
2261
+ destroyTexture(shadowAtlas.texture)
2262
+ shadowAtlas = null
2263
+ }
1253
2264
  if (background !== null) {
1254
2265
  // The entry died with the target; the pipeline and program are the
1255
2266
  // scene's own (unlike shared material pipelines), so they go too.