<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dominik</title>
    <description>The latest articles on DEV Community by Dominik (@gronskideveloper).</description>
    <link>https://dev.to/gronskideveloper</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4063459%2F22e7ac79-5161-44b2-b933-ff19ebcb5783.png</url>
      <title>DEV Community: Dominik</title>
      <link>https://dev.to/gronskideveloper</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gronskideveloper"/>
    <language>en</language>
    <item>
      <title>Building a 3D Product Configurator in Three.js — Lessons From 9 Client Deployments</title>
      <dc:creator>Dominik</dc:creator>
      <pubDate>Wed, 05 Aug 2026 06:47:15 +0000</pubDate>
      <link>https://dev.to/gronskideveloper/building-a-3d-product-configurator-in-threejs-lessons-from-9-client-deployments-3c90</link>
      <guid>https://dev.to/gronskideveloper/building-a-3d-product-configurator-in-threejs-lessons-from-9-client-deployments-3c90</guid>
      <description>&lt;p&gt;Over the last year I shipped &lt;strong&gt;9 production 3D configurators&lt;/strong&gt; for polish manufacturers — pools, garage doors, saunas, pergolas, greenhouses, packaging, decorative lamps, terrace roofs, and light-boxes. Each one runs live on its own subdomain of my studio at &lt;a href="https://grodev.pl" rel="noopener noreferrer"&gt;grodev.pl&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Some of the lessons were obvious in hindsight. Some cost me a weekend of debugging. Sharing the non-obvious ones here.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Draco compression is not optional for CAD-heavy models
&lt;/h2&gt;

&lt;p&gt;Manufacturers send you &lt;strong&gt;STEP or SolidWorks files exported to glTF&lt;/strong&gt;. Raw output is 40–120 MB per variant. On 4G mobile that's a 20-second load with an empty white canvas.&lt;/p&gt;

&lt;p&gt;Draco compression brings that to 2–5 MB with &lt;strong&gt;no visible quality loss&lt;/strong&gt; on product shots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GLTFLoader&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;three/examples/jsm/loaders/GLTFLoader.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DRACOLoader&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;three/examples/jsm/loaders/DRACOLoader.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dracoLoader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DRACOLoader&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;dracoLoader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDecoderPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/draco/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// self-hosted, don't use CDN&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GLTFLoader&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDRACOLoader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dracoLoader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nx"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/models/pool-3.5m.glb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gltf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gltf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Self-host the decoder — Google's CDN version added ~600 ms to first paint in my measurements. Copy &lt;code&gt;node_modules/three/examples/jsm/libs/draco/&lt;/code&gt; to your &lt;code&gt;public/&lt;/code&gt; folder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tooling:&lt;/strong&gt; &lt;code&gt;gltf-pipeline -i model.glb -o model.draco.glb --draco.compressionLevel 10&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Instancing beats individual meshes past ~200 objects
&lt;/h2&gt;

&lt;p&gt;A pergola with 40 louvres × 3 tilt positions × user color picker = 120 meshes updating on every frame. Naive approach tanks FPS to 12 on mid-range phones.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;InstancedMesh&lt;/code&gt; batches identical geometry into one draw call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;geo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BoxGeometry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MeshStandardMaterial&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;louvres&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InstancedMesh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;geo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dummy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Object3D&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;dummy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;dummy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userTilt&lt;/span&gt;  &lt;span class="c1"&gt;// update per frame is fine&lt;/span&gt;
  &lt;span class="nx"&gt;dummy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateMatrix&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;louvres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMatrixAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dummy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;louvres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instanceMatrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;needsUpdate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;louvres&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same for basen tiles, brama slats, sauna wall boards. &lt;strong&gt;One material change updates all instances.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Ship the price calculator to the server, not the client
&lt;/h2&gt;

&lt;p&gt;Every configurator eventually needs a "Show price" button. Tempting to compute client-side — you already have all the state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't.&lt;/strong&gt; Manufacturers change prices monthly. Every hardcoded PHP-in-JS multiplication is a redeploy. Also: users open DevTools.&lt;/p&gt;

&lt;p&gt;Instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only what changed goes over the wire&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pool-3.5m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granite-grey&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;extras&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lighting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cover&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deliveryWeeks&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/quote&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server (Laravel in my case) hits a &lt;code&gt;pricing_rules&lt;/code&gt; table with monthly-updated coefficients. Client only knows what a valid config looks like, never the pricing logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bonus:&lt;/strong&gt; the same endpoint powers the "email me a PDF quote" flow. Zero duplication.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Mobile is your target device, not a "nice to have"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;70% of my configurator traffic is mobile.&lt;/strong&gt; Sauna buyers browse on the couch, pool buyers browse at the site. Any deployment that assumes desktop-first will feel broken.&lt;/p&gt;

&lt;p&gt;Concrete mobile survival kit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))&lt;/code&gt; — clamping to 2 saves ~40% GPU on retina phones&lt;/li&gt;
&lt;li&gt;Detect &lt;code&gt;WEBGL_lose_context&lt;/code&gt; and reload cleanly when the browser tab is backgrounded on iOS&lt;/li&gt;
&lt;li&gt;Preload one hero material, lazy-load the rest — first paint at 400 ms feels instant even if full swatch library is still coming&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. The integration is 60% of the project
&lt;/h2&gt;

&lt;p&gt;Building the WebGL viewer takes 2–3 weeks. Making it actually feed the manufacturer's WooCommerce / CRM / ERP takes &lt;strong&gt;another 4–6 weeks&lt;/strong&gt; — and the client only sees the shiny part.&lt;/p&gt;

&lt;p&gt;Budget accordingly. I now quote &lt;strong&gt;12–55k PLN&lt;/strong&gt; depending on catalog depth and integration surface, not per screen.&lt;/p&gt;




&lt;p&gt;Live examples if you want to see any of these techniques in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://basen3d.grodev.pl" rel="noopener noreferrer"&gt;basen3d.grodev.pl&lt;/a&gt;&lt;/strong&gt; — pool configurator (Draco + PWA)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://pergola3d.grodev.pl" rel="noopener noreferrer"&gt;pergola3d.grodev.pl&lt;/a&gt;&lt;/strong&gt; — bioclimatic pergola (InstancedMesh louvres)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://lampy3d.grodev.pl" rel="noopener noreferrer"&gt;lampy3d.grodev.pl&lt;/a&gt;&lt;/strong&gt; — decorative lamp (white-label)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full studio at &lt;strong&gt;&lt;a href="https://grodev.pl" rel="noopener noreferrer"&gt;grodev.pl&lt;/a&gt;&lt;/strong&gt; — happy to chat if you're integrating similar for a manufacturer.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What tripped you up on your first Three.js production project? Reply — always curious about other people's disaster stories.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>threejs</category>
      <category>webgl</category>
      <category>javascript</category>
      <category>ecommerce</category>
    </item>
  </channel>
</rss>
