Skip to content

Repository files navigation

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.

Work Owner
Paragraph bidi, line breaking, wrapping, cursor/grapheme policy Host
UTF-8 → positioned glyphs, style selection, font fallback snail + HarfBuzz
Glyph/path preparation and persistent record storage snail on the CPU
GPU resources, uploads, pipelines, and submission Host
Optional affine software rendering snail-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 containing only lines and quadratics use a two-texel dense segment format; general paths retain the four-texel format. 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; set 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

Rendering splits into a preparation phase on the CPU, once per record, and a draw phase for every covered fragment. Outlines are not rasterized ahead of time: the atlas stores curves and indexes. Caller-provided image paints remain raster images.

1. Prepare: outlines remain curves. TrueType outlines use lines and quadratic Béziers. CFF/CFF2 outlines and caller-authored paths can contain cubics; paths can also contain rational conics. Cubics are split at axis extrema and inflections, then adaptively approximated by tangent-preserving quadratic chains. Endpoints and joins are preserved. Every emitted quadratic is certified against the requested source-space tolerance (the default is 1/8192, where one em is one unit for normalized font outlines); if finite f32 output cannot represent a certified result, preparation returns a typed error instead of silently exceeding the tolerance.

Font lines and quadratics use two RGBA16F texels per segment. General paths use four so they can carry explicit segment kind and rational-conic metadata. Unhinted and autohint records are ppem-independent. TrueType grid-fitted curve records are ppem-specific.

a glyph outline with one highlighted quadratic segment, and its two texels in the dense font curve texture

2. Prepare: bands index the curves. The record's box is divided into equal horizontal and vertical bands. Each band lists the segments whose bounds overlap it, sorted by decreasing maximum coordinate so the evaluator can stop once the remaining curves lie more than half a pixel behind the sample.

The current packer chooses 1–12 bands per axis from the logical curve count; the packed record format accepts up to 16. A curve that crosses several bands appears in each of their lists and records its first member band for draw-time deduplication.

horizontal and vertical bands over the glyph, one band highlighted with the curves it references

The atlas textures are curves (RGBA16F), bands (RG16UI), and layer-info rows (RGBA32F), plus an optional host-formatted image array. Instance data, the shared parameter block, samplers, pipelines, and command state are separate host resources.

3. Draw: emit one quad per non-empty instance. A placed shape with curves becomes one instance of a bounding quad; empty records produce no instance. The vertex shader dynamically dilates the quad far enough in device space to cover the grayscale-AA or LCD-filter footprint, including under perspective.

The fragment receives the corresponding record-local position and derivatives. GPU pipelines support affine and projective transforms; the CPU rasterizer supports affine transforms only and reports NonAffineMvp for a perspective MVP.

a transformed glyph in its bounding quad on screen, and a fragment mapped back to glyph space

4. Draw: the pixel footprint selects band spans. This is a deliberate departure from the public Slug shader. That shader selects one horizontal band and one vertical band. snail instead maps both edges of the fragment's record-local pixel footprint to band indexes and visits every band between them.

This is not limited to two band lists: an extremely minified footprint can span every band on either axis. Curves duplicated across touched bands are evaluated once, at max(first_member_band, first_touched_band).

a sample footprint with its horizontal and vertical band spans highlighted and their candidate curves emphasized

5. Draw: classify and solve ray crossings. The evaluator casts axis-aligned rays through the sample. For a quadratic, the signs of its three sample-relative control coordinates index Slug's 0x2E74 eligibility table. That exact classification says whether zero, one, or two roots contribute before the polynomial roots are used for their crossing positions.

snail normalizes sample-relative coordinates within 1/65536 of zero to positive zero so adjacent segments retain one half-open shared-endpoint decision after f16 storage and transform/subtraction drift. The quadratic solver uses a cancellation-resistant Vieta form. Lines use the same half-open sign convention; rational conics use the quadratic eligibility code on their weighted control values before solving their rational crossings.

horizontal and vertical rays through the sample with signed root crossings marked

6. Draw: signed crossings produce winding coverage. A crossing adds or subtracts according to its direction. Horizontal and vertical results are combined using edge-proximity weights, with a conservative fallback near tangencies. General paths then apply their non_zero or even_odd fill rule once to the resolved winding coverage. Oppositely wound hole contours cancel without special hole handling.

two samples with their rays: crossings sum to w=1 in the ring and cancel to w=0 in the hole

7. Draw: nearby crossings become fractional coverage. A crossing within half a device pixel of the sample contributes a fraction rather than a binary inside/outside value. This is analytic antialiasing without a prefiltered glyph image.

Coverage multiplies the resolved solid, gradient, or image paint, producing premultiplied linear color. The stage can leave that value linear for a linear or hardware-sRGB attachment, or encode it when sRGB pixels must be written through a linear attachment. Grayscale AA evaluates one analytic sample per pixel. LCD modes evaluate seven samples at one-third-pixel phases along the display stripe axis, then use a five-tap filter to form RGB coverage.

device pixels along a zoomed edge shaded by fractional analytic coverage

The diagrams are rendered by snail itself. zig build run-algorithm-diagrams writes their TGA sources to zig-out/.

Differences from the public Slug reference

The winding method, 0x2E74 root eligibility, directional coverage combination, half-pixel analytic ramp, sorted-curve early-out, RG16UI band data, and dynamic dilation follow the current public reference.

Public reference shader snail
Selects one horizontal and one vertical band list at the sample center Visits every band touched by the local pixel footprint and deduplicates shared curves
Evaluates quadratic Béziers Evaluates lines, quadratics, and rational conics; cubics are lowered on the CPU
Stores a quadratic in two curve texels, often sharing endpoints Uses two direct texels for font lines/quadratics; general paths use four for kind and conic metadata
Uses raw floating-point sign bits for root eligibility Snaps values within 1/65536 of the ray to positive zero before using the same table
Uses the direct quadratic formula Uses a cancellation-resistant, order-preserving Vieta form
Accepts a caller-built bounding polygon emit always produces a simple quad and the vertex shader dilates its four corners
Covers the core GPU quadratic renderer Adds shaping, fallback, CFF/CFF2, paths and paints, hinting, LCD modes, persistent atlas/upload APIs, and a CPU backend

The band-span behavior is implemented in coverage_common.slang and used by regular text, COLRv0, hinted text, subpixel text, sampled text, and general paths. The CPU mirror is src/snail-raster/coverage.zig. Band construction and its 12-band heuristic are 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:

Mode Use it for Preparation and placement behavior
.unhinted Scalable or transformed content; the default planRuns(..., .{ .unhinted = options }); one ppem-independent curve record reusable at subpixel positions
.autohint = policy Small UI/terminal text without relying on font bytecode planRuns(..., .{ .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 fitting planRuns(..., .{ .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.

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 -A demo, 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.

About

GPU text and vector rendering via direct Bezier curve evaluation.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages