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