DEV Community

Noor E Alam
Noor E Alam

Posted on

Building a Three.js 3D Product Configurator for WooCommerce: 4 Things I Didn't Expect

Most WooCommerce product pages still show the same thing stores have shown for 20 years: a handful of flat photos. I spent the last few months building Noorifa, a plugin that replaces that with an interactive Three.js viewer — customers rotate the model, zoom in, and switch colors/materials on specific meshes in real time, synced to the store's actual WooCommerce variations.

The 3D rendering part was the easy 20%. The other 80% was a series of small, specific problems that don't show up in a Three.js tutorial. Here are four of them.

1. A directional light rig can't light a face it can't see

Early on, customers rotating a table model would find the underside of the tabletop rendering near-black — no matter how far I pushed the light intensity.

The rig at the time was a single key light plus a hemisphere ambient:

scene.add( new THREE.HemisphereLight( 0xffffff, 0x444444, 1.2 ) );

const keyLight = new THREE.DirectionalLight( 0xffffff, 1.2 );
keyLight.position.set( 3, 5, 4 );
scene.add( keyLight );
Enter fullscreen mode Exit fullscreen mode

The bug was geometric, not a brightness problem: keyLight sits above the model, so its light direction only reaches surfaces whose normal faces back toward it. A downward-facing surface — the underside of an overhanging tabletop — can't receive any direct contribution from a light positioned above it, at any intensity. Cranking the brightness slider was scaling a number that was multiplying against zero.

The fix was closer to actual three-point studio lighting: key, fill, and rim from above for shape and separation, plus a dedicated light from below, and a brighter hemisphere ground color to approximate bounced light:

scene.add( new THREE.HemisphereLight( 0xffffff, 0x888888, 1.1 * brightness ) );

const keyLight = new THREE.DirectionalLight( 0xffffff, 1.1 * brightness );
keyLight.position.set( 3, 5, 4 );

const fillLight = new THREE.DirectionalLight( 0xffffff, 0.5 * brightness );
fillLight.position.set( -4, 2, 3 );

const rimLight = new THREE.DirectionalLight( 0xffffff, 0.45 * brightness );
rimLight.position.set( -1, 3, -5 );

const underLight = new THREE.DirectionalLight( 0xffffff, 0.55 * brightness );
underLight.position.set( 0, -5, 2 );

None of these cast shadows, so the GPU cost is negligible — it's just four more cheap terms in the lighting sum, not a new render pass.

  1. Unbounded OrbitControls zoom looks like a bug, not a feature By default, OrbitControls' zoom range is [0, Infinity]. Customers were scrolling the model either through the near clipping plane or out until it was a speck — both read as "this is broken," not "I zoomed."

The fix is bounding minDistance/maxDistance, but the useful bit is what to scale them against. A fixed number breaks the moment two products have wildly different physical sizes — a ring and a wardrobe can't share a zoom range. Scaling off the model's own auto-computed framing radius fixes that:

`function applyFraming( framing ) {
camera.near = framing.radius / 100;
camera.far = framing.radius * 100;
camera.updateProjectionMatrix();
controls.target.copy( framing.center );

// Initial camera distance is framing.radius * sqrt(3) ≈ 1.73,
// so [1, 4] always brackets it comfortably regardless of the
// product's actual size.
controls.minDistance = framing.radius;
controls.maxDistance = framing.radius * 4;
Enter fullscreen mode Exit fullscreen mode

}`

Every product gets a zoom range proportional to itself, computed at load time from its bounding box, instead of a number that only happens to work for whatever model I was testing with.

  1. Draco, and the WASM question (corrected from an earlier post of mine) I originally claimed here — and in a Reddit post — that "WordPress.org disallows bundling .wasm binaries." A commenter fairly asked me to point to that rule, and I couldn't find one. Correcting it properly:

There's no line item that names WebAssembly specifically. What actually exists is broader: WordPress.org's plugin guidelines require all distributed code to be non-obfuscated and available as human-readable source. A compiled .wasm binary doesn't qualify as source in any form, so in practice it runs into that same rule — but that's an inference on my part, not a cited policy. Worth being precise about the difference.

Practical effect is the same either way: Draco's decoder ships in both a WASM build (faster) and a pure-JS build (slower, fully inspectable as source). For WP.org distribution, I force the JS one:

dracoLoader.setDecoderConfig( { type: 'js' } );
Noticeably slower to decode on large meshes than the WASM path would be — the real cost of going through a plugin directory with source-transparency requirements, not a rule I can point to by name.

  1. Don't reinvent variation pricing — hand off to WooCommerce's own form The tempting-but-wrong approach when a customer clicks a color swatch: fetch the variation's price yourself and update the DOM. Don't — WooCommerce already has a variation form with its own price/stock lookup, AJAX-cached, that themes and other plugins already hook into. Reimplementing it means every theme customization and every other plugin's price-affecting hook silently stops working for your swatches.

Instead, the swatch click just drives WooCommerce's own native : button.addEventListener( 'click', () => { select.value = swatch.selectValue; window.jQuery( select ).trigger( 'change' ); currentSelections[ group.attributeKey ] = swatch.value; recomputeAndApply(); // repaints the 3D model's material syncActive(); } ); recomputeAndApply() handles the visual side (swapping the mesh's material). Everything price/stock-related comes from WooCommerce itself reacting to that change event, the same as if the customer had used the dropdown directly. One nice side effect: if a store's variation data has an inconsistency (a value left on "Any" instead of a specific option, which silently breaks WooCommerce's own price matching), it breaks the same way it would with the native dropdown — which made it easy to build a diagnostic warning for, instead of a mystery bug report. Curious if anyone here has hit the WASM-via-obfuscation-rule issue on other plugin directories, or has a cleaner pattern than event-triggering a native for handing control back to a host platform's own logic.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.