Cendre
Du texte peint dans une texture, qu'un nuage découvre pixel par pixel.
Le texte ne se fond pas.Il se forme.
Puis il se défait,et recommence autrement.
<Cendre
flou={16}
front={0.22}
ressort={16}
repos={320}
relief={0.006}
/>Fiche
Pourquoi
Le texte de la carte ci-dessus n'est pas animé : il est PEINT. Les deux blocs sont composés une fois sur un canevas 2D hors écran, téléversés dans une texture, et c'est un fragment shader qui décide, pour chaque pixel, s'il est déjà découvert et de combien il est encore flou. À partir de là, ce n'est plus de la typographie, c'est une image.
C'est le détour qui rend l'effet possible. Un fondu qui dissout du texte en volutes doit trancher pixel par pixel, et le DOM ne sait pas faire ça : il connaît des boîtes, pas des pixels. On peut découper le texte en lettres et animer chaque lettre : l'effet est autre, plus mécanique, et il coûte un nœud par caractère. Une texture et un shader coûtent un nœud, que le texte fasse quatre mots ou quarante.
Trois champs se superposent pour décider quand un pixel se découvre : une DIAGONALE, qui donne le sens général de la marche ; une TURBULENCE, un fbm déformé sur lui-même, qui donne les volutes ; et un GRAIN fin, qui casse les contours restés trop lisses. Leur somme est comparée à l'avancement. Au-delà, le pixel est encore caché ; en deçà, il est découvert. Entre les deux, une bande où il est à la fois partiellement opaque et flou : c'est le front, et c'est le curseur « Front » qui en règle la largeur. Serrez-le à quatre centièmes : le nuage redevient une lame.
float champ = mix(diag, turb, 0.28);
champ = mix(champ, grain, 0.14);
/* Le curseur creuse le champ : le texte se découvre plus tôt là où
la main se trouve. Au repos, « uSurvol » vaut zéro et la ligne est
sans effet. */
vec2 cur = (vUV - uCurseur) * vec2(uRatio, 1.0);
float pres = 1.0 - smoothstep(0.0, 0.32, length(cur));
champ -= pres * uSurvol * 0.28;
/* « 1 - smoothstep(a, b, x) », et jamais « smoothstep(b, a, x) ». Les
deux dessinent la même rampe descendante, mais la seconde passe
Le flou décroît vers zéro DERRIÈRE le front, et c'est ce qui distingue cette planche d'un fondu. Le texte ne se contente pas d'apparaître : il fait le point. Chaque partie sort floue de la cendre et se résout à mesure qu'elle est découverte. Mettez « Flou » à zéro dans la carte : le dessin reste, la sensation part. Le texte se découpe au lieu de se former.
Le flou est un anneau de seize prélèvements, deux couronnes de huit dont la seconde à double rayon. Ce n'est pas un flou gaussien, et il n'a pas à l'être : sur des lettres, à ces rayons, personne ne fait la différence, et un vrai gaussien séparable demanderait deux passes et une cible de rendu intermédiaire. Le shader sort tôt quand le rayon est négligeable, si bien que tout ce qui est déjà net ne coûte qu'un seul prélèvement.
Le temps est un RESSORT, pas une durée, et c'est la deuxième décision de la planche. Un cubic-bezier sait aller d'un point à un autre en un temps donné : parfait pour une transition qu'on déclenche. Ici la valeur est poursuivie en continu, et un ressort amorti donne ce qu'aucune courbe ne donne : une arrivée qui décélère d'elle-même, une reprise à mi-course qui ne saute pas, et une sortie qu'on peut rendre plus raide que l'entrée sans changer de vocabulaire.
function pas(x: number, v: number, cible: number, k: number, dt: number): [number, number] {
const c = 2 * Math.sqrt(k) * AMORTI;
const nv = v + (-k * (x - cible) - c * v) * dt;
return [x + nv * dt, nv];
}
La sortie est quarante pour cent plus raide que l'entrée, et ce n'est pas un caprice : le texte se forme lentement et s'efface vite, ce qui est le rythme de la lecture, pas celui d'une courbe symétrique. Une machine à quatre états fait le reste (entrée, repos, sortie, échange), et à chaque échange une GRAINE nouvelle décale le bruit. Sans elle, le nuage se dissout deux fois de la même façon, et à la troisième on ne regarde plus.
Rien de tout cela ne tourne pour personne : hors écran, onglet caché, mouvement réduit, la boucle s'arrête. Une boucle requestAnimationFrame qui tourne pour un onglet qu'on ne regarde pas est la façon la plus discrète de vider une batterie.
Et le texte reste dans le DOM. C'est la règle de la maison : l'animation est l'agrément, le texte est le contrat. Sans JavaScript, sans WebGL, ou avec un shader qui refuse de compiler, les deux blocs sont simplement là, dans leurs coins, en clair, et le composant n'a rien à faire pour ça : il lui suffit de ne rien faire.
Ce qui coince
smoothstepaux bornes inversées : indéfini, donc invisible. Pour dessiner une rampe DESCENDANTE, on écrit volontierssmoothstep(haut, bas, x): on inverse les bornes et on lit à l'envers. Ça marche : la division par un dénominateur négatif tombe juste, et tous les pilotes de cartes graphiques rendent exactement ce qu'on attend. La spécification GLSL, elle, déclare le résultat INDÉFINI quandedge0 >= edge1. Le rendu logiciel a renvoyé zéro. Résultat :renduvalait zéro partout, chaque fragment étaitdiscard, et la carte était vide, sans erreur de compilation, sans avertissement, sur une seule famille de machines. Un moteur qui applique la spécification à la lettre a le droit de faire ça, et c'est le pire genre de bogue : celui qui ne se voit pas là où on développe.1.0 - smoothstep(bas, haut, x)dessine la même courbe et n'est indéfini nulle part.float rendu = 1.0 - smoothstep(p - uFront, p + uFront, champ);La même rampe, et définie partout. - Le repli n'est pas un repli, c'est le rendu par défaut. L'ordre est celui-ci et pas l'inverse : le texte est écrit en HTML, dans ses deux coins, avec sa mise en page ; ensuite, et seulement si le contexte existe et si le shader compile, la toile vient par-dessus et la copie en clair passe à zéro d'opacité. Un composant qui construit son repli APRÈS avoir échoué a toujours un chemin d'échec qu'il n'a pas prévu. Celui-ci n'en a aucun : ne rien faire donne déjà la bonne page.
.cnd[data-gl="true"] .cnd__hg, .cnd[data-gl="true"] .cnd__bd { opacity: 0; pointer-events: none; } - Une opacité nulle, pas une visibilité cachée.
visibility: hiddenretire un nœud de l'arbre d'accessibilité. Une carte dont tout le texte est peint dans un canevas ne dirait alors plus rien à personne : le canevas n'est pas annoncé, et la seule copie lisible venait d'être masquée. Une opacité nulle laisse le texte annoncé tout en le rendant invisible. C'est aussi pourquoi l'index des paires vit dans un état React et pas seulement dans la boucle : ce qu'un lecteur d'écran annonce est exactement ce qui est peint. - Les réglages passent par une référence, jamais par les dépendances. Premier montage naïf : les cinq réglages en dépendances de l'effet. Chaque cran de curseur détruisait le contexte WebGL, recompilait le shader et recomposait la texture, soixante fois par seconde pendant qu'un doigt glisse. La boucle les lit maintenant dans une référence mise à jour à chaque rendu : le composant ne se remonte jamais, et régler le flou ne coûte pas plus qu'un uniforme de plus à envoyer.
const reg = useRef({ flou, front, ressort, repos, relief, paires }); reg.current = { flou, front, ressort, repos, relief, paires }; - Un contexte WebGL ne se ramasse pas tout seul. Le navigateur en tolère une poignée par page, souvent seize. Une carte qu'on monte et démonte au fil de la navigation les épuise, et le seizième contexte échoue en silence : pas d'erreur, une toile vide, et un composant qui marchait très bien il y a trois pages.
WEBGL_lose_contextle rend explicitement au démontage. C'est deux lignes, et c'est la différence entre une démonstration et un composant. - Le curseur n'écoute que la souris. La planche penche vers le pointeur, d'un seul décalage partagé, pas un par lettre : c'est une plaque posée sur une table qu'on incline, pas un mobile. Mais un
pointermovereçoit aussi les doigts, et un appui sur téléphone laissait un creux permanent là où le doigt avait quitté l'écran, sans rien pour l'en faire partir. Le test surpointerTyperègle la question en une ligne : sans souris, la planche reste droite, ce qui est exactement ce qu'on veut d'un effet de survol.
Le code
Les fichiers en entier, lus sur le disque au moment du build, dans le composant même qui tourne au-dessus. Les extraits cités plus haut en sont des lignes exactes : les numéros correspondent. Copiez-les, le composant n'a besoin de rien d'autre.
"use client";
import { useEffect, useRef, useState } from "react";
import { Cendre as Toile, dessinerTexte } from "./cendre-gl";
/* ------------------------------------------------------------------
Cendre · deux blocs de texte qui se forment dans un nuage.
Le texte n'est pas animé : il est PEINT. Les deux blocs sont composés
une fois sur un canevas 2D hors écran, téléversés en texture, et c'est
un fragment shader qui décide, pour chaque pixel, s'il est déjà
découvert et de combien il est encore flou. Voir `cendre-gl.ts` pour
ce qui se passe dans le shader ; ce fichier-ci ne s'occupe que du
TEMPS.
Le temps est un RESSORT, pas une durée. Un `cubic-bezier` sait aller
d'un point à un autre en un temps donné, et c'est très bien pour une
transition qu'on déclenche. Ici la valeur est poursuivie en continu,
et un ressort amorti donne ce qu'aucune courbe ne donne : une arrivée
qui décélère d'elle-même, une sortie qui peut être plus raide que
l'entrée sans changer de vocabulaire, et une reprise à mi-course qui
ne saute pas. Le ressort de sortie est plus dur que celui d'entrée :
le texte se forme lentement et s'efface vite, ce qui est le rythme
naturel de la lecture.
Une machine à quatre états, et c'est tout le pilotage :
entrée → la cible est 1, on attend d'être arrivé
repos → on tient quelques centaines de millisecondes
sortie → la cible est 0, on attend d'être vide
échange → on tire la paire suivante, on change la graine, on repart
LA GRAINE est ce qui empêche la boucle de se dénoncer. Sans elle, le
nuage se dissout deux fois de la même façon, et la troisième fois on
ne regarde plus.
CE QUI NE TOURNE PAS : hors écran, onglet caché, mouvement réduit. Une
boucle `requestAnimationFrame` qui tourne pour personne est la façon
la plus discrète de vider une batterie.
ET LE TEXTE RESTE DANS LE DOM. C'est la règle de la maison :
l'animation est l'agrément, le texte est le contrat. Sans WebGL, sans
JavaScript, ou avec un shader qui refuse de compiler, les deux blocs
sont simplement là, dans leurs coins, en clair.
------------------------------------------------------------------- */
export type Paire = { haut: string[]; bas: string[] };
export type CendreProps = {
/** Les paires de phrases, tirées à tour de rôle. */
paires: Paire[];
/** Rayon maximal du flou, en texels. Zéro : le texte se découvre net. */
flou?: number;
/** Demi-largeur du front, en parts du champ. Petit : une lame. Grand : un banc. */
front?: number;
/** Raideur du ressort d'entrée. La sortie est toujours plus raide. */
ressort?: number;
/** Temps de repos entre l'arrivée et la sortie, en millisecondes. */
repos?: number;
/** Amplitude de la parallaxe au curseur, en parts de la texture. */
relief?: number;
className?: string;
};
/* Un ressort critiquement amorti : l'amortissement est calé sur la
raideur, donc il n'y a qu'un seul nombre à régler. Au-dessus de 1, il
n'oscille jamais : un texte qui rebondit n'est pas un texte qui se
pose. */
const AMORTI = 1.12;
const SORTIE = 1.4; /* la sortie est 40 % plus raide que l'entrée */
const ARRIVE = 0.95;
const VIDE = 0.02;
const REPOS_SORTIE = 80;
const HG: [number, number] = [0.16, 0.18];
const BD: [number, number] = [0.84, 0.82];
function pas(x: number, v: number, cible: number, k: number, dt: number): [number, number] {
const c = 2 * Math.sqrt(k) * AMORTI;
const nv = v + (-k * (x - cible) - c * v) * dt;
return [x + nv * dt, nv];
}
/** Lit une couleur du thème et la rend en trois flottants. */
function encre(el: HTMLElement, prop: string, repli: string): string {
const v = getComputedStyle(el).getPropertyValue(prop).trim();
return v || repli;
}
function versRGB(css: string): [number, number, number] {
const m = css.match(/\d+(\.\d+)?/g);
if (m && m.length >= 3 && css.startsWith("rgb")) {
return [+m[0] / 255, +m[1] / 255, +m[2] / 255];
}
let h = css.replace("#", "");
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
const n = parseInt(h, 16);
if (Number.isNaN(n)) return [1, 1, 1];
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
export default function CendreCarte({
paires,
flou = 16,
front = 0.22,
ressort = 16,
repos = 320,
relief = 0.006,
className,
}: CendreProps) {
const hote = useRef<HTMLDivElement>(null);
/* Les réglages voyagent par une référence : la boucle les lit à chaque
trame, et les changer ne remonte donc ni le contexte ni la texture.
Un curseur qu'on fait glisser ne doit pas recompiler un shader. */
const reg = useRef({ flou, front, ressort, repos, relief, paires });
reg.current = { flou, front, ressort, repos, relief, paires };
/* L'index est aussi dans React : le texte du DOM suit celui de la
texture, donc ce qu'un lecteur d'écran annonce est ce qui est peint. */
const [i, setI] = useState(0);
useEffect(() => {
const el = hote.current;
if (!el) return;
const calme = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
/* La braise du front reprend l'encre du texte : le front est un
endroit où le texte est en train d'arriver, pas une couleur de
plus. Un ton pris ailleurs dans le thème donne une frange qui
n'appartient à rien. */
const bord = versRGB(encre(el, "--fg", "#efeae9"));
const teinte = encre(el, "--fg", "#efeae9");
let L = el.clientWidth || 1;
let H = el.clientHeight || 1;
let police = getComputedStyle(el).fontFamily || "sans-serif";
const toile = new Toile();
if (!toile.disponible) return;
el.dataset.gl = "true";
toile.taille(L, H, dpr);
el.appendChild(toile.canvas);
let index = 0;
let graine = 1.7;
const composer = () => {
const p = reg.current.paires[index % reg.current.paires.length];
toile.texture(
dessinerTexte({
haut: p.haut,
bas: p.bas,
police,
encre: teinte,
largeur: L,
hauteur: H,
dpr,
})
);
};
composer();
/* ---------- Le curseur ---------- */
let cibleX = 0.5, cibleY = 0.5, curX = 0.5, curY = 0.5;
let uv: [number, number] = [-1, -1];
let survolC = 0, survolV = 0;
const surLaMain = (e: PointerEvent) => {
/* Une main, pas un doigt : un appui sur téléphone donnerait un
creux qui reste là où le doigt a quitté l'écran. */
if (e.pointerType !== "mouse") return;
const b = el.getBoundingClientRect();
cibleX = (e.clientX - b.left) / b.width;
cibleY = (e.clientY - b.top) / b.height;
uv = [cibleX, cibleY];
survolV = 1;
};
const sortie = () => {
cibleX = 0.5;
cibleY = 0.5;
survolV = 0;
};
el.addEventListener("pointermove", surLaMain);
el.addEventListener("pointerleave", sortie);
/* ---------- La boucle ---------- */
let phase: "entree" | "repos" | "sortie" = "entree";
let depuis = 0;
let x = 0, v = 0, cible = 1;
let horloge = 0, avant = 0, raf = 0, tourne = false;
const trame = () => {
if (!tourne) return;
const t = performance.now();
const dt = Math.min(0.05, Math.max(0.001, (t - avant) / 1000));
avant = t;
horloge += dt;
const r = reg.current;
[x, v] = pas(x, v, cible, cible > 0.5 ? r.ressort : r.ressort * SORTIE, dt);
if (phase === "entree") {
if (x >= ARRIVE) { phase = "repos"; depuis = t; }
} else if (phase === "repos") {
if (t - depuis >= r.repos) { phase = "sortie"; depuis = t; cible = 0; }
} else if (x <= VIDE && t - depuis >= REPOS_SORTIE) {
index = (index + 1) % r.paires.length;
setI(index);
graine = ((graine * 1.618) % 7) + 0.3;
composer();
phase = "entree";
depuis = t;
x = 0;
v = 0;
cible = 1;
}
/* Deux lissages exponentiels indépendants du pas de temps : la
planche suit la main sans jamais la rattraper d'un coup. */
const k = 1 - Math.pow(0.0009, dt);
curX += (cibleX - curX) * k;
curY += (cibleY - curY) * k;
survolC += (survolV - survolC) * (1 - Math.pow(0.002, dt));
const amp = r.relief * survolC;
toile.peindre({
progres: Math.max(0, Math.min(1, x)),
flou: r.flou,
front: r.front,
bord,
temps: horloge,
ratio: L / Math.max(1, H),
graine,
parHG: [(curX - HG[0]) * amp, (curY - HG[1]) * amp],
parBD: [(curX - BD[0]) * amp, (curY - BD[1]) * amp],
curseur: uv,
survol: survolC,
retour: phase === "sortie" ? 1 : 0,
});
raf = requestAnimationFrame(trame);
};
const fige = () =>
toile.peindre({
progres: 1, flou: 0, front: reg.current.front, bord, temps: 0,
ratio: L / Math.max(1, H), graine, parHG: [0, 0], parBD: [0, 0],
curseur: [-1, -1], survol: 0, retour: 0,
});
const partir = () => {
if (tourne) return;
tourne = true;
phase = "entree";
depuis = avant = performance.now();
x = 0; v = 0; cible = 1;
raf = requestAnimationFrame(trame);
};
const arreter = () => {
tourne = false;
if (raf) cancelAnimationFrame(raf);
raf = 0;
};
let vue = false, cache = false;
const accorder = () => {
if (calme) return;
if (vue && !cache) partir();
else arreter();
};
const io = new IntersectionObserver(
(e) => { vue = e[0]?.isIntersecting ?? false; accorder(); },
{ threshold: 0.15 }
);
io.observe(el);
const surVisibilite = () => { cache = document.hidden; accorder(); };
document.addEventListener("visibilitychange", surVisibilite);
/* Une seule remesure par redimensionnement, et différée : recomposer
la planche à chaque pixel de largeur reviendrait à recomposer du
texte soixante fois par seconde pendant qu'on tire une fenêtre. */
let minuteur = 0;
const ro = new ResizeObserver(() => {
window.clearTimeout(minuteur);
minuteur = window.setTimeout(() => {
const l = el.clientWidth || 1, h = el.clientHeight || 1;
if (l < 2 || h < 2 || (l === L && h === H)) return;
L = l; H = h;
toile.taille(L, H, dpr);
composer();
if (calme) fige();
}, 140);
});
ro.observe(el);
/* La police du site est chargée après le premier rendu : composer
avant qu'elle arrive donne une planche en police système, et
personne ne comprend pourquoi le texte a changé de visage. */
if (document.fonts?.load) {
document.fonts.load(`500 1em ${police}`).then(
() => {
police = getComputedStyle(el).fontFamily || police;
composer();
if (calme) fige();
},
() => {}
);
}
if (calme) fige();
return () => {
arreter();
io.disconnect();
ro.disconnect();
window.clearTimeout(minuteur);
document.removeEventListener("visibilitychange", surVisibilite);
el.removeEventListener("pointermove", surLaMain);
el.removeEventListener("pointerleave", sortie);
toile.detruire();
toile.canvas.remove();
delete el.dataset.gl;
};
}, []);
const p = paires[i % paires.length];
return (
<div ref={hote} className={className ? `cnd ${className}` : "cnd"}>
<p className="cnd__hg">
{p.haut.map((l, k) => (
<span key={k}>{l}</span>
))}
</p>
<p className="cnd__bd">
{p.bas.map((l, k) => (
<span key={k}>{l}</span>
))}
</p>
</div>
);
}
/* ------------------------------------------------------------------
Cendre · la moitié basse : le contexte WebGL et la planche de texte.
Deux objets, deux métiers, et rien d'autre dans ce fichier.
`dessinerTexte` compose les deux blocs de texte sur un canevas 2D hors
écran. C'est du remplissage de texte ordinaire (police, mesure,
retour à la ligne), et c'est la seule fois où la typographie est
traitée comme de la typographie. Ensuite, ce n'est plus qu'une image.
`Cendre` téléverse cette image dans une texture et la donne à un seul
fragment shader. Aucune bibliothèque, aucun moteur : un quadrilatère,
deux triangles, treize uniformes.
POURQUOI PASSER PAR UNE TEXTURE plutôt que d'animer du DOM. Un fondu
qui dissout le texte en volutes doit décider, POUR CHAQUE PIXEL, s'il
est déjà découvert et de combien il est flou. Le DOM ne sait pas faire
ça : il connaît des boîtes, pas des pixels. Découper le texte en
lettres et animer chaque lettre donne un autre effet, plus mécanique,
et qui coûte un nœud par caractère. Une texture et un shader coûtent
un nœud, quelle que soit la longueur du texte.
------------------------------------------------------------------- */
const SOMMET = `
attribute vec2 aPos;
attribute vec2 aUV;
varying vec2 vUV;
void main(){
vUV = aUV;
gl_Position = vec4(aPos, 0.0, 1.0);
}
`;
/* Le fragment shader, et tout l'effet tient dedans.
Trois champs se superposent pour décider quand un pixel se découvre :
une DIAGONALE (le sens général de la marche), une TURBULENCE (le fbm
déformé sur lui-même, qui donne les volutes) et un GRAIN fin (qui
casse les contours restés trop lisses). Leur somme est comparée à
l'avancement : au-delà, le pixel est encore caché ; en deçà, il est
découvert. Entre les deux, une bande de largeur `uFront` où le pixel
est à la fois partiellement opaque ET flou.
Le FLOU décroît vers zéro derrière le front. C'est ce qui fait que le
texte ne se contente pas d'apparaître : il fait le point. Un anneau de
seize prélèvements suffit (deux couronnes de huit, la seconde à
double rayon), et le shader sort tôt quand le rayon est négligeable,
ce qui rend les zones déjà nettes presque gratuites. */
const FRAGMENT = `
precision highp float;
uniform sampler2D uTex;
uniform vec2 uTexel;
uniform float uProgres;
uniform float uFlou;
uniform float uFront;
uniform vec3 uBord;
uniform float uTemps;
uniform float uRatio;
uniform float uGraine;
uniform vec2 uParHG;
uniform vec2 uParBD;
uniform vec2 uCurseur;
uniform float uSurvol;
uniform float uRetour;
varying vec2 vUV;
float alea(vec2 p){ return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5453); }
float bruit(vec2 p){
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = alea(i), b = alea(i + vec2(1, 0));
float c = alea(i + vec2(0, 1)), d = alea(i + vec2(1, 1));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
float fbm(vec2 p){
float v = 0.0, amp = 0.5;
for (int i = 0; i < 4; i++){
v += amp * bruit(p);
p *= 2.03;
amp *= 0.5;
}
return v;
}
vec4 flouter(vec2 uv, float rayon){
if (rayon < 0.35) return texture2D(uTex, uv);
vec2 r1 = uTexel * rayon;
vec2 r2 = r1 * 2.0;
vec4 somme = texture2D(uTex, uv);
float poids = 1.0;
for (int i = 0; i < 8; i++){
float a = float(i) * 0.785398;
vec2 dir = vec2(cos(a), sin(a));
somme += texture2D(uTex, uv + dir * r1) * 0.75; poids += 0.75;
somme += texture2D(uTex, uv + dir * r2) * 0.5; poids += 0.5;
}
return somme / poids;
}
void main(){
/* Un seul décalage de parallaxe pour toute la planche, interpolé
entre les deux coins. Décaler chaque lettre séparément donnerait
un mobile ; c'est la PLANCHE qui penche, comme une plaque posée
sur une table qu'on incline. */
float zone = smoothstep(0.35, 0.65, (vUV.x + vUV.y) * 0.5);
vec2 par = mix(uParHG, uParBD, zone);
vec2 uvBase = vUV + par;
if (uProgres >= 0.999) { gl_FragColor = texture2D(uTex, uvBase); return; }
float p = uProgres * 1.3;
vec2 gr = vec2(uGraine * 1.7, uGraine * -1.3);
vec2 rc = (vUV - 0.5) * vec2(uRatio, 1.0);
float diag = smoothstep(0.18, 0.82, (vUV.x + vUV.y) * 0.5);
diag = mix(diag, 1.0 - diag, uRetour);
diag += (fbm(vUV * 1.3 + gr) - 0.5) * 0.08;
vec2 gauchi = vec2(fbm(vUV * 3.2 + gr + uTemps * 0.05 + 11.0),
fbm(vUV * 3.2 - gr - uTemps * 0.04 - 7.0)) - 0.5;
float turb = fbm(vUV * 5.5 + gauchi * 1.7 + gr + uTemps * 0.06);
float grain = bruit(vUV * vec2(uRatio, 1.0) * 46.0 + gr * 3.0);
float champ = mix(diag, turb, 0.28);
champ = mix(champ, grain, 0.14);
/* Le curseur creuse le champ : le texte se découvre plus tôt là où
la main se trouve. Au repos, « uSurvol » vaut zéro et la ligne est
sans effet. */
vec2 cur = (vUV - uCurseur) * vec2(uRatio, 1.0);
float pres = 1.0 - smoothstep(0.0, 0.32, length(cur));
champ -= pres * uSurvol * 0.28;
/* « 1 - smoothstep(a, b, x) », et jamais « smoothstep(b, a, x) ». Les
deux dessinent la même rampe descendante, mais la seconde passe
des bornes inversées : la spécification GLSL déclare le résultat
INDÉFINI quand « edge0 >= edge1 ». Les pilotes des cartes le
tolèrent (la division par un dénominateur négatif tombe juste)
et un rendu logiciel peut très bien renvoyer zéro partout. On ne
voit alors rien du tout, sans une erreur de compilation, et sur
une seule famille de machines. */
float rendu = 1.0 - smoothstep(p - uFront, p + uFront, champ);
if (rendu <= 0.0) discard;
float voile = smoothstep(p - uFront * 1.55, p + uFront, champ);
/* Le texte qui émerge dérive et grandit d'un cheveu, puis se pose.
Sans ça, il apparaît net et immobile : plus propre, et mort. */
vec2 derive = (-rc * 0.010 + vec2(0.0, 0.006)) * voile;
float pousse = 1.0 + 0.03 * voile;
vec2 suv = (uvBase - 0.5) / pousse + 0.5 + derive;
float rayon = voile * uFlou;
vec4 tex = flouter(suv, rayon);
/* La braise du front : une bande étroite, vive, qui ne vit que
pendant la traversée et s'éteint à l'arrivée. */
float largeur = uFront * 1.36;
float braise = smoothstep(p - largeur, p, champ) *
(1.0 - smoothstep(p, p + largeur, champ));
braise *= 1.0 - smoothstep(0.8, 1.0, uProgres);
/* L'écartement des canaux est proportionnel au TEXEL, donc au format
de la planche : le même coefficient qui donne un liseré discret
sur une carte large donne une frange colorée sur une vignette,
parce que le texel y est trois fois plus gros. Un tiers suffit :
au-delà, ce n'est plus un prisme, c'est du bruit. */
float ecart = braise * 0.35 * uTexel.x * uFlou;
if (ecart > 0.0001) {
tex.r = flouter(suv + vec2(ecart, 0.0), rayon).r;
tex.b = flouter(suv - vec2(ecart, 0.0), rayon).b;
}
vec4 large = flouter(suv, uFlou * 1.3);
float halo = large.a;
vec3 rgb = tex.rgb + mix(uBord, vec3(1.0), 0.3) * braise * (tex.a * 0.6 + halo * 0.5);
gl_FragColor = vec4(rgb, max(tex.a * rendu, halo * braise * 0.5));
}
`;
export type Coins = {
haut: string[];
bas: string[];
police: string;
encre: string;
largeur: number;
hauteur: number;
dpr: number;
};
/** Les deux blocs de texte, composés sur un canevas 2D hors écran. */
export function dessinerTexte(o: Coins): HTMLCanvasElement {
const L = Math.max(1, Math.round(o.largeur));
const H = Math.max(1, Math.round(o.hauteur));
const cv = document.createElement("canvas");
cv.width = Math.round(L * o.dpr);
cv.height = Math.round(H * o.dpr);
const ctx = cv.getContext("2d");
if (!ctx) return cv;
ctx.scale(o.dpr, o.dpr);
ctx.fillStyle = o.encre;
ctx.textBaseline = "top";
const marge = Math.round(L * 0.055);
const maxi = L * 0.56;
const lignes = [...o.haut, ...o.bas];
/* Le corps se cherche par essais, du plus grand au plus petit, jusqu'à
ce que la ligne la plus large tienne. Une seule mesure par essai, et
un plancher : sous quatorze pixels, on préfère déborder que rendre
un texte qu'on ne lit plus. */
let corps = Math.max(15, Math.min(30, L * 0.038));
for (let i = 0; i < 24; i++) {
ctx.font = `500 ${corps}px ${o.police}`;
const large = Math.max(...lignes.map((l) => ctx.measureText(l).width));
if (large <= maxi || corps <= 14) break;
corps -= 1;
}
const interligne = corps * 1.45;
ctx.font = `500 ${corps}px ${o.police}`;
ctx.textAlign = "left";
o.haut.forEach((l, i) => ctx.fillText(l, marge, marge + i * interligne));
ctx.textAlign = "right";
const depart = H - marge - o.bas.length * interligne;
o.bas.forEach((l, i) => ctx.fillText(l, L - marge, depart + i * interligne));
return cv;
}
export class Cendre {
readonly canvas: HTMLCanvasElement;
private gl: WebGLRenderingContext | null = null;
private prog: WebGLProgram | null = null;
private quad: WebGLBuffer | null = null;
private u: Record<string, WebGLUniformLocation | null> = {};
private aPos = 0;
private aUV = 0;
private tex: WebGLTexture | null = null;
private tw = 1;
private th = 1;
private vivant = false;
constructor() {
this.canvas = document.createElement("canvas");
this.canvas.className = "cnd__toile";
/* `premultipliedAlpha: false` parce que la texture vient d'un canevas
2D, qui est prémultiplié : le laisser à vrai fait un liseré sombre
autour de chaque lettre, visible seulement sur fond clair. */
const gl = this.canvas.getContext("webgl", {
alpha: true,
premultipliedAlpha: false,
antialias: false,
});
if (!gl) return;
try {
const prog = this.lier(gl, SOMMET, FRAGMENT);
this.gl = gl;
this.prog = prog;
this.aPos = gl.getAttribLocation(prog, "aPos");
this.aUV = gl.getAttribLocation(prog, "aUV");
for (const n of [
"uTex", "uTexel", "uProgres", "uFlou", "uFront", "uBord", "uTemps",
"uRatio", "uGraine", "uParHG", "uParBD", "uCurseur", "uSurvol", "uRetour",
]) {
this.u[n] = gl.getUniformLocation(prog, n);
}
this.quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 0, 1, 1, -1, 1, 1, -1, 1, 0, 0, 1, 1, 1, 0]),
gl.STATIC_DRAW
);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
this.vivant = true;
} catch {
/* Un shader qui ne compile pas n'est pas une page qui casse : le
composant retombe sur son texte en clair, qui n'a jamais quitté
le DOM. Le contexte, lui, est rendu tout de suite : il a bien été
obtenu, il compte donc dans la poignée que le navigateur
accorde, et personne ne viendra le réclamer plus tard puisque
`detruire` ne saura plus qu'il a existé. */
gl.getExtension("WEBGL_lose_context")?.loseContext();
this.gl = null;
}
}
get disponible() {
return this.vivant;
}
private lier(gl: WebGLRenderingContext, vs: string, fs: string): WebGLProgram {
const compiler = (type: number, src: string) => {
const sh = gl.createShader(type);
if (!sh) throw new Error("shader");
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(sh) || "compilation");
}
return sh;
};
const p = gl.createProgram();
if (!p) throw new Error("programme");
gl.attachShader(p, compiler(gl.VERTEX_SHADER, vs));
gl.attachShader(p, compiler(gl.FRAGMENT_SHADER, fs));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(p) || "édition de liens");
}
return p;
}
texture(planche: HTMLCanvasElement) {
const gl = this.gl;
if (!gl) return;
if (this.tex) gl.deleteTexture(this.tex);
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, planche);
this.tex = t;
this.tw = planche.width;
this.th = planche.height;
}
taille(l: number, h: number, dpr: number) {
this.canvas.width = Math.max(1, Math.round(l * dpr));
this.canvas.height = Math.max(1, Math.round(h * dpr));
this.gl?.viewport(0, 0, this.canvas.width, this.canvas.height);
}
peindre(v: {
progres: number;
flou: number;
front: number;
bord: [number, number, number];
temps: number;
ratio: number;
graine: number;
parHG: [number, number];
parBD: [number, number];
curseur: [number, number];
survol: number;
retour: number;
}) {
const gl = this.gl;
if (!gl || !this.prog || !this.tex) return;
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(this.prog);
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
gl.enableVertexAttribArray(this.aPos);
gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(this.aUV);
gl.vertexAttribPointer(this.aUV, 2, gl.FLOAT, false, 16, 8);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tex);
gl.uniform1i(this.u.uTex, 0);
gl.uniform2f(this.u.uTexel, 1 / this.tw, 1 / this.th);
gl.uniform1f(this.u.uProgres, v.progres);
gl.uniform1f(this.u.uFlou, v.flou);
gl.uniform1f(this.u.uFront, v.front);
gl.uniform3f(this.u.uBord, v.bord[0], v.bord[1], v.bord[2]);
gl.uniform1f(this.u.uTemps, v.temps);
gl.uniform1f(this.u.uRatio, v.ratio);
gl.uniform1f(this.u.uGraine, v.graine);
gl.uniform2f(this.u.uParHG, v.parHG[0], v.parHG[1]);
gl.uniform2f(this.u.uParBD, v.parBD[0], v.parBD[1]);
gl.uniform2f(this.u.uCurseur, v.curseur[0], v.curseur[1]);
gl.uniform1f(this.u.uSurvol, v.survol);
gl.uniform1f(this.u.uRetour, v.retour);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
/* Un contexte WebGL n'est pas ramassé avec le nœud : le navigateur en
tolère une poignée, et une page qui monte et démonte la carte finit
par les perdre en silence. On le rend explicitement. */
detruire() {
const gl = this.gl;
if (!gl) return;
if (this.tex) gl.deleteTexture(this.tex);
gl.getExtension("WEBGL_lose_context")?.loseContext();
this.gl = null;
this.vivant = false;
}
}
/* Cendre · src/components/book/kit/Cendre.tsx */
/* La planche. Elle ne se positionne pas elle-même : elle prend la place
qu'on lui donne, et c'est tout ce qu'elle demande.
`isolation` contient le mélange du canevas : sans elle, un navigateur
qui compose la toile en dehors de la boîte peut la faire déborder sur
ce qui l'entoure. */
.cnd {
position: relative;
overflow: hidden;
isolation: isolate;
color: var(--fg);
/* Le texte est une image dans la texture, donc rien à sélectionner
dessus : autant que la copie DOM ne se sélectionne pas non plus. */
user-select: none;
-webkit-user-select: none;
}
/* La toile, ajoutée par le composant. Elle recouvre la boîte, ne reçoit
aucun pointeur (c'est la boîte qui écoute la main) et se laisse
redimensionner par le DPR sans que le CSS s'en mêle. */
.cnd__toile {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
/* ---------- Le texte en clair ----------
Il n'est pas un repli : il est le contrat. Sans JavaScript, sans
WebGL, ou avec un shader qui refuse de compiler, c'est LUI qu'on lit,
et le composant n'a rien à faire pour ça, il suffit qu'il ne fasse
rien. Les deux blocs sont dans les coins opposés parce que c'est là
qu'ils seront peints : la version en clair et la version en texture
ont la même mise en page, au corps près.
Les lignes sont des `<span>` en `display: block` plutôt que des
`<br>` : une ligne est un objet, pas une coupure. */
.cnd__hg,
.cnd__bd {
position: absolute;
margin: 0;
max-width: 56%;
font-size: clamp(0.82rem, 2.6vw, 1.06rem);
font-weight: 500;
line-height: 1.45;
letter-spacing: -0.006em;
text-wrap: balance;
}
.cnd__hg > span,
.cnd__bd > span {
display: block;
}
.cnd__hg {
top: 5.5%;
left: 5.5%;
}
.cnd__bd {
right: 5.5%;
bottom: 5.5%;
text-align: right;
}
/* Quand la toile prend la main, la copie en clair s'efface, mais reste
dans l'arbre d'accessibilité. `visibility: hidden` l'en retirerait, et
une carte dont tout le texte est peint dans un canevas ne dirait plus
rien à personne. Une opacité nulle, elle, laisse le texte annoncé. */
.cnd[data-gl="true"] .cnd__hg,
.cnd[data-gl="true"] .cnd__bd {
opacity: 0;
pointer-events: none;
}
/* Mouvement réduit : la toile reste et peint UNE image, résolue. Le
texte est là, net, à sa place, sans qu'une seule trame soit demandée
après la première. */
@media (prefers-reduced-motion: reduce) {
.cnd__toile {
animation: none;
}
}