Text and vector rendering via direct Bezier curve evaluation.
snail renders text and vector art by evaluating Bezier curves at draw time. No bitmap glyph atlases, no signed distance fields. Glyphs and paths are resolution-independent and render correctly at any size, rotation, or perspective transform. GPU backends run this in shaders; the CPU backend uses the same prepared atlas data in software.
This is alpha-quality software. The Zig API and C API are settling but not yet stable, and breaking changes are expected.
This is an implementation of the Slug algorithm:
- Eric Lengyel, "GPU-Centered Font Rendering Directly from Glyph Outlines", JCGT 2017
- Eric Lengyel, "A Decade of Slug", 2026
- Reference HLSL shaders (MIT / Apache-2.0)
The Slug patent (US 10,373,352) was dedicated to the public domain in March 2026. This implementation is original code, not derived from the Slug Library product. Licensed under MIT.
Font loading. snail parses TrueType fonts directly: cmap for codepoint-to-glyph mapping, glyf/loca for outlines, hhea/hmtx for metrics, kern for legacy kerning, and OS/2 + post for underline/strikethrough/superscript/subscript metrics. COLR is parsed for color emoji. Optional OpenType shaping applies GSUB ligature substitution (type 4) and GPOS pair positioning (type 2). HarfBuzz can be compiled in for full complex-script shaping.
Atlas preparation. Each glyph's quadratic Bezier curves are packed into two GPU textures at load time:
- Curve texture (RGBA16F): control points for every curve segment, stored as f16 in font-unit coordinates.
- Band texture (RG16UI): spatial subdivision indices. The glyph bounding box is split into horizontal and vertical bands; each band records which curve segments intersect it.
This preprocessing is CPU-only and runs once per glyph set. GPU backends upload the prepared data into 2D texture arrays (one layer per atlas page); the CPU backend reads the same arrays directly without uploading.
Fragment shader. At draw time, each glyph is a screen-space quad. The fragment shader:
- Reads the band indices for this fragment's position.
- For each curve in the active bands, evaluates a quadratic Bezier root equation to count ray crossings.
- Applies the winding rule (non-zero or even-odd) to determine inside/outside.
- Outputs analytic coverage as alpha, optionally with per-channel LCD subpixel offsets for horizontal RGB/BGR or vertical VRGB/VBGR subpixel rendering.
There is no rasterization, no texture sampling for glyph shapes, and no distance field approximation.
Vector paths. Filled and stroked Path geometry shares the curve/band texture format with text; only the fragment shader differs (the path shader handles per-shape paint records and composite groups, while the text shader fast-paths plain coverage). Cubic Bezier inputs are adaptively approximated to quadratics. Strokes are expanded into offset curves with joins (miter, bevel, round) and caps (butt, square, round). The PathPicture type freezes a set of styled paths into an immutable atlas snapshot that can be instanced cheaply per frame.
All color parameters are sRGB, straight (unpremultiplied) alpha, as [4]f32 in the range 0.0–1.0. This applies to Paint.solid, gradient stops, ImagePaint.tint, and text color arguments. The renderer premultiplies alpha and linearizes for blending internally.
Images (Image.initSrgba8) expect sRGB-encoded RGBA8 pixel data (4 bytes per pixel, 0–255). This is what most image decoders produce. Linear-space pixel buffers will appear too bright.
Gradients interpolate in sRGB space, which gives perceptually smooth results for UI use. LinearGradient and RadialGradient provide extend modes for clamp, repeat, and reflect behavior.
Blending uses premultiplied alpha. Shaders decode sRGB inputs to linear before applying coverage. On GL/Vulkan sRGB framebuffers, fixed-function framebuffer encoding handles linear->sRGB storage and gamma-correct blending. On linear framebuffers or CPU buffers, ResolveTarget.encoding states what the framebuffer accepts and what final pixel bytes the consumer expects.
Output encoding. ResolveTarget.encoding is required on every draw:
TargetEncoding.srgb: normal GL/Vulkan_SRGBframebuffer or swapchain image; the framebuffer does the final encode.TargetEncoding.linear: linear UNORM/float targets or CPU buffers whose bytes should stay linear.TargetEncoding.srgb_pixels_on_linear_framebuffer: linear-format storage, including CPU byte buffers, whose consumer expects sRGB bytes. With the default direct resolve strategy, fixed-function blending happens in storage space; this is a compatibility path for targets that cannot be tagged as sRGB, not a gamma-correct composition path.
ResolveTarget.resolve_strategy selects how Snail resolves into the target:
ResolveStrategy.direct: draw straight into the target. This is the default.ResolveStrategy.linear_intermediate: valid withTargetEncoding.srgb_pixels_on_linear_framebuffer. Snail renders its own content into a linear intermediate, then encodes that result into the linear-format target as sRGB pixels. This keeps overlapping Snail draws linear-correct on GL and CPU. Vulkan currently reportserror.UnsupportedResolveStrategyfor this mode because its renderer records inside a caller-owned render pass.
When linear_intermediate composites over pre-existing sRGB bytes in a linear-format target, only the Snail-internal composition is guaranteed linear-correct unless the caller first seeds the intermediate from the destination or includes the backdrop in the Snail draw.
The CPU renderer has no format-level encoder: it writes RGBA8 bytes according to encoding.pixels. It uses an exact 256-entry sRGB->linear LUT for u8 texels and the IEC 61966-2-1 formula directly for linear->sRGB output, with round-to-nearest rounding.
Coverage transfer. ResolveTarget.coverage_transfer optionally remaps analytic coverage before blending. The default is identity; CoverageTransfer.power(exponent) exposes explicit display tuning when a target benefits from slightly stronger or lighter antialiasing.
Requires Zig 0.16, OpenGL 3.3+, Vulkan headers/loader, glslc, and pkg-config. Vulkan and HarfBuzz are enabled by default but can be disabled (see flags below). The interactive demo requires Wayland, plus EGL for OpenGL mode.
zig build test # unit tests
zig build run # interactive 2D demo; press C to cycle enabled backends
zig build run -Dvulkan=false # demo without Vulkan
zig build run -Dopengl=false # demo without OpenGL
zig build run -Dcpu-renderer=false # demo without CPU rendering
zig build run-game-demo # 3D scene with HUD + world-space text on walls
zig build screenshot # 2D demo offscreen → zig-out/demo-screenshot.tga
zig build backend-compare # CPU/GL/Vulkan parity
zig build bench # benchmarks, including Vulkan rows when a Vulkan device is available
zig build install --release=fast # install libsnail + enabled C headers
zig build check-c-api # verify checked-in generated C API files
zig build gen-c-api # regenerate checked-in generated C API filesLibrary backend flags:
-Dopengl=true(default) — OpenGL backend (GlRenderer); installssnail_gl.hwhen the C API is enabled.-Dvulkan=true(default) — Vulkan backend (VulkanRenderer); pass=falsefor a slimmer OpenGL/CPU-only build. SPIR-V shaders are compiled at build time viaglslc; installssnail_vulkan.hwhen the C API is enabled. That extension header includes Vulkan headers.-Dcpu-renderer=true(default) — CPU backend (CpuRenderer); pass=falseto drop it. Installssnail_cpu.hwhen the C API is enabled.-Dharfbuzz=true(default) — pass=falsefor a HarfBuzz-free build using the built-in GSUB type 4 / GPOS type 2 shaper.-Dprofile=false(default) — pass=trueto enable the comptime CPU timers.-Dc-api=true(default) — pass=falsefor a Zig-module-only build (skipslibsnail.{a,so}and the header install).-Dc-api-shared=true/-Dc-api-static=true(default to-Dc-api) — pass either=falseto install only the library form you need.
The checked-in screenshot at assets/demo_screenshot.png is regenerated from the zig build screenshot TGA output.
nix-shell # dev shell with all dependencies
nix-build -A lib # build libsnail + enabled C headers
nix-build -A demo # build snail-demoThe Nix library package is defined in nix/snail.nix; the demo executable is
defined separately in nix/snail-demo.nix. Both are wired through
callPackage from default.nix. The library defaults mirror the Zig build
defaults: OpenGL on, Vulkan on, CPU renderer on, HarfBuzz on, and the C API
enabled with both shared and static libraries. Override enableVulkan,
enableOpenGL, enableCpu, enableHarfBuzz, enableCApi, cApiShared, or
cApiStatic when calling the library package directly.
The demo package also enables all three renderers by default and cycles between
the enabled set at runtime.
Add snail to your build.zig.zon:
zig fetch --save git+https://github.com/psyclyx/snailThen in your build.zig:
const snail_dep = b.dependency("snail", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("snail", snail_dep.module("snail"));The default dependency module enables OpenGL, Vulkan, CPU rendering, and HarfBuzz. Workspace builds that import snail/build.zig directly can call moduleWithOptions to trim backend support explicitly. On NixOS/nix-shell, system libraries are provided automatically; on other systems, install the development packages for your distro.
const snail = @import("snail");
// Create an immutable TextAtlas snapshot with a fallback chain.
var atlas = try snail.TextAtlas.init(allocator, &.{
.{ .data = noto_sans_regular },
.{ .data = noto_sans_bold, .weight = .bold },
.{ .data = noto_sans_regular, .italic = true, .synthetic = .{ .skew_x = 0.2 } },
.{ .data = noto_sans_arabic, .fallback = true },
.{ .data = twemoji, .fallback = true },
});
defer atlas.deinit();
if (try atlas.ensureText(.{}, "Hello, world!")) |next| {
atlas.deinit();
atlas = next;
}
var blob_builder = snail.TextBlobBuilder.init(allocator, &atlas);
defer blob_builder.deinit();
var shaped = try atlas.shapeText(allocator, .{}, "Hello, world!");
defer shaped.deinit();
_ = try blob_builder.append(.{
.shaped = &shaped,
.placement = .{ .baseline = .{ .x = 10, .y = 400 }, .em = 48 },
.fill = .{ .solid = .{ 1, 1, 1, 1 } },
});
var blob = try blob_builder.finish();
defer blob.deinit();
var scene = snail.Scene.init(allocator);
defer scene.deinit();
try scene.addText(.{ .blob = &blob });
// (See "Vector Paths" below for adding a PathPicture to the same scene.)
var resource_entries: [8]snail.ResourceSet.Entry = undefined;
var resources = snail.ResourceSet.init(&resource_entries);
try resources.addScene(&scene);
// Requires an active GL context. Vulkan uses snail.VulkanRenderer.init(ctx).
var gl = try snail.GlRenderer.init(allocator);
defer gl.deinit();
var prepared = try gl.uploadResourcesBlocking(.{ .persistent = allocator, .scratch = allocator }, &resources);
defer prepared.deinit();
const viewport_wf: f32 = @floatFromInt(viewport_w);
const viewport_hf: f32 = @floatFromInt(viewport_h);
const options = snail.DrawOptions{
.mvp = snail.Mat4.ortho(0, viewport_wf, viewport_hf, 0, -1, 1),
.target = .{
.pixel_width = viewport_wf,
.pixel_height = viewport_hf,
.subpixel_order = .rgb,
.encoding = .srgb,
},
};
var prepared_scene = try snail.PreparedScene.initOwned(allocator, &prepared, &scene, options);
defer prepared_scene.deinit();
try gl.drawPrepared(&prepared, &prepared_scene, options);ensureText, ensureShaped, and ensureGlyphs return a new immutable snapshot; the old one remains valid for in-flight readers. Existing TextBlobs keep working with the snapshot they were built against as long as that snapshot and its prepared backend resources stay alive.
if (try atlas.ensureText(.{}, text)) |next| {
atlas.deinit(); // safe only after readers of the old snapshot are done
atlas = next;
}TextBlob.rebound is optional. Use it when you cache blobs across atlas extension and want a new blob that borrows the compatible superset snapshot, usually so the old snapshot can be released and old prepared resources retired without reshaping unchanged text rows.
If you already have shaped glyph IDs, extend the atlas directly and rebind only the cached blobs you plan to keep:
if (try atlas.ensureGlyphs(face_index, glyph_ids)) |next| {
var next_blob = try blob.rebound(allocator, &next);
blob.deinit();
blob = next_blob;
atlas.deinit();
atlas = next;
}A PathPicture is built once and submitted to a Scene like a TextBlob. Add
all draws to the scene before calling resources.addScene and uploading —
PreparedResources is a snapshot of the scene's resource set at upload time.
var path = snail.Path.init(allocator);
defer path.deinit();
try path.addRoundedRect(.{ .x = 0, .y = 0, .w = 200, .h = 80 }, 12);
var builder = snail.PathPictureBuilder.init(allocator);
defer builder.deinit();
try builder.addPath(&path,
.{ .paint = .{ .solid = .{ 0.1, 0.1, 0.2, 0.9 } } }, // fill
.{ .paint = .{ .solid = .{ 0.4, 0.6, 1, 1 } }, .width = 2, .join = .round }, // stroke
.identity,
);
var picture = try builder.freeze(.{
.persistent_allocator = allocator,
.scratch_allocator = allocator,
});
defer picture.deinit();
// Submit before uploading (see the Zig example above).
try scene.addPath(.{ .picture = &picture });Note: This example uses the OpenGL C backend and requires an active OpenGL context. CPU callers include
snail_cpu.hand provide a caller-owned RGBA8 buffer; Vulkan callers includesnail_vulkan.hand provide aSnailVulkanContext. Error checks are omitted here for brevity.
#include "snail.h"
#include "snail_gl.h"
SnailFaceSpec faces[] = {{
.data = ttf_data,
.len = ttf_len,
.weight = SNAIL_FONT_WEIGHT_REGULAR,
}};
SnailTextAtlas *atlas = NULL;
snail_text_atlas_init(NULL, faces, 1, &atlas);
SnailFontStyle style = {.weight = SNAIL_FONT_WEIGHT_REGULAR, .italic = false};
SnailTextAtlas *next = NULL;
snail_text_atlas_ensure_text(atlas, style, "Hello", 5, &next);
if (next) {
snail_text_atlas_deinit(atlas);
atlas = next;
}
// Shape text with source-span metadata.
SnailShapedText *shaped = NULL;
snail_text_atlas_shape_utf8(atlas, style, "Hello", 5, &shaped);
size_t n = snail_shaped_text_glyph_count(shaped);
SnailShapedGlyph g;
for (size_t i = 0; i < n; i++) {
snail_shaped_text_glyph(shaped, i, &g);
// g.glyph_id, g.x_offset, g.source_start, g.source_end ...
}
SnailTextBlob *blob = NULL;
SnailTextAppendOptions text_options = {
.placement = {.baseline_x = 10, .baseline_y = 400, .em = 48},
.fill = {.kind = SNAIL_PAINT_SOLID, .paint_solid = {1, 1, 1, 1}},
};
snail_text_blob_init_from_shaped(NULL, atlas, shaped, text_options, &blob);
snail_shaped_text_deinit(shaped);
// Vector path
SnailPath *path = NULL;
snail_path_init(NULL, &path);
snail_path_add_rounded_rect(path, (SnailRect){0, 0, 200, 80}, 12);
SnailPathPictureBuilder *builder = NULL;
snail_path_picture_builder_init(NULL, &builder);
SnailFillStyle fill = {
.paint = {.kind = SNAIL_PAINT_SOLID, .paint_solid = {0.1, 0.1, 0.2, 0.9}},
};
snail_path_picture_builder_add_filled_path(builder, path, fill,
SNAIL_TRANSFORM2D_IDENTITY);
SnailPathPicture *picture = NULL;
snail_path_picture_builder_freeze(builder, NULL, NULL, &picture);
SnailScene *scene = NULL;
snail_scene_init(NULL, &scene);
snail_scene_add_text(scene, blob);
snail_scene_add_path_picture(scene, picture);
SnailResourceSet *resources = NULL;
snail_resource_set_init(NULL, 8, &resources);
snail_resource_set_add_scene(resources, scene);
SnailRenderer *renderer = NULL;
snail_gl_renderer_init(&renderer);
SnailDrawOptions draw_options = {
.mvp = snail_mat4_identity(), // replace with your pixel-to-clip projection
.target = {
.pixel_width = 1280,
.pixel_height = 720,
.subpixel_order = SNAIL_SUBPIXEL_RGB,
.fill_rule = SNAIL_FILL_NONZERO,
.is_final_composite = true,
.opaque_backdrop = true,
.will_resample = false,
.framebuffer_encoding = SNAIL_COLOR_ENCODING_SRGB,
.pixel_encoding = SNAIL_COLOR_ENCODING_SRGB,
.resolve_strategy = SNAIL_RESOLVE_DIRECT,
.coverage_exponent = 1.0f,
},
};
SnailPreparedResources *prepared = NULL;
snail_renderer_upload_resources_blocking(renderer, NULL, resources, &prepared);
SnailPreparedScene *prepared_scene = NULL;
snail_prepared_scene_init(NULL, prepared, scene, draw_options, &prepared_scene);
snail_renderer_draw_prepared(renderer, prepared, prepared_scene, draw_options);
// Cleanup
snail_prepared_scene_deinit(prepared_scene);
snail_prepared_resources_deinit(prepared);
snail_renderer_deinit(renderer);
snail_resource_set_deinit(resources);
snail_scene_deinit(scene);
snail_text_blob_deinit(blob);
snail_path_picture_deinit(picture);
snail_path_picture_builder_deinit(builder);
snail_path_deinit(path);
snail_text_atlas_deinit(atlas);| Type | Description |
|---|---|
TextAtlas |
Immutable CPU font/glyph snapshot. ensureText, ensureShaped, and ensureGlyphs return a new snapshot; old stays valid. |
ShapedText |
Shaped glyph placements for a string/run. |
TextBlob |
Positioned text that borrows a TextAtlas snapshot. It can be rebound to a compatible superset snapshot when cache lifetime needs it. |
Font |
Stable parsed-font helper for unitsPerEm, glyphIndex, and advanceWidth when callers manage raw font data directly. |
FaceSpec |
{ .data, .weight, .italic, .fallback, .synthetic } — font face specification for TextAtlas.init. |
FontStyle |
{ .weight: FontWeight, .italic: bool } — selects a face for rendering. |
FontWeight |
.regular, .bold, .semi_bold, etc. |
SyntheticStyle |
{ .skew_x, .embolden } — synthetic italic shear and bold offset. |
Image |
Immutable sRGB RGBA8 raster image. Created with initSrgba8. |
Path |
Mutable path builder: moveTo, lineTo, quadTo, cubicTo, close, plus shape helpers. |
PathPictureBuilder |
Accumulates filled/stroked paths and shapes with paint styles. |
PathPicture |
Immutable frozen vector art. |
Scene |
Borrowed command list of TextDraw and PathDraw submissions. |
PathDraw, TextDraw |
Submission record: resource pointer, optional sub-range, and an []const Override array (length = GPU instance count). |
Override |
Per-instance composition: transform composed onto baked transform, tint multiplied onto the resource's baked color or paint, including color-font palette layers. |
Range |
{ start, count } slice into a PathPicture's shapes or a TextBlob's glyphs. |
ResourceSet |
Fixed-capacity borrowed manifest of CPU values. |
ResourceFootprint |
Used and allocated upload bytes split by curve, band, layer-info, and image storage. |
PreparedResources |
Backend realization for one renderer/context. |
DrawList |
Caller-buffered draw records. |
PreparedScene |
Optional owned draw-record cache for static scenes. |
TargetEncoding |
Pair of color encodings for framebuffer interpretation and final stored pixels. Common presets are .srgb, .linear, and .srgb_pixels_on_linear_framebuffer. |
ResolveStrategy |
Per-target resolve path: .direct or .linear_intermediate for gamma-correct Snail composition into sRGB pixels on a linear framebuffer. |
CoverageTransfer |
Optional analytic coverage remap. .identity is the default; .power(exponent) is explicit display tuning. |
ResolveTarget |
Final target metadata: pixel size, subpixel order, fill rule, composite safety flags, required encoding, optional resolve_strategy, and optional coverage_transfer. |
GlRenderer, VulkanRenderer, CpuRenderer |
First-class backend renderers. |
Renderer |
Type-erased convenience wrapper around a backend renderer. |
Rect |
{ x, y, w, h } rectangle. |
Transform2D |
2x3 affine matrix { xx, xy, tx, yx, yy, ty }. |
FillStyle |
Fill Paint. |
StrokeStyle |
Stroke Paint, width, cap, join, miter limit, placement. |
Paint |
Tagged union: .solid, .linear_gradient, .radial_gradient, .image. |
| Method | Description |
|---|---|
TextAtlas.init(alloc, faces) !TextAtlas |
Parse font faces. Atlas starts empty. |
atlas.deinit() |
Release this snapshot. Pages shared with other snapshots stay alive. |
atlas.shapeText(alloc, style, text) !ShapedText |
Shape text without growing the atlas. Caller frees ShapedText. |
atlas.ensureShaped(shaped) !?TextAtlas |
Return a new snapshot with the shaped glyphs present. Null if already present. |
atlas.ensureText(style, text) !?TextAtlas |
Shape-and-ensure helper. |
atlas.ensureGlyphs(face_index, glyph_ids) !?TextAtlas |
Extend one face by resolved glyph IDs without reshaping. |
atlas.faceCount() usize / atlas.primaryFaceIndex() !FaceIndex |
Inspect configured faces for layout code that caches face indices. |
atlas.faceLineMetrics(face_index) !LineMetrics / atlas.faceUnitsPerEm(face_index) !u16 / atlas.glyphIndex(face_index, cp) !?u16 / atlas.advanceWidth(face_index, gid) !i16 |
Stable per-face font metrics for layout code. |
atlas.cellMetrics(.{ .style, .em }) !CellMetrics |
Resolve the styled primary face and return { .cell_width, .line_height } in caller units. |
TextBlob.init(alloc, atlas, append) !TextBlob |
Build one positioned, painted TextAppend from a ShapedText. The blob borrows atlas. |
blob.rebound(alloc, new_atlas) !TextBlob |
Optional cache/lifetime helper: return a blob bound to a compatible atlas snapshot that retains old pages and contains all referenced glyphs. |
TextBlobBuilder.init(alloc, atlas) / builder.append(TextAppend) !TextAppendResult / builder.finish() !TextBlob |
Append shaped runs with explicit placement and fill. Call atlas.ensureText/ensureShaped/ensureGlyphs first if all glyphs must be renderable. |
TextAppend |
{ .shaped, .glyphs, .placement = .{ .baseline, .em }, .fill } — appends a whole shaped run or glyph subrange with independent position/scale and paint. Fill accepts the same Paint union used by paths, in the same coordinate space as placement. |
TextAppendResult |
{ .advance: Vec2, .missing: bool } — pen advance and whether any referenced glyph was absent from the current atlas snapshot. |
A scene is a borrowed list of PathDraw / TextDraw submissions. Each submission selects a sub-range of an immutable resource and emits one GPU instance per Override (default: a single identity instance). The scene borrows the picture / blob pointer and the instances slice on each submission — all three must stay live until scene.reset() or scene.deinit(). addPath / addText use the allocator captured by Scene.init only when growing the command list.
| Method | Description |
|---|---|
Scene.init(alloc) Scene |
New empty scene. |
scene.addPath(PathDraw) !void |
Submit a path draw. Borrows picture and instances. |
scene.addText(TextDraw) !void |
Submit a text draw. Borrows blob and instances. |
scene.reset() |
Clear commands; capacity is retained. |
scene.deinit() |
Free the command list. |
// Trivial draw.
try scene.addPath(.{ .picture = &picture });
// One transform.
const overrides = [_]snail.Override{.{ .transform = transform }};
try scene.addPath(.{ .picture = &picture, .instances = &overrides });
// Sub-range of shapes.
try scene.addPath(.{
.picture = &picture,
.shapes = .{ .start = 4, .count = 12 },
});
// Many instances (tile / sprite / particle batch).
try scene.addPath(.{ .picture = &sprite, .instances = entity_overrides });ResourceSet is a caller-buffered manifest of CPU resources to prepare for a renderer. Entries borrow their source objects; keep those objects alive through the blocking upload or through pending.record for a scheduled upload. GPU backends copy texture payload during upload. CPU-backed PreparedResources still borrow uploaded atlas band/layer-info data and image pixels, so keep uploaded TextAtlas, painted TextBlob, PathPicture, and Image values alive until those CPU prepared resources are retired.
| Method | Description |
|---|---|
ResourceSet.init(entries) |
Wrap a caller-owned []ResourceSet.Entry buffer. |
set.reset() |
Clear entries; capacity is retained. |
set.putTextAtlas(key, atlas) / set.putTextAtlasOptions(key, atlas, options) |
Add a text atlas, optionally overriding atlas capacity mode. |
set.putPathPicture(key, picture) / set.putPathPictureOptions(key, picture, options) |
Add a path picture, optionally overriding atlas capacity mode. |
set.putImage(key, image) |
Add an image resource. |
set.addScene(scene) |
Discover and add all resources referenced by a scene. |
set.estimateUploadFootprint() !ResourceFootprint |
Allocation-free estimate for a resource set before upload. |
GlRenderer, VulkanRenderer, and CpuRenderer are first-class types; Renderer is a type-erased wrapper for backend-agnostic code. Blocking upload and draw methods are present on each concrete renderer and on Renderer; scheduled upload is exposed on Renderer, GlRenderer, and VulkanRenderer.
| Method | Description |
|---|---|
GlRenderer.init(alloc) !GlRenderer |
Initialize the OpenGL backend. Requires the GL context to be current. |
VulkanRenderer.init(ctx) !VulkanRenderer |
Initialize the Vulkan backend from a caller-owned VulkanContext. |
CpuRenderer.init(pixels, w, h, stride) CpuRenderer |
Initialize the CPU backend over a caller-owned RGBA8 buffer. |
cpu.setThreadPool(?*snail.ThreadPool) |
Opt into scanline-tiled multithreaded rendering using a caller-owned snail.ThreadPool. Byte-identical output to the single-threaded path; the draw call itself stays allocation-free. |
vk.beginFrame(.{ .cmd, .frame_index }) |
Bind a caller-recorded Vulkan command buffer + frame index for the current frame. |
renderer.uploadResourcesBlocking(.{ .persistent, .scratch }, set) !PreparedResources |
Blocking upload + view construction. Persistent allocations live with PreparedResources; scratch allocations end when upload returns. |
renderer.planResourceUpload(current, next_set, changed_keys) !ResourceUploadPlan |
Diff a new resource set against existing prepared resources. |
renderer.beginResourceUpload(.{ .persistent, .scratch }, plan) !PendingResourceUpload |
Start a scheduled upload; record into a caller command buffer for Vulkan, then call pending.publish(). |
DrawList.init(words, segments) |
Wrap a caller-buffered word + segment buffer for addScene. |
DrawList.estimate(scene, options) |
Upper bound for the word buffer required by draw.addScene(prepared, scene, options). |
DrawList.estimateSegments(scene, options) |
Upper bound for the segment buffer required by draw.addScene(prepared, scene, options). |
PreparedScene.initOwned(alloc, prepared, scene, options) !PreparedScene |
Build an owned draw-record cache for a static scene. |
renderer.draw(prepared, records, options) |
Execute prebuilt draw records. No resource discovery or upload. |
renderer.drawPrepared(prepared, prepared_scene, options) |
Draw a PreparedScene cache. |
prepared.retireNow() |
Retire backend resources immediately once no in-flight frame references them. |
PreparedResourceRetirementQueue.init(alloc) / queue.sweep() |
Caller-owned queue for prepared resources that must retire after a fence completes. |
prepared.retireAfter(&queue, fence_or_frame) |
Move prepared resources into the caller-owned retirement queue. |
uploadResourcesBlocking is the simple path; for engines that want to overlap
upload with the main render queue (Vulkan in particular) there is an explicit
plan / record / publish flow. Use a type-erased Renderer for backend-agnostic
scheduled uploads, including CPU-backed uploads.
- Plan.
renderer.planResourceUpload(current, next_set, changed_keys_buf)diffsnext_setagainst the existingPreparedResources(ornullfor a first upload) and records whichResourceKeyentries changed. The result is aResourceUploadPlanwhoseupload_footprint,upload_bytes, andchangedKeys()are informational.upload_bytesisupload_footprint.allocatedBytes()for simple budget checks.changed_keys_bufis caller-owned scratch — size it to the number of distinct resources you might submit. - Begin + record.
renderer.beginResourceUpload(.{ .persistent = allocator, .scratch = allocator }, plan)returns aPendingResourceUpload. Callpending.record(.no_command, .{ .budget_bytes = N })for GL/CPU. For Vulkan, pass.{ .vulkan = command_buffer }while recording the caller-owned command buffer. - Wait + publish. Call
pending.ready(.complete),.pending, or.{ .vulkan_fence = fence }to report external completion. GL/CPU report ready immediately after record. Once true,pending.publish()returns the newPreparedResources. Callpending.deinit()if you need to abandon the upload before publishing.
The new PreparedResources replaces the old one; retire the old one via
old.retireNow() once no in-flight frame still references it. For Vulkan
resources that need fence retirement, keep a caller-owned
PreparedResourceRetirementQueue, call old.retireAfter(&queue, fence), and
sweep the queue explicitly.
CoverageShader, TextCoverageRecords, and CoverageBackend let a
material shader sample snail's exact glyph coverage without going through
Renderer.draw. The typical use is layering text with custom lighting,
masking, or compositing.
CoverageShader.glexposes GLSL 330 sources you can@embedFile-style splice into your own program:vertex_interface,fragment_interface, andfragment_body. The legacy aliasesTextCoverageShader.glsl330_*point at the same GL sources.CoverageShader.vulkanexposes the Vulkan shader sources and descriptor binding numbers. The Vulkan coverage backend binds Snail's descriptor set into a caller-owned compatible pipeline layout.TextCoverageRecordsis the per-glyph vertex stream over a caller-owned[]u32. Size it withTextCoverageRecords.wordCapacityForBlob(blob), initialize withTextCoverageRecords.init(buffer), then callrecords.buildLocal(prepared, blob, .{ .transform = ... }).buildLocaldoes not allocate; it returnserror.DrawListFullif the buffer is too small. Callrecords.validFor(prepared)after a re-upload andrecords.rebuildLocal(prepared, blob, options)if the atlas has moved.CoverageBackendis the backend hook. Get one fromprepared.textCoverageBackend(renderer)(orgl.textCoverageBackend(prepared)/vk.textCoverageBackend(prepared)on typed renderers). Callbind(.{ .gl = bindings })orbind(.{ .vulkan = bindings }), thendrawCoverage(&records)ordrawVerticeswith your own buffer.
| Method | Description |
|---|---|
Path.init(alloc) Path |
New empty path. |
path.deinit() |
Free curves. |
path.reset() |
Clear curves; capacity is retained. |
path.isEmpty() bool |
True when no curves have been emitted. |
path.bounds() ?BBox |
Tight bounding box of all curves, or null when empty. |
path.moveTo(point) |
Begin subpath. |
path.lineTo(point) |
Line segment. |
path.quadTo(control, point) |
Quadratic Bezier. |
path.cubicTo(c1, c2, point) |
Cubic Bezier (adaptively approximated to quadratics). |
path.close() |
Close current subpath. |
path.addRect(rect) / path.addRectReversed(rect) |
Append rectangle subpath. The Reversed variant emits the opposite winding (use it to punch a hole through a fill of the same path under nonzero fill rule). |
path.addRoundedRect(rect, radius) / path.addRoundedRectReversed(rect, radius) |
Append rounded rectangle (and reversed-winding form). |
path.addEllipse(rect) / path.addEllipseReversed(rect) |
Append ellipse inscribed in rect (and reversed-winding form). |
| Method | Description |
|---|---|
PathPictureBuilder.init(alloc) |
New builder. |
builder.addPath(path, fill, stroke, transform) |
Add path with optional fill and/or stroke. |
builder.addFilledPath(path, fill, transform) |
Fill-only convenience. |
builder.addStrokedPath(path, stroke, transform) |
Stroke-only convenience. |
builder.addRect(rect, fill, stroke, transform) |
Direct rectangle. |
builder.addRoundedRect(rect, fill, stroke, radius, transform) |
Direct rounded rectangle. |
builder.addEllipse(rect, fill, stroke, transform) |
Direct ellipse. |
builder.shapeCount() usize |
Number of shapes added so far (matches indices used by Range). |
builder.mark() ShapeMark |
Capture the current shape count for later range construction. |
builder.rangeFrom(mark) !Range |
Build a shape range from a mark to the current end. |
builder.rangeBetween(start, end) !Range |
Build a shape range between two marks. |
builder.freeze(.{ .persistent_allocator, .scratch_allocator }) !PathPicture |
Compile to immutable atlas with explicit persistent and temporary allocation. |
Building blocks for callers who need direct curve/band data, want to emit
glyph vertices outside the Scene/DrawList pipeline, or build a custom
backend on top of snail's rasterization. Most apps should not need this.
| Symbol | Use |
|---|---|
lowlevel.bezier, lowlevel.curve_tex |
Geometry math and curve-page packing primitives. |
lowlevel.CurveAtlas/Atlas, lowlevel.AtlasPage |
Raw atlas storage exposed for backend authors. |
lowlevel.TextBatch, lowlevel.PathBatch |
Caller-buffered glyph/shape vertex emission below the DrawList layer. |
lowlevel.TEXT_WORDS_PER_GLYPH, lowlevel.PATH_WORDS_PER_SHAPE, related sizing constants |
u32 word budget per record (prefer DrawList.estimate when possible). |
lowlevel.PATH_PAINT_* constants |
Path-paint texel tags used by PathPicture records. |
lowlevel.PathPictureDebugView, lowlevel.PathPictureBoundsOverlayOptions |
Debug overlays for vector authoring. |
lowlevel.textureLayerWindowBase, lowlevel.textureLayerLocal, lowlevel.TEXTURE_LAYER_WINDOW_SIZE |
Texture-array layer windowing helpers. |
| Type | Rule |
|---|---|
TextAtlas |
Immutable snapshot. Safe for concurrent reads. ensureText, ensureShaped, and ensureGlyphs return a new snapshot; old remains valid for in-flight readers. |
TextBlob, PathPicture, Image |
Safe for concurrent reads while the borrowed atlas / pictures / pixels outlive the reader. TextBlob.rebound returns a new blob instead of mutating the existing one. |
ResourceSet, Scene |
Borrowed manifests/lists. Source values must outlive upload/record building; CPU prepared resources extend some source lifetimes as described below. |
PreparedResources |
Backend/context-specific. GPU prepared resources own backend texture uploads. CPU prepared resources own prepared curve sidecars but still borrow atlas band/layer-info data, painted TextBlob layer-info data, and image pixels. |
DrawList |
Caller-owned buffer. Thread-local — no sharing needed. |
Renderer |
Single-threaded. Must be called from the GL/Vulkan context thread. |
CpuRenderer |
Single-threaded by default. Pass a *snail.ThreadPool via cpu.setThreadPool to enable internal scanline-tiled parallelism; the renderer fans tile work out and joins before each draw returns, so calls remain serial from the caller's perspective. |
Typical pattern: build TextAtlas and call ensureText / ensureShaped on a loading thread, publish a new ResourceSet to the render thread, upload into PreparedResources, build DrawList records or a PreparedScene, then draw. The draw call does not allocate, upload, discover resources, or invalidate caches.
For CPU-backend speed, hand the renderer a snail.ThreadPool. The pool allocates once at init (a []std.Thread slice); dispatch and the draw path itself are heap-free.
var pool: snail.ThreadPool = undefined;
try pool.init(allocator, .{}); // defaults to ncpu - 1 worker threads
defer pool.deinit();
var cpu = snail.CpuRenderer.init(pixels.ptr, w, h, stride);
cpu.setThreadPool(&pool);
// draws now fan out across scanline tilessnail is used in development but is not yet stable. The Zig API is settling and follows the explicit-resource model described above. Known gaps:
- Built-in OpenType shaping covers GSUB type 4 (ligatures) and GPOS type 2 (pair positioning) only; complex scripts (Arabic, Devanagari, Thai, etc.) require building with
-Dharfbuzz=true. - TrueType outlines only — no CFF/CFF2.
- No variable fonts.
- The C API exposes backend constructors for CPU (
snail_cpu.h), OpenGL (snail_gl.h), and Vulkan (snail_vulkan.h) plus the unifiedSnailRendererblocking upload/draw path. Zig-side scheduled upload (planResourceUpload/beginResourceUpload/pending.publish) is not yet exposed to C callers.
zig build bench
zig build bench -Dvulkan=false # skip Vulkan rowsLast run: 2026-05-14, zig build bench, ReleaseFast benchmark build. Lower
times are better. These numbers are one local machine/run, not a portability
guarantee.
NotoSans-Regular, 20 prep runs, 1000 text iterations, 1000 draw-record iterations.
The vector workload contains filled and stroked rounded rectangles, ellipses, and custom cubic/quadratic paths. Vulkan rows are emitted unless the build is configured with -Dvulkan=false.
| Component | Detected |
|---|---|
| CPU | AMD Ryzen 9 5950X 16-Core Processor |
| OpenGL renderer | NVIDIA GeForce RTX 3090/PCIe/SSE2 |
| OpenGL version | 4.4.0 NVIDIA 595.71.05 |
| Vulkan device | NVIDIA GeForce RTX 3090 |
| Workload | Snail | FreeType | FreeType / Snail |
|---|---|---|---|
| Font load | 1.88 us | 8.60 us | 4.58x |
| Glyph prep, ASCII | 446.39 us | 988.29 us | 2.21x |
| Glyph prep, 7 sizes | 446.39 us | 6866.71 us | 15.38x |
| PathPicture freeze, 25 shapes | 138.07 us | n/a | n/a |
| Resource | Bytes | KiB |
|---|---|---|
| Snail text curve/band textures | 98304 | 96.0 |
| Snail vector curve/band textures | 54352 | 53.1 |
| FreeType bitmaps, one size | 65001 | 63.5 |
| FreeType bitmaps, seven sizes | 538020 | 525.4 |
| Workload | Snail TextBlob | FreeType layout | FreeType / Snail |
|---|---|---|---|
| Short string | 1.52 us | 76.86 us | 50.54x |
| Sentence | 5.19 us | 380.75 us | 73.40x |
| Paragraph | 17.75 us | 1375.86 us | 77.52x |
| Paragraph x 7 sizes | 124.67 us | 10091.24 us | 80.94x |
| Scene | Commands | Words | Segments | PreparedScene.initOwned |
|---|---|---|---|---|
| Text | 4 | 4048 | 4 | 7.54 us |
| Rich text | 1 | 1136 | 1 | 2.04 us |
| Vector paths | 1 | 400 | 1 | 0.21 us |
| Mixed text + vector | 5 | 4448 | 5 | 7.84 us |
| Multi-script text | 4 | 1488 | 4 | 2.81 us |
Target: 640x360. CPU uses 20 measured frames; GPU backends use 500 measured frames.
| Backend | Scene | Frames | Commands | Words | Segments | Draw prepared scene |
|---|---|---|---|---|---|---|
| CPU | Text | 20 | 4 | 4048 | 4 | 7487.94 us |
| CPU | Rich text | 20 | 1 | 1136 | 1 | 3944.90 us |
| CPU | Vector paths | 20 | 1 | 400 | 1 | 15656.01 us |
| CPU | Mixed text + vector | 20 | 5 | 4448 | 5 | 23162.31 us |
| CPU | Multi-script text | 20 | 4 | 1488 | 4 | 4525.96 us |
| CPU (threaded) | Text | 20 | 4 | 4048 | 4 | 3241.19 us |
| CPU (threaded) | Rich text | 20 | 1 | 1136 | 1 | 2003.87 us |
| CPU (threaded) | Vector paths | 20 | 1 | 400 | 1 | 3135.56 us |
| CPU (threaded) | Mixed text + vector | 20 | 5 | 4448 | 5 | 5169.83 us |
| CPU (threaded) | Multi-script text | 20 | 4 | 1488 | 4 | 1991.90 us |
| GL 4.4 (persistent mapped) | Text | 500 | 4 | 4048 | 4 | 313.72 us |
| GL 4.4 (persistent mapped) | Rich text | 500 | 1 | 1136 | 1 | 261.37 us |
| GL 4.4 (persistent mapped) | Vector paths | 500 | 1 | 400 | 1 | 79.21 us |
| GL 4.4 (persistent mapped) | Mixed text + vector | 500 | 5 | 4448 | 5 | 366.23 us |
| GL 4.4 (persistent mapped) | Multi-script text | 500 | 4 | 1488 | 4 | 283.30 us |
| Vulkan | Text | 500 | 4 | 4048 | 4 | 79.93 us |
| Vulkan | Rich text | 500 | 1 | 1136 | 1 | 86.06 us |
| Vulkan | Vector paths | 500 | 1 | 400 | 1 | 80.35 us |
| Vulkan | Mixed text + vector | 500 | 5 | 4448 | 5 | 116.68 us |
| Vulkan | Multi-script text | 500 | 4 | 1488 | 4 | 74.02 us |
Per-AA timings for the text and multi-script scenes. AA controls the fragment-shader path (grayscale vs LCD subpixel).
| Backend | Scene | AA | Words | Segments | PreparedScene | Draw |
|---|---|---|---|---|---|---|
| CPU | Text | grayscale | 4048 | 4 | 7.82 us | 1491.94 us |
| CPU | Text | subpixel rgb | 4048 | 4 | 7.52 us | 7513.28 us |
| CPU | Rich text | grayscale | 1136 | 1 | 2.01 us | 1298.80 us |
| CPU | Rich text | subpixel rgb | 1136 | 1 | 1.98 us | 3927.44 us |
| CPU | Multi-script text | grayscale | 1488 | 4 | 2.76 us | 933.67 us |
| CPU | Multi-script text | subpixel rgb | 1488 | 4 | 2.83 us | 4510.95 us |
| GL 4.4 (persistent mapped) | Text | grayscale | 4048 | 4 | 7.57 us | 90.15 us |
| GL 4.4 (persistent mapped) | Text | subpixel rgb | 4048 | 4 | 7.51 us | 306.24 us |
| GL 4.4 (persistent mapped) | Rich text | grayscale | 1136 | 1 | 1.96 us | 114.61 us |
| GL 4.4 (persistent mapped) | Rich text | subpixel rgb | 1136 | 1 | 1.97 us | 251.82 us |
| GL 4.4 (persistent mapped) | Multi-script text | grayscale | 1488 | 4 | 2.72 us | 91.19 us |
| GL 4.4 (persistent mapped) | Multi-script text | subpixel rgb | 1488 | 4 | 2.75 us | 311.02 us |
| Vulkan | Text | grayscale | 4048 | 4 | 7.51 us | 27.81 us |
| Vulkan | Text | subpixel rgb | 4048 | 4 | 7.50 us | 87.86 us |
| Vulkan | Rich text | grayscale | 1136 | 1 | 1.93 us | 30.71 us |
| Vulkan | Rich text | subpixel rgb | 1136 | 1 | 1.94 us | 72.84 us |
| Vulkan | Multi-script text | grayscale | 1488 | 4 | 2.74 us | 27.80 us |
| Vulkan | Multi-script text | subpixel rgb | 1488 | 4 | 2.69 us | 74.01 us |
src/
snail/
root.zig public API facade and domain-module exports
core.zig shared public model and implementation glue
text.zig text-domain public aliases
paint.zig paint, gradient, image-paint public types
fonts.zig TextAtlas internals: multi-font manager with immutable snapshot atlas
c_api.zig C ABI over the explicit resource model
glyph_emit.zig glyph -> vertex dispatch (plain, COLR, painted, multi-layer)
paint_records.zig shared paint-record encoding for text and vector draws
resource_key.zig stable resource-key helpers
font/ TrueType/OpenType/HarfBuzz text primitives
math/ Bezier, vector, matrix, and root-solving primitives
renderer/
gl.zig OpenGL renderer and prepared resource state
vulkan.zig Vulkan renderer and prepared resource state (optional)
cpu.zig software rasterizer (same atlas data, no GPU)
gl_bindings.zig OpenGL C imports
gl_backend.zig GL version detection and backend selection
shaders.zig GLSL 330 vertex + fragment shaders (GL backend)
vulkan_shaders.zig SPIR-V bytecode loader (Vulkan backend)
curve_texture.zig RGBA16F curve control point packing
band_texture.zig RG16UI spatial band subdivision
vertex.zig glyph quad vertex generation
upload_common.zig shared texture upload logic
subpixel_order.zig RGB/BGR/VRGB/VBGR enum
subpixel_policy.zig subpixel rendering policy logic
glsl/ shared GLSL bodies for GL and Vulkan backends
vulkan_glsl/ Vulkan shader wrappers (compiled to SPIR-V at build time)
demo/
main.zig interactive renderer demo
game.zig game-style OpenGL demo entry point
screenshot.zig headless screenshot demo
bench.zig benchmark tool
backend_compare.zig CPU/GL/Vulkan pixel comparison tool
platform/ demo-only Wayland/EGL/Vulkan/offscreen support
gl.zig Wayland + EGL platform for the GL demo
vulkan.zig Wayland + Vulkan swapchain/offscreen setup
cpu.zig Wayland shared-memory platform for the CPU demo
wayland.zig Wayland window + input handling
egl.zig shared EGL setup
offscreen_gl.zig headless EGL context
subpixel.zig display subpixel-layout detection
presentation.zig demo presentation metadata
profile/
timer.zig comptime-gated CPU timers
include/
snail.h shared C API
snail_cpu.h CPU backend C constructor
snail_gl.h OpenGL backend C constructor
snail_vulkan.h Vulkan backend C constructor and context type
MIT
