The hero on this site is a 3D product shot of my own head. Scrolling turns it one full revolution, the surface breaks into points from the back forwards, and the points settle onto a sugar-skull mesh. The mesh that came out of the generator was 57.97MB. The one that ships is 1.61MB (1,612,796 bytes). This note covers the pipeline, the one command that did most of the work, and the three.js side that has to cooperate with it.
Where the mesh came from
Both meshes are image-to-3D output from Tripo H3.1, run through Higgsfield (tripo_h3_1_image_to_3d) with textures, PBR, and detailed texture and geometry quality switched on. The head came from a groomed front portrait. The skull came from a flat sugar-skull illustration I had already made for an earlier version of the hero. Each generation cost 18 credits.
What comes back is built for a DCC tool, not a browser. gltf-transform inspect on the raw head:
| Raw (Tripo) | Shipped | |
|---|---|---|
| File size | 57.97MB | 1.61MB |
| Vertices | 987,094 | 89,200 |
| Triangles | 1,935,676 | 154,850 |
| Textures | JPEG, 4096 x 4096 | WebP, 2048 x 2048 |
| Vertex attributes | all f32 |
POSITION i16_norm, NORMAL i8_norm, TEXCOORD_0 u16_norm |
| Extensions | none | EXT_meshopt_compression, EXT_texture_webp, KHR_mesh_quantization |
The geometry alone was 54.82MB of the raw file. The textures were never the main problem. The skull followed the same pattern: 57.05MB in, 1.82MB out, 90,022 vertices and 150,922 triangles.
The command
The whole optimisation is one optimize call from the glTF-Transform CLI, wrapped in a script so both meshes go through identical settings:
npx --yes @gltf-transform/cli@4 optimize "$in" "$out" \
--texture-compress webp --texture-size 2048 \
--simplify true --simplify-ratio 0.08 --simplify-error 0.005 \
--compress meshoptWhat each part is doing:
--simplify-ratio 0.08 --simplify-error 0.005targets 8% of the triangles, with a cap on how far the simplified surface may drift from the original. The output landed exactly on target: 154,850 / 1,935,676 is 8.0%.--texture-size 2048 --texture-compress webptakes the three 4K JPEG maps (colour, ORM and normal) down to 2K WebP. The colour map went from 1.91MB to 436KB.--compress meshoptapplies meshoptimizer's vertex and index codecs (EXT_meshopt_compression). The output also carriesKHR_mesh_quantization, which is why positions are 16-bit and normals 8-bit integers instead of 32-bit floats.
The script then refuses anything over a budget and records what shipped:
size=$(stat -f %z "$out")
if [ "$size" -gt 5000000 ]; then echo "over the 5MB cap"; exit 1; fiIt writes the final byte sizes into src/hero-manifest.json ({"head":1612796,"skull":1817520}). That file exists for the progress bar, covered below.
Loading it in three.js
Meshopt-compressed GLBs do not decode on their own. The loader needs the decoder registered before it parses:
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
const loader = new GLTFLoader(); loader.setMeshoptDecoder(MeshoptDecoder);
const headGltf = await loader.parseAsync(await headBytes, '/hero/');The first render also showed the side of my head instead of my face. Tripo exports face +X; the camera looks down Z. One constant fixes it for every mesh from that pipeline:
// Tripo meshes face +X. Turn them to face the camera on +Z.
const FRONT = -Math.PI / 2;
root.rotation.y = FRONT;I only caught that because a render script (scripts/hero/render-glb.mjs) draws each mesh from several angles in Playwright before anything ships. Looking at a mesh from one angle in the page is how you miss a sideways face.
Progress that does not lie
The preloader shows a real fraction of the download, so the GLB is streamed rather than fetched in one go. The catch: a CDN that compresses the response can drop content-length, and then there is no total to divide by. The manifest written by the optimise script is the fallback:
const total = Number(response.headers.get('content-length')) || expected;
if (!response.body || !total) return response.arrayBuffer();
const reader = response.body.getReader(); const chunks = []; let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value); received += value.length;
onProgress?.(Math.min(1, received / total));
}The download also starts before the three.js chunk has finished loading, so the network and the JavaScript engine overlap instead of queueing.
Scroll drives one number
Everything in the hero reads one progress value between 0 and 1: the section's scroll offset divided by its travel (a 320svh section with a sticky stage). svh rather than innerHeight, so mobile browser chrome expanding and collapsing never remaps the same scroll position to a different frame.
That raw value is smoothed with a fixed half-life instead of a per-frame lerp, so 60Hz and 120Hz screens land on the same value after the same elapsed time:
export function easedValue(current, target, elapsed, halfLife = 85, settle = 0.0005) {
const next = current + (target - current) * (1 - 2 ** (-Math.max(0, elapsed) / halfLife));
return Math.abs(next - target) < settle ? target : next;
}A pure timeline maps progress to every visual parameter, which keeps the choreography unit-testable:
export const TURN = [0.10, 0.84]; // one full turn of the head
export const DISSOLVE = [0.34, 0.60]; // the surface becomes points, back first
export const ASSEMBLE = [0.60, 0.84]; // the points settle onto the skull, face lastThe dissolve is injected into the standard material with onBeforeCompile: a fragment discard below a threshold, driven by a value that is 0 at the back of the head and 1 at the face with some value-noise grain, plus a thin red emissive edge at the cut. A MeshSurfaceSampler takes 60,000 points (30,000 on low-quality devices) from the head and the skull, paired by height so the flight reads as a flow. One scene, one renderer: the head, the points and a mirrored reflection on the floor all hang off the same timeline.
What it cost at runtime
The hero loads two GLBs totalling 3.43MB, only when motion is allowed and WebGL exists. Reduced-motion visitors get a static WebP poster and never download three.js. Two numbers are still unmeasured: first-frame time on a mid-range phone, and how the simplified mesh holds up on a real GPU. So far it has only been checked in renders and in a software-rendered browser. Those are next.
What I'd do differently
- Measure on a real GPU before anything else. My automated browser ran headless Chrome with SwiftShader, which took over 500ms a frame. That produced three problems that do not exist on real hardware, including GSAP's lag smoothing pausing tweens, which I fixed with
gsap.ticker.lagSmoothing(0). A software renderer is fine for "does it draw"; it tells you nothing about timing. - Keep the raw files. The 57.97MB originals lived in a gitignored folder inside a worktree that has since been removed. The before numbers in this note survive only because the tool output was captured in a session log. Next time the raw exports go into durable storage with a checksum next to the optimised output.
- Pick the simplify ratio from a sweep, not a guess. 0.08 was chosen once and looked acceptable. Rendering 0.04, 0.08 and 0.15 side by side at the hero's real size would have told me whether I left bytes or detail on the table.
- Budget the pair, not the file. The script caps each GLB at 5MB, but the visitor downloads both. The budget that matters is the 3.43MB total before first interaction.




