KYLE TRAN
PROJECT / WEB

Bottle Study

Web
2026

[01]

Overview

Open the viewer

Bottle Study · Real-Time Product Viewer · Santa Fe Botanicals

Product Engineering · WebGL · 2026

A real-time WebGL product viewer for Santa Fe Botanicals, the DTC skincare brand I founded. One glass bottle, rendered in the browser instead of photographed, shipped to a public URL and built to embed in a Shopify storefront without slowing the page that hosts it.

I built the website and run Santa Fe Botanicals. Visit the store at santafebotanicals.store ↗.

  • RoleSole author: 3D pipeline, code, deploy
  • Stackthree.js r166, WebGL2, glTF, Vercel
  • StatusLive in production
  • ScopeOne asset replacing a photo shoot

Rendered in real time in the browser. Nothing here is a photograph or a pre-rendered video. Drag to rotate; zoom and pan are disabled so the product cannot be framed badly.

[02]

Background

Santa Fe Botanicals sells a cuticle serum in a clear glass bottle with a dropper. Glass is the expensive case in product photography: it reads as a set of reflections rather than a surface, so it needs a controlled light box, and every change to the label or the fill level means booking the shoot again. For a brand running one SKU at a time, the recurring cost is the reshoot rather than the original photograph.

The storefront also needed the product to move. A still image cannot show a dropper bottle from the back, and a video loop is a fixed camera path that goes stale the moment the packaging changes.

I had already modelled the bottle in Blender. The question was whether one 3D asset could replace the photography pipeline outright and still run fast enough to sit on a live storefront.

[03]

Design

The build renders the bottle in the visitor’s own browser with three.js. That moves the cost from a recurring studio booking to a one-off asset plus the code around it, and it makes relighting or swapping a label a file change instead of a shoot.

It also creates a real constraint. A storefront cannot afford a heavy 3D scene on the critical path, so the viewer had to be built to an explicit budget: it loads nothing until the canvas is visible, and stops rendering when it is not.

In underwriting terms, this converts a recurring operating cost into a one-off capital cost. The build still has to hold up under real load, which is what the rest of this page measures.

[04]

Constraints

The budget the viewer was built against
ConstraintWhat it forcedMeasured
Model weightThe glTF binary can never sit on the storefront’s critical path8.4 MB
Environment mapStudio HDR deferred behind a procedural fallback so frame one still has reflections1.6 MB
Fetched before first viewAn embed that is never scrolled to costs the host page the HTML shell and nothing else0 bytes
Pixel ratio ceilingCapped at 1.25 instead of the device value, roughly 30% fewer fragments than 1.51.25×
GPU selectionNo powerPreference hint, so dual-GPU laptops are not forced onto the discrete cardiGPU
Meshes drawnEverything else in the export is stripped at load: studio scenery, duplicates, oversized geometry6

Source: deployed build, measured from the production URL.

[05]

Implementation

01 Pausing the loop when the canvas is off-screen

Three independent signals gate the render loop: the document’s own visibility, an IntersectionObserver on the canvas, and a postMessage channel the host storefront can use to say the iframe has scrolled out. All three must agree before a frame is drawn.

02 Promoting the materials after import

The loader walks every material and promotes smooth dielectrics to a physical material with transmission, index of refraction and thickness, so light bends through the bottle instead of reflecting off it. Anisotropic filtering and correct colour space go on every texture.

03 Keeping the label legible

ACES filmic tone mapping improves the glass and washes out the label type. The label material opts out of tone mapping, drops its paper-grain normal map and takes no environment contribution, so the brand mark keeps the contrast it was designed with.

The visibility gate

// all three must agree before a frame is drawn
const isActive = () =>
  docVisible && tabVisible && inViewport;

new IntersectionObserver((entries) => {
  inViewport = entries[0].isIntersecting;
  if (isActive()) start(); else stop();
}, { threshold: 0.01 }).observe(canvas);

function stop() {
  running = false;
  renderer.setAnimationLoop(null);
}

Why setAnimationLoop(null) and not an early return. A guard inside the tick still costs a callback every frame, on the same main thread as the storefront it is embedded in. Unscheduling the loop means the browser stops calling the viewer at all, so an off-screen embed contributes nothing to the host page’s frame budget.

The same gate defers the 8.4 MB model and the HDR. A visitor who never scrolls to the viewer never pays for it.

Keeping the brand mark legible

if (/label/i.test(mesh.name)) {
  mat.normalMap       = null;  // drop paper grain
  mat.roughness       = 0.85;  // matte stock
  mat.envMapIntensity = 0.0;   // no softbox glare
  mat.toneMapped      = false; // bypass ACES curve
  mat.color.multiplyScalar(0.92);
  mat.needsUpdate     = true;
}

The label needed different treatment from the glass. ACES filmic tone mapping rolls off the highlights, which is exactly what makes the glass read as glass and exactly what washes the label’s type toward the background. Rather than compromise both, the label is excluded from the curve and lit separately.

The liquid ribbons get the opposite treatment: a physical material with iridescence and chromatic dispersion, drawn before the bottle so the transmission pass samples a frame that already contains them.

[06]

Deliverables

31 KBHTML shell, the entire cost to a storefront page that never scrolls to the embed
1.25×Device pixel ratio ceiling, chosen over 1.5 for roughly 30% fewer fragments
3Camera breakpoints, each with its own field of view and distance, so the bottle frames correctly on a phone
0Build dependencies. Plain ES modules and an import map, deployed as static files

The viewer runs at studio-bottle-deploy.vercel.app and respects prefers-reduced-motion. It also exposes a pause API, so the storefront can tell the iframe it has scrolled out of view rather than leaving it to guess.

[07]

Applications

This is not real estate work. It is here because the discipline is the same one I bring to underwriting tooling, and it is easier to verify: the URL is public, the source is readable, and every number on this page can be measured from the deployed build.

The pattern repeats in the work that is CRE. At Rexford Industrial I reconciled 269 underwriting workbooks and 193 capital approval records into a single reporting database under the same rule that governs this viewer: state the budget first, then measure against it. At MRK Partners the T12 automation and Power BI dashboards behind LP reporting were built the same way.

In practice that means I can build the tool myself instead of waiting on an engineering queue.

All projects Contact