jeanrojas.com

Footer

jeanrojas.com

Boosting remote teamwork and improving systems architecture focusing on team communication patterns.



jrojastechnology@gmail.com
+1 (929) 2245443

Links

  • About
  • Experience
  • Blog
  • Contact

Social

  • Github
  • Codepen
  • Linkedin
  • Twitter
  • Behance
  • Quora
  • AdpList

Subscribe to my newsletter

The latest news, articles, and resources, sent to your inbox weekly.

© Jeanrojas.com All rights reserved.

← All articles

May 5, 2026 · 17 min read · updated August 26, 2026

Building a 3D ring configurator in Expo

The complete story of Lumière: loading GLBs on device, composing rings from parts at runtime, a custom refractive gem shader, porting the whole thing to the browser — and the audit that found a stranger's email address inside a binary before I made it public.

On this page

This post began in May as a recipe for putting a ring configurator on a phone. It has been rewritten and folded together with the release notes now that the project is finished, public, and running in a browser — because most of what I learned happened after the first version went up, and some of it contradicts what I wrote back then.

Lumière is a jewellery configurator built in Expo: one codebase, iOS, Android and web. You compose a ring from a head and a shank, choose the metal, the stone, the carat weight and the ring size, and it renders live — including the diamond, which is a hand-written shader rather than a material preset.

The source is open now, under MIT. That took longer than writing it, because the most interesting part of the project was the part I did not want to give away, and because a repository turns out to carry things you never typed into it.

Run / Deploy / Read

Pick the path that matches your hardware and patience

Try the live demoGitHub
ShareLinkedInX / Twitter

Six parts: what the device gives you, getting geometry onto it, composing a ring from parts, the gem, the port to the browser, and what it took to publish safely — then what is actually in the repository, if you want to read the code instead. The last two parts are new, and the audit is the one I would most want to read.

Part I — What the device actually gives you

Phones are not small desktops. expo-gl gives you GLES 3.0, the JS thread does double duty as the render thread, every megabyte of GLB is a megabyte on someone's data plan, and sustained 60fps is earned rather than assumed.

Three rules carried the whole project:

  1. Load once, render many. GLB parsing is expensive on React Native's JS thread. Cache the parsed scene by module id, so a ring swapped away and back is free.
  2. Gestures live on the UI thread. Anything driven by a finger runs in a Reanimated worklet. The moment a rotation update crosses the JS bridge every frame, the frame budget is gone.
  3. State lives outside React. The scene subscribes to individual slices of the store and mutates three.js objects directly. Changing the metal should not re-render a component tree; it should assign a colour to a material.

That third rule is why the store uses subscribeWithSelector:

// The scene reads slices imperatively and mutates objects in place.
// No re-render, no reconciliation, no dropped frame on a picker tap.
useRing.subscribe(
  (s) => s.headMetal,
  (metal) => applyMetal(headMeshes, metal),
);

And one flag matters more than any other on mid-range Android:

<Canvas dpr={[1, 2]} gl={{ antialias: true, powerPreference: "high-performance" }}>

The default pixel ratio on a Pixel 7 is 2.625. Capping at 2 cuts fragment shader work by roughly a third for a difference nobody can see.

Part II — Getting geometry onto the device

Metro does not treat .glb or .hdr as bundleable assets. This line is the difference between a bundle that works and one that silently 404s on a physical device while working perfectly in the simulator:

metro.config.js
config.resolver.assetExts.push("glb", "gltf", "hdr", "exr", "bin");

Loading is where React Native diverges most from the web. fetch() does not hand you an ArrayBuffer you can pass to GLTFLoader, so the file goes through expo-asset onto the real filesystem first, then gets read back as bytes. All of that hides behind one function:

export async function assetArrayBuffer(moduleId: number): Promise<ArrayBuffer>

Everything upstream — the loader, the environment maps, the cache — knows only that signature. Which turns out to matter enormously in Part V.

Part III — A ring is not a model

The first version loaded one GLB and swapped its materials. The finished one composes each ring at runtime from a head (the setting and its stone) and a shank (the band), each in its own wrapper group so they can be transformed independently.

Four heads and five shanks, six metals, six stone types, four carat weights and four US ring sizes — 11,520 compositions, each priced from its parts. Cut is not a separate axis: it is a property of the head, the way it is in a real setting.

Composition raises a problem that sounds trivial and is not: given an arbitrary mesh out of a CAD export, is it a stone or is it metal?

My first answer was optical — check transmission, check the index of refraction, check transparency. It was wrong on real files, consistently. Jewellery CAD exporters set KHR_materials_ior on metal slots often enough that every optical heuristic false-positives, and you get a band rendered as a diamond.

The answer that works is boring and strict:

function classifyMaterial(mat: THREE.Material | undefined): SlotKind {
  // Only meshes whose material slot is NAMED with a gem keyword get the
  // diamond shader. Everything else — including unnamed slots — is metal.
  // No optical, metalness, IOR or transparency fallbacks; those
  // false-positive on CAD exports that set KHR_materials_ior on metal.
  if (!mat) return "metal";
  if (mat instanceof THREE.ShaderMaterial) return "gem";
  const name = (mat.name || "").trim();
  if (name && GEM_NAME_RE.test(name)) return "gem";
  return "metal";
}
In plain English

I tried to detect diamonds by asking "does this material behave like glass?" — and the files lied. The models were exported by software that marks metal as slightly glassy, so bands kept turning into gemstones.

What actually works is reading the label a human typed. Less clever, and right every time.

Remember this one. It comes back in Part VI, as the reason a cleanup nearly destroyed the catalogue.

The two transforms are similarly opinionated. Carat scales the stone about its own centre, pivot pinned so it stays seated in the prong cup — carat is mass, so linear scale goes as the cube root. Ring size scales the band radially in X and Z only, leaving its height and the head untouched, so a larger size reads as a bigger hole rather than a uniformly inflated ring:

export function caratToScale(carat: number): number {
  return Math.cbrt(Math.max(0.01, carat) / MAX_CARAT);
}

Part IV — The gem

The May version of this post recommended a compromise: MeshPhysicalMaterial with transmission, ior: 2.418 and a low-res environment map. Keep the depth, lose the rainbow, keep the frames.

I no longer ship that. The stone is a custom shader that bakes the gem's geometry into a cubemap once, then traces refracted rays against that texture with per-channel dispersion and Beer-Lambert absorption. Why a cut gemstone can become a single texture lookup at all is its own article: Rendering Brilliance.

Two integration details are worth repeating here, because both cost me a day.

Every gem mesh needs its own material instance. The shader carries a per-mesh model matrix as a uniform. Share one material across several stones and they all collapse onto the last mesh's pose — a pavé band becomes one stone smeared along the shank. The expensive part, the bake, is cached per geometry, so sharing the cache while splitting the material gives you both.

The bake projects rays from the origin, so a gem whose geometry sits off-origin bakes dark. Each stone's vertices are re-centred before baking and the mesh position offset by the inverse, which leaves it visually where the artist put it:

// Guards against the same shared BufferGeometry being double-shifted when
// the gem regex matches several meshes pointing at it.
const recentredGeometries = new WeakSet<THREE.BufferGeometry>();

That WeakSet is a supporting character here. Its cousin becomes the villain of Part V.

Part V — The same app, a second runtime

React Native Web means the UI mostly transfers for free, and @react-three/fiber renders through the same three.js on both sides. What broke was everything that touches the device.

expo-file-system is the clearest case: on native it reads bytes off disk, on web it is a stub that warns and returns nothing. That takes down the GLB loader and every persisted preference, because preferences were built on the same primitive. Metro resolves .web.ts before .ts, so the fix is three sibling files rather than a runtime branch:

assetBuffer.ts   →  assetBuffer.web.ts    fetch() instead of readAsStringAsync
kv.ts            →  kv.web.ts             localStorage instead of a JSON file
renderStore.ts   →  renderStore.web.ts    object URLs instead of a cache directory

Nothing importing them knows which one it got. That is the payoff for the narrow interface in Part II.

A blank page, caused by a library I never call

The web bundle died at parse time on import.meta — no stack, no component, nothing rendered at all. The cause was Zustand's ESM middleware build reading import.meta.env.

import.meta is a parse-time SyntaxError in a classic script. Not a runtime error at the import — the parser rejects the file before executing a line of it, and because everything is bundled into one file, rejecting the file rejects the application.

metro.config.js
// zustand ships `devtools` in the same module as `subscribeWithSelector`, and
// its ESM build reads `import.meta.env`. Expo's web resolver prefers that ESM
// build, and `import.meta` is a *parse-time* SyntaxError in a classic script —
// so the try/catch around it in zustand doesn't help and the whole bundle dies.
// The CJS build is identical minus that access, so point web at it.
const CJS_ON_WEB = {
  "zustand/middleware": path.resolve(__dirname, "node_modules/zustand/middleware.js"),
};

The bloom goo

Bloom looked right on device and wrapped the browser stage in a milky grey haze.

The canvas was transparent. With no background drawn, the pixels around the ring had zero alpha — but bloom had still written colour into them. The browser composites the canvas over the page using premultiplied alpha, and colour sitting in zero-alpha pixels has nowhere to go. It smears.

The fix was not to tune the bloom. It was to make the darkness around the ring into real, opaque pixels:

// Camera-pinned backdrop: always behind everything, always filling the
// frame, never tone mapped so it matches the page background exactly.
<mesh ref={ref} renderOrder={-1} frustumCulled={false}>
  <planeGeometry args={[1, 1]} />
  <meshBasicMaterial color={t.bg} toneMapped={false} depthWrite={false} />
</mesh>

toneMapped={false} earns its place. Using scene.background instead puts the colour through the tone mapping curve, so the stage lands a few shades darker than the page around it and the seam shows on every theme.

The cache that could not be cleared

Resizing the browser window destroyed the scene. Not degraded — the gem went black and stayed black.

Three causes, stacked, and fixing any two left the bug intact:

  1. expo-gl does not resize its drawing buffer when the canvas element changes size.
  2. The baked cubemap belongs to the GL context that created it, so it is meaningless to a new renderer.
  3. The bake cache is keyed by geometry in a WeakMap — and a WeakMap cannot be enumerated, so it cannot be cleared.

My clearBakeCache() did nothing whatsoever. The GLTF cache held the geometry alive, the geometry key stayed reachable, and every remount was handed back a bake belonging to a context that no longer existed.

onCreated={() => {
  clearBakeCache();
  clearGltfCache(); // without this the WeakMap keys stay alive and the
                    // stale bake survives the remount
}}

A cache you cannot empty is not a cache. It is a leak with a lookup table.

Above 900px the layout splits into a stage beside its controls, and the stage remounts on a quantised width key rather than on every pixel of a drag — a full rebake per animation frame is not something a GPU forgives.

What happened to AR

The May version of this post had a whole section on expo-three-ar. It is gone.

Two reasons. The user's lighting becomes your scene's lighting, and a gold material tuned under a studio HDR reads brassy under a kitchen bulb — so the thing you are selling looks wrong at exactly the moment you most want it to look right. And it added a native module, a permission prompt and about 6MB of binary for a feature that demoed well and got used rarely. Cutting it is what made a single web build possible.

Part VI — Publishing it without publishing too much

The application is MIT. The gem shader is not. Getting both required being honest about something first:

Anything you ship to a browser, you have shipped. The bundle is on the client, the GLSL is in the bundle, and a determined reader will get it out. No arrangement of files changes that.

So the split follows what each piece actually is:

  • The app — state model, composition, gestures, pricing, cart, the platform splits above — is MIT on GitHub. Take it, sell it, whatever you like.
  • The shader ships as lumiere-gem, seven files and about 42 KB, under PolyForm Noncommercial 1.0.0. Use it in anything that is not a commercial product. The readable source stays in a private repository.

The package ships the shader compacted — comments stripped, identifiers renamed, GLSL reassembled at runtime.

In plain English

Obfuscation is not encryption. There is no secret key, because the program has to read its own shader in order to run it — which means anything running the program can read it too.

What it buys is friction. It stops the accidental copy-paste and makes "I did not realise it was licensed" hard to claim. It does not stop anyone who has decided to take it. The license is the protection; the obfuscation is a speed bump with a sign on it.

The build enforces this rather than trusting it. A verification script fails the publish if the tarball contains any file from src/, a sourcemap, recognisable plaintext GLSL, or a missing license banner. A round-trip test decodes the shader back out of the shipped bundle and asserts it still compiles — balanced braces, both stages present, none of the original identifiers. If a refactor ever quietly reintroduces the source, npm publish stops.

What the audit found

Before making the repository public I ran an adversarial review over it — several independent passes, each hunting a different class of problem, each finding checked by something trying to disprove it. The verdict was not safe to publish. It was right twice.

The first finding was the shader source, sitting in a branch I had already deleted it from. It had come back through a later merge. I would never have caught that by looking, because I knew it was gone.

The second is the one worth writing down. The models came out of CAD by way of a commercial jewellery pipeline, and glTF lets any object in a file carry an extras block — free-form JSON that renderers ignore. Those blocks held the original .3dm filenames, the layer tree, vendor material-pack URLs, internal identifiers, and the name and personal email address of the person who had exported the model.

None of it is visible in the app. None of it appears if you grep the source tree. It sits inside binaries that get committed once and never opened again, and it ships to everyone who clones the repository.

Your source tree can be clean while your binaries are not. Grep does not open a GLB.

The models are mine to publish. That person's contact details were not, and I would have published them. The fix walks the JSON chunk of each GLB, deletes every extras object and the node and scene names carrying the external catalogue numbering, normalises the asset block, and rebuilds the file with correct chunk padding.

Material names are deliberately left alone — which is where Part III comes back. The gem-versus-metal classifier matches on the material slot name. A scrubber thorough enough to strip those would have quietly turned every stone in the catalogue into polished steel.

Rewriting history is the wrong tool

My plan had been to filter the shader, the discarded models and the tooling out of the existing history. I abandoned it. Rewriting history is subtractive: you are never certain you enumerated everything, and the dangerous content is precisely the content you forgot about.

So the public repository was rebuilt from the final state instead — fourteen commits, each staging a disjoint slice of the audited tree, replayed with the original authorship dates so the timeline stays honest. Nothing is ever read from the old history, so nothing from it can come back.

The difference is that the result is checkable. The public repository's object database is 222 objects, and every one of them can be scanned:

intersectEllipsoid                 -> 0
getRefractionColor                 -> 0
diamondShader.source               -> 0
[vendor + CAD provenance markers]  -> 0

The same scan against the original repository still returns hits on all of them. Which is why that one is private, permanently. Deleting a file does not remove it from history, and a private repository is the only reliable containment for history you cannot fully vouch for.

What is in the repository

The whole application, not a teaching subset of it:

  • A production @react-three/fiber/native app that holds frame rate on mid-tier phones, and the same code running in a browser.
  • The bespoke engine: 4 heads × 5 shanks, 6 metals, 6 stone types, 4 carat weights and 4 US ring sizes, swapping live without remounting the scene.
  • The gem integration — per-mesh materials, the geometry-keyed bake cache, and the environment pipeline that feeds them. The shader itself arrives as a dependency.
  • A Zustand store built for imperative consumers, so gesture worklets and the render loop can read it without a re-render.
  • The practical parts that are usually left out: loading GLBs on device, caching parses, snapshotting the GL view for cart thumbnails, and the three platform splits that make one codebase serve native and web.
  • A complete, small commerce flow — catalogue, live editor, cart with captured thumbnails, checkout.

Clone it and it runs:

npm install
npx expo start        # press i, a, or w

Where to look first

  • src/store/ring.ts — the entire state model, and the comments explaining why carat and ring size scale the way they do.
  • src/components/Ring.tsx — composition, the gem/metal classifier from Part III, and the geometry re-centring the bake depends on.
  • src/lib/bespokeBase.ts and materials.ts — how a head and a shank become one ring, and how metals are defined.
  • src/components/RingViewer.tsx — the gesture overlay, and why it sits above the canvas rather than on it.
  • src/lib/assetBuffer.web.ts and metro.config.js — the platform split, and the resolver note about import.meta from Part V.

Fork it for a jewellery configurator, lift the loading and caching patterns into something unrelated, or read it as a non-trivial react-three-fiber codebase that went through EAS and real devices. The application is MIT; only the gem shader carries the noncommercial terms, and it is a dependency you can swap out.

Where it runs

The web build is a static export — about 18 MB, of which 12 MB is models and environment maps and 6.13 MB is the JavaScript bundle. It is served from GitHub Pages, which needs two easily-missed things: baseUrl, because a project site lives under a repository-name prefix rather than at the domain root, and a .nojekyll file, because Pages runs Jekyll by default and Jekyll skips directories beginning with an underscore — precisely where Expo puts the bundle.

The same export also serves a chrome-less build of the renderer at ?embed=1 — the ring, its ground and nothing else — which is what is turning in the tile on my home page. A single-page export has no router to add a route to, so the mode is read off the URL.

It is the same code on a phone. npm install && npx expo start and it is on your device.


  • Live demo — jeanc18rlos.github.io/ring-configurator-expo
  • Source (MIT) — github.com/jeanc18rlos/ring-configurator-expo
  • Gem shader — lumiere-gem on npm
  • How the shader works — Rendering Brilliance

Originally published May 2026 as a build recipe; rewritten and merged with the release notes in August 2026, once the project had actually shipped.

Comments

Tags in this post

  • #expo
  • #react-native
  • #three.js
  • #r3f
  • #webgl
  • #shaders
  • #open-source
  • #licensing

Keep reading

  • Rendering Brilliance

    A visual tour of the cubemap-based diamond shader — how a faceted gemstone becomes a single texture lookup, and what that gets you. Eight interactive figures, plain-English asides, and the optics that hold it all together.

    15 min · May 26, 2026

  • Carnegie Hall, rebuilt from a seating chart

    How I turned a flat ticketing map into a complete Stern Auditorium: 2,758 seats, four tiers of gilded parapets, vaulted ceilings and hundreds of real light fixtures. I did it by building tools instead of sculpting.

    33 min · Sep 16, 2026

  • Parsing documents without uploading them

    A procurement pipeline that never leaves the tab: PDF, DOCX and XLSX to editable Markdown, PP-OCRv6 on ONNX Runtime Web inside a module worker, and the canvas shims nobody warns you about.

    12 min · Jul 7, 2026

All tags

  • #ai
  • #blender
  • #cubemap
  • #diamond
  • #environment-art
  • #expo
  • #graphics
  • #houdini
  • #huggingface
  • #image-generation
  • #licensing
  • #mdx
  • #meta
  • #next.js
  • #ocr
  • #onnx
  • #open-source
  • #pdf
  • #procedural
  • #python
  • #r3f
  • #ray-tracing
  • #react-native
  • #rendering
  • #replicate
  • #sam2
  • #segmentation
  • #shaders
  • #technical-art
  • #three.js
  • #vercel
  • #wasm
  • #web-worker
  • #webgl
  • #webgpu
← Back to all articles

Tags in this post

  • #expo
  • #react-native
  • #three.js
  • #r3f
  • #webgl
  • #shaders
  • #open-source
  • #licensing

Keep reading

  • Rendering Brilliance

    A visual tour of the cubemap-based diamond shader — how a faceted gemstone becomes a single texture lookup, and what that gets you. Eight interactive figures, plain-English asides, and the optics that hold it all together.

    15 min · May 26, 2026

  • Carnegie Hall, rebuilt from a seating chart

    How I turned a flat ticketing map into a complete Stern Auditorium: 2,758 seats, four tiers of gilded parapets, vaulted ceilings and hundreds of real light fixtures. I did it by building tools instead of sculpting.

    33 min · Sep 16, 2026

  • Parsing documents without uploading them

    A procurement pipeline that never leaves the tab: PDF, DOCX and XLSX to editable Markdown, PP-OCRv6 on ONNX Runtime Web inside a module worker, and the canvas shims nobody warns you about.

    12 min · Jul 7, 2026

All tags

  • #ai
  • #blender
  • #cubemap
  • #diamond
  • #environment-art
  • #expo
  • #graphics
  • #houdini
  • #huggingface
  • #image-generation
  • #licensing
  • #mdx
  • #meta
  • #next.js
  • #ocr
  • #onnx
  • #open-source
  • #pdf
  • #procedural
  • #python
  • #r3f
  • #ray-tracing
  • #react-native
  • #rendering
  • #replicate
  • #sam2
  • #segmentation
  • #shaders
  • #technical-art
  • #three.js
  • #vercel
  • #wasm
  • #web-worker
  • #webgl
  • #webgpu