Tree Shaking Three.js

The current status the engine’s tree shaking support is mentioned here: Three.js file size when importing via NPM and bundling with Webpack

1 Like

Related: https://github.com/yushijinhun/three-minifier

3 Likes

You sir just got a star. The GLSL minifier mostly removes the comments? The gl literal thing is super interesting, how much does this alone shave off? This is absolutely guaranteed to be the same across the universe?

Tested with rollup:

  • noCompileGLConstants: false, noCompileGLSL: false: 354K
  • noCompileGLConstants: true, noCompileGLSL: false: 354K
  • noCompileGLConstants: false, noCompileGLSL: true: 372K
  • noCompileGLConstants: true, noCompileGLSL: true: 372K

See https://github.com/mrdoob/three.js/blob/03cad1e860c32fe47461afb41dcc73ab26f378cb/utils/build/rollup.config.js

2 Likes

Just to confirm what i’m reading here, it doesn’t seem like it shaved off much, if anything?

Inlining GL constants is just a minor feature. The main function of the plugin is to alter module resolution, redirecting three and three/build/three.module.js to three/src/Three.js and marking it side-effects free. This can largely improve the performance of tree-shaking.

1 Like

@yushijinhun great to see your work on trying to get three.js smaller :slightly_smiling_face: we are also always on the search to get the size of three.js down because we have a three.js app which should run on mobile devices as well and therefore each byte less of JavaScript is great.

When I have time I’ll give the project from @yushijinhun a try and see if it works with our app as well

Hi!

I just want to share our experience with treeshaking a project using three and GSAP. We’re bundling with Rollup and our solution at the moment sounds somewhat similar to what @yushijinhun’s plugin is supposed to do, but still a bit different:

  • rollup treeshake:
    add treeshake: { moduleSideEffects: false } to Rollup config. If this is not done GSAP and THREE will be fully added including all modules and plugins even if you just import / use one of them (notice: this will happen with all namespaced libraries like ‘three’ or ‘gsap’). See rollup treeshake docs

  • three as npm dependency:
    import { Foo } from 'three', means from 'node_modules/three/build/three.module.js' does NOT treeshake, you have to import from 'src': e.g. import { Foo } from '../../node_modules/three/src/Three'. To achieve this easily we write from 'three' in our code and use the @rollup/plugin-alias to replace all 'three' entries accordingly when bundling, like this:

plugins: [
alias({
            entries: [
                {
                    find: 'three',
                    replacement: __dirname + '/node_modules/three/src/Three'
                }
            ],
        })
]

The above method works / treeshakes perfectly if you use modules from inside 'three/src' folder ONLY. As soon as you import something from the 'three/examples' folder, e.g. 'import { Foo } from '../../node_modules/three/examples/jsm/loaders/Foo', full three is going to be added to the bundle ADDITIONALLY because (as it seems) modules inside 'three/examples/jsm' import from 'three/build/three.module.js'. I’m not quite sure why exactly this is happening, but we’ve found a solution (not sure if best) which works:

At the moment we only need the GLTFLoader and the DRACOLoader both inside 'three/examples/jsm/loaders'. We copied them to the 'three/src/loaders' and changed the imports inside them accordingly + added corresponding exports to 'three/src/Three.js' & 'three/src/Three.d.ts'. See here Move GLTF & DRACO Loaders from 'examples' to 'src' · cream-gmbh/three-116-1@9c2b1d3 · GitHub

This way the Loaders get also treeshaked & we get the smallest possible bundle size, I guess… well, let’s say I’m pretty sure. :wink: check out https://cream.gmbh/ to see what I’m talking about (bundle size 840 KB / 273 KB gzipped incl. three, gsap, svelte & website-engine / animations). (notice: GLTFLoader is importing A LOT of modules, so without it, the bundle size would be even smaller.)

I’m not sure though if this is the best, most elegant way to do this :thinking:, means maybe there are some other Rollup config tweaks we didn’t figure out by now + this way any 'three/examples/jsm'-modules you might need would have to be copied and changed like the both Loaders mentioned above.

I hope this helps & please feel free to correct me / propose a better solution! :grin:

3 Likes

Thanks for sharing, that’s very interesting. I’m curious, if you have time, can you check what the smallest bundle size is if you import just a single thing from three.js? E.g:

import { Scene } from 'three';
// or 
import { WebGLRenderer } from 'three';

That will give a better idea of how well tree shaking is working using your method.

Here’s another way using aliases, this brings threejs down to 95kb for simple geometries and materials, you don’t have to change the way you write imports and it works for add-ons and third party imports.

registering the alias: https://github.com/drcmda/testlighting/blob/faed149a35d44d116b5c30434f26fb3f98b0d07b/config-overrides.js
new index: https://github.com/drcmda/testlighting/blob/faed149a35d44d116b5c30434f26fb3f98b0d07b/src/utils/three.js

if you scroll down you see that you can even mock objects. if threejs depends on something you know it’s not going to use, just fake it.

export class Raycaster {
  setFromCamera() {}
  intersectObjects() {
    return []
  }
}

i got it lower than 80kb by digging into WebGLRenderer manually, but here it gets messed up.

1 Like

Ok, tested with this simple Svelte-App (three 116.1):

<script>
    import {onMount} from 'svelte';
    import {WebGLRenderer, Scene} from "../node_modules/three/src/Three";

    let scene;
    let renderer;
    let c;

    onMount(() => {
        scene = new Scene();
        renderer = new WebGLRenderer({canvas: c});
    });

</script>

<style></style>

<canvas bind:this={c}></canvas>

Scene + WebGLRenderer:

722 K unminified / uncompressed on disk
379 K minified (terser) / uncompressed on disk

Chrome DevTools Network Tab:
95.8 K transferred over network, resource size 388 K

WebGLRenderer only
(same as Scene + WebGLRenderer as WebGLRenderer is importing ‘Scene’):

722 K unminified / uncompressed on disk
379 K minified (terser) / uncompressed on disk

Chrome DevTools Network Tab:
95.8 K transferred over network, resource size 388 K

Scene only

99 K unminified / uncompressed on disk
44 K minified (terser) / uncompressed on disk

Chrome DevTools Network Tab:
13.5 K transferred over network, resource size 45.2 K

2 Likes

@cream I’ve tested this out on a simple app that uses GLTFLoader + OrbitControls, no other libraries. This results in about 340kb less (uncompressed) code. That’s great! :grin:

Using the Chrome coverage tool, I’ve gone from about 50% unused JS to 37%.

EDIT: I compared this with the edited src/Three.js file @drcmda shared above, manually removing everything possible and replacing unused classes with empty classes.

I have been able to remove an extra 32kb (again, pre-minification). That brings my code coverage down to 35.5%.

Here are my results:

Initial size: 1323kb
Using @cream’s technique: 981kb
Manually editing src/Three.js: 949kb

Manually editing the src/ code is a lot of extra work for diminishing returns. I don’t think it’s worth it, personally.

2 Likes

As I know here solution how to make Three.js tree-shakable out of the box. We need a hero. Node materials benchmark is pretty good. Pure profit.

Well as I see it, the most of it already is tree-shakable, you can get bundle sizes (three + etc.) below 300 KB transferred over network and that’s a single high-quality JPEG, so I’m pretty satisfied with that :man_shrugging: Just import from ‘src’ and move example-based modules to ‘src’ if you need them.

Switching to Node-based materials would be cool for several other reasons, but tree-shaking? :thinking:
I just tested adding MeshStandardMaterial to my simple example above, it added only 5K (unminified / uncompressed) to the bundle.

1 Like

Well as I see it, the most of it already is tree-shakable

I think the reason the bundle size is still so large, even when tree-shaking is working, is that all the GLSL code is still imported. That means you still get all the code from src/renderer/shaders even when you just use a MeshStandardMaterial.
I have not personally checked this, just going from other people’s comments.
The hope is that switching to node materials would allow for tree shaking GLSL code as well, which would potentially reduce the bundle size by another ~100kb.

2 Likes

ah, I see, so it is relevant to tree-shaking. Well, nothing against that cherry on top of all other benefits concerning more creativity with materials / shaders. :rocket:

I do not know how the node-based materials (Promote Node-based Materials to Core and Polish · Issue #16440 · mrdoob/three.js · GitHub) could help with three-shaking but I only skipped over this issue.

It’s totally true what @looeee wrote. When you include something from the ShaderLib, all Shaders get dragged in and there is no chance for further tree-shaking.

For our project, we created a small “hack” which puts all the shaders into a JSON file. This brings the bundle size down and parsing a JSON is even faster than parsing JS. If there is any interest in how we split the shaders out of the JavaScript bundle I can outline it here. Just let me know :slightly_smiling_face:

Some other things we always should keep in mind is, that parsing a JPEG is something completely different than parsing a JS file. A very interesting read can be found here at the google developer blog.

but the gist is:

So 300KB in JS affects boot time performance much more than a 300KB big JPEG. So maybe there is a chance to get out the shaders from JS.

6 Likes

Yes please!

Absolutely. It’s a huge difference. If you want to simulate this you can use Chrome dev tools and set to “low end mobile”. Itt takes 7.4s just to load three.module.js:

Then 123ms to compile:

And finally 985ms to “evaluate script” which I think means parsing:

I’m not sure what they mean by “low-end mobile” but from my experience testing on budget phones, this seems accurate. So it takes around 20s to load the cloth example. If you care at all about people that don’t own high-end phones, then reducing JS payload is hugely important.

Yea and only after that we loading resources. Just waste of time and we need to download it in parallel.

Here picture from rollup-bundle-visualizer, I guess this pink brick can be reduced.

4 Likes

cool. :sunglasses: can’t believe I didn’t use the visualizer yet. :man_facepalming:

I modified our simple svelte example a bit:

<script>
    import {onMount} from 'svelte';
    import {Scene, Camera, WebGLRenderer, MeshStandardMaterial} from "../node_modules/three/src/Three";

    let s;
    let cam;
    let r;
    let m;
    let c;

    onMount(() => {
        r = new WebGLRenderer({canvas: c});
        m = new MeshStandardMaterial();
        s = new Scene();
        cam = new Camera();

        s.add(cam);
    });

</script>

<canvas bind:this={c} ></canvas>

here the visualization:

the nice colored squares on the right are all ShaderChunks imported by ShaderChunk.js, alltogether roundabout 122 KB, means 18.6% of all three related imports.

1 Like