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.
Ce chapitre construit un portfolio 3D complet de zéro à fin. Le résultat : une page unique avec un canvas Three.js en fond, des objets 3D positionnés dans l'espace qui apparaissent au scroll, une caméra qui voyage entre les sections, et des effets post-processing discrets.
src/
├── three/
│ ├── scene.js ← setup scène, renderer, caméra
│ ├── models.js ← chargement et placement des modèles
│ ├── particles.js ← système de particules de fond
│ ├── scroll.js ← lien scroll ↔ animations Three.js
│ └── postprocessing.js ← effets visuels
├── main.js ← point d'entrée
└── index.htmlSéparer les responsabilités rend le code maintenable. scene.js exporte les objets partagés (scene, camera, renderer) — les autres modules les importent.
// three/scene.js
import * as THREE from 'three'
export const scene = new THREE.Scene()
export const camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
0.1,
100
)
camera.position.set(0, 0, 8)
export const renderer = new THREE.WebGLRenderer({
canvas: document.querySelector('#canvas'),
antialias: true,
alpha: true,
})
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1
renderer.outputColorSpace = THREE.SRGBColorSpace
// Lumières
const ambient = new THREE.AmbientLight('#ffffff', 0.3)
scene.add(ambient)
const keyLight = new THREE.DirectionalLight('#ffffff', 3)
keyLight.position.set(5, 8, 5)
scene.add(keyLight)
const rimLight = new THREE.DirectionalLight('#4444ff', 1.5)
rimLight.position.set(-5, 2, -8)
scene.add(rimLight)
// Resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})Chaque section du portfolio correspond à une position dans l'espace 3D. La caméra voyage entre ces positions au scroll.
// three/models.js
import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'
import { scene } from './scene.js'
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/draco/')
const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)
const stlLoader = new STLLoader()
export const objects = {}
// Section 1 — Hero : modèle flottant centré
gltfLoader.load('/models/logo.glb', (gltf) => {
const model = gltf.scene
model.position.set(0, 0, 0)
model.scale.setScalar(0.8)
model.traverse((child) => {
if (child.isMesh) {
child.material = new THREE.MeshStandardMaterial({
color: '#cdff4f',
roughness: 0.1,
metalness: 0.9,
})
}
})
scene.add(model)
objects.hero = model
})
// Section 2 — Compétences : pièce mécanique STL
stlLoader.load('/models/gear.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 = 1.5 / Math.max(size.x, size.y, size.z)
const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
color: '#888888',
roughness: 0.2,
metalness: 1.0,
}))
mesh.scale.setScalar(s)
mesh.position.set(3, -8, 0) // positionné en Y négatif = section plus basse
scene.add(mesh)
objects.gear = mesh
})
// Section 3 — Projets : laptop ou autre objet de prod
gltfLoader.load('/models/laptop.glb', (gltf) => {
const model = gltf.scene
model.scale.setScalar(0.012)
model.position.set(-2, -16, 1)
model.rotation.y = Math.PI / 6
scene.add(model)
objects.laptop = model
})
// Section 4 — Contact : sphère abstraite
const sphereMat = new THREE.MeshStandardMaterial({
color: '#cdff4f',
roughness: 0,
metalness: 0.8,
wireframe: true,
})
const sphere = new THREE.Mesh(
new THREE.IcosahedronGeometry(1.2, 4),
sphereMat
)
sphere.position.set(0, -24, 0)
scene.add(sphere)
objects.sphere = sphereLe système de coordonnées utilisé ici : chaque section fait 8 unités de hauteur en Y. Section 1 = Y:0, Section 2 = Y:-8, Section 3 = Y:-16, Section 4 = Y:-24.
// three/particles.js
import * as THREE from 'three'
import { scene } from './scene.js'
const count = 2000
const positions = new Float32Array(count * 3)
for (let i = 0; i < count; i++) {
positions[i * 3] = (Math.random() - 0.5) * 40
positions[i * 3 + 1] = (Math.random() - 0.5) * 60 // réparties sur toute la hauteur
positions[i * 3 + 2] = (Math.random() - 0.5) * 20
}
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
const mat = new THREE.PointsMaterial({
size: 0.04,
color: '#ffffff',
transparent: true,
opacity: 0.4,
sizeAttenuation: true,
})
export const stars = new THREE.Points(geo, mat)
scene.add(stars)// three/scroll.js
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { camera, scene } from './scene.js'
import { objects, stars } from './models.js'
gsap.registerPlugin(ScrollTrigger)
// Hauteur d'une "section" en unités Three.js
const SECTION_HEIGHT = 8
export function setupScrollAnimations() {
// ── Section 1 (Hero) ────────────────────────────────────────────────
// Rotation continue légère sur le hero
// (gérée dans la render loop, pas ScrollTrigger)
// S1 → S2 : caméra descend, objet hero tourne
gsap.to(camera.position, {
y: -SECTION_HEIGHT,
ease: 'none',
scrollTrigger: {
trigger: '#s2',
start: 'top bottom',
end: 'top top',
scrub: 1.5,
},
})
if (objects.hero) {
gsap.to(objects.hero.rotation, {
y: Math.PI,
z: Math.PI / 8,
ease: 'none',
scrollTrigger: {
trigger: '#s2',
start: 'top bottom',
end: 'top top',
scrub: 1,
},
})
gsap.to(objects.hero, {
// Faire disparaître le hero
ease: 'none',
scrollTrigger: {
trigger: '#s2',
start: 'top center',
end: 'top top',
scrub: true,
onUpdate: (self) => {
if (objects.hero) {
objects.hero.material?.traverse?.((m) => { m.opacity = 1 - self.progress })
}
},
},
})
}
// ── Section 2 (Compétences) ─────────────────────────────────────────
// S2 → S3 : caméra continue de descendre
gsap.to(camera.position, {
y: -SECTION_HEIGHT * 2,
ease: 'none',
scrollTrigger: {
trigger: '#s3',
start: 'top bottom',
end: 'top top',
scrub: 1.5,
},
})
if (objects.gear) {
// Engrenage tourne au scroll
ScrollTrigger.create({
trigger: '#s2',
start: 'top bottom',
end: 'bottom top',
onUpdate: (self) => {
objects.gear.rotation.z = self.progress * Math.PI * 4
},
})
}
// ── Section 3 (Projets) ──────────────────────────────────────────────
gsap.to(camera.position, {
y: -SECTION_HEIGHT * 3,
ease: 'none',
scrollTrigger: {
trigger: '#s4',
start: 'top bottom',
end: 'top top',
scrub: 1.5,
},
})
if (objects.laptop) {
gsap.from(objects.laptop.position, {
x: 6,
ease: 'none',
scrollTrigger: {
trigger: '#s3',
start: 'top center',
end: 'top top',
scrub: 1,
},
})
}
// ── Section 4 (Contact) ──────────────────────────────────────────────
if (objects.sphere) {
ScrollTrigger.create({
trigger: '#s4',
start: 'top bottom',
end: 'bottom top',
onUpdate: (self) => {
objects.sphere.rotation.y = self.progress * Math.PI * 2
objects.sphere.rotation.x = self.progress * Math.PI
},
})
}
// Rotation des étoiles globale au scroll
ScrollTrigger.create({
trigger: 'body',
start: 'top top',
end: 'bottom bottom',
onUpdate: (self) => {
stars.rotation.y = self.progress * Math.PI * 0.5
},
})
}// main.js
import { scene, camera, renderer } from './three/scene.js'
import { objects, stars } from './three/models.js'
import { setupScrollAnimations } from './three/scroll.js'
import { createComposer } from './three/postprocessing.js'
import * as THREE from 'three'
const composer = createComposer(renderer, scene, camera)
const clock = new THREE.Clock()
// Rotation parallax souris
const mouse = new THREE.Vector2()
window.addEventListener('mousemove', (e) => {
mouse.x = (e.clientX / window.innerWidth - 0.5) * 2
mouse.y = -(e.clientY / window.innerHeight - 0.5) * 2
})
function animate() {
requestAnimationFrame(animate)
const elapsed = clock.getElapsedTime()
const delta = clock.getDelta()
// Rotation lente du hero
if (objects.hero) {
objects.hero.rotation.y += delta * 0.3
// Parallax souris (subtil)
objects.hero.rotation.x += (mouse.y * 0.1 - objects.hero.rotation.x) * 0.05
}
// Pulsation de la sphère contact
if (objects.sphere) {
objects.sphere.material.wireframe = elapsed % 2 > 1 // alterne wireframe
const scale = 1 + Math.sin(elapsed * 1.5) * 0.05
objects.sphere.scale.setScalar(scale)
}
// Rotation lente des particules
stars.rotation.x += delta * 0.01
composer.render()
}
animate()
setupScrollAnimations()<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Portfolio — Votre Nom</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0a; color: #fff; font-family: 'Helvetica Neue', sans-serif; }
canvas {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 0;
}
main {
position: relative;
z-index: 1;
}
.section {
height: 100vh;
display: flex;
align-items: center;
padding: 0 8vw;
}
.section-content {
max-width: 540px;
}
h1 { font-size: clamp(2.5rem, 6vw, 5rem); font-weight: 700; }
h2 { font-size: clamp(1.8rem, 4vw, 3rem); font-weight: 600; margin-bottom: 1rem; }
p { font-size: 1.1rem; line-height: 1.7; color: #aaa; max-width: 420px; }
.accent { color: #cdff4f; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<main>
<section id="s1" class="section">
<div class="section-content">
<h1>Votre <span class="accent">Nom</span></h1>
<p>Développeur fullstack. Je construis des interfaces qui font la différence.</p>
</div>
</section>
<section id="s2" class="section" style="justify-content: flex-end;">
<div class="section-content">
<h2>Compétences</h2>
<p>React, Node.js, Three.js, PostgreSQL, Docker. 5 ans d'expérience produit.</p>
</div>
</section>
<section id="s3" class="section">
<div class="section-content">
<h2>Projets</h2>
<p>SaaS B2B, applications temps réel, outils internes. Du prototype au produit.</p>
</div>
</section>
<section id="s4" class="section" style="justify-content: center; text-align: center;">
<div class="section-content" style="max-width: 100%;">
<h2>Contact</h2>
<p>Disponible pour des missions freelance à partir de septembre 2026.</p>
<a href="mailto:vous@exemple.com" style="color: #cdff4f; font-size: 1.2rem;">
vous@exemple.com
</a>
</div>
</section>
</main>
<script type="module" src="/src/main.js"></script>
</body>
</html>En Next.js, le canvas Three.js va dans un composant client avec useEffect.
// components/PortfolioScene.tsx
'use client'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
export default function PortfolioScene() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (!canvasRef.current) return
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100)
camera.position.z = 8
const renderer = new THREE.WebGLRenderer({
canvas: canvasRef.current,
antialias: true,
alpha: true,
})
renderer.setSize(innerWidth, innerHeight)
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
// ... setup lumières, modèles, scroll animations
const clock = new THREE.Clock()
let rafId: number
function animate() {
rafId = requestAnimationFrame(animate)
renderer.render(scene, camera)
}
animate()
const onResize = () => {
camera.aspect = innerWidth / innerHeight
camera.updateProjectionMatrix()
renderer.setSize(innerWidth, innerHeight)
ScrollTrigger.refresh()
}
window.addEventListener('resize', onResize)
// Cleanup — critique pour éviter les fuites mémoire
return () => {
cancelAnimationFrame(rafId)
window.removeEventListener('resize', onResize)
ScrollTrigger.getAll().forEach((t) => t.kill())
renderer.dispose()
scene.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose()
if (Array.isArray(obj.material)) {
obj.material.forEach((m) => m.dispose())
} else {
obj.material.dispose()
}
}
})
}
}, [])
return (
<canvas
ref={canvasRef}
style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', zIndex: 0 }}
/>
)
}Three.js alloue des ressources GPU (textures, buffers, programs). Sans cleanup, chaque montage du composant accumule ces ressources jusqu'à planter l'onglet.
// Toujours libérer dans le cleanup
renderer.dispose() // libère le contexte WebGL
geometry.dispose() // libère le buffer GPU
material.dispose() // libère les shaders et textures
texture.dispose() // libère la mémoire GPU de la texture
composer.dispose() // libère les render targets du post-processingEn React, c'est le rôle du return de useEffect. Un Portfolio sans cleanup peut sembler fonctionner mais consomme de plus en plus de mémoire GPU à chaque navigation.
Loading screen. Les modèles prennent du temps. Afficher une barre de progression avec LoadingManager avant de montrer la scène.
Performance mobile. Réduire le nombre de particules, désactiver le post-processing, limiter le pixel ratio à 1 sur mobile. Détecter avec navigator.userAgent ou une media query.
Cohérence visuelle. Un objet 3D seul sur fond sombre avec deux lumières bien placées vaut mieux que cinq objets mal éclairés. Less is more.
Le texte HTML reste HTML. Ne pas tenter de mettre le texte dans Three.js (TextGeometry, troika-three-text) sauf pour des effets spécifiques. Le texte HTML reste sélectionnable, indexable, accessible.
La formation est complète. De la scène de base aux shaders GLSL, en passant par les modèles STL/GLTF, les animations au scroll et le post-processing — vous avez tous les outils pour construire des expériences 3D web professionnelles.