@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/AGENTS.md +235 -43
- package/README.md +37 -5
- package/examples/README.md +8 -0
- package/examples/aim.tsx +119 -0
- package/examples/scene-post-effect.tsx +82 -0
- package/examples/sweep-paths.tsx +79 -0
- package/package.json +4 -3
- package/src/components.tsx +42 -9
- package/src/geometry.ts +298 -37
- package/src/glsl.ts +112 -0
- package/src/index.ts +11 -5
- package/src/material.ts +43 -6
- package/src/math.ts +334 -19
- package/src/orbit.ts +187 -27
- package/src/profile.ts +315 -0
- package/src/scene.ts +224 -46
- package/src/sweep.ts +460 -0
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
|
//
|
|
@@ -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 "@
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
32
|
-
// only when
|
|
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
|
-
/**
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
{
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|