@solidrt/3d 0.0.46 → 0.0.48

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 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 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.
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
  //
@@ -16,21 +19,36 @@
16
19
 
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
- 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 { getOwner, onCleanup } from "@solidrt/core"
23
+ // The scene's lookAt() aims a node; math's builds a camera's view matrix -
24
+ // the same pairing (and the same name) as Three's Object3D/Matrix4.
25
+ import { compose, copy, eulerFromQuat, identity, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quatFromEuler, quatFromFrame, quatNormalize, transformPoint } from "./math.ts"
26
+ import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
22
27
  import { geometryBuffers } from "./geometry.ts"
23
28
  import type { Geometry } from "./geometry.ts"
24
29
  import type { Material } from "./material.ts"
25
30
 
26
31
  const IDENTITY = mat4()
27
32
  const RESOLVED = Promise.resolve()
33
+ // lookAt()'s default roll reference. Read-only: quatFromFrame never
34
+ // writes its inputs, so one shared vector is safe.
35
+ const WORLD_UP: Vec3 = [0, 1, 0]
36
+ // Param values are snapshotted at the FFI boundary (addDraw shares
37
+ // IDENTITY the same way), so one scratch serves every uNormal write.
38
+ let normalScratch = mat4()
39
+ // lookAt()/worldPosition() scratch: the ancestor walk recomputes worlds
40
+ // without touching node state, so nothing here outlives a single call.
41
+ let worldScratch = mat4()
42
+ let localScratch = mat4()
43
+ let pointScratch: Vec4 = [0, 0, 0, 0]
44
+ let aimScratch: Vec3 = [0, 0, 0]
45
+ let upScratch: Vec3 = [0, 0, 0]
28
46
 
29
47
  // The scene half a node needs to reach: attach/detach entries and schedule
30
48
  // 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.
49
+ // the app-facing surface. The camera (uViewProj + uCamPos) is written
50
+ // through the shared channel only when it changes - attach never re-seeds
51
+ // it, because target state survives entry churn.
34
52
  type SceneHooks = {
35
53
  _schedule(): void
36
54
  _attach(mesh: Mesh): void
@@ -44,8 +62,10 @@ export type SceneNode = {
44
62
  children: SceneNode[]
45
63
  /** Read freely; write through setTransform/setVisible so changes sync. */
46
64
  position: Vec3
47
- /** Euler radians, applied x then y then z. */
48
- rotation: Vec3
65
+ /** The stored rotation, always a UNIT quaternion. Euler triples convert
66
+ * on the way in (setTransform's `rotation`) and out (getRotation) - there
67
+ * is no second rotation field to fall out of step with this one. */
68
+ quaternion: Quat
49
69
  scale: Vec3
50
70
  visible: boolean
51
71
  _localDirty: boolean
@@ -91,6 +111,18 @@ export type Scene = {
91
111
  /** Partial camera update; absent keys keep their current value. */
92
112
  setCamera(update: CameraUpdate): void
93
113
  setSize(width: number, height: number): void
114
+ /**
115
+ * Project a world point to scene pixels: origin top-left, y down - the
116
+ * output texture's own coordinate space, ready for overlay layout (HUD
117
+ * markers, labels). `w` is the clip-space w, the point's camera-forward
118
+ * distance (useful for depth-ordering or distance-scaling markers).
119
+ * Returns null for a point at or behind the camera plane - such a point
120
+ * has no place on screen. Reflects a pending setCamera immediately.
121
+ */
122
+ project(point: Vec3): { x: number; y: number; w: number } | null
123
+ /** The camera's view-projection matrix, copied into `out` (or a fresh
124
+ * mat4). The batch escape hatch; for single points use project(). */
125
+ viewProj(out?: Mat4): Mat4
94
126
  /** Destroy the target (entries die with it). Idempotent. Geometry
95
127
  * buffers and material pipelines are shared and survive - they are
96
128
  * app-lifetime (see geometry.ts / material.ts). */
@@ -103,7 +135,7 @@ function makeNode(kind: "group" | "mesh"): SceneNode {
103
135
  parent: null,
104
136
  children: [],
105
137
  position: [0, 0, 0],
106
- rotation: [0, 0, 0],
138
+ quaternion: [0, 0, 0, 1],
107
139
  scale: [1, 1, 1],
108
140
  visible: true,
109
141
  _localDirty: true,
@@ -165,7 +197,13 @@ function leaveScene(node: SceneNode): void {
165
197
 
166
198
  export type TransformUpdate = {
167
199
  position?: Vec3
200
+ /** Euler radians in XYZ order (x first), Three's `Euler` default -
201
+ * converted to the node's quaternion on write. */
168
202
  rotation?: Vec3
203
+ /** The rotation itself. Normalized on write, so a hand-built or
204
+ * drifted quaternion cannot silently scale the geometry. Passing this
205
+ * together with `rotation` is an error, not a precedence question. */
206
+ quaternion?: Quat
169
207
  /** A number is uniform scale. */
170
208
  scale?: Vec3 | number
171
209
  }
@@ -184,11 +222,12 @@ export function setTransform(node: SceneNode, update: TransformUpdate): void {
184
222
  node.position[2] = p[2]
185
223
  }
186
224
  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]
225
+ let q = update.quaternion
226
+ if (r !== undefined && q !== undefined) {
227
+ throw new Error("Pass rotation or quaternion to setTransform, not both")
191
228
  }
229
+ if (r !== undefined) quatFromEuler(node.quaternion, r)
230
+ else if (q !== undefined) quatNormalize(node.quaternion, q)
192
231
  let s = update.scale
193
232
  if (s !== undefined) {
194
233
  if (typeof s === "number") {
@@ -205,6 +244,109 @@ export function setTransform(node: SceneNode, update: TransformUpdate): void {
205
244
  node._scene?._schedule()
206
245
  }
207
246
 
247
+ /**
248
+ * Aim a node at a WORLD-space point, Three's `Object3D.lookAt`: the node's
249
+ * local +z ends up pointing at `target`, with `up` (world space, default
250
+ * +y) choosing the roll about that axis. Ancestor transforms are undone,
251
+ * so the aim holds under a rotated group - the ancestor chain is brought
252
+ * up to date on the spot rather than waiting for the pending sync.
253
+ *
254
+ * +z because that is the library's own sweep axis (`extrude`, `sweep`,
255
+ * `tube` run along z), so aiming their output needs no correction. For a
256
+ * y-axis solid (`cylinder`, `cone`) reach for `quatFromTo` instead, which
257
+ * takes the axis to aim as an argument.
258
+ *
259
+ * Writes `node.quaternion` - an ordinary rotation afterwards, readable and
260
+ * overwritable by setTransform. To aim along a DIRECTION rather than at a
261
+ * point, add it to the node's world position (`worldPosition`), the same
262
+ * conversion Three asks for.
263
+ *
264
+ * Exact for rotation and uniform scale in the ancestor chain; a
265
+ * non-uniformly scaled ancestor shears the frame and the aim is
266
+ * approximate, exactly as in Three (both read the parent's upper 3x3 as
267
+ * if it were a rotation).
268
+ */
269
+ export function lookAt(node: SceneNode, target: Vec3, up: Vec3 = WORLD_UP): void {
270
+ let parent = node.parent
271
+ if (parent === null) {
272
+ // No ancestors: parent space IS world space, aim straight from the
273
+ // node's own position.
274
+ aimScratch[0] = target[0] - node.position[0]
275
+ aimScratch[1] = target[1] - node.position[1]
276
+ aimScratch[2] = target[2] - node.position[2]
277
+ quatFromFrame(node.quaternion, aimScratch, up)
278
+ } else {
279
+ let world = worldInto(worldScratch, parent)
280
+ transformPoint(pointScratch, world, node.position)
281
+ aimScratch[0] = target[0] - pointScratch[0]
282
+ aimScratch[1] = target[1] - pointScratch[1]
283
+ aimScratch[2] = target[2] - pointScratch[2]
284
+ // World -> parent space for both vectors: rotating forward and up
285
+ // rotates the frame they build, so converting the inputs is the same
286
+ // as converting the resulting rotation, and needs no matrix inverse.
287
+ unrotate(aimScratch, world, aimScratch)
288
+ unrotate(upScratch, world, up)
289
+ quatFromFrame(node.quaternion, aimScratch, upScratch)
290
+ }
291
+ node._localDirty = true
292
+ node._scene?._schedule()
293
+ }
294
+
295
+ /**
296
+ * A node's rotation as Euler radians in XYZ order, copied into `out` (or a
297
+ * fresh Vec3). A convenience for reading and debugging, NOT a peer of
298
+ * `node.quaternion`: the conversion is lossy in the sense that it cannot
299
+ * recover the triple that was written (see eulerFromQuat), only a triple
300
+ * that means the same rotation. Anything composing or interpolating
301
+ * rotations should work with the quaternion.
302
+ */
303
+ export function getRotation(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
304
+ return eulerFromQuat(out, node.quaternion)
305
+ }
306
+
307
+ /**
308
+ * A node's position in world space, copied into `out` (or a fresh Vec3) -
309
+ * Three's `getWorldPosition`. Brings the ancestor chain up to date first,
310
+ * so it is exact before the pending sync has run.
311
+ */
312
+ export function worldPosition(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
313
+ let world = worldInto(worldScratch, node)
314
+ out[0] = world[12]
315
+ out[1] = world[13]
316
+ out[2] = world[14]
317
+ return out
318
+ }
319
+
320
+ /**
321
+ * `out` = node's world matrix, composing any dirty locals up the chain
322
+ * WITHOUT clearing their flags: the pending sync still has to see them to
323
+ * write uModel. One shared local scratch serves any depth - each frame
324
+ * uses it only after its recursive call has returned.
325
+ */
326
+ function worldInto(out: Mat4, node: SceneNode): Mat4 {
327
+ if (node.parent === null) identity(out)
328
+ else worldInto(out, node.parent)
329
+ let local = node._localDirty
330
+ ? compose(localScratch, node.position, node.quaternion, node.scale)
331
+ : node._local
332
+ return multiply(out, out, local)
333
+ }
334
+
335
+ /**
336
+ * `out` = v with m's rotation undone: the transpose of m's upper 3x3 with
337
+ * its columns normalized, so uniform scale divides out. out may alias v.
338
+ */
339
+ function unrotate(out: Vec3, m: Mat4, v: Vec3): Vec3 {
340
+ let x = v[0], y = v[1], z = v[2]
341
+ let l0 = Math.hypot(m[0], m[1], m[2]) || 1
342
+ let l1 = Math.hypot(m[4], m[5], m[6]) || 1
343
+ let l2 = Math.hypot(m[8], m[9], m[10]) || 1
344
+ out[0] = (m[0] * x + m[1] * y + m[2] * z) / l0
345
+ out[1] = (m[4] * x + m[5] * y + m[6] * z) / l1
346
+ out[2] = (m[8] * x + m[9] * y + m[10] * z) / l2
347
+ return out
348
+ }
349
+
208
350
  /** Show or hide a node and its whole subtree (a hidden mesh costs one
209
351
  * `instanceCount: 0` draw range - the entry stays, drawing nothing). */
210
352
  export function setVisible(node: SceneNode, visible: boolean): void {
@@ -238,7 +380,7 @@ function rebuildEntry(mesh: Mesh): void {
238
380
 
239
381
  /**
240
382
  * 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
383
+ * values (a time, a per-object tint). Names must be
242
384
  * declared and used by the mesh's material shaders (unknown names throw at
243
385
  * the call site, the engine's validation contract). Values persist on the
244
386
  * mesh: they survive geometry/material entry rebuilds and re-apply then.
@@ -276,28 +418,39 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
276
418
  let target: Vec3 = [0, 0, 0]
277
419
  let up: Vec3 = [0, 1, 0]
278
420
  let cameraDirty = true
279
- let cameraSynced = false
421
+ let cameraPending = false
280
422
  let proj = mat4()
281
423
  let view = mat4()
282
424
  let viewProj = mat4()
425
+ let clip: Vec4 = [0, 0, 0, 0]
426
+
427
+ // Matrix recompute, split from sync so project()/viewProj() see a fresh
428
+ // matrix right after setCamera, before the microtask runs. cameraPending
429
+ // keeps the GPU write owed to the next sync.
430
+ let ensureCamera = () => {
431
+ if (!cameraDirty) return
432
+ cameraDirty = false
433
+ cameraPending = true
434
+ perspective(proj, (fov * Math.PI) / 180, width / height, near, far)
435
+ lookAtMatrix(view, eye, target, up)
436
+ multiply(viewProj, proj, view)
437
+ }
283
438
 
284
439
  let sync = () => {
285
440
  scheduled = false
286
441
  if (disposed) return
287
- if (cameraDirty) {
442
+ ensureCamera()
443
+ if (cameraPending) {
288
444
  // 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 })
445
+ // holds. Entries are untouched - uModel is camera-independent, and
446
+ // uCamPos is stored even when no current material declares it.
447
+ cameraPending = false
448
+ setTargetParams(texture, { uViewProj: viewProj, uCamPos: eye })
296
449
  }
297
450
  let walk = (node: SceneNode, parentChanged: boolean, parentVisible: boolean) => {
298
451
  let changed = parentChanged
299
452
  if (node._localDirty) {
300
- compose(node._local, node.position, node.rotation, node.scale)
453
+ compose(node._local, node.position, node.quaternion, node.scale)
301
454
  node._localDirty = false
302
455
  changed = true
303
456
  }
@@ -315,7 +468,14 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
315
468
  if (shown) mesh._fresh = true
316
469
  }
317
470
  if (!mesh._hidden && (changed || mesh._fresh)) {
318
- setDrawParams(texture, mesh._entry, { uModel: mesh._world })
471
+ if (mesh.material.normalMatrix) {
472
+ setDrawParams(texture, mesh._entry, {
473
+ uModel: mesh._world,
474
+ uNormal: normalMatrix(normalScratch, mesh._world),
475
+ })
476
+ } else {
477
+ setDrawParams(texture, mesh._entry, { uModel: mesh._world })
478
+ }
319
479
  mesh._fresh = false
320
480
  } else if (changed) {
321
481
  // Moved while hidden: write the fresh matrix on unhide.
@@ -336,28 +496,33 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
336
496
  },
337
497
  _attach(mesh) {
338
498
  if (disposed) return
499
+ // Layout is stride: a mismatched pair would not miss a channel, it
500
+ // would read garbage - so it is an error here, like the rest of the
501
+ // strict entry path.
502
+ let geoLayout = mesh.geometry.layout ?? "standard"
503
+ let matLayout = mesh.material.layout ?? "standard"
504
+ if (geoLayout !== matLayout) {
505
+ throw new Error(
506
+ "Mesh geometry layout '" + geoLayout + "' does not match its material's '" + matLayout +
507
+ "' - a material reading aColor needs withColors() geometry, and colored geometry needs such a material",
508
+ )
509
+ }
339
510
  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
- )
511
+ // The uNormal seed keys off the material flag because entry params
512
+ // validate strictly - and a material declaring uNormal without using
513
+ // it therefore throws right here, at add().
514
+ let seed: ShaderParams = mesh.material.normalMatrix
515
+ ? { uModel: IDENTITY, uNormal: IDENTITY, ...mesh.material.params, ...mesh._params }
516
+ : { uModel: IDENTITY, ...mesh.material.params, ...mesh._params }
517
+ mesh._entry = addDraw(texture, mesh.material.pipeline(), seed, {
518
+ buffer: bufs.buffer,
519
+ indexBuffer: bufs.index,
520
+ indexFormat: bufs.indexFormat,
521
+ textures: mesh.material.textures,
522
+ })
351
523
  mesh._hidden = false
352
524
  mesh._fresh = true
353
525
  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
526
  },
362
527
  _detach(mesh) {
363
528
  if (mesh._entry !== null && !disposed) removeDraw(texture, mesh._entry)
@@ -392,6 +557,19 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
392
557
  cameraDirty = true
393
558
  hooks._schedule()
394
559
  },
560
+ project(point) {
561
+ ensureCamera()
562
+ transformPoint(clip, viewProj, point)
563
+ let w = clip[3]
564
+ if (w < 1e-6) return null
565
+ // perspective() bakes the y-down clip flip, so NDC maps straight to
566
+ // top-left-origin pixels with no negation here.
567
+ return { x: ((clip[0] / w) * 0.5 + 0.5) * width, y: ((clip[1] / w) * 0.5 + 0.5) * height, w }
568
+ },
569
+ viewProj(out) {
570
+ ensureCamera()
571
+ return copy(out ?? mat4(), viewProj)
572
+ },
395
573
  dispose() {
396
574
  if (disposed) return
397
575
  disposed = true