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 7·45 min

Portfolio 3D animé — projet 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.

Architecture du projet

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.html

Séparer les responsabilités rend le code maintenable. scene.js exporte les objets partagés (scene, camera, renderer) — les autres modules les importent.

La scène de base

// 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)
})

Les modèles positionnés dans l'espace

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 = sphere

Le 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.

Particules de fond

// 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)

Animations scroll — le cœur du portfolio

// 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
    },
  })
}

La render loop

// 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()

Le HTML

<!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>

Intégration Next.js

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 }}
    />
  )
}

Cleanup Three.js — pourquoi c'est critique

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-processing

En 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.

Points finaux : ce qui différencie un bon portfolio 3D

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.

Précédent
Shaders GLSL et systèmes de particules

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