Skip to content

Commit

Permalink
Merge pull request #408 from linebender/gpu-shitty-strokes
Browse files Browse the repository at this point in the history
Basic GPU stroke rendering with support for caps and joins
  • Loading branch information
armansito committed Nov 14, 2023
2 parents 28914d6 + 7a122b6 commit e6c5bcf
Show file tree
Hide file tree
Showing 6 changed files with 480 additions and 108 deletions.
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

0 comments on commit e6c5bcf

Please sign in to comment.