@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/src/node.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import * as spatial from "flux:spatial"
|
|
10
10
|
import type { NodeId, NodeMotionSpec, NodeTransition } from "flux:spatial"
|
|
11
|
-
import { on } from "
|
|
11
|
+
import { on } from "sol:events"
|
|
12
12
|
import { createMutableTexture, destroyTexture } from "@solidrt/core/gpu"
|
|
13
13
|
import type { ShaderParams, TextureId } from "@solidrt/core/gpu"
|
|
14
14
|
import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
|
|
@@ -273,6 +273,11 @@ export type SceneEventBase = {
|
|
|
273
273
|
* (a view's own pixels under a view leaf). */
|
|
274
274
|
x: number
|
|
275
275
|
y: number
|
|
276
|
+
/** When the event happened, ms: the element event's `timeStamp`. */
|
|
277
|
+
timeStamp: number
|
|
278
|
+
/** The element event's `predicted`: a move the pointer was never
|
|
279
|
+
* reported at. Draw with it, do not measure with it. */
|
|
280
|
+
predicted: boolean
|
|
276
281
|
pointerId: number
|
|
277
282
|
pointerType: string
|
|
278
283
|
button?: number
|
|
@@ -771,7 +776,7 @@ export type { TransformUpdate } from "./math.ts"
|
|
|
771
776
|
* orchestrator is always an ancestor). Clearing cancels running tracks in
|
|
772
777
|
* place (the node keeps its mid-flight transform) and later writes snap. Each natural settle calls the node's
|
|
773
778
|
* `onTransitionEnd` with the component (the raw "spatialTransitionEnd"
|
|
774
|
-
* engine event on
|
|
779
|
+
* engine event on sol:events stays for flux:spatial consumers; it carries
|
|
775
780
|
* the core id, `_node`).
|
|
776
781
|
*/
|
|
777
782
|
export function setTransition(node: SceneNode, transition: NodeTransition | string | null): void {
|
package/src/scene-pointer.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// dispatch.ts is the same model one dimension down.
|
|
20
20
|
//
|
|
21
21
|
// Pure BY DESIGN (types only, nothing with GPU imports) so
|
|
22
|
-
//
|
|
22
|
+
// tests/dispatch.test.ts drives it headless with a fake pick.
|
|
23
23
|
|
|
24
24
|
import type { PointerEvent as ElementPointerEvent, PointerFeed } from "@solidrt/core"
|
|
25
25
|
import type { NodePointerEvent, SceneNode, ScenePointerListener } from "./node.ts"
|
|
@@ -58,6 +58,8 @@ type InternalEvent = {
|
|
|
58
58
|
distance: number | null
|
|
59
59
|
x: number
|
|
60
60
|
y: number
|
|
61
|
+
timeStamp: number
|
|
62
|
+
predicted: boolean
|
|
61
63
|
pointerId: number
|
|
62
64
|
pointerType: string
|
|
63
65
|
button?: number
|
|
@@ -95,8 +97,6 @@ export type PointerInputDeps = {
|
|
|
95
97
|
root: Scene | ViewHandle
|
|
96
98
|
/** The root's listeners, in registration order. */
|
|
97
99
|
listeners: Set<ScenePointerListener>
|
|
98
|
-
/** The clock for tap repeats (default performance.now; checks inject). */
|
|
99
|
-
now?: () => number
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export type PointerInput = {
|
|
@@ -108,7 +108,6 @@ export type PointerInput = {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
export function makePointerInput(deps: PointerInputDeps): PointerInput {
|
|
111
|
-
let now = deps.now ?? (() => performance.now())
|
|
112
111
|
let presses = new Map<number, Press>()
|
|
113
112
|
let hover = new Map<number, Target>()
|
|
114
113
|
let lastTap: { time: number; cx: number; cy: number; target: Target | null; count: number } | null = null
|
|
@@ -170,6 +169,8 @@ export function makePointerInput(deps: PointerInputDeps): PointerInput {
|
|
|
170
169
|
distance: hit === null ? null : hit.distance,
|
|
171
170
|
x,
|
|
172
171
|
y,
|
|
172
|
+
timeStamp: e.timeStamp,
|
|
173
|
+
predicted: e.predicted,
|
|
173
174
|
pointerId: e.pointerId,
|
|
174
175
|
pointerType: e.pointerType,
|
|
175
176
|
button: e.button,
|
|
@@ -245,7 +246,7 @@ export function makePointerInput(deps: PointerInputDeps): PointerInput {
|
|
|
245
246
|
if (press.moved || !press.alone) return
|
|
246
247
|
let release = nearest(x, y)
|
|
247
248
|
if (press.target === null ? release !== null : release === null || !sameHit(release, press.target)) return
|
|
248
|
-
let time =
|
|
249
|
+
let time = e.timeStamp
|
|
249
250
|
let repeat = lastTap !== null && time - lastTap.time <= TAP_INTERVAL_MS && sameTarget(lastTap.target, press.target) && Math.hypot(e.clientX - lastTap.cx, e.clientY - lastTap.cy) <= TAP_REPEAT_SLOP
|
|
250
251
|
let count = repeat ? lastTap!.count + 1 : 1
|
|
251
252
|
lastTap = { time, cx: e.clientX, cy: e.clientY, target: press.target, count }
|
package/src/scene.ts
CHANGED
|
@@ -635,7 +635,7 @@ export type ReflectionProbeOptions = {
|
|
|
635
635
|
export type ReflectionProbe = {
|
|
636
636
|
/** The cube map: what `environment={{ cube }}` and `background={{
|
|
637
637
|
* cube }}` take. The prefiltered chain (roughness 0 sharp at level 0,
|
|
638
|
-
* blurred below, the same rule as a baked .
|
|
638
|
+
* blurred below, the same rule as a baked .sol3e), or with `prefilter:
|
|
639
639
|
* false` the sharp faces alone - then `standard` reflects it at its
|
|
640
640
|
* roughness 0 look whatever its roughness. */
|
|
641
641
|
cube: TextureId
|
package/src/splat-data.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// @solidrt/3d/splat - the splat DATA side of the package without the
|
|
2
2
|
// runtime: the capture parsers (.ply, .splat, gunzipped .spz), the
|
|
3
|
-
// covariance bake and the .
|
|
3
|
+
// covariance bake and the .sol3s container, for `sol tool 3d/splat` and any
|
|
4
4
|
// app bake script under bun. The package root imports `flux:*` and so
|
|
5
5
|
// loads only on the runtime; this entry imports nothing that does (the
|
|
6
|
-
// model-data rule; tests/splat-data.test.ts imports it
|
|
7
|
-
// the moment a
|
|
6
|
+
// model-data rule; tests/splat-data.test.ts imports it on the bare flux
|
|
7
|
+
// binary and fails the moment a gui or sol: import creeps into the chain).
|
|
8
8
|
//
|
|
9
9
|
// import { encodeSplat, parseSplat } from "@solidrt/3d/splat"
|
|
10
|
-
// writeFileSync("assets/scan.
|
|
10
|
+
// writeFileSync("assets/scan.sol3s", encodeSplat(parseSplat(bytes)))
|
|
11
11
|
//
|
|
12
|
-
// loadSplat / createSplatMesh on the runtime read what it writes: a .
|
|
12
|
+
// loadSplat / createSplatMesh on the runtime read what it writes: a .sol3s
|
|
13
13
|
// record IS the texel data the splat shader fetches (two rgba32ui texels
|
|
14
14
|
// per splat, SPLAT_ATTRIBUTES as a byte layout) and the record the core
|
|
15
15
|
// sorts by depth, so runtime loading is a header parse plus byte views -
|
|
@@ -113,8 +113,8 @@ const SPZ_COLOR_SCALE = 0.15
|
|
|
113
113
|
const SPZ_SCALE_OFFSET = 10
|
|
114
114
|
const SPZ_SCALE_STEP = 16
|
|
115
115
|
|
|
116
|
-
/** "
|
|
117
|
-
const MAGIC =
|
|
116
|
+
/** "SOLS" read as a little-endian u32. */
|
|
117
|
+
const MAGIC = 0x534c4f53
|
|
118
118
|
// Version 2: the 32-byte two-texel record, importance-sorted, y-up unless
|
|
119
119
|
// the bake kept orientation, plus the optional SH texel block; the header
|
|
120
120
|
// carries count, shDegree, bounds and both block ranges. (Version 1 was
|
|
@@ -164,10 +164,10 @@ let colorByte = (v: number) => Math.round(Math.min(1, Math.max(0, v)) * 255)
|
|
|
164
164
|
* Parse a captured splat cloud - a trainer's .ply, the de-facto .splat
|
|
165
165
|
* interchange, or a gunzipped .spz (Niantic; the container is
|
|
166
166
|
* gzip-compressed, and this entry stays decompression-free, so hand it
|
|
167
|
-
* the inflated bytes - `
|
|
167
|
+
* the inflated bytes - `sol tool 3d/splat` does) - into baked SplatData:
|
|
168
168
|
* covariance precomputed, y-up by default, importance-sorted (size times
|
|
169
169
|
* opacity), bounds measured. Runs anywhere (bun, the runtime); for a
|
|
170
|
-
* capture of real size run it at pack time and ship encodeSplat's .
|
|
170
|
+
* capture of real size run it at pack time and ship encodeSplat's .sol3s.
|
|
171
171
|
*/
|
|
172
172
|
export function parseSplat(bytes: Uint8Array, opts?: ParseSplatOptions): SplatData {
|
|
173
173
|
let format = opts?.format ?? sniffFormat(bytes)
|
|
@@ -185,7 +185,7 @@ function sniffFormat(bytes: Uint8Array): SplatFormat {
|
|
|
185
185
|
if (dv.getUint32(0, true) === SPZ_MAGIC) return "spz"
|
|
186
186
|
}
|
|
187
187
|
if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
|
188
|
-
throw new Error("parseSplat: gzip-compressed input (.spz files are); gunzip the bytes first -
|
|
188
|
+
throw new Error("parseSplat: gzip-compressed input (.spz files are); gunzip the bytes first - sol tool 3d/splat does")
|
|
189
189
|
}
|
|
190
190
|
if (bytes.length > 0 && bytes.length % SPLAT_IN_BYTES === 0) return "splat"
|
|
191
191
|
throw new Error("parseSplat: unrecognized input - not a .ply, not a gunzipped .spz, and not whole 32-byte .splat records")
|
|
@@ -537,8 +537,8 @@ function shBytesOf(count: number, shDegree: number): number {
|
|
|
537
537
|
}
|
|
538
538
|
|
|
539
539
|
/**
|
|
540
|
-
* Serialize a splat cloud into the .
|
|
541
|
-
* "
|
|
540
|
+
* Serialize a splat cloud into the .sol3s container, all little-endian:
|
|
541
|
+
* "SOLS" u32 | version u32 | jsonLength u32 | json (padded to 4) |
|
|
542
542
|
* payload. The JSON header carries count, shDegree, bounds and the byte
|
|
543
543
|
* range of the record block and (above degree 0) the SH block; the
|
|
544
544
|
* payload is both blocks verbatim.
|
|
@@ -574,7 +574,7 @@ export function encodeSplat(data: SplatData): Uint8Array {
|
|
|
574
574
|
}
|
|
575
575
|
|
|
576
576
|
/**
|
|
577
|
-
* Read a .
|
|
577
|
+
* Read a .sol3s container back into SplatData. The records and the SH
|
|
578
578
|
* block are VIEWS onto `bytes` (copied once only when the input is not
|
|
579
579
|
* 4-aligned), so the bytes must outlive the data - which is the point:
|
|
580
580
|
* loading a bake is a header parse, and the views upload as-is.
|
|
@@ -585,7 +585,7 @@ export function decodeSplat(bytes: Uint8Array): SplatData {
|
|
|
585
585
|
let dv = new DataView(bytes.buffer, bytes.byteOffset, 12)
|
|
586
586
|
if (dv.getUint32(0, true) !== MAGIC) throw new Error("decodeSplat: not a splat file (bad magic)")
|
|
587
587
|
let version = dv.getUint32(4, true)
|
|
588
|
-
if (version !== VERSION) throw new Error("decodeSplat: version " + version + ", expected " + VERSION + " - re-bake with `
|
|
588
|
+
if (version !== VERSION) throw new Error("decodeSplat: version " + version + ", expected " + VERSION + " - re-bake with `sol tool 3d/splat`")
|
|
589
589
|
let jsonLength = dv.getUint32(8, true)
|
|
590
590
|
let header: Header = JSON.parse(new TextDecoder().decode(bytes.subarray(12, 12 + jsonLength)))
|
|
591
591
|
let payload = 12 + jsonLength + ((4 - (jsonLength % 4)) % 4)
|
package/src/splat.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The splat RUNTIME side: the stock splat material and createSplatMesh
|
|
2
2
|
// over a baked SplatData (parse and bake live in ./splat-data.ts, the
|
|
3
|
-
// runtime-free `@solidrt/3d/splat` entry; `
|
|
4
|
-
// .
|
|
3
|
+
// runtime-free `@solidrt/3d/splat` entry; `sol tool 3d/splat` writes the
|
|
4
|
+
// .sol3s this loads). A splat cloud here is an ordinary record mesh in the
|
|
5
5
|
// INDEXED form - the records live in an rgba32ui data texture the vertex
|
|
6
6
|
// stage fetches by id, SPLAT_GROUP splats share one drawn instance (a
|
|
7
7
|
// merged-quad geometry; the instancing frontend was the measured wall),
|
|
@@ -393,7 +393,7 @@ export function createSplatMesh(data: SplatData, opts?: SplatMeshOptions): Splat
|
|
|
393
393
|
return mesh
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
-
/** Read a baked .
|
|
396
|
+
/** Read a baked .sol3s splat cloud (`sol tool 3d/splat`): no parsing, the
|
|
397
397
|
* records and the SH block view the file's bytes and upload as-is at
|
|
398
398
|
* createSplatMesh. */
|
|
399
399
|
export async function loadSplat(path: string): Promise<SplatData> {
|
package/tools/environment.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/// <reference path="./node-env.d.ts" />
|
|
3
3
|
|
|
4
|
-
//
|
|
4
|
+
// sol tool 3d/environment: bake a Radiance .hdr panorama into a .sol3e
|
|
5
5
|
// environment file - the six cube faces and their GGX-prefiltered mip
|
|
6
6
|
// chain in linear float, the exact chain loadEnvironment uploads as an
|
|
7
7
|
// explicit rgba16f cube (no runtime conversion, no generated mipmaps, no
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// at runtime every time the sky changes. Put the output under assets/ so
|
|
11
11
|
// it ships with the app.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
13
|
+
// sol tool 3d/environment <in.hdr> [-o <out.sol3e>] [--size <edge>]
|
|
14
14
|
//
|
|
15
15
|
// --size is the cube's face edge: 128 (default) is Unity's probe default
|
|
16
16
|
// and enough for every surface short of a mirror; 256 (Godot's radiance
|
|
@@ -28,7 +28,7 @@ const MAX_SIZE = 1024
|
|
|
28
28
|
|
|
29
29
|
function usage(error?: string): never {
|
|
30
30
|
if (error) console.error(error)
|
|
31
|
-
console.log("Usage:
|
|
31
|
+
console.log("Usage: sol tool 3d/environment <in.hdr> [-o <out.sol3e>] [--size <edge>]")
|
|
32
32
|
process.exit(error ? 1 : 0)
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -54,7 +54,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
54
54
|
else usage("Unexpected argument " + arg)
|
|
55
55
|
}
|
|
56
56
|
if (input === undefined) usage("Missing input file")
|
|
57
|
-
if (output === undefined) output = basename(input, extname(input)) + ".
|
|
57
|
+
if (output === undefined) output = basename(input, extname(input)) + ".sol3e"
|
|
58
58
|
|
|
59
59
|
let started = performance.now()
|
|
60
60
|
let panorama = decodeHdr(new Uint8Array(readFileSync(input)))
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// sol tool 3d/model: bake a glTF into a .sol3m model file - the parse
|
|
2
|
+
// (src/gltf.ts) run once here, the result written in the exact layout
|
|
3
|
+
// loadModel views without any per-vertex work at runtime. Same parser,
|
|
4
|
+
// same subset, same result as loadGltf; this only moves the cost to build
|
|
5
|
+
// time. Put the output under assets/ so it ships with the app.
|
|
6
|
+
//
|
|
7
|
+
// The images are baked too: each PNG or JPEG becomes a KTX2 compressed
|
|
8
|
+
// texture with its mip chain, which the device turns into its own block
|
|
9
|
+
// format at load (a quarter of the GPU memory of the decoded image). One
|
|
10
|
+
// file for every platform, so the tool takes no platform. How each image
|
|
11
|
+
// is compressed, and whether, is the app's: the `textures` group of the
|
|
12
|
+
// `solidrt` key in the package.json of the project the tool runs in, over
|
|
13
|
+
// the defaults (@solidrt/core/textures) - per kind of map (color, normal,
|
|
14
|
+
// data) and per file, an image named by its path from the project root
|
|
15
|
+
// and an embedded one by `<model path>#<its name>`. --compress and
|
|
16
|
+
// --no-compress stand in for the app's own `compress` for one run.
|
|
17
|
+
//
|
|
18
|
+
// A flux tool (the file name says so): the texture encoder is the
|
|
19
|
+
// runtime's, flux:image. The model side comes from the runtime-free
|
|
20
|
+
// `@solidrt/3d/model` entry (src/model-data.ts), the same surface an
|
|
21
|
+
// app's own bake script uses under bun.
|
|
22
|
+
//
|
|
23
|
+
// sol tool 3d/model <in.gltf|in.glb> [-o <out.sol3m>] [--compress|--no-compress]
|
|
24
|
+
|
|
25
|
+
import { file, glob } from "flux:fs"
|
|
26
|
+
import { decodeImage, encodeTexture } from "flux:image"
|
|
27
|
+
import { basename, dirname, extname, join, matchesGlob, relative } from "flux:path"
|
|
28
|
+
import { argv, exit } from "flux:process"
|
|
29
|
+
import { isKtx2, textureSettingFor, textureSettings, unmatchedTextureFiles, unmatchedTextureNames } from "@solidrt/core/textures"
|
|
30
|
+
import type { TextureKind, TextureSetting, TextureSettings, TextureSource } from "@solidrt/core/textures"
|
|
31
|
+
import { encodeModel, gltfExternalUris, layoutStride, modelImageUses, parseGltf } from "../src/model-data.ts"
|
|
32
|
+
import type { ModelData, ModelImage } from "../src/model-data.ts"
|
|
33
|
+
|
|
34
|
+
// The project's package.json, where the texture settings live.
|
|
35
|
+
const PROJECT_CONFIG = "package.json"
|
|
36
|
+
|
|
37
|
+
// How many images are decoded and encoded at once: what the runtime
|
|
38
|
+
// encodes at a time. More would only queue, each with its decoded pixels.
|
|
39
|
+
const IMAGES_IN_FLIGHT = 4
|
|
40
|
+
|
|
41
|
+
// The project the tool runs in: its root is the cwd, which the patterns
|
|
42
|
+
// of the texture settings are written from.
|
|
43
|
+
const PROJECT_ROOT = "."
|
|
44
|
+
|
|
45
|
+
const USAGE = "Usage: sol tool 3d/model <in.gltf|in.glb> [-o <out.sol3m>] [--compress|--no-compress]"
|
|
46
|
+
|
|
47
|
+
function usage(error?: string): never {
|
|
48
|
+
if (error) console.error(error)
|
|
49
|
+
console.log(USAGE)
|
|
50
|
+
exit(error ? 1 : 0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function kib(bytes: number): string {
|
|
54
|
+
return (bytes / 1024).toFixed(0) + " KiB"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function images(count: number): string {
|
|
58
|
+
return count === 1 ? "1 image" : count + " images"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The texture settings of the project the tool runs in; the defaults where
|
|
62
|
+
// it has no package.json. `compress` is the run's own word, when it has
|
|
63
|
+
// one.
|
|
64
|
+
async function projectTextureSettings(compress: boolean | undefined): Promise<TextureSettings> {
|
|
65
|
+
let config = file(PROJECT_CONFIG)
|
|
66
|
+
return textureSettings((await config.exists()) ? await config.json() : undefined, { compress })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// What an image is to the texture settings: its file as the path from the
|
|
70
|
+
// project root, or for an embedded image the model's file and its name.
|
|
71
|
+
function imageSource(image: ModelImage, model: string): TextureSource {
|
|
72
|
+
if (image.uri === undefined) return { file: relative(PROJECT_ROOT, model), name: image.name }
|
|
73
|
+
return { file: relative(PROJECT_ROOT, join(dirname(model), decodeURIComponent(image.uri))) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The entries of the settings that name nothing, which is a misspelled
|
|
77
|
+
// pattern: a file the project does not have, a name the model does not.
|
|
78
|
+
async function unmatchedPatterns(data: ModelData, model: string, settings: TextureSettings): Promise<string[]> {
|
|
79
|
+
let files = await unmatchedTextureFiles(settings, (pattern) => glob(pattern, { cwd: PROJECT_ROOT }))
|
|
80
|
+
let embedded = data.images.filter((image) => image.uri === undefined).map((image) => image.name)
|
|
81
|
+
return [...files, ...unmatchedTextureNames(settings, relative(PROJECT_ROOT, model), embedded, matchesGlob)]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The images of one kind compressed the same way, and the ones kept.
|
|
85
|
+
type Group = { kind: TextureKind; setting: TextureSetting; images: number; from: number; to: number }
|
|
86
|
+
type Baked = { groups: Group[]; kept: { images: number; bytes: number } }
|
|
87
|
+
|
|
88
|
+
// The images as KTX2, in place, each as the settings say for it and in
|
|
89
|
+
// the color space createModel samples it in (modelImageUses, the rule the
|
|
90
|
+
// two share), mipmapped for repeat wrap. IMAGES_IN_FLIGHT at a time, each
|
|
91
|
+
// decoded when its turn comes. An image the settings leave alone is kept,
|
|
92
|
+
// and so is one that is a KTX2 already.
|
|
93
|
+
async function bakeImages(data: ModelData, model: string, settings: TextureSettings): Promise<Baked> {
|
|
94
|
+
let baked: Baked = { groups: [], kept: { images: 0, bytes: 0 } }
|
|
95
|
+
let groupOf = (kind: TextureKind, setting: TextureSetting): Group => {
|
|
96
|
+
let group = baked.groups.find((g) => g.kind === kind && g.setting.codec === setting.codec && g.setting.quality === setting.quality)
|
|
97
|
+
if (group === undefined) {
|
|
98
|
+
group = { kind, setting, images: 0, from: 0, to: 0 }
|
|
99
|
+
baked.groups.push(group)
|
|
100
|
+
}
|
|
101
|
+
return group
|
|
102
|
+
}
|
|
103
|
+
let uses = modelImageUses(data)
|
|
104
|
+
let next = 0
|
|
105
|
+
let lane = async () => {
|
|
106
|
+
while (next < data.images.length) {
|
|
107
|
+
let i = next++
|
|
108
|
+
let image = data.images[i]!
|
|
109
|
+
let source = image.bytes
|
|
110
|
+
let { kind, srgb } = uses[i]!
|
|
111
|
+
let setting = textureSettingFor(settings, kind, imageSource(image, model), matchesGlob)
|
|
112
|
+
if (!setting.compress || isKtx2(source)) {
|
|
113
|
+
baked.kept.images++
|
|
114
|
+
baked.kept.bytes += source.byteLength
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
image.bytes = await encodeTexture(decodeImage(source), {
|
|
118
|
+
codec: setting.codec,
|
|
119
|
+
quality: setting.quality,
|
|
120
|
+
srgb,
|
|
121
|
+
mipmap: true,
|
|
122
|
+
wrap: "repeat",
|
|
123
|
+
})
|
|
124
|
+
let group = groupOf(kind, setting)
|
|
125
|
+
group.images++
|
|
126
|
+
group.from += source.byteLength
|
|
127
|
+
group.to += image.bytes.byteLength
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
await Promise.all(Array.from({ length: IMAGES_IN_FLIGHT }, lane))
|
|
131
|
+
let order: TextureKind[] = ["color", "normal", "data"]
|
|
132
|
+
baked.groups.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind))
|
|
133
|
+
return baked
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let input: string | undefined
|
|
137
|
+
let output: string | undefined
|
|
138
|
+
let compress: boolean | undefined
|
|
139
|
+
for (let i = 0; i < argv.length; i++) {
|
|
140
|
+
let arg = argv[i]!
|
|
141
|
+
if (arg === "--help" || arg === "-h") usage()
|
|
142
|
+
else if (arg === "-o" || arg === "--output") {
|
|
143
|
+
output = argv[++i]
|
|
144
|
+
if (output === undefined) usage("Missing value for " + arg)
|
|
145
|
+
} else if (arg === "--compress") compress = true
|
|
146
|
+
else if (arg === "--no-compress") compress = false
|
|
147
|
+
else if (arg.startsWith("-")) usage("Unknown option " + arg)
|
|
148
|
+
else if (input === undefined) input = arg
|
|
149
|
+
else usage("Unexpected argument " + arg)
|
|
150
|
+
}
|
|
151
|
+
if (input === undefined) usage("Missing input file")
|
|
152
|
+
if (output === undefined) output = basename(input, extname(input)) + ".sol3m"
|
|
153
|
+
|
|
154
|
+
// The parse is synchronous, so the external files it will open are read
|
|
155
|
+
// ahead of it (the same prefetch loadGltf does).
|
|
156
|
+
let dir = dirname(input)
|
|
157
|
+
let bytes = await file(input).bytes()
|
|
158
|
+
let files = new Map<string, Uint8Array>()
|
|
159
|
+
for (let uri of gltfExternalUris(bytes)) {
|
|
160
|
+
if (!files.has(uri)) files.set(uri, await file(join(dir, decodeURIComponent(uri))).bytes())
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let started = performance.now()
|
|
164
|
+
let data = parseGltf(bytes, (uri) => files.get(uri)!)
|
|
165
|
+
let parsed = performance.now() - started
|
|
166
|
+
|
|
167
|
+
// Read and checked before the encode, so a mistake in the settings costs
|
|
168
|
+
// no bake.
|
|
169
|
+
let settings: TextureSettings
|
|
170
|
+
let unmatched: string[]
|
|
171
|
+
try {
|
|
172
|
+
settings = await projectTextureSettings(compress)
|
|
173
|
+
unmatched = await unmatchedPatterns(data, input, settings)
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.error(`The texture settings in ${PROJECT_CONFIG} cannot be used: ${e instanceof Error ? e.message : String(e)}`)
|
|
176
|
+
exit(1)
|
|
177
|
+
}
|
|
178
|
+
if (unmatched.length > 0) {
|
|
179
|
+
console.error(`The texture settings in ${PROJECT_CONFIG} (solidrt.textures.files) name nothing with:`)
|
|
180
|
+
for (let pattern of unmatched) console.error(` ${pattern}`)
|
|
181
|
+
console.error("A pattern is a path from the project root; after a model's path, #name picks an image embedded in it.")
|
|
182
|
+
exit(1)
|
|
183
|
+
}
|
|
184
|
+
started = performance.now()
|
|
185
|
+
let baked = await bakeImages(data, input, settings)
|
|
186
|
+
let bakeMs = performance.now() - started
|
|
187
|
+
|
|
188
|
+
let encoded = encodeModel(data)
|
|
189
|
+
await file(output).write(encoded)
|
|
190
|
+
|
|
191
|
+
let triangles = data.parts.reduce((n, p) => n + p.geometry.indices.length / 3, 0)
|
|
192
|
+
let vertices = data.parts.reduce((n, p) => n + p.geometry.vertices.byteLength / layoutStride(p.geometry.layout), 0)
|
|
193
|
+
let targets = data.parts.reduce((n, p) => n + (p.geometry.morphs?.names.length ?? 0), 0)
|
|
194
|
+
console.log(
|
|
195
|
+
`${output}: ${data.nodes.length} nodes, ${data.parts.length} parts, ${vertices} vertices, ${triangles} triangles, ` +
|
|
196
|
+
(targets > 0 ? `${targets} morph targets, ` : "") +
|
|
197
|
+
`${data.clips.length} clips, ${data.materials.length} materials, ${data.images.length} images, ${kib(encoded.byteLength)} ` +
|
|
198
|
+
`(parsed in ${parsed.toFixed(0)} ms)`,
|
|
199
|
+
)
|
|
200
|
+
if (baked.groups.length > 0) {
|
|
201
|
+
console.log(`Images as KTX2 (encoded in ${(bakeMs / 1000).toFixed(1)} s):`)
|
|
202
|
+
for (let group of baked.groups) {
|
|
203
|
+
console.log(` ${group.kind}: ${images(group.images)}, ${group.setting.codec} at quality ${group.setting.quality}, ${kib(group.from)} to ${kib(group.to)}`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (baked.kept.images > 0) console.log(`Kept as they are: ${images(baked.kept.images)}, ${kib(baked.kept.bytes)}`)
|
package/tools/node-env.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The node surface the tools use, declared here so `
|
|
1
|
+
// The node surface the tools use, declared here so `sol check
|
|
2
2
|
// packages/3d/tools/<name>.ts` typechecks them on the package's flux-typed
|
|
3
3
|
// program (the nearest tsconfig is the repo root's). The tools run under
|
|
4
4
|
// bun, but bun's own types cannot join this program: their globals
|
package/tools/splat.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/// <reference path="./node-env.d.ts" />
|
|
3
3
|
|
|
4
|
-
//
|
|
4
|
+
// sol tool 3d/splat: bake a captured splat cloud into a .sol3s file - the
|
|
5
5
|
// parse, covariance bake and SH packing (src/splat-data.ts) run once here
|
|
6
6
|
// under bun, the result written in the exact texel layout createSplatMesh
|
|
7
7
|
// uploads and the engine sorts, without any per-splat work at runtime.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Everything here comes from the runtime-free `@solidrt/3d/splat` entry,
|
|
10
10
|
// the same surface an app's own bake script uses.
|
|
11
11
|
//
|
|
12
|
-
//
|
|
12
|
+
// sol tool 3d/splat <in.ply|in.splat|in.spz> [-o <out.sol3s>] [--sh <0..3>] [--keep-orientation]
|
|
13
13
|
//
|
|
14
14
|
// Inputs: a trainer's .ply, the de-facto .splat interchange, or Niantic's
|
|
15
15
|
// .spz (gunzipped here; version 2 or 3). A y-down capture (.ply, .splat)
|
|
@@ -25,7 +25,7 @@ import { encodeSplat, parseSplat } from "../src/splat-data.ts"
|
|
|
25
25
|
|
|
26
26
|
function usage(error?: string): never {
|
|
27
27
|
if (error) console.error(error)
|
|
28
|
-
console.log("Usage:
|
|
28
|
+
console.log("Usage: sol tool 3d/splat <in.ply|in.splat|in.spz> [-o <out.sol3s>] [--sh <0..3>] [--keep-orientation]")
|
|
29
29
|
process.exit(error ? 1 : 0)
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -51,7 +51,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
51
51
|
else usage("Unexpected argument " + arg)
|
|
52
52
|
}
|
|
53
53
|
if (input === undefined) usage("Missing input file")
|
|
54
|
-
if (output === undefined) output = basename(input, extname(input)) + ".
|
|
54
|
+
if (output === undefined) output = basename(input, extname(input)) + ".sol3s"
|
|
55
55
|
|
|
56
56
|
let bytes = new Uint8Array(readFileSync(input))
|
|
57
57
|
// .spz is gzip-compressed; the parser takes the inflated bytes.
|
package/tools/model.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/// <reference path="./node-env.d.ts" />
|
|
3
|
-
|
|
4
|
-
// srt tool 3d/model: bake a glTF into a .srtm model file - the parse
|
|
5
|
-
// (src/gltf.ts) run once here under bun, the result written in the exact
|
|
6
|
-
// layout loadModel views without any per-vertex work at runtime. Same
|
|
7
|
-
// parser, same subset, same result as loadGltf; this only moves the cost
|
|
8
|
-
// to build time. Put the output under assets/ so it ships with the app.
|
|
9
|
-
// Everything here comes from the runtime-free `@solidrt/3d/model` entry
|
|
10
|
-
// (src/model-data.ts), the same surface an app's own bake script uses.
|
|
11
|
-
//
|
|
12
|
-
// srt tool 3d/model <in.gltf|in.glb> [-o <out.srtm>]
|
|
13
|
-
|
|
14
|
-
import { readFileSync, writeFileSync } from "node:fs"
|
|
15
|
-
import { basename, dirname, extname, join } from "node:path"
|
|
16
|
-
import { encodeModel, layoutStride, parseGltf } from "../src/model-data.ts"
|
|
17
|
-
|
|
18
|
-
function usage(error?: string): never {
|
|
19
|
-
if (error) console.error(error)
|
|
20
|
-
console.log("Usage: srt tool 3d/model <in.gltf|in.glb> [-o <out.srtm>]")
|
|
21
|
-
process.exit(error ? 1 : 0)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
let input: string | undefined
|
|
25
|
-
let output: string | undefined
|
|
26
|
-
let args = process.argv.slice(2)
|
|
27
|
-
for (let i = 0; i < args.length; i++) {
|
|
28
|
-
let arg = args[i]!
|
|
29
|
-
if (arg === "--help" || arg === "-h") usage()
|
|
30
|
-
else if (arg === "-o" || arg === "--output") {
|
|
31
|
-
output = args[++i]
|
|
32
|
-
if (output === undefined) usage("Missing value for " + arg)
|
|
33
|
-
} else if (arg.startsWith("-")) usage("Unknown option " + arg)
|
|
34
|
-
else if (input === undefined) input = arg
|
|
35
|
-
else usage("Unexpected argument " + arg)
|
|
36
|
-
}
|
|
37
|
-
if (input === undefined) usage("Missing input file")
|
|
38
|
-
if (output === undefined) output = basename(input, extname(input)) + ".srtm"
|
|
39
|
-
|
|
40
|
-
let dir = dirname(input)
|
|
41
|
-
let bytes = new Uint8Array(readFileSync(input))
|
|
42
|
-
let started = performance.now()
|
|
43
|
-
let data = parseGltf(bytes, (uri) => new Uint8Array(readFileSync(join(dir, decodeURIComponent(uri)))))
|
|
44
|
-
let parsed = performance.now() - started
|
|
45
|
-
let encoded = encodeModel(data)
|
|
46
|
-
writeFileSync(output, encoded)
|
|
47
|
-
|
|
48
|
-
let triangles = data.parts.reduce((n, p) => n + p.geometry.indices.length / 3, 0)
|
|
49
|
-
let vertices = data.parts.reduce((n, p) => n + p.geometry.vertices.byteLength / layoutStride(p.geometry.layout), 0)
|
|
50
|
-
let targets = data.parts.reduce((n, p) => n + (p.geometry.morphs?.names.length ?? 0), 0)
|
|
51
|
-
console.log(
|
|
52
|
-
`${output}: ${data.nodes.length} nodes, ${data.parts.length} parts, ${vertices} vertices, ${triangles} triangles, ` +
|
|
53
|
-
(targets > 0 ? `${targets} morph targets, ` : "") +
|
|
54
|
-
`${data.clips.length} clips, ${data.materials.length} materials, ${data.images.length} images, ${(encoded.byteLength / 1024).toFixed(0)} KiB ` +
|
|
55
|
-
`(parsed in ${parsed.toFixed(0)} ms)`,
|
|
56
|
-
)
|