
TL;DR
За последний год я собрал вайбкодингом не один десяток лендингов — для программ МФТИ, для своих проектов и на заказ. Разберу четыре самых показательных: Школу Техпреда, конференцию «Точка сборки», курс «ИИ Продакт-менеджмент» (aimipt.ru) и один свой, личный — на нём эффекты раскачаны сильнее всего. Вайбкодинг здесь — это когда код пишет агент под мою постановку задачи, а я ставлю рамки, правлю и проверяю результат.
У всех четырёх есть слой «дорогих» визуальных эффектов: интерактивная вода на WebGL-шейдере с освещением, 3D-логотип Физтеха из тысяч частиц-спрайтов, живая 3D-башня из кубов, облако частиц, которое морфит форму по скроллу.
К этому слою я пришёл двумя разными путями. На «Точке сборки» эффекты написаны руками на чистом WebGL — ноль анимационных библиотек. На остальных работает свой движок на Three.js: вынесен отдельными файлами, переиспользуется между сайтами и откатывается на 2D-canvas, если CDN не ответит. На личном лендинге я раскачал этот движок сильнее всего.
Между «насмотренностью» и кодом стоит цепочка инструментов: сначала дизайн-система в формате DESIGN.md, из неё мокап в Claude Design, из мокапа handoff-бандл и только потом перенос в код. Ниже — оба пути на реальном коде, эта цепочка и грабли, на которые я наступил.
Сразу про проверяемость. Репозитории этих лендингов клиентские и закрытые: имена файлов, их размеры и счётчики ниже — из моей рабочей копии, по ссылке вы её не откроете. Снаружи можно проверить живые страницы и чужой референс, который я разбираю; код в статье — фрагменты из работающих файлов.
Пара слов о себе: я Жемал Хамидун, CPO AlpinaGPT и Head of AI в Alpina Digital. Фронтенд — не моя основная работа, так что дальше — взгляд человека, который собирает такое агентом и пишет про это в «Готовим ИИшницу».
Четыре лендинга, две ветки
Технически эти четыре лендинга разделились на две ветки. Первой была Школа Техпреда: именно на ней движок 3D-эффектов на Three.js впервые переехал в отдельные файлы и стал переиспользуемым. Из того же набора вырос лендинг курса «ИИ Продакт-менеджмент» (aimipt.ru) — он подключает те же движки со Школы.
А лендинг конференции «Точка сборки» (techpred.online/upcycle) пошёл другим путём: весь визуал написан вручную на голом WebGL и CSS, без единой внешней библиотеки.
И четвёртый — один из моих собственных лендингов. Тот же Three.js-движок на нём раскачан сильнее всего: облако частиц морфит форму по скроллу, рядом 3D-компас, спотлайт-подсветка под курсором и заголовки, набранные теми же частицами. Три лендинга МФТИ показывают, «как это устроено». Четвёртый — «насколько далеко можно увести один движок».
Цепочка инструментов: от насмотренности до прода
Цепочка одна: вкус → мокап → handoff-бандл → код. Каждое звено — отдельный инструмент.
Вкус в машиночитаемом виде — DESIGN.md
Я держу библиотеку дизайн-систем, где каждый бренд (Stripe, Linear, Apple, Cursor и десятки других, плюс мои собственные) разобран в один структурированный файл DESIGN.md. Для агента это сырьё, из которого он берёт конкретные значения. Туда входят:
Токены с ролями. Токен — именованное значение дизайна: цвет, отступ, радиус. Рядом со значением лежит роль: #533afd подписан как «Deep Violet — primary calls to action, active states».
Type scale — шкала типографики: размеры, line-height, letter-spacing плюс OpenType-фичи, то есть необязательные начертания внутри шрифта ("ss01" — альтернативный набор глифов, "tnum" — моноширинные цифры).
Spacing/radius/shadows — base unit 4px, именованные радиусы, мягкие тени.
Компоненты — Primary/Ghost/Outlined Button, Default/Feature Card с точными padding и фонами.
Do's и Don'ts — «не вводи новые семейства шрифтов», «не используй жёсткие тени».
Agent Prompt Guide — готовые примеры промптов под этот стиль.
Quick Start — те же токены сразу как CSS Custom Properties и @theme для Tailwind v4.
Отличие от обычной палитры — в ролях. Шрифт описан так: «sohne-var, вес 300 для крупных заголовков ради сдержанной авторитетности, 400 для тела». Агент из такого описания понимает не только значение, но и где его применять. Акцентный цвет у него перестаёт уезжать в фон, а заголовочный шрифт — в подписи.
Похожий формат отдаёт Refero Styles — галерея стилей реальных продуктов, которая собирает из бренда готовый DESIGN.md. Смысл один: вместо «сделай красиво, синенько» я кладу агенту полный блюпринт и говорю, что в нём поменять. Для «Точки сборки» исходным стилем был Stripe.
Claude Design — мокап, который оркестрирует сам Claude Code
Дальше — мокап. Я делаю его в Claude Design (claude.ai/design) — это отдельный браузерный инструмент Anthropic, где интерфейс рисуется диалогом с моделью: «сделай тёмнее», «как в Linear», можно перетаскивать референсные скриншоты. На выходе — кнопка Handoff, которая отдаёт zip-архив с исходниками макета (дальше — бандл).
По Claude Design я при этом не кликаю руками. Я заставляю Claude Code оркестрировать Claude Design через Playwright-плагин: агент сам открывает страницу, создаёт проект, вбивает длинный структурированный промпт целиком, ждёт отрисовки canvas, итерирует репликами в чат и в конце жмёт Handoff, скачивая бандл. Чтобы это работало стабильно, я реверс-инжинирил поведение Claude Design — какие селекторы ждать, как устроен бандл, где автоматизация спотыкается — и собрал из этого отдельный скилл.
Главный камень — Cloudflare Turnstile. Логин в Claude Design прикрыт капчей, и она отлично детектит автоматизацию. Поэтому в скилле зашит честный момент остановки: агент доходит до капчи, говорит «кликни чекбокс руками», я кликаю — и дальше он продолжает сам. Обходить капчу я не пробовал: она тут стоит по делу.
Handoff-бандл — что приходит в проект
Бандл из Claude Design распаковывается в предсказуемую структуру, и первое, что в нём лежит, — README.md с прямым обращением к coding-агенту. Дословно оттуда:
CODING AGENTS: READ THIS FIRST. This is a handoff bundle from Claude Design. Recreate the designs pixel-perfectly in whatever technology fits the codebase. Don't render these files in a browser or take screenshots — everything you need is in the source.
Дальше — project/<Name>.html (сам мокап, читать целиком), styles.css с токенами, и uploads/ с моими референсами и тем самым DESIGN.md + tokens.json. Бандлы я держу в git рядом с кодом: это source-of-truth дизайна.
И работает правило, которое легко нарушить: бандл — это референс, а не production-код. HTML из Claude Design я не копирую дословно — читаю целиком (не skim, не первые сто строк), вытаскиваю токены и паттерны и пересобираю визуал уже в живом коде, навешивая ту интерактивность, которой в мокапе нет. Мокап задаёт композицию и палитру. Эффекты — отдельный слой, который я добавляю руками поверх.
Разбор живого референса — и урок про «3D, которого нет»
Когда нужен конкретный эффект с чужого сайта, я его препарирую: открываю живой референс через Playwright, снимаю DOM и computed styles, смотрю, из чего он реально собран.
Хрестоматийный пример — dayos.com. Сайт выглядит так, будто там тяжёлый 3D на Three.js: объёмные блоки крутятся и переливаются. Я снял его на момент разбора, прочитал DOM — и увидел: canvases=0, ни three, ни spline. «3D-блоки» оказались пререндеренным зацикленным webm-видео. Проверка повторяется в одну строку в консоли: document.querySelectorAll('canvas').length.
Вывод двойной. С одной стороны, не всё, что похоже на 3D, надо писать кодом: иногда это просто ролик, и два дня на шейдеры можно не тратить. С другой — когда мне самому понадобились живые 3D-блоки на Школе, я сознательно выбрал настоящий Three.js, потому что блоки должны были реагировать на пользователя.
Глубокое препарирование: разобрать чужой движок на 27 шейдеров
Снять DOM — это лёгкий уровень. Тяжёлый начинается, когда референс — топовый WebGL-сайт и хочется понять не «что», а «как именно». Самый показательный мой разбор — dala.craftedbygc.com: сайт, который студия Unseen сделала для AI-поисковика Dala (разбор проекта у самой студии). Чёрный одностраничник с 3D-«мозгом» из примерно десяти тысяч цветных частиц-пирамидок. При скролле он разлетается, морфит между формами и собирается обратно; сверху постобработка, smooth-scroll и текст, выезжающий «из-под пола».
Тут одним скриншотом не обойдёшься. Я скачал бандл целиком, прогнал минифицированный theme.js через beautifier (266 КБ развернулись в 9168 читаемых строк) и написал маленький скрипт, который выдрал из него все шейдеры. Получилось 27 файлов .glsl.
Внутри оказался кастомный движок без фреймворка, на Three.js. GPGPU на ping-pong FBO — позиции и скорости частиц живут в текстурах и пересчитываются на GPU, две текстуры меняются ролями каждый кадр. Частицы — InstancedMesh, то есть тысячи копий одной геометрии за один вызов отрисовки, из настоящих .glb-пирамидок.
Постобработка идёт через EffectComposer, цепочку экранных фильтров: свечение (UnrealBloom), размытие по глубине (Bokeh DoF), виньетка и зерно. Вся анимация оркестрируется одним тикером GSAP поверх ASScroll — библиотеки плавного скролла. Форма мозга зашита в EXR-текстуру (это формат изображений с расширенным динамическим диапазоном), а шрифт — PP Neue Montreal от Pangram Pangram: свободен только для личного использования, коммерческая лицензия платная.
Гладко это не прошло: скрипт-экстрактор шейдеров с первого раза падал на экранировании бэкслешей внутри строковых литералов — пришлось писать ручной посимвольный парсер.
EXR-текстура с «формой» оказалась не одним облаком. Там четыре формы, упакованные в квадранты текстуры 200×200 — мозг, лампочка, глобус, абстрактная сеть, по 100×100 каждая. На первой попытке я вытащил все 40 000 точек скопом и получил кашу. Пришлось резать по квадрантам.
А Bokeh DoF перенести вообще не вышло. Ему нужен depth-буфер — карта глубины сцены, а мои частицы рисуются с depthTest:false и аддитивным блендингом, глубину пришлось бы подделывать.
Отдельная мелкая засада: локальный python http.server отдавал JS из кэша, и правки не были видны в браузере. Пока не поднял свой сервер с Cache-Control: no-store, легко было принять старый код за новый.
Вывод этого разбора: клонировать такой сайт в лоб — плохая идея. Чужой бренд и платный шрифт в проект не перенесёшь. Рабочий ход — вытащить рецепты: как устроен GPGPU, какие шейдеры дают свечение и глубину резкости, как сделан морфинг по скроллу — и доразогнать до этого уровня свой движок, тот самый brain.js с облаками частиц.
Отдельно про формы. Разбирая их EXR, я вытащил облака точек и на них отлаживал морфинг — это был самый быстрый способ понять механику. В прод такие данные не идут: чужой ассет остаётся чужим, и формы для своего лендинга надо строить из своих масок.
Путь первый: написать руками. «Точка сборки», ноль библиотек
На лендинге конференции я не подключил ни одной анимационной библиотеки: никаких GSAP, three.js, Lottie, AOS — в коде вообще нет ни одного <script src> на внешний движок. Весь визуал — ванильный WebGL и CSS. Файл sdvig-tilda-T123-v3.html весит 148 235 байт (1965 строк). Внутри: 14 CSS-@keyframes, 7 вызовов requestAnimationFrame, одна функция инициализации WebGL на два канваса, 2 IntersectionObserver, 10 карточек спикеров. Это не оценка на глаз — я посчитал grep'ом по одному файлу, потому что весь лендинг и есть один файл.
Главный эффект — WebGL-волна
Самый «дорогой» элемент — поверхность воды во весь экран на WebGL1. Интерактивная, с настоящим освещением, нарисована фрагментным шейдером примерно на 70 строк. Фрагментный шейдер — это программа, которая выполняется на видеокарте и считает цвет каждого пикселя отдельно; никакой геометрии воды здесь нет, вся вода живёт в математике.
Высота поверхности в каждой точке — сумма шести синусоид разной частоты и амплитуды:
float wave(vec2 p, float t){
float h=0.;
h+=.38*sin(p.x*1.7+p.y*.9+t*.6);
h+=.28*sin(p.x*.5-p.y*2.1+t*.4+1.5);
h+=.18*sin(p.x*3.0+p.y*1.4-t*.8+2.8);
h+=.10*sin(p.x*4.2-p.y*3.5+t*1.1+.7);
h+=.06*sin(p.x*6.5+p.y*5.0+t*.7-1.2);
h+=.04*sin(p.x*9.+p.y*7.-t*1.3+3.1);
return h;
}Крупные слагаемые с большой амплитудой дают медленную зыбь, мелкие и частые — рябь. Один синус — стиральная доска, шесть с подобранными коэффициентами — вода.
Чтобы поверхность ловила свет, ей нужна нормаль — вектор, перпендикулярный поверхности в данной точке, по нему считается угол падения света. Я беру её аналитически из градиента волны и считаю освещение по модели Блинна — Фонга: рассеянная составляющая плюс зеркальная.
float diff=max(dot(N,L),0.);
float spec=pow(max(dot(N,H2),0.),60.)*.4;Высокая степень в spec даёт узкий резкий блик — то самое глянцевое пятно, которое бежит по воде. Интерактив — гауссово возмущение от курсора прямо в шейдере (exp(-dist*dist*6.)), а позиция мыши догоняется с инерцией в JS-цикле (lerp 8% за кадр), иначе движение дёрганое.
Дёшево, но выглядит дорого
По моим ощущениям большую часть «дорогого» впечатления дают мелочи на 5–20 строк, где JS почти ничего не считает и отдаёт отрисовку GPU. Свечение за курсором: JS пишет две CSS-переменные, а radial-gradient с mix-blend-mode:screen рисует сам.
.hero-glow{position:absolute;inset:0;mix-blend-mode:screen;
background:radial-gradient(circle 600px at var(--mx) var(--my),
rgba(0,40,245,0.28), transparent 70%)}JS только догоняет координаты курсора с инерцией — стоимость для CPU почти нулевая.
3D-наклон карточек — requestAnimationFrame, который умеет останавливаться, когда наклон «успокоился», чтобы десять карточек не крутили цикл вхолостую:
rx+=(trx-rx)*0.12; ry+=(try_-ry)*0.12;
card.style.transform=`perspective(900px) rotateX(${rx.toFixed(2)}deg) rotateY(${ry.toFixed(2)}deg)`;
if(Math.abs(trx-rx)>0.01||Math.abs(try_-ry)>0.01){ raf=requestAnimationFrame(loop); }
else { raf=null; } // ← цикл засыпает, пока на карточку не наведут сноваSVG-волны-разделители — гладкая кривая через контрольные точки в серединах отрезков, без честного кубического безье. Хедер, перекрашивающийся под секцию через «пробу» цвета на высоте 36px. Всё это — десятки строк, ни одной зависимости.
Этот путь полностью под контролем: можно залезть внутрь шейдера и подвинуть частоту синусоиды. Цена — всё пишешь сам.

Путь второй: свой движок на Three.js. Школа и aimipt
На Школе Техпреда я выбрал противоположную стратегию: движок 3D-эффектов на Three.js, вынесенный отдельными файлами на S3 — brain.js, blocks3d.js, school-fx.js, school-boot.js. Сам Three.js лежит там же, рядом с движками.
Собрал я его не для МФТИ: движок родился раньше, на моём личном лендинге, где я гонял те же облака частиц, а на Школу переехал почти без изменений. Дальше он ушёл на aimipt.ru — префикс school- в именах файлов остался с того переезда. Оба сайта подключают ровно одни и те же файлы: поменялась фактически только маска — облако-мозг стало логотипом Физтеха.
Подключение устроено честно, с расчётом на сбой CDN:
<!-- three.min.js без defer: грузится и выполняется до остальных;
каждый движок проверяет typeof THREE и откатывается на 2D, если S3 упал -->
<script src="…/three.min.js"></script>
<script src="…/brain.js" defer></script>
<script src="…/blocks3d.js" defer></script>
<script src="…/school-fx.js" defer></script>
<script src="…/school-boot.js" defer></script>Пути в src я урезал до имён файлов. Важен здесь порядок подключения.
school-boot.js — это «клей»: движки сами себя инициализируют. brain.js при загрузке находит на странице все canvas[data-cloud-glyph] и оживляет каждый. Параметры эффекта живут прямо в разметке, в data-атрибутах, — настройка без правки кода.
Главный эффект — 3D-логотип Физтеха из спрайтов
Вот он, самый дорогой эффект всей тройки. В секции-манифесте справа висит логотип Физтеха, собранный из тысяч летающих частиц, которые вращаются, реагируют на курсор и переливаются. В разметке это один тег:
<canvas
data-cloud-glyph
data-cloud-shape="brainimg"
data-cloud-img="…/phystech_mask.png"
data-cloud-density="1.9"
data-cloud-depth="0.85"></canvas>Движок brain.js собирает облако в несколько шагов. Сначала рисует картинку-маску на скрытом 2D-canvas (480×440) и читает пиксели:
function glyphMask(spec,W,H,img){
const c=document.createElement('canvas'); c.width=W; c.height=H;
const x=c.getContext('2d');
if(spec==='brainimg'){ // картинка-логотип вписывается по центру
const sc=Math.min(W/img.naturalWidth, H/img.naturalHeight)*0.98;
const dw=img.naturalWidth*sc, dh=img.naturalHeight*sc;
x.drawImage(img,(W-dw)/2,(H-dh)/2,dw,dh);
}
return x.getImageData(0,0,W,H).data; // ← пиксели маски
}Из непрозрачных пикселей маски строятся 3D-координаты частиц: плоский силуэт логотипа получает объём по оси Z (data-cloud-depth="0.85" — почти полный объём). Количество частиц зависит от ширины экрана и плотности:
const dense=+(canvas.getAttribute('data-cloud-density')||1);
const target=Math.round((w<560?2600 : w<900?4200 : 5800)*dense);При density=1.9 из этой формулы выходит около 4900 точек на узком экране, 8000 на среднем и 11 000 на десктопе.
Дальше — две вещи, которые и делают эффект «дорогим». Во-первых, каждая частица рисуется спрайтом — маленькой картинкой-текстурой, натянутой на точку. Здесь это крошечный треугольник-вайрфрейм (в коде честный комментарий: «reads as a data/AI particle, not a water drop»). Во-вторых, это THREE.Points, и стандартный материал я потом дописываю шейдером, чтобы у каждой частицы были свой размер и своё мерцание:
geo=new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos,3));
geo.setAttribute('color', new THREE.BufferAttribute(col,3));
const mat=new THREE.PointsMaterial({
size:+(canvas.getAttribute('data-cloud-size')||0.05),
map:SPRITE, vertexColors:true, transparent:true,
sizeAttenuation:true, depthWrite:false, opacity:0.92
});Здесь pos и col — типизированные массивы координат и цветов, собранные на предыдущем шаге из маски, а SPRITE — та самая текстура треугольника.
Анимация — облако медленно крутится за курсором, а вблизи мыши частицы разлетаются и упруго возвращаются на свои места:
group.rotation.y = smx*0.5; // следование за курсором, со сглаживанием
group.rotation.x = -smy*0.38;
// упругий возврат каждой частицы к home-позиции:
const K=0.045, DAMP=0.865, R=0.30, PUSH=0.024;Чтобы тысячи одинаковых треугольников не выглядели мёртвой сеткой, у каждой частицы свой случайный «сид». Материал патчится через onBeforeCompile — это штатный хук Three.js, который даёт дописать код в уже готовый шейдер материала. В шейдер добавляются атрибуты asize/aseed и общий uTime, и точки еле заметно пульсируют вразнобой. А за самим логотипом крутится отдельный, более редкий слой фоновых частиц — он добавляет глубины, чтобы фигура не висела в пустоте.
Получается логотип, который держит форму, но при этом «дышит» и рассыпается под рукой. Тот же brain.js рисует и облако-мозг на первом экране — та же механика с другой маской.

Живая 3D-башня из кубов
Второй движок, blocks3d.js, — «living 3D knowledge tower»: башня из кубов 2×2, которая медленно вращается. Здесь уже честная 3D-геометрия на Three.js (BoxGeometry, Mesh). Ровно тот случай, ради которого стоило разбирать dayos.com.
Третий файл, school-fx.js, облаков не рисует. Это набор мелких улучшений, перекрашенных под cyan/navy бренд Школы: тот же 3D-наклон карточек к курсору и мягкое свечение за мышью, что и в ванильной ветке, только здесь они часть общего Three.js-набора.
Вместе это четыре файла, которые подключаются на любой сайт пятью тегами <script>.
Морфинг форм по скроллу — и почему я выключил bloom
Самый продвинутый сайт на этом движке — мой личный лендинг. Там тот же brain.js я довёл до morph-движка (morph.js, в комментарии честно: «built by taking brain.js AS-IS»). Облако частиц перетекает между формами по мере скролла: мозг, лампочка, глобус, компас.
Драйвер morph-boot.js на каждой секции скользит фигуру влево-вправо противоходом тексту, доворачивает её на 180 градусов и в конце «взрывает» частицы к центру экрана, чтобы собрать в следующую форму. Рядом — отдельный 3D-компас из частиц (compass3d.js) и текст, набранный теми же спрайтами-треугольниками (textsprites.js).
Компас дался дороже всего остального. Я сделал тринадцать итераций, пытаясь собрать его из частиц, и сдался. Движок отлично держит сплошные «блобы» — мозг, глобус, лампочку. А детальную графику (кольцо, розу ветров, метки сторон света) при таком размере замыливает в золотой шар, как ни крути плотность и глубину.
В итоге форму компаса я взял внешней PNG-маской, а частицами оставил только «живость» вокруг: ambient-поле, покачивание от курсора и стрелку, которая крутится за мышью, не выходя за круг. Урок тот же, что с EXR-формами: не всё стоит рисовать частицами.
Механику морфинга и «взрыва» частиц я подсмотрел как раз в разборе Dala — там в шейдере скорости был параметр силы взрыва на каждую частицу с каскадом «рябью от центра». Я перенёс идею (в коде даже осталась пометка Dala tParams3.x ∈ [1..6] — per-particle blast strength), но переписал под свой движок, который считает частицы на процессоре.
А вот один урок Dala я сознательно не взял. Попробовал постобработку — EffectComposer + UnrealBloom для свечения частиц, как на Dala. И выключил. Bloom давал «зефирную» дымку по всему кадру и убивал тёмное ядро облака. В коде так и осталось:
if(false && typeof THREE.EffectComposer!=='undefined' && !reduced){
// composer DISABLED: bloom was a permanent haze-glow AND killed the dim CORE layer.
// Direct render = crisp sprites.
}Прямой рендер оказался чётче.
Главный урок: WebGL нельзя отлаживать на глаз
Самым тяжёлым в этих движках оказалась отладка, а не математика. Сцену из частиц нельзя «посмотреть и понять, что не так»: глаз врёт, а скриншоты врут ещё сильнее.
Самый дорогой случай: фоновые частицы пропадали в одной из секций. Я потратил почти день, тыкая вслепую и несколько раз сломав фон по всему сайту. Спор о том, есть ли вообще баг, свёлся к «мы на разное смотрим» по скриншотам.
Перелом наступил, когда я перестал верить глазам и поставил объективные замеры. Сетка gl.readPixels по кадру — читаю реальные пиксели из фреймбуфера вместо разглядывания картинки. Подсчёт частиц проекцией в NDC — это нормализованные координаты устройства, куб от −1 до 1, в который попадает всё видимое; сколько точек попало в куб, столько на экране и есть. Плюс рендер слоёв по одному и бисекция материала: половину настроек выключил, смотрю, на какой половине пропадает.
Баг локализовался за минуты. Виноват был кастомный rotation-шейдер (onBeforeCompile), который убивал разреженные точки. На плотной форме со свечением поверх это было не видно — поэтому фоновый слой и «исчезал».
Таких ловушек «глаз врёт» было много. Свечение под курсором я сначала сделал как рост размера спрайтов — оказалось, нужно выталкивать частицы по оси Z. Отталкивание частиц от мыши вызывало резонанс и «чёрную дыру»-вихрь, пришлось заменить его сдвигом цели пружины по кругу.
Цветные ядра-спрайты на NormalBlending, обычном смешивании цветов, оказались невидимыми сами по себе — их «вытягивало» только аддитивное свечение поверх. Это надолго увело отладку по ложному следу. А мой процедурный мозг из 2D-PNG при развороте на 180 градусов показывал «ребро»: плоский силуэт выдавал себя. Помог только отказ от процедурности в пользу реальных объёмных форм, взятых из EXR-облака Dala как данные.
Отсюда три правила. Первое: для WebGL заводи инструментальные пробы (readPixels, счётчики частиц, послойный рендер) с самого начала — отладка «на глаз» сжигает дни. Второе: чужой 3D часто дешевле скопировать как данные, готовое облако точек, чем воспроизвести процедурно. Третье, прозаичное: headless-браузер на сцене в 10 000 частиц у меня стабильно падал, так что финальную проверку «живого» движения всё равно делаешь на реальной машине с дискретной видеокартой, а не в CI.
За время этой возни у меня накопились десятки именованных снапшотов good-state-NN — единственный способ не потерять рабочее состояние, когда каждая третья правка что-нибудь ломает.
Деградация заложена заранее
Важное в этом пути — он не падает, если Three.js не загрузился. Каждый движок проверяет typeof THREE, и при сбое CDN страница откатывается на инлайновый 2D-canvas: то же поле частиц, нарисованное обычным getContext('2d') без библиотеки. Семь волновых линий из точек, которые у курсора рассыпаются в яркие «атомы» и пружинят обратно:
const LINES=7, R=92, K=0.072, DAMP=0.84;
function homeY(x,i,phase){
const cy=h*0.40, amp=h*0.16, spread=h*0.028;
return cy + Math.sin((x/w)*5+phase)*amp*Math.cos(phase*0.6)
+ (i-(LINES-1)/2)*spread;
}На хорошем канале пользователь видит 3D-облако на Three.js, на плохом (или со старым WebGL) — лёгкую 2D-анимацию вместо пустоты.

Один движок — несколько сайтов
Вайбкод окупается на дистанции — когда из разовой страницы вырастает переиспользуемый движок. Оба пути это показывают, просто по-разному.
В ванильной ветке («Точка сборки») волна — это параметризованная функция инициализации (setupWave3D). Чтобы пересобрать первый экран под другую палитру, я подаю в неё четыре цветовых вектора и не трогаю шейдер. Сам лендинг прошёл восемь версий: первая страница весила 44 КБ, финальная — 148 КБ. Вес вырос втрое с лишним, и почти весь прирост — код эффектов.
В Three.js-ветке движок физически вынесен в отдельные файлы на S3, и Школа с aimipt.ru подключают одни и те же. Поменять облако-логотип на другой бренд — это сменить data-cloud-img в разметке, код движка не трогается вовсе.
Переиспользование разрослось дальше двух сайтов: тот же brain.js крутится ещё на главной магистратуры techpred.online, на лендинге школы, в квизе и на конкурсе эссе. Везде один движок, разная только маска.
На главной магистратуры он лепит из частиц логотип Физтеха (phystech_mask.png); маски лежат в одном бакете и меняются одним атрибутом. Итого один Three.js-движок обслуживает полдюжины лендингов.
Ещё один приём: рябь по живому фото
Не все «дорогие» эффекты — это частицы и процедурная вода. Иногда нужно оживить обычную фотографию. На моём лендинге про релокацию в Бразилию (тоже Tilda) первый экран — аэрофото моста над океаном, по которому от курсора расходится рябь, как по воде.
Сделано снова на голом WebGL, без библиотек. Canvas грузит фотографию как текстуру, а фрагментный шейдер читает её и искажает UV-координаты — это координаты точки внутри текстуры, сдвинул их, и картинка «поехала». Рябь радиально расходится от каждого движения мыши и затухает примерно за полторы секунды. Сам шейдер я перенёс из другого своего лендинга — ещё один пример переносимого эффекта.
// рябь подмешивает акцентный цвет в пикселях гребня волны
baseColor.rgb = mix(baseColor.rgb, accent, clamp(waveIntensity, 0.0, 0.32));
gl_FragColor = baseColor;Деградация честная: если WebGL недоступен или включён prefers-reduced-motion, canvas прячется и остаётся обычный CSS-фон с фотографией.

Бонус: вайбкод хорош в одноразовых утилитах
Отдельная боль на курсе была в данных, а не в вебе, — фотографии преподавателей. Снимки с разными фонами, а на лендинге им всем нужно быть на едином тёмно-бирюзовом градиенте. Открывать ради этого Photoshop и руками вырезать каждого — долго, поэтому я написал утилиту.
Точнее, восемь версий утилиты — итерации одного подхода: от наивного порога по RGB к библиотеке rembg, которая вырезает фон нейросетью, и дальше к гибриду из HSV-масок и математического круга, сложенных по AND.
Самым неочевидным оказался кадр со светлыми волосами на жёлтом фоне: наивная маска по цвету срезала вместе с фоном и волосы. Спасает порог по насыщенности — фон насыщенно-жёлтый, а блонд нет (hue, sat, val здесь — каналы HSV, посчитанные по всему массиву пикселей):
# жёлтый фон: H 20-58, S>0.55 (волосы блонд S≈0.3-0.5 — не задеваются)
yellow_bg = (hue>20)&(hue<58)&(sat>0.55)&(val>0.55)Восемь версий скрипта под конкретные фото по ощущениям вышли быстрее, чем возня с пятью разными фонами вручную; секундомер я не включал. Зато утилита воспроизводимая: правки видно в коде, а не в духе «я там что-то поводил лассо».

Как это вообще вайбкодилось
Код — половина дела. Вторая — как ставится задача, когда ТЗ нет, а есть живой процесс с созвонами.
Правки на лендинг «Точки сборки» мне не присылали документом. Присылали транскрипты созвонов оргкомитета. Я отдавал их Claude Code, и он сам извлекал из них список правок с приоритетами P0/P1/P2: больше сотни пунктов, каждый с временной меткой цитаты.
Один момент особенно показателен. Лендинг показывал старт в 11:00, а в транскрипте звучало «с 10 утра». Claude не стал молча выбирать одно из значений — он зафиксировал это как P0-блокер с формулировкой «не запускать промо с расхождением» и эскалировал. Конфликт данных нельзя «решить» угадыванием: правильное время знают только организаторы.
Однажды при сборке карточек спикеров роль одного слиплась с био другого без разделителя — классический баг конкатенации, на странице выглядевший абракадаброй в подписи.
Отдельно мучительным был нейминг: рабочее «Сдвиг» (отсюда и sdvig- в имени файла) в финале стало «Точкой сборки». Каждое переименование — это правка по всему проекту: ещё один довод держать тексты в данных, а не вшивать в разметку.
А моя постоянная инструкция в работе звучала просто: «открой в Playwright и проверь, что всё так, как задумано». После каждой крупной правки — реальный прогон в браузере, а не «по коду должно работать».
Грабли
Дальше — только то, что реально ломалось.
CORS с двойным заголовком — форма «работает», регистраций ноль
Самая дорогая грабля из всех: после деплоя форма регистрации выглядела рабочей — поля на месте, кнопка жмётся, отправка проходит. А в базе — ноль.
Запрос падал с Access-Control-Allow-Origin: , — заголовок пришёл дважды. Это заголовок CORS, механизма, которым сервер разрешает браузеру принимать ответ с другого домена. Его выставляли одновременно CORSMiddleware в FastAPI и add_header в nginx. На дублирующемся заголовке браузер отбраковывал предварительный запрос OPTIONS — и основной POST до бэка просто не уходил.
Лечение — убрать add_header из nginx и оставить CORS в одном месте. Урок: «визуально работает» — это ещё не «работает», и прогон в Playwright не отменяет проверки всего пути целиком: форма отправила, бэк принял, запись легла в базу.
Tilda T123 ломает вложенность DOM
Лендинги встраиваются в Tilda через блок T123 — произвольный HTML. Tilda оборачивает контент своими обёртками и ломает ожидаемую вложенность DOM. Селекторы, завязанные на структуру «потомок такого-то wrapper по id», перестают попадать.
Пришлось писать плоский CSS: каждый класс адресуется сам по себе, без цепочек вложенности.
Залить готовое в Tilda — отдельный квест
Сам деплой в Tilda оказался отдельным источником боли. Кастомный HTML живёт в ACE — это встроенный в браузер редактор кода, тот же движок, что в облачных IDE. И editor.setValue() молча не сохранялся: Tilda пишет из скрытой textarea[name="code"], которую надо синхронизировать руками и дёрнуть события input/change.
Скрипт заливки переписывался раз пять, пока не сложилась рабочая связка: setValue + session.setValue + ручной sync textarea + dispatch. В коде так и осталась пометка // CRITICAL: sync textarea. Отдельный лимит — у меня payload больше ~50 КБ не проходил строкой в evaluate, пришлось грузить через fetch.
Сверху Tilda глобальным правилом перекрашивает все ссылки в свой оранжевый, так что каждый цвет ссылки пришлось ставить с !important. И после «успешной» публикации результату нельзя доверять из-за кэша. Выработался ритуал: дёрнуть live с ?nocache=<timestamp> и побайтово сверить, что на странице действительно новый код.
А один раз браузерная автоматизация, через которую шла публикация, упала прямо посреди деплоя — пришлось переключаться на другой инструмент и доводить вручную.
Маски частиц — только со своего CORS-домена
Движок облаков читает пиксели PNG-маски через getImageData, чтобы разложить частицы по форме. А drawImage картинки с чужого домена без CORS «пачкает» canvas, и следующий за ним getImageData падает с SecurityError. Поэтому все маски, шейдеры и JS-движки пришлось держать на своём S3 с правильными CORS-заголовками и грузить картинки с crossOrigin='anonymous'. Фото спикеров можно оставить на CDN конструктора, а вот всё, что движок читает попиксельно, — нет.
v5 «как у Stripe» — красивый прототип, который не доехал
Стиль Stripe я приносил из DESIGN.md ещё в мокап, и одна из версий лендинга — v5 «Stripe DS» — была собрана прямо под него. Свой mesh-gradient в стиле Stripe: переливающийся цветной фон, где цвета задаются сеткой точек. Отдельный stripe-gradient.js, 113 строк, шум value-noise считается в вершинном шейдере.
Технически работало и выглядело модно. Я от неё отказался в пользу 3D-волны с честным освещением — она зрелищнее. Выброшенный рабочий вариант — нормальная часть процесса.
Деградация длинной сессии
К концу длинной сессии в Claude Code я несколько раз сломал синтаксис вызовов инструментов. Классика: контекст копится, и tool-calls начинают ломаться. Практический вывод — резать задачу на сессии и фиксировать промежуточные артефакты на диск, а не продавливать одну гигантскую сессию.
Производительность и доступность
Тяжёлая графика на каждом первом экране — это нагрузка, и её нельзя оставлять включённой всем подряд. Сразу оговорюсь: профилирования по устройствам и замеров FPS я не делал. Ниже то, что заложено в код, а не результаты измерений.
prefers-reduced-motion гасит всё тяжёлое в обоих путях. На «Точке сборки» один media-query убирает WebGL-canvas и обнуляет анимации. В Three.js-ветке тот же флаг останавливает облака частиц. Для людей с вестибулярной чувствительностью бегущая вода и летающие частицы — реальный дискомфорт, так что это вопрос доступности.
Остальное по мелочи, но важное:
Плотность пикселей ограничена сверху. Math.min(devicePixelRatio, 2): на экране с DPR 3 рендер идёт максимум в 2x. Для глаза почти незаметно, а пикселей для GPU в 2,25 раза меньше — площадь растёт как квадрат.
Все scroll/touch-слушатели — {passive:true}, чтобы не тормозить прокрутку.
IntersectionObserver вместо скролл-спама, с unobserve() после срабатывания.
Обфускация почты. Адрес в подвале собирается из data-атрибутов через String.fromCharCode(64) — скраперы статичного HTML его не находят.
Чек-лист: собрать такой сайт на вайбкоде
Сначала вкус, потом код. Держи стиль в структуре — DESIGN.md с токенами, шкалой типографики, компонентами и Do/Don't. Агенту нужен блюпринт, словесное описание он трактует как хочет.
Мокап отдельно, эффекты отдельно. Композицию и палитру быстрее собрать в Claude Design и забрать handoff-бандлом; интерактивный слой навешивается уже в коде, поверх.
Бандл — это референс, а не production-код. Читай главный HTML целиком, вытаскивай токены, пересобирай визуал в идиомах своего стека. Структуру прототипа не копируй.
Препарируй референс, не угадывай. Сними живой сайт через Playwright и проверь canvases/three: может оказаться, что «3D» — это webm, и шейдер не нужен.
Выбирай путь осознанно. Один уникальный эффект на первом экране можно написать руками без библиотек. Несколько 3D-сцен, которые переезжают между сайтами, дешевле собрать движком на Three.js и вынести в отдельные файлы.
Тяжёлый эффект обязан уметь упрощаться. typeof THREE guard + 2D-fallback, prefers-reduced-motion, ограничение DPR. Это часть каркаса, а не финальная полировка.
Проверяй сквозной путь, а не картинку. «Форма показала спасибо» не равно «запись в базе». Дойди до бэка.
Почему я выложил наработки
Я выложил это в открытый доступ. Вокруг Claude Code растёт движение ai-skills — переиспользуемые навыки и каркасы, которые не пишешь заново, а забираешь готовыми: репозиторий letta-ai/skills и теги ai-skills / ai-agents на GitHub.
Свой набор я собрал в плагин-маркетплейс для Claude Code. По этой статье там полезен скилл claude-design — тот самый разобранный по шагам workflow оркестрации Claude Design через Playwright, со структурой handoff-бандла и честной остановкой на Turnstile. Каркас с самими эффектами — волна, наклон карточек, свечение под курсором — пока лежит локально: слишком много завязок на конкретные проекты. Выложу, когда вычищу.
Выводы
Главное, что я хотел показать: «дорогой» визуал у меня свёлся к двум конкретным вещам. Либо ~70 строк GLSL руками, либо свой движок на Three.js с рабочим fallback. Оба варианта видно в коде, оба чинятся построчно, оба переезжают с сайта на сайт.
Собрать сайт агентом — частный случай большого сюжета: как ИИ доезжает до реальных процессов в компаниях.
16 сентября с 10:00 до 16:00 (МСК) мы проводим вторую онлайн-конференцию «ИИ-Трансформация» — про российские и международные кейсы внедрения ИИ в бизнес-процессы. Выступят спикеры из Skyeng, mymeet и других компаний.
В апреле на первой конференции собралось больше 1150 участников: CEO, CTO и HRD разбирали реальные внедрения — от ИИ-агентов в бэк-офисе до расчёта окупаемости. Записи выступлений и саммари с трендами лежат в нашем боте — можно посмотреть уже сейчас.
Участвовать можно бесплатно, регистрация здесь.
P.S. Эти лендинги — побочный продукт: основное, чем я занимаюсь, — продукт AlpinaGPT в Альпине, и вайбкод-сборка сайтов выросла как раз из ежедневной работы с агентами там.
P.P.S. Ещё больше кейсов и разборов внедрений ИИ — в Telegram-канале «Дело в промпте». Заходите, если хочется быть в курсе.