<?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: Md. Raju Ahmed</title>
    <description>The latest articles on DEV Community by Md. Raju Ahmed (@rifatsarkerraju).</description>
    <link>https://dev.to/rifatsarkerraju</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%2F4133349%2F2de61cca-ce53-41d9-9296-20e744442901.png</url>
      <title>DEV Community: Md. Raju Ahmed</title>
      <link>https://dev.to/rifatsarkerraju</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rifatsarkerraju"/>
    <language>en</language>
    <item>
      <title>Cutting a Three.js product configurator from 16.7 MB to 2.4 MB</title>
      <dc:creator>Md. Raju Ahmed</dc:creator>
      <pubDate>Sat, 19 Sep 2026 20:12:51 +0000</pubDate>
      <link>https://dev.to/rifatsarkerraju/cutting-a-threejs-product-configurator-from-167-mb-to-24-mb-2bka</link>
      <guid>https://dev.to/rifatsarkerraju/cutting-a-threejs-product-configurator-from-167-mb-to-24-mb-2bka</guid>
      <description>&lt;p&gt;I built a sneaker configurator that runs in a browser tab — orbit the shoe,&lt;br&gt;
switch colourways and materials, watch the price update. It works. But the&lt;br&gt;
first version had a problem that had nothing to do with 3D, and everything to&lt;br&gt;
do with whether anyone would ever see it.&lt;/p&gt;

&lt;p&gt;The model was 16.7 MB.&lt;/p&gt;

&lt;p&gt;On a laptop on office wifi that is a two second wait. On a mid-range Android&lt;br&gt;
phone on mobile data in Bangladesh, where I built this, it is not a slow&lt;br&gt;
experience. It is an abandoned one. The user leaves before the first frame.&lt;/p&gt;

&lt;p&gt;This is a write-up of how I got it to 2.4 MB, and of the second problem I hit&lt;br&gt;
on the way — that the obvious way to recolour a 3D model destroys the thing&lt;br&gt;
you built it to show.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the weight actually is
&lt;/h2&gt;

&lt;p&gt;The first thing worth saying: "the model is too big" is not a diagnosis. A&lt;br&gt;
glTF file is geometry plus textures plus everything else, and those three&lt;br&gt;
respond to completely different treatments. Before optimising anything I&lt;br&gt;
wanted to know the split.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx gltf-transform inspect model.glb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints a table of meshes, materials and textures with their sizes. In my&lt;br&gt;
case the split was roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Textures&lt;/strong&gt; — the large majority of the file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Geometry&lt;/strong&gt; — a meaningful chunk, but smaller than I expected&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything else&lt;/strong&gt; — noise&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters because it tells you where to spend effort. Draco is the famous&lt;br&gt;
answer and it is a good one, but Draco compresses &lt;em&gt;geometry&lt;/em&gt;. If textures are&lt;br&gt;
80% of your file, Draco alone will not save you.&lt;/p&gt;
&lt;h2&gt;
  
  
  Geometry: Draco
&lt;/h2&gt;

&lt;p&gt;Draco quantises vertex attributes and entropy-codes the result. The important&lt;br&gt;
parameter is quantisation bits per attribute, and the important insight is&lt;br&gt;
that different attributes tolerate very different precision.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx gltf-transform draco model.glb model-draco.glb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--quantize-position&lt;/span&gt; 14 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--quantize-normal&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--quantize-texcoord&lt;/span&gt; 12 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--quantize-color&lt;/span&gt; 8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Position needs the most precision — drop it too far and surfaces visibly&lt;br&gt;
shift. Normals tolerate far less than people expect; at 10 bits I could not&lt;br&gt;
see a difference on curved surfaces under moving light. Texture coordinates&lt;br&gt;
sit in between, and getting them wrong shows up as texture swimming at the&lt;br&gt;
seams, which is a distinctive and ugly artefact worth knowing by sight.&lt;/p&gt;

&lt;p&gt;The defaults are conservative. Walking each value down until you can see the&lt;br&gt;
damage, then stepping back one, is worth more than any single setting I could&lt;br&gt;
give you, because the right numbers depend on your model's scale and how close&lt;br&gt;
the camera gets.&lt;/p&gt;

&lt;p&gt;On the loading side, Draco needs its decoder:&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;draco&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;draco&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="nx"&gt;draco&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDecoderConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;js&lt;/span&gt;&lt;span class="dl"&gt;'&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;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;draco&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing that cost me time: the decoder is itself a download. Serve it&lt;br&gt;
yourself rather than from a CDN you do not control, and make sure it is&lt;br&gt;
cached — otherwise you have traded model bytes for decoder bytes on every&lt;br&gt;
cold load.&lt;/p&gt;
&lt;h2&gt;
  
  
  Textures: the part that actually mattered
&lt;/h2&gt;

&lt;p&gt;Draco got me a good cut. It did not get me to 2.4 MB. The textures did.&lt;/p&gt;

&lt;p&gt;The mistake in the source asset was authoring every map at the resolution you&lt;br&gt;
would want if the camera could get arbitrarily close. But in a configurator,&lt;br&gt;
the camera cannot. There is a defined orbit distance and a maximum zoom, and&lt;br&gt;
those set a real upper bound on how much texture detail can ever reach a&lt;br&gt;
pixel.&lt;/p&gt;

&lt;p&gt;So I re-authored the set at sizes chosen for the actual viewing distance, and&lt;br&gt;
separated the maps by how much precision each one needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Base colour&lt;/strong&gt; — the map the eye judges. Keeps the most resolution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normal&lt;/strong&gt; — needs resolution, but tolerates compression artefacts poorly,
so treat it differently from colour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roughness / metalness / AO&lt;/strong&gt; — these can go dramatically smaller than
feels comfortable. They drive shading response, not perceived detail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then KTX2 with Basis Universal, which matters for a reason beyond file size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx gltf-transform uastc model.glb model-ktx2.glb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--level&lt;/span&gt; 4 &lt;span class="nt"&gt;--rdo-quality&lt;/span&gt; 4 &lt;span class="nt"&gt;--zstd&lt;/span&gt; 18
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A JPEG texture has to be decoded to raw RGBA before the GPU can use it. A&lt;br&gt;
2048×2048 texture becomes 16 MB in GPU memory regardless of how small the JPEG&lt;br&gt;
was. KTX2 stays compressed &lt;em&gt;on the GPU&lt;/em&gt;. On a mid-range phone, where GPU memory&lt;br&gt;
is the actual ceiling, this is often the difference between running and&lt;br&gt;
crashing — and it is invisible if you only measure download size.&lt;/p&gt;
&lt;h2&gt;
  
  
  The second problem: colour
&lt;/h2&gt;

&lt;p&gt;With the file small enough, the configurator had a different failure. Swapping&lt;br&gt;
a colourway made the shoe look wrong.&lt;/p&gt;

&lt;p&gt;The obvious implementation is to multiply a tint over the base colour texture:&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;// Don't do this&lt;/span&gt;
&lt;span class="nx"&gt;material&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;color&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="nx"&gt;selectedColour&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The texture was baked with lighting and shadow information in it. Multiplying&lt;br&gt;
a saturated colour over that drags everything toward flat. The fabric weave&lt;br&gt;
disappears, the baked shadows go muddy, and a red colourway stops reading as&lt;br&gt;
&lt;em&gt;the same shoe in red&lt;/em&gt; and starts reading as a red silhouette. Which defeats&lt;br&gt;
the entire point of showing it in 3D rather than as a photograph.&lt;/p&gt;

&lt;p&gt;What worked was separating luminance from chroma and replacing only the&lt;br&gt;
chroma. The luminance channel carries the weave, the stitching and the baked&lt;br&gt;
shadow — all the information that makes it look like a material. The chroma&lt;br&gt;
channel carries the colour, and that is the only part a colourway swap should&lt;br&gt;
touch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In the fragment shader, on the base colour sample&lt;/span&gt;
&lt;span class="kt"&gt;vec3&lt;/span&gt; &lt;span class="n"&gt;texel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;texture2D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vUv&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Perceptual luminance - the weights are not arbitrary,&lt;/span&gt;
&lt;span class="c1"&gt;// the eye is far more sensitive to green than to blue&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;luma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;vec3&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;2126&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;7152&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="mo"&gt;0722&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Re-apply the target colour at the texel's own brightness&lt;/span&gt;
&lt;span class="kt"&gt;vec3&lt;/span&gt; &lt;span class="n"&gt;tinted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tintColour&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Blend back toward the original where the texel is near-neutral,&lt;/span&gt;
&lt;span class="c1"&gt;// so white midsoles and metal eyelets do not get tinted&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;chroma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;texel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;texel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;texel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;texel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;gl_FragColor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rgb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tinted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;smoothstep&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="mo"&gt;02&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;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chroma&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last blend is the part I would not have guessed at the start. Without it,&lt;br&gt;
recolouring to blue also turned the white midsole blue. Masking by the texel's&lt;br&gt;
own chroma means neutral areas stay neutral and only the already-coloured&lt;br&gt;
regions take the new colourway — which is exactly what happens when a factory&lt;br&gt;
actually dyes a material.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it came to
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16.7 MB  →  2.4 MB      86% smaller
60 fps                   mid-range Android, not just a laptop
0 page reloads           every option change is client-side
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I would tell myself at the start
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Measure the split before optimising.&lt;/strong&gt; I spent my first effort on geometry&lt;br&gt;
because Draco is what everyone writes about. The textures were the larger&lt;br&gt;
problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quantisation tolerance is not uniform.&lt;/strong&gt; Normals survive far more&lt;br&gt;
aggressive settings than positions. Treating every attribute the same leaves&lt;br&gt;
a lot of bytes on the table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download size is not the only budget.&lt;/strong&gt; KTX2 mattered more for GPU memory&lt;br&gt;
than for the network, and GPU memory is what actually fails on a mid-range&lt;br&gt;
phone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test on the worst device you expect, early.&lt;/strong&gt; Everything above is obvious&lt;br&gt;
on a laptop and invisible until you are holding a cheap phone on a slow&lt;br&gt;
connection. I had one, which is probably why I noticed.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The configurator is live at &lt;a href="https://3d-bay-ten.vercel.app" rel="noopener noreferrer"&gt;https://3d-bay-ten.vercel.app&lt;/a&gt; — orbit it, swap&lt;br&gt;
the colourways, check the load on your phone.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm Md. Raju Ahmed, a freelance real-time 3D developer in Rajshahi, Bangladesh. I build&lt;br&gt;
real-time 3D on the web with Three.js, React Three Fiber and Next.js, and the&lt;br&gt;
applications it lives in. Portfolio: &lt;a href="https://rifatsarkerraju.com" rel="noopener noreferrer"&gt;https://rifatsarkerraju.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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