@solidrt/3d 0.0.64 → 0.0.65
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 +140 -31
- package/examples/README.md +3 -3
- package/examples/fog.tsx +18 -5
- package/examples/model-load.tsx +1 -1
- package/package.json +6 -3
- package/src/geometry.ts +1 -1
- package/src/glsl.ts +1 -1
- package/src/gltf.ts +98 -15
- package/src/index.ts +3 -3
- package/src/model-data.ts +9 -7
- package/src/model-file.ts +16 -7
- package/src/model.ts +40 -20
- package/src/node.ts +5 -0
- package/src/scene-pointer.ts +6 -5
- package/src/splat-data.ts +2 -2
- package/tools/model.flux.ts +206 -0
- package/tools/model.ts +0 -56
package/AGENTS.md
CHANGED
|
@@ -550,25 +550,25 @@ are Solid-free and
|
|
|
550
550
|
GPU-free BY DESIGN so they can be checked headless and run under bun:
|
|
551
551
|
`src/model-data.ts` re-exports exactly this set as the published
|
|
552
552
|
`@solidrt/3d/model` entry (the bake tool and app bake scripts), and
|
|
553
|
-
`tests/model-data.test.ts` imports it under `
|
|
554
|
-
import creeping into any of them fails there
|
|
553
|
+
`tests/model-data.test.ts` imports it under `srt test`, on the bare flux
|
|
554
|
+
binary, so a gui or `srt:` import creeping into any of them fails there
|
|
555
|
+
(a headless `flux:` import would not); keep them that way.
|
|
555
556
|
`src/splat-data.ts` is its own published entry the same way
|
|
556
557
|
(`@solidrt/3d/splat`, guarded by `tests/splat-data.test.ts`).
|
|
557
|
-
The
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
collision claims - two copies of this contract have drifted before.
|
|
558
|
+
The tests under `tests/` run with `bun run srt test packages/3d` from the
|
|
559
|
+
repo root, or one file, or `--filter <text>` for the tests whose name
|
|
560
|
+
contains it: the pure modules on the bare flux binary, and the files
|
|
561
|
+
that need a GPU (`.test.tsx`, importing `@solidrt/test`) on the
|
|
562
|
+
headless dev client. Run them after a change. `raycast.test.tsx` and
|
|
563
|
+
`collision.test.tsx` assert the documented picking contract (triangle
|
|
564
|
+
accuracy, the box tier, pick/raycast parity, layer masks, the
|
|
565
|
+
`{ meshes }` filter) and collision contract (exact sweep times, the
|
|
566
|
+
surface rule, the slide filter, layers and meshes on overlap/sweep,
|
|
567
|
+
moveAndSlide's landing) against a real scene; run them whenever a doc
|
|
568
|
+
edit touches picking or collision claims - two copies of this contract
|
|
569
|
+
have drifted before. `fog.test.tsx` reads the fog example's pixels
|
|
570
|
+
through its debug commands, and `camera-components.test.tsx` drives
|
|
571
|
+
`<OrbitCamera>` and `<FirstPersonCamera>` with a real drag and key.
|
|
572
572
|
|
|
573
573
|
## Components
|
|
574
574
|
|
|
@@ -810,7 +810,7 @@ driving - the predicate every camera control shares) are reactive
|
|
|
810
810
|
(HUD-safe); the pose is plain state via `pose()`/`set()` (also the
|
|
811
811
|
debug-command shape). It drives position and target only; fov/near/far
|
|
812
812
|
stay on scene.setCamera (or the Scene `camera` prop). The auto-orbit
|
|
813
|
-
pauses while a gesture is open.
|
|
813
|
+
pauses while a gesture is open. tests/orbit.test.ts pins every stage
|
|
814
814
|
headless.
|
|
815
815
|
|
|
816
816
|
In a component tree, skip the wiring: `<OrbitCamera input={input}
|
|
@@ -868,7 +868,7 @@ the shot's recording target, so an `<OrbitCamera>` or
|
|
|
868
868
|
it, the container runs the blend on frames while one is in flight. A
|
|
869
869
|
shot's picks and raycasts (`anchor`, `occlusion`) go through the
|
|
870
870
|
owner's actual camera, which is the shot's own only while it is live
|
|
871
|
-
and at rest. Pinned in
|
|
871
|
+
and at rest. Pinned in tests/orbit.test.ts; probes/shots-3d-probe.tsx
|
|
872
872
|
is the live guard.
|
|
873
873
|
|
|
874
874
|
### First-person camera
|
|
@@ -931,8 +931,8 @@ swap with it), `moveSpeed`/`lookSpeed` follow their props, and a pitch
|
|
|
931
931
|
clamp change re-clamps at once. Like the orbit control it drives
|
|
932
932
|
position and yaw/pitch only: fov/near/far stay on scene.setCamera (or
|
|
933
933
|
the Scene `camera` prop), so a walker in a scene bigger than the default
|
|
934
|
-
`far` of 100 sets one there.
|
|
935
|
-
|
|
934
|
+
`far` of 100 sets one there. tests/orbit.test.ts and
|
|
935
|
+
tests/first-person.test.ts pin both controls headless (they import
|
|
936
936
|
`@solidrt/core/input` only).
|
|
937
937
|
|
|
938
938
|
### Overlay projection
|
|
@@ -1084,7 +1084,9 @@ repeats within 300 ms and 20 px on the same target (a double tap is
|
|
|
1084
1084
|
tap. Wheel walks like a move with `deltaX/deltaY` (a node stopping it
|
|
1085
1085
|
keeps the zoom out). Every event carries `native`, the leaf's element
|
|
1086
1086
|
event, for core's recognizers, and the element fields (pointerId,
|
|
1087
|
-
pointerType, button, modifiers
|
|
1087
|
+
pointerType, button, modifiers, `timeStamp`, and `predicted`: a move to
|
|
1088
|
+
draw with, not to measure with - core's AGENTS.md has both) plus `x`/`y`
|
|
1089
|
+
in scene pixels
|
|
1088
1090
|
(screenRay's input - a drag plane is one intersection away). Root
|
|
1089
1091
|
listeners all run, in registration order (the root is the last stop,
|
|
1090
1092
|
nothing is left to claim). Wiring: the built-in `<Scene>` leaf carries
|
|
@@ -1098,7 +1100,7 @@ natural size, under any ancestor transforms or design-size fits (the hit
|
|
|
1098
1100
|
test undoes them; localX/localY arrive in the leaf's layout frame). A
|
|
1099
1101
|
leaf laid out at a different size (the supersampling pattern) uses
|
|
1100
1102
|
`scene.handlersFor(() => ({ width, height }))` with its layout size.
|
|
1101
|
-
|
|
1103
|
+
tests/dispatch.test.ts pins the walk, claiming, capture, hover, wheel
|
|
1102
1104
|
and tap rules headless; examples/pick.tsx is the live guard.
|
|
1103
1105
|
|
|
1104
1106
|
### Geometry generators
|
|
@@ -1662,7 +1664,7 @@ specular, `envIrradiance` - the fully rough sample along the normal, as
|
|
|
1662
1664
|
Three's getIBLIrradiance and Godot read it - added to the hemisphere
|
|
1663
1665
|
for the diffuse) and every `phong({ reflectivity })` material mirrors,
|
|
1664
1666
|
typically the skybox's own cube turned with it. The cube to use is a
|
|
1665
|
-
BAKED one: `
|
|
1667
|
+
BAKED one: `bun run srt tool 3d/environment sky.hdr -o assets/sky.srte`
|
|
1666
1668
|
turns an equirectangular Radiance .hdr (Poly Haven's are CC0) into the
|
|
1667
1669
|
six faces plus the GGX-prefiltered mip chain in linear float, and
|
|
1668
1670
|
`await loadEnvironment("assets/sky.srte")` uploads it as an explicit
|
|
@@ -2225,7 +2227,9 @@ index into `images`, `doubleSided`, `transparent` = alphaMode BLEND,
|
|
|
2225
2227
|
`alphaMode` as written and `alphaCutoff`, spec default 0.5, the
|
|
2226
2228
|
normal and emissive slots, `metalness`/`roughness` factors and the
|
|
2227
2229
|
packed `metalnessRoughnessMap` - standard's inputs), `images`
|
|
2228
|
-
(the encoded PNG
|
|
2230
|
+
(each `{ bytes, name, uri? }`: the encoded PNG, JPEG or KTX2 file,
|
|
2231
|
+
undecoded; its name in the document, else the uri of its file, else
|
|
2232
|
+
"image<i>"; the `uri` when it is a file of its own) and `bounds` (world-space rest
|
|
2229
2233
|
pose: a part's box through its node, a skinned part's per-joint boxes
|
|
2230
2234
|
through the joints' rest transforms - where the skin places it, armature
|
|
2231
2235
|
scale included; conservative under rotation). External
|
|
@@ -2273,7 +2277,8 @@ its true roots; a hierarchy reaching a node twice (a cycle) throws.
|
|
|
2273
2277
|
#### createModel
|
|
2274
2278
|
|
|
2275
2279
|
`createModel(data, { material?, label?, autoFree? })` - uploads the images (repeat
|
|
2276
|
-
wrap, mipmapped, 4x anisotropic
|
|
2280
|
+
wrap, mipmapped, 4x anisotropic; each texture is labeled `<label>-<image name>`, so the
|
|
2281
|
+
`/gpu` inventory names the image), makes one material per glTF material (default `standard`
|
|
2277
2282
|
with the file's color, maps, normal scale, metalness/roughness and
|
|
2278
2283
|
packed map, emissive and transparency - the glTF material model, so a
|
|
2279
2284
|
scene showing a model wants an `environment` (a glTF metal in a scene
|
|
@@ -2321,14 +2326,118 @@ and one loaded in a component with its unmount. A load that settles
|
|
|
2321
2326
|
after its scope is gone (unmounted mid-load) is freed on arrival and
|
|
2322
2327
|
resolves already disposed - nothing owns it any more. `loadModel` reads the baked `.srtm` written by `srt tool
|
|
2323
2328
|
3d/model <in.gltf|glb> -o assets/<name>.srtm`: the same parse run once
|
|
2324
|
-
|
|
2325
|
-
bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
|
|
2329
|
+
at build time, stored in the GPU layout, so loading is views onto the
|
|
2330
|
+
file's bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
|
|
2326
2331
|
on a release client: `parseGltf` 124 ms on flux (22 ms under bun) against
|
|
2327
2332
|
40 ms for the whole baked load - the runtime parse is fine for small
|
|
2328
2333
|
models and a binary import (`import bytes from "./x.glb" with { type:
|
|
2329
2334
|
"binary" }` then `createModel(parseGltf(bytes))`, see
|
|
2330
2335
|
`examples/model.tsx`); bake anything big.
|
|
2331
2336
|
|
|
2337
|
+
#### Compressed textures
|
|
2338
|
+
|
|
2339
|
+
`srt tool 3d/model <in> -o assets/<name>.srtm` bakes the images
|
|
2340
|
+
too: each becomes a KTX2 compressed texture with its mip chain. The file is the
|
|
2341
|
+
same for every platform; at load each device turns the images into the
|
|
2342
|
+
block format its GPU samples (BC7 on desktops, ETC2 elsewhere), which
|
|
2343
|
+
holds them in a quarter of the memory of the decoded image. Measured on
|
|
2344
|
+
the Khronos Sponza sample (69 images): 363 MB of textures as PNG/JPEG,
|
|
2345
|
+
91 MB compressed. The encode is slow (a minute for that
|
|
2346
|
+
model) and runs once, at bake time. Block compression is lossy: judge
|
|
2347
|
+
the result on your own textures. What it saves is GPU memory; the file
|
|
2348
|
+
shrinks for color maps and can grow for data maps.
|
|
2349
|
+
|
|
2350
|
+
How the images are compressed, and whether, is the app's to say, in the
|
|
2351
|
+
`textures` group of the `solidrt` key in its package.json (the project
|
|
2352
|
+
the tool runs in, whose root is where the tool is run). These are the
|
|
2353
|
+
defaults; name only what differs:
|
|
2354
|
+
|
|
2355
|
+
```json
|
|
2356
|
+
"solidrt": {
|
|
2357
|
+
"textures": {
|
|
2358
|
+
"compress": true,
|
|
2359
|
+
"color": { "codec": "etc1s", "quality": 0.75 },
|
|
2360
|
+
"normal": { "codec": "uastc", "quality": 0.9 },
|
|
2361
|
+
"data": { "codec": "uastc", "quality": 0.9 },
|
|
2362
|
+
"files": []
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
```
|
|
2366
|
+
|
|
2367
|
+
Three levels, the narrower one winning: the app (`compress`), a kind
|
|
2368
|
+
of map (`compress`, `codec`, `quality`), and single files:
|
|
2369
|
+
|
|
2370
|
+
```json
|
|
2371
|
+
"files": [
|
|
2372
|
+
{ "match": "assets/sponza/textures/lion_*.png", "codec": "uastc", "quality": 0.95 },
|
|
2373
|
+
{ "match": ["assets/ui/**", "assets/hero.glb#lut*"], "compress": false }
|
|
2374
|
+
]
|
|
2375
|
+
```
|
|
2376
|
+
|
|
2377
|
+
- `match` is a glob pattern, or a list of them, against the image's
|
|
2378
|
+
path FROM THE PROJECT ROOT, written with "/": `*` inside a segment,
|
|
2379
|
+
`**` across segments, `?`, `[a-z]`, `[!a-z]` (`matchesGlob` of
|
|
2380
|
+
flux:path; no `{a,b}`, list the patterns). Not the uri the glTF
|
|
2381
|
+
writes: two models with a `textures/base.png` each are two files.
|
|
2382
|
+
- An image embedded in the model (a .glb) has no file of its own: the
|
|
2383
|
+
model's path names every image in it, and `#name` after it picks
|
|
2384
|
+
the ones of that name (the glTF image's `name`, "image<i>" without
|
|
2385
|
+
one).
|
|
2386
|
+
- An entry sets `compress`, `codec`, `quality` or several, over what
|
|
2387
|
+
the image's kind says. Where several entries name one image, a
|
|
2388
|
+
later entry wins, field by field.
|
|
2389
|
+
- `"compress": false` keeps the image as its PNG or JPEG. That is for
|
|
2390
|
+
what must come back exact (a lookup table, a distance field) and
|
|
2391
|
+
for sharp art and text, which block compression damages.
|
|
2392
|
+
- A pattern that names nothing FAILS the bake, before anything is
|
|
2393
|
+
encoded: no file of the project matches it, or the baked model has
|
|
2394
|
+
no embedded image of that name. A misspelled pattern would
|
|
2395
|
+
otherwise do nothing and say nothing.
|
|
2396
|
+
|
|
2397
|
+
`--compress` and `--no-compress` stand in for the app's `compress` for
|
|
2398
|
+
one run (a quick bake while the model is still changing); what a kind
|
|
2399
|
+
or an entry says about itself still holds.
|
|
2400
|
+
|
|
2401
|
+
The kinds are core's and say what the texels are (`@solidrt/core/textures`).
|
|
2402
|
+
This package maps a material's slots onto them: base color and emissive
|
|
2403
|
+
maps are `color`, normal maps are `normal`, metallic-roughness maps
|
|
2404
|
+
(occlusion rides in the same image) are `data`.
|
|
2405
|
+
An image several slots share takes the most demanding kind, normal over
|
|
2406
|
+
color over data, and stays sRGB when one of them is a color slot;
|
|
2407
|
+
`modelImageUses(data)` returns that per image, the rule the loader and
|
|
2408
|
+
the bake both follow, for a bake script of your own.
|
|
2409
|
+
`codec` and `quality` are `encodeTexture`'s: "etc1s" is small (a
|
|
2410
|
+
quarter of a JPEG), "uastc" is accurate and large (often larger than
|
|
2411
|
+
the JPEG it replaces), `quality` is 0..1. A normal map steers the
|
|
2412
|
+
lighting and shows its errors, and a metallic-roughness map packs
|
|
2413
|
+
unrelated values into its channels, which the small codec handles
|
|
2414
|
+
badly, so both take the accurate one (what the Khronos KTX guide
|
|
2415
|
+
recommends). Set `data` to "etc1s" when the download matters more than
|
|
2416
|
+
roughness detail: on the Khronos Sponza sample that is 9 MB less for
|
|
2417
|
+
about 2 dB in the views that glossy surfaces fill. An unknown key, codec or field fails the bake naming the key,
|
|
2418
|
+
before anything is encoded, and the bake prints how many images it
|
|
2419
|
+
compressed with which codec and quality, and how many it kept. The color space (sRGB for `color`), the mip chain and the
|
|
2420
|
+
wrap are not settings: they follow how `createModel` samples. The
|
|
2421
|
+
settings are the app's, not this package's: an app's own bake script,
|
|
2422
|
+
or another extension's, reads the same group with
|
|
2423
|
+
`textureSettings(packageJson)` from `@solidrt/core/textures` and asks
|
|
2424
|
+
`textureSettingFor(settings, kind, { file, name }, matchesGlob)` per
|
|
2425
|
+
texture (core's AGENTS.md has the rest).
|
|
2426
|
+
|
|
2427
|
+
`loadModel` and `loadGltf` handle such a model as they are, and
|
|
2428
|
+
`loadGltf` reads a glTF that carries KTX2 images itself
|
|
2429
|
+
(`KHR_texture_basisu`). Only the primitives show the step: transcoding
|
|
2430
|
+
is asynchronous and `createModel` is not, so bytes obtained another way
|
|
2431
|
+
go through `transcodeModelImages` between the two:
|
|
2432
|
+
|
|
2433
|
+
```ts
|
|
2434
|
+
let data = await transcodeModelImages(decodeModel(bytes))
|
|
2435
|
+
let model = createModel(data)
|
|
2436
|
+
```
|
|
2437
|
+
|
|
2438
|
+
`createModel` on data whose KTX2 images were not transcoded throws and
|
|
2439
|
+
names the function. Data without KTX2 images needs no such step.
|
|
2440
|
+
|
|
2332
2441
|
#### Baking your own geometry
|
|
2333
2442
|
|
|
2334
2443
|
Geometry an app GENERATES (a terrain, a city block, a hundred thousand
|
|
@@ -2587,9 +2696,9 @@ mesh (JS-written records, no nodes) cannot morph: rejected at add().
|
|
|
2587
2696
|
Not in the subset, dropped: tangents and further UV sets; samplers are
|
|
2588
2697
|
ignored (every texture repeats); additive blending draws as base color.
|
|
2589
2698
|
The follow-ups are filed in okf/backlog/3d-model-loader.md. The `.srtm`
|
|
2590
|
-
container is VERSION
|
|
2591
|
-
skins, clips, packed morph targets
|
|
2592
|
-
with `srt tool 3d/model`.
|
|
2699
|
+
container is VERSION 11 (node table in the header, node-local vertices,
|
|
2700
|
+
skins, clips, packed morph targets, app data, images under their
|
|
2701
|
+
names); older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
2593
2702
|
|
|
2594
2703
|
## Splats
|
|
2595
2704
|
|
package/examples/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @solidrt/3d examples
|
|
2
2
|
|
|
3
|
-
One concept per file; run with `
|
|
3
|
+
One concept per file; run with `bun run srt run <file>` from an app that
|
|
4
4
|
depends on `@solidrt/3d` (or in-repo from the package directory).
|
|
5
5
|
|
|
6
6
|
- `scene-basic.tsx` - the whole v1 surface: a `<Scene>` composited as a
|
|
@@ -58,7 +58,7 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
|
|
|
58
58
|
sky as a faint face-on gloss. Drag to look around.
|
|
59
59
|
- `environment.tsx` - a baked HDR environment lighting the scene ALONE
|
|
60
60
|
(no lights, no hemisphere): `loadEnvironment` reads the .srte that
|
|
61
|
-
`
|
|
61
|
+
`bun run srt tool 3d/environment <panorama>.hdr -o assets/environment.srte`
|
|
62
62
|
bakes from any equirectangular .hdr (Poly Haven's are CC0; the asset
|
|
63
63
|
is not committed, bake one first), used as background and environment
|
|
64
64
|
with ACES tone mapping. A metal row and a red dielectric row, roughness
|
|
@@ -200,4 +200,4 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
|
|
|
200
200
|
stays above the boundary - the two rules that avoid
|
|
201
201
|
PENDING_ASYNC_UNTRACKED_READ and the suspend-retry element leak. Needs
|
|
202
202
|
the asset baked into the running app:
|
|
203
|
-
`
|
|
203
|
+
`bun run srt tool 3d/model examples/model.glb -o assets/model.srtm`.
|
package/examples/fog.tsx
CHANGED
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
// command sets the mode and its knobs (`{ mode: "linear" | "exp2" |
|
|
17
17
|
// "height" | "off", near, far, density, height, falloff }`) and returns
|
|
18
18
|
// the state; `pan` parks the camera (`{ t: seconds }`), so a capture
|
|
19
|
-
// repeats
|
|
19
|
+
// repeats; `suns` is the two suns' world positions and `project`
|
|
20
|
+
// (`{ point }`) is where a world point is on screen, for a pixel probe.
|
|
20
21
|
import { createSignal, flush, onFrame, pct, render } from "@solidrt/core"
|
|
21
22
|
import { registerDebug } from "srt:dev"
|
|
22
23
|
import { cone, cylinder, DirectionalLight, HemisphereLight, phong, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit } from "@solidrt/3d"
|
|
23
|
-
import type { FogOptions, Vec3 } from "@solidrt/3d"
|
|
24
|
+
import type { FogOptions, SceneHandle, Vec3 } from "@solidrt/3d"
|
|
24
25
|
|
|
25
26
|
const FAR = 400
|
|
26
27
|
// The sky, shared by the clear and the fog so the horizon has no band.
|
|
@@ -36,6 +37,10 @@ const LINEAR_FAR = FAR
|
|
|
36
37
|
const DENSITY = 0.006
|
|
37
38
|
const HEIGHT = 10
|
|
38
39
|
const HEIGHT_FALLOFF = 0.12
|
|
40
|
+
// The two suns, side by side on the far ridge: the left one opts out of
|
|
41
|
+
// the fog, the right one fogs like everything else.
|
|
42
|
+
const SUN_LIT: Vec3 = [-30, 100, -220]
|
|
43
|
+
const SUN_FOGGED: Vec3 = [30, 100, -220]
|
|
39
44
|
|
|
40
45
|
type Mode = "linear" | "exp2" | "height" | "off"
|
|
41
46
|
const MODES: Mode[] = ["linear", "exp2", "height", "off"]
|
|
@@ -48,6 +53,7 @@ let [height, setHeight] = createSignal(HEIGHT)
|
|
|
48
53
|
let [falloff, setFalloff] = createSignal(HEIGHT_FALLOFF)
|
|
49
54
|
let [time, setTime] = createSignal(0)
|
|
50
55
|
let parked: number | null = null
|
|
56
|
+
let scene: SceneHandle | null = null
|
|
51
57
|
|
|
52
58
|
let num = (v: unknown, set: (n: number) => void) => {
|
|
53
59
|
if (typeof v === "number") set(v)
|
|
@@ -73,6 +79,13 @@ registerDebug("pan", (args?: Record<string, unknown>) => {
|
|
|
73
79
|
flush()
|
|
74
80
|
return { t: time(), parked: parked !== null }
|
|
75
81
|
})
|
|
82
|
+
registerDebug("suns", () => ({ lit: SUN_LIT, fogged: SUN_FOGGED }))
|
|
83
|
+
registerDebug("project", (args?: Record<string, unknown>) => {
|
|
84
|
+
if (scene === null) throw new Error("project: the scene is not mounted")
|
|
85
|
+
let point = args?.point
|
|
86
|
+
if (!Array.isArray(point) || point.length !== 3 || !point.every(v => typeof v === "number")) throw new Error("project: pass { point: [x, y, z] }")
|
|
87
|
+
return scene.project(point as Vec3)
|
|
88
|
+
})
|
|
76
89
|
|
|
77
90
|
function fog(): FogOptions | undefined {
|
|
78
91
|
switch (mode()) {
|
|
@@ -148,7 +161,7 @@ function App() {
|
|
|
148
161
|
return (
|
|
149
162
|
<window>
|
|
150
163
|
<view width={pct(100)} height={pct(100)} onPointerDown={() => setMode(m => MODES[(MODES.indexOf(m) + 1) % MODES.length] ?? "linear")}>
|
|
151
|
-
<Scene clearColor={[SKY[0], SKY[1], SKY[2], 1]} fog={fog()} samples={4} label="fog">
|
|
164
|
+
<Scene ref={s => (scene = s)} clearColor={[SKY[0], SKY[1], SKY[2], 1]} fog={fog()} samples={4} label="fog">
|
|
152
165
|
<PerspectiveCamera fov={55} near={0.5} far={FAR} position={EYE} lookAt={lookAt()} />
|
|
153
166
|
<HemisphereLight sky={[0.55, 0.62, 0.75]} ground={[0.28, 0.24, 0.2]} />
|
|
154
167
|
<DirectionalLight color={[0.9, 0.85, 0.75]} rotation={[-0.9, 0.6, 0]} />
|
|
@@ -162,8 +175,8 @@ function App() {
|
|
|
162
175
|
<Mesh geometry={crown} material={needles} position={[p.position[0], p.position[1] + p.size * 0.3 + p.size * 0.5, p.position[2]]} scale={[p.size * 0.9, p.size, p.size * 0.9]} />
|
|
163
176
|
</>
|
|
164
177
|
))}
|
|
165
|
-
<Mesh geometry={sun} material={sunLit} position={
|
|
166
|
-
<Mesh geometry={sun} material={sunFogged} position={
|
|
178
|
+
<Mesh geometry={sun} material={sunLit} position={SUN_LIT} />
|
|
179
|
+
<Mesh geometry={sun} material={sunFogged} position={SUN_FOGGED} />
|
|
167
180
|
</Scene>
|
|
168
181
|
</view>
|
|
169
182
|
</window>
|
package/examples/model-load.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// bundle - the shape every app with real content ends up needing. Bake
|
|
4
4
|
// the asset once and drop it in your app's assets tree:
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// bun run srt tool 3d/model examples/model.glb -o assets/model.srtm
|
|
7
7
|
//
|
|
8
8
|
// (loadGltf("assets/model.glb") has the exact same async shape; the bake
|
|
9
9
|
// just removes the runtime parse.)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/3d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.65",
|
|
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.65"
|
|
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.65"
|
|
25
28
|
}
|
|
26
29
|
}
|
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
|
|
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/model-data.ts
CHANGED
|
@@ -2,19 +2,21 @@
|
|
|
2
2
|
// runtime: the glTF parser, the .srtm 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
10
|
// writeFileSync("assets/terrain.srtm", 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 srt: 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,7 +1,9 @@
|
|
|
1
1
|
// The baked model container (.srtm): 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
|
// "SRTM" u32 | version u32 | jsonLength u32 | json (padded to 4) | payload
|
|
@@ -16,7 +18,7 @@
|
|
|
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"
|
|
@@ -54,7 +56,10 @@ const MAGIC = 0x4d545253
|
|
|
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
58
|
// with `srt tool 3d/model`.
|
|
57
|
-
|
|
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[]
|
|
@@ -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) => ({
|
|
@@ -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
|
@@ -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 `srt 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:
|
|
@@ -434,7 +452,8 @@ 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
459
|
/** Read a baked .srtm model (`srt tool 3d/model`) and build it: no parsing,
|
|
@@ -442,5 +461,6 @@ export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model
|
|
|
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
|
}
|
package/src/node.ts
CHANGED
|
@@ -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
|
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/splat-data.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// covariance bake and the .srts container, for `srt 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 srt: import creeps into the chain).
|
|
8
8
|
//
|
|
9
9
|
// import { encodeSplat, parseSplat } from "@solidrt/3d/splat"
|
|
10
10
|
// writeFileSync("assets/scan.srts", encodeSplat(parseSplat(bytes)))
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// srt tool 3d/model: bake a glTF into a .srtm 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
|
+
// srt tool 3d/model <in.gltf|in.glb> [-o <out.srtm>] [--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: srt tool 3d/model <in.gltf|in.glb> [-o <out.srtm>] [--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)) + ".srtm"
|
|
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/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
|
-
)
|