Тот же Rust‑код, что рисует топологию на десктопе, собирается в wasm и рисует в <canvas> через WebGPU. Сайт при этом не знает про GPU: кликнул на нарушение в списке, камера уехала на место, маркер показал дефект. Ниже как это устроено, где «просто пересобрали» было бы враньём, и какие ловушки сидят не в Rust, а в вебе.

Задача

У нас десктопный инструмент для топологии интегральных схем, layout в формате GDSII (и OASIS). Файл описывает геометрию кристалла: иерархия ячеек, на листьях прямоугольники и полигоны по слоям (металл, поликремний, диффузия). Координаты целые, в DBU, единицах базы данных; в микрометры переводим уже при развороте сцены. Реальный проект: сотни мегабайт на диске, миллионы фигур после разворачивания иерархии.

Рядом выросла веб‑платформа с результатами анализа этих дизайнов. Захотелось показать сам layout в браузере, в том же UI: клик по нарушению в списке открывает его место на топологии. В десктопных пакетах это называют RVE, results viewing environment. Нам нужна была такая навигация без второго рендер‑движка.

Что отсекли сразу

Серверный рендер в тайлы. Каждый зум это запрос. Интерактивность мёртвая, плюс сервер рисует чужие данные.

Canvas2D или SVG на JS. На миллионах прямоугольников не работает. И это был бы второй движок.

Отдельный JS/TS‑парсер и рендер. Формат один, интерпретации через полгода две. Для GDS это особенно опасно: иерархия, зеркала, массивы ссылок.

Остался вариант, который мы и сделали: тот же Rust, цель wasm32-unknown-unknown, картинка в <canvas> через WebGPU.

Общие крейты, не общий бинарь

Рендер‑стек давно разрезан на библиотеки. Модель данных: иерархия ячеек, целые координаты в DBU. Парсеры GDSII и OASIS свои, без внешних зависимостей. GPU‑обёртки над wgpu: instanced‑рендер, WGSL‑шейдеры. Мелкая математика. Десктопное приложение тонкая оболочка поверх этого.

Браузерный вьювер такая же тонкая оболочка над теми же крейтами. Парсер читает Vec<u8> вместо файла. Модель разворачивает иерархию. Те же шейдеры гоняет тот же wgpu, только бэкенд WebGPU, не Vulkan или Metal.

В wasm нет верификации. DRC, LVS, PEX это проверка зазоров, сверка со схемой и паразитные ёмкости. Тяжёлые движки, в браузере им делать нечего. Крейт с ними тянет rusqlite с бандленным C, winit, rfd, reqwest. Под wasm это не собирается, и мы не стали делать вид, что можно. Из roughly 500 тысяч строк верификации наружу не уходит ни одна. GDS после скачивания остаётся в памяти вкладки, на сервер за разбор мы его не гоняем.

Граница не «что успели портировать», а «что имеет смысл». Wasm рисует геометрию в канвасе. Панель слоёв, тулбар, свойства рисует Angular, теми же компонентами, что остальной сайт. Втаскивать второй UI‑тулкит (у нас на десктопе egui) внутрь чужого интерфейса значит получить две визуальных системы на одной странице.

egui в wasm‑крейте всё же есть, и это стоит сказать прямо. Мы не рисуем им панели. Мы берём eframe как оболочку: канвас, события мыши и жестов, поверхность wgpu. Без этого пришлось бы заново писать цикл кадров и ввод. Шрифты egui нам не нужны, текст рисует сайт. Как только выключили default_fonts, из бинаря ушло почти 1.4 МБ. Об этом ниже.

Лимиты, которые замерили до кода

День разведки до первой строки. Ни один пункт потом не стал сюрпризом.

WebGL2 не подходит. Instanced‑шейдеры сидят на var<storage>, storage‑буферах. В WebGL2 их нет. Значит только WebGPU: Chrome, Edge, Safari 26 и новее. Это жёсткое требование, и мы его показываем. Ещё уточнение, которое всплыло на сайте: «нет WebGPU» бывает трёх разных видов. Страница открыта не по https и не по localhost, тогда navigator.gpu просто отсутствует. Браузер API не умеет. Адаптер не выдался (нет GPU, --disable-gpu, удалённый рабочий стол). Одно общее сообщение здесь врало бы.

Адресное пространство wasm32 это 2–4 ГБ. Чипы на 12–20 ГБ в вкладку не влезают. Хуже другое: файл в пару сотен мегабайт умирает не ошибкой, а молчаливым OOM вкладки. Сайт это не ловит и не показывает. Поэтому отказ до разбора:

rust:

/// Байты живут в wasm-памяти дважды: JS ArrayBuffer и Vec<u8>

/// аргумента viewer_load. Разбор строит store поверх них.

const MAX_LOAD_BYTES: usize = 400  1024  1024;

Почему дважды. wasm-bindgen копирует Uint8Array в Vec<u8>. JS‑буфер ещё жив, пока сборщик его не заберёт. Zero‑copy через shared memory мы не делали: парсер многопроходный, байты должны жить до конца init_world_fast, а пик всё равно будет «исходник плюс store». Честный потолок проще, чем гоняться за одним копированием и ловить тот же OOM на следующем шаге.

Превысил лимит, сайт получает «файл слишком большой, откройте в десктопе». Вкладка жива.

То же с геометрией: потолок 2 млн отображаемых прямоугольников. Сколько срезано, видно в truncated. Честный предел лучше молчаливого свопа.

rayon под wasm схлопывается в один поток. Крупный дизайн грузится медленнее, чем на десктопе. Параллель вернётся с wasm-bindgen-rayon, но для этого на хостинге нужны заголовки COOP/COEP. Это отдельный заход, мы его отложили.

Загрузка: иерархию разворачиваем один раз

GDSII иерархический. Ячейка ссылается на ячейки со сдвигом, поворотом, отражением. Вложенность бывает десятки уровней. Рендерить дерево каждый кадр дорого. При загрузке один раз разворачиваем всё в плоский массив мировых прямоугольников, дальше рендер тупой: один unit‑quad, миллион инстансов, один draw call.

rust:

/// Контракт шейдера: вершина умножается на (instance.size, layer.thickness),

/// поэтому XY лежит в [-0.5, 0.5].

fn unit_quad() -> (Vec<Vertex>, Vec<u16>) { /* 4 вершины, 2 треугольника */ }

Композиция трансформов не своя, а движковая (CompactInstance::compose). Свою арифметику мы уже один раз написали неправильно: вращения всегда складывались, а под зеркальным родителем их надо вычитать. На одном из тестовых чипов это 386 мест, где вложенная ячейка встала бы не туда.

Что с полигонами

Во вступлении честно написано «прямоугольники и полигоны». Рендер умеет только прямоугольники. Прямоугольные (манхэттенские) полигоны раскладываются тем же decompose_rectilinear, что и в DRC, и попадают в сцену как набор прямоугольников. Остальное не подменяем bounding box: box накрыл бы соседние цепи и выглядел бы как настоящая геометрия.

Пути (PATH), тексты и нативные массивы ссылок (AREF) пока не рисуем. Считаем и отдаём сайту в skipped. Страница пишет: вот столько полигонов, путей, текстов не показано. В первой редакции обход читал только geo.rects. На одном PDK‑тесте в никуда ушли 58.6 млн путей и около 100 тысяч полигонов. Пользователь видел «дизайн загружен» и дырявую картинку.

Если фигур больше 2 млн, режем не «первые N по обходу». Два прохода: сначала считаем всех и bbox всего чипа, потом тем же обходом отбираем кандидатов равномерным шагом Брезенхэма. Усечённый вид выглядит как прореженный чип, а не как обрубок с одной стороны. Fit камеры тоже по полному габариту, иначе в кадр попала бы только видимая культя.

Перезагрузка файла. Нельзя разбирать новый дизайн поверх старого: пик это новый store плюс ещё живой Vec<WorldRect> (около 20 байт на штуку, миллионы штук). Сначала старое освобождается, сайт в этот момент видит «дизайна нет», потом разбор нового.

Иерархия для панели сайта снимается по определениям ячеек, не по инстансам. При ромбовидных ссылках одна ячейка живёт у нескольких родителей. Обход по инстансам показал бы её поддерево несколько раз, дерево в UI распухло бы. Показываем под первым родителем в порядке обхода. Это дерево для навигации, не граф размещений. Ячейки, на которые никто не ссылается (библиотечный мусор), не выкидываем: они отдельные корни. Ссылки считаем и по обычным инстансам, и по AREF, иначе ячейка из большого массива всплыла бы лишним корнем.

Мост между сайтом и wasm

Наружу дюжина функций viewer_*: загрузить байты, отдать слои и иерархию, переключить слой, вписать в экран, зумнуться к прямоугольнику, поставить маркер, подписаться на изменения. Команды с сайта применяются в ближайшем кадре. JS и отрисовка в одном потоке, но не в одном стеке. Прямой вызов в рендер из обработчика ловили бы как тонкие баги.

Слои уезжают с именами, цветами и дефолтной видимостью из движка. Браузер красит так же, как десктоп. Клик по глазу в панели не пересобирает буфер из двух миллионов инстансов: скрытие это нулевая alpha в маленьком буфере слоёв. Первая версия перезаливалась целиком и на каждом клике подвисала.

Самый полезный кусок для пользователя как раз fly‑to. В браузере результатов клик по строке «нарушение на слое N в координатах таких‑то» шлёт две команды: камера на мировой прямоугольник и маркер на тот же bbox. Камера показывает район, красная обводка с белым гало показывает дефект. Координаты те же, что в экспорте DRC, в микрометрах. Сайт не пишет ни строчки GPU‑кода.

Камера живёт в URL. Центр и масштаб пишутся в hash, #view=cx,cy,span, через history.replaceState. Hash, не query: иначе дёргался бы роутер Angular. Троттлинг 250 мс, чтобы pan не спамил историю. Ссылка и перезагрузка открывают тот же вид. Для «кинь коллеге место на топологии» это базовая вещь, не украшение.

Время разбора уезжает в load_ms и видно в панели свойств. Отдельный бенчмарк «столько‑то FPS» мы в статью не тащим: после загрузки кадр это один instanced draw плюс сетка. Упираемся в память и в однопоточный разбор, не в GPU.

Мелочь про встраивание. Модуль на вкладке один. Страница сайта монтируется сколько угодно раз. Повторный вход без остановки предыдущего eframe поднимал второй рантайм на ту же очередь команд. viewer_start сначала гасит старый цикл, подписку сайта на изменения не трогает.

Ловушка 1. wasm не должен жить в бандле сайта

Первая попытка: подключить wasm через сборщик фронта. Glue‑код wasm-bindgen резолвит .wasm относительно import.meta.url. Модуль попал внутрь чанка, файл не находится.

Обходов два. Рантайм‑import() через new Function это eval, под CSP ломается. Либо обычный <script type="module"> с фиксированным src. Взяли второе: bootstrap на полтора десятка строк импортирует glue, ждёт инициализации, кладёт наружу один promise. Никакого eval, работает при script-src 'self'.

Заодно bootstrap качает layout. Wasm этого видеть не должен. На сотнях мегабайт молчаливый fetch выглядит как зависшая страница, поэтому читаем resp.body.getReader() и шлём прогресс в байтах на каждый чанк:

js:

const reader = resp.body.getReader();

for (;;) {

const { done, value } = await reader.read();

if (done) break;

  chunks.push(value); loaded += value.byteLength;

  window.postMessage(

    { source: 'viewer', type: 'load-progress', loaded, total },

    window.location.origin,

  );

}

postMessage с '*' в первой версии тоже был. Сообщения фильтруем по полю source, но origin лучше свой: вьювер иногда живёт во фрейме, и звёздочка здесь лишняя дырка, не фича.

Ловушка 2. статика уходит до аутентификации

Положить wasm в wwwroot кажется очевидным. В ASP.NET UseStaticFiles стоит в конвейере до аутентификации. Любой файл в wwwroot (и в public/ фронта: он копируется туда при сборке) раздаётся анонимно. [Authorize] на контроллерах это не меняет. Отдавать публике бинарь с картой рендер‑стека не хочется.

Ассеты лежат в отдельной директории вне wwwroot, в publish попадают include'ом в .csproj. Отдаёт контроллер. Тут вторая мелочь. Вьювер грузится как ES‑модуль и сам делает fetch за .wasm. Ни import, ни этот fetch не умеют послать заголовок Authorization. Куку послать умеют. Поэтому схема такая: фронт меняет обычный Bearer на короткоживущую куку, ограниченную путём /viewer. Кука не дефолтная схема всего API, иначе каждый [Authorize] начал бы принимать её и на мутациях вылез бы CSRF.

Ещё три детали, на которые наступили бы позже. MIME для wasm обязан быть application/wasm, иначе браузер молча идёт в медленный путь. Белый список из трёх имён, не «отдай всё из папки». Кэш private, no-cache: браузер может держать 2 МБ локально и спрашивать 304, но не должен склеить новый glue со старым wasm после деплоя. Имена файлов у нас не версионируются.

Стоило это строки в скрипте сборки и одного контроллера. Сработало только потому, что порядок middleware был известен заранее.

Диета артефакта

Release‑wasm из коробки весил 3 558 942 байта. Разбор по слоям:

39.6% это четыре встроенных ttf egui (default_fonts): 1 407 752 байта, байт в байт в бинаре. Текст рисует сайт, канвас только геометрия. default-features = false, шрифтов ноль.

strip=symbols. Иначе в wasm уезжает name‑section с именами функций, по сути карта модулей.

--remap-path-prefix. Panic‑локации (file!()) вшивают абсолютные пути сборочной машины. Аудит нашёл 123 строки вида /Users/<имя>/.cargo/registry/...: имя пользователя и список зависимостей с версиями, раздаваемый каждому клиенту. Корни переписываем на /deps и /src.

Комментарии в WGSL для wasm‑сборки вырезаются на этапе build.rs. В исходниках шейдеров жили внутренние имена десктопных модулей. В бинаре их быть не должно.

wasm-opt -Oz сверху приятен, но не обязателен. Страница работает и без binaryen.

После этого 1 969 786 байт, минус 45%. Лицензий, секретов и PDK в бинаре нет: проверяли разбором артефакта, не рассуждением.

Флаги сидят в скрипте сборки вьювера, не в профиле воркспейса. Общий профиль про десктоп, трогать его ради wasm нельзя.

Версия wasm-bindgen-cli должна совпадать с крейтом wasm-bindgen в Cargo.lock. Захардкодили в скрипте с проверкой. Иначе glue и бинарь разъедутся на минорке, и это всплывёт в проде.

Что получилось и что нет

Получилось. Один парсер, одна модель, один шейдерный стек на десктопе и в браузере. Дизайны в пределах 400 МБ / 2 млн фигур открываются на обычной машине, время разбора видно в статусе. Сайт получил интерактивный layout без GPU‑кода на своей стороне. Firefox и старый Safari получают понятное «нужен браузер с WebGPU», а не белый канвас. Неполная геометрия не прикидывается полной: truncated и skipped торчат в UI.

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

Не сделали и сознательно отложили.

Многопоточная загрузка. Нужны COOP/COEP на хостинге.

Дизайны за пределом лимита. Им нужен потоковый разбор, серверный pre‑parse в промежуточный формат. Это уже не про wasm, а про протокол.

Верификация в браузере. Это не ограничение, а граница: проверка живёт на десктопе и в batch, вьювер показывает геометрию и место нарушения.

Пути и неманхэттенские полигоны. Пока честный skipped, не фальшивый прямоугольник.

Выводы

Переиспользование кода главная причина, по которой затея имеет смысл. Если рендер и парсер уже библиотеки без UI и без ОС, wasm‑оболочка это небольшой крейт. Если они сидят внутри десктопного приложения, вы сначала будете пилить границу, и это другой проект.

Лимит с понятным сообщением это фича. Молчаливый OOM вкладки пользователь запишет на вас.

«Wasm рисует геометрию, сайт рисует UI» самое удачное решение из всех, что мы пробовали. Стороны не знают внутренностей друг друга, кроме десятка функций.

Большая часть времени ушла не на шейдеры. Порядок middleware на статике, import.meta.url внутри бандла, кука вместо Bearer, пути сборочной машины в бинаре, шрифты тулкита, который мы даже не показываем. Ни один из этих пунктов не про Rust и не про GPU.