slug-font-rendering

Reference HLSL shader implementations for the Slug font rendering algorithm, enabling high-quality GPU-accelerated vector font and glyph rendering.

INSTALLATION
npx skills add https://github.com/aradotso/trending-skills --skill slug-font-rendering
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

$27

Repository Structure

Slug/

├── slug.hlsl          # Core fragment shader — coverage computation

├── band.hlsl          # Band-based optimization for glyph rendering

├── curve.hlsl         # Quadratic Bézier and line segment evaluation

├── README.md

Installation / Integration

Slug is a reference implementation — you integrate the HLSL shaders into your own rendering pipeline.

Step 1: Clone the Repository

git clone https://github.com/EricLengyel/Slug.git

Step 2: Include the Shaders

Copy the .hlsl files into your shader directory and include them in your pipeline:

#include "slug.hlsl"

#include "curve.hlsl"

Step 3: Prepare Glyph Data on the CPU

You must preprocess font outlines (TrueType/OTF) into Slug's curve buffer format:

  • Decompose glyph contours into quadratic Bézier segments and line segments
  • Upload curve data to a GPU buffer (structured buffer or texture buffer)
  • Precompute per-glyph "band" metadata for the band optimization

Core Concepts

Glyph Coordinate System

  • Glyph outlines live in font units (typically 0–2048 or 0–1000 per em)
  • The fragment shader receives a position in glyph space via interpolated vertex attributes
  • Coverage is computed by counting signed curve crossings in the Y direction (winding number)

Curve Data Format

Each curve entry in the GPU buffer stores:

// Line segment: p0, p1

// Quadratic Bézier: p0, p1 (control), p2

struct CurveRecord

{

    float2 p0;   // Start point

    float2 p1;   // Control point (or end point for lines)

    float2 p2;   // End point (unused for lines — flagged via type)

    // Type/flags encoded separately or in padding

};

Band Optimization

The glyph bounding box is divided into horizontal bands. Each band stores only the curves that intersect it, reducing per-fragment work from O(all curves) to O(local curves).

Key Shader Code & Patterns

Fragment Shader Entry Point (Conceptual Integration)

// Inputs from vertex shader

struct PS_Input

{

    float4 position  : SV_Position;

    float2 glyphCoord : TEXCOORD0;  // Position in glyph/font units

    // Band index or precomputed band data

    nointerpolation uint bandOffset : TEXCOORD1;

    nointerpolation uint curveCount : TEXCOORD2;

};

// Glyph curve data buffer

StructuredBuffer<float4> CurveBuffer : register(t0);

float4 PS_Slug(PS_Input input) : SV_Target

{

    float coverage = ComputeGlyphCoverage(

        input.glyphCoord,

        CurveBuffer,

        input.bandOffset,

        input.curveCount

    );

    // Premultiplied alpha output

    float4 color = float4(textColor.rgb * coverage, coverage);

    return color;

}

Quadratic Bézier Coverage Computation

The heart of the algorithm — computing signed coverage from a quadratic Bézier:

// Evaluate whether a quadratic bezier contributes to coverage at point p

// p0: start, p1: control, p2: end

// Returns signed coverage contribution

float QuadraticBezierCoverage(float2 p, float2 p0, float2 p1, float2 p2)

{

    // Transform to canonical space

    float2 a = p1 - p0;

    float2 b = p0 - 2.0 * p1 + p2;

    // Find t values where bezier Y == p.y

    float2 delta = p - p0;

    float A = b.y;

    float B = a.y;

    float C = p0.y - p.y;

    float coverage = 0.0;

    if (abs(A) > 1e-6)

    {

        float disc = B * B - A * C;

        if (disc >= 0.0)

        {

            float sqrtDisc = sqrt(disc);

            float t0 = (-B - sqrtDisc) / A;

            float t1 = (-B + sqrtDisc) / A;

            // For each valid t in [0,1], compute x and check winding

            if (t0 >= 0.0 &#x26;&#x26; t0 <= 1.0)

            {

                float x = (A * t0 + 2.0 * B) * t0 + p0.x + delta.x;

                // ... accumulate signed coverage

            }

            if (t1 >= 0.0 &#x26;&#x26; t1 <= 1.0)

            {

                float x = (A * t1 + 2.0 * B) * t1 + p0.x + delta.x;

                // ... accumulate signed coverage

            }

        }

    }

    else

    {

        // Degenerate to linear case

        float t = -C / (2.0 * B);

        if (t >= 0.0 &#x26;&#x26; t <= 1.0)

        {

            float x = 2.0 * a.x * t + p0.x;

            // ... accumulate signed coverage

        }

    }

    return coverage;

}

Line Segment Coverage

// Signed coverage contribution of a line segment from p0 to p1

float LineCoverage(float2 p, float2 p0, float2 p1)

{

    // Check Y range

    float minY = min(p0.y, p1.y);

    float maxY = max(p0.y, p1.y);

    if (p.y < minY || p.y >= maxY)

        return 0.0;

    // Interpolate X at p.y

    float t = (p.y - p0.y) / (p1.y - p0.y);

    float x = lerp(p0.x, p1.x, t);

    // Winding: +1 if p is to the left (inside), -1 if right

    float dir = (p1.y > p0.y) ? 1.0 : -1.0;

    return (p.x <= x) ? dir : 0.0;

}

Anti-Aliasing with Partial Coverage

For smooth edges, use the distance to the nearest curve for sub-pixel anti-aliasing:

// Compute AA coverage using partial pixel coverage

// windingNumber: integer winding from coverage pass

// distToEdge: signed distance to nearest curve (in pixels)

float AntiAliasedCoverage(int windingNumber, float distToEdge)

{

    // Non-zero winding rule

    bool inside = (windingNumber != 0);

    // Smooth transition at edges using clamp

    float edgeCoverage = clamp(distToEdge + 0.5, 0.0, 1.0);

    return inside ? edgeCoverage : (1.0 - edgeCoverage);

}

Vertex Shader Pattern

struct VS_Input

{

    float2 position   : POSITION;     // Glyph quad corner in screen/world space

    float2 glyphCoord : TEXCOORD0;    // Corresponding glyph-space coordinate

    uint   bandOffset : TEXCOORD1;    // Offset into curve buffer for this glyph

    uint   curveCount : TEXCOORD2;    // Number of curves in band

};

struct VS_Output

{

    float4 position   : SV_Position;

    float2 glyphCoord : TEXCOORD0;

    nointerpolation uint bandOffset : TEXCOORD1;

    nointerpolation uint curveCount : TEXCOORD2;

};

VS_Output VS_Slug(VS_Input input)

{

    VS_Output output;

    output.position   = mul(float4(input.position, 0.0, 1.0), WorldViewProjection);

    output.glyphCoord = input.glyphCoord;

    output.bandOffset = input.bandOffset;

    output.curveCount = input.curveCount;

    return output;

}

CPU-Side Data Preparation (Pseudocode)

// 1. Load font file and extract glyph outlines

FontOutline outline = LoadGlyphOutline(font, glyphIndex);

// 2. Decompose to quadratic Beziers (TrueType is already quadratic)

//    OTF cubic curves must be approximated/split into quadratics

std::vector<SlugCurve> curves = DecomposeToQuadratics(outline);

// 3. Compute bands

float bandHeight = outline.bounds.height / NUM_BANDS;

std::vector<BandData> bands = ComputeBands(curves, NUM_BANDS, bandHeight);

// 4. Upload to GPU

UploadStructuredBuffer(curveBuffer, curves.data(), curves.size());

UploadStructuredBuffer(bandBuffer, bands.data(), bands.size());

// 5. Per glyph instance: store bandOffset and curveCount per band

//    in vertex data so the fragment shader can index directly

Render State Requirements

// Blend state: premultiplied alpha

BlendState SlugBlend

{

    BlendEnable    = TRUE;

    SrcBlend       = ONE;           // Premultiplied

    DestBlend      = INV_SRC_ALPHA;

    BlendOp        = ADD;

    SrcBlendAlpha  = ONE;

    DestBlendAlpha = INV_SRC_ALPHA;

    BlendOpAlpha   = ADD;

};

// Depth: typically write disabled for text overlay

DepthStencilState SlugDepth

{

    DepthEnable    = FALSE;

    DepthWriteMask = ZERO;

};

// Rasterizer: no backface culling (glyph quads are 2D)

RasterizerState SlugRaster

{

    CullMode = NONE;

    FillMode = SOLID;

};

Common Patterns

Rendering a String

// For each glyph in string:

for (auto&#x26; glyph : string.glyphs)

{

    // Emit a quad (2 triangles) covering the glyph bounding box

    // Each vertex carries:

    //   - screen position

    //   - glyph-space coordinate (the same corner in font units)

    //   - bandOffset + curveCount for the fragment shader

    float2 min = glyph.screenMin;

    float2 max = glyph.screenMax;

    float2 glyphMin = glyph.fontMin;

    float2 glyphMax = glyph.fontMax;

    EmitQuad(min, max, glyphMin, glyphMax,

             glyph.bandOffset, glyph.curveCount);

}

Scaling Text

Scaling is handled entirely on the CPU side by transforming the screen-space quad. The glyph-space coordinates stay constant — the fragment shader always works in font units.

float scale = desiredPixelSize / font.unitsPerEm;

float2 screenMin = origin + glyph.fontMin * scale;

float2 screenMax = origin + glyph.fontMax * scale;

Troubleshooting

Problem

Cause

Fix

Glyph appears hollow/inverted

Winding order reversed

Check contour orientation; TrueType uses clockwise for outer contours

Jagged edges

Anti-aliasing not applied

Ensure distance-to-edge is computed and used in final coverage

Performance poor

Band optimization not active

Verify per-fragment curve count is small (< ~20); increase band count

Cubic curves not rendering

OTF cubic Béziers unsupported natively

Split cubics into quadratic approximations on CPU

Artifacts at glyph overlap

Curves not clipped to band

Clip curve Y range to band extents before upload

Black box instead of glyph

Blend state wrong

Use premultiplied alpha blend (ONE, INV_SRC_ALPHA)

Missing glyphs

Band offset incorrect

Validate bandOffset indexing aligns with buffer layout

Credits &#x26; Attribution

Per the license: if you distribute software using this code, you must give credit to Eric Lengyel and the Slug algorithm.

Suggested attribution:

Font rendering uses the Slug Algorithm by Eric Lengyel (https://jcgt.org/published/0006/02/02/)

References

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card