@solidrt/3d 0.0.57 → 0.0.59

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.
Files changed (72) hide show
  1. package/AGENTS.md +1479 -678
  2. package/README.md +4 -2
  3. package/examples/README.md +56 -4
  4. package/examples/bloom.tsx +113 -0
  5. package/examples/cascades.tsx +4 -4
  6. package/examples/collision.tsx +6 -6
  7. package/examples/exits.tsx +96 -0
  8. package/examples/first-person.tsx +5 -5
  9. package/examples/fleet.tsx +4 -4
  10. package/examples/fog.tsx +5 -5
  11. package/examples/instanced.tsx +70 -50
  12. package/examples/lamps.tsx +4 -4
  13. package/examples/lod.tsx +125 -0
  14. package/examples/materials.tsx +9 -9
  15. package/examples/morph.tsx +174 -0
  16. package/examples/normals.tsx +118 -0
  17. package/examples/{lit.tsx → phong.tsx} +9 -9
  18. package/examples/polyhedra.tsx +61 -0
  19. package/examples/scene-atlas.tsx +70 -0
  20. package/examples/scene-views.tsx +5 -5
  21. package/examples/shadows.tsx +9 -9
  22. package/examples/sky-lit.tsx +3 -3
  23. package/examples/skybox.tsx +5 -5
  24. package/examples/standard.tsx +2 -2
  25. package/examples/streams.tsx +122 -0
  26. package/examples/sweep-paths.tsx +5 -5
  27. package/examples/wireframe.tsx +129 -0
  28. package/package.json +3 -3
  29. package/src/clip.ts +18 -5
  30. package/src/color.ts +11 -2
  31. package/src/components/context.tsx +11 -2
  32. package/src/components/directional-light.tsx +2 -2
  33. package/src/components/first-person-camera.tsx +4 -4
  34. package/src/components/group.tsx +9 -5
  35. package/src/components/hemisphere-light.tsx +2 -2
  36. package/src/components/index.ts +4 -1
  37. package/src/components/instanced-mesh.tsx +28 -10
  38. package/src/components/lod.tsx +102 -0
  39. package/src/components/mesh.tsx +24 -6
  40. package/src/components/node-props.ts +35 -5
  41. package/src/components/orbit-camera.tsx +15 -12
  42. package/src/components/point-light.tsx +2 -2
  43. package/src/components/record-mesh.tsx +12 -7
  44. package/src/components/scene.tsx +55 -2
  45. package/src/components/spot-light.tsx +2 -2
  46. package/src/components/sprite.tsx +2 -2
  47. package/src/components/view3d.tsx +18 -1
  48. package/src/environment.ts +9 -8
  49. package/src/first-person.ts +125 -38
  50. package/src/geometry-gpu.ts +187 -22
  51. package/src/geometry.ts +1718 -203
  52. package/src/glsl.ts +398 -113
  53. package/src/gltf.ts +457 -154
  54. package/src/index.ts +32 -21
  55. package/src/input.ts +11 -2
  56. package/src/light.ts +7 -8
  57. package/src/lod.ts +148 -0
  58. package/src/material.ts +322 -190
  59. package/src/mesh.ts +492 -259
  60. package/src/mixer.ts +27 -14
  61. package/src/model-file.ts +54 -15
  62. package/src/model.ts +151 -54
  63. package/src/motion.ts +17 -0
  64. package/src/node.ts +423 -23
  65. package/src/orbit.ts +378 -116
  66. package/src/profile.ts +4 -25
  67. package/src/resolve.ts +181 -0
  68. package/src/scene-shadows.ts +94 -93
  69. package/src/scene.ts +608 -185
  70. package/src/skeleton.ts +211 -0
  71. package/src/sweep.ts +27 -8
  72. package/tools/model.ts +3 -1
@@ -0,0 +1,211 @@
1
+ // Shared skeletons. Wardrobe pieces (a hood, a cape, cuffs) export with
2
+ // a skin over the body's joints - the same joint names, their own inverse
3
+ // binds - and no clips of their own, so a piece added beside an animated
4
+ // body holds its bind pose. bindSkeleton makes the piece's skin read the
5
+ // BODY's joints instead of its own: each matched joint's palette row is
6
+ // re-bound onto the body's node, with the piece's inverse bind as the
7
+ // row's post-multiply, so the frame's flush writes the body's pose into
8
+ // the piece's palette and no per-frame code exists anywhere. One
9
+ // skeleton, many skinned meshes: Three's SkinnedMesh.bind(skeleton),
10
+ // Unity's SkinnedMeshRenderer.bones, Godot's MeshInstance3D.skeleton.
11
+ //
12
+ // The piece's own node tree is deliberately NOT posed or composed:
13
+ // exporters truncate it above (a hat's table starts at the spine, the
14
+ // missing ancestors folded into its first local) or skip an ancestor in
15
+ // the middle (a cape parents Spine_1 straight to Root_0), and copying
16
+ // locals through such a tree hangs joints off the wrong place while
17
+ // looking right for every piece whose tree happens to match. A bone
18
+ // matrix needs the joint's WORLD matrix, which the body's node has, and
19
+ // the piece's inverse binds agree with the body's whenever the piece
20
+ // renders at rest beside it - checked at bind against the body's own.
21
+ // Joints with no body counterpart (a hat's internal bones) keep their own
22
+ // node, grafted under the nearest matched body joint so they ride the
23
+ // pose; so does any rigid part hanging off a matched node.
24
+
25
+ import * as spatial from "flux:spatial"
26
+ import type { TextureId } from "@solidrt/core/gpu"
27
+ import { add, remove, setTransform } from "./node.ts"
28
+ import type { SceneNode } from "./node.ts"
29
+ import type { Model } from "./model.ts"
30
+
31
+ /** Inverse-bind entries of a piece and its body differ by exporter
32
+ * rounding (1e-6 or so); a piece exported against another rest pose or
33
+ * scale is off by centimetres. The gate between the two. */
34
+ const BIND_TOLERANCE = 1e-3
35
+
36
+ /** A piece bound onto a body's skeleton: the piece, and its matched
37
+ * joints' influence boxes (in joint space) keyed by the body joint that
38
+ * now drives them - the body's cull-bounds union takes them in. */
39
+ export type WornPiece = {
40
+ piece: Model
41
+ boxes: { joint: SceneNode; box: Float32Array }[]
42
+ }
43
+
44
+ /**
45
+ * Drive `piece`'s skins from `body`'s skeleton: a wardrobe piece exported
46
+ * over the body's joints (matched by name, case-insensitive) with no
47
+ * clips of its own. After this the piece hangs under the body at the
48
+ * body's placement, its skinned parts follow every pose the body takes -
49
+ * mixer-driven or hand-posed - and its joints with no body counterpart
50
+ * ride the nearest matched body joint. The piece's own joint nodes are
51
+ * no longer posed by anything; socket items on the body's joints. Throws
52
+ * when the piece has no skin, shares no joint name with the body, or a
53
+ * shared joint's bind pose differs (a piece exported against another
54
+ * rest pose). A piece is taken off by disposing it; disposing the body
55
+ * disposes what it wears.
56
+ */
57
+ export function bindSkeleton(body: Model, piece: Model): void {
58
+ if (piece === body) throw new Error("bindSkeleton: a model cannot wear itself")
59
+ if (piece._body !== null) throw new Error("bindSkeleton: the piece is already bound to a body")
60
+ if (body._body !== null) throw new Error("bindSkeleton: the body is itself a worn piece")
61
+ if (piece._skins.length === 0) throw new Error("bindSkeleton: the piece has no skin")
62
+
63
+ // Out of the scene first: the piece's rows must release before they
64
+ // re-bind under a different anchor (one anchor per palette texture),
65
+ // and the add() under the body re-enters everything below.
66
+ remove(piece)
67
+
68
+ let bodyByName = new Map<string, SceneNode>()
69
+ for (let n of body.nodes) {
70
+ let key = n.name.toLowerCase()
71
+ if (!bodyByName.has(key)) bodyByName.set(key, n.node)
72
+ }
73
+ let bodyBind = new Map<SceneNode, Float32Array>()
74
+ for (let s of body._skins) s.joints.forEach((j, k) => bodyBind.set(j, s.inverseBind.subarray(k * 16, k * 16 + 16)))
75
+ let match = new Map<SceneNode, SceneNode>()
76
+ let names = new Map<SceneNode, string>()
77
+ for (let n of piece.nodes) {
78
+ names.set(n.node, n.name)
79
+ let b = bodyByName.get(n.name.toLowerCase())
80
+ if (b !== undefined) match.set(n.node, b)
81
+ }
82
+ if (match.size === 0) throw new Error("bindSkeleton: no node of the piece is named like a node of the body")
83
+
84
+ // Matched rows move onto the body's joints. Identical skins share one
85
+ // texture, so its rows move with the first skin naming it; the check
86
+ // and the boxes run for every skin regardless.
87
+ let rebound = new Set<TextureId>()
88
+ let boxes: WornPiece["boxes"] = []
89
+ for (let s of piece._skins) {
90
+ let first = !rebound.has(s.texture)
91
+ rebound.add(s.texture)
92
+ s.joints.forEach((joint, k) => {
93
+ let b = match.get(joint)
94
+ if (b === undefined) return
95
+ let post = s.inverseBind.subarray(k * 16, k * 16 + 16)
96
+ let own = bodyBind.get(b)
97
+ if (own !== undefined && !sameMatrix(own, post)) {
98
+ throw new Error("bindSkeleton: joint '" + names.get(joint) + "' has a different bind pose on the piece than on the body")
99
+ }
100
+ let box = s.jointBounds.subarray(k * 6, k * 6 + 6)
101
+ if (validBox(box)) boxes.push({ joint: b, box: Float32Array.from(box) })
102
+ if (!first || joint._palettes === null) return
103
+ let at = joint._palettes.findIndex((p) => p.texture === s.texture)
104
+ if (at < 0) return
105
+ let row = joint._palettes.splice(at, 1)[0]!
106
+ if (joint._palettes.length === 0) joint._palettes = null
107
+ ;(b._palettes ??= []).push({ texture: s.texture, row: k, post: row.post, anchor: body })
108
+ if (b._node !== null && body._node !== null) spatial.bindTextureSlot(b._node, s.texture, k, row.post, body._node)
109
+ })
110
+ }
111
+ // The rows that stay on the piece's own nodes (unmatched joints) share
112
+ // the texture's one anchor: the body root, an ancestor of every bound
113
+ // node once the piece and its grafts hang under the body.
114
+ for (let n of piece.nodes) {
115
+ if (n.node._palettes === null) continue
116
+ for (let p of n.node._palettes) if (p.anchor === piece) p.anchor = body
117
+ }
118
+ for (let part of piece.parts) {
119
+ let joints = part.mesh._cullJoints
120
+ if (joints !== null) part.mesh._cullJoints = joints.map((j) => match.get(j) ?? j)
121
+ }
122
+ // Graft: whatever hangs off a matched node that is not itself matched
123
+ // (an unmatched joint subtree, a rigid part) moves under the body's
124
+ // node with its local transform intact - the two nodes share a bind
125
+ // pose, so the local means the same thing there.
126
+ for (let [node, bodyNode] of match) {
127
+ for (let child of node.children.slice()) {
128
+ if (match.has(child)) continue
129
+ add(bodyNode, child)
130
+ piece._grafts.push(child)
131
+ }
132
+ }
133
+
134
+ piece._body = body
135
+ body._worn.push({ piece, boxes })
136
+ refreshJointBounds(body)
137
+ // A piece's skinned vertices are in the body's model space, so it takes
138
+ // the body's placement and nothing else.
139
+ setTransform(piece, { position: [0, 0, 0], quaternion: [0, 0, 0, 1], scale: [1, 1, 1] })
140
+ add(body, piece)
141
+ }
142
+
143
+ /** @internal Take a worn piece off its body: its rows leave the body's
144
+ * joints, its grafts detach, the body's joint boxes shrink back. The
145
+ * piece's own bindings are not restored - dispose is the one caller, and
146
+ * it destroys the piece's palette textures right after. */
147
+ export function unbindSkeleton(piece: Model): void {
148
+ let body = piece._body
149
+ if (body === null) return
150
+ let mine = new Set(piece._skins.map((s) => s.texture))
151
+ for (let n of body.nodes) {
152
+ let rows = n.node._palettes
153
+ if (rows === null) continue
154
+ let keep = rows.filter((p) => !mine.has(p.texture))
155
+ if (keep.length === rows.length) continue
156
+ if (n.node._node !== null) {
157
+ for (let p of rows) if (mine.has(p.texture)) spatial.unbindTextureSlot(n.node._node, p.texture)
158
+ }
159
+ n.node._palettes = keep.length > 0 ? keep : null
160
+ }
161
+ for (let g of piece._grafts) remove(g)
162
+ piece._grafts = []
163
+ body._worn.splice(body._worn.findIndex((w) => w.piece === piece), 1)
164
+ piece._body = null
165
+ refreshJointBounds(body)
166
+ }
167
+
168
+ /** @internal Recompute every joint's culling box from the model's skins
169
+ * and the pieces it wears: the union, per joint, of each skin's joint-
170
+ * space influence box (a body/legs split and a worn hood all weight the
171
+ * head), applied to the live core node where it changed. A joint no skin
172
+ * reaches gets none and contributes nothing to a part's cull group. */
173
+ export function refreshJointBounds(model: Model): void {
174
+ let boxes = new Map<SceneNode, Float32Array>()
175
+ let grow = (joint: SceneNode, box: Float32Array): void => {
176
+ if (!validBox(box)) return
177
+ let have = boxes.get(joint)
178
+ if (have === undefined) {
179
+ boxes.set(joint, Float32Array.from(box))
180
+ return
181
+ }
182
+ for (let i = 0; i < 3; i++) {
183
+ have[i] = Math.min(have[i]!, box[i]!)
184
+ have[i + 3] = Math.max(have[i + 3]!, box[i + 3]!)
185
+ }
186
+ }
187
+ for (let s of model._skins) s.joints.forEach((j, k) => grow(j, s.jointBounds.subarray(k * 6, k * 6 + 6)))
188
+ for (let w of model._worn) for (let b of w.boxes) grow(b.joint, b.box)
189
+ for (let n of model.nodes) {
190
+ let box = boxes.get(n.node) ?? null
191
+ if (sameBox(n.node._cullBounds, box)) continue
192
+ n.node._cullBounds = box
193
+ if (n.node._node !== null) spatial.setCullBounds(n.node._node, box)
194
+ }
195
+ }
196
+
197
+ /** A joint influencing no vertex carries an inverted box (min > max). */
198
+ function validBox(box: Float32Array): boolean {
199
+ return box[0]! <= box[3]! && box[1]! <= box[4]! && box[2]! <= box[5]!
200
+ }
201
+
202
+ function sameBox(a: Float32Array | null, b: Float32Array | null): boolean {
203
+ if (a === null || b === null) return a === b
204
+ for (let i = 0; i < 6; i++) if (a[i] !== b[i]) return false
205
+ return true
206
+ }
207
+
208
+ function sameMatrix(a: Float32Array, b: Float32Array): boolean {
209
+ for (let i = 0; i < 16; i++) if (Math.abs(a[i]! - b[i]!) > BIND_TOLERANCE) return false
210
+ return true
211
+ }
package/src/sweep.ts CHANGED
@@ -1,14 +1,14 @@
1
- // Swept solids: the generators that run a Profile (profile.ts) along a
2
- // path. extrude() sweeps along a straight line (z), lathe() around a
3
- // circle (about y), sweep() along an arbitrary 3D polyline with mitred
4
- // joints, and tube() is sweep() with a circular profile - the wire, rope
5
- // and pipe primitive. Output is the shared vertex layout of geometry.ts
1
+ // Profile generators: polygon() fills a Profile (profile.ts) as a flat
2
+ // face, the rest sweep it along a path. extrude() sweeps along a straight
3
+ // line (z), lathe() around a circle (about y), sweep() along an arbitrary
4
+ // 3D polyline with mitred joints, and tube() is sweep() with a circular
5
+ // profile - the wire, rope and pipe primitive. Output is the shared vertex layout of geometry.ts
6
6
  // with real texture UVs; winding is CCW seen from outside like every
7
7
  // generator, so cull: "back" works. Indices pick Uint16Array or
8
8
  // Uint32Array by vertex count - filleted profiles times many path points
9
9
  // make dense outputs routine here.
10
10
 
11
- import { STANDARD_FLOATS, packGeometry } from "./geometry.ts"
11
+ import { BASE_FLOATS, packGeometry } from "./geometry.ts"
12
12
  import type { Geometry, GeometryOptions } from "./geometry.ts"
13
13
  import { add, cross, dot, normalize, scale, sub } from "./math.ts"
14
14
  import type { Vec3 } from "./math.ts"
@@ -58,7 +58,7 @@ function emitCap(
58
58
  mirrorU: boolean,
59
59
  flip: boolean,
60
60
  ): void {
61
- let base = verts.length / STANDARD_FLOATS
61
+ let base = verts.length / BASE_FLOATS
62
62
  for (let i = 0; i < px.length; i++) {
63
63
  let p = place(px[i]!, py[i]!)
64
64
  let u = mirrorU ? (b.maxX - px[i]!) / b.w : (px[i]! - b.minX) / b.w
@@ -70,6 +70,25 @@ function emitCap(
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * The profile filled as a flat face in the XY plane facing +z - the
75
+ * general case of circle()/ring() for arbitrary outlines, and the 2D
76
+ * counterpart of polyhedron(). UVs map the
77
+ * profile's bounding box to the unit square like plane(); rotate flat the
78
+ * same way: `rotation={[-Math.PI / 2, 0, 0]}`.
79
+ */
80
+ export function polygon(profile: Profile, options: GeometryOptions = {}): Geometry {
81
+ let pts = normalizeProfile(profile)
82
+ let { minX, maxY, w, h } = profileBounds(pts)
83
+ let px = pts.map((p) => p.x)
84
+ let py = pts.map((p) => p.y)
85
+ let verts: number[] = []
86
+ for (let p of pts) {
87
+ verts.push(p.x, p.y, 0, 0, 0, 1, (p.x - minX) / w, (maxY - p.y) / h)
88
+ }
89
+ return packGeometry(verts, earClip(px, py), options)
90
+ }
91
+
73
92
  /**
74
93
  * The profile swept along z, centered on the origin: z runs from depth/2
75
94
  * down to -depth/2, caps at both ends. `bevel` rounds both rims with a
@@ -348,7 +367,7 @@ export function sweep(profile: Profile, path: SweepPath, options: GeometryOption
348
367
  let verts: number[] = []
349
368
  let indices: number[] = []
350
369
  let emitRing = (pos: Vec3[], normals: Vec3[], v: number): number => {
351
- let base = verts.length / STANDARD_FLOATS
370
+ let base = verts.length / BASE_FLOATS
352
371
  for (let k = 0; k < entries.length; k++) {
353
372
  let q = pos[k]!
354
373
  let m = normals[k]!
package/tools/model.ts CHANGED
@@ -45,9 +45,11 @@ let encoded = encodeModel(data)
45
45
  writeFileSync(output, encoded)
46
46
 
47
47
  let triangles = data.parts.reduce((n, p) => n + p.geometry.indices.length / 3, 0)
48
- let vertices = data.parts.reduce((n, p) => n + p.geometry.vertices.length / layoutStride(p.geometry.layout), 0)
48
+ let vertices = data.parts.reduce((n, p) => n + p.geometry.vertices.byteLength / layoutStride(p.geometry.layout), 0)
49
+ let targets = data.parts.reduce((n, p) => n + (p.geometry.morphs?.names.length ?? 0), 0)
49
50
  console.log(
50
51
  `${output}: ${data.nodes.length} nodes, ${data.parts.length} parts, ${vertices} vertices, ${triangles} triangles, ` +
52
+ (targets > 0 ? `${targets} morph targets, ` : "") +
51
53
  `${data.clips.length} clips, ${data.materials.length} materials, ${data.images.length} images, ${(encoded.byteLength / 1024).toFixed(0)} KiB ` +
52
54
  `(parsed in ${parsed.toFixed(0)} ms)`,
53
55
  )