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

Round join and all cap styles #414

Merged
merged 5 commits into from
Nov 22, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 119 additions & 43 deletions shader/flatten.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ let MAX_QUADS = 16u;
// When subdividing the cubic in its local coordinate space, the scale factor gets decomposed out of
// the local-to-device transform and gets factored into the tolerance threshold when estimating
// subdivisions.
fn flatten_cubic(cubic: Cubic, local_to_device: Transform, offset: f32) {
fn flatten_cubic(cubic: CubicPoints, path_ix: u32, local_to_device: Transform, offset: f32) {
var p0: vec2f;
var p1: vec2f;
var p2: vec2f;
Expand Down Expand Up @@ -209,7 +209,7 @@ fn flatten_cubic(cubic: Cubic, local_to_device: Transform, offset: f32) {
var qp1 = eval_cubic(p0, p1, p2, p3, t - 0.5 * step);
qp1 = 2.0 * qp1 - 0.5 * (qp0 + qp2);

// TODO: Estimate an accurate subdivision count for strokes, handling cusps.
// TODO: Estimate an accurate subdivision count for strokes
let params = estimate_subdiv(qp0, qp1, qp2, scaled_sqrt_tol);
keep_params[i] = params;
val += params.val;
Expand Down Expand Up @@ -262,14 +262,14 @@ fn flatten_cubic(cubic: Cubic, local_to_device: Transform, offset: f32) {
n1 = eval_quad_normal(qp0, qp1, qp2, t1);
}
n1 *= offset;
output_two_lines_with_transform(cubic.path_ix,
output_two_lines_with_transform(path_ix,
lp0 + n0, lp1 + n1,
lp1 - n1, lp0 - n0,
transform);
n0 = n1;
} else {
// Output line segment lp0..lp1
output_line_with_transform(cubic.path_ix, lp0, lp1, transform);
output_line_with_transform(path_ix, lp0, lp1, transform);
}
n_out += 1u;
val_target += v_step;
Expand All @@ -280,39 +280,104 @@ fn flatten_cubic(cubic: Cubic, local_to_device: Transform, offset: f32) {
}
}

// Flattens the circular arc that subtends the angle begin-center-end. It is assumed that
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not obvious from this comment how the direction is chosen. It must be based on the value of angle, but it feels like 'pacman' style arcs will be difficult.

I worry that if n_lines is big, the arc would draw the full way around, but if n_lines is small, it would end up drawing the closing part, rather than the full way around

I'm not sure that this is coherent - my last maths education was many years ago.

Maybe this would only be an issue if n_lines is 1, which is impossible?

Copy link
Collaborator Author

@armansito armansito Nov 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The direction of the arc is always a counter-clockwise rotation starting from begin, towards end, centered at center, and will be subtended by angle (which is assumed to be positive). If angle terminates the arc before it smoothly reaches end, a line will be drawn from the end of the arc towards end. I will document this.

n_lines only depends on the radius of the arc and the chosen tolerance. For n_lines to be $1$, theta must be close to $6.2831853$ ($2\pi$). acos can't return that by definition. If you somehow got n_lines == 1, basically no arc would be drawn and you'd get a line connecting begin and end.

Realistically, n_lines could be as low as $2$ if the absolute value of x is very small, which can happen if tol and radius are close to each other (the code prevents radius from being smaller than tol, so that case isn't possible). You'd only get into that situation with a sub-pixel radius. At that scale, the coarseness of the approximation won't be easily noticeable. If you increased the tolerance to make this possible at larger radii, with n_lines == 2 you'd get a shape resembling a wedge/triangle (I tested this locally, which works as you'd expect).

That said, this function is meant for drawing caps and joins where angle$\le\pi$ and the begin, center, and end points are chosen carefully (so that the angle and winding make sense).

Though there is something that should get fixed: I think selecting 1u when theta <= EPS is the wrong thing to do and this is a bug. That happens when the subdivision count tends to infinity. It's probably better to define a MAX_LINES and return that instead.

Copy link
Collaborator Author

@armansito armansito Nov 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To illustrate what I mean, here is what the code draws if I center the center of a round join directly below the join control point and hardcode an angle of $343.77\degree$:

  • n_lines == 1:
Screenshot 2023-11-17 at 12 10 01 PM
  • n_lines == 2:
Screenshot 2023-11-17 at 12 10 44 PM
  • n_lines == 3:
Screenshot 2023-11-17 at 12 11 29 PM
  • n_lines == 6:
Screenshot 2023-11-17 at 12 12 18 PM
  • n_lines == 100:
Screenshot 2023-11-17 at 12 12 58 PM

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the very detailed reply!

I hadn't clocked the additional invariants being maintained by the callers

Those pictures are very cool as well!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm glad they were helpful 🙂. I updated the comment above flatten_arc in latest commit.

// ||begin - center|| == ||end - center||. `begin`, `end`, and `center` are defined in the path's
// local coordinate space.
//
// The direction of the arc is always a counter-clockwise (Y-down) rotation starting from `begin`,
// towards `end`, centered at `center`, and will be subtended by `angle` (which is assumed to be
// positive). A line segment will always be drawn from the arc's terminus to `end`, regardless of
// `angle`.
//
// `begin`, `end`, center`, and `angle` should be chosen carefully to ensure a smooth arc with the
// correct winding.
fn flatten_arc(
path_ix: u32, begin: vec2f, end: vec2f, center: vec2f, angle: f32, transform: Transform
) {
var p0 = transform_apply(transform, begin);
var r = begin - center;

let EPS = 1e-9;
let tol = 0.5;
let radius = max(tol, length(p0 - transform_apply(transform, center)));
let x = 1. - tol / radius;
let theta = acos(clamp(2. * x * x - 1., -1., 1.));
let MAX_LINES = 1000u;
let n_lines = select(min(MAX_LINES, u32(ceil(6.2831853 / theta))), MAX_LINES, theta <= EPS);

let th = angle / f32(n_lines);
let c = cos(th);
let s = sin(th);
let rot = mat2x2(c, -s, s, c);

let line_ix = atomicAdd(&bump.lines, n_lines);
for (var i = 0u; i < n_lines - 1u; i += 1u) {
r = rot * r;
let p1 = transform_apply(transform, center + r);
write_line(line_ix + i, path_ix, p0, p1);
p0 = p1;
}
let p1 = transform_apply(transform, end);
write_line(line_ix + n_lines - 1u, path_ix, p0, p1);
}

fn draw_cap(
path_ix: u32, cap_style: u32, point: vec2f,
cap0: vec2f, cap1: vec2f, offset_tangent: vec2f,
transform: Transform,
) {
if cap_style == STYLE_FLAGS_CAP_ROUND {
flatten_arc(path_ix, cap0, cap1, point, 3.1415927, transform);
return;
}

var start = cap0;
var end = cap1;
let is_square = (cap_style == STYLE_FLAGS_CAP_SQUARE);
let line_ix = atomicAdd(&bump.lines, select(1u, 3u, is_square));
if is_square {
let v = offset_tangent;
let p0 = start + v;
let p1 = end + v;
write_line_with_transform(line_ix + 1u, path_ix, start, p0, transform);
write_line_with_transform(line_ix + 2u, path_ix, p1, end, transform);
start = p0;
end = p1;
}
write_line_with_transform(line_ix, path_ix, start, end, transform);
}

fn draw_join(
stroke: vec2f, path_ix: u32, style_flags: u32, p0: vec2f,
path_ix: u32, style_flags: u32, p0: vec2f,
tan_prev: vec2f, tan_next: vec2f,
n_prev: vec2f, n_next: vec2f,
transform: Transform,
) {
var front0 = p0 + n_prev;
let front1 = p0 + n_next;
var back0 = p0 - n_next;
let back1 = p0 - n_prev;

let cr = tan_prev.x * tan_next.y - tan_prev.y * tan_next.x;
let d = dot(tan_prev, tan_next);

switch style_flags & STYLE_FLAGS_JOIN_MASK {
case /*STYLE_FLAGS_JOIN_BEVEL*/0u: {
output_two_lines_with_transform(path_ix,
p0 + n_prev, p0 + n_next,
p0 - n_next, p0 - n_prev,
transform);
output_two_lines_with_transform(path_ix, front0, front1, back0, back1, transform);
}
case /*STYLE_FLAGS_JOIN_MITER*/0x10000000u: {
let c = tan_prev.x * tan_next.y - tan_prev.y * tan_next.x;
let d = dot(tan_prev, tan_next);
let hypot = length(vec2f(c, d));
let hypot = length(vec2f(cr, d));
let miter_limit = unpack2x16float(style_flags & STYLE_MITER_LIMIT_MASK)[0];

var front0 = p0 + n_prev;
let front1 = p0 + n_next;
var back0 = p0 - n_next;
let back1 = p0 - n_prev;
var line_ix: u32;

if 2. * hypot < (hypot + d) * miter_limit * miter_limit && c != 0. {
let is_backside = c > 0.;
if 2. * hypot < (hypot + d) * miter_limit * miter_limit && cr != 0. {
let is_backside = cr > 0.;
let fp_last = select(front0, back1, is_backside);
let fp_this = select(front1, back0, is_backside);
let p = select(front0, back0, is_backside);

let v = fp_this - fp_last;
let h = (tan_prev.x * v.y - tan_prev.y * v.x) / c;
let h = (tan_prev.x * v.y - tan_prev.y * v.x) / cr;
let miter_pt = fp_this - tan_next * h;

line_ix = atomicAdd(&bump.lines, 3u);
Expand All @@ -331,11 +396,23 @@ fn draw_join(
write_line_with_transform(line_ix + 1u, path_ix, back0, back1, transform);
}
case /*STYLE_FLAGS_JOIN_ROUND*/0x20000000u: {
// TODO: round join
output_two_lines_with_transform(path_ix,
p0 + n_prev, p0 + n_next,
p0 - n_next, p0 - n_prev,
transform);
var arc0: vec2f;
var arc1: vec2f;
var other0: vec2f;
var other1: vec2f;
if cr > 0. {
arc0 = back0;
arc1 = back1;
other0 = front0;
other1 = front1;
} else {
arc0 = front0;
arc1 = front1;
other0 = back0;
other1 = back1;
}
flatten_arc(path_ix, arc0, arc1, p0, abs(atan2(cr, d)), transform);
output_line_with_transform(path_ix, other0, other1, transform);
}
default: {}
}
Expand Down Expand Up @@ -524,8 +601,8 @@ fn read_neighboring_segment(ix: u32) -> NeighboringSegment {
// `pathdata_base` is decoded once and reused by helpers above.
var<private> pathdata_base: u32;

// This is the bounding box of the shape flattened by a single shader invocation. This is adjusted
// as lines are generated.
// This is the bounding box of the shape flattened by a single shader invocation. It gets modified
// during LineSoup generation.
var<private> bbox: vec4f;

@compute @workgroup_size(256)
Expand Down Expand Up @@ -557,46 +634,45 @@ fn main(
let transform = read_transform(config.transform_base, trans_ix);
let pts = read_path_segment(tag, is_stroke);

var stroke = vec2(0.0, 0.0);
if is_stroke {
let linewidth = bitcast<f32>(scene[config.style_base + style_ix + 1u]);
let offset = 0.5 * linewidth;

// See https://www.iquilezles.org/www/articles/ellipses/ellipses.htm
// This is the correct bounding box, but we're not handling rendering
// in the isotropic case, so it may mismatch.
stroke = offset * vec2(length(transform.mat.xz), length(transform.mat.yw));

let is_open = (tag.tag_byte & PATH_TAG_SEG_TYPE) != PATH_TAG_LINETO;
let is_stroke_cap_marker = (tag.tag_byte & PATH_TAG_SUBPATH_END) != 0u;
if is_stroke_cap_marker {
if is_open {
// Draw start cap (butt)
let n = offset * cubic_start_normal(pts.p0, pts.p1, pts.p2, pts.p3);
output_line_with_transform(path_ix, pts.p0 - n, pts.p0 + n, transform);
// Draw start cap
let tangent = cubic_start_tangent(pts.p0, pts.p1, pts.p2, pts.p3);
let offset_tangent = offset * normalize(tangent);
let n = offset_tangent.yx * vec2f(-1., 1.);
draw_cap(path_ix, (style_flags & STYLE_FLAGS_START_CAP_MASK) >> 2u,
pts.p0, pts.p0 - n, pts.p0 + n, -offset_tangent, transform);
} else {
// Don't draw anything if the path is closed.
}
} else {
// Render offset curves
flatten_cubic(Cubic(pts.p0, pts.p1, pts.p2, pts.p3, stroke, path_ix, u32(is_stroke)), transform, offset);
flatten_cubic(pts, path_ix, transform, offset);

// Read the neighboring segment.
let neighbor = read_neighboring_segment(ix + 1u);
let tan_prev = cubic_end_tangent(pts.p0, pts.p1, pts.p2, pts.p3);
let tan_next = neighbor.tangent;
let n_prev = offset * (normalize(tan_prev).yx * vec2f(-1., 1.));
let n_next = offset * (normalize(tan_next).yx * vec2f(-1., 1.));
let offset_tangent = offset * normalize(tan_prev);
let n_prev = offset_tangent.yx * vec2f(-1., 1.);
let n_next = offset * normalize(tan_next).yx * vec2f(-1., 1.);
if neighbor.do_join {
draw_join(stroke, path_ix, style_flags, pts.p3,
tan_prev, tan_next, n_prev, n_next, transform);
draw_join(path_ix, style_flags, pts.p3, tan_prev, tan_next,
n_prev, n_next, transform);
} else {
// Draw end cap.
output_line_with_transform(path_ix, pts.p3 + n_prev, pts.p3 - n_prev, transform);
draw_cap(path_ix, (style_flags & STYLE_FLAGS_END_CAP_MASK),
pts.p3, pts.p3 + n_prev, pts.p3 - n_prev, offset_tangent, transform);
}
}
} else {
flatten_cubic(Cubic(pts.p0, pts.p1, pts.p2, pts.p3, stroke, path_ix, u32(is_stroke)), transform, 0.);
flatten_cubic(pts, path_ix, transform, /*offset*/ 0.);
}
// Update bounding box using atomics only. Computing a monoid is a
// potential future optimization.
Expand Down