Centralize world and viewport game loops
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled

This commit is contained in:
2026-08-23 13:42:01 +02:00
parent cb9233c59c
commit 33d1cf6827
18 changed files with 2541 additions and 287 deletions

View File

@@ -159,12 +159,27 @@ async fn live_primary_map_name_reports_varregion_dimensions() -> Result<(), Box<
async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Error>> {
let environment = live_environment()?;
let renderer_owner = LibremetaverseClientOwner::new()?;
let (mut renderer_session, renderer_cancel) =
login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
let limits = VisionLimits {
minimum_interval: Duration::ZERO,
..VisionLimits::default()
};
let (_world_events, world_receiver) =
metacrate_game_loop::LibreMetaverseWorldEvents::subscribe_visible(
renderer_owner.client(),
renderer_owner.agent(),
limits.max_distance_meters,
4_096,
)
.ok_or("live world event subscription failed")?;
let world_loop = metacrate_game_loop::start(
metacrate_game_loop::LoopConfig::default(),
world_receiver,
(),
None,
)
.ok_or("live world loop failed to start")?;
let (mut renderer_session, renderer_cancel) =
login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
let scene_source = Arc::new(LibremetaverseSceneSource::new(&renderer_owner, limits));
let prefetch_started = Instant::now();
scene_source.start_prefetch();
@@ -183,6 +198,27 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
renderer_simulator.name, region_size.0, region_size.1
);
wait_scene_settled(&renderer_owner).await?;
tokio::time::timeout(Duration::from_secs(10), async {
while world_loop
.latest_world()
.is_none_or(|world| world.value.region.is_none())
{
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.map_err(|_| "central world state did not become ready")?;
let world = world_loop
.latest_world()
.ok_or("central world snapshot disappeared")?;
println!(
"LIVE_WORLD_STATE=sequence:{},primitives:{},avatars:{},terrain_patches:{},dropped_events:{}",
world.sequence,
world.value.primitives.len(),
world.value.avatars.len(),
world.value.terrain.len(),
world_loop.dropped_world_events(),
);
let view_forward = Vector3::mul_with_vector3_quaternion(
Vector3::unit_x(),
renderer_owner.agent().sim_rotation(),
@@ -273,6 +309,31 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
}
}
let capture = capture.ok_or("live renderer produced no frame")?;
let viewport_before = vision
.viewport_stats()
.ok_or("viewport loop did not expose statistics")?;
let soak_started = Instant::now();
tokio::time::sleep(Duration::from_secs(2)).await;
let soak_elapsed = soak_started.elapsed();
let viewport_after = vision
.viewport_stats()
.ok_or("viewport loop stopped exposing statistics")?;
let soak_frames = viewport_after
.completed_frames
.saturating_sub(viewport_before.completed_frames);
let soak_missed = viewport_after
.missed_frame_deadlines
.saturating_sub(viewport_before.missed_frame_deadlines);
let soak_fps = f64::from(u32::try_from(soak_frames)?) / soak_elapsed.as_secs_f64();
println!(
"LIVE_VIEWPORT_SOAK=frames:{soak_frames},elapsed_ms:{},fps:{soak_fps:.2},missed_deadlines:{soak_missed},render_errors:{},last_render_ms:{}",
soak_elapsed.as_millis(),
viewport_after.render_errors,
viewport_after.last_render.as_millis(),
);
if soak_fps < 10.0 || soak_missed != 0 || viewport_after.render_errors != 0 {
return Err("stable viewport did not sustain its 10 Hz target".into());
}
profile_warm_capture(&scene_source, limits, renderer_cancel.token()).await?;
println!("LIVE_RENDER_OUTPUT={}", output.display());
println!("LIVE_RENDER_SHA256={}", capture.image_sha256);
@@ -281,10 +342,14 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
.await;
scene_source.stop_prefetch().await;
world_loop
.shutdown()
.map_err(|_| "world loop thread panicked")?;
let _ = renderer_session.logout(renderer_cancel.token()).await;
result
}
#[allow(clippy::too_many_lines)] // One ordered live timing record keeps phase boundaries explicit.
async fn profile_warm_capture(
source: &LibremetaverseSceneSource,
limits: VisionLimits,
@@ -311,9 +376,10 @@ async fn profile_warm_capture(
.into_iter()
.map(|(_, renderable)| renderable)
.collect::<Vec<_>>();
let renderables: Arc<[metacrate_rendering_wgpu::Renderable]> = renderables.into();
let preparation = preparation_started.elapsed();
let renderer_started = Instant::now();
let renderer = metacrate_rendering_wgpu::Renderer::new_blocking()?;
let renderer = metacrate_game_loop::ViewportRuntime::new_blocking()?;
let renderer_initialization = renderer_started.elapsed();
let render_limits = metacrate_rendering_wgpu::RenderLimits {
width: limits.width,
@@ -323,24 +389,38 @@ async fn profile_warm_capture(
max_texture_pixels: limits.max_decode_pixels,
far_distance: 512,
};
let first = renderer.render_profiled(
let first = renderer.render_scene(
scene.scene_sequence,
scene.camera,
&renderables,
&scene.textures,
|| Arc::clone(&renderables),
Arc::clone(&scene.textures),
[125, 149, 173, 255],
render_limits,
)?;
let warm_started = Instant::now();
let warm = renderer.render_profiled(
let refresh_started = Instant::now();
let refresh = renderer.render_scene(
scene.scene_sequence.saturating_add(1),
scene.camera,
&renderables,
&scene.textures,
|| Arc::clone(&renderables),
Arc::clone(&scene.textures),
[125, 149, 173, 255],
render_limits,
)?;
let refresh_total = refresh_started.elapsed();
let warm_started = Instant::now();
let warm = renderer.render_scene(
scene.scene_sequence.saturating_add(1),
scene.camera,
|| panic!("unchanged scene was transferred to the renderer again"),
Arc::clone(&scene.textures),
[125, 149, 173, 255],
render_limits,
)?;
let warm_render_total = warm_started.elapsed();
let jpeg_started = Instant::now();
let rgb = warm
.frame
.value
.rgba
.chunks_exact(4)
.flat_map(|pixel| pixel[..3].iter().copied())
@@ -354,7 +434,7 @@ async fn profile_warm_capture(
)?;
let jpeg_encode = jpeg_started.elapsed();
println!(
"LIVE_RENDER_PROFILE=scene_total_ms:{},terrain_wait_ms:{},object_snapshot_and_asset_discovery_ms:{},material_fetch_ms:{},asset_fetch_ms:{},decode_and_geometry_ms:{},renderable_collection_and_sort_ms:{},renderer_initialization_ms:{},first_render_total_ms:{},first_render:{:?},warm_render_total_ms:{},warm_render:{:?},jpeg_encode_ms:{},profile_total_ms:{}",
"LIVE_RENDER_PROFILE=scene_total_ms:{},terrain_wait_ms:{},object_snapshot_and_asset_discovery_ms:{},material_fetch_ms:{},asset_fetch_ms:{},decode_and_geometry_ms:{},renderable_collection_and_sort_ms:{},renderer_initialization_ms:{},first_render_total_ms:{},first_render:{:?},scene_refresh_total_ms:{},scene_refresh:{:?},warm_render_total_ms:{},warm_render:{:?},jpeg_encode_ms:{},profile_total_ms:{}",
scene_total.as_millis(),
scene.timings.terrain_wait.as_millis(),
scene
@@ -368,11 +448,16 @@ async fn profile_warm_capture(
renderer_initialization.as_millis(),
render_total(&first.timings).as_millis(),
first.timings,
refresh_total.as_millis(),
refresh.timings,
warm_render_total.as_millis(),
warm.timings,
jpeg_encode.as_millis(),
total_started.elapsed().as_millis(),
);
if refresh_total >= metacrate_game_loop::DEFAULT_VIEWPORT_FRAME_INTERVAL {
return Err("cached scene refresh exceeded the viewport frame interval".into());
}
Ok(())
}
@@ -400,8 +485,7 @@ impl SceneSource for DiagnosticSceneSource {
Box::pin(async move {
let mut scene = self.inner.capture_scene(generation, cancellation).await?;
if let Some(material_override) = &self.material_override {
for renderable in scene
.entities
for renderable in Arc::make_mut(&mut scene.entities)
.iter_mut()
.filter(|entity| entity.kind == SceneEntityKind::Object)
.flat_map(|entity| &mut entity.renderables)