gled-core 2.33.0

The engine of gled without a user interface: inputs, timing, rendering, Art-Net/DMX output and storage
Documentation
//! Dedicated thread that renders the LED output and drives Art-Net/DMX.
//!
//! The engine thread owns and edits the `Project`. Once per tick it sends a
//! snapshot holding the engine's shared project (an `Arc` the engine copies
//! on write); the render thread keeps it and renders it at its own pace on
//! its own GPU device (see `gled_gpu::gpu::OutputContext`), independent of
//! any window, compositor and vsync. Nothing in the render loop
//! waits on the engine thread, and nothing it submits can disturb a window's
//! surface.

mod messages;
mod pacing;

pub(crate) use messages::{RenderCommand, RenderFeedback, RenderSnapshot};

use crate::{
    audio::sound_data::SoundData,
    pipeline::{extract_output::ExtractOutput, output::render_output},
    runtime::timing::sample_beat_state,
    storage::{asset::project::Project, collections::Collections},
};
use gled_gpu::{
    effect_state::EffectPipelines,
    gpu::OutputContext,
    scene_render::{EvalContext, ensure_project_output_pipelines},
};
use kanal::{Receiver, Sender, bounded, unbounded};
use pacing::{FramePacer, FrameStats};
use rusty_link::AblLink;
use std::{collections::VecDeque, sync::Arc, time::Duration};
use tracing::{debug, error, trace, warn};
use wgpu::{Adapter, PollType, SubmissionIndex};

/// Snapshots in flight. They are dropped when the channel is full, so a busy
/// render thread (e.g. compiling shaders) never makes the engine thread wait.
const SNAPSHOT_CHANNEL_CAPACITY: usize = 4;
/// Output frames that may be queued on the GPU. Beyond this the render thread
/// waits for the GPU, so output latency stays bounded when the GPU is slower
/// than the frame rate limit.
const MAX_FRAMES_IN_FLIGHT: usize = 2;
/// Frame rate while no project is loaded, so an unlimited setting does not
/// spin a CPU core on empty frames.
const IDLE_FPS: f32 = 60.0;
/// Upper bound for waiting on one frame; a longer wait means a hung GPU.
const GPU_WAIT_TIMEOUT: Duration = Duration::from_secs(1);

/// The engine's handle of the render thread.
#[derive(Debug)]
pub(crate) struct RenderThread {
    snapshots: Sender<RenderSnapshot>,
    commands: Sender<RenderCommand>,
    feedback: Receiver<RenderFeedback>,
}

impl RenderThread {
    /// Create the output device on `adapter` and spawn the `gled:render` thread.
    /// The thread stops when this handle is dropped.
    pub(crate) fn spawn(
        adapter: &Adapter,
        link: Arc<AblLink>,
        extract_output: ExtractOutput,
        fps_limit: f32,
    ) -> anyhow::Result<Self> {
        #[cfg(feature = "profiling")]
        let features = wgpu_profiler::GpuProfiler::ALL_WGPU_TIMER_FEATURES;
        #[cfg(not(feature = "profiling"))]
        let features = wgpu::Features::empty();
        let context = OutputContext::new(adapter, features)?;
        let (snapshot_sender, snapshot_receiver) = bounded(SNAPSHOT_CHANNEL_CAPACITY);
        let (command_sender, command_receiver) = unbounded();
        let (feedback_sender, feedback_receiver) = unbounded();

        std::thread::Builder::new()
            .name("gled:render".to_owned())
            .spawn(move || {
                #[cfg(feature = "profiling")]
                {
                    profiling::register_thread!("render");
                    crate::runtime::init_wgpu_profiler(&context.gpu.device);
                }

                RenderLoop {
                    snapshots: snapshot_receiver,
                    commands: command_receiver,
                    feedback: feedback_sender,
                    context,
                    link,
                    collections: Collections::default(),
                    sound_data: SoundData::default(),
                    extract_output,
                    project: None,
                    pipelines: EffectPipelines::default(),
                    blackout: true,
                    fps_limit,
                    framerate: 0.0,
                    pacer: FramePacer::new(),
                    stats: FrameStats::new(),
                    in_flight: VecDeque::with_capacity(MAX_FRAMES_IN_FLIGHT + 1),
                }
                .run();
            })?;

        Ok(Self {
            snapshots: snapshot_sender,
            commands: command_sender,
            feedback: feedback_receiver,
        })
    }

    /// Hand the current UI state to the render thread. Never blocks.
    pub(crate) fn send_snapshot(&self, snapshot: RenderSnapshot) {
        match self.snapshots.try_send(snapshot) {
            Ok(true) => {}
            Ok(false) => trace!("Render thread is busy, dropped project snapshot"),
            Err(err) => error!("Render thread is gone, could not send snapshot: {err}"),
        }
    }

    pub(crate) fn send_command(&self, command: RenderCommand) {
        if let Err(err) = self.commands.send(command) {
            error!("Render thread is gone, could not send command: {err}");
        }
    }

    /// Next pending report from the render thread, if any.
    pub(crate) fn try_recv_feedback(&self) -> Option<RenderFeedback> {
        self.feedback.try_recv().ok().flatten()
    }
}

/// State owned by the render thread.
struct RenderLoop {
    snapshots: Receiver<RenderSnapshot>,
    commands: Receiver<RenderCommand>,
    feedback: Sender<RenderFeedback>,
    context: OutputContext,
    link: Arc<AblLink>,
    collections: Collections,
    sound_data: SoundData,
    extract_output: ExtractOutput,
    project: Option<Arc<Project>>,
    /// GPU state of the project's effects. It outlives each snapshot, so only
    /// new effects and effects whose animation changed get new pipelines.
    pipelines: EffectPipelines,
    blackout: bool,
    fps_limit: f32,
    framerate: f32,
    pacer: FramePacer,
    stats: FrameStats,
    in_flight: VecDeque<SubmissionIndex>,
}

impl RenderLoop {
    fn run(mut self) {
        loop {
            let fps_limit = if self.project.is_some() {
                self.fps_limit
            } else {
                IDLE_FPS
            };
            self.pacer.wait_for_next_frame(fps_limit);
            self.collections.update();
            self.sound_data.update();
            if !self.receive() {
                debug!("Engine side of the render thread was dropped, stopping");
                return;
            }
            self.frame();
        }
    }

    /// Apply pending commands and the newest snapshot. Returns `false` once the
    /// engine side is gone.
    fn receive(&mut self) -> bool {
        let mut changed = false;
        loop {
            match self.commands.try_recv() {
                Ok(Some(command)) => {
                    self.apply_command(command);
                    changed = true;
                }
                Ok(None) => break,
                Err(_) => return false,
            }
        }

        let mut latest = None;
        loop {
            match self.snapshots.try_recv() {
                Ok(Some(snapshot)) => latest = Some(snapshot),
                Ok(None) => break,
                Err(_) => return false,
            }
        }
        if let Some(snapshot) = latest {
            self.merge_snapshot(snapshot);
            changed = true;
        }

        if changed && let Some(project) = self.project.as_deref() {
            ensure_project_output_pipelines(
                project,
                &mut self.pipelines,
                &self.context,
                &self.collections,
            );
        }
        true
    }

    fn apply_command(&mut self, command: RenderCommand) {
        match command {
            RenderCommand::ReloadShaderCode(animation) => {
                // The UI saved the animation before sending this command, so the
                // collections update is already queued for this instance.
                self.collections.update();
                self.pipelines.invalidate(animation);
            }
        }
    }

    /// Replace the project with the snapshot. The GPU state of its effects
    /// stays in `pipelines`, keyed by scene instance and effect index, so
    /// `ensure_project_output_pipelines` afterwards builds pipelines only for
    /// new effects and effects whose animation changed.
    #[cfg_attr(feature = "profiling", profiling::function)]
    fn merge_snapshot(&mut self, snapshot: RenderSnapshot) {
        let RenderSnapshot {
            project,
            blackout,
            output_config,
            fps_limit,
            layout_generation,
        } = snapshot;
        self.blackout = blackout;
        self.fps_limit = fps_limit;
        self.extract_output.set_config(output_config);

        if self.project.is_some() != project.is_some() {
            debug!(
                "Render thread project changed: {} scene instances",
                project
                    .as_ref()
                    .map_or(0, |project| project.scenes_instances_grid.len())
            );
        }
        if project.is_none() {
            self.pipelines = EffectPipelines::default();
        }
        self.pipelines.follow_layout_generation(layout_generation);
        self.project = project;
    }

    #[cfg_attr(feature = "profiling", profiling::function)]
    fn frame(&mut self) {
        #[cfg(feature = "profiling")]
        {
            crate::runtime::wgpu_profiler()
                .end_frame()
                .expect("Could not end WGPU profiler frame");
            let latest_profiler_results = crate::runtime::wgpu_profiler()
                .process_finished_frame(self.context.gpu.queue.get_timestamp_period());
            wgpu_profiler::puffin::output_frame_to_puffin(
                &mut crate::runtime::PUFFIN_GPU_PROFILER.lock(),
                latest_profiler_results.as_deref().unwrap_or_default(),
            );
            crate::runtime::PUFFIN_GPU_PROFILER.lock().new_frame();
        }

        let beat_state = sample_beat_state(&self.link, self.framerate);
        if let Some(project) = self.project.as_deref() {
            let submission = render_output(
                project,
                &mut self.pipelines,
                &self.context,
                &beat_state,
                self.blackout,
                EvalContext {
                    patch: &project.patch,
                    collections: &self.collections,
                    sound: &self.sound_data,
                },
                &mut self.extract_output,
            );
            self.limit_frames_in_flight(submission);
        }

        if let Some(stats) = self.stats.record_frame() {
            self.framerate = stats.fps;
            self.send_feedback(RenderFeedback::FrameStats(stats));
        }
    }

    /// Wait for the oldest frame on the GPU once more than
    /// `MAX_FRAMES_IN_FLIGHT` are queued.
    fn limit_frames_in_flight(&mut self, submission: SubmissionIndex) {
        self.in_flight.push_back(submission);
        while self.in_flight.len() > MAX_FRAMES_IN_FLIGHT {
            let Some(oldest) = self.in_flight.pop_front() else {
                return;
            };
            if let Err(err) = self.context.gpu.device.poll(PollType::Wait {
                submission_index: Some(oldest),
                timeout: Some(GPU_WAIT_TIMEOUT),
            }) {
                warn!("Waiting for an output frame on the GPU failed: {err}");
            }
        }
    }

    fn send_feedback(&self, feedback: RenderFeedback) {
        if let Err(err) = self.feedback.send(feedback) {
            trace!("Engine side of the render thread is gone: {err}");
        }
    }
}