@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 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. Keep the repair profile, scripts, candidate, review images and verdict together there.
19
- Do not put per-character work inside this shipped skill: GDK upgrade refreshes its files.
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.