@bitmagic/cli 0.1.73-dev.0 → 0.1.74-dev.0
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/README.md +31 -0
- package/assets/blender-level/README.md +4 -0
- package/assets/character-rigging/SKILL.md +5 -2
- package/assets/character-skin-authoring/SKILL.md +1 -1
- package/assets/optimizing-assets/SKILL.md +222 -0
- package/assets/optimizing-assets/references/recipes.md +171 -0
- package/assets/optimizing-assets/references/runtime.md +156 -0
- package/assets/optimizing-assets/references/tools.md +131 -0
- package/assets/optimizing-assets/references/verification.md +118 -0
- package/assets/optimizing-assets/scripts/ab-compare.mjs +250 -0
- package/assets/optimizing-assets/scripts/optimize-glb.mjs +953 -0
- package/dist/scaffold/asset-pipeline-skill-files.js +2 -0
- package/dist/scaffold/asset-pipeline-skill-files.js.map +1 -1
- package/dist/scaffold/metahuman-skill-files.js +1 -1
- package/dist/scaffold/optimizing-assets-skill-files.d.ts +3 -0
- package/dist/scaffold/optimizing-assets-skill-files.js +54 -0
- package/dist/scaffold/optimizing-assets-skill-files.js.map +1 -0
- package/dist/scaffold/project-files.d.ts +1 -1
- package/dist/scaffold/project-files.js +5 -2
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +2 -0
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/skill-files.js +2 -1
- package/dist/scaffold/skill-files.js.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1385,6 +1385,37 @@ runtime module (`src/work/metahuman/`), the lip-sync tool and a headless verific
|
|
|
1385
1385
|
each failure a raw export produces (arms crossed into the chest, a torn shirt, hair left behind by a
|
|
1386
1386
|
head turn, tinted skin, black eyes) with the fix that resolved it.
|
|
1387
1387
|
|
|
1388
|
+
## Optimizing assets
|
|
1389
|
+
|
|
1390
|
+
The scaffolded `optimizing-assets` skill makes a game's GLBs smaller and faster to load with no
|
|
1391
|
+
visible change, the same way in every project. It measures first (`--audit` ranks `world.json` rows
|
|
1392
|
+
by bytes, `--report` gives triangles, texture formats and estimated GPU memory) and sorts each asset
|
|
1393
|
+
into a class by how close the player gets. It then runs the skill's own `scripts/optimize-glb.mjs`
|
|
1394
|
+
with that class's preset: `distant-prop`, `hero-prop`, `character`, `code-driven` or `level`. The
|
|
1395
|
+
script compresses textures (KTX2 ETC1S or UASTC, WebP, JPEG 4:4:4) and vertex buffers (meshopt,
|
|
1396
|
+
quantization) and enforces the rules the first games learned: separate vertex layout, int8 normals,
|
|
1397
|
+
float positions on skinned meshes and on nodes that code reads by name, and no pruned marker nodes.
|
|
1398
|
+
It keeps solid-colour textures, keeps a base map and the mask code pairs with it the same size,
|
|
1399
|
+
keeps a KTX2 atlas already in the target encoding instead of encoding it twice, keeps every
|
|
1400
|
+
animation key on characters, prints before and after numbers, lists every named node whose origin
|
|
1401
|
+
quantization moved, and runs the Khronos validator. It refuses to run outside a project root,
|
|
1402
|
+
where it could not see which node names the code reads. The candidate is A/B'd in the running
|
|
1403
|
+
game before anything is uploaded (a temporary `?preview=1` redirect under `bitmagic dev`), scored
|
|
1404
|
+
by the skill's `scripts/ab-compare.mjs` (luma SSIM, luminance and silhouette ratios, a sheet). A
|
|
1405
|
+
`glb` row is then replaced in place with `bitmagic assets add --keep-glb --asset-id`, its
|
|
1406
|
+
`sourceGlbUrl` pointed back at the original so Blender and `assets revoxelize` can still read it;
|
|
1407
|
+
a `character` or other row gets a new row and its references repointed.
|
|
1408
|
+
|
|
1409
|
+
The encoder runs on tools shared by every project on the machine, in `~/.bitmagic/tools`
|
|
1410
|
+
(glTF-Transform, meshoptimizer and sharp at pinned versions, and KTX-Software). They are not CLI
|
|
1411
|
+
dependencies. The skill asks the creator before installing them and gives no-sudo steps for macOS,
|
|
1412
|
+
Linux and Windows. The skill checks the vendored engine's source rather than its version number:
|
|
1413
|
+
meshopt output needs a loader factory that attaches three's `MeshoptDecoder`, KTX2 on WebGPU needs
|
|
1414
|
+
the factory's wait for the device (`expectGltfLoaderSupport`), and `assets revoxelize` needs a
|
|
1415
|
+
voxelizer that reads quantized attributes. Engines older than the Sep 2026 loader factory read no
|
|
1416
|
+
KTX2 at all. For a gap the skill offers `bitmagic upgrade`, meshopt-off and non-KTX2 settings, or a
|
|
1417
|
+
game-side shim for the missing half.
|
|
1418
|
+
|
|
1388
1419
|
## Art style
|
|
1389
1420
|
|
|
1390
1421
|
A 3D game is voxel-built unless `src/work/game.json` says otherwise:
|
|
@@ -21,6 +21,10 @@ skill's step 1 tells an agent how to copy them into a project's `tools/blender-l
|
|
|
21
21
|
| `sync_world.py` | python3 | carries the doors, spawn and `terrain.shape: 'none'` into `src/work/world.json` |
|
|
22
22
|
| `tests/` | python3 | `python3 -m unittest discover -s tests` — no Blender needed; `cli`'s `pnpm test` runs it |
|
|
23
23
|
|
|
24
|
+
`bmedit`'s `load(id)` and `review_asset.py` use Blender's glTF importer, which refuses
|
|
25
|
+
`EXT_meshopt_compression` and `KHR_texture_basisu`. For an asset the `optimizing-assets` skill has
|
|
26
|
+
compressed, put the original it recorded in `build/inputs/`, and run its preset again on the export.
|
|
27
|
+
|
|
24
28
|
`review.py` and `audit.py` are the skill's **inner loop**: with the build they answer every
|
|
25
29
|
geometric and (under `--lighting game`) every lighting question about a level for a few seconds
|
|
26
30
|
and no network, which is what lets the engine handoff — `bitmagic assets add`, `sync_world.py`,
|
|
@@ -15,8 +15,11 @@ With no visual inspection capability, produce a technical diagnostic and leave v
|
|
|
15
15
|
Read the project's AGENTS.md and identify the input GLB or blend file, desired animation clips,
|
|
16
16
|
runtime skeleton constraints and output location. Infer these from the project before asking.
|
|
17
17
|
Copy the source to a per-character work directory; never overwrite the only original. Record its
|
|
18
|
-
SHA-256.
|
|
19
|
-
|
|
18
|
+
SHA-256. Blender's importer refuses a GLB that carries `EXT_meshopt_compression` or
|
|
19
|
+
`KHR_texture_basisu`. If it is an `optimizing-assets` output, start from the original that skill
|
|
20
|
+
recorded, then run its preset again on your result. Keep the repair profile, scripts, candidate,
|
|
21
|
+
review images and verdict together there. Do not put per-character work inside this shipped
|
|
22
|
+
skill: GDK upgrade refreshes its files.
|
|
20
23
|
|
|
21
24
|
Find Blender with `blender --version`, BLENDER_BIN, or the installed application executable
|
|
22
25
|
(macOS commonly `/Applications/Blender.app/Contents/MacOS/Blender`; Windows installations commonly
|
|
@@ -9,7 +9,7 @@ The local agent makes anatomical/material decisions from images. Do not replace
|
|
|
9
9
|
|
|
10
10
|
## Establish evidence
|
|
11
11
|
|
|
12
|
-
Locate the model, its physical height, materials, texture resolution and rig. Keep the original immutable and write a separate material revision. Inspect the original generated image when available, but make the **actual mesh and its textures** authoritative: the generated mesh may differ from that image.
|
|
12
|
+
Locate the model, its physical height, materials, texture resolution and rig. Keep the original immutable and write a separate material revision. Blender cannot import a GLB that carries `EXT_meshopt_compression` or `KHR_texture_basisu`: if it is an `optimizing-assets` output, work from the original that skill recorded, then optimize the result again. Inspect the original generated image when available, but make the **actual mesh and its textures** authoritative: the generated mesh may differ from that image.
|
|
13
13
|
|
|
14
14
|
Use the local `scripts/skin_views.py` helper to create unlit, occlusion-correct views with their UV correspondences. Read [the projection contract](references/projection.md) for commands and formats. It requires local Blender and a Python environment with NumPy/Pillow. The helper performs no semantic recognition. Verify orientation from the resulting images; its axes are not anatomical labels.
|
|
15
15
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: optimizing-assets
|
|
3
|
+
description: Make this game's models and textures smaller and faster to load with no visible change. Measure the download, GPU memory and triangles; classify each asset by how close the player gets; compress its textures (KTX2, WebP, JPEG) and vertex buffers (meshopt, quantization) with the shipped optimize-glb.mjs presets; A/B it in the game, then swap it in. Use when the creator asks to optimize, compress, shrink or slim assets, cut the download size or load time, reduce memory, fix a slow first load on phones, or prepare a mobile game for publishing.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Optimizing assets
|
|
7
|
+
|
|
8
|
+
A shipped game's download is mostly a handful of GLBs, and most of their bytes are things the player
|
|
9
|
+
never sees: 2048² maps on a rock seen from 40 m, float32 normals, a second rig nobody animates. Two
|
|
10
|
+
games shipped with this recipe. Squall Riders' phone download went 43.3 → 13.5 MiB; Trick or Roll's
|
|
11
|
+
stages went 92 → 28 MB and its animals 17.9 → 1.7 MB. Both results passed an in-game A/B.
|
|
12
|
+
You measure first, then choose one preset per asset class from one script. The script enforces the
|
|
13
|
+
safety rules, and nothing ships until the player's view says so.
|
|
14
|
+
|
|
15
|
+
The scripts are in `.agents/skills/optimizing-assets/scripts/`: `optimize-glb.mjs` encodes and
|
|
16
|
+
`ab-compare.mjs` scores an A/B. Run them from the project root with `node`: the encoder reads
|
|
17
|
+
`src/` and `engine/` for the node names code uses, and refuses to run where neither exists. They
|
|
18
|
+
never edit `world.json` and never overwrite their input. Optimizing is the last step (see 6.4).
|
|
19
|
+
|
|
20
|
+
## 1. Measure before touching anything
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
O=.agents/skills/optimizing-assets/scripts/optimize-glb.mjs
|
|
24
|
+
node $O --audit src/work/world.json # every asset row by bytes, cumulative %, and what refers to it
|
|
25
|
+
node $O --report big.glb # triangles, textures (format, size, bytes), estimated GPU memory
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- **Fetch each GLB into a work directory**, e.g. `.bitmagic/optimize/<asset>/` (git ignores
|
|
29
|
+
`.bitmagic/`). Get it from its row's `url`, record its SHA-256, and never edit the only copy.
|
|
30
|
+
Keep per-asset work out of this skill folder, because `bitmagic upgrade` rewrites it.
|
|
31
|
+
- **Start from the original, never from a variant.** Rows marked `(variant?)`, and files whose
|
|
32
|
+
report says `already EXT_meshopt_compression`, are earlier optimized copies. Re-encoding one
|
|
33
|
+
stacks the losses, so find the original row or URL first.
|
|
34
|
+
- **Rank by what players actually download.** A row without a recorded size is sized by a HEAD
|
|
35
|
+
request (marked `~`) or listed first as `unknown`. `used` says what names the row: world.json,
|
|
36
|
+
code, or nothing. Neither proves a fetch, so confirm in `bitmagic dev`'s network panel. In Squall
|
|
37
|
+
Riders, 12 assets were 38.5 of 43 MiB.
|
|
38
|
+
- **Write down the baseline for each candidate:** transfer bytes, estimated GPU memory, triangles,
|
|
39
|
+
and the total first download. Every later claim is measured against these numbers.
|
|
40
|
+
- **Out of scope:** voxel (`vxl`) rows (`bitmagic assets revoxelize` changes those) and audio.
|
|
41
|
+
For images, see the end of [recipes](references/recipes.md).
|
|
42
|
+
|
|
43
|
+
## 2. Classify each asset by how the player sees it
|
|
44
|
+
|
|
45
|
+
Pick the class from the **closest distance a player can reach**, not the usual one. When you are
|
|
46
|
+
unsure, take the closer, less lossy class. Recipes, rejected alternatives and when to override are
|
|
47
|
+
in [recipes](references/recipes.md). "Measured" is this script's default on the original file.
|
|
48
|
+
|
|
49
|
+
| Class (preset) | What it is | What the preset does | Measured |
|
|
50
|
+
|---|---|---|---|
|
|
51
|
+
| `distant-prop` | landmarks, scenery, ~30 m or more away | simplifies (normal-weighted, bounds locked); one 512 ETC1S base map with AO baked in; normal and ORM maps dropped; 14-bit positions | beacon 4.45 MiB → 180 KiB, 61k → 10k triangles (bytes only: riders reach its rocks, see recipes) |
|
|
52
|
+
| `hero-prop` | sculpts and buildings passed at ~10–20 m | no simplification; 1024 JPEG 4:4:4 base and normal maps; 512 UASTC ORM; float positions | rock islet 4.93 → 1.71 MiB, SSIM ≥ 0.984 at 12 m |
|
|
53
|
+
| `character` | skinned or animated, fills the screen | float positions; exact int8 normals; every animation key kept; 1024 UASTC (RDO) atlas, a UASTC source kept as it is | penguin rider 2.43 → 1.58 MiB (0.69 at 512) |
|
|
54
|
+
| `code-driven` | vehicles, anything whose vertices, node positions or pixels code reads | float positions; base maps kept, the ORM/mask slot as lossless WebP (same pixels); never KTX2 | jet ski 1.26 → 0.70 MiB (0.40 at 512) |
|
|
55
|
+
| `level` | stages and level meshes | float positions and triangle order kept; normals int8, UVs 16-bit; textures untouched | a Trick or Roll stage 2.26 → 1.01 MiB |
|
|
56
|
+
|
|
57
|
+
**Phone variants differ per class.** For `character`, `code-driven` and `level`, add
|
|
58
|
+
`--max-texture=512`: a kept texture is resized in its own format, and a base map stays paired with
|
|
59
|
+
its mask. `distant-prop` is already 512. `hero-prop`'s default already is Squall Riders' phone set:
|
|
60
|
+
at 512 the islet scored 0.906–0.940 at 12 m, so do not go lower without passing that A/B.
|
|
61
|
+
|
|
62
|
+
**Check what code reads before you choose.** The script scans `src/` and the vendored engine for the
|
|
63
|
+
node names code compares. It also warns about game files that parse GLB bytes themselves (collision
|
|
64
|
+
read from a stage file, `bufferViews`), because the engine's decoder never reaches them. Give such a
|
|
65
|
+
parser the meshopt decoder and normalized-int handling, or use `--meshopt=off` and still check that
|
|
66
|
+
it reads int8 normals.
|
|
67
|
+
|
|
68
|
+
## 3. Tools: check first, ask before installing
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
ls ~/.bitmagic/tools/gltf/node_modules/@gltf-transform/core ~/.bitmagic/tools/ktx/bin/ktx
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
If both exist, go on. If not, installing them downloads about 70 MB. **Ask the creator first**, then
|
|
75
|
+
follow [tools](references/tools.md): pinned versions, no-sudo steps for macOS, Linux and Windows,
|
|
76
|
+
and the shared README. Never run the plain `gltf-transform meshopt` or `etc1s` commands on game
|
|
77
|
+
assets, whatever an older `~/.bitmagic/tools/README.md` suggests: they quantize skinned and named
|
|
78
|
+
meshes and encode normal maps as ETC1S. The script warns when the installed versions differ from
|
|
79
|
+
the ones it was tested with. On Windows, [tools](references/tools.md) has PowerShell equivalents.
|
|
80
|
+
|
|
81
|
+
## 4. Run the script
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
node $O in.glb out.glb --preset=distant-prop
|
|
85
|
+
node $O in.glb out-mobile.glb --preset=character --max-texture=512 # a phone variant
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`--help` lists the overrides. Change **one variable at a time** and keep each output. These rules
|
|
89
|
+
hold on every run, and no flag turns them off:
|
|
90
|
+
|
|
91
|
+
- The vertex layout is SEPARATE, and normals and tangents are int8.
|
|
92
|
+
- Skinned or morphed files keep float32 positions.
|
|
93
|
+
- Mesh nodes that code reads keep float positions and their exact TRS, and are not simplified:
|
|
94
|
+
`BM_*` engine nodes, names that `src/` or `engine/` compares node names against (literals,
|
|
95
|
+
`startsWith`/`includes` arguments, regex words), and anything given with `--keep-float`.
|
|
96
|
+
- Empty nodes are never pruned, nodes and materials are never renamed, and no texture is folded
|
|
97
|
+
into a material colour (a solid-white mask is still a texture code paints into).
|
|
98
|
+
- Simplification locks the bounding box.
|
|
99
|
+
|
|
100
|
+
The script prints before and after numbers, every texture's format and size, notes and checks:
|
|
101
|
+
node names, texture slots, paired sizes (`code-driven`), layout, kept nodes, skinned positions,
|
|
102
|
+
bounds drift, smaller, and the Khronos validator. **Exit code 1 means do not ship this output.**
|
|
103
|
+
Read every note; each is a decision:
|
|
104
|
+
|
|
105
|
+
- **`kept exact`** names the file and string that matched. A false match only costs bytes. To drop
|
|
106
|
+
one, rerun with `--code=none --keep-float=<the nodes code really reads>`.
|
|
107
|
+
- **`positions quantized: … a different transform`** lists named nodes whose origin moved. If code
|
|
108
|
+
reads the position of one (a lamp, an anchor), add it with `--keep-float`.
|
|
109
|
+
- **`AO not baked`** (the occlusion map uses other UVs or a texture transform), a metalness warning,
|
|
110
|
+
or `already EXT_meshopt_compression` (you started from a variant).
|
|
111
|
+
- **`KTX2 kept as it is`**: a library atlas already in the slot's encoding is not encoded twice.
|
|
112
|
+
An explicit slot spec re-encodes it for fewer bytes; that output must pass the A/B on its own.
|
|
113
|
+
- **`names the clip`**: code plays a clip `--only-default-scene` or `--drop-animations` removed.
|
|
114
|
+
- `TEXCOORD_0 leaves 0-1` is harmless: tiling UVs stay float. glTF-Transform prints it as
|
|
115
|
+
`quantize: Skipping TEXCOORD_0; out of [0,1] range.`
|
|
116
|
+
|
|
117
|
+
## 5. The engine must decode what the script writes
|
|
118
|
+
|
|
119
|
+
Check the vendored engine's source, not its version: a build number does not say which fixes it
|
|
120
|
+
carries. A grep that prints 0 (or a missing file) is a gap:
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
ls engine/engine/loaders/GltfLoaderSupport.ts # missing: no KTX2 at all
|
|
124
|
+
grep -c setMeshoptDecoder engine/engine/loaders/GltfLoaderSupport.ts # meshopt decodes
|
|
125
|
+
grep -c expectGltfLoaderSupport engine/engine/loaders/GltfLoaderSupport.ts # KTX2 waits for WebGPU
|
|
126
|
+
grep -c packedFloats engine/engine/GLBVoxelizer.ts # voxelizes quantized files
|
|
127
|
+
grep -rlE "setMeshoptDecoder|meshoptDecoder" src/ # a game-side decoder
|
|
128
|
+
grep -rlE "gltfLoaderSupportsKtx2|KHR_texture_basisu" src/ # a game-side KTX2 wait
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- **meshopt** (every preset): with no decoder, a meshopt GLB fails with `setMeshoptDecoder must
|
|
132
|
+
be called`. A game-side decoder in `src/` covers it.
|
|
133
|
+
- **KTX2 on WebGPU** (`character`, `hero-prop`, `distant-prop`): without the wait, a KTX2 GLB that
|
|
134
|
+
arrives before the renderer's `init()` (a smaller file arrives sooner) fails with `setKTX2Loader
|
|
135
|
+
must be called`. A `src/` shim covers it only if the last grep finds it: Squall Riders' waits,
|
|
136
|
+
Trick or Roll's does not. With no `GltfLoaderSupport.ts` (before September 2026), no loader
|
|
137
|
+
reads KTX2 at all. Without `packedFloats`, `bitmagic assets revoxelize` misreads quantized files.
|
|
138
|
+
|
|
139
|
+
When a check fails, pick one:
|
|
140
|
+
|
|
141
|
+
1. `bitmagic upgrade`. Read its `--help` first: it refuses a dirty working tree. Then grep again.
|
|
142
|
+
2. `--meshopt=off` (quantization only, which three reads natively; 20–60% larger) and, where KTX2
|
|
143
|
+
fails, no KTX2: `--base=webp:1024:90`, plus `--orm=webp-lossless:512` where the preset writes
|
|
144
|
+
UASTC. With no `GltfLoaderSupport.ts` that is every asset, not only those loaded during boot.
|
|
145
|
+
3. The game-side shim in [runtime](references/runtime.md), or the half an existing `src/` shim
|
|
146
|
+
lacks. Its KTX2 wait needs `GltfLoaderSupport.ts`. Delete it once an upgrade passes the greps.
|
|
147
|
+
The dev asset panel runs no game code, so it cannot open meshopt files: verify in the game.
|
|
148
|
+
|
|
149
|
+
## 6. A/B, then swap it in
|
|
150
|
+
|
|
151
|
+
1. **A/B before anything is uploaded.** Under `bitmagic dev` the candidate is served from its work
|
|
152
|
+
directory (`/.bitmagic/optimize/<asset>/out.glb`), and the preview module in
|
|
153
|
+
[runtime](references/runtime.md) loads it in place of the original on `?preview=1`: one build
|
|
154
|
+
shows both. A game that already rewrites asset URLs needs its originals override on both sides
|
|
155
|
+
(runtime says how to tell). Confirm the swap happened, capture the views in
|
|
156
|
+
[verification](references/verification.md), score them with `ab-compare.mjs`, and show the
|
|
157
|
+
creator the sheet. Delete the preview module afterwards.
|
|
158
|
+
2. **Re-read `src/work/world.json`.** Note the row's `id`, `name`, `type` and `targetHeight`, and
|
|
159
|
+
every reference to its id **and** its URL: `grep -rn "<id>\|<file name>" src/`. The player's
|
|
160
|
+
model is `worldProfileData.characterUrl`, a URL, and skies and code often load by URL too.
|
|
161
|
+
3. **Swap it in, by the row's type:**
|
|
162
|
+
- **`glb`:** `bitmagic assets add out.glb --keep-glb --asset-id <id> --name <its name> --height <its targetHeight>`.
|
|
163
|
+
The new record wins field by field: leaving out `--name` renames the asset to the file name,
|
|
164
|
+
and leaving out `--height` resets it to 2 m. Then edit the row's `sourceGlbUrl` back to the
|
|
165
|
+
original URL, because the replace points it at the optimized file. Placements follow the id;
|
|
166
|
+
every URL reference from step 2 needs the new URL.
|
|
167
|
+
- **Any other type** (`character`, `polygon-mesh`, `gltf`): not in place. `--keep-glb` would turn
|
|
168
|
+
the row into a 2 m `glb`: refused under id references without `--force`, silent otherwise.
|
|
169
|
+
Add a new row with `bitmagic assets add out.glb --keep-glb --name <name>_opt` and point each
|
|
170
|
+
reference from step 2 at it: `characterUrl` gets the new URL, code the new id or URL. For many
|
|
171
|
+
rows, or per-device sets, use the variant map in [runtime](references/runtime.md) instead.
|
|
172
|
+
4. **Optimization is the last step, so keep the original importable.** Blender cannot import
|
|
173
|
+
meshopt or KTX2, a voxel bake wants full detail, and an engine without `packedFloats` (step 5)
|
|
174
|
+
misreads quantized attributes when it voxelizes. Rigging, skin authoring, Blender edits and
|
|
175
|
+
`bitmagic assets revoxelize` start from the original file or URL; then re-run the preset on
|
|
176
|
+
their result. Record the original URL and SHA-256 next to the new URL in the work directory,
|
|
177
|
+
so a rollback is one command.
|
|
178
|
+
|
|
179
|
+
## 7. Verify after the swap: all three verdicts
|
|
180
|
+
|
|
181
|
+
1. **Numbers:** the script's report per asset, then the game's total first download before and
|
|
182
|
+
after, from the network panel with the cache disabled.
|
|
183
|
+
2. **Decode:** play the scene that uses the asset. The console must show no `setMeshoptDecoder`,
|
|
184
|
+
`KTX2` or WebGPU pipeline errors. Run `bitmagic verify`. After a `glb` swap the dev asset panel
|
|
185
|
+
shows `sourceGlbUrl`, the original, so it proves nothing about the new file. Open
|
|
186
|
+
`/asset-preview?kind=glb&url=<new URL, encoded>` on the dev view: the vendored engine's loader.
|
|
187
|
+
3. **Look:** capture the step 6.1 views again, with the swapped asset and no preview, and compare
|
|
188
|
+
them with the original captures. That proves the game loads what you tested.
|
|
189
|
+
|
|
190
|
+
Keep the numbers verdict and the visual verdict separate. A good SSIM does not approve a look. Show
|
|
191
|
+
the creator the A/B sheet; the look is theirs to call.
|
|
192
|
+
|
|
193
|
+
## Pitfalls, and why each one bites
|
|
194
|
+
|
|
195
|
+
- **Interleaved quantized vertex buffers** crash WebGPU pipeline creation (`unorm32x4 is not a
|
|
196
|
+
valid enum`).
|
|
197
|
+
- **int16 normals with tangents** drew camouflage blotches on WebGPU, on both desktop and phone.
|
|
198
|
+
- **Quantized positions on a skinned mesh** rewrite its inverse bind matrices, so code that reads
|
|
199
|
+
the bind pose silently mis-poses the character.
|
|
200
|
+
- **Quantized positions on a named mesh node** move its origin (lamp centres, anchors) or push the
|
|
201
|
+
mesh onto an unnamed child.
|
|
202
|
+
- **Quantized positions under `positionLocal` or noise materials, or merged batches,** make the
|
|
203
|
+
pattern swim and reopen coplanar seams. Use `level` or `--pos-bits=0`.
|
|
204
|
+
- **`prune()` with default options** deletes empty marker nodes (spawns, items, goals) and folds a
|
|
205
|
+
solid-colour texture into its material colour, which deletes a mask code paints into.
|
|
206
|
+
- **ETC1S up close** blotches colour, and on a hero prop it came out larger than the JPEG.
|
|
207
|
+
- **Lossy WebP for normal maps** is always 4:2:0. Use JPEG 4:4:4 or UASTC.
|
|
208
|
+
- **KTX2 on pixels code reads** (`getImageData`, livery masks) makes them unreadable. That is what
|
|
209
|
+
`code-driven` is for.
|
|
210
|
+
- **The game's first KTX2 texture** adds the Basis transcoder download (~0.25 MB). For a lone small
|
|
211
|
+
atlas, `--base=webp:512:90` avoids it.
|
|
212
|
+
- **Simplifying a sculpt** loses the detail that lives in its vertex normals. Every attempt on the
|
|
213
|
+
hero props failed the A/B.
|
|
214
|
+
- **A shrunken bounding box** makes the engine rescale and re-centre the prop to its targetHeight.
|
|
215
|
+
- **Downsizing an atlas without filling its gutters** bleeds dark seams. The script fills them.
|
|
216
|
+
- **Baking AO and dropping the ORM map** shifts brightness by up to about 5–10%. Check luminance in
|
|
217
|
+
the A/B.
|
|
218
|
+
- **The octahedral normal filter** is about 1° off and also filters animation. Characters stay exact.
|
|
219
|
+
- **A loaded machine** auto-drops the quality tier mid-capture. Pin it with `?quality=<rung>`.
|
|
220
|
+
- **Quantized attributes are padded to a 4-byte stride** (int8 normals to 4 bytes, int16 positions
|
|
221
|
+
to 8), with or without meshopt. Code reading attribute arrays directly then gets an
|
|
222
|
+
`InterleavedBufferAttribute`: read values through `getX()`, which also denormalizes them.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Recipes per asset class
|
|
2
|
+
|
|
3
|
+
Each preset below is what held up in a shipped game, with the numbers that justified it and the
|
|
4
|
+
alternatives that were measured and rejected. Override a preset only for a reason you can name, and
|
|
5
|
+
A/B the override the same way. "SR" is Squall Riders, a photoreal WebGPU racer. "TOR" is Trick or
|
|
6
|
+
Roll, a stylised ball game. SSIM is luma SSIM on the object (`ab-compare.mjs`'s `SSIM (luma,
|
|
7
|
+
object)`), original against new, at the stated view. "This script" numbers are `optimize-glb.mjs`
|
|
8
|
+
with the preset's defaults on the original file.
|
|
9
|
+
|
|
10
|
+
## distant-prop: seen from about 30 m or more
|
|
11
|
+
|
|
12
|
+
These read as silhouettes: landmarks, background ships, scenery. With this script, SR's beacon went
|
|
13
|
+
4.45 MiB → 180 KiB (61,487 → 10,349 triangles) and its trawler 4.02 MiB → 329 KiB (74,577 → 20,637).
|
|
14
|
+
SR's shipped set, four landmarks, went from 15.0 MB of originals to 0.84 MB and 8–12k triangles each,
|
|
15
|
+
with no visible change at race distance. That set also had per-region budgets and a Blender re-bake
|
|
16
|
+
on the trawler; its earlier phone set was 4.32 MB.
|
|
17
|
+
|
|
18
|
+
**The beacon shows the bytes, not a clean pass.** Riders can reach its rock ring (SR's collision
|
|
19
|
+
hulls stop a ski on the awash rocks), so by the closest-distance rule it is a `hero-prop`: 1.26 MiB,
|
|
20
|
+
SSIM 0.999 at 40 m in golden and storm light, and 0.958 (0.981 on the crop) at 15 m. As
|
|
21
|
+
`distant-prop`, in a neutral viewer at 40 m it scored 0.70–0.74 on the object and 0.875–0.887 on the
|
|
22
|
+
crop, its storm luminance ratio fell to 0.94 at one azimuth, and the rock visibly lost its
|
|
23
|
+
normal-map relief. Shipping it as a distant prop takes the extra work below.
|
|
24
|
+
|
|
25
|
+
- **Texture:** one base-colour map, 512² (256² for a "lite" tier), as KTX2 ETC1S at qlevel 255 with
|
|
26
|
+
full mips.
|
|
27
|
+
- AO from the ORM map is baked into it in linear light: `base × (1 − w·(1 − ao))`, w = 0.6. The
|
|
28
|
+
bake needs both maps on the same UV set with no `KHR_texture_transform`; otherwise the script
|
|
29
|
+
skips it and says so, and the dark-scene A/B decides.
|
|
30
|
+
- The normal map, the ORM map and TANGENT are dropped. Roughness and metalness become constants,
|
|
31
|
+
the ORM map's own means; the script prints them.
|
|
32
|
+
- If metalness comes out above 0.1, the prop has painted metal. Keep the ORM map with
|
|
33
|
+
`--orm=uastc:512` instead of flattening it.
|
|
34
|
+
- Crisp high-contrast graphics (stripes, signage) blotch less as UASTC: `--base=uastc:512:2`.
|
|
35
|
+
One SR beacon shipped that way.
|
|
36
|
+
- **Geometry:** meshopt `simplifyWithAttributes`. The error bound is 0.2% of the model's largest
|
|
37
|
+
dimension, and normals are weighted 0.1, so shading survives and not just the outline.
|
|
38
|
+
- UV seams are kept, and vertices on the bounding box are locked.
|
|
39
|
+
- Plain geometric simplification left soft shading gradients. Weights of 0.25–0.5 with
|
|
40
|
+
re-derived normals were needed once, on a rusty wreck: A/B before reaching for them.
|
|
41
|
+
- **If the player can get close to one side** (a rider could touch SR's trawler), that side needs
|
|
42
|
+
its own budget. One global error bound erased the nets and cables at 6k triangles. SR fixed it
|
|
43
|
+
with per-region budgets and a Blender re-bake, which is beyond this script. Classify such an asset
|
|
44
|
+
as `hero-prop` unless the creator wants the extra work.
|
|
45
|
+
- **Vertex data:** 14-bit positions, int8 octahedral normals, 12-bit UVs (enough for 512²).
|
|
46
|
+
- **Gate:** views at 40, 80 and 150 m from 3 azimuths, golden and storm lighting, desktop and
|
|
47
|
+
portrait. The mean-luminance ratio should stay within 0.96–1.10 in both lights. The silhouette
|
|
48
|
+
pixel ratio, within 0.99–1.01, is read on the golden or a mask capture: in storm light the dark
|
|
49
|
+
rock barely clears the background, and the beacon's unchanged outline read 0.89–1.01. Look at the
|
|
50
|
+
40 m view yourself at 100%: there SSIM runs lower (0.91–0.96 for the trawler in a neutral viewer,
|
|
51
|
+
with the luminance ratio up to 1.09; the beacon's numbers are above) and shading on thin parts
|
|
52
|
+
such as booms and nets can change. If it shows, try the closer class.
|
|
53
|
+
|
|
54
|
+
## hero-prop: closest approach about 10–20 m
|
|
55
|
+
|
|
56
|
+
Sculpted rocks, a lighthouse, anything the course passes close to. With this script the rock islet
|
|
57
|
+
went 4.93 → 1.71 MiB, SSIM 0.984–0.988 at 12 m. SR's shipped set: the sea stack 10.90 → 2.34 MiB,
|
|
58
|
+
the islet 4.93 → 1.69 and the lighthouse 4.70 → 1.53, SSIM 0.986–0.987 on phones at 12–20 m.
|
|
59
|
+
|
|
60
|
+
- **Textures:**
|
|
61
|
+
- Base colour and normal map: 1024 JPEG, q90–92, 4:4:4, mozjpeg.
|
|
62
|
+
- ORM: 512 KTX2 UASTC, RDO λ2, zstd, linear.
|
|
63
|
+
- TANGENT is kept.
|
|
64
|
+
- Where strata-like detail is the point, use `--normal=jpeg:2048:92`. The stack at 12 m scored
|
|
65
|
+
only 0.954–0.973 with a 1024 normal map on desktop.
|
|
66
|
+
- **This default is already SR's phone set.** `--max-texture=512` took the islet to 1.19 MiB but
|
|
67
|
+
scored 0.906–0.940 at 12 m, with visibly softer lichen and normal detail. Go below the default
|
|
68
|
+
only when the 12 m A/B passes.
|
|
69
|
+
- **Rejected, with the numbers:**
|
|
70
|
+
- An ETC1S base colour blotched at 10 m and came out larger than the JPEG (732 vs 653 KB).
|
|
71
|
+
- A UASTC normal map is 4.4 MB at 2048².
|
|
72
|
+
- A lossy WebP normal map scored 0.960.
|
|
73
|
+
- A 256² set passed the course views and failed at 12 m (0.895–0.925). Gate at the closest
|
|
74
|
+
approach.
|
|
75
|
+
- **No simplification.** Every attempt failed, down to 0.873 on the islet, because a sculpt keeps
|
|
76
|
+
its detail in its vertex normals. The only path left is a Blender normal bake onto a decimated
|
|
77
|
+
mesh.
|
|
78
|
+
- **Vertex data:** float32 positions, int8 normals and tangents, 14-bit UVs.
|
|
79
|
+
- **The trade-off:** a JPEG uploads as RGBA8. At 2048², one map is 21 MiB of GPU memory.
|
|
80
|
+
|
|
81
|
+
## character: skinned or animated
|
|
82
|
+
|
|
83
|
+
With this script, SR's penguin rider went 2.43 → 1.58 MiB, and 0.69 MiB with `--max-texture=512`.
|
|
84
|
+
The croc rider went 4.88 → 1.77 MiB with `--only-default-scene`, at SSIM 0.999 or more at 1 m and
|
|
85
|
+
3.4 m, posed and in the bind pose. SR's five riders shipped at 15.94 → 6.98 MiB at 1024 and
|
|
86
|
+
3.66 MiB at 512 (atlases re-encoded), with SSIM ≥ 0.985, IK grip misses of 0.0000 m and exact bind
|
|
87
|
+
positions. In TOR, 4 animals went 17.9 → 1.73 MB.
|
|
88
|
+
|
|
89
|
+
- **Texture:** 1024 KTX2 UASTC, RDO λ4, zstd 18, sRGB with mips. Use `--max-texture=512` for
|
|
90
|
+
phones.
|
|
91
|
+
- A source that is already UASTC at 1024 or less (library characters ship one) is kept as it is.
|
|
92
|
+
Re-encoding it is a second lossy generation: it saves about 18% of the atlas (the croc's
|
|
93
|
+
1.13 MiB → 830 KiB) and cost the croc 0.986 against 0.9996 at 3.4 m in the bind pose. An
|
|
94
|
+
explicit `--base=uastc:1024:4` does it anyway; A/B that output on its own.
|
|
95
|
+
- A larger or different KTX2 source is transcoded to RGBA and re-encoded. If the re-encode is
|
|
96
|
+
not smaller than the source at the same size, the source is kept.
|
|
97
|
+
- ETC1S was rejected: 0.983 at the chase view, and banding on flat panels at 512.
|
|
98
|
+
- RDO λ10 saved only 25 KB.
|
|
99
|
+
- TOR's alternative was `--base=webp:512:90`: 7.1 → 0.23 MB of atlases, and no Basis transcoder
|
|
100
|
+
download, because they were the game's only KTX2 files.
|
|
101
|
+
- **Vertex data:** positions ALWAYS float32. The script enforces this for skins and morphs.
|
|
102
|
+
Normals are exact int8, UVs 16-bit (float where outside 0–1), joints uint8 and weights unorm8.
|
|
103
|
+
- **No simplification.** At 8–11k triangles a character fills the screen. SR's decimated "lite"
|
|
104
|
+
riders scored 0.87–0.91.
|
|
105
|
+
- **Opt-in clean-up, only after checking the code:**
|
|
106
|
+
- `--only-default-scene`: library characters carry a second "static eyes" rig and scene. The
|
|
107
|
+
flag drops that scene with every node, mesh, skin and clip only it used, and names them: the
|
|
108
|
+
croc lost five of its ten clips (`NewIdle_Static`, `HandsGrip_Skin2`, …). First check that
|
|
109
|
+
nothing reads `gltf.scenes` or those node names, and grep the clip names. The script warns
|
|
110
|
+
when code names a clip it dropped.
|
|
111
|
+
- `--drop-animations`: TOR's game poses the bones itself; SR kept every clip. First grep for
|
|
112
|
+
the clip names.
|
|
113
|
+
- Duplicate textures (two library atlases stored the same 1024² image) are always merged.
|
|
114
|
+
- **Animation:** every key is kept, never quantized or resampled. `--resample=on` drops keys the
|
|
115
|
+
neighbouring keys reproduce within 1e-4: it saved 1–2% of the file (croc 20 KB,
|
|
116
|
+
penguin 36 KB) and moved the croc's hands by up to 1.2 mm, enough to fail the 1 m face view
|
|
117
|
+
(0.962). The `octahedral` normals mode would also filter the clips.
|
|
118
|
+
- **Replacing one:** a character row is not a `glb` row. Swap it in as a new row and repoint its
|
|
119
|
+
references (SKILL.md step 6), never in place.
|
|
120
|
+
|
|
121
|
+
## code-driven: code reads its vertices, node positions or pixels
|
|
122
|
+
|
|
123
|
+
This covers vehicles and props that code rebuilds from raw geometry, reads node world positions
|
|
124
|
+
from, or samples pixels from (a livery mask read through `getImageData`). With this script SR's
|
|
125
|
+
jet ski went 1.26 → 0.70 MiB, and 0.40 MiB with `--max-texture=512`. Of the saving at full size,
|
|
126
|
+
0.14 MiB is the mask stored as lossless WebP instead of PNG, and the rest is the vertex buffers.
|
|
127
|
+
|
|
128
|
+
- **Positions stay float32.** Quantized positions drew SR's skis as black tubes.
|
|
129
|
+
- **Textures stay readable images.** The preset refuses KTX2. Base maps are kept as they are; the
|
|
130
|
+
ORM slot, where SR's jet ski keeps its livery mask, becomes lossless WebP with the exact pixels
|
|
131
|
+
(colour under transparent texels, and every alpha value, included). It stays a PNG or JPEG when
|
|
132
|
+
that is smaller, and a 16-bit PNG stays as it is: WebP has 8 bits per channel.
|
|
133
|
+
- **A base map and the mask the code pairs with it must stay the same size**, or every racer falls
|
|
134
|
+
back to the stock livery. The script fails the `paired sizes` check when textures of one
|
|
135
|
+
material that matched in the input no longer do. `--max-texture=512` resizes both; overriding
|
|
136
|
+
one slot alone (`--base=jpeg:512:90`) fails.
|
|
137
|
+
- **Halving the triangles failed** (0.965–0.977).
|
|
138
|
+
|
|
139
|
+
## level: stages and level geometry
|
|
140
|
+
|
|
141
|
+
Positions are float32, triangle order is untouched (coplanar overlaps resolve by draw order),
|
|
142
|
+
vertices are renumbered by first use, and textures and animation clips are untouched. Normals and
|
|
143
|
+
tangents are stored as int8 and in-range UVs as 16-bit, which no stage has shown. One TOR stage went
|
|
144
|
+
2.26 → 1.01 MiB.
|
|
145
|
+
|
|
146
|
+
- **Positions must not be quantized.** TOR's painterly materials sample `positionLocal` noise, so
|
|
147
|
+
quantizing shifts the pattern. Its batching merges identity transforms and would break.
|
|
148
|
+
Quantizing also reopens coplanar seam ties.
|
|
149
|
+
- **Never delete "hidden" faces.** three draws shadow casters from their back faces, so the
|
|
150
|
+
undersides nobody sees are what cast the shadows.
|
|
151
|
+
- **Empty marker nodes** carry spawn, item and goal data. The script never prunes them.
|
|
152
|
+
- **It can save nothing.** Most of the saving is meshopt. With `--meshopt=off`, a TOR stage whose
|
|
153
|
+
export was already int8 came out 2% larger; the script then fails its `smaller` check, and the
|
|
154
|
+
original is what ships.
|
|
155
|
+
- **A stage built by `building-levels-in-blender` is rebuilt from its script.** Every rebuild writes
|
|
156
|
+
an unoptimized GLB: run the preset on each new build before registering it.
|
|
157
|
+
- **TOR went further, to 92 → 28 MB for 30 stages,** by dropping flat-shaded normals and rebuilding
|
|
158
|
+
them at load in game code. That is game-specific; do it only with the creator, as its own change.
|
|
159
|
+
TOR's own collision reader parses the stage GLBs directly, and it needed the meshopt decoder too.
|
|
160
|
+
|
|
161
|
+
## Images outside GLBs (skies, backgrounds)
|
|
162
|
+
|
|
163
|
+
The script handles GLBs only. SR's three skies went 0.81 → 0.17 MiB as 2048×1024 WebP at q97, and
|
|
164
|
+
0.07 MiB at 1024×512 for phones, with no visible change. The shared tools include sharp:
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
node -e "import('$HOME/.bitmagic/tools/gltf/node_modules/sharp/dist/index.mjs').then(({default: s}) => s('sky.png').webp({ quality: 97 }).toFile('sky.webp'))"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
A smaller sky blurs differently. SR gave each sky slot `lodBias = log2(2048 / width)` so a 1024 sky
|
|
171
|
+
matched.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Runtime: older engines, the A/B preview and per-device variants
|
|
2
|
+
|
|
3
|
+
## A game-side decoder shim, for an engine without the decoder or the KTX2 wait
|
|
4
|
+
|
|
5
|
+
SKILL.md step 5 greps the vendored engine for each half. An engine whose `GltfLoaderSupport.ts`
|
|
6
|
+
has no `setMeshoptDecoder` cannot load `EXT_meshopt_compression`. One with no
|
|
7
|
+
`expectGltfLoaderSupport` attaches the KTX2 loader on WebGPU only after the renderer's `init()`: a
|
|
8
|
+
KTX2 GLB whose parse starts before that fails with `setKTX2Loader must be called before loading
|
|
9
|
+
KTX2 textures`, and an optimized file, being smaller, arrives sooner. An engine with both does
|
|
10
|
+
this in `createGltfLoader`. First choice: `bitmagic upgrade`, then grep again. Second:
|
|
11
|
+
`--meshopt=off`, and non-KTX2 textures. Third, when the game must ship now: add this file and
|
|
12
|
+
import it first thing in the game's entry module, before anything loads a GLB.
|
|
13
|
+
|
|
14
|
+
- **A shim already in `src/`** counts only for the half it has. Squall Riders' `GltfDecoders.ts`
|
|
15
|
+
sets the decoder and waits for KTX2. Trick or Roll's sets only the decoder, so KTX2 assets there
|
|
16
|
+
need the wait below added to it, or non-KTX2 slot specs (`--base=webp:1024:90`).
|
|
17
|
+
- **No `engine/engine/loaders/GltfLoaderSupport.ts`** (engines from before September 2026): the
|
|
18
|
+
engine reads no KTX2 anywhere, and the `gltfLoaderSupportsKtx2` import below does not compile.
|
|
19
|
+
Keep the decoder half: drop that import, `needsKtx2` and the wait, and call `parse` directly.
|
|
20
|
+
Give every asset non-KTX2 slots.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// src/work/GltfDecoderShim.ts: delete it once an upgrade passes SKILL.md step 5's engine greps.
|
|
24
|
+
// Wraps GLTFLoader.prototype.parse once, so it covers every loader, including the ones the engine
|
|
25
|
+
// builds, which game code cannot reach. It never replaces a decoder or KTX2 loader already set.
|
|
26
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; // the engine's exact specifier
|
|
27
|
+
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
|
|
28
|
+
import { gltfLoaderSupportsKtx2 } from 'engine/loaders/GltfLoaderSupport.js';
|
|
29
|
+
|
|
30
|
+
type Parse = GLTFLoader['parse'];
|
|
31
|
+
type Fields = { meshoptDecoder: unknown; ktx2Loader: unknown };
|
|
32
|
+
const proto = GLTFLoader.prototype as unknown as { parse: Parse; decoderNotice?: string };
|
|
33
|
+
|
|
34
|
+
/** Does this GLB (or glTF JSON) use KHR_texture_basisu? Reads the JSON chunk only. */
|
|
35
|
+
function needsKtx2(data: unknown): boolean {
|
|
36
|
+
if (typeof data === 'string') return data.includes('"KHR_texture_basisu"');
|
|
37
|
+
if (!(data instanceof ArrayBuffer) || data.byteLength < 20) return false;
|
|
38
|
+
const view = new DataView(data);
|
|
39
|
+
const isGlb = view.getUint32(0, true) === 0x46546c67;
|
|
40
|
+
const start = isGlb ? 20 : 0, length = isGlb ? view.getUint32(12, true) : data.byteLength;
|
|
41
|
+
const json = new TextDecoder().decode(new Uint8Array(data, start, Math.min(length, data.byteLength - start)));
|
|
42
|
+
return json.includes('"KHR_texture_basisu"');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!proto.decoderNotice) {
|
|
46
|
+
// Kept as a string: minifiers strip the decoder's licence comment, and MIT asks for the notice.
|
|
47
|
+
proto.decoderNotice = 'meshoptimizer decoder, MIT License, Copyright (c) Arseny Kapoulkine';
|
|
48
|
+
const parse = proto.parse;
|
|
49
|
+
proto.parse = function (this: GLTFLoader, ...args: Parameters<Parse>): void {
|
|
50
|
+
const self = this as unknown as Fields;
|
|
51
|
+
self.meshoptDecoder ??= MeshoptDecoder; // GLTFLoader reads it when it parses, not when built
|
|
52
|
+
if (self.ktx2Loader || gltfLoaderSupportsKtx2() || !needsKtx2(args[0])) return parse.apply(this, args);
|
|
53
|
+
// A KTX2 file that beat the renderer's init: wait (up to 20 s) for the engine to attach KTX2.
|
|
54
|
+
const started = performance.now();
|
|
55
|
+
const tick = (): void => {
|
|
56
|
+
if (!self.ktx2Loader && !gltfLoaderSupportsKtx2() && performance.now() - started < 20000) {
|
|
57
|
+
setTimeout(tick, 30);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
parse.apply(this, args);
|
|
62
|
+
} catch (err) { // a deferred throw must still reach the caller's onError
|
|
63
|
+
const onError = args[3];
|
|
64
|
+
if (onError) onError(err as ErrorEvent); else console.error(err);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
setTimeout(tick, 30);
|
|
68
|
+
} as Parse;
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The decoder module embeds its WebAssembly, so it needs no second file. It is still a module: in the
|
|
73
|
+
CDN lane, whose import map points `three/addons/` at esm.sh, it is one more request (~8 KB gzip);
|
|
74
|
+
single-file builds inline it. Squall Riders and Trick or Roll vendored a copy into `src/`, so
|
|
75
|
+
their CDN-lane publish bundles it instead of fetching it. Squall Riders shipped both halves before
|
|
76
|
+
the engine took them over. Run `bitmagic check`, then decode the asset in the game as in
|
|
77
|
+
[verification](verification.md): the dev asset panel does not load game code, so it cannot open
|
|
78
|
+
meshopt files under this shim.
|
|
79
|
+
|
|
80
|
+
## Previewing a candidate without uploading it (A/B)
|
|
81
|
+
|
|
82
|
+
`bitmagic dev` serves the project folder, so a candidate in `.bitmagic/optimize/<asset>/` is at
|
|
83
|
+
`/.bitmagic/optimize/<asset>/out.glb`. This temporary module swaps it in for the original URL on
|
|
84
|
+
`?preview=1`, for every engine loader, without touching world.json. Import it first in the game's
|
|
85
|
+
entry module, run the A/B, then delete the file and its import.
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
// src/work/AssetPreview.ts: TEMPORARY, for an A/B under `bitmagic dev`. Delete it after the A/B.
|
|
89
|
+
import * as THREE from 'three';
|
|
90
|
+
|
|
91
|
+
/** The row's `url` -> the candidate in its work directory. */
|
|
92
|
+
const PREVIEW: Record<string, string> = {
|
|
93
|
+
'https://<the original url>.glb': '/.bitmagic/optimize/<asset>/out.glb',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (new URLSearchParams(window.location.search).has('preview')) {
|
|
97
|
+
// Every GLTFLoader and TextureLoader the engine builds fetches through this manager.
|
|
98
|
+
const manager = THREE.DefaultLoadingManager;
|
|
99
|
+
const resolve = manager.resolveURL.bind(manager);
|
|
100
|
+
const swapped = new Set<string>();
|
|
101
|
+
manager.resolveURL = (url: string): string => {
|
|
102
|
+
const candidate = PREVIEW[url];
|
|
103
|
+
if (candidate && !swapped.has(url)) { // logged on a real hit only, never at boot
|
|
104
|
+
swapped.add(url);
|
|
105
|
+
console.info('[preview] swapped', url, '->', candidate);
|
|
106
|
+
}
|
|
107
|
+
return resolve(candidate ?? url);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- **Confirm the swap before capturing.** The `[preview] swapped` line appears only when the engine
|
|
113
|
+
asks for a mapped URL, and the network panel must show the `/.bitmagic/optimize/…` request. With
|
|
114
|
+
neither, the key differs from the URL the engine fetches (a query string, another encoding, a
|
|
115
|
+
variant), the "candidate" capture is the original, and the A/B passes at the noise floor.
|
|
116
|
+
- **A game that rewrites asset URLs itself** (`grep -rlE "resolveURL|setURLModifier" src/`, such
|
|
117
|
+
as the variant map below) wraps the same function later, at game start, so its rewrite runs
|
|
118
|
+
first: the original URL becomes its variant before this module sees it. The candidate never
|
|
119
|
+
loads, and both sides show the variant. Capture both sides with that game's originals override
|
|
120
|
+
(Squall Riders: `?preview=1&assets=orig` against `?assets=orig`). That also makes the original,
|
|
121
|
+
not a variant, the baseline.
|
|
122
|
+
- The published bundle has no `/.bitmagic/` folder, which is one more reason the module must go
|
|
123
|
+
before a publish.
|
|
124
|
+
|
|
125
|
+
## Per-device variants (optional)
|
|
126
|
+
|
|
127
|
+
One optimized set, swapped in, is usually enough. Squall Riders' creator saw no difference and
|
|
128
|
+
moved every device to the phone set. Reach for variants when the phone set is visibly worse on a
|
|
129
|
+
large screen, or when the asset is a `character` or other non-`glb` row, which cannot be replaced
|
|
130
|
+
in place. The pattern, not a framework:
|
|
131
|
+
|
|
132
|
+
- **Rows:** keep the original rows byte-identical. Add each variant as its own row, named after the
|
|
133
|
+
original with a suffix (`bitmagic assets add <file> --keep-glb --name <name>_mobile`). A new row
|
|
134
|
+
gets a new id, and nothing places it.
|
|
135
|
+
- **The map:** a small module in `src/work/` maps each original asset id or URL to
|
|
136
|
+
`{ hq, mobile }` variant URLs.
|
|
137
|
+
- **The decision:** make it once per page load from the device quality tier, the engine's answer
|
|
138
|
+
to what a device can afford (the engine's `DeviceQuality.ts`). `activeDeviceQualityTier()` of
|
|
139
|
+
`low` or `minimal` gets the small set, and so does a phone-sized screen
|
|
140
|
+
(`min(screen.width, screen.height) < 600` CSS px), which cannot show the difference anyway.
|
|
141
|
+
Everything else gets hq. Do not decide by `isMobileRuntime()`: it answers the UI question (a
|
|
142
|
+
small touch screen), not capability, so a Steam Deck would get the phone set and a slow Chromebook
|
|
143
|
+
the hq one. The auto-tuner can lower the tier later, but by then the assets are loaded. Log the
|
|
144
|
+
decision once at boot.
|
|
145
|
+
- **Override:** `?assets=orig|hq|mobile|auto` on the URL, for A/B captures and a one-page rollback.
|
|
146
|
+
It wins over the tier, including a tier pinned with `?quality=`.
|
|
147
|
+
- **Loads the engine owns** (placed props, the player character): rewrite the URL at fetch time,
|
|
148
|
+
as the preview module above does: wrap `THREE.DefaultLoadingManager.resolveURL` so it maps
|
|
149
|
+
original → variant for registered URLs only. **Never write variant URLs into `world.json` or its
|
|
150
|
+
in-memory copy.** The editor would save them back.
|
|
151
|
+
- **Fallback:** if a variant fails to load, retry the original once, then report the error as
|
|
152
|
+
before.
|
|
153
|
+
- **Rollback:** make the decision function return `orig`, or empty the map.
|
|
154
|
+
|
|
155
|
+
The same device decision can drive non-asset savings too. SR skipped music on phones (−1.04 MiB)
|
|
156
|
+
and capped a code-painted livery canvas at 512.
|