Case study · Personal project · 2026

Lumière

A jewellery configurator that runs on iOS, Android and the web from a single Expo codebase — with a hand-written refractive gem shader and rings composed from parts at runtime rather than picked from a pre-rendered set.

Expo SDK 54React Native 0.81three.js@react-three/fiberGLSLReanimated 4ZustandTypeScript

Run / Deploy / Read

ShareLinkedInX / Twitter
A bespoke composition: an oval brilliant in a four-prong setting on a pavé band, rendered live.

Every stone here is the gem shader, not a texture. The pavé stones on the band each carry their own material instance — sharing one collapses them all onto a single pose.

The problem

Most jewellery configurators are a grid of pre-rendered photographs. It works, and it means every combination has to exist before anyone can look at it. Four heads, five shanks, six metals, six stones, four carat weights and four ring sizes is 11,520 compositions — far past the point where rendering them in advance is sensible.

So the ring is assembled at runtime instead, and the expensive part — a convincing diamond — is solved once in a shader rather than 11,520 times in a render farm.

What I built

  • A runtime composition system. Each ring is a head and a shank in independent wrapper groups, so carat scales the stone about its own centre and ring size scales the band radially without touching the setting.

  • A refractive gem shader. The gem's geometry is baked into a cubemap once, then refracted rays are traced against that texture with per-channel dispersion and Beer-Lambert absorption — no mesh traversal at render time.

  • An imperative state bridge. Zustand with subscribeWithSelector lets the scene subscribe to single slices and mutate three.js objects directly, so changing a metal assigns a colour instead of re-rendering a tree.

  • One codebase, three targets. Native and web diverge only where they touch the device — three platform-split modules behind identical signatures.

The parts that were hard

Telling a diamond from a ring band

Detecting stones by optical properties fails on real CAD exports, which set an index of refraction on metal slots often enough that bands render as gemstones. Classification is strict and name-based instead — less clever, right every time.

A cache that could not be emptied

Resizing a browser window turned the gem black. Three causes stacked: the GL drawing buffer does not follow the canvas, a baked cubemap belongs to the context that made it, and the bake cache was keyed in a WeakMap — which cannot be enumerated, and therefore cannot be cleared.

Publishing without publishing too much

An adversarial review before release found shader source resurrected by a merge, and — inside the binary model files, invisible to any search of the source tree — the original CAD filenames, vendor URLs and the personal contact details of whoever had exported the models. Both were removed before anything went public.

Where it ended up

The application is open source under MIT. The gem shader ships separately as lumiere-gem on npm under a noncommercial license — about 42 KB, free for anything that is not a product being sold.

The full technical write-up, including the bugs above in detail, is in

Building a 3D ring configurator in Expo

. The optics behind the stone are in Rendering Brilliance.

The reference solitaire: a round brilliant in a four-prong setting on a plain band.

The reference solitaire. Same shader, same pipeline, one stone.