fix(render): refresh viewport after swapchain recreation
Docs Deploy / Build and Deploy MkDocs (push) Successful in 45s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 45s
This commit is contained in:
3 files changed
+545
-133
No files matched your search
@@ -38,9 +38,17 @@ cargo run --release -p fparkan-game -- `
|
|||||||
луна, flares, молнии, дождь и снег, point lights, динамические тени и связанные
|
луна, flares, молнии, дождь и снег, point lights, динамические тени и связанные
|
||||||
звуковые события обновляются в каждом кадре.
|
звуковые события обновляются в каждом кадре.
|
||||||
|
|
||||||
`--frames N` ограничивает запуск числом кадров для smoke/readback-проверок;
|
`--frames N` ограничивает запуск числом представленных кадров для
|
||||||
значение `0` означает бесконечный интерактивный запуск. `--legacy-camera-capture`
|
smoke/readback-проверок;
|
||||||
читает сохранённый захват камеры и запускает воспроизводимый режим без
|
значение `0` означает бесконечный интерактивный запуск. `--control-clock-ms N`
|
||||||
|
воспроизводит общий clock анимации MSH и материалов вместе с окружением от нуля
|
||||||
|
до `N` за `--frames` кадров и требует не менее двух кадров. Пересоздание
|
||||||
|
swapchain повторяет текущий sample времени и состояние мира; перед повторной
|
||||||
|
отправкой обновляются camera projection и viewport-зависимая геометрия. Это не
|
||||||
|
продвигает сцену или replay clock. Счётчик и replay clock переходят дальше
|
||||||
|
после представленного кадра.
|
||||||
|
`--legacy-camera-capture` читает сохранённый захват камеры и запускает
|
||||||
|
воспроизводимый режим без
|
||||||
free-flight управления. `--static-animation-frame <u16>` задаёт один кадр для
|
free-flight управления. `--static-animation-frame <u16>` задаёт один кадр для
|
||||||
всех узлов. Если параметр не указан, приложение берёт сохранённые настройки
|
всех узлов. Если параметр не указан, приложение берёт сохранённые настройки
|
||||||
узлов из явной ссылки прототипа на `.ctl`; для узлов без такой настройки
|
узлов из явной ссылки прототипа на `.ctl`; для узлов без такой настройки
|
||||||
|
|||||||
+517
-124
@@ -153,6 +153,7 @@ fn run(args: &[String]) -> Result<String, String> {
|
|||||||
environment,
|
environment,
|
||||||
audio,
|
audio,
|
||||||
args.frames,
|
args.frames,
|
||||||
|
args.control_clock_ms,
|
||||||
&args.mission,
|
&args.mission,
|
||||||
loaded.object_count,
|
loaded.object_count,
|
||||||
args.readback_out.as_deref(),
|
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
|
/// 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
|
/// same update clock, so camera movement cannot leave environment audio or
|
||||||
/// lighting one frame behind.
|
/// 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 {
|
struct DynamicEnvironment {
|
||||||
schedule: TypedAtmosphere,
|
schedule: TypedAtmosphere,
|
||||||
sky: SkySystem,
|
sky: SkySystem,
|
||||||
@@ -3416,7 +3425,7 @@ impl DynamicEnvironment {
|
|||||||
viewport: [f32; 2],
|
viewport: [f32; 2],
|
||||||
renderer: &mut VulkanSmokeRenderer,
|
renderer: &mut VulkanSmokeRenderer,
|
||||||
mut audio: Option<&mut audio::GameAudio>,
|
mut audio: Option<&mut audio::GameAudio>,
|
||||||
) -> Result<(SkyFrame<'_>, SkyMesh, EnvironmentFrame), String> {
|
) -> Result<DynamicEnvironmentFrame, String> {
|
||||||
let dt_seconds = if dt_seconds.is_finite() {
|
let dt_seconds = if dt_seconds.is_finite() {
|
||||||
dt_seconds.max(0.0)
|
dt_seconds.max(0.0)
|
||||||
} else {
|
} else {
|
||||||
@@ -3518,7 +3527,12 @@ impl DynamicEnvironment {
|
|||||||
.map_err(|error| format!("play environment sound: {error}"))?;
|
.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>,
|
environment: Option<DynamicEnvironment>,
|
||||||
audio: Option<audio::GameAudio>,
|
audio: Option<audio::GameAudio>,
|
||||||
target_frames: u64,
|
target_frames: u64,
|
||||||
|
control_clock_target_ms: Option<u32>,
|
||||||
mission: &str,
|
mission: &str,
|
||||||
object_count: usize,
|
object_count: usize,
|
||||||
readback_out: Option<&std::path::Path>,
|
readback_out: Option<&std::path::Path>,
|
||||||
@@ -5481,6 +5496,7 @@ fn run_static_vulkan_mode(
|
|||||||
shadow_base_index,
|
shadow_base_index,
|
||||||
audio,
|
audio,
|
||||||
target_frames,
|
target_frames,
|
||||||
|
control_clock_target_ms,
|
||||||
mission,
|
mission,
|
||||||
object_count,
|
object_count,
|
||||||
readback_out.map(std::path::Path::to_path_buf),
|
readback_out.map(std::path::Path::to_path_buf),
|
||||||
@@ -5493,6 +5509,80 @@ fn run_static_vulkan_mode(
|
|||||||
app.finish()
|
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 {
|
struct StaticVulkanApp {
|
||||||
mesh: VulkanStaticMesh,
|
mesh: VulkanStaticMesh,
|
||||||
native_handedness_by_range: HashMap<usize, i8>,
|
native_handedness_by_range: HashMap<usize, i8>,
|
||||||
@@ -5523,19 +5613,18 @@ struct StaticVulkanApp {
|
|||||||
shadow_receivers: Vec<ShadowTriangle>,
|
shadow_receivers: Vec<ShadowTriangle>,
|
||||||
shadow_cache: ShadowPageCache,
|
shadow_cache: ShadowPageCache,
|
||||||
shadow_last_evidence: ShadowFrameEvidence,
|
shadow_last_evidence: ShadowFrameEvidence,
|
||||||
environment_last_tick: Instant,
|
|
||||||
material_last_tick: Instant,
|
|
||||||
material_elapsed: Duration,
|
|
||||||
material_time_ms: u32,
|
material_time_ms: u32,
|
||||||
environment_primitive_count: usize,
|
environment_primitive_count: usize,
|
||||||
pressed_keys: HashSet<KeyCode>,
|
pressed_keys: HashSet<KeyCode>,
|
||||||
last_tick: Instant,
|
last_tick: Instant,
|
||||||
|
simulation_elapsed: Duration,
|
||||||
mouse_look: bool,
|
mouse_look: bool,
|
||||||
audio: Option<audio::GameAudio>,
|
audio: Option<audio::GameAudio>,
|
||||||
camera_mode: &'static str,
|
camera_mode: &'static str,
|
||||||
mesh_components: usize,
|
mesh_components: usize,
|
||||||
terrain_components: usize,
|
terrain_components: usize,
|
||||||
target_frames: u64,
|
target_frames: u64,
|
||||||
|
control_clock_target_ms: Option<u32>,
|
||||||
mission: String,
|
mission: String,
|
||||||
object_count: usize,
|
object_count: usize,
|
||||||
readback_out: Option<PathBuf>,
|
readback_out: Option<PathBuf>,
|
||||||
@@ -5544,7 +5633,7 @@ struct StaticVulkanApp {
|
|||||||
window_id: Option<WindowId>,
|
window_id: Option<WindowId>,
|
||||||
window: Option<Window>,
|
window: Option<Window>,
|
||||||
renderer: Option<VulkanSmokeRenderer>,
|
renderer: Option<VulkanSmokeRenderer>,
|
||||||
frames_presented: u64,
|
lifecycle: SceneFrameLifecycle,
|
||||||
output: Option<String>,
|
output: Option<String>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -5559,6 +5648,7 @@ impl StaticVulkanApp {
|
|||||||
shadow_base_index: usize,
|
shadow_base_index: usize,
|
||||||
audio: Option<audio::GameAudio>,
|
audio: Option<audio::GameAudio>,
|
||||||
target_frames: u64,
|
target_frames: u64,
|
||||||
|
control_clock_target_ms: Option<u32>,
|
||||||
mission: &str,
|
mission: &str,
|
||||||
object_count: usize,
|
object_count: usize,
|
||||||
readback_out: Option<PathBuf>,
|
readback_out: Option<PathBuf>,
|
||||||
@@ -5611,19 +5701,18 @@ impl StaticVulkanApp {
|
|||||||
shadow_receivers: preview.shadow_receivers,
|
shadow_receivers: preview.shadow_receivers,
|
||||||
shadow_cache: ShadowPageCache::default(),
|
shadow_cache: ShadowPageCache::default(),
|
||||||
shadow_last_evidence: ShadowFrameEvidence::default(),
|
shadow_last_evidence: ShadowFrameEvidence::default(),
|
||||||
environment_last_tick: Instant::now(),
|
|
||||||
material_last_tick: Instant::now(),
|
|
||||||
material_elapsed: Duration::ZERO,
|
|
||||||
material_time_ms: 0,
|
material_time_ms: 0,
|
||||||
environment_primitive_count: 0,
|
environment_primitive_count: 0,
|
||||||
pressed_keys: HashSet::new(),
|
pressed_keys: HashSet::new(),
|
||||||
last_tick: Instant::now(),
|
last_tick: Instant::now(),
|
||||||
|
simulation_elapsed: Duration::ZERO,
|
||||||
mouse_look: false,
|
mouse_look: false,
|
||||||
audio,
|
audio,
|
||||||
camera_mode: preview.camera_mode,
|
camera_mode: preview.camera_mode,
|
||||||
mesh_components: preview.mesh_components,
|
mesh_components: preview.mesh_components,
|
||||||
terrain_components: preview.terrain_components,
|
terrain_components: preview.terrain_components,
|
||||||
target_frames,
|
target_frames,
|
||||||
|
control_clock_target_ms,
|
||||||
mission: mission.to_string(),
|
mission: mission.to_string(),
|
||||||
object_count,
|
object_count,
|
||||||
readback_out,
|
readback_out,
|
||||||
@@ -5632,7 +5721,7 @@ impl StaticVulkanApp {
|
|||||||
window_id: None,
|
window_id: None,
|
||||||
window: None,
|
window: None,
|
||||||
renderer: None,
|
renderer: None,
|
||||||
frames_presented: 0,
|
lifecycle: SceneFrameLifecycle::default(),
|
||||||
output: None,
|
output: None,
|
||||||
error: None,
|
error: None,
|
||||||
}
|
}
|
||||||
@@ -5739,30 +5828,17 @@ impl StaticVulkanApp {
|
|||||||
Ok(keys)
|
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() {
|
if self.free_camera.is_none() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let now = Instant::now();
|
let aspect = viewport[0] / viewport[1].max(1.0);
|
||||||
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 view_camera = {
|
let view_camera = {
|
||||||
let free_camera = self
|
let free_camera = self
|
||||||
.free_camera
|
.free_camera
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| "free-flight camera disappeared".to_string())?;
|
.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.keep_above_terrain(&self.terrain)?;
|
||||||
free_camera.preview_frame(aspect)
|
free_camera.preview_frame(aspect)
|
||||||
};
|
};
|
||||||
@@ -5785,26 +5861,20 @@ impl StaticVulkanApp {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_material_clock(&mut self) {
|
fn update_material_clock(&mut self, sample: FrameClockSample) {
|
||||||
let now = Instant::now();
|
self.material_time_ms = sample.time_ms;
|
||||||
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_controlled_component_geometry(&mut self) -> Result<(), String> {
|
fn update_controlled_component_geometry(
|
||||||
|
&mut self,
|
||||||
|
sample: FrameClockSample,
|
||||||
|
) -> Result<(), String> {
|
||||||
let mut pose_changed = false;
|
let mut pose_changed = false;
|
||||||
for instance in &mut self.control_animation_instances {
|
for instance in &mut self.control_animation_instances {
|
||||||
let pose = static_preview_control_pose_buffer(
|
let pose = static_preview_control_pose_buffer(
|
||||||
&instance.model,
|
&instance.model,
|
||||||
&instance.control,
|
&instance.control,
|
||||||
self.material_time_ms,
|
sample.time_ms,
|
||||||
&mut instance.state,
|
&mut instance.state,
|
||||||
)?;
|
)?;
|
||||||
let slot = self
|
let slot = self
|
||||||
@@ -5993,35 +6063,31 @@ impl StaticVulkanApp {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_environment(&mut self) -> Result<(), String> {
|
fn update_environment(
|
||||||
let Some(renderer) = self.renderer.as_mut() else {
|
&mut self,
|
||||||
return Ok(());
|
sample: FrameClockSample,
|
||||||
};
|
viewport: [f32; 2],
|
||||||
let viewport = self.captured_viewport.map_or_else(
|
) -> Result<Option<DynamicEnvironmentFrame>, String> {
|
||||||
|| {
|
if self.renderer.is_none() {
|
||||||
self.window
|
return Ok(None);
|
||||||
.as_ref()
|
}
|
||||||
.map(|window| {
|
let active_materials = {
|
||||||
let size = window.inner_size();
|
let renderer = self
|
||||||
[size.width.max(1) as f32, size.height.max(1) as f32]
|
.renderer
|
||||||
})
|
.as_mut()
|
||||||
.unwrap_or([1280.0, 720.0])
|
.ok_or_else(|| "native Vulkan renderer was not initialized".to_string())?;
|
||||||
},
|
update_material_animations(
|
||||||
|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,
|
&mut self.material_animations,
|
||||||
&self.materials,
|
&self.materials,
|
||||||
renderer,
|
renderer,
|
||||||
self.material_time_ms,
|
self.material_time_ms,
|
||||||
)?;
|
)?
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let renderer = self
|
||||||
|
.renderer
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| "native Vulkan renderer was not initialized".to_string())?;
|
||||||
Self::update_world_transparency(
|
Self::update_world_transparency(
|
||||||
&self.mesh,
|
&self.mesh,
|
||||||
&self.materials,
|
&self.materials,
|
||||||
@@ -6032,24 +6098,68 @@ impl StaticVulkanApp {
|
|||||||
&active_materials,
|
&active_materials,
|
||||||
renderer,
|
renderer,
|
||||||
)?;
|
)?;
|
||||||
|
}
|
||||||
let camera = self.view_camera;
|
let camera = self.view_camera;
|
||||||
if let Some(audio) = self.audio.as_mut() {
|
if let Some(audio) = self.audio.as_mut() {
|
||||||
audio
|
audio
|
||||||
.update_listener(camera.position, camera.forward, camera.up)
|
.update_listener(camera.position, camera.forward, camera.up)
|
||||||
.map_err(|error| format!("update audio listener: {error}"))?;
|
.map_err(|error| format!("update audio listener: {error}"))?;
|
||||||
}
|
}
|
||||||
if let Some(environment) = self.environment.as_mut() {
|
let environment_frame = if self.environment.is_some() {
|
||||||
let (sky_frame, sky_mesh, environment_frame) =
|
let renderer = self
|
||||||
environment.update(dt, &camera, aspect, viewport, renderer, self.audio.as_mut())?;
|
.renderer
|
||||||
self.environment_primitive_count = environment_frame.primitives.len();
|
.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() {
|
if let Some(environment_gpu) = self.environment_gpu.as_mut() {
|
||||||
environment_gpu.update(
|
environment_gpu.update(
|
||||||
&mut self.mesh,
|
&mut self.mesh,
|
||||||
&self.materials,
|
&self.materials,
|
||||||
renderer,
|
renderer,
|
||||||
&sky_frame,
|
&sky_frame,
|
||||||
&sky_mesh,
|
&frame.sky_mesh,
|
||||||
&environment_frame,
|
&frame.environment_frame,
|
||||||
&camera,
|
&camera,
|
||||||
viewport,
|
viewport,
|
||||||
self.material_time_ms,
|
self.material_time_ms,
|
||||||
@@ -6073,7 +6183,7 @@ impl StaticVulkanApp {
|
|||||||
self.shadow_material_index,
|
self.shadow_material_index,
|
||||||
renderer,
|
renderer,
|
||||||
Some(&sky_frame),
|
Some(&sky_frame),
|
||||||
Some(&environment_frame),
|
Some(&frame.environment_frame),
|
||||||
Some(shadow_camera),
|
Some(shadow_camera),
|
||||||
)?;
|
)?;
|
||||||
} else {
|
} else {
|
||||||
@@ -6100,6 +6210,75 @@ impl StaticVulkanApp {
|
|||||||
Ok(())
|
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) {
|
fn set_mouse_look(&mut self, active: bool) {
|
||||||
self.mouse_look = active;
|
self.mouse_look = active;
|
||||||
if let Some(window) = self.window.as_ref() {
|
if let Some(window) = self.window.as_ref() {
|
||||||
@@ -6200,7 +6379,7 @@ impl StaticVulkanApp {
|
|||||||
self.output = Some(format!(
|
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}}}",
|
"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.mission,
|
||||||
self.frames_presented,
|
self.lifecycle.frames_presented,
|
||||||
self.object_count,
|
self.object_count,
|
||||||
self.mesh_components,
|
self.mesh_components,
|
||||||
self.terrain_components,
|
self.terrain_components,
|
||||||
@@ -6360,15 +6539,8 @@ impl ApplicationHandler for StaticVulkanApp {
|
|||||||
self.window = Some(window);
|
self.window = Some(window);
|
||||||
self.renderer = Some(renderer);
|
self.renderer = Some(renderer);
|
||||||
self.last_tick = Instant::now();
|
self.last_tick = Instant::now();
|
||||||
self.environment_last_tick = Instant::now();
|
self.simulation_elapsed = Duration::ZERO;
|
||||||
self.material_last_tick = Instant::now();
|
|
||||||
self.material_elapsed = Duration::ZERO;
|
|
||||||
self.material_time_ms = 0;
|
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();
|
self.schedule_next_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6437,45 +6609,64 @@ impl ApplicationHandler for StaticVulkanApp {
|
|||||||
if let Some(renderer) = self.renderer.as_mut() {
|
if let Some(renderer) = self.renderer.as_mut() {
|
||||||
renderer.request_resize((drawable_size.width, drawable_size.height));
|
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 => {
|
WindowEvent::RedrawRequested => {
|
||||||
if let Err(error) = self.update_free_camera() {
|
if self.lifecycle.pending.is_none() {
|
||||||
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();
|
|
||||||
return;
|
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 {
|
let Some(renderer) = self.renderer.as_mut() else {
|
||||||
self.error = Some("native Vulkan renderer was not initialized".to_string());
|
self.error = Some("native Vulkan renderer was not initialized".to_string());
|
||||||
event_loop.exit();
|
event_loop.exit();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match renderer.draw_frame() {
|
renderer.draw_frame()
|
||||||
|
};
|
||||||
|
match outcome {
|
||||||
Ok(VulkanSmokeFrameOutcome::Presented) => {
|
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) => {
|
Err(err) => {
|
||||||
self.error = Some(err.to_string());
|
self.error = Some(err.to_string());
|
||||||
event_loop.exit();
|
event_loop.exit();
|
||||||
return;
|
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);
|
self.complete(event_loop);
|
||||||
} else {
|
} else if drawable_available {
|
||||||
self.schedule_next_redraw();
|
self.schedule_next_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6483,10 +6674,39 @@ impl ApplicationHandler for StaticVulkanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
|
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
if self.output.is_none() && self.error.is_none() {
|
if self.output.is_some() || self.error.is_some() {
|
||||||
self.schedule_next_redraw();
|
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,
|
mission: String,
|
||||||
frames: u64,
|
frames: u64,
|
||||||
atmosphere_seconds: Option<f32>,
|
atmosphere_seconds: Option<f32>,
|
||||||
|
control_clock_ms: Option<u32>,
|
||||||
validation: bool,
|
validation: bool,
|
||||||
readback_out: Option<PathBuf>,
|
readback_out: Option<PathBuf>,
|
||||||
preview_roots: NonZeroUsize,
|
preview_roots: NonZeroUsize,
|
||||||
@@ -6513,6 +6734,7 @@ impl Args {
|
|||||||
// runs and validation scripts.
|
// runs and validation scripts.
|
||||||
let mut frames = 0;
|
let mut frames = 0;
|
||||||
let mut atmosphere_seconds = None;
|
let mut atmosphere_seconds = None;
|
||||||
|
let mut control_clock_ms = None;
|
||||||
let mut validation = false;
|
let mut validation = false;
|
||||||
let mut readback_out = None;
|
let mut readback_out = None;
|
||||||
// A native static-Vulkan invocation is the usable mission preview, so
|
// A native static-Vulkan invocation is the usable mission preview, so
|
||||||
@@ -6560,6 +6782,14 @@ impl Args {
|
|||||||
}
|
}
|
||||||
atmosphere_seconds = Some(value);
|
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" => {
|
||||||
validation = true;
|
validation = true;
|
||||||
}
|
}
|
||||||
@@ -6608,11 +6838,18 @@ impl Args {
|
|||||||
}
|
}
|
||||||
let root = root.ok_or_else(|| "missing --root".to_string())?;
|
let root = root.ok_or_else(|| "missing --root".to_string())?;
|
||||||
let mission = mission.ok_or_else(|| "missing --mission".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 {
|
Ok(Self {
|
||||||
root,
|
root,
|
||||||
mission,
|
mission,
|
||||||
frames,
|
frames,
|
||||||
atmosphere_seconds,
|
atmosphere_seconds,
|
||||||
|
control_clock_ms,
|
||||||
validation,
|
validation,
|
||||||
readback_out,
|
readback_out,
|
||||||
preview_roots,
|
preview_roots,
|
||||||
@@ -6781,7 +7018,7 @@ fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> 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)]
|
#[cfg(test)]
|
||||||
@@ -7066,7 +7303,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 model = Arc::new(control_pose_drawable_test_model());
|
||||||
let control = control_pose_test_control();
|
let control = control_pose_test_control();
|
||||||
let dynamic = DynamicComponentGeometry {
|
let dynamic = DynamicComponentGeometry {
|
||||||
@@ -7093,8 +7330,33 @@ mod tests {
|
|||||||
center: [0.0; 3],
|
center: [0.0; 3],
|
||||||
radius: 1.0,
|
radius: 1.0,
|
||||||
};
|
};
|
||||||
let view_camera = FreeFlightCamera {
|
let mut land = fparkan_terrain_format::LandMeshDocument {
|
||||||
position: [0.0; 3],
|
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,
|
yaw: 0.0,
|
||||||
pitch: 0.0,
|
pitch: 0.0,
|
||||||
horizontal_fov: 1.0,
|
horizontal_fov: 1.0,
|
||||||
@@ -7102,8 +7364,8 @@ mod tests {
|
|||||||
far_plane: 100.0,
|
far_plane: 100.0,
|
||||||
move_speed: 1.0,
|
move_speed: 1.0,
|
||||||
ground_clearance: 0.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 {
|
let preview = StaticPreviewScene {
|
||||||
mesh: initial_mesh.clone(),
|
mesh: initial_mesh.clone(),
|
||||||
native_handedness_by_range: HashMap::new(),
|
native_handedness_by_range: HashMap::new(),
|
||||||
@@ -7120,7 +7382,7 @@ mod tests {
|
|||||||
shadow_sphere_baselines: vec![baseline],
|
shadow_sphere_baselines: vec![baseline],
|
||||||
shadow_receivers: Vec::new(),
|
shadow_receivers: Vec::new(),
|
||||||
shadow_material_index: 0,
|
shadow_material_index: 0,
|
||||||
terrain: Arc::new(TerrainWorld::default()),
|
terrain,
|
||||||
component_pose_buffers: vec![vec![Some(initial_pose)]],
|
component_pose_buffers: vec![vec![Some(initial_pose)]],
|
||||||
component_mount_poses: vec![vec![None]],
|
component_mount_poses: vec![vec![None]],
|
||||||
control_animation_instances: vec![ControlAnimationInstance {
|
control_animation_instances: vec![ControlAnimationInstance {
|
||||||
@@ -7155,15 +7417,34 @@ mod tests {
|
|||||||
initial_mesh.indices.len(),
|
initial_mesh.indices.len(),
|
||||||
None,
|
None,
|
||||||
0,
|
0,
|
||||||
|
None,
|
||||||
"control-test",
|
"control-test",
|
||||||
1,
|
1,
|
||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
StartupTimings::default(),
|
StartupTimings::default(),
|
||||||
);
|
);
|
||||||
app.material_time_ms = 225;
|
app.free_camera = Some(free_camera);
|
||||||
app.update_controlled_component_geometry()
|
app.pressed_keys.insert(KeyCode::KeyW);
|
||||||
.expect("update projected component and caster");
|
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.indices, initial_mesh.indices);
|
||||||
assert_eq!(app.mesh.vertices.len(), initial_mesh.vertices.len());
|
assert_eq!(app.mesh.vertices.len(), initial_mesh.vertices.len());
|
||||||
@@ -7178,14 +7459,41 @@ mod tests {
|
|||||||
assert_ne!(b_triangles, initial_triangles);
|
assert_ne!(b_triangles, initial_triangles);
|
||||||
assert!(b_sphere.radius > baseline.radius);
|
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");
|
.expect("repeat the same control timestamp");
|
||||||
assert_eq!(app.mesh.vertices, b_vertices);
|
assert_eq!(app.mesh.vertices, b_vertices);
|
||||||
assert_eq!(app.shadow_casters[0].triangles, b_triangles);
|
assert_eq!(app.shadow_casters[0].triangles, b_triangles);
|
||||||
assert_eq!(app.shadow_casters[0].sphere, b_sphere);
|
assert_eq!(app.shadow_casters[0].sphere, b_sphere);
|
||||||
|
|
||||||
app.material_time_ms = 0;
|
let first_sample = FrameClockSample {
|
||||||
app.update_controlled_component_geometry()
|
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");
|
.expect("return to first control pose");
|
||||||
assert_eq!(app.mesh.vertices, initial_mesh.vertices);
|
assert_eq!(app.mesh.vertices, initial_mesh.vertices);
|
||||||
assert_eq!(app.shadow_casters[0].triangles, initial_triangles);
|
assert_eq!(app.shadow_casters[0].triangles, initial_triangles);
|
||||||
@@ -7210,6 +7518,28 @@ mod tests {
|
|||||||
values.iter().map(|value| (*value).to_string()).collect()
|
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]
|
#[test]
|
||||||
#[ignore = "requires licensed corpus"]
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_part1_prepares_every_mission() {
|
fn licensed_corpus_part1_prepares_every_mission() {
|
||||||
@@ -7597,6 +7927,7 @@ mod tests {
|
|||||||
mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
||||||
frames: 3,
|
frames: 3,
|
||||||
atmosphere_seconds: None,
|
atmosphere_seconds: None,
|
||||||
|
control_clock_ms: None,
|
||||||
validation: false,
|
validation: false,
|
||||||
readback_out: None,
|
readback_out: None,
|
||||||
preview_roots: NonZeroUsize::MAX,
|
preview_roots: NonZeroUsize::MAX,
|
||||||
@@ -7619,6 +7950,60 @@ mod tests {
|
|||||||
assert_eq!(parsed.frames, 0);
|
assert_eq!(parsed.frames, 0);
|
||||||
assert_eq!(parsed.preview_roots, NonZeroUsize::MAX);
|
assert_eq!(parsed.preview_roots, NonZeroUsize::MAX);
|
||||||
assert_eq!(parsed.static_animation_frame, None);
|
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]
|
#[test]
|
||||||
@@ -7716,15 +8101,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn free_flight_camera_uses_native_horizontal_fov_for_every_viewport_aspect() {
|
fn free_flight_camera_uses_native_horizontal_fov_for_every_viewport_aspect() {
|
||||||
let horizontal_fov = 1.3_f32;
|
let horizontal_fov = 1.3_f32;
|
||||||
let camera = FreeFlightCamera::from_bounds([0.0; 3], [100.0; 3])
|
let camera =
|
||||||
.expect("finite free-flight bounds");
|
FreeFlightCamera::from_bounds([0.0; 3], [100.0; 3]).expect("finite free-flight bounds");
|
||||||
assert_eq!(camera.horizontal_fov, horizontal_fov);
|
assert_eq!(camera.horizontal_fov, horizontal_fov);
|
||||||
assert_eq!(camera.near_plane, 0.5);
|
assert_eq!(camera.near_plane, 0.5);
|
||||||
assert_eq!(camera.far_plane, 700.0);
|
assert_eq!(camera.far_plane, 700.0);
|
||||||
|
|
||||||
let assert_edges = |frame: PreviewCameraFrame, aspect: f32| {
|
let assert_edges = |frame: PreviewCameraFrame, aspect: f32| {
|
||||||
let expected_vertical_fov =
|
let expected_vertical_fov = 2.0 * ((horizontal_fov * 0.5).tan() / aspect).atan();
|
||||||
2.0 * ((horizontal_fov * 0.5).tan() / aspect).atan();
|
|
||||||
assert!((frame.vertical_fov - expected_vertical_fov).abs() < 1.0e-5);
|
assert!((frame.vertical_fov - expected_vertical_fov).abs() < 1.0e-5);
|
||||||
let depth = 10.0;
|
let depth = 10.0;
|
||||||
let center = add3(frame.position, scale3(frame.forward, depth));
|
let center = add3(frame.position, scale3(frame.forward, depth));
|
||||||
@@ -7749,8 +8133,14 @@ mod tests {
|
|||||||
frame.clip_from_world,
|
frame.clip_from_world,
|
||||||
);
|
);
|
||||||
assert!(center_clip[3] > 0.0 && right_clip[3] > 0.0 && top_clip[3] > 0.0);
|
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!(
|
||||||
assert!(center_clip[1].abs() < 5.0e-4, "aspect {aspect}: {center_clip:?}");
|
center_clip[0].abs() < 5.0e-4,
|
||||||
|
"aspect {aspect}: {center_clip:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
center_clip[1].abs() < 5.0e-4,
|
||||||
|
"aspect {aspect}: {center_clip:?}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
(right_clip[0] / right_clip[3] - 1.0).abs() < 1.0e-4,
|
(right_clip[0] / right_clip[3] - 1.0).abs() < 1.0e-4,
|
||||||
"aspect {aspect}: {right_clip:?}"
|
"aspect {aspect}: {right_clip:?}"
|
||||||
@@ -7792,7 +8182,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let captured = PreviewCameraFrame::from_legacy_d3d7(raw_transform, projection)
|
let captured = PreviewCameraFrame::from_legacy_d3d7(raw_transform, projection)
|
||||||
.expect("valid D3D7 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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-6
@@ -1035,15 +1035,25 @@ WEAR и MAT0 до объединения геометрии. Индексы GPU
|
|||||||
animation phase; object batches сохраняют их legacy blend/depth/alpha state.
|
animation phase; object batches сохраняют их legacy blend/depth/alpha state.
|
||||||
|
|
||||||
После загрузки world path создаёт интерактивную free-flight камеру (`WASD`,
|
После загрузки world path создаёт интерактивную free-flight камеру (`WASD`,
|
||||||
`E/Q`, `Shift`, `Ctrl`, RMB relative-look), обновляет listener и на каждом
|
`E/Q`, `Shift`, `Ctrl`, RMB relative-look), обновляет listener и собирает
|
||||||
кадре собирает environment frame. После шага движения камера проверяет высоту
|
environment frame для каждого подготовленного кадра. Event loop один раз
|
||||||
|
сэмплирует камеру, общий animation clock, геометрию и окружение перед запросом
|
||||||
|
redraw; redraw потребляет это состояние. Если surface требует пересоздать
|
||||||
|
swapchain, тот же sample времени и состояние мира отправляются повторно.
|
||||||
|
Перед повторной отправкой projection камеры и viewport-зависимые environment
|
||||||
|
и shadow geometry обновляются для нового размера без продвижения сцены.
|
||||||
|
Счётчик и replay clock продвигаются только после результата `Presented`.
|
||||||
|
После шага движения камера проверяет высоту
|
||||||
`TerrainWorld`: над конечной поверхностью сохраняется начальный просвет, а вне
|
`TerrainWorld`: над конечной поверхностью сохраняется начальный просвет, а вне
|
||||||
поверхности высота не ограничивается. Mission-local `sky.ske`/`sky.wea` выбирают
|
поверхности высота не ограничивается. Mission-local `sky.ske`/`sky.wea` выбирают
|
||||||
атмосферное расписание и sky materials, `effects.rlb/env_lightning` даёт
|
атмосферное расписание и sky materials, `effects.rlb/env_lightning` даёт
|
||||||
lightning visual; weather particles, flares, sun/moon, point lights,
|
lightning visual; weather particles, flares, sun/moon, point lights,
|
||||||
projected shadows и audio events обновляются вместе с камерой и временем.
|
projected shadows и audio events обновляются вместе с камерой и временем.
|
||||||
`--atmosphere-seconds` задаёт старт времени, `--frames 0` оставляет цикл
|
`--atmosphere-seconds` задаёт старт времени, `--frames 0` оставляет цикл
|
||||||
бесконечным, а `--preview-roots` служит только диагностическим ограничителем.
|
бесконечным, `--control-clock-ms N` воспроизводит общий clock анимации MSH и
|
||||||
|
материалов вместе с окружением от нуля до `N` за заданное число кадров и требует
|
||||||
|
как минимум два кадра.
|
||||||
|
`--preview-roots` служит только диагностическим ограничителем.
|
||||||
`--legacy-camera-capture` выбирает воспроизводимую камеру без free-flight
|
`--legacy-camera-capture` выбирает воспроизводимую камеру без free-flight
|
||||||
управления: запись selector 0 задаёт D3D7 transform, clip параметры и native
|
управления: запись selector 0 задаёт D3D7 transform, clip параметры и native
|
||||||
viewport RECT `[left, top, right, bottom]`. Путь сохраняет оси basis из view
|
viewport RECT `[left, top, right, bottom]`. Путь сохраняет оси basis из view
|
||||||
@@ -1052,14 +1062,15 @@ matrix вместе с roll, переносит полученный `clip_from_
|
|||||||
и отклоняет несовпадение размера при
|
и отклоняет несовпадение размера при
|
||||||
создании или resize вместо растягивания кадра. Результат нужно сравнивать с
|
создании или resize вместо растягивания кадра. Результат нужно сравнивать с
|
||||||
тем же crop исходного PNG `[left, top, right, bottom]`. Контроллер
|
тем же crop исходного PNG `[left, top, right, bottom]`. Контроллер
|
||||||
`FreeFlightCamera` в этом режиме не создаётся, но redraw продолжает обновлять
|
`FreeFlightCamera` в этом режиме не создаётся. Подготовка кадра обновляет
|
||||||
environment и projected shadows от captured camera. Перед environment update и
|
environment и projected shadows от captured camera. Перед environment update и
|
||||||
его audio events listener получает position, forward и up captured camera;
|
его audio events listener получает position, forward и up captured camera;
|
||||||
начальная сортировка world draw использует её же position. Phase capture может
|
начальная сортировка world draw использует её же position. Phase capture может
|
||||||
содержать наблюдаемый mission path и проверенный `atmosphere_seconds`; viewer
|
содержать наблюдаемый mission path и проверенный `atmosphere_seconds`; viewer
|
||||||
использует эту абсолютную фазу по умолчанию, если не указан явный CLI override,
|
использует эту абсолютную фазу по умолчанию, если не указан явный CLI override,
|
||||||
и удерживает sample атмосферы на ней через readback frames. FX по-прежнему
|
и удерживает sample атмосферы на ней через readback frames. Без
|
||||||
получает реальный frame time. Это не полное simulation time: `weather`, RNG и
|
`--control-clock-ms` FX получает реальный frame time; с параметром environment
|
||||||
|
и материалы используют replay clock. Это не полное simulation time: `weather`, RNG и
|
||||||
остальное mission state остаются неизвестными, поэтому совпадение камеры и
|
остальное mission state остаются неизвестными, поэтому совпадение камеры и
|
||||||
фазы не доказывает visual parity с native frame.
|
фазы не доказывает visual parity с native frame.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user