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")]
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,
};

View File

@@ -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<LiveInteractions, Box<dyn Error>> {
@@ -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<dyn metacrate_grid_agent::ResponsePacer> = Arc::new(behavior_ingress);

View File

@@ -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<S: SceneSource> VisionService<S> {
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<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) {
if self.current_generation.swap(generation, Ordering::AcqRel) != generation {
self.cancel_active();
@@ -396,71 +426,58 @@ impl<S: SceneSource> VisionService<S> {
async fn render_scene(&self, scene: SceneSnapshot) -> Result<VisionCapture, VisionError> {
#[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::<Vec<_>>();
renderables.sort_by(|left, right| left.0.cmp(&right.0));
let renderables = renderables
.into_iter()
.map(|(_, renderable)| renderable)
.collect::<Vec<_>>();
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::<Vec<_>>();
renderables.sort_by(|left, right| left.0.cmp(&right.0));
let renderables = renderables
.into_iter()
.map(|(_, renderable)| renderable)
.collect::<Vec<_>>();
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<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 {
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,
},
})
})
}

View File

@@ -399,6 +399,7 @@ fn scene() -> SceneSnapshot {
texture_fetches: 1,
texture_bytes: 128,
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
}
#[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)]
#[ignore = "records the primary region's asynchronous map-name reply"]
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,
)?
.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);
let mut capture = None;
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")?;
profile_warm_capture(&scene_source, limits, renderer_cancel.token()).await?;
println!("LIVE_RENDER_OUTPUT={}", output.display());
println!("LIVE_RENDER_SHA256={}", capture.image_sha256);
Ok::<(), Box<dyn Error>>(())
@@ -226,6 +285,107 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
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 {
inner: Arc<LibremetaverseSceneSource>,
material_override: Option<String>,

View File

@@ -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<Texture>,
background_srgb: [u8; 4],
limits: RenderLimits,
reply: mpsc::SyncSender<Result<Vec<u8>, RenderError>>,
reply: mpsc::SyncSender<Result<RenderedFrame, RenderError>>,
},
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.
pub struct Renderer {
commands: mpsc::SyncSender<RenderCommand>,
@@ -110,26 +129,46 @@ impl Renderer {
background_srgb: [u8; 4],
limits: RenderLimits,
) -> 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)?;
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<Vec<u8>, RenderError> {
) -> Result<RenderedFrame, RenderError> {
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 {

View File

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