@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/AGENTS.md +114 -0
- package/README.md +42 -0
- package/examples/README.md +9 -0
- package/examples/scene-basic.tsx +53 -0
- package/package.json +21 -0
- package/src/components.tsx +148 -0
- package/src/geometry.ts +227 -0
- package/src/index.ts +19 -0
- package/src/material.ts +209 -0
- package/src/math.ts +179 -0
- package/src/orbit.ts +164 -0
- package/src/scene.ts +403 -0
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
|
+
}
|