Files
MetaCrate/crates/metacrate-grid-agent/tests/live_renderer.rs
Chili Palmer b59bc4c3cd
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled
Prefetch nearby scene assets in the background
2026-08-23 11:06:28 +02:00

430 lines
15 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 = "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 (mut renderer_session, renderer_cancel) =
login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
let limits = VisionLimits {
minimum_interval: Duration::ZERO,
..VisionLimits::default()
};
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?;
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();
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")?;
println!("LIVE_RENDER_OUTPUT={}", output.display());
println!("LIVE_RENDER_SHA256={}", capture.image_sha256);
Ok::<(), Box<dyn Error>>(())
}
.await;
scene_source.stop_prefetch().await;
let _ = renderer_session.logout(renderer_cancel.token()).await;
result
}
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 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
}
);
}