Comment optimiser la performance 3D web : Three.js, R3F et le hero en lazy-load
Un hero 3D a l'air génial dans une vidéo de lancement produit et fait tomber votre audit Lighthouse. Voici comment nous avons livré une homepage Three.js qui maintient la bundle initial lean et la scène interactive chaude.
Le hero 3D de 2run.be est construit sur Three.js avec React Three Fiber. La première version de cette page ajoutait 600 KB au bundle initial et poussait la Performance Lighthouse-mobile sous 60. La version actuelle ne livre la scène 3D qu'après que l'utilisateur a scrollé à proximité, et pèse sur le chemin critique un dixième de ce que la première version pesait. Voici comment nous y sommes arrivés.
Ce qu'il y a dans une scène 3D web
Un graphe de scène R3F typique traîne avec lui :
- Three.js lui-même (~150 KB gzippé pour le noyau, plus avec controls, loaders, postprocessing).
- React Three Fiber (~30 KB) et Drei (~50 KB+ selon les helpers utilisés).
- Les loaders de geometry et de material que vous utilisez réellement.
- Les programmes de shaders, si vous écrivez des shaders personnalisés.
Une baseline conservatrice est 250-350 KB gzippé pour une scène non triviale. Une scène plus lourde avec physics, postprocessing et shaders personnalisés peut dépasser 600 KB. Ce nombre est toute l'histoire pour la performance : il concurrence le budget du chemin critique avec le reste de la page.
Deux approches
Approche A : livrer sur le chemin critique
Vous vous engagez à ce que la scène 3D fasse partie du premier paint. La scène complète, y compris les loaders de geometry et les controls, atterrit dans le bundle initial. L'utilisateur voit la scène 3D dès que le HTML arrive. L'histoire first-paint est l'histoire 3D.
Cela fonctionne si :
- Le reste de la page est réellement minimal (un hero portfolio, une landing page).
- La scène 3D est la value proposition — vendre la scène c'est vendre le produit.
- Les utilisateurs mobiles dans votre marché cible sont principalement sur wifi.
Cela ne fonctionne pas si :
- La scène 3D est décorative, pas fonctionnelle.
- La page a plus de contenu (texte, CTAs, un formulaire) que juste la scène.
- Le LCP mobile fait partie de votre engagement Core Web Vitals.
Approche B : lazy-load après première interaction
Vous reportez la scène 3D à un import next/dynamic avec ssr: false, et déclenchez le chargement uniquement quand l'utilisateur scrolle à proximité du hero ou après un petit délai. Le bundle initial ne porte jamais le payload 3D. La scène apparaît quand l'utilisateur va la voir.
Cela fonctionne si :
- Le contenu hero au-dessus du fold peut tenir tout seul (une image, un dégradé, une affirmation typographique).
- L'histoire first-paint est les promesses de la marque, pas la scène 3D.
- Vous avez un
IntersectionObserverou un scroll-listener pour déclencher le chargement.
C'est l'approche sur laquelle nous avons atterri pour la homepage 2Run. Le compromis :
- Pour : Le bundle initial baisse de 600 KB. La Performance Lighthouse-mobile passe de sous 60 à au-dessus de 90.
- Contre : L'utilisateur voit ~0,5 s un placeholder avant que la scène se peuple. Avec un bon placement de la zone de déclenchement, cela donne l'impression que la scène « monte » plutôt qu'« apparaît ».
Le squelette d'implémentation
Le pattern, réduit à l'essentiel :
- Un hook
useInViewqui retourne un booléen quand la ref cible entre dans la viewport. - Un import
next/dynamicpour le composant scène lourd avecssr: falseet un skeleton léger en chargement. - La section hero rend la typographie et le CTA avec empressement, puis monte la scène conditionnellement quand
inViewbascule à true.
Un gotcha subtil : le composant scène doit aussi accepter un fallback reduced-motion. Les utilisateurs qui ont demandé reduced-motion dans le système d'exploitation reçoivent le hero typographique sans la scène 3D, et la Performance Lighthouse n'est pas la seule métrique qui compte.
Mobile et la classe la plus basse
La vérité honnête sur le 3D mobile : un téléphone Android milieu de gamme à 30 °C de throttling batterie ne peut toujours pas se mesurer à un navigateur desktop avec la même complexité de scène. Deux chemins :
- Détecter et dégrader. Utilisez un check de capacités
gl(renderer.getContext() et un petit benchmark) et servez une scène lower-poly sur les appareils plus faibles. Meilleure UX, coûte du temps d'ingénierie. - Rendre inconditionnellement et accepter le coût. Très bien pour les acheteurs B2B sur des appareils gérés par l'entreprise. Pas très bien pour un public consommateur.
Nous avons choisi le premier chemin. La logique de détection tourne une fois par session et le résultat est caché dans sessionStorage. La scène fallback utilise une geometry lower-poly et supprime la passe de postprocessing.
Ce qui n'est pas dans cette histoire
- Le rendu côté serveur des scènes 3D. Il n'y a pas de SSR pour Three.js — le canvas a besoin d'un contexte DOM. Les stubs SSR pour une scène 3D sont généralement décoratifs et ajoutent de la complexité SSR pour un gain limité.
- La précompilation des shaders GPU. De vrais gains, mais à moins que vous ne livriez une couche de postprocessing personnalisée, le JIT du navigateur est suffisamment bon.
- Le 3D sur le LCP lui-même. Cibler le 3D comme élément LCP est le chemin vers un premier paint rapide pour un portfolio 3D et un LCP lent pour une page riche en contenu. Choisissez celui que vous voulez où.
Le take-away
Traitez les scènes 3D web comme vous traiteriez toute autre feature lourde : profilez le bundle, reportez ce que vous pouvez, rendez la bonne classe sur le bon appareil, et assumez le compromis explicitement. Une scène 3D qui fait échouer les Core Web Vitals n'est pas une victoire créative — c'est un site de lancement qui ne ranke pas.
