Building a Three.js Product Viewer That Actually Ships

A founder-voice technical guide to building a Three.js product viewer for premium ecommerce: tone mapping, lighting, depth, and the four parts most tutorials skip.

O
Oeave Team
April 29, 2026
7 min read
Building a Three.js Product Viewer That Actually Ships

If you have followed a Three.js tutorial to add a 3D product viewer to a Shopify store and the result felt nothing like the demo, the cause is almost always the parts the tutorial skipped. Three.js is a low-level library; the four pieces that turn it into a production viewer are not in any single tutorial. This guide covers what a shippable Three.js product viewer needs and the order to build it.

What does a Three.js product viewer need to actually ship?

A production Three.js product viewer needs four parts that most tutorials gloss over. A tone-mapped renderer set to ACES Filmic with exposure around 1.5 so the materials read close to professional product photography. Studio-style lighting that matches the photography brief for the catalog, not a generic environment map pulled from a sample. Depth isolation so 2D overlays (price chips, hotspots, captions) render cleanly on top of the model without z-fighting. A single GLB loader that streams the model from a CDN with Draco mesh compression and lazy texture upload so the buyer sees something in under 2 seconds on LTE. Skip any one of those and the viewer looks broken on a real product page next to professional photography.

The library itself is at threejs.org. The four parts above are not optional add-ons; they are the difference between a demo and a viewer that converts.

Tone mapping is the visual baseline

A Three.js scene rendered with default settings looks washed out next to product photography. The fix is two settings on the renderer:

renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.5;
renderer.outputColorSpace = THREE.SRGBColorSpace;

ACES Filmic is the same tone curve most film cameras and modern photo editors use. Exposure around 1.5 brings the rendered output close to a daylight-balanced product photo. SRGB output color space ensures the colors are correct when the browser composites the canvas onto the page.

A viewer without these three settings reads as flat next to the hero photo. The buyer sees the inconsistency before they see the product.

Studio lighting is the second baseline

The default Three.js scene has no lighting. Most tutorials add an AmbientLight and a DirectionalLight and call it done. The result looks like a 3D model under fluorescent office lighting, which is the wrong baseline for a product page.

The lighting setup that matches professional product photography:

  • Two soft area lights at 45 degrees to the front of the model, with intensity around 1.5 each. These create the soft shadows and material highlights buyers expect.
  • A subtle fill light from above, intensity around 0.5, to lift the top of the model.
  • A low-intensity rim light opposite the camera, intensity around 0.3, to separate the model from the background.
  • An environment map (HDR) for material reflections. Use a studio environment, not an outdoor one. The Three.js examples include a few studio HDRs that work as starting points.

This setup matches the lighting most premium DTC photography uses. The 3D viewer next to the photo reads consistent.

Depth isolation matters more than tutorials say

A 3D viewer next to overlay UI (price chips, "see in your space" buttons, hotspot labels) has to render the overlays on top of the model without depth conflicts. Without explicit depth handling, the overlays z-fight with the model surface; sometimes they show, sometimes they vanish.

The fix:

  • Render the model first with a known render order (e.g., renderOrder = -1).
  • Render a depth-clearing mesh between the model and the overlays. This mesh has colorWrite = false, depthWrite = true, depthTest = false. It clears the depth buffer so the next layer renders on top regardless of position.
  • Render the overlays last with renderOrder = 0 and depthTest = true against the cleared depth.

Most tutorials skip this entirely because the demo scene has no overlays. Production viewers always have overlays.

The GLB loader and what to compress

The model loader is the part of the viewer that decides whether the buyer waits 2 seconds or 9. A naive GLTFLoader call without compression streams a full GLB at the buyer.

The setup that ships:

  • Draco mesh compression. The official glTF spec includes the Draco extension. Configure the GLTFLoader with a DRACOLoader pointing at the Draco decoder hosted on a CDN.
  • KTX2 textures. Replace JPEG and PNG textures with KTX2 (Basis Universal) compressed textures. Most modern phones support them; the file size drop is significant.
  • CDN delivery. Serve the GLB and the textures from a CDN with HTTP/2 and proper cache headers, not from your origin server. The buyer's first byte arrives faster.
  • Lazy load. The viewer code loads on first paint, but the GLB only loads when the buyer scrolls near the viewer or taps a "rotate" affordance. Google's LCP target is 2.5 seconds for a "good" page; the viewer should not block that.

A production viewer with these four settings ships a 2 to 4 MB total payload (code plus model) that loads in under 2 seconds on LTE.

Why custom controls beat OrbitControls

OrbitControls is the default camera controller in the Three.js examples. It works for an editor where the developer is moving the camera around a scene. It does not work for a buyer-facing viewer because:

  • It allows vertical orbit beyond the natural range, so the buyer flips the model upside down.
  • The zoom is unconstrained, so the buyer zooms inside the model.
  • The pan is enabled by default, so a stray two-finger gesture moves the camera off the model.

A buyer-facing controller should:

  • Constrain vertical rotation to roughly -30 to +30 degrees from the natural front of the product.
  • Constrain horizontal rotation to a full 360 only if the back of the product is interesting; otherwise limit to 180.
  • Lock vertical and horizontal pan entirely.
  • Damp the wheel zoom so the buyer does not jump from far to inside the model in one scroll.
  • Auto-rotate slowly when the buyer is idle, then pause on first interaction.

This is 30 to 50 lines of code in addition to or replacing OrbitControls. It is the difference between a viewer that feels professional and one that frustrates the buyer.

What to skip in v1

Tutorials often pile on features that ship slowly. The features to defer:

  • Real-time shadows. Cast shadows on a ground plane look great in demos, eat frame budget on mobile. Use a baked AO ground texture instead.
  • Post-processing effects. Bloom, depth of field, screen-space ambient occlusion all add complexity. Skip them in v1.
  • Multiple cameras and transitions. A buyer-facing viewer needs one camera and smooth controls. Save multi-camera flows for hotspot tours, which come after v1.
  • Custom shaders. Standard PBR materials with the lighting and tone mapping above cover 95% of premium product needs. Custom shaders are a v2 problem.

Where to start

If you have a Three.js viewer prototype that does not look like the demos, walk the four-part checklist: tone mapping, studio lighting, depth isolation, GLB compression. Most prototypes are missing two or three of these.

If you have not started yet and the timeline matters more than the customization, ship Google's <model-viewer> web component first. It handles the four parts above out of the box for the rotate-and-AR use case. Move to Three.js when you need the rest of the page to react to what the buyer does in the viewer.

The three ways to embed a 3D model covers the choice between model-viewer, an iframe app, and a custom Three.js component. The WebGL product page performance guide covers the runtime budget the viewer has to fit inside.

The buyer is on a phone. The viewer either looks like a professional product photo with rotation, or it does not. The four parts above are the difference.