@solidrt/3d 0.0.54 → 0.0.56

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.
Files changed (54) hide show
  1. package/AGENTS.md +1121 -177
  2. package/README.md +15 -7
  3. package/examples/README.md +86 -5
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +20 -5
  6. package/examples/collision.tsx +250 -0
  7. package/examples/first-person.tsx +119 -0
  8. package/examples/fleet.tsx +194 -0
  9. package/examples/fog.tsx +173 -0
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +32 -6
  17. package/examples/pick.tsx +2 -4
  18. package/examples/probe.tsx +101 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +32 -3
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +86 -0
  24. package/examples/skybox.tsx +128 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +132 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +199 -0
  32. package/src/color.ts +32 -0
  33. package/src/components.tsx +773 -82
  34. package/src/environment-bake.ts +492 -0
  35. package/src/environment.ts +325 -0
  36. package/src/first-person.ts +306 -0
  37. package/src/geometry.ts +6 -2
  38. package/src/glsl.ts +1390 -82
  39. package/src/gltf.ts +432 -112
  40. package/src/index.ts +57 -9
  41. package/src/light.ts +383 -0
  42. package/src/material.ts +682 -216
  43. package/src/math.ts +110 -0
  44. package/src/mesh.ts +728 -0
  45. package/src/mixer.ts +476 -0
  46. package/src/model-file.ts +80 -15
  47. package/src/model.ts +211 -34
  48. package/src/node.ts +506 -0
  49. package/src/orbit.ts +97 -48
  50. package/src/scene-pointer.ts +166 -0
  51. package/src/scene-shadows.ts +471 -0
  52. package/src/scene.ts +1304 -1532
  53. package/tools/environment.ts +69 -0
  54. package/tools/model.ts +4 -3
package/src/scene.ts CHANGED
@@ -27,322 +27,162 @@
27
27
  // broadphase and its leaves, the transparent sort's centers and the light
28
28
  // params. They read world matrices back from the core, and only for the
29
29
  // subtrees that moved since they last looked.
30
-
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"
30
+ //
31
+ // This file is the scene half: createScene and everything one scene
32
+ // instance owns (targets, views, sync, picking). The node layer it
33
+ // renders - the graph, transforms, meshes, lights - lives in node.ts,
34
+ // mesh.ts and light.ts, talking back through the SceneHooks seam
35
+ // (node.ts); the camera record is camera.ts, and the per-scene shadow
36
+ // and pointer subsystems are scene-shadows.ts / scene-pointer.ts,
37
+ // built here with the scene's seams as their deps.
38
+
39
+ import { addDraw, createCubeDrawTarget, createDrawTarget, depthTexture, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, renderTarget, setDrawBuffers, setDrawOrder, setDrawParams, setDrawTextures, setTargetParams, setTargetRect, setTargetSize, setTargetTextures } from "@solidrt/core/gpu"
32
40
  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"
41
+ import type { NodeId } from "flux:spatial"
42
+ import type { DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
36
43
  import { getOwner, onCleanup } from "@solidrt/core"
37
44
  import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
38
- // The scene's lookAt() aims a node; math's builds a camera's view matrix -
39
- // the same pairing (and the same name) as Three's Object3D/Matrix4.
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"
41
- import type { Mat4, Quat, TransformUpdate, Vec3, Vec4 } from "./math.ts"
42
- import { MAX_CASCADES, MAX_LIGHTS, MAX_SHADOW_MAPS } 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"
45
+ import { copy, mat4, transformPoint } from "./math.ts"
46
+ import { linearColor } from "./color.ts"
47
+ import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
48
+ import { MAX_LIGHTS, MAX_SHADOW_MAPS } from "./glsl.ts"
49
+ import { cameraParams, cameraState, ensureCamera, makeCamera, updateCamera } from "./camera.ts"
50
+ import type { Camera, CameraState, CameraUpdate } from "./camera.ts"
51
+ import { makeShadowSystem } from "./scene-shadows.ts"
52
+ import { makePointerInput } from "./scene-pointer.ts"
53
+ import { geometryBounds, layoutKey, validateGeometry } from "./geometry.ts"
46
54
  import type { Geometry } from "./geometry.ts"
47
- import { backgroundPipeline, missingAttributes, shadowDepthMaterial } from "./material.ts"
48
- import { orderEntries } from "./order.ts"
55
+ import { acquireGeometryBuffers, releaseGeometryBuffers } from "./geometry-gpu.ts"
56
+ import { backgroundPipeline, missingAttributes, SKYBOX_FRAGMENT } from "./material.ts"
57
+ import { createEnvironmentPlaceholder, createPrefilter, probeFormat } from "./environment.ts"
58
+ import type { Prefilter } from "./environment.ts"
49
59
  import type { Material } from "./material.ts"
60
+ import { orderEntries } from "./order.ts"
61
+ import { fillTransform, leaveScene, makeNode, worldInto } from "./node.ts"
62
+ import type { SceneHooks, SceneNode, ScenePointerEvent } from "./node.ts"
63
+ import { checkInstancePairing, checkMask, instanceBinding, localBounds, publishInstanceStyle } from "./mesh.ts"
64
+ import type { InstancedMesh, InstanceNode, Mesh } from "./mesh.ts"
65
+ import type { CastingLight, Light } from "./light.ts"
50
66
 
51
67
  const IDENTITY = mat4()
52
68
  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
60
- // lookAt()'s default roll reference. Read-only: quatFromFrame never
61
- // writes its inputs, so one shared vector is safe.
62
- const WORLD_UP: Vec3 = [0, 1, 0]
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.
69
- let worldScratch = mat4()
70
- let localScratch = mat4()
69
+
70
+ // raycast()'s FFI carriers; values are copied at the boundary, so one of
71
+ // each serves every call.
71
72
  let rayOriginScratch = new Float32Array(3)
72
73
  let rayDirScratch = new Float32Array(3)
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
- })
74
+ // overlap()/sweep()'s carriers: the volume in flux:spatial's packed
75
+ // layout, one array per kind (capsule: a, b, radius; box: center, half
76
+ // extents, rotation), and the sweep's motion.
77
+ let capsuleScratch = new Float32Array(7)
78
+ let boxScratch = new Float32Array(10)
79
+ let motionScratch = new Float32Array(3)
80
+ const IDENTITY_QUAT: Quat = [0, 0, 0, 1]
81
+
82
+ /** Pack a volume into its kind's scratch array and name the kind; a
83
+ * sphere is a capsule whose segment is one point. */
84
+ function packVolume(volume: Volume, site: string): "capsule" | "box" {
85
+ if ("halfExtents" in volume) {
86
+ let s = boxScratch
87
+ let h = volume.halfExtents
88
+ if (!(h[0] >= 0 && h[1] >= 0 && h[2] >= 0)) throw new Error(site + ": halfExtents must be >= 0, got " + h)
89
+ let q = volume.rotation ?? IDENTITY_QUAT
90
+ s[0] = volume.center[0]
91
+ s[1] = volume.center[1]
92
+ s[2] = volume.center[2]
93
+ s[3] = h[0]
94
+ s[4] = h[1]
95
+ s[5] = h[2]
96
+ s[6] = q[0]
97
+ s[7] = q[1]
98
+ s[8] = q[2]
99
+ s[9] = q[3]
100
+ return "box"
101
+ }
102
+ if (!(volume.radius >= 0)) throw new Error(site + ": radius must be >= 0, got " + volume.radius)
103
+ let s = capsuleScratch
104
+ let a = "center" in volume ? volume.center : volume.a
105
+ let b = "center" in volume ? volume.center : volume.b
106
+ s[0] = a[0]
107
+ s[1] = a[1]
108
+ s[2] = a[2]
109
+ s[3] = b[0]
110
+ s[4] = b[1]
111
+ s[5] = b[2]
112
+ s[6] = volume.radius
113
+ return "capsule"
96
114
  }
97
- let aimScratch: Vec3 = [0, 0, 0]
98
- let upScratch: Vec3 = [0, 0, 0]
115
+
99
116
  // pick()'s camera-ray scratch.
100
117
  let pickDir: Vec3 = [0, 0, 0]
101
- // setTransform's rotation compare happens AFTER conversion, so an euler and
102
- // the quaternion it produces are the same write. Nothing outlives the call.
103
- let rotScratch = quat()
104
- let scaleScratch: Vec3 = [1, 1, 1]
105
-
106
- // The scene half a node needs to reach: attach/detach entries and schedule
107
- // a sync. Kept separate from the public Scene type so internals stay off
108
- // the app-facing surface. The camera (uViewProj + uCamPos) is written
109
- // through the shared channel only when it changes - attach never re-seeds
110
- // it, because target state survives entry churn.
111
- type SceneHooks = {
112
- _schedule(): void
113
- _attach(mesh: Mesh): void
114
- _detach(mesh: Mesh): void
115
- _attachLight(light: Light): void
116
- _detachLight(light: Light): void
117
- _lightChanged(): void
118
- _setParams(mesh: Mesh, params: ShaderParams): void
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
126
- _reorder(): void
127
- /** The node's transform changed (for the sort and light bookkeeping). */
128
- _moved(node: SceneNode): void
129
- }
130
-
131
- export type SceneNode = {
132
- kind: "group" | "mesh" | "light"
133
- parent: SceneNode | null
134
- children: SceneNode[]
135
- /** Read freely; write through setTransform/setVisible so changes sync. */
136
- position: Vec3
137
- /** The stored rotation, always a UNIT quaternion. Euler triples convert
138
- * on the way in (setTransform's `rotation`) and out (getRotation) - there
139
- * is no second rotation field to fall out of step with this one. */
140
- quaternion: Quat
141
- scale: Vec3
142
- visible: boolean
143
- /** Pointer event handlers - plain fields, assign freely (they touch no
144
- * GPU state, so they need no setTransform-style write path; components
145
- * sync their props here). Down/move/up dispatch on the hit mesh and
146
- * bubble through its ancestors (stopPropagation stops the walk);
147
- * enter/leave fire on the mesh alone. Events flow once the element
148
- * showing the scene carries `scene.handlers`. */
149
- onPointerDown?: (event: ScenePointerEvent) => void
150
- onPointerMove?: (event: ScenePointerEvent) => void
151
- onPointerUp?: (event: ScenePointerEvent) => void
152
- onPointerEnter?: (event: ScenePointerEvent) => void
153
- onPointerLeave?: (event: ScenePointerEvent) => void
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
160
- _scene: SceneHooks | null
161
- /** The declared transition, re-applied on every scene enter. */
162
- _transition: NodeTransition | string | null
163
- }
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
-
235
- export type Mesh = SceneNode & {
236
- kind: "mesh"
237
- geometry: Geometry
238
- material: Material
239
- /** Explicit draw-order key (default 0), Three's name: lower draws first.
240
- * Sorts within the opaque group and within the transparent group; the
241
- * transparent group always follows the opaque one. Set with setRenderOrder. */
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
247
- _entry: DrawId | null
248
- /** The geometry-buffer reference the entry was built from, acquired at
249
- * attach and what _detach releases - like _transparent, a snapshot,
250
- * because setGeometry swaps mesh.geometry before the rebuild. */
251
- _buffers: GeometryBuffers | null
252
- /** material.transparent as of the last attach - the entry's actual
253
- * pipeline state, and what _detach counts against (setMaterial swaps
254
- * mesh.material before the rebuild). */
255
- _transparent: boolean
256
- /** World-space center of the local bounds, refreshed at sort time: the
257
- * transparent sort key. */
258
- _center: Vec3
259
- _params: ShaderParams | null
260
- /** Instance state when the mesh was made by createInstancedMesh; null on
261
- * an ordinary mesh. */
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
266
- }
267
-
268
- /** The per-mesh half of instancing: the record buffer and its bookkeeping.
269
- * Read the public fields freely; write through setInstances /
270
- * setInstanceCount so the draw range follows. */
271
- export type MeshInstances = {
272
- /** The GPU record buffer, owned by the mesh (disposeInstances frees it). */
273
- buffer: BufferId
274
- /** Floats per record - the material's instanceAttributes summed. */
275
- stride: number
276
- /** Records the buffer has room for; doubles when setInstances writes
277
- * more (a replacement buffer, never a resize). */
278
- capacity: number
279
- /** The buffer label, carried to replacement buffers on growth. */
280
- label: string | undefined
281
- /** Records currently drawn (the entry's instanceCount while visible). */
282
- count: number
283
- /** Explicit LOCAL bounds covering the whole population ([minX, minY,
284
- * minZ, maxX, maxY, maxZ]), or null: the mesh then has no picking leaf -
285
- * records are opaque data, so the library cannot derive where the
286
- * instances are. */
287
- bounds: Float32Array | null
288
- }
289
-
290
- /** A mesh from createInstancedMesh: an ordinary Mesh whose entry draws
291
- * `instances.count` copies of the geometry, one record each. */
292
- export type InstancedMesh = Mesh & { _instances: MeshInstances }
118
+ // worldInto's out for the light/center readbacks below; nothing here
119
+ // outlives a single call.
120
+ let worldScratch = mat4()
293
121
 
294
122
  /** One picking intersection, Three's intersect result: the mesh, the
295
123
  * camera-ray distance in world units, the world-space point, and for a
296
124
  * triangle hit (every ordinary mesh - the test is per triangle, so a ray
297
125
  * through a knot's hole misses) the world-space geometric `normal` facing
298
126
  * 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. */
127
+ * An instanced mesh is picked per instance, by the geometry's triangles
128
+ * placed as that instance (`instance` names it, Three's instanceId). A
129
+ * record mesh is picked by the faces of its explicit population box and
130
+ * a sprite by those of a unit box around its center, so their hits carry
131
+ * the struck face's `normal` and no `face`/`uv`. */
302
132
  export type Hit = {
303
133
  mesh: Mesh
134
+ /** The instance struck, for an instanced mesh. */
135
+ instance?: InstanceNode
304
136
  distance: number
305
137
  point: Vec3
306
- normal?: Vec3
138
+ normal: Vec3
307
139
  face?: number
308
140
  uv?: [number, number]
309
141
  }
310
142
 
311
- /** The settled component of a node transition. */
312
- export type TransitionEndEvent = {
313
- component: "position" | "rotation" | "scale"
314
- }
143
+ /** A pixel's camera ray (screenRay): `direction` is NOT normalized - its
144
+ * camera-forward component is 1, so `origin + w * direction` is the world
145
+ * point at camera-forward distance `w` (unproject's mapping). */
146
+ export type ScreenRay = { origin: Vec3; direction: Vec3 }
315
147
 
316
- /**
317
- * The event a mesh (or ancestor group) handler receives: the element
318
- * pointer vocabulary carried over, plus the 3D fields. `point`/`distance`
319
- * are null exactly when the ray misses the dispatch mesh - which happens
320
- * only during a captured drag or on a leave.
321
- */
322
- export type ScenePointerEvent = {
323
- /** The mesh the event is about (the hit, or the captured mesh during a
324
- * drag) - constant while the event bubbles. */
325
- mesh: Mesh
326
- /** Node whose handler is running; changes as the event bubbles. */
327
- currentTarget: SceneNode
328
- /** World-space hit point on `mesh`, or null when the ray misses it. */
329
- point: Vec3 | null
330
- /** Camera-ray distance to `point` in world units, or null with it. */
331
- distance: number | null
332
- /** Pointer position in scene pixels - project()'s coordinate space. */
333
- x: number
334
- y: number
335
- pointerId: number
336
- pointerType: string
337
- button?: number
338
- shiftKey: boolean
339
- ctrlKey: boolean
340
- altKey: boolean
341
- metaKey: boolean
342
- /** Stops the bubble walk after the current handler. */
343
- stopPropagation(): void
148
+ /** Filters for one raycast/overlap/sweep query. */
149
+ export type QueryOptions = {
150
+ /**
151
+ * Layer mask replacing the scene's for this query (Unity's layerMask,
152
+ * Three's raycaster.layers): hits come from meshes whose `layers`
153
+ * intersect it, wherever the scene mask would look. A mask the scene
154
+ * excludes makes a ray-only mesh - a low-poly collision mesh living
155
+ * undrawn in the same scene, the physics-collider pattern.
156
+ */
157
+ layers?: number
158
+ /** Only these meshes report hits (Three's intersectObjects, an
159
+ * include-list); composes with `layers`. */
160
+ meshes?: Mesh[]
344
161
  }
345
162
 
163
+ /** A query volume for overlap()/sweep(): a sphere, a capsule (the radius
164
+ * swept along the segment a-b) or an oriented box (half extents along
165
+ * the axes of `rotation`, identity when absent) - Unity's Overlap/Cast
166
+ * trio, Godot's Sphere/Capsule/BoxShape3D. */
167
+ export type Sphere = { center: Vec3; radius: number }
168
+ export type Capsule = { a: Vec3; b: Vec3; radius: number }
169
+ export type OrientedBox = { center: Vec3; halfExtents: Vec3; rotation?: Quat }
170
+ export type Volume = Sphere | Capsule | OrientedBox
171
+
172
+ /** One mesh an overlap() volume touches: its deepest contact - the point
173
+ * on the mesh, the unit direction out of it, and the depth along that
174
+ * direction that clears the contact (Unity's ComputePenetration, Godot's
175
+ * get_rest_info, per mesh). `instance` names the instance touched on an
176
+ * instanced mesh, which reports once per instance. */
177
+ export type Overlap = { mesh: Mesh; instance?: InstanceNode; point: Vec3; normal: Vec3; depth: number }
178
+
179
+ /** One mesh a sweep() volume touches on its way: `time` is the fraction
180
+ * of the motion at first touch (0 for a volume already in contact and
181
+ * moving in), `point` the touch point on the mesh, `normal` the unit
182
+ * normal there facing the volume (Unity's RaycastHit from a cast,
183
+ * Godot's KinematicCollision3D). `instance` as on Overlap. */
184
+ export type Impact = { mesh: Mesh; instance?: InstanceNode; time: number; point: Vec3; normal: Vec3 }
185
+
346
186
  /** Element handlers wiring a scene's pointer events: spread onto whatever
347
187
  * element shows `scene.texture` (the built-in `<Scene>` leaf wires them
348
188
  * automatically). `scene.handlers` expects the leaf laid out at the target
@@ -354,30 +194,205 @@ export type SceneHandlers = {
354
194
  onPointerLeave(event: ElementPointerEvent): void
355
195
  }
356
196
 
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 }
197
+ /**
198
+ * Scene fog, by RADIAL distance from the camera, in one of two forms:
199
+ * linear (Three's `Fog`, Unity's linear mode) fades toward `color` from
200
+ * `near` to `far` world units and is fully fogged past `far`; exp2
201
+ * (Three's `FogExp2`, Unity's default) thickens as
202
+ * `1 - exp(-(distance * density)^2)`, no start band, never quite opaque
203
+ * - `density` 0.01 is about 63% fog at 100 units, 98% at 200. Either
204
+ * form takes the height attenuation (Godot's fog height, Unreal's
205
+ * height falloff): the fog is full at and below `height` (world y) and
206
+ * thins above it by `exp(-(y - height) * heightFalloff)` - a valley
207
+ * fills while the hilltops and the sky stay clear; `heightFalloff` 0.1
208
+ * halves the fog every ~7 units of climb. Match
209
+ * `color` to the clearColor or background - the background is not
210
+ * fogged, so a mismatch shows as a band at the horizon - and, for the
211
+ * linear form, set `far` at or inside the camera's far plane to hide
212
+ * the clip.
213
+ */
214
+ export type FogOptions = (
215
+ | {
216
+ /** Distance where the fade starts, world units; negative puts the
217
+ * camera itself part-way into the fog (Three allows the same). */
218
+ near: number
219
+ /** Distance where the fade completes, world units; must exceed near. */
220
+ far: number
221
+ }
222
+ | {
223
+ /** Exp2 thickness per world unit, > 0 (0.005 haze .. 0.05 pea soup). */
224
+ density: number
225
+ }
226
+ ) & {
227
+ /** Straight [r, g, b], 0..1, sRGB like every color option (decoded to
228
+ * linear light for the mix). */
229
+ color: [number, number, number]
230
+ /** World y at and below which the fog is full; default 0. Only acts
231
+ * with a `heightFalloff`. */
232
+ height?: number
233
+ /** How fast the fog thins above `height`, per world unit (e-fold rate,
234
+ * >= 0); default 0, no height attenuation. */
235
+ heightFalloff?: number
236
+ }
360
237
 
361
- export type CameraUpdate = {
362
- /** Vertical field of view in DEGREES (default 60). */
363
- fov?: number
364
- near?: number
365
- far?: number
366
- position?: Vec3
367
- target?: Vec3
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
238
+ // The shared-param writes a fog value compiles to (null = the three zeros
239
+ // that turn the factor off) - one source for scene.setFog and a view's own
240
+ // `fog`, so both validate and spell the uniforms identically.
241
+ function fogParams(fog: FogOptions | null): ShaderParams {
242
+ if (fog === null) return { uFogInv: 0, uFogDensity: 0, uFogHeightFalloff: 0 }
243
+ let color = linearColor(fog.color)
244
+ let height = fog.height ?? 0
245
+ let falloff = fog.heightFalloff ?? 0
246
+ if (!Number.isFinite(height) || !Number.isFinite(falloff) || falloff < 0) {
247
+ throw new Error(`fog: height must be finite and heightFalloff finite and >= 0, got ${height} / ${falloff}`)
248
+ }
249
+ let params: ShaderParams = { uFogColor: color, uFogHeight: height, uFogHeightFalloff: falloff }
250
+ if ("density" in fog) {
251
+ let { density } = fog
252
+ if (!Number.isFinite(density) || density <= 0) {
253
+ throw new Error(`fog: density must be finite and > 0, got ${density}`)
254
+ }
255
+ return { ...params, uFogInv: 0, uFogDensity: density }
256
+ }
257
+ let { near, far } = fog
258
+ if (!(Number.isFinite(near) && Number.isFinite(far)) || far <= near) {
259
+ throw new Error(`fog: near/far must be finite with near < far, got ${near}..${far}`)
260
+ }
261
+ return { ...params, uFogNear: near, uFogInv: 1 / (far - near), uFogDensity: 0 }
262
+ }
263
+
264
+ // `params` minus `names` - the scene-params fan-out around a view's own
265
+ // names. Returns the input untouched (no copy) when nothing intersects.
266
+ function withoutNames(params: ShaderParams, names: Set<string>): ShaderParams {
267
+ let hit = false
268
+ for (let k of names) {
269
+ if (k in params) {
270
+ hit = true
271
+ break
272
+ }
273
+ }
274
+ if (!hit) return params
275
+ let out: ShaderParams = {}
276
+ for (let k of Object.keys(params)) if (!names.has(k)) out[k] = params[k]!
277
+ return out
278
+ }
279
+
280
+ /**
281
+ * A skybox: the scene's background sampled from a cube map along each
282
+ * pixel's view ray (setBackground's object form). Three's
283
+ * `scene.background = cubeTexture` with `backgroundIntensity` and
284
+ * `backgroundRotation`; Unity's Skybox/Cubemap `_Exposure` and
285
+ * `_Rotation`; Godot's `background_energy_multiplier` and `sky_rotation`.
286
+ * Under an orthographic camera every pixel looks the same way, so the
287
+ * skybox is one flat color there (a sky needs perspective in every
288
+ * engine).
289
+ */
290
+ export type SkyboxOptions = {
291
+ /** A cube map (createCubeTexture, faces in +X, -X, +Y, -Y, +Z, -Z
292
+ * order), sampled as GL defines it: each face is what a lookup in that
293
+ * direction returns. No lookup flip (Godot's and Unity's convention):
294
+ * a Three-style face set (each face as seen from inside) is mirrored
295
+ * per image at load, not in the shader. A 2D texture id throws. */
296
+ cube: TextureId
297
+ /** Multiplier on the sampled color, >= 0; default 1. */
298
+ intensity?: number
299
+ /** Turn about world y in RADIANS (default 0): the sky turns as a node
300
+ * with rotation [0, r, 0] would. Radians like node rotation and Three;
301
+ * Unity's `_Rotation` is degrees. */
302
+ rotation?: number
374
303
  }
375
304
 
305
+ /**
306
+ * The scene's environment: a cube map every `lit` material with
307
+ * `reflectivity` mirrors (scene.setEnvironment). The same three fields
308
+ * as a skybox, and typically the same cube - Three's `scene.environment`
309
+ * with `environmentIntensity` and `environmentRotation`; Unity's
310
+ * environment reflections source, Godot's reflected light from the sky.
311
+ */
312
+ export type EnvironmentOptions = {
313
+ /** A cube map (loadEnvironment's baked chain; equirectToCube for an LDR
314
+ * panorama; createCubeTexture with `mipmap: true` for a hand-baked
315
+ * sky), faces in +X, -X, +Y, -Y, +Z, -Z order, looked up like the
316
+ * skybox (no flip). A 2D texture id throws. */
317
+ cube: TextureId
318
+ /** Multiplier on the reflected color, >= 0; default 1. */
319
+ intensity?: number
320
+ /** Turn about world y in RADIANS (default 0), the skybox's convention:
321
+ * the environment turns as a node with rotation [0, r, 0] would. */
322
+ rotation?: number
323
+ }
324
+
325
+ // The uniform turn a rotated cube map is looked up through: the INVERSE
326
+ // of the sky's turn, because a lookup along view direction v must find
327
+ // the texel that sat at R(-r) v before the sky turned by +r.
328
+ function cubeTurn(rotation: number): Mat4 {
329
+ let c = Math.cos(rotation)
330
+ let n = Math.sin(rotation)
331
+ // prettier-ignore
332
+ return [
333
+ c, 0, n, 0,
334
+ 0, 1, 0, 0,
335
+ -n, 0, c, 0,
336
+ 0, 0, 0, 1,
337
+ ]
338
+ }
339
+
340
+ function checkCubeKnobs(o: { intensity?: number; rotation?: number }, site: string): { intensity: number; rotation: number } {
341
+ let intensity = o.intensity ?? 1
342
+ let rotation = o.rotation ?? 0
343
+ if (!Number.isFinite(intensity) || intensity < 0) throw new Error(site + ": intensity must be a finite number >= 0, got " + intensity)
344
+ if (!Number.isFinite(rotation)) throw new Error(site + ": rotation must be a finite angle in radians, got " + rotation)
345
+ return { intensity, rotation }
346
+ }
347
+
348
+ // The entry params a skybox compiles to.
349
+ function skyboxParams(sky: SkyboxOptions, site: string): ShaderParams {
350
+ let k = checkCubeKnobs(sky, site)
351
+ return { uSkyIntensity: k.intensity, uSkyRotation: cubeTurn(k.rotation) }
352
+ }
353
+
354
+ // The shared params an environment compiles to (null = off: uEnvOn 0
355
+ // makes every reflective material's term vanish; the set ENVIRONMENT in
356
+ // `@solidrt/3d/glsl` declares).
357
+ function environmentParams(env: EnvironmentOptions | null): ShaderParams {
358
+ if (env === null) return { uEnvIntensity: 0, uEnvRotation: cubeTurn(0), uEnvOn: 0 }
359
+ let k = checkCubeKnobs(env, "scene.setEnvironment")
360
+ return { uEnvIntensity: k.intensity, uEnvRotation: cubeTurn(k.rotation), uEnvOn: 1 }
361
+ }
362
+
363
+ /** The output tone mapping (setToneMapping): "none" clamps, "aces" is the
364
+ * filmic curve every engine ships (Three's ACESFilmic, Godot's ACES,
365
+ * Unity's ACES). */
366
+ export type ToneMapping = "none" | "aces"
367
+
368
+ // The uToneMapping value per mode; the OUTPUT set branches on it.
369
+ const TONE_MAPPING_CODE: Record<ToneMapping, number> = { none: 0, aces: 1 }
370
+
376
371
  export type SceneOptions = {
372
+ /** The target's clear, written as given: encoded pixels, untouched by
373
+ * exposure and tone mapping (a GLSL background or skybox goes through
374
+ * both). */
377
375
  clearColor?: [number, number, number, number]
378
- /** Fragment GLSL drawn behind the meshes, inside the scene's own pass -
379
- * see setBackground. */
380
- background?: string
376
+ /** Scene-wide fog; see setFog. */
377
+ fog?: FogOptions
378
+ /** Output tone mapping, default "none"; see setToneMapping. */
379
+ toneMapping?: ToneMapping
380
+ /** Output exposure, default 1; see setExposure. */
381
+ exposure?: number
382
+ /** The scene target's layer mask (bitmask, default 1): the scene draws
383
+ * the meshes whose `layers` intersect it. Live via scene.setLayers. */
384
+ layers?: number
385
+ /** The scene target's depth storage: true (default) for a buffer,
386
+ * "texture" for a sampleable one exposed as `scene.depthTexture` - the
387
+ * input for a depth-reading post effect in `output` (depth fog, SSAO,
388
+ * depth of field). Not with `samples` (the engine has no multisampled
389
+ * sampleable depth): render larger and display smaller instead. */
390
+ depth?: true | "texture"
391
+ /** The background drawn behind the meshes, inside the scene's own pass:
392
+ * fragment GLSL or a skybox - see setBackground. */
393
+ background?: string | SkyboxOptions
394
+ /** The cube map reflective materials mirror; see setEnvironment. */
395
+ environment?: EnvironmentOptions
381
396
  label?: string
382
397
  /** `autoFree: false` opts out of owner-scoped auto-dispose (then call dispose yourself). */
383
398
  autoFree?: boolean
@@ -400,6 +415,17 @@ export type ViewOptions = {
400
415
  * draws in add order (no renderOrder or transparent sort).
401
416
  */
402
417
  overrideMaterial?: Material
418
+ /** The view's layer mask (bitmask, default 1): the view draws the
419
+ * meshes whose `layers` intersect it - a minimap admitting marker
420
+ * meshes only. Live via view.setLayers. */
421
+ layers?: number
422
+ /**
423
+ * The view's own fog: FogOptions overrides the scene's, null turns fog
424
+ * off in this view (an unfogged minimap over a fogged scene). Absent
425
+ * follows the scene. The fog names become view-owned params - the
426
+ * scene's setParams/setFog fan-out skips them (see View.setParams).
427
+ */
428
+ fog?: FogOptions | null
403
429
  /** The view target's depth storage: true (default) for a buffer,
404
430
  * "texture" for a sampleable one exposed as `view.depthTexture`. Not
405
431
  * with `into` (the depth is the parent's). */
@@ -429,25 +455,111 @@ export type View = {
429
455
  depthTexture: TextureId | null
430
456
  /** Partial camera update, exactly scene.setCamera. */
431
457
  setCamera(update: CameraUpdate): void
458
+ /** Current camera state, exactly scene.camera. */
459
+ camera(): CameraState
432
460
  setSize(width: number, height: number): void
433
461
  /** Move and resize a view created `into` an atlas (top-left origin);
434
462
  * throws on a view with a target of its own. */
435
463
  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). */
464
+ /** View-owned shared params on the view's target. Names written here
465
+ * (and the `fog` option's) become the view's OWN: the scene's
466
+ * setParams/setFog fan-out skips them from then on, so a view override
467
+ * is never clobbered by the next scene-wide write. */
438
468
  setParams(params: ShaderParams): void
469
+ /** Replace the view's layer mask (bitmask): entries for newly admitted
470
+ * meshes attach, masked-out ones detach. */
471
+ setLayers(mask: number): void
439
472
  /** Destroy the view's target (its entries die with it). Idempotent;
440
473
  * views also die with their scene. */
441
474
  dispose(): void
442
475
  }
443
476
 
477
+ /** A dynamic environment rendered from a point in the scene; see
478
+ * Scene.createReflectionProbe. */
479
+ export type ReflectionProbeOptions = {
480
+ /** Where the probe looks out from, world space (live via setPosition). */
481
+ position: Vec3
482
+ /** Face edge in texels; default 128 (Unity's probe default). */
483
+ size?: number
484
+ /** The face cameras' near and far planes (defaults 0.1 and 100). */
485
+ near?: number
486
+ far?: number
487
+ /** Layer mask (bitmask, default 1): which meshes the probe sees. */
488
+ layers?: number
489
+ clearColor?: [number, number, number, number]
490
+ /** Convolve the faces into the roughness chain after each update
491
+ * (default true: Unity's and Godot's probes; `standard` blurs it by
492
+ * roughness like a baked environment). `false` keeps the sharp cube
493
+ * alone - Three's CubeCamera - at a sixth of the passes. */
494
+ prefilter?: boolean
495
+ label?: string
496
+ }
497
+
498
+ export type ReflectionProbe = {
499
+ /** The cube map: what `environment={{ cube }}` and `background={{
500
+ * cube }}` take. The prefiltered chain (roughness 0 sharp at level 0,
501
+ * blurred below, the same rule as a baked .srte), or with `prefilter:
502
+ * false` the sharp faces alone - then `standard` reflects it at its
503
+ * roughness 0 look whatever its roughness. */
504
+ cube: TextureId
505
+ setPosition(position: Vec3): void
506
+ /** Render the six faces now - six passes over the scene's draw list,
507
+ * seeing the meshes where the last frame's flush placed them - then
508
+ * (unless `prefilter: false`) the chain: one small pass per face per
509
+ * level. Call it when the surroundings changed (every frame for a
510
+ * moving scene, once for a still one). The probe's own cube is never
511
+ * sampled by the faces it renders (a black environment stands in): one
512
+ * bounce. */
513
+ update(): void
514
+ /** Destroy the cube (idempotent; probes also die with the scene). */
515
+ dispose(): void
516
+ }
517
+
518
+ // The face cameras of a reflection probe, GL order (+X, -X, +Y, -Y, +Z,
519
+ // -Z): the direction each looks along and its up vector, chosen so that
520
+ // through the x-mirrored projection (Camera.mirror) each face lands in
521
+ // GL's own cube layout - plain world-up cameras, unlike Three's rolled
522
+ // CubeCamera set, because target images here are stored top-down.
523
+ const PROBE_FACE_DIRECTION: Vec3[] = [
524
+ [1, 0, 0],
525
+ [-1, 0, 0],
526
+ [0, 1, 0],
527
+ [0, -1, 0],
528
+ [0, 0, 1],
529
+ [0, 0, -1],
530
+ ]
531
+ const PROBE_FACE_UP: Vec3[] = [
532
+ [0, 1, 0],
533
+ [0, 1, 0],
534
+ [0, 0, -1],
535
+ [0, 0, 1],
536
+ [0, 1, 0],
537
+ [0, 1, 0],
538
+ ]
539
+ // Default face edge of a reflection probe (Unity's default resolution),
540
+ // and of a baked background (the bake tool's default).
541
+ const PROBE_SIZE = 128
542
+ // A cube face spans a quarter turn.
543
+ const PROBE_FOV = 90
544
+ // The output stage of a cube that holds light rather than display pixels
545
+ // (a probe's faces, a baked sky): no sRGB encode, no tone mapping, unit
546
+ // exposure - so the environment lookups read radiance.
547
+ const LINEAR_OUTPUT: ShaderParams = { uOutputEncode: 0, uToneMapping: TONE_MAPPING_CODE.none, uExposure: 1 }
548
+
444
549
  export type Scene = {
445
550
  /** The scene's output: an ordinary texture id (`<texture src>`). */
446
551
  texture: TextureId
552
+ /** The scene target's depth as a sampler-only texture id when created
553
+ * with `depth: "texture"`, else null. */
554
+ depthTexture: TextureId | null
447
555
  /** The tree root; add(scene.root, node) attaches top-level nodes. */
448
556
  root: SceneNode
449
557
  /** Partial camera update; absent keys keep their current value. */
450
558
  setCamera(update: CameraUpdate): void
559
+ /** The camera as the next frame draws it: setCamera's own fields, a
560
+ * fresh snapshot per call (arrays are copies - mutate freely, write
561
+ * back through setCamera). Reflects a pending setCamera immediately. */
562
+ camera(): CameraState
451
563
  setSize(width: number, height: number): void
452
564
  /**
453
565
  * Scene-wide uniforms: merge app-owned names into the target's SHARED
@@ -459,45 +571,153 @@ export type Scene = {
459
571
  */
460
572
  setParams(params: ShaderParams): void
461
573
  /**
462
- * Set, replace, or remove (null) the scene's background: fragment GLSL
463
- * drawn as the FIRST entry of the scene's own pass - one target, no
464
- * second texture layer, no separate resize plumbing. The fragment gets
465
- * the shader-target contract exactly (vUV 0..1 top-left origin,
466
- * iResolution, fragColor; no `#version` line means the standard
467
- * preamble), so a source written for createShaderTexture ports verbatim.
468
- * It draws with depth off before every mesh and covers the whole target,
469
- * so the clearColor stops being visible. Three's `scene.background =
470
- * color` is `clearColor` here; the texture form can arrive later as a
471
- * non-breaking widening. No app-driven uniforms in v1 - a background is
472
- * static art (anything animated is a mesh's own shaderMaterial).
574
+ * Set, replace, or remove (null) the scene's fog, linear
575
+ * (`{ color, near, far }`) or exp2 (`{ color, density }`), either with
576
+ * the optional `height`/`heightFalloff`. One shared-params write
577
+ * (`uFogColor`, `uFogNear`, `uFogInv`, `uFogDensity`, `uFogHeight`,
578
+ * `uFogHeightFalloff` - the set FOG in `@solidrt/3d/glsl` declares;
579
+ * the form not in use is written 0), fanned out to every view like
580
+ * setParams, so however many meshes fog it costs nothing per frame.
581
+ * Every standard material (unlit, lit, sprite) composes it unless
582
+ * created with `fog: false`; a shaderMaterial opts in by composing FOG.
583
+ * The background is not fogged: match colors (see FogOptions).
584
+ */
585
+ setFog(fog: FogOptions | null): void
586
+ /** Replace the scene target's layer mask (bitmask): entries for newly
587
+ * admitted meshes attach, masked-out ones detach. Shadow views follow
588
+ * this mask - what the scene cannot see must not darken it. */
589
+ setLayers(mask: number): void
590
+ /**
591
+ * Set, replace, or remove (null) the scene's background, drawn as the
592
+ * FIRST entry of the scene's own pass - one target, no second texture
593
+ * layer, no separate resize plumbing. It draws with depth off before
594
+ * every mesh and covers the whole target, so the clearColor stops being
595
+ * visible. Two forms:
596
+ *
597
+ * Fragment GLSL. The fragment gets the shader-target contract (vUV 0..1
598
+ * top-left origin, iResolution, fragColor; no `#version` line means the
599
+ * standard preamble), so a source written for createShaderTexture ports
600
+ * verbatim, PLUS `vRay`: the world-space view ray through the pixel
601
+ * (unnormalized; normalize it), which makes a directional sky - a
602
+ * horizon gradient, a sun disc, stars - a few lines of fragment code.
603
+ * The background is an ordinary scene entry, so it may also declare
604
+ * `uniform vec3 uCamPos` (the ray's origin) and any name written through
605
+ * scene.setParams (an app clock for an animated sky).
606
+ *
607
+ * A skybox (SkyboxOptions): `{ cube, intensity?, rotation? }` samples a
608
+ * cube map along the same ray. Replacing one skybox with another
609
+ * rewrites the entry's params and textures without recompiling, so
610
+ * `rotation` can animate from the reactive prop.
611
+ *
612
+ * Three's `scene.background = color` is `clearColor` here; a 2D image
613
+ * form can arrive later as a non-breaking widening.
614
+ */
615
+ setBackground(source: string | SkyboxOptions | null): void
616
+ /**
617
+ * Set, replace, or remove (null) the scene's environment: the cube map
618
+ * every `lit` material created with `reflectivity` mirrors, blurred by
619
+ * its `shininess`. Scene-level like Three's `scene.environment`,
620
+ * Unity's environment reflections and Godot's sky-lit reflections: one
621
+ * cube bound on every target the scene draws into, one shared-params
622
+ * write for intensity and rotation, however many meshes reflect (a
623
+ * custom material composes ENVIRONMENT from `@solidrt/3d/glsl`). No
624
+ * per-material map. Typically the skybox's own cube, turned with it.
625
+ * Not an ambient light source yet: the hemisphere light stays the
626
+ * ambient term.
627
+ */
628
+ setEnvironment(env: EnvironmentOptions | null): void
629
+ /**
630
+ * The output stage's tone mapping, applied by every library material,
631
+ * the skybox and a GLSL background that ends with outputColor: "none"
632
+ * (default) clamps the linear result, "aces" compresses highlights on
633
+ * the filmic curve. One shared-params write (`uToneMapping`, the OUTPUT
634
+ * set in `@solidrt/3d/glsl` declares), like Three's
635
+ * renderer.toneMapping and Godot's Environment tonemap; a custom
636
+ * fragment that writes fragColor directly is untouched. The clearColor
637
+ * is not tone mapped: with a curve on, draw the backdrop as a
638
+ * background.
473
639
  */
474
- setBackground(source: string | null): void
640
+ setToneMapping(mode: ToneMapping): void
641
+ /**
642
+ * The output stage's exposure (default 1): the linear result is scaled
643
+ * by it before tone mapping, so a scene lit in physical-ish units is
644
+ * brought into range here rather than by dimming every light. One
645
+ * shared-params write (`uExposure`), like Three's toneMappingExposure.
646
+ */
647
+ setExposure(exposure: number): void
475
648
  /**
476
649
  * Project a world point to scene pixels: origin top-left, y down - the
477
650
  * output texture's own coordinate space, ready for overlay layout (HUD
478
- * markers, labels). `w` is the clip-space w, the point's camera-forward
479
- * distance (useful for depth-ordering or distance-scaling markers).
480
- * Returns null for a point at or behind the camera plane - such a point
481
- * has no place on screen. Reflects a pending setCamera immediately.
651
+ * markers, labels). `w` is the point's camera-forward distance in world
652
+ * units under either projection (useful for depth-ordering or
653
+ * distance-scaling markers, and unproject's exact input). Returns null
654
+ * for a point at or behind a PERSPECTIVE camera's plane - such a point
655
+ * has no place on screen; a parallel projection places every point, so
656
+ * an orthographic camera never returns null and `w` may be zero or
657
+ * negative there (negative near is legal ortho). Reflects a pending
658
+ * setCamera immediately.
482
659
  */
483
660
  project(point: Vec3): { x: number; y: number; w: number } | null
661
+ /**
662
+ * project()'s exact inverse (Unity's ScreenToWorldPoint, Godot's
663
+ * project_position): the world point at scene pixel (`x`, `y`) and
664
+ * camera-forward distance `w`, copied into `out` (or a fresh Vec3).
665
+ * project()'s `w` round-trips under either projection - the
666
+ * drag-at-depth recipe: project the grabbed point once, keep its `w`,
667
+ * unproject each pointer move to slide the object at its original
668
+ * depth. Reflects a pending setCamera immediately, like project().
669
+ */
670
+ unproject(x: number, y: number, w: number, out?: Vec3): Vec3
484
671
  /** The camera's view-projection matrix, copied into `out` (or a fresh
485
672
  * mat4). The batch escape hatch; for single points use project(). */
486
673
  viewProj(out?: Mat4): Mat4
487
674
  /**
488
675
  * Cast the camera ray through a scene pixel (top-left origin, y down -
489
676
  * project()'s space, the inverse direction) and return every visible
490
- * mesh it hits, nearest first. The volume tier: hits test the mesh's
491
- * bounding box, transformed exactly (any node transform, including
492
- * non-uniform scale), so a hit through a concave gap - a knot's hole -
493
- * still reports. Broadphase runs over a BVH kept in step by the sync
494
- * walk: a query costs O(log meshes), not O(meshes). Reflects pending
495
- * setTransform/add writes immediately (the sync is flushed).
677
+ * mesh it hits, nearest first. An ordinary mesh tests per triangle
678
+ * against its geometry (hits carry `face`, `uv`, a world-space `normal`
679
+ * facing the ray, and a ray through a knot's hole misses); an instanced
680
+ * mesh or sprite is tested by its box's faces (a `normal`, no
681
+ * `face`/`uv`). Broadphase runs over a BVH kept in step
682
+ * by the sync walk, and a large geometry's triangles are BVH-indexed
683
+ * too (built by the first ray that reaches it), so merged static
684
+ * geometry stays cheap to query. Reflects pending setTransform/add
685
+ * writes immediately (the sync is flushed).
496
686
  */
497
687
  pick(x: number, y: number): Hit[]
498
688
  /** pick()'s world-space half: the same query along an arbitrary ray.
499
689
  * `direction` need not be normalized; distances are world units. */
500
- raycast(origin: Vec3, direction: Vec3): Hit[]
690
+ raycast(origin: Vec3, direction: Vec3, opts?: QueryOptions): Hit[]
691
+ /**
692
+ * Every mesh the volume touches (Unity's OverlapSphere/Box/Capsule,
693
+ * Godot's intersect_shape), each with its deepest contact. Tested per
694
+ * triangle in world space against the same shapes raycast() uses, so
695
+ * an undrawn collider layer answers through `opts.layers`, and any
696
+ * transform holds; an instanced mesh or sprite counts by its box.
697
+ * Surfaces, not solids: a volume wholly inside a closed mesh with no
698
+ * triangle in reach touches nothing. Unordered; reflects pending writes
699
+ * like raycast().
700
+ */
701
+ overlap(volume: Volume, opts?: QueryOptions): Overlap[]
702
+ /**
703
+ * The volume moved by `motion`: every mesh it touches on the way, at
704
+ * its first touch, earliest first (Unity's SphereCast/CapsuleCast/
705
+ * BoxCast, Godot's cast_motion). A volume already in contact reports
706
+ * time 0 while the motion closes in, and nothing while it leaves or
707
+ * slides along the contact - what lets a move-and-slide proceed along
708
+ * a wall. A zero motion touches nothing. Same testing as overlap().
709
+ */
710
+ sweep(volume: Volume, motion: Vec3, opts?: QueryOptions): Impact[]
711
+ /**
712
+ * pick()'s ray half (Unity's ScreenPointToRay, Godot's
713
+ * project_ray_origin/normal): the camera ray through a scene pixel,
714
+ * fresh arrays each call, for intersection work pick() cannot do - a
715
+ * drag plane, a ground grid, a raycast with the hits filtered
716
+ * yourself. `direction`'s camera-forward component is 1 (see
717
+ * ScreenRay), and raycast() takes it as-is. Reflects a pending
718
+ * setCamera immediately.
719
+ */
720
+ screenRay(x: number, y: number): ScreenRay
501
721
  /**
502
722
  * Element pointer handlers driving the mesh event fields
503
723
  * (onPointerDown/Move/Up/Enter/Leave on nodes): spread onto the element
@@ -531,11 +751,43 @@ export type Scene = {
531
751
  * nothing per view. Views share the scene's geometry buffers and
532
752
  * (unless `overrideMaterial`) its materials; the light set and
533
753
  * 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.
754
+ * view's own channel. A view's backdrop is its clearColor (the scene's
755
+ * background draws on probes, not on views), and a view has no picking
756
+ * or pointer events. Views die with the scene; `view.dispose()` drops
757
+ * one early.
537
758
  */
538
759
  createView(opts: ViewOptions): View
760
+ /**
761
+ * A reflection probe: the scene rendered into a cube map from a point,
762
+ * six faces at 90 degrees - Three's CubeCamera, Unity's and Godot's
763
+ * realtime ReflectionProbe - for the environment a moving object
764
+ * mirrors its surroundings through. A view under the hood (one entry
765
+ * list, the light set and scene params fanned out) that also draws the
766
+ * scene's background behind the meshes, as a probe does in every
767
+ * engine, rendered only by
768
+ * `probe.update()`, which the app calls when the surroundings moved:
769
+ * six scene passes each time, then the GPU prefilter into the roughness
770
+ * chain (unless `prefilter: false`), so `standard` blurs it by
771
+ * roughness exactly as it does a baked environment. Probes die with
772
+ * the scene.
773
+ */
774
+ createReflectionProbe(opts: ReflectionProbeOptions): ReflectionProbe
775
+ /**
776
+ * Bake the background into an environment: a reflection probe at the
777
+ * origin that sees no mesh (layer mask 0), so its six `size` faces
778
+ * (default 128) hold the background alone - the GLSL sky or the skybox,
779
+ * exactly as the scene draws it, written LINEAR (uOutputEncode 0, no tone mapping, unit exposure,
780
+ * so a sky ending in outputColor bakes its light; one writing fragColor
781
+ * raw bakes those bytes as light) - then GGX-prefiltered into the chain
782
+ * `standard` samples by roughness: Godot's sky-to-radiance bake, a
783
+ * procedural sky lighting the scene, or a hi-res LDR skybox reduced to
784
+ * an environment. A snapshot: bake again when the sky changed. Returns
785
+ * the cube for `environment={{ cube }}` - a mipmapped cube draw target,
786
+ * 8-bit linear like a probe, NOT auto-freed (an environment normally
787
+ * lives as long as the app; destroyTexture it otherwise). Throws
788
+ * without a background.
789
+ */
790
+ bakeBackground(size?: number): TextureId
539
791
  /** Destroy the target (entries die with it). Idempotent. Material
540
792
  * pipelines are shared and survive (app-lifetime, see material.ts);
541
793
  * geometry buffers are reference-counted and freed with their last
@@ -543,79 +795,6 @@ export type Scene = {
543
795
  dispose(): void
544
796
  }
545
797
 
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
798
  // A material reads attributes by name; the geometry's layout must carry
620
799
  // every one it declares (the pipeline is built for that layout, so a
621
800
  // missing channel would have no home) - an error, like the rest of the
@@ -640,602 +819,6 @@ function entrySeed(material: Material, params: ShaderParams | null): ShaderParam
640
819
  : { uModel: IDENTITY, ...material.params, ...params }
641
820
  }
642
821
 
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 {
655
- return {
656
- kind,
657
- parent: null,
658
- children: [],
659
- position: [0, 0, 0],
660
- quaternion: [0, 0, 0, 1],
661
- scale: [1, 1, 1],
662
- visible: true,
663
- _node: null,
664
- _moved: false,
665
- _scene: null,
666
- _transition: null,
667
- }
668
- }
669
-
670
- export function createGroup(): SceneNode {
671
- return makeNode("group")
672
- }
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
-
755
- export function createMesh(geometry: Geometry, material: Material): Mesh {
756
- let mesh = makeNode("mesh") as Mesh
757
- mesh.geometry = geometry
758
- mesh.material = material
759
- mesh.renderOrder = 0
760
- mesh.castShadow = false
761
- mesh._entry = null
762
- mesh._buffers = null
763
- mesh._transparent = false
764
- mesh._center = [0, 0, 0]
765
- mesh._params = null
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
790
- return mesh
791
- }
792
-
793
- /** The local box picking and sorting work from: explicit instance bounds
794
- * when the mesh is instanced (null without them - no leaf, no hits), the
795
- * unit box for a sprite, the geometry's own bounds otherwise. */
796
- function localBounds(mesh: Mesh): Float32Array | null {
797
- if (mesh._instances !== null) return mesh._instances.bounds
798
- return mesh._sprite ? SPRITE_BOUNDS : geometryBounds(mesh.geometry)
799
- }
800
-
801
- const ATTRIBUTE_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
802
-
803
- function instanceStride(attributes: VertexAttribute[]): number {
804
- let stride = 0
805
- for (let a of attributes) stride += ATTRIBUTE_FLOATS[a.format]
806
- return stride
807
- }
808
-
809
- export type InstancedMeshOptions = {
810
- /** LOCAL bounds covering every instance the records place ([minX, minY,
811
- * minZ, maxX, maxY, maxZ] - geometryBounds' shape), copied in. Records
812
- * are opaque data, so only the app knows where its instances are: with
813
- * bounds the mesh picks and transparent-sorts like any other
814
- * (conservatively - one box around the whole population); without, it
815
- * has no picking leaf and pointer events never target it. */
816
- bounds?: ArrayLike<number>
817
- /** Debug label for the record buffer. */
818
- label?: string
819
- }
820
-
821
- /**
822
- * A mesh drawing `geometry` once per record of `records`: one draw entry,
823
- * one uModel write, N instances - the shape for forests, particles, and
824
- * every fleet whose per-copy data is a few floats rather than a merged
825
- * vertex buffer. The material must declare `instanceAttributes`
826
- * (shaderMaterialClass); its vertex stage reads each record through those
827
- * `in` variables. `records` is the interleaved attribute data (stride =
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.
831
- *
832
- * The result is an ordinary Mesh: add/remove, setTransform (uModel places
833
- * the whole population), setVisible (hiding zeroes the drawn count,
834
- * unhiding restores it), setMeshParams and renderOrder all apply. Update
835
- * records with setInstances, the drawn count with setInstanceCount, and
836
- * free the record buffer with disposeInstances when done for good.
837
- */
838
- export function createInstancedMesh(
839
- geometry: Geometry,
840
- material: Material,
841
- records: Float32Array,
842
- count?: number,
843
- opts?: InstancedMeshOptions,
844
- ): InstancedMesh {
845
- let attributes = material.instanceAttributes
846
- if (attributes === undefined) {
847
- throw new Error(
848
- "createInstancedMesh: the material declares no instanceAttributes - build it with shaderMaterialClass({ instanceAttributes: [...] })",
849
- )
850
- }
851
- let stride = instanceStride(attributes)
852
- if (records.length % stride !== 0) {
853
- throw new Error(
854
- "createInstancedMesh: " + records.length + " floats is not a whole number of " + stride + "-float records",
855
- )
856
- }
857
- let bounds: Float32Array | null = null
858
- if (opts?.bounds !== undefined) {
859
- if (opts.bounds.length !== 6) {
860
- throw new Error("createInstancedMesh: bounds must be [minX, minY, minZ, maxX, maxY, maxZ]")
861
- }
862
- bounds = new Float32Array(6)
863
- for (let i = 0; i < 6; i++) bounds[i] = opts.bounds[i]!
864
- }
865
- let capacity = records.length / stride
866
- let mesh = createMesh(geometry, material) as InstancedMesh
867
- mesh._instances = {
868
- buffer: createBuffer(records, { autoFree: false, label: opts?.label }),
869
- stride,
870
- capacity,
871
- label: opts?.label,
872
- count: Math.max(0, Math.min(Math.floor(count ?? capacity), capacity)),
873
- bounds,
874
- }
875
- return mesh
876
- }
877
-
878
- /**
879
- * Overwrite an instanced mesh's records from the start of its buffer and
880
- * (by default) draw exactly the records written - pass `count` to draw
881
- * fewer, or to keep more previously written ones alive past a partial
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.
888
- */
889
- export function setInstances(mesh: InstancedMesh, records: Float32Array, count?: number): void {
890
- let inst = mesh._instances
891
- if (records.length % inst.stride !== 0) {
892
- throw new Error("setInstances: " + records.length + " floats is not a whole number of " + inst.stride + "-float records")
893
- }
894
- let written = records.length / inst.stride
895
- if (written > inst.capacity) {
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)
901
- }
902
- writeBuffer(inst.buffer, records)
903
- setInstanceCount(mesh, count ?? written)
904
- }
905
-
906
- /** Set how many records draw (clamped to [0, capacity]). The visibility
907
- * switch composes: a hidden mesh stores the count and draws it on unhide. */
908
- export function setInstanceCount(mesh: InstancedMesh, count: number): void {
909
- let inst = mesh._instances
910
- let n = Math.max(0, Math.min(Math.floor(count), inst.capacity))
911
- if (n === inst.count) return
912
- inst.count = n
913
- mesh._scene?._setCount(mesh)
914
- }
915
-
916
- /**
917
- * Detach the mesh (if attached) and free its record buffer. The buffer is
918
- * mesh-owned with no reference count (unlike geometry buffers it is never
919
- * shared), so this is the one explicit free; the mesh cannot be re-added
920
- * afterwards.
921
- */
922
- export function disposeInstances(mesh: InstancedMesh): void {
923
- let inst: MeshInstances | null = mesh._instances
924
- if (inst === null) return
925
- if (mesh._scene) remove(mesh)
926
- destroyBuffer(inst.buffer)
927
- ;(mesh as Mesh)._instances = null
928
- }
929
-
930
- /** Attach `child` under `parent` (re-parenting detaches it first). */
931
- export function add(parent: SceneNode, child: SceneNode): void {
932
- if (child.parent !== null) remove(child)
933
- child.parent = parent
934
- parent.children.push(child)
935
- if (parent._scene) enterScene(child, parent._scene)
936
- }
937
-
938
- /** Detach `child` from its parent (and its meshes from the scene). */
939
- export function remove(child: SceneNode): void {
940
- if (child._scene) leaveScene(child)
941
- let parent = child.parent
942
- if (parent !== null) {
943
- let i = parent.children.indexOf(child)
944
- if (i >= 0) parent.children.splice(i, 1)
945
- child.parent = null
946
- }
947
- }
948
-
949
- function enterScene(node: SceneNode, scene: SceneHooks): void {
950
- node._scene = scene
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)
959
- if (node.kind === "mesh") scene._attach(node as Mesh)
960
- else if (node.kind === "light") scene._attachLight(node as Light)
961
- for (let c of node.children) enterScene(c, scene)
962
- scene._schedule()
963
- }
964
-
965
- function leaveScene(node: SceneNode): void {
966
- let scene = node._scene
967
- if (scene && node.kind === "mesh") scene._detach(node as Mesh)
968
- else if (scene && node.kind === "light") scene._detachLight(node as Light)
969
- node._scene = null
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)
993
- }
994
-
995
- export type { TransformUpdate } from "./math.ts"
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
-
1022
- /**
1023
- * The one write path for node transforms (so the scene knows to sync).
1024
- * Values are copied in; absent keys keep their current value. This is also
1025
- * the frame-rate escape hatch: call it from onFrame on a node grabbed via
1026
- * `ref`, bypassing signals entirely.
1027
- *
1028
- * A write that changes nothing schedules nothing, so driving every node
1029
- * unconditionally from onFrame costs only the compare for the nodes that
1030
- * did not move. Rotation is compared after conversion, so passing an euler
1031
- * equal to the node's current quaternion is also a no-op.
1032
- */
1033
- export function setTransform(node: SceneNode, update: TransformUpdate): void {
1034
- // A no-op write costs nothing: driving every node from onFrame is the
1035
- // intended shape, and most nodes did not move. Exact compares, like
1036
- // setVisible - a value that survives a float round trip unchanged is the
1037
- // same value, and an epsilon would need a scale-dependent one anyway.
1038
- let changed = false
1039
- let p = update.position
1040
- if (p && (p[0] !== node.position[0] || p[1] !== node.position[1] || p[2] !== node.position[2])) {
1041
- node.position[0] = p[0]
1042
- node.position[1] = p[1]
1043
- node.position[2] = p[2]
1044
- changed = true
1045
- }
1046
- if (updateRotation(rotScratch, update, "setTransform")) {
1047
- let n = node.quaternion
1048
- if (rotScratch[0] !== n[0] || rotScratch[1] !== n[1] || rotScratch[2] !== n[2] || rotScratch[3] !== n[3]) {
1049
- n[0] = rotScratch[0]
1050
- n[1] = rotScratch[1]
1051
- n[2] = rotScratch[2]
1052
- n[3] = rotScratch[3]
1053
- changed = true
1054
- }
1055
- }
1056
- if (update.scale !== undefined) {
1057
- updateScale(scaleScratch, update.scale)
1058
- if (scaleScratch[0] !== node.scale[0] || scaleScratch[1] !== node.scale[1] || scaleScratch[2] !== node.scale[2]) {
1059
- node.scale[0] = scaleScratch[0]
1060
- node.scale[1] = scaleScratch[1]
1061
- node.scale[2] = scaleScratch[2]
1062
- changed = true
1063
- }
1064
- }
1065
- if (!changed) return
1066
- pushTransform(node)
1067
- }
1068
-
1069
- /**
1070
- * Aim a node at a WORLD-space point, Three's `Object3D.lookAt`: the node's
1071
- * local +z ends up pointing at `target`, with `up` (world space, default
1072
- * +y) choosing the roll about that axis. Ancestor transforms are undone,
1073
- * so the aim holds under a rotated group - the ancestor chain is brought
1074
- * up to date on the spot rather than waiting for the pending sync.
1075
- *
1076
- * +z because that is the library's own sweep axis (`extrude`, `sweep`,
1077
- * `tube` run along z), so aiming their output needs no correction. For a
1078
- * y-axis solid (`cylinder`, `cone`) reach for `quatFromTo` instead, which
1079
- * takes the axis to aim as an argument.
1080
- *
1081
- * Writes `node.quaternion` - an ordinary rotation afterwards, readable and
1082
- * overwritable by setTransform. To aim along a DIRECTION rather than at a
1083
- * point, add it to the node's world position (`worldPosition`), the same
1084
- * conversion Three asks for.
1085
- *
1086
- * Exact for rotation and uniform scale in the ancestor chain; a
1087
- * non-uniformly scaled ancestor shears the frame and the aim is
1088
- * approximate, exactly as in Three (both read the parent's upper 3x3 as
1089
- * if it were a rotation).
1090
- */
1091
- export function lookAt(node: SceneNode, target: Vec3, up: Vec3 = WORLD_UP): void {
1092
- let parent = node.parent
1093
- if (parent === null) {
1094
- // No ancestors: parent space IS world space, aim straight from the
1095
- // node's own position.
1096
- aimScratch[0] = target[0] - node.position[0]
1097
- aimScratch[1] = target[1] - node.position[1]
1098
- aimScratch[2] = target[2] - node.position[2]
1099
- quatFromFrame(node.quaternion, aimScratch, up)
1100
- } else {
1101
- let world = worldInto(worldScratch, parent)
1102
- transformPoint(pointScratch, world, node.position)
1103
- aimScratch[0] = target[0] - pointScratch[0]
1104
- aimScratch[1] = target[1] - pointScratch[1]
1105
- aimScratch[2] = target[2] - pointScratch[2]
1106
- // World -> parent space for both vectors: rotating forward and up
1107
- // rotates the frame they build, so converting the inputs is the same
1108
- // as converting the resulting rotation, and needs no matrix inverse.
1109
- unrotate(aimScratch, world, aimScratch)
1110
- unrotate(upScratch, world, up)
1111
- quatFromFrame(node.quaternion, aimScratch, upScratch)
1112
- }
1113
- pushTransform(node)
1114
- }
1115
-
1116
- /**
1117
- * A node's rotation as Euler radians in XYZ order, copied into `out` (or a
1118
- * fresh Vec3). A convenience for reading and debugging, NOT a peer of
1119
- * `node.quaternion`: the conversion is lossy in the sense that it cannot
1120
- * recover the triple that was written (see eulerFromQuat), only a triple
1121
- * that means the same rotation. Anything composing or interpolating
1122
- * rotations should work with the quaternion.
1123
- */
1124
- export function getRotation(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
1125
- return eulerFromQuat(out, node.quaternion)
1126
- }
1127
-
1128
- /**
1129
- * A node's position in world space, copied into `out` (or a fresh Vec3) -
1130
- * Three's `getWorldPosition`. Brings the ancestor chain up to date first,
1131
- * so it is exact before the pending sync has run.
1132
- */
1133
- export function worldPosition(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
1134
- let world = worldInto(worldScratch, node)
1135
- out[0] = world[12]
1136
- out[1] = world[13]
1137
- out[2] = world[14]
1138
- return out
1139
- }
1140
-
1141
- /**
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.
1148
- */
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
- }
1155
- if (node.parent === null) identity(out)
1156
- else worldInto(out, node.parent)
1157
- return multiply(out, out, compose(localScratch, node.position, node.quaternion, node.scale))
1158
- }
1159
-
1160
- /**
1161
- * `out` = v with m's rotation undone: the transpose of m's upper 3x3 with
1162
- * its columns normalized, so uniform scale divides out. out may alias v.
1163
- */
1164
- function unrotate(out: Vec3, m: Mat4, v: Vec3): Vec3 {
1165
- let x = v[0], y = v[1], z = v[2]
1166
- let l0 = Math.hypot(m[0], m[1], m[2]) || 1
1167
- let l1 = Math.hypot(m[4], m[5], m[6]) || 1
1168
- let l2 = Math.hypot(m[8], m[9], m[10]) || 1
1169
- out[0] = (m[0] * x + m[1] * y + m[2] * z) / l0
1170
- out[1] = (m[4] * x + m[5] * y + m[6] * z) / l1
1171
- out[2] = (m[8] * x + m[9] * y + m[10] * z) / l2
1172
- return out
1173
- }
1174
-
1175
- /** Show or hide a node and its whole subtree (a hidden mesh costs one
1176
- * `instanceCount: 0` draw range - the entry stays, drawing nothing). */
1177
- export function setVisible(node: SceneNode, visible: boolean): void {
1178
- if (node.visible === visible) return
1179
- node.visible = visible
1180
- if (node._node !== null) {
1181
- spatial.setVisible(node._node, visible)
1182
- node._scene?._schedule()
1183
- }
1184
- }
1185
-
1186
- /** Set a mesh's explicit draw-order key (see Mesh.renderOrder). */
1187
- export function setRenderOrder(mesh: Mesh, order: number): void {
1188
- if (mesh.renderOrder === order) return
1189
- mesh.renderOrder = order
1190
- mesh._scene?._reorder()
1191
- }
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
-
1200
- /** Swap a mesh's geometry: its draw entry is rebuilt (the scene re-sorts
1201
- * the list, so the mesh keeps its place). */
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")
1204
- if (mesh.geometry === geometry) return
1205
- mesh.geometry = geometry
1206
- rebuildEntry(mesh)
1207
- }
1208
-
1209
- /** Swap a mesh's material: its draw entry is rebuilt. */
1210
- export function setMaterial(mesh: Mesh, material: Material): void {
1211
- if (mesh.material === material) return
1212
- mesh.material = material
1213
- rebuildEntry(mesh)
1214
- }
1215
-
1216
- function rebuildEntry(mesh: Mesh): void {
1217
- let scene = mesh._scene
1218
- if (scene) {
1219
- scene._detach(mesh)
1220
- scene._attach(mesh)
1221
- }
1222
- }
1223
-
1224
- /**
1225
- * Write per-mesh uniforms - the channel for a custom material's app-driven
1226
- * values (a time, a per-object tint). Names must be
1227
- * declared and used by the mesh's material shaders (unknown names throw at
1228
- * the call site, the engine's validation contract). Values persist on the
1229
- * mesh: they survive geometry/material entry rebuilds and re-apply then.
1230
- * Also the frame-rate path - like setTransform, call it from onFrame
1231
- * freely.
1232
- */
1233
- export function setMeshParams(mesh: Mesh, params: ShaderParams): void {
1234
- if (mesh._params === null) mesh._params = {}
1235
- Object.assign(mesh._params, params)
1236
- mesh._scene?._setParams(mesh, params)
1237
- }
1238
-
1239
822
  /**
1240
823
  * Create a scene rendering into a depth-buffered draw target of the given
1241
824
  * size. Returns the scene handle; `scene.texture` is the output. Inside a
@@ -1243,8 +826,15 @@ export function setMeshParams(mesh: Mesh, params: ShaderParams): void {
1243
826
  * `autoFree: false`); outside one, call `dispose()` yourself.
1244
827
  */
1245
828
  export function createScene(width: number, height: number, opts?: SceneOptions): Scene {
829
+ let depthMode = opts?.depth ?? true
830
+ if (depthMode === "texture" && (opts?.samples ?? 1) > 1) {
831
+ throw new Error('createScene: depth "texture" cannot combine with samples (no multisampled sampleable depth) - render larger and display smaller instead')
832
+ }
833
+ // The scene target's layer mask; shadow views follow it (a mesh the
834
+ // scene cannot see must not darken it).
835
+ let sceneMask = checkMask(opts?.layers ?? 1, "createScene")
1246
836
  let texture = createDrawTarget(width, height, null, {
1247
- depth: true,
837
+ depth: depthMode,
1248
838
  clearColor: opts?.clearColor,
1249
839
  filter: opts?.filter,
1250
840
  wrap: opts?.wrap,
@@ -1256,15 +846,42 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1256
846
  let scheduled = false
1257
847
 
1258
848
  // 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>()
849
+ // map turns a hit's core node back into the mesh, or the instance of an
850
+ // instanced mesh (each instance node is its own leaf).
851
+ let byNode = new Map<NodeId, Mesh | InstanceNode>()
852
+ // The per-query filter raycast/overlap/sweep share: a hit's core node
853
+ // back to its mesh (and instance), or null when the query mask excludes
854
+ // the mesh (skipped like an invisible one; the mask defaults to the
855
+ // scene's, so an undrawn layer needs an explicit opts.layers to report)
856
+ // or the include-list leaves it out.
857
+ let admitted = (qopts: QueryOptions | undefined, site: string) => {
858
+ let mask = qopts?.layers !== undefined ? checkMask(qopts.layers, site) : sceneMask
859
+ let include = qopts?.meshes !== undefined ? new Set(qopts.meshes) : null
860
+ return (node: NodeId): { mesh: Mesh; instance: InstanceNode | null } | null => {
861
+ let leaf = byNode.get(node)
862
+ if (leaf === undefined) return null
863
+ let instance = leaf.kind === "instance" ? leaf : null
864
+ let mesh = instance !== null ? instance.mesh : (leaf as Mesh)
865
+ if (mesh === null || (mesh.layers & mask) === 0) return null
866
+ if (include !== null && !include.has(mesh)) return null
867
+ return { mesh, instance }
868
+ }
869
+ }
1262
870
  // Nodes whose transform changed since the last sync (deduped by the
1263
871
  // _moved flag): what the light and transparent-order bookkeeping
1264
872
  // reacts to, since which meshes moved is the core's knowledge now.
1265
873
  let moved: SceneNode[] = []
1266
- let capture = new Map<number, Mesh>()
1267
- let hover = new Map<number, Mesh>()
874
+ // Instanced meshes with style writes to publish at the next sync.
875
+ let styleDirty = new Set<InstancedMesh>()
876
+ // Instanced meshes whose cull group - their live instance nodes - changed
877
+ // since the last sync: one setCullGroup per mesh however many entered
878
+ // or left.
879
+ let groupDirty = new Set<InstancedMesh>()
880
+ let instanceGroup = (mesh: InstancedMesh): NodeId[] => {
881
+ let members: NodeId[] = []
882
+ for (let n of mesh._instances.nodes.slots) if (n !== null && n._node !== null) members.push(n._node)
883
+ return members
884
+ }
1268
885
 
1269
886
  // Live meshes (those holding a draw entry) in add order; the background
1270
887
  // entry never joins this list. Draw order is derived from it by
@@ -1278,47 +895,78 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1278
895
  // light params at the end of the sync - one write, however many meshes.
1279
896
  let lights: Light[] = []
1280
897
  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.
898
+ // uLightDir and uLightPos are CORE-DRIVEN: each light's slots are
899
+ // shared-slot sinks following the node's world transform - the
900
+ // direction slot (bindDirectionSlot) with -direction as the local
901
+ // vector (the shader wants the vector TOWARD the light; a spot's axis
902
+ // the same way), the position slot (bindPositionSlot) for the
903
+ // positional types - so a light that merely moves costs no JS.
1285
904
  // 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.
905
+ // only and owns the rest: types, colors, cone/falloff params, count,
906
+ // hemisphere. The light set is scene state, so it lands on the scene
907
+ // target and every view target.
1288
908
  let vecScratch = new Float32Array(3)
909
+ // SPOT_PENUMBRA_MIN floors the cone's inner-outer cosine window so a
910
+ // hard rim (penumbra 0) is still a defined smoothstep edge pair.
911
+ const SPOT_PENUMBRA_MIN = 1e-3
1289
912
  let writeLights = () => {
1290
913
  lightsDirty = false
1291
914
  let sky: Vec3 = [0, 0, 0]
1292
915
  let ground: Vec3 = [0, 0, 0]
916
+ let types: number[] = []
1293
917
  let colors: number[] = []
918
+ let coneFalloff: number[] = []
1294
919
  let bias: number[] = []
1295
920
  let normalBias: number[] = []
921
+ let radius: number[] = []
1296
922
  let count = 0
1297
923
  for (let light of lights) {
1298
924
  if (light.type === "hemisphere") {
1299
925
  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]
926
+ let s = linearColor(light.sky)
927
+ let g = linearColor(light.ground)
928
+ sky = [s[0]! * k, s[1]! * k, s[2]! * k]
929
+ ground = [g[0]! * k, g[1]! * k, g[2]! * k]
1302
930
  continue
1303
931
  }
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
932
+ if (light.type !== "point") {
933
+ vecScratch[0] = -light.direction[0]
934
+ vecScratch[1] = -light.direction[1]
935
+ vecScratch[2] = -light.direction[2]
936
+ spatial.bindDirectionSlot(light._node!, texture, "uLightDir", MAX_LIGHTS * 3, count, vecScratch)
937
+ for (let v of views) spatial.bindDirectionSlot(light._node!, v.texture, "uLightDir", MAX_LIGHTS * 3, count, vecScratch)
938
+ }
939
+ if (light.type !== "directional") {
940
+ spatial.bindPositionSlot(light._node!, texture, "uLightPos", MAX_LIGHTS * 3, count)
941
+ for (let v of views) spatial.bindPositionSlot(light._node!, v.texture, "uLightPos", MAX_LIGHTS * 3, count)
942
+ }
943
+ types.push(light.type === "directional" ? 0 : light.type === "spot" ? 1 : 2)
944
+ let c = linearColor(light.color)
1310
945
  let k = light.intensity
1311
- colors.push(c[0] * k, c[1] * k, c[2] * k)
946
+ colors.push(c[0]! * k, c[1]! * k, c[2]! * k)
947
+ if (light.type === "spot") {
948
+ let pen = Math.max(light.penumbra, SPOT_PENUMBRA_MIN)
949
+ let rad = (light.angle * Math.PI) / 180
950
+ coneFalloff.push(Math.cos(rad * (1 - pen)), Math.cos(rad), light.distance, light.decay)
951
+ } else if (light.type === "point") {
952
+ coneFalloff.push(0, 0, light.distance, light.decay)
953
+ } else {
954
+ coneFalloff.push(0, 0, 0, 0)
955
+ }
1312
956
  bias.push(light.shadow.bias)
1313
957
  normalBias.push(light.shadow.normalBias)
958
+ radius.push(light.shadow.radius)
1314
959
  count++
1315
960
  }
1316
961
  for (let i = count; i < MAX_LIGHTS; i++) {
962
+ types.push(0)
1317
963
  colors.push(0, 0, 0)
964
+ coneFalloff.push(0, 0, 0, 0)
1318
965
  bias.push(0)
1319
966
  normalBias.push(0)
967
+ radius.push(1)
1320
968
  }
1321
- // The shadow set rides with the lights. Per directional light i: its
969
+ // The shadow set rides with the lights. Per casting light i: its
1322
970
  // map slots as uShadowFirst[i] + uShadowCount[i] (0 = a receiving
1323
971
  // material draws that light plain) and its biases; per map slot j its
1324
972
  // tile of the atlas as uShadowRect[j] in atlas UV (the whole map in
@@ -1328,12 +976,14 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1328
976
  let first: number[] = new Array(MAX_LIGHTS).fill(0)
1329
977
  let counts: number[] = new Array(MAX_LIGHTS).fill(0)
1330
978
  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) => {
979
+ let atlas = shadowSys.atlas()
980
+ let maps: Record<string, TextureId> = {
981
+ uShadowAtlas: atlas !== null ? depthTexture(atlas.texture) : shadowSys.placeholder(),
982
+ uEnv: environment ?? envPlaceholder,
983
+ }
984
+ shadowSys.forEachShadowSlot((slot, i, c, r) => {
1334
985
  if (c === 0) first[i] = slot
1335
986
  counts[i] = counts[i]! + 1
1336
- let r = shadow.rects[c]!
1337
987
  let a = atlas!
1338
988
  rects.push(r.x / a.width, r.y / a.height, r.width / a.width, r.height / a.height)
1339
989
  })
@@ -1342,29 +992,55 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1342
992
  uHemiSky: sky,
1343
993
  uHemiGround: ground,
1344
994
  uLightCount: count,
995
+ uLightType: types,
1345
996
  uLightColor: colors,
997
+ uLightParams: coneFalloff,
1346
998
  uShadowFirst: first,
1347
999
  uShadowCount: counts,
1348
1000
  uShadowBias: bias,
1349
1001
  uShadowNormalBias: normalBias,
1002
+ uShadowRadius: radius,
1350
1003
  uShadowRect: rects,
1351
1004
  }
1352
1005
  receivingTargets(t => {
1353
1006
  setTargetParams(t, params)
1354
- setTargetTextures(t, maps)
1007
+ // A probe never samples its own cube while rendering it (a same-pass
1008
+ // feedback the engine rejects): its faces see a black environment.
1009
+ setTargetTextures(t, ownsEnvironment(t) ? { ...maps, uEnv: envPlaceholder } : maps)
1355
1010
  })
1356
1011
  // A slot change (a light attached, detached or reordered) moves every
1357
1012
  // matrix too: rewrite the whole array once.
1358
- shadowMatricesDirty = true
1013
+ shadowSys.markMatricesDirty()
1359
1014
  }
1360
1015
  let orderDirty = false
1361
1016
  // The order last handed to the engine: a resort that lands on the same
1362
1017
  // permutation (the common case under a moving camera) issues nothing.
1363
1018
  let lastOrder: DrawId[] = []
1364
- let background: { entry: DrawId; pipeline: RenderPipelineId; program: ProgramId } | null = null
1019
+ // `skybox`: the entry runs the library's cube-map fragment (its params
1020
+ // and textures are rewritable in place); false = app GLSL.
1021
+ // `sky` is the skybox source (its cube and knobs) for the bake, null
1022
+ // for a GLSL background.
1023
+ // `entries` is the background's draw entry per target that draws it:
1024
+ // the scene's own and every sky view's (a reflection probe), each first
1025
+ // in its list.
1026
+ let background: { pipeline: RenderPipelineId; program: ProgramId; sky: SkyboxOptions | null; entries: Map<TextureId, DrawId> } | null = null
1027
+ // Add the current background to `target` before entry `before` (its
1028
+ // first mesh entry; undefined appends, right for a target with none yet).
1029
+ let attachBackground = (target: TextureId, before: DrawId | undefined) => {
1030
+ if (background === null) return
1031
+ let sky = background.sky
1032
+ let params = sky === null ? null : skyboxParams(sky, "scene.setBackground")
1033
+ let textures = sky === null ? undefined : { uSky: sky.cube }
1034
+ background.entries.set(target, addDraw(target, background.pipeline, params, { vertexCount: 3, before, textures }))
1035
+ }
1036
+ // The environment cube bound as uEnv on every receiving target (the
1037
+ // light rewrite seeds it on new targets); null binds the placeholder,
1038
+ // the scene's own 1x1 black cube.
1039
+ let environment: TextureId | null = null
1040
+ let envPlaceholder = createEnvironmentPlaceholder((opts?.label ?? "scene") + "-env-none")
1365
1041
  let sortEntries = () => {
1366
1042
  orderDirty = false
1367
- let order = orderEntries(meshes, camera.view, background?.entry)
1043
+ let order = orderEntries(meshes, camera.view, background?.entries.get(texture))
1368
1044
  if (order.length === lastOrder.length && order.every((id, i) => id === lastOrder[i])) return
1369
1045
  lastOrder = order
1370
1046
  setDrawOrder(texture, order)
@@ -1394,6 +1070,45 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1394
1070
  let clip: Vec4 = [0, 0, 0, 0]
1395
1071
  let pickOrigin: Vec3 = [0, 0, 0]
1396
1072
 
1073
+ // The camera ray through a scene pixel, into the pickOrigin/pickDir
1074
+ // scratches. The direction keeps a camera-forward component of 1
1075
+ // (perspective: (cx, cy, -1) in the camera frame; ortho: unit forward),
1076
+ // so origin + w * direction is the world point at camera-forward
1077
+ // distance w - what unproject() banks on. pick()/screenRay()/
1078
+ // unproject() all cast exactly this ray.
1079
+ let pixelRay = (x: number, y: number): void => {
1080
+ ensureCamera(camera, width, height)
1081
+ let v = camera.view
1082
+ let o = camera.ortho
1083
+ if (o === null) {
1084
+ // The camera-frame ray through the pixel, inverting project()'s
1085
+ // mapping: the baked y-down clip flip is why pixel y converts with
1086
+ // no negation there and one here.
1087
+ let f = 1 / Math.tan(((camera.fov * Math.PI) / 180) / 2)
1088
+ let cx = (((x / width) * 2 - 1) * (width / height)) / f
1089
+ let cy = -((y / height) * 2 - 1) / f
1090
+ // The view's upper 3x3 rows are the camera axes, so its transpose
1091
+ // carries the camera-space direction (cx, cy, -1) to world.
1092
+ pickDir[0] = cx * v[0] + cy * v[1] - v[2]
1093
+ pickDir[1] = cx * v[4] + cy * v[5] - v[6]
1094
+ pickDir[2] = cx * v[8] + cy * v[9] - v[10]
1095
+ pickOrigin[0] = camera.eye[0]
1096
+ pickOrigin[1] = camera.eye[1]
1097
+ pickOrigin[2] = camera.eye[2]
1098
+ return
1099
+ }
1100
+ // Orthographic: every ray runs along the camera's forward axis; the
1101
+ // pixel picks where on the camera plane it starts (top row = top).
1102
+ let cx = o.left + (x / width) * (o.right - o.left)
1103
+ let cy = o.top + (y / height) * (o.bottom - o.top)
1104
+ pickOrigin[0] = camera.eye[0] + cx * v[0] + cy * v[1]
1105
+ pickOrigin[1] = camera.eye[1] + cx * v[4] + cy * v[5]
1106
+ pickOrigin[2] = camera.eye[2] + cx * v[8] + cy * v[9]
1107
+ pickDir[0] = -v[2]
1108
+ pickDir[1] = -v[6]
1109
+ pickDir[2] = -v[10]
1110
+ }
1111
+
1397
1112
  // Views (scene.createView): more targets drawing the same meshes from
1398
1113
  // their own cameras. A view holds one entry per mesh in its target,
1399
1114
  // bound as one more draw sink of the mesh's core node, so the flush
@@ -1405,149 +1120,115 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1405
1120
  width: number
1406
1121
  height: number
1407
1122
  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
1123
+ /** Non-null marks a SHADOW view and names its caster set (m =>
1124
+ * m.castShadow). Re-evaluated per mesh by _setCast; also picks the
1125
+ * caster's own shadow material variant over the depth override. */
1126
+ shadowFilter: ((mesh: Mesh) => boolean) | null
1127
+ /** Layer mask: the view draws the meshes whose `layers` intersect
1128
+ * it. A shadow view's follows the scene's. */
1129
+ mask: number
1130
+ /** Names the view set itself (view.setParams, the fog option): the
1131
+ * scene's fan-out skips them so a view override survives scene-wide
1132
+ * writes. */
1133
+ ownNames: Set<string>
1411
1134
  camera: Camera
1412
1135
  entries: Map<Mesh, DrawId>
1413
1136
  orderDirty: boolean
1414
1137
  lastOrder: DrawId[]
1138
+ /** A reflection probe's target is a cube draw target of this face
1139
+ * edge, rendered face by face through probe.update(); null for a
1140
+ * 2D view. */
1141
+ cube: number | null
1142
+ /** A probe's public cube: the chain prefiltered from its target, or
1143
+ * the target itself when sharp (null for a 2D view). When it is the
1144
+ * scene's environment, the probe's own faces bind the placeholder. */
1145
+ probeCube: TextureId | null
1146
+ /** Whether the scene's background draws on this target, first in its
1147
+ * list (a probe: the sky behind the meshes); a plain view's backdrop
1148
+ * is its clearColor. */
1149
+ sky: boolean
1415
1150
  disposed: boolean
1416
1151
  }
1417
1152
  let views: ViewRecord[] = []
1418
1153
  // Every name scene.setParams has merged so far, replayed on a new view.
1419
1154
  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
1155
  // Every target a receiving material can draw into: the scene's and each
1530
1156
  // view's but the shadow views (binding a target's own depth into it
1531
1157
  // would be same-pass feedback).
1532
1158
  let receivingTargets = (fn: (target: TextureId) => void) => {
1533
1159
  fn(texture)
1534
- for (let v of views) if (v.filter === null) fn(v.texture)
1160
+ for (let v of views) if (v.shadowFilter === null) fn(v.texture)
1535
1161
  }
1162
+ // Whether receiving target `t` is the probe whose cube is the scene's
1163
+ // environment (the faces, or the chain prefiltered from them): it
1164
+ // samples the placeholder instead of itself.
1165
+ let ownsEnvironment = (t: TextureId) => environment !== null && views.some(v => v.texture === t && v.probeCube === environment)
1166
+ // The mesh's entry in the scene's OWN target - what mesh._entry is.
1167
+ // Created when the scene mask admits the mesh, dropped when it stops:
1168
+ // the same lifecycle a view entry has, so `_buffers` (not `_entry`) is
1169
+ // the attached-to-the-scene sentinel.
1170
+ let attachScene = (mesh: Mesh) => {
1171
+ if (mesh._entry !== null) return
1172
+ if ((mesh.layers & sceneMask) === 0) return
1173
+ let inst = mesh._instances
1174
+ let bufs = mesh._buffers!
1175
+ // The entry starts switched off: it has no world matrix yet - the walk
1176
+ // in sync() computes one - and _schedule() defers that to a microtask,
1177
+ // so added live it would draw at the seeded identity until then. The
1178
+ // mismatch branch in sync() turns it on in the same pass that writes
1179
+ // uModel.
1180
+ mesh._entry = addDraw(texture, mesh.material.pipeline(mesh.geometry.layout), entrySeed(mesh.material, mesh._params), {
1181
+ buffer: bufs.buffer,
1182
+ indexBuffer: bufs.index,
1183
+ indexFormat: bufs.indexFormat,
1184
+ textures: mesh._textures !== null ? { ...mesh.material.textures, ...mesh._textures } : mesh.material.textures,
1185
+ ...(inst !== null ? instanceBinding(mesh.material, inst) : {}),
1186
+ instanceCount: 0,
1187
+ })
1188
+ // The core turns the entry on (with the world matrix) at the next
1189
+ // flush, and off again whenever the node or an ancestor hides.
1190
+ spatial.bindDraw(mesh._node!, texture, mesh._entry, mesh.material.normalMatrix === true, inst !== null ? inst.count : 1)
1191
+ orderDirty = true
1192
+ }
1193
+ let detachScene = (mesh: Mesh) => {
1194
+ if (mesh._entry === null) return
1195
+ if (mesh._node !== null) spatial.unbindDraw(mesh._node, texture)
1196
+ if (!disposed) removeDraw(texture, mesh._entry)
1197
+ mesh._entry = null
1198
+ orderDirty = true
1199
+ }
1200
+ // The material a view draws a mesh with: a shadow view lets a caster's
1201
+ // material pick its own depth variant (its cull side, cutout, skinning
1202
+ // - or the class's shadowVertex, the instanced placement); any other
1203
+ // override view draws exactly what it was given.
1204
+ let viewMaterial = (v: ViewRecord, mesh: Mesh): Material =>
1205
+ v.override !== null ? (v.shadowFilter !== null ? (mesh.material.shadow ?? v.override) : v.override) : mesh.material
1536
1206
  let attachView = (v: ViewRecord, mesh: Mesh) => {
1207
+ if (v.entries.has(mesh)) return
1537
1208
  let inst = mesh._instances
1538
- if (v.override !== null && inst !== null) return
1539
- if (v.filter !== null && !v.filter(mesh)) return
1540
- // A shadow view (the filtered kind) lets a caster's material pick its
1541
- // own depth variant (its cull side); any other override view draws
1542
- // exactly what it was given.
1543
- let material = v.override !== null ? (v.filter !== null ? (mesh.material.shadow ?? v.override) : v.override) : mesh.material
1209
+ if (v.shadowFilter !== null && !v.shadowFilter(mesh)) return
1210
+ if ((mesh.layers & v.mask) === 0) return
1211
+ let material = viewMaterial(v, mesh)
1212
+ // An override pipeline cannot know a populated mesh's record layout,
1213
+ // so the mesh is skipped - unless the variant chosen above is
1214
+ // instanced itself (a class with shadowVertex, a stock material's
1215
+ // instanced depth pass), whose attributes must fit the records like
1216
+ // the main material's did at add().
1217
+ if (v.override !== null && inst !== null) {
1218
+ if (material.instanceAttributes === undefined) return
1219
+ checkInstancePairing(material, inst, "Shadow material")
1220
+ }
1544
1221
  let bufs = mesh._buffers!
1545
1222
  let entry = addDraw(v.texture, material.pipeline(mesh.geometry.layout), entrySeed(material, v.override !== null ? null : mesh._params), {
1546
1223
  buffer: bufs.buffer,
1547
1224
  indexBuffer: bufs.index,
1548
1225
  indexFormat: bufs.indexFormat,
1549
- textures: material.textures,
1550
- instanceBuffer: inst !== null ? inst.buffer : undefined,
1226
+ // The mesh's own bindings ride with its own material and with any
1227
+ // skinned stand-in (a skinned shadow variant declares uBones and
1228
+ // needs the mesh's palette); other override programs may not
1229
+ // declare the names, and per-entry bindings validate strictly.
1230
+ textures: (material === mesh.material || material.skinned === true) && mesh._textures !== null ? { ...material.textures, ...mesh._textures } : material.textures,
1231
+ ...(inst !== null ? instanceBinding(material, inst) : {}),
1551
1232
  instanceCount: 0,
1552
1233
  })
1553
1234
  spatial.bindDraw(mesh._node!, v.texture, entry, material.normalMatrix === true, inst !== null ? inst.count : 1)
@@ -1565,7 +1246,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1565
1246
  let sortView = (v: ViewRecord) => {
1566
1247
  v.orderDirty = false
1567
1248
  if (v.override !== null) return
1568
- let order = orderEntries(meshes, v.camera.view, undefined, m => v.entries.get(m as Mesh) ?? null)
1249
+ let order = orderEntries(meshes, v.camera.view, background?.entries.get(v.texture), m => v.entries.get(m as Mesh) ?? null)
1569
1250
  if (order.length === v.lastOrder.length && order.every((id, i) => id === v.lastOrder[i])) return
1570
1251
  v.lastOrder = order
1571
1252
  setDrawOrder(v.texture, order)
@@ -1573,158 +1254,140 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1573
1254
  let disposeView = (v: ViewRecord) => {
1574
1255
  if (v.disposed) return
1575
1256
  v.disposed = true
1257
+ background?.entries.delete(v.texture)
1576
1258
  for (let mesh of v.entries.keys()) if (mesh._node !== null) spatial.unbindDraw(mesh._node, v.texture)
1577
1259
  v.entries.clear()
1578
1260
  for (let light of lights) if (light.type === "directional" && light._node !== null) spatial.unbindSlot(light._node, v.texture)
1261
+ spatial.setFrustum(v.texture, null)
1579
1262
  // Drain the zeroed direction slots while the target still exists.
1580
1263
  spatial.flush()
1581
1264
  destroyTexture(v.texture)
1582
1265
  let i = views.indexOf(v)
1583
1266
  if (i >= 0) views.splice(i, 1)
1584
1267
  }
1268
+ // The six face renders of cube target `target` from `position` through
1269
+ // `cam`, a mirrored 90-degree camera (PROBE_FACE_DIRECTION and
1270
+ // PROBE_FACE_UP): each face's camera lands in the target's params,
1271
+ // `perFace` runs (a probe's sort), then the face renders. With `chain`
1272
+ // (a `mipmap: true` target, the prefilter's source) the first five faces
1273
+ // render into level 0 explicitly - an explicit level regenerates nothing
1274
+ // - and only the last face's plain render rebuilds the cube's chain: one
1275
+ // generation per six faces instead of six (a whole-cube generateMipmap
1276
+ // is the expensive step on some drivers).
1277
+ let renderCubeFaces = (target: TextureId, cam: Camera, size: number, position: Vec3, chain: boolean, perFace?: (face: number) => void) => {
1278
+ let last = PROBE_FACE_DIRECTION.length - 1
1279
+ for (let face = 0; face <= last; face++) {
1280
+ let d = PROBE_FACE_DIRECTION[face]!
1281
+ updateCamera(cam, { position, target: [position[0] + d[0], position[1] + d[1], position[2] + d[2]], up: PROBE_FACE_UP[face] })
1282
+ ensureCamera(cam, size, size)
1283
+ cam.pending = false
1284
+ setTargetParams(target, cameraParams(cam))
1285
+ perFace?.(face)
1286
+ if (chain && face < last) renderTarget(target, face, 0)
1287
+ else renderTarget(target, face)
1288
+ }
1289
+ }
1585
1290
  // A view record: the target, seeded with everything the scene target
1586
1291
  // already holds (the light set - rewritten for every target, the simple
1587
1292
  // write - the merged scene params, the shadow map binding), then one
1588
1293
  // entry per mesh the filter admits.
1589
- let makeView = (vopts: ViewOptions, filter: ((mesh: Mesh) => boolean) | null): ViewRecord => {
1294
+ let makeView = (
1295
+ vopts: ViewOptions,
1296
+ shadowFilter: ((mesh: Mesh) => boolean) | null,
1297
+ cube: number | null = null,
1298
+ mipmap = false,
1299
+ sky = false,
1300
+ ): ViewRecord => {
1590
1301
  let override = vopts.overrideMaterial ?? null
1591
1302
  if (override !== null) {
1592
1303
  for (let mesh of meshes) if (mesh._instances === null) checkLayout(override, mesh.geometry, "View override material")
1593
1304
  }
1594
1305
  let tiled = vopts.into !== undefined
1595
1306
  let v: ViewRecord = {
1596
- texture: createDrawTarget(vopts.width, vopts.height, null, {
1597
- depth: tiled ? undefined : (vopts.depth ?? true),
1598
- clearColor: vopts.clearColor,
1599
- filter: vopts.filter,
1600
- wrap: vopts.wrap,
1601
- samples: vopts.samples,
1602
- label: vopts.label ?? (opts?.label ?? "scene") + "-view",
1603
- autoFree: false,
1604
- into: vopts.into,
1605
- x: vopts.x,
1606
- y: vopts.y,
1607
- }),
1307
+ texture:
1308
+ cube !== null
1309
+ ? createCubeDrawTarget(cube, null, {
1310
+ depth: true,
1311
+ // The prefilter reads the faces at the lod of each sample's
1312
+ // solid angle: a generated chain, refreshed per face render.
1313
+ mipmap,
1314
+ // Linear radiance, HDR where the device renders half float.
1315
+ format: probeFormat(),
1316
+ clearColor: vopts.clearColor,
1317
+ label: vopts.label ?? (opts?.label ?? "scene") + "-probe",
1318
+ autoFree: false,
1319
+ })
1320
+ : createDrawTarget(vopts.width, vopts.height, null, {
1321
+ depth: tiled ? undefined : (vopts.depth ?? true),
1322
+ clearColor: vopts.clearColor,
1323
+ filter: vopts.filter,
1324
+ wrap: vopts.wrap,
1325
+ samples: vopts.samples,
1326
+ label: vopts.label ?? (opts?.label ?? "scene") + "-view",
1327
+ autoFree: false,
1328
+ into: vopts.into,
1329
+ x: vopts.x,
1330
+ y: vopts.y,
1331
+ }),
1608
1332
  width: vopts.width,
1609
1333
  height: vopts.height,
1610
1334
  override,
1611
- filter,
1335
+ shadowFilter,
1336
+ mask: shadowFilter !== null ? sceneMask : checkMask(vopts.layers ?? 1, "createView"),
1337
+ ownNames: new Set(),
1612
1338
  camera: makeCamera(),
1613
1339
  entries: new Map(),
1614
1340
  orderDirty: true,
1615
1341
  lastOrder: [],
1342
+ cube,
1343
+ probeCube: null,
1344
+ sky,
1616
1345
  disposed: false,
1617
1346
  }
1618
1347
  views.push(v)
1348
+ // The background goes in first (no mesh entry exists yet), where every
1349
+ // later sort keeps it.
1350
+ if (sky) attachBackground(v.texture, undefined)
1619
1351
  // The light rewrite seeds the shadow set (maps, casts, biases,
1620
1352
  // matrices) on the new target too.
1621
1353
  lightsDirty = true
1622
- setTargetParams(v.texture, sceneParams)
1354
+ // The view's own fog claims its names before the scene-params seed,
1355
+ // so the seed (and every later fan-out) leaves them to the view.
1356
+ let ownFog = vopts.fog !== undefined ? fogParams(vopts.fog) : null
1357
+ if (ownFog !== null) for (let k of Object.keys(ownFog)) v.ownNames.add(k)
1358
+ setTargetParams(v.texture, ownFog === null ? sceneParams : withoutNames(sceneParams, v.ownNames))
1359
+ if (ownFog !== null) setTargetParams(v.texture, ownFog)
1623
1360
  for (let mesh of meshes) attachView(v, mesh)
1624
1361
  hooks._schedule()
1625
1362
  return v
1626
1363
  }
1627
- // A shadow's views: one square tile of the shadow atlas per map drawing
1628
- // the casting meshes with the depth override from that map's frustum.
1629
- // The light rewrite writes the rects in the light's slots on every
1630
- // receiving target.
1631
- let createShadow = (light: DirectionalLight) => {
1632
- let rects = placeShadows(light)
1633
- if (rects === null || shadowAtlas === null) return
1634
- let atlas = shadowAtlas
1635
- let views = rects.map(rect =>
1636
- makeView(
1637
- {
1638
- width: rect.width,
1639
- height: rect.height,
1640
- into: atlas.texture,
1641
- x: rect.x,
1642
- y: rect.y,
1643
- overrideMaterial: shadowDepthMaterial(),
1644
- clearColor: [1, 1, 1, 1],
1645
- label: (opts?.label ?? "scene") + "-shadow",
1646
- },
1647
- m => m.castShadow,
1648
- ),
1649
- )
1650
- shadows.set(light, { light, views, lastWorld: mat4(), dirty: true, rects })
1651
- lightsDirty = true
1652
- }
1653
- let destroyShadow = (light: DirectionalLight) => {
1654
- let shadow = shadows.get(light)
1655
- if (shadow === undefined) return
1656
- shadows.delete(light)
1657
- for (let v of shadow.views) disposeView(v)
1658
- placeShadows(null)
1659
- lightsDirty = true
1660
- hooks._schedule()
1661
- }
1662
- // Place a shadow's cameras from its light's world matrix. A box light:
1663
- // at its world position, looking along its world direction, the light
1664
- // frustum as the orthographic extents. Compared against the matrix it
1665
- // was last placed from, so a scene animating elsewhere rewrites nothing
1666
- // here. A cascaded light also follows the scene camera (`cameraMoved`).
1667
- let cascadeScratch = mat4()
1668
- let cascadeCenter: Vec3 = [0, 0, 0]
1669
- let placeShadowCamera = (shadow: Shadow, cameraMoved: boolean) => {
1670
- let light = shadow.light
1671
- let m = worldInto(worldScratch, light)
1672
- let cascaded = shadow.views.length > 1
1673
- if (!shadow.dirty && !(cascaded && cameraMoved) && m.every((x, i) => x === shadow.lastWorld[i])) return
1674
- shadow.dirty = false
1675
- copy(shadow.lastWorld, m)
1676
- transformVector(shadowDir, m, light.direction)
1677
- let len = Math.hypot(shadowDir[0], shadowDir[1], shadowDir[2]) || 1
1678
- let d: Vec3 = [shadowDir[0] / len, shadowDir[1] / len, shadowDir[2] / len]
1679
- // A sun straight down is the common case and the degenerate one for
1680
- // world up: roll about z then (the map's orientation is invisible).
1681
- let up: Vec3 = Math.abs(d[1]) > VERTICAL_LIGHT ? [0, 0, 1] : [0, 1, 0]
1682
- if (!cascaded) {
1683
- let c = light.shadow.camera
1684
- updateCamera(shadow.views[0]!.camera, {
1685
- position: [m[12], m[13], m[14]],
1686
- target: [m[12] + d[0], m[13] + d[1], m[14] + d[2]],
1687
- up,
1688
- ortho: { left: c.left, right: c.right, top: c.top, bottom: c.bottom },
1689
- near: c.near,
1690
- far: c.far,
1691
- })
1692
- return
1693
- }
1694
- // Cascades: the scene camera's range near..far (far capped by
1695
- // shadow.distance) sliced by cascadeSplit, each slice's bounding
1696
- // sphere (frustumSliceSphere) as an orthographic box looking along
1697
- // the light, its centre snapped to the map's texel grid in light
1698
- // space (snapToGrid) so the shadow edges do not swim as the camera
1699
- // moves. The box reaches back toward the light by the whole range,
1700
- // so a caster outside the slice still casts into it.
1701
- let n = shadow.views.length
1702
- let near = camera.near
1703
- let far = Math.min(camera.far, light.shadow.distance ?? Infinity)
1704
- if (!(far > near)) far = near + 1
1705
- // The light's rotation only: rows are its right, up and back axes.
1706
- let basis = lookAtMatrix(cascadeScratch, [0, 0, 0], d, up)
1707
- let aspect = width / height
1708
- let zn = near
1709
- for (let c = 0; c < n; c++) {
1710
- let zf = cascadeSplit(near, far, c, n, CASCADE_SPLIT_LAMBDA)
1711
- let radius = frustumSliceSphere(cascadeCenter, camera, aspect, zn, zf)
1712
- zn = zf
1713
- let view = shadow.views[c]!
1714
- // A texel is 2r / mapSize world units.
1715
- snapToGrid(cascadeCenter, cascadeCenter, basis, (2 * radius) / view.width)
1716
- let back = radius + far
1717
- updateCamera(view.camera, {
1718
- position: [cascadeCenter[0] - d[0] * back, cascadeCenter[1] - d[1] * back, cascadeCenter[2] - d[2] * back],
1719
- target: cascadeCenter,
1720
- up,
1721
- ortho: { left: -radius, right: radius, top: radius, bottom: -radius },
1722
- near: 0,
1723
- far: back + radius,
1724
- })
1725
- }
1726
- }
1364
+ // The shadow subsystem: the atlas, the per-caster shadow views (made
1365
+ // through makeView above) and their map cameras. The deps close over
1366
+ // this scene instance; writeLights reads the dealt slots back through
1367
+ // forEachShadowSlot/atlas and sync drives placeCameras/flushMatrices.
1368
+ let shadowSys = makeShadowSystem({
1369
+ lights,
1370
+ camera,
1371
+ targetSize: () => ({ width, height }),
1372
+ label: opts?.label ?? "scene",
1373
+ makeView,
1374
+ disposeView,
1375
+ markLightsDirty: () => {
1376
+ lightsDirty = true
1377
+ },
1378
+ schedule: () => hooks._schedule(),
1379
+ })
1727
1380
 
1381
+ // A camera write is also the target's frustum for the core's culling:
1382
+ // the scene's own, every view's, every shadow tile's - set before the
1383
+ // flush below, which switches the entries outside it off. Cube face
1384
+ // renders (probes) set no frustum: six cameras share one target and the
1385
+ // core would only ever see the last.
1386
+ let frustumScratch = new Float32Array(16)
1387
+ let setFrustum = (target: TextureId, cam: Camera) => {
1388
+ frustumScratch.set(cam.viewProj)
1389
+ spatial.setFrustum(target, frustumScratch)
1390
+ }
1728
1391
  let sync = () => {
1729
1392
  scheduled = false
1730
1393
  if (disposed) return
@@ -1733,35 +1396,39 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1733
1396
  if (camera.pending) {
1734
1397
  camera.pending = false
1735
1398
  setTargetParams(texture, cameraParams(camera))
1399
+ setFrustum(texture, camera)
1736
1400
  if (transparentCount > 1) orderDirty = true
1737
1401
  }
1738
- for (let shadow of shadows.values()) placeShadowCamera(shadow, cameraMoved)
1402
+ shadowSys.placeCameras(cameraMoved)
1739
1403
  for (let v of views) {
1740
1404
  ensureCamera(v.camera, v.width, v.height)
1741
1405
  if (v.camera.pending) {
1742
1406
  v.camera.pending = false
1743
1407
  setTargetParams(v.texture, cameraParams(v.camera))
1408
+ setFrustum(v.texture, v.camera)
1744
1409
  if (transparentCount > 1) v.orderDirty = true
1745
- if (v.filter !== null) shadowMatricesDirty = true
1410
+ if (v.shadowFilter !== null) shadowSys.markMatricesDirty()
1746
1411
  }
1747
1412
  }
1748
1413
  // Light bookkeeping first, so a fresh direction-slot bind is seeded
1749
1414
  // by the flush below in the same sync.
1750
1415
  if (lightsDirty) writeLights()
1416
+ // The instanced meshes' style writes since the last sync: one
1417
+ // coalesced buffer write per mesh.
1418
+ if (styleDirty.size > 0) {
1419
+ for (let m of styleDirty) if (m._instances !== null) publishInstanceStyle(m)
1420
+ styleDirty.clear()
1421
+ }
1422
+ // An instanced mesh without explicit bounds is culled by the union of
1423
+ // its instances' boxes: the core's cull group (a skinned part's joint
1424
+ // group, generalized) over the live instance nodes.
1425
+ if (groupDirty.size > 0) {
1426
+ for (let m of groupDirty) if (m._node !== null && m._buffers !== null && m._instances !== null) spatial.setCullGroup(m._node, instanceGroup(m))
1427
+ groupDirty.clear()
1428
+ }
1751
1429
  // The matrices that render the maps are the ones receivers look up
1752
1430
  // with: one array to every receiving target per shadow-camera move.
1753
- if (shadowMatricesDirty) {
1754
- shadowMatricesDirty = false
1755
- let dealt = 0
1756
- forEachShadowSlot((slot, _i, shadow, c) => {
1757
- let m = shadow.views[c]!.camera.viewProj
1758
- for (let k = 0; k < 16; k++) shadowMatrices[slot * 16 + k] = m[k]!
1759
- dealt = slot + 1
1760
- })
1761
- for (let slot = dealt; slot < MAX_SHADOW_MAPS; slot++) for (let k = 0; k < 16; k++) shadowMatrices[slot * 16 + k] = IDENTITY[k]!
1762
- let params: ShaderParams = { uShadowMatrix: shadowMatrices }
1763
- receivingTargets(t => setTargetParams(t, params))
1764
- }
1431
+ shadowSys.flushMatrices(params => receivingTargets(t => setTargetParams(t, params)))
1765
1432
  // The core recomputes the moved subtrees and writes every entry's
1766
1433
  // uModel/uNormal, visibility switch and direction slots.
1767
1434
  spatial.flush()
@@ -1791,49 +1458,39 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1791
1458
  },
1792
1459
  _attachLight(light) {
1793
1460
  if (disposed) return
1794
- if (light.type === "directional" && lights.filter(l => l.type === "directional").length >= MAX_LIGHTS) {
1795
- throw new Error("A scene takes at most " + MAX_LIGHTS + " directional lights")
1461
+ if (light.type !== "hemisphere" && lights.filter(l => l.type !== "hemisphere").length >= MAX_LIGHTS) {
1462
+ throw new Error("A scene takes at most " + MAX_LIGHTS + " lights (directional, spot and point together)")
1796
1463
  }
1797
1464
  lights.push(light)
1798
1465
  lightsDirty = true
1799
- if (light.type === "directional" && light.castShadow) createShadow(light)
1466
+ if (light.type !== "hemisphere" && light.castShadow) shadowSys.createShadow(light)
1800
1467
  },
1801
1468
  _detachLight(light) {
1802
1469
  let i = lights.indexOf(light)
1803
1470
  if (i >= 0) lights.splice(i, 1)
1804
1471
  lightsDirty = true
1805
- if (light.type === "directional") destroyShadow(light)
1472
+ if (light.type !== "hemisphere") shadowSys.destroyShadow(light)
1806
1473
  hooks._schedule()
1807
1474
  },
1808
1475
  _shadowChanged(light) {
1809
1476
  if (disposed) return
1810
- let shadow = shadows.get(light)
1811
- if (shadow !== undefined) {
1812
- if (!light.castShadow) {
1813
- destroyShadow(light)
1814
- return
1815
- }
1816
- // A cascade count change is a different view set: rebuild it. A
1817
- // mapSize change re-places every tile (the grid cell follows the
1818
- // largest map).
1819
- if (shadow.views.length !== light.shadow.cascades) {
1820
- destroyShadow(light)
1821
- createShadow(light)
1822
- return
1823
- }
1824
- placeShadows(null)
1825
- shadow.dirty = true
1826
- lightsDirty = true
1827
- hooks._schedule()
1828
- } else if (light.castShadow) {
1829
- createShadow(light)
1830
- }
1477
+ shadowSys.shadowChanged(light)
1831
1478
  },
1832
1479
  _setCast(mesh) {
1833
- if (mesh._entry === null || disposed) return
1480
+ if (mesh._buffers === null || disposed) return
1834
1481
  for (let v of views) {
1835
- if (v.filter === null) continue
1836
- if (v.filter(mesh)) attachView(v, mesh)
1482
+ if (v.shadowFilter === null) continue
1483
+ if (v.shadowFilter(mesh)) attachView(v, mesh)
1484
+ else detachView(v, mesh)
1485
+ }
1486
+ hooks._schedule()
1487
+ },
1488
+ _setLayers(mesh) {
1489
+ if (mesh._buffers === null || disposed) return
1490
+ if ((mesh.layers & sceneMask) !== 0) attachScene(mesh)
1491
+ else detachScene(mesh)
1492
+ for (let v of views) {
1493
+ if ((mesh.layers & v.mask) !== 0) attachView(v, mesh)
1837
1494
  else detachView(v, mesh)
1838
1495
  }
1839
1496
  hooks._schedule()
@@ -1852,53 +1509,59 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1852
1509
  if (v.override !== null && mesh._instances === null) checkLayout(v.override, mesh.geometry, "View override material")
1853
1510
  }
1854
1511
  // Instancing pairs the same way layout does: the pipeline's instance
1855
- // attributes describe the mesh's record buffer, so one without the
1512
+ // attributes describe the mesh's record buffers, so one without the
1856
1513
  // other (or a record stride from a different attribute list) would
1857
1514
  // bind garbage - errors here, at add().
1858
1515
  let inst = mesh._instances
1859
- let instAttrs = mesh.material.instanceAttributes
1860
- if (instAttrs !== undefined && inst === null) {
1516
+ if (mesh.material.instanceAttributes !== undefined && inst === null) {
1861
1517
  throw new Error(
1862
- "Material declares instanceAttributes - create its meshes with createInstancedMesh (records included), not createMesh",
1518
+ "Material declares instanceAttributes - create its meshes with createInstancedMesh or createRecordMesh, not createMesh",
1863
1519
  )
1864
1520
  }
1865
- if (inst !== null) {
1866
- if (instAttrs === undefined) {
1867
- throw new Error("Instanced mesh with a non-instanced material - the material must declare instanceAttributes")
1868
- }
1869
- let stride = instanceStride(instAttrs)
1870
- if (stride !== inst.stride) {
1871
- throw new Error(
1872
- "Instanced mesh records are " + inst.stride + " floats but the material's instanceAttributes take " + stride,
1873
- )
1874
- }
1875
- }
1521
+ if (inst !== null) checkInstancePairing(mesh.material, inst, "Mesh material")
1876
1522
  let bufs = acquireGeometryBuffers(mesh.geometry)
1877
1523
  mesh._buffers = bufs
1878
- // The entry starts switched off: it has no world matrix yet - the walk
1879
- // in sync() computes one - and _schedule() defers that to a microtask,
1880
- // so added live it would draw at the seeded identity until then. The
1881
- // mismatch branch in sync() turns it on in the same pass that writes
1882
- // uModel.
1883
- mesh._entry = addDraw(texture, mesh.material.pipeline(mesh.geometry.layout), entrySeed(mesh.material, mesh._params), {
1884
- buffer: bufs.buffer,
1885
- indexBuffer: bufs.index,
1886
- indexFormat: bufs.indexFormat,
1887
- textures: mesh.material.textures,
1888
- instanceBuffer: inst !== null ? inst.buffer : undefined,
1889
- instanceCount: 0,
1890
- })
1891
- // The core turns the entry on (with the world matrix) at the next
1892
- // flush, and off again whenever the node or an ancestor hides.
1893
- spatial.bindDraw(mesh._node!, texture, mesh._entry, mesh.material.normalMatrix === true, inst !== null ? inst.count : 1)
1524
+ attachScene(mesh)
1894
1525
  for (let v of views) attachView(v, mesh)
1526
+ // The style mirror may have been written while the mesh was out of
1527
+ // a scene (or into a buffer this scene never saw): republish it.
1528
+ if (inst !== null && inst.style !== null) {
1529
+ inst.style.dirty = inst.count > 0 ? [0, inst.count * inst.style.stride] : null
1530
+ if (inst.style.dirty !== null) styleDirty.add(mesh as InstancedMesh)
1531
+ }
1895
1532
  // Picking: the local box puts the node in the core index; an
1896
- // ordinary mesh also gets its geometry's triangle shape, an
1897
- // instanced one is box-only (records are opaque, and without
1898
- // explicit bounds it is not picked at all), as is a sprite (its
1899
- // triangles lie wherever the camera is, not where the geometry says).
1533
+ // ordinary mesh also gets its geometry's triangle shape, a
1534
+ // populated one is box-only (its instances are the leaves that
1535
+ // pick, each with the shape; a record mesh's records are opaque, so
1536
+ // without explicit bounds it is not picked at all), as is a sprite
1537
+ // (its triangles lie wherever the camera is, not where the geometry
1538
+ // says).
1900
1539
  spatial.setBounds(mesh._node!, localBounds(mesh))
1901
1540
  spatial.setShape(mesh._node!, inst === null && !mesh._sprite ? bufs.shape : null)
1541
+ // A rebuilt entry (setGeometry) re-boxes the live instances to the
1542
+ // new geometry; their record bindings are untouched. On a first
1543
+ // entry none has a core node yet - they enter after the mesh.
1544
+ if (inst !== null && inst.nodes !== null) {
1545
+ for (let n of inst.nodes.slots) {
1546
+ if (n !== null && n._node !== null) {
1547
+ spatial.setBounds(n._node, geometryBounds(mesh.geometry))
1548
+ spatial.setShape(n._node, bufs.shape)
1549
+ }
1550
+ }
1551
+ }
1552
+ // Culling: the gate and margin only when off the defaults, and a
1553
+ // skinned part's joint group (joints entered before it: a model
1554
+ // adds its node tree before its parts).
1555
+ if (!mesh.frustumCulled || mesh.cullMargin !== 0) spatial.setCull(mesh._node!, mesh.frustumCulled, mesh.cullMargin)
1556
+ if (mesh._cullJoints !== null) {
1557
+ let joints: NodeId[] = []
1558
+ for (let j of mesh._cullJoints) if (j._node !== null) joints.push(j._node)
1559
+ spatial.setCullGroup(mesh._node!, joints)
1560
+ }
1561
+ // An instanced mesh without explicit bounds is culled by its
1562
+ // instances' union, the same group over the instance nodes - set
1563
+ // at the sync, once they have entered (they enter after the mesh).
1564
+ if (inst !== null && inst.nodes !== null && inst.bounds === null) groupDirty.add(mesh as InstancedMesh)
1902
1565
  byNode.set(mesh._node!, mesh)
1903
1566
  meshes.push(mesh)
1904
1567
  mesh._transparent = mesh.material.transparent === true
@@ -1907,16 +1570,17 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1907
1570
  this._schedule()
1908
1571
  },
1909
1572
  _detach(mesh) {
1910
- if (mesh._entry !== null) {
1573
+ if (mesh._buffers !== null) {
1574
+ styleDirty.delete(mesh as InstancedMesh)
1575
+ groupDirty.delete(mesh as InstancedMesh)
1911
1576
  for (let v of views) detachView(v, mesh)
1577
+ detachScene(mesh)
1912
1578
  if (mesh._node !== null) {
1913
1579
  spatial.setShape(mesh._node, null)
1914
1580
  spatial.setBounds(mesh._node, null)
1915
- spatial.unbindDraw(mesh._node, texture)
1916
1581
  byNode.delete(mesh._node)
1917
1582
  }
1918
- if (!disposed) removeDraw(texture, mesh._entry)
1919
- if (mesh._buffers !== null) releaseGeometryBuffers(mesh._buffers)
1583
+ releaseGeometryBuffers(mesh._buffers)
1920
1584
  mesh._buffers = null
1921
1585
  let i = meshes.indexOf(mesh)
1922
1586
  if (i >= 0) meshes.splice(i, 1)
@@ -1925,9 +1589,35 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1925
1589
  }
1926
1590
  mesh._entry = null
1927
1591
  },
1592
+ _attachInstance(instance) {
1593
+ if (disposed) return
1594
+ let mesh = instance.mesh
1595
+ // The mesh entered first (parents enter before children), so its
1596
+ // geometry buffers and core node are live - a mesh masked out of
1597
+ // the scene included, whose instances still pick.
1598
+ if (mesh === null || mesh._buffers === null || mesh._node === null || instance._node === null) return
1599
+ spatial.setBounds(instance._node, geometryBounds(mesh.geometry))
1600
+ spatial.setShape(instance._node, mesh._buffers.shape)
1601
+ // The record is the instance's placement inside the mesh (the mesh
1602
+ // node is the anchor), staged by the core at the next flush.
1603
+ spatial.bindMatrixRecord(instance._node, mesh._instances.buffer, instance._slot, mesh._node)
1604
+ byNode.set(instance._node, instance)
1605
+ if (mesh._instances.bounds === null) groupDirty.add(mesh)
1606
+ this._schedule()
1607
+ },
1608
+ _detachInstance(instance) {
1609
+ // The core node is destroyed right after: its box, shape and record
1610
+ // binding go with it, and the slot hides at the next flush. The
1611
+ // mesh's group loses the member at the sync (a mesh leaving with
1612
+ // its instances is skipped there: its node is gone by then).
1613
+ if (instance._node !== null) byNode.delete(instance._node)
1614
+ let mesh = instance.mesh
1615
+ if (mesh !== null && mesh._instances !== null && mesh._instances.bounds === null) groupDirty.add(mesh)
1616
+ this._schedule()
1617
+ },
1928
1618
  _setParams(mesh, params) {
1929
- if (mesh._entry === null || disposed) return
1930
- setDrawParams(texture, mesh._entry, params)
1619
+ if (mesh._buffers === null || disposed) return
1620
+ if (mesh._entry !== null) setDrawParams(texture, mesh._entry, params)
1931
1621
  // A view drawing the mesh's own material carries its params too; an
1932
1622
  // overridden view has none of them.
1933
1623
  for (let v of views) {
@@ -1938,22 +1628,29 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1938
1628
  _setCount(mesh) {
1939
1629
  // The core composes the count with the visibility switch: a hidden
1940
1630
  // entry stays at 0 and the unhide restores the new count.
1941
- if (mesh._entry !== null && mesh._node !== null && !disposed && mesh._instances !== null) {
1631
+ if (mesh._buffers !== null && mesh._node !== null && !disposed && mesh._instances !== null) {
1942
1632
  spatial.setDrawCount(mesh._node, mesh._instances.count)
1943
1633
  }
1944
1634
  },
1945
1635
  _setBuffer(mesh) {
1946
- // The entry keeps its range (at most the old capacity, so the larger
1947
- // buffer always passes the swap's bounds check); the caller destroys
1948
- // the old buffer after this, which the entry held alive until now.
1949
- if (mesh._entry !== null && !disposed && mesh._instances !== null) {
1950
- setDrawBuffers(texture, mesh._entry, { instanceBuffer: mesh._instances.buffer })
1636
+ // The entries keep their range (at most the old capacity, so the
1637
+ // larger buffers always pass the swap's bounds check); the caller
1638
+ // destroys the old buffers after this, which the entries held alive
1639
+ // until now. Each entry swaps the slots its own material binds.
1640
+ let inst = mesh._instances
1641
+ if (mesh._buffers !== null && !disposed && inst !== null) {
1642
+ if (mesh._entry !== null) setDrawBuffers(texture, mesh._entry, instanceBinding(mesh.material, inst))
1951
1643
  for (let v of views) {
1952
1644
  let entry = v.entries.get(mesh)
1953
- if (entry !== undefined) setDrawBuffers(v.texture, entry, { instanceBuffer: mesh._instances.buffer })
1645
+ if (entry !== undefined) setDrawBuffers(v.texture, entry, instanceBinding(viewMaterial(v, mesh), inst))
1954
1646
  }
1955
1647
  }
1956
1648
  },
1649
+ _setStyle(mesh) {
1650
+ if (disposed || mesh._buffers === null) return
1651
+ styleDirty.add(mesh)
1652
+ this._schedule()
1653
+ },
1957
1654
  _reorder() {
1958
1655
  orderDirty = true
1959
1656
  for (let v of views) v.orderDirty = true
@@ -1977,134 +1674,23 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1977
1674
  lightsDirty = true
1978
1675
  hooks._schedule()
1979
1676
 
1980
- // --- Pointer event dispatch (behind scene.handlers) ---
1981
-
1982
- type BubbleName = "onPointerDown" | "onPointerMove" | "onPointerUp"
1983
- type InternalEvent = ScenePointerEvent & { _stopped: boolean }
1984
-
1985
- let makeEvent = (e: ElementPointerEvent, mesh: Mesh, x: number, y: number, point: Vec3 | null, distance: number | null): InternalEvent => {
1986
- let event: InternalEvent = {
1987
- mesh,
1988
- currentTarget: mesh,
1989
- point,
1990
- distance,
1991
- x,
1992
- y,
1993
- pointerId: e.pointerId,
1994
- pointerType: e.pointerType,
1995
- button: e.button,
1996
- shiftKey: e.shiftKey,
1997
- ctrlKey: e.ctrlKey,
1998
- altKey: e.altKey,
1999
- metaKey: e.metaKey,
2000
- _stopped: false,
2001
- stopPropagation() {
2002
- event._stopped = true
2003
- },
2004
- }
2005
- return event
2006
- }
2007
-
2008
- let bubble = (name: BubbleName, event: InternalEvent): void => {
2009
- for (let n: SceneNode | null = event.mesh; n !== null && !event._stopped; n = n.parent) {
2010
- let handler = n[name]
2011
- if (handler) {
2012
- event.currentTarget = n
2013
- handler(event)
2014
- }
2015
- }
2016
- }
2017
-
2018
- // The captured mesh's own hit, if the ray still strikes it.
2019
- let hitOn = (mesh: Mesh, x: number, y: number): Hit | null => {
2020
- for (let h of scene.pick(x, y)) if (h.mesh === mesh) return h
2021
- return null
2022
- }
2023
-
2024
- // localX/localY arrive in the leaf's LAYOUT frame (the hit test undoes
2025
- // every transform above it, design-size fits included), so a leaf laid out at
2026
- // the target size - the built-in <Scene> leaf, a d-texture at natural
2027
- // size - is already in scene pixels. Only a leaf deliberately laid out at
2028
- // a DIFFERENT size (the supersampling pattern) needs the ratio, and only
2029
- // the app knows that layout: handlersFor takes it.
2030
- let makeHandlers = (layout: (() => { width: number; height: number }) | null): SceneHandlers => {
2031
- let eventX = 0
2032
- let eventY = 0
2033
- let toScene = (e: ElementPointerEvent): void => {
2034
- if (layout === null) {
2035
- eventX = e.localX
2036
- eventY = e.localY
2037
- return
2038
- }
2039
- let l = layout()
2040
- eventX = e.localX * (l.width > 0 ? width / l.width : 1)
2041
- eventY = e.localY * (l.height > 0 ? height / l.height : 1)
2042
- }
2043
- return {
2044
- onPointerDown(e) {
2045
- toScene(e)
2046
- let hit = scene.pick(eventX, eventY)[0]
2047
- if (hit === undefined) return
2048
- capture.set(e.pointerId, hit.mesh)
2049
- bubble("onPointerDown", makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
2050
- },
2051
- onPointerMove(e) {
2052
- toScene(e)
2053
- let captured = capture.get(e.pointerId)
2054
- if (captured !== undefined) {
2055
- let hit = hitOn(captured, eventX, eventY)
2056
- bubble("onPointerMove", makeEvent(e, captured, eventX, eventY, hit ? hit.point : null, hit ? hit.distance : null))
2057
- return
2058
- }
2059
- let hit = scene.pick(eventX, eventY)[0]
2060
- let prev = hover.get(e.pointerId)
2061
- if (prev !== hit?.mesh) {
2062
- if (prev !== undefined) {
2063
- hover.delete(e.pointerId)
2064
- prev.onPointerLeave?.(makeEvent(e, prev, eventX, eventY, null, null))
2065
- }
2066
- if (hit !== undefined) {
2067
- hover.set(e.pointerId, hit.mesh)
2068
- hit.mesh.onPointerEnter?.(makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
2069
- }
2070
- }
2071
- if (hit !== undefined) {
2072
- bubble("onPointerMove", makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
2073
- }
2074
- },
2075
- onPointerUp(e) {
2076
- toScene(e)
2077
- let captured = capture.get(e.pointerId)
2078
- if (captured !== undefined) {
2079
- capture.delete(e.pointerId)
2080
- let hit = hitOn(captured, eventX, eventY)
2081
- bubble("onPointerUp", makeEvent(e, captured, eventX, eventY, hit ? hit.point : null, hit ? hit.distance : null))
2082
- return
2083
- }
2084
- let hit = scene.pick(eventX, eventY)[0]
2085
- if (hit !== undefined) {
2086
- bubble("onPointerUp", makeEvent(e, hit.mesh, eventX, eventY, hit.point, hit.distance))
2087
- }
2088
- },
2089
- onPointerLeave(e) {
2090
- let prev = hover.get(e.pointerId)
2091
- if (prev !== undefined) {
2092
- hover.delete(e.pointerId)
2093
- toScene(e)
2094
- prev.onPointerLeave?.(makeEvent(e, prev, eventX, eventY, null, null))
2095
- }
2096
- },
2097
- }
2098
- }
2099
- let handlers = makeHandlers(null)
1677
+ // Pointer dispatch (scene.handlers): capture/hover bookkeeping and the
1678
+ // bubble walk live in scene-pointer.ts; all it needs of the scene is
1679
+ // pick() and the target size.
1680
+ let pointer = makePointerInput({
1681
+ pick: (x, y) => scene.pick(x, y),
1682
+ targetSize: () => ({ width, height }),
1683
+ })
2100
1684
 
2101
1685
  let scene: Scene = {
2102
1686
  texture,
1687
+ depthTexture: depthMode === "texture" ? depthTexture(texture) : null,
2103
1688
  root,
2104
1689
  setCamera(update) {
2105
1690
  updateCamera(camera, update)
2106
1691
  hooks._schedule()
2107
1692
  },
1693
+ camera: () => cameraState(camera),
2108
1694
  setSize(w, h) {
2109
1695
  if (disposed || (w === width && h === height)) return
2110
1696
  width = w
@@ -2117,22 +1703,85 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2117
1703
  if (disposed) return
2118
1704
  Object.assign(sceneParams, params)
2119
1705
  setTargetParams(texture, params)
2120
- for (let v of views) setTargetParams(v.texture, params)
1706
+ for (let v of views) {
1707
+ // A view's own names (view.setParams, its fog) win over the
1708
+ // scene-wide fan-out.
1709
+ let fanned = v.ownNames.size === 0 ? params : withoutNames(params, v.ownNames)
1710
+ if (fanned !== params && Object.keys(fanned).length === 0) continue
1711
+ setTargetParams(v.texture, fanned)
1712
+ }
1713
+ },
1714
+ setFog(fog) {
1715
+ scene.setParams(fogParams(fog))
1716
+ },
1717
+ setLayers(mask) {
1718
+ checkMask(mask, "scene.setLayers")
1719
+ if (disposed || sceneMask === mask) return
1720
+ sceneMask = mask
1721
+ for (let mesh of meshes) {
1722
+ if ((mesh.layers & mask) !== 0) attachScene(mesh)
1723
+ else detachScene(mesh)
1724
+ }
1725
+ // Shadow views follow the scene's mask: what the scene cannot see
1726
+ // must not darken it.
1727
+ for (let v of views) {
1728
+ if (v.shadowFilter === null) continue
1729
+ v.mask = mask
1730
+ for (let mesh of meshes) {
1731
+ if ((mesh.layers & mask) !== 0) attachView(v, mesh)
1732
+ else detachView(v, mesh)
1733
+ }
1734
+ }
1735
+ hooks._schedule()
2121
1736
  },
2122
1737
  setBackground(source) {
2123
1738
  if (disposed) return
1739
+ // A skybox replacing a skybox keeps its entries: same program, new
1740
+ // params and cube on every target.
1741
+ if (source !== null && typeof source !== "string" && background !== null && background.sky !== null) {
1742
+ let params = skyboxParams(source, "scene.setBackground")
1743
+ for (let [target, entry] of background.entries) {
1744
+ setDrawParams(target, entry, params)
1745
+ setDrawTextures(target, entry, { uSky: source.cube })
1746
+ }
1747
+ background.sky = source
1748
+ return
1749
+ }
1750
+ let sky = source === null || typeof source === "string" ? null : source
1751
+ if (sky !== null) skyboxParams(sky, "scene.setBackground")
2124
1752
  if (background !== null) {
2125
- removeDraw(texture, background.entry)
1753
+ for (let [target, entry] of background.entries) removeDraw(target, entry)
2126
1754
  destroyRenderPipeline(background.pipeline)
2127
1755
  destroyProgram(background.program)
2128
1756
  background = null
2129
1757
  }
2130
1758
  if (source === null) return
2131
- let built = backgroundPipeline(source, (opts?.label ?? "scene") + "-background")
2132
- // First in list order: inserted before the first mesh entry, and every
1759
+ let label = opts?.label ?? "scene"
1760
+ let built = sky === null ? backgroundPipeline(source as string, label + "-background") : backgroundPipeline(SKYBOX_FRAGMENT, label + "-skybox")
1761
+ background = { pipeline: built.pipeline, program: built.program, sky, entries: new Map() }
1762
+ // First in list order on every target that draws it: inserted before
1763
+ // the first mesh ENTRY (a layers-masked mesh has none), and every
2133
1764
  // later sort keeps it there.
2134
- let entry = addDraw(texture, built.pipeline, null, { vertexCount: 3, before: meshes[0]?._entry ?? undefined })
2135
- background = { entry, pipeline: built.pipeline, program: built.program }
1765
+ attachBackground(texture, meshes.find(m => m._entry !== null)?._entry ?? undefined)
1766
+ for (let v of views) if (v.sky && !v.disposed) attachBackground(v.texture, v.lastOrder[0] ?? v.entries.values().next().value)
1767
+ },
1768
+ setEnvironment(env) {
1769
+ if (disposed) return
1770
+ let params = environmentParams(env)
1771
+ environment = env === null ? null : env.cube
1772
+ let cube = environment ?? envPlaceholder
1773
+ // A probe's own faces never sample the probe (see writeLights).
1774
+ receivingTargets(t => setTargetTextures(t, { uEnv: ownsEnvironment(t) ? envPlaceholder : cube }))
1775
+ scene.setParams(params)
1776
+ },
1777
+ setToneMapping(mode) {
1778
+ let code = TONE_MAPPING_CODE[mode]
1779
+ if (code === undefined) throw new Error('scene.setToneMapping: expected "none" or "aces", got ' + mode)
1780
+ scene.setParams({ uToneMapping: code })
1781
+ },
1782
+ setExposure(exposure) {
1783
+ if (!Number.isFinite(exposure) || exposure < 0) throw new Error("scene.setExposure: expected a finite number >= 0, got " + exposure)
1784
+ scene.setParams({ uExposure: exposure })
2136
1785
  },
2137
1786
  project(point) {
2138
1787
  ensureCamera(camera, width, height)
@@ -2140,50 +1789,48 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2140
1789
  let w = clip[3]
2141
1790
  if (w < 1e-6) return null
2142
1791
  // perspective() bakes the y-down clip flip, so NDC maps straight to
2143
- // top-left-origin pixels with no negation here. (An orthographic
2144
- // camera has w = 1 everywhere: every point projects.)
2145
- return { x: ((clip[0] / w) * 0.5 + 0.5) * width, y: ((clip[1] / w) * 0.5 + 0.5) * height, w }
1792
+ // top-left-origin pixels with no negation here.
1793
+ let x = ((clip[0] / w) * 0.5 + 0.5) * width
1794
+ let y = ((clip[1] / w) * 0.5 + 0.5) * height
1795
+ if (camera.ortho !== null) {
1796
+ // An orthographic clip w is 1 everywhere (every point projects, the
1797
+ // divides above are no-ops) and carries no depth, so `w` reports
1798
+ // the camera-forward distance off the view row instead - the same
1799
+ // meaning as the perspective clip w, and unproject's exact input.
1800
+ let v = camera.view
1801
+ w = -(v[2] * point[0] + v[6] * point[1] + v[10] * point[2] + v[14])
1802
+ }
1803
+ return { x, y, w }
2146
1804
  },
2147
1805
  viewProj(out) {
2148
1806
  ensureCamera(camera, width, height)
2149
1807
  return copy(out ?? mat4(), camera.viewProj)
2150
1808
  },
2151
1809
  pick(x, y) {
2152
- ensureCamera(camera, width, height)
2153
- let v = camera.view
2154
- let o = camera.ortho
2155
- if (o === null) {
2156
- // The camera-frame ray through the pixel, inverting project()'s
2157
- // mapping: the baked y-down clip flip is why pixel y converts with
2158
- // no negation there and one here.
2159
- let f = 1 / Math.tan(((camera.fov * Math.PI) / 180) / 2)
2160
- let cx = (((x / width) * 2 - 1) * (width / height)) / f
2161
- let cy = -((y / height) * 2 - 1) / f
2162
- // The view's upper 3x3 rows are the camera axes, so its transpose
2163
- // carries the camera-space direction (cx, cy, -1) to world.
2164
- pickDir[0] = cx * v[0] + cy * v[1] - v[2]
2165
- pickDir[1] = cx * v[4] + cy * v[5] - v[6]
2166
- pickDir[2] = cx * v[8] + cy * v[9] - v[10]
2167
- return scene.raycast(camera.eye, pickDir)
2168
- }
2169
- // Orthographic: every ray runs along the camera's forward axis; the
2170
- // pixel picks where on the camera plane it starts (top row = top).
2171
- let cx = o.left + (x / width) * (o.right - o.left)
2172
- let cy = o.top + (y / height) * (o.bottom - o.top)
2173
- pickOrigin[0] = camera.eye[0] + cx * v[0] + cy * v[1]
2174
- pickOrigin[1] = camera.eye[1] + cx * v[4] + cy * v[5]
2175
- pickOrigin[2] = camera.eye[2] + cx * v[8] + cy * v[9]
2176
- pickDir[0] = -v[2]
2177
- pickDir[1] = -v[6]
2178
- pickDir[2] = -v[10]
1810
+ pixelRay(x, y)
2179
1811
  return scene.raycast(pickOrigin, pickDir)
2180
1812
  },
2181
- raycast(origin, direction) {
1813
+ unproject(x, y, w, out = [0, 0, 0]) {
1814
+ pixelRay(x, y)
1815
+ out[0] = pickOrigin[0] + w * pickDir[0]
1816
+ out[1] = pickOrigin[1] + w * pickDir[1]
1817
+ out[2] = pickOrigin[2] + w * pickDir[2]
1818
+ return out
1819
+ },
1820
+ screenRay(x, y) {
1821
+ pixelRay(x, y)
1822
+ return {
1823
+ origin: [pickOrigin[0], pickOrigin[1], pickOrigin[2]],
1824
+ direction: [pickDir[0], pickDir[1], pickDir[2]],
1825
+ }
1826
+ },
1827
+ raycast(origin, direction, rayOpts) {
2182
1828
  // Flush pending writes: picking sees the tree as the app just wrote
2183
1829
  // it, the same immediacy contract as lookAt()/project(). (The queued
2184
1830
  // microtask still runs and finds nothing dirty - harmless.)
2185
1831
  if (scheduled) sync()
2186
1832
  if (disposed) return []
1833
+ let admit = admitted(rayOpts, "raycast")
2187
1834
  let hits: Hit[] = []
2188
1835
  rayOriginScratch[0] = origin[0]
2189
1836
  rayOriginScratch[1] = origin[1]
@@ -2192,20 +1839,51 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2192
1839
  rayDirScratch[1] = direction[1]
2193
1840
  rayDirScratch[2] = direction[2]
2194
1841
  for (let h of spatial.raycast(rayOriginScratch, rayDirScratch)) {
2195
- let mesh = byNode.get(h.node)
2196
- if (mesh === undefined) continue
2197
- let hit: Hit = { mesh, distance: h.distance, point: h.point }
2198
- if (h.normal !== undefined) hit.normal = h.normal
1842
+ let leaf = admit(h.node)
1843
+ if (leaf === null) continue
1844
+ let hit: Hit = { mesh: leaf.mesh, distance: h.distance, point: h.point, normal: h.normal }
1845
+ if (leaf.instance !== null) hit.instance = leaf.instance
2199
1846
  if (h.face !== undefined) hit.face = h.face
2200
1847
  if (h.uv !== undefined) hit.uv = h.uv
2201
1848
  hits.push(hit)
2202
1849
  }
2203
1850
  return hits
2204
1851
  },
2205
- handlers,
2206
- handlersFor(layout) {
2207
- return makeHandlers(layout)
1852
+ overlap(volume, qopts) {
1853
+ if (scheduled) sync()
1854
+ if (disposed) return []
1855
+ let admit = admitted(qopts, "overlap")
1856
+ let kind = packVolume(volume, "overlap")
1857
+ let out: Overlap[] = []
1858
+ for (let h of spatial.overlap(kind, kind === "box" ? boxScratch : capsuleScratch)) {
1859
+ let leaf = admit(h.node)
1860
+ if (leaf === null) continue
1861
+ let contact: Overlap = { mesh: leaf.mesh, point: h.point, normal: h.normal, depth: h.depth }
1862
+ if (leaf.instance !== null) contact.instance = leaf.instance
1863
+ out.push(contact)
1864
+ }
1865
+ return out
1866
+ },
1867
+ sweep(volume, motion, qopts) {
1868
+ if (scheduled) sync()
1869
+ if (disposed) return []
1870
+ let admit = admitted(qopts, "sweep")
1871
+ let kind = packVolume(volume, "sweep")
1872
+ motionScratch[0] = motion[0]
1873
+ motionScratch[1] = motion[1]
1874
+ motionScratch[2] = motion[2]
1875
+ let out: Impact[] = []
1876
+ for (let h of spatial.sweep(kind, kind === "box" ? boxScratch : capsuleScratch, motionScratch)) {
1877
+ let leaf = admit(h.node)
1878
+ if (leaf === null) continue
1879
+ let impact: Impact = { mesh: leaf.mesh, time: h.time, point: h.point, normal: h.normal }
1880
+ if (leaf.instance !== null) impact.instance = leaf.instance
1881
+ out.push(impact)
1882
+ }
1883
+ return out
2208
1884
  },
1885
+ handlers: pointer.handlers,
1886
+ handlersFor: pointer.handlersFor,
2209
1887
  createView(vopts) {
2210
1888
  if (disposed) throw new Error("createView: the scene is disposed")
2211
1889
  let v = makeView(vopts, null)
@@ -2216,6 +1894,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2216
1894
  updateCamera(v.camera, update)
2217
1895
  hooks._schedule()
2218
1896
  },
1897
+ camera: () => cameraState(v.camera),
2219
1898
  setSize(w, h) {
2220
1899
  if (v.disposed || (w === v.width && h === v.height)) return
2221
1900
  v.width = w
@@ -2234,13 +1913,40 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2234
1913
  hooks._schedule()
2235
1914
  },
2236
1915
  setParams(params) {
2237
- if (!v.disposed) setTargetParams(v.texture, params)
1916
+ if (v.disposed) return
1917
+ for (let k of Object.keys(params)) v.ownNames.add(k)
1918
+ setTargetParams(v.texture, params)
1919
+ },
1920
+ setLayers(mask) {
1921
+ checkMask(mask, "view.setLayers")
1922
+ if (v.disposed || v.mask === mask) return
1923
+ v.mask = mask
1924
+ for (let mesh of meshes) {
1925
+ if ((mesh.layers & mask) !== 0) attachView(v, mesh)
1926
+ else detachView(v, mesh)
1927
+ }
1928
+ hooks._schedule()
2238
1929
  },
2239
1930
  dispose() {
2240
1931
  disposeView(v)
2241
1932
  },
2242
1933
  }
2243
1934
  },
1935
+ createReflectionProbe(popts) {
1936
+ if (disposed) throw new Error("createReflectionProbe: the scene is disposed")
1937
+ let { cube, setPosition, update, dispose } = makeProbe(popts, checkMask(popts.layers ?? 1, "createReflectionProbe"))
1938
+ return { cube, setPosition, update, dispose }
1939
+ },
1940
+ bakeBackground(size = PROBE_SIZE) {
1941
+ if (disposed) throw new Error("bakeBackground: the scene is disposed")
1942
+ if (background === null) throw new Error("scene.bakeBackground: the scene has no background to bake")
1943
+ if (!Number.isInteger(size) || size < 1) throw new Error("scene.bakeBackground: size must be a positive integer, got " + size)
1944
+ // A probe that sees no mesh (mask 0) draws the background alone: the
1945
+ // same face cameras, linear output and prefilter as any probe.
1946
+ let probe = makeProbe({ position: [0, 0, 0], size, label: (opts?.label ?? "scene") + "-sky" }, 0)
1947
+ probe.update()
1948
+ return probe.finish()
1949
+ },
2244
1950
  dispose() {
2245
1951
  if (disposed) return
2246
1952
  disposed = true
@@ -2254,26 +1960,92 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2254
1960
  spatial.destroyNode(root._node)
2255
1961
  root._node = null
2256
1962
  }
1963
+ spatial.setFrustum(texture, null)
2257
1964
  // Drain the zeroed direction slots the teardown queued while the
2258
1965
  // targets still exist; afterwards their groups are gone.
2259
1966
  spatial.flush()
2260
1967
  destroyTexture(texture)
2261
- shadows.clear()
2262
1968
  for (let v of views.slice()) disposeView(v)
2263
- if (shadowAtlas !== null) {
2264
- destroyTexture(shadowAtlas.texture)
2265
- shadowAtlas = null
2266
- }
1969
+ shadowSys.dispose()
1970
+ destroyTexture(envPlaceholder)
2267
1971
  if (background !== null) {
2268
- // The entry died with the target; the pipeline and program are the
2269
- // scene's own (unlike shared material pipelines), so they go too.
1972
+ // The entries died with the targets; the pipeline and program are
1973
+ // the scene's own (unlike shared material pipelines), so they go too.
2270
1974
  destroyRenderPipeline(background.pipeline)
2271
1975
  destroyProgram(background.program)
2272
1976
  background = null
2273
1977
  }
2274
1978
  },
2275
1979
  }
1980
+ // A reflection probe over layer mask `mask` (see createReflectionProbe):
1981
+ // the public object plus `finish`, which keeps the prefiltered chain and
1982
+ // drops everything else - the bake's one-shot use.
1983
+ let makeProbe = (popts: ReflectionProbeOptions, mask: number): ReflectionProbe & { finish(): TextureId } => {
1984
+ let size = popts.size ?? PROBE_SIZE
1985
+ if (!Number.isInteger(size) || size < 1) throw new Error("createReflectionProbe: size must be a positive integer, got " + size)
1986
+ let prefilter = popts.prefilter !== false
1987
+ let v = makeView(
1988
+ { width: size, height: size, layers: mask, clearColor: popts.clearColor, label: popts.label },
1989
+ null,
1990
+ size,
1991
+ prefilter,
1992
+ true,
1993
+ )
1994
+ v.camera.mirror = true
1995
+ updateCamera(v.camera, { fov: PROBE_FOV, near: popts.near, far: popts.far })
1996
+ // The faces hold LINEAR radiance, untouched by the scene's output
1997
+ // stage: no sRGB encode, no tone mapping, unit exposure - the
1998
+ // probe's own names, so the scene's fan-out leaves them alone.
1999
+ for (let k of Object.keys(LINEAR_OUTPUT)) v.ownNames.add(k)
2000
+ setTargetParams(v.texture, LINEAR_OUTPUT)
2001
+ let chain = prefilter ? createPrefilter(size, v.texture, probeFormat(), (popts.label ?? (opts?.label ?? "scene") + "-probe") + "-chain") : null
2002
+ v.probeCube = chain?.cube ?? v.texture
2003
+ let position: Vec3 = [popts.position[0], popts.position[1], popts.position[2]]
2004
+ return {
2005
+ cube: v.probeCube,
2006
+ setPosition(p) {
2007
+ position = [p[0], p[1], p[2]]
2008
+ },
2009
+ update() {
2010
+ if (v.disposed) return
2011
+ // The scene's pending state (lights, params, the fan-out to this
2012
+ // target) lands before the faces read it.
2013
+ sync()
2014
+ renderCubeFaces(v.texture, v.camera, size, position, chain !== null, face => {
2015
+ // Opaque order is a hint (front to back); only transparency
2016
+ // needs the per-face sort.
2017
+ if (face === 0 || transparentCount > 1) sortView(v)
2018
+ })
2019
+ chain?.run()
2020
+ },
2021
+ dispose() {
2022
+ chain?.dispose()
2023
+ disposeView(v)
2024
+ },
2025
+ finish() {
2026
+ if (chain === null) throw new Error("finish: a sharp probe has no chain to keep")
2027
+ let cube = chain.finish()
2028
+ disposeView(v)
2029
+ return cube
2030
+ },
2031
+ }
2032
+ }
2033
+ // The fog set starts at "none" (uFogInv and uFogDensity 0 are factor 0,
2034
+ // uFogHeightFalloff 0 is no attenuation) so every material that declares
2035
+ // it has coverage from the first frame; a target tolerates the names
2036
+ // when nothing declares them.
2037
+ scene.setParams({ uFogColor: [0, 0, 0], uFogNear: 0, uFogInv: 0, uFogDensity: 0, uFogHeight: 0, uFogHeightFalloff: 0 })
2038
+ // Likewise the environment set starts at "none" (uEnvOn 0), so a
2039
+ // reflective material has coverage before setEnvironment.
2040
+ scene.setParams(environmentParams(null))
2041
+ // And the output stage at its defaults (exposure 1, no tone mapping),
2042
+ // which every library fragment declares.
2043
+ scene.setParams({ uExposure: 1, uToneMapping: TONE_MAPPING_CODE.none, uOutputEncode: 1 })
2044
+ if (opts?.fog !== undefined) scene.setFog(opts.fog)
2045
+ if (opts?.toneMapping !== undefined) scene.setToneMapping(opts.toneMapping)
2046
+ if (opts?.exposure !== undefined) scene.setExposure(opts.exposure)
2276
2047
  if (opts?.background !== undefined) scene.setBackground(opts.background)
2048
+ if (opts?.environment !== undefined) scene.setEnvironment(opts.environment)
2277
2049
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => scene.dispose())
2278
2050
  return scene
2279
- }
2051
+ }