Уже год мы небольшой командой пишем на Rust + wgpu редактор топологий интегральных схем — что-то вроде KLayout, только с прицелом на российский рынок. Команда — три человека. Я в роли CTO направляю архитектуру и принимаю основные технические решения. История ниже — про одну такую серию решений, которую я завёл в тупик четыре раза подряд, прежде чем мы поняли, в чём была ошибка.
Тестовый дизайн у нас — Caravel SkyWater SKY130, открытый чип на ~4,4 миллиона полигонов, 1014 уникальных ячеек и 22 уровня иерархии. Полный GDS-файл — 278 МБ.
Первая попытка отрендерить это на экране показала: всё работает, всё на месте. Только мерцает. Не «иногда подёргивается» — а так, что смотреть невозможно. Любой zoom или pan превращал картинку в стробоскоп: половина чипа есть, половина пропала, через кадр — наоборот.
Дальше — история про то, как мы четыре раза по-разному пытались это починить, и как пятая попытка наконец заработала. Если у кого-то такой же случай — может, сэкономлю недели две.
Почему вообще мерцало
Изначальная архитектура была такая: на каждом кадре мы рекурсивно обходили иерархию ячеек, для каждой видимой ячейки разворачивали её инстансы в массив прямоугольников и загружали этот массив на GPU как instance buffer.
Сцена слишком большая, чтобы держать всё на GPU целиком, поэтому буфер перестраивался каждый кадр исходя из текущей камеры. Видимый bounding box, скрытые ячейки отсекаются, остальное — на видеокарту.
В среднем кадр выходил приличный — порядка 800 тысяч элементов после culling, 10–15 миллисекунд на CPU-side rebuild. Проблема в том, что во время этих 10 мс GPU держится за старый буфер. На следующем кадре буфер вдруг другой, и геометрия скачет. Чем быстрее ты двигаешь камеру, тем хуже — потому что cache-friendly путей через дерево становится меньше.
Это была моя первая архитектурная ошибка, которую я тогда ещё не понимал: мы искали, как быстрее перестраивать буфер. А надо было искать, как не перестраивать его вообще.
Попытка №1: density rendering
Идея. Если для ячейки на текущем зуме её площадь на экране меньше, скажем, 4×4 пикселя — какой смысл разворачивать её в тысячи прямоугольников? Нарисуем её как один цветной квадрат с alpha, пропорциональной плотности элементов внутри. На дальних zoom-уровнях получим карту плотности вместо детальной геометрии, на близких — обычный рендер.
Звучит разумно. На практике через неделю мы поняли, что:
-
Плотность нужно считать. Считаем мы её по объёму геометрии в ячейке — но число элементов не равно «видимой плотности». Внутри ячейки могут быть тонкие линии, занимающие 90% площади. Получаются тёмные блоки, где должна быть прозрачная решётка.
-
Плотность нужно где-то хранить. Попробовали прикрепить её к ячейке как pre-computed свойство. Но плотность зависит от того, какие слои включены — а это меняется. То есть пересчитывать всё равно надо при любом изменении layer visibility.
-
Самое весёлое: density-карта взаимодействовала с retention-кешем (это следующая попытка, до неё дойдём). При zoom in часть элементов раскрывалась и кешировалась, а при zoom out кеш их не чистил. В результате поверх новой density-карты всплывал блок элементов из предыдущего zoom-уровня.
На density ушла примерно неделя командного времени. В итоге удалили полностью. Density не решала проблему — она добавляла ещё один слой состояния, который начинал конфликтовать со всем остальным.
Мораль №1. Если фича вводит новый слой визуального состояния, задача «не мерцать» становится сложнее, а не проще. Density не уменьшала количество per-frame решений — она их увеличивала.
Попытка №2: prev_visible retention
Идея. Сохранять между кадрами массив элементов, которые были видны в прошлом кадре. Если новый rebuild не успел — рисуем старый массив. Никакого мерцания: пока новые данные не готовы, показываем прежние.
Провалилось в первый же тест.
Сценарий: zoom in на один слой, глубоко, потом быстрый zoom out. Результат — плотный синий блок (тот самый, из попытки №1) в месте, где только что был зум. Логика очевидна пост-фактум: в prev_visible сохранились expanded элементы deep-zoom-уровня. Они остались в viewport после zoom out и наложились поверх sparse-представления, которое должно было быть на этом масштабе.
Попробовали чинить: если zoom изменился более чем в 2 раза с последнего rebuild — чистим prev_visible. Это убрало худшие артефакты, но создало новые. Теперь при zoom в 1,8× кеш сохранялся, и призраки приходили в меньшем размере. Пороги никогда не бывают достаточно умными.
Было ещё несколько итераций: «очищать через мягкое затухание», «дедупликация по позиции», «cap на 100 000 элементов» — все с одинаковым исходом. Каждая эвристика работала в восьми сценариях и ломалась в двух новых.
Мораль №2. Кеш между кадрами с разным состоянием камеры — это кеш без валидной стратегии инвалидации. Никакая эвристика (zoom_ratio > 2×, cap по размеру, fade-out) не сделает его корректным, потому что «сохранить старое ради плавности» в принципе конфликтует с «показать актуальное».
Попытка №3: adaptive skip threshold + budget LOD
Идея. Каждую ячейку при обходе дерева проверяем по порогу: если её размер на экране меньше skip_threshold пикселей, не раскрываем в элементы, а рисуем как один прямоугольник-заглушку. Порог делаем адаптивным: (20 * sqrt(viewport_fraction)).max(2.0). Плюс глобальный budget: максимум 4 миллиона элементов на кадр, при превышении — обрезаем.
Добавили трёхуровневый LOD: density → micro (только собственные rect-ы ячейки без рекурсии в детей) → full (полный обход). Hysteresis 0.85, чтобы на границе не дребезжало.
В какой-то момент у нас было пять взаимодействующих эвристик одновременно: adaptive skip threshold, variable budget 1M–4M, 3-level LOD, hysteresis, dynamic viewport margin. В такой ситуации ты уже не понимаешь, какая из них что делает. Когда что-то ломается, ты меняешь одну константу и ждёшь, пока сломается в другом месте. Это не отладка — это whack-a-mole.
Хуже всего, что мерцание не ушло. Оно стало другим: теперь это были резкие переключения LOD-уровня при движении камеры. Целый блок чипа вдруг превращался в плотный квадрат-плейсхолдер на полкадра. Hysteresis уменьшил частоту, но не убрал.
К концу этой итерации я как CTO понял, что мы накопили технический долг быстрее, чем добавляли работающей функциональности. Решение откатить все пять эвристик было моим, и оно далось тяжело — спрашивать команду удалить две недели работы всегда неприятно.
Мораль №3. Если в системе больше трёх эвристик с настраиваемыми порогами в одном pipeline — это не система, а набор костылей, которые на стыках производят новые баги. Любая такая эвристика говорит о том, что архитектура решает не ту задачу.
Попытка №4: background rebuild с deferred swap
Идея. Окей, проблема в том, что rebuild занимает 10–15 мс и блокирует кадр. Делаем double-buffer: пока GPU рисует front buffer, в фоновом потоке мы строим back buffer. Когда back готов — атомарно меняем указатели местами.
Это, наверное, единственная из четырёх попыток, которая была логически чистой. Если бы она работала, было бы изящно. Она не работала.
Во-первых, фоновой поток не успевал. Camera state на момент начала rebuild и на момент свопа — разные. Получалось, что мы загружали в GPU геометрию для камеры, которая была 30 мс назад. На медленном движении это незаметно, на быстром — буфер всегда отстаёт, и видно, как геометрия плывёт за курсором.
Во-вторых, синхронизация между потоком и render loop’ом сама по себе вносила jitter. Atomic swap дешёвый, но решение «свопать сейчас или подождать ещё кадр» — это решение, которое надо принимать с учётом текущего FPS, нагрузки GPU и предыдущей истории свопов. Эта логика заняла у нас неделю.
В-третьих — и это меня окончательно убедило — фундаментальная проблема не в том, что rebuild медленный. Она в том, что rebuild в принципе происходит каждый кадр. Любой подход, который сохраняет per-frame rebuild, работает в режиме «гонимся за камерой и иногда проигрываем». Хочется не гнаться.
Мораль №4. Если ты решаешь проблему «как сделать X быстрее», а на самом деле проблема в том, что X происходит, — никакая оптимизация X не поможет. В какой-то момент надо отойти и спросить: а почему X вообще происходит каждый кадр?
Решение, которое мы должны были сделать сразу
Прорыв пришёл в момент, который выглядит сейчас очевидным до неловкости.
Геометрия чипа — статическая. Топология интегральной схемы не меняется при движении камеры. Это инвариант, который я как архитектор не использовал в течение четырёх попыток. Мы каждый кадр заново решали, какие элементы должны быть на экране, как будто их состав может поменяться. Хотя единственное, что меняется, — это view_proj матрица.
Архитектура, к которой мы в итоге пришли:
Cell geometry buffer. Для каждой уникальной ячейки в чипе один раз при импорте GDS строим массив прямоугольников в её локальных координатах. У Caravel ~1000 уникальных ячеек, ~50 000 прямоугольников суммарно. Размер буфера — ~1,2 МБ. Загружается один раз. Никогда больше не меняется.
Instance transform buffer. Для каждого инстанса ячейки в чипе — одна запись с матрицей трансформации (translation + scale + rotation + cell_id + layer mask). У Caravel ~762 000 инстансов. Буфер — ~5,8 МБ. Загружается один раз при импорте. Никогда больше не меняется.
Per-frame update. Меняется только view_proj — 64 байта uniform-буфера. Всё.
Vertex shader получает на вход instance_id, читает по нему cell_id и transform, по cell_id находит диапазон прямоугольников в geometry buffer, применяет transform → view_proj → клип-координаты. Native instanced rendering, всё на GPU, CPU не делает ничего, кроме обновления одной матрицы.
Цифры до и после:
|
|
До (per-frame rebuild) |
После (static buffers) |
|---|---|---|
|
GPU память |
522 МБ |
5,8 МБ |
|
Время сборки буфера |
205 мс на импорте + 10–15 мс/кадр |
3 мс на импорте, 0 мс/кадр |
|
Мерцание |
Постоянно |
Нет |
|
Pan/zoom отзывчивость |
60+ FPS, но дёргается |
130–340 FPS на Apple M4, плавно |
90× меньше памяти. 68× быстрее сборка. Ноль мерцания. И всё это на ровном месте.
Что я как CTO вынес из этого
Четыре итерации заняли в сумме примерно месяц. Финальная архитектура реализовалась за неделю. Это типичное соотношение, когда команда блуждает не в той части задачи. Главное, что я понял про свою роль — пора было раньше остановиться и переосмыслить постановку, а не давать команде ещё одну эвристику.
Архитектурные инварианты. У нас была инвариантная сцена и переменная камера. Это асимметрия, которую я не использовал. Если в системе есть что-то, что не меняется во времени, а ты пересчитываешь это каждый кадр — это симптом того, что архитектура переразвёрнута. Все четыре попытки имели одну общую черту: они принимали per-frame rebuild как данность и пытались его оптимизировать. Density уменьшала что перестраивать, retention пытался показывать старое, LOD ограничивал сколько перестраивать, double-buffer прятал когда перестраивать. Никто (включая меня) не спросил «зачем мы вообще каждый кадр это делаем».
Эвристики как сигнал. Любая система, которая накапливает три и более настраиваемых порога во взаимосвязанном pipeline, — это система без хорошей абстракции. Эвристики хороши как костыли в моменте, но когда их становится больше двух, они начинают перемножать друг друга в сценариях, которые ты не предусмотрел. Я взял за правило: если внутри одного pipeline появилась третья эвристика — это сигнал отойти и пересмотреть, не неверная ли это постановка задачи.
GPU как state machine. wgpu, Vulkan, Metal — все они проектировались с расчётом, что данные на GPU долгоживущие. Современный графический pipeline счастлив, когда buffers стабильны и обновляется только uniform. Каждый раз, когда ты заливаешь новый буфер, ты борешься с архитектурой API. Если можешь не заливать — не заливай.
Где я мог сэкономить команде месяц. В первую неделю работы над этой задачей нужно было нарисовать таблицу: «что меняется, что не меняется». Если бы я сделал это сразу, мы бы увидели, что геометрия в правом столбце, а матрица камеры — в левом. Это бы перерезало путь к статической архитектуре за день, а не за месяц. Сейчас у нас в команде это формальный шаг любой графической задачи: прежде чем оптимизировать что-то per-frame, мы перечисляем инварианты системы. Простой ритуал, который сильно меняет траекторию решений.
Если кто-то решал похожую задачу на других графических pipeline (Vulkan напрямую, Metal, DX12) — буду рад комментариям. Особенно интересны кейсы, где такой же сдвиг — от dynamic rebuild к static buffers — работал/не работал на других видах сцен.
Автор: GrigoryF


