@solidrt/3d 0.0.46 → 0.0.47
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 +145 -35
- package/README.md +22 -5
- package/examples/scene-post-effect.tsx +82 -0
- package/package.json +4 -3
- package/src/components.tsx +20 -4
- package/src/geometry.ts +292 -37
- package/src/glsl.ts +112 -0
- package/src/index.ts +6 -4
- package/src/material.ts +43 -6
- package/src/math.ts +40 -0
- package/src/orbit.ts +187 -27
- package/src/profile.ts +520 -0
- package/src/scene.ts +91 -37
package/src/scene.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// The retained scene: plain objects and dirty flags, no signals - the hot
|
|
2
2
|
// path (a moved node) is flat imperative code, and reactivity stays at the
|
|
3
3
|
// component boundary (components.tsx). A scene compiles to one draw
|
|
4
|
-
// target: every mesh is one draw entry whose uModel
|
|
5
|
-
// keeps in step with the tree, and the
|
|
6
|
-
//
|
|
4
|
+
// target: every mesh is one draw entry whose uModel (and, for materials
|
|
5
|
+
// declaring it, uNormal) this module keeps in step with the tree, and the
|
|
6
|
+
// camera is the target's SHARED uViewProj + uCamPos - one setTargetParams
|
|
7
|
+
// per camera move, not one write per mesh. uCamPos rides unconditionally:
|
|
8
|
+
// shared params tolerate zero coverage (stored and skipped until a
|
|
9
|
+
// declaring material arrives), so no bookkeeping tracks who reads it.
|
|
7
10
|
// Mutations batch to a microtask, so a burst of writes (a whole subtree
|
|
8
11
|
// moved, many effects in one flush) syncs once.
|
|
9
12
|
//
|
|
@@ -17,20 +20,23 @@
|
|
|
17
20
|
import { addDraw, createDrawTarget, destroyTexture, removeDraw, setDrawParams, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
|
|
18
21
|
import type { DrawId, FilterMode, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
|
|
19
22
|
import { getOwner, onCleanup } from "@solidjs/signals"
|
|
20
|
-
import { compose, lookAt, mat4, multiply, perspective } from "./math.ts"
|
|
21
|
-
import type { Mat4, Vec3 } from "./math.ts"
|
|
23
|
+
import { compose, copy, lookAt, mat4, multiply, normalMatrix, perspective, transformPoint } from "./math.ts"
|
|
24
|
+
import type { Mat4, Vec3, Vec4 } from "./math.ts"
|
|
22
25
|
import { geometryBuffers } from "./geometry.ts"
|
|
23
26
|
import type { Geometry } from "./geometry.ts"
|
|
24
27
|
import type { Material } from "./material.ts"
|
|
25
28
|
|
|
26
29
|
const IDENTITY = mat4()
|
|
27
30
|
const RESOLVED = Promise.resolve()
|
|
31
|
+
// Param values are snapshotted at the FFI boundary (addDraw shares
|
|
32
|
+
// IDENTITY the same way), so one scratch serves every uNormal write.
|
|
33
|
+
let normalScratch = mat4()
|
|
28
34
|
|
|
29
35
|
// The scene half a node needs to reach: attach/detach entries and schedule
|
|
30
36
|
// a sync. Kept separate from the public Scene type so internals stay off
|
|
31
|
-
// the app-facing surface.
|
|
32
|
-
// only when
|
|
33
|
-
// state survives entry churn.
|
|
37
|
+
// the app-facing surface. The camera (uViewProj + uCamPos) is written
|
|
38
|
+
// through the shared channel only when it changes - attach never re-seeds
|
|
39
|
+
// it, because target state survives entry churn.
|
|
34
40
|
type SceneHooks = {
|
|
35
41
|
_schedule(): void
|
|
36
42
|
_attach(mesh: Mesh): void
|
|
@@ -91,6 +97,18 @@ export type Scene = {
|
|
|
91
97
|
/** Partial camera update; absent keys keep their current value. */
|
|
92
98
|
setCamera(update: CameraUpdate): void
|
|
93
99
|
setSize(width: number, height: number): void
|
|
100
|
+
/**
|
|
101
|
+
* Project a world point to scene pixels: origin top-left, y down - the
|
|
102
|
+
* output texture's own coordinate space, ready for overlay layout (HUD
|
|
103
|
+
* markers, labels). `w` is the clip-space w, the point's camera-forward
|
|
104
|
+
* distance (useful for depth-ordering or distance-scaling markers).
|
|
105
|
+
* Returns null for a point at or behind the camera plane - such a point
|
|
106
|
+
* has no place on screen. Reflects a pending setCamera immediately.
|
|
107
|
+
*/
|
|
108
|
+
project(point: Vec3): { x: number; y: number; w: number } | null
|
|
109
|
+
/** The camera's view-projection matrix, copied into `out` (or a fresh
|
|
110
|
+
* mat4). The batch escape hatch; for single points use project(). */
|
|
111
|
+
viewProj(out?: Mat4): Mat4
|
|
94
112
|
/** Destroy the target (entries die with it). Idempotent. Geometry
|
|
95
113
|
* buffers and material pipelines are shared and survive - they are
|
|
96
114
|
* app-lifetime (see geometry.ts / material.ts). */
|
|
@@ -238,7 +256,7 @@ function rebuildEntry(mesh: Mesh): void {
|
|
|
238
256
|
|
|
239
257
|
/**
|
|
240
258
|
* Write per-mesh uniforms - the channel for a custom material's app-driven
|
|
241
|
-
* values (a
|
|
259
|
+
* values (a time, a per-object tint). Names must be
|
|
242
260
|
* declared and used by the mesh's material shaders (unknown names throw at
|
|
243
261
|
* the call site, the engine's validation contract). Values persist on the
|
|
244
262
|
* mesh: they survive geometry/material entry rebuilds and re-apply then.
|
|
@@ -276,23 +294,34 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
276
294
|
let target: Vec3 = [0, 0, 0]
|
|
277
295
|
let up: Vec3 = [0, 1, 0]
|
|
278
296
|
let cameraDirty = true
|
|
279
|
-
let
|
|
297
|
+
let cameraPending = false
|
|
280
298
|
let proj = mat4()
|
|
281
299
|
let view = mat4()
|
|
282
300
|
let viewProj = mat4()
|
|
301
|
+
let clip: Vec4 = [0, 0, 0, 0]
|
|
302
|
+
|
|
303
|
+
// Matrix recompute, split from sync so project()/viewProj() see a fresh
|
|
304
|
+
// matrix right after setCamera, before the microtask runs. cameraPending
|
|
305
|
+
// keeps the GPU write owed to the next sync.
|
|
306
|
+
let ensureCamera = () => {
|
|
307
|
+
if (!cameraDirty) return
|
|
308
|
+
cameraDirty = false
|
|
309
|
+
cameraPending = true
|
|
310
|
+
perspective(proj, (fov * Math.PI) / 180, width / height, near, far)
|
|
311
|
+
lookAt(view, eye, target, up)
|
|
312
|
+
multiply(viewProj, proj, view)
|
|
313
|
+
}
|
|
283
314
|
|
|
284
315
|
let sync = () => {
|
|
285
316
|
scheduled = false
|
|
286
317
|
if (disposed) return
|
|
287
|
-
|
|
318
|
+
ensureCamera()
|
|
319
|
+
if (cameraPending) {
|
|
288
320
|
// The camera is target state: one shared write, whatever the scene
|
|
289
|
-
// holds. Entries are untouched - uModel is camera-independent
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
lookAt(view, eye, target, up)
|
|
294
|
-
multiply(viewProj, proj, view)
|
|
295
|
-
setTargetParams(texture, { uViewProj: viewProj })
|
|
321
|
+
// holds. Entries are untouched - uModel is camera-independent, and
|
|
322
|
+
// uCamPos is stored even when no current material declares it.
|
|
323
|
+
cameraPending = false
|
|
324
|
+
setTargetParams(texture, { uViewProj: viewProj, uCamPos: eye })
|
|
296
325
|
}
|
|
297
326
|
let walk = (node: SceneNode, parentChanged: boolean, parentVisible: boolean) => {
|
|
298
327
|
let changed = parentChanged
|
|
@@ -315,7 +344,14 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
315
344
|
if (shown) mesh._fresh = true
|
|
316
345
|
}
|
|
317
346
|
if (!mesh._hidden && (changed || mesh._fresh)) {
|
|
318
|
-
|
|
347
|
+
if (mesh.material.normalMatrix) {
|
|
348
|
+
setDrawParams(texture, mesh._entry, {
|
|
349
|
+
uModel: mesh._world,
|
|
350
|
+
uNormal: normalMatrix(normalScratch, mesh._world),
|
|
351
|
+
})
|
|
352
|
+
} else {
|
|
353
|
+
setDrawParams(texture, mesh._entry, { uModel: mesh._world })
|
|
354
|
+
}
|
|
319
355
|
mesh._fresh = false
|
|
320
356
|
} else if (changed) {
|
|
321
357
|
// Moved while hidden: write the fresh matrix on unhide.
|
|
@@ -336,28 +372,33 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
336
372
|
},
|
|
337
373
|
_attach(mesh) {
|
|
338
374
|
if (disposed) return
|
|
375
|
+
// Layout is stride: a mismatched pair would not miss a channel, it
|
|
376
|
+
// would read garbage - so it is an error here, like the rest of the
|
|
377
|
+
// strict entry path.
|
|
378
|
+
let geoLayout = mesh.geometry.layout ?? "standard"
|
|
379
|
+
let matLayout = mesh.material.layout ?? "standard"
|
|
380
|
+
if (geoLayout !== matLayout) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
"Mesh geometry layout '" + geoLayout + "' does not match its material's '" + matLayout +
|
|
383
|
+
"' - a material reading aColor needs withColors() geometry, and colored geometry needs such a material",
|
|
384
|
+
)
|
|
385
|
+
}
|
|
339
386
|
let bufs = geometryBuffers(mesh.geometry)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
{
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
387
|
+
// The uNormal seed keys off the material flag because entry params
|
|
388
|
+
// validate strictly - and a material declaring uNormal without using
|
|
389
|
+
// it therefore throws right here, at add().
|
|
390
|
+
let seed: ShaderParams = mesh.material.normalMatrix
|
|
391
|
+
? { uModel: IDENTITY, uNormal: IDENTITY, ...mesh.material.params, ...mesh._params }
|
|
392
|
+
: { uModel: IDENTITY, ...mesh.material.params, ...mesh._params }
|
|
393
|
+
mesh._entry = addDraw(texture, mesh.material.pipeline(), seed, {
|
|
394
|
+
buffer: bufs.buffer,
|
|
395
|
+
indexBuffer: bufs.index,
|
|
396
|
+
indexFormat: bufs.indexFormat,
|
|
397
|
+
textures: mesh.material.textures,
|
|
398
|
+
})
|
|
351
399
|
mesh._hidden = false
|
|
352
400
|
mesh._fresh = true
|
|
353
401
|
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
402
|
},
|
|
362
403
|
_detach(mesh) {
|
|
363
404
|
if (mesh._entry !== null && !disposed) removeDraw(texture, mesh._entry)
|
|
@@ -392,6 +433,19 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
392
433
|
cameraDirty = true
|
|
393
434
|
hooks._schedule()
|
|
394
435
|
},
|
|
436
|
+
project(point) {
|
|
437
|
+
ensureCamera()
|
|
438
|
+
transformPoint(clip, viewProj, point)
|
|
439
|
+
let w = clip[3]
|
|
440
|
+
if (w < 1e-6) return null
|
|
441
|
+
// perspective() bakes the y-down clip flip, so NDC maps straight to
|
|
442
|
+
// top-left-origin pixels with no negation here.
|
|
443
|
+
return { x: ((clip[0] / w) * 0.5 + 0.5) * width, y: ((clip[1] / w) * 0.5 + 0.5) * height, w }
|
|
444
|
+
},
|
|
445
|
+
viewProj(out) {
|
|
446
|
+
ensureCamera()
|
|
447
|
+
return copy(out ?? mat4(), viewProj)
|
|
448
|
+
},
|
|
395
449
|
dispose() {
|
|
396
450
|
if (disposed) return
|
|
397
451
|
disposed = true
|