Profile and preinitialize viewport rendering
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 12:16:52 +02:00
parent b59bc4c3cd
commit cb9233c59c
8 changed files with 444 additions and 84 deletions

View File

@@ -183,8 +183,8 @@ pub use types::{
#[cfg(feature = "live-grid")] #[cfg(feature = "live-grid")]
pub use vision::LibremetaverseSceneSource; pub use vision::LibremetaverseSceneSource;
pub use vision::{ pub use vision::{
CameraPose, SceneEntity, SceneEntityKind, SceneSnapshot, SceneSource, SceneTriangle, CameraPose, SceneBuildTimings, SceneEntity, SceneEntityKind, SceneSnapshot, SceneSource,
SnapshotCompleteness, VisionAugmentedResponder, VisionCapture, VisionControl, VisionError, SceneTriangle, SnapshotCompleteness, VisionAugmentedResponder, VisionCapture, VisionControl,
VisionFuture, VisionLimits, VisionObservation, VisionObservationKind, VisionService, VisionError, VisionFuture, VisionLimits, VisionObservation, VisionObservationKind,
visual_question, VisionService, visual_question,
}; };

View File

@@ -284,7 +284,7 @@ async fn run_live(
let owner = LibremetaverseClientOwner::new_with_asset_cache_max_bytes( let owner = LibremetaverseClientOwner::new_with_asset_cache_max_bytes(
config.vision.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( let backend = match owner.session_backend_with_agent_services(
connection, connection,
live.interaction.ingress(), live.interaction.ingress(),
@@ -612,7 +612,7 @@ struct LiveInteractions {
#[cfg(feature = "live-grid")] #[cfg(feature = "live-grid")]
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
fn start_live_interactions( async fn start_live_interactions(
config: &metacrate_grid_agent::AgentConfig, config: &metacrate_grid_agent::AgentConfig,
owner: &metacrate_grid_agent::LibremetaverseClientOwner, owner: &metacrate_grid_agent::LibremetaverseClientOwner,
) -> Result<LiveInteractions, Box<dyn Error>> { ) -> Result<LiveInteractions, Box<dyn Error>> {
@@ -751,6 +751,9 @@ fn start_live_interactions(
scene_source.start_prefetch(); scene_source.start_prefetch();
let vision = let vision =
Arc::new(VisionService::new(Arc::clone(&scene_source), vision_limits)?.prefer_gpu()); 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 responder = Arc::new(VisionAugmentedResponder::new(vision.clone(), responder));
let sink = Arc::new(owner.interaction_sink()); let sink = Arc::new(owner.interaction_sink());
let pacer: Arc<dyn metacrate_grid_agent::ResponsePacer> = Arc::new(behavior_ingress); let pacer: Arc<dyn metacrate_grid_agent::ResponsePacer> = Arc::new(behavior_ingress);

View File

@@ -60,6 +60,15 @@ pub struct SnapshotCompleteness {
pub terrain_available: bool, 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)] #[derive(Clone, Debug, PartialEq)]
pub struct SceneSnapshot { pub struct SceneSnapshot {
pub generation: u64, pub generation: u64,
@@ -74,6 +83,7 @@ pub struct SceneSnapshot {
pub texture_fetches: usize, pub texture_fetches: usize,
pub texture_bytes: usize, pub texture_bytes: usize,
pub decoded_texture_pixels: usize, pub decoded_texture_pixels: usize,
pub timings: SceneBuildTimings,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -258,6 +268,26 @@ impl<S: SceneSource> VisionService<S> {
self.gpu_preferred = true; self.gpu_preferred = true;
self 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<Arc<metacrate_rendering_wgpu::Renderer>> {
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) { pub fn set_generation(&self, generation: u64) {
if self.current_generation.swap(generation, Ordering::AcqRel) != generation { if self.current_generation.swap(generation, Ordering::AcqRel) != generation {
self.cancel_active(); self.cancel_active();
@@ -396,19 +426,9 @@ impl<S: SceneSource> VisionService<S> {
async fn render_scene(&self, scene: SceneSnapshot) -> Result<VisionCapture, VisionError> { async fn render_scene(&self, scene: SceneSnapshot) -> Result<VisionCapture, VisionError> {
#[cfg(feature = "live-grid")] #[cfg(feature = "live-grid")]
if self.gpu_preferred { if self.gpu_preferred
let renderer = self && let Some(renderer) = self.renderer().await
.gpu {
.get_or_init(|| async {
tokio::task::spawn_blocking(bevy_renderer_new)
.await
.ok()
.and_then(Result::ok)
.map(Arc::new)
})
.await
.clone();
if let Some(renderer) = renderer {
let mut renderables = scene let mut renderables = scene
.entities .entities
.iter() .iter()
@@ -450,9 +470,7 @@ impl<S: SceneSource> VisionService<S> {
metacrate_rendering_wgpu::RenderError::ResourceLimit => { metacrate_rendering_wgpu::RenderError::ResourceLimit => {
VisionError::ResourceLimit VisionError::ResourceLimit
} }
metacrate_rendering_wgpu::RenderError::TimedOut => { metacrate_rendering_wgpu::RenderError::TimedOut => VisionError::TimedOut,
VisionError::TimedOut
}
_ => VisionError::Encode, _ => VisionError::Encode,
}) })
.and_then(|rgba| finish_capture(&gpu_scene, limits, &rgba)) .and_then(|rgba| finish_capture(&gpu_scene, limits, &rgba))
@@ -462,7 +480,6 @@ impl<S: SceneSource> VisionService<S> {
return Ok(capture); return Ok(capture);
} }
} }
}
render_scene_software(&scene, self.limits) render_scene_software(&scene, self.limits)
} }
} }
@@ -1058,6 +1075,9 @@ async fn request_asset_bytes(
asset_type: libremetaverse_types::AssetType, asset_type: libremetaverse_types::AssetType,
cancellation: CancellationToken, cancellation: CancellationToken,
) -> Option<Vec<u8>> { ) -> Option<Vec<u8>> {
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 { tokio::time::timeout(Duration::from_secs(8), async {
match asset_type { match asset_type {
libremetaverse_types::AssetType::Texture => { libremetaverse_types::AssetType::Texture => {
@@ -1095,6 +1115,7 @@ impl SceneSource for LibremetaverseSceneSource {
cancellation: CancellationToken, cancellation: CancellationToken,
) -> VisionFuture<'_, SceneSnapshot> { ) -> VisionFuture<'_, SceneSnapshot> {
Box::pin(async move { Box::pin(async move {
let build_started = Instant::now();
if cancellation.is_cancellation_requested() { if cancellation.is_cancellation_requested() {
return Err(VisionError::Cancelled); return Err(VisionError::Cancelled);
} }
@@ -1122,6 +1143,8 @@ impl SceneSource for LibremetaverseSceneSource {
() = tokio::time::sleep(Duration::from_millis(100)) => {} () = 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 camera = &self.agent.movement.camera;
let position = if generation == 0 { let position = if generation == 0 {
self.agent.sim_position() self.agent.sim_position()
@@ -1340,6 +1363,8 @@ impl SceneSource for LibremetaverseSceneSource {
ids ids
}); });
let assets = self.client.assets(); let assets = self.client.assets();
let object_snapshot_and_asset_discovery = discovery_started.elapsed();
let material_started = Instant::now();
let missing_legacy_materials = { let missing_legacy_materials = {
let cache = lock(&self.cache); let cache = lock(&self.cache);
legacy_material_ids 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()); texture_ids.retain(|id| *id != UUID::zero());
let scene_texture_ids = texture_ids.clone(); 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 maximum = self.limits.max_triangles;
let decode_pixels = self.limits.max_decode_pixels; let decode_pixels = self.limits.max_decode_pixels;
let decode_bytes = self.limits.max_texture_bytes; let decode_bytes = self.limits.max_texture_bytes;
@@ -1581,6 +1610,7 @@ impl SceneSource for LibremetaverseSceneSource {
}) })
.await .await
.map_err(|_| VisionError::InvalidScene)??; .map_err(|_| VisionError::InvalidScene)??;
let decode_and_geometry = conversion_started.elapsed();
if cancellation.is_cancellation_requested() { if cancellation.is_cancellation_requested() {
return Err(VisionError::Cancelled); return Err(VisionError::Cancelled);
} }
@@ -1618,6 +1648,13 @@ impl SceneSource for LibremetaverseSceneSource {
texture_fetches, texture_fetches,
texture_bytes, texture_bytes,
decoded_texture_pixels, decoded_texture_pixels,
timings: SceneBuildTimings {
terrain_wait,
object_snapshot_and_asset_discovery,
material_fetch,
asset_fetch,
decode_and_geometry,
},
}) })
}) })
} }

View File

@@ -399,6 +399,7 @@ fn scene() -> SceneSnapshot {
texture_fetches: 1, texture_fetches: 1,
texture_bytes: 128, texture_bytes: 128,
decoded_texture_pixels: 16, decoded_texture_pixels: 16,
timings: SceneBuildTimings::default(),
} }
} }

View File

@@ -50,6 +50,56 @@ async fn live_primary_login_resolves_varregion_dimensions() -> Result<(), Box<dy
result result
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "profiles primary login, renderer initialization, and first visible-scene warmup"]
async fn live_primary_startup_profile() -> Result<(), Box<dyn Error>> {
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)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "records the primary region's asynchronous map-name reply"] #[ignore = "records the primary region's asynchronous map-name reply"]
async fn live_primary_map_name_reports_varregion_dimensions() -> Result<(), Box<dyn Error>> { async fn live_primary_map_name_reports_varregion_dimensions() -> Result<(), Box<dyn Error>> {
@@ -175,6 +225,14 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
limits, limits,
)? )?
.prefer_gpu(); .prefer_gpu();
let initialization_started = Instant::now();
if !vision.initialize_renderer().await {
return Err("live wgpu renderer initialization failed".into());
}
println!(
"LIVE_RENDER_INITIALIZATION_MILLISECONDS={}",
initialization_started.elapsed().as_millis()
);
vision.set_generation(1); vision.set_generation(1);
let mut capture = None; let mut capture = None;
let output = std::env::var_os("METACRATE_LIVE_RENDER_OUTPUT").map_or_else( let output = std::env::var_os("METACRATE_LIVE_RENDER_OUTPUT").map_or_else(
@@ -215,6 +273,7 @@ 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 capture = capture.ok_or("live renderer produced no frame")?;
profile_warm_capture(&scene_source, limits, renderer_cancel.token()).await?;
println!("LIVE_RENDER_OUTPUT={}", output.display()); println!("LIVE_RENDER_OUTPUT={}", output.display());
println!("LIVE_RENDER_SHA256={}", capture.image_sha256); println!("LIVE_RENDER_SHA256={}", capture.image_sha256);
Ok::<(), Box<dyn Error>>(()) Ok::<(), Box<dyn Error>>(())
@@ -226,6 +285,107 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
result result
} }
async fn profile_warm_capture(
source: &LibremetaverseSceneSource,
limits: VisionLimits,
cancellation: CancellationToken,
) -> Result<(), Box<dyn Error>> {
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::<Vec<_>>();
renderables.sort_by(|left, right| left.0.cmp(&right.0));
let renderables = renderables
.into_iter()
.map(|(_, renderable)| renderable)
.collect::<Vec<_>>();
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::<Vec<_>>();
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 { struct DiagnosticSceneSource {
inner: Arc<LibremetaverseSceneSource>, inner: Arc<LibremetaverseSceneSource>,
material_override: Option<String>, material_override: Option<String>,

View File

@@ -30,7 +30,7 @@ use std::{
marker::PhantomData, marker::PhantomData,
sync::{Mutex, mpsc}, sync::{Mutex, mpsc},
thread, thread,
time::Duration, time::{Duration, Instant},
}; };
const WORLD_AMBIENT_BRIGHTNESS: f32 = 400.0; const WORLD_AMBIENT_BRIGHTNESS: f32 = 400.0;
@@ -43,11 +43,30 @@ enum RenderCommand {
textures: Vec<Texture>, textures: Vec<Texture>,
background_srgb: [u8; 4], background_srgb: [u8; 4],
limits: RenderLimits, limits: RenderLimits,
reply: mpsc::SyncSender<Result<Vec<u8>, RenderError>>, reply: mpsc::SyncSender<Result<RenderedFrame, RenderError>>,
}, },
Stop, 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<u8>,
pub timings: RenderTimings,
}
/// Reusable renderer facade. Bevy and its ECS stay on a dedicated OS thread. /// Reusable renderer facade. Bevy and its ECS stay on a dedicated OS thread.
pub struct Renderer { pub struct Renderer {
commands: mpsc::SyncSender<RenderCommand>, commands: mpsc::SyncSender<RenderCommand>,
@@ -110,26 +129,46 @@ impl Renderer {
background_srgb: [u8; 4], background_srgb: [u8; 4],
limits: RenderLimits, limits: RenderLimits,
) -> Result<Vec<u8>, RenderError> { ) -> Result<Vec<u8>, 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<RenderedFrame, RenderError> {
let started = Instant::now();
validate_scene(camera, renderables, textures, limits)?; validate_scene(camera, renderables, textures, limits)?;
let validation = started.elapsed();
let started = Instant::now();
let (sender, receiver) = mpsc::sync_channel(1); let (sender, receiver) = mpsc::sync_channel(1);
self.commands let command = RenderCommand::Frame {
.try_send(RenderCommand::Frame {
camera, camera,
renderables: renderables.to_vec(), renderables: renderables.to_vec(),
textures: textures.to_vec(), textures: textures.to_vec(),
background_srgb, background_srgb,
limits, limits,
reply: sender, reply: sender,
}) };
let command_copy = started.elapsed();
self.commands
.try_send(command)
.map_err(|error| match error { .map_err(|error| match error {
mpsc::TrySendError::Full(_) => RenderError::TimedOut, mpsc::TrySendError::Full(_) => RenderError::TimedOut,
mpsc::TrySendError::Disconnected(_) => { mpsc::TrySendError::Disconnected(_) => {
RenderError::Device("renderer thread stopped".into()) RenderError::Device("renderer thread stopped".into())
} }
})?; })?;
receiver let mut frame = receiver
.recv_timeout(Duration::from_mins(2)) .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], textures: &[Texture],
background_srgb: [u8; 4], background_srgb: [u8; 4],
limits: RenderLimits, limits: RenderLimits,
) -> Result<Vec<u8>, RenderError> { ) -> Result<RenderedFrame, RenderError> {
let started = Instant::now();
self.clear_frame(); self.clear_frame();
let clear_previous_frame = started.elapsed();
self.apps self.apps
.main .main
.world_mut() .world_mut()
@@ -371,16 +412,21 @@ impl BevyBackend {
background_srgb[2], background_srgb[2],
background_srgb[3], background_srgb[3],
))); )));
let started = Instant::now();
self.texture_handles = add_textures( self.texture_handles = add_textures(
self.apps.main.world_mut(), self.apps.main.world_mut(),
textures, textures,
&mut self.texture_cache, &mut self.texture_cache,
); );
let texture_cache_sync = started.elapsed();
let started = Instant::now();
let mesh_handles = add_meshes( let mesh_handles = add_meshes(
self.apps.main.world_mut(), self.apps.main.world_mut(),
renderables, renderables,
&mut self.mesh_cache, &mut self.mesh_cache,
)?; )?;
let mesh_cache_sync = started.elapsed();
let started = Instant::now();
for (renderable, mesh) in renderables.iter().zip(mesh_handles) { for (renderable, mesh) in renderables.iter().zip(mesh_handles) {
let entity = match &renderable.material { let entity = match &renderable.material {
Material::BlinnPhong(material) => { Material::BlinnPhong(material) => {
@@ -463,7 +509,20 @@ impl BevyBackend {
)) ))
.id(); .id();
self.frame_entities.push(light); 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 { fn new_target(&mut self, width: u32, height: u32) -> RenderTarget {

View File

@@ -260,7 +260,7 @@ fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
#[cfg(feature = "wgpu")] #[cfg(feature = "wgpu")]
mod gpu; mod gpu;
#[cfg(feature = "wgpu")] #[cfg(feature = "wgpu")]
pub use gpu::Renderer; pub use gpu::{RenderTimings, RenderedFrame, Renderer};
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@@ -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.