Ottimizzare Three.js: 4 Tecniche Chiave

Scritto da Francesco Di Donato • 12 febbraio 2024 • 4 minuti di lettura
Il codice può essere arte. Che si tratti di una sintassi intelligente, di strutture dati eleganti o di interazioni raffinate, c’è una bellezza che solo i programmatori vedono—e va bene così.
Ma il codice può anche creare qualcosa di visivamente sbalorditivo, qualcosa che tutti possono apprezzare. È qui che strumenti come [Three.js][three-js] brillano. Tuttavia, Three.js può essere pesante, specialmente se usato in una pagina web dinamica accessibile da dispositivi con potenze di calcolo diverse.
Se sei come me, e aggiungi diverse scene Three.js al tuo sito (come faccio su [didof.dev][didof.dev]), avrai bisogno di ottimizzazioni. Ecco tre tecniche pratiche per tenere sotto controllo le prestazioni.
Carica le Scene Solo Quando Necessario
Non caricare una scena se non è visibile. Questo vale per qualsiasi componente grafico pesante. Lo strumento migliore per questo è [IntersectionObserver
][intersection-observer], che rileva quando un elemento entra nel viewport. Ecco come lo gestisco in [SvelteKit][svelte-kit]:
<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;
// ci serve solo una volta
observer.disconnect();
}
});
// ref è stato collegato da Svelte poiché siamo in onMount
observer.observe(ref);
return () => observer.disconnect();
});
</script>
<div bind:this={ref}>
{#if download}
{#await import('./three-scene.svelte')}
Caricamento
{:then module}
<module.default />
{:catch error}
<div>{error}</div>
{/await}
{/if}
</div>
Metti in Pausa le Scene Fuori dalla Vista
Se una scena non è visibile, smetti di renderizzarla. La maggior parte dei tutorial si concentra su una singola scena a schermo intero, ma per siti con più scene, mettere in pausa quelle nascoste risparmia risorse.
Ecco uno snippet che usa IntersectionObserver
per controllare il ciclo di animazione di una scena:
function tick() {
const elapsedTime = clock.getElapsedTime();
// Aggiorna la tua scena (es. imposta uniform, muovi/ruota geometrie...)
renderer.render(scene, camera);
}
// Avvia il rendering
renderer.setAnimationLoop(tick);
Ancora una volta, il nostro amico IntersectionObserver
ci viene in aiuto.
let clock: THREE.Clock;
let renderer: THREE.WebGLRenderer;
if (browser)
onMount(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
clock.start();
renderer.setAnimationLoop(tick); // riprendi
} else {
clock.stop();
renderer.setAnimationLoop(null); // metti in pausa
}
});
observer.observe(canvas);
// Impostazione della scena...
return () => {
observer.disconnect();
// Altra pulizia...
};
});
Adatta il Carico di Lavoro dello Shader alla Dimensione della Finestra
I dispositivi con schermi più piccoli sono spesso meno potenti. Adatta di conseguenza il carico computazionale del tuo shader. Ad esempio, riduci il numero di ottave utilizzate in uno shader frattale in base alla larghezza del viewport:
<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} />
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uOctaves: new Three.Uniform(octaves), // proveniente come $prop
},
});
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);
}
Questo approccio bilancia dinamicamente prestazioni e qualità visiva.
Lascia che sia il Browser a Occuparsi della Pulizia
Qui le cose si complicano. Three.js non pulisce automaticamente la memoria, e devi tracciare e rilasciare manualmente oggetti come geometrie, texture e materiali. Se salti questo passaggio, l’uso della memoria cresce ogni volta che navighi via e torni indietro, portando alla fine al crash del browser.
Lascia che ti mostri cosa ho osservato sulla mia homepage:
Uso iniziale della memoria: 22.4MB
Dopo una navigazione soft verso un’altra pagina: 28.6MB (anche se quella pagina era HTML statico).
Dopo ripetute navigazioni avanti e indietro: L’uso della memoria continuava a salire fino a quando il browser non è andato in crash.
Perché? Perché gli oggetti Three.js non venivano rilasciati correttamente. E nonostante ricerche approfondite, non sono riuscito a trovare un modo affidabile per pulire completamente la memoria nei framework moderni.
Ecco la soluzione più semplice che ho trovato: forzare un ricaricamento completo (hard-reload) quando si lasciano pagine con scene Three.js. Un hard-reload permette al browser di:
- Creare un nuovo contesto di pagina.
- Eseguire il garbage collection sulla vecchia pagina (lasciando la pulizia al browser).
In SvelteKit
, questo è facile con data-sveltekit-reload. Basta abilitarlo per le pagine con le scene:
export function load() {
return {
sveltekitReload: true,
};
}
Per i link di navigazione, passa questo valore dinamicamente:
<script lang="ts">
import { page } from '$app/stores';
</script>
<a href="/docs" data-sveltekit-reload={$page.data.sveltekitReload}>Docs</a>
Se usi un componente
<Link>
generico, devi implementarlo solo una volta.
Questo approccio non è perfetto—disabilita il routing fluido lato client per pagine specifiche—ma mantiene la memoria sotto controllo e previene i crash. Per me, questo compromesso ne vale la pena.
Considerazioni Finali
Queste ottimizzazioni hanno funzionato bene per me, ma la domanda rimane: come puliamo correttamente gli oggetti Three.js nei framework moderni? Se hai trovato una soluzione affidabile, mi piacerebbe sentirla!