WebGL внутри Tilda
Вёрстка Кастомизация JS
Как мы переносим shader-анимации без React
26.09.2026
WebGL внутри Tilda: как мы переносим shader-анимации без React
Tilda отлично справляется с версткой, контентом, формами и обычной анимацией.
Проблемы начинаются, когда в дизайн хочется добавить что-нибудь вроде:
— живого shader-фона;
— WebGL-деформации под курсором;
— процедурной анимации линий;
— частиц, из которых собирается надпись;
— шума и световых искажений;
— интерактивного canvas;
— эффекта, изначально написанного на React Three Fiber.
Большинство таких решений распространяются как React-компоненты и устанавливаются примерно так:
npm install three
или:
pnpm dlx shadcn@latest add @react-bits/GhostFibers-JS-CSS
В обычном React-проекте всё понятно.
Но у нас Tilda.
Нет привычного src, нет сборщика, нет JSX, а вставить:
<GhostFibersspeed={0.2}scale={2}layers={4}/>
в HTML-блок и надеяться на лучшее не получится.
Поэтому в нескольких проектах мы постепенно собрали собственный подход к переносу WebGL-эффектов в Tilda.
Задача
Нам хотелось использовать современные интерактивные эффекты примерно такого класса, как backgrounds из React Bits:
В частности, мы экспериментировали с:
Ghost Fibers — системой светящихся волокон и линий;
Beams — объёмными световыми лучами;
Silk / GradFlow — плавными процедурными WebGL-градиентами;
а позже — с собственной WebGL-анимацией hero-блока Aucs.
Задача при этом была не в том, чтобы просто вставить чужой React-код.
Нам нужно было получить:
чистый HTML + CSS + JavaScript, который можно положить в T123 или HTML-элемент Zero Block и забыть о React вообще.
Почему нельзя просто скопировать React-компонент
Возьмём типичный современный WebGL-компонент.
Внутри него могут использоваться:
import * as THREE from 'three';import { Canvas } from '@react-three/fiber';import { useFrame } from '@react-three/fiber';import { useRef, useEffect } from 'react';
Сам React-компонент в этом случае — лишь оболочка.
Настоящий визуальный эффект обычно находится глубже:
— в GLSL vertex shader;
— в fragment shader;
— в математике изменения координат;
— в uniforms;
— в обработке времени;
— в camera / scene / renderer;
— в mouse interaction.
Именно эту часть и нужно было вытащить.
По сути схема преобразования выглядела так:
React component↓Three.js / WebGL logic↓shader + uniforms↓обычный JavaScript↓canvas внутри Tilda
Мы не переносили React.
Мы переносили сам принцип рендеринга.
Первый подход: обычный WebGL canvas
Внутрь Tilda добавляется минимальный контейнер:
<div id="aucs-webgl"><canvas></canvas></div>
И CSS:
#aucs-webgl {position: absolute;inset: 0;width: 100%;height: 100%;overflow: hidden;pointer-events: none;}#aucs-webgl canvas {display: block;width: 100%;height: 100%;}
После этого Tilda фактически перестаёт участвовать в рендеринге эффекта.
Она только предоставляет нам контейнер.
Всё остальное рисует GPU.
Shader вместо DOM-анимации
Обычная CSS-анимация меняет свойства HTML-элементов.
Shader работает совсем иначе.
Для каждого пикселя изображения можно вычислять собственное состояние.
Упрощённо наш fragment shader может выглядеть так:
precision highp float;uniform vec2 uResolution;uniform float uTime;void main() {vec2 uv = gl_FragCoord.xy / uResolution.xy;float wave =sin(uv.x * 8.0 + uTime) *cos(uv.y * 6.0 - uTime * 0.7);vec3 color = vec3(0.1 + wave * 0.05,0.08,0.22 + wave * 0.12);gl_FragColor = vec4(color, 1.0);}
Здесь нет ни одного HTML-объекта.
Браузер просто рассчитывает цвет каждого пикселя canvas.
И именно поэтому WebGL позволяет делать эффекты, которые было бы бессмысленно собирать из сотен <div>.
Время становится частью изображения
Практически во всех наших эффектах используется uniform времени:
const startTime = performance.now();function render(now) {const time = (now - startTime) / 1000;gl.uniform1f(uTime, time);gl.drawArrays(gl.TRIANGLES,0,6);requestAnimationFrame(render);}requestAnimationFrame(render);
Shader получает:
uniform float uTime;
и дальше может использовать его практически где угодно:
sin(x + uTime)noise(position + uTime * 0.2)rotation += uTime * 0.05
В результате изображение существует не как набор кадров, а фактически пересчитывается в реальном времени.
GradFlow: когда React-компонент оказался не нужен
Одним из первых экспериментов был GradFlow — библиотека WebGL-градиентов.
В ней были разные режимы:
linearconicwavesilksmokestripe
Нас особенно заинтересовал silk. Посмотрите, что вышло
Вместо подключения всего React-компонента мы разобрали shader-логику и перенесли её непосредственно в canvas.
Конфигурация выглядела примерно так:
const config = {speed: 1,scale: 1.2,noise: 0.1};
А сам эффект уже создавался fragment shader.
Получилась важная для дальнейших проектов идея:
если визуальный компонент построен вокруг shader, React часто вообще не является обязательной частью эффекта.
Он нужен разработчику библиотеки для удобного API.
Нам же нужен непосредственно WebGL.
Ghost Fibers
Позже мы захотели использовать более сложный фон в духе Ghost Fibers из React Bits.
Исходные параметры эффекта были примерно такими:
lineColor: '#140E35',glowColor: '#3437A0',speed: 0.2,scale: 2,rotation: 0,rotationSpeed: 0.25,layers: 4,waveAmplitude: 0.015,waveFrequency: 3,waveSpeed: 0.15,layerSpeed: 0.08,twist: 0.1,twistFrequency: 5,twistSpeed: 1.2,lineFrequency: 5,lineSpacing: 2,lineSharpness: 16,glowFalloff: 10,glowIntensity: 1.6,brightness: 2
После первой интеграции стало понятно, что переносить настройки буквально тоже нельзя.
На большом preview эффект выглядел хорошо, а внутри настоящего hero становился слишком активным.
Особенно мешала мелкая рябь.
Поэтому мы начали уменьшать влияние noise, wave и rotation.
Здесь проявилась ещё одна особенность WebGL-дизайна:
настроить эффект технически и настроить его визуально — две разные задачи.
Красивый demo-background не обязательно будет хорошим фоном под заголовок, кнопку и навигацию.
WebGL должен находиться под контентом
Началась с того, что мы захотели сделать легкий glow эффект под курсором мыши. А в hero-блоке нам было важно, чтобы WebGL не ломал интерфейс.
Поэтому canvas располагался внутри собственной системы слоёв:
#rec4118077301 {position: relative;z-index: 1;}#aucs-vector-hero {position: absolute;inset: 0;z-index: 0;pointer-events: none;}#rec4118077301 .t396__artboard {position: relative;z-index: 2;}
А header получал отдельный верхний слой:
#rec589693974 {position: relative;z-index: 9999 !important;}
Ключевая строка здесь:
pointer-events: none;
Canvas остаётся визуально поверх или под нужными слоями, но не перехватывает клики пользователя.
Без этого красивый WebGL-фон очень быстро превращается в прозрачную плёнку, лежащую поверх кнопок.
Но как тогда реагировать на курсор?
pointer-events: none запрещает canvas принимать мышь.
Поэтому координаты можно получать не с самого canvas:
document.addEventListener('pointermove', (e) => {const rect = hero.getBoundingClientRect();mouse.x =(e.clientX - rect.left) / rect.width;mouse.y =1.0 -(e.clientY - rect.top) / rect.height;});
А затем передавать их в shader:
gl.uniform2f(uMouse,mouse.x,mouse.y);
В shader:
uniform vec2 uMouse;
Теперь можно вычислять расстояние каждого пикселя до курсора:
float dist = distance(uv,uMouse);
и превращать его, например, в glow:
float glow =smoothstep(0.3,0.0,dist);
Получается эффект, который реагирует на мышь, хотя сам canvas вообще не участвует в системе событий.
Плавность вместо прямого следования
Если просто записывать текущие координаты курсора в shader, эффект получается слишком механическим.
Поэтому мы использовали интерполяцию.
Пример:
currentMouse.x +=(targetMouse.x - currentMouse.x) * 0.075;currentMouse.y +=(targetMouse.y - currentMouse.y) * 0.075;
Так появился эффект небольшой инерции.
В одном из вариантов настройки выглядели примерно так:
const RADIUS = 0.28;const STRENGTH = 0.065;const FOLLOW = 0.075;const RETURN_SPEED = 0.055;const RIPPLE = 0.010;const CHROMATIC = 0.0025;
То есть курсор не просто включал эффект.
Он становился физическим параметром сцены.
Retina внезапно делает WebGL тяжёлым
Одна из типичных ошибок при создании canvas:
canvas.width = window.innerWidth * devicePixelRatio;canvas.height = window.innerHeight * devicePixelRatio;
На Retina:
devicePixelRatio = 2
или даже выше.
Если экран физически большой, GPU начинает обрабатывать в несколько раз больше пикселей.
Поэтому практически во всех наших WebGL-решениях появился лимит:
const MAX_DPR = 2;const dpr = Math.min(window.devicePixelRatio || 1,MAX_DPR);
И дальше:
canvas.width =Math.round(width * dpr);canvas.height =Math.round(height * dpr);
Это почти незаметно визуально, но сильно влияет на производительность.
ResizeObserver вместо window.resize
В Tilda размер блока Zero Block HTML не всегда напрямую равен размеру окна.
Поэтому применять только:
window.addEventListener('resize', resize);
недостаточно.
Мы начали использовать:
const observer =new ResizeObserver(() => {resize();});observer.observe(hero);
Теперь WebGL реагирует именно на изменение своего контейнера.
Это особенно важно для Tilda, где высота artboard может меняться между breakpoints.
И это спасло адаптив
Во время одного из последних hero-экспериментов WebGL на десктопе выглядел идеально, но мобильная версия внезапно становилась шире viewport.
Мы долго искали проблему в shader.
Потом выяснилось, что проблема вообще не в WebGL.
HTML-элемент Zero Block имел фиксированную ширину больше 2000px.
После переключения размера элемента на:
width: 100% window
всё сразу стало работать нормально.
Хороший пример того, насколько легко при работе с WebGL начать искать сложную математическую ошибку там, где виноваты мы сами, забыв проверить адаптив еще раз.
Частицы вместо PNG
Другой эксперимент был связан с particle-анимацией текста.
Первоначальное решение зависело от внешней текстуры:
particle.png
и отдельно загружаемого изображения надписи.
Мы решили убрать обе зависимости.
Частицу можно нарисовать непосредственно shader-кодом.
Например:
vec2 p =gl_PointCoord -vec2(0.5);float d =length(p);float alpha =smoothstep(0.5,0.35,d);gl_FragColor =vec4(vec3(1.0),alpha);
И всё.
Никакого:
<img src="particle.png">
не требуется. А то мало ли где он лежит...
SVG вместо картинки текста
Тк изначальный скрипт, шрифт и пнг частиц использовали внешние ссылки на хостинг, было решено от этого уйти. Нужно было оптимизировать скрипт, разместить его на самой странице. А шрифт и картинки отрисовать.
Мы заменили их на SVG.
При этом SVG был встроен прямо в HTML:
<svgid="codeminer-svg"viewBox="0 0 247 52"xmlns="http://www.w3.org/2000/svg"><path d="..."/><path d="..."/></svg>
После этого геометрию текста можно было использовать как основу для расположения частиц.
В итоге внешняя инфраструктура эффекта сократилась практически до одной зависимости:
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r121/three.min.js"></script>
Three.js r121 действительно доступен через cdnjs.
Всё остальное — SVG, частицы, анимация, mouse interaction — находилось непосредственно внутри страницы.
Three.js как удобный слой над WebGL
Не каждый эффект имеет смысл писать на голом WebGL.
Для particle-систем и 3D-сцен мы использовали Three.js.
Упрощённо сцена выглядит так:
const scene = new THREE.Scene();const camera =new THREE.PerspectiveCamera(45,width / height,0.1,100);const renderer =new THREE.WebGLRenderer({alpha: true,antialias: true});
Затем создаётся geometry:
const geometry =new THREE.BufferGeometry();
и shader material:
const material =new THREE.ShaderMaterial({transparent: true,uniforms: {uTime: {value: 0},uMouse: {value:new THREE.Vector2()}},vertexShader,fragmentShader});
Three.js здесь не создаёт сам визуальный стиль.
Он просто избавляет нас от большого количества низкоуровневой WebGL-инфраструктуры.
React Bits Beams → Three.js внутри Zero Block
Ещё один эксперимент был основан на React Bits Beams.
Оригинальный компонент использует экосистему Three / React Three Fiber. Сам React Bits указывает для него зависимости:
npm install three @react-three/fiber @react-three/drei
Но внутри Tilda нам снова не нужны были Fiber и Drei.
Мы оставили только Three.js.
Получилась схема:
React Bits Beams↓анализ геометрии и material↓Three.js↓shader через onBeforeCompile↓canvas внутри Zero Block
Например, собственные uniforms можно добавить так:
material.onBeforeCompile =(shader) => {shader.uniforms.uTime = {value: 0};shader.uniforms.uSpeed = {value: 1};shader.uniforms.uScale = {value: 0.2};shader.uniforms.uNoiseIntensity = {value: 1.75};};
Мы сохранили основные параметры эффекта:
beamWidth: 2,beamHeight: 15,beamNumber: 12
Эти же значения используются React Bits как базовые параметры компонента Beams.
Оптимизация: не рендерить то, чего пользователь не видит
requestAnimationFrame() сам по себе не знает, находится ли блок на экране.
Поэтому бессмысленно продолжать WebGL-рендеринг hero-блока, если пользователь уже проскроллил страницу на несколько экранов вниз.
Для этого мы использовали IntersectionObserver:
let visible = true;const io =new IntersectionObserver(([entry]) => {visible =entry.isIntersecting;});io.observe(hero);
В цикле:
function animate() {requestAnimationFrame(animate);if (!visible) return;renderer.render(scene,camera);}
Дополнительно учитывали состояние вкладки:
document.addEventListener('visibilitychange',() => {pageVisible =!document.hidden;});
То есть GPU не должен работать просто потому, что когда-то пользователь открыл страницу.
Собственный WebGL hero для Aucs
В какой-то момент мы уже перестали просто переносить существующие эффекты.
Для Aucs появилась собственная композиция:
— линии;
— точки;
— квадраты;
— пунктирный треугольник;
— векторное движение;
— автоматический sweep;
— реакция на курсор;
— собственная текстовая текстура.
Canvas занимал весь первый экран:
<div id="aucs-vector-hero"><canvas></canvas></div>
А дальше геометрия рассчитывалась уже непосредственно под композицию сайта.
Это важный этап.
Мы начали с вопроса:
«Как встроить React-анимацию в Tilda?»
А пришли к:
«React нам вообще не нужен — мы можем использовать WebGL как собственный графический движок внутри Tilda».
Отдельный glow поверх заголовка
Похожий принцип мы применили уже не ко всему экрану, а к конкретному заголовку:
«Дизайн-поддержка для вашего бизнеса»
Сам текст оставался чёрным.
А жёлтое свечение должно было появляться только внутри формы букв.
При этом glow:
— медленно перемещался сам;
— не выходил за границы текста;
— при появлении курсора начинал плавно притягиваться к нему.
Логику положения можно представить так:
if (pointerInside) {targetX = pointerX;targetY = pointerY;} else {targetX = autoX;targetY = autoY;}glowX +=(targetX - glowX) * 0.06;glowY +=(targetY - glowY) * 0.06;
Таким образом пользователь не «включает анимацию».
Он вмешивается в уже существующую систему движения.
И после ухода курсора она снова начинает жить самостоятельно.
Почему мы не используем видео
Практически любой такой background можно записать в MP4.
И иногда это действительно разумно.
Но видео не знает:
mouse.xmouse.y
не реагирует на:
pointermove
не может изменить направление движения в зависимости от пользователя и не адаптирует саму математическую композицию к размеру контейнера.
WebGL в этом смысле — не видеофон.
Это генерируемый в реальном времени интерфейс.
А что остаётся от Tilda?
Самое интересное — практически всё остальное.
Tilda продолжает отвечать за:
— Zero Block;
— текст;
— кнопки;
— формы;
— responsive layout;
— CMS;
— публикацию;
— управление страницами.
А WebGL отвечает только за ту часть, где Tilda заканчивается.
Нет необходимости переписывать весь сайт на React ради одного интерактивного hero.
Что оказалось самым сложным
Не написание shader.
Главная сложность — заставить всё это нормально жить внутри реального сайта.
Нужно учитывать:
Tilda breakpoints
DPR
Retina
resize
mobile viewport
Zero Block
z-index
pointer events
scroll
performance
visibility
browser rendering
Проблема может находиться где угодно.
Например:
анимация тормозит
→ слишком высокий DPR.
курсор перестал нажимать кнопки
→ canvas перехватывает pointer events.
эффект обрезается
→ неправильный размер контейнера.
на мобильном WebGL огромный
→ HTML-элемент Zero Block имеет фиксированную ширину 2000 px.
эффект работает за пределами экрана
→ нет IntersectionObserver.
после ресайза всё растянулось
→ canvas слушает window, а не реальный контейнер.
Именно вокруг этих деталей обычно находится большая часть настоящей разработки.
Что в итоге
За несколько проектов мы собрали рабочий подход, при котором Tilda перестаёт быть ограничением для сложной realtime-графики.
Мы можем брать идеи из React/WebGL-экосистемы и переносить их в обычный сайт без полноценной миграции проекта на React.
В зависимости от задачи используем:
WebGL
GLSL
Three.js
Canvas
SVG
ResizeObserver
IntersectionObserver
requestAnimationFrame
Pointer Events
и оставляем Tilda выполнять ту работу, для которой она действительно удобна.
Что мы сделали
В рамках этих экспериментов мы:
— переносили React/WebGL-компоненты в чистый JavaScript;
— адаптировали shader-анимации для HTML-блоков Tilda;
— работали с GLSL vertex и fragment shaders;
— переносили GradFlow / Silk-подобные procedural backgrounds;
— адаптировали React Bits Beams;
— экспериментировали с Ghost Fibers;
— создавали собственные WebGL-композиции для hero-блоков;
— реализовывали реакции на курсор;
— добавляли физику плавного следования;
— создавали автономное движение без действий пользователя;
— заменяли PNG-текстуры математически отрисованными частицами;
— заменяли внешние изображения inline-SVG;
— оптимизировали рендеринг под Retina;
— ограничивали DPR;
— подключали ResizeObserver;
— останавливали ненужный rendering через IntersectionObserver;
— адаптировали canvas под desktop, tablet и mobile;
— решали конфликты canvas с интерфейсом Tilda.
Главное
Для нас ценность этих экспериментов не в самом WebGL.
И не в том, чтобы заставить ноутбук пользователя рисовать как можно больше частиц.
Главный результат — возможность не выбирать между:
«сделать быстро на Tilda»
и
«сделать действительно нестандартный интерфейс».
Tilda может оставаться основой проекта.
А поверх неё можно построить собственный графический слой практически любой сложности.
Иногда достаточно CSS.
Иногда нужен JavaScript.
А иногда наступает момент, когда браузеру проще просто отдать несколько миллионов пикселей GPU и сказать:
рисуй.
Смотрите также