Saltar al contenido
Mateo Sonzogni
Todos los artículos

Sincronización de Shaders en Three.js con GSAP ScrollTrigger y Lenis en React

Cómo estructurar un pipeline de renderizado en WebGL sin sobrecargar el hilo principal de React, unificando la inercia de Lenis con la actualización de uniformes GLSL mediante GSAP.

Cuando encaré la landing inmersiva de Ynara Web (ynara-web), el objetivo no era meter 3D de forma decorativa, sino representar visualmente la idea de un nodo de luz que muta y reacciona a medida que la persona navega la página. En la teoría suena simple: tenés una escena de WebGL renderizada con Three.js, un script de desplazamiento suave (lenis) para normalizar la inercia del scroll y gsap con ScrollTrigger para orquestar la progresión de la animación. En la práctica, combinar el árbol de componentes de React con renderizado en GPU y eventos de desplazamiento continua suele derivar en problemas severos de rendimiento si no definís claramente qué capa es dueña del bucle de animación.

El error común cuando se trabaja con este stack es dejar que cada biblioteca controle su propio ciclo de ejecución mediante llamados independientes a requestAnimationFrame. Si React re-renderiza componentes por cambios de estado mientras Lenis calcula la inercia del scroll y Three.js intenta dibujar la escena en el canvas, el hilo principal de JavaScript se satura. La consecuencia inmediata es la pérdida de fluidez visual y tirones bruscos en la animación. Para resolverlo en Ynara Web, diseñé una arquitectura donde existe un único punto de verdad para el reloj de animación y las modificaciones de la GPU se realizan directamente sobre la memoria de las shaders, puenteando por completo el sistema de reconciliación de React.

El conflicto entre el DOM, el Virtual DOM y la GPU

React es excelente declarando interfaces basadas en estado, pero es pésimo gestionando variaciones que ocurren 60 veces por segundo. Si intentás guardar el progreso del scroll en un estado de React (useState) para pasárselo como propiedad a un componente de Three.js, forzás a React a ejecutar su algoritmo de reconciliación en cada frame. Esto destruye el rendimiento.

Por otro lado, los navegadores manejan el scroll del DOM en un hilo separado o lo sincronizan con el refresco de pantalla mediante eventos implícitos. Lenis intercepta el evento de rueda (wheel) y simula un desplazamiento suavizado recalculando la posición virtual. Si Three.js intenta leer window.scrollY directamente dentro de su propio bucle requestAnimationFrame, existe un descalce entre la posición real de la barra de navegación, la posición interpolada por Lenis y la fracción de segundo en la que se dibuja el cuadro WebGL.

La solución técnica requiere resolver dos problemas estructurales:

  1. Centralizar la ejecución: un solo bucle de animación debe actualizar el desplazamiento suave, calcular las posiciones relativas del scroll y renderizar la escena 3D.
  2. Mutación directa de punteros: los valores calculados por GSAP deben escribirse de forma imperativa en las propiedades uniforms del material GLSL, sin pasar por el estado de React.

Sincronización del ciclo de renderizado mediante el ticker de GSAP

Para evitar que Lenis y Three.js peleen por el hilo principal, tomé la decisión de pausar el bucle interno de Lenis y atar su actualización al ticker de GSAP. GSAP posee un reloj altamente optimizado que maneja internamente las llamadas a requestAnimationFrame y ajusta los deltas de tiempo de forma consistente.

En la inicialización del proyecto, deshabilito la auto-actualización de Lenis e instalo un listener en el ticker de GSAP para que actualice el estado del scroll en cada ciclo de reloj:

import gsap from 'gsap';
import ScrollTrigger from 'gsap/ScrollTrigger';
import Lenis from 'lenis';

gsap.registerPlugin(ScrollTrigger);

export function setupSmoothScroll() {
  const lenis = new Lenis({
    duration: 1.2,
    easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
    smoothWheel: true,
  });

  // Decirle a ScrollTrigger que utilice los eventos de Lenis
  lenis.on('scroll', ScrollTrigger.update);

  // Desactivar la actualización automática de Lenis
  // y acoplarla directamente al ticker central de GSAP
  gsap.ticker.add((time) => {
    lenis.raf(time * 1000);
  });

  // Desactivar la corrección de lag de GSAP para mantener sincronía perfecta
  gsap.ticker.lagSmoothing(0);

  return lenis;
}

Con esta configuración, en cada cuadro de animación GSAP ejecuta el cálculo de interpolación de Lenis y de inmediato notifica a ScrollTrigger. No hay desfase temporal ni doble invocación a requestAnimationFrame.

Mutación de uniformes GLSL sin re-renders

En Ynara Web, la entidad central es una esfera de luz deformada por ruido Perlin tridimensional calculado directamente en un fragment shader de WebGL. Para que la forma mute a medida que la persona baja por la página, necesito pasar dos variables fundamentales desde el mundo de JavaScript hacia el lenguaje de shaders (GLSL):

  • uTime: tiempo transcurrido en segundos para la animación orgánica base.
  • uProgress: un número decimal entre 0.0 y 1.0 que representa qué tan profundo avanzó el usuario en la estructura de la landing.

En lugar de instanciar componentes con wrappers de React que oculten el objeto Canvas, utilicé useRef para mantener referencias persistentes a la escena, la cámara, el renderizador y el material personalizado (ShaderMaterial).

import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import gsap from 'gsap';
import ScrollTrigger from 'gsap/ScrollTrigger';

// Vertex Shader simple que pasa las coordenadas UV y normales
const vertexShader = `
  varying vec2 vUv;
  varying vec3 vNormal;
  uniform float uProgress;
  uniform float uTime;
  
  void main() {
    vUv = uv;
    vNormal = normal;
    vec3 pos = position;
    
    // Deformación de vértices según uProgress
    float distortion = sin(pos.y * 5.0 + uTime) * uProgress * 0.2;
    pos += normal * distortion;
    
    gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
  }
`;

// Fragment Shader para el cálculo de iluminación y color
const fragmentShader = `
  varying vec2 vUv;
  varying vec3 vNormal;
  uniform float uProgress;
  
  void main() {
    vec3 colorA = vec3(0.05, 0.05, 0.1);
    vec3 colorB = vec3(0.2, 0.5, 1.0);
    vec3 finalColor = mix(colorA, colorB, vNormal.z + uProgress);
    gl_FragColor = vec4(finalColor, 1.0);
  }
`;

Cuando el componente de React se monta, construyo el canvas imperativamente en un useEffect. Las referencias a las variables uniformes del shader quedan almacenadas en memoria. GSAP modifica directamente las propiedades de esos objetos mediante punteros de JavaScript, puenteando por completo el ciclo de vida de React.

export function WebGLCanvas() {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!containerRef.current) return;

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    containerRef.current.appendChild(renderer.domElement);

    const uniforms = {
      uTime: { value: 0 },
      uProgress: { value: 0 },
    };

    const material = new THREE.ShaderMaterial({
      vertexShader,
      fragmentShader,
      uniforms,
    });

    const geometry = new THREE.IcosahedronGeometry(2, 64);
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    camera.position.z = 5;

    // Conectar GSAP ScrollTrigger directamente a los uniformes del shader
    const trigger = ScrollTrigger.create({
      trigger: containerRef.current,
      start: 'top top',
      end: 'bottom bottom',
      scrub: 0.5,
      onUpdate: (self) => {
        // Asignación directa a la memoria del shader
        uniforms.uProgress.value = self.progress;
      },
    });

    // Bucle de renderizado atado al ticker central
    const updateRender = (time: number) => {
      uniforms.uTime.value = time;
      renderer.render(scene, camera);
    };

    gsap.ticker.add(updateRender);

    return () => {
      gsap.ticker.remove(updateRender);
      trigger.kill();
      geometry.dispose();
      material.dispose();
      renderer.dispose();
    };
  }, []);

  return <div ref={containerRef} className="fixed inset-0 pointer-events-none" />;
}

Limpieza rigurosa de recursos WebGL y ciclo de vida en Next.js

Un aspecto crítico al trabajar con WebGL dentro de frameworks de renderizado en cliente como Next.js o React es la recolección de basura. Los navegadores no liberan automáticamente los buffers de geometría, texturas y contextos de renderizado de la GPU cuando un componente de React se desmonta del DOM. Si el usuario navega entre rutas y el componente de Three.js se destruye sin un tratamiento explícito, la memoria retenida en la placa de video crece hasta colapsar la pestaña.

En la función de limpieza (cleanup) del useEffect, implementé la liberación manual de cada recurso computacional:

  1. Eliminación del listener en el ticker de GSAP: Rompe la referencia de actualización para evitar invocaciones sobre un contexto inexistente.
  2. Invocación de .dispose() sobre geometrías y materiales: Informa a la API de WebGL que debe liberar las estructuras de datos reservadas en la VRAM de la tarjeta gráfica.
  3. Eliminación explícita del canvas del árbol del DOM y destrucción de los disparadores de ScrollTrigger mediante trigger.kill().

Sin este procedimiento explícito, cada recarga rápida en modo desarrollo (Hot Module Replacement) duplica el consumo de memoria en la GPU.

Lecciones aprendidas y trade-offs de arquitectura

Asumir el control imperativo de Three.js y WebGL dentro de un proyecto estructurado en React exige renunciar a la comodidad de ciertas abstracciones, pero la ganancia en control técnico y estabilidad de rendimiento paga el costo con creces. Las conclusiones principales que me dejó este desarrollo son:

El estado del UI y el pipeline visual de alta frecuencia deben correr por carriles separados. Mezclar React state con loops de requestAnimationFrame es una receta directa para arruinar la experiencia del usuario.

  • Unificación de bucles: El rendimiento no depende únicamente de cuán livianos sean tus shaders, sino de asegurar que exista un único director de orquesta para los eventos de tiempo. Usar el ticker de GSAP para alimentar el ciclo de cálculo de Lenis y la actualización del canvas elimina conflictos de frames descartados.
  • Mutaciones fuera del reconciliador: Tratar los parámetros de los shaders como propiedades mutables de alto nivel mediante referencias directas evita que React se entere de los cambios que ocurren en cada frame, reservando el motor del framework para lo que realmente hace bien: manejar la estructura y el árbol de componentes del sitio.
  • Adaptación al hardware: La interpolación mediante la propiedad scrub de GSAP combinada con el suavizado de Lenis permite absorber las variaciones en los dispositivos del usuario sin perder sincronía entre la posición física del contenido en pantalla y la deformación geométrica del nodo de luz en el canvas.