iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
Formations
JavaScript · Intermédiaire

Three.js

Créer de la 3D dans le navigateur de zéro : scène, modèles STL/GLTF, animation au scroll, shaders, post-processing et portfolio 3D animé complet.

Three.jsJavaScriptGSAPGLSLWebGL
01Introduction à Three.js02Géométries, matériaux et lumières03Charger des modèles 3D — STL et GLTF04Animer au scroll — Three.js + GSAP05Post-processing et effets visuels06Shaders GLSL et systèmes de particules07Portfolio 3D animé — projet complet
Chapitre 4·35 min

Animer au scroll — Three.js + GSAP

C'est le chapitre central. Animer un objet 3D au scroll est l'effet qu'on retrouve sur les sites Apple, les portfolios haut de gamme, les landing pages de produit. Le principe : récupérer la progression du scroll et l'appliquer aux propriétés Three.js (position, rotation, opacité, uniforms de shader).

Deux approches existent. On va couvrir les deux.

Approche 1 — GSAP ScrollTrigger

GSAP ScrollTrigger (couvert dans la formation GSAP) s'intègre parfaitement avec Three.js. Au lieu d'animer des propriétés CSS, on anime des propriétés d'objets JavaScript — exactement ce que GSAP sait faire.

Setup

import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
 
gsap.registerPlugin(ScrollTrigger)

Le canvas Three.js est en position: fixed, la page HTML scrolle normalement. ScrollTrigger lit la position de scroll de la page et l'applique aux objets 3D.

canvas {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
}
 
.sections {
  position: relative;
  z-index: 1;
  pointer-events: none;  /* laisser passer les events au canvas si nécessaire */
}

Animer la rotation d'un STL au scroll

// Charger le modèle
let model = null
 
stlLoader.load('/models/piece.stl', (geometry) => {
  geometry.computeVertexNormals()
  geometry.computeBoundingBox()
 
  const center = new THREE.Vector3()
  geometry.boundingBox.getCenter(center)
  geometry.translate(-center.x, -center.y, -center.z)
 
  model = new THREE.Mesh(
    geometry,
    new THREE.MeshStandardMaterial({
      color: '#cdff4f',
      roughness: 0.2,
      metalness: 0.8,
    })
  )
 
  scene.add(model)
  setupScrollAnimations()
})
 
function setupScrollAnimations() {
  // Section 1 → 2 : rotation Y + légère translation
  gsap.to(model.rotation, {
    y: Math.PI * 2,   // rotation complète
    scrollTrigger: {
      trigger: '#section-2',
      start: 'top bottom',
      end: 'top top',
      scrub: 1.5,    // lag pour un effet "rattrapé" doux
    },
  })
 
  // Section 2 → 3 : monter et pencher
  gsap.to(model.position, {
    y: 2,
    scrollTrigger: {
      trigger: '#section-3',
      start: 'top bottom',
      end: 'top top',
      scrub: 1,
    },
  })
 
  gsap.to(model.rotation, {
    x: Math.PI / 4,
    scrollTrigger: {
      trigger: '#section-3',
      start: 'top bottom',
      end: 'top top',
      scrub: 1,
    },
  })
 
  // Section 3 → 4 : disparaître
  gsap.to(model.material, {
    opacity: 0,
    scrollTrigger: {
      trigger: '#section-4',
      start: 'top bottom',
      end: 'top center',
      scrub: true,
    },
    onStart: () => { model.material.transparent = true },
  })
}

Animer la caméra au scroll

// La caméra suit un chemin entre les sections
const cameraPath = [
  { x: 0, y: 0, z: 5 },   // position initiale
  { x: 3, y: 1, z: 4 },   // section 2
  { x: -2, y: 2, z: 6 },  // section 3
  { x: 0, y: 0, z: 8 },   // section 4
]
 
document.querySelectorAll('.section').forEach((section, i) => {
  if (i === 0 || !cameraPath[i]) return
 
  const pos = cameraPath[i]
 
  gsap.to(camera.position, {
    x: pos.x,
    y: pos.y,
    z: pos.z,
    scrollTrigger: {
      trigger: section,
      start: 'top bottom',
      end: 'top top',
      scrub: 2,
    },
    onUpdate: () => camera.lookAt(model ? model.position : new THREE.Vector3()),
  })
})

Approche 2 — scroll natif + lerp

Sans GSAP, on lit window.scrollY directement dans la render loop et on interpole les valeurs. Plus léger, moins de fonctionnalités.

let scrollY = 0
let currentY = 0
 
window.addEventListener('scroll', () => {
  scrollY = window.scrollY
})
 
const sectionHeight = window.innerHeight
 
const clock = new THREE.Clock()
 
function animate() {
  requestAnimationFrame(animate)
  const delta = clock.getDelta()
 
  // Interpolation linéaire — smooth scroll effect
  currentY += (scrollY - currentY) * 5 * delta
 
  // Convertir la position de scroll en progression (0 à 1 par section)
  const section = Math.floor(currentY / sectionHeight)
  const progress = (currentY % sectionHeight) / sectionHeight
 
  if (model) {
    // Section 0 → 1 : rotation Y
    model.rotation.y = currentY / sectionHeight * Math.PI * 2
    // Section 1 → 2 : monter
    model.position.y = -currentY / sectionHeight
  }
 
  renderer.render(scene, camera)
}
 
animate()

Quand utiliser quoi :

  • GSAP ScrollTrigger : contrôle précis par section, scrub configuré, code déclaratif → recommandé
  • Scroll natif + lerp : minimaliste, pas de dépendance GSAP, bonne pour des transitions continues simples

Pattern complet : STL qui tourne entre 3 sections

<div id="webgl-container" style="position: fixed; inset: 0; z-index: 0;"></div>
 
<main style="position: relative; z-index: 1;">
  <section id="s1" style="height: 100vh;">
    <h1>Section 1 — découverte du modèle</h1>
  </section>
  <section id="s2" style="height: 100vh;">
    <h2>Section 2 — vue de côté</h2>
  </section>
  <section id="s3" style="height: 100vh;">
    <h2>Section 3 — détail</h2>
  </section>
</main>
import * as THREE from 'three'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
 
gsap.registerPlugin(ScrollTrigger)
 
// ── Setup ──────────────────────────────────────────────────────────────
const scene = new THREE.Scene()
 
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100)
camera.position.set(0, 0, 5)
 
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setSize(innerWidth, innerHeight)
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.2
document.getElementById('webgl-container').appendChild(renderer.domElement)
 
// Lumières
scene.add(new THREE.AmbientLight('#ffffff', 0.4))
const key = new THREE.DirectionalLight('#ffffff', 3)
key.position.set(5, 8, 5)
scene.add(key)
const fill = new THREE.DirectionalLight('#8888ff', 1)
fill.position.set(-5, 0, -5)
scene.add(fill)
 
// ── Charger le STL ────────────────────────────────────────────────────
const loader = new STLLoader()
 
loader.load('/models/piece.stl', (geo) => {
  geo.computeVertexNormals()
  geo.computeBoundingBox()
 
  const center = new THREE.Vector3()
  geo.boundingBox.getCenter(center)
  geo.translate(-center.x, -center.y, -center.z)
 
  const size = new THREE.Vector3()
  geo.boundingBox.getSize(size)
  const s = 2 / Math.max(size.x, size.y, size.z)
 
  const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
    color: '#cdff4f',
    roughness: 0.15,
    metalness: 0.9,
  }))
 
  mesh.scale.setScalar(s)
  scene.add(mesh)
 
  // ── Animations au scroll ──────────────────────────────────────────
  const defaults = { ease: 'none' }
 
  // S1 → S2 : rotation Y complète + léger recul
  gsap.to(mesh.rotation, {
    y: Math.PI,
    ...defaults,
    scrollTrigger: { trigger: '#s2', start: 'top bottom', end: 'top top', scrub: 1.5 },
  })
  gsap.to(camera.position, {
    z: 7,
    ...defaults,
    scrollTrigger: { trigger: '#s2', start: 'top bottom', end: 'top top', scrub: 2 },
  })
 
  // S2 → S3 : inclinaison + remontée
  gsap.to(mesh.rotation, {
    x: Math.PI / 5,
    y: Math.PI * 1.5,
    ...defaults,
    scrollTrigger: { trigger: '#s3', start: 'top bottom', end: 'top top', scrub: 1.5 },
  })
  gsap.to(mesh.position, {
    y: 0.5,
    ...defaults,
    scrollTrigger: { trigger: '#s3', start: 'top bottom', end: 'top top', scrub: 1 },
  })
})
 
// ── Resize ────────────────────────────────────────────────────────────
window.addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight
  camera.updateProjectionMatrix()
  renderer.setSize(innerWidth, innerHeight)
  ScrollTrigger.refresh()  // ← important : recalculer après resize
})
 
// ── Render loop ───────────────────────────────────────────────────────
;(function animate() {
  requestAnimationFrame(animate)
  renderer.render(scene, camera)
})()

Tone mapping et rendu photoréaliste

renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.2
renderer.outputColorSpace = THREE.SRGBColorSpace

ACESFilmicToneMapping compresse les hautes lumières comme un film photo — donne un rendu plus naturel que le linéaire. SRGBColorSpace assure que les couleurs sont affichées correctement sur les écrans sRGB (tous les écrans modernes).

Raycaster — interaction souris avec les objets 3D

Pour détecter quand la souris survole un objet 3D :

const raycaster = new THREE.Raycaster()
const pointer = new THREE.Vector2()
 
window.addEventListener('mousemove', (e) => {
  // Normaliser les coordonnées souris en [-1, 1]
  pointer.x = (e.clientX / innerWidth) * 2 - 1
  pointer.y = -(e.clientY / innerHeight) * 2 + 1
})
 
// Dans la render loop
raycaster.setFromCamera(pointer, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
 
if (intersects.length > 0) {
  const hit = intersects[0].object
  // hit.material.color.set('#ff0000')  // par exemple
}

Rotation parallax à la souris

L'objet qui suit légèrement la souris — effet "vivant" très apprécié sur les portfolios.

const mouse = new THREE.Vector2()
 
window.addEventListener('mousemove', (e) => {
  mouse.x = (e.clientX / innerWidth - 0.5) * 2
  mouse.y = -(e.clientY / innerHeight - 0.5) * 2
})
 
// Dans la render loop
function animate() {
  requestAnimationFrame(animate)
 
  if (model) {
    // Rotation cible selon la souris
    const targetX = mouse.y * 0.3
    const targetY = mouse.x * 0.5
 
    // Lerp vers la cible
    model.rotation.x += (targetX - model.rotation.x) * 0.05
    model.rotation.y += (targetY - model.rotation.y) * 0.05
  }
 
  renderer.render(scene, camera)
}

Les animations scroll et les interactions souris sont en place. Le chapitre suivant explore le post-processing — les effets visuels appliqués après le rendu : bloom, film grain, vignette, aberration chromatique.

Précédent
Charger des modèles 3D — STL et GLTF
Suivant
Post-processing et effets visuels

Développeur fullstack passionné. J'apprends en construisant et je documente tout — front, back, outils. Le code s'apprend mieux en public.

Naviguer

IndexTous les articlesFormationsProfilOutilsBibliothech

Ailleurs

GitHub RSS

Newsletter

Les articles, libs et découvertes. Une fois par semaine, pas plus.

© 2026 William LoreeConçu & codé à la main