freetype: resize glyphs that are too tall prior to storing in texture

Most emoji fonts are massive glyphs (128x128, 256x256, etc.). This means
the texture we need to store emoji is also massive. For a 128x128 emoji
font (both Apple and Noto), we can only store 12 emoji before resizing
prior to this commit.

This commit now threads through a max height through to the font face
and resizes the bitmap in memory before putting it in the atlas. This
results in significant savings. The max height is the cell height. We
allow the glyphs to be as wide as necessary due to double (and more)
wide glyphs.

For the unicode emoji test file, the atlas size before and after:

  Before: 262 MB
  After: 16 MB
This commit is contained in:
Mitchell Hashimoto
2022-10-17 19:04:39 -07:00
parent c103a278f1
commit 58c107dceb
10 changed files with 2794 additions and 10 deletions

View File

@ -11,6 +11,7 @@ const libxml2 = @import("vendor/zig-libxml2/libxml2.zig");
const libuv = @import("pkg/libuv/build.zig");
const libpng = @import("pkg/libpng/build.zig");
const macos = @import("pkg/macos/build.zig");
const stb_image_resize = @import("pkg/stb_image_resize/build.zig");
const utf8proc = @import("pkg/utf8proc/build.zig");
const zlib = @import("pkg/zlib/build.zig");
const tracylib = @import("pkg/tracy/build.zig");
@ -193,6 +194,7 @@ fn addDeps(
step.addPackage(imgui.pkg);
step.addPackage(glfw.pkg);
step.addPackage(libuv.pkg);
step.addPackage(stb_image_resize.pkg);
step.addPackage(utf8proc.pkg);
// Mac Stuff
@ -212,6 +214,9 @@ fn addDeps(
system_sdk.include(b, tracy_step, .{});
}
// stb_image_resize
_ = try stb_image_resize.link(b, step, .{});
// utf8proc
_ = try utf8proc.link(b, step);

View File

@ -0,0 +1,59 @@
const std = @import("std");
/// Directories with our includes.
const root = thisDir();
pub const include_paths = [_][]const u8{
root,
};
pub const pkg = std.build.Pkg{
.name = "stb_image_resize",
.source = .{ .path = thisDir() ++ "/main.zig" },
};
fn thisDir() []const u8 {
return std.fs.path.dirname(@src().file) orelse ".";
}
pub const Options = struct {};
pub fn link(
b: *std.build.Builder,
step: *std.build.LibExeObjStep,
opt: Options,
) !*std.build.LibExeObjStep {
const lib = try buildStbImageResize(b, step, opt);
step.linkLibrary(lib);
inline for (include_paths) |path| step.addIncludePath(path);
return lib;
}
pub fn buildStbImageResize(
b: *std.build.Builder,
step: *std.build.LibExeObjStep,
opt: Options,
) !*std.build.LibExeObjStep {
_ = opt;
const lib = b.addStaticLibrary("stb_image_resize", null);
lib.setTarget(step.target);
lib.setBuildMode(step.build_mode);
// Include
inline for (include_paths) |path| lib.addIncludePath(path);
// Link
lib.linkLibC();
// Compile
var flags = std.ArrayList([]const u8).init(b.allocator);
defer flags.deinit();
try flags.appendSlice(&.{
//"-fno-sanitize=undefined",
});
// C files
lib.addCSourceFile(root ++ "/stb_image_resize.c", flags.items);
return lib;
}

View File

@ -0,0 +1,7 @@
pub usingnamespace @cImport({
@cInclude("stb_image_resize.h");
});
test {
// Needed to not crash on test
}

View File

@ -0,0 +1,2 @@
#define STB_IMAGE_RESIZE_IMPLEMENTATION
#include <stb_image_resize.h>

File diff suppressed because it is too large Load Diff

View File

@ -583,6 +583,7 @@ pub fn updateCell(
self.alloc,
shaper_run.font_index,
shaper_cell.glyph_index,
@floatToInt(u16, @ceil(self.cell_size.height)),
);
// If we're rendering a color font, we use the color atlas

View File

@ -160,10 +160,11 @@ pub fn renderGlyph(
atlas: *Atlas,
index: FontIndex,
glyph_index: u32,
max_height: ?u16,
) !Glyph {
const face = &self.faces.get(index.style).items[@intCast(usize, index.idx)];
try face.load(self.lib, self.size);
return try face.face.?.renderGlyph(alloc, atlas, glyph_index);
return try face.face.?.renderGlyph(alloc, atlas, glyph_index, max_height);
}
test {
@ -201,6 +202,7 @@ test {
&atlas_greyscale,
idx,
glyph_index,
null,
);
}
@ -260,6 +262,7 @@ test {
&atlas_greyscale,
idx,
glyph_index,
null,
);
}
}

View File

@ -110,6 +110,7 @@ pub fn renderGlyph(
alloc: Allocator,
index: Group.FontIndex,
glyph_index: u32,
max_height: ?u16,
) !Glyph {
const key: GlyphKey = .{ .index = index, .glyph = glyph_index };
const gop = try self.glyphs.getOrPut(alloc, key);
@ -125,6 +126,7 @@ pub fn renderGlyph(
atlas,
index,
glyph_index,
max_height,
) catch |err| switch (err) {
// If the atlas is full, we resize it
error.AtlasFull => blk: {
@ -134,6 +136,7 @@ pub fn renderGlyph(
atlas,
index,
glyph_index,
max_height,
);
},
@ -186,6 +189,7 @@ test {
alloc,
idx,
glyph_index,
null,
);
}
@ -207,6 +211,7 @@ test {
alloc,
idx,
glyph_index,
null,
);
}
}

View File

@ -87,7 +87,15 @@ pub const Face = struct {
/// Render a glyph using the glyph index. The rendered glyph is stored in the
/// given texture atlas.
pub fn renderGlyph(self: Face, alloc: Allocator, atlas: *Atlas, glyph_index: u32) !font.Glyph {
pub fn renderGlyph(
self: Face,
alloc: Allocator,
atlas: *Atlas,
glyph_index: u32,
max_height: ?u16,
) !font.Glyph {
_ = max_height;
var glyphs = [_]macos.graphics.Glyph{@intCast(macos.graphics.Glyph, glyph_index)};
// Get the bounding rect for this glyph to determine the width/height
@ -321,7 +329,7 @@ test {
var i: u8 = 32;
while (i < 127) : (i += 1) {
try testing.expect(face.glyphIndex(i) != null);
_ = try face.renderGlyph(alloc, &atlas, face.glyphIndex(i).?);
_ = try face.renderGlyph(alloc, &atlas, face.glyphIndex(i).?, null);
}
}
@ -365,6 +373,6 @@ test "in-memory" {
var i: u8 = 32;
while (i < 127) : (i += 1) {
try testing.expect(face.glyphIndex(i) != null);
_ = try face.renderGlyph(alloc, &atlas, face.glyphIndex(i).?);
_ = try face.renderGlyph(alloc, &atlas, face.glyphIndex(i).?, null);
}
}

View File

@ -8,6 +8,7 @@ const std = @import("std");
const builtin = @import("builtin");
const freetype = @import("freetype");
const harfbuzz = @import("harfbuzz");
const resize = @import("stb_image_resize");
const assert = std.debug.assert;
const testing = std.testing;
const Allocator = std.mem.Allocator;
@ -118,7 +119,13 @@ pub const Face = struct {
/// Render a glyph using the glyph index. The rendered glyph is stored in the
/// given texture atlas.
pub fn renderGlyph(self: Face, alloc: Allocator, atlas: *Atlas, glyph_index: u32) !Glyph {
pub fn renderGlyph(
self: Face,
alloc: Allocator,
atlas: *Atlas,
glyph_index: u32,
max_height: ?u16,
) !Glyph {
// If our glyph has color, we want to render the color
try self.face.loadGlyph(glyph_index, .{
.render = true,
@ -168,11 +175,58 @@ pub const Face = struct {
break :blk try func(alloc, bitmap_ft);
} else null;
defer if (bitmap_converted) |bm| {
const len = bm.width * bm.rows * atlas.format.depth();
const len = @intCast(usize, bm.pitch) * @intCast(usize, bm.rows);
alloc.free(bm.buffer[0..len]);
};
const bitmap = bitmap_converted orelse bitmap_ft;
// Now we need to see if we need to resize this bitmap. This can happen
// in scenarios where we have fixed size glyphs. For example, emoji
// can be quite large (i.e. 128x128) when we have a cell width of 24!
// The issue with large bitmaps is they take a huge amount of space in
// the atlas and force resizes quite frequently. We pay some CPU cost
// up front to resize the glyph to avoid significant CPU cost to resize
// and copy the atlas.
const bitmap_resized: ?freetype.c.struct_FT_Bitmap_ = resized: {
const max = max_height orelse break :resized null;
const bm = bitmap_converted orelse bitmap_ft;
if (bm.rows <= max) break :resized null;
var result = bm;
result.rows = max;
result.width = (result.rows * bm.width) / bm.rows;
result.pitch = @intCast(c_int, result.width) * atlas.format.depth();
const buf = try alloc.alloc(
u8,
@intCast(usize, result.pitch) * @intCast(usize, result.rows),
);
result.buffer = buf.ptr;
errdefer alloc.free(buf);
if (resize.stbir_resize_uint8(
bm.buffer,
@intCast(c_int, bm.width),
@intCast(c_int, bm.rows),
bm.pitch,
result.buffer,
@intCast(c_int, result.width),
@intCast(c_int, result.rows),
result.pitch,
atlas.format.depth(),
) == 0) {
// This should never fail because this is a fairly straightforward
// in-memory operation...
return error.GlyphResizeFailed;
}
break :resized result;
};
defer if (bitmap_resized) |bm| {
const len = @intCast(usize, bm.pitch) * @intCast(usize, bm.rows);
alloc.free(bm.buffer[0..len]);
};
const bitmap = bitmap_resized orelse (bitmap_converted orelse bitmap_ft);
const tgt_w = bitmap.width;
const tgt_h = bitmap.rows;
@ -415,7 +469,7 @@ test {
// Generate all visible ASCII
var i: u8 = 32;
while (i < 127) : (i += 1) {
_ = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex(i).?);
_ = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex(i).?, null);
}
}
@ -434,7 +488,13 @@ test "color emoji" {
try testing.expectEqual(Presentation.emoji, ft_font.presentation);
_ = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex('🥸').?);
_ = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex('🥸').?, null);
// resize
{
const glyph = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex('🥸').?, 24);
try testing.expectEqual(@as(u32, 24), glyph.height);
}
}
test "mono to rgba" {
@ -451,5 +511,5 @@ test "mono to rgba" {
defer ft_font.deinit();
// glyph 3 is mono in Noto
_ = try ft_font.renderGlyph(alloc, &atlas, 3);
_ = try ft_font.renderGlyph(alloc, &atlas, 3, null);
}