DEV Community

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.