cf.completefrontend
My progress
SYSTEM DESIGN · #139

Design a 3D product configurator

The 3D viewer on a product page where shoppers spin a sneaker, sofa or car and change its parts and materials, like Nike By You, Tesla's configurator or Google's model-viewer. The hard parts are an asset pipeline that turns a 50 MB CAD export into a few megabytes of glTF, loading 3D without hurting the page, rendering only when something changes, a product schema with rules and prices, and AR and accessibility for a canvas that screen readers cannot see.

The question

Design the 3D configurator on a product page. A shopper sees a sofa they can rotate and zoom, picks the frame, the fabric, the leg style and the cushion count, sees the price change, shares the exact configuration with a link, adds it to the cart, and on a phone can place it in their living room with AR.

Walk me through how the 3D assets get to the browser, how you load them without hurting the product page, how you model the options and their rules, and what happens on a cheap Android phone and for a shopper using a screen reader.

Real-life examples: Nike By You, Tesla's car configurator, IKEA and furniture sofa configurators, Apple AR Quick Look on product pages, Shopify 3D models, Google's model-viewer web component

Requirements: a showroom inside a product page

A 3D configurator replaces the showroom. In a store you walk around a sofa, touch the fabric, and ask for it in green. On the web the shopper drags to rotate the sofa, taps a fabric swatch, and watches the sofa change. Nike By You lets shoppers design sneakers, Tesla lets them build a car, furniture brands do the same for sofas, and Shopify lets any merchant add a 3D model to a product page.

It is a component, not a whole app: it lives inside a product page that already exists. That page has its own job (sell the product, rank in search, load fast), so most of our decisions are about not getting in its way. The page itself, variants, reviews and the cart are covered in the amazon and shopping-cart guides, and 2D photos, swiping and zoom in image-carousel and image-lightbox. We link them and focus on what only 3D adds.

We also stay away from ground other guides own: drawing and hit testing on a 2D canvas is in figma and excalidraw, WebGL image processing is in photo-editor, and WebGL map tiles are in google-maps. Here the GPU draws a lit, textured 3D object, and the questions are about bytes, memory, battery and rules.

Questions to ask

QuestionAnswer we assumeWhat it changes
How many products have 3D?About 2,000 sofas and chairs, each with 5 to 30 options. New ones weekly.Assets must come from a pipeline, never hand tuned per product.
What can change?Fabric and color (materials), leg style and arm style (different shapes), cushion count (parts added or removed).Some changes swap a material, some swap a mesh. They cost very different amounts.
Are there rules?Yes. Velvet only comes in 6 colors, the chaise only fits the 3-seat frame, metal legs cost extra.We need a schema with constraints, not a flat list of swatches.
Which devices?About 70% of traffic is mobile; many are mid-range Android phones on 4G.Download and GPU memory budgets are set by a mid-range phone, not a MacBook.
Is the product page SEO critical?Yes. It ranks for the product name and must keep good Core Web Vitals.The LCP element must be an image or text, and 3D must never block the first render.
AR?Yes, on phones that support it: place the configured sofa in the room.We need GLB for Android and USDZ for iOS, for every configuration.
What does the cart and email show?An image of the exact configuration the shopper built.We need a snapshot renderer, and a configuration that can be stored and replayed.
Who makes the 3D models?A 3D team exports from CAD and Blender. Files arrive at 20 to 200 MB.Compression, texture resizing and LOD are a build step, not a runtime step.

Functional requirements

  • Show a product image right away, then a 3D view the shopper can rotate, zoom and (sometimes) pan with mouse, touch and keyboard.
  • Show option groups (frame, fabric, color, legs, cushions) as swatches and choices. Picking one updates the 3D view, the price and the summary text.
  • Enforce rules: options that do not fit the current choices are disabled with a reason, never silently allowed.
  • Hotspots: small labelled dots on the model ("Removable covers", "Solid oak legs") that stay attached to the right spot as it rotates.
  • Share: the URL always holds the configuration, so a copied link opens the same sofa.
  • Add to cart with the exact configuration, a SKU or build code, the price, and a picture of it.
  • View in AR on supported phones, showing the configured sofa at real size.
  • Work without 3D: on old devices, without WebGL2, or when 3D fails, the shopper can still configure and buy with images.

Non-functional requirements

  • Page speed first: the product page keeps LCP under 2.5 seconds on a mid-range phone on 4G. The poster image is the LCP element, not the canvas.
  • 3D within a few seconds: an interactive model within about 3 seconds of the shopper asking for it on 4G, with a low-detail model sooner.
  • Download budget: under about 5 MB for the first 3D view (runtime plus model plus textures), an estimate we justify in the asset chapter.
  • GPU memory budget: under about 150 MB of textures and buffers on phones (an estimate, chosen to leave room on a 4 GB device).
  • Option change feels instant: a material change shows within 100 ms; a mesh change within about 500 ms, with a visible loading hint if slower.
  • Battery: no rendering at 60 fps while nothing moves. A shopper reading the reviews should not drain their phone.
  • Accessible: every option is a real form control, the current configuration is described in text, and nothing is shown only in 3D.

Out of scope

  • Room planners where the shopper lays out a whole kitchen. That is an editor with snapping and collision, closer to figma in 3D.
  • Authoring tools for the 3D team. We consume their exports through a pipeline.
  • Photoreal ray tracing in the browser. We aim for good real-time rendering, and use offline renders where photo quality matters.
  • Inventory, pricing engines and checkout. We call them; the shopping-cart and checkout-flow guides cover them.
Wireframe of the product page for the Harlow sofa. On the left, the 3D view shows a poster first, with two hotspot buttons on the model, and below it Reset view and View in AR buttons. On the right, option groups: Frame with 2-seat, 3-seat selected and chaise; Fabric with linen, velvet selected and wool; Color with navy selected, sage, and mustard disabled because velvet does not come in mustard. Below, a summary note says 1,899 dollars, 3-seat, velvet, navy, and an Add to cart button.Product page: Harlow sofa3D view, poster first12hotspots are buttonsReset viewView in ARcamera and AR controlsFrame2-seat3-seatchaiseFabriclinenvelvetwoolColornavysagemustard$1,8993-seat, velvet, navyAdd to cart
The right column is ordinary server-rendered HTML: radio groups, a price and a button. Mustard stays visible but disabled, because velvet does not come in it. It works with no 3D at all. The left column starts as a poster image and becomes 3D later; its hotspots and buttons are real DOM elements over the canvas.

What makes 3D on a product page hard

A photo carousel downloads a few images and lets the browser draw them. A 3D viewer downloads a program (the 3D engine) and a model (geometry and textures), uploads them to the GPU, then draws the whole scene again every time the camera moves. Six properties of the problem drive every later decision.

  • The weight is hidden twice. A model has a download size (bytes on the network) and a GPU size (bytes in video memory), and they are different numbers. A 2 MB JPEG texture of 4096 by 4096 pixels becomes about 85 MB of GPU memory once decoded with mipmaps. Phones run out of the second long before the first.
  • Optional for the sale, but on the most important page. Most shoppers buy from photos. The viewer must never slow the page, hide the price, or break the add to cart button, and if it fails the page must still work.
  • Combinatorial. 4 frames, 40 fabrics, 3 leg styles and 2 cushion counts make 960 combinations. We cannot pre-build 960 models per product, so the browser assembles each build from parts, and the rules about which parts fit together live in data.
  • Continuous drawing on a battery. Games draw 60 frames per second forever. A product page that does this heats the phone while the shopper reads reviews, so the viewer must draw only when something changes.
  • Invisible to assistive technology and search. A canvas is a grid of pixels: a screen reader hears nothing and a search engine sees nothing. Every fact shown in 3D must also exist as text and real controls.
  • Devices vary enormously. The same page runs on a desktop with a dedicated GPU and on a four year old Android phone with 3 GB of RAM, and a few browsers still lack WebGL2. The design needs tiers, not one experience.

The next chapters build the solution one decision at a time. A build pipeline turns raw exports into compressed glTF files at a few detail levels. The page shows a poster image first, loads the 3D engine only when it cannot hurt the page, and renders on demand. A product schema describes parts, options, rules and prices, and the AR files, the cart snapshot and the accessible text all read the same configuration, which lives in the URL.

Think first

Why not skip 3D and render a photo of every configuration on a server instead?

Check your understanding · Exercise 1

Which answers from the product team would change this design the most? Select all that apply.

The asset pipeline: from a 120 MB export to a 3 MB file

The 3D team exports a sofa from CAD or Blender, and a raw export is huge: 500,000 triangles because CAD keeps every screw, and 4096 by 4096 PNG textures because artists work at full detail. Say 120 MB on disk. Served as is, it takes about 96 seconds on 4G at a realistic 10 Mbps, and then the phone tries to decode the textures into GPU memory and the tab crashes.

So the first design decision is not in the browser at all. It is a build pipeline that runs every time the 3D team publishes a product, and turns the export into small, GPU-friendly files with a fixed budget. Nobody tunes files by hand; with 2,000 products, only automation keeps every file under budget.

The format: glTF 2.0 and GLB

glTF 2.0 is the Khronos standard for sending 3D scenes to a renderer, often called the JPEG of 3D. A glTF file is JSON that describes nodes, meshes, materials and cameras, and points at binary buffers (vertex data) and images (textures). GLB is the same thing packed into one binary file, which is what we serve: one request, no base64. Every web engine (three.js, Babylon.js, model-viewer), Android Scene Viewer and most 3D tools read it.

glTF materials are PBR (physically based rendering): each material has a base color, a metallic value, a roughness value and a normal map, and the renderer works out how light bounces from those. That matters for us because the same fabric looks right under any lighting, so we can mix fabrics and frames freely. glTF also has extensions, and four of them matter here: KHR_draco_mesh_compression and EXT_meshopt_compression for geometry, KHR_texture_basisu for KTX2 textures (explained below), and KHR_materials_variants, which stores several named material sets ("Navy velvet", "Grey linen") in one file.

The asset pipeline. A raw export from the 3D team, 120 MB with 500 thousand triangles, enters a build pipeline that runs in CI. Its steps, top to bottom: simplify and make LODs, resize textures, encode KTX2, compress with Meshopt, and a budget check that fails the publish if the product is over budget. It writes three outputs: GLB files at LOD2, LOD1 and LOD0; a shared fabric library in KTX2; and a poster image with a 360 degree spin. All of them go to a CDN with a one year cache.Raw export120 MB500k triangles,from the 3D teamBuild pipeline (CI)simplify, LODsresize texturesKTX2 encodeMeshoptbudget checkover budget: failGLB: LOD2, 1, 0Fabric libraryPoster, 360 spinCDN, 1 year cachepublish
Every product goes through the same steps, so no file reaches shoppers unless it passed the budget check. Outputs carry a version in their URL, so the CDN can cache them for a year with Cache-Control: immutable.

The hidden cost: textures in GPU memory

A JPEG is small on the network because it is compressed for storage, but the GPU cannot sample a JPEG. The browser decodes it to raw pixels, 4 bytes per pixel in RGBA, and uploads those. A 4096 by 4096 texture is 16,777,216 pixels, so 64 MB (MiB, to be exact).

Mipmaps grow it by a third, to about 85 MB. They are smaller copies at half, quarter and so on that the GPU samples when the object is far away, to avoid shimmering. A PBR material usually has three textures: base color, normal, and a packed occlusion, roughness and metallic map. That is about 256 MB for one material at 4K, more than many phones will give a tab.

GPU compressed formats fix this: the GPU reads them directly, still compressed, in blocks of 4 by 4 pixels. ASTC 4x4 and BC7 use 16 bytes per block (1 byte per pixel); ETC1 and BC1 use 8 bytes per block (half a byte per pixel). GPU families support different formats, though: phones mostly ASTC and ETC2, desktops mostly BC. KTX2 (a Khronos texture container) with Basis Universal solves that: we ship one file, and a small decoder in the browser transcodes it to a format the GPU supports, never going through raw RGBA.

Decision Which texture format should the pipeline produce?

JPEG, PNG or WebP

Resize to 2048, encode as WebP or JPEG (PNG for normal maps). The browser decodes and uploads RGBA8.

Pros

  • Smallest download for photographic base color maps.
  • No decoder to ship; every tool reads them.

Cons

  • 4 bytes per pixel on the GPU: a 2048 map is about 21 MB with mipmaps.
  • Decoding happens on the main thread or in the image decoder, and upload can stall a frame.
  • Lossy JPEG artifacts in normal maps look like bumps.

KTX2 with ETC1S (Basis Universal)

Encode with ETC1S, a low-bitrate mode. In the browser KTX2Loader transcodes it to ETC1, BC1 or similar at about 0.5 byte per pixel (twice that if the map needs alpha).

Pros

  • Small download, often similar to JPEG.
  • 8 times less GPU memory than RGBA8: a 2048 map is about 2.7 MB with mipmaps.

Cons

  • Visible blockiness on fine detail and on normal maps.
  • Needs the Basis transcoder (WebAssembly) loaded once.

KTX2 with UASTC (Basis Universal)

Encode with UASTC, a high-quality mode, then Zstandard supercompression. It transcodes to ASTC 4x4 or BC7 at 1 byte per pixel.

Pros

  • Near-original quality, good for normal maps and close-up fabric weave.
  • 4 times less GPU memory than RGBA8.

Cons

  • Larger download than ETC1S, often 2 to 4 times.
  • Same transcoder cost.

What we pick: KTX2 for everything, mixed by map type: UASTC for normal maps and the hero fabric close-up, ETC1S for base color and the packed occlusion, roughness and metallic map. GPU memory, not download size, is what crashes phones, and only GPU formats cut it. We keep a WebP set only for the snapshot renderer and as a fallback for the rare browser where the transcoder fails.

Computed: width times height times bytes per pixel, times 4/3 for mipmaps, in MiB. Download sizes vary by content; these are memory sizes, which do not.
One textureRGBA8 (JPEG or PNG decoded)1 byte per pixel (ASTC 4x4, BC7)Half a byte per pixel (ETC1, BC1)
4096 by 4096, no mipmaps64 MB16 MB8 MB
4096 by 4096, with mipmapsabout 85 MBabout 21 MBabout 11 MB
2048 by 2048, with mipmapsabout 21 MBabout 5.3 MBabout 2.7 MB
1024 by 1024, with mipmapsabout 5.3 MBabout 1.3 MBabout 0.7 MB

Geometry: simplify first, then compress

Geometry has the same two costs. Each vertex holds a position, a normal, texture coordinates and often a tangent: about 48 bytes as plain floats. A 150,000 triangle sofa has roughly 80,000 vertices, so about 4 MB of vertices plus about 1.8 MB of indices (three 4 byte indices per triangle).

The pipeline does three things. It simplifies, removing triangles nobody can see at product-page distance, like screw threads. It quantizes, storing positions and normals as 16 bit or 8 bit integers instead of 32 bit floats, which glTF allows through KHR_mesh_quantization. Then it compresses with one of two extensions.

GeometryHow it worksDownloadDecode in the browser
None (quantized only)Smaller integer types, no codec.About half of raw floats.Free: the data goes to the GPU as is.
Draco (KHR_draco_mesh_compression)Encodes the mesh connectivity and predicts vertex values. Very high ratios.Often the smallest file.Needs a WebAssembly decoder: about 250 KB, 75 KB gzipped, in three.js r186. Decoding a big mesh can take tens to hundreds of ms on a phone (estimate).
Meshopt (EXT_meshopt_compression)Reorders and filters vertex and index buffers so they compress well, then a light codec. Pairs with gzip or Brotli from the CDN.Close to Draco once the CDN compresses it.Tiny decoder (about 30 KB, 8 KB gzipped) and very fast, typically well under Draco's cost. Also works for animation and morph data.

We pick Meshopt: the download is close to Draco, the decoder is a fraction of the size, and decoding is fast enough to do on the main thread for small meshes or in a worker for large ones. Draco stays a valid choice when every byte matters and the device is fast. A file that uses either extension lists it as required, so the loader must have the matching decoder configured (setDRACOLoader or setMeshoptDecoder on three.js GLTFLoader), or loading fails.

Levels of detail and a budget per product

The pipeline writes each product at several levels of detail (LOD): LOD2 at about 10,000 triangles with 512 textures for the first view, LOD1 at about 40,000 triangles with 1024 textures, and LOD0 at about 150,000 triangles with 2048 textures for desktop and close zoom. Fabrics are separate, tiling material textures (a 1024 weave repeated across the cushion) shared by every product that offers that fabric, so 40 fabrics do not mean 40 copies per sofa.

The pipeline fails the publish if a product goes over budget. The first-view budget is an estimate built from parts, all compressed: about 200 KB of 3D runtime and viewer code, about 270 KB of decoders (mostly the Basis transcoder), about 1 MB for LOD2 with its textures, and about 0.5 MB for the default fabric. That is around 2 MB, leaving room under the 5 MB ceiling for LOD1, while GPU memory for the full LOD0 view must stay under about 150 MB. The lab shows how each choice moves the numbers.

Texture size
Textures
Geometry
Detail

Download 15 MB, GPU memory 133 MB, first view on 4G 12 seconds. Over the download budget for a first view.

Budget for a sofa with 6 textures at 2048 pixels as JPEG and PNG, geometry none, 150,000 triangles. Download 15 MB against a 5 MB budget, GPU memory 133 MB against 150 MB, first view on 4G in 12 seconds against 3. Over the download budget for a first view.download15 MBGPU memory133 MB4G first view12 sdashed line: budgetOver the download budget for a first view.
Estimates: 10 Mbps 4G, 6 texture maps (two materials) at one size and format, 48 bytes per vertex before compression, gzipped decoder sizes from three.js r186, decode speeds approximate. Try 4096 with JPEG and PNG, then KTX2 ETC1S: texture memory drops 8 times, far more than the download does. Draco shrinks the download most but decodes back to full floats, so its GPU size does not shrink.
text
# Per product, in CI, with the glTF Transform CLI (one step per line for clarity)
gltf-transform simplify raw.glb lod0.glb --ratio 0.3 --error 0.001
gltf-transform resize lod0.glb lod0.glb --width 2048 --height 2048
gltf-transform uastc lod0.glb lod0.glb --slots "normalTexture"
gltf-transform etc1s lod0.glb lod0.glb --slots "{baseColorTexture,occlusionTexture,metallicRoughnessTexture}"
gltf-transform meshopt lod0.glb lod0.glb
# Repeat with smaller ratios and sizes for lod1 and lod2, then:
node check-budget.mjs lod2.glb --max-download 1.2MB --max-gpu 40MB
The pipeline is ordinary build tooling. The budget check is ours: it reads the file, sums texture memory by format and size, and fails the publish if a product is over.

Check your understanding · Exercise 2

A product's base color texture is a 4096 by 4096 JPEG of 2.5 MB. Which statements are true? Select all that apply.

Loading: 3D must never cost the page

The naive version puts a canvas at the top of the product page and loads three.js and the model with the page. The whole three.js library is about 185 KB minified and gzipped (Bundlephobia, r186), and a tree-shaken build is smaller. On a mid-range phone, parsing and running that much JavaScript can still take a few hundred ms of main thread (estimate), which delays the page's response to the first taps, measured by INP (Interaction to Next Paint). The model then downloads alongside the hero image and takes its bandwidth.

A canvas is also not an LCP candidate: Largest Contentful Paint only counts images, video, background images and blocks of text. So the product area is a blank box until the first frame, while LCP lands on some other element, slowed by the GLB competing for bandwidth. And most shoppers never touch the 3D at all.

The fix starts from one rule: the first thing on screen is a poster image, a normal img rendered from the same model in the default configuration. It is the LCP element, it is optimized like any product image (see the image-optimization and core-web-vitals lessons), and it looks exactly like the first frame of the 3D view, so the swap is invisible. The question is when to start loading the 3D behind it.

Decision When should the page start loading the 3D runtime and the model?

Eagerly, with the page

Import the viewer in the main bundle and fetch the GLB with the page.

Pros

  • 3D is ready soonest for the shoppers who want it.

Cons

  • Every visitor pays for it, including the majority who never rotate the sofa.
  • Competes with the hero image and the page's own JavaScript, hurting LCP and INP.

Only on interaction

Show the poster with a "View in 3D" button. Load everything on tap.

Pros

  • Zero cost for visitors who do not ask.
  • Respects data saver and metered connections.

Cons

  • A visible wait of a few seconds after the tap, which feels slow and loses some users.
  • Hidden cost on fast devices that could have preloaded for free.

When visible and idle, by device tier

When the viewer is near the viewport (IntersectionObserver) and the main thread is idle (requestIdleCallback), fetch the runtime and the low LOD, but only on capable devices on a fast, unmetered connection. Everyone else waits for a tap.

Pros

  • Fast devices get 3D ready before the shopper reaches for it.
  • Slow devices and data savers pay nothing until they ask.
  • Never competes with the page's critical resources.

Cons

  • More logic: tier detection, two paths to test.
  • A wrong tier guess still costs some bytes.

What we pick: Visible and idle, by tier. The page's job is to sell; it loads first and alone. Then capable devices quietly fetch the runtime and LOD2 so a drag starts rotating at once, while low tier devices and anyone with Save-Data keep the poster and a "View in 3D" button. The model-viewer web component does a version of this out of the box: its default loading value waits until the element is near the viewport.

Device tiers and the image fallback

Before loading anything heavy, a tiny script (under 2 KB, in the page bundle) decides the tier. It checks for WebGL2 by creating a context on a throwaway canvas, reads navigator.connection.saveData and effectiveType where available (Chromium only), navigator.deviceMemory (also Chromium only, rounded on purpose). Unknown values count as "medium". The result is one of three tiers: full 3D with LOD1 and LOD0 streaming, light 3D that stays on LOD2 with a lower pixel ratio, and no 3D.

No 3D is not "no product view". The fallback is a 360 degree spin: 24 to 36 frames rendered offline by the same pipeline, shown in an image strip that follows the pointer, which covers rotation (most of what shoppers do) for under about 1 MB of images. Configuration still works, because options are form controls and the poster for each material set can be rendered offline. The same fallback catches runtime failures: a decoder that does not load, a lost GPU context that will not come back, or a timeout.

ts
type Tier = "full" | "light" | "none";

export function detectTier(): Tier {
  const gl = document.createElement("canvas").getContext("webgl2");
  if (!gl) return "none";
  gl.getExtension("WEBGL_lose_context")?.loseContext();
  const nav = navigator as Navigator & {
    connection?: { saveData?: boolean; effectiveType?: string };
    deviceMemory?: number;
  };
  if (nav.connection?.saveData) return "none";
  const slowNet = ["slow-2g", "2g", "3g"].includes(nav.connection?.effectiveType ?? "4g");
  const lowMemory = (nav.deviceMemory ?? 4) <= 2;
  return slowNet || lowMemory ? "light" : "full";
}

export function whenVisibleAndIdle(el: Element, run: () => void) {
  const io = new IntersectionObserver(([entry]) => {
    if (!entry.isIntersecting) return;
    io.disconnect();
    (window.requestIdleCallback ?? ((cb) => setTimeout(cb, 200)))(run);
  }, { rootMargin: "200px" });
  io.observe(el);
}
The probe context is released at once: browsers cap the number of live WebGL contexts per page. "light" still allows 3D, but only on tap and only LOD2.

Loading on idle still runs a lot of JavaScript once it starts. To protect INP, the viewer imports its chunk with a dynamic import() and splits setup (parsing the GLB, creating materials, the first GPU upload) into short tasks, yielding to the browser between them with scheduler.yield() where supported and setTimeout elsewhere. A tap on a swatch during setup then waits for one short task, not the whole setup.

Progressive loading: low detail first

When 3D starts, the viewer fetches LOD2 (about 1 MB), renders it as soon as it is ready, then fades the poster out: the shopper can rotate about 1.5 to 2 seconds after the trigger on 4G (estimate). In the background it fetches LOD1, and LOD0 only on full tier devices, then swaps the meshes in place without moving the camera. Textures follow the same pattern: 512 first, 1024 or 2048 later. Each step is a separate fetch with an AbortController, so leaving the page or switching product cancels the rest.

The viewer state machine. Poster moves to load runtime on a trigger: visible and idle, or a tap. Load runtime moves to load LOD2 when ready. Load LOD2 moves to interactive on the first frame. Interactive moves to upgrade when idle and back when the better LOD is swapped in. From interactive, a webglcontextlost event moves to context lost, and restored moves back. An error or timeout while loading moves to the 360 spin image fallback, and so does a context that is not restored in time or is lost twice.posterload runtimeload LOD2interactiveupgradecontext lost360 spinimage fallbacktriggerreadyfirst frameidlelostrestorederror, timeoutno restore, lost twice
Every state has a picture on screen: the poster, the model, or the spin images. No path leads to a blank canvas, and a second tap while loading changes nothing because only the poster state accepts the trigger.

The viewer is a small state machine (see the state-machines lesson): poster, loading runtime, loading model, interactive, upgrading detail, fallback, and context lost. Writing it as states with allowed transitions keeps the edge cases honest: a tap during loading does not start a second load, an error in any loading state goes to the fallback, and a lost context goes to its own state instead of a blank canvas.

Sequence of the first 3D view between the Page, the Viewer, the CDN and the GPU. The Page tells the Viewer it is visible and the browser is idle. The Viewer requests the runtime chunk, about 200 KB, and the CDN returns it. The Viewer requests the LOD2 GLB, about 1 MB, and the CDN returns the GLB with KTX2 textures. The Viewer decodes, uploads to the GPU and renders a frame. The Viewer tells the Page to fade out the poster. Then it requests LOD1 in the background.PageViewerCDNGPUvisible + idleruntime, 200 KBJS chunkLOD2 GLB, 1 MBGLB + KTX2render framefade out posterLOD1, background
The poster stays on screen until the first real frame is ready, so the shopper never sees an empty canvas. Detail upgrades happen after the swap and never move the camera.

Check your understanding · Exercise 3

Which choices keep the product page's Core Web Vitals healthy while still offering 3D? Select all that apply.

Rendering: good pixels, drawn only when needed

A product is judged by how real it looks, so the scene setup matters more than in most WebGL work. Four settings do most of the work in three.js, and they map one to one to concepts in other engines.

  • Image-based lighting. Instead of placing lamps, we light the product with an environment map: a 360 degree HDR photo of a softly lit studio. HDRLoader (called RGBELoader before r180) loads it and PMREMGenerator pre-filters it so rough fabric gets blurry reflections and chrome legs get sharp ones. One 1K HDR, about 1 to 2 MB, is shared by every product (estimate).
  • Color management and tone mapping. Textures are authored in sRGB, lighting is computed in linear space, and the result is mapped back to the screen. The tone mapper squeezes bright HDR values into the screen's range. For products, NeutralToneMapping (Khronos PBR Neutral) is a good default because it keeps base colors close to the swatch, which matters when a shopper picks "Sage green". Filmic mappers like ACES shift hues, which looks good in games and can turn into returns in commerce.
  • Shadows: baked, not real time. The product sits on an invisible floor with a soft contact shadow. Real-time shadow maps cost an extra render pass and look jagged on phones. We bake ambient occlusion into the mesh's texture in the pipeline and draw the floor shadow from a pre-blurred texture, re-rendered only when the shape changes (adding the chaise), not when the fabric does.
  • Antialiasing and pixel ratio. antialias: true asks for MSAA, which is cheap on mobile GPUs for simple scenes. A phone with devicePixelRatio of 3 would render 9 times the pixels of a ratio of 1, so we cap it: Math.min(devicePixelRatio, 2) on full tier, 1.5 on light tier. The difference is hard to see on a moving 3D object; the GPU time is not.

Render on demand, not 60 times a second

Most three.js tutorials start a requestAnimationFrame loop that renders forever. On a product page, the sofa is still 95% of the time: the shopper is reading, scrolling, or comparing fabrics. A continuous loop renders 60 identical frames per second, keeps the GPU awake, and on a phone costs real battery and heat, up to twice as much on 120 Hz screens.

Decision How often should the viewer render?

Continuous loop

renderer.setAnimationLoop(render) forever while the viewer exists.

Pros

  • Simplest code; animations and damping work without extra code.

Cons

  • Burns GPU and battery while nothing changes.
  • Keeps running when scrolled away unless paused by hand.

On demand

A requestRender() function schedules one frame with requestAnimationFrame if none is pending. Camera changes, option changes, texture uploads and resizes call it.

Pros

  • Zero GPU work when still.
  • Easy to pause: when the viewer leaves the viewport or the tab hides, nothing requests frames.

Cons

  • Every source of change must remember to request a frame, or the screen shows stale pixels.
  • Camera damping and fades need a short run of frames until they settle.

What we pick: On demand. Each change source calls requestRender(), and anything that animates (damping, a material crossfade, an auto-rotate) keeps requesting frames only until it settles. An IntersectionObserver and visibilitychange stop requests when the viewer is off screen. Battery is a requirement here, not a nicety.

ts
let pending = false;
let animating = 0; // number of active animations (damping, fades)

export function requestRender() {
  if (pending || !visible) return;
  pending = true;
  requestAnimationFrame(() => {
    pending = false;
    const moving = controls.update(); // true while damping is settling
    renderer.render(scene, camera);
    hotspots.project(camera); // move DOM hotspots to their new 2D spots
    if (moving || animating > 0) requestRender();
  });
}

controls.addEventListener("change", requestRender);
configurator.on("applied", requestRender);
One frame at a time. When the camera stops and no fade is running, the loop stops by itself.
Frames drawn over time, in 16 slots. In the first five slots the shopper drags, in the next two the camera damping settles, and in the last nine the shopper is reading reviews. The continuous loop draws in all 16 slots. On-demand rendering draws in the first seven slots only and nothing while the shopper reads.dragdampingstill: reading reviewscontinuouson demand16 of 16 slots draw7 of 16 slots draw
Same pixels on screen, very different GPU work. Most of a product page visit looks like the right side: nothing moves, so nothing should draw.

WebGL2 or WebGPU, main thread or worker

WebGPU now ships in Chrome and Edge (Windows, macOS and ChromeOS since 113, Android 12+ since 121), Safari 26, and Firefox on Windows (141) and Apple silicon Macs (145), with Linux and more platforms in progress (web.dev, November 2025). three.js ships a WebGPURenderer that falls back to WebGL2. WebGPU mainly lowers the CPU cost per draw call, which matters little for one sofa with 20 draw calls. So we render with WebGL2, which reaches more devices, and treat WebGPU as an upgrade path; our glTF and KTX2 assets work with both.

OffscreenCanvas lets a worker own the canvas (canvas.transferControlToOffscreen()), so rendering never blocks the main thread, at the cost of forwarding pointer events to the worker and posting hotspot positions back. One product takes a few ms per frame and renders only on demand, so we keep rendering on the main thread and move the heavy, bursty work into workers: KTX2Loader transcodes in a worker pool by default, and the Meshopt decoder does after MeshoptDecoder.useWorkers(). A worker renderer is the next step if field data shows long tasks.

Check your understanding · Exercise 4

A shopper has the viewer open on a phone and is reading reviews below it. What should the viewer be doing? Select all that apply.

Configuration as data: parts, options, rules and prices

The naive configurator hard-codes the sofa: an if for "velvet only comes in 6 colors", a switch for leg meshes, prices in the component. It works for one product. With 2,000 products, merchandisers changing prices weekly, and fabrics retiring every season, every change is a deploy. The fix is the idea in the config-driven-ui lesson: a product schema, served as JSON, that describes everything, and a generic configurator that reads it.

ts
type ProductSchema = {
  productId: string;
  version: number; // bumps when options or rules change
  basePrice: number; // minor units, e.g. cents
  groups: OptionGroup[]; // shown in this order
  rules: Rule[];
  sku: { pattern: string }; // "SOF-{frame}-{fabric}-{color}-{legs}"
};

type OptionGroup = { id: string; label: string; options: Option[]; defaultId: string };

type Option = {
  id: string; // short and stable: used in URLs and SKUs
  label: string;
  priceDelta: number;
  swatch?: string; // image URL for the button
  apply: // what the viewer does
    | { kind: "material"; slot: string; materialUrl: string }
    | { kind: "mesh"; slot: string; meshUrl: string; lods: string[] }
    | { kind: "variant"; name: string }; // KHR_materials_variants
};

type Rule =
  | { kind: "only-with"; when: Pick; allow: Pick[]; reason: string }
  | { kind: "excludes"; when: Pick; deny: Pick[]; reason: string };

type Pick = { group: string; option: string };
The schema carries labels, prices, rules and what each choice means for the 3D scene. The viewer never knows it is a sofa.

Swapping materials versus swapping meshes

Every option does one of two very different things in the scene. A material swap changes how a part looks: a new color only updates a value passed to the shader, while a new fabric is a texture set of 0.3 to 1 MB to download and upload. A mesh swap changes a part's shape, like metal legs or the chaise, which means downloading and decoding new geometry and redrawing the floor shadow. The schema says which is which, so the UI can predict the cost and show a loading hint only where needed.

Decision How should the changeable parts be packaged as files?

One file per full combination

The pipeline exports every combination as its own GLB.

Pros

  • The viewer only ever loads one file.
  • Each file can be perfectly tuned.

Cons

  • 960 combinations per sofa: storage and pipeline time explode.
  • Every option change downloads a whole sofa again.

One file with every part and variant inside

One GLB holds all leg meshes and all materials, using KHR_materials_variants for named material sets. The viewer toggles visibility and variants.

Pros

  • Every change is instant after one download.
  • model-viewer supports variants with its variant-name attribute.

Cons

  • The shopper downloads every option to see one.
  • 40 fabrics times three maps blows the GPU memory budget if all are uploaded.

A base file, part files, and a shared material library

The base GLB holds the default parts. Alternative shapes are small part GLBs attached to named slots. Fabrics are material files shared across products, fetched on demand. Small color sets inside one fabric use variants.

Pros

  • The first view downloads only the default.
  • Fabrics are cached once and reused on every product page.
  • GPU memory holds only what is shown, plus a small cache.

Cons

  • More requests and a slot naming contract between the 3D team and the schema.
  • Mesh changes have a visible delay unless preloaded.

What we pick: A base file, part files and a shared material library. It matches how shoppers behave: they look at the default, try a few fabrics, and change shape once or twice. It also matches the CDN: fabric files are immutable, shared, and cached for a year. Variants inside a file are still used where a set is small and always wanted together, like the four finishes of one leg.

Rules, conflicts and prices

The rules engine is a pure function: given the schema and the current picks, return each option's state (allowed, or disabled with a reason) and the price. It runs on every change in well under a millisecond for a few hundred options, and the same code runs on the server to validate the cart, so the client and the server can never disagree about what is buildable.

The tricky case is a conflict: the shopper has "Mustard" color, then switches to velvet, which does not come in mustard. Silently keeping an invalid build is a bug; blocking the fabric change is frustrating. We switch the color to the group's default valid option, update the price, and announce it in text: "Color changed to Navy: Mustard is not available in velvet." The state-machines lesson's idea applies: the configuration is never allowed to sit in an invalid state, even for one render.

Prices on the client are a preview: base price plus the sum of deltas. The cart asks the server to price the configuration, because promotions, regions and taxes live there. The SKU or build code comes from the schema's pattern, for example SOF-3S-VEL-NAVY-OAK, which the order system maps to manufacturing.

Frame
Fabric
Color
Legs

Pick options. Try velvet while mustard is chosen, or the chaise with metal legs.

Schematic of the configured sofa: 3-seat frame, linen in navy, oak legs. Price $1,699. URL ?v=7&c=3s.lin.navy.oak. Build code SOF-3S-LIN-NAVY-OAK. No change yet.?v=7&c=3s.lin.navy.oaknavynavynavyoakoakfabric: linenprice: $1,699SOF-3S-LIN-NAVY-OAKno change yetpick an option
Pick options. Try velvet while mustard is chosen, or the chaise with metal legs.

Preloading the likely next option

A 500 ms mesh swap feels slow, so after the first view settles the viewer uses idle time to prefetch with fetch and priority: "low": swatch images for all fabrics (tiny), the material files of the 3 most chosen fabrics from analytics, and the part file behind any swatch the shopper hovers or focuses for 150 ms. Prefetched files go to the HTTP cache, not the GPU, so they cost bytes but not GPU memory until chosen. On light tier, only hover and focus prefetching runs.

Check your understanding · Exercise 5

Which of these belong in the product schema rather than in the configurator code? Select all that apply.

Sharing, the cart, and a picture of what they built

A configuration is worth something only if it survives: a shared link, a page reload, the cart, the order email, the factory. So the configuration needs one compact, stable encoding, and every consumer reads that.

The configuration in the URL

Following the url-as-state lesson, the URL is the source of truth for the picks. We encode the schema version and one short option id per group, in group order: /sofas/harlow?v=7&c=3s.vel.navy.oak.2c. That is readable, short enough for a chat message, and stable because option ids never change once published. On every change we call history.replaceState, not pushState, so the Back button leaves the product page instead of undoing fabric picks one by one (browsers may throw a SecurityError when history methods are called too often, so rapid changes are coalesced into one update).

Loading a link runs the same rules engine. If an option was retired since the link was shared, or the version changed and a rule now forbids the combination, we replace the bad pick with the default, keep the rest, and say so in a short notice. A shared link should degrade, never error. The server renders the page with the configuration from the URL too, so the summary text, price and poster are right in the first HTML, before any JavaScript.

The cart line item

The cart stores the configuration, not a description of it: product id, schema version, the picks, the build code, and the server's price. The cart and checkout (see shopping-cart) treat it as an opaque item with a title, an image and a price. When the shopper clicks the line item, it links back to the configurator URL with the same c value, so they can keep editing. Two lines are "the same item" only if their build codes match.

A snapshot image of the configured product

Decision Where should the cart and email image of the configured product come from?

Client capture with canvas.toBlob

On add to cart, render one frame from a fixed camera and call canvas.toBlob() (the drawing buffer must still hold the frame, so capture in the same task as the render). Upload the PNG.

Pros

  • Instant, free, and exactly what the shopper saw.

Cons

  • Quality depends on the device: LOD2 on a cheap phone, missing on the image fallback tier.
  • An uploaded image is user content: it must be validated and stored, and could be tampered with.
  • Emails sent days later need a durable copy anyway.

Server render of the same scene

A render service loads the same schema, files and camera preset, renders at LOD0 with the full HDR environment, and stores the image under a hash of the build code. A CDN caches it forever.

Pros

  • Same quality for every shopper and every email.
  • Trusted: built from the configuration, not uploaded pixels.
  • Popular builds render once and are cached.

Cons

  • A render service to run, with GPU cost; a new combination takes a second or more the first time.
  • Two renderers must stay visually identical, so use the same engine and assets.

Composite pre-rendered layers

Render each part and option from fixed angles offline as transparent layers, and stack them as 2D images.

Pros

  • Cheap at request time; no GPU on the server.

Cons

  • Only fixed angles, and shadows and reflections between parts are wrong.
  • The layer count grows with options.

What we pick: Server render, keyed by build code, behind the CDN. The cart shows a client capture as a placeholder for the second or two until the server image is ready, so the shopper never waits. The order email and the order history always use the server image. Using the same engine (three.js in a headless browser with GPU access, or the same scene in a native renderer) and the same files keeps both images consistent.

Sequence of add to cart between the Shopper, the Configurator, the Cart API and the Render service. The Shopper taps Add to cart. The Configurator posts the picks with schema version 7. The Cart API asks the Render service to render the build code, and replies 201 with the line and the price. The Configurator shows the line as added, with a client image as a preview until the server image is ready. The Render service puts the image on the CDN. Failure path, when the schema changed since the page loaded: the Cart API replies 409 with a repair, and the Configurator applies the repair and asks the shopper to confirm.ShopperConfiguratorCart APIRender svcAdd to cartPOST picks, v7render build201 line, priceadded, previewimage on CDNschema changed:409 + repairrepaired: confirm?
The server prices and validates with the same rules engine, so a stale page cannot add an unbuildable sofa. The client image covers the second or two before the server render exists.

Think first

A future product has 40 option groups. Is the URL still the right place for the configuration?

Check your understanding · Exercise 6

A shopper opens a shared link whose fabric was retired last month. What should happen? Select all that apply.

Interaction: a camera shoppers cannot lose

Free 3D cameras are disorienting. A naive orbit control lets the shopper spin under the floor, zoom inside a cushion, or pan the sofa off screen, then they have no idea how to get back. A product camera is a constrained orbit: it circles a fixed target at the product's center, and every degree of freedom has limits set per product in the schema.

  • Rotate: drag with one finger or the mouse. Horizontal is unlimited; vertical is clamped between about 10 and 85 degrees from straight up (minPolarAngle and maxPolarAngle in three.js OrbitControls), so the camera never goes under the floor.
  • Zoom: pinch or wheel, clamped between a distance that fits the whole product and one that shows the fabric weave (minDistance, maxDistance). The wheel only zooms after the viewer has focus, so scrolling the page past it does not get captured.
  • Pan: off by default. Products are framed by the pipeline, and pan is the control that loses people.
  • Touch and page scroll: on phones a one-finger vertical drag over the viewer should still scroll the page. We set touch-action: pan-y on the canvas, so the browser keeps vertical scrolling, and use horizontal drags to rotate. Inside a full-screen view, all gestures go to the camera.
  • Reset: a visible "Reset view" button and the Home key return to the default camera, so getting lost is never permanent.

Hotspots anchored to 3D points

A hotspot ("Removable covers") is a real HTML button positioned over the canvas, not pixels drawn in WebGL, so it is focusable, readable by screen readers, and styled like the rest of the page. Each hotspot has a 3D position and a surface normal in the schema. After every rendered frame, position.clone().project(camera) gives coordinates from -1 to 1, which we map to CSS pixels and apply with a transform. If the normal faces away from the camera, the hotspot fades out, so a label for the back of the sofa does not float over the front.

Hotspot projection in three steps. A 3D point on the sofa at 0.4, 0.6, 0.3 meters is projected with the camera to normalized device coordinates x 0.2, y 0.5, which map to 480 by 150 CSS pixels on an 800 by 600 canvas. Below, the canvas shows hotspot 1, which faces the camera and is shown, and hotspot 2, whose normal faces away, faded out.3D point(0.4, 0.6, 0.3) mNDCx 0.2, y 0.5CSS pixels480, 150project(camera)to canvas size12canvas, 800 by 6001: faces camera2: faces away,faded out
vector.project(camera) gives -1 to 1 on both axes. Map x to (x + 1) / 2 * width and y to (1 - y) / 2 * height, then set a transform on the button. Only write, never measure, in the frame.

Because we render on demand, projection runs only when a frame renders, which is exactly when hotspots could have moved. Writing a transform on 5 buttons is cheap; reading layout in the same loop would not be, so we never measure DOM inside it.

Keyboard and reduced motion

The viewer is one focusable element with a label ("3D view of the Harlow sofa, use arrow keys to rotate"): arrow keys rotate by 15 degrees, + and - zoom, Home resets, and Tab moves to the hotspots in a sensible order (the keyboard-navigation lesson covers focus order and visible focus styles). When the shopper has prefers-reduced-motion: reduce, there is no auto-rotate, no camera flights between hotspots (the camera jumps), and no material crossfades. Auto-rotate is off by default anyway, because a spinning product competes with the text the shopper is reading.

Check your understanding · Exercise 7

Which choices keep shoppers from getting lost or stuck in the viewer? Select all that apply.

AR handoff: the configured sofa in the living room

AR answers the question photos cannot: will it fit, and does the green work with my rug? On the web, AR is mostly a handoff: the page passes a model file to the operating system's AR viewer, which handles the camera, floor detection and real scale. There are three paths, one per platform, and they need different files.

PathWhereFile it needsHow the page starts it
Scene ViewerAndroid, on ARCore supported devices (Android 7.0+, with Google Play Services for AR)GLB (glTF 2.0)An intent://arvr.google.com/scene-viewer/1.0?file=... link that opens the Google app's viewer.
AR Quick LookiOS and iPadOS SafariUSDZ (or Apple's Reality format), not glTFA link with rel="ar" wrapping an img, pointing at the USDZ file.
WebXR immersive-arChrome on Android with ARCore; not Safari on iOSAnything the page can rendernavigator.xr.requestSession("immersive-ar") and the page draws the AR scene itself.

Decision Which AR path should the configurator use?

WebXR everywhere it exists

Render the AR view in the page with the same three.js scene, using a WebXR session with hit testing.

Pros

  • The configured scene is already loaded; no extra files.
  • Full control over the UI in AR.

Cons

  • No iOS support, which is a large share of shoppers.
  • We own floor detection UX, lighting estimation and scale, which the OS viewers already do well.

Native viewers: Scene Viewer and Quick Look

Generate a GLB and a USDZ of the current configuration and hand them to the platform viewer.

Pros

  • Works on most AR capable phones on both platforms.
  • Polished native UX: placement, real scale, shadows, sharing.

Cons

  • Needs a USDZ for every configuration.
  • We leave the page; the AR view cannot change options.

What we pick: Native viewers first, WebXR as an extra on Android where it is available. model-viewer wraps exactly this with its ar-modes attribute, default webxr scene-viewer quick-look in that priority. The hard part is not launching AR, it is making sure AR shows the configured sofa and not the default.

AR must show the configured variant

The shopper picked velvet, navy and metal legs. If AR opens the default grey linen sofa, we have taught them not to trust the feature. There are three ways to get files for the configured build, in order of preference.

  • Server export. The render service exports a merged GLB and USDZ per build code, cached on the CDN like the snapshot. The AR button links to them; a build nobody has asked for yet waits a moment on first use.
  • Client export. three.js has GLTFExporter and USDZExporter, and model-viewer generates a USDZ on the fly when quick-look is enabled without an ios-src. It needs no server, but it costs memory and CPU on the phone at the moment of the tap.
  • Pre-built files per combination. Only practical for products with a handful of combinations.

We use server export with client export as the fallback. Whichever path builds the file, it must be at real-world scale in meters, the unit glTF uses, so the sofa lands in the room at its true size.

Sequence of AR handoff on iOS between the Shopper, the Page, the Render service and the AR viewer. The Shopper taps View in AR. The Page requests the USDZ for the build code. The Render service returns it, cached or built now. The Page opens Quick Look with the file, and the sofa is placed in the room at real size. Failure path, when the server export fails or times out: the Page exports a USDZ on the device and opens Quick Look with it.ShopperPageRender svcAR viewerView in ARGET build.usdzcached or builtopen Quick Lookplaced in roomif export fails:error, timeoutclient export
On Android the same flow hands a GLB to Scene Viewer. Either way the file is the configured build, never the default sofa, and it is cached by build code so the second shopper with the same build waits for nothing.

Check your understanding · Exercise 8

Which statements about AR handoff are true? Select all that apply.

Architecture, data model, accessibility and failure

The key boundary is between configuring and viewing. The option panel, the rules engine, the price and the URL work with no 3D at all; they are server-rendered and hydrated like any product page form. The 3D viewer is a lazily loaded chunk that subscribes to the configuration and renders it. If the viewer never loads or crashes, configuring and buying still work, which gives us the fallback tier, accessibility and SEO almost for free.

PartResponsibilityLoaded
Option panel and summaryReal form controls per group, price, a text description of the build, a live region for changes.With the page (server-rendered).
Configuration storeCurrent picks; derives option states and price with the rules engine; syncs the URL.With the page, a few KB.
Rules enginePure function from schema and picks to option states, repairs and price. Shared with the server.With the page.
Viewer shellTier detection, the viewer state machine, poster swap, fallback to the 360 spin.With the page, under 2 KB.
Scene managerthree.js renderer, slots, render on demand, LOD upgrades, disposal, context loss.Lazy chunk.
Asset loaderGLTFLoader with Meshopt and KTX2 decoders in workers, caches, abort on leave.Lazy chunk.
Hotspot layer, AR launcher, snapshot clientDOM buttons over the canvas; AR links per build code; cart image.Lazy chunk, AR on demand.
Component architecture. On the left, the product page, server-rendered: Options with radio groups and Summary with a live region sit above the Configuration store, which holds the picks and the rules engine. Options send picks to the store, and the store sends derived state to the Summary. The store writes the URL with replaceState and posts to the Cart API on add to cart. On the right, the viewer chunk, loaded lazily: the Viewer shell with tier detection and the state machine, the Scene manager with three.js and render on demand, and the Asset loader with Meshopt and KTX2 decoders in workers. The store sends picks to the Viewer shell. The Asset loader fetches from the CDN, which holds GLB files, KTX2 textures and renders.Product page (SSR)Optionsradio groupsSummarylive regionConfiguration storepicks, rules enginepickderivedViewer chunk (lazy)Viewer shelltier, state machineScene managerthree.js, render on demandAsset loaderMeshopt, KTX2 in workersURLCart APICDN: GLB, KTX2, rendersreplaceStateadd
The left side works alone: if the right side never loads, shoppers still configure and buy. The only thing crossing the line is the picks, one way, plus a status back for loading hints.
Data flow in three lanes. Assets: the Pipeline publishes GLB and KTX2 files to the CDN, the Asset loader fetches them with caching, and uploads them to the GPU. Data: the Product API sends the schema as JSON into the server-rendered page, which hydrates the Store with the schema and picks; the Store sends derived state to the Options UI and gets picks back. The Store tells the Asset loader which picks to show. Writes: the Store posts picks to the Cart API and writes the URL.PipelineCDNAsset loaderGPUGLB, KTX2fetch, cacheduploadProduct APIschema v7SSR pageschema, picksStorepicks, derivedOptions UIradio groupsJSONhydratepicksCart APIURLPOST
Two kinds of data meet in the browser: big, immutable files that go to the GPU, and a small schema and picks that drive the UI. The Store is the only place they connect, through the picks.

Data flows one way. A pick updates the store; the store derives the new state and writes the URL; the panel re-renders from the store; the viewer receives the new picks and works out the difference from what it shows (which slots changed, material or mesh), loads what is missing, swaps, and requests one frame. The viewer never writes to the store except to report its own status (loading a part, ready, failed), which the panel shows as a small hint on the swatch.

Sequence of an option change between the Shopper, the Options UI, the Store, the Viewer and the CDN. The Shopper taps Metal legs. The Options UI calls pick legs metal. The Store derives state, writes the URL and tells the Viewer the picks changed. The Viewer requests legs-metal.glb from the CDN, which returns 300 KB. The Viewer swaps the mesh, renders a frame and reports ready to the Store. Failure path, when the fetch fails: the CDN returns an error, the Viewer reports failed and keeps the old legs, and the Store tells the Options UI to show a retry hint.ShopperOptions UIStoreViewerCDNtap metalpick metalpicks changedlegs-metal.glb300 KBready, drawnif fetch fails:errorfailedshow retry
The price, URL and text update at once, before any bytes arrive, because they come from the Store. Only the picture waits for the CDN, and if it fails the shopper sees a retry hint on that swatch instead of a broken model.
ts
// URL and store: what the shopper chose
type Configuration = { productId: string; version: number; picks: Record<string, string> };

// Derived on every change, never stored
type Derived = {
  options: Record<string, { state: "allowed" | "disabled"; reason?: string }>;
  repaired: { group: string; from: string; to: string; reason: string }[];
  pricePreview: number;
  buildCode: string;
  description: string; // "3-seat sofa in navy velvet with oak legs"
};

// Viewer: memory only, per page
type SlotState = { slot: string; shown: string; lod: 0 | 1 | 2; status: "ready" | "loading" | "failed" };

// Component interface
type ConfiguratorProps = {
  schema: ProductSchema;
  initial: Configuration; // parsed from the URL on the server
  onAddToCart: (config: Configuration) => Promise<CartLine>;
};
Picks live in the URL and the store; everything else is derived. The schema arrives inside the page HTML, so the first render needs no extra request.
http
GET /api/products/harlow/schema?v=7          -> ProductSchema (CDN, immutable per version)
GET https://cdn.example.com/3d/harlow/v7/base-lod2.glb   (immutable, 1 year)
GET https://cdn.example.com/3d/materials/velvet-navy.glb (shared by all products)

POST /api/cart/lines
{ "productId": "harlow", "version": 7, "picks": { "frame": "3s", "fabric": "vel", "color": "navy", "legs": "oak" } }
-> 201 { "lineId": "l_81", "buildCode": "SOF-3S-VEL-NAVY-OAK", "price": 189900,
         "imageUrl": "https://cdn.example.com/render/SOF-3S-VEL-NAVY-OAK.webp" }
-> 409 { "error": "not-buildable", "repair": { "color": "navy" } }

GET /ar/SOF-3S-VEL-NAVY-OAK.usdz | .glb      -> rendered on first use, then cached
Asset URLs carry a version, so they are cached for a year (see the cdn-caching lesson). The 409 covers a schema that changed between page load and add to cart.

Check your understanding · Exercise 9

Why is the option panel server-rendered and independent of the 3D viewer? Select all that apply.

Nothing only in 3D

A canvas is opaque to assistive technology: a screen reader announces it, at best, as one image. So the 3D view is a visual extra, and every fact and every action exists in HTML too. Each option group is a fieldset with a legend and native radio inputs (the aria-patterns lesson explains why native controls beat custom roles).

Each swatch has a text name and its price change, "Velvet, plus 200 dollars", so color is never the only cue (see visual-accessibility). A disallowed option stays focusable with aria-disabled="true" and its reason linked by aria-describedby, and the change handler ignores it. A natively disabled radio would be skipped by the keyboard, and the shopper would never learn why it is unavailable.

The summary holds the description from the rules engine ("3-seat sofa in navy velvet with oak legs, 1,899 dollars"), and a polite live region announces each change and each repair. The canvas element itself gets role="img" and an aria-label with the same description, kept in sync; not the wrapper, because an image role would hide the hotspot buttons inside it. Hotspot facts also appear in a features list below, and dimensions, which shoppers often look for in 3D or AR, are written in text.

GPU memory: dispose what you replace

three.js does not free GPU memory when you remove an object from the scene: geometries, materials and textures hold GPU buffers until you call dispose() on each. Swapping fabrics without disposing leaks about 2 to 16 MB per swap (three 1024 maps, as KTX2 or RGBA), and a few dozen swatch clicks can get the tab killed. iOS Safari is the strictest: WebKit publishes no limit, but a tab using a few hundred MB of GPU and JavaScript memory on an older iPhone is at risk (an estimate; test on real devices).

  • Keep a small LRU cache of material sets on the GPU: the current fabric plus the last two, so flipping between two favorites is instant. Evicted sets are disposed; their files stay in the HTTP cache for a fast return.
  • Dispose replaced part meshes and their unique textures; never dispose shared ones still in use (the environment map, the floor shadow). Count references per texture.
  • On leaving the product (client-side navigation), dispose everything and call renderer.dispose(). The memory-leaks lesson shows how to confirm with heap snapshots; renderer.info.memory counts live geometries and textures, which a test can assert.

When the GPU context is lost

The browser can take the WebGL context away at any time: a GPU driver reset, too many contexts, or the OS reclaiming memory from a background tab. The canvas fires webglcontextlost, and if the handler calls event.preventDefault(), the browser may later fire webglcontextrestored, after which every buffer and texture must be uploaded again. We test this path with the WEBGL_lose_context extension, which simulates the loss on demand.

Our viewer then moves to its context lost state, shows the poster of the current build (the server snapshot, so it matches), and pauses requests. On restore, it rebuilds the scene from the HTTP cache. If restore does not come within a few seconds, or the context is lost twice in a minute, it stays on the 360 spin fallback.

Field data closes the loop: report the tier chosen, time from trigger to first 3D frame, LOD reached, option change latency, context losses and fallbacks, split by device class. Those numbers tell us whether the budgets and tier thresholds are right, which lab tests on a fast laptop never will.

Check your understanding · Exercise 10

A shopper on an iPhone taps through 25 fabrics and the tab reloads itself. What are likely causes or fixes? Select all that apply.

Running it in the interview

This problem tempts candidates to talk about shaders for 40 minutes. Interviewers for a frontend role want the product page view: budgets, loading, data, failure and access. A plan for 45 to 60 minutes:

  1. Minutes 0 to 5, scope. Ask about devices, which options change shape, rules, AR, and what the cart needs. State the two budgets: download for first view and GPU memory, set by a mid-range phone.
  2. Minutes 5 to 12, the asset pipeline. glTF and GLB, KTX2 for GPU memory, Meshopt or Draco for geometry, LODs, a budget check in CI. Do the 4096 texture math out loud: about 85 MB as RGBA, about 21 MB as ASTC 4x4, both with mipmaps.
  3. Minutes 12 to 20, loading. Poster as LCP, tiers, load when visible and idle, LOD2 first, image spin fallback. Draw the viewer state machine.
  4. Minutes 20 to 32, configuration. The schema, material versus mesh swaps, the rules engine shared with the server, conflicts and repair, URL encoding, cart line, snapshot service.
  5. Minutes 32 to 40, architecture. Draw the split between the server-rendered option panel and the lazy viewer, and the one-way data flow.
  6. Remaining time, depth on request. Render on demand, disposal and context loss, AR with the configured variant, accessibility.

Say the numbers as you go: 64 MB for one raw 4K texture, about 2 MB for the first view, a 2x pixel ratio cap, a 150 MB GPU budget. Numbers turn "it should be fast" into decisions the interviewer can push on.

Think first

The interviewer moves you to a car: 10,000 valid combinations, and the paint must look perfect. What changes in your design?

Think first

Why not just use the model-viewer web component?

Summary

  • Budget two things separately: download bytes for the first view (about 2 MB, under a 5 MB ceiling) and GPU memory (under about 150 MB), both set by a mid-range phone.
  • A build pipeline turns raw exports into GLB with Meshopt geometry, KTX2 textures and three LODs, and fails any product over budget. With mipmaps, a 4096 texture is about 85 MB as RGBA and about 21 MB as ASTC 4x4.
  • The poster image is the LCP element. The 3D runtime loads when the viewer is visible and the page is idle, only on capable devices; others get it on tap or keep a 360 degree image spin.
  • Render on demand and cap the pixel ratio. A still product should cost zero GPU work.
  • The product is data: groups, options, rules, price deltas, SKU patterns, and whether each option swaps a material or a mesh. One rules engine runs on the client and the server.
  • The configuration lives in the URL, the cart line and the order, as short stable option ids. A server render keyed by build code supplies cart, email and AR files.
  • Configuring works without 3D: real form controls, a text description, a live region. The canvas is a visual extra, never the only source of information.
  • Dispose replaced geometries and textures, keep a small LRU of materials, and handle context loss by falling back to the poster.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next