From 12cf7201ac15f33ca5175adbdb0644037e98635c Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Sun, 4 Jan 2026 20:16:31 +0200 Subject: [PATCH] add fontstash --- README.md | 7 + build.zig | 57 ++++- build.zig.zon | 20 +- src/assets.zig | 19 ++ src/engine/fontstash/context.zig | 250 ++++++++++++++++++++ src/engine/fontstash/font.zig | 33 +++ src/engine/fontstash/root.zig | 3 + src/engine/fontstash/sokol_fontstash_impl.c | 12 + src/engine/graphics.zig | 48 ++++ src/engine/root.zig | 2 + src/engine/screen_scaler.zig | 4 + src/game.zig | 7 + 12 files changed, 449 insertions(+), 13 deletions(-) create mode 100644 src/engine/fontstash/context.zig create mode 100644 src/engine/fontstash/font.zig create mode 100644 src/engine/fontstash/root.zig create mode 100644 src/engine/fontstash/sokol_fontstash_impl.c diff --git a/README.md b/README.md index 1937b82..54b01d0 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,10 @@ Cross-compile for Windows from Linux: ```sh zig build -Dtarget=x86_64-windows ``` + +## TODO + +* Use [Skribidi](https://github.com/memononen/Skribidi) instead of fontstash for text rendering +* Gamepad support. + * WASM Support. Currently a build config isn't provided for this target. + * Update build config for other platforms to reduce binary size. All of the video and audio drivers aren't needed, only gamepads diff --git a/build.zig b/build.zig index fa02ae1..3693756 100644 --- a/build.zig +++ b/build.zig @@ -3,7 +3,7 @@ const cimgui = @import("cimgui"); const sokol = @import("sokol"); const builtin = @import("builtin"); -pub fn build(b: *std.Build) void { +pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); @@ -11,7 +11,9 @@ pub fn build(b: *std.Build) void { var has_tracy = b.option(bool, "tracy", "Tracy integration") orelse (optimize == .Debug); const has_console = b.option(bool, "console", "Show console (Window only)") orelse (optimize == .Debug); - if (target.result.cpu.arch.isWasm()) { + const isWasm = target.result.cpu.arch.isWasm(); + + if (isWasm) { has_tracy = false; } @@ -19,6 +21,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, + .link_libc = true }); const cimgui_conf = cimgui.getConfig(false); @@ -27,6 +30,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .with_sokol_imgui = has_imgui, }); + mod_main.linkLibrary(dep_sokol.artifact("sokol_clib")); mod_main.addImport("sokol", dep_sokol.module("sokol")); if (has_imgui) { @@ -56,6 +60,41 @@ pub fn build(b: *std.Build) void { const dep_stb_image = b.dependency("stb_image", .{}); mod_main.addImport("stb_image", dep_stb_image.module("stb_image")); + const dep_fontstash_c = b.dependency("fontstash_c", .{}); + mod_main.addIncludePath(dep_fontstash_c.path("src")); + + const dep_sokol_c = b.dependency("sokol_c", .{}); + { + var cflags_buffer: [64][]const u8 = undefined; + var cflags = std.ArrayListUnmanaged([]const u8).initBuffer(&cflags_buffer); + switch (sokol.resolveSokolBackend(.auto, target.result)) { + .d3d11 => try cflags.appendBounded("-DSOKOL_D3D11"), + .metal => try cflags.appendBounded("-DSOKOL_METAL"), + .gl => try cflags.appendBounded("-DSOKOL_GLCORE"), + .gles3 => try cflags.appendBounded("-DSOKOL_GLES3"), + .wgpu => try cflags.appendBounded("-DSOKOL_WGPU"), + else => @panic("unknown sokol backend"), + } + + mod_main.addIncludePath(dep_sokol_c.path("util")); + mod_main.addCSourceFile(.{ + .file = b.path("src/engine/fontstash/sokol_fontstash_impl.c"), + .flags = cflags.items + }); + } + + // TODO: + // const sdl = b.dependency("sdl", .{ + // .optimize = optimize, + // .target = target, + // .linkage = .static, + // .default_target_config = !isWasm + // }); + // mod_main.linkLibrary(sdl.artifact("SDL3")); + // if (isWasm) { + // // TODO: Define buid config for wasm + // } + var options = b.addOptions(); options.addOption(bool, "has_imgui", has_imgui); options.addOption(bool, "has_tracy", has_tracy); @@ -96,7 +135,6 @@ fn buildNative(b: *std.Build, name: []const u8, mod: *std.Build.Module, has_cons const png_to_icon_step = b.addRunArtifact(png_to_icon_tool); png_to_icon_step.addFileArg(b.path("src/assets/icon.png")); const icon_file = png_to_icon_step.addOutputFileArg("icon.ico"); - // const icon_file = b.path("src/assets/icon.ico"); // png_to_icon_step.addOutputFileArg("icon.ico"); const add_icon_step = AddExecutableIcon.init(exe, icon_file); exe.step.dependOn(&add_icon_step.step); @@ -139,13 +177,14 @@ fn patchWasmIncludeDirs( path: std.Build.LazyPath, depend_step: *std.Build.Step ) void { + if (module.link_libc != null and module.link_libc.?) { + // need to inject the Emscripten system header include path into + // the cimgui C library otherwise the C/C++ code won't find + // C stdlib headers + module.addSystemIncludePath(path); + } + for (module.import_table.values()) |imported_module| { - if (imported_module.link_libc != null and imported_module.link_libc.?) { - // need to inject the Emscripten system header include path into - // the cimgui C library otherwise the C/C++ code won't find - // C stdlib headers - imported_module.addSystemIncludePath(path); - } patchWasmIncludeDirs(imported_module, path, depend_step); } diff --git a/build.zig.zon b/build.zig.zon index ffdd935..24484e4 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -8,21 +8,33 @@ .url = "git+https://github.com/floooh/sokol-zig.git#aaf291ca2d3d1cedc05d65f5a1cacae0f53d934a", .hash = "sokol-0.1.0-pb1HK4iDNgCom5dkY66eUBm_bYBHEl8KWFDAiqwWgpEy", }, + .sokol_c = .{ + .url = "git+https://github.com/floooh/sokol.git#c66a1f04e6495d635c5e913335ab2308281e0492", + .hash = "N-V-__8AAC3eYABB1DVLb4dkcEzq_xVeEZZugVfQ6DoNQBDN", + }, + .fontstash_c = .{ + .url = "git+https://github.com/memononen/fontstash.git#b5ddc9741061343740d85d636d782ed3e07cf7be", + .hash = "N-V-__8AAA9xHgAxdLYPmlNTy6qzv9IYqiIePEHQUOPWYQ_6", + }, .cimgui = .{ .url = "git+https://github.com/floooh/dcimgui.git#33c99ef426b68030412b5a4b11487a23da9d4f13", .hash = "cimgui-0.1.0-44ClkQRJlABdFMKRqIG8KDD6jy1eQbgPO335NziPYjmL", - .lazy = true + .lazy = true, }, .tracy = .{ .url = "git+https://github.com/sagehane/zig-tracy.git#80933723efe9bf840fe749b0bfc0d610f1db1669", .hash = "zig_tracy-0.0.5-aOIqsX1tAACKaRRB-sraMLuNiMASXi_y-4FtRuw4cTpx", }, .tiled = .{ - .path = "./libs/tiled" + .path = "./libs/tiled", }, .stb_image = .{ - .path = "./libs/stb_image" - } + .path = "./libs/stb_image", + }, + // .sdl = .{ + // .url = "git+https://github.com/allyourcodebase/SDL3.git#f85824b0db782b7d01c60aaad8bcb537892394e8", + // .hash = "sdl-0.0.0-i4QD0UuFqADRQysNyJ1OvCOZnq-clcVhq3BfPcBOf9zr", + // }, }, .paths = .{ "build.zig", diff --git a/src/assets.zig b/src/assets.zig index 1c1a033..010e749 100644 --- a/src/assets.zig +++ b/src/assets.zig @@ -1,8 +1,27 @@ +const std = @import("std"); +const Gfx = @import("./engine/graphics.zig"); const Assets = @This(); +const FontName = enum { + regular, + bold, + italic, + + const EnumArray = std.EnumArray(FontName, Gfx.Font.Id); +}; + +font_id: FontName.EnumArray, + pub fn init() !Assets { + const font_id_array: FontName.EnumArray = .init(.{ + .regular = try Gfx.addFont("regular", @embedFile("assets/roboto-font/Roboto-Regular.ttf")), + .bold = try Gfx.addFont("bold", @embedFile("assets/roboto-font/Roboto-Bold.ttf")), + .italic = try Gfx.addFont("italic", @embedFile("assets/roboto-font/Roboto-Italic.ttf")), + }); + return Assets{ + .font_id = font_id_array }; } diff --git a/src/engine/fontstash/context.zig b/src/engine/fontstash/context.zig new file mode 100644 index 0000000..d360b31 --- /dev/null +++ b/src/engine/fontstash/context.zig @@ -0,0 +1,250 @@ +const std = @import("std"); + +const Font = @import("./font.zig"); +const Align = Font.Align; + +const Context = @This(); + +const c = @cImport({ + @cInclude("fontstash.h"); + + @cInclude("sokol/sokol_gfx.h"); + @cInclude("sokol/sokol_gl.h"); + @cInclude("sokol_fontstash.h"); +}); + +pub const FONScontext = c.FONScontext; + +pub const Size = struct { + width: u32, + height: u32, +}; + +pub const TextBounds = struct { + advance: f32, + min_x: f32, + max_x: f32, + min_y: f32, + max_y: f32, +}; + +pub const LineBounds = struct { + min_y: f32, + max_y: f32, +}; + +pub const VertMetrics = struct { + ascender: f32, + descender: f32, + lineh: f32 +}; + +pub const Quad = struct { + x0: f32, + y0: f32, + s0: f32, + t0: f32, + + x1: f32, + y1: f32, + s1: f32, + t1: f32, +}; + +pub const TextIterator = struct { + context: Context, + iter: c.FONStextIter, + + pub fn init(ctx: Context, x: f32, y: f32, text: []const u8) TextIterator { + var self = TextIterator{ + .context = ctx, + .iter = undefined + }; + + const success = c.fonsTextIterInit( + self.context.ctx, + &self.iter, + x, + y, + text.ptr, + text.ptr + text.len + ); + if (success != 1) { + return error.fonsTextIterInit; + } + + return self; + } + + pub fn next(self: TextIterator) ?Quad { + var quad: c.FONSquad = undefined; + const success = c.fonsTextIterNext(self.context, &self.iter, &quad); + if (success != 1) { + return null; + } + + return Quad{ + .x0 = quad.x0, + .y0 = quad.y0, + .s0 = quad.s0, + .t0 = quad.t0, + + .x1 = quad.x0, + .y1 = quad.y0, + .s1 = quad.s0, + .t1 = quad.t0, + }; + } +}; + +ctx: *FONScontext, + +pub fn init(desc: c.sfons_desc_t) !Context { + const ctx = c.sfons_create(&desc); + if (ctx == null) { + return error.sfons_create; + } + + return Context{ + .ctx = ctx.? + }; +} + +pub fn deinit(self: Context) void { + c.sfons_destroy(self.ctx); +} + +pub fn flush(self: Context) void { + c.sfons_flush(self.ctx); +} + +pub fn addFont(self: Context, name: [*c]const u8, data: []const u8) !Font.Id { + const font_id = c.fonsAddFontMem( + self.ctx, + name, + @constCast(data.ptr), + @intCast(data.len), + 0 + ); + if (font_id == c.FONS_INVALID) { + return error.fonsAddFontMem; + } + return @enumFromInt(font_id); +} + +pub fn addFallbackFont(self: Context, base: Font.Id, fallback: Font.Id) void { + const success = c.fonsAddFallbackFont(self.ctx, @intFromEnum(base), @intFromEnum(fallback)); + if (success != 1) { + return error.fonsAddFallbackFont; + } +} + +pub fn getFontByName(self: Context, name: [*c]const u8) ?Font.Id { + const font_id = c.fonsGetFontByName(self.ctx, name); + if (font_id == c.FONS_INVALID) { + return null; + } + return @enumFromInt(font_id); +} + +// TODO: fonsSetErrorCallback + +pub fn getAtlasSize(self: Context) Size { + var result: Size = .{ + .width = 0, + .height = 0 + }; + c.fonsGetAtlasSize(self.ctx, &result.width, &result.height); + return result; +} + +pub fn expandAtlas(self: Context, width: u32, height: u32) !void { + const success = c.fonsExpandAtlas(self.ctx, @bitCast(width), @bitCast(height)); + if (success != 1) { + return error.fonsExpandAtlas; + } +} + +pub fn resetAtlas(self: Context) !void { + const success = c.fonsResetAtlas(self.ctx); + if (success != 1) { + return error.fonsResetAtlas; + } +} + +pub fn pushState(self: Context) void { + c.fonsPushState(self.ctx); +} + +pub fn popState(self: Context) void { + c.fonsPopState(self.ctx); +} + +pub fn clearState(self: Context) void { + c.fonsClearState(self.ctx); +} + +pub fn setSize(self: Context, size: f32) void { + c.fonsSetSize(self.ctx, size); +} + +pub fn setColor(self: Context, color: u32) void { + c.fonsSetColor(self.ctx, color); +} + +pub fn setSpacing(self: Context, spacing: f32) void { + c.fonsSetSpacing(self.ctx, spacing); +} + +pub fn setBlur(self: Context, blur: f32) void { + c.fonsSetSpacing(self.ctx, blur); +} + +pub fn setAlign(self: Context, alignment: Align) void { + c.fonsSetAlign(self.ctx, @intFromEnum(alignment.x) | @intFromEnum(alignment.y)); +} + +pub fn setFont(self: Context, id: Font.Id) void { + c.fonsSetFont(self.ctx, @intFromEnum(id)); +} + +pub fn drawText(self: Context, x: f32, y: f32, text: []const u8) void { + const advance = c.fonsDrawText(self.ctx, x, y, text.ptr, text.ptr + text.len); + _ = advance; +} + +pub fn textBounds(self: Context, x: f32, y: f32, text: []const u8) TextBounds { + var bounds: f32[4] = undefined; + const advance = c.fonsTextBounds(self.ctx, x, y, text.ptr, text.ptr + text.len, &bounds); + + return TextBounds{ + .advance = advance, + .min_x = bounds[0], + .max_x = bounds[1], + .min_y = bounds[2], + .max_y = bounds[3] + }; +} + +pub fn lineBounds(self: Context, y: f32) LineBounds { + var result: LineBounds = .{ + .max_y = 0, + .min_y = 0 + }; + c.fonsLineBounds(self.ctx, y, &result.min_y, &result.max_y); + return result; +} + +pub fn vertMetrics(self: Context) void { + var result: VertMetrics = .{ + .ascender = 0, + .descender = 0, + .lineh = 0 + }; + c.fonsVertMetrics(self.ctx, &result.ascender, &result.descender, &result.lineh); + return result; +} + +pub fn drawDebug(self: Context, x: f32, y: f32) void { + c.fonsDrawDebug(self.ctx, x, y); +} diff --git a/src/engine/fontstash/font.zig b/src/engine/fontstash/font.zig new file mode 100644 index 0000000..3b2032c --- /dev/null +++ b/src/engine/fontstash/font.zig @@ -0,0 +1,33 @@ +const std = @import("std"); + +const c = @cImport({ + @cInclude("fontstash.h"); +}); + +const Font = @This(); + +pub const Id = enum(c_int) { + _, + + pub const invalid: Id = @enumFromInt(c.FONS_INVALID); +}; + +pub const AlignX = enum(c_int) { + left = c.FONS_ALIGN_LEFT, + right = c.FONS_ALIGN_RIGHT, + center = c.FONS_ALIGN_CENTER, + _, +}; + +pub const AlignY = enum(c_int) { + top = c.FONS_ALIGN_TOP, + middle = c.FONS_ALIGN_MIDDLE, + bottom = c.FONS_ALIGN_BOTTOM, + baseline = c.FONS_ALIGN_BASELINE, + _, +}; + +pub const Align = struct { + x: AlignX, + y: AlignY, +}; diff --git a/src/engine/fontstash/root.zig b/src/engine/fontstash/root.zig new file mode 100644 index 0000000..2620597 --- /dev/null +++ b/src/engine/fontstash/root.zig @@ -0,0 +1,3 @@ + +pub const Font = @import("./font.zig"); +pub const Context = @import("./context.zig"); diff --git a/src/engine/fontstash/sokol_fontstash_impl.c b/src/engine/fontstash/sokol_fontstash_impl.c new file mode 100644 index 0000000..26aeca8 --- /dev/null +++ b/src/engine/fontstash/sokol_fontstash_impl.c @@ -0,0 +1,12 @@ +#include +#include +#ifdef _WIN32 +#include +#endif +#define FONTSTASH_IMPLEMENTATION +#include "fontstash.h" + +#include "sokol/sokol_gfx.h" +#include "sokol/sokol_gl.h" +#define SOKOL_FONTSTASH_IMPL +#include "sokol_fontstash.h" diff --git a/src/engine/graphics.zig b/src/engine/graphics.zig index c1c30d7..d996475 100644 --- a/src/engine/graphics.zig +++ b/src/engine/graphics.zig @@ -18,6 +18,8 @@ const assert = std.debug.assert; const imgui = @import("imgui.zig"); const tracy = @import("tracy"); +const fontstash = @import("./fontstash/root.zig"); +pub const Font = fontstash.Font; // TODO: Seems that there is a vertical jitter bug when resizing a window in OpenGL. Seems like a driver bug. // From other peoples research it seems that disabling vsync when a resize event occurs fixes it. @@ -55,6 +57,8 @@ var draw_frame: DrawFrame = undefined; var main_pipeline: sgl.Pipeline = .{}; var linear_sampler: sg.Sampler = .{}; var nearest_sampler: sg.Sampler = .{}; +var font_context: fontstash.Context = undefined; +pub var font_resolution_scale: f32 = 1; pub fn init(options: Options) !void { draw_frame.init(); @@ -119,10 +123,19 @@ pub fn init(options: Options) !void { .mipmap_filter = .NEAREST, .label = "nearest-sampler", }); + + const dpi_scale = sapp.dpiScale(); + const atlas_size = 512; + const atlas_dim = std.math.ceilPowerOfTwoAssert(u32, @intFromFloat(atlas_size * dpi_scale)); + font_context = try fontstash.Context.init(.{ + .width = @intCast(atlas_dim), + .height = @intCast(atlas_dim), + }); } pub fn deinit() void { imgui.shutdown(); + font_context.deinit(); sgl.shutdown(); sg.shutdown(); } @@ -142,6 +155,7 @@ pub fn beginFrame() void { .dpi_scale = sapp.dpiScale() }); + font_context.clearState(); sgl.defaults(); sgl.matrixModeProjection(); sgl.ortho(0, draw_frame.screen_size.x, draw_frame.screen_size.y, 0, -1, 1); @@ -166,6 +180,8 @@ pub fn endFrame() void { } }; + font_context.flush(); + { sg.beginPass(.{ .action = pass_action, @@ -260,3 +276,35 @@ pub fn popScissor() void { sgl.scissorRectf(rect.pos.x, rect.pos.y, rect.size.x, rect.size.y, true); } + +pub fn addFont(name: [*c]const u8, data: []const u8) !Font.Id { + return try font_context.addFont(name, data); +} + +pub const DrawTextOptions = struct { + font: Font.Id, + size: f32 = 16, + color: Vec4 = rgb(255, 255, 255), +}; + +pub fn drawText(position: Vec2, text: []const u8, opts: DrawTextOptions) void { + pushTransform(.{ .x = 0, .y = 0}, 1/font_resolution_scale); + defer popTransform(); + + font_context.setFont(opts.font); + font_context.setSize(opts.size * font_resolution_scale); + font_context.setAlign(.{ .x = .left, .y = .top }); + font_context.setSpacing(0); + + const r: u8 = @intFromFloat(opts.color.x * 255); + const g: u8 = @intFromFloat(opts.color.y * 255); + const b: u8 = @intFromFloat(opts.color.z * 255); + const a: u8 = @intFromFloat(opts.color.w * 255); + const color: u32 = r | (@as(u32, g) << 8) | (@as(u32, b) << 16) | (@as(u32, a) << 24); + font_context.setColor(color); + font_context.drawText( + position.x * font_resolution_scale, + position.y * font_resolution_scale, + text + ); +} diff --git a/src/engine/root.zig b/src/engine/root.zig index 6f12647..e479b60 100644 --- a/src/engine/root.zig +++ b/src/engine/root.zig @@ -179,6 +179,8 @@ fn sokolFrame(self: *Engine) !void { const ctx = ScreenScalar.push(window_size, self.game.canvas_size); defer ctx.pop(); + Graphics.font_resolution_scale = ctx.scale; + try self.game.tick(Frame{ .time_ns = time_passed, .dt_ns = dt_ns, diff --git a/src/engine/screen_scaler.zig b/src/engine/screen_scaler.zig index 3d1ad62..e7860f3 100644 --- a/src/engine/screen_scaler.zig +++ b/src/engine/screen_scaler.zig @@ -6,6 +6,10 @@ const rgb = Math.rgb; const ScreenScalar = @This(); +// TODO: Implement a fractional pixel perfect scalar +// Based on this video: https://www.youtube.com/watch?v=d6tp43wZqps +// And this blog: https://colececil.dev/blog/2017/scaling-pixel-art-without-destroying-it/ + window_size: Vec2, translation: Vec2, scale: f32, diff --git a/src/game.zig b/src/game.zig index 1bf39eb..02636a3 100644 --- a/src/game.zig +++ b/src/game.zig @@ -50,12 +50,19 @@ pub fn tick(self: *Game, frame: Engine.Frame) !void { self.player = self.player.add(dir.multiplyScalar(50 * frame.dt)); + const regular_font = self.assets.font_id.get(.regular); + Gfx.drawRectangle(.init(0, 0), self.canvas_size, rgb(20, 20, 20)); const size = Vec2.init(20, 20); Gfx.drawRectangle(self.player.sub(size.divideScalar(2)), size, rgb(200, 20, 20)); if (dir.x != 0 or dir.y != 0) { Gfx.drawRectanglOutline(self.player.sub(size.divideScalar(2)), size, rgb(20, 200, 20), 3); } + + Gfx.drawText(self.player, "Player", .{ + .font = regular_font, + .size = 10 + }); } pub fn debug(self: *Game) !void {