fix(render): refresh viewport after swapchain recreation
Docs Deploy / Build and Deploy MkDocs (push) Successful in 45s

This commit is contained in:
Valentin Popov committed 2026-10-19 22:59:36 +04:00
1 parent 84ebd259ea
commit bf1f330379
3 files changed
+562 -150

No files matched your search

+11 -3
View File
@@ -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`; для узлов без такой настройки
+534 -141
View File
@@ -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,63 +6063,103 @@ 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],
) -> 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( {
|| { let renderer = self
self.window .renderer
.as_ref() .as_mut()
.map(|window| { .ok_or_else(|| "native Vulkan renderer was not initialized".to_string())?;
let size = window.inner_size(); Self::update_world_transparency(
[size.width.max(1) as f32, size.height.max(1) as f32] &self.mesh,
}) &self.materials,
.unwrap_or([1280.0, 720.0]) &self.world_range_indices,
}, &self.world_range_index_counts,
|viewport| [viewport.extent[0] as f32, viewport.extent[1] as f32], &self.native_handedness_by_range,
); self.view_camera.position,
let aspect = viewport[0] / viewport[1].max(1.0); &active_materials,
let now = Instant::now(); renderer,
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 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; return;
} }
self.update_material_clock(); let drawable_available = self.window.as_ref().is_some_and(|window| {
if let Err(error) = self.update_controlled_component_geometry() { let size = window.inner_size();
self.error = Some(error); size.width != 0 && size.height != 0
event_loop.exit(); });
return; let outcome = {
} let Some(renderer) = self.renderer.as_mut() else {
if let Err(error) = self.update_environment() { self.error = Some("native Vulkan renderer was not initialized".to_string());
self.error = Some(error); event_loop.exit();
event_loop.exit(); return;
return; };
} renderer.draw_frame()
let Some(renderer) = self.renderer.as_mut() else {
self.error = Some("native Vulkan renderer was not initialized".to_string());
event_loop.exit();
return;
}; };
match 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
View File
@@ -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.