renderer: glyph protocol

This commit is contained in:
Mitchell Hashimoto
2026-06-05 20:00:05 -07:00
parent 502078a276
commit 4c34ccf523
3 changed files with 400 additions and 0 deletions

View File

@@ -17,6 +17,7 @@ const noMinContrast = cellpkg.noMinContrast;
const constraintWidth = cellpkg.constraintWidth;
const isCovering = cellpkg.isCovering;
const rowNeverExtendBg = @import("row.zig").neverExtendBg;
const glyph_protocol = @import("glyph_protocol.zig");
const Overlay = @import("Overlay.zig");
const imagepkg = @import("image.zig");
const ImageState = imagepkg.State;
@@ -173,6 +174,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
font_shaper: font.Shaper,
font_shaper_cache: font.ShaperCache,
/// Renderer-local snapshot of per-session Glyph Protocol entries.
glyph_protocol: glyph_protocol.State = .empty,
/// The images that we may render.
images: ImageState = .empty,
@@ -815,6 +819,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.font_shaper.deinit();
self.font_shaper_cache.deinit(self.alloc);
self.glyph_protocol.deinit(self.alloc);
self.config.deinit();
@@ -1074,6 +1079,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Update our grid
self.font_grid = grid;
// When our font grid changes, our cell metrics can change,
// our atlas is gone, etc. so invalid all prior glyph
// protocol rasterizations.
self.glyph_protocol.invalidateRasterized();
// Update all our textures so that they sync on the next frame.
// We can modify this without a lock because the GPU does not
// touch this data.
@@ -1199,6 +1209,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
state.terminal.scrollViewport(.bottom);
}
// Sync our Glyph Protocol state before updating the render
// state because RenderState.update consumes terminal dirty
// flags, including glyph_glossary.
if (state.terminal.flags.dirty.glyph_glossary) {
self.glyph_protocol.syncFromGlossary(
self.alloc,
&state.terminal.glyph_glossary,
) catch |err| {
// Don't hard fail the frame update, just degrade
// to showing some invalid glyphs.
log.warn("error syncing glyph protocol state err={}", .{err});
};
}
// Update our terminal state
try self.terminal_state.update(self.alloc, state.terminal);
@@ -3173,6 +3197,59 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
const cell = cell_raws[x];
const cp = cell.codepoint();
// If we have a glyph via the glyph protocol, render that.
if (self.glyph_protocol.renderGlyph(
self.alloc,
self.font_grid,
self.grid_metrics,
cp,
) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.RasterizationFailed => glyph: {
// If rasterization fails for any reason, just render the
// replacement char to show some invalid character is
// here.
log.warn("error rasterizing glyph protocol glyph, rendering replacement character cp={X}", .{cp});
const replacement = try self.font_grid.renderCodepoint(
self.alloc,
0xFFFD,
.regular,
.text,
.{
.grid_metrics = self.grid_metrics,
.cell_width = cell.gridWidth(),
},
);
break :glyph if (replacement) |render|
render.glyph
else {
// Super weird, we should always have something for
// the replacement char. Log it and just render
// nothing I guess.
log.warn("replacement character couldn't render", .{});
return;
};
},
}) |glyph| {
// If the glyph is 0 width or height, it will be invisible
// when drawn, so don't bother adding it to the buffer.
if (glyph.width == 0 or glyph.height == 0) return;
try self.cells.add(self.alloc, .text, .{
// For now, we only support grayscale glyphs.
.atlas = .grayscale,
.grid_pos = .{ @intCast(x), @intCast(y) },
.color = .{ color.r, color.g, color.b, alpha },
.glyph_pos = .{ glyph.atlas_x, glyph.atlas_y },
.glyph_size = .{ glyph.width, glyph.height },
.bearings = .{
@intCast(glyph.offset_x),
@intCast(glyph.offset_y),
},
});
return;
}
// Render
const render = try self.font_grid.renderGlyph(
self.alloc,

View File

@@ -0,0 +1,318 @@
const std = @import("std");
const testing = std.testing;
const Allocator = std.mem.Allocator;
const font = @import("../font/main.zig");
const glyf_rasterize = font.glyf_rasterize;
const Glossary = @import("../terminal/main.zig").apc.glyph.Glossary;
const Glyf = @import("../font/opentype/glyf.zig").Glyf;
/// Map of registered codepoints in renderer-local state.
pub const CodepointMap = std.AutoArrayHashMapUnmanaged(u21, Codepoint);
/// Renderer-local Glyph Protocol state for one terminal session.
///
/// The terminal owns the authoritative `Glossary`, but the renderer cannot
/// keep pointers into it because rendering happens outside the terminal mutex
/// and rasterized atlas metadata has renderer-specific lifetime.
///
/// This state is a snapshot of the terminal glossary plus lazy rasterization
/// cache for glyphs that have actually appeared on screen.
pub const State = struct {
/// Registered codepoints keyed by Unicode scalar value.
codepoints: CodepointMap = .empty,
/// Empty initial state with no registrations and no allocated storage.
pub const empty: State = .{ .codepoints = .empty };
/// Release all cloned glossary entries and map storage owned by this state.
pub fn deinit(self: *State, alloc: Allocator) void {
for (self.codepoints.values()) |*entry| entry.deinit(alloc);
self.codepoints.deinit(alloc);
self.* = undefined;
}
/// Synchronize the renderer-local state from the terminal glossary.
///
/// This does a full replacement rather than attempting to diff because the
/// glossary is spec-limited to 1024 entries and full replacement naturally
/// invalidates rasterized glyphs for clear, overwrite, and FIFO eviction.
/// If cloning fails, the previous renderer state is left intact.
pub fn syncFromGlossary(
self: *State,
alloc: Allocator,
glossary: *const Glossary,
) Allocator.Error!void {
// Build a complete replacement first. If any clone/allocation fails,
// errdefer releases the partial map and `self` continues to point at
// the previous successfully-synced snapshot.
var new_codepoints: CodepointMap = .empty;
errdefer deinitMap(&new_codepoints, alloc);
try new_codepoints.ensureTotalCapacity(alloc, glossary.entries.count());
for (glossary.entries.keys(), glossary.entries.values()) |cp, *entry| {
new_codepoints.putAssumeCapacityNoClobber(cp, .{
.entry = try entry.clone(alloc),
});
}
// Only after the new snapshot is complete do we release the old one.
// This makes sync failure non-destructive, which lets the renderer
// fall back to the last good snapshot under memory pressure.
deinitMap(&self.codepoints, alloc);
self.codepoints = new_codepoints;
}
/// Invalidate rasterized atlas metadata while preserving registration
/// entries so they can be re-rasterized for a new font grid or cell size.
///
/// Call this when the terminal grid metrics change in any way.
pub fn invalidateRasterized(self: *State) void {
for (self.codepoints.values()) |*entry| entry.rasterized = null;
}
/// Errors that callers of `renderGlyph` can meaningfully react to.
pub const RenderError = Allocator.Error || error{
/// The decoded glyph could not be rasterized by the renderer.
RasterizationFailed,
};
/// Render a registered codepoint into the shared grayscale atlas, lazily.
///
/// The returned glyph is atlas metadata only. The underlying registration
/// entry is kept so font-grid changes can invalidate and rasterize again.
pub fn renderGlyph(
self: *State,
alloc: Allocator,
grid: *font.SharedGrid,
grid_metrics: font.Metrics,
cp: u21,
) RenderError!?font.Glyph {
const codepoint = self.codepoints.getPtr(cp) orelse return null;
// Fast path: the glyph was already rasterized into the current font
// atlas. `font.Glyph` is just atlas metadata, so returning it by value
// is cheap and avoids touching the cloned outline again.
if (codepoint.rasterized) |glyph| return glyph;
const entry = &codepoint.entry;
// Glyph Protocol `width` is the requested cell span. Use it for both
// the bitmap width and constraint span so sizing/alignment/padding are
// applied over the same 1- or 2-cell render span described by the spec.
const width_cells: u2 = switch (entry.width) {
.narrow => 1,
.wide => 2,
};
// Decode-time validation already guaranteed this is a supported glyf
// outline. Rasterization happens lazily because applications may
// register many glyphs that never become visible.
var bitmap = switch (entry.glyph) {
.glyf => |outline| glyf_rasterize.rasterize(
alloc,
outline,
entry.design,
.{
.grid_metrics = grid_metrics,
.cell_width = width_cells,
.constraint = entry.constraint,
.constraint_width = width_cells,
},
) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.NoCurrentPoint,
error.InvalidMatrix,
error.PathNotClosed,
error.InvalidHeight,
error.InvalidWidth,
error.InvalidState,
=> return error.RasterizationFailed,
},
};
defer bitmap.deinit(alloc);
// Cache the empty result too. This prevents repeated rasterization for
// valid but visually-empty outlines while keeping downstream render code
// on the normal zero-sized-glyph skip path.
if (bitmap.width == 0 or bitmap.height == 0) {
codepoint.rasterized = .{
.width = 0,
.height = 0,
.offset_x = 0,
.offset_y = 0,
.atlas_x = 0,
.atlas_y = 0,
};
return codepoint.rasterized.?;
}
// Atlas allocation and writes must hold the shared grid lock. The more
// expensive vector rasterization above intentionally happens outside the
// lock so other surfaces sharing this grid are blocked for less time.
grid.lock.lock();
defer grid.lock.unlock();
// Reuse the normal font grayscale atlas. If it fills up, grow it and
// retry just like SharedGrid.renderGlyph does for regular font glyphs.
const region = region: while (true) {
break :region grid.atlas_grayscale.reserve(
alloc,
bitmap.width,
bitmap.height,
) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.AtlasFull => {
try grid.atlas_grayscale.grow(alloc, grid.atlas_grayscale.size * 2);
continue;
},
};
};
grid.atlas_grayscale.set(region, bitmap.data);
// A Glyph Protocol glyf bitmap is rasterized as a full render-span
// bitmap. A top bearing equal to bitmap height places the top of the
// quad at the top of the cell in the existing text shader convention.
codepoint.rasterized = .{
.width = bitmap.width,
.height = bitmap.height,
.offset_x = 0,
.offset_y = @intCast(bitmap.height),
.atlas_x = region.x,
.atlas_y = region.y,
};
return codepoint.rasterized.?;
}
/// Release a codepoint map and all cloned entries in it.
///
/// This helper resets the map to `.empty` so it can be safely reused after
/// both successful replacement and error-path cleanup.
fn deinitMap(map: *CodepointMap, alloc: Allocator) void {
for (map.values()) |*entry| entry.deinit(alloc);
map.deinit(alloc);
map.* = .empty;
}
};
/// One registered codepoint in the renderer snapshot.
pub const Codepoint = struct {
/// Cloned terminal glossary entry. This is kept even after rasterization so
/// font grid changes can discard atlas metadata and rasterize again.
entry: Glossary.Entry,
/// Cached atlas metadata for the current font grid, if visible before.
rasterized: ?font.Glyph = null,
/// Release the cloned glossary entry owned by this codepoint.
pub fn deinit(self: *Codepoint, alloc: Allocator) void {
self.entry.deinit(alloc);
self.* = undefined;
}
};
fn testEntry(alloc: Allocator, x_offset: i32) !Glossary.Entry {
const contours = try alloc.dupe(u16, &.{2});
errdefer alloc.free(contours);
const points = try alloc.dupe(Glyf.Outline.Point, &.{
.{ .x = x_offset, .y = 0, .on_curve = true },
.{ .x = x_offset + 500, .y = 0, .on_curve = true },
.{ .x = x_offset, .y = 500, .on_curve = true },
});
errdefer alloc.free(points);
return .{
.glyph = .{ .glyf = .{
.contours = contours,
.points = points,
} },
.design = .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
},
.width = .narrow,
.constraint = .none,
};
}
test "State syncFromGlossary clones terminal entries" {
const alloc = testing.allocator;
var glossary: Glossary = .empty;
defer glossary.deinit(alloc);
try glossary.register(alloc, 0xE000, try testEntry(alloc, 0));
var state: State = .empty;
defer state.deinit(alloc);
try state.syncFromGlossary(alloc, &glossary);
try testing.expect(state.codepoints.contains(0xE000));
try testing.expect(!state.codepoints.contains(0xE001));
const src = glossary.entries.getPtr(0xE000).?;
const dst = state.codepoints.getPtr(0xE000).?;
try testing.expect(src.glyph.glyf.points.ptr != dst.entry.glyph.glyf.points.ptr);
try testing.expectEqual(src.glyph.glyf.points[0], dst.entry.glyph.glyf.points[0]);
}
test "State syncFromGlossary replaces removed and changed entries" {
const alloc = testing.allocator;
var glossary: Glossary = .empty;
defer glossary.deinit(alloc);
try glossary.register(alloc, 0xE000, try testEntry(alloc, 0));
var state: State = .empty;
defer state.deinit(alloc);
try state.syncFromGlossary(alloc, &glossary);
try glossary.register(alloc, 0xE001, try testEntry(alloc, 10));
try glossary.delete(alloc, 0xE000);
try state.syncFromGlossary(alloc, &glossary);
try testing.expect(!state.codepoints.contains(0xE000));
try testing.expect(state.codepoints.contains(0xE001));
try testing.expectEqual(@as(i32, 10), state.codepoints.getPtr(0xE001).?.entry.glyph.glyf.points[0].x);
}
test "State invalidateRasterized clears cached glyph metadata" {
const alloc = testing.allocator;
var glossary: Glossary = .empty;
defer glossary.deinit(alloc);
try glossary.register(alloc, 0xE000, try testEntry(alloc, 0));
var state: State = .empty;
defer state.deinit(alloc);
try state.syncFromGlossary(alloc, &glossary);
const codepoint = state.codepoints.getPtr(0xE000).?;
codepoint.rasterized = .{
.width = 1,
.height = 1,
.offset_x = 0,
.offset_y = 1,
.atlas_x = 2,
.atlas_y = 3,
};
state.invalidateRasterized();
try testing.expect(codepoint.rasterized == null);
}
test "State renderGlyph returns null for unregistered codepoint" {
var state: State = .empty;
const grid: *font.SharedGrid = undefined;
const glyph = try state.renderGlyph(
testing.allocator,
grid,
undefined,
0xE000,
);
try testing.expect(glyph == null);
}

View File

@@ -3208,6 +3208,11 @@ pub fn fullReset(self: *Terminal) void {
// Always mark dirty so we redraw everything
self.flags.dirty.clear = true;
// The clear dirty bit is enough to force a full render-state rebuild, but
// renderers keep their own cloned Glyph Protocol state. Set the glossary
// dirty bit too so they sync and drop any registrations cleared above.
self.flags.dirty.glyph_glossary = true;
}
/// Returns true if the point is dirty, used for testing.