Skip to content

Instantly share code, notes, and snippets.

@jezell
Created February 16, 2025 20:54
Show Gist options
  • Save jezell/abe82d47c620fc3112f291fd1cfc8bea to your computer and use it in GitHub Desktop.
Save jezell/abe82d47c620fc3112f291fd1cfc8bea to your computer and use it in GitHub Desktop.

Overview

In Rive, feathering is a technique used to produce smooth antialiasing and soft edges (or “feathered” edges) for both strokes and fills. Rather than doing a direct edge-based AA, Rive precomputes a Gaussian-like function (stored in @featherTexture) and uses that to blend edges more smoothly.

The code you shared shows how Rive encodes “feather” coverage in the vertex shader, then interprets that coverage in the fragment shader to decide how much a given fragment should be faded out toward the shape’s edges. Below is a step-by-step explanation of how it all comes together.


1. Coverage Encoding and Classification

Feather Coverage Bias and Threshold

#define FEATHER_COVERAGE_BIAS -2.
#define FEATHER_COVERAGE_THRESHOLD -1.5
#define FEATHER_X_COORD_BIAS .25
  • FEATHER_COVERAGE_BIAS: Fragments whose coverage.y is less than -2.0 (due to this bias) are considered “feathered” rather than “fully inside” the shape. By shifting coverage values downward, Rive can test them against a threshold to identify feathered fragments.
  • FEATHER_COVERAGE_THRESHOLD: The fragment shader checks if coverage.x or coverage.y is less than -1.5 to see if it belongs to a feathered region.
  • FEATHER_X_COORD_BIAS: Slightly biases x coverage values above 0 (to avoid losing the sign when x == 0).

Distinguishing Strokes vs Fills

In Rive’s coverage system:

  • Strokes have coverage.y >= 0.
  • Fills have coverage.y < 0.

Even when feathering is enabled, you can see code like this:

INLINE bool is_stroke(float4 coverages) { return coverages.y >= .0; }
INLINE bool is_feathered_stroke(float4 coverages)
{
    return coverages.x < FEATHER_COVERAGE_THRESHOLD;
}

In other words, once you know it’s a stroke (because coverages.y >= 0), you then check if it is feathered (coverages.x < -1.5).

Similarly for fills:

INLINE bool is_feathered_fill(float4 coverages)
{
    return coverages.y < FEATHER_COVERAGE_THRESHOLD;
}

2. The Feather Texture

Rive stores a precomputed Gaussian and its inverse in a 1D texture (@featherTexture). You’ll see two macros for sampling it:

#define FEATHER(X) TEXTURE_SAMPLE_LOD(@featherTexture, featherSampler, float2(X, .0), .0).r
#define INVERSE_FEATHER(X) TEXTURE_SAMPLE_LOD(@featherTexture, featherSampler, float2(X, 1.), .0).r
  • FEATHER(x) returns the Gaussian integral (from x=0 to x)—or a similar cumulative function—used to simulate the soft transition.
  • INVERSE_FEATHER(x) is the inverse of that function (the second row of the same texture).

By sampling these functions, the code can quickly compute how to fade a fragment near the edge, effectively doing a 1D Gaussian blur without a high runtime cost.


3. Feathering Fills

Vertex Shader: Packing Feather Coverage

When a fill is feathered, the vertex shader calls:

float4 pack_feathered_fill_coverages(float cornerTheta, float2 spokeNorm, float outset)
{
    // 1) Compute a "local coordinate" in [0..1] that maps into the feather's 2D convolution space.
    float2 cornerLocalCoord = (1. - spokeNorm * abs(outset)) * .5;

    // 2) Compute 'cotTheta' = cotangent of the corner angle,
    //    and 'y0' for the fragment shader to use.
    //    These help approximate the 2D feather as a separable integral.
    float cotTheta, y0;
    if (abs(cornerTheta - PI / 2.) < 1. / HORIZONTAL_COTANGENT_THRESHOLD) {
        cotTheta = 0.;
        y0 = 0.;
    } else {
        float tanTheta = tan(cornerTheta);
        cotTheta = sign(PI/2. - cornerTheta) / max(abs(tanTheta), 1./HORIZONTAL_COTANGENT_VALUE);
        ...
    }

    // 3) Store these values into a float4 coverage:
    //    coverages.x => "x + FEATHER_X_COORD_BIAS"
    //    coverages.y => "y + FEATHER_COVERAGE_BIAS"
    //    coverages.z => cotTheta
    //    coverages.w => y0
    float4 coverages;
    coverages.x = max(cornerLocalCoord.x, 0.) + FEATHER_X_COORD_BIAS;
    coverages.y = -cornerLocalCoord.y + FEATHER_COVERAGE_BIAS;
    coverages.z = cotTheta;
    coverages.w = y0;
    return coverages;
}

Key ideas:

  1. cornerLocalCoord:
    A 2D coordinate in the “feather convolution” space where (0.5, 0.5) is the center of the Gaussian, and we measure how far we are from the edge(s).

  2. cotTheta and y0:
    Describe how the corner angle cuts across the feather’s 2D Gaussian so the fragment shader can integrate it properly.

  3. Biasing:

    • x is biased above zero, so it can be flipped negative if the triangle is “back-facing.”
    • y is biased below -2.0 so the fragment shader knows it’s a feather.

Fragment Shader: Evaluating Feathered Fill

INLINE float eval_feathered_fill(float4 coverages TEXTURE_CONTEXT_DECL)
{
    // coverage.z => cotTheta
    // coverage.w => y0
    half cotTheta = coverages.z;
    half y0 = max(coverages.w, 0.);

    // 1) Start with the "upper area" if cotTheta >= 0
    half featherCoverage = (cotTheta >= 0.) ? FEATHER(y0) : 0.;

    // 2) If the corner is not flat, integrate the partial coverage by sampling ~4 times
    //    across the vertical dimension. This simulates the 2D Gaussian shape.
    if (abs(cotTheta) < HORIZONTAL_COTANGENT_THRESHOLD) {
        half x = abs(coverages.x) - FEATHER_X_COORD_BIAS;
        half y = -coverages.y + FEATHER_COVERAGE_BIAS;

        // Subdivide and sample the Gaussian (the normal distribution).
        half4 t = ...;   // 4 vertical samples
        half4 feathers = half4(FEATHER(u[0]), FEATHER(u[1]), ...);
        half4 ddtFeather = exp2(-t_ * t_); // normal distribution
        ...

        // Accumulate these samples, effectively integrating the 2D shape.
        featherCoverage += dot(feathers, ddtFeather) * dt;
    }

    // Multiply by the sign(coverage.x) so we get correct orientation
    return featherCoverage * sign(coverages.x);
}
  • The code treats a corner’s feather region like a small 2D area, but compresses the integral into a few 1D lookups.
  • The result is a smooth, partial coverage telling how much of the fragment is inside the fill.

4. Feathering Strokes

Vertex Shader

For strokes, coverage is set differently. The sign of coverages.y >= 0 indicates stroke, and there’s logic to handle:

  • The stroke radius (half the stroke’s thickness).
  • The “feather radius” for antialiasing or more pronounced softness.
  • “Outset” expansions for joins (miter, bevel, etc.).

For example:

// If this path is a stroke:
float aaRadius = featherRadius != 0. ? featherRadius : manhattan_pixel_width(M, norm) * AA_RADIUS;
float2 vertexOffset = MUL(norm, strokeRadius + aaRadius);
...
// The coverage.x, coverage.y hold stroke edge coverage data.
outCoverages.xy = <some function of strokeRadius, etc.>;
...
// If featherRadius != 0, shift coverage.x by FEATHER_COVERAGE_BIAS

Key difference: For a feathered stroke, you often see coverages.x set to something like FEATHER_COVERAGE_BIAS - outCoverages.x, ensuring the fragment shader recognizes it as a feather.

Fragment Shader

INLINE half eval_feathered_stroke(float4 coverages TEXTURE_CONTEXT_DECL)
{
    // Feathered stroke coverage = 1 - feather(1 - leftCoverage) - feather(1 - rightCoverage)
    float featherCoverage = 1.;
    float leftOutsideCoverage = (1. - FEATHER_COVERAGE_BIAS) + coverages.x;
    featherCoverage -= FEATHER(leftOutsideCoverage);

    float rightOutsideCoverage = 1. - coverages.y;
    featherCoverage -= FEATHER(rightOutsideCoverage);

    return featherCoverage;
}

The stroke is thought of as a band with two edges, so we subtract feather lookups for the left and right edges from 1.0, giving the portion that is “solid” in the center.


5. Other Details

  • HORIZONTAL_COTANGENT_THRESHOLD: Used to decide when an angle is so close to 90° that it’s effectively treated as “flat” in the feathering math.
  • Corner Joins: Miter, bevel, and round joins handle offsets carefully in the vertex shader. For feather corners, Rive uses an approximate integral in the fragment shader that blends the shape smoothly.
  • INVERSE_FEATHER(x): In some modes (like atlas coverage) the code needs to go from the Gaussian domain back to linear space, do an operation, then reapply the Gaussian. INVERSE_FEATHER(x) is that “inverse” function, stored in the second row of the same texture.

6. Putting It All Together

  1. Vertex Stage:

    • Each vertex calculates how far it is from the edge or corner, sets up coverage.x/y to indicate stroke vs fill, whether it is feathered or not, and how strongly it’s feathered (cornerTheta, cotTheta, etc.).
    • A negative bias (FEATHER_COVERAGE_BIAS) in coverage signals “this pixel belongs in a feather region,” so the fragment shader can do the more expensive integral lookups.
  2. Fragment Stage:

    • Reads coverages.
    • Checks if it’s fill or stroke (sign of coverages.y).
    • Checks if it’s a feathered fill/stroke vs. a regular (no feather) fill/stroke (coverages.x < FEATHER_COVERAGE_THRESHOLD or coverages.y < FEATHER_COVERAGE_THRESHOLD).
    • Uses the precomputed Gaussian integral from @featherTexture to compute partial coverage in the feather region, or returns a simpler coverage if not feathered.
  3. Visual Result:

    • Smooth transitions at shape boundaries.
    • Soft corners and edges where configured for “feathering.”
    • No heavy runtime blur passes—just table lookups combined with some local geometry expansion and coverage math.

Summary

In Rive’s pipeline:

  • Feathering is all about converting a “distance from shape boundary” (either stroke or fill) into a soft, smooth alpha coverage by sampling a precomputed Gaussian function.
  • Coverage is stored in float4 (or half2) in the vertex shader, with special biases to indicate feather vs. non-feather, stroke vs. fill.
  • The fragment shader then checks these biases/thresholds, performs a few short lookups into @featherTexture, and calculates how much of the pixel is covered by the shape’s “blurred” boundary.
  • For corners (fills) or joins (strokes), extra geometry + math in the vertex shader packs the needed parameters (cotTheta, y0) into coverage so the fragment shader can do a mini 2D integral via a few 1D lookups.

This yields high-quality antialiasing (and optional soft edges) without heavy pixel-by-pixel convolution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment