Landing WebGL generativo con React Three Fiber: cuando el sitio del producto es el producto#
La mayoría de los landings de apps se ven iguales: una captura dentro de un marco de teléfono, tres tarjetas de funciones, un botón "Descargar en App Store". Funciona, pero no se queda en la memoria. Al construir meteohealth.pro — el sitio de marketing de MeteoHealth, una app de iOS que rastrea la sensibilidad meteorológica — quise probar otra hipótesis: ¿y si el propio sitio se convirtiera en una demostración del producto, en vez de una descripción de él?
La idea es simple de enunciar y difícil de ejecutar: la sección hero contiene un "campo de presión" generativo — un patrón ondulado construido en React Three Fiber que reacciona en tiempo real a los datos meteorológicos reales de la ciudad seleccionada. Cuando crece la caída de presión, el patrón se espesa y se agita. Cuando el clima está tranquilo, el campo respira de forma pareja. No hay ruido por ruido: cada deformación del shader responde a un número real que viene del servidor.
Este artículo recorre cómo está construido: la arquitectura de React Three Fiber, un desglose del shader en sí, y el sistema de degradación que tiene que funcionar igual de bien en un iPhone de gama alta y en un Android económico con prefers-reduced-motion activado.
Cuando el sitio del producto es el producto#
Es tentador tratar una escena WebGL en un landing como "solo un fondo bonito". Ese es el error que suele matar experimentos así: la animación decorativa compite con el texto por la atención, arrastra el LCP hacia abajo y no tiene relación con lo que la página realmente vende.
La alternativa es hacer que la visualización sea prueba literal de lo que hace el producto. MeteoHealth analiza la relación entre las caídas de presión atmosférica y cómo se siente la gente. Así que la escena hero no es un patrón abstracto: es una representación directa de esa misma caída de presión: pressureDelta24h, el dato del servidor meteorológico, se convierte en el único "nervio" que gobierna todo el campo. No es una ilustración de una función; es un corte funcional de la misma lógica que corre la app, solo que visto en formas en vez de números en una tabla.
De ahí sale una regla arquitectónica: la escena no debería tener variables decorativas. Si un parámetro del shader no corresponde a un dato real, no debería existir. Esa restricción disciplina el diseño mucho más que cualquier brief creativo.
Anatomía de un campo generativo: R3F, Three.js y datos en vez de ruido#
React Three Fiber no es una alternativa a Three.js, sino una capa declarativa sobre él: la escena se describe como un árbol JSX, mientras que el loop de render, la liberación de recursos y la sincronización con el estado de React quedan a cargo del reconciliador de R3F. Para gráficos generativos data-driven, eso da lo que más importa: una frontera de componentes entre la "capa de datos" (estado de React, fetch, mapear el clima a parámetros del shader) y la "capa de render" (useFrame, mutaciones directas de uniforms).
La escena de meteohealth.pro está construida con dos capas superpuestas:
- Isolíneas de presión — un plano a pantalla completa con un fragment shader que dibuja líneas onduladas concéntricas, similares a un mapa topográfico. Cada tercera línea es de acento (verde); el resto tiene un tono "tinta" con alfa bajo.
- Partículas en GPU a lo largo de esas mismas isolíneas — estelas finas rotadas 90° respecto al gradiente del campo (imitando el viento geostrófico, como en la meteorología real), simuladas mediante un FBO ping-pong enteramente en la GPU, sin un solo loop de JS por partícula en la CPU.
La decisión arquitectónica clave es alimentar ambos sistemas desde un único valor escalar de "agitación del campo", en lugar de desde un tiempo arbitrario. A continuación, un esquema simplificado pero fiel de cómo se ensambla esto en React Three Fiber.
// components/field/PressureField.tsx
'use client'
import { Suspense, lazy, useEffect, useState } from 'react'
import { useReducedMotion } from '@/lib/hooks/use-reduced-motion'
const FieldScene = lazy(() => import('./FieldScene'))
type Tier = 'A' | 'B' | 'C'
function detectTier(prefersReducedMotion: boolean): Tier {
if (prefersReducedMotion) return 'C'
if (!('WebGLRenderingContext' in window)) return 'C'
const memory = (navigator as any).deviceMemory ?? 4
const isTouchHeavy = navigator.maxTouchPoints > 4
if (memory <= 2 || isTouchHeavy) return 'B'
return 'A'
}
export function PressureField({ posterSrc }: { posterSrc: string }) {
const prefersReducedMotion = useReducedMotion()
const [tier, setTier] = useState<Tier | null>(null)
useEffect(() => {
setTier(detectTier(prefersReducedMotion))
}, [prefersReducedMotion])
if (tier === null || tier === 'C') {
return <img src={posterSrc} alt="" aria-hidden className="field-poster" />
}
return (
<Suspense fallback={<img src={posterSrc} alt="" aria-hidden className="field-poster" />}>
<FieldScene tier={tier} />
</Suspense>
)
}Fíjate en que el canvas no aparece hasta que se decide un tier. El póster no es un fallback "por si acaso" — es un primer estado de pantalla legítimo y completo. Es el póster, no el canvas WebGL, el responsable del LCP.
Código: de Canvas al shader#
A continuación, el componente de material que conecta los datos meteorológicos con el shader a través de shaderMaterial de @react-three/drei. La fórmula que traduce una caída de presión en "agitación del campo" es deliberadamente no lineal: una caída de 7 hPa produce una respuesta notable pero no brusca, y la curva se satura después para que el campo nunca se dispare hacia el caos visual en valores extremos.
// components/field/FieldMaterial.tsx
import { shaderMaterial } from '@react-three/drei'
import { extend, useFrame } from '@react-three/fiber'
import { useRef } from 'react'
import vertexShader from './field.vert.glsl'
import fragmentShader from './field.frag.glsl'
export const FieldMaterial = shaderMaterial(
{
uTime: 0,
uAgitation: 0, // 0..~0.97, crece con una caída de presión en 24h
uIsoDensity: 14,
uInk: [0.06, 0.07, 0.07],
uGreen: [0.12, 0.48, 0.35],
},
vertexShader,
fragmentShader
)
extend({ FieldMaterial })
export function agitationFromPressureDelta(deltaHpa24h: number): number {
// Una caída de presión mueve el campo; una subida o estabilidad lo calma
const drop = Math.max(0, -deltaHpa24h)
return 1 - Math.exp(-drop / 7)
}
export function FieldMesh({ pressureDelta24h }: { pressureDelta24h: number }) {
const materialRef = useRef<any>(null)
const targetAgitation = agitationFromPressureDelta(pressureDelta24h)
useFrame((state, delta) => {
if (!materialRef.current) return
materialRef.current.uTime = state.clock.elapsedTime
// Lerp suave hacia el valor objetivo — sin saltos al refrescar datos
materialRef.current.uAgitation +=
(targetAgitation - materialRef.current.uAgitation) * Math.min(1, delta * 2)
})
return (
<mesh scale={[2, 2, 1]}>
<planeGeometry args={[1, 1]} />
{/* @ts-expect-error – extended material via shaderMaterial */}
<fieldMaterial ref={materialRef} transparent />
</mesh>
)
}Y aquí está el fragment shader en sí — una versión simplificada de la lógica que efectivamente dibuja las isolíneas:
// field.frag.glsl
uniform float uTime;
uniform float uAgitation; // 0 (calma) .. ~0.97 (caída de presión fuerte)
uniform float uIsoDensity; // densidad base de isolíneas
uniform vec3 uInk;
uniform vec3 uGreen;
varying vec2 vUv;
float snoise(vec2 v); // ruido simplex 2D, implementación omitida
void main() {
vec2 uv = vUv * 2.0 - 1.0;
// El campo "respira" en dos octavas de ruido; la segunda crece
// con la agitación y apenas se nota con clima tranquilo
float breathing =
snoise(uv * 1.6 + uTime * 0.075) * 0.5 +
snoise(uv * 3.7 - uTime * 0.028) * (0.2 + uAgitation * 0.3);
float density = uIsoDensity * (0.82 + uAgitation * 0.55);
float field = length(uv) * density + breathing * (0.055 + uAgitation * 0.05);
// Isolíneas: distancia a la "línea" más cercana en el módulo del campo
float line = abs(fract(field) - 0.5) * 2.0;
float iso = 1.0 - smoothstep(0.0, fwidth(field) * 1.5, line);
// Cada tercera línea es de acento, el resto tono "tinta"
bool isAccent = mod(floor(field), 3.0) < 1.0;
vec3 color = isAccent ? uGreen : uInk;
float alpha = iso * (isAccent ? 0.55 : 0.24);
gl_FragColor = vec4(color, alpha);
}Fíjate en el fwidth(field) usado para el antialiasing de las líneas — es lo que convierte al shader de un "mosaico dentado" en líneas finas y limpias en cualquier resolución y devicePixelRatio, sin un paso extra de postprocesado.
Canvas 2D, SVG o WebGL: qué elegir#
Antes de escribir un solo shader, vale la pena preguntarse honestamente si hace falta WebGL. Para la mayoría de los landings "animados", basta con Canvas 2D o incluso SVG con animación CSS. WebGL se justifica solo cuando se cumplen tres condiciones a la vez: se necesitan muchos primitivos en movimiento (cientos o miles de partículas), se necesita suavidad subpíxel en requestAnimationFrame, y el gráfico debe reaccionar a datos que cambian continuamente sin reconstruir el DOM.
| Criterio | Canvas 2D | SVG | WebGL (R3F / Three.js) |
|---|---|---|---|
| Cantidad de objetos animados | Cientos, luego cae el FPS | Decenas — el DOM es costoso | Miles de partículas en GPU |
| Reacción a datos en vivo | Requiere re-dibujo manual | Simple (atributos/variables CSS) | Simple (uniforms del shader) |
| Accesibilidad / SEO | Contenido invisible para lectores de pantalla | Nativamente accesible, indexable | Requiere duplicados HTML del texto |
| Curva de aprendizaje | Baja | Muy baja | Alta (shaders, buffers) |
| Degradación en equipos débiles | Buena | Excelente | Necesita un tier de fallback explícito |
| Caso de uso típico | Gráficos, partículas simples | Iconos, diagramas, esquemas dash-draw | Escenas generativas, simulaciones de campo |
Para meteohealth.pro, elegir WebGL fue un compromiso deliberado: la escena es el único elemento "caro" de todo el sitio, y el presupuesto de JS en las páginas de contenido (blog, funciones, legal) se mantiene alrededor de 100 KB precisamente porque el chunk de three solo se carga en el landing, con import dinámico, y solo después del idle.
Degradación sin concesiones: tiers, pósteres y prefers-reduced-motion#
El error más común en los landings generativos es probarlos solo en el MacBook Pro del desarrollador. Los visitantes reales llegan desde Android económicos de tres años, en modo de ahorro de energía, con prefers-reduced-motion activado. Un sistema que solo se ve bien en condiciones ideales no está listo para producción.
En meteohealth.pro, la degradación está construida como tres tiers explícitos, no como un único flag de "apagar animación":
- Tier A — la escena completa: ambas octavas de ruido, isolíneas densas, el set completo de partículas,
devicePixelRatiohasta 1.75. - Tier B — escena simplificada: menos partículas dibujadas (
drawRange), una sola octava de ruido,devicePixelRatiolimitado a 1.25. - Tier C — un póster estático en AVIF capturado de la escena real. También sirve de base para la imagen OG — no es un placeholder de relleno, sino un fotograma honesto del producto real.
La elección del tier no es única: además de la heurística inicial (deviceMemory, maxTouchPoints), la app mide el tiempo real de fotograma durante los primeros 20 frames y baja de tier si el render supera el presupuesto de forma constante. Lo mismo ocurre en vivo durante la sesión — una ventana móvil de 120 frames, y si el tiempo medio de frame supera ~33 ms, la escena baja un tier en lugar de seguir exprimiendo la GPU hasta recalentar el teléfono.
prefers-reduced-motion se maneja aparte y con prioridad — no es "otra señal de rendimiento más", es una decisión explícita del usuario. Con el flag activado, Three.js, GSAP y Lenis directamente no se cargan: no solo se desactiva la animación, sino que todo ese JS relacionado ni siquiera se solicita a la red. El canvas aparece sobre el póster con un crossfade suave de 0.7 segundos tras el primer frame listo — el propio cambio no debe leerse como un salto brusco de interfaz.
El sistema de diseño como contrato: GSAP, Lenis y Liquid Signal#
La escena WebGL es solo un elemento, aunque sea el más llamativo. Vive dentro de su propio sistema de diseño, "Liquid Signal": tipografía editorial serena junto con primitivos HUD — lecturas monoespaciadas como PRESSURE_Δ24H, TIME_CODE, RISK junto a mediciones genuinamente reales, nunca números decorativos. La idea es que el sitio se comporte como un instrumento calibrado y no como un banner publicitario: cada número en pantalla es real, incluido el que gobierna el campo generativo.
GSAP y Lenis solo se cargan en el landing y hacen exactamente dos cosas: una secuencia de carga corta en la primera visita (las líneas se dibujan, las etiquetas HUD se "escriben"), y revelaciones de scroll para el resto del contenido (translateY más fade, unos 0.6s, power2.out). En las páginas de contenido — blog, funciones, textos legales — no hay animación o solo transiciones CSS: el presupuesto de JS de esas páginas no debe depender de lo que pase en el landing.
El resultado resultó sorprendentemente estricto para un proyecto "generativo": cuanto más audaz es el elemento de marca, más disciplinado debe ser todo lo que lo rodea. Una sola escena carga con toda la audacia artística del sitio; el resto se mantiene silencioso, predecible y sin parpadeos.
Conclusión#
Una escena WebGL generativa se gana su lugar en un landing solo cuando no es decoración sino una representación directa de los datos del producto — y solo si la ingeniería de degradación está tan pensada como el shader mismo. React Three Fiber ofrece una frontera declarativa cómoda entre datos y render, pero la disciplina está en los detalles: un único parámetro de agitación en vez de un puñado de números mágicos, tiers explícitos en vez de un solo interruptor de animación, y prefers-reduced-motion tratado como una decisión del usuario, no como un ajuste de rendimiento.
Puedes verlo en vivo en meteohealth.pro, el sitio de marketing de MeteoHealth: ábrelo en un teléfono con prefers-reduced-motion activado y comprueba que el póster se lee como una decisión de diseño deliberada, no como un simple relleno temporal.



