Skip to main content

phosphor/
headless.rs

1//! Headless (off-screen) rendering support.
2//!
3//! This module provides functionality for rendering waveforms to image buffers without
4//! requiring a display or window.
5//!
6//! # Example
7//!
8//! ```rust
9//! use phosphor::{PhosphorHeadless, gradient::RgbColor};
10//!
11//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
12//! // Create headless renderer
13//! let mut renderer = PhosphorHeadless::new((800, 600).into()).await?;
14//!
15//! // Set waveform data
16//! let samples: Vec<f32> = (0..1000).map(|i| (i as f32 * 0.01).sin()).collect();
17//! renderer.set_waveform_yt(&samples);
18//!
19//! // Configure appearance
20//! renderer.set_intensity(2.0);
21//! renderer.set_xlim(0.0, 999.0);
22//! renderer.set_ylim(-1.1, 1.1);
23//!
24//! // Render to bitmap
25//! let bitmap = renderer.render_to_buffer()?;
26//! // bitmap.buffer contains RGBA pixel data.
27//! # Ok(())
28//! # }
29//! ```
30
31use crate::gradient::RgbColor;
32use crate::renderer::Size;
33use crate::{Renderer, renderer};
34use std::sync;
35
36#[derive(Debug, thiserror::Error)]
37pub enum HeadlessCreationError {
38    #[error("Failed to create adapter")]
39    AdapterCreation(#[from] wgpu::RequestAdapterError),
40    #[error("Unable to request device")]
41    DeviceCreation(#[from] wgpu::RequestDeviceError),
42    #[error("Texture width must be a multiple of {alignment}, got {actual}")]
43    InvalidRowLength { alignment: u32, actual: u32 },
44}
45
46#[derive(Debug)]
47struct Context {
48    device: wgpu::Device,
49    queue: wgpu::Queue,
50}
51
52/// RGBA bitmap image data.
53///
54/// Contains the raw pixel data from a rendered waveform in RGBA format.
55/// Each pixel is represented by 4 consecutive bytes (R, G, B, A) in sRGB color space.
56#[derive(Clone, Debug)]
57pub struct Bitmap {
58    /// Image dimensions in pixels
59    pub size: Size,
60    /// Raw RGBA pixel data (4 bytes per pixel)
61    pub buffer: Vec<u8>,
62}
63
64impl Context {
65    async fn new() -> Result<Self, HeadlessCreationError> {
66        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
67            backends: wgpu::Backends::all(),
68            ..Default::default()
69        });
70
71        let adapter = instance
72            .request_adapter(&wgpu::RequestAdapterOptions {
73                power_preference: wgpu::PowerPreference::default(),
74                compatible_surface: None,
75                force_fallback_adapter: false,
76            })
77            .await?;
78
79        let (device, queue) = adapter
80            .request_device(&wgpu::DeviceDescriptor {
81                required_features: wgpu::Features::empty(),
82                required_limits: wgpu::Limits::default(),
83                label: None,
84                memory_hints: Default::default(),
85                trace: wgpu::Trace::default(),
86            })
87            .await
88            .map_err(HeadlessCreationError::from)?;
89
90        Ok(Context { device, queue })
91    }
92}
93
94#[derive(Debug, thiserror::Error)]
95pub enum RenderError {
96    #[error("Failed to render intermediate texture")]
97    Intermediate(#[source] renderer::Error),
98    #[error("Error during post-processing step")]
99    Postprocessing(#[source] renderer::Error),
100    #[error("Error during readback")]
101    Readback(#[source] wgpu::BufferAsyncError),
102}
103
104/// Headless waveform renderer for off-screen image generation.
105///
106/// Provides the same rendering capabilities as [`Renderer`] but outputs to a bitmap
107/// instead of a render target.
108///
109/// # Example
110///
111/// ```rust
112/// use phosphor::{PhosphorHeadless, gradient::RgbColor};
113///
114/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
115/// let mut renderer = PhosphorHeadless::new((800, 600).into()).await?;
116///
117/// // Generate sine wave data
118/// let samples: Vec<f32> = (0..1000)
119///     .map(|i| (i as f32 * 0.02).sin())
120///     .collect();
121/// renderer.set_waveform_yt(&samples);
122///
123/// // Set up scope-like green gradient
124/// let gradient = vec![
125///     (0.0, RgbColor { r: 0.0, g: 0.0, b: 0.0 }),
126///     (1.0, RgbColor { r: 0.0, g: 1.0, b: 0.0 }),
127/// ];
128/// renderer.set_lut(gradient);
129///
130/// // Render to bitmap
131/// let bitmap = renderer.render_to_buffer()?;
132/// // Save bitmap.buffer to file or process further
133/// # Ok(())
134/// # }
135/// ```
136#[derive(Debug)]
137pub struct PhosphorHeadless {
138    device: wgpu::Device,
139    queue: wgpu::Queue,
140    renderer: Renderer,
141    output_texture: wgpu::Texture,
142    output_buffer: wgpu::Buffer,
143}
144
145impl PhosphorHeadless {
146    /// Creates a new headless renderer.
147    ///
148    /// Initializes a WebGPU context without requiring a window or display surface.
149    /// The renderer will output to RGBA8 sRGB format.
150    ///
151    /// # Arguments
152    ///
153    /// * `size` - Output image dimensions in pixels
154    ///
155    /// # Errors
156    ///
157    /// Returns `HeadlessCreationError` if WebGPU adapter or device creation fails.
158    pub async fn new(size: Size<u32>) -> Result<Self, HeadlessCreationError> {
159        let Context { device, queue } = Context::new().await?;
160
161        let bytes_per_pixel = 4;
162        let bytes_per_row = bytes_per_pixel * size.width;
163        if bytes_per_row % wgpu::COPY_BYTES_PER_ROW_ALIGNMENT != 0 {
164            return Err(HeadlessCreationError::InvalidRowLength {
165                alignment: wgpu::COPY_BYTES_PER_ROW_ALIGNMENT / bytes_per_pixel,
166                actual: size.width,
167            });
168        }
169
170        let renderer = Renderer::new(&device, &wgpu::TextureFormat::Rgba8UnormSrgb, size);
171
172        let (output_texture, output_buffer) = Self::create_output_buffer_and_texture(&device, size);
173
174        Ok(PhosphorHeadless {
175            device,
176            queue,
177            renderer,
178            output_buffer,
179            output_texture,
180        })
181    }
182
183    fn create_output_buffer_and_texture(
184        device: &wgpu::Device,
185        size: Size,
186    ) -> (wgpu::Texture, wgpu::Buffer) {
187        // output texture for readback
188        let output_texture = device.create_texture(&wgpu::TextureDescriptor {
189            size: wgpu::Extent3d {
190                width: size.width,
191                height: size.height,
192                depth_or_array_layers: 1,
193            },
194            mip_level_count: 1,
195            sample_count: 1,
196            dimension: wgpu::TextureDimension::D2,
197            format: wgpu::TextureFormat::Rgba8UnormSrgb,
198            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
199            label: Some("output_texture"),
200            view_formats: &[],
201        });
202
203        // buffer to read back texture data
204        let output_buffer = device.create_buffer(&wgpu::BufferDescriptor {
205            size: (size.width * size.height * 4) as u64, // 4 bytes per pixel (RGBA)
206            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
207            label: Some("output_buffer"),
208            mapped_at_creation: false,
209        });
210
211        (output_texture, output_buffer)
212    }
213
214    /// See [`Renderer::resize`].
215    pub fn resize(&mut self, size: Size) -> renderer::Result<()> {
216        (self.output_texture, self.output_buffer) =
217            Self::create_output_buffer_and_texture(&self.device, size);
218
219        self.renderer.resize(size)
220    }
221
222    /// See [`Renderer::set_xlim`].
223    pub fn set_xlim(&mut self, left: f32, right: f32) {
224        self.renderer.set_xlim(left, right)
225    }
226
227    /// See [`Renderer::set_ylim`].
228    pub fn set_ylim(&mut self, lower: f32, upper: f32) {
229        self.renderer.set_ylim(lower, upper)
230    }
231
232    /// See [`Renderer::set_intensity`].
233    pub fn set_intensity(&mut self, value: f32) {
234        self.renderer.set_intensity(value)
235    }
236
237    /// See [`Renderer::set_gamma`].
238    pub fn set_gamma(&mut self, value: f32) {
239        self.renderer.set_gamma(value)
240    }
241
242    /// See [`Renderer::set_beam_width`].
243    pub fn set_beam_width(&mut self, width: f32) {
244        self.renderer.set_beam_width(width)
245    }
246
247    /// See [`Renderer::set_waveform_yt`].
248    pub fn set_waveform_yt(&mut self, waveform: &[f32]) {
249        self.renderer.set_waveform_yt(waveform)
250    }
251
252    /// See [`Renderer::set_waveform_xy`].
253    pub fn set_waveform_xy(&mut self, waveform: &[[f32; 2]]) {
254        self.renderer.set_waveform_xy(waveform)
255    }
256
257    /// See [`Renderer::set_lut`].
258    pub fn set_lut(&mut self, lut: impl IntoIterator<Item = (f32, RgbColor<f32>)>) {
259        self.renderer.set_lut(lut)
260    }
261
262    /// See [`Renderer::set_decay`].
263    pub fn set_decay(&mut self, decay: renderer::Decay) {
264        self.renderer.set_decay(decay)
265    }
266}
267
268/// The following methods require feature flag `auto-intensity`.
269#[cfg(feature = "auto-intensity")]
270impl PhosphorHeadless {
271    /// See [`Renderer::enable_auto_intensity`].
272    pub fn auto_intensity(&mut self, value: bool) {
273        self.renderer.enable_auto_intensity(value)
274    }
275
276    /// See [Renderer::reset_auto_intensity()].
277    pub fn reset_auto_intensity(&self, queue: &wgpu::Queue) -> renderer::Result<()> {
278        self.renderer.reset_auto_intensity(queue)
279    }
280}
281impl PhosphorHeadless {
282    /// Renders the waveform to a bitmap.
283    ///
284    /// Performs the complete rendering pipeline and reads the result back from the GPU.
285    /// This is a synchronous operation that will block until rendering is complete.
286    ///
287    /// # Errors
288    ///
289    /// Returns `RenderError` if any step of the rendering pipeline fails.
290    ///
291    pub fn render_to_buffer(&mut self) -> Result<Bitmap, RenderError> {
292        self.submit_render()?;
293        let buffer_owned: Vec<u8> = self.readback_buffer()?.to_vec();
294        Ok(Bitmap {
295            buffer: buffer_owned,
296            size: self.renderer.size(),
297        })
298    }
299
300    fn submit_render(&mut self) -> Result<(), RenderError> {
301        let intermediate_cmd = self
302            .renderer
303            .render_intermediate(&self.queue)
304            .map_err(RenderError::Intermediate)?;
305
306        let mut encoder = self
307            .device
308            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
309                label: Some("render_encoder"),
310            });
311
312        // Final render pass to output texture
313        {
314            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
315                label: Some("render_pass"),
316                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
317                    view: &self
318                        .output_texture
319                        .create_view(&wgpu::TextureViewDescriptor::default()),
320                    resolve_target: None,
321                    ops: wgpu::Operations {
322                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
323                        store: wgpu::StoreOp::Store,
324                    },
325                })],
326                depth_stencil_attachment: None,
327                timestamp_writes: None,
328                occlusion_query_set: None,
329            });
330
331            self.renderer
332                .render(&mut render_pass)
333                .map_err(RenderError::Postprocessing)?
334        }
335
336        // Copy texture to buffer for readback
337        encoder.copy_texture_to_buffer(
338            wgpu::TexelCopyTextureInfo {
339                texture: &self.output_texture,
340                mip_level: 0,
341                origin: wgpu::Origin3d::ZERO,
342                aspect: wgpu::TextureAspect::All,
343            },
344            wgpu::TexelCopyBufferInfo {
345                buffer: &self.output_buffer,
346                layout: wgpu::TexelCopyBufferLayout {
347                    offset: 0,
348                    bytes_per_row: Some(self.renderer.size().width * 4),
349                    rows_per_image: Some(self.renderer.size().height),
350                },
351            },
352            wgpu::Extent3d {
353                width: self.renderer.size().width,
354                height: self.renderer.size().height,
355                depth_or_array_layers: 1,
356            },
357        );
358
359        // Submit all commands
360        self.queue.submit([intermediate_cmd, encoder.finish()]);
361        Ok(())
362    }
363
364    fn readback_buffer(&self) -> Result<Vec<u8>, RenderError> {
365        let data: Vec<_>;
366        {
367            let buffer_slice: wgpu::BufferSlice = self.output_buffer.slice(..);
368            let (sender, receiver) = sync::mpsc::channel();
369            buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
370                sender.send(result).unwrap()
371            });
372
373            // wait for the mapping to complete (will block until the rendering has finished)
374            self.device.poll(wgpu::PollType::Wait).unwrap();
375            receiver
376                .try_recv()
377                .unwrap()
378                .map_err(RenderError::Readback)?;
379
380            data = buffer_slice.get_mapped_range().to_vec();
381        }
382
383        self.output_buffer.unmap();
384        Ok(data)
385    }
386}