Prefetch nearby scene assets in the background
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 11:06:28 +02:00
parent d85f7d22f2
commit b59bc4c3cd
12 changed files with 259 additions and 32 deletions

View File

@@ -38,6 +38,7 @@ behavior:
vision: vision:
max_distance_meters: 64 max_distance_meters: 64
asset_cache_max_bytes: 2147483648
reconnect: reconnect:
initial_delay_milliseconds: 1000 initial_delay_milliseconds: 1000
maximum_delay_seconds: 60 maximum_delay_seconds: 60

View File

@@ -37,3 +37,11 @@ Files: `src/grid_manager.rs`, `src/client_core.rs`.
the existing UDP asset transfer path instead of reporting a missing asset immediately. the existing UDP asset transfer path instead of reporting a missing asset immediately.
File: `src/asset_manager.rs`. File: `src/asset_manager.rs`.
## Deterministic persistent-asset LRU
- Successful immutable asset-cache reads explicitly refresh the file modification timestamp.
Pruning uses that timestamp as its cross-platform LRU order instead of filesystem access time,
which can be disabled or coarsened by `noatime` and `relatime` mounts.
Files: `src/asset_cache.rs`, `src/asset_pipeline_semantics.rs`.

View File

@@ -189,6 +189,10 @@ impl AssetCache {
if bytes.len() as u64 != metadata.len() { if bytes.len() as u64 != metadata.len() {
return Err(Error::InvalidOperation); return Err(Error::InvalidOperation);
} }
// Use the modification timestamp as a portable, explicit LRU marker.
// Filesystems commonly mount with relatime/noatime, so access time is
// not reliable enough for cache eviction.
let _ = fs::File::open(path).and_then(|file| file.set_modified(SystemTime::now()));
Ok(Some(bytes)) Ok(Some(bytes))
} }
@@ -353,10 +357,7 @@ impl AssetCache {
entries.push(( entries.push((
entry.path(), entry.path(),
metadata.len(), metadata.len(),
metadata metadata.modified().unwrap_or(UNIX_EPOCH),
.accessed()
.or_else(|_| metadata.modified())
.unwrap_or(UNIX_EPOCH),
)); ));
} }
} }

View File

@@ -137,13 +137,16 @@ fn custom_cache_naming_is_used_but_cannot_escape_the_cache_directory() {
#[tokio::test] #[tokio::test]
async fn cache_pruning_reclaims_old_entries_to_below_the_limit() { async fn cache_pruning_reclaims_old_entries_to_below_the_limit() {
let (_client, cache, path) = fixture(12); let (_client, cache, path) = fixture(12);
let first = UUID::random().unwrap();
let second = UUID::random().unwrap();
cache cache
.save_asset_to_cache_with_uuid_bytes(UUID::random().unwrap(), vec![1; 8]) .save_asset_to_cache_with_uuid_bytes(first, vec![1; 8])
.unwrap(); .unwrap();
std::thread::sleep(std::time::Duration::from_millis(5)); std::thread::sleep(std::time::Duration::from_millis(20));
cache cache
.save_asset_to_cache_with_uuid_bytes(UUID::random().unwrap(), vec![2; 8]) .save_asset_to_cache_with_uuid_bytes(second, vec![2; 8])
.unwrap(); .unwrap();
cache.get_cached_asset_bytes_with_uuid(first).unwrap();
cache.prune(None).await.unwrap(); cache.prune(None).await.unwrap();
let bytes: u64 = fs::read_dir(&path) let bytes: u64 = fs::read_dir(&path)
.unwrap() .unwrap()
@@ -152,5 +155,7 @@ async fn cache_pruning_reclaims_old_entries_to_below_the_limit() {
.map(|metadata| metadata.len()) .map(|metadata| metadata.len())
.sum(); .sum();
assert!(bytes <= 10); assert!(bytes <= 10);
assert!(path.join(first.to_string()).is_file());
assert!(!path.join(second.to_string()).exists());
fs::remove_dir_all(path).unwrap(); fs::remove_dir_all(path).unwrap();
} }

View File

@@ -167,8 +167,26 @@ impl LibremetaverseClientOwner {
/// ///
/// Returns a backend configuration error if the shared client defaults are invalid. /// Returns a backend configuration error if the shared client defaults are invalid.
pub fn new() -> Result<Self, BackendError> { pub fn new() -> Result<Self, BackendError> {
Self::new_with_asset_cache_max_bytes(2 * 1024 * 1024 * 1024)
}
/// Builds the shared client with the configured persistent asset-cache limit.
///
/// # Errors
///
/// Returns a backend configuration error for a zero or unsupported limit.
pub fn new_with_asset_cache_max_bytes(max_bytes: u64) -> Result<Self, BackendError> {
let mut settings = libremetaverse::Settings::default(); let mut settings = libremetaverse::Settings::default();
settings.asset_cache_mut().dir = metacrate_cache_dir()?.to_string_lossy().into_owned(); settings.asset_cache_mut().dir = metacrate_cache_dir()?.to_string_lossy().into_owned();
settings.asset_cache_mut().max_size =
i64::try_from(max_bytes).map_err(|_| BackendError::Configuration {
component: "persistent asset cache limit",
})?;
if settings.asset_cache().max_size == 0 {
return Err(BackendError::Configuration {
component: "persistent asset cache limit",
});
}
let client = libremetaverse::GridClientBuilder::default() let client = libremetaverse::GridClientBuilder::default()
.with_settings(settings) .with_settings(settings)
.build() .build()
@@ -1552,12 +1570,15 @@ mod tests {
#[cfg(feature = "live-grid")] #[cfg(feature = "live-grid")]
#[test] #[test]
fn asset_cache_is_platform_scoped_to_metacrate() { fn asset_cache_is_platform_scoped_to_metacrate() {
let cache = metacrate_cache_dir().expect("platform cache directory"); let owner = LibremetaverseClientOwner::new().expect("client owner");
let settings = owner.client().settings_ref().asset_cache();
let cache = std::path::PathBuf::from(settings.dir);
assert!(cache.is_absolute()); assert!(cache.is_absolute());
assert!( assert!(
cache cache
.components() .components()
.any(|part| part.as_os_str() == "metacrate") .any(|part| part.as_os_str() == "metacrate")
); );
assert_eq!(settings.max_size, 2 * 1024 * 1024 * 1024);
} }
} }

View File

@@ -363,6 +363,20 @@ impl AgentConfig {
if !self.behavior.is_valid() { if !self.behavior.is_valid() {
return Err(ConfigError::InvalidBehavior); return Err(ConfigError::InvalidBehavior);
} }
if !(64 * 1024 * 1024..=64 * 1024 * 1024 * 1024)
.contains(&self.vision.asset_cache_max_bytes)
{
return Err(ConfigError::UnsafeLimit {
field: "vision.asset_cache_max_bytes",
value: self
.vision
.asset_cache_max_bytes
.try_into()
.unwrap_or(usize::MAX),
minimum: 64 * 1024 * 1024,
maximum: usize::try_from(64_u64 * 1024 * 1024 * 1024).unwrap_or(usize::MAX),
});
}
if !self.vision.valid() { if !self.vision.valid() {
return Err(ConfigError::UnsafeLimit { return Err(ConfigError::UnsafeLimit {
field: "vision.max_distance_meters", field: "vision.max_distance_meters",
@@ -1129,6 +1143,7 @@ struct RawBehavior {
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
struct RawVision { struct RawVision {
max_distance_meters: Option<u32>, max_distance_meters: Option<u32>,
asset_cache_max_bytes: Option<u64>,
} }
#[derive(Clone, Default, Deserialize)] #[derive(Clone, Default, Deserialize)]
@@ -1585,6 +1600,10 @@ fn resolve<E: Environment>(
}, },
vision: crate::vision::VisionLimits { vision: crate::vision::VisionLimits {
max_distance_meters: raw.vision.max_distance_meters.unwrap_or(64), max_distance_meters: raw.vision.max_distance_meters.unwrap_or(64),
asset_cache_max_bytes: raw
.vision
.asset_cache_max_bytes
.unwrap_or(2 * 1024 * 1024 * 1024),
..crate::vision::VisionLimits::default() ..crate::vision::VisionLimits::default()
}, },
reconnect, reconnect,
@@ -2011,6 +2030,17 @@ mod tests {
.. ..
}) })
)); ));
let cache = temporary_file(
"vision-cache.yml",
"llm:\n endpoint_url: https://llm.invalid/chat\n api_key: placeholder\nvision:\n asset_cache_max_bytes: 1073741824\n",
);
let config = ConfigLoader::new()
.with_file(&cache)
.with_environment(MapEnvironment::default())
.load()
.expect("configured asset cache");
assert_eq!(config.vision.asset_cache_max_bytes, 1024 * 1024 * 1024);
} }
#[cfg(unix)] #[cfg(unix)]

View File

@@ -281,7 +281,9 @@ async fn run_live(
let connection = config.grid.clone().ok_or_else(|| { let connection = config.grid.clone().ok_or_else(|| {
CliError("validated live configuration did not contain a grid connection".into()) CliError("validated live configuration did not contain a grid connection".into())
})?; })?;
let owner = LibremetaverseClientOwner::new()?; 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)?;
let backend = match owner.session_backend_with_agent_services( let backend = match owner.session_backend_with_agent_services(
connection, connection,
@@ -291,6 +293,7 @@ async fn run_live(
) { ) {
Ok(backend) => backend, Ok(backend) => backend,
Err(error) => { Err(error) => {
live.scene_source.stop_prefetch().await;
live.interaction.shutdown().await?; live.interaction.shutdown().await?;
live.behavior.shutdown().await?; live.behavior.shutdown().await?;
return Err(error.into()); return Err(error.into());
@@ -348,6 +351,7 @@ async fn run_live(
Err(_) => Err(CliError("timed out waiting for full grid readiness".into())), Err(_) => Err(CliError("timed out waiting for full grid readiness".into())),
}; };
if let Err(error) = readiness { if let Err(error) = readiness {
live.scene_source.stop_prefetch().await;
let session_result = handle.shutdown().await; let session_result = handle.shutdown().await;
let interaction_result = live.interaction.shutdown().await; let interaction_result = live.interaction.shutdown().await;
let behavior_result = live.behavior.shutdown().await; let behavior_result = live.behavior.shutdown().await;
@@ -356,6 +360,7 @@ async fn run_live(
behavior_result?; behavior_result?;
return Err(error.into()); return Err(error.into());
} }
live.scene_source.stop_prefetch().await;
let session_result = handle.shutdown().await; let session_result = handle.shutdown().await;
let interaction_result = live.interaction.shutdown().await; let interaction_result = live.interaction.shutdown().await;
let behavior_result = live.behavior.shutdown().await; let behavior_result = live.behavior.shutdown().await;
@@ -562,6 +567,7 @@ async fn run_live(
let _ = live.observability.record(shutdown_event); let _ = live.observability.record(shutdown_event);
control_target.mark_stopping(); control_target.mark_stopping();
live.landmark_roaming.shutdown().await; live.landmark_roaming.shutdown().await;
live.scene_source.stop_prefetch().await;
let session_result = handle.shutdown().await; let session_result = handle.shutdown().await;
let interaction_result = live.interaction.shutdown().await; let interaction_result = live.interaction.shutdown().await;
let behavior_result = live.behavior.shutdown().await; let behavior_result = live.behavior.shutdown().await;
@@ -600,6 +606,7 @@ struct LiveInteractions {
build_control: Arc<dyn metacrate_grid_agent::BuildControl>, build_control: Arc<dyn metacrate_grid_agent::BuildControl>,
vision: vision:
Arc<metacrate_grid_agent::VisionService<metacrate_grid_agent::LibremetaverseSceneSource>>, Arc<metacrate_grid_agent::VisionService<metacrate_grid_agent::LibremetaverseSceneSource>>,
scene_source: Arc<metacrate_grid_agent::LibremetaverseSceneSource>,
world: Arc<metacrate_grid_agent::LibremetaverseWorldSnapshotSource>, world: Arc<metacrate_grid_agent::LibremetaverseWorldSnapshotSource>,
} }
@@ -740,13 +747,10 @@ fn start_live_interactions(
config.storage_path.join("mentra"), config.storage_path.join("mentra"),
)?); )?);
let vision_limits = config.vision; let vision_limits = config.vision;
let vision = Arc::new( let scene_source = Arc::new(LibremetaverseSceneSource::new(owner, vision_limits));
VisionService::new( scene_source.start_prefetch();
Arc::new(LibremetaverseSceneSource::new(owner, vision_limits)), let vision =
vision_limits, Arc::new(VisionService::new(Arc::clone(&scene_source), vision_limits)?.prefer_gpu());
)?
.prefer_gpu(),
);
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);
@@ -776,6 +780,7 @@ fn start_live_interactions(
landmark_roaming, landmark_roaming,
build_control, build_control,
vision, vision,
scene_source,
world, world,
}) })
} }

View File

@@ -81,6 +81,7 @@ pub struct VisionLimits {
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
pub max_distance_meters: u32, pub max_distance_meters: u32,
pub asset_cache_max_bytes: u64,
pub max_entities: usize, pub max_entities: usize,
pub max_triangles: usize, pub max_triangles: usize,
pub max_texture_fetches: usize, pub max_texture_fetches: usize,
@@ -98,6 +99,7 @@ impl Default for VisionLimits {
width: 1920, width: 1920,
height: 1080, height: 1080,
max_distance_meters: 64, max_distance_meters: 64,
asset_cache_max_bytes: 2 * 1024 * 1024 * 1024,
max_entities: 2_048, max_entities: 2_048,
max_triangles: 1_048_576, max_triangles: 1_048_576,
max_texture_fetches: 1_024, max_texture_fetches: 1_024,
@@ -116,6 +118,7 @@ impl VisionLimits {
&& (64..=1_080).contains(&self.height) && (64..=1_080).contains(&self.height)
&& self.width as usize * self.height as usize <= 2_073_600 && self.width as usize * self.height as usize <= 2_073_600
&& (1..=1_024).contains(&self.max_distance_meters) && (1..=1_024).contains(&self.max_distance_meters)
&& (64 * 1024 * 1024..=64 * 1024 * 1024 * 1024).contains(&self.asset_cache_max_bytes)
&& (1..=4_096).contains(&self.max_entities) && (1..=4_096).contains(&self.max_entities)
&& (1..=1_048_576).contains(&self.max_triangles) && (1..=1_048_576).contains(&self.max_triangles)
&& self.max_texture_fetches <= 1_024 && self.max_texture_fetches <= 1_024
@@ -945,6 +948,8 @@ pub struct LibremetaverseSceneSource {
agent: Arc<libremetaverse::AgentManager>, agent: Arc<libremetaverse::AgentManager>,
limits: VisionLimits, limits: VisionLimits,
cache: Arc<Mutex<SceneCache>>, cache: Arc<Mutex<SceneCache>>,
prefetch_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
prefetch_passes: AtomicU64,
} }
#[cfg(feature = "live-grid")] #[cfg(feature = "live-grid")]
@@ -954,6 +959,8 @@ struct SceneCache {
textures: std::collections::BTreeMap<UUID, CachedTexture>, textures: std::collections::BTreeMap<UUID, CachedTexture>,
texture_pixels: usize, texture_pixels: usize,
geometry: std::collections::HashMap<UUID, CachedGeometry>, geometry: std::collections::HashMap<UUID, CachedGeometry>,
legacy_materials: std::collections::BTreeMap<UUID, libremetaverse::materials::LegacyMaterial>,
pbr_materials: std::collections::BTreeMap<UUID, libremetaverse::assets::AssetMaterial>,
access: u64, access: u64,
} }
@@ -989,6 +996,57 @@ impl LibremetaverseSceneSource {
agent: owner.agent(), agent: owner.agent(),
limits, limits,
cache: Arc::new(Mutex::new(SceneCache::default())), cache: Arc::new(Mutex::new(SceneCache::default())),
prefetch_task: Mutex::new(None),
prefetch_passes: AtomicU64::new(0),
}
}
/// Continuously warms the same asset, decode, material, and geometry caches used by capture.
pub fn start_prefetch(self: &Arc<Self>) {
let mut task = lock(&self.prefetch_task);
if task.is_some() {
return;
}
let source = Arc::downgrade(self);
*task = Some(tokio::spawn(async move {
loop {
let Some(source) = source.upgrade() else {
break;
};
if source.client.network().current_sim().is_some()
&& source
.capture_scene(0, CancellationToken::default())
.await
.is_ok()
{
source.prefetch_passes.fetch_add(1, Ordering::AcqRel);
}
drop(source);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}));
}
/// Stops background prefetching before logout or runtime shutdown.
pub async fn stop_prefetch(&self) {
let task = lock(&self.prefetch_task).take();
if let Some(task) = task {
task.abort();
let _ = task.await;
}
}
#[must_use]
pub fn prefetch_passes(&self) -> u64 {
self.prefetch_passes.load(Ordering::Acquire)
}
}
#[cfg(feature = "live-grid")]
impl Drop for LibremetaverseSceneSource {
fn drop(&mut self) {
if let Some(task) = lock(&self.prefetch_task).take() {
task.abort();
} }
} }
} }
@@ -1065,7 +1123,11 @@ impl SceneSource for LibremetaverseSceneSource {
} }
} }
let camera = &self.agent.movement.camera; let camera = &self.agent.movement.camera;
let position = camera.position(); let position = if generation == 0 {
self.agent.sim_position()
} else {
camera.position()
};
let forward = native_camera_forward(camera); let forward = native_camera_forward(camera);
let up = camera.up_axis(); let up = camera.up_axis();
let entity_limit = self.limits.max_entities.saturating_sub(2); let entity_limit = self.limits.max_entities.saturating_sub(2);
@@ -1186,6 +1248,8 @@ impl SceneSource for LibremetaverseSceneSource {
cache.textures.clear(); cache.textures.clear();
cache.texture_pixels = 0; cache.texture_pixels = 0;
cache.geometry.clear(); cache.geometry.clear();
cache.legacy_materials.clear();
cache.pbr_materials.clear();
cache.access = 0; cache.access = 0;
} }
} }
@@ -1276,19 +1340,36 @@ impl SceneSource for LibremetaverseSceneSource {
ids ids
}); });
let assets = self.client.assets(); let assets = self.client.assets();
pbr_material_ids.truncate(self.limits.max_texture_fetches / 4); let missing_legacy_materials = {
let legacy_material_fetches = usize::from(!legacy_material_ids.is_empty()); let cache = lock(&self.cache);
let legacy_materials = if legacy_material_ids.is_empty() { legacy_material_ids
Vec::new() .iter()
} else { .any(|id| !cache.legacy_materials.contains_key(id))
self.client };
let legacy_material_fetches = usize::from(missing_legacy_materials);
if missing_legacy_materials {
let fetched = self
.client
.objects() .objects()
.request_materials_with_simulator_cancellation_token( .request_materials_with_simulator_cancellation_token(
simulator.clone(), simulator.clone(),
Some(cancellation.clone()), Some(cancellation.clone()),
) )
.await .await
.map_or_else(|_| Vec::new(), Iterator::collect) .map_or_else(|_| Vec::new(), Iterator::collect::<Vec<_>>);
let mut cache = lock(&self.cache);
cache.legacy_materials.extend(
fetched
.into_iter()
.map(|material| (material.id(), material)),
);
}
let legacy_materials = {
let cache = lock(&self.cache);
legacy_material_ids
.iter()
.filter_map(|id| cache.legacy_materials.get(id).cloned())
.collect::<Vec<_>>()
}; };
for material in &legacy_materials { for material in &legacy_materials {
for id in [material.normal_map(), material.specular_map()] { for id in [material.normal_map(), material.specular_map()] {
@@ -1298,7 +1379,31 @@ impl SceneSource for LibremetaverseSceneSource {
} }
} }
let mut texture_bytes = 0usize; let mut texture_bytes = 0usize;
let mut pbr_materials = Vec::new(); let mut pbr_materials = {
let cache = lock(&self.cache);
pbr_material_ids
.iter()
.filter_map(|id| {
cache
.pbr_materials
.get(id)
.cloned()
.map(|value| (*id, value))
})
.collect::<Vec<_>>()
};
for (_, material) in &pbr_materials {
for texture in material.texture_ids() {
if texture != UUID::zero() && !texture_ids.contains(&texture) {
texture_ids.push(texture);
}
}
}
{
let cache = lock(&self.cache);
pbr_material_ids.retain(|id| !cache.pbr_materials.contains_key(id));
}
pbr_material_ids.truncate(self.limits.max_texture_fetches / 4);
let mut pending_materials = tokio::task::JoinSet::new(); let mut pending_materials = tokio::task::JoinSet::new();
let mut material_jobs = std::collections::VecDeque::from(pbr_material_ids.clone()); let mut material_jobs = std::collections::VecDeque::from(pbr_material_ids.clone());
for _ in 0..16 { for _ in 0..16 {
@@ -1337,6 +1442,7 @@ impl SceneSource for LibremetaverseSceneSource {
texture_ids.push(texture); texture_ids.push(texture);
} }
} }
lock(&self.cache).pbr_materials.insert(id, material.clone());
pbr_materials.push((id, material)); pbr_materials.push((id, material));
} }
} }

View File

@@ -33,6 +33,22 @@ fn uuid(value: u32) -> UUID {
UUID::new_with_string(format!("00000000-0000-4000-8000-{value:012}")).unwrap() UUID::new_with_string(format!("00000000-0000-4000-8000-{value:012}")).unwrap()
} }
#[cfg(feature = "live-grid")]
#[tokio::test]
async fn background_prefetch_is_idempotent_and_stoppable_without_a_simulator() {
let owner = crate::backend::LibremetaverseClientOwner::new().unwrap();
let source = Arc::new(LibremetaverseSceneSource::new(
&owner,
VisionLimits::default(),
));
source.start_prefetch();
source.start_prefetch();
tokio::task::yield_now().await;
source.stop_prefetch().await;
source.stop_prefetch().await;
assert_eq!(source.prefetch_passes(), 0);
}
fn triangle(z: f32, color: [u8; 4]) -> SceneTriangle { fn triangle(z: f32, color: [u8; 4]) -> SceneTriangle {
SceneTriangle { SceneTriangle {
vertices: [[-1.0, -1.0, z], [1.0, -1.0, z], [0.0, 1.0, z]], vertices: [[-1.0, -1.0, z], [1.0, -1.0, z], [0.0, 1.0, z]],

View File

@@ -111,6 +111,13 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
let renderer_owner = LibremetaverseClientOwner::new()?; let renderer_owner = LibremetaverseClientOwner::new()?;
let (mut renderer_session, renderer_cancel) = let (mut renderer_session, renderer_cancel) =
login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?; 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 { let result = async {
wait_ready("renderer", &mut renderer_session, &renderer_cancel).await?; wait_ready("renderer", &mut renderer_session, &renderer_cancel).await?;
@@ -143,17 +150,26 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
.camera .camera
.set_vertical_fov_angle(65_f32.to_radians())?; .set_vertical_fov_angle(65_f32.to_radians())?;
let limits = VisionLimits { tokio::time::timeout(Duration::from_mins(10), async {
minimum_interval: Duration::ZERO, while scene_source.prefetch_passes() == 0 {
..VisionLimits::default() 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!( println!(
"LIVE_RENDER_SCOPE=max_distance_meters:{}", "LIVE_RENDER_SCOPE=max_distance_meters:{}",
limits.max_distance_meters limits.max_distance_meters
); );
let vision = VisionService::new( let vision = VisionService::new(
Arc::new(DiagnosticSceneSource { Arc::new(DiagnosticSceneSource {
inner: LibremetaverseSceneSource::new(&renderer_owner, limits), inner: Arc::clone(&scene_source),
material_override: std::env::var("METACRATE_LIVE_RENDER_MATERIAL_OVERRIDE").ok(), material_override: std::env::var("METACRATE_LIVE_RENDER_MATERIAL_OVERRIDE").ok(),
}), }),
limits, limits,
@@ -173,6 +189,7 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
let mut previous_missing = u32::MAX; let mut previous_missing = u32::MAX;
let mut stable_missing = 0_u32; let mut stable_missing = 0_u32;
for frame in 1..=frames { for frame in 1..=frames {
let frame_started = Instant::now();
let next = vision let next = vision
.capture( .capture(
&format!("live-renderer-evidence-{frame}"), &format!("live-renderer-evidence-{frame}"),
@@ -180,6 +197,10 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
renderer_cancel.token(), renderer_cancel.token(),
) )
.await?; .await?;
println!(
"LIVE_RENDER_FRAME_{frame}_MILLISECONDS={}",
frame_started.elapsed().as_millis()
);
println!("LIVE_RENDER_FRAME_{frame}_SUMMARY={}", next.summary); println!("LIVE_RENDER_FRAME_{frame}_SUMMARY={}", next.summary);
std::fs::write(&output, &next.jpeg)?; std::fs::write(&output, &next.jpeg)?;
if next.completeness.textures_missing == previous_missing { if next.completeness.textures_missing == previous_missing {
@@ -200,12 +221,13 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
} }
.await; .await;
scene_source.stop_prefetch().await;
let _ = renderer_session.logout(renderer_cancel.token()).await; let _ = renderer_session.logout(renderer_cancel.token()).await;
result result
} }
struct DiagnosticSceneSource { struct DiagnosticSceneSource {
inner: LibremetaverseSceneSource, inner: Arc<LibremetaverseSceneSource>,
material_override: Option<String>, material_override: Option<String>,
} }

View File

@@ -78,6 +78,14 @@ objects are selected by that distance; avatar attachments are rigged through
their linkset hierarchy and included only when the camera is focused on the their linkset hierarchy and included only when the camera is focused on the
avatar, after world-object capacity is reserved. avatar, after world-object capacity is reserved.
Raw mesh, texture, and material assets are treated as immutable by UUID and persist in the
platform MetaCrate cache. `vision.asset_cache_max_bytes` bounds that cache with a 2 GiB default;
successful reads refresh its LRU order and pruning removes the least recently used assets first.
While logged in, the live scene source continuously runs its normal distance-bounded collection,
asset fetch, texture decode, material parse, and mesh reconstruction path in the background. A
snapshot therefore reuses the warm persistent, decoded, geometry, material, and GPU caches instead
of beginning asset discovery when the model asks to look.
`behavior_camera_set` gives an authorized model a bounded position, target, and `behavior_camera_set` gives an authorized model a bounded position, target, and
vertical field of view; `behavior_camera_reset` restores the avatar-facing vertical field of view; `behavior_camera_reset` restores the avatar-facing
view. Full avatar body/attachment mesh fidelity and baking remain separately view. Full avatar body/attachment mesh fidelity and baking remain separately

View File

@@ -55,7 +55,11 @@ deployments should use absolute paths.
Downloaded grid assets use the platform cache directory: `$XDG_CACHE_HOME/metacrate` Downloaded grid assets use the platform cache directory: `$XDG_CACHE_HOME/metacrate`
or `$HOME/.cache/metacrate` on Linux, `$HOME/Library/Caches/metacrate` on macOS, or `$HOME/.cache/metacrate` on Linux, `$HOME/Library/Caches/metacrate` on macOS,
and `%LOCALAPPDATA%\metacrate\cache` on Windows. and `%LOCALAPPDATA%\metacrate\cache` on Windows. The cache treats UUID-addressed assets as
immutable, defaults to a 2 GiB LRU limit, and is configurable with
`vision.asset_cache_max_bytes`. The live scene source continuously prefetches and prepares assets
inside the configured view radius while the avatar is logged in; prefetching is stopped before
logout and shutdown.
## Endpoint, grid identity, and authority ## Endpoint, grid identity, and authority