Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic GPU stroke rendering with support for caps and joins #408

Merged
merged 12 commits into from
Nov 14, 2023
14 changes: 13 additions & 1 deletion crates/encoding/src/encoding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@

use super::{DrawColor, DrawTag, PathEncoder, PathTag, Style, Transform};

use peniko::{kurbo::Shape, BlendMode, BrushRef, Color, Fill};
use peniko::{
kurbo::{Shape, Stroke},
BlendMode, BrushRef, Color, Fill,
};

#[cfg(feature = "full")]
use {
Expand Down Expand Up @@ -172,6 +175,15 @@ impl Encoding {
}
}

/// Encodes a stroke style.
pub fn encode_stroke_style(&mut self, stroke: &Stroke) {
let style = Style::from_stroke(stroke);
if self.styles.last() != Some(&style) {
self.path_tags.push(PathTag::STYLE);
self.styles.push(style);
}
}

/// Encodes a transform.
///
/// If the given transform is different from the current one, encodes it and
Expand Down
141 changes: 134 additions & 7 deletions crates/encoding/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ pub struct PathEncoder<'a> {
n_segments: &'a mut u32,
n_paths: &'a mut u32,
first_point: [f32; 2],
first_start_tangent_end: [f32; 2],
state: PathState,
n_encoded_segments: u32,
is_fill: bool,
Expand All @@ -431,8 +432,42 @@ enum PathState {
}

impl<'a> PathEncoder<'a> {
/// Creates a new path encoder for the specified path tags and data. If `is_fill` is true,
/// ensures that all subpaths are closed.
/// Creates a new path encoder for the specified path tags and data.
///
/// If `is_fill` is true, ensures that all subpaths are closed. Otherwise, the path is treated
/// as a stroke and an additional "stroke cap marker" segment is inserted at the end of every
/// subpath.
///
/// Stroke Encoding
/// ---------------
/// Every subpath within a stroked path is terminated with a "stroke cap marker" segment. This
/// segment tells the GPU stroker whether to draw a cap or a join based on the topology of the
/// path:
///
/// 1. This marker segment is encoded as a `quad-to` (2 additional points) for an open path and
/// a `line-to` (1 additional point) for a closed path. An open path gets drawn with a start
/// and end cap. A closed path gets drawn with a single join in place of the caps where the
/// subpath's start and end control points meet.
///
/// 2. The marker segment tells the GPU flattening stage how to render caps and joins while
/// processing each path segment in parallel. All subpaths end with the marker segment which
/// is the only segment that has the `SUBPATH_END_BIT` set to 1.
///
/// The algorithm is as follows:
///
/// a) If a GPU thread is processing a regular segment (i.e. `SUBPATH_END_BIT` is 0), it
/// outputs the offset curves for the segment. If the segment is immediately followed by
/// the marker segment, then the same thread draws an end cap if the subpath is open
/// (i.e. the marker is a quad-to) or a join if the subpath is closed (i.e. the marker is
/// a line-to) using the tangent encoded in the marker segment.
///
/// If the segment is immediately followed by another regular segment, then the thread
/// draws a join using the start tangent of the neighboring segment.
///
/// b) If a GPU thread is processing the marker segment (i.e. `SUBPATH_END_BIT` is 1), then
/// it draws a start cap using the information encoded in the segment IF the subpath is
/// open (i.e. the marker is a quad-to). If the subpath is closed (i.e. the marker is a
/// line-to), the thread draws nothing.
pub fn new(
tags: &'a mut Vec<PathTag>,
data: &'a mut Vec<u8>,
Expand All @@ -446,6 +481,7 @@ impl<'a> PathEncoder<'a> {
n_segments,
n_paths,
first_point: [0.0, 0.0],
first_start_tangent_end: [0.0, 0.0],
state: PathState::Start,
n_encoded_segments: 0,
is_fill,
Expand All @@ -459,15 +495,18 @@ impl<'a> PathEncoder<'a> {
}
let buf = [x, y];
let bytes = bytemuck::bytes_of(&buf);
self.first_point = buf;
if self.state == PathState::MoveTo {
let new_len = self.data.len() - 8;
self.data.truncate(new_len);
} else if self.state == PathState::NonemptySubpath {
if !self.is_fill {
self.insert_stroke_cap_marker_segment(false);
}
if let Some(tag) = self.tags.last_mut() {
tag.set_subpath_end();
}
}
self.first_point = buf;
self.data.extend_from_slice(bytes);
self.state = PathState::MoveTo;
}
Expand All @@ -483,6 +522,16 @@ impl<'a> PathEncoder<'a> {
}
self.move_to(self.first_point[0], self.first_point[1]);
}
if self.state == PathState::MoveTo {
let p0 = (self.first_point[0], self.first_point[1]);
// Ensure that we don't end up with a zero-length start tangent.
let Some((x, y)) = start_tangent_for_curve(p0, (x, y), p0, p0) else {
// Drop the segment if its length is zero
// TODO: do this for all not segments, not just start.
return;
};
self.first_start_tangent_end = [x, y];
}
let buf = [x, y];
let bytes = bytemuck::bytes_of(&buf);
self.data.extend_from_slice(bytes);
Expand All @@ -500,6 +549,16 @@ impl<'a> PathEncoder<'a> {
}
self.move_to(self.first_point[0], self.first_point[1]);
}
if self.state == PathState::MoveTo {
let p0 = (self.first_point[0], self.first_point[1]);
// Ensure that we don't end up with a zero-length start tangent.
let Some((x, y)) = start_tangent_for_curve(p0, (x1, y1), (x2, y2), p0) else {
// Drop the segment if its length is zero
// TODO: do this for all not segments, not just start.
return;
};
self.first_start_tangent_end = [x, y];
}
let buf = [x1, y1, x2, y2];
let bytes = bytemuck::bytes_of(&buf);
self.data.extend_from_slice(bytes);
Expand All @@ -517,6 +576,16 @@ impl<'a> PathEncoder<'a> {
}
self.move_to(self.first_point[0], self.first_point[1]);
}
if self.state == PathState::MoveTo {
let p0 = (self.first_point[0], self.first_point[1]);
// Ensure that we don't end up with a zero-length start tangent.
let Some((x, y)) = start_tangent_for_curve(p0, (x1, y1), (x2, y2), (x3, y3)) else {
// Drop the segment if its length is zero
// TODO: do this for all not segments, not just start.
return;
};
self.first_start_tangent_end = [x, y];
}
let buf = [x1, y1, x2, y2, x3, y3];
let bytes = bytemuck::bytes_of(&buf);
self.data.extend_from_slice(bytes);
Expand Down Expand Up @@ -545,11 +614,13 @@ impl<'a> PathEncoder<'a> {
let first_bytes = bytemuck::bytes_of(&self.first_point);
if &self.data[len - 8..len] != first_bytes {
self.data.extend_from_slice(first_bytes);
let mut tag = PathTag::LINE_TO_F32;
tag.set_subpath_end();
self.tags.push(tag);
self.tags.push(PathTag::LINE_TO_F32);
self.n_encoded_segments += 1;
} else if let Some(tag) = self.tags.last_mut() {
}
if !self.is_fill {
self.insert_stroke_cap_marker_segment(true);
}
if let Some(tag) = self.tags.last_mut() {
tag.set_subpath_end();
}
self.state = PathState::Start;
Expand Down Expand Up @@ -592,6 +663,9 @@ impl<'a> PathEncoder<'a> {
self.data.truncate(new_len);
}
if self.n_encoded_segments != 0 {
if !self.is_fill && self.state == PathState::NonemptySubpath {
self.insert_stroke_cap_marker_segment(false);
}
if let Some(tag) = self.tags.last_mut() {
tag.set_subpath_end();
}
Expand All @@ -603,6 +677,27 @@ impl<'a> PathEncoder<'a> {
}
self.n_encoded_segments
}

fn insert_stroke_cap_marker_segment(&mut self, is_closed: bool) {
assert!(!self.is_fill);
assert!(self.state == PathState::NonemptySubpath);
if is_closed {
// We expect that the most recently encoded pair of coordinates in the path data stream
// contain the first control point in the path segment (see `PathEncoder::close`).
// Hence a line-to encoded here should embed the subpath's start tangent.
self.line_to(
self.first_start_tangent_end[0],
self.first_start_tangent_end[1],
);
} else {
self.quad_to(
self.first_point[0],
self.first_point[1],
self.first_start_tangent_end[0],
self.first_start_tangent_end[1],
);
}
}
}

#[cfg(feature = "full")]
Expand All @@ -628,6 +723,38 @@ impl fello::scale::Pen for PathEncoder<'_> {
}
}

// Returns the end point of the start tangent of a curve starting at `(x0, y0)`, or `None` if the
// curve is degenerate / has zero-length. The inputs are a sequence of control points that can
// represent a line, a quadratic Bezier, or a cubic Bezier. Lines and quadratic Beziers can be
// passed to this function by simply setting the invalid control point degrees equal to `(x0, y0)`.
fn start_tangent_for_curve(
p0: (f32, f32),
p1: (f32, f32),
p2: (f32, f32),
p3: (f32, f32),
) -> Option<(f32, f32)> {
debug_assert!(!p0.0.is_nan());
debug_assert!(!p0.1.is_nan());
debug_assert!(!p1.0.is_nan());
debug_assert!(!p1.1.is_nan());
debug_assert!(!p2.0.is_nan());
debug_assert!(!p2.1.is_nan());
debug_assert!(!p3.0.is_nan());
debug_assert!(!p3.1.is_nan());

const EPS: f32 = 1e-12;
let pt = if (p1.0 - p0.0).abs() > EPS || (p1.1 - p0.1).abs() > EPS {
p1
} else if (p2.0 - p0.0).abs() > EPS || (p2.1 - p0.1).abs() > EPS {
p2
} else if (p3.0 - p0.0).abs() > EPS || (p3.1 - p0.1).abs() > EPS {
p3
} else {
return None;
};
Some(pt)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
46 changes: 44 additions & 2 deletions examples/scenes/src/test_scenes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,29 @@ fn stroke_styles(sb: &mut SceneBuilder, params: &mut SceneParams) {
Color::rgb8(201, 147, 206),
Color::rgb8(150, 195, 160),
];
let simple_stroke = [LineTo((100., 0.).into())];
let simple_stroke = [MoveTo((0., 0.).into()), LineTo((100., 0.).into())];
let join_stroke = [
MoveTo((0., 0.).into()),
CurveTo((20., 0.).into(), (42.5, 5.).into(), (50., 25.).into()),
CurveTo((57.5, 5.).into(), (80., 0.).into(), (100., 0.).into()),
];
let miter_stroke = [LineTo((90., 21.).into()), LineTo((0., 42.).into())];
let miter_stroke = [
MoveTo((0., 0.).into()),
LineTo((90., 21.).into()),
LineTo((0., 42.).into()),
];
let closed_strokes = [
MoveTo((0., 0.).into()),
LineTo((90., 21.).into()),
LineTo((0., 42.).into()),
ClosePath,
MoveTo((200., 0.).into()),
CurveTo((100., 42.).into(), (300., 42.).into(), (200., 0.).into()),
ClosePath,
MoveTo((290., 0.).into()),
CurveTo((200., 42.).into(), (400., 42.).into(), (310., 0.).into()),
ClosePath,
];
let cap_styles = [Cap::Butt, Cap::Square, Cap::Round];
let join_styles = [Join::Bevel, Join::Miter, Join::Round];
let miter_limits = [4., 5., 0.1, 10.];
Expand Down Expand Up @@ -192,6 +209,31 @@ fn stroke_styles(sb: &mut SceneBuilder, params: &mut SceneParams) {
y += 180.;
color_idx = (color_idx + 1) % colors.len();
}

// Closed paths
for (i, join) in join_styles.iter().enumerate() {
params.text.add(
sb,
None,
12.,
None,
Affine::translate((0., y)) * t,
&format!("Closed path with join: {:?}", join),
);
// The cap style is not important since a closed path shouldn't have any caps.
sb.stroke(
&Stroke::new(10.)
.with_caps(cap_styles[i])
.with_join(*join)
.with_miter_limit(5.),
Affine::translate((0., y + 30.)) * t,
colors[color_idx],
None,
&closed_strokes,
);
y += 180.;
color_idx = (color_idx + 1) % colors.len();
}
}

// This test has been adapted from Skia's "trickycubicstrokes" GM slide which can be found at
Expand Down