@solidrt/3d 0.0.55 → 0.0.57

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