@solidrt/3d 0.0.60 → 0.0.61

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.
@@ -0,0 +1,27 @@
1
+ // @solidrt/3d/model - the model DATA side of the package without the
2
+ // runtime: the glTF parser, the .srtm container and the pure geometry kit,
3
+ // for a script that bakes an app's own geometry under bun (or any host
4
+ // with no flux). The package root imports `flux:*` and so loads only on
5
+ // the runtime; this entry imports nothing that does. tools/model.ts (the
6
+ // glTF bake) runs on exactly this surface, so an app's bake script and
7
+ // the tool share one path:
8
+ //
9
+ // import { box, encodeModel, transformGeometry } from "@solidrt/3d/model"
10
+ // writeFileSync("assets/terrain.srtm", encodeModel({ nodes, parts, ... }))
11
+ //
12
+ // createModel / loadModel on the runtime read what it writes. Keep this
13
+ // entry pure: tests/model-data.test.ts imports it under bun and fails the
14
+ // moment a runtime import creeps into the chain.
15
+
16
+ export { gltfExternalUris, isGlb, parseGltf } from "./gltf.ts"
17
+ export type { ModelChannel, ModelClip, ModelData, ModelExtras, ModelMaterial, ModelNode, ModelPart, ModelSkin, UriResolver } from "./gltf.ts"
18
+ export { decodeModel, encodeModel } from "./model-file.ts"
19
+ export { arrowHelper, axesHelper, box, box3Helper, capsule, capsuleHelper, circle, cone, cylinder, dodecahedron, fillAttribute, fillColors, edgesGeometry, mergeVertices, normalsHelper, toNonIndexed, withNormals, geometryBounds, geometryTopology, gridHelper, icosahedron, attributeAccess, geometryAttribute, geometryKey, geometryLayouts, geometrySlot, geometryStreams, geometryVertexCount, isFloatFormat, isFloatLayout, layoutAttributes, layoutKey, layoutSlot, layoutStride, mergeGeometries, octahedron, packGeometry, packMorphTargets, plane, planeHelper, polyhedron, ring, sphere, tetrahedron, torus, torusKnot, transformGeometry, validateGeometry, wireframeGeometry, vertexBytes, vertexCount, vertexView, withAttribute, withColors, withMorphTargets, MORPH_ENTRY_TEXELS, MORPH_TEXEL_FLOATS, BASE_FLOATS, VERTEX_FORMATS, VERTEX_LAYOUTS } from "./geometry.ts"
20
+ export type { ArrowHelperOptions, AttributeAccess, AttributeFill, VertexStream, WithAttributeOptions, AxesHelperOptions, BoxOptions, CapsuleHelperOptions, CapsuleOptions, CircleOptions, ColorFill, ConeOptions, CylinderOptions, FormatCodec, Geometry, GeometryOptions, GridHelperOptions, MorphTarget, MorphTargets, NormalsHelperOptions, PlaneHelperOptions, PlaneOptions, PolyhedronOptions, RingOptions, SphereOptions, TorusKnotOptions, TorusOptions, VertexLayout } from "./geometry.ts"
21
+ export { fillet, roundRect, triangulate } from "./profile.ts"
22
+ export type { Profile, ProfilePoint } from "./profile.ts"
23
+ export { extrude, lathe, pathFrames, polygon, sweep, tube } from "./sweep.ts"
24
+ export type { ExtrudeOptions, LatheOptions, PathFrames, PathPoint, SweepPath, TubeOptions } from "./sweep.ts"
25
+ export { linearColor, linearToSrgb, premultipliedColor, srgbToLinear } from "./color.ts"
26
+ export { rayBoxDistance, compose, copy, eulerFromQuat, identity, lookAt, mat4, multiply, normalMatrix, orthographic, perspective, quat, quatFromAxisAngle, quatFromEuler, quatFromFrame, quatFromTo, quatMultiply, quatNormalize, quatSlerp } from "./math.ts"
27
+ export type { Mat4, Quat, Vec2, Vec3 } from "./math.ts"
package/src/model-file.ts CHANGED
@@ -7,12 +7,19 @@
7
7
  // "SRTM" u32 | version u32 | jsonLength u32 | json (padded to 4) | payload
8
8
  // The JSON header describes each block's byte range into the payload; every
9
9
  // block starts 4-aligned so Float32Array/Uint32Array views sit on it
10
- // directly. Images travel as their encoded files (PNG/JPEG bytes).
10
+ // directly. Images travel as their encoded files (PNG/JPEG bytes), app
11
+ // data as the header's `extras` (JSON) and named `blobs` (aligned blocks).
12
+ // Geometry is a table the parts index, and a buffer view is written once
13
+ // however many geometries read it: parts sharing a Geometry object (a
14
+ // skinned or morphed mesh several nodes place) share one entry, and two
15
+ // geometries over one vertex buffer (a mirrored copy: same vertices, the
16
+ // flipped index order) share the vertex block - decodeModel restores the
17
+ // same identities, so the runtime uploads shared bytes once.
11
18
 
12
- import type { ModelChannel, ModelData, ModelMaterial, ModelNode, ModelSkin } from "./gltf.ts"
19
+ import type { ModelChannel, ModelData, ModelExtras, ModelMaterial, ModelNode, ModelSkin } from "./gltf.ts"
13
20
  import { layoutStride, vertexView, VERTEX_FORMATS } from "./geometry.ts"
14
21
  import type { VertexAttribute } from "@solidrt/core/gpu"
15
- import type { VertexLayout } from "./geometry.ts"
22
+ import type { Geometry, VertexLayout } from "./geometry.ts"
16
23
 
17
24
  /** "SRTM" read as a little-endian u32. */
18
25
  const MAGIC = 0x4d545253
@@ -39,7 +46,15 @@ const MAGIC = 0x4d545253
39
46
  // Version 9 names the plain layout "base" (position, normal, uv - the
40
47
  // prefix "colored" and "skinned" extend) where version 8 wrote
41
48
  // "standard"; a version-8 file's layout word does not parse.
42
- const VERSION = 9
49
+ // Version 10 carries app data and reuse: `extras` (JSON) on the root, the
50
+ // nodes, the parts and the materials, named binary `blobs` as payload
51
+ // blocks, a part's `placements` (the further nodes drawing the same
52
+ // geometry), and geometry as a table the parts index (shared entries and
53
+ // shared blocks, see the header). A version-9 file has none of these and is rejected like
54
+ // every earlier version rather than read as a file without them, so a
55
+ // stale bake never silently drops the data an app relies on. Re-bake
56
+ // with `srt tool 3d/model`.
57
+ const VERSION = 10
43
58
 
44
59
  // The named layouts the container writes by name; a custom attribute-list
45
60
  // layout (a skinned primitive with COLOR_0, a withAttribute channel) is
@@ -61,17 +76,29 @@ function decodeLayout(layout: string | VertexAttribute[], name: string): VertexL
61
76
 
62
77
  type Block = { offset: number; bytes: number }
63
78
 
64
- type PartHeader = Block & {
65
- name: string
66
- node: number
67
- skin: number | null
68
- material: number
79
+ /** One geometry of the table: its interleaved vertex block, index block
80
+ * and packed morph targets. Blocks may be shared between entries. */
81
+ type GeometryHeader = {
82
+ vertices: Block
69
83
  layout: string | VertexAttribute[]
70
84
  vertexCount: number
71
85
  indexBits: 16 | 32
72
86
  index: Block
73
- /** The part's packed morph targets, when it has any. */
87
+ /** The packed morph targets, when it has any. */
74
88
  morphs?: { names: string[]; texels: Block; extent: number[] }
89
+ label?: string
90
+ }
91
+
92
+ type PartHeader = {
93
+ name: string
94
+ node: number
95
+ skin: number | null
96
+ material: number
97
+ /** Index into the header's geometry table. */
98
+ geometry: number
99
+ /** The further nodes placing the part's geometry (ModelPart.placements). */
100
+ placements?: number[]
101
+ extras?: ModelExtras
75
102
  }
76
103
 
77
104
  type SkinHeader = { joints: number[]; inverseBind: Block; jointBounds: Block }
@@ -90,10 +117,16 @@ type Header = {
90
117
  nodes: ModelNode[]
91
118
  materials: ModelMaterial[]
92
119
  images: Block[]
120
+ geometries: GeometryHeader[]
93
121
  parts: PartHeader[]
94
122
  skins: SkinHeader[]
95
123
  clips: ClipHeader[]
96
124
  bounds: number[]
125
+ /** The root extras (ModelData.extras); node and material extras ride
126
+ * inside their own records. */
127
+ extras?: ModelExtras
128
+ /** The named binary sections (ModelData.blobs), each its own block. */
129
+ blobs?: Record<string, Block>
97
130
  }
98
131
 
99
132
  /** Serialize a model into the .srtm container. */
@@ -112,29 +145,49 @@ export function encodeModel(data: ModelData): Uint8Array {
112
145
  return block
113
146
  }
114
147
 
115
- let parts: PartHeader[] = data.parts.map((part) => {
116
- let g = part.geometry
148
+ // A buffer view is written once however many geometries read it, and a
149
+ // Geometry object gets one table entry however many parts draw it.
150
+ let views = new Map<ArrayBufferView, Block>()
151
+ let pushOnce = (view: ArrayBufferView): Block => {
152
+ let have = views.get(view)
153
+ if (have !== undefined) return have
154
+ let block = push(new Uint8Array(view.buffer, view.byteOffset, view.byteLength))
155
+ views.set(view, block)
156
+ return block
157
+ }
158
+ let geometries: GeometryHeader[] = []
159
+ let geometryIds = new Map<Geometry, number>()
160
+ let geometryId = (g: Geometry, name: string): number => {
161
+ let id = geometryIds.get(g)
162
+ if (id !== undefined) return id
117
163
  if (g.streams !== undefined && g.streams.length > 0) {
118
- throw new Error("encodeModel: part '" + part.name + "' carries extra vertex streams; the container writes one interleaved buffer per part")
164
+ throw new Error("encodeModel: part '" + name + "' carries extra vertex streams; the container writes one interleaved buffer per geometry")
119
165
  }
120
166
  let layout = g.layout === undefined ? "base" : g.layout
121
- let vertices = push(new Uint8Array(g.vertices.buffer, g.vertices.byteOffset, g.vertices.byteLength))
122
- let index = push(new Uint8Array(g.indices.buffer, g.indices.byteOffset, g.indices.byteLength))
167
+ let header: GeometryHeader = {
168
+ vertices: pushOnce(g.vertices),
169
+ layout,
170
+ vertexCount: g.vertices.byteLength / layoutStride(layout),
171
+ indexBits: g.indices instanceof Uint32Array ? 32 : 16,
172
+ index: pushOnce(g.indices),
173
+ }
174
+ if (g.morphs !== undefined) header.morphs = { names: g.morphs.names, texels: pushOnce(g.morphs.texels), extent: Array.from(g.morphs.extent) }
175
+ if (g.label !== undefined) header.label = g.label
176
+ id = geometries.length
177
+ geometries.push(header)
178
+ geometryIds.set(g, id)
179
+ return id
180
+ }
181
+ let parts: PartHeader[] = data.parts.map((part) => {
123
182
  let header: PartHeader = {
124
- ...vertices,
125
183
  name: part.name,
126
184
  node: part.node,
127
185
  skin: part.skin,
128
186
  material: part.material,
129
- layout,
130
- vertexCount: g.vertices.byteLength / layoutStride(layout),
131
- indexBits: g.indices instanceof Uint32Array ? 32 : 16,
132
- index,
133
- }
134
- if (g.morphs !== undefined) {
135
- let texels = push(new Uint8Array(g.morphs.texels.buffer, g.morphs.texels.byteOffset, g.morphs.texels.byteLength))
136
- header.morphs = { names: g.morphs.names, texels, extent: Array.from(g.morphs.extent) }
187
+ geometry: geometryId(part.geometry, part.name),
137
188
  }
189
+ if (part.placements !== undefined && part.placements.length > 0) header.placements = part.placements
190
+ if (part.extras !== undefined) header.extras = part.extras
138
191
  return header
139
192
  })
140
193
  let images = data.images.map((image) => push(image))
@@ -151,7 +204,12 @@ export function encodeModel(data: ModelData): Uint8Array {
151
204
  values: floats(c.values),
152
205
  })),
153
206
  }))
154
- let header: Header = { nodes: data.nodes, materials: data.materials, images, parts, skins, clips, bounds: Array.from(data.bounds) }
207
+ let header: Header = { nodes: data.nodes, materials: data.materials, images, geometries, parts, skins, clips, bounds: Array.from(data.bounds) }
208
+ if (data.extras !== undefined) header.extras = data.extras
209
+ if (data.blobs !== undefined) {
210
+ header.blobs = {}
211
+ for (let [name, bytes] of Object.entries(data.blobs)) header.blobs[name] = push(bytes)
212
+ }
155
213
 
156
214
  let json = new TextEncoder().encode(JSON.stringify(header))
157
215
  let jsonPadded = json.byteLength + ((4 - (json.byteLength % 4)) % 4)
@@ -187,26 +245,37 @@ export function decodeModel(bytes: Uint8Array): ModelData {
187
245
  let header: Header = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, base + 12, jsonLength)))
188
246
  let payload = base + 12 + jsonLength + ((4 - (jsonLength % 4)) % 4)
189
247
 
190
- let parts = header.parts.map((part) => {
191
- let layout = decodeLayout(part.layout, part.name)
192
- let indexCount = part.index.bytes / (part.indexBits / 8)
193
- let geometry: ModelData["parts"][0]["geometry"] = {
194
- vertices: vertexView(layout, buffer, payload + part.offset, part.vertexCount * layoutStride(layout)),
195
- indices: part.indexBits === 32 ? new Uint32Array(buffer, payload + part.index.offset, indexCount) : new Uint16Array(buffer, payload + part.index.offset, indexCount),
196
- label: part.name,
248
+ // The geometry table first: one Geometry object per entry, so parts
249
+ // naming the same entry share it (and the runtime shares its buffers).
250
+ let geometries: Geometry[] = header.geometries.map((g, i) => {
251
+ let name = g.label ?? "geometry " + i
252
+ let layout = decodeLayout(g.layout, name)
253
+ let indexCount = g.index.bytes / (g.indexBits / 8)
254
+ let geometry: Geometry = {
255
+ vertices: vertexView(layout, buffer, payload + g.vertices.offset, g.vertexCount * layoutStride(layout)),
256
+ indices: g.indexBits === 32 ? new Uint32Array(buffer, payload + g.index.offset, indexCount) : new Uint16Array(buffer, payload + g.index.offset, indexCount),
197
257
  }
198
- if (part.layout !== "base") geometry.layout = layout
199
- if (part.morphs !== undefined) {
200
- let m = part.morphs
258
+ if (g.label !== undefined) geometry.label = g.label
259
+ if (g.layout !== "base") geometry.layout = layout
260
+ if (g.morphs !== undefined) {
261
+ let m = g.morphs
201
262
  geometry.morphs = { names: m.names, texels: new Float32Array(buffer, payload + m.texels.offset, m.texels.bytes / 4), extent: Float32Array.from(m.extent) }
202
263
  }
203
- return {
264
+ return geometry
265
+ })
266
+ let parts = header.parts.map((part) => {
267
+ let geometry = geometries[part.geometry]
268
+ if (geometry === undefined) throw new Error("decodeModel: part '" + part.name + "' names a missing geometry " + part.geometry)
269
+ let out: ModelData["parts"][0] = {
204
270
  name: part.name,
205
271
  node: part.node,
206
272
  skin: part.skin,
207
273
  material: part.material,
208
274
  geometry,
209
275
  }
276
+ if (part.placements !== undefined) out.placements = part.placements
277
+ if (part.extras !== undefined) out.extras = part.extras
278
+ return out
210
279
  })
211
280
  let images = header.images.map((block) => new Uint8Array(buffer, payload + block.offset, block.bytes))
212
281
  let floats = (block: Block): Float32Array => new Float32Array(buffer, payload + block.offset, block.bytes / 4)
@@ -222,5 +291,11 @@ export function decodeModel(bytes: Uint8Array): ModelData {
222
291
  values: floats(c.values),
223
292
  })),
224
293
  }))
225
- return { nodes: header.nodes, parts, skins, clips, materials: header.materials, images, bounds: Float32Array.from(header.bounds) }
294
+ let data: ModelData = { nodes: header.nodes, parts, skins, clips, materials: header.materials, images, bounds: Float32Array.from(header.bounds) }
295
+ if (header.extras !== undefined) data.extras = header.extras
296
+ if (header.blobs !== undefined) {
297
+ data.blobs = {}
298
+ for (let [name, block] of Object.entries(header.blobs)) data.blobs[name] = new Uint8Array(buffer, payload + block.offset, block.bytes)
299
+ }
300
+ return data
226
301
  }
package/src/model.ts CHANGED
@@ -3,11 +3,17 @@
3
3
  // local TRS, each part's mesh under its node - with the images uploaded as
4
4
  // textures and a material per glTF material: Three's `gltf.scene`, an
5
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
7
- // created (geometry buffers, textures): dispose() frees them and detaches
8
- // the group. loadGltf / loadModel are the read-then-create conveniences
9
- // over flux:fs; parseGltf / decodeModel + createModel are the primitives
10
- // under them, for bytes obtained any other way (a binary import, a fetch).
6
+ // nodes (`model.nodes`) can be moved individually. A part several nodes
7
+ // place (ModelPart.placements: a reused mesh, EXT_mesh_gpu_instancing) is
8
+ // ONE InstancedMesh at identity under the model root with an instance
9
+ // under every placement node, so the copies are one draw entry and still
10
+ // ride, animate, pick and cull as nodes of the hierarchy. The file's
11
+ // extras and blobs come along as plain data. The model owns what it
12
+ // created (geometry buffers, record buffers, textures): dispose() frees
13
+ // them and detaches the group. loadGltf / loadModel are the
14
+ // read-then-create conveniences over flux:fs; parseGltf / decodeModel +
15
+ // createModel are the primitives under them, for bytes obtained any other
16
+ // way (a binary import, a fetch).
11
17
 
12
18
  import { file } from "flux:fs"
13
19
  import * as spatial from "flux:spatial"
@@ -16,7 +22,7 @@ import type { Owner } from "@solidrt/core"
16
22
  import { createMutableTexture, createTexture, destroyTexture } from "@solidrt/core/gpu"
17
23
  import type { TextureId } from "@solidrt/core/gpu"
18
24
  import { gltfExternalUris, parseGltf } from "./gltf.ts"
19
- import type { ModelClip, ModelData, ModelMaterial } from "./gltf.ts"
25
+ import type { ModelClip, ModelData, ModelExtras, ModelMaterial } from "./gltf.ts"
20
26
  import { decodeModel } from "./model-file.ts"
21
27
  import { disposeGeometry } from "./geometry-gpu.ts"
22
28
  import { layoutSlot } from "./geometry.ts"
@@ -24,8 +30,8 @@ import { standard } from "./material.ts"
24
30
  import type { Material } from "./material.ts"
25
31
  import { add, afterFree, createGroup, createMorphState, remove, setTransform } from "./node.ts"
26
32
  import type { SceneNode } from "./node.ts"
27
- import { createMesh } from "./mesh.ts"
28
- import type { Mesh } from "./mesh.ts"
33
+ import { addInstance, createInstancedMesh, createMesh, disposeInstances } from "./mesh.ts"
34
+ import type { InstancedMesh, InstanceNode, Mesh } from "./mesh.ts"
29
35
  import { refreshJointBounds, unbindSkeleton } from "./skeleton.ts"
30
36
  import type { WornPiece } from "./skeleton.ts"
31
37
 
@@ -61,9 +67,13 @@ export type ModelOptions = {
61
67
  * `vertexColors` is true when the part carries COLOR_0 in aColor (pass
62
68
  * it through, or read aColor yourself); `morphed` is true when the part
63
69
  * carries morph targets (pass it through as `morph`, or splice
64
- * MORPH_DECLS/MORPH_APPLY yourself). `data.materials` is in file order,
65
- * so the calls arrive in file order too. */
66
- material?: (material: ModelMaterial, maps: ModelMaps, skinned: boolean, vertexColors: boolean, morphed: boolean) => Material
70
+ * MORPH_DECLS/MORPH_APPLY yourself); `instanced` is true when the part
71
+ * is a population (ModelPart.placements: drawn as one InstancedMesh),
72
+ * so the material must be instanced (pass it through as `instanced`,
73
+ * or declare INSTANCE_MATRIX_ATTRIBUTES and splice INSTANCE_MATRIX
74
+ * yourself). `data.materials` is in file order, so the calls arrive in
75
+ * file order too. */
76
+ material?: (material: ModelMaterial, maps: ModelMaps, skinned: boolean, vertexColors: boolean, morphed: boolean, instanced: boolean) => Material
67
77
  /** Debug name for the textures. */
68
78
  label?: string
69
79
  /** Free the model with the owning reactive scope (default true, like
@@ -77,15 +87,31 @@ export type ModelOptions = {
77
87
  * nested Groups, with each part's mesh a child of its node. */
78
88
  export type Model = SceneNode & {
79
89
  kind: "group"
80
- /** The parts by name, in file order; each `mesh` sits under its node. */
81
- parts: { name: string; mesh: Mesh }[]
90
+ /** The parts by name, in file order; each `mesh` sits under its node.
91
+ * A part with placements (a mesh several nodes place) is instead ONE
92
+ * InstancedMesh at identity under the model root, with `instances` in
93
+ * placement order (the part's node first, then its placements), each
94
+ * an instance node at identity under its placement node in `nodes`:
95
+ * move the placement node and its copy follows; a pick on a copy
96
+ * reports `hit.mesh` = this mesh and `hit.instance` one of these
97
+ * (its `parent` is the placement node). A material swap on the mesh
98
+ * applies to every copy. `extras` is the glTF mesh's, when it has
99
+ * any. */
100
+ parts: { name: string; mesh: Mesh; instances?: InstanceNode[]; extras?: ModelExtras }[]
82
101
  /** The file's retained nodes in table order (parents first), each an
83
102
  * ordinary Group under the model: `setTransform` on one moves its
84
103
  * subtree - a wheel spins relative to the axle it hangs from. Names
85
- * repeat when the file's do; find yours with `.find()`/`.filter()`. */
86
- nodes: { name: string; node: SceneNode }[]
104
+ * repeat when the file's do; find yours with `.find()`/`.filter()`.
105
+ * `extras` is the glTF node's, when it has any. */
106
+ nodes: { name: string; node: SceneNode; extras?: ModelExtras }[]
87
107
  /** One per glTF material, in file order. */
88
108
  materials: Material[]
109
+ /** The file's root extras (glTF `extras`, or what a bake wrote), when
110
+ * it has any. */
111
+ extras?: ModelExtras
112
+ /** The file's named binary sections (a bake's), when it has any: views
113
+ * onto the loaded bytes. */
114
+ blobs?: Record<string, Uint8Array>
89
115
  /** The file's animation clips (empty when it has none); createMixer
90
116
  * plays them. Channel node indices resolve through `nodes`. */
91
117
  clips: ModelClip[]
@@ -144,7 +170,7 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
144
170
  label: label ? label + "-image" + i : undefined,
145
171
  })
146
172
  })
147
- let make = opts.material ?? ((m: ModelMaterial, maps: ModelMaps, skinned: boolean, vertexColors: boolean, morphed: boolean): Material => {
173
+ let make = opts.material ?? ((m: ModelMaterial, maps: ModelMaps, skinned: boolean, vertexColors: boolean, morphed: boolean, instanced: boolean): Material => {
148
174
  // An emissive factor of zero is emission OFF (the glTF product rule:
149
175
  // factor times texture), so the map is skipped too - no sampler for
150
176
  // a term that cannot show.
@@ -167,18 +193,20 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
167
193
  skinned: skinned || undefined,
168
194
  vertexColors: vertexColors || undefined,
169
195
  morph: morphed || undefined,
196
+ instanced: instanced || undefined,
170
197
  })
171
198
  })
172
199
  let slot = (index: number | null): TextureId | null => (index === null ? null : textures[index]!)
173
200
  // One instance per glTF material as today, plus a skinned variant per
174
- // material the skinned parts bring and a vertex-colored one per material
175
- // the COLOR_0 parts bring (a material shared by parts that differ in
176
- // either needs two programs - different vertex stages).
201
+ // material the skinned parts bring, a vertex-colored one per material
202
+ // the COLOR_0 parts bring, a morphed and an instanced one likewise (a
203
+ // material shared by parts that differ in any needs two programs -
204
+ // different vertex stages).
177
205
  let variants = new Map<string, Material>()
178
- let materialFor = (index: number, skinned: boolean, vertexColors: boolean, morphed: boolean): Material => {
206
+ let materialFor = (index: number, skinned: boolean, vertexColors: boolean, morphed: boolean, instanced: boolean): Material => {
179
207
  let m = data.materials[index]
180
208
  if (m === undefined) throw new Error("createModel: a part names a missing material " + index)
181
- let key = index + (skinned ? "|skinned" : "") + (vertexColors ? "|colored" : "") + (morphed ? "|morphed" : "")
209
+ let key = index + (skinned ? "|skinned" : "") + (vertexColors ? "|colored" : "") + (morphed ? "|morphed" : "") + (instanced ? "|instanced" : "")
182
210
  let made = variants.get(key)
183
211
  if (made === undefined) {
184
212
  made = make(
@@ -193,12 +221,13 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
193
221
  skinned,
194
222
  vertexColors,
195
223
  morphed,
224
+ instanced,
196
225
  )
197
226
  variants.set(key, made)
198
227
  }
199
228
  return made
200
229
  }
201
- let materials = data.materials.map((_, i) => materialFor(i, false, false, false))
230
+ let materials = data.materials.map((_, i) => materialFor(i, false, false, false, false))
202
231
 
203
232
  let model = createGroup() as Model
204
233
  model._skins = []
@@ -213,7 +242,7 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
213
242
  return group
214
243
  })
215
244
  data.nodes.forEach((n, i) => add(n.parent === null ? model : groups[n.parent]!, groups[i]!))
216
- model.nodes = data.nodes.map((n, i) => ({ name: n.name, node: groups[i]! }))
245
+ model.nodes = data.nodes.map((n, i) => (n.extras === undefined ? { name: n.name, node: groups[i]! } : { name: n.name, node: groups[i]!, extras: n.extras }))
217
246
  // A glTF node with morph targets owns its parts' weights (the file's
218
247
  // node.weights / mesh.weights seed them): one register, one weights
219
248
  // texture, every part of the mesh reads it - so setMorphWeights on
@@ -268,10 +297,37 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
268
297
  // Each joint's influence box, in joint space: culling-only bounds the
269
298
  // flush carries through the pose, united over every skin reaching it.
270
299
  refreshJointBounds(model)
300
+ let groupOf = (part: ModelData["parts"][0], index: number): SceneNode => {
301
+ let node = groups[index]
302
+ if (node === undefined) throw new Error("createModel: part '" + part.name + "' names a missing node " + index)
303
+ return node
304
+ }
271
305
  model.parts = data.parts.map((part) => {
272
306
  let skinned = part.skin !== null
273
307
  let morphed = part.geometry.morphs !== undefined
274
- let material = materialFor(part.material, skinned, layoutSlot(part.geometry.layout, "aColor") !== null, morphed)
308
+ let colored = layoutSlot(part.geometry.layout, "aColor") !== null
309
+ let extras = part.extras
310
+ let placements = part.placements ?? []
311
+ if (placements.length > 0) {
312
+ // A population: the geometry once, drawn per placement. The mesh
313
+ // sits at identity under the model root, which anchors its records,
314
+ // and each placement node - the first is `part.node` - carries an
315
+ // instance at identity, so the record the core writes is that
316
+ // node's model-relative world: moving the node (setTransform, a
317
+ // clip, a transition) moves the copy, hiding it hides the copy,
318
+ // and a pick lands on the instance. Skins place by palette and
319
+ // morph weights are per node, so neither can share one geometry;
320
+ // parseGltf never folds them, and a hand-built ModelData that does
321
+ // is refused rather than drawn wrong.
322
+ if (skinned) throw new Error("createModel: part '" + part.name + "' is skinned and has placements; a skinned part cannot be shared")
323
+ if (morphed) throw new Error("createModel: part '" + part.name + "' has morph targets and placements; a morphed part cannot be shared")
324
+ let material = materialFor(part.material, false, colored, false, true)
325
+ let mesh = createInstancedMesh(part.geometry, material, { capacity: placements.length + 1, anchor: model, label: label ? label + "-" + part.name : part.name })
326
+ add(model, mesh)
327
+ let instances = [part.node, ...placements].map((index) => addInstance(mesh, undefined, groupOf(part, index)))
328
+ return extras === undefined ? { name: part.name, mesh, instances } : { name: part.name, mesh, instances, extras }
329
+ }
330
+ let material = materialFor(part.material, skinned, colored, morphed, false)
275
331
  let mesh = createMesh(part.geometry, material)
276
332
  // The part's weights are its glTF node's (above), not its own.
277
333
  if (morphed) mesh._morphOwner = groups[part.node] ?? mesh
@@ -287,15 +343,15 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
287
343
  mesh._cullJoints = skin.joints.slice()
288
344
  add(model, mesh)
289
345
  } else {
290
- let node = groups[part.node]
291
- if (node === undefined) throw new Error("createModel: part '" + part.name + "' names a missing node " + part.node)
292
- add(node, mesh)
346
+ add(groupOf(part, part.node), mesh)
293
347
  }
294
- return { name: part.name, mesh }
348
+ return extras === undefined ? { name: part.name, mesh } : { name: part.name, mesh, extras }
295
349
  })
296
350
  model.materials = materials
297
351
  model.clips = data.clips
298
352
  model.bounds = data.bounds
353
+ if (data.extras !== undefined) model.extras = data.extras
354
+ if (data.blobs !== undefined) model.blobs = data.blobs
299
355
  let disposed = false
300
356
  model.dispose = () => {
301
357
  if (disposed) return
@@ -311,7 +367,12 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
311
367
  // destroyed texture.
312
368
  if (afterFree(model, () => model.dispose())) return
313
369
  disposed = true
314
- for (let part of model.parts) disposeGeometry(part.mesh.geometry)
370
+ for (let part of model.parts) {
371
+ // A population's record buffers are mesh-owned (the one explicit
372
+ // free); its geometry buffers are shared like any part's.
373
+ if (part.instances !== undefined) disposeInstances(part.mesh as InstancedMesh)
374
+ disposeGeometry(part.mesh.geometry)
375
+ }
315
376
  for (let id of textures) destroyTexture(id)
316
377
  textures.length = 0
317
378
  // The nodes' weights textures (the parts' entries are off the scene
package/src/node.ts CHANGED
@@ -163,7 +163,7 @@ export type SceneHooks = {
163
163
  _setLayers(mesh: Mesh): void
164
164
  /** A light's castShadow/shadow options changed. */
165
165
  _shadowChanged(light: CastingLight): void
166
- _reorder(): void
166
+ _reorder(mesh: Mesh): void
167
167
  /** The node's transform changed (for the sort and light bookkeeping). */
168
168
  _moved(node: SceneNode): void
169
169
  /** Push the node's LOD declaration (`_lod`) to the core: at scene enter