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.
Les matériaux Three.js (Standard, Physical, Normal) sont puissants mais figés — ce sont des shaders GLSL précompilés avec des paramètres exposés. Dès qu'on veut un effet vraiment custom — un dégradé animé, une distorsion de surface, un effet de bruit procédural — on écrit son propre shader.
Un shader est un programme qui s'exécute sur le GPU, en parallèle, pour chaque vertex ou chaque pixel.
Il y en a deux types, toujours en tandem :
Vertex shader — s'exécute pour chaque vertex (point 3D). Son rôle : positionner le vertex en 2D sur l'écran (gl_Position). Peut aussi déformer la géométrie.
Fragment shader — s'exécute pour chaque pixel à l'intérieur des triangles. Son rôle : définir la couleur finale (gl_FragColor).
// Vertex shader minimal
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}// Fragment shader minimal
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // rouge, plein
}projectionMatrix * modelViewMatrix — la chaîne de transformations qui passe des coordonnées 3D du monde aux coordonnées 2D de l'écran. Three.js injecte ces matrices automatiquement.
const material = new THREE.ShaderMaterial({
vertexShader: `
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
void main() {
gl_FragColor = vec4(1.0, 0.5, 0.0, 1.0);
}
`,
})En pratique, on externalise les shaders dans des fichiers .glsl ou on utilise des template strings avec un syntax highlighter VSCode (extension glsl-canvas ou Shader languages support).
Les uniforms sont des variables qu'on envoie depuis JavaScript au shader. Elles restent constantes pendant un frame (d'où "uniform").
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uColor: { value: new THREE.Color('#cdff4f') },
uResolution: { value: new THREE.Vector2(innerWidth, innerHeight) },
uMouse: { value: new THREE.Vector2(0, 0) },
},
vertexShader: /* glsl */`
uniform float uTime;
void main() {
vec3 pos = position;
pos.y += sin(pos.x * 2.0 + uTime) * 0.2; // ondulation sur Y
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`,
fragmentShader: /* glsl */`
uniform vec3 uColor;
uniform float uTime;
void main() {
// Couleur qui pulse légèrement
float pulse = sin(uTime * 2.0) * 0.1 + 0.9;
gl_FragColor = vec4(uColor * pulse, 1.0);
}
`,
})
// Mettre à jour uTime dans la render loop
const clock = new THREE.Clock()
function animate() {
requestAnimationFrame(animate)
material.uniforms.uTime.value = clock.getElapsedTime()
renderer.render(scene, camera)
}Les varyings passent des données du vertex shader au fragment shader. La valeur est interpolée entre les vertices.
// Vertex shader
varying vec2 vUv; // déclarer la varying
varying vec3 vPosition;
void main() {
vUv = uv; // uv = coordonnées de texture (0,0 → 1,1)
vPosition = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}// Fragment shader
varying vec2 vUv;
varying vec3 vPosition;
void main() {
// Dégradé de gauche à droite
vec3 color = mix(vec3(0.0), vec3(0.8, 1.0, 0.3), vUv.x);
gl_FragColor = vec4(color, 1.0);
}mix(a, b, t) interpole linéairement entre a et b selon t (0 = a, 1 = b). C'est la fonction GLSL la plus utilisée.
La base de la plupart des effets de surface organiques. GLSL n'a pas de fonction noise() native — on l'ajoute manuellement ou on utilise une bibliothèque.
// Fonction hash simple (pseudo-aléatoire)
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
// Noise de valeur 2D interpolé
float noise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
vec2 u = f * f * (3.0 - 2.0 * f); // smoothstep
return mix(
mix(random(i + vec2(0.0, 0.0)), random(i + vec2(1.0, 0.0)), u.x),
mix(random(i + vec2(0.0, 1.0)), random(i + vec2(1.0, 1.0)), u.x),
u.y
);
}// Fragment shader : surface organique animée
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 uv = vUv * 3.0;
float n = noise(uv + uTime * 0.3);
n += noise(uv * 2.0 - uTime * 0.1) * 0.5; // FBM (Fractional Brownian Motion)
vec3 color = mix(vec3(0.05), vec3(0.8, 1.0, 0.3), n);
gl_FragColor = vec4(color, 1.0);
}La librairie glslify permet d'importer des modules GLSL (noise, simplex, etc.) comme des modules npm.
Points utilise une géométrie dont chaque vertex est rendu comme un sprite carré.
const count = 5000
// Créer les positions
const positions = new Float32Array(count * 3)
for (let i = 0; i < count; i++) {
positions[i * 3 + 0] = (Math.random() - 0.5) * 10 // x
positions[i * 3 + 1] = (Math.random() - 0.5) * 10 // y
positions[i * 3 + 2] = (Math.random() - 0.5) * 10 // z
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
const material = new THREE.PointsMaterial({
size: 0.05,
color: '#cdff4f',
transparent: true,
opacity: 0.8,
sizeAttenuation: true, // les particules lointaines sont plus petites
})
const particles = new THREE.Points(geometry, material)
scene.add(particles)
// Animer dans la render loop
function animate() {
particles.rotation.y += 0.0005
// ...
}Pour un contrôle total : taille, couleur, opacité par particule.
const count = 10000
const positions = new Float32Array(count * 3)
const sizes = new Float32Array(count)
const colors = new Float32Array(count * 3)
for (let i = 0; i < count; i++) {
// Positions en sphère
const phi = Math.acos(2 * Math.random() - 1)
const theta = 2 * Math.PI * Math.random()
const r = Math.random() * 5
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta)
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta)
positions[i * 3 + 2] = r * Math.cos(phi)
sizes[i] = Math.random() * 2 + 0.5
colors[i * 3] = Math.random()
colors[i * 3 + 1] = Math.random()
colors[i * 3 + 2] = Math.random()
}
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geo.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1))
geo.setAttribute('aColor', new THREE.BufferAttribute(colors, 3))
const mat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uPixelRatio: { value: Math.min(devicePixelRatio, 2) },
},
vertexShader: /* glsl */`
attribute float aSize;
attribute vec3 aColor;
uniform float uTime;
uniform float uPixelRatio;
varying vec3 vColor;
void main() {
vColor = aColor;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
// Flottement individuel par particule
float offset = sin(position.x * 2.0 + uTime) * 0.1;
mvPosition.y += offset;
gl_Position = projectionMatrix * mvPosition;
gl_PointSize = aSize * uPixelRatio * (300.0 / -mvPosition.z);
}
`,
fragmentShader: /* glsl */`
varying vec3 vColor;
void main() {
// Particule ronde (par défaut c'est un carré)
float dist = length(gl_PointCoord - vec2(0.5));
if (dist > 0.5) discard;
// Bords doux
float alpha = 1.0 - smoothstep(0.3, 0.5, dist);
gl_FragColor = vec4(vColor, alpha);
}
`,
transparent: true,
depthWrite: false, // éviter les artefacts de tri avec les particules transparentes
})
const particles = new THREE.Points(geo, mat)
scene.add(particles)gl_PointCoord est une varying automatique de (0,0) à (1,1) à l'intérieur du sprite. discard rejette le pixel — c'est comme ça qu'on fait des particules rondes.
Effet spectaculaire : des particules qui forment la silhouette d'un modèle 3D.
loader.load('/models/piece.stl', (geo) => {
// Extraire les positions du modèle comme positions de particules
const modelPositions = geo.attributes.position.array
const count = Math.floor(modelPositions.length / 3)
// Sous-échantillonner si trop de vertices
const maxParticles = 5000
const step = Math.max(1, Math.floor(count / maxParticles))
const particlePositions = []
for (let i = 0; i < count; i += step) {
particlePositions.push(
modelPositions[i * 3],
modelPositions[i * 3 + 1],
modelPositions[i * 3 + 2]
)
}
const particleGeo = new THREE.BufferGeometry()
particleGeo.setAttribute(
'position',
new THREE.BufferAttribute(new Float32Array(particlePositions), 3)
)
const particles = new THREE.Points(particleGeo, mat)
scene.add(particles)
})Combiné avec GSAP pour animer la transition "particules dispersées → forment le modèle" est un effet d'entrée très impressionnant.
Shaders et particules ouvrent des possibilités visuelles infinies. Le dernier chapitre assemble tout dans un portfolio 3D complet — scène, modèles, caméra animée au scroll, sections interactives.