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