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.
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.
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.
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 */
}// 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 },
})
}// 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()),
})
})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 :
<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)
})()renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.2
renderer.outputColorSpace = THREE.SRGBColorSpaceACESFilmicToneMapping 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).
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
}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.