Three.js From Zero · Article s11-01

Three.js Product Configurator from Scratch

Three.js Product Configurator from Scratch is Article s11-01 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

Season 11 · Article 01 · Configurator

From an empty <div> to a working product configurator with material-slot binding, URL share state, IBL via PMREM, and a UI panel. The architecture every retailer's $30k-$200k 3D viewer is built on.

~30 min read configurator PMREM IBL URL state

1. What we're building (and why this article exists)

Every retailer with more than a few million dollars in annual revenue is now either shipping a 3D configurator or budgeting for one. The market is roughly $1.5B today, projected ~$8B by 2030, dominated by Three.js. Threekit, Roomle, Cylindo, Hapticmedia at the platform tier; Lusion, Active Theory, Demodern, Bureaux at the agency tier. Every flagship build (Polestar, BMW, Cartier, Nike By You, IKEA Kreativ) is a Three.js scene wired to a UI layer.

What's strangely missing from the tutorial market is the full architecture. There are tutorials about loading a glTF, tutorials about swapping a color, tutorials about OrbitControls. There are no tutorials that say: here's mounting, scene setup, options state, material-slot binding, URL share, IBL, and a UI panel — together — in one file. This article is that tutorial.

By the end you'll have a working configurator: pick a color, pick a material finish, copy the URL, paste it in another tab, and the scene rebuilds with your selections. That's the spine of every production configurator.

2. The architecture in one picture

┌────────────────────────────────────────────────────┐
│  store (Zustand-flavored vanilla, see S8-01)        │
│   { color, finish, ... }                            │
└────────┬───────────────────────────────┬────────────┘
         │  subscribe                    │  setState
         ▼                               ▲
┌────────────────┐                ┌──────┴──────┐
│  Three scene    │                │  UI panel   │
│  - meshes       │                │  - swatches │
│  - materials    │                │  - selects  │
│  - cameras      │                │  - share btn│
└────────┬────────┘                └─────────────┘
         │  bound to material slots
         ▼
   GPU draw at 60fps

This is the S8-01 split applied to a configurator. The store is the only source of truth. The scene reads from it. The UI writes to it. URL state is just a serialized snapshot. Once you internalize this split, every other configurator pattern (rule engines, AR, analytics, persistent carts) bolts on cleanly.

Why the split matters

  • Testable. You can unit-test the store without a GPU.
  • Shareable. Serialize the store, you have a share URL.
  • Server-renderable. The same store hands off to a headless renderer for catalog stills (S6-08 territory; the Threekit pattern).
  • Replayable. Analytics records a sequence of setState calls; you can replay exactly what a user did.

3. Mounting and the render loop

Real configurators almost always mount into a host <div>, not full-screen. Use ResizeObserver — the host can resize from CSS, drawer-open animations, or device rotation, and you want a clean DPR-aware response.

const host = document.getElementById('viewer');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(host.clientWidth, host.clientHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
host.appendChild(renderer.domElement);

new ResizeObserver(() => {
  const w = host.clientWidth, h = host.clientHeight;
  renderer.setSize(w, h);
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
}).observe(host);

ACES Filmic tone mapping is the industry default — every flagship configurator (Polestar, BMW, Cartier) ships with it on. You almost never want the linear default for a product render.

4. The store (Zustand-flavored vanilla)

You don't need React or a 200KB state library for this. A 30-line vanilla store with subscribe / getState / setState covers 95% of configurators. Inspired by Zustand, written with no dependencies.

function createStore(initial) {
  let state = initial;
  const listeners = new Set();
  return {
    getState: () => state,
    setState: (patch) => {
      state = { ...state, ...patch };
      listeners.forEach(fn => fn(state));
    },
    subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
  };
}

const store = createStore({
  color: '#10b981',
  finish: 'matte',
  cushion: '#f4d8b6',
});

Two virtues: every state change notifies subscribers, and the entire state is a plain JSON object — which means we can serialize it for URL share for free in §6.

5. Binding store state to material slots

The technical heart of every configurator is material-slot binding. The Polestar / BMW / Cartier pattern: one mesh, multiple named material slots, swap material properties when state changes. You don't reload the model. You don't rebuild the scene graph. You poke a property on a MeshPhysicalMaterial and Three.js does the rest.

// In real builds, slots come from glTF userData or named primitives.
// Here we build them by hand for clarity.
const slots = {
  body:    new THREE.MeshPhysicalMaterial({ color: 0x10b981, roughness: 0.5, metalness: 0.0 }),
  cushion: new THREE.MeshPhysicalMaterial({ color: 0xf4d8b6, roughness: 0.85 }),
};

// Bind: store change → material update
store.subscribe((s) => {
  slots.body.color.set(s.color);
  slots.cushion.color.set(s.cushion);
  if (s.finish === 'matte')   { slots.body.roughness = 0.85; slots.body.metalness = 0.0; slots.body.clearcoat = 0.0; }
  if (s.finish === 'glossy')  { slots.body.roughness = 0.18; slots.body.metalness = 0.05; slots.body.clearcoat = 1.0; }
  slots.body.needsUpdate = true;
});

Notice needsUpdate = true. Three.js caches shader programs; for properties that change a uniform you don't need it, but if you toggle features that affect defines (like flipping clearcoat on for the first time), you do. Setting it always is a safe default.

In production, you'll load a glTF and walk scene.traverse to collect named meshes. The named slots become a map. The same binding pattern applies — only the meshes are loaded instead of authored.

6. URL state encode and decode

The "share my chair" button is what sells. It also auto-bookmarks the user's progress, which lifts conversion. Two encodings are common:

  1. Query params per option: ?color=10b981&finish=matte. Human-readable. Best for SEO and analytics.
  2. Base64 JSON blob: ?s=eyJjb2xvciI6Ii4uLn0. Compact, opaque, evolves as the schema grows.

Use option 1 until the URL gets ugly (~6+ options), then switch. Both are 10-line implementations:

function encodeURL(state) {
  const url = new URL(location.href);
  Object.entries(state).forEach(([k, v]) => url.searchParams.set(k, v));
  return url.toString();
}
function decodeURL() {
  const sp = new URLSearchParams(location.search);
  const out = {};
  for (const [k, v] of sp) out[k] = v;
  return out;
}
// On boot:
store.setState(decodeURL());
// On share:
navigator.clipboard.writeText(encodeURL(store.getState()));

7. IBL via PMREM (the production lighting setup)

A "Studio" HDRI piped through PMREMGenerator is the universal product-render lighting. Polyhaven's "studio_small" series, "photo_studio_loft", or any RoomEnvironment built-in are the typical defaults. We'll use RoomEnvironment here so the demo runs offline; in production you'd load an HDR with RGBELoader or EXRLoader and feed it the same way.

import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(renderer), 0.04).texture;

That single texture lights every PBR material in the scene with a real-feeling bounce. Set scene.background = scene.environment if you want the room visible; leave it null for a neutral product shot.

8. Live demo — a configurable chair

A primitive "chair" composed from boxes and a cylinder. Three swatches drive body color, two finishes drive material, two cushion swatches drive the seat. Share copies the URL. Reset orbits back to the hero camera.

–
copied

9. Camera reset (the hero camera)

Production configurators always have a "hero camera" — the angle the marketing team approves. OrbitControls lets the user wander, but they should be able to snap back. We tween camera.position and controls.target over ~600ms with an easing curve.

function resetCamera(targetPos, lookAt, ms = 600) {
  const start = camera.position.clone();
  const startTarget = controls.target.clone();
  const t0 = performance.now();
  function tween(now) {
    const t = Math.min(1, (now - t0) / ms);
    const e = 1 - Math.pow(1 - t, 3); // ease-out cubic
    camera.position.lerpVectors(start, targetPos, e);
    controls.target.lerpVectors(startTarget, lookAt, e);
    controls.update();
    if (t < 1) requestAnimationFrame(tween);
  }
  requestAnimationFrame(tween);
}

10. What's in the box vs. what's still ahead

ConcernThis articleFuture S11 articles
Mounting + render loop✓—
Store split (S8-01)✓—
Material slot binding✓S11-04 watch industry deep dive
URL share state✓—
IBL / PMREM✓S6-02 (assets), S11-04 (luxury)
Polished UI / a11ypartialS11-02 picker, S11-08 finale
AR pipeline—S11-03
glTF asset pipeline—S6 series
Variant rule engine—S11-06 modular furniture
Shopify integration—S11-07
Snap-points / parametric—S11-06

11. Production references

If you're building a configurator for a real client, study these in this order:

  • Threekit — enterprise SaaS playbook. Their headless render + variant rule engine are the gold standard.
  • Roomle — modular furniture / kitchens; best parametric snap-volume implementation in the wild.
  • Polestar 4 / Polestar Precept (Lusion) — flagship art direction. HDRI-baked stage geometry is the trick.
  • BMW Build Your Own (Demodern) — single mesh, swap material slots only. Same pattern as §5 above, scaled.
  • Google's <model-viewer> — long-tail "drop-in" deployment. Worth knowing where its ceiling is so you know when to graduate to custom Three.js.

12. Takeaways

  • Store / scene split is the spine. Every other configurator concern hangs off it.
  • Material-slot binding is the technical heart. One mesh, swap properties on shared materials.
  • URL state is the conversion lever. Share buttons and bookmarkable URLs.
  • PMREM IBL lights every PBR material for free with one line.
  • ResizeObserver + DPR cap is the only mounting pattern that survives drawers, phones, and rotations.
  • Architect for slots first, ship more slots later. The hard work is the wiring, not the meshes.