Three.js From Zero · Article s11-04
S11-04 The Watch Industry's Common Three.js Stack
The Watch Industry's Common Three.js Stack
Cartier, Longines, Omega, Girard-Perregaux, Hublot. Five luxury watch viewers. Open all five DevTools panels and they look astonishingly similar. This article deconstructs the convergent stack — and what the watch industry knows that the rest of e-commerce hasn't figured out yet.
1. The convergence — what every luxury watch viewer ships
Open Cartier's Tank, Longines' Master Collection, Omega's Speedmaster, Girard-Perregaux's Laureato, Hublot's Big Bang, and Tag Heuer's Carrera one after another. The chrome is different. The marketing copy is different. The 3D viewer is shockingly close to identical:
- Hero camera at a slight 3/4 offset from straight-on. Never fully top-down, never fully profile.
- 360° turntable with damped rotation — usually scroll-bound via Lenis or pointer-drag with inertia.
- Strap material picker — leather grain (Barenia, Alligator, Calf), rubber, NATO, steel bracelet.
- Case finish picker — polished, satin, brushed (anisotropy direction matters).
- Dial picker — color × pattern (sunburst, opaline, guilloché).
- Engraving feature on the case-back, decal-textured at runtime.
- Discreet AR Quick Look badge on iOS (the watch landing on your wrist via USDZ).
Why so similar? Because the buyer's journey converges. A €15,000 watch is purchased after the user has compared 4-12 references, often in different browser tabs, often at 11pm in bed on an iPad. The viewer's job isn't to dazzle. It's to be indistinguishably better than the next tab.
2. Live demo — the watch viewer pattern in 350 lines
A procedural watch — composed cylinders for the case, a plane for the dial, a torus-segmented strap. Three strap textures, three case finishes, four dial colors, an engraving field. Drag to rotate, or let the turntable spin.
3. The hero camera — slight offset, never centered
A horological hero shot is never straight-on. It's 8°-15° from frontal, slightly above the dial plane, with the crown side of the case favored. Why? Because that's the angle a wristwatch is naturally seen at on a wrist. Aligning the camera with this convention matters because it triggers familiarity in the buyer.
// The 3/4 hero offset
camera.position.set(0, 0.6, 3.4); // Slightly above
camera.lookAt(0.05, 0, 0); // Crown side bias
controls.target.set(0, 0, 0);
controls.minPolarAngle = Math.PI * 0.25; // Constrain pan
controls.maxPolarAngle = Math.PI * 0.62;
Constrain OrbitControls tightly. Most watch viewers do not let you go fully behind the watch — the back is reserved for the engraving reveal animation, which is staged.
4. KHR_materials_clearcoat — the crystal
The sapphire crystal is the most important visual cue: a watch with a flat-shaded crystal looks like a rendering. A watch with a properly clearcoated crystal looks like a photograph.
const crystal = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
metalness: 0.0,
roughness: 0.05,
transmission: 0.95, // KHR_materials_transmission
thickness: 0.4, // KHR_materials_volume
ior: 1.77, // sapphire IOR (glass is 1.5)
clearcoat: 1.0, // KHR_materials_clearcoat
clearcoatRoughness: 0.02,
envMapIntensity: 1.4,
});
Two material layers — the body (glass-like transmission with sapphire's ~1.77 IOR) and a clearcoat that picks up the studio lighting reflection. The clearcoat is what makes the crystal "pop" when the user rotates the watch under directional lights.
5. KHR_materials_anisotropy — brushed metal that knows which way it's brushed
Brushed steel isn't blurry; it's directionally blurry. Light scatters perpendicular to the brush direction, making a thin streak instead of a circular highlight. The KHR_materials_anisotropy extension drives this with two parameters: strength and direction.
const brushedSteel = new THREE.MeshPhysicalMaterial({
color: 0xc8c8d0,
metalness: 1.0,
roughness: 0.45,
anisotropy: 0.8, // 0 = isotropic, 1 = full streak
anisotropyRotation: Math.PI / 2, // Radial brushing
// Or supply an anisotropyMap (R = strength, G = rotation)
});
For a case-side brushed finish, the rotation is parallel to the side. For a dial sunburst, the rotation rotates radially around the center — an anisotropy map is required because the rotation isn't constant across UVs.
6. Strap material patterns
Three patterns, each a tiny micro-economy of texture authoring:
Alligator (the prestige tier)
A tile-able alligator scale pattern with bump (or normal) and a slight darkening at the seams. Most viewers use a 1024×1024 baked normal map — anything sharper than that is wasted on a strap that's 18mm wide on a 1080p display.
| Map | Resolution | Notes |
|---|---|---|
| basecolor | 1024 | Brown #3d2417 to black |
| normal | 1024 | Strong scale relief, soft seams |
| roughness | 512 | Higher in scale valleys |
Calfskin / Barenia (the everyday tier)
Smoother grain, lighter color variation, less contrast. The trick is a microfiber sheen via KHR_materials_sheen — just enough that the strap looks like leather and not like rubber.
NATO / canvas (the sport tier)
Striped weave, often dual-color. Authored as a tiled basecolor with stripe-mask UVs. Weaving micro-detail comes from a sheen + a noisy specular variation.
7. The engraving — decal text as runtime texture
The user types "JKB 2026" in the engraving field. The viewer needs to put that text on the case-back, lit and shadowed correctly, without blowing the GPU budget on a re-bake every keystroke.
The pattern is canvas-baked decal texture:
- Allocate a 512×128
CanvasTexturefor the case-back UV island. - On engraving change, draw the text into the canvas with a slight emboss / outer-glow.
- Mark
texture.needsUpdate = true. - Use it as the case-back's
normalMap(engraving is depth, not color) plus a darkening tweak inaoMap.
const cv = document.createElement('canvas');
cv.width = 512; cv.height = 128;
const ctx = cv.getContext('2d');
function bakeEngraving(text) {
ctx.fillStyle = '#808080'; // Neutral normal-map base
ctx.fillRect(0, 0, 512, 128);
ctx.fillStyle = '#404040'; // Darker = engraved depth
ctx.font = 'bold 56px serif';
ctx.textAlign = 'center';
ctx.fillText(text, 256, 90);
caseBackTexture.needsUpdate = true;
}
For a physically convincing engraving, bake into a normal map not a color map: text becomes recessed grooves, lights catch the inner edges. Cartier's case-back does this — type a name, watch the lighting on the engraved letters change as you rotate.
8. Damped rotation — the Lenis pattern
Most watch viewers use Lenis or a hand-rolled equivalent for the turntable feel. The user drags, releases, and the watch coasts to a stop with the right inertial decay. Lenis is overkill for a single rotation — a tiny damping primitive does it:
// In the render loop
let velocity = 0;
let drag = 0.94; // Damping factor — 0.92-0.97 feels right
function tick() {
if (!userDragging) {
angle += velocity;
velocity *= drag;
if (Math.abs(velocity) < 1e-4) velocity = 0;
}
watch.rotation.y = angle;
}
The magic number is drag = 0.94. Lower (0.85) feels rubber-like and trashy. Higher (0.99) feels frictionless and weightless. 0.94 feels mechanical — like ball bearings that have just enough resistance.
9. Performance — the silent rule of luxury
Watch buyers compare. They open 6 Cartier references, 4 Omegas, and 2 Lange & Söhnes in tabs. If your viewer chews 12% CPU on idle, the user's MacBook fan turns on, and they close the tab.
Production targets for the watch tier:
| Metric | Hero target | Mobile threshold |
|---|---|---|
| Initial bundle (gz) | < 220 KB | < 380 KB |
| Initial 3D asset (gz) | < 1.2 MB | < 2.0 MB |
| FPS, hero turntable | 60 | 30-60 |
| CPU, idle (no animation) | < 1% | < 3% |
| GPU memory | < 120 MB | < 90 MB |
The killer is idle CPU. A naive requestAnimationFrame loop renders at 60 fps even when nothing changes — that's a fan-on watch viewer. The fix:
// Render-on-demand: only render when state changed
let dirty = true;
controls.addEventListener('change', () => { dirty = true; });
function tick() {
if (dirty || velocity > 0 || engravingChanged) {
renderer.render(scene, camera);
if (Math.abs(velocity) < 1e-4) dirty = false;
}
requestAnimationFrame(tick);
}
Idle CPU drops from 8-15% to under 0.5%. The user's MacBook stays quiet. The user opens a 7th tab, your watch stays smooth in tab #4.
10. The full stack, summarized
| Component | Tech | Why |
|---|---|---|
| Loader | GLTFLoader + Draco + KTX2 | Standard. 60-70% asset size reduction. |
| Crystal | MeshPhysicalMaterial + clearcoat + transmission | Sapphire IOR 1.77, scratch-resistant feel. |
| Brushed metal | KHR_materials_anisotropy | Directional highlight, not blur. |
| Strap leather | KHR_materials_sheen + tiled normal | Sheen separates leather from rubber. |
| Engraving | CanvasTexture as normalMap | Real-time, depth-correct. |
| Rotation | Damped angle integrator | Mechanical inertia feel. |
| Render | Render-on-demand loop | Idle CPU < 1%. |
| AR | USDZ via gltf-transform usd | iOS Quick Look badge. |
11. Takeaways
- Watch viewers converged because the buying behavior converged: tab comparison at midnight on an iPad.
- Sapphire crystal needs clearcoat + transmission; brushed metal needs anisotropy; leather needs sheen.
- Engraving is a
CanvasTexturebaked into a normal map — not a separate mesh. - Damped rotation at
drag = 0.94is the magic number. - Render-on-demand is non-negotiable for the watch tier — idle CPU below 1% or you lose the tab.
- The hero camera is a 3/4 offset, never straight-on. It echoes the on-wrist sight angle.