Кинематограф частиц без зависимостей
Двадцать лет я строил работу, которой никто не видит. Это — видимая её часть, разобранная с той же дисциплиной.
У инфраструктурной работы странная эстетика: сделанную хорошо, её никто не замечает. Резервное копирование двадцать лет работает тихо, а потом однажды не работает — и в этот день все узнают, что оно существует. Такова мера невидимой работы: её судят только тогда, когда она отказывает.
Этот сайт — попытка вывести эту привычку на поверхность. Дальше не украшение, а разобранная вещь: что здесь есть, почему оно здесь и чего это стоит.
Сначала ограничение
Всё определило одно правило: никаких внешних запросов.
Это не лозунг, это заголовок ответа. Политика безопасности сайта выглядит так:
Content-Security-Policy: default-src 'self'; script-src 'self';
style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:;
connect-src 'self'; object-src 'none'; base-uri 'self';
form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests
В тот момент, когда вы пишете default-src 'self', ваши варианты закрываются. Ни GSAP, ни three.js, ни Lenis — ни одного шрифта из CDN. Нужна библиотека анимации — напишете. Нужно сглаживание прокрутки — напишете. Нужен движок частиц — да, и его тоже.
Однажды правило неожиданно доказало себя само. Cloudflare внедряет собственный аналитический скрипт на пограничном узле. Политика сайта заблокировала его:
Loading the script 'https://static.cloudflareinsights.com/beacon.min.js/…'
violates the following Content Security Policy directive: "script-src 'self'"
На локальном сервере Lighthouse даёт Best Practices 100; на живом — 93. Эти семь баллов и есть ровно это. Ослабить политику было бы легко. Вместо этого отключили сам скрипт — потому что правило что-то значит лишь тогда, когда действует и в неудобный момент.
Где живут частицы
Всё движение на странице рисуется на одном <canvas> через WebGL2. Позиции и скорости частиц не хранятся на CPU; они живут в двух текстурах, и каждый кадр фрагментный шейдер продвигает их. Результат пишется в текстуру, а в следующем кадре эта текстура становится входом. Старейший приём GPGPU: считать из текстуры в текстуру, минуя экран.
На практике это значит, что количество частиц не касается CPU. На настольном компьютере поле работает в полной плотности, на телефоне опускается до 12 100 частиц — но в обоих случаях работа на кадр это один и тот же вызов шейдера.
Держать две текстуры — маленькая, но несущая деталь. Нельзя читать и писать одну текстуру за один проход; это неопределённое поведение. Поэтому два буфера чередуются: в этом кадре читается A и пишется B, в следующем роли меняются. Этот «пинг-понг» — базовая гигиена GPGPU.
Цвет считается отдельно. Сцена рисуется в HDR, яркие области выделяются и размываются (избирательное свечение), затем кривая ACES возвращает всё в отображаемый диапазон. Эти три шага предотвращают самую лёгкую ошибку аддитивного рисования — выгорание всего в белое. Набор проверок измеряет именно это: пиковая яркость кадра должна оставаться ниже порога.
Различия устройств решает лестница из четырёх ступеней:
| Ступень | Что отключается |
|---|---|
| 0 | Ничего — полное кино |
| 1 | Нет zoom-blur, свечение в два прохода |
| 2 | Число частиц падает до 55% |
| 3 | Запасной путь WebGL1 — GPGPU выключен |
Ступень 3 важна: в браузере без WebGL2 страница не перестаёт работать, она лишь теряет слой симуляции. Текст, навигация, контакты — всё на месте.
Восемь языков, один источник истины
Сайт выходит на восьми языках: турецкий в корне, остальные семь в своих каталогах. Но восемь страниц не пишутся вручную.
Турецкая страница — мастер. Каждый переводимый узел несёт метку:
<span data-i="about.e1t">Kurumsal altyapı</span>
Генератор читает эти метки и извлекает i18n/tr.json — сейчас 102 ключа. Остальные семь языков заполняют те же ключи своим текстом, и генератор печатает страницы. Переводимые поля JSON-LD идут по тому же конвейеру: должность, город, области экспертизы, описание услуги.
Самое жёсткое правило этой схемы: отсутствующий ключ останавливает сборку. Никаких запасных вариантов, никакого отката к английскому. Если один язык отстал — не выходит ничего. Лучше остановиться, чем опубликовать страницу, тихо переведённую наполовину.
То, что турецкая страница не генерируется, тоже намеренно. Мастер пишется вручную, потому что несёт комментарии измерений, поля масок и заметки об оптическом выравнивании — перенеси их внутрь генератора, и они станут нечитаемыми.
Результат измерим: между восемью страницами связи hreflang взаимны в 64 направлениях из 64, а x-default верен на всех восьми.
Шрифты — самая тяжёлая часть
Вся страница — 172 КБ в одиннадцати ресурсах. Разбивка удивляет:
| Статья | Вес |
|---|---|
| Шрифты (woff2) | 116 КБ |
| JavaScript | 56 КБ |
| Изображения | 0 КБ |
| CSS | 0 КБ — встроен |
На сайте нет ни одного <img>. Вся визуальная нагрузка — canvas и SVG. Против этого пятнадцать файлов woff2: четыре семейства для латиницы и отдельные замены для кириллицы. Кириллические файлы привязаны к тем же именам семейств через unicode-range — русская страница никогда не скачивает латинский файл, браузер выбирает сам.
Объявления @font-face встроены прямо в HTML. Это измеренное решение: отдельная таблица стилей была единственным ресурсом, блокирующим отрисовку, и встраивание подняло мобильный балл Lighthouse с 99 до 100.
Реальная передача после сжатия:
| Ресурс | Сырой | Brotli | Доля |
|---|---|---|---|
| HTML | 87 890 Б | 20 675 Б | 76,5% |
app.js | 186 011 Б | 55 354 Б | 70,2% |
Без измерения ничего не называется «готовым»
В репозитории два набора проверок: 72 теста на физику сцены и 100 на перевод и слой обнаружения. Оба работают в настоящем браузере, и ни один не смотрит на снимок экрана — они читают яркость с canvas, опрашивают каналы состояния и проверяют пороги числом.
Причина проста: визуальная проверка лжёт. Панель может прокрутиться сама, вкладка уйти в фон, requestAnimationFrame мог ни разу не сработать. Снимок говорит «работает», когда не началось ничего.
Как лжёт измерение
Больше всего времени здесь занял не код. Оно ушло на то, чтобы научиться не доверять самому измерению.
Пример. Страница открылась в браузере автоматизации, и содержимое семьдесят секунд оставалось невидимым. Чёрный экран, вступительный занавес замер. Очевидное прочтение: страница зависла.
Прежде чем сделать этот вывод, измерили три вещи:
document.visibilityState → "hidden"
кадров rAF за 2 секунды → 0
прогресс вступления → 0
Вкладка была в фоне. requestAnimationFrame не срабатывает в фоновых вкладках; последовательность вообще не начиналась. Ошибки на странице не было — ошибка была в среде измерения.
До попадания в отчёт в качестве «находки» оставался один шаг. В репозитории на этот счёт есть записанное правило: не называй сделанным без измерения — и измеряй то, что тебе показалось. Снимок экрана не доказательство; доказательство — яркость, считанная с canvas, или сам канал состояния.
Две тихие поломки
Самое интересное, что вскрывает аудит, — то, чего он не искал.
Первое: поле lastmod в карте сайта читало дату из предыдущей карты сайта. Генератор брал собственный вывод как вход. Однажды записанное значение никогда бы не сдвинулось — как бы ни менялось содержимое, файл вечно показывал бы один и тот же день.
Замерший сигнал свежести превращается в ложь. Устаревший lastmod хуже, чем его отсутствие: он говорит поисковому роботу, что страница не меняется. Теперь дата живёт ровно в одном месте, а карта сайта следует за ней.
Второе: список переводимых полей JSON-LD был продублирован в генераторе в двух местах — одно для извлечения, другое для записи. Что будет, если новое поле добавить лишь в одно? Поле тихо не переведётся. Турецкий текст дословно скопируется в структурированные данные семи языковых страниц, и ни один из ста тестов этого не заметит.
Это была ловушка, ломающая ровно то, что должна была починить: тот, кто добавит недостающее поле description, незаметно написал бы турецкий на семи языках. Теперь список — единственный источник.
Общий урок: самые опасные ошибки системы — те, при которых не падает ни один тест.
Отвергнутый трюк
Самая честная строка этого проекта не в отчёте об ошибке. Она в записи решения.
На мобильных Lighthouse не может выдать для главной страницы значение Largest Contentful Paint. Причина — вступительная последовательность: четыре секунды занавеса, затем замирание на контакте, затем вспышка, затем чернильный переход. До этого ни один текст DOM не виден, а Lighthouse закрывает трассировку гораздо раньше. Итог: NO_LCP — метрика не вычисляется.
Исправление измерили. Если начальную непрозрачность слоя содержимого поставить 0.004 вместо 0, LCP падает с 9 052 мс до 164 мс, и полный заголовок помечается как кандидат LCP. Визуальной цены нет: непрозрачность 0,004 даёт максимум 1/255 единицы, то есть ничего.
Именно поэтому его отвергли.
LCP определён как самый крупный текстовый блок, видимый в области просмотра. Заголовок с непрозрачностью 0,004 не виден. Исключение opacity: 0 в Chromium существует, чтобы блокировать ровно этот приём; 0,004 проскальзывает по его краю. Число стало бы лучше, правда — нет: посетитель по-прежнему увидел бы заголовок через девять секунд.
В документацию решение вошло так: не возвращайте этот приём.
Почему эта страница другая
Страница, которую вы читаете, не загружает описанный выше движок. Поле частиц за ней — отдельный, очень маленький скрипт; текст полностью виден на первой отрисовке и не ждёт ни за каким затвором opacity.
Причина — предыдущий раздел. Текст существует, чтобы его читали и цитировали. Кинематографическое вступление — подпись главной страницы, но на текстовой странице та же последовательность задержала бы не только измерение, но и чтение. Для этого раздела записан бюджет: LCP менее 2,5 секунды, JavaScript страницы менее 60 КБ, никакого затвора непрозрачности на содержимом — и набор проверок сверяет его при каждом запуске.
Та же дисциплина, другой результат. Если эстетика невидимой работы в том, что она никогда не отказывает, то эстетика видимой работы в том, что её можно измерить.