Code can be art. Whether it's in clever syntax, elegant data structures, or refined interactions, there’s beauty only programmers see—and that’s fine.
But code can also create something visually stunning, something everyone can appreciate. This is where tools like Three.js shine. However, Three.js can be heavy, especially when used in a dynamic web page accessed by devices with varying computational power.
If you’re like me, adding multiple Three.js scenes to your site (as I do on didof.dev), you’ll need optimizations. Here are three practical techniques to keep performance in check.
Originally posted on my blog.
Don’t load a scene if it’s not visible. This applies to any heavy graphical component. The best tool for this is IntersectionObserver, which detects when an element enters the viewport. Here's how I handle it in SvelteKit:
<script lang="ts"> import { browser } from '$app/environment'; import { onMount } from 'svelte'; let ref: HTMLDivElement; let download = $state(false); if (browser) onMount(() => { const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) { download = true; // we need this once only observer.disconnect(); } }); // ref has been bound by Svelte since we are in onMount observer.observe(ref); return () => observer.disconnect(); }); </script> <div bind:this={ref}> {#if download} <!-- let SvelteKit handle the code splitting --> {#await import('./three-scene.svelte')} Loading {:then module} <module.default /> {:catch error} <div>{error}</div> {/await} {/if} </div>
If a scene isn’t visible, stop rendering it. Most tutorials focus on a single fullscreen scene, but for sites with multiple scenes, pausing hidden ones saves resources.
Here’s a snippet using IntersectionObserver to control a scene’s animation loop:
function tick() { const elapsedTime = clock.getElapsedTime(); // Update your scene (e.g. set uniforms, move/rotate geometries...) renderer.render(scene, camera); } // Start the rendering renderer.setAnimationLoop(tick);
Once again, our friend IntersectionObserver comes to our aid.
let clock: THREE.Clock; let renderer: THREE.WebGLRenderer; if (browser) onMount(() => { const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) { clock.start(); renderer.setAnimationLoop(tick); // resume } else { clock.stop(); renderer.setAnimationLoop(null); // pause } }); observer.observe(canvas); // Scene setup... return () => { observer.disconnect(); // Other cleanup... }; });
Devices with smaller screens are often less powerful. Adapt your shader’s computational workload accordingly. For example, reduce the number of octaves used in a fractal shader based on the viewport width:
from the browser...
<script lang="ts"> import ThreeScene from "./three-scene.svelte"; import { browser } from '$app/environment'; const octaves = browser ? (window.innerWidth <= 680 ? 2 : 4) : 1 </script> <ThreeScene {octaves} />
...through three.js...
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms: { uOctaves: new Three.Uniform(octaves) // coming as $prop } });
...finally, in the shader.
uniform float uOctaves; for(float i = 0.0; i <= uOctaves; i++) { elevation += simplexNoise2d(warpedPosition * uPositionFrequency * pow(2.0, i)) / pow(2.0, i + 1.0); }
This approach dynamically balances performance and visual quality.
Here’s where things get tricky. Three.js doesn’t automatically clean up memory, and you need to manually track and dispose of objects like geometries, textures, and materials. If you skip this, memory usage grows every time you navigate away and back, eventually crashing the browser.
Let me share what I observed on my homepage:
Initial memory usage: 22.4MB
After soft-navigation to another page: 28.6MB (even though that page was static HTML).
After repeated navigation back and forth: Memory usage kept climbing until the browser crashed.
Why? Because Three.js objects weren’t being disposed of properly. And despite extensive research, I couldn’t find a reliable way to fully clean up memory in modern frameworks.
Here’s the simplest solution I found: force a hard-reload when leaving pages with Three.js scenes. A hard-reload lets the browser:
In SvelteKit, this is easy with data-sveltekit-reload. Just enable it for pages with scenes:
homepage's server.page.ts
<script lang="ts"> import { browser } from '$app/environment'; import { onMount } from 'svelte'; let ref: HTMLDivElement; let download = $state(false); if (browser) onMount(() => { const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) { download = true; // we need this once only observer.disconnect(); } }); // ref has been bound by Svelte since we are in onMount observer.observe(ref); return () => observer.disconnect(); }); </script> <div bind:this={ref}> {#if download} <!-- let SvelteKit handle the code splitting --> {#await import('./three-scene.svelte')} Loading {:then module} <module.default /> {:catch error} <div>{error}</div> {/await} {/if} </div>
For navigation links, pass this value dynamically:
function tick() { const elapsedTime = clock.getElapsedTime(); // Update your scene (e.g. set uniforms, move/rotate geometries...) renderer.render(scene, camera); } // Start the rendering renderer.setAnimationLoop(tick);
If you use a generic component, you only need to implement this once.
This approach isn’t perfect—it disables smooth client-side routing for specific pages—but it keeps memory in check and prevents crashes. For me, that trade-off is worth it.
These optimizations have worked well for me, but the question remains: how do we properly clean up Three.js objects in modern frameworks? If you’ve found a reliable solution, I’d love to hear from you!
The above is the detailed content of Optimizing Three.js: ey Techniques. For more information, please follow other related articles on the PHP Chinese website!