@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.
- package/AGENTS.md +1479 -678
- package/README.md +4 -2
- package/examples/README.md +56 -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 +129 -0
- 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 +187 -22
- package/src/geometry.ts +1718 -203
- package/src/glsl.ts +398 -113
- package/src/gltf.ts +457 -154
- package/src/index.ts +32 -21
- package/src/input.ts +11 -2
- package/src/light.ts +7 -8
- package/src/lod.ts +148 -0
- package/src/material.ts +322 -190
- package/src/mesh.ts +492 -259
- package/src/mixer.ts +27 -14
- package/src/model-file.ts +54 -15
- package/src/model.ts +151 -54
- package/src/motion.ts +17 -0
- package/src/node.ts +423 -23
- 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 +608 -185
- package/src/skeleton.ts +211 -0
- 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,19 +11,23 @@
|
|
|
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"
|
|
29
|
+
import { refreshJointBounds, unbindSkeleton } from "./skeleton.ts"
|
|
30
|
+
import type { WornPiece } from "./skeleton.ts"
|
|
27
31
|
|
|
28
32
|
/** Anisotropic filtering level for a model's textures: the engines' usual
|
|
29
33
|
* default (Godot ships 2x, Unity's quality presets 2-8x) - enough to keep a
|
|
@@ -31,7 +35,7 @@ import type { Mesh } from "./mesh.ts"
|
|
|
31
35
|
* the device by the runtime. */
|
|
32
36
|
const MODEL_ANISOTROPY = 4
|
|
33
37
|
|
|
34
|
-
/** A glTF material's uploaded textures, by
|
|
38
|
+
/** A glTF material's uploaded textures, by phong()/standard() option name;
|
|
35
39
|
* null where the material has none. */
|
|
36
40
|
export type ModelMaps = {
|
|
37
41
|
map: TextureId | null
|
|
@@ -47,18 +51,26 @@ export type ModelOptions = {
|
|
|
47
51
|
/** The material for each glTF material (default: `standard` with its
|
|
48
52
|
* color, maps, normal scale, metalness/roughness, emissive and
|
|
49
53
|
* transparency - the glTF material model; a scene with no `environment`
|
|
50
|
-
* renders its metals near black, so set one, or return `
|
|
54
|
+
* renders its metals near black, so set one, or return `phong` here for
|
|
51
55
|
* the Blinn-Phong look). `maps` holds the uploaded textures by
|
|
52
|
-
*
|
|
53
|
-
* or once per (material, skinned) combination
|
|
54
|
-
*
|
|
55
|
-
* `skinned` is true when the material must skin (pass it
|
|
56
|
-
* `
|
|
57
|
-
* `
|
|
58
|
-
*
|
|
59
|
-
|
|
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
|
|
60
67
|
/** Debug name for the textures. */
|
|
61
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
|
|
62
74
|
}
|
|
63
75
|
|
|
64
76
|
/** A model in the scene: a Group carrying the file's node hierarchy as
|
|
@@ -80,8 +92,30 @@ export type Model = SceneNode & {
|
|
|
80
92
|
/** Local rest-pose [minX, minY, minZ, maxX, maxY, maxZ] over every part
|
|
81
93
|
* (conservative for parts under rotated nodes). */
|
|
82
94
|
bounds: Float32Array
|
|
83
|
-
/** Detach the model and free its geometry buffers and textures.
|
|
95
|
+
/** Detach the model and free its geometry buffers and textures. A
|
|
96
|
+
* worn piece (bindSkeleton) comes off its body; a body takes the
|
|
97
|
+
* pieces it wears with it. */
|
|
84
98
|
dispose(): void
|
|
99
|
+
/** @internal The skins the parts draw, in file order: the palette
|
|
100
|
+
* texture (shared by identical skins), the joint nodes in row order,
|
|
101
|
+
* their inverse binds and joint-space influence boxes. */
|
|
102
|
+
_skins: ModelSkinNodes[]
|
|
103
|
+
/** @internal The pieces bound onto this model's skeleton. */
|
|
104
|
+
_worn: WornPiece[]
|
|
105
|
+
/** @internal The body this model is bound onto, or null. */
|
|
106
|
+
_body: Model | null
|
|
107
|
+
/** @internal Subtrees bindSkeleton moved under the body's joints (no
|
|
108
|
+
* longer under this model); detached again by dispose. */
|
|
109
|
+
_grafts: SceneNode[]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @internal One skin of a built model: ModelSkin with its joints
|
|
113
|
+
* resolved to the model's nodes and its palette texture. */
|
|
114
|
+
export type ModelSkinNodes = {
|
|
115
|
+
texture: TextureId
|
|
116
|
+
joints: SceneNode[]
|
|
117
|
+
inverseBind: Float32Array
|
|
118
|
+
jointBounds: Float32Array
|
|
85
119
|
}
|
|
86
120
|
|
|
87
121
|
/**
|
|
@@ -110,7 +144,7 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
110
144
|
label: label ? label + "-image" + i : undefined,
|
|
111
145
|
})
|
|
112
146
|
})
|
|
113
|
-
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 => {
|
|
114
148
|
// An emissive factor of zero is emission OFF (the glTF product rule:
|
|
115
149
|
// factor times texture), so the map is skipped too - no sampler for
|
|
116
150
|
// a term that cannot show.
|
|
@@ -131,17 +165,20 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
131
165
|
cull: m.doubleSided ? "none" : "back",
|
|
132
166
|
alphaTest: m.alphaMode === "MASK" ? m.alphaCutoff : undefined,
|
|
133
167
|
skinned: skinned || undefined,
|
|
168
|
+
vertexColors: vertexColors || undefined,
|
|
169
|
+
morph: morphed || undefined,
|
|
134
170
|
})
|
|
135
171
|
})
|
|
136
172
|
let slot = (index: number | null): TextureId | null => (index === null ? null : textures[index]!)
|
|
137
173
|
// One instance per glTF material as today, plus a skinned variant per
|
|
138
|
-
// material the skinned parts bring
|
|
139
|
-
//
|
|
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).
|
|
140
177
|
let variants = new Map<string, Material>()
|
|
141
|
-
let materialFor = (index: number, skinned: boolean): Material => {
|
|
178
|
+
let materialFor = (index: number, skinned: boolean, vertexColors: boolean, morphed: boolean): Material => {
|
|
142
179
|
let m = data.materials[index]
|
|
143
180
|
if (m === undefined) throw new Error("createModel: a part names a missing material " + index)
|
|
144
|
-
let key = index + (skinned ? "|skinned" : "")
|
|
181
|
+
let key = index + (skinned ? "|skinned" : "") + (vertexColors ? "|colored" : "") + (morphed ? "|morphed" : "")
|
|
145
182
|
let made = variants.get(key)
|
|
146
183
|
if (made === undefined) {
|
|
147
184
|
made = make(
|
|
@@ -154,14 +191,20 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
154
191
|
roughnessMap: slot(m.metalnessRoughnessMap),
|
|
155
192
|
},
|
|
156
193
|
skinned,
|
|
194
|
+
vertexColors,
|
|
195
|
+
morphed,
|
|
157
196
|
)
|
|
158
197
|
variants.set(key, made)
|
|
159
198
|
}
|
|
160
199
|
return made
|
|
161
200
|
}
|
|
162
|
-
let materials = data.materials.map((_, i) => materialFor(i, false))
|
|
201
|
+
let materials = data.materials.map((_, i) => materialFor(i, false, false, false))
|
|
163
202
|
|
|
164
203
|
let model = createGroup() as Model
|
|
204
|
+
model._skins = []
|
|
205
|
+
model._worn = []
|
|
206
|
+
model._body = null
|
|
207
|
+
model._grafts = []
|
|
165
208
|
// The node table is pre-order, so every parent group exists before its
|
|
166
209
|
// children reference it.
|
|
167
210
|
let groups: SceneNode[] = data.nodes.map((n) => {
|
|
@@ -171,6 +214,16 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
171
214
|
})
|
|
172
215
|
data.nodes.forEach((n, i) => add(n.parent === null ? model : groups[n.parent]!, groups[i]!))
|
|
173
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
|
+
})
|
|
174
227
|
// Each skin's palette lives in an rgba32f texture, 4 texels wide, one
|
|
175
228
|
// row per joint (the four columns of that joint's mat4), sized to the
|
|
176
229
|
// RIG: rig size is bounded by texture height (>= 2048 everywhere), not
|
|
@@ -183,53 +236,55 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
183
236
|
// joints move - there is no JS palette walk, and posing joints with
|
|
184
237
|
// setTransform is always enough. Identical skins (same joints, same
|
|
185
238
|
// inverse binds - the body/legs and LOD splits exporters produce) share
|
|
186
|
-
// one texture, so their palette is computed and uploaded once.
|
|
187
|
-
|
|
239
|
+
// one texture, so their palette is computed and uploaded once. A worn
|
|
240
|
+
// piece's rows move onto another model's joints (bindSkeleton).
|
|
188
241
|
data.skins.forEach((skin, i) => {
|
|
189
|
-
let joints = skin.joints.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
242
|
+
let joints = skin.joints.map((n) => {
|
|
243
|
+
let joint = groups[n]
|
|
244
|
+
if (joint === undefined) throw new Error("createModel: skin " + i + " names a missing node " + n)
|
|
245
|
+
return joint
|
|
205
246
|
})
|
|
247
|
+
let key = skin.joints.join(",")
|
|
248
|
+
let same = model._skins.find(
|
|
249
|
+
(other, j) =>
|
|
250
|
+
data.skins[j]!.joints.join(",") === key &&
|
|
251
|
+
other.inverseBind.length === skin.inverseBind.length &&
|
|
252
|
+
other.inverseBind.every((v, k) => v === skin.inverseBind[k]),
|
|
253
|
+
)
|
|
254
|
+
let texture =
|
|
255
|
+
same?.texture ??
|
|
256
|
+
createMutableTexture(new Float32Array(skin.joints.length * 16), 4, skin.joints.length, {
|
|
257
|
+
format: "rgba32f",
|
|
258
|
+
autoFree: false,
|
|
259
|
+
label: label ? label + "-skin" + i : "skin" + i,
|
|
260
|
+
})
|
|
261
|
+
model._skins.push({ texture, joints, inverseBind: skin.inverseBind, jointBounds: skin.jointBounds })
|
|
262
|
+
if (same !== undefined) return
|
|
206
263
|
textures.push(texture)
|
|
207
|
-
|
|
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])
|
|
264
|
+
joints.forEach((joint, j) => {
|
|
211
265
|
;(joint._palettes ??= []).push({ texture, row: j, post: skin.inverseBind.slice(j * 16, j * 16 + 16), anchor: model })
|
|
212
|
-
|
|
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
|
-
}
|
|
266
|
+
})
|
|
218
267
|
})
|
|
268
|
+
// Each joint's influence box, in joint space: culling-only bounds the
|
|
269
|
+
// flush carries through the pose, united over every skin reaching it.
|
|
270
|
+
refreshJointBounds(model)
|
|
219
271
|
model.parts = data.parts.map((part) => {
|
|
220
272
|
let skinned = part.skin !== null
|
|
221
|
-
let
|
|
273
|
+
let morphed = part.geometry.morphs !== undefined
|
|
274
|
+
let material = materialFor(part.material, skinned, layoutSlot(part.geometry.layout, "aColor") !== null, morphed)
|
|
222
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
|
|
223
278
|
if (skinned) {
|
|
224
279
|
// A skinned part's vertices are model-space bind pose and the skin
|
|
225
280
|
// matrices place them, so its mesh hangs off the model root (the
|
|
226
281
|
// spec ignores the node's transform for skinned meshes) and uModel
|
|
227
282
|
// stays the model's own placement.
|
|
228
|
-
let
|
|
229
|
-
if (
|
|
230
|
-
mesh._textures = { uBones: texture }
|
|
283
|
+
let skin = model._skins[part.skin!]
|
|
284
|
+
if (skin === undefined) throw new Error("createModel: part '" + part.name + "' names a missing skin " + part.skin)
|
|
285
|
+
mesh._textures = { uBones: skin.texture }
|
|
231
286
|
// Culled by its joints' boxes, so the box follows the animation.
|
|
232
|
-
mesh._cullJoints =
|
|
287
|
+
mesh._cullJoints = skin.joints.slice()
|
|
233
288
|
add(model, mesh)
|
|
234
289
|
} else {
|
|
235
290
|
let node = groups[part.node]
|
|
@@ -241,11 +296,32 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
241
296
|
model.materials = materials
|
|
242
297
|
model.clips = data.clips
|
|
243
298
|
model.bounds = data.bounds
|
|
299
|
+
let disposed = false
|
|
244
300
|
model.dispose = () => {
|
|
301
|
+
if (disposed) return
|
|
302
|
+
// A worn piece comes off first (its rows sit on the body's joints);
|
|
303
|
+
// a body takes its pieces with it (their skins read joints that are
|
|
304
|
+
// about to go).
|
|
305
|
+
if (model._body !== null) unbindSkeleton(model)
|
|
306
|
+
for (let worn of model._worn.slice()) worn.piece.dispose()
|
|
245
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
|
|
246
314
|
for (let part of model.parts) disposeGeometry(part.mesh.geometry)
|
|
247
315
|
for (let id of textures) destroyTexture(id)
|
|
248
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
|
+
}
|
|
249
325
|
// Core clips a mixer registered; their players drop at the next
|
|
250
326
|
// advance (the leave above already killed the target nodes).
|
|
251
327
|
for (let clip of model.clips) {
|
|
@@ -259,9 +335,27 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
259
335
|
}
|
|
260
336
|
}
|
|
261
337
|
}
|
|
338
|
+
if (opts.autoFree !== false && getOwner()) onCleanup(() => model.dispose())
|
|
262
339
|
return model
|
|
263
340
|
}
|
|
264
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
|
+
|
|
265
359
|
/**
|
|
266
360
|
* Read a .glb or .gltf (with its external .bin and image files, resolved
|
|
267
361
|
* next to it) and build the model. The parse runs on the runtime - fine
|
|
@@ -269,6 +363,7 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
269
363
|
* `srt tool 3d/model` and use loadModel.
|
|
270
364
|
*/
|
|
271
365
|
export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model> {
|
|
366
|
+
let owner = getOwner()
|
|
272
367
|
let bytes = await file(path).bytes()
|
|
273
368
|
// A .glb usually embeds everything, but external uris are legal there
|
|
274
369
|
// too (some exporters keep images as files), so both containers get
|
|
@@ -278,11 +373,13 @@ export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model
|
|
|
278
373
|
for (let uri of gltfExternalUris(bytes)) {
|
|
279
374
|
if (!files.has(uri)) files.set(uri, await file(dir + decodeURIComponent(uri)).bytes())
|
|
280
375
|
}
|
|
281
|
-
return createModel(parseGltf(bytes, (uri) => files.get(uri)!), opts)
|
|
376
|
+
return buildOwned(owner, opts, () => createModel(parseGltf(bytes, (uri) => files.get(uri)!), opts))
|
|
282
377
|
}
|
|
283
378
|
|
|
284
379
|
/** Read a baked .srtm model (`srt tool 3d/model`) and build it: no parsing,
|
|
285
380
|
* the geometry views the file's bytes directly. */
|
|
286
381
|
export async function loadModel(path: string, opts?: ModelOptions): Promise<Model> {
|
|
287
|
-
|
|
382
|
+
let owner = getOwner()
|
|
383
|
+
let bytes = await file(path).bytes()
|
|
384
|
+
return buildOwned(owner, opts, () => createModel(decodeModel(bytes), opts))
|
|
288
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)
|