@solidrt/3d 0.0.54 → 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.
Files changed (54) hide show
  1. package/AGENTS.md +1121 -177
  2. package/README.md +15 -7
  3. package/examples/README.md +86 -5
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +20 -5
  6. package/examples/collision.tsx +250 -0
  7. package/examples/first-person.tsx +119 -0
  8. package/examples/fleet.tsx +194 -0
  9. package/examples/fog.tsx +173 -0
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +32 -6
  17. package/examples/pick.tsx +2 -4
  18. package/examples/probe.tsx +101 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +32 -3
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +86 -0
  24. package/examples/skybox.tsx +128 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +132 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +199 -0
  32. package/src/color.ts +32 -0
  33. package/src/components.tsx +773 -82
  34. package/src/environment-bake.ts +492 -0
  35. package/src/environment.ts +325 -0
  36. package/src/first-person.ts +306 -0
  37. package/src/geometry.ts +6 -2
  38. package/src/glsl.ts +1390 -82
  39. package/src/gltf.ts +432 -112
  40. package/src/index.ts +57 -9
  41. package/src/light.ts +383 -0
  42. package/src/material.ts +682 -216
  43. package/src/math.ts +110 -0
  44. package/src/mesh.ts +728 -0
  45. package/src/mixer.ts +476 -0
  46. package/src/model-file.ts +80 -15
  47. package/src/model.ts +211 -34
  48. package/src/node.ts +506 -0
  49. package/src/orbit.ts +97 -48
  50. package/src/scene-pointer.ts +166 -0
  51. package/src/scene-shadows.ts +471 -0
  52. package/src/scene.ts +1304 -1532
  53. package/tools/environment.ts +69 -0
  54. package/tools/model.ts +4 -3
package/src/model.ts CHANGED
@@ -1,42 +1,84 @@
1
1
  // Models in a scene: ModelData (parsed glTF or a decoded .srtm) becomes a
2
- // Group of meshes, one per part, with the images uploaded as textures and
3
- // a material per glTF material - Three's `gltf.scene`, an object you add
4
- // to the scene and place with setTransform. The model owns what it
2
+ // Group carrying the file's node hierarchy - nested Groups with the file's
3
+ // local TRS, each part's mesh under its node - with the images uploaded as
4
+ // textures and a material per glTF material: Three's `gltf.scene`, an
5
+ // object you add to the scene and place with setTransform, whose named
6
+ // nodes (`model.nodes`) can be moved individually. The model owns what it
5
7
  // created (geometry buffers, textures): dispose() frees them and detaches
6
8
  // the group. loadGltf / loadModel are the read-then-create conveniences
7
9
  // over flux:fs; parseGltf / decodeModel + createModel are the primitives
8
10
  // under them, for bytes obtained any other way (a binary import, a fetch).
9
11
 
10
12
  import { file } from "flux:fs"
13
+ import * as spatial from "flux:spatial"
11
14
  import { decodeImage } from "@solidrt/core"
12
- import { createTexture, destroyTexture } from "@solidrt/core/gpu"
15
+ import { createMutableTexture, createTexture, destroyTexture } from "@solidrt/core/gpu"
13
16
  import type { TextureId } from "@solidrt/core/gpu"
14
- import { gltfExternalUris, isGlb, parseGltf } from "./gltf.ts"
15
- import type { ModelData, ModelMaterial } from "./gltf.ts"
17
+ import { gltfExternalUris, parseGltf } from "./gltf.ts"
18
+ import type { ModelClip, ModelData, ModelMaterial } from "./gltf.ts"
16
19
  import { decodeModel } from "./model-file.ts"
17
20
  import { disposeGeometry } from "./geometry-gpu.ts"
18
- import { lit } from "./material.ts"
21
+ import { standard } from "./material.ts"
19
22
  import type { Material } from "./material.ts"
20
- import { add, createGroup, createMesh, remove } from "./scene.ts"
21
- import type { Mesh, SceneNode } from "./scene.ts"
23
+ import { add, createGroup, remove, setTransform } from "./node.ts"
24
+ import type { SceneNode } from "./node.ts"
25
+ import { createMesh } from "./mesh.ts"
26
+ import type { Mesh } from "./mesh.ts"
27
+
28
+ /** Anisotropic filtering level for a model's textures: the engines' usual
29
+ * default (Godot ships 2x, Unity's quality presets 2-8x) - enough to keep a
30
+ * tiled surface legible at a grazing angle, cheap on every GPU. Clamped to
31
+ * the device by the runtime. */
32
+ const MODEL_ANISOTROPY = 4
33
+
34
+ /** A glTF material's uploaded textures, by lit()/standard() option name;
35
+ * null where the material has none. */
36
+ export type ModelMaps = {
37
+ map: TextureId | null
38
+ normalMap: TextureId | null
39
+ emissiveMap: TextureId | null
40
+ /** glTF's ONE packed metallicRoughnessTexture under both of standard's
41
+ * names (blue = metalness, green = roughness): pass both through. */
42
+ metalnessMap: TextureId | null
43
+ roughnessMap: TextureId | null
44
+ }
22
45
 
23
46
  export type ModelOptions = {
24
- /** The material for each glTF material (default: `lit` with its color,
25
- * map and transparency). `map` is the uploaded base color texture, or
26
- * null. Called once per material, shared by every part using it. */
27
- material?: (material: ModelMaterial, map: TextureId | null) => Material
47
+ /** The material for each glTF material (default: `standard` with its
48
+ * color, maps, normal scale, metalness/roughness, emissive and
49
+ * transparency - the glTF material model; a scene with no `environment`
50
+ * renders its metals near black, so set one, or return `lit` here for
51
+ * the Blinn-Phong look). `maps` holds the uploaded textures by
52
+ * lit()/standard() option name. Called once per material -
53
+ * or once per (material, skinned) combination when skinned parts share
54
+ * a material with static ones - and shared by every part using it.
55
+ * `skinned` is true when the material must skin (pass it through to
56
+ * `lit`/`unlit`, or read aJoints/aWeights + uBones yourself).
57
+ * `data.materials` is in file order, so the calls arrive in file order
58
+ * too. */
59
+ material?: (material: ModelMaterial, maps: ModelMaps, skinned: boolean) => Material
28
60
  /** Debug name for the textures. */
29
61
  label?: string
30
62
  }
31
63
 
32
- /** A model in the scene: a Group whose children are the parts' meshes. */
64
+ /** A model in the scene: a Group carrying the file's node hierarchy as
65
+ * nested Groups, with each part's mesh a child of its node. */
33
66
  export type Model = SceneNode & {
34
67
  kind: "group"
35
- /** The parts by name, in file order; each `mesh` is a child of the model. */
68
+ /** The parts by name, in file order; each `mesh` sits under its node. */
36
69
  parts: { name: string; mesh: Mesh }[]
70
+ /** The file's retained nodes in table order (parents first), each an
71
+ * ordinary Group under the model: `setTransform` on one moves its
72
+ * subtree - a wheel spins relative to the axle it hangs from. Names
73
+ * repeat when the file's do; find yours with `.find()`/`.filter()`. */
74
+ nodes: { name: string; node: SceneNode }[]
37
75
  /** One per glTF material, in file order. */
38
76
  materials: Material[]
39
- /** Local [minX, minY, minZ, maxX, maxY, maxZ] over every part. */
77
+ /** The file's animation clips (empty when it has none); createMixer
78
+ * plays them. Channel node indices resolve through `nodes`. */
79
+ clips: ModelClip[]
80
+ /** Local rest-pose [minX, minY, minZ, maxX, maxY, maxZ] over every part
81
+ * (conservative for parts under rotated nodes). */
40
82
  bounds: Float32Array
41
83
  /** Detach the model and free its geometry buffers and textures. */
42
84
  dispose(): void
@@ -44,44 +86,178 @@ export type Model = SceneNode & {
44
86
 
45
87
  /**
46
88
  * Build the scene object for parsed model data: upload its images (repeat
47
- * wrap, mipmapped), make a material per glTF material, a mesh per part,
48
- * all under one Group. Synchronous - the data is already in memory.
89
+ * wrap, mipmapped, MODEL_ANISOTROPY; the base color and emissive images
90
+ * as "rgba8-srgb" since glTF stores those sRGB-encoded, the data maps as
91
+ * rgba8), make a material per glTF material, the file's node hierarchy as
92
+ * nested Groups, and a mesh per part under its node. Synchronous - the
93
+ * data is already in memory.
49
94
  */
50
95
  export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
51
96
  let label = opts.label
97
+ let colorImages = new Set<number>()
98
+ for (let m of data.materials) {
99
+ if (m.map !== null) colorImages.add(m.map)
100
+ if (m.emissiveMap !== null) colorImages.add(m.emissiveMap)
101
+ }
52
102
  let textures: TextureId[] = data.images.map((bytes, i) => {
53
103
  let image = decodeImage(bytes)
54
104
  return createTexture(image.data, image.width, image.height, {
105
+ format: colorImages.has(i) ? "rgba8-srgb" : "rgba8",
55
106
  wrap: "repeat",
56
107
  mipmap: true,
108
+ anisotropy: MODEL_ANISOTROPY,
57
109
  autoFree: false,
58
110
  label: label ? label + "-image" + i : undefined,
59
111
  })
60
112
  })
61
- let make = opts.material ?? ((m: ModelMaterial, map: TextureId | null): Material => lit({
62
- color: m.color,
63
- map: map ?? undefined,
64
- transparent: m.transparent,
65
- cull: m.doubleSided ? "none" : "back",
66
- alphaTest: m.alphaMode === "MASK" ? m.alphaCutoff : undefined,
67
- }))
68
- let materials = data.materials.map((m) => make(m, m.map === null ? null : textures[m.map]!))
113
+ let make = opts.material ?? ((m: ModelMaterial, maps: ModelMaps, skinned: boolean): Material => {
114
+ // An emissive factor of zero is emission OFF (the glTF product rule:
115
+ // factor times texture), so the map is skipped too - no sampler for
116
+ // a term that cannot show.
117
+ let emissive = m.emissive[0] > 0 || m.emissive[1] > 0 || m.emissive[2] > 0
118
+ return standard({
119
+ color: m.color,
120
+ map: maps.map ?? undefined,
121
+ normalMap: maps.normalMap ?? undefined,
122
+ normalScale: m.normalScale,
123
+ metalness: m.metalness,
124
+ roughness: m.roughness,
125
+ metalnessMap: maps.metalnessMap ?? undefined,
126
+ roughnessMap: maps.roughnessMap ?? undefined,
127
+ emissive: emissive ? m.emissive : undefined,
128
+ emissiveIntensity: emissive ? m.emissiveIntensity : undefined,
129
+ emissiveMap: emissive ? maps.emissiveMap ?? undefined : undefined,
130
+ transparent: m.transparent,
131
+ cull: m.doubleSided ? "none" : "back",
132
+ alphaTest: m.alphaMode === "MASK" ? m.alphaCutoff : undefined,
133
+ skinned: skinned || undefined,
134
+ })
135
+ })
136
+ let slot = (index: number | null): TextureId | null => (index === null ? null : textures[index]!)
137
+ // One instance per glTF material as today, plus a skinned variant per
138
+ // material the skinned parts bring (a material shared by a static and
139
+ // a skinned part needs two programs - different vertex stages).
140
+ let variants = new Map<string, Material>()
141
+ let materialFor = (index: number, skinned: boolean): Material => {
142
+ let m = data.materials[index]
143
+ if (m === undefined) throw new Error("createModel: a part names a missing material " + index)
144
+ let key = index + (skinned ? "|skinned" : "")
145
+ let made = variants.get(key)
146
+ if (made === undefined) {
147
+ made = make(
148
+ m,
149
+ {
150
+ map: slot(m.map),
151
+ normalMap: slot(m.normalMap),
152
+ emissiveMap: slot(m.emissiveMap),
153
+ metalnessMap: slot(m.metalnessRoughnessMap),
154
+ roughnessMap: slot(m.metalnessRoughnessMap),
155
+ },
156
+ skinned,
157
+ )
158
+ variants.set(key, made)
159
+ }
160
+ return made
161
+ }
162
+ let materials = data.materials.map((_, i) => materialFor(i, false))
69
163
 
70
164
  let model = createGroup() as Model
165
+ // The node table is pre-order, so every parent group exists before its
166
+ // children reference it.
167
+ let groups: SceneNode[] = data.nodes.map((n) => {
168
+ let group = createGroup()
169
+ setTransform(group, { position: n.position, quaternion: n.rotation, scale: n.scale })
170
+ return group
171
+ })
172
+ data.nodes.forEach((n, i) => add(n.parent === null ? model : groups[n.parent]!, groups[i]!))
173
+ model.nodes = data.nodes.map((n, i) => ({ name: n.name, node: groups[i]! }))
174
+ // Each skin's palette lives in an rgba32f texture, 4 texels wide, one
175
+ // row per joint (the four columns of that joint's mat4), sized to the
176
+ // RIG: rig size is bounded by texture height (>= 2048 everywhere), not
177
+ // the vertex uniform budget, so there is no joint cap. The texture id
178
+ // is bound as uBones on every mesh drawing the skin and freed with the
179
+ // model's other textures. The bone matrices themselves are the spatial
180
+ // core's job: each joint node carries a palette-row binding (texture,
181
+ // row, inverse bind, the model root as anchor) that enterScene attaches,
182
+ // so the flush writes model-local jointWorld x inverseBind rows whenever
183
+ // joints move - there is no JS palette walk, and posing joints with
184
+ // setTransform is always enough. Identical skins (same joints, same
185
+ // inverse binds - the body/legs and LOD splits exporters produce) share
186
+ // one texture, so their palette is computed and uploaded once.
187
+ let skinTextures: TextureId[] = []
188
+ data.skins.forEach((skin, i) => {
189
+ let joints = skin.joints.join(",")
190
+ for (let j = 0; j < i; j++) {
191
+ let other = data.skins[j]!
192
+ if (
193
+ other.joints.join(",") === joints &&
194
+ other.inverseBind.length === skin.inverseBind.length &&
195
+ other.inverseBind.every((v, k) => v === skin.inverseBind[k])
196
+ ) {
197
+ skinTextures.push(skinTextures[j]!)
198
+ return
199
+ }
200
+ }
201
+ let texture = createMutableTexture(new Float32Array(skin.joints.length * 16), 4, skin.joints.length, {
202
+ format: "rgba32f",
203
+ autoFree: false,
204
+ label: label ? label + "-skin" + i : "skin" + i,
205
+ })
206
+ textures.push(texture)
207
+ skinTextures.push(texture)
208
+ for (let j = 0; j < skin.joints.length; j++) {
209
+ let joint = groups[skin.joints[j]!]
210
+ if (joint === undefined) throw new Error("createModel: skin " + i + " names a missing node " + skin.joints[j])
211
+ ;(joint._palettes ??= []).push({ texture, row: j, post: skin.inverseBind.slice(j * 16, j * 16 + 16), anchor: model })
212
+ // The joint's influence box, in joint space: culling-only bounds the
213
+ // flush carries through the pose. A joint influencing nothing (an
214
+ // inverted box) gets none and contributes nothing to the union.
215
+ let jb = skin.jointBounds.subarray(j * 6, j * 6 + 6)
216
+ if (jb[0]! <= jb[3]! && jb[1]! <= jb[4]! && jb[2]! <= jb[5]!) joint._cullBounds = Float32Array.from(jb)
217
+ }
218
+ })
71
219
  model.parts = data.parts.map((part) => {
72
- let material = materials[part.material]
73
- if (material === undefined) throw new Error("createModel: part '" + part.name + "' names a missing material " + part.material)
220
+ let skinned = part.skin !== null
221
+ let material = materialFor(part.material, skinned)
74
222
  let mesh = createMesh(part.geometry, material)
75
- add(model, mesh)
223
+ if (skinned) {
224
+ // A skinned part's vertices are model-space bind pose and the skin
225
+ // matrices place them, so its mesh hangs off the model root (the
226
+ // spec ignores the node's transform for skinned meshes) and uModel
227
+ // stays the model's own placement.
228
+ let texture = skinTextures[part.skin!]
229
+ if (texture === undefined) throw new Error("createModel: part '" + part.name + "' names a missing skin " + part.skin)
230
+ mesh._textures = { uBones: texture }
231
+ // Culled by its joints' boxes, so the box follows the animation.
232
+ mesh._cullJoints = data.skins[part.skin!]!.joints.map((n) => groups[n]!)
233
+ add(model, mesh)
234
+ } else {
235
+ let node = groups[part.node]
236
+ if (node === undefined) throw new Error("createModel: part '" + part.name + "' names a missing node " + part.node)
237
+ add(node, mesh)
238
+ }
76
239
  return { name: part.name, mesh }
77
240
  })
78
241
  model.materials = materials
242
+ model.clips = data.clips
79
243
  model.bounds = data.bounds
80
244
  model.dispose = () => {
81
245
  if (model.parent !== null) remove(model)
82
246
  for (let part of model.parts) disposeGeometry(part.mesh.geometry)
83
247
  for (let id of textures) destroyTexture(id)
84
248
  textures.length = 0
249
+ // Core clips a mixer registered; their players drop at the next
250
+ // advance (the leave above already killed the target nodes).
251
+ for (let clip of model.clips) {
252
+ if (clip._core !== undefined) {
253
+ spatial.destroyClip(clip._core)
254
+ clip._core = undefined
255
+ }
256
+ if (clip._coreVariants !== undefined) {
257
+ for (let id of clip._coreVariants.values()) spatial.destroyClip(id)
258
+ clip._coreVariants = undefined
259
+ }
260
+ }
85
261
  }
86
262
  return model
87
263
  }
@@ -94,12 +270,13 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
94
270
  */
95
271
  export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model> {
96
272
  let bytes = await file(path).bytes()
273
+ // A .glb usually embeds everything, but external uris are legal there
274
+ // too (some exporters keep images as files), so both containers get
275
+ // the same prefetch; gltfExternalUris is empty for a self-contained one.
97
276
  let files = new Map<string, Uint8Array>()
98
- if (!isGlb(bytes)) {
99
- let dir = path.slice(0, path.lastIndexOf("/") + 1)
100
- for (let uri of gltfExternalUris(bytes)) {
101
- if (!files.has(uri)) files.set(uri, await file(dir + decodeURIComponent(uri)).bytes())
102
- }
277
+ let dir = path.slice(0, path.lastIndexOf("/") + 1)
278
+ for (let uri of gltfExternalUris(bytes)) {
279
+ if (!files.has(uri)) files.set(uri, await file(dir + decodeURIComponent(uri)).bytes())
103
280
  }
104
281
  return createModel(parseGltf(bytes, (uri) => files.get(uri)!), opts)
105
282
  }