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