← Все заметки
// КОНСПЕКТ ЗАМЕТКИ // СИСТЕМЫ · КОНСПЕКТ НА 30 СЕК · ПОЛНЫЙ ТЕКСТ 5 МИН

Я обошёлся без Three.js в хиро. WebGL-разработчик нашёл баг одной фразой.

Покадровая анимация на 120 Гц идёт вдвое быстрее; для easing нужно 1 − (1 − rate)^frames, а не rate × dt.

“Агент отлично делает код корректным. А правильный ли он, как ощущается и зачем нужен, видит тот, кто смотрит на живую вещь.”

  • Топология в хиро это Canvas 2D, ~20 строк перспективной проекции и сортировка по глубине алгоритмом художника: без Three.js и WebGL.
  • Всё двигалось покадрово, поэтому на экране 120 Гц сцена шла вдвое быстрее. WebGL-разработчик увидел это с одного прочтения.
  • Линейное движение умножаем на прошедшие кадры, для easing берём 1 − (1 − rate)^frames, для затухания factor^frames, и ограничиваем шаг.

Я выкатил личный сайт с 3D-топологией архитектуры в хиро: три палубы систем, пакеты бегают между нодами, сбои можно вызвать руками. Без Three.js. Без WebGL. Только Canvas 2D и примерно 20 строк собственной перспективной проекции.

Потом я законнектился с разработчиком, который пишет на WebGL за деньги, и задал очевидный вопрос: взял бы он тут Three.js?

Он реально открыл мой код. Ответ был «нет, Canvas 2D для такой сцены вполне разумный выбор». А потом, почти между делом: анимацию стоит перевести на delta-time, чтобы темп не зависел от частоты экрана.

Я об этом вообще не думал. Всё считалось покадрово. На экране 120 Гц моя «спокойная кинематографичная» сцена шла ровно в два раза быстрее.

Почему не Three.js

Сцена это фиксированный набор коробок. Ноды это выдавленные прямоугольники, стены это четырёхугольники, пакеты это точки, которые едут по линиям. Никаких мешей из файлов, материалов, света и теней.

Для такого Three.js это слишком много библиотеки для первого экрана портфолио. На самом деле мне нужно было вот это:

function project3D(wx, wy, wz) {
  const dx = wx - camFocusX, dy = wy - camFocusY, dz = wz - camFocusZ;
  const rx = dx * Math.cos(camYaw) - dz * Math.sin(camYaw);
  const rz = dx * Math.sin(camYaw) + dz * Math.cos(camYaw);
  const ry = dy * Math.cos(camPitch) - rz * Math.sin(camPitch);
  const depth = -(dy * Math.sin(camPitch) + rz * Math.cos(camPitch));
  const persp = (1100 / Math.max(340, 1100 + depth)) * camZoom;
  return { x: width * 0.5 + rx * persp, y: height * 0.54 - ry * persp, depth, scale: persp };
}

Повернуть по yaw, повернуть по pitch, поделить на расстояние. Math.max(340, …) не даёт объектам взорваться, когда они подлетают слишком близко к камере.

Z-буфера тоже нет. Это алгоритм художника: спроецировать всё, отсортировать стены и ноды по глубине, рисовать от дальнего к ближнему. На паре десятков объектов сортировка каждый кадр ничего не стоит.

Движок вырос примерно до 1 500 строк, и большая часть это то, что делает его сайтом, а не демкой: hover и фокус, навигация с клавиатуры, режим истории с гидом, симуляция сбоев, reduced motion.

Баг: 0.12 это не скорость

Вот как выглядел easing камеры:

camYaw += (targetYaw - camYaw) * 0.12;

Каждый кадр закрываем 12% оставшегося расстояния. На 60 Гц ощущается отлично.

Но «каждый кадр» значит, что скорость решает монитор. На 120 Гц эти 12% закрываются вдвое чаще, и камера приезжает вдвое быстрее. С остальным то же самое: пакеты двигались на += 0.015 за кадр, сбой прогрессировал на += 0.0042 за кадр, вспышки гасли на *= 0.9 за кадр. На 120 Гц вся сцена была как после трёх эспрессо.

Ленивый фикс это «умножить на dt». Для линейного движения это правильно. Для easing нет: 0.12 * 2 это не то, что делают два кадра easing по 12%.

После двух кадров по 12% остаётся 0.88 × 0.88 = 0.7744 расстояния, то есть закрыто 22,56%, а не 24%. Правильно так:

// Animation constants were tuned at 60 fps. `frames` is how many 60 fps frames actually elapsed.
const frames = Math.min((ts - lastFrameTs) / (1000 / 60), 4);
const ease = (rate, frames) => 1 - Math.pow(1 - rate, frames);

camYaw += (targetYaw - camYaw) * ease(0.12, frames);  // exponential easing
flash *= Math.pow(0.9, frames);                       // exponential decay
packet.t += packet.speed * frames;                    // linear motion: just scale

Три правила:

  • Линейное движение: умножаем на прошедшее время.
  • Экспоненциальный easing: 1 - (1 - rate)^frames.
  • Затухание: factor^frames.

И frames надо ограничить, здесь четырьмя. Иначе вкладка, вернувшаяся из фона, получит один гигантский шаг, и всё телепортируется.

Константы я специально оставил в «кадрах при 60 fps». Движение подбиралось на глаз на 60 Гц, так что числа значат «как это ощущалось тогда», а формула сохраняет это ощущение на любом экране.

Скучные вещи, которые важнее

Анимация в хиро это первое, что есть на странице, а значит, первое, что может сделать страницу плохой.

  • Её нет в первой загрузке. Движок это ленивый чанк. Когда я вынес его из стартового бандла, стартовый JS страницы упал с 20,9 KB до 2,8 KB.
  • Она останавливается, когда её не видно. IntersectionObserver и document.hidden останавливают цикл за пределами экрана и в фоновой вкладке, а lastFrameTs сбрасывается, так что первый кадр после возвращения не делает гигантский шаг.
  • Она останавливается, когда ничего не движется. С prefers-reduced-motion цикл крутится, только пока каждое значение не подойдёт к цели ближе чем на 0.001, а потом оставляет последний кадр на canvas, пока вы что-нибудь не тронете.

Perf-гейт в CI гоняет Lighthouse на мобильных и десктопе на каждом PR, так что хиро не может тихо потяжелеть.

Что сделал агент, а что нет

Большую часть движка я собрал с coding-агентом за два вечера. Логика давалась ему легко: проекция, сортировка, hit-testing, клавиатура, тесты.

Чего он не мог, так это посмотреть на экран и сказать «это выглядит нервно» или «камера приезжает слишком резво». Каждую константу в этом easing подбирал я, щурясь на canvas. Баг с 120 Гц агент тоже не поймал, и я не поймал, потому что мы оба смотрели на 60 Гц. Понадобился человек, который читает WebGL-код профессионально, чтобы увидеть это с одного прочтения.

Это то, что я заново выучиваю на работе: агент отлично делает код корректным. А правильный ли он, как он ощущается и зачем он вообще нужен, по-прежнему видит тот, кто смотрит на живую вещь.

Так правильно ли было обойтись без Three.js? Для фиксированной сцены из коробок я всё ещё думаю, что да. Спросите меня снова, когда мне понадобится свет.

Четыре дня спустя

Я писал это в день запуска сайта. С тех пор было около пятидесяти PR: движок всё тот же файл, но показывает он другое.

  • Сцена начинается с реального прогона. Теперь она по шагам повторяет настоящий прогон агентов в этом репозитории: PR #11, тот самый, в котором агенты собрали самозаживающую монограмму GR. Дальше управление можно забрать себе: внести сбой, выключить судью и смотреть, как политика примерно из тысячи обученных параметров пытается удержать игрушечный сервис. С выключенным судьёй плохой деплой всё равно уезжает.
  • Свет я всё-таки добавил, только не в сцену. Жёсткие тени на остальном сайте теперь падают от вашего курсора, а на Android зависят от наклона телефона. Это две CSS-переменные, которые пишутся раз за кадр. Three.js по-прежнему нет.
  • Бюджет выдержал. После этого поста у сайта появились офлайн-режим, переходы между страницами без перезагрузки, переключение языка, которое перестраивает страницу на месте, и ещё с десяток мелочей. Собственный стартовый JS страницы всё ещё меньше 8 KB, а движок по-прежнему ленивый чанк.

Покрутите её сами: rossev.dev. Движок лежит в src/scripts/iso-engine.ts в репозитории.

#Canvas 2D#Animation
СКОПИРОВАНО