phosphor-crt 0.1.0

A real-time plotter of waveforms, imitating oscillscope CRTs
Documentation
//! Headless (off-screen) rendering support.
//!
//! This module provides functionality for rendering waveforms to image buffers without
//! requiring a display or window.
//!
//! # Example
//!
//! ```rust
//! use phosphor::{PhosphorHeadless, gradient::RgbColor};
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! // Create headless renderer
//! let mut renderer = PhosphorHeadless::new((800, 600).into()).await?;
//!
//! // Set waveform data
//! let samples: Vec<f32> = (0..1000).map(|i| (i as f32 * 0.01).sin()).collect();
//! renderer.set_waveform_yt(&samples);
//!
//! // Configure appearance
//! renderer.set_intensity(2.0);
//! renderer.set_xlim(0.0, 999.0);
//! renderer.set_ylim(-1.1, 1.1);
//!
//! // Render to bitmap
//! let bitmap = renderer.render_to_buffer()?;
//! // bitmap.buffer contains RGBA pixel data.
//! # Ok(())
//! # }
//! ```

use crate::gradient::RgbColor;
use crate::renderer::Size;
use crate::{Renderer, renderer};
use std::sync;

#[derive(Debug, thiserror::Error)]
pub enum HeadlessCreationError {
    #[error("Failed to create adapter")]
    AdapterCreation(#[from] wgpu::RequestAdapterError),
    #[error("Unable to request device")]
    DeviceCreation(#[from] wgpu::RequestDeviceError),
    #[error("Texture width must be a multiple of {alignment}, got {actual}")]
    InvalidRowLength { alignment: u32, actual: u32 },
}

#[derive(Debug)]
struct Context {
    device: wgpu::Device,
    queue: wgpu::Queue,
}

/// RGBA bitmap image data.
///
/// Contains the raw pixel data from a rendered waveform in RGBA format.
/// Each pixel is represented by 4 consecutive bytes (R, G, B, A) in sRGB color space.
#[derive(Clone, Debug)]
pub struct Bitmap {
    /// Image dimensions in pixels
    pub size: Size,
    /// Raw RGBA pixel data (4 bytes per pixel)
    pub buffer: Vec<u8>,
}

impl Context {
    async fn new() -> Result<Self, HeadlessCreationError> {
        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
            backends: wgpu::Backends::all(),
            ..Default::default()
        });

        let adapter = instance
            .request_adapter(&wgpu::RequestAdapterOptions {
                power_preference: wgpu::PowerPreference::default(),
                compatible_surface: None,
                force_fallback_adapter: false,
            })
            .await?;

        let (device, queue) = adapter
            .request_device(&wgpu::DeviceDescriptor {
                required_features: wgpu::Features::empty(),
                required_limits: wgpu::Limits::default(),
                label: None,
                memory_hints: Default::default(),
                trace: wgpu::Trace::default(),
            })
            .await
            .map_err(HeadlessCreationError::from)?;

        Ok(Context { device, queue })
    }
}

#[derive(Debug, thiserror::Error)]
pub enum RenderError {
    #[error("Failed to render intermediate texture")]
    Intermediate(#[source] renderer::Error),
    #[error("Error during post-processing step")]
    Postprocessing(#[source] renderer::Error),
    #[error("Error during readback")]
    Readback(#[source] wgpu::BufferAsyncError),
}

/// Headless waveform renderer for off-screen image generation.
///
/// Provides the same rendering capabilities as [`Renderer`] but outputs to a bitmap
/// instead of a render target.
///
/// # Example
///
/// ```rust
/// use phosphor::{PhosphorHeadless, gradient::RgbColor};
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let mut renderer = PhosphorHeadless::new((800, 600).into()).await?;
///
/// // Generate sine wave data
/// let samples: Vec<f32> = (0..1000)
///     .map(|i| (i as f32 * 0.02).sin())
///     .collect();
/// renderer.set_waveform_yt(&samples);
///
/// // Set up scope-like green gradient
/// let gradient = vec![
///     (0.0, RgbColor { r: 0.0, g: 0.0, b: 0.0 }),
///     (1.0, RgbColor { r: 0.0, g: 1.0, b: 0.0 }),
/// ];
/// renderer.set_lut(gradient);
///
/// // Render to bitmap
/// let bitmap = renderer.render_to_buffer()?;
/// // Save bitmap.buffer to file or process further
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct PhosphorHeadless {
    device: wgpu::Device,
    queue: wgpu::Queue,
    renderer: Renderer,
    output_texture: wgpu::Texture,
    output_buffer: wgpu::Buffer,
}

impl PhosphorHeadless {
    /// Creates a new headless renderer.
    ///
    /// Initializes a WebGPU context without requiring a window or display surface.
    /// The renderer will output to RGBA8 sRGB format.
    ///
    /// # Arguments
    ///
    /// * `size` - Output image dimensions in pixels
    ///
    /// # Errors
    ///
    /// Returns `HeadlessCreationError` if WebGPU adapter or device creation fails.
    pub async fn new(size: Size<u32>) -> Result<Self, HeadlessCreationError> {
        let Context { device, queue } = Context::new().await?;

        let bytes_per_pixel = 4;
        let bytes_per_row = bytes_per_pixel * size.width;
        if bytes_per_row % wgpu::COPY_BYTES_PER_ROW_ALIGNMENT != 0 {
            return Err(HeadlessCreationError::InvalidRowLength {
                alignment: wgpu::COPY_BYTES_PER_ROW_ALIGNMENT / bytes_per_pixel,
                actual: size.width,
            });
        }

        let renderer = Renderer::new(&device, &wgpu::TextureFormat::Rgba8UnormSrgb, size);

        let (output_texture, output_buffer) = Self::create_output_buffer_and_texture(&device, size);

        Ok(PhosphorHeadless {
            device,
            queue,
            renderer,
            output_buffer,
            output_texture,
        })
    }

    fn create_output_buffer_and_texture(
        device: &wgpu::Device,
        size: Size,
    ) -> (wgpu::Texture, wgpu::Buffer) {
        // output texture for readback
        let output_texture = device.create_texture(&wgpu::TextureDescriptor {
            size: wgpu::Extent3d {
                width: size.width,
                height: size.height,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Rgba8UnormSrgb,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
            label: Some("output_texture"),
            view_formats: &[],
        });

        // buffer to read back texture data
        let output_buffer = device.create_buffer(&wgpu::BufferDescriptor {
            size: (size.width * size.height * 4) as u64, // 4 bytes per pixel (RGBA)
            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
            label: Some("output_buffer"),
            mapped_at_creation: false,
        });

        (output_texture, output_buffer)
    }

    /// See [`Renderer::resize`].
    pub fn resize(&mut self, size: Size) -> renderer::Result<()> {
        (self.output_texture, self.output_buffer) =
            Self::create_output_buffer_and_texture(&self.device, size);

        self.renderer.resize(size)
    }

    /// See [`Renderer::set_xlim`].
    pub fn set_xlim(&mut self, left: f32, right: f32) {
        self.renderer.set_xlim(left, right)
    }

    /// See [`Renderer::set_ylim`].
    pub fn set_ylim(&mut self, lower: f32, upper: f32) {
        self.renderer.set_ylim(lower, upper)
    }

    /// See [`Renderer::set_intensity`].
    pub fn set_intensity(&mut self, value: f32) {
        self.renderer.set_intensity(value)
    }

    /// See [`Renderer::set_gamma`].
    pub fn set_gamma(&mut self, value: f32) {
        self.renderer.set_gamma(value)
    }

    /// See [`Renderer::set_beam_width`].
    pub fn set_beam_width(&mut self, width: f32) {
        self.renderer.set_beam_width(width)
    }

    /// See [`Renderer::set_waveform_yt`].
    pub fn set_waveform_yt(&mut self, waveform: &[f32]) {
        self.renderer.set_waveform_yt(waveform)
    }

    /// See [`Renderer::set_waveform_xy`].
    pub fn set_waveform_xy(&mut self, waveform: &[[f32; 2]]) {
        self.renderer.set_waveform_xy(waveform)
    }

    /// See [`Renderer::set_lut`].
    pub fn set_lut(&mut self, lut: impl IntoIterator<Item = (f32, RgbColor<f32>)>) {
        self.renderer.set_lut(lut)
    }

    /// See [`Renderer::set_decay`].
    pub fn set_decay(&mut self, decay: renderer::Decay) {
        self.renderer.set_decay(decay)
    }
}

/// The following methods require feature flag `auto-intensity`.
#[cfg(feature = "auto-intensity")]
impl PhosphorHeadless {
    /// See [`Renderer::enable_auto_intensity`].
    pub fn auto_intensity(&mut self, value: bool) {
        self.renderer.enable_auto_intensity(value)
    }

    /// See [Renderer::reset_auto_intensity()].
    pub fn reset_auto_intensity(&self, queue: &wgpu::Queue) -> renderer::Result<()> {
        self.renderer.reset_auto_intensity(queue)
    }
}
impl PhosphorHeadless {
    /// Renders the waveform to a bitmap.
    ///
    /// Performs the complete rendering pipeline and reads the result back from the GPU.
    /// This is a synchronous operation that will block until rendering is complete.
    ///
    /// # Errors
    ///
    /// Returns `RenderError` if any step of the rendering pipeline fails.
    ///
    pub fn render_to_buffer(&mut self) -> Result<Bitmap, RenderError> {
        self.submit_render()?;
        let buffer_owned: Vec<u8> = self.readback_buffer()?.to_vec();
        Ok(Bitmap {
            buffer: buffer_owned,
            size: self.renderer.size(),
        })
    }

    fn submit_render(&mut self) -> Result<(), RenderError> {
        let intermediate_cmd = self
            .renderer
            .render_intermediate(&self.queue)
            .map_err(RenderError::Intermediate)?;

        let mut encoder = self
            .device
            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
                label: Some("render_encoder"),
            });

        // Final render pass to output texture
        {
            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("render_pass"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: &self
                        .output_texture
                        .create_view(&wgpu::TextureViewDescriptor::default()),
                    resolve_target: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
                        store: wgpu::StoreOp::Store,
                    },
                })],
                depth_stencil_attachment: None,
                timestamp_writes: None,
                occlusion_query_set: None,
            });

            self.renderer
                .render(&mut render_pass)
                .map_err(RenderError::Postprocessing)?
        }

        // Copy texture to buffer for readback
        encoder.copy_texture_to_buffer(
            wgpu::TexelCopyTextureInfo {
                texture: &self.output_texture,
                mip_level: 0,
                origin: wgpu::Origin3d::ZERO,
                aspect: wgpu::TextureAspect::All,
            },
            wgpu::TexelCopyBufferInfo {
                buffer: &self.output_buffer,
                layout: wgpu::TexelCopyBufferLayout {
                    offset: 0,
                    bytes_per_row: Some(self.renderer.size().width * 4),
                    rows_per_image: Some(self.renderer.size().height),
                },
            },
            wgpu::Extent3d {
                width: self.renderer.size().width,
                height: self.renderer.size().height,
                depth_or_array_layers: 1,
            },
        );

        // Submit all commands
        self.queue.submit([intermediate_cmd, encoder.finish()]);
        Ok(())
    }

    fn readback_buffer(&self) -> Result<Vec<u8>, RenderError> {
        let data: Vec<_>;
        {
            let buffer_slice: wgpu::BufferSlice = self.output_buffer.slice(..);
            let (sender, receiver) = sync::mpsc::channel();
            buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
                sender.send(result).unwrap()
            });

            // wait for the mapping to complete (will block until the rendering has finished)
            self.device.poll(wgpu::PollType::Wait).unwrap();
            receiver
                .try_recv()
                .unwrap()
                .map_err(RenderError::Readback)?;

            data = buffer_slice.get_mapped_range().to_vec();
        }

        self.output_buffer.unmap();
        Ok(data)
    }
}