Loupe

Un shader qui ne déplace pas des lettres, mais des coordonnées de lecture.

Réglages
page.tsx
<Loupe
  force={1}
  rides={1}
  frange={0.016}
  attirance={0.72}
/>

Fiche

Chapitre
Surface
Nom courant
Glass type
Publié le
9 septembre 2026
Fichiers
Loupe.tsx, loupe-gl.ts, Loupe.css
Dépendances
Aucune

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.

loupe-gl.tsL128–135
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);
}
Le grossissement en un point. Une puissance pour la courbure, un sinus pour les rides.

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.

loupe-gl.tsL29–34
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,
];
Quarante et un nombres. La montée de la première scène, image par image.

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à.
    loupe-gl.tsL214
    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>
  );
}

Ailleurs dans le Livre

Sedan, disponible pour de nouveaux projetsSurface · publié le 9 septembre 2026