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",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "metacrate-game-loop"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"metacrate-grid-world",
|
||||||
|
"metacrate-rendering-wgpu",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "metacrate-grid-agent"
|
name = "metacrate-grid-agent"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
@@ -4620,6 +4628,7 @@ dependencies = [
|
|||||||
"libremetaverse-rendering-simple",
|
"libremetaverse-rendering-simple",
|
||||||
"libremetaverse-types",
|
"libremetaverse-types",
|
||||||
"mentra",
|
"mentra",
|
||||||
|
"metacrate-game-loop",
|
||||||
"metacrate-lsl-tools",
|
"metacrate-lsl-tools",
|
||||||
"metacrate-rendering-wgpu",
|
"metacrate-rendering-wgpu",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -4633,6 +4642,14 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "metacrate-grid-world"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"libremetaverse",
|
||||||
|
"libremetaverse-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "metacrate-lsl-tools"
|
name = "metacrate-lsl-tools"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ members = [
|
|||||||
"crates/libremetaverse-rendering-simple",
|
"crates/libremetaverse-rendering-simple",
|
||||||
"crates/libremetaverse-rendering-mesh-foundry",
|
"crates/libremetaverse-rendering-mesh-foundry",
|
||||||
"crates/metacrate-lsl-tools",
|
"crates/metacrate-lsl-tools",
|
||||||
|
"crates/metacrate-game-loop",
|
||||||
|
"crates/metacrate-grid-world",
|
||||||
"crates/metacrate-rendering-wgpu",
|
"crates/metacrate-rendering-wgpu",
|
||||||
"crates/libremetaverse-rlv",
|
"crates/libremetaverse-rlv",
|
||||||
"crates/libremetaverse-utilities",
|
"crates/libremetaverse-utilities",
|
||||||
@@ -37,6 +39,8 @@ default-members = [
|
|||||||
"crates/libremetaverse-rendering-simple",
|
"crates/libremetaverse-rendering-simple",
|
||||||
"crates/libremetaverse-rendering-mesh-foundry",
|
"crates/libremetaverse-rendering-mesh-foundry",
|
||||||
"crates/metacrate-lsl-tools",
|
"crates/metacrate-lsl-tools",
|
||||||
|
"crates/metacrate-game-loop",
|
||||||
|
"crates/metacrate-grid-world",
|
||||||
"crates/metacrate-rendering-wgpu",
|
"crates/metacrate-rendering-wgpu",
|
||||||
"crates/libremetaverse-rlv",
|
"crates/libremetaverse-rlv",
|
||||||
"crates/libremetaverse-utilities",
|
"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-rendering-simple = { version = "0.0.1", path = "../libremetaverse-rendering-simple", optional = true }
|
||||||
libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" }
|
libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" }
|
||||||
metacrate-lsl-tools = { version = "0.0.1", path = "../metacrate-lsl-tools" }
|
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" }
|
metacrate-rendering-wgpu = { version = "0.0.1", path = "../metacrate-rendering-wgpu" }
|
||||||
mentra = { version = "0.18.3", default-features = false }
|
mentra = { version = "0.18.3", default-features = false }
|
||||||
jpeg-encoder = "0.6.1"
|
jpeg-encoder = "0.6.1"
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ pub struct LibremetaverseClientOwner {
|
|||||||
pub struct LibremetaverseWorldSnapshotSource {
|
pub struct LibremetaverseWorldSnapshotSource {
|
||||||
client: libremetaverse::GridClient,
|
client: libremetaverse::GridClient,
|
||||||
agent: Arc<libremetaverse::AgentManager>,
|
agent: Arc<libremetaverse::AgentManager>,
|
||||||
|
world: metacrate_game_loop::WorldReader,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "live-grid")]
|
#[cfg(feature = "live-grid")]
|
||||||
@@ -143,6 +144,7 @@ pub struct LibremetaverseSessionBackend {
|
|||||||
interaction: Option<crate::interaction::InteractionIngress>,
|
interaction: Option<crate::interaction::InteractionIngress>,
|
||||||
perception: Option<crate::perception::PerceptionIngress>,
|
perception: Option<crate::perception::PerceptionIngress>,
|
||||||
behavior: Option<crate::behavior::BehaviorIngress>,
|
behavior: Option<crate::behavior::BehaviorIngress>,
|
||||||
|
framework_readiness: Option<Arc<crate::session::FrameworkReadiness>>,
|
||||||
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +321,7 @@ impl LibremetaverseClientOwner {
|
|||||||
interaction,
|
interaction,
|
||||||
perception,
|
perception,
|
||||||
behavior,
|
behavior,
|
||||||
|
framework_readiness: None,
|
||||||
delivery_generation: Arc::clone(&self.delivery_generation),
|
delivery_generation: Arc::clone(&self.delivery_generation),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -332,10 +335,14 @@ impl LibremetaverseClientOwner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn world_snapshot_source(&self) -> LibremetaverseWorldSnapshotSource {
|
pub fn world_snapshot_source(
|
||||||
|
&self,
|
||||||
|
world: metacrate_game_loop::WorldReader,
|
||||||
|
) -> LibremetaverseWorldSnapshotSource {
|
||||||
LibremetaverseWorldSnapshotSource {
|
LibremetaverseWorldSnapshotSource {
|
||||||
client: self.client.clone(),
|
client: self.client.clone(),
|
||||||
agent: Arc::clone(&self.agent),
|
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")]
|
#[cfg(feature = "live-grid")]
|
||||||
fn metacrate_cache_dir() -> Result<std::path::PathBuf, BackendError> {
|
fn metacrate_cache_dir() -> Result<std::path::PathBuf, BackendError> {
|
||||||
directories::ProjectDirs::from("", "", "metacrate")
|
directories::ProjectDirs::from("", "", "metacrate")
|
||||||
@@ -362,10 +381,9 @@ fn metacrate_cache_dir() -> Result<std::path::PathBuf, BackendError> {
|
|||||||
impl LibremetaverseWorldSnapshotSource {
|
impl LibremetaverseWorldSnapshotSource {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn current_region_handle(&self) -> Option<u64> {
|
pub fn current_region_handle(&self) -> Option<u64> {
|
||||||
self.client
|
self.world
|
||||||
.network()
|
.latest()
|
||||||
.native_current_sim()
|
.and_then(|world| world.value.region.as_ref().map(|region| region.handle))
|
||||||
.map(|simulator| simulator.handle)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One atomic projection keeps native cache reads together. Terrain lookup
|
// One atomic projection keeps native cache reads together. Terrain lookup
|
||||||
@@ -386,19 +404,24 @@ impl LibremetaverseWorldSnapshotSource {
|
|||||||
{
|
{
|
||||||
return Err(crate::perception::PerceptionError::NotReady);
|
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 agent_position = self.agent.sim_position();
|
||||||
let position = crate::perception::WorldPosition {
|
let position = crate::perception::WorldPosition {
|
||||||
x: f64::from(agent_position.x),
|
x: f64::from(agent_position.x),
|
||||||
y: f64::from(agent_position.y),
|
y: f64::from(agent_position.y),
|
||||||
z: f64::from(agent_position.z),
|
z: f64::from(agent_position.z),
|
||||||
};
|
};
|
||||||
let avatar_cache = simulator
|
let avatars_truncated = world.value.avatars.len() > 2_048;
|
||||||
.objects_avatars
|
|
||||||
.read()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
let avatars_truncated = avatar_cache.len() > 2_048;
|
|
||||||
let mut avatars = BTreeMap::new();
|
let mut avatars = BTreeMap::new();
|
||||||
for (local_id, avatar) in avatar_cache
|
for (local_id, avatar) in world
|
||||||
|
.value
|
||||||
|
.avatars
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, avatar)| avatar.id != UUID::zero())
|
.filter(|(_, avatar)| avatar.id != UUID::zero())
|
||||||
{
|
{
|
||||||
@@ -414,15 +437,12 @@ impl LibremetaverseWorldSnapshotSource {
|
|||||||
avatars.pop_last();
|
avatars.pop_last();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop(avatar_cache);
|
|
||||||
let avatars = avatars.into_values().collect();
|
let avatars = avatars.into_values().collect();
|
||||||
let object_cache = simulator
|
let objects_truncated = world.value.primitives.len() > 2_048;
|
||||||
.objects_primitives
|
|
||||||
.read()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
let objects_truncated = object_cache.len() > 2_048;
|
|
||||||
let mut objects = BTreeMap::new();
|
let mut objects = BTreeMap::new();
|
||||||
for (local_id, primitive) in object_cache
|
for (local_id, primitive) in world
|
||||||
|
.value
|
||||||
|
.primitives
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, primitive)| primitive.id != UUID::zero())
|
.filter(|(_, primitive)| primitive.id != UUID::zero())
|
||||||
{
|
{
|
||||||
@@ -452,7 +472,6 @@ impl LibremetaverseWorldSnapshotSource {
|
|||||||
objects.pop_last();
|
objects.pop_last();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop(object_cache);
|
|
||||||
let objects = objects.into_values().collect();
|
let objects = objects.into_values().collect();
|
||||||
let parcel = simulator
|
let parcel = simulator
|
||||||
.parcels
|
.parcels
|
||||||
@@ -500,7 +519,7 @@ impl LibremetaverseWorldSnapshotSource {
|
|||||||
let (inventory, landmarks, inventory_truncated) = native_inventory_metadata(&self.client);
|
let (inventory, landmarks, inventory_truncated) = native_inventory_metadata(&self.client);
|
||||||
Ok(crate::perception::WorldSnapshot {
|
Ok(crate::perception::WorldSnapshot {
|
||||||
generation,
|
generation,
|
||||||
observed_unix_millis: crate::perception::unix_millis_now(),
|
observed_unix_millis: world.value.observed_unix_millis,
|
||||||
region_id: simulator.region_id,
|
region_id: simulator.region_id,
|
||||||
region_name: crate::perception::sanitize_untrusted(&simulator.name),
|
region_name: crate::perception::sanitize_untrusted(&simulator.name),
|
||||||
agent: crate::perception::AgentSnapshot {
|
agent: crate::perception::AgentSnapshot {
|
||||||
@@ -1029,6 +1048,8 @@ struct LibremetaverseSession {
|
|||||||
interaction: Option<crate::interaction::InteractionIngress>,
|
interaction: Option<crate::interaction::InteractionIngress>,
|
||||||
perception: Option<crate::perception::PerceptionIngress>,
|
perception: Option<crate::perception::PerceptionIngress>,
|
||||||
behavior: Option<crate::behavior::BehaviorIngress>,
|
behavior: Option<crate::behavior::BehaviorIngress>,
|
||||||
|
agent: Arc<libremetaverse::AgentManager>,
|
||||||
|
framework_readiness: Option<Arc<crate::session::FrameworkReadiness>>,
|
||||||
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,7 +1064,7 @@ impl crate::session::GridSession for LibremetaverseSession {
|
|||||||
cancellation: CancellationToken,
|
cancellation: CancellationToken,
|
||||||
) -> crate::session::SessionFuture<'_, crate::session::SessionSignal> {
|
) -> crate::session::SessionFuture<'_, crate::session::SessionSignal> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
tokio::select! {
|
let signal = tokio::select! {
|
||||||
signal = self.signals.recv() => signal.unwrap_or_else(|| {
|
signal = self.signals.recv() => signal.unwrap_or_else(|| {
|
||||||
crate::session::SessionSignal::Disconnected(
|
crate::session::SessionSignal::Disconnected(
|
||||||
crate::session::SessionFailure::new(
|
crate::session::SessionFailure::new(
|
||||||
@@ -1056,7 +1077,36 @@ impl crate::session::GridSession for LibremetaverseSession {
|
|||||||
crate::session::SessionFailureKind::TransientTransport,
|
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_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 ready_once = Arc::new(AtomicBool::new(false));
|
||||||
let callback_ready = Arc::clone(&ready_once);
|
let callback_ready = Arc::clone(&ready_once);
|
||||||
let ready = self
|
let ready = self
|
||||||
.network
|
.network
|
||||||
.native_subscribe_event_queue_running(Arc::new(move |_| {
|
.native_subscribe_event_queue_running(Arc::new(move |_| {
|
||||||
if !callback_ready.swap(true, Ordering::AcqRel) {
|
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);
|
let _ = ready_sender.try_send(crate::session::SessionSignal::Ready);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -1299,23 +1327,6 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
&& !ready_once.swap(true, Ordering::AcqRel)
|
&& !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 _ = sender.try_send(crate::session::SessionSignal::Ready);
|
||||||
}
|
}
|
||||||
let session: Box<dyn crate::session::GridSession> = Box::new(LibremetaverseSession {
|
let session: Box<dyn crate::session::GridSession> = Box::new(LibremetaverseSession {
|
||||||
@@ -1326,6 +1337,8 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
|||||||
interaction: self.interaction.clone(),
|
interaction: self.interaction.clone(),
|
||||||
perception: self.perception.clone(),
|
perception: self.perception.clone(),
|
||||||
behavior: self.behavior.clone(),
|
behavior: self.behavior.clone(),
|
||||||
|
agent: Arc::clone(&self.agent),
|
||||||
|
framework_readiness: self.framework_readiness.clone(),
|
||||||
delivery_generation: Arc::clone(&self.delivery_generation),
|
delivery_generation: Arc::clone(&self.delivery_generation),
|
||||||
});
|
});
|
||||||
Ok(session)
|
Ok(session)
|
||||||
|
|||||||
@@ -164,9 +164,9 @@ pub use script_delivery::{
|
|||||||
};
|
};
|
||||||
pub use service::{AgentService, ServiceError, ServiceHandle, ServiceState};
|
pub use service::{AgentService, ServiceError, ServiceHandle, ServiceState};
|
||||||
pub use session::{
|
pub use session::{
|
||||||
GridSession, GridSessionBackend, ReconnectPolicy, SessionControl, SessionFailure,
|
FrameworkReadiness, GridSession, GridSessionBackend, ReconnectPolicy, SessionControl,
|
||||||
SessionFailureKind, SessionFuture, SessionObservation, SessionReason, SessionSignal,
|
SessionFailure, SessionFailureKind, SessionFuture, SessionObservation, SessionReason,
|
||||||
SessionState, SessionStatus, SessionSupervisor, SessionSupervisorError,
|
SessionSignal, SessionState, SessionStatus, SessionSupervisor, SessionSupervisorError,
|
||||||
SessionSupervisorHandle, SessionWork, WorkDisposition, WorkKind,
|
SessionSupervisorHandle, SessionWork, WorkDisposition, WorkKind,
|
||||||
};
|
};
|
||||||
pub use tool_loop::{ToolExecution, ToolExecutor, ToolFuture, ToolLoopError, ToolLoopLimits};
|
pub use tool_loop::{ToolExecution, ToolExecutor, ToolFuture, ToolLoopError, ToolLoopLimits};
|
||||||
|
|||||||
@@ -273,9 +273,9 @@ async fn run_live(
|
|||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
use metacrate_grid_agent::{
|
use metacrate_grid_agent::{
|
||||||
AgentControlTarget, BehaviorObservation, ControlEventKind, ControlPlane, ControlTarget,
|
AgentControlTarget, BehaviorObservation, ControlEventKind, ControlPlane, ControlTarget,
|
||||||
GridSessionBackend, LibremetaverseClientOwner, OperatingMode, RuntimeControlCommand,
|
FrameworkReadiness, GridSessionBackend, LibremetaverseClientOwner, OperatingMode,
|
||||||
SessionControl, SessionObservation, SessionState, SessionSupervisor, TcpControlConfig,
|
RuntimeControlCommand, SessionControl, SessionObservation, SessionReason, SessionState,
|
||||||
TcpControlServer, WorldSnapshotSource,
|
SessionSupervisor, TcpControlConfig, TcpControlServer, WorldSnapshotSource,
|
||||||
};
|
};
|
||||||
|
|
||||||
let connection = config.grid.clone().ok_or_else(|| {
|
let connection = config.grid.clone().ok_or_else(|| {
|
||||||
@@ -285,15 +285,20 @@ async fn run_live(
|
|||||||
config.vision.asset_cache_max_bytes,
|
config.vision.asset_cache_max_bytes,
|
||||||
)?;
|
)?;
|
||||||
let mut live = start_live_interactions(&config, &owner).await?;
|
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(
|
let backend = match owner.session_backend_with_agent_services(
|
||||||
connection,
|
connection,
|
||||||
live.interaction.ingress(),
|
live.interaction.ingress(),
|
||||||
live.perception.clone(),
|
live.perception.clone(),
|
||||||
live.behavior.ingress(),
|
live.behavior.ingress(),
|
||||||
) {
|
) {
|
||||||
Ok(backend) => backend,
|
Ok(backend) => backend.with_framework_readiness(Arc::clone(&framework_readiness)),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
live.vision.stop_viewport().await;
|
||||||
live.scene_source.stop_prefetch().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.interaction.shutdown().await?;
|
||||||
live.behavior.shutdown().await?;
|
live.behavior.shutdown().await?;
|
||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
@@ -313,6 +318,26 @@ async fn run_live(
|
|||||||
let readiness = tokio::time::timeout(config.timeouts.startup, async {
|
let readiness = tokio::time::timeout(config.timeouts.startup, async {
|
||||||
loop {
|
loop {
|
||||||
match handle.next_observation().await {
|
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, .. })
|
Some(event @ SessionObservation::Transition { status, .. })
|
||||||
if status.agent_ready =>
|
if status.agent_ready =>
|
||||||
{
|
{
|
||||||
@@ -351,7 +376,11 @@ async fn run_live(
|
|||||||
Err(_) => Err(CliError("timed out waiting for full grid readiness".into())),
|
Err(_) => Err(CliError("timed out waiting for full grid readiness".into())),
|
||||||
};
|
};
|
||||||
if let Err(error) = readiness {
|
if let Err(error) = readiness {
|
||||||
|
live.vision.stop_viewport().await;
|
||||||
live.scene_source.stop_prefetch().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 session_result = handle.shutdown().await;
|
||||||
let interaction_result = live.interaction.shutdown().await;
|
let interaction_result = live.interaction.shutdown().await;
|
||||||
let behavior_result = live.behavior.shutdown().await;
|
let behavior_result = live.behavior.shutdown().await;
|
||||||
@@ -360,7 +389,11 @@ async fn run_live(
|
|||||||
behavior_result?;
|
behavior_result?;
|
||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
|
live.vision.stop_viewport().await;
|
||||||
live.scene_source.stop_prefetch().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 session_result = handle.shutdown().await;
|
||||||
let interaction_result = live.interaction.shutdown().await;
|
let interaction_result = live.interaction.shutdown().await;
|
||||||
let behavior_result = live.behavior.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 {
|
if let SessionObservation::Transition { status, reason, retry_in } = event {
|
||||||
live.vision.set_generation(status.generation);
|
live.vision.set_generation(status.generation);
|
||||||
control_target.update_session(status);
|
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
|
if status.agent_ready
|
||||||
&& let Ok(snapshot) = live
|
&& let Ok(snapshot) = live
|
||||||
.world
|
.world
|
||||||
@@ -485,6 +528,34 @@ async fn run_live(
|
|||||||
status.transport_connected,
|
status.transport_connected,
|
||||||
status.agent_ready,
|
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() => {
|
event = live.interaction.next_observation() => {
|
||||||
@@ -567,7 +638,11 @@ async fn run_live(
|
|||||||
let _ = live.observability.record(shutdown_event);
|
let _ = live.observability.record(shutdown_event);
|
||||||
control_target.mark_stopping();
|
control_target.mark_stopping();
|
||||||
live.landmark_roaming.shutdown().await;
|
live.landmark_roaming.shutdown().await;
|
||||||
|
live.vision.stop_viewport().await;
|
||||||
live.scene_source.stop_prefetch().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 session_result = handle.shutdown().await;
|
||||||
let interaction_result = live.interaction.shutdown().await;
|
let interaction_result = live.interaction.shutdown().await;
|
||||||
let behavior_result = live.behavior.shutdown().await;
|
let behavior_result = live.behavior.shutdown().await;
|
||||||
@@ -590,6 +665,55 @@ async fn run_live(
|
|||||||
Ok(())
|
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")]
|
#[cfg(feature = "live-grid")]
|
||||||
struct LiveInteractions {
|
struct LiveInteractions {
|
||||||
interaction: metacrate_grid_agent::InteractionHandle,
|
interaction: metacrate_grid_agent::InteractionHandle,
|
||||||
@@ -608,6 +732,8 @@ struct LiveInteractions {
|
|||||||
Arc<metacrate_grid_agent::VisionService<metacrate_grid_agent::LibremetaverseSceneSource>>,
|
Arc<metacrate_grid_agent::VisionService<metacrate_grid_agent::LibremetaverseSceneSource>>,
|
||||||
scene_source: Arc<metacrate_grid_agent::LibremetaverseSceneSource>,
|
scene_source: Arc<metacrate_grid_agent::LibremetaverseSceneSource>,
|
||||||
world: Arc<metacrate_grid_agent::LibremetaverseWorldSnapshotSource>,
|
world: Arc<metacrate_grid_agent::LibremetaverseWorldSnapshotSource>,
|
||||||
|
_world_events: metacrate_game_loop::LibreMetaverseWorldEvents,
|
||||||
|
world_loop: Option<metacrate_game_loop::LoopHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "live-grid")]
|
#[cfg(feature = "live-grid")]
|
||||||
@@ -630,7 +756,25 @@ async fn start_live_interactions(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let conversation = Arc::new(ConversationStore::from_config(config)?);
|
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(
|
let perception = Arc::new(PerceptionBackend::new(
|
||||||
world.clone(),
|
world.clone(),
|
||||||
Arc::clone(&conversation),
|
Arc::clone(&conversation),
|
||||||
@@ -752,7 +896,7 @@ async fn start_live_interactions(
|
|||||||
let vision =
|
let vision =
|
||||||
Arc::new(VisionService::new(Arc::clone(&scene_source), vision_limits)?.prefer_gpu());
|
Arc::new(VisionService::new(Arc::clone(&scene_source), vision_limits)?.prefer_gpu());
|
||||||
if !vision.initialize_renderer().await {
|
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 responder = Arc::new(VisionAugmentedResponder::new(vision.clone(), responder));
|
||||||
let sink = Arc::new(owner.interaction_sink());
|
let sink = Arc::new(owner.interaction_sink());
|
||||||
@@ -785,6 +929,8 @@ async fn start_live_interactions(
|
|||||||
vision,
|
vision,
|
||||||
scene_source,
|
scene_source,
|
||||||
world,
|
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::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot, watch};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tokio::time::Instant;
|
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>>;
|
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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
pub enum SessionState {
|
pub enum SessionState {
|
||||||
@@ -215,7 +248,7 @@ impl Default for ReconnectPolicy {
|
|||||||
Self {
|
Self {
|
||||||
initial_delay: Duration::from_secs(1),
|
initial_delay: Duration::from_secs(1),
|
||||||
maximum_delay: Duration::from_mins(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),
|
stable_reset_after: Duration::from_mins(2),
|
||||||
shutdown_deadline: Duration::from_secs(10),
|
shutdown_deadline: Duration::from_secs(10),
|
||||||
jitter_basis_points: 2_000,
|
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);
|
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)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct SceneSnapshot {
|
pub struct SceneSnapshot {
|
||||||
pub generation: u64,
|
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 observed_unix_millis: u64,
|
||||||
pub region_id: UUID,
|
pub region_id: UUID,
|
||||||
pub region_name: String,
|
pub region_name: String,
|
||||||
pub camera: CameraPose,
|
pub camera: CameraPose,
|
||||||
pub entities: Vec<SceneEntity>,
|
pub entities: Arc<[SceneEntity]>,
|
||||||
pub textures: Vec<SceneTexture>,
|
pub textures: Arc<[SceneTexture]>,
|
||||||
pub completeness: SnapshotCompleteness,
|
pub completeness: SnapshotCompleteness,
|
||||||
/// Work already performed by the scene source; validated again before rendering.
|
/// Work already performed by the scene source; validated again before rendering.
|
||||||
pub texture_fetches: usize,
|
pub texture_fetches: usize,
|
||||||
@@ -235,7 +237,9 @@ pub struct VisionService<S: SceneSource> {
|
|||||||
#[cfg(feature = "live-grid")]
|
#[cfg(feature = "live-grid")]
|
||||||
gpu_preferred: bool,
|
gpu_preferred: bool,
|
||||||
#[cfg(feature = "live-grid")]
|
#[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> {
|
impl<S: SceneSource> VisionService<S> {
|
||||||
@@ -258,6 +262,8 @@ impl<S: SceneSource> VisionService<S> {
|
|||||||
gpu_preferred: false,
|
gpu_preferred: false,
|
||||||
#[cfg(feature = "live-grid")]
|
#[cfg(feature = "live-grid")]
|
||||||
gpu: tokio::sync::OnceCell::new(),
|
gpu: tokio::sync::OnceCell::new(),
|
||||||
|
#[cfg(feature = "live-grid")]
|
||||||
|
viewport_task: Mutex::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/// Prefer cached offscreen `wgpu` rendering, falling back to the
|
/// Prefer cached offscreen `wgpu` rendering, falling back to the
|
||||||
@@ -276,7 +282,7 @@ impl<S: SceneSource> VisionService<S> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "live-grid")]
|
#[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
|
self.gpu
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
tokio::task::spawn_blocking(bevy_renderer_new)
|
tokio::task::spawn_blocking(bevy_renderer_new)
|
||||||
@@ -288,6 +294,85 @@ impl<S: SceneSource> VisionService<S> {
|
|||||||
.await
|
.await
|
||||||
.clone()
|
.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) {
|
pub fn set_generation(&self, generation: u64) {
|
||||||
if self.current_generation.swap(generation, Ordering::AcqRel) != generation {
|
if self.current_generation.swap(generation, Ordering::AcqRel) != generation {
|
||||||
self.cancel_active();
|
self.cancel_active();
|
||||||
@@ -429,6 +514,33 @@ impl<S: SceneSource> VisionService<S> {
|
|||||||
if self.gpu_preferred
|
if self.gpu_preferred
|
||||||
&& let Some(renderer) = self.renderer().await
|
&& 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
|
let mut renderables = scene
|
||||||
.entities
|
.entities
|
||||||
.iter()
|
.iter()
|
||||||
@@ -441,20 +553,17 @@ impl<S: SceneSource> VisionService<S> {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
renderables.sort_by(|left, right| left.0.cmp(&right.0));
|
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()
|
.into_iter()
|
||||||
.map(|(_, renderable)| renderable)
|
.map(|(_, renderable)| renderable)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>()
|
||||||
let gpu_scene = scene.clone();
|
.into(),
|
||||||
let limits = self.limits;
|
textures: Arc::clone(&scene.textures),
|
||||||
if let Ok(Ok(capture)) = tokio::task::spawn_blocking(move || {
|
background_srgb: environment_color(scene),
|
||||||
renderer
|
limits: metacrate_rendering_wgpu::RenderLimits {
|
||||||
.render(
|
|
||||||
gpu_scene.camera,
|
|
||||||
&renderables,
|
|
||||||
&gpu_scene.textures,
|
|
||||||
environment_color(&gpu_scene),
|
|
||||||
metacrate_rendering_wgpu::RenderLimits {
|
|
||||||
width: limits.width,
|
width: limits.width,
|
||||||
height: limits.height,
|
height: limits.height,
|
||||||
max_triangles: limits.max_triangles,
|
max_triangles: limits.max_triangles,
|
||||||
@@ -462,32 +571,7 @@ impl<S: SceneSource> VisionService<S> {
|
|||||||
max_texture_pixels: limits.max_decode_pixels,
|
max_texture_pixels: limits.max_decode_pixels,
|
||||||
far_distance: 512,
|
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
|
/// 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);
|
return Err(VisionError::ResourceLimit);
|
||||||
}
|
}
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
for entity in &scene.entities {
|
for entity in scene.entities.iter() {
|
||||||
count = count
|
count = count
|
||||||
.checked_add(entity.triangles.len())
|
.checked_add(entity.triangles.len())
|
||||||
.ok_or(VisionError::ResourceLimit)?;
|
.ok_or(VisionError::ResourceLimit)?;
|
||||||
@@ -965,8 +1049,11 @@ pub struct LibremetaverseSceneSource {
|
|||||||
agent: Arc<libremetaverse::AgentManager>,
|
agent: Arc<libremetaverse::AgentManager>,
|
||||||
limits: VisionLimits,
|
limits: VisionLimits,
|
||||||
cache: Arc<Mutex<SceneCache>>,
|
cache: Arc<Mutex<SceneCache>>,
|
||||||
|
latest_scene: metacrate_game_loop::Latest<SceneSnapshot>,
|
||||||
|
scene_build: tokio::sync::Mutex<()>,
|
||||||
prefetch_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
prefetch_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||||
prefetch_passes: AtomicU64,
|
prefetch_passes: AtomicU64,
|
||||||
|
scene_sequence: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "live-grid")]
|
#[cfg(feature = "live-grid")]
|
||||||
@@ -1013,8 +1100,11 @@ impl LibremetaverseSceneSource {
|
|||||||
agent: owner.agent(),
|
agent: owner.agent(),
|
||||||
limits,
|
limits,
|
||||||
cache: Arc::new(Mutex::new(SceneCache::default())),
|
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_task: Mutex::new(None),
|
||||||
prefetch_passes: AtomicU64::new(0),
|
prefetch_passes: AtomicU64::new(0),
|
||||||
|
scene_sequence: AtomicU64::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,11 +1121,15 @@ impl LibremetaverseSceneSource {
|
|||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
if source.client.network().current_sim().is_some()
|
if source.client.network().current_sim().is_some()
|
||||||
&& source
|
&& let Ok(scene) = source.capture_scene(0, CancellationToken::default()).await
|
||||||
.capture_scene(0, CancellationToken::default())
|
|
||||||
.await
|
|
||||||
.is_ok()
|
|
||||||
{
|
{
|
||||||
|
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);
|
source.prefetch_passes.fetch_add(1, Ordering::AcqRel);
|
||||||
}
|
}
|
||||||
drop(source);
|
drop(source);
|
||||||
@@ -1057,6 +1151,40 @@ impl LibremetaverseSceneSource {
|
|||||||
pub fn prefetch_passes(&self) -> u64 {
|
pub fn prefetch_passes(&self) -> u64 {
|
||||||
self.prefetch_passes.load(Ordering::Acquire)
|
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")]
|
#[cfg(feature = "live-grid")]
|
||||||
@@ -1124,6 +1252,13 @@ impl SceneSource for LibremetaverseSceneSource {
|
|||||||
.network()
|
.network()
|
||||||
.current_sim()
|
.current_sim()
|
||||||
.ok_or(VisionError::StaleGeneration)?;
|
.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);
|
let scene_deadline = Instant::now() + Duration::from_secs(5);
|
||||||
loop {
|
loop {
|
||||||
let terrain_ready = simulator
|
let terrain_ready = simulator
|
||||||
@@ -1624,11 +1759,8 @@ impl SceneSource for LibremetaverseSceneSource {
|
|||||||
.unwrap_or(u32::MAX);
|
.unwrap_or(u32::MAX);
|
||||||
Ok(SceneSnapshot {
|
Ok(SceneSnapshot {
|
||||||
generation,
|
generation,
|
||||||
observed_unix_millis: std::time::SystemTime::now()
|
scene_sequence: self.scene_sequence.fetch_add(1, Ordering::AcqRel) + 1,
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
observed_unix_millis: unix_millis_now(),
|
||||||
.map_or(0, |duration| {
|
|
||||||
duration.as_millis().try_into().unwrap_or(u64::MAX)
|
|
||||||
}),
|
|
||||||
region_id,
|
region_id,
|
||||||
region_name,
|
region_name,
|
||||||
camera: CameraPose {
|
camera: CameraPose {
|
||||||
@@ -1637,8 +1769,8 @@ impl SceneSource for LibremetaverseSceneSource {
|
|||||||
up: [up.x, up.y, up.z],
|
up: [up.x, up.y, up.z],
|
||||||
vertical_fov_degrees: camera.vertical_fov_angle().to_degrees(),
|
vertical_fov_degrees: camera.vertical_fov_angle().to_degrees(),
|
||||||
},
|
},
|
||||||
entities,
|
entities: entities.into(),
|
||||||
textures,
|
textures: textures.into(),
|
||||||
completeness: SnapshotCompleteness {
|
completeness: SnapshotCompleteness {
|
||||||
objects_truncated: objects_truncated || geometry_truncated,
|
objects_truncated: objects_truncated || geometry_truncated,
|
||||||
avatars_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")]
|
#[cfg(feature = "live-grid")]
|
||||||
pub(crate) fn native_camera_forward(
|
pub(crate) fn native_camera_forward(
|
||||||
camera: &libremetaverse::AgentManagerAgentMovementAgentCamera,
|
camera: &libremetaverse::AgentManagerAgentMovementAgentCamera,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use libremetaverse_types::{
|
|||||||
compat::{CancellationToken, CancellationTokenSource},
|
compat::{CancellationToken, CancellationTokenSource},
|
||||||
};
|
};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
struct FakeScene {
|
struct FakeScene {
|
||||||
scene: SceneSnapshot,
|
scene: SceneSnapshot,
|
||||||
@@ -363,6 +364,7 @@ fn scene_visibility_uses_configured_distance_and_avatar_view() {
|
|||||||
fn scene() -> SceneSnapshot {
|
fn scene() -> SceneSnapshot {
|
||||||
SceneSnapshot {
|
SceneSnapshot {
|
||||||
generation: 7,
|
generation: 7,
|
||||||
|
scene_sequence: 1,
|
||||||
observed_unix_millis: 1_700_000_000_000,
|
observed_unix_millis: 1_700_000_000_000,
|
||||||
region_id: uuid(1),
|
region_id: uuid(1),
|
||||||
region_name: "Fixture Region".into(),
|
region_name: "Fixture Region".into(),
|
||||||
@@ -389,8 +391,9 @@ fn scene() -> SceneSnapshot {
|
|||||||
renderables: Vec::new(),
|
renderables: Vec::new(),
|
||||||
texture_available: true,
|
texture_available: true,
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
textures: Vec::new(),
|
.into(),
|
||||||
|
textures: Vec::new().into(),
|
||||||
completeness: SnapshotCompleteness {
|
completeness: SnapshotCompleteness {
|
||||||
textures_missing: 1,
|
textures_missing: 1,
|
||||||
terrain_available: false,
|
terrain_available: false,
|
||||||
@@ -449,7 +452,7 @@ async fn golden_scene_is_deterministic_depth_ordered_and_privacy_marked() {
|
|||||||
#[cfg(feature = "live-grid")]
|
#[cfg(feature = "live-grid")]
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn offscreen_wgpu_renders_depth_ordered_scene_when_an_adapter_is_available() {
|
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;
|
return;
|
||||||
};
|
};
|
||||||
let limits = VisionLimits {
|
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)
|
let renderables = scene_renderable_from_triangles(&triangles)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let rgba = tokio::task::spawn_blocking(move || {
|
renderer.submit(metacrate_game_loop::ViewportScene {
|
||||||
renderer.render(
|
sequence: scene.scene_sequence,
|
||||||
scene.camera,
|
camera: scene.camera,
|
||||||
&renderables,
|
renderables: renderables.into(),
|
||||||
&scene.textures,
|
textures: Arc::clone(&scene.textures),
|
||||||
[72, 96, 120, 255],
|
background_srgb: [72, 96, 120, 255],
|
||||||
metacrate_rendering_wgpu::RenderLimits {
|
limits: metacrate_rendering_wgpu::RenderLimits {
|
||||||
width: limits.width,
|
width: limits.width,
|
||||||
height: limits.height,
|
height: limits.height,
|
||||||
max_triangles: limits.max_triangles,
|
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,
|
max_texture_pixels: limits.max_decode_pixels,
|
||||||
far_distance: 512,
|
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
|
.await
|
||||||
.expect("render worker")
|
.expect("fixed-rate viewport did not publish repeated frames");
|
||||||
.expect("offscreen render");
|
let rgba = renderer.latest().unwrap().value.rgba.clone();
|
||||||
|
assert_eq!(renderer.stats().render_errors, 0);
|
||||||
assert_eq!(rgba.len(), 64 * 64 * 4);
|
assert_eq!(rgba.len(), 64 * 64 * 4);
|
||||||
let center = (32 * 64 + 32) * 4;
|
let center = (32 * 64 + 32) * 4;
|
||||||
assert!(rgba[center + 1] > 180 && rgba[center] < 80 && rgba[center + 2] < 80);
|
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::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
const ALLOWED_DEPENDENCIES: [&str; 21] = [
|
const ALLOWED_DEPENDENCIES: [&str; 22] = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
@@ -10,6 +10,7 @@ const ALLOWED_DEPENDENCIES: [&str; 21] = [
|
|||||||
"libremetaverse",
|
"libremetaverse",
|
||||||
"libremetaverse-imaging",
|
"libremetaverse-imaging",
|
||||||
"libremetaverse-rendering-simple",
|
"libremetaverse-rendering-simple",
|
||||||
|
"metacrate-game-loop",
|
||||||
"metacrate-lsl-tools",
|
"metacrate-lsl-tools",
|
||||||
"metacrate-rendering-wgpu",
|
"metacrate-rendering-wgpu",
|
||||||
"mentra",
|
"mentra",
|
||||||
@@ -170,8 +171,6 @@ fn live_session_adapter_reuses_native_lifecycle_and_messaging_managers() {
|
|||||||
".subscribe_im(",
|
".subscribe_im(",
|
||||||
".chat(",
|
".chat(",
|
||||||
".instant_message_with_uuid_string(",
|
".instant_message_with_uuid_string(",
|
||||||
".objects_avatars",
|
|
||||||
".objects_primitives",
|
|
||||||
".parcels",
|
".parcels",
|
||||||
".environment()",
|
".environment()",
|
||||||
".inventory()",
|
".inventory()",
|
||||||
@@ -183,6 +182,15 @@ fn live_session_adapter_reuses_native_lifecycle_and_messaging_managers() {
|
|||||||
"missing native lifecycle {required}"
|
"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"));
|
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>> {
|
async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Error>> {
|
||||||
let environment = live_environment()?;
|
let environment = live_environment()?;
|
||||||
let renderer_owner = LibremetaverseClientOwner::new()?;
|
let renderer_owner = LibremetaverseClientOwner::new()?;
|
||||||
let (mut renderer_session, renderer_cancel) =
|
|
||||||
login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
|
|
||||||
let limits = VisionLimits {
|
let limits = VisionLimits {
|
||||||
minimum_interval: Duration::ZERO,
|
minimum_interval: Duration::ZERO,
|
||||||
..VisionLimits::default()
|
..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 scene_source = Arc::new(LibremetaverseSceneSource::new(&renderer_owner, limits));
|
||||||
let prefetch_started = Instant::now();
|
let prefetch_started = Instant::now();
|
||||||
scene_source.start_prefetch();
|
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
|
renderer_simulator.name, region_size.0, region_size.1
|
||||||
);
|
);
|
||||||
wait_scene_settled(&renderer_owner).await?;
|
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(
|
let view_forward = Vector3::mul_with_vector3_quaternion(
|
||||||
Vector3::unit_x(),
|
Vector3::unit_x(),
|
||||||
renderer_owner.agent().sim_rotation(),
|
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 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?;
|
profile_warm_capture(&scene_source, limits, renderer_cancel.token()).await?;
|
||||||
println!("LIVE_RENDER_OUTPUT={}", output.display());
|
println!("LIVE_RENDER_OUTPUT={}", output.display());
|
||||||
println!("LIVE_RENDER_SHA256={}", capture.image_sha256);
|
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;
|
.await;
|
||||||
|
|
||||||
scene_source.stop_prefetch().await;
|
scene_source.stop_prefetch().await;
|
||||||
|
world_loop
|
||||||
|
.shutdown()
|
||||||
|
.map_err(|_| "world loop thread panicked")?;
|
||||||
let _ = renderer_session.logout(renderer_cancel.token()).await;
|
let _ = renderer_session.logout(renderer_cancel.token()).await;
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)] // One ordered live timing record keeps phase boundaries explicit.
|
||||||
async fn profile_warm_capture(
|
async fn profile_warm_capture(
|
||||||
source: &LibremetaverseSceneSource,
|
source: &LibremetaverseSceneSource,
|
||||||
limits: VisionLimits,
|
limits: VisionLimits,
|
||||||
@@ -311,9 +376,10 @@ async fn profile_warm_capture(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(_, renderable)| renderable)
|
.map(|(_, renderable)| renderable)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
let renderables: Arc<[metacrate_rendering_wgpu::Renderable]> = renderables.into();
|
||||||
let preparation = preparation_started.elapsed();
|
let preparation = preparation_started.elapsed();
|
||||||
let renderer_started = Instant::now();
|
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 renderer_initialization = renderer_started.elapsed();
|
||||||
let render_limits = metacrate_rendering_wgpu::RenderLimits {
|
let render_limits = metacrate_rendering_wgpu::RenderLimits {
|
||||||
width: limits.width,
|
width: limits.width,
|
||||||
@@ -323,24 +389,38 @@ async fn profile_warm_capture(
|
|||||||
max_texture_pixels: limits.max_decode_pixels,
|
max_texture_pixels: limits.max_decode_pixels,
|
||||||
far_distance: 512,
|
far_distance: 512,
|
||||||
};
|
};
|
||||||
let first = renderer.render_profiled(
|
let first = renderer.render_scene(
|
||||||
|
scene.scene_sequence,
|
||||||
scene.camera,
|
scene.camera,
|
||||||
&renderables,
|
|| Arc::clone(&renderables),
|
||||||
&scene.textures,
|
Arc::clone(&scene.textures),
|
||||||
[125, 149, 173, 255],
|
[125, 149, 173, 255],
|
||||||
render_limits,
|
render_limits,
|
||||||
)?;
|
)?;
|
||||||
let warm_started = Instant::now();
|
let refresh_started = Instant::now();
|
||||||
let warm = renderer.render_profiled(
|
let refresh = renderer.render_scene(
|
||||||
|
scene.scene_sequence.saturating_add(1),
|
||||||
scene.camera,
|
scene.camera,
|
||||||
&renderables,
|
|| Arc::clone(&renderables),
|
||||||
&scene.textures,
|
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],
|
[125, 149, 173, 255],
|
||||||
render_limits,
|
render_limits,
|
||||||
)?;
|
)?;
|
||||||
let warm_render_total = warm_started.elapsed();
|
let warm_render_total = warm_started.elapsed();
|
||||||
let jpeg_started = Instant::now();
|
let jpeg_started = Instant::now();
|
||||||
let rgb = warm
|
let rgb = warm
|
||||||
|
.frame
|
||||||
|
.value
|
||||||
.rgba
|
.rgba
|
||||||
.chunks_exact(4)
|
.chunks_exact(4)
|
||||||
.flat_map(|pixel| pixel[..3].iter().copied())
|
.flat_map(|pixel| pixel[..3].iter().copied())
|
||||||
@@ -354,7 +434,7 @@ async fn profile_warm_capture(
|
|||||||
)?;
|
)?;
|
||||||
let jpeg_encode = jpeg_started.elapsed();
|
let jpeg_encode = jpeg_started.elapsed();
|
||||||
println!(
|
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_total.as_millis(),
|
||||||
scene.timings.terrain_wait.as_millis(),
|
scene.timings.terrain_wait.as_millis(),
|
||||||
scene
|
scene
|
||||||
@@ -368,11 +448,16 @@ async fn profile_warm_capture(
|
|||||||
renderer_initialization.as_millis(),
|
renderer_initialization.as_millis(),
|
||||||
render_total(&first.timings).as_millis(),
|
render_total(&first.timings).as_millis(),
|
||||||
first.timings,
|
first.timings,
|
||||||
|
refresh_total.as_millis(),
|
||||||
|
refresh.timings,
|
||||||
warm_render_total.as_millis(),
|
warm_render_total.as_millis(),
|
||||||
warm.timings,
|
warm.timings,
|
||||||
jpeg_encode.as_millis(),
|
jpeg_encode.as_millis(),
|
||||||
total_started.elapsed().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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,8 +485,7 @@ impl SceneSource for DiagnosticSceneSource {
|
|||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let mut scene = self.inner.capture_scene(generation, cancellation).await?;
|
let mut scene = self.inner.capture_scene(generation, cancellation).await?;
|
||||||
if let Some(material_override) = &self.material_override {
|
if let Some(material_override) = &self.material_override {
|
||||||
for renderable in scene
|
for renderable in Arc::make_mut(&mut scene.entities)
|
||||||
.entities
|
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.filter(|entity| entity.kind == SceneEntityKind::Object)
|
.filter(|entity| entity.kind == SceneEntityKind::Object)
|
||||||
.flat_map(|entity| &mut entity.renderables)
|
.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,
|
limits: RenderLimits,
|
||||||
reply: mpsc::SyncSender<Result<RenderedFrame, RenderError>>,
|
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,
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +115,32 @@ impl Renderer {
|
|||||||
limits,
|
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,
|
RenderCommand::Stop => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,6 +209,83 @@ impl Renderer {
|
|||||||
frame.timings.command_copy = command_copy;
|
frame.timings.command_copy = command_copy;
|
||||||
Ok(frame)
|
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 {
|
impl Drop for Renderer {
|
||||||
@@ -189,6 +305,9 @@ impl Drop for Renderer {
|
|||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
struct FrameEntity;
|
struct FrameEntity;
|
||||||
|
|
||||||
|
#[derive(Component)]
|
||||||
|
struct PersistentSceneEntity;
|
||||||
|
|
||||||
struct LegacyMaterialPlugin;
|
struct LegacyMaterialPlugin;
|
||||||
struct PbrMaterialPlugin;
|
struct PbrMaterialPlugin;
|
||||||
|
|
||||||
@@ -328,13 +447,14 @@ struct CachedGpuMesh {
|
|||||||
struct BevyBackend {
|
struct BevyBackend {
|
||||||
apps: SubApps,
|
apps: SubApps,
|
||||||
frame_entities: Vec<Entity>,
|
frame_entities: Vec<Entity>,
|
||||||
|
scene_entities: Vec<Entity>,
|
||||||
texture_handles: Vec<GpuTexture>,
|
texture_handles: Vec<GpuTexture>,
|
||||||
texture_cache: HashMap<u64, Vec<CachedGpuTexture>>,
|
texture_cache: HashMap<u64, Vec<CachedGpuTexture>>,
|
||||||
mesh_cache: HashMap<u64, Vec<CachedGpuMesh>>,
|
mesh_cache: HashMap<u64, Vec<CachedGpuMesh>>,
|
||||||
pbr_handles: Vec<Handle<PbrBevyMaterial>>,
|
pbr_handles: Vec<Handle<PbrBevyMaterial>>,
|
||||||
legacy_handles: Vec<Handle<LegacyBevyMaterial>>,
|
legacy_handles: Vec<Handle<LegacyBevyMaterial>>,
|
||||||
environment_map: EnvironmentMapLight,
|
environment_map: EnvironmentMapLight,
|
||||||
target: Option<Handle<Image>>,
|
target: Option<(u32, u32, Handle<Image>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BevyBackend {
|
impl BevyBackend {
|
||||||
@@ -381,6 +501,7 @@ impl BevyBackend {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
apps: std::mem::take(app.sub_apps_mut()),
|
apps: std::mem::take(app.sub_apps_mut()),
|
||||||
frame_entities: Vec::new(),
|
frame_entities: Vec::new(),
|
||||||
|
scene_entities: Vec::new(),
|
||||||
texture_handles: Vec::new(),
|
texture_handles: Vec::new(),
|
||||||
texture_cache: HashMap::new(),
|
texture_cache: HashMap::new(),
|
||||||
mesh_cache: HashMap::new(),
|
mesh_cache: HashMap::new(),
|
||||||
@@ -400,18 +521,28 @@ impl BevyBackend {
|
|||||||
background_srgb: [u8; 4],
|
background_srgb: [u8; 4],
|
||||||
limits: RenderLimits,
|
limits: RenderLimits,
|
||||||
) -> Result<RenderedFrame, RenderError> {
|
) -> 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();
|
let started = Instant::now();
|
||||||
self.clear_frame();
|
self.clear_scene();
|
||||||
let clear_previous_frame = started.elapsed();
|
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();
|
let started = Instant::now();
|
||||||
self.texture_handles = add_textures(
|
self.texture_handles = add_textures(
|
||||||
self.apps.main.world_mut(),
|
self.apps.main.world_mut(),
|
||||||
@@ -443,7 +574,7 @@ impl BevyBackend {
|
|||||||
.spawn((
|
.spawn((
|
||||||
Mesh3d(mesh.clone()),
|
Mesh3d(mesh.clone()),
|
||||||
MeshMaterial3d(material.clone()),
|
MeshMaterial3d(material.clone()),
|
||||||
FrameEntity,
|
PersistentSceneEntity,
|
||||||
))
|
))
|
||||||
.id();
|
.id();
|
||||||
self.legacy_handles.push(material);
|
self.legacy_handles.push(material);
|
||||||
@@ -463,15 +594,70 @@ impl BevyBackend {
|
|||||||
.spawn((
|
.spawn((
|
||||||
Mesh3d(mesh.clone()),
|
Mesh3d(mesh.clone()),
|
||||||
MeshMaterial3d(material.clone()),
|
MeshMaterial3d(material.clone()),
|
||||||
FrameEntity,
|
PersistentSceneEntity,
|
||||||
))
|
))
|
||||||
.id();
|
.id();
|
||||||
self.pbr_handles.push(material);
|
self.pbr_handles.push(material);
|
||||||
entity
|
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 target = self.new_target(limits.width, limits.height);
|
||||||
let transform = camera_transform(camera)?;
|
let transform = camera_transform(camera)?;
|
||||||
let camera_entity = self
|
let camera_entity = self
|
||||||
@@ -494,21 +680,6 @@ impl BevyBackend {
|
|||||||
))
|
))
|
||||||
.id();
|
.id();
|
||||||
self.frame_entities.push(camera_entity);
|
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 scene_setup = started.elapsed();
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let rgba = self.capture(&target)?;
|
let rgba = self.capture(&target)?;
|
||||||
@@ -516,8 +687,6 @@ impl BevyBackend {
|
|||||||
rgba,
|
rgba,
|
||||||
timings: RenderTimings {
|
timings: RenderTimings {
|
||||||
clear_previous_frame,
|
clear_previous_frame,
|
||||||
texture_cache_sync,
|
|
||||||
mesh_cache_sync,
|
|
||||||
scene_setup,
|
scene_setup,
|
||||||
capture: started.elapsed(),
|
capture: started.elapsed(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -526,7 +695,12 @@ impl BevyBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn new_target(&mut self, width: u32, height: u32) -> RenderTarget {
|
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
|
self.apps
|
||||||
.main
|
.main
|
||||||
.world_mut()
|
.world_mut()
|
||||||
@@ -550,7 +724,7 @@ impl BevyBackend {
|
|||||||
.world_mut()
|
.world_mut()
|
||||||
.resource_mut::<Assets<Image>>()
|
.resource_mut::<Assets<Image>>()
|
||||||
.add(image);
|
.add(image);
|
||||||
self.target = Some(handle.clone());
|
self.target = Some((width, height, handle.clone()));
|
||||||
handle.into()
|
handle.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,6 +774,13 @@ impl BevyBackend {
|
|||||||
for entity in self.frame_entities.drain(..) {
|
for entity in self.frame_entities.drain(..) {
|
||||||
let _ = self.apps.main.world_mut().despawn(entity);
|
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();
|
let world = self.apps.main.world_mut();
|
||||||
self.texture_handles.clear();
|
self.texture_handles.clear();
|
||||||
let mut materials = world.resource_mut::<Assets<PbrBevyMaterial>>();
|
let mut materials = world.resource_mut::<Assets<PbrBevyMaterial>>();
|
||||||
@@ -1231,6 +1412,36 @@ mod tests {
|
|||||||
.collect::<Vec<_>>()
|
.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 {
|
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
|
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
|
1024 m varregion, using the default 64 m view distance. The rendered scene had
|
||||||
211 objects, 427,829 triangles, and five unavailable grid textures. Release
|
211 objects, 427,253 triangles, and five unavailable grid textures. All timing
|
||||||
numbers are the performance baseline; debug timings are intentionally omitted.
|
numbers below are optimized release builds.
|
||||||
|
|
||||||
## Current capture path
|
## Runtime ownership
|
||||||
|
|
||||||
| Phase | Warm release time | Can run ahead? | Required change |
|
- `metacrate-grid-world` owns native OpenSim/Second Life event subscriptions,
|
||||||
|---|---:|---|---|
|
distance filtering, attachment world transforms, terse movement application,
|
||||||
| 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. |
|
event-drop accounting, and immutable published world snapshots.
|
||||||
| 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. |
|
- `metacrate-game-loop` owns the fixed update schedule, renderer lifetime,
|
||||||
| 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. |
|
persistent GPU scene, fixed-rate viewport loop, completed-frame buffers, and
|
||||||
| Collect and sort renderables | 1 ms | Yes | Persistent renderer entities remove this per-frame list build. |
|
timing signals. Readers receive an immutable `Arc<[u8]>`; they never read a
|
||||||
| Validate scene | 1 ms | Yes | Validate assets and changes when admitted, not every frame. |
|
render target while Bevy writes it.
|
||||||
| Copy render command | 21 ms | Yes | Send stable IDs and dirty updates; do not clone the complete scene for a frame. |
|
- `metacrate-rendering-wgpu` owns Bevy/wgpu resources and supports separate
|
||||||
| Re-identify cached textures by content hash | 30 ms | Yes | Key immutable texture resources by grid asset UUID. |
|
scene loading and camera/frame rendering. Shared immutable scene arrays cross
|
||||||
| Re-identify cached meshes by content hash | 14 ms | Yes | Key derived meshes by stable object/asset signature and LOD. |
|
the renderer thread without copying their contents.
|
||||||
| Recreate frame entities | <1 ms | Yes | Keep Bevy entities and material handles alive; update only dirty components. |
|
- `metacrate-grid-agent` supplies policy, conversation, tools, and lifecycle
|
||||||
| 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. |
|
triggers. It consumes published world/viewport state instead of synchronizing
|
||||||
| 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. |
|
render buffers or individual native event channels.
|
||||||
|
|
||||||
The existing end-to-end warm capture takes 525 ms. A repeated renderer call
|
Grid callbacks never perform asset I/O or render work. The world loop drains a
|
||||||
alone takes 113 ms (8.8 FPS), including 67 ms of avoidable copying, hashing,
|
bounded 4,096-event queue in batches, coalesces dirty IDs, and publishes only
|
||||||
and cache synchronization. Its measured render/readback segment is 46 ms
|
complete snapshots. Object, avatar, attachment, and terrain admission uses the
|
||||||
(21.9 FPS equivalent). A persistent GPU scene therefore makes the initial
|
configured view distance; 64 m is the default. The live reference contained
|
||||||
10 FPS viewport target realistic on the measured hardware.
|
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
|
## Readiness
|
||||||
timestamp. Continuous viewport rendering without readback should be faster and
|
|
||||||
must be measured separately once the persistent render target exists.
|
|
||||||
|
|
||||||
## 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 |
|
| Client owner initialization | 2 ms | Startup thread |
|
||||||
| Grid login | 3.37 s | Parallel with renderer initialization |
|
| Grid login | 3.37 s | Parallel with renderer initialization |
|
||||||
| wgpu/Bevy initialization | 452 ms | Parallel with grid login; completed before agent readiness |
|
| Bevy/wgpu initialization | 0.49 s | Completed before agent readiness |
|
||||||
| Test scene convergence window | 30.04 s | World events continue asynchronously; this is not a system-readiness gate. |
|
| Initial world/asset convergence | 30.05 s | Asynchronous Grid and asset workers |
|
||||||
| First full visible-scene build from persistent raw assets | 8.35 s | Background asset/scene workers; publish partial complete frames as content converges. |
|
| First persistent GPU scene and frame | 1.02 s | Before the readiness gate opens |
|
||||||
|
|
||||||
System readiness must require configuration, renderer initialization (or an
|
The slower first start is intentional: the agent does not claim readiness until
|
||||||
explicitly reported fallback), grid login, region dimensions, and the running
|
it can provide stable world and visual state. Raw immutable assets persist in
|
||||||
world/update loops. It must not wait until every user-created asset inside the
|
the configured 2 GiB LRU under the platform cache directory
|
||||||
view radius has decoded: that would turn missing or slow grid assets into a
|
(`$HOME/.cache/metacrate` on Linux), so later starts reuse them.
|
||||||
permanent login stall. Visual readiness is a separate completeness signal.
|
|
||||||
|
|
||||||
## 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
|
The current Bevy screenshot API still performs synchronous CPU readback, which
|
||||||
changes immediately. They never perform asset I/O or rendering.
|
accounts for most of the 31–36 ms warm frame cost. This meets the current
|
||||||
2. A fixed update loop drains those events, updates the authoritative CPU world,
|
headless-agent target, but a GUI viewer should present the GPU target directly.
|
||||||
recalculates 64 m visibility, and emits stable dirty IDs. Grid event handling
|
For higher-rate CPU capture, use a ring of three staging buffers: render, enqueue
|
||||||
must remain faster than the render tick.
|
GPU copy, map asynchronously, and publish only after completion. If all staging
|
||||||
3. Bounded asset workers fetch, decode, and derive only dirty visible content.
|
buffers are occupied, skip that readback instead of stalling rendering.
|
||||||
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.
|
|
||||||
|
|
||||||
Simulation/update and rendering use separate clocks. Slow asset downloads,
|
## Instrumentation and observed limits
|
||||||
JPEG encoding, LLM requests, and subscribers must never hold either loop.
|
|
||||||
|
|
||||||
### 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
|
Observed limiting factors:
|
||||||
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.
|
|
||||||
|
|
||||||
Use a ring of three staging buffers. Mapping and CPU consumption happen
|
- Initial scene/asset convergence, about 30 seconds, dominates readiness.
|
||||||
asynchronously; a buffer is reused only after its completion signal. If all
|
- First GPU admission is about one second; it occurs before `READY`.
|
||||||
three are busy, skip that readback rather than stall rendering. Publish only the
|
- A cached full-scene refresh is 86 ms and is currently the closest operation to
|
||||||
latest completed immutable CPU frame, tagged with frame sequence, camera pose,
|
the 90 ms frame budget. Incremental per-entity GPU updates are the next useful
|
||||||
and observation time. JPEG and LLM workers use that published frame and discard
|
optimization if denser or highly animated regions exceed that budget.
|
||||||
superseded work. The current synchronous Bevy screenshot plus `device.poll(Wait)`
|
- Synchronous readback costs roughly 31–36 ms per frame. A direct GUI path or
|
||||||
path is only a diagnostic bridge and must not remain in the game loop.
|
asynchronous staging ring removes it from presentation.
|
||||||
|
- Five referenced textures were unavailable from the test grid. Missing assets
|
||||||
## Instrumentation
|
do not deadlock readiness; completeness remains explicit.
|
||||||
|
- The successful live run used 280 visible prims, one avatar, 51 terrain
|
||||||
Hot paths record only monotonic start/end timestamps and emit fixed-size,
|
patches, 427,253 render triangles, 4,096 event slots, and dropped zero events.
|
||||||
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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user