@solidrt/3d 0.0.64 → 0.0.66
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 +163 -54
- package/examples/README.md +4 -4
- package/examples/cascades.tsx +1 -1
- package/examples/collision.tsx +1 -1
- package/examples/exits.tsx +1 -1
- package/examples/first-person.tsx +1 -1
- package/examples/fleet.tsx +1 -1
- package/examples/fog.tsx +19 -6
- package/examples/lod.tsx +1 -1
- package/examples/model-load.tsx +2 -2
- package/examples/model.tsx +1 -1
- package/examples/morph.tsx +1 -1
- package/examples/normals.tsx +1 -1
- package/examples/pick.tsx +1 -1
- package/examples/wireframe.tsx +1 -1
- package/package.json +6 -3
- package/src/environment-bake.ts +8 -8
- package/src/environment.ts +6 -6
- package/src/geometry.ts +1 -1
- package/src/glsl.ts +2 -2
- package/src/gltf.ts +98 -15
- package/src/index.ts +3 -3
- package/src/mixer.ts +1 -1
- package/src/model-data.ts +11 -9
- package/src/model-file.ts +25 -16
- package/src/model.ts +43 -23
- package/src/node.ts +7 -2
- package/src/scene-pointer.ts +6 -5
- package/src/scene.ts +1 -1
- package/src/splat-data.ts +14 -14
- package/src/splat.ts +3 -3
- package/tools/environment.ts +4 -4
- package/tools/model.flux.ts +206 -0
- package/tools/node-env.d.ts +1 -1
- package/tools/splat.ts +4 -4
- package/tools/model.ts +0 -56
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/3d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.66",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -19,8 +19,11 @@
|
|
|
19
19
|
"examples/",
|
|
20
20
|
"AGENTS.md"
|
|
21
21
|
],
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@solidrt/test": "0.0.66"
|
|
24
|
+
},
|
|
22
25
|
"peerDependencies": {
|
|
23
|
-
"@solidjs/signals": "2.0.0-rc.
|
|
24
|
-
"@solidrt/core": "0.0.
|
|
26
|
+
"@solidjs/signals": "2.0.0-rc.13",
|
|
27
|
+
"@solidrt/core": "0.0.66"
|
|
25
28
|
}
|
|
26
29
|
}
|
package/src/environment-bake.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// Environment baking: the pure pipeline behind `
|
|
1
|
+
// Environment baking: the pure pipeline behind `sol tool 3d/environment`
|
|
2
2
|
// and its tests. A Radiance .hdr panorama is decoded to linear float, laid
|
|
3
3
|
// onto the six faces of a cube, convolved level by level with the GGX lobe
|
|
4
4
|
// `standard`'s split sum samples (Karis's importance sampling, the source
|
|
5
5
|
// lod picked from each sample's solid angle - the Filament and Unity
|
|
6
6
|
// method; Godot and Three convolve the same way at runtime), and packed
|
|
7
|
-
// into the .
|
|
7
|
+
// into the .sol3e container loadEnvironment uploads as an explicit rgba16f
|
|
8
8
|
// chain. Runs under bun and on the runtime alike: no GPU, no flux imports.
|
|
9
9
|
//
|
|
10
10
|
// Cube faces hold what a GL lookup of a world direction returns (GL's own
|
|
@@ -428,15 +428,15 @@ export function prefilterCube(base: CubeFaces, size: number): CubeFaces[] {
|
|
|
428
428
|
return levels
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
-
// The .
|
|
431
|
+
// The .sol3e container: "SOLE" u32 | version u32 | size u32 | levels u32,
|
|
432
432
|
// then the faces as float32 rgba, level-major, face-major - the exact
|
|
433
433
|
// buffers createCubeTexture's explicit chain takes, viewed in place.
|
|
434
|
-
/** "
|
|
435
|
-
const MAGIC =
|
|
434
|
+
/** "SOLE" read as a little-endian u32. */
|
|
435
|
+
const MAGIC = 0x454c4f53
|
|
436
436
|
const VERSION = 1
|
|
437
437
|
const HEADER_BYTES = 16
|
|
438
438
|
|
|
439
|
-
/** Serialize a prefiltered chain (prefilterCube's result) into the .
|
|
439
|
+
/** Serialize a prefiltered chain (prefilterCube's result) into the .sol3e container. */
|
|
440
440
|
export function encodeEnvironment(levels: CubeFaces[], size: number): Uint8Array {
|
|
441
441
|
if (levels.length !== mipLevels(size)) {
|
|
442
442
|
throw new Error("encodeEnvironment: a " + size + " chain has " + mipLevels(size) + " levels, got " + levels.length)
|
|
@@ -459,14 +459,14 @@ export function encodeEnvironment(levels: CubeFaces[], size: number): Uint8Array
|
|
|
459
459
|
return out
|
|
460
460
|
}
|
|
461
461
|
|
|
462
|
-
/** Read a .
|
|
462
|
+
/** Read a .sol3e container back into its size and chain, the faces viewing
|
|
463
463
|
* the bytes in place (copied only if the buffer is not float-aligned). */
|
|
464
464
|
export function decodeEnvironment(bytes: Uint8Array): { size: number; levels: CubeFaces[] } {
|
|
465
465
|
if (bytes.byteLength < HEADER_BYTES) throw new Error("decodeEnvironment: not an environment file (too short)")
|
|
466
466
|
let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
467
467
|
if (view.getUint32(0, true) !== MAGIC) throw new Error("decodeEnvironment: not an environment file (bad magic)")
|
|
468
468
|
let version = view.getUint32(4, true)
|
|
469
|
-
if (version !== VERSION) throw new Error("decodeEnvironment: version " + version + ", expected " + VERSION + " - re-bake with
|
|
469
|
+
if (version !== VERSION) throw new Error("decodeEnvironment: version " + version + ", expected " + VERSION + " - re-bake with sol tool 3d/environment")
|
|
470
470
|
let size = view.getUint32(8, true)
|
|
471
471
|
let count = view.getUint32(12, true)
|
|
472
472
|
if (size < 1 || count !== mipLevels(size)) throw new Error("decodeEnvironment: a " + size + " chain has " + mipLevels(size) + " levels, file says " + count)
|
package/src/environment.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Environment helpers: the baked HDR environment loader (the .
|
|
2
|
-
// `
|
|
1
|
+
// Environment helpers: the baked HDR environment loader (the .sol3e chain
|
|
2
|
+
// `sol tool 3d/environment` writes), the equirectangular-to-cube
|
|
3
3
|
// conversion a fetched LDR panorama needs at runtime, the GPU prefilter
|
|
4
4
|
// that turns a rendered cube (a reflection probe's, a baked sky's) into
|
|
5
5
|
// the roughness chain, and the placeholder cube a scene binds as uEnv
|
|
@@ -28,8 +28,8 @@ import type { CreateOptions, DrawId, ProgramId, RenderPipelineId, SamplerOptions
|
|
|
28
28
|
import { decodeEnvironment, levelRoughness, mipLevels } from "./environment-bake.ts"
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Read a baked environment (`
|
|
32
|
-
* assets/sky.
|
|
31
|
+
* Read a baked environment (`sol tool 3d/environment sky.hdr -o
|
|
32
|
+
* assets/sky.sol3e`) and upload it: the HDR cube with its GGX-prefiltered
|
|
33
33
|
* mip chain, as an explicit "rgba16f" chain - no generated mipmaps, so it
|
|
34
34
|
* works on every device. The id is what `environment={{ cube }}` and
|
|
35
35
|
* `background={{ cube }}` take (Three's `scene.environment` from an
|
|
@@ -147,7 +147,7 @@ const EQUIRECT_FACE = glsl`
|
|
|
147
147
|
* photographed sky uploaded as such, "rgba8" (default) for data,
|
|
148
148
|
* "rgba16f" for an HDR panorama (the cube keeps the range; renderable on
|
|
149
149
|
* every device with half-float rendering, else it throws - the prefiltered
|
|
150
|
-
* form of that panorama is `
|
|
150
|
+
* form of that panorama is `sol tool 3d/environment` plus
|
|
151
151
|
* loadEnvironment). The panorama's center column faces -Z and its top
|
|
152
152
|
* row is +Y. Leave its wrap at the default clamp: `repeat` would also
|
|
153
153
|
* wrap vertically and bleed the poles across the top and bottom rows,
|
|
@@ -280,7 +280,7 @@ export function bufferFormat(): BufferFormat {
|
|
|
280
280
|
* `source`, a `mipmap: true` cube of the same size and format (a cube
|
|
281
281
|
* draw target rendered sharp), one pass per face per level - 48 small
|
|
282
282
|
* passes at 128. Three's PMREMGenerator, Unity's and Godot's probe
|
|
283
|
-
* convolution, on the same roughness-to-level rule as the .
|
|
283
|
+
* convolution, on the same roughness-to-level rule as the .sol3e chain
|
|
284
284
|
* (levelRoughness), so `standard` reads both alike.
|
|
285
285
|
*/
|
|
286
286
|
export function createPrefilter(size: number, source: TextureId, format: BufferFormat, label: string): Prefilter {
|
package/src/geometry.ts
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
// format's codec.
|
|
38
38
|
//
|
|
39
39
|
// Pure module by design - geometry is data, and every function here is
|
|
40
|
-
// array math (
|
|
40
|
+
// array math (tests/geometry.test.ts runs it headless on
|
|
41
41
|
// flux). The GPU buffer step lives in geometry-gpu.ts.
|
|
42
42
|
|
|
43
43
|
import type { Topology, VertexAttribute, VertexFormat } from "@solidrt/core/gpu"
|
package/src/glsl.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import type { CullMode, VertexAttribute } from "@solidrt/core/gpu"
|
|
20
20
|
|
|
21
21
|
// core/gpu's glsl tag, aliased locally (it is String.raw) so this module
|
|
22
|
-
// stays runtime-pure: no flux:gpu import, so the
|
|
22
|
+
// stays runtime-pure: no flux:gpu import, so the tests and any
|
|
23
23
|
// bake-time tool can run it headless on bare flux.
|
|
24
24
|
let glsl = String.raw
|
|
25
25
|
|
|
@@ -815,7 +815,7 @@ export function resolveFragment(source: string = DEFAULT_RESOLVE): string {
|
|
|
815
815
|
* roughness - 0 the sharp base level, 1 the level whose faces are
|
|
816
816
|
* ENV_ROUGH_FACE (4) texels wide, linear between (Godot's
|
|
817
817
|
* `roughness * MAX_ROUGHNESS_LOD`; Unity's six LOD steps; Three maps
|
|
818
|
-
* onto a 16-texel floor): the level a baked chain (`
|
|
818
|
+
* onto a 16-texel floor): the level a baked chain (`sol tool
|
|
819
819
|
* 3d/environment`, loadEnvironment) convolves at that roughness, and a
|
|
820
820
|
* generated chain merely box-filters - scaled by the intensity: the
|
|
821
821
|
* prefiltered radiance of the split sum (`standard` multiplies it by PBR's
|
package/src/gltf.ts
CHANGED
|
@@ -16,10 +16,13 @@
|
|
|
16
16
|
// data: buffers and images, and single-file .glb.
|
|
17
17
|
//
|
|
18
18
|
// Pure module by design - a parse is JSON plus typed-array views plus one
|
|
19
|
-
// interleave loop per primitive, so it runs the same under bun (
|
|
20
|
-
//
|
|
19
|
+
// interleave loop per primitive, so it runs the same under bun (an app's
|
|
20
|
+
// bake script, the check rig) and on flux (loadGltf in
|
|
21
21
|
// model.ts). It never decodes images: material.map indexes the encoded
|
|
22
|
-
//
|
|
22
|
+
// files in `images`, and uploading is the engine side's job. An image is
|
|
23
|
+
// a PNG or JPEG, or a KTX2 compressed texture (KHR_texture_basisu, whose
|
|
24
|
+
// source a texture prefers over its plain one), kept with what its
|
|
25
|
+
// source calls it.
|
|
23
26
|
//
|
|
24
27
|
// Outside the subset: Draco/meshopt-compressed meshes and any other
|
|
25
28
|
// required extension throw naming it; tangents (the base channel and
|
|
@@ -44,6 +47,8 @@
|
|
|
44
47
|
// the way Three fills userData, so app data authored as custom
|
|
45
48
|
// properties arrives with the model.
|
|
46
49
|
|
|
50
|
+
import type { TextureKind } from "@solidrt/core/textures"
|
|
51
|
+
|
|
47
52
|
import { compose, decompose, det3, mat4, multiply, quatNormalize } from "./math.ts"
|
|
48
53
|
import { linearToSrgb } from "./color.ts"
|
|
49
54
|
import type { Mat4, Quat, Vec3 } from "./math.ts"
|
|
@@ -229,8 +234,13 @@ export type ModelData = {
|
|
|
229
234
|
/** The file's animations (empty when it has none). */
|
|
230
235
|
clips: ModelClip[]
|
|
231
236
|
materials: ModelMaterial[]
|
|
232
|
-
/**
|
|
233
|
-
images:
|
|
237
|
+
/** The images the materials' maps index. */
|
|
238
|
+
images: ModelImage[]
|
|
239
|
+
/** The KTX2 images transcoded for this device, by image index (the
|
|
240
|
+
* others absent): what `transcodeModelImages` adds and `createModel`
|
|
241
|
+
* uploads in place of the file. Never in a file; a parse and
|
|
242
|
+
* `decodeModel` leave it out. */
|
|
243
|
+
textures?: (ModelTexture | undefined)[]
|
|
234
244
|
/** World-space rest-pose [minX, minY, minZ, maxX, maxY, maxZ] over every
|
|
235
245
|
* part - each part's local box through its node's composed transform, and
|
|
236
246
|
* a skinned part's per-joint boxes through the joints' rest transforms
|
|
@@ -355,6 +365,68 @@ const DEFAULT_MATERIAL: ModelMaterial = {
|
|
|
355
365
|
}
|
|
356
366
|
|
|
357
367
|
/** True when the bytes are a .glb container (the "glTF" magic). */
|
|
368
|
+
/** One image of a model: its file as it is, and what its source calls
|
|
369
|
+
* it. */
|
|
370
|
+
export type ModelImage = {
|
|
371
|
+
/** The encoded image file: PNG, JPEG or KTX2 bytes. */
|
|
372
|
+
bytes: Uint8Array
|
|
373
|
+
/** Its name in the document; without one the uri of its file, and
|
|
374
|
+
* "image<i>" (its index in the document) for a nameless image embedded
|
|
375
|
+
* there. Labels its texture, and is how an image embedded in a model
|
|
376
|
+
* is told from the others (`solidrt.textures`). */
|
|
377
|
+
name: string
|
|
378
|
+
/** The file a parse read it from, as the document names it (still
|
|
379
|
+
* percent-encoded), when it is a file of its own. Absent for an image
|
|
380
|
+
* embedded in its document, and on everything read from the container,
|
|
381
|
+
* which embeds its images. */
|
|
382
|
+
uri?: string
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** How a model samples one of its images: what decides its color space
|
|
386
|
+
* at upload and how it is compressed at bake. */
|
|
387
|
+
export type ModelImageUse = {
|
|
388
|
+
/** A material samples it as color (base color, emissive): its texels
|
|
389
|
+
* are sRGB-encoded, as glTF stores those. Every other map is linear. */
|
|
390
|
+
srgb: boolean
|
|
391
|
+
/** What its errors cost, which picks its codec at bake time (the kinds
|
|
392
|
+
* of `solidrt.textures`): "normal" when any material samples it as a
|
|
393
|
+
* normal map, else "color" when it is a color map, else "data". An
|
|
394
|
+
* image several slots share takes the most demanding of them, and
|
|
395
|
+
* stays sRGB if one of them is a color slot. */
|
|
396
|
+
kind: TextureKind
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* How the model's materials sample each of its images, by image index.
|
|
401
|
+
* The one rule behind createModel's uploads and the bake's encodes, so a
|
|
402
|
+
* baked image is sampled as the same image unbaked.
|
|
403
|
+
*/
|
|
404
|
+
export function modelImageUses(data: Pick<ModelData, "images" | "materials">): ModelImageUse[] {
|
|
405
|
+
let uses: ModelImageUse[] = data.images.map(() => ({ srgb: false, kind: "data" }))
|
|
406
|
+
let at = (index: number | null): ModelImageUse | undefined => (index === null ? undefined : uses[index])
|
|
407
|
+
for (let m of data.materials) {
|
|
408
|
+
for (let color of [at(m.map), at(m.emissiveMap)]) {
|
|
409
|
+
if (color === undefined) continue
|
|
410
|
+
color.srgb = true
|
|
411
|
+
if (color.kind === "data") color.kind = "color"
|
|
412
|
+
}
|
|
413
|
+
let normal = at(m.normalMap)
|
|
414
|
+
if (normal !== undefined) normal.kind = "normal"
|
|
415
|
+
}
|
|
416
|
+
return uses
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** A compressed texture ready to upload: the payload `createTexture`
|
|
420
|
+
* takes at `format`, with the full mip chain when `mipmap`. The shape
|
|
421
|
+
* `transcodeTexture` returns. */
|
|
422
|
+
export type ModelTexture = {
|
|
423
|
+
data: Uint8Array
|
|
424
|
+
width: number
|
|
425
|
+
height: number
|
|
426
|
+
format: "etc2-rgba8" | "etc2-rgba8-srgb" | "bc7-rgba8" | "bc7-rgba8-srgb" | "rgba8" | "rgba8-srgb"
|
|
427
|
+
mipmap: boolean
|
|
428
|
+
}
|
|
429
|
+
|
|
358
430
|
export function isGlb(bytes: Uint8Array): boolean {
|
|
359
431
|
return bytes.length >= 12 && new DataView(bytes.buffer, bytes.byteOffset, 12).getUint32(0, true) === GLB_MAGIC
|
|
360
432
|
}
|
|
@@ -363,7 +435,7 @@ export function isGlb(bytes: Uint8Array): boolean {
|
|
|
363
435
|
// samples, and so the only images the parser ever opens: the prefetch
|
|
364
436
|
// list (gltfExternalUris) and the parser's demand set are both derived
|
|
365
437
|
// from this one table, so a channel added to the map is added here too or
|
|
366
|
-
//
|
|
438
|
+
// tests/gltf.test.ts fails on its all-channels fixture.
|
|
367
439
|
const SAMPLED_TEXTURES: ((material: any) => any)[] = [
|
|
368
440
|
(m) => m.pbrMetallicRoughness?.baseColorTexture,
|
|
369
441
|
(m) => m.normalTexture,
|
|
@@ -371,14 +443,21 @@ const SAMPLED_TEXTURES: ((material: any) => any)[] = [
|
|
|
371
443
|
(m) => m.pbrMetallicRoughness?.metallicRoughnessTexture,
|
|
372
444
|
]
|
|
373
445
|
|
|
446
|
+
// The image a texture samples: the KTX2 one where the file carries it
|
|
447
|
+
// (KHR_texture_basisu), ahead of the plain source a file may keep beside
|
|
448
|
+
// it as a fallback for loaders without the extension.
|
|
449
|
+
function textureSource(texture: any): number | undefined {
|
|
450
|
+
return texture?.extensions?.KHR_texture_basisu?.source ?? texture?.source
|
|
451
|
+
}
|
|
452
|
+
|
|
374
453
|
// The image indices a document's materials sample (SAMPLED_TEXTURES
|
|
375
|
-
// through
|
|
454
|
+
// through textureSource), the demand set imageSlot will meet.
|
|
376
455
|
function sampledImages(gltf: any): Set<number> {
|
|
377
456
|
let images = new Set<number>()
|
|
378
457
|
for (let m of gltf.materials ?? []) {
|
|
379
458
|
for (let channel of SAMPLED_TEXTURES) {
|
|
380
459
|
let ref = channel(m)
|
|
381
|
-
let source = ref === undefined ? undefined : gltf.textures?.[ref.index]
|
|
460
|
+
let source = ref === undefined ? undefined : textureSource(gltf.textures?.[ref.index])
|
|
382
461
|
if (source !== undefined) images.add(source)
|
|
383
462
|
}
|
|
384
463
|
}
|
|
@@ -429,9 +508,9 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
|
|
|
429
508
|
}
|
|
430
509
|
// Quantized attributes read through the normalized-integer path,
|
|
431
510
|
// emissive strength is the emissive intensity, GPU instancing is
|
|
432
|
-
// read as placements
|
|
433
|
-
// the file means.
|
|
434
|
-
if (ext !== "KHR_mesh_quantization" && ext !== "KHR_materials_emissive_strength" && ext !== "EXT_mesh_gpu_instancing") {
|
|
511
|
+
// read as placements, a KTX2 image is an image like another; every
|
|
512
|
+
// other required extension changes what the file means.
|
|
513
|
+
if (ext !== "KHR_mesh_quantization" && ext !== "KHR_materials_emissive_strength" && ext !== "EXT_mesh_gpu_instancing" && ext !== "KHR_texture_basisu") {
|
|
435
514
|
throw new Error("parseGltf: the file requires the " + ext + " extension, which is not supported")
|
|
436
515
|
}
|
|
437
516
|
}
|
|
@@ -462,7 +541,7 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
|
|
|
462
541
|
|
|
463
542
|
// Images are pulled in only when a material samples them, in first-use
|
|
464
543
|
// order, so `map` indexes a compact list.
|
|
465
|
-
let images:
|
|
544
|
+
let images: ModelImage[] = []
|
|
466
545
|
let imageSlots = new Map<number, number>()
|
|
467
546
|
let imageSlot = (index: number): number => {
|
|
468
547
|
let slot = imageSlots.get(index)
|
|
@@ -470,8 +549,12 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
|
|
|
470
549
|
let image = gltf.images?.[index]
|
|
471
550
|
if (image === undefined) throw new Error("parseGltf: texture names a missing image " + index)
|
|
472
551
|
let bytes = image.uri !== undefined ? external(image.uri, "image " + index) : bufferViewBytes(image.bufferView)
|
|
552
|
+
// A data: uri is the image embedded in the document, not a file.
|
|
553
|
+
let file: string | undefined = image.uri !== undefined && !image.uri.startsWith("data:") ? image.uri : undefined
|
|
554
|
+
let entry: ModelImage = { bytes, name: image.name ?? file ?? "image" + index }
|
|
555
|
+
if (file !== undefined) entry.uri = file
|
|
473
556
|
slot = images.length
|
|
474
|
-
images.push(
|
|
557
|
+
images.push(entry)
|
|
475
558
|
imageSlots.set(index, slot)
|
|
476
559
|
}
|
|
477
560
|
return slot
|
|
@@ -484,8 +567,8 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
|
|
|
484
567
|
// (the prefetch list): a new channel goes in the table first.
|
|
485
568
|
let textureSlot = (ref: any): number | null => {
|
|
486
569
|
if (ref === undefined) return null
|
|
487
|
-
let
|
|
488
|
-
return
|
|
570
|
+
let source = textureSource(gltf.textures?.[ref.index])
|
|
571
|
+
return source !== undefined ? imageSlot(source) : null
|
|
489
572
|
}
|
|
490
573
|
let materials: ModelMaterial[] = (gltf.materials ?? []).map((m: any, i: number): ModelMaterial => {
|
|
491
574
|
let pbr = m.pbrMetallicRoughness ?? {}
|
package/src/index.ts
CHANGED
|
@@ -77,10 +77,10 @@ export type {
|
|
|
77
77
|
ShotProps,
|
|
78
78
|
ShotsProps,
|
|
79
79
|
} from "./components/index.ts"
|
|
80
|
-
export { gltfExternalUris, isGlb, parseGltf } from "./gltf.ts"
|
|
81
|
-
export type { ModelChannel, ModelClip, ModelData, ModelExtras, ModelMaterial, ModelNode, ModelPart, ModelSkin, UriResolver } from "./gltf.ts"
|
|
80
|
+
export { gltfExternalUris, isGlb, modelImageUses, parseGltf } from "./gltf.ts"
|
|
81
|
+
export type { ModelChannel, ModelClip, ModelData, ModelExtras, ModelImage, ModelImageUse, ModelMaterial, ModelNode, ModelPart, ModelSkin, ModelTexture, UriResolver } from "./gltf.ts"
|
|
82
82
|
export { decodeModel, encodeModel } from "./model-file.ts"
|
|
83
|
-
export { createModel, loadGltf, loadModel } from "./model.ts"
|
|
83
|
+
export { createModel, loadGltf, loadModel, transcodeModelImages } from "./model.ts"
|
|
84
84
|
export type { Model, ModelMaps, ModelOptions } from "./model.ts"
|
|
85
85
|
export { decodeSplat, encodeSplat, parseSplat, SPLAT_ATTRIBUTES, SPLAT_RECORD_BYTES, SPLAT_RECORD_TEXELS, SPLAT_SH_TEXELS, SPLAT_TEXEL_BYTES } from "./splat-data.ts"
|
|
86
86
|
export type { ParseSplatOptions, SplatData, SplatFormat } from "./splat-data.ts"
|
package/src/mixer.ts
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
|
|
45
45
|
import * as spatial from "flux:spatial"
|
|
46
46
|
import type { NodeId } from "flux:spatial"
|
|
47
|
-
import { on } from "
|
|
47
|
+
import { on } from "sol:events"
|
|
48
48
|
import type { ModelChannel, ModelClip } from "./gltf.ts"
|
|
49
49
|
import { channelElements, sampleChannel } from "./clip.ts"
|
|
50
50
|
import type { Vec3 } from "./math.ts"
|
package/src/model-data.ts
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
// @solidrt/3d/model - the model DATA side of the package without the
|
|
2
|
-
// runtime: the glTF parser, the .
|
|
2
|
+
// runtime: the glTF parser, the .sol3m container and the pure geometry kit,
|
|
3
3
|
// for a script that bakes an app's own geometry under bun (or any host
|
|
4
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
|
|
6
|
-
// glTF bake)
|
|
7
|
-
// the tool share one path:
|
|
5
|
+
// the runtime; this entry imports nothing that does. tools/model.flux.ts
|
|
6
|
+
// (the glTF bake) takes its model side from exactly this surface, so an
|
|
7
|
+
// app's bake script and the tool share one path:
|
|
8
8
|
//
|
|
9
9
|
// import { box, encodeModel, transformGeometry } from "@solidrt/3d/model"
|
|
10
|
-
// writeFileSync("assets/terrain.
|
|
10
|
+
// writeFileSync("assets/terrain.sol3m", encodeModel({ nodes, parts, ... }))
|
|
11
11
|
//
|
|
12
12
|
// createModel / loadModel on the runtime read what it writes. Keep this
|
|
13
|
-
// entry pure
|
|
14
|
-
// moment a
|
|
13
|
+
// entry pure. tests/model-data.test.ts imports it on the bare flux binary
|
|
14
|
+
// and fails the moment a gui or sol: import creeps into the chain; a
|
|
15
|
+
// headless flux: import it would not notice, and a bake script under bun
|
|
16
|
+
// would.
|
|
15
17
|
|
|
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 { gltfExternalUris, isGlb, modelImageUses, parseGltf } from "./gltf.ts"
|
|
19
|
+
export type { ModelChannel, ModelClip, ModelData, ModelExtras, ModelImage, ModelImageUse, ModelMaterial, ModelNode, ModelPart, ModelSkin, ModelTexture, UriResolver } from "./gltf.ts"
|
|
18
20
|
export { decodeModel, encodeModel } from "./model-file.ts"
|
|
19
21
|
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
22
|
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"
|
package/src/model-file.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// The baked model container (.
|
|
1
|
+
// The baked model container (.sol3m): ModelData as one file whose payload IS
|
|
2
2
|
// the GPU layout, so loading it is a header parse plus typed-array views -
|
|
3
|
-
// no per-vertex work. Written by tools/model.ts
|
|
4
|
-
// read by loadModel on flux; both ends are this pure module.
|
|
3
|
+
// no per-vertex work. Written by tools/model.flux.ts (from parseGltf),
|
|
4
|
+
// read by loadModel on flux; both ends are this pure module. An image
|
|
5
|
+
// block carries the image file as it is, a PNG, a JPEG or a KTX2, under
|
|
6
|
+
// the image's name.
|
|
5
7
|
//
|
|
6
8
|
// Layout, all little-endian:
|
|
7
|
-
// "
|
|
9
|
+
// "SOLM" u32 | version u32 | jsonLength u32 | json (padded to 4) | payload
|
|
8
10
|
// The JSON header describes each block's byte range into the payload; every
|
|
9
11
|
// block starts 4-aligned so Float32Array/Uint32Array views sit on it
|
|
10
12
|
// directly. Images travel as their encoded files (PNG/JPEG bytes), app
|
|
@@ -16,19 +18,19 @@
|
|
|
16
18
|
// flipped index order) share the vertex block - decodeModel restores the
|
|
17
19
|
// same identities, so the runtime uploads shared bytes once.
|
|
18
20
|
|
|
19
|
-
import type { ModelChannel, ModelData, ModelExtras, ModelMaterial, ModelNode, ModelSkin } from "./gltf.ts"
|
|
21
|
+
import type { ModelChannel, ModelData, ModelExtras, ModelImage, ModelMaterial, ModelNode, ModelSkin } from "./gltf.ts"
|
|
20
22
|
import { layoutStride, vertexView, VERTEX_FORMATS } from "./geometry.ts"
|
|
21
23
|
import type { VertexAttribute } from "@solidrt/core/gpu"
|
|
22
24
|
import type { Geometry, VertexLayout } from "./geometry.ts"
|
|
23
25
|
|
|
24
|
-
/** "
|
|
25
|
-
const MAGIC =
|
|
26
|
+
/** "SOLM" read as a little-endian u32. */
|
|
27
|
+
const MAGIC = 0x4d4c4f53
|
|
26
28
|
// Version 3 retained the node hierarchy - a node table (name/parent/TRS)
|
|
27
29
|
// in the header, parts referencing their node, vertices NODE-LOCAL
|
|
28
30
|
// instead of world-baked - and carries skins (joints + inverse binds,
|
|
29
31
|
// parts in the "skinned" layout) and animation clips (channel
|
|
30
32
|
// times/values as payload blocks). Version-2 files world-baked and have
|
|
31
|
-
// none of it, so they are rejected - re-bake with `
|
|
33
|
+
// none of it, so they are rejected - re-bake with `sol tool 3d/model`.
|
|
32
34
|
// Version 4 adds the metalness/roughness fields to the material records
|
|
33
35
|
// (metalness, roughness, metalnessRoughnessMap); a version-3 file lacks
|
|
34
36
|
// them, so it is rejected the same way rather than read as all-metal.
|
|
@@ -37,7 +39,7 @@ const MAGIC = 0x4d545253
|
|
|
37
39
|
// carry packed channels; a version-5 file's "vec3" words do not parse.
|
|
38
40
|
// Version 7 changes the named "skinned" layout's joints from float32x4 to
|
|
39
41
|
// uint8x4 (an integer shader input), so a version-6 skinned part would
|
|
40
|
-
// decode at the wrong stride; it is rejected. Re-bake with `
|
|
42
|
+
// decode at the wrong stride; it is rejected. Re-bake with `sol tool
|
|
41
43
|
// 3d/model`.
|
|
42
44
|
// Version 8 carries morph targets: a part's packed targets block (names,
|
|
43
45
|
// the sparse-by-vertex texels, the extent - see packMorphTargets), the
|
|
@@ -53,8 +55,11 @@ const MAGIC = 0x4d545253
|
|
|
53
55
|
// shared blocks, see the header). A version-9 file has none of these and is rejected like
|
|
54
56
|
// every earlier version rather than read as a file without them, so a
|
|
55
57
|
// stale bake never silently drops the data an app relies on. Re-bake
|
|
56
|
-
// with `
|
|
57
|
-
|
|
58
|
+
// with `sol tool 3d/model`.
|
|
59
|
+
// Version 11 names the images: an image record is its block and the
|
|
60
|
+
// image's name (ModelImage.name), which labels its texture. A
|
|
61
|
+
// version-10 file has blocks alone and is rejected the same way.
|
|
62
|
+
const VERSION = 11
|
|
58
63
|
|
|
59
64
|
// The named layouts the container writes by name; a custom attribute-list
|
|
60
65
|
// layout (a skinned primitive with COLOR_0, a withAttribute channel) is
|
|
@@ -76,6 +81,10 @@ function decodeLayout(layout: string | VertexAttribute[], name: string): VertexL
|
|
|
76
81
|
|
|
77
82
|
type Block = { offset: number; bytes: number }
|
|
78
83
|
|
|
84
|
+
/** One image: the block holding its file, and its name. The uri a parse
|
|
85
|
+
* read it from is not written: the container embeds the image. */
|
|
86
|
+
type ImageHeader = Block & { name: string }
|
|
87
|
+
|
|
79
88
|
/** One geometry of the table: its interleaved vertex block, index block
|
|
80
89
|
* and packed morph targets. Blocks may be shared between entries. */
|
|
81
90
|
type GeometryHeader = {
|
|
@@ -116,7 +125,7 @@ type ClipHeader = { name: string; duration: number; channels: ChannelHeader[] }
|
|
|
116
125
|
type Header = {
|
|
117
126
|
nodes: ModelNode[]
|
|
118
127
|
materials: ModelMaterial[]
|
|
119
|
-
images:
|
|
128
|
+
images: ImageHeader[]
|
|
120
129
|
geometries: GeometryHeader[]
|
|
121
130
|
parts: PartHeader[]
|
|
122
131
|
skins: SkinHeader[]
|
|
@@ -129,7 +138,7 @@ type Header = {
|
|
|
129
138
|
blobs?: Record<string, Block>
|
|
130
139
|
}
|
|
131
140
|
|
|
132
|
-
/** Serialize a model into the .
|
|
141
|
+
/** Serialize a model into the .sol3m container. */
|
|
133
142
|
export function encodeModel(data: ModelData): Uint8Array {
|
|
134
143
|
let blocks: Uint8Array[] = []
|
|
135
144
|
let offset = 0
|
|
@@ -190,7 +199,7 @@ export function encodeModel(data: ModelData): Uint8Array {
|
|
|
190
199
|
if (part.extras !== undefined) header.extras = part.extras
|
|
191
200
|
return header
|
|
192
201
|
})
|
|
193
|
-
let images = data.images.map((image) => push(image))
|
|
202
|
+
let images: ImageHeader[] = data.images.map((image) => ({ ...push(image.bytes), name: image.name }))
|
|
194
203
|
let floats = (f: Float32Array): Block => push(new Uint8Array(f.buffer, f.byteOffset, f.byteLength))
|
|
195
204
|
let skins: SkinHeader[] = data.skins.map((skin) => ({ joints: skin.joints, inverseBind: floats(skin.inverseBind), jointBounds: floats(skin.jointBounds) }))
|
|
196
205
|
let clips: ClipHeader[] = data.clips.map((clip) => ({
|
|
@@ -228,7 +237,7 @@ export function encodeModel(data: ModelData): Uint8Array {
|
|
|
228
237
|
}
|
|
229
238
|
|
|
230
239
|
/**
|
|
231
|
-
* Read a .
|
|
240
|
+
* Read a .sol3m container back into ModelData. The geometry arrays are
|
|
232
241
|
* VIEWS onto `bytes` (copied once only when the input is not 4-aligned),
|
|
233
242
|
* so the bytes must outlive the model.
|
|
234
243
|
*/
|
|
@@ -277,7 +286,7 @@ export function decodeModel(bytes: Uint8Array): ModelData {
|
|
|
277
286
|
if (part.extras !== undefined) out.extras = part.extras
|
|
278
287
|
return out
|
|
279
288
|
})
|
|
280
|
-
let images = header.images.map((
|
|
289
|
+
let images: ModelImage[] = header.images.map((image) => ({ bytes: new Uint8Array(buffer, payload + image.offset, image.bytes), name: image.name }))
|
|
281
290
|
let floats = (block: Block): Float32Array => new Float32Array(buffer, payload + block.offset, block.bytes / 4)
|
|
282
291
|
let skins: ModelSkin[] = header.skins.map((skin) => ({ joints: skin.joints, inverseBind: floats(skin.inverseBind), jointBounds: floats(skin.jointBounds) }))
|
|
283
292
|
let clips = header.clips.map((clip) => ({
|
package/src/model.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Models in a scene: ModelData (parsed glTF or a decoded .
|
|
1
|
+
// Models in a scene: ModelData (parsed glTF or a decoded .sol3m) becomes a
|
|
2
2
|
// Group carrying the file's node hierarchy - nested Groups with the file's
|
|
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
|
|
@@ -13,15 +13,17 @@
|
|
|
13
13
|
// them and detaches the group. loadGltf / loadModel are the
|
|
14
14
|
// read-then-create conveniences over flux:fs; parseGltf / decodeModel +
|
|
15
15
|
// createModel are the primitives under them, for bytes obtained any other
|
|
16
|
-
// way (a binary import, a fetch).
|
|
16
|
+
// way (a binary import, a fetch). Compressed images (KTX2, what `sol tool
|
|
17
|
+
// 3d/model` bakes) are turned into this device's block format by
|
|
18
|
+
// transcodeModelImages, the one asynchronous step between the two.
|
|
17
19
|
|
|
18
20
|
import { file } from "flux:fs"
|
|
19
21
|
import * as spatial from "flux:spatial"
|
|
20
|
-
import { decodeImage, getOwner, isDisposed, onCleanup, runWithOwner } from "@solidrt/core"
|
|
22
|
+
import { decodeImage, getOwner, isDisposed, isKtx2, onCleanup, runWithOwner, transcodeTexture } from "@solidrt/core"
|
|
21
23
|
import type { Owner } from "@solidrt/core"
|
|
22
24
|
import { createMutableTexture, createTexture, destroyTexture } from "@solidrt/core/gpu"
|
|
23
25
|
import type { TextureId } from "@solidrt/core/gpu"
|
|
24
|
-
import { gltfExternalUris, parseGltf } from "./gltf.ts"
|
|
26
|
+
import { gltfExternalUris, modelImageUses, parseGltf } from "./gltf.ts"
|
|
25
27
|
import type { ModelClip, ModelData, ModelExtras, ModelMaterial } from "./gltf.ts"
|
|
26
28
|
import { decodeModel } from "./model-file.ts"
|
|
27
29
|
import { disposeGeometry } from "./geometry-gpu.ts"
|
|
@@ -144,6 +146,20 @@ export type ModelSkinNodes = {
|
|
|
144
146
|
jointBounds: Float32Array
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Transcode the model's compressed images (KTX2) into this device's block
|
|
151
|
+
* format, off the JS thread (all are started at once; the runtime runs a
|
|
152
|
+
* few at a time), and return the data with them as `textures`; the images that are PNG or JPEG are left for
|
|
153
|
+
* createModel to decode. The step between a parse (or decodeModel) and
|
|
154
|
+
* createModel for data that may hold KTX2 images; loadModel and loadGltf
|
|
155
|
+
* run it. Data with no KTX2 image comes back as it is.
|
|
156
|
+
*/
|
|
157
|
+
export async function transcodeModelImages(data: ModelData): Promise<ModelData> {
|
|
158
|
+
if (!data.images.some((image) => isKtx2(image.bytes))) return data
|
|
159
|
+
let textures = await Promise.all(data.images.map((image) => (isKtx2(image.bytes) ? transcodeTexture(image.bytes) : undefined)))
|
|
160
|
+
return { ...data, textures }
|
|
161
|
+
}
|
|
162
|
+
|
|
147
163
|
/**
|
|
148
164
|
* Build the scene object for parsed model data: upload its images (repeat
|
|
149
165
|
* wrap, mipmapped, MODEL_ANISOTROPY; the base color and emissive images
|
|
@@ -151,24 +167,26 @@ export type ModelSkinNodes = {
|
|
|
151
167
|
* rgba8), make a material per glTF material, the file's node hierarchy as
|
|
152
168
|
* nested Groups, and a mesh per part under its node. Synchronous - the
|
|
153
169
|
* data is already in memory.
|
|
170
|
+
*
|
|
171
|
+
* A compressed image uploads as transcodeModelImages left it: at the
|
|
172
|
+
* format and color space its file names, with the mip chain the file
|
|
173
|
+
* carries (none can be generated for compressed storage). Data holding a
|
|
174
|
+
* KTX2 image that was not transcoded throws.
|
|
154
175
|
*/
|
|
155
176
|
export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
156
177
|
let label = opts.label
|
|
157
|
-
let
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
autoFree: false,
|
|
170
|
-
label: label ? label + "-image" + i : undefined,
|
|
171
|
-
})
|
|
178
|
+
let uses = modelImageUses(data)
|
|
179
|
+
let textures: TextureId[] = data.images.map((image, i) => {
|
|
180
|
+
let sampling = { wrap: "repeat" as const, anisotropy: MODEL_ANISOTROPY, autoFree: false, label: label ? label + "-" + image.name : image.name }
|
|
181
|
+
let ready = data.textures?.[i]
|
|
182
|
+
if (ready !== undefined) {
|
|
183
|
+
return createTexture(ready.data, ready.width, ready.height, { ...sampling, format: ready.format, mipmap: ready.mipmap })
|
|
184
|
+
}
|
|
185
|
+
if (isKtx2(image.bytes)) {
|
|
186
|
+
throw new Error("createModel: image '" + image.name + "' is a KTX2 compressed texture, which is transcoded before the model is created: await transcodeModelImages(data) first (loadModel and loadGltf do)")
|
|
187
|
+
}
|
|
188
|
+
let decoded = decodeImage(image.bytes)
|
|
189
|
+
return createTexture(decoded.data, decoded.width, decoded.height, { ...sampling, format: uses[i]!.srgb ? "rgba8-srgb" : "rgba8", mipmap: true })
|
|
172
190
|
})
|
|
173
191
|
let make = opts.material ?? ((m: ModelMaterial, maps: ModelMaps, skinned: boolean, vertexColors: boolean, morphed: boolean, instanced: boolean): Material => {
|
|
174
192
|
// An emissive factor of zero is emission OFF (the glTF product rule:
|
|
@@ -421,7 +439,7 @@ function buildOwned(owner: Owner | null, opts: ModelOptions | undefined, build:
|
|
|
421
439
|
* Read a .glb or .gltf (with its external .bin and image files, resolved
|
|
422
440
|
* next to it) and build the model. The parse runs on the runtime - fine
|
|
423
441
|
* for models of tens of thousands of vertices; bake bigger ones with
|
|
424
|
-
* `
|
|
442
|
+
* `sol tool 3d/model` and use loadModel.
|
|
425
443
|
*/
|
|
426
444
|
export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model> {
|
|
427
445
|
let owner = getOwner()
|
|
@@ -434,13 +452,15 @@ export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model
|
|
|
434
452
|
for (let uri of gltfExternalUris(bytes)) {
|
|
435
453
|
if (!files.has(uri)) files.set(uri, await file(dir + decodeURIComponent(uri)).bytes())
|
|
436
454
|
}
|
|
437
|
-
|
|
455
|
+
let data = await transcodeModelImages(parseGltf(bytes, (uri) => files.get(uri)!))
|
|
456
|
+
return buildOwned(owner, opts, () => createModel(data, opts))
|
|
438
457
|
}
|
|
439
458
|
|
|
440
|
-
/** Read a baked .
|
|
459
|
+
/** Read a baked .sol3m model (`sol tool 3d/model`) and build it: no parsing,
|
|
441
460
|
* the geometry views the file's bytes directly. */
|
|
442
461
|
export async function loadModel(path: string, opts?: ModelOptions): Promise<Model> {
|
|
443
462
|
let owner = getOwner()
|
|
444
463
|
let bytes = await file(path).bytes()
|
|
445
|
-
|
|
464
|
+
let data = await transcodeModelImages(decodeModel(bytes))
|
|
465
|
+
return buildOwned(owner, opts, () => createModel(data, opts))
|
|
446
466
|
}
|