WEBGL : Vertex Shader dans page html
Particles WebGL2 — cours en 10 étapes
Objectif : partir d’un simple <canvas> noir pour arriver à un
système de particules animé (fumée, champ de flux, souffle souris) en
WebGL2 natif, sans dépendance.
Adapté à des élèves avancés (collège/lycée) et suffisamment propre
pour que des développeurs viennent y piocher des morceaux en douce 😉
1) Structure HTML minimale Niveau 1
On commence par une page fond noir avec un <canvas>
plein écran. C’est notre “toile” où le GPU dessinera les particules.
html, bodysans marges, hauteur 100%canvasfixé sur tout l’écran
<style>
html,body{margin:0;height:100%;background:#000;overflow:hidden}
canvas{position:fixed;inset:0;width:100%;height:100%;display:block}
</style>
<canvas id="gl"></canvas>
2) Canvas + DPR (haute résolution) Niveau 2
Pour éviter le flou sur les écrans haute densité, on adapte la
résolution du canvas au devicePixelRatio (DPR), et on
met à jour le viewport au resize.
const canvas = document.getElementById('gl');
const gl = canvas.getContext('webgl2', { alpha:false });
let DPR=1, W=0, H=0;
function resize(){
DPR = Math.max(1, Math.min(3, window.devicePixelRatio||1));
W = Math.floor(innerWidth * DPR);
H = Math.floor(innerHeight * DPR);
canvas.width = W;
canvas.height = H;
gl.viewport(0,0,W,H);
}
addEventListener('resize', resize, {passive:true});
resize();
3) Contexte WebGL2 + shaders minimaux Niveau 2
Avant d’aller plus loin, on fait un smoke test : un vertex shader ultra simple, un fragment shader blanc, et on dessine un point au centre de l’écran.
// Vertex (NDC au centre)
const vs = `#version 300 es
precision highp float;
void main(){
gl_PointSize = 8.0;
gl_Position = vec4(0.0,0.0,0.0,1.0);
}`;
const fs = `#version 300 es
precision mediump float;
out vec4 outColor;
void main(){ outColor = vec4(1.0); }`;
// Ensuite : compile + link + draw
// const program = link(vs, fs);
// gl.useProgram(program);
// gl.drawArrays(gl.POINTS, 0, 1);
4) Grille via gl_VertexID (sans VBO) Niveau 3
Astuce WebGL2 : on peut se passer de VBO pour placer les points.
Chaque sommet utilise son gl_VertexID pour se placer
dans une grille across × across en coordonnées NDC.
uniform int uAcross;
void main(){
int id = gl_VertexID;
int x = int(mod(float(id), float(uAcross)));
int y = id / uAcross;
float u = float(x)/float(uAcross-1);
float v = float(y)/float(uAcross-1);
vec2 p = vec2(u*2.0-1.0, v*2.0-1.0);
gl_PointSize = 6.0;
gl_Position = vec4(p,0.0,1.0);
}
5) Taille selon DPR + uBaseSize Niveau 3
Pour garder des points lisibles quel que soit l’écran, on exprime
la taille en pixels “device” avec uDPR et une
taille de base uBaseSize. On ajoute un léger
gradient de taille : plus gros en bas de la grille.
uniform float uDPR, uBaseSize; // px float base = mix(1.0, 1.6, 1.0 - v); // particules plus grosses en bas gl_PointSize = uBaseSize * base * uDPR;
6) Ondes sinusoïdales Niveau 3
Pour casser l’aspect “grille rigide”, on ajoute deux offsets sin/cos dépendants du temps et des indices de ligne/colonne. Visuellement, la grille “ondule” en permanence.
uniform float uTime; float offx = sin(uTime*0.8 + float(y)*0.21) * 0.08; float offy = cos(uTime*0.6 + float(x)*0.17) * 0.10; p += vec2(offx, offy);
7) Value-noise → champ de flux Niveau 4
On construit un champ de flux : pour chaque point,
un bruit 3D valueNoise(x,y,t) produit un angle,
donc une direction de mouvement. Les particules se déplacent
dans un champ pseudo-organique.
float valueNoise(vec3 p){ /* trilinear value-noise 3D */ }
uniform float uFlowScale, uFlowStr;
float ang = valueNoise(vec3(p*uFlowScale*2.0, uTime*0.15)) * 6.28318530718;
vec2 flow = vec2(cos(ang), sin(ang)) * uFlowStr;
p += flow;
8) Montée + traînées (voile noir) Niveau 4
Pour l’effet “fumée” :
- on fait monter les particules en ajoutant
uRisesur l’axe Y ; - on reboucle verticalement quand on sort de l’écran ;
- on dessine d’abord un voile noir plein écran, avec une alpha faible, pour créer des traînées dans le temps.
// Dans le vertex shader
uniform float uRise;
p.y += (uTime * uRise);
if (p.y > 1.2) p.y -= 2.4;
// Fragment du "voile noir" (pass trails)
#version 300 es
precision mediump float;
uniform float uFade; // 0.06–0.1
out vec4 outColor;
void main(){
outColor = vec4(0.0, 0.0, 0.0, uFade);
}
9) Halo doux + filaire (F) + couleurs HSV Niveau 4–5
Au lieu d’un simple carré, on utilise gl_PointCoord pour dessiner un
halo circulaire avec un dégradé de transparence. En mode
“fillaire”, on ne garde que l’anneau. La couleur est calculée en HSV,
puis convertie en RGB côté vertex shader.
in vec4 vColor;
in float vAlpha;
uniform bool uWireframe;
out vec4 outColor;
void main(){
vec2 uv = gl_PointCoord - 0.5;
float r = length(uv) * 2.0;
float soft = smoothstep(1.0, 0.0, r); // halo plein
float ring = smoothstep(1.0, 0.90, r) // bord externe
* smoothstep(0.75, 0.9, r); // bord interne
float a = (uWireframe ? ring : soft) * vAlpha;
if (a < 0.01) discard;
outColor = vec4(vColor.rgb, a);
}
10) Souffle souris + burst (B) + blend additif Niveau 5
Dernière touche :
- la souris agit comme un souffle qui repousse les particules proches ;
- la touche B déclenche un burst radial court ;
- le blend additif (
SRC_ALPHA, ONE) donne un effet “glow” cumulatif.
// Vertex : souffle + burst
uniform vec2 uMouseNDC;
uniform float uMousePush, uBurst;
vec2 d = p - uMouseNDC;
float r = length(d);
float push = smoothstep(0.7, 0.0, r) * uMousePush; // max près de la souris
if (r > 0.0001) {
p += normalize(d) * 0.35 * push;
}
// Burst global depuis le bas
p += normalize(vec2(p.x, p.y + 1.5)) * uBurst * 0.35;
// Côté JS : rendu additif
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
QCM – As-tu compris les 10 étapes ?
Coche une réponse par question, puis clique sur « Vérifier mes réponses ».
Code JS final complet (démo Particles WebGL2)
Voici un code JavaScript complet qui reprend les principes des 10 étapes.
À placer dans un <script> après le
<canvas id="gl"> de l’étape 1.
(() => {
const canvas = document.getElementById('gl');
const gl = canvas.getContext('webgl2', {
antialias: false,
alpha: false,
powerPreference: 'high-performance'
});
if (!gl) {
alert('WebGL2 requis');
return;
}
// ----- Resize + DPR -----
let DPR = 1, W = 0, H = 0;
function resize() {
DPR = Math.max(1, Math.min(3, window.devicePixelRatio || 1));
W = Math.floor(window.innerWidth * DPR);
H = Math.floor(window.innerHeight * DPR);
canvas.width = W;
canvas.height = H;
gl.viewport(0, 0, W, H);
}
window.addEventListener('resize', resize, { passive: true });
resize();
// ----- Shaders -----
const vertSrc = `#version 300 es
precision highp float;
uniform float uTime;
uniform vec2 uRes;
uniform float uDPR;
uniform int uAcross;
uniform float uFlowScale;
uniform float uFlowStr;
uniform float uRise;
uniform vec2 uMouseNDC;
uniform float uMousePush;
uniform float uBurst;
uniform float uBaseSize;
out vec4 vColor;
out float vAlpha;
float hash(float n){ return fract(sin(n)*43758.5453123); }
float hash31(vec3 p){ return hash(dot(p, vec3(127.1, 311.7, 74.7))); }
float valueNoise(vec3 p){
vec3 i = floor(p);
vec3 f = fract(p);
vec3 u = f*f*(3.0-2.0*f);
float n000 = hash31(i + vec3(0,0,0));
float n100 = hash31(i + vec3(1,0,0));
float n010 = hash31(i + vec3(0,1,0));
float n110 = hash31(i + vec3(1,1,0));
float n001 = hash31(i + vec3(0,0,1));
float n101 = hash31(i + vec3(1,0,1));
float n011 = hash31(i + vec3(0,1,1));
float n111 = hash31(i + vec3(1,1,1));
float x00 = mix(n000, n100, u.x);
float x10 = mix(n010, n110, u.x);
float x01 = mix(n001, n101, u.x);
float x11 = mix(n011, n111, u.x);
float y0 = mix(x00, x10, u.y);
float y1 = mix(x01, x11, u.y);
return mix(y0, y1, u.z);
}
vec3 hsv2rgb(vec3 c){
vec3 K = vec3(1., 2./3., 1./3.);
vec3 p = abs(fract(c.xxx + K) * 6. - 3.);
return c.z * mix(vec3(1.), clamp(p - 1., 0., 1.), c.y);
}
void main(){
int across = uAcross;
int id = gl_VertexID;
int x = int(mod(float(id), float(across)));
int y = id / across;
float u = float(x) / float(across - 1);
float v = float(y) / float(across - 1);
vec2 p = vec2(u*2.-1., v*2.-1.);
float offx = sin(uTime*0.8 + float(y)*0.21) * 0.08;
float offy = cos(uTime*0.6 + float(x)*0.17) * 0.10;
p += vec2(offx, offy);
float ang = valueNoise(vec3(p*uFlowScale*2.0, uTime*0.15)) * 6.28318530718;
vec2 flow = vec2(cos(ang), sin(ang)) * uFlowStr;
p.y += (uTime * uRise);
if (p.y > 1.2) p.y -= 2.4;
p += normalize(vec2(p.x, p.y + 1.5)) * uBurst * 0.35;
vec2 d = p - uMouseNDC;
float r = length(d);
float push = smoothstep(0.7, 0.0, r) * uMousePush;
if (r > 0.0001) {
p += normalize(d) * push * 0.35;
}
p += flow;
float base = mix(1.0, 1.6, 1.0 - v);
float nsz = valueNoise(vec3(p*uFlowScale*1.5, uTime*0.35));
float size = uBaseSize * (0.6 + 0.8*nsz) * base;
float snd = valueNoise(vec3(float(x)*0.07, float(y)*0.11, uTime*1.2));
float hue = u*0.1 + snd*0.20 + uTime*0.05;
float sat = smoothstep(0.2, 0.9, snd);
float val = pow(snd + 0.2, 3.0);
vColor = vec4(hsv2rgb(vec3(hue, sat, val)), 1.0);
vAlpha = 0.85;
gl_PointSize = size * uDPR;
gl_Position = vec4(p, 0.0, 1.0);
}`;
const fragSrc = `#version 300 es
precision highp float;
in vec4 vColor;
in float vAlpha;
uniform bool uWireframe;
out vec4 outColor;
void main(){
vec2 uv = gl_PointCoord - 0.5;
float r = length(uv) * 2.0;
float soft = smoothstep(1.0, 0.0, r);
float ring = smoothstep(1.0, 0.90, r) * smoothstep(0.75, 0.9, r);
float a = (uWireframe ? ring : soft) * vAlpha;
if (a < 0.01) discard;
outColor = vec4(vColor.rgb, a);
}`;
const trailVS = `#version 300 es
precision highp float;
void main(){
vec2 p = (gl_VertexID==0)? vec2(-1.0,-1.0)
: (gl_VertexID==1)? vec2( 3.0,-1.0)
: vec2(-1.0, 3.0);
gl_Position = vec4(p,0.0,1.0);
}`;
const trailFS = `#version 300 es
precision mediump float;
uniform float uFade;
out vec4 outColor;
void main(){
outColor = vec4(0.0, 0.0, 0.0, uFade);
}`;
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(s));
throw new Error('shader compile error');
}
return s;
};
const link = (vsSrc, fsSrc) => {
const p = gl.createProgram();
gl.attachShader(p, compile(gl.VERTEX_SHADER, vsSrc));
gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fsSrc));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(p));
throw new Error('program link error');
}
return p;
};
const prog = link(vertSrc, fragSrc);
const trailProg = link(trailVS, trailFS);
const U = name => gl.getUniformLocation(prog, name);
const UT = name => gl.getUniformLocation(trailProg, name);
const uTime = U('uTime');
const uRes = U('uRes');
const uDPR = U('uDPR');
const uAcross = U('uAcross');
const uFlowScale = U('uFlowScale');
const uFlowStr = U('uFlowStr');
const uRise = U('uRise');
const uMouseNDC = U('uMouseNDC');
const uMousePush = U('uMousePush');
const uBurst = U('uBurst');
const uBaseSize = U('uBaseSize');
const uWireframe = U('uWireframe');
const uFade = UT('uFade');
const vaoPoints = gl.createVertexArray();
const vaoTri = gl.createVertexArray();
const ACROSS = 160;
const N = ACROSS * ACROSS;
// ----- Interaction -----
let mouse = { x:0, y:0, down:false };
canvas.addEventListener('mousemove', e => {
const r = canvas.getBoundingClientRect();
const x = (e.clientX - r.left) * DPR;
const y = (e.clientY - r.top) * DPR;
mouse.x = (x / W) * 2 - 1;
mouse.y = -(y / H) * 2 + 1;
}, { passive:true });
canvas.addEventListener('mousedown', () => { mouse.down = true; });
window.addEventListener('mouseup', () => { mouse.down = false; });
window.addEventListener('mouseleave',() => { mouse.down = false; });
let wireframe = false;
let burstVal = 0.0;
window.addEventListener('keydown', e => {
const k = (e.key || '').toLowerCase();
if (k === 'b') burstVal = 1.0;
if (k === 'f') wireframe = !wireframe;
});
// ----- Render loop -----
gl.disable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
gl.clearColor(0,0,0,1);
gl.clear(gl.COLOR_BUFFER_BIT);
let last = performance.now();
function frame(now) {
const dt = Math.min(50, now - last);
last = now;
const t = now * 0.001;
// 1) voile noir (traînées)
gl.useProgram(trailProg);
gl.bindVertexArray(vaoTri);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.uniform1f(uFade, 0.08);
gl.drawArrays(gl.TRIANGLES, 0, 3);
// 2) particules
gl.useProgram(prog);
gl.bindVertexArray(vaoPoints);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
gl.uniform1f(uTime, t);
gl.uniform2f(uRes, W, H);
gl.uniform1f(uDPR, DPR);
gl.uniform1i(uAcross, ACROSS);
gl.uniform1f(uFlowScale, 1.8);
gl.uniform1f(uFlowStr, 0.15);
gl.uniform1f(uRise, 0.06);
gl.uniform2f(uMouseNDC, mouse.x, mouse.y);
gl.uniform1f(uMousePush, mouse.down ? 0.9 : 0.15);
gl.uniform1f(uBaseSize, 22.0);
gl.uniform1i(uWireframe, wireframe ? 1 : 0);
burstVal *= Math.pow(0.001, dt/16.6);
gl.uniform1f(uBurst, burstVal);
gl.drawArrays(gl.POINTS, 0, N);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
Commentaires
Enregistrer un commentaire