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.
#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
orcoverage.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 whenx == 0
).
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;
}
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 (fromx=0
tox
)—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.
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:
-
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). -
cotTheta
andy0
:
Describe how the corner angle cuts across the feather’s 2D Gaussian so the fragment shader can integrate it properly. -
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.
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.
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.
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.
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.
-
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.
- 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 (
-
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
orcoverages.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.
- Reads
-
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.
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.