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

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

View File

@@ -0,0 +1,16 @@
[package]
name = "metacrate-game-loop"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Stable OpenSim/Second Life update, rendering, and viewport orchestration"
publish = false
[dependencies]
metacrate-grid-world = { version = "0.0.1", path = "../metacrate-grid-world" }
metacrate-rendering-wgpu = { version = "0.0.1", path = "../metacrate-rendering-wgpu", features = ["wgpu"] }
[lints]
workspace = true

View File

@@ -0,0 +1,761 @@
//! Stable OpenSim/Second Life update, rendering, and viewport orchestration.
#![allow(clippy::missing_errors_doc)]
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TryRecvError},
},
thread,
time::{Duration, Instant},
};
pub use metacrate_grid_world::{
DirtyWorldItem, LibreMetaverseWorldEvents, RegionDescriptor, WorldEvent, WorldEventMetrics,
WorldEventReceiver, WorldSnapshot, WorldState,
};
/// Slight scheduling margin keeps measured delivery at or above 10 FPS.
pub const DEFAULT_VIEWPORT_FRAME_INTERVAL: Duration = Duration::from_millis(90);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LoopConfig {
pub update_interval: Duration,
pub render_interval: Duration,
pub max_update_catch_up: u32,
pub max_events_per_iteration: usize,
pub event_queue_capacity: usize,
}
impl Default for LoopConfig {
fn default() -> Self {
Self {
update_interval: Duration::from_nanos(16_666_667),
render_interval: Duration::from_millis(100),
max_update_catch_up: 4,
max_events_per_iteration: 1_024,
event_queue_capacity: 4_096,
}
}
}
impl LoopConfig {
#[must_use]
pub const fn valid(self) -> bool {
!self.update_interval.is_zero()
&& !self.render_interval.is_zero()
&& self.max_update_catch_up != 0
&& self.max_events_per_iteration != 0
&& self.event_queue_capacity != 0
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct LoopStep {
pub updates: u32,
pub render: bool,
pub skipped_updates: u64,
pub skipped_renders: u64,
}
#[derive(Clone, Copy, Debug)]
pub struct LoopSchedule {
config: LoopConfig,
next_update: Instant,
next_render: Instant,
}
impl LoopSchedule {
#[must_use]
pub fn new(config: LoopConfig, started: Instant) -> Option<Self> {
config.valid().then_some(Self {
config,
next_update: started + config.update_interval,
next_render: started + config.render_interval,
})
}
#[must_use]
pub fn next_deadline(self) -> Instant {
self.next_update.min(self.next_render)
}
pub fn advance(&mut self, now: Instant) -> LoopStep {
let update_due = intervals_due(now, self.next_update, self.config.update_interval);
let updates = u32::try_from(update_due.min(u64::from(self.config.max_update_catch_up)))
.unwrap_or(self.config.max_update_catch_up);
let skipped_updates = update_due.saturating_sub(u64::from(updates));
if update_due != 0 {
self.next_update = next_after(now, self.next_update, self.config.update_interval);
}
let render_due = intervals_due(now, self.next_render, self.config.render_interval);
if render_due != 0 {
self.next_render = next_after(now, self.next_render, self.config.render_interval);
}
LoopStep {
updates,
render: render_due != 0,
skipped_updates,
skipped_renders: render_due.saturating_sub(1),
}
}
}
fn intervals_due(now: Instant, deadline: Instant, interval: Duration) -> u64 {
if now < deadline {
return 0;
}
let intervals = now.duration_since(deadline).as_nanos() / interval.as_nanos() + 1;
intervals.try_into().unwrap_or(u64::MAX)
}
fn next_after(now: Instant, deadline: Instant, interval: Duration) -> Instant {
let due = intervals_due(now, deadline, interval);
let Ok(due) = u32::try_from(due) else {
return now + interval;
};
deadline
.checked_add(interval.saturating_mul(due))
.unwrap_or(now + interval)
}
#[derive(Debug)]
struct LatestValue<T> {
sequence: u64,
value: Option<Arc<T>>,
}
#[derive(Debug)]
pub struct Latest<T> {
inner: Mutex<LatestValue<T>>,
}
impl<T> Default for Latest<T> {
fn default() -> Self {
Self {
inner: Mutex::new(LatestValue {
sequence: 0,
value: None,
}),
}
}
}
#[derive(Clone, Debug)]
pub struct Published<T> {
pub sequence: u64,
pub value: Arc<T>,
}
#[derive(Clone, Debug)]
pub struct ViewportFrame {
pub sequence: u64,
pub scene_sequence: u64,
pub observed_unix_millis: u64,
pub camera: metacrate_rendering_wgpu::Camera,
pub width: u32,
pub height: u32,
pub rgba: Arc<[u8]>,
}
#[derive(Debug, Default)]
pub struct StableViewport {
latest: Latest<ViewportFrame>,
}
impl StableViewport {
pub fn publish(&self, frame: ViewportFrame) -> Result<u64, ViewportFrame> {
let expected = usize::try_from(frame.width)
.ok()
.and_then(|width| {
usize::try_from(frame.height)
.ok()
.and_then(|height| width.checked_mul(height))
})
.and_then(|pixels| pixels.checked_mul(4));
if frame.sequence == 0 || expected != Some(frame.rgba.len()) {
return Err(frame);
}
Ok(self.latest.publish(frame))
}
#[must_use]
pub fn latest(&self) -> Option<Published<ViewportFrame>> {
self.latest.load()
}
}
/// Owns the renderer and publishes immutable completed frames. Readers never
/// observe the render target while Bevy is writing the next frame.
pub struct ViewportRuntime {
renderer: metacrate_rendering_wgpu::Renderer,
viewport: StableViewport,
next_sequence: AtomicU64,
loaded_scene: Mutex<Option<u64>>,
}
pub struct ViewportRender {
pub frame: Published<ViewportFrame>,
pub timings: metacrate_rendering_wgpu::RenderTimings,
}
#[derive(Clone, Debug)]
pub struct ViewportScene {
pub sequence: u64,
pub camera: metacrate_rendering_wgpu::Camera,
pub renderables: Arc<[metacrate_rendering_wgpu::Renderable]>,
pub textures: Arc<[metacrate_rendering_wgpu::Texture]>,
pub background_srgb: [u8; 4],
pub limits: metacrate_rendering_wgpu::RenderLimits,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct ViewportStats {
pub completed_frames: u64,
pub missed_frame_deadlines: u64,
pub render_errors: u64,
pub last_render: Duration,
}
/// Fixed-rate viewport producer. Scene submission is nonblocking and always
/// replaces stale pending input with the newest complete scene.
pub struct ViewportLoop {
runtime: Arc<ViewportRuntime>,
input: Arc<Latest<ViewportScene>>,
stopped: Arc<AtomicBool>,
thread: Mutex<Option<thread::JoinHandle<()>>>,
completed_frames: Arc<AtomicU64>,
missed_frame_deadlines: Arc<AtomicU64>,
render_errors: Arc<AtomicU64>,
last_render_nanos: Arc<AtomicU64>,
}
impl ViewportLoop {
pub fn start(frame_interval: Duration) -> Result<Self, metacrate_rendering_wgpu::RenderError> {
if frame_interval.is_zero() {
return Err(metacrate_rendering_wgpu::RenderError::InvalidScene);
}
let runtime = Arc::new(ViewportRuntime::new_blocking()?);
let input: Arc<Latest<ViewportScene>> = Arc::new(Latest::default());
let stopped = Arc::new(AtomicBool::new(false));
let completed_frames = Arc::new(AtomicU64::new(0));
let missed_frame_deadlines = Arc::new(AtomicU64::new(0));
let render_errors = Arc::new(AtomicU64::new(0));
let last_render_nanos = Arc::new(AtomicU64::new(0));
let worker_runtime = Arc::clone(&runtime);
let worker_input = Arc::clone(&input);
let worker_stopped = Arc::clone(&stopped);
let worker_completed = Arc::clone(&completed_frames);
let worker_missed = Arc::clone(&missed_frame_deadlines);
let worker_errors = Arc::clone(&render_errors);
let worker_last_render = Arc::clone(&last_render_nanos);
let thread = thread::Builder::new()
.name("metacrate-viewport-loop".into())
.spawn(move || {
let mut next_frame = Instant::now();
while !worker_stopped.load(Ordering::Acquire) {
let now = Instant::now();
if now < next_frame {
thread::park_timeout(next_frame - now);
continue;
}
let due = intervals_due(now, next_frame, frame_interval);
worker_missed.fetch_add(due.saturating_sub(1), Ordering::Relaxed);
next_frame = next_after(now, next_frame, frame_interval);
let Some(scene) = worker_input.load() else {
continue;
};
let started = Instant::now();
let result = worker_runtime.render_scene(
scene.value.sequence,
scene.value.camera,
|| Arc::clone(&scene.value.renderables),
Arc::clone(&scene.value.textures),
scene.value.background_srgb,
scene.value.limits,
);
worker_last_render.store(
started.elapsed().as_nanos().try_into().unwrap_or(u64::MAX),
Ordering::Relaxed,
);
if result.is_ok() {
worker_completed.fetch_add(1, Ordering::Relaxed);
} else {
worker_errors.fetch_add(1, Ordering::Relaxed);
}
}
})
.map_err(|error| metacrate_rendering_wgpu::RenderError::Device(error.to_string()))?;
Ok(Self {
runtime,
input,
stopped,
thread: Mutex::new(Some(thread)),
completed_frames,
missed_frame_deadlines,
render_errors,
last_render_nanos,
})
}
pub fn submit(&self, scene: ViewportScene) -> u64 {
let sequence = self.input.publish(scene);
if let Some(thread) = lock(&self.thread).as_ref() {
thread.thread().unpark();
}
sequence
}
#[must_use]
pub fn latest(&self) -> Option<Published<ViewportFrame>> {
self.runtime.latest()
}
#[must_use]
pub fn stats(&self) -> ViewportStats {
ViewportStats {
completed_frames: self.completed_frames.load(Ordering::Relaxed),
missed_frame_deadlines: self.missed_frame_deadlines.load(Ordering::Relaxed),
render_errors: self.render_errors.load(Ordering::Relaxed),
last_render: Duration::from_nanos(self.last_render_nanos.load(Ordering::Relaxed)),
}
}
pub fn shutdown(&self) {
self.stopped.store(true, Ordering::Release);
if let Some(thread) = lock(&self.thread).take() {
thread.thread().unpark();
let _ = thread.join();
}
}
}
impl Drop for ViewportLoop {
fn drop(&mut self) {
self.shutdown();
}
}
impl ViewportRuntime {
pub fn new_blocking() -> Result<Self, metacrate_rendering_wgpu::RenderError> {
Ok(Self {
renderer: metacrate_rendering_wgpu::Renderer::new_blocking()?,
viewport: StableViewport::default(),
next_sequence: AtomicU64::new(0),
loaded_scene: Mutex::new(None),
})
}
pub fn render_scene<F>(
&self,
scene_sequence: u64,
camera: metacrate_rendering_wgpu::Camera,
renderables: F,
textures: Arc<[metacrate_rendering_wgpu::Texture]>,
background_srgb: [u8; 4],
limits: metacrate_rendering_wgpu::RenderLimits,
) -> Result<ViewportRender, metacrate_rendering_wgpu::RenderError>
where
F: FnOnce() -> Arc<[metacrate_rendering_wgpu::Renderable]>,
{
let mut loaded_scene = lock(&self.loaded_scene);
let mut timings = if *loaded_scene == Some(scene_sequence) {
metacrate_rendering_wgpu::RenderTimings::default()
} else {
let renderables = renderables();
let timings = self
.renderer
.load_scene_shared(camera, renderables, textures, limits)?;
*loaded_scene = Some(scene_sequence);
timings
};
let rendered = self.renderer.render_view(camera, background_srgb, limits)?;
timings.clear_previous_frame += rendered.timings.clear_previous_frame;
timings.scene_setup += rendered.timings.scene_setup;
timings.capture += rendered.timings.capture;
let sequence = self.next_sequence.fetch_add(1, Ordering::AcqRel) + 1;
self.viewport
.publish(ViewportFrame {
sequence,
scene_sequence,
observed_unix_millis: unix_millis_now(),
camera,
width: limits.width,
height: limits.height,
rgba: rendered.rgba.into(),
})
.map_err(|_| metacrate_rendering_wgpu::RenderError::InvalidScene)?;
Ok(ViewportRender {
frame: self
.viewport
.latest()
.ok_or(metacrate_rendering_wgpu::RenderError::Readback)?,
timings,
})
}
#[must_use]
pub fn latest(&self) -> Option<Published<ViewportFrame>> {
self.viewport.latest()
}
}
impl<T> Latest<T> {
pub fn publish(&self, value: T) -> u64 {
self.publish_arc(Arc::new(value))
}
pub fn publish_arc(&self, value: Arc<T>) -> u64 {
let mut latest = lock(&self.inner);
latest.sequence = latest.sequence.saturating_add(1);
latest.value = Some(value);
latest.sequence
}
#[must_use]
pub fn load(&self) -> Option<Published<T>> {
let latest = lock(&self.inner);
Some(Published {
sequence: latest.sequence,
value: Arc::clone(latest.value.as_ref()?),
})
}
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum TimingPhase {
Events,
Update,
Render,
Publish,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TimingSignal {
pub phase: TimingPhase,
pub duration: Duration,
pub sequence: u64,
}
#[derive(Clone, Debug)]
pub struct TimingEmitter {
sender: SyncSender<TimingSignal>,
dropped: Arc<AtomicU64>,
}
#[derive(Debug)]
pub struct TimingReceiver {
receiver: Receiver<TimingSignal>,
dropped: Arc<AtomicU64>,
}
#[must_use]
pub fn timing_channel(capacity: usize) -> Option<(TimingEmitter, TimingReceiver)> {
if capacity == 0 {
return None;
}
let (sender, receiver) = mpsc::sync_channel(capacity);
let dropped = Arc::new(AtomicU64::new(0));
Some((
TimingEmitter {
sender,
dropped: Arc::clone(&dropped),
},
TimingReceiver { receiver, dropped },
))
}
impl TimingEmitter {
pub fn emit(&self, signal: TimingSignal) {
if self.sender.try_send(signal).is_err() {
self.dropped.fetch_add(1, Ordering::Relaxed);
}
}
}
impl TimingReceiver {
pub fn recv_timeout(&self, timeout: Duration) -> Result<TimingSignal, RecvTimeoutError> {
self.receiver.recv_timeout(timeout)
}
pub fn try_recv(&self) -> Result<TimingSignal, TryRecvError> {
self.receiver.try_recv()
}
#[must_use]
pub fn dropped(&self) -> u64 {
self.dropped.load(Ordering::Relaxed)
}
}
pub trait LoopDriver: Send + 'static {
fn world_changed(&mut self, world: &WorldState, dirty: &[DirtyWorldItem]);
fn update(&mut self, world: &WorldState, step: Duration);
fn render(&mut self, world: &WorldState);
}
impl LoopDriver for () {
fn world_changed(&mut self, _world: &WorldState, _dirty: &[DirtyWorldItem]) {}
fn update(&mut self, _world: &WorldState, _step: Duration) {}
fn render(&mut self, _world: &WorldState) {}
}
pub struct LoopHandle {
stopped: Arc<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
world: Arc<Latest<WorldSnapshot>>,
world_events: WorldEventMetrics,
}
#[derive(Clone)]
pub struct WorldReader {
world: Arc<Latest<WorldSnapshot>>,
world_events: WorldEventMetrics,
}
impl WorldReader {
#[must_use]
pub fn latest(&self) -> Option<Published<WorldSnapshot>> {
self.world.load()
}
#[must_use]
pub fn dropped_events(&self) -> u64 {
self.world_events.dropped()
}
}
impl LoopHandle {
#[must_use]
pub fn latest_world(&self) -> Option<Published<WorldSnapshot>> {
self.world.load()
}
#[must_use]
pub fn world_reader(&self) -> WorldReader {
WorldReader {
world: Arc::clone(&self.world),
world_events: self.world_events.clone(),
}
}
#[must_use]
pub fn dropped_world_events(&self) -> u64 {
self.world_events.dropped()
}
pub fn shutdown(mut self) -> thread::Result<()> {
self.stop();
self.thread.take().map_or(Ok(()), thread::JoinHandle::join)
}
fn stop(&self) {
self.stopped.store(true, Ordering::Release);
if let Some(thread) = &self.thread {
thread.thread().unpark();
}
}
}
impl Drop for LoopHandle {
fn drop(&mut self) {
self.stop();
}
}
#[must_use]
pub fn start<D: LoopDriver>(
config: LoopConfig,
events: WorldEventReceiver,
mut driver: D,
timings: Option<TimingEmitter>,
) -> Option<LoopHandle> {
if !config.valid() {
return None;
}
let stopped = Arc::new(AtomicBool::new(false));
let world_events = events.metrics();
let world = Arc::new(Latest::default());
let published_world = Arc::clone(&world);
let thread_stopped = Arc::clone(&stopped);
let thread = thread::Builder::new()
.name("metacrate-game-loop".into())
.spawn(move || {
let Some(mut schedule) = LoopSchedule::new(config, Instant::now()) else {
return;
};
let mut sequence = 0_u64;
let mut world = WorldState::default();
while !thread_stopped.load(Ordering::Acquire) {
let started = Instant::now();
let mut event_count = 0;
while event_count < config.max_events_per_iteration {
match events.try_recv() {
Ok(event) => {
world.apply(event);
event_count += 1;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => return,
}
}
if event_count != 0 {
let dirty = world.take_dirty();
driver.world_changed(&world, &dirty);
published_world.publish(world.snapshot());
emit(
timings.as_ref(),
TimingPhase::Events,
started.elapsed(),
sequence,
);
}
let step = schedule.advance(Instant::now());
for _ in 0..step.updates {
let started = Instant::now();
driver.update(&world, config.update_interval);
sequence = sequence.saturating_add(1);
emit(
timings.as_ref(),
TimingPhase::Update,
started.elapsed(),
sequence,
);
}
if step.render {
let started = Instant::now();
driver.render(&world);
emit(
timings.as_ref(),
TimingPhase::Render,
started.elapsed(),
sequence,
);
}
let wait = schedule
.next_deadline()
.saturating_duration_since(Instant::now());
if wait.is_zero() {
thread::yield_now();
} else {
thread::park_timeout(wait);
}
}
})
.ok()?;
Some(LoopHandle {
stopped,
thread: Some(thread),
world,
world_events,
})
}
fn emit(emitter: Option<&TimingEmitter>, phase: TimingPhase, duration: Duration, sequence: u64) {
if let Some(emitter) = emitter {
emitter.emit(TimingSignal {
phase,
duration,
sequence,
});
}
}
fn lock<T>(value: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
value
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn unix_millis_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| {
duration.as_millis().try_into().unwrap_or(u64::MAX)
})
}
#[cfg(test)]
mod tests {
use super::*;
struct Driver(Arc<AtomicU64>);
impl LoopDriver for Driver {
fn world_changed(&mut self, _world: &WorldState, dirty: &[DirtyWorldItem]) {
self.0.fetch_add(
u64::try_from(dirty.len()).unwrap_or(u64::MAX),
Ordering::Relaxed,
);
}
fn update(&mut self, _world: &WorldState, _step: Duration) {
self.0.fetch_add(1, Ordering::Relaxed);
}
fn render(&mut self, _world: &WorldState) {}
}
#[test]
fn schedule_bounds_catch_up_latest_publishes_and_runner_emits_timings() {
let config = LoopConfig {
update_interval: Duration::from_millis(10),
render_interval: Duration::from_millis(25),
max_update_catch_up: 2,
..LoopConfig::default()
};
let started = Instant::now();
let mut schedule = LoopSchedule::new(config, started).unwrap();
let step = schedule.advance(started + Duration::from_millis(56));
assert_eq!(step.updates, 2);
assert_eq!(step.skipped_updates, 3);
assert!(step.render);
assert_eq!(step.skipped_renders, 1);
let latest = Latest::default();
assert_eq!(latest.publish("old"), 1);
assert_eq!(latest.publish("new"), 2);
let published = latest.load().unwrap();
assert_eq!(published.sequence, 2);
assert_eq!(*published.value, "new");
let viewport = StableViewport::default();
let camera = metacrate_rendering_wgpu::Camera::look_at(
[0.0; 3],
[1.0, 0.0, 0.0],
[0.0, 0.0, 1.0],
60.0,
)
.unwrap();
assert!(
viewport
.publish(ViewportFrame {
sequence: 1,
scene_sequence: 1,
observed_unix_millis: 1,
camera,
width: 2,
height: 1,
rgba: vec![0; 8].into(),
})
.is_ok()
);
assert_eq!(viewport.latest().unwrap().value.sequence, 1);
let count = Arc::new(AtomicU64::new(0));
let (timings, receiver) = timing_channel(32).unwrap();
let (events, event_receiver) = metacrate_grid_world::world_event_channel(32).unwrap();
events.send(WorldEvent::RegionChanged(None));
let handle = start(
config,
event_receiver,
Driver(Arc::clone(&count)),
Some(timings),
)
.unwrap();
thread::sleep(Duration::from_millis(40));
handle.shutdown().unwrap();
assert!(count.load(Ordering::Relaxed) >= 2);
assert!(receiver.try_recv().is_ok());
}
}

View File

@@ -18,6 +18,7 @@ libremetaverse-imaging = { version = "0.0.1", path = "../libremetaverse-imaging"
libremetaverse-rendering-simple = { version = "0.0.1", path = "../libremetaverse-rendering-simple", optional = true }
libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" }
metacrate-lsl-tools = { version = "0.0.1", path = "../metacrate-lsl-tools" }
metacrate-game-loop = { version = "0.0.1", path = "../metacrate-game-loop" }
metacrate-rendering-wgpu = { version = "0.0.1", path = "../metacrate-rendering-wgpu" }
mentra = { version = "0.18.3", default-features = false }
jpeg-encoder = "0.6.1"

View File

@@ -107,6 +107,7 @@ pub struct LibremetaverseClientOwner {
pub struct LibremetaverseWorldSnapshotSource {
client: libremetaverse::GridClient,
agent: Arc<libremetaverse::AgentManager>,
world: metacrate_game_loop::WorldReader,
}
#[cfg(feature = "live-grid")]
@@ -143,6 +144,7 @@ pub struct LibremetaverseSessionBackend {
interaction: Option<crate::interaction::InteractionIngress>,
perception: Option<crate::perception::PerceptionIngress>,
behavior: Option<crate::behavior::BehaviorIngress>,
framework_readiness: Option<Arc<crate::session::FrameworkReadiness>>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
@@ -319,6 +321,7 @@ impl LibremetaverseClientOwner {
interaction,
perception,
behavior,
framework_readiness: None,
delivery_generation: Arc::clone(&self.delivery_generation),
})
}
@@ -332,10 +335,14 @@ impl LibremetaverseClientOwner {
}
#[must_use]
pub fn world_snapshot_source(&self) -> LibremetaverseWorldSnapshotSource {
pub fn world_snapshot_source(
&self,
world: metacrate_game_loop::WorldReader,
) -> LibremetaverseWorldSnapshotSource {
LibremetaverseWorldSnapshotSource {
client: self.client.clone(),
agent: Arc::clone(&self.agent),
world,
}
}
@@ -349,6 +356,18 @@ impl LibremetaverseClientOwner {
}
}
#[cfg(feature = "live-grid")]
impl LibremetaverseSessionBackend {
#[must_use]
pub fn with_framework_readiness(
mut self,
readiness: Arc<crate::session::FrameworkReadiness>,
) -> Self {
self.framework_readiness = Some(readiness);
self
}
}
#[cfg(feature = "live-grid")]
fn metacrate_cache_dir() -> Result<std::path::PathBuf, BackendError> {
directories::ProjectDirs::from("", "", "metacrate")
@@ -362,10 +381,9 @@ fn metacrate_cache_dir() -> Result<std::path::PathBuf, BackendError> {
impl LibremetaverseWorldSnapshotSource {
#[must_use]
pub fn current_region_handle(&self) -> Option<u64> {
self.client
.network()
.native_current_sim()
.map(|simulator| simulator.handle)
self.world
.latest()
.and_then(|world| world.value.region.as_ref().map(|region| region.handle))
}
// One atomic projection keeps native cache reads together. Terrain lookup
@@ -386,19 +404,24 @@ impl LibremetaverseWorldSnapshotSource {
{
return Err(crate::perception::PerceptionError::NotReady);
}
let world = self
.world
.latest()
.ok_or(crate::perception::PerceptionError::NotReady)?;
if world.value.region.as_ref().map(|region| region.id) != Some(simulator.region_id) {
return Err(crate::perception::PerceptionError::NotReady);
}
let agent_position = self.agent.sim_position();
let position = crate::perception::WorldPosition {
x: f64::from(agent_position.x),
y: f64::from(agent_position.y),
z: f64::from(agent_position.z),
};
let avatar_cache = simulator
.objects_avatars
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let avatars_truncated = avatar_cache.len() > 2_048;
let avatars_truncated = world.value.avatars.len() > 2_048;
let mut avatars = BTreeMap::new();
for (local_id, avatar) in avatar_cache
for (local_id, avatar) in world
.value
.avatars
.iter()
.filter(|(_, avatar)| avatar.id != UUID::zero())
{
@@ -414,15 +437,12 @@ impl LibremetaverseWorldSnapshotSource {
avatars.pop_last();
}
}
drop(avatar_cache);
let avatars = avatars.into_values().collect();
let object_cache = simulator
.objects_primitives
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let objects_truncated = object_cache.len() > 2_048;
let objects_truncated = world.value.primitives.len() > 2_048;
let mut objects = BTreeMap::new();
for (local_id, primitive) in object_cache
for (local_id, primitive) in world
.value
.primitives
.iter()
.filter(|(_, primitive)| primitive.id != UUID::zero())
{
@@ -452,7 +472,6 @@ impl LibremetaverseWorldSnapshotSource {
objects.pop_last();
}
}
drop(object_cache);
let objects = objects.into_values().collect();
let parcel = simulator
.parcels
@@ -500,7 +519,7 @@ impl LibremetaverseWorldSnapshotSource {
let (inventory, landmarks, inventory_truncated) = native_inventory_metadata(&self.client);
Ok(crate::perception::WorldSnapshot {
generation,
observed_unix_millis: crate::perception::unix_millis_now(),
observed_unix_millis: world.value.observed_unix_millis,
region_id: simulator.region_id,
region_name: crate::perception::sanitize_untrusted(&simulator.name),
agent: crate::perception::AgentSnapshot {
@@ -1029,6 +1048,8 @@ struct LibremetaverseSession {
interaction: Option<crate::interaction::InteractionIngress>,
perception: Option<crate::perception::PerceptionIngress>,
behavior: Option<crate::behavior::BehaviorIngress>,
agent: Arc<libremetaverse::AgentManager>,
framework_readiness: Option<Arc<crate::session::FrameworkReadiness>>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
@@ -1043,7 +1064,7 @@ impl crate::session::GridSession for LibremetaverseSession {
cancellation: CancellationToken,
) -> crate::session::SessionFuture<'_, crate::session::SessionSignal> {
Box::pin(async move {
tokio::select! {
let signal = tokio::select! {
signal = self.signals.recv() => signal.unwrap_or_else(|| {
crate::session::SessionSignal::Disconnected(
crate::session::SessionFailure::new(
@@ -1056,7 +1077,36 @@ impl crate::session::GridSession for LibremetaverseSession {
crate::session::SessionFailureKind::TransientTransport,
),
),
};
if matches!(signal, crate::session::SessionSignal::Ready) {
if let Some(readiness) = &self.framework_readiness
&& !readiness.wait_ready(self.generation, cancellation).await
{
return crate::session::SessionSignal::Disconnected(
crate::session::SessionFailure::new(
crate::session::SessionFailureKind::TransientTransport,
),
);
}
*self
.delivery_generation
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(self.generation);
if let Some(interaction) = &self.interaction {
let _ = interaction.try_connected(self.generation, self.agent.agent_id());
}
if let (Some(perception), Some(simulator)) =
(&self.perception, self.network.native_current_sim())
{
let _ = perception.connected(self.generation, simulator.region_id);
}
if let (Some(behavior), Some(simulator)) =
(&self.behavior, self.network.native_current_sim())
{
let _ = behavior.connected(self.generation, simulator.region_id);
}
}
signal
})
}
@@ -1220,34 +1270,12 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
));
}));
let ready_sender = sender.clone();
let ready_interaction = self.interaction.clone();
let ready_perception = self.perception.clone();
let ready_behavior = self.behavior.clone();
let ready_network = self.network.clone();
let ready_agent = Arc::clone(&self.agent);
let ready_generation = Arc::clone(&self.delivery_generation);
let ready_once = Arc::new(AtomicBool::new(false));
let callback_ready = Arc::clone(&ready_once);
let ready = self
.network
.native_subscribe_event_queue_running(Arc::new(move |_| {
if !callback_ready.swap(true, Ordering::AcqRel) {
*ready_generation
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation);
if let Some(interaction) = &ready_interaction {
let _ = interaction.try_connected(generation, ready_agent.agent_id());
}
if let (Some(perception), Some(simulator)) =
(&ready_perception, ready_network.native_current_sim())
{
let _ = perception.connected(generation, simulator.region_id);
}
if let (Some(behavior), Some(simulator)) =
(&ready_behavior, ready_network.native_current_sim())
{
let _ = behavior.connected(generation, simulator.region_id);
}
let _ = ready_sender.try_send(crate::session::SessionSignal::Ready);
}
}));
@@ -1299,23 +1327,6 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
.unwrap_or(false)
&& !ready_once.swap(true, Ordering::AcqRel)
{
*self
.delivery_generation
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation);
if let Some(interaction) = &self.interaction {
let _ = interaction.try_connected(generation, self.agent.agent_id());
}
if let (Some(perception), Some(simulator)) =
(&self.perception, self.network.native_current_sim())
{
let _ = perception.connected(generation, simulator.region_id);
}
if let (Some(behavior), Some(simulator)) =
(&self.behavior, self.network.native_current_sim())
{
let _ = behavior.connected(generation, simulator.region_id);
}
let _ = sender.try_send(crate::session::SessionSignal::Ready);
}
let session: Box<dyn crate::session::GridSession> = Box::new(LibremetaverseSession {
@@ -1326,6 +1337,8 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
interaction: self.interaction.clone(),
perception: self.perception.clone(),
behavior: self.behavior.clone(),
agent: Arc::clone(&self.agent),
framework_readiness: self.framework_readiness.clone(),
delivery_generation: Arc::clone(&self.delivery_generation),
});
Ok(session)

View File

@@ -164,9 +164,9 @@ pub use script_delivery::{
};
pub use service::{AgentService, ServiceError, ServiceHandle, ServiceState};
pub use session::{
GridSession, GridSessionBackend, ReconnectPolicy, SessionControl, SessionFailure,
SessionFailureKind, SessionFuture, SessionObservation, SessionReason, SessionSignal,
SessionState, SessionStatus, SessionSupervisor, SessionSupervisorError,
FrameworkReadiness, GridSession, GridSessionBackend, ReconnectPolicy, SessionControl,
SessionFailure, SessionFailureKind, SessionFuture, SessionObservation, SessionReason,
SessionSignal, SessionState, SessionStatus, SessionSupervisor, SessionSupervisorError,
SessionSupervisorHandle, SessionWork, WorkDisposition, WorkKind,
};
pub use tool_loop::{ToolExecution, ToolExecutor, ToolFuture, ToolLoopError, ToolLoopLimits};

View File

@@ -273,9 +273,9 @@ async fn run_live(
) -> Result<(), Box<dyn Error>> {
use metacrate_grid_agent::{
AgentControlTarget, BehaviorObservation, ControlEventKind, ControlPlane, ControlTarget,
GridSessionBackend, LibremetaverseClientOwner, OperatingMode, RuntimeControlCommand,
SessionControl, SessionObservation, SessionState, SessionSupervisor, TcpControlConfig,
TcpControlServer, WorldSnapshotSource,
FrameworkReadiness, GridSessionBackend, LibremetaverseClientOwner, OperatingMode,
RuntimeControlCommand, SessionControl, SessionObservation, SessionReason, SessionState,
SessionSupervisor, TcpControlConfig, TcpControlServer, WorldSnapshotSource,
};
let connection = config.grid.clone().ok_or_else(|| {
@@ -285,15 +285,20 @@ async fn run_live(
config.vision.asset_cache_max_bytes,
)?;
let mut live = start_live_interactions(&config, &owner).await?;
let framework_readiness = Arc::new(FrameworkReadiness::default());
let backend = match owner.session_backend_with_agent_services(
connection,
live.interaction.ingress(),
live.perception.clone(),
live.behavior.ingress(),
) {
Ok(backend) => backend,
Ok(backend) => backend.with_framework_readiness(Arc::clone(&framework_readiness)),
Err(error) => {
live.vision.stop_viewport().await;
live.scene_source.stop_prefetch().await;
if let Some(world_loop) = live.world_loop.take() {
let _ = world_loop.shutdown();
}
live.interaction.shutdown().await?;
live.behavior.shutdown().await?;
return Err(error.into());
@@ -313,6 +318,26 @@ async fn run_live(
let readiness = tokio::time::timeout(config.timeouts.startup, async {
loop {
match handle.next_observation().await {
Some(
event @ SessionObservation::Transition {
status,
reason: SessionReason::LoginSucceeded,
..
},
) => {
live.vision.set_generation(status.generation);
record_session_observation(&live.observability, &event);
prepare_live_framework(
&live,
&framework_readiness,
status.generation,
config
.timeouts
.startup
.saturating_sub(Duration::from_secs(5)),
)
.await?;
}
Some(event @ SessionObservation::Transition { status, .. })
if status.agent_ready =>
{
@@ -351,7 +376,11 @@ async fn run_live(
Err(_) => Err(CliError("timed out waiting for full grid readiness".into())),
};
if let Err(error) = readiness {
live.vision.stop_viewport().await;
live.scene_source.stop_prefetch().await;
if let Some(world_loop) = live.world_loop.take() {
let _ = world_loop.shutdown();
}
let session_result = handle.shutdown().await;
let interaction_result = live.interaction.shutdown().await;
let behavior_result = live.behavior.shutdown().await;
@@ -360,7 +389,11 @@ async fn run_live(
behavior_result?;
return Err(error.into());
}
live.vision.stop_viewport().await;
live.scene_source.stop_prefetch().await;
if let Some(world_loop) = live.world_loop.take() {
let _ = world_loop.shutdown();
}
let session_result = handle.shutdown().await;
let interaction_result = live.interaction.shutdown().await;
let behavior_result = live.behavior.shutdown().await;
@@ -454,6 +487,16 @@ async fn run_live(
if let SessionObservation::Transition { status, reason, retry_in } = event {
live.vision.set_generation(status.generation);
control_target.update_session(status);
if reason == SessionReason::LoginSucceeded
&& let Err(error) = prepare_live_framework(
&live,
&framework_readiness,
status.generation,
config.timeouts.startup.saturating_sub(Duration::from_secs(5)),
).await
{
eprintln!("framework initialization failed: {error}");
}
if status.agent_ready
&& let Ok(snapshot) = live
.world
@@ -485,6 +528,34 @@ async fn run_live(
status.transport_connected,
status.agent_ready,
);
if status.agent_ready {
let world = live
.world_loop
.as_ref()
.and_then(metacrate_game_loop::LoopHandle::latest_world);
let world_sequence = world.as_ref().map_or(0, |world| world.sequence);
let primitives = world
.as_ref()
.map_or(0, |world| world.value.primitives.len());
let avatars = world.as_ref().map_or(0, |world| world.value.avatars.len());
let dropped_world_events = live
.world_loop
.as_ref()
.map_or(0, metacrate_game_loop::LoopHandle::dropped_world_events);
let frames = live
.vision
.viewport_stats()
.map_or(0, |stats| stats.completed_frames);
println!(
"READY generation={} world_sequence={} world_primitives={} world_avatars={} dropped_world_events={} viewport_frames={} viewport_target_fps=10",
status.generation,
world_sequence,
primitives,
avatars,
dropped_world_events,
frames
);
}
}
}
event = live.interaction.next_observation() => {
@@ -567,7 +638,11 @@ async fn run_live(
let _ = live.observability.record(shutdown_event);
control_target.mark_stopping();
live.landmark_roaming.shutdown().await;
live.vision.stop_viewport().await;
live.scene_source.stop_prefetch().await;
if let Some(world_loop) = live.world_loop.take() {
let _ = world_loop.shutdown();
}
let session_result = handle.shutdown().await;
let interaction_result = live.interaction.shutdown().await;
let behavior_result = live.behavior.shutdown().await;
@@ -590,6 +665,55 @@ async fn run_live(
Ok(())
}
#[cfg(feature = "live-grid")]
async fn prepare_live_framework(
live: &LiveInteractions,
readiness: &metacrate_grid_agent::FrameworkReadiness,
generation: u64,
timeout: Duration,
) -> Result<(), CliError> {
println!("INITIALIZING generation={generation} waiting_for=world_state,viewport target_fps=10");
tokio::time::timeout(timeout, async {
loop {
let world_ready = live
.world_loop
.as_ref()
.and_then(metacrate_game_loop::LoopHandle::latest_world)
.is_some_and(|world| world.value.region.is_some());
if world_ready && live.scene_source.prefetch_passes() != 0 {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
live.vision.start_viewport(
generation,
libremetaverse_types::compat::CancellationToken::default(),
);
loop {
if live
.vision
.viewport_stats()
.is_some_and(|stats| stats.completed_frames >= 2 && stats.render_errors == 0)
{
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.map_err(|_| CliError("timed out waiting for stable world state and viewport".into()))?;
readiness.mark_ready(generation);
let stats = live.vision.viewport_stats().unwrap_or_default();
println!(
"INITIALIZED generation={} viewport_frames={} last_render_ms={} missed_deadlines={} world_state=stable",
generation,
stats.completed_frames,
stats.last_render.as_millis(),
stats.missed_frame_deadlines,
);
Ok(())
}
#[cfg(feature = "live-grid")]
struct LiveInteractions {
interaction: metacrate_grid_agent::InteractionHandle,
@@ -608,6 +732,8 @@ struct LiveInteractions {
Arc<metacrate_grid_agent::VisionService<metacrate_grid_agent::LibremetaverseSceneSource>>,
scene_source: Arc<metacrate_grid_agent::LibremetaverseSceneSource>,
world: Arc<metacrate_grid_agent::LibremetaverseWorldSnapshotSource>,
_world_events: metacrate_game_loop::LibreMetaverseWorldEvents,
world_loop: Option<metacrate_game_loop::LoopHandle>,
}
#[cfg(feature = "live-grid")]
@@ -630,7 +756,25 @@ async fn start_live_interactions(
};
let conversation = Arc::new(ConversationStore::from_config(config)?);
let world = Arc::new(owner.world_snapshot_source());
let (world_events, world_receiver) =
metacrate_game_loop::LibreMetaverseWorldEvents::subscribe_visible(
owner.client(),
owner.agent(),
config.vision.max_distance_meters,
4_096,
)
.ok_or_else(|| CliError("world event queue capacity is zero".into()))?;
let world_loop = metacrate_game_loop::start(
metacrate_game_loop::LoopConfig {
event_queue_capacity: 4_096,
..metacrate_game_loop::LoopConfig::default()
},
world_receiver,
(),
None,
)
.ok_or_else(|| CliError("world game loop initialization failed".into()))?;
let world = Arc::new(owner.world_snapshot_source(world_loop.world_reader()));
let perception = Arc::new(PerceptionBackend::new(
world.clone(),
Arc::clone(&conversation),
@@ -752,7 +896,7 @@ async fn start_live_interactions(
let vision =
Arc::new(VisionService::new(Arc::clone(&scene_source), vision_limits)?.prefer_gpu());
if !vision.initialize_renderer().await {
eprintln!("WARNING: wgpu renderer unavailable; visual captures use software fallback");
return Err(CliError("wgpu viewport renderer initialization failed".into()).into());
}
let responder = Arc::new(VisionAugmentedResponder::new(vision.clone(), responder));
let sink = Arc::new(owner.interaction_sink());
@@ -785,6 +929,8 @@ async fn start_live_interactions(
vision,
scene_source,
world,
_world_events: world_events,
world_loop: Some(world_loop),
})
}

View File

@@ -12,7 +12,7 @@ use std::pin::Pin;
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::{mpsc, oneshot};
use tokio::sync::{mpsc, oneshot, watch};
use tokio::task::JoinHandle;
use tokio::time::Instant;
@@ -25,6 +25,39 @@ const MAX_SHUTDOWN: Duration = Duration::from_mins(1);
pub type SessionFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// Generation-scoped gate between transport readiness and autonomous agent readiness.
pub struct FrameworkReadiness {
generation: watch::Sender<u64>,
}
impl Default for FrameworkReadiness {
fn default() -> Self {
let (generation, _) = watch::channel(0);
Self { generation }
}
}
impl FrameworkReadiness {
pub fn mark_ready(&self, generation: u64) {
if generation != 0 {
self.generation.send_replace(generation);
}
}
pub async fn wait_ready(&self, generation: u64, cancellation: CancellationToken) -> bool {
let mut ready = self.generation.subscribe();
loop {
if *ready.borrow() == generation {
return true;
}
tokio::select! {
() = cancellation.cancelled() => return false,
changed = ready.changed() => if changed.is_err() { return false; },
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum SessionState {
@@ -215,7 +248,7 @@ impl Default for ReconnectPolicy {
Self {
initial_delay: Duration::from_secs(1),
maximum_delay: Duration::from_mins(1),
readiness_timeout: Duration::from_secs(30),
readiness_timeout: Duration::from_mins(2),
stable_reset_after: Duration::from_mins(2),
shutdown_deadline: Duration::from_secs(10),
jitter_basis_points: 2_000,

View File

@@ -557,3 +557,16 @@ async fn shutdown_is_cancellation_driven_during_connect_backoff_paused_and_onlin
assert_eq!(stats.flushes.load(Ordering::Acquire), 1);
}
}
#[tokio::test]
async fn framework_readiness_releases_only_the_matching_generation() {
let readiness = Arc::new(FrameworkReadiness::default());
let waiting = Arc::clone(&readiness);
let task =
tokio::spawn(async move { waiting.wait_ready(2, CancellationToken::default()).await });
readiness.mark_ready(1);
tokio::task::yield_now().await;
assert!(!task.is_finished());
readiness.mark_ready(2);
assert!(task.await.unwrap());
}

View File

@@ -72,12 +72,14 @@ pub struct SceneBuildTimings {
#[derive(Clone, Debug, PartialEq)]
pub struct SceneSnapshot {
pub generation: u64,
/// Changes only when the persistent render scene changes; camera movement does not change it.
pub scene_sequence: u64,
pub observed_unix_millis: u64,
pub region_id: UUID,
pub region_name: String,
pub camera: CameraPose,
pub entities: Vec<SceneEntity>,
pub textures: Vec<SceneTexture>,
pub entities: Arc<[SceneEntity]>,
pub textures: Arc<[SceneTexture]>,
pub completeness: SnapshotCompleteness,
/// Work already performed by the scene source; validated again before rendering.
pub texture_fetches: usize,
@@ -235,7 +237,9 @@ pub struct VisionService<S: SceneSource> {
#[cfg(feature = "live-grid")]
gpu_preferred: bool,
#[cfg(feature = "live-grid")]
gpu: tokio::sync::OnceCell<Option<Arc<metacrate_rendering_wgpu::Renderer>>>,
gpu: tokio::sync::OnceCell<Option<Arc<metacrate_game_loop::ViewportLoop>>>,
#[cfg(feature = "live-grid")]
viewport_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl<S: SceneSource> VisionService<S> {
@@ -258,6 +262,8 @@ impl<S: SceneSource> VisionService<S> {
gpu_preferred: false,
#[cfg(feature = "live-grid")]
gpu: tokio::sync::OnceCell::new(),
#[cfg(feature = "live-grid")]
viewport_task: Mutex::new(None),
})
}
/// Prefer cached offscreen `wgpu` rendering, falling back to the
@@ -276,7 +282,7 @@ impl<S: SceneSource> VisionService<S> {
}
#[cfg(feature = "live-grid")]
async fn renderer(&self) -> Option<Arc<metacrate_rendering_wgpu::Renderer>> {
async fn renderer(&self) -> Option<Arc<metacrate_game_loop::ViewportLoop>> {
self.gpu
.get_or_init(|| async {
tokio::task::spawn_blocking(bevy_renderer_new)
@@ -288,6 +294,85 @@ impl<S: SceneSource> VisionService<S> {
.await
.clone()
}
/// Returns the last fully completed viewport without waiting for or
/// interfering with the next render.
#[cfg(feature = "live-grid")]
#[must_use]
pub fn latest_viewport(
&self,
) -> Option<metacrate_game_loop::Published<metacrate_game_loop::ViewportFrame>> {
self.gpu.get()?.as_ref()?.latest()
}
#[cfg(feature = "live-grid")]
#[must_use]
pub fn viewport_stats(&self) -> Option<metacrate_game_loop::ViewportStats> {
Some(self.gpu.get()?.as_ref()?.stats())
}
/// Starts the scene feeder. The viewport crate owns the fixed-rate render
/// loop and completed-frame publication after submission.
#[cfg(feature = "live-grid")]
pub fn start_viewport(self: &Arc<Self>, generation: u64, cancellation: CancellationToken) {
if let Some(task) = lock(&self.viewport_task).take() {
task.abort();
}
let service = Arc::downgrade(self);
*lock(&self.viewport_task) = Some(tokio::spawn(async move {
loop {
if cancellation.is_cancellation_requested() {
break;
}
let Some(service) = service.upgrade() else {
break;
};
if let Ok(scene) = service
.source
.capture_scene(generation, cancellation.clone())
.await
{
service.submit_viewport_scene(&scene).await;
}
drop(service);
tokio::select! {
() = cancellation.cancelled() => break,
() = tokio::time::sleep(Duration::from_millis(100)) => {}
}
}
}));
}
#[cfg(feature = "live-grid")]
pub async fn stop_viewport(&self) {
let task = lock(&self.viewport_task).take();
if let Some(task) = task {
task.abort();
let _ = task.await;
}
}
#[cfg(feature = "live-grid")]
pub async fn wait_viewport_ready(&self, timeout: Duration) -> bool {
tokio::time::timeout(timeout, async {
loop {
if self.latest_viewport().is_some() {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.is_ok()
}
#[cfg(feature = "live-grid")]
async fn submit_viewport_scene(&self, scene: &SceneSnapshot) {
let Some(viewport) = self.renderer().await else {
return;
};
viewport.submit(viewport_scene(scene, self.limits));
}
pub fn set_generation(&self, generation: u64) {
if self.current_generation.swap(generation, Ordering::AcqRel) != generation {
self.cancel_active();
@@ -429,55 +514,16 @@ impl<S: SceneSource> VisionService<S> {
if self.gpu_preferred
&& let Some(renderer) = self.renderer().await
{
let mut renderables = scene
.entities
.iter()
.flat_map(|entity| {
entity
.renderables
.iter()
.cloned()
.map(move |renderable| (entity.id.to_string(), renderable))
})
.collect::<Vec<_>>();
renderables.sort_by(|left, right| left.0.cmp(&right.0));
let renderables = renderables
.into_iter()
.map(|(_, renderable)| renderable)
.collect::<Vec<_>>();
let gpu_scene = scene.clone();
let limits = self.limits;
if let Ok(Ok(capture)) = tokio::task::spawn_blocking(move || {
renderer
.render(
gpu_scene.camera,
&renderables,
&gpu_scene.textures,
environment_color(&gpu_scene),
metacrate_rendering_wgpu::RenderLimits {
width: limits.width,
height: limits.height,
max_triangles: limits.max_triangles,
max_texture_bytes: limits.max_texture_bytes,
max_texture_pixels: limits.max_decode_pixels,
far_distance: 512,
},
)
.map_err(|error| match error {
metacrate_rendering_wgpu::RenderError::InvalidScene => {
VisionError::InvalidScene
}
metacrate_rendering_wgpu::RenderError::ResourceLimit => {
VisionError::ResourceLimit
}
metacrate_rendering_wgpu::RenderError::TimedOut => VisionError::TimedOut,
_ => VisionError::Encode,
})
.and_then(|rgba| finish_capture(&gpu_scene, limits, &rgba))
})
.await
{
return Ok(capture);
renderer.submit(viewport_scene(&scene, self.limits));
let deadline = Instant::now() + self.limits.capture_timeout;
while Instant::now() < deadline {
if let Some(frame) = renderer.latest()
&& frame.value.scene_sequence == scene.scene_sequence
&& frame.value.camera == scene.camera
{
return finish_capture(&scene, self.limits, &frame.value.rgba);
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
render_scene_software(&scene, self.limits)
@@ -486,8 +532,46 @@ impl<S: SceneSource> VisionService<S> {
#[cfg(feature = "live-grid")]
fn bevy_renderer_new()
-> Result<metacrate_rendering_wgpu::Renderer, metacrate_rendering_wgpu::RenderError> {
metacrate_rendering_wgpu::Renderer::new_blocking()
-> Result<metacrate_game_loop::ViewportLoop, metacrate_rendering_wgpu::RenderError> {
metacrate_game_loop::ViewportLoop::start(metacrate_game_loop::DEFAULT_VIEWPORT_FRAME_INTERVAL)
}
#[cfg(feature = "live-grid")]
fn viewport_scene(
scene: &SceneSnapshot,
limits: VisionLimits,
) -> metacrate_game_loop::ViewportScene {
let mut renderables = scene
.entities
.iter()
.flat_map(|entity| {
entity
.renderables
.iter()
.cloned()
.map(move |renderable| (entity.id.to_string(), renderable))
})
.collect::<Vec<_>>();
renderables.sort_by(|left, right| left.0.cmp(&right.0));
metacrate_game_loop::ViewportScene {
sequence: scene.scene_sequence,
camera: scene.camera,
renderables: renderables
.into_iter()
.map(|(_, renderable)| renderable)
.collect::<Vec<_>>()
.into(),
textures: Arc::clone(&scene.textures),
background_srgb: environment_color(scene),
limits: metacrate_rendering_wgpu::RenderLimits {
width: limits.width,
height: limits.height,
max_triangles: limits.max_triangles,
max_texture_bytes: limits.max_texture_bytes,
max_texture_pixels: limits.max_decode_pixels,
far_distance: 512,
},
}
}
/// Type-erased runtime control surface. It deliberately exposes metadata and
@@ -746,7 +830,7 @@ fn validate_scene(scene: &SceneSnapshot, limits: VisionLimits) -> Result<(), Vis
return Err(VisionError::ResourceLimit);
}
let mut count = 0usize;
for entity in &scene.entities {
for entity in scene.entities.iter() {
count = count
.checked_add(entity.triangles.len())
.ok_or(VisionError::ResourceLimit)?;
@@ -965,8 +1049,11 @@ pub struct LibremetaverseSceneSource {
agent: Arc<libremetaverse::AgentManager>,
limits: VisionLimits,
cache: Arc<Mutex<SceneCache>>,
latest_scene: metacrate_game_loop::Latest<SceneSnapshot>,
scene_build: tokio::sync::Mutex<()>,
prefetch_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
prefetch_passes: AtomicU64,
scene_sequence: AtomicU64,
}
#[cfg(feature = "live-grid")]
@@ -1013,8 +1100,11 @@ impl LibremetaverseSceneSource {
agent: owner.agent(),
limits,
cache: Arc::new(Mutex::new(SceneCache::default())),
latest_scene: metacrate_game_loop::Latest::default(),
scene_build: tokio::sync::Mutex::new(()),
prefetch_task: Mutex::new(None),
prefetch_passes: AtomicU64::new(0),
scene_sequence: AtomicU64::new(0),
}
}
@@ -1031,11 +1121,15 @@ impl LibremetaverseSceneSource {
break;
};
if source.client.network().current_sim().is_some()
&& source
.capture_scene(0, CancellationToken::default())
.await
.is_ok()
&& let Ok(scene) = source.capture_scene(0, CancellationToken::default()).await
{
if source
.latest_scene
.load()
.is_none_or(|published| !same_render_scene(&published.value, &scene))
{
source.latest_scene.publish(scene);
}
source.prefetch_passes.fetch_add(1, Ordering::AcqRel);
}
drop(source);
@@ -1057,6 +1151,40 @@ impl LibremetaverseSceneSource {
pub fn prefetch_passes(&self) -> u64 {
self.prefetch_passes.load(Ordering::Acquire)
}
fn cached_scene(
&self,
generation: u64,
simulator: &libremetaverse::Simulator,
) -> Option<SceneSnapshot> {
let published = self.latest_scene.load()?;
if generation == 0 || published.value.region_id != simulator.region_id {
return None;
}
let camera = &self.agent.movement.camera;
let position = camera.position();
let forward = native_camera_forward(camera);
let up = camera.up_axis();
let mut scene = (*published.value).clone();
scene.generation = generation;
scene.observed_unix_millis = unix_millis_now();
scene.camera = CameraPose {
position: [position.x, position.y, position.z],
forward: [forward.x, forward.y, forward.z],
up: [up.x, up.y, up.z],
vertical_fov_degrees: camera.vertical_fov_angle().to_degrees(),
};
scene.timings = SceneBuildTimings::default();
Some(scene)
}
}
#[cfg(feature = "live-grid")]
fn same_render_scene(left: &SceneSnapshot, right: &SceneSnapshot) -> bool {
left.region_id == right.region_id
&& left.entities == right.entities
&& left.textures == right.textures
&& left.completeness == right.completeness
}
#[cfg(feature = "live-grid")]
@@ -1124,6 +1252,13 @@ impl SceneSource for LibremetaverseSceneSource {
.network()
.current_sim()
.ok_or(VisionError::StaleGeneration)?;
if let Some(scene) = self.cached_scene(generation, &simulator) {
return Ok(scene);
}
let _scene_build = self.scene_build.lock().await;
if let Some(scene) = self.cached_scene(generation, &simulator) {
return Ok(scene);
}
let scene_deadline = Instant::now() + Duration::from_secs(5);
loop {
let terrain_ready = simulator
@@ -1624,11 +1759,8 @@ impl SceneSource for LibremetaverseSceneSource {
.unwrap_or(u32::MAX);
Ok(SceneSnapshot {
generation,
observed_unix_millis: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| {
duration.as_millis().try_into().unwrap_or(u64::MAX)
}),
scene_sequence: self.scene_sequence.fetch_add(1, Ordering::AcqRel) + 1,
observed_unix_millis: unix_millis_now(),
region_id,
region_name,
camera: CameraPose {
@@ -1637,8 +1769,8 @@ impl SceneSource for LibremetaverseSceneSource {
up: [up.x, up.y, up.z],
vertical_fov_degrees: camera.vertical_fov_angle().to_degrees(),
},
entities,
textures,
entities: entities.into(),
textures: textures.into(),
completeness: SnapshotCompleteness {
objects_truncated: objects_truncated || geometry_truncated,
avatars_truncated,
@@ -1660,6 +1792,14 @@ impl SceneSource for LibremetaverseSceneSource {
}
}
fn unix_millis_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| {
duration.as_millis().try_into().unwrap_or(u64::MAX)
})
}
#[cfg(feature = "live-grid")]
pub(crate) fn native_camera_forward(
camera: &libremetaverse::AgentManagerAgentMovementAgentCamera,

View File

@@ -9,6 +9,7 @@ use libremetaverse_types::{
compat::{CancellationToken, CancellationTokenSource},
};
use std::sync::{Arc, Mutex};
use std::time::Duration;
struct FakeScene {
scene: SceneSnapshot,
@@ -363,6 +364,7 @@ fn scene_visibility_uses_configured_distance_and_avatar_view() {
fn scene() -> SceneSnapshot {
SceneSnapshot {
generation: 7,
scene_sequence: 1,
observed_unix_millis: 1_700_000_000_000,
region_id: uuid(1),
region_name: "Fixture Region".into(),
@@ -389,8 +391,9 @@ fn scene() -> SceneSnapshot {
renderables: Vec::new(),
texture_available: true,
},
],
textures: Vec::new(),
]
.into(),
textures: Vec::new().into(),
completeness: SnapshotCompleteness {
textures_missing: 1,
terrain_available: false,
@@ -449,7 +452,7 @@ async fn golden_scene_is_deterministic_depth_ordered_and_privacy_marked() {
#[cfg(feature = "live-grid")]
#[tokio::test(flavor = "multi_thread")]
async fn offscreen_wgpu_renders_depth_ordered_scene_when_an_adapter_is_available() {
let Ok(renderer) = metacrate_rendering_wgpu::Renderer::new_blocking() else {
let Ok(renderer) = metacrate_game_loop::ViewportLoop::start(Duration::from_millis(100)) else {
return;
};
let limits = VisionLimits {
@@ -467,25 +470,30 @@ async fn offscreen_wgpu_renders_depth_ordered_scene_when_an_adapter_is_available
let renderables = scene_renderable_from_triangles(&triangles)
.into_iter()
.collect::<Vec<_>>();
let rgba = tokio::task::spawn_blocking(move || {
renderer.render(
scene.camera,
&renderables,
&scene.textures,
[72, 96, 120, 255],
metacrate_rendering_wgpu::RenderLimits {
width: limits.width,
height: limits.height,
max_triangles: limits.max_triangles,
max_texture_bytes: limits.max_texture_bytes,
max_texture_pixels: limits.max_decode_pixels,
far_distance: 512,
},
)
renderer.submit(metacrate_game_loop::ViewportScene {
sequence: scene.scene_sequence,
camera: scene.camera,
renderables: renderables.into(),
textures: Arc::clone(&scene.textures),
background_srgb: [72, 96, 120, 255],
limits: metacrate_rendering_wgpu::RenderLimits {
width: limits.width,
height: limits.height,
max_triangles: limits.max_triangles,
max_texture_bytes: limits.max_texture_bytes,
max_texture_pixels: limits.max_decode_pixels,
far_distance: 512,
},
});
tokio::time::timeout(Duration::from_secs(3), async {
while renderer.stats().completed_frames < 2 {
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("render worker")
.expect("offscreen render");
.expect("fixed-rate viewport did not publish repeated frames");
let rgba = renderer.latest().unwrap().value.rgba.clone();
assert_eq!(renderer.stats().render_errors, 0);
assert_eq!(rgba.len(), 64 * 64 * 4);
let center = (32 * 64 + 32) * 4;
assert!(rgba[center + 1] > 180 && rgba[center] < 80 && rgba[center + 2] < 80);

View File

@@ -2,7 +2,7 @@ use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
const ALLOWED_DEPENDENCIES: [&str; 21] = [
const ALLOWED_DEPENDENCIES: [&str; 22] = [
"async-trait",
"base64",
"crossterm",
@@ -10,6 +10,7 @@ const ALLOWED_DEPENDENCIES: [&str; 21] = [
"libremetaverse",
"libremetaverse-imaging",
"libremetaverse-rendering-simple",
"metacrate-game-loop",
"metacrate-lsl-tools",
"metacrate-rendering-wgpu",
"mentra",
@@ -170,8 +171,6 @@ fn live_session_adapter_reuses_native_lifecycle_and_messaging_managers() {
".subscribe_im(",
".chat(",
".instant_message_with_uuid_string(",
".objects_avatars",
".objects_primitives",
".parcels",
".environment()",
".inventory()",
@@ -183,6 +182,15 @@ fn live_session_adapter_reuses_native_lifecycle_and_messaging_managers() {
"missing native lifecycle {required}"
);
}
let world = fs::read_to_string(root.join("../metacrate-grid-world/src/lib.rs"))
.expect("central world source");
for required in [".objects_avatars", ".objects_primitives", ".terrain"] {
assert!(
world.contains(required),
"missing centralized native world cache {required}"
);
}
assert!(backend.contains("metacrate_game_loop::WorldReader"));
assert!(!backend.contains("reqwest"));
}

View File

@@ -159,12 +159,27 @@ async fn live_primary_map_name_reports_varregion_dimensions() -> Result<(), Box<
async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Error>> {
let environment = live_environment()?;
let renderer_owner = LibremetaverseClientOwner::new()?;
let (mut renderer_session, renderer_cancel) =
login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
let limits = VisionLimits {
minimum_interval: Duration::ZERO,
..VisionLimits::default()
};
let (_world_events, world_receiver) =
metacrate_game_loop::LibreMetaverseWorldEvents::subscribe_visible(
renderer_owner.client(),
renderer_owner.agent(),
limits.max_distance_meters,
4_096,
)
.ok_or("live world event subscription failed")?;
let world_loop = metacrate_game_loop::start(
metacrate_game_loop::LoopConfig::default(),
world_receiver,
(),
None,
)
.ok_or("live world loop failed to start")?;
let (mut renderer_session, renderer_cancel) =
login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
let scene_source = Arc::new(LibremetaverseSceneSource::new(&renderer_owner, limits));
let prefetch_started = Instant::now();
scene_source.start_prefetch();
@@ -183,6 +198,27 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
renderer_simulator.name, region_size.0, region_size.1
);
wait_scene_settled(&renderer_owner).await?;
tokio::time::timeout(Duration::from_secs(10), async {
while world_loop
.latest_world()
.is_none_or(|world| world.value.region.is_none())
{
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.map_err(|_| "central world state did not become ready")?;
let world = world_loop
.latest_world()
.ok_or("central world snapshot disappeared")?;
println!(
"LIVE_WORLD_STATE=sequence:{},primitives:{},avatars:{},terrain_patches:{},dropped_events:{}",
world.sequence,
world.value.primitives.len(),
world.value.avatars.len(),
world.value.terrain.len(),
world_loop.dropped_world_events(),
);
let view_forward = Vector3::mul_with_vector3_quaternion(
Vector3::unit_x(),
renderer_owner.agent().sim_rotation(),
@@ -273,6 +309,31 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
}
}
let capture = capture.ok_or("live renderer produced no frame")?;
let viewport_before = vision
.viewport_stats()
.ok_or("viewport loop did not expose statistics")?;
let soak_started = Instant::now();
tokio::time::sleep(Duration::from_secs(2)).await;
let soak_elapsed = soak_started.elapsed();
let viewport_after = vision
.viewport_stats()
.ok_or("viewport loop stopped exposing statistics")?;
let soak_frames = viewport_after
.completed_frames
.saturating_sub(viewport_before.completed_frames);
let soak_missed = viewport_after
.missed_frame_deadlines
.saturating_sub(viewport_before.missed_frame_deadlines);
let soak_fps = f64::from(u32::try_from(soak_frames)?) / soak_elapsed.as_secs_f64();
println!(
"LIVE_VIEWPORT_SOAK=frames:{soak_frames},elapsed_ms:{},fps:{soak_fps:.2},missed_deadlines:{soak_missed},render_errors:{},last_render_ms:{}",
soak_elapsed.as_millis(),
viewport_after.render_errors,
viewport_after.last_render.as_millis(),
);
if soak_fps < 10.0 || soak_missed != 0 || viewport_after.render_errors != 0 {
return Err("stable viewport did not sustain its 10 Hz target".into());
}
profile_warm_capture(&scene_source, limits, renderer_cancel.token()).await?;
println!("LIVE_RENDER_OUTPUT={}", output.display());
println!("LIVE_RENDER_SHA256={}", capture.image_sha256);
@@ -281,10 +342,14 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
.await;
scene_source.stop_prefetch().await;
world_loop
.shutdown()
.map_err(|_| "world loop thread panicked")?;
let _ = renderer_session.logout(renderer_cancel.token()).await;
result
}
#[allow(clippy::too_many_lines)] // One ordered live timing record keeps phase boundaries explicit.
async fn profile_warm_capture(
source: &LibremetaverseSceneSource,
limits: VisionLimits,
@@ -311,9 +376,10 @@ async fn profile_warm_capture(
.into_iter()
.map(|(_, renderable)| renderable)
.collect::<Vec<_>>();
let renderables: Arc<[metacrate_rendering_wgpu::Renderable]> = renderables.into();
let preparation = preparation_started.elapsed();
let renderer_started = Instant::now();
let renderer = metacrate_rendering_wgpu::Renderer::new_blocking()?;
let renderer = metacrate_game_loop::ViewportRuntime::new_blocking()?;
let renderer_initialization = renderer_started.elapsed();
let render_limits = metacrate_rendering_wgpu::RenderLimits {
width: limits.width,
@@ -323,24 +389,38 @@ async fn profile_warm_capture(
max_texture_pixels: limits.max_decode_pixels,
far_distance: 512,
};
let first = renderer.render_profiled(
let first = renderer.render_scene(
scene.scene_sequence,
scene.camera,
&renderables,
&scene.textures,
|| Arc::clone(&renderables),
Arc::clone(&scene.textures),
[125, 149, 173, 255],
render_limits,
)?;
let warm_started = Instant::now();
let warm = renderer.render_profiled(
let refresh_started = Instant::now();
let refresh = renderer.render_scene(
scene.scene_sequence.saturating_add(1),
scene.camera,
&renderables,
&scene.textures,
|| Arc::clone(&renderables),
Arc::clone(&scene.textures),
[125, 149, 173, 255],
render_limits,
)?;
let refresh_total = refresh_started.elapsed();
let warm_started = Instant::now();
let warm = renderer.render_scene(
scene.scene_sequence.saturating_add(1),
scene.camera,
|| panic!("unchanged scene was transferred to the renderer again"),
Arc::clone(&scene.textures),
[125, 149, 173, 255],
render_limits,
)?;
let warm_render_total = warm_started.elapsed();
let jpeg_started = Instant::now();
let rgb = warm
.frame
.value
.rgba
.chunks_exact(4)
.flat_map(|pixel| pixel[..3].iter().copied())
@@ -354,7 +434,7 @@ async fn profile_warm_capture(
)?;
let jpeg_encode = jpeg_started.elapsed();
println!(
"LIVE_RENDER_PROFILE=scene_total_ms:{},terrain_wait_ms:{},object_snapshot_and_asset_discovery_ms:{},material_fetch_ms:{},asset_fetch_ms:{},decode_and_geometry_ms:{},renderable_collection_and_sort_ms:{},renderer_initialization_ms:{},first_render_total_ms:{},first_render:{:?},warm_render_total_ms:{},warm_render:{:?},jpeg_encode_ms:{},profile_total_ms:{}",
"LIVE_RENDER_PROFILE=scene_total_ms:{},terrain_wait_ms:{},object_snapshot_and_asset_discovery_ms:{},material_fetch_ms:{},asset_fetch_ms:{},decode_and_geometry_ms:{},renderable_collection_and_sort_ms:{},renderer_initialization_ms:{},first_render_total_ms:{},first_render:{:?},scene_refresh_total_ms:{},scene_refresh:{:?},warm_render_total_ms:{},warm_render:{:?},jpeg_encode_ms:{},profile_total_ms:{}",
scene_total.as_millis(),
scene.timings.terrain_wait.as_millis(),
scene
@@ -368,11 +448,16 @@ async fn profile_warm_capture(
renderer_initialization.as_millis(),
render_total(&first.timings).as_millis(),
first.timings,
refresh_total.as_millis(),
refresh.timings,
warm_render_total.as_millis(),
warm.timings,
jpeg_encode.as_millis(),
total_started.elapsed().as_millis(),
);
if refresh_total >= metacrate_game_loop::DEFAULT_VIEWPORT_FRAME_INTERVAL {
return Err("cached scene refresh exceeded the viewport frame interval".into());
}
Ok(())
}
@@ -400,8 +485,7 @@ impl SceneSource for DiagnosticSceneSource {
Box::pin(async move {
let mut scene = self.inner.capture_scene(generation, cancellation).await?;
if let Some(material_override) = &self.material_override {
for renderable in scene
.entities
for renderable in Arc::make_mut(&mut scene.entities)
.iter_mut()
.filter(|entity| entity.kind == SceneEntityKind::Object)
.flat_map(|entity| &mut entity.renderables)

View File

@@ -0,0 +1,16 @@
[package]
name = "metacrate-grid-world"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Bounded OpenSim and Second Life event stream and persistent world state"
publish = false
[dependencies]
libremetaverse = { version = "0.0.1", path = "../libremetaverse", default-features = false }
libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" }
[lints]
workspace = true

View File

@@ -0,0 +1,767 @@
//! Bounded OpenSim/Second Life event consumption and persistent world state.
#![allow(clippy::missing_errors_doc)]
use libremetaverse::{Avatar, GridClient, Primitive, Simulator};
use libremetaverse_types::{UUID, compat::Subscription};
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc::{self, Receiver, RecvError, SyncSender, TryRecvError},
},
thread,
time::Duration,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RegionDescriptor {
pub id: UUID,
pub handle: u64,
pub name: String,
pub size: (u32, u32),
}
impl RegionDescriptor {
#[must_use]
pub fn from_simulator(simulator: &Simulator) -> Self {
Self {
id: simulator.region_id,
handle: simulator.handle,
name: simulator.name.clone(),
size: simulator.region_size(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum PrimitiveChange {
Full,
Movement,
}
pub enum WorldEvent {
RegionChanged(Option<RegionDescriptor>),
PrimitiveUpdated {
region: RegionDescriptor,
primitive: Arc<Primitive>,
change: PrimitiveChange,
},
AvatarUpdated {
region: RegionDescriptor,
avatar: Arc<Avatar>,
},
AvatarMoved {
region: RegionDescriptor,
local_id: u32,
position: libremetaverse_types::Vector3,
rotation: libremetaverse_types::Quaternion,
velocity: libremetaverse_types::Vector3,
},
ObjectsRemoved {
region: RegionDescriptor,
local_ids: Vec<u32>,
},
TerrainUpdated {
region: RegionDescriptor,
x: i32,
y: i32,
patch_size: i32,
height_map: Vec<f32>,
},
VisibilitySnapshot {
region: RegionDescriptor,
primitives: Vec<Arc<Primitive>>,
avatars: Vec<Arc<Avatar>>,
terrain: Vec<((i32, i32), TerrainPatch)>,
},
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum DirtyWorldItem {
Region,
Primitive(u32, PrimitiveChange),
Avatar(u32),
Terrain(i32, i32),
}
#[derive(Clone, Debug, PartialEq)]
pub struct TerrainPatch {
pub patch_size: i32,
pub height_map: Arc<[f32]>,
}
#[derive(Clone)]
pub struct WorldSnapshot {
pub observed_unix_millis: u64,
pub region: Option<RegionDescriptor>,
pub primitives: Arc<HashMap<u32, Arc<Primitive>>>,
pub avatars: Arc<HashMap<u32, Arc<Avatar>>>,
pub terrain: Arc<BTreeMap<(i32, i32), TerrainPatch>>,
}
#[derive(Default)]
pub struct WorldState {
region: Option<RegionDescriptor>,
primitives: HashMap<u32, Arc<Primitive>>,
avatars: HashMap<u32, Arc<Avatar>>,
terrain: BTreeMap<(i32, i32), TerrainPatch>,
dirty: BTreeSet<DirtyWorldItem>,
}
impl WorldState {
pub fn apply(&mut self, event: WorldEvent) {
match event {
WorldEvent::RegionChanged(region) => self.change_region(region),
WorldEvent::PrimitiveUpdated {
region,
primitive,
change,
} => {
self.ensure_region(region);
let local_id = primitive.local_id;
self.primitives.insert(local_id, primitive);
self.dirty.retain(
|item| !matches!(item, DirtyWorldItem::Primitive(id, _) if *id == local_id),
);
self.dirty
.insert(DirtyWorldItem::Primitive(local_id, change));
}
WorldEvent::AvatarUpdated { region, avatar } => {
self.ensure_region(region);
let local_id = avatar.local_id;
self.avatars.insert(local_id, avatar);
self.dirty.insert(DirtyWorldItem::Avatar(local_id));
}
WorldEvent::AvatarMoved {
region,
local_id,
position,
rotation,
velocity,
} => {
self.ensure_region(region);
if let Some(avatar) = self.avatars.get_mut(&local_id) {
let avatar = Arc::make_mut(avatar);
avatar.position = position;
avatar.rotation = rotation;
avatar.velocity = velocity;
self.dirty.insert(DirtyWorldItem::Avatar(local_id));
}
}
WorldEvent::ObjectsRemoved { region, local_ids } => {
self.ensure_region(region);
for local_id in local_ids {
self.primitives.remove(&local_id);
self.avatars.remove(&local_id);
self.dirty
.insert(DirtyWorldItem::Primitive(local_id, PrimitiveChange::Full));
}
}
WorldEvent::TerrainUpdated {
region,
x,
y,
patch_size,
height_map,
} => {
self.ensure_region(region);
self.terrain.insert(
(x, y),
TerrainPatch {
patch_size,
height_map: height_map.into(),
},
);
self.dirty.insert(DirtyWorldItem::Terrain(x, y));
}
WorldEvent::VisibilitySnapshot {
region,
primitives,
avatars,
terrain,
} => {
self.region = Some(region);
self.primitives = primitives
.into_iter()
.map(|primitive| (primitive.local_id, primitive))
.collect();
self.avatars = avatars
.into_iter()
.map(|avatar| (avatar.local_id, avatar))
.collect();
self.terrain = terrain.into_iter().collect();
self.dirty.clear();
self.dirty.insert(DirtyWorldItem::Region);
}
}
}
fn ensure_region(&mut self, region: RegionDescriptor) {
if self.region.as_ref().map(|value| value.handle) != Some(region.handle) {
self.change_region(Some(region));
}
}
fn change_region(&mut self, region: Option<RegionDescriptor>) {
self.region = region;
self.primitives.clear();
self.avatars.clear();
self.terrain.clear();
self.dirty.clear();
self.dirty.insert(DirtyWorldItem::Region);
}
#[must_use]
pub fn region(&self) -> Option<&RegionDescriptor> {
self.region.as_ref()
}
#[must_use]
pub fn primitive(&self, local_id: u32) -> Option<Arc<Primitive>> {
self.primitives.get(&local_id).cloned()
}
#[must_use]
pub fn avatar(&self, local_id: u32) -> Option<Arc<Avatar>> {
self.avatars.get(&local_id).cloned()
}
#[must_use]
pub fn terrain(&self, x: i32, y: i32) -> Option<&TerrainPatch> {
self.terrain.get(&(x, y))
}
pub fn take_dirty(&mut self) -> Vec<DirtyWorldItem> {
std::mem::take(&mut self.dirty).into_iter().collect()
}
#[must_use]
pub fn snapshot(&self) -> WorldSnapshot {
WorldSnapshot {
observed_unix_millis: unix_millis_now(),
region: self.region.clone(),
primitives: Arc::new(self.primitives.clone()),
avatars: Arc::new(self.avatars.clone()),
terrain: Arc::new(self.terrain.clone()),
}
}
}
fn unix_millis_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| {
duration.as_millis().try_into().unwrap_or(u64::MAX)
})
}
#[derive(Clone)]
pub struct WorldEventSender {
sender: SyncSender<WorldEvent>,
dropped: Arc<AtomicU64>,
}
impl WorldEventSender {
pub fn send(&self, event: WorldEvent) {
if self.sender.try_send(event).is_err() {
self.dropped.fetch_add(1, Ordering::Relaxed);
}
}
#[must_use]
pub fn dropped(&self) -> u64 {
self.dropped.load(Ordering::Relaxed)
}
}
pub struct WorldEventReceiver {
receiver: Receiver<WorldEvent>,
dropped: Arc<AtomicU64>,
}
#[derive(Clone)]
pub struct WorldEventMetrics {
dropped: Arc<AtomicU64>,
}
impl WorldEventMetrics {
#[must_use]
pub fn dropped(&self) -> u64 {
self.dropped.load(Ordering::Relaxed)
}
}
#[must_use]
pub fn world_event_channel(capacity: usize) -> Option<(WorldEventSender, WorldEventReceiver)> {
if capacity == 0 {
return None;
}
let (sender, receiver) = mpsc::sync_channel(capacity);
let dropped = Arc::new(AtomicU64::new(0));
Some((
WorldEventSender {
sender,
dropped: Arc::clone(&dropped),
},
WorldEventReceiver { receiver, dropped },
))
}
impl WorldEventReceiver {
pub fn recv(&self) -> Result<WorldEvent, RecvError> {
self.receiver.recv()
}
pub fn try_recv(&self) -> Result<WorldEvent, TryRecvError> {
self.receiver.try_recv()
}
#[must_use]
pub fn dropped(&self) -> u64 {
self.dropped.load(Ordering::Relaxed)
}
#[must_use]
pub fn metrics(&self) -> WorldEventMetrics {
WorldEventMetrics {
dropped: Arc::clone(&self.dropped),
}
}
}
pub struct LibreMetaverseWorldEvents {
_subscriptions: Vec<Subscription>,
stopped: Arc<AtomicBool>,
monitor: Option<thread::JoinHandle<()>>,
}
impl Drop for LibreMetaverseWorldEvents {
fn drop(&mut self) {
self.stopped.store(true, Ordering::Release);
if let Some(monitor) = self.monitor.take() {
monitor.thread().unpark();
let _ = monitor.join();
}
}
}
impl LibreMetaverseWorldEvents {
#[must_use]
pub fn subscribe(client: &GridClient, capacity: usize) -> Option<(Self, WorldEventReceiver)> {
Self::subscribe_inner(client, None, capacity)
}
#[must_use]
pub fn subscribe_visible(
client: &GridClient,
agent: Arc<libremetaverse::AgentManager>,
max_distance_meters: u32,
capacity: usize,
) -> Option<(Self, WorldEventReceiver)> {
let maximum = f32::from(u16::try_from(max_distance_meters).ok()?);
(maximum > 0.0).then_some(())?;
Self::subscribe_inner(client, Some((agent, maximum)), capacity)
}
#[allow(clippy::too_many_lines)] // One subscription owner keeps all native event lifetimes together.
fn subscribe_inner(
client: &GridClient,
view: Option<(Arc<libremetaverse::AgentManager>, f32)>,
capacity: usize,
) -> Option<(Self, WorldEventReceiver)> {
if capacity == 0 {
return None;
}
let (ingress, receiver) = world_event_channel(capacity)?;
let objects = client.objects();
let terrain = client.terrain();
let network = client.network();
let monitor_view = view.clone();
let monitor_ingress = ingress.clone();
let mut subscriptions = Vec::with_capacity(6);
let event_ingress = ingress.clone();
let event_view = view.clone();
subscriptions.push(objects.subscribe_object_update(Arc::new(move |event| {
let simulator = event.simulator();
let primitive = event.prim();
let Some(primitive) = visible_primitive(&simulator, primitive, event_view.as_ref())
else {
return;
};
event_ingress.send(WorldEvent::PrimitiveUpdated {
region: RegionDescriptor::from_simulator(&simulator),
primitive: Arc::new(primitive),
change: PrimitiveChange::Full,
});
})));
let event_ingress = ingress.clone();
let event_view = view.clone();
subscriptions.push(
objects.subscribe_terse_object_update(Arc::new(move |event| {
let simulator = event.simulator();
let update = event.update();
if update.avatar {
if visible_position(update.position, event_view.as_ref()) {
event_ingress.send(WorldEvent::AvatarMoved {
region: RegionDescriptor::from_simulator(&simulator),
local_id: update.local_id,
position: update.position,
rotation: update.rotation,
velocity: update.velocity,
});
} else {
event_ingress.send(WorldEvent::ObjectsRemoved {
region: RegionDescriptor::from_simulator(&simulator),
local_ids: vec![update.local_id],
});
}
return;
}
let mut primitive = event.prim();
primitive.position = update.position;
primitive.rotation = update.rotation;
primitive.velocity = update.velocity;
primitive.acceleration = update.acceleration;
primitive.angular_velocity = update.angular_velocity;
let local_id = primitive.local_id;
let Some(primitive) = visible_primitive(&simulator, primitive, event_view.as_ref())
else {
event_ingress.send(WorldEvent::ObjectsRemoved {
region: RegionDescriptor::from_simulator(&simulator),
local_ids: vec![local_id],
});
return;
};
event_ingress.send(WorldEvent::PrimitiveUpdated {
region: RegionDescriptor::from_simulator(&simulator),
primitive: Arc::new(primitive),
change: PrimitiveChange::Movement,
});
})),
);
let event_ingress = ingress.clone();
let event_view = view.clone();
subscriptions.push(objects.subscribe_avatar_update(Arc::new(move |event| {
let simulator = event.simulator();
let avatar = event.avatar();
if !visible_position(avatar.position, event_view.as_ref()) {
return;
}
event_ingress.send(WorldEvent::AvatarUpdated {
region: RegionDescriptor::from_simulator(&simulator),
avatar: Arc::new(avatar),
});
})));
let event_ingress = ingress.clone();
subscriptions.push(objects.subscribe_kill_object(Arc::new(move |event| {
let simulator = event.simulator();
event_ingress.send(WorldEvent::ObjectsRemoved {
region: RegionDescriptor::from_simulator(&simulator),
local_ids: vec![event.object_local_id()],
});
})));
let event_ingress = ingress.clone();
let event_view = view;
subscriptions.push(
terrain.subscribe_land_patch_received(Arc::new(move |event| {
let simulator = event.simulator();
if !visible_terrain_patch(
event.x(),
event.y(),
event.patch_size(),
event_view.as_ref(),
) {
return;
}
event_ingress.send(WorldEvent::TerrainUpdated {
region: RegionDescriptor::from_simulator(&simulator),
x: event.x(),
y: event.y(),
patch_size: event.patch_size(),
height_map: event.height_map(),
});
})),
);
let event_ingress = ingress;
let callback_client = client.clone();
subscriptions.push(network.subscribe_sim_changed(Arc::new(move |_| {
event_ingress.send(WorldEvent::RegionChanged(
callback_client
.network()
.current_sim()
.as_ref()
.map(RegionDescriptor::from_simulator),
));
})));
let stopped = Arc::new(AtomicBool::new(false));
let monitor = if let Some((agent, maximum)) = monitor_view {
let monitor_client = client.clone();
let monitor_stopped = Arc::clone(&stopped);
Some(
thread::Builder::new()
.name("metacrate-world-visibility".into())
.spawn(move || {
let mut last = None;
while !monitor_stopped.load(Ordering::Acquire) {
if let Some(simulator) = monitor_client.network().current_sim() {
let position = agent.sim_position();
let changed = last.is_none_or(
|(handle, previous): (u64, libremetaverse_types::Vector3)| {
let dx = position.x - previous.x;
let dy = position.y - previous.y;
let dz = position.z - previous.z;
handle != simulator.handle
|| dx * dx + dy * dy + dz * dz >= 16.0
},
);
if changed {
monitor_ingress
.send(visibility_snapshot(&simulator, &agent, maximum));
last = Some((simulator.handle, position));
}
}
thread::park_timeout(Duration::from_millis(250));
}
})
.ok()?,
)
} else {
None
};
Some((
Self {
_subscriptions: subscriptions,
stopped,
monitor,
},
receiver,
))
}
}
fn visibility_snapshot(
simulator: &Simulator,
agent: &libremetaverse::AgentManager,
maximum: f32,
) -> WorldEvent {
let center = agent.sim_position();
let primitives = simulator
.objects_primitives
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let avatars = simulator
.objects_avatars
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let visible_primitives = primitives
.values()
.filter_map(|primitive| resolved_primitive(primitive.clone(), &primitives, &avatars))
.filter(|primitive| position_in_radius(primitive.position, center, maximum))
.map(Arc::new)
.collect();
let visible_avatars = avatars
.values()
.filter(|avatar| position_in_radius(avatar.position, center, maximum))
.cloned()
.map(Arc::new)
.collect();
drop(primitives);
drop(avatars);
let terrain = simulator
.terrain
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.filter(|patch| terrain_patch_in_radius(patch.x, patch.y, 16, center, maximum))
.map(|patch| {
(
(patch.x, patch.y),
TerrainPatch {
patch_size: 16,
height_map: Arc::from(patch.data.clone()),
},
)
})
.collect();
WorldEvent::VisibilitySnapshot {
region: RegionDescriptor::from_simulator(simulator),
primitives: visible_primitives,
avatars: visible_avatars,
terrain,
}
}
fn position_in_radius(
position: libremetaverse_types::Vector3,
center: libremetaverse_types::Vector3,
maximum: f32,
) -> bool {
let dx = position.x - center.x;
let dy = position.y - center.y;
let dz = position.z - center.z;
dx * dx + dy * dy + dz * dz <= maximum * maximum
}
fn visible_position(
position: libremetaverse_types::Vector3,
view: Option<&(Arc<libremetaverse::AgentManager>, f32)>,
) -> bool {
view.is_none_or(|(agent, maximum)| {
let center = agent.sim_position();
position_in_radius(position, center, *maximum)
})
}
fn visible_terrain_patch(
x: i32,
y: i32,
patch_size: i32,
view: Option<&(Arc<libremetaverse::AgentManager>, f32)>,
) -> bool {
let Some((agent, maximum)) = view else {
return true;
};
terrain_patch_in_radius(x, y, patch_size, agent.sim_position(), *maximum)
}
fn terrain_patch_in_radius(
x: i32,
y: i32,
patch_size: i32,
center: libremetaverse_types::Vector3,
maximum: f32,
) -> bool {
let (Ok(x), Ok(y), Ok(patch_size)) = (
i16::try_from(x),
i16::try_from(y),
i16::try_from(patch_size),
) else {
return false;
};
let (Some(patch_x), Some(patch_y)) = (x.checked_mul(patch_size), y.checked_mul(patch_size))
else {
return false;
};
let patch_x = f32::from(patch_x) + f32::from(patch_size) * 0.5;
let patch_y = f32::from(patch_y) + f32::from(patch_size) * 0.5;
let dx = patch_x - center.x;
let dy = patch_y - center.y;
dx * dx + dy * dy <= maximum * maximum
}
fn visible_primitive(
simulator: &Simulator,
mut primitive: Primitive,
view: Option<&(Arc<libremetaverse::AgentManager>, f32)>,
) -> Option<Primitive> {
if view.is_none() {
return Some(primitive);
}
let primitives = simulator
.objects_primitives
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let avatars = simulator
.objects_avatars
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
primitive = resolved_primitive(primitive, &primitives, &avatars)?;
visible_position(primitive.position, view).then_some(primitive)
}
fn resolved_primitive(
mut primitive: Primitive,
primitives: &HashMap<u32, Primitive>,
avatars: &HashMap<u32, Avatar>,
) -> Option<Primitive> {
let mut parent_id = primitive.parent_id;
for _ in 0..32 {
if parent_id == 0 {
primitive.parent_id = 0;
return Some(primitive);
}
if let Some(parent) = primitives.get(&parent_id) {
primitive.position = libremetaverse_types::Vector3::add_with_vector3_vector3(
parent.position,
libremetaverse_types::Vector3::mul_with_vector3_quaternion(
primitive.position,
parent.rotation,
),
)
.ok()?;
primitive.rotation = libremetaverse_types::Quaternion::mul_with_quaternion_quaternion(
parent.rotation,
primitive.rotation,
);
parent_id = parent.parent_id;
continue;
}
let avatar = avatars.get(&parent_id)?;
primitive.position = libremetaverse_types::Vector3::add_with_vector3_vector3(
avatar.position,
libremetaverse_types::Vector3::mul_with_vector3_quaternion(
primitive.position,
avatar.rotation,
),
)
.ok()?;
primitive.rotation = libremetaverse_types::Quaternion::mul_with_quaternion_quaternion(
avatar.rotation,
primitive.rotation,
);
primitive.parent_id = 0;
primitive.is_attachment = true;
return Some(primitive);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn region_changes_clear_state_and_dirty_changes_coalesce() {
let first = RegionDescriptor {
id: UUID::new_with_string("00000000-0000-0000-0000-000000000001".into()).unwrap(),
handle: 1,
name: "First".into(),
size: (256, 256),
};
let second = RegionDescriptor {
id: UUID::new_with_string("00000000-0000-0000-0000-000000000002".into()).unwrap(),
handle: 2,
name: "Second".into(),
size: (1024, 1024),
};
let mut world = WorldState::default();
world.apply(WorldEvent::RegionChanged(Some(first)));
world.apply(WorldEvent::TerrainUpdated {
region: second.clone(),
x: 0,
y: 0,
patch_size: 1,
height_map: vec![3.0],
});
assert_eq!(world.region(), Some(&second));
assert_eq!(world.terrain(0, 0).unwrap().height_map.as_ref(), &[3.0]);
assert_eq!(
world.take_dirty(),
vec![DirtyWorldItem::Region, DirtyWorldItem::Terrain(0, 0)]
);
assert!(world.take_dirty().is_empty());
world.apply(WorldEvent::VisibilitySnapshot {
region: second,
primitives: Vec::new(),
avatars: Vec::new(),
terrain: Vec::new(),
});
assert!(world.terrain(0, 0).is_none());
assert_eq!(world.take_dirty(), vec![DirtyWorldItem::Region]);
}
}

View File

@@ -45,6 +45,19 @@ enum RenderCommand {
limits: RenderLimits,
reply: mpsc::SyncSender<Result<RenderedFrame, RenderError>>,
},
LoadScene {
camera: Camera,
renderables: std::sync::Arc<[Renderable]>,
textures: std::sync::Arc<[Texture]>,
limits: RenderLimits,
reply: mpsc::SyncSender<Result<RenderTimings, RenderError>>,
},
View {
camera: Camera,
background_srgb: [u8; 4],
limits: RenderLimits,
reply: mpsc::SyncSender<Result<RenderedFrame, RenderError>>,
},
Stop,
}
@@ -102,6 +115,32 @@ impl Renderer {
limits,
));
}
RenderCommand::LoadScene {
camera,
renderables,
textures,
limits,
reply,
} => {
let _ = reply.send(backend.load_scene(
camera,
&renderables,
&textures,
limits,
));
}
RenderCommand::View {
camera,
background_srgb,
limits,
reply,
} => {
let _ = reply.send(backend.render_view(
camera,
background_srgb,
limits,
));
}
RenderCommand::Stop => break,
}
}
@@ -170,6 +209,83 @@ impl Renderer {
frame.timings.command_copy = command_copy;
Ok(frame)
}
pub fn load_scene(
&self,
camera: Camera,
renderables: &[Renderable],
textures: &[Texture],
limits: RenderLimits,
) -> Result<RenderTimings, RenderError> {
self.load_scene_shared(
camera,
renderables.to_vec().into(),
textures.to_vec().into(),
limits,
)
}
pub fn load_scene_shared(
&self,
camera: Camera,
renderables: std::sync::Arc<[Renderable]>,
textures: std::sync::Arc<[Texture]>,
limits: RenderLimits,
) -> Result<RenderTimings, RenderError> {
let started = Instant::now();
validate_scene(camera, &renderables, &textures, limits)?;
let validation = started.elapsed();
let started = Instant::now();
let (sender, receiver) = mpsc::sync_channel(1);
let command = RenderCommand::LoadScene {
camera,
renderables,
textures,
limits,
reply: sender,
};
let command_copy = started.elapsed();
self.send(command)?;
let mut timings = receiver
.recv_timeout(Duration::from_mins(2))
.map_err(|_| RenderError::TimedOut)??;
timings.validation = validation;
timings.command_copy = command_copy;
Ok(timings)
}
pub fn render_view(
&self,
camera: Camera,
background_srgb: [u8; 4],
limits: RenderLimits,
) -> Result<RenderedFrame, RenderError> {
camera.basis()?;
if limits.width == 0 || limits.height == 0 || limits.far_distance == 0 {
return Err(RenderError::InvalidScene);
}
let (sender, receiver) = mpsc::sync_channel(1);
self.send(RenderCommand::View {
camera,
background_srgb,
limits,
reply: sender,
})?;
receiver
.recv_timeout(Duration::from_mins(2))
.map_err(|_| RenderError::TimedOut)?
}
fn send(&self, command: RenderCommand) -> Result<(), RenderError> {
self.commands
.try_send(command)
.map_err(|error| match error {
mpsc::TrySendError::Full(_) => RenderError::TimedOut,
mpsc::TrySendError::Disconnected(_) => {
RenderError::Device("renderer thread stopped".into())
}
})
}
}
impl Drop for Renderer {
@@ -189,6 +305,9 @@ impl Drop for Renderer {
#[derive(Component)]
struct FrameEntity;
#[derive(Component)]
struct PersistentSceneEntity;
struct LegacyMaterialPlugin;
struct PbrMaterialPlugin;
@@ -328,13 +447,14 @@ struct CachedGpuMesh {
struct BevyBackend {
apps: SubApps,
frame_entities: Vec<Entity>,
scene_entities: Vec<Entity>,
texture_handles: Vec<GpuTexture>,
texture_cache: HashMap<u64, Vec<CachedGpuTexture>>,
mesh_cache: HashMap<u64, Vec<CachedGpuMesh>>,
pbr_handles: Vec<Handle<PbrBevyMaterial>>,
legacy_handles: Vec<Handle<LegacyBevyMaterial>>,
environment_map: EnvironmentMapLight,
target: Option<Handle<Image>>,
target: Option<(u32, u32, Handle<Image>)>,
}
impl BevyBackend {
@@ -381,6 +501,7 @@ impl BevyBackend {
Ok(Self {
apps: std::mem::take(app.sub_apps_mut()),
frame_entities: Vec::new(),
scene_entities: Vec::new(),
texture_handles: Vec::new(),
texture_cache: HashMap::new(),
mesh_cache: HashMap::new(),
@@ -400,18 +521,28 @@ impl BevyBackend {
background_srgb: [u8; 4],
limits: RenderLimits,
) -> Result<RenderedFrame, RenderError> {
let mut timings = self.load_scene(camera, renderables, textures, limits)?;
let frame = self.render_view(camera, background_srgb, limits)?;
timings.clear_previous_frame += frame.timings.clear_previous_frame;
timings.scene_setup += frame.timings.scene_setup;
timings.capture += frame.timings.capture;
Ok(RenderedFrame {
rgba: frame.rgba,
timings,
})
}
#[allow(clippy::too_many_lines)]
fn load_scene(
&mut self,
camera: Camera,
renderables: &[Renderable],
textures: &[Texture],
_limits: RenderLimits,
) -> Result<RenderTimings, RenderError> {
let started = Instant::now();
self.clear_frame();
self.clear_scene();
let clear_previous_frame = started.elapsed();
self.apps
.main
.world_mut()
.insert_resource(ClearColor(Color::srgba_u8(
background_srgb[0],
background_srgb[1],
background_srgb[2],
background_srgb[3],
)));
let started = Instant::now();
self.texture_handles = add_textures(
self.apps.main.world_mut(),
@@ -443,7 +574,7 @@ impl BevyBackend {
.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(material.clone()),
FrameEntity,
PersistentSceneEntity,
))
.id();
self.legacy_handles.push(material);
@@ -463,15 +594,70 @@ impl BevyBackend {
.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(material.clone()),
FrameEntity,
PersistentSceneEntity,
))
.id();
self.pbr_handles.push(material);
entity
}
};
self.frame_entities.push(entity);
self.scene_entities.push(entity);
}
let light = self
.apps
.main
.world_mut()
.spawn((
DirectionalLight {
illuminance: 12_000.0,
shadow_maps_enabled: false,
..Default::default()
},
Transform::from_xyz(40.0, 80.0, 30.0).looking_at(Vec3::ZERO, Vec3::Y),
PersistentSceneEntity,
))
.id();
self.scene_entities.push(light);
Ok(RenderTimings {
clear_previous_frame,
texture_cache_sync,
mesh_cache_sync,
scene_setup: started.elapsed(),
..Default::default()
})
}
fn render_view(
&mut self,
camera: Camera,
background_srgb: [u8; 4],
limits: RenderLimits,
) -> Result<RenderedFrame, RenderError> {
let started = Instant::now();
self.clear_frame();
let clear_previous_frame = started.elapsed();
self.apps
.main
.world_mut()
.insert_resource(ClearColor(Color::srgba_u8(
background_srgb[0],
background_srgb[1],
background_srgb[2],
background_srgb[3],
)));
let camera_position = world_vector(camera.position).extend(1.0);
for handle in &self.legacy_handles {
if let Some(mut material) = self
.apps
.main
.world_mut()
.resource_mut::<Assets<LegacyBevyMaterial>>()
.get_mut(handle)
{
material.uniform.camera_position = camera_position;
}
}
let started = Instant::now();
let target = self.new_target(limits.width, limits.height);
let transform = camera_transform(camera)?;
let camera_entity = self
@@ -494,21 +680,6 @@ impl BevyBackend {
))
.id();
self.frame_entities.push(camera_entity);
let light = self
.apps
.main
.world_mut()
.spawn((
DirectionalLight {
illuminance: 12_000.0,
shadow_maps_enabled: false,
..Default::default()
},
Transform::from_xyz(40.0, 80.0, 30.0).looking_at(Vec3::ZERO, Vec3::Y),
FrameEntity,
))
.id();
self.frame_entities.push(light);
let scene_setup = started.elapsed();
let started = Instant::now();
let rgba = self.capture(&target)?;
@@ -516,8 +687,6 @@ impl BevyBackend {
rgba,
timings: RenderTimings {
clear_previous_frame,
texture_cache_sync,
mesh_cache_sync,
scene_setup,
capture: started.elapsed(),
..Default::default()
@@ -526,7 +695,12 @@ impl BevyBackend {
}
fn new_target(&mut self, width: u32, height: u32) -> RenderTarget {
if let Some(old) = self.target.take() {
if let Some((old_width, old_height, handle)) = &self.target
&& (*old_width, *old_height) == (width, height)
{
return handle.clone().into();
}
if let Some((_, _, old)) = self.target.take() {
self.apps
.main
.world_mut()
@@ -550,7 +724,7 @@ impl BevyBackend {
.world_mut()
.resource_mut::<Assets<Image>>()
.add(image);
self.target = Some(handle.clone());
self.target = Some((width, height, handle.clone()));
handle.into()
}
@@ -600,6 +774,13 @@ impl BevyBackend {
for entity in self.frame_entities.drain(..) {
let _ = self.apps.main.world_mut().despawn(entity);
}
}
fn clear_scene(&mut self) {
self.clear_frame();
for entity in self.scene_entities.drain(..) {
let _ = self.apps.main.world_mut().despawn(entity);
}
let world = self.apps.main.world_mut();
self.texture_handles.clear();
let mut materials = world.resource_mut::<Assets<PbrBevyMaterial>>();
@@ -1231,6 +1412,36 @@ mod tests {
.collect::<Vec<_>>()
);
}
let limits = RenderLimits {
width: 96,
height: 64,
max_triangles: 2,
max_texture_bytes: 64,
max_texture_pixels: 16,
far_distance: 64,
};
renderer
.load_scene(
camera,
&[renderable(Material::BlinnPhong(BlinnPhongMaterial {
diffuse_color_srgb: [30, 220, 60, 255],
fullbright: true,
..Default::default()
}))],
&textures,
limits,
)
.unwrap();
for _ in 0..2 {
let frame = renderer
.render_view(camera, [0, 0, 0, 255], limits)
.unwrap();
assert_eq!(frame.rgba.len(), 96 * 64 * 4);
assert_eq!(frame.timings.command_copy, Duration::ZERO);
assert_eq!(frame.timings.texture_cache_sync, Duration::ZERO);
assert_eq!(frame.timings.mesh_cache_sync, Duration::ZERO);
}
}
fn texture_slot(texture: usize, rotation_radians: f32) -> TextureSlot {