Порт редактора 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
+15
View File
@@ -0,0 +1,15 @@
# Аналог цели simv_editor: камера, ввод и панели. Vulkan не видит.
[package]
name = "simv-editor"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Орбитальная камера и панели редактора на egui"
[dependencies]
simv-core.workspace = true
simv-mesh.workspace = true
egui.workspace = true
glam.workspace = true
log.workspace = true
+152
View File
@@ -0,0 +1,152 @@
//! Орбитальная камера — перенос `simv::editor::Camera`.
//!
//! Смотрит всегда в начало координат; yaw/pitch/расстояние ставят глаз на сферу
//! вокруг него. Загруженные модели переносятся в центр матрицей модели, так что
//! отдельной точки интереса у камеры нет.
//!
//! Проекция — в пространстве отсечения Vulkan. В C++ это стоит двух отдельных мер:
//! `#define GLM_FORCE_DEPTH_ZERO_TO_ONE` перед включением заголовка (и надежда, что
//! никакой другой файл не включит glm раньше без него) плюс ручной переворот
//! `p[1][1] *= -1` с комментарием, зачем он. У glam соглашение вынесено в путь
//! функции: `camera::rh::proj::vulkan::perspective` уже даёт и глубину [0, 1], и
//! Y вниз, так что переворачивать вручную нечего.
use glam::{Mat4, Vec3};
const PITCH_LIMIT: f32 = 1.553_34; // ~89°, чтобы не поймать вырождение look_at
const MIN_DISTANCE: f32 = 0.5;
const MAX_DISTANCE: f32 = 200.0;
/// Плоскость, к которой можно развернуть вид кнопкой.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ViewPlane {
/// Спереди: взгляд вдоль −Z, плоскость XY.
Front,
/// Сверху: взгляд вдоль −Y, плоскость XZ.
Top,
/// Сбоку: взгляд вдоль −X, плоскость YZ.
Side,
}
pub struct Camera {
yaw: f32,
pitch: f32,
distance: f32,
}
impl Default for Camera {
fn default() -> Self {
Self {
yaw: 0.6,
pitch: 0.5,
distance: 8.0,
}
}
}
impl Camera {
/// Повернуть вокруг цели, в радианах.
pub fn orbit(&mut self, d_yaw: f32, d_pitch: f32) {
self.yaw += d_yaw;
self.pitch = (self.pitch + d_pitch).clamp(-PITCH_LIMIT, PITCH_LIMIT);
}
/// Приблизить или отдалить. Положительное значение (колесо вверх) приближает.
pub fn zoom(&mut self, delta: f32) {
self.distance = (self.distance * (-delta * 0.15).exp()).clamp(MIN_DISTANCE, MAX_DISTANCE);
}
pub fn set_distance(&mut self, distance: f32) {
self.distance = distance.clamp(MIN_DISTANCE, MAX_DISTANCE);
}
pub fn distance(&self) -> f32 {
self.distance
}
/// Развернуть вид точно на одну из главных плоскостей.
pub fn align_to_plane(&mut self, plane: ViewPlane) {
match plane {
ViewPlane::Front => {
self.yaw = 0.0;
self.pitch = 0.0;
}
ViewPlane::Top => {
self.yaw = 0.0;
self.pitch = PITCH_LIMIT;
}
ViewPlane::Side => {
self.yaw = std::f32::consts::FRAC_PI_2;
self.pitch = 0.0;
}
}
}
pub fn view(&self) -> Mat4 {
let target = Vec3::ZERO;
let eye = target
+ self.distance
* Vec3::new(
self.pitch.cos() * self.yaw.sin(),
self.pitch.sin(),
self.pitch.cos() * self.yaw.cos(),
);
glam::camera::rh::view::look_at_mat4(eye, target, Vec3::Y)
}
/// Проекция в пространстве отсечения Vulkan: глубина [0, 1] и Y вниз.
pub fn proj(&self, aspect: f32) -> Mat4 {
glam::camera::rh::proj::vulkan::perspective(45.0_f32.to_radians(), aspect, 0.05, 500.0)
}
pub fn view_proj(&self, aspect: f32) -> Mat4 {
self.proj(aspect) * self.view()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pitch_is_clamped_at_the_poles() {
let mut camera = Camera::default();
camera.orbit(0.0, 100.0);
assert!(camera.view().is_finite(), "матрица вида выродилась на полюсе");
camera.orbit(0.0, -200.0);
assert!(camera.view().is_finite());
}
#[test]
fn zoom_stays_within_limits() {
let mut camera = Camera::default();
for _ in 0..200 {
camera.zoom(1.0);
}
assert!(camera.distance() >= MIN_DISTANCE);
for _ in 0..200 {
camera.zoom(-1.0);
}
assert!(camera.distance() <= MAX_DISTANCE);
}
/// Ближняя плоскость обязана лечь в z = 0, дальняя — в z = 1: это и есть
/// пространство отсечения Vulkan, а не OpenGL с диапазоном [−1, 1].
#[test]
fn projection_uses_vulkan_depth_range() {
let camera = Camera::default();
let p = camera.proj(16.0 / 9.0);
let near = p * glam::Vec4::new(0.0, 0.0, -0.05, 1.0);
let far = p * glam::Vec4::new(0.0, 0.0, -500.0, 1.0);
assert!((near.z / near.w - 0.0).abs() < 1e-4, "ближняя плоскость: {}", near.z / near.w);
assert!((far.z / far.w - 1.0).abs() < 1e-4, "дальняя плоскость: {}", far.z / far.w);
}
/// Переворот Y обязателен: без него картинка встанет вверх ногами.
#[test]
fn projection_flips_y_for_vulkan() {
let camera = Camera::default();
let p = camera.proj(1.0);
assert!(p.y_axis.y < 0.0);
}
}
@@ -0,0 +1,30 @@
//! Мышь для камеры — перенос `simv::editor::CameraInput`.
use crate::camera::Camera;
/// Радиан на пиксель перетаскивания — как в C++-версии.
const ORBIT_SPEED: f32 = 0.01;
/// Множитель колеса.
///
/// В ImGui `io.MouseWheel` считает «щелчки»: одна прокрутка — ±1, и C++-версия берёт
/// половину. egui отдаёт прокрутку в точках, и один щелчок — это примерно пятьдесят.
/// Отсюда сотая доля: ощущение от колеса остаётся тем же.
const ZOOM_SPEED: f32 = 0.01;
/// Применить ввод мыши к камере. Ввод, который забрал интерфейс, до сцены не доходит.
pub fn apply_mouse_input(ctx: &egui::Context, camera: &mut Camera) {
if ctx.egui_wants_pointer_input() {
return; // указатель над панелью
}
ctx.input(|input| {
if input.pointer.primary_down() {
let delta = input.pointer.delta();
camera.orbit(delta.x * ORBIT_SPEED, -delta.y * ORBIT_SPEED);
}
let wheel = input.smooth_scroll_delta.y;
if wheel != 0.0 {
camera.zoom(wheel * ZOOM_SPEED);
}
});
}
+13
View File
@@ -0,0 +1,13 @@
//! Камера, ввод и панели редактора — соответствует цели `simv_editor`.
//!
//! Vulkan отсюда не виден: у крейта нет ни `ash`, ни `simv-vk` в зависимостях.
pub mod camera;
pub mod camera_input;
pub mod mesh_load_panel;
pub mod ui;
pub use camera::{Camera, ViewPlane};
pub use camera_input::apply_mouse_input;
pub use mesh_load_panel::MeshLoadPanel;
pub use ui::{DisplayMode, EditorUi};
@@ -0,0 +1,156 @@
//! Панель загрузки моделей — перенос `simv::editor::MeshLoadPanel`.
//!
//! Расхождение с C++-версией — в том, как результат попадает наружу. Там панель
//! держит `std::function<void(Mesh)>` и зовёт его прямо из `Draw`, то есть из
//! середины `Renderer::DrawFrame`, уже после `vkAcquireNextImageKHR`. Обработчик в
//! `main.cpp` из этого колбэка дёргает `renderer.SetMeshCpu`, а тот — `vkDeviceWaitIdle`
//! и выгрузку на видеокарту. Ждать простоя устройства посреди записи кадра законно,
//! но сомнительно.
//!
//! В Rust так просто не выйдет: замыкание, трогающее рендерер, вызывается из метода
//! этого же рендерера, то есть рендерер одолжен дважды. Панель вместо колбэка
//! складывает готовый меш в поле, а приложение забирает его через
//! [`MeshLoadPanel::take_loaded`] после того, как кадр закончен. Ограничение языка
//! здесь заодно вынесло выгрузку из середины кадра.
use std::path::{Path, PathBuf};
use simv_mesh::{load_obj_async, Mesh, PendingLoad};
pub struct MeshLoadPanel {
mesh_dir: PathBuf,
obj_files: Vec<String>,
selected: Option<usize>,
status: String,
pending: Option<PendingLoad>,
loaded: Option<Mesh>,
open: bool,
}
impl Default for MeshLoadPanel {
fn default() -> Self {
let mut panel = Self {
mesh_dir: PathBuf::from("assets/meshes"),
obj_files: Vec::new(),
selected: None,
status: String::new(),
pending: None,
loaded: None,
open: true,
};
panel.rescan_files();
panel
}
}
impl MeshLoadPanel {
pub fn set_mesh_directory(&mut self, dir: PathBuf) {
self.mesh_dir = dir;
self.selected = None;
self.rescan_files();
}
/// Забрать загруженный меш, если он готов. Вызывать после кадра.
pub fn take_loaded(&mut self) -> Option<Mesh> {
self.loaded.take()
}
fn rescan_files(&mut self) {
self.obj_files.clear();
let Ok(entries) = std::fs::read_dir(&self.mesh_dir) else {
return;
};
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|t| t.is_file()) {
continue;
}
let path = entry.path();
// Расширение без учёта регистра: `Cube.obj`, `cube.OBJ`, `Plane.Obj`.
let is_obj = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("obj"));
if is_obj {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
self.obj_files.push(name.to_owned());
}
}
}
self.obj_files.sort();
}
pub fn draw(&mut self, ctx: &egui::Context) {
if !self.open {
return;
}
// Опрос незавершённой загрузки на главном потоке.
if let Some(pending) = &mut self.pending {
if let Some(result) = pending.try_take() {
self.pending = None;
match result {
Ok(mesh) => {
self.loaded = Some(mesh);
self.status = String::from("Загружено.");
}
Err(e) => self.status = format!("Ошибка: {e}"),
}
}
}
let mut open = self.open;
egui::Window::new("Модель")
.open(&mut open)
.default_size([360.0, 240.0])
.show(ctx, |ui| self.contents(ui));
self.open = open;
}
fn contents(&mut self, ui: &mut egui::Ui) {
ui.label(egui::RichText::new(format!("Каталог: {}", self.mesh_dir.display())).weak());
if ui.small_button("Обновить список").clicked() {
self.rescan_files();
}
ui.separator();
egui::ScrollArea::vertical()
.max_height(120.0)
.auto_shrink([false, false])
.show(ui, |ui| {
for (index, name) in self.obj_files.iter().enumerate() {
if ui
.selectable_label(self.selected == Some(index), name)
.clicked()
{
self.selected = Some(index);
}
}
});
let busy = self.pending.is_some();
let can_load = self.selected.is_some() && !busy;
ui.horizontal(|ui| {
if ui
.add_enabled(can_load, egui::Button::new("Загрузить"))
.clicked()
{
if let Some(index) = self.selected {
self.status = String::from("Загружаю…");
self.pending = Some(load_obj_async(self.mesh_dir.join(&self.obj_files[index])));
}
}
if busy {
ui.label(egui::RichText::new("(идёт загрузка…)").weak());
}
});
if !self.status.is_empty() {
ui.separator();
ui.label(&self.status);
}
}
pub fn mesh_directory(&self) -> &Path {
&self.mesh_dir
}
}
+80
View File
@@ -0,0 +1,80 @@
//! Панель вьюпорта — перенос `simv::editor::EditorUI`.
//!
//! Про [`DisplayMode`]. Панель обязана как-то сообщить рендереру выбранный режим, но
//! `simv_vk::RenderMode` ей недоступен: `simv-editor` не зависит от `simv-vk`, и это
//! проверяет компилятор. C++ обходит ту же границу, возвращая из `EditorUI` голый
//! `int`, который `main.cpp` приводит через `static_cast` к `vk::RenderMode` —
//! связь между двумя перечислениями там держится на честном слове. Здесь у панели
//! свой типизированный режим, а разбор в `simv_vk::RenderMode` делает `simv-app`
//! обычным `match`: добавите вариант — компилятор укажет, где его дописать.
use crate::camera::{Camera, ViewPlane};
/// Как отображать модель. Соответствует `simv_vk::RenderMode`, но живёт по эту
/// сторону границы модулей.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum DisplayMode {
#[default]
Solid,
Wireframe,
SolidWire,
}
pub struct EditorUi {
display_mode: DisplayMode,
grid_visible: bool,
}
impl Default for EditorUi {
fn default() -> Self {
Self {
display_mode: DisplayMode::Solid,
grid_visible: true,
}
}
}
impl EditorUi {
pub fn display_mode(&self) -> DisplayMode {
self.display_mode
}
pub fn grid_visible(&self) -> bool {
self.grid_visible
}
pub fn draw(&mut self, ctx: &egui::Context, camera: &mut Camera) {
egui::Window::new("Viewport")
.default_size([280.0, 200.0])
.show(ctx, |ui| {
ui.label(egui::RichText::new("Камера").weak());
ui.horizontal(|ui| {
if ui.button("Спереди").clicked() {
camera.align_to_plane(ViewPlane::Front);
}
if ui.button("Сверху").clicked() {
camera.align_to_plane(ViewPlane::Top);
}
if ui.button("Сбоку").clicked() {
camera.align_to_plane(ViewPlane::Side);
}
});
ui.label(
egui::RichText::new("Перетаскивание — поворот, колесо — приближение").weak(),
);
ui.separator();
ui.label(egui::RichText::new("Отображение модели").weak());
ui.radio_value(&mut self.display_mode, DisplayMode::Solid, "Заливка");
ui.radio_value(&mut self.display_mode, DisplayMode::Wireframe, "Каркас");
ui.radio_value(
&mut self.display_mode,
DisplayMode::SolidWire,
"Заливка с каркасом",
);
ui.separator();
ui.checkbox(&mut self.grid_visible, "Показывать сетку");
});
}
}