Something went wrong. Try again.
Homebrew 4D Game Engine In Rust, inspired by Engine4D
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496//! Headless visual verification of the slice-plane invariant.//!//! Runs the full app stack (scene, physics, camera, simulation) without a//! window, renders frames through the REAL slice + render pipelines into an//! offscreen texture, and saves them as PPM images.//!//! Protocol (mirrors scratchpad/plans/2026-02-06-camera-debug.md)://! 1. settle, capture `00-baseline`//! 2. strafe right 1s (no rotation), capture every 20 frames//! 3. rotate 45° in the QE/ana plane, capture `10-rotated`//! 4. strafe left 1s, capture every 20 frames <- morphs when buggy//! 5. walk forward 1s, capture every 20 frames <- morphs when buggy//!//! If the slice invariant holds, the tesseract cross-section may translate//! and parallax across frames within a phase, but its shape/extent in the//! W direction must not change. Triangle counts are printed as a coarse//! numeric morph indicator.//!//! Usage: cargo run --example headless_protocol [output_dir]
use rust4d::systems::{build_geometry, SimulationSystem};use rust4d_core::SceneManager;use rust4d_game::{scene_helpers, CharacterConfig, CharacterController4D};use rust4d_input::CameraController;use rust4d_math::Vec4;use rust4d_physics::PhysicsConfig;use rust4d_render::camera4d::Camera4D;use rust4d_render::pipeline::{ perspective_matrix, RenderPipeline, RenderUniforms, SliceParams, SlicePipeline,};use winit::event::ElementState;use winit::keyboard::KeyCode;
use std::f32::consts::FRAC_PI_4;use std::path::{Path, PathBuf};
const WIDTH: u32 = 800;const HEIGHT: u32 = 600;const DT: f32 = 1.0 / 60.0;const MOVE_SPEED: f32 = 3.0;const W_MOVE_SPEED: f32 = 2.0;const MAX_TRIANGLES: usize = 900_000;
// ============================================================================// Headless GPU context// ============================================================================
struct HeadlessGpu { device: wgpu::Device, queue: wgpu::Queue, slice_pipeline: SlicePipeline, render_pipeline: RenderPipeline, color_texture: wgpu::Texture, readback_buffer: wgpu::Buffer, padded_bytes_per_row: u32,}
impl HeadlessGpu { fn new() -> Self { let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { backends: wgpu::Backends::all(), ..Default::default() });
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::default(), compatible_surface: None, force_fallback_adapter: false, })) .expect("no GPU adapter available");
let info = adapter.get_info(); println!( "[GPU] adapter: {} ({:?}, {:?})", info.name, info.device_type, info.backend );
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("Headless Device"), required_features: wgpu::Features::empty(), required_limits: wgpu::Limits::default(), memory_hints: wgpu::MemoryHints::default(), trace: wgpu::Trace::Off, })) .expect("failed to create device");
let slice_pipeline = SlicePipeline::new(&device, MAX_TRIANGLES); let format = wgpu::TextureFormat::Rgba8UnormSrgb; let mut render_pipeline = RenderPipeline::new(&device, format); render_pipeline.ensure_depth_texture(&device, WIDTH, HEIGHT);
let color_texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("Offscreen Color"), size: wgpu::Extent3d { width: WIDTH, height: HEIGHT, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], });
// wgpu requires bytes_per_row aligned to 256 let unpadded = WIDTH * 4; let padded_bytes_per_row = unpadded.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Readback"), size: (padded_bytes_per_row * HEIGHT) as u64, usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false, });
Self { device, queue, slice_pipeline, render_pipeline, color_texture, readback_buffer, padded_bytes_per_row, } }
fn upload_geometry(&mut self, geometry: &rust4d_render::RenderableGeometry) { self.slice_pipeline.upload_tetrahedra( &self.device, &geometry.vertices, &geometry.tetrahedra, ); println!( "[GPU] uploaded {} vertices, {} tetrahedra", geometry.vertex_count(), geometry.tetrahedron_count() ); }
/// Render one frame exactly like `RenderSystem::render_frame` and save it. fn capture(&mut self, camera: &Camera4D, tetrahedron_count: u32, path: &Path) -> u32 { let pos = camera.position; let camera_matrix = camera.rotation_matrix(); let slice_params = SliceParams { slice_w: camera.get_slice_w(), tetrahedron_count, _padding: [0.0; 2], camera_matrix, camera_position: [pos.x, pos.y, pos.z, pos.w], }; self.slice_pipeline .update_params(&self.queue, &slice_params);
let proj = perspective_matrix( 45.0_f32.to_radians(), WIDTH as f32 / HEIGHT as f32, 0.1, 100.0, ); let identity = [ [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], ]; self.render_pipeline.update_uniforms( &self.queue, &RenderUniforms { view_matrix: identity, projection_matrix: proj, light_dir: [0.5, 1.0, 0.3], _padding: 0.0, ambient_strength: 0.3, diffuse_strength: 0.7, w_color_strength: 0.3, w_range: 2.0, ..RenderUniforms::default() }, );
let view = self .color_texture .create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Headless Encoder"), });
self.slice_pipeline.reset_counter(&self.queue); self.slice_pipeline.run_slice_pass(&mut encoder); self.render_pipeline .prepare_indirect_draw(&mut encoder, self.slice_pipeline.counter_buffer()); self.render_pipeline.render( &mut encoder, &view, self.slice_pipeline.output_buffer(), wgpu::Color { r: 0.05, g: 0.05, b: 0.08, a: 1.0, }, );
encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: &self.color_texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &self.readback_buffer, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(self.padded_bytes_per_row), rows_per_image: Some(HEIGHT), }, }, wgpu::Extent3d { width: WIDTH, height: HEIGHT, depth_or_array_layers: 1, }, );
self.queue.submit(std::iter::once(encoder.finish()));
// Map and save let slice = self.readback_buffer.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap()); let _ = self.device.poll(wgpu::PollType::Wait); rx.recv().unwrap().expect("readback map failed");
{ let data = slice.get_mapped_range(); write_ppm(path, &data, WIDTH, HEIGHT, self.padded_bytes_per_row); } self.readback_buffer.unmap();
// Read the vertex counter for a coarse numeric morph indicator let vertex_count = self.read_counter(); println!( "[CAPTURE] {} cam=({:.3},{:.3},{:.3},{:.3}) triangles={}", path.file_name().unwrap().to_string_lossy(), pos.x, pos.y, pos.z, pos.w, vertex_count / 3 ); vertex_count / 3 }
fn read_counter(&self) -> u32 { let staging = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Counter Staging"), size: 4, usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false, }); let mut encoder = self .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); encoder.copy_buffer_to_buffer(self.slice_pipeline.counter_buffer(), 0, &staging, 0, 4); self.queue.submit(std::iter::once(encoder.finish()));
let slice = staging.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap()); let _ = self.device.poll(wgpu::PollType::Wait); rx.recv().unwrap().expect("counter map failed"); let count = { let data = slice.get_mapped_range(); u32::from_le_bytes(data[..4].try_into().unwrap()) }; staging.unmap(); count }}
fn write_ppm(path: &Path, data: &[u8], width: u32, height: u32, padded_bytes_per_row: u32) { let mut out = Vec::with_capacity((width * height * 3) as usize + 32); out.extend_from_slice(format!("P6\n{} {}\n255\n", width, height).as_bytes()); for y in 0..height { let row_start = (y * padded_bytes_per_row) as usize; for x in 0..width { let px = row_start + (x * 4) as usize; out.extend_from_slice(&data[px..px + 3]); // RGB, drop A } } std::fs::write(path, out).expect("failed to write ppm");}
// ============================================================================// Simulation rig (mirrors App::new in src/main.rs)// ============================================================================
struct Rig { scene_manager: SceneManager, camera: Camera4D, controller: CameraController, character: Option<CharacterController4D>, simulation: SimulationSystem,}
impl Rig { fn new() -> Self { let mut scene_manager = SceneManager::new().with_physics(PhysicsConfig::new(-20.0)); let scene_name = scene_manager .load_scene("scenes/default.ron") .expect("load default scene"); scene_manager.instantiate(&scene_name).expect("instantiate"); scene_manager.push_scene(&scene_name).expect("push");
let mut player_start = Vec4::new(0.0, 0.0, 5.0, 0.0); if let Some(scene) = scene_manager.active_scene_mut() { if let Some(spawn) = scene.player_spawn { let spawn_pos = Vec4::new(spawn[0], spawn[1], spawn[2], spawn[3]); player_start = spawn_pos; if let Some(physics) = scene.world.physics_mut() { let key = scene_helpers::create_player_body(physics, spawn_pos, 0.5); scene.player_body_key = Some(key); } } }
let mut camera = Camera4D::new(); camera.position = player_start;
let controller = CameraController::new() .with_move_speed(MOVE_SPEED) .with_w_move_speed(W_MOVE_SPEED);
let character = scene_manager .active_scene() .and_then(|s| s.player_body_key) .map(|key| { CharacterController4D::new( key, CharacterConfig { move_speed: MOVE_SPEED, w_move_speed: W_MOVE_SPEED, jump_velocity: 8.0, }, ) });
Self { scene_manager, camera, controller, character, simulation: SimulationSystem::new(), } }
fn step(&mut self) { self.simulation.update_with_dt( DT, &mut self.scene_manager, &mut self.camera, &mut self.controller, self.character.as_ref(), false, ); }
fn run(&mut self, frames: usize) { for _ in 0..frames { self.step(); } }
fn slice_w_of(&self, p: Vec4) -> f32 { self.camera.ana().dot(p - self.camera.position) }}
// ============================================================================// Protocol// ============================================================================
fn main() { env_logger::init();
let out_dir = std::env::args() .nth(1) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".scratchpad/captures")); std::fs::create_dir_all(&out_dir).expect("create output dir");
let mut rig = Rig::new(); let geometry = build_geometry(rig.scene_manager.active_world().unwrap()); let tetra_count = geometry.tetrahedron_count() as u32;
let mut gpu = HeadlessGpu::new(); gpu.upload_geometry(&geometry);
let tesseract = Vec4::new(0.0, 0.0, 0.0, 0.0); let cap = |rig: &Rig, gpu: &mut HeadlessGpu, name: &str| { let path = out_dir.join(format!("{name}.ppm")); let tris = gpu.capture(&rig.camera, tetra_count, &path); println!( "[STATE] {name}: slice_w(tesseract)={:.6} tris={tris}", rig.slice_w_of(tesseract) ); };
// Phase 0: settle rig.run(120); cap(&rig, &mut gpu, "00-baseline");
// Phase 1: strafe right then back, no rotation (control: shape must only // translate/parallax, never morph) rig.controller .process_keyboard(KeyCode::KeyD, ElementState::Pressed); for i in 1..=2 { rig.run(30); cap(&rig, &mut gpu, &format!("0{i}-strafe-no-rotation")); } rig.controller .process_keyboard(KeyCode::KeyD, ElementState::Released); rig.controller .process_keyboard(KeyCode::KeyA, ElementState::Pressed); rig.run(60); // return to x ≈ 0 so the rotated slice passes through the tesseract rig.controller .process_keyboard(KeyCode::KeyA, ElementState::Released); cap(&rig, &mut gpu, "03-returned-to-start");
// Phase 2: rotate 45° into the 4th dimension (XW plane: right tilts into W, // keeps the slice plane through the tesseract at x=0) rig.camera.rotate_xw(FRAC_PI_4); cap(&rig, &mut gpu, "10-rotated-45");
// Phase 3: strafe left after rotation (bug trigger: with the anisotropic // scaling bug, the slice plane drifts 0.5 units/s and the cross-section // visibly morphs/shrinks; when fixed it only translates) rig.controller .process_keyboard(KeyCode::KeyA, ElementState::Pressed); for i in 1..=4 { rig.run(30); cap(&rig, &mut gpu, &format!("1{i}-strafe-after-rotation")); } rig.controller .process_keyboard(KeyCode::KeyA, ElementState::Released);
// Phase 4: walk forward after rotation. At this particular orientation // forward stays -Z, so even the buggy build must not morph here — // an in-protocol control. rig.controller .process_keyboard(KeyCode::KeyW, ElementState::Pressed); for i in 1..=2 { rig.run(30); cap(&rig, &mut gpu, &format!("2{i}-forward-after-rotation")); } rig.controller .process_keyboard(KeyCode::KeyW, ElementState::Released);
// Phase 5: rotate back to 3D and walk up close to the tesseract. // Verifies the projection depth range: with the OpenGL-convention // perspective matrix, geometry closer than √(near·far) ≈ 3.16 units // was clipped to nothing. rig.camera.rotate_xw(-FRAC_PI_4); // Teleport the player body back to spawn for a clean approach if let (Some(character), Some(physics)) = ( rig.character.as_ref().map(|c| c.body_key()), rig.scene_manager .active_world_mut() .and_then(|w| w.physics_mut()), ) { if let Some(body) = physics.get_body_mut(character) { body.position = Vec4::new(0.0, -1.5, 5.0, 0.0); body.velocity = Vec4::ZERO; } } rig.run(2); cap(&rig, &mut gpu, "30-back-to-3d"); rig.controller .process_keyboard(KeyCode::KeyW, ElementState::Pressed); for i in 1..=3 { rig.run(20); cap(&rig, &mut gpu, &format!("3{i}-approach-near")); } rig.controller .process_keyboard(KeyCode::KeyW, ElementState::Released);
println!("\nCaptures written to {}", out_dir.display());}