diff --git a/crates/metacrate-grid-agent/src/lib.rs b/crates/metacrate-grid-agent/src/lib.rs index 1e7f206..f0fcc2e 100644 --- a/crates/metacrate-grid-agent/src/lib.rs +++ b/crates/metacrate-grid-agent/src/lib.rs @@ -183,8 +183,8 @@ pub use types::{ #[cfg(feature = "live-grid")] pub use vision::LibremetaverseSceneSource; pub use vision::{ - CameraPose, SceneEntity, SceneEntityKind, SceneSnapshot, SceneSource, SceneTriangle, - SnapshotCompleteness, VisionAugmentedResponder, VisionCapture, VisionControl, VisionError, - VisionFuture, VisionLimits, VisionObservation, VisionObservationKind, VisionService, - visual_question, + CameraPose, SceneBuildTimings, SceneEntity, SceneEntityKind, SceneSnapshot, SceneSource, + SceneTriangle, SnapshotCompleteness, VisionAugmentedResponder, VisionCapture, VisionControl, + VisionError, VisionFuture, VisionLimits, VisionObservation, VisionObservationKind, + VisionService, visual_question, }; diff --git a/crates/metacrate-grid-agent/src/main.rs b/crates/metacrate-grid-agent/src/main.rs index 424d377..71c22bc 100644 --- a/crates/metacrate-grid-agent/src/main.rs +++ b/crates/metacrate-grid-agent/src/main.rs @@ -284,7 +284,7 @@ async fn run_live( let owner = LibremetaverseClientOwner::new_with_asset_cache_max_bytes( config.vision.asset_cache_max_bytes, )?; - let mut live = start_live_interactions(&config, &owner)?; + let mut live = start_live_interactions(&config, &owner).await?; let backend = match owner.session_backend_with_agent_services( connection, live.interaction.ingress(), @@ -612,7 +612,7 @@ struct LiveInteractions { #[cfg(feature = "live-grid")] #[allow(clippy::too_many_lines)] -fn start_live_interactions( +async fn start_live_interactions( config: &metacrate_grid_agent::AgentConfig, owner: &metacrate_grid_agent::LibremetaverseClientOwner, ) -> Result> { @@ -751,6 +751,9 @@ fn start_live_interactions( scene_source.start_prefetch(); let vision = Arc::new(VisionService::new(Arc::clone(&scene_source), vision_limits)?.prefer_gpu()); + if !vision.initialize_renderer().await { + eprintln!("WARNING: wgpu renderer unavailable; visual captures use software fallback"); + } let responder = Arc::new(VisionAugmentedResponder::new(vision.clone(), responder)); let sink = Arc::new(owner.interaction_sink()); let pacer: Arc = Arc::new(behavior_ingress); diff --git a/crates/metacrate-grid-agent/src/vision.rs b/crates/metacrate-grid-agent/src/vision.rs index 30cc628..e252513 100644 --- a/crates/metacrate-grid-agent/src/vision.rs +++ b/crates/metacrate-grid-agent/src/vision.rs @@ -60,6 +60,15 @@ pub struct SnapshotCompleteness { pub terrain_available: bool, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SceneBuildTimings { + pub terrain_wait: Duration, + pub object_snapshot_and_asset_discovery: Duration, + pub material_fetch: Duration, + pub asset_fetch: Duration, + pub decode_and_geometry: Duration, +} + #[derive(Clone, Debug, PartialEq)] pub struct SceneSnapshot { pub generation: u64, @@ -74,6 +83,7 @@ pub struct SceneSnapshot { pub texture_fetches: usize, pub texture_bytes: usize, pub decoded_texture_pixels: usize, + pub timings: SceneBuildTimings, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -258,6 +268,26 @@ impl VisionService { self.gpu_preferred = true; self } + + /// Completes renderer discovery before the live agent is allowed to become active. + #[cfg(feature = "live-grid")] + pub async fn initialize_renderer(&self) -> bool { + !self.gpu_preferred || self.renderer().await.is_some() + } + + #[cfg(feature = "live-grid")] + async fn renderer(&self) -> Option> { + self.gpu + .get_or_init(|| async { + tokio::task::spawn_blocking(bevy_renderer_new) + .await + .ok() + .and_then(Result::ok) + .map(Arc::new) + }) + .await + .clone() + } pub fn set_generation(&self, generation: u64) { if self.current_generation.swap(generation, Ordering::AcqRel) != generation { self.cancel_active(); @@ -396,71 +426,58 @@ impl VisionService { async fn render_scene(&self, scene: SceneSnapshot) -> Result { #[cfg(feature = "live-grid")] - if self.gpu_preferred { - let renderer = self - .gpu - .get_or_init(|| async { - tokio::task::spawn_blocking(bevy_renderer_new) - .await - .ok() - .and_then(Result::ok) - .map(Arc::new) + if self.gpu_preferred + && let Some(renderer) = self.renderer().await + { + let mut renderables = scene + .entities + .iter() + .flat_map(|entity| { + entity + .renderables + .iter() + .cloned() + .map(move |renderable| (entity.id.to_string(), renderable)) }) - .await - .clone(); - if let Some(renderer) = renderer { - let mut renderables = scene - .entities - .iter() - .flat_map(|entity| { - entity - .renderables - .iter() - .cloned() - .map(move |renderable| (entity.id.to_string(), renderable)) + .collect::>(); + renderables.sort_by(|left, right| left.0.cmp(&right.0)); + let renderables = renderables + .into_iter() + .map(|(_, renderable)| renderable) + .collect::>(); + let gpu_scene = scene.clone(); + let limits = self.limits; + if let Ok(Ok(capture)) = tokio::task::spawn_blocking(move || { + renderer + .render( + gpu_scene.camera, + &renderables, + &gpu_scene.textures, + environment_color(&gpu_scene), + metacrate_rendering_wgpu::RenderLimits { + width: limits.width, + height: limits.height, + max_triangles: limits.max_triangles, + max_texture_bytes: limits.max_texture_bytes, + max_texture_pixels: limits.max_decode_pixels, + far_distance: 512, + }, + ) + .map_err(|error| match error { + metacrate_rendering_wgpu::RenderError::InvalidScene => { + VisionError::InvalidScene + } + metacrate_rendering_wgpu::RenderError::ResourceLimit => { + VisionError::ResourceLimit + } + metacrate_rendering_wgpu::RenderError::TimedOut => VisionError::TimedOut, + _ => VisionError::Encode, }) - .collect::>(); - renderables.sort_by(|left, right| left.0.cmp(&right.0)); - let renderables = renderables - .into_iter() - .map(|(_, renderable)| renderable) - .collect::>(); - let gpu_scene = scene.clone(); - let limits = self.limits; - if let Ok(Ok(capture)) = tokio::task::spawn_blocking(move || { - renderer - .render( - gpu_scene.camera, - &renderables, - &gpu_scene.textures, - environment_color(&gpu_scene), - metacrate_rendering_wgpu::RenderLimits { - width: limits.width, - height: limits.height, - max_triangles: limits.max_triangles, - max_texture_bytes: limits.max_texture_bytes, - max_texture_pixels: limits.max_decode_pixels, - far_distance: 512, - }, - ) - .map_err(|error| match error { - metacrate_rendering_wgpu::RenderError::InvalidScene => { - VisionError::InvalidScene - } - metacrate_rendering_wgpu::RenderError::ResourceLimit => { - VisionError::ResourceLimit - } - metacrate_rendering_wgpu::RenderError::TimedOut => { - VisionError::TimedOut - } - _ => VisionError::Encode, - }) - .and_then(|rgba| finish_capture(&gpu_scene, limits, &rgba)) - }) - .await - { - return Ok(capture); - } + .and_then(|rgba| finish_capture(&gpu_scene, limits, &rgba)) + }) + .await + { + return Ok(capture); } } render_scene_software(&scene, self.limits) @@ -1058,6 +1075,9 @@ async fn request_asset_bytes( asset_type: libremetaverse_types::AssetType, cancellation: CancellationToken, ) -> Option> { + if let Ok(Some(bytes)) = assets.cache.get_cached_asset_bytes_with_uuid(id) { + return Some(bytes); + } tokio::time::timeout(Duration::from_secs(8), async { match asset_type { libremetaverse_types::AssetType::Texture => { @@ -1095,6 +1115,7 @@ impl SceneSource for LibremetaverseSceneSource { cancellation: CancellationToken, ) -> VisionFuture<'_, SceneSnapshot> { Box::pin(async move { + let build_started = Instant::now(); if cancellation.is_cancellation_requested() { return Err(VisionError::Cancelled); } @@ -1122,6 +1143,8 @@ impl SceneSource for LibremetaverseSceneSource { () = tokio::time::sleep(Duration::from_millis(100)) => {} } } + let terrain_wait = build_started.elapsed(); + let discovery_started = Instant::now(); let camera = &self.agent.movement.camera; let position = if generation == 0 { self.agent.sim_position() @@ -1340,6 +1363,8 @@ impl SceneSource for LibremetaverseSceneSource { ids }); let assets = self.client.assets(); + let object_snapshot_and_asset_discovery = discovery_started.elapsed(); + let material_started = Instant::now(); let missing_legacy_materials = { let cache = lock(&self.cache); legacy_material_ids @@ -1463,6 +1488,8 @@ impl SceneSource for LibremetaverseSceneSource { }); } } + let material_fetch = material_started.elapsed(); + let asset_fetch_started = Instant::now(); texture_ids.retain(|id| *id != UUID::zero()); let scene_texture_ids = texture_ids.clone(); { @@ -1552,6 +1579,8 @@ impl SceneSource for LibremetaverseSceneSource { }); } } + let asset_fetch = asset_fetch_started.elapsed(); + let conversion_started = Instant::now(); let maximum = self.limits.max_triangles; let decode_pixels = self.limits.max_decode_pixels; let decode_bytes = self.limits.max_texture_bytes; @@ -1581,6 +1610,7 @@ impl SceneSource for LibremetaverseSceneSource { }) .await .map_err(|_| VisionError::InvalidScene)??; + let decode_and_geometry = conversion_started.elapsed(); if cancellation.is_cancellation_requested() { return Err(VisionError::Cancelled); } @@ -1618,6 +1648,13 @@ impl SceneSource for LibremetaverseSceneSource { texture_fetches, texture_bytes, decoded_texture_pixels, + timings: SceneBuildTimings { + terrain_wait, + object_snapshot_and_asset_discovery, + material_fetch, + asset_fetch, + decode_and_geometry, + }, }) }) } diff --git a/crates/metacrate-grid-agent/src/vision_tests.rs b/crates/metacrate-grid-agent/src/vision_tests.rs index b7fdec9..e0cb1b8 100644 --- a/crates/metacrate-grid-agent/src/vision_tests.rs +++ b/crates/metacrate-grid-agent/src/vision_tests.rs @@ -399,6 +399,7 @@ fn scene() -> SceneSnapshot { texture_fetches: 1, texture_bytes: 128, decoded_texture_pixels: 16, + timings: SceneBuildTimings::default(), } } diff --git a/crates/metacrate-grid-agent/tests/live_renderer.rs b/crates/metacrate-grid-agent/tests/live_renderer.rs index 5d7ecc1..b9429ca 100644 --- a/crates/metacrate-grid-agent/tests/live_renderer.rs +++ b/crates/metacrate-grid-agent/tests/live_renderer.rs @@ -50,6 +50,56 @@ async fn live_primary_login_resolves_varregion_dimensions() -> Result<(), Box Result<(), Box> { + let total_started = Instant::now(); + let environment = live_environment()?; + let owner_started = Instant::now(); + let owner = LibremetaverseClientOwner::new()?; + let owner_initialization = owner_started.elapsed(); + let gpu_task = tokio::task::spawn_blocking(|| { + let started = Instant::now(); + ( + metacrate_rendering_wgpu::Renderer::new_blocking(), + started.elapsed(), + ) + }); + let login_started = Instant::now(); + let (mut session, cancellation) = + login(&owner, &environment, "GRID_USER", "GRID_PASSWORD").await?; + let login = login_started.elapsed(); + let ready_started = Instant::now(); + wait_ready("primary", &mut session, &cancellation).await?; + let readiness = ready_started.elapsed(); + let settle_started = Instant::now(); + wait_scene_settled(&owner).await?; + let scene_settle = settle_started.elapsed(); + let limits = VisionLimits { + minimum_interval: Duration::ZERO, + ..VisionLimits::default() + }; + let source = LibremetaverseSceneSource::new(&owner, limits); + let warmup_started = Instant::now(); + let scene = source.capture_scene(0, cancellation.token()).await?; + let scene_warmup = warmup_started.elapsed(); + let (renderer, gpu_initialization) = gpu_task.await?; + renderer?; + println!( + "LIVE_STARTUP_PROFILE=owner_initialization_ms:{},grid_login_ms:{},grid_readiness_ms:{},scene_settle_ms:{},gpu_initialization_parallel_ms:{},first_scene_warmup_ms:{},first_scene:{:?},total_ms:{}", + owner_initialization.as_millis(), + login.as_millis(), + readiness.as_millis(), + scene_settle.as_millis(), + gpu_initialization.as_millis(), + scene_warmup.as_millis(), + scene.timings, + total_started.elapsed().as_millis(), + ); + let _ = session.logout(cancellation.token()).await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore = "records the primary region's asynchronous map-name reply"] async fn live_primary_map_name_reports_varregion_dimensions() -> Result<(), Box> { @@ -175,6 +225,14 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box Result<(), Box>(()) @@ -226,6 +285,107 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box Result<(), Box> { + let total_started = Instant::now(); + let scene_started = Instant::now(); + let scene = source.capture_scene(1, cancellation).await?; + let scene_total = scene_started.elapsed(); + let preparation_started = Instant::now(); + let mut renderables = scene + .entities + .iter() + .flat_map(|entity| { + entity + .renderables + .iter() + .cloned() + .map(move |renderable| (entity.id.to_string(), renderable)) + }) + .collect::>(); + renderables.sort_by(|left, right| left.0.cmp(&right.0)); + let renderables = renderables + .into_iter() + .map(|(_, renderable)| renderable) + .collect::>(); + let preparation = preparation_started.elapsed(); + let renderer_started = Instant::now(); + let renderer = metacrate_rendering_wgpu::Renderer::new_blocking()?; + let renderer_initialization = renderer_started.elapsed(); + let render_limits = metacrate_rendering_wgpu::RenderLimits { + width: limits.width, + height: limits.height, + max_triangles: limits.max_triangles, + max_texture_bytes: limits.max_texture_bytes, + max_texture_pixels: limits.max_decode_pixels, + far_distance: 512, + }; + let first = renderer.render_profiled( + scene.camera, + &renderables, + &scene.textures, + [125, 149, 173, 255], + render_limits, + )?; + let warm_started = Instant::now(); + let warm = renderer.render_profiled( + scene.camera, + &renderables, + &scene.textures, + [125, 149, 173, 255], + render_limits, + )?; + let warm_render_total = warm_started.elapsed(); + let jpeg_started = Instant::now(); + let rgb = warm + .rgba + .chunks_exact(4) + .flat_map(|pixel| pixel[..3].iter().copied()) + .collect::>(); + let mut jpeg = Vec::new(); + jpeg_encoder::Encoder::new(&mut jpeg, 92).encode( + &rgb, + u16::try_from(limits.width)?, + u16::try_from(limits.height)?, + jpeg_encoder::ColorType::Rgb, + )?; + 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:{}", + scene_total.as_millis(), + scene.timings.terrain_wait.as_millis(), + scene + .timings + .object_snapshot_and_asset_discovery + .as_millis(), + scene.timings.material_fetch.as_millis(), + scene.timings.asset_fetch.as_millis(), + scene.timings.decode_and_geometry.as_millis(), + preparation.as_millis(), + renderer_initialization.as_millis(), + render_total(&first.timings).as_millis(), + first.timings, + warm_render_total.as_millis(), + warm.timings, + jpeg_encode.as_millis(), + total_started.elapsed().as_millis(), + ); + Ok(()) +} + +fn render_total(timings: &metacrate_rendering_wgpu::RenderTimings) -> Duration { + timings.validation + + timings.command_copy + + timings.clear_previous_frame + + timings.texture_cache_sync + + timings.mesh_cache_sync + + timings.scene_setup + + timings.capture +} + struct DiagnosticSceneSource { inner: Arc, material_override: Option, diff --git a/crates/metacrate-rendering-wgpu/src/gpu.rs b/crates/metacrate-rendering-wgpu/src/gpu.rs index 5d1123a..f85e6ec 100644 --- a/crates/metacrate-rendering-wgpu/src/gpu.rs +++ b/crates/metacrate-rendering-wgpu/src/gpu.rs @@ -30,7 +30,7 @@ use std::{ marker::PhantomData, sync::{Mutex, mpsc}, thread, - time::Duration, + time::{Duration, Instant}, }; const WORLD_AMBIENT_BRIGHTNESS: f32 = 400.0; @@ -43,11 +43,30 @@ enum RenderCommand { textures: Vec, background_srgb: [u8; 4], limits: RenderLimits, - reply: mpsc::SyncSender, RenderError>>, + reply: mpsc::SyncSender>, }, Stop, } +/// Wall-clock costs of one renderer call. GPU work and readback are included in +/// `capture`; the fields before it are CPU-side preparation. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct RenderTimings { + pub validation: Duration, + pub command_copy: Duration, + pub clear_previous_frame: Duration, + pub texture_cache_sync: Duration, + pub mesh_cache_sync: Duration, + pub scene_setup: Duration, + pub capture: Duration, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RenderedFrame { + pub rgba: Vec, + pub timings: RenderTimings, +} + /// Reusable renderer facade. Bevy and its ECS stay on a dedicated OS thread. pub struct Renderer { commands: mpsc::SyncSender, @@ -110,26 +129,46 @@ impl Renderer { background_srgb: [u8; 4], limits: RenderLimits, ) -> Result, RenderError> { + self.render_profiled(camera, renderables, textures, background_srgb, limits) + .map(|frame| frame.rgba) + } + + pub fn render_profiled( + &self, + camera: Camera, + renderables: &[Renderable], + textures: &[Texture], + background_srgb: [u8; 4], + limits: RenderLimits, + ) -> Result { + let started = Instant::now(); validate_scene(camera, renderables, textures, limits)?; + let validation = started.elapsed(); + let started = Instant::now(); let (sender, receiver) = mpsc::sync_channel(1); + let command = RenderCommand::Frame { + camera, + renderables: renderables.to_vec(), + textures: textures.to_vec(), + background_srgb, + limits, + reply: sender, + }; + let command_copy = started.elapsed(); self.commands - .try_send(RenderCommand::Frame { - camera, - renderables: renderables.to_vec(), - textures: textures.to_vec(), - background_srgb, - limits, - reply: sender, - }) + .try_send(command) .map_err(|error| match error { mpsc::TrySendError::Full(_) => RenderError::TimedOut, mpsc::TrySendError::Disconnected(_) => { RenderError::Device("renderer thread stopped".into()) } })?; - receiver + let mut frame = receiver .recv_timeout(Duration::from_mins(2)) - .map_err(|_| RenderError::TimedOut)? + .map_err(|_| RenderError::TimedOut)??; + frame.timings.validation = validation; + frame.timings.command_copy = command_copy; + Ok(frame) } } @@ -360,8 +399,10 @@ impl BevyBackend { textures: &[Texture], background_srgb: [u8; 4], limits: RenderLimits, - ) -> Result, RenderError> { + ) -> Result { + let started = Instant::now(); self.clear_frame(); + let clear_previous_frame = started.elapsed(); self.apps .main .world_mut() @@ -371,16 +412,21 @@ impl BevyBackend { background_srgb[2], background_srgb[3], ))); + let started = Instant::now(); self.texture_handles = add_textures( self.apps.main.world_mut(), textures, &mut self.texture_cache, ); + let texture_cache_sync = started.elapsed(); + let started = Instant::now(); let mesh_handles = add_meshes( self.apps.main.world_mut(), renderables, &mut self.mesh_cache, )?; + let mesh_cache_sync = started.elapsed(); + let started = Instant::now(); for (renderable, mesh) in renderables.iter().zip(mesh_handles) { let entity = match &renderable.material { Material::BlinnPhong(material) => { @@ -463,7 +509,20 @@ impl BevyBackend { )) .id(); self.frame_entities.push(light); - self.capture(&target) + let scene_setup = started.elapsed(); + let started = Instant::now(); + let rgba = self.capture(&target)?; + Ok(RenderedFrame { + rgba, + timings: RenderTimings { + clear_previous_frame, + texture_cache_sync, + mesh_cache_sync, + scene_setup, + capture: started.elapsed(), + ..Default::default() + }, + }) } fn new_target(&mut self, width: u32, height: u32) -> RenderTarget { diff --git a/crates/metacrate-rendering-wgpu/src/lib.rs b/crates/metacrate-rendering-wgpu/src/lib.rs index 7fa8e87..de92872 100644 --- a/crates/metacrate-rendering-wgpu/src/lib.rs +++ b/crates/metacrate-rendering-wgpu/src/lib.rs @@ -260,7 +260,7 @@ fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { #[cfg(feature = "wgpu")] mod gpu; #[cfg(feature = "wgpu")] -pub use gpu::Renderer; +pub use gpu::{RenderTimings, RenderedFrame, Renderer}; #[cfg(test)] mod tests { diff --git a/docs/renderer-performance.md b/docs/renderer-performance.md new file mode 100644 index 0000000..6881fa3 --- /dev/null +++ b/docs/renderer-performance.md @@ -0,0 +1,100 @@ +# Renderer and viewport performance + +Measured on 2026-08-23 with the primary account in Broceliande, a 1024 m x +1024 m varregion, using the default 64 m view distance. The visible scene had +211 objects, 427,829 triangles, and five unavailable grid textures. Release +numbers are the performance baseline; debug timings are intentionally omitted. + +## Current capture path + +| Phase | Warm release time | Can run ahead? | Required change | +|---|---:|---|---| +| Read current simulator objects and discover assets | 8 ms | Yes | Apply incoming object/avatar/terrain events to a persistent world instead of scanning the simulator maps per capture. | +| Fetch cached/missing assets | 151 ms | Yes | Bounded asset workers continuously fetch visible dirty assets. Raw immutable assets remain in the 2 GiB disk LRU. | +| Decode textures and derive scene geometry | 176 ms warm; 8.03 s on the first scene | Yes | Retain decoded visible textures and derived meshes in memory. Add a versioned persistent derived-mesh cache only for the measured 8 s cold-start cost; decoded RGBA textures should normally remain memory-only because they expand substantially. | +| Collect and sort renderables | 1 ms | Yes | Persistent renderer entities remove this per-frame list build. | +| Validate scene | 1 ms | Yes | Validate assets and changes when admitted, not every frame. | +| Copy render command | 21 ms | Yes | Send stable IDs and dirty updates; do not clone the complete scene for a frame. | +| Re-identify cached textures by content hash | 30 ms | Yes | Key immutable texture resources by grid asset UUID. | +| Re-identify cached meshes by content hash | 14 ms | Yes | Key derived meshes by stable object/asset signature and LOD. | +| Recreate frame entities | <1 ms | Yes | Keep Bevy entities and material handles alive; update only dirty components. | +| Render plus synchronous readback | 46 ms | Partly | Render continuously into a persistent target. A GUI presents that target directly; readback happens only for CPU consumers. | +| JPEG encoding | 41 ms | Yes, and not part of viewport FPS | A separate latest-frame worker encodes only when an LLM/image consumer asks for it. | + +The existing end-to-end warm capture takes 525 ms. A repeated renderer call +alone takes 113 ms (8.8 FPS), including 67 ms of avoidable copying, hashing, +and cache synchronization. Its measured render/readback segment is 46 ms +(21.9 FPS equivalent). A persistent GPU scene therefore makes the initial +10 FPS viewport target realistic on the measured hardware. + +The 46 ms value still includes synchronous CPU readback. It is not a pure GPU +timestamp. Continuous viewport rendering without readback should be faster and +must be measured separately once the persistent render target exists. + +## Startup + +The optimized cold-start profile was: + +| Phase | Time | Scheduling | +|---|---:|---| +| Client owner initialization | 2 ms | Startup thread | +| Grid login | 3.37 s | Parallel with renderer initialization | +| wgpu/Bevy initialization | 452 ms | Parallel with grid login; completed before agent readiness | +| Test scene convergence window | 30.04 s | World events continue asynchronously; this is not a system-readiness gate. | +| First full visible-scene build from persistent raw assets | 8.35 s | Background asset/scene workers; publish partial complete frames as content converges. | + +System readiness must require configuration, renderer initialization (or an +explicitly reported fallback), grid login, region dimensions, and the running +world/update loops. It must not wait until every user-created asset inside the +view radius has decoded: that would turn missing or slow grid assets into a +permanent login stall. Visual readiness is a separate completeness signal. + +## Game loop + +The agent needs four independent paths: + +1. Grid callbacks enqueue compact object, avatar, terrain, region, and camera + changes immediately. They never perform asset I/O or rendering. +2. A fixed update loop drains those events, updates the authoritative CPU world, + recalculates 64 m visibility, and emits stable dirty IDs. Grid event handling + must remain faster than the render tick. +3. Bounded asset workers fetch, decode, and derive only dirty visible content. + Completed resources update the CPU world and enqueue GPU changes. +4. A render loop applies GPU changes and updates a persistent viewport at an + initial 10 FPS target. It retains the latest complete frame. GUI presentation + uses the GPU target directly; snapshot readback, resize, JPEG encoding, and + LLM upload are independent latest-frame consumers. + +Simulation/update and rendering use separate clocks. Slow asset downloads, +JPEG encoding, LLM requests, and subscribers must never hold either loop. + +### Frame publication and readback + +The persistent viewport renders into a GPU texture. A GUI presents it without +CPU readback. When a CPU image is requested, the render graph schedules a copy +after that frame's render commands into the next free staging buffer. GPU queue +ordering makes the copied frame coherent even when the render target is reused +for the following frame. + +Use a ring of three staging buffers. Mapping and CPU consumption happen +asynchronously; a buffer is reused only after its completion signal. If all +three are busy, skip that readback rather than stall rendering. Publish only the +latest completed immutable CPU frame, tagged with frame sequence, camera pose, +and observation time. JPEG and LLM workers use that published frame and discard +superseded work. The current synchronous Bevy screenshot plus `device.poll(Wait)` +path is only a diagnostic bridge and must not remain in the game loop. + +## Instrumentation + +Hot paths record only monotonic start/end timestamps and emit fixed-size, +fixed-cardinality timing signals with `try_send`. A bounded receiver owned by a +dedicated profiling worker aggregates counts, totals, maxima, and percentile +histograms and publishes observability records. A full queue drops profiling +signals and increments a drop counter; it never backpressures grid or rendering. +Formatting, serialization, journal I/O, and subscriber delivery remain outside +the measured thread. + +Required fixed phases are startup/config, client creation, renderer creation, +login, grid readiness, event ingest, update tick, visibility, asset disk read, +asset network fetch, texture decode, geometry derive, GPU change application, +render submission, GPU completion, optional readback, and optional JPEG encode.