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.
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 );
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.
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;
}
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.
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.
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 <select>:
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 <select> for handing control back to a host platform's own logic.
Comments
No comments yet. Start the discussion.