From b59bc4c3cd644e70c622a0310f200b1cc08f08bb Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 23 Aug 2026 11:06:28 +0200 Subject: [PATCH] Prefetch nearby scene assets in the background --- config/grid-agent.example.yml | 1 + crates/libremetaverse/CHANGES.md | 8 ++ crates/libremetaverse/src/asset_cache.rs | 9 +- .../src/asset_pipeline_semantics.rs | 11 +- crates/metacrate-grid-agent/src/backend.rs | 23 +++- crates/metacrate-grid-agent/src/config.rs | 30 +++++ crates/metacrate-grid-agent/src/main.rs | 21 +-- crates/metacrate-grid-agent/src/vision.rs | 124 ++++++++++++++++-- .../metacrate-grid-agent/src/vision_tests.rs | 16 +++ .../tests/live_renderer.rs | 34 ++++- docs/grid-agent-llm.md | 8 ++ docs/grid-agent-operations.md | 6 +- 12 files changed, 259 insertions(+), 32 deletions(-) diff --git a/config/grid-agent.example.yml b/config/grid-agent.example.yml index 5a9a521..5f7464d 100644 --- a/config/grid-agent.example.yml +++ b/config/grid-agent.example.yml @@ -38,6 +38,7 @@ behavior: vision: max_distance_meters: 64 + asset_cache_max_bytes: 2147483648 reconnect: initial_delay_milliseconds: 1000 maximum_delay_seconds: 60 diff --git a/crates/libremetaverse/CHANGES.md b/crates/libremetaverse/CHANGES.md index a7a06e2..fd825db 100644 --- a/crates/libremetaverse/CHANGES.md +++ b/crates/libremetaverse/CHANGES.md @@ -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. 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`. diff --git a/crates/libremetaverse/src/asset_cache.rs b/crates/libremetaverse/src/asset_cache.rs index fa25a37..4719e95 100644 --- a/crates/libremetaverse/src/asset_cache.rs +++ b/crates/libremetaverse/src/asset_cache.rs @@ -189,6 +189,10 @@ impl AssetCache { if bytes.len() as u64 != metadata.len() { 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)) } @@ -353,10 +357,7 @@ impl AssetCache { entries.push(( entry.path(), metadata.len(), - metadata - .accessed() - .or_else(|_| metadata.modified()) - .unwrap_or(UNIX_EPOCH), + metadata.modified().unwrap_or(UNIX_EPOCH), )); } } diff --git a/crates/libremetaverse/src/asset_pipeline_semantics.rs b/crates/libremetaverse/src/asset_pipeline_semantics.rs index d39b499..4e72960 100644 --- a/crates/libremetaverse/src/asset_pipeline_semantics.rs +++ b/crates/libremetaverse/src/asset_pipeline_semantics.rs @@ -137,13 +137,16 @@ fn custom_cache_naming_is_used_but_cannot_escape_the_cache_directory() { #[tokio::test] async fn cache_pruning_reclaims_old_entries_to_below_the_limit() { let (_client, cache, path) = fixture(12); + let first = UUID::random().unwrap(); + let second = UUID::random().unwrap(); 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(); - std::thread::sleep(std::time::Duration::from_millis(5)); + std::thread::sleep(std::time::Duration::from_millis(20)); 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(); + cache.get_cached_asset_bytes_with_uuid(first).unwrap(); cache.prune(None).await.unwrap(); let bytes: u64 = fs::read_dir(&path) .unwrap() @@ -152,5 +155,7 @@ async fn cache_pruning_reclaims_old_entries_to_below_the_limit() { .map(|metadata| metadata.len()) .sum(); assert!(bytes <= 10); + assert!(path.join(first.to_string()).is_file()); + assert!(!path.join(second.to_string()).exists()); fs::remove_dir_all(path).unwrap(); } diff --git a/crates/metacrate-grid-agent/src/backend.rs b/crates/metacrate-grid-agent/src/backend.rs index bd982d2..379d498 100644 --- a/crates/metacrate-grid-agent/src/backend.rs +++ b/crates/metacrate-grid-agent/src/backend.rs @@ -167,8 +167,26 @@ impl LibremetaverseClientOwner { /// /// Returns a backend configuration error if the shared client defaults are invalid. pub fn new() -> Result { + 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 { let mut settings = libremetaverse::Settings::default(); 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() .with_settings(settings) .build() @@ -1552,12 +1570,15 @@ mod tests { #[cfg(feature = "live-grid")] #[test] 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 .components() .any(|part| part.as_os_str() == "metacrate") ); + assert_eq!(settings.max_size, 2 * 1024 * 1024 * 1024); } } diff --git a/crates/metacrate-grid-agent/src/config.rs b/crates/metacrate-grid-agent/src/config.rs index 6f35cbd..9eccb4b 100644 --- a/crates/metacrate-grid-agent/src/config.rs +++ b/crates/metacrate-grid-agent/src/config.rs @@ -363,6 +363,20 @@ impl AgentConfig { if !self.behavior.is_valid() { 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() { return Err(ConfigError::UnsafeLimit { field: "vision.max_distance_meters", @@ -1129,6 +1143,7 @@ struct RawBehavior { #[serde(default, deny_unknown_fields)] struct RawVision { max_distance_meters: Option, + asset_cache_max_bytes: Option, } #[derive(Clone, Default, Deserialize)] @@ -1585,6 +1600,10 @@ fn resolve( }, vision: crate::vision::VisionLimits { 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() }, 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)] diff --git a/crates/metacrate-grid-agent/src/main.rs b/crates/metacrate-grid-agent/src/main.rs index bb9098f..424d377 100644 --- a/crates/metacrate-grid-agent/src/main.rs +++ b/crates/metacrate-grid-agent/src/main.rs @@ -281,7 +281,9 @@ async fn run_live( let connection = config.grid.clone().ok_or_else(|| { 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 backend = match owner.session_backend_with_agent_services( connection, @@ -291,6 +293,7 @@ async fn run_live( ) { Ok(backend) => backend, Err(error) => { + live.scene_source.stop_prefetch().await; live.interaction.shutdown().await?; live.behavior.shutdown().await?; return Err(error.into()); @@ -348,6 +351,7 @@ async fn run_live( Err(_) => Err(CliError("timed out waiting for full grid readiness".into())), }; if let Err(error) = readiness { + live.scene_source.stop_prefetch().await; let session_result = handle.shutdown().await; let interaction_result = live.interaction.shutdown().await; let behavior_result = live.behavior.shutdown().await; @@ -356,6 +360,7 @@ async fn run_live( behavior_result?; return Err(error.into()); } + live.scene_source.stop_prefetch().await; let session_result = handle.shutdown().await; let interaction_result = live.interaction.shutdown().await; let behavior_result = live.behavior.shutdown().await; @@ -562,6 +567,7 @@ async fn run_live( let _ = live.observability.record(shutdown_event); control_target.mark_stopping(); live.landmark_roaming.shutdown().await; + live.scene_source.stop_prefetch().await; let session_result = handle.shutdown().await; let interaction_result = live.interaction.shutdown().await; let behavior_result = live.behavior.shutdown().await; @@ -600,6 +606,7 @@ struct LiveInteractions { build_control: Arc, vision: Arc>, + scene_source: Arc, world: Arc, } @@ -740,13 +747,10 @@ fn start_live_interactions( config.storage_path.join("mentra"), )?); let vision_limits = config.vision; - let vision = Arc::new( - VisionService::new( - Arc::new(LibremetaverseSceneSource::new(owner, vision_limits)), - vision_limits, - )? - .prefer_gpu(), - ); + let scene_source = Arc::new(LibremetaverseSceneSource::new(owner, vision_limits)); + scene_source.start_prefetch(); + let vision = + Arc::new(VisionService::new(Arc::clone(&scene_source), vision_limits)?.prefer_gpu()); let responder = Arc::new(VisionAugmentedResponder::new(vision.clone(), responder)); let sink = Arc::new(owner.interaction_sink()); let pacer: Arc = Arc::new(behavior_ingress); @@ -776,6 +780,7 @@ fn start_live_interactions( landmark_roaming, build_control, vision, + scene_source, world, }) } diff --git a/crates/metacrate-grid-agent/src/vision.rs b/crates/metacrate-grid-agent/src/vision.rs index 79741e5..30cc628 100644 --- a/crates/metacrate-grid-agent/src/vision.rs +++ b/crates/metacrate-grid-agent/src/vision.rs @@ -81,6 +81,7 @@ pub struct VisionLimits { pub width: u32, pub height: u32, pub max_distance_meters: u32, + pub asset_cache_max_bytes: u64, pub max_entities: usize, pub max_triangles: usize, pub max_texture_fetches: usize, @@ -98,6 +99,7 @@ impl Default for VisionLimits { width: 1920, height: 1080, max_distance_meters: 64, + asset_cache_max_bytes: 2 * 1024 * 1024 * 1024, max_entities: 2_048, max_triangles: 1_048_576, max_texture_fetches: 1_024, @@ -116,6 +118,7 @@ impl VisionLimits { && (64..=1_080).contains(&self.height) && self.width as usize * self.height as usize <= 2_073_600 && (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..=1_048_576).contains(&self.max_triangles) && self.max_texture_fetches <= 1_024 @@ -945,6 +948,8 @@ pub struct LibremetaverseSceneSource { agent: Arc, limits: VisionLimits, cache: Arc>, + prefetch_task: Mutex>>, + prefetch_passes: AtomicU64, } #[cfg(feature = "live-grid")] @@ -954,6 +959,8 @@ struct SceneCache { textures: std::collections::BTreeMap, texture_pixels: usize, geometry: std::collections::HashMap, + legacy_materials: std::collections::BTreeMap, + pbr_materials: std::collections::BTreeMap, access: u64, } @@ -989,6 +996,57 @@ impl LibremetaverseSceneSource { agent: owner.agent(), limits, 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) { + 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 position = camera.position(); + let position = if generation == 0 { + self.agent.sim_position() + } else { + camera.position() + }; let forward = native_camera_forward(camera); let up = camera.up_axis(); let entity_limit = self.limits.max_entities.saturating_sub(2); @@ -1186,6 +1248,8 @@ impl SceneSource for LibremetaverseSceneSource { cache.textures.clear(); cache.texture_pixels = 0; cache.geometry.clear(); + cache.legacy_materials.clear(); + cache.pbr_materials.clear(); cache.access = 0; } } @@ -1276,19 +1340,36 @@ impl SceneSource for LibremetaverseSceneSource { ids }); let assets = self.client.assets(); - pbr_material_ids.truncate(self.limits.max_texture_fetches / 4); - let legacy_material_fetches = usize::from(!legacy_material_ids.is_empty()); - let legacy_materials = if legacy_material_ids.is_empty() { - Vec::new() - } else { - self.client + let missing_legacy_materials = { + let cache = lock(&self.cache); + legacy_material_ids + .iter() + .any(|id| !cache.legacy_materials.contains_key(id)) + }; + let legacy_material_fetches = usize::from(missing_legacy_materials); + if missing_legacy_materials { + let fetched = self + .client .objects() .request_materials_with_simulator_cancellation_token( simulator.clone(), Some(cancellation.clone()), ) .await - .map_or_else(|_| Vec::new(), Iterator::collect) + .map_or_else(|_| Vec::new(), Iterator::collect::>); + 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::>() }; for material in &legacy_materials { for id in [material.normal_map(), material.specular_map()] { @@ -1298,7 +1379,31 @@ impl SceneSource for LibremetaverseSceneSource { } } 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::>() + }; + 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 material_jobs = std::collections::VecDeque::from(pbr_material_ids.clone()); for _ in 0..16 { @@ -1337,6 +1442,7 @@ impl SceneSource for LibremetaverseSceneSource { texture_ids.push(texture); } } + lock(&self.cache).pbr_materials.insert(id, material.clone()); pbr_materials.push((id, material)); } } diff --git a/crates/metacrate-grid-agent/src/vision_tests.rs b/crates/metacrate-grid-agent/src/vision_tests.rs index 83b2d25..b7fdec9 100644 --- a/crates/metacrate-grid-agent/src/vision_tests.rs +++ b/crates/metacrate-grid-agent/src/vision_tests.rs @@ -33,6 +33,22 @@ fn uuid(value: u32) -> UUID { 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 { SceneTriangle { vertices: [[-1.0, -1.0, z], [1.0, -1.0, z], [0.0, 1.0, z]], diff --git a/crates/metacrate-grid-agent/tests/live_renderer.rs b/crates/metacrate-grid-agent/tests/live_renderer.rs index 79e5fb4..5d7ecc1 100644 --- a/crates/metacrate-grid-agent/tests/live_renderer.rs +++ b/crates/metacrate-grid-agent/tests/live_renderer.rs @@ -111,6 +111,13 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box, material_override: Option, } diff --git a/docs/grid-agent-llm.md b/docs/grid-agent-llm.md index 796c329..8b6ee5d 100644 --- a/docs/grid-agent-llm.md +++ b/docs/grid-agent-llm.md @@ -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 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 vertical field of view; `behavior_camera_reset` restores the avatar-facing view. Full avatar body/attachment mesh fidelity and baking remain separately diff --git a/docs/grid-agent-operations.md b/docs/grid-agent-operations.md index f7f6615..db49bd6 100644 --- a/docs/grid-agent-operations.md +++ b/docs/grid-agent-operations.md @@ -55,7 +55,11 @@ deployments should use absolute paths. Downloaded grid assets use the platform cache directory: `$XDG_CACHE_HOME/metacrate` 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