Skip to main content

hephaestus/backend/hybrid/
wgpu_renderer.rs

1//! The wgpu-backed renderer: rasterises a replayed scene through
2//! `vello_hybrid`'s render pipeline onto a wgpu texture.
3//!
4//! Split from the scene layer beside it because that layer needs no GPU
5//! API at all, which is what lets a WebGL2 build leave wgpu out entirely.
6
7use std::collections::HashMap;
8
9use vello_common::paint::ImageSource;
10use vello_hybrid::{
11    RenderSize, RenderTargetConfig, Renderer as HRenderer, Resources, Scene, TextureBindings,
12};
13
14use super::{
15    dimension, image_key, recorded_images, unpremultiply, HybridScene, Pass, Writer,
16    PICK_ALIASING_THRESHOLD,
17};
18use crate::backend::{BackendError, Renderer, WgpuRenderer};
19use crate::color::Color;
20use crate::geometry::Affine;
21use crate::pick;
22
23// ---------- Renderer ----------
24
25/// Render target plus the readback buffer that drains it, sized for the
26/// current frame. Recreated on size change.
27struct Target {
28    texture: wgpu::Texture,
29    view: wgpu::TextureView,
30    readback: wgpu::Buffer,
31    width: u32,
32    height: u32,
33    /// Bytes per row in the readback buffer (padded to wgpu's alignment).
34    padded_bytes_per_row: u32,
35    format: wgpu::TextureFormat,
36}
37
38impl Target {
39    /// Allocate a render-attachment texture and a row-padded readback buffer.
40    ///
41    /// `RENDER_ATTACHMENT` rather than `STORAGE_BINDING`: this backend
42    /// rasterises through a render pipeline, not a compute shader.
43    fn new(device: &wgpu::Device, width: u32, height: u32, format: wgpu::TextureFormat) -> Self {
44        let bytes_per_row = width * 4;
45        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
46        let padded_bytes_per_row = bytes_per_row.div_ceil(align) * align;
47
48        let texture = device.create_texture(&wgpu::TextureDescriptor {
49            label: Some("hephaestus.hybrid.target"),
50            size: wgpu::Extent3d {
51                width,
52                height,
53                depth_or_array_layers: 1,
54            },
55            mip_level_count: 1,
56            sample_count: 1,
57            dimension: wgpu::TextureDimension::D2,
58            format,
59            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
60            view_formats: &[],
61        });
62        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
63        let readback = device.create_buffer(&wgpu::BufferDescriptor {
64            label: Some("hephaestus.hybrid.readback"),
65            size: u64::from(padded_bytes_per_row) * u64::from(height),
66            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
67            mapped_at_creation: false,
68        });
69        Self {
70            texture,
71            view,
72            readback,
73            width,
74            height,
75            padded_bytes_per_row,
76            format,
77        }
78    }
79}
80
81/// The half of the renderer bound to a frame size.
82///
83/// `RenderTargetConfig` fixes the dimensions a `vello_hybrid::Renderer` is
84/// built for, and `Scene` its own, so a size change rebuilds both. The image
85/// atlas lives here too and is therefore invalidated by a resize.
86struct SizeBound {
87    renderer: HRenderer,
88    resources: Resources,
89    display: Scene,
90    pick: Option<Scene>,
91    images: HashMap<u64, ImageSource>,
92    width: u32,
93    height: u32,
94    format: wgpu::TextureFormat,
95}
96
97/// A pick readback in flight: a slot the `map_async` callback fills, and the
98/// dimensions it covers.
99///
100/// A slot rather than a future, so completion can be *checked* instead of
101/// awaited. Awaiting would mean holding a borrow of the renderer across a
102/// suspension point, which a browser host — where the only caller is a
103/// callback that may re-enter — cannot do safely.
104struct PendingPick {
105    slot: std::sync::Arc<std::sync::Mutex<Option<Result<(), wgpu::BufferAsyncError>>>>,
106    width: u32,
107    height: u32,
108}
109
110/// Hephaestus Hybrid renderer: owns the wgpu device and queue, the recorded
111/// scene, and the per-size rasterisation state.
112///
113/// When constructed via [`Self::with_picking`], every render also replays the
114/// recording into a pick scene rasterised with binary coverage, reads it back,
115/// and caches it as the hitmap behind [`Self::pick_at`].
116pub struct HybridRenderer {
117    device: wgpu::Device,
118    queue: wgpu::Queue,
119    scene: HybridScene,
120    picking: bool,
121    sized: Option<SizeBound>,
122    target: Option<Target>,
123    pick_target: Option<Target>,
124    /// Decoded pick pixels of the most recent render, one `u32` per pixel.
125    hitmap: Option<Vec<u32>>,
126    hitmap_dims: Option<(u32, u32)>,
127    pick_pending: Option<PendingPick>,
128    /// Format `render_to_texture` writes. A host presenting straight into its
129    /// swap chain sets this to the surface's format.
130    target_format: wgpu::TextureFormat,
131    /// Whether the coming render refreshes the hitmap. See
132    /// [`HybridRenderer::set_refresh_pick`].
133    refresh_pick: bool,
134}
135
136impl HybridRenderer {
137    /// Build a renderer with no picking machinery. File-export workloads
138    /// should use this form; nothing in the pick path is allocated.
139    pub fn new() -> Result<Self, BackendError> {
140        pollster::block_on(Self::new_async(false))
141    }
142
143    /// Build a renderer with picking enabled. Each render additionally
144    /// rasterises the pick scene with binary coverage and reads it back.
145    pub fn with_picking() -> Result<Self, BackendError> {
146        pollster::block_on(Self::new_async(true))
147    }
148
149    /// Build a renderer that shares an existing wgpu device and queue — e.g.
150    /// the device backing a window's swap chain.
151    ///
152    /// `device` and `queue` are handles (Arc-backed in wgpu); the host keeps
153    /// its own and the renderer holds clones.
154    pub fn with_device(device: &wgpu::Device, queue: &wgpu::Queue) -> Result<Self, BackendError> {
155        Ok(Self::build(device.clone(), queue.clone(), false))
156    }
157
158    /// Like [`Self::with_device`] but enables picking.
159    pub fn with_device_and_picking(
160        device: &wgpu::Device,
161        queue: &wgpu::Queue,
162    ) -> Result<Self, BackendError> {
163        Ok(Self::build(device.clone(), queue.clone(), true))
164    }
165
166    async fn new_async(picking: bool) -> Result<Self, BackendError> {
167        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
168        // GL sits alongside PRIMARY so a Linux host without Vulkan reaches
169        // the GLES backend rather than finding no adapter. Unlike the
170        // compute-shader backend this one has no stage WebGL2 lacks, so the
171        // flag is meaningful on more targets.
172        desc.backends =
173            wgpu::Backends::from_env().unwrap_or(wgpu::Backends::PRIMARY | wgpu::Backends::GL);
174        let instance = wgpu::Instance::new(desc);
175        let adapter = instance
176            .request_adapter(&wgpu::RequestAdapterOptions {
177                power_preference: wgpu::PowerPreference::HighPerformance,
178                compatible_surface: None,
179                force_fallback_adapter: false,
180            })
181            .await
182            .map_err(|_| BackendError::NoAdapter)?;
183        let (device, queue) = adapter
184            .request_device(&wgpu::DeviceDescriptor {
185                label: Some("hephaestus.hybrid.device"),
186                required_features: wgpu::Features::empty(),
187                required_limits: wgpu::Limits::default(),
188                memory_hints: wgpu::MemoryHints::default(),
189                trace: wgpu::Trace::Off,
190                experimental_features: wgpu::ExperimentalFeatures::default(),
191            })
192            .await
193            .map_err(|e| BackendError::DeviceRequest(e.to_string()))?;
194        Ok(Self::build(device, queue, picking))
195    }
196
197    fn build(device: wgpu::Device, queue: wgpu::Queue, picking: bool) -> Self {
198        Self {
199            device,
200            queue,
201            scene: HybridScene::new(),
202            picking,
203            sized: None,
204            target: None,
205            pick_target: None,
206            hitmap: None,
207            hitmap_dims: None,
208            pick_pending: None,
209            target_format: wgpu::TextureFormat::Rgba8Unorm,
210            refresh_pick: true,
211        }
212    }
213
214    /// Id recorded at the given pixel, or `None` for a miss.
215    ///
216    /// Returns `None` when picking is disabled, nothing has been rendered
217    /// yet, the coordinates fall outside the last render, or nothing
218    /// pickable covered the pixel. Binary coverage means the answer is the
219    /// id of exactly one primitive — never a blend of two.
220    pub fn pick_at(&self, x: u32, y: u32) -> Option<u32> {
221        let (w, h) = self.hitmap_dims?;
222        if x >= w || y >= h {
223            return None;
224        }
225        let hitmap = self.hitmap.as_ref()?;
226        pick::decode(hitmap[(y * w + x) as usize])
227    }
228
229    /// Control whether the coming render refreshes the hitmap.
230    ///
231    /// The pick pass costs about what the display pass does — it is a second
232    /// strip generation over the same geometry, on the CPU — so a host that is
233    /// resizing, animating, or otherwise redrawing faster than it queries can
234    /// leave the hitmap alone and pay for it only when an answer is wanted.
235    /// Measured at 100k marks: 88 ms a frame without it, 150 ms with.
236    ///
237    /// While it is off, [`Self::pick_at`] keeps answering from the last render
238    /// that refreshed — so the ids stay readable but describe an older frame.
239    /// Set it back to `true` (the default) and the next render brings the
240    /// hitmap up to date.
241    ///
242    /// No effect when picking was not enabled at construction.
243    pub fn set_refresh_pick(&mut self, refresh: bool) {
244        self.refresh_pick = refresh;
245    }
246
247    /// Whether the coming render will refresh the hitmap.
248    pub fn refreshes_pick(&self) -> bool {
249        self.picking && self.refresh_pick
250    }
251
252    /// Set the texture format [`WgpuRenderer::render_to_texture`] writes.
253    ///
254    /// Defaults to `Rgba8Unorm`. A host that presents straight into its swap
255    /// chain — which this backend can do, since it rasterises through a render
256    /// pipeline — sets the surface's format here instead of blitting from an
257    /// intermediate texture. Changing it rebuilds the size-bound state, so set
258    /// it once rather than per frame.
259    ///
260    /// [`Renderer::render_to_buffer`] is unaffected: it owns its target and
261    /// always uses `Rgba8Unorm`, since that is the byte order it hands out.
262    pub fn set_target_format(&mut self, format: wgpu::TextureFormat) {
263        self.target_format = format;
264    }
265
266    /// Raw pick pixels of the most recent render, for bulk queries.
267    ///
268    /// Row-major, `width * height` entries. Interpret each with
269    /// [`pick::decode`].
270    pub fn hitmap(&self) -> Option<&[u32]> {
271        self.hitmap.as_deref()
272    }
273
274    /// Rebuild the size-bound state when the requested frame size differs
275    /// from what it was built for.
276    fn ensure_sized(
277        &mut self,
278        width: u32,
279        height: u32,
280        format: wgpu::TextureFormat,
281    ) -> Result<(), BackendError> {
282        if self
283            .sized
284            .as_ref()
285            .is_some_and(|s| s.width == width && s.height == height && s.format == format)
286        {
287            return Ok(());
288        }
289        let (w16, h16) = (dimension(width)?, dimension(height)?);
290        let (renderer, resources) = HRenderer::new(
291            &self.device,
292            &RenderTargetConfig {
293                format,
294                width,
295                height,
296            },
297        );
298        self.sized = Some(SizeBound {
299            renderer,
300            resources,
301            display: Scene::new(w16, h16),
302            pick: self.picking.then(|| Scene::new(w16, h16)),
303            images: HashMap::new(),
304            width,
305            height,
306            format,
307        });
308        Ok(())
309    }
310}
311
312/// Narrow a pixel dimension to the `u16` the rasteriser sizes scenes in.
313impl HybridRenderer {
314    /// Upload every image the recording needs, reusing atlas handles already
315    /// held for this size.
316    fn upload_images(&mut self, encoder: &mut wgpu::CommandEncoder) -> Result<(), BackendError> {
317        let images = recorded_images(&self.scene.ops);
318        if images.is_empty() {
319            return Ok(());
320        }
321        let sized = self.sized.as_mut().expect("sized state ensured");
322        for image in images {
323            let key = image_key(image);
324            if sized.images.contains_key(&key) {
325                continue;
326            }
327            // Their conversion handles both the format narrowing and the
328            // premultiply; we only need the pixmap back out of it to upload.
329            let ImageSource::Pixmap(pixmap) = ImageSource::from_peniko_image_data(image) else {
330                return Err(BackendError::Other(
331                    "image conversion did not yield pixel data".into(),
332                ));
333            };
334            let transparency = pixmap.may_have_transparency();
335            let id = sized.renderer.upload_image(
336                &mut sized.resources,
337                &self.device,
338                &self.queue,
339                encoder,
340                &pixmap,
341            );
342            sized.images.insert(
343                key,
344                ImageSource::opaque_id_with_transparency_hint(id, transparency),
345            );
346        }
347        Ok(())
348    }
349
350    /// Replay the recording into the display scene, and into the pick scene
351    /// when picking is on.
352    fn replay(&mut self, background: Color, width: u32, height: u32, refresh_pick: bool) {
353        let sized = self.sized.as_mut().expect("sized state ensured");
354        let frame = crate::geometry::Rect::new(0.0, 0.0, width.into(), height.into());
355
356        sized.display.reset();
357        sized.display.set_aliasing_threshold(None);
358        // No base-colour parameter here, so the background is a draw. It has
359        // to be the first one.
360        sized.display.set_transform(Affine::IDENTITY);
361        sized.display.set_paint(background);
362        sized.display.fill_rect(&frame);
363
364        let mut writer = Writer {
365            scene: &mut sized.display,
366            resources: &mut sized.resources,
367            pass: Pass::Display,
368            images: &sized.images,
369        };
370        self.scene.ops.replay(&mut writer);
371
372        if !refresh_pick {
373            return;
374        }
375        if let Some(pick) = sized.pick.as_mut() {
376            pick.reset();
377            // The one line the whole backend exists for: a pick pixel is
378            // painted by exactly one primitive, so an edge reports a real id
379            // instead of a blend of the two ids either side of it.
380            pick.set_aliasing_threshold(Some(PICK_ALIASING_THRESHOLD));
381            // No background: an uncovered pick pixel must stay at alpha 0,
382            // which is what `pick::decode` reads as "no hit".
383            let mut writer = Writer {
384                scene: pick,
385                resources: &mut sized.resources,
386                pass: Pass::Pick,
387                images: &sized.images,
388            };
389            self.scene.ops.replay(&mut writer);
390        }
391    }
392}
393
394impl HybridRenderer {
395    /// Allocate the pick target when the requested size differs from the
396    /// cached one.
397    fn ensure_pick_target(&mut self, width: u32, height: u32) {
398        // The pick pass goes through the same renderer as the display, and a
399        // renderer targets one format — so the pick target has to match it.
400        // `read_hitmap` puts the channels back in order.
401        let format = self
402            .sized
403            .as_ref()
404            .map_or(wgpu::TextureFormat::Rgba8Unorm, |s| s.format);
405        if self
406            .pick_target
407            .as_ref()
408            .is_none_or(|t| t.width != width || t.height != height || t.format != format)
409        {
410            self.pick_target = Some(Target::new(&self.device, width, height, format));
411        }
412    }
413
414    /// Rasterise one of the two scenes into `view`.
415    fn rasterise(
416        &mut self,
417        encoder: &mut wgpu::CommandEncoder,
418        pass: Pass,
419        view: &wgpu::TextureView,
420        width: u32,
421        height: u32,
422    ) -> Result<(), BackendError> {
423        let sized = self.sized.as_mut().expect("sized state ensured");
424        let scene = match pass {
425            Pass::Display => &sized.display,
426            Pass::Pick => sized.pick.as_ref().expect("pick scene present"),
427        };
428        sized
429            .renderer
430            .render(
431                scene,
432                &mut sized.resources,
433                &self.device,
434                &self.queue,
435                encoder,
436                &RenderSize { width, height },
437                view,
438                &TextureBindings::new(),
439            )
440            .map_err(|e| match pass {
441                Pass::Display => BackendError::Other(format!("hybrid render: {e}")),
442                Pass::Pick => BackendError::Other(format!("hybrid pick render: {e}")),
443            })
444    }
445
446    /// Drain the pick target into the CPU-side hitmap.
447    ///
448    /// Assumes the copy has been submitted and the buffer mapped.
449    fn read_hitmap(&mut self, width: u32, height: u32) {
450        let pick_target = self.pick_target.as_ref().expect("pick target ensured");
451        let row_bytes = (width as usize) * 4;
452        let row_px = width as usize;
453        let hitmap = self.hitmap.get_or_insert_with(Vec::new);
454        hitmap.clear();
455        hitmap.resize(row_px * height as usize, 0);
456        // The pick target carries the display format, because one renderer
457        // targets one format. `pick::decode` reads an id out of a
458        // little-endian RGBA word, so a BGRA target needs its red and blue
459        // channels put back before that means anything.
460        let swizzle = matches!(
461            pick_target.format,
462            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
463        );
464        {
465            let data = pick_target.readback.slice(..).get_mapped_range();
466            let padded = pick_target.padded_bytes_per_row as usize;
467            for y in 0..height as usize {
468                let dst: &mut [u8] =
469                    bytemuck::cast_slice_mut(&mut hitmap[y * row_px..(y + 1) * row_px]);
470                dst.copy_from_slice(&data[y * padded..y * padded + row_bytes]);
471                if swizzle {
472                    for px in dst.chunks_exact_mut(4) {
473                        px.swap(0, 2);
474                    }
475                }
476            }
477        }
478        pick_target.readback.unmap();
479        self.hitmap_dims = Some((width, height));
480    }
481
482    /// Settle any deferred pick readback before a blocking path reuses the
483    /// buffer.
484    ///
485    /// `map_async` on a buffer with a map already outstanding is a validation
486    /// error, so a renderer that has been driven through
487    /// [`Self::render_to_texture_deferring_pick`] and is then rendered
488    /// blocking has to land the old readback first. Blocking is allowed on
489    /// these paths, so this waits.
490    fn settle_pending_pick(&mut self) -> Result<(), BackendError> {
491        if self.pick_pending.is_some() {
492            let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
493            self.try_finish_pick()?;
494        }
495        Ok(())
496    }
497
498    /// Rasterise the pick scene, read it back, and refresh the hitmap.
499    ///
500    /// Uses an encoder of its own and submits it separately from the display
501    /// pass. Both passes go through one renderer, whose per-frame coverage,
502    /// paint and glyph uploads are written while a pass is being *recorded* —
503    /// so sharing a command buffer would let this pass's uploads overwrite the
504    /// display pass's before the GPU consumed them.
505    fn submit_pick_blocking(&mut self, width: u32, height: u32) -> Result<(), BackendError> {
506        let mut encoder = self
507            .device
508            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
509                label: Some("hephaestus.hybrid.pick"),
510            });
511        let pick_view = self
512            .pick_target
513            .as_ref()
514            .expect("pick target ensured")
515            .view
516            .clone();
517        self.rasterise(&mut encoder, Pass::Pick, &pick_view, width, height)?;
518        {
519            let pick_target = self.pick_target.as_ref().expect("pick target ensured");
520            copy_to_readback(&mut encoder, pick_target, width, height);
521        }
522        self.queue.submit(std::iter::once(encoder.finish()));
523
524        let pick_target = self.pick_target.as_ref().expect("pick target ensured");
525        let (tx, rx) = futures_intrusive::channel::shared::oneshot_channel();
526        pick_target
527            .readback
528            .slice(..)
529            .map_async(wgpu::MapMode::Read, move |res| {
530                let _ = tx.send(res);
531            });
532        let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
533        await_map(pollster::block_on(rx.receive()))?;
534        self.read_hitmap(width, height);
535        Ok(())
536    }
537
538    /// Rasterise the pick scene and submit its readback without waiting.
539    ///
540    /// Pair with [`Self::try_finish_pick`]. Assumes the scene has already been
541    /// replayed for this frame.
542    fn submit_pick(&mut self, width: u32, height: u32) -> Result<(), BackendError> {
543        self.ensure_pick_target(width, height);
544        let mut encoder = self
545            .device
546            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
547                label: Some("hephaestus.hybrid.pick"),
548            });
549        let pick_view = self
550            .pick_target
551            .as_ref()
552            .expect("pick target ensured")
553            .view
554            .clone();
555        self.rasterise(&mut encoder, Pass::Pick, &pick_view, width, height)?;
556        let pick_target = self.pick_target.as_ref().expect("pick target ensured");
557        copy_to_readback(&mut encoder, pick_target, width, height);
558        self.queue.submit(std::iter::once(encoder.finish()));
559
560        let slot = std::sync::Arc::new(std::sync::Mutex::new(None));
561        let sink = std::sync::Arc::clone(&slot);
562        pick_target
563            .readback
564            .slice(..)
565            .map_async(wgpu::MapMode::Read, move |res| {
566                if let Ok(mut guard) = sink.lock() {
567                    *guard = Some(res);
568                }
569            });
570        self.pick_pending = Some(PendingPick {
571            slot,
572            width,
573            height,
574        });
575        Ok(())
576    }
577
578    /// Drain a readback submitted by [`Self::submit_pick`] into the hitmap,
579    /// if it has landed.
580    ///
581    /// Returns whether the hitmap was refreshed: `false` means nothing was in
582    /// flight, or the GPU has not finished. Never blocks, so a host that
583    /// cannot park a thread calls this and accepts that the hitmap may lag
584    /// the drawn frame.
585    ///
586    /// Only meaningful after [`Self::render_to_texture_deferring_pick`]; the
587    /// blocking render paths drain their own readback before returning.
588    pub fn try_finish_pick(&mut self) -> Result<bool, BackendError> {
589        let Some(pending) = self.pick_pending.as_ref() else {
590            return Ok(false);
591        };
592        let landed = pending
593            .slot
594            .lock()
595            .map_err(|_| BackendError::Readback("pick readback slot poisoned".into()))?
596            .take();
597        let Some(result) = landed else {
598            return Ok(false);
599        };
600        let PendingPick { width, height, .. } =
601            self.pick_pending.take().expect("checked just above");
602        result.map_err(|e| BackendError::Readback(e.to_string()))?;
603        self.read_hitmap(width, height);
604        Ok(true)
605    }
606
607    /// Rasterise into `view` and submit the pick pass without waiting on it.
608    ///
609    /// The non-blocking counterpart to
610    /// [`WgpuRenderer::render_to_texture`](crate::WgpuRenderer::render_to_texture),
611    /// whose pick readback parks the calling thread until the GPU is done —
612    /// which a browser's main thread cannot do. Pair with
613    /// [`Self::try_finish_pick`]: until that drains, [`Self::pick_at`] keeps
614    /// answering from the last frame that landed.
615    pub fn render_to_texture_deferring_pick(
616        &mut self,
617        view: &wgpu::TextureView,
618        width: u32,
619        height: u32,
620        background: Color,
621    ) -> Result<(), BackendError> {
622        let mut encoder = self
623            .device
624            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
625                label: Some("hephaestus.hybrid.render_to_texture"),
626            });
627        let format = self.target_format;
628        self.prepare(width, height, background, format, &mut encoder)?;
629        self.rasterise(&mut encoder, Pass::Display, view, width, height)?;
630        self.queue.submit(std::iter::once(encoder.finish()));
631
632        if self.refreshes_pick() {
633            // Drain first: that unmaps the readback buffer, and `map_async`
634            // on a still-mapped buffer is a validation error. Draining also
635            // has to happen before `ensure_pick_target`, which may reallocate
636            // the target the in-flight readback is reading from.
637            self.try_finish_pick()?;
638            // Still in flight — skip this frame rather than queue a second
639            // map on the same buffer. The hitmap lags until it lands, which
640            // `pick_at` already documents.
641            if self.pick_pending.is_none() {
642                self.submit_pick(width, height)?;
643            }
644        }
645        Ok(())
646    }
647
648    /// Shared front half of both render entry points: validate the size,
649    /// rebuild size-bound state, upload images, and replay the recording.
650    fn prepare(
651        &mut self,
652        width: u32,
653        height: u32,
654        background: Color,
655        format: wgpu::TextureFormat,
656        encoder: &mut wgpu::CommandEncoder,
657    ) -> Result<(), BackendError> {
658        if width == 0 || height == 0 {
659            return Err(BackendError::Other(
660                "cannot render a zero-sized frame".into(),
661            ));
662        }
663        self.ensure_sized(width, height, format)?;
664        self.upload_images(encoder)?;
665        self.replay(background, width, height, self.refresh_pick);
666        Ok(())
667    }
668}
669
670impl Renderer for HybridRenderer {
671    type Scene = HybridScene;
672
673    fn scene(&mut self) -> &mut Self::Scene {
674        &mut self.scene
675    }
676
677    fn render_to_buffer(
678        &mut self,
679        width: u32,
680        height: u32,
681        background: Color,
682        out: &mut [u8],
683    ) -> Result<(), BackendError> {
684        let expected = (width as usize) * (height as usize) * 4;
685        if out.len() != expected {
686            return Err(BackendError::BufferSize {
687                expected,
688                actual: out.len(),
689            });
690        }
691
692        let mut encoder = self
693            .device
694            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
695                label: Some("hephaestus.hybrid.render"),
696            });
697        self.prepare(
698            width,
699            height,
700            background,
701            wgpu::TextureFormat::Rgba8Unorm,
702            &mut encoder,
703        )?;
704        if self
705            .target
706            .as_ref()
707            .is_none_or(|t| t.width != width || t.height != height)
708        {
709            self.target = Some(Target::new(
710                &self.device,
711                width,
712                height,
713                wgpu::TextureFormat::Rgba8Unorm,
714            ));
715        }
716        let picking = self.refreshes_pick();
717        if picking {
718            self.settle_pending_pick()?;
719            self.ensure_pick_target(width, height);
720        }
721
722        let display_view = self.target.as_ref().expect("target ensured").view.clone();
723        self.rasterise(&mut encoder, Pass::Display, &display_view, width, height)?;
724        {
725            let target = self.target.as_ref().expect("target ensured");
726            copy_to_readback(&mut encoder, target, width, height);
727        }
728        // Submit before the pick pass is recorded, not after. Rasterising a
729        // scene writes this frame's coverage, paints and glyphs into
730        // renderer-owned textures, and both passes share one renderer — so
731        // recording them into a single command buffer would let the pick
732        // pass's uploads land before the GPU ran the display pass, and the
733        // display would come out reading the pick pass's binary coverage.
734        self.queue.submit(std::iter::once(encoder.finish()));
735        if picking {
736            self.submit_pick_blocking(width, height)?;
737        }
738        let target = self.target.as_ref().expect("target ensured");
739
740        let display_slice = target.readback.slice(..);
741        let (display_tx, display_rx) = futures_intrusive::channel::shared::oneshot_channel();
742        display_slice.map_async(wgpu::MapMode::Read, move |res| {
743            let _ = display_tx.send(res);
744        });
745        let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
746        await_map(pollster::block_on(display_rx.receive()))?;
747
748        let row_bytes = (width as usize) * 4;
749        {
750            let data = display_slice.get_mapped_range();
751            let padded = target.padded_bytes_per_row as usize;
752            for y in 0..height as usize {
753                out[y * row_bytes..(y + 1) * row_bytes]
754                    .copy_from_slice(&data[y * padded..y * padded + row_bytes]);
755            }
756        }
757        target.readback.unmap();
758        // The rasteriser composites premultiplied; every `Renderer` hands out
759        // straight alpha.
760        unpremultiply(out);
761        Ok(())
762    }
763}
764
765impl WgpuRenderer for HybridRenderer {
766    const REQUIRED_TARGET_USAGE: wgpu::TextureUsages = wgpu::TextureUsages::RENDER_ATTACHMENT;
767
768    const TARGET_IS_PREMULTIPLIED: bool = true;
769
770    fn render_to_texture(
771        &mut self,
772        view: &wgpu::TextureView,
773        width: u32,
774        height: u32,
775        background: Color,
776    ) -> Result<(), BackendError> {
777        let mut encoder = self
778            .device
779            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
780                label: Some("hephaestus.hybrid.render_to_texture"),
781            });
782        let format = self.target_format;
783        self.prepare(width, height, background, format, &mut encoder)?;
784        self.rasterise(&mut encoder, Pass::Display, view, width, height)?;
785        // Submitted before the pick pass is recorded — see
786        // `submit_pick_blocking` for why they cannot share a command buffer.
787        self.queue.submit(std::iter::once(encoder.finish()));
788
789        if self.refreshes_pick() {
790            self.settle_pending_pick()?;
791            self.ensure_pick_target(width, height);
792            self.submit_pick_blocking(width, height)?;
793        }
794        Ok(())
795    }
796}
797
798/// Queue the texture-to-buffer copy that drains a target to CPU.
799fn copy_to_readback(encoder: &mut wgpu::CommandEncoder, target: &Target, width: u32, height: u32) {
800    encoder.copy_texture_to_buffer(
801        wgpu::TexelCopyTextureInfo {
802            texture: &target.texture,
803            mip_level: 0,
804            origin: wgpu::Origin3d::ZERO,
805            aspect: wgpu::TextureAspect::All,
806        },
807        wgpu::TexelCopyBufferInfo {
808            buffer: &target.readback,
809            layout: wgpu::TexelCopyBufferLayout {
810                offset: 0,
811                bytes_per_row: Some(target.padded_bytes_per_row),
812                rows_per_image: Some(height),
813            },
814        },
815        wgpu::Extent3d {
816            width,
817            height,
818            depth_or_array_layers: 1,
819        },
820    );
821}
822
823/// Turn a `map_async` completion into a backend error.
824fn await_map(res: Option<Result<(), wgpu::BufferAsyncError>>) -> Result<(), BackendError> {
825    match res {
826        Some(Ok(())) => Ok(()),
827        Some(Err(e)) => Err(BackendError::Readback(e.to_string())),
828        None => Err(BackendError::Readback("map_async sender dropped".into())),
829    }
830}