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