WebGLSurface
BêtaPrimitiveUn contexte WebGL2 pour toute la page : chaque élément dessine dans son propre canvas, sans atteindre la limite de contextes du navigateur ni recompiler ses shaders.
- Frameworks
- ReactVueVanilla
- Technologies
- WebGL
- Mis à jour le
WebGLSurface donne à chaque élément une surface : son propre canvas, dans lequel il dessine en WebGL2 sans créer de contexte. Un seul contexte, caché, sert tout le document. Un navigateur ne garde qu'environ seize contextes actifs, puis il tue le plus ancien ; chaque contexte coûte aussi sa création et la compilation de ses shaders. Les éléments WebGL d'O-UI (Paper, Hologram, ParticleText, Shapeshift) s'appuient sur WebGLSurface ; utilisez-la pour vos propres shaders, qui partageront le même contexte. Pour une vraie scène 3D, gardez three.js et son propre canvas.
Installation
Installez l'élément et ses dépendances en une commande :
pnpm dlx shadcn@latest add https://o-ui.hd987.dev/r/webgl-surface.jsonWebGLSurface n'a aucune dépendance. Les éléments d'O-UI qui l'utilisent l'installent d'eux-mêmes, dans components/o-ui/webgl-surface/. Les exemples ci-dessous utilisent aussi FrameLoop pour dessiner à chaque frame.
Utilisation
Un fond animé en shader, dans une carte. La surface prépare le programme dans setup, et un membre de FrameLoop dessine à chaque frame.
"use client";
import { useEffect, useRef, useState } from "react";
import { useFrameLoop } from "@/components/o-ui/frame-loop/frame-loop";
import { useWebGLSurface } from "@/components/o-ui/webgl-surface/webgl-surface";
import type { WebGLSurfaceProgram } from "@/components/o-ui/webgl-surface/webgl-surface-core";
const VERTEX = `#version 300 es
void main() {
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;
const FRAGMENT = `#version 300 es
precision highp float;
uniform vec2 uSize;
uniform float uTime;
out vec4 color;
void main() {
vec2 uv = gl_FragCoord.xy / uSize;
color = vec4(0.5 + 0.5 * cos(uTime + uv.xyx * 3.0 + vec3(0, 2, 4)), 1.0);
}`;
export function ShaderCard() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const state = useRef({ width: 0, height: 0, time: 0, reduce: false });
const [visible, setVisible] = useState(false);
const surface = useWebGLSurface<WebGLSurfaceProgram>(canvasRef, {
// The program is shared by every surface that uses the same sources.
setup: (_gl, created) => created.program(VERTEX, FRAGMENT),
});
const wake = useFrameLoop(
{
continuous: true,
write(frame) {
const s = state.current;
const still = frame.paused || s.reduce;
if (!still) s.time += frame.dt;
const drawn = surface.draw(s.width, s.height, (gl, target, program) => {
// The program compiles in the background: nothing is drawn meanwhile.
if (!program.ready()) return false;
gl.useProgram(program.handle);
gl.uniform2f(program.uniform("uSize"), target.width, target.height);
gl.uniform1f(program.uniform("uTime"), s.time);
gl.drawArrays(gl.TRIANGLES, 0, 3);
return true;
});
// Without WebGL2, the card keeps its CSS background.
if (!drawn && surface.state === "unavailable") return false;
return !drawn || !still;
},
},
// Off screen, the card stops drawing.
{ active: visible },
);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const s = state.current;
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const motion = () => {
s.reduce = query.matches;
wake();
};
motion();
query.addEventListener("change", motion);
const resize = new ResizeObserver(([entry]) => {
s.width = entry?.contentRect.width ?? 0;
s.height = entry?.contentRect.height ?? 0;
wake();
});
resize.observe(canvas);
const view = new IntersectionObserver(([entry]) => {
setVisible(entry?.isIntersecting ?? false);
});
view.observe(canvas);
return () => {
query.removeEventListener("change", motion);
resize.disconnect();
view.disconnect();
};
}, [wake]);
return (
<div className="relative h-48 overflow-hidden rounded-xl border bg-muted">
<canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 size-full" />
</div>
);
}En React, le hook est dans webgl-surface.tsx et le reste dans webgl-surface-core.ts. En Vue, tout est exporté par @/components/o-ui/webgl-surface.
Quelques règles font la différence :
- Dessinez dans le tampon de la surface. Le rappel de
drawreçoit untargetdéjà lié, à sa taille et à son origine :gl_FragCoordetgl.cleargardent le sens qu'ils auraient dans un canvas dédié. Après une passe dans un framebuffer à vous, revenez-y avectarget.bind(), jamaisgl.bindFramebuffer(…, null). Utiliseztarget.widthettarget.height, pasgl.drawingBufferWidth. - N'attendez rien de l'état GL. Avant chaque dessin, la surface remet à zéro le mélange, la profondeur, le programme, le VAO, les tampons liés, l'unité de texture active et
pixelStorei: posez ce dont vous avez besoin dans le rappel, à chaque fois, y compris les textures liées. - Créez les ressources dans
setup. Il est rappelé après une perte du contexte : textures, tampons et programmes reviennent d'eux-mêmes. Libérez-les dansrelease. - Vérifiez
program.ready(). La compilation se fait en arrière-plan, sans bloquer : tant qu'elle dure, renvoyezfalse, et le canvas garde son image. - Dessinez depuis la phase
writede FrameLoop. L'image arrive dans le canvas à la fin de la tâche, en une seule copie pour toutes les surfaces. Pour lire le canvas tout de suite (toBlob,getImageData), appelez d'abordflushWebGLSurfaces(). - Arrêtez de dessiner au repos. Un canvas garde sa dernière image sans rien coûter : ne redessinez que ce qui change, et laissez une animation continue suivre la pause commune (
frame.paused).
Exemples
Cartes au survol
Vingt-quatre cartes produit, chacune avec un reflet de film mince qui suit le pointeur, ou s'allume au focus du clavier. Plus de seize surfaces vivent sur la page, mais seule la carte active dessine : les autres gardent leur image, et la page n'utilise qu'un contexte.
Composer des éléments
Hologram, ParticleText et Paper réunis : ils dessinent tous avec le même contexte, et chaque programme n'est compilé qu'une fois. La ligne du bas lit getWebGLSurfaceStats() deux fois par seconde.
Perte de contexte
Le bouton appelle simulateWebGLContextLoss(). Chaque panneau reçoit onLost et garde sa dernière image ; à la restauration, onRestored le relance, et setup recrée ses ressources.
Intégration
Next.js et Nuxt
Le hook commence par "use client" et ne crée la surface qu'au montage ; le composable n'agit qu'après onMounted. Au rendu serveur, draw renvoie false et rien ne touche au DOM.
Avec three.js ou une autre bibliothèque WebGL
Une scène three.js garde son propre contexte : WebGLSurface ne le partage pas. Les deux cohabitent ; le contexte d'O-UI compte pour un seul dans la limite du navigateur.
Du travail GPU sans dessin
surface.use((gl, resources) => …) donne le contexte et les ressources, l'état remis à zéro, sans rien copier : une étape de simulation, une relecture de tampon. ParticleText fait ainsi avancer ses particules par transform feedback.
API
Cœur (webgl-surface-core.ts)
| Fonction | Rôle |
|---|---|
createWebGLSurface(canvas, options) | Crée la surface d'un canvas. Renvoie une WebGLSurface. |
flushWebGLSurfaces() | Copie tout de suite les dessins en attente vers leurs canvas, au lieu d'attendre la fin de la tâche. |
getWebGLSurfaceStats() | Le contexte partagé (0 ou 1), les surfaces, les programmes, la taille de l'atlas et l'état. |
simulateWebGLContextLoss({ restoreAfter }) | Perd le contexte exprès, puis le restaure après restoreAfter secondes (0 à 1,5). Avec false, un nouveau contexte le remplace après 2 s. |
| Méthode de la surface | Rôle |
|---|---|
draw(width, height, render) | Dessine une image de width × height pixels CSS : prépare le tampon, puis appelle render(gl, target, resources). Renvoie true si l'image attend sa copie. |
program(vertex, fragment, options) | Un programme partagé, compilé en arrière-plan. options.varyings pour le transform feedback. |
use(callback) | Du travail GPU sans dessin, avec l'état remis à zéro. Renvoie ce que renvoie callback, ou undefined sans contexte. |
releaseResources() | Appelle release maintenant ; le prochain dessin rappelle setup. |
update(options) | Change les options. Une valeur absente ou invalide garde la valeur courante. |
destroy() | Libère les ressources et les programmes de la surface. Le canvas garde son image. Sans effet si on le rappelle. |
state | "idle" (pas encore de contexte), "ready", "lost" ou "unavailable". |
| Prop | Type | Défaut | Description |
|---|---|---|---|
depth | boolean | false | Donne un tampon de profondeur aux dessins de la surface. |
antialias | boolean | false | Anticrénelage multiéchantillon (4 échantillons au plus), résolu à la copie. |
pixelRatio | number | 2 | La densité maximale du tampon, de 0,25 à 4. Elle reste bornée par la densité de l'écran, maxPixels et les limites du GPU. |
maxPixels | number | 4000000 | Le nombre de pixels au plus par dessin ; au-delà, la densité baisse. |
setup | ((gl: WebGL2RenderingContext, surface: WebGLSurface<R>) => R) | — | Crée les ressources GPU de l'élément, transmises à chaque render. Appelé au premier dessin, après une restauration et après releaseResources(). |
release | ((gl: WebGL2RenderingContext, resources: R) => void) | — | Libère ces ressources, à destroy() et à releaseResources(). Jamais après une perte : elles n'existent plus. |
onLost | (() => void) | — | Le contexte partagé est perdu : affichez votre repli. Le canvas garde sa dernière image. |
onRestored | (() => void) | — | Un contexte est de nouveau disponible : redessinez. |
Hooks et composables
| Fonction | Rôle |
|---|---|
useWebGLSurface(canvas, options) | Crée la surface au montage, la suit si le canvas change, la détruit au démontage. Renvoie { draw, surface, state } ; draw renvoie false avant le montage. |
- React :
canvasest une ref ou l'élément lui-même. Les rappels sont lus au dernier rendu ; changerdepth,antialias,pixelRatiooumaxPixelsmet la surface à jour sans la recréer. - Vue : appelez
useWebGLSurfacedanssetup.canvaset les options de densité acceptent une valeur, unerefou un getter. Un composant mis en cache par<KeepAlive>détruit sa surface quand il est désactivé.
La cible d'un dessin (WebGLSurfaceTarget)
| Prop | Type | Défaut | Description |
|---|---|---|---|
width | number | — | La largeur du dessin, en pixels du tampon. À utiliser à la place de gl.drawingBufferWidth. |
height | number | — | La hauteur du dessin, en pixels du tampon. |
cssWidth | number | — | La largeur demandée, en pixels CSS. |
cssHeight | number | — | La hauteur demandée, en pixels CSS. |
pixelRatio | number | — | Les pixels du tampon par pixel CSS réellement obtenus. |
framebuffer | WebGLFramebuffer | — | Le framebuffer où dessiner. Ne dessinez jamais dans le framebuffer par défaut (null). |
bind | () => void | — | Relie framebuffer et son viewport, après une passe dans un framebuffer à vous. |
Un programme (WebGLSurfaceProgram)
| Prop | Type | Défaut | Description |
|---|---|---|---|
ready | () => boolean | — | true une fois le programme lié. Sonde la compilation sans jamais bloquer ; false tant qu'elle dure, sans contexte ou après un échec. |
failed | boolean | — | La liaison a échoué ; log dit pourquoi. |
log | string | — | Le journal de compilation et de liaison, en cas d'échec. |
handle | WebGLProgram | null | — | Le WebGLProgram une fois prêt, sinon null. |
uniform | (name: string) => WebGLUniformLocation | null | — | L'emplacement d'un uniform, mis en cache et recalculé après une restauration. |
attribute | (name: string) => number | — | L'emplacement d'un attribut, mis en cache de même. |
Accessibilité
- Ce que fait WebGLSurface : rien de visible par lui-même. Il ne touche ni au focus ni à la sémantique.
- Ce que vous faites :
- le canvas est décoratif :
aria-hidden="true", et le contenu qui compte reste dans le HTML ; - respectez
prefers-reduced-motion: une image fixe plutôt qu'un mouvement ; - déclarez votre animation
continuous: truedans FrameLoop et figez-la quandframe.pausedvauttrue; - prévoyez un repli sans WebGL2 (
state === "unavailable") : un fond CSS, une image.
- le canvas est décoratif :
- Contraste élevé : en
forced-colors, préférez votre repli CSS au shader, comme le font les éléments d'O-UI.
Performances
- Un seul contexte par document, quel que soit le nombre de surfaces, et une seule compilation par programme.
- Par image : la copie d'un tampon privé dans un atlas pour chaque surface, puis une seule copie hors de WebGL, puis une copie 2D par surface. Avec un shader simple, 24 surfaces animées tiennent 120 Hz dans Chrome et Firefox. Dans les éléments d'O-UI, comptez environ 0,05 ms par surface et par image sur le fil principal, pour la remise à zéro de l'état et les copies.
- Au repos, rien ne tourne. Chaque tampon privé est libéré après 2 s sans usage, même si d'autres surfaces dessinent ; l'atlas revient à 1 × 1 pixel après 10 s sans dessin, et se réduit à ce que les dessins utilisent quand il est quatre fois plus grand. Le contexte est libéré 2 s après la dernière surface.
- La création du contexte (environ 5 ms) se fait dans une tâche à part, jamais dans la frame qui le demande.
- Poids : environ 5 ko compressés, sans dépendance, partagés par tous les éléments WebGL de la page.
Notes et limites
- Un contexte par document. Chaque iframe a le sien. Deux copies du fichier
webgl-surface-core.tsdans un projet feraient deux contextes : gardez un seul dossiercomponents/o-ui/webgl-surface/. - Ne déplacez pas un dossier d'O-UI seul. Les éléments voisins importent
../webgl-surface/webgl-surface-core: déplacezwebgl-surface/avec eux. - Une perte touche toutes les surfaces à la fois. Si le navigateur ne restaure pas le contexte, un nouveau le remplace après 2 s ; après trois remplacements en une minute, WebGL2 est tenu pour indisponible.
- Le canvas reçoit un contexte 2D. Un canvas qui a déjà un contexte WebGL ne peut pas servir de surface (
state === "unavailable"). - Navigateurs : WebGL2 et
blitFramebuffer, dans tous les navigateurs actuels. La compilation parallèle (KHR_parallel_shader_compile) est utilisée quand elle existe.