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