@solidrt/3d 0.0.58 → 0.0.60
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 +734 -264
- package/README.md +4 -2
- package/examples/README.md +50 -4
- package/examples/bloom.tsx +113 -0
- package/examples/cascades.tsx +4 -4
- package/examples/collision.tsx +6 -6
- package/examples/exits.tsx +96 -0
- package/examples/first-person.tsx +5 -5
- package/examples/fleet.tsx +4 -4
- package/examples/fog.tsx +5 -5
- package/examples/instanced.tsx +70 -50
- package/examples/lamps.tsx +4 -4
- package/examples/lod.tsx +125 -0
- package/examples/materials.tsx +9 -9
- package/examples/morph.tsx +174 -0
- package/examples/normals.tsx +118 -0
- package/examples/{lit.tsx → phong.tsx} +9 -9
- package/examples/polyhedra.tsx +61 -0
- package/examples/scene-atlas.tsx +70 -0
- package/examples/scene-views.tsx +5 -5
- package/examples/shadows.tsx +9 -9
- package/examples/sky-lit.tsx +3 -3
- package/examples/skybox.tsx +5 -5
- package/examples/standard.tsx +2 -2
- package/examples/streams.tsx +122 -0
- package/examples/sweep-paths.tsx +5 -5
- package/examples/wireframe.tsx +24 -2
- package/package.json +3 -3
- package/src/clip.ts +18 -5
- package/src/color.ts +11 -2
- package/src/components/context.tsx +11 -2
- package/src/components/directional-light.tsx +2 -2
- package/src/components/first-person-camera.tsx +4 -4
- package/src/components/group.tsx +9 -5
- package/src/components/hemisphere-light.tsx +2 -2
- package/src/components/index.ts +4 -1
- package/src/components/instanced-mesh.tsx +28 -10
- package/src/components/lod.tsx +102 -0
- package/src/components/mesh.tsx +24 -6
- package/src/components/node-props.ts +35 -5
- package/src/components/orbit-camera.tsx +15 -12
- package/src/components/point-light.tsx +2 -2
- package/src/components/record-mesh.tsx +12 -7
- package/src/components/scene.tsx +55 -2
- package/src/components/spot-light.tsx +2 -2
- package/src/components/sprite.tsx +2 -2
- package/src/components/view3d.tsx +18 -1
- package/src/environment.ts +9 -8
- package/src/first-person.ts +125 -38
- package/src/geometry-gpu.ts +172 -39
- package/src/geometry.ts +1573 -239
- package/src/glsl.ts +398 -113
- package/src/gltf.ts +363 -141
- package/src/index.ts +31 -21
- package/src/light.ts +7 -8
- package/src/lod.ts +148 -0
- package/src/material.ts +316 -202
- package/src/mesh.ts +490 -256
- package/src/mixer.ts +27 -14
- package/src/model-file.ts +54 -15
- package/src/model.ts +86 -21
- package/src/motion.ts +17 -0
- package/src/node.ts +421 -22
- package/src/orbit.ts +378 -116
- package/src/profile.ts +4 -25
- package/src/resolve.ts +181 -0
- package/src/scene-shadows.ts +94 -93
- package/src/scene.ts +605 -185
- package/src/sweep.ts +27 -8
- package/tools/model.ts +3 -1
package/src/mixer.ts
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
// (getTransform) and may overwrite them (root-motion strips, skeleton
|
|
10
10
|
// copies), last write wins, palettes and uModel following at the frame's
|
|
11
11
|
// flush. A channel the active clips do not animate keeps the node's
|
|
12
|
-
// current pose.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// current pose. A weights channel (morph targets) plays the same way,
|
|
13
|
+
// into the owning node's weights register (`getMorphWeights` reads it),
|
|
14
|
+
// blended across players like a TRS path. Playing requires the model
|
|
15
|
+
// to be IN A SCENE (players bind live arena nodes); removing it drops
|
|
16
|
+
// the players, and a re-added model plays again from play().
|
|
15
17
|
//
|
|
16
18
|
// Root motion: a locomotion clip translates its root, which is what a game
|
|
17
19
|
// consumes to move the character and what sends a viewer's model through
|
|
@@ -44,7 +46,7 @@ import * as spatial from "flux:spatial"
|
|
|
44
46
|
import type { NodeId } from "flux:spatial"
|
|
45
47
|
import { on } from "srt:events"
|
|
46
48
|
import type { ModelChannel, ModelClip } from "./gltf.ts"
|
|
47
|
-
import { sampleChannel } from "./clip.ts"
|
|
49
|
+
import { channelElements, sampleChannel } from "./clip.ts"
|
|
48
50
|
import type { Vec3 } from "./math.ts"
|
|
49
51
|
import type { Model } from "./model.ts"
|
|
50
52
|
|
|
@@ -111,8 +113,10 @@ export type Mixer = {
|
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
// The packed-clip codes of flux:spatial's createClip layout.
|
|
114
|
-
const PATH_CODE = { position: 0, rotation: 1, scale: 2 } as const
|
|
116
|
+
const PATH_CODE = { position: 0, rotation: 1, scale: 2, weights: 3 } as const
|
|
115
117
|
const INTERP_CODE = { step: 0, linear: 1, cubic: 2 } as const
|
|
118
|
+
// Meta words per channel in that layout.
|
|
119
|
+
const CLIP_META_WORDS = 5
|
|
116
120
|
|
|
117
121
|
// Net root x/z drift, as a fraction of the model's rest height, above
|
|
118
122
|
// which a clip counts as travelling. Measured on a stock rig: run cycles
|
|
@@ -141,16 +145,18 @@ on("spatialRootMotion", (event: { player: number; x: number; y: number; z: numbe
|
|
|
141
145
|
|
|
142
146
|
/** Pack channels into the core's createClip layout. */
|
|
143
147
|
function registerClip(duration: number, channels: ModelChannel[]): number {
|
|
144
|
-
let meta = new Uint32Array(channels.length *
|
|
148
|
+
let meta = new Uint32Array(channels.length * CLIP_META_WORDS)
|
|
145
149
|
let timeCount = 0
|
|
146
150
|
let valueCount = 0
|
|
147
151
|
channels.forEach((c, i) => {
|
|
148
152
|
// Target slot = channel index: the player's target table below is
|
|
149
153
|
// built in the same order.
|
|
150
|
-
|
|
151
|
-
meta[
|
|
152
|
-
meta[
|
|
153
|
-
meta[
|
|
154
|
+
let at = i * CLIP_META_WORDS
|
|
155
|
+
meta[at] = i
|
|
156
|
+
meta[at + 1] = PATH_CODE[c.path]
|
|
157
|
+
meta[at + 2] = INTERP_CODE[c.interpolation]
|
|
158
|
+
meta[at + 3] = c.times.length
|
|
159
|
+
meta[at + 4] = channelElements(c)
|
|
154
160
|
timeCount += c.times.length
|
|
155
161
|
valueCount += c.values.length
|
|
156
162
|
})
|
|
@@ -211,7 +217,7 @@ function axisQuat(up: Vec3, angle: number): [number, number, number, number] {
|
|
|
211
217
|
* linear or step channel is returned as is). */
|
|
212
218
|
function linearized(c: ModelChannel, duration: number): ModelChannel {
|
|
213
219
|
if (c.interpolation !== "cubic") return c
|
|
214
|
-
let elements = c
|
|
220
|
+
let elements = channelElements(c)
|
|
215
221
|
let keys = Math.max(2, Math.ceil(duration * YAW_HOLD_RESAMPLE_HZ) + 1)
|
|
216
222
|
let times = new Float32Array(keys)
|
|
217
223
|
let values = new Float32Array(keys * elements)
|
|
@@ -249,7 +255,7 @@ function holdYaw(rotation: ModelChannel, duration: number, up: Vec3): ModelChann
|
|
|
249
255
|
// Floats per key and the value's offset within a key: a key's value sits
|
|
250
256
|
// at [in, value, out] for cubic keys; rotations are four floats.
|
|
251
257
|
function keyLayout(c: ModelChannel): { stride: number; mid: number } {
|
|
252
|
-
let elements = c
|
|
258
|
+
let elements = channelElements(c)
|
|
253
259
|
let cubic = c.interpolation === "cubic"
|
|
254
260
|
return { stride: cubic ? elements * 3 : elements, mid: cubic ? elements : 0 }
|
|
255
261
|
}
|
|
@@ -369,14 +375,21 @@ export function createMixer(model: Model, mixerOpts: MixerOptions = {}): Mixer {
|
|
|
369
375
|
let rootAcc = { position: [0, 0, 0] as [number, number, number], yaw: 0 }
|
|
370
376
|
// Channel targets resolve at play: clips index the model's node table,
|
|
371
377
|
// and the arena ids are per scene-entry, so a re-added model binds
|
|
372
|
-
// fresh ones.
|
|
378
|
+
// fresh ones. A weights channel targets the node's weights register
|
|
379
|
+
// (the glTF node owning its mesh's morph targets, model.ts); its key
|
|
380
|
+
// width must be the node's target count.
|
|
373
381
|
let targetsFor = (clip: ModelClip): NodeId[] =>
|
|
374
382
|
clip.channels.map((c) => {
|
|
375
383
|
let entry = model.nodes[c.node]
|
|
376
384
|
if (entry === undefined) throw new Error("createMixer: clip '" + clip.name + "' targets a missing node " + c.node)
|
|
377
385
|
let id = entry.node._node
|
|
378
386
|
if (id === null) throw new Error("play: the model must be in a scene (add() it first) before clips can play")
|
|
379
|
-
|
|
387
|
+
if (c.path === "weights") {
|
|
388
|
+
let targets = entry.node._morph?.names.length ?? 0
|
|
389
|
+
if (channelElements(c) !== targets) {
|
|
390
|
+
throw new Error("play: clip '" + clip.name + "' has " + channelElements(c) + " weights per key for node '" + entry.name + "' with " + targets + " morph targets")
|
|
391
|
+
}
|
|
392
|
+
} else entry.node._native = true
|
|
380
393
|
return id
|
|
381
394
|
})
|
|
382
395
|
|
package/src/model-file.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// directly. Images travel as their encoded files (PNG/JPEG bytes).
|
|
11
11
|
|
|
12
12
|
import type { ModelChannel, ModelData, ModelMaterial, ModelNode, ModelSkin } from "./gltf.ts"
|
|
13
|
-
import { layoutStride } from "./geometry.ts"
|
|
13
|
+
import { layoutStride, vertexView, VERTEX_FORMATS } from "./geometry.ts"
|
|
14
|
+
import type { VertexAttribute } from "@solidrt/core/gpu"
|
|
14
15
|
import type { VertexLayout } from "./geometry.ts"
|
|
15
16
|
|
|
16
17
|
/** "SRTM" read as a little-endian u32. */
|
|
@@ -24,11 +25,39 @@ const MAGIC = 0x4d545253
|
|
|
24
25
|
// Version 4 adds the metalness/roughness fields to the material records
|
|
25
26
|
// (metalness, roughness, metalnessRoughnessMap); a version-3 file lacks
|
|
26
27
|
// them, so it is rejected the same way rather than read as all-metal.
|
|
27
|
-
|
|
28
|
+
// Version 6 spells attribute formats in the WebGPU vocabulary
|
|
29
|
+
// (float32x3, unorm8x4, ...) and counts vertices in bytes, so a part may
|
|
30
|
+
// carry packed channels; a version-5 file's "vec3" words do not parse.
|
|
31
|
+
// Version 7 changes the named "skinned" layout's joints from float32x4 to
|
|
32
|
+
// uint8x4 (an integer shader input), so a version-6 skinned part would
|
|
33
|
+
// decode at the wrong stride; it is rejected. Re-bake with `srt tool
|
|
34
|
+
// 3d/model`.
|
|
35
|
+
// Version 8 carries morph targets: a part's packed targets block (names,
|
|
36
|
+
// the sparse-by-vertex texels, the extent - see packMorphTargets), the
|
|
37
|
+
// mesh weights on the node table, and clip channels on the "weights"
|
|
38
|
+
// path. A version-7 file has none, so it is rejected the same way.
|
|
39
|
+
// Version 9 names the plain layout "base" (position, normal, uv - the
|
|
40
|
+
// prefix "colored" and "skinned" extend) where version 8 wrote
|
|
41
|
+
// "standard"; a version-8 file's layout word does not parse.
|
|
42
|
+
const VERSION = 9
|
|
28
43
|
|
|
29
|
-
// The named layouts the container writes; a custom attribute-list
|
|
30
|
-
//
|
|
31
|
-
|
|
44
|
+
// The named layouts the container writes by name; a custom attribute-list
|
|
45
|
+
// layout (a skinned primitive with COLOR_0, a withAttribute channel) is
|
|
46
|
+
// written as its list.
|
|
47
|
+
const NAMED_LAYOUTS = ["base", "colored", "skinned"]
|
|
48
|
+
|
|
49
|
+
// A part's layout as written: a named preset, or a custom attribute list
|
|
50
|
+
// checked to the shape the stride and the pipeline are built from.
|
|
51
|
+
function decodeLayout(layout: string | VertexAttribute[], name: string): VertexLayout {
|
|
52
|
+
if (typeof layout === "string") {
|
|
53
|
+
if (!NAMED_LAYOUTS.includes(layout)) throw new Error("decodeModel: part '" + name + "' has an unsupported layout " + layout)
|
|
54
|
+
return layout as VertexLayout
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(layout) || !layout.every((a) => typeof a.name === "string" && a.format in VERTEX_FORMATS)) {
|
|
57
|
+
throw new Error("decodeModel: part '" + name + "' has a malformed attribute list")
|
|
58
|
+
}
|
|
59
|
+
return layout
|
|
60
|
+
}
|
|
32
61
|
|
|
33
62
|
type Block = { offset: number; bytes: number }
|
|
34
63
|
|
|
@@ -37,10 +66,12 @@ type PartHeader = Block & {
|
|
|
37
66
|
node: number
|
|
38
67
|
skin: number | null
|
|
39
68
|
material: number
|
|
40
|
-
layout: string
|
|
69
|
+
layout: string | VertexAttribute[]
|
|
41
70
|
vertexCount: number
|
|
42
71
|
indexBits: 16 | 32
|
|
43
72
|
index: Block
|
|
73
|
+
/** The part's packed morph targets, when it has any. */
|
|
74
|
+
morphs?: { names: string[]; texels: Block; extent: number[] }
|
|
44
75
|
}
|
|
45
76
|
|
|
46
77
|
type SkinHeader = { joints: number[]; inverseBind: Block; jointBounds: Block }
|
|
@@ -83,23 +114,28 @@ export function encodeModel(data: ModelData): Uint8Array {
|
|
|
83
114
|
|
|
84
115
|
let parts: PartHeader[] = data.parts.map((part) => {
|
|
85
116
|
let g = part.geometry
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
throw new Error("encodeModel: part '" + part.name + "' has a custom attribute-list layout; only the named layouts are written")
|
|
117
|
+
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")
|
|
89
119
|
}
|
|
120
|
+
let layout = g.layout === undefined ? "base" : g.layout
|
|
90
121
|
let vertices = push(new Uint8Array(g.vertices.buffer, g.vertices.byteOffset, g.vertices.byteLength))
|
|
91
122
|
let index = push(new Uint8Array(g.indices.buffer, g.indices.byteOffset, g.indices.byteLength))
|
|
92
|
-
|
|
123
|
+
let header: PartHeader = {
|
|
93
124
|
...vertices,
|
|
94
125
|
name: part.name,
|
|
95
126
|
node: part.node,
|
|
96
127
|
skin: part.skin,
|
|
97
128
|
material: part.material,
|
|
98
129
|
layout,
|
|
99
|
-
vertexCount: g.vertices.
|
|
130
|
+
vertexCount: g.vertices.byteLength / layoutStride(layout),
|
|
100
131
|
indexBits: g.indices instanceof Uint32Array ? 32 : 16,
|
|
101
132
|
index,
|
|
102
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) }
|
|
137
|
+
}
|
|
138
|
+
return header
|
|
103
139
|
})
|
|
104
140
|
let images = data.images.map((image) => push(image))
|
|
105
141
|
let floats = (f: Float32Array): Block => push(new Uint8Array(f.buffer, f.byteOffset, f.byteLength))
|
|
@@ -152,15 +188,18 @@ export function decodeModel(bytes: Uint8Array): ModelData {
|
|
|
152
188
|
let payload = base + 12 + jsonLength + ((4 - (jsonLength % 4)) % 4)
|
|
153
189
|
|
|
154
190
|
let parts = header.parts.map((part) => {
|
|
155
|
-
|
|
156
|
-
let layout = part.layout as VertexLayout
|
|
191
|
+
let layout = decodeLayout(part.layout, part.name)
|
|
157
192
|
let indexCount = part.index.bytes / (part.indexBits / 8)
|
|
158
193
|
let geometry: ModelData["parts"][0]["geometry"] = {
|
|
159
|
-
vertices:
|
|
194
|
+
vertices: vertexView(layout, buffer, payload + part.offset, part.vertexCount * layoutStride(layout)),
|
|
160
195
|
indices: part.indexBits === 32 ? new Uint32Array(buffer, payload + part.index.offset, indexCount) : new Uint16Array(buffer, payload + part.index.offset, indexCount),
|
|
161
196
|
label: part.name,
|
|
162
197
|
}
|
|
163
|
-
if (part.layout !== "
|
|
198
|
+
if (part.layout !== "base") geometry.layout = layout
|
|
199
|
+
if (part.morphs !== undefined) {
|
|
200
|
+
let m = part.morphs
|
|
201
|
+
geometry.morphs = { names: m.names, texels: new Float32Array(buffer, payload + m.texels.offset, m.texels.bytes / 4), extent: Float32Array.from(m.extent) }
|
|
202
|
+
}
|
|
164
203
|
return {
|
|
165
204
|
name: part.name,
|
|
166
205
|
node: part.node,
|
package/src/model.ts
CHANGED
|
@@ -11,16 +11,18 @@
|
|
|
11
11
|
|
|
12
12
|
import { file } from "flux:fs"
|
|
13
13
|
import * as spatial from "flux:spatial"
|
|
14
|
-
import { decodeImage } from "@solidrt/core"
|
|
14
|
+
import { decodeImage, getOwner, isDisposed, onCleanup, runWithOwner } from "@solidrt/core"
|
|
15
|
+
import type { Owner } from "@solidrt/core"
|
|
15
16
|
import { createMutableTexture, createTexture, destroyTexture } from "@solidrt/core/gpu"
|
|
16
17
|
import type { TextureId } from "@solidrt/core/gpu"
|
|
17
18
|
import { gltfExternalUris, parseGltf } from "./gltf.ts"
|
|
18
19
|
import type { ModelClip, ModelData, ModelMaterial } from "./gltf.ts"
|
|
19
20
|
import { decodeModel } from "./model-file.ts"
|
|
20
21
|
import { disposeGeometry } from "./geometry-gpu.ts"
|
|
22
|
+
import { layoutSlot } from "./geometry.ts"
|
|
21
23
|
import { standard } from "./material.ts"
|
|
22
24
|
import type { Material } from "./material.ts"
|
|
23
|
-
import { add, createGroup, remove, setTransform } from "./node.ts"
|
|
25
|
+
import { add, afterFree, createGroup, createMorphState, remove, setTransform } from "./node.ts"
|
|
24
26
|
import type { SceneNode } from "./node.ts"
|
|
25
27
|
import { createMesh } from "./mesh.ts"
|
|
26
28
|
import type { Mesh } from "./mesh.ts"
|
|
@@ -33,7 +35,7 @@ import type { WornPiece } from "./skeleton.ts"
|
|
|
33
35
|
* the device by the runtime. */
|
|
34
36
|
const MODEL_ANISOTROPY = 4
|
|
35
37
|
|
|
36
|
-
/** A glTF material's uploaded textures, by
|
|
38
|
+
/** A glTF material's uploaded textures, by phong()/standard() option name;
|
|
37
39
|
* null where the material has none. */
|
|
38
40
|
export type ModelMaps = {
|
|
39
41
|
map: TextureId | null
|
|
@@ -49,18 +51,26 @@ export type ModelOptions = {
|
|
|
49
51
|
/** The material for each glTF material (default: `standard` with its
|
|
50
52
|
* color, maps, normal scale, metalness/roughness, emissive and
|
|
51
53
|
* transparency - the glTF material model; a scene with no `environment`
|
|
52
|
-
* renders its metals near black, so set one, or return `
|
|
54
|
+
* renders its metals near black, so set one, or return `phong` here for
|
|
53
55
|
* the Blinn-Phong look). `maps` holds the uploaded textures by
|
|
54
|
-
*
|
|
55
|
-
* or once per (material, skinned) combination
|
|
56
|
-
*
|
|
57
|
-
* `skinned` is true when the material must skin (pass it
|
|
58
|
-
* `
|
|
59
|
-
* `
|
|
60
|
-
*
|
|
61
|
-
|
|
56
|
+
* phong()/standard() option name. Called once per material -
|
|
57
|
+
* or once per (material, skinned, vertexColors, morphed) combination
|
|
58
|
+
* when parts that differ in any share a material - and shared by every
|
|
59
|
+
* part using it. `skinned` is true when the material must skin (pass it
|
|
60
|
+
* through to `phong`/`unlit`, or read aJoints/aWeights + uBones yourself);
|
|
61
|
+
* `vertexColors` is true when the part carries COLOR_0 in aColor (pass
|
|
62
|
+
* it through, or read aColor yourself); `morphed` is true when the part
|
|
63
|
+
* 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
|
|
62
67
|
/** Debug name for the textures. */
|
|
63
68
|
label?: string
|
|
69
|
+
/** Free the model with the owning reactive scope (default true, like
|
|
70
|
+
* createScene: `dispose()` runs at the scope's cleanup); `false` opts
|
|
71
|
+
* out, then call dispose yourself. For loadGltf/loadModel the scope is
|
|
72
|
+
* the caller's at the call, before the await. */
|
|
73
|
+
autoFree?: boolean
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
/** A model in the scene: a Group carrying the file's node hierarchy as
|
|
@@ -134,7 +144,7 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
134
144
|
label: label ? label + "-image" + i : undefined,
|
|
135
145
|
})
|
|
136
146
|
})
|
|
137
|
-
let make = opts.material ?? ((m: ModelMaterial, maps: ModelMaps, skinned: boolean): Material => {
|
|
147
|
+
let make = opts.material ?? ((m: ModelMaterial, maps: ModelMaps, skinned: boolean, vertexColors: boolean, morphed: boolean): Material => {
|
|
138
148
|
// An emissive factor of zero is emission OFF (the glTF product rule:
|
|
139
149
|
// factor times texture), so the map is skipped too - no sampler for
|
|
140
150
|
// a term that cannot show.
|
|
@@ -155,17 +165,20 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
155
165
|
cull: m.doubleSided ? "none" : "back",
|
|
156
166
|
alphaTest: m.alphaMode === "MASK" ? m.alphaCutoff : undefined,
|
|
157
167
|
skinned: skinned || undefined,
|
|
168
|
+
vertexColors: vertexColors || undefined,
|
|
169
|
+
morph: morphed || undefined,
|
|
158
170
|
})
|
|
159
171
|
})
|
|
160
172
|
let slot = (index: number | null): TextureId | null => (index === null ? null : textures[index]!)
|
|
161
173
|
// One instance per glTF material as today, plus a skinned variant per
|
|
162
|
-
// material the skinned parts bring
|
|
163
|
-
//
|
|
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).
|
|
164
177
|
let variants = new Map<string, Material>()
|
|
165
|
-
let materialFor = (index: number, skinned: boolean): Material => {
|
|
178
|
+
let materialFor = (index: number, skinned: boolean, vertexColors: boolean, morphed: boolean): Material => {
|
|
166
179
|
let m = data.materials[index]
|
|
167
180
|
if (m === undefined) throw new Error("createModel: a part names a missing material " + index)
|
|
168
|
-
let key = index + (skinned ? "|skinned" : "")
|
|
181
|
+
let key = index + (skinned ? "|skinned" : "") + (vertexColors ? "|colored" : "") + (morphed ? "|morphed" : "")
|
|
169
182
|
let made = variants.get(key)
|
|
170
183
|
if (made === undefined) {
|
|
171
184
|
made = make(
|
|
@@ -178,12 +191,14 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
178
191
|
roughnessMap: slot(m.metalnessRoughnessMap),
|
|
179
192
|
},
|
|
180
193
|
skinned,
|
|
194
|
+
vertexColors,
|
|
195
|
+
morphed,
|
|
181
196
|
)
|
|
182
197
|
variants.set(key, made)
|
|
183
198
|
}
|
|
184
199
|
return made
|
|
185
200
|
}
|
|
186
|
-
let materials = data.materials.map((_, i) => materialFor(i, false))
|
|
201
|
+
let materials = data.materials.map((_, i) => materialFor(i, false, false, false))
|
|
187
202
|
|
|
188
203
|
let model = createGroup() as Model
|
|
189
204
|
model._skins = []
|
|
@@ -199,6 +214,16 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
199
214
|
})
|
|
200
215
|
data.nodes.forEach((n, i) => add(n.parent === null ? model : groups[n.parent]!, groups[i]!))
|
|
201
216
|
model.nodes = data.nodes.map((n, i) => ({ name: n.name, node: groups[i]! }))
|
|
217
|
+
// A glTF node with morph targets owns its parts' weights (the file's
|
|
218
|
+
// node.weights / mesh.weights seed them): one register, one weights
|
|
219
|
+
// texture, every part of the mesh reads it - so setMorphWeights on
|
|
220
|
+
// the node (and a clip's weights track) moves the whole mesh.
|
|
221
|
+
data.nodes.forEach((n, i) => {
|
|
222
|
+
if (n.weights === undefined) return
|
|
223
|
+
let part = data.parts.find((p) => p.node === i && p.geometry.morphs !== undefined)
|
|
224
|
+
if (part === undefined) return
|
|
225
|
+
groups[i]!._morph = createMorphState(part.geometry.morphs!.names, n.weights, label ? label + "-" + n.name + "-weights" : n.name + "-weights")
|
|
226
|
+
})
|
|
202
227
|
// Each skin's palette lives in an rgba32f texture, 4 texels wide, one
|
|
203
228
|
// row per joint (the four columns of that joint's mat4), sized to the
|
|
204
229
|
// RIG: rig size is bounded by texture height (>= 2048 everywhere), not
|
|
@@ -245,8 +270,11 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
245
270
|
refreshJointBounds(model)
|
|
246
271
|
model.parts = data.parts.map((part) => {
|
|
247
272
|
let skinned = part.skin !== null
|
|
248
|
-
let
|
|
273
|
+
let morphed = part.geometry.morphs !== undefined
|
|
274
|
+
let material = materialFor(part.material, skinned, layoutSlot(part.geometry.layout, "aColor") !== null, morphed)
|
|
249
275
|
let mesh = createMesh(part.geometry, material)
|
|
276
|
+
// The part's weights are its glTF node's (above), not its own.
|
|
277
|
+
if (morphed) mesh._morphOwner = groups[part.node] ?? mesh
|
|
250
278
|
if (skinned) {
|
|
251
279
|
// A skinned part's vertices are model-space bind pose and the skin
|
|
252
280
|
// matrices place them, so its mesh hangs off the model root (the
|
|
@@ -268,16 +296,32 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
268
296
|
model.materials = materials
|
|
269
297
|
model.clips = data.clips
|
|
270
298
|
model.bounds = data.bounds
|
|
299
|
+
let disposed = false
|
|
271
300
|
model.dispose = () => {
|
|
301
|
+
if (disposed) return
|
|
272
302
|
// A worn piece comes off first (its rows sit on the body's joints);
|
|
273
303
|
// a body takes its pieces with it (their skins read joints that are
|
|
274
304
|
// about to go).
|
|
275
305
|
if (model._body !== null) unbindSkeleton(model)
|
|
276
306
|
for (let worn of model._worn.slice()) worn.piece.dispose()
|
|
277
307
|
if (model.parent !== null) remove(model)
|
|
308
|
+
// A destroyed model still animating out (its root waits for every
|
|
309
|
+
// part's exit) keeps its textures until it is gone: destroy then
|
|
310
|
+
// dispose is the unmount shape, and a corpse must not sample a
|
|
311
|
+
// destroyed texture.
|
|
312
|
+
if (afterFree(model, () => model.dispose())) return
|
|
313
|
+
disposed = true
|
|
278
314
|
for (let part of model.parts) disposeGeometry(part.mesh.geometry)
|
|
279
315
|
for (let id of textures) destroyTexture(id)
|
|
280
316
|
textures.length = 0
|
|
317
|
+
// The nodes' weights textures (the parts' entries are off the scene
|
|
318
|
+
// with the remove above).
|
|
319
|
+
for (let n of model.nodes) {
|
|
320
|
+
if (n.node._morph !== null) {
|
|
321
|
+
destroyTexture(n.node._morph.texture)
|
|
322
|
+
n.node._morph = null
|
|
323
|
+
}
|
|
324
|
+
}
|
|
281
325
|
// Core clips a mixer registered; their players drop at the next
|
|
282
326
|
// advance (the leave above already killed the target nodes).
|
|
283
327
|
for (let clip of model.clips) {
|
|
@@ -291,9 +335,27 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
291
335
|
}
|
|
292
336
|
}
|
|
293
337
|
}
|
|
338
|
+
if (opts.autoFree !== false && getOwner()) onCleanup(() => model.dispose())
|
|
294
339
|
return model
|
|
295
340
|
}
|
|
296
341
|
|
|
342
|
+
// Build under the reactive scope a loader was called from, captured
|
|
343
|
+
// before its first await (an await leaves the reactive context; whatever
|
|
344
|
+
// scope happens to be current when the bytes arrive is not the caller's).
|
|
345
|
+
// autoFree then frees the model with that scope. A scope gone by the time
|
|
346
|
+
// the load settles - a component unmounted mid-load - can own nothing
|
|
347
|
+
// any more, so the model is freed on arrival and the promise resolves
|
|
348
|
+
// with it disposed: the caller nobody listens to gets a valid, inert
|
|
349
|
+
// object rather than a rejection or a leak.
|
|
350
|
+
function buildOwned(owner: Owner | null, opts: ModelOptions | undefined, build: () => Model): Model {
|
|
351
|
+
if (opts?.autoFree !== false && owner !== null && isDisposed(owner)) {
|
|
352
|
+
let model = build()
|
|
353
|
+
model.dispose()
|
|
354
|
+
return model
|
|
355
|
+
}
|
|
356
|
+
return runWithOwner(owner, build)
|
|
357
|
+
}
|
|
358
|
+
|
|
297
359
|
/**
|
|
298
360
|
* Read a .glb or .gltf (with its external .bin and image files, resolved
|
|
299
361
|
* next to it) and build the model. The parse runs on the runtime - fine
|
|
@@ -301,6 +363,7 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
301
363
|
* `srt tool 3d/model` and use loadModel.
|
|
302
364
|
*/
|
|
303
365
|
export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model> {
|
|
366
|
+
let owner = getOwner()
|
|
304
367
|
let bytes = await file(path).bytes()
|
|
305
368
|
// A .glb usually embeds everything, but external uris are legal there
|
|
306
369
|
// too (some exporters keep images as files), so both containers get
|
|
@@ -310,11 +373,13 @@ export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model
|
|
|
310
373
|
for (let uri of gltfExternalUris(bytes)) {
|
|
311
374
|
if (!files.has(uri)) files.set(uri, await file(dir + decodeURIComponent(uri)).bytes())
|
|
312
375
|
}
|
|
313
|
-
return createModel(parseGltf(bytes, (uri) => files.get(uri)!), opts)
|
|
376
|
+
return buildOwned(owner, opts, () => createModel(parseGltf(bytes, (uri) => files.get(uri)!), opts))
|
|
314
377
|
}
|
|
315
378
|
|
|
316
379
|
/** Read a baked .srtm model (`srt tool 3d/model`) and build it: no parsing,
|
|
317
380
|
* the geometry views the file's bytes directly. */
|
|
318
381
|
export async function loadModel(path: string, opts?: ModelOptions): Promise<Model> {
|
|
319
|
-
|
|
382
|
+
let owner = getOwner()
|
|
383
|
+
let bytes = await file(path).bytes()
|
|
384
|
+
return buildOwned(owner, opts, () => createModel(decodeModel(bytes), opts))
|
|
320
385
|
}
|
package/src/motion.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The ease every camera control's self-driven motion runs on: a glide to
|
|
2
|
+
// a commanded pose (glideTo, fit) and the damping of an unbracketed input
|
|
3
|
+
// delta (a wheel notch) both close the gap to a goal pose by the same
|
|
4
|
+
// exponential fraction per frame, so the two controls here and the 2d
|
|
5
|
+
// camera read the same. Frame-rate independent: the step is
|
|
6
|
+
// 1 - e^(-rate * dt), never a fixed fraction.
|
|
7
|
+
|
|
8
|
+
// E-foldings per second toward the goal: high enough that a wheel notch
|
|
9
|
+
// reads as one push, low enough to look smooth (the 2d camera's rate).
|
|
10
|
+
export const GLIDE_EASE = 9
|
|
11
|
+
// A motion lands - snaps to its goal and stops - once this fraction of
|
|
12
|
+
// its initial gap remains: under a pixel on any pose component a glide
|
|
13
|
+
// moves through at ordinary distances.
|
|
14
|
+
export const GLIDE_EPSILON = 0.001
|
|
15
|
+
|
|
16
|
+
/** The fraction of the remaining gap an ease at `rate` closes over dt. */
|
|
17
|
+
export let easeStep = (rate: number, dt: number): number => 1 - Math.exp(-rate * dt)
|