Порт редактора 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,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
|
||||
@@ -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(), "треугольник выродился и должен быть удалён");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user