@solidrt/3d 0.0.55 → 0.0.56
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 +1083 -183
- package/README.md +15 -7
- package/examples/README.md +79 -5
- package/examples/aim.tsx +2 -3
- package/examples/cascades.tsx +1 -4
- package/examples/collision.tsx +250 -0
- package/examples/first-person.tsx +119 -0
- package/examples/fleet.tsx +194 -0
- package/examples/fog.tsx +2 -3
- package/examples/instanced.tsx +72 -32
- package/examples/lamps.tsx +92 -0
- package/examples/lit.tsx +3 -5
- package/examples/materials.tsx +150 -0
- package/examples/model-load.tsx +95 -0
- package/examples/model.glb +0 -0
- package/examples/model.tsx +32 -6
- package/examples/pick.tsx +2 -4
- package/examples/probe.tsx +101 -0
- package/examples/scene-background.tsx +2 -4
- package/examples/scene-basic.tsx +2 -4
- package/examples/scene-views.tsx +32 -3
- package/examples/shadows.tsx +2 -4
- package/examples/sky-lit.tsx +86 -0
- package/examples/skybox.tsx +128 -0
- package/examples/sprites.tsx +4 -6
- package/examples/standard.tsx +132 -0
- package/examples/sweep-paths.tsx +2 -4
- package/package.json +3 -3
- package/src/camera.ts +148 -0
- package/src/clip.ts +96 -0
- package/src/collision.ts +199 -0
- package/src/color.ts +32 -0
- package/src/components.tsx +763 -82
- package/src/environment-bake.ts +492 -0
- package/src/environment.ts +325 -0
- package/src/first-person.ts +306 -0
- package/src/geometry.ts +6 -2
- package/src/glsl.ts +1343 -82
- package/src/gltf.ts +432 -112
- package/src/index.ts +57 -9
- package/src/light.ts +383 -0
- package/src/material.ts +667 -232
- package/src/math.ts +110 -0
- package/src/mesh.ts +728 -0
- package/src/mixer.ts +476 -0
- package/src/model-file.ts +80 -15
- package/src/model.ts +204 -35
- package/src/node.ts +506 -0
- package/src/orbit.ts +97 -48
- package/src/scene-pointer.ts +166 -0
- package/src/scene-shadows.ts +471 -0
- package/src/scene.ts +1242 -1557
- package/tools/environment.ts +69 -0
- package/tools/model.ts +4 -3
package/src/node.ts
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// The retained node layer: the scene-graph objects (groups, and the base
|
|
2
|
+
// the meshes and lights extend), the add/remove tree walk binding them to
|
|
3
|
+
// the spatial core, and the transform write paths. Plain objects, no
|
|
4
|
+
// signals - scene.ts's header tells the full model. A node reaches its
|
|
5
|
+
// scene only through the SceneHooks seam, so this module has no runtime
|
|
6
|
+
// import of scene.ts (or of mesh.ts/light.ts - their imports here are
|
|
7
|
+
// types only).
|
|
8
|
+
|
|
9
|
+
import * as spatial from "flux:spatial"
|
|
10
|
+
import type { NodeId, NodeTransition } from "flux:spatial"
|
|
11
|
+
import { on } from "srt:events"
|
|
12
|
+
import type { ShaderParams, TextureId } from "@solidrt/core/gpu"
|
|
13
|
+
// The scene's lookAt() aims a node; math's builds a camera's view matrix -
|
|
14
|
+
// the same pairing (and the same name) as Three's Object3D/Matrix4.
|
|
15
|
+
import { compose, eulerFromQuat, identity, mat4, multiply, quat, quatFromFrame, transformPoint, updateRotation, updateScale } from "./math.ts"
|
|
16
|
+
import type { Mat4, Quat, TransformUpdate, Vec3, Vec4 } from "./math.ts"
|
|
17
|
+
import type { CastingLight, Light } from "./light.ts"
|
|
18
|
+
import type { InstancedMesh, InstanceNode, Mesh } from "./mesh.ts"
|
|
19
|
+
|
|
20
|
+
// lookAt()'s default roll reference. Read-only: quatFromFrame never
|
|
21
|
+
// writes its inputs, so one shared vector is safe.
|
|
22
|
+
const WORLD_UP: Vec3 = [0, 1, 0]
|
|
23
|
+
// The FFI carriers: one transform write (position, quaternion, scale) and
|
|
24
|
+
// one world-matrix read. Values are copied at the boundary, so one of each
|
|
25
|
+
// serves every call.
|
|
26
|
+
let transformScratch = new Float32Array(10)
|
|
27
|
+
let worldRead = new Float32Array(16)
|
|
28
|
+
// lookAt()/worldPosition() scratch: nothing here outlives a single call.
|
|
29
|
+
let worldScratch = mat4()
|
|
30
|
+
let localScratch = mat4()
|
|
31
|
+
let pointScratch: Vec4 = [0, 0, 0, 0]
|
|
32
|
+
let aimScratch: Vec3 = [0, 0, 0]
|
|
33
|
+
let upScratch: Vec3 = [0, 0, 0]
|
|
34
|
+
// setTransform's rotation compare happens AFTER conversion, so an euler and
|
|
35
|
+
// the quaternion it produces are the same write. Nothing outlives the call.
|
|
36
|
+
let rotScratch = quat()
|
|
37
|
+
let scaleScratch: Vec3 = [1, 1, 1]
|
|
38
|
+
|
|
39
|
+
// Settle routing: the core's "spatialTransitionEnd" event carries the node
|
|
40
|
+
// id, so nodes with a transition DECLARED (only those can settle) are
|
|
41
|
+
// indexed by their core id while in a scene, and one lazy subscription,
|
|
42
|
+
// started at the first declaration, routes to the node's onTransitionEnd.
|
|
43
|
+
// Target-only, like the element transitions.
|
|
44
|
+
let declared = new Map<NodeId, SceneNode>()
|
|
45
|
+
let subscribed = false
|
|
46
|
+
|
|
47
|
+
function declareTransition(id: NodeId, node: SceneNode): void {
|
|
48
|
+
declared.set(id, node)
|
|
49
|
+
if (subscribed) return
|
|
50
|
+
subscribed = true
|
|
51
|
+
on("spatialTransitionEnd", (event: { node: NodeId; component: TransitionEndEvent["component"] }) => {
|
|
52
|
+
let node = declared.get(event.node)
|
|
53
|
+
if (!node) return
|
|
54
|
+
try {
|
|
55
|
+
node.onTransitionEnd?.({ component: event.component })
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error("Error in onTransitionEnd handler:", err)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The scene half a node needs to reach: attach/detach entries and schedule
|
|
63
|
+
// a sync. Kept separate from the public Scene type so internals stay off
|
|
64
|
+
// the app-facing surface. The camera (uViewProj + uCamPos) is written
|
|
65
|
+
// through the shared channel only when it changes - attach never re-seeds
|
|
66
|
+
// it, because target state survives entry churn.
|
|
67
|
+
export type SceneHooks = {
|
|
68
|
+
_schedule(): void
|
|
69
|
+
_attach(mesh: Mesh): void
|
|
70
|
+
_detach(mesh: Mesh): void
|
|
71
|
+
/** An instance entered the scene (its mesh entered first): box, shape
|
|
72
|
+
* and record binding. */
|
|
73
|
+
_attachInstance(instance: InstanceNode): void
|
|
74
|
+
_detachInstance(instance: InstanceNode): void
|
|
75
|
+
_attachLight(light: Light): void
|
|
76
|
+
_detachLight(light: Light): void
|
|
77
|
+
_lightChanged(): void
|
|
78
|
+
_setParams(mesh: Mesh, params: ShaderParams): void
|
|
79
|
+
_setCount(mesh: Mesh): void
|
|
80
|
+
/** Re-point the mesh's entries at its (replaced) instance buffers. */
|
|
81
|
+
_setBuffer(mesh: Mesh): void
|
|
82
|
+
/** The instanced mesh's style mirror has a dirty range: publish it at
|
|
83
|
+
* the next sync. */
|
|
84
|
+
_setStyle(mesh: InstancedMesh): void
|
|
85
|
+
/** The mesh's castShadow flag changed: re-evaluate the filtered views. */
|
|
86
|
+
_setCast(mesh: Mesh): void
|
|
87
|
+
/** The mesh's layers bitmask changed: re-evaluate every target. */
|
|
88
|
+
_setLayers(mesh: Mesh): void
|
|
89
|
+
/** A light's castShadow/shadow options changed. */
|
|
90
|
+
_shadowChanged(light: CastingLight): void
|
|
91
|
+
_reorder(): void
|
|
92
|
+
/** The node's transform changed (for the sort and light bookkeeping). */
|
|
93
|
+
_moved(node: SceneNode): void
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type SceneNode = {
|
|
97
|
+
kind: "group" | "mesh" | "light" | "instance"
|
|
98
|
+
parent: SceneNode | null
|
|
99
|
+
children: SceneNode[]
|
|
100
|
+
/** Read freely; write through setTransform/setVisible so changes sync. */
|
|
101
|
+
position: Vec3
|
|
102
|
+
/** The stored rotation, always a UNIT quaternion. Euler triples convert
|
|
103
|
+
* on the way in (setTransform's `rotation`) and out (getRotation) - there
|
|
104
|
+
* is no second rotation field to fall out of step with this one. */
|
|
105
|
+
quaternion: Quat
|
|
106
|
+
scale: Vec3
|
|
107
|
+
visible: boolean
|
|
108
|
+
/** Pointer event handlers - plain fields, assign freely (they touch no
|
|
109
|
+
* GPU state, so they need no setTransform-style write path; components
|
|
110
|
+
* sync their props here). Down/move/up dispatch on the hit mesh and
|
|
111
|
+
* bubble through its ancestors (stopPropagation stops the walk);
|
|
112
|
+
* enter/leave fire on the mesh alone. Events flow once the element
|
|
113
|
+
* showing the scene carries `scene.handlers`. */
|
|
114
|
+
onPointerDown?: (event: ScenePointerEvent) => void
|
|
115
|
+
onPointerMove?: (event: ScenePointerEvent) => void
|
|
116
|
+
onPointerUp?: (event: ScenePointerEvent) => void
|
|
117
|
+
onPointerEnter?: (event: ScenePointerEvent) => void
|
|
118
|
+
onPointerLeave?: (event: ScenePointerEvent) => void
|
|
119
|
+
/** A declared transition (setTransition) settled naturally on one
|
|
120
|
+
* component; a cancel, snap or scene leave never fires. */
|
|
121
|
+
onTransitionEnd?: (event: TransitionEndEvent) => void
|
|
122
|
+
/** The core node while in a scene (created at add, freed at remove). */
|
|
123
|
+
_node: NodeId | null
|
|
124
|
+
_moved: boolean
|
|
125
|
+
/** The core writes this node's TRS itself (a clip player's target, a
|
|
126
|
+
* root-motion anchor), so the JS mirror above is stale: setTransform
|
|
127
|
+
* never short-circuits on it. Set once, never cleared. */
|
|
128
|
+
_native: boolean
|
|
129
|
+
_scene: SceneHooks | null
|
|
130
|
+
/** The declared transition, re-applied on every scene enter. */
|
|
131
|
+
_transition: NodeTransition | string | null
|
|
132
|
+
/** Skin palette rows this node feeds (a model joint carries one per
|
|
133
|
+
* skin): bound to the core at every scene enter, so the flush writes
|
|
134
|
+
* `inverse(anchorWorld) * world * post` - the model-local bone matrix -
|
|
135
|
+
* to the palette texture's row whenever the node moves. `anchor` is the
|
|
136
|
+
* model root; null for non-joints (the common case pays one null check). */
|
|
137
|
+
_palettes: { texture: TextureId; row: number; post: Float32Array; anchor: SceneNode }[] | null
|
|
138
|
+
/** A culling-only local box (a model joint's influence region, in joint
|
|
139
|
+
* space): bound at every scene enter so the joint's world box follows
|
|
140
|
+
* the pose without joining the picking index. null for the common case. */
|
|
141
|
+
_cullBounds: Float32Array | null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The settled component of a node transition. */
|
|
145
|
+
export type TransitionEndEvent = {
|
|
146
|
+
component: "position" | "rotation" | "scale"
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The event a mesh, instance or ancestor group handler receives: the
|
|
151
|
+
* element pointer vocabulary carried over, plus the 3D fields.
|
|
152
|
+
* `point`/`distance` are null exactly when the ray misses the dispatch
|
|
153
|
+
* target - which happens only during a captured drag or on a leave.
|
|
154
|
+
*/
|
|
155
|
+
export type ScenePointerEvent = {
|
|
156
|
+
/** The mesh the event is about (the hit, or the captured mesh during a
|
|
157
|
+
* drag) - constant while the event bubbles. */
|
|
158
|
+
mesh: Mesh
|
|
159
|
+
/** The instance struck when `mesh` is an instanced mesh (the walk
|
|
160
|
+
* starts there), null otherwise - constant while the event bubbles. */
|
|
161
|
+
instance: InstanceNode | null
|
|
162
|
+
/** Node whose handler is running; changes as the event bubbles. */
|
|
163
|
+
currentTarget: SceneNode
|
|
164
|
+
/** World-space hit point on `mesh`, or null when the ray misses it. */
|
|
165
|
+
point: Vec3 | null
|
|
166
|
+
/** Camera-ray distance to `point` in world units, or null with it. */
|
|
167
|
+
distance: number | null
|
|
168
|
+
/** Pointer position in scene pixels - project()'s coordinate space. */
|
|
169
|
+
x: number
|
|
170
|
+
y: number
|
|
171
|
+
pointerId: number
|
|
172
|
+
pointerType: string
|
|
173
|
+
button?: number
|
|
174
|
+
shiftKey: boolean
|
|
175
|
+
ctrlKey: boolean
|
|
176
|
+
altKey: boolean
|
|
177
|
+
metaKey: boolean
|
|
178
|
+
/** Stops the bubble walk after the current handler. */
|
|
179
|
+
stopPropagation(): void
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A bare node record (internal: the mesh and light constructors build on it). */
|
|
183
|
+
export function makeNode(kind: SceneNode["kind"]): SceneNode {
|
|
184
|
+
return {
|
|
185
|
+
kind,
|
|
186
|
+
parent: null,
|
|
187
|
+
children: [],
|
|
188
|
+
position: [0, 0, 0],
|
|
189
|
+
quaternion: [0, 0, 0, 1],
|
|
190
|
+
scale: [1, 1, 1],
|
|
191
|
+
visible: true,
|
|
192
|
+
_node: null,
|
|
193
|
+
_native: false,
|
|
194
|
+
_moved: false,
|
|
195
|
+
_scene: null,
|
|
196
|
+
_transition: null,
|
|
197
|
+
_palettes: null,
|
|
198
|
+
_cullBounds: null,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function createGroup(): SceneNode {
|
|
203
|
+
return makeNode("group")
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Attach `child` under `parent` (re-parenting detaches it first). An
|
|
207
|
+
* instance is slot-bound to its mesh: addInstance places it. */
|
|
208
|
+
export function add(parent: SceneNode, child: SceneNode): void {
|
|
209
|
+
if (child.kind === "instance") throw new Error("add: an instance is slot-bound to its mesh - addInstance places it, removeInstance destroys it")
|
|
210
|
+
if (child.parent !== null) remove(child)
|
|
211
|
+
child.parent = parent
|
|
212
|
+
parent.children.push(child)
|
|
213
|
+
if (parent._scene) enterScene(child, parent._scene)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Detach `child` from its parent (and its meshes from the scene). An
|
|
217
|
+
* instance is destroyed instead, by removeInstance. */
|
|
218
|
+
export function remove(child: SceneNode): void {
|
|
219
|
+
if (child.kind === "instance") throw new Error("remove: an instance is slot-bound to its mesh - removeInstance destroys it")
|
|
220
|
+
if (child._scene) leaveScene(child)
|
|
221
|
+
let parent = child.parent
|
|
222
|
+
if (parent !== null) {
|
|
223
|
+
let i = parent.children.indexOf(child)
|
|
224
|
+
if (i >= 0) parent.children.splice(i, 1)
|
|
225
|
+
child.parent = null
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function enterScene(node: SceneNode, scene: SceneHooks): void {
|
|
230
|
+
node._scene = scene
|
|
231
|
+
node._node = spatial.createNode(fillTransform(node), node.visible)
|
|
232
|
+
if (node._transition !== null) {
|
|
233
|
+
spatial.setTransition(node._node, node._transition)
|
|
234
|
+
declareTransition(node._node, node)
|
|
235
|
+
}
|
|
236
|
+
// The parent is in the scene already (add() enters the child only then),
|
|
237
|
+
// and the scene root is the one node without a parent.
|
|
238
|
+
if (node.parent !== null && node.parent._node !== null) spatial.setParent(node._node, node.parent._node)
|
|
239
|
+
if (node._palettes !== null) {
|
|
240
|
+
// A joint's palette rows: the anchor (its model root) entered first -
|
|
241
|
+
// enterScene recurses parents-first - so its core node is live. The
|
|
242
|
+
// core drops the binding with the node at leaveScene.
|
|
243
|
+
for (let p of node._palettes) {
|
|
244
|
+
if (p.anchor._node !== null) spatial.bindTextureSlot(node._node, p.texture, p.row, p.post, p.anchor._node)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (node._cullBounds !== null) spatial.setCullBounds(node._node, node._cullBounds)
|
|
248
|
+
if (node.kind === "mesh") scene._attach(node as Mesh)
|
|
249
|
+
else if (node.kind === "light") scene._attachLight(node as Light)
|
|
250
|
+
else if (node.kind === "instance") scene._attachInstance(node as InstanceNode)
|
|
251
|
+
for (let c of node.children) enterScene(c, scene)
|
|
252
|
+
scene._schedule()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function leaveScene(node: SceneNode): void {
|
|
256
|
+
let scene = node._scene
|
|
257
|
+
if (scene && node.kind === "mesh") scene._detach(node as Mesh)
|
|
258
|
+
else if (scene && node.kind === "light") scene._detachLight(node as Light)
|
|
259
|
+
else if (scene && node.kind === "instance") scene._detachInstance(node as InstanceNode)
|
|
260
|
+
node._scene = null
|
|
261
|
+
for (let c of node.children) leaveScene(c)
|
|
262
|
+
if (node._node !== null) {
|
|
263
|
+
declared.delete(node._node)
|
|
264
|
+
spatial.destroyNode(node._node)
|
|
265
|
+
node._node = null
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The node's local transform in the FFI carrier. */
|
|
270
|
+
export function fillTransform(node: SceneNode): Float32Array {
|
|
271
|
+
let t = transformScratch
|
|
272
|
+
t[0] = node.position[0]; t[1] = node.position[1]; t[2] = node.position[2]
|
|
273
|
+
t[3] = node.quaternion[0]; t[4] = node.quaternion[1]; t[5] = node.quaternion[2]; t[6] = node.quaternion[3]
|
|
274
|
+
t[7] = node.scale[0]; t[8] = node.scale[1]; t[9] = node.scale[2]
|
|
275
|
+
return t
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Forward a changed local transform to the core (no-op outside a scene:
|
|
279
|
+
* entering pushes the whole transform). */
|
|
280
|
+
function pushTransform(node: SceneNode): void {
|
|
281
|
+
if (node._node === null || node._scene === null) return
|
|
282
|
+
spatial.writeTransform(node._node, fillTransform(node))
|
|
283
|
+
node._scene._moved(node)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export type { TransformUpdate } from "./math.ts"
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Declare (or with null clear) how the node's transform writes animate:
|
|
290
|
+
* once set, setTransform writes are TARGETS the core animates toward
|
|
291
|
+
* (position/scale per lane, rotation along the quaternion geodesic - a
|
|
292
|
+
* spring keeps its velocity through retargets, the pursuit-safe shape),
|
|
293
|
+
* so JS writes once per target change instead of once per frame. A spec
|
|
294
|
+
* per component (position, rotation, scale) plus `all`; each
|
|
295
|
+
* `{ duration, bounce? }` (a spring, the default) / `{ duration, curve }`
|
|
296
|
+
* (a tween) / a shorthand string like "300ms ease-out". The declaration
|
|
297
|
+
* lives on the node and re-applies whenever it enters a scene; the pose
|
|
298
|
+
* it enters with always snaps. Clearing cancels running tracks in place
|
|
299
|
+
* (the node keeps its mid-flight transform) and later writes snap. Each
|
|
300
|
+
* natural settle calls the node's `onTransitionEnd` with the component
|
|
301
|
+
* (the raw "spatialTransitionEnd" engine event on srt:events stays for
|
|
302
|
+
* flux:spatial consumers; it carries the core id, `_node`).
|
|
303
|
+
*/
|
|
304
|
+
export function setTransition(node: SceneNode, transition: NodeTransition | string | null): void {
|
|
305
|
+
node._transition = transition
|
|
306
|
+
if (node._node !== null) {
|
|
307
|
+
spatial.setTransition(node._node, transition)
|
|
308
|
+
if (transition === null) declared.delete(node._node)
|
|
309
|
+
else declareTransition(node._node, node)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The one write path for node transforms (so the scene knows to sync).
|
|
315
|
+
* Values are copied in; absent keys keep their current value. This is also
|
|
316
|
+
* the frame-rate escape hatch: call it from onFrame on a node grabbed via
|
|
317
|
+
* `ref`, bypassing signals entirely.
|
|
318
|
+
*
|
|
319
|
+
* A write that changes nothing schedules nothing, so driving every node
|
|
320
|
+
* unconditionally from onFrame costs only the compare for the nodes that
|
|
321
|
+
* did not move. Rotation is compared after conversion, so passing an euler
|
|
322
|
+
* equal to the node's current quaternion is also a no-op.
|
|
323
|
+
*/
|
|
324
|
+
export function setTransform(node: SceneNode, update: TransformUpdate): void {
|
|
325
|
+
// A no-op write costs nothing: driving every node from onFrame is the
|
|
326
|
+
// intended shape, and most nodes did not move. Exact compares, like
|
|
327
|
+
// setVisible - a value that survives a float round trip unchanged is the
|
|
328
|
+
// same value, and an epsilon would need a scale-dependent one anyway.
|
|
329
|
+
// Except on a node the core poses itself: its mirror is stale, so an
|
|
330
|
+
// equal write may well be a real move (a character teleported back to
|
|
331
|
+
// where it started) and always goes through.
|
|
332
|
+
let changed = node._native
|
|
333
|
+
let p = update.position
|
|
334
|
+
if (p && (p[0] !== node.position[0] || p[1] !== node.position[1] || p[2] !== node.position[2])) {
|
|
335
|
+
node.position[0] = p[0]
|
|
336
|
+
node.position[1] = p[1]
|
|
337
|
+
node.position[2] = p[2]
|
|
338
|
+
changed = true
|
|
339
|
+
}
|
|
340
|
+
if (updateRotation(rotScratch, update, "setTransform")) {
|
|
341
|
+
let n = node.quaternion
|
|
342
|
+
if (rotScratch[0] !== n[0] || rotScratch[1] !== n[1] || rotScratch[2] !== n[2] || rotScratch[3] !== n[3]) {
|
|
343
|
+
n[0] = rotScratch[0]
|
|
344
|
+
n[1] = rotScratch[1]
|
|
345
|
+
n[2] = rotScratch[2]
|
|
346
|
+
n[3] = rotScratch[3]
|
|
347
|
+
changed = true
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (update.scale !== undefined) {
|
|
351
|
+
updateScale(scaleScratch, update.scale)
|
|
352
|
+
if (scaleScratch[0] !== node.scale[0] || scaleScratch[1] !== node.scale[1] || scaleScratch[2] !== node.scale[2]) {
|
|
353
|
+
node.scale[0] = scaleScratch[0]
|
|
354
|
+
node.scale[1] = scaleScratch[1]
|
|
355
|
+
node.scale[2] = scaleScratch[2]
|
|
356
|
+
changed = true
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (!changed) return
|
|
360
|
+
pushTransform(node)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Aim a node at a WORLD-space point, Three's `Object3D.lookAt`: the node's
|
|
365
|
+
* local +z ends up pointing at `target`, with `up` (world space, default
|
|
366
|
+
* +y) choosing the roll about that axis. Ancestor transforms are undone,
|
|
367
|
+
* so the aim holds under a rotated group - the ancestor chain is brought
|
|
368
|
+
* up to date on the spot rather than waiting for the pending sync.
|
|
369
|
+
*
|
|
370
|
+
* +z because that is the library's own sweep axis (`extrude`, `sweep`,
|
|
371
|
+
* `tube` run along z), so aiming their output needs no correction. For a
|
|
372
|
+
* y-axis solid (`cylinder`, `cone`) reach for `quatFromTo` instead, which
|
|
373
|
+
* takes the axis to aim as an argument.
|
|
374
|
+
*
|
|
375
|
+
* Writes `node.quaternion` - an ordinary rotation afterwards, readable and
|
|
376
|
+
* overwritable by setTransform. To aim along a DIRECTION rather than at a
|
|
377
|
+
* point, add it to the node's world position (`worldPosition`), the same
|
|
378
|
+
* conversion Three asks for.
|
|
379
|
+
*
|
|
380
|
+
* Exact for rotation and uniform scale in the ancestor chain; a
|
|
381
|
+
* non-uniformly scaled ancestor shears the frame and the aim is
|
|
382
|
+
* approximate, exactly as in Three (both read the parent's upper 3x3 as
|
|
383
|
+
* if it were a rotation).
|
|
384
|
+
*/
|
|
385
|
+
export function lookAt(node: SceneNode, target: Vec3, up: Vec3 = WORLD_UP): void {
|
|
386
|
+
let parent = node.parent
|
|
387
|
+
if (parent === null) {
|
|
388
|
+
// No ancestors: parent space IS world space, aim straight from the
|
|
389
|
+
// node's own position.
|
|
390
|
+
aimScratch[0] = target[0] - node.position[0]
|
|
391
|
+
aimScratch[1] = target[1] - node.position[1]
|
|
392
|
+
aimScratch[2] = target[2] - node.position[2]
|
|
393
|
+
quatFromFrame(node.quaternion, aimScratch, up)
|
|
394
|
+
} else {
|
|
395
|
+
let world = worldInto(worldScratch, parent)
|
|
396
|
+
transformPoint(pointScratch, world, node.position)
|
|
397
|
+
aimScratch[0] = target[0] - pointScratch[0]
|
|
398
|
+
aimScratch[1] = target[1] - pointScratch[1]
|
|
399
|
+
aimScratch[2] = target[2] - pointScratch[2]
|
|
400
|
+
// World -> parent space for both vectors: rotating forward and up
|
|
401
|
+
// rotates the frame they build, so converting the inputs is the same
|
|
402
|
+
// as converting the resulting rotation, and needs no matrix inverse.
|
|
403
|
+
unrotate(aimScratch, world, aimScratch)
|
|
404
|
+
unrotate(upScratch, world, up)
|
|
405
|
+
quatFromFrame(node.quaternion, aimScratch, upScratch)
|
|
406
|
+
}
|
|
407
|
+
pushTransform(node)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* A node's rotation as Euler radians in XYZ order, copied into `out` (or a
|
|
412
|
+
* fresh Vec3). A convenience for reading and debugging, NOT a peer of
|
|
413
|
+
* `node.quaternion`: the conversion is lossy in the sense that it cannot
|
|
414
|
+
* recover the triple that was written (see eulerFromQuat), only a triple
|
|
415
|
+
* that means the same rotation. Anything composing or interpolating
|
|
416
|
+
* rotations should work with the quaternion.
|
|
417
|
+
*/
|
|
418
|
+
export function getRotation(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
|
|
419
|
+
return eulerFromQuat(out, node.quaternion)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// readTransform's FFI carrier (position 3, quaternion 4, scale 3).
|
|
423
|
+
let transformRead = new Float32Array(10)
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The node's CURRENT local transform as fresh arrays. In a scene this
|
|
427
|
+
* reads the core - which is the truth for a node a clip player animates:
|
|
428
|
+
* the players write core TRS directly, so the JS `position`/`quaternion`/
|
|
429
|
+
* `scale` fields of animated joints go stale (they hold the last JS
|
|
430
|
+
* write). Use this for pose reads on animated rigs (root-motion strips,
|
|
431
|
+
* copying a skeleton); out of a scene it copies the JS fields.
|
|
432
|
+
*/
|
|
433
|
+
export function getTransform(node: SceneNode): { position: Vec3; quaternion: Quat; scale: Vec3 } {
|
|
434
|
+
if (node._node !== null) {
|
|
435
|
+
spatial.readTransform(node._node, transformRead)
|
|
436
|
+
let t = transformRead
|
|
437
|
+
return {
|
|
438
|
+
position: [t[0]!, t[1]!, t[2]!],
|
|
439
|
+
quaternion: [t[3]!, t[4]!, t[5]!, t[6]!],
|
|
440
|
+
scale: [t[7]!, t[8]!, t[9]!],
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
position: [node.position[0], node.position[1], node.position[2]],
|
|
445
|
+
quaternion: [node.quaternion[0], node.quaternion[1], node.quaternion[2], node.quaternion[3]],
|
|
446
|
+
scale: [node.scale[0], node.scale[1], node.scale[2]],
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* A node's position in world space, copied into `out` (or a fresh Vec3) -
|
|
452
|
+
* Three's `getWorldPosition`. Brings the ancestor chain up to date first,
|
|
453
|
+
* so it is exact before the pending sync has run.
|
|
454
|
+
*/
|
|
455
|
+
export function worldPosition(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
|
|
456
|
+
let world = worldInto(worldScratch, node)
|
|
457
|
+
out[0] = world[12]
|
|
458
|
+
out[1] = world[13]
|
|
459
|
+
out[2] = world[14]
|
|
460
|
+
return out
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* `out` = node's world matrix as the tree stands now. In a scene that is
|
|
465
|
+
* one core read (pending writes included, nothing cleared); outside one
|
|
466
|
+
* the chain is composed here. Scene membership is subtree-closed, so the
|
|
467
|
+
* recursion meets a core node at the first in-scene ancestor at the
|
|
468
|
+
* latest. One shared local scratch serves any depth - each frame uses it
|
|
469
|
+
* only after its recursive call has returned.
|
|
470
|
+
*/
|
|
471
|
+
export function worldInto(out: Mat4, node: SceneNode): Mat4 {
|
|
472
|
+
if (node._node !== null) {
|
|
473
|
+
spatial.worldMatrix(node._node, worldRead)
|
|
474
|
+
for (let i = 0; i < 16; i++) out[i] = worldRead[i]!
|
|
475
|
+
return out
|
|
476
|
+
}
|
|
477
|
+
if (node.parent === null) identity(out)
|
|
478
|
+
else worldInto(out, node.parent)
|
|
479
|
+
return multiply(out, out, compose(localScratch, node.position, node.quaternion, node.scale))
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* `out` = v with m's rotation undone: the transpose of m's upper 3x3 with
|
|
484
|
+
* its columns normalized, so uniform scale divides out. out may alias v.
|
|
485
|
+
*/
|
|
486
|
+
function unrotate(out: Vec3, m: Mat4, v: Vec3): Vec3 {
|
|
487
|
+
let x = v[0], y = v[1], z = v[2]
|
|
488
|
+
let l0 = Math.hypot(m[0], m[1], m[2]) || 1
|
|
489
|
+
let l1 = Math.hypot(m[4], m[5], m[6]) || 1
|
|
490
|
+
let l2 = Math.hypot(m[8], m[9], m[10]) || 1
|
|
491
|
+
out[0] = (m[0] * x + m[1] * y + m[2] * z) / l0
|
|
492
|
+
out[1] = (m[4] * x + m[5] * y + m[6] * z) / l1
|
|
493
|
+
out[2] = (m[8] * x + m[9] * y + m[10] * z) / l2
|
|
494
|
+
return out
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Show or hide a node and its whole subtree (a hidden mesh costs one
|
|
498
|
+
* `instanceCount: 0` draw range - the entry stays, drawing nothing). */
|
|
499
|
+
export function setVisible(node: SceneNode, visible: boolean): void {
|
|
500
|
+
if (node.visible === visible) return
|
|
501
|
+
node.visible = visible
|
|
502
|
+
if (node._node !== null) {
|
|
503
|
+
spatial.setVisible(node._node, visible)
|
|
504
|
+
node._scene?._schedule()
|
|
505
|
+
}
|
|
506
|
+
}
|