Centralize world and viewport game loops
This commit is contained in:
17
Cargo.lock
generated
17
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
16
crates/metacrate-game-loop/Cargo.toml
Normal file
16
crates/metacrate-game-loop/Cargo.toml
Normal 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
|
||||
761
crates/metacrate-game-loop/src/lib.rs
Normal file
761
crates/metacrate-game-loop/src/lib.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,6 +514,33 @@ impl<S: SceneSource> VisionService<S> {
|
||||
if self.gpu_preferred
|
||||
&& let Some(renderer) = self.renderer().await
|
||||
{
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
fn bevy_renderer_new()
|
||||
-> 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()
|
||||
@@ -441,20 +553,17 @@ impl<S: SceneSource> VisionService<S> {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
renderables.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
let renderables = renderables
|
||||
metacrate_game_loop::ViewportScene {
|
||||
sequence: scene.scene_sequence,
|
||||
camera: scene.camera,
|
||||
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 {
|
||||
.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,
|
||||
@@ -462,32 +571,7 @@ impl<S: SceneSource> VisionService<S> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
render_scene_software(&scene, self.limits)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
fn bevy_renderer_new()
|
||||
-> Result<metacrate_rendering_wgpu::Renderer, metacrate_rendering_wgpu::RenderError> {
|
||||
metacrate_rendering_wgpu::Renderer::new_blocking()
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -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,13 +470,13 @@ 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 {
|
||||
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,
|
||||
@@ -481,11 +484,16 @@ async fn offscreen_wgpu_renders_depth_ordered_scene_when_an_adapter_is_available
|
||||
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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
16
crates/metacrate-grid-world/Cargo.toml
Normal file
16
crates/metacrate-grid-world/Cargo.toml
Normal 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
|
||||
767
crates/metacrate-grid-world/src/lib.rs
Normal file
767
crates/metacrate-grid-world/src/lib.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,100 +1,116 @@
|
||||
# Renderer and viewport performance
|
||||
# Agent runtime and viewport performance
|
||||
|
||||
Measured on 2026-08-23 with the primary account in Broceliande, a 1024 m x
|
||||
1024 m varregion, using the default 64 m view distance. The visible scene had
|
||||
211 objects, 427,829 triangles, and five unavailable grid textures. Release
|
||||
numbers are the performance baseline; debug timings are intentionally omitted.
|
||||
1024 m varregion, using the default 64 m view distance. The rendered scene had
|
||||
211 objects, 427,253 triangles, and five unavailable grid textures. All timing
|
||||
numbers below are optimized release builds.
|
||||
|
||||
## Current capture path
|
||||
## Runtime ownership
|
||||
|
||||
| Phase | Warm release time | Can run ahead? | Required change |
|
||||
|---|---:|---|---|
|
||||
| Read current simulator objects and discover assets | 8 ms | Yes | Apply incoming object/avatar/terrain events to a persistent world instead of scanning the simulator maps per capture. |
|
||||
| Fetch cached/missing assets | 151 ms | Yes | Bounded asset workers continuously fetch visible dirty assets. Raw immutable assets remain in the 2 GiB disk LRU. |
|
||||
| Decode textures and derive scene geometry | 176 ms warm; 8.03 s on the first scene | Yes | Retain decoded visible textures and derived meshes in memory. Add a versioned persistent derived-mesh cache only for the measured 8 s cold-start cost; decoded RGBA textures should normally remain memory-only because they expand substantially. |
|
||||
| Collect and sort renderables | 1 ms | Yes | Persistent renderer entities remove this per-frame list build. |
|
||||
| Validate scene | 1 ms | Yes | Validate assets and changes when admitted, not every frame. |
|
||||
| Copy render command | 21 ms | Yes | Send stable IDs and dirty updates; do not clone the complete scene for a frame. |
|
||||
| Re-identify cached textures by content hash | 30 ms | Yes | Key immutable texture resources by grid asset UUID. |
|
||||
| Re-identify cached meshes by content hash | 14 ms | Yes | Key derived meshes by stable object/asset signature and LOD. |
|
||||
| Recreate frame entities | <1 ms | Yes | Keep Bevy entities and material handles alive; update only dirty components. |
|
||||
| Render plus synchronous readback | 46 ms | Partly | Render continuously into a persistent target. A GUI presents that target directly; readback happens only for CPU consumers. |
|
||||
| JPEG encoding | 41 ms | Yes, and not part of viewport FPS | A separate latest-frame worker encodes only when an LLM/image consumer asks for it. |
|
||||
- `metacrate-grid-world` owns native OpenSim/Second Life event subscriptions,
|
||||
distance filtering, attachment world transforms, terse movement application,
|
||||
event-drop accounting, and immutable published world snapshots.
|
||||
- `metacrate-game-loop` owns the fixed update schedule, renderer lifetime,
|
||||
persistent GPU scene, fixed-rate viewport loop, completed-frame buffers, and
|
||||
timing signals. Readers receive an immutable `Arc<[u8]>`; they never read a
|
||||
render target while Bevy writes it.
|
||||
- `metacrate-rendering-wgpu` owns Bevy/wgpu resources and supports separate
|
||||
scene loading and camera/frame rendering. Shared immutable scene arrays cross
|
||||
the renderer thread without copying their contents.
|
||||
- `metacrate-grid-agent` supplies policy, conversation, tools, and lifecycle
|
||||
triggers. It consumes published world/viewport state instead of synchronizing
|
||||
render buffers or individual native event channels.
|
||||
|
||||
The existing end-to-end warm capture takes 525 ms. A repeated renderer call
|
||||
alone takes 113 ms (8.8 FPS), including 67 ms of avoidable copying, hashing,
|
||||
and cache synchronization. Its measured render/readback segment is 46 ms
|
||||
(21.9 FPS equivalent). A persistent GPU scene therefore makes the initial
|
||||
10 FPS viewport target realistic on the measured hardware.
|
||||
Grid callbacks never perform asset I/O or render work. The world loop drains a
|
||||
bounded 4,096-event queue in batches, coalesces dirty IDs, and publishes only
|
||||
complete snapshots. Object, avatar, attachment, and terrain admission uses the
|
||||
configured view distance; 64 m is the default. The live reference contained
|
||||
280 visible prims, one avatar, and 51 nearby terrain patches, with zero dropped
|
||||
events.
|
||||
|
||||
The 46 ms value still includes synchronous CPU readback. It is not a pure GPU
|
||||
timestamp. Continuous viewport rendering without readback should be faster and
|
||||
must be measured separately once the persistent render target exists.
|
||||
## Readiness
|
||||
|
||||
## Startup
|
||||
Transport connection is not agent readiness. A generation remains degraded and
|
||||
chat, tools, movement, and autonomous behavior remain fenced while the terminal
|
||||
reports:
|
||||
|
||||
The optimized cold-start profile was:
|
||||
```text
|
||||
INITIALIZING generation=N waiting_for=world_state,viewport target_fps=10
|
||||
```
|
||||
|
||||
| Phase | Time | Scheduling |
|
||||
The framework gate opens only after the region-scoped world state exists, the
|
||||
first scene/assets have converged, and at least two complete viewport frames
|
||||
have been published without render errors. The terminal then emits
|
||||
`INITIALIZED ...` followed by one unambiguous `READY ...` line when the session
|
||||
and agent services are released. The default readiness allowance is two
|
||||
minutes; the measured scene convergence window was about 30.05 seconds.
|
||||
|
||||
## Measured steady state
|
||||
|
||||
| Phase | Measured time |
|
||||
|---|---:|
|
||||
| Bevy/wgpu initialization | 476 ms |
|
||||
| First persistent GPU scene load and frame | 1.02 s |
|
||||
| Cached complete scene refresh | 86 ms |
|
||||
| Stable warm render plus coherent CPU readback | 31–36 ms |
|
||||
| JPEG encoding on demand | 37 ms |
|
||||
| Repeated capture using the already published viewport, including JPEG | 45 ms |
|
||||
|
||||
The viewport uses a 90 ms scheduling interval, providing margin above the
|
||||
minimum 10 FPS requirement. The live two-second soak completed 22 frames at
|
||||
10.99 FPS with zero missed deadlines and zero render errors. JPEG encoding and
|
||||
LLM transfer are not in this loop.
|
||||
|
||||
A cached full-scene refresh completed in 86 ms, inside the 90 ms interval:
|
||||
validation 1.08 ms, shared command handoff 0.001 ms, texture cache sync 30.47
|
||||
ms, mesh cache sync 15.21 ms, scene setup 0.47 ms, and render/readback 35.70 ms.
|
||||
Unchanged frames skip validation, command transfer, texture sync, and mesh sync.
|
||||
|
||||
## Startup work
|
||||
|
||||
| Phase | Reference time | Scheduling |
|
||||
|---|---:|---|
|
||||
| Client owner initialization | 2 ms | Startup thread |
|
||||
| Grid login | 3.37 s | Parallel with renderer initialization |
|
||||
| wgpu/Bevy initialization | 452 ms | Parallel with grid login; completed before agent readiness |
|
||||
| Test scene convergence window | 30.04 s | World events continue asynchronously; this is not a system-readiness gate. |
|
||||
| First full visible-scene build from persistent raw assets | 8.35 s | Background asset/scene workers; publish partial complete frames as content converges. |
|
||||
| Bevy/wgpu initialization | 0.49 s | Completed before agent readiness |
|
||||
| Initial world/asset convergence | 30.05 s | Asynchronous Grid and asset workers |
|
||||
| First persistent GPU scene and frame | 1.02 s | Before the readiness gate opens |
|
||||
|
||||
System readiness must require configuration, renderer initialization (or an
|
||||
explicitly reported fallback), grid login, region dimensions, and the running
|
||||
world/update loops. It must not wait until every user-created asset inside the
|
||||
view radius has decoded: that would turn missing or slow grid assets into a
|
||||
permanent login stall. Visual readiness is a separate completeness signal.
|
||||
The slower first start is intentional: the agent does not claim readiness until
|
||||
it can provide stable world and visual state. Raw immutable assets persist in
|
||||
the configured 2 GiB LRU under the platform cache directory
|
||||
(`$HOME/.cache/metacrate` on Linux), so later starts reuse them.
|
||||
|
||||
## Game loop
|
||||
## Frame publication and future GUI path
|
||||
|
||||
The agent needs four independent paths:
|
||||
The current agent path continuously renders and publishes the latest complete
|
||||
RGBA frame. A requested snapshot reads that immutable frame and performs JPEG
|
||||
encoding independently. Camera or scene submissions replace stale pending
|
||||
input; consumers never block the viewport loop.
|
||||
|
||||
1. Grid callbacks enqueue compact object, avatar, terrain, region, and camera
|
||||
changes immediately. They never perform asset I/O or rendering.
|
||||
2. A fixed update loop drains those events, updates the authoritative CPU world,
|
||||
recalculates 64 m visibility, and emits stable dirty IDs. Grid event handling
|
||||
must remain faster than the render tick.
|
||||
3. Bounded asset workers fetch, decode, and derive only dirty visible content.
|
||||
Completed resources update the CPU world and enqueue GPU changes.
|
||||
4. A render loop applies GPU changes and updates a persistent viewport at an
|
||||
initial 10 FPS target. It retains the latest complete frame. GUI presentation
|
||||
uses the GPU target directly; snapshot readback, resize, JPEG encoding, and
|
||||
LLM upload are independent latest-frame consumers.
|
||||
The current Bevy screenshot API still performs synchronous CPU readback, which
|
||||
accounts for most of the 31–36 ms warm frame cost. This meets the current
|
||||
headless-agent target, but a GUI viewer should present the GPU target directly.
|
||||
For higher-rate CPU capture, use a ring of three staging buffers: render, enqueue
|
||||
GPU copy, map asynchronously, and publish only after completion. If all staging
|
||||
buffers are occupied, skip that readback instead of stalling rendering.
|
||||
|
||||
Simulation/update and rendering use separate clocks. Slow asset downloads,
|
||||
JPEG encoding, LLM requests, and subscribers must never hold either loop.
|
||||
## Instrumentation and observed limits
|
||||
|
||||
### Frame publication and readback
|
||||
Hot paths record monotonic timestamps and fixed-cardinality counters only.
|
||||
World event drops, completed frames, missed deadlines, render errors, and last
|
||||
render duration are available without formatting or I/O on the loop threads.
|
||||
|
||||
The persistent viewport renders into a GPU texture. A GUI presents it without
|
||||
CPU readback. When a CPU image is requested, the render graph schedules a copy
|
||||
after that frame's render commands into the next free staging buffer. GPU queue
|
||||
ordering makes the copied frame coherent even when the render target is reused
|
||||
for the following frame.
|
||||
Observed limiting factors:
|
||||
|
||||
Use a ring of three staging buffers. Mapping and CPU consumption happen
|
||||
asynchronously; a buffer is reused only after its completion signal. If all
|
||||
three are busy, skip that readback rather than stall rendering. Publish only the
|
||||
latest completed immutable CPU frame, tagged with frame sequence, camera pose,
|
||||
and observation time. JPEG and LLM workers use that published frame and discard
|
||||
superseded work. The current synchronous Bevy screenshot plus `device.poll(Wait)`
|
||||
path is only a diagnostic bridge and must not remain in the game loop.
|
||||
|
||||
## Instrumentation
|
||||
|
||||
Hot paths record only monotonic start/end timestamps and emit fixed-size,
|
||||
fixed-cardinality timing signals with `try_send`. A bounded receiver owned by a
|
||||
dedicated profiling worker aggregates counts, totals, maxima, and percentile
|
||||
histograms and publishes observability records. A full queue drops profiling
|
||||
signals and increments a drop counter; it never backpressures grid or rendering.
|
||||
Formatting, serialization, journal I/O, and subscriber delivery remain outside
|
||||
the measured thread.
|
||||
|
||||
Required fixed phases are startup/config, client creation, renderer creation,
|
||||
login, grid readiness, event ingest, update tick, visibility, asset disk read,
|
||||
asset network fetch, texture decode, geometry derive, GPU change application,
|
||||
render submission, GPU completion, optional readback, and optional JPEG encode.
|
||||
- Initial scene/asset convergence, about 30 seconds, dominates readiness.
|
||||
- First GPU admission is about one second; it occurs before `READY`.
|
||||
- A cached full-scene refresh is 86 ms and is currently the closest operation to
|
||||
the 90 ms frame budget. Incremental per-entity GPU updates are the next useful
|
||||
optimization if denser or highly animated regions exceed that budget.
|
||||
- Synchronous readback costs roughly 31–36 ms per frame. A direct GUI path or
|
||||
asynchronous staging ring removes it from presentation.
|
||||
- Five referenced textures were unavailable from the test grid. Missing assets
|
||||
do not deadlock readiness; completeness remains explicit.
|
||||
- The successful live run used 280 visible prims, one avatar, 51 terrain
|
||||
patches, 427,253 render triangles, 4,096 event slots, and dropped zero events.
|
||||
|
||||
Reference in New Issue
Block a user