Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b66719029 | ||
|
|
dd5f519c06 |
@@ -40,13 +40,6 @@ docs/theory/solver_2x_sdf/out/
|
|||||||
# ноутбук kbc_lbm.ipynb их встраивает, и без них он нечитаем на свежем клоне
|
# ноутбук kbc_lbm.ipynb их встраивает, и без них он нечитаем на свежем клоне
|
||||||
# (перегенерация требует GPU-сервера). См. заметку о них в README ветки research.
|
# (перегенерация требует GPU-сервера). См. заметку о них в README ветки research.
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Rust (rust/ — порт редактора)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Каталог сборки cargo: объектники, зависимости, инкрементальный кэш.
|
|
||||||
target/
|
|
||||||
# Cargo.lock для приложения коммитят (воспроизводимость сборки) — он НЕ игнорируется.
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Редакторы и ОС
|
# Редакторы и ОС
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -11,18 +11,16 @@ reference grid planes through the origin, with `.obj` model loading and display.
|
|||||||
at the origin.
|
at the origin.
|
||||||
* `.obj` loading with three display modes — solid (flat-shaded), wireframe, and
|
* `.obj` loading with three display modes — solid (flat-shaded), wireframe, and
|
||||||
solid + wireframe overlay.
|
solid + wireframe overlay.
|
||||||
* ImGui interface — two windows, titled `Viewport` and `Mesh`.
|
* ImGui interface (Viewport controls + Mesh load panel).
|
||||||
|
|
||||||
All Vulkan code is isolated in `src/vk/`; the rest of the app is Vulkan-free.
|
All Vulkan code is isolated in `src/vk/`; the rest of the app is Vulkan-free.
|
||||||
|
|
||||||
## Targets
|
## Targets
|
||||||
|
|
||||||
* Windows x64 (MSVC)
|
* Windows x64 (MSVC / clang-cl)
|
||||||
* Linux x64 (gcc / clang)
|
* Linux x64 (gcc / clang)
|
||||||
* macOS arm64 (Apple Silicon, via MoltenVK)
|
* macOS arm64 (Apple Silicon, via MoltenVK)
|
||||||
|
|
||||||
The presets do not pin a compiler — each inherits whatever the environment provides.
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
* Vulkan SDK 1.3.290+ (`https://vulkan.lunarg.com/`)
|
* Vulkan SDK 1.3.290+ (`https://vulkan.lunarg.com/`)
|
||||||
@@ -30,10 +28,6 @@ The presets do not pin a compiler — each inherits whatever the environment pro
|
|||||||
* Ninja
|
* Ninja
|
||||||
* C++20 compiler (MSVC 19.36+, gcc 11+, clang 14+)
|
* 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
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -56,10 +50,8 @@ ctest --preset windows-msvc-debug
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
core/ Logger, Window, App (orchestration, Vulkan-free)
|
core/ Logger, Window, App (orchestration, Vulkan-free)
|
||||||
vk/ All Vulkan: Context, Swapchain, Shader, GpuMesh,
|
vk/ All Vulkan: Context, Swapchain, Buffer, Image, Shader, GpuMesh,
|
||||||
Renderer (frame loop + depth + ImGui), MeshRenderer, GridRenderer
|
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)
|
mesh/ CPU mesh: tinyobjloader + welding (no Vulkan)
|
||||||
editor/ Camera, mouse input, EditorUI + MeshLoadPanel (ImGui, no Vulkan)
|
editor/ Camera, mouse input, EditorUI + MeshLoadPanel (ImGui, no Vulkan)
|
||||||
app/ main.cpp
|
app/ main.cpp
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
# Геометрические ядра САПР: что доступно и что брать
|
||||||
|
|
||||||
|
Документ по задаче #5. Цель — выбрать геометрическое ядро, поверх которого строится
|
||||||
|
САПР-часть CFDManager, и обосновать выбор, а не объявить его.
|
||||||
|
|
||||||
|
Сейчас в дереве нет ничего, что можно было бы назвать ядром: `simv_mesh` читает `.obj`
|
||||||
|
через tinyobjloader, сваривает вершины и считает AABB. Ни B-rep, ни NURBS, ни булевых
|
||||||
|
операций, ни параметрических форматов. Всё это придётся взять снаружи — своё ядро
|
||||||
|
пишется годами и не является предметом этого проекта.
|
||||||
|
|
||||||
|
## Требования и как они проверялись
|
||||||
|
|
||||||
|
Пять требований заданы в задаче, шестое добавлено здесь, потому что без него выбор
|
||||||
|
некорректен.
|
||||||
|
|
||||||
|
| № | Требование | Как проверялось |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Лёгкость и оптимизированность | сборка и запуск на этой машине, вес артефактов |
|
||||||
|
| 2 | Rust-стек или доступное для Rust API | наличие крейта/биндингов, собрал и запустил |
|
||||||
|
| 3 | Базовое редактирование 3D (аналог Plasticity) | наличие булевых, фасок, скруглений, выдавливания |
|
||||||
|
| 4 | NURBS | какие поверхности представимы, что уходит в STEP |
|
||||||
|
| 5 | Стандартные параметрические и полигональные форматы | STEP/IGES на запись **и чтение**, STL/OBJ/glTF |
|
||||||
|
| 6 | **Лицензия** | текст лицензии и то, что она требует при поставке продукта |
|
||||||
|
|
||||||
|
Шестое требование добавлено по следующей причине. Ядро — не библиотека утилит, его
|
||||||
|
нельзя заменить за неделю; лицензионное условие, замеченное после того, как на ядре
|
||||||
|
написан год работы, стоит переписывания продукта. Поэтому GPL-ядра и ядра
|
||||||
|
с некоммерческой лицензией разбираются здесь наравне с техникой, а не в сноске.
|
||||||
|
|
||||||
|
Измерения выполнены на машине разработки: Windows 11, MSVC 19.37.32825 (VS 2022
|
||||||
|
Community), CMake 4.3.2, Ninja 1.13.2, Rust 1.97.1, сборки release. Числа, взятые
|
||||||
|
из внешних источников, помечены явно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Группа 1. Открытый исходный код
|
||||||
|
|
||||||
|
### Open CASCADE Technology (OCCT)
|
||||||
|
|
||||||
|
Единственное зрелое открытое B-rep/NURBS ядро промышленного класса. Версия **8.0.1**
|
||||||
|
от 30 июля 2026; семь модулей (Foundation Classes, Modeling Data, Modeling Algorithms,
|
||||||
|
Visualization, Data Exchange, Application Framework, Mesh).
|
||||||
|
|
||||||
|
**Форматы:** STEP (AP203, AP214, AP242), IGES до 5.3, STL, OBJ, glTF 2.0, VRML 1.0 —
|
||||||
|
на чтение и запись. ACIS, Parasolid, DXF, IFC, JT — **только через платные компоненты**
|
||||||
|
Open Cascade, в открытую поставку не входят.
|
||||||
|
|
||||||
|
**Лицензия — LGPL 2.1 с исключением, и исключение не то, за которое его обычно
|
||||||
|
принимают.** Полный текст `OCCT_LGPL_EXCEPTION.txt` разрешает ровно одно: включать
|
||||||
|
в объектный код материал из заголовочных файлов библиотеки (инлайн-функции, шаблоны,
|
||||||
|
CDL-классы) и распространять такой объектный код на своих условиях — при заметном
|
||||||
|
уведомлении, что продукт использует OCCT. Про линковку исключение не говорит **ничего**.
|
||||||
|
|
||||||
|
Практический вывод: динамическая линковка — штатный путь, обязанности сводятся
|
||||||
|
к уведомлению и к тому, что пользователь может подменить библиотеку. Статическая
|
||||||
|
линковка остаётся под LGPL 2.1 §6 и требует выдать объектные файлы приложения, чтобы
|
||||||
|
пользователь мог пересобрать его с изменённым OCCT. Это важно, потому что
|
||||||
|
`opencascade-rs` со своим режимом `builtin` собирает OCCT **статически**
|
||||||
|
(`BUILD_LIBRARY_TYPE=Static` в вызове CMake) — то есть самый удобный путь сборки
|
||||||
|
оказывается как раз тем, который для проприетарной поставки неудобен.
|
||||||
|
|
||||||
|
Динамический путь существует: без `builtin` обвязка ищет **предустановленную** OCCT
|
||||||
|
и, если та собрана с `BUILD_SHARED_LIBS=ON`, линкуется к ней как `dylib`
|
||||||
|
(`crates/opencascade-sys/build.rs:43`). Но там же, строкой ниже, стоит жёсткая
|
||||||
|
проверка версии: требуется мажор ровно **7** и минор не ниже 8, иначе сборка
|
||||||
|
паникует. Поставить в системе свежую OCCT 8.0.1 и слинковаться с ней **не получится** —
|
||||||
|
обвязка её отвергнет.
|
||||||
|
|
||||||
|
### opencascade-rs — путь к OCCT из Rust
|
||||||
|
|
||||||
|
Крейты `opencascade` / `opencascade-sys` версии 0.3.0 (обновлены 24 августа 2026),
|
||||||
|
биндинги через `cxx`, лицензия LGPL-2.1. Форк `bschwind` живой, 263 звезды; форк
|
||||||
|
`mkovaxx` последний раз трогали в октябре 2025 — его рассматривать не стоит.
|
||||||
|
|
||||||
|
API высокоуровневый и на вид приятный:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let my_box = Shape::box_with_dimensions(10.0, 10.0, 1.0);
|
||||||
|
let another = Shape::box_with_dimensions(1.0, 1.0, 0.8);
|
||||||
|
my_box.subtract(&another).chamfer(0.07)
|
||||||
|
```
|
||||||
|
|
||||||
|
Есть `write_step` / `write_iges` / `write_stl`, `read_step`, `mesh()`
|
||||||
|
с настраиваемым допуском, фаски, скругления, выдавливание, заметание по траектории.
|
||||||
|
|
||||||
|
**Существенное отставание:** `occt-sys` зафиксирован на OCCT **7.8.1**, тогда как
|
||||||
|
апстрим уже 8.0.1. Это не косметика — между ними мажорный релиз, и всё, что исправлено
|
||||||
|
в 7.9 и 8.0, в этом пути недоступно, пока обвязку не обновят. Причём 8.0 заявлен
|
||||||
|
командой OCCT3D (Capgemini Engineering) как релиз с ломающими изменениями API,
|
||||||
|
чисткой интерфейсов и переработкой булевых операций, тесселяции и обработки допусков —
|
||||||
|
то есть обновление обвязки будет не механическим, и ждать его быстро не стоит.
|
||||||
|
|
||||||
|
### truck — чистый Rust
|
||||||
|
|
||||||
|
Единственный живой чисто-растовый B-rep/NURBS кернел. Apache-2.0, 1559 звёзд,
|
||||||
|
коммиты в репозитории вплоть до 7 сентября 2026 — **но крейты на crates.io не
|
||||||
|
обновлялись с 20 сентября 2024** (`truck-modeling` 0.6.0). Два года git-активности
|
||||||
|
без релиза — характерный признак: пользоваться придётся либо старым релизом,
|
||||||
|
либо git-зависимостью.
|
||||||
|
|
||||||
|
Умеет: NURBS-геометрию, топологию (вершины/рёбра/грани/оболочки/тела), булевы операции
|
||||||
|
(`truck-shapeops`), тесселяцию (`truck-meshalgo`), чтение и запись STEP
|
||||||
|
(`truck-stepio`, есть и `in`, и `out`), полигональные форматы через `truck-polymesh`.
|
||||||
|
|
||||||
|
### Остальные открытые — отсев с причиной
|
||||||
|
|
||||||
|
| Проект | Состояние | Почему не подходит |
|
||||||
|
|---|---|---|
|
||||||
|
| **Fornjot** | архивирован 19 июня 2026 | Автор закрыл проект: «This project has been shut down. Its goals were never reached», код read-only, пригоден лишь для «very simple models» |
|
||||||
|
| **SolveSpace** | живой, 4163★, GPL-3.0 | GPLv3 несовместим с проприетарной поставкой; NURBS-булевы, по признанию самих авторов, медленные, ограничены поверхностями выдавливания и вращения и «not too robust even then» |
|
||||||
|
| **BRL-CAD** | живой, 1029★ | CSG-ориентированное ядро с Tcl-инфраструктурой, не про интерактивное NURBS-моделирование |
|
||||||
|
| **openNURBS** | живой, 562★ | Чтение/запись `.3dm` и NURBS-геометрия, **но моделирования нет**: ни булевых, ни фасок |
|
||||||
|
| **curvo** | живой, MIT, 0.1.91 | NURBS-кривые и поверхности как математика, не B-rep ядро |
|
||||||
|
| **Manifold** | живой, Apache-2.0, есть крейт `manifold3d` 0.4.1 (обновлён 9 сентября 2026) | Быстрые и надёжные булевы, но по **полигональным сеткам**; NURBS и B-rep отсутствуют как понятие. Полезен позже — для подготовки геометрии к расчётной сетке, не как ядро |
|
||||||
|
| **CGAL** | живой | Вычислительная геометрия, не САПР-ядро; ядро GPL с платной альтернативой |
|
||||||
|
|
||||||
|
Отдельно стоит отметить `libslvs` из SolveSpace: это **решатель геометрических
|
||||||
|
ограничений**, то есть ровно та подсистема, которой нет ни у OCCT, ни у truck.
|
||||||
|
К выбору ядра он отношения не имеет, но к параметрике — прямое (см. раздел о том,
|
||||||
|
что придётся писать самим).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Группа 2. Закрытый код, бесплатные и некоммерческие лицензии
|
||||||
|
|
||||||
|
Задача изначально говорила про открытые ядра; рамка расширена владельцем проекта.
|
||||||
|
Результат этой группы отрезвляющий: **бесплатного входа в промышленное ядро нет ни
|
||||||
|
у одного из четырёх.**
|
||||||
|
|
||||||
|
### C3D Modeler (C3D Labs)
|
||||||
|
|
||||||
|
Единственное коммерческое геометрическое ядро российской разработки, одно из пяти
|
||||||
|
лицензируемых ядер в мире. B-rep с NURBS, обмен со STEP, IGES, Parasolid, ACIS,
|
||||||
|
полигональными форматами. API — C++ с C-обёрткой; Rust-биндингов нет, их пришлось бы
|
||||||
|
писать самим.
|
||||||
|
|
||||||
|
Публично доступна **только 90-дневная пробная версия** с техподдержкой. Бесплатной
|
||||||
|
некоммерческой или академической лицензии в открытом доступе найти не удалось:
|
||||||
|
у компании есть образовательные партнёрства с вузами, хакатоны и стажировки, но
|
||||||
|
лицензия под них публично не описана и выдаётся по договорённости. Условия
|
||||||
|
коммерческой лицензии компания называет гибкими и определяет индивидуально; цифра
|
||||||
|
в 1 750 000 ₽ в год за лицензию разработчика плюс роялти встречается во вторичных
|
||||||
|
источниках и **здесь не проверена** — уточнять её нужно у самой C3D Labs.
|
||||||
|
|
||||||
|
### Parasolid (Siemens)
|
||||||
|
|
||||||
|
Ядро, на котором построена названная в задаче Plasticity, а также SolidWorks, NX и
|
||||||
|
десятки других систем. Эталон по надёжности булевых и качеству поверхностей.
|
||||||
|
|
||||||
|
Лицензируется коммерчески; публичных условий, прайса, бесплатного или академического
|
||||||
|
тарифа в открытом доступе нет — вход только через переговоры с Siemens, и по отзывам
|
||||||
|
разработчиков процесс и суммы для небольших проектов оказываются заградительными.
|
||||||
|
Rust-биндингов нет.
|
||||||
|
|
||||||
|
### 3D ACIS Modeler и CGM Modeler (Spatial / Dassault Systèmes)
|
||||||
|
|
||||||
|
Второе историческое коммерческое ядро (ACIS, с конца 1980-х) и ядро CATIA (CGM).
|
||||||
|
Технически полноценные B-rep/NURBS ядра. Ситуация с лицензией та же, что у Parasolid:
|
||||||
|
публичных бесплатных или академических условий нет, пробный доступ и академические
|
||||||
|
программы — по запросу. Rust-биндингов нет.
|
||||||
|
|
||||||
|
### Zoo Design API (бывш. KittyCAD)
|
||||||
|
|
||||||
|
Самый близкий к требованию 2 вариант: проприетарное ядро, **написанное на Rust**,
|
||||||
|
GPU-native, выдаёт настоящий B-rep. Есть официальный Rust-клиент `kittycad.rs`,
|
||||||
|
открытые Design Studio и язык KCL.
|
||||||
|
|
||||||
|
Но это **облачный сервис**, а не библиотека: геометрия считается на их серверах,
|
||||||
|
тарификация поминутная, бесплатно даётся 20 минут API (баланс $10), дальше
|
||||||
|
pay-as-you-go. Для настольного САПР с интерактивным редактированием это означает
|
||||||
|
зависимость от сети, задержку на каждую операцию и оплату за использование, которая
|
||||||
|
растёт вместе с числом пользователей. Для проекта, который сам по себе считает
|
||||||
|
гидродинамику локально, такая схема противоречива.
|
||||||
|
|
||||||
|
### Обмен форматами отдельно
|
||||||
|
|
||||||
|
CAD Exchanger SDK, HOOPS Exchange, ODA — не ядра моделирования, а конвертеры.
|
||||||
|
Они закрывают требование 5 там, где ядро не справляется (DWG, JT, CATIA, NX), и
|
||||||
|
имеют смысл как **дополнение** к выбранному ядру, если такие форматы понадобятся.
|
||||||
|
Все три коммерческие. Для OCCT, который сам читает STEP и IGES, необходимости
|
||||||
|
в них на текущем этапе нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сводная таблица
|
||||||
|
|
||||||
|
Обозначения: ● полностью, ◐ частично, ○ нет.
|
||||||
|
|
||||||
|
| Кандидат | 1. Лёгкость | 2. Rust | 3. Редактирование | 4. NURBS | 5. Форматы | 6. Лицензия и её риск |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **OCCT + opencascade-rs** | ○ 2,7 ГБ сборки, exe 19,4 МБ | ◐ биндинги есть, но на Windows не собираются без патчей | ● булевы, фаски, скругления | ● полноценно, аналитика сохраняется | ● STEP/IGES/STL/OBJ/glTF | LGPL-2.1+искл.: динамическая линковка — ок, статическая обязывает |
|
||||||
|
| **truck** | ● 98 с сборки, exe 1,6 МБ | ● родной, собрался с первой попытки | ◐ булевы есть (в 4–6 раз медленнее), набор беднее | ◐ есть, но аналитика уходит в B-сплайны | ◐ STEP r/w, полигоны; IGES нет | Apache-2.0 — риска нет |
|
||||||
|
| Fornjot | — | ● | ○ | ○ | ○ | архивирован |
|
||||||
|
| SolveSpace | ● | ○ | ◐ | ◐ слабые булевы | ◐ | **GPL-3.0 — стоп для проприетарного** |
|
||||||
|
| openNURBS | ● | ○ | ○ | ● | ◐ только 3dm | своя, свободная |
|
||||||
|
| Manifold | ● | ● крейт `manifold3d` | ◐ только сетки | ○ | ◐ | Apache-2.0 — риска нет |
|
||||||
|
| C3D Modeler | ◐ | ○ писать самим | ● | ● | ● | 90 дней, дальше платно |
|
||||||
|
| Parasolid | ◐ | ○ | ● эталон | ● | ● | платно, условий публично нет |
|
||||||
|
| ACIS / CGM | ◐ | ○ | ● | ● | ● | платно, условий публично нет |
|
||||||
|
| Zoo Design API | ● клиент лёгкий | ● родной Rust | ● | ● | ● | облако, поминутная оплата |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Измерения
|
||||||
|
|
||||||
|
Всё в этом разделе измерено здесь, а не переписано из чужих статей.
|
||||||
|
|
||||||
|
### truck
|
||||||
|
|
||||||
|
Зонд: два куба → булево объединение → тесселяция → экспорт OBJ и STEP. Написан
|
||||||
|
по документации и **скомпилировался с первой попытки без правок** — показатель того,
|
||||||
|
что API соответствует документации.
|
||||||
|
|
||||||
|
| Что | Значение |
|
||||||
|
|---|---|
|
||||||
|
| холодная сборка release | **98,6 с** |
|
||||||
|
| пакетов в графе зависимостей | 103 |
|
||||||
|
| исходники самих крейтов truck | 1,9 МБ, 233 файла, 10 крейтов |
|
||||||
|
| размер `target/` | 446,8 МБ |
|
||||||
|
| исполняемый файл зонда | 1,60 МБ |
|
||||||
|
| построение двух кубов | 116 мкс |
|
||||||
|
| булево объединение (плоскости) | 1,72 мс |
|
||||||
|
| тесселяция результата | 340 мкс, 60 вершин / 36 граней |
|
||||||
|
| экспорт STEP | 445 мкс, 17,6 КБ |
|
||||||
|
| экспорт OBJ | 637 мкс, 8,8 КБ |
|
||||||
|
|
||||||
|
Второй зонд — на кривых поверхностях: цилиндр (через вращательное заметание) против
|
||||||
|
куба, пересечение и объединение.
|
||||||
|
|
||||||
|
| Что | Значение |
|
||||||
|
|---|---|
|
||||||
|
| булево пересечение цилиндр ∩ куб | **32,6 мс** (в 19 раз дороже плоскостного) |
|
||||||
|
| булево объединение цилиндр ∪ куб | 29,5 мс |
|
||||||
|
| результат тесселируется | да, 84 вершины |
|
||||||
|
|
||||||
|
Третий зонд — чтение STEP обратно: файл, записанный первым зондом, разобран за
|
||||||
|
**11,1 мс**, из него восстановлены 1 оболочка, 12 граней и 18 плоскостей. То есть
|
||||||
|
круг «записал — прочитал» у truck замкнут. Мелочь, характеризующая зрелость API:
|
||||||
|
`Table::from_step` возвращает `Option`, а не `Result`, поэтому причина неудачного
|
||||||
|
разбора вызывающему не сообщается вовсе.
|
||||||
|
|
||||||
|
Обе булевы операции отработали — это лучше, чем можно было ожидать от ядра такой
|
||||||
|
зрелости. Но экспорт цилиндра в STEP показал существенное: файл содержит `B_SPLINE_SURFACE` и
|
||||||
|
`PLANE`, и **не содержит `CYLINDRICAL_SURFACE`**. То есть аналитические поверхности
|
||||||
|
truck не сохраняет, всё сводится к сплайнам. Файл валиден, но принимающая система
|
||||||
|
получает вместо цилиндра сплайновую аппроксимацию: хуже для распознавания элементов,
|
||||||
|
хуже для точности, тяжелее.
|
||||||
|
|
||||||
|
### OCCT через opencascade-rs
|
||||||
|
|
||||||
|
Сборка этого пути оказалась мерой сама по себе — она упёрлась в три препятствия
|
||||||
|
подряд, и каждое стоит знать заранее.
|
||||||
|
|
||||||
|
1. **CMake 4.x против OCCT 7.8.1.** `occt-sys` собирает OCCT 7.8.1, чей
|
||||||
|
`cmake_minimum_required` объявляет совместимость ниже 3.5. В CMake 4.3.2 такая
|
||||||
|
совместимость удалена, и конфигурация падает сразу:
|
||||||
|
«Compatibility with CMake < 3.5 has been removed from CMake». Обходится
|
||||||
|
переменной окружения `CMAKE_POLICY_VERSION_MINIMUM=3.5`. В OCCT 8.0.1 этой
|
||||||
|
проблемы нет — но обвязка на него не переведена.
|
||||||
|
2. **Генератор Visual Studio.** Крейт `cmake` по умолчанию берёт генератор
|
||||||
|
«Visual Studio 17 2022», который на этой машине не отработал: установлены
|
||||||
|
и Build Tools 2026, и VS 2022 Community. Лечится `CMAKE_GENERATOR=Ninja`
|
||||||
|
из среды `vcvars64.bat`.
|
||||||
|
3. **Длина пути Windows.** Дерево OCCT глубокое, и в каталоге с длинным путём
|
||||||
|
сборка предупреждает про `CMAKE_OBJECT_PATH_MAX` (187 символов при пределе 250)
|
||||||
|
и падает. Понадобился короткий путь вида `C:\occprobe`.
|
||||||
|
|
||||||
|
После того как все три обойдены, сборка идёт и **само ядро OCCT собирается успешно**.
|
||||||
|
А потом падает компиляция cxx-мостов — тремя однотипными ошибками в собственных
|
||||||
|
заголовках обвязки:
|
||||||
|
|
||||||
|
```
|
||||||
|
b_rep.hxx(16): error C2440: невозможно преобразовать
|
||||||
|
"opencascade::handle<Geom_Surface> *" в "std::unique_ptr<Handle_Geom_Surface, ...>"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Что | Значение |
|
||||||
|
|---|---|
|
||||||
|
| время до провала | **36 мин 09 с** (01:59:43 → 02:35:52) |
|
||||||
|
| объём `target/` к этому моменту | ~1,0 ГБ |
|
||||||
|
| исходники OCCT (`occt-sys` 7.8.1) | 87 МБ, 14 984 файла; скачиваемый архив 13,7 МБ |
|
||||||
|
| ошибок компиляции | 3, все в `b_rep.hxx` |
|
||||||
|
| собралось ли ядро OCCT | **да** |
|
||||||
|
| собрались ли биндинги | **нет** |
|
||||||
|
|
||||||
|
Причина — коллизия имён: обвязка объявляет собственные типы `Handle_Geom_Surface`
|
||||||
|
и им подобные, которые под MSVC сталкиваются с одноимёнными типами самого OCCT.
|
||||||
|
Это **известная незакрытая проблема проекта**, а не особенность этой машины:
|
||||||
|
PR #196 «Handle name conflict to fix Windows build» был влит ещё в январе 2025,
|
||||||
|
PR #230 «Fix MSVC handle name collisions» от 19 августа 2026 — **закрыт, не влит**,
|
||||||
|
а PR #216 «Fix Windows build» открыт с октября 2025 и всё ещё не принят.
|
||||||
|
|
||||||
|
Проверка обхода: патч из непринятого PR #230 (переименование `Handle_*` →
|
||||||
|
`RustHandle_*`, 26 файлов) применяется к текущему master чисто — `git apply` проходит
|
||||||
|
без конфликтов. После него мосты компилируются, **но сборка падает уже на линковке**:
|
||||||
|
23 неразрешённых символа, среди них `InitializeAcl` и `InitializeSecurityDescriptor`,
|
||||||
|
то есть не подключены системные библиотеки Windows. Это ровно предмет второго,
|
||||||
|
до сих пор **открытого** PR #216 — и наложить его поверх первого не получается,
|
||||||
|
два непринятых патча конфликтуют между собой в `build.rs`. Понадобилось дописать
|
||||||
|
четыре строки вручную:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
println!("cargo:rustc-link-lib=dylib=advapi32");
|
||||||
|
println!("cargo:rustc-link-lib=dylib=gdi32");
|
||||||
|
println!("cargo:rustc-link-lib=dylib=shell32");
|
||||||
|
println!("cargo:rustc-link-lib=dylib=comdlg32");
|
||||||
|
```
|
||||||
|
|
||||||
|
**После этого всё собралось и заработало.** Итого, чтобы получить рабочий
|
||||||
|
OCCT-из-Rust на Windows, потребовались: три настройки окружения, один непринятый
|
||||||
|
PR и четыре строки своей правки.
|
||||||
|
|
||||||
|
| Что | Значение |
|
||||||
|
|---|---|
|
||||||
|
| холодная сборка до провала (без патчей) | 36 мин 09 с |
|
||||||
|
| пересборка после патчей (ядро уже собрано) | 1 мин 45 с |
|
||||||
|
| исходники OCCT (`occt-sys` 7.8.1) | 87 МБ, 14 984 файла; архив 13,7 МБ |
|
||||||
|
| размер `target/` | ~2,7 ГБ |
|
||||||
|
| исполняемый файл зонда | **19,4 МБ** (против 1,60 МБ у truck) |
|
||||||
|
|
||||||
|
Работа зонда: куб 10×10×10 минус куб 6×6×6, фаска 0,5, тесселяция, три экспорта
|
||||||
|
и чтение STEP обратно.
|
||||||
|
|
||||||
|
| Операция | Время |
|
||||||
|
|---|---|
|
||||||
|
| два примитива | 503 мкс |
|
||||||
|
| булево вычитание | 7,46 мс |
|
||||||
|
| фаска по всем рёбрам | 20,2 мс |
|
||||||
|
| тесселяция | 12,1 мс, 174 вершины / 86 треугольников |
|
||||||
|
| экспорт STEP | 25,4 мс, 110,8 КБ, 2378 сущностей |
|
||||||
|
| экспорт IGES | 14,6 мс, 87,3 КБ |
|
||||||
|
| экспорт STL | 15,2 мс, 22,4 КБ |
|
||||||
|
| чтение STEP обратно | 48,4 мс, те же 174 вершины |
|
||||||
|
| всего | 145 мс |
|
||||||
|
|
||||||
|
### Прямое сравнение на одной задаче
|
||||||
|
|
||||||
|
Чтобы сравнение было честным, обоим ядрам дана одна и та же работа: цилиндр
|
||||||
|
радиуса 2 высоты 4, пересечение и объединение с кубом 1,5, затем запись цилиндра
|
||||||
|
в STEP.
|
||||||
|
|
||||||
|
| | truck | OCCT |
|
||||||
|
|---|---|---|
|
||||||
|
| цилиндр ∩ куб | 32,6 мс | **7,2 мс** |
|
||||||
|
| цилиндр ∪ куб | 29,5 мс | **4,7 мс** |
|
||||||
|
| тесселяция результата | 84 вершины | 38 вершин |
|
||||||
|
| STEP цилиндра, размер | 13 281 байт | **5 652 байта** |
|
||||||
|
| боковая поверхность в STEP | 9 × `B_SPLINE_SURFACE` | **1 × `CYLINDRICAL_SURFACE`** |
|
||||||
|
|
||||||
|
Последняя строка — самая содержательная во всём документе. Там, где OCCT пишет
|
||||||
|
одну аналитическую цилиндрическую поверхность, truck пишет девять сплайновых
|
||||||
|
заплаток: файл вдвое толще, а принимающая система видит не цилиндр, а его
|
||||||
|
аппроксимацию. Булевы операции при этом у OCCT в 4–6 раз быстрее.
|
||||||
|
|
||||||
|
Мелкая, но заметная деталь эксплуатации: OCCT при записи STEP печатает в stdout
|
||||||
|
собственную статистику трансфера («Statistics on Transfer», рамки из звёздочек).
|
||||||
|
В библиотеке, встраиваемой в приложение с интерфейсом, этот вывод придётся глушить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендация
|
||||||
|
|
||||||
|
**Основной вариант — Open CASCADE Technology, из Rust через `opencascade-rs`.**
|
||||||
|
|
||||||
|
Причины, по порядку веса:
|
||||||
|
|
||||||
|
1. **Это единственное открытое ядро, которое закрывает требования 3, 4 и 5
|
||||||
|
целиком.** Всё остальное открытое либо не умеет моделировать (openNURBS, curvo,
|
||||||
|
Manifold), либо умеет плохо и под GPL (SolveSpace), либо закрыто (Fornjot).
|
||||||
|
2. **Требование 2 выполняется без написания биндингов.** `opencascade-rs` даёт
|
||||||
|
готовый Rust-API с булевыми, фасками, тесселяцией и обменом форматами.
|
||||||
|
3. **Требование 5 закрывается из коробки:** STEP AP242 и IGES на чтение и запись —
|
||||||
|
это то, чем САПР обменивается с внешним миром, и ни truck, ни полигональные
|
||||||
|
библиотеки этого в таком объёме не дают.
|
||||||
|
4. Зрелость: ядро развивается с 1990-х; на нём построены FreeCAD (его B-rep/NURBS
|
||||||
|
слой — это OCCT), CadQuery и 3D-часть KiCad.
|
||||||
|
|
||||||
|
Цена решения, названная честно и **измеренная**: ядро тяжёлое (87 МБ исходников,
|
||||||
|
2,7 ГБ сборочного каталога, 19,4 МБ исполняемый файл против 1,6 МБ у truck), это
|
||||||
|
C++ с версией 7.8.1 в текущей обвязке, а сама обвязка **на Windows из коробки не
|
||||||
|
собирается**: понадобились три настройки окружения, патч из непринятого PR #230 и
|
||||||
|
четыре строки правки `build.rs`. Плюс лицензия, обязывающая к динамической линковке
|
||||||
|
при проприетарной поставке — а динамический путь требует системной OCCT ветки 7.x.
|
||||||
|
|
||||||
|
Это не аргумент против выбора, но это работа, которую придётся сделать до того, как
|
||||||
|
будет написана первая строка САПР-логики, и её лучше запланировать явно. Разумный
|
||||||
|
первый шаг после утверждения — **форк `opencascade-rs`** с наложенными исправлениями:
|
||||||
|
иначе каждый разработчик проекта будет проходить этот путь заново, а обновление
|
||||||
|
апстрима будет ломать сборку.
|
||||||
|
|
||||||
|
**Запасной вариант — truck**, и он не запасной «на всякий случай», а по конкретному
|
||||||
|
сценарию: если проект останется целиком на Rust и согласится на собственную
|
||||||
|
реализацию недостающего. truck легче, собирается за полторы минуты, не тянет C++
|
||||||
|
и не создаёт лицензионных обязанностей вовсе (Apache-2.0). Но за это придётся
|
||||||
|
заплатить отсутствием IGES, потерей аналитических поверхностей при экспорте,
|
||||||
|
более скудным набором операций и работой с крейтами двухлетней давности либо
|
||||||
|
git-зависимостью.
|
||||||
|
|
||||||
|
**Отдельно про требование 3.** В задаче эталоном названа Plasticity — она построена
|
||||||
|
на Parasolid, и это надо понимать буквально: уровня её прямого моделирования
|
||||||
|
(push/pull по грани с мгновенной перестройкой тела) ни одно открытое ядро само по
|
||||||
|
себе не даёт. OCCT предоставляет операции, из которых такой режим собирается, но
|
||||||
|
собирать его придётся самим.
|
||||||
|
|
||||||
|
**Про закрытые ядра.** Ни одно из четырёх не даёт бесплатного пути в продукт:
|
||||||
|
C3D — 90 дней, Parasolid и ACIS/CGM — только переговоры, Zoo — поминутная оплата
|
||||||
|
и облако. Возвращаться к ним осмысленно тогда, когда у проекта появится бюджет
|
||||||
|
и станет ясно, что именно в OCCT не устраивает: менять ядро на этапе прототипа
|
||||||
|
дороже, чем позже, когда требования известны точно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что придётся писать самим поверх любого ядра
|
||||||
|
|
||||||
|
Это не недостаток конкретного кандидата, а общее свойство: ядро даёт геометрию,
|
||||||
|
а не САПР.
|
||||||
|
|
||||||
|
- **Решатель геометрических ограничений.** Ни OCCT, ни truck его не содержат.
|
||||||
|
Без него нет параметрического эскиза. Кандидаты: `libslvs` из SolveSpace
|
||||||
|
(GPL-3.0 — то же лицензионное ограничение, что и у самого SolveSpace),
|
||||||
|
либо собственная реализация.
|
||||||
|
- **Дерево построения и история операций.** Ядро выполняет операции, но не помнит,
|
||||||
|
в каком порядке и от чего они зависели.
|
||||||
|
- **Топологические имена.** Устойчивая идентификация граней и рёбер между
|
||||||
|
перестроениями — классическая трудная задача, ядром не решается.
|
||||||
|
- **Связь с расчётной частью.** Для CFD нужна сетка, а не B-rep: понадобится
|
||||||
|
контролируемая тесселяция и, вероятно, воксельное или SDF-представление,
|
||||||
|
согласованное с решателем KBC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что нужно от владельца проекта
|
||||||
|
|
||||||
|
1. **Утвердить ядро** — критерий готовности задачи #5 сформулирован именно так.
|
||||||
|
2. **Решить вопрос линковки**, если продукт будет проприетарным: удобный путь
|
||||||
|
(`builtin`) даёт статическую сборку OCCT, а динамический требует предустановленной
|
||||||
|
в системе OCCT ветки 7.8/7.9 — версию 8.x обвязка отвергает. Выбор: принять
|
||||||
|
обязанности LGPL 2.1 §6, держать системную 7.x, либо править обвязку под себя.
|
||||||
|
3. **Согласиться на форк `opencascade-rs`** (или явно отказаться): без него сборка
|
||||||
|
на Windows воспроизводится только вручную, по шагам из раздела измерений.
|
||||||
|
4. Если решено смотреть на закрытые ядра всерьёз — **запросить условия** у C3D Labs
|
||||||
|
(90-дневная пробная плюс индивидуальные условия) и у Siemens по Parasolid.
|
||||||
|
Регистрация и подписание — действия владельца, агентом они не выполняются.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Источники
|
||||||
|
|
||||||
|
- [Open CASCADE Technology — обзор и форматы](https://occt3d.com/dev/doc/overview/html/index.html)
|
||||||
|
- [FOSDEM 2026: OCCT3D 8.0 — ломающие изменения и развитие ядра](https://archive.fosdem.org/2026/schedule/event/QQRAAF-occt3d-8-kernel-evolution/)
|
||||||
|
- [OCCT_LGPL_EXCEPTION.txt — текст исключения](https://raw.githubusercontent.com/Open-Cascade-SAS/OCCT/master/OCCT_LGPL_EXCEPTION.txt)
|
||||||
|
- [Релизы OCCT (8.0.1, 30.07.2026)](https://github.com/Open-Cascade-SAS/OCCT/releases)
|
||||||
|
- [opencascade-rs](https://github.com/bschwind/opencascade-rs)
|
||||||
|
- [opencascade-rs PR #230 «Fix MSVC handle name collisions» — закрыт, не влит](https://github.com/bschwind/opencascade-rs/pull/230)
|
||||||
|
- [opencascade-rs PR #216 «Fix Windows build: …missing system libraries» — открыт](https://github.com/bschwind/opencascade-rs/pull/216)
|
||||||
|
- [truck — shape processing kernel by Rust](https://github.com/ricosjp/truck)
|
||||||
|
- [Fornjot — архив, заявление о закрытии](https://github.com/hannobraun/fornjot)
|
||||||
|
- [SolveSpace — использование как библиотеки](https://solvespace.com/library.pl)
|
||||||
|
- [openNURBS](https://github.com/mcneel/opennurbs)
|
||||||
|
- [Manifold](https://github.com/elalish/manifold)
|
||||||
|
- [curvo](https://github.com/mattatz/curvo)
|
||||||
|
- [C3D Toolkit — лицензирование](https://c3dlabs.ru/products/c3d-toolkit/licensing/)
|
||||||
|
- [C3D Toolkit — тестовая версия](https://c3dlabs.ru/evaluation/)
|
||||||
|
- [Zoo Design API](https://zoo.dev/design-api)
|
||||||
|
- [Zoo — тарифы API](https://zoo.dev/api-pricing)
|
||||||
|
- [kittycad.rs — Rust-клиент Zoo](https://github.com/KittyCAD/kittycad.rs)
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
# 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 тянет девять библиотек по фиксированным тегам и не имеет общего кэша: каждый
|
|
||||||
свежий каталог сборки скачивает их заново.
|
|
||||||
|
|
||||||
Выбор языка — за вами. Этот документ фиксирует только то, что удалось измерить и показать
|
|
||||||
на коде.
|
|
||||||
@@ -31,10 +31,8 @@
|
|||||||
## Где какая математика (для ревизии)
|
## Где какая математика (для ревизии)
|
||||||
|
|
||||||
- **Столкновение** — `collision.py`. KBC-N1: `f ← f − β(2Δs + γΔh)`, где `Δs = Ps·(f−feq)` —
|
- **Столкновение** — `collision.py`. KBC-N1: `f ← f − β(2Δs + γΔh)`, где `Δs = Ps·(f−feq)` —
|
||||||
проекция на сдвиг-моменты, `γ` — энтропийный лимитер. Оператор жёстко зафиксирован:
|
проекция на сдвиг-моменты, `γ` — энтропийный лимитер. Чтобы поэкспериментировать:
|
||||||
модуль объявляет `collide = kbc_collide`, поля `cfg.collision` и реестра операторов
|
переключить `cfg.collision="bgk"`, либо добавить TRT новой функцией и зарегистрировать в `get`.
|
||||||
не существует (см. раздел «Статус» ниже). Чтобы поэкспериментировать с BGK или TRT,
|
|
||||||
придётся вводить и то и другое — это осознанно не сделано.
|
|
||||||
- **Перенос** — `streaming.py`. Чистая пул-схема; на физику влияет только корректность сдвигов.
|
- **Перенос** — `streaming.py`. Чистая пул-схема; на физику влияет только корректность сдвигов.
|
||||||
- **Силы** — `forces.py`. Обмен импульсом по линкам тела:
|
- **Силы** — `forces.py`. Обмен импульсом по линкам тела:
|
||||||
`F = Σ_links c_i (f_i^{после столкн.} + f_ī^{после стриминга})`. Второй член — из поля ПОСЛЕ
|
`F = Σ_links c_i (f_i^{после столкн.} + f_ī^{после стриминга})`. Второй член — из поля ПОСЛЕ
|
||||||
|
|||||||
Generated
-2475
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
|||||||
# Порт редактора 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
@@ -1,113 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Исполняемый файл. Здесь же живёт 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
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
//! Владелец времени жизни: окно, рендерер, камера, панели — перенос `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")
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
//! Точка входа редактора 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()
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Аналог цели 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
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
//! Общая основа редактора: журнал и окно.
|
|
||||||
//!
|
|
||||||
//! Соответствует цели `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};
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
//! Журнал с категориями, уровнями и троттлингом — перенос `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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
//! Окно — перенос `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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Аналог цели 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
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
//! Орбитальная камера — перенос `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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
//! Мышь для камеры — перенос `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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
//! Камера, ввод и панели редактора — соответствует цели `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};
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
//! Панель загрузки моделей — перенос `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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
//! Панель вьюпорта — перенос `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, "Показывать сетку");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Аналог цели 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
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
//! Сварка близких вершин — перенос `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(), "треугольник выродился и должен быть удалён");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
//! Меш на стороне процессора: чтение `.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};
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
//! Меш на стороне процессора — перенос `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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
//! Загрузка `.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 { .. }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Аналог цели 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
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
//! Компиляция 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)
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
//! Буфер с памятью из аллокатора — перенос `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) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,570 +0,0 @@
|
|||||||
//! Инстанс, устройство, очереди, аллокатор и кэш конвейеров — перенос
|
|
||||||
//! `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
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
//! Ошибки 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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
//! Меш на видеокарте — перенос `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
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
//! Опорная сетка и оси — перенос `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
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
//! Изображение с представлением — перенос `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) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
//! Весь 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};
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
//! Конвейеры модели — перенос `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) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
//! Режим отображения модели — перенос `simv::vk::RenderMode`.
|
|
||||||
|
|
||||||
/// Как рисуется загруженная модель во вьюпорте.
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
|
||||||
pub enum RenderMode {
|
|
||||||
/// Залитые треугольники с плоской заливкой по нормали грани.
|
|
||||||
#[default]
|
|
||||||
Solid,
|
|
||||||
/// Только рёбра треугольников.
|
|
||||||
Wireframe,
|
|
||||||
/// Заливка с наложенным поверх каркасом.
|
|
||||||
SolidWire,
|
|
||||||
}
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
//! Кадр целиком — перенос `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))
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
//! Шейдерные модули — перенос `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) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
//! Цепочка показа — перенос `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