diff --git a/Cargo.lock b/Cargo.lock index 9b2957d..9aca6af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4605,6 +4605,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "metacrate-game-loop" +version = "0.0.1" +dependencies = [ + "metacrate-grid-world", + "metacrate-rendering-wgpu", +] + [[package]] name = "metacrate-grid-agent" version = "0.0.1" @@ -4620,6 +4628,7 @@ dependencies = [ "libremetaverse-rendering-simple", "libremetaverse-types", "mentra", + "metacrate-game-loop", "metacrate-lsl-tools", "metacrate-rendering-wgpu", "rustls", @@ -4633,6 +4642,14 @@ dependencies = [ "url", ] +[[package]] +name = "metacrate-grid-world" +version = "0.0.1" +dependencies = [ + "libremetaverse", + "libremetaverse-types", +] + [[package]] name = "metacrate-lsl-tools" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index bcb9f28..2672688 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ members = [ "crates/libremetaverse-rendering-simple", "crates/libremetaverse-rendering-mesh-foundry", "crates/metacrate-lsl-tools", + "crates/metacrate-game-loop", + "crates/metacrate-grid-world", "crates/metacrate-rendering-wgpu", "crates/libremetaverse-rlv", "crates/libremetaverse-utilities", @@ -37,6 +39,8 @@ default-members = [ "crates/libremetaverse-rendering-simple", "crates/libremetaverse-rendering-mesh-foundry", "crates/metacrate-lsl-tools", + "crates/metacrate-game-loop", + "crates/metacrate-grid-world", "crates/metacrate-rendering-wgpu", "crates/libremetaverse-rlv", "crates/libremetaverse-utilities", diff --git a/crates/metacrate-game-loop/Cargo.toml b/crates/metacrate-game-loop/Cargo.toml new file mode 100644 index 0000000..a5ac4c6 --- /dev/null +++ b/crates/metacrate-game-loop/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "metacrate-game-loop" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Stable OpenSim/Second Life update, rendering, and viewport orchestration" +publish = false + +[dependencies] +metacrate-grid-world = { version = "0.0.1", path = "../metacrate-grid-world" } +metacrate-rendering-wgpu = { version = "0.0.1", path = "../metacrate-rendering-wgpu", features = ["wgpu"] } + +[lints] +workspace = true diff --git a/crates/metacrate-game-loop/src/lib.rs b/crates/metacrate-game-loop/src/lib.rs new file mode 100644 index 0000000..6109eb6 --- /dev/null +++ b/crates/metacrate-game-loop/src/lib.rs @@ -0,0 +1,761 @@ +//! Stable OpenSim/Second Life update, rendering, and viewport orchestration. + +#![allow(clippy::missing_errors_doc)] + +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TryRecvError}, + }, + thread, + time::{Duration, Instant}, +}; + +pub use metacrate_grid_world::{ + DirtyWorldItem, LibreMetaverseWorldEvents, RegionDescriptor, WorldEvent, WorldEventMetrics, + WorldEventReceiver, WorldSnapshot, WorldState, +}; + +/// Slight scheduling margin keeps measured delivery at or above 10 FPS. +pub const DEFAULT_VIEWPORT_FRAME_INTERVAL: Duration = Duration::from_millis(90); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LoopConfig { + pub update_interval: Duration, + pub render_interval: Duration, + pub max_update_catch_up: u32, + pub max_events_per_iteration: usize, + pub event_queue_capacity: usize, +} + +impl Default for LoopConfig { + fn default() -> Self { + Self { + update_interval: Duration::from_nanos(16_666_667), + render_interval: Duration::from_millis(100), + max_update_catch_up: 4, + max_events_per_iteration: 1_024, + event_queue_capacity: 4_096, + } + } +} + +impl LoopConfig { + #[must_use] + pub const fn valid(self) -> bool { + !self.update_interval.is_zero() + && !self.render_interval.is_zero() + && self.max_update_catch_up != 0 + && self.max_events_per_iteration != 0 + && self.event_queue_capacity != 0 + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct LoopStep { + pub updates: u32, + pub render: bool, + pub skipped_updates: u64, + pub skipped_renders: u64, +} + +#[derive(Clone, Copy, Debug)] +pub struct LoopSchedule { + config: LoopConfig, + next_update: Instant, + next_render: Instant, +} + +impl LoopSchedule { + #[must_use] + pub fn new(config: LoopConfig, started: Instant) -> Option { + config.valid().then_some(Self { + config, + next_update: started + config.update_interval, + next_render: started + config.render_interval, + }) + } + + #[must_use] + pub fn next_deadline(self) -> Instant { + self.next_update.min(self.next_render) + } + + pub fn advance(&mut self, now: Instant) -> LoopStep { + let update_due = intervals_due(now, self.next_update, self.config.update_interval); + let updates = u32::try_from(update_due.min(u64::from(self.config.max_update_catch_up))) + .unwrap_or(self.config.max_update_catch_up); + let skipped_updates = update_due.saturating_sub(u64::from(updates)); + if update_due != 0 { + self.next_update = next_after(now, self.next_update, self.config.update_interval); + } + let render_due = intervals_due(now, self.next_render, self.config.render_interval); + if render_due != 0 { + self.next_render = next_after(now, self.next_render, self.config.render_interval); + } + LoopStep { + updates, + render: render_due != 0, + skipped_updates, + skipped_renders: render_due.saturating_sub(1), + } + } +} + +fn intervals_due(now: Instant, deadline: Instant, interval: Duration) -> u64 { + if now < deadline { + return 0; + } + let intervals = now.duration_since(deadline).as_nanos() / interval.as_nanos() + 1; + intervals.try_into().unwrap_or(u64::MAX) +} + +fn next_after(now: Instant, deadline: Instant, interval: Duration) -> Instant { + let due = intervals_due(now, deadline, interval); + let Ok(due) = u32::try_from(due) else { + return now + interval; + }; + deadline + .checked_add(interval.saturating_mul(due)) + .unwrap_or(now + interval) +} + +#[derive(Debug)] +struct LatestValue { + sequence: u64, + value: Option>, +} + +#[derive(Debug)] +pub struct Latest { + inner: Mutex>, +} + +impl Default for Latest { + fn default() -> Self { + Self { + inner: Mutex::new(LatestValue { + sequence: 0, + value: None, + }), + } + } +} + +#[derive(Clone, Debug)] +pub struct Published { + pub sequence: u64, + pub value: Arc, +} + +#[derive(Clone, Debug)] +pub struct ViewportFrame { + pub sequence: u64, + pub scene_sequence: u64, + pub observed_unix_millis: u64, + pub camera: metacrate_rendering_wgpu::Camera, + pub width: u32, + pub height: u32, + pub rgba: Arc<[u8]>, +} + +#[derive(Debug, Default)] +pub struct StableViewport { + latest: Latest, +} + +impl StableViewport { + pub fn publish(&self, frame: ViewportFrame) -> Result { + let expected = usize::try_from(frame.width) + .ok() + .and_then(|width| { + usize::try_from(frame.height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .and_then(|pixels| pixels.checked_mul(4)); + if frame.sequence == 0 || expected != Some(frame.rgba.len()) { + return Err(frame); + } + Ok(self.latest.publish(frame)) + } + + #[must_use] + pub fn latest(&self) -> Option> { + self.latest.load() + } +} + +/// Owns the renderer and publishes immutable completed frames. Readers never +/// observe the render target while Bevy is writing the next frame. +pub struct ViewportRuntime { + renderer: metacrate_rendering_wgpu::Renderer, + viewport: StableViewport, + next_sequence: AtomicU64, + loaded_scene: Mutex>, +} + +pub struct ViewportRender { + pub frame: Published, + pub timings: metacrate_rendering_wgpu::RenderTimings, +} + +#[derive(Clone, Debug)] +pub struct ViewportScene { + pub sequence: u64, + pub camera: metacrate_rendering_wgpu::Camera, + pub renderables: Arc<[metacrate_rendering_wgpu::Renderable]>, + pub textures: Arc<[metacrate_rendering_wgpu::Texture]>, + pub background_srgb: [u8; 4], + pub limits: metacrate_rendering_wgpu::RenderLimits, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ViewportStats { + pub completed_frames: u64, + pub missed_frame_deadlines: u64, + pub render_errors: u64, + pub last_render: Duration, +} + +/// Fixed-rate viewport producer. Scene submission is nonblocking and always +/// replaces stale pending input with the newest complete scene. +pub struct ViewportLoop { + runtime: Arc, + input: Arc>, + stopped: Arc, + thread: Mutex>>, + completed_frames: Arc, + missed_frame_deadlines: Arc, + render_errors: Arc, + last_render_nanos: Arc, +} + +impl ViewportLoop { + pub fn start(frame_interval: Duration) -> Result { + if frame_interval.is_zero() { + return Err(metacrate_rendering_wgpu::RenderError::InvalidScene); + } + let runtime = Arc::new(ViewportRuntime::new_blocking()?); + let input: Arc> = Arc::new(Latest::default()); + let stopped = Arc::new(AtomicBool::new(false)); + let completed_frames = Arc::new(AtomicU64::new(0)); + let missed_frame_deadlines = Arc::new(AtomicU64::new(0)); + let render_errors = Arc::new(AtomicU64::new(0)); + let last_render_nanos = Arc::new(AtomicU64::new(0)); + let worker_runtime = Arc::clone(&runtime); + let worker_input = Arc::clone(&input); + let worker_stopped = Arc::clone(&stopped); + let worker_completed = Arc::clone(&completed_frames); + let worker_missed = Arc::clone(&missed_frame_deadlines); + let worker_errors = Arc::clone(&render_errors); + let worker_last_render = Arc::clone(&last_render_nanos); + let thread = thread::Builder::new() + .name("metacrate-viewport-loop".into()) + .spawn(move || { + let mut next_frame = Instant::now(); + while !worker_stopped.load(Ordering::Acquire) { + let now = Instant::now(); + if now < next_frame { + thread::park_timeout(next_frame - now); + continue; + } + let due = intervals_due(now, next_frame, frame_interval); + worker_missed.fetch_add(due.saturating_sub(1), Ordering::Relaxed); + next_frame = next_after(now, next_frame, frame_interval); + let Some(scene) = worker_input.load() else { + continue; + }; + let started = Instant::now(); + let result = worker_runtime.render_scene( + scene.value.sequence, + scene.value.camera, + || Arc::clone(&scene.value.renderables), + Arc::clone(&scene.value.textures), + scene.value.background_srgb, + scene.value.limits, + ); + worker_last_render.store( + started.elapsed().as_nanos().try_into().unwrap_or(u64::MAX), + Ordering::Relaxed, + ); + if result.is_ok() { + worker_completed.fetch_add(1, Ordering::Relaxed); + } else { + worker_errors.fetch_add(1, Ordering::Relaxed); + } + } + }) + .map_err(|error| metacrate_rendering_wgpu::RenderError::Device(error.to_string()))?; + Ok(Self { + runtime, + input, + stopped, + thread: Mutex::new(Some(thread)), + completed_frames, + missed_frame_deadlines, + render_errors, + last_render_nanos, + }) + } + + pub fn submit(&self, scene: ViewportScene) -> u64 { + let sequence = self.input.publish(scene); + if let Some(thread) = lock(&self.thread).as_ref() { + thread.thread().unpark(); + } + sequence + } + + #[must_use] + pub fn latest(&self) -> Option> { + self.runtime.latest() + } + + #[must_use] + pub fn stats(&self) -> ViewportStats { + ViewportStats { + completed_frames: self.completed_frames.load(Ordering::Relaxed), + missed_frame_deadlines: self.missed_frame_deadlines.load(Ordering::Relaxed), + render_errors: self.render_errors.load(Ordering::Relaxed), + last_render: Duration::from_nanos(self.last_render_nanos.load(Ordering::Relaxed)), + } + } + + pub fn shutdown(&self) { + self.stopped.store(true, Ordering::Release); + if let Some(thread) = lock(&self.thread).take() { + thread.thread().unpark(); + let _ = thread.join(); + } + } +} + +impl Drop for ViewportLoop { + fn drop(&mut self) { + self.shutdown(); + } +} + +impl ViewportRuntime { + pub fn new_blocking() -> Result { + Ok(Self { + renderer: metacrate_rendering_wgpu::Renderer::new_blocking()?, + viewport: StableViewport::default(), + next_sequence: AtomicU64::new(0), + loaded_scene: Mutex::new(None), + }) + } + + pub fn render_scene( + &self, + scene_sequence: u64, + camera: metacrate_rendering_wgpu::Camera, + renderables: F, + textures: Arc<[metacrate_rendering_wgpu::Texture]>, + background_srgb: [u8; 4], + limits: metacrate_rendering_wgpu::RenderLimits, + ) -> Result + where + F: FnOnce() -> Arc<[metacrate_rendering_wgpu::Renderable]>, + { + let mut loaded_scene = lock(&self.loaded_scene); + let mut timings = if *loaded_scene == Some(scene_sequence) { + metacrate_rendering_wgpu::RenderTimings::default() + } else { + let renderables = renderables(); + let timings = self + .renderer + .load_scene_shared(camera, renderables, textures, limits)?; + *loaded_scene = Some(scene_sequence); + timings + }; + let rendered = self.renderer.render_view(camera, background_srgb, limits)?; + timings.clear_previous_frame += rendered.timings.clear_previous_frame; + timings.scene_setup += rendered.timings.scene_setup; + timings.capture += rendered.timings.capture; + let sequence = self.next_sequence.fetch_add(1, Ordering::AcqRel) + 1; + self.viewport + .publish(ViewportFrame { + sequence, + scene_sequence, + observed_unix_millis: unix_millis_now(), + camera, + width: limits.width, + height: limits.height, + rgba: rendered.rgba.into(), + }) + .map_err(|_| metacrate_rendering_wgpu::RenderError::InvalidScene)?; + Ok(ViewportRender { + frame: self + .viewport + .latest() + .ok_or(metacrate_rendering_wgpu::RenderError::Readback)?, + timings, + }) + } + + #[must_use] + pub fn latest(&self) -> Option> { + self.viewport.latest() + } +} + +impl Latest { + pub fn publish(&self, value: T) -> u64 { + self.publish_arc(Arc::new(value)) + } + + pub fn publish_arc(&self, value: Arc) -> u64 { + let mut latest = lock(&self.inner); + latest.sequence = latest.sequence.saturating_add(1); + latest.value = Some(value); + latest.sequence + } + + #[must_use] + pub fn load(&self) -> Option> { + let latest = lock(&self.inner); + Some(Published { + sequence: latest.sequence, + value: Arc::clone(latest.value.as_ref()?), + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum TimingPhase { + Events, + Update, + Render, + Publish, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TimingSignal { + pub phase: TimingPhase, + pub duration: Duration, + pub sequence: u64, +} + +#[derive(Clone, Debug)] +pub struct TimingEmitter { + sender: SyncSender, + dropped: Arc, +} + +#[derive(Debug)] +pub struct TimingReceiver { + receiver: Receiver, + dropped: Arc, +} + +#[must_use] +pub fn timing_channel(capacity: usize) -> Option<(TimingEmitter, TimingReceiver)> { + if capacity == 0 { + return None; + } + let (sender, receiver) = mpsc::sync_channel(capacity); + let dropped = Arc::new(AtomicU64::new(0)); + Some(( + TimingEmitter { + sender, + dropped: Arc::clone(&dropped), + }, + TimingReceiver { receiver, dropped }, + )) +} + +impl TimingEmitter { + pub fn emit(&self, signal: TimingSignal) { + if self.sender.try_send(signal).is_err() { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } +} + +impl TimingReceiver { + pub fn recv_timeout(&self, timeout: Duration) -> Result { + self.receiver.recv_timeout(timeout) + } + + pub fn try_recv(&self) -> Result { + self.receiver.try_recv() + } + + #[must_use] + pub fn dropped(&self) -> u64 { + self.dropped.load(Ordering::Relaxed) + } +} + +pub trait LoopDriver: Send + 'static { + fn world_changed(&mut self, world: &WorldState, dirty: &[DirtyWorldItem]); + fn update(&mut self, world: &WorldState, step: Duration); + fn render(&mut self, world: &WorldState); +} + +impl LoopDriver for () { + fn world_changed(&mut self, _world: &WorldState, _dirty: &[DirtyWorldItem]) {} + fn update(&mut self, _world: &WorldState, _step: Duration) {} + fn render(&mut self, _world: &WorldState) {} +} + +pub struct LoopHandle { + stopped: Arc, + thread: Option>, + world: Arc>, + world_events: WorldEventMetrics, +} + +#[derive(Clone)] +pub struct WorldReader { + world: Arc>, + world_events: WorldEventMetrics, +} + +impl WorldReader { + #[must_use] + pub fn latest(&self) -> Option> { + self.world.load() + } + + #[must_use] + pub fn dropped_events(&self) -> u64 { + self.world_events.dropped() + } +} + +impl LoopHandle { + #[must_use] + pub fn latest_world(&self) -> Option> { + self.world.load() + } + + #[must_use] + pub fn world_reader(&self) -> WorldReader { + WorldReader { + world: Arc::clone(&self.world), + world_events: self.world_events.clone(), + } + } + + #[must_use] + pub fn dropped_world_events(&self) -> u64 { + self.world_events.dropped() + } + + pub fn shutdown(mut self) -> thread::Result<()> { + self.stop(); + self.thread.take().map_or(Ok(()), thread::JoinHandle::join) + } + + fn stop(&self) { + self.stopped.store(true, Ordering::Release); + if let Some(thread) = &self.thread { + thread.thread().unpark(); + } + } +} + +impl Drop for LoopHandle { + fn drop(&mut self) { + self.stop(); + } +} + +#[must_use] +pub fn start( + config: LoopConfig, + events: WorldEventReceiver, + mut driver: D, + timings: Option, +) -> Option { + if !config.valid() { + return None; + } + let stopped = Arc::new(AtomicBool::new(false)); + let world_events = events.metrics(); + let world = Arc::new(Latest::default()); + let published_world = Arc::clone(&world); + let thread_stopped = Arc::clone(&stopped); + let thread = thread::Builder::new() + .name("metacrate-game-loop".into()) + .spawn(move || { + let Some(mut schedule) = LoopSchedule::new(config, Instant::now()) else { + return; + }; + let mut sequence = 0_u64; + let mut world = WorldState::default(); + while !thread_stopped.load(Ordering::Acquire) { + let started = Instant::now(); + let mut event_count = 0; + while event_count < config.max_events_per_iteration { + match events.try_recv() { + Ok(event) => { + world.apply(event); + event_count += 1; + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => return, + } + } + if event_count != 0 { + let dirty = world.take_dirty(); + driver.world_changed(&world, &dirty); + published_world.publish(world.snapshot()); + emit( + timings.as_ref(), + TimingPhase::Events, + started.elapsed(), + sequence, + ); + } + let step = schedule.advance(Instant::now()); + for _ in 0..step.updates { + let started = Instant::now(); + driver.update(&world, config.update_interval); + sequence = sequence.saturating_add(1); + emit( + timings.as_ref(), + TimingPhase::Update, + started.elapsed(), + sequence, + ); + } + if step.render { + let started = Instant::now(); + driver.render(&world); + emit( + timings.as_ref(), + TimingPhase::Render, + started.elapsed(), + sequence, + ); + } + let wait = schedule + .next_deadline() + .saturating_duration_since(Instant::now()); + if wait.is_zero() { + thread::yield_now(); + } else { + thread::park_timeout(wait); + } + } + }) + .ok()?; + Some(LoopHandle { + stopped, + thread: Some(thread), + world, + world_events, + }) +} + +fn emit(emitter: Option<&TimingEmitter>, phase: TimingPhase, duration: Duration, sequence: u64) { + if let Some(emitter) = emitter { + emitter.emit(TimingSignal { + phase, + duration, + sequence, + }); + } +} + +fn lock(value: &Mutex) -> std::sync::MutexGuard<'_, T> { + value + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn unix_millis_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| { + duration.as_millis().try_into().unwrap_or(u64::MAX) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Driver(Arc); + + impl LoopDriver for Driver { + fn world_changed(&mut self, _world: &WorldState, dirty: &[DirtyWorldItem]) { + self.0.fetch_add( + u64::try_from(dirty.len()).unwrap_or(u64::MAX), + Ordering::Relaxed, + ); + } + fn update(&mut self, _world: &WorldState, _step: Duration) { + self.0.fetch_add(1, Ordering::Relaxed); + } + fn render(&mut self, _world: &WorldState) {} + } + + #[test] + fn schedule_bounds_catch_up_latest_publishes_and_runner_emits_timings() { + let config = LoopConfig { + update_interval: Duration::from_millis(10), + render_interval: Duration::from_millis(25), + max_update_catch_up: 2, + ..LoopConfig::default() + }; + let started = Instant::now(); + let mut schedule = LoopSchedule::new(config, started).unwrap(); + let step = schedule.advance(started + Duration::from_millis(56)); + assert_eq!(step.updates, 2); + assert_eq!(step.skipped_updates, 3); + assert!(step.render); + assert_eq!(step.skipped_renders, 1); + + let latest = Latest::default(); + assert_eq!(latest.publish("old"), 1); + assert_eq!(latest.publish("new"), 2); + let published = latest.load().unwrap(); + assert_eq!(published.sequence, 2); + assert_eq!(*published.value, "new"); + + let viewport = StableViewport::default(); + let camera = metacrate_rendering_wgpu::Camera::look_at( + [0.0; 3], + [1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + 60.0, + ) + .unwrap(); + assert!( + viewport + .publish(ViewportFrame { + sequence: 1, + scene_sequence: 1, + observed_unix_millis: 1, + camera, + width: 2, + height: 1, + rgba: vec![0; 8].into(), + }) + .is_ok() + ); + assert_eq!(viewport.latest().unwrap().value.sequence, 1); + + let count = Arc::new(AtomicU64::new(0)); + let (timings, receiver) = timing_channel(32).unwrap(); + let (events, event_receiver) = metacrate_grid_world::world_event_channel(32).unwrap(); + events.send(WorldEvent::RegionChanged(None)); + let handle = start( + config, + event_receiver, + Driver(Arc::clone(&count)), + Some(timings), + ) + .unwrap(); + thread::sleep(Duration::from_millis(40)); + handle.shutdown().unwrap(); + assert!(count.load(Ordering::Relaxed) >= 2); + assert!(receiver.try_recv().is_ok()); + } +} diff --git a/crates/metacrate-grid-agent/Cargo.toml b/crates/metacrate-grid-agent/Cargo.toml index efa8ab9..26c2b0e 100644 --- a/crates/metacrate-grid-agent/Cargo.toml +++ b/crates/metacrate-grid-agent/Cargo.toml @@ -18,6 +18,7 @@ libremetaverse-imaging = { version = "0.0.1", path = "../libremetaverse-imaging" libremetaverse-rendering-simple = { version = "0.0.1", path = "../libremetaverse-rendering-simple", optional = true } libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" } metacrate-lsl-tools = { version = "0.0.1", path = "../metacrate-lsl-tools" } +metacrate-game-loop = { version = "0.0.1", path = "../metacrate-game-loop" } metacrate-rendering-wgpu = { version = "0.0.1", path = "../metacrate-rendering-wgpu" } mentra = { version = "0.18.3", default-features = false } jpeg-encoder = "0.6.1" diff --git a/crates/metacrate-grid-agent/src/backend.rs b/crates/metacrate-grid-agent/src/backend.rs index 379d498..ff112a6 100644 --- a/crates/metacrate-grid-agent/src/backend.rs +++ b/crates/metacrate-grid-agent/src/backend.rs @@ -107,6 +107,7 @@ pub struct LibremetaverseClientOwner { pub struct LibremetaverseWorldSnapshotSource { client: libremetaverse::GridClient, agent: Arc, + world: metacrate_game_loop::WorldReader, } #[cfg(feature = "live-grid")] @@ -143,6 +144,7 @@ pub struct LibremetaverseSessionBackend { interaction: Option, perception: Option, behavior: Option, + framework_readiness: Option>, delivery_generation: Arc>>, } @@ -319,6 +321,7 @@ impl LibremetaverseClientOwner { interaction, perception, behavior, + framework_readiness: None, delivery_generation: Arc::clone(&self.delivery_generation), }) } @@ -332,10 +335,14 @@ impl LibremetaverseClientOwner { } #[must_use] - pub fn world_snapshot_source(&self) -> LibremetaverseWorldSnapshotSource { + pub fn world_snapshot_source( + &self, + world: metacrate_game_loop::WorldReader, + ) -> LibremetaverseWorldSnapshotSource { LibremetaverseWorldSnapshotSource { client: self.client.clone(), agent: Arc::clone(&self.agent), + world, } } @@ -349,6 +356,18 @@ impl LibremetaverseClientOwner { } } +#[cfg(feature = "live-grid")] +impl LibremetaverseSessionBackend { + #[must_use] + pub fn with_framework_readiness( + mut self, + readiness: Arc, + ) -> Self { + self.framework_readiness = Some(readiness); + self + } +} + #[cfg(feature = "live-grid")] fn metacrate_cache_dir() -> Result { directories::ProjectDirs::from("", "", "metacrate") @@ -362,10 +381,9 @@ fn metacrate_cache_dir() -> Result { impl LibremetaverseWorldSnapshotSource { #[must_use] pub fn current_region_handle(&self) -> Option { - self.client - .network() - .native_current_sim() - .map(|simulator| simulator.handle) + self.world + .latest() + .and_then(|world| world.value.region.as_ref().map(|region| region.handle)) } // One atomic projection keeps native cache reads together. Terrain lookup @@ -386,19 +404,24 @@ impl LibremetaverseWorldSnapshotSource { { return Err(crate::perception::PerceptionError::NotReady); } + let world = self + .world + .latest() + .ok_or(crate::perception::PerceptionError::NotReady)?; + if world.value.region.as_ref().map(|region| region.id) != Some(simulator.region_id) { + return Err(crate::perception::PerceptionError::NotReady); + } let agent_position = self.agent.sim_position(); let position = crate::perception::WorldPosition { x: f64::from(agent_position.x), y: f64::from(agent_position.y), z: f64::from(agent_position.z), }; - let avatar_cache = simulator - .objects_avatars - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let avatars_truncated = avatar_cache.len() > 2_048; + let avatars_truncated = world.value.avatars.len() > 2_048; let mut avatars = BTreeMap::new(); - for (local_id, avatar) in avatar_cache + for (local_id, avatar) in world + .value + .avatars .iter() .filter(|(_, avatar)| avatar.id != UUID::zero()) { @@ -414,15 +437,12 @@ impl LibremetaverseWorldSnapshotSource { avatars.pop_last(); } } - drop(avatar_cache); let avatars = avatars.into_values().collect(); - let object_cache = simulator - .objects_primitives - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let objects_truncated = object_cache.len() > 2_048; + let objects_truncated = world.value.primitives.len() > 2_048; let mut objects = BTreeMap::new(); - for (local_id, primitive) in object_cache + for (local_id, primitive) in world + .value + .primitives .iter() .filter(|(_, primitive)| primitive.id != UUID::zero()) { @@ -452,7 +472,6 @@ impl LibremetaverseWorldSnapshotSource { objects.pop_last(); } } - drop(object_cache); let objects = objects.into_values().collect(); let parcel = simulator .parcels @@ -500,7 +519,7 @@ impl LibremetaverseWorldSnapshotSource { let (inventory, landmarks, inventory_truncated) = native_inventory_metadata(&self.client); Ok(crate::perception::WorldSnapshot { generation, - observed_unix_millis: crate::perception::unix_millis_now(), + observed_unix_millis: world.value.observed_unix_millis, region_id: simulator.region_id, region_name: crate::perception::sanitize_untrusted(&simulator.name), agent: crate::perception::AgentSnapshot { @@ -1029,6 +1048,8 @@ struct LibremetaverseSession { interaction: Option, perception: Option, behavior: Option, + agent: Arc, + framework_readiness: Option>, delivery_generation: Arc>>, } @@ -1043,7 +1064,7 @@ impl crate::session::GridSession for LibremetaverseSession { cancellation: CancellationToken, ) -> crate::session::SessionFuture<'_, crate::session::SessionSignal> { Box::pin(async move { - tokio::select! { + let signal = tokio::select! { signal = self.signals.recv() => signal.unwrap_or_else(|| { crate::session::SessionSignal::Disconnected( crate::session::SessionFailure::new( @@ -1056,7 +1077,36 @@ impl crate::session::GridSession for LibremetaverseSession { crate::session::SessionFailureKind::TransientTransport, ), ), + }; + if matches!(signal, crate::session::SessionSignal::Ready) { + if let Some(readiness) = &self.framework_readiness + && !readiness.wait_ready(self.generation, cancellation).await + { + return crate::session::SessionSignal::Disconnected( + crate::session::SessionFailure::new( + crate::session::SessionFailureKind::TransientTransport, + ), + ); + } + *self + .delivery_generation + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(self.generation); + if let Some(interaction) = &self.interaction { + let _ = interaction.try_connected(self.generation, self.agent.agent_id()); + } + if let (Some(perception), Some(simulator)) = + (&self.perception, self.network.native_current_sim()) + { + let _ = perception.connected(self.generation, simulator.region_id); + } + if let (Some(behavior), Some(simulator)) = + (&self.behavior, self.network.native_current_sim()) + { + let _ = behavior.connected(self.generation, simulator.region_id); + } } + signal }) } @@ -1220,34 +1270,12 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend { )); })); let ready_sender = sender.clone(); - let ready_interaction = self.interaction.clone(); - let ready_perception = self.perception.clone(); - let ready_behavior = self.behavior.clone(); - let ready_network = self.network.clone(); - let ready_agent = Arc::clone(&self.agent); - let ready_generation = Arc::clone(&self.delivery_generation); let ready_once = Arc::new(AtomicBool::new(false)); let callback_ready = Arc::clone(&ready_once); let ready = self .network .native_subscribe_event_queue_running(Arc::new(move |_| { if !callback_ready.swap(true, Ordering::AcqRel) { - *ready_generation - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation); - if let Some(interaction) = &ready_interaction { - let _ = interaction.try_connected(generation, ready_agent.agent_id()); - } - if let (Some(perception), Some(simulator)) = - (&ready_perception, ready_network.native_current_sim()) - { - let _ = perception.connected(generation, simulator.region_id); - } - if let (Some(behavior), Some(simulator)) = - (&ready_behavior, ready_network.native_current_sim()) - { - let _ = behavior.connected(generation, simulator.region_id); - } let _ = ready_sender.try_send(crate::session::SessionSignal::Ready); } })); @@ -1299,23 +1327,6 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend { .unwrap_or(false) && !ready_once.swap(true, Ordering::AcqRel) { - *self - .delivery_generation - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation); - if let Some(interaction) = &self.interaction { - let _ = interaction.try_connected(generation, self.agent.agent_id()); - } - if let (Some(perception), Some(simulator)) = - (&self.perception, self.network.native_current_sim()) - { - let _ = perception.connected(generation, simulator.region_id); - } - if let (Some(behavior), Some(simulator)) = - (&self.behavior, self.network.native_current_sim()) - { - let _ = behavior.connected(generation, simulator.region_id); - } let _ = sender.try_send(crate::session::SessionSignal::Ready); } let session: Box = Box::new(LibremetaverseSession { @@ -1326,6 +1337,8 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend { interaction: self.interaction.clone(), perception: self.perception.clone(), behavior: self.behavior.clone(), + agent: Arc::clone(&self.agent), + framework_readiness: self.framework_readiness.clone(), delivery_generation: Arc::clone(&self.delivery_generation), }); Ok(session) diff --git a/crates/metacrate-grid-agent/src/lib.rs b/crates/metacrate-grid-agent/src/lib.rs index f0fcc2e..3fc9cdd 100644 --- a/crates/metacrate-grid-agent/src/lib.rs +++ b/crates/metacrate-grid-agent/src/lib.rs @@ -164,9 +164,9 @@ pub use script_delivery::{ }; pub use service::{AgentService, ServiceError, ServiceHandle, ServiceState}; pub use session::{ - GridSession, GridSessionBackend, ReconnectPolicy, SessionControl, SessionFailure, - SessionFailureKind, SessionFuture, SessionObservation, SessionReason, SessionSignal, - SessionState, SessionStatus, SessionSupervisor, SessionSupervisorError, + FrameworkReadiness, GridSession, GridSessionBackend, ReconnectPolicy, SessionControl, + SessionFailure, SessionFailureKind, SessionFuture, SessionObservation, SessionReason, + SessionSignal, SessionState, SessionStatus, SessionSupervisor, SessionSupervisorError, SessionSupervisorHandle, SessionWork, WorkDisposition, WorkKind, }; pub use tool_loop::{ToolExecution, ToolExecutor, ToolFuture, ToolLoopError, ToolLoopLimits}; diff --git a/crates/metacrate-grid-agent/src/main.rs b/crates/metacrate-grid-agent/src/main.rs index 71c22bc..6bb37d5 100644 --- a/crates/metacrate-grid-agent/src/main.rs +++ b/crates/metacrate-grid-agent/src/main.rs @@ -273,9 +273,9 @@ async fn run_live( ) -> Result<(), Box> { use metacrate_grid_agent::{ AgentControlTarget, BehaviorObservation, ControlEventKind, ControlPlane, ControlTarget, - GridSessionBackend, LibremetaverseClientOwner, OperatingMode, RuntimeControlCommand, - SessionControl, SessionObservation, SessionState, SessionSupervisor, TcpControlConfig, - TcpControlServer, WorldSnapshotSource, + FrameworkReadiness, GridSessionBackend, LibremetaverseClientOwner, OperatingMode, + RuntimeControlCommand, SessionControl, SessionObservation, SessionReason, SessionState, + SessionSupervisor, TcpControlConfig, TcpControlServer, WorldSnapshotSource, }; let connection = config.grid.clone().ok_or_else(|| { @@ -285,15 +285,20 @@ async fn run_live( config.vision.asset_cache_max_bytes, )?; let mut live = start_live_interactions(&config, &owner).await?; + let framework_readiness = Arc::new(FrameworkReadiness::default()); let backend = match owner.session_backend_with_agent_services( connection, live.interaction.ingress(), live.perception.clone(), live.behavior.ingress(), ) { - Ok(backend) => backend, + Ok(backend) => backend.with_framework_readiness(Arc::clone(&framework_readiness)), Err(error) => { + live.vision.stop_viewport().await; live.scene_source.stop_prefetch().await; + if let Some(world_loop) = live.world_loop.take() { + let _ = world_loop.shutdown(); + } live.interaction.shutdown().await?; live.behavior.shutdown().await?; return Err(error.into()); @@ -313,6 +318,26 @@ async fn run_live( let readiness = tokio::time::timeout(config.timeouts.startup, async { loop { match handle.next_observation().await { + Some( + event @ SessionObservation::Transition { + status, + reason: SessionReason::LoginSucceeded, + .. + }, + ) => { + live.vision.set_generation(status.generation); + record_session_observation(&live.observability, &event); + prepare_live_framework( + &live, + &framework_readiness, + status.generation, + config + .timeouts + .startup + .saturating_sub(Duration::from_secs(5)), + ) + .await?; + } Some(event @ SessionObservation::Transition { status, .. }) if status.agent_ready => { @@ -351,7 +376,11 @@ async fn run_live( Err(_) => Err(CliError("timed out waiting for full grid readiness".into())), }; if let Err(error) = readiness { + live.vision.stop_viewport().await; live.scene_source.stop_prefetch().await; + if let Some(world_loop) = live.world_loop.take() { + let _ = world_loop.shutdown(); + } let session_result = handle.shutdown().await; let interaction_result = live.interaction.shutdown().await; let behavior_result = live.behavior.shutdown().await; @@ -360,7 +389,11 @@ async fn run_live( behavior_result?; return Err(error.into()); } + live.vision.stop_viewport().await; live.scene_source.stop_prefetch().await; + if let Some(world_loop) = live.world_loop.take() { + let _ = world_loop.shutdown(); + } let session_result = handle.shutdown().await; let interaction_result = live.interaction.shutdown().await; let behavior_result = live.behavior.shutdown().await; @@ -454,6 +487,16 @@ async fn run_live( if let SessionObservation::Transition { status, reason, retry_in } = event { live.vision.set_generation(status.generation); control_target.update_session(status); + if reason == SessionReason::LoginSucceeded + && let Err(error) = prepare_live_framework( + &live, + &framework_readiness, + status.generation, + config.timeouts.startup.saturating_sub(Duration::from_secs(5)), + ).await + { + eprintln!("framework initialization failed: {error}"); + } if status.agent_ready && let Ok(snapshot) = live .world @@ -485,6 +528,34 @@ async fn run_live( status.transport_connected, status.agent_ready, ); + if status.agent_ready { + let world = live + .world_loop + .as_ref() + .and_then(metacrate_game_loop::LoopHandle::latest_world); + let world_sequence = world.as_ref().map_or(0, |world| world.sequence); + let primitives = world + .as_ref() + .map_or(0, |world| world.value.primitives.len()); + let avatars = world.as_ref().map_or(0, |world| world.value.avatars.len()); + let dropped_world_events = live + .world_loop + .as_ref() + .map_or(0, metacrate_game_loop::LoopHandle::dropped_world_events); + let frames = live + .vision + .viewport_stats() + .map_or(0, |stats| stats.completed_frames); + println!( + "READY generation={} world_sequence={} world_primitives={} world_avatars={} dropped_world_events={} viewport_frames={} viewport_target_fps=10", + status.generation, + world_sequence, + primitives, + avatars, + dropped_world_events, + frames + ); + } } } event = live.interaction.next_observation() => { @@ -567,7 +638,11 @@ async fn run_live( let _ = live.observability.record(shutdown_event); control_target.mark_stopping(); live.landmark_roaming.shutdown().await; + live.vision.stop_viewport().await; live.scene_source.stop_prefetch().await; + if let Some(world_loop) = live.world_loop.take() { + let _ = world_loop.shutdown(); + } let session_result = handle.shutdown().await; let interaction_result = live.interaction.shutdown().await; let behavior_result = live.behavior.shutdown().await; @@ -590,6 +665,55 @@ async fn run_live( Ok(()) } +#[cfg(feature = "live-grid")] +async fn prepare_live_framework( + live: &LiveInteractions, + readiness: &metacrate_grid_agent::FrameworkReadiness, + generation: u64, + timeout: Duration, +) -> Result<(), CliError> { + println!("INITIALIZING generation={generation} waiting_for=world_state,viewport target_fps=10"); + tokio::time::timeout(timeout, async { + loop { + let world_ready = live + .world_loop + .as_ref() + .and_then(metacrate_game_loop::LoopHandle::latest_world) + .is_some_and(|world| world.value.region.is_some()); + if world_ready && live.scene_source.prefetch_passes() != 0 { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + live.vision.start_viewport( + generation, + libremetaverse_types::compat::CancellationToken::default(), + ); + loop { + if live + .vision + .viewport_stats() + .is_some_and(|stats| stats.completed_frames >= 2 && stats.render_errors == 0) + { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .map_err(|_| CliError("timed out waiting for stable world state and viewport".into()))?; + readiness.mark_ready(generation); + let stats = live.vision.viewport_stats().unwrap_or_default(); + println!( + "INITIALIZED generation={} viewport_frames={} last_render_ms={} missed_deadlines={} world_state=stable", + generation, + stats.completed_frames, + stats.last_render.as_millis(), + stats.missed_frame_deadlines, + ); + Ok(()) +} + #[cfg(feature = "live-grid")] struct LiveInteractions { interaction: metacrate_grid_agent::InteractionHandle, @@ -608,6 +732,8 @@ struct LiveInteractions { Arc>, scene_source: Arc, world: Arc, + _world_events: metacrate_game_loop::LibreMetaverseWorldEvents, + world_loop: Option, } #[cfg(feature = "live-grid")] @@ -630,7 +756,25 @@ async fn start_live_interactions( }; let conversation = Arc::new(ConversationStore::from_config(config)?); - let world = Arc::new(owner.world_snapshot_source()); + let (world_events, world_receiver) = + metacrate_game_loop::LibreMetaverseWorldEvents::subscribe_visible( + owner.client(), + owner.agent(), + config.vision.max_distance_meters, + 4_096, + ) + .ok_or_else(|| CliError("world event queue capacity is zero".into()))?; + let world_loop = metacrate_game_loop::start( + metacrate_game_loop::LoopConfig { + event_queue_capacity: 4_096, + ..metacrate_game_loop::LoopConfig::default() + }, + world_receiver, + (), + None, + ) + .ok_or_else(|| CliError("world game loop initialization failed".into()))?; + let world = Arc::new(owner.world_snapshot_source(world_loop.world_reader())); let perception = Arc::new(PerceptionBackend::new( world.clone(), Arc::clone(&conversation), @@ -752,7 +896,7 @@ async fn start_live_interactions( let vision = Arc::new(VisionService::new(Arc::clone(&scene_source), vision_limits)?.prefer_gpu()); if !vision.initialize_renderer().await { - eprintln!("WARNING: wgpu renderer unavailable; visual captures use software fallback"); + return Err(CliError("wgpu viewport renderer initialization failed".into()).into()); } let responder = Arc::new(VisionAugmentedResponder::new(vision.clone(), responder)); let sink = Arc::new(owner.interaction_sink()); @@ -785,6 +929,8 @@ async fn start_live_interactions( vision, scene_source, world, + _world_events: world_events, + world_loop: Some(world_loop), }) } diff --git a/crates/metacrate-grid-agent/src/session.rs b/crates/metacrate-grid-agent/src/session.rs index 56c4b03..c237f23 100644 --- a/crates/metacrate-grid-agent/src/session.rs +++ b/crates/metacrate-grid-agent/src/session.rs @@ -12,7 +12,7 @@ use std::pin::Pin; use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::{mpsc, oneshot, watch}; use tokio::task::JoinHandle; use tokio::time::Instant; @@ -25,6 +25,39 @@ const MAX_SHUTDOWN: Duration = Duration::from_mins(1); pub type SessionFuture<'a, T> = Pin + Send + 'a>>; +/// Generation-scoped gate between transport readiness and autonomous agent readiness. +pub struct FrameworkReadiness { + generation: watch::Sender, +} + +impl Default for FrameworkReadiness { + fn default() -> Self { + let (generation, _) = watch::channel(0); + Self { generation } + } +} + +impl FrameworkReadiness { + pub fn mark_ready(&self, generation: u64) { + if generation != 0 { + self.generation.send_replace(generation); + } + } + + pub async fn wait_ready(&self, generation: u64, cancellation: CancellationToken) -> bool { + let mut ready = self.generation.subscribe(); + loop { + if *ready.borrow() == generation { + return true; + } + tokio::select! { + () = cancellation.cancelled() => return false, + changed = ready.changed() => if changed.is_err() { return false; }, + } + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(u8)] pub enum SessionState { @@ -215,7 +248,7 @@ impl Default for ReconnectPolicy { Self { initial_delay: Duration::from_secs(1), maximum_delay: Duration::from_mins(1), - readiness_timeout: Duration::from_secs(30), + readiness_timeout: Duration::from_mins(2), stable_reset_after: Duration::from_mins(2), shutdown_deadline: Duration::from_secs(10), jitter_basis_points: 2_000, diff --git a/crates/metacrate-grid-agent/src/session_tests.rs b/crates/metacrate-grid-agent/src/session_tests.rs index f54dd6b..bbde6b6 100644 --- a/crates/metacrate-grid-agent/src/session_tests.rs +++ b/crates/metacrate-grid-agent/src/session_tests.rs @@ -557,3 +557,16 @@ async fn shutdown_is_cancellation_driven_during_connect_backoff_paused_and_onlin assert_eq!(stats.flushes.load(Ordering::Acquire), 1); } } + +#[tokio::test] +async fn framework_readiness_releases_only_the_matching_generation() { + let readiness = Arc::new(FrameworkReadiness::default()); + let waiting = Arc::clone(&readiness); + let task = + tokio::spawn(async move { waiting.wait_ready(2, CancellationToken::default()).await }); + readiness.mark_ready(1); + tokio::task::yield_now().await; + assert!(!task.is_finished()); + readiness.mark_ready(2); + assert!(task.await.unwrap()); +} diff --git a/crates/metacrate-grid-agent/src/vision.rs b/crates/metacrate-grid-agent/src/vision.rs index e252513..43f52b9 100644 --- a/crates/metacrate-grid-agent/src/vision.rs +++ b/crates/metacrate-grid-agent/src/vision.rs @@ -72,12 +72,14 @@ pub struct SceneBuildTimings { #[derive(Clone, Debug, PartialEq)] pub struct SceneSnapshot { pub generation: u64, + /// Changes only when the persistent render scene changes; camera movement does not change it. + pub scene_sequence: u64, pub observed_unix_millis: u64, pub region_id: UUID, pub region_name: String, pub camera: CameraPose, - pub entities: Vec, - pub textures: Vec, + pub entities: Arc<[SceneEntity]>, + pub textures: Arc<[SceneTexture]>, pub completeness: SnapshotCompleteness, /// Work already performed by the scene source; validated again before rendering. pub texture_fetches: usize, @@ -235,7 +237,9 @@ pub struct VisionService { #[cfg(feature = "live-grid")] gpu_preferred: bool, #[cfg(feature = "live-grid")] - gpu: tokio::sync::OnceCell>>, + gpu: tokio::sync::OnceCell>>, + #[cfg(feature = "live-grid")] + viewport_task: Mutex>>, } impl VisionService { @@ -258,6 +262,8 @@ impl VisionService { gpu_preferred: false, #[cfg(feature = "live-grid")] gpu: tokio::sync::OnceCell::new(), + #[cfg(feature = "live-grid")] + viewport_task: Mutex::new(None), }) } /// Prefer cached offscreen `wgpu` rendering, falling back to the @@ -276,7 +282,7 @@ impl VisionService { } #[cfg(feature = "live-grid")] - async fn renderer(&self) -> Option> { + async fn renderer(&self) -> Option> { self.gpu .get_or_init(|| async { tokio::task::spawn_blocking(bevy_renderer_new) @@ -288,6 +294,85 @@ impl VisionService { .await .clone() } + + /// Returns the last fully completed viewport without waiting for or + /// interfering with the next render. + #[cfg(feature = "live-grid")] + #[must_use] + pub fn latest_viewport( + &self, + ) -> Option> { + self.gpu.get()?.as_ref()?.latest() + } + + #[cfg(feature = "live-grid")] + #[must_use] + pub fn viewport_stats(&self) -> Option { + Some(self.gpu.get()?.as_ref()?.stats()) + } + + /// Starts the scene feeder. The viewport crate owns the fixed-rate render + /// loop and completed-frame publication after submission. + #[cfg(feature = "live-grid")] + pub fn start_viewport(self: &Arc, generation: u64, cancellation: CancellationToken) { + if let Some(task) = lock(&self.viewport_task).take() { + task.abort(); + } + let service = Arc::downgrade(self); + *lock(&self.viewport_task) = Some(tokio::spawn(async move { + loop { + if cancellation.is_cancellation_requested() { + break; + } + let Some(service) = service.upgrade() else { + break; + }; + if let Ok(scene) = service + .source + .capture_scene(generation, cancellation.clone()) + .await + { + service.submit_viewport_scene(&scene).await; + } + drop(service); + tokio::select! { + () = cancellation.cancelled() => break, + () = tokio::time::sleep(Duration::from_millis(100)) => {} + } + } + })); + } + + #[cfg(feature = "live-grid")] + pub async fn stop_viewport(&self) { + let task = lock(&self.viewport_task).take(); + if let Some(task) = task { + task.abort(); + let _ = task.await; + } + } + + #[cfg(feature = "live-grid")] + pub async fn wait_viewport_ready(&self, timeout: Duration) -> bool { + tokio::time::timeout(timeout, async { + loop { + if self.latest_viewport().is_some() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .is_ok() + } + + #[cfg(feature = "live-grid")] + async fn submit_viewport_scene(&self, scene: &SceneSnapshot) { + let Some(viewport) = self.renderer().await else { + return; + }; + viewport.submit(viewport_scene(scene, self.limits)); + } pub fn set_generation(&self, generation: u64) { if self.current_generation.swap(generation, Ordering::AcqRel) != generation { self.cancel_active(); @@ -429,55 +514,16 @@ impl VisionService { if self.gpu_preferred && let Some(renderer) = self.renderer().await { - let mut renderables = scene - .entities - .iter() - .flat_map(|entity| { - entity - .renderables - .iter() - .cloned() - .map(move |renderable| (entity.id.to_string(), renderable)) - }) - .collect::>(); - renderables.sort_by(|left, right| left.0.cmp(&right.0)); - let renderables = renderables - .into_iter() - .map(|(_, renderable)| renderable) - .collect::>(); - let gpu_scene = scene.clone(); - let limits = self.limits; - if let Ok(Ok(capture)) = tokio::task::spawn_blocking(move || { - renderer - .render( - gpu_scene.camera, - &renderables, - &gpu_scene.textures, - environment_color(&gpu_scene), - 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, - }, - ) - .map_err(|error| match error { - metacrate_rendering_wgpu::RenderError::InvalidScene => { - VisionError::InvalidScene - } - metacrate_rendering_wgpu::RenderError::ResourceLimit => { - VisionError::ResourceLimit - } - metacrate_rendering_wgpu::RenderError::TimedOut => VisionError::TimedOut, - _ => VisionError::Encode, - }) - .and_then(|rgba| finish_capture(&gpu_scene, limits, &rgba)) - }) - .await - { - return Ok(capture); + renderer.submit(viewport_scene(&scene, self.limits)); + let deadline = Instant::now() + self.limits.capture_timeout; + while Instant::now() < deadline { + if let Some(frame) = renderer.latest() + && frame.value.scene_sequence == scene.scene_sequence + && frame.value.camera == scene.camera + { + return finish_capture(&scene, self.limits, &frame.value.rgba); + } + tokio::time::sleep(Duration::from_millis(5)).await; } } render_scene_software(&scene, self.limits) @@ -486,8 +532,46 @@ impl VisionService { #[cfg(feature = "live-grid")] fn bevy_renderer_new() --> Result { - metacrate_rendering_wgpu::Renderer::new_blocking() +-> Result { + metacrate_game_loop::ViewportLoop::start(metacrate_game_loop::DEFAULT_VIEWPORT_FRAME_INTERVAL) +} + +#[cfg(feature = "live-grid")] +fn viewport_scene( + scene: &SceneSnapshot, + limits: VisionLimits, +) -> metacrate_game_loop::ViewportScene { + let mut renderables = scene + .entities + .iter() + .flat_map(|entity| { + entity + .renderables + .iter() + .cloned() + .map(move |renderable| (entity.id.to_string(), renderable)) + }) + .collect::>(); + renderables.sort_by(|left, right| left.0.cmp(&right.0)); + metacrate_game_loop::ViewportScene { + sequence: scene.scene_sequence, + camera: scene.camera, + renderables: renderables + .into_iter() + .map(|(_, renderable)| renderable) + .collect::>() + .into(), + textures: Arc::clone(&scene.textures), + background_srgb: environment_color(scene), + 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, + }, + } } /// Type-erased runtime control surface. It deliberately exposes metadata and @@ -746,7 +830,7 @@ fn validate_scene(scene: &SceneSnapshot, limits: VisionLimits) -> Result<(), Vis return Err(VisionError::ResourceLimit); } let mut count = 0usize; - for entity in &scene.entities { + for entity in scene.entities.iter() { count = count .checked_add(entity.triangles.len()) .ok_or(VisionError::ResourceLimit)?; @@ -965,8 +1049,11 @@ pub struct LibremetaverseSceneSource { agent: Arc, limits: VisionLimits, cache: Arc>, + latest_scene: metacrate_game_loop::Latest, + scene_build: tokio::sync::Mutex<()>, prefetch_task: Mutex>>, prefetch_passes: AtomicU64, + scene_sequence: AtomicU64, } #[cfg(feature = "live-grid")] @@ -1013,8 +1100,11 @@ impl LibremetaverseSceneSource { agent: owner.agent(), limits, cache: Arc::new(Mutex::new(SceneCache::default())), + latest_scene: metacrate_game_loop::Latest::default(), + scene_build: tokio::sync::Mutex::new(()), prefetch_task: Mutex::new(None), prefetch_passes: AtomicU64::new(0), + scene_sequence: AtomicU64::new(0), } } @@ -1031,11 +1121,15 @@ impl LibremetaverseSceneSource { break; }; if source.client.network().current_sim().is_some() - && source - .capture_scene(0, CancellationToken::default()) - .await - .is_ok() + && let Ok(scene) = source.capture_scene(0, CancellationToken::default()).await { + if source + .latest_scene + .load() + .is_none_or(|published| !same_render_scene(&published.value, &scene)) + { + source.latest_scene.publish(scene); + } source.prefetch_passes.fetch_add(1, Ordering::AcqRel); } drop(source); @@ -1057,6 +1151,40 @@ impl LibremetaverseSceneSource { pub fn prefetch_passes(&self) -> u64 { self.prefetch_passes.load(Ordering::Acquire) } + + fn cached_scene( + &self, + generation: u64, + simulator: &libremetaverse::Simulator, + ) -> Option { + let published = self.latest_scene.load()?; + if generation == 0 || published.value.region_id != simulator.region_id { + return None; + } + let camera = &self.agent.movement.camera; + let position = camera.position(); + let forward = native_camera_forward(camera); + let up = camera.up_axis(); + let mut scene = (*published.value).clone(); + scene.generation = generation; + scene.observed_unix_millis = unix_millis_now(); + scene.camera = CameraPose { + position: [position.x, position.y, position.z], + forward: [forward.x, forward.y, forward.z], + up: [up.x, up.y, up.z], + vertical_fov_degrees: camera.vertical_fov_angle().to_degrees(), + }; + scene.timings = SceneBuildTimings::default(); + Some(scene) + } +} + +#[cfg(feature = "live-grid")] +fn same_render_scene(left: &SceneSnapshot, right: &SceneSnapshot) -> bool { + left.region_id == right.region_id + && left.entities == right.entities + && left.textures == right.textures + && left.completeness == right.completeness } #[cfg(feature = "live-grid")] @@ -1124,6 +1252,13 @@ impl SceneSource for LibremetaverseSceneSource { .network() .current_sim() .ok_or(VisionError::StaleGeneration)?; + if let Some(scene) = self.cached_scene(generation, &simulator) { + return Ok(scene); + } + let _scene_build = self.scene_build.lock().await; + if let Some(scene) = self.cached_scene(generation, &simulator) { + return Ok(scene); + } let scene_deadline = Instant::now() + Duration::from_secs(5); loop { let terrain_ready = simulator @@ -1624,11 +1759,8 @@ impl SceneSource for LibremetaverseSceneSource { .unwrap_or(u32::MAX); Ok(SceneSnapshot { generation, - observed_unix_millis: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| { - duration.as_millis().try_into().unwrap_or(u64::MAX) - }), + scene_sequence: self.scene_sequence.fetch_add(1, Ordering::AcqRel) + 1, + observed_unix_millis: unix_millis_now(), region_id, region_name, camera: CameraPose { @@ -1637,8 +1769,8 @@ impl SceneSource for LibremetaverseSceneSource { up: [up.x, up.y, up.z], vertical_fov_degrees: camera.vertical_fov_angle().to_degrees(), }, - entities, - textures, + entities: entities.into(), + textures: textures.into(), completeness: SnapshotCompleteness { objects_truncated: objects_truncated || geometry_truncated, avatars_truncated, @@ -1660,6 +1792,14 @@ impl SceneSource for LibremetaverseSceneSource { } } +fn unix_millis_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| { + duration.as_millis().try_into().unwrap_or(u64::MAX) + }) +} + #[cfg(feature = "live-grid")] pub(crate) fn native_camera_forward( camera: &libremetaverse::AgentManagerAgentMovementAgentCamera, diff --git a/crates/metacrate-grid-agent/src/vision_tests.rs b/crates/metacrate-grid-agent/src/vision_tests.rs index e0cb1b8..d3a845f 100644 --- a/crates/metacrate-grid-agent/src/vision_tests.rs +++ b/crates/metacrate-grid-agent/src/vision_tests.rs @@ -9,6 +9,7 @@ use libremetaverse_types::{ compat::{CancellationToken, CancellationTokenSource}, }; use std::sync::{Arc, Mutex}; +use std::time::Duration; struct FakeScene { scene: SceneSnapshot, @@ -363,6 +364,7 @@ fn scene_visibility_uses_configured_distance_and_avatar_view() { fn scene() -> SceneSnapshot { SceneSnapshot { generation: 7, + scene_sequence: 1, observed_unix_millis: 1_700_000_000_000, region_id: uuid(1), region_name: "Fixture Region".into(), @@ -389,8 +391,9 @@ fn scene() -> SceneSnapshot { renderables: Vec::new(), texture_available: true, }, - ], - textures: Vec::new(), + ] + .into(), + textures: Vec::new().into(), completeness: SnapshotCompleteness { textures_missing: 1, terrain_available: false, @@ -449,7 +452,7 @@ async fn golden_scene_is_deterministic_depth_ordered_and_privacy_marked() { #[cfg(feature = "live-grid")] #[tokio::test(flavor = "multi_thread")] async fn offscreen_wgpu_renders_depth_ordered_scene_when_an_adapter_is_available() { - let Ok(renderer) = metacrate_rendering_wgpu::Renderer::new_blocking() else { + let Ok(renderer) = metacrate_game_loop::ViewportLoop::start(Duration::from_millis(100)) else { return; }; let limits = VisionLimits { @@ -467,25 +470,30 @@ async fn offscreen_wgpu_renders_depth_ordered_scene_when_an_adapter_is_available let renderables = scene_renderable_from_triangles(&triangles) .into_iter() .collect::>(); - let rgba = tokio::task::spawn_blocking(move || { - renderer.render( - scene.camera, - &renderables, - &scene.textures, - [72, 96, 120, 255], - 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, - }, - ) + renderer.submit(metacrate_game_loop::ViewportScene { + sequence: scene.scene_sequence, + camera: scene.camera, + renderables: renderables.into(), + textures: Arc::clone(&scene.textures), + background_srgb: [72, 96, 120, 255], + 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, + }, + }); + tokio::time::timeout(Duration::from_secs(3), async { + while renderer.stats().completed_frames < 2 { + tokio::time::sleep(Duration::from_millis(20)).await; + } }) .await - .expect("render worker") - .expect("offscreen render"); + .expect("fixed-rate viewport did not publish repeated frames"); + let rgba = renderer.latest().unwrap().value.rgba.clone(); + assert_eq!(renderer.stats().render_errors, 0); assert_eq!(rgba.len(), 64 * 64 * 4); let center = (32 * 64 + 32) * 4; assert!(rgba[center + 1] > 180 && rgba[center] < 80 && rgba[center + 2] < 80); diff --git a/crates/metacrate-grid-agent/tests/dependency_policy.rs b/crates/metacrate-grid-agent/tests/dependency_policy.rs index 1fd7dd1..2caafa5 100644 --- a/crates/metacrate-grid-agent/tests/dependency_policy.rs +++ b/crates/metacrate-grid-agent/tests/dependency_policy.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; -const ALLOWED_DEPENDENCIES: [&str; 21] = [ +const ALLOWED_DEPENDENCIES: [&str; 22] = [ "async-trait", "base64", "crossterm", @@ -10,6 +10,7 @@ const ALLOWED_DEPENDENCIES: [&str; 21] = [ "libremetaverse", "libremetaverse-imaging", "libremetaverse-rendering-simple", + "metacrate-game-loop", "metacrate-lsl-tools", "metacrate-rendering-wgpu", "mentra", @@ -170,8 +171,6 @@ fn live_session_adapter_reuses_native_lifecycle_and_messaging_managers() { ".subscribe_im(", ".chat(", ".instant_message_with_uuid_string(", - ".objects_avatars", - ".objects_primitives", ".parcels", ".environment()", ".inventory()", @@ -183,6 +182,15 @@ fn live_session_adapter_reuses_native_lifecycle_and_messaging_managers() { "missing native lifecycle {required}" ); } + let world = fs::read_to_string(root.join("../metacrate-grid-world/src/lib.rs")) + .expect("central world source"); + for required in [".objects_avatars", ".objects_primitives", ".terrain"] { + assert!( + world.contains(required), + "missing centralized native world cache {required}" + ); + } + assert!(backend.contains("metacrate_game_loop::WorldReader")); assert!(!backend.contains("reqwest")); } diff --git a/crates/metacrate-grid-agent/tests/live_renderer.rs b/crates/metacrate-grid-agent/tests/live_renderer.rs index b9429ca..d22eba2 100644 --- a/crates/metacrate-grid-agent/tests/live_renderer.rs +++ b/crates/metacrate-grid-agent/tests/live_renderer.rs @@ -159,12 +159,27 @@ async fn live_primary_map_name_reports_varregion_dimensions() -> Result<(), Box< async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box> { 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 (_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(); @@ -183,6 +198,27 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box Result<(), Box Result<(), Box>(); + let renderables: Arc<[metacrate_rendering_wgpu::Renderable]> = renderables.into(); let preparation = preparation_started.elapsed(); let renderer_started = Instant::now(); - let renderer = metacrate_rendering_wgpu::Renderer::new_blocking()?; + let renderer = metacrate_game_loop::ViewportRuntime::new_blocking()?; let renderer_initialization = renderer_started.elapsed(); let render_limits = metacrate_rendering_wgpu::RenderLimits { width: limits.width, @@ -323,24 +389,38 @@ async fn profile_warm_capture( max_texture_pixels: limits.max_decode_pixels, far_distance: 512, }; - let first = renderer.render_profiled( + let first = renderer.render_scene( + scene.scene_sequence, scene.camera, - &renderables, - &scene.textures, + || Arc::clone(&renderables), + Arc::clone(&scene.textures), [125, 149, 173, 255], render_limits, )?; - let warm_started = Instant::now(); - let warm = renderer.render_profiled( + let refresh_started = Instant::now(); + let refresh = renderer.render_scene( + scene.scene_sequence.saturating_add(1), scene.camera, - &renderables, - &scene.textures, + || 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()) @@ -354,7 +434,7 @@ async fn profile_warm_capture( )?; 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:{:?},warm_render_total_ms:{},warm_render:{:?},jpeg_encode_ms:{},profile_total_ms:{}", + "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 @@ -368,11 +448,16 @@ async fn profile_warm_capture( 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(()) } @@ -400,8 +485,7 @@ impl SceneSource for DiagnosticSceneSource { 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 + for renderable in Arc::make_mut(&mut scene.entities) .iter_mut() .filter(|entity| entity.kind == SceneEntityKind::Object) .flat_map(|entity| &mut entity.renderables) diff --git a/crates/metacrate-grid-world/Cargo.toml b/crates/metacrate-grid-world/Cargo.toml new file mode 100644 index 0000000..7017342 --- /dev/null +++ b/crates/metacrate-grid-world/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "metacrate-grid-world" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Bounded OpenSim and Second Life event stream and persistent world state" +publish = false + +[dependencies] +libremetaverse = { version = "0.0.1", path = "../libremetaverse", default-features = false } +libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" } + +[lints] +workspace = true diff --git a/crates/metacrate-grid-world/src/lib.rs b/crates/metacrate-grid-world/src/lib.rs new file mode 100644 index 0000000..fdbe065 --- /dev/null +++ b/crates/metacrate-grid-world/src/lib.rs @@ -0,0 +1,767 @@ +//! Bounded OpenSim/Second Life event consumption and persistent world state. + +#![allow(clippy::missing_errors_doc)] + +use libremetaverse::{Avatar, GridClient, Primitive, Simulator}; +use libremetaverse_types::{UUID, compat::Subscription}; +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc::{self, Receiver, RecvError, SyncSender, TryRecvError}, + }, + thread, + time::Duration, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RegionDescriptor { + pub id: UUID, + pub handle: u64, + pub name: String, + pub size: (u32, u32), +} + +impl RegionDescriptor { + #[must_use] + pub fn from_simulator(simulator: &Simulator) -> Self { + Self { + id: simulator.region_id, + handle: simulator.handle, + name: simulator.name.clone(), + size: simulator.region_size(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum PrimitiveChange { + Full, + Movement, +} + +pub enum WorldEvent { + RegionChanged(Option), + PrimitiveUpdated { + region: RegionDescriptor, + primitive: Arc, + change: PrimitiveChange, + }, + AvatarUpdated { + region: RegionDescriptor, + avatar: Arc, + }, + AvatarMoved { + region: RegionDescriptor, + local_id: u32, + position: libremetaverse_types::Vector3, + rotation: libremetaverse_types::Quaternion, + velocity: libremetaverse_types::Vector3, + }, + ObjectsRemoved { + region: RegionDescriptor, + local_ids: Vec, + }, + TerrainUpdated { + region: RegionDescriptor, + x: i32, + y: i32, + patch_size: i32, + height_map: Vec, + }, + VisibilitySnapshot { + region: RegionDescriptor, + primitives: Vec>, + avatars: Vec>, + terrain: Vec<((i32, i32), TerrainPatch)>, + }, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum DirtyWorldItem { + Region, + Primitive(u32, PrimitiveChange), + Avatar(u32), + Terrain(i32, i32), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TerrainPatch { + pub patch_size: i32, + pub height_map: Arc<[f32]>, +} + +#[derive(Clone)] +pub struct WorldSnapshot { + pub observed_unix_millis: u64, + pub region: Option, + pub primitives: Arc>>, + pub avatars: Arc>>, + pub terrain: Arc>, +} + +#[derive(Default)] +pub struct WorldState { + region: Option, + primitives: HashMap>, + avatars: HashMap>, + terrain: BTreeMap<(i32, i32), TerrainPatch>, + dirty: BTreeSet, +} + +impl WorldState { + pub fn apply(&mut self, event: WorldEvent) { + match event { + WorldEvent::RegionChanged(region) => self.change_region(region), + WorldEvent::PrimitiveUpdated { + region, + primitive, + change, + } => { + self.ensure_region(region); + let local_id = primitive.local_id; + self.primitives.insert(local_id, primitive); + self.dirty.retain( + |item| !matches!(item, DirtyWorldItem::Primitive(id, _) if *id == local_id), + ); + self.dirty + .insert(DirtyWorldItem::Primitive(local_id, change)); + } + WorldEvent::AvatarUpdated { region, avatar } => { + self.ensure_region(region); + let local_id = avatar.local_id; + self.avatars.insert(local_id, avatar); + self.dirty.insert(DirtyWorldItem::Avatar(local_id)); + } + WorldEvent::AvatarMoved { + region, + local_id, + position, + rotation, + velocity, + } => { + self.ensure_region(region); + if let Some(avatar) = self.avatars.get_mut(&local_id) { + let avatar = Arc::make_mut(avatar); + avatar.position = position; + avatar.rotation = rotation; + avatar.velocity = velocity; + self.dirty.insert(DirtyWorldItem::Avatar(local_id)); + } + } + WorldEvent::ObjectsRemoved { region, local_ids } => { + self.ensure_region(region); + for local_id in local_ids { + self.primitives.remove(&local_id); + self.avatars.remove(&local_id); + self.dirty + .insert(DirtyWorldItem::Primitive(local_id, PrimitiveChange::Full)); + } + } + WorldEvent::TerrainUpdated { + region, + x, + y, + patch_size, + height_map, + } => { + self.ensure_region(region); + self.terrain.insert( + (x, y), + TerrainPatch { + patch_size, + height_map: height_map.into(), + }, + ); + self.dirty.insert(DirtyWorldItem::Terrain(x, y)); + } + WorldEvent::VisibilitySnapshot { + region, + primitives, + avatars, + terrain, + } => { + self.region = Some(region); + self.primitives = primitives + .into_iter() + .map(|primitive| (primitive.local_id, primitive)) + .collect(); + self.avatars = avatars + .into_iter() + .map(|avatar| (avatar.local_id, avatar)) + .collect(); + self.terrain = terrain.into_iter().collect(); + self.dirty.clear(); + self.dirty.insert(DirtyWorldItem::Region); + } + } + } + + fn ensure_region(&mut self, region: RegionDescriptor) { + if self.region.as_ref().map(|value| value.handle) != Some(region.handle) { + self.change_region(Some(region)); + } + } + + fn change_region(&mut self, region: Option) { + self.region = region; + self.primitives.clear(); + self.avatars.clear(); + self.terrain.clear(); + self.dirty.clear(); + self.dirty.insert(DirtyWorldItem::Region); + } + + #[must_use] + pub fn region(&self) -> Option<&RegionDescriptor> { + self.region.as_ref() + } + + #[must_use] + pub fn primitive(&self, local_id: u32) -> Option> { + self.primitives.get(&local_id).cloned() + } + + #[must_use] + pub fn avatar(&self, local_id: u32) -> Option> { + self.avatars.get(&local_id).cloned() + } + + #[must_use] + pub fn terrain(&self, x: i32, y: i32) -> Option<&TerrainPatch> { + self.terrain.get(&(x, y)) + } + + pub fn take_dirty(&mut self) -> Vec { + std::mem::take(&mut self.dirty).into_iter().collect() + } + + #[must_use] + pub fn snapshot(&self) -> WorldSnapshot { + WorldSnapshot { + observed_unix_millis: unix_millis_now(), + region: self.region.clone(), + primitives: Arc::new(self.primitives.clone()), + avatars: Arc::new(self.avatars.clone()), + terrain: Arc::new(self.terrain.clone()), + } + } +} + +fn unix_millis_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| { + duration.as_millis().try_into().unwrap_or(u64::MAX) + }) +} + +#[derive(Clone)] +pub struct WorldEventSender { + sender: SyncSender, + dropped: Arc, +} + +impl WorldEventSender { + pub fn send(&self, event: WorldEvent) { + if self.sender.try_send(event).is_err() { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } + + #[must_use] + pub fn dropped(&self) -> u64 { + self.dropped.load(Ordering::Relaxed) + } +} + +pub struct WorldEventReceiver { + receiver: Receiver, + dropped: Arc, +} + +#[derive(Clone)] +pub struct WorldEventMetrics { + dropped: Arc, +} + +impl WorldEventMetrics { + #[must_use] + pub fn dropped(&self) -> u64 { + self.dropped.load(Ordering::Relaxed) + } +} + +#[must_use] +pub fn world_event_channel(capacity: usize) -> Option<(WorldEventSender, WorldEventReceiver)> { + if capacity == 0 { + return None; + } + let (sender, receiver) = mpsc::sync_channel(capacity); + let dropped = Arc::new(AtomicU64::new(0)); + Some(( + WorldEventSender { + sender, + dropped: Arc::clone(&dropped), + }, + WorldEventReceiver { receiver, dropped }, + )) +} + +impl WorldEventReceiver { + pub fn recv(&self) -> Result { + self.receiver.recv() + } + + pub fn try_recv(&self) -> Result { + self.receiver.try_recv() + } + + #[must_use] + pub fn dropped(&self) -> u64 { + self.dropped.load(Ordering::Relaxed) + } + + #[must_use] + pub fn metrics(&self) -> WorldEventMetrics { + WorldEventMetrics { + dropped: Arc::clone(&self.dropped), + } + } +} + +pub struct LibreMetaverseWorldEvents { + _subscriptions: Vec, + stopped: Arc, + monitor: Option>, +} + +impl Drop for LibreMetaverseWorldEvents { + fn drop(&mut self) { + self.stopped.store(true, Ordering::Release); + if let Some(monitor) = self.monitor.take() { + monitor.thread().unpark(); + let _ = monitor.join(); + } + } +} + +impl LibreMetaverseWorldEvents { + #[must_use] + pub fn subscribe(client: &GridClient, capacity: usize) -> Option<(Self, WorldEventReceiver)> { + Self::subscribe_inner(client, None, capacity) + } + + #[must_use] + pub fn subscribe_visible( + client: &GridClient, + agent: Arc, + max_distance_meters: u32, + capacity: usize, + ) -> Option<(Self, WorldEventReceiver)> { + let maximum = f32::from(u16::try_from(max_distance_meters).ok()?); + (maximum > 0.0).then_some(())?; + Self::subscribe_inner(client, Some((agent, maximum)), capacity) + } + + #[allow(clippy::too_many_lines)] // One subscription owner keeps all native event lifetimes together. + fn subscribe_inner( + client: &GridClient, + view: Option<(Arc, f32)>, + capacity: usize, + ) -> Option<(Self, WorldEventReceiver)> { + if capacity == 0 { + return None; + } + let (ingress, receiver) = world_event_channel(capacity)?; + let objects = client.objects(); + let terrain = client.terrain(); + let network = client.network(); + let monitor_view = view.clone(); + let monitor_ingress = ingress.clone(); + let mut subscriptions = Vec::with_capacity(6); + + let event_ingress = ingress.clone(); + let event_view = view.clone(); + subscriptions.push(objects.subscribe_object_update(Arc::new(move |event| { + let simulator = event.simulator(); + let primitive = event.prim(); + let Some(primitive) = visible_primitive(&simulator, primitive, event_view.as_ref()) + else { + return; + }; + event_ingress.send(WorldEvent::PrimitiveUpdated { + region: RegionDescriptor::from_simulator(&simulator), + primitive: Arc::new(primitive), + change: PrimitiveChange::Full, + }); + }))); + let event_ingress = ingress.clone(); + let event_view = view.clone(); + subscriptions.push( + objects.subscribe_terse_object_update(Arc::new(move |event| { + let simulator = event.simulator(); + let update = event.update(); + if update.avatar { + if visible_position(update.position, event_view.as_ref()) { + event_ingress.send(WorldEvent::AvatarMoved { + region: RegionDescriptor::from_simulator(&simulator), + local_id: update.local_id, + position: update.position, + rotation: update.rotation, + velocity: update.velocity, + }); + } else { + event_ingress.send(WorldEvent::ObjectsRemoved { + region: RegionDescriptor::from_simulator(&simulator), + local_ids: vec![update.local_id], + }); + } + return; + } + let mut primitive = event.prim(); + primitive.position = update.position; + primitive.rotation = update.rotation; + primitive.velocity = update.velocity; + primitive.acceleration = update.acceleration; + primitive.angular_velocity = update.angular_velocity; + let local_id = primitive.local_id; + let Some(primitive) = visible_primitive(&simulator, primitive, event_view.as_ref()) + else { + event_ingress.send(WorldEvent::ObjectsRemoved { + region: RegionDescriptor::from_simulator(&simulator), + local_ids: vec![local_id], + }); + return; + }; + event_ingress.send(WorldEvent::PrimitiveUpdated { + region: RegionDescriptor::from_simulator(&simulator), + primitive: Arc::new(primitive), + change: PrimitiveChange::Movement, + }); + })), + ); + let event_ingress = ingress.clone(); + let event_view = view.clone(); + subscriptions.push(objects.subscribe_avatar_update(Arc::new(move |event| { + let simulator = event.simulator(); + let avatar = event.avatar(); + if !visible_position(avatar.position, event_view.as_ref()) { + return; + } + event_ingress.send(WorldEvent::AvatarUpdated { + region: RegionDescriptor::from_simulator(&simulator), + avatar: Arc::new(avatar), + }); + }))); + let event_ingress = ingress.clone(); + subscriptions.push(objects.subscribe_kill_object(Arc::new(move |event| { + let simulator = event.simulator(); + event_ingress.send(WorldEvent::ObjectsRemoved { + region: RegionDescriptor::from_simulator(&simulator), + local_ids: vec![event.object_local_id()], + }); + }))); + let event_ingress = ingress.clone(); + let event_view = view; + subscriptions.push( + terrain.subscribe_land_patch_received(Arc::new(move |event| { + let simulator = event.simulator(); + if !visible_terrain_patch( + event.x(), + event.y(), + event.patch_size(), + event_view.as_ref(), + ) { + return; + } + event_ingress.send(WorldEvent::TerrainUpdated { + region: RegionDescriptor::from_simulator(&simulator), + x: event.x(), + y: event.y(), + patch_size: event.patch_size(), + height_map: event.height_map(), + }); + })), + ); + let event_ingress = ingress; + let callback_client = client.clone(); + subscriptions.push(network.subscribe_sim_changed(Arc::new(move |_| { + event_ingress.send(WorldEvent::RegionChanged( + callback_client + .network() + .current_sim() + .as_ref() + .map(RegionDescriptor::from_simulator), + )); + }))); + + let stopped = Arc::new(AtomicBool::new(false)); + let monitor = if let Some((agent, maximum)) = monitor_view { + let monitor_client = client.clone(); + let monitor_stopped = Arc::clone(&stopped); + Some( + thread::Builder::new() + .name("metacrate-world-visibility".into()) + .spawn(move || { + let mut last = None; + while !monitor_stopped.load(Ordering::Acquire) { + if let Some(simulator) = monitor_client.network().current_sim() { + let position = agent.sim_position(); + let changed = last.is_none_or( + |(handle, previous): (u64, libremetaverse_types::Vector3)| { + let dx = position.x - previous.x; + let dy = position.y - previous.y; + let dz = position.z - previous.z; + handle != simulator.handle + || dx * dx + dy * dy + dz * dz >= 16.0 + }, + ); + if changed { + monitor_ingress + .send(visibility_snapshot(&simulator, &agent, maximum)); + last = Some((simulator.handle, position)); + } + } + thread::park_timeout(Duration::from_millis(250)); + } + }) + .ok()?, + ) + } else { + None + }; + + Some(( + Self { + _subscriptions: subscriptions, + stopped, + monitor, + }, + receiver, + )) + } +} + +fn visibility_snapshot( + simulator: &Simulator, + agent: &libremetaverse::AgentManager, + maximum: f32, +) -> WorldEvent { + let center = agent.sim_position(); + let primitives = simulator + .objects_primitives + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let avatars = simulator + .objects_avatars + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let visible_primitives = primitives + .values() + .filter_map(|primitive| resolved_primitive(primitive.clone(), &primitives, &avatars)) + .filter(|primitive| position_in_radius(primitive.position, center, maximum)) + .map(Arc::new) + .collect(); + let visible_avatars = avatars + .values() + .filter(|avatar| position_in_radius(avatar.position, center, maximum)) + .cloned() + .map(Arc::new) + .collect(); + drop(primitives); + drop(avatars); + let terrain = simulator + .terrain + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter(|patch| terrain_patch_in_radius(patch.x, patch.y, 16, center, maximum)) + .map(|patch| { + ( + (patch.x, patch.y), + TerrainPatch { + patch_size: 16, + height_map: Arc::from(patch.data.clone()), + }, + ) + }) + .collect(); + WorldEvent::VisibilitySnapshot { + region: RegionDescriptor::from_simulator(simulator), + primitives: visible_primitives, + avatars: visible_avatars, + terrain, + } +} + +fn position_in_radius( + position: libremetaverse_types::Vector3, + center: libremetaverse_types::Vector3, + maximum: f32, +) -> bool { + let dx = position.x - center.x; + let dy = position.y - center.y; + let dz = position.z - center.z; + dx * dx + dy * dy + dz * dz <= maximum * maximum +} + +fn visible_position( + position: libremetaverse_types::Vector3, + view: Option<&(Arc, f32)>, +) -> bool { + view.is_none_or(|(agent, maximum)| { + let center = agent.sim_position(); + position_in_radius(position, center, *maximum) + }) +} + +fn visible_terrain_patch( + x: i32, + y: i32, + patch_size: i32, + view: Option<&(Arc, f32)>, +) -> bool { + let Some((agent, maximum)) = view else { + return true; + }; + terrain_patch_in_radius(x, y, patch_size, agent.sim_position(), *maximum) +} + +fn terrain_patch_in_radius( + x: i32, + y: i32, + patch_size: i32, + center: libremetaverse_types::Vector3, + maximum: f32, +) -> bool { + let (Ok(x), Ok(y), Ok(patch_size)) = ( + i16::try_from(x), + i16::try_from(y), + i16::try_from(patch_size), + ) else { + return false; + }; + let (Some(patch_x), Some(patch_y)) = (x.checked_mul(patch_size), y.checked_mul(patch_size)) + else { + return false; + }; + let patch_x = f32::from(patch_x) + f32::from(patch_size) * 0.5; + let patch_y = f32::from(patch_y) + f32::from(patch_size) * 0.5; + let dx = patch_x - center.x; + let dy = patch_y - center.y; + dx * dx + dy * dy <= maximum * maximum +} + +fn visible_primitive( + simulator: &Simulator, + mut primitive: Primitive, + view: Option<&(Arc, f32)>, +) -> Option { + if view.is_none() { + return Some(primitive); + } + let primitives = simulator + .objects_primitives + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let avatars = simulator + .objects_avatars + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + primitive = resolved_primitive(primitive, &primitives, &avatars)?; + visible_position(primitive.position, view).then_some(primitive) +} + +fn resolved_primitive( + mut primitive: Primitive, + primitives: &HashMap, + avatars: &HashMap, +) -> Option { + let mut parent_id = primitive.parent_id; + for _ in 0..32 { + if parent_id == 0 { + primitive.parent_id = 0; + return Some(primitive); + } + if let Some(parent) = primitives.get(&parent_id) { + primitive.position = libremetaverse_types::Vector3::add_with_vector3_vector3( + parent.position, + libremetaverse_types::Vector3::mul_with_vector3_quaternion( + primitive.position, + parent.rotation, + ), + ) + .ok()?; + primitive.rotation = libremetaverse_types::Quaternion::mul_with_quaternion_quaternion( + parent.rotation, + primitive.rotation, + ); + parent_id = parent.parent_id; + continue; + } + let avatar = avatars.get(&parent_id)?; + primitive.position = libremetaverse_types::Vector3::add_with_vector3_vector3( + avatar.position, + libremetaverse_types::Vector3::mul_with_vector3_quaternion( + primitive.position, + avatar.rotation, + ), + ) + .ok()?; + primitive.rotation = libremetaverse_types::Quaternion::mul_with_quaternion_quaternion( + avatar.rotation, + primitive.rotation, + ); + primitive.parent_id = 0; + primitive.is_attachment = true; + return Some(primitive); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn region_changes_clear_state_and_dirty_changes_coalesce() { + let first = RegionDescriptor { + id: UUID::new_with_string("00000000-0000-0000-0000-000000000001".into()).unwrap(), + handle: 1, + name: "First".into(), + size: (256, 256), + }; + let second = RegionDescriptor { + id: UUID::new_with_string("00000000-0000-0000-0000-000000000002".into()).unwrap(), + handle: 2, + name: "Second".into(), + size: (1024, 1024), + }; + let mut world = WorldState::default(); + world.apply(WorldEvent::RegionChanged(Some(first))); + world.apply(WorldEvent::TerrainUpdated { + region: second.clone(), + x: 0, + y: 0, + patch_size: 1, + height_map: vec![3.0], + }); + assert_eq!(world.region(), Some(&second)); + assert_eq!(world.terrain(0, 0).unwrap().height_map.as_ref(), &[3.0]); + assert_eq!( + world.take_dirty(), + vec![DirtyWorldItem::Region, DirtyWorldItem::Terrain(0, 0)] + ); + assert!(world.take_dirty().is_empty()); + world.apply(WorldEvent::VisibilitySnapshot { + region: second, + primitives: Vec::new(), + avatars: Vec::new(), + terrain: Vec::new(), + }); + assert!(world.terrain(0, 0).is_none()); + assert_eq!(world.take_dirty(), vec![DirtyWorldItem::Region]); + } +} diff --git a/crates/metacrate-rendering-wgpu/src/gpu.rs b/crates/metacrate-rendering-wgpu/src/gpu.rs index f85e6ec..8fbab29 100644 --- a/crates/metacrate-rendering-wgpu/src/gpu.rs +++ b/crates/metacrate-rendering-wgpu/src/gpu.rs @@ -45,6 +45,19 @@ enum RenderCommand { limits: RenderLimits, reply: mpsc::SyncSender>, }, + LoadScene { + camera: Camera, + renderables: std::sync::Arc<[Renderable]>, + textures: std::sync::Arc<[Texture]>, + limits: RenderLimits, + reply: mpsc::SyncSender>, + }, + View { + camera: Camera, + background_srgb: [u8; 4], + limits: RenderLimits, + reply: mpsc::SyncSender>, + }, Stop, } @@ -102,6 +115,32 @@ impl Renderer { limits, )); } + RenderCommand::LoadScene { + camera, + renderables, + textures, + limits, + reply, + } => { + let _ = reply.send(backend.load_scene( + camera, + &renderables, + &textures, + limits, + )); + } + RenderCommand::View { + camera, + background_srgb, + limits, + reply, + } => { + let _ = reply.send(backend.render_view( + camera, + background_srgb, + limits, + )); + } RenderCommand::Stop => break, } } @@ -170,6 +209,83 @@ impl Renderer { frame.timings.command_copy = command_copy; Ok(frame) } + + pub fn load_scene( + &self, + camera: Camera, + renderables: &[Renderable], + textures: &[Texture], + limits: RenderLimits, + ) -> Result { + self.load_scene_shared( + camera, + renderables.to_vec().into(), + textures.to_vec().into(), + limits, + ) + } + + pub fn load_scene_shared( + &self, + camera: Camera, + renderables: std::sync::Arc<[Renderable]>, + textures: std::sync::Arc<[Texture]>, + limits: RenderLimits, + ) -> Result { + let started = Instant::now(); + validate_scene(camera, &renderables, &textures, limits)?; + let validation = started.elapsed(); + let started = Instant::now(); + let (sender, receiver) = mpsc::sync_channel(1); + let command = RenderCommand::LoadScene { + camera, + renderables, + textures, + limits, + reply: sender, + }; + let command_copy = started.elapsed(); + self.send(command)?; + let mut timings = receiver + .recv_timeout(Duration::from_mins(2)) + .map_err(|_| RenderError::TimedOut)??; + timings.validation = validation; + timings.command_copy = command_copy; + Ok(timings) + } + + pub fn render_view( + &self, + camera: Camera, + background_srgb: [u8; 4], + limits: RenderLimits, + ) -> Result { + camera.basis()?; + if limits.width == 0 || limits.height == 0 || limits.far_distance == 0 { + return Err(RenderError::InvalidScene); + } + let (sender, receiver) = mpsc::sync_channel(1); + self.send(RenderCommand::View { + camera, + background_srgb, + limits, + reply: sender, + })?; + receiver + .recv_timeout(Duration::from_mins(2)) + .map_err(|_| RenderError::TimedOut)? + } + + fn send(&self, command: RenderCommand) -> Result<(), RenderError> { + self.commands + .try_send(command) + .map_err(|error| match error { + mpsc::TrySendError::Full(_) => RenderError::TimedOut, + mpsc::TrySendError::Disconnected(_) => { + RenderError::Device("renderer thread stopped".into()) + } + }) + } } impl Drop for Renderer { @@ -189,6 +305,9 @@ impl Drop for Renderer { #[derive(Component)] struct FrameEntity; +#[derive(Component)] +struct PersistentSceneEntity; + struct LegacyMaterialPlugin; struct PbrMaterialPlugin; @@ -328,13 +447,14 @@ struct CachedGpuMesh { struct BevyBackend { apps: SubApps, frame_entities: Vec, + scene_entities: Vec, texture_handles: Vec, texture_cache: HashMap>, mesh_cache: HashMap>, pbr_handles: Vec>, legacy_handles: Vec>, environment_map: EnvironmentMapLight, - target: Option>, + target: Option<(u32, u32, Handle)>, } impl BevyBackend { @@ -381,6 +501,7 @@ impl BevyBackend { Ok(Self { apps: std::mem::take(app.sub_apps_mut()), frame_entities: Vec::new(), + scene_entities: Vec::new(), texture_handles: Vec::new(), texture_cache: HashMap::new(), mesh_cache: HashMap::new(), @@ -400,18 +521,28 @@ impl BevyBackend { background_srgb: [u8; 4], limits: RenderLimits, ) -> Result { + let mut timings = self.load_scene(camera, renderables, textures, limits)?; + let frame = self.render_view(camera, background_srgb, limits)?; + timings.clear_previous_frame += frame.timings.clear_previous_frame; + timings.scene_setup += frame.timings.scene_setup; + timings.capture += frame.timings.capture; + Ok(RenderedFrame { + rgba: frame.rgba, + timings, + }) + } + + #[allow(clippy::too_many_lines)] + fn load_scene( + &mut self, + camera: Camera, + renderables: &[Renderable], + textures: &[Texture], + _limits: RenderLimits, + ) -> Result { let started = Instant::now(); - self.clear_frame(); + self.clear_scene(); let clear_previous_frame = started.elapsed(); - self.apps - .main - .world_mut() - .insert_resource(ClearColor(Color::srgba_u8( - background_srgb[0], - background_srgb[1], - background_srgb[2], - background_srgb[3], - ))); let started = Instant::now(); self.texture_handles = add_textures( self.apps.main.world_mut(), @@ -443,7 +574,7 @@ impl BevyBackend { .spawn(( Mesh3d(mesh.clone()), MeshMaterial3d(material.clone()), - FrameEntity, + PersistentSceneEntity, )) .id(); self.legacy_handles.push(material); @@ -463,15 +594,70 @@ impl BevyBackend { .spawn(( Mesh3d(mesh.clone()), MeshMaterial3d(material.clone()), - FrameEntity, + PersistentSceneEntity, )) .id(); self.pbr_handles.push(material); entity } }; - self.frame_entities.push(entity); + self.scene_entities.push(entity); } + let light = self + .apps + .main + .world_mut() + .spawn(( + DirectionalLight { + illuminance: 12_000.0, + shadow_maps_enabled: false, + ..Default::default() + }, + Transform::from_xyz(40.0, 80.0, 30.0).looking_at(Vec3::ZERO, Vec3::Y), + PersistentSceneEntity, + )) + .id(); + self.scene_entities.push(light); + Ok(RenderTimings { + clear_previous_frame, + texture_cache_sync, + mesh_cache_sync, + scene_setup: started.elapsed(), + ..Default::default() + }) + } + + fn render_view( + &mut self, + camera: Camera, + background_srgb: [u8; 4], + limits: RenderLimits, + ) -> Result { + let started = Instant::now(); + self.clear_frame(); + let clear_previous_frame = started.elapsed(); + self.apps + .main + .world_mut() + .insert_resource(ClearColor(Color::srgba_u8( + background_srgb[0], + background_srgb[1], + background_srgb[2], + background_srgb[3], + ))); + let camera_position = world_vector(camera.position).extend(1.0); + for handle in &self.legacy_handles { + if let Some(mut material) = self + .apps + .main + .world_mut() + .resource_mut::>() + .get_mut(handle) + { + material.uniform.camera_position = camera_position; + } + } + let started = Instant::now(); let target = self.new_target(limits.width, limits.height); let transform = camera_transform(camera)?; let camera_entity = self @@ -494,21 +680,6 @@ impl BevyBackend { )) .id(); self.frame_entities.push(camera_entity); - let light = self - .apps - .main - .world_mut() - .spawn(( - DirectionalLight { - illuminance: 12_000.0, - shadow_maps_enabled: false, - ..Default::default() - }, - Transform::from_xyz(40.0, 80.0, 30.0).looking_at(Vec3::ZERO, Vec3::Y), - FrameEntity, - )) - .id(); - self.frame_entities.push(light); let scene_setup = started.elapsed(); let started = Instant::now(); let rgba = self.capture(&target)?; @@ -516,8 +687,6 @@ impl BevyBackend { rgba, timings: RenderTimings { clear_previous_frame, - texture_cache_sync, - mesh_cache_sync, scene_setup, capture: started.elapsed(), ..Default::default() @@ -526,7 +695,12 @@ impl BevyBackend { } fn new_target(&mut self, width: u32, height: u32) -> RenderTarget { - if let Some(old) = self.target.take() { + if let Some((old_width, old_height, handle)) = &self.target + && (*old_width, *old_height) == (width, height) + { + return handle.clone().into(); + } + if let Some((_, _, old)) = self.target.take() { self.apps .main .world_mut() @@ -550,7 +724,7 @@ impl BevyBackend { .world_mut() .resource_mut::>() .add(image); - self.target = Some(handle.clone()); + self.target = Some((width, height, handle.clone())); handle.into() } @@ -600,6 +774,13 @@ impl BevyBackend { for entity in self.frame_entities.drain(..) { let _ = self.apps.main.world_mut().despawn(entity); } + } + + fn clear_scene(&mut self) { + self.clear_frame(); + for entity in self.scene_entities.drain(..) { + let _ = self.apps.main.world_mut().despawn(entity); + } let world = self.apps.main.world_mut(); self.texture_handles.clear(); let mut materials = world.resource_mut::>(); @@ -1231,6 +1412,36 @@ mod tests { .collect::>() ); } + + let limits = RenderLimits { + width: 96, + height: 64, + max_triangles: 2, + max_texture_bytes: 64, + max_texture_pixels: 16, + far_distance: 64, + }; + renderer + .load_scene( + camera, + &[renderable(Material::BlinnPhong(BlinnPhongMaterial { + diffuse_color_srgb: [30, 220, 60, 255], + fullbright: true, + ..Default::default() + }))], + &textures, + limits, + ) + .unwrap(); + for _ in 0..2 { + let frame = renderer + .render_view(camera, [0, 0, 0, 255], limits) + .unwrap(); + assert_eq!(frame.rgba.len(), 96 * 64 * 4); + assert_eq!(frame.timings.command_copy, Duration::ZERO); + assert_eq!(frame.timings.texture_cache_sync, Duration::ZERO); + assert_eq!(frame.timings.mesh_cache_sync, Duration::ZERO); + } } fn texture_slot(texture: usize, rotation_radians: f32) -> TextureSlot { diff --git a/docs/renderer-performance.md b/docs/renderer-performance.md index 6881fa3..b12147c 100644 --- a/docs/renderer-performance.md +++ b/docs/renderer-performance.md @@ -1,100 +1,116 @@ -# Renderer and viewport performance +# Agent runtime and viewport performance Measured on 2026-08-23 with the primary account in Broceliande, a 1024 m x -1024 m varregion, using the default 64 m view distance. The visible scene had -211 objects, 427,829 triangles, and five unavailable grid textures. Release -numbers are the performance baseline; debug timings are intentionally omitted. +1024 m varregion, using the default 64 m view distance. The rendered scene had +211 objects, 427,253 triangles, and five unavailable grid textures. All timing +numbers below are optimized release builds. -## Current capture path +## Runtime ownership -| Phase | Warm release time | Can run ahead? | Required change | -|---|---:|---|---| -| Read current simulator objects and discover assets | 8 ms | Yes | Apply incoming object/avatar/terrain events to a persistent world instead of scanning the simulator maps per capture. | -| Fetch cached/missing assets | 151 ms | Yes | Bounded asset workers continuously fetch visible dirty assets. Raw immutable assets remain in the 2 GiB disk LRU. | -| Decode textures and derive scene geometry | 176 ms warm; 8.03 s on the first scene | Yes | Retain decoded visible textures and derived meshes in memory. Add a versioned persistent derived-mesh cache only for the measured 8 s cold-start cost; decoded RGBA textures should normally remain memory-only because they expand substantially. | -| Collect and sort renderables | 1 ms | Yes | Persistent renderer entities remove this per-frame list build. | -| Validate scene | 1 ms | Yes | Validate assets and changes when admitted, not every frame. | -| Copy render command | 21 ms | Yes | Send stable IDs and dirty updates; do not clone the complete scene for a frame. | -| Re-identify cached textures by content hash | 30 ms | Yes | Key immutable texture resources by grid asset UUID. | -| Re-identify cached meshes by content hash | 14 ms | Yes | Key derived meshes by stable object/asset signature and LOD. | -| Recreate frame entities | <1 ms | Yes | Keep Bevy entities and material handles alive; update only dirty components. | -| Render plus synchronous readback | 46 ms | Partly | Render continuously into a persistent target. A GUI presents that target directly; readback happens only for CPU consumers. | -| JPEG encoding | 41 ms | Yes, and not part of viewport FPS | A separate latest-frame worker encodes only when an LLM/image consumer asks for it. | +- `metacrate-grid-world` owns native OpenSim/Second Life event subscriptions, + distance filtering, attachment world transforms, terse movement application, + event-drop accounting, and immutable published world snapshots. +- `metacrate-game-loop` owns the fixed update schedule, renderer lifetime, + persistent GPU scene, fixed-rate viewport loop, completed-frame buffers, and + timing signals. Readers receive an immutable `Arc<[u8]>`; they never read a + render target while Bevy writes it. +- `metacrate-rendering-wgpu` owns Bevy/wgpu resources and supports separate + scene loading and camera/frame rendering. Shared immutable scene arrays cross + the renderer thread without copying their contents. +- `metacrate-grid-agent` supplies policy, conversation, tools, and lifecycle + triggers. It consumes published world/viewport state instead of synchronizing + render buffers or individual native event channels. -The existing end-to-end warm capture takes 525 ms. A repeated renderer call -alone takes 113 ms (8.8 FPS), including 67 ms of avoidable copying, hashing, -and cache synchronization. Its measured render/readback segment is 46 ms -(21.9 FPS equivalent). A persistent GPU scene therefore makes the initial -10 FPS viewport target realistic on the measured hardware. +Grid callbacks never perform asset I/O or render work. The world loop drains a +bounded 4,096-event queue in batches, coalesces dirty IDs, and publishes only +complete snapshots. Object, avatar, attachment, and terrain admission uses the +configured view distance; 64 m is the default. The live reference contained +280 visible prims, one avatar, and 51 nearby terrain patches, with zero dropped +events. -The 46 ms value still includes synchronous CPU readback. It is not a pure GPU -timestamp. Continuous viewport rendering without readback should be faster and -must be measured separately once the persistent render target exists. +## Readiness -## Startup +Transport connection is not agent readiness. A generation remains degraded and +chat, tools, movement, and autonomous behavior remain fenced while the terminal +reports: -The optimized cold-start profile was: +```text +INITIALIZING generation=N waiting_for=world_state,viewport target_fps=10 +``` -| Phase | Time | Scheduling | +The framework gate opens only after the region-scoped world state exists, the +first scene/assets have converged, and at least two complete viewport frames +have been published without render errors. The terminal then emits +`INITIALIZED ...` followed by one unambiguous `READY ...` line when the session +and agent services are released. The default readiness allowance is two +minutes; the measured scene convergence window was about 30.05 seconds. + +## Measured steady state + +| Phase | Measured time | +|---|---:| +| Bevy/wgpu initialization | 476 ms | +| First persistent GPU scene load and frame | 1.02 s | +| Cached complete scene refresh | 86 ms | +| Stable warm render plus coherent CPU readback | 31–36 ms | +| JPEG encoding on demand | 37 ms | +| Repeated capture using the already published viewport, including JPEG | 45 ms | + +The viewport uses a 90 ms scheduling interval, providing margin above the +minimum 10 FPS requirement. The live two-second soak completed 22 frames at +10.99 FPS with zero missed deadlines and zero render errors. JPEG encoding and +LLM transfer are not in this loop. + +A cached full-scene refresh completed in 86 ms, inside the 90 ms interval: +validation 1.08 ms, shared command handoff 0.001 ms, texture cache sync 30.47 +ms, mesh cache sync 15.21 ms, scene setup 0.47 ms, and render/readback 35.70 ms. +Unchanged frames skip validation, command transfer, texture sync, and mesh sync. + +## Startup work + +| Phase | Reference time | Scheduling | |---|---:|---| | Client owner initialization | 2 ms | Startup thread | | Grid login | 3.37 s | Parallel with renderer initialization | -| wgpu/Bevy initialization | 452 ms | Parallel with grid login; completed before agent readiness | -| Test scene convergence window | 30.04 s | World events continue asynchronously; this is not a system-readiness gate. | -| First full visible-scene build from persistent raw assets | 8.35 s | Background asset/scene workers; publish partial complete frames as content converges. | +| Bevy/wgpu initialization | 0.49 s | Completed before agent readiness | +| Initial world/asset convergence | 30.05 s | Asynchronous Grid and asset workers | +| First persistent GPU scene and frame | 1.02 s | Before the readiness gate opens | -System readiness must require configuration, renderer initialization (or an -explicitly reported fallback), grid login, region dimensions, and the running -world/update loops. It must not wait until every user-created asset inside the -view radius has decoded: that would turn missing or slow grid assets into a -permanent login stall. Visual readiness is a separate completeness signal. +The slower first start is intentional: the agent does not claim readiness until +it can provide stable world and visual state. Raw immutable assets persist in +the configured 2 GiB LRU under the platform cache directory +(`$HOME/.cache/metacrate` on Linux), so later starts reuse them. -## Game loop +## Frame publication and future GUI path -The agent needs four independent paths: +The current agent path continuously renders and publishes the latest complete +RGBA frame. A requested snapshot reads that immutable frame and performs JPEG +encoding independently. Camera or scene submissions replace stale pending +input; consumers never block the viewport loop. -1. Grid callbacks enqueue compact object, avatar, terrain, region, and camera - changes immediately. They never perform asset I/O or rendering. -2. A fixed update loop drains those events, updates the authoritative CPU world, - recalculates 64 m visibility, and emits stable dirty IDs. Grid event handling - must remain faster than the render tick. -3. Bounded asset workers fetch, decode, and derive only dirty visible content. - Completed resources update the CPU world and enqueue GPU changes. -4. A render loop applies GPU changes and updates a persistent viewport at an - initial 10 FPS target. It retains the latest complete frame. GUI presentation - uses the GPU target directly; snapshot readback, resize, JPEG encoding, and - LLM upload are independent latest-frame consumers. +The current Bevy screenshot API still performs synchronous CPU readback, which +accounts for most of the 31–36 ms warm frame cost. This meets the current +headless-agent target, but a GUI viewer should present the GPU target directly. +For higher-rate CPU capture, use a ring of three staging buffers: render, enqueue +GPU copy, map asynchronously, and publish only after completion. If all staging +buffers are occupied, skip that readback instead of stalling rendering. -Simulation/update and rendering use separate clocks. Slow asset downloads, -JPEG encoding, LLM requests, and subscribers must never hold either loop. +## Instrumentation and observed limits -### Frame publication and readback +Hot paths record monotonic timestamps and fixed-cardinality counters only. +World event drops, completed frames, missed deadlines, render errors, and last +render duration are available without formatting or I/O on the loop threads. -The persistent viewport renders into a GPU texture. A GUI presents it without -CPU readback. When a CPU image is requested, the render graph schedules a copy -after that frame's render commands into the next free staging buffer. GPU queue -ordering makes the copied frame coherent even when the render target is reused -for the following frame. +Observed limiting factors: -Use a ring of three staging buffers. Mapping and CPU consumption happen -asynchronously; a buffer is reused only after its completion signal. If all -three are busy, skip that readback rather than stall rendering. Publish only the -latest completed immutable CPU frame, tagged with frame sequence, camera pose, -and observation time. JPEG and LLM workers use that published frame and discard -superseded work. The current synchronous Bevy screenshot plus `device.poll(Wait)` -path is only a diagnostic bridge and must not remain in the game loop. - -## Instrumentation - -Hot paths record only monotonic start/end timestamps and emit fixed-size, -fixed-cardinality timing signals with `try_send`. A bounded receiver owned by a -dedicated profiling worker aggregates counts, totals, maxima, and percentile -histograms and publishes observability records. A full queue drops profiling -signals and increments a drop counter; it never backpressures grid or rendering. -Formatting, serialization, journal I/O, and subscriber delivery remain outside -the measured thread. - -Required fixed phases are startup/config, client creation, renderer creation, -login, grid readiness, event ingest, update tick, visibility, asset disk read, -asset network fetch, texture decode, geometry derive, GPU change application, -render submission, GPU completion, optional readback, and optional JPEG encode. +- Initial scene/asset convergence, about 30 seconds, dominates readiness. +- First GPU admission is about one second; it occurs before `READY`. +- A cached full-scene refresh is 86 ms and is currently the closest operation to + the 90 ms frame budget. Incremental per-entity GPU updates are the next useful + optimization if denser or highly animated regions exceed that budget. +- Synchronous readback costs roughly 31–36 ms per frame. A direct GUI path or + asynchronous staging ring removes it from presentation. +- Five referenced textures were unavailable from the test grid. Missing assets + do not deadlock readiness; completeness remains explicit. +- The successful live run used 280 visible prims, one avatar, 51 terrain + patches, 427,253 render triangles, 4,096 event slots, and dropped zero events.