GPU text and vector rendering via direct Bezier curve evaluation.
Zig
2
1,151 commits
updated Sep 30, 2026
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.
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:
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.
sbix)
via Font.colorBitmap plus a host image decoder. COLRv1 and OpenType SVG
are not currently supported.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.
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.
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.
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.
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 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.
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.
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.
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.
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.
The diagrams are rendered by snail itself; zig build run-algorithm-diagrams writes them to zig-out/.
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 shader | snail | |
|---|---|---|
| Band lookup | One row and one column, at the pixel center | Every row and column the pixel's footprint spans; a curve listed in several is evaluated once |
| Curve kinds | Quadratics | Lines, quadratics, and rational conics; cubics become quadratics on the CPU |
| Curve storage | Two texels per quadratic, the second shared with the next curve | The same for font outlines; paths use four texels per segment to carry kind and conic weights |
| Root eligibility | Sign bits of the control points | The same table, after rounding values within 1/65536 of the ray to zero so segments meeting on the ray agree |
| Root solve | Quadratic formula | One root from the formula, arranged to avoid cancellation; the other from the product of the roots |
| Draw geometry | Caller-supplied bounding polygon | One 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.
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. 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.
The full contracts are in Embedding snail. The points most likely to affect a first integration are:
[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.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.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.
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.
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
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.
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.
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.
MIT.
Zig
91.8%
Slang
4.3%
C
3.0%
GPU text and vector rendering via direct Bezier curve evaluation.
Zig
2
1,151 commits
updated Sep 30, 2026
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.
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:
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.
sbix)
via Font.colorBitmap plus a host image decoder. COLRv1 and OpenType SVG
are not currently supported.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.
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.
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.
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.
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 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.
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.
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.
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.
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.
The diagrams are rendered by snail itself; zig build run-algorithm-diagrams writes them to zig-out/.
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 shader | snail | |
|---|---|---|
| Band lookup | One row and one column, at the pixel center | Every row and column the pixel's footprint spans; a curve listed in several is evaluated once |
| Curve kinds | Quadratics | Lines, quadratics, and rational conics; cubics become quadratics on the CPU |
| Curve storage | Two texels per quadratic, the second shared with the next curve | The same for font outlines; paths use four texels per segment to carry kind and conic weights |
| Root eligibility | Sign bits of the control points | The same table, after rounding values within 1/65536 of the ray to zero so segments meeting on the ray agree |
| Root solve | Quadratic formula | One root from the formula, arranged to avoid cancellation; the other from the product of the roots |
| Draw geometry | Caller-supplied bounding polygon | One 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.
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. 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.
The full contracts are in Embedding snail. The points most likely to affect a first integration are:
[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.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.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.
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.
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
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.
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.
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.
MIT.
Zig
91.8%
Slang
4.3%
C
3.0%