Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
[target.x86_64-pc-windows-msvc]
|
||||||
|
rustflags = ["-C", "target-feature=+avx2,+crt-static"]
|
||||||
+29
@@ -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
File diff suppressed because it is too large
Load Diff
+38
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user