Initial commit

This commit is contained in:
Efim Beshmenev
2026-07-19 00:19:41 +03:00
commit 0c36122baa
32 changed files with 14261 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+avx2,+crt-static"]
+29
View File
@@ -0,0 +1,29 @@
# Rust / Cargo build output
/target/
**/*.rs.bk
# Local logs and temporary files
*.log
*.tmp
*.bak
*.swp
*.swo
# Local environment files
.env
.env.*
!.env.example
# IDE and editor state
/.idea/
/.vscode/
/.vs/
*.user
*.suo
*.userosscache
*.sln.docstates
# Operating-system metadata
Thumbs.db
Desktop.ini
.DS_Store
Generated
+3008
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "ray-marching-editor"
version = "0.1.0"
edition = "2024"
rust-version = "1.92"
description = "A small Vulkan-only SDF ray-marching level editor"
license = "MIT OR Apache-2.0"
[dependencies]
anyhow = "1.0.103"
bytemuck = { version = "1.25.1", features = ["derive"] }
egui = "0.35.0"
egui-wgpu = { version = "0.35.0", default-features = false }
egui-winit = { version = "0.35.0", default-features = false }
env_logger = "0.11.8"
glam = { version = "0.33.2", features = ["bytemuck", "serde"] }
log = "0.4.29"
pollster = "0.4.0"
rfd = { version = "0.17.2", default-features = false }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
thiserror = "2.0.18"
wgpu = { version = "29.0.0", default-features = false, features = ["std", "vulkan", "wgsl"] }
winit = "0.30.13"
[dev-dependencies]
naga = { version = "29.0.4", features = ["spv-out", "wgsl-in"] }
[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 2
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
+98
View File
@@ -0,0 +1,98 @@
# Ray Marching Editor
Небольшой редактор уровней на Rust с ray-marching рендерером. На desktop
рендер запускается только через Vulkan backend `wgpu`; внешний Vulkan SDK и
компилятор GLSL не нужны — шейдер хранится в WGSL и проверяется Naga.
## Что уже есть
- Полноэкранный ray marcher с нормалями, мягкими тенями, AO, туманом и ACES
tonemapping.
- 16 SDF-примитивов: Sphere, Box, Rounded Box, Box Frame, Plane, Torus, Link,
Capsule, Cylinder, Capped Cone, Rounded Cone, Ellipsoid, Octahedron,
Triangular Prism, Hexagonal Prism и Black Hole.
- Размещаемая чёрная дыра: абсолютно чёрный горизонт событий и настраиваемые
радиус влияния, сила линзирования и закручивание света вокруг локальной оси Z.
У выбранной чёрной дыры показывается фиолетовая граница области влияния.
- Conservative bounding sphere перед вычислением основного SDF.
- Sparse CPU uniform grid и компактная GPU-сетка до `32 × 32 × 32`; переходы
между ячейками ограничивают шаг луча, поэтому объекты в соседних ячейках не
пропускаются. Plane всегда обрабатывается глобально.
- Русский интерфейс сцены с аутлайнером, инспектором и интерактивным transform gizmo:
оси и плоскости перемещения, кольца вращения, осевое и равномерное масштабирование.
Drag вычисляется пересечениями мировых лучей с осями/плоскостями, а переключатель
«Мир / Локально» меняет и геометрию гизмо, и саму трансформацию.
- Система координат как в UE: `X/Y` образуют горизонтальную плоскость, `Z` — высота.
- Групповое выделение: `Ctrl` во вьюпорте, `Ctrl`/`Shift` в аутлайнере; команды
перемещения, вращения, масштабирования, дублирования и удаления работают со всей группой.
- Размещаемые источники «Солнце», «Лампочка» (`Point Light`) и «Прожектор» (`Spot Light`).
У выбранной лампочки виден сферический радиус действия, у прожектора — внешний конус.
Для источников доступны цвет, мощность, дальность и, у прожектора, углы конуса.
- Процедурное небо с движущимися фоновыми облаками.
- Компактная панель в углу вьюпорта в стиле `stat fps` + `stat unit`:
FPS, Frame, Game, Draw и реальный GPU time через неблокирующие timestamp queries.
- Версионированные JSON-карты со стабильными ID, источниками, материалами,
настройками мира и editor camera. «Открыть» и «Сохранить как» используют
системный Проводник; обычное сохранение повторно пишет в выбранный файл.
Запись идёт через временный файл и атомарную замену.
- VSync по умолчанию выключен: Vulkan выбирает `Immediate`, затем `Mailbox`;
в панели инструментов режим можно переключить.
## Сборка
Целевая конфигурация — Windows x86-64 с CPU, поддерживающим AVX2, и актуальным
Vulkan-драйвером:
```powershell
cargo build --release
```
Release-профиль использует `opt-level = 3`, thin LTO, один codegen unit,
`+avx2` и статический MSVC CRT (`+crt-static`). Rust-библиотеки упаковываются в
исполняемый файл. Системный Vulkan loader `vulkan-1.dll` и видеодрайвер остаются
внешними системными компонентами.
Готовый файл: `target\release\ray-marching-editor.exe`.
## Управление
| Действие | Управление |
|---|---|
| Обзор | RMB + мышь |
| Полёт | RMB + W/A/S/D, Q/E вниз/вверх |
| Ускорение | Shift |
| Скорость камеры | Колесо |
| Orbit | Alt + LMB |
| Pan | MMB |
| Dolly | Alt + RMB |
| Выделить объект / снять всё кликом по пустому месту | LMB |
| Добавить объект в выделение или убрать из него | Ctrl+LMB во вьюпорте |
| Групповое выделение в аутлайнере | Ctrl+клик; Shift+клик для диапазона |
| Взаимодействовать с гизмо | LMB по оси, плоскости, кольцу или центральному маркеру |
| Фокус на выделенном | F |
| Move / Rotate / Scale | W / E / R |
| Дублировать | Ctrl+D |
| Удалить | Delete |
| Сохранить (с выбором файла для новой карты) | Ctrl+S |
| Сохранить как | Ctrl+Shift+S |
| Открыть карту | Ctrl+O |
| Новая карта | Ctrl+N |
Примитивы и свет добавляются кнопками `+ Примитив` и `+ Свет`. Числовые
параметры, трансформация, материал, освещение и облака редактируются в панели «Свойства».
## Статические проверки
```powershell
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test
cargo check --release
```
Тесты отдельно парсят WGSL, валидируют все entry points и генерируют Vulkan
SPIR-V, проверяют все 16 CPU SDF, bounds, grid, picking и round-trip карты.
Текущие GPU-лимиты MVP: 128 видимых примитивов, 64 уникальных материала,
16 источников света и 131072 ссылок примитивов в spatial grid. При переполнении
сетки рендерер безопасно переключается на brute-force путь.
+781
View File
@@ -0,0 +1,781 @@
use std::{path::PathBuf, sync::Arc, time::Instant};
use anyhow::{Context, Result};
use egui::Rect;
use glam::{Quat, Vec2, Vec3};
use winit::{
application::ApplicationHandler,
dpi::{LogicalSize, PhysicalPosition},
event::{DeviceEvent, ElementState, MouseButton as WinitMouseButton, WindowEvent},
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
keyboard::{KeyCode, ModifiersState, PhysicalKey},
window::{CursorGrabMode, Window, WindowId},
};
use crate::{
editor::{InputState, MouseButton, SceneEditorState, SceneObjectId, SelectionMode},
io::{choose_map_open_path, choose_map_save_path, load_map, save_map},
renderer::{
FLAG_AO, FLAG_CLOUDS, FLAG_FOG, FLAG_SELECTED_HIGHLIGHT, FLAG_SHADOWS, FLAG_USE_GRID,
GlobalsGpu, GpuRenderer, SceneGpuUpload, ViewportRectPx,
},
scene::{LightKind, Material, Scene, SdfPrimitive, Transform},
stats::FrameStats,
ui::{EditorUi, EditorUiOutput, UiAction},
};
pub fn run() -> Result<()> {
let event_loop = EventLoop::new().context("failed to create the window event loop")?;
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = RayMarchingEditorApp::default();
event_loop
.run_app(&mut app)
.context("the editor event loop stopped unexpectedly")?;
if let Some(error) = app.fatal_error {
return Err(error);
}
Ok(())
}
#[derive(Default)]
struct RayMarchingEditorApp {
runtime: Option<Runtime>,
fatal_error: Option<anyhow::Error>,
}
impl ApplicationHandler for RayMarchingEditorApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.runtime.is_some() {
return;
}
match Runtime::new(event_loop) {
Ok(runtime) => self.runtime = Some(runtime),
Err(error) => {
self.fatal_error = Some(error);
event_loop.exit();
}
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
window_id: WindowId,
event: WindowEvent,
) {
let Some(runtime) = self.runtime.as_mut() else {
return;
};
if runtime.window.id() != window_id {
return;
}
let egui_response = runtime.egui_state.on_window_event(&runtime.window, &event);
runtime.track_window_event(&event, egui_response.consumed);
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => runtime.renderer.resize(size),
WindowEvent::RedrawRequested => {
if let Err(error) = runtime.redraw() {
runtime.ui.status = format!("Ошибка рендера: {error:#}");
log::error!("frame failed: {error:#}");
}
runtime.window.request_redraw();
}
_ => {}
}
}
fn device_event(
&mut self,
_event_loop: &ActiveEventLoop,
_device_id: winit::event::DeviceId,
event: DeviceEvent,
) {
if let Some(runtime) = self.runtime.as_mut()
&& runtime.cursor_grabbed
{
runtime.input.handle_device_event(&event);
}
}
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
if let Some(runtime) = &self.runtime {
runtime.window.request_redraw();
}
}
}
struct Runtime {
window: Arc<Window>,
renderer: GpuRenderer,
egui_context: egui::Context,
egui_state: egui_winit::State,
egui_renderer: egui_wgpu::Renderer,
scene: Scene,
current_map_path: Option<PathBuf>,
editor: SceneEditorState,
input: InputState,
ui: EditorUi,
viewport: Rect,
viewport_hovered: bool,
cursor_position_px: PhysicalPosition<f64>,
cursor_grabbed: bool,
modifiers: ModifiersState,
pending_action: Option<UiAction>,
stats: FrameStats,
last_frame: Instant,
started_at: Instant,
}
impl Runtime {
fn new(event_loop: &ActiveEventLoop) -> Result<Self> {
let attributes = Window::default_attributes()
.with_title("Редактор Ray Marching — Vulkan")
.with_inner_size(LogicalSize::new(1600.0, 900.0))
.with_min_inner_size(LogicalSize::new(1024.0, 640.0))
.with_visible(false);
let window = Arc::new(
event_loop
.create_window(attributes)
.context("failed to create the editor window")?,
);
let renderer = pollster::block_on(GpuRenderer::new(window.clone()))?;
log::info!("using Vulkan adapter: {}", renderer.adapter_name());
let egui_context = egui::Context::default();
egui_context.set_visuals(egui::Visuals::dark());
let egui_state = egui_winit::State::new(
egui_context.clone(),
egui::ViewportId::ROOT,
window.as_ref(),
Some(window.scale_factor() as f32),
window.theme(),
Some(renderer.device().limits().max_texture_dimension_2d as usize),
);
let egui_renderer = egui_wgpu::Renderer::new(
renderer.device(),
renderer.format(),
egui_wgpu::RendererOptions::default(),
);
let scene = starter_scene();
let mut editor = SceneEditorState::default();
editor.restore_camera_from_scene(&scene);
let mut ui = EditorUi::default();
ui.vsync = renderer.vsync_enabled();
ui.status = if ui.vsync {
format!(
"Новая карта · режим без VSync недоступен · {:?}",
renderer.present_mode()
)
} else {
format!(
"Новая карта · VSync выключен · {:?}",
renderer.present_mode()
)
};
let now = Instant::now();
let size = window.inner_size();
window.set_title("Новая карта — Редактор Ray Marching — Vulkan");
window.set_visible(true);
Ok(Self {
window,
renderer,
egui_context,
egui_state,
egui_renderer,
scene,
current_map_path: None,
editor,
input: InputState::default(),
ui,
viewport: Rect::from_min_max(
egui::Pos2::ZERO,
egui::pos2(size.width as f32, size.height as f32),
),
viewport_hovered: false,
cursor_position_px: PhysicalPosition::new(0.0, 0.0),
cursor_grabbed: false,
modifiers: ModifiersState::default(),
pending_action: None,
stats: FrameStats::default(),
last_frame: now,
started_at: now,
})
}
fn track_window_event(&mut self, event: &WindowEvent, egui_consumed: bool) {
match event {
WindowEvent::CursorMoved { position, .. } => {
self.cursor_position_px = *position;
}
WindowEvent::ModifiersChanged(modifiers) => {
self.modifiers = modifiers.state();
}
WindowEvent::KeyboardInput {
event,
is_synthetic: false,
..
} if event.state == ElementState::Pressed
&& !event.repeat
&& self.modifiers.control_key() =>
{
self.pending_action = match event.physical_key {
PhysicalKey::Code(KeyCode::KeyS) if self.modifiers.shift_key() => {
Some(UiAction::SaveMapAs)
}
PhysicalKey::Code(KeyCode::KeyS) => Some(UiAction::SaveMap),
PhysicalKey::Code(KeyCode::KeyO) => Some(UiAction::LoadMap),
PhysicalKey::Code(KeyCode::KeyN) => Some(UiAction::NewMap),
_ => self.pending_action,
};
}
WindowEvent::MouseInput {
state,
button: WinitMouseButton::Right,
..
} => {
if *state == ElementState::Pressed && self.cursor_in_viewport() {
self.grab_cursor();
} else if *state == ElementState::Released && self.cursor_grabbed {
self.release_cursor();
}
}
WindowEvent::Focused(false) if self.cursor_grabbed => self.release_cursor(),
_ => {}
}
let release_event = matches!(
event,
WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Released
) || matches!(
event,
WindowEvent::MouseInput {
state: ElementState::Released,
..
}
);
let pointer_event = matches!(
event,
WindowEvent::CursorMoved { .. }
| WindowEvent::MouseInput { .. }
| WindowEvent::MouseWheel { .. }
);
let editor_owns_event = release_event
|| self.cursor_grabbed
|| (pointer_event && self.cursor_in_viewport())
|| (!pointer_event && !egui_consumed);
if editor_owns_event {
if self.cursor_grabbed && matches!(event, WindowEvent::CursorMoved { .. }) {
// Captured navigation uses raw DeviceEvent deltas only.
} else {
self.input.handle_window_event(event);
}
} else if matches!(event, WindowEvent::Focused(false)) {
self.input.handle_window_event(event);
}
}
fn cursor_in_viewport(&self) -> bool {
let scale = self.window.scale_factor() as f32;
self.viewport.contains(egui::pos2(
self.cursor_position_px.x as f32 / scale,
self.cursor_position_px.y as f32 / scale,
))
}
fn grab_cursor(&mut self) {
let grabbed = self
.window
.set_cursor_grab(CursorGrabMode::Locked)
.or_else(|_| self.window.set_cursor_grab(CursorGrabMode::Confined))
.is_ok();
if grabbed {
self.cursor_grabbed = true;
self.window.set_cursor_visible(false);
self.input.reset_cursor_position();
}
}
fn release_cursor(&mut self) {
let _ = self.window.set_cursor_grab(CursorGrabMode::None);
self.window.set_cursor_visible(true);
self.cursor_grabbed = false;
self.input.reset_cursor_position();
}
fn redraw(&mut self) -> Result<()> {
let frame_started = Instant::now();
let gpu_ms = self.renderer.poll_gpu_time();
let now = frame_started;
let delta = now.duration_since(self.last_frame);
self.last_frame = now;
let delta_seconds = delta.as_secs_f32().clamp(1.0e-6, 0.25);
let input = self.input.take_frame();
let viewport_size = Vec2::new(self.viewport.width(), self.viewport.height());
if !self.egui_context.egui_wants_keyboard_input() {
self.editor.handle_shortcuts(&mut self.scene, &input);
}
if self.cursor_grabbed || self.viewport_hovered || input.is_navigating() {
self.editor
.update_navigation(&input, delta_seconds, viewport_size.y);
}
self.update_viewport_interaction(&input, viewport_size);
let raw_input = self.egui_state.take_egui_input(&self.window);
let mut ui_output: Option<EditorUiOutput> = None;
let full_output = self.egui_context.run_ui(raw_input, |root_ui| {
ui_output = Some(
self.ui
.show(root_ui, &mut self.scene, &mut self.editor, &self.stats),
);
});
let ui_output = ui_output.expect("the root egui pass always runs");
self.viewport = ui_output.viewport;
self.viewport_hovered = ui_output.viewport_hovered;
self.egui_state
.handle_platform_output(&self.window, full_output.platform_output);
let action = self.pending_action.take().or(ui_output.action);
if let Some(action) = action {
self.apply_ui_action(action);
}
let game_seconds = frame_started.elapsed().as_secs_f32();
let draw_started = Instant::now();
let pixels_per_point = full_output.pixels_per_point;
let viewport_px = viewport_to_pixels(self.viewport, pixels_per_point, self.renderer.size());
let mut upload = SceneGpuUpload::from_scene(&self.scene);
for primitive in &mut upload.primitives {
let selected = self.editor.selection.ids().iter().any(|object| {
matches!(object, SceneObjectId::Primitive(_))
&& object.entity_id() == primitive.meta[2]
});
if selected {
primitive.meta[3] |= 2;
}
}
let grid = self
.ui
.use_grid
.then(|| upload.pack_spatial_grid(&self.scene, 4.0))
.flatten();
let globals = self.build_globals(viewport_px, grid);
self.renderer.upload(globals, &upload)?;
let frame = match self.renderer.acquire_frame() {
wgpu::CurrentSurfaceTexture::Success(frame) => frame,
wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => {
return Ok(());
}
wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
self.renderer.reconfigure();
return Ok(());
}
wgpu::CurrentSurfaceTexture::Validation => {
anyhow::bail!("Vulkan surface validation failed");
}
};
let target = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
for (id, image_delta) in &full_output.textures_delta.set {
self.egui_renderer.update_texture(
self.renderer.device(),
self.renderer.queue(),
*id,
image_delta,
);
}
let paint_jobs = self
.egui_context
.tessellate(full_output.shapes, pixels_per_point);
let screen = egui_wgpu::ScreenDescriptor {
size_in_pixels: [self.renderer.size().width, self.renderer.size().height],
pixels_per_point,
};
self.renderer.prepare_gpu_timing();
let mut encoder = self.renderer.create_encoder();
let callback_buffers = self.egui_renderer.update_buffers(
self.renderer.device(),
self.renderer.queue(),
&mut encoder,
&paint_jobs,
&screen,
);
self.renderer
.encode_scene(&mut encoder, &target, viewport_px);
{
let timestamp_writes = self.renderer.gpu_frame_end_timestamp_writes();
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("editor UI"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
timestamp_writes,
occlusion_query_set: None,
multiview_mask: None,
});
let mut pass = pass.forget_lifetime();
self.egui_renderer.render(&mut pass, &paint_jobs, &screen);
}
self.renderer.resolve_gpu_timing(&mut encoder);
let frame_commands = encoder.finish();
self.renderer
.queue()
.submit(callback_buffers.into_iter().chain([frame_commands]));
self.renderer.map_gpu_timing_after_submit();
frame.present();
for id in &full_output.textures_delta.free {
self.egui_renderer.free_texture(id);
}
self.stats.record_frame(
delta.as_secs_f32(),
game_seconds,
draw_started.elapsed().as_secs_f32(),
gpu_ms,
);
Ok(())
}
fn update_viewport_interaction(&mut self, input: &crate::editor::InputFrame, size: Vec2) {
let left_down = input.mouse_down(MouseButton::Left);
let scale = self.window.scale_factor() as f32;
let local_cursor = input.cursor_position.map(|cursor_px| {
cursor_px / scale - Vec2::new(self.viewport.min.x, self.viewport.min.y)
});
if self.editor.is_transform_dragging() {
if left_down {
if let Some(local_cursor) = local_cursor {
self.editor
.update_gizmo_drag(&mut self.scene, local_cursor, size);
}
} else {
self.editor.end_gizmo_drag();
}
return;
}
if !self.viewport_hovered {
self.editor.clear_gizmo_hover();
return;
}
let Some(local_cursor) = local_cursor else {
self.editor.clear_gizmo_hover();
return;
};
self.editor
.update_gizmo_hover(&self.scene, local_cursor, size);
if input.modifiers.alt || !input.mouse_pressed(MouseButton::Left) {
return;
}
// The gizmo owns the click before scene picking, exactly as an editor
// transform widget should. Object bodies only change selection.
if self
.editor
.begin_gizmo_drag(&self.scene, local_cursor, size)
.is_some()
{
return;
}
let selection_mode = if input.modifiers.control {
SelectionMode::Toggle
} else {
SelectionMode::Replace
};
self.editor
.select_at_with_mode(&self.scene, local_cursor, size, selection_mode);
}
fn apply_ui_action(&mut self, action: UiAction) {
match action {
UiAction::NewMap => {
self.scene = starter_scene();
self.editor = SceneEditorState::default();
self.editor.restore_camera_from_scene(&self.scene);
self.current_map_path = None;
self.update_window_title();
self.ui.status = "Создана новая несохранённая карта".into();
}
UiAction::SaveMap => {
if let Some(path) = self.current_map_path.clone() {
self.save_to_path(path);
} else {
self.save_as();
}
}
UiAction::SaveMapAs => self.save_as(),
UiAction::LoadMap => {
let selected =
choose_map_open_path(self.window.as_ref(), self.current_map_path.as_deref());
if let Some(path) = selected {
match load_map(&path) {
Ok(scene) => {
self.scene = scene;
self.editor = SceneEditorState::default();
self.editor.restore_camera_from_scene(&self.scene);
self.current_map_path = Some(path.clone());
self.update_window_title();
self.ui.status = format!("Карта загружена: {}", path.display());
}
Err(error) => {
self.ui.status = format!("Ошибка загрузки: {error}");
}
}
} else {
self.ui.status = "Открытие карты отменено".into();
}
}
UiAction::SetVsync(enabled) => {
let honored = self.renderer.set_vsync(enabled);
self.ui.vsync = self.renderer.vsync_enabled();
self.ui.status = if honored {
format!(
"VSync {} · режим {:?}",
if enabled {
"включён"
} else {
"выключен"
},
self.renderer.present_mode()
)
} else {
format!(
"Драйвер не поддерживает запрошенный режим VSync · используется {:?}",
self.renderer.present_mode()
)
};
}
}
}
fn save_as(&mut self) {
let selected = choose_map_save_path(self.window.as_ref(), self.current_map_path.as_deref());
if let Some(path) = selected {
self.save_to_path(path);
} else {
self.ui.status = "Сохранение карты отменено".into();
}
}
fn save_to_path(&mut self, path: PathBuf) {
self.editor.store_camera_in_scene(&mut self.scene);
match save_map(&path, &self.scene) {
Ok(()) => {
self.current_map_path = Some(path.clone());
self.update_window_title();
self.ui.status = format!("Карта сохранена: {}", path.display());
}
Err(error) => self.ui.status = format!("Ошибка сохранения: {error}"),
}
}
fn update_window_title(&self) {
let map_name = self
.current_map_path
.as_deref()
.and_then(std::path::Path::file_name)
.and_then(|name| name.to_str())
.unwrap_or("Новая карта");
self.window
.set_title(&format!("{map_name} — Редактор Ray Marching — Vulkan"));
}
fn build_globals(
&self,
viewport: ViewportRectPx,
grid: Option<crate::renderer::GridGpuInfo>,
) -> GlobalsGpu {
let aspect = viewport.width.max(1) as f32 / viewport.height.max(1) as f32;
let mut globals = GlobalsGpu {
inv_view_proj: self
.editor
.camera
.view_projection_matrix(aspect)
.inverse()
.to_cols_array_2d(),
camera_pos_time: [
self.editor.camera.position.x,
self.editor.camera.position.y,
self.editor.camera.position.z,
self.started_at.elapsed().as_secs_f32(),
],
viewport_maxdist_epsilon: [
viewport.width as f32,
viewport.height as f32,
self.editor.raymarch.max_distance,
self.editor.raymarch.hit_epsilon,
],
sun_color_ambient: [
self.scene.lighting.ambient_color.x,
self.scene.lighting.ambient_color.y,
self.scene.lighting.ambient_color.z,
self.scene.lighting.ambient_intensity,
],
fog_color_density: [
self.scene.background_color.x,
self.scene.background_color.y,
self.scene.background_color.z,
0.008,
],
raymarch_params: [
0.82,
self.scene.lighting.shadow_softness,
0.65,
self.scene.clouds.coverage,
],
..GlobalsGpu::default()
};
if let Some(sun) = self
.scene
.lights()
.iter()
.find(|light| light.enabled && matches!(light.kind, LightKind::DirectionalSun))
{
let to_sun = -sun.direction();
globals.sun_dir_intensity = [to_sun.x, to_sun.y, to_sun.z, sun.intensity];
}
let mut flags = 0;
if self.ui.shadows {
flags |= FLAG_SHADOWS;
}
if self.ui.ambient_occlusion {
flags |= FLAG_AO;
}
if self.ui.fog {
flags |= FLAG_FOG;
}
if self.ui.clouds && self.scene.clouds.enabled {
flags |= FLAG_CLOUDS;
}
if let Some(selected) = self.editor.selection.active() {
globals.counts_flags[2] = selected.entity_id();
}
if self
.editor
.selection
.ids()
.iter()
.any(|selected| matches!(selected, SceneObjectId::Primitive(_)))
{
flags |= FLAG_SELECTED_HIGHLIGHT;
}
if let Some(grid) = grid {
flags |= FLAG_USE_GRID;
globals.grid_origin_cell_size =
[grid.origin.x, grid.origin.y, grid.origin.z, grid.cell_size];
globals.grid_dims_steps = [
grid.dimensions.x,
grid.dimensions.y,
grid.dimensions.z,
self.editor.raymarch.max_steps.min(192),
];
} else {
globals.grid_dims_steps[3] = self.editor.raymarch.max_steps.min(192);
}
globals.counts_flags[3] = flags;
globals
}
}
fn viewport_to_pixels(
rect: Rect,
pixels_per_point: f32,
target: winit::dpi::PhysicalSize<u32>,
) -> ViewportRectPx {
ViewportRectPx {
x: (rect.min.x * pixels_per_point).round().max(0.0) as u32,
y: (rect.min.y * pixels_per_point).round().max(0.0) as u32,
width: (rect.width() * pixels_per_point).round().max(1.0) as u32,
height: (rect.height() * pixels_per_point).round().max(1.0) as u32,
}
.clamped(target)
}
fn starter_scene() -> Scene {
let mut scene = Scene::default();
scene.name = "Карта Ray Marching".into();
let ground_material = Material {
albedo: Vec3::new(0.18, 0.21, 0.25),
roughness: 0.9,
..Material::default()
};
scene.add_primitive_with(
"Земля",
SdfPrimitive::Plane {
normal: Vec3::Z,
offset: 0.0,
},
Transform::default(),
ground_material,
);
scene.add_primitive_with(
"Рубиновая сфера",
SdfPrimitive::Sphere { radius: 1.0 },
Transform::from_translation(Vec3::new(-1.7, 0.0, 1.0)),
Material {
albedo: Vec3::new(0.72, 0.08, 0.11),
roughness: 0.3,
metallic: 0.08,
..Material::default()
},
);
scene.add_primitive_with(
"Скруглённый блок",
SdfPrimitive::RoundedBox {
half_extents: Vec3::new(1.0, 1.0, 1.0),
radius: 0.22,
},
Transform {
translation: Vec3::new(1.45, 0.1, 1.0),
rotation: Quat::from_rotation_z(-0.35),
scale: Vec3::ONE,
},
Material {
albedo: Vec3::new(0.08, 0.32, 0.72),
roughness: 0.42,
metallic: 0.2,
..Material::default()
},
);
scene.add_primitive_with(
"Латунный тор",
SdfPrimitive::Torus {
major_radius: 1.0,
minor_radius: 0.25,
},
Transform {
translation: Vec3::new(0.0, -2.4, 1.3),
rotation: Quat::from_rotation_x(0.6),
scale: Vec3::ONE,
},
Material {
albedo: Vec3::new(0.62, 0.36, 0.08),
roughness: 0.22,
metallic: 0.82,
..Material::default()
},
);
scene
}
+253
View File
@@ -0,0 +1,253 @@
use glam::{Mat4, Vec2, Vec3};
const MIN_NEAR: f32 = 0.000_1;
const MIN_FOV: f32 = 1.0_f32.to_radians();
const MAX_FOV: f32 = 175.0_f32.to_radians();
const PITCH_LIMIT: f32 = 89.5_f32.to_radians();
/// Editor world convention: X points right, Y points forward and Z is height.
pub const WORLD_UP: Vec3 = Vec3::Z;
pub const WORLD_FORWARD: Vec3 = Vec3::Y;
/// Perspective settings kept separate from the renderer's GPU camera data.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CameraProjection {
pub vertical_fov: f32,
pub near: f32,
pub far: f32,
}
impl Default for CameraProjection {
fn default() -> Self {
Self {
vertical_fov: 60.0_f32.to_radians(),
near: 0.05,
far: 10_000.0,
}
}
}
/// A simple right-handed Z-up editor camera. Yaw zero looks along world +Y.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Camera {
pub position: Vec3,
pub yaw: f32,
pub pitch: f32,
pub projection: CameraProjection,
}
impl Default for Camera {
fn default() -> Self {
Self {
position: Vec3::new(7.0, -9.0, 5.0),
yaw: -38.0_f32.to_radians(),
pitch: -18.0_f32.to_radians(),
projection: CameraProjection::default(),
}
}
}
/// World-space ray cast from a viewport pixel.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ViewportRay {
pub origin: Vec3,
pub direction: Vec3,
}
/// Bounds used by the editor's `F` (focus selection) command.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FocusTarget {
pub center: Vec3,
pub radius: f32,
}
impl FocusTarget {
pub fn new(center: Vec3, radius: f32) -> Self {
Self {
center,
radius: radius.max(0.001),
}
}
}
impl Camera {
pub fn new(position: Vec3, yaw: f32, pitch: f32) -> Self {
let mut camera = Self {
position,
yaw,
pitch,
projection: CameraProjection::default(),
};
camera.sanitize_angles();
camera
}
/// Unit vector in the direction the camera is looking.
pub fn forward(&self) -> Vec3 {
let (sin_yaw, cos_yaw) = self.yaw.sin_cos();
let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
Vec3::new(sin_yaw * cos_pitch, cos_yaw * cos_pitch, sin_pitch).normalize()
}
pub fn right(&self) -> Vec3 {
self.forward().cross(WORLD_UP).normalize()
}
pub fn up(&self) -> Vec3 {
self.right().cross(self.forward()).normalize()
}
pub fn view_matrix(&self) -> Mat4 {
glam::camera::rh::view::look_to_mat4(self.position, self.forward(), self.up())
}
/// Right-handed projection with a Vulkan/WebGPU [0, 1] depth range.
pub fn projection_matrix(&self, aspect_ratio: f32) -> Mat4 {
let projection = self.sanitized_projection();
glam::camera::rh::proj::directx::perspective(
projection.vertical_fov,
aspect_ratio.max(0.001),
projection.near,
projection.far,
)
}
pub fn view_projection_matrix(&self, aspect_ratio: f32) -> Mat4 {
self.projection_matrix(aspect_ratio) * self.view_matrix()
}
pub fn rotate(&mut self, yaw_delta: f32, pitch_delta: f32) {
self.yaw += yaw_delta;
self.pitch += pitch_delta;
self.sanitize_angles();
}
pub fn look_at(&mut self, target: Vec3) {
let direction = (target - self.position).normalize_or_zero();
if direction.length_squared() <= f32::EPSILON {
return;
}
self.pitch = direction.z.clamp(-1.0, 1.0).asin();
self.yaw = direction.x.atan2(direction.y);
self.sanitize_angles();
}
/// Move far enough back to fit a sphere in view while preserving orientation.
pub fn focus(&mut self, target: FocusTarget, margin: f32) -> f32 {
let half_fov = self.projection.vertical_fov.clamp(MIN_FOV, MAX_FOV) * 0.5;
let distance = target.radius * margin.max(1.0) / half_fov.sin().max(0.001);
self.position = target.center - self.forward() * distance;
distance
}
/// Convert a top-left-origin viewport coordinate to a normalized world ray.
pub fn viewport_ray(&self, pixel: Vec2, viewport_size: Vec2) -> Option<ViewportRay> {
if !pixel.is_finite()
|| !viewport_size.is_finite()
|| viewport_size.x <= 0.0
|| viewport_size.y <= 0.0
{
return None;
}
let ndc = Vec2::new(
pixel.x.mul_add(2.0 / viewport_size.x, -1.0),
1.0 - pixel.y * (2.0 / viewport_size.y),
);
let tan_half_fov = (self.projection.vertical_fov.clamp(MIN_FOV, MAX_FOV) * 0.5).tan();
let aspect = viewport_size.x / viewport_size.y;
let direction = (self.forward()
+ self.right() * (ndc.x * aspect * tan_half_fov)
+ self.up() * (ndc.y * tan_half_fov))
.normalize();
Some(ViewportRay {
origin: self.position,
direction,
})
}
fn sanitized_projection(&self) -> CameraProjection {
let near = self.projection.near.max(MIN_NEAR);
CameraProjection {
vertical_fov: self.projection.vertical_fov.clamp(MIN_FOV, MAX_FOV),
near,
far: self.projection.far.max(near + MIN_NEAR),
}
}
fn sanitize_angles(&mut self) {
self.pitch = self.pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT);
self.yaw = wrap_angle(self.yaw);
}
}
fn wrap_angle(angle: f32) -> f32 {
(angle + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI
}
#[cfg(test)]
mod tests {
use super::*;
const EPSILON: f32 = 1.0e-5;
#[test]
fn default_coordinate_basis_is_orthonormal() {
let camera = Camera::new(Vec3::ZERO, 0.0, 0.0);
assert!(camera.forward().abs_diff_eq(Vec3::Y, EPSILON));
assert!(camera.right().abs_diff_eq(Vec3::X, EPSILON));
assert!(camera.up().abs_diff_eq(Vec3::Z, EPSILON));
assert!(camera.forward().dot(camera.right()).abs() < EPSILON);
assert!(camera.forward().dot(camera.up()).abs() < EPSILON);
assert!(camera.right().dot(camera.up()).abs() < EPSILON);
}
#[test]
fn pitch_is_clamped_away_from_the_poles() {
let mut camera = Camera::new(Vec3::ZERO, 0.0, 0.0);
camera.rotate(0.0, std::f32::consts::PI);
assert!(camera.pitch < std::f32::consts::FRAC_PI_2);
assert!(camera.forward().is_finite());
assert!(camera.right().is_finite());
}
#[test]
fn viewport_center_ray_matches_forward() {
let camera = Camera::new(Vec3::new(1.0, 2.0, 3.0), 0.7, -0.2);
let ray = camera
.viewport_ray(Vec2::new(640.0, 360.0), Vec2::new(1280.0, 720.0))
.unwrap();
assert_eq!(ray.origin, camera.position);
assert!(ray.direction.abs_diff_eq(camera.forward(), EPSILON));
}
#[test]
fn focus_places_target_on_forward_axis() {
let mut camera = Camera::new(Vec3::ZERO, 0.4, -0.1);
let target = FocusTarget::new(Vec3::new(3.0, 1.0, -4.0), 2.0);
let distance = camera.focus(target, 1.25);
let offset = target.center - camera.position;
assert!((offset.length() - distance).abs() < EPSILON);
assert!(offset.normalize().abs_diff_eq(camera.forward(), EPSILON));
}
#[test]
fn look_at_uses_z_as_height_and_preserves_an_orthonormal_basis() {
let mut camera = Camera::new(Vec3::new(4.0, -7.0, 3.0), 0.0, 0.0);
let target = Vec3::new(-2.0, 5.0, 9.0);
camera.look_at(target);
assert!(
camera
.forward()
.abs_diff_eq((target - camera.position).normalize(), EPSILON)
);
assert!(
camera
.right()
.abs_diff_eq(camera.forward().cross(Vec3::Z).normalize(), EPSILON)
);
assert!(camera.up().z > 0.0);
}
}
+59
View File
@@ -0,0 +1,59 @@
use std::time::Duration;
/// Minimal per-frame timing state for the viewport overlay.
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub struct FpsCounter {
fps: f32,
frame_time_ms: f32,
}
impl FpsCounter {
/// Record a completed frame. The displayed value changes every frame and
/// intentionally has no smoothing or publication delay.
pub fn record_frame(&mut self, delta: Duration) -> f32 {
self.record_seconds(delta.as_secs_f32())
}
pub fn record_seconds(&mut self, seconds: f32) -> f32 {
if seconds.is_finite() && seconds > 0.0 {
self.fps = seconds.recip();
self.frame_time_ms = seconds * 1000.0;
}
self.fps
}
pub fn fps(&self) -> f32 {
self.fps
}
pub fn frame_time_ms(&self) -> f32 {
self.frame_time_ms
}
pub fn reset(&mut self) {
*self = Self::default();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reports_inverse_of_latest_frame_delta() {
let mut counter = FpsCounter::default();
assert!((counter.record_seconds(1.0 / 60.0) - 60.0).abs() < 0.001);
assert!((counter.record_seconds(1.0 / 120.0) - 120.0).abs() < 0.001);
assert!((counter.frame_time_ms() - 1000.0 / 120.0).abs() < 0.001);
}
#[test]
fn invalid_samples_leave_last_value_unchanged() {
let mut counter = FpsCounter::default();
counter.record_seconds(0.02);
let before = counter;
counter.record_seconds(0.0);
counter.record_seconds(f32::NAN);
assert_eq!(counter, before);
}
}
+582
View File
@@ -0,0 +1,582 @@
use glam::{Quat, Vec2, Vec3, Vec4};
use super::{Axis, Camera, TransformMode};
/// A two-axis translation constraint.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Plane {
XY,
XZ,
YZ,
}
impl Plane {
pub const fn axes(self) -> (Axis, Axis) {
match self {
Self::XY => (Axis::X, Axis::Y),
Self::XZ => (Axis::X, Axis::Z),
Self::YZ => (Axis::Y, Axis::Z),
}
}
pub(crate) const fn indices(self) -> (usize, usize) {
match self {
Self::XY => (0, 1),
Self::XZ => (0, 2),
Self::YZ => (1, 2),
}
}
}
/// A concrete interactive part of the transform gizmo.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GizmoHandle {
TranslateAxis(Axis),
TranslatePlane(Plane),
RotateAxis(Axis),
ScaleAxis(Axis),
ScaleUniform,
}
impl GizmoHandle {
pub const fn mode(self) -> TransformMode {
match self {
Self::TranslateAxis(_) | Self::TranslatePlane(_) => TransformMode::Translate,
Self::RotateAxis(_) => TransformMode::Rotate,
Self::ScaleAxis(_) | Self::ScaleUniform => TransformMode::Scale,
}
}
pub const fn axis(self) -> Option<Axis> {
match self {
Self::TranslateAxis(axis) | Self::RotateAxis(axis) | Self::ScaleAxis(axis) => {
Some(axis)
}
Self::TranslatePlane(_) | Self::ScaleUniform => None,
}
}
}
/// Result of screen-space gizmo hit-testing. Smaller distance wins when parts
/// overlap; plane and uniform handles report zero while the pointer is inside.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GizmoHit {
pub handle: GizmoHandle,
pub distance_pixels: f32,
}
/// Pixel dimensions are deliberately independent of camera distance, like the
/// transform widget in conventional level editors.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GizmoConfig {
pub axis_length_pixels: f32,
pub axis_pick_radius_pixels: f32,
pub plane_inner_pixels: f32,
pub plane_outer_pixels: f32,
pub ring_radius_pixels: f32,
pub ring_pick_radius_pixels: f32,
pub uniform_pick_radius_pixels: f32,
}
impl Default for GizmoConfig {
fn default() -> Self {
Self {
axis_length_pixels: 82.0,
axis_pick_radius_pixels: 7.0,
plane_inner_pixels: 18.0,
plane_outer_pixels: 34.0,
ring_radius_pixels: 64.0,
ring_pick_radius_pixels: 7.0,
uniform_pick_radius_pixels: 10.0,
}
}
}
pub(crate) fn hit_test(
camera: &Camera,
pivot: Vec3,
orientation: Quat,
mode: TransformMode,
pixel: Vec2,
viewport_size: Vec2,
config: GizmoConfig,
) -> Option<GizmoHit> {
if !pixel.is_finite()
|| !viewport_size.is_finite()
|| viewport_size.x <= 0.0
|| viewport_size.y <= 0.0
{
return None;
}
let pivot_screen = project_world(camera, pivot, viewport_size)?;
let world_scale = world_scale(camera, pivot, viewport_size.y, config.axis_length_pixels)?;
let basis = basis(orientation);
match mode {
TransformMode::Translate => hit_translate(
camera,
pivot,
pivot_screen,
basis,
world_scale,
pixel,
viewport_size,
config,
),
TransformMode::Rotate => hit_rotate(
camera,
pivot,
basis,
world_scale,
pixel,
viewport_size,
config,
),
TransformMode::Scale => hit_scale(
camera,
pivot,
pivot_screen,
basis,
world_scale,
pixel,
viewport_size,
config,
),
}
}
#[allow(clippy::too_many_arguments)]
fn hit_translate(
camera: &Camera,
pivot: Vec3,
pivot_screen: Vec2,
basis: [Vec3; 3],
world_scale: f32,
pixel: Vec2,
viewport_size: Vec2,
config: GizmoConfig,
) -> Option<GizmoHit> {
let screens = projected_axis_endpoints(camera, pivot, basis, world_scale, viewport_size);
// Plane handles get priority over axes when their filled areas overlap.
for plane in [Plane::XY, Plane::XZ, Plane::YZ] {
let (a, b) = plane.indices();
let Some(axis_a) = screens[a].map(|end| end - pivot_screen) else {
continue;
};
let Some(axis_b) = screens[b].map(|end| end - pivot_screen) else {
continue;
};
let Some(dir_a) = normalized_screen_axis(axis_a) else {
continue;
};
let Some(dir_b) = normalized_screen_axis(axis_b) else {
continue;
};
let inner = config.plane_inner_pixels;
let outer = config.plane_outer_pixels;
let quad = [
pivot_screen + dir_a * inner + dir_b * inner,
pivot_screen + dir_a * outer + dir_b * inner,
pivot_screen + dir_a * outer + dir_b * outer,
pivot_screen + dir_a * inner + dir_b * outer,
];
if polygon_area(&quad).abs() >= 12.0 && point_in_convex_quad(pixel, quad) {
return Some(GizmoHit {
handle: GizmoHandle::TranslatePlane(plane),
distance_pixels: 0.0,
});
}
}
closest_axis_hit(
pivot_screen,
screens,
pixel,
config.axis_pick_radius_pixels,
GizmoHandle::TranslateAxis,
)
}
#[allow(clippy::too_many_arguments)]
fn hit_scale(
camera: &Camera,
pivot: Vec3,
pivot_screen: Vec2,
basis: [Vec3; 3],
world_scale: f32,
pixel: Vec2,
viewport_size: Vec2,
config: GizmoConfig,
) -> Option<GizmoHit> {
if pixel.distance(pivot_screen) <= config.uniform_pick_radius_pixels {
return Some(GizmoHit {
handle: GizmoHandle::ScaleUniform,
distance_pixels: pixel.distance(pivot_screen),
});
}
let screens = projected_axis_endpoints(camera, pivot, basis, world_scale, viewport_size);
closest_axis_hit(
pivot_screen,
screens,
pixel,
config.axis_pick_radius_pixels,
GizmoHandle::ScaleAxis,
)
}
fn hit_rotate(
camera: &Camera,
pivot: Vec3,
basis: [Vec3; 3],
world_scale: f32,
pixel: Vec2,
viewport_size: Vec2,
config: GizmoConfig,
) -> Option<GizmoHit> {
const SEGMENTS: usize = 72;
let radius = world_scale * config.ring_radius_pixels / config.axis_length_pixels.max(1.0);
let ring_bases = [
(basis[1], basis[2]),
(basis[2], basis[0]),
(basis[0], basis[1]),
];
let axes = [Axis::X, Axis::Y, Axis::Z];
let mut best: Option<GizmoHit> = None;
for (axis_index, (u, v)) in ring_bases.into_iter().enumerate() {
let mut previous = project_world(camera, pivot + u * radius, viewport_size);
let mut minimum = f32::INFINITY;
for segment in 1..=SEGMENTS {
let angle = segment as f32 * std::f32::consts::TAU / SEGMENTS as f32;
let point = pivot + (u * angle.cos() + v * angle.sin()) * radius;
let current = project_world(camera, point, viewport_size);
if let (Some(from), Some(to)) = (previous, current) {
minimum = minimum.min(distance_to_segment(pixel, from, to));
}
previous = current;
}
if minimum <= config.ring_pick_radius_pixels
&& best.is_none_or(|candidate| minimum < candidate.distance_pixels)
{
best = Some(GizmoHit {
handle: GizmoHandle::RotateAxis(axes[axis_index]),
distance_pixels: minimum,
});
}
}
best
}
fn closest_axis_hit(
pivot_screen: Vec2,
screens: [Option<Vec2>; 3],
pixel: Vec2,
tolerance: f32,
make_handle: impl Fn(Axis) -> GizmoHandle,
) -> Option<GizmoHit> {
let axes = [Axis::X, Axis::Y, Axis::Z];
let mut best: Option<GizmoHit> = None;
for (index, endpoint) in screens.into_iter().enumerate() {
let Some(endpoint) = endpoint else {
continue;
};
let start = pivot_screen.lerp(endpoint, 0.13);
let distance = distance_to_segment(pixel, start, endpoint);
if distance <= tolerance
&& best.is_none_or(|candidate| distance < candidate.distance_pixels)
{
best = Some(GizmoHit {
handle: make_handle(axes[index]),
distance_pixels: distance,
});
}
}
best
}
pub(crate) fn basis(orientation: Quat) -> [Vec3; 3] {
[
(orientation * Vec3::X).normalize_or_zero(),
(orientation * Vec3::Y).normalize_or_zero(),
(orientation * Vec3::Z).normalize_or_zero(),
]
}
pub(crate) fn world_scale(
camera: &Camera,
pivot: Vec3,
viewport_height: f32,
pixel_length: f32,
) -> Option<f32> {
if !pivot.is_finite() || !viewport_height.is_finite() || viewport_height <= 0.0 {
return None;
}
let distance = (pivot - camera.position).dot(camera.forward());
if !distance.is_finite() || distance <= camera.projection.near {
return None;
}
let units_per_pixel =
2.0 * distance * (camera.projection.vertical_fov * 0.5).tan() / viewport_height;
Some((units_per_pixel * pixel_length).max(0.000_1))
}
pub(crate) fn project_world(camera: &Camera, world: Vec3, viewport_size: Vec2) -> Option<Vec2> {
if !world.is_finite()
|| !viewport_size.is_finite()
|| viewport_size.x <= 0.0
|| viewport_size.y <= 0.0
{
return None;
}
let view_projection = camera.view_projection_matrix(viewport_size.x / viewport_size.y);
let clip: Vec4 = view_projection * world.extend(1.0);
if !clip.is_finite() || clip.w <= 0.001 {
return None;
}
let ndc = clip.truncate().truncate() / clip.w;
Some(Vec2::new(
(ndc.x * 0.5 + 0.5) * viewport_size.x,
(0.5 - ndc.y * 0.5) * viewport_size.y,
))
}
fn projected_axis_endpoints(
camera: &Camera,
pivot: Vec3,
basis: [Vec3; 3],
world_scale: f32,
viewport_size: Vec2,
) -> [Option<Vec2>; 3] {
basis.map(|axis| project_world(camera, pivot + axis * world_scale, viewport_size))
}
fn normalized_screen_axis(axis: Vec2) -> Option<Vec2> {
(axis.length_squared() >= 4.0).then(|| axis.normalize())
}
fn distance_to_segment(point: Vec2, from: Vec2, to: Vec2) -> f32 {
let segment = to - from;
let length_squared = segment.length_squared();
if length_squared <= f32::EPSILON {
return point.distance(from);
}
let t = ((point - from).dot(segment) / length_squared).clamp(0.0, 1.0);
point.distance(from + segment * t)
}
fn polygon_area(points: &[Vec2; 4]) -> f32 {
let mut area = 0.0;
for index in 0..4 {
let next = (index + 1) % 4;
area += points[index].x * points[next].y - points[next].x * points[index].y;
}
area * 0.5
}
fn point_in_convex_quad(point: Vec2, quad: [Vec2; 4]) -> bool {
let mut sign = 0.0_f32;
for index in 0..4 {
let edge = quad[(index + 1) % 4] - quad[index];
let to_point = point - quad[index];
let cross = edge.perp_dot(to_point);
if cross.abs() <= 0.001 {
continue;
}
if sign == 0.0 {
sign = cross.signum();
} else if sign * cross < 0.0 {
return false;
}
}
sign != 0.0
}
#[cfg(test)]
mod tests {
use super::*;
fn test_camera() -> Camera {
Camera::new(Vec3::new(0.0, -5.0, 0.0), 0.0, 0.0)
}
#[test]
fn translate_axes_and_plane_are_screen_pickable() {
let camera = test_camera();
let viewport = Vec2::new(800.0, 600.0);
let config = GizmoConfig::default();
let x_axis = hit_test(
&camera,
Vec3::ZERO,
Quat::IDENTITY,
TransformMode::Translate,
Vec2::new(460.0, 300.0),
viewport,
config,
)
.unwrap();
assert_eq!(x_axis.handle, GizmoHandle::TranslateAxis(Axis::X));
let xz_plane = hit_test(
&camera,
Vec3::ZERO,
Quat::IDENTITY,
TransformMode::Translate,
Vec2::new(426.0, 274.0),
viewport,
config,
)
.unwrap();
assert_eq!(xz_plane.handle, GizmoHandle::TranslatePlane(Plane::XZ));
}
#[test]
fn rotate_ring_and_scale_uniform_are_screen_pickable() {
let camera = test_camera();
let viewport = Vec2::new(800.0, 600.0);
let config = GizmoConfig::default();
let ring = hit_test(
&camera,
Vec3::ZERO,
Quat::IDENTITY,
TransformMode::Rotate,
Vec2::new(445.25, 254.75),
viewport,
config,
)
.unwrap();
assert_eq!(ring.handle, GizmoHandle::RotateAxis(Axis::Y));
let uniform = hit_test(
&camera,
Vec3::ZERO,
Quat::IDENTITY,
TransformMode::Scale,
Vec2::new(400.0, 300.0),
viewport,
config,
)
.unwrap();
assert_eq!(uniform.handle, GizmoHandle::ScaleUniform);
}
#[test]
fn every_required_axis_plane_ring_and_uniform_handle_can_be_hit() {
let mut camera = Camera::new(Vec3::new(4.0, 3.0, 5.0), 0.0, 0.0);
camera.look_at(Vec3::ZERO);
let viewport = Vec2::new(1_000.0, 800.0);
let config = GizmoConfig::default();
let pivot = Vec3::ZERO;
let pivot_screen = project_world(&camera, pivot, viewport).unwrap();
let scale = world_scale(&camera, pivot, viewport.y, config.axis_length_pixels).unwrap();
let world_basis = basis(Quat::IDENTITY);
let endpoints = projected_axis_endpoints(&camera, pivot, world_basis, scale, viewport);
let axes = [Axis::X, Axis::Y, Axis::Z];
for (index, axis) in axes.into_iter().enumerate() {
let pointer = pivot_screen.lerp(endpoints[index].unwrap(), 0.82);
let translate = hit_test(
&camera,
pivot,
Quat::IDENTITY,
TransformMode::Translate,
pointer,
viewport,
config,
)
.unwrap();
assert_eq!(translate.handle, GizmoHandle::TranslateAxis(axis));
let scale_axis = hit_test(
&camera,
pivot,
Quat::IDENTITY,
TransformMode::Scale,
pointer,
viewport,
config,
)
.unwrap();
assert_eq!(scale_axis.handle, GizmoHandle::ScaleAxis(axis));
}
for plane in [Plane::XY, Plane::XZ, Plane::YZ] {
let (a, b) = plane.indices();
let direction_a = (endpoints[a].unwrap() - pivot_screen).normalize();
let direction_b = (endpoints[b].unwrap() - pivot_screen).normalize();
let mut found = false;
'search: for along_a in 20..=32 {
for along_b in 20..=32 {
let pointer =
pivot_screen + direction_a * along_a as f32 + direction_b * along_b as f32;
if hit_test(
&camera,
pivot,
Quat::IDENTITY,
TransformMode::Translate,
pointer,
viewport,
config,
)
.is_some_and(|hit| hit.handle == GizmoHandle::TranslatePlane(plane))
{
found = true;
break 'search;
}
}
}
assert!(found, "translation plane {plane:?} was not pickable");
}
let radius = scale * config.ring_radius_pixels / config.axis_length_pixels;
let ring_bases = [
(world_basis[1], world_basis[2]),
(world_basis[2], world_basis[0]),
(world_basis[0], world_basis[1]),
];
for (index, axis) in axes.into_iter().enumerate() {
let (u, v) = ring_bases[index];
let mut found = false;
for step in 0..360 {
let angle = step as f32 * std::f32::consts::TAU / 360.0;
let Some(pointer) = project_world(
&camera,
pivot + (u * angle.cos() + v * angle.sin()) * radius,
viewport,
) else {
continue;
};
if hit_test(
&camera,
pivot,
Quat::IDENTITY,
TransformMode::Rotate,
pointer,
viewport,
config,
)
.is_some_and(|hit| hit.handle == GizmoHandle::RotateAxis(axis))
{
found = true;
break;
}
}
assert!(found, "rotation ring {axis:?} was not pickable");
}
assert_eq!(
hit_test(
&camera,
pivot,
Quat::IDENTITY,
TransformMode::Scale,
pivot_screen,
viewport,
config,
)
.unwrap()
.handle,
GizmoHandle::ScaleUniform
);
}
}
+450
View File
@@ -0,0 +1,450 @@
use glam::{Vec2, Vec3};
use winit::{
event::{DeviceEvent, ElementState, MouseScrollDelta, WindowEvent},
keyboard::{KeyCode, PhysicalKey},
};
use super::{Camera, WORLD_UP};
const KEY_COUNT: usize = 9;
const MOUSE_COUNT: usize = 3;
/// Physical editor keys. W/E are interpreted contextually: camera movement
/// while navigating and gizmo shortcuts otherwise.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum EditorKey {
W,
A,
S,
D,
Q,
E,
R,
F,
Delete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum MouseButton {
Left,
Middle,
Right,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Modifiers {
pub alt: bool,
pub control: bool,
pub shift: bool,
}
/// Immutable input snapshot consumed once by the editor each frame.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct InputFrame {
keys_down: [bool; KEY_COUNT],
keys_pressed: [bool; KEY_COUNT],
mouse_down: [bool; MOUSE_COUNT],
mouse_pressed: [bool; MOUSE_COUNT],
pub modifiers: Modifiers,
pub mouse_delta: Vec2,
pub cursor_position: Option<Vec2>,
pub wheel_steps: f32,
}
impl Default for InputFrame {
fn default() -> Self {
Self {
keys_down: [false; KEY_COUNT],
keys_pressed: [false; KEY_COUNT],
mouse_down: [false; MOUSE_COUNT],
mouse_pressed: [false; MOUSE_COUNT],
modifiers: Modifiers::default(),
mouse_delta: Vec2::ZERO,
cursor_position: None,
wheel_steps: 0.0,
}
}
}
impl InputFrame {
pub fn key_down(&self, key: EditorKey) -> bool {
self.keys_down[key as usize]
}
pub fn key_pressed(&self, key: EditorKey) -> bool {
self.keys_pressed[key as usize]
}
pub fn mouse_down(&self, button: MouseButton) -> bool {
self.mouse_down[button as usize]
}
pub fn mouse_pressed(&self, button: MouseButton) -> bool {
self.mouse_pressed[button as usize]
}
pub fn is_navigating(&self) -> bool {
self.mouse_down(MouseButton::Right)
|| self.mouse_down(MouseButton::Middle)
|| (self.modifiers.alt && self.mouse_down(MouseButton::Left))
}
}
/// Stateful event accumulator. It is deliberately separate from editor logic,
/// so an egui panel or another frontend can feed the same controls manually.
#[derive(Debug, Clone)]
pub struct InputState {
keys_down: [bool; KEY_COUNT],
keys_pressed: [bool; KEY_COUNT],
mouse_down: [bool; MOUSE_COUNT],
mouse_pressed: [bool; MOUSE_COUNT],
modifiers: Modifiers,
mouse_delta: Vec2,
cursor_position: Option<Vec2>,
wheel_steps: f32,
}
impl Default for InputState {
fn default() -> Self {
Self {
keys_down: [false; KEY_COUNT],
keys_pressed: [false; KEY_COUNT],
mouse_down: [false; MOUSE_COUNT],
mouse_pressed: [false; MOUSE_COUNT],
modifiers: Modifiers::default(),
mouse_delta: Vec2::ZERO,
cursor_position: None,
wheel_steps: 0.0,
}
}
}
impl InputState {
pub fn set_key(&mut self, key: EditorKey, pressed: bool) {
let index = key as usize;
if pressed && !self.keys_down[index] {
self.keys_pressed[index] = true;
}
self.keys_down[index] = pressed;
}
pub fn set_mouse_button(&mut self, button: MouseButton, pressed: bool) {
let index = button as usize;
if pressed && !self.mouse_down[index] {
self.mouse_pressed[index] = true;
}
self.mouse_down[index] = pressed;
}
pub fn set_modifiers(&mut self, modifiers: Modifiers) {
self.modifiers = modifiers;
}
pub fn add_mouse_delta(&mut self, delta: Vec2) {
if delta.is_finite() {
self.mouse_delta += delta;
}
}
pub fn set_cursor_position(&mut self, position: Vec2) {
if !position.is_finite() {
return;
}
if let Some(previous) = self.cursor_position {
self.mouse_delta += position - previous;
}
self.cursor_position = Some(position);
}
pub fn add_wheel_steps(&mut self, steps: f32) {
if steps.is_finite() {
self.wheel_steps += steps;
}
}
/// Consume transient deltas/presses while retaining held state.
pub fn take_frame(&mut self) -> InputFrame {
let frame = InputFrame {
keys_down: self.keys_down,
keys_pressed: self.keys_pressed,
mouse_down: self.mouse_down,
mouse_pressed: self.mouse_pressed,
modifiers: self.modifiers,
mouse_delta: self.mouse_delta,
cursor_position: self.cursor_position,
wheel_steps: self.wheel_steps,
};
self.keys_pressed.fill(false);
self.mouse_pressed.fill(false);
self.mouse_delta = Vec2::ZERO;
self.wheel_steps = 0.0;
frame
}
pub fn clear_held(&mut self) {
self.keys_down.fill(false);
self.mouse_down.fill(false);
self.keys_pressed.fill(false);
self.mouse_pressed.fill(false);
self.mouse_delta = Vec2::ZERO;
}
/// Forget the last absolute cursor sample when entering or leaving cursor
/// grab, preventing the first unlocked event from producing a large jump.
pub fn reset_cursor_position(&mut self) {
self.cursor_position = None;
self.mouse_delta = Vec2::ZERO;
}
/// Convenience bridge for winit 0.30. Returns true when the event belongs
/// to the editor controls. Raw mouse motion can be passed to
/// [`Self::handle_device_event`] while the cursor is grabbed.
pub fn handle_window_event(&mut self, event: &WindowEvent) -> bool {
match event {
WindowEvent::KeyboardInput { event, .. } => {
let PhysicalKey::Code(code) = event.physical_key else {
return false;
};
let Some(key) = editor_key(code) else {
return false;
};
self.set_key(key, event.state == ElementState::Pressed);
true
}
WindowEvent::MouseInput { state, button, .. } => {
let button = match button {
winit::event::MouseButton::Left => MouseButton::Left,
winit::event::MouseButton::Middle => MouseButton::Middle,
winit::event::MouseButton::Right => MouseButton::Right,
_ => return false,
};
self.set_mouse_button(button, *state == ElementState::Pressed);
true
}
WindowEvent::MouseWheel { delta, .. } => {
let steps = match delta {
MouseScrollDelta::LineDelta(_, y) => *y,
MouseScrollDelta::PixelDelta(position) => position.y as f32 / 100.0,
};
self.add_wheel_steps(steps);
true
}
WindowEvent::CursorMoved { position, .. } => {
self.set_cursor_position(Vec2::new(position.x as f32, position.y as f32));
true
}
WindowEvent::ModifiersChanged(modifiers) => {
let state = modifiers.state();
self.set_modifiers(Modifiers {
alt: state.alt_key(),
control: state.control_key(),
shift: state.shift_key(),
});
true
}
WindowEvent::Focused(false) => {
self.clear_held();
true
}
_ => false,
}
}
pub fn handle_device_event(&mut self, event: &DeviceEvent) -> bool {
if let DeviceEvent::MouseMotion { delta } = event {
self.add_mouse_delta(Vec2::new(delta.0 as f32, delta.1 as f32));
true
} else {
false
}
}
}
fn editor_key(code: KeyCode) -> Option<EditorKey> {
Some(match code {
KeyCode::KeyW => EditorKey::W,
KeyCode::KeyA => EditorKey::A,
KeyCode::KeyS => EditorKey::S,
KeyCode::KeyD => EditorKey::D,
KeyCode::KeyQ => EditorKey::Q,
KeyCode::KeyE => EditorKey::E,
KeyCode::KeyR => EditorKey::R,
KeyCode::KeyF => EditorKey::F,
KeyCode::Delete => EditorKey::Delete,
_ => return None,
})
}
/// UE-style viewport navigation operating only on a camera and an input frame.
#[derive(Debug, Clone, Copy)]
pub struct NavigationController {
pub move_speed: f32,
pub min_move_speed: f32,
pub max_move_speed: f32,
pub speed_step: f32,
pub fast_multiplier: f32,
pub look_sensitivity: f32,
pub orbit_sensitivity: f32,
pub pan_sensitivity: f32,
pub dolly_sensitivity: f32,
pub orbit_pivot: Vec3,
pub orbit_distance: f32,
}
impl Default for NavigationController {
fn default() -> Self {
Self {
move_speed: 6.0,
min_move_speed: 0.05,
max_move_speed: 2_000.0,
speed_step: 1.2,
fast_multiplier: 4.0,
look_sensitivity: 0.0025,
orbit_sensitivity: 0.004,
pan_sensitivity: 1.0,
dolly_sensitivity: 0.012,
orbit_pivot: Vec3::ZERO,
orbit_distance: 10.0,
}
}
}
impl NavigationController {
pub fn set_orbit_target(&mut self, camera: &Camera, pivot: Vec3) {
self.orbit_pivot = pivot;
self.orbit_distance = camera.position.distance(pivot).max(0.01);
}
pub fn update(
&mut self,
camera: &mut Camera,
input: &InputFrame,
delta_seconds: f32,
viewport_height: f32,
) {
if input.wheel_steps != 0.0 {
self.move_speed = (self.move_speed * self.speed_step.powf(input.wheel_steps))
.clamp(self.min_move_speed, self.max_move_speed);
}
let mouse_delta = input.mouse_delta;
let alt = input.modifiers.alt;
let left = input.mouse_down(MouseButton::Left);
let middle = input.mouse_down(MouseButton::Middle);
let right = input.mouse_down(MouseButton::Right);
if alt && left {
camera.rotate(
mouse_delta.x * self.orbit_sensitivity,
-mouse_delta.y * self.orbit_sensitivity,
);
camera.position = self.orbit_pivot - camera.forward() * self.orbit_distance;
} else if middle {
let world_per_pixel =
world_units_per_pixel(camera, self.orbit_distance, viewport_height)
* self.pan_sensitivity;
let translation = camera.right() * (-mouse_delta.x * world_per_pixel)
+ camera.up() * (mouse_delta.y * world_per_pixel);
camera.position += translation;
self.orbit_pivot += translation;
} else if alt && right {
let drag = mouse_delta.x - mouse_delta.y;
self.orbit_distance = (self.orbit_distance * (-drag * self.dolly_sensitivity).exp())
.clamp(0.01, 1_000_000.0);
camera.position = self.orbit_pivot - camera.forward() * self.orbit_distance;
} else if right {
camera.rotate(
mouse_delta.x * self.look_sensitivity,
-mouse_delta.y * self.look_sensitivity,
);
}
if right && !alt && delta_seconds.is_finite() && delta_seconds > 0.0 {
let forward = bool_as_axis(input.key_down(EditorKey::W))
- bool_as_axis(input.key_down(EditorKey::S));
let right_axis = bool_as_axis(input.key_down(EditorKey::D))
- bool_as_axis(input.key_down(EditorKey::A));
let up = bool_as_axis(input.key_down(EditorKey::E))
- bool_as_axis(input.key_down(EditorKey::Q));
let direction =
camera.forward() * forward + camera.right() * right_axis + WORLD_UP * up;
if direction.length_squared() > 0.0 {
let multiplier = if input.modifiers.shift {
self.fast_multiplier
} else {
1.0
};
let translation =
direction.normalize() * self.move_speed * multiplier * delta_seconds.min(0.25);
camera.position += translation;
self.orbit_pivot += translation;
}
}
}
}
fn bool_as_axis(value: bool) -> f32 {
if value { 1.0 } else { 0.0 }
}
pub(crate) fn world_units_per_pixel(camera: &Camera, distance: f32, viewport_height: f32) -> f32 {
if !viewport_height.is_finite() || viewport_height <= 0.0 {
return 0.0;
}
2.0 * distance.max(camera.projection.near) * (camera.projection.vertical_fov * 0.5).tan()
/ viewport_height
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wheel_changes_speed_with_limits() {
let mut controller = NavigationController::default();
let mut camera = Camera::default();
let mut frame = InputFrame {
wheel_steps: 2.0,
..InputFrame::default()
};
controller.update(&mut camera, &frame, 0.0, 720.0);
assert!((controller.move_speed - 6.0 * 1.2 * 1.2).abs() < 1.0e-5);
frame.wheel_steps = -10_000.0;
controller.update(&mut camera, &frame, 0.0, 720.0);
assert_eq!(controller.move_speed, controller.min_move_speed);
}
#[test]
fn rmb_w_moves_forward() {
let mut controller = NavigationController::default();
let mut camera = Camera::new(Vec3::ZERO, 0.0, 0.0);
let mut frame = InputFrame::default();
frame.mouse_down[MouseButton::Right as usize] = true;
frame.keys_down[EditorKey::W as usize] = true;
controller.update(&mut camera, &frame, 0.25, 720.0);
assert!(
camera
.position
.abs_diff_eq(Vec3::new(0.0, 1.5, 0.0), 1.0e-5)
);
}
#[test]
fn rmb_qe_move_only_along_world_z() {
let mut controller = NavigationController::default();
let mut camera = Camera::new(Vec3::new(3.0, 4.0, 5.0), 0.8, -0.4);
let start = camera.position;
let mut frame = InputFrame::default();
frame.mouse_down[MouseButton::Right as usize] = true;
frame.keys_down[EditorKey::E as usize] = true;
controller.update(&mut camera, &frame, 0.25, 720.0);
let delta = camera.position - start;
assert!(delta.x.abs() < 1.0e-6);
assert!(delta.y.abs() < 1.0e-6);
assert!((delta.z - 1.5).abs() < 1.0e-5);
}
}
+22
View File
@@ -0,0 +1,22 @@
//! UI-agnostic state and controls for the level editor viewport.
//!
//! The surrounding application is responsible for translating its events into
//! [`InputState`] and for drawing gizmos. This module owns the camera, selection,
//! transform commands and the small amount of timing state those views need.
mod camera;
mod fps;
mod gizmo;
mod input;
mod scene_adapter;
mod state;
pub use camera::{Camera, CameraProjection, FocusTarget, ViewportRay, WORLD_FORWARD, WORLD_UP};
pub use fps::FpsCounter;
pub use gizmo::{GizmoConfig, GizmoHandle, GizmoHit, Plane};
pub use input::{EditorKey, InputFrame, InputState, Modifiers, MouseButton, NavigationController};
pub use scene_adapter::{SceneEditorState, SceneObjectId};
pub use state::{
Axis, EditableScene, EditorCommand, EditorState, EditorTransform, PickHit, PickSample,
RaymarchSettings, Selection, SelectionMode, TransformMode, TransformSpace, raymarch_scene,
};
+226
View File
@@ -0,0 +1,226 @@
use glam::Vec3;
use crate::scene::{EntityId, LightId, PrimitiveId, Scene, Transform};
use super::{
EditableScene, EditorState, EditorTransform, FocusTarget, PickHit, PickSample, ViewportRay,
};
const LIGHT_PICK_RADIUS: f32 = 0.4;
const UNBOUNDED_PRIMITIVE_FOCUS_RADIUS: f32 = 2.0;
/// Selection discriminator for objects whose numeric IDs share one scene-wide
/// namespace. Keeping the kind explicit makes delete/duplicate unambiguous and
/// lets UI code choose the right inspector without another scene lookup.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SceneObjectId {
Primitive(PrimitiveId),
Light(LightId),
}
pub type SceneEditorState = EditorState<SceneObjectId>;
impl SceneObjectId {
pub fn entity_id(self) -> EntityId {
match self {
Self::Primitive(id) | Self::Light(id) => id,
}
}
pub fn is_light(self) -> bool {
matches!(self, Self::Light(_))
}
}
impl EditorState<SceneObjectId> {
/// Restore the viewport state stored alongside a map.
pub fn restore_camera_from_scene(&mut self, scene: &Scene) {
let saved = &scene.editor_camera;
self.camera.position = saved.position;
self.camera.yaw = saved.yaw_radians;
self.camera.pitch = saved.pitch_radians;
self.camera.projection.vertical_fov = saved.field_of_view_y_radians;
self.camera.projection.near = saved.near_plane;
self.camera.projection.far = saved.far_plane;
// Rotate by zero to apply the camera's angle wrapping and pitch limit.
self.camera.rotate(0.0, 0.0);
self.navigation.move_speed = saved.movement_speed.clamp(
self.navigation.min_move_speed,
self.navigation.max_move_speed,
);
self.navigation
.set_orbit_target(&self.camera, saved.orbit_pivot);
}
/// Persist camera/navigation state before serializing a map.
pub fn store_camera_in_scene(&self, scene: &mut Scene) {
let saved = &mut scene.editor_camera;
saved.position = self.camera.position;
saved.yaw_radians = self.camera.yaw;
saved.pitch_radians = self.camera.pitch;
saved.movement_speed = self.navigation.move_speed;
saved.orbit_pivot = self.navigation.orbit_pivot;
saved.field_of_view_y_radians = self.camera.projection.vertical_fov;
saved.near_plane = self.camera.projection.near;
saved.far_plane = self.camera.projection.far;
}
}
impl EditableScene for Scene {
type Id = SceneObjectId;
fn sample_for_pick(&self, point: Vec3) -> PickSample<Self::Id> {
let sample = self.sample(point);
PickSample {
distance: sample.distance,
object_id: sample.primitive_id.map(SceneObjectId::Primitive),
}
}
fn pick_auxiliary(&self, ray: ViewportRay, max_distance: f32) -> Option<PickHit<Self::Id>> {
self.lights()
.iter()
.filter_map(|light| {
let sphere = light.selection_sphere(LIGHT_PICK_RADIUS);
let distance = ray_sphere_distance(ray, sphere.center, sphere.radius)?;
(distance <= max_distance).then(|| {
let position = ray.origin + ray.direction * distance;
PickHit {
object_id: SceneObjectId::Light(light.id),
position,
normal: (position - sphere.center).normalize_or_zero(),
distance,
steps: 1,
}
})
})
.min_by(|left, right| left.distance.total_cmp(&right.distance))
}
fn editor_transform(&self, id: Self::Id) -> Option<EditorTransform> {
let transform = match id {
SceneObjectId::Primitive(id) => self.primitive(id)?.transform,
SceneObjectId::Light(id) => self.light(id)?.transform,
};
Some(to_editor_transform(transform))
}
fn set_editor_transform(&mut self, id: Self::Id, transform: EditorTransform) -> bool {
let transform = to_scene_transform(transform);
match id {
SceneObjectId::Primitive(id) => {
let Some(primitive) = self.primitive_mut(id) else {
return false;
};
primitive.transform = transform;
}
SceneObjectId::Light(id) => {
let Some(light) = self.light_mut(id) else {
return false;
};
light.transform = transform;
}
}
true
}
fn remove_editor_object(&mut self, id: Self::Id) -> bool {
match id {
SceneObjectId::Primitive(id) => self.remove_primitive(id).is_some(),
SceneObjectId::Light(id) => self.remove_light(id).is_some(),
}
}
fn duplicate_editor_object(&mut self, id: Self::Id) -> Option<Self::Id> {
match id {
SceneObjectId::Primitive(id) => {
self.duplicate_primitive(id).map(SceneObjectId::Primitive)
}
SceneObjectId::Light(id) => self.duplicate_light(id).map(SceneObjectId::Light),
}
}
fn focus_target(&self, id: Self::Id) -> Option<FocusTarget> {
match id {
SceneObjectId::Primitive(id) => {
let primitive = self.primitive(id)?;
let (center, radius) = primitive
.shape
.bounding_sphere()
.map(|bounds| {
(
primitive.transform.local_to_world(bounds.center),
bounds.radius * primitive.transform.maximum_abs_scale(),
)
})
.unwrap_or((
primitive.transform.translation,
UNBOUNDED_PRIMITIVE_FOCUS_RADIUS,
));
Some(FocusTarget::new(center, radius))
}
SceneObjectId::Light(id) => {
let sphere = self.light(id)?.selection_sphere(LIGHT_PICK_RADIUS);
Some(FocusTarget::new(sphere.center, sphere.radius))
}
}
}
}
fn to_editor_transform(transform: Transform) -> EditorTransform {
EditorTransform {
translation: transform.translation,
rotation: transform.rotation,
scale: transform.scale,
}
}
fn to_scene_transform(transform: EditorTransform) -> Transform {
let transform = transform.sanitized();
Transform {
translation: transform.translation,
rotation: transform.rotation,
scale: transform.scale,
}
}
fn ray_sphere_distance(ray: ViewportRay, center: Vec3, radius: f32) -> Option<f32> {
let direction = ray.direction.normalize_or_zero();
let origin_to_center = ray.origin - center;
let half_b = origin_to_center.dot(direction);
let c = origin_to_center.length_squared() - radius * radius;
let discriminant = half_b * half_b - c;
if discriminant < 0.0 {
return None;
}
let root = discriminant.sqrt();
let near = -half_b - root;
let far = -half_b + root;
if near >= 0.0 {
Some(near)
} else if far >= 0.0 {
Some(far)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sphere_intersection_chooses_near_positive_root() {
let distance = ray_sphere_distance(
ViewportRay {
origin: Vec3::new(0.0, 0.0, 3.0),
direction: Vec3::NEG_Z,
},
Vec3::ZERO,
1.0,
)
.unwrap();
assert!((distance - 2.0).abs() < 1.0e-6);
}
}
+1919
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
use std::path::{Path, PathBuf};
use rfd::FileDialog;
use winit::window::Window;
const DEFAULT_FILE_NAME: &str = "map.json";
/// Show the platform-native Save dialog. The caller owns the returned path and
/// decides when to write, which keeps dialogs entirely out of unit tests.
pub fn choose_map_save_path(window: &Window, current: Option<&Path>) -> Option<PathBuf> {
let mut dialog = map_dialog(window, "Сохранить карту");
if let Some(directory) = starting_directory(current) {
dialog = dialog.set_directory(directory);
}
let suggested_name = current
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.unwrap_or(DEFAULT_FILE_NAME);
dialog
.set_file_name(suggested_name)
.save_file()
.map(ensure_json_extension)
}
/// Show the platform-native Open dialog for one versioned JSON map.
pub fn choose_map_open_path(window: &Window, current: Option<&Path>) -> Option<PathBuf> {
let mut dialog = map_dialog(window, "Открыть карту");
if let Some(directory) = starting_directory(current) {
dialog = dialog.set_directory(directory);
}
dialog.pick_file()
}
fn map_dialog(window: &Window, title: &str) -> FileDialog {
FileDialog::new()
.set_parent(window)
.set_title(title)
.add_filter("Карта Ray Marching (JSON)", &["json"])
}
fn starting_directory(current: Option<&Path>) -> Option<PathBuf> {
current
.and_then(|path| {
if path.is_dir() {
Some(path)
} else {
path.parent()
}
})
.filter(|path| !path.as_os_str().is_empty())
.map(Path::to_owned)
.or_else(|| std::env::current_dir().ok())
}
pub(crate) fn ensure_json_extension(mut path: PathBuf) -> PathBuf {
let is_json = path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("json"));
if !is_json {
path.set_extension("json");
}
path
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn save_path_gets_one_json_extension() {
assert_eq!(
ensure_json_extension(PathBuf::from("level")),
PathBuf::from("level.json")
);
assert_eq!(
ensure_json_extension(PathBuf::from("level.JSON")),
PathBuf::from("level.JSON")
);
assert_eq!(
ensure_json_extension(PathBuf::from("level.txt")),
PathBuf::from("level.json")
);
}
}
+253
View File
@@ -0,0 +1,253 @@
use std::{
fs::{self, OpenOptions},
io::Write,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::scene::{Scene, SceneValidationError};
pub const CURRENT_MAP_VERSION: u32 = 1;
pub const DEFAULT_MAP_PATH: &str = "map.json";
static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MapDocument {
pub version: u32,
pub scene: Scene,
}
impl MapDocument {
pub fn new(scene: Scene) -> Self {
Self {
version: CURRENT_MAP_VERSION,
scene,
}
}
}
#[derive(Debug, Error)]
pub enum MapIoError {
#[error("failed to access map file {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to serialize map JSON: {0}")]
Serialize(#[source] serde_json::Error),
#[error("failed to parse map JSON: {0}")]
Deserialize(#[source] serde_json::Error),
#[error("map version {found} is unsupported; this build supports version {supported}")]
UnsupportedVersion { found: u32, supported: u32 },
#[error("map scene is invalid: {0}")]
InvalidScene(#[from] SceneValidationError),
}
/// Serializes a map using the current versioned envelope.
pub fn serialize_map(scene: &Scene) -> Result<String, MapIoError> {
let mut scene = scene.clone();
scene.repair_allocator()?;
scene.validate()?;
serde_json::to_string_pretty(&MapDocument::new(scene)).map_err(MapIoError::Serialize)
}
/// Parses and validates a map. Allocator metadata may be repaired, but stable
/// object IDs from the document are never changed.
pub fn deserialize_map(json: &str) -> Result<Scene, MapIoError> {
let mut document: MapDocument = serde_json::from_str(json).map_err(MapIoError::Deserialize)?;
if document.version != CURRENT_MAP_VERSION {
return Err(MapIoError::UnsupportedVersion {
found: document.version,
supported: CURRENT_MAP_VERSION,
});
}
document.scene.repair_allocator()?;
document.scene.validate()?;
Ok(document.scene)
}
pub fn load_map(path: impl AsRef<Path>) -> Result<Scene, MapIoError> {
let path = path.as_ref();
let json = fs::read_to_string(path).map_err(|source| MapIoError::Io {
path: path.to_owned(),
source,
})?;
deserialize_map(&json)
}
pub fn load_default_map() -> Result<Scene, MapIoError> {
load_map(DEFAULT_MAP_PATH)
}
/// Writes beside the destination, flushes the temporary file, then atomically
/// renames it over the map. A crash can therefore leave either the old or the
/// new complete JSON document, never a half-written destination.
pub fn save_map(path: impl AsRef<Path>, scene: &Scene) -> Result<(), MapIoError> {
let path = path.as_ref();
let json = serialize_map(scene)?;
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty());
if let Some(parent) = parent {
fs::create_dir_all(parent).map_err(|source| MapIoError::Io {
path: parent.to_owned(),
source,
})?;
}
let temporary = temporary_path(path);
let write_result = (|| {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
.map_err(|source| MapIoError::Io {
path: temporary.clone(),
source,
})?;
file.write_all(json.as_bytes())
.and_then(|()| file.write_all(b"\n"))
.and_then(|()| file.sync_all())
.map_err(|source| MapIoError::Io {
path: temporary.clone(),
source,
})?;
drop(file);
fs::rename(&temporary, path).map_err(|source| MapIoError::Io {
path: path.to_owned(),
source,
})
})();
if write_result.is_err() {
let _ = fs::remove_file(&temporary);
}
write_result
}
pub fn save_default_map(scene: &Scene) -> Result<(), MapIoError> {
save_map(DEFAULT_MAP_PATH, scene)
}
fn temporary_path(destination: &Path) -> PathBuf {
let parent = destination.parent().unwrap_or_else(|| Path::new("."));
let name = destination
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("map.json");
let counter = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
parent.join(format!(".{name}.{}.{}.tmp", std::process::id(), counter))
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use glam::{Quat, Vec3};
use super::*;
use crate::scene::{LightKind, Material, SdfPrimitive, Transform};
fn test_path(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"ray-marching-editor-{name}-{}-{nonce}.json",
std::process::id()
))
}
#[test]
fn json_round_trip_preserves_scene_ids_lights_and_settings() {
let mut scene = Scene::default();
scene.name = "Round Trip".into();
scene.clouds.coverage = 0.73;
scene.editor_camera.position = Vec3::new(9.0, 8.0, 7.0);
let id = scene.add_primitive_with(
"Hero Shape",
SdfPrimitive::RoundedBox {
half_extents: Vec3::new(1.0, 2.0, 3.0),
radius: 0.25,
},
Transform {
translation: Vec3::new(4.0, 5.0, 6.0),
rotation: Quat::from_rotation_y(0.4),
scale: Vec3::new(1.0, 2.0, 1.0),
},
Material {
albedo: Vec3::new(0.2, 0.5, 0.8),
roughness: 0.3,
metallic: 0.7,
..Material::default()
},
);
let black_hole = scene.add_primitive(SdfPrimitive::BlackHole {
horizon_radius: 0.8,
influence_radius: 12.0,
lens_strength: 2.4,
spin: -0.65,
});
let spot = scene.add_light(LightKind::Spot {
range: 40.0,
inner_angle_radians: 0.2,
outer_angle_radians: 0.4,
});
let json = serialize_map(&scene).unwrap();
let restored = deserialize_map(&json).unwrap();
assert_eq!(restored, scene);
assert!(restored.primitive(id).is_some());
let Some(SdfPrimitive::BlackHole {
horizon_radius,
influence_radius,
lens_strength,
spin,
}) = restored
.primitive(black_hole)
.map(|primitive| primitive.shape)
else {
panic!("the black hole must survive map serialization");
};
assert_eq!(
[horizon_radius, influence_radius, lens_strength, spin],
[0.8, 12.0, 2.4, -0.65]
);
assert!(restored.light(spot).is_some());
let mut restored = restored;
let new_id = restored.add_primitive(SdfPrimitive::Sphere { radius: 1.0 });
assert!(new_id > id.max(black_hole).max(spot));
}
#[test]
fn atomic_save_replaces_the_single_map() {
let path = test_path("atomic");
let mut scene = Scene::default();
scene.name = "First".into();
save_map(&path, &scene).unwrap();
scene.name = "Second".into();
save_map(&path, &scene).unwrap();
assert_eq!(load_map(&path).unwrap().name, "Second");
fs::remove_file(path).unwrap();
}
#[test]
fn unsupported_versions_are_reported() {
let json = r#"{"version":999,"scene":{}}"#;
assert!(matches!(
deserialize_map(json),
Err(MapIoError::UnsupportedVersion {
found: 999,
supported: CURRENT_MAP_VERSION
})
));
}
}
+10
View File
@@ -0,0 +1,10 @@
//! Versioned JSON map persistence.
mod dialog;
mod map;
pub use dialog::{choose_map_open_path, choose_map_save_path};
pub use map::{
CURRENT_MAP_VERSION, DEFAULT_MAP_PATH, MapDocument, MapIoError, deserialize_map,
load_default_map, load_map, save_default_map, save_map, serialize_map,
};
+7
View File
@@ -0,0 +1,7 @@
pub mod app;
pub mod editor;
pub mod io;
pub mod renderer;
pub mod scene;
pub mod stats;
pub mod ui;
+4
View File
@@ -0,0 +1,4 @@
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
ray_marching_editor::app::run()
}
+684
View File
@@ -0,0 +1,684 @@
use std::sync::{
Arc,
mpsc::{Receiver, Sender, channel},
};
use anyhow::{Context, Result, bail};
use bytemuck::Zeroable;
use wgpu::{
BindGroup, Buffer, CurrentSurfaceTexture, Device, Queue, RenderPipeline, Surface,
SurfaceConfiguration, TextureFormat, TextureView,
};
use winit::{dpi::PhysicalSize, window::Window};
use super::gpu_types::{
CellRangeGpu, GlobalsGpu, LightGpu, MAX_CELL_INDICES, MAX_GRID_CELLS, MAX_LIGHTS,
MAX_MATERIALS, MAX_PRIMITIVES, MaterialGpu, PrimitiveGpu, SceneGpuUpload,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ViewportRectPx {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
impl ViewportRectPx {
pub fn clamped(self, target: PhysicalSize<u32>) -> Self {
let x = self.x.min(target.width);
let y = self.y.min(target.height);
Self {
x,
y,
width: self.width.min(target.width.saturating_sub(x)),
height: self.height.min(target.height.saturating_sub(y)),
}
}
}
pub struct GpuRenderer {
surface: Surface<'static>,
device: Device,
queue: Queue,
config: SurfaceConfiguration,
pipeline: RenderPipeline,
bind_group: BindGroup,
globals_buffer: Buffer,
primitive_buffer: Buffer,
material_buffer: Buffer,
light_buffer: Buffer,
cell_range_buffer: Buffer,
cell_index_buffer: Buffer,
adapter_name: String,
supported_present_modes: Vec<wgpu::PresentMode>,
frame_timer: Option<GpuFrameTimer>,
}
const GPU_TIMESTAMP_BYTES: u64 = 2 * std::mem::size_of::<u64>() as u64;
const GPU_READBACK_SLOTS: usize = 3;
struct GpuReadbackSlot {
buffer: Buffer,
busy: bool,
serial: u64,
}
struct GpuFrameTimer {
query_set: wgpu::QuerySet,
resolve_buffer: Buffer,
readbacks: Vec<GpuReadbackSlot>,
active_slot: Option<usize>,
completed_tx: Sender<(usize, u64, bool)>,
completed_rx: Receiver<(usize, u64, bool)>,
next_serial: u64,
last_completed_serial: u64,
timestamp_period_ns: f32,
latest_ms: Option<f32>,
}
impl GpuRenderer {
pub async fn new(window: Arc<Window>) -> Result<Self> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::VULKAN,
..wgpu::InstanceDescriptor::new_without_display_handle()
});
let surface = instance
.create_surface(window.clone())
.context("failed to create the Vulkan presentation surface")?;
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
})
.await
.context("no Vulkan adapter can present to this window")?;
let adapter_info = adapter.get_info();
if adapter_info.backend != wgpu::Backend::Vulkan {
bail!(
"expected the Vulkan backend, got {:?}",
adapter_info.backend
);
}
let optional_features = adapter.features() & wgpu::Features::TIMESTAMP_QUERY;
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("ray-marching-editor device"),
required_features: optional_features,
required_limits: wgpu::Limits::default(),
experimental_features: wgpu::ExperimentalFeatures::disabled(),
memory_hints: wgpu::MemoryHints::Performance,
trace: wgpu::Trace::Off,
})
.await
.context("failed to create the Vulkan device")?;
let size = window.inner_size();
let capabilities = surface.get_capabilities(&adapter);
let format = capabilities
.formats
.iter()
.copied()
.find(TextureFormat::is_srgb)
.or_else(|| capabilities.formats.first().copied())
.context("the Vulkan surface exposes no color formats")?;
// VSync is deliberately off by default. Immediate gives the GPU no
// presentation throttle; Mailbox is the tear-free uncapped fallback.
let present_mode = choose_present_mode(&capabilities.present_modes, false);
let config = SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: size.width.max(1),
height: size.height.max(1),
present_mode,
// Three or more keeps Vulkan Mailbox unlimited as well, instead
// of silently capping it to twice the monitor refresh rate.
desired_maximum_frame_latency: 3,
alpha_mode: wgpu::CompositeAlphaMode::Auto,
view_formats: vec![],
};
surface.configure(&device, &config);
let globals_buffer = create_buffer(
&device,
"ray marcher globals",
std::mem::size_of::<GlobalsGpu>() as u64,
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
);
let primitive_buffer = create_buffer(
&device,
"SDF primitives",
(MAX_PRIMITIVES * std::mem::size_of::<PrimitiveGpu>()) as u64,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
);
let material_buffer = create_buffer(
&device,
"SDF materials",
(MAX_MATERIALS * std::mem::size_of::<MaterialGpu>()) as u64,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
);
let light_buffer = create_buffer(
&device,
"scene lights",
(MAX_LIGHTS * std::mem::size_of::<LightGpu>()) as u64,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
);
let cell_range_buffer = create_buffer(
&device,
"spatial grid cell ranges",
(MAX_GRID_CELLS * std::mem::size_of::<CellRangeGpu>()) as u64,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
);
let cell_index_buffer = create_buffer(
&device,
"spatial grid primitive indices",
(MAX_CELL_INDICES * std::mem::size_of::<u32>()) as u64,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
);
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("ray marcher bind group layout"),
entries: &[
binding_layout_entry(0, wgpu::BufferBindingType::Uniform),
binding_layout_entry(1, wgpu::BufferBindingType::Storage { read_only: true }),
binding_layout_entry(2, wgpu::BufferBindingType::Storage { read_only: true }),
binding_layout_entry(3, wgpu::BufferBindingType::Storage { read_only: true }),
binding_layout_entry(4, wgpu::BufferBindingType::Storage { read_only: true }),
binding_layout_entry(5, wgpu::BufferBindingType::Storage { read_only: true }),
],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("ray marcher bind group"),
layout: &bind_group_layout,
entries: &[
buffer_entry(0, &globals_buffer),
buffer_entry(1, &primitive_buffer),
buffer_entry(2, &material_buffer),
buffer_entry(3, &cell_range_buffer),
buffer_entry(4, &cell_index_buffer),
buffer_entry(5, &light_buffer),
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("SDF ray marcher WGSL"),
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("ray marcher pipeline layout"),
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});
let targets = [Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("fullscreen ray marcher"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &targets,
}),
multiview_mask: None,
cache: None,
});
let frame_timer = optional_features
.contains(wgpu::Features::TIMESTAMP_QUERY)
.then(|| GpuFrameTimer::new(&device, &queue));
let mut renderer = Self {
surface,
device,
queue,
config,
pipeline,
bind_group,
globals_buffer,
primitive_buffer,
material_buffer,
light_buffer,
cell_range_buffer,
cell_index_buffer,
adapter_name: adapter_info.name,
supported_present_modes: capabilities.present_modes,
frame_timer,
};
renderer.upload(GlobalsGpu::default(), &SceneGpuUpload::default())?;
Ok(renderer)
}
pub fn adapter_name(&self) -> &str {
&self.adapter_name
}
pub fn device(&self) -> &Device {
&self.device
}
pub fn queue(&self) -> &Queue {
&self.queue
}
pub fn format(&self) -> TextureFormat {
self.config.format
}
pub fn size(&self) -> PhysicalSize<u32> {
PhysicalSize::new(self.config.width, self.config.height)
}
pub fn present_mode(&self) -> wgpu::PresentMode {
self.config.present_mode
}
pub fn vsync_enabled(&self) -> bool {
is_vsync_mode(self.config.present_mode)
}
/// Reconfigure the swapchain immediately. Returns whether the driver could
/// honor the requested state; a FIFO-only surface necessarily keeps VSync.
pub fn set_vsync(&mut self, enabled: bool) -> bool {
let present_mode = choose_present_mode(&self.supported_present_modes, enabled);
if self.config.present_mode != present_mode {
self.config.present_mode = present_mode;
self.surface.configure(&self.device, &self.config);
}
self.vsync_enabled() == enabled
}
pub fn resize(&mut self, size: PhysicalSize<u32>) {
if size.width == 0 || size.height == 0 {
return;
}
self.config.width = size.width;
self.config.height = size.height;
self.surface.configure(&self.device, &self.config);
}
pub fn reconfigure(&self) {
self.surface.configure(&self.device, &self.config);
}
pub fn acquire_frame(&self) -> CurrentSurfaceTexture {
self.surface.get_current_texture()
}
pub fn create_encoder(&self) -> wgpu::CommandEncoder {
self.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("ray marching editor frame"),
})
}
/// Poll completed timestamp readbacks without waiting for the GPU.
pub fn poll_gpu_time(&mut self) -> Option<f32> {
let timer = self.frame_timer.as_mut()?;
timer.poll(&self.device)
}
/// Reserve a free readback slot for this frame. If the GPU is more than
/// three frames behind, timing is skipped instead of stalling the editor.
pub fn prepare_gpu_timing(&mut self) {
if let Some(timer) = self.frame_timer.as_mut() {
timer.prepare_frame();
}
}
/// Timestamp written at the end of the final UI pass. The matching start
/// timestamp is emitted by [`Self::encode_scene`].
pub fn gpu_frame_end_timestamp_writes(&self) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
self.frame_timer
.as_ref()
.and_then(GpuFrameTimer::end_timestamp_writes)
}
/// Resolve this frame's two timestamps and copy them to its staging slot.
pub fn resolve_gpu_timing(&self, encoder: &mut wgpu::CommandEncoder) {
if let Some(timer) = self.frame_timer.as_ref() {
timer.resolve(encoder);
}
}
/// Start asynchronous CPU mapping after the command buffer was submitted.
pub fn map_gpu_timing_after_submit(&mut self) {
if let Some(timer) = self.frame_timer.as_mut() {
timer.map_after_submit();
}
}
pub fn upload(&mut self, mut globals: GlobalsGpu, scene: &SceneGpuUpload) -> Result<()> {
scene.validate().map_err(anyhow::Error::msg)?;
globals.counts_flags[0] = scene.primitives.len() as u32;
globals.counts_flags[1] = scene.materials.len() as u32;
globals.light_count_pad[0] = scene.lights.len() as u32;
self.queue
.write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals));
write_slice_or_dummy(
&self.queue,
&self.primitive_buffer,
&scene.primitives,
PrimitiveGpu::zeroed(),
);
write_slice_or_dummy(
&self.queue,
&self.material_buffer,
&scene.materials,
MaterialGpu::zeroed(),
);
write_slice_or_dummy(
&self.queue,
&self.light_buffer,
&scene.lights,
LightGpu::zeroed(),
);
write_slice_or_dummy(
&self.queue,
&self.cell_range_buffer,
&scene.cell_ranges,
CellRangeGpu::zeroed(),
);
write_slice_or_dummy(
&self.queue,
&self.cell_index_buffer,
&scene.cell_indices,
0_u32,
);
Ok(())
}
pub fn encode_scene(
&self,
encoder: &mut wgpu::CommandEncoder,
target: &TextureView,
viewport: ViewportRectPx,
) {
let viewport = viewport.clamped(self.size());
let timestamp_writes = self
.frame_timer
.as_ref()
.and_then(GpuFrameTimer::begin_timestamp_writes);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("ray marching viewport"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.018,
g: 0.021,
b: 0.028,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
timestamp_writes,
occlusion_query_set: None,
multiview_mask: None,
});
if viewport.width == 0 || viewport.height == 0 {
return;
}
pass.set_viewport(
viewport.x as f32,
viewport.y as f32,
viewport.width as f32,
viewport.height as f32,
0.0,
1.0,
);
pass.set_scissor_rect(viewport.x, viewport.y, viewport.width, viewport.height);
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.draw(0..3, 0..1);
}
pub fn submit(&self, encoder: wgpu::CommandEncoder) {
self.queue.submit([encoder.finish()]);
}
}
fn choose_present_mode(modes: &[wgpu::PresentMode], vsync: bool) -> wgpu::PresentMode {
let preferences: &[wgpu::PresentMode] = if vsync {
&[
wgpu::PresentMode::Fifo,
wgpu::PresentMode::FifoRelaxed,
wgpu::PresentMode::AutoVsync,
]
} else {
&[
wgpu::PresentMode::Immediate,
wgpu::PresentMode::Mailbox,
wgpu::PresentMode::AutoNoVsync,
]
};
preferences
.iter()
.copied()
.find(|candidate| modes.contains(candidate))
.or_else(|| modes.first().copied())
.unwrap_or(wgpu::PresentMode::Fifo)
}
fn is_vsync_mode(mode: wgpu::PresentMode) -> bool {
matches!(
mode,
wgpu::PresentMode::Fifo | wgpu::PresentMode::FifoRelaxed | wgpu::PresentMode::AutoVsync
)
}
impl GpuFrameTimer {
fn new(device: &Device, queue: &Queue) -> Self {
let query_set = device.create_query_set(&wgpu::QuerySetDescriptor {
label: Some("frame GPU timestamps"),
ty: wgpu::QueryType::Timestamp,
count: 2,
});
let resolve_buffer = create_buffer(
device,
"frame GPU timestamp resolve",
GPU_TIMESTAMP_BYTES,
wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
);
let readbacks = (0..GPU_READBACK_SLOTS)
.map(|index| GpuReadbackSlot {
buffer: create_buffer(
device,
&format!("frame GPU timestamp readback {index}"),
GPU_TIMESTAMP_BYTES,
wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
),
busy: false,
serial: 0,
})
.collect();
let (completed_tx, completed_rx) = channel();
Self {
query_set,
resolve_buffer,
readbacks,
active_slot: None,
completed_tx,
completed_rx,
next_serial: 1,
last_completed_serial: 0,
timestamp_period_ns: queue.get_timestamp_period(),
latest_ms: None,
}
}
fn prepare_frame(&mut self) {
if self.active_slot.is_some() {
return;
}
let Some((index, slot)) = self
.readbacks
.iter_mut()
.enumerate()
.find(|(_, slot)| !slot.busy)
else {
return;
};
slot.busy = true;
slot.serial = self.next_serial;
self.next_serial = self.next_serial.saturating_add(1);
self.active_slot = Some(index);
}
fn begin_timestamp_writes(&self) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
self.active_slot.map(|_| wgpu::RenderPassTimestampWrites {
query_set: &self.query_set,
beginning_of_pass_write_index: Some(0),
end_of_pass_write_index: None,
})
}
fn end_timestamp_writes(&self) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
self.active_slot.map(|_| wgpu::RenderPassTimestampWrites {
query_set: &self.query_set,
beginning_of_pass_write_index: None,
end_of_pass_write_index: Some(1),
})
}
fn resolve(&self, encoder: &mut wgpu::CommandEncoder) {
let Some(index) = self.active_slot else {
return;
};
encoder.resolve_query_set(&self.query_set, 0..2, &self.resolve_buffer, 0);
encoder.copy_buffer_to_buffer(
&self.resolve_buffer,
0,
&self.readbacks[index].buffer,
0,
GPU_TIMESTAMP_BYTES,
);
}
fn map_after_submit(&mut self) {
let Some(index) = self.active_slot.take() else {
return;
};
let serial = self.readbacks[index].serial;
let completed_tx = self.completed_tx.clone();
self.readbacks[index]
.buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
let _ = completed_tx.send((index, serial, result.is_ok()));
});
}
fn poll(&mut self, device: &Device) -> Option<f32> {
let _ = device.poll(wgpu::PollType::Poll);
while let Ok((index, serial, success)) = self.completed_rx.try_recv() {
let Some(slot) = self.readbacks.get_mut(index) else {
continue;
};
if success && serial > self.last_completed_serial {
let mapped = slot.buffer.slice(..).get_mapped_range();
let timestamps: &[u64] = bytemuck::cast_slice(&mapped);
if let [start, end, ..] = timestamps
&& end >= start
&& self.timestamp_period_ns.is_finite()
&& self.timestamp_period_ns > 0.0
{
self.latest_ms =
Some((*end - *start) as f32 * self.timestamp_period_ns / 1_000_000.0);
self.last_completed_serial = serial;
}
drop(mapped);
}
slot.buffer.unmap();
slot.busy = false;
}
self.latest_ms
}
}
fn create_buffer(device: &Device, label: &str, size: u64, usage: wgpu::BufferUsages) -> Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage,
mapped_at_creation: false,
})
}
fn binding_layout_entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
fn buffer_entry(binding: u32, buffer: &Buffer) -> wgpu::BindGroupEntry<'_> {
wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
}
}
fn write_slice_or_dummy<T: bytemuck::Pod>(queue: &Queue, buffer: &Buffer, values: &[T], dummy: T) {
if values.is_empty() {
queue.write_buffer(buffer, 0, bytemuck::bytes_of(&dummy));
} else {
queue.write_buffer(buffer, 0, bytemuck::cast_slice(values));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vsync_is_off_by_default_when_immediate_or_mailbox_is_available() {
let modes = [
wgpu::PresentMode::Fifo,
wgpu::PresentMode::Mailbox,
wgpu::PresentMode::Immediate,
];
assert_eq!(
choose_present_mode(&modes, false),
wgpu::PresentMode::Immediate
);
assert!(!is_vsync_mode(choose_present_mode(&modes, false)));
assert_eq!(choose_present_mode(&modes, true), wgpu::PresentMode::Fifo);
}
#[test]
fn fifo_only_driver_reports_the_unavoidable_vsync_fallback() {
let mode = choose_present_mode(&[wgpu::PresentMode::Fifo], false);
assert_eq!(mode, wgpu::PresentMode::Fifo);
assert!(is_vsync_mode(mode));
}
}
+129
View File
@@ -0,0 +1,129 @@
use bytemuck::{Pod, Zeroable};
pub const MAX_PRIMITIVES: usize = 128;
pub const MAX_MATERIALS: usize = 64;
pub const MAX_LIGHTS: usize = 16;
pub const MAX_GRID_CELLS: usize = 32 * 32 * 32;
pub const MAX_CELL_INDICES: usize = 131_072;
pub const FLAG_USE_GRID: u32 = 1 << 0;
pub const FLAG_SHADOWS: u32 = 1 << 1;
pub const FLAG_AO: u32 = 1 << 2;
pub const FLAG_FOG: u32 = 1 << 3;
pub const FLAG_CLOUDS: u32 = 1 << 4;
pub const FLAG_SELECTED_HIGHLIGHT: u32 = 1 << 5;
/// Uniform data shared by the fullscreen ray-marching pass.
///
/// Keep this in lock-step with `Globals` in `shader.wgsl`.
#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable)]
pub struct GlobalsGpu {
pub inv_view_proj: [[f32; 4]; 4],
pub camera_pos_time: [f32; 4],
pub viewport_maxdist_epsilon: [f32; 4],
pub sun_dir_intensity: [f32; 4],
pub sun_color_ambient: [f32; 4],
pub fog_color_density: [f32; 4],
pub raymarch_params: [f32; 4],
pub grid_origin_cell_size: [f32; 4],
pub counts_flags: [u32; 4],
pub grid_dims_steps: [u32; 4],
pub light_count_pad: [u32; 4],
}
impl Default for GlobalsGpu {
fn default() -> Self {
Self {
inv_view_proj: glam::Mat4::IDENTITY.to_cols_array_2d(),
camera_pos_time: [0.0, 0.0, 0.0, 0.0],
viewport_maxdist_epsilon: [1280.0, 720.0, 500.0, 0.001],
sun_dir_intensity: [0.35, 0.25, 0.8, 3.0],
sun_color_ambient: [1.0, 0.93, 0.82, 0.18],
fog_color_density: [0.45, 0.62, 0.82, 0.012],
raymarch_params: [0.82, 24.0, 0.65, 0.48],
grid_origin_cell_size: [-16.0, -16.0, -16.0, 4.0],
counts_flags: [
0,
0,
u32::MAX,
FLAG_SHADOWS | FLAG_AO | FLAG_FOG | FLAG_CLOUDS,
],
grid_dims_steps: [8, 8, 8, 160],
light_count_pad: [0; 4],
}
}
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, Pod, Zeroable)]
pub struct PrimitiveGpu {
pub center_bound: [f32; 4],
pub rotation: [f32; 4],
pub scale_smoothing: [f32; 4],
pub params0: [f32; 4],
pub params1: [f32; 4],
pub meta: [u32; 4],
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, Pod, Zeroable)]
pub struct MaterialGpu {
pub base_color_roughness: [f32; 4],
pub emissive_metallic: [f32; 4],
}
/// A directional sun (`kind = 0`), spot light (`kind = 1`), or point light (`kind = 2`).
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, Pod, Zeroable)]
pub struct LightGpu {
pub position_range: [f32; 4],
pub direction_outer_cos: [f32; 4],
pub color_intensity: [f32; 4],
pub params: [f32; 4],
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, Pod, Zeroable)]
pub struct CellRangeGpu {
pub offset: u32,
pub count: u32,
}
#[derive(Clone, Debug, Default)]
pub struct SceneGpuUpload {
pub primitives: Vec<PrimitiveGpu>,
pub materials: Vec<MaterialGpu>,
pub lights: Vec<LightGpu>,
pub cell_ranges: Vec<CellRangeGpu>,
pub cell_indices: Vec<u32>,
}
impl SceneGpuUpload {
pub fn validate(&self) -> Result<(), &'static str> {
if self.primitives.len() > MAX_PRIMITIVES {
return Err("scene exceeds the GPU primitive limit");
}
if self.materials.len() > MAX_MATERIALS {
return Err("scene exceeds the GPU material limit");
}
if self.lights.len() > MAX_LIGHTS {
return Err("scene exceeds the GPU light limit");
}
if self.cell_ranges.len() > MAX_GRID_CELLS {
return Err("spatial grid exceeds the GPU cell limit");
}
if self.cell_indices.len() > MAX_CELL_INDICES {
return Err("spatial grid exceeds the GPU index limit");
}
Ok(())
}
}
const _: () = {
assert!(std::mem::size_of::<GlobalsGpu>() == 224);
assert!(std::mem::size_of::<PrimitiveGpu>() == 96);
assert!(std::mem::size_of::<MaterialGpu>() == 32);
assert!(std::mem::size_of::<LightGpu>() == 64);
assert!(std::mem::size_of::<CellRangeGpu>() == 8);
};
+13
View File
@@ -0,0 +1,13 @@
mod gpu;
mod gpu_types;
mod scene_upload;
#[cfg(test)]
mod shader_test;
pub use gpu::{GpuRenderer, ViewportRectPx};
pub use gpu_types::{
CellRangeGpu, FLAG_AO, FLAG_CLOUDS, FLAG_FOG, FLAG_SELECTED_HIGHLIGHT, FLAG_SHADOWS,
FLAG_USE_GRID, GlobalsGpu, LightGpu, MAX_LIGHTS, MAX_MATERIALS, MAX_PRIMITIVES, MaterialGpu,
PrimitiveGpu, SceneGpuUpload,
};
pub use scene_upload::GridGpuInfo;
+516
View File
@@ -0,0 +1,516 @@
use glam::{UVec3, Vec3};
use crate::scene::{
GridCell, LightKind, Material, Scene, SdfPrimitive, SpatialGrid, SpatialGridBuildOptions,
};
use super::gpu_types::{MAX_CELL_INDICES, MAX_GRID_CELLS};
use super::{
CellRangeGpu, LightGpu, MAX_LIGHTS, MAX_MATERIALS, MAX_PRIMITIVES, MaterialGpu, PrimitiveGpu,
SceneGpuUpload,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GridGpuInfo {
pub origin: Vec3,
pub cell_size: f32,
pub dimensions: UVec3,
}
impl SceneGpuUpload {
/// Convert the serializable editor scene to the fixed GPU ABI.
///
/// Grid ranges are filled separately because the CPU grid builder keeps
/// stable entity IDs while the GPU uses a compact visible-primitive index.
pub fn from_scene(scene: &Scene) -> Self {
let mut upload = Self::default();
let mut unique_materials: Vec<Material> = Vec::new();
for primitive in scene
.primitives()
.iter()
.filter(|primitive| primitive.visible)
.take(MAX_PRIMITIVES)
{
let material_index = unique_materials
.iter()
.position(|material| material == &primitive.material)
.unwrap_or_else(|| {
if unique_materials.len() < MAX_MATERIALS {
unique_materials.push(primitive.material.clone());
unique_materials.len() - 1
} else {
0
}
});
let (kind, params0, params1) = shape_gpu_params(&primitive.shape);
let bounds = primitive.bounding_sphere();
let rotation = if primitive.transform.rotation.is_finite()
&& primitive.transform.rotation.length_squared() > 1.0e-8
{
primitive.transform.rotation.normalize()
} else {
glam::Quat::IDENTITY
};
let scale = primitive.transform.scale.map(|value| {
if value.abs() < 1.0e-5 {
1.0e-5_f32.copysign(value)
} else {
value
}
});
upload.primitives.push(PrimitiveGpu {
center_bound: [
primitive.transform.translation.x,
primitive.transform.translation.y,
primitive.transform.translation.z,
bounds.map_or(1.0e20, |bound| bound.radius),
],
rotation: rotation.to_array(),
scale_smoothing: [scale.x, scale.y, scale.z, 0.0],
params0,
params1,
meta: [kind, material_index as u32, primitive.id, 0],
});
}
if unique_materials.is_empty() {
unique_materials.push(Material::default());
}
upload.materials = unique_materials.iter().map(material_gpu).collect();
upload.lights = scene
.lights()
.iter()
.take(MAX_LIGHTS)
.map(light_gpu)
.collect();
upload
}
pub fn gpu_primitive_index(&self, entity_id: u32) -> Option<u32> {
self.primitives
.iter()
.position(|primitive| primitive.meta[2] == entity_id)
.map(|index| index as u32)
}
/// Build a dense GPU view of the scene's sparse CPU uniform grid.
/// Returns `None` when there are no finite primitives or when the compact
/// representation would exceed the fixed shader buffers; callers should
/// then use the brute-force reference path.
pub fn pack_spatial_grid(
&mut self,
scene: &Scene,
preferred_cell_size: f32,
) -> Option<GridGpuInfo> {
self.cell_ranges.clear();
self.cell_indices.clear();
let finite_bounds: Vec<_> = scene
.primitives()
.iter()
.filter(|primitive| primitive.visible)
.filter_map(|primitive| primitive.bounding_sphere())
.collect();
let first = finite_bounds.first()?;
let mut minimum = first.center - Vec3::splat(first.radius);
let mut maximum = first.center + Vec3::splat(first.radius);
for bounds in &finite_bounds[1..] {
minimum = minimum.min(bounds.center - Vec3::splat(bounds.radius));
maximum = maximum.max(bounds.center + Vec3::splat(bounds.radius));
}
let extent = (maximum - minimum).max(Vec3::splat(0.001));
let mut cell_size = preferred_cell_size.max(0.25);
cell_size = cell_size.max(extent.max_element() / 31.0);
let min_cell = GridCell::new(
(minimum.x / cell_size).floor() as i32,
(minimum.y / cell_size).floor() as i32,
(minimum.z / cell_size).floor() as i32,
);
let max_cell = GridCell::new(
(maximum.x / cell_size).floor() as i32,
(maximum.y / cell_size).floor() as i32,
(maximum.z / cell_size).floor() as i32,
);
let dimensions = UVec3::new(
(max_cell.x - min_cell.x + 1).max(1) as u32,
(max_cell.y - min_cell.y + 1).max(1) as u32,
(max_cell.z - min_cell.z + 1).max(1) as u32,
);
let cell_count = dimensions.x as usize * dimensions.y as usize * dimensions.z as usize;
if cell_count > MAX_GRID_CELLS {
return None;
}
let grid = SpatialGrid::with_options(
scene,
SpatialGridBuildOptions {
cell_size,
max_cells_per_primitive: MAX_GRID_CELLS,
},
);
// The shader's global path intentionally scans planes only. A finite
// primitive in this list would therefore make GPU grid traversal
// incomplete, so fall back to brute force instead.
if grid.global_ids().iter().any(|id| {
scene
.primitive(*id)
.is_some_and(|primitive| !matches!(primitive.shape, SdfPrimitive::Plane { .. }))
}) {
return None;
}
for z in 0..dimensions.z as i32 {
for y in 0..dimensions.y as i32 {
for x in 0..dimensions.x as i32 {
let cell = GridCell::new(min_cell.x + x, min_cell.y + y, min_cell.z + z);
let offset = self.cell_indices.len();
for entity_id in grid.ids_in_cell(cell) {
if let Some(index) = self.gpu_primitive_index(*entity_id) {
self.cell_indices.push(index);
}
}
let count = self.cell_indices.len() - offset;
self.cell_ranges.push(CellRangeGpu {
offset: offset as u32,
count: count as u32,
});
if self.cell_indices.len() > MAX_CELL_INDICES {
self.cell_ranges.clear();
self.cell_indices.clear();
return None;
}
}
}
}
Some(GridGpuInfo {
origin: Vec3::new(min_cell.x as f32, min_cell.y as f32, min_cell.z as f32) * cell_size,
cell_size,
dimensions,
})
}
}
fn material_gpu(material: &Material) -> MaterialGpu {
let emissive = material.emissive_color * material.emission_strength;
MaterialGpu {
base_color_roughness: [
material.albedo.x,
material.albedo.y,
material.albedo.z,
material.roughness.clamp(0.0, 1.0),
],
emissive_metallic: [
emissive.x,
emissive.y,
emissive.z,
material.metallic.clamp(0.0, 1.0),
],
}
}
fn light_gpu(light: &crate::scene::SceneLight) -> LightGpu {
let position = light.position();
let direction = light.direction().normalize_or(glam::Vec3::NEG_Z);
let (kind, range, inner_cos, outer_cos) = match light.kind {
LightKind::DirectionalSun => (0.0, 0.0, 1.0, -1.0),
LightKind::Point { range } => (2.0, range.max(0.001), 1.0, -1.0),
LightKind::Spot {
range,
inner_angle_radians,
outer_angle_radians,
} => (
1.0,
range.max(0.001),
inner_angle_radians.cos(),
outer_angle_radians.cos(),
),
};
LightGpu {
position_range: [position.x, position.y, position.z, range],
direction_outer_cos: [direction.x, direction.y, direction.z, outer_cos],
color_intensity: [
light.color.x,
light.color.y,
light.color.z,
light.intensity.max(0.0),
],
params: [kind, inner_cos, if light.enabled { 1.0 } else { 0.0 }, 0.0],
}
}
fn shape_gpu_params(shape: &SdfPrimitive) -> (u32, [f32; 4], [f32; 4]) {
let mut p0 = [0.0; 4];
let p1 = [0.0; 4];
let kind = match *shape {
SdfPrimitive::Sphere { radius } => {
p0[0] = radius;
0
}
SdfPrimitive::Box { half_extents } => {
p0[..3].copy_from_slice(&half_extents.to_array());
1
}
SdfPrimitive::RoundedBox {
half_extents,
radius,
} => {
p0[..3].copy_from_slice(&half_extents.to_array());
p0[3] = radius;
2
}
SdfPrimitive::BoxFrame {
half_extents,
edge_thickness,
} => {
p0[..3].copy_from_slice(&half_extents.to_array());
p0[3] = edge_thickness;
3
}
SdfPrimitive::Plane { normal, offset } => {
p0[..3].copy_from_slice(&normal.to_array());
p0[3] = offset;
4
}
SdfPrimitive::Torus {
major_radius,
minor_radius,
} => {
p0[0] = major_radius;
p0[1] = minor_radius;
5
}
SdfPrimitive::Link {
half_length,
major_radius,
minor_radius,
} => {
p0[0] = half_length;
p0[1] = major_radius;
p0[2] = minor_radius;
6
}
SdfPrimitive::Capsule {
half_height,
radius,
} => {
p0[0] = half_height;
p0[1] = radius;
7
}
SdfPrimitive::Cylinder {
half_height,
radius,
} => {
p0[0] = half_height;
p0[1] = radius;
8
}
SdfPrimitive::CappedCone {
half_height,
bottom_radius,
top_radius,
} => {
p0[0] = half_height;
p0[1] = bottom_radius;
p0[2] = top_radius;
9
}
SdfPrimitive::RoundedCone {
half_height,
bottom_radius,
top_radius,
} => {
p0[0] = half_height;
p0[1] = bottom_radius;
p0[2] = top_radius;
10
}
SdfPrimitive::Ellipsoid { radii } => {
p0[..3].copy_from_slice(&radii.to_array());
11
}
SdfPrimitive::Octahedron { size } => {
p0[0] = size;
12
}
SdfPrimitive::TriangularPrism {
triangle_radius,
half_height,
} => {
p0[0] = triangle_radius;
p0[1] = half_height;
13
}
SdfPrimitive::HexagonalPrism {
hexagon_radius,
half_height,
} => {
p0[0] = hexagon_radius;
p0[1] = half_height;
14
}
SdfPrimitive::BlackHole {
horizon_radius,
influence_radius,
lens_strength,
spin,
} => {
p0 = [horizon_radius, influence_radius, lens_strength, spin];
15
}
};
(kind, p0, p1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_cpu_shapes_have_distinct_gpu_kinds() {
let shapes = [
SdfPrimitive::Sphere { radius: 1.0 },
SdfPrimitive::Box {
half_extents: glam::Vec3::ONE,
},
SdfPrimitive::RoundedBox {
half_extents: glam::Vec3::ONE,
radius: 0.1,
},
SdfPrimitive::BoxFrame {
half_extents: glam::Vec3::ONE,
edge_thickness: 0.1,
},
SdfPrimitive::Plane {
normal: glam::Vec3::Z,
offset: 0.0,
},
SdfPrimitive::Torus {
major_radius: 1.0,
minor_radius: 0.2,
},
SdfPrimitive::Link {
half_length: 0.5,
major_radius: 0.8,
minor_radius: 0.15,
},
SdfPrimitive::Capsule {
half_height: 1.0,
radius: 0.25,
},
SdfPrimitive::Cylinder {
half_height: 1.0,
radius: 0.5,
},
SdfPrimitive::CappedCone {
half_height: 1.0,
bottom_radius: 0.7,
top_radius: 0.2,
},
SdfPrimitive::RoundedCone {
half_height: 1.0,
bottom_radius: 0.7,
top_radius: 0.2,
},
SdfPrimitive::Ellipsoid {
radii: glam::Vec3::ONE,
},
SdfPrimitive::Octahedron { size: 1.0 },
SdfPrimitive::TriangularPrism {
triangle_radius: 1.0,
half_height: 0.5,
},
SdfPrimitive::HexagonalPrism {
hexagon_radius: 1.0,
half_height: 0.5,
},
SdfPrimitive::BlackHole {
horizon_radius: 0.75,
influence_radius: 8.0,
lens_strength: 1.5,
spin: -0.4,
},
];
let kinds: Vec<_> = shapes
.iter()
.map(|shape| shape_gpu_params(shape).0)
.collect();
assert_eq!(kinds, (0..16).collect::<Vec<_>>());
}
#[test]
fn black_hole_upload_uses_kind_fifteen_and_preserves_lensing_parameters() {
let mut scene = Scene::empty();
let id = scene.add_primitive(SdfPrimitive::BlackHole {
horizon_radius: 0.8,
influence_radius: 11.0,
lens_strength: 2.25,
spin: -0.6,
});
let upload = SceneGpuUpload::from_scene(&scene);
let primitive = upload
.primitives
.iter()
.find(|primitive| primitive.meta[2] == id)
.unwrap();
assert_eq!(primitive.meta[0], 15);
assert_eq!(primitive.params0, [0.8, 11.0, 2.25, -0.6]);
assert_eq!(primitive.params1, [0.0; 4]);
assert_eq!(primitive.center_bound[3], 0.8);
}
#[test]
fn point_light_uses_kind_two_and_preserves_range() {
let mut scene = Scene::empty();
let id = scene.add_light(LightKind::Point { range: 17.5 });
let light = scene.light_mut(id).unwrap();
light.transform.translation = glam::Vec3::new(1.0, 2.0, 3.0);
light.color = glam::Vec3::new(0.25, 0.5, 0.75);
light.intensity = 8.0;
let upload = SceneGpuUpload::from_scene(&scene);
assert_eq!(upload.lights.len(), 1);
let gpu = upload.lights[0];
assert_eq!(gpu.position_range, [1.0, 2.0, 3.0, 17.5]);
assert_eq!(gpu.color_intensity, [0.25, 0.5, 0.75, 8.0]);
assert_eq!(gpu.params, [2.0, 1.0, 1.0, 0.0]);
}
#[test]
fn dense_gpu_grid_uses_compact_indices_and_keeps_plane_global() {
let mut scene = Scene::empty();
let plane = scene.add_primitive(SdfPrimitive::Plane {
normal: glam::Vec3::Z,
offset: 0.0,
});
let sphere = scene.add_primitive(SdfPrimitive::Sphere { radius: 1.0 });
scene.primitive_mut(sphere).unwrap().transform.translation =
glam::Vec3::new(-2.0, 0.0, 1.0);
let box_id = scene.add_primitive(SdfPrimitive::Box {
half_extents: glam::Vec3::ONE,
});
scene.primitive_mut(box_id).unwrap().transform.translation = glam::Vec3::new(2.0, 0.0, 1.0);
let mut upload = SceneGpuUpload::from_scene(&scene);
let info = upload.pack_spatial_grid(&scene, 1.0).unwrap();
assert_eq!(
upload.cell_ranges.len(),
(info.dimensions.x * info.dimensions.y * info.dimensions.z) as usize
);
let plane_index = upload.gpu_primitive_index(plane).unwrap();
let sphere_index = upload.gpu_primitive_index(sphere).unwrap();
let box_index = upload.gpu_primitive_index(box_id).unwrap();
assert!(!upload.cell_indices.contains(&plane_index));
assert!(upload.cell_indices.contains(&sphere_index));
assert!(upload.cell_indices.contains(&box_index));
assert!(upload.cell_ranges.iter().all(|range| {
range.offset as usize + range.count as usize <= upload.cell_indices.len()
}));
}
}
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
#[test]
fn wgsl_validates_and_emits_vulkan_spirv() {
let source = include_str!("shader.wgsl");
let module = naga::front::wgsl::parse_str(source).expect("ray marcher WGSL must parse");
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.expect("ray marcher WGSL must validate");
for (shader_stage, entry_point) in [
(naga::ShaderStage::Vertex, "vs_main"),
(naga::ShaderStage::Fragment, "fs_main"),
(naga::ShaderStage::Fragment, "fs_pick"),
] {
let words = naga::back::spv::write_vec(
&module,
&info,
&naga::back::spv::Options::default(),
Some(&naga::back::spv::PipelineOptions {
shader_stage,
entry_point: entry_point.into(),
}),
)
.unwrap_or_else(|error| panic!("{entry_point} must emit Vulkan SPIR-V: {error}"));
assert_eq!(words.first().copied(), Some(0x0723_0203));
}
}
+19
View File
@@ -0,0 +1,19 @@
//! Serializable scene model, CPU SDF evaluation and spatial acceleration.
mod model;
mod primitive;
#[allow(clippy::module_inception)]
mod scene;
mod spatial_grid;
mod transform;
pub use model::{
CloudSettings, EditorCamera, EntityId, LightId, LightKind, LightingSettings, Material,
PrimitiveId, SceneLight,
};
pub use primitive::{BoundingSphere, SdfPrimitive};
pub use scene::{
Ray, RayHit, RayMarchSettings, Scene, ScenePrimitive, SceneSample, SceneValidationError,
};
pub use spatial_grid::{GridCell, SpatialGrid, SpatialGridBuildOptions};
pub use transform::Transform;
+363
View File
@@ -0,0 +1,363 @@
use glam::{Quat, Vec2, Vec3};
use serde::{Deserialize, Serialize};
use super::{BoundingSphere, Transform};
/// IDs share one namespace so an editor can never confuse a primitive and a
/// light after save/load or duplication.
pub type EntityId = u32;
pub type PrimitiveId = EntityId;
pub type LightId = EntityId;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Material {
pub albedo: Vec3,
pub roughness: f32,
pub metallic: f32,
pub emissive_color: Vec3,
pub emission_strength: f32,
}
impl Default for Material {
fn default() -> Self {
Self {
albedo: Vec3::splat(0.7),
roughness: 0.6,
metallic: 0.0,
emissive_color: Vec3::ONE,
emission_strength: 0.0,
}
}
}
impl Material {
pub(crate) fn validate(&self) -> Result<(), String> {
if !self.albedo.is_finite() || self.albedo.min_element() < 0.0 {
return Err("material albedo must be finite and non-negative".into());
}
if !self.emissive_color.is_finite() || self.emissive_color.min_element() < 0.0 {
return Err("material emissive_color must be finite and non-negative".into());
}
if !self.roughness.is_finite() || !(0.0..=1.0).contains(&self.roughness) {
return Err("material roughness must be in 0..=1".into());
}
if !self.metallic.is_finite() || !(0.0..=1.0).contains(&self.metallic) {
return Err("material metallic must be in 0..=1".into());
}
if !self.emission_strength.is_finite() || self.emission_strength < 0.0 {
return Err("material emission_strength must be finite and non-negative".into());
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LightKind {
DirectionalSun,
Point {
range: f32,
},
Spot {
range: f32,
inner_angle_radians: f32,
outer_angle_radians: f32,
},
}
impl LightKind {
pub fn point_default() -> Self {
Self::Point { range: 25.0 }
}
pub fn spot_default() -> Self {
Self::Spot {
range: 25.0,
inner_angle_radians: 20.0_f32.to_radians(),
outer_angle_radians: 30.0_f32.to_radians(),
}
}
pub fn display_name(&self) -> &'static str {
match self {
Self::DirectionalSun => "Солнце",
Self::Point { .. } => "Точечный свет",
Self::Spot { .. } => "Прожектор",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct SceneLight {
pub id: LightId,
pub name: String,
pub transform: Transform,
pub kind: LightKind,
pub color: Vec3,
pub intensity: f32,
pub enabled: bool,
}
impl Default for SceneLight {
fn default() -> Self {
Self {
id: 0,
name: "Солнце".into(),
transform: Transform::default(),
kind: LightKind::DirectionalSun,
color: Vec3::ONE,
intensity: 4.0,
enabled: true,
}
}
}
impl SceneLight {
/// Directional and spot lights point down their local -Z axis. A point
/// light has no direction, but returning the same stable vector keeps
/// editor helpers and the fixed GPU ABI uniform across all light kinds.
pub fn direction(&self) -> Vec3 {
let rotation = if self.transform.rotation.is_finite()
&& self.transform.rotation.length_squared() > Transform::MIN_SCALE
{
self.transform.rotation.normalize()
} else {
Quat::IDENTITY
};
rotation * -Vec3::Z
}
pub fn position(&self) -> Vec3 {
self.transform.translation
}
pub fn selection_sphere(&self, icon_radius: f32) -> BoundingSphere {
BoundingSphere {
center: self.position(),
radius: icon_radius.abs().max(0.01),
}
}
pub(crate) fn validate(&self) -> Result<(), String> {
if !self.transform.is_finite() {
return Err("light transform contains invalid values".into());
}
if !self.color.is_finite() || self.color.min_element() < 0.0 {
return Err("light color must be finite and non-negative".into());
}
if !self.intensity.is_finite() || self.intensity < 0.0 {
return Err("light intensity must be finite and non-negative".into());
}
match self.kind {
LightKind::DirectionalSun => {}
LightKind::Point { range } => {
if !range.is_finite() || range <= 0.0 {
return Err("point range must be finite and greater than zero".into());
}
}
LightKind::Spot {
range,
inner_angle_radians,
outer_angle_radians,
} => {
if !range.is_finite() || range <= 0.0 {
return Err("spot range must be finite and greater than zero".into());
}
if !inner_angle_radians.is_finite()
|| !outer_angle_radians.is_finite()
|| inner_angle_radians < 0.0
|| outer_angle_radians <= 0.0
|| inner_angle_radians > outer_angle_radians
|| outer_angle_radians >= core::f32::consts::FRAC_PI_2
{
return Err("spot cone angles must satisfy 0 <= inner <= outer < pi/2".into());
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct LightingSettings {
pub ambient_color: Vec3,
pub ambient_intensity: f32,
pub shadow_softness: f32,
pub max_shadow_distance: f32,
}
impl Default for LightingSettings {
fn default() -> Self {
Self {
ambient_color: Vec3::new(0.35, 0.42, 0.55),
ambient_intensity: 0.12,
shadow_softness: 12.0,
max_shadow_distance: 200.0,
}
}
}
impl LightingSettings {
pub(crate) fn validate(&self) -> Result<(), String> {
if !self.ambient_color.is_finite() || self.ambient_color.min_element() < 0.0 {
return Err("ambient color must be finite and non-negative".into());
}
for (name, value) in [
("ambient_intensity", self.ambient_intensity),
("shadow_softness", self.shadow_softness),
("max_shadow_distance", self.max_shadow_distance),
] {
if !value.is_finite() || value < 0.0 {
return Err(format!("{name} must be finite and non-negative"));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct CloudSettings {
pub enabled: bool,
pub base_height: f32,
pub thickness: f32,
pub coverage: f32,
pub density: f32,
pub scale: f32,
pub wind_direction: Vec2,
pub wind_speed: f32,
pub color: Vec3,
pub absorption: f32,
}
impl Default for CloudSettings {
fn default() -> Self {
Self {
enabled: true,
base_height: 80.0,
thickness: 35.0,
coverage: 0.45,
density: 0.7,
scale: 0.012,
wind_direction: Vec2::new(1.0, 0.2).normalize(),
wind_speed: 1.5,
color: Vec3::ONE,
absorption: 0.6,
}
}
}
impl CloudSettings {
pub(crate) fn validate(&self) -> Result<(), String> {
for (name, value) in [
("base_height", self.base_height),
("thickness", self.thickness),
("density", self.density),
("scale", self.scale),
("wind_speed", self.wind_speed),
("absorption", self.absorption),
] {
if !value.is_finite() || value < 0.0 {
return Err(format!("cloud {name} must be finite and non-negative"));
}
}
if !self.coverage.is_finite() || !(0.0..=1.0).contains(&self.coverage) {
return Err("cloud coverage must be in 0..=1".into());
}
if !self.wind_direction.is_finite() || self.wind_direction.length_squared() < 1.0e-6 {
return Err("cloud wind_direction must be finite and non-zero".into());
}
if !self.color.is_finite() || self.color.min_element() < 0.0 {
return Err("cloud color must be finite and non-negative".into());
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct EditorCamera {
pub position: Vec3,
pub yaw_radians: f32,
pub pitch_radians: f32,
pub movement_speed: f32,
pub orbit_pivot: Vec3,
pub field_of_view_y_radians: f32,
pub near_plane: f32,
pub far_plane: f32,
}
impl Default for EditorCamera {
fn default() -> Self {
Self {
position: Vec3::new(8.0, -8.0, 6.0),
yaw_radians: -45.0_f32.to_radians(),
pitch_radians: -27.938_353_f32.to_radians(),
movement_speed: 8.0,
orbit_pivot: Vec3::ZERO,
field_of_view_y_radians: 60.0_f32.to_radians(),
near_plane: 0.01,
far_plane: 2_000.0,
}
}
}
impl EditorCamera {
pub(crate) fn validate(&self) -> Result<(), String> {
if !self.position.is_finite() || !self.orbit_pivot.is_finite() {
return Err("editor camera vectors must be finite".into());
}
for (name, value) in [
("yaw_radians", self.yaw_radians),
("pitch_radians", self.pitch_radians),
("movement_speed", self.movement_speed),
("field_of_view_y_radians", self.field_of_view_y_radians),
("near_plane", self.near_plane),
("far_plane", self.far_plane),
] {
if !value.is_finite() {
return Err(format!("editor camera {name} must be finite"));
}
}
if self.movement_speed <= 0.0
|| self.field_of_view_y_radians <= 0.0
|| self.field_of_view_y_radians >= core::f32::consts::PI
|| self.near_plane <= 0.0
|| self.far_plane <= self.near_plane
{
return Err("editor camera projection or movement settings are invalid".into());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn point_light_has_stable_serde_shape_and_round_trips() {
let kind = LightKind::Point { range: 17.5 };
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(json, r#"{"type":"point","range":17.5}"#);
assert_eq!(serde_json::from_str::<LightKind>(&json).unwrap(), kind);
}
#[test]
fn point_light_range_must_be_positive_and_finite() {
let mut light = SceneLight {
kind: LightKind::Point { range: 0.0 },
..SceneLight::default()
};
assert!(light.validate().is_err());
light.kind = LightKind::Point { range: f32::NAN };
assert!(light.validate().is_err());
light.kind = LightKind::point_default();
assert!(light.validate().is_ok());
}
}
+844
View File
@@ -0,0 +1,844 @@
use glam::{Vec2, Vec3};
use serde::{Deserialize, Serialize};
const EPSILON: f32 = 1.0e-6;
/// A conservative sphere used by the spatial index before evaluating an SDF.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct BoundingSphere {
pub center: Vec3,
pub radius: f32,
}
impl BoundingSphere {
pub fn contains(self, point: Vec3) -> bool {
point.distance_squared(self.center) <= self.radius * self.radius
}
pub fn intersects(self, other: Self) -> bool {
let radius = self.radius + other.radius;
self.center.distance_squared(other.center) <= radius * radius
}
}
/// The built-in set of local-space signed-distance shapes.
///
/// All finite shapes are centered at the origin. `Plane` is the only
/// unbounded primitive and is consequently placed in a spatial grid's global
/// list. The editor is Z-up: the torus normal and the height axes of capsule,
/// cylinder, cones, and prisms are local +Z before the primitive transform.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SdfPrimitive {
Sphere {
radius: f32,
},
Box {
half_extents: Vec3,
},
RoundedBox {
half_extents: Vec3,
radius: f32,
},
BoxFrame {
half_extents: Vec3,
edge_thickness: f32,
},
Plane {
normal: Vec3,
offset: f32,
},
Torus {
major_radius: f32,
minor_radius: f32,
},
Link {
half_length: f32,
major_radius: f32,
minor_radius: f32,
},
Capsule {
half_height: f32,
radius: f32,
},
Cylinder {
half_height: f32,
radius: f32,
},
CappedCone {
half_height: f32,
bottom_radius: f32,
top_radius: f32,
},
RoundedCone {
half_height: f32,
bottom_radius: f32,
top_radius: f32,
},
Ellipsoid {
radii: Vec3,
},
Octahedron {
size: f32,
},
TriangularPrism {
triangle_radius: f32,
half_height: f32,
},
HexagonalPrism {
hexagon_radius: f32,
half_height: f32,
},
BlackHole {
horizon_radius: f32,
influence_radius: f32,
lens_strength: f32,
spin: f32,
},
}
impl Default for SdfPrimitive {
fn default() -> Self {
Self::Sphere { radius: 1.0 }
}
}
impl SdfPrimitive {
/// Signed distance in the primitive's local space.
pub fn signed_distance(&self, point: Vec3) -> f32 {
match *self {
Self::Sphere { radius } => point.length() - positive(radius),
Self::Box { half_extents } => sd_box(point, positive_vec3(half_extents)),
Self::RoundedBox {
half_extents,
radius,
} => sd_box(point, positive_vec3(half_extents)) - positive(radius),
Self::BoxFrame {
half_extents,
edge_thickness,
} => sd_box_frame(point, positive_vec3(half_extents), positive(edge_thickness)),
Self::Plane { normal, offset } => {
point.dot(normal.normalize_or(Vec3::Z)) + finite_or_zero(offset)
}
Self::Torus {
major_radius,
minor_radius,
} => {
let radial = Vec2::new(point.x, point.y).length() - positive(major_radius);
Vec2::new(radial, point.z).length() - positive(minor_radius)
}
Self::Link {
half_length,
major_radius,
minor_radius,
} => sd_link(
point,
positive(half_length),
positive(major_radius),
positive(minor_radius),
),
Self::Capsule {
half_height,
radius,
} => {
let closest_z = point.z.clamp(-positive(half_height), positive(half_height));
(point - Vec3::Z * closest_z).length() - positive(radius)
}
Self::Cylinder {
half_height,
radius,
} => sd_cylinder(point, positive(half_height), positive(radius)),
Self::CappedCone {
half_height,
bottom_radius,
top_radius,
} => sd_capped_cone(
point,
positive(half_height),
positive(bottom_radius),
positive(top_radius),
),
Self::RoundedCone {
half_height,
bottom_radius,
top_radius,
} => sd_rounded_cone(
point,
positive(half_height),
positive(bottom_radius),
positive(top_radius),
),
Self::Ellipsoid { radii } => sd_ellipsoid(point, positive_vec3(radii)),
Self::Octahedron { size } => sd_octahedron(point, positive(size)),
Self::TriangularPrism {
triangle_radius,
half_height,
} => sd_triangular_prism(point, positive(triangle_radius), positive(half_height)),
Self::HexagonalPrism {
hexagon_radius,
half_height,
} => sd_hexagonal_prism(point, positive(hexagon_radius), positive(half_height)),
Self::BlackHole { horizon_radius, .. } => point.length() - positive(horizon_radius),
}
}
/// A conservative local-space bound. `None` means the primitive is
/// unbounded and must be considered globally.
pub fn bounding_sphere(&self) -> Option<BoundingSphere> {
let radius = match *self {
Self::Sphere { radius } => positive(radius),
Self::Box { half_extents } => positive_vec3(half_extents).length(),
Self::RoundedBox {
half_extents,
radius,
} => positive_vec3(half_extents).length() + positive(radius),
Self::BoxFrame { half_extents, .. } => positive_vec3(half_extents).length(),
Self::Plane { .. } => return None,
Self::Torus {
major_radius,
minor_radius,
} => positive(major_radius) + positive(minor_radius),
Self::Link {
half_length,
major_radius,
minor_radius,
} => positive(half_length) + positive(major_radius) + positive(minor_radius),
Self::Capsule {
half_height,
radius,
} => positive(half_height) + positive(radius),
Self::Cylinder {
half_height,
radius,
} => positive(half_height).hypot(positive(radius)),
Self::CappedCone {
half_height,
bottom_radius,
top_radius,
} => positive(half_height).hypot(positive(bottom_radius).max(positive(top_radius))),
Self::RoundedCone {
half_height,
bottom_radius,
top_radius,
} => positive(half_height) + positive(bottom_radius).max(positive(top_radius)),
Self::Ellipsoid { radii } => positive_vec3(radii).max_element(),
Self::Octahedron { size } => positive(size),
Self::TriangularPrism {
triangle_radius,
half_height,
} => (2.0 * positive(triangle_radius)).hypot(positive(half_height)),
Self::HexagonalPrism {
hexagon_radius,
half_height,
} => {
let vertex_radius = 2.0 * positive(hexagon_radius) / 3.0_f32.sqrt();
vertex_radius.hypot(positive(half_height))
}
Self::BlackHole { horizon_radius, .. } => positive(horizon_radius),
};
Some(BoundingSphere {
center: Vec3::ZERO,
radius,
})
}
pub fn kind_name(&self) -> &'static str {
match self {
Self::Sphere { .. } => "Сфера",
Self::Box { .. } => "Куб",
Self::RoundedBox { .. } => "Скруглённый куб",
Self::BoxFrame { .. } => "Каркас куба",
Self::Plane { .. } => "Плоскость",
Self::Torus { .. } => "Тор",
Self::Link { .. } => "Звено",
Self::Capsule { .. } => "Капсула",
Self::Cylinder { .. } => "Цилиндр",
Self::CappedCone { .. } => "Усечённый конус",
Self::RoundedCone { .. } => "Скруглённый конус",
Self::Ellipsoid { .. } => "Эллипсоид",
Self::Octahedron { .. } => "Октаэдр",
Self::TriangularPrism { .. } => "Треугольная призма",
Self::HexagonalPrism { .. } => "Шестиугольная призма",
Self::BlackHole { .. } => "Чёрная дыра",
}
}
pub(crate) fn validate_parameters(&self) -> Result<(), String> {
let positive_scalar = |name: &str, value: f32| {
if value.is_finite() && value > 0.0 {
Ok(())
} else {
Err(format!("{name} must be finite and greater than zero"))
}
};
let positive_vector = |name: &str, value: Vec3| {
if value.is_finite() && value.min_element() > 0.0 {
Ok(())
} else {
Err(format!("{name} must contain finite, positive components"))
}
};
match *self {
Self::Sphere { radius } => positive_scalar("radius", radius),
Self::Box { half_extents } => positive_vector("half_extents", half_extents),
Self::RoundedBox {
half_extents,
radius,
} => {
positive_vector("half_extents", half_extents)?;
positive_scalar("radius", radius)
}
Self::BoxFrame {
half_extents,
edge_thickness,
} => {
positive_vector("half_extents", half_extents)?;
positive_scalar("edge_thickness", edge_thickness)
}
Self::Plane { normal, offset } => {
if !normal.is_finite() || normal.length_squared() <= EPSILON {
return Err("normal must be finite and non-zero".into());
}
if !offset.is_finite() {
return Err("offset must be finite".into());
}
Ok(())
}
Self::Torus {
major_radius,
minor_radius,
} => {
positive_scalar("major_radius", major_radius)?;
positive_scalar("minor_radius", minor_radius)
}
Self::Link {
half_length,
major_radius,
minor_radius,
} => {
positive_scalar("half_length", half_length)?;
positive_scalar("major_radius", major_radius)?;
positive_scalar("minor_radius", minor_radius)
}
Self::Capsule {
half_height,
radius,
}
| Self::Cylinder {
half_height,
radius,
} => {
positive_scalar("half_height", half_height)?;
positive_scalar("radius", radius)
}
Self::CappedCone {
half_height,
bottom_radius,
top_radius,
}
| Self::RoundedCone {
half_height,
bottom_radius,
top_radius,
} => {
positive_scalar("half_height", half_height)?;
positive_scalar("bottom_radius", bottom_radius)?;
positive_scalar("top_radius", top_radius)
}
Self::Ellipsoid { radii } => positive_vector("radii", radii),
Self::Octahedron { size } => positive_scalar("size", size),
Self::TriangularPrism {
triangle_radius,
half_height,
} => {
positive_scalar("triangle_radius", triangle_radius)?;
positive_scalar("half_height", half_height)
}
Self::HexagonalPrism {
hexagon_radius,
half_height,
} => {
positive_scalar("hexagon_radius", hexagon_radius)?;
positive_scalar("half_height", half_height)
}
Self::BlackHole {
horizon_radius,
influence_radius,
lens_strength,
spin,
} => {
positive_scalar("horizon_radius", horizon_radius)?;
if !influence_radius.is_finite() || influence_radius <= horizon_radius {
return Err(
"influence_radius must be finite and greater than horizon_radius".into(),
);
}
if !lens_strength.is_finite() || lens_strength < 0.0 {
return Err("lens_strength must be finite and non-negative".into());
}
if !spin.is_finite() {
return Err("spin must be finite".into());
}
Ok(())
}
}
}
}
fn sd_box(point: Vec3, half_extents: Vec3) -> f32 {
let q = point.abs() - half_extents;
q.max(Vec3::ZERO).length() + q.max_element().min(0.0)
}
fn sd_box_frame(point: Vec3, half_extents: Vec3, edge: f32) -> f32 {
let p = point.abs() - half_extents;
let q = (p + Vec3::splat(edge)).abs() - Vec3::splat(edge);
let x = Vec3::new(p.x, q.y, q.z);
let y = Vec3::new(q.x, p.y, q.z);
let z = Vec3::new(q.x, q.y, p.z);
frame_part(x).min(frame_part(y)).min(frame_part(z))
}
fn frame_part(value: Vec3) -> f32 {
value.max(Vec3::ZERO).length() + value.max_element().min(0.0)
}
fn sd_link(point: Vec3, half_length: f32, major_radius: f32, minor_radius: f32) -> f32 {
let q = Vec3::new(point.x, (point.y.abs() - half_length).max(0.0), point.z);
Vec2::new(Vec2::new(q.x, q.y).length() - major_radius, q.z).length() - minor_radius
}
fn sd_cylinder(point: Vec3, half_height: f32, radius: f32) -> f32 {
let d = Vec2::new(
Vec2::new(point.x, point.y).length() - radius,
point.z.abs() - half_height,
);
d.max(Vec2::ZERO).length() + d.max_element().min(0.0)
}
fn sd_capped_cone(point: Vec3, half_height: f32, bottom_radius: f32, top_radius: f32) -> f32 {
let q = Vec2::new(Vec2::new(point.x, point.y).length(), point.z);
let k1 = Vec2::new(top_radius, half_height);
let k2 = Vec2::new(top_radius - bottom_radius, 2.0 * half_height);
let cap_radius = if q.y < 0.0 { bottom_radius } else { top_radius };
let ca = Vec2::new(q.x - q.x.min(cap_radius), q.y.abs() - half_height);
let denominator = k2.length_squared().max(EPSILON);
let cb = q - k1 + k2 * ((k1 - q).dot(k2) / denominator).clamp(0.0, 1.0);
let sign = if cb.x < 0.0 && ca.y < 0.0 { -1.0 } else { 1.0 };
sign * ca.length_squared().min(cb.length_squared()).sqrt()
}
fn sd_rounded_cone(point: Vec3, half_height: f32, bottom_radius: f32, top_radius: f32) -> f32 {
let height = 2.0 * half_height;
let q = Vec2::new(Vec2::new(point.x, point.y).length(), point.z + half_height);
let radius_delta = bottom_radius - top_radius;
if radius_delta.abs() >= height {
return if bottom_radius >= top_radius {
q.length() - bottom_radius
} else {
(q - Vec2::new(0.0, height)).length() - top_radius
};
}
let slope = radius_delta / height;
let adjacent = (1.0 - slope * slope).sqrt();
let k = q.dot(Vec2::new(-slope, adjacent));
if k < 0.0 {
q.length() - bottom_radius
} else if k > adjacent * height {
(q - Vec2::new(0.0, height)).length() - top_radius
} else {
q.dot(Vec2::new(adjacent, slope)) - bottom_radius
}
}
fn sd_ellipsoid(point: Vec3, radii: Vec3) -> f32 {
let k0 = (point / radii).length();
let k1 = (point / (radii * radii)).length();
if k1 <= EPSILON {
-radii.min_element()
} else {
k0 * (k0 - 1.0) / k1
}
}
fn sd_octahedron(point: Vec3, size: f32) -> f32 {
let p = point.abs();
let m = p.x + p.y + p.z - size;
let q = if 3.0 * p.x < m {
p
} else if 3.0 * p.y < m {
Vec3::new(p.y, p.z, p.x)
} else if 3.0 * p.z < m {
Vec3::new(p.z, p.x, p.y)
} else {
return m * 0.577_350_26;
};
let k = (0.5 * (q.z - q.y + size)).clamp(0.0, size);
Vec3::new(q.x, q.y - size + k, q.z - k).length()
}
fn sd_equilateral_triangle(mut point: Vec2, radius: f32) -> f32 {
const SQRT_3: f32 = 1.732_050_8;
point.x = point.x.abs() - radius;
point.y += radius / SQRT_3;
if point.x + SQRT_3 * point.y > 0.0 {
point = Vec2::new(point.x - SQRT_3 * point.y, -SQRT_3 * point.x - point.y) * 0.5;
}
point.x -= point.x.clamp(-2.0 * radius, 0.0);
-point.length() * point.y.signum()
}
fn sd_triangular_prism(point: Vec3, triangle_radius: f32, half_height: f32) -> f32 {
let triangle = sd_equilateral_triangle(Vec2::new(point.x, point.y), triangle_radius);
extrude_distance(triangle, point.z.abs() - half_height)
}
fn sd_hexagon(mut point: Vec2, radius: f32) -> f32 {
const KX: f32 = -0.866_025_4;
const KY: f32 = 0.5;
const KZ: f32 = 0.577_350_26;
point = point.abs();
let projection = (Vec2::new(KX, KY).dot(point)).min(0.0);
point -= 2.0 * projection * Vec2::new(KX, KY);
point -= Vec2::new(point.x.clamp(-KZ * radius, KZ * radius), radius);
point.length() * point.y.signum()
}
fn sd_hexagonal_prism(point: Vec3, hexagon_radius: f32, half_height: f32) -> f32 {
let hexagon = sd_hexagon(Vec2::new(point.x, point.y), hexagon_radius);
extrude_distance(hexagon, point.z.abs() - half_height)
}
fn extrude_distance(planar_distance: f32, vertical_distance: f32) -> f32 {
let d = Vec2::new(planar_distance, vertical_distance);
d.max(Vec2::ZERO).length() + d.max_element().min(0.0)
}
fn positive(value: f32) -> f32 {
if value.is_finite() {
value.abs().max(EPSILON)
} else {
EPSILON
}
}
fn positive_vec3(value: Vec3) -> Vec3 {
value.map(positive)
}
fn finite_or_zero(value: f32) -> f32 {
if value.is_finite() { value } else { 0.0 }
}
#[cfg(test)]
mod tests {
use super::*;
fn approx_eq(left: f32, right: f32) {
assert!((left - right).abs() < 1.0e-5, "{left} != {right}");
}
#[test]
fn sphere_box_and_plane_distances_are_signed() {
let sphere = SdfPrimitive::Sphere { radius: 2.0 };
approx_eq(sphere.signed_distance(Vec3::ZERO), -2.0);
approx_eq(sphere.signed_distance(Vec3::X * 3.0), 1.0);
let cube = SdfPrimitive::Box {
half_extents: Vec3::ONE,
};
approx_eq(cube.signed_distance(Vec3::ZERO), -1.0);
approx_eq(cube.signed_distance(Vec3::splat(2.0)), 3.0_f32.sqrt());
let plane = SdfPrimitive::Plane {
normal: Vec3::Z,
offset: 0.5,
};
approx_eq(plane.signed_distance(Vec3::new(0.0, 0.0, 1.0)), 1.5);
approx_eq(plane.signed_distance(Vec3::new(0.0, 0.0, -1.0)), -0.5);
}
#[test]
fn all_sixteen_shape_variants_evaluate_finite_distances() {
let shapes = vec![
SdfPrimitive::Sphere { radius: 1.0 },
SdfPrimitive::Box {
half_extents: Vec3::ONE,
},
SdfPrimitive::RoundedBox {
half_extents: Vec3::ONE,
radius: 0.2,
},
SdfPrimitive::BoxFrame {
half_extents: Vec3::ONE,
edge_thickness: 0.1,
},
SdfPrimitive::Plane {
normal: Vec3::Z,
offset: 0.0,
},
SdfPrimitive::Torus {
major_radius: 1.0,
minor_radius: 0.2,
},
SdfPrimitive::Link {
half_length: 0.5,
major_radius: 1.0,
minor_radius: 0.2,
},
SdfPrimitive::Capsule {
half_height: 1.0,
radius: 0.5,
},
SdfPrimitive::Cylinder {
half_height: 1.0,
radius: 0.5,
},
SdfPrimitive::CappedCone {
half_height: 1.0,
bottom_radius: 1.0,
top_radius: 0.2,
},
SdfPrimitive::RoundedCone {
half_height: 1.0,
bottom_radius: 0.8,
top_radius: 0.3,
},
SdfPrimitive::Ellipsoid {
radii: Vec3::new(1.0, 2.0, 0.5),
},
SdfPrimitive::Octahedron { size: 1.0 },
SdfPrimitive::TriangularPrism {
triangle_radius: 1.0,
half_height: 1.0,
},
SdfPrimitive::HexagonalPrism {
hexagon_radius: 1.0,
half_height: 1.0,
},
SdfPrimitive::BlackHole {
horizon_radius: 0.75,
influence_radius: 6.0,
lens_strength: 1.5,
spin: 0.4,
},
];
assert_eq!(shapes.len(), 16);
for shape in shapes {
assert!(shape.signed_distance(Vec3::new(0.1, 0.2, 0.3)).is_finite());
if !matches!(shape, SdfPrimitive::Plane { .. }) {
assert!(shape.bounding_sphere().is_some());
}
}
}
#[test]
fn conservative_bounds_contain_representative_extreme_points() {
let cases = [
(
SdfPrimitive::Sphere { radius: 2.0 },
Vec3::new(2.0, 0.0, 0.0),
),
(
SdfPrimitive::Box {
half_extents: Vec3::new(1.0, 2.0, 3.0),
},
Vec3::new(1.0, 2.0, 3.0),
),
(
SdfPrimitive::RoundedBox {
half_extents: Vec3::new(1.0, 2.0, 3.0),
radius: 0.5,
},
Vec3::new(1.5, 2.0, 3.0),
),
(
SdfPrimitive::BoxFrame {
half_extents: Vec3::new(1.0, 2.0, 3.0),
edge_thickness: 0.2,
},
Vec3::new(1.0, 2.0, 3.0),
),
(
SdfPrimitive::Torus {
major_radius: 2.0,
minor_radius: 0.5,
},
Vec3::new(2.5, 0.0, 0.0),
),
(
SdfPrimitive::Link {
half_length: 1.0,
major_radius: 2.0,
minor_radius: 0.5,
},
Vec3::new(0.0, 3.5, 0.0),
),
(
SdfPrimitive::Capsule {
half_height: 2.0,
radius: 0.5,
},
Vec3::new(0.0, 0.0, 2.5),
),
(
SdfPrimitive::Cylinder {
half_height: 2.0,
radius: 0.5,
},
Vec3::new(0.5, 0.0, 2.0),
),
(
SdfPrimitive::CappedCone {
half_height: 2.0,
bottom_radius: 1.0,
top_radius: 0.5,
},
Vec3::new(1.0, 0.0, -2.0),
),
(
SdfPrimitive::RoundedCone {
half_height: 2.0,
bottom_radius: 1.0,
top_radius: 0.5,
},
Vec3::new(0.0, 0.0, -3.0),
),
(
SdfPrimitive::Ellipsoid {
radii: Vec3::new(1.0, 2.0, 3.0),
},
Vec3::new(0.0, 0.0, 3.0),
),
(
SdfPrimitive::Octahedron { size: 2.0 },
Vec3::new(2.0, 0.0, 0.0),
),
(
SdfPrimitive::TriangularPrism {
triangle_radius: 1.0,
half_height: 2.0,
},
Vec3::new(0.0, 0.0, 2.0),
),
(
SdfPrimitive::HexagonalPrism {
hexagon_radius: 1.0,
half_height: 2.0,
},
Vec3::new(2.0 / 3.0_f32.sqrt(), 0.0, 2.0),
),
(
SdfPrimitive::BlackHole {
horizon_radius: 2.0,
influence_radius: 10.0,
lens_strength: 1.5,
spin: -0.25,
},
Vec3::new(2.0, 0.0, 0.0),
),
];
for (shape, point) in cases {
let bounds = shape.bounding_sphere().unwrap();
assert!(
point.distance(bounds.center) <= bounds.radius + 1.0e-5,
"{} bound does not contain {point:?}",
shape.kind_name()
);
}
assert!(
SdfPrimitive::Plane {
normal: Vec3::Z,
offset: 0.0
}
.bounding_sphere()
.is_none()
);
}
#[test]
fn black_hole_uses_the_horizon_as_its_sdf_and_conservative_bound() {
let black_hole = SdfPrimitive::BlackHole {
horizon_radius: 2.0,
influence_radius: 12.0,
lens_strength: 1.25,
spin: -0.5,
};
approx_eq(black_hole.signed_distance(Vec3::ZERO), -2.0);
approx_eq(black_hole.signed_distance(Vec3::X * 2.0), 0.0);
approx_eq(black_hole.signed_distance(Vec3::Z * 5.0), 3.0);
assert_eq!(black_hole.bounding_sphere().unwrap().radius, 2.0);
}
#[test]
fn black_hole_parameters_are_strictly_validated() {
let black_hole =
|horizon_radius, influence_radius, lens_strength, spin| SdfPrimitive::BlackHole {
horizon_radius,
influence_radius,
lens_strength,
spin,
};
let valid = black_hole(1.0, 8.0, 0.0, -2.5);
assert!(valid.validate_parameters().is_ok());
for invalid in [
black_hole(0.0, 8.0, 0.0, -2.5),
black_hole(f32::NAN, 8.0, 0.0, -2.5),
black_hole(1.0, 1.0, 0.0, -2.5),
black_hole(1.0, f32::INFINITY, 0.0, -2.5),
black_hole(1.0, 8.0, -0.01, -2.5),
black_hole(1.0, 8.0, f32::NAN, -2.5),
black_hole(1.0, 8.0, 0.0, f32::INFINITY),
] {
assert!(invalid.validate_parameters().is_err(), "{invalid:?}");
}
}
#[test]
fn axial_primitives_use_local_z_as_height() {
let capsule = SdfPrimitive::Capsule {
half_height: 2.0,
radius: 0.5,
};
approx_eq(capsule.signed_distance(Vec3::new(0.0, 0.0, 2.5)), 0.0);
approx_eq(capsule.signed_distance(Vec3::new(0.5, 0.0, 0.0)), 0.0);
let cylinder = SdfPrimitive::Cylinder {
half_height: 2.0,
radius: 0.5,
};
approx_eq(cylinder.signed_distance(Vec3::new(0.0, 0.0, 2.0)), 0.0);
approx_eq(cylinder.signed_distance(Vec3::new(0.5, 0.0, 0.0)), 0.0);
let capped_cone = SdfPrimitive::CappedCone {
half_height: 2.0,
bottom_radius: 1.0,
top_radius: 0.5,
};
approx_eq(capped_cone.signed_distance(Vec3::new(1.0, 0.0, -2.0)), 0.0);
approx_eq(capped_cone.signed_distance(Vec3::new(0.5, 0.0, 2.0)), 0.0);
let triangular_prism = SdfPrimitive::TriangularPrism {
triangle_radius: 1.0,
half_height: 2.0,
};
approx_eq(
triangular_prism.signed_distance(Vec3::new(0.0, 0.0, 2.0)),
0.0,
);
let hexagonal_prism = SdfPrimitive::HexagonalPrism {
hexagon_radius: 1.0,
half_height: 2.0,
};
approx_eq(
hexagonal_prism.signed_distance(Vec3::new(0.0, 0.0, 2.0)),
0.0,
);
}
}
+582
View File
@@ -0,0 +1,582 @@
use std::collections::HashSet;
use glam::{Quat, Vec3};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::{
BoundingSphere, CloudSettings, EditorCamera, EntityId, LightId, LightKind, LightingSettings,
Material, PrimitiveId, SceneLight, SdfPrimitive, Transform,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct ScenePrimitive {
pub id: PrimitiveId,
pub name: String,
pub transform: Transform,
pub shape: SdfPrimitive,
pub material: Material,
pub visible: bool,
}
impl Default for ScenePrimitive {
fn default() -> Self {
Self {
id: 0,
name: "Сфера".into(),
transform: Transform::default(),
shape: SdfPrimitive::default(),
material: Material::default(),
visible: true,
}
}
}
impl ScenePrimitive {
/// A conservative world-space distance under arbitrary non-uniform scale.
pub fn signed_distance(&self, world_point: Vec3) -> f32 {
let local_point = self.transform.world_to_local(world_point);
self.shape.signed_distance(local_point) * self.transform.minimum_abs_scale()
}
pub fn bounding_sphere(&self) -> Option<BoundingSphere> {
self.shape.bounding_sphere().map(|local| BoundingSphere {
center: self.transform.local_to_world(local.center),
radius: local.radius * self.transform.maximum_abs_scale(),
})
}
pub fn normal(&self, world_point: Vec3, epsilon: f32) -> Vec3 {
let epsilon = epsilon.abs().max(1.0e-5);
let x = Vec3::X * epsilon;
let y = Vec3::Y * epsilon;
let z = Vec3::Z * epsilon;
Vec3::new(
self.signed_distance(world_point + x) - self.signed_distance(world_point - x),
self.signed_distance(world_point + y) - self.signed_distance(world_point - y),
self.signed_distance(world_point + z) - self.signed_distance(world_point - z),
)
.normalize_or(Vec3::Z)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SceneSample {
pub distance: f32,
pub primitive_id: Option<PrimitiveId>,
}
impl SceneSample {
pub const EMPTY: Self = Self {
distance: f32::INFINITY,
primitive_id: None,
};
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Ray {
pub origin: Vec3,
pub direction: Vec3,
}
impl Ray {
pub fn new(origin: Vec3, direction: Vec3) -> Option<Self> {
if !origin.is_finite() || !direction.is_finite() || direction.length_squared() < 1.0e-12 {
return None;
}
Some(Self {
origin,
direction: direction.normalize(),
})
}
pub fn point_at(self, distance: f32) -> Vec3 {
self.origin + self.direction * distance
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RayMarchSettings {
pub max_steps: u32,
pub max_distance: f32,
pub surface_epsilon: f32,
pub normal_epsilon: f32,
}
impl Default for RayMarchSettings {
fn default() -> Self {
Self {
max_steps: 192,
max_distance: 2_000.0,
surface_epsilon: 1.0e-3,
normal_epsilon: 2.0e-3,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RayHit {
pub primitive_id: PrimitiveId,
pub position: Vec3,
pub normal: Vec3,
pub distance: f32,
pub steps: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Scene {
#[serde(default = "default_scene_name")]
pub name: String,
#[serde(default)]
pub primitives: Vec<ScenePrimitive>,
#[serde(default)]
pub lights: Vec<SceneLight>,
#[serde(default)]
pub lighting: LightingSettings,
#[serde(default)]
pub clouds: CloudSettings,
#[serde(default)]
pub editor_camera: EditorCamera,
#[serde(default = "default_background_color")]
pub background_color: Vec3,
#[serde(default = "first_entity_id")]
next_entity_id: EntityId,
}
impl Default for Scene {
fn default() -> Self {
let mut scene = Self::empty_named(default_scene_name());
let sun_id = scene.add_light(LightKind::DirectionalSun);
if let Some(sun) = scene.light_mut(sun_id) {
sun.name = "Солнце".into();
let ray_direction = Vec3::new(-0.35, -0.25, -1.0).normalize();
sun.transform.rotation = Quat::from_rotation_arc(Vec3::NEG_Z, ray_direction);
sun.intensity = 5.0;
sun.color = Vec3::new(1.0, 0.94, 0.82);
}
let spot_id = scene.add_light(LightKind::spot_default());
if let Some(spot) = scene.light_mut(spot_id) {
spot.name = "Прожектор".into();
spot.transform.translation = Vec3::new(4.0, 4.0, 6.0);
let target = Vec3::new(0.0, 0.0, 0.5);
let ray_direction = (target - spot.transform.translation).normalize();
spot.transform.rotation = Quat::from_rotation_arc(Vec3::NEG_Z, ray_direction);
spot.intensity = 1_500.0;
}
scene
}
}
impl Scene {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
..Self::default()
}
}
/// An empty scene is useful for tests/importers. The regular `Default`
/// includes the editor's default sun and spot light.
pub fn empty() -> Self {
Self::empty_named(default_scene_name())
}
pub fn empty_named(name: impl Into<String>) -> Self {
Self {
name: name.into(),
primitives: Vec::new(),
lights: Vec::new(),
lighting: LightingSettings::default(),
clouds: CloudSettings::default(),
editor_camera: EditorCamera::default(),
background_color: default_background_color(),
next_entity_id: first_entity_id(),
}
}
pub fn primitives(&self) -> &[ScenePrimitive] {
&self.primitives
}
pub fn primitives_mut(&mut self) -> &mut [ScenePrimitive] {
&mut self.primitives
}
pub fn lights(&self) -> &[SceneLight] {
&self.lights
}
pub fn lights_mut(&mut self) -> &mut [SceneLight] {
&mut self.lights
}
pub fn add_primitive(&mut self, shape: SdfPrimitive) -> PrimitiveId {
let id = self.allocate_id();
let name = format!("{} {id}", shape.kind_name());
self.primitives.push(ScenePrimitive {
id,
name,
shape,
..ScenePrimitive::default()
});
id
}
pub fn add_primitive_with(
&mut self,
name: impl Into<String>,
shape: SdfPrimitive,
transform: Transform,
material: Material,
) -> PrimitiveId {
let id = self.allocate_id();
self.primitives.push(ScenePrimitive {
id,
name: name.into(),
transform,
shape,
material,
visible: true,
});
id
}
pub fn primitive(&self, id: PrimitiveId) -> Option<&ScenePrimitive> {
self.primitives.iter().find(|primitive| primitive.id == id)
}
pub fn primitive_mut(&mut self, id: PrimitiveId) -> Option<&mut ScenePrimitive> {
self.primitives
.iter_mut()
.find(|primitive| primitive.id == id)
}
pub fn remove_primitive(&mut self, id: PrimitiveId) -> Option<ScenePrimitive> {
let index = self
.primitives
.iter()
.position(|primitive| primitive.id == id)?;
Some(self.primitives.remove(index))
}
pub fn duplicate_primitive(&mut self, id: PrimitiveId) -> Option<PrimitiveId> {
let mut duplicate = self.primitive(id)?.clone();
duplicate.id = self.allocate_id();
duplicate.name = format!("{} (копия)", duplicate.name);
duplicate.transform.translation += Vec3::splat(0.25);
let new_id = duplicate.id;
self.primitives.push(duplicate);
Some(new_id)
}
pub fn add_light(&mut self, kind: LightKind) -> LightId {
let id = self.allocate_id();
let name = format!("{} {id}", kind.display_name());
let intensity = match kind {
LightKind::DirectionalSun => 5.0,
LightKind::Point { .. } => 750.0,
LightKind::Spot { .. } => 1_500.0,
};
self.lights.push(SceneLight {
id,
name,
kind,
intensity,
..SceneLight::default()
});
id
}
pub fn light(&self, id: LightId) -> Option<&SceneLight> {
self.lights.iter().find(|light| light.id == id)
}
pub fn light_mut(&mut self, id: LightId) -> Option<&mut SceneLight> {
self.lights.iter_mut().find(|light| light.id == id)
}
pub fn remove_light(&mut self, id: LightId) -> Option<SceneLight> {
let index = self.lights.iter().position(|light| light.id == id)?;
Some(self.lights.remove(index))
}
pub fn duplicate_light(&mut self, id: LightId) -> Option<LightId> {
let mut duplicate = self.light(id)?.clone();
duplicate.id = self.allocate_id();
duplicate.name = format!("{} (копия)", duplicate.name);
duplicate.transform.translation += Vec3::splat(0.5);
let new_id = duplicate.id;
self.lights.push(duplicate);
Some(new_id)
}
pub fn sample(&self, world_point: Vec3) -> SceneSample {
self.sample_primitive_ids(
world_point,
self.primitives
.iter()
.filter(|primitive| primitive.visible)
.map(|primitive| primitive.id),
)
}
pub(crate) fn sample_primitive_ids(
&self,
world_point: Vec3,
ids: impl IntoIterator<Item = PrimitiveId>,
) -> SceneSample {
let mut sample = SceneSample::EMPTY;
for id in ids {
let Some(primitive) = self.primitive(id) else {
continue;
};
if !primitive.visible {
continue;
}
// The cheap conservative sphere is evaluated first. Its distance
// is a lower bound for the enclosed shape, so the expensive SDF
// can only be skipped when that lower bound cannot beat the best
// exact distance found so far. Substituting the sphere distance
// itself would create false hits on the bounding sphere.
if let Some(bounds) = primitive.bounding_sphere() {
let bound_distance = world_point.distance(bounds.center) - bounds.radius;
if bound_distance >= sample.distance {
continue;
}
}
let distance = primitive.signed_distance(world_point);
if distance.is_finite() && distance < sample.distance {
sample = SceneSample {
distance,
primitive_id: Some(id),
};
}
}
sample
}
pub fn ray_march(&self, ray: Ray, settings: RayMarchSettings) -> Option<RayHit> {
if !ray.origin.is_finite()
|| !ray.direction.is_finite()
|| ray.direction.length_squared() < 1.0e-12
|| settings.max_steps == 0
|| !settings.max_distance.is_finite()
|| settings.max_distance <= 0.0
{
return None;
}
let direction = ray.direction.normalize();
let surface_epsilon = settings.surface_epsilon.abs().max(1.0e-6);
let mut distance_travelled = 0.0;
for steps in 0..settings.max_steps {
let position = ray.origin + direction * distance_travelled;
let sample = self.sample(position);
let primitive_id = sample.primitive_id?;
if sample.distance <= surface_epsilon {
let primitive = self.primitive(primitive_id)?;
return Some(RayHit {
primitive_id,
position,
normal: primitive.normal(position, settings.normal_epsilon),
distance: distance_travelled,
steps: steps + 1,
});
}
distance_travelled += sample.distance.max(surface_epsilon);
if distance_travelled > settings.max_distance {
break;
}
}
None
}
pub fn validate(&self) -> Result<(), SceneValidationError> {
let mut ids = HashSet::with_capacity(self.primitives.len() + self.lights.len());
for primitive in &self.primitives {
validate_id(&mut ids, primitive.id)?;
if !primitive.transform.is_finite() {
return Err(SceneValidationError::InvalidPrimitive {
id: primitive.id,
reason: "transform contains non-finite or zero-scale values".into(),
});
}
primitive.shape.validate_parameters().map_err(|reason| {
SceneValidationError::InvalidPrimitive {
id: primitive.id,
reason,
}
})?;
primitive.material.validate().map_err(|reason| {
SceneValidationError::InvalidPrimitive {
id: primitive.id,
reason,
}
})?;
}
for light in &self.lights {
validate_id(&mut ids, light.id)?;
light
.validate()
.map_err(|reason| SceneValidationError::InvalidLight {
id: light.id,
reason,
})?;
}
self.lighting
.validate()
.map_err(SceneValidationError::InvalidSettings)?;
self.clouds
.validate()
.map_err(SceneValidationError::InvalidSettings)?;
self.editor_camera
.validate()
.map_err(SceneValidationError::InvalidSettings)?;
if !self.background_color.is_finite() || self.background_color.min_element() < 0.0 {
return Err(SceneValidationError::InvalidSettings(
"background color must be finite and non-negative".into(),
));
}
if self.next_entity_id == 0 || ids.contains(&self.next_entity_id) {
return Err(SceneValidationError::InvalidAllocator);
}
Ok(())
}
/// Repairs only allocator metadata. Object IDs themselves are never
/// rewritten, preserving references from editor state and saved maps.
pub(crate) fn repair_allocator(&mut self) -> Result<(), SceneValidationError> {
let mut ids = HashSet::with_capacity(self.primitives.len() + self.lights.len());
for id in self
.primitives
.iter()
.map(|primitive| primitive.id)
.chain(self.lights.iter().map(|light| light.id))
{
validate_id(&mut ids, id)?;
}
let max_id = ids.iter().copied().max().unwrap_or(0);
if self.next_entity_id == 0
|| self.next_entity_id <= max_id
|| ids.contains(&self.next_entity_id)
{
self.next_entity_id = max_id
.checked_add(1)
.filter(|candidate| *candidate != 0 && !ids.contains(candidate))
.or_else(|| (1..=u32::MAX).find(|candidate| !ids.contains(candidate)))
.ok_or(SceneValidationError::IdSpaceExhausted)?;
}
Ok(())
}
fn allocate_id(&mut self) -> EntityId {
self.repair_allocator()
.expect("scene entity ID space is exhausted or corrupt");
let id = self.next_entity_id;
self.next_entity_id = id.checked_add(1).unwrap_or(1);
id
}
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum SceneValidationError {
#[error("entity ID 0 is reserved")]
ReservedId,
#[error("duplicate entity ID {0}")]
DuplicateId(EntityId),
#[error("primitive {id} is invalid: {reason}")]
InvalidPrimitive { id: PrimitiveId, reason: String },
#[error("light {id} is invalid: {reason}")]
InvalidLight { id: LightId, reason: String },
#[error("scene settings are invalid: {0}")]
InvalidSettings(String),
#[error("scene entity ID allocator metadata is invalid")]
InvalidAllocator,
#[error("scene entity ID space is exhausted")]
IdSpaceExhausted,
}
fn validate_id(ids: &mut HashSet<EntityId>, id: EntityId) -> Result<(), SceneValidationError> {
if id == 0 {
return Err(SceneValidationError::ReservedId);
}
if !ids.insert(id) {
return Err(SceneValidationError::DuplicateId(id));
}
Ok(())
}
fn first_entity_id() -> EntityId {
1
}
fn default_scene_name() -> String {
"Новая карта".into()
}
fn default_background_color() -> Vec3 {
Vec3::new(0.055, 0.085, 0.14)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_scene_has_placeable_sun_and_spot() {
let scene = Scene::default();
assert_eq!(scene.lights.len(), 2);
assert!(matches!(scene.lights[0].kind, LightKind::DirectionalSun));
assert!(matches!(scene.lights[1].kind, LightKind::Spot { .. }));
assert_ne!(scene.lights[0].id, scene.lights[1].id);
assert!(scene.lights[0].direction().z < -0.8);
assert!(scene.lights[1].direction().z < -0.6);
scene.validate().unwrap();
}
#[test]
fn primitive_and_light_ids_share_one_stable_namespace() {
let mut scene = Scene::default();
let primitive = scene.add_primitive(SdfPrimitive::Sphere { radius: 1.0 });
let light = scene.add_light(LightKind::spot_default());
let duplicate = scene.duplicate_primitive(primitive).unwrap();
assert_ne!(primitive, light);
assert_ne!(light, duplicate);
assert_eq!(scene.remove_primitive(primitive).unwrap().id, primitive);
assert!(scene.primitive(duplicate).is_some());
scene.validate().unwrap();
}
#[test]
fn transformed_sdf_and_cpu_ray_march_hit_expected_primitive() {
let mut scene = Scene::empty();
let id = scene.add_primitive(SdfPrimitive::Sphere { radius: 1.0 });
scene.primitive_mut(id).unwrap().transform.translation = Vec3::new(0.0, 0.0, -5.0);
let ray = Ray::new(Vec3::ZERO, -Vec3::Z).unwrap();
let hit = scene.ray_march(ray, RayMarchSettings::default()).unwrap();
assert_eq!(hit.primitive_id, id);
assert!((hit.distance - 4.0).abs() < 0.01);
assert!(hit.normal.z > 0.99);
}
#[test]
fn bounding_sphere_is_not_reported_as_a_surface() {
let mut scene = Scene::empty();
let id = scene.add_primitive(SdfPrimitive::Torus {
major_radius: 1.0,
minor_radius: 0.2,
});
scene.primitive_mut(id).unwrap().transform.translation = Vec3::new(0.0, 0.0, -5.0);
// This ray crosses the torus bounding sphere but travels through the
// hole along its local Z axis, so it must not produce a primitive hit.
let ray = Ray::new(Vec3::new(0.0, 0.0, -2.0), -Vec3::Z).unwrap();
assert!(scene.ray_march(ray, RayMarchSettings::default()).is_none());
}
}
+237
View File
@@ -0,0 +1,237 @@
use std::collections::HashMap;
use glam::Vec3;
use super::{BoundingSphere, PrimitiveId, Scene, SceneSample};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct GridCell {
pub x: i32,
pub y: i32,
pub z: i32,
}
impl GridCell {
pub const fn new(x: i32, y: i32, z: i32) -> Self {
Self { x, y, z }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpatialGridBuildOptions {
pub cell_size: f32,
/// Very large primitives are cheaper and safer in the global list than in
/// millions of individual hash-map entries.
pub max_cells_per_primitive: usize,
}
impl Default for SpatialGridBuildOptions {
fn default() -> Self {
Self {
cell_size: 8.0,
max_cells_per_primitive: 32_768,
}
}
}
/// Runtime-only uniform-grid index. Rebuild it after primitive transforms or
/// visibility change; it is deliberately not serialized with the map.
#[derive(Debug, Clone)]
pub struct SpatialGrid {
cell_size: f32,
cells: HashMap<GridCell, Vec<PrimitiveId>>,
global: Vec<PrimitiveId>,
}
impl SpatialGrid {
pub fn build(scene: &Scene, cell_size: f32) -> Self {
Self::with_options(
scene,
SpatialGridBuildOptions {
cell_size,
..SpatialGridBuildOptions::default()
},
)
}
pub fn with_options(scene: &Scene, options: SpatialGridBuildOptions) -> Self {
let cell_size = if options.cell_size.is_finite() && options.cell_size > 1.0e-4 {
options.cell_size
} else {
SpatialGridBuildOptions::default().cell_size
};
let max_cells = options.max_cells_per_primitive.max(1);
let mut grid = Self {
cell_size,
cells: HashMap::new(),
global: Vec::new(),
};
for primitive in scene.primitives().iter().filter(|item| item.visible) {
let Some(bounds) = primitive.bounding_sphere() else {
// Plane and any future unbounded SDF must be evaluated in every
// cell. In particular, a plane must never disappear merely
// because its transform is far from the grid origin.
grid.global.push(primitive.id);
continue;
};
if !bounds.center.is_finite() || !bounds.radius.is_finite() {
grid.global.push(primitive.id);
continue;
}
let min = grid.cell_for_point(bounds.center - Vec3::splat(bounds.radius));
let max = grid.cell_for_point(bounds.center + Vec3::splat(bounds.radius));
if cell_volume(min, max) > max_cells as u64 {
grid.global.push(primitive.id);
continue;
}
for x in min.x..=max.x {
for y in min.y..=max.y {
for z in min.z..=max.z {
let cell = GridCell::new(x, y, z);
if sphere_intersects_cell(bounds, cell, cell_size) {
grid.cells.entry(cell).or_default().push(primitive.id);
}
}
}
}
}
for ids in grid.cells.values_mut() {
ids.sort_unstable();
ids.dedup();
}
grid.global.sort_unstable();
grid.global.dedup();
grid
}
pub fn cell_size(&self) -> f32 {
self.cell_size
}
pub fn cell_count(&self) -> usize {
self.cells.len()
}
pub fn global_ids(&self) -> &[PrimitiveId] {
&self.global
}
pub fn occupied_cells(&self) -> impl Iterator<Item = (GridCell, &[PrimitiveId])> + '_ {
self.cells.iter().map(|(cell, ids)| (*cell, ids.as_slice()))
}
pub fn cell_for_point(&self, point: Vec3) -> GridCell {
GridCell::new(
floor_to_i32(point.x / self.cell_size),
floor_to_i32(point.y / self.cell_size),
floor_to_i32(point.z / self.cell_size),
)
}
pub fn ids_in_cell(&self, cell: GridCell) -> &[PrimitiveId] {
self.cells.get(&cell).map(Vec::as_slice).unwrap_or(&[])
}
pub fn candidates_at(&self, point: Vec3) -> impl Iterator<Item = PrimitiveId> + Clone + '_ {
self.ids_in_cell(self.cell_for_point(point))
.iter()
.chain(self.global.iter())
.copied()
}
pub fn candidate_ids(&self, point: Vec3) -> Vec<PrimitiveId> {
self.candidates_at(point).collect()
}
/// Samples only primitives assigned to the point's cell plus all global
/// primitives. This is intended for a grid traversal, not as a replacement
/// for `Scene::sample` at arbitrary points outside the indexed bounds.
pub fn sample_cell(&self, scene: &Scene, point: Vec3) -> SceneSample {
scene.sample_primitive_ids(point, self.candidates_at(point))
}
}
fn sphere_intersects_cell(sphere: BoundingSphere, cell: GridCell, cell_size: f32) -> bool {
let min = Vec3::new(cell.x as f32, cell.y as f32, cell.z as f32) * cell_size;
let max = min + Vec3::splat(cell_size);
let closest = sphere.center.clamp(min, max);
closest.distance_squared(sphere.center) <= sphere.radius * sphere.radius
}
fn cell_volume(min: GridCell, max: GridCell) -> u64 {
let x = (i64::from(max.x) - i64::from(min.x) + 1).max(0) as u64;
let y = (i64::from(max.y) - i64::from(min.y) + 1).max(0) as u64;
let z = (i64::from(max.z) - i64::from(min.z) + 1).max(0) as u64;
x.saturating_mul(y).saturating_mul(z)
}
fn floor_to_i32(value: f32) -> i32 {
if value.is_nan() {
0
} else if value <= i32::MIN as f32 {
i32::MIN
} else if value >= i32::MAX as f32 {
i32::MAX
} else {
value.floor() as i32
}
}
#[cfg(test)]
mod tests {
use glam::Vec3;
use super::*;
use crate::scene::{SdfPrimitive, Transform};
#[test]
fn finite_objects_fill_cells_but_plane_is_always_global() {
let mut scene = Scene::empty();
let sphere = scene.add_primitive(SdfPrimitive::Sphere { radius: 1.0 });
let plane = scene.add_primitive(SdfPrimitive::Plane {
normal: Vec3::Z,
offset: 0.0,
});
let grid = SpatialGrid::build(&scene, 1.0);
assert_eq!(grid.global_ids(), &[plane]);
let near = grid.candidate_ids(Vec3::ZERO);
assert!(near.contains(&sphere));
assert!(near.contains(&plane));
let far = grid.candidate_ids(Vec3::splat(100.0));
assert_eq!(far, vec![plane]);
}
#[test]
fn negative_world_coordinates_use_floor_not_truncation() {
let mut scene = Scene::empty();
let id = scene.add_primitive(SdfPrimitive::Sphere { radius: 0.2 });
scene.primitive_mut(id).unwrap().transform = Transform::from_translation(Vec3::splat(-1.2));
let grid = SpatialGrid::build(&scene, 1.0);
assert_eq!(
grid.cell_for_point(Vec3::splat(-1.2)),
GridCell::new(-2, -2, -2)
);
assert!(grid.candidate_ids(Vec3::splat(-1.2)).contains(&id));
}
#[test]
fn oversized_finite_shape_uses_global_fallback() {
let mut scene = Scene::empty();
let id = scene.add_primitive(SdfPrimitive::Sphere { radius: 10.0 });
let grid = SpatialGrid::with_options(
&scene,
SpatialGridBuildOptions {
cell_size: 1.0,
max_cells_per_primitive: 8,
},
);
assert_eq!(grid.global_ids(), &[id]);
assert_eq!(grid.cell_count(), 0);
}
}
+108
View File
@@ -0,0 +1,108 @@
use glam::{Mat4, Quat, Vec3};
use serde::{Deserialize, Serialize};
/// Position, orientation and scale of an SDF primitive in world space.
///
/// Non-uniform scales are supported conservatively: distances are multiplied
/// by the smallest absolute scale component, so CPU sphere tracing never
/// oversteps the transformed surface.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Transform {
pub translation: Vec3,
pub rotation: Quat,
pub scale: Vec3,
}
impl Default for Transform {
fn default() -> Self {
Self {
translation: Vec3::ZERO,
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
}
}
}
impl Transform {
pub const MIN_SCALE: f32 = 1.0e-6;
pub fn from_translation(translation: Vec3) -> Self {
Self {
translation,
..Self::default()
}
}
pub fn matrix(self) -> Mat4 {
Mat4::from_scale_rotation_translation(
self.safe_scale(),
self.safe_rotation(),
self.translation,
)
}
pub fn local_to_world(self, point: Vec3) -> Vec3 {
self.translation + self.safe_rotation() * (point * self.safe_scale())
}
pub fn world_to_local(self, point: Vec3) -> Vec3 {
let unrotated = self.safe_rotation().conjugate() * (point - self.translation);
unrotated / self.safe_scale()
}
pub fn minimum_abs_scale(self) -> f32 {
let scale = self.safe_scale().abs();
scale.x.min(scale.y).min(scale.z)
}
pub fn maximum_abs_scale(self) -> f32 {
let scale = self.safe_scale().abs();
scale.x.max(scale.y).max(scale.z)
}
pub fn is_finite(self) -> bool {
self.translation.is_finite()
&& self.rotation.is_finite()
&& self.scale.is_finite()
&& self.scale.abs().min_element() >= Self::MIN_SCALE
&& self.rotation.length_squared() >= Self::MIN_SCALE
}
fn safe_rotation(self) -> Quat {
if self.rotation.is_finite() && self.rotation.length_squared() >= Self::MIN_SCALE {
self.rotation.normalize()
} else {
Quat::IDENTITY
}
}
fn safe_scale(self) -> Vec3 {
self.scale.map(|component| {
if component.abs() >= Self::MIN_SCALE {
component
} else if component.is_sign_negative() {
-Self::MIN_SCALE
} else {
Self::MIN_SCALE
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transform_round_trip() {
let transform = Transform {
translation: Vec3::new(3.0, -2.0, 1.0),
rotation: Quat::from_rotation_y(0.7),
scale: Vec3::new(2.0, 3.0, 0.5),
};
let local = Vec3::new(0.5, -1.0, 4.0);
let restored = transform.world_to_local(transform.local_to_world(local));
assert!((restored - local).length() < 1.0e-5);
}
}
+76
View File
@@ -0,0 +1,76 @@
/// Per-frame timings shown by the editor's `stat fps` / `stat unit` overlay.
///
/// The values intentionally update every frame. GPU time is optional because
/// Vulkan adapters are allowed to omit timestamp-query support.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FrameStats {
pub fps: f32,
pub frame_ms: f32,
pub game_ms: f32,
pub draw_ms: f32,
pub gpu_ms: Option<f32>,
}
impl Default for FrameStats {
fn default() -> Self {
Self {
fps: 0.0,
frame_ms: 0.0,
game_ms: 0.0,
draw_ms: 0.0,
gpu_ms: None,
}
}
}
impl FrameStats {
pub fn record_frame(
&mut self,
frame_seconds: f32,
game_seconds: f32,
draw_seconds: f32,
gpu_ms: Option<f32>,
) {
let frame_seconds = finite_non_negative(frame_seconds);
self.frame_ms = frame_seconds * 1_000.0;
self.fps = if frame_seconds > 0.0 {
1.0 / frame_seconds
} else {
0.0
};
self.game_ms = finite_non_negative(game_seconds) * 1_000.0;
self.draw_ms = finite_non_negative(draw_seconds) * 1_000.0;
self.gpu_ms = gpu_ms.filter(|value| value.is_finite() && *value >= 0.0);
}
}
fn finite_non_negative(value: f32) -> f32 {
if value.is_finite() {
value.max(0.0)
} else {
0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_unsmoothed_per_frame_values() {
let mut stats = FrameStats::default();
stats.record_frame(0.01, 0.002, 0.003, Some(4.0));
assert!((stats.fps - 100.0).abs() < 1.0e-4);
assert!((stats.frame_ms - 10.0).abs() < 1.0e-4);
assert!((stats.game_ms - 2.0).abs() < 1.0e-4);
assert!((stats.draw_ms - 3.0).abs() < 1.0e-4);
assert_eq!(stats.gpu_ms, Some(4.0));
}
#[test]
fn rejects_invalid_timings() {
let mut stats = FrameStats::default();
stats.record_frame(f32::NAN, -1.0, f32::INFINITY, Some(f32::NAN));
assert_eq!(stats, FrameStats::default());
}
}
+1484
View File
File diff suppressed because it is too large Load Diff