Порт редактора SimVulcan на Rust

Та же программа на ash + winit + egui: орбитальная камера, три плоскости сетки,
загрузка .obj, три режима отображения, единый проход динамического рендеринга с
глубинным вложением и submit через synchronization2. Шейдеры общие с C++-версией —
build.rs компилирует их из shaders/ и вшивает SPIR-V в бинарь.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 20:58:19 +03:00
co-authored by Claude Opus 5
parent 11ff7b79b4
commit f5c9e61589
36 changed files with 6679 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
//! Владелец времени жизни: окно, рендерер, камера, панели — перенос `simv::core::App`
//! вместе с обвязкой из `main.cpp`.
//!
//! Живёт в исполняемом крейте, а не в `simv-core`, где ему соответствовал бы прообраз.
//! Причина в графе зависимостей: в C++ `simv_core` содержит `App`, который владеет
//! `vk::Renderer`, а `simv_vk` принимает `core::Window&` — то есть цели ссылаются друг
//! на друга по кругу. Сходится это лишь потому, что `simv_vk` не линкует `simv_core`, а
//! видит его заголовки через `target_include_directories`, и всё склеивается на
//! компоновке исполняемого файла. Cargo цикл между крейтами отвергает, так что `App`
//! пришлось поднять на уровень выше обоих.
//!
//! Второе отличие — сам цикл. У GLFW его пишет приложение
//! (`while (!ShouldClose()) { PollEvents(); DrawFrame(); }`), у winit цикл владеет
//! собой и зовёт приложение через `ApplicationHandler`. Кадр по-прежнему рисуется
//! непрерывно: `about_to_wait` каждый раз просит перерисовку.
use std::path::PathBuf;
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow};
use winit::window::WindowId;
use simv_core::{Window, WindowDesc};
use simv_editor::{apply_mouse_input, Camera, DisplayMode, EditorUi, MeshLoadPanel};
use simv_mesh::weld_vertices;
use simv_vk::{FrameState, RenderMode, Renderer};
/// Допуск сварки вершин после загрузки — как в C++-версии.
const WELD_TOLERANCE: f32 = 1e-4;
#[derive(Default)]
pub struct App {
window: Option<Window>,
renderer: Option<Renderer>,
camera: Camera,
editor_ui: EditorUi,
mesh_panel: MeshLoadPanel,
/// Ошибка, из-за которой цикл был прерван. Возвращается из `main` кодом выхода.
failure: Option<anyhow::Error>,
}
impl App {
/// Забрать причину аварийного завершения, если она была.
pub fn into_result(self) -> anyhow::Result<()> {
match self.failure {
Some(e) => Err(e),
None => Ok(()),
}
}
fn fail(&mut self, event_loop: &ActiveEventLoop, error: anyhow::Error) {
log::error!(target: "core", "{error:#}");
self.failure = Some(error);
event_loop.exit();
}
/// Отдать событие интерфейсу. `true` — egui забрал его себе.
fn feed_ui(&mut self, event: &WindowEvent) -> bool {
let Some(window) = self.window.as_ref() else {
return false;
};
let Some(renderer) = self.renderer.as_mut() else {
return false;
};
renderer.on_window_event(window, event)
}
fn draw(&mut self) -> anyhow::Result<()> {
let Some(window) = self.window.as_ref() else {
return Ok(());
};
let Some(renderer) = self.renderer.as_mut() else {
return Ok(());
};
// Отношение сторон внутри замыкания не спросить — рендерер там уже одолжен,
// но за кадр оно и не меняется.
let aspect = renderer.aspect_ratio();
let camera = &mut self.camera;
let editor_ui = &mut self.editor_ui;
let mesh_panel = &mut self.mesh_panel;
renderer.draw_frame(window, |ctx| {
editor_ui.draw(ctx, camera);
mesh_panel.draw(ctx);
apply_mouse_input(ctx, camera);
FrameState {
view_proj: camera.view_proj(aspect),
render_mode: to_render_mode(editor_ui.display_mode()),
grid_visible: editor_ui.grid_visible(),
}
})?;
// Готовый меш забирается ПОСЛЕ кадра. В C++ то же самое делалось прямо из
// колбэка панели, то есть из середины `DrawFrame`, уже после захвата образа
// цепочки показа: `SetMeshCpu` там дёргает `vkDeviceWaitIdle` внутри записи
// кадра. Здесь так нельзя (рендерер одолжен), и получилось честнее.
if let Some(mut mesh) = mesh_panel.take_loaded() {
weld_vertices(&mut mesh, WELD_TOLERANCE);
log::info!(
target: "mesh",
"меш готов: {} вершин, {} треугольников",
mesh.vertex_count(),
mesh.triangle_count()
);
renderer.set_mesh_cpu(&mesh)?;
// Подогнать камеру под размер модели: она перенесена в начало координат.
let radius = 0.5 * (mesh.bbox_max - mesh.bbox_min).length();
if radius > 0.0 {
camera.set_distance(radius * 2.5);
}
}
Ok(())
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return; // возврат из фона — окно уже есть
}
event_loop.set_control_flow(ControlFlow::Poll);
let window = match Window::new(event_loop, &WindowDesc::default()) {
Ok(w) => w,
Err(e) => return self.fail(event_loop, anyhow::anyhow!("не создать окно: {e}")),
};
let renderer = match Renderer::new(&window) {
Ok(r) => r,
Err(e) => {
return self.fail(event_loop, anyhow::anyhow!("не запустить рендерер: {e}"))
}
};
let mesh_dir = find_mesh_directory();
log::info!(target: "core", "каталог моделей: {}", mesh_dir.display());
self.mesh_panel.set_mesh_directory(mesh_dir);
self.window = Some(window);
self.renderer = Some(renderer);
log::info!(target: "core", "SimVulcan запущен. Закройте окно, чтобы выйти.");
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
self.feed_ui(&event);
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::RedrawRequested => {
if let Err(e) = self.draw() {
self.fail(event_loop, e);
}
}
_ => {}
}
}
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
if let Some(window) = &self.window {
window.request_redraw();
}
}
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
if let Some(renderer) = &self.renderer {
renderer.wait_idle();
}
}
}
/// Панель редактора не знает про `simv_vk` (крейт ей недоступен), поэтому разбор
/// делается здесь. Добавите вариант в [`DisplayMode`] — компилятор укажет на этот
/// `match`; в C++ на его месте `static_cast<vk::RenderMode>(int)`, который смолчит.
fn to_render_mode(mode: DisplayMode) -> RenderMode {
match mode {
DisplayMode::Solid => RenderMode::Solid,
DisplayMode::Wireframe => RenderMode::Wireframe,
DisplayMode::SolidWire => RenderMode::SolidWire,
}
}
/// Найти `assets/meshes`, поднимаясь от исполняемого файла, а затем от текущего
/// каталога.
///
/// C++-версия перебирает пять относительных путей вида `../../../../../assets/meshes`
/// от текущего каталога — то есть работает, только если запускать из каталога бинаря.
/// Здесь отсчёт идёт в первую очередь от самого бинаря, так что запуск из любого места
/// находит модели. Шейдеров этот поиск не касается: они вшиты в исполняемый файл.
fn find_mesh_directory() -> PathBuf {
let mut roots: Vec<PathBuf> = Vec::new();
if let Ok(exe) = std::env::current_exe() {
roots.extend(exe.ancestors().map(PathBuf::from));
}
if let Ok(cwd) = std::env::current_dir() {
roots.extend(cwd.ancestors().map(PathBuf::from));
}
for root in roots {
let candidate = root.join("assets").join("meshes");
if candidate.is_dir() {
return candidate;
}
}
PathBuf::from("assets/meshes")
}
+21
View File
@@ -0,0 +1,21 @@
//! Точка входа редактора SimVulcan — перенос `src/app/main.cpp`.
//!
//! В C++ здесь же собирается вся обвязка редактора: поиск каталога моделей, обработчик
//! загрузки, UI-колбэк. Всё это переехало в [`app::App`], потому что при модели
//! событий winit его методы и есть тело цикла.
mod app;
use winit::event_loop::EventLoop;
fn main() -> anyhow::Result<()> {
simv_core::logger::init();
let event_loop = EventLoop::new()?;
let mut app = app::App::default();
event_loop.run_app(&mut app)?;
// Ошибка, из-за которой цикл прервался, возвращается сюда: в отличие от
// C++-версии её не приходится ловить `catch (...)` — она пришла значением.
app.into_result()
}