Centralize world and viewport game loops
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled

This commit is contained in:
2026-08-23 13:42:01 +02:00
parent cb9233c59c
commit 33d1cf6827
18 changed files with 2541 additions and 287 deletions

17
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@@ -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<Self> {
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<T> {
sequence: u64,
value: Option<Arc<T>>,
}
#[derive(Debug)]
pub struct Latest<T> {
inner: Mutex<LatestValue<T>>,
}
impl<T> Default for Latest<T> {
fn default() -> Self {
Self {
inner: Mutex::new(LatestValue {
sequence: 0,
value: None,
}),
}
}
}
#[derive(Clone, Debug)]
pub struct Published<T> {
pub sequence: u64,
pub value: Arc<T>,
}
#[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<ViewportFrame>,
}
impl StableViewport {
pub fn publish(&self, frame: ViewportFrame) -> Result<u64, ViewportFrame> {
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<Published<ViewportFrame>> {
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<Option<u64>>,
}
pub struct ViewportRender {
pub frame: Published<ViewportFrame>,
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<ViewportRuntime>,
input: Arc<Latest<ViewportScene>>,
stopped: Arc<AtomicBool>,
thread: Mutex<Option<thread::JoinHandle<()>>>,
completed_frames: Arc<AtomicU64>,
missed_frame_deadlines: Arc<AtomicU64>,
render_errors: Arc<AtomicU64>,
last_render_nanos: Arc<AtomicU64>,
}
impl ViewportLoop {
pub fn start(frame_interval: Duration) -> Result<Self, metacrate_rendering_wgpu::RenderError> {
if frame_interval.is_zero() {
return Err(metacrate_rendering_wgpu::RenderError::InvalidScene);
}
let runtime = Arc::new(ViewportRuntime::new_blocking()?);
let input: Arc<Latest<ViewportScene>> = 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<Published<ViewportFrame>> {
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<Self, metacrate_rendering_wgpu::RenderError> {
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<F>(
&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<ViewportRender, metacrate_rendering_wgpu::RenderError>
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<Published<ViewportFrame>> {
self.viewport.latest()
}
}
impl<T> Latest<T> {
pub fn publish(&self, value: T) -> u64 {
self.publish_arc(Arc::new(value))
}
pub fn publish_arc(&self, value: Arc<T>) -> 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<Published<T>> {
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<TimingSignal>,
dropped: Arc<AtomicU64>,
}
#[derive(Debug)]
pub struct TimingReceiver {
receiver: Receiver<TimingSignal>,
dropped: Arc<AtomicU64>,
}
#[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<TimingSignal, RecvTimeoutError> {
self.receiver.recv_timeout(timeout)
}
pub fn try_recv(&self) -> Result<TimingSignal, TryRecvError> {
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<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
world: Arc<Latest<WorldSnapshot>>,
world_events: WorldEventMetrics,
}
#[derive(Clone)]
pub struct WorldReader {
world: Arc<Latest<WorldSnapshot>>,
world_events: WorldEventMetrics,
}
impl WorldReader {
#[must_use]
pub fn latest(&self) -> Option<Published<WorldSnapshot>> {
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<Published<WorldSnapshot>> {
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<D: LoopDriver>(
config: LoopConfig,
events: WorldEventReceiver,
mut driver: D,
timings: Option<TimingEmitter>,
) -> Option<LoopHandle> {
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<T>(value: &Mutex<T>) -> 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<AtomicU64>);
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());
}
}

View File

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

View File

@@ -107,6 +107,7 @@ pub struct LibremetaverseClientOwner {
pub struct LibremetaverseWorldSnapshotSource {
client: libremetaverse::GridClient,
agent: Arc<libremetaverse::AgentManager>,
world: metacrate_game_loop::WorldReader,
}
#[cfg(feature = "live-grid")]
@@ -143,6 +144,7 @@ pub struct LibremetaverseSessionBackend {
interaction: Option<crate::interaction::InteractionIngress>,
perception: Option<crate::perception::PerceptionIngress>,
behavior: Option<crate::behavior::BehaviorIngress>,
framework_readiness: Option<Arc<crate::session::FrameworkReadiness>>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
@@ -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<crate::session::FrameworkReadiness>,
) -> Self {
self.framework_readiness = Some(readiness);
self
}
}
#[cfg(feature = "live-grid")]
fn metacrate_cache_dir() -> Result<std::path::PathBuf, BackendError> {
directories::ProjectDirs::from("", "", "metacrate")
@@ -362,10 +381,9 @@ fn metacrate_cache_dir() -> Result<std::path::PathBuf, BackendError> {
impl LibremetaverseWorldSnapshotSource {
#[must_use]
pub fn current_region_handle(&self) -> Option<u64> {
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<crate::interaction::InteractionIngress>,
perception: Option<crate::perception::PerceptionIngress>,
behavior: Option<crate::behavior::BehaviorIngress>,
agent: Arc<libremetaverse::AgentManager>,
framework_readiness: Option<Arc<crate::session::FrameworkReadiness>>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
@@ -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<dyn crate::session::GridSession> = 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)

View File

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

View File

@@ -273,9 +273,9 @@ async fn run_live(
) -> Result<(), Box<dyn Error>> {
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<metacrate_grid_agent::VisionService<metacrate_grid_agent::LibremetaverseSceneSource>>,
scene_source: Arc<metacrate_grid_agent::LibremetaverseSceneSource>,
world: Arc<metacrate_grid_agent::LibremetaverseWorldSnapshotSource>,
_world_events: metacrate_game_loop::LibreMetaverseWorldEvents,
world_loop: Option<metacrate_game_loop::LoopHandle>,
}
#[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),
})
}

View File

@@ -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<Box<dyn Future<Output = T> + Send + 'a>>;
/// Generation-scoped gate between transport readiness and autonomous agent readiness.
pub struct FrameworkReadiness {
generation: watch::Sender<u64>,
}
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,

View File

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

View File

@@ -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<SceneEntity>,
pub textures: Vec<SceneTexture>,
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<S: SceneSource> {
#[cfg(feature = "live-grid")]
gpu_preferred: bool,
#[cfg(feature = "live-grid")]
gpu: tokio::sync::OnceCell<Option<Arc<metacrate_rendering_wgpu::Renderer>>>,
gpu: tokio::sync::OnceCell<Option<Arc<metacrate_game_loop::ViewportLoop>>>,
#[cfg(feature = "live-grid")]
viewport_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl<S: SceneSource> VisionService<S> {
@@ -258,6 +262,8 @@ impl<S: SceneSource> VisionService<S> {
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<S: SceneSource> VisionService<S> {
}
#[cfg(feature = "live-grid")]
async fn renderer(&self) -> Option<Arc<metacrate_rendering_wgpu::Renderer>> {
async fn renderer(&self) -> Option<Arc<metacrate_game_loop::ViewportLoop>> {
self.gpu
.get_or_init(|| async {
tokio::task::spawn_blocking(bevy_renderer_new)
@@ -288,6 +294,85 @@ impl<S: SceneSource> VisionService<S> {
.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<metacrate_game_loop::Published<metacrate_game_loop::ViewportFrame>> {
self.gpu.get()?.as_ref()?.latest()
}
#[cfg(feature = "live-grid")]
#[must_use]
pub fn viewport_stats(&self) -> Option<metacrate_game_loop::ViewportStats> {
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<Self>, 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<S: SceneSource> VisionService<S> {
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::<Vec<_>>();
renderables.sort_by(|left, right| left.0.cmp(&right.0));
let renderables = renderables
.into_iter()
.map(|(_, renderable)| renderable)
.collect::<Vec<_>>();
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<S: SceneSource> VisionService<S> {
#[cfg(feature = "live-grid")]
fn bevy_renderer_new()
-> Result<metacrate_rendering_wgpu::Renderer, metacrate_rendering_wgpu::RenderError> {
metacrate_rendering_wgpu::Renderer::new_blocking()
-> Result<metacrate_game_loop::ViewportLoop, metacrate_rendering_wgpu::RenderError> {
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::<Vec<_>>();
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::<Vec<_>>()
.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<libremetaverse::AgentManager>,
limits: VisionLimits,
cache: Arc<Mutex<SceneCache>>,
latest_scene: metacrate_game_loop::Latest<SceneSnapshot>,
scene_build: tokio::sync::Mutex<()>,
prefetch_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
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<SceneSnapshot> {
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,

View File

@@ -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::<Vec<_>>();
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);

View File

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

View File

@@ -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<dyn Error>> {
let environment = live_environment()?;
let renderer_owner = LibremetaverseClientOwner::new()?;
let (mut renderer_session, renderer_cancel) =
login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
let limits = VisionLimits {
minimum_interval: Duration::ZERO,
..VisionLimits::default()
};
let (_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<dyn Er
renderer_simulator.name, region_size.0, region_size.1
);
wait_scene_settled(&renderer_owner).await?;
tokio::time::timeout(Duration::from_secs(10), async {
while world_loop
.latest_world()
.is_none_or(|world| world.value.region.is_none())
{
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.map_err(|_| "central world state did not become ready")?;
let world = world_loop
.latest_world()
.ok_or("central world snapshot disappeared")?;
println!(
"LIVE_WORLD_STATE=sequence:{},primitives:{},avatars:{},terrain_patches:{},dropped_events:{}",
world.sequence,
world.value.primitives.len(),
world.value.avatars.len(),
world.value.terrain.len(),
world_loop.dropped_world_events(),
);
let view_forward = Vector3::mul_with_vector3_quaternion(
Vector3::unit_x(),
renderer_owner.agent().sim_rotation(),
@@ -273,6 +309,31 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
}
}
let capture = capture.ok_or("live renderer produced no frame")?;
let viewport_before = vision
.viewport_stats()
.ok_or("viewport loop did not expose statistics")?;
let soak_started = Instant::now();
tokio::time::sleep(Duration::from_secs(2)).await;
let soak_elapsed = soak_started.elapsed();
let viewport_after = vision
.viewport_stats()
.ok_or("viewport loop stopped exposing statistics")?;
let soak_frames = viewport_after
.completed_frames
.saturating_sub(viewport_before.completed_frames);
let soak_missed = viewport_after
.missed_frame_deadlines
.saturating_sub(viewport_before.missed_frame_deadlines);
let soak_fps = f64::from(u32::try_from(soak_frames)?) / soak_elapsed.as_secs_f64();
println!(
"LIVE_VIEWPORT_SOAK=frames:{soak_frames},elapsed_ms:{},fps:{soak_fps:.2},missed_deadlines:{soak_missed},render_errors:{},last_render_ms:{}",
soak_elapsed.as_millis(),
viewport_after.render_errors,
viewport_after.last_render.as_millis(),
);
if soak_fps < 10.0 || soak_missed != 0 || viewport_after.render_errors != 0 {
return Err("stable viewport did not sustain its 10 Hz target".into());
}
profile_warm_capture(&scene_source, limits, renderer_cancel.token()).await?;
println!("LIVE_RENDER_OUTPUT={}", output.display());
println!("LIVE_RENDER_SHA256={}", capture.image_sha256);
@@ -281,10 +342,14 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
.await;
scene_source.stop_prefetch().await;
world_loop
.shutdown()
.map_err(|_| "world loop thread panicked")?;
let _ = renderer_session.logout(renderer_cancel.token()).await;
result
}
#[allow(clippy::too_many_lines)] // One ordered live timing record keeps phase boundaries explicit.
async fn profile_warm_capture(
source: &LibremetaverseSceneSource,
limits: VisionLimits,
@@ -311,9 +376,10 @@ async fn profile_warm_capture(
.into_iter()
.map(|(_, renderable)| renderable)
.collect::<Vec<_>>();
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)

View File

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

View File

@@ -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<RegionDescriptor>),
PrimitiveUpdated {
region: RegionDescriptor,
primitive: Arc<Primitive>,
change: PrimitiveChange,
},
AvatarUpdated {
region: RegionDescriptor,
avatar: Arc<Avatar>,
},
AvatarMoved {
region: RegionDescriptor,
local_id: u32,
position: libremetaverse_types::Vector3,
rotation: libremetaverse_types::Quaternion,
velocity: libremetaverse_types::Vector3,
},
ObjectsRemoved {
region: RegionDescriptor,
local_ids: Vec<u32>,
},
TerrainUpdated {
region: RegionDescriptor,
x: i32,
y: i32,
patch_size: i32,
height_map: Vec<f32>,
},
VisibilitySnapshot {
region: RegionDescriptor,
primitives: Vec<Arc<Primitive>>,
avatars: Vec<Arc<Avatar>>,
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<RegionDescriptor>,
pub primitives: Arc<HashMap<u32, Arc<Primitive>>>,
pub avatars: Arc<HashMap<u32, Arc<Avatar>>>,
pub terrain: Arc<BTreeMap<(i32, i32), TerrainPatch>>,
}
#[derive(Default)]
pub struct WorldState {
region: Option<RegionDescriptor>,
primitives: HashMap<u32, Arc<Primitive>>,
avatars: HashMap<u32, Arc<Avatar>>,
terrain: BTreeMap<(i32, i32), TerrainPatch>,
dirty: BTreeSet<DirtyWorldItem>,
}
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<RegionDescriptor>) {
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<Arc<Primitive>> {
self.primitives.get(&local_id).cloned()
}
#[must_use]
pub fn avatar(&self, local_id: u32) -> Option<Arc<Avatar>> {
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<DirtyWorldItem> {
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<WorldEvent>,
dropped: Arc<AtomicU64>,
}
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<WorldEvent>,
dropped: Arc<AtomicU64>,
}
#[derive(Clone)]
pub struct WorldEventMetrics {
dropped: Arc<AtomicU64>,
}
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<WorldEvent, RecvError> {
self.receiver.recv()
}
pub fn try_recv(&self) -> Result<WorldEvent, TryRecvError> {
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<Subscription>,
stopped: Arc<AtomicBool>,
monitor: Option<thread::JoinHandle<()>>,
}
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<libremetaverse::AgentManager>,
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<libremetaverse::AgentManager>, 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<libremetaverse::AgentManager>, 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<libremetaverse::AgentManager>, 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<libremetaverse::AgentManager>, f32)>,
) -> Option<Primitive> {
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<u32, Primitive>,
avatars: &HashMap<u32, Avatar>,
) -> Option<Primitive> {
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]);
}
}

View File

@@ -45,6 +45,19 @@ enum RenderCommand {
limits: RenderLimits,
reply: mpsc::SyncSender<Result<RenderedFrame, RenderError>>,
},
LoadScene {
camera: Camera,
renderables: std::sync::Arc<[Renderable]>,
textures: std::sync::Arc<[Texture]>,
limits: RenderLimits,
reply: mpsc::SyncSender<Result<RenderTimings, RenderError>>,
},
View {
camera: Camera,
background_srgb: [u8; 4],
limits: RenderLimits,
reply: mpsc::SyncSender<Result<RenderedFrame, RenderError>>,
},
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<RenderTimings, RenderError> {
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<RenderTimings, RenderError> {
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<RenderedFrame, RenderError> {
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<Entity>,
scene_entities: Vec<Entity>,
texture_handles: Vec<GpuTexture>,
texture_cache: HashMap<u64, Vec<CachedGpuTexture>>,
mesh_cache: HashMap<u64, Vec<CachedGpuMesh>>,
pbr_handles: Vec<Handle<PbrBevyMaterial>>,
legacy_handles: Vec<Handle<LegacyBevyMaterial>>,
environment_map: EnvironmentMapLight,
target: Option<Handle<Image>>,
target: Option<(u32, u32, Handle<Image>)>,
}
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<RenderedFrame, RenderError> {
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<RenderTimings, RenderError> {
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<RenderedFrame, RenderError> {
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::<Assets<LegacyBevyMaterial>>()
.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::<Assets<Image>>()
.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::<Assets<PbrBevyMaterial>>();
@@ -1231,6 +1412,36 @@ mod tests {
.collect::<Vec<_>>()
);
}
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 {

View File

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