feat: implement Phase 1 foundation - triangle rendering

- voltex_math: Vec3 with arithmetic ops, dot, cross, length, normalize
- voltex_platform: VoltexWindow (winit wrapper), InputState (keyboard/mouse),
  GameTimer (fixed timestep + variable render loop)
- voltex_renderer: GpuContext (wgpu init), Vertex + buffer layout,
  WGSL shader, render pipeline
- triangle example: colored triangle with ESC to exit

All 13 tests passing. Window renders RGB triangle on dark background.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 19:34:39 +09:00
parent 56abc42cf8
commit 81ba6f7e5d
12 changed files with 788 additions and 8 deletions

View File

@@ -0,0 +1,85 @@
use std::time::{Duration, Instant};
pub struct GameTimer {
last_frame: Instant,
accumulator: Duration,
fixed_dt: Duration,
frame_time: Duration,
}
impl GameTimer {
pub fn new(fixed_hz: u32) -> Self {
Self {
last_frame: Instant::now(),
accumulator: Duration::ZERO,
fixed_dt: Duration::from_secs_f64(1.0 / fixed_hz as f64),
frame_time: Duration::ZERO,
}
}
pub fn tick(&mut self) {
let now = Instant::now();
self.frame_time = now - self.last_frame;
if self.frame_time > Duration::from_millis(250) {
self.frame_time = Duration::from_millis(250);
}
self.accumulator += self.frame_time;
self.last_frame = now;
}
pub fn should_fixed_update(&mut self) -> bool {
if self.accumulator >= self.fixed_dt {
self.accumulator -= self.fixed_dt;
true
} else {
false
}
}
pub fn fixed_dt(&self) -> f32 {
self.fixed_dt.as_secs_f32()
}
pub fn frame_dt(&self) -> f32 {
self.frame_time.as_secs_f32()
}
pub fn alpha(&self) -> f32 {
self.accumulator.as_secs_f32() / self.fixed_dt.as_secs_f32()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn test_fixed_dt() {
let timer = GameTimer::new(60);
let expected = 1.0 / 60.0;
assert!((timer.fixed_dt() - expected).abs() < 1e-6);
}
#[test]
fn test_should_fixed_update_accumulates() {
let mut timer = GameTimer::new(60);
thread::sleep(Duration::from_millis(100));
timer.tick();
let mut count = 0;
while timer.should_fixed_update() {
count += 1;
}
assert!(count >= 5 && count <= 7, "Expected ~6 fixed updates, got {count}");
}
#[test]
fn test_alpha_range() {
let mut timer = GameTimer::new(60);
thread::sleep(Duration::from_millis(10));
timer.tick();
while timer.should_fixed_update() {}
let alpha = timer.alpha();
assert!(alpha >= 0.0 && alpha <= 1.0, "Alpha should be 0..1, got {alpha}");
}
}

View File

@@ -0,0 +1,106 @@
use winit::keyboard::KeyCode;
use std::collections::HashSet;
use winit::event::MouseButton;
pub struct InputState {
pressed: HashSet<KeyCode>,
just_pressed: HashSet<KeyCode>,
just_released: HashSet<KeyCode>,
mouse_position: (f64, f64),
mouse_delta: (f64, f64),
mouse_buttons: HashSet<MouseButton>,
mouse_buttons_just_pressed: HashSet<MouseButton>,
mouse_buttons_just_released: HashSet<MouseButton>,
mouse_scroll_delta: f32,
}
impl InputState {
pub fn new() -> Self {
Self {
pressed: HashSet::new(),
just_pressed: HashSet::new(),
just_released: HashSet::new(),
mouse_position: (0.0, 0.0),
mouse_delta: (0.0, 0.0),
mouse_buttons: HashSet::new(),
mouse_buttons_just_pressed: HashSet::new(),
mouse_buttons_just_released: HashSet::new(),
mouse_scroll_delta: 0.0,
}
}
pub fn is_key_pressed(&self, key: KeyCode) -> bool {
self.pressed.contains(&key)
}
pub fn is_key_just_pressed(&self, key: KeyCode) -> bool {
self.just_pressed.contains(&key)
}
pub fn is_key_just_released(&self, key: KeyCode) -> bool {
self.just_released.contains(&key)
}
pub fn mouse_position(&self) -> (f64, f64) {
self.mouse_position
}
pub fn mouse_delta(&self) -> (f64, f64) {
self.mouse_delta
}
pub fn is_mouse_button_pressed(&self, button: MouseButton) -> bool {
self.mouse_buttons.contains(&button)
}
pub fn is_mouse_button_just_pressed(&self, button: MouseButton) -> bool {
self.mouse_buttons_just_pressed.contains(&button)
}
pub fn mouse_scroll(&self) -> f32 {
self.mouse_scroll_delta
}
pub fn begin_frame(&mut self) {
self.just_pressed.clear();
self.just_released.clear();
self.mouse_buttons_just_pressed.clear();
self.mouse_buttons_just_released.clear();
self.mouse_delta = (0.0, 0.0);
self.mouse_scroll_delta = 0.0;
}
pub fn process_key(&mut self, key: KeyCode, pressed: bool) {
if pressed {
if self.pressed.insert(key) {
self.just_pressed.insert(key);
}
} else {
if self.pressed.remove(&key) {
self.just_released.insert(key);
}
}
}
pub fn process_mouse_move(&mut self, x: f64, y: f64) {
self.mouse_delta.0 += x - self.mouse_position.0;
self.mouse_delta.1 += y - self.mouse_position.1;
self.mouse_position = (x, y);
}
pub fn process_mouse_button(&mut self, button: MouseButton, pressed: bool) {
if pressed {
if self.mouse_buttons.insert(button) {
self.mouse_buttons_just_pressed.insert(button);
}
} else {
if self.mouse_buttons.remove(&button) {
self.mouse_buttons_just_released.insert(button);
}
}
}
pub fn process_scroll(&mut self, delta: f32) {
self.mouse_scroll_delta += delta;
}
}

View File

@@ -1,2 +1,7 @@
// Voltex Platform - Phase 1
// Modules will be added in Task 3
pub mod window;
pub mod input;
pub mod game_loop;
pub use window::{VoltexWindow, WindowConfig};
pub use input::InputState;
pub use game_loop::GameTimer;

View File

@@ -0,0 +1,55 @@
use std::sync::Arc;
use winit::event_loop::ActiveEventLoop;
use winit::window::{Window as WinitWindow, WindowAttributes};
pub struct WindowConfig {
pub title: String,
pub width: u32,
pub height: u32,
pub fullscreen: bool,
pub vsync: bool,
}
impl Default for WindowConfig {
fn default() -> Self {
Self {
title: "Voltex Engine".to_string(),
width: 1280,
height: 720,
fullscreen: false,
vsync: true,
}
}
}
pub struct VoltexWindow {
pub handle: Arc<WinitWindow>,
pub vsync: bool,
}
impl VoltexWindow {
pub fn new(event_loop: &ActiveEventLoop, config: &WindowConfig) -> Self {
let mut attrs = WindowAttributes::default()
.with_title(&config.title)
.with_inner_size(winit::dpi::LogicalSize::new(config.width, config.height));
if config.fullscreen {
attrs = attrs.with_fullscreen(Some(winit::window::Fullscreen::Borderless(None)));
}
let window = event_loop.create_window(attrs).expect("Failed to create window");
Self {
handle: Arc::new(window),
vsync: config.vsync,
}
}
pub fn inner_size(&self) -> (u32, u32) {
let size = self.handle.inner_size();
(size.width, size.height)
}
pub fn request_redraw(&self) {
self.handle.request_redraw();
}
}