Skip to main content

brepkit_render/
pipeline.rs

1//! The wgpu pipeline: adapter/device setup, render passes, readback.
2//!
3//! The pieces here are shared by two render targets: the offscreen path
4//! ([`render`], which draws to textures and reads them back) and the
5//! interactive viewer ([`crate::viewer`], which draws to a window surface).
6//! [`GpuContext`], [`Pipelines`], [`GeometryBuffers`], and [`encode_scene`] are
7//! the reusable building blocks; the offscreen path also owns its readback.
8
9use bytemuck::{Pod, Zeroable};
10use wgpu::util::DeviceExt;
11
12use crate::camera::Camera;
13use crate::error::RenderError;
14use crate::mesh::{EdgeVertex, RenderMesh, Vertex};
15use crate::{RenderOpts, RenderOutput};
16
17/// Uniform block shared by both shaders (must match the WGSL `Globals` layout).
18#[repr(C)]
19#[derive(Debug, Clone, Copy, Pod, Zeroable)]
20pub struct Globals {
21    /// Combined view-projection matrix (column-major), RTC-folded.
22    pub view_proj: [f32; 16],
23    /// World-space view direction (xyz; w padding).
24    pub view_dir: [f32; 4],
25    /// Ambient light fraction.
26    pub ambient: f32,
27    /// Encoded `FaceId` (`index + 1`) to highlight, or `0` for none.
28    pub selected_id: u32,
29    /// Padding to a 16-byte boundary.
30    pub _pad: [f32; 2],
31}
32
33pub const COLOR_FORMAT_OFFSCREEN: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
34pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
35pub const ID_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R32Uint;
36
37/// Probe whether any wgpu adapter (real GPU first, then software fallback) can
38/// be obtained on this machine.
39///
40/// Renders never run when this returns `false`; useful for gating tests in
41/// headless environments. Returns the adapter backend/name on success.
42#[must_use]
43pub fn probe_adapter() -> Option<String> {
44    let instance = wgpu::Instance::default();
45    candidate_adapters(&instance, None).first().map(|adapter| {
46        let info = adapter.get_info();
47        format!(
48            "{:?} / {} ({:?})",
49            info.backend, info.name, info.device_type
50        )
51    })
52}
53
54/// Request the preferred (real GPU) adapter, then the software fallback.
55///
56/// Returns adapters in priority order (real first, fallback second); either may
57/// be absent. Both are returned so device creation can fall back if the first
58/// adapter fails to produce a device. When `surface` is `Some`, each adapter is
59/// required to be compatible with it (a hard requirement for window presentation).
60fn candidate_adapters(
61    instance: &wgpu::Instance,
62    surface: Option<&wgpu::Surface<'_>>,
63) -> Vec<wgpu::Adapter> {
64    let mut out = Vec::new();
65    if let Ok(adapter) =
66        pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
67            power_preference: wgpu::PowerPreference::HighPerformance,
68            force_fallback_adapter: false,
69            compatible_surface: surface,
70            apply_limit_buckets: false,
71        }))
72    {
73        out.push(adapter);
74    }
75    if let Ok(adapter) =
76        pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
77            power_preference: wgpu::PowerPreference::LowPower,
78            force_fallback_adapter: true,
79            compatible_surface: surface,
80            apply_limit_buckets: false,
81        }))
82    {
83        out.push(adapter);
84    }
85    out
86}
87
88/// Acquire an adapter + device + queue, trying each candidate adapter in
89/// priority order.
90///
91/// A real adapter that exists but cannot create a device falls back to the
92/// software adapter rather than failing outright. The chosen adapter is
93/// returned alongside the device so callers (the viewer) can query surface
94/// capabilities. When `surface` is `Some`, only surface-compatible adapters are
95/// considered.
96pub fn acquire_device(
97    instance: &wgpu::Instance,
98    surface: Option<&wgpu::Surface<'_>>,
99) -> Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue), RenderError> {
100    let adapters = candidate_adapters(instance, surface);
101    if adapters.is_empty() {
102        return Err(RenderError::NoAdapter(
103            "request_adapter returned no adapter".into(),
104        ));
105    }
106    let mut last_err = String::new();
107    for adapter in adapters {
108        match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
109            label: Some("brepkit-render device"),
110            required_features: wgpu::Features::empty(),
111            required_limits: wgpu::Limits::downlevel_defaults(),
112            ..Default::default()
113        })) {
114            Ok((device, queue)) => return Ok((adapter, device, queue)),
115            Err(e) => last_err = e.to_string(),
116        }
117    }
118    Err(RenderError::DeviceRequest(last_err))
119}
120
121/// A wgpu adapter/device/queue, set up once and shared across frames.
122///
123/// The instance used to obtain these is dropped after device creation — wgpu's
124/// adapter/device/surface keep their own handles to the backend.
125pub struct GpuContext {
126    // Read by the viewer (surface capabilities) but not by the offscreen path.
127    #[cfg_attr(not(feature = "window"), allow(dead_code))]
128    pub adapter: wgpu::Adapter,
129    pub device: wgpu::Device,
130    pub queue: wgpu::Queue,
131}
132
133impl GpuContext {
134    /// Create a surfaceless context for the offscreen path.
135    ///
136    /// Window callers must use [`GpuContext::with_instance`] instead, passing a
137    /// surface created from the *same* instance — a surface created from a
138    /// different instance than the adapter/device is invalid on strict backends,
139    /// so this constructor deliberately does not accept one.
140    ///
141    /// # Errors
142    ///
143    /// [`RenderError::NoAdapter`] if no adapter exists, or
144    /// [`RenderError::DeviceRequest`] if the device cannot be created.
145    pub fn new() -> Result<Self, RenderError> {
146        let instance = wgpu::Instance::default();
147        Self::with_instance(instance, None)
148    }
149
150    /// Build a context from an existing instance, optionally constraining the
151    /// adapter to be compatible with `surface`.
152    ///
153    /// The surface (when `Some`) must have been created from `instance`, so the
154    /// viewer builds the instance first, creates the surface from it, then hands
155    /// both here — guaranteeing the adapter/device and surface share an instance.
156    ///
157    /// # Errors
158    ///
159    /// See [`GpuContext::new`].
160    pub fn with_instance(
161        instance: wgpu::Instance,
162        surface: Option<&wgpu::Surface<'_>>,
163    ) -> Result<Self, RenderError> {
164        let (adapter, device, queue) = acquire_device(&instance, surface)?;
165        Ok(Self {
166            adapter,
167            device,
168            queue,
169        })
170    }
171}
172
173/// The globals uniform buffer plus its bind group and layout.
174///
175/// The buffer is `COPY_DST` so the viewer can re-upload [`Globals`] every frame
176/// (camera orbit, selection change) without rebuilding the bind group.
177pub struct GlobalsBinding {
178    // Retained to keep the uniform buffer alive (it backs `bind_group`); also
179    // re-uploaded each frame by the viewer via `upload`.
180    #[cfg_attr(not(feature = "window"), allow(dead_code))]
181    pub buffer: wgpu::Buffer,
182    pub bind_group: wgpu::BindGroup,
183    pub layout: wgpu::BindGroupLayout,
184}
185
186impl GlobalsBinding {
187    pub fn new(device: &wgpu::Device, globals: &Globals) -> Self {
188        let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
189            label: Some("globals"),
190            contents: bytemuck::bytes_of(globals),
191            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
192        });
193        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
194            label: Some("globals layout"),
195            entries: &[wgpu::BindGroupLayoutEntry {
196                binding: 0,
197                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
198                ty: wgpu::BindingType::Buffer {
199                    ty: wgpu::BufferBindingType::Uniform,
200                    has_dynamic_offset: false,
201                    min_binding_size: None,
202                },
203                count: None,
204            }],
205        });
206        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
207            label: Some("globals bind group"),
208            layout: &layout,
209            entries: &[wgpu::BindGroupEntry {
210                binding: 0,
211                resource: buffer.as_entire_binding(),
212            }],
213        });
214        Self {
215            buffer,
216            bind_group,
217            layout,
218        }
219    }
220
221    /// Re-upload the uniform block (call before encoding a frame).
222    #[cfg_attr(not(feature = "window"), allow(dead_code))]
223    pub fn upload(&self, queue: &wgpu::Queue, globals: &Globals) {
224        queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(globals));
225    }
226}
227
228/// The mesh pipeline plus the optional edge pipeline, built for one color
229/// format. Both passes target `[color, id]` so the id buffer is always written
230/// alongside the shaded image (the edge pass masks out id writes).
231pub struct Pipelines {
232    pub mesh: wgpu::RenderPipeline,
233    pub edge: Option<wgpu::RenderPipeline>,
234}
235
236impl Pipelines {
237    /// Build the pipelines for `color_format` (offscreen uses
238    /// `Rgba8UnormSrgb`; the viewer uses the surface's preferred sRGB format).
239    /// `with_edges` controls whether the edge pipeline is built.
240    pub fn new(
241        device: &wgpu::Device,
242        layout: &wgpu::PipelineLayout,
243        color_format: wgpu::TextureFormat,
244        with_edges: bool,
245    ) -> Self {
246        let mesh_shader = device.create_shader_module(wgpu::include_wgsl!("../shaders/mesh.wgsl"));
247        let color_targets = [
248            Some(wgpu::ColorTargetState {
249                format: color_format,
250                blend: None,
251                write_mask: wgpu::ColorWrites::ALL,
252            }),
253            Some(wgpu::ColorTargetState {
254                format: ID_FORMAT,
255                blend: None,
256                write_mask: wgpu::ColorWrites::ALL,
257            }),
258        ];
259        let mesh = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
260            label: Some("mesh pipeline"),
261            layout: Some(layout),
262            vertex: wgpu::VertexState {
263                module: &mesh_shader,
264                entry_point: Some("vs_main"),
265                buffers: &[Some(wgpu::VertexBufferLayout {
266                    array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
267                    step_mode: wgpu::VertexStepMode::Vertex,
268                    attributes: &[
269                        wgpu::VertexAttribute {
270                            format: wgpu::VertexFormat::Float32x3,
271                            offset: 0,
272                            shader_location: 0,
273                        },
274                        wgpu::VertexAttribute {
275                            format: wgpu::VertexFormat::Float32x3,
276                            offset: 12,
277                            shader_location: 1,
278                        },
279                        wgpu::VertexAttribute {
280                            format: wgpu::VertexFormat::Uint32,
281                            offset: 24,
282                            shader_location: 2,
283                        },
284                    ],
285                })],
286                compilation_options: wgpu::PipelineCompilationOptions::default(),
287            },
288            primitive: wgpu::PrimitiveState {
289                topology: wgpu::PrimitiveTopology::TriangleList,
290                cull_mode: None,
291                ..Default::default()
292            },
293            depth_stencil: Some(wgpu::DepthStencilState {
294                format: DEPTH_FORMAT,
295                depth_write_enabled: Some(true),
296                depth_compare: Some(wgpu::CompareFunction::Less),
297                stencil: wgpu::StencilState::default(),
298                bias: wgpu::DepthBiasState::default(),
299            }),
300            multisample: wgpu::MultisampleState::default(),
301            fragment: Some(wgpu::FragmentState {
302                module: &mesh_shader,
303                entry_point: Some("fs_main"),
304                targets: &color_targets,
305                compilation_options: wgpu::PipelineCompilationOptions::default(),
306            }),
307            multiview_mask: None,
308            cache: None,
309        });
310
311        let edge = if with_edges {
312            let edge_shader =
313                device.create_shader_module(wgpu::include_wgsl!("../shaders/edge.wgsl"));
314            // The id target is still bound during the edge pass, so give it a
315            // target with writes masked off (edges only recolor; the underlying
316            // face id must survive for picking).
317            let edge_color_targets = [
318                Some(wgpu::ColorTargetState {
319                    format: color_format,
320                    blend: None,
321                    write_mask: wgpu::ColorWrites::ALL,
322                }),
323                Some(wgpu::ColorTargetState {
324                    format: ID_FORMAT,
325                    blend: None,
326                    write_mask: wgpu::ColorWrites::empty(),
327                }),
328            ];
329            let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
330                label: Some("edge pipeline"),
331                layout: Some(layout),
332                vertex: wgpu::VertexState {
333                    module: &edge_shader,
334                    entry_point: Some("vs_main"),
335                    buffers: &[Some(wgpu::VertexBufferLayout {
336                        array_stride: std::mem::size_of::<EdgeVertex>() as wgpu::BufferAddress,
337                        step_mode: wgpu::VertexStepMode::Vertex,
338                        attributes: &[wgpu::VertexAttribute {
339                            format: wgpu::VertexFormat::Float32x3,
340                            offset: 0,
341                            shader_location: 0,
342                        }],
343                    })],
344                    compilation_options: wgpu::PipelineCompilationOptions::default(),
345                },
346                primitive: wgpu::PrimitiveState {
347                    topology: wgpu::PrimitiveTopology::LineList,
348                    ..Default::default()
349                },
350                depth_stencil: Some(wgpu::DepthStencilState {
351                    format: DEPTH_FORMAT,
352                    depth_write_enabled: Some(true),
353                    depth_compare: Some(wgpu::CompareFunction::LessEqual),
354                    stencil: wgpu::StencilState::default(),
355                    bias: wgpu::DepthBiasState::default(),
356                }),
357                multisample: wgpu::MultisampleState::default(),
358                fragment: Some(wgpu::FragmentState {
359                    module: &edge_shader,
360                    entry_point: Some("fs_main"),
361                    targets: &edge_color_targets,
362                    compilation_options: wgpu::PipelineCompilationOptions::default(),
363                }),
364                multiview_mask: None,
365                cache: None,
366            });
367            Some(pipeline)
368        } else {
369            None
370        };
371
372        Self { mesh, edge }
373    }
374}
375
376/// GPU vertex/index/edge buffers built once from a [`RenderMesh`].
377pub struct GeometryBuffers {
378    pub vertex: wgpu::Buffer,
379    pub index: wgpu::Buffer,
380    pub index_count: u32,
381    pub edge: Option<wgpu::Buffer>,
382    pub edge_count: u32,
383}
384
385impl GeometryBuffers {
386    pub fn new(device: &wgpu::Device, mesh: &RenderMesh) -> Self {
387        let vertex = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
388            label: Some("mesh vertices"),
389            contents: bytemuck::cast_slice(&mesh.vertices),
390            usage: wgpu::BufferUsages::VERTEX,
391        });
392        let index = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
393            label: Some("mesh indices"),
394            contents: bytemuck::cast_slice(&mesh.indices),
395            usage: wgpu::BufferUsages::INDEX,
396        });
397        #[allow(clippy::cast_possible_truncation)]
398        let index_count = mesh.indices.len() as u32;
399
400        let (edge, edge_count) = if mesh.edge_vertices.is_empty() {
401            (None, 0)
402        } else {
403            let buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
404                label: Some("edge vertices"),
405                contents: bytemuck::cast_slice(&mesh.edge_vertices),
406                usage: wgpu::BufferUsages::VERTEX,
407            });
408            #[allow(clippy::cast_possible_truncation)]
409            let count = mesh.edge_vertices.len() as u32;
410            (Some(buf), count)
411        };
412
413        Self {
414            vertex,
415            index,
416            index_count,
417            edge,
418            edge_count,
419        }
420    }
421}
422
423/// Views and clear color for one scene-pass encode.
424pub struct PassTargets<'a> {
425    pub color: &'a wgpu::TextureView,
426    pub id: &'a wgpu::TextureView,
427    pub depth: &'a wgpu::TextureView,
428    pub background: [f32; 4],
429}
430
431/// Encode the mesh pass (and the edge pass, if both the pipeline and the
432/// geometry have edges) into `encoder`, drawing to `targets`.
433///
434/// Shared verbatim by the offscreen path and the viewer so the two never drift.
435pub fn encode_scene(
436    encoder: &mut wgpu::CommandEncoder,
437    pipelines: &Pipelines,
438    globals: &GlobalsBinding,
439    geometry: &GeometryBuffers,
440    targets: &PassTargets<'_>,
441) {
442    let bg = targets.background;
443    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
444        label: Some("mesh + edge pass"),
445        color_attachments: &[
446            Some(wgpu::RenderPassColorAttachment {
447                view: targets.color,
448                depth_slice: None,
449                resolve_target: None,
450                ops: wgpu::Operations {
451                    load: wgpu::LoadOp::Clear(wgpu::Color {
452                        r: f64::from(bg[0]),
453                        g: f64::from(bg[1]),
454                        b: f64::from(bg[2]),
455                        a: f64::from(bg[3]),
456                    }),
457                    store: wgpu::StoreOp::Store,
458                },
459            }),
460            Some(wgpu::RenderPassColorAttachment {
461                view: targets.id,
462                depth_slice: None,
463                resolve_target: None,
464                ops: wgpu::Operations {
465                    // 0 = background sentinel.
466                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
467                    store: wgpu::StoreOp::Store,
468                },
469            }),
470        ],
471        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
472            view: targets.depth,
473            depth_ops: Some(wgpu::Operations {
474                load: wgpu::LoadOp::Clear(1.0),
475                store: wgpu::StoreOp::Store,
476            }),
477            stencil_ops: None,
478        }),
479        timestamp_writes: None,
480        occlusion_query_set: None,
481        multiview_mask: None,
482    });
483
484    pass.set_bind_group(0, &globals.bind_group, &[]);
485    pass.set_pipeline(&pipelines.mesh);
486    pass.set_vertex_buffer(0, geometry.vertex.slice(..));
487    pass.set_index_buffer(geometry.index.slice(..), wgpu::IndexFormat::Uint32);
488    pass.draw_indexed(0..geometry.index_count, 0, 0..1);
489
490    if let (Some(edge_pipeline), Some(edge_buf)) = (pipelines.edge.as_ref(), geometry.edge.as_ref())
491    {
492        pass.set_pipeline(edge_pipeline);
493        pass.set_vertex_buffer(0, edge_buf.slice(..));
494        pass.draw(0..geometry.edge_count, 0..1);
495    }
496}
497
498/// Build the [`Globals`] block for a frame from the camera, RTC center, and the
499/// rendering options (with no face selected).
500pub fn build_globals(cam: &Camera, center: brepkit_math::vec::Point3, ambient: f32) -> Globals {
501    let view_proj = crate::camera::view_proj_rtc(cam, center);
502    let view_dir = cam.view_direction();
503    #[allow(clippy::cast_possible_truncation)]
504    Globals {
505        view_proj,
506        view_dir: [
507            view_dir.x() as f32,
508            view_dir.y() as f32,
509            view_dir.z() as f32,
510            0.0,
511        ],
512        ambient,
513        selected_id: 0,
514        _pad: [0.0; 2],
515    }
516}
517
518/// Render a solid's prepared geometry offscreen and read back color + ids.
519///
520/// `mesh` is the center-relative geometry; `cam` and `opts` control the view
521/// and targets. This performs all GPU work synchronously (blocking on async
522/// via `pollster`).
523///
524/// # Errors
525///
526/// See [`crate::render_solid_offscreen`].
527#[allow(clippy::too_many_lines)]
528pub fn render(
529    mesh: &RenderMesh,
530    cam: &Camera,
531    opts: &RenderOpts,
532) -> Result<RenderOutput, RenderError> {
533    if opts.width == 0 || opts.height == 0 {
534        return Err(RenderError::InvalidSize {
535            width: opts.width,
536            height: opts.height,
537        });
538    }
539
540    let ctx = GpuContext::new()?;
541    let device = &ctx.device;
542    let queue = &ctx.queue;
543
544    let (width, height) = (opts.width, opts.height);
545    // Reject oversized targets with a clean error rather than tripping wgpu's
546    // internal validation (which surfaces as a device-error/panic path).
547    let max = device.limits().max_texture_dimension_2d;
548    if width > max || height > max {
549        return Err(RenderError::SizeTooLarge { width, height, max });
550    }
551    let extent = wgpu::Extent3d {
552        width,
553        height,
554        depth_or_array_layers: 1,
555    };
556
557    // --- Targets -----------------------------------------------------------
558    let color_tex = device.create_texture(&wgpu::TextureDescriptor {
559        label: Some("color target"),
560        size: extent,
561        mip_level_count: 1,
562        sample_count: 1,
563        dimension: wgpu::TextureDimension::D2,
564        format: COLOR_FORMAT_OFFSCREEN,
565        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
566        view_formats: &[],
567    });
568    let depth_tex = device.create_texture(&wgpu::TextureDescriptor {
569        label: Some("depth target"),
570        size: extent,
571        mip_level_count: 1,
572        sample_count: 1,
573        dimension: wgpu::TextureDimension::D2,
574        format: DEPTH_FORMAT,
575        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
576        view_formats: &[],
577    });
578    let id_tex = device.create_texture(&wgpu::TextureDescriptor {
579        label: Some("id target"),
580        size: extent,
581        mip_level_count: 1,
582        sample_count: 1,
583        dimension: wgpu::TextureDimension::D2,
584        format: ID_FORMAT,
585        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
586        view_formats: &[],
587    });
588    let color_view = color_tex.create_view(&wgpu::TextureViewDescriptor::default());
589    let depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default());
590    let id_view = id_tex.create_view(&wgpu::TextureViewDescriptor::default());
591
592    // --- Shared GPU objects ------------------------------------------------
593    let globals = build_globals(cam, mesh.center, opts.ambient);
594    let globals_binding = GlobalsBinding::new(device, &globals);
595    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
596        label: Some("pipeline layout"),
597        bind_group_layouts: &[Some(&globals_binding.layout)],
598        immediate_size: 0,
599    });
600    let with_edges = opts.edges && !mesh.edge_vertices.is_empty();
601    let pipelines = Pipelines::new(device, &pipeline_layout, COLOR_FORMAT_OFFSCREEN, with_edges);
602    let geometry = GeometryBuffers::new(device, mesh);
603
604    // --- Readback buffers --------------------------------------------------
605    // Bytes per row must be a multiple of COPY_BYTES_PER_ROW_ALIGNMENT (256).
606    let color_bpp = 4_u32; // Rgba8
607    let id_bpp = 4_u32; // R32Uint
608    let color_padded_bpr = padded_bytes_per_row(width, color_bpp);
609    let id_padded_bpr = padded_bytes_per_row(width, id_bpp);
610
611    let color_readback = device.create_buffer(&wgpu::BufferDescriptor {
612        label: Some("color readback"),
613        size: u64::from(color_padded_bpr) * u64::from(height),
614        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
615        mapped_at_creation: false,
616    });
617    let id_readback = device.create_buffer(&wgpu::BufferDescriptor {
618        label: Some("id readback"),
619        size: u64::from(id_padded_bpr) * u64::from(height),
620        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
621        mapped_at_creation: false,
622    });
623
624    // --- Encode ------------------------------------------------------------
625    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
626        label: Some("encoder"),
627    });
628    encode_scene(
629        &mut encoder,
630        &pipelines,
631        &globals_binding,
632        &geometry,
633        &PassTargets {
634            color: &color_view,
635            id: &id_view,
636            depth: &depth_view,
637            background: opts.background,
638        },
639    );
640
641    encoder.copy_texture_to_buffer(
642        wgpu::TexelCopyTextureInfo {
643            texture: &color_tex,
644            mip_level: 0,
645            origin: wgpu::Origin3d::ZERO,
646            aspect: wgpu::TextureAspect::All,
647        },
648        wgpu::TexelCopyBufferInfo {
649            buffer: &color_readback,
650            layout: wgpu::TexelCopyBufferLayout {
651                offset: 0,
652                bytes_per_row: Some(color_padded_bpr),
653                rows_per_image: Some(height),
654            },
655        },
656        extent,
657    );
658    encoder.copy_texture_to_buffer(
659        wgpu::TexelCopyTextureInfo {
660            texture: &id_tex,
661            mip_level: 0,
662            origin: wgpu::Origin3d::ZERO,
663            aspect: wgpu::TextureAspect::All,
664        },
665        wgpu::TexelCopyBufferInfo {
666            buffer: &id_readback,
667            layout: wgpu::TexelCopyBufferLayout {
668                offset: 0,
669                bytes_per_row: Some(id_padded_bpr),
670                rows_per_image: Some(height),
671            },
672        },
673        extent,
674    );
675
676    queue.submit(Some(encoder.finish()));
677
678    // --- Map + read --------------------------------------------------------
679    let color_bytes = map_and_read(device, &color_readback)?;
680    let id_bytes = map_and_read(device, &id_readback)?;
681
682    let color = unpad_to_rgba(&color_bytes, width, height, color_padded_bpr);
683    let id_buffer = unpad_to_u32(&id_bytes, width, height, id_padded_bpr);
684
685    Ok(RenderOutput {
686        color,
687        id_buffer,
688        width,
689        height,
690    })
691}
692
693/// Round `width * bpp` up to the next multiple of the row-copy alignment.
694pub fn padded_bytes_per_row(width: u32, bytes_per_pixel: u32) -> u32 {
695    let unpadded = width * bytes_per_pixel;
696    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
697    unpadded.div_ceil(align) * align
698}
699
700/// Map a readback buffer (blocking) and copy its bytes out.
701pub fn map_and_read(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Result<Vec<u8>, RenderError> {
702    use std::sync::mpsc;
703    let (tx, rx) = mpsc::channel();
704    buffer.slice(..).map_async(wgpu::MapMode::Read, move |res| {
705        let _ = tx.send(res);
706    });
707    device
708        .poll(wgpu::PollType::Wait {
709            submission_index: None,
710            timeout: None,
711        })
712        .map_err(|e| RenderError::Poll(e.to_string()))?;
713    match rx.recv() {
714        Ok(Ok(())) => {}
715        Ok(Err(e)) => return Err(RenderError::BufferMap(e.to_string())),
716        Err(e) => return Err(RenderError::BufferMap(e.to_string())),
717    }
718    let data = buffer
719        .slice(..)
720        .get_mapped_range()
721        .map_err(|e| RenderError::BufferMap(e.to_string()))?
722        .to_vec();
723    buffer.unmap();
724    Ok(data)
725}
726
727/// Strip per-row copy padding and build an RGBA image (rows are tightly packed).
728pub fn unpad_to_rgba(bytes: &[u8], width: u32, height: u32, padded_bpr: u32) -> image::RgbaImage {
729    let row_len = (width * 4) as usize;
730    let mut packed = Vec::with_capacity(row_len * height as usize);
731    for row in 0..height as usize {
732        let start = row * padded_bpr as usize;
733        if let Some(slice) = bytes.get(start..start + row_len) {
734            packed.extend_from_slice(slice);
735        } else {
736            packed.resize(packed.len() + row_len, 0);
737        }
738    }
739    image::RgbaImage::from_raw(width, height, packed)
740        .unwrap_or_else(|| image::RgbaImage::new(width, height))
741}
742
743/// Strip per-row copy padding and decode the R32Uint id target to `Vec<u32>`.
744pub fn unpad_to_u32(bytes: &[u8], width: u32, height: u32, padded_bpr: u32) -> Vec<u32> {
745    let mut out = Vec::with_capacity((width * height) as usize);
746    for row in 0..height as usize {
747        let row_start = row * padded_bpr as usize;
748        for col in 0..width as usize {
749            let off = row_start + col * 4;
750            let v = bytes
751                .get(off..off + 4)
752                .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
753                .unwrap_or(0);
754            out.push(v);
755        }
756    }
757    out
758}