Profile and preinitialize viewport rendering
This commit is contained in:
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
100
docs/renderer-performance.md
Normal file
100
docs/renderer-performance.md
Normal 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.
|
||||||
Reference in New Issue
Block a user