gled 2.29.0

gled is an application for creating animations and effects on artnet or dmx installations
//! Dedicated thread that renders the LED output and drives Art-Net/DMX.
//!
//! The UI thread owns and edits the `Project`. Once per UI frame it sends a
//! cloned snapshot; the render thread keeps its own copy and renders it at its
//! own pace on its own GPU device (see `pipeline::gpu`), independent of the
//! window, the compositor and vsync. Nothing in the render loop waits on the
//! UI thread, and nothing it submits can disturb the window's surface.

mod messages;
mod pacing;

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

use crate::{
    app::timing::BeatState,
    audio::sound_data::SoundData,
    pipeline::{extract_output::ExtractOutput, gpu::OutputContext},
    storage::{
        asset::{project::Project, scene::effect_state::EffectState},
        collections::Collections,
    },
};
use kanal::{Receiver, Sender, bounded, unbounded};
use pacing::{FramePacer, FrameStats};
use rusty_link::AblLink;
use std::{
    collections::{HashMap, VecDeque},
    sync::Arc,
    time::Duration,
};
use tracing::{debug, error, trace, warn};
use uuid::Uuid;
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 UI 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);

/// UI-side 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> {
        let context = OutputContext::new(adapter)?;
        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::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,
                    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<Project>,
    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!("UI side of the render thread was dropped, stopping");
                return;
            }
            self.frame();
        }
    }

    /// Apply pending commands and the newest snapshot. Returns `false` once the
    /// UI 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_mut() {
            project.ensure_output_pipelines(&self.context, &self.collections);
        }
        true
    }

    fn apply_command(&mut self, command: RenderCommand) {
        let Some(project) = self.project.as_mut() else {
            return;
        };

        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();
                project.invalidate_pipelines(animation);
            }
            RenderCommand::SendPositions => project.send_positions(),
        }
    }

    /// Replace the project with the snapshot, keeping the GPU state of every
    /// effect that still exists. `ensure_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,
        } = snapshot;
        self.blackout = blackout;
        self.fps_limit = fps_limit;
        self.extract_output.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())
            );
        }

        let mut states = self.take_effect_states();
        self.project = project;
        let Some(project) = self.project.as_mut() else {
            return;
        };

        for scene_instance in project.scenes_instances_grid.values_mut() {
            for (index, effect) in scene_instance.scene.effects.iter_mut().enumerate() {
                if let Some(state) = states.remove(&(scene_instance.id, index)) {
                    effect.state = state;
                }
            }
        }
    }

    fn take_effect_states(&mut self) -> HashMap<(Uuid, usize), EffectState> {
        let Some(project) = self.project.as_mut() else {
            return HashMap::new();
        };

        project
            .scenes_instances_grid
            .values_mut()
            .flat_map(|scene_instance| {
                let id = scene_instance.id;
                scene_instance
                    .scene
                    .effects
                    .iter_mut()
                    .enumerate()
                    .map(move |(index, effect)| ((id, index), std::mem::take(&mut effect.state)))
            })
            .collect()
    }

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

        let beat_state = BeatState::sample(&self.link, self.framerate);
        if let Some(project) = self.project.as_mut() {
            let submission = project.render_output(
                &self.context,
                &beat_state,
                self.blackout,
                &self.collections,
                &mut self.extract_output,
                &self.sound_data,
            );
            self.limit_frames_in_flight(submission);
        }

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

    /// 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!("UI side of the render thread is gone: {err}");
        }
    }
}