Генеративный WebGL-лендинг на React Three Fiber: когда сайт продукта — сам продукт#
Большинство лендингов приложений выглядят одинаково: скриншот в рамке телефона, три карточки с фичами, кнопка «Скачать в App Store». Это работает, но не запоминается. Когда я собирал meteohealth.pro — маркетинговый сайт iOS-приложения MeteoHealth, которое отслеживает метеочувствительность, — я решил проверить другую гипотезу: а что, если сам сайт станет демонстрацией продукта, а не его описанием?
Идея простая на словах и трудная в реализации: в hero-секции живёт генеративное «поле давления» — волнистый рисунок на React Three Fiber, который в реальном времени управляется настоящими метеоданными выбранного города. Растёт перепад давления — рисунок сгущается и «беспокоится». Погода спокойная — поле дышит ровно. Никакого шума ради шума: каждая деформация шейдера отвечает на конкретное число с сервера.
В этой статье — как это устроено технически: от архитектуры React Three Fiber и разбора самого шейдера до системы деградации, которая должна не испортить впечатление ни на флагманском iPhone, ни на бюджетном Android с prefers-reduced-motion.
Когда сайт продукта — это сам продукт#
Есть соблазн считать WebGL-сцену на лендинге «просто красивым фоном». Это ошибка, которая обычно и убивает подобные эксперименты: декоративная анимация конкурирует с текстом за внимание, замедляет LCP и не имеет отношения к тому, что продаёт страница.
Другой подход — сделать визуализацию буквальным доказательством того, что делает продукт. MeteoHealth анализирует связь между перепадом атмосферного давления и самочувствием. Значит, hero-сцена не абстрактный узор, а прямое отображение того самого перепада: pressureDelta24h с сервера погоды становится единственным «нервом», который управляет всем полем. Это не иллюстрация функции — это работающий срез той же логики, что и в приложении, только увиденный глазами, а не цифрами в таблице.
Отсюда вытекает архитектурное правило: у сцены не должно быть decorативных переменных. Если параметр шейдера ни на что не влияет с точки зрения данных — его не должно быть. Это дисциплинирует дизайн сильнее любого брифа.
Анатомия генеративного поля: R3F, Three.js и данные вместо шума#
React Three Fiber — это не альтернатива Three.js, а декларативный слой поверх него: сцена описывается JSX-деревом, а рендер-цикл, disposal ресурсов и синхронизация с React state берёт на себя реконсилятор R3F. Для генеративной data-driven графики это даёт главное — компонентную границу между «слоем данных» (React state, fetch, приведение погоды к параметрам шейдера) и «слоем рендера» (useFrame, прямые мутации uniform-ов).
Сцена в meteohealth.pro построена из двух слоёв, наложенных друг на друга:
- Изолинии давления — полноэкранная плоскость с fragment-шейдером, который рисует концентрические волнистые линии, похожие на топографическую карту. Каждая третья линия — акцентная (зелёная), остальные — тон «чернил» с низкой альфой.
- GPU-частицы вдоль тех же изолиний — тонкие следы, повёрнутые на 90° к градиенту поля (как геострофический ветер в реальной метеорологии), симулируемые через ping-pong FBO прямо на GPU, без единого JS-цикла по каждой частице на CPU.
Ключевое архитектурное решение — вести обе системы от одного и того же скалярного параметра беспокойства поля, а не от произвольного времени. Ниже — упрощённая, но рабочая по сути схема того, как это собрано в 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>
)
}Обратите внимание: канвас не появляется, пока не определён тир. Постер — не запасной вариант «на всякий случай», а полноценное первое состояние экрана. Именно постер, а не WebGL-канвас, отвечает за LCP.
Код: от Canvas к шейдеру#
Дальше — компонент материала, который связывает данные погоды с шейдером через shaderMaterial из @react-three/drei. Формула перевода перепада давления в «беспокойство поля» — не линейная: падение на 7 гПа даёт заметный, но не резкий отклик, а дальше кривая насыщается, не давая полю сорваться в визуальный хаос при экстремальных значениях.
// 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, растёт при падении давления за 24ч
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 {
// Падение давления двигает поле; рост или стабильность — поле спокойно
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
// Плавный лерп к целевому значению — без рывков при обновлении данных
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>
)
}А вот сам фрагментный шейдер — упрощённая версия той логики, что рисует изолинии:
// field.frag.glsl
uniform float uTime;
uniform float uAgitation; // 0 (штиль) .. ~0.97 (сильное падение давления)
uniform float uIsoDensity; // базовая плотность изолиний
uniform vec3 uInk;
uniform vec3 uGreen;
varying vec2 vUv;
float snoise(vec2 v); // simplex noise (2D), реализация опущена
void main() {
vec2 uv = vUv * 2.0 - 1.0;
// Поле "дышит" на двух октавах шума; вторая октава усиливается
// вместе с беспокойством поля — на спокойной погоде почти незаметна
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);
// Изолинии — расстояние до ближайшей "линии" по модулю поля
float line = abs(fract(field) - 0.5) * 2.0;
float iso = 1.0 - smoothstep(0.0, fwidth(field) * 1.5, line);
// Каждая третья линия — акцентная, остальные — тон "чернил"
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);
}Обратите внимание на fwidth(field) в антиалиасинге линий — это то, что превращает шейдер из «мозаики зубцов» в аккуратные тонкие линии на любом разрешении и devicePixelRatio, без дополнительного постпроцесса.
Canvas 2D, SVG или WebGL — что выбрать#
Перед тем как писать шейдер, стоит честно ответить на вопрос: а нужен ли вообще WebGL? Для большинства «оживлённых» лендингов достаточно Canvas 2D или даже SVG с CSS-анимацией. WebGL оправдан, когда одновременно верны три условия: нужно много подвижных примитивов (сотни-тысячи частиц), нужна субпиксельная плавность на requestAnimationFrame и графика должна реагировать на непрерывно меняющиеся данные без пересборки DOM.
| Критерий | Canvas 2D | SVG | WebGL (R3F / Three.js) |
|---|---|---|---|
| Кол-во анимируемых объектов | Сотни, дальше проседает FPS | Десятки — DOM дорогой | Тысячи частиц на GPU |
| Реакция на live-данные | Нужен ручной re-draw | Просто (атрибуты/CSS-переменные) | Просто (uniform-ы шейдера) |
| Доступность / SEO | Контент невидим для скринридеров | Нативно доступен, индексируется | Требует HTML-дублей текста |
| Кривая внедрения | Низкая | Очень низкая | Высокая (шейдеры, буферы) |
| Деградация на слабых устройствах | Хорошая | Отличная | Нужен явный fallback-тир |
| Типичный кейс | Графики, простые частицы | Иконки, диаграммы, dash-draw схемы | Генеративные сцены, симуляции поля |
Для meteohealth.pro выбор WebGL был осознанным компромиссом: сцена — единственный «дорогой» элемент на весь сайт, а бюджет JS на контентных страницах (blog, features, legal) остаётся в районе 100 KB именно потому, что three-чанк грузится только на лендинге, динамическим импортом, и только после idle.
Деградация без компромиссов: тиры, постеры и prefers-reduced-motion#
Самая частая ошибка генеративных лендингов — тестировать их только на MacBook Pro разработчика. Реальная аудитория заходит с трёхлетних Android-бюджетников, из режима энергосбережения, с включённым prefers-reduced-motion. Система, которая красиво выглядит только в идеальных условиях, — не готова к продакшену.
В meteohealth.pro деградация построена как три явных тира, а не как один флаг «выключить анимацию»:
- Tier A — полная сцена: обе октавы шума, плотная изолиния, полный набор частиц,
devicePixelRatioдо 1.75. - Tier B — упрощённая сцена: меньше отрисовываемых частиц (
drawRange), одна октава шума,devicePixelRatioограничен до 1.25. - Tier C — статичный AVIF-постер, снятый с реальной сцены. Он же используется как база OG-изображения — не отдельный «заглушечный» ассет, а честный кадр продукта.
Выбор тира не разовый: помимо стартовой эвристики (deviceMemory, maxTouchPoints), приложение измеряет реальное время кадра в первые 20 кадров и понижает тир, если рендер стабильно выходит за бюджет. То же самое происходит на лету во время сессии — скользящее окно в 120 кадров, и если среднее время кадра превышает ~33 мс, сцена откатывается на тир ниже, а не продолжает молотить GPU до перегрева телефона.
prefers-reduced-motion обрабатывается отдельно и приоритетно — это не «ещё один сигнал производительности», а явное пользовательское решение. При включённом флаге three.js, GSAP и Lenis вообще не подгружаются: не просто анимация выключена, а весь связанный JS не запрашивается с сети. Canvas появляется поверх постера плавным crossfade за 0.7 секунды после первого готового кадра — сама подмена не должна быть заметным «прыжком» интерфейса.
Дизайн-система как контракт: GSAP, Lenis и Liquid Signal#
WebGL-сцена — это только один, пусть и самый заметный, элемент. Она живёт внутри собственной дизайн-системы «Liquid Signal»: спокойная editorial-типографика плюс HUD-примитивы — моноширинные подписи вроде PRESSURE_Δ24H, TIME_CODE, RISK рядом с реальными показаниями, а не декоративными числами. Идея в том, что сайт ведёт себя как откалиброванный прибор, а не как рекламный баннер: каждая цифра на экране — настоящая, включая ту, что управляет генеративным полем.
GSAP и Lenis подключены только на лендинге и делают ровно две вещи: один короткий load-sequence при первом заходе (линии прорисовываются, HUD-метки «печатаются») и скролл-ревилы для остального контента (translateY + fade, ~0.6 с, power2.out). На контентных страницах — блоге, страницах фич, юридических текстах — анимаций либо нет вовсе, либо только CSS-переходы: бюджет JS для этих страниц не должен зависеть от того, что происходит на лендинге.
Итог получился неожиданно строгим для «генеративного» проекта: чем смелее signature-элемент, тем дисциплинированнее должно быть всё вокруг него. Одна сцена несёт всю художественную смелость сайта; остальное — тихо, предсказуемо и не мигает.
Заключение#
Генеративная WebGL-сцена оправдывает себя на лендинге только тогда, когда она не декорация, а прямое отображение данных продукта — и только если инженерия деградации так же продумана, как сам шейдер. React Three Fiber даёт для этого удобную декларативную границу между данными и рендером, но дисциплина — в деталях: единый параметр беспокойства вместо россыпи магических чисел, явные тиры вместо одного флага «анимация вкл/выкл», и prefers-reduced-motion как решение пользователя, а не как настройку производительности.
Живой пример — meteohealth.pro, сайт-визитка приложения MeteoHealth: можно открыть его на телефоне с prefers-reduced-motion и убедиться, что постер выглядит осмысленно сам по себе, а не как временная заглушка.



