henad-compute 0.3.0

CPU and GPU engines that run Henad models, with their sim threads and parallel primitives.
Documentation
//! Drivers of a sim loop, and the one place the native and browser ways of driving one differ.
//!
//! A loop says what work is due and when it next wants calling. A [`Driver`] decides how to wait:
//! its own thread on native, the host's frame loop in a browser.

use std::time::Duration;

#[cfg(any(target_arch = "wasm32", test))]
mod frame;
#[cfg(not(target_arch = "wasm32"))]
mod thread;

#[cfg(target_arch = "wasm32")]
pub use frame::Driver;
#[cfg(not(target_arch = "wasm32"))]
pub use thread::Driver;

/// Whether the target can spawn an OS thread. A browser cannot, even with atomics.
pub const CAN_SPAWN_THREADS: bool = cfg!(not(target_arch = "wasm32"));

use crate::snapshot::Snapshot;
use std::sync::{Arc, Mutex};

/// Wall-clock time in milliseconds that one pump may spend.
///
/// The frame driver stops pumping once a frame has spent this much, and a loop that sizes its own
/// batches aims to fill it. Two different numbers would leave a frame running two batches.
pub const PUMP_BUDGET_MS: f64 = 6.0;

/// Time between two publishes while a loop runs to a tick.
///
/// It is longer than the interval while playing, since each publish copies the whole view.
pub const RUN_TO_PUBLISH_INTERVAL: Duration = Duration::from_millis(100);

/// Longest time one publish may spend preparing its view, including a network's layout.
///
/// In a browser, the view is prepared inside the frame pump, so going past the pump's budget stalls the frame.
#[cfg(target_arch = "wasm32")]
pub const MAX_VIEW_BUDGET_MS: f32 = PUMP_BUDGET_MS as f32;

/// Longest time one publish may spend preparing its view, including a network's layout.
#[cfg(not(target_arch = "wasm32"))]
pub const MAX_VIEW_BUDGET_MS: f32 = f32::INFINITY;

/// Next pump that a loop requests after one [`SimLoop::pump`].
#[derive(Debug)]
pub enum Pace {
    /// No pump until a command arrives.
    Idle,
    /// Another pump as soon as the driver can.
    Now,
    /// Another pump after this long, unless a command arrives first.
    After(Duration),
}

/// A simulation loop, independent of how it is driven.
///
/// `pump` does whatever is due now and says when the next work falls due. Everything about
/// blocking, waiting and frame budgets belongs to the driver.
pub trait SimLoop {
    /// Command a host sends the loop.
    type Command;

    /// Handles one command, and returns whether the loop is finished and the driver should stop.
    fn handle_command(&mut self, cmd: Self::Command) -> bool;

    /// Does whatever is due now, and returns when the loop next wants a pump.
    fn pump(&mut self) -> Pace;

    /// Runs once before the first pump.
    fn start(&mut self) {}
}

/// Slot where a loop leaves a snapshot for the host to pick up.
///
/// `fresh` is the newest publish waiting to be taken, `spare` a consumed one returned for its
/// buffers. A `fresh` nobody took is stale by definition, so it becomes the next spare.
#[derive(Debug, Default)]
pub struct SnapshotSlot {
    fresh: Option<Snapshot>,
    spare: Option<Snapshot>,
}

/// Snapshot slot shared between a loop and its host. On the web one thread holds both ends, and the slot is never
/// sent anywhere.
pub type SharedSlot = Arc<Mutex<SnapshotSlot>>;

impl SnapshotSlot {
    /// Returns an empty slot, for a loop that publishes its own first snapshot from [`SimLoop::start`].
    ///
    /// Building one here would mean reporting stats nothing has read back yet.
    #[cfg_attr(
        all(target_arch = "wasm32", target_feature = "atomics"),
        expect(
            clippy::arc_with_non_send_sync,
            reason = "a snapshot holds wgpu handles, which atomics leave unsendable"
        )
    )]
    pub fn empty() -> SharedSlot {
        Arc::new(Mutex::new(Self::default()))
    }

    /// Returns a slot holding `snapshot` as its first publish.
    #[cfg_attr(
        all(target_arch = "wasm32", target_feature = "atomics"),
        expect(
            clippy::arc_with_non_send_sync,
            reason = "a snapshot holds wgpu handles, which atomics leave unsendable"
        )
    )]
    pub fn with_initial(snapshot: Snapshot) -> SharedSlot {
        Arc::new(Mutex::new(Self {
            fresh: Some(snapshot),
            spare: None,
        }))
    }
}

/// Takes the latest publish, or `None` when nothing new has been published since the last take.
pub fn take_snapshot(slot: &SharedSlot) -> Option<Snapshot> {
    slot.lock().ok()?.fresh.take()
}

/// Stores a taken snapshot as the spare for the next publish to refill.
///
/// This is only an optimisation. Dropping a snapshot instead only means the next publish allocates.
pub fn recycle(slot: &SharedSlot, snapshot: Snapshot) {
    if let Ok(mut slot) = slot.lock() {
        slot.spare = Some(snapshot);
    }
}

/// Takes the spare buffer, if the host returned one.
pub fn claim_spare(slot: &SharedSlot) -> Option<Snapshot> {
    slot.lock().ok()?.spare.take()
}

/// Passes a freshly built snapshot to the slot.
pub fn publish(slot: &SharedSlot, snapshot: Snapshot) {
    if let Ok(mut slot) = slot.lock() {
        slot.spare = slot.fresh.replace(snapshot);
    }
}