← Voir la démo ⬇️ Télécharger le code 🇬🇧 English

🌊 Tutoriel : Simulation de Fluide WebGL avec Metaballs

📚 Table des Matières

1. Introduction

Ce tutoriel explique comment créer une simulation de fluide en temps réel utilisant WebGL et la technique des metaballs. Notre projet comprend :

Prérequis : Connaissances de base en JavaScript, HTML5 Canvas, et notions de GLSL (langage de shaders).

2. Qu'est-ce qu'un Metaball ?

Les metaballs (ou "boules de méta") sont une technique de rendu où plusieurs formes "fusionnent" ensemble de manière organique, comme des gouttes d'eau qui se rejoignent.

Le Principe Mathématique

Pour chaque pixel de l'écran, on calcule une somme d'influences de toutes les particules :

somme = Σ (r² / d²)

r = rayon effectif, d = distance au centre de la particule

Si cette somme dépasse un seuil (généralement 1.0), le pixel est considéré comme étant "à l'intérieur" du fluide.

Particule 1 Particule 2 Zone de fusion (somme > 1)

Code du Shader

// Pour chaque particule, ajouter son influence
float sum = 0.0;
for(int i = 0; i < NUM_PARTICLES; i++) {
  vec2 p = u_particles[i];
  float d2 = dot(pos - p, pos - p);  // distance au carré
  sum += (u_radius * u_radius) / (d2 + 0.0001);
}

// Si sum > 1.0, on est dans le fluide
float t = step(1.0, sum);
Astuce : On utilise au lieu de d pour éviter un calcul de racine carrée coûteux !

3. Structure du Code

3.1 Paramètres de Simulation

const NUM_PARTICLES = 100;    // Nombre de particules
const RADIUS = 0.04;          // Taille des metaballs
const BUBBLE_RADIUS = 0.4;    // Rayon de la bulle conteneur
const GRAVITY = 0.0008;       // Gravité vers le bas
const PRESSURE_RADIUS = 0.08; // Rayon d'interaction entre particules
const STIFFNESS = 0.004;      // Rigidité de la pression
const STIFFNESS_NEAR = 0.01;  // Pression rapprochée

3.2 Initialisation des Particules

// Distribution initiale dans un cercle
for(let i = 0; i < NUM_PARTICLES; i++){
  let angle = Math.random() * Math.PI * 2;
  let r = Math.random() * 0.3;
  particles.push({
    x: r * Math.cos(angle),
    y: r * Math.sin(angle),
    vx: 0,  // vélocité X
    vy: 0   // vélocité Y
  });
}

3.3 Boucle d'Animation

function animate() {
  // 1. Mettre à jour la physique des particules
  // 2. Calculer les forces de pression SPH
  // 3. Appliquer les contraintes de frontière
  // 4. Calculer la position du bateau
  // 5. Mettre à jour les particules de fumée
  // 6. Envoyer les données aux shaders
  // 7. Dessiner
  
  requestAnimationFrame(animate);
}

4. Les Shaders WebGL

4.1 Vertex Shader

Le vertex shader est très simple - il dessine un quad plein écran (deux triangles couvrant tout l'écran) :

attribute vec2 a_position;
varying vec2 v_uv;

void main() {
  // Convertir position [-1,1] en UV [0,1]
  v_uv = a_position * 0.5 + 0.5;
  gl_Position = vec4(a_position, 0.0, 1.0);
}

4.2 Fragment Shader

Le fragment shader fait tout le rendu pixel par pixel :

precision highp float;
varying vec2 v_uv;
uniform vec2 u_resolution;
uniform vec2 u_particles[100];
uniform float u_radius;

void main() {
  // Convertir UV en coordonnées centrées [-1, 1]
  vec2 pos = v_uv * 2.0 - 1.0;
  
  // Corriger le ratio d'aspect
  float aspect = u_resolution.x / u_resolution.y;
  pos.x *= aspect;
  
  // Calculer la somme des metaballs
  float sum = 0.0;
  for(int i = 0; i < 100; i++) {
    vec2 p = u_particles[i];
    p.x *= aspect;
    float d2 = dot(pos - p, pos - p);
    sum += (u_radius * u_radius) / (d2 + 0.0001);
  }
  
  // Rendu : bleu si dans le fluide, fond sinon
  float t = step(1.0, sum);
  vec3 fluidColor = vec3(0.0, 0.8, 1.0);
  vec3 bgColor = vec3(0.53, 0.81, 0.92);
  vec3 col = mix(bgColor, fluidColor, t);
  
  gl_FragColor = vec4(col, 1.0);
}
Attention WebGL 1.0 : Les boucles avec u_particles[i] (indexation dynamique) ne fonctionnent pas sur tous les appareils. Notre code "déroule" la boucle manuellement pour compatibilité.

5. Simulation Physique SPH

SPH (Smoothed Particle Hydrodynamics) est une méthode pour simuler des fluides avec des particules. Voici comment ça fonctionne :

5.1 Forces Appliquées

// Gravité
p.vy -= GRAVITY;

// Perturbation aléatoire (vagues naturelles)
if(Math.random() < 0.05) {
  p.vx += (Math.random() - 0.5) * 0.02;
  p.vy += (Math.random() - 0.5) * 0.015;
}

// Répulsion de la souris
let dx = p.x - mouseX;
let dy = p.y - mouseY;
let dist2 = dx * dx + dy * dy;
if(dist2 < 0.05) {
  p.vx += dx * 0.02;
  p.vy += dy * 0.02;
}

5.2 Forces de Pression SPH

Les particules se repoussent mutuellement pour éviter la compression :

// Étape 1 : Calculer la densité locale
let density = 0, nearDensity = 0;
for(let j = 0; j < particles.length; j++) {
  if(i === j) continue;
  let d = distance(pi, pj);
  if(d < PRESSURE_RADIUS) {
    let q = 1 - d / PRESSURE_RADIUS;  // [0, 1]
    density += q * q;
    nearDensity += q * q * q;
  }
}

// Étape 2 : Appliquer la force de répulsion
let pressure = STIFFNESS * density;
let pressureNear = STIFFNESS_NEAR * nearDensity;

// Pousser les particules apart
let force = pressure * q + pressureNear * q * q;
pj.x += dx * force * 0.5;
pj.y += dy * force * 0.5;
pi.x -= dx * force * 0.5;
pi.y -= dy * force * 0.5;
Pourquoi deux pressions ?

5.3 Contrainte de Frontière Circulaire

// Vérifier si la particule sort du cercle
let px = p.x * aspect;
let py = p.y;
let dist = Math.sqrt(px * px + py * py);
let radiusScaled = BUBBLE_RADIUS * aspect;

if(dist > radiusScaled) {
  // Ramener la particule sur le bord
  let nx = px / dist;
  let ny = py / dist;
  p.x = (nx * radiusScaled) / aspect;
  p.y = ny * radiusScaled;
  
  // Rebond (inverser la vélocité)
  p.vx *= -0.3;
  p.vy *= -0.3;
}

6. Le Bateau Flottant

6.1 Trouver la Surface du Fluide

On utilise une recherche binaire pour trouver où la surface du fluide (somme = 1.0) se trouve :

function getSurfaceY(atX) {
  let low = -0.5;   // Dans le fluide
  let high = 0.5;   // Au-dessus du fluide
  
  // Recherche binaire : 20 itérations
  for(let i = 0; i < 20; i++) {
    let mid = (low + high) / 2;
    let sum = getMetaballSum(atX, mid);
    
    if(sum > 1.0) {
      low = mid;   // Encore dans le fluide
    } else {
      high = mid;  // Sorti du fluide
    }
  }
  return (low + high) / 2;
}

6.2 Calculer l'Inclinaison

// Hauteur de surface à gauche et à droite du bateau
let leftY = getSurfaceY(boatX - 0.06);
let rightY = getSurfaceY(boatX + 0.06);

// Angle = arctan(différence / distance)
let boatAngle = Math.atan2(rightY - leftY, 0.12);

6.3 Dessiner le Bateau (Shader)

// Transformer les coordonnées en espace local du bateau
vec2 relPos = pos - boatPos;
relPos = rotate(relPos, -u_boatAngle);

// Coque (trapèze)
float widthAtY = boatWidth * (1.0 - (hullTop - relPos.y) / boatHeight * 0.5);
if(relPos.y > hullBottom && relPos.y < hullTop && abs(relPos.x) < widthAtY) {
  col = vec3(1.0, 1.0, 1.0);  // Blanc
}

// Cabine (rectangle)
if(relPos.y > hullTop && relPos.y < hullTop + cabinHeight && abs(relPos.x) < cabinWidth) {
  col = vec3(1.0, 1.0, 1.0);
}

// Hublot (cercle)
float hublotDist = length(relPos - hublotPos);
if(hublotDist < hublotRadius) {
  col = vec3(0.3, 0.6, 0.8);  // Bleu clair
}

7. Système de Particules de Fumée

7.1 Paramètres

const NUM_SMOKE = 30;           // Nombre max de particules
const SMOKE_SPAWN_RATE = 0.15;  // Probabilité de spawn par frame
const SMOKE_BUOYANCY = 0.00008; // Poussée vers le haut
const SMOKE_FADE = 0.008;       // Diminution de l'alpha par frame
const SMOKE_GROW = 0.0003;      // Croissance de taille par frame

7.2 Créer une Nouvelle Particule de Fumée

// Position de la cheminée en coordonnées monde
let cosA = Math.cos(boatAngle);
let sinA = Math.sin(boatAngle);
let chimneyWorldX = boatX + (chimneyLocalX * cosA - chimneyLocalY * sinA);
let chimneyWorldY = surfaceY + (chimneyLocalX * sinA + chimneyLocalY * cosA);

// Spawn avec un peu de variation aléatoire
smokeParticles.push({
  x: chimneyWorldX + (Math.random() - 0.5) * 0.005,
  y: chimneyWorldY + Math.random() * 0.005,
  vx: -0.002,     // Vélocité initiale vers la gauche
  vy: 0.004,      // Vélocité initiale vers le haut
  alpha: 0.8,     // Opacité initiale
  size: 0.012     // Taille initiale
});

7.3 Mise à Jour des Particules

for(let s of smokeParticles) {
  s.vy += SMOKE_BUOYANCY;   // Flottabilité
  s.vx -= 0.00005;          // Vent vers la gauche
  s.x += s.vx;
  s.y += s.vy;
  s.alpha -= SMOKE_FADE;    // Disparition progressive
  s.size += SMOKE_GROW;     // Expansion
}

7.4 Rendu de la Fumée (Shader)

// Cercle doux avec smoothstep
float d = length(pos - smokePos);
float puff = smoothstep(smokeSize, smokeSize * 0.3, d);

// Mélanger avec la couleur existante
vec3 smokeColor = vec3(1.0, 1.0, 1.0);  // Blanc
col = mix(col, smokeColor, puff * smoke.z * 0.7);

8. Optimisations et Astuces

8.1 Éviter les Calculs Coûteux

8.2 Compatibilité WebGL

// ❌ Ne fonctionne pas partout (indexation dynamique)
for(int i = 0; i < NUM; i++) {
  sum += u_particles[i];
}

// ✅ Solution : dérouler la boucle en JavaScript
let shaderCode = '';
for(let i = 0; i < NUM_PARTICLES; i++) {
  shaderCode += `sum += u_particles[${i}]; `;
}

8.3 Anti-aliasing

// Bord dur
float mask = step(radius, dist);

// Bord doux (anti-aliasé)
float aa = 0.002;  // Largeur du dégradé
float mask = smoothstep(radius - aa, radius + aa, dist);

8.4 Amélioration de la Performance

Pour aller plus loin :

🎉 Conclusion

Vous avez maintenant compris les bases d'une simulation de fluide avec metaballs !

Concepts clés :

Idées d'amélioration :

← Retour à la démo