mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-15 16:26:08 +03:00
Merge pull request #1725 from mitchellh/shaper-cache
Cache font shaping results
This commit is contained in:
@ -14,6 +14,7 @@ pub const Glyph = @import("Glyph.zig");
|
||||
pub const Metrics = face.Metrics;
|
||||
pub const shape = @import("shape.zig");
|
||||
pub const Shaper = shape.Shaper;
|
||||
pub const ShaperCache = shape.Cache;
|
||||
pub const SharedGrid = @import("SharedGrid.zig");
|
||||
pub const SharedGridSet = @import("SharedGridSet.zig");
|
||||
pub const sprite = @import("sprite.zig");
|
||||
|
@ -4,6 +4,7 @@ pub const harfbuzz = @import("shaper/harfbuzz.zig");
|
||||
pub const coretext = @import("shaper/coretext.zig");
|
||||
pub const web_canvas = @import("shaper/web_canvas.zig");
|
||||
pub usingnamespace @import("shaper/run.zig");
|
||||
pub const Cache = @import("shaper/Cache.zig");
|
||||
|
||||
/// Shaper implementation for our compile options.
|
||||
pub const Shaper = switch (options.backend) {
|
||||
@ -56,3 +57,8 @@ pub const Options = struct {
|
||||
/// support applying features globally.
|
||||
features: []const []const u8 = &.{},
|
||||
};
|
||||
|
||||
test {
|
||||
_ = Cache;
|
||||
_ = Shaper;
|
||||
}
|
||||
|
86
src/font/shaper/Cache.zig
Normal file
86
src/font/shaper/Cache.zig
Normal file
@ -0,0 +1,86 @@
|
||||
//! This structure caches the shaped cells for a given text run.
|
||||
//!
|
||||
//! At one point, shaping was the most expensive part of rendering text
|
||||
//! (accounting for 96% of frame time on my machine). To speed it up, this
|
||||
//! was introduced so that shaping results can be cached depending on the
|
||||
//! run.
|
||||
//!
|
||||
//! The cache key is the text run. The text run builds its own hash value
|
||||
//! based on the font, style, codepoint, etc. This just utilizes the hash that
|
||||
//! the text run provides.
|
||||
pub const Cache = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const font = @import("../main.zig");
|
||||
const lru = @import("../../lru.zig");
|
||||
|
||||
const log = std.log.scoped(.font_shaper_cache);
|
||||
|
||||
/// Our LRU is the run hash to the shaped cells.
|
||||
const LRU = lru.AutoHashMap(u64, []font.shape.Cell);
|
||||
|
||||
/// The cache of shaped cells.
|
||||
map: LRU,
|
||||
|
||||
pub fn init() Cache {
|
||||
// Note: this is very arbitrary. Increasing this number will increase
|
||||
// the cache hit rate, but also increase the memory usage. We should do
|
||||
// some more empirical testing to see what the best value is.
|
||||
const capacity = 1024;
|
||||
|
||||
return .{ .map = LRU.init(capacity) };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Cache, alloc: Allocator) void {
|
||||
var it = self.map.map.iterator();
|
||||
while (it.next()) |entry| alloc.free(entry.value_ptr.*.data.value);
|
||||
self.map.deinit(alloc);
|
||||
}
|
||||
|
||||
/// Get the shaped cells for the given text run or null if they are not
|
||||
/// in the cache.
|
||||
pub fn get(self: *const Cache, run: font.shape.TextRun) ?[]const font.shape.Cell {
|
||||
return self.map.get(run.hash);
|
||||
}
|
||||
|
||||
/// Insert the shaped cells for the given text run into the cache. The
|
||||
/// cells will be duplicated.
|
||||
pub fn put(
|
||||
self: *Cache,
|
||||
alloc: Allocator,
|
||||
run: font.shape.TextRun,
|
||||
cells: []const font.shape.Cell,
|
||||
) Allocator.Error!void {
|
||||
const copy = try alloc.dupe(font.shape.Cell, cells);
|
||||
const gop = try self.map.getOrPut(alloc, run.hash);
|
||||
if (gop.evicted) |evicted| {
|
||||
log.debug("evicted shaped cells for text run hash={}", .{run.hash});
|
||||
alloc.free(evicted.value);
|
||||
}
|
||||
gop.value_ptr.* = copy;
|
||||
}
|
||||
|
||||
pub fn count(self: *const Cache) usize {
|
||||
return self.map.map.count();
|
||||
}
|
||||
|
||||
test Cache {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var c = Cache.init();
|
||||
defer c.deinit(alloc);
|
||||
|
||||
var run: font.shape.TextRun = undefined;
|
||||
run.hash = 1;
|
||||
try testing.expect(c.get(run) == null);
|
||||
try c.put(alloc, run, &.{
|
||||
.{ .x = 0, .glyph_index = 0 },
|
||||
.{ .x = 1, .glyph_index = 1 },
|
||||
});
|
||||
|
||||
const actual = c.get(run).?;
|
||||
try testing.expect(actual.len == 2);
|
||||
}
|
@ -4,11 +4,21 @@ const Allocator = std.mem.Allocator;
|
||||
const font = @import("../main.zig");
|
||||
const shape = @import("../shape.zig");
|
||||
const terminal = @import("../../terminal/main.zig");
|
||||
const autoHash = std.hash.autoHash;
|
||||
const Hasher = std.hash.Wyhash;
|
||||
|
||||
/// A single text run. A text run is only valid for one Shaper instance and
|
||||
/// until the next run is created. A text run never goes across multiple
|
||||
/// rows in a terminal, so it is guaranteed to always be one line.
|
||||
pub const TextRun = struct {
|
||||
/// A unique hash for this run. This can be used to cache the shaping
|
||||
/// results. We don't provide a means to compare actual values if the
|
||||
/// hash is the same, so we should continue to improve this hash to
|
||||
/// lower the chance of hash collisions if they become a problem. If
|
||||
/// there are hash collisions, it would result in rendering issues but
|
||||
/// the core data would be correct.
|
||||
hash: u64,
|
||||
|
||||
/// The offset in the row where this run started
|
||||
offset: u16,
|
||||
|
||||
@ -54,6 +64,9 @@ pub const RunIterator = struct {
|
||||
// Allow the hook to prepare
|
||||
try self.hooks.prepare();
|
||||
|
||||
// Initialize our hash for this run.
|
||||
var hasher = Hasher.init(0);
|
||||
|
||||
// Let's get our style that we'll expect for the run.
|
||||
const style = self.row.style(&cells[self.i]);
|
||||
|
||||
@ -211,12 +224,13 @@ pub const RunIterator = struct {
|
||||
// If we're a fallback character, add that and continue; we
|
||||
// don't want to add the entire grapheme.
|
||||
if (font_info.fallback) |cp| {
|
||||
try self.hooks.addCodepoint(cp, @intCast(cluster));
|
||||
try self.addCodepoint(&hasher, cp, @intCast(cluster));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add all the codepoints for our grapheme
|
||||
try self.hooks.addCodepoint(
|
||||
try self.addCodepoint(
|
||||
&hasher,
|
||||
if (cell.codepoint() == 0) ' ' else cell.codepoint(),
|
||||
@intCast(cluster),
|
||||
);
|
||||
@ -225,7 +239,7 @@ pub const RunIterator = struct {
|
||||
for (cps) |cp| {
|
||||
// Do not send presentation modifiers
|
||||
if (cp == 0xFE0E or cp == 0xFE0F) continue;
|
||||
try self.hooks.addCodepoint(cp, @intCast(cluster));
|
||||
try self.addCodepoint(&hasher, cp, @intCast(cluster));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -233,10 +247,17 @@ pub const RunIterator = struct {
|
||||
// Finalize our buffer
|
||||
try self.hooks.finalize();
|
||||
|
||||
// Add our length to the hash as an additional mechanism to avoid collisions
|
||||
autoHash(&hasher, j - self.i);
|
||||
|
||||
// Add our font index
|
||||
autoHash(&hasher, current_font);
|
||||
|
||||
// Move our cursor. Must defer since we use self.i below.
|
||||
defer self.i = j;
|
||||
|
||||
return TextRun{
|
||||
.hash = hasher.final(),
|
||||
.offset = @intCast(self.i),
|
||||
.cells = @intCast(j - self.i),
|
||||
.grid = self.grid,
|
||||
@ -244,6 +265,12 @@ pub const RunIterator = struct {
|
||||
};
|
||||
}
|
||||
|
||||
fn addCodepoint(self: *RunIterator, hasher: anytype, cp: u32, cluster: u32) !void {
|
||||
autoHash(hasher, cp);
|
||||
autoHash(hasher, cluster);
|
||||
try self.hooks.addCodepoint(cp, cluster);
|
||||
}
|
||||
|
||||
/// Find a font index that supports the grapheme for the given cell,
|
||||
/// or null if no such font exists.
|
||||
///
|
||||
|
@ -150,7 +150,7 @@ pub fn HashMap(
|
||||
}
|
||||
|
||||
/// Get a value for a key.
|
||||
pub fn get(self: *Self, key: K) ?V {
|
||||
pub fn get(self: *const Self, key: K) ?V {
|
||||
if (@sizeOf(Context) != 0) {
|
||||
@compileError("getContext must be used.");
|
||||
}
|
||||
@ -158,7 +158,7 @@ pub fn HashMap(
|
||||
}
|
||||
|
||||
/// See get
|
||||
pub fn getContext(self: *Self, key: K, ctx: Context) ?V {
|
||||
pub fn getContext(self: *const Self, key: K, ctx: Context) ?V {
|
||||
const node = self.map.getContext(key, ctx) orelse return null;
|
||||
return node.data.value;
|
||||
}
|
||||
|
@ -98,6 +98,7 @@ uniforms: mtl_shaders.Uniforms,
|
||||
/// The font structures.
|
||||
font_grid: *font.SharedGrid,
|
||||
font_shaper: font.Shaper,
|
||||
font_shaper_cache: font.ShaperCache,
|
||||
|
||||
/// The images that we may render.
|
||||
images: ImageMap = .{},
|
||||
@ -571,6 +572,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
|
||||
// Fonts
|
||||
.font_grid = options.font_grid,
|
||||
.font_shaper = font_shaper,
|
||||
.font_shaper_cache = font.ShaperCache.init(),
|
||||
|
||||
// Shaders
|
||||
.shaders = shaders,
|
||||
@ -588,6 +590,7 @@ pub fn deinit(self: *Metal) void {
|
||||
self.cells.deinit(self.alloc);
|
||||
|
||||
self.font_shaper.deinit();
|
||||
self.font_shaper_cache.deinit(self.alloc);
|
||||
|
||||
self.config.deinit();
|
||||
|
||||
@ -1666,6 +1669,12 @@ fn rebuildCells(
|
||||
cursor_style_: ?renderer.CursorStyle,
|
||||
color_palette: *const terminal.color.Palette,
|
||||
) !void {
|
||||
// const start = try std.time.Instant.now();
|
||||
// defer {
|
||||
// const end = std.time.Instant.now() catch unreachable;
|
||||
// std.log.warn("rebuildCells time={}us", .{end.since(start) / std.time.ns_per_us});
|
||||
// }
|
||||
|
||||
// Create an arena for all our temporary allocations while rebuilding
|
||||
var arena = ArenaAllocator.init(self.alloc);
|
||||
defer arena.deinit();
|
||||
@ -1728,7 +1737,24 @@ fn rebuildCells(
|
||||
if (shape_cursor) screen.cursor.x else null,
|
||||
);
|
||||
while (try iter.next(self.alloc)) |run| {
|
||||
for (try self.font_shaper.shape(run)) |shaper_cell| {
|
||||
// Try to read the cells from the shaping cache if we can.
|
||||
const shaper_cells = self.font_shaper_cache.get(run) orelse cache: {
|
||||
const cells = try self.font_shaper.shape(run);
|
||||
|
||||
// Try to cache them. If caching fails for any reason we continue
|
||||
// because it is just a performance optimization, not a correctness
|
||||
// issue.
|
||||
self.font_shaper_cache.put(self.alloc, run, cells) catch |err| {
|
||||
log.warn("error caching font shaping results err={}", .{err});
|
||||
};
|
||||
|
||||
// The cells we get from direct shaping are always owned by
|
||||
// the shaper and valid until the next shaping call so we can
|
||||
// just return them.
|
||||
break :cache cells;
|
||||
};
|
||||
|
||||
for (shaper_cells) |shaper_cell| {
|
||||
const coord: terminal.Coordinate = .{
|
||||
.x = shaper_cell.x,
|
||||
.y = y,
|
||||
@ -1829,6 +1855,11 @@ fn rebuildCells(
|
||||
x += if (cp.wide) 2 else 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Log some things
|
||||
// log.debug("rebuildCells complete cached_runs={}", .{
|
||||
// self.font_shaper_cache.count(),
|
||||
// });
|
||||
}
|
||||
|
||||
fn updateCell(
|
||||
|
@ -74,6 +74,7 @@ gl_state: ?GLState = null,
|
||||
/// The font structures.
|
||||
font_grid: *font.SharedGrid,
|
||||
font_shaper: font.Shaper,
|
||||
font_shaper_cache: font.ShaperCache,
|
||||
texture_greyscale_modified: usize = 0,
|
||||
texture_greyscale_resized: usize = 0,
|
||||
texture_color_modified: usize = 0,
|
||||
@ -345,6 +346,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !OpenGL {
|
||||
.gl_state = gl_state,
|
||||
.font_grid = grid,
|
||||
.font_shaper = shaper,
|
||||
.font_shaper_cache = font.ShaperCache.init(),
|
||||
.draw_background = options.config.background,
|
||||
.focused = true,
|
||||
.foreground_color = options.config.foreground,
|
||||
@ -359,6 +361,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !OpenGL {
|
||||
|
||||
pub fn deinit(self: *OpenGL) void {
|
||||
self.font_shaper.deinit();
|
||||
self.font_shaper_cache.deinit(self.alloc);
|
||||
|
||||
{
|
||||
var it = self.images.iterator();
|
||||
@ -1013,7 +1016,24 @@ pub fn rebuildCells(
|
||||
if (shape_cursor) screen.cursor.x else null,
|
||||
);
|
||||
while (try iter.next(self.alloc)) |run| {
|
||||
for (try self.font_shaper.shape(run)) |shaper_cell| {
|
||||
// Try to read the cells from the shaping cache if we can.
|
||||
const shaper_cells = self.font_shaper_cache.get(run) orelse cache: {
|
||||
const cells = try self.font_shaper.shape(run);
|
||||
|
||||
// Try to cache them. If caching fails for any reason we continue
|
||||
// because it is just a performance optimization, not a correctness
|
||||
// issue.
|
||||
self.font_shaper_cache.put(self.alloc, run, cells) catch |err| {
|
||||
log.warn("error caching font shaping results err={}", .{err});
|
||||
};
|
||||
|
||||
// The cells we get from direct shaping are always owned by
|
||||
// the shaper and valid until the next shaping call so we can
|
||||
// just return them.
|
||||
break :cache cells;
|
||||
};
|
||||
|
||||
for (shaper_cells) |shaper_cell| {
|
||||
// If this cell falls within our preedit range then we skip it.
|
||||
// We do this so we don't have conflicting data on the same
|
||||
// cell.
|
||||
|
Reference in New Issue
Block a user