@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/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
  //
@@ -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. 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.
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 camera position, a time, a per-object tint). Names must be
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 cameraSynced = false
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
- if (cameraDirty) {
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
- 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 })
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
- setDrawParams(texture, mesh._entry, { uModel: mesh._world })
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
- 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
- )
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