Loupe
Un shader qui ne déplace pas des lettres, mais des coordonnées de lecture.
<Loupe
force={1}
rides={1}
frange={0.016}
attirance={0.72}
/>Fiche
Pourquoi
Tout tient dans une phrase : le shader ne déplace pas des lettres, il déplace des COORDONNÉES DE LECTURE. Le texte est peint une fois sur un canevas 2D, et pour chaque pixel de l'écran on demande : quel point de cette image faut-il lire ici ? Rapprocher ce point du centre grossit. L'y ramener par une courbe qui n'est pas droite fait une lentille.
float grossir(float r, float k) {
float rn = r / uRL;
float m = 1.0
+ uStr * k * pow(clamp(rn, 0.0, 1.6), uPow)
+ uRip * sin(6.28318 * r / (uOnde * uU) - uPhase)
* smoothstep(uRim, 1.0, rn);
return max(m, 0.35);
}
C'est ce renversement qui rend l'effet possible en une passe. Déformer une image en déplaçant ses pixels demande de savoir, pour chaque pixel de départ, où il arrive, et deux pixels peuvent arriver au même endroit, ou nulle part. En partant de l'arrivée, la question n'a jamais qu'une réponse : chaque pixel de l'écran lit un point, un seul, et l'image ne peut ni se déchirer ni se trouer.
La frange colorée du bord vaut à elle seule la planche. Le grossissement est calculé TROIS FOIS, une par canal, avec un coefficient très légèrement différent, et l'écart croît avec la distance au centre, si bien qu'au milieu du verre les trois canaux se superposent et qu'au bord ils se séparent. Mettez « Frange » à zéro dans la carte : le verre grossit toujours, et il cesse d'être du verre. Un verre réel ne dévie pas le rouge et le bleu du même angle ; deux centièmes d'écart suffisent à ce que l'œil le reconnaisse.
Les trois scènes ne sont pas des animations, ce sont des TABLES. Chaque nombre est une image relevée sur la référence, vingt-quatre par seconde, et le moteur interpole entre deux. C'est un choix, pas un pis-aller : une ligne qui se déroule ne monte pas régulièrement : elle hésite, accélère, et s'arrête net. Aucune courbe de Bézier ne décrit ça, et l'écrire à la main en quarante et un nombres coûte moins cher que de l'approcher en trois segments qui sonneront faux.
export const A_Y = [
0.864, 0.842, 0.826, 0.812, 0.801, 0.792, 0.784, 0.777, 0.733, 0.71, 0.689,
0.63, 0.611, 0.593, 0.579, 0.567, 0.556, 0.546, 0.537, 0.528, 0.52, 0.511,
0.503, 0.494, 0.486, 0.476, 0.464, 0.453, 0.439, 0.426, 0.409, 0.391, 0.371,
0.349, 0.323, 0.292, 0.256, 0.182, 0.133, 0.104, 0.069,
];
Le contour de la bille de verre, dans la deuxième scène, se joue sur un seul détail : son temps est QUANTIFIÉ à douze images par seconde. Trois harmoniques le font onduler, et si on les laisse évoluer en continu, sur soixante images, le contour ne tremble pas : il flotte, et on voit un cercle animé. À douze, il bout. C'est la différence entre une forme calculée et un trait tracé à la main, et elle tient dans un Math.floor.
L'unité de toute la scène est déduite du format, jamais des pixels. Les tables sont en parts de cette unité : la même animation tient dans une vignette de trois cents pixels et dans une pleine largeur, sans qu'une seule valeur change. C'est aussi ce qui permet à la carte de se redimensionner sans que rien ne saute.
Les couleurs, enfin, ne sont pas dans le composant. Le canevas source reste en noir sur blanc (le shader n'y lit qu'une couverture d'encre), et l'encre et le papier ne sont appliqués qu'à la dernière ligne du fragment. Poser la planche sur une carte claire ou sombre ne demande donc rien d'autre que d'échanger deux variables CSS ; la référence est noire sur blanc, celle-ci est claire sur sombre, et c'est le même shader.
Ce qui coince
- Un triangle, pas deux. Le quadrilatère plein écran s'écrit d'habitude en deux triangles. Un seul, plus grand que l'écran, fait le même travail : il a un sommet de moins, un appel de dessin identique, et surtout aucune diagonale au milieu de l'image. Là où deux triangles se rencontrent, l'interpolation des varyings peut laisser une couture d'un pixel : invisible sur un dégradé, visible sur une lentille qui amplifie tout ce qui la traverse.
getAttribLocationà chaque trame, soixante fois pour rien. Le code d'origine relève l'emplacement de l'attribut de position à l'intérieur de la boucle de dessin. C'est une requête au pilote graphique, et elle rend toujours le même nombre : celui-ci ne change qu'à l'édition de liens du programme. Soixante allers-retours par seconde pour une constante. Il est maintenant relevé une fois, au montage, comme les emplacements d'uniformes l'étaient déjà.private aPos = 0;- Le texte ne se recompose pas soixante fois par seconde. Recomposer du texte sur un canevas 2D et le téléverser en texture coûte quelques millisecondes, assez pour manger une trame. Le contenu n'est donc refait que si la chaîne, sa largeur voulue ou le format ont changé, et la clé qui en décide contient les trois. Sur un cycle complet des trois scènes, cela fait six recompositions, pas cent cinquante.
- Un plancher de grossissement, sinon la lentille retourne. Le facteur est borné à 0,35 par le bas. Sans ce plancher, un réglage un peu fort (ou une ride au mauvais endroit) le fait passer sous zéro, et l'image se retourne autour du point : le texte apparaît en miroir dans une flaque. Ce n'est pas un effet, c'est une division par un nombre qui a changé de signe. Poussez « Force » à deux cents pour cent dans la carte : le verre déforme énormément, et il ne se retourne jamais.
- La lentille se pose là où la main entre. Premier essai : la lentille glissait de son centre jusqu'au pointeur à l'arrivée de la souris. Une lentille qui traverse la carte pour venir vous chercher se remarque, et ce n'est pas ce qu'on veut faire remarquer. Elle se pose maintenant directement sous la main la première fois, et ne se met à suivre qu'ensuite. Le lissage n'est pas au repos : il l'est seulement une fois qu'on est déjà là.
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 } from "react";
import { A_TEXTE, Loupe as Verre, type Reglages } from "./loupe-gl";
/* ------------------------------------------------------------------
Loupe · de la typographie derrière un verre.
Le shader ne déplace pas des lettres : il déplace des coordonnées de
lecture. Le texte est peint une fois sur un canevas 2D, et pour chaque
pixel de l'écran on demande quel point de cette image lire ici. Voir
`loupe-gl.ts` ; ce fichier-ci ne s'occupe que du branchement : la
boucle, la main, les couleurs du thème et l'arrêt.
UNE SEULE BOUCLE, ET ELLE NE TOURNE PAS POUR RIEN. Hors écran, onglet
caché, mouvement réduit : elle s'arrête. Et elle ne redémarre pas au
début, l'horloge de la scène reprend là où elle s'était arrêtée, si
bien qu'un retour d'onglet ne relance pas une animation qu'on avait
déjà vue à moitié.
ET LA LIGNE RESTE DANS LE DOM. Sans WebGL, la carte ne serait qu'un
rectangle de papier : la première ligne des trois scènes est donc
écrite en clair, au milieu, et ne s'efface que lorsque la toile prend
la main. C'est la règle de la maison (l'animation est l'agrément, le
texte est le contrat), et elle s'applique même à un texte décoratif.
LES COULEURS VIENNENT DU THÈME, pas du composant. Le canevas source
reste en noir sur blanc (le shader n'y lit qu'une couverture d'encre),
et l'encre et le papier ne sont appliqués qu'à la dernière ligne du
fragment. Poser le composant sur une carte claire ou sombre ne demande
donc rien d'autre que deux variables CSS.
------------------------------------------------------------------- */
export type LoupeProps = {
/** Force de la lentille, en multiple du réglage de chaque scène. */
force?: number;
/** Amplitude des rides, en multiple du réglage de chaque scène. */
rides?: number;
/** Écart des trois canaux au bord du verre. Zéro : pas de frange. */
frange?: number;
/** Part du chemin que la lentille fait vers le pointeur. */
attirance?: number;
className?: string;
};
export default function LoupeCarte({
force = 1,
rides = 1,
frange = 0.016,
attirance = 0.72,
className,
}: LoupeProps) {
const boite = useRef<HTMLDivElement>(null);
const toile = useRef<HTMLCanvasElement>(null);
/* Les réglages voyagent par une référence : les faire glisser ne
remonte ni le contexte ni la texture. */
const reg = useRef<Reglages>({ force, rides, frange, attirance });
reg.current = { force, rides, frange, attirance };
useEffect(() => {
const el = boite.current;
const cv = toile.current;
if (!el || !cv) return;
const calme = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const style = getComputedStyle(el);
const verre = new Verre(cv);
if (!verre.ok) return;
el.dataset.gl = "true";
verre.couleurs(
style.getPropertyValue("--lou-encre").trim() || "#efeae9",
style.getPropertyValue("--lou-papier").trim() || "#0b0001"
);
verre.setPolice(style.fontFamily || "sans-serif");
verre.taille(el.clientWidth, el.clientHeight, dpr);
let raf = 0;
let tourne = false;
let avant = 0;
const trame = () => {
if (!tourne) return;
const t = performance.now();
const dt = Math.min(0.1, Math.max(1 / 240, (t - avant) / 1000));
avant = t;
verre.reglages = reg.current;
verre.trame(dt);
raf = requestAnimationFrame(trame);
};
const partir = () => {
if (tourne) return;
tourne = true;
avant = performance.now();
raf = requestAnimationFrame(trame);
};
const arreter = () => {
tourne = false;
if (raf) cancelAnimationFrame(raf);
raf = 0;
};
let vue = false;
let 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.2 }
);
io.observe(el);
const surVisibilite = () => { cache = document.hidden; accorder(); };
document.addEventListener("visibilitychange", surVisibilite);
let minuteur = 0;
const ro = new ResizeObserver(() => {
window.clearTimeout(minuteur);
minuteur = window.setTimeout(() => {
verre.taille(el.clientWidth, el.clientHeight, dpr);
if (calme || !tourne) {
verre.reglages = reg.current;
verre.fige();
}
}, 140);
});
ro.observe(el);
/* La main, et la souris seule : un appui laisserait la lentille
plantée là où le doigt a quitté l'écran, sans rien pour l'en faire
partir. */
const surLaMain = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
const b = cv.getBoundingClientRect();
if (!b.width || !b.height) return;
verre.pointeur({
x: (e.clientX - b.left) / b.width,
y: (e.clientY - b.top) / b.height,
});
};
const sortie = () => verre.pointeur(null);
cv.addEventListener("pointermove", surLaMain);
cv.addEventListener("pointerleave", sortie);
window.addEventListener("blur", sortie);
/* La police du site arrive après le premier rendu : composer avant
donne une planche en police système, et le texte change de visage
sous les yeux du lecteur. */
if (document.fonts?.ready) {
void document.fonts.ready.then(() => {
verre.setPolice(getComputedStyle(el).fontFamily || "sans-serif");
if (calme || !tourne) {
verre.reglages = reg.current;
verre.fige();
}
});
}
verre.reglages = reg.current;
verre.fige();
if (!calme) accorder();
return () => {
arreter();
io.disconnect();
ro.disconnect();
window.clearTimeout(minuteur);
document.removeEventListener("visibilitychange", surVisibilite);
cv.removeEventListener("pointermove", surLaMain);
cv.removeEventListener("pointerleave", sortie);
window.removeEventListener("blur", sortie);
verre.detruire();
delete el.dataset.gl;
};
}, []);
return (
<div
ref={boite}
className={className ? `lou ${className}` : "lou"}
role="img"
aria-label="De la typographie derrière un verre : une ligne monte et se déroule jusqu'à devenir lisible, une bille de verre au contour tremblant échange deux mots, puis des mots seuls traversent une onde grossissante. Le bord du verre sépare les couleurs, et la lentille suit le pointeur."
>
{/* Le repli, et il n'est pas construit après coup : la ligne est là
d'emblée, et la toile ne fait que passer devant. */}
<p className="lou__clair" aria-hidden="true">
{A_TEXTE}
</p>
<canvas ref={toile} className="lou__toile" />
</div>
);
}
/* ------------------------------------------------------------------
Loupe · la moitié basse : les trois scènes et le verre.
Ce que fait le shader tient en une phrase : il ne déplace pas des
lettres, il déplace des COORDONNÉES DE LECTURE. Le texte est peint une
fois sur un canevas 2D, et pour chaque pixel de l'écran on demande
« quel point de cette image faut-il lire ici ? ». Rapprocher ce point
du centre grossit ; l'y ramener par une courbe qui n'est pas droite
fait une lentille.
Le grossissement est calculé TROIS FOIS, une par canal, avec un
coefficient très légèrement différent. C'est toute la frange colorée
du bord : un verre réel ne dévie pas le rouge et le bleu du même
angle, et deux centièmes d'écart suffisent à ce que l'œil le
reconnaisse.
Les trois scènes ne sont pas des animations : ce sont des TABLES.
Chaque nombre est une image relevée sur la référence, et le moteur
interpole entre deux. Vingt-quatre valeurs par seconde décrivent un
mouvement qu'aucune courbe ne décrirait : une ligne qui se déroule ne
monte pas régulièrement, elle hésite, accélère, et s'arrête net.
------------------------------------------------------------------- */
/* ---------- Les trois scènes ---------- */
/** Une ligne qui monte et se déroule jusqu'à devenir lisible. */
export const A_TEXTE = "(voir) de plus près";
export const A_IMAGES = 41;
export const A_Y = [
0.864, 0.842, 0.826, 0.812, 0.801, 0.792, 0.784, 0.777, 0.733, 0.71, 0.689,
0.63, 0.611, 0.593, 0.579, 0.567, 0.556, 0.546, 0.537, 0.528, 0.52, 0.511,
0.503, 0.494, 0.486, 0.476, 0.464, 0.453, 0.439, 0.426, 0.409, 0.391, 0.371,
0.349, 0.323, 0.292, 0.256, 0.182, 0.133, 0.104, 0.069,
];
export const A_LARGEUR = 1.49;
/** Une bille de verre qui échange deux mots dans son contour bouillant. */
export const B_MOTS = ["Le verre", "déforme"];
export const B_IMAGES = 35;
export const B_ECHANGE = 15;
export const B_R = [
0.641, 0.664, 0.679, 0.69, 0.697, 0.702, 0.706, 0.707, 0.707, 0.706, 0.702,
0.699, 0.693, 0.687, 0.659, 0.567, 0.539, 0.518, 0.512, 0.506, 0.502, 0.5,
0.498, 0.49, 0.494, 0.494, 0.494, 0.494, 0.494, 0.493, 0.496, 0.491, 0.492,
0.488, 0.486,
];
export const B_LARGEUR = 0.56;
/** Des mots seuls qui traversent l'onde.
Trois mots de longueur voisine, et ce n'est pas un détail de style :
chaque mot est mis à l'échelle sur la MÊME largeur. Un mot de deux
lettres y devient un monogramme haut comme la carte, magnifié encore
par la lentille, spectaculaire et illisible. */
export const C_MOTS = ["Lire", "sous", "verre"] as const;
export const C_IMAGES = 52;
export const C_SEGMENTS: [number, number][] = [
[0, 17],
[18, 34],
[35, 51],
];
export const C_X = [
0.422, 0.21, 0.113, 0.068, 0.042, 0.026, 0.014, 0.007, 0.001, -0.002, -0.004,
-0.004, -0.006, -0.01, -0.016, -0.027, -0.043, -0.076, 0.416, 0.304, 0.223,
0.159, 0.107, 0.069, 0.019, 0.004, -0.004, -0.009, -0.011, -0.013, -0.023,
-0.039, -0.066, -0.108, -0.169, 0.45, 0.268, 0.156, 0.093, 0.056, 0.031,
0.017, 0.008, 0, -0.004, -0.007, -0.007, -0.009, -0.02, -0.031, -0.053,
-0.093,
];
export const C_LARGEUR = 0.76;
export const IPS = 24;
export const SCENE_SECONDES = 5;
/* Un verre par scène : rayon, force, courbure, rides, longueur d'onde,
et l'endroit où les rides commencent à compter. */
const VERRES = [
{ rl: 1.05, str: 2.4, pow: 3.0, rip: 0.35, onde: 0.34, rim: 0.25 },
{ rl: 0.49, str: 1.7, pow: 2.4, rip: 0.55, onde: 0.16, rim: 0.35 },
{ rl: 0.85, str: 0.9, pow: 2.2, rip: 0.28, onde: 0.42, rim: 0.1 },
];
/* Le contour de la bille : trois harmoniques qui le font onduler, et un
temps QUANTIFIÉ à douze images par seconde. Un contour lissé sur
soixante images ne tremble pas, il flotte ; à douze, il bout : c'est
la différence entre un cercle animé et un trait tracé à la main. */
const BOSSES: [number, number, number][] = [
[3, 0.01, 1.3],
[7, 0.007, -2.1],
[11, 0.005, 3.7],
];
const TRAIT = 0.008;
const MINCEUR = 0.35;
const BOUILLON_IPS = 12;
const DERIVE_RIDES = 1.1;
export const SUR_ECHANTILLON = 2;
const nb = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`);
const SOMMET = `
attribute vec2 aPos;
varying vec2 vUv;
void main() {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
const FRAGMENT = `
precision mediump float;
varying vec2 vUv;
uniform sampler2D uTex;
uniform vec2 uRes;
uniform float uU;
uniform vec2 uOff;
uniform vec2 uCen;
uniform float uRL;
uniform float uStr, uPow, uRip, uOnde, uRim;
uniform float uPhase;
uniform float uBille;
uniform float uAnneau;
uniform float uDisp;
uniform vec3 uEncre, uPapier;
/* Le grossissement en un point, à distance r du centre. Un facteur
plancher de 0.35 : sans lui, un réglage un peu fort peut retourner
l'image, et une lentille qui inverse n'est plus une lentille. */
float grossir(float r, float k) {
float rn = r / uRL;
float m = 1.0
+ uStr * k * pow(clamp(rn, 0.0, 1.6), uPow)
+ uRip * sin(6.28318 * r / (uOnde * uU) - uPhase)
* smoothstep(uRim, 1.0, rn);
return max(m, 0.35);
}
float encreEn(vec2 q, vec2 c, float m) {
vec2 src = c + q / m - uOff;
return 1.0 - texture2D(uTex, src / uRes).r;
}
void main() {
vec2 px = vUv * uRes;
vec2 c = uCen;
vec2 q = px - c;
float r = length(q);
float rn = r / uRL;
/* Trois lectures, trois grossissements. L'écart croît avec la distance
au centre : au milieu du verre les trois canaux se superposent, au
bord ils se séparent. */
float d = uDisp * clamp(rn, 0.0, 1.0);
vec3 encre = vec3(
encreEn(q, c, grossir(r, 1.0 + d)),
encreEn(q, c, grossir(r, 1.0)),
encreEn(q, c, grossir(r, 1.0 - d))
);
if (uBille > 0.5) {
float th = atan(q.y, q.x);
float bosse =
${BOSSES.map(([k, a, s]) => `${nb(a)} * sin(${k}.0 * th + ${nb(s)} * uAnneau)`).join(" + ")};
float Rw = uRL * (1.0 + bosse);
encre *= 1.0 - smoothstep(Rw - 1.5, Rw + 0.5, r);
float ep = ${nb(TRAIT)} * uU * (${nb(MINCEUR)} + ${nb(1 - MINCEUR)} * (0.5 + 0.5 * sin(2.0 * th + 0.9 * uAnneau)));
encre = max(encre, vec3(1.0 - smoothstep(ep * 0.45, ep, abs(r - Rw))));
}
gl_FragColor = vec4(clamp(mix(uPapier, uEncre, clamp(encre, 0.0, 1.0)), 0.0, 1.0), 1.0);
}`;
type Scene = 0 | 1 | 2;
const 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("#", "").trim();
if (h.length === 3) h = h.split("").map((x) => x + x).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];
};
/** Interpolation linéaire dans une table d'images clés. */
function dansTable(tab: number[], f: number, lo = 0, hi = tab.length - 1) {
const x = Math.min(Math.max(f, lo), hi);
const i = Math.floor(x);
return tab[i] + (tab[Math.min(i + 1, hi)] - tab[i]) * (x - i);
}
export type Reglages = {
force: number;
rides: number;
frange: number;
attirance: number;
};
export class Loupe {
ok = false;
private canvas: HTMLCanvasElement;
private gl: WebGLRenderingContext | null = null;
private prog: WebGLProgram | null = null;
private quad: WebGLBuffer | null = null;
private tex: WebGLTexture | null = null;
private u: Record<string, WebGLUniformLocation | null> = {};
/* Relevé UNE fois. `getAttribLocation` est une requête au pilote : la
rappeler à chaque trame, c'est soixante allers-retours par seconde
pour un nombre qui ne change jamais. */
private aPos = 0;
private src: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D | null;
private police = "sans-serif";
private cle = "";
private encre: [number, number, number] = [1, 1, 1];
private papier: [number, number, number] = [0, 0, 0];
private scene: Scene = 0;
private t0 = 0;
private arret = 0;
private W = 0;
private H = 0;
private ptr = { x: 0.5, y: 0.5 };
private lentille = { x: 0.5, y: 0.5 };
private survol = 0;
private survolCible = 0;
reglages: Reglages = { force: 1, rides: 1, frange: 0.016, attirance: 0.72 };
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.src = document.createElement("canvas");
this.ctx = this.src.getContext("2d");
const gl = canvas.getContext("webgl", {
alpha: false,
antialias: false,
premultipliedAlpha: false,
powerPreference: "low-power",
});
if (!gl || !this.ctx) return;
const compiler = (type: number, s: string) => {
const sh = gl.createShader(type);
if (!sh) return null;
gl.shaderSource(sh, s);
gl.compileShader(sh);
return gl.getShaderParameter(sh, gl.COMPILE_STATUS) ? sh : null;
};
const v = compiler(gl.VERTEX_SHADER, SOMMET);
const f = compiler(gl.FRAGMENT_SHADER, FRAGMENT);
const p = gl.createProgram();
if (!v || !f || !p) {
gl.getExtension("WEBGL_lose_context")?.loseContext();
return;
}
gl.attachShader(p, v);
gl.attachShader(p, f);
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
gl.getExtension("WEBGL_lose_context")?.loseContext();
return;
}
this.gl = gl;
this.prog = p;
this.aPos = gl.getAttribLocation(p, "aPos");
this.quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
/* Un seul triangle, pas deux. Il couvre l'écran, il a un sommet de
moins, et il n'a pas de diagonale au milieu, donc pas de couture
possible entre deux moitiés. */
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
for (const n of [
"uTex", "uRes", "uU", "uOff", "uRL", "uStr", "uPow", "uRip", "uOnde",
"uRim", "uPhase", "uBille", "uAnneau", "uDisp", "uEncre", "uPapier", "uCen",
]) this.u[n] = gl.getUniformLocation(p, n);
this.tex = gl.createTexture();
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
gl.bindTexture(gl.TEXTURE_2D, this.tex);
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.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
this.ok = true;
this.t0 = performance.now();
}
couleurs(encre: string, papier: string) {
this.encre = versRGB(encre);
this.papier = versRGB(papier);
}
setPolice(f: string) {
this.police = f;
this.cle = "";
}
taille(l: number, h: number, dpr: number) {
if (!this.gl || l < 2 || h < 2) return;
this.W = Math.round(l * dpr);
this.H = Math.round(h * dpr);
this.canvas.width = this.W;
this.canvas.height = this.H;
this.src.width = this.W * SUR_ECHANTILLON;
this.src.height = this.H * SUR_ECHANTILLON;
this.cle = "";
}
pointeur(p: { x: number; y: number } | null) {
if (!p) {
this.survolCible = 0;
return;
}
/* La lentille se pose là où la main entre, au lieu de traverser la
carte depuis son centre : une lentille qui glisse pour venir vous
chercher se remarque, et ce n'est pas ce qu'on veut remarquer. */
if (this.survolCible === 0 && this.survol < 0.01) {
this.lentille.x = p.x;
this.lentille.y = p.y;
}
this.ptr = p;
this.survolCible = 1;
}
/** Avance l'horloge et peint. `dt` en secondes. */
trame(dt: number) {
const tau = this.survolCible > this.survol ? 0.22 : 0.5;
this.survol += (this.survolCible - this.survol) * (1 - Math.exp(-dt / tau));
const k = 1 - Math.exp(-dt * 6);
this.lentille.x += (this.ptr.x - this.lentille.x) * k;
this.lentille.y += (this.ptr.y - this.lentille.y) * k;
const ecoule = performance.now() - this.t0;
const images = [A_IMAGES, B_IMAGES, C_IMAGES][this.scene];
const boucle = (images / IPS) * 1000;
if (ecoule >= SCENE_SECONDES * 1000 && ecoule % boucle < 40) {
this.scene = ((this.scene + 1) % 3) as Scene;
this.t0 = performance.now();
this.peindre(0);
} else {
this.peindre(ecoule % boucle);
}
}
/** Une image fixe, au milieu de la première scène. */
fige() {
this.scene = 0;
this.peindre((A_IMAGES / 2 / IPS) * 1000);
}
allerA(s: Scene) {
this.scene = s;
this.t0 = performance.now();
this.arret = 0;
}
detruire() {
const gl = this.gl;
if (!gl) return;
if (this.tex) gl.deleteTexture(this.tex);
if (this.quad) gl.deleteBuffer(this.quad);
gl.getExtension("WEBGL_lose_context")?.loseContext();
this.gl = null;
this.ok = false;
}
/* Le texte n'est recomposé QUE si la chaîne, sa largeur voulue ou le
format ont changé. Sur les trois scènes, cela fait six recompositions
par cycle complet, pas soixante par seconde. */
private contenu(texte: string, largeur: number, U: number) {
const cle = `${texte}|${largeur.toFixed(3)}|${U.toFixed(1)}|${this.police}`;
if (cle === this.cle || !this.ctx || !this.gl) return;
this.cle = cle;
const S = SUR_ECHANTILLON;
const W = this.W * S;
const H = this.H * S;
const ctx = this.ctx;
/* Le canevas source reste en NOIR SUR BLANC, quelle que soit la
couleur de la carte : le shader n'y lit qu'une couverture d'encre,
et la vraie couleur est appliquée à la toute fin. */
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "#000";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
/* Le corps se déduit d'une seule mesure : on mesure à cent pixels,
et on met à l'échelle. Une recherche par essais successifs
coûterait vingt mesures pour le même résultat. */
ctx.font = `500 100px ${this.police}`;
const w100 = ctx.measureText(texte).width || 1;
ctx.font = `500 ${(largeur * U * S * 100) / w100}px ${this.police}`;
ctx.fillText(texte, W / 2, H / 2);
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.src);
}
private peindre(ms: number) {
const gl = this.gl;
if (!gl || !this.prog || !this.W) return;
const { W, H } = this;
/* L'unité de toute la scène. Les tables sont en parts de cette
unité, jamais en pixels : la même animation tient dans une
vignette et dans une pleine largeur. */
const U = Math.min(H, W / (16 / 9));
const img = (ms / 1000) * IPS;
const t = ms / 1000;
const verre = VERRES[this.scene];
const r = this.reglages;
let offX = 0;
let offY = 0;
let rl: number;
let bille = 0;
if (this.scene === 0) {
this.contenu(A_TEXTE, A_LARGEUR, U);
offY = -(dansTable(A_Y, img) - 0.5) * U;
rl = verre.rl * U;
} else if (this.scene === 1) {
this.contenu(B_MOTS[img >= B_ECHANGE ? 1 : 0], B_LARGEUR, U);
rl = dansTable(B_R, img) * U * (verre.rl / 0.49);
bille = 1;
} else {
const i = C_SEGMENTS.findIndex(([a, b]) => img >= a && img <= b + 1);
const s = i < 0 ? 2 : i;
this.contenu(C_MOTS[s], C_LARGEUR, U);
offX = dansTable(C_X, img, C_SEGMENTS[s][0], C_SEGMENTS[s][1]) * U;
rl = verre.rl * U;
}
gl.viewport(0, 0, W, H);
gl.useProgram(this.prog);
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
gl.enableVertexAttribArray(this.aPos);
gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, 0, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tex);
const tire = this.survol * r.attirance;
gl.uniform1i(this.u.uTex, 0);
gl.uniform2f(this.u.uRes, W, H);
gl.uniform1f(this.u.uU, U);
gl.uniform2f(this.u.uOff, offX, offY);
gl.uniform2f(
this.u.uCen,
W * (0.5 + (this.lentille.x - 0.5) * tire),
H * (0.5 - (this.lentille.y - 0.5) * tire)
);
gl.uniform1f(this.u.uRL, rl);
gl.uniform1f(this.u.uStr, verre.str * r.force);
gl.uniform1f(this.u.uPow, verre.pow);
gl.uniform1f(this.u.uRip, verre.rip * r.rides);
gl.uniform1f(this.u.uOnde, verre.onde);
gl.uniform1f(this.u.uRim, verre.rim);
gl.uniform1f(this.u.uPhase, t * DERIVE_RIDES);
gl.uniform1f(this.u.uBille, bille);
gl.uniform1f(this.u.uAnneau, Math.floor(t * BOUILLON_IPS) / BOUILLON_IPS);
gl.uniform1f(this.u.uDisp, r.frange);
gl.uniform3f(this.u.uEncre, this.encre[0], this.encre[1], this.encre[2]);
gl.uniform3f(this.u.uPapier, this.papier[0], this.papier[1], this.papier[2]);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
}
/* Loupe · src/components/book/kit/Loupe.tsx */
/* La boîte. Deux variables, et ce sont les seules : le shader ne connaît
pas les couleurs du site, il ne connaît qu'une couverture d'encre. La
même planche tient sur une carte sombre et sur une carte claire :
il n'y a que ces deux lignes à échanger. */
.lou {
position: relative;
overflow: hidden;
isolation: isolate;
--lou-encre: var(--fg, #efeae9);
--lou-papier: var(--leaf-face, #0b0001);
background: var(--lou-papier);
}
/* La toile porte le pointeur : c'est elle qu'on survole, et ses
coordonnées sont celles que la lentille suit. Elle est posée PAR-DESSUS
la ligne en clair, qui reste dessous et disparaît d'elle-même. */
.lou__toile {
position: relative;
z-index: 1;
display: block;
width: 100%;
height: 100%;
}
/* La ligne en clair. Elle occupe la carte entière et s'efface dès que la
toile prend la main, et non `visibility: hidden`, qui la retirerait de
l'arbre d'accessibilité, mais elle y est déjà cachée par le
`role="img"` de la boîte : c'est la description qui parle pour elle. */
.lou__clair {
position: absolute;
inset: 0;
z-index: 0;
display: grid;
place-content: center;
margin: 0;
padding-inline: 8%;
color: var(--lou-encre);
font-size: clamp(1.1rem, 5.2vw, 2rem);
font-weight: 500;
letter-spacing: -0.02em;
text-align: center;
}
.lou[data-gl="true"] .lou__clair {
opacity: 0;
}