# 58MB to 1.6MB: shipping a 3D face mesh with glTF-Transform and scroll-driven three.js

> An AI-generated head mesh came out at 57.97MB and 987,094 vertices. One glTF-Transform command took it to 1.61MB and 89,200 vertices for a scroll-driven three.js hero.

- Author: Dil Ahdan (https://dil.dev/)
- Published: 2026-09-23
- Updated: 2026-09-23
- Tags: three.js, WebGL, glTF, performance
- Canonical: https://dil.dev/notes/3d-face-mesh-58mb-to-1-6mb/

## Key takeaways

- One `gltf-transform optimize` call took a Tripo head mesh from 57.97MB to 1.61MB, and from 987,094 to 89,200 vertices.
- Geometry was the problem, not textures: 54.82MB of the raw file was mesh data.
- Meshopt-compressed GLBs need `loader.setMeshoptDecoder(MeshoptDecoder)` or they will not load.
- Tripo meshes face +X. Rotate the root -90° on Y to face a three.js camera.
- Judge timing on a real GPU: a SwiftShader browser invented three problems that real hardware does not have.

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:

```bash
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 meshopt
```

What each part is doing:

- **`--simplify-ratio 0.08 --simplify-error 0.005`** targets 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 webp`** takes the three 4K JPEG maps (colour, ORM and normal) down to 2K WebP. The colour map went from 1.91MB to 436KB.
- **`--compress meshopt`** applies meshoptimizer's vertex and index codecs (`EXT_meshopt_compression`). The output also carries `KHR_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:

```bash
size=$(stat -f %z "$out")
if [ "$size" -gt 5000000 ]; then echo "over the 5MB cap"; exit 1; fi
```

It 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:

```js
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:

```js
// 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:

```js
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:

```js
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:

```js
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 last
```

The 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.

## Quick answers

**How do you shrink a Tripo GLB for the web?**

Run glTF-Transform's optimize command with WebP textures at 2048px, mesh simplification and meshopt compression. On this head mesh that took the file from 57.97MB to 1.61MB and the vertex count from 987,094 to 89,200.

**What simplify ratio works for a face mesh?**

A ratio of 0.08 with an error limit of 0.005 kept 8% of the triangles on this head, going from 1,935,676 to 154,850. Whether the loss is visible on a real GPU is still to be judged; check it with renders from several angles before shipping.

**Does meshopt compression need anything in three.js?**

Yes. A GLB written with EXT_meshopt_compression needs the decoder registered on the loader: call loader.setMeshoptDecoder(MeshoptDecoder) with the decoder from three/addons/libs/meshopt_decoder.module.js before parsing.

**Why does a Tripo mesh face sideways in three.js?**

Tripo exports face the +X axis, while a three.js camera usually looks down the Z axis. Rotating the model root by minus 90 degrees around Y turns the face to the camera.

**How do you show real download progress for a GLB?**

Stream the response body and divide received bytes by content-length. A CDN that compresses the response can drop content-length, so keep the shipped byte size in a manifest at build time and use it as the fallback total.
