psyclyx/snail

GPU text and vector rendering via direct Bezier curve evaluation.

Zig

2

1,151 commits

updated Sep 30, 2026

See the code

README

snail banner: every size fits, illustrated by a blue-gray vector snail carrying a detailed golden-ratio shell construction

snail

Text and vector rendering from Bézier curves, built to embed in an engine.

snail stores glyph outlines and vector paths as curves, then evaluates those curves while drawing. It does not pre-render glyphs into bitmap atlases or signed distance fields. An unhinted record has no baked pixel resolution: the same prepared data can be reused across sizes, rotations, affine transforms, and projective transforms on the GPU.

snail does not provide or own a GPU backend. The core library prepares CPU data and emits texture uploads, typed draw records, native Slang modules, and complete generated shader stages. Your engine owns GPU textures, pipelines, uploads, command buffers, and draw calls. The optional snail-raster module is an affine-only CPU backend.

This is alpha-quality software; see Status.

Start here

Text rendering in one minute

A character is part of a text encoding; a glyph is a drawable shape chosen from a font. They are not one-to-one: a ligature can turn several characters into one glyph, while a base character plus marks can produce several positioned glyphs.

Text rendering therefore has two separate jobs:

  1. Shaping turns UTF-8 text into glyph IDs, positions, and advances, following script, language, direction, OpenType features, and font fallback. snail uses HarfBuzz for this.
  2. Rasterization determines how much of each screen pixel those glyphs cover. snail does this from the glyphs' mathematical outlines.

A font outline is one or more closed contours made from lines and Bézier curves. Font coordinates are measured relative to the em, the font's design-space unit square. ppem means pixels per em: roughly the rendered text size in device pixels. Hinting moves outline features onto the pixel grid at small sizes; unhinted outlines retain their natural geometry.

The word atlas here means a packed GPU lookup store, not a bitmap glyph sheet. snail's atlas contains curves, band indexes, and optional paint records.

WorkOwner
Paragraph bidi, line breaking, wrapping, cursor/grapheme policyHost
UTF-8 → positioned glyphs, style selection, font fallbacksnail + HarfBuzz
Glyph/path preparation and persistent record storagesnail on the CPU
GPU resources, uploads, pipelines, and submissionHost
Optional affine software renderingsnail-raster

snail is aimed at text or vector art that changes scale or orientation, especially world-space and perspective-projected content. The tradeoff is that the fragment shader solves candidate curve intersections instead of performing a small fixed number of bitmap/SDF samples. Benchmark the actual content and target hardware; a conventional cached bitmap renderer can be a better fit for static, fixed-size UI text.

Supported scope

  • TrueType, CFF, and CFF2 outlines in OpenType containers, including font collections and selected variable-font instances.
  • HarfBuzz shaping with styled face chains, fallback, explicit or inferred direction/script/language, OpenType features, and UTF-8 source ranges.
  • Unhinted rendering, a resolution-independent draw-time autohinter, and TrueType bytecode hinting.
  • General paths containing lines, quadratics, cubics, and rational conics; fills, strokes, solid colors, linear/radial/conic gradients, and images.
  • COLRv0 color fonts, and PNG embedded-bitmap strikes (CBDT/CBLC, sbix) via Font.colorBitmap plus a host image decoder. COLRv1 and OpenType SVG are not currently supported.
  • Grayscale analytic AA and optional LCD subpixel AA.

snail does not perform paragraph bidi, line breaking, wrapping, Unicode grapheme or terminal-width policy, cursor movement, or image-file decoding. See Font format support for the detailed matrix and the planned unified COLRv1/SVG integration boundary.

The pipeline

Everything is shape → plan/prepare → apply → upload → emit → draw.

Atlas is a persistent, value-typed CPU store. Preparation, caching, and residency are explicit: there is no hidden thread pool, filesystem cache, or eviction policy.

const snail = @import("snail");

// Shape: parse fonts and turn UTF-8 into positioned glyphs.
var font = try snail.Font.init(font_bytes); // borrows font_bytes
var faces = try snail.Faces.build(alloc, &.{
    .{ .font = &font, .font_id = 0 },
});
defer faces.deinit();
var shaped = try snail.shape(alloc, &faces, "Hello, world", .{});
defer shaped.deinit();

// Plan: discover missing, cacheable work without extracting outlines.
var pool = try snail.PagePool.init(alloc, .{
    .max_pages = 8, // may exceed 256 with a banked/flat backend
    .curve_words_per_page = 1 << 17,
    .band_words_per_page = 1 << 14,
});
defer pool.deinit();
var atlas = try snail.Atlas.initWithPacking(
    alloc, pool, .{ .recent_page_limit = 12 },
);
defer atlas.deinit();
const sources = [_]snail.FontSource{.{
    .font_id = 0,
    .font = &font,
    // Stable identity of these font bytes, face index, and variations.
    .cache_key = myFontInstanceKey(font_bytes, 0, &.{}),
}};
var prepare_plan = try snail.planRuns(
    &atlas, alloc, &sources, &.{&shaped}, .{ .unhinted = .{} },
);
defer prepare_plan.deinit();

// Prepare: the caller may satisfy requests from an Archive or schedule them
// on workers. This short example executes unhinted misses synchronously.
const requests = prepare_plan.requests();
const owned = try alloc.alloc(?snail.prepared.OwnedRecord, requests.len);
defer {
    for (owned) |*record| if (record.*) |*value| value.deinit();
    alloc.free(owned);
}
@memset(owned, null);
const results = try alloc.alloc(?snail.prepared.RecordView, requests.len);
defer alloc.free(results);
@memset(results, null);

var outline_context = snail.OutlineContext.init(alloc, alloc);
defer outline_context.deinit();
for (requests, 0..) |request, index| {
    owned[index] = try outline_context.prepare(request);
    results[index] = owned[index].?.view();
}

// Apply: validate every result, then publish one logically atomic AtlasUpdate.
try prepare_plan.applyInPlace(alloc, &atlas, results);

// Upload: copy backend-neutral regions into textures owned by your engine.
const upload_options: snail.atlas_upload.Options = .{
    .max_bindings = 16,
    .layer_info_height = 64,
    .max_images = 16,
    .max_image_width = 2048,
    .max_image_height = 2048,
};
var planner = try snail.atlas_upload.OwnedPlanner.init(
    alloc, pool, upload_options,
);
defer planner.deinit();
const binding = upload: {
    var pending_upload = try planner.plan(&atlas);
    errdefer planner.abort(&pending_upload) catch {};
    for (pending_upload.regions()) |region| {
        try myEngine.acceptTextureCopy(region);
    }
    break :upload try planner.commit(&pending_upload);
};

// Emit: place glyphs, then produce typed instances and coalesced batches.
const shapes = try snail.placeRunAlloc(alloc, &shaped, null, .{
    .baseline = .{ .x = 48, .y = 92 },
    .em = 34,
});
defer alloc.free(shapes);
_ = try snail.emit.emit(
    instances, batches, &instance_count, &batch_count,
    binding, &atlas, shapes, world_xform, .{ 1, 1, 1, 1 },
);
const records: snail.render.records.DrawRecords = .{
    .instances = instances[0..instance_count],
    .batches = batches[0..batch_count],
};

// Draw: bind the generated stages from @import("snail_shaders").
// Submit one instanced draw per batch and one quad per instance. Release
// `binding` only after the final GPU use of it has completed.

The complete raw-OpenGL version is dev/demo/app/minimal_gl.zig, runnable with zig build run-minimal-gl. Reference GPU integrations live under dev/demo/render/gl and dev/demo/render/vulkan. The software-renderer flow and the detailed upload, lifetime, color, threading, and ABI contracts are in Embedding snail.

Expensive producer output can optionally be stored in a snail.prepared.Archive. Archive.fromBytes borrows the archive bytes—it does not take a filename or perform I/O—so the host chooses whether to read, stream into retained storage, memory-map, embed, or download them. Lookups are zero-copy views into those retained bytes. Stable artifact keys are constructed by Snail from a caller-supplied font-instance identity, the producer version, and every output-affecting option, including cubic tolerance. planRuns exposes cacheable requests and their dependencies so the host can satisfy hits immediately, schedule misses on its own workers, then apply the completed views to the atlas atomically. See Prepared artifacts and disk caches for the ownership, validation, async, and cache-versioning contracts.

PagePool is the resident page budget. Applying prepared data is idempotent and returns error.OutOfLayers when a new record cannot acquire a page. The host chooses what to evict. Atlas.compactInto(..., target_pool, filter) rebuilds a retained working set in a distinct pool, so the source can remain drawable while the replacement uploads in the background. Publish the new atlas and binding together, then release the old binding, atlas, and pool after their final GPU use completes. The same-pool compact convenience retains both persistent snapshots and therefore still needs result-sized free-page headroom.

Font outlines (lines and quadratics only) store about one texel per segment (see How it works); general paths use four. Page height is a host budget (curve_words_per_page / band_words_per_page), not a fixed shader dimension. Logical pages are allocated lazily. Array backends bank them in groups of 256; flat typed-buffer backends address them with fixed page strides. Either path avoids eviction solely because one texture array is full. The default packer considers only the 12 most recent pages and chooses the tightest fit across both curve and band capacity; Atlas.Packing{ .recent_page_limit = 1 } selects tail-only placement. The bounded window makes insertion cost independent of total atlas size.

On a direct append-only atlas child, planDelta returns a PendingUpload containing only changed page regions and appended side data. Layer-info and image storage are fixed reservations made by the original plan; release and create a fresh binding if later side data no longer fits.

Algorithm

snail implements the core coverage method described by Eric Lengyel:

The Slug patent (US 10,373,352) was permanently dedicated to the public domain effective March 17, 2026. snail is original code rather than code copied from the Slug Library product or public reference shaders, and is licensed under MIT.

How it works

The CPU prepares each record once. The GPU then evaluates those curves for every covered pixel. Nothing is rasterized ahead of time; only image paints are images.

1. Prepare: outlines stay curves. A font outline is a list of line and quadratic segments. A quadratic has a start point p0, a control point p1, and an end point p2, stored as em-unit coordinates in RGBA16F texels, one 16-bit float per channel: a segment reads p0 and p1 from its texel and p2 from the next. That next texel also holds the following segment's control point, so it starts the following segment: a contour of n segments takes n + 1 texels. TrueType outlines are already quadratic. Cubics from CFF/CFF2 or paths are split at extrema and inflections, then approximated by quadratics certified against a tolerance (default 1/8192 em); if f32 cannot represent a certified result, preparation returns a typed error. General paths use four texels per segment so they can also record each segment's kind and rational-conic weights.

Unhinted and autohint records do not depend on size. TrueType-hinted records are prepared per ppem.

an 'o' outline with its 16 segment boundaries marked and one segment's p0, p1, p2 labeled; beside it, the glyph as one run of single texels in the curve texture, with the labeled segment's two RGBA16F texels expanded: x and y per 16-bit channel, p0 and p1 in the first texel, p2 and the next segment's p1 in the second

2. Prepare: bands index the curves. The outline's bounds are cut into equal horizontal and vertical bands, 1 to 12 per axis depending on the segment count. Each band lists the segments that overlap it, in the order the evaluator reads them: farthest along the ray first, so it can stop once the rest lie more than half a pixel behind the sample.

the 'o' with six horizontal and six vertical bands over its bounds; one band per axis is highlighted with its overlapping segments numbered and listed

The atlas textures are curves (RGBA16F), bands (RG16UI), and layer info (RGBA32F), plus an optional image array. Instances, parameters, samplers, and pipelines belong to the host.

3. Draw: one quad per glyph. Each placed shape with curves becomes one instance of a quad over its bounds. The vertex shader grows the quad just enough to cover the antialiasing or LCD-filter footprint under any transform, including perspective. Each fragment receives its position in glyph space and the derivatives that size its pixel there. The CPU rasterizer handles affine transforms only and reports NonAffineMvp for a perspective MVP.

a rotated 'o' inside its bounding quad on a pixel grid, with one pixel mapped back to the upright glyph

4. Draw: the pixel footprint picks band spans. The rows the pixel's footprint spans supply candidates for the horizontal ray; the columns it spans supply candidates for the vertical ray. A curve listed in several of those bands is evaluated once. The public Slug shader reads one row and one column at the pixel center instead, which can miss curves when a pixel covers more than one band.

an enlarged pixel footprint on the 'o'; the rows and columns it spans are shaded and their curves highlighted as candidates

5. Draw: solve candidates along two rays. One ray runs from the pixel center toward +x, another toward +y. Each candidate's roots give where it crosses a ray, and its direction there gives the crossing's sign. Before any root is used, the signs of the three control points relative to the sample index Slug's 0x2E74 table, which says whether zero, one, or two roots count. snail snaps values within 1/65536 of the ray to zero, so adjacent segments meeting on the ray make the same decision, and solves for roots in a form that avoids cancellation. Lines follow the same rule; rational conics apply it to their weighted control points.

a sample in the ring of the 'o' with a ray to the right crossing three edges and a ray upward crossing one, each crossing marked +1 or -1

6. Draw: signed crossings sum to winding. In the ring, the crossings sum to a nonzero winding; in the hole, the inner and outer contours cancel, so holes need no special handling. Paths apply their non_zero or even_odd rule to the result.

two samples with rays to the right: one in the ring whose crossings sum to +1, one in the hole whose -1 and +1 cancel

7. Draw: nearby crossings give partial coverage. A crossing more than half a pixel ahead of the center counts fully; one more than half a pixel behind counts not at all; in between it counts linearly. That ramp is the antialiasing. The two rays' results are blended, weighted toward the ray whose crossing lies nearer the center. Coverage scales the paint into premultiplied linear color, encoded to sRGB when the target needs it. LCD modes take seven samples at third-pixel offsets along the stripe axis and filter them to RGB.

a zoomed edge over device pixels shaded by snail's coverage: full inside, empty outside, partial only where the edge passes within half a pixel of a center

The diagrams are rendered by snail itself; zig build run-algorithm-diagrams writes them to zig-out/.

Differences from the public Slug reference

snail keeps Slug's core: winding from signed ray crossings, the 0x2E74 root-eligibility table, the half-pixel coverage ramp blended across two rays, band lists sorted for early exit, RG16UI band data, and dynamic quad dilation. It differs here:

Public reference shadersnail
Band lookupOne row and one column, at the pixel centerEvery row and column the pixel's footprint spans; a curve listed in several is evaluated once
Curve kindsQuadraticsLines, quadratics, and rational conics; cubics become quadratics on the CPU
Curve storageTwo texels per quadratic, the second shared with the next curveThe same for font outlines; paths use four texels per segment to carry kind and conic weights
Root eligibilitySign bits of the control pointsThe same table, after rounding values within 1/65536 of the ray to zero so segments meeting on the ray agree
Root solveQuadratic formulaOne root from the formula, arranged to avoid cancellation; the other from the product of the roots
Draw geometryCaller-supplied bounding polygonOne quad per shape

Beyond the renderer, snail adds shaping, font fallback, CFF/CFF2 outlines, paths and paints, hinting, LCD antialiasing, a persistent atlas, and a CPU backend.

Band spans are evaluated in coverage_common.slang, mirrored on the CPU in coverage.zig; bands are built in band_texture.zig.

Text and hinting

Faces.build creates reusable HarfBuzz shaping state over caller-owned Font values. shape() performs style selection, fallback itemization, and shaping, returning glyph IDs, em-space positions, resolved font_id values, and half-open UTF-8 source-byte ranges.

Direction, script, and language can be explicit or inferred:

const features = [_]snail.OpenTypeFeature{
    .{ .tag = "liga".*, .value = 1 },
    .{ .tag = "kern".*, .value = 0, .range = .{ .start = 0, .end = 8 } },
};
var shaped = try snail.shape(alloc, &faces, text, .{
    .direction = .rtl,
    .script = "Arab".*,
    .language = "ar",
    .features = &features,
});
defer shaped.deinit();

direction is run-level shaping direction, not paragraph bidi. Glyph order follows HarfBuzz within each fallback-font run. The fallback itemizer keeps its supported font-sensitive marks, emoji sequences, and Indic sequences together; it is not a general UAX #29 segmenter.

Every face has a caller-assigned font_id. Within one retained atlas, the same ID must always identify the same stable Font pointer, including its selected face and variable coordinates.

Choose a hinting path according to the content:

ModeUse it forPreparation and placement behavior
.unhintedScalable or transformed content; the defaultplanRuns(..., .{ .unhinted = options }); one ppem-independent curve record reusable at subpixel positions
.autohint = policySmall UI/terminal text without relying on font bytecodeplanRuns(..., .{ .autohint = options }); cacheable font model and glyph facts, fitted at draw time by the placement policy
.tt_hint = .{ .ppem_26_6 }TrueType fonts whose native instructions should control small-size fittingplanRuns(..., .{ .tt_hint = TtHintPpem.uniform(ppem_26_6) }); ppem-specific curves and advances prepared by TtHintContext

TrueType bytecode hinting applies only to TrueType outlines and rejects a selected variable-font instance. The autohinter is outline-format agnostic and supports TrueType, CFF/CFF2, and selected variable instances. It fits once per glyph quad under affine placement; a non-affine (perspective) placement, or an axis with more than autohint.max_fit_features features, renders unhinted.

Strong x-axis autohint policies and TrueType hinting need integer device-pixel glyph origins. Use RunSnap.origins for proportional text or .columns for monospace grids, supplying world_to_pixel = mvpToScenePixel(mvp, fb_width, fb_height). Snapped shapes are tied to that transform; unsnapped shapes remain content-only.

For measurement with TrueType-hinted advances, planTtAdvances discovers page-free preparation requests and TtAdvanceSource feeds applied records back into shape() as an AdvanceProvider.

Terminal integrations should use placeCellRun, which preserves HarfBuzz cluster offsets while the host supplies exact source ranges and columns. See Terminal-style cell grids and the run-terminal example.

General paths use the same atlas and draw pipeline. Author a Path, call prepare, obtain fill or stroke curves, and place the result with a transform. Paint remapping can fail: radial records accept similarities and conic records accept orientation-preserving similarities; an ellipse, shear, or reversed conic sweep returns UnsupportedTransform.

Integration contracts

The full contracts are in Embedding snail. The points most likely to affect a first integration are:

  • Colors: paint, tint, placement, and instance [4]f32 colors are linear-light with straight alpha. Coverage first produces premultiplied linear color; the target policy decides whether the stage encodes it. LinearResolve.Backdrop.clear is the explicit sRGB-input exception. CPAL colors are converted from sRGB during extraction.
  • Y axis: font geometry is y-up; placement selects a y-down or y-up scene.
  • Images: the core stores opaque tightly packed texels. The backend must sample them as linear color with straight alpha.
  • Lifetimes: Font borrows bytes; Faces borrows Font pointers; prepared archive views borrow archive bytes; upload regions borrow planner, atlas, or image memory; PagePool outlives every related atlas/planner/device cache.
  • Threading: separate atlas handles may be used on separate threads, but the same mutable handle may not. Preparation contexts and planners are thread-confined; use one per worker.
  • CPU transform limit: snail-raster supports affine transforms, not perspective.

Generated complete shaders cover Vulkan SPIR-V, WGSL, GLSL 330, GLES 300, D3D11 HLSL, and Metal MSL. The authored source of truth is src/snail/shader/slang. The render ABI is versioned; each packed instance is 72 bytes (18 words). Instances carry an 8-bit bank-local layer while draw batches carry the aligned logical-page base, so one pool can address up to 65,536 pages.

Modules

  • snail — fonts, shaping, placement, paths, paints, atlas storage, upload planning, draw emission, and render contracts. It links libc and system HarfBuzz, with no GPU or window-system dependency.
  • snail-raster — optional software DeviceAtlas, Renderer, and draw, including linear-light blending and subpixel AA.
  • snail-shaders* — generated complete stages and reflected binding contracts. Import only the target scope needed by the host.
  • src/snail and src/snail-raster — the only hand-written runtime packages. The raster package's helper modules are private implementation details wired into the published snail-raster module.
  • dev — development-only tests, tools, asset support, demos, and complete reference renderers. In particular, the OpenGL and Vulkan code here is caller-owned example code, not a Snail GPU backend or public runtime module.

Public module boundaries are gated by dev/tests/public_renderer_api.zig and dev/tests/public_shader_api.zig.

Build

The core requires Zig 0.16 and HarfBuzz via pkg-config. Shader generation requires slangc; the repository's complete shader-validation suite also uses naga. Interactive demos require the corresponding window system and graphics API.

zig build test-core               # core + software renderer; no shader tools
zig build test                    # complete generated-shader/API suite
zig build ci                      # complete local Linux CI suite (in nix-shell)
zig build run-minimal-gl          # public-API GL example → zig-out/minimal-gl.tga
zig build run-terminal            # incremental terminal cell grid
zig build run-game                # perspective text in a custom material
zig build run-banner-screenshot   # headless CPU reference render
zig build run-backend-compare     # CPU/GL divergence gate
zig build gen-shaders             # materialize every generated shader target

Other useful gates include run-minimal-wgpu, run-minimal-d3d11, run-minimal-metal, check-metal-demo, run-composite-probe, run-coverage-parity, and run-gamma-probe. Run zig build -l for the full list.

With Nix: nix-build builds the demo package, or enter nix-shell for the complete development toolchain. zig build ci pins the shell's Mesa software stack and runs the same scoped gates used by the parallel Linux CI jobs. The individual ci-tests, ci-linux-gl, ci-linux-vulkan, ci-linux-wgpu-wine, ci-consumer-builds, ci-cross, and ci-nix steps are useful when iterating on one job.

To regenerate the README images after rendering their TGA sources:

magick zig-out/banner.tga assets/banner.png
for image in curves bands quad sample-bands roots winding alpha; do
  magick "zig-out/algorithm-${image}.tga" "assets/algorithm-${image}.png"
done

As a dependency

zig fetch --save git+https://github.com/psyclyx/snail
const snail_dep = b.dependency("snail", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("snail", snail_dep.module("snail"));
exe.root_module.addImport("snail-raster", snail_dep.module("snail-raster"));
exe.root_module.addImport(
    "snail_shaders",
    snail_dep.module("snail-shaders-glsl330"),
);

Use these named modules instead of constructing modules from snail_dep.path("src/..."). The named snail-raster module already contains all of its implementation wiring and depends only on the named snail module. Shared backend-neutral target values live at snail.render.target. Passing target and optimize to b.dependency binds all selected Snail modules to the consumer's build configuration.

Other shader scopes are snail-shaders-vk (Vulkan SPIR-V only), snail-shaders-gl (GLSL 330 + GLES 300), snail-shaders-wgsl, snail-shaders-hlsl, and snail-shaders-msl. snail-shaders includes every target and runs the generated-artifact validations. Omit shader imports when using only snail or snail-raster.

An engine that compiles its own shaders needs none of those. The push-constant and binding contract is committed at src/snail/shader/reflection.zig and exposed by snail-shaders-reflection, which embeds no artifacts and needs no shader toolchain: pair it with the snail_slang source (below) and compile to your target with your own pipeline. zig build check-reflection keeps the committed contract in lockstep with the shaders.

Custom shader families

To draw your own effects through snail's pipeline, author a Slang family that imports snail's caller-facing modules — the pattern the game demo uses in dev/demo/game/slang/game_material.slang. snail publishes its Slang module catalog as the snail_slang named lazy path; hand it to slangc via -I and compile for your target.

A family that reuses snail's inputs stays layout-compatible with the built-in stages: the reflected snail-shaders* binding contract still describes the vertex format and push constants. The full slangc recipe, the public modules you can import, and the complete per-target flag matrix (defines, profiles, and GL/WGSL/Metal quirks) live in src/snail/shader/slang/README.md and build/slang_shaders.zig.

Status

Alpha. The embeddable-only rewrite is complete; the Zig API is settling but breaking changes remain possible. See the changelog.

CI runs the unit/public-API suites and generated shader contracts; CPU/GPU image comparison; coverage, composite, gamma, and screenshot probes; Vulkan, WebGPU, D3D11, and software consumer builds; and real Metal execution on macOS. The pinned local toolchain is the same one described by shell.nix.

License

MIT.

psyclyx/snail

GPU text and vector rendering via direct Bezier curve evaluation.

Zig

2

1,151 commits

updated Sep 30, 2026

See the code

README

snail banner: every size fits, illustrated by a blue-gray vector snail carrying a detailed golden-ratio shell construction

snail

Text and vector rendering from Bézier curves, built to embed in an engine.

snail stores glyph outlines and vector paths as curves, then evaluates those curves while drawing. It does not pre-render glyphs into bitmap atlases or signed distance fields. An unhinted record has no baked pixel resolution: the same prepared data can be reused across sizes, rotations, affine transforms, and projective transforms on the GPU.

snail does not provide or own a GPU backend. The core library prepares CPU data and emits texture uploads, typed draw records, native Slang modules, and complete generated shader stages. Your engine owns GPU textures, pipelines, uploads, command buffers, and draw calls. The optional snail-raster module is an affine-only CPU backend.

This is alpha-quality software; see Status.

Start here

Text rendering in one minute

A character is part of a text encoding; a glyph is a drawable shape chosen from a font. They are not one-to-one: a ligature can turn several characters into one glyph, while a base character plus marks can produce several positioned glyphs.

Text rendering therefore has two separate jobs:

  1. Shaping turns UTF-8 text into glyph IDs, positions, and advances, following script, language, direction, OpenType features, and font fallback. snail uses HarfBuzz for this.
  2. Rasterization determines how much of each screen pixel those glyphs cover. snail does this from the glyphs' mathematical outlines.

A font outline is one or more closed contours made from lines and Bézier curves. Font coordinates are measured relative to the em, the font's design-space unit square. ppem means pixels per em: roughly the rendered text size in device pixels. Hinting moves outline features onto the pixel grid at small sizes; unhinted outlines retain their natural geometry.

The word atlas here means a packed GPU lookup store, not a bitmap glyph sheet. snail's atlas contains curves, band indexes, and optional paint records.

WorkOwner
Paragraph bidi, line breaking, wrapping, cursor/grapheme policyHost
UTF-8 → positioned glyphs, style selection, font fallbacksnail + HarfBuzz
Glyph/path preparation and persistent record storagesnail on the CPU
GPU resources, uploads, pipelines, and submissionHost
Optional affine software renderingsnail-raster

snail is aimed at text or vector art that changes scale or orientation, especially world-space and perspective-projected content. The tradeoff is that the fragment shader solves candidate curve intersections instead of performing a small fixed number of bitmap/SDF samples. Benchmark the actual content and target hardware; a conventional cached bitmap renderer can be a better fit for static, fixed-size UI text.

Supported scope

  • TrueType, CFF, and CFF2 outlines in OpenType containers, including font collections and selected variable-font instances.
  • HarfBuzz shaping with styled face chains, fallback, explicit or inferred direction/script/language, OpenType features, and UTF-8 source ranges.
  • Unhinted rendering, a resolution-independent draw-time autohinter, and TrueType bytecode hinting.
  • General paths containing lines, quadratics, cubics, and rational conics; fills, strokes, solid colors, linear/radial/conic gradients, and images.
  • COLRv0 color fonts, and PNG embedded-bitmap strikes (CBDT/CBLC, sbix) via Font.colorBitmap plus a host image decoder. COLRv1 and OpenType SVG are not currently supported.
  • Grayscale analytic AA and optional LCD subpixel AA.

snail does not perform paragraph bidi, line breaking, wrapping, Unicode grapheme or terminal-width policy, cursor movement, or image-file decoding. See Font format support for the detailed matrix and the planned unified COLRv1/SVG integration boundary.

The pipeline

Everything is shape → plan/prepare → apply → upload → emit → draw.

Atlas is a persistent, value-typed CPU store. Preparation, caching, and residency are explicit: there is no hidden thread pool, filesystem cache, or eviction policy.

const snail = @import("snail");

// Shape: parse fonts and turn UTF-8 into positioned glyphs.
var font = try snail.Font.init(font_bytes); // borrows font_bytes
var faces = try snail.Faces.build(alloc, &.{
    .{ .font = &font, .font_id = 0 },
});
defer faces.deinit();
var shaped = try snail.shape(alloc, &faces, "Hello, world", .{});
defer shaped.deinit();

// Plan: discover missing, cacheable work without extracting outlines.
var pool = try snail.PagePool.init(alloc, .{
    .max_pages = 8, // may exceed 256 with a banked/flat backend
    .curve_words_per_page = 1 << 17,
    .band_words_per_page = 1 << 14,
});
defer pool.deinit();
var atlas = try snail.Atlas.initWithPacking(
    alloc, pool, .{ .recent_page_limit = 12 },
);
defer atlas.deinit();
const sources = [_]snail.FontSource{.{
    .font_id = 0,
    .font = &font,
    // Stable identity of these font bytes, face index, and variations.
    .cache_key = myFontInstanceKey(font_bytes, 0, &.{}),
}};
var prepare_plan = try snail.planRuns(
    &atlas, alloc, &sources, &.{&shaped}, .{ .unhinted = .{} },
);
defer prepare_plan.deinit();

// Prepare: the caller may satisfy requests from an Archive or schedule them
// on workers. This short example executes unhinted misses synchronously.
const requests = prepare_plan.requests();
const owned = try alloc.alloc(?snail.prepared.OwnedRecord, requests.len);
defer {
    for (owned) |*record| if (record.*) |*value| value.deinit();
    alloc.free(owned);
}
@memset(owned, null);
const results = try alloc.alloc(?snail.prepared.RecordView, requests.len);
defer alloc.free(results);
@memset(results, null);

var outline_context = snail.OutlineContext.init(alloc, alloc);
defer outline_context.deinit();
for (requests, 0..) |request, index| {
    owned[index] = try outline_context.prepare(request);
    results[index] = owned[index].?.view();
}

// Apply: validate every result, then publish one logically atomic AtlasUpdate.
try prepare_plan.applyInPlace(alloc, &atlas, results);

// Upload: copy backend-neutral regions into textures owned by your engine.
const upload_options: snail.atlas_upload.Options = .{
    .max_bindings = 16,
    .layer_info_height = 64,
    .max_images = 16,
    .max_image_width = 2048,
    .max_image_height = 2048,
};
var planner = try snail.atlas_upload.OwnedPlanner.init(
    alloc, pool, upload_options,
);
defer planner.deinit();
const binding = upload: {
    var pending_upload = try planner.plan(&atlas);
    errdefer planner.abort(&pending_upload) catch {};
    for (pending_upload.regions()) |region| {
        try myEngine.acceptTextureCopy(region);
    }
    break :upload try planner.commit(&pending_upload);
};

// Emit: place glyphs, then produce typed instances and coalesced batches.
const shapes = try snail.placeRunAlloc(alloc, &shaped, null, .{
    .baseline = .{ .x = 48, .y = 92 },
    .em = 34,
});
defer alloc.free(shapes);
_ = try snail.emit.emit(
    instances, batches, &instance_count, &batch_count,
    binding, &atlas, shapes, world_xform, .{ 1, 1, 1, 1 },
);
const records: snail.render.records.DrawRecords = .{
    .instances = instances[0..instance_count],
    .batches = batches[0..batch_count],
};

// Draw: bind the generated stages from @import("snail_shaders").
// Submit one instanced draw per batch and one quad per instance. Release
// `binding` only after the final GPU use of it has completed.

The complete raw-OpenGL version is dev/demo/app/minimal_gl.zig, runnable with zig build run-minimal-gl. Reference GPU integrations live under dev/demo/render/gl and dev/demo/render/vulkan. The software-renderer flow and the detailed upload, lifetime, color, threading, and ABI contracts are in Embedding snail.

Expensive producer output can optionally be stored in a snail.prepared.Archive. Archive.fromBytes borrows the archive bytes—it does not take a filename or perform I/O—so the host chooses whether to read, stream into retained storage, memory-map, embed, or download them. Lookups are zero-copy views into those retained bytes. Stable artifact keys are constructed by Snail from a caller-supplied font-instance identity, the producer version, and every output-affecting option, including cubic tolerance. planRuns exposes cacheable requests and their dependencies so the host can satisfy hits immediately, schedule misses on its own workers, then apply the completed views to the atlas atomically. See Prepared artifacts and disk caches for the ownership, validation, async, and cache-versioning contracts.

PagePool is the resident page budget. Applying prepared data is idempotent and returns error.OutOfLayers when a new record cannot acquire a page. The host chooses what to evict. Atlas.compactInto(..., target_pool, filter) rebuilds a retained working set in a distinct pool, so the source can remain drawable while the replacement uploads in the background. Publish the new atlas and binding together, then release the old binding, atlas, and pool after their final GPU use completes. The same-pool compact convenience retains both persistent snapshots and therefore still needs result-sized free-page headroom.

Font outlines (lines and quadratics only) store about one texel per segment (see How it works); general paths use four. Page height is a host budget (curve_words_per_page / band_words_per_page), not a fixed shader dimension. Logical pages are allocated lazily. Array backends bank them in groups of 256; flat typed-buffer backends address them with fixed page strides. Either path avoids eviction solely because one texture array is full. The default packer considers only the 12 most recent pages and chooses the tightest fit across both curve and band capacity; Atlas.Packing{ .recent_page_limit = 1 } selects tail-only placement. The bounded window makes insertion cost independent of total atlas size.

On a direct append-only atlas child, planDelta returns a PendingUpload containing only changed page regions and appended side data. Layer-info and image storage are fixed reservations made by the original plan; release and create a fresh binding if later side data no longer fits.

Algorithm

snail implements the core coverage method described by Eric Lengyel:

The Slug patent (US 10,373,352) was permanently dedicated to the public domain effective March 17, 2026. snail is original code rather than code copied from the Slug Library product or public reference shaders, and is licensed under MIT.

How it works

The CPU prepares each record once. The GPU then evaluates those curves for every covered pixel. Nothing is rasterized ahead of time; only image paints are images.

1. Prepare: outlines stay curves. A font outline is a list of line and quadratic segments. A quadratic has a start point p0, a control point p1, and an end point p2, stored as em-unit coordinates in RGBA16F texels, one 16-bit float per channel: a segment reads p0 and p1 from its texel and p2 from the next. That next texel also holds the following segment's control point, so it starts the following segment: a contour of n segments takes n + 1 texels. TrueType outlines are already quadratic. Cubics from CFF/CFF2 or paths are split at extrema and inflections, then approximated by quadratics certified against a tolerance (default 1/8192 em); if f32 cannot represent a certified result, preparation returns a typed error. General paths use four texels per segment so they can also record each segment's kind and rational-conic weights.

Unhinted and autohint records do not depend on size. TrueType-hinted records are prepared per ppem.

an 'o' outline with its 16 segment boundaries marked and one segment's p0, p1, p2 labeled; beside it, the glyph as one run of single texels in the curve texture, with the labeled segment's two RGBA16F texels expanded: x and y per 16-bit channel, p0 and p1 in the first texel, p2 and the next segment's p1 in the second

2. Prepare: bands index the curves. The outline's bounds are cut into equal horizontal and vertical bands, 1 to 12 per axis depending on the segment count. Each band lists the segments that overlap it, in the order the evaluator reads them: farthest along the ray first, so it can stop once the rest lie more than half a pixel behind the sample.

the 'o' with six horizontal and six vertical bands over its bounds; one band per axis is highlighted with its overlapping segments numbered and listed

The atlas textures are curves (RGBA16F), bands (RG16UI), and layer info (RGBA32F), plus an optional image array. Instances, parameters, samplers, and pipelines belong to the host.

3. Draw: one quad per glyph. Each placed shape with curves becomes one instance of a quad over its bounds. The vertex shader grows the quad just enough to cover the antialiasing or LCD-filter footprint under any transform, including perspective. Each fragment receives its position in glyph space and the derivatives that size its pixel there. The CPU rasterizer handles affine transforms only and reports NonAffineMvp for a perspective MVP.

a rotated 'o' inside its bounding quad on a pixel grid, with one pixel mapped back to the upright glyph

4. Draw: the pixel footprint picks band spans. The rows the pixel's footprint spans supply candidates for the horizontal ray; the columns it spans supply candidates for the vertical ray. A curve listed in several of those bands is evaluated once. The public Slug shader reads one row and one column at the pixel center instead, which can miss curves when a pixel covers more than one band.

an enlarged pixel footprint on the 'o'; the rows and columns it spans are shaded and their curves highlighted as candidates

5. Draw: solve candidates along two rays. One ray runs from the pixel center toward +x, another toward +y. Each candidate's roots give where it crosses a ray, and its direction there gives the crossing's sign. Before any root is used, the signs of the three control points relative to the sample index Slug's 0x2E74 table, which says whether zero, one, or two roots count. snail snaps values within 1/65536 of the ray to zero, so adjacent segments meeting on the ray make the same decision, and solves for roots in a form that avoids cancellation. Lines follow the same rule; rational conics apply it to their weighted control points.

a sample in the ring of the 'o' with a ray to the right crossing three edges and a ray upward crossing one, each crossing marked +1 or -1

6. Draw: signed crossings sum to winding. In the ring, the crossings sum to a nonzero winding; in the hole, the inner and outer contours cancel, so holes need no special handling. Paths apply their non_zero or even_odd rule to the result.

two samples with rays to the right: one in the ring whose crossings sum to +1, one in the hole whose -1 and +1 cancel

7. Draw: nearby crossings give partial coverage. A crossing more than half a pixel ahead of the center counts fully; one more than half a pixel behind counts not at all; in between it counts linearly. That ramp is the antialiasing. The two rays' results are blended, weighted toward the ray whose crossing lies nearer the center. Coverage scales the paint into premultiplied linear color, encoded to sRGB when the target needs it. LCD modes take seven samples at third-pixel offsets along the stripe axis and filter them to RGB.

a zoomed edge over device pixels shaded by snail's coverage: full inside, empty outside, partial only where the edge passes within half a pixel of a center

The diagrams are rendered by snail itself; zig build run-algorithm-diagrams writes them to zig-out/.

Differences from the public Slug reference

snail keeps Slug's core: winding from signed ray crossings, the 0x2E74 root-eligibility table, the half-pixel coverage ramp blended across two rays, band lists sorted for early exit, RG16UI band data, and dynamic quad dilation. It differs here:

Public reference shadersnail
Band lookupOne row and one column, at the pixel centerEvery row and column the pixel's footprint spans; a curve listed in several is evaluated once
Curve kindsQuadraticsLines, quadratics, and rational conics; cubics become quadratics on the CPU
Curve storageTwo texels per quadratic, the second shared with the next curveThe same for font outlines; paths use four texels per segment to carry kind and conic weights
Root eligibilitySign bits of the control pointsThe same table, after rounding values within 1/65536 of the ray to zero so segments meeting on the ray agree
Root solveQuadratic formulaOne root from the formula, arranged to avoid cancellation; the other from the product of the roots
Draw geometryCaller-supplied bounding polygonOne quad per shape

Beyond the renderer, snail adds shaping, font fallback, CFF/CFF2 outlines, paths and paints, hinting, LCD antialiasing, a persistent atlas, and a CPU backend.

Band spans are evaluated in coverage_common.slang, mirrored on the CPU in coverage.zig; bands are built in band_texture.zig.

Text and hinting

Faces.build creates reusable HarfBuzz shaping state over caller-owned Font values. shape() performs style selection, fallback itemization, and shaping, returning glyph IDs, em-space positions, resolved font_id values, and half-open UTF-8 source-byte ranges.

Direction, script, and language can be explicit or inferred:

const features = [_]snail.OpenTypeFeature{
    .{ .tag = "liga".*, .value = 1 },
    .{ .tag = "kern".*, .value = 0, .range = .{ .start = 0, .end = 8 } },
};
var shaped = try snail.shape(alloc, &faces, text, .{
    .direction = .rtl,
    .script = "Arab".*,
    .language = "ar",
    .features = &features,
});
defer shaped.deinit();

direction is run-level shaping direction, not paragraph bidi. Glyph order follows HarfBuzz within each fallback-font run. The fallback itemizer keeps its supported font-sensitive marks, emoji sequences, and Indic sequences together; it is not a general UAX #29 segmenter.

Every face has a caller-assigned font_id. Within one retained atlas, the same ID must always identify the same stable Font pointer, including its selected face and variable coordinates.

Choose a hinting path according to the content:

ModeUse it forPreparation and placement behavior
.unhintedScalable or transformed content; the defaultplanRuns(..., .{ .unhinted = options }); one ppem-independent curve record reusable at subpixel positions
.autohint = policySmall UI/terminal text without relying on font bytecodeplanRuns(..., .{ .autohint = options }); cacheable font model and glyph facts, fitted at draw time by the placement policy
.tt_hint = .{ .ppem_26_6 }TrueType fonts whose native instructions should control small-size fittingplanRuns(..., .{ .tt_hint = TtHintPpem.uniform(ppem_26_6) }); ppem-specific curves and advances prepared by TtHintContext

TrueType bytecode hinting applies only to TrueType outlines and rejects a selected variable-font instance. The autohinter is outline-format agnostic and supports TrueType, CFF/CFF2, and selected variable instances. It fits once per glyph quad under affine placement; a non-affine (perspective) placement, or an axis with more than autohint.max_fit_features features, renders unhinted.

Strong x-axis autohint policies and TrueType hinting need integer device-pixel glyph origins. Use RunSnap.origins for proportional text or .columns for monospace grids, supplying world_to_pixel = mvpToScenePixel(mvp, fb_width, fb_height). Snapped shapes are tied to that transform; unsnapped shapes remain content-only.

For measurement with TrueType-hinted advances, planTtAdvances discovers page-free preparation requests and TtAdvanceSource feeds applied records back into shape() as an AdvanceProvider.

Terminal integrations should use placeCellRun, which preserves HarfBuzz cluster offsets while the host supplies exact source ranges and columns. See Terminal-style cell grids and the run-terminal example.

General paths use the same atlas and draw pipeline. Author a Path, call prepare, obtain fill or stroke curves, and place the result with a transform. Paint remapping can fail: radial records accept similarities and conic records accept orientation-preserving similarities; an ellipse, shear, or reversed conic sweep returns UnsupportedTransform.

Integration contracts

The full contracts are in Embedding snail. The points most likely to affect a first integration are:

  • Colors: paint, tint, placement, and instance [4]f32 colors are linear-light with straight alpha. Coverage first produces premultiplied linear color; the target policy decides whether the stage encodes it. LinearResolve.Backdrop.clear is the explicit sRGB-input exception. CPAL colors are converted from sRGB during extraction.
  • Y axis: font geometry is y-up; placement selects a y-down or y-up scene.
  • Images: the core stores opaque tightly packed texels. The backend must sample them as linear color with straight alpha.
  • Lifetimes: Font borrows bytes; Faces borrows Font pointers; prepared archive views borrow archive bytes; upload regions borrow planner, atlas, or image memory; PagePool outlives every related atlas/planner/device cache.
  • Threading: separate atlas handles may be used on separate threads, but the same mutable handle may not. Preparation contexts and planners are thread-confined; use one per worker.
  • CPU transform limit: snail-raster supports affine transforms, not perspective.

Generated complete shaders cover Vulkan SPIR-V, WGSL, GLSL 330, GLES 300, D3D11 HLSL, and Metal MSL. The authored source of truth is src/snail/shader/slang. The render ABI is versioned; each packed instance is 72 bytes (18 words). Instances carry an 8-bit bank-local layer while draw batches carry the aligned logical-page base, so one pool can address up to 65,536 pages.

Modules

  • snail — fonts, shaping, placement, paths, paints, atlas storage, upload planning, draw emission, and render contracts. It links libc and system HarfBuzz, with no GPU or window-system dependency.
  • snail-raster — optional software DeviceAtlas, Renderer, and draw, including linear-light blending and subpixel AA.
  • snail-shaders* — generated complete stages and reflected binding contracts. Import only the target scope needed by the host.
  • src/snail and src/snail-raster — the only hand-written runtime packages. The raster package's helper modules are private implementation details wired into the published snail-raster module.
  • dev — development-only tests, tools, asset support, demos, and complete reference renderers. In particular, the OpenGL and Vulkan code here is caller-owned example code, not a Snail GPU backend or public runtime module.

Public module boundaries are gated by dev/tests/public_renderer_api.zig and dev/tests/public_shader_api.zig.

Build

The core requires Zig 0.16 and HarfBuzz via pkg-config. Shader generation requires slangc; the repository's complete shader-validation suite also uses naga. Interactive demos require the corresponding window system and graphics API.

zig build test-core               # core + software renderer; no shader tools
zig build test                    # complete generated-shader/API suite
zig build ci                      # complete local Linux CI suite (in nix-shell)
zig build run-minimal-gl          # public-API GL example → zig-out/minimal-gl.tga
zig build run-terminal            # incremental terminal cell grid
zig build run-game                # perspective text in a custom material
zig build run-banner-screenshot   # headless CPU reference render
zig build run-backend-compare     # CPU/GL divergence gate
zig build gen-shaders             # materialize every generated shader target

Other useful gates include run-minimal-wgpu, run-minimal-d3d11, run-minimal-metal, check-metal-demo, run-composite-probe, run-coverage-parity, and run-gamma-probe. Run zig build -l for the full list.

With Nix: nix-build builds the demo package, or enter nix-shell for the complete development toolchain. zig build ci pins the shell's Mesa software stack and runs the same scoped gates used by the parallel Linux CI jobs. The individual ci-tests, ci-linux-gl, ci-linux-vulkan, ci-linux-wgpu-wine, ci-consumer-builds, ci-cross, and ci-nix steps are useful when iterating on one job.

To regenerate the README images after rendering their TGA sources:

magick zig-out/banner.tga assets/banner.png
for image in curves bands quad sample-bands roots winding alpha; do
  magick "zig-out/algorithm-${image}.tga" "assets/algorithm-${image}.png"
done

As a dependency

zig fetch --save git+https://github.com/psyclyx/snail
const snail_dep = b.dependency("snail", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("snail", snail_dep.module("snail"));
exe.root_module.addImport("snail-raster", snail_dep.module("snail-raster"));
exe.root_module.addImport(
    "snail_shaders",
    snail_dep.module("snail-shaders-glsl330"),
);

Use these named modules instead of constructing modules from snail_dep.path("src/..."). The named snail-raster module already contains all of its implementation wiring and depends only on the named snail module. Shared backend-neutral target values live at snail.render.target. Passing target and optimize to b.dependency binds all selected Snail modules to the consumer's build configuration.

Other shader scopes are snail-shaders-vk (Vulkan SPIR-V only), snail-shaders-gl (GLSL 330 + GLES 300), snail-shaders-wgsl, snail-shaders-hlsl, and snail-shaders-msl. snail-shaders includes every target and runs the generated-artifact validations. Omit shader imports when using only snail or snail-raster.

An engine that compiles its own shaders needs none of those. The push-constant and binding contract is committed at src/snail/shader/reflection.zig and exposed by snail-shaders-reflection, which embeds no artifacts and needs no shader toolchain: pair it with the snail_slang source (below) and compile to your target with your own pipeline. zig build check-reflection keeps the committed contract in lockstep with the shaders.

Custom shader families

To draw your own effects through snail's pipeline, author a Slang family that imports snail's caller-facing modules — the pattern the game demo uses in dev/demo/game/slang/game_material.slang. snail publishes its Slang module catalog as the snail_slang named lazy path; hand it to slangc via -I and compile for your target.

A family that reuses snail's inputs stays layout-compatible with the built-in stages: the reflected snail-shaders* binding contract still describes the vertex format and push constants. The full slangc recipe, the public modules you can import, and the complete per-target flag matrix (defines, profiles, and GL/WGSL/Metal quirks) live in src/snail/shader/slang/README.md and build/slang_shaders.zig.

Status

Alpha. The embeddable-only rewrite is complete; the Zig API is settling but breaking changes remain possible. See the changelog.

CI runs the unit/public-API suites and generated shader contracts; CPU/GPU image comparison; coverage, composite, gamma, and screenshot probes; Vulkan, WebGPU, D3D11, and software consumer builds; and real Metal execution on macOS. The pinned local toolchain is the same one described by shell.nix.

License

MIT.

Languages

Zig

91.8%

Slang

4.3%

C

3.0%