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:
max_distance_meters: 64
asset_cache_max_bytes: 2147483648
reconnect:
initial_delay_milliseconds: 1000
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.
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() {
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),
));
}
}

View File

@@ -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();
}

View File

@@ -167,8 +167,26 @@ impl LibremetaverseClientOwner {
///
/// Returns a backend configuration error if the shared client defaults are invalid.
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();
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);
}
}

View File

@@ -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<u32>,
asset_cache_max_bytes: Option<u64>,
}
#[derive(Clone, Default, Deserialize)]
@@ -1585,6 +1600,10 @@ fn resolve<E: Environment>(
},
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)]

View File

@@ -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<dyn metacrate_grid_agent::BuildControl>,
vision:
Arc<metacrate_grid_agent::VisionService<metacrate_grid_agent::LibremetaverseSceneSource>>,
scene_source: Arc<metacrate_grid_agent::LibremetaverseSceneSource>,
world: Arc<metacrate_grid_agent::LibremetaverseWorldSnapshotSource>,
}
@@ -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<dyn metacrate_grid_agent::ResponsePacer> = Arc::new(behavior_ingress);
@@ -776,6 +780,7 @@ fn start_live_interactions(
landmark_roaming,
build_control,
vision,
scene_source,
world,
})
}

View File

@@ -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<libremetaverse::AgentManager>,
limits: VisionLimits,
cache: Arc<Mutex<SceneCache>>,
prefetch_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
prefetch_passes: AtomicU64,
}
#[cfg(feature = "live-grid")]
@@ -954,6 +959,8 @@ struct SceneCache {
textures: std::collections::BTreeMap<UUID, CachedTexture>,
texture_pixels: usize,
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,
}
@@ -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<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 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::<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 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::<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 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));
}
}

View File

@@ -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]],

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 (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?;
@@ -143,17 +150,26 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
.camera
.set_vertical_fov_angle(65_f32.to_radians())?;
let limits = VisionLimits {
minimum_interval: Duration::ZERO,
..VisionLimits::default()
};
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: LibremetaverseSceneSource::new(&renderer_owner, limits),
inner: Arc::clone(&scene_source),
material_override: std::env::var("METACRATE_LIVE_RENDER_MATERIAL_OVERRIDE").ok(),
}),
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 stable_missing = 0_u32;
for frame in 1..=frames {
let frame_started = Instant::now();
let next = vision
.capture(
&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(),
)
.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 {
@@ -200,12 +221,13 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
}
.await;
scene_source.stop_prefetch().await;
let _ = renderer_session.logout(renderer_cancel.token()).await;
result
}
struct DiagnosticSceneSource {
inner: LibremetaverseSceneSource,
inner: Arc<LibremetaverseSceneSource>,
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
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

View File

@@ -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