Ce tutoriel explique comment créer une simulation de fluide en temps réel utilisant WebGL et la technique des metaballs. Notre projet comprend :
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.
Pour chaque pixel de l'écran, on calcule une somme d'influences de toutes les particules :
Si cette somme dépasse un seuil (généralement 1.0), le pixel est considéré comme étant "à l'intérieur" du fluide.
// 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);
d² au lieu de d pour éviter un calcul de racine carrée coûteux !
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
// 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
});
}
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);
}
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);
}
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);
}
u_particles[i] (indexation dynamique) ne fonctionnent pas sur tous les appareils. Notre code "déroule" la boucle manuellement pour compatibilité.
SPH (Smoothed Particle Hydrodynamics) est une méthode pour simuler des fluides avec des particules. Voici comment ça fonctionne :
// 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;
}
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;
pressure : Force standard qui empêche la compressionpressureNear : Force supplémentaire pour les particules très proches (évite les "grumeaux")// 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;
}
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;
}
// 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);
// 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
}
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
// 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
});
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
}
// 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);
d² au lieu de sqrt(d) quand possible0.0001 aux dénominateurs pour éviter la division par zéro// ❌ 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}]; `;
}
// 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);
Vous avez maintenant compris les bases d'une simulation de fluide avec metaballs !
Concepts clés :
Idées d'amélioration :