Bottle Study
Web
2026
Overview
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.
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.
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.
Constraints
| Constraint | What it forced | Measured |
|---|---|---|
| Model weight | The glTF binary can never sit on the storefront’s critical path | 8.4 MB |
| Environment map | Studio HDR deferred behind a procedural fallback so frame one still has reflections | 1.6 MB |
| Fetched before first view | An embed that is never scrolled to costs the host page the HTML shell and nothing else | 0 bytes |
| Pixel ratio ceiling | Capped at 1.25 instead of the device value, roughly 30% fewer fragments than 1.5 | 1.25× |
| GPU selection | No powerPreference hint, so dual-GPU laptops are not forced onto the discrete card | iGPU |
| Meshes drawn | Everything else in the export is stripped at load: studio scenery, duplicates, oversized geometry | 6 |
Source: deployed build, measured from the production URL.
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.
Deliverables
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.
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.