# Rust против C++: две версии редактора SimVulcan Ветка `rust` содержит порт редактора с C++20 на Rust. Обе версии лежат в дереве рядом, собираются независимо и делают одно и то же. Этот документ — не выбор победителя, а разложенные факты: что изменилось, чего это стоило и что померилось. ## Что именно сравнивается Программа одна и та же: окно 1280×720, орбитальная камера вокруг начала координат, три опорные плоскости сетки и цветные оси XYZ, загрузка `.obj` в отдельном потоке, сварка вершин с допуском `1e-4`, три режима отображения модели, единый проход динамического рендеринга с глубинным вложением, submit через `synchronization2`, два кадра в полёте. **Общее:** каталог `shaders/` — обе версии компилируют одни и те же `.vert`/`.frag` тем же `glslangValidator -V --target-env vulkan1.3`; каталог `assets/` — одни и те же модели. **Разное по договорённости:** интерфейс. В C++ это Dear ImGui, в Rust — **egui**. Крейт `imgui` существует, но это биндинги: сборка всё равно тянула бы ~40 тыс. строк C++ через `cc`, и «Rust-версия», компилирующая C++, обесценила бы сравнение экосистем. Панели из-за этого выглядят иначе; набор элементов управления тот же. ## Условия замеров | | | |---|---| | машина | Intel Core i5-1135G7, 4 ядра / 8 потоков, 16 ГБ, SSD | | видео | Intel Iris Xe, драйвер 31.0.101.5186, экран 60 Гц | | ОС | Windows 11 Home 10.0.26200 | | C++ | MSVC 14.50.35717 (VS 18 BuildTools), CMake 4.3.2, Ninja 1.13.2 | | Rust | rustc / cargo 1.97.1, MSVC-ABI | | общее | Vulkan SDK 1.4.341.1 | Обе сборки — релизные (`CMAKE_BUILD_TYPE=Release`; `cargo --release` с `lto = "thin"`, `codegen-units = 1`). Обе однопоточно ограничены одним и тем же железом; замеры сделаны подряд, без других нагрузок. ## Цифры ### Сборка Все числа C++ уменьшены на 3.0 с — накладные расходы `vcvars64.bat`, который приходится вызывать в каждом сеансе (замерены отдельно, среднее из трёх). У `cargo` такого нет. | | C++ | Rust | |---|---|---| | конфигурация (исходники зависимостей на месте) | 9.7 с | входит в сборку | | **release с нуля** | **114 с** | **194 с** (thin LTO) · 182 с (без LTO) | | release, ничего не менялось | 1.1 с | 1.0 с | | **release после правки одного файла** | **4.4 с** | **52.3 с** (thin LTO) · 5.5 с (без LTO) | | debug с нуля | 112 с | 83 с | | debug после правки одного файла | 8.0 с | 5.2 с | Правился в обоих случаях самый крупный файл Vulkan-слоя: `src/vk/Renderer.cpp` и `crates/simv-vk/src/renderer.rs`. Две оговорки, без которых таблица врёт. **Уровни оптимизации разные.** `CMAKE_BUILD_TYPE=Release` у MSVC — это `/O2` без оптимизации всей программы (CMake не включает `INTERPROCEDURAL_OPTIMIZATION` сам). Профиль порта просит `lto = "thin"` и `codegen-units = 1`, то есть межкрейтовую оптимизацию. Отсюда и 52 секунды на инкрементальную пересборку: правка одной строки заставляет заново оптимизировать и слинковать весь бинарь. Колонка «без LTO» — `CARGO_PROFILE_RELEASE_LTO=false`, `CODEGEN_UNITS=16`, то есть примерный аналог `/O2` без LTCG: **5.5 с против 4.4 с**, и разницы уже нет. Инкрементальная сборка в Rust медленная не сама по себе, а ровно настолько, насколько её просят оптимизировать. **«С нуля» здесь не значит «с чистой машины».** Обе цифры сняты при уже скачанных зависимостях. Честная холодная сборка C++ до конца не дошла: `FetchContent` клонирует девять репозиториев с полной историей, и на этом соединении она за пятнадцать минут успела скачать два из них (glfw 24 МБ, glm 100 МБ) и была прервана. Готовое дерево зависимостей от отладочной сборки занимает **775 МБ**, и общего кэша у FetchContent нет: каждый новый каталог сборки скачивает всё заново. Реестр cargo, наоборот, один на пользователя. ### Артефакты и зависимости | | C++ | Rust | |---|---|---| | `SimVulcan.exe`, release | **1.18 МБ** | **5.80 МБ** | | `SimVulcan.exe`, debug | 5.68 МБ | 28.97 МБ | | каталог сборки | 78 МБ | 469 МБ | | объявлено зависимостей | 9 | 15 | | всего пакетов в графе | 10 | **76** | | исходники зависимостей на диске | **775 МБ** на каждый каталог сборки | **80 МБ** в общем реестре | Пятикратная разница в размере бинаря — это в основном статически влинкованная стандартная библиотека Rust и форматирование `core::fmt`; C++-версия тянет CRT из системы. Разница в числе пакетов (76 против 10) впечатляет ровно до того момента, как посмотреть на объём: 76 крейтов Rust занимают в восемь раз меньше места, чем девять библиотек C++, потому что `.crate` — это архив выпуска, а `FetchContent` — клон с историей. ### Выполнение Обе версии показывают в режиме FIFO на экране 60 Гц, поэтому частота кадров у них одинаковая по построению и ничего не измеряет. Сравнимы старт и процессорная стоимость. | | C++ | Rust | |---|---|---| | старт до готовности рендерера (лучший из трёх) | 1068 мс | **754 мс** | | то же, средний | 1080 мс | 872 мс* | | процессорное время при 60 Гц | **11.2%** реального | 12.6% реального | \* без первого запуска сразу после сборки: он занял 3.4 с, пока система подтягивала свежий 5.8-мегабайтный образ с диска. У C++-версии разброс между запусками — 21 мс. Полтора процента разницы по процессору — это egui против Dear ImGui, а не Rust против C++: egui пересобирает раскладку каждый кадр, ImGui хранит её между кадрами. Сцена (сетка + модель) в обеих версиях записывается одинаковыми вызовами. ### Совпадают ли результаты Проверка, что порт считает то же самое: один и тот же файл загружается обеими версиями, сравниваются счётчики. | файл | C++ (tinyobjloader) | Rust (tobj) | |---|---|---| | `cow.obj` | 2451 вершины, 4898 треугольников | **2451, 4898** | | `plane.obj` | 10673 вершины, 20035 треугольников | 10638, **20061** | `cow.obj` сходится точно. `plane.obj` расходится, и оба расхождения объяснимы: * **35 вершин.** tinyobjloader отдаёт глобальный массив `attrib.vertices` целиком, включая вершины, на которые не ссылается ни одна грань; tobj такие отбрасывает. Сварка потом всё равно оставила бы их висеть в списке — на картинку они не влияют. * **26 треугольников.** В `plane.obj` 121 грань с пятью и более вершинами. tobj режет n-угольник веером, что даёт ровно `n − 2` треугольника — суммарно 20061, и это совпадает с прямым подсчётом по файлу. tinyobjloader для `n ≥ 5` применяет earcut-подобную триангуляцию и выбрасывает выродившиеся треугольники, отсюда 20035. Разница 0.13% и целиком лежит в библиотеке чтения, а не в переносе. Сварка вершин (`weld_vertices`) перенесена построчно, включая бакетирование округлением и FNV-хеш; три её теста из Catch2 перенесены дословно и проходят. ### Побочная находка: строка, которой не бывает При сверке счётчиков выяснилось, что C++-версия **никогда не печатает** результат сварки. `ObjLoader::LoadObj` пишет `Loaded OBJ '…': N verts, M tris` в категорию `MeshIO`, а обработчик в `main.cpp` сразу за ним пишет туда же `Mesh ready: …`. У категории стоит троттлинг 0.5 с, оба сообщения уровня `Info` — второе гарантированно отбрасывается. В журналах обеих загрузок (`cow.obj` и `plane.obj`) строки `Mesh ready` нет. Это и стало причиной единственного расхождения в переносе журнала: в порте троттлинг по умолчанию выключен, а `set_throttle` включается там, где сообщения идут покадрово. Иначе стартовый вывод терял бы имя видеокарты — в C++ он уцелел лишь потому, что низкоуровневый код Vulkan пишет напрямую через `spdlog`, минуя категории. ## Строки кода Считано одинаковыми правилами для обеих версий: строка комментарийная, если после обрезки пробелов начинается с `//` (для C++ ещё блочные `/* … */`); хвостовые комментарии за кодом не вычитаются. | модуль | C++ | | | | Rust | | | | |---|---|---|---|---|---|---|---|---| | | всего | пусто | комм. | **кода** | всего | пусто | комм. | **кода** | | журнал, окно (`core`) | 370 | 83 | 11 | **276** | 310 | 36 | 62 | **212** | | Vulkan (`vk`) | 2120 | 380 | 88 | **1652** | 2491 | 238 | 255 | **1998** | | меш (`mesh`) | 236 | 50 | 14 | **172** | 395 | 50 | 70 | **275** | | редактор (`editor`) | 305 | 68 | 20 | **217** | 431 | 48 | 62 | **321** | | приложение (`app`) | 85 | 14 | 5 | **66** | 226 | 27 | 43 | **156** | | тесты | 65 | 9 | 5 | **51** | *внутри модулей* | | | *≈150* | | **итого** | **3181** | 604 | 143 | **2434** | **3853** | 399 | 492 | **2962** | | система сборки | 431 | | | **399** | 232 | | | **197** | Порт длиннее примерно на пятую часть, и перевес почти весь в Vulkan-слое: +346 строк кода в `simv-vk` — это написанная руками замена vk-bootstrap. Приложение выросло с 66 до 156 строк, потому что в него переехал `App` из `simv_core` (см. расхождение 1) и поиск каталога моделей стал честным обходом предков вместо пяти захардкоженных `../`. Комментариев в порте втрое больше (492 против 143), и это перекос замера, а не свойство языка: значительная их часть — пометки «здесь расхождение с C++-версией и вот почему», написанные ради этого документа. По строкам собственно кода разрыв — 2962 против 2434, и он меньше, если вычесть тесты: они в Rust живут внутри модулей (≈150 строк), в C++ — отдельной целью (51 строка). ## Структурные расхождения Каждое — с причиной. Ни одно не сделано «чтобы было красивее». ### 1. `App` переехал в исполняемый крейт В C++ граф целей цикличен: `core::App` владеет `vk::Renderer`, а `vk::Renderer` принимает `core::Window&`. Работает это только потому, что `simv_vk` **не линкует** `simv_core`, а видит его заголовки через `target_include_directories(simv_vk PUBLIC ..)`, и всё сходится на компоновке исполняемого файла. Cargo цикл между крейтами отвергает сразу, поэтому `App` (25 строк) поднят на уровень выше обоих — в `simv-app`. Это единственный пункт, где Rust потребовал перекладывать код, и заодно единственный, где он указал на настоящую проблему в исходной раскладке: срез слоёв, который CMake пропустил молча. ### 2. Границу модулей проверяет компилятор Главный инвариант проекта — «весь Vulkan живёт в `src/vk/`» — в C++ держится на дисциплине и записи в `CLAUDE.md`: ничто не мешает написать `#include ` в `mesh/ObjLoader.cpp`, все заголовки видны. В порте `simv-mesh` и `simv-editor` просто не имеют `ash` в зависимостях: нарушение не соберётся. Побочный эффект: `EditorUI` не может назвать тип `vk::RenderMode`. C++ обходит ту же границу, возвращая из панели голый `int`, который `main.cpp` приводит `static_cast` — связь двух перечислений там держится на честном слове. В порте у панели свой `DisplayMode`, а разбор делает `match` в `simv-app`: добавится вариант — компилятор укажет место. ### 3. pImpl не понадобился `vk::Renderer` в C++ прячет всё за `struct Renderer::Impl` и `unique_ptr` ради одного: чтобы заголовок не тащил volk в трансляционные единицы `App` и `main`. В Rust заголовков нет, и приватные поля модуля дают ту же изоляцию бесплатно — минус один уровень косвенности и минус ручное объявление/определение `Impl` на 45 строк. ### 4. Замыкание интерфейса стало параметром, а состояние — возвратом В C++ `Renderer` хранит `std::function`, а замыкание из `main.cpp` захватывает по ссылке камеру, панели и **сам рендерер**, чтобы вызвать `SetViewProj`, `SetRenderMode`, `SetGridVisible`. В Rust так нельзя: замыкание вызывается из метода рендерера, то есть рендерер оказался бы одолжен дважды. Решение: замыкание передаётся параметром в `draw_frame` и **возвращает** `FrameState` с матрицей вида-проекции, режимом и видимостью сетки. Состояние применяется к тому же кадру, отставания нет. Сеттеры исчезли за ненадобностью. ```cpp // C++: состояние ставится изнутри колбэка app.SetUiCallback([&]() { editorUI.Draw(camera); renderer.SetViewProj(camera.ViewProj(renderer.AspectRatio())); }); ``` ```rust // Rust: состояние возвращается из замыкания renderer.draw_frame(window, |ctx| { editor_ui.draw(ctx, camera); FrameState { view_proj: camera.view_proj(aspect), .. } })?; ``` ### 5. Выгрузка меша уехала из середины кадра В C++ панель загрузки зовёт `OnLoaded` прямо из `Draw`, то есть из середины `Renderer::DrawFrame`, **уже после** `vkAcquireNextImageKHR`. Обработчик оттуда дёргает `SetMeshCpu`, а тот — `vkDeviceWaitIdle` и выгрузку на видеокарту. Законно, но ждать простоя устройства посреди записи кадра — сомнительно. В Rust это невозможно по той же причине, что и пункт 4. Панель складывает готовый меш в поле, приложение забирает его после кадра. Ограничение языка здесь вынесло тяжёлую операцию из горячего пути — не потому, что кто-то это заметил. ### 6. `Buffer` и `Image` перестали быть мёртвым грузом В C++ обе обёртки собираются в цель `simv_vk`, но **ими не пользуется никто**: и `GridRenderer`, и `GpuMesh`, и глубинное вложение `Renderer` создают ресурсы прямыми вызовами VMA. Это 200 строк, которые компилируются каждую сборку и никогда не работают. В порте на них построены все три потребителя — не из-за языка, а потому что порт писался, когда потребители уже известны. С gpu-allocator обёртка ещё и обязательна по существу: она держит `Allocation`, который иначе пришлось бы освобождать руками в каждом `Drop`. ### 7. Асинхронная загрузка: `mpsc` вместо `std::future` `LoadObjAsync` в C++ возвращает `std::future`, и исключение из рабочего потока всплывает при `get()`. В порте поток шлёт `Result` по каналу, панель опрашивает `try_recv()`. Разница не в количестве кода (его столько же), а в том, что ошибка стала значением: пропустить её нельзя, а тип в сигнатуре перечисляет, что вообще может пойти не так. ### 8. SPIR-V вшит в исполняемый файл `build.rs` компилирует шейдеры в `OUT_DIR`, откуда они попадают в бинарь через `include_bytes!`. Вместе с этим исчезли: функция `FindSpvPath` с перебором путей, два POST_BUILD-шага копирования каталогов в `CMakeLists.txt`, требование запускать редактор из его собственного каталога и целый класс ошибок «шейдер не найден» во время выполнения — отсутствующий шейдер теперь ломает сборку. Из путевых зависимостей остался только `assets/meshes`, и он ищется от бинаря, а не от текущего каталога. ### 9. Цикл событий принадлежит библиотеке GLFW отдаёт цикл приложению (`while (!ShouldClose()) { PollEvents(); DrawFrame(); }`), winit забирает его себе и зовёт приложение через `ApplicationHandler`. `Window::ShouldClose` и `Window::PollEvents` исчезли, их место заняли `window_event` и `about_to_wait`. Это свойство winit, а не Rust: крейт `glfw` существует и сохранил бы прежнюю форму, но он такие же биндинги к C, как `imgui`. ## Чего в экосистеме Rust не нашлось **Замены vk-bootstrap нет.** В C++ выбор физического устройства по требуемым возможностям, поиск семейств очередей, проверка наличия слоя валидации и сборка цепочки показа — это `vkb::InstanceBuilder`, `vkb::PhysicalDeviceSelector` и `vkb::SwapchainBuilder`. В порте всё это написано руками: перебор устройств, чтение `PhysicalDeviceFeatures2` с цепочкой `Vulkan12/13Features` и сверка девяти возможностей, разбор флагов семейств очередей с проверкой поддержки показа на поверхность, выбор формата с откатом на первый доступный, зажим размера и числа образов по возможностям поверхности, создание представлений. Счёт по одним и тем же модулям (`Context` и `Swapchain` целиком, строки кода без пустых и комментариев): **301 строка в C++ против 606 в порте**. Эти 305 строк разницы и есть главная причина, по которой `simv-vk` вышел длиннее прообраза, — весь остальной перевес Vulkan-слоя укладывается в четыре десятка строк. **Ритм выпусков разный.** `ash` 0.38 вышел в апреле 2024 и с тех пор не обновлялся — это не заброшенность (Vulkan 1.3 он покрывает полностью, а генерируемый API стабилен), но зафиксировать стоит. `egui` 0.36 и `egui-ash-renderer` 0.13 — август 2026, живее некуда, причём вторая обновилась вслед за первой за две недели. Обратная сторона живости — поломки: `egui` 0.36 переименовал `Context::run` в `run_ui`, сменил форму `TexturesDelta` (карта правок вместо одной на текстуру) и объявил `set_textures` устаревшим. Порт писался под 0.36 и учитывает всё это; годичной давности пример из сети не собрался бы. **Мелочи, которые в C++ приходят с библиотекой.** spdlog печатает локальное время из коробки; в Rust за форматированной меткой пришлось взять `chrono`. GLM определяется одним заголовком; glam — крейт, зато соглашение о пространстве отсечения у него вынесено в путь функции (`camera::rh::proj::vulkan::perspective` вместо `GLM_FORCE_DEPTH_ZERO_TO_ONE` плюс ручной `p[1][1] *= -1`). ## Где какой язык помог, а где помешал **Rust помешал — заимствования на границе рендерера.** Пункты 4 и 5 выше: конструкция «объект хранит замыкание, которое трогает этот же объект» в C++ пишется не думая, а в Rust не компилируется вовсе. Обход занял полчаса и, если честно, дал код лучше исходного — но полчаса были потрачены на спор с компилятором, а не на задачу. **Rust помешал — цепочки pNext.** `push_next` держит `&mut` на вложенную структуру, пока жива голова цепочки, поэтому прочитать `f13.synchronization2` при живом `PhysicalDeviceFeatures2` компилятор не даёт. В C++ pNext — сырой указатель, и вопроса нет. Лечится блоком, который гасит заимствование: ```rust let (f13, f12, f10) = { let mut f13 = vk::PhysicalDeviceVulkan13Features::default(); let mut f2 = vk::PhysicalDeviceFeatures2::default().push_next(&mut f13) /* … */; unsafe { instance.get_physical_device_features2(handle, &mut f2) }; (f13, /* … */ f2.features) }; ``` **Rust помог — порядок уничтожения.** В C++ `Renderer::Impl::~Impl` — это тридцать строк ручного разрушения в правильном порядке, и любая перестановка полей в объявлении структуры её не сломает, но и не поможет: связи нет. В Rust порядок полей **и есть** порядок уничтожения, а `Drop` у каждой обёртки снимает вопрос «а это уже освободили?». Ловушка осталась одна и она подписана в коде: `ash::Device` клонируется как таблица функций, а не как владеющая ссылка, поэтому контекст обязан объявляться последним. **Rust помог — ошибки как значения.** `CheckVk(r, where)` в `Renderer.cpp` — это ручная проверка каждого вызова с бросанием `runtime_error`. В порте её место занял `?` и трейт-расширение на четыре строки, навешивающее имя вызова. Пропустить проверку нельзя: `#[must_use]` на `VkResult` не даст. **Rust помог — незагруженный меш.** В C++ это `GpuMesh` с состоянием «пустой», метод `IsValid()` и отдельный флаг `hasMesh` в `Renderer`, которые обязаны быть согласованы. В порте — `Option`, и рассогласовать нечего. **Ничья — сам Vulkan.** Записи `vkCmdBeginRendering`, барьеры sync2, сборка конвейеров выглядят в обеих версиях почти одинаково, строка в строку. `ash` не пытается быть безопасным поверх Vulkan, и это правильно: `unsafe`-блоки локализуют ровно те места, где C++ тоже опасен, не притворяясь, что их нет. ## Итог Ни один из замеров не даёт разницы в порядок величины, так что решение к таблице не сводится. Что можно утверждать по итогам порта: **Порт занял примерно на пятую часть больше строк**, и почти весь перевес пришёлся на `simv-vk`. Причина одна и она названа выше: замены vk-bootstrap нет, и 301 строка `Context` с `Swapchain` превратилась в 606. Остальной Vulkan-слой — запись кадра, барьеры, сборка конвейеров — совпадает почти строка в строку. Если проект растёт дальше именно в Vulkan-часть, перевес разовый: инициализация пишется один раз. **Сборка: паритет везде, кроме одной клетки.** Полная сборка release у C++ вдвое быстрее (114 с против 194 с), полная debug — наоборот, медленнее (112 с против 83 с), инкрементальная в debug у Rust быстрее (5.2 с против 8.0 с). Единственная резкая клетка — инкрементальная release: 52 с против 4.4 с. Это цена thin LTO, а не языка: со сравнимым уровнем оптимизации (LTO выключен, как у MSVC `/O2` без LTCG) те же 5.5 с против 4.4 с. Практический вывод простой — держать в профиле разработки LTO выключенным, включая его только для выпуска. **Четыре дефекта в C++-версии нашлись при переносе, и ни один не искали.** Цикл в графе целей CMake, который проходит только из-за незалинкованной цели. Выгрузка меша с `vkDeviceWaitIdle` посреди записи кадра. Рассогласование `GpuMesh::IsValid()` и флага `hasMesh` — два источника правды об одном. Строка `Mesh ready`, которую троттлинг категории `MeshIO` отбрасывает всегда, так что счётчики после сварки не печатались никогда. Первое стало ошибкой сборки, второе — невозможным, третье — невыразимым, четвёртое всплыло при сверке чисел. **И двести строк мёртвого кода.** `Buffer` и `Image` собираются каждую сборку и никем не используются. Это не про язык, а про то, что переписывание само по себе работает как аудит. **Цена — время на границах.** Замыкание, которое трогает объект, из чьего метода оно вызвано; цепочки pNext, держащие заимствование; порядок уничтожения полей. Каждое место решается за полчаса, каждое решение оказывается не хуже исходного, но полчаса уходят на разговор с компилятором, а не на задачу. **Экосистема разнородна.** `egui` и его Vulkan-бэкенд выпускаются раз в месяц и ломают API; `ash` не менялся два года и не собирается. Замены vk-bootstrap нет вовсе. В C++ FetchContent тянет девять библиотек по фиксированным тегам и не имеет общего кэша: каждый свежий каталог сборки скачивает их заново. Выбор языка — за вами. Этот документ фиксирует только то, что удалось измерить и показать на коде.