Brief & concept
The assignment was site 23 of 25: "one chair, configurable." A Three.js viewer with orbit controls, studio lighting, a soft shadow catcher, material swatches that morph live, an updating price, and a dimension overlay — the flex being real-time 3D that feels like a premium DTC store, the kind of page where a furniture brand sells a $3,000 lounge chair.
So the first job wasn't code — it was a fiction worth building. The chair became The Halcyon, a bent-plywood lounge in a fictional "Edition 03." The copy commits to it: nine plies of steam-bent walnut, pressed over a two-tonne form, boucle woven in Kortrijk. A configurator only feels premium if the words around it believe the product is real.
Assets & provenance — the pipeline star
There is no photographer, no 3D modeller, and no product. The entire chair came from a two-step generative pipeline:
| Step | Tool | Output |
|---|---|---|
| Product photo | Higgsfield nano_banana_2 (text → image) | A studio shot of a mid-century walnut & boucle lounge chair — chair.webp |
| Photo → mesh | Higgsfield image_to_3d | A textured glTF 2.0 mesh, ~6 MB — chair.glb |
That's the whole story, and it's a good one: a photograph of a thing that was never
photographed, lifted into a mesh that was never modelled. The generated .webp pulls
double duty — it's also the loading poster and the WebGL-failure fallback, so the shot is never a
void while the 6 MB GLB streams.
Design decisions
Split-screen, not scroll
A configurator is a workbench, not an article. The layout is a fixed two-pane grid — 3D stage left, controls right — so the product never leaves your eye while you change it. On mobile it stacks to a 56vh viewport over a scrollable panel. No page ever scrolls the body horizontally.
Material variants on a single baked texture (the honest version)
Here's the constraint that shaped everything: the GLB is one mesh with one material and one baked colour texture covering the walnut and the boucle together. There are no separate sub-materials to swap. Real per-part material picking is impossible on this asset — so I didn't pretend to. Instead I inject a small shader that reads each pixel's luminance and saturation to tell wood from cloth, then re-hues the wood range and multiply-tints the cushion range. It's an approximation, documented as one in the code. It reads convincingly and it updates instantly, which is the actual point of a configurator.
Warm gallery, not the default studio white
The backdrop is a warm radial paper gradient with a grounding floor vignette and a film-grain
overlay — committing to a #e9e5dd paper palette with a single amber accent, rather
than the reflexive cool-grey product-shot look. Type is Space Grotesk throughout, with tight
display tracking on the headline.
Signature techniques
GLB streaming with a real progress state
loader.load('./assets/chair.glb',
(gltf) => { /* recenter, sit on floor, patch material, reveal */ },
(evt) => {
// total is unknown when gzipped, so fall back to the known 6.1MB
const p = evt.total ? evt.loaded / evt.total
: evt.loaded / 6100816;
setProgress(Math.min(0.99, p)); // drives the ring + bar
},
(err) => { // GLB failed: keep the generated poster as the fallback
loader.classList.add('done');
});
Material variants — the shader that separates wood from boucle
mat.onBeforeCompile = (shader) => {
// One baked texture holds BOTH materials, so we can't swap meshes.
// Classify each texel by luminance + saturation, then treat it.
shader.fragmentShader = shader.fragmentShader.replace(
'#include <color_fragment>',
`#include <color_fragment>
float lum = ...; float sat = ...;
// boucle = light + low-saturation → MULTIPLY tint (keeps woven shading)
float fabMask = smoothstep(0.44, 0.66, lum)
* (1.0 - smoothstep(0.36, 0.62, sat));
diffuseColor.rgb = mix(wooded, uFabTint * soft, fabMask);`
);
};
Damped orbit controls that never dip below the floor
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true; controls.dampingFactor = 0.06;
controls.enablePan = false;
controls.minDistance = 2.6; controls.maxDistance = 6.5;
controls.maxPolarAngle = Math.PI / 2 - 0.02; // never under the seat
controls.autoRotate = true; controls.autoRotateSpeed = 0.85;
// auto-spin stops the instant the user grabs the model
controls.addEventListener('start', () => controls.autoRotate = false);
Dimension overlay drawn in 3D space
// Dashed amber leader lines + canvas-sprite labels, built from the
// chair's bounding box, toggled by the "dimensions" chip (or the "d" key).
const dashMat = new THREE.LineDashedMaterial({ color: 0xa8672d,
dashSize: 0.05, gapSize: 0.03 });
const line = new THREE.Line(geo, dashMat);
line.computeLineDistances(); // required for dashes to show
dimGroup.add(makeLabel('82 cm', new THREE.Vector3(xR + 0.18, midY, z)));
The three iteration passes
Every site is rendered in headless Chrome, screenshotted at desktop and mobile, and the pictures are read back and critiqued — code review can't see a papery cushion, only pixels can. Each pass fixes what it finds and adds one deliberate upgrade.
Pass 1 — get it on screen
First render threw a shader error: the injected uniforms were added to the uniform map but never
declared in the GLSL, so the fragment shader wouldn't compile. Fixed by prepending the
uniform declarations. With that resolved the chair appeared — but the cushions read as
crumpled paper, because the first tint replaced the baked shading instead of multiplying it.
Pass 2 — the fork in the road
I tried the tempting hybrid: keep the scanned shell, cover the bad cushions with clean procedural rounded-box boucle. It looked worse — because the cushions are baked into the same mesh as the shell, they can't be removed, only darkened, and any darkened remnant peeking out reads as a defect. The two cushion systems fought each other. So I deleted the procedural cushions entirely and instead softened the scanned cushions in-shader (flatten harsh highlights, gentle even tone). The brief explicitly blessed a full-primitive fallback; the honest middle path — keep the gorgeous shell, treat the flawed cushions — beat both extremes. I also tightened the camera framing and moved the marginalia off the cluttered bottom edge.
Pass 3 — make the variants unmistakable, then add delight
Verifying variants live (ebony shell, clay boucle) showed the colourways were too subtle, so I
boosted the fabric mask and tint strength until each reads clearly. The deliberate upgrades: a
tactile field-of-view "settle" nudge when a material changes, and an easter egg — press R
to roll a random configuration, D to toggle dimensions. Final pass: zero console errors
at 1440×900 and 390×844, no horizontal scroll.
Reproduce this
- Generate a clean studio product photo of your object (
nano_banana_2), then lift it to a textured GLB (image_to_3d). Keep the photo — it's your poster and fallback. - Inspect the GLB before trusting it. Parse the header: how many meshes, materials, textures? A single baked texture means you can't swap sub-materials — plan variants accordingly.
- Load with
GLTFLoaderand a real progress callback; fall back toevt.loaded / knownByteswhenevt.totalis 0 under gzip. - Set up the scene like a photographer:
ACESFilmicToneMapping, a three-point rig,RoomEnvironmentfor soft image-based reflections, and aShadowMaterialplane as the floor. - Damp your
OrbitControls, clamp the polar angle above the floor, and stop auto-rotate on first interaction. - For variants on a baked texture, patch the material with
onBeforeCompileand classify texels by luminance/saturation — multiply-tint fabric so its shading survives; be honest in comments that it's an approximation. - Render headless, read the screenshots yourself, and iterate at least three times. Judge the flawed asset honestly — sometimes the fix is to treat it, not to hide it.
- Ship with zero console errors and no horizontal body scroll at 1440 and 390.