heavy-slug is a Zig 0.16 library for analytic, resolution-independent GPU
text rendering. It shapes Unicode with HarfBuzz, captures native font outlines
with FreeType and HarfBuzz draw callbacks, encodes compact cubic coverage blobs,
and renders text with mesh and fragment shaders on Vulkan 1.4 and Metal 4.
It follows the central idea of
Slug: keep glyph coverage analytic
until the fragment shader instead of baking text into a raster atlas. The public
boundary is deliberately small: applications own windows, devices, queues,
swapchains or layers, command buffers, frame completion, and presentation;
heavy-slug owns shaping, glyph encoding, cache state, and backend draw data.
UTF-8 text -> HarfBuzz shaping -> cubic coverage blob
-> CPU h-band meshlets -> analytic GPU coverage
Enable the module you need, then let the host application keep ownership of the graphics objects and call the backend renderer from its frame loop.
| Module | Enable With | Purpose |
|---|---|---|
heavy_slug |
default | Backend-neutral value types and core renderer options. |
heavy_slug_vulkan |
-Dvulkan=true or Vulkan demo builds |
Vulkan 1.4 backend. |
heavy_slug_metal |
-Dmetal=true or Metal demo builds |
Metal 4 backend. |
Primary heavy_slug exports are FontSource, FontOptions, FontHandle,
TextRun, ScreenTextRun, Color, Transform, View, PrecisionPolicy,
PrecisionSelection, FillRule, RendererOptions, DrawTextResult,
SubmitResult, FrameDiagnostics, FrameWarning, FrameToken, and
ShaderStats. Backend
modules expose the application-facing flow through Context, Renderer,
Frame, Target, RendererOptions, FontHandle, DrawTextResult,
SubmitResult, FrameToken, Stats, and
shader_stats_enabled. Vulkan also exposes requirement helpers such as
required_api_version and required_device_extensions, plus
Renderer.setFixedRenderState(target) which the host calls once per
command buffer recording before the first Frame.submit(). Metal exposes
Host, the borrowed native object bundle used to create a backend context.
Typical frame shape:
const heavy_slug = @import("heavy_slug");
const font = try renderer.loadFont(.{ .path = "assets/NotoSansJP-Regular.otf" }, .{
.size_px = 32,
});
const view = heavy_slug.View.identity(width_px, height_px);
var frame = try renderer.beginFrame(view);
_ = try frame.drawText(.{
.font = font,
.text = "Heavy Slug",
.transform = heavy_slug.Transform.translation(80, 140),
.color = heavy_slug.Color.white,
});
_ = try frame.drawScreenText(.{
.font = font,
.text = "FPS 60.0 Zoom 1.00x",
.screen_from_text = heavy_slug.Transform.translation(24, height_px - 28),
.color = heavy_slug.Color.white,
});
const submit = try frame.submit(target);
const token = switch (submit) {
.submitted_text => |token| token,
.submitted_clear_only => |token| token,
.empty_noop => |empty| empty.previous_token,
};Color is linear, premultiplied-alpha RGBA. Use Color.straight(r, g, b, a)
for conventional non-premultiplied input (the constructor multiplies the
RGB channels by a for you) and Color.premultiplied(r, g, b, a) only when
the caller already owns premultiplied data. The Vulkan and Metal backends
blend with src = ONE, dst = ONE_MINUS_SRC_ALPHA, so straight RGBA passed
through the wrong constructor would render as a too-bright haloed glyph
against any non-opaque background.
Useful commands:
| Goal | Command |
|---|---|
| Build the core library | zig build |
| Run core and build-tool tests | zig build test |
| Test Vulkan or Metal | zig build test -Dvulkan=true / zig build test -Dmetal=true |
| Compile shaders | zig build spirv / zig build msl |
| Run a demo | zig build run -Ddemo=true -Ddemo-backend=vulkan or -Ddemo-backend=metal |
-Ddemo-backend=auto chooses Vulkan on Windows/Linux and Metal on macOS.
-Dshader-stats=true adds GPU-side counters for shader diagnostics.
Application
owns window, graphics device, queues, swapchain/layer, frame completion
|
v
Backend: heavy_slug_vulkan or heavy_slug_metal
owns GPU buffers, shader state, draw recording/submission glue
|
v
Core: heavy_slug
owns fonts, shaping, outline normalization, blob encoding, cache metadata
|
v
Slang mesh and fragment shaders
clip meshlets and integrate analytic coverage
The hot path uses one byte-addressed glyph blob pool plus compact per-frame glyph and meshlet streams. Cached glyphs are keyed by font, glyph id, precision tier, and variation key; cache entries retire only after the frame token that may reference them has completed.
heavy-slug is useful when text is transformed, zoomed, panned, or reused
without wanting a new raster atlas for every scale. A glyph is cached as a
small analytic coverage program: fixed-point cubic Bezier spans plus a
conservative horizontal-band candidate index. The mesh shader bounds where work
can happen; the fragment shader computes the curve coverage for the current
pixel.
Cubic Bernstein blobs
All outline segments are raised to cubic Bernstein spans:
The CPU splits cubics at roots of
Fill direction comes from the exact Green-form area integral, not a
control-polygon approximation. For one cubic span, with
Coverage integral
For a local pixel cell
Fubini turns coverage area into horizontal slice length, and Green turns each oriented boundary contribution into a line integral. After clipping a y-monotone span to the pixel y-range, the shader accumulates:
Spans wholly left of the cell contribute 0; spans wholly right contribute
Stable quintic evaluation
For a partial span,
so the integrand is a degree-five Bernstein polynomial:
The sum for
Precision and acceleration
The public PrecisionPolicy chooses even fixed-point tiers from a screen-space
error budget. If
It requires that value to stay below target_error_px, rejects non-finite or
ill-conditioned transforms, and encodes glyph bounds with outward rounding.
Precision selection returns structured supported/unsupported data so frame
diagnostics can report the largest observed screen-space scale and required
fraction bits without turning a single degraded glyph into a whole-frame error.
The h-band table is a conservative CSR index from y bands to curve ids:
Fragments merge a bounded number of adjacent band lists and deduplicate curve ids before integration; if the window is invalid or too wide, the shader falls back to a full curve scan.
CPU meshlet planning emits pixel-center support domains, not just glyph bbox
strips: curve bounds are inflated by the local half-pixel footprint on all
sides, and y slices scan neighboring h-bands through that influence domain.
Fixed-point geometry enters f32 only through explicit relative exact checks
within the 2^24 integer radius of the current chart anchor; the shader does
not clamp Bezier control points to make them representable.
Core builds need Zig 0.16.0. FreeType 2.14.3 and HarfBuzz 14.2.0 are
pinned source dependencies; Zig compiles and links them statically, so normal
core builds do not require a separate C/C++ compiler or system FreeType/HarfBuzz
packages.
Backend and demo commands add native toolchains:
| Path | Additional Requirements |
|---|---|
| Shaders | slangc with Slang 2026, SPIR-V 1.6, and metallib_4_0 support. |
| Vulkan | Vulkan loader, Vulkan 1.4 driver, push descriptors, dynamic rendering, VK_EXT_mesh_shader, and VK_EXT_shader_object. |
| Metal | macOS 26.0+, Swift 6.3, Apple SDK with Metal 4, and Metal/QuartzCore/Foundation/AppKit/SwiftUI. |
| Wayland demo | wayland-scanner, wayland-client, xkbcommon, and pinned Wayland protocol XML dependencies. |
Core-only zig build and zig build test do not require slangc, Vulkan,
Metal, Wayland, Cocoa, or a window toolkit.
The demos are native host examples, not a portability layer. Vulkan uses Win32
or Wayland; Metal uses SwiftUI/AppKit with a CAMetalLayer. All demos use B
to switch between explicit light and dark appearance. The shared scene uses
NotoSansJP-Regular.otf for a dense vertically grouped multilingual and symbol
sample corpus, plus a screen-space metrics overlay for FPS, relative view zoom,
native display scale, and renderer warnings. Vulkan demo window titles are
validated UTF-8; Win32 presents them as UTF-16 native titles, and Wayland
publishes the exact title through xdg-shell.
Vulkan hosts report completed GPU work with Renderer.markFrameComplete(token).
The Metal backend tracks completion through bridge-managed frame slots and waits
for submitted work during teardown. Frame.submit() returns an explicit
SubmitResult: Vulkan reports empty text batches as .empty_noop because the
host still owns swapchain presentation, while Metal submits .submitted_clear_only
for empty text batches so the drawable is cleared and presented.
Renderer.stats() exposes CPU/backend counters for shaping, cache, uploads,
retirements, pool state, submitted glyphs, and meshlets; Frame.diagnostics()
exposes frame-local run/glyph reject counters and stable FrameWarning values
for application UI. The demo snapshots those warnings before drawing its
screen-space overlay, then displays every public warning vertically with
Frame.drawScreenText(). -Dshader-stats=true adds GPU counters for meshlet
culling, h-band candidate use, full-scan fallback, bbox rejection, and curve
integration.
Run the narrowest verification that covers the change, then broaden when touching public APIs, shader ABI, backend resource binding, or demo platforms.
| Path | Purpose |
|---|---|
src/core/ |
Font, outline, blob, cache, and renderer-core internals. |
src/gpu/ |
Backend-neutral resource model, mesh budgets, and shader stats. |
src/backends/ |
Vulkan and Metal backend modules. |
shaders/ |
Slang core modules, backend shims, and shader entries. |
demo/ |
Native Win32, Wayland, and SwiftUI/AppKit demo hosts. |
build/, tools/ |
Zig build helpers and Slang reflection layout generation. |
Use zig fmt build.zig build/ demo/ src/ tools/ for Zig formatting. CI is
verification-only: formatting and script/YAML checks, core tests on
Ubuntu/macOS/Windows, shader compilation, Vulkan backend/demo build tests,
Swift format lint, and Metal backend/demo build tests on macOS.
heavy-slug builds on Slug's practical GPU-side analytic glyph coverage and
recasts it around cubic blobs, h-band meshlets, and modern Vulkan/Metal APIs.
MIT. See LICENSE.