Apple
This is an apple from a desktop app I'm building, brought over to the browser just because I wanted to see it here.
The page starts with 512 pixel textures so it loads quickly. If you want the sharper version, use the picker below. The full-res file is 41 MB, so it takes a second.
How it's made
Loading a .glb
A .glb is just a bundled glTF file. Geometry, textures, materials, all in one place. Three.js can load it straight into a THREE.Scene with GLTFLoader. These files also use meshopt compression, so the loader needs that decoder first.
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);
});
The reflections come from a RoomEnvironment passed through PMREMGenerator, which turns the scene into an environment map for the material to sample. Without that step the apple looks flat. With it, the skin catches light the way a real surface would.
Three texture tiers
The source model is a .usdz from a macOS app I'm building. It has three 4096² PBR textures: base color, normal, and metallic-roughness. That is fine on a desktop GPU, but in a browser it turns into about 270 MB of VRAM for textures alone, plus a 41 MB download before the apple even appears.
So this 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 one 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 page opens on the Low tier because it is small enough to show up almost immediately. Half and Full only load if you ask for them.
Hot-swapping quality
When you click a pill, the page fetches the new glb with a ReadableStream reader so it can show progress on the button. Once the download finishes, GLTFLoader.parse turns the bytes into a scene, and the new apple replaces the old one at the same rotation. Ideally the only thing you notice is the extra detail.
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 gets copied to the new one. Geometry, materials, and textures all get disposed too. If you skip that cleanup, every swap leaks memory until the tab becomes a problem.
Sometimes it is enough for a thing to load, spin, and look good. No bigger point than that.