Là où le son
prend forme.
Deux cabines traitées, une console analogique 32 pistes et un ingénieur du son à chaque session.
Des centaines de particules bleu-violet suivent les courants d'un vrai bruit de Perlin 3D qui se transforme lentement, et laissent des traînées soyeuses sur un canvas net en Retina.
Mis à jour le
Cet effet est gratuit — la catégorie SVG en contient 30 au total, dont 3 gratuits. Effect.Labs regroupe 840 effets vanilla. Découvrir la catégorie →
Deux cabines traitées, une console analogique 32 pistes et un ingénieur du son à chaque session.
Nappes lentes et piano feutré pour travailler sans paroles.
Tessaline
La racine .fx-0609-root contient un seul <canvas class="pff-canvas"> de 300 px de haut (variable --pff-height) et de toute la largeur. Le script lit la taille affichée du canvas, la multiplie par devicePixelRatio pour sa résolution interne, puis la relit à chaque changement grâce à un ResizeObserver. Tant que cette taille vaut 0 (onglet, accordéon ou modale fermés au chargement), rien ne tourne ; l'animation part dès que la zone apparaît. Chaque racine de la page a sa propre instance : deux copies tournent indépendamment, et le script marque chaque racine lancée (data-pff-ready) pour ne jamais la démarrer deux fois, même s'il est collé deux fois dans la page.
Le cœur est la fonction createNoise() : un bruit de Perlin « amélioré » (la version publiée par Ken Perlin en 2002), avec une table de permutation de 256 valeurs mélangée à chaque chargement, des gradients aux arêtes d'un cube et la courbe de lissage 6t⁵ − 15t⁴ + 10t³. On l'interroge en trois dimensions : x × SCALE, y × SCALE et le temps, qui avance de DRIFT par milliseconde. La valeur obtenue, multipliée par 4π, donne l'angle de déplacement de la particule à cet endroit : deux points voisins reçoivent des angles voisins, d'où ces courants continus qui se plient peu à peu.
Une particule par 480 px² (500 sur une zone de 800 × 300) avance de 0,5 à 1,5 px par image à 60 i/s ; le pas est recalculé à partir du temps écoulé (plafonné à 50 ms), donc la vitesse est la même sur un écran à 60 ou à 120 Hz. Au bout de 2 à 6 s, chaque particule renaît à un endroit tiré au hasard : sans ce renouvellement, toutes finiraient regroupées dans quelques courants. À chaque image, 5 % de l'image précédente est effacée vers la transparence, ce qui produit les traînées ; le fond visible est donc celui de la racine. Un IntersectionObserver coupe la boucle quand le canvas sort de l'écran.
role="img" et un aria-label qui décrit l'image. Utilisé en fond décoratif derrière un titre, remplacez-les par aria-hidden="true" pour ne pas faire lire une description inutile.prefers-reduced-motion: reduce, le champ est figé et les particules tracent leurs lignes de courant d'un seul coup ; on obtient une image fixe et complète, jamais une zone vide. Le réglage est relu s'il change pendant la visite.hidden de la racine : le code s'arrête dès que la taille tombe à 0 et repart là où il en était.Canvas 2D et JavaScript sans dépendance (environ 5,8 Ko non minifié). ResizeObserver : Chrome 64, Firefox 69, Safari 13.1 ; IntersectionObserver : Chrome 51, Firefox 55, Safari 12.1 (données MDN). Mesuré dans Chromium 151, Firefox 153 et WebKit 26.5 : rendu net à densité 2, deux copies indépendantes, démarrage après un affichage différé, image fixe en mouvement réduit, aucune image calculée hors écran, aucune erreur.
Sans ResizeObserver, le canvas suit seulement le redimensionnement de la fenêtre ; sans IntersectionObserver, l'animation continue hors écran. Avant Safari 14, un changement du réglage « réduire les animations » n'est pris en compte qu'au rechargement. Sans JavaScript, la zone reste un aplat sombre.
Copiez les trois blocs dans votre page. Aucune dépendance.
<div class="fx-0609-root">
<canvas class="pff-canvas" role="img" aria-label="Particules bleues et violettes qui suivent les courants d'un champ de bruit de Perlin"></canvas>
</div>
.fx-0609-root,
.fx-0609-root * {
box-sizing: border-box;
}
.fx-0609-root {
position: relative;
width: 100%;
overflow: hidden;
background: #0a0a0f;
}
/* Hauteur fixe par défaut ; --pff-height accepte aussi 100 % dans un parent de hauteur connue */
.fx-0609-root .pff-canvas {
display: block;
width: 100%;
height: var(--pff-height, 300px);
}
(function () {
// Perlin Flow Field : chaque particule suit la direction donnée par un bruit de Perlin 3D (x, y, temps).
var SCALE = 0.0035; // taille des courants : plus petit = courants plus larges
var DRIFT = 0.00009; // vitesse à laquelle le champ se transforme (unités de bruit par ms)
var DENSITY = 480; // une particule pour 480 px² de zone
var TRAIL = 0.05; // part de l'image effacée à chaque image (60 i/s) : plus petit = traînées plus longues
var LIFE = 4000; // durée de vie moyenne d'une particule (ms) avant de renaître ailleurs
var HUE = 220; // teinte de départ (bleu) ; les particules vont de HUE à HUE + 60
var reduced = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
// Bruit de Perlin « amélioré » (Ken Perlin, 2002) : gradients sur une grille 3D, interpolation lissée.
function createNoise() {
var p = new Uint8Array(512), i, j, t;
for (i = 0; i < 256; i++) p[i] = i;
for (i = 255; i > 0; i--) { j = Math.floor(Math.random() * (i + 1)); t = p[i]; p[i] = p[j]; p[j] = t; }
for (i = 0; i < 256; i++) p[i + 256] = p[i];
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function mix(a, b, t) { return a + t * (b - a); }
function grad(h, x, y, z) {
h &= 15;
var u = h < 8 ? x : y;
var v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
}
return function (x, y, z) {
var X = Math.floor(x), Y = Math.floor(y), Z = Math.floor(z);
x -= X; y -= Y; z -= Z;
X &= 255; Y &= 255; Z &= 255;
var u = fade(x), v = fade(y), w = fade(z);
var A = p[X] + Y, AA = p[A] + Z, AB = p[A + 1] + Z;
var B = p[X + 1] + Y, BA = p[B] + Z, BB = p[B + 1] + Z;
return mix(
mix(mix(grad(p[AA], x, y, z), grad(p[BA], x - 1, y, z), u),
mix(grad(p[AB], x, y - 1, z), grad(p[BB], x - 1, y - 1, z), u), v),
mix(mix(grad(p[AA + 1], x, y, z - 1), grad(p[BA + 1], x - 1, y, z - 1), u),
mix(grad(p[AB + 1], x, y - 1, z - 1), grad(p[BB + 1], x - 1, y - 1, z - 1), u), v),
w);
};
}
function init(root) {
// Une seule instance par racine, même si le script est collé plusieurs fois dans la page.
if (root.hasAttribute('data-pff-ready')) return;
root.setAttribute('data-pff-ready', '');
var canvas = root.querySelector('.pff-canvas');
var ctx = canvas && canvas.getContext ? canvas.getContext('2d') : null;
if (!ctx) return;
var noise = createNoise();
var W = 0, H = 0, ratio = 0, time = Math.random() * 100;
var particles = [], raf = 0, last = 0, visible = true;
// Une particule renaît à un endroit au hasard : sans cela, toutes finissent dans les mêmes courants.
function place(q) {
q.x = Math.random() * W;
q.y = Math.random() * H;
q.life = LIFE * (0.5 + Math.random());
}
function seed() {
var n = Math.max(60, Math.min(3000, Math.round(W * H / DENSITY)));
particles = [];
for (var i = 0; i < n; i++) {
var q = { v: 0.5 + Math.random(), c: 'hsla(' + Math.round(HUE + Math.random() * 60) + ', 80%, 60%, 0.8)' };
place(q);
q.life *= Math.random();
particles.push(q);
}
}
function advance(dt) {
var k = dt / 16.667;
// Les traînées s'effacent vers la transparence : le fond visible est celui de la racine (CSS).
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0, 0, 0, ' + (1 - Math.pow(1 - TRAIL, k)) + ')';
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'source-over';
for (var i = 0; i < particles.length; i++) {
var q = particles[i];
var angle = noise(q.x * SCALE, q.y * SCALE, time) * Math.PI * 4;
q.x += Math.cos(angle) * q.v * k;
q.y += Math.sin(angle) * q.v * k;
if (q.x < 0) q.x += W; else if (q.x > W) q.x -= W;
if (q.y < 0) q.y += H; else if (q.y > H) q.y -= H;
q.life -= dt;
if (q.life <= 0) place(q);
ctx.fillStyle = q.c;
ctx.beginPath();
ctx.arc(q.x, q.y, 1, 0, Math.PI * 2);
ctx.fill();
}
}
// Mouvement réduit : le champ est figé et les particules tracent leurs lignes de courant une fois.
function drawStill() {
for (var i = 0; i < 160; i++) advance(16.667);
}
function loop(t) {
var dt = last ? Math.min(t - last, 50) : 16.667;
last = t;
time += DRIFT * dt;
advance(dt);
raf = requestAnimationFrame(loop);
}
function stop() {
if (raf) cancelAnimationFrame(raf);
raf = 0;
}
function play() {
if (raf || !W || !visible || (reduced && reduced.matches)) return;
last = 0;
raf = requestAnimationFrame(loop);
}
// Taille lue sur la zone affichée ; rien ne démarre tant qu'elle vaut 0 (onglet ou accordéon fermé).
function resize(force) {
var w = canvas.clientWidth, h = canvas.clientHeight;
if (!w || !h) { stop(); return; }
var r = window.devicePixelRatio || 1;
if (force || w !== W || h !== H || r !== ratio) {
W = w; H = h; ratio = r;
canvas.width = Math.round(w * r);
canvas.height = Math.round(h * r);
ctx.setTransform(r, 0, 0, r, 0, 0);
seed();
if (reduced && reduced.matches) { stop(); drawStill(); }
}
play();
}
if (window.ResizeObserver) new ResizeObserver(function () { resize(false); }).observe(canvas);
else window.addEventListener('resize', function () { resize(false); });
if (window.IntersectionObserver) {
new IntersectionObserver(function (entries) {
visible = entries[entries.length - 1].isIntersecting;
if (visible) play(); else stop();
}).observe(canvas);
}
if (reduced && reduced.addEventListener) reduced.addEventListener('change', function () { stop(); resize(true); });
resize(false);
}
document.querySelectorAll('.fx-0609-root').forEach(init);
})();
Options passées à l'API ou attributs data-* :
| Option / propriété | Défaut | Effet |
|---|---|---|
Taille des courants (JS — SCALE) |
0.0035 | Plus petit (0.002) : de larges courants paresseux. Plus grand (0.008) : des tourbillons serrés et nerveux. |
Transformation du champ (JS — DRIFT) |
0.00009 par ms | 0 fige le champ (les particules suivent toujours les mêmes lignes) ; 0.0003 le fait changer sous les yeux. |
Densité (JS — DENSITY) |
480 px² par particule | 240 double le nombre de particules, 1000 l'allège. Le total reste borné entre 60 et 3 000. |
Longueur des traînées (JS — TRAIL) |
0.05 | Part effacée à chaque image : 0.02 donne de longues traînées laiteuses, 0.15 des points presque nets. |
Durée de vie (JS — LIFE) |
4000 ms en moyenne (de 2 à 6 s) | Une valeur longue laisse les particules se regrouper en quelques grands courants ; une valeur courte répartit le dessin sur toute la zone. |
Couleurs (JS — HUE) |
220 (bleu → violet sur 60°) | 160 donne du vert d'eau au bleu, 330 du rose à l'orange. La saturation (80 %) et la luminosité (60 %) sont dans la même ligne du script. |
Fond (CSS — background de .fx-0609-root) |
#0a0a0f | Les traînées s'effacent vers la transparence : seule cette couleur est à changer. Sur fond clair, préférez des particules plus sombres (luminosité 40 % dans la ligne de couleur). |
Hauteur (CSS — --pff-height) |
300px | Toute hauteur fixe ; 100% pour remplir un parent de hauteur connue. Le canvas se redimensionne seul. |
Oui. createNoise() implémente l'algorithme de Perlin de 2002 (table de permutation, gradients pseudo-aléatoires sur une grille, interpolation lissée), en 3D pour que le temps fasse évoluer le champ sans saut. La table est mélangée par Math.random() : chaque chargement et chaque copie dessinent des courants différents.
Un champ de Perlin a des zones où les directions convergent : les particules s'y rassemblent naturellement. Le renouvellement après LIFE millisecondes les redistribue. Montez LIFE à 10000 pour des lignes plus marquées, descendez à 1500 pour un voile régulier.
Une zone de 800 × 300 calcule 500 particules par image, soit 500 appels au bruit et 500 petits disques : c'est léger sur un ordinateur comme sur un téléphone récent. Le calcul s'arrête hors écran et dans un onglet en arrière-plan. Pour un hero plein écran, gardez DENSITY à 480 ou plus : le plafond de 3 000 particules évite les surprises.