@solidrt/3d 0.0.50 → 0.0.52

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