Порт редактора 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
+16
View File
@@ -0,0 +1,16 @@
# Аналог цели simv_mesh: меш на стороне процессора. Ни ash, ни egui в зависимостях —
# граница «Vulkan только в simv-vk» держится не дисциплиной, а графом крейтов.
[package]
name = "simv-mesh"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Загрузка OBJ и сварка вершин, без графического API"
[dependencies]
simv-core.workspace = true
glam.workspace = true
tobj.workspace = true
log.workspace = true
thiserror.workspace = true
+169
View File
@@ -0,0 +1,169 @@
//! Сварка близких вершин — перенос `simv::mesh::WeldVertices`.
//!
//! Имя файла в C++-версии (`MeshDecimator`) обещает больше, чем есть: никакого LOD и
//! прореживания здесь нет, только схлопывание дубликатов по пространственному хешу и
//! удаление выродившихся треугольников. Название сохранено, чтобы модули двух версий
//! сопоставлялись один к одному.
use std::collections::HashMap;
use std::hash::{BuildHasherDefault, Hasher};
use glam::Vec3;
use crate::mesh::Mesh;
/// Ключ ячейки пространственного хеша.
type CellKey = [i32; 3];
/// Тот же FNV-1a, что в C++-версии.
///
/// На результат сварки хеш-функция не влияет — карта точная, ключи целочисленные, —
/// но брать стандартный SipHash значило бы сравнивать не алгоритмы, а хеши: SipHash
/// криптостойкий и заметно дороже. FNV перенесён ровно ради того, чтобы замер времени
/// сварки в отчёте мерил одно и то же в обеих версиях.
#[derive(Default)]
struct Fnv1a(u64);
impl Hasher for Fnv1a {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
// Ключ приходит четвёрками байт (три i32), мешаем по 32-битным словам —
// так же, как C++-версия мешает три int32.
let mut h = if self.0 == 0 { 0xcbf2_9ce4_8422_2325 } else { self.0 };
for word in bytes.chunks(4) {
let mut v = [0u8; 4];
v[..word.len()].copy_from_slice(word);
h ^= u32::from_ne_bytes(v) as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
self.0 = h;
}
}
type FnvMap = HashMap<CellKey, u32, BuildHasherDefault<Fnv1a>>;
/// Группировка округлением к ближайшему узлу решётки.
///
/// Две точки, отстоящие меньше чем на `tolerance`, гарантированно попадают в одну
/// ячейку, если обе лежат в пределах допуска от общего узла. Бакетирование через
/// `floor` на границах ячеек ломается, когда числа падают по разные стороны, —
/// округление и есть стандартное лекарство для сварки по пространственному хешу.
#[inline]
fn to_cell(p: Vec3, tolerance: f32) -> CellKey {
let t = tolerance as f64;
[
(p.x as f64 / t).round() as i32,
(p.y as f64 / t).round() as i32,
(p.z as f64 / t).round() as i32,
]
}
/// Схлопнуть вершины, отстоящие меньше чем на `tolerance`, переписать индексы и
/// выбросить треугольники, у которых после этого совпали хотя бы две вершины.
pub fn weld_vertices(mesh: &mut Mesh, tolerance: f32) {
if mesh.positions.is_empty() || tolerance <= 0.0 {
return;
}
let mut cell_to_vertex: FnvMap =
FnvMap::with_capacity_and_hasher(mesh.positions.len(), BuildHasherDefault::default());
let mut welded: Vec<Vec3> = Vec::with_capacity(mesh.positions.len());
let mut remap: Vec<u32> = Vec::with_capacity(mesh.positions.len());
for &p in &mesh.positions {
let cell = to_cell(p, tolerance);
let index = *cell_to_vertex.entry(cell).or_insert_with(|| {
welded.push(p);
(welded.len() - 1) as u32
});
remap.push(index);
}
let mut new_indices = Vec::with_capacity(mesh.indices.len());
for tri in mesh.indices.chunks_exact(3) {
let a = remap[tri[0] as usize];
let b = remap[tri[1] as usize];
let c = remap[tri[2] as usize];
if a == b || b == c || a == c {
continue;
}
new_indices.extend_from_slice(&[a, b, c]);
}
mesh.positions = welded;
mesh.indices = new_indices;
mesh.recalculate_bounds();
}
#[cfg(test)]
mod tests {
use super::*;
/// Перенос `TEST_CASE("WeldVertices collapses near-duplicate vertices")`.
#[test]
fn weld_collapses_near_duplicate_vertices() {
let mut m = Mesh {
// Два треугольника с общим ребром; вершины разведены в пределах допуска.
positions: vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0001, 0.0, 0.0), // ≈ #0
Vec3::new(1.0001, 0.0, 0.0), // ≈ #1
Vec3::new(1.0, 1.0, 0.0),
],
indices: vec![0, 1, 2, 3, 4, 5],
..Default::default()
};
weld_vertices(&mut m, 0.01);
assert_eq!(m.positions.len(), 4);
assert_eq!(m.indices.len(), 6, "оба треугольника обязаны выжить");
// Габариты должны совпасть с «настоящей» оболочкой независимо от порядка сварки.
assert!((m.bbox_max.x - 1.0001).abs() < 1e-3);
}
/// Перенос `TEST_CASE("WeldVertices drops degenerate triangles")`.
#[test]
fn weld_drops_degenerate_triangles() {
let mut m = Mesh {
// Один настоящий треугольник и один вырожденный: все три вершины в допуске.
positions: vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(2.0, 2.0, 0.0),
Vec3::new(2.00005, 2.0, 0.0),
Vec3::new(2.0, 2.00005, 0.0),
],
indices: vec![0, 1, 2, 3, 4, 5],
..Default::default()
};
weld_vertices(&mut m, 0.001);
assert_eq!(m.indices.len(), 3, "вырожденный треугольник обязан исчезнуть");
}
/// Округление, а не отсечение: точки по разные стороны границы ячейки, но в
/// пределах допуска друг от друга, всё равно должны слиться.
#[test]
fn weld_buckets_by_rounding_not_flooring() {
let mut m = Mesh {
positions: vec![
Vec3::new(0.00999, 0.0, 0.0),
Vec3::new(0.01001, 0.0, 0.0),
Vec3::new(5.0, 0.0, 0.0),
],
indices: vec![0, 1, 2],
..Default::default()
};
weld_vertices(&mut m, 0.01);
assert_eq!(m.positions.len(), 2);
assert!(m.indices.is_empty(), "треугольник выродился и должен быть удалён");
}
}
+13
View File
@@ -0,0 +1,13 @@
//! Меш на стороне процессора: чтение `.obj`, сварка вершин, габариты.
//!
//! Соответствует цели `simv_mesh`. Ни `ash`, ни `egui` в зависимостях крейта нет —
//! в C++ границу «Vulkan только в `src/vk/`» держит дисциплина, здесь её держит
//! граф зависимостей: нарушение просто не соберётся.
pub mod decimator;
pub mod mesh;
pub mod obj_loader;
pub use decimator::weld_vertices;
pub use mesh::Mesh;
pub use obj_loader::{load_obj, load_obj_async, ObjError, PendingLoad};
+77
View File
@@ -0,0 +1,77 @@
//! Меш на стороне процессора — перенос `simv::mesh::Mesh`.
use glam::Vec3;
/// Суп из треугольников: позиции и индексы по три на треугольник.
/// Загружается `obj_loader`, при желании прореживается `decimator`, затем уезжает в
/// `simv_vk::GpuMesh`. Никакого графического API здесь нет и быть не должно.
#[derive(Clone, Debug, Default)]
pub struct Mesh {
pub positions: Vec<Vec3>,
pub indices: Vec<u32>,
pub bbox_min: Vec3,
pub bbox_max: Vec3,
}
impl Mesh {
pub fn vertex_count(&self) -> usize {
self.positions.len()
}
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
/// Пуст в смысле «рисовать нечего»: одних вершин без индексов недостаточно.
pub fn is_empty(&self) -> bool {
self.positions.is_empty() || self.indices.is_empty()
}
/// Пересчитать габаритный ящик. У пустого меша он схлопывается в начало координат —
/// так же, как в C++-версии, чтобы камера не уезжала в бесконечность.
pub fn recalculate_bounds(&mut self) {
if self.positions.is_empty() {
self.bbox_min = Vec3::ZERO;
self.bbox_max = Vec3::ZERO;
return;
}
let mut lo = Vec3::splat(f32::MAX);
let mut hi = Vec3::splat(f32::MIN);
for p in &self.positions {
lo = lo.min(*p);
hi = hi.max(*p);
}
self.bbox_min = lo;
self.bbox_max = hi;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Перенос `TEST_CASE("Mesh::RecalculateBounds finds AABB")`.
#[test]
fn recalculate_bounds_finds_aabb() {
let mut m = Mesh {
positions: vec![
Vec3::new(-1.0, -2.0, -3.0),
Vec3::new(4.0, 5.0, 6.0),
Vec3::new(0.5, 0.0, 1.0),
],
..Default::default()
};
m.recalculate_bounds();
assert_eq!(m.bbox_min, Vec3::new(-1.0, -2.0, -3.0));
assert_eq!(m.bbox_max, Vec3::new(4.0, 5.0, 6.0));
}
#[test]
fn empty_mesh_has_degenerate_bounds() {
let mut m = Mesh::default();
m.recalculate_bounds();
assert_eq!(m.bbox_min, Vec3::ZERO);
assert_eq!(m.bbox_max, Vec3::ZERO);
assert!(m.is_empty());
}
}
+153
View File
@@ -0,0 +1,153 @@
//! Загрузка `.obj` — перенос `simv::mesh::ObjLoader`.
//!
//! Асинхронный путь устроен иначе, чем в C++. Там `LoadObjAsync` возвращал
//! `std::future<Mesh>`, и исключение из рабочего потока всплывало на главном при
//! вызове `get()`. Здесь поток посылает `Result` по каналу, а вызывающий разбирает
//! его обычным `match` — ошибка стала значением, и потерять её молча уже не выйдет.
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, TryRecvError};
use glam::Vec3;
use crate::mesh::Mesh;
#[derive(Debug, thiserror::Error)]
pub enum ObjError {
#[error("не удалось разобрать {path}: {source}")]
Parse {
path: String,
#[source]
source: tobj::LoadError,
},
/// Единственный способ, которым канал может закрыться без ответа, — паника в
/// рабочем потоке. Отдельный вариант нужен, чтобы панель показала это как ошибку,
/// а не висела в состоянии «грузится» вечно.
#[error("поток загрузки завершился аварийно")]
WorkerDied,
}
/// Синхронная загрузка: маленькие файлы, тесты.
///
/// Расхождение с C++-версией. tinyobjloader отдаёт единый глобальный массив вершин
/// (`attrib.vertices`), в который индексируют все формы, — C++-код так его и берёт.
/// tobj режет файл на модели (по объектам и по материалам) и каждой выдаёт свой
/// массив позиций, поэтому здесь модели склеиваются со сдвигом индексов. Вершины,
/// общие для соседних моделей, при этом задваиваются: до сварки счётчик вершин будет
/// больше, чем в C++-версии. `weld_vertices` их схлопывает, так что после сварки —
/// той самой, которую делает приложение сразу после загрузки, — счётчики совпадают.
pub fn load_obj(path: &Path) -> Result<Mesh, ObjError> {
let options = tobj::LoadOptions {
triangulate: true,
ignore_points: true,
ignore_lines: true,
..Default::default()
};
let (models, _materials) = tobj::load_obj(path, &options).map_err(|source| ObjError::Parse {
path: path.display().to_string(),
source,
})?;
let mut mesh = Mesh::default();
mesh.positions
.reserve(models.iter().map(|m| m.mesh.positions.len() / 3).sum());
mesh.indices
.reserve(models.iter().map(|m| m.mesh.indices.len()).sum());
for model in &models {
let base = mesh.positions.len() as u32;
for xyz in model.mesh.positions.chunks_exact(3) {
mesh.positions.push(Vec3::new(xyz[0], xyz[1], xyz[2]));
}
mesh.indices
.extend(model.mesh.indices.iter().map(|i| i + base));
}
mesh.recalculate_bounds();
log::info!(
target: "mesh",
"загружен OBJ '{}': {} вершин, {} треугольников",
path.file_name().unwrap_or(path.as_os_str()).to_string_lossy(),
mesh.vertex_count(),
mesh.triangle_count()
);
Ok(mesh)
}
/// Незавершённая загрузка. Опрашивается на главном потоке — так же, как C++-версия
/// опрашивала `future` через `wait_for(0ms)`, чтобы обратный вызов (а с ним и выгрузка
/// на видеокарту) выполнялся на потоке рендера.
pub struct PendingLoad {
receiver: Receiver<Result<Mesh, ObjError>>,
}
impl PendingLoad {
/// `None` — ещё грузится. `Some(..)` — результат готов; повторные вызовы после
/// этого снова вернут `Err(WorkerDied)`, поэтому забирать надо один раз.
pub fn try_take(&mut self) -> Option<Result<Mesh, ObjError>> {
match self.receiver.try_recv() {
Ok(result) => Some(result),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Err(ObjError::WorkerDied)),
}
}
}
/// Запустить загрузку в отдельном потоке.
pub fn load_obj_async(path: PathBuf) -> PendingLoad {
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
// Получатель мог уйти вместе с закрытой панелью — это не ошибка.
let _ = sender.send(load_obj(&path));
});
PendingLoad { receiver }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::decimator::weld_vertices;
fn asset(name: &str) -> PathBuf {
// crates/simv-mesh → корень репозитория.
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../assets/meshes")
.join(name)
}
#[test]
fn cube_loads_with_twelve_triangles() {
let path = asset("Cube.obj");
let mut mesh = load_obj(&path).expect("Cube.obj обязан читаться");
assert_eq!(mesh.triangle_count(), 12);
weld_vertices(&mut mesh, 1e-4);
// Куб после сварки — ровно восемь углов и те же двенадцать треугольников.
assert_eq!(mesh.vertex_count(), 8);
assert_eq!(mesh.triangle_count(), 12);
}
/// Диагностика для сверки с C++-версией: печатает счётчики до и после сварки.
/// Запуск: cargo test -p simv-mesh -- --ignored --nocapture weld_counts
#[test]
#[ignore = "диагностика, не проверка"]
fn weld_counts_for_bundled_assets() {
for name in ["Cube.obj", "Cube1.obj", "cow.obj", "plane.obj"] {
let mut mesh = load_obj(&asset(name)).expect(name);
let (v0, t0) = (mesh.vertex_count(), mesh.triangle_count());
weld_vertices(&mut mesh, 1e-4);
println!(
"{name}: до сварки {v0} вершин / {t0} треугольников, после {} / {}",
mesh.vertex_count(),
mesh.triangle_count()
);
}
}
#[test]
fn missing_file_reports_error_not_panic() {
let err = load_obj(Path::new("нет-такого-файла.obj")).unwrap_err();
assert!(matches!(err, ObjError::Parse { .. }));
}
}