Three.js From Zero · Article s11-04

S11-04 The Watch Industry's Common Three.js Stack

Season 11 · Article 04

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.

Don't actually use full transmission for an in-page demo — the cost is 2-4x render time on integrated GPUs. Production viewers fake it with an environment map cube reflection plus a roughness/ior pair, falling back to true transmission only when the user opens an "explore" view.

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.

MapResolutionNotes
basecolor1024Brown #3d2417 to black
normal1024Strong scale relief, soft seams
roughness512Higher 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:

  1. Allocate a 512×128 CanvasTexture for the case-back UV island.
  2. On engraving change, draw the text into the canvas with a slight emboss / outer-glow.
  3. Mark texture.needsUpdate = true.
  4. Use it as the case-back's normalMap (engraving is depth, not color) plus a darkening tweak in aoMap.
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:

MetricHero targetMobile threshold
Initial bundle (gz)< 220 KB< 380 KB
Initial 3D asset (gz)< 1.2 MB< 2.0 MB
FPS, hero turntable6030-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

ComponentTechWhy
LoaderGLTFLoader + Draco + KTX2Standard. 60-70% asset size reduction.
CrystalMeshPhysicalMaterial + clearcoat + transmissionSapphire IOR 1.77, scratch-resistant feel.
Brushed metalKHR_materials_anisotropyDirectional highlight, not blur.
Strap leatherKHR_materials_sheen + tiled normalSheen separates leather from rubber.
EngravingCanvasTexture as normalMapReal-time, depth-correct.
RotationDamped angle integratorMechanical inertia feel.
RenderRender-on-demand loopIdle CPU < 1%.
ARUSDZ via gltf-transform usdiOS 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 CanvasTexture baked into a normal map — not a separate mesh.
  • Damped rotation at drag = 0.94 is 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.