Порт редактора 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
+13
View File
@@ -0,0 +1,13 @@
# Аналог цели simv_core: журнал и окно. Vulkan сюда не входит и входить не должен.
[package]
name = "simv-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Журнал с категориями и окно — общая основа редактора"
[dependencies]
log.workspace = true
chrono.workspace = true
winit.workspace = true
+13
View File
@@ -0,0 +1,13 @@
//! Общая основа редактора: журнал и окно.
//!
//! Соответствует цели `simv_core` из CMake, но без `App`: в C++ граф целей цикличен
//! (`core::App` владеет `vk::Renderer`, а `vk::Renderer` принимает `core::Window&`),
//! и держится это лишь на том, что `simv_vk` видит заголовки `core` через
//! `target_include_directories`, не линкуя саму цель. Cargo цикл между крейтами
//! отвергает, поэтому `App` переехал в `simv-app`. Разбор — в `docs/rust_vs_cpp.md`.
pub mod logger;
pub mod window;
pub use logger::LogCategory;
pub use window::{Window, WindowDesc};
+230
View File
@@ -0,0 +1,230 @@
//! Журнал с категориями, уровнями и троттлингом — перенос `simv::core::Logger`.
//!
//! Категории C++-версии легли на target'ы крейта `log`, поэтому писать в журнал
//! можно обычными макросами:
//!
//! ```ignore
//! log::info!(target: "vk", "контекст готов: {}", name);
//! ```
//!
//! Выигрыш против C++-версии, где `LogFmt` знал только про свои вызовы: под тот же
//! фильтр и троттлинг автоматически попадают сообщения зависимостей, которые тоже
//! пишут через `log`. Незнакомый target считается категорией `Core` — так опечатка
//! в имени не выбрасывает сообщение мимо всех настроек.
use std::io::Write;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering};
use std::sync::{LazyLock, OnceLock};
use std::time::Instant;
use log::{Level, LevelFilter, Log, Metadata, Record};
/// Категории журнала. Порядок и имена — как в C++-версии.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum LogCategory {
Core,
Vulkan,
MeshIo,
Ui,
Test,
}
impl LogCategory {
pub const ALL: [LogCategory; 5] = [
LogCategory::Core,
LogCategory::Vulkan,
LogCategory::MeshIo,
LogCategory::Ui,
LogCategory::Test,
];
/// Строка, которую передают в `target:` у макросов `log`.
pub const fn target(self) -> &'static str {
match self {
LogCategory::Core => "core",
LogCategory::Vulkan => "vk",
LogCategory::MeshIo => "mesh",
LogCategory::Ui => "ui",
LogCategory::Test => "test",
}
}
const fn index(self) -> usize {
match self {
LogCategory::Core => 0,
LogCategory::Vulkan => 1,
LogCategory::MeshIo => 2,
LogCategory::Ui => 3,
LogCategory::Test => 4,
}
}
/// Разбор target'а. Крейты-зависимости пишут в свои target'ы («wgpu_hal», «winit»
/// и подобные) — они попадают в `Core` и подчиняются её настройкам.
fn from_target(target: &str) -> LogCategory {
// Зависимости пишут в target'ы вида «egui_winit::state», берём корень.
let root = target.split("::").next().unwrap_or(target);
match root {
"vk" => LogCategory::Vulkan,
"mesh" => LogCategory::MeshIo,
"ui" => LogCategory::Ui,
"test" => LogCategory::Test,
_ => LogCategory::Core,
}
}
}
/// Настройки одной категории. Меняются в любой момент из любого потока — ровно то,
/// что в C++-версии делали `SetMinLevel` / `SetThrottle` / `SetEnabled`.
struct CategoryState {
enabled: AtomicBool,
/// `LevelFilter as u8`: Off=0, Error=1, … Trace=5.
min_level: AtomicU8,
throttle_ms: AtomicI64,
/// `i64::MIN` — в этой категории ещё ничего не печатали.
last_log_ms: AtomicI64,
}
impl CategoryState {
const fn new() -> Self {
Self {
enabled: AtomicBool::new(true),
min_level: AtomicU8::new(LevelFilter::Info as u8),
// C++-версия ставит по умолчанию 0.5 с на категорию, но её низкоуровневый
// код Vulkan пишет прямо через spdlog, минуя категории, поэтому стартовые
// сообщения там всё равно печатаются все. Здесь через категории идёт ВСЁ,
// и тот же порог съедал бы половину стартового вывода: имя видеокарты и
// размер кэша конвейеров приходят в пределах одной сотой секунды.
// Поведение приложения важнее буквы: по умолчанию не троттлим,
// `set_throttle` включает порог там, где сообщения идут покадрово.
throttle_ms: AtomicI64::new(0),
last_log_ms: AtomicI64::new(i64::MIN),
}
}
}
static CATEGORIES: [CategoryState; 5] = [
CategoryState::new(),
CategoryState::new(),
CategoryState::new(),
CategoryState::new(),
CategoryState::new(),
];
static EPOCH: LazyLock<Instant> = LazyLock::new(Instant::now);
static LOGGER: SimvLogger = SimvLogger;
static INIT: OnceLock<()> = OnceLock::new();
fn now_ms() -> i64 {
EPOCH.elapsed().as_millis() as i64
}
struct SimvLogger;
impl Log for SimvLogger {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
// Дешёвая проверка до форматирования аргументов. Троттлинг сюда не попадает
// намеренно: у него есть побочный эффект, а `enabled` вызывается и просто так.
let state = &CATEGORIES[LogCategory::from_target(metadata.target()).index()];
state.enabled.load(Ordering::Relaxed)
&& (metadata.level() as u8) <= state.min_level.load(Ordering::Relaxed)
}
fn log(&self, record: &Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let category = LogCategory::from_target(record.target());
let state = &CATEGORIES[category.index()];
// Предупреждения и ошибки троттлинг обходят: пропустить их — хуже, чем
// залить ими вывод.
let important = record.level() <= Level::Warn;
if !important {
let throttle = state.throttle_ms.load(Ordering::Relaxed);
if throttle > 0 {
let now = now_ms();
let last = state.last_log_ms.load(Ordering::Relaxed);
if last != i64::MIN && (now - last) < throttle {
return;
}
state.last_log_ms.store(now, Ordering::Relaxed);
}
}
let (name, color) = match record.level() {
Level::Error => ("error", "\x1b[31;1m"),
Level::Warn => ("warning", "\x1b[33;1m"),
Level::Info => ("info", "\x1b[32m"),
Level::Debug => ("debug", "\x1b[36m"),
Level::Trace => ("trace", "\x1b[90m"),
};
let stamp = chrono::Local::now().format("%H:%M:%S%.3f");
let mut out = std::io::stdout().lock();
let _ = writeln!(
out,
"[{stamp}] [{color}{name}\x1b[0m] [{}] {}",
category.target(),
record.args()
);
}
fn flush(&self) {
let _ = std::io::stdout().flush();
}
}
/// Подключить журнал. Повторные вызовы безвредны — выигрывает первый.
pub fn init() {
INIT.get_or_init(|| {
LazyLock::force(&EPOCH);
// Глобальный порог держим открытым: решение принимает фильтр по категориям.
log::set_max_level(LevelFilter::Trace);
// Ошибка означает, что журнал уже кто-то поставил — ничего страшного.
let _ = log::set_logger(&LOGGER);
});
}
/// Порог уровня для категории.
pub fn set_min_level(category: LogCategory, level: LevelFilter) {
CATEGORIES[category.index()]
.min_level
.store(level as u8, Ordering::Relaxed);
}
/// Минимальный интервал между сообщениями категории, в секундах. `0` — без троттлинга.
pub fn set_throttle(category: LogCategory, seconds: f64) {
CATEGORIES[category.index()]
.throttle_ms
.store((seconds * 1000.0) as i64, Ordering::Relaxed);
}
/// Полностью выключить или включить категорию.
pub fn set_enabled(category: LogCategory, enabled: bool) {
CATEGORIES[category.index()]
.enabled
.store(enabled, Ordering::Relaxed);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_targets_map_to_their_categories() {
assert_eq!(LogCategory::from_target("vk"), LogCategory::Vulkan);
assert_eq!(LogCategory::from_target("mesh"), LogCategory::MeshIo);
// Чужие target'ы зависимостей не теряются, а попадают в Core.
assert_eq!(LogCategory::from_target("winit::platform"), LogCategory::Core);
}
#[test]
fn every_category_has_a_distinct_index() {
let mut seen = [false; 5];
for category in LogCategory::ALL {
assert!(!seen[category.index()], "индекс {} занят дважды", category.index());
seen[category.index()] = true;
}
}
}
+67
View File
@@ -0,0 +1,67 @@
//! Окно — перенос `simv::core::Window`.
//!
//! Отличие от C++-версии не в обёртке, а в модели событий. У GLFW цикл пишет
//! приложение: `while (!ShouldClose()) { PollEvents(); DrawFrame(); }`. У winit цикл
//! владеет собой сам и зовёт приложение через `ApplicationHandler`, поэтому
//! `ShouldClose` и `PollEvents` здесь просто нечему соответствовать — они переехали
//! в `simv_app::App` в виде обработчиков событий.
//!
//! Заодно исчез `RequiredInstanceExtensions`: в C++ список расширений спрашивали у
//! GLFW, в Rust его даёт `ash_window::enumerate_required_extensions` по дескриптору
//! дисплея, то есть он оказался на стороне графики, а не окна.
use winit::dpi::LogicalSize;
use winit::error::OsError;
use winit::event_loop::ActiveEventLoop;
use winit::window::{Window as WinitWindow, WindowAttributes};
/// Параметры создаваемого окна.
pub struct WindowDesc {
pub title: String,
pub width: u32,
pub height: u32,
pub resizable: bool,
}
impl Default for WindowDesc {
fn default() -> Self {
Self {
title: "SimVulcan".to_owned(),
width: 1280,
height: 720,
resizable: true,
}
}
}
/// Окно приложения.
pub struct Window {
handle: WinitWindow,
}
impl Window {
pub fn new(event_loop: &ActiveEventLoop, desc: &WindowDesc) -> Result<Self, OsError> {
let attributes = WindowAttributes::default()
.with_title(desc.title.clone())
.with_inner_size(LogicalSize::new(desc.width, desc.height))
.with_resizable(desc.resizable);
Ok(Self {
handle: event_loop.create_window(attributes)?,
})
}
pub fn raw(&self) -> &WinitWindow {
&self.handle
}
/// Размер кадрового буфера в пикселях. Ноль по любой оси означает свёрнутое окно —
/// рисовать в этот кадр нельзя.
pub fn framebuffer_size(&self) -> (u32, u32) {
let size = self.handle.inner_size();
(size.width, size.height)
}
pub fn request_redraw(&self) {
self.handle.request_redraw();
}
}