diff options
Diffstat (limited to 'apps/fparkan-game/src/main.rs')
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 675 |
1 files changed, 534 insertions, 141 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 49e40c0..12630b1 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -153,6 +153,7 @@ fn run(args: &[String]) -> Result<String, String> { environment, audio, args.frames, + args.control_clock_ms, &args.mission, loaded.object_count, args.readback_out.as_deref(), @@ -3395,6 +3396,14 @@ fn lightning_quad_vertices( /// per-frame light/fog block. The sky mesh and weather primitives stay on the /// same update clock, so camera movement cannot leave environment audio or /// lighting one frame behind. +#[derive(Clone, Debug, PartialEq)] +struct DynamicEnvironmentFrame { + atmosphere: fparkan_fx::atmosphere::AtmosphereFrame, + sky_mesh: SkyMesh, + environment_frame: EnvironmentFrame, + absolute_time_seconds: f32, +} + struct DynamicEnvironment { schedule: TypedAtmosphere, sky: SkySystem, @@ -3416,7 +3425,7 @@ impl DynamicEnvironment { viewport: [f32; 2], renderer: &mut VulkanSmokeRenderer, mut audio: Option<&mut audio::GameAudio>, - ) -> Result<(SkyFrame<'_>, SkyMesh, EnvironmentFrame), String> { + ) -> Result<DynamicEnvironmentFrame, String> { let dt_seconds = if dt_seconds.is_finite() { dt_seconds.max(0.0) } else { @@ -3518,7 +3527,12 @@ impl DynamicEnvironment { .map_err(|error| format!("play environment sound: {error}"))?; } } - Ok((sky_frame, sky_mesh, environment_frame)) + Ok(DynamicEnvironmentFrame { + atmosphere, + sky_mesh, + environment_frame, + absolute_time_seconds, + }) } } @@ -5429,6 +5443,7 @@ fn run_static_vulkan_mode( environment: Option<DynamicEnvironment>, audio: Option<audio::GameAudio>, target_frames: u64, + control_clock_target_ms: Option<u32>, mission: &str, object_count: usize, readback_out: Option<&std::path::Path>, @@ -5481,6 +5496,7 @@ fn run_static_vulkan_mode( shadow_base_index, audio, target_frames, + control_clock_target_ms, mission, object_count, readback_out.map(std::path::Path::to_path_buf), @@ -5493,6 +5509,80 @@ fn run_static_vulkan_mode( app.finish() } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SceneSampleId { + Live(u64), + Replay(u64), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct FrameClockSample { + id: SceneSampleId, + time_ms: u32, + delta_seconds: f32, +} + +#[derive(Clone, Debug, PartialEq)] +struct SceneFrameUpdate { + sample: FrameClockSample, + camera: PreviewCameraFrame, + viewport: [f32; 2], + aspect: f32, + environment: Option<DynamicEnvironmentFrame>, +} + +#[derive(Debug, Default)] +struct SceneFrameLifecycle { + pending: Option<SceneFrameUpdate>, + frames_presented: u64, + last_replay_time_ms: u32, +} + +impl SceneFrameLifecycle { + fn sample_replay(&self, target_ms: u32, target_frames: u64) -> FrameClockSample { + if let Some(pending) = self.pending.as_ref() { + return pending.sample; + } + let time_ms = control_clock_ms_for_frame(target_ms, self.frames_presented, target_frames); + FrameClockSample { + id: SceneSampleId::Replay(self.frames_presented), + time_ms, + delta_seconds: replay_environment_delta_seconds(self.last_replay_time_ms, time_ms), + } + } + + fn prepare(&mut self, update: SceneFrameUpdate) -> bool { + if self.pending.is_some() { + return false; + } + self.pending = Some(update); + true + } + + fn record_presented(&mut self) -> Option<u64> { + let sample = self.pending.take()?.sample; + let frame_index = self.frames_presented; + self.frames_presented = self.frames_presented.saturating_add(1); + if matches!(sample.id, SceneSampleId::Replay(_)) { + self.last_replay_time_ms = sample.time_ms; + } + Some(frame_index) + } +} + +fn control_clock_ms_for_frame(target_ms: u32, frame_index: u64, target_frames: u64) -> u32 { + if target_frames <= 1 { + return target_ms; + } + let last_frame = u128::from(target_frames - 1); + let frame = u128::from(frame_index).min(last_frame); + ((u128::from(target_ms) * frame) / last_frame) as u32 +} + +fn replay_environment_delta_seconds(last_applied_ms: u32, current_ms: u32) -> f32 { + current_ms.saturating_sub(last_applied_ms) as f32 / 1_000.0 +} + struct StaticVulkanApp { mesh: VulkanStaticMesh, native_handedness_by_range: HashMap<usize, i8>, @@ -5523,19 +5613,18 @@ struct StaticVulkanApp { shadow_receivers: Vec<ShadowTriangle>, shadow_cache: ShadowPageCache, shadow_last_evidence: ShadowFrameEvidence, - environment_last_tick: Instant, - material_last_tick: Instant, - material_elapsed: Duration, material_time_ms: u32, environment_primitive_count: usize, pressed_keys: HashSet<KeyCode>, last_tick: Instant, + simulation_elapsed: Duration, mouse_look: bool, audio: Option<audio::GameAudio>, camera_mode: &'static str, mesh_components: usize, terrain_components: usize, target_frames: u64, + control_clock_target_ms: Option<u32>, mission: String, object_count: usize, readback_out: Option<PathBuf>, @@ -5544,7 +5633,7 @@ struct StaticVulkanApp { window_id: Option<WindowId>, window: Option<Window>, renderer: Option<VulkanSmokeRenderer>, - frames_presented: u64, + lifecycle: SceneFrameLifecycle, output: Option<String>, error: Option<String>, } @@ -5559,6 +5648,7 @@ impl StaticVulkanApp { shadow_base_index: usize, audio: Option<audio::GameAudio>, target_frames: u64, + control_clock_target_ms: Option<u32>, mission: &str, object_count: usize, readback_out: Option<PathBuf>, @@ -5611,19 +5701,18 @@ impl StaticVulkanApp { shadow_receivers: preview.shadow_receivers, shadow_cache: ShadowPageCache::default(), shadow_last_evidence: ShadowFrameEvidence::default(), - environment_last_tick: Instant::now(), - material_last_tick: Instant::now(), - material_elapsed: Duration::ZERO, material_time_ms: 0, environment_primitive_count: 0, pressed_keys: HashSet::new(), last_tick: Instant::now(), + simulation_elapsed: Duration::ZERO, mouse_look: false, audio, camera_mode: preview.camera_mode, mesh_components: preview.mesh_components, terrain_components: preview.terrain_components, target_frames, + control_clock_target_ms, mission: mission.to_string(), object_count, readback_out, @@ -5632,7 +5721,7 @@ impl StaticVulkanApp { window_id: None, window: None, renderer: None, - frames_presented: 0, + lifecycle: SceneFrameLifecycle::default(), output: None, error: None, } @@ -5739,30 +5828,17 @@ impl StaticVulkanApp { Ok(keys) } - fn update_free_camera(&mut self) -> Result<(), String> { + fn update_free_camera(&mut self, delta_seconds: f32, viewport: [f32; 2]) -> Result<(), String> { if self.free_camera.is_none() { return Ok(()); } - let now = Instant::now(); - let elapsed = now - .saturating_duration_since(self.last_tick) - .min(Duration::from_millis(100)); - self.last_tick = now; - let Some(window) = self.window.as_ref() else { - return Ok(()); - }; - let size = window.inner_size(); - let aspect = if size.height == 0 { - 16.0 / 9.0 - } else { - size.width as f32 / size.height as f32 - }; + let aspect = viewport[0] / viewport[1].max(1.0); let view_camera = { let free_camera = self .free_camera .as_mut() .ok_or_else(|| "free-flight camera disappeared".to_string())?; - free_camera.advance(&self.pressed_keys, elapsed.as_secs_f32()); + free_camera.advance(&self.pressed_keys, delta_seconds.min(0.1)); free_camera.keep_above_terrain(&self.terrain)?; free_camera.preview_frame(aspect) }; @@ -5785,26 +5861,20 @@ impl StaticVulkanApp { Ok(()) } - fn update_material_clock(&mut self) { - let now = Instant::now(); - let elapsed = now - .saturating_duration_since(self.material_last_tick) - .min(Duration::from_millis(100)); - self.material_last_tick = now; - self.material_elapsed = self.material_elapsed.saturating_add(elapsed); - // Keep the native u32 millisecond clock while flooring only the - // accumulated duration. This preserves sub-millisecond frame deltas - // instead of dropping them once per redraw. - self.material_time_ms = self.material_elapsed.as_millis() as u32; + fn update_material_clock(&mut self, sample: FrameClockSample) { + self.material_time_ms = sample.time_ms; } - fn update_controlled_component_geometry(&mut self) -> Result<(), String> { + fn update_controlled_component_geometry( + &mut self, + sample: FrameClockSample, + ) -> Result<(), String> { let mut pose_changed = false; for instance in &mut self.control_animation_instances { let pose = static_preview_control_pose_buffer( &instance.model, &instance.control, - self.material_time_ms, + sample.time_ms, &mut instance.state, )?; let slot = self @@ -5993,63 +6063,103 @@ impl StaticVulkanApp { }) } - fn update_environment(&mut self) -> Result<(), String> { - let Some(renderer) = self.renderer.as_mut() else { - return Ok(()); + fn update_environment( + &mut self, + sample: FrameClockSample, + viewport: [f32; 2], + ) -> Result<Option<DynamicEnvironmentFrame>, String> { + if self.renderer.is_none() { + return Ok(None); + } + let active_materials = { + let renderer = self + .renderer + .as_mut() + .ok_or_else(|| "native Vulkan renderer was not initialized".to_string())?; + update_material_animations( + &mut self.material_animations, + &self.materials, + renderer, + self.material_time_ms, + )? }; - let viewport = self.captured_viewport.map_or_else( - || { - self.window - .as_ref() - .map(|window| { - let size = window.inner_size(); - [size.width.max(1) as f32, size.height.max(1) as f32] - }) - .unwrap_or([1280.0, 720.0]) - }, - |viewport| [viewport.extent[0] as f32, viewport.extent[1] as f32], - ); - let aspect = viewport[0] / viewport[1].max(1.0); - let now = Instant::now(); - let dt = now - .saturating_duration_since(self.environment_last_tick) - .min(Duration::from_millis(100)) - .as_secs_f32(); - self.environment_last_tick = now; - let active_materials = update_material_animations( - &mut self.material_animations, - &self.materials, - renderer, - self.material_time_ms, - )?; - Self::update_world_transparency( - &self.mesh, - &self.materials, - &self.world_range_indices, - &self.world_range_index_counts, - &self.native_handedness_by_range, - self.view_camera.position, - &active_materials, - renderer, - )?; + { + let renderer = self + .renderer + .as_mut() + .ok_or_else(|| "native Vulkan renderer was not initialized".to_string())?; + Self::update_world_transparency( + &self.mesh, + &self.materials, + &self.world_range_indices, + &self.world_range_index_counts, + &self.native_handedness_by_range, + self.view_camera.position, + &active_materials, + renderer, + )?; + } let camera = self.view_camera; if let Some(audio) = self.audio.as_mut() { audio .update_listener(camera.position, camera.forward, camera.up) .map_err(|error| format!("update audio listener: {error}"))?; } - if let Some(environment) = self.environment.as_mut() { - let (sky_frame, sky_mesh, environment_frame) = - environment.update(dt, &camera, aspect, viewport, renderer, self.audio.as_mut())?; - self.environment_primitive_count = environment_frame.primitives.len(); + let environment_frame = if self.environment.is_some() { + let renderer = self + .renderer + .as_mut() + .ok_or_else(|| "native Vulkan renderer was not initialized".to_string())?; + let environment = self + .environment + .as_mut() + .ok_or_else(|| "dynamic environment disappeared".to_string())?; + Some(environment.update( + sample.delta_seconds, + &camera, + viewport[0] / viewport[1].max(1.0), + viewport, + renderer, + self.audio.as_mut(), + )?) + } else { + None + }; + if let Some(frame) = environment_frame.as_ref() { + self.environment_primitive_count = frame.environment_frame.primitives.len(); + } + self.apply_environment_view(environment_frame.as_ref(), camera, viewport)?; + Ok(environment_frame) + } + + fn apply_environment_view( + &mut self, + frame: Option<&DynamicEnvironmentFrame>, + camera: PreviewCameraFrame, + viewport: [f32; 2], + ) -> Result<(), String> { + let Some(renderer) = self.renderer.as_mut() else { + return Ok(()); + }; + let aspect = viewport[0] / viewport[1].max(1.0); + if let Some(frame) = frame { + let environment = self + .environment + .as_ref() + .ok_or_else(|| "dynamic environment disappeared".to_string())?; + let sky_frame = SkyFrame::from_atmosphere( + &frame.atmosphere, + &environment.materials, + frame.absolute_time_seconds, + ); if let Some(environment_gpu) = self.environment_gpu.as_mut() { environment_gpu.update( &mut self.mesh, &self.materials, renderer, &sky_frame, - &sky_mesh, - &environment_frame, + &frame.sky_mesh, + &frame.environment_frame, &camera, viewport, self.material_time_ms, @@ -6073,7 +6183,7 @@ impl StaticVulkanApp { self.shadow_material_index, renderer, Some(&sky_frame), - Some(&environment_frame), + Some(&frame.environment_frame), Some(shadow_camera), )?; } else { @@ -6100,6 +6210,75 @@ impl StaticVulkanApp { Ok(()) } + fn refresh_pending_viewport(&mut self, viewport: [f32; 2]) -> Result<(), String> { + if self.captured_viewport.is_some() { + return Ok(()); + } + let Some(mut update) = self.lifecycle.pending.clone() else { + return Ok(()); + }; + let viewport = [viewport[0].max(1.0), viewport[1].max(1.0)]; + let aspect = viewport[0] / viewport[1]; + if let Some(free_camera) = self.free_camera.as_ref() { + let camera = free_camera.preview_frame(aspect); + self.view_camera = camera; + self.camera = camera.vulkan_camera(); + if let Some(renderer) = self.renderer.as_mut() { + renderer + .set_camera(self.camera) + .map_err(|error| format!("refresh free-flight camera viewport: {error}"))?; + } + } + let camera = self.view_camera; + self.apply_environment_view(update.environment.as_ref(), camera, viewport)?; + update.camera = camera; + update.viewport = viewport; + update.aspect = aspect; + self.lifecycle.pending = Some(update); + Ok(()) + } + + fn sample_frame_clock(&mut self) -> FrameClockSample { + if let Some(target_ms) = self.control_clock_target_ms { + return self.lifecycle.sample_replay(target_ms, self.target_frames); + } + self.sample_live_frame_clock_at(Instant::now()) + } + + fn sample_live_frame_clock_at(&mut self, now: Instant) -> FrameClockSample { + if let Some(pending) = self.lifecycle.pending.as_ref() { + return pending.sample; + } + let elapsed = now.saturating_duration_since(self.last_tick); + self.last_tick = now; + self.simulation_elapsed = self.simulation_elapsed.saturating_add(elapsed); + FrameClockSample { + id: SceneSampleId::Live(self.lifecycle.frames_presented), + time_ms: self.simulation_elapsed.as_millis() as u32, + delta_seconds: elapsed.as_secs_f32(), + } + } + + fn prepare_scene_update( + &mut self, + sample: FrameClockSample, + viewport: [f32; 2], + ) -> Result<SceneFrameUpdate, String> { + let viewport = [viewport[0].max(1.0), viewport[1].max(1.0)]; + let aspect = viewport[0] / viewport[1]; + self.update_free_camera(sample.delta_seconds, viewport)?; + self.update_material_clock(sample); + self.update_controlled_component_geometry(sample)?; + let environment = self.update_environment(sample, viewport)?; + Ok(SceneFrameUpdate { + sample, + camera: self.view_camera, + viewport, + aspect, + environment, + }) + } + fn set_mouse_look(&mut self, active: bool) { self.mouse_look = active; if let Some(window) = self.window.as_ref() { @@ -6200,7 +6379,7 @@ impl StaticVulkanApp { self.output = Some(format!( "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, captured_viewport_crop={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}", self.mission, - self.frames_presented, + self.lifecycle.frames_presented, self.object_count, self.mesh_components, self.terrain_components, @@ -6360,15 +6539,8 @@ impl ApplicationHandler for StaticVulkanApp { self.window = Some(window); self.renderer = Some(renderer); self.last_tick = Instant::now(); - self.environment_last_tick = Instant::now(); - self.material_last_tick = Instant::now(); - self.material_elapsed = Duration::ZERO; + self.simulation_elapsed = Duration::ZERO; self.material_time_ms = 0; - if let Err(error) = self.update_free_camera() { - self.error = Some(error); - event_loop.exit(); - return; - } self.schedule_next_redraw(); } @@ -6437,45 +6609,64 @@ impl ApplicationHandler for StaticVulkanApp { if let Some(renderer) = self.renderer.as_mut() { renderer.request_resize((drawable_size.width, drawable_size.height)); } + if drawable_size.width == 0 || drawable_size.height == 0 { + event_loop.set_control_flow(ControlFlow::Wait); + } else { + event_loop.set_control_flow(ControlFlow::Poll); + } } WindowEvent::RedrawRequested => { - if let Err(error) = self.update_free_camera() { - self.error = Some(error); - event_loop.exit(); - return; - } - self.update_material_clock(); - if let Err(error) = self.update_controlled_component_geometry() { - self.error = Some(error); - event_loop.exit(); - return; - } - if let Err(error) = self.update_environment() { - self.error = Some(error); - event_loop.exit(); + if self.lifecycle.pending.is_none() { return; } - let Some(renderer) = self.renderer.as_mut() else { - self.error = Some("native Vulkan renderer was not initialized".to_string()); - event_loop.exit(); - return; + let drawable_available = self.window.as_ref().is_some_and(|window| { + let size = window.inner_size(); + size.width != 0 && size.height != 0 + }); + let outcome = { + let Some(renderer) = self.renderer.as_mut() else { + self.error = Some("native Vulkan renderer was not initialized".to_string()); + event_loop.exit(); + return; + }; + renderer.draw_frame() }; - match renderer.draw_frame() { + match outcome { Ok(VulkanSmokeFrameOutcome::Presented) => { - self.frames_presented = self.frames_presented.saturating_add(1); + self.lifecycle.record_presented(); + } + Ok(VulkanSmokeFrameOutcome::Recreated) => { + if let Some((width, height)) = self + .renderer + .as_ref() + .map(|renderer| renderer.report().swapchain_extent) + { + if width != 0 && height != 0 { + if let Err(error) = + self.refresh_pending_viewport([width as f32, height as f32]) + { + self.error = Some(error); + event_loop.exit(); + return; + } + } + } + } + Ok(VulkanSmokeFrameOutcome::ZeroExtent) => { + if !drawable_available { + event_loop.set_control_flow(ControlFlow::Wait); + } } - Ok( - VulkanSmokeFrameOutcome::Recreated | VulkanSmokeFrameOutcome::ZeroExtent, - ) => {} Err(err) => { self.error = Some(err.to_string()); event_loop.exit(); return; } } - if self.target_frames != 0 && self.frames_presented >= self.target_frames { + if self.target_frames != 0 && self.lifecycle.frames_presented >= self.target_frames + { self.complete(event_loop); - } else { + } else if drawable_available { self.schedule_next_redraw(); } } @@ -6483,10 +6674,39 @@ impl ApplicationHandler for StaticVulkanApp { } } - fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { - if self.output.is_none() && self.error.is_none() { - self.schedule_next_redraw(); + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if self.output.is_some() || self.error.is_some() { + return; + } + let Some(window) = self.window.as_ref() else { + return; + }; + let size = window.inner_size(); + if size.width == 0 || size.height == 0 { + event_loop.set_control_flow(ControlFlow::Wait); + return; } + event_loop.set_control_flow(ControlFlow::Poll); + if self.lifecycle.pending.is_none() { + let viewport = self + .captured_viewport + .map_or([size.width as f32, size.height as f32], |captured| { + [captured.extent[0] as f32, captured.extent[1] as f32] + }); + let sample = self.sample_frame_clock(); + let update = match self.prepare_scene_update(sample, viewport) { + Ok(update) => update, + Err(error) => { + self.error = Some(error); + event_loop.exit(); + return; + } + }; + if !self.lifecycle.prepare(update) { + return; + } + } + self.schedule_next_redraw(); } } @@ -6496,6 +6716,7 @@ struct Args { mission: String, frames: u64, atmosphere_seconds: Option<f32>, + control_clock_ms: Option<u32>, validation: bool, readback_out: Option<PathBuf>, preview_roots: NonZeroUsize, @@ -6513,6 +6734,7 @@ impl Args { // runs and validation scripts. let mut frames = 0; let mut atmosphere_seconds = None; + let mut control_clock_ms = None; let mut validation = false; let mut readback_out = None; // A native static-Vulkan invocation is the usable mission preview, so @@ -6560,6 +6782,14 @@ impl Args { } atmosphere_seconds = Some(value); } + "--control-clock-ms" => { + control_clock_ms = Some( + iter.next() + .ok_or_else(|| "--control-clock-ms requires a value".to_string())? + .parse() + .map_err(|_| "--control-clock-ms must be a u32 integer".to_string())?, + ); + } "--validation" => { validation = true; } @@ -6608,11 +6838,18 @@ impl Args { } let root = root.ok_or_else(|| "missing --root".to_string())?; let mission = mission.ok_or_else(|| "missing --mission".to_string())?; + if control_clock_ms.is_some() && frames < 2 { + return Err( + "--control-clock-ms requires --frames of at least 2 to replay the animation clock" + .to_string(), + ); + } Ok(Self { root, mission, frames, atmosphere_seconds, + control_clock_ms, validation, readback_out, preview_roots, @@ -6781,7 +7018,7 @@ fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, String> { } fn usage() -> String { - "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally.".to_string() + "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--control-clock-ms <u32>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally. --control-clock-ms replays one shared MSH/material/environment clock from zero to the selected time over --frames frames.".to_string() } #[cfg(test)] @@ -7066,7 +7303,7 @@ mod tests { } #[test] - fn controlled_component_update_keeps_vertices_and_shadow_triangles_in_sync() { + fn live_tick_and_pending_resize_keep_the_scene_sample_while_refreshing_projection() { let model = Arc::new(control_pose_drawable_test_model()); let control = control_pose_test_control(); let dynamic = DynamicComponentGeometry { @@ -7093,8 +7330,33 @@ mod tests { center: [0.0; 3], radius: 1.0, }; - let view_camera = FreeFlightCamera { - position: [0.0; 3], + let mut land = fparkan_terrain_format::LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: fparkan_terrain_format::TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![fparkan_terrain_format::TerrainFace28 { + flags: FullSurfaceMask(1), + material_tag: 0, + aux_tag: 0, + vertices: [0, 1, 2], + neighbors: [None, None, None], + tail_raw: [0; 8], + raw: [0; 28], + }], + }; + set_test_terrain_lod0_range(&mut land); + let terrain = Arc::new(TerrainWorld::from_land_msh(&land).expect("test terrain")); + let free_camera = FreeFlightCamera { + position: [0.25, 0.25, 0.0], yaw: 0.0, pitch: 0.0, horizontal_fov: 1.0, @@ -7102,8 +7364,8 @@ mod tests { far_plane: 100.0, move_speed: 1.0, ground_clearance: 0.0, - } - .preview_frame(16.0 / 9.0); + }; + let view_camera = free_camera.preview_frame(16.0 / 9.0); let preview = StaticPreviewScene { mesh: initial_mesh.clone(), native_handedness_by_range: HashMap::new(), @@ -7120,7 +7382,7 @@ mod tests { shadow_sphere_baselines: vec![baseline], shadow_receivers: Vec::new(), shadow_material_index: 0, - terrain: Arc::new(TerrainWorld::default()), + terrain, component_pose_buffers: vec![vec![Some(initial_pose)]], component_mount_poses: vec![vec![None]], control_animation_instances: vec![ControlAnimationInstance { @@ -7155,15 +7417,34 @@ mod tests { initial_mesh.indices.len(), None, 0, + None, "control-test", 1, None, false, StartupTimings::default(), ); - app.material_time_ms = 225; - app.update_controlled_component_geometry() - .expect("update projected component and caster"); + app.free_camera = Some(free_camera); + app.pressed_keys.insert(KeyCode::KeyW); + let start = app.last_tick; + let sample = app.sample_live_frame_clock_at(start + Duration::from_millis(225)); + assert_eq!(sample.time_ms, 225); + assert!((sample.delta_seconds - 0.225).abs() < f32::EPSILON); + let update = app + .prepare_scene_update(sample, [1280.0, 720.0]) + .expect("apply one full live tick to the scene"); + let camera_after_tick = app.free_camera.expect("free-flight camera").position; + assert!((camera_after_tick[0] - 0.35).abs() < 1.0e-5); + assert!((camera_after_tick[1] - 0.25).abs() < 1.0e-5); + assert_eq!(app.material_time_ms, sample.time_ms); + assert!(app.lifecycle.prepare(update.clone())); + let control_state_after_tick = app.control_animation_instances[0].state.clone(); + assert_eq!( + app.sample_live_frame_clock_at(start + Duration::from_millis(500)), + sample + ); + assert_eq!(app.simulation_elapsed, Duration::from_millis(225)); + assert_eq!(app.lifecycle.pending.as_ref(), Some(&update)); assert_eq!(app.mesh.indices, initial_mesh.indices); assert_eq!(app.mesh.vertices.len(), initial_mesh.vertices.len()); @@ -7178,14 +7459,41 @@ mod tests { assert_ne!(b_triangles, initial_triangles); assert!(b_sphere.radius > baseline.radius); - app.update_controlled_component_geometry() + app.refresh_pending_viewport([800.0, 600.0]) + .expect("refresh only viewport-dependent state after recreation"); + let refreshed = app.lifecycle.pending.as_ref().expect("pending sample"); + assert_eq!(refreshed.sample, sample); + assert_eq!(refreshed.viewport, [800.0, 600.0]); + assert_eq!(refreshed.aspect, 4.0 / 3.0); + assert_eq!(refreshed.camera.position, update.camera.position); + assert_ne!( + refreshed.camera.clip_from_world, + update.camera.clip_from_world + ); + assert_eq!(app.mesh.vertices, b_vertices); + assert_eq!(app.shadow_casters[0].triangles, b_triangles); + assert_eq!( + app.control_animation_instances[0].state, + control_state_after_tick + ); + assert_eq!(app.material_time_ms, sample.time_ms); + assert_eq!(app.simulation_elapsed, Duration::from_millis(225)); + assert_eq!(app.lifecycle.frames_presented, 0); + assert_eq!(app.lifecycle.record_presented(), Some(0)); + + app.update_controlled_component_geometry(sample) .expect("repeat the same control timestamp"); assert_eq!(app.mesh.vertices, b_vertices); assert_eq!(app.shadow_casters[0].triangles, b_triangles); assert_eq!(app.shadow_casters[0].sphere, b_sphere); - app.material_time_ms = 0; - app.update_controlled_component_geometry() + let first_sample = FrameClockSample { + id: SceneSampleId::Live(0), + time_ms: 0, + delta_seconds: 0.0, + }; + app.update_material_clock(first_sample); + app.update_controlled_component_geometry(first_sample) .expect("return to first control pose"); assert_eq!(app.mesh.vertices, initial_mesh.vertices); assert_eq!(app.shadow_casters[0].triangles, initial_triangles); @@ -7210,6 +7518,28 @@ mod tests { values.iter().map(|value| (*value).to_string()).collect() } + fn scene_frame_update(sample: FrameClockSample, viewport: [f32; 2]) -> SceneFrameUpdate { + let aspect = viewport[0] / viewport[1]; + let camera = FreeFlightCamera { + position: [0.0; 3], + yaw: 0.0, + pitch: 0.0, + horizontal_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 1.0, + ground_clearance: 0.0, + } + .preview_frame(aspect); + SceneFrameUpdate { + sample, + camera, + viewport, + aspect, + environment: None, + } + } + #[test] #[ignore = "requires licensed corpus"] fn licensed_corpus_part1_prepares_every_mission() { @@ -7597,6 +7927,7 @@ mod tests { mission: "MISSIONS/Autodemo.00/data.tma".to_string(), frames: 3, atmosphere_seconds: None, + control_clock_ms: None, validation: false, readback_out: None, preview_roots: NonZeroUsize::MAX, @@ -7619,6 +7950,60 @@ mod tests { assert_eq!(parsed.frames, 0); assert_eq!(parsed.preview_roots, NonZeroUsize::MAX); assert_eq!(parsed.static_animation_frame, None); + assert_eq!(parsed.control_clock_ms, None); + } + + #[test] + fn replay_clock_stays_on_one_sample_until_presented() { + let mut lifecycle = SceneFrameLifecycle::default(); + let first = lifecycle.sample_replay(500, 101); + assert_eq!(first.id, SceneSampleId::Replay(0)); + assert_eq!(first.time_ms, 0); + assert_eq!(first.delta_seconds, 0.0); + let update = scene_frame_update(first, [1280.0, 720.0]); + assert!(lifecycle.prepare(update.clone())); + + // A swapchain recreation retries the same packet and does not count a frame. + assert_eq!(lifecycle.sample_replay(500, 101), first); + assert_eq!(lifecycle.frames_presented, 0); + assert_eq!(lifecycle.pending.as_ref(), Some(&update)); + assert_eq!(lifecycle.record_presented(), Some(0)); + + let second = lifecycle.sample_replay(500, 101); + assert_eq!(second.id, SceneSampleId::Replay(1)); + assert_eq!(second.time_ms, 5); + assert!((second.delta_seconds - 0.005).abs() < f32::EPSILON); + } + + #[test] + fn replay_clock_requires_at_least_two_requested_frames() { + for frame_count in ["0", "1"] { + assert!(Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--frames", + frame_count, + "--control-clock-ms", + "500", + ])) + .unwrap_err() + .contains("--frames of at least 2")); + } + + let parsed = Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--frames", + "101", + "--control-clock-ms", + "500", + ])) + .expect("valid bounded replay arguments"); + assert_eq!(parsed.control_clock_ms, Some(500)); } #[test] @@ -7716,15 +8101,14 @@ mod tests { #[test] fn free_flight_camera_uses_native_horizontal_fov_for_every_viewport_aspect() { let horizontal_fov = 1.3_f32; - let camera = FreeFlightCamera::from_bounds([0.0; 3], [100.0; 3]) - .expect("finite free-flight bounds"); + let camera = + FreeFlightCamera::from_bounds([0.0; 3], [100.0; 3]).expect("finite free-flight bounds"); assert_eq!(camera.horizontal_fov, horizontal_fov); assert_eq!(camera.near_plane, 0.5); assert_eq!(camera.far_plane, 700.0); let assert_edges = |frame: PreviewCameraFrame, aspect: f32| { - let expected_vertical_fov = - 2.0 * ((horizontal_fov * 0.5).tan() / aspect).atan(); + let expected_vertical_fov = 2.0 * ((horizontal_fov * 0.5).tan() / aspect).atan(); assert!((frame.vertical_fov - expected_vertical_fov).abs() < 1.0e-5); let depth = 10.0; let center = add3(frame.position, scale3(frame.forward, depth)); @@ -7749,8 +8133,14 @@ mod tests { frame.clip_from_world, ); assert!(center_clip[3] > 0.0 && right_clip[3] > 0.0 && top_clip[3] > 0.0); - assert!(center_clip[0].abs() < 5.0e-4, "aspect {aspect}: {center_clip:?}"); - assert!(center_clip[1].abs() < 5.0e-4, "aspect {aspect}: {center_clip:?}"); + assert!( + center_clip[0].abs() < 5.0e-4, + "aspect {aspect}: {center_clip:?}" + ); + assert!( + center_clip[1].abs() < 5.0e-4, + "aspect {aspect}: {center_clip:?}" + ); assert!( (right_clip[0] / right_clip[3] - 1.0).abs() < 1.0e-4, "aspect {aspect}: {right_clip:?}" @@ -7792,7 +8182,10 @@ mod tests { }; let captured = PreviewCameraFrame::from_legacy_d3d7(raw_transform, projection) .expect("valid D3D7 projection"); - assert_edges(captured, projection.viewport[2] as f32 / projection.viewport[3] as f32); + assert_edges( + captured, + projection.viewport[2] as f32 / projection.viewport[3] as f32, + ); } } |
