Skip to content

Commit

Permalink
Make the segmentation of feathered curves more uniform
Browse files Browse the repository at this point in the history
The old method would have tiny pops and wobbles when the segmentation snapped while animating a feathered curve. With the recursion it also wasn't well suited to being moved to the GPU.

Redo the segmentation based entirely on uniform steps in tangent angle. As feather grows initially, we need smaller segments, but once we cross a certain threshold, we can start making the segments bigger again.

This introduces precision issues in some places where more joins are overlapping, but in these scenarios our end goal will be to render to a scaled-down, offscreen atlas, and this atlas can be fp32.

The uniform steps in tangent angle will also be very easy to move to the GPU.

Diffs=
7968fd06b8 Make the segmentation of feathered curves more uniform (#9008)

Co-authored-by: Chris Dalton <[email protected]>
  • Loading branch information
csmartdalton and csmartdalton committed Feb 10, 2025
1 parent 9d5c56f commit e21572f
Show file tree
Hide file tree
Showing 6 changed files with 224 additions and 224 deletions.
2 changes: 1 addition & 1 deletion .rive_head
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0d03957f838861065c01db2c58cd9e7adcd5976e
7968fd06b819e702e1a6fea94221e93e356c889a
16 changes: 2 additions & 14 deletions include/rive/math/bezier_utils.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -151,18 +151,6 @@ float measure_angle_between_vectors(Vec2D a, Vec2D b);
// on a degenerate flat line.
int find_cubic_convex_180_chops(const Vec2D[], float T[2], bool* areCusps);

// Returns up to 4 T values at which to chop the given curve in order to
// guarantee the resulting cubics are convex and rotate no more than 90 degrees.
//
// If the curve has any cusp points (proper cusps or 180-degree turnarounds on
// a degenerate flat line), the cusps are straddled with `cuspPadding` on either
// side and `areCusps` is set to true. In this cases, odd-numbered curves after
// chopping will always be the small sections that pass through the cusp.
int find_cubic_convex_90_chops(const Vec2D[],
float T[4],
float cuspPadding,
bool* areCusps);

// Find the location and value of a cubic's maximum height, relative to the
// baseline p0->p3.
float find_cubic_max_height(const Vec2D pts[4], float* outT);
Expand Down Expand Up @@ -203,8 +191,8 @@ RIVE_ALWAYS_INLINE void find_cubic_tangents(const Vec2D p[4], Vec2D tangents[2])
}

RIVE_ALWAYS_INLINE constexpr float pow2(float x) { return x * x; }

RIVE_ALWAYS_INLINE constexpr float length_squared(Vec2D v)
RIVE_ALWAYS_INLINE constexpr float pow3(float x) { return x * pow2(x); }
RIVE_ALWAYS_INLINE constexpr float length_pow2(Vec2D v)
{
return pow2(v.x) + pow2(v.y);
}
Expand Down
4 changes: 2 additions & 2 deletions renderer/include/rive/renderer/gpu.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ constexpr static uint32_t kMaxPolarSegments = 1023;
// The Gaussian distribution is very blurry on the outer edges. Regardless of
// how wide a feather is, the polar segments never need to have a finer angle
// than this value.
constexpr static float FEATHER_POLAR_SEGMENT_MIN_ANGLE = math::PI / 16;
constexpr static float FEATHER_POLAR_SEGMENT_MIN_ANGLE = 3 * math::PI / 32;

// cos(FEATHER_MIN_POLAR_SEGMENT_ANGLE / 2)
constexpr static float COS_FEATHER_POLAR_SEGMENT_MIN_ANGLE_OVER_2 =
0.99518472667f;
0.98917650996f;

// We allocate all our GPU buffers in rings. This ensures the CPU can prepare
// frames in parallel while the GPU renders them.
Expand Down
106 changes: 61 additions & 45 deletions renderer/src/rive_render_path.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,11 @@ static void add_softened_cubic_for_feathering(RawPath* featheredPath,
{
math::CubicCoeffs coeffs(p);

// If we rotate too much _and_ the endpoints are further apart than 1
// standard deviation of each other, chop and recurse.
// ("feather" is 2 standard deviations, so (feather^2)/4 == one_stddev^2.)
if (abs(totalRotation) > abs(rotationBetweenJoins) + 1e-2f &&
math::length_squared(p[3] - p[0]) > feather * feather * .25f)
// Recurse until each segment rotates by no more than approximately
// "rotationBetweenJoins" radians.
// TODO: Now that this recursion is uniform, we can move the chopping to the
// GPU.
if (abs(totalRotation) > abs(rotationBetweenJoins) + 1e-4f)
{
// The cubic rotates more than rotationBetweenJoins. Find a boundary
// of rotationBetweenJoins toward the center to chop on.
Expand Down Expand Up @@ -220,6 +220,18 @@ static void add_softened_cubic_for_feathering(RawPath* featheredPath,
desiredSpread);
float dimming = 1 - theta * (1 / math::PI);

// It gets hard to measure curvature on short segments. Also taper down to
// completely flat as the distance between endpoints moves from 2 standard
// deviations to 1.
float stddevsPow2 =
math::length_pow2(p[3] - p[0]) / (.25 * math::pow2(feather));
float dimmingByStddevs = (stddevsPow2 - 1) * .5f;
dimming = fminf(dimming, dimmingByStddevs);

// Unfortunately, the best method we have to get rid of some final speckles
// on cusps is to dim everything by 1%.
dimming = fminf(dimming, .99f);

// Soften the feather by reducing the curve height. Find a new height such
// that the center of the feather (currently 50% opacity) is reduced to
// "50% * dimming".
Expand Down Expand Up @@ -251,11 +263,27 @@ rcp<RiveRenderPath> RiveRenderPath::makeSoftenedCopyForFeathering(
{
// Since curvature is what breaks 1-dimensional feathering along the normal
// vector, chop into segments that rotate no more than a certain threshold.
constexpr static int POLAR_JOIN_PRECISION = 2;
float r_ = feather * (FEATHER_TEXTURE_STDDEVS / 2) * matrixMaxScale * .25f;
float polarSegmentsPerRadian =
math::calc_polar_segments_per_radian<gpu::kPolarPrecision>(r_);
float rotationBetweenJoins = std::max(1 / polarSegmentsPerRadian,
gpu::FEATHER_POLAR_SEGMENT_MIN_ANGLE);
math::calc_polar_segments_per_radian<POLAR_JOIN_PRECISION>(r_);
float rotationBetweenJoins = 1 / polarSegmentsPerRadian;
if (rotationBetweenJoins < gpu::FEATHER_POLAR_SEGMENT_MIN_ANGLE)
{
// Once we cross the FEATHER_POLAR_SEGMENT_MIN_ANGLE threshold, we start
// needing fewer polar joins again. Mirror at this point and begin
// adding back space between the joins.
// TODO: This formula is founded entirely in what feels good visually.
// It has almost no mathematical method. We can probably improve it.
rotationBetweenJoins =
gpu::FEATHER_POLAR_SEGMENT_MIN_ANGLE +
math::pow3(
(gpu::FEATHER_POLAR_SEGMENT_MIN_ANGLE - rotationBetweenJoins) *
5.f);
}
// Our math that flattens feathered curves relies on curves not rotating
// more than 90 degrees.
rotationBetweenJoins = std::min(rotationBetweenJoins, math::PI / 2);

RawPath featheredPath;
// Reserve a generous amount of space upfront so we hopefully don't have to
Expand All @@ -280,50 +308,38 @@ rcp<RiveRenderPath> RiveRenderPath::makeSoftenedCopyForFeathering(
float T[4];
Vec2D chops[(std::size(T) + 1) * 3 + 1]; // 4 chops will produce
// 16 cubic vertices.
bool areCusps;
bool areCusps; // Ignored. Polar joins handle cusps without us
// having to do anything special.
// A generous cusp padding looks better empirically.
constexpr static float CUSP_PADDING = 1e-2f;
int n = math::find_cubic_convex_90_chops(pts,
T,
CUSP_PADDING,
&areCusps);
int n = math::find_cubic_convex_180_chops(pts, T, &areCusps);
math::chop_cubic_at(pts, chops, T, n);
Vec2D* p = chops;
for (int i = 0; i <= n; ++i, p += 3)
{
if (areCusps && (i & 1))
{
// If the chops are straddling cusps, odd-numbered chops
// are the ones that pass through a cusp.
featheredPath.line(p[3]);
}
else
Vec2D tangents[2];
math::find_cubic_tangents(p, tangents);
// Determine which the direction the curve turns.
// NOTE: Since the curve does not inflect, we can just
// check F'(.5) x F''(.5).
// NOTE: F'(.5) x F''(.5) has the same sign as
// (p2 - p0) x (p3 - p1).
float turn = Vec2D::cross(p[2] - p[0], p[3] - p[1]);
if (turn == 0)
{
Vec2D tangents[2];
math::find_cubic_tangents(p, tangents);
// Determine which the direction the curve turns.
// NOTE: Since the curve does not inflect, we can just
// check F'(.5) x F''(.5).
// NOTE: F'(.5) x F''(.5) has the same sign as
// (p2 - p0) x (p3 - p1).
float turn = Vec2D::cross(p[2] - p[0], p[3] - p[1]);
if (turn == 0)
{
// This is the case for joins and cusps where points
// are co-located.
turn = Vec2D::cross(tangents[0], tangents[1]);
}
float totalRotation = copysignf(
math::measure_angle_between_vectors(tangents[0],
tangents[1]),
turn);
add_softened_cubic_for_feathering(
&featheredPath,
p,
feather,
copysignf(rotationBetweenJoins, totalRotation),
totalRotation);
// This is the case for joins and cusps where points
// are co-located.
turn = Vec2D::cross(tangents[0], tangents[1]);
}
float totalRotation = copysignf(
math::measure_angle_between_vectors(tangents[0],
tangents[1]),
turn);
add_softened_cubic_for_feathering(
&featheredPath,
p,
feather,
copysignf(rotationBetweenJoins, totalRotation),
totalRotation);
}
break;
}
Expand Down
151 changes: 0 additions & 151 deletions src/math/bezier_utils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -329,157 +329,6 @@ int find_cubic_convex_180_chops(const Vec2D pts[], float T[2], bool* areCusps)
return 0;
}

int find_cubic_convex_90_chops(const Vec2D pts[],
float outT[4],
float cuspPadding,
bool* areCusps)
{
assert(pts);
assert(outT);
assert(areCusps);

// Now find the cubic's inflection function.
// There are inflections where F' x F'' == 0.
//
// We formulate this as a quadratic equation:
//
// F' x F'' == a * T^2 + b * T + c == 0.
//
// See:
// https://www.microsoft.com/en-us/research/wp-content/uploads/2005/01/p1000-loop.pdf
// NOTE: We only need the roots, so a uniform scale factor does not affect
// the solution.
CubicCoeffs coeffs(pts);
float a = simd::cross(coeffs.A, coeffs.B);
float b_over_2 = simd::cross(coeffs.A, coeffs.C) * .5f;
float c = simd::cross(coeffs.B, coeffs.C);
float discr_over_4 = b_over_2 * b_over_2 - a * c;

// If -cuspThreshold <= discr_over_4 <= cuspThreshold, it means the two
// roots are within TESS_EPSILON of one another (in parametric space). This
// is close enough for our purposes to consider them a single cusp.
float cuspThreshold = a * (TESS_EPSILON / 2);
cuspThreshold *= cuspThreshold;

// Find the first two chops, based on curve classification. Also fill in
// "tan90", which will define the second pair of chops as the two points
// perpendicular to "tan90".
float4 T;
float2 tan90;
if (discr_over_4 < -cuspThreshold ||
// Check if it's quadratic.
std::max(fabs(a), fabs(b_over_2)) < fabs(c) * TESS_EPSILON)
{
// The curve is a loop or quadratic.
// One chop is where rotation == 180 deg (which happens at infinity if
// the curve is quadratic).
// (This is the 2nd root where the tangent is parallel to tan0.)
//
// Tangent_Direction(T) x tan0 == 0
// (AT^2 x tan0) + (2BT x tan0) + (C x tan0) == 0
// (A x C)T^2 + (2B x C)T + (C x C) == 0
// [[because tan0 == P1 - P0 == C]]
// bT^2 + 2cT + 0 == 0 [[because A x C == b, B x C == c]]
// T = [0, -2c/b]
//
// NOTE: if C == 0, then C != tan0. But this is fine because the curve
// can only rotate 180 degrees if the endpoints are colocated, and this
// gets handled next.
T.xy = {-c / b_over_2, 1};

// Next chop 90 degrees from the starting tangent of the curve.
tan90 = simd::any(coeffs.C != 0.f)
? coeffs.C
: math::bit_cast<float2>(pts[2] - pts[0]);
*areCusps = false;
}
else if (discr_over_4 > cuspThreshold)
{
// The curve is serpentine. Solve for the two inflection points.
float q = sqrtf(discr_over_4);
q = -b_over_2 - copysignf(q, b_over_2);
T.xy = float2{q, c} / float2{a, q};

// Next chop 90 degrees from the whichever inflection point is closest
// to the middle.
float t = fabsf(T.x - .5f) < fabsf(T.y - .5f) ? T.x : T.y;
tan90 = (coeffs.A * t + 2.f * coeffs.B) * t + coeffs.C;
*areCusps = false;
}
else
{
// The curve is a cusp. A proper cusp is at T=-b/2a, but just solving
// for 90 degrees from the starting tangent will also find it, in
// addition to finding cusps from degenerate flat lines reversing
// direction. Since 180 degrees of rotation is lost to the cusp, we only
// need to find 2 roots max.
T.xy = 1;
tan90 = simd::any(coeffs.C != 0.f)
? coeffs.C
: math::bit_cast<float2>(pts[2] - pts[0]);
*areCusps = true;
}

// Find a second set of chops where the curve is perpendicular to tan90.
//
// Tangent_Direction(T) dot tan90 == 0
// (A dot tan90) * T^2 + (2B dot tan90) * T + (C dot tan90) == 0
//
a = simd::dot(coeffs.A, tan90);
b_over_2 = simd::dot(coeffs.B, tan90);
c = simd::dot(coeffs.C, tan90);
discr_over_4 = b_over_2 * b_over_2 - a * c;
float q = sqrtf(discr_over_4);
q = -b_over_2 - copysignf(q, b_over_2);
T.zw = float2{q, c} / float2{a, q};

// Throw out T <= epsilon and T >= epsilon by converting them to 1.
// (Use logic such that NaN also converts to 1.)
T = simd::if_then_else((T > 0) & (T < 1), T, float4(1));
assert(simd::all(T > 0));
assert(simd::all(T <= 1));

// Sort the roots.
T = simd::if_then_else((float2{T.x, T.z} < float2{T.y, T.w}).xxyy,
T,
T.yxwz);
T = simd::if_then_else((float2{T.x, T.y} < float2{T.z, T.w}).xyxy,
T,
T.zwxy);
T = T.y < T.z ? T : T.xzyw;

// Count the number of roots that != 1 and store T.
int4 n4 = (T != 1) & 1;
n4.xy += n4.zw;
int n = n4.x + n4.y;
simd::store(outT, T);

if (*areCusps && n > 0)
{
// Generate padding around cusp points. Odd numbered chops are always
// padding sections that pass through a cusp.
assert(n <= 2);
for (int i = n - 1; i >= 0; --i)
{
float maxT = i == n - 1 ? 1 : outT[i * 2 + 1];
float minT = i == 0 ? 0 : (outT[i - 1] + outT[i]) * .5f;
outT[i * 2 + 1] = std::min(outT[i] + cuspPadding, maxT);
outT[i * 2 + 0] = std::max(outT[i] - cuspPadding, minT);
}
n *= 2;
// Re-clip and re-sort n after adding cusp padding. This is a hack, but
// we leave it here for now becase we're about to remove this entire
// method in favor of something more robust.
if (outT[n - 1] == 1)
{
--n;
}
std::sort(outT, outT + n);
}

return n;
}

float find_cubic_max_height(const Vec2D p[4], float* outT)
{
// Calculate the cubic height function: 3(dht^3 - (h1 + dh)t^2 + h1t)
Expand Down
Loading

0 comments on commit e21572f

Please sign in to comment.