5 Commits
Author SHA1 Message Date
NotBigGhostandClaude Opus 5 d7881496b0 Сверка документации с кодом: исправлены расхождения
Сплошная проверка документов против дерева. Код не менялся.

docs/rust_vs_cpp.md — числа, снятые с чужого каталога сборки:
- «775 МБ исходников зависимостей» и «10 пакетов в графе C++» получены
  на build/, сконфигурированном из другого дерева (его _deps содержит
  262 МБ nlohmann_json, который проект не объявляет). На деле девять
  объявленных репозиториев дают ~400 МБ исходников;
- пакетов в графе Rust 82, а не 76 (cargo tree по текущему Cargo.lock);
- итог по строкам 2976/494, а не 2962/492: строка «меш» устарела на 17
  строк игнорируемого диагностического теста, тесты в модулях — 176;
- «перевес почти весь в Vulkan-слое» — на деле 346 из 542 (64 %), из них
  305 на Context и Swapchain; остальное приходится на меш, редактор и
  приложение;
- «ни один замер не даёт разницы в порядок величины» неверно:
  инкрементальная release — 52.3 против 4.4 с, это 11.9x;
- граней с пятью и более вершинами в plane.obj 119, а не 121;
- из Catch2 перенесены все три случая, и добавлено ещё два, а не
  «три перенесены дословно»;
- деструктор Renderer::Impl — 22 строки, а не тридцать;
- путевых зависимостей у порта две: assets/meshes с откатом на текущий
  каталог и pipeline_cache.bin рядом с бинарём;
- граф целей CMake ацикличен, цикл существует на уровне исходников —
  именно поэтому CMake и молчит.

rust/README.md:
- объявленный rust-version = "1.82" недостижим: залоченные egui,
  egui-winit и epaint 0.36.1 требуют 1.95;
- перечни зависимостей крейтов были неполны, приведены целиком;
- build.rs читает ../../../shaders, а не ../../shaders;
- тесты не «чистая математика»: тесты загрузчика читают файлы из
  assets/meshes и требуют клон репозитория;
- assets/ порту никто не копирует, он находит корневую копию сам;
- те же поправки про цикл в CMake и про момент ожидания простоя.

README.md:
- панель называется Mesh, а не «Mesh load»;
- пресета clang-cl не существует, компилятор пресеты не фиксируют;
- версии Vulkan SDK и компиляторов сборкой не проверяются;
- Buffer и Image перечислены среди рабочих модулей vk/, хотя ими не
  пользуется никто.

docs/theory/solver_2x_sdf/README.md:
- предлагался несуществующий переключатель cfg.collision="bgk" и реестр
  операторов get; тот же файл двумя разделами ниже говорит, что оператор
  зафиксирован.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:11:06 +03:00
NotBigGhostandClaude Opus 5 fe5ca7f936 CLAUDE.md вне версионирования: один файл на все ветки
Файл описывает всё дерево целиком, а ветки содержат разные его части
(порт на Rust, исследование CFD, базовый C++-редактор). Отслеживаемая
копия при каждом переключении подменялась бы версией своей ветки, тогда
как нужна одна общая. Содержимое на всех ветках было идентично, так что
расхождений открепление не теряет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9Gcr11JJJyf8NnWsXjDzZ
2026-09-06 16:25:02 +03:00
NotBigGhostandClaude Opus 5 d6e1fa52b9 Уточнить цену замены vk-bootstrap измеренными числами
Было сказано «около сорока строк вызовов» на глаз. Счёт по одним и тем же
модулям (Context и Swapchain целиком, код без пустых и комментариев) даёт
301 строку в C++ против 606 в порте.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 20:59:32 +03:00
NotBigGhostandClaude Opus 5 1d7f9191ea Сравнение Rust- и C++-версий: замеры и разбор расхождений
Условия, цифры и разбор по коду. Замеры на одной машине подряд: сборка (полная,
тёплая, инкрементальная — в release и debug), размеры бинарей и каталогов, состав
и объём зависимостей, старт до готовности рендерера, процессорное время при 60 Гц,
строки по модулям одинаковыми правилами для обеих версий.

Главные наблюдения:
 * инкрементальная release-сборка Rust 52 с против 4.4 с — это цена thin LTO, а не
   языка: со сравнимым уровнем оптимизации 5.5 с против 4.4 с;
 * FetchContent клонирует 775 МБ истории на каждый каталог сборки и не имеет общего
   кэша; 76 крейтов Rust занимают 80 МБ в общем реестре;
 * загрузчики .obj сходятся на cow.obj точно и расходятся на plane.obj на 0.13%
   треугольников — из-за разной триангуляции многоугольников с n >= 5.

Плюс четыре дефекта C++-версии, найденных при переносе, в том числе строка
«Mesh ready», которую троттлинг категории MeshIO отбрасывает всегда: счётчики после
сварки не печатались никогда.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 20:58:33 +03:00
NotBigGhostandClaude Opus 5 f5c9e61589 Порт редактора SimVulcan на Rust
Та же программа на ash + winit + egui: орбитальная камера, три плоскости сетки,
загрузка .obj, три режима отображения, единый проход динамического рендеринга с
глубинным вложением и submit через synchronization2. Шейдеры общие с C++-версией —
build.rs компилирует их из shaders/ и вшивает SPIR-V в бинарь.

Пять крейтов повторяют карту целей CMake, но инвариант «весь Vulkan в одном месте»
теперь проверяет компилятор: у simv-mesh и simv-editor нет ash в зависимостях.

Расхождения, вынужденные языком, а не вкусом:
 * App переехал в исполняемый крейт — в C++ граф целей цикличен (core владеет vk,
   vk принимает core::Window&), и держится это лишь на том, что simv_vk видит
   заголовки core, не линкуя цель; Cargo такой цикл отвергает;
 * состояние сцены возвращается из замыкания интерфейса, а не ставится сеттерами:
   замыкание вызывается из метода рендерера, трогать рендерер оттуда нельзя;
 * выгрузка меша на видеокарту уехала из середины записи кадра, где C++-версия
   зовёт vkDeviceWaitIdle уже после захвата образа цепочки показа.

Vulkan-контекст и цепочка показа написаны руками: замены vk-bootstrap в экосистеме
Rust нет. Обёртки Buffer и Image, лежащие в C++-версии без применения, здесь
задействованы всеми потребителями.

13 тестов, из них три перенесены из Catch2 дословно. cargo clippy -D warnings чист,
слой валидации молчит.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 20:58:19 +03:00
60 changed files with 7123 additions and 15992 deletions
+2 -11
View File
@@ -41,20 +41,11 @@ docs/theory/solver_2x_sdf/out/
# (перегенерация требует GPU-сервера). См. заметку о них в README ветки research.
# ---------------------------------------------------------------------------
# Rust (docs/theory/2d_solver — решатель KBC на Rust)
# Rust (rust/ — порт редактора)
# ---------------------------------------------------------------------------
# Каталог сборки cargo: объектники, зависимости, инкрементальный кэш.
target/
# Cargo.lock для бинарного крейта обычно коммитят (воспроизводимость прогонов) —
# он НЕ игнорируется намеренно.
# Артефакты прогонов решателя: гифки, ряды, сводки. Воспроизводятся запуском.
docs/theory/2d_solver/*.gif
docs/theory/2d_solver/*.csv
docs/theory/2d_solver/out/
# Результаты валидационной кампании — гигабайты гифок и рядов. Сам список сценариев
# (bench/scenarios.json) лежит рядом с драйверами и версионируется.
docs/theory/2d_solver/bench/out/
# Cargo.lock для приложения коммитят (воспроизводимость сборки) — он НЕ игнорируется.
# ---------------------------------------------------------------------------
# Редакторы и ОС
+11 -3
View File
@@ -11,16 +11,18 @@ reference grid planes through the origin, with `.obj` model loading and display.
at the origin.
* `.obj` loading with three display modes — solid (flat-shaded), wireframe, and
solid + wireframe overlay.
* ImGui interface (Viewport controls + Mesh load panel).
* ImGui interface — two windows, titled `Viewport` and `Mesh`.
All Vulkan code is isolated in `src/vk/`; the rest of the app is Vulkan-free.
## Targets
* Windows x64 (MSVC / clang-cl)
* Windows x64 (MSVC)
* Linux x64 (gcc / clang)
* macOS arm64 (Apple Silicon, via MoltenVK)
The presets do not pin a compiler — each inherits whatever the environment provides.
## Prerequisites
* Vulkan SDK 1.3.290+ (`https://vulkan.lunarg.com/`)
@@ -28,6 +30,10 @@ All Vulkan code is isolated in `src/vk/`; the rest of the app is Vulkan-free.
* Ninja
* C++20 compiler (MSVC 19.36+, gcc 11+, clang 14+)
Only CMake 3.26 and C++20 are enforced by the build. `find_package(Vulkan)` carries no
version argument, and the compiler minimums above are recommendations, not checks — the
de-facto SDK floor comes from the pinned volk and vk-bootstrap 1.3.295.
## Build
```sh
@@ -50,8 +56,10 @@ ctest --preset windows-msvc-debug
```
src/
core/ Logger, Window, App (orchestration, Vulkan-free)
vk/ All Vulkan: Context, Swapchain, Buffer, Image, Shader, GpuMesh,
vk/ All Vulkan: Context, Swapchain, Shader, GpuMesh,
Renderer (frame loop + depth + ImGui), MeshRenderer, GridRenderer
(Buffer and Image are also built here but used by nobody — the
renderers call VMA directly)
mesh/ CPU mesh: tinyobjloader + welding (no Vulkan)
editor/ Camera, mouse input, EditorUI + MeshLoadPanel (ImGui, no Vulkan)
app/ main.cpp
Binary file not shown.
+416
View File
@@ -0,0 +1,416 @@
# 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 |
| всего пакетов в графе | 9 | **82** |
| исходники зависимостей на диске | **~400 МБ** на каждый каталог сборки | **80 МБ** в общем реестре |
Пятикратная разница в размере бинаря — это в основном статически влинкованная стандартная
библиотека Rust и форматирование `core::fmt`; C++-версия тянет CRT из системы. Разница в
числе пакетов (82 против 9) впечатляет ровно до того момента, как посмотреть на объём:
82 крейта Rust занимают в пять раз меньше места, чем девять библиотек C++, потому что
`.crate` — это архив выпуска, а `FetchContent` — клон с историей.
> **Поправка.** В первой редакции здесь стояли «775 МБ» и «10 пакетов». Обе цифры сняты
> с каталога `build/`, сконфигурированного из другого дерева исходников: его `_deps/`
> содержит 262 МБ `nlohmann_json`, которого этот проект не объявляет. Девять объявленных
> репозиториев дают около 400 МБ исходников и около 510 МБ всего `_deps` после сборки.
> Число пакетов Rust получено `cargo tree -e normal` по текущему `Cargo.lock`.
### Выполнение
Обе версии показывают в режиме 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` 119 граней с пятью и более вершинами. 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** | 412 | 51 | 72 | **289** |
| редактор (`editor`) | 305 | 68 | 20 | **217** | 431 | 48 | 62 | **321** |
| приложение (`app`) | 85 | 14 | 5 | **66** | 226 | 27 | 43 | **156** |
| тесты | 65 | 9 | 5 | **51** | *внутри модулей* | | | *176* |
| **итого** | **3181** | 604 | 143 | **2434** | **3870** | 400 | 494 | **2976** |
| система сборки | 431 | | | **399** | 232 | | | **197** |
Порт длиннее примерно на пятую часть, и большая часть перевеса — в Vulkan-слое: +346
строк кода в `simv-vk` — это написанная руками замена vk-bootstrap. Но «почти весь» было
бы преувеличением: из 542 строк общего перевеса на Vulkan-слой приходится 346 (64 %), а
внутри него на `Context` + `Swapchain` — 305 (56 % от общего). Остальное настоящее: меш
+117, редактор +104, приложение +90, `core` −64. Приложение выросло с 66 до 156 строк,
потому что в него переехал `App` из `simv_core` (см. расхождение 1) и поиск каталога
моделей стал честным обходом предков вместо пяти захардкоженных `../`.
Комментариев в порте втрое больше (494 против 143), и это перекос замера, а не свойство
языка: значительная их часть — пометки «здесь расхождение с C++-версией и вот почему»,
написанные ради этого документа. По строкам собственно кода разрыв — 2976 против 2434,
и он меньше, если вычесть тесты: они в Rust живут внутри модулей (176 строк), в C++ —
отдельной целью (51 строка).
## Структурные расхождения
Каждое — с причиной. Ни одно не сделано «чтобы было красивее».
### 1. `App` переехал в исполняемый крейт
В C++ цикличны исходники: `core::App` владеет `vk::Renderer`, а `vk::Renderer`
принимает `core::Window&`. Граф целей CMake при этом ацикличен — потому CMake ни на что
и не жалуется: `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 <volk.h>` в
`mesh/ObjLoader.cpp`, все заголовки видны. В порте `simv-mesh` и `simv-editor` просто не
имеют `ash` в зависимостях: нарушение не соберётся.
Побочный эффект: `EditorUI` не может назвать тип `vk::RenderMode`. C++ обходит ту же
границу, возвращая из панели голый `int`, который `main.cpp` приводит
`static_cast<vk::RenderMode>` — связь двух перечислений там держится на честном слове.
В порте у панели свой `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<void()>`, а замыкание из `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<Mesh>`, и исключение из рабочего потока
всплывает при `get()`. В порте поток шлёт `Result<Mesh, ObjError>` по каналу, панель
опрашивает `try_recv()`. Разница не в количестве кода (его столько же), а в том, что
ошибка стала значением: пропустить её нельзя, а тип в сигнатуре перечисляет, что вообще
может пойти не так.
### 8. SPIR-V вшит в исполняемый файл
`build.rs` компилирует шейдеры в `OUT_DIR`, откуда они попадают в бинарь через
`include_bytes!`. Вместе с этим исчезли: функция `FindSpvPath` с перебором путей, два
POST_BUILD-шага копирования каталогов в `CMakeLists.txt`, требование запускать редактор
из его собственного каталога и целый класс ошибок «шейдер не найден» во время выполнения
— отсутствующий шейдер теперь ломает сборку. Путевых зависимостей осталось две:
`assets/meshes`, который ищется подъёмом по предкам бинаря и лишь затем — по предкам
текущего каталога, и `pipeline_cache.bin`, который пишется рядом с бинарём.
### 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<GpuMesh>`, и рассогласовать нечего.
**Ничья — сам Vulkan.** Записи `vkCmdBeginRendering`, барьеры sync2, сборка конвейеров
выглядят в обеих версиях почти одинаково, строка в строку. `ash` не пытается быть
безопасным поверх Vulkan, и это правильно: `unsafe`-блоки локализуют ровно те места, где
C++ тоже опасен, не притворяясь, что их нет.
## Итог
Кроме одной клетки, ни один замер не даёт разницы в порядок величины, так что решение к
таблице не сводится. Исключение — инкрементальная сборка release: 52.3 с против 4.4 с,
это 11.9×, и объясняется оно thin LTO, а не языком (разбор ниже). Следующие по величине
разрывы уже укладываются в порядок: исходники зависимостей ~5×, размер бинаря 4.9×.
Что можно утверждать по итогам порта:
**Порт занял примерно на пятую часть больше строк**, и бо́льшая часть перевеса пришлась на
`simv-vk`. Главная причина названа выше: замены vk-bootstrap нет, и 301 строка
`Context` с `Swapchain` превратилась в 606 — это 305 строк из 542 общего перевеса, то есть
чуть больше половины, а весь Vulkan-слой даёт 346 (64 %). Остальной 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 тянет девять библиотек по фиксированным тегам и не имеет общего кэша: каждый
свежий каталог сборки скачивает их заново.
Выбор языка — за вами. Этот документ фиксирует только то, что удалось измерить и показать
на коде.
-14
View File
@@ -1,14 +0,0 @@
# Каталог сборки cargo — сотни мегабайт, внутри образа он собирается заново.
target/
# Результаты прогонов: гифки и ряды монтируются с хоста, в образ не кладутся.
bench/out/
*.gif
*.csv
*.json
!bench/scenarios.json
# Мусор редакторов и ОС
.vs/
.vscode/
.idea/
.DS_Store
Thumbs.db
-1308
View File
File diff suppressed because it is too large Load Diff
-35
View File
@@ -1,35 +0,0 @@
[package]
name = "kbc2d"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Двумерный решатель LBM D2Q9 с энтропийным оператором столкновения KBC (модель D / N1)"
[[bin]]
name = "kbc2d"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
rayon = "1"
gif = "0.13"
bytemuck = { version = "1", features = ["derive"] }
wgpu = { version = "22", optional = true }
pollster = { version = "0.3", optional = true }
[features]
default = ["gpu"]
# GPU-бэкенд на wgpu (Vulkan/DX12/Metal). Отключается: --no-default-features
gpu = ["dep:wgpu", "dep:pollster"]
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
panic = "abort"
# Отдельный профиль для прогонов с проверками переполнения индексов
[profile.release-dbg]
inherits = "release"
debug = true
panic = "unwind"
-79
View File
@@ -1,79 +0,0 @@
# Образ решателя и валидационной кампании.
#
# Поддерживает два способа добраться до видеокарты, и выбирать между ними не нужно —
# доступным окажется ровно один:
#
# 1. НАСТОЯЩИЙ LINUX-СЕРВЕР с драйвером NVIDIA. Vulkan-ICD подкладывает внутрь
# NVIDIA Container Toolkit, и только если в NVIDIA_DRIVER_CAPABILITIES есть
# `graphics` — с одним `compute` wgpu не увидит ни одного адаптера. Запуск через
# docker-compose.server.yml.
#
# 2. WSL2. Драйвер NVIDIA для Linux там отсутствует как класс: карта отдаётся через
# /dev/dxg по протоколу WDDM, а нативный libGLX_nvidia про него не знает и
# enumerate возвращает ноль устройств. Зато с /dev/dxg умеет говорить dzn (Dozen) —
# драйвер Mesa, транслирующий Vulkan в D3D12. Он в образе и лежит. NVIDIA Container
# Toolkit при этом НЕ НУЖЕН: нужен проброс устройства и монтирование /usr/lib/wsl,
# где Microsoft держит libd3d12.so. Запуск через docker-compose.wsl.yml.
#
# Сборка и быстрая проверка:
# docker build -t kbc2d docs/theory/2d_solver
# docker compose -f docker-compose.wsl.yml --profile check run --rm vulkan
# ── сборка ───────────────────────────────────────────────────────────────────
# Версия тулчейна закреплена: та же, на которой решатель собирался и проверялся.
FROM rust:1.97-bookworm AS build
WORKDIR /src
# Сначала только манифесты — тогда слой с зависимостями переиспользуется, пока они не менялись.
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release --locked 2>/dev/null || true && rm -rf src
COPY src ./src
# touch нужен, чтобы cargo не спутал новые исходники с заглушкой из слоя выше
RUN touch src/main.rs && cargo build --release --locked
# ── исполнение ───────────────────────────────────────────────────────────────
# База Arch, а не debian-slim, по одной причине: dzn собирают не все дистрибутивы.
# В mesa-vulkan-drivers Debian и Ubuntu его нет (проверено по списку файлов пакета),
# в Arch он лежит отдельным пакетом vulkan-dzn той же версии Mesa, что и на хосте WSL.
FROM archlinux:base
ARG VERSION=dev
ARG REVISION=unknown
LABEL org.opencontainers.image.title="kbc2d"
LABEL org.opencontainers.image.description="Двумерный решатель LBM D2Q9 с энтропийным столкновением KBC"
LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.revision="${REVISION}"
LABEL org.opencontainers.image.source="https://gitea.arseniev.info/NotBigGhost/TurbulenceCAD"
# Справка, руководства и переводы занимают в базе Arch заметно больше, чем сам
# драйвер, а в контейнере не нужны никому. NoExtract прописывается ДО установки.
RUN { echo 'NoExtract = usr/share/man/*'; \
echo 'NoExtract = usr/share/doc/*'; \
echo 'NoExtract = usr/share/info/*'; \
echo 'NoExtract = usr/share/locale/*'; \
echo 'NoExtract = usr/share/i18n/*'; } >> /etc/pacman.conf \
&& pacman -Syu --noconfirm --needed \
vulkan-icd-loader vulkan-dzn vulkan-tools python \
&& pacman -Scc --noconfirm \
&& rm -rf /var/cache/pacman/pkg/* /var/lib/pacman/sync/* \
/usr/share/man /usr/share/doc /usr/share/info /usr/share/locale
# Проверка на этапе сборки: без dzn образ бесполезен для WSL, и узнать об этом надо
# здесь, а не через час после старта кампании.
RUN ls /usr/share/vulkan/icd.d/ && ls /usr/share/vulkan/icd.d/dzn_icd*.json > /dev/null
# Для пути 1 (настоящий сервер). На WSL эти переменные ни на что не влияют.
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics
WORKDIR /work
COPY --from=build /src/target/release/kbc2d /work/target/release/kbc2d
COPY bench /work/bench
RUN chmod +x /work/bench/run_campaign.sh
# Кампания идёт десятки часов, поэтому по умолчанию образ НЕ запускает её: случайный
# `docker run` покажет смету и выйдет.
WORKDIR /work/bench
ENTRYPOINT ["./run_campaign.sh"]
CMD ["--dry-run"]
-609
View File
@@ -1,609 +0,0 @@
# kbc2d — двумерный решатель LBM D2Q9 с энтропийным столкновением KBC
Переписанный на Rust решатель обтекания тела в канале. Физика — та же, что в
`docs/theory/solver_2x_sdf` (python/CuPy), но собранная заново: с тестами против формул
первоисточников, двумя взаимозаменяемыми бэкендами и анимацией, привязанной к физическому
времени потока.
Оператор столкновения — энтропийный KBC по работам Bösch, Chikatamarla, Karlin: *Entropic
Multi-Relaxation Models for Simulation of Fluid Turbulence* (arXiv:1507.02509, двумерная
реализация) и *Entropic multi-relaxation lattice Boltzmann scheme for turbulent flows* (2024,
трёхмерная). Состав сдвиговой части выбирается ключом `--kbc-model`: `n1` — только девиатор
{N, Π_xy} (в 2D-статье KBC D), `n2` — девиатор со следом {N, Π_xy, T} (KBC C). Статьи лежат в
`docs/origins/`; ссылки на формулы в коде и ниже даны по нумерации 2D-статьи.
## Устройство
Пять файлов по ролям — каждый отвечает ровно за одно:
| файл | роль |
|---|---|
| `src/math.rs` | **математика решателя.** Решётка D2Q9, энтропийное равновесие в product-form, проектор на сдвиг, стабилизатор γ, столкновение, Zou–He, геометрия тел через SDF, перевод единиц, спектральная диагностика. Всё узловое и чистое; здесь же тесты против формул статьи. |
| `src/cpu.rs` | **бэкенд под процессор.** Раскладка AoS, обход сетки, rayon, сборка Bouzidi-линков, связка уровней AMR. Физику берёт из `math`. |
| `src/gpu.rs` | **бэкенд под видеокарту.** wgpu + WGSL (Vulkan/DX12/Metal), раскладка SoA. Физика построчно повторяет `math.rs` на f32; топологию (маски, линки, рамка патча) не дублирует, а берёт из `cpu`. |
| `src/main.rs` | **запуск и оркестрирование.** Разбор параметров, сборка постановки, цикл по шагам, живой вывод и итоговый отчёт, выгрузка рядов в CSV. Здесь же контракт `Spec` / `StepRec` / `FieldKind`, общий для обоих бэкендов. |
| `src/gif.rs` | **создание гифок.** Тайминг относительно физического времени, палитры, нормировка, служебная надпись, кодирование. |
Рядом, вне этого разделения: `bench/` — валидационная кампания (список сценариев и драйверы под
Linux и Windows), `Dockerfile` с `docker-compose.yml` — образ для развёртывания на сервере.
## Сборка и запуск
Нужен Rust 1.75+.
```sh
cargo build --release # с GPU-бэкендом
cargo build --release --no-default-features # только CPU (без wgpu)
cargo test --release # 29 быстрых тестов
cargo test --release -- --include-ignored # плюс 4 эталона статьи (~18 с)
```
Сборка без GPU кладёт бинарь по тому же пути, поэтому собирать её ПОСЛЕДНЕЙ нельзя: `kbc2d`
окажется перезаписан вариантом без wgpu, и `--backend gpu` будет отказывать. Порядок —
сначала `--no-default-features`, потом обычная.
На сервер удобнее ставить образом — см. [Развёртывание](#развёртывание-docker).
Пример: цилиндр Re=150, гифка завихренности в реальном времени.
```sh
./target/release/kbc2d --shape cylinder --size 24 --re 150 \
--nx 480 --ny 240 --steps 40000 --sponge-len 32 \
--gif wake.gif --gif-field vorticity --verbose full
```
`--help` показывает все ключи, разбитые по группам: Физика, Сетка, Тело, Время, Схема,
Анимация, Вывод.
## Синхронизация анимации с физическим временем
Требование: гифка идёт с той же скоростью, что и настоящий поток, независимо от того, с какой
скоростью считает машина. Реальная производительность в тайминг не входит вообще.
В LBM скорость самой решётки жёстко равна c = δx/δt = 1, поэтому шаг по времени однозначно
определяется тем, какую **решёточную** скорость `u_lat` мы назначаем физическому потоку:
```
δt = u_lat · δx / u_phys [с/шаг]
шагов в секунду = u_phys / (u_lat · δx)
задержка кадра = (шагов на кадр) · δt / playback
```
**Про пример из постановки.** «30 м/с, ячейка 0.1 м ⇒ 300 шагов/с» — это арифметика
δt = δx/u_phys, то есть `u_lat = 1`: поток проходит ровно ячейку за шаг. Формула
воспроизводится буквально ключом `--u-lat 1.0` (тест `units_time_scaling` это проверяет), но
физически такой режим негоден: Ma = u_lat/c_s = √3 ≈ 1.73, сверхзвук, разложение
Чепмена–Энскога не работает. Поэтому по умолчанию `u_lat = 0.05` (Ma ≈ 0.087), и те же 30 м/с
при ячейке 0.1 м дают 6000 шагов/с. Синхронность гифки выдерживается в обоих случаях — меняется
только, сколько шагов приходится на кадр.
**Две тонкости формата GIF**, обе разобраны:
1. *Задержка хранится в сотых долях секунды.* Точная физическая задержка почти никогда не целая:
30 кадр/с — это 3⅓ сотых. Покадровое округление до 3 дало бы анимацию на 11% быстрее
реальности, и уход копился бы линейно (к тысячному кадру — 3.3 секунды). Поэтому задержки
выдаёт накопитель `DelayDither`: суммарное время кадров отслеживает точное физическое с
точностью до одной сотой (3, 3, 4, 3, 3, 4, …), ошибка ограничена ±5 мс и не растёт.
2. *Меньше одной сотой не бывает.* При физически корректном `u_lat` кадр каждые 10 шагов — это
600 кадр/с, чего формат не умеет. Поэтому режим по умолчанию `--gif-every auto` подбирает
шаг сам под `--gif-fps` (30) так, чтобы получилось ровно реальное время. Если шаг задан
жёстко и задержка не представима, программа печатает фактический коэффициент расхождения и
конкретный совет, как починить.
`--gif-speed 0.1` даёт замедление в 10 раз (тоже точно, через тот же накопитель).
## Шаг — не фиксированная порция времени
Физическая длительность шага привязана к размеру клетки: δt = u_lat·δx/u_phys. Уменьшили клетку
вдвое — вдвое уменьшился и δt, и то же число шагов покроет вдвое меньше физического времени.
Отсюда `--time`: задаёте длительность в секундах, число шагов считается само.
Измельчение стоит дважды: клеток становится (1/δx)², а шагов на ту же секунду — 1/δx, итого
работы ~(1/δx)³ в двумерии. И помните, что `--size` задаётся В КЛЕТКАХ: уменьшив клетку и не
тронув `--size`, вы уменьшите тело физически.
Судить о длительности удобнее всего по **конвективным временам D/U** — их печатает шапка. Это
единственная мера, не зависящая ни от сетки, ни от выбора u_lat.
## Что параметризовано
- **поток**: скорость (м/с), направление, число Рейнольдса, решёточная скорость (число Маха);
- **постановка**: `--case channel` — обтекание тела; `taylor-green`, `shear-layer`,
`decaying-turbulence` — периодические эталоны из статей, без тела и граничных условий;
- **сетка**: размеры домена и размер ячейки в метрах (плотность сетки), коэффициент измельчения
вложенного патча и его границы;
- **тело**: восемь форм на выбор — `cylinder`, `square`, `diamond`, `ellipse`, `naca`,
`triangle`, `plate`, `polygon` — плюс характерный размер, относительная толщина, угол атаки и
положение. Профиль задаётся четырёхзначным кодом (`--naca 4412`, с кривизной), произвольный
контур — списком вершин (`--poly "x,y;x,y;…"`), **несколько тел сразу** — списком
`--bodies "cylinder:d=24,x=120,y=120; naca:d=48,x=260,y=120,a=8"` (сила считается по каждому
телу отдельно, до четырёх);
- **время**: длительность прогона — либо числом шагов (`--steps`), либо прямо в СЕКУНДАХ
физического времени (`--time`, число шагов считается как time/δt); начальное поле (однородный
поток либо покой с разгоном), длина разгона, амплитуда и длительность стартового возмущения;
- **схема**: оператор столкновения (`kbc`/`bgk`), состав сдвиговой части (`n1`/`n2`), модель
стенки на теле (`hrr`/`grad`/`bouzidi`/`staircase`), режим выхода, поглощающие губки перед
выходом и после входа, бэкенд, число потоков;
- **анимация**: файл, поле (`speed`/`vorticity`/`density`/`gamma`), палитра, масштаб, шаг кадра,
частота, скорость воспроизведения, диапазон нормировки, усреднение k×k клеток в пиксель
(`--gif-downsample`, без него кадр с сетки 4096×2048 неподъёмен);
- **вывод**: период живых строк, три уровня подробности, число окон в отчёте о сходимости, CSV
рядов с прореживанием (`--series-every`), x–t диаграмма осевой линии (`--xt`), метрики
эталонных течений (`--case-csv`) и машиночитаемая сводка всего прогона (`--summary`).
## Что печатает отчёт
*Шапка* — вся постановка с производными величинами: δt, шагов на секунду, Ma, τ, физическая
вязкость, блокировка канала, геометрия патча, полный план тайминга анимации.
*Живой вывод* — шаг, физическое время, ⟨ρ⟩, max|u|, Cd, Cl, скорость счёта и ETA; на уровне
`full` дополнительно ⟨γ⟩ с размахом, доля вырожденных узлов, доля узлов с ξ < 0, MLUPS и
отношение скорости счёта к реальному времени.
*Итог* — установившийся режим (St, ⟨Cd⟩, rms Cl, ⟨Cm⟩) сырой и с поправкой на блокировку, рядом
литературные значения для цилиндра; таблица сходимости по окнам с вердиктом о дрейфе массы и
насыщении; разбор стабилизатора γ; производительность.
## Состояние проверки
Опора — статьи авторов метода из `docs/origins/`, а не сторонние реализации. Ссылки на формулы
даны по нумерации 2D-статьи (arXiv:1507.02509); там, где полезнее формулировка из работы 2024
года по трёхмерной реализации, это отмечено отдельно.
### Оператор столкновения сверен с листингом статьи
Работа 2024 года приводит оператор явным пошаговым листингом (разд. IV). Реализация повторяет
его дословно: ρ, u → f^eq → s и s^eq → Δs = s − s^eq → **Δh = h − h^eq = f − f^eq − Δs** →
γ по замкнутой оценке → **f′ = f − β(2Δs + γΔh)**.
Проверено тестами (`cargo test`, 29 быстрых + 4 длинных):
- **проектор Δs** совпадает с матричным `M⁻¹·D·M` в базисе натуральных моментов (6)–(7) до 1e-13
— для обоих составов сдвиговой части; идемпотентен, не несёт ни массы, ни импульса;
- **γ из замкнутой оценки** (ур. 17 / ур. 25 работы 2024) — корень условия максимума энтропии
(ур. 15 / 23): невязка при γ\* более чем в 20 раз меньше, чем при γ\*±1;
- **при γ = 2 схема совпадает с LBGK** поточечно — как и заявлено под ур. (14);
- **сдвиговые моменты релаксируют ровно с 2β при любой γ** (β = 0.3, 0.6, 0.95, обе модели) —
именно это гарантирует, что стабилизатор не трогает вязкость;
- **вязкость по ур. (5)** воспроизводится затуханием сдвиговой волны точнее 1% (τ = 0.6 и 1.0);
- **объёмная вязкость по ур. (57)**: ξ = ν при следе в сдвиговой части и ξ = c_s²(1/(γβ) − ½)
без него;
- равновесие в product-form сохраняет ρ и ρu до 1e-13; Zou–He ставит ровно заданные скорость
на входе и плотность на выходе; SDF всех восьми форм даёт верный знак и |∇φ| = 1 ± 0.05
(у эллипса для этого пришлось считать ближайшую точку итеративно: дешёвое приближение давало
|∇φ| = 0.57 вдали от поверхности);
- **HRR-сборка сохраняет моменты**, ради которых затевалась: ρ, ρu и Π восстановленной функции
распределения совпадают с целевыми, а при нулевой скорости она совпадает с Градовой (все
коэффициенты 3-го порядка рекурсивно обращаются в ноль).
### Вихрь Тейлора–Грина: второй порядок сходимости (разд. VI)
Единственное из трёх эталонных течений статьи с ТОЧНЫМ аналитическим решением, поэтому
проверяется не «похоже на чужой прогон», а прямое совпадение с формулой:
u = ∇×[(u₀/k₂)cos(k₁x)cos(k₂y)·exp(−ν(k₁²+k₂²)t)], k₁ = 1, k₂ = 4,
область 0 < x,y < 2π на N×N, Re = u₀N/ν, полураспад t_c = ln2/[ν(k₁²+k₂²)].
Старт — приближением Града (ур. 58), как в статье. Метрика — как на рис. 1:
Σ|u_x − u_x^точн| / Σ|u_x^точн| в момент t_c.
| N | u₀ | Re | полная | амплитуда | форма |
|---|---|---|---|---|---|
| 64 | 0.03 | 100 | 6.68e-3 | 6.49e-3 | 8.65e-4 |
| 128 | 0.015 | 100 | 1.61e-3 | 1.57e-3 | 2.07e-4 |
| 256 | 0.0075 | 100 | 4.00e-4 | 3.89e-4 | 4.36e-5 |
**Порядок 2.05 и 2.01** — второй порядок статьи воспроизведён, причём отдельно по амплитуде
(скорость затухания) и по форме.
Два места, где пришлось разобраться, и оба поучительны:
1. **Давление в начальных условиях.** Течение несёт собственное поле давления порядка ρu₀²,
находимое из ∇²p = 2ρ(ψ_xx·ψ_yy − ψ_xy²):
`p = −(ρu₀²/4)[cos(2k₁x) + (k₁²/k₂²)cos(2k₂y)]`. Старт с ρ ≡ 1 сбрасывает эту разницу в
акустику, которая в периодическом ящике почти не затухает и садится полкой на ошибку. Работа
2024 года делает то же самое явно: там начальные ρ и старшие моменты получают, решая
∂ρ/∂t + ∇·(ρu₀) = D∇²ρ до стационара.
2. **Способ измельчения.** При фиксированном u₀ ошибка упирается в полку O(Ma²), от сетки не
зависящую (измерено: относительная ошибка формы ∝ u₀¹·⁰⁷, то есть абсолютная ∝ Ma²).
Второй порядок виден целиком только при диффузионном измельчении — ν фиксирована, u₀ ∝ 1/N,
тогда Re сохраняется, а Маха падает вместе с сеткой. Это свойство слабо-сжимаемого метода,
а не реализации: **LBGK на том же тесте даёт ту же полку** (1.39/1.37/1.36e-3 против
0.86/1.05/1.23e-3 у KBC), что согласуется с утверждением статьи «все модели работают
практически одинаково».
### Дважды периодический сдвиговый слой (разд. VII)
Второй эталон статьи: N = 128, Re = 30000, u₀ = 0.04, κ = 80, δ = 0.05, одно конвективное
время. Отношение энстрофии к начальной сходится с fp64-значением 0.6035. Тест чувствителен
именно к тому, что важно: на испорченном (абсолютном) пороге вырожденности γ тот же прогон
даёт 0.6599, то есть +9.3%, а чистый LBGK при этих параметрах разваливается.
### Порог вырожденности γ
`GREL = 1e-8` — **относительный** порог, доля от ⟨Δ|Δ⟩, а не абсолютный. Знаменатель ⟨Δh|Δh⟩
квадратичен по неравновесию и физически мал (~1e-7…1e-9 в развитом следе), поэтому абсолютный
порог срабатывает на подавляющем большинстве узлов и молча подменяет γ на 2 — то есть гонит
чистый LBGK вместо KBC. Работа 2024 года прямо об этом: γ «далеко не постоянна», её эволюция
тесно связана с состоянием потока, и «любой MRT с γ = const не достигнет той же устойчивости».
Доля вырожденных узлов печатается в отчёте; на исправном пороге она обязана быть ~0.
Единственное исключение — самый первый шаг: поле в точности равно равновесию, Δ ≡ 0, и порог
честно срабатывает везде. На результат это не влияет: γ умножается на Δh = 0.
### Выбор состава сдвиговой части (табл. I)
Ключ `--kbc-model`:
- `n1` (умолчание) — s = {N, Π_xy}, только девиатор. 2D-статья: KBC D; 3D: KBC-N1.
- `n2` — s = {N, Π_xy, T}, девиатор со следом. 2D-статья: KBC C; 3D: KBC-N2.
По точности они неразличимы, как и заявляет статья: на одной постановке St 0.1828 у обоих,
⟨Cd⟩ 1.4479 против 1.4468, rms Cl 0.383 против 0.388.
Разница — в объёмной вязкости (ур. 57). У `n2` она фиксирована: ξ = ν. У `n1` она равна
c_s²(1/(γβ) − ½) и, поскольку измеренная ⟨γ⟩ ≈ 1.73 < 2, в среднем оказывается примерно
вчетверо БОЛЬШЕ ν. Отрицательной она бывает лишь в долях процента узлов. Практический вывод
против ожидания: `n1` демпфирует продольную акустику сильнее, и в специально испорченной
постановке (старт из покоя, губка выключена) `n1` доживает до конца с пульсацией 75% от U,
а `n2` разваливается. Поэтому умолчание — `n1`.
### Модель стенки на теле: насколько она субсеточная
Ключ `--wall`:
- `hrr` (умолчание) — восстановление по целевым моментам с **рекурсивной регуляризацией**
(Malaspinas 2015; Coreixas и др., PRE 96, 033306): ряд Эрмита продолжен до 3-го порядка, а
коэффициенты 3-го порядка не считаются по популяциям, а выражаются через 2-й рекурсивно:
`a₃_xxy = 2u_x·a₂_xy + u_y·a₂_xx`, `a₃_xyy = 2u_y·a₂_xy + u_x·a₂_yy`. В D2Q9 `a₃_xxx` и
`a₃_yyy` решёткой не поддерживаются и отбрасываются; множитель 1/2c_s⁶ (а не 1/6c_s⁶) учитывает
три перестановки индексов;
- `grad` — то же самое с обрывом ряда на тензоре давлений: условие Града (Dorschner, Bösch,
Chikatamarla, Boulouchos, Karlin, JFM 801 (2016), разд. 2.1 и прил. B). Задаются не популяции,
а целевые моменты — ρ, u и Π, — после чего недостающие популяции собираются приближением
Града (2.13);
- `bouzidi` — интерполированный отскок: доля пересечения q входит в КАЖДУЮ восстанавливаемую
популяцию, полинково;
- `staircase` — простой отскок, q игнорируется. Не для счёта: это база сравнения, показывающая,
сколько именно даёт субсеточность.
Целевые моменты у `hrr` и `grad` одни и те же — (B 1) и (B 3) прил. B JFM 801; отличается только
то, до какого порядка восстанавливается функция распределения по этим моментам.
**Субсеточность — измеренная.** Прямой тест: сдвигаем тело внутри клетки и смотрим, насколько
поедет Cd. У по-настоящему субсеточной границы ответ не должен зависеть от того, где тело стоит
относительно узлов (Re = 20, D = 16, стационар, пять положений на полклетки, GPU):
| модель | разброс Cd | Cd |
|---|---|---|
| `staircase` | 0.98% | 2.482–2.506 |
| `grad` | 0.62% | 2.449–2.464 |
| `hrr` | 0.61% | 2.450–2.464 |
| `bouzidi` | **0.14%** | 2.459–2.463 |
**HRR не улучшает разрешение геометрии и не должен** — 0.61% против 0.62% у Града. Это следует
из устройства обеих схем: третий порядок Эрмита уточняет ВОССТАНОВЛЕНИЕ популяций по моментам, а
положение стенки входит в моментные схемы совсем другим местом. Обе моментные схемы оказываются
ровно между ступенькой и Bouzidi, и это тоже следует из их устройства: положение
стенки входит туда ТОЛЬКО через целевую скорость (B 1) — одну усреднённую по узлу величину.
Целевая плотность (B 3) — обычная сумма отскочивших и известных популяций, без q вовсе; тензор
давлений — конечные разности по решётке, тоже без q. Плюс все недостающие популяции узла
собираются из ОДНОГО набора моментов, так что полинковая направленность теряется. Bouzidi же
подставляет свою q в каждую популяцию отдельно. Ступенчатой поверхность у моментных схем не
становится, но геометрия у них разрешена заметно грубее.
**Сходимость по разрешению тела.** Физическая постановка фиксирована (домен 15D × 10D,
блокировка 0.1, Re = 20), меняется только число клеток на диаметр:
| D | `bouzidi` | `grad` |
|---|---|---|
| 8 | 2.581 | 2.618 |
| 16 | 2.529 | 2.534 |
| 32 | **2.521** | **2.521** |
Обе модели состоятельны и сходятся к одному пределу с наблюдаемым порядком ≈2.7; к D = 32 они
неразличимы. Но на грубой сетке Град заметно хуже: ошибка при D = 8 равна 0.097 против 0.060.
Для сравнения, `staircase` при D = 16 даёт 2.69 — то есть +6.7% к пределу, тогда как обе
субсеточные модели держатся в пределах +0.4%.
**Зачем тогда моментные схемы.** Их преимущество в статье — не геометрическая точность, а
устойчивость на турбулентных режимах (авторы пишут, что интерполяционные схемы «ограничены
низкими числами Рейнольдса, поскольку на границе возникают паразитные скачки») и естественная
форма для подвижных стенок: скорость стенки входит в целевые значения, а не отдельной поправкой.
В здешней канальной постановке преимущества по устойчивости воспроизвести не удалось: при росте
Re обе модели теряют счёт на одном и том же значении (Re ≈ 5·10⁴ при теле в 16 клеток), то есть
ограничивает не стенка, а что-то другое — вероятнее всего Zou–He при τ → ½.
**Умолчание — `hrr`, и это решение временное.** Оно принято по устройству схемы (третий порядок
Эрмита фильтрует высокочастотный мусор у стенки, чего обрыв на Π не делает), а не по здешним
измерениям: на стационарном цилиндре при Re = 20 отличить `hrr` от `grad` нельзя вовсе. Вопрос
ставит ребром группа C кампании — там обе моментные схемы и Bouzidi гоняются на Re = 20, 150 и
2000. Если данные не подтвердят преимущества HRR на турбулентном режиме, умолчанием станет
`bouzidi`, у которого измеренное разрешение геометрии вчетверо лучше.
Все четыре модели работают на обоих бэкендах и совпадают между ними до 0.007% по Cd. Это
специально проверяется: раньше GPU при `--wall staircase` молча считал по Bouzidi, и обнаружилось
это только потому, что две модели дали побитово одинаковый результат там, где обязаны были
разойтись.
### Паритет бэкендов и согласованность уровней
CPU (f64) и GPU (f32) на одной постановке совпадают до 4–5 значащих цифр шаг в шаг: ⟨ρ⟩
1.04933 против 1.04934, Cd 2.339 против 2.338, ⟨γ⟩ 1.2645 против 1.2646. На Intel Iris Xe GPU
даёт ≈195 MLUPS против ≈18 MLUPS у процессора.
Один и тот же случай с патчем ×2 и вовсе без измельчения (`--refine 1`) даёт St 0.1951 против
0.1970 и ⟨Cd⟩ 1.956 против 1.943 — связка уровней систематики не вносит.
**Предел на размер сетки снят.** У GPU есть жёсткий предел `maxComputeWorkgroupsPerDimension`
= 65535, а диспетчеризация была одномерной: при 64 узлах на рабочую группу это упирало сетку в
4.2 миллиона узлов, то есть примерно 2048×2048. Всё, что крупнее, падало ошибкой валидации —
не считало медленно, а не запускалось вовсе. Теперь диспетчеризация двумерная, а линейный
индекс собирается в шейдере (`lin()`/`wlin()`); отображение «рабочая группа → узлы» при этом
остаётся ровно линейным, поэтому редукции ничего не заметили. Проверено до 4096×4096, паритет
с CPU не сдвинулся ни в одной цифре.
**Где именно кончается f32.** Прямой замер на Тейлоре–Грине, где ошибка известна точно:
| N | CPU, f64 | GPU, f32 |
|---|---|---|
| 64 | 9.83e-3 | 9.80e-3 |
| 128 | 2.40e-3 | 3.29e-3 |
| 256 | 5.97e-4 | 2.37e-2 |
Пока истинная ошибка выше ~10⁻³, f32 идёт с f64 вровень; ниже — промахивается на порядок и
больше. Практический вывод, заложенный в кампанию: исследования сходимости считаются на CPU,
всё остальное — на GPU. Двойной точности на GPU здесь быть не может в принципе: **в WGSL типа
`f64` не существует**, поэтому wgpu не выразит её ни на каком железе; локальная Iris Xe вдобавок
сообщает `shaderFloat64 = false`, а на потребительских NVIDIA f64 идёт в 1/64 от f32 — то есть
медленнее, чем CPU.
Что удалось выжать вместо точности — производительность. Два изменения:
1. **Батчинг чтения.** Раньше после каждого шага делался `map_async` + `poll(Wait)` ради 48 байт
статистики: на 240×120 счёт упирался в 863 шаг/с при том, что сам счёт занимал 0.27 мс из
1.16. Теперь итоги копятся в кольце на 128 слотов, синхронизация — раз в батч: **6715 шаг/с**,
в 7.8 раза быстрее, при неизменном пошаговом интерфейсе снаружи.
2. **Компенсированное суммирование** (Кэхена–Ноймайера) в редукциях и в сумме сил. Наивная сумма
по 10⁵–10⁷ узлам съедает ~log₂N бит мантиссы — именно там f32 терял основную точность.
### Обтекание цилиндра против литературы
Постановка 480×240, D = 24, Re = 150, блокировка β = D/Ny = 0.1, умолчания решателя:
| величина | сырое | с поправкой на блокировку | литература (безгранич. цилиндр) |
|---|---|---|---|
| St | 0.1828 | 0.1645 | 0.183 |
| ⟨Cd⟩ | 1.448 | 1.173 | 1.33 |
| rms Cl | 0.383 | 0.310 | ~0.30 |
| ⟨Cm⟩ | 0.00002 | — | 0 (симметрия) |
Поправка: St×(1−β), Cd и rms Cl ×(1−β)². Сырое St и скорректированный rms Cl ложатся на
литературу; Cd после поправки ниже на 12%. ⟨Cm⟩ ≈ 0 — контроль симметрии считывания силы.
**Формы тел ведут себя физично.** Прогон на каждую форму (260×130, размер 20, угол атаки 12°)
даёт ожидаемый порядок сопротивления: профиль 0.44, эллипс 0.45, пластина 0.60, цилиндр 1.33,
ромб 1.65, квадрат 2.38, треугольник 2.72. ⟨Cm⟩ ≈ 0 **только** у круга (−0.0002), которому
угол атаки безразличен, а у несимметричных под углом тел он ненулевой (профиль +0.31,
эллипс +0.15, пластина +0.13).
### Мелкие отличия от питоновского прототипа
Решатель писался заново, не как порт, но пара мест разошлась с `solver_2x_sdf` намеренно:
внутри тела здесь не считается столкновение (эти популяции фиктивны — Bouzidi перекрывает всё,
что могло бы прийти из тела в жидкость; побочно статистика γ собирается строго по жидкости), а
рестрикция дополнительно пропускает узлы, у которых тонкий узел-источник лежит внутри тела.
Процессорный бэкенд работает в f64, GPU-бэкенд — в f32.
## Продольная акустика канала: почему поток может «дышать»
Самая заметная ловушка этой постановки, и её стоит понимать до первого запуска.
**Граничное условие с заданной скоростью на входе акустически есть жёсткий поршень**: оно
отражает продольные волны с коэффициентом +1. Выход по давлению — наоборот, открытый конец.
Вместе они делают из канала четвертьволновый резонатор с пучностью давления на входе и узлом
на выходе:
период основной моды = 4·Nx/c_s шагов
затухание вязкостью ~ ν(π/2Nx)² — на длинном домене практически ноль
Измерено на 480×240: период пульсации ⟨ρ⟩ **3332 шага** против расчётных 4·Nx/c_s = 3325
(0.2%), первый ноль автокорреляции на 827 шагах = ровно Nx/c_s (четверть периода). За 30000
шагов амплитуда упала на 3.8% — то есть мода не гаснет вообще. Ничто её не подкачивает; это
звон от старта, запертый в почти без потерь резонаторе.
Отсюда умолчания:
- **`--init uniform`** — домен сразу заполнен набегающим потоком, вход включён на полную.
Старт из покоя (`--init rest`) разгоняет весь столб жидкости и закачивает моду; при разгоне
за 1000 шагов против акустического пробега 831 шаг это для звука удар.
- **губка перед выходом включена и подобрана по домену** (`nx/12`, не меньше 16 столбцов,
с запасом до патча). Отключается `--sponge-len 0`. Вклад у неё скромный — см. таблицу ниже,
— но она бесплатна для сил и снимает остаточную пульсацию примерно вдвое при утроении длины.
- **`--outlet extrapolate`** — нуль-градиент поперечной скорости; жёсткий ноль отражает вихри
дорожки обратно к телу.
**Что именно помогает — разделено измерением** (480×240, D=24, Re=150, 30 000 шагов):
| старт | губка, столбцов | пульсация u′/U | St | ⟨Cd⟩ | rms Cl |
|---|---|---|---|---|---|
| uniform | 0 | 1.12% | 0.1839 | 1.483 | 0.404 |
| uniform | 40 | 1.03% | 0.1840 | 1.483 | 0.404 |
| uniform | 120 | 0.83% | 0.1839 | 1.482 | 0.403 |
| rest | 0 | **74.91%** | 0.1534 | 2.009 | 0.807 |
| rest | 40 | **74.90%** | 0.1539 | 2.010 | 0.808 |
| rest | 120 | **74.99%** | 0.1542 | 2.008 | 0.808 |
Читается однозначно: **весь эффект даёт однородный старт**, 74.9% → 1.12%, причём при
полностью выключенной губке. Губка снимает только остаток — 1.12% → 1.03% → 0.83%, — и на
силы с частотой схода не влияет вовсе (St 0.1839 во всех трёх строках).
**При старте из покоя губка не помогает совсем.** Это не осечка реализации, а свойство моды:
губка поднимает вязкость на последних столбцах, а там у стоячей четвертьволновой моды **узел
давления и пучность скорости** — то самое место, где повышенная вязкость её почти не трогает.
Бегущую волну такая губка съедает, стоячую — нет. Убрать моду можно только не возбуждая её.
Заодно видно, ЧЕМ платит неверный старт: St 0.153 вместо 0.184, ⟨Cd⟩ 2.01 вместо 1.48,
rms Cl 0.81 вместо 0.40 — то есть 75-процентная продольная пульсация ломает не косметику, а
все три величины, ради которых постановка и считается.
Отчёт печатает период моды, время её вязкого затухания и измеренную пульсацию в конце прогона,
с явным предупреждением, если она превысила 5% от U.
**Инструмент разбора — `--xt`.** Каждые `--xt-every` шагов пишется срез ⟨ρ⟩ и u_x вдоль осевой
линии, по строке на срез. Наклон полос на такой диаграмме прямо даёт скорость распространения:
звук (±c_s) или конвекция (U). Стоячие узлы видны как вертикальные линии постоянной фазы, и это
сразу отличает резонанс от неустойчивости самого граничного условия, привязанной к столбцу x = 0
и никуда не бегущей.
Есть и **губка после входа** — `--sponge-in <столбцов>`, по умолчанию выключена. Гасит продольные
волны до того, как они отразятся от входа-поршня. Осмысленна на высоких Re, где стартовая волна
перестаёт быть безобидной полоской; цена — искажение профиля прямо на входе, поэтому включать её
надо осознанно, а не «на всякий случай».
Что **не помогает** и оставлено только для повторной проверки — `--init-taper`. Замерено:
сглаживание стартовой скорости у тела давит возмущение плотности на первом шаге восьмикратно
(2.54·10⁻² → 3.13·10⁻³), но пик ЗА ПРОГОН при этом даже подрастает (до 3.22·10⁻²). Возмущение
просто переносится во времени: поток всё равно обязан разогнаться вокруг тела, и энергия этого
переходного процесса задана физикой, а не гладкостью начального поля. Умолчание — 0.
## Валидационная кампания
`bench/` — 115 прогонов на ≈90 часов GPU, разложенных по девяти группам: эталоны первоисточников,
цилиндр против литературы, модели стенки, профили крыла, сложная и множественная геометрия,
границы домена, старт и время жизни, внутренние инварианты, сверхмелкие сетки до 4096×2048.
Каждый прогон кладёт логи, ряды, машиночитаемую сводку и гифку на всю свою длительность в
собственную папку.
```sh
cd bench
python preflight.py # каждый сценарий стартует на два шага: ловит опечатки
./run_campaign.sh --calibrate # замерить MLUPS этой машины: оценки в часах иначе гадание
./run_campaign.sh --dry-run # смета: что, сколько шагов, сколько часов
./run_campaign.sh --resume # считать, пропуская уже готовое
```
Предполётную проверку стоит гонять всерьёз: она поймала, что вся группа сверхмелких сеток
падала на пределе GPU (см. ниже), а девять прогонов передавали `--body-x` дважды. Оба отказа
проявились бы только на сервере, часов через двадцать после старта кампании.
Подробности — в `bench/README.md`: раскладка выходных файлов, таблица групп, модель стоимости и
список того, что известно заранее (какие прогоны обязаны развалиться и почему).
## Развёртывание (Docker)
Собранный образ опубликован: **`notbigghost/kbc2d:1.2.0`** (он же `latest`, платформа `linux/amd64`).
Исходники на сервере не нужны — достаточно перенести туда один файл
`docker-compose.server.yml`:
```sh
mkdir -p ~/kbc2d && cd ~/kbc2d # сюда же ляжет ./out с результатами
# перенести docker-compose.server.yml
docker compose -f docker-compose.server.yml --profile check run --rm vulkan # карта видна?
docker compose -f docker-compose.server.yml --profile check run --rm preflight # сценарии стартуют?
docker compose -f docker-compose.server.yml --profile check run --rm calibrate # сколько MLUPS?
docker compose -f docker-compose.server.yml up -d # кампания
docker compose -f docker-compose.server.yml logs -f
```
Порядок именно такой: узнать, что карта не видна, лучше через минуту, чем через час. Замеренные
`--calibrate` числа подставляются переменными `KBC2D_GPU_MLUPS` / `KBC2D_CPU_MLUPS` — только на
оценки в часах, на счёт они не влияют.
Собрать образ самому (`docker-compose.yml` рядом делает то же самое с `build:`):
```sh
docker build -t kbc2d docs/theory/2d_solver
docker run --rm --gpus all kbc2d --calibrate
```
`ENTRYPOINT` — драйвер кампании, `CMD` по умолчанию `--dry-run`: случайный `docker run` покажет
смету и выйдет, а не запустит сточасовую задачу. В серверном compose политика перезапуска —
`on-failure`, а не `unless-stopped`: кампания завершается штатно с кодом 0, и «перезапускать
всегда» крутило бы контейнер вхолостую по кругу, тогда как падение или перезагрузку хоста
`on-failure` подхватывает, а `--resume` продолжает с места.
**Главная тонкость — Vulkan внутри контейнера.** NVIDIA Container Toolkit подкладывает
Vulkan-ICD (`nvidia_icd.json`) только если в `NVIDIA_DRIVER_CAPABILITIES` есть `graphics`;
с одним `compute` wgpu не увидит ни одного адаптера. В образе это прописано, но может быть
переопределено снаружи, поэтому `vulkan-tools` лежит внутрь: первым делом на сервере стоит
выполнить `docker run --rm --gpus all --entrypoint vulkaninfo kbc2d --summary`.
Проверено локально: образ собирается, кампания внутри него проходит смоук с монтированием
результатов на хост, физика совпадает с хостовой до последней цифры (ошибка Тейлора–Грина
9.829e-3 при N=64 и 2.401e-3 при N=128 — те же значения, что вне контейнера), а `--backend gpu`
без проброшенной карты отказывает явным сообщением, а не считает молча.
### WSL2 — отдельный путь
В WSL2 всё вышеописанное не работает, и не из-за настроек. **Драйвера Vulkan для Linux у
NVIDIA там нет**: карта отдаётся через `/dev/dxg` по протоколу WDDM, нативный
`libGLX_nvidia` про него не знает и перечисляет ноль устройств. Container Toolkit
подкладывать внутрь нечего, отсюда `could not select device driver "nvidia"`.
Работает другое: **dzn** (Dozen) — драйвер Mesa, транслирующий Vulkan в D3D12, он умеет
говорить с `/dev/dxg` напрямую. Он положен в образ (ради него база сменена с
`debian:bookworm-slim` на `archlinux:base`: в пакетах Mesa у Debian и Ubuntu dzn не
собирают). NVIDIA-runtime для этого пути не нужен вовсе — нужны проброс устройства и
монтирование `/usr/lib/wsl`. Запуск через `docker-compose.wsl.yml`, подробности в
[`bench/README.md`](bench/README.md).
Три вещи, которые надо знать про этот путь.
**wgpu по умолчанию прячет несоответствующие адаптеры.** dzn сообщает о себе
`conformanceVersion = 0.0.0.0`, и wgpu молча его отбрасывает — решатель докладывает, что
GPU не найден. Согласие даётся явно, переменной `WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1`;
чтобы она вообще читалась, в `gpu.rs` при создании инстанса стоит
`InstanceFlags::from_build_config().with_env()`. Поведение по умолчанию не изменилось: без
переменной несоответствующие адаптеры по-прежнему скрыты.
**У dzn мал предел на размер одной привязки — 128 МиБ** против гигабайтов у нативных
драйверов. Массив популяций занимает `nx · ny · 9 · 4` байта, поэтому при одной общей
привязке потолок выходил 3.73 млн узлов, и сетки от 2048×2048 не запускались вовсе. Но
ограничена именно привязка, а не буфер (`max_buffer_size` у dzn 2047 МиБ), так что тот же
буфер теперь показывается **девятью привязками по одному направлению**: потолок
поднимается до 33.5 млн узлов. Вариант выбирается по возможностям адаптера; где предела
нет, собирается прежний общий, без `switch` в аксессорах. Оба дают одинаковые числа
(Cd 2.39486 в обоих), раздельный стоит 5.7% пропускной способности. Принудительно
включается переменной `KBC2D_SPLIT_POPULATIONS=1` — она нужна для сверки двух вариантов
на одной карте.
**Точность трансляция не портит.** Замерено на Intel Iris Xe одним и тем же прогоном
(`bench/parity.py`, цилиндр Re=20, 8000 шагов):
| | Cd | energy_end (Тейлор–Грин) |
|---|---|---|
| CPU, f64 | 2.39490 | 5.74754e-05 |
| нативный Vulkan, f32 | 2.39486 | 5.74813e-05 |
| dzn в контейнере, f32 | 2.39486 | 5.74810e-05 |
Числа dzn и нативного драйвера сходятся до 5–6 значащих цифр, и разница между ними меньше,
чем между любым из них и f64. Считает трансляция то же самое.
**Скорость она портит, но тем меньше, чем крупнее сетка** — плата почти вся приходится на
трансляцию вызова, а не счёта:
| сетка | узлов | нативно | dzn в контейнере | плата |
|---|---|---|---|---|
| 320×192 | 61 тыс. | 188.8 MLUPS | 44.5 MLUPS | 4.2× |
| 960×480 | 461 тыс. | 125.3 MLUPS | 86.0 MLUPS | 1.46× |
| 1920×960 | 1.84 млн | 128.7 MLUPS | 98.7 MLUPS | 1.30× |
Для кампании это решает дело: 95.1% её стоимости приходится на сетки крупнее 600 тыс.
узлов, а на сетки мельче 150 тыс. — 0.0%. Ожидаемое удорожание всей кампании в контейнере —
около трети, а не в разы. Проверяется профилем `calibrate`, который меряет в том числе
1920×960.
## Дальше
- σ·n-кросс-чек силы (интеграл тензора напряжений по контуру) как независимая проверка GMEM;
- согласованные начальные условия по образцу работы 2024 года: там ρ и старшие моменты
получают, решая ∂ρ/∂t + ∇·(ρu₀) = D∇²ρ до стационара, что убрало бы и остаточный
стартовый импульс от появления тела в потоке;
- подвижные и вращающиеся тела: GMEM уже записан в галилей-инвариантной форме и принимает
скорость стенки на линке, но подача этой скорости не подключена;
- разбор нерешённых 12% по Cd и предела устойчивости Re ≈ 5·10⁴ — обе задачи вынесены в
кампанию (группы B и G), выводы делать по её данным;
- больше четырёх тел в домене: сейчас сила считается по четырём вёдрам (`MAX_BODY_BUCKETS`),
геометрия при этом собирается из любого числа тел, но силы сверх четвёртого сливаются вместе.
-248
View File
@@ -1,248 +0,0 @@
# Валидационная кампания
115 прогонов, ≈90 часов на RTX 4070 Ti. Проверяет решатель по трём независимым линиям:
эталонам из статей авторов метода, литературе по обтеканию тел и внутренним инвариантам самой
схемы. Каждый прогон кладёт логи, ряды, машиночитаемую сводку и гифку в собственную папку.
## Быстрый старт на сервере
Образ опубликован, собирать ничего не нужно: **`notbigghost/kbc2d:1.2.0`**. Исходники на
сервере тоже не нужны — переносится один файл `docker-compose.server.yml`.
```sh
mkdir -p ~/kbc2d && cd ~/kbc2d # сюда же ляжет ./out с результатами
# перенести сюда docker-compose.server.yml
C=docker-compose.server.yml
docker compose -f $C --profile check run --rm vulkan # 1. карта видна из контейнера?
docker compose -f $C --profile check run --rm preflight # 2. все 115 сценариев стартуют?
docker compose -f $C --profile check run --rm calibrate # 3. сколько MLUPS на этой машине?
docker compose -f $C --profile check run --rm plan # 4. смета в часах по замеренному
docker compose -f $C up -d # 5. кампания
docker compose -f $C logs -f
```
Порядок не случайный: узнать, что карта не видна, лучше на первом шаге, чем через час счёта.
Замеренные калибровкой числа подставляются переменными окружения — они влияют только на
оценки в часах, не на счёт:
```sh
KBC2D_GPU_MLUPS=1450 KBC2D_CPU_MLUPS=40 docker compose -f $C --profile check run --rm plan
```
**Если `vulkaninfo` показывает ноль адаптеров** — почти наверняка дело в
`NVIDIA_DRIVER_CAPABILITIES`: Container Toolkit подкладывает Vulkan-ICD только при наличии
`graphics` в списке. В образе и в compose это прописано, но может быть переопределено снаружи.
Проверить, что хост вообще умеет отдавать карту:
```sh
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi
```
## Запуск в WSL2
Отдельный путь, потому что в WSL2 **драйвера Vulkan для Linux у NVIDIA не существует**.
Карта отдаётся через `/dev/dxg` по протоколу WDDM; нативный `libGLX_nvidia` про него не
знает и возвращает ноль устройств — загрузчик его выбрасывает. Отсюда и `could not select
device driver "nvidia"`: NVIDIA Container Toolkit тут не поможет, потому что подкладывать
внутрь нечего.
Зато с `/dev/dxg` умеет говорить **dzn** (Dozen) — драйвер Mesa, транслирующий Vulkan в
D3D12. Он лежит в образе. NVIDIA-runtime при этом **не нужен вовсе**: достаточно проброса
устройства и монтирования `/usr/lib/wsl`, где Microsoft держит `libd3d12.so`.
```sh
C=docker-compose.wsl.yml
docker compose -f $C --profile check run --rm vulkan # 1. карта видна?
docker compose -f $C --profile check run --rm parity # 2. считает ли она правильно?
docker compose -f $C --profile check run --rm calibrate # 3. и с какой скоростью?
docker compose -f $C --profile check run --rm preflight # 4. все сценарии стартуют?
docker compose -f $C --profile check run --rm plan # 5. смета в часах
docker compose -f $C up -d # 6. кампания
```
Если образа нет ни локально, ни в реестре — `docker compose -f $C build`, доступ к Docker
Hub не обязателен.
### Шаг parity обязателен
dzn сообщает о себе `conformanceVersion = 0.0.0.0`: набор тестов соответствия Vulkan он не
проходил. wgpu по этой причине по умолчанию **прячет** такие адаптеры, и решатель сообщает,
что GPU не найден. Согласие считать на непроверенном драйвере даётся явно — переменной
`WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1`, она прописана в `docker-compose.wsl.yml`.
Раз соответствие не проверено вендором, его проверяем сами. `bench/parity.py` гоняет два
коротких эталона на CPU в f64 и на GPU и сверяет числа: вихрь Тейлора–Грина (есть точное
решение) и стационарное обтекание цилиндра при Re=20. Течения выбраны намеренно не
хаотические — там расхождение f32 и f64 не нарастает, поэтому заметное различие означает
проблему драйвера, а не разрядности.
Замерено на Intel Iris Xe: **dzn совпадает с нативным драйвером той же карты до 5–6
значащих цифр** (`energy_end` 3.02277e-07 против 3.02283e-07, `cd` 2.39486 против 2.39486).
Точность трансляция не портит. Но это замер на Intel; на своей карте прогоните сами — две
минуты.
### Предел 128 МиБ на привязку и как он снят
dzn объявляет `max_storage_buffer_binding_size = 128 МиБ` (2²⁷ байт), тогда как нативные
драйверы дают гигабайты. Массив популяций занимает `nx · ny · 9 · 4` байта, так что при
одной общей привязке потолок выходил **3.73 млн узлов** (примерно 1920×1920), и 14
прогонов кампании из 115 падали на создании bind group:
```
Buffer binding 0 range 150994944 exceeds `max_*_buffer_binding_size` limit 134217728
```
На эти 14 приходилось 70.7% стоимости кампании — дорогие прогоны как раз крупносеточные.
Существенно, что ограничена только **привязка**: `max_buffer_size` у dzn 2047 МиБ, то есть
буфер держать разрешено, нельзя лишь показать шейдеру его целиком. Поэтому решатель теперь
умеет показывать тот же буфер **девятью привязками**, по одному направлению в каждой.
Потолок поднимается в девять раз — до 33.5 млн узлов, чего хватает всей кампании с запасом
(самая крупная сетка в ней 4096×4096 — 16.8 млн узлов).
Вариант выбирается сам, по `max_storage_buffer_binding_size` адаптера: где предела нет,
собирается прежний общий вариант без `switch` в аксессорах. Проверить оба на одной карте
можно переменной `KBC2D_SPLIT_POPULATIONS=1` — она включает раздельные привязки
принудительно.
Замерено на Intel Iris Xe, один драйвер, два варианта привязки:
| | Cd | energy_end | MLUPS (цилиндр) |
|---|---|---|---|
| общая привязка | 2.39486 | 5.74813e-05 | 169.7 |
| девять привязок | 2.39486 | 5.74813e-05 | 160.0 |
Числа совпадают полностью; раздельный вариант стоит **5.7%** пропускной способности, и
включается только там, где без него счёт вообще невозможен.
### Чего трансляция стоит по скорости
Плата есть, но она почти вся — накладные расходы на вызов, а не на счёт, и потому падает
с ростом сетки. Замерено на Intel Iris Xe, один и тот же решатель:
| сетка | узлов | нативный Vulkan | dzn в контейнере | плата |
|---|---|---|---|---|
| 320×192 | 61 тыс. | 188.8 MLUPS | 44.5 MLUPS | **4.2×** |
| 960×480 | 461 тыс. | 125.3 MLUPS | 86.0 MLUPS | **1.46×** |
| 1920×960 | 1.84 млн | 128.7 MLUPS | 98.7 MLUPS | **1.30×** |
| 2048×2048 | 4.19 млн | 111.8 MLUPS | 67.4 MLUPS | **1.66×** |
Последняя строка стоит особняком: там уже раздельные привязки (см. ниже), и в плату
входит их `switch` в аксессорах. Ожидаемый разброс по кампании — от 1.3× до 1.7×.
Каждый шаг решателя — несколько отправок в очередь; на мелкой сетке трансляция вызова
стоит дороже самого счёта, на крупной размазывается. Для кампании это решающее
обстоятельство, потому что дорогие прогоны в ней как раз крупные:
| узлов в сетке | прогонов | доля стоимости кампании |
|---|---|---|
| < 150 тыс. | 18 | 0.0% |
| 150–600 тыс. | 47 | 4.9% |
| > 600 тыс. | 50 | **95.1%** |
То есть 95% времени кампания проводит там, где плата 1.3–1.5×, и почти не бывает там, где
она четырёхкратна. Ожидаемое удорожание по всей кампании — около трети: 90 часов
превращаются примерно в 115–120, а не в 400.
Проверьте на своей карте: профиль `calibrate` меряет три сетки, и ориентироваться надо на
**1920×960** — она представительна для кампании, а 240×120 показывает худший случай.
## Своя сборка образа
Если нужен образ из текущего состояния репозитория, а не опубликованный:
```sh
docker build -t kbc2d docs/theory/2d_solver --build-arg VERSION=dev --build-arg REVISION=$(git rev-parse --short HEAD)
```
Рядом лежит `docker-compose.yml` — то же самое через `build:`, для локальной отладки.
## Без Docker
```sh
cargo build --release # из docs/theory/2d_solver
cd bench
python preflight.py # каждый сценарий стартует на два шага
./run_campaign.sh --calibrate # замерить MLUPS этой машины
KBC2D_GPU_MLUPS=1400 ./run_campaign.sh --dry-run
./run_campaign.sh --resume
```
Под Windows то же самое делает `run_campaign.ps1` (тот же `scenarios.json`); он нужен для
локальной отладки обвязки, целевая машина — Linux.
## Ключи драйвера
| ключ | что делает |
|---|---|
| `--dry-run` | печатает смету: что, сколько шагов, сколько часов. Ничего не считает |
| `--calibrate` | три коротких прогона, замер фактических MLUPS этой машины |
| `--smoke` | три самых дешёвых прогона: проверить обвязку, а не физику |
| `--resume` | пропускает прогоны, у которых уже есть `summary.json` |
| `--group A,B` | только выбранные группы |
| `--only cyl_re150` | по подстроке идентификатора |
| `--budget-hours 24` | остановиться, когда время выйдет |
Прогон, который упал или развалился, помечается в сводке и **не останавливает кампанию**:
группы E и G специально ищут предел устойчивости, там развал — ожидаемый результат.
## Что где лежит
```
out/summary.csv сводная таблица: id, группа, статус, секунды, стоимость
out/campaign.log журнал с отметками времени
out/<id>/cmd.txt точная команда, которой прогон был запущен
out/<id>/log.txt полный вывод
out/<id>/report.txt только итоговый отчёт
out/<id>/series.csv временные ряды по шагам
out/<id>/summary.json ключевые метрики машиночитаемо — с этого удобно начинать разбор
out/<id>/*.gif анимация
out/<id>/*_case.csv энергия, энстрофия, палинстрофия (эталонные течения)
out/<id>/xt_*.csv x–t диаграммы (группа G)
```
## Группы
| | прогонов | что проверяется |
|---|---|---|
| **A** | 12 | эталоны первоисточников: Тейлор–Грин (второй порядок сходимости; при фиксированном u₀ — полка O(Ma²)), сдвиговый слой Re=3·10⁴, затухающая турбулентность |
| **B** | 16 | цилиндр против литературы: стационар Re=20/40, дорожка Re=100…300, разрешение D=16…128, блокировка Ny/D=6…32 с экстраполяцией к бесконечной среде |
| **C** | 20 | модели стенки и субсеточность: hrr / grad / bouzidi / staircase при Re=150 и 2000, плюс сдвиг тела внутри клетки на 0 / ¼ / ½ для всех четырёх |
| **D** | 14 | профили крыла: поляра NACA 0012, Re-серия, изгиб 4412, сходимость по хорде |
| **E** | 12 | сложная и множественная геометрия: тандем, решётка, перфорация, многоэлементный профиль, сверхтонкая пластина, клин, зазубренная кромка |
| **F** | 9 | поле влияния и границы домена: отступы до входа и выхода, вложенный патч |
| **G** | 14 | старт, акустика, время жизни: x–t диаграммы, губки, предел по Re, прогон на 10⁷ шагов |
| **H** | 9 | инварианты: симметрия, зеркальность, зависимость от числа Маха, расхождение f32 против f64 |
| **I** | 9 | сверхмелкие сетки 4096×2048 по всем формам и композиции из трёх тел |
## Стоимость и время
Стоимость каждого прогона хранится в **обновлениях узлов** (`nodes_per_step × steps`) — это
единственная мера, переносимая между машинами. Часы драйвер получает, поделив её на MLUPS.
Оценки по умолчанию исходят из 1200 MLUPS на GPU и 22 на CPU; **`--calibrate` обязателен**,
потому что эти числа взяты с другой машины.
Пересобрать список с другой длительностью:
```sh
python gen_scenarios.py --scale 3 # все прогоны в полтора раза длиннее (≈135 ч)
```
## Что известно заранее
- **Точность f32.** Замерено на Тейлоре–Грине: GPU совпадает с CPU/f64, пока истинная ошибка
выше ~10⁻³, и промахивается в 40 раз, когда она ниже. Поэтому исследования сходимости из
группы A идут на CPU — это указано в самих сценариях.
- **Развалы ожидаемы** в группе G (предел по Re) и у прогона `A09_shear_n512_lbgk`: LBGK при
Re=3·10⁴ обязан развалиться там, где KBC доживает — это и есть проверяемое утверждение.
- **Гифки** пишутся на всю длительность прогона, в реальном времени, 10 кадр/с. Число кадров
этим задано жёстко (у самого длинного прогона их 16 666), поэтому единственный рычаг —
размер кадра. Замерено: 0.103 байта на пиксель после LZW; отсюда бюджет
кадры×пиксели ≤ 1.5·10⁹ на гифку, но ширина не опускается ниже 480 пикселей. Итог по
кампании: **≈4.4 ГБ**, самый тяжёлый файл 226 МБ.
- **Перед запуском** имеет смысл прогнать предполётную проверку: каждый сценарий стартует на
два шага, что ловит опечатки в ключах и несовместимые сочетания до того, как кампания уйдёт
считать на девяносто часов. Именно она поймала, что вся группа I падала на пределе GPU в
65535 рабочих групп на измерение.
@@ -1,518 +0,0 @@
#!/usr/bin/env python3
# Генератор списка прогонов кампании -> scenarios.json
#
# Список порождается кодом, а не правится руками: серии по Re, по разрешению и по углу атаки
# получаются циклами, а стоимость каждого прогона считается тут же и суммируется. Запуск:
# python gen_scenarios.py # перезаписать scenarios.json и напечатать смету
# python gen_scenarios.py --scale 2 # растянуть все длительности вдвое
#
# Стоимость меряется в ОБНОВЛЕНИЯХ УЗЛОВ (nodes_per_step * steps) — единственная переносимая
# между машинами мера. Часы драйвер получает из неё, поделив на фактические MLUPS, которые
# замеряет на месте (--calibrate).
import argparse
import json
import math
import os
RUNS = []
U_LAT = 0.05 # решёточная скорость по умолчанию
DX = 0.1 # размер клетки, м (умолчание решателя)
U_PHYS = 30.0 # скорость потока, м/с (умолчание решателя)
GIF_MAX_W = 1280 # предельная ширина кадра после прореживания
GIF_MIN_W = 480 # ниже не опускаемся: нечитаемая гифка бесполезнее большой
GIF_PX_BUDGET = 1.5e9 # кадры×пиксели на гифку; при 0.103 байта на пиксель это ~150 МБ
def conv_steps(d_cells, n_conv, u_lat=U_LAT):
"""Сколько шагов нужно на n_conv конвективных времён D/U."""
return int(round(n_conv * d_cells / u_lat))
def add(rid, group, title, expect, args, nodes, steps, backend="gpu",
gif=None, nx=None, extra_out=()):
"""Записать прогон. `nodes` — обновлений узлов за шаг, `steps` — сколько шагов."""
a = list(args) + ["--steps", str(steps), "--backend", backend]
# ряды прореживаем так, чтобы их осталось порядка 200 тысяч записей
if steps > 400_000:
a += ["--series-every", str(max(1, steps // 200_000))]
if "case_csv" in extra_out:
a += ["--case-csv", f"{rid}_case.csv"]
if gif:
# Гифка идёт на всю длительность прогона в реальном времени, 10 кадр/с — число кадров
# задано физикой прогона и не обсуждается. Единственный рычаг — пиксели в кадре, и он
# нужен: замерено 0.103 байта на пиксель после LZW, так что прогон на 16 тысяч кадров
# при 960×576 весит 900 МБ, а такой файл уже нечем открыть. Поэтому кроме ограничения
# по ширине действует бюджет ПРОИЗВЕДЕНИЯ кадры×пиксели, но ширина не опускается ниже
# GIF_MIN_W: нечитаемая гифка бесполезнее большой.
ny = max(1, int(nodes) // max(1, nx or 1))
u_lat = float(a[a.index("--u-lat") + 1]) if "--u-lat" in a else U_LAT
frames = max(1, int(steps * (u_lat * DX / U_PHYS) * 10))
down = max(1, math.ceil((nx or 0) / GIF_MAX_W))
while nx and frames * (nx // down) * (ny // down) > GIF_PX_BUDGET \
and nx // (down + 1) >= GIF_MIN_W:
down += 1
a += ["--gif", gif, "--gif-field", "vorticity", "--gif-every", "auto",
"--gif-fps", "10", "--gif-speed", "1",
"--gif-downsample", str(down), "--gif-scale", "1"]
RUNS.append({
"id": rid, "group": group, "title": title, "expect": expect,
"backend": backend, "args": a,
"nodes_per_step": int(nodes), "steps": int(steps),
"cost": int(nodes) * int(steps),
"outputs": list(extra_out),
})
# ══════════════════════════════════════════════════════════════════════════════
# A. Эталоны первоисточников: периодические течения, тела и ГУ нет вовсе
# ══════════════════════════════════════════════════════════════════════════════
def group_a(scale):
NU = 0.0192
for n in (64, 128, 256, 512):
# диффузионное измельчение: nu фиксирована, u0 ~ 1/N, поэтому Re сохраняется, а
# число Маха падает вместе с сеткой — только так виден второй порядок целиком
u0 = 0.03 * 64 / n
tc = int(round(math.log(2.0) / (NU * (2 * math.pi / n) ** 2 * 17)))
add(f"A{len(RUNS)+1:02d}_tg_diff_n{n}", "A",
f"Тейлор–Грин, диффузионное измельчение, N={n} (CPU/f64)",
"порядок сходимости 2 по ряду N=64..512; ошибка на N=512 ниже 2e-4",
["--case", "taylor-green", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", f"{u0:.6f}", "--re", "100", "--case-every", str(max(1, tc // 20))],
n * n, tc, backend="cpu", extra_out=("case_csv",))
for n in (128, 256):
# при ФИКСИРОВАННОМ u0 ошибка упирается в полку O(Ma^2) и от сетки не зависит
tc = int(round(math.log(2.0) / (NU * (2 * math.pi / n) ** 2 * 17)))
add(f"A{len(RUNS)+1:02d}_tg_fixed_n{n}", "A",
f"Тейлор–Грин при фиксированном u₀, N={n} — демонстрация полки O(Ma²)",
"ошибка перестаёт падать с ростом N: это свойство слабо-сжимаемого метода",
["--case", "taylor-green", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", "0.03", "--re", f"{0.03 * n / NU:.1f}",
"--case-every", str(max(1, tc // 20))],
n * n, tc, backend="cpu", extra_out=("case_csv",))
for n in (512,):
for op, km, tag in (("kbc", "n1", "kbc_n1"), ("kbc", "n2", "kbc_n2"), ("bgk", "n1", "lbgk")):
steps = int(4 * n / 0.04 * scale)
add(f"A{len(RUNS)+1:02d}_shear_n{n}_{tag}", "A",
f"Сдвиговый слой Re=3·10⁴, N={n}, {tag}",
"KBC доживает до конца, LBGK обязан развалиться (разд. VII статьи)",
["--case", "shear-layer", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", "0.04", "--re", "30000", "--collision", op, "--kbc-model", km,
"--case-every", str(max(1, steps // 200))],
n * n, steps, gif=f"shear_{tag}.gif", nx=n, extra_out=("case_csv",))
for n, tag, ops in ((2048, "n2048", ("kbc", "bgk")), (4096, "n4096", ("kbc",))):
for op in ops:
steps = int(200_000 * scale * (2048 / n))
add(f"A{len(RUNS)+1:02d}_turb_{tag}_{op}", "A",
f"Затухающая турбулентность N={n}, {op}",
"энстрофия и палинстрофия падают монотонно; KBC держится там, где LBGK нет",
["--case", "decaying-turbulence", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", "0.02", "--re", f"{0.02 * n / 8.16e-5:.0f}", "--collision", op,
"--case-every", str(max(1, steps // 300))],
n * n, steps, gif=f"turb_{tag}_{op}.gif", nx=n, extra_out=("case_csv",))
# ══════════════════════════════════════════════════════════════════════════════
# B. Цилиндр против литературы
# ══════════════════════════════════════════════════════════════════════════════
def flatten(d):
"""Словарь ключей в плоский argv. Именно словарь, а не список: clap отвергает
повторённый ключ, а сценарии сплошь и рядом переопределяют умолчания сборщика."""
a = []
for k, v in d.items():
a += [k, str(v)]
return a
def cyl_args(d, re, nx, ny, **kw):
a = {"--shape": "cylinder", "--size": d, "--nx": nx, "--ny": ny,
"--body-x": nx // 5, "--re": re, "--refine": 1}
a.update({"--" + k.replace("_", "-"): v for k, v in kw.items()})
return flatten(a)
def group_b(scale):
# стационарные режимы: дорожки нет, Cd — одно число
for re in (20, 40):
d, nx, ny = 48, 960, 576
st = int(conv_steps(d, 60) * scale)
add(f"B{len(RUNS)-len(RUNS)+len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_re{re}",
"B", f"Цилиндр Re={re}, стационар, D=48",
"Cd выходит на постоянную; сравнение с литературой по стационарному обтеканию",
cyl_args(d, re, nx, ny, pert_amp=0), nx * ny, st,
gif=f"cyl_re{re}.gif", nx=nx)
# дорожка Кармана: длинное осреднение, это главный заход на литературу
for re in (100, 150, 200, 300):
d, nx, ny = 64, 1280, 768
st = int(conv_steps(d, 1500) * scale)
add(f"B{len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_re{re}", "B",
f"Цилиндр Re={re}, дорожка Кармана, D=64, 1500 конв. времён",
"St≈0.183 и ⟨Cd⟩≈1.33 при Re=150 после поправки на блокировку",
cyl_args(d, re, nx, ny), nx * ny, st, gif=f"cyl_re{re}.gif", nx=nx)
# разрешение тела: сходимость Cd
for d in (16, 32, 64, 128):
nx, ny = 20 * d, 12 * d
st = int(conv_steps(d, 800) * scale)
add(f"B{len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_d{d}", "B",
f"Цилиндр Re=150, разрешение D={d}",
"Cd сходится по D; экстраполяция даёт сеточно-независимое значение",
cyl_args(d, 150, nx, ny), nx * ny, st, gif=f"cyl_d{d}.gif", nx=nx)
# блокировка канала: экстраполяция к бесконечной среде — прямой заход на нерешённые 12%
for k in (6, 8, 12, 16, 24, 32):
d, nx, ny = 48, 960, k * 48
st = int(conv_steps(d, 1000) * scale)
add(f"B{len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_block{k}", "B",
f"Цилиндр Re=150, блокировка Ny/D={k}",
"экстраполяция Cd и St к нулевой блокировке; ожидание — сходимость к 1.33 и 0.183",
cyl_args(d, 150, nx, ny), nx * ny, st, gif=None, nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# C. Модели стенки и субсеточность
# ══════════════════════════════════════════════════════════════════════════════
def group_c(scale):
for wall in ("hrr", "grad", "bouzidi", "staircase"):
for re in (150, 2000):
d, nx, ny = 32, 640, 384
st = int(conv_steps(d, 400 if re > 20 else 60) * scale)
add(f"C{len([r for r in RUNS if r['group']=='C'])+1:02d}_wall_{wall}_re{re}", "C",
f"Модель стенки {wall}, Re={re}",
"все субсеточные модели обязаны сойтись к одному пределу; staircase — база",
cyl_args(d, re, nx, ny, wall=wall), nx * ny, st,
gif=f"wall_{wall}_re{re}.gif" if re == 150 else None, nx=nx)
# Чувствительность к положению тела ВНУТРИ клетки — прямая проверка субсеточности, и
# единственное измерение, которое вообще разделяет модели стенки. Двух положений для этого
# мало: по двум точкам не отличить систематический разброс от совпадения, поэтому берём
# четверти, и все четыре модели. Прогоны короткие (стационар при Re=20), вся серия — минуты.
for wall in ("hrr", "grad", "bouzidi", "staircase"):
for off in (0.0, 0.25, 0.5):
d, nx, ny = 16, 320, 192
st = int(conv_steps(d, 60) * scale)
add(f"C{len([r for r in RUNS if r['group']=='C'])+1:02d}_sub_{wall}_{int(off*100):02d}",
"C", f"Субклеточный сдвиг тела {off} клетки, стенка {wall}",
"разброс Cd по сдвигам: чем субсеточнее модель, тем он меньше",
cyl_args(d, 20, nx, ny, body_y=96 + off, wall=wall, pert_amp=0),
nx * ny, st)
# ══════════════════════════════════════════════════════════════════════════════
# D. Профили крыла
# ══════════════════════════════════════════════════════════════════════════════
def foil_args(chord, alpha, re, nx, ny, naca="0012", **kw):
a = {"--shape": "naca", "--naca": naca, "--size": chord,
"--body-angle": alpha, "--nx": nx, "--ny": ny,
"--body-x": nx // 4, "--re": re, "--refine": 1}
a.update({"--" + k.replace("_", "-"): v for k, v in kw.items()})
return flatten(a)
def group_d(scale):
# поляра: наклон Cl(alpha) и положение сваливания
for al in (0, 4, 8, 12, 16):
c, nx, ny = 128, 2048, 1024
st = int(conv_steps(c, 400) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca0012_a{al:02d}", "D",
f"NACA 0012, α={al}°, Re=1000, хорда 128",
"линейный участок Cl(α) и срыв; сравнение с низкорейнольдсовой литературой",
foil_args(c, al, 1000, nx, ny), nx * ny, st,
gif=f"naca0012_a{al:02d}.gif", nx=nx)
for re in (500, 2000, 10000):
c, nx, ny = 128, 2048, 1024
st = int(conv_steps(c, 400) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca0012_re{re}", "D",
f"NACA 0012, α=8°, Re={re}",
"перестройка следа с ростом Re; проверка устойчивости на профиле",
foil_args(c, 8, re, nx, ny), nx * ny, st,
gif=f"naca0012_re{re}.gif", nx=nx)
for al in (0, 6, 12):
c, nx, ny = 128, 2048, 1024
st = int(conv_steps(c, 400) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca4412_a{al:02d}", "D",
f"NACA 4412 (с изгибом), α={al}°, Re=1000",
"ненулевой Cl при α=0 — прямая проверка средней линии профиля",
foil_args(c, al, 1000, nx, ny, naca="4412"), nx * ny, st,
gif=f"naca4412_a{al:02d}.gif", nx=nx)
for c in (48, 96, 192):
nx, ny = 16 * c, 8 * c
st = int(conv_steps(c, 300) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca_chord{c}", "D",
f"NACA 0012, α=8°, сходимость по хорде {c}",
"Cl и Cd сходятся по разрешению хорды",
foil_args(c, 8, 1000, nx, ny), nx * ny, st, nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# E. Сложная и множественная геометрия
# ══════════════════════════════════════════════════════════════════════════════
def group_e(scale):
# тандем: литература даёт переключение режимов около L/D 3.5–4
for ld in (1.5, 3.0, 5.0):
d, nx, ny = 48, 1440, 720
gap = int(ld * d)
bodies = f"cylinder:d={d},x=360,y=360; cylinder:d={d},x={360 + gap},y=360"
st = int(conv_steps(d, 800) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_tandem_ld{int(ld*10)}", "E",
f"Тандем цилиндров, зазор L/D={ld}",
"при малом зазоре у заднего тела ОТРИЦАТЕЛЬНОЕ сопротивление; переход около 3.5–4",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "150", "--refine", "1"],
nx * ny, st, gif=f"tandem_ld{int(ld*10)}.gif", nx=nx)
# решётка как пористая среда
for step_d, tag in ((3, "sparse"), (2, "dense")):
d, nx, ny = 24, 1440, 720
bodies = "; ".join(
f"cylinder:d={d},x={360 + i * step_d * d},y={360 + (j - 1.5) * step_d * d}"
for i in range(4) for j in range(4))
st = int(conv_steps(d, 600) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_array_{tag}", "E",
f"Решётка 4×4 цилиндров, шаг {step_d}D — пористая среда",
"суммарное сопротивление и структура течения сквозь набор тел",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "150", "--refine", "1"],
nx * ny, st, gif=f"array_{tag}.gif", nx=nx)
# перфорированная пластина: набор коротких пластин со щелями
for open_frac, tag in ((0.2, "open20"), (0.5, "open50")):
nx, ny = 1200, 600
seg, gap = 40, int(40 * open_frac / (1 - open_frac))
bodies = "; ".join(
f"plate:d={seg},t=0.12,a=90,x=300,y={60 + k * (seg + gap)}"
for k in range(max(1, (ny - 120) // (seg + gap))))
st = int(conv_steps(seg, 500) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_perf_{tag}", "E",
f"Перфорированная пластина, скважность {open_frac}",
"струи в щелях и общее сопротивление; проверка множества тонких тел",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif=f"perf_{tag}.gif", nx=nx)
# многоэлементный профиль: основной + предкрылок
nx, ny = 2048, 1024
bodies = ("naca:naca=4412,d=200,a=6,x=560,y=512; "
"naca:naca=2412,d=70,a=18,x=430,y=470")
st = int(conv_steps(200, 300) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_multielem", "E",
"Многоэлементный профиль: основной 4412 плюс предкрылок",
"щель между элементами и раздельные Cl/Cd по телам",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "2000", "--refine", "1"],
nx * ny, st, gif="multielem.gif", nx=nx)
# сверхтонкая пластина: предел субсеточности, тело тоньше клетки
for t, tag in ((0.02, "t05"), (0.01, "t025")):
nx, ny = 1200, 600
st = int(conv_steps(50, 500) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_thin_{tag}", "E",
f"Сверхтонкая пластина, толщина {t*50:.2f} клетки",
"предел субсеточной границы: тело тоньше клетки; ждём откаты на простой отскок",
["--shape", "plate", "--size", "50", "--thickness", str(t), "--body-angle", "20",
"--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif=f"thin_{tag}.gif", nx=nx)
# клин и зазубренная кромка — субклеточная деталь на многоугольнике
nx, ny = 1200, 600
st = int(conv_steps(60, 500) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_wedge", "E",
"Клин остриём против потока", "острая кромка: особая точка геометрии",
["--shape", "polygon", "--poly=-30,-18;36,0;-30,18", "--size", "60",
"--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif="wedge.gif", nx=nx)
saw = ";".join(f"{-30 + 12 * k},{(-1) ** k * 6}" for k in range(6)) + ";30,-18;-30,-18"
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_serrated", "E",
"Зазубренная задняя кромка", "субклеточные зубцы: как их видит граница",
["--shape", "polygon", f"--poly={saw}", "--size", "60",
"--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif="serrated.gif", nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# F. Поле влияния и границы домена
# ══════════════════════════════════════════════════════════════════════════════
def group_f(scale):
d = 48
for up in (2, 6, 16):
nx, ny = int((up + 20) * d), 12 * d
st = int(conv_steps(d, 600) * scale)
add(f"F{len([r for r in RUNS if r['group']=='F'])+1:02d}_inlet{up}d", "F",
f"Отступ до входа {up}D",
"с какого отступа результат перестаёт зависеть от положения входа",
cyl_args(d, 150, nx, ny, body_x=up * d), nx * ny, st)
for down in (5, 15, 40):
nx, ny = int((6 + down) * d), 12 * d
st = int(conv_steps(d, 600) * scale)
add(f"F{len([r for r in RUNS if r['group']=='F'])+1:02d}_outlet{down}d", "F",
f"Отступ до выхода {down}D",
"с какой длины следа выход перестаёт влиять на силы",
cyl_args(d, 150, nx, ny, body_x=6 * d), nx * ny, st)
for r in (1, 2, 3):
d, nx, ny = 32, 640, 384
st = int(conv_steps(d, 600) * scale)
nodes = nx * ny
if r > 1:
ax, bx = int(nx // 5 - 1.5 * d), int(nx // 5 + 6.25 * d)
ay, by = int(ny / 2 - 2 * d), int(ny / 2 + 2 * d)
nodes += r * (r * (bx - ax) + 1) * (r * (by - ay) + 1)
add(f"F{len([r_ for r_ in RUNS if r_['group']=='F'])+1:02d}_refine{r}", "F",
f"Вложенный патч ×{r}",
"вносит ли связка уровней систематику: Cd и St обязаны совпасть",
cyl_args(d, 150, nx, ny, refine=r), nodes, st)
# ══════════════════════════════════════════════════════════════════════════════
# G. Старт, акустика, время жизни
# ══════════════════════════════════════════════════════════════════════════════
def group_g(scale):
d, nx, ny = 48, 960, 576
for re in (150, 2000, 20000):
for init in ("uniform", "rest"):
st = int(conv_steps(d, 300) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_xt_re{re}_{init}", "G",
f"x–t диаграмма, Re={re}, старт {init}",
"откуда идёт стартовое возмущение и раскачивается ли продольная мода",
cyl_args(d, re, nx, ny, init=init, sponge_len=0,
xt=f"xt_re{re}_{init}.csv", xt_every=200),
nx * ny, st, gif=f"xt_re{re}_{init}.gif", nx=nx, extra_out=("xt",))
for sp in (0, 40):
st = int(conv_steps(d, 400) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_sponge{sp}", "G",
f"Губка выхода {sp} столбцов при Re=5000",
"губка — единственное, что реально ест продольную моду",
cyl_args(d, 5000, nx, ny, sponge_len=sp), nx * ny, st)
for sp in (0, 40):
st = int(conv_steps(d, 400) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_spongein{sp}", "G",
f"Губка входа {sp} столбцов при Re=5000",
"помогает ли гасить волну до отражения от входа",
cyl_args(d, 5000, nx, ny, sponge_in=sp), nx * ny, st)
for re in (10000, 50000, 200000):
st = int(conv_steps(d, 200) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_limit_re{re}", "G",
f"Предел устойчивости, Re={re}",
"где именно гибнет счёт; часть этих прогонов обязана развалиться",
cyl_args(d, re, nx, ny), nx * ny, st)
# тест на время жизни: один очень длинный прогон
st = int(5_000_000 * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_lifetime", "G",
"Тест на время жизни: 5·10⁶ шагов",
"не уплывает ли ⟨ρ⟩ и не деградирует ли решение на очень длинной дистанции",
cyl_args(d, 150, nx, ny), nx * ny, st, gif="lifetime.gif", nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# H. Инварианты и точность
# ══════════════════════════════════════════════════════════════════════════════
def group_h(scale):
d, nx, ny = 32, 640, 384
st = int(conv_steps(d, 400) * scale)
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_sym_cyl", "H",
"Симметрия: цилиндр при α=0 без возмущения",
"Cl и Cm обязаны остаться ≈0 — это внутреннее свойство схемы, не литература",
cyl_args(d, 40, nx, ny, pert_amp=0), nx * ny, st)
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_sym_foil", "H",
"Симметрия: NACA 0012 при α=0 без возмущения",
"Cl ≈ 0 у симметричного профиля под нулевым углом",
foil_args(64, 0, 500, 1024, 512, pert_amp=0), 1024 * 512,
int(conv_steps(64, 300) * scale))
for al, tag in ((8, "plus"), (-8, "minus")):
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_mirror_{tag}", "H",
f"Зеркальность: NACA 0012 при α={al}°",
"ряды обязаны зеркалиться: Cl меняет знак, Cd совпадает",
foil_args(64, al, 500, 1024, 512), 1024 * 512,
int(conv_steps(64, 300) * scale))
for ul in (0.02, 0.05, 0.1):
st2 = int(conv_steps(d, 400, ul) * scale)
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_mach{int(ul*100):02d}", "H",
f"Зависимость от числа Маха, u_lat={ul}",
"результат обязан сходиться при Ma→0; расхождение и есть сжимаемостная ошибка",
cyl_args(d, 150, nx, ny, u_lat=ul), nx * ny, st2)
# прямой замер расхождения f32 и f64 от длины прогона
for bk in ("cpu", "gpu"):
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_prec_{bk}", "H",
f"Точность: одинаковая постановка на {bk}",
"расхождение f32 против f64 в зависимости от длины прогона",
cyl_args(d, 150, nx, ny), nx * ny, int(conv_steps(d, 800) * scale), backend=bk)
# ══════════════════════════════════════════════════════════════════════════════
# I. Сверхмелкие сетки по всем формам
# ══════════════════════════════════════════════════════════════════════════════
def group_i(scale):
shapes = [
("cylinder", ["--shape", "cylinder"]),
("square", ["--shape", "square"]),
("diamond", ["--shape", "diamond"]),
("ellipse", ["--shape", "ellipse", "--thickness", "0.35"]),
("naca", ["--shape", "naca", "--naca", "0012", "--body-angle", "8"]),
("triangle", ["--shape", "triangle"]),
("plate", ["--shape", "plate", "--thickness", "0.08", "--body-angle", "25"]),
("wedge", ["--shape", "polygon", "--poly=-128,-72;154,0;-128,72"]),
]
d, nx, ny = 256, 4096, 2048
st = int(conv_steps(d, 300) * scale)
for tag, extra in shapes:
add(f"I{len([r for r in RUNS if r['group']=='I'])+1:02d}_fine_{tag}", "I",
f"Сверхмелкая сетка 4096×2048, {tag}, D=256",
"держится ли субсеточная граница на предельном разрешении для этой формы",
extra + ["--size", str(d), "--nx", str(nx), "--ny", str(ny),
"--body-x", "820", "--re", "1000", "--refine", "1"],
nx * ny, st, gif=f"fine_{tag}.gif", nx=nx)
bodies = ("cylinder:d=200,x=800,y=1024; cylinder:d=200,x=1400,y=1024; "
"naca:naca=4412,d=300,a=10,x=2200,y=1024")
add(f"I{len([r for r in RUNS if r['group']=='I'])+1:02d}_fine_multi", "I",
"Сверхмелкая сетка, композиция из трёх тел",
"взаимодействие следов на предельном разрешении",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "1000", "--refine", "1"],
nx * ny, st, gif="fine_multi.gif", nx=nx)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--scale", type=float, default=2.0,
help="общий множитель длительности всех прогонов")
ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "scenarios.json"))
args = ap.parse_args()
for g in (group_a, group_b, group_c, group_d, group_e, group_f, group_g, group_h, group_i):
g(args.scale)
doc = {
"meta": {
"note": "Стоимость в обновлениях узлов — единственная переносимая между машинами "
"мера. Часы драйвер получает, поделив её на фактические MLUPS, которые "
"замеряет на месте (--calibrate).",
"gif_policy": "вся длительность прогона, реальная скорость, 10 кадр/с; кадр "
"прореживается до ширины не больше 1280 пикселей",
"scale": args.scale,
"assumed_gpu_mlups": 1200,
"assumed_cpu_mlups": 22,
},
"runs": RUNS,
}
with open(args.out, "w", encoding="utf-8") as f:
json.dump(doc, f, ensure_ascii=False, indent=1)
print(f"прогонов: {len(RUNS)} файл: {args.out}\n")
print(f"{'гр':>3}{'прогонов':>10}{'обновлений узлов':>20}{'часов GPU':>12}{'часов CPU':>12}")
tot_gpu = tot_cpu = 0.0
for g in sorted({r["group"] for r in RUNS}):
rs = [r for r in RUNS if r["group"] == g]
cost = sum(r["cost"] for r in rs)
hg = sum(r["cost"] / 1.2e9 / 3600 for r in rs if r["backend"] == "gpu")
hc = sum(r["cost"] / 2.2e7 / 3600 for r in rs if r["backend"] == "cpu")
tot_gpu += hg
tot_cpu += hc
print(f"{g:>3}{len(rs):>10}{cost:>20.3e}{hg:>12.1f}{hc:>12.1f}")
print(f"{'—':>3}{len(RUNS):>10}{sum(r['cost'] for r in RUNS):>20.3e}"
f"{tot_gpu:>12.1f}{tot_cpu:>12.1f}")
print(f"\nвсего ≈ {tot_gpu + tot_cpu:.1f} ч при 1200 MLUPS на GPU и 22 на CPU")
if __name__ == "__main__":
main()
-188
View File
@@ -1,188 +0,0 @@
#!/usr/bin/env python3
"""Сверка GPU-пути с эталонным CPU-путём.
Зачем. На настоящем сервере GPU-путь идёт через драйвер NVIDIA. В WSL такого драйвера
для Linux нет, и единственный доступный Vulkan — dzn (Dozen), транслирующий вызовы в
D3D12. Он честно сообщает о себе `conformanceVersion = 0.0.0.0`, то есть набор тестов
соответствия не проходил. Арифметика f32 в обоих API описана одним и тем же IEEE 754,
но точность трансцендентных функций (exp, log, sqrt — а они в энтропийном равновесии
на каждом узле) стандартами задаётся с допуском в несколько ULP, и допуски эти разные.
Плюс энтропийный стабилизатор сравнивает знаменатель с относительным порогом GREL=1e-8:
достаточно мелкого расхождения, чтобы решение «вырожден или нет» поменялось.
Поэтому пригодность dzn нельзя принимать на веру — её надо мерить. Скрипт гоняет два
коротких эталона на CPU (f64) и на GPU и сверяет числа из summary.json.
Прогоны подобраны намеренно НЕ хаотические: вихрь Тейлора–Грина затухает гладко и имеет
точное решение, обтекание цилиндра при Re=20 стационарно. В таких течениях расхождение
f32 и f64 не нарастает, поэтому любое заметное различие означает проблему драйвера, а не
разрядности. На развитой дорожке (Re >= 100) сверять бессмысленно: там разойдётся любая
пара запусков с разной арифметикой.
Порог взят с запасом от факта: на нативном Vulkan разница Cd между CPU-f64 и GPU-f32
измерена и составила 0.007%.
python3 parity.py # сверка
python3 parity.py --tol 2e-3 # свой порог
python3 parity.py --keep out/ # оставить сводки для разбора
Код возврата 1, если хоть одно поле вышло за порог или прогон развалился.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
BIN = os.environ.get("KBC2D_BIN") or os.path.join(
HERE, "..", "target", "release", "kbc2d" + (".exe" if os.name == "nt" else ""))
# Поля сводки: "fields" сверяются с порогом, "report" только печатаются.
#
# * energy_end, enstrophy_end — интегралы поля, ловят расхождение самого счёта;
# * cd — интегральная сила, накапливает ошибку по всей границе тела;
# * gamma_mean и degenerate_frac — поведение энтропийного стабилизатора, самый чуткий
# индикатор расхождений в exp/log;
# * rho_mean — сохранение массы, обязано совпадать почти побитово.
#
# Пороги откалиброваны по замеру на НАТИВНОМ драйвере Vulkan (Intel Iris Xe, Windows):
# там же, где f64 и f32 сравниваются в отсутствие какой-либо трансляции, отклонения
# составили cd 1.7e-5, rho_mean 1.5e-6, gamma_mean 6.5e-4, energy_end 3.4e-3 (вихрь
# доведён до затухания). Пороги ниже взяты с запасом к этим числам, но заметно ниже
# того, что дал бы по-настоящему сломанный драйвер.
#
# Прогоны намеренно НЕ доводятся до глубокого затухания: когда энергия падает до 1e-7,
# f32 упирается в собственный шум, и сверять становится нечего — это свойство
# разрядности, а не драйвера.
CASES = [
{
"name": "Тейлор-Грин 128x128, 500 шагов",
"args": ["--case", "taylor-green", "--nx", "128", "--ny", "128",
"--refine", "1", "--steps", "500", "--case-every", "100"],
# taylor_green_error намеренно НЕ в списке сверяемых, только в отчётных: это
# разность почти равных величин, и её относительное отклонение f32 от f64
# достигает десятков процентов на любом, в том числе нативном, драйвере.
# Замерено на нативном Vulkan: 0.0116 (f64) против 0.0218 (f32). Сверять по
# ней бессмысленно, а видеть её полезно.
"fields": {"energy_end": 1.0, "enstrophy_end": 1.0, "gamma_mean": 1.0},
"report": ["taylor_green_error"],
},
{
"name": "цилиндр Re=20, стационар, 8000 шагов",
"args": ["--shape", "cylinder", "--size", "16", "--nx", "320", "--ny", "192",
"--re", "20", "--refine", "1", "--steps", "8000"],
"fields": {"cd": 1.0, "rho_mean": 0.05, "gamma_mean": 2.0,
"degenerate_frac": 5.0},
"report": ["strouhal"],
},
]
def run(backend, args, summary_path):
"""Один прогон. Возвращает разобранную сводку либо строку с ошибкой."""
cmd = [BIN] + args + ["--backend", backend, "--verbose", "quiet",
"--report-every", "0", "--summary", summary_path]
proc = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip().splitlines()
return None, "\n".join(tail[-6:]) or f"код возврата {proc.returncode}"
try:
with open(summary_path, encoding="utf-8") as f:
return json.load(f), None
except Exception as e:
return None, f"сводка не читается: {e}"
def deviation(ref, got):
"""Относительное отклонение; для околонулевых величин — абсолютное."""
if abs(ref) > 1e-12:
return abs(got - ref) / abs(ref)
return abs(got - ref)
def main():
ap = argparse.ArgumentParser(description="Сверка GPU-пути с CPU-эталоном")
ap.add_argument("--tol", type=float, default=1e-3,
help="базовый относительный порог (по умолчанию 1e-3)")
ap.add_argument("--keep", metavar="DIR", help="куда положить сводки прогонов")
args = ap.parse_args()
if not os.path.exists(BIN):
print(f"не найден бинарь решателя: {BIN}")
return 1
workdir = args.keep or tempfile.mkdtemp(prefix="kbc2d-parity-")
os.makedirs(workdir, exist_ok=True)
failures = []
speeds = []
for case in CASES:
print(f"\n=== {case['name']} ===")
summaries = {}
for backend in ("cpu", "gpu"):
path = os.path.join(workdir, f"{backend}.json")
data, err = run(backend, case["args"], path)
if err:
print(f" {backend}: ПРОГОН НЕ УДАЛСЯ\n {err}")
failures.append(f"{case['name']}: {backend} не отработал")
break
if data.get("blew_up"):
print(f" {backend}: счёт РАЗВАЛИЛСЯ")
failures.append(f"{case['name']}: {backend} развалился")
break
summaries[backend] = data
if len(summaries) != 2:
continue
cpu, gpu = summaries["cpu"], summaries["gpu"]
m_cpu, m_gpu = cpu.get("mlups", 0.0), gpu.get("mlups", 0.0)
speeds.append((case["name"], m_cpu, m_gpu))
ratio = f", ускорение x{m_gpu / m_cpu:.1f}" if m_cpu > 0 else ""
print(f" скорость: CPU {m_cpu:.1f} MLUPS, GPU {m_gpu:.1f} MLUPS{ratio}")
print(f" {'поле':<22}{'CPU (f64)':>16}{'GPU':>16}{'откл.':>12} порог")
for field in case.get("report", []):
if field in cpu and field in gpu:
ref, got = float(cpu[field]), float(gpu[field])
print(f" {field:<22}{ref:>16.6g}{got:>16.6g}{deviation(ref, got):>11.2e}"
f" (только к сведению)")
for field, mult in case["fields"].items():
if field not in cpu or field not in gpu:
print(f" {field:<22}{'нет в сводке':>16}")
continue
ref, got = float(cpu[field]), float(gpu[field])
dev = deviation(ref, got)
tol = args.tol * mult
mark = "" if dev <= tol else " <-- ВЫШЕ ПОРОГА"
print(f" {field:<22}{ref:>16.6g}{got:>16.6g}{dev:>11.2e} {tol:.1e}{mark}")
if dev > tol:
failures.append(f"{case['name']}: {field} отклонилось на {dev:.2e} "
f"при пороге {tol:.1e}")
print()
if speeds:
print("Скорость по прогонам:")
for name, m_cpu, m_gpu in speeds:
print(f" {name}: CPU {m_cpu:.1f} MLUPS, GPU {m_gpu:.1f} MLUPS")
print()
if failures:
print("СВЕРКА НЕ ПРОЙДЕНА:")
for f in failures:
print(f" * {f}")
print("\nGPU-путь на этом драйвере доверия не заслуживает — кампанию на нём "
"запускать нельзя.")
else:
print("Сверка пройдена: GPU считает то же, что CPU в двойной точности.")
if not args.keep:
shutil.rmtree(workdir, ignore_errors=True)
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
-113
View File
@@ -1,113 +0,0 @@
#!/usr/bin/env python3
# Предполётная проверка кампании: каждый сценарий запускается на два шага.
#
# python preflight.py # проверить все
# python preflight.py --group I # только группу
#
# Ловит опечатки в ключах, несовместимые сочетания и пределы железа ДО того, как кампания
# уйдёт считать на девяносто часов. Не бесплатная привычка, а окупившаяся: именно так
# выяснилось, что вся группа сверхмелких сеток падала на пределе GPU в 65535 рабочих групп
# на измерение, а девять прогонов группы F передавали --body-x дважды.
#
# Физику проверка не трогает вовсе: два шага — это ровно «запустилось и не упало».
import argparse
import json
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
BIN = os.environ.get("KBC2D_BIN") or os.path.join(
HERE, "..", "target", "release", "kbc2d" + (".exe" if os.name == "nt" else ""))
# Всё, что пишет файлы, выкидываем: проверяем запуск, а не вывод. Ключ идёт со значением,
# поэтому следующий за ним аргумент тоже пропускается.
DROP = {"--gif", "--xt", "--case-csv", "--gif-field", "--gif-downsample", "--gif-scale",
"--gif-every", "--gif-fps", "--series-every"}
# Строки, которые ничего не объясняют и только вытесняют полезные.
NOISE = (
"note: run with `RUST_BACKTRACE",
"note: Some details are omitted",
"WARNING: dzn is not a conformant",
"stack backtrace",
"Caused by:",
)
def explain(output):
"""Достать из вывода упавшего прогона то, ради чего его вообще читают.
Раньше бралась последняя строка вывода — а у паники Rust последняя строка это
«note: run with RUST_BACKTRACE=1», то есть ровно ноль сведений о причине.
Полезное лежит либо в собственном сообщении решателя, либо на строке ПОСЛЕ
«panicked at»: там текст самой паники.
"""
out = [l.rstrip() for l in output.splitlines() if l.strip()]
out = [l for l in out if not any(n in l for n in NOISE)]
if not out:
return "(без вывода)"
for l in out:
if l.lstrip().startswith("ОШИБКА"):
return l.strip()
for i, l in enumerate(out):
if "panicked at" in l:
rest = [x.strip() for x in out[i + 1:]]
return " | ".join(rest[:4]) if rest else l.strip()
return out[-1].strip()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--group", help="только эта группа, например I")
ap.add_argument("--scenarios", default=os.path.join(HERE, "scenarios.json"))
args = ap.parse_args()
runs = json.load(open(args.scenarios, encoding="utf-8"))["runs"]
if args.group:
runs = [r for r in runs if r["group"].upper() == args.group.upper()]
if not runs:
print("под выборку не попал ни один прогон")
return 0
if not os.path.exists(BIN):
print(f"не найден бинарь решателя: {BIN}")
return 1
bad = []
for i, r in enumerate(runs, 1):
argv, skip = [], False
for a in r["args"]:
if skip:
skip = False
continue
if a in DROP:
skip = True
continue
argv.append(a)
if "--steps" in argv:
argv[argv.index("--steps") + 1] = "2"
else:
argv += ["--steps", "2"]
p = subprocess.run([BIN] + argv + ["--verbose", "quiet", "--report-every", "0"],
capture_output=True, cwd=HERE)
ok = p.returncode == 0
print("." if ok else "X", end="", flush=True)
if not ok:
out = (p.stdout + p.stderr).decode("utf-8", "replace")
bad.append((r["id"], explain(out)))
if i % 40 == 0:
print(f" {i}/{len(runs)}", flush=True)
print(f"\n\nпроверено сценариев: {len(runs)}, не запустились: {len(bad)}")
for id_, msg in bad:
print(f" {id_}")
print(f" {msg}")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,110 +0,0 @@
# Драйвер валидационной кампании для Windows. Делает ровно то же, что run_campaign.sh, и
# читает тот же scenarios.json — целевая машина Linux, а этот вариант нужен для локальной
# отладки обвязки.
#
# .\run_campaign.ps1 -DryRun
# .\run_campaign.ps1 -Calibrate
# .\run_campaign.ps1 -Smoke
# .\run_campaign.ps1 -Resume -Group A,B
# .\run_campaign.ps1 -Only cyl_re150 -BudgetHours 6
[CmdletBinding()]
param(
[switch]$DryRun,
[switch]$Resume,
[switch]$Calibrate,
[switch]$Smoke,
[string[]]$Group,
[string]$Only,
[double]$BudgetHours = 0,
[string]$Bin = "..\target\release\kbc2d.exe",
[string]$Scenarios = "scenarios.json",
[string]$Out = "out",
[double]$GpuMlups = 1200,
[double]$CpuMlups = 22
)
Set-Location $PSScriptRoot
if (-not (Test-Path $Bin)) { throw "не найден бинарь решателя: $Bin (собери cargo build --release)" }
if (-not (Test-Path $Scenarios)) { throw "не найден список сценариев: $Scenarios" }
if ($Calibrate) {
"Калибровка на этой машине (три коротких прогона)…"
# 1920x960 добавлена не для красоты: на 95% стоимости кампании сетки крупнее
# 600 тыс. узлов, и оценивать по мелким - значит занижать пропускную способность.
foreach ($c in @(@(240,120,'gpu'), @(960,480,'gpu'), @(1920,960,'gpu'), @(480,240,'cpu'))) {
$o = & $Bin --nx $c[0] --ny $c[1] --size 16 --refine 1 --steps 3000 `
--report-every 3000 --verbose full --backend $c[2] 2>$null
$m = ($o | Select-String 'MLUPS' | Select-Object -Last 1)
" $($c[0])x$($c[1]) на $($c[2]): $(if ($m) { ($m -replace '.*?([0-9.]+) MLUPS.*','$1') } else { 'не измерено' })"
}
""
"Подставь замеренное: .\run_campaign.ps1 -DryRun -GpuMlups <число> -CpuMlups <число>"
return
}
$runs = (Get-Content $Scenarios -Raw -Encoding UTF8 | ConvertFrom-Json).runs
if ($Group) { $g = $Group | ForEach-Object { $_.ToUpper() }; $runs = $runs | Where-Object { $g -contains $_.group.ToUpper() } }
if ($Only) { $runs = $runs | Where-Object { $_.id -like "*$Only*" } }
if ($Smoke) { $runs = $runs | Sort-Object cost | Select-Object -First 3 }
if (-not $runs) { "под выборку не попал ни один прогон"; return }
$totalCost = ($runs | Measure-Object -Property cost -Sum).Sum
$estH = $totalCost / ($GpuMlups * 1e6) / 3600
"прогонов: $($runs.Count) обновлений узлов: {0:e3} ≈ {1:F1} ч при $GpuMlups MLUPS" -f $totalCost, $estH
"результаты: $Out\<id>\"
""
if ($DryRun) {
"{0,-28} {1,-3} {2,-4} {3,12} {4,8} {5}" -f 'id','гр','бэк','шагов','часов','название'
foreach ($r in $runs) {
$m = if ($r.backend -eq 'cpu') { $CpuMlups } else { $GpuMlups }
"{0,-28} {1,-3} {2,-4} {3,12} {4,8:F2} {5}" -f $r.id, $r.group, $r.backend, $r.steps, ($r.cost/($m*1e6)/3600), $r.title
}
return
}
New-Item -ItemType Directory -Force $Out | Out-Null
$summary = Join-Path $Out 'summary.csv'
$log = Join-Path $Out 'campaign.log'
if (-not (Test-Path $summary)) { 'id,group,backend,status,seconds,steps,cost,title' | Out-File $summary -Encoding utf8 }
$started = Get-Date
foreach ($r in $runs) {
$dir = Join-Path $Out $r.id
if ($Resume -and (Test-Path (Join-Path $dir 'summary.json'))) { "· $($r.id) — уже посчитан, пропускаю"; continue }
if ($BudgetHours -gt 0 -and ((Get-Date) - $started).TotalHours -ge $BudgetHours) {
"бюджет $BudgetHours ч исчерпан, останавливаюсь"; break
}
New-Item -ItemType Directory -Force $dir | Out-Null
# относительные имена файлов из сценария кладём внутрь папки прогона
$argv = @()
for ($i = 0; $i -lt $r.args.Count; $i++) {
$v = $r.args[$i]
if ($i -gt 0 -and @('--gif','--xt','--case-csv') -contains $r.args[$i-1]) { $v = Join-Path $dir $v }
$argv += $v
}
$argv += @('--summary', (Join-Path $dir 'summary.json'),
'--csv', (Join-Path $dir 'series.csv'),
'--verbose','full','--report-every','5000')
"$Bin $($argv -join ' ')" | Out-File (Join-Path $dir 'cmd.txt') -Encoding utf8
"▶ $($r.id) ($($r.group), $($r.backend)) $($r.title)"
$t0 = Get-Date
& $Bin @argv 2>&1 | Out-File (Join-Path $dir 'log.txt') -Encoding utf8
$st = if ($LASTEXITCODE -eq 0) { 'ok' } else { 'fail' }
$dt = [int]((Get-Date) - $t0).TotalSeconds
if (Select-String -Path (Join-Path $dir 'log.txt') -Pattern 'РАЗВАЛИЛСЯ' -Quiet) { $st = 'blewup' }
$rep = Get-Content (Join-Path $dir 'log.txt') -Encoding utf8
$k = ($rep | Select-String 'ИТОГОВЫЙ ОТЧЁТ' | Select-Object -First 1).LineNumber
if ($k) { $rep[($k-1)..($rep.Count-1)] | Out-File (Join-Path $dir 'report.txt') -Encoding utf8 }
'{0},{1},{2},{3},{4},{5},{6},"{7}"' -f $r.id,$r.group,$r.backend,$st,$dt,$r.steps,$r.cost,$r.title |
Out-File $summary -Append -Encoding utf8
"$(Get-Date -Format o) $($r.id) $st ${dt}s" | Out-File $log -Append -Encoding utf8
" → $st за $dt с"
}
""
"готово. сводная таблица: $summary"
-152
View File
@@ -1,152 +0,0 @@
#!/usr/bin/env bash
# Драйвер валидационной кампании. Читает scenarios.json, гоняет прогоны по одному, кладёт
# логи, ряды, сводки и гифки каждого в собственную папку и ведёт общую таблицу.
#
# ./run_campaign.sh --dry-run смета: что и сколько будет считаться
# ./run_campaign.sh --calibrate замерить фактические MLUPS этой машины
# ./run_campaign.sh --smoke три коротких прогона: проверить обвязку
# ./run_campaign.sh --resume считать, пропуская уже готовое
# ./run_campaign.sh --group A,B только выбранные группы
# ./run_campaign.sh --only cyl_re150 по подстроке идентификатора
# ./run_campaign.sh --budget-hours 24 остановиться, когда время выйдет
#
# Прогон, который упал или развалился, помечается в сводке и НЕ останавливает кампанию:
# группы E и G специально ищут предел устойчивости.
set -u
cd "$(dirname "$0")"
BIN="${KBC2D_BIN:-../target/release/kbc2d}"
SCEN="${KBC2D_SCENARIOS:-scenarios.json}"
OUT="${KBC2D_OUT:-out}"
# ВНИМАНИЕ: имя GROUPS занято самим bash (список групп пользователя), присваивание в неё
# молча игнорируется, а "$GROUPS" под root разворачивается в 0 — и выборка съедает всю
# кампанию, не сказав ни слова. Отсюда GRP_SEL.
DRY=0; RESUME=0; CALIB=0; SMOKE=0; GRP_SEL=""; ONLY=""; BUDGET=""
GPU_MLUPS="${KBC2D_GPU_MLUPS:-1200}"
CPU_MLUPS="${KBC2D_CPU_MLUPS:-22}"
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY=1 ;;
--resume) RESUME=1 ;;
--calibrate) CALIB=1 ;;
--smoke) SMOKE=1 ;;
--group) GRP_SEL="$2"; shift ;;
--only) ONLY="$2"; shift ;;
--budget-hours) BUDGET="$2"; shift ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) echo "неизвестный ключ: $1" >&2; exit 2 ;;
esac
shift
done
command -v python3 >/dev/null 2>&1 && PY=python3 || PY=python
[ -x "$BIN" ] || { echo "не найден бинарь решателя: $BIN (собери cargo build --release)" >&2; exit 1; }
[ -f "$SCEN" ] || { echo "не найден список сценариев: $SCEN" >&2; exit 1; }
# ── калибровка: три коротких прогона на месте, чтобы оценки в часах были не гаданием ──
if [ "$CALIB" = 1 ]; then
echo "Калибровка на этой машине (три коротких прогона)…"
# 1920x960 добавлена не для красоты: на 95% стоимости кампании сетки крупнее
# 600 тыс. узлов, и оценивать по мелким — значит занижать пропускную способность.
for spec in "240 120 gpu" "960 480 gpu" "1920 960 gpu" "480 240 cpu"; do
set -- $spec
m=$("$BIN" --nx "$1" --ny "$2" --size 16 --refine 1 --steps 3000 --report-every 3000 \
--verbose full --backend "$3" 2>/dev/null | grep -o '[0-9.]* MLUPS' | tail -1)
echo " $1x$2 на $3: ${m:-не измерено}"
done
echo
echo "Подставь замеренное в переменные окружения и запусти смету:"
echo " KBC2D_GPU_MLUPS=<число> KBC2D_CPU_MLUPS=<число> ./run_campaign.sh --dry-run"
exit 0
fi
mkdir -p "$OUT"
SUMMARY="$OUT/summary.csv"
LOG="$OUT/campaign.log"
[ -f "$SUMMARY" ] || echo "id,group,backend,status,seconds,steps,cost,title" > "$SUMMARY"
# Выборку и порядок считает python: разбирать JSON башем — верный способ ошибиться.
PLAN=$("$PY" - "$SCEN" "$GRP_SEL" "$ONLY" "$SMOKE" <<'PYEOF'
import json, sys
scen, groups, only, smoke = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "1"
runs = json.load(open(scen, encoding="utf-8"))["runs"]
if groups:
keep = {g.strip().upper() for g in groups.split(",")}
runs = [r for r in runs if r["group"].upper() in keep]
if only:
runs = [r for r in runs if only in r["id"]]
if smoke:
# три самых дешёвых прогона: обвязку проверяем, а не физику
runs = sorted(runs, key=lambda r: r["cost"])[:3]
for r in runs:
print("\t".join([r["id"], r["group"], r["backend"], str(r["cost"]),
str(r["steps"]), r["title"], "\x1f".join(r["args"])]))
PYEOF
)
[ -n "$PLAN" ] || { echo "под выборку не попал ни один прогон"; exit 0; }
total_cost=0; n=0
while IFS=$'\t' read -r id grp bk cost steps title args; do
n=$((n + 1)); total_cost=$((total_cost + cost))
done <<< "$PLAN"
est_h=$("$PY" -c "
import sys
print('%.1f' % (float(sys.argv[1]) / (float(sys.argv[2]) * 1e6) / 3600))" "$total_cost" "$GPU_MLUPS")
echo "прогонов: $n обновлений узлов: $total_cost ≈ $est_h ч при $GPU_MLUPS MLUPS"
echo "результаты: $OUT/<id>/"
echo
if [ "$DRY" = 1 ]; then
printf '%-28s %-3s %-4s %12s %10s %s\n' id гр бэк шагов часов название
while IFS=$'\t' read -r id grp bk cost steps title args; do
m=$GPU_MLUPS; [ "$bk" = cpu ] && m=$CPU_MLUPS
h=$("$PY" -c "import sys;print('%.2f'%(float(sys.argv[1])/(float(sys.argv[2])*1e6)/3600))" "$cost" "$m")
printf '%-28s %-3s %-4s %12s %10s %s\n' "$id" "$grp" "$bk" "$steps" "$h" "$title"
done <<< "$PLAN"
exit 0
fi
started=$(date +%s)
while IFS=$'\t' read -r id grp bk cost steps title args; do
dir="$OUT/$id"
if [ "$RESUME" = 1 ] && [ -f "$dir/summary.json" ]; then
echo "· $id — уже посчитан, пропускаю"
continue
fi
if [ -n "$BUDGET" ]; then
spent=$(( ($(date +%s) - started) ))
limit=$("$PY" -c "import sys;print(int(float(sys.argv[1])*3600))" "$BUDGET")
[ "$spent" -ge "$limit" ] && { echo "бюджет $BUDGET ч исчерпан, останавливаюсь"; break; }
fi
mkdir -p "$dir"
# аргументы разделены символом 0x1f: в них есть пробелы (список тел) и запятые
IFS=$'\x1f' read -r -a ARGV <<< "$args"
ARGV+=(--summary "$dir/summary.json" --csv "$dir/series.csv" --verbose full --report-every 5000)
# относительные имена файлов из сценария кладём внутрь папки прогона
for i in "${!ARGV[@]}"; do
case "${ARGV[$((i-1))]:-}" in
--gif|--xt|--case-csv) ARGV[$i]="$dir/${ARGV[$i]}" ;;
esac
done
printf '%s\n' "$BIN ${ARGV[*]}" > "$dir/cmd.txt"
echo "▶ $id ($grp, $bk) $title"
t0=$(date +%s)
if "$BIN" "${ARGV[@]}" > "$dir/log.txt" 2>&1; then st=ok; else st=fail; fi
t1=$(date +%s); dt=$((t1 - t0))
grep -q "РАЗВАЛИЛСЯ" "$dir/log.txt" 2>/dev/null && st=blewup
sed -n '/ИТОГОВЫЙ ОТЧЁТ/,$p' "$dir/log.txt" > "$dir/report.txt" 2>/dev/null
printf '%s,%s,%s,%s,%s,%s,%s,"%s"\n' "$id" "$grp" "$bk" "$st" "$dt" "$steps" "$cost" "$title" >> "$SUMMARY"
echo "$(date -Is) $id $st ${dt}s" >> "$LOG"
echo " → $st за ${dt} с"
done <<< "$PLAN"
echo
echo "готово. сводная таблица: $SUMMARY"
File diff suppressed because it is too large Load Diff
@@ -1,97 +0,0 @@
# Развёртывание кампании на сервере с NVIDIA. Этот файл САМОДОСТАТОЧЕН: он тянет готовый образ
# из реестра и исходников репозитория не требует. Скопировать на сервер достаточно его одного.
#
# mkdir -p ~/kbc2d && cd ~/kbc2d
# curl -O <ссылка на этот файл> # либо просто перенести файл руками
#
# docker compose -f docker-compose.server.yml --profile check run --rm vulkan
# docker compose -f docker-compose.server.yml --profile check run --rm preflight
# docker compose -f docker-compose.server.yml --profile check run --rm calibrate
# docker compose -f docker-compose.server.yml --profile check run --rm plan
# docker compose -f docker-compose.server.yml up -d
# docker compose -f docker-compose.server.yml logs -f
#
# Порядок именно такой. Сначала vulkan: если карта не видна, кампания молча уйдёт считать
# ничего — точнее, откажется стартовать на первом же прогоне, но узнать об этом через час
# обиднее, чем через минуту. Потом preflight (каждый сценарий стартует на два шага), потом
# calibrate (замер MLUPS этой машины), и только потом сама кампания.
#
# Результаты складываются в ./out на хосте — около 4.4 ГБ гифок и рядов за полный проход.
name: kbc2d
# ── общая часть всех сервисов ────────────────────────────────────────────────
x-kbc2d: &kbc2d
image: notbigghost/kbc2d:1.2.0
pull_policy: missing
volumes:
- ./out:/work/bench/out
environment:
# ГЛАВНОЕ МЕСТО ВСЕГО ФАЙЛА. NVIDIA Container Toolkit подкладывает внутрь Vulkan-ICD
# (nvidia_icd.json) только если в этом списке есть `graphics`. С одним `compute` wgpu не
# увидит НИ ОДНОГО адаптера, и решатель откажется стартовать с --backend gpu. В образе
# значение уже прописано, здесь оно продублировано явно — чтобы его было видно тому, кто
# читает compose, а не Dockerfile.
NVIDIA_DRIVER_CAPABILITIES: compute,utility,graphics
NVIDIA_VISIBLE_DEVICES: all
# Оценки в часах драйвер считает из этих чисел. Умолчания взяты с другой машины —
# подставьте сюда то, что напечатает профиль calibrate.
KBC2D_GPU_MLUPS: ${KBC2D_GPU_MLUPS:-1200}
KBC2D_CPU_MLUPS: ${KBC2D_CPU_MLUPS:-22}
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
services:
# ── сама кампания: единственный сервис, который поднимается по `up -d` ──────
campaign:
<<: *kbc2d
container_name: kbc2d-campaign
# --resume пропускает всё, у чего уже есть summary.json: перезапуск продолжает с места,
# а не начинает заново. Именно поэтому перезапуск здесь безопасен и дёшев.
command: ["--resume"]
# on-failure, а НЕ unless-stopped: кампания завершается штатно с кодом 0, и политика
# «перезапускать всегда» после её окончания крутила бы контейнер вхолостую по кругу.
# Падение (OOM, перезагрузка хоста) даёт ненулевой код и будет подхвачено.
restart: on-failure:5
stop_grace_period: 30s
# Девяносто часов вывода — это сотни мегабайт журнала; без ограничения он съест диск.
# Полные логи каждого прогона всё равно лежат в out/<id>/log.txt.
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
# ── проверки перед запуском (профиль check, сами не поднимаются) ────────────
vulkan:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["vulkaninfo"]
command: ["--summary"]
preflight:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["python3"]
command: ["preflight.py"]
calibrate:
<<: *kbc2d
profiles: ["check"]
command: ["--calibrate"]
plan:
<<: *kbc2d
profiles: ["check"]
command: ["--dry-run"]
# ── если результаты нужны не под root ────────────────────────────────────────
# Контейнер работает от root, поэтому файлы в ./out окажутся root:root. Чтобы они
# принадлежали вам, добавьте в x-kbc2d строку
# user: "${UID}:${GID}"
# и запускайте как UID=$(id -u) GID=$(id -g) docker compose -f … up -d
# (каталог ./out при этом должен быть создан заранее и принадлежать вам).
@@ -1,114 +0,0 @@
# Запуск решателя и кампании в WSL2 — там, где драйвера NVIDIA для Linux не существует.
#
# ЧЕМ ЭТОТ ФАЙЛ ОТЛИЧАЕТСЯ ОТ docker-compose.server.yml. Тот рассчитан на настоящий
# Linux-сервер: просит у Docker устройство через драйвер `nvidia`, а Vulkan-ICD внутрь
# подкладывает NVIDIA Container Toolkit. В WSL2 этого сделать нельзя в принципе —
# у NVIDIA там нет Linux-библиотеки Vulkan, карта отдаётся по протоколу WDDM через
# /dev/dxg. Отсюда и ошибка `could not select device driver "nvidia"`.
#
# Здесь NVIDIA-runtime не запрашивается ВООБЩЕ. Нужно ровно две вещи:
# * проброс /dev/dxg — сама видеокарта;
# * монтирование /usr/lib/wsl — там Microsoft держит libd3d12.so и libdxcore.so.
# Дальше работает dzn (Dozen) из образа: драйвер Mesa, транслирующий Vulkan в D3D12.
# Ни nvidia-container-toolkit, ни nvidia-ctk, ни правки в daemon.json не требуются.
#
# ВАЖНО, ПРОЧТИТЕ ДО ЗАПУСКА КАМПАНИИ. dzn сообщает о себе conformanceVersion 0.0.0.0,
# то есть набор тестов соответствия Vulkan не проходил. Поэтому порядок такой:
#
# docker compose -f docker-compose.wsl.yml --profile check run --rm vulkan # карта видна?
# docker compose -f docker-compose.wsl.yml --profile check run --rm parity # числа те же?
# docker compose -f docker-compose.wsl.yml --profile check run --rm calibrate # скорость какая?
# docker compose -f docker-compose.wsl.yml --profile check run --rm plan
# docker compose -f docker-compose.wsl.yml up -d
# docker compose -f docker-compose.wsl.yml logs -f
#
# Шаг parity — не формальность. Он гоняет вихрь Тейлора-Грина (есть точное решение) и
# стационарное обтекание цилиндра на CPU в f64 и на GPU, и сверяет числа. Если он не
# прошёл, кампанию запускать нельзя: результаты будет нечем защищать.
#
# Результаты складываются в ./out на хосте.
name: kbc2d
# ── общая часть всех сервисов ────────────────────────────────────────────────
x-kbc2d: &kbc2d
image: notbigghost/kbc2d:1.2.0
# Контекст сборки — каталог с этим файлом. Если образа нет ни локально, ни в реестре,
# достаточно `docker compose -f docker-compose.wsl.yml build`: доступ к Docker Hub
# для запуска не обязателен.
build:
context: .
args:
VERSION: "1.2.0"
pull_policy: missing
devices:
- /dev/dxg:/dev/dxg
volumes:
- ./out:/work/bench/out
# Только на чтение: контейнеру нужны отсюда libd3d12.so, libd3d12core.so и
# libdxcore.so. Каталог наполняет сам WSL из C:\Windows\System32\lxss\lib.
- /usr/lib/wsl:/usr/lib/wsl:ro
environment:
# Без этого dzn не найдёт D3D12 и молча не даст ни одного адаптера.
LD_LIBRARY_PATH: /usr/lib/wsl/lib
# Осознанное согласие считать на драйвере, не прошедшем тесты соответствия Vulkan.
# Без этой переменной wgpu прячет dzn и решатель сообщает, что адаптер не найден.
# Прежде чем запускать кампанию, обязательно пройдите профиль parity.
WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER: "1"
# Оценки в часах драйвер считает из этих чисел. Умолчания взяты с другой машины —
# подставьте сюда то, что напечатает профиль calibrate.
KBC2D_GPU_MLUPS: ${KBC2D_GPU_MLUPS:-1200}
KBC2D_CPU_MLUPS: ${KBC2D_CPU_MLUPS:-22}
services:
# ── сама кампания: единственный сервис, который поднимается по `up -d` ──────
campaign:
<<: *kbc2d
container_name: kbc2d-campaign
# --resume пропускает всё, у чего уже есть summary.json: перезапуск продолжает с
# места, а не начинает заново.
command: ["--resume"]
# on-failure, а НЕ unless-stopped: кампания завершается штатно с кодом 0, и политика
# «перезапускать всегда» после её окончания крутила бы контейнер вхолостую.
restart: on-failure:5
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
# ── проверки перед запуском (профиль check, сами не поднимаются) ────────────
vulkan:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["vulkaninfo"]
command: ["--summary"]
parity:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["python3"]
command: ["parity.py"]
preflight:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["python3"]
command: ["preflight.py"]
calibrate:
<<: *kbc2d
profiles: ["check"]
command: ["--calibrate"]
plan:
<<: *kbc2d
profiles: ["check"]
command: ["--dry-run"]
# ── если результаты нужны не под root ────────────────────────────────────────
# Контейнер работает от root, поэтому файлы в ./out окажутся root:root. Чтобы они
# принадлежали вам, добавьте в x-kbc2d строку
# user: "${UID}:${GID}"
# и запускайте как UID=$(id -u) GID=$(id -g) docker compose -f ... up -d
# (каталог ./out при этом должен быть создан заранее и принадлежать вам).
-27
View File
@@ -1,27 +0,0 @@
# Запуск кампании на сервере с NVIDIA одной командой:
# docker compose run --rm kbc2d --calibrate # сначала убедиться, что GPU виден
# docker compose up -d # кампания в фоне
# docker compose logs -f # смотреть ход
#
# Результаты складываются в ./out на хосте.
services:
kbc2d:
build: .
image: kbc2d
container_name: kbc2d
volumes:
- ./out:/work/bench/out
environment:
# Без graphics NVIDIA Container Toolkit не подложит Vulkan-ICD и wgpu не увидит карту.
NVIDIA_DRIVER_CAPABILITIES: compute,utility,graphics
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
# Кампания идёт десятки часов и переживает перезапуск: --resume пропускает готовое.
command: ["--resume"]
restart: "no"
File diff suppressed because it is too large Load Diff
-632
View File
@@ -1,632 +0,0 @@
//! БЛОК СОЗДАНИЯ ГИФОК: перевод поля в индексированный кадр, палитры, служебная надпись и —
//! главное — СИНХРОНИЗАЦИЯ АНИМАЦИИ С ФИЗИЧЕСКИМ ВРЕМЕНЕМ ПОТОКА.
//!
//! Требование: гифка идёт с той же скоростью, что и настоящий поток, независимо от того, с
//! какой скоростью считает машина. Реальная производительность (шагов в секунду wall-clock)
//! в тайминг не входит вообще — берётся только физическая длительность шага δt из `Units`:
//!
//! δt = u_lat·δx/u_phys с/шаг
//! кадр каждые S шагов ⇒ между кадрами S·δt секунд физического времени
//! задержка кадра = S·δt / playback с (playback = 1 ⇒ реальное время)
//!
//! Ограничение формата: задержка в GIF хранится в СОТЫХ долях секунды (u16), т.е. сетка 10 мс,
//! и почти все декодеры считают задержку 0–1 сс как «как можно быстрее». Поэтому реальное время
//! достижимо не при любом S: при δt = 1.7e-4 с (30 м/с, ячейка 0.1 м, u_lat = 0.05) кадр каждые
//! 10 шагов — это 600 кадр/с, чего GIF не умеет. Отсюда два режима:
//!
//! * `--gif-every N` — шаг задан жёстко; если реальное время не представимо, задержка
//! зажимается и в отчёт печатается фактический коэффициент замедления;
//! * `--gif-every auto` — шаг подбирается так, чтобы реальное время получилось точно при
//! целевой частоте `--gif-fps` (по умолчанию 30). Это режим по
//! умолчанию: синхронность важнее, чем конкретное число шагов на кадр.
use std::fs::File;
use std::io::BufWriter;
use crate::math::{Units, R};
// ─────────────────────────────────────────────────────────────────────────────
// Тайминг
// ─────────────────────────────────────────────────────────────────────────────
/// Как кадры анимации ложатся на физическое время.
#[derive(Clone, Copy, Debug)]
pub struct GifPlan {
/// Кадр каждые столько шагов симуляции.
pub stride: u64,
/// ТОЧНАЯ задержка кадра в сотых долях секунды — вообще говоря дробная.
pub exact_delay_cs: R,
/// Физическое время между соседними кадрами, с.
pub frame_dt: R,
/// Запрошенная скорость воспроизведения (1 = реальное время, 0.1 = 10× замедление).
pub requested_playback: R,
/// Фактическая, в среднем по анимации.
pub actual_playback: R,
/// Средняя частота кадров получившейся гифки.
pub fps: R,
/// Шаг подбирался автоматически.
pub auto: bool,
/// Точная задержка нецелая ⇒ задержки кадров чередуются (см. `DelayDither`).
pub dithered: bool,
/// Точная задержка меньше одной сотой ⇒ формат физически не тянет, время сжато.
pub clamped: bool,
}
impl GifPlan {
/// `stride`: Some(N) — жёстко N шагов на кадр; None — подобрать под `target_fps`.
pub fn new(units: &Units, stride: Option<u64>, target_fps: R, playback: R) -> GifPlan {
let dt = units.dt;
let playback = if playback > 0.0 { playback } else { 1.0 };
let auto = stride.is_none();
let stride = match stride {
Some(s) => s.max(1),
None => {
// хотим кадр раз в 1/target_fps секунды экранного времени, что при скорости
// playback отвечает playback/target_fps секундам физического времени
((playback / target_fps / dt).round() as i64).max(1) as u64
}
};
let frame_dt = stride as R * dt;
let exact_delay_cs = 100.0 * frame_dt / playback;
// 0 сотых большинство декодеров трактует как «как можно быстрее», поэтому пол — единица
let clamped = exact_delay_cs < 1.0;
let mean_delay_cs = if clamped { 1.0 } else { exact_delay_cs };
GifPlan {
stride,
exact_delay_cs,
frame_dt,
requested_playback: playback,
actual_playback: frame_dt / (mean_delay_cs / 100.0),
fps: 100.0 / mean_delay_cs,
auto,
dithered: !clamped && (exact_delay_cs - exact_delay_cs.round()).abs() > 1e-9,
clamped,
}
}
/// Отношение фактической скорости воспроизведения к запрошенной (1.0 = точно).
pub fn sync_error(&self) -> R {
self.actual_playback / self.requested_playback
}
/// Синхронность выдержана: средняя задержка совпадает с точной.
pub fn is_synced(&self) -> bool {
(self.sync_error() - 1.0).abs() < 0.02
}
/// Совет, как починить рассинхрон, если формат его не тянет.
pub fn advice(&self, units: &Units) -> Option<String> {
// разойтись с запрошенной скоростью можно только одним способом: точная задержка
// не влезла в минимальную единицу формата и была зажата
if !self.clamped {
return None;
}
let good = ((0.01 * self.requested_playback / units.dt).ceil() as i64).max(1);
Some(format!(
"кадр требует задержки {:.4} сотых — меньше минимальной единицы формата, поэтому \
анимация идёт в {:.2}× быстрее запрошенного. Починить: --gif-every auto (подберёт \
шаг сам), либо --gif-every {} (минимальный шаг с представимой задержкой), либо \
--gif-speed {:.4} (замедление вместо реального времени)",
self.exact_delay_cs,
self.sync_error(),
good,
self.requested_playback / self.sync_error()
))
}
}
/// Раскладка дробной задержки по целым сотым без накопления ошибки.
///
/// Формат GIF хранит задержку кадра целым числом сотых долей секунды. Точная физическая
/// задержка почти никогда не целая: 30 кадр/с — это 3⅓ сотых. Округление каждого кадра
/// по отдельности даёт систематический уход (3 вместо 3.333 — анимация на 11% быстрее
/// реальности, и расхождение копится линейно). Поэтому задержки выдаются так, чтобы
/// НАКОПЛЕННОЕ время кадров всегда совпадало с накопленным физическим с точностью до одной
/// сотой: для 3.333 получается 3, 3, 4, 3, 3, 4, … Ошибка ограничена ±5 мс и не растёт.
#[derive(Clone, Copy, Debug, Default)]
pub struct DelayDither {
exact_cum: R,
emitted_cum: i64,
}
impl DelayDither {
/// Задержка очередного кадра в сотых долях секунды.
pub fn next(&mut self, exact_delay_cs: R) -> u16 {
self.exact_cum += exact_delay_cs;
let want = self.exact_cum.round() as i64;
let d = (want - self.emitted_cum).max(1);
self.emitted_cum += d;
d.clamp(1, u16::MAX as i64) as u16
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Палитры
// ─────────────────────────────────────────────────────────────────────────────
/// Индексы служебных цветов в палитре (после градиента).
const NCOLORS: usize = 250;
const IDX_BODY: u8 = 250;
const IDX_PATCH: u8 = 251;
const IDX_TEXT: u8 = 252;
const IDX_TEXT_BG: u8 = 253;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColorMap {
/// Последовательная, воспринимаемо-равномерная — для |u| и ρ.
Viridis,
/// Яркая последовательная с широким динамическим диапазоном.
Turbo,
/// Расходящаяся сине-бело-красная — для знакопеременных полей (завихренность, γ−2).
CoolWarm,
}
impl ColorMap {
pub fn from_str(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"viridis" => ColorMap::Viridis,
"turbo" => ColorMap::Turbo,
"coolwarm" | "rdbu" => ColorMap::CoolWarm,
_ => return None,
})
}
pub const ALL: [&'static str; 3] = ["viridis", "turbo", "coolwarm"];
/// Опорные точки градиента; между ними линейная интерполяция в sRGB.
fn anchors(&self) -> &'static [[u8; 3]] {
match self {
ColorMap::Viridis => &[
[68, 1, 84],
[72, 40, 120],
[62, 74, 137],
[49, 104, 142],
[38, 130, 142],
[31, 158, 137],
[53, 183, 121],
[109, 205, 89],
[180, 222, 44],
[253, 231, 37],
],
ColorMap::Turbo => &[
[48, 18, 59],
[70, 107, 227],
[40, 187, 236],
[49, 242, 153],
[138, 252, 61],
[210, 226, 27],
[254, 165, 45],
[239, 89, 17],
[180, 27, 1],
[122, 4, 3],
],
// Расходящаяся палитра Морланда. Опорных точек НЕЧЁТНОЕ число: только тогда
// середина диапазона (для знакопеременного поля — ноль) попадает ровно на
// нейтральный серый, а не между двумя соседними точками.
ColorMap::CoolWarm => &[
[59, 76, 192],
[98, 130, 234],
[141, 176, 254],
[184, 208, 249],
[221, 221, 221],
[245, 196, 173],
[244, 154, 123],
[222, 96, 77],
[180, 4, 38],
],
}
}
/// Палитра GIF: NCOLORS ступеней градиента плюс служебные цвета.
fn palette(&self) -> Vec<u8> {
let a = self.anchors();
let mut p = vec![0u8; 256 * 3];
for k in 0..NCOLORS {
let t = k as R / (NCOLORS - 1) as R * (a.len() - 1) as R;
let i = (t.floor() as usize).min(a.len() - 2);
let fr = t - i as R;
for c in 0..3 {
p[k * 3 + c] = (a[i][c] as R * (1.0 - fr) + a[i + 1][c] as R * fr).round() as u8;
}
}
let set = |p: &mut Vec<u8>, idx: u8, rgb: [u8; 3]| {
let o = idx as usize * 3;
p[o] = rgb[0];
p[o + 1] = rgb[1];
p[o + 2] = rgb[2];
};
set(&mut p, IDX_BODY, [25, 25, 28]);
set(&mut p, IDX_PATCH, [255, 255, 255]);
set(&mut p, IDX_TEXT, [255, 255, 255]);
set(&mut p, IDX_TEXT_BG, [0, 0, 0]);
p
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Микрошрифт 5×7 для служебной надписи
// ─────────────────────────────────────────────────────────────────────────────
/// Битовая маска глифа: 7 строк по 5 бит (старший бит — левый пиксель).
fn glyph(c: char) -> [u8; 7] {
match c.to_ascii_uppercase() {
'0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
'1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
'2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
'3' => [0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E],
'4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
'5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
'6' => [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E],
'7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
'8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
'9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C],
'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
'B' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
'C' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
'D' => [0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C],
'E' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
'F' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
'G' => [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0F],
'H' => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
'I' => [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
'M' => [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11],
'N' => [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11],
'O' => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
'S' => [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E],
'T' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
'V' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04],
'W' => [0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11],
'X' => [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11],
'Y' => [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04],
'Z' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
'.' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C],
',' => [0x00, 0x00, 0x00, 0x00, 0x0C, 0x04, 0x08],
':' => [0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x0C, 0x00],
'-' => [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
'+' => [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00],
'=' => [0x00, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00],
'/' => [0x01, 0x02, 0x02, 0x04, 0x08, 0x08, 0x10],
'%' => [0x19, 0x1A, 0x02, 0x04, 0x08, 0x0B, 0x13],
'(' => [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02],
')' => [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08],
'Ч' => [0x11, 0x11, 0x11, 0x0F, 0x01, 0x01, 0x01],
_ => [0; 7], // пробел и всё незнакомое
}
}
/// Нарисовать строку в индексированный буфер (координаты — левый верхний угол).
fn draw_text(buf: &mut [u8], w: usize, h: usize, x0: usize, y0: usize, s: &str, scale: usize) {
let gw = 6 * scale; // 5 пикселей глифа + 1 пробел
// подложка, чтобы текст читался на любом фоне
let tw = s.chars().count() * gw;
for yy in y0.saturating_sub(scale)..(y0 + 7 * scale + scale).min(h) {
for xx in x0.saturating_sub(scale)..(x0 + tw + scale).min(w) {
buf[yy * w + xx] = IDX_TEXT_BG;
}
}
for (k, ch) in s.chars().enumerate() {
let g = glyph(ch);
for (row, bits) in g.iter().enumerate() {
for col in 0..5 {
if bits & (1 << (4 - col)) == 0 {
continue;
}
for sy in 0..scale {
for sx in 0..scale {
let px = x0 + k * gw + col * scale + sx;
let py = y0 + row * scale + sy;
if px < w && py < h {
buf[py * w + px] = IDX_TEXT;
}
}
}
}
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Диапазон нормировки
// ─────────────────────────────────────────────────────────────────────────────
/// Диапазон значений, отображаемый на палитру. Фиксируется на весь прогон: плавающая
/// автонормировка делает анимацию нечитаемой — цвет перестаёт что-либо значить.
#[derive(Clone, Copy, Debug)]
pub struct Range {
pub lo: R,
pub hi: R,
}
impl Range {
#[inline]
fn index(&self, v: R) -> u8 {
if !v.is_finite() {
return 0;
}
let t = ((v - self.lo) / (self.hi - self.lo)).clamp(0.0, 1.0);
(t * (NCOLORS - 1) as R).round() as u8
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Запись гифки
// ─────────────────────────────────────────────────────────────────────────────
/// Что подписывать на кадре.
pub struct Hud {
pub u_phys: R,
pub field: &'static str,
pub show: bool,
}
pub struct GifWriter {
enc: gif::Encoder<BufWriter<File>>,
pub plan: GifPlan,
/// Размеры ИСХОДНОЙ сетки.
src_nx: usize,
src_ny: usize,
/// Во сколько раз клетки усредняются в пиксель перед отрисовкой.
down: usize,
/// Размеры картинки после прореживания.
nx: usize,
ny: usize,
scale: usize,
w: usize,
h: usize,
range: Range,
hud: Hud,
patch: Option<(usize, usize, usize, usize)>,
dither: DelayDither,
pub frames: u32,
}
impl GifWriter {
#[allow(clippy::too_many_arguments)]
pub fn create(
path: &str,
nx: usize,
ny: usize,
scale: usize,
down: usize,
cmap: ColorMap,
range: Range,
plan: GifPlan,
hud: Hud,
patch: Option<(usize, usize, usize, usize)>,
) -> std::io::Result<GifWriter> {
let scale = scale.max(1);
let down = down.max(1);
let (src_nx, src_ny) = (nx, ny);
// прореживание с округлением вверх: последний блок может быть неполным
let nx = nx.div_ceil(down);
let ny = ny.div_ceil(down);
let w = nx * scale;
let h = ny * scale;
let file = BufWriter::new(File::create(path)?);
let mut enc = gif::Encoder::new(file, w as u16, h as u16, &cmap.palette())
.map_err(std::io::Error::other)?;
enc.set_repeat(gif::Repeat::Infinite).map_err(std::io::Error::other)?;
Ok(GifWriter {
enc,
plan,
src_nx,
src_ny,
down,
nx,
ny,
scale,
w,
h,
range,
hud,
patch,
dither: DelayDither::default(),
frames: 0,
})
}
/// Добавить кадр. `field` — значения по узлам L0, `solid` — маска тела,
/// `t_phys` — физическое время этого кадра (с), `cd` — текущий коэффициент сопротивления.
pub fn push(
&mut self,
field: &[R],
solid: &[bool],
t_phys: R,
cd: Option<R>,
) -> std::io::Result<()> {
// Прореживание: блок down×down усредняется в один пиксель, а телом пиксель считается,
// если тело занимает хотя бы половину блока. Без этого кадр с сетки 4096×2048 весит
// столько, что гифка становится непригодной.
let (field, solid) = if self.down == 1 {
(field.to_vec(), solid.to_vec())
} else {
let d = self.down;
let mut fv = vec![0.0; self.nx * self.ny];
let mut sv = vec![false; self.nx * self.ny];
for gy in 0..self.ny {
for gx in 0..self.nx {
let (mut acc, mut cnt, mut sol) = (0.0, 0usize, 0usize);
for yy in gy * d..((gy + 1) * d).min(self.src_ny) {
for xx in gx * d..((gx + 1) * d).min(self.src_nx) {
let k = yy * self.src_nx + xx;
acc += field[k];
sol += solid[k] as usize;
cnt += 1;
}
}
let g = gy * self.nx + gx;
fv[g] = if cnt > 0 { acc / cnt as R } else { 0.0 };
sv[g] = cnt > 0 && 2 * sol >= cnt;
}
}
(fv, sv)
};
let (field, solid) = (&field[..], &solid[..]);
let mut buf = vec![0u8; self.w * self.h];
// строка 0 изображения — это ВЕРХ, а y = 0 решётки — низ канала: переворачиваем
for y in 0..self.ny {
let src = (self.ny - 1 - y) * self.nx;
for x in 0..self.nx {
let idx = if solid[src + x] { IDX_BODY } else { self.range.index(field[src + x]) };
for sy in 0..self.scale {
let row = (y * self.scale + sy) * self.w + x * self.scale;
for sx in 0..self.scale {
buf[row + sx] = idx;
}
}
}
}
if let Some((ax, bx, ay, by)) = self.patch {
let d = self.down;
self.draw_patch_outline(&mut buf, ax / d, bx / d, ay / d, by / d);
}
if self.hud.show {
let line = match cd {
Some(c) => format!(
"T={:.4}S U={:.1}M/S CD={:.2}",
t_phys, self.hud.u_phys, c
),
None => format!("T={:.4}S U={:.1}M/S", t_phys, self.hud.u_phys),
};
let sc = (self.scale.min(3)).max(1);
draw_text(&mut buf, self.w, self.h, 2 * sc, 2 * sc, &line, sc);
draw_text(&mut buf, self.w, self.h, 2 * sc, self.h - 9 * sc, self.hud.field, sc);
}
let mut frame = gif::Frame::from_indexed_pixels(self.w as u16, self.h as u16, buf, None);
// задержка берётся из накопителя: сумма задержек кадров отслеживает точное
// физическое время, а не округляется покадрово (см. DelayDither)
frame.delay = self.dither.next(self.plan.exact_delay_cs.max(1.0));
self.enc.write_frame(&frame).map_err(std::io::Error::other)?;
self.frames += 1;
Ok(())
}
/// Контур области измельчения — видно, где считает тонкая сетка.
fn draw_patch_outline(&self, buf: &mut [u8], ax: usize, bx: usize, ay: usize, by: usize) {
let flip = |y: usize| self.ny - 1 - y.min(self.ny - 1);
let mut put = |x: usize, y: usize| {
if x < self.nx && y < self.ny {
let py = flip(y) * self.scale;
let px = x * self.scale;
for sy in 0..self.scale {
for sx in 0..self.scale {
let o = (py + sy) * self.w + px + sx;
if o < buf.len() {
buf[o] = IDX_PATCH;
}
}
}
}
};
for x in ax..=bx.min(self.nx - 1) {
put(x, ay);
put(x, by);
}
for y in ay..=by.min(self.ny - 1) {
put(ax, y);
put(bx, y);
}
}
pub fn finish(self) -> std::io::Result<u32> {
Ok(self.frames)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Пример из постановки: 30 м/с, ячейка 0.1 м, u_lat = 1 ⇒ 300 шагов/с; кадр каждые
/// 10 шагов ⇒ 30 кадр/с. Точная задержка 3⅓ сотых — нецелая, поэтому включается дизеринг,
/// и в среднем гифка идёт ровно в реальном времени.
#[test]
fn spec_example_is_realtime() {
let u = Units::new(0.1, 30.0, 1.0, 150.0, 16.0);
assert!((u.steps_per_second() - 300.0).abs() < 1e-9);
let p = GifPlan::new(&u, Some(10), 30.0, 1.0);
assert!((p.exact_delay_cs - 10.0 / 3.0).abs() < 1e-9, "{}", p.exact_delay_cs);
assert!(p.dithered && !p.clamped);
assert!((p.fps - 30.0).abs() < 1e-9);
assert!(p.is_synced(), "коэффициент {}", p.sync_error());
}
/// Дизеринг обязан вести НАКОПЛЕННОЕ время кадров вплотную к физическому, без ухода.
/// Покадровое округление 3.333 → 3 дало бы к тысячному кадру уход в 3.3 секунды.
#[test]
fn dither_tracks_exact_time_without_drift() {
let exact = 10.0 / 3.0;
let mut d = DelayDither::default();
let mut sum = 0i64;
for k in 1..=1000 {
sum += d.next(exact) as i64;
let want = exact * k as R;
assert!(
(sum as R - want).abs() <= 0.5 + 1e-9,
"кадр {k}: накоплено {sum} сотых против точных {want:.3}"
);
}
// а наивное округление к этому моменту отстало бы на треть сотой на кадр
assert!((sum as R - exact * 1000.0).abs() < 1.0);
assert!((3000 - sum).abs() > 300, "дизеринг обязан отличаться от покадрового округления");
}
/// Тайминг обязан зависеть ТОЛЬКО от физики, не от того, как быстро считает машина:
/// один и тот же δt даёт одну и ту же задержку, вдвое больший шаг — вдвое большую.
#[test]
fn timing_ignores_wall_clock() {
let u = Units::new(0.1, 30.0, 1.0, 150.0, 16.0);
let a = GifPlan::new(&u, Some(10), 30.0, 1.0);
let b = GifPlan::new(&u, Some(10), 30.0, 1.0);
assert!((a.exact_delay_cs - b.exact_delay_cs).abs() < 1e-12);
let c = GifPlan::new(&u, Some(20), 30.0, 1.0);
assert!((c.exact_delay_cs - 2.0 * a.exact_delay_cs).abs() < 1e-12);
assert!(c.is_synced() && a.is_synced());
}
/// Авто-режим обязан вытянуть реальное время при физически корректном u_lat, где жёсткий
/// шаг «каждые 10» потребовал бы 600 кадр/с и формат бы этого не вытянул.
#[test]
fn auto_stride_restores_realtime() {
let u = Units::new(0.1, 30.0, 0.05, 150.0, 16.0);
assert!((u.steps_per_second() - 6000.0).abs() < 1e-6);
let fixed = GifPlan::new(&u, Some(10), 30.0, 1.0);
assert!(fixed.clamped, "0.167 сотых обязаны упереться в пол формата");
assert!(!fixed.is_synced(), "жёсткий шаг не должен был попасть в реальное время");
assert!(fixed.advice(&u).is_some(), "в таком режиме обязан быть совет");
let auto = GifPlan::new(&u, None, 30.0, 1.0);
assert_eq!(auto.stride, 200); // 1/30 с при 6000 шаг/с
assert!(auto.is_synced(), "коэффициент {}", auto.sync_error());
assert!((auto.fps - 30.0).abs() < 1e-9);
}
/// Замедление: playback = 0.1 растягивает то же физическое время ровно в 10 раз.
#[test]
fn slow_motion_scales_delay() {
let u = Units::new(0.1, 30.0, 1.0, 150.0, 16.0);
let fast = GifPlan::new(&u, Some(10), 30.0, 1.0);
let slow = GifPlan::new(&u, Some(10), 30.0, 0.1);
assert!((slow.exact_delay_cs - 10.0 * fast.exact_delay_cs).abs() < 1e-9);
assert!((slow.actual_playback - 0.1).abs() < 1e-9);
assert!(slow.is_synced());
}
/// Палитра обязана быть ровно 256 цветов по 3 байта и не затирать служебные индексы.
#[test]
fn palette_layout() {
for cm in [ColorMap::Viridis, ColorMap::Turbo, ColorMap::CoolWarm] {
let p = cm.palette();
assert_eq!(p.len(), 768);
let o = IDX_BODY as usize * 3;
assert_eq!(&p[o..o + 3], &[25, 25, 28]);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -31,8 +31,10 @@
## Где какая математика (для ревизии)
- **Столкновение** — `collision.py`. KBC-N1: `f ← f − β(2Δs + γΔh)`, где `Δs = Ps·(f−feq)` —
проекция на сдвиг-моменты, `γ` — энтропийный лимитер. Чтобы поэкспериментировать:
переключить `cfg.collision="bgk"`, либо добавить TRT новой функцией и зарегистрировать в `get`.
проекция на сдвиг-моменты, `γ` — энтропийный лимитер. Оператор жёстко зафиксирован:
модуль объявляет `collide = kbc_collide`, поля `cfg.collision` и реестра операторов
не существует (см. раздел «Статус» ниже). Чтобы поэкспериментировать с BGK или TRT,
придётся вводить и то и другое — это осознанно не сделано.
- **Перенос** — `streaming.py`. Чистая пул-схема; на физику влияет только корректность сдвигов.
- **Силы** — `forces.py`. Обмен импульсом по линкам тела:
`F = Σ_links c_i (f_i^{после столкн.} + f_ī^{после стриминга})`. Второй член — из поля ПОСЛЕ
+2475
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
# Порт редактора SimVulcan на Rust. Пять крейтов повторяют карту целей CMake из
# корня репозитория (simv_core / simv_vk / simv_mesh / simv_editor + исполняемый файл),
# и это не косметика: главный инвариант проекта — «весь Vulkan живёт в одном месте» —
# здесь проверяется компилятором. У simv-mesh и simv-editor просто нет ash в
# зависимостях, так что нарушить границу нельзя даже случайно.
[workspace]
resolver = "2"
members = ["crates/*"]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.82"
license = "MIT"
# Версии закреплены здесь, крейты ссылаются через `workspace = true`.
[workspace.dependencies]
simv-core = { path = "crates/simv-core" }
simv-mesh = { path = "crates/simv-mesh" }
simv-vk = { path = "crates/simv-vk" }
simv-editor = { path = "crates/simv-editor" }
ash = "0.38"
ash-window = "0.13"
raw-window-handle = "0.6"
winit = "0.30"
gpu-allocator = { version = "0.28", default-features = false, features = ["vulkan"] }
egui = "0.36"
egui-winit = { version = "0.36", default-features = false, features = ["wayland", "x11"] }
egui-ash-renderer = { version = "0.13", features = ["gpu-allocator"] }
glam = { version = "0.33", features = ["bytemuck"] }
tobj = "4"
log = "0.4"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
thiserror = "2"
anyhow = "1"
bytemuck = { version = "1", features = ["derive"] }
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
+113
View File
@@ -0,0 +1,113 @@
# SimVulcan на Rust
Порт редактора `SimVulcan` с C++20 на Rust. Делает то же самое: орбитальная камера над
тремя опорными плоскостями сетки, цветные оси XYZ, загрузка `.obj` и три режима
отображения модели. Тот же Vulkan 1.3 с динамическим рендерингом и synchronization2,
те же шейдеры.
Существует ради сравнения: обе версии лежат в репозитории рядом и собираются
независимо. Разбор различий с цифрами — в [`../docs/rust_vs_cpp.md`](../docs/rust_vs_cpp.md).
## Требования
* Rust 1.95+ (проверялось на 1.97.1). В `Cargo.toml` объявлено `rust-version = "1.82"`,
но это значение недостижимо: залоченные `egui`, `egui-winit` и `epaint` 0.36.1 сами
требуют 1.95, и на 1.82 Cargo обрывается на разрешении зависимостей
* Vulkan SDK 1.3.290+ — нужен только `glslangValidator` для сборки шейдеров
(ищется в `%VULKAN_SDK%\Bin`, затем в `PATH`)
* Драйвер Vulkan 1.3 с `fillModeNonSolid`
## Сборка и запуск
```sh
cd rust
cargo build --release
./target/release/SimVulcan
```
Запускать можно из любого каталога. Шейдеры вшиты в исполняемый файл на этапе сборки,
а `assets/meshes` ищется подъёмом по предкам от самого бинаря и только затем — от
текущего каталога; в отличие от C++-версии, которой нужен запуск из её собственного
каталога. В отличие от неё же, `assets/` никуда не копируется: порт находит копию в
корне репозитория, поднимаясь от `target/<профиль>/`.
Рядом с исполняемым файлом создаётся `pipeline_cache.bin` — сериализованный
`VkPipelineCache`, он перечитывается при следующем запуске. Файл одноразовый: если
конвейеры повели себя странно, его можно удалить.
Слой валидации Khronos запрашивается во всех конфигурациях, включая `--release`, — как
и в C++-версии. Если слоя нет, приложение об этом предупредит и пойдёт дальше.
**Для работы над кодом собирайте `cargo build` без `--release`.** В профиле выпуска стоит
`lto = "thin"`, и правка одной строки заставляет заново оптимизировать и слинковать весь
бинарь: 52 секунды против 5 в отладочной сборке. Замеры — в
[`../docs/rust_vs_cpp.md`](../docs/rust_vs_cpp.md).
## Тесты
```sh
cargo test
```
Видеокарта не нужна: габариты меша, сварка вершин (три случая), чтение `Cube.obj` и его
путь ошибки, пространство отсечения камеры и ограничители наклона и приближения,
разбор категорий журнала — тринадцать тестов плюс один помеченный `#[ignore]`
диагностический, тот самый, которым получена таблица расхождения загрузчиков. Чистой
математикой это, впрочем, не является: тесты загрузчика читают настоящие файлы из
`assets/meshes` через `$CARGO_MANIFEST_DIR`, поэтому им нужен рабочий клон репозитория.
Тесты живут прямо в модулях (`#[cfg(test)] mod tests`), отдельной цели под них нет.
## Раскладка
Пять крейтов повторяют карту целей CMake из корня репозитория:
Ниже перечислены сторонние зависимости; вдобавок каждый крейт зависит от тех крейтов
рабочего пространства, что указаны в скобках.
```
crates/simv-core/ журнал с категориями, окно → log, chrono, winit
crates/simv-mesh/ чтение .obj, сварка, габариты → glam, tobj, log, thiserror
(+ simv-core)
crates/simv-vk/ весь Vulkan + build.rs → ash, ash-window, raw-window-handle,
(GLSL → SPIR-V) gpu-allocator, egui, egui-winit,
egui-ash-renderer, winit, glam,
bytemuck, log, thiserror
(+ simv-core, simv-mesh)
crates/simv-editor/ камера, ввод, панели → egui, glam, log
(+ simv-core, simv-mesh)
crates/simv-app/ App и точка входа → четыре крейта выше
+ egui, winit, glam, log, anyhow
```
Главный инвариант проекта — «весь Vulkan живёт в одном месте» — здесь проверяет
компилятор: у `simv-mesh` и `simv-editor` в зависимостях нет ни `ash`, ни `simv-vk`,
так что нарушить границу нельзя даже по невнимательности.
Шейдеры общие с C++-версией: `build.rs` крейта `simv-vk` компилирует
`../../../shaders/editor/*.{vert,frag}` тем же `glslangValidator -V --target-env vulkan1.3`
и кладёт SPIR-V в `OUT_DIR`, откуда он попадает в бинарь через `include_bytes!`.
## Чем отличается от C++-версии
Снаружи — почти ничем, кроме внешнего вида панелей: вместо Dear ImGui взят **egui**.
Причина: крейт `imgui` — это биндинги, и сборка всё равно тянула бы за собой исходники
Dear ImGui на C++, что обесценило бы сравнение.
Внутри отличия сведены в отдельный документ, здесь только самые заметные:
* **`App` живёт в исполняемом крейте**, а не в `simv-core`. В C++ цикличны исходники
(`core::App` владеет `vk::Renderer`, `vk::Renderer` принимает `core::Window&`), тогда
как граф целей CMake ацикличен — потому CMake и молчит: `simv_vk` не линкует
`simv_core`, а лишь видит его заголовки, и символы `Window` находятся при сборке
исполняемого файла. Cargo такую схему отвергает.
* **Состояние сцены возвращается из замыкания интерфейса**, а не ставится сеттерами.
Замыкание вызывается из метода рендерера, поэтому трогать рендерер оттуда нельзя.
* **Загруженный меш выгружается на видеокарту после кадра**, а не изнутри него, как в
C++, где колбэк панели дёргает `vkDeviceWaitIdle` уже после захвата образа цепочки
показа — хотя запись команд там ещё не началась, `vkBeginCommandBuffer` идёт позже.
* **`Buffer` и `Image` задействованы.** В C++ обе обёртки собираются в цель `simv_vk`, но
ими не пользуется никто.
* **Нет pImpl.** Приватные поля модуля дают ту же изоляцию, ради которой в C++ заведён
`struct Renderer::Impl`.
* **Цикл событий принадлежит winit.** `ShouldClose`/`PollEvents` заменены методами
`ApplicationHandler`.
+25
View File
@@ -0,0 +1,25 @@
# Исполняемый файл. Здесь же живёт App — в C++-версии он лежит в simv_core, но там
# граф целей цикличен (core владеет vk, vk принимает core::Window), а Cargo цикл
# отвергает. Подробности — в docs/rust_vs_cpp.md.
[package]
name = "simv-app"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Редактор SimVulcan: сборка окна, рендерера и панелей"
[[bin]]
name = "SimVulcan"
path = "src/main.rs"
[dependencies]
simv-core.workspace = true
simv-mesh.workspace = true
simv-vk.workspace = true
simv-editor.workspace = true
egui.workspace = true
winit.workspace = true
glam.workspace = true
log.workspace = true
anyhow.workspace = true
+205
View File
@@ -0,0 +1,205 @@
//! Владелец времени жизни: окно, рендерер, камера, панели — перенос `simv::core::App`
//! вместе с обвязкой из `main.cpp`.
//!
//! Живёт в исполняемом крейте, а не в `simv-core`, где ему соответствовал бы прообраз.
//! Причина в графе зависимостей: в C++ `simv_core` содержит `App`, который владеет
//! `vk::Renderer`, а `simv_vk` принимает `core::Window&` — то есть цели ссылаются друг
//! на друга по кругу. Сходится это лишь потому, что `simv_vk` не линкует `simv_core`, а
//! видит его заголовки через `target_include_directories`, и всё склеивается на
//! компоновке исполняемого файла. Cargo цикл между крейтами отвергает, так что `App`
//! пришлось поднять на уровень выше обоих.
//!
//! Второе отличие — сам цикл. У GLFW его пишет приложение
//! (`while (!ShouldClose()) { PollEvents(); DrawFrame(); }`), у winit цикл владеет
//! собой и зовёт приложение через `ApplicationHandler`. Кадр по-прежнему рисуется
//! непрерывно: `about_to_wait` каждый раз просит перерисовку.
use std::path::PathBuf;
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow};
use winit::window::WindowId;
use simv_core::{Window, WindowDesc};
use simv_editor::{apply_mouse_input, Camera, DisplayMode, EditorUi, MeshLoadPanel};
use simv_mesh::weld_vertices;
use simv_vk::{FrameState, RenderMode, Renderer};
/// Допуск сварки вершин после загрузки — как в C++-версии.
const WELD_TOLERANCE: f32 = 1e-4;
#[derive(Default)]
pub struct App {
window: Option<Window>,
renderer: Option<Renderer>,
camera: Camera,
editor_ui: EditorUi,
mesh_panel: MeshLoadPanel,
/// Ошибка, из-за которой цикл был прерван. Возвращается из `main` кодом выхода.
failure: Option<anyhow::Error>,
}
impl App {
/// Забрать причину аварийного завершения, если она была.
pub fn into_result(self) -> anyhow::Result<()> {
match self.failure {
Some(e) => Err(e),
None => Ok(()),
}
}
fn fail(&mut self, event_loop: &ActiveEventLoop, error: anyhow::Error) {
log::error!(target: "core", "{error:#}");
self.failure = Some(error);
event_loop.exit();
}
/// Отдать событие интерфейсу. `true` — egui забрал его себе.
fn feed_ui(&mut self, event: &WindowEvent) -> bool {
let Some(window) = self.window.as_ref() else {
return false;
};
let Some(renderer) = self.renderer.as_mut() else {
return false;
};
renderer.on_window_event(window, event)
}
fn draw(&mut self) -> anyhow::Result<()> {
let Some(window) = self.window.as_ref() else {
return Ok(());
};
let Some(renderer) = self.renderer.as_mut() else {
return Ok(());
};
// Отношение сторон внутри замыкания не спросить — рендерер там уже одолжен,
// но за кадр оно и не меняется.
let aspect = renderer.aspect_ratio();
let camera = &mut self.camera;
let editor_ui = &mut self.editor_ui;
let mesh_panel = &mut self.mesh_panel;
renderer.draw_frame(window, |ctx| {
editor_ui.draw(ctx, camera);
mesh_panel.draw(ctx);
apply_mouse_input(ctx, camera);
FrameState {
view_proj: camera.view_proj(aspect),
render_mode: to_render_mode(editor_ui.display_mode()),
grid_visible: editor_ui.grid_visible(),
}
})?;
// Готовый меш забирается ПОСЛЕ кадра. В C++ то же самое делалось прямо из
// колбэка панели, то есть из середины `DrawFrame`, уже после захвата образа
// цепочки показа: `SetMeshCpu` там дёргает `vkDeviceWaitIdle` внутри записи
// кадра. Здесь так нельзя (рендерер одолжен), и получилось честнее.
if let Some(mut mesh) = mesh_panel.take_loaded() {
weld_vertices(&mut mesh, WELD_TOLERANCE);
log::info!(
target: "mesh",
"меш готов: {} вершин, {} треугольников",
mesh.vertex_count(),
mesh.triangle_count()
);
renderer.set_mesh_cpu(&mesh)?;
// Подогнать камеру под размер модели: она перенесена в начало координат.
let radius = 0.5 * (mesh.bbox_max - mesh.bbox_min).length();
if radius > 0.0 {
camera.set_distance(radius * 2.5);
}
}
Ok(())
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return; // возврат из фона — окно уже есть
}
event_loop.set_control_flow(ControlFlow::Poll);
let window = match Window::new(event_loop, &WindowDesc::default()) {
Ok(w) => w,
Err(e) => return self.fail(event_loop, anyhow::anyhow!("не создать окно: {e}")),
};
let renderer = match Renderer::new(&window) {
Ok(r) => r,
Err(e) => {
return self.fail(event_loop, anyhow::anyhow!("не запустить рендерер: {e}"))
}
};
let mesh_dir = find_mesh_directory();
log::info!(target: "core", "каталог моделей: {}", mesh_dir.display());
self.mesh_panel.set_mesh_directory(mesh_dir);
self.window = Some(window);
self.renderer = Some(renderer);
log::info!(target: "core", "SimVulcan запущен. Закройте окно, чтобы выйти.");
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
self.feed_ui(&event);
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::RedrawRequested => {
if let Err(e) = self.draw() {
self.fail(event_loop, e);
}
}
_ => {}
}
}
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
if let Some(window) = &self.window {
window.request_redraw();
}
}
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
if let Some(renderer) = &self.renderer {
renderer.wait_idle();
}
}
}
/// Панель редактора не знает про `simv_vk` (крейт ей недоступен), поэтому разбор
/// делается здесь. Добавите вариант в [`DisplayMode`] — компилятор укажет на этот
/// `match`; в C++ на его месте `static_cast<vk::RenderMode>(int)`, который смолчит.
fn to_render_mode(mode: DisplayMode) -> RenderMode {
match mode {
DisplayMode::Solid => RenderMode::Solid,
DisplayMode::Wireframe => RenderMode::Wireframe,
DisplayMode::SolidWire => RenderMode::SolidWire,
}
}
/// Найти `assets/meshes`, поднимаясь от исполняемого файла, а затем от текущего
/// каталога.
///
/// C++-версия перебирает пять относительных путей вида `../../../../../assets/meshes`
/// от текущего каталога — то есть работает, только если запускать из каталога бинаря.
/// Здесь отсчёт идёт в первую очередь от самого бинаря, так что запуск из любого места
/// находит модели. Шейдеров этот поиск не касается: они вшиты в исполняемый файл.
fn find_mesh_directory() -> PathBuf {
let mut roots: Vec<PathBuf> = Vec::new();
if let Ok(exe) = std::env::current_exe() {
roots.extend(exe.ancestors().map(PathBuf::from));
}
if let Ok(cwd) = std::env::current_dir() {
roots.extend(cwd.ancestors().map(PathBuf::from));
}
for root in roots {
let candidate = root.join("assets").join("meshes");
if candidate.is_dir() {
return candidate;
}
}
PathBuf::from("assets/meshes")
}
+21
View File
@@ -0,0 +1,21 @@
//! Точка входа редактора SimVulcan — перенос `src/app/main.cpp`.
//!
//! В C++ здесь же собирается вся обвязка редактора: поиск каталога моделей, обработчик
//! загрузки, UI-колбэк. Всё это переехало в [`app::App`], потому что при модели
//! событий winit его методы и есть тело цикла.
mod app;
use winit::event_loop::EventLoop;
fn main() -> anyhow::Result<()> {
simv_core::logger::init();
let event_loop = EventLoop::new()?;
let mut app = app::App::default();
event_loop.run_app(&mut app)?;
// Ошибка, из-за которой цикл прервался, возвращается сюда: в отличие от
// C++-версии её не приходится ловить `catch (...)` — она пришла значением.
app.into_result()
}
+13
View File
@@ -0,0 +1,13 @@
# Аналог цели simv_core: журнал и окно. Vulkan сюда не входит и входить не должен.
[package]
name = "simv-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Журнал с категориями и окно — общая основа редактора"
[dependencies]
log.workspace = true
chrono.workspace = true
winit.workspace = true
+13
View File
@@ -0,0 +1,13 @@
//! Общая основа редактора: журнал и окно.
//!
//! Соответствует цели `simv_core` из CMake, но без `App`: в C++ граф целей цикличен
//! (`core::App` владеет `vk::Renderer`, а `vk::Renderer` принимает `core::Window&`),
//! и держится это лишь на том, что `simv_vk` видит заголовки `core` через
//! `target_include_directories`, не линкуя саму цель. Cargo цикл между крейтами
//! отвергает, поэтому `App` переехал в `simv-app`. Разбор — в `docs/rust_vs_cpp.md`.
pub mod logger;
pub mod window;
pub use logger::LogCategory;
pub use window::{Window, WindowDesc};
+230
View File
@@ -0,0 +1,230 @@
//! Журнал с категориями, уровнями и троттлингом — перенос `simv::core::Logger`.
//!
//! Категории C++-версии легли на target'ы крейта `log`, поэтому писать в журнал
//! можно обычными макросами:
//!
//! ```ignore
//! log::info!(target: "vk", "контекст готов: {}", name);
//! ```
//!
//! Выигрыш против C++-версии, где `LogFmt` знал только про свои вызовы: под тот же
//! фильтр и троттлинг автоматически попадают сообщения зависимостей, которые тоже
//! пишут через `log`. Незнакомый target считается категорией `Core` — так опечатка
//! в имени не выбрасывает сообщение мимо всех настроек.
use std::io::Write;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering};
use std::sync::{LazyLock, OnceLock};
use std::time::Instant;
use log::{Level, LevelFilter, Log, Metadata, Record};
/// Категории журнала. Порядок и имена — как в C++-версии.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum LogCategory {
Core,
Vulkan,
MeshIo,
Ui,
Test,
}
impl LogCategory {
pub const ALL: [LogCategory; 5] = [
LogCategory::Core,
LogCategory::Vulkan,
LogCategory::MeshIo,
LogCategory::Ui,
LogCategory::Test,
];
/// Строка, которую передают в `target:` у макросов `log`.
pub const fn target(self) -> &'static str {
match self {
LogCategory::Core => "core",
LogCategory::Vulkan => "vk",
LogCategory::MeshIo => "mesh",
LogCategory::Ui => "ui",
LogCategory::Test => "test",
}
}
const fn index(self) -> usize {
match self {
LogCategory::Core => 0,
LogCategory::Vulkan => 1,
LogCategory::MeshIo => 2,
LogCategory::Ui => 3,
LogCategory::Test => 4,
}
}
/// Разбор target'а. Крейты-зависимости пишут в свои target'ы («wgpu_hal», «winit»
/// и подобные) — они попадают в `Core` и подчиняются её настройкам.
fn from_target(target: &str) -> LogCategory {
// Зависимости пишут в target'ы вида «egui_winit::state», берём корень.
let root = target.split("::").next().unwrap_or(target);
match root {
"vk" => LogCategory::Vulkan,
"mesh" => LogCategory::MeshIo,
"ui" => LogCategory::Ui,
"test" => LogCategory::Test,
_ => LogCategory::Core,
}
}
}
/// Настройки одной категории. Меняются в любой момент из любого потока — ровно то,
/// что в C++-версии делали `SetMinLevel` / `SetThrottle` / `SetEnabled`.
struct CategoryState {
enabled: AtomicBool,
/// `LevelFilter as u8`: Off=0, Error=1, … Trace=5.
min_level: AtomicU8,
throttle_ms: AtomicI64,
/// `i64::MIN` — в этой категории ещё ничего не печатали.
last_log_ms: AtomicI64,
}
impl CategoryState {
const fn new() -> Self {
Self {
enabled: AtomicBool::new(true),
min_level: AtomicU8::new(LevelFilter::Info as u8),
// C++-версия ставит по умолчанию 0.5 с на категорию, но её низкоуровневый
// код Vulkan пишет прямо через spdlog, минуя категории, поэтому стартовые
// сообщения там всё равно печатаются все. Здесь через категории идёт ВСЁ,
// и тот же порог съедал бы половину стартового вывода: имя видеокарты и
// размер кэша конвейеров приходят в пределах одной сотой секунды.
// Поведение приложения важнее буквы: по умолчанию не троттлим,
// `set_throttle` включает порог там, где сообщения идут покадрово.
throttle_ms: AtomicI64::new(0),
last_log_ms: AtomicI64::new(i64::MIN),
}
}
}
static CATEGORIES: [CategoryState; 5] = [
CategoryState::new(),
CategoryState::new(),
CategoryState::new(),
CategoryState::new(),
CategoryState::new(),
];
static EPOCH: LazyLock<Instant> = LazyLock::new(Instant::now);
static LOGGER: SimvLogger = SimvLogger;
static INIT: OnceLock<()> = OnceLock::new();
fn now_ms() -> i64 {
EPOCH.elapsed().as_millis() as i64
}
struct SimvLogger;
impl Log for SimvLogger {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
// Дешёвая проверка до форматирования аргументов. Троттлинг сюда не попадает
// намеренно: у него есть побочный эффект, а `enabled` вызывается и просто так.
let state = &CATEGORIES[LogCategory::from_target(metadata.target()).index()];
state.enabled.load(Ordering::Relaxed)
&& (metadata.level() as u8) <= state.min_level.load(Ordering::Relaxed)
}
fn log(&self, record: &Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let category = LogCategory::from_target(record.target());
let state = &CATEGORIES[category.index()];
// Предупреждения и ошибки троттлинг обходят: пропустить их — хуже, чем
// залить ими вывод.
let important = record.level() <= Level::Warn;
if !important {
let throttle = state.throttle_ms.load(Ordering::Relaxed);
if throttle > 0 {
let now = now_ms();
let last = state.last_log_ms.load(Ordering::Relaxed);
if last != i64::MIN && (now - last) < throttle {
return;
}
state.last_log_ms.store(now, Ordering::Relaxed);
}
}
let (name, color) = match record.level() {
Level::Error => ("error", "\x1b[31;1m"),
Level::Warn => ("warning", "\x1b[33;1m"),
Level::Info => ("info", "\x1b[32m"),
Level::Debug => ("debug", "\x1b[36m"),
Level::Trace => ("trace", "\x1b[90m"),
};
let stamp = chrono::Local::now().format("%H:%M:%S%.3f");
let mut out = std::io::stdout().lock();
let _ = writeln!(
out,
"[{stamp}] [{color}{name}\x1b[0m] [{}] {}",
category.target(),
record.args()
);
}
fn flush(&self) {
let _ = std::io::stdout().flush();
}
}
/// Подключить журнал. Повторные вызовы безвредны — выигрывает первый.
pub fn init() {
INIT.get_or_init(|| {
LazyLock::force(&EPOCH);
// Глобальный порог держим открытым: решение принимает фильтр по категориям.
log::set_max_level(LevelFilter::Trace);
// Ошибка означает, что журнал уже кто-то поставил — ничего страшного.
let _ = log::set_logger(&LOGGER);
});
}
/// Порог уровня для категории.
pub fn set_min_level(category: LogCategory, level: LevelFilter) {
CATEGORIES[category.index()]
.min_level
.store(level as u8, Ordering::Relaxed);
}
/// Минимальный интервал между сообщениями категории, в секундах. `0` — без троттлинга.
pub fn set_throttle(category: LogCategory, seconds: f64) {
CATEGORIES[category.index()]
.throttle_ms
.store((seconds * 1000.0) as i64, Ordering::Relaxed);
}
/// Полностью выключить или включить категорию.
pub fn set_enabled(category: LogCategory, enabled: bool) {
CATEGORIES[category.index()]
.enabled
.store(enabled, Ordering::Relaxed);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_targets_map_to_their_categories() {
assert_eq!(LogCategory::from_target("vk"), LogCategory::Vulkan);
assert_eq!(LogCategory::from_target("mesh"), LogCategory::MeshIo);
// Чужие target'ы зависимостей не теряются, а попадают в Core.
assert_eq!(LogCategory::from_target("winit::platform"), LogCategory::Core);
}
#[test]
fn every_category_has_a_distinct_index() {
let mut seen = [false; 5];
for category in LogCategory::ALL {
assert!(!seen[category.index()], "индекс {} занят дважды", category.index());
seen[category.index()] = true;
}
}
}
+67
View File
@@ -0,0 +1,67 @@
//! Окно — перенос `simv::core::Window`.
//!
//! Отличие от C++-версии не в обёртке, а в модели событий. У GLFW цикл пишет
//! приложение: `while (!ShouldClose()) { PollEvents(); DrawFrame(); }`. У winit цикл
//! владеет собой сам и зовёт приложение через `ApplicationHandler`, поэтому
//! `ShouldClose` и `PollEvents` здесь просто нечему соответствовать — они переехали
//! в `simv_app::App` в виде обработчиков событий.
//!
//! Заодно исчез `RequiredInstanceExtensions`: в C++ список расширений спрашивали у
//! GLFW, в Rust его даёт `ash_window::enumerate_required_extensions` по дескриптору
//! дисплея, то есть он оказался на стороне графики, а не окна.
use winit::dpi::LogicalSize;
use winit::error::OsError;
use winit::event_loop::ActiveEventLoop;
use winit::window::{Window as WinitWindow, WindowAttributes};
/// Параметры создаваемого окна.
pub struct WindowDesc {
pub title: String,
pub width: u32,
pub height: u32,
pub resizable: bool,
}
impl Default for WindowDesc {
fn default() -> Self {
Self {
title: "SimVulcan".to_owned(),
width: 1280,
height: 720,
resizable: true,
}
}
}
/// Окно приложения.
pub struct Window {
handle: WinitWindow,
}
impl Window {
pub fn new(event_loop: &ActiveEventLoop, desc: &WindowDesc) -> Result<Self, OsError> {
let attributes = WindowAttributes::default()
.with_title(desc.title.clone())
.with_inner_size(LogicalSize::new(desc.width, desc.height))
.with_resizable(desc.resizable);
Ok(Self {
handle: event_loop.create_window(attributes)?,
})
}
pub fn raw(&self) -> &WinitWindow {
&self.handle
}
/// Размер кадрового буфера в пикселях. Ноль по любой оси означает свёрнутое окно —
/// рисовать в этот кадр нельзя.
pub fn framebuffer_size(&self) -> (u32, u32) {
let size = self.handle.inner_size();
(size.width, size.height)
}
pub fn request_redraw(&self) {
self.handle.request_redraw();
}
}
+15
View File
@@ -0,0 +1,15 @@
# Аналог цели simv_editor: камера, ввод и панели. Vulkan не видит.
[package]
name = "simv-editor"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Орбитальная камера и панели редактора на egui"
[dependencies]
simv-core.workspace = true
simv-mesh.workspace = true
egui.workspace = true
glam.workspace = true
log.workspace = true
+152
View File
@@ -0,0 +1,152 @@
//! Орбитальная камера — перенос `simv::editor::Camera`.
//!
//! Смотрит всегда в начало координат; yaw/pitch/расстояние ставят глаз на сферу
//! вокруг него. Загруженные модели переносятся в центр матрицей модели, так что
//! отдельной точки интереса у камеры нет.
//!
//! Проекция — в пространстве отсечения Vulkan. В C++ это стоит двух отдельных мер:
//! `#define GLM_FORCE_DEPTH_ZERO_TO_ONE` перед включением заголовка (и надежда, что
//! никакой другой файл не включит glm раньше без него) плюс ручной переворот
//! `p[1][1] *= -1` с комментарием, зачем он. У glam соглашение вынесено в путь
//! функции: `camera::rh::proj::vulkan::perspective` уже даёт и глубину [0, 1], и
//! Y вниз, так что переворачивать вручную нечего.
use glam::{Mat4, Vec3};
const PITCH_LIMIT: f32 = 1.553_34; // ~89°, чтобы не поймать вырождение look_at
const MIN_DISTANCE: f32 = 0.5;
const MAX_DISTANCE: f32 = 200.0;
/// Плоскость, к которой можно развернуть вид кнопкой.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ViewPlane {
/// Спереди: взгляд вдоль −Z, плоскость XY.
Front,
/// Сверху: взгляд вдоль −Y, плоскость XZ.
Top,
/// Сбоку: взгляд вдоль −X, плоскость YZ.
Side,
}
pub struct Camera {
yaw: f32,
pitch: f32,
distance: f32,
}
impl Default for Camera {
fn default() -> Self {
Self {
yaw: 0.6,
pitch: 0.5,
distance: 8.0,
}
}
}
impl Camera {
/// Повернуть вокруг цели, в радианах.
pub fn orbit(&mut self, d_yaw: f32, d_pitch: f32) {
self.yaw += d_yaw;
self.pitch = (self.pitch + d_pitch).clamp(-PITCH_LIMIT, PITCH_LIMIT);
}
/// Приблизить или отдалить. Положительное значение (колесо вверх) приближает.
pub fn zoom(&mut self, delta: f32) {
self.distance = (self.distance * (-delta * 0.15).exp()).clamp(MIN_DISTANCE, MAX_DISTANCE);
}
pub fn set_distance(&mut self, distance: f32) {
self.distance = distance.clamp(MIN_DISTANCE, MAX_DISTANCE);
}
pub fn distance(&self) -> f32 {
self.distance
}
/// Развернуть вид точно на одну из главных плоскостей.
pub fn align_to_plane(&mut self, plane: ViewPlane) {
match plane {
ViewPlane::Front => {
self.yaw = 0.0;
self.pitch = 0.0;
}
ViewPlane::Top => {
self.yaw = 0.0;
self.pitch = PITCH_LIMIT;
}
ViewPlane::Side => {
self.yaw = std::f32::consts::FRAC_PI_2;
self.pitch = 0.0;
}
}
}
pub fn view(&self) -> Mat4 {
let target = Vec3::ZERO;
let eye = target
+ self.distance
* Vec3::new(
self.pitch.cos() * self.yaw.sin(),
self.pitch.sin(),
self.pitch.cos() * self.yaw.cos(),
);
glam::camera::rh::view::look_at_mat4(eye, target, Vec3::Y)
}
/// Проекция в пространстве отсечения Vulkan: глубина [0, 1] и Y вниз.
pub fn proj(&self, aspect: f32) -> Mat4 {
glam::camera::rh::proj::vulkan::perspective(45.0_f32.to_radians(), aspect, 0.05, 500.0)
}
pub fn view_proj(&self, aspect: f32) -> Mat4 {
self.proj(aspect) * self.view()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pitch_is_clamped_at_the_poles() {
let mut camera = Camera::default();
camera.orbit(0.0, 100.0);
assert!(camera.view().is_finite(), "матрица вида выродилась на полюсе");
camera.orbit(0.0, -200.0);
assert!(camera.view().is_finite());
}
#[test]
fn zoom_stays_within_limits() {
let mut camera = Camera::default();
for _ in 0..200 {
camera.zoom(1.0);
}
assert!(camera.distance() >= MIN_DISTANCE);
for _ in 0..200 {
camera.zoom(-1.0);
}
assert!(camera.distance() <= MAX_DISTANCE);
}
/// Ближняя плоскость обязана лечь в z = 0, дальняя — в z = 1: это и есть
/// пространство отсечения Vulkan, а не OpenGL с диапазоном [−1, 1].
#[test]
fn projection_uses_vulkan_depth_range() {
let camera = Camera::default();
let p = camera.proj(16.0 / 9.0);
let near = p * glam::Vec4::new(0.0, 0.0, -0.05, 1.0);
let far = p * glam::Vec4::new(0.0, 0.0, -500.0, 1.0);
assert!((near.z / near.w - 0.0).abs() < 1e-4, "ближняя плоскость: {}", near.z / near.w);
assert!((far.z / far.w - 1.0).abs() < 1e-4, "дальняя плоскость: {}", far.z / far.w);
}
/// Переворот Y обязателен: без него картинка встанет вверх ногами.
#[test]
fn projection_flips_y_for_vulkan() {
let camera = Camera::default();
let p = camera.proj(1.0);
assert!(p.y_axis.y < 0.0);
}
}
@@ -0,0 +1,30 @@
//! Мышь для камеры — перенос `simv::editor::CameraInput`.
use crate::camera::Camera;
/// Радиан на пиксель перетаскивания — как в C++-версии.
const ORBIT_SPEED: f32 = 0.01;
/// Множитель колеса.
///
/// В ImGui `io.MouseWheel` считает «щелчки»: одна прокрутка — ±1, и C++-версия берёт
/// половину. egui отдаёт прокрутку в точках, и один щелчок — это примерно пятьдесят.
/// Отсюда сотая доля: ощущение от колеса остаётся тем же.
const ZOOM_SPEED: f32 = 0.01;
/// Применить ввод мыши к камере. Ввод, который забрал интерфейс, до сцены не доходит.
pub fn apply_mouse_input(ctx: &egui::Context, camera: &mut Camera) {
if ctx.egui_wants_pointer_input() {
return; // указатель над панелью
}
ctx.input(|input| {
if input.pointer.primary_down() {
let delta = input.pointer.delta();
camera.orbit(delta.x * ORBIT_SPEED, -delta.y * ORBIT_SPEED);
}
let wheel = input.smooth_scroll_delta.y;
if wheel != 0.0 {
camera.zoom(wheel * ZOOM_SPEED);
}
});
}
+13
View File
@@ -0,0 +1,13 @@
//! Камера, ввод и панели редактора — соответствует цели `simv_editor`.
//!
//! Vulkan отсюда не виден: у крейта нет ни `ash`, ни `simv-vk` в зависимостях.
pub mod camera;
pub mod camera_input;
pub mod mesh_load_panel;
pub mod ui;
pub use camera::{Camera, ViewPlane};
pub use camera_input::apply_mouse_input;
pub use mesh_load_panel::MeshLoadPanel;
pub use ui::{DisplayMode, EditorUi};
@@ -0,0 +1,156 @@
//! Панель загрузки моделей — перенос `simv::editor::MeshLoadPanel`.
//!
//! Расхождение с C++-версией — в том, как результат попадает наружу. Там панель
//! держит `std::function<void(Mesh)>` и зовёт его прямо из `Draw`, то есть из
//! середины `Renderer::DrawFrame`, уже после `vkAcquireNextImageKHR`. Обработчик в
//! `main.cpp` из этого колбэка дёргает `renderer.SetMeshCpu`, а тот — `vkDeviceWaitIdle`
//! и выгрузку на видеокарту. Ждать простоя устройства посреди записи кадра законно,
//! но сомнительно.
//!
//! В Rust так просто не выйдет: замыкание, трогающее рендерер, вызывается из метода
//! этого же рендерера, то есть рендерер одолжен дважды. Панель вместо колбэка
//! складывает готовый меш в поле, а приложение забирает его через
//! [`MeshLoadPanel::take_loaded`] после того, как кадр закончен. Ограничение языка
//! здесь заодно вынесло выгрузку из середины кадра.
use std::path::{Path, PathBuf};
use simv_mesh::{load_obj_async, Mesh, PendingLoad};
pub struct MeshLoadPanel {
mesh_dir: PathBuf,
obj_files: Vec<String>,
selected: Option<usize>,
status: String,
pending: Option<PendingLoad>,
loaded: Option<Mesh>,
open: bool,
}
impl Default for MeshLoadPanel {
fn default() -> Self {
let mut panel = Self {
mesh_dir: PathBuf::from("assets/meshes"),
obj_files: Vec::new(),
selected: None,
status: String::new(),
pending: None,
loaded: None,
open: true,
};
panel.rescan_files();
panel
}
}
impl MeshLoadPanel {
pub fn set_mesh_directory(&mut self, dir: PathBuf) {
self.mesh_dir = dir;
self.selected = None;
self.rescan_files();
}
/// Забрать загруженный меш, если он готов. Вызывать после кадра.
pub fn take_loaded(&mut self) -> Option<Mesh> {
self.loaded.take()
}
fn rescan_files(&mut self) {
self.obj_files.clear();
let Ok(entries) = std::fs::read_dir(&self.mesh_dir) else {
return;
};
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|t| t.is_file()) {
continue;
}
let path = entry.path();
// Расширение без учёта регистра: `Cube.obj`, `cube.OBJ`, `Plane.Obj`.
let is_obj = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("obj"));
if is_obj {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
self.obj_files.push(name.to_owned());
}
}
}
self.obj_files.sort();
}
pub fn draw(&mut self, ctx: &egui::Context) {
if !self.open {
return;
}
// Опрос незавершённой загрузки на главном потоке.
if let Some(pending) = &mut self.pending {
if let Some(result) = pending.try_take() {
self.pending = None;
match result {
Ok(mesh) => {
self.loaded = Some(mesh);
self.status = String::from("Загружено.");
}
Err(e) => self.status = format!("Ошибка: {e}"),
}
}
}
let mut open = self.open;
egui::Window::new("Модель")
.open(&mut open)
.default_size([360.0, 240.0])
.show(ctx, |ui| self.contents(ui));
self.open = open;
}
fn contents(&mut self, ui: &mut egui::Ui) {
ui.label(egui::RichText::new(format!("Каталог: {}", self.mesh_dir.display())).weak());
if ui.small_button("Обновить список").clicked() {
self.rescan_files();
}
ui.separator();
egui::ScrollArea::vertical()
.max_height(120.0)
.auto_shrink([false, false])
.show(ui, |ui| {
for (index, name) in self.obj_files.iter().enumerate() {
if ui
.selectable_label(self.selected == Some(index), name)
.clicked()
{
self.selected = Some(index);
}
}
});
let busy = self.pending.is_some();
let can_load = self.selected.is_some() && !busy;
ui.horizontal(|ui| {
if ui
.add_enabled(can_load, egui::Button::new("Загрузить"))
.clicked()
{
if let Some(index) = self.selected {
self.status = String::from("Загружаю…");
self.pending = Some(load_obj_async(self.mesh_dir.join(&self.obj_files[index])));
}
}
if busy {
ui.label(egui::RichText::new("(идёт загрузка…)").weak());
}
});
if !self.status.is_empty() {
ui.separator();
ui.label(&self.status);
}
}
pub fn mesh_directory(&self) -> &Path {
&self.mesh_dir
}
}
+80
View File
@@ -0,0 +1,80 @@
//! Панель вьюпорта — перенос `simv::editor::EditorUI`.
//!
//! Про [`DisplayMode`]. Панель обязана как-то сообщить рендереру выбранный режим, но
//! `simv_vk::RenderMode` ей недоступен: `simv-editor` не зависит от `simv-vk`, и это
//! проверяет компилятор. C++ обходит ту же границу, возвращая из `EditorUI` голый
//! `int`, который `main.cpp` приводит через `static_cast` к `vk::RenderMode` —
//! связь между двумя перечислениями там держится на честном слове. Здесь у панели
//! свой типизированный режим, а разбор в `simv_vk::RenderMode` делает `simv-app`
//! обычным `match`: добавите вариант — компилятор укажет, где его дописать.
use crate::camera::{Camera, ViewPlane};
/// Как отображать модель. Соответствует `simv_vk::RenderMode`, но живёт по эту
/// сторону границы модулей.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum DisplayMode {
#[default]
Solid,
Wireframe,
SolidWire,
}
pub struct EditorUi {
display_mode: DisplayMode,
grid_visible: bool,
}
impl Default for EditorUi {
fn default() -> Self {
Self {
display_mode: DisplayMode::Solid,
grid_visible: true,
}
}
}
impl EditorUi {
pub fn display_mode(&self) -> DisplayMode {
self.display_mode
}
pub fn grid_visible(&self) -> bool {
self.grid_visible
}
pub fn draw(&mut self, ctx: &egui::Context, camera: &mut Camera) {
egui::Window::new("Viewport")
.default_size([280.0, 200.0])
.show(ctx, |ui| {
ui.label(egui::RichText::new("Камера").weak());
ui.horizontal(|ui| {
if ui.button("Спереди").clicked() {
camera.align_to_plane(ViewPlane::Front);
}
if ui.button("Сверху").clicked() {
camera.align_to_plane(ViewPlane::Top);
}
if ui.button("Сбоку").clicked() {
camera.align_to_plane(ViewPlane::Side);
}
});
ui.label(
egui::RichText::new("Перетаскивание — поворот, колесо — приближение").weak(),
);
ui.separator();
ui.label(egui::RichText::new("Отображение модели").weak());
ui.radio_value(&mut self.display_mode, DisplayMode::Solid, "Заливка");
ui.radio_value(&mut self.display_mode, DisplayMode::Wireframe, "Каркас");
ui.radio_value(
&mut self.display_mode,
DisplayMode::SolidWire,
"Заливка с каркасом",
);
ui.separator();
ui.checkbox(&mut self.grid_visible, "Показывать сетку");
});
}
}
+16
View File
@@ -0,0 +1,16 @@
# Аналог цели simv_mesh: меш на стороне процессора. Ни ash, ни egui в зависимостях —
# граница «Vulkan только в simv-vk» держится не дисциплиной, а графом крейтов.
[package]
name = "simv-mesh"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Загрузка OBJ и сварка вершин, без графического API"
[dependencies]
simv-core.workspace = true
glam.workspace = true
tobj.workspace = true
log.workspace = true
thiserror.workspace = true
+169
View File
@@ -0,0 +1,169 @@
//! Сварка близких вершин — перенос `simv::mesh::WeldVertices`.
//!
//! Имя файла в C++-версии (`MeshDecimator`) обещает больше, чем есть: никакого LOD и
//! прореживания здесь нет, только схлопывание дубликатов по пространственному хешу и
//! удаление выродившихся треугольников. Название сохранено, чтобы модули двух версий
//! сопоставлялись один к одному.
use std::collections::HashMap;
use std::hash::{BuildHasherDefault, Hasher};
use glam::Vec3;
use crate::mesh::Mesh;
/// Ключ ячейки пространственного хеша.
type CellKey = [i32; 3];
/// Тот же FNV-1a, что в C++-версии.
///
/// На результат сварки хеш-функция не влияет — карта точная, ключи целочисленные, —
/// но брать стандартный SipHash значило бы сравнивать не алгоритмы, а хеши: SipHash
/// криптостойкий и заметно дороже. FNV перенесён ровно ради того, чтобы замер времени
/// сварки в отчёте мерил одно и то же в обеих версиях.
#[derive(Default)]
struct Fnv1a(u64);
impl Hasher for Fnv1a {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
// Ключ приходит четвёрками байт (три i32), мешаем по 32-битным словам —
// так же, как C++-версия мешает три int32.
let mut h = if self.0 == 0 { 0xcbf2_9ce4_8422_2325 } else { self.0 };
for word in bytes.chunks(4) {
let mut v = [0u8; 4];
v[..word.len()].copy_from_slice(word);
h ^= u32::from_ne_bytes(v) as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
self.0 = h;
}
}
type FnvMap = HashMap<CellKey, u32, BuildHasherDefault<Fnv1a>>;
/// Группировка округлением к ближайшему узлу решётки.
///
/// Две точки, отстоящие меньше чем на `tolerance`, гарантированно попадают в одну
/// ячейку, если обе лежат в пределах допуска от общего узла. Бакетирование через
/// `floor` на границах ячеек ломается, когда числа падают по разные стороны, —
/// округление и есть стандартное лекарство для сварки по пространственному хешу.
#[inline]
fn to_cell(p: Vec3, tolerance: f32) -> CellKey {
let t = tolerance as f64;
[
(p.x as f64 / t).round() as i32,
(p.y as f64 / t).round() as i32,
(p.z as f64 / t).round() as i32,
]
}
/// Схлопнуть вершины, отстоящие меньше чем на `tolerance`, переписать индексы и
/// выбросить треугольники, у которых после этого совпали хотя бы две вершины.
pub fn weld_vertices(mesh: &mut Mesh, tolerance: f32) {
if mesh.positions.is_empty() || tolerance <= 0.0 {
return;
}
let mut cell_to_vertex: FnvMap =
FnvMap::with_capacity_and_hasher(mesh.positions.len(), BuildHasherDefault::default());
let mut welded: Vec<Vec3> = Vec::with_capacity(mesh.positions.len());
let mut remap: Vec<u32> = Vec::with_capacity(mesh.positions.len());
for &p in &mesh.positions {
let cell = to_cell(p, tolerance);
let index = *cell_to_vertex.entry(cell).or_insert_with(|| {
welded.push(p);
(welded.len() - 1) as u32
});
remap.push(index);
}
let mut new_indices = Vec::with_capacity(mesh.indices.len());
for tri in mesh.indices.chunks_exact(3) {
let a = remap[tri[0] as usize];
let b = remap[tri[1] as usize];
let c = remap[tri[2] as usize];
if a == b || b == c || a == c {
continue;
}
new_indices.extend_from_slice(&[a, b, c]);
}
mesh.positions = welded;
mesh.indices = new_indices;
mesh.recalculate_bounds();
}
#[cfg(test)]
mod tests {
use super::*;
/// Перенос `TEST_CASE("WeldVertices collapses near-duplicate vertices")`.
#[test]
fn weld_collapses_near_duplicate_vertices() {
let mut m = Mesh {
// Два треугольника с общим ребром; вершины разведены в пределах допуска.
positions: vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0001, 0.0, 0.0), // ≈ #0
Vec3::new(1.0001, 0.0, 0.0), // ≈ #1
Vec3::new(1.0, 1.0, 0.0),
],
indices: vec![0, 1, 2, 3, 4, 5],
..Default::default()
};
weld_vertices(&mut m, 0.01);
assert_eq!(m.positions.len(), 4);
assert_eq!(m.indices.len(), 6, "оба треугольника обязаны выжить");
// Габариты должны совпасть с «настоящей» оболочкой независимо от порядка сварки.
assert!((m.bbox_max.x - 1.0001).abs() < 1e-3);
}
/// Перенос `TEST_CASE("WeldVertices drops degenerate triangles")`.
#[test]
fn weld_drops_degenerate_triangles() {
let mut m = Mesh {
// Один настоящий треугольник и один вырожденный: все три вершины в допуске.
positions: vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(2.0, 2.0, 0.0),
Vec3::new(2.00005, 2.0, 0.0),
Vec3::new(2.0, 2.00005, 0.0),
],
indices: vec![0, 1, 2, 3, 4, 5],
..Default::default()
};
weld_vertices(&mut m, 0.001);
assert_eq!(m.indices.len(), 3, "вырожденный треугольник обязан исчезнуть");
}
/// Округление, а не отсечение: точки по разные стороны границы ячейки, но в
/// пределах допуска друг от друга, всё равно должны слиться.
#[test]
fn weld_buckets_by_rounding_not_flooring() {
let mut m = Mesh {
positions: vec![
Vec3::new(0.00999, 0.0, 0.0),
Vec3::new(0.01001, 0.0, 0.0),
Vec3::new(5.0, 0.0, 0.0),
],
indices: vec![0, 1, 2],
..Default::default()
};
weld_vertices(&mut m, 0.01);
assert_eq!(m.positions.len(), 2);
assert!(m.indices.is_empty(), "треугольник выродился и должен быть удалён");
}
}
+13
View File
@@ -0,0 +1,13 @@
//! Меш на стороне процессора: чтение `.obj`, сварка вершин, габариты.
//!
//! Соответствует цели `simv_mesh`. Ни `ash`, ни `egui` в зависимостях крейта нет —
//! в C++ границу «Vulkan только в `src/vk/`» держит дисциплина, здесь её держит
//! граф зависимостей: нарушение просто не соберётся.
pub mod decimator;
pub mod mesh;
pub mod obj_loader;
pub use decimator::weld_vertices;
pub use mesh::Mesh;
pub use obj_loader::{load_obj, load_obj_async, ObjError, PendingLoad};
+77
View File
@@ -0,0 +1,77 @@
//! Меш на стороне процессора — перенос `simv::mesh::Mesh`.
use glam::Vec3;
/// Суп из треугольников: позиции и индексы по три на треугольник.
/// Загружается `obj_loader`, при желании прореживается `decimator`, затем уезжает в
/// `simv_vk::GpuMesh`. Никакого графического API здесь нет и быть не должно.
#[derive(Clone, Debug, Default)]
pub struct Mesh {
pub positions: Vec<Vec3>,
pub indices: Vec<u32>,
pub bbox_min: Vec3,
pub bbox_max: Vec3,
}
impl Mesh {
pub fn vertex_count(&self) -> usize {
self.positions.len()
}
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
/// Пуст в смысле «рисовать нечего»: одних вершин без индексов недостаточно.
pub fn is_empty(&self) -> bool {
self.positions.is_empty() || self.indices.is_empty()
}
/// Пересчитать габаритный ящик. У пустого меша он схлопывается в начало координат —
/// так же, как в C++-версии, чтобы камера не уезжала в бесконечность.
pub fn recalculate_bounds(&mut self) {
if self.positions.is_empty() {
self.bbox_min = Vec3::ZERO;
self.bbox_max = Vec3::ZERO;
return;
}
let mut lo = Vec3::splat(f32::MAX);
let mut hi = Vec3::splat(f32::MIN);
for p in &self.positions {
lo = lo.min(*p);
hi = hi.max(*p);
}
self.bbox_min = lo;
self.bbox_max = hi;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Перенос `TEST_CASE("Mesh::RecalculateBounds finds AABB")`.
#[test]
fn recalculate_bounds_finds_aabb() {
let mut m = Mesh {
positions: vec![
Vec3::new(-1.0, -2.0, -3.0),
Vec3::new(4.0, 5.0, 6.0),
Vec3::new(0.5, 0.0, 1.0),
],
..Default::default()
};
m.recalculate_bounds();
assert_eq!(m.bbox_min, Vec3::new(-1.0, -2.0, -3.0));
assert_eq!(m.bbox_max, Vec3::new(4.0, 5.0, 6.0));
}
#[test]
fn empty_mesh_has_degenerate_bounds() {
let mut m = Mesh::default();
m.recalculate_bounds();
assert_eq!(m.bbox_min, Vec3::ZERO);
assert_eq!(m.bbox_max, Vec3::ZERO);
assert!(m.is_empty());
}
}
+153
View File
@@ -0,0 +1,153 @@
//! Загрузка `.obj` — перенос `simv::mesh::ObjLoader`.
//!
//! Асинхронный путь устроен иначе, чем в C++. Там `LoadObjAsync` возвращал
//! `std::future<Mesh>`, и исключение из рабочего потока всплывало на главном при
//! вызове `get()`. Здесь поток посылает `Result` по каналу, а вызывающий разбирает
//! его обычным `match` — ошибка стала значением, и потерять её молча уже не выйдет.
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, TryRecvError};
use glam::Vec3;
use crate::mesh::Mesh;
#[derive(Debug, thiserror::Error)]
pub enum ObjError {
#[error("не удалось разобрать {path}: {source}")]
Parse {
path: String,
#[source]
source: tobj::LoadError,
},
/// Единственный способ, которым канал может закрыться без ответа, — паника в
/// рабочем потоке. Отдельный вариант нужен, чтобы панель показала это как ошибку,
/// а не висела в состоянии «грузится» вечно.
#[error("поток загрузки завершился аварийно")]
WorkerDied,
}
/// Синхронная загрузка: маленькие файлы, тесты.
///
/// Расхождение с C++-версией. tinyobjloader отдаёт единый глобальный массив вершин
/// (`attrib.vertices`), в который индексируют все формы, — C++-код так его и берёт.
/// tobj режет файл на модели (по объектам и по материалам) и каждой выдаёт свой
/// массив позиций, поэтому здесь модели склеиваются со сдвигом индексов. Вершины,
/// общие для соседних моделей, при этом задваиваются: до сварки счётчик вершин будет
/// больше, чем в C++-версии. `weld_vertices` их схлопывает, так что после сварки —
/// той самой, которую делает приложение сразу после загрузки, — счётчики совпадают.
pub fn load_obj(path: &Path) -> Result<Mesh, ObjError> {
let options = tobj::LoadOptions {
triangulate: true,
ignore_points: true,
ignore_lines: true,
..Default::default()
};
let (models, _materials) = tobj::load_obj(path, &options).map_err(|source| ObjError::Parse {
path: path.display().to_string(),
source,
})?;
let mut mesh = Mesh::default();
mesh.positions
.reserve(models.iter().map(|m| m.mesh.positions.len() / 3).sum());
mesh.indices
.reserve(models.iter().map(|m| m.mesh.indices.len()).sum());
for model in &models {
let base = mesh.positions.len() as u32;
for xyz in model.mesh.positions.chunks_exact(3) {
mesh.positions.push(Vec3::new(xyz[0], xyz[1], xyz[2]));
}
mesh.indices
.extend(model.mesh.indices.iter().map(|i| i + base));
}
mesh.recalculate_bounds();
log::info!(
target: "mesh",
"загружен OBJ '{}': {} вершин, {} треугольников",
path.file_name().unwrap_or(path.as_os_str()).to_string_lossy(),
mesh.vertex_count(),
mesh.triangle_count()
);
Ok(mesh)
}
/// Незавершённая загрузка. Опрашивается на главном потоке — так же, как C++-версия
/// опрашивала `future` через `wait_for(0ms)`, чтобы обратный вызов (а с ним и выгрузка
/// на видеокарту) выполнялся на потоке рендера.
pub struct PendingLoad {
receiver: Receiver<Result<Mesh, ObjError>>,
}
impl PendingLoad {
/// `None` — ещё грузится. `Some(..)` — результат готов; повторные вызовы после
/// этого снова вернут `Err(WorkerDied)`, поэтому забирать надо один раз.
pub fn try_take(&mut self) -> Option<Result<Mesh, ObjError>> {
match self.receiver.try_recv() {
Ok(result) => Some(result),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Err(ObjError::WorkerDied)),
}
}
}
/// Запустить загрузку в отдельном потоке.
pub fn load_obj_async(path: PathBuf) -> PendingLoad {
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
// Получатель мог уйти вместе с закрытой панелью — это не ошибка.
let _ = sender.send(load_obj(&path));
});
PendingLoad { receiver }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::decimator::weld_vertices;
fn asset(name: &str) -> PathBuf {
// crates/simv-mesh → корень репозитория.
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../assets/meshes")
.join(name)
}
#[test]
fn cube_loads_with_twelve_triangles() {
let path = asset("Cube.obj");
let mut mesh = load_obj(&path).expect("Cube.obj обязан читаться");
assert_eq!(mesh.triangle_count(), 12);
weld_vertices(&mut mesh, 1e-4);
// Куб после сварки — ровно восемь углов и те же двенадцать треугольников.
assert_eq!(mesh.vertex_count(), 8);
assert_eq!(mesh.triangle_count(), 12);
}
/// Диагностика для сверки с C++-версией: печатает счётчики до и после сварки.
/// Запуск: cargo test -p simv-mesh -- --ignored --nocapture weld_counts
#[test]
#[ignore = "диагностика, не проверка"]
fn weld_counts_for_bundled_assets() {
for name in ["Cube.obj", "Cube1.obj", "cow.obj", "plane.obj"] {
let mut mesh = load_obj(&asset(name)).expect(name);
let (v0, t0) = (mesh.vertex_count(), mesh.triangle_count());
weld_vertices(&mut mesh, 1e-4);
println!(
"{name}: до сварки {v0} вершин / {t0} треугольников, после {} / {}",
mesh.vertex_count(),
mesh.triangle_count()
);
}
}
#[test]
fn missing_file_reports_error_not_panic() {
let err = load_obj(Path::new("нет-такого-файла.obj")).unwrap_err();
assert!(matches!(err, ObjError::Parse { .. }));
}
}
+26
View File
@@ -0,0 +1,26 @@
# Аналог цели simv_vk: единственный крейт, которому позволено видеть ash.
# build.rs компилирует общие с C++-версией шейдеры из ../../shaders в SPIR-V.
[package]
name = "simv-vk"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Весь Vulkan: контекст, swapchain, конвейеры, кадр"
build = "build.rs"
[dependencies]
simv-core.workspace = true
simv-mesh.workspace = true
ash.workspace = true
ash-window.workspace = true
raw-window-handle.workspace = true
gpu-allocator.workspace = true
egui.workspace = true
egui-winit.workspace = true
egui-ash-renderer.workspace = true
winit.workspace = true
glam.workspace = true
bytemuck.workspace = true
log.workspace = true
thiserror.workspace = true
+95
View File
@@ -0,0 +1,95 @@
//! Компиляция GLSL в SPIR-V на этапе сборки — аналог `cmake/ShaderCompile.cmake`.
//!
//! Шейдеры берутся из общего с C++-версией каталога `shaders/` в корне репозитория:
//! обе сборки компилируют одни и те же файлы теми же ключами, поэтому расхождение в
//! картинке нельзя списать на шейдер.
//!
//! В отличие от CMake результат не копируется рядом с исполняемым файлом, а зашивается
//! в бинарь через `include_bytes!`. Отсюда исчезают и `FindSpvPath`, и POST_BUILD-копия,
//! и требование «запускать из каталога исполняемого файла».
use std::path::{Path, PathBuf};
use std::process::Command;
fn main() {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
// crates/simv-vk → rust → корень репозитория.
let shader_root = manifest.join("../../../shaders");
let shader_root = shader_root.canonicalize().unwrap_or(shader_root);
let editor_dir = shader_root.join("editor");
println!("cargo:rerun-if-env-changed=VULKAN_SDK");
println!("cargo:rerun-if-changed={}", editor_dir.display());
let compiler = find_glslang();
let mut sources: Vec<PathBuf> = std::fs::read_dir(&editor_dir)
.unwrap_or_else(|e| panic!("не читается каталог шейдеров {}: {e}", editor_dir.display()))
.filter_map(|entry| entry.ok().map(|e| e.path()))
.filter(|p| {
matches!(
p.extension().and_then(|e| e.to_str()),
Some("vert") | Some("frag")
)
})
.collect();
// Порядок обхода каталога зависит от файловой системы — сортируем, чтобы сборка
// была воспроизводимой.
sources.sort();
assert!(
!sources.is_empty(),
"в {} нет ни одного .vert/.frag",
editor_dir.display()
);
let out_editor = out_dir.join("editor");
std::fs::create_dir_all(&out_editor).unwrap();
for src in &sources {
println!("cargo:rerun-if-changed={}", src.display());
let name = src.file_name().unwrap().to_string_lossy().to_string();
let dst = out_editor.join(format!("{name}.spv"));
let output = Command::new(&compiler)
.arg("-V")
.args(["--target-env", "vulkan1.3"])
// glslangValidator требует -I<путь> без пробела.
.arg(format!("-I{}", shader_root.display()))
.arg("-o")
.arg(&dst)
.arg(src)
.output()
.unwrap_or_else(|e| panic!("не запускается {}: {e}", compiler.display()));
if !output.status.success() {
panic!(
"GLSL → SPIR-V не удалось для {}:\n{}{}",
src.display(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
}
}
/// Ищем компилятор там же, где его ищет CMake: сначала в Vulkan SDK, потом в PATH.
fn find_glslang() -> PathBuf {
let exe = if cfg!(windows) {
"glslangValidator.exe"
} else {
"glslangValidator"
};
if let Ok(sdk) = std::env::var("VULKAN_SDK") {
let candidate = Path::new(&sdk).join("Bin").join(exe);
if candidate.exists() {
return candidate;
}
let candidate = Path::new(&sdk).join("bin").join(exe);
if candidate.exists() {
return candidate;
}
}
PathBuf::from(exe)
}
+141
View File
@@ -0,0 +1,141 @@
//! Буфер с памятью из аллокатора — перенос `simv::vk::Buffer`.
//!
//! Расхождение с C++-версией. Там `vk::Buffer` есть, но им не пользуется никто:
//! `GridRenderer`, `GpuMesh` и глубинное вложение `Renderer` создают ресурсы напрямую
//! вызовами VMA, а сама обёртка так и лежит в цели `simv_vk` мёртвым грузом. Здесь она
//! задействована всеми тремя, потому что порт писался, когда потребители уже известны,
//! а с gpu-allocator обёртка — самый короткий путь: она же и держит `Allocation`,
//! который иначе пришлось бы освобождать руками в каждом `Drop`.
use std::sync::{Arc, Mutex};
use ash::vk;
use gpu_allocator::vulkan::{Allocation, AllocationCreateDesc, AllocationScheme, Allocator};
use crate::context::Context;
use crate::error::{Ctx, Result, VkError};
/// Где живёт память буфера.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum MemoryLocation {
/// Только для видеокарты: быстрая, недоступна процессору.
DeviceLocal,
/// Видима процессору, пишется последовательно (загрузка на видеокарту).
HostVisible,
/// Видима процессору, читается в произвольном порядке (чтение с видеокарты).
HostReadback,
}
impl From<MemoryLocation> for gpu_allocator::MemoryLocation {
fn from(value: MemoryLocation) -> Self {
match value {
MemoryLocation::DeviceLocal => gpu_allocator::MemoryLocation::GpuOnly,
MemoryLocation::HostVisible => gpu_allocator::MemoryLocation::CpuToGpu,
MemoryLocation::HostReadback => gpu_allocator::MemoryLocation::GpuToCpu,
}
}
}
pub struct Buffer {
device: ash::Device,
allocator: Arc<Mutex<Allocator>>,
handle: vk::Buffer,
/// `Option` ради `Drop`: память возвращается аллокатору по значению.
allocation: Option<Allocation>,
size: vk::DeviceSize,
}
impl Buffer {
pub fn new(
ctx: &Context,
name: &str,
size: vk::DeviceSize,
usage: vk::BufferUsageFlags,
location: MemoryLocation,
) -> Result<Self> {
let device = ctx.device().clone();
let ci = vk::BufferCreateInfo::default()
.size(size)
.usage(usage)
.sharing_mode(vk::SharingMode::EXCLUSIVE);
let handle = unsafe { device.create_buffer(&ci, None) }.ctx("vkCreateBuffer")?;
let requirements = unsafe { device.get_buffer_memory_requirements(handle) };
let allocator = ctx.allocator().clone();
let allocation = {
let mut guard = allocator.lock().expect("аллокатор отравлен паникой");
guard.allocate(&AllocationCreateDesc {
name,
requirements,
location: location.into(),
linear: true,
allocation_scheme: AllocationScheme::GpuAllocatorManaged,
})
};
let allocation = match allocation {
Ok(a) => a,
Err(e) => {
unsafe { device.destroy_buffer(handle, None) };
return Err(VkError::Other(format!("gpu-allocator: {e}")));
}
};
unsafe { device.bind_buffer_memory(handle, allocation.memory(), allocation.offset()) }
.ctx("vkBindBufferMemory")?;
Ok(Self {
device,
allocator,
handle,
allocation: Some(allocation),
size,
})
}
pub fn handle(&self) -> vk::Buffer {
self.handle
}
pub fn size(&self) -> vk::DeviceSize {
self.size
}
/// Постоянно отображённая память, если она вообще видима процессору.
///
/// В отличие от C++-версии здесь нет пары `Map`/`Unmap`: gpu-allocator держит
/// отображение всё время жизни выделения, а срез с временем жизни буфера не даёт
/// уронить указатель раньше самого буфера.
pub fn mapped(&mut self) -> Option<&mut [u8]> {
self.allocation.as_mut()?.mapped_slice_mut()
}
/// Записать байты по смещению. Возвращает ошибку, если память недоступна
/// процессору или запись не помещается.
pub fn write(&mut self, src: &[u8], offset: usize) -> Result<()> {
let size = self.size as usize;
let Some(dst) = self.mapped() else {
return Err(VkError::Other(String::from(
"запись в буфер, недоступный процессору",
)));
};
if offset + src.len() > size {
return Err(VkError::Other(format!(
"запись {} байт по смещению {offset} не помещается в буфер {size} байт",
src.len()
)));
}
dst[offset..offset + src.len()].copy_from_slice(src);
Ok(())
}
}
impl Drop for Buffer {
fn drop(&mut self) {
if let Some(allocation) = self.allocation.take() {
let _ = self
.allocator
.lock()
.map(|mut guard| guard.free(allocation));
}
unsafe { self.device.destroy_buffer(self.handle, None) };
}
}
+570
View File
@@ -0,0 +1,570 @@
//! Инстанс, устройство, очереди, аллокатор и кэш конвейеров — перенос
//! `simv::vk::Context`.
//!
//! Самый самостоятельный кусок порта. В C++ всё это строит vk-bootstrap: выбор
//! физического устройства по требуемым возможностям, поиск семейств очередей,
//! проверка наличия слоя валидации. Замены vk-bootstrap в экосистеме Rust нет, так что
//! ниже примерно двести пятьдесят строк, которых в C++-версии просто не существует.
use std::ffi::{c_char, c_void, CStr};
use std::sync::{Arc, Mutex};
use ash::vk;
use gpu_allocator::vulkan::{Allocator, AllocatorCreateDesc};
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use crate::error::{Ctx, Result, VkError};
/// Пожелания к контексту. Значения по умолчанию совпадают с C++-версией, включая
/// включённую валидацию во всех конфигурациях сборки.
pub struct ContextOptions {
pub enable_validation: bool,
pub enable_debug_utils: bool,
pub require_async_compute: bool,
pub require_async_transfer: bool,
}
impl Default for ContextOptions {
fn default() -> Self {
Self {
enable_validation: true,
enable_debug_utils: true,
require_async_compute: true,
require_async_transfer: true,
}
}
}
const VALIDATION_LAYER: &CStr = c"VK_LAYER_KHRONOS_validation";
/// Vulkan 1.3: инстанс, поверхность, физическое и логическое устройство, очереди,
/// аллокатор памяти и кэш конвейеров.
///
/// Владеет всем перечисленным и разрушает в обратном порядке в `Drop`. Всё, что
/// клонирует `ash::Device` (а это делают `GpuMesh`, `MeshRenderer`, `GridRenderer`),
/// обязано быть уничтожено раньше контекста: клон `Device` — это копия таблицы
/// функций, а не владеющая ссылка, и пережить `destroy_device` он не может.
pub struct Context {
device_name: String,
graphics_queue: vk::Queue,
compute_queue: vk::Queue,
transfer_queue: vk::Queue,
graphics_family: u32,
compute_family: u32,
transfer_family: u32,
pipeline_cache: vk::PipelineCache,
/// `Option` только ради `Drop`: аллокатор обязан умереть до устройства.
allocator: Option<Arc<Mutex<Allocator>>>,
swapchain_loader: ash::khr::swapchain::Device,
device: ash::Device,
physical_device: vk::PhysicalDevice,
surface: vk::SurfaceKHR,
surface_loader: ash::khr::surface::Instance,
debug: Option<(ash::ext::debug_utils::Instance, vk::DebugUtilsMessengerEXT)>,
instance: ash::Instance,
/// `Option` ради `Drop`: при динамической загрузке `Entry` держит саму
/// библиотеку Vulkan, поэтому уйти она обязана последней — иначе
/// `destroy_instance` вызовет указатель в уже выгруженной DLL.
entry: Option<ash::Entry>,
}
impl Context {
pub fn new(window: &simv_core::Window, opts: &ContextOptions) -> Result<Self> {
let entry = unsafe { ash::Entry::load() }?;
let display_handle = window.raw().display_handle()?.as_raw();
let window_handle = window.raw().window_handle()?.as_raw();
// ── Инстанс ──────────────────────────────────────────────────────────
let mut extensions: Vec<*const c_char> =
ash_window::enumerate_required_extensions(display_handle)
.ctx("ash_window::enumerate_required_extensions")?
.to_vec();
if opts.enable_debug_utils {
extensions.push(ash::ext::debug_utils::NAME.as_ptr());
}
// Проверка наличия слоя обязательна: vk-bootstrap её делал, а без неё машина
// без установленного SDK не запустила бы приложение вовсе.
let mut layers: Vec<*const c_char> = Vec::new();
if opts.enable_validation {
let available = unsafe { entry.enumerate_instance_layer_properties() }
.ctx("vkEnumerateInstanceLayerProperties")?;
let has_validation = available
.iter()
.any(|l| l.layer_name_as_c_str() == Ok(VALIDATION_LAYER));
if has_validation {
layers.push(VALIDATION_LAYER.as_ptr());
} else {
log::warn!(target: "vk", "слой валидации не установлен — иду без него");
}
}
let app_info = vk::ApplicationInfo::default()
.application_name(c"SimVulcan")
.engine_name(c"simv")
.api_version(vk::API_VERSION_1_3);
let instance_ci = vk::InstanceCreateInfo::default()
.application_info(&app_info)
.enabled_extension_names(&extensions)
.enabled_layer_names(&layers);
let instance =
unsafe { entry.create_instance(&instance_ci, None) }.ctx("vkCreateInstance")?;
// Любой ранний выход дальше обязан прибрать уже созданное: `Drop` собирается
// только на полностью построенном контексте. Отсюда явный разбор ошибки.
match Self::build(&entry, &instance, display_handle, window_handle, opts) {
Ok(mut ctx) => {
ctx.entry = Some(entry);
Ok(ctx)
}
Err(e) => {
unsafe { instance.destroy_instance(None) };
Err(e)
}
}
}
/// Всё, что строится поверх готового инстанса.
fn build(
entry: &ash::Entry,
instance: &ash::Instance,
display_handle: raw_window_handle::RawDisplayHandle,
window_handle: raw_window_handle::RawWindowHandle,
opts: &ContextOptions,
) -> Result<Self> {
// ── Сообщения валидации ──────────────────────────────────────────────
let debug = if opts.enable_debug_utils {
let loader = ash::ext::debug_utils::Instance::new(entry, instance);
let ci = vk::DebugUtilsMessengerCreateInfoEXT::default()
.message_severity(
vk::DebugUtilsMessageSeverityFlagsEXT::ERROR
| vk::DebugUtilsMessageSeverityFlagsEXT::WARNING,
)
.message_type(
vk::DebugUtilsMessageTypeFlagsEXT::GENERAL
| vk::DebugUtilsMessageTypeFlagsEXT::VALIDATION
| vk::DebugUtilsMessageTypeFlagsEXT::PERFORMANCE,
)
.pfn_user_callback(Some(debug_callback));
let messenger = unsafe { loader.create_debug_utils_messenger(&ci, None) }
.ctx("vkCreateDebugUtilsMessengerEXT")?;
Some((loader, messenger))
} else {
None
};
// ── Поверхность ──────────────────────────────────────────────────────
let surface_loader = ash::khr::surface::Instance::new(entry, instance);
let surface = unsafe {
ash_window::create_surface(entry, instance, display_handle, window_handle, None)
}
.ctx("vkCreate*SurfaceKHR")?;
// ── Физическое устройство ────────────────────────────────────────────
let chosen = select_physical_device(instance, &surface_loader, surface)?;
let device_name = chosen.name.clone();
let physical_device = chosen.handle;
// ── Логическое устройство ────────────────────────────────────────────
let mut families = vec![chosen.graphics_family];
for f in [chosen.compute_family, chosen.transfer_family]
.into_iter()
.flatten()
{
if !families.contains(&f) {
families.push(f);
}
}
let priority = [1.0f32];
let queue_cis: Vec<_> = families
.iter()
.map(|&family| {
vk::DeviceQueueCreateInfo::default()
.queue_family_index(family)
.queue_priorities(&priority)
})
.collect();
let device_extensions = [ash::khr::swapchain::NAME.as_ptr()];
// Ровно тот набор, который включает C++-версия (Context.cpp: feat10/12/13).
let mut features13 = vk::PhysicalDeviceVulkan13Features::default()
.synchronization2(true)
.dynamic_rendering(true);
let mut features12 = vk::PhysicalDeviceVulkan12Features::default()
.timeline_semaphore(true)
.descriptor_indexing(true)
.buffer_device_address(true)
.shader_subgroup_extended_types(true)
.scalar_block_layout(true)
.runtime_descriptor_array(true);
// fillModeNonSolid — без него не собрать каркасный конвейер.
let features10 = vk::PhysicalDeviceFeatures::default().fill_mode_non_solid(true);
let mut features2 = vk::PhysicalDeviceFeatures2::default()
.features(features10)
.push_next(&mut features13)
.push_next(&mut features12);
let device_ci = vk::DeviceCreateInfo::default()
.queue_create_infos(&queue_cis)
.enabled_extension_names(&device_extensions)
.push_next(&mut features2);
let device = unsafe { instance.create_device(physical_device, &device_ci, None) }
.ctx("vkCreateDevice")?;
// ── Очереди ──────────────────────────────────────────────────────────
let graphics_family = chosen.graphics_family;
let graphics_queue = unsafe { device.get_device_queue(graphics_family, 0) };
let (compute_family, compute_queue) = match chosen.compute_family {
Some(f) if opts.require_async_compute => (f, unsafe { device.get_device_queue(f, 0) }),
_ => {
log::warn!(target: "vk", "выделенной вычислительной очереди нет — беру графическую");
(graphics_family, graphics_queue)
}
};
let (transfer_family, transfer_queue) = match chosen.transfer_family {
Some(f) if opts.require_async_transfer => (f, unsafe { device.get_device_queue(f, 0) }),
_ => {
log::warn!(target: "vk", "выделенной очереди передачи нет — беру графическую");
(graphics_family, graphics_queue)
}
};
// ── Аллокатор ────────────────────────────────────────────────────────
let allocator = Allocator::new(&AllocatorCreateDesc {
instance: instance.clone(),
device: device.clone(),
physical_device,
debug_settings: Default::default(),
buffer_device_address: true,
allocation_sizes: Default::default(),
})
.map_err(|e| VkError::Other(format!("gpu-allocator: {e}")))?;
let swapchain_loader = ash::khr::swapchain::Device::new(instance, &device);
let pipeline_cache = create_pipeline_cache(&device);
log::info!(target: "vk", "контекст Vulkan готов: {device_name}");
Ok(Self {
device_name,
graphics_queue,
compute_queue,
transfer_queue,
graphics_family,
compute_family,
transfer_family,
pipeline_cache,
allocator: Some(Arc::new(Mutex::new(allocator))),
swapchain_loader,
device,
physical_device,
surface,
surface_loader,
debug,
instance: instance.clone(),
// Заполняет вызывающий: сюда `Entry` не передаётся, чтобы на пути ошибки
// инстанс уничтожался ровно один раз, а библиотека выгружалась после него.
entry: None,
})
}
pub fn device(&self) -> &ash::Device {
&self.device
}
pub fn instance(&self) -> &ash::Instance {
&self.instance
}
pub fn physical_device(&self) -> vk::PhysicalDevice {
self.physical_device
}
pub fn surface(&self) -> vk::SurfaceKHR {
self.surface
}
pub fn surface_loader(&self) -> &ash::khr::surface::Instance {
&self.surface_loader
}
pub fn swapchain_loader(&self) -> &ash::khr::swapchain::Device {
&self.swapchain_loader
}
pub fn allocator(&self) -> &Arc<Mutex<Allocator>> {
self.allocator.as_ref().expect("аллокатор уже уничтожен")
}
pub fn graphics_queue(&self) -> vk::Queue {
self.graphics_queue
}
pub fn compute_queue(&self) -> vk::Queue {
self.compute_queue
}
pub fn transfer_queue(&self) -> vk::Queue {
self.transfer_queue
}
pub fn graphics_family(&self) -> u32 {
self.graphics_family
}
pub fn compute_family(&self) -> u32 {
self.compute_family
}
pub fn transfer_family(&self) -> u32 {
self.transfer_family
}
pub fn pipeline_cache(&self) -> vk::PipelineCache {
self.pipeline_cache
}
pub fn device_name(&self) -> &str {
&self.device_name
}
pub fn wait_idle(&self) {
let _ = unsafe { self.device.device_wait_idle() };
}
}
impl Drop for Context {
fn drop(&mut self) {
unsafe {
let _ = self.device.device_wait_idle();
save_pipeline_cache(&self.device, self.pipeline_cache);
if self.pipeline_cache != vk::PipelineCache::null() {
self.device.destroy_pipeline_cache(self.pipeline_cache, None);
}
// Аллокатор обязан уйти до устройства, иначе он освободит память уже
// уничтоженного `VkDevice`.
drop(self.allocator.take());
self.device.destroy_device(None);
self.surface_loader.destroy_surface(self.surface, None);
if let Some((loader, messenger)) = self.debug.take() {
loader.destroy_debug_utils_messenger(messenger, None);
}
self.instance.destroy_instance(None);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Выбор физического устройства — то, что в C++ делает vkb::PhysicalDeviceSelector
// ─────────────────────────────────────────────────────────────────────────────
struct ChosenDevice {
handle: vk::PhysicalDevice,
name: String,
graphics_family: u32,
/// Выделенное семейство: COMPUTE без GRAPHICS. `None` — такого нет.
compute_family: Option<u32>,
/// Выделенное семейство: TRANSFER без GRAPHICS и COMPUTE.
transfer_family: Option<u32>,
score: u32,
}
fn select_physical_device(
instance: &ash::Instance,
surface_loader: &ash::khr::surface::Instance,
surface: vk::SurfaceKHR,
) -> Result<ChosenDevice> {
let devices =
unsafe { instance.enumerate_physical_devices() }.ctx("vkEnumeratePhysicalDevices")?;
let mut best: Option<ChosenDevice> = None;
for handle in devices {
let Some(candidate) = inspect_device(instance, surface_loader, surface, handle) else {
continue;
};
if best.as_ref().is_none_or(|b| candidate.score > b.score) {
best = Some(candidate);
}
}
best.ok_or(VkError::NoSuitableDevice)
}
fn inspect_device(
instance: &ash::Instance,
surface_loader: &ash::khr::surface::Instance,
surface: vk::SurfaceKHR,
handle: vk::PhysicalDevice,
) -> Option<ChosenDevice> {
let props = unsafe { instance.get_physical_device_properties(handle) };
let name = props
.device_name_as_c_str()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|_| String::from("устройство без имени"));
if props.api_version < vk::API_VERSION_1_3 {
log::debug!(target: "vk", "{name}: пропускаю, API ниже 1.3");
return None;
}
// Возможности: ровно тот набор, который требует C++-версия.
//
// Запрос обёрнут в блок не для красоты. `push_next` в ash берёт `&mut` на
// вложенную структуру и держит заимствование, пока жива голова цепочки, поэтому
// читать `f13`/`f12` при живом `f2` компилятор не даёт. В C++ pNext — сырой
// указатель, и там этот вопрос просто не возникает. Блок гасит заимствование:
// наружу выходят уже заполненные значения.
let (f13, f12, f10) = {
let mut f13 = vk::PhysicalDeviceVulkan13Features::default();
let mut f12 = vk::PhysicalDeviceVulkan12Features::default();
let mut f2 = vk::PhysicalDeviceFeatures2::default()
.push_next(&mut f13)
.push_next(&mut f12);
unsafe { instance.get_physical_device_features2(handle, &mut f2) };
let f10 = f2.features;
(f13, f12, f10)
};
let missing = [
("synchronization2", f13.synchronization2 == vk::TRUE),
("dynamicRendering", f13.dynamic_rendering == vk::TRUE),
("timelineSemaphore", f12.timeline_semaphore == vk::TRUE),
("descriptorIndexing", f12.descriptor_indexing == vk::TRUE),
("bufferDeviceAddress", f12.buffer_device_address == vk::TRUE),
(
"shaderSubgroupExtendedTypes",
f12.shader_subgroup_extended_types == vk::TRUE,
),
("scalarBlockLayout", f12.scalar_block_layout == vk::TRUE),
(
"runtimeDescriptorArray",
f12.runtime_descriptor_array == vk::TRUE,
),
("fillModeNonSolid", f10.fill_mode_non_solid == vk::TRUE),
]
.into_iter()
.filter_map(|(feature, ok)| (!ok).then_some(feature))
.collect::<Vec<_>>();
if !missing.is_empty() {
log::debug!(target: "vk", "{name}: пропускаю, нет возможностей: {}", missing.join(", "));
return None;
}
let extensions = unsafe { instance.enumerate_device_extension_properties(handle) }.ok()?;
let has_swapchain = extensions
.iter()
.any(|e| e.extension_name_as_c_str() == Ok(ash::khr::swapchain::NAME));
if !has_swapchain {
log::debug!(target: "vk", "{name}: пропускаю, нет VK_KHR_swapchain");
return None;
}
// Семейства очередей. Графическое обязано ещё и уметь показывать на поверхность:
// C++-версия полагалась на то, что vkb проверит это за неё.
let families = unsafe { instance.get_physical_device_queue_family_properties(handle) };
let mut graphics_family = None;
let mut compute_family = None;
let mut transfer_family = None;
for (index, family) in families.iter().enumerate() {
let i = index as u32;
let flags = family.queue_flags;
let graphics = flags.contains(vk::QueueFlags::GRAPHICS);
let compute = flags.contains(vk::QueueFlags::COMPUTE);
let transfer = flags.contains(vk::QueueFlags::TRANSFER);
if graphics_family.is_none() && graphics {
let presents =
unsafe { surface_loader.get_physical_device_surface_support(handle, i, surface) }
.unwrap_or(false);
if presents {
graphics_family = Some(i);
}
}
if compute_family.is_none() && compute && !graphics {
compute_family = Some(i);
}
if transfer_family.is_none() && transfer && !graphics && !compute {
transfer_family = Some(i);
}
}
let graphics_family = graphics_family?;
let score = match props.device_type {
vk::PhysicalDeviceType::DISCRETE_GPU => 1000,
vk::PhysicalDeviceType::INTEGRATED_GPU => 100,
vk::PhysicalDeviceType::VIRTUAL_GPU => 10,
_ => 1,
};
Some(ChosenDevice {
handle,
name,
graphics_family,
compute_family,
transfer_family,
score,
})
}
// ─────────────────────────────────────────────────────────────────────────────
// Кэш конвейеров
// ─────────────────────────────────────────────────────────────────────────────
/// Файл кэша лежит рядом с исполняемым файлом, а не в текущем каталоге, как в
/// C++-версии: запускать редактор из произвольного места теперь можно, и кэш при этом
/// не рассыпается по диску.
fn pipeline_cache_path() -> std::path::PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("pipeline_cache.bin")))
.unwrap_or_else(|| std::path::PathBuf::from("pipeline_cache.bin"))
}
fn create_pipeline_cache(device: &ash::Device) -> vk::PipelineCache {
let blob = std::fs::read(pipeline_cache_path()).unwrap_or_default();
let ci = vk::PipelineCacheCreateInfo::default().initial_data(&blob);
match unsafe { device.create_pipeline_cache(&ci, None) } {
Ok(cache) => {
log::info!(target: "vk", "кэш конвейеров загружен: {} байт", blob.len());
cache
}
Err(e) => {
log::warn!(
target: "vk",
"кэш конвейеров не создан ({e:?}) — конвейеры будут пересобираться каждый запуск"
);
vk::PipelineCache::null()
}
}
}
fn save_pipeline_cache(device: &ash::Device, cache: vk::PipelineCache) {
if cache == vk::PipelineCache::null() {
return;
}
if let Ok(blob) = unsafe { device.get_pipeline_cache_data(cache) } {
if !blob.is_empty() {
let _ = std::fs::write(pipeline_cache_path(), &blob);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Сообщения слоя валидации
// ─────────────────────────────────────────────────────────────────────────────
unsafe extern "system" fn debug_callback(
severity: vk::DebugUtilsMessageSeverityFlagsEXT,
_types: vk::DebugUtilsMessageTypeFlagsEXT,
data: *const vk::DebugUtilsMessengerCallbackDataEXT<'_>,
_user: *mut c_void,
) -> vk::Bool32 {
let message = unsafe {
data.as_ref()
.filter(|d| !d.p_message.is_null())
.map(|d| CStr::from_ptr(d.p_message).to_string_lossy())
.unwrap_or(std::borrow::Cow::Borrowed("сообщение без текста"))
};
if severity.contains(vk::DebugUtilsMessageSeverityFlagsEXT::ERROR) {
log::error!(target: "vk", "{message}");
} else if severity.contains(vk::DebugUtilsMessageSeverityFlagsEXT::WARNING) {
log::warn!(target: "vk", "{message}");
} else {
log::info!(target: "vk", "{message}");
}
vk::FALSE
}
+43
View File
@@ -0,0 +1,43 @@
//! Ошибки Vulkan-слоя.
//!
//! C++-версия бросала `std::runtime_error` со строкой, собранной на месте. Здесь
//! ошибка — значение: её нельзя не заметить, а `?` избавляет от той же ручной
//! проверки `if (r != VK_SUCCESS)`, которой в `Renderer.cpp` посвящена отдельная
//! функция `CheckVk`.
use ash::vk;
#[derive(Debug, thiserror::Error)]
pub enum VkError {
#[error("не удалось загрузить библиотеку Vulkan (нет драйвера или загрузчика): {0}")]
Loading(#[from] ash::LoadingError),
#[error("{context} вернул ошибку: {source:?}")]
Api {
context: &'static str,
#[source]
source: vk::Result,
},
#[error("не найдено устройство Vulkan 1.3 с нужным набором возможностей")]
NoSuitableDevice,
#[error("не удалось получить дескриптор окна: {0}")]
WindowHandle(#[from] raw_window_handle::HandleError),
#[error("{0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, VkError>;
/// Навесить на результат ash имя вызова — то же, что делал `CheckVk(r, where)`.
pub(crate) trait Ctx<T> {
fn ctx(self, context: &'static str) -> Result<T>;
}
impl<T> Ctx<T> for std::result::Result<T, vk::Result> {
fn ctx(self, context: &'static str) -> Result<T> {
self.map_err(|source| VkError::Api { context, source })
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Меш на видеокарте — перенос `simv::vk::GpuMesh`.
//!
//! Загрузка через промежуточный буфер на очереди передачи, с ожиданием забора.
//! Загрузки редки (открытие модели), так что блокирующая копия здесь уместна — но
//! копировать этот приём в покадровые пути нельзя.
//!
//! В C++ у `GpuMesh` есть состояние «пустой» и метод `IsValid()`, а у `Renderer` —
//! отдельный флаг `hasMesh`. Здесь оба исчезли: незагруженный меш выражается через
//! `Option<GpuMesh>`, и забыть проверить его невозможно — компилятор не даст.
use ash::vk;
use simv_mesh::Mesh;
use crate::buffer::{Buffer, MemoryLocation};
use crate::context::Context;
use crate::error::{Ctx, Result, VkError};
pub struct GpuMesh {
vertex: Buffer,
index: Buffer,
vertex_count: u32,
index_count: u32,
}
impl GpuMesh {
pub fn upload(ctx: &Context, mesh: &Mesh) -> Result<Self> {
if mesh.is_empty() {
return Err(VkError::Other(String::from(
"выгрузка пустого меша на видеокарту",
)));
}
let vertex_count = mesh.positions.len() as u32;
let index_count = mesh.indices.len() as u32;
let vertex_bytes: &[u8] = bytemuck::cast_slice(&mesh.positions);
let index_bytes: &[u8] = bytemuck::cast_slice(&mesh.indices);
let vertex = Buffer::new(
ctx,
"mesh.vertices",
vertex_bytes.len() as vk::DeviceSize,
vk::BufferUsageFlags::TRANSFER_DST | vk::BufferUsageFlags::VERTEX_BUFFER,
MemoryLocation::DeviceLocal,
)?;
let index = Buffer::new(
ctx,
"mesh.indices",
index_bytes.len() as vk::DeviceSize,
vk::BufferUsageFlags::TRANSFER_DST | vk::BufferUsageFlags::INDEX_BUFFER,
MemoryLocation::DeviceLocal,
)?;
let mut vertex_stage = Buffer::new(
ctx,
"mesh.vertices.staging",
vertex_bytes.len() as vk::DeviceSize,
vk::BufferUsageFlags::TRANSFER_SRC,
MemoryLocation::HostVisible,
)?;
let mut index_stage = Buffer::new(
ctx,
"mesh.indices.staging",
index_bytes.len() as vk::DeviceSize,
vk::BufferUsageFlags::TRANSFER_SRC,
MemoryLocation::HostVisible,
)?;
vertex_stage.write(vertex_bytes, 0)?;
index_stage.write(index_bytes, 0)?;
one_shot_copy(
ctx,
&[
(
vertex_stage.handle(),
vertex.handle(),
vertex_bytes.len() as vk::DeviceSize,
),
(
index_stage.handle(),
index.handle(),
index_bytes.len() as vk::DeviceSize,
),
],
)?;
Ok(Self {
vertex,
index,
vertex_count,
index_count,
})
}
pub fn vertex_buffer(&self) -> vk::Buffer {
self.vertex.handle()
}
pub fn index_buffer(&self) -> vk::Buffer {
self.index.handle()
}
pub fn vertex_count(&self) -> u32 {
self.vertex_count
}
pub fn index_count(&self) -> u32 {
self.index_count
}
}
/// Одноразовый командный буфер на очереди передачи: записать копии, отправить,
/// дождаться забора, прибрать за собой.
///
/// C++-версия вызывала эту функцию по разу на буфер, то есть дважды отправляла работу
/// и дважды ждала. Здесь копии собраны в одну отправку — при загрузке `plane.obj`
/// это экономит одно полное ожидание забора.
fn one_shot_copy(ctx: &Context, copies: &[(vk::Buffer, vk::Buffer, vk::DeviceSize)]) -> Result<()> {
let device = ctx.device();
let pool_ci = vk::CommandPoolCreateInfo::default()
.flags(vk::CommandPoolCreateFlags::TRANSIENT)
.queue_family_index(ctx.transfer_family());
let pool = unsafe { device.create_command_pool(&pool_ci, None) }
.ctx("vkCreateCommandPool(one-shot)")?;
// Дальше любой выход обязан прибрать пул, поэтому тело вынесено в замыкание.
let result = (|| -> Result<()> {
let alloc_ci = vk::CommandBufferAllocateInfo::default()
.command_pool(pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1);
let cmd = unsafe { device.allocate_command_buffers(&alloc_ci) }
.ctx("vkAllocateCommandBuffers(one-shot)")?[0];
let begin = vk::CommandBufferBeginInfo::default()
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT);
unsafe { device.begin_command_buffer(cmd, &begin) }.ctx("vkBeginCommandBuffer")?;
for &(src, dst, size) in copies {
let region = [vk::BufferCopy::default().size(size)];
unsafe { device.cmd_copy_buffer(cmd, src, dst, &region) };
}
unsafe { device.end_command_buffer(cmd) }.ctx("vkEndCommandBuffer")?;
let fence = unsafe { device.create_fence(&vk::FenceCreateInfo::default(), None) }
.ctx("vkCreateFence(one-shot)")?;
let cmds = [cmd];
let submit = [vk::SubmitInfo::default().command_buffers(&cmds)];
let submitted = unsafe { device.queue_submit(ctx.transfer_queue(), &submit, fence) };
let waited = submitted
.ctx("vkQueueSubmit(one-shot)")
.and_then(|()| {
unsafe { device.wait_for_fences(&[fence], true, u64::MAX) }
.ctx("vkWaitForFences(one-shot)")
});
unsafe { device.destroy_fence(fence, None) };
waited
})();
unsafe { device.destroy_command_pool(pool, None) };
result
}
+257
View File
@@ -0,0 +1,257 @@
//! Опорная сетка и оси — перенос `simv::vk::GridRenderer`.
//!
//! Геометрия строится один раз в буфер, видимый процессору: она статична и крошечная,
//! так что промежуточная копия ради неё не нужна. Дескрипторных наборов нет — всё
//! состояние умещается в одну push-константу.
use ash::vk;
use glam::{Mat4, Vec3};
use crate::buffer::{Buffer, MemoryLocation};
use crate::context::Context;
use crate::error::{Ctx, Result};
use crate::shader::{spirv, ShaderModule};
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct GridVertex {
pos: Vec3,
color: Vec3,
}
/// Половина протяжённости сетки в клетках и размер клетки — как в C++-версии.
const HALF_EXTENT_CELLS: i32 = 10;
const CELL_SIZE: f32 = 1.0;
pub struct GridRenderer {
device: ash::Device,
layout: vk::PipelineLayout,
pipeline: vk::Pipeline,
vertex_buffer: Buffer,
vertex_count: u32,
color_format: vk::Format,
depth_format: vk::Format,
}
impl GridRenderer {
pub fn new(ctx: &Context) -> Result<Self> {
let device = ctx.device().clone();
let ranges = [vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::VERTEX)
.offset(0)
.size(std::mem::size_of::<Mat4>() as u32)];
let layout_ci = vk::PipelineLayoutCreateInfo::default().push_constant_ranges(&ranges);
let layout = unsafe { device.create_pipeline_layout(&layout_ci, None) }
.ctx("vkCreatePipelineLayout(grid)")?;
let vertices = build_geometry();
let bytes: &[u8] = bytemuck::cast_slice(&vertices);
let mut vertex_buffer = Buffer::new(
ctx,
"grid.vertices",
bytes.len() as vk::DeviceSize,
vk::BufferUsageFlags::VERTEX_BUFFER,
MemoryLocation::HostVisible,
)?;
vertex_buffer.write(bytes, 0)?;
Ok(Self {
device,
layout,
pipeline: vk::Pipeline::null(),
vertex_buffer,
vertex_count: vertices.len() as u32,
color_format: vk::Format::UNDEFINED,
depth_format: vk::Format::UNDEFINED,
})
}
/// Пересобрать конвейер, если форматы вложений сменились (пересоздание swapchain).
pub fn ensure_pipeline(
&mut self,
ctx: &Context,
color_format: vk::Format,
depth_format: vk::Format,
) -> Result<()> {
if self.pipeline != vk::Pipeline::null()
&& self.color_format == color_format
&& self.depth_format == depth_format
{
return Ok(());
}
self.destroy_pipeline();
self.color_format = color_format;
self.depth_format = depth_format;
self.pipeline = self.build_pipeline(ctx)?;
Ok(())
}
fn build_pipeline(&self, ctx: &Context) -> Result<vk::Pipeline> {
// Модули живут до конца функции и уничтожаются на выходе, включая пути ошибок.
let vert = ShaderModule::new(&self.device, spirv::GRID_VERT)?;
let frag = ShaderModule::new(&self.device, spirv::GRID_FRAG)?;
let stages = [
vk::PipelineShaderStageCreateInfo::default()
.stage(vk::ShaderStageFlags::VERTEX)
.module(vert.handle())
.name(c"main"),
vk::PipelineShaderStageCreateInfo::default()
.stage(vk::ShaderStageFlags::FRAGMENT)
.module(frag.handle())
.name(c"main"),
];
let bindings = [vk::VertexInputBindingDescription::default()
.binding(0)
.stride(std::mem::size_of::<GridVertex>() as u32)
.input_rate(vk::VertexInputRate::VERTEX)];
let attributes = [
vk::VertexInputAttributeDescription::default()
.location(0)
.binding(0)
.format(vk::Format::R32G32B32_SFLOAT)
.offset(std::mem::offset_of!(GridVertex, pos) as u32),
vk::VertexInputAttributeDescription::default()
.location(1)
.binding(0)
.format(vk::Format::R32G32B32_SFLOAT)
.offset(std::mem::offset_of!(GridVertex, color) as u32),
];
let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
.vertex_binding_descriptions(&bindings)
.vertex_attribute_descriptions(&attributes);
let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
.topology(vk::PrimitiveTopology::LINE_LIST);
let viewport = vk::PipelineViewportStateCreateInfo::default()
.viewport_count(1)
.scissor_count(1);
let rasterization = vk::PipelineRasterizationStateCreateInfo::default()
// Для линейной топологии режим заливки не используется.
.polygon_mode(vk::PolygonMode::FILL)
.cull_mode(vk::CullModeFlags::NONE)
.line_width(1.0);
let multisample = vk::PipelineMultisampleStateCreateInfo::default()
.rasterization_samples(vk::SampleCountFlags::TYPE_1);
let depth_stencil = vk::PipelineDepthStencilStateCreateInfo::default()
.depth_test_enable(true)
.depth_write_enable(true)
.depth_compare_op(vk::CompareOp::LESS_OR_EQUAL)
.min_depth_bounds(0.0)
.max_depth_bounds(1.0);
let blend_attachments = [vk::PipelineColorBlendAttachmentState::default()
.color_write_mask(vk::ColorComponentFlags::RGBA)];
let color_blend =
vk::PipelineColorBlendStateCreateInfo::default().attachments(&blend_attachments);
let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
let dynamic = vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states);
let color_formats = [self.color_format];
let mut rendering = vk::PipelineRenderingCreateInfo::default()
.color_attachment_formats(&color_formats)
.depth_attachment_format(self.depth_format);
let create_info = [vk::GraphicsPipelineCreateInfo::default()
.stages(&stages)
.vertex_input_state(&vertex_input)
.input_assembly_state(&input_assembly)
.viewport_state(&viewport)
.rasterization_state(&rasterization)
.multisample_state(&multisample)
.depth_stencil_state(&depth_stencil)
.color_blend_state(&color_blend)
.dynamic_state(&dynamic)
.layout(self.layout)
.push_next(&mut rendering)];
let pipelines = unsafe {
self.device
.create_graphics_pipelines(ctx.pipeline_cache(), &create_info, None)
}
.map_err(|(_, e)| crate::error::VkError::Api {
context: "vkCreateGraphicsPipelines(grid)",
source: e,
})?;
Ok(pipelines[0])
}
pub fn draw(&self, cmd: vk::CommandBuffer, view_proj: &Mat4) {
if self.pipeline == vk::Pipeline::null() || self.vertex_count == 0 {
return;
}
unsafe {
self.device
.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, self.pipeline);
self.device.cmd_push_constants(
cmd,
self.layout,
vk::ShaderStageFlags::VERTEX,
0,
bytemuck::bytes_of(view_proj),
);
self.device
.cmd_bind_vertex_buffers(cmd, 0, &[self.vertex_buffer.handle()], &[0]);
self.device.cmd_draw(cmd, self.vertex_count, 1, 0, 0);
}
}
fn destroy_pipeline(&mut self) {
if self.pipeline != vk::Pipeline::null() {
unsafe { self.device.destroy_pipeline(self.pipeline, None) };
self.pipeline = vk::Pipeline::null();
}
}
}
impl Drop for GridRenderer {
fn drop(&mut self) {
self.destroy_pipeline();
unsafe { self.device.destroy_pipeline_layout(self.layout, None) };
}
}
/// Три плоскости сетки через начало координат плюс цветные оси.
fn build_geometry() -> Vec<GridVertex> {
const GRID: Vec3 = Vec3::new(0.30, 0.31, 0.35);
let n = HALF_EXTENT_CELLS;
let ext = n as f32 * CELL_SIZE;
let mut verts = Vec::with_capacity(((2 * n + 1) * 6 * 2 + 6) as usize);
let mut line = |a: Vec3, b: Vec3, color: Vec3| {
verts.push(GridVertex { pos: a, color });
verts.push(GridVertex { pos: b, color });
};
for i in -n..=n {
let t = i as f32 * CELL_SIZE;
// Плоскость XZ (y = 0)
line(Vec3::new(-ext, 0.0, t), Vec3::new(ext, 0.0, t), GRID);
line(Vec3::new(t, 0.0, -ext), Vec3::new(t, 0.0, ext), GRID);
// Плоскость XY (z = 0)
line(Vec3::new(-ext, t, 0.0), Vec3::new(ext, t, 0.0), GRID);
line(Vec3::new(t, -ext, 0.0), Vec3::new(t, ext, 0.0), GRID);
// Плоскость YZ (x = 0)
line(Vec3::new(0.0, -ext, t), Vec3::new(0.0, ext, t), GRID);
line(Vec3::new(0.0, t, -ext), Vec3::new(0.0, t, ext), GRID);
}
// Оси идут последними, чтобы перекрыть серые центральные линии.
line(
Vec3::new(-ext, 0.0, 0.0),
Vec3::new(ext, 0.0, 0.0),
Vec3::new(0.85, 0.25, 0.25), // X красная
);
line(
Vec3::new(0.0, -ext, 0.0),
Vec3::new(0.0, ext, 0.0),
Vec3::new(0.25, 0.80, 0.30), // Y зелёная
);
line(
Vec3::new(0.0, 0.0, -ext),
Vec3::new(0.0, 0.0, ext),
Vec3::new(0.30, 0.45, 0.90), // Z синяя
);
verts
}
+182
View File
@@ -0,0 +1,182 @@
//! Изображение с представлением — перенос `simv::vk::Image`.
//!
//! Как и `buffer.rs`, в C++-версии лежит без применения: глубинное вложение
//! `Renderer` создаёт там свои `VkImage`/`VkImageView` напрямую. Здесь на нём как раз
//! и построено глубинное вложение — см. заметку в `buffer.rs`.
use std::sync::{Arc, Mutex};
use ash::vk;
use gpu_allocator::vulkan::{Allocation, AllocationCreateDesc, AllocationScheme, Allocator};
use crate::context::Context;
use crate::error::{Ctx, Result, VkError};
pub struct ImageDesc {
pub extent: vk::Extent3D,
pub format: vk::Format,
pub usage: vk::ImageUsageFlags,
pub image_type: vk::ImageType,
pub view_type: vk::ImageViewType,
pub aspect: vk::ImageAspectFlags,
}
impl Default for ImageDesc {
fn default() -> Self {
Self {
extent: vk::Extent3D {
width: 1,
height: 1,
depth: 1,
},
format: vk::Format::R8G8B8A8_UNORM,
usage: vk::ImageUsageFlags::SAMPLED,
image_type: vk::ImageType::TYPE_2D,
view_type: vk::ImageViewType::TYPE_2D,
aspect: vk::ImageAspectFlags::COLOR,
}
}
}
pub struct Image {
device: ash::Device,
allocator: Arc<Mutex<Allocator>>,
image: vk::Image,
view: vk::ImageView,
allocation: Option<Allocation>,
extent: vk::Extent3D,
format: vk::Format,
aspect: vk::ImageAspectFlags,
/// Текущая раскладка. Отслеживается здесь, чтобы `transition_to` не выставлял
/// барьер на переход в ту же раскладку — так же, как в C++-версии.
layout: vk::ImageLayout,
}
impl Image {
pub fn new(ctx: &Context, name: &str, desc: &ImageDesc) -> Result<Self> {
let device = ctx.device().clone();
let ci = vk::ImageCreateInfo::default()
.image_type(desc.image_type)
.format(desc.format)
.extent(desc.extent)
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
.usage(desc.usage)
.sharing_mode(vk::SharingMode::EXCLUSIVE)
.initial_layout(vk::ImageLayout::UNDEFINED);
let image = unsafe { device.create_image(&ci, None) }.ctx("vkCreateImage")?;
let requirements = unsafe { device.get_image_memory_requirements(image) };
let allocator = ctx.allocator().clone();
let allocation = {
let mut guard = allocator.lock().expect("аллокатор отравлен паникой");
guard.allocate(&AllocationCreateDesc {
name,
requirements,
location: gpu_allocator::MemoryLocation::GpuOnly,
linear: false,
allocation_scheme: AllocationScheme::GpuAllocatorManaged,
})
};
let allocation = match allocation {
Ok(a) => a,
Err(e) => {
unsafe { device.destroy_image(image, None) };
return Err(VkError::Other(format!("gpu-allocator: {e}")));
}
};
unsafe { device.bind_image_memory(image, allocation.memory(), allocation.offset()) }
.ctx("vkBindImageMemory")?;
let view_ci = vk::ImageViewCreateInfo::default()
.image(image)
.view_type(desc.view_type)
.format(desc.format)
.subresource_range(vk::ImageSubresourceRange {
aspect_mask: desc.aspect,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: 1,
});
let view = unsafe { device.create_image_view(&view_ci, None) }.ctx("vkCreateImageView")?;
Ok(Self {
device,
allocator,
image,
view,
allocation: Some(allocation),
extent: desc.extent,
format: desc.format,
aspect: desc.aspect,
layout: vk::ImageLayout::UNDEFINED,
})
}
pub fn image(&self) -> vk::Image {
self.image
}
pub fn view(&self) -> vk::ImageView {
self.view
}
pub fn extent(&self) -> vk::Extent3D {
self.extent
}
pub fn format(&self) -> vk::Format {
self.format
}
pub fn layout(&self) -> vk::ImageLayout {
self.layout
}
/// Барьер перехода раскладки на sync2. Повторный переход в ту же раскладку —
/// не операция.
pub fn transition_to(
&mut self,
cmd: vk::CommandBuffer,
new_layout: vk::ImageLayout,
src_stage: vk::PipelineStageFlags2,
src_access: vk::AccessFlags2,
dst_stage: vk::PipelineStageFlags2,
dst_access: vk::AccessFlags2,
) {
if new_layout == self.layout {
return;
}
let barrier = vk::ImageMemoryBarrier2::default()
.src_stage_mask(src_stage)
.src_access_mask(src_access)
.dst_stage_mask(dst_stage)
.dst_access_mask(dst_access)
.old_layout(self.layout)
.new_layout(new_layout)
.image(self.image)
.subresource_range(vk::ImageSubresourceRange {
aspect_mask: self.aspect,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: 1,
});
let barriers = [barrier];
let dependency = vk::DependencyInfo::default().image_memory_barriers(&barriers);
unsafe { self.device.cmd_pipeline_barrier2(cmd, &dependency) };
self.layout = new_layout;
}
}
impl Drop for Image {
fn drop(&mut self) {
unsafe { self.device.destroy_image_view(self.view, None) };
if let Some(allocation) = self.allocation.take() {
let _ = self
.allocator
.lock()
.map(|mut guard| guard.free(allocation));
}
unsafe { self.device.destroy_image(self.image, None) };
}
}
+26
View File
@@ -0,0 +1,26 @@
//! Весь Vulkan редактора — соответствует цели `simv_vk`.
//!
//! Единственный крейт с `ash` в зависимостях. В C++ это правило («весь Vulkan живёт в
//! `src/vk/`») держится на дисциплине и комментарии в CLAUDE.md; здесь его проверяет
//! компоновщик зависимостей: у `simv-mesh` и `simv-editor` `ash` просто нет.
//!
//! Публичный интерфейс `Renderer` не содержит типов Vulkan — как и в C++-версии, где
//! ради этого заведён pImpl. В Rust того же добиваются приватные поля модуля, так что
//! `struct Renderer::Impl` и вся возня с `unique_ptr` не понадобились.
pub mod buffer;
pub mod context;
pub mod error;
pub mod gpu_mesh;
pub mod grid_renderer;
pub mod image;
pub mod mesh_renderer;
pub mod render_mode;
pub mod renderer;
pub mod shader;
pub mod swapchain;
pub use context::{Context, ContextOptions};
pub use error::{Result, VkError};
pub use render_mode::RenderMode;
pub use renderer::{FrameState, Renderer};
+238
View File
@@ -0,0 +1,238 @@
//! Конвейеры модели — перенос `simv::vk::MeshRenderer`.
//!
//! Два конвейера на один макет: заливка и `POLYGON_MODE_LINE` для каркаса. У линейного
//! включено небольшое смещение глубины, чтобы каркас лёг поверх заливки, а не спорил
//! с ней за z. Отсечение выключено — у загруженных моделей обход граней вперемешку.
//!
//! ВАЖНО: `MeshPushConstants` обязан байт в байт совпадать с блоком `push_constant`
//! в `shaders/editor/mesh.vert` и `mesh.frag`. Поле `color.a` — это ФЛАГ (1 — плоская
//! заливка, 0 — постоянный цвет каркаса), а не прозрачность. Меняете здесь — меняйте
//! и в шейдере.
use ash::vk;
use glam::{Mat4, Vec4};
use crate::context::Context;
use crate::error::{Ctx, Result, VkError};
use crate::gpu_mesh::GpuMesh;
use crate::render_mode::RenderMode;
use crate::shader::{spirv, ShaderModule};
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct MeshPushConstants {
mvp: Mat4,
color: Vec4,
}
/// `color.a`: 1 — плоская заливка, 0 — постоянный цвет.
const SOLID: Vec4 = Vec4::new(0.72, 0.74, 0.78, 1.0);
const WIRE: Vec4 = Vec4::new(0.78, 0.82, 0.88, 0.0);
const OVERLAY: Vec4 = Vec4::new(0.06, 0.07, 0.09, 0.0);
pub struct MeshRenderer {
device: ash::Device,
layout: vk::PipelineLayout,
fill: vk::Pipeline,
line: vk::Pipeline,
color_format: vk::Format,
depth_format: vk::Format,
}
impl MeshRenderer {
pub fn new(ctx: &Context) -> Result<Self> {
let device = ctx.device().clone();
let ranges = [vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT)
.offset(0)
.size(std::mem::size_of::<MeshPushConstants>() as u32)];
let layout_ci = vk::PipelineLayoutCreateInfo::default().push_constant_ranges(&ranges);
let layout = unsafe { device.create_pipeline_layout(&layout_ci, None) }
.ctx("vkCreatePipelineLayout(mesh)")?;
Ok(Self {
device,
layout,
fill: vk::Pipeline::null(),
line: vk::Pipeline::null(),
color_format: vk::Format::UNDEFINED,
depth_format: vk::Format::UNDEFINED,
})
}
pub fn ensure_pipelines(
&mut self,
ctx: &Context,
color_format: vk::Format,
depth_format: vk::Format,
) -> Result<()> {
if self.fill != vk::Pipeline::null()
&& self.color_format == color_format
&& self.depth_format == depth_format
{
return Ok(());
}
self.destroy_pipelines();
self.color_format = color_format;
self.depth_format = depth_format;
self.fill = self.build_pipeline(ctx, vk::PolygonMode::FILL, false)?;
self.line = self.build_pipeline(ctx, vk::PolygonMode::LINE, true)?;
Ok(())
}
fn build_pipeline(
&self,
ctx: &Context,
polygon_mode: vk::PolygonMode,
depth_bias: bool,
) -> Result<vk::Pipeline> {
let vert = ShaderModule::new(&self.device, spirv::MESH_VERT)?;
let frag = ShaderModule::new(&self.device, spirv::MESH_FRAG)?;
let stages = [
vk::PipelineShaderStageCreateInfo::default()
.stage(vk::ShaderStageFlags::VERTEX)
.module(vert.handle())
.name(c"main"),
vk::PipelineShaderStageCreateInfo::default()
.stage(vk::ShaderStageFlags::FRAGMENT)
.module(frag.handle())
.name(c"main"),
];
// Поток вершин — плотный float3: `mesh.frag` восстанавливает нормаль грани из
// экранных производных, так что нормали передавать не нужно.
let bindings = [vk::VertexInputBindingDescription::default()
.binding(0)
.stride(std::mem::size_of::<f32>() as u32 * 3)
.input_rate(vk::VertexInputRate::VERTEX)];
let attributes = [vk::VertexInputAttributeDescription::default()
.location(0)
.binding(0)
.format(vk::Format::R32G32B32_SFLOAT)
.offset(0)];
let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
.vertex_binding_descriptions(&bindings)
.vertex_attribute_descriptions(&attributes);
let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
.topology(vk::PrimitiveTopology::TRIANGLE_LIST);
let viewport = vk::PipelineViewportStateCreateInfo::default()
.viewport_count(1)
.scissor_count(1);
let mut rasterization = vk::PipelineRasterizationStateCreateInfo::default()
.polygon_mode(polygon_mode)
// У загруженных моделей обход граней бывает вперемешку.
.cull_mode(vk::CullModeFlags::NONE)
.front_face(vk::FrontFace::COUNTER_CLOCKWISE)
.line_width(1.0);
if depth_bias {
// Подвинуть каркас к камере, чтобы он лёг поверх заливки.
rasterization = rasterization
.depth_bias_enable(true)
.depth_bias_constant_factor(-1.0)
.depth_bias_slope_factor(-1.0);
}
let multisample = vk::PipelineMultisampleStateCreateInfo::default()
.rasterization_samples(vk::SampleCountFlags::TYPE_1);
let depth_stencil = vk::PipelineDepthStencilStateCreateInfo::default()
.depth_test_enable(true)
.depth_write_enable(true)
.depth_compare_op(vk::CompareOp::LESS_OR_EQUAL)
.min_depth_bounds(0.0)
.max_depth_bounds(1.0);
let blend_attachments = [vk::PipelineColorBlendAttachmentState::default()
.color_write_mask(vk::ColorComponentFlags::RGBA)];
let color_blend =
vk::PipelineColorBlendStateCreateInfo::default().attachments(&blend_attachments);
let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
let dynamic = vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states);
let color_formats = [self.color_format];
let mut rendering = vk::PipelineRenderingCreateInfo::default()
.color_attachment_formats(&color_formats)
.depth_attachment_format(self.depth_format);
let create_info = [vk::GraphicsPipelineCreateInfo::default()
.stages(&stages)
.vertex_input_state(&vertex_input)
.input_assembly_state(&input_assembly)
.viewport_state(&viewport)
.rasterization_state(&rasterization)
.multisample_state(&multisample)
.depth_stencil_state(&depth_stencil)
.color_blend_state(&color_blend)
.dynamic_state(&dynamic)
.layout(self.layout)
.push_next(&mut rendering)];
let pipelines = unsafe {
self.device
.create_graphics_pipelines(ctx.pipeline_cache(), &create_info, None)
}
.map_err(|(_, e)| VkError::Api {
context: "vkCreateGraphicsPipelines(mesh)",
source: e,
})?;
Ok(pipelines[0])
}
pub fn draw(&self, cmd: vk::CommandBuffer, mesh: &GpuMesh, mvp: &Mat4, mode: RenderMode) {
if self.fill == vk::Pipeline::null() || self.line == vk::Pipeline::null() {
return;
}
unsafe {
self.device
.cmd_bind_vertex_buffers(cmd, 0, &[mesh.vertex_buffer()], &[0]);
self.device
.cmd_bind_index_buffer(cmd, mesh.index_buffer(), 0, vk::IndexType::UINT32);
}
let draw_with = |pipeline: vk::Pipeline, color: Vec4| {
let push = MeshPushConstants { mvp: *mvp, color };
unsafe {
self.device
.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, pipeline);
self.device.cmd_push_constants(
cmd,
self.layout,
vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
0,
bytemuck::bytes_of(&push),
);
self.device
.cmd_draw_indexed(cmd, mesh.index_count(), 1, 0, 0, 0);
}
};
match mode {
RenderMode::Solid => draw_with(self.fill, SOLID),
RenderMode::Wireframe => draw_with(self.line, WIRE),
RenderMode::SolidWire => {
draw_with(self.fill, SOLID);
draw_with(self.line, OVERLAY);
}
}
}
fn destroy_pipelines(&mut self) {
unsafe {
if self.fill != vk::Pipeline::null() {
self.device.destroy_pipeline(self.fill, None);
self.fill = vk::Pipeline::null();
}
if self.line != vk::Pipeline::null() {
self.device.destroy_pipeline(self.line, None);
self.line = vk::Pipeline::null();
}
}
}
}
impl Drop for MeshRenderer {
fn drop(&mut self) {
self.destroy_pipelines();
unsafe { self.device.destroy_pipeline_layout(self.layout, None) };
}
}
+13
View File
@@ -0,0 +1,13 @@
//! Режим отображения модели — перенос `simv::vk::RenderMode`.
/// Как рисуется загруженная модель во вьюпорте.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum RenderMode {
/// Залитые треугольники с плоской заливкой по нормали грани.
#[default]
Solid,
/// Только рёбра треугольников.
Wireframe,
/// Заливка с наложенным поверх каркасом.
SolidWire,
}
+626
View File
@@ -0,0 +1,626 @@
//! Кадр целиком — перенос `simv::vk::Renderer`.
//!
//! Владеет всем стеком показа: контекстом, цепочкой показа, глубинным вложением,
//! покадровыми командными буферами и синхронизацией, бэкендом egui и обоими
//! рендерерами сцены. В публичном интерфейсе нет ни одного типа Vulkan — ровно та же
//! цель, ради которой в C++ заведён pImpl, но здесь её решают приватные поля модуля,
//! так что `struct Renderer::Impl` и `unique_ptr` не понадобились.
//!
//! Расхождение с C++-версией: там замыкание интерфейса хранится полем
//! (`SetUiCallback`), здесь оно передаётся параметром в [`Renderer::draw_frame`].
//! Причина не стилистическая. Замыкание в C++ захватывает по ссылке разом камеру,
//! панели и сам рендерер; в Rust хранить такой `FnMut` внутри объекта, который это же
//! замыкание и вызывает, значит одолжить рендерер дважды — компилятор не пропустит.
//! Параметр развязывает время жизни и ничего не стоит.
use ash::vk;
use egui_ash_renderer::{DynamicRendering, Options};
use glam::Mat4;
use simv_mesh::Mesh;
use crate::context::{Context, ContextOptions};
use crate::error::{Ctx, Result, VkError};
use crate::gpu_mesh::GpuMesh;
use crate::grid_renderer::GridRenderer;
use crate::image::{Image, ImageDesc};
use crate::mesh_renderer::MeshRenderer;
use crate::render_mode::RenderMode;
use crate::swapchain::Swapchain;
/// Бэкенд egui поверх нашего же аллокатора: обёртка `GpuAllocator` — это
/// `Arc<Mutex<gpu_allocator::vulkan::Allocator>>`, тот самый, что раздаёт память
/// буферам и изображениям сцены. Одна куча на всё приложение.
type EguiRenderer = egui_ash_renderer::Renderer<egui_ash_renderer::allocator::GpuAllocator>;
const FRAMES_IN_FLIGHT: usize = 2;
const DEPTH_FORMAT: vk::Format = vk::Format::D32_SFLOAT;
const CLEAR_COLOR: [f32; 4] = [0.05, 0.06, 0.08, 1.0];
struct FrameSlot {
cmd_pool: vk::CommandPool,
cmd: vk::CommandBuffer,
image_available: vk::Semaphore,
in_flight: vk::Fence,
/// Текстуры egui, которые перестали быть нужны на кадре, использовавшем этот слот.
/// Освобождаются, когда забор слота снова сработает, — то есть когда видеокарта
/// точно закончила с тем кадром.
textures_to_free: Vec<egui::TextureId>,
}
pub struct Renderer {
// Поля объявлены в порядке уничтожения: всё, что держит клон `ash::Device` или
// ссылку на аллокатор, обязано уйти раньше контекста, а контекст — последним.
egui_renderer: EguiRenderer,
egui_state: egui_winit::State,
egui_ctx: egui::Context,
egui_pool: vk::CommandPool,
gpu_mesh: Option<GpuMesh>,
mesh_renderer: MeshRenderer,
grid_renderer: GridRenderer,
depth: Image,
frames: Vec<FrameSlot>,
render_finished: Vec<vk::Semaphore>,
cursor: usize,
swapchain: Swapchain,
context: Context,
/// Матрица модели: загруженная сетка переносится в начало координат.
model: Mat4,
}
/// Что интерфейс сообщает сцене на этом кадре.
///
/// В C++ то же состояние попадало в рендерер сеттерами (`SetViewProj`,
/// `SetRenderMode`, `SetGridVisible`), которые UI-колбэк вызывал прямо из
/// `DrawFrame`. Здесь колбэк вызывается из метода рендерера, поэтому трогать
/// рендерер оттуда нельзя — он уже одолжен. Состояние вместо этого возвращается
/// из замыкания и применяется к тому же кадру, без отставания на кадр.
pub struct FrameState {
pub view_proj: Mat4,
pub render_mode: RenderMode,
pub grid_visible: bool,
}
impl Renderer {
pub fn new(window: &simv_core::Window) -> Result<Self> {
let context = Context::new(
window,
&ContextOptions {
// В редакторе нет вычислительной работы.
require_async_compute: false,
..Default::default()
},
)?;
let swapchain = Swapchain::new(&context, window, true)?;
let (frames, render_finished) = create_frame_state(&context, swapchain.frames().len())?;
let depth = create_depth(&context, swapchain.extent())?;
let egui_ctx = egui::Context::default();
let egui_state = egui_winit::State::new(
egui_ctx.clone(),
egui::ViewportId::ROOT,
window.raw(),
Some(window.raw().scale_factor() as f32),
None,
None,
);
let egui_pool = {
let ci = vk::CommandPoolCreateInfo::default()
.flags(vk::CommandPoolCreateFlags::TRANSIENT)
.queue_family_index(context.graphics_family());
unsafe { context.device().create_command_pool(&ci, None) }
.ctx("vkCreateCommandPool(egui)")?
};
let egui_renderer = EguiRenderer::with_gpu_allocator(
context.allocator().clone(),
context.device().clone(),
egui_ash_renderer::RenderMode::DynamicRendering(dynamic_rendering(&swapchain)),
egui_options(),
)
.map_err(|e| VkError::Other(format!("egui_ash_renderer: {e}")))?;
let mut mesh_renderer = MeshRenderer::new(&context)?;
let mut grid_renderer = GridRenderer::new(&context)?;
mesh_renderer.ensure_pipelines(&context, swapchain.color_format(), DEPTH_FORMAT)?;
grid_renderer.ensure_pipeline(&context, swapchain.color_format(), DEPTH_FORMAT)?;
Ok(Self {
egui_renderer,
egui_state,
egui_ctx,
egui_pool,
gpu_mesh: None,
mesh_renderer,
grid_renderer,
depth,
frames,
render_finished,
cursor: 0,
swapchain,
context,
model: Mat4::IDENTITY,
})
}
/// Отношение сторон кадрового буфера — для матрицы проекции.
pub fn aspect_ratio(&self) -> f32 {
let extent = self.swapchain.extent();
if extent.height == 0 {
return 1.0;
}
extent.width as f32 / extent.height as f32
}
/// Скормить событие окна интерфейсу. Возвращает `true`, если egui его забрал себе
/// и сцене оно не предназначено.
pub fn on_window_event(
&mut self,
window: &simv_core::Window,
event: &winit::event::WindowEvent,
) -> bool {
self.egui_state
.on_window_event(window.raw(), event)
.consumed
}
/// Выгрузить меш на видеокарту и заново отцентрировать модель на начале координат.
///
/// Ожидание простоя устройства здесь допустимо: загрузки редки. В покадровые пути
/// этот приём переносить нельзя.
pub fn set_mesh_cpu(&mut self, mesh: &Mesh) -> Result<()> {
if mesh.is_empty() {
return Ok(());
}
self.context.wait_idle();
self.gpu_mesh = Some(GpuMesh::upload(&self.context, mesh)?);
let center = 0.5 * (mesh.bbox_min + mesh.bbox_max);
self.model = Mat4::from_translation(-center);
Ok(())
}
pub fn clear_mesh(&mut self) {
self.context.wait_idle();
self.gpu_mesh = None;
self.model = Mat4::IDENTITY;
}
pub fn wait_idle(&self) {
self.context.wait_idle();
}
/// Нарисовать кадр: интерфейс, сетка, модель — и показать.
///
/// Замыкание вызывается между сбором ввода и тесселяцией, то есть ровно там же,
/// где C++-версия зовёт `uiCallback` между `ImGui::NewFrame` и `ImGui::Render`.
pub fn draw_frame(
&mut self,
window: &simv_core::Window,
mut ui: impl FnMut(&egui::Context) -> FrameState,
) -> Result<()> {
let (fbw, fbh) = window.framebuffer_size();
if fbw == 0 || fbh == 0 {
return Ok(()); // свёрнуто — рисовать некуда
}
let slot = self.cursor % FRAMES_IN_FLIGHT;
let device = self.context.device().clone();
let in_flight = self.frames[slot].in_flight;
unsafe { device.wait_for_fences(&[in_flight], true, u64::MAX) }.ctx("vkWaitForFences")?;
// Забор сработал — значит кадр, занимавший этот слот, отсчитан, и его текстуры
// можно отдавать.
for id in std::mem::take(&mut self.frames[slot].textures_to_free) {
self.egui_renderer
.free_texture(id)
.map_err(|e| VkError::Other(format!("egui free_texture: {e}")))?;
}
let acquired = unsafe {
self.context.swapchain_loader().acquire_next_image(
self.swapchain.handle(),
u64::MAX,
self.frames[slot].image_available,
vk::Fence::null(),
)
};
let image_index = match acquired {
Ok((index, _suboptimal)) => index as usize,
Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
self.recreate_swap_dependent(window)?;
return Ok(());
}
Err(e) => return Err(VkError::Api { context: "vkAcquireNextImageKHR", source: e }),
};
// ── Кадр egui: панели заполняются переданным замыканием ──────────────
let raw_input = self.egui_state.take_egui_input(window.raw());
// egui может прогнать интерфейс не один раз за кадр (например, если виджет
// попросил пересчёт), поэтому берётся последнее состояние.
let mut frame_state = None;
let mut output = self.egui_ctx.run_ui(raw_input, |u| {
let ctx = u.ctx().clone();
frame_state = Some(ui(&ctx));
});
let frame_state = frame_state.expect("замыкание интерфейса не вернуло состояние кадра");
self.egui_state
.handle_platform_output(window.raw(), output.platform_output);
let primitives = self
.egui_ctx
.tessellate(output.shapes, output.pixels_per_point);
// egui 0.36 отдаёт на текстуру список правок, а не одну: атлас шрифта может
// дорасти несколькими кусками за кадр.
for (&id, deltas) in &output.textures_delta.set {
for delta in deltas {
self.egui_renderer
.set_texture(
self.context.graphics_queue(),
self.egui_pool,
id,
delta,
)
.map_err(|e| VkError::Other(format!("egui set_texture: {e}")))?;
}
}
// Освобождение откладывается на два кадра: сейчас эти текстуры ещё попадут в
// командный буфер, который только предстоит отправить.
self.frames[slot]
.textures_to_free
.extend(output.textures_delta.free.iter().copied());
// У `TexturesDelta` есть `Drop`, который в отладочной сборке падает, если
// правки уронили не применив. Разобрали — говорим об этом явно.
output.textures_delta.clear();
// ── Запись команд ────────────────────────────────────────────────────
let cmd = self.frames[slot].cmd;
unsafe {
device.reset_fences(&[in_flight]).ctx("vkResetFences")?;
device
.reset_command_buffer(cmd, vk::CommandBufferResetFlags::empty())
.ctx("vkResetCommandBuffer")?;
let begin = vk::CommandBufferBeginInfo::default()
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT);
device
.begin_command_buffer(cmd, &begin)
.ctx("vkBeginCommandBuffer")?;
}
let swap_image = self.swapchain.frames()[image_index].image;
let swap_view = self.swapchain.frames()[image_index].view;
let extent = self.swapchain.extent();
// Цвет: UNDEFINED → COLOR_ATTACHMENT_OPTIMAL, глубина: UNDEFINED → DEPTH_ATTACHMENT.
{
let color_barrier = vk::ImageMemoryBarrier2::default()
.src_stage_mask(vk::PipelineStageFlags2::TOP_OF_PIPE)
.dst_stage_mask(vk::PipelineStageFlags2::COLOR_ATTACHMENT_OUTPUT)
.dst_access_mask(vk::AccessFlags2::COLOR_ATTACHMENT_WRITE)
.old_layout(vk::ImageLayout::UNDEFINED)
.new_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
.image(swap_image)
.subresource_range(color_range());
let depth_barrier = vk::ImageMemoryBarrier2::default()
.src_stage_mask(vk::PipelineStageFlags2::TOP_OF_PIPE)
.dst_stage_mask(
vk::PipelineStageFlags2::EARLY_FRAGMENT_TESTS
| vk::PipelineStageFlags2::LATE_FRAGMENT_TESTS,
)
.dst_access_mask(vk::AccessFlags2::DEPTH_STENCIL_ATTACHMENT_WRITE)
.old_layout(vk::ImageLayout::UNDEFINED)
.new_layout(vk::ImageLayout::DEPTH_ATTACHMENT_OPTIMAL)
.image(self.depth.image())
.subresource_range(depth_range());
let barriers = [color_barrier, depth_barrier];
let dependency = vk::DependencyInfo::default().image_memory_barriers(&barriers);
unsafe { device.cmd_pipeline_barrier2(cmd, &dependency) };
}
let color_attachment = vk::RenderingAttachmentInfo::default()
.image_view(swap_view)
.image_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
.load_op(vk::AttachmentLoadOp::CLEAR)
.store_op(vk::AttachmentStoreOp::STORE)
.clear_value(vk::ClearValue {
color: vk::ClearColorValue {
float32: CLEAR_COLOR,
},
});
let depth_attachment = vk::RenderingAttachmentInfo::default()
.image_view(self.depth.view())
.image_layout(vk::ImageLayout::DEPTH_ATTACHMENT_OPTIMAL)
.load_op(vk::AttachmentLoadOp::CLEAR)
.store_op(vk::AttachmentStoreOp::DONT_CARE)
.clear_value(vk::ClearValue {
depth_stencil: vk::ClearDepthStencilValue {
depth: 1.0,
stencil: 0,
},
});
let color_attachments = [color_attachment];
let rendering = vk::RenderingInfo::default()
.render_area(vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 },
extent,
})
.layer_count(1)
.color_attachments(&color_attachments)
.depth_attachment(&depth_attachment);
unsafe {
device.cmd_begin_rendering(cmd, &rendering);
// Область вывода и отсечения общие для обоих рендереров сцены.
let viewport = [vk::Viewport {
x: 0.0,
y: 0.0,
width: extent.width as f32,
height: extent.height as f32,
min_depth: 0.0,
max_depth: 1.0,
}];
let scissor = [vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 },
extent,
}];
device.cmd_set_viewport(cmd, 0, &viewport);
device.cmd_set_scissor(cmd, 0, &scissor);
}
if frame_state.grid_visible {
self.grid_renderer.draw(cmd, &frame_state.view_proj);
}
if let Some(mesh) = &self.gpu_mesh {
self.mesh_renderer.draw(
cmd,
mesh,
&(frame_state.view_proj * self.model),
frame_state.render_mode,
);
}
self.egui_renderer
.cmd_draw(cmd, extent, output.pixels_per_point, &primitives)
.map_err(|e| VkError::Other(format!("egui cmd_draw: {e}")))?;
unsafe {
device.cmd_end_rendering(cmd);
// Цвет: COLOR_ATTACHMENT_OPTIMAL → PRESENT_SRC.
let present_barrier = [vk::ImageMemoryBarrier2::default()
.src_stage_mask(vk::PipelineStageFlags2::COLOR_ATTACHMENT_OUTPUT)
.src_access_mask(vk::AccessFlags2::COLOR_ATTACHMENT_WRITE)
.dst_stage_mask(vk::PipelineStageFlags2::BOTTOM_OF_PIPE)
.old_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
.new_layout(vk::ImageLayout::PRESENT_SRC_KHR)
.image(swap_image)
.subresource_range(color_range())];
let dependency = vk::DependencyInfo::default().image_memory_barriers(&present_barrier);
device.cmd_pipeline_barrier2(cmd, &dependency);
device
.end_command_buffer(cmd)
.ctx("vkEndCommandBuffer")?;
}
// ── Отправка и показ ─────────────────────────────────────────────────
let render_finished = self.render_finished[image_index];
let wait = [vk::SemaphoreSubmitInfo::default()
.semaphore(self.frames[slot].image_available)
.stage_mask(vk::PipelineStageFlags2::COLOR_ATTACHMENT_OUTPUT)];
let signal = [vk::SemaphoreSubmitInfo::default()
.semaphore(render_finished)
.stage_mask(vk::PipelineStageFlags2::ALL_GRAPHICS)];
let cmd_infos = [vk::CommandBufferSubmitInfo::default().command_buffer(cmd)];
let submit = [vk::SubmitInfo2::default()
.wait_semaphore_infos(&wait)
.command_buffer_infos(&cmd_infos)
.signal_semaphore_infos(&signal)];
unsafe { device.queue_submit2(self.context.graphics_queue(), &submit, in_flight) }
.ctx("vkQueueSubmit2")?;
let swapchains = [self.swapchain.handle()];
let wait_semaphores = [render_finished];
let indices = [image_index as u32];
let present = vk::PresentInfoKHR::default()
.wait_semaphores(&wait_semaphores)
.swapchains(&swapchains)
.image_indices(&indices);
let presented = unsafe {
self.context
.swapchain_loader()
.queue_present(self.context.graphics_queue(), &present)
};
match presented {
Ok(false) => {}
Ok(true) | Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
self.recreate_swap_dependent(window)?;
}
Err(e) => {
return Err(VkError::Api {
context: "vkQueuePresentKHR",
source: e,
})
}
}
self.cursor = self.cursor.wrapping_add(1);
Ok(())
}
/// Пересобрать всё, что зависит от цепочки показа.
///
/// Порядок важен и перенесён из C++-версии дословно: неудачный `vkAcquireNextImage`
/// может оставить семафор `image_available` взведённым, поэтому покадровые семафоры
/// пересоздаются целиком; число образов цепочки могло измениться, поэтому семафоры
/// «кадр готов» тоже; и только потом переcобираются конвейеры.
fn recreate_swap_dependent(&mut self, window: &simv_core::Window) -> Result<()> {
self.context.wait_idle();
self.swapchain.recreate(&self.context, window, true)?;
self.depth = create_depth(&self.context, self.swapchain.extent())?;
let device = self.context.device();
for frame in &mut self.frames {
unsafe { device.destroy_semaphore(frame.image_available, None) };
frame.image_available =
unsafe { device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None) }
.ctx("vkCreateSemaphore")?;
}
for semaphore in self.render_finished.drain(..) {
unsafe { device.destroy_semaphore(semaphore, None) };
}
for _ in 0..self.swapchain.frames().len() {
self.render_finished.push(
unsafe { device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None) }
.ctx("vkCreateSemaphore")?,
);
}
let color_format = self.swapchain.color_format();
self.mesh_renderer
.ensure_pipelines(&self.context, color_format, DEPTH_FORMAT)?;
self.grid_renderer
.ensure_pipeline(&self.context, color_format, DEPTH_FORMAT)?;
self.egui_renderer
.set_render_mode(egui_ash_renderer::RenderMode::DynamicRendering(
dynamic_rendering(&self.swapchain),
))
.map_err(|e| VkError::Other(format!("egui set_render_mode: {e}")))?;
Ok(())
}
}
impl Drop for Renderer {
fn drop(&mut self) {
self.context.wait_idle();
let device = self.context.device();
unsafe {
for frame in &self.frames {
device.destroy_fence(frame.in_flight, None);
device.destroy_semaphore(frame.image_available, None);
device.destroy_command_pool(frame.cmd_pool, None);
}
for &semaphore in &self.render_finished {
device.destroy_semaphore(semaphore, None);
}
device.destroy_command_pool(self.egui_pool, None);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Вспомогательное
// ─────────────────────────────────────────────────────────────────────────────
fn color_range() -> vk::ImageSubresourceRange {
vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: 1,
}
}
fn depth_range() -> vk::ImageSubresourceRange {
vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::DEPTH,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: 1,
}
}
/// Форматы вложений для конвейера egui. Глубина здесь не для того, чтобы egui её
/// использовал, а чтобы его конвейер совпал с проходом: интерфейс рисуется в тот же
/// `vkCmdBeginRendering`, что и сцена, а у прохода есть глубинное вложение.
fn dynamic_rendering(swapchain: &Swapchain) -> DynamicRendering {
DynamicRendering {
color_attachment_format: swapchain.color_format(),
depth_attachment_format: Some(DEPTH_FORMAT),
stencil_attachment_format: None,
}
}
fn egui_options() -> Options {
Options {
in_flight_frames: FRAMES_IN_FLIGHT,
// Интерфейс рисуется поверх сцены и в глубину не пишет.
enable_depth_test: false,
enable_depth_write: false,
// Цель показа в формате UNORM, значит перевод в sRGB делает фрагментный шейдер.
srgb_framebuffer: false,
}
}
fn create_depth(ctx: &Context, extent: vk::Extent2D) -> Result<Image> {
Image::new(
ctx,
"depth",
&ImageDesc {
extent: vk::Extent3D {
width: extent.width.max(1),
height: extent.height.max(1),
depth: 1,
},
format: DEPTH_FORMAT,
usage: vk::ImageUsageFlags::DEPTH_STENCIL_ATTACHMENT,
image_type: vk::ImageType::TYPE_2D,
view_type: vk::ImageViewType::TYPE_2D,
aspect: vk::ImageAspectFlags::DEPTH,
},
)
}
fn create_frame_state(
ctx: &Context,
swapchain_image_count: usize,
) -> Result<(Vec<FrameSlot>, Vec<vk::Semaphore>)> {
let device = ctx.device();
let mut frames = Vec::with_capacity(FRAMES_IN_FLIGHT);
for _ in 0..FRAMES_IN_FLIGHT {
let pool_ci = vk::CommandPoolCreateInfo::default()
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER)
.queue_family_index(ctx.graphics_family());
let cmd_pool =
unsafe { device.create_command_pool(&pool_ci, None) }.ctx("vkCreateCommandPool")?;
let alloc_ci = vk::CommandBufferAllocateInfo::default()
.command_pool(cmd_pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1);
let cmd = unsafe { device.allocate_command_buffers(&alloc_ci) }
.ctx("vkAllocateCommandBuffers")?[0];
let image_available =
unsafe { device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None) }
.ctx("vkCreateSemaphore")?;
// Взведён при создании: первый кадр не должен ждать несуществующей отправки.
let fence_ci = vk::FenceCreateInfo::default().flags(vk::FenceCreateFlags::SIGNALED);
let in_flight =
unsafe { device.create_fence(&fence_ci, None) }.ctx("vkCreateFence")?;
frames.push(FrameSlot {
cmd_pool,
cmd,
image_available,
in_flight,
textures_to_free: Vec::new(),
});
}
// По одному семафору «кадр готов» на образ цепочки: он взводится при показе.
let mut render_finished = Vec::with_capacity(swapchain_image_count);
for _ in 0..swapchain_image_count {
render_finished.push(
unsafe { device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None) }
.ctx("vkCreateSemaphore")?,
);
}
Ok((frames, render_finished))
}
+55
View File
@@ -0,0 +1,55 @@
//! Шейдерные модули — перенос `simv::vk::Shader`.
//!
//! Главное отличие от C++-версии: SPIR-V не ищется на диске, а зашит в исполняемый
//! файл на этапе сборки (см. `build.rs`). Вместе с `FindSpvPath` исчезли и
//! POST_BUILD-копирование каталога `spirv/`, и требование запускать редактор из
//! каталога бинаря, и целый класс ошибок «шейдер не найден» во время выполнения:
//! отсутствующий шейдер теперь ломает сборку, а не запуск.
use std::io::Cursor;
use ash::vk;
use crate::error::{Ctx, Result};
/// Скомпилированный SPIR-V, вшитый в бинарь. Пути соответствуют раскладке
/// `shaders/editor/` в корне репозитория.
pub mod spirv {
pub const MESH_VERT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/editor/mesh.vert.spv"));
pub const MESH_FRAG: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/editor/mesh.frag.spv"));
pub const GRID_VERT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/editor/grid.vert.spv"));
pub const GRID_FRAG: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/editor/grid.frag.spv"));
}
/// Владеющая обёртка над `VkShaderModule`: уничтожается на выходе из области
/// видимости, в том числе если сборка конвейера завершилась ошибкой.
pub struct ShaderModule {
device: ash::Device,
handle: vk::ShaderModule,
}
impl ShaderModule {
pub fn new(device: &ash::Device, spirv_bytes: &[u8]) -> Result<Self> {
// `read_spv` заодно снимает вопрос выравнивания: `include_bytes!` даёт срез
// байтов, а Vulkan хочет 32-битные слова.
let words = ash::util::read_spv(&mut Cursor::new(spirv_bytes))
.map_err(|e| crate::error::VkError::Other(format!("битый SPIR-V: {e}")))?;
let ci = vk::ShaderModuleCreateInfo::default().code(&words);
let handle =
unsafe { device.create_shader_module(&ci, None) }.ctx("vkCreateShaderModule")?;
Ok(Self {
device: device.clone(),
handle,
})
}
pub fn handle(&self) -> vk::ShaderModule {
self.handle
}
}
impl Drop for ShaderModule {
fn drop(&mut self) {
unsafe { self.device.destroy_shader_module(self.handle, None) };
}
}
+182
View File
@@ -0,0 +1,182 @@
//! Цепочка показа — перенос `simv::vk::Swapchain`.
//!
//! В C++ вся эта функция умещается в вызов `vkb::SwapchainBuilder`: формат, режим
//! показа, число образов и представления создаются за него. Здесь то же самое сделано
//! руками — выбор формата с откатом, зажим числа образов и размера по возможностям
//! поверхности, создание представлений.
use ash::vk;
use crate::context::Context;
use crate::error::{Ctx, Result};
pub struct SwapchainFrame {
pub image: vk::Image,
pub view: vk::ImageView,
}
pub struct Swapchain {
device: ash::Device,
loader: ash::khr::swapchain::Device,
handle: vk::SwapchainKHR,
frames: Vec<SwapchainFrame>,
color_format: vk::Format,
extent: vk::Extent2D,
}
impl Swapchain {
pub fn new(ctx: &Context, window: &simv_core::Window, vsync: bool) -> Result<Self> {
let mut swapchain = Self {
device: ctx.device().clone(),
loader: ctx.swapchain_loader().clone(),
handle: vk::SwapchainKHR::null(),
frames: Vec::new(),
color_format: vk::Format::UNDEFINED,
extent: vk::Extent2D::default(),
};
swapchain.recreate(ctx, window, vsync)?;
Ok(swapchain)
}
pub fn handle(&self) -> vk::SwapchainKHR {
self.handle
}
pub fn frames(&self) -> &[SwapchainFrame] {
&self.frames
}
pub fn color_format(&self) -> vk::Format {
self.color_format
}
pub fn extent(&self) -> vk::Extent2D {
self.extent
}
/// Пересобрать цепочку под текущий размер окна. Старая уничтожается полностью:
/// `oldSwapchain` не используется — так же, как в C++-версии.
pub fn recreate(
&mut self,
ctx: &Context,
window: &simv_core::Window,
vsync: bool,
) -> Result<()> {
ctx.wait_idle();
self.destroy();
let surface_loader = ctx.surface_loader();
let physical_device = ctx.physical_device();
let surface = ctx.surface();
let caps = unsafe {
surface_loader.get_physical_device_surface_capabilities(physical_device, surface)
}
.ctx("vkGetPhysicalDeviceSurfaceCapabilitiesKHR")?;
let formats =
unsafe { surface_loader.get_physical_device_surface_formats(physical_device, surface) }
.ctx("vkGetPhysicalDeviceSurfaceFormatsKHR")?;
let present_modes = unsafe {
surface_loader.get_physical_device_surface_present_modes(physical_device, surface)
}
.ctx("vkGetPhysicalDeviceSurfacePresentModesKHR")?;
// Желаемый формат тот же, что просит C++-версия; если его нет — первый доступный.
let wanted = vk::SurfaceFormatKHR {
format: vk::Format::B8G8R8A8_UNORM,
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR,
};
let surface_format = formats
.iter()
.copied()
.find(|f| f.format == wanted.format && f.color_space == wanted.color_space)
.unwrap_or_else(|| formats[0]);
// FIFO поддерживается всегда; MAILBOX может отсутствовать.
let desired_mode = if vsync {
vk::PresentModeKHR::FIFO
} else {
vk::PresentModeKHR::MAILBOX
};
let present_mode = present_modes
.iter()
.copied()
.find(|&m| m == desired_mode)
.unwrap_or(vk::PresentModeKHR::FIFO);
// `current_extent` со всеми битами в единицах означает «размер задаёт
// приложение» — тогда берём размер окна и зажимаем по пределам поверхности.
let (width, height) = window.framebuffer_size();
let extent = if caps.current_extent.width == u32::MAX {
vk::Extent2D {
width: width.clamp(caps.min_image_extent.width, caps.max_image_extent.width),
height: height.clamp(caps.min_image_extent.height, caps.max_image_extent.height),
}
} else {
caps.current_extent
};
let mut image_count = caps.min_image_count + 1;
if caps.max_image_count > 0 && image_count > caps.max_image_count {
image_count = caps.max_image_count;
}
let ci = vk::SwapchainCreateInfoKHR::default()
.surface(surface)
.min_image_count(image_count)
.image_format(surface_format.format)
.image_color_space(surface_format.color_space)
.image_extent(extent)
.image_array_layers(1)
.image_usage(
vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_DST,
)
.image_sharing_mode(vk::SharingMode::EXCLUSIVE)
.pre_transform(caps.current_transform)
.composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE)
.present_mode(present_mode)
.clipped(true);
self.handle =
unsafe { self.loader.create_swapchain(&ci, None) }.ctx("vkCreateSwapchainKHR")?;
self.color_format = surface_format.format;
self.extent = extent;
let images = unsafe { self.loader.get_swapchain_images(self.handle) }
.ctx("vkGetSwapchainImagesKHR")?;
self.frames.reserve(images.len());
for image in images {
let view_ci = vk::ImageViewCreateInfo::default()
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(self.color_format)
.subresource_range(vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: 1,
});
let view = unsafe { self.device.create_image_view(&view_ci, None) }
.ctx("vkCreateImageView(swapchain)")?;
self.frames.push(SwapchainFrame { image, view });
}
Ok(())
}
fn destroy(&mut self) {
unsafe {
for frame in self.frames.drain(..) {
self.device.destroy_image_view(frame.view, None);
}
if self.handle != vk::SwapchainKHR::null() {
self.loader.destroy_swapchain(self.handle, None);
self.handle = vk::SwapchainKHR::null();
}
}
}
}
impl Drop for Swapchain {
fn drop(&mut self) {
self.destroy();
}
}