@solidrt/3d 0.0.46

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/src/scene.ts ADDED
@@ -0,0 +1,403 @@
1
+ // The retained scene: plain objects and dirty flags, no signals - the hot
2
+ // path (a moved node) is flat imperative code, and reactivity stays at the
3
+ // component boundary (components.tsx). A scene compiles to one draw
4
+ // target: every mesh is one draw entry whose uModel uniform this module
5
+ // keeps in step with the tree, and the camera is the target's SHARED
6
+ // uViewProj - one setTargetParams per camera move, not one write per mesh.
7
+ // Mutations batch to a microtask, so a burst of writes (a whole subtree
8
+ // moved, many effects in one flush) syncs once.
9
+ //
10
+ // Rendering itself belongs to the runtime: the target is an ordinary
11
+ // `render: "auto"` draw target that re-renders when its entries change, so
12
+ // a static scene costs zero passes and this module registers no frame
13
+ // loop. Continuous animation is the app's onFrame writing transforms -
14
+ // each write lands here, the microtask syncs the affected uModels, and the
15
+ // flush renders once that frame.
16
+
17
+ import { addDraw, createDrawTarget, destroyTexture, removeDraw, setDrawParams, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
18
+ import type { DrawId, FilterMode, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
19
+ import { getOwner, onCleanup } from "@solidjs/signals"
20
+ import { compose, lookAt, mat4, multiply, perspective } from "./math.ts"
21
+ import type { Mat4, Vec3 } from "./math.ts"
22
+ import { geometryBuffers } from "./geometry.ts"
23
+ import type { Geometry } from "./geometry.ts"
24
+ import type { Material } from "./material.ts"
25
+
26
+ const IDENTITY = mat4()
27
+ const RESOLVED = Promise.resolve()
28
+
29
+ // The scene half a node needs to reach: attach/detach entries and schedule
30
+ // a sync. Kept separate from the public Scene type so internals stay off
31
+ // the app-facing surface. uViewProj is written through the shared channel
32
+ // only when the camera changes - attach never re-seeds it, because target
33
+ // state survives entry churn.
34
+ type SceneHooks = {
35
+ _schedule(): void
36
+ _attach(mesh: Mesh): void
37
+ _detach(mesh: Mesh): void
38
+ _setParams(mesh: Mesh, params: ShaderParams): void
39
+ }
40
+
41
+ export type SceneNode = {
42
+ kind: "group" | "mesh"
43
+ parent: SceneNode | null
44
+ children: SceneNode[]
45
+ /** Read freely; write through setTransform/setVisible so changes sync. */
46
+ position: Vec3
47
+ /** Euler radians, applied x then y then z. */
48
+ rotation: Vec3
49
+ scale: Vec3
50
+ visible: boolean
51
+ _localDirty: boolean
52
+ _local: Mat4
53
+ _world: Mat4
54
+ _scene: SceneHooks | null
55
+ }
56
+
57
+ export type Mesh = SceneNode & {
58
+ kind: "mesh"
59
+ geometry: Geometry
60
+ material: Material
61
+ _entry: DrawId | null
62
+ _hidden: boolean
63
+ _fresh: boolean
64
+ _params: ShaderParams | null
65
+ }
66
+
67
+ export type CameraUpdate = {
68
+ /** Vertical field of view in DEGREES (default 60). */
69
+ fov?: number
70
+ near?: number
71
+ far?: number
72
+ position?: Vec3
73
+ target?: Vec3
74
+ up?: Vec3
75
+ }
76
+
77
+ export type SceneOptions = {
78
+ clearColor?: [number, number, number, number]
79
+ label?: string
80
+ /** `autoFree: false` opts out of owner-scoped auto-dispose (then call dispose yourself). */
81
+ autoFree?: boolean
82
+ filter?: FilterMode
83
+ wrap?: WrapMode
84
+ }
85
+
86
+ export type Scene = {
87
+ /** The scene's output: an ordinary texture id (`<texture src>`). */
88
+ texture: TextureId
89
+ /** The tree root; add(scene.root, node) attaches top-level nodes. */
90
+ root: SceneNode
91
+ /** Partial camera update; absent keys keep their current value. */
92
+ setCamera(update: CameraUpdate): void
93
+ setSize(width: number, height: number): void
94
+ /** Destroy the target (entries die with it). Idempotent. Geometry
95
+ * buffers and material pipelines are shared and survive - they are
96
+ * app-lifetime (see geometry.ts / material.ts). */
97
+ dispose(): void
98
+ }
99
+
100
+ function makeNode(kind: "group" | "mesh"): SceneNode {
101
+ return {
102
+ kind,
103
+ parent: null,
104
+ children: [],
105
+ position: [0, 0, 0],
106
+ rotation: [0, 0, 0],
107
+ scale: [1, 1, 1],
108
+ visible: true,
109
+ _localDirty: true,
110
+ _local: mat4(),
111
+ _world: mat4(),
112
+ _scene: null,
113
+ }
114
+ }
115
+
116
+ export function createGroup(): SceneNode {
117
+ return makeNode("group")
118
+ }
119
+
120
+ export function createMesh(geometry: Geometry, material: Material): Mesh {
121
+ let mesh = makeNode("mesh") as Mesh
122
+ mesh.geometry = geometry
123
+ mesh.material = material
124
+ mesh._entry = null
125
+ mesh._hidden = false
126
+ mesh._fresh = false
127
+ mesh._params = null
128
+ return mesh
129
+ }
130
+
131
+ /** Attach `child` under `parent` (re-parenting detaches it first). */
132
+ export function add(parent: SceneNode, child: SceneNode): void {
133
+ if (child.parent !== null) remove(child)
134
+ child.parent = parent
135
+ parent.children.push(child)
136
+ child._localDirty = true
137
+ if (parent._scene) enterScene(child, parent._scene)
138
+ }
139
+
140
+ /** Detach `child` from its parent (and its meshes from the scene). */
141
+ export function remove(child: SceneNode): void {
142
+ if (child._scene) leaveScene(child)
143
+ let parent = child.parent
144
+ if (parent !== null) {
145
+ let i = parent.children.indexOf(child)
146
+ if (i >= 0) parent.children.splice(i, 1)
147
+ child.parent = null
148
+ }
149
+ }
150
+
151
+ function enterScene(node: SceneNode, scene: SceneHooks): void {
152
+ node._scene = scene
153
+ node._localDirty = true
154
+ if (node.kind === "mesh") scene._attach(node as Mesh)
155
+ for (let c of node.children) enterScene(c, scene)
156
+ scene._schedule()
157
+ }
158
+
159
+ function leaveScene(node: SceneNode): void {
160
+ let scene = node._scene
161
+ if (scene && node.kind === "mesh") scene._detach(node as Mesh)
162
+ node._scene = null
163
+ for (let c of node.children) leaveScene(c)
164
+ }
165
+
166
+ export type TransformUpdate = {
167
+ position?: Vec3
168
+ rotation?: Vec3
169
+ /** A number is uniform scale. */
170
+ scale?: Vec3 | number
171
+ }
172
+
173
+ /**
174
+ * The one write path for node transforms (so the scene knows to sync).
175
+ * Values are copied in; absent keys keep their current value. This is also
176
+ * the frame-rate escape hatch: call it from onFrame on a node grabbed via
177
+ * `ref`, bypassing signals entirely.
178
+ */
179
+ export function setTransform(node: SceneNode, update: TransformUpdate): void {
180
+ let p = update.position
181
+ if (p) {
182
+ node.position[0] = p[0]
183
+ node.position[1] = p[1]
184
+ node.position[2] = p[2]
185
+ }
186
+ let r = update.rotation
187
+ if (r) {
188
+ node.rotation[0] = r[0]
189
+ node.rotation[1] = r[1]
190
+ node.rotation[2] = r[2]
191
+ }
192
+ let s = update.scale
193
+ if (s !== undefined) {
194
+ if (typeof s === "number") {
195
+ node.scale[0] = s
196
+ node.scale[1] = s
197
+ node.scale[2] = s
198
+ } else {
199
+ node.scale[0] = s[0]
200
+ node.scale[1] = s[1]
201
+ node.scale[2] = s[2]
202
+ }
203
+ }
204
+ node._localDirty = true
205
+ node._scene?._schedule()
206
+ }
207
+
208
+ /** Show or hide a node and its whole subtree (a hidden mesh costs one
209
+ * `instanceCount: 0` draw range - the entry stays, drawing nothing). */
210
+ export function setVisible(node: SceneNode, visible: boolean): void {
211
+ if (node.visible === visible) return
212
+ node.visible = visible
213
+ node._scene?._schedule()
214
+ }
215
+
216
+ /** Swap a mesh's geometry: its draw entry is rebuilt (appended last -
217
+ * order is irrelevant while every entry is opaque and depth-tested). */
218
+ export function setGeometry(mesh: Mesh, geometry: Geometry): void {
219
+ if (mesh.geometry === geometry) return
220
+ mesh.geometry = geometry
221
+ rebuildEntry(mesh)
222
+ }
223
+
224
+ /** Swap a mesh's material: its draw entry is rebuilt. */
225
+ export function setMaterial(mesh: Mesh, material: Material): void {
226
+ if (mesh.material === material) return
227
+ mesh.material = material
228
+ rebuildEntry(mesh)
229
+ }
230
+
231
+ function rebuildEntry(mesh: Mesh): void {
232
+ let scene = mesh._scene
233
+ if (scene) {
234
+ scene._detach(mesh)
235
+ scene._attach(mesh)
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Write per-mesh uniforms - the channel for a custom material's app-driven
241
+ * values (a camera position, a time, a per-object tint). Names must be
242
+ * declared and used by the mesh's material shaders (unknown names throw at
243
+ * the call site, the engine's validation contract). Values persist on the
244
+ * mesh: they survive geometry/material entry rebuilds and re-apply then.
245
+ * Also the frame-rate path - like setTransform, call it from onFrame
246
+ * freely.
247
+ */
248
+ export function setMeshParams(mesh: Mesh, params: ShaderParams): void {
249
+ if (mesh._params === null) mesh._params = {}
250
+ Object.assign(mesh._params, params)
251
+ mesh._scene?._setParams(mesh, params)
252
+ }
253
+
254
+ /**
255
+ * Create a scene rendering into a depth-buffered draw target of the given
256
+ * size. Returns the scene handle; `scene.texture` is the output. Inside a
257
+ * reactive scope the scene disposes with the owner (opt out with
258
+ * `autoFree: false`); outside one, call `dispose()` yourself.
259
+ */
260
+ export function createScene(width: number, height: number, opts?: SceneOptions): Scene {
261
+ let texture = createDrawTarget(width, height, null, {
262
+ depth: true,
263
+ clearColor: opts?.clearColor,
264
+ filter: opts?.filter,
265
+ wrap: opts?.wrap,
266
+ label: opts?.label ?? "scene",
267
+ autoFree: false,
268
+ })
269
+ let disposed = false
270
+ let scheduled = false
271
+
272
+ let fov = 60
273
+ let near = 0.1
274
+ let far = 100
275
+ let eye: Vec3 = [0, 0, 3]
276
+ let target: Vec3 = [0, 0, 0]
277
+ let up: Vec3 = [0, 1, 0]
278
+ let cameraDirty = true
279
+ let cameraSynced = false
280
+ let proj = mat4()
281
+ let view = mat4()
282
+ let viewProj = mat4()
283
+
284
+ let sync = () => {
285
+ scheduled = false
286
+ if (disposed) return
287
+ if (cameraDirty) {
288
+ // The camera is target state: one shared write, whatever the scene
289
+ // holds. Entries are untouched - uModel is camera-independent.
290
+ cameraDirty = false
291
+ cameraSynced = true
292
+ perspective(proj, (fov * Math.PI) / 180, width / height, near, far)
293
+ lookAt(view, eye, target, up)
294
+ multiply(viewProj, proj, view)
295
+ setTargetParams(texture, { uViewProj: viewProj })
296
+ }
297
+ let walk = (node: SceneNode, parentChanged: boolean, parentVisible: boolean) => {
298
+ let changed = parentChanged
299
+ if (node._localDirty) {
300
+ compose(node._local, node.position, node.rotation, node.scale)
301
+ node._localDirty = false
302
+ changed = true
303
+ }
304
+ if (changed) {
305
+ multiply(node._world, node.parent ? node.parent._world : IDENTITY, node._local)
306
+ }
307
+ let shown = parentVisible && node.visible
308
+ if (node.kind === "mesh") {
309
+ let mesh = node as Mesh
310
+ if (mesh._entry !== null) {
311
+ if (mesh._hidden === shown) {
312
+ // Mismatch: flip the entry's cheap off switch.
313
+ setDrawRange(texture, mesh._entry, { instanceCount: shown ? 1 : 0 })
314
+ mesh._hidden = !shown
315
+ if (shown) mesh._fresh = true
316
+ }
317
+ if (!mesh._hidden && (changed || mesh._fresh)) {
318
+ setDrawParams(texture, mesh._entry, { uModel: mesh._world })
319
+ mesh._fresh = false
320
+ } else if (changed) {
321
+ // Moved while hidden: write the fresh matrix on unhide.
322
+ mesh._fresh = true
323
+ }
324
+ }
325
+ }
326
+ for (let c of node.children) walk(c, changed, shown)
327
+ }
328
+ walk(root, false, true)
329
+ }
330
+
331
+ let hooks: SceneHooks = {
332
+ _schedule() {
333
+ if (scheduled || disposed) return
334
+ scheduled = true
335
+ RESOLVED.then(sync)
336
+ },
337
+ _attach(mesh) {
338
+ if (disposed) return
339
+ let bufs = geometryBuffers(mesh.geometry)
340
+ mesh._entry = addDraw(
341
+ texture,
342
+ mesh.material.pipeline(),
343
+ { uModel: IDENTITY, ...mesh.material.params, ...mesh._params },
344
+ {
345
+ buffer: bufs.buffer,
346
+ indexBuffer: bufs.index,
347
+ indexFormat: "uint16",
348
+ textures: mesh.material.textures,
349
+ },
350
+ )
351
+ mesh._hidden = false
352
+ mesh._fresh = true
353
+ this._schedule()
354
+ // Re-issue the camera (same value, one write): a scene whose only
355
+ // materials lack uViewProj then throws HERE, at add(), with the
356
+ // engine's coverage message, instead of inside the next camera sync's
357
+ // microtask. Before the first sync there is no value to re-issue; the
358
+ // sync this attach just scheduled writes (and checks) it. Scheduled
359
+ // first so a throw still leaves the walk queued.
360
+ if (cameraSynced) setTargetParams(texture, { uViewProj: viewProj })
361
+ },
362
+ _detach(mesh) {
363
+ if (mesh._entry !== null && !disposed) removeDraw(texture, mesh._entry)
364
+ mesh._entry = null
365
+ },
366
+ _setParams(mesh, params) {
367
+ if (mesh._entry !== null && !disposed) setDrawParams(texture, mesh._entry, params)
368
+ },
369
+ }
370
+
371
+ let root = makeNode("group")
372
+ root._scene = hooks
373
+
374
+ let scene: Scene = {
375
+ texture,
376
+ root,
377
+ setCamera(update) {
378
+ if (update.fov !== undefined) fov = update.fov
379
+ if (update.near !== undefined) near = update.near
380
+ if (update.far !== undefined) far = update.far
381
+ if (update.position) eye = [update.position[0], update.position[1], update.position[2]]
382
+ if (update.target) target = [update.target[0], update.target[1], update.target[2]]
383
+ if (update.up) up = [update.up[0], update.up[1], update.up[2]]
384
+ cameraDirty = true
385
+ hooks._schedule()
386
+ },
387
+ setSize(w, h) {
388
+ if (disposed || (w === width && h === height)) return
389
+ width = w
390
+ height = h
391
+ setTargetSize(texture, w, h)
392
+ cameraDirty = true
393
+ hooks._schedule()
394
+ },
395
+ dispose() {
396
+ if (disposed) return
397
+ disposed = true
398
+ destroyTexture(texture)
399
+ },
400
+ }
401
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => scene.dispose())
402
+ return scene
403
+ }