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