← back

Apple

April 2026

An apple. Pulled from a desktop app I'm building, dropped into the browser.

Default textures are 512 pixels square so the page loads fast. If you want to see it at full resolution, the picker below will swap in the heavier version — that one is 41 MB.

loading…

How it's made

Loading a .glb

A .glb is a binary-packaged glTF file — one file that holds geometry, textures, and materials together. Three.js has a GLTFLoader in its addons that parses it directly into a scene graph you can drop into any THREE.Scene. Because these glbs use meshopt-compressed geometry, the loader also needs a decoder registered before it can parse them.

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);

loader.load('/apple-low.glb', (gltf) => {
  scene.add(gltf.scene);
});

Reflections come from a RoomEnvironment processed through a PMREMGenerator, which turns any scene into an environment map the PBR materials can sample. The apple has a real metallic-roughness workflow, so without an environment map it looks flat — with one, the red skin actually catches highlights.

Three texture tiers

The source model is a .usdz from a macOS app I'm building. It ships with three 4096² PBR textures — base color, normal, and metallic-roughness. On a desktop GPU that's fine. In a browser it's ~270 MB of VRAM just for textures, plus a 41 MB download before the page can render anything.

So the page ships three versions of the same model. gltf-transform optimize downsamples the textures, re-encodes them as WebP, and compresses the geometry with meshopt in a single pass:

# low — 512² webp + meshopt geometry
npx @gltf-transform/cli optimize apple-full.glb apple-low.glb \
  --texture-compress webp \
  --texture-size 512 \
  --compress meshopt

# half — 1024² webp + meshopt geometry
npx @gltf-transform/cli optimize apple-full.glb apple-half.glb \
  --texture-compress webp \
  --texture-size 1024 \
  --compress meshopt

The default that loads on page open is the Low tier. It's small enough that first paint happens almost immediately on any connection. Half and Full are only fetched if you click them.

Hot-swapping quality

When you click a pill, the page fetches the new glb with a ReadableStream reader so progress can update the pill's label while bytes come in. Once the fetch finishes, GLTFLoader.parse turns the bytes into a scene, and the new apple replaces the old one at the exact same rotation — the swap is meant to be invisible except for the change in fidelity.

async function loadTier(url, onProgress) {
  const res = await fetch(url);
  const total = Number(res.headers.get('content-length'));
  const reader = res.body.getReader();
  const chunks = [];
  let received = 0;
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    chunks.push(value);
    received += value.length;
    onProgress(received, total);
  }
  // concatenate chunks, hand to GLTFLoader.parse
}

Before the old apple is removed, its rotation is copied and reapplied to the new one. Its geometry, materials, and textures all get disposed — without that, every swap would leak memory and eventually the tab would crash on the Full tier.

Sometimes the whole point is that a thing loads and spins and you can look at it. No pitch.