Separación del bucle de renderizado para evitar la caída de fotogramas al usar React y WebGL juntos
TuBrief 편집팀
2026년 8월 7일
0
Computing/Software원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
Integrar un lienzo WebGL en un panel de Next.js es más complicado de lo que parece. Al añadir gráficos llamativos o elementos pesados, la pantalla suele entrecortarse. Con solo mover el ratón los fotogramas caen, y si el panel que tanto te costó crear se traba, es frustrante para cualquier desarrollador.
Este problema suele ocurrir porque el enfoque de gestión de estado de React choca con el enfoque de renderizado de WebGL. Si no separas la forma en que trabajan ambos motores, por muy buena que sea la biblioteca que uses, la pantalla seguirá entrecortándose.
Bibliotecas como Three.js o Deck.gl tienen un gran tamaño porque incluyen internamente un motor de operaciones matemáticas y un compilador de sombreadores (shaders). Si importas el paquete desde arriba sin pensarlo, hasta el código que ni siquiera usas se mezcla en el paquete principal.
Si estás en un entorno de Next.js, debes combinar las importaciones directas de subrutas y la configuración de carga dinámica para aligerar esta carga. También puedes evitar que explote el error window is not defined durante el renderizado del lado del servidor.
`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);
`
Tras aplicar esta configuración, al cargar la página inicial no recibes todos los recursos gráficos de golpe, sino que los obtienes por partes según los necesitas. Si lo compruebas con el analizador de paquetes, verás que el código relacionado con el lienzo, que antes estaba agrupado en el archivo JS principal, se ha separado en un bloque independiente.
En el contenedor del componente de cliente, desactiva el SSR al importarlo.
`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 (
`
Al configurarlo de esta manera, obtienes tres efectos inmediatos:
next.config.mjs.{ ssr: false } a next/dynamic.Si usas Tailwind CSS, también debes verificar los eventos de puntero. Aplica pointer-events-none a todo el contenedor del lienzo para que los eventos pasen al DOM posterior, y vuelve a dar pointer-events-auto únicamente a la capa de objetos que requiera manipulación real.
React vuelve a dibujar el DOM virtual cuando cambia el estado (useState, useContext). En cambio, WebGL redibuja la pantalla 60 veces por segundo mediante requestAnimationFrame.
¿Qué pasa si llamas a la función de cambio de estado de React dentro de un bucle que se ejecuta 60 veces por segundo? En cada fotograma se ejecuta el cálculo de reconciliación de React y el hilo principal se bloquea. Esta es la verdadera razón por la que la pantalla se traba.
Los valores cambiantes deben guardarse en useRef, y la tarea de dibujar la pantalla debe dejarse en manos de un bucle exclusivo de 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]);
};
`
Lo mismo ocurre con los eventos frecuentes como el movimiento del ratón o el arrastre. Si actualizas el estado de React desde el manejador de eventos, la recolección de basura se acumula en poco tiempo y se produce retraso.
Cambiemos la estructura:
current de useRef.requestAnimationFrame propio con el gancho useAnimationFrame.Al igual que la especificación HTML in Canvas que se debate en el WICG, existen intentos de dibujar el DOM directamente en el mapa de bits del lienzo con métodos como drawElementImage. Funciona bien en entornos con flags recientes de Chrome, pero si confías ciegamente en esto en entornos de producción, la pantalla fallará por completo en navegadores antiguos o en algunos dispositivos móviles.
Primero lee si la función es compatible y prepárate para excepciones agrupándola en un límite de errores.
`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>
);
}
}
`
Los pasos para establecer una red de seguridad son:
min-h-[400px] en la caja contenedora para evitar que la relación de aspecto de la pantalla se rompa al cambiar a una interfaz de respaldo (como gráficos HTML/SVG estándar).El motor V8 limpia muy bien los objetos que se encuentran en el montón (heap) de JavaScript. Sin embargo, no sabe nada sobre los datos colocados en la memoria de la GPU, como los búferes de WebGL, las texturas o los programas de sombreadores.
Si no borras explícitamente estos recursos al navegar entre páginas o cambiar pestañas del panel, la memoria de la GPU se llenará y la pestaña del navegador terminará cerrándose con un error de Context lost.
`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 };
};
`
Esta es la secuencia para comprobar directamente con las Herramientas de desarrollador de Chrome si la memoria se libera correctamente:
Detached HTMLCanvasElement o WebGLBuffer descienda a 0 y no queden restos.Al usar React y WebGL juntos, la clave es mantenerlos estables sin dañar las áreas en las que cada biblioteca destaca. Dividir el paquete, eliminar el estado de React del bucle de renderizado y vaciar con seguridad la memoria de la GPU cuando el componente desaparece. Siguiendo estos tres puntos podrás crear un panel que funcione de manera impecable y sin caídas de fotogramas.