936 lines
35 KiB
Rust
936 lines
35 KiB
Rust
#![cfg(feature = "live-grid")]
|
|
|
|
use libremetaverse_types::{
|
|
Vector3,
|
|
compat::{CancellationToken, CancellationTokenSource},
|
|
};
|
|
use metacrate_grid_agent::{
|
|
EndpointUrl, GridConnection, GridSession, GridSessionBackend, LibremetaverseClientOwner,
|
|
LibremetaverseSceneSource, SceneEntityKind, SceneSnapshot, SceneSource, SecretString,
|
|
SessionSignal, VisionFuture, VisionLimits, VisionService,
|
|
};
|
|
use std::{
|
|
collections::BTreeMap,
|
|
error::Error,
|
|
path::PathBuf,
|
|
sync::Arc,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[ignore = "logs the primary agent into the configured live varregion"]
|
|
async fn live_primary_login_resolves_varregion_dimensions() -> Result<(), Box<dyn Error>> {
|
|
let environment = live_environment()?;
|
|
let owner = LibremetaverseClientOwner::new()?;
|
|
let (mut session, cancellation) =
|
|
login(&owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
|
|
let result = async {
|
|
wait_ready("primary", &mut session, &cancellation).await?;
|
|
let simulator = owner
|
|
.client()
|
|
.network()
|
|
.current_sim()
|
|
.ok_or("primary simulator unavailable")?;
|
|
let size = simulator.region_size();
|
|
println!(
|
|
"LIVE_PRIMARY_REGION=region:{:?},handle:{},size:{}x{},position:{:?}",
|
|
simulator.name,
|
|
simulator.handle,
|
|
size.0,
|
|
size.1,
|
|
owner.agent().sim_position()
|
|
);
|
|
if size != (1024, 1024) {
|
|
return Err(format!("expected 1024x1024 varregion, got {}x{}", size.0, size.1).into());
|
|
}
|
|
Ok(())
|
|
}
|
|
.await;
|
|
let _ = session.logout(cancellation.token()).await;
|
|
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>> {
|
|
let environment = live_environment()?;
|
|
let owner = LibremetaverseClientOwner::new()?;
|
|
let (mut session, cancellation) =
|
|
login(&owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
|
|
let result = async {
|
|
wait_ready("primary", &mut session, &cancellation).await?;
|
|
let simulator = owner
|
|
.client()
|
|
.network()
|
|
.current_sim()
|
|
.ok_or("primary simulator unavailable")?;
|
|
let name = simulator.name.clone();
|
|
let handle = simulator.handle;
|
|
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
|
|
let _subscription = owner
|
|
.client()
|
|
.grid()
|
|
.subscribe_grid_region(Arc::new(move |event| {
|
|
let _ = sender.send(event.region());
|
|
}));
|
|
let started = Instant::now();
|
|
owner
|
|
.client()
|
|
.grid()
|
|
.request_map_region(name.clone(), libremetaverse::GridLayerType::Objects)?;
|
|
let deadline = started + Duration::from_secs(15);
|
|
while Instant::now() < deadline {
|
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
let Ok(Some(region)) = tokio::time::timeout(remaining, receiver.recv()).await else {
|
|
break;
|
|
};
|
|
println!(
|
|
"LIVE_MAP_NAME_EVENT=elapsed_ms:{},name:{:?},handle:{},size:{}x{}",
|
|
started.elapsed().as_millis(),
|
|
region.name,
|
|
region.region_handle,
|
|
region.size_x,
|
|
region.size_y
|
|
);
|
|
if region.region_handle == handle && (region.size_x, region.size_y) == (1024, 1024) {
|
|
return Ok::<(), Box<dyn Error>>(());
|
|
}
|
|
}
|
|
Err(format!("no explicit 1024x1024 map-name reply for {name:?}").into())
|
|
}
|
|
.await;
|
|
let _ = session.logout(cancellation.token()).await;
|
|
result
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[ignore = "logs the primary agent into the configured live grid and writes a JPEG"]
|
|
#[allow(clippy::too_many_lines)] // The one end-to-end live workflow stays readable in execution order.
|
|
async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Error>> {
|
|
let environment = live_environment()?;
|
|
let renderer_owner = LibremetaverseClientOwner::new()?;
|
|
let limits = VisionLimits {
|
|
minimum_interval: Duration::ZERO,
|
|
..VisionLimits::default()
|
|
};
|
|
let (_world_events, world_receiver) =
|
|
metacrate_game_loop::LibreMetaverseWorldEvents::subscribe_visible(
|
|
renderer_owner.client(),
|
|
renderer_owner.agent(),
|
|
limits.max_distance_meters,
|
|
4_096,
|
|
)
|
|
.ok_or("live world event subscription failed")?;
|
|
let world_loop = metacrate_game_loop::start(
|
|
metacrate_game_loop::LoopConfig::default(),
|
|
world_receiver,
|
|
(),
|
|
None,
|
|
)
|
|
.ok_or("live world loop failed to start")?;
|
|
let (mut renderer_session, renderer_cancel) =
|
|
login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
|
|
let scene_source = Arc::new(LibremetaverseSceneSource::new(&renderer_owner, limits));
|
|
let prefetch_started = Instant::now();
|
|
scene_source.start_prefetch();
|
|
|
|
let result = async {
|
|
wait_ready("renderer", &mut renderer_session, &renderer_cancel).await?;
|
|
let renderer_simulator = renderer_owner
|
|
.client()
|
|
.network()
|
|
.current_sim()
|
|
.ok_or("renderer simulator unavailable")?;
|
|
let settled_position = renderer_owner.agent().sim_position();
|
|
let region_size = renderer_simulator.region_size();
|
|
println!(
|
|
"LIVE_RENDER_LOCATION=region:{:?},size:{}x{},position:{settled_position:?}",
|
|
renderer_simulator.name, region_size.0, region_size.1
|
|
);
|
|
wait_scene_settled(&renderer_owner).await?;
|
|
tokio::time::timeout(Duration::from_secs(10), async {
|
|
while world_loop
|
|
.latest_world()
|
|
.is_none_or(|world| world.value.region.is_none())
|
|
{
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|_| "central world state did not become ready")?;
|
|
let world = world_loop
|
|
.latest_world()
|
|
.ok_or("central world snapshot disappeared")?;
|
|
println!(
|
|
"LIVE_WORLD_STATE=sequence:{},primitives:{},avatars:{},terrain_patches:{},dropped_events:{}",
|
|
world.sequence,
|
|
world.value.primitives.len(),
|
|
world.value.avatars.len(),
|
|
world.value.terrain.len(),
|
|
world_loop.dropped_world_events(),
|
|
);
|
|
let view_forward = Vector3::mul_with_vector3_quaternion(
|
|
Vector3::unit_x(),
|
|
renderer_owner.agent().sim_rotation(),
|
|
);
|
|
let (camera_position, camera_target) =
|
|
avatar_forward_camera(settled_position, view_forward)?;
|
|
renderer_owner
|
|
.agent()
|
|
.movement
|
|
.camera
|
|
.look_at_with_vector3_vector3(camera_position, camera_target)?;
|
|
renderer_owner
|
|
.agent()
|
|
.movement
|
|
.camera
|
|
.set_vertical_fov_angle(65_f32.to_radians())?;
|
|
|
|
tokio::time::timeout(Duration::from_mins(10), async {
|
|
while scene_source.prefetch_passes() == 0 {
|
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|_| "background scene prefetch did not complete")?;
|
|
println!(
|
|
"LIVE_RENDER_PREFETCH_MILLISECONDS={}",
|
|
prefetch_started.elapsed().as_millis()
|
|
);
|
|
scene_source.stop_prefetch().await;
|
|
|
|
println!(
|
|
"LIVE_RENDER_SCOPE=max_distance_meters:{}",
|
|
limits.max_distance_meters
|
|
);
|
|
let vision = VisionService::new(
|
|
Arc::new(DiagnosticSceneSource {
|
|
inner: Arc::clone(&scene_source),
|
|
material_override: std::env::var("METACRATE_LIVE_RENDER_MATERIAL_OVERRIDE").ok(),
|
|
}),
|
|
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(
|
|
|| std::env::temp_dir().join("metacrate-live-render.jpg"),
|
|
PathBuf::from,
|
|
);
|
|
let frames = std::env::var("METACRATE_LIVE_RENDER_FRAMES")
|
|
.ok()
|
|
.and_then(|value| value.parse::<u32>().ok())
|
|
.unwrap_or(12)
|
|
.clamp(1, 24);
|
|
let mut previous_missing = u32::MAX;
|
|
let mut stable_missing = 0_u32;
|
|
for frame in 1..=frames {
|
|
let frame_started = Instant::now();
|
|
let next = vision
|
|
.capture(
|
|
&format!("live-renderer-evidence-{frame}"),
|
|
1,
|
|
renderer_cancel.token(),
|
|
)
|
|
.await?;
|
|
println!(
|
|
"LIVE_RENDER_FRAME_{frame}_MILLISECONDS={}",
|
|
frame_started.elapsed().as_millis()
|
|
);
|
|
println!("LIVE_RENDER_FRAME_{frame}_SUMMARY={}", next.summary);
|
|
std::fs::write(&output, &next.jpeg)?;
|
|
if next.completeness.textures_missing == previous_missing {
|
|
stable_missing += 1;
|
|
} else {
|
|
previous_missing = next.completeness.textures_missing;
|
|
stable_missing = 0;
|
|
}
|
|
capture = Some(next);
|
|
if previous_missing == 0 || (frame >= 8 && stable_missing >= 3) {
|
|
break;
|
|
}
|
|
}
|
|
let capture = capture.ok_or("live renderer produced no frame")?;
|
|
let viewport_before = vision
|
|
.viewport_stats()
|
|
.ok_or("viewport loop did not expose statistics")?;
|
|
let soak_started = Instant::now();
|
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
let soak_elapsed = soak_started.elapsed();
|
|
let viewport_after = vision
|
|
.viewport_stats()
|
|
.ok_or("viewport loop stopped exposing statistics")?;
|
|
let soak_frames = viewport_after
|
|
.completed_frames
|
|
.saturating_sub(viewport_before.completed_frames);
|
|
let soak_missed = viewport_after
|
|
.missed_frame_deadlines
|
|
.saturating_sub(viewport_before.missed_frame_deadlines);
|
|
let soak_fps = f64::from(u32::try_from(soak_frames)?) / soak_elapsed.as_secs_f64();
|
|
println!(
|
|
"LIVE_VIEWPORT_SOAK=frames:{soak_frames},elapsed_ms:{},fps:{soak_fps:.2},missed_deadlines:{soak_missed},render_errors:{},last_render_ms:{}",
|
|
soak_elapsed.as_millis(),
|
|
viewport_after.render_errors,
|
|
viewport_after.last_render.as_millis(),
|
|
);
|
|
if soak_fps < 10.0 || soak_missed != 0 || viewport_after.render_errors != 0 {
|
|
return Err("stable viewport did not sustain its 10 Hz target".into());
|
|
}
|
|
profile_warm_capture(&scene_source, limits, renderer_cancel.token()).await?;
|
|
println!("LIVE_RENDER_OUTPUT={}", output.display());
|
|
println!("LIVE_RENDER_SHA256={}", capture.image_sha256);
|
|
Ok::<(), Box<dyn Error>>(())
|
|
}
|
|
.await;
|
|
|
|
scene_source.stop_prefetch().await;
|
|
world_loop
|
|
.shutdown()
|
|
.map_err(|_| "world loop thread panicked")?;
|
|
let _ = renderer_session.logout(renderer_cancel.token()).await;
|
|
result
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[ignore = "logs Myrddin and a second account into the live grid and renders appearance evidence"]
|
|
#[allow(clippy::too_many_lines)] // One ordered two-account evidence workflow is easier to audit.
|
|
async fn live_observer_renders_myrddin_appearance() -> Result<(), Box<dyn Error>> {
|
|
let environment = live_environment()?;
|
|
let target = LibremetaverseClientOwner::new()?;
|
|
let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let _appearance = target
|
|
.client()
|
|
.appearance()
|
|
.subscribe_recovery(Arc::new(move |event| {
|
|
let _ = progress_tx.send(event);
|
|
}));
|
|
let (mut target_session, target_cancel) =
|
|
login(&target, &environment, "GRID_USER", "GRID_PASSWORD").await?;
|
|
wait_ready("Myrddin", &mut target_session, &target_cancel).await?;
|
|
let recovery = tokio::time::timeout(Duration::from_mins(2), async {
|
|
loop {
|
|
let event = progress_rx
|
|
.recv()
|
|
.await
|
|
.ok_or("appearance recovery stream closed")?;
|
|
println!("LIVE_APPEARANCE_RECOVERY={event:?}");
|
|
if matches!(
|
|
event.phase,
|
|
libremetaverse::AppearanceRecoveryPhase::AppearanceSent
|
|
| libremetaverse::AppearanceRecoveryPhase::Complete
|
|
) {
|
|
return Ok::<_, Box<dyn Error>>(event);
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|_| "timed out waiting for Myrddin appearance recovery")??;
|
|
|
|
let observer = LibremetaverseClientOwner::new()?;
|
|
let (mut observer_session, observer_cancel) = login(
|
|
&observer,
|
|
&environment,
|
|
"GRID_TEST_AUTHORIZED_USER",
|
|
"GRID_TEST_AUTHORIZED_PASSWORD",
|
|
)
|
|
.await?;
|
|
let result = async {
|
|
wait_ready("observer", &mut observer_session, &observer_cancel).await?;
|
|
wait_scene_settled(&observer).await?;
|
|
let target_name = required(&environment, "GRID_USER")?;
|
|
let target_avatar = tokio::time::timeout(Duration::from_secs(30), async {
|
|
loop {
|
|
let simulator = observer
|
|
.client()
|
|
.network()
|
|
.current_sim()
|
|
.ok_or("observer simulator unavailable")?;
|
|
if let Some(avatar) = simulator
|
|
.objects_avatars
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.values()
|
|
.find(|avatar| avatar.name() == target_name)
|
|
.cloned()
|
|
{
|
|
return Ok::<_, Box<dyn Error>>(avatar);
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|_| "observer did not see Myrddin")??;
|
|
let attachment_primitives = observer
|
|
.client()
|
|
.network()
|
|
.current_sim()
|
|
.ok_or("observer simulator unavailable")?
|
|
.objects_primitives
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.values()
|
|
.filter(|primitive| primitive.parent_id == target_avatar.local_id)
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
let attachment_ids = attachment_primitives
|
|
.iter()
|
|
.map(|primitive| primitive.id)
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
println!(
|
|
"LIVE_APPEARANCE_TARGET=position:{:?},local_id:{},observer_attachments:{}",
|
|
target_avatar.position,
|
|
target_avatar.local_id,
|
|
attachment_ids.len()
|
|
);
|
|
if attachment_ids.len() < 2 {
|
|
return Err("observer did not receive Myrddin's mesh body and coat attachments".into());
|
|
}
|
|
let assets = observer.client().assets();
|
|
let mut mesh_loads = tokio::task::JoinSet::new();
|
|
for mesh_id in attachment_primitives
|
|
.iter()
|
|
.filter_map(|primitive| primitive.sculpt.as_ref())
|
|
.filter(|sculpt| sculpt.type_() == libremetaverse_types::SculptType::Mesh)
|
|
.map(|sculpt| sculpt.sculpt_texture)
|
|
{
|
|
let assets = assets.clone();
|
|
let token = observer_cancel.token();
|
|
mesh_loads.spawn(async move { assets.request_mesh(mesh_id, Some(token)).await });
|
|
}
|
|
let mut loaded_meshes = 0;
|
|
while let Some(result) = tokio::time::timeout(Duration::from_secs(30), mesh_loads.join_next())
|
|
.await
|
|
.map_err(|_| "timed out loading Myrddin attachment mesh")?
|
|
{
|
|
loaded_meshes += usize::from(matches!(result, Ok(Ok(Some(_)))));
|
|
}
|
|
println!("LIVE_APPEARANCE_MESHES_LOADED={loaded_meshes}");
|
|
if loaded_meshes < 2 {
|
|
return Err("observer could not load Myrddin's mesh body and coat assets".into());
|
|
}
|
|
let mut decoded_meshes = 0;
|
|
for primitive in &attachment_primitives {
|
|
let Some(sculpt) = primitive.sculpt.as_ref() else {
|
|
continue;
|
|
};
|
|
let Some(bytes) = assets
|
|
.cache
|
|
.get_cached_asset_bytes_with_uuid(sculpt.sculpt_texture)?
|
|
else {
|
|
continue;
|
|
};
|
|
let asset = libremetaverse::assets::AssetMesh::new_with_uuid_bytes(
|
|
sculpt.sculpt_texture,
|
|
bytes,
|
|
)?;
|
|
let mut mesh = None;
|
|
decoded_meshes += usize::from(
|
|
libremetaverse::rendering::FacetedMesh::try_decode_from_asset(
|
|
primitive.clone(),
|
|
asset,
|
|
libremetaverse::rendering::DetailLevel::Highest,
|
|
&mut mesh,
|
|
),
|
|
);
|
|
}
|
|
println!("LIVE_APPEARANCE_MESHES_DECODED={decoded_meshes}");
|
|
if decoded_meshes < 2 {
|
|
return Err("renderer could not decode Myrddin's mesh body and coat assets".into());
|
|
}
|
|
let limits = VisionLimits {
|
|
width: 640,
|
|
height: 640,
|
|
max_distance_meters: 8,
|
|
max_entities: 128,
|
|
max_texture_fetches: 128,
|
|
minimum_interval: Duration::ZERO,
|
|
..VisionLimits::default()
|
|
};
|
|
let source = Arc::new(LibremetaverseSceneSource::new(&observer, limits));
|
|
let vision = VisionService::new(source.clone(), limits)?.prefer_gpu();
|
|
if !vision.initialize_renderer().await {
|
|
return Err("appearance evidence renderer initialization failed".into());
|
|
}
|
|
let facing = Vector3::mul_with_vector3_quaternion(
|
|
Vector3::unit_x(),
|
|
target_avatar.rotation,
|
|
);
|
|
let length = (facing.x * facing.x + facing.y * facing.y).sqrt();
|
|
if !length.is_finite() || length < f32::EPSILON {
|
|
return Err("Myrddin facing direction unavailable".into());
|
|
}
|
|
let forward = Vector3 {
|
|
x: facing.x / length,
|
|
y: facing.y / length,
|
|
z: 0.0,
|
|
};
|
|
let side = Vector3 {
|
|
x: -forward.y,
|
|
y: forward.x,
|
|
z: 0.0,
|
|
};
|
|
let back = Vector3 {
|
|
x: -forward.x,
|
|
y: -forward.y,
|
|
z: 0.0,
|
|
};
|
|
for (view, (label, direction)) in [("front", forward), ("side", side), ("back", back)]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let generation = u64::try_from(view)? + 1;
|
|
let eye = Vector3 {
|
|
x: target_avatar.position.x,
|
|
y: target_avatar.position.y,
|
|
z: target_avatar.position.z + 0.9,
|
|
};
|
|
let camera = Vector3 {
|
|
x: eye.x + direction.x * 4.0,
|
|
y: eye.y + direction.y * 4.0,
|
|
z: eye.z,
|
|
};
|
|
observer
|
|
.agent()
|
|
.movement
|
|
.camera
|
|
.look_at_with_vector3_vector3(camera, eye)?;
|
|
observer
|
|
.agent()
|
|
.movement
|
|
.camera
|
|
.set_vertical_fov_angle(45_f32.to_radians())?;
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
vision.set_generation(generation);
|
|
if view == 0 {
|
|
let scene = source
|
|
.capture_scene(generation, observer_cancel.token())
|
|
.await?;
|
|
let rendered_attachments = scene
|
|
.entities
|
|
.iter()
|
|
.filter(|entity| {
|
|
attachment_ids.contains(&entity.id) && !entity.renderables.is_empty()
|
|
})
|
|
.count();
|
|
println!(
|
|
"LIVE_APPEARANCE_SCENE=entities:{},rendered_attachments:{rendered_attachments},textures_missing:{},texture_fetches:{}",
|
|
scene.entities.len(),
|
|
scene.completeness.textures_missing,
|
|
scene.texture_fetches,
|
|
);
|
|
if rendered_attachments < 2 {
|
|
return Err("renderer did not decode Myrddin's mesh body and coat".into());
|
|
}
|
|
}
|
|
let capture = vision
|
|
.capture(
|
|
&format!("myrddin-appearance-{label}"),
|
|
generation,
|
|
observer_cancel.token(),
|
|
)
|
|
.await?;
|
|
let output = std::env::temp_dir().join(format!(
|
|
"metacrate-myrddin-appearance-{label}.jpg"
|
|
));
|
|
std::fs::write(&output, &capture.jpeg)?;
|
|
println!("LIVE_APPEARANCE_OUTPUT_{label}={}", output.display());
|
|
println!("LIVE_APPEARANCE_SHA256_{label}={}", capture.image_sha256);
|
|
}
|
|
source.stop_prefetch().await;
|
|
println!(
|
|
"LIVE_APPEARANCE_STATE=cof_version:{},wearables:{},attachments:{},missing_attachments:{},elapsed_ms:{}",
|
|
recovery.cof_version,
|
|
recovery.wearable_count,
|
|
recovery.attachment_count,
|
|
recovery.missing_attachment_count,
|
|
recovery.elapsed_millis,
|
|
);
|
|
Ok::<(), Box<dyn Error>>(())
|
|
}
|
|
.await;
|
|
let _ = observer_session.logout(observer_cancel.token()).await;
|
|
let _ = target_session.logout(target_cancel.token()).await;
|
|
result
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)] // One ordered live timing record keeps phase boundaries explicit.
|
|
async fn profile_warm_capture(
|
|
source: &LibremetaverseSceneSource,
|
|
limits: VisionLimits,
|
|
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 renderables: Arc<[metacrate_rendering_wgpu::Renderable]> = renderables.into();
|
|
let preparation = preparation_started.elapsed();
|
|
let renderer_started = Instant::now();
|
|
let renderer = metacrate_game_loop::ViewportRuntime::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_scene(
|
|
scene.scene_sequence,
|
|
scene.camera,
|
|
|| Arc::clone(&renderables),
|
|
Arc::clone(&scene.textures),
|
|
[125, 149, 173, 255],
|
|
render_limits,
|
|
)?;
|
|
let refresh_started = Instant::now();
|
|
let refresh = renderer.render_scene(
|
|
scene.scene_sequence.saturating_add(1),
|
|
scene.camera,
|
|
|| Arc::clone(&renderables),
|
|
Arc::clone(&scene.textures),
|
|
[125, 149, 173, 255],
|
|
render_limits,
|
|
)?;
|
|
let refresh_total = refresh_started.elapsed();
|
|
let warm_started = Instant::now();
|
|
let warm = renderer.render_scene(
|
|
scene.scene_sequence.saturating_add(1),
|
|
scene.camera,
|
|
|| panic!("unchanged scene was transferred to the renderer again"),
|
|
Arc::clone(&scene.textures),
|
|
[125, 149, 173, 255],
|
|
render_limits,
|
|
)?;
|
|
let warm_render_total = warm_started.elapsed();
|
|
let jpeg_started = Instant::now();
|
|
let rgb = warm
|
|
.frame
|
|
.value
|
|
.rgba
|
|
.chunks_exact(4)
|
|
.flat_map(|pixel| pixel[..3].iter().copied())
|
|
.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:{:?},scene_refresh_total_ms:{},scene_refresh:{:?},warm_render_total_ms:{},warm_render:{:?},jpeg_encode_ms:{},profile_total_ms:{}",
|
|
scene_total.as_millis(),
|
|
scene.timings.terrain_wait.as_millis(),
|
|
scene
|
|
.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,
|
|
refresh_total.as_millis(),
|
|
refresh.timings,
|
|
warm_render_total.as_millis(),
|
|
warm.timings,
|
|
jpeg_encode.as_millis(),
|
|
total_started.elapsed().as_millis(),
|
|
);
|
|
if refresh_total >= metacrate_game_loop::DEFAULT_VIEWPORT_FRAME_INTERVAL {
|
|
return Err("cached scene refresh exceeded the viewport frame interval".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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>,
|
|
}
|
|
|
|
impl SceneSource for DiagnosticSceneSource {
|
|
fn capture_scene(
|
|
&self,
|
|
generation: u64,
|
|
cancellation: CancellationToken,
|
|
) -> VisionFuture<'_, SceneSnapshot> {
|
|
Box::pin(async move {
|
|
let mut scene = self.inner.capture_scene(generation, cancellation).await?;
|
|
if let Some(material_override) = &self.material_override {
|
|
for renderable in Arc::make_mut(&mut scene.entities)
|
|
.iter_mut()
|
|
.filter(|entity| entity.kind == SceneEntityKind::Object)
|
|
.flat_map(|entity| &mut entity.renderables)
|
|
{
|
|
match &mut renderable.material {
|
|
metacrate_rendering_wgpu::Material::BlinnPhong(material) => {
|
|
if material_override == "solid" {
|
|
*material = metacrate_rendering_wgpu::BlinnPhongMaterial {
|
|
diffuse_color_srgb: [255, 0, 255, 255],
|
|
fullbright: true,
|
|
double_sided: true,
|
|
..Default::default()
|
|
};
|
|
} else if material_override == "unlit-white" {
|
|
material.diffuse_color_srgb = [255; 4];
|
|
material.fullbright = true;
|
|
}
|
|
}
|
|
metacrate_rendering_wgpu::Material::Pbr(material) => {
|
|
if material_override == "solid" {
|
|
*material = metacrate_rendering_wgpu::PbrMaterial {
|
|
base_color_srgb: [0, 255, 255, 255],
|
|
emissive_factor_srgb: [0, 255, 255],
|
|
metallic_factor: 0.0,
|
|
roughness_factor: 1.0,
|
|
double_sided: true,
|
|
..Default::default()
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(scene)
|
|
})
|
|
}
|
|
}
|
|
|
|
async fn login(
|
|
owner: &LibremetaverseClientOwner,
|
|
environment: &BTreeMap<String, String>,
|
|
user: &'static str,
|
|
password: &'static str,
|
|
) -> Result<(Box<dyn GridSession>, CancellationTokenSource), Box<dyn Error>> {
|
|
let cancellation = CancellationTokenSource::new();
|
|
let backend = owner.session_backend(GridConnection {
|
|
login_url: EndpointUrl::parse("grid.login_url", &required(environment, "GRID_LOGIN_URL")?)?,
|
|
avatar_name: required(environment, user)?,
|
|
password: SecretString::new("grid.password", required(environment, password)?)?,
|
|
})?;
|
|
let session = backend.login(1, cancellation.token()).await?;
|
|
Ok((session, cancellation))
|
|
}
|
|
|
|
async fn wait_ready(
|
|
account: &str,
|
|
session: &mut Box<dyn GridSession>,
|
|
cancellation: &CancellationTokenSource,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
let signal = tokio::time::timeout(
|
|
Duration::from_secs(30),
|
|
session.next_signal(cancellation.token()),
|
|
)
|
|
.await
|
|
.map_err(|_| format!("timed out waiting for {account} session readiness"))?;
|
|
match signal {
|
|
SessionSignal::Ready => Ok(()),
|
|
signal => Err(format!("unexpected {account} session signal: {signal:?}").into()),
|
|
}
|
|
}
|
|
|
|
async fn wait_scene_settled(owner: &LibremetaverseClientOwner) -> Result<(), Box<dyn Error>> {
|
|
let deadline = Instant::now() + Duration::from_secs(30);
|
|
let minimum = Instant::now() + Duration::from_secs(10);
|
|
let mut previous = 0;
|
|
let mut stable = 0;
|
|
loop {
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
let simulator = owner
|
|
.client()
|
|
.network()
|
|
.current_sim()
|
|
.ok_or("simulator unavailable while settling scene")?;
|
|
let count = simulator
|
|
.objects_primitives
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.len();
|
|
if count == previous && count != 0 {
|
|
stable += 1;
|
|
} else {
|
|
stable = 0;
|
|
previous = count;
|
|
}
|
|
if (Instant::now() >= minimum && stable >= 3) || Instant::now() >= deadline {
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn live_environment() -> Result<BTreeMap<String, String>, Box<dyn Error>> {
|
|
let mut values = std::env::vars().collect::<BTreeMap<_, _>>();
|
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.env");
|
|
for line in std::fs::read_to_string(path)?.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
let Some((key, value)) = line.split_once('=') else {
|
|
continue;
|
|
};
|
|
values.entry(key.trim().to_owned()).or_insert_with(|| {
|
|
value
|
|
.trim()
|
|
.trim_matches(|character| character == '\'' || character == '"')
|
|
.to_owned()
|
|
});
|
|
}
|
|
Ok(values)
|
|
}
|
|
|
|
fn required(
|
|
environment: &BTreeMap<String, String>,
|
|
key: &'static str,
|
|
) -> Result<String, Box<dyn Error>> {
|
|
environment
|
|
.get(key)
|
|
.filter(|value| !value.is_empty())
|
|
.cloned()
|
|
.ok_or_else(|| format!("missing live test setting {key}").into())
|
|
}
|
|
|
|
#[allow(
|
|
clippy::cast_possible_truncation,
|
|
clippy::cast_possible_wrap,
|
|
clippy::cast_sign_loss
|
|
)] // Coordinates are already clamped inside the non-negative simulator extent.
|
|
fn avatar_forward_camera(
|
|
avatar: Vector3,
|
|
forward: Vector3,
|
|
) -> Result<(Vector3, Vector3), Box<dyn Error>> {
|
|
let length = (forward.x * forward.x + forward.y * forward.y + forward.z * forward.z).sqrt();
|
|
if !length.is_finite() || length < f32::EPSILON {
|
|
return Err("avatar facing direction unavailable".into());
|
|
}
|
|
let position = Vector3 {
|
|
x: avatar.x,
|
|
y: avatar.y,
|
|
z: avatar.z + 1.6,
|
|
};
|
|
Ok((
|
|
position,
|
|
Vector3 {
|
|
x: position.x + forward.x / length * 24.0,
|
|
y: position.y + forward.y / length * 24.0,
|
|
z: position.z + forward.z / length * 24.0,
|
|
},
|
|
))
|
|
}
|
|
|
|
#[test]
|
|
fn evidence_camera_uses_avatar_eye_position_and_facing() {
|
|
let avatar = Vector3 {
|
|
x: 10.0,
|
|
y: 20.0,
|
|
z: 30.0,
|
|
};
|
|
let (position, target) = avatar_forward_camera(avatar, Vector3::unit_y()).unwrap();
|
|
assert_eq!(
|
|
position,
|
|
Vector3 {
|
|
x: 10.0,
|
|
y: 20.0,
|
|
z: 31.6
|
|
}
|
|
);
|
|
assert_eq!(
|
|
target,
|
|
Vector3 {
|
|
x: 10.0,
|
|
y: 44.0,
|
|
z: 31.6
|
|
}
|
|
);
|
|
}
|