a73x

445311ee

feat(font): glyph atlas with row-based packing

a73x   2026-04-08 06:20


diff --git a/src/font.zig b/src/font.zig
index cd75d12..ff94d0a 100644
--- a/src/font.zig
+++ b/src/font.zig
@@ -140,6 +140,93 @@ pub const Face = struct {
    }
};

pub const GlyphUV = struct {
    u0: f32,
    v0: f32,
    u1: f32,
    v1: f32,
    width: u32,
    height: u32,
    bearing_x: i32,
    bearing_y: i32,
    advance_x: i32,
};

pub const Atlas = struct {
    alloc: std.mem.Allocator,
    width: u32,
    height: u32,
    pixels: []u8, // R8
    cursor_x: u32,
    cursor_y: u32,
    row_height: u32,
    cache: std.AutoHashMap(u21, GlyphUV),
    dirty: bool,

    pub fn init(alloc: std.mem.Allocator, width: u32, height: u32) !Atlas {
        const pixels = try alloc.alloc(u8, @as(usize, width) * @as(usize, height));
        @memset(pixels, 0);
        return .{
            .alloc = alloc,
            .width = width,
            .height = height,
            .pixels = pixels,
            .cursor_x = 0,
            .cursor_y = 0,
            .row_height = 0,
            .cache = std.AutoHashMap(u21, GlyphUV).init(alloc),
            .dirty = true,
        };
    }

    pub fn deinit(self: *Atlas) void {
        self.alloc.free(self.pixels);
        self.cache.deinit();
    }

    pub fn getOrInsert(self: *Atlas, face: *Face, codepoint: u21) !GlyphUV {
        if (self.cache.get(codepoint)) |uv| return uv;

        const glyph = try face.rasterize(codepoint);
        defer face.freeGlyph(glyph);

        if (self.cursor_x + glyph.width > self.width) {
            self.cursor_x = 0;
            self.cursor_y += self.row_height;
            self.row_height = 0;
        }
        if (self.cursor_y + glyph.height > self.height) {
            return error.AtlasFull;
        }

        var y: u32 = 0;
        while (y < glyph.height) : (y += 1) {
            const src = glyph.bitmap.ptr + @as(usize, y) * glyph.width;
            const dst = self.pixels.ptr + (@as(usize, self.cursor_y + y) * self.width) + self.cursor_x;
            @memcpy(dst[0..glyph.width], src[0..glyph.width]);
        }

        const uv = GlyphUV{
            .u0 = @as(f32, @floatFromInt(self.cursor_x)) / @as(f32, @floatFromInt(self.width)),
            .v0 = @as(f32, @floatFromInt(self.cursor_y)) / @as(f32, @floatFromInt(self.height)),
            .u1 = @as(f32, @floatFromInt(self.cursor_x + glyph.width)) / @as(f32, @floatFromInt(self.width)),
            .v1 = @as(f32, @floatFromInt(self.cursor_y + glyph.height)) / @as(f32, @floatFromInt(self.height)),
            .width = glyph.width,
            .height = glyph.height,
            .bearing_x = glyph.bearing_x,
            .bearing_y = glyph.bearing_y,
            .advance_x = glyph.advance_x,
        };

        try self.cache.put(codepoint, uv);
        self.cursor_x += glyph.width;
        if (glyph.height > self.row_height) self.row_height = glyph.height;
        self.dirty = true;

        return uv;
    }
};

test "lookupMonospace returns a valid font path" {
    var lookup = try lookupMonospace(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);
@@ -162,3 +249,24 @@ test "Face rasterizes glyph 'M'" {
    try std.testing.expect(glyph.height > 0);
    try std.testing.expect(glyph.bitmap.len == @as(usize, glyph.width) * @as(usize, glyph.height));
}

test "Atlas packs multiple glyphs and returns UVs" {
    var lookup = try lookupMonospace(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
    defer face.deinit();

    var atlas = try Atlas.init(std.testing.allocator, 512, 512);
    defer atlas.deinit();

    const uv_m = try atlas.getOrInsert(&face, 'M');
    const uv_a = try atlas.getOrInsert(&face, 'a');

    try std.testing.expect(uv_m.u0 >= 0.0 and uv_m.u1 <= 1.0);
    try std.testing.expect(uv_a.u0 >= 0.0 and uv_a.u1 <= 1.0);

    const uv_m2 = try atlas.getOrInsert(&face, 'M');
    try std.testing.expectEqual(uv_m.u0, uv_m2.u0);
    try std.testing.expectEqual(uv_m.v0, uv_m2.v0);
}