Séparation de la boucle de rendu pour éviter les baisses de fréquence d'images lors de l'utilisation conjointe de React et WebGL
Intégrer un canvas WebGL dans un tableau de bord Next.js s'avère plus complexe que prévu. À force d'ajouter des graphiques sophistiqués ou des éléments lourds, l'affichage finit inévitablement par saccader. Si le moindre mouvement de souris entraîne une baisse de performance et que le tableau de bord tant préparé se met à ramer, il y a de quoi frustrer n'importe quel développeur.
Ce problème provient généralement d'un conflit entre le mode de gestion d'état de React et la méthode de rendu de WebGL. Si l'on ne sépare pas la logique de travail de ces deux moteurs, l'écran continuera de saccader, peu importe la qualité des bibliothèques utilisées.
Le problème de l'inclusion globale de bibliothèques graphiques dans le bundle principal
Des bibliothèques telles que Three.js ou Deck.gl intègrent en interne un moteur de calcul mathématique et un compilateur de shaders, ce qui les rend très lourdes. Importer bêtement ces paquets en haut de page entraîne l'inclusion de code inutile directement dans le bundle principal.
Dans un environnement Next.js, il convient de combiner l'importation directe de sous-chemins avec le chargement dynamique pour s'alléger de ce poids. Cela permet également d'éviter l'apparition d'erreurs window is not defined lors du rendu côté serveur.
`javascript
// next.config.mjs
import withBundleAnalyzer from '@next/bundle-analyzer';
const bundleAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
optimizePackageImports: [
'@radix-ui/react-icons',
'lucide-react',
'three',
'deck.gl'
],
},
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
};
}
return config;
},
};
export default bundleAnalyzer(nextConfig);
`
Cette configuration évite de charger l'ensemble des ressources graphiques d'un coup lors du chargement initial de la page et permet de les récupérer au moment opportun. Une vérification avec l'analyseur de bundle permet de constater que le code lié au canvas, initialement regroupé dans le fichier JS principal, a été extrait sous forme d'un bloc indépendant.
Le composant conteneur client désactive le SSR lors de l'importation.
`typescript
// components/dashboard/CanvasDashboardWrapper.tsx
'use client';
import dynamic from 'next/dynamic';
import { Skeleton } from '@/components/ui/skeleton';
const DynamicCanvasRenderer = dynamic(
() => import('./CanvasRenderer').then((mod) => mod.CanvasRenderer),
{
ssr: false,
loading: () => (
),
}
);
export function CanvasDashboardWrapper() {
return (
);
}
`
Cette structure apporte immédiatement trois bénéfices :
- L'enregistrement des paquets dans
next.config.mjs incite à omettre les modules superflus.
- L'utilisation de
{ ssr: false } dans next/dynamic bloque les erreurs d'exécution lors du rendu serveur.
- L'affichage d'un composant Skeleton à hauteur fixe prévient les sauts de mise en page avant l'arrivée des données.
En utilisant Tailwind CSS, il faut aussi prêter attention aux événements de pointeur. Appliquez pointer-events-none sur l'ensemble du conteneur du canvas pour laisser passer les événements vers le DOM situé en arrière-plan, puis rétablissez pointer-events-auto uniquement sur la couche d'objets nécessitant une manipulation réelle.
Sortir useState de la boucle du canvas
React redessine le DOM virtuel lorsque son état (useState, useContext) change. À l'inverse, WebGL actualise l'affichage 60 fois par seconde via requestAnimationFrame.
Que se passe-t-il si l'on appelle une fonction de modification d'état React au sein d'une boucle cadencée à 60 images par seconde ? À chaque frame, l'opération de réconciliation de React s'exécute et finit par figer le thread principal. C'est la véritable cause des saccades à l'écran.
Les valeurs sujettes à variation doivent être conservées dans useRef, tandis que le travail de dessin doit être confié à une boucle isolée utilisant exclusivement requestAnimationFrame.
`typescript
// hooks/useAnimationFrame.ts
import { useEffect, useRef } from 'react';
type AnimationCallback = (deltaTime: number, timestamp: number) => void;
export const useAnimationFrame = (callback: AnimationCallback, isPaused: boolean = false) => {
const requestRef = useRef<number | null>(null);
const previousTimeRef = useRef<number | null>(null);
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
if (isPaused) {
if (requestRef.current !== null) {
cancelAnimationFrame(requestRef.current);
}
return;
}
const animate = (timestamp: number) => {
if (previousTimeRef.current !== null) {
const deltaTime = timestamp - previousTimeRef.current;
callbackRef.current(deltaTime, timestamp);
}
previousTimeRef.current = timestamp;
requestRef.current = requestAnimationFrame(animate);
};
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current !== null) {
cancelAnimationFrame(requestRef.current);
}
};
}, [isPaused]);
};
`
Il en va de même pour les événements fréquents tels que les déplacements de souris ou les glissements. Actualiser l'état de React dans le gestionnaire d'événements provoque une accumulation de ramasse-miettes sur un court laps de temps, ce qui génère des ralentissements.
Modifions l'architecture :
- Les données changeant fréquemment, telles que la position de la souris ou les valeurs de rotation, sont écrites dans le
current d'un useRef.
- Le hook
useAnimationFrame permet de créer une boucle requestAnimationFrame dédiée.
- La fonction de rendu se contente de lire ces valeurs de type Ref pour les dessiner sur le canvas. Comme l'état de React n'est pas modifié, aucun nouveau rendu n'est déclenché.
Gestion d'un repli (fallback) pour les navigateurs non pris en charge
À l'instar de la spécification HTML in Canvas actuellement débattue au sein du WICG, certaines approches tentent de dessiner directement le DOM sur une bitmap de canvas à l'aide de méthodes comme drawElementImage.
Bien que cela fonctionne parfaitement avec les derniers indicateurs (flags) de Chrome, s'y fier aveuglément en production risque de corrompre l'affichage sur des navigateurs obsolètes ou certains appareils mobiles.
Il est préférable de vérifier la prise en charge des fonctionnalités en amont et de recourir à une limite d'erreur pour parer aux situations exceptionnelles.
`typescript
// components/canvas/CanvasErrorBoundary.tsx
'use client';
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { detectCanvasCapabilities } from '@/utils/canvasFeatureDetection';
interface Props {
children: ReactNode;
fallbackUI: ReactNode;
}
interface State {
hasError: boolean;
isSupported: boolean;
}
export class CanvasErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
isSupported: true,
};
public componentDidMount() {
const capabilities = detectCanvasCapabilities();
if (!capabilities.webgl2) {
this.setState({ isSupported: false });
}
}
public static getDerivedStateFromError(_: Error): Partial {
return { hasError: true };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Canvas UI Rendering Engine Crashed:', error, errorInfo);
}
public render() {
if (this.state.hasError || !this.state.isSupported) {
return (
);
}
return (
<div className="relative w-full h-full min-h-[400px] overflow-hidden">
{this.props.children}
</div>
);
}
}
`
Voici comment mettre en place ce filet de sécurité :
- Vérifier la prise en charge de WebGL2 à l'aide de la logique de détection du contexte du canvas.
- Intercepter les erreurs d'exécution survenant pendant le rendu grâce à une limite d'erreur (Error Boundary) de type classe React.
- Fixer une hauteur minimale telle que
min-h-[400px] sur le conteneur pour empêcher toute rupture des proportions lors de la transition vers l'interface de repli (graphiques HTML/SVG classiques, etc.).
Le récupérateur de mémoire JavaScript ne nettoie pas la mémoire GPU
Même si le moteur V8 nettoie efficacement les objets situés dans le tas JavaScript, il ignore les données stockées dans la mémoire GPU telles que les tampons (buffers), les textures ou les programmes de shaders.
Si l'on ne supprime pas explicitement ces ressources lors de la navigation entre les pages ou du changement d'onglets de tableau de bord, la mémoire GPU finit par saturer, provoquant une erreur Context lost et le plantage de l'onglet du navigateur.
`typescript
// hooks/useWebGLCleanUp.ts
import { useEffect, useRef } from 'react';
export const useWebGLCleanUp = () => {
const glRef = useRef<WebGL2RenderingContext | null>(null);
const resourcesRef = useRef<{
buffers: WebGLBuffer[];
textures: WebGLTexture[];
programs: WebGLProgram[];
}>({
buffers: [],
textures: [],
programs: [],
});
const registerBuffer = (buffer: WebGLBuffer) => resourcesRef.current.buffers.push(buffer);
const registerTexture = (texture: WebGLTexture) => resourcesRef.current.textures.push(texture);
const registerProgram = (program: WebGLProgram) => resourcesRef.current.programs.push(program);
useEffect(() => {
return () => {
const gl = glRef.current;
if (!gl) return;
resourcesRef.current.buffers.forEach((buffer) => gl.deleteBuffer(buffer));
resourcesRef.current.textures.forEach((texture) => gl.deleteTexture(texture));
resourcesRef.current.programs.forEach((program) => {
const shaders = gl.getAttachedShaders(program);
if (shaders) {
shaders.forEach((shader) => {
gl.detachShader(program, shader);
gl.deleteShader(shader);
});
}
gl.deleteProgram(program);
});
const loseContextExt = gl.getExtension('WEBGL_lose_context');
if (loseContextExt) {
loseContextExt.loseContext();
}
resourcesRef.current = { buffers: [], textures: [], programs: [] };
glRef.current = null;
};
}, []);
return { glRef, registerBuffer, registerTexture, registerProgram };
};
`
Suivez ces étapes pour vérifier directement dans les outils de développement de Chrome si la mémoire est correctement libérée :
- Ouvrez le composant du tableau de bord et prenez un premier instantané du tas (S1) dans l'onglet Memory.
- Répétez le montage et le démontage du composant (changement d'onglet, etc.) plus de 10 fois.
- Déclenchez une collecte manuelle des déchets (icône de corbeille), puis prenez un second instantané du tas (S2).
- Comparez S2 à S1 et vérifiez que le nombre d'instances de
Detached HTMLCanvasElement ou WebGLBuffer redescend bien à 0.
Lors de l'utilisation conjointe de React et WebGL, l'essentiel réside dans le fait de préserver les domaines de compétences respectifs de chaque bibliothèque sans empiéter sur l'autre. Diviser le bundle, éliminer l'état React de la boucle de rendu et vider soigneusement la mémoire GPU lorsque le composant disparaît. Le respect de ces trois principes suffit à concevoir un tableau de bord fluide et exempt de pertes de frames.