hephaestus 0.2.0

Backend-agnostic 2D scene renderer for data visualization.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
//! Vello Hybrid backend: path processing and coverage on the CPU, a plain
//! render pipeline on the GPU.
//!
//! Two properties drive the design. First, coverage is computed CPU-side, so
//! the rasteriser can paint with *binary* coverage on request — a pixel is
//! either fully painted or not painted at all. That is what an id buffer
//! needs, and it is why picking here reports exactly one id per pixel rather
//! than a blend of two. Second, the GPU buffers are sized to the scene's
//! actual content instead of fixed caps, so there is no draw-count ceiling to
//! budget against.
//!
//! # Why the scene is recorded rather than written straight through
//!
//! `vello_hybrid::Scene` generates strips as each path arrives, which means it
//! needs the frame's pixel dimensions *before* the first draw. [`SceneBuilder`]
//! carries no size, so [`HybridScene`] records draws into a
//! [`RecordingScene`] and the renderer replays them once the size is known.
//! Replaying is also how the pick pass is produced: one recording feeds both
//! scenes, so enabling picking costs a second rasterisation but not a second
//! set of recorded draws.

use std::collections::HashMap;

use vello_common::paint::{ImageSource, PaintType};
use vello_hybrid::{Resources, Scene};

use crate::backend::{convert, mesh, BackendError};
use crate::blend::BlendMode;
use crate::brush::{Brush, Image, Sampling};
use crate::geometry::Affine;
use crate::mesh::Mesh;
use crate::path::{FillRule, Path};
use crate::pick::{self, PickId};
use crate::scene::recording::RecordingScene;
use crate::scene::{GlyphRun, SceneBuilder};
use crate::stroke::Stroke;

#[cfg(all(feature = "webgl", target_arch = "wasm32"))]
mod webgl;
#[cfg(feature = "vello-hybrid")]
mod wgpu_renderer;

#[cfg(all(feature = "webgl", target_arch = "wasm32"))]
pub use webgl::HybridWebGlRenderer;
#[cfg(feature = "vello-hybrid")]
pub use wgpu_renderer::HybridRenderer;

/// Coverage a pick pixel must exceed to be painted at all.
///
/// The midpoint: a pixel belongs to whichever mark covers most of it. Any
/// value disables antialiasing; the choice only decides which side of a
/// half-covered pixel wins.
const PICK_ALIASING_THRESHOLD: u8 = 128;

/// Minimum stroke width (in pixels) the pick pass uses, so hairline strokes
/// remain hittable even when the visual stroke is sub-pixel.
///
/// Binary coverage makes this load-bearing rather than a nicety: a stroke
/// thinner than the threshold covers no pixel past
/// [`PICK_ALIASING_THRESHOLD`] and would vanish from the hitmap entirely.
const MIN_PICK_STROKE_WIDTH: f64 = 2.0;

/// Largest scene dimension the rasteriser accepts, in pixels.
///
/// `vello_hybrid::Scene` sizes itself in `u16`.
pub const MAX_DIMENSION: u32 = u16::MAX as u32;

// ---------- Scene ----------

/// A [`SceneBuilder`] that records draws for the Hybrid renderer to replay.
///
/// Recording rather than rasterising immediately is what lets one set of draws
/// serve a frame whose size is only known at render time, and serve the
/// parallel pick pass as well. See the module docs.
#[derive(Debug, Default, Clone)]
pub struct HybridScene {
    ops: RecordingScene,
}

impl HybridScene {
    /// Build an empty scene.
    pub fn new() -> Self {
        Self::default()
    }

    /// Number of recorded draw operations.
    pub fn len(&self) -> usize {
        self.ops.ops.len()
    }

    /// True when nothing has been recorded.
    pub fn is_empty(&self) -> bool {
        self.ops.ops.is_empty()
    }
}

impl SceneBuilder for HybridScene {
    fn clear(&mut self) {
        self.ops.clear();
    }

    fn fill(
        &mut self,
        rule: FillRule,
        transform: Affine,
        brush: &Brush,
        brush_transform: Option<Affine>,
        path: &Path,
        pick_id: PickId,
    ) {
        self.ops
            .fill(rule, transform, brush, brush_transform, path, pick_id);
    }

    fn stroke(
        &mut self,
        stroke: &Stroke,
        transform: Affine,
        brush: &Brush,
        brush_transform: Option<Affine>,
        path: &Path,
        pick_id: PickId,
    ) {
        self.ops
            .stroke(stroke, transform, brush, brush_transform, path, pick_id);
    }

    fn draw_image(
        &mut self,
        image: &Image,
        transform: Affine,
        sampling: Sampling,
        alpha: f32,
        pick_id: PickId,
    ) {
        self.ops
            .draw_image(image, transform, sampling, alpha, pick_id);
    }

    fn draw_glyphs(&mut self, run: &GlyphRun<'_>, pick_id: PickId) {
        self.ops.draw_glyphs(run, pick_id);
    }

    fn draw_mesh(&mut self, mesh: &Mesh, transform: Affine, pick_id: PickId) {
        self.ops.draw_mesh(mesh, transform, pick_id);
    }

    fn push_layer(&mut self, blend: BlendMode, alpha: f32, transform: Affine, clip: &Path) {
        self.ops.push_layer(blend, alpha, transform, clip);
    }

    fn pop_layer(&mut self) {
        self.ops.pop_layer();
    }
}

// ---------- replay ----------

/// Which of the two scenes a replay is filling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Pass {
    /// The visible frame: the caller's brushes, blend modes and antialiasing.
    Display,
    /// The id buffer: solid encoded ids, normalised blending, binary coverage.
    Pick,
}

/// Key identifying an image's pixels, so one upload serves every draw of it.
///
/// Peniko blobs carry a process-local id, which is exactly the identity an
/// atlas wants: two handles onto the same decoded pixels share it.
fn image_key(image: &Image) -> u64 {
    image.data.id()
}

/// Replays recorded draws into a `vello_hybrid::Scene`.
///
/// One writer per pass. The pick pass differs in three ways: solid ids
/// replace brushes, blending and layer alpha are normalised so ids cannot
/// fade toward the no-hit sentinel, and hairline strokes are widened.
struct Writer<'a> {
    scene: &'a mut Scene,
    resources: &'a mut Resources,
    pass: Pass,
    /// Atlas handle per image, filled in before replay — uploading needs the
    /// device, which a [`SceneBuilder`] has no access to.
    images: &'a HashMap<u64, ImageSource>,
}

impl Writer<'_> {
    /// Paint for a draw, or `None` when this pass should skip the draw.
    fn paint(&self, brush: &Brush, pick_id: PickId) -> Option<PaintType> {
        match self.pass {
            Pass::Display => match brush {
                Brush::Solid(color) => Some((*color).into()),
                Brush::Gradient(gradient) => Some(gradient.clone().into()),
                Brush::Image(image) => self.image_paint(
                    &image.image,
                    image.sampler.quality,
                    image.sampler.x_extend,
                    image.sampler.y_extend,
                ),
            },
            Pass::Pick => pick::raw_id(pick_id).map(|id| pick::id_to_color(id).into()),
        }
    }

    /// Paint sampling an already-uploaded image, or `None` if it never made
    /// it into the atlas.
    fn image_paint(
        &self,
        image: &Image,
        quality: peniko::ImageQuality,
        x_extend: peniko::Extend,
        y_extend: peniko::Extend,
    ) -> Option<PaintType> {
        let source = self.images.get(&image_key(image))?;
        Some(
            vello_common::paint::Image {
                image: source.clone(),
                sampler: peniko::ImageSampler {
                    x_extend,
                    y_extend,
                    quality,
                    // Opacity cannot ride on the sampler: the paint encoder
                    // rejects any value but 1.0. Callers' alpha becomes an
                    // opacity layer instead.
                    alpha: 1.0,
                },
            }
            .into(),
        )
    }

    /// Apply the caller's transform and brush transform to the scene state.
    fn set_placement(&mut self, transform: Affine, brush_transform: Option<Affine>) {
        self.scene.set_transform(transform);
        match brush_transform {
            Some(bt) => self.scene.set_paint_transform(bt),
            None => self.scene.reset_paint_transform(),
        }
    }
}

impl SceneBuilder for Writer<'_> {
    fn clear(&mut self) {
        self.scene.reset();
    }

    fn fill(
        &mut self,
        rule: FillRule,
        transform: Affine,
        brush: &Brush,
        brush_transform: Option<Affine>,
        path: &Path,
        pick_id: PickId,
    ) {
        let Some(paint) = self.paint(brush, pick_id) else {
            return;
        };
        self.set_placement(transform, brush_transform);
        self.scene.set_fill_rule(convert::fill_rule(rule));
        self.scene.set_paint(paint);
        self.scene.fill_path(path);
    }

    fn stroke(
        &mut self,
        stroke: &Stroke,
        transform: Affine,
        brush: &Brush,
        brush_transform: Option<Affine>,
        path: &Path,
        pick_id: PickId,
    ) {
        let Some(paint) = self.paint(brush, pick_id) else {
            return;
        };
        let mut stroke = stroke.clone();
        if self.pass == Pass::Pick && stroke.width < MIN_PICK_STROKE_WIDTH {
            stroke.width = MIN_PICK_STROKE_WIDTH;
        }
        self.set_placement(transform, brush_transform);
        self.scene.set_stroke(stroke);
        self.scene.set_paint(paint);
        self.scene.stroke_path(path);
    }

    fn draw_image(
        &mut self,
        image: &Image,
        transform: Affine,
        sampling: Sampling,
        alpha: f32,
        pick_id: PickId,
    ) {
        let bounds = crate::geometry::Rect::new(0.0, 0.0, image.width.into(), image.height.into());
        let paint = match self.pass {
            Pass::Display => self.image_paint(
                image,
                convert::sampling_to_quality(sampling),
                peniko::Extend::Pad,
                peniko::Extend::Pad,
            ),
            Pass::Pick => pick::raw_id(pick_id).map(|id| pick::id_to_color(id).into()),
        };
        let Some(paint) = paint else {
            return;
        };
        // Image opacity has to be a layer rather than a sampler field; the
        // pick pass ignores it, since a faded id is a wrong id.
        let layered = self.pass == Pass::Display && alpha < 1.0;
        if layered {
            self.scene.push_opacity_layer(alpha);
        }
        self.set_placement(transform, None);
        self.scene.set_fill_rule(peniko::Fill::NonZero);
        self.scene.set_paint(paint);
        self.scene.fill_rect(&bounds);
        if layered {
            self.scene.pop_layer();
        }
    }

    fn draw_glyphs(&mut self, run: &GlyphRun<'_>, pick_id: PickId) {
        let Some(paint) = self.paint(run.brush, pick_id) else {
            return;
        };
        let layered = self.pass == Pass::Display && run.brush_alpha < 1.0;
        if layered {
            self.scene.push_opacity_layer(run.brush_alpha);
        }
        self.scene.set_transform(run.transform);
        self.scene.reset_paint_transform();
        self.scene.set_paint(paint);

        let stroked = match (self.pass, run.style) {
            (Pass::Display, Some(stroke)) => {
                self.scene.set_stroke(stroke.clone());
                true
            }
            // The pick pass fills glyph outlines whatever the display style:
            // an outlined glyph should still be hittable in its interior.
            _ => false,
        };

        let glyphs = run
            .glyphs
            .iter()
            .map(|g| glifo::Glyph {
                id: g.id,
                x: g.x,
                y: g.y,
            })
            .collect::<Vec<_>>();

        let mut builder = self
            .scene
            .glyph_run(self.resources, run.font.data())
            .font_size(run.font_size)
            .hint(run.hint);
        if let Some(gt) = run.glyph_transform {
            builder = builder.glyph_transform(gt);
        }
        if stroked {
            builder.stroke_glyphs(glyphs.into_iter());
        } else {
            builder.fill_glyphs(glyphs.into_iter());
        }

        if layered {
            self.scene.pop_layer();
        }
    }

    fn draw_mesh(&mut self, mesh_data: &Mesh, transform: Affine, pick_id: PickId) {
        mesh::decompose(mesh_data, transform, pick_id, self);
    }

    fn push_layer(&mut self, blend: BlendMode, alpha: f32, transform: Affine, clip: &Path) {
        self.scene.set_transform(transform);
        match self.pass {
            Pass::Display => self.scene.push_layer(
                Some(clip),
                Some(convert::blend_mode(blend)),
                Some(alpha),
                None,
                None,
            ),
            // Mirror the clip so subsequent draws are clipped identically,
            // but drop the blend mode and alpha: either would distort the
            // encoded ids, and a translucent layer would fade them toward
            // the no-hit sentinel.
            Pass::Pick => self.scene.push_layer(Some(clip), None, None, None, None),
        }
    }

    fn pop_layer(&mut self) {
        self.scene.pop_layer();
    }
}

fn dimension(v: u32) -> Result<u16, BackendError> {
    u16::try_from(v).map_err(|_| {
        BackendError::Other(format!(
            "frame dimension {v} exceeds the {MAX_DIMENSION} px the hybrid backend supports"
        ))
    })
}

/// Every distinct image the recording paints with, in first-drawn order.
///
/// Images arrive as CPU pixels but the rasteriser only samples handles into
/// its atlas, so each one has to be uploaded before a replay can reference it.
fn recorded_images(ops: &RecordingScene) -> Vec<&Image> {
    use crate::scene::recording::Op;

    let mut keys: Vec<u64> = Vec::new();
    let mut images: Vec<&Image> = Vec::new();
    for op in &ops.ops {
        let image = match op {
            Op::Fill { brush, .. } | Op::Stroke { brush, .. } => match brush {
                Brush::Image(b) => Some(&b.image),
                _ => None,
            },
            Op::DrawGlyphs(run) => match &run.brush {
                Brush::Image(b) => Some(&b.image),
                _ => None,
            },
            Op::DrawImage { image, .. } => Some(image),
            _ => None,
        };
        if let Some(image) = image {
            let key = image_key(image);
            if !keys.contains(&key) {
                keys.push(key);
                images.push(image);
            }
        }
    }
    images
}

/// Convert premultiplied RGBA8 in place to the straight alpha every
/// [`Renderer`](crate::backend::Renderer) hands out.
///
/// Only the wgpu path needs it: that is the one with a `render_to_buffer` to
/// hand bytes out of.
///
/// The rasteriser composites premultiplied, so without this a PNG writer
/// would darken every partially transparent pixel.
#[cfg(feature = "vello-hybrid")]
fn unpremultiply(buf: &mut [u8]) {
    for px in buf.chunks_exact_mut(4) {
        let a = px[3];
        if a == 0 || a == 255 {
            continue;
        }
        let a32 = u32::from(a);
        for c in &mut px[..3] {
            *c = ((u32::from(*c) * 255 + a32 / 2) / a32).min(255) as u8;
        }
    }
}