add fontstash

This commit is contained in:
Rokas Puzonas 2026-01-04 20:16:31 +02:00
parent 44968f35f2
commit 12cf7201ac
12 changed files with 449 additions and 13 deletions

View File

@ -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

View File

@ -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);
}

View File

@ -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",

View File

@ -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
};
}

View File

@ -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);
}

View File

@ -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,
};

View File

@ -0,0 +1,3 @@
pub const Font = @import("./font.zig");
pub const Context = @import("./context.zig");

View File

@ -0,0 +1,12 @@
#include <stdio.h>
#include <stdlib.h>
#ifdef _WIN32
#include <windows.h>
#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"

View File

@ -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
);
}

View File

@ -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,

View File

@ -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,

View File

@ -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 {