Skip to main content

arcane_core/renderer/
test_harness.rs

1//! GPU test harness for headless rendering tests.
2//!
3//! Provides utilities for creating a GPU context without a window,
4//! rendering to textures, and reading back pixel data for verification.
5
6use anyhow::{Context, Result};
7
8use super::camera::Camera2D;
9use super::geometry::GeometryBatch;
10use super::postprocess::PostProcessPipeline;
11use super::radiance::RadiancePipeline;
12use super::rendertarget::RenderTargetStore;
13use super::sdf::SdfPipelineStore;
14use super::shader::ShaderStore;
15use super::sprite::SpritePipeline;
16use super::texture::TextureStore;
17
18/// Headless GPU context for testing (no window/surface required).
19pub struct TestGpu {
20    pub device: wgpu::Device,
21    pub queue: wgpu::Queue,
22    pub format: wgpu::TextureFormat,
23}
24
25/// A GPU context for headless testing that provides the same interface
26/// as GpuContext but without requiring a window surface.
27pub struct TestGpuContext {
28    pub device: wgpu::Device,
29    pub queue: wgpu::Queue,
30    pub config: wgpu::SurfaceConfiguration,
31}
32
33impl TestGpuContext {
34    /// Create from TestGpu with default 64x64 dimensions.
35    pub fn from_test_gpu(gpu: &TestGpu) -> Self {
36        Self::from_test_gpu_sized(gpu, 64, 64)
37    }
38
39    /// Create from TestGpu with specified dimensions.
40    pub fn from_test_gpu_sized(gpu: &TestGpu, width: u32, height: u32) -> Self {
41        Self {
42            device: gpu.device.clone(),
43            queue: gpu.queue.clone(),
44            config: wgpu::SurfaceConfiguration {
45                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
46                format: gpu.format,
47                width,
48                height,
49                present_mode: wgpu::PresentMode::AutoVsync,
50                alpha_mode: wgpu::CompositeAlphaMode::Auto,
51                view_formats: vec![],
52                desired_maximum_frame_latency: 2,
53            },
54        }
55    }
56}
57
58impl TestGpu {
59    /// Create a headless GPU context for testing.
60    /// Returns None if no suitable GPU adapter is available.
61    pub fn new() -> Option<Self> {
62        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
63            backends: wgpu::Backends::all(),
64            ..Default::default()
65        });
66
67        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
68            power_preference: wgpu::PowerPreference::default(),
69            compatible_surface: None, // headless
70            force_fallback_adapter: false,
71        }))?;
72
73        let (device, queue) = pollster::block_on(adapter.request_device(
74            &wgpu::DeviceDescriptor {
75                label: Some("test_device"),
76                required_features: wgpu::Features::empty(),
77                required_limits: wgpu::Limits::default(),
78                ..Default::default()
79            },
80            None,
81        ))
82        .ok()?;
83
84        Some(Self {
85            device,
86            queue,
87            format: wgpu::TextureFormat::Rgba8Unorm,
88        })
89    }
90
91    /// Create a render target texture that can be read back.
92    pub fn create_target(&self, width: u32, height: u32) -> TestRenderTarget {
93        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
94            label: Some("test_target"),
95            size: wgpu::Extent3d {
96                width,
97                height,
98                depth_or_array_layers: 1,
99            },
100            mip_level_count: 1,
101            sample_count: 1,
102            dimension: wgpu::TextureDimension::D2,
103            format: self.format,
104            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
105            view_formats: &[],
106        });
107
108        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
109
110        // Buffer for reading back pixels (4 bytes per pixel, aligned to 256)
111        let bytes_per_row = ((width * 4 + 255) / 256) * 256;
112        let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
113            label: Some("test_readback"),
114            size: (bytes_per_row * height) as u64,
115            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
116            mapped_at_creation: false,
117        });
118
119        TestRenderTarget {
120            texture,
121            view,
122            buffer,
123            width,
124            height,
125            bytes_per_row,
126        }
127    }
128
129    // ── Pipeline factory methods ──────────────────────────────────────────────
130
131    /// Create a SpritePipeline for headless testing.
132    pub fn create_sprite_pipeline(&self) -> SpritePipeline {
133        SpritePipeline::new_headless(&self.device, &self.queue, self.format)
134    }
135
136    /// Create a TextureStore for headless testing.
137    pub fn create_texture_store(&self) -> TextureStore {
138        TextureStore::new()
139    }
140
141    /// Create a GeometryBatch for headless testing.
142    pub fn create_geometry_batch(&self) -> GeometryBatch {
143        GeometryBatch::new_headless(&self.device, self.format)
144    }
145
146    /// Create a PostProcessPipeline for headless testing.
147    pub fn create_postprocess(&self) -> PostProcessPipeline {
148        PostProcessPipeline::new_headless(&self.device, self.format)
149    }
150
151    /// Create a ShaderStore for headless testing.
152    pub fn create_shader_store(&self) -> ShaderStore {
153        ShaderStore::new_headless(&self.device, self.format)
154    }
155
156    /// Create a RenderTargetStore for headless testing.
157    pub fn create_render_target_store(&self) -> RenderTargetStore {
158        RenderTargetStore::new()
159    }
160
161    /// Create an SdfPipelineStore for headless testing.
162    pub fn create_sdf_pipeline(&self) -> SdfPipelineStore {
163        SdfPipelineStore::new_headless(&self.device, self.format)
164    }
165
166    /// Create a RadiancePipeline for headless testing.
167    pub fn create_radiance_pipeline(&self) -> RadiancePipeline {
168        RadiancePipeline::new_headless(&self.device, self.format)
169    }
170
171    /// Create a default Camera2D for testing.
172    /// Camera position is top-left origin (0,0), viewing the area (0..width, 0..height).
173    pub fn create_camera(&self, width: f32, height: f32) -> Camera2D {
174        Camera2D {
175            x: 0.0,
176            y: 0.0,
177            zoom: 1.0,
178            viewport_size: [width, height],
179            ..Camera2D::default()
180        }
181    }
182}
183
184/// A render target that can be read back to CPU memory.
185pub struct TestRenderTarget {
186    pub texture: wgpu::Texture,
187    pub view: wgpu::TextureView,
188    buffer: wgpu::Buffer,
189    pub width: u32,
190    pub height: u32,
191    bytes_per_row: u32,
192}
193
194impl TestRenderTarget {
195    /// Read back the rendered pixels as RGBA bytes.
196    /// Call this after submitting render commands.
197    pub fn read_pixels(&self, gpu: &TestGpu) -> Result<Vec<u8>> {
198        let mut encoder = gpu.device.create_command_encoder(
199            &wgpu::CommandEncoderDescriptor { label: Some("readback_encoder") },
200        );
201
202        encoder.copy_texture_to_buffer(
203            wgpu::TexelCopyTextureInfo {
204                texture: &self.texture,
205                mip_level: 0,
206                origin: wgpu::Origin3d::ZERO,
207                aspect: wgpu::TextureAspect::All,
208            },
209            wgpu::TexelCopyBufferInfo {
210                buffer: &self.buffer,
211                layout: wgpu::TexelCopyBufferLayout {
212                    offset: 0,
213                    bytes_per_row: Some(self.bytes_per_row),
214                    rows_per_image: Some(self.height),
215                },
216            },
217            wgpu::Extent3d {
218                width: self.width,
219                height: self.height,
220                depth_or_array_layers: 1,
221            },
222        );
223
224        gpu.queue.submit(std::iter::once(encoder.finish()));
225
226        // Map the buffer and read pixels
227        let buffer_slice = self.buffer.slice(..);
228        let (tx, rx) = std::sync::mpsc::channel();
229        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
230            tx.send(result).unwrap();
231        });
232        gpu.device.poll(wgpu::Maintain::Wait);
233        rx.recv().unwrap().context("Failed to map buffer")?;
234
235        let data = buffer_slice.get_mapped_range();
236
237        // Copy data, removing row padding
238        let mut pixels = Vec::with_capacity((self.width * self.height * 4) as usize);
239        for y in 0..self.height {
240            let start = (y * self.bytes_per_row) as usize;
241            let end = start + (self.width * 4) as usize;
242            pixels.extend_from_slice(&data[start..end]);
243        }
244
245        drop(data);
246        self.buffer.unmap();
247
248        Ok(pixels)
249    }
250
251    /// Get a pixel color at (x, y) as [R, G, B, A].
252    pub fn get_pixel(&self, pixels: &[u8], x: u32, y: u32) -> [u8; 4] {
253        let idx = ((y * self.width + x) * 4) as usize;
254        [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]]
255    }
256
257    /// Check if a pixel approximately matches an expected color (within tolerance).
258    pub fn pixel_matches(&self, pixels: &[u8], x: u32, y: u32, expected: [u8; 4], tolerance: u8) -> bool {
259        let actual = self.get_pixel(pixels, x, y);
260        actual.iter().zip(expected.iter()).all(|(a, e)| {
261            (*a as i16 - *e as i16).abs() <= tolerance as i16
262        })
263    }
264}
265
266/// Helper to clear a render target to a solid color.
267pub fn clear_target(gpu: &TestGpu, target: &TestRenderTarget, color: [f32; 4]) {
268    let mut encoder = gpu.device.create_command_encoder(
269        &wgpu::CommandEncoderDescriptor { label: Some("clear_encoder") },
270    );
271
272    {
273        let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
274            label: Some("clear_pass"),
275            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
276                view: &target.view,
277                resolve_target: None,
278                ops: wgpu::Operations {
279                    load: wgpu::LoadOp::Clear(wgpu::Color {
280                        r: color[0] as f64,
281                        g: color[1] as f64,
282                        b: color[2] as f64,
283                        a: color[3] as f64,
284                    }),
285                    store: wgpu::StoreOp::Store,
286                },
287            })],
288            depth_stencil_attachment: None,
289            timestamp_writes: None,
290            occlusion_query_set: None,
291        });
292    }
293
294    gpu.queue.submit(std::iter::once(encoder.finish()));
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    #[ignore] // requires GPU
303    fn test_gpu_context_creation() {
304        let gpu = TestGpu::new().expect("Failed to create GPU context");
305        // If we got here, the GPU context was created successfully
306        let _target = gpu.create_target(16, 16);
307    }
308
309    #[test]
310    #[ignore] // requires GPU
311    fn test_clear_and_readback() {
312        let gpu = TestGpu::new().expect("Failed to create GPU context");
313        let target = gpu.create_target(64, 64);
314
315        // Clear to red
316        clear_target(&gpu, &target, [1.0, 0.0, 0.0, 1.0]);
317
318        let pixels = target.read_pixels(&gpu).expect("Failed to read pixels");
319
320        // Check center pixel is red
321        assert!(target.pixel_matches(&pixels, 32, 32, [255, 0, 0, 255], 1));
322    }
323
324    #[test]
325    #[ignore] // requires GPU
326    fn test_clear_to_green() {
327        let gpu = TestGpu::new().expect("Failed to create GPU context");
328        let target = gpu.create_target(32, 32);
329
330        clear_target(&gpu, &target, [0.0, 1.0, 0.0, 1.0]);
331
332        let pixels = target.read_pixels(&gpu).expect("Failed to read pixels");
333        assert!(target.pixel_matches(&pixels, 16, 16, [0, 255, 0, 255], 1));
334    }
335}