Порт редактора 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,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
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user