Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7881496b0 | ||
|
|
fe5ca7f936 | ||
|
|
d6e1fa52b9 | ||
|
|
1d7f9191ea | ||
|
|
f5c9e61589 |
+2
-11
@@ -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,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.
@@ -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 тянет девять библиотек по фиксированным тегам и не имеет общего кэша: каждый
|
||||
свежий каталог сборки скачивает их заново.
|
||||
|
||||
Выбор языка — за вами. Этот документ фиксирует только то, что удалось измерить и показать
|
||||
на коде.
|
||||
@@ -1,14 +0,0 @@
|
||||
# Каталог сборки cargo — сотни мегабайт, внутри образа он собирается заново.
|
||||
target/
|
||||
# Результаты прогонов: гифки и ряды монтируются с хоста, в образ не кладутся.
|
||||
bench/out/
|
||||
*.gif
|
||||
*.csv
|
||||
*.json
|
||||
!bench/scenarios.json
|
||||
# Мусор редакторов и ОС
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Generated
-1308
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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`),
|
||||
геометрия при этом собирается из любого числа тел, но силы сверх четвёртого сливаются вместе.
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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 при этом должен быть создан заранее и принадлежать вам).
|
||||
@@ -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
@@ -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
@@ -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_ī^{после стриминга})`. Второй член — из поля ПОСЛЕ
|
||||
|
||||
Generated
+2475
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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, "Показывать сетку");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(), "треугольник выродился и должен быть удалён");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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, ®ion) };
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Режим отображения модели — перенос `simv::vk::RenderMode`.
|
||||
|
||||
/// Как рисуется загруженная модель во вьюпорте.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum RenderMode {
|
||||
/// Залитые треугольники с плоской заливкой по нормали грани.
|
||||
#[default]
|
||||
Solid,
|
||||
/// Только рёбра треугольников.
|
||||
Wireframe,
|
||||
/// Заливка с наложенным поверх каркасом.
|
||||
SolidWire,
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user