A mesh generator hands you a file that would take a phone on a mobile connection most of a minute to fetch, and the browser would then have to decode three 16-megapixel JPEGs before it could draw a single triangle. Between that file and the one the court serves there is one offline script per model, one texture repaint that no script could do, and a check that the result still looks like her. The tasks that produced the raw files are on the Tripo page; the loader that consumes the finished ones is on the court build page.
| Model | Raw | Shipped | Triangles | Textures shipped | On the wire |
|---|---|---|---|---|---|
helen-sit-v2.glb | 61.44 MB, 1,988,734 tris, 3 × 4096² JPEG | 2,163,088 B | 119,998 | 2048² colour 713 kB · 1024² normal 71 kB · 1024² ORM 7 kB | 1,988,513 B |
helen-sit-std.glb | 2.34 MB, 48,574 tris, 3 × 1024² JPEG | 737,820 B | 48,574 | 1024² × 3: 131 / 97 / 17 kB | 686,241 B |
helen-standing.glb | 2.20 MB, 48,810 tris | 671,972 B | 48,810 | 1024² × 3: 122 / 80 / 28 kB | 630,204 B |
helen-morse-bust.glb | 3.83 MB, 77,735 tris | 781,328 B | 77,735 | 1024² × 3: 76 / 45 / 30 kB | 668,468 B |
charles-morse-bust.glb | 4.38 MB, 79,570 tris | 850,020 B | 79,570 | 1024² × 3: 99 / 80 / 23 kB | 754,194 B |
Two things stand out in that table. Triangle count is untouched on the four August models; the entire saving there is texture encoding and meshopt. And on the September model the 2048² colour map is a third of the file by itself, which is the price of fur that reads as fur at court distance. Gzip takes a further seven to eight percent off each file in transit, all of it from the meshopt blocks; the WebP does not compress again.
The pass, in order
Both scripts use glTF-Transform's function library with sharp as the image encoder and meshoptimizer's encoder for geometry. The September script adds a simplify step because two million triangles need it; the August script does not because 50,000 do not.
// Pack the Tripo output (tens of MB) into the court's budget:
// simplify to ~TARGET_TRIS, 2K WebP textures, Meshopt geometry.
// node scripts/pack-helen-v2.mjs [in.glb] [out.glb] [targetTris] [texSize]
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { dedup, meshopt, prune, resample, simplify, textureCompress, weld } from "@gltf-transform/functions";
import { MeshoptDecoder, MeshoptEncoder, MeshoptSimplifier } from "meshoptimizer";
import sharp from "sharp";
const src = process.argv[2] || "public/models/helen-sit-v2-mv.glb";
const dest = process.argv[3] || "public/models/helen-sit-v2.glb";
const TARGET_TRIS = Number(process.argv[4] || 160000);
const TEX = Number(process.argv[5] || 2048);
await MeshoptEncoder.ready; await MeshoptDecoder.ready; await MeshoptSimplifier.ready;
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.encoder": MeshoptEncoder, "meshopt.decoder": MeshoptDecoder });
const doc = await io.read(src);
const before = tris(doc); // counts indices/3 across every primitive
const ratio = Math.min(1, TARGET_TRIS / before);
await doc.transform(
weld(),
dedup(),
ratio < 1 ? simplify({ simplifier: MeshoptSimplifier, ratio, error: 0.0008, lockBorder: false }) : (d) => d,
// Colour keeps the full size; normal + ORM at half. Fur normals at 1K read fine at court distance.
textureCompress({ encoder: sharp, targetFormat: "webp", resize: [TEX, TEX], quality: 86, slots: /baseColor/i }),
textureCompress({ encoder: sharp, targetFormat: "webp", resize: [TEX / 2, TEX / 2], quality: 80,
slots: /(normal|occlusion|metallicRoughness)/i }),
prune(),
resample(),
meshopt({ encoder: MeshoptEncoder, level: "medium" }),
);
await io.write(dest, doc);
console.log(`out: ${dest} ${(statSync(dest).size / 1e6).toFixed(2)} MB, ${tris(await io.read(dest))} tris`);
Order matters. weld() and dedup() come first so the simplifier sees one connected surface rather than a soup of duplicated vertices along seams; a simplifier run on unwelded geometry opens cracks. simplify() is meshoptimizer's, with error: 0.0008 as the permitted deviation in normalised units and lockBorder: false because a closed cat has no border worth locking. Texture compression comes after, by slot: colour at full size and quality 86, the normal and occlusion-roughness-metallic maps at half size and quality 80, because a normal map at 1024² reads identically to 2048² from a metre away and costs a quarter of the bytes. prune() and resample() drop whatever nothing references any more. meshopt() is last because it wants the final vertex stream; at level: "medium" it also quantizes positions and UVs, which is why the shipped files carry KHR_mesh_quantization alongside EXT_meshopt_compression.
/**
* Encode the four live GLBs: WebP textures + Meshopt geometry.
* KTX2/ETC1S was smaller but turned Helen's fur into clay and pulled a 527 KB
* Basis transcoder onto the homepage critical path.
*/
const jobs = [
{ file: "helen-sit-std.glb", max: 1024 },
{ file: "helen-standing.glb", max: 1024 },
{ file: "helen-morse-bust.glb", max: 1024 },
{ file: "charles-morse-bust.glb", max: 1024 },
];
for (const job of jobs) {
const dest = join(models, job.file);
const bak = dest.replace(/\.glb$/, ".bak.glb");
if (!existsSync(bak)) copyFileSync(dest, bak); // always re-run from the Tripo original
const doc = await io.read(existsSync(bak) ? bak : dest);
await doc.transform(
textureCompress({ encoder: sharp, targetFormat: "webp", resize: [job.max, job.max], quality: 88 }),
weld(),
dedup(),
prune(),
resample(),
meshopt({ encoder: MeshoptEncoder, level: "medium" }),
);
await io.write(dest, doc);
const used = (await io.read(dest)).getRoot().listExtensionsUsed().map((e) => e.extensionName);
console.log(`${job.file} -> ${statSync(dest).size} bytes [${used.join(", ")}]`);
}
# September cat: 61 MB in, 2.16 MB out. Arguments: in, out, target triangles, colour texture size.
node scripts/pack-helen-v2.mjs public/models/helen-sit-v2-mv.glb public/models/helen-sit-v2.glb 160000 2048
# in: 61.4 MB, 1988734 tris, textures: 4096x4096 image/jpeg | 4096x4096 image/jpeg | 4096x4096 image/jpeg
# out: public/models/helen-sit-v2.glb 2.16 MB, 119998 tris
# the four August-era models, from their .bak.glb originals
node scripts/compress-glb.mjs
The .bak.glb convention is doing quiet work: every run reads the Tripo original, never the previous output, so the numbers can be changed and the pass re-run without compounding loss. The originals are git-ignored; the packed files are committed.
Decisions that were made, and one that was not
- WebP over KTX2. KTX2 with ETC1S was tried first because every guide recommends it and it produced smaller files. It was rejected for two reasons recorded in the script header: the GPU-native texture flattened the fur into something that read as clay, on the one object in the room whose whole job is to look like a specific soft animal; and decoding it needs the Basis transcoder, 527 kB of WASM and JS that landed on the homepage's critical path ahead of the cat. WebP decodes natively, and three.js's meshopt decoder is a small module already in the bundle.
- Meshopt over Draco. Draco was not tried. It compresses geometry harder but needs its own decoder download and decodes on the CPU into full-precision buffers; meshopt's blocks are decoded into the quantized layout the GPU uses directly. Either would have been fine at these sizes; the choice was made once for the loader and never revisited.
- Quantization, but only meshopt's. glTF-Transform has a separate
quantize()with per-attribute bit depths. It is not in either script; the quantization present comes frommeshopt()and was not tuned. - 120,000 triangles. The target was 160,000; the simplifier's ratio landed at 119,998 against the two-million input. Nobody has argued for more.
The repair no script could make
// decode.mjs <in.glb> <outdir> -- dump geometry and the textures of a meshopt GLB
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import fs from "fs";
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const [path, outDir] = process.argv.slice(2);
const doc = await io.read(path);
const root = doc.getRoot();
const prim = root.listMeshes()[0].listPrimitives()[0];
const pos = prim.getAttribute("POSITION"), uv = prim.getAttribute("TEXCOORD_0"), idx = prim.getIndices();
const n = pos.getCount();
const posOut = new Float32Array(n * 3), uvOut = new Float32Array(n * 2), t3 = [0, 0, 0], t2 = [0, 0];
for (let i = 0; i < n; i++) { pos.getElement(i, t3); posOut.set(t3, i * 3); uv.getElement(i, t2); uvOut.set(t2, i * 2); }
const idxOut = new Uint32Array(idx.getArray()); // quantized/meshopt accessors decode to plain arrays here
fs.writeFileSync(`${outDir}/pos.f32`, Buffer.from(posOut.buffer));
fs.writeFileSync(`${outDir}/uv.f32`, Buffer.from(uvOut.buffer));
fs.writeFileSync(`${outDir}/idx.u32`, Buffer.from(idxOut.buffer));
root.listTextures().forEach((tex, i) => fs.writeFileSync(`${outDir}/tex${i}.webp`, Buffer.from(tex.getImage())));
for (const mat of root.listMaterials()) {
const bc = mat.getBaseColorTexture();
console.log("baseColor texture index", bc ? root.listTextures().indexOf(bc) : null); // 0 on every Tripo export
}
// pack.mjs <in.glb> <newTex.webp> <out.glb> -- swap texture 0 (base colour) and re-write
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
import fs from "fs";
await MeshoptEncoder.ready; await MeshoptDecoder.ready;
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder, "meshopt.encoder": MeshoptEncoder });
const [inPath, newTexPath, outPath] = process.argv.slice(2);
const doc = await io.read(inPath);
const tex0 = doc.getRoot().listTextures()[0];
console.log("replacing texture0, old size", tex0.getImage().length, "mime", tex0.getMimeType());
tex0.setImage(new Uint8Array(fs.readFileSync(newTexPath)));
tex0.setMimeType("image/webp");
await io.write(outPath, doc);
console.log("wrote", outPath, fs.statSync(outPath).size, "bytes");
# 1. decode the shipped file: geometry to raw arrays, textures to files
node decode.mjs public/models/helen-sit-std.glb work/before
# baseColor texture index 0 -> work/before/tex0.webp is the colour map (1024x1024, 134110 bytes)
# 2. convert tex0.webp to PNG, paint out the tongue in an image editor, export back to WebP
# (the repaint touched about 1,100 pixels, all inside x 343-385, y 76-127 of the 1024x1024 map; nothing else)
# -> work/tex0_fixed.webp, 131492 bytes
# 3. put the repainted texture back; geometry is untouched
cp public/models/helen-sit-std.glb public/models/helen-sit-std.pre-tongue-fix.bak.glb
node pack.mjs public/models/helen-sit-std.glb work/tex0_fixed.webp work/helen-sit-std.tonguefix.glb
# replacing texture0, old size 134110 mime image/webp
# wrote work/helen-sit-std.tonguefix.glb 737820 bytes
# 4. prove nothing but the colour map changed
node decode.mjs work/helen-sit-std.tonguefix.glb work/after
cmp work/before/pos.f32 work/after/pos.f32 && cmp work/before/idx.u32 work/after/idx.u32 && echo geometry identical
cmp work/before/tex1.webp work/after/tex1.webp && cmp work/before/tex2.webp work/after/tex2.webp && echo normal and ORM identical
# 5. ship, and bump the cache-busting query on the URL in src/models.ts
cp work/helen-sit-std.tonguefix.glb public/models/helen-sit-std.glb
Step 2 is the part a person did in an image editor, and it is the whole repair: about 1,100 pixels of a 1,048,576-pixel map, inside a 43 × 52 patch where the muzzle sits in the UV layout (diffing the decoded colour map against the repainted one, counting anything that moved by more than a hair of noise). What painted them is not recorded, only the before and after. Because the change is in the texture rather than the mesh, the file shrank by 2,616 bytes, two bytes off the difference between the two WebP encodes. The previous file is kept as helen-sit-std.pre-tongue-fix.bak.glb so the repair can be redone from the same starting point if the model is ever re-packed.
Checking that nothing got worse
There are two checks, and neither is a model scoring a picture.
The first is structural and cheap: open the file and read what is in it. The JSON chunk of a GLB is plain text, so the triangle count, the extensions, the generator and the texture sizes can be read without a viewer. The wire size is one curl. Those are the numbers in the table at the top of this page, and they are the numbers to compare before and after any change to the pass.
# what is actually inside a GLB, without a viewer: the JSON chunk is plain text after a 20-byte header
python3 - <<'EOF'
import struct, json, sys
b = open("public/models/helen-sit-v2.glb", "rb").read()
jl = struct.unpack("<I", b[12:16])[0]
j = json.loads(b[20:20 + jl])
tris = sum(j["accessors"][p["indices"]]["count"] // 3 for m in j["meshes"] for p in m["primitives"])
print("generator", j["asset"].get("generator"))
print("extensions", j.get("extensionsUsed"))
print("triangles", tris)
print("images", [(im.get("mimeType"), j["bufferViews"][im["bufferView"]]["byteLength"]) for im in j["images"]])
EOF
# generator glTF-Transform v4.4.2
# extensions ['EXT_meshopt_compression', 'EXT_texture_webp', 'KHR_mesh_quantization']
# triangles 119998
# images [('image/webp', 713378), ('image/webp', 71440), ('image/webp', 6908)]
# what the browser actually downloads (Caddy gzips model/gltf-binary)
curl -s -o /dev/null -w '%{size_download}\n' -H 'Accept-Encoding: gzip' https://helenthecatlive.com/models/helen-sit-v2.glb
# 1988513
The second is visual, and a person does it. npm run qa (scripts/qa-shots.mjs) boots the court under headless Chromium with software GL at 1440 × 900 and 390 × 844, waits for the boot flag, reads the WebGL canvas into a PNG for each width and writes qa-output/score.json with the status text, any page errors and the byte size of each capture. It does not grade anything. The two captures are then held against the twelve checks in QUALITY_BAR.md, and the first of those is the one no script can answer: "It is Helen. Coat patches, blaze, and face come from real photos, not a generic 3D cat." The KTX2 rejection came from exactly this look, not from a metric; the file was smaller and the fur was wrong.
What happens once the loader has the file, the fit, the fallbacks and the cache-busting query, is on the court build page. The generation side is the Tripo page; what all of it cost is on the cost page.