I run svetslik.si, a small shop in Slovenia selling hand-painted oil paintings and canvas reproductions. Six languages, WooCommerce, shared hosting, no venture money, one developer.
We offer 62 frames. For years they were presented the way every art shop presents them: a wall of thumbnails. Customers picked one, received the painting, and sometimes discovered that the frame they imagined and the frame they bought were not the same object. Framing is the highest-margin part of the order and the one people are most reluctant to commit to, because they cannot picture it.
So the product page now renders it. Choose a painting, choose a frame, see the combination in the real proportions, in the browser, in real time.
Try it: https://svetslik.si/en/gustav-klimt/the-kiss/ — open the frame selector and change frames.
The Three.js took a weekend. Everything else took months. This is a writeup of everything else, because that is the part nobody writes up.
The architecture, and one rule that saved the project
Three pieces:
- A compiled single-file HTML bundle containing the whole viewer — 2,726 lines.
- A bridge mu-plugin in WordPress that mounts it and mediates — 1,486 lines.
- A
postMessagecontract between them.
The viewer lives in an iframe. People push back on this, so let me defend it: a WooCommerce product page is a hostile environment for a canvas application. Mine carries a page builder that rewrites the DOM, a theme and a child theme, a multilingual plugin, a permalink manager, a caching layer that rewrites markup, and a stack of jQuery plugins that assert themselves on layout whenever they feel like it. Any of them can reach into your canvas container and change its size, its position, or its stacking context.
An iframe is a hard boundary. Inside it I control every byte. Outside it I control nothing — which is fine, because outside it I only need to control six messages:
| Message | Direction | Payload |
|---|---|---|
svetslik:ready | child → parent | { frames: [{ index, key, code, label }, …] } |
svetslik:aspect | child → parent | { ratio } — W/H |
svetslik:empty | child → parent | { empty } |
svetslik:frame | parent → child | { frame } — index, or null to clear |
svetslik:painting | parent → child | { url } |
svetslik:dimensions | parent → child | { w, h } in cm |
The rule that saved the project: the compiled bundle is never hand-edited. It is a build output. Every fix goes through source and gets rebuilt. The first time you hand-patch a build artifact to ship a quick fix, you have silently destroyed your ability to rebuild, and you will not find out for three weeks.
The sizing handshake: the child reports shape, the parent decides size
This is the problem I most wanted to read about before I started, so it goes first.
An iframe that sizes to its content needs to know its content’s height. A full-bleed canvas sizes itself to its container. The container is the iframe. Neither can resolve. Each is waiting for the other.
I saw both failure modes. The canvas never getting a size at all, noted in the bundle at the guard that now prevents it:
“it still has to produce (and emit) a height, else the iframe never gets sized and the canvas stays 0 forever (deadlock). Only bail if width is unknown.”
And the opposite — a height that resolved to garbage. The product template has its own frame-swatch slider, roughly 870px tall, sitting in the image column. It pushed the iframe down the page until the measured top was 1028px, at which point the available-room calculation went negative and the height collapsed to its 240px floor. A viewer 240 pixels tall is not a viewer.
The fix is a rule rather than a trick: exactly one side owns the size, and it is not the side that knows the content.
The child never reports pixels. It reports a ratio. It projects the frame box’s eight corners across every settle pose, takes the widest and tallest silhouette it will ever occupy, and posts the resulting aspect:
let worldW = 0, worldH = 0;
for (const cs of cornerSets){
let xmin=1e9,xmax=-1e9,ymin=1e9,ymax=-1e9;
for (const p of cs){ if(p.xxmax)xmax=p.x; if(p.yymax)ymax=p.y; }
if (xmax-xmin > worldW) worldW = xmax-xmin;
if (ymax-ymin > worldH) worldH = ymax-ymin;
}
this._aspect = Math.max(1e-6, worldW) / Math.max(1e-6, worldH);
this.emitAspect(); Using the rotation-safe bounding box rather than the painting’s own dimensions is what stops the iframe from resizing mid-rotation. The parent then owns pixels entirely:
var width = wrap.clientWidth;
if ( ! width ) { return; }
var ideal = width / ( aspect > 0 ? aspect : 0.78 );
var cap;
if ( MOBILE_MQ.matches ) {
cap = Math.max( 320, Math.round( window.innerHeight * 0.72 ) );
} else {
var room = window.innerHeight - iframe.getBoundingClientRect().top - galleryRowHeight() - 24;
cap = Math.max( 240, room );
}
iframe.style.height = Math.round( Math.min( ideal, cap ) ) + 'px';The other half of the fix is refusing to emit unless the value actually changed:
emitAspect(){
const r = this._aspect; if (r == null || !isFinite(r)) return;
if (r === this._lastEmitAspect) return;
this._lastEmitAspect = r;
try { if (window.parent && window.parent !== window) window.parent.postMessage({ type:'svetslik:aspect', ratio: r }, '*'); } catch(_){}
}Without that guard the frame rotates, the silhouette changes by a rounding error, the parent resizes, the canvas re-lays-out, the silhouette changes again. That is not a bug you find in testing. It is a bug you find on someone else’s laptop.
The generalisable version: any time both sides of an iframe boundary can trigger a resize, you have built a feedback loop. It may be stable today because one side happens to settle first. It will not be stable on a slower device, after a font loads, or when a scrollbar appears. Pick an authority. Let the other side report shape, never size.
Two materials, two philosophies
The first renders looked expensive and wrong. Beautifully lit, convincing depth, and a gold that was not the gold of the painting.
The renderer is doing real work — sRGB output, ACES filmic tone mapping, exposure at 1.15:
const renderer = new THREE.WebGLRenderer({ canvas: cv, antialias: true, alpha: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;That is correct for the moulding. A picture frame is a physical object with gilding and relief, and it should be lit, shaded and tone-mapped like one.
It is completely wrong for the artwork. The painting face is not an object in the scene — it is a photograph of a thing the customer is about to compare against another photograph of the same thing. Run it through a lighting rig and a filmic curve and bright paintings wash out. So the painting opts out of the entire pipeline:
const pmat = new THREE.MeshBasicMaterial({ toneMapped: false, color: 0xcfc3a8 });Unlit material, tone mapping explicitly off, and when the real texture arrives it is declared sRGB and the base tint is cleared to white so nothing tints it:
tex.colorSpace = THREE.SRGBColorSpace;
// on load:
pmat.map = tex; pmat.color.setHex(0xffffff);The frame is simulated. The artwork is reproduced. Two materials, two philosophies, one scene.
This is the opposite of what a Three.js tutorial teaches, and it is the right call whenever the customer will compare your render against a photograph. Nobody has ever asked for a more convincing specular highlight on a picture frame. They ask whether it is the same gold.
The 0xcfc3a8 starting colour is deliberate too — a neutral raw-canvas tone, so that in the moment before the texture loads you see something that looks like an unpainted canvas rather than a white flash.
Paying the boot cost when nobody is looking
Profiling on a real GPU put viewer boot at ~4.1s cold, ~1.2s warm, with about 72% of that being synchronous shader compilation paid once per boot.
Four seconds of frozen main thread is not an acceptable response to a tap. But notice the shape of that number: it is a one-off. So the fix is not to make it faster, it is to spend it earlier, while the customer is doing something else.
The parent watches for the first interaction with the frame selector, in the capture phase, before the dropdown has even finished opening:
function prebootViewer( e ) {
if ( viewerBuilt ) { removePrebootListeners(); return; }
var t = e.target;
if ( ! t || ! t.closest || ! t.closest( '.sds--okvir, .sds--podokvir' ) ) {
return; // some other control — keep waiting
}
if ( buildViewer() ) {
removePrebootListeners();
}
}
document.addEventListener( 'pointerdown', prebootViewer, true );
document.addEventListener( 'keydown', prebootViewer, true );buildViewer() creates the iframe and sets its src, but the wrapper is display:none and never gets its is-on class. Three.js parses, the WebGL context is created, and every shader compiles — entirely invisibly, while the customer is still reading a list of frame names. The comment in the bridge puts it plainly: the one-off boot cost is paid before the eventual pick.
By the time they choose a frame, the cost is already sunk and the reveal is instant. Nothing was optimised. It was just moved.
The failure that has no error message
There is no WebGL feature detection anywhere in this codebase. I want to explain why, because I originally considered that a gap and now consider it the design.
Detection is failure-based. init() tries, and readiness is signalled by a message that only ever gets posted if everything worked:
init(){
try {
const THREE = window.THREE;
if (!THREE) { console.error('three.js failed to load'); return; }
this.THREE = THREE;
this.initScene(); // new THREE.WebGLRenderer(...) — throws with no WebGL
this.setupMessaging(); // posts svetslik:ready — only reached if initScene succeeded
this.buildFrame(this.selected);
this.startLoop();
this.initDrag();
this.loadingEl.style.display = 'none';
this.reveal();
} catch(e){ console.error('three init failed', e); }
}The ordering is the whole mechanism. setupMessaging() runs after initScene(), so if the WebGL context throws, svetslik:ready is never posted. The parent draws its conclusion from silence:
var READY_WATCHDOG_MS = 10000;
// armed when the iframe is created:
readyWatchdog = setTimeout( function () {
readyWatchdog = null;
if ( ! viewerReady ) {
viewerDead = true;
hideViewer();
update2dFallback();
}
}, READY_WATCHDOG_MS );A feature probe answers exactly one question: does this browser have WebGL. The watchdog answers the question I actually care about: is this customer going to see a working 3D preview in a reasonable amount of time. No WebGL, a bundle that failed to download, an exception in my own code, a device too slow to boot inside ten seconds — one mechanism catches all of it, and the customer gets the normal product gallery either way.
One refinement that matters. A late ready revives a dead session rather than being ignored — the note in the source reads “late ready = slow device, not crippled.” A slow phone that boots in twelve seconds still gets the viewer; it just arrives after the fallback did.
And if you are wondering who actually has no WebGL in 2026, the reproduction case was not an ancient Android. It was in-app browsers — Facebook, Instagram, Messenger. A large share of e-commerce traffic arrives through exactly those WebViews. If you ship WebGL to a store, that is your fallback path, and you will never see it in your own testing because you do not browse your own shop through a Facebook link.
Mobile: a different mount, not a different stylesheet
The first mobile version replaced the product image with the viewer. Small screen, one hero slot, put the impressive thing in it. It was wrong.
The painting is the product. The frame is an accessory. Replacing the photograph with a 3D toy removed the thing that makes someone want to buy and substituted the thing that makes them want to play. Those are different behaviours and only one ends in a purchase.
The fix is not CSS. Below 991px the viewer gets a genuinely different DOM parent — a purpose-built div injected directly after the variations table, whose last visible row is the frame selector:
var sel = document.getElementById( 'pa_okvir_select' );
if ( ! sel || 'SELECT' !== sel.tagName ) { return null; }
var table = sel.closest( 'table' );
if ( ! table || ! table.parentNode ) { return null; }
mobileMount = document.createElement( 'div' );
mobileMount.id = 'svetslik-3d-mobile-mount';
table.parentNode.insertBefore( mobileMount, table.nextSibling );
return mobileMount;function currentMount() {
return ( MOBILE_MQ.matches && getMobileMount() ) || host;
}On desktop the mount is the image column. On mobile the reading order becomes frame selector → viewer → swatch gallery → moulding close-up, and the product photograph stays exactly where it was, untouched. The configurator appears at the decision point instead of replacing the thing that created the desire.
One cost worth knowing: crossing the breakpoint reparents the iframe, and reparenting an iframe reloads its document. Readiness is dropped and the watchdog re-armed. Rotating your phone re-boots the viewer. I accepted that; the alternative is two live WebGL contexts on a phone.
A configurator is a decision aid, not the product. It belongs where the decision happens.
The bug that threw no error
Worth its own section because of how it presented.
Early on the viewer simply did nothing. No console error, no failed request, no exception. Frames were selected and nothing happened.
The bridge was sending index: and src:. The viewer was reading frame: and url:. Both sides worked perfectly. The condition just never passed.
postMessage has no schema, no type checking, and no failure mode for a message nobody wanted. If you build a contract across that boundary, write it down in one place — I keep it as a comment block in the bundle — and make one side the canonical definition. A silent no-op is a considerably worse debugging experience than a thrown error, and this is a boundary that produces them by default.
The unglamorous 80%: the catalogue
The rendering is the part that photographs well. Here is the part that consumed the schedule.
62 physical frames had to be catalogued, measured, profiled and turned into assets, producing 22 new WooCommerce products and a set of moulding textures I ended up rendering with Three.js itself — the engine that displays the frames also generated the source images for them.
Then the commerce plumbing, where the interesting failures live. Painting dimensions arrive as attribute slugs, and slug conventions accumulate history. The parser handled {W}-cm-x-{H}-cm and {W}-x-{H}-cm. It did not handle the legacy bare N-x-N form, used by 17 terms. Those returned null, the dimensions message was never sent, and the viewer silently kept its default 40×30 opening — so Monet’s water lilies at 260×60, a 4.33:1 panorama, rendered approximately square.
var m = slug.match( new RegExp( '^' + NUM + '-cm-x-' + NUM + '-cm$' ) )
|| slug.match( new RegExp( '^' + NUM + '-x-' + NUM + '-cm$' ) )
|| slug.match( /^(\d+)-x-(\d+)$/ ); // legacy bare formatOne added alternation. Days to find, because the failure was a plausible-looking render rather than an error.
None of this is interesting. All of it is the project. If you are estimating a configurator build, estimate the catalogue and the commerce integration, then add a weekend for the 3D.
What I would tell you before you start
- Put it in an iframe. A commerce product page is not somewhere you control the DOM.
- One side owns the size. Let the content report shape; let the host decide pixels.
- Emit only on change. An aspect ratio posted every animation frame is a resize loop.
- Simulate the product’s container, reproduce the product. Tone-map the frame, never the artwork.
- Move the boot cost, don’t optimise it. 4.1 seconds nobody is waiting through is free.
- Prefer a readiness watchdog to a feature probe. It catches every reason the thing didn’t appear, not one.
- Your no-WebGL users are in Facebook’s browser, not on a museum-piece Android.
- On mobile, add the configurator — never substitute it for the product image.
- Write the postMessage contract down. Nothing on that boundary will ever tell you it is wrong.
- The 3D is a weekend. The catalogue is the project.
It is live: https://svetslik.si/en/gustav-klimt/the-kiss/ — pick a frame and drag to rotate.
