Interrupt / delve-framework

Delve is a framework for writing Games in Zig and Lua. For those who value being cross platform and keeping things simple.
MIT License
203 stars 11 forks source link

Positioning of text for font rendering with `addStringToSpriteBatch` and `addStringToSpriteBatchWithKerning` does not behave as expected #61

Open stefanpartheym opened 4 weeks ago

stefanpartheym commented 4 weeks ago

Hi @Interrupt ,

recently I tried to use the builtin addStringToSpriteBatch and addStringToSpriteBatchWithKerning functions to render text to a 2D surface. However, positioning the text seems to not work as it does for regular shapes or textures (for instance with Batcher.addRectangle).

In the following example I positioned both – a red rectangle and the text – at X: 50 and Y: 50 (using a math grid, meaning Y grows up and X grows to the right). The result looks as follows: Bildschirmfoto vom 2024-10-10 16-39-34

One can clearly see that the text is in a completely different position than the rectangle. I would have expected to be below the left-bottom corner of the rectangle.

const std = @import("std");
const delve = @import("delve");
const app = delve.app;

const debug = delve.debug;
const graphics = delve.platform.graphics;
const colors = delve.colors;
const input = delve.platform.input;
const math = delve.math;
const modules = delve.modules;

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
var font_batch: delve.graphics.batcher.SpriteBatcher = undefined;
var batch: delve.graphics.batcher.Batcher = undefined;
var shader_blend: graphics.Shader = undefined;
const font_name = "DroidSans";

pub fn main() !void {
    try delve.init(std.heap.c_allocator);
    try registerModule();
    try app.start(app.AppConfig{ .title = "Delve Framework - 2D Fonts Example" });
}

pub fn registerModule() !void {
    const fontsExample = modules.Module{
        .name = "fonts_example",
        .init_fn = on_init,
        .tick_fn = on_tick,
        .pre_draw_fn = on_pre_draw,
        .draw_fn = on_draw,
        .cleanup_fn = on_cleanup,
    };

    try modules.registerModule(fontsExample);
}

pub fn getCamera2d() graphics.CameraMatrices {
    return graphics.CameraMatrices{
        .view = math.Mat4.lookat(
            .{ .x = 0, .y = 0, .z = 1 },
            math.Vec3.zero,
            math.Vec3.up,
        ),
        .proj = graphics.getProjectionOrtho(-1, 1, false),
    };
}

fn on_init() !void {
    debug.log("2D Fonts example module initializing", .{});
    graphics.setClearColor(colors.examples_bg_dark);

    font_batch = delve.graphics.batcher.SpriteBatcher.init(.{}) catch {
        debug.log("Error creating font sprite batch!", .{});
        return;
    };

    batch = delve.graphics.batcher.Batcher.init(.{}) catch {
        debug.log("Error creating shape batch!", .{});
        return;
    };

    _ = try delve.fonts.loadFont(font_name, "assets/fonts/" ++ font_name ++ ".ttf", 1024, 200);

    // make a shader with alpha blending
    shader_blend = try graphics.Shader.initDefault(.{ .blend_mode = graphics.BlendMode.BLEND });
}

fn on_tick(_: f32) void {
    if (input.isKeyJustPressed(.ESCAPE))
        delve.platform.app.exit();
}

fn on_pre_draw() void {
    const text_scale: f32 = 0.25;

    // Drawing position of characters, updated as each gets added
    var x_pos: f32 = 50.0;
    var y_pos: f32 = 50.0;

    const message = "This is some text!\nHello World!\n[]'.@%#$<>?;:-=_+'";

    const font_name_string = std.fmt.allocPrintZ(delve.mem.getAllocator(), "Drawing from {s}", .{font_name}) catch {
        return;
    };
    defer delve.mem.getAllocator().free(font_name_string);

    // Add font characters as sprites to our sprite batch
    // Ideally you would only do this when the text updates, and just draw the batch until then
    font_batch.reset();

    font_batch.useShader(shader_blend);

    if (delve.fonts.getLoadedFont(font_name)) |font| {
        // give the header a bit of padding
        const extra_header_line_height = font.font_size / 8;

        delve.fonts.addStringToSpriteBatchWithKerning(font, &font_batch, font_name_string, &x_pos, &y_pos, extra_header_line_height, 0, text_scale, colors.blue);
        delve.fonts.addStringToSpriteBatch(font, &font_batch, message, &x_pos, &y_pos, text_scale, colors.white);
    }

    font_batch.apply();

    batch.reset();
    const rect = delve.spatial.Rect.fromSize(math.vec2(100, 100)).setPosition(math.vec2(50, 50));
    batch.addRectangle(rect, delve.graphics.sprites.TextureRegion.default(), delve.colors.red);
    batch.apply();
}

fn on_draw() void {
    const cam = getCamera2d();
    batch.draw(cam, math.Mat4.identity);
    font_batch.draw(cam, math.Mat4.identity);
}

fn on_cleanup() !void {
    font_batch.deinit();
    shader_blend.destroy();
}

You can run the example by putting in the examples directory of the repo. Maybe you can also temporarily overwrite the fonts.zig example and the run zig build run-fonts.

Thanks :)

Interrupt commented 3 weeks ago

Thanks for the report! I'll check this out.

stefanpartheym commented 2 weeks ago

Ok, I basically hacked it so that i can intuitively render text as expected. Please be aware, the example below uses camera projection with a flipped Y-axis (growing downwards) (as I find it more intuitive). Also stbtt uses it this way I guess. However, I think you could easily change the code to use it with a "normal" Y-axis (growing upwards).

const std = @import("std");
const stbtt = @import("stb_truetype").stbtt;
const d = @import("delve");
const math = d.math;
const spatial = d.spatial;
const graphics = d.graphics;
const platform_graphics = d.platform.graphics;
const input = d.platform.input;

var batcher: graphics.batcher.SpriteBatcher = undefined;
var shader: platform_graphics.Shader = undefined;
var camera: platform_graphics.CameraMatrices = undefined;

var pos = math.vec2(0, 0);

pub fn main() !void {
    const mod = d.modules.Module{
        .name = "main",
        .init_fn = init,
        .cleanup_fn = cleanup,
        .tick_fn = tick,
        .pre_draw_fn = pre_draw,
        .draw_fn = draw,
    };

    try d.init(std.heap.c_allocator);
    try d.modules.registerModule(mod);
    try d.app.start(.{
        .title = "delve-fonts-test",
        .width = 800,
        .height = 600,
    });
}

fn init() !void {
    camera = getCamera2d();
    _ = try d.fonts.loadFont("default", "assets/VT323-Regular.ttf", 1024, 200);
    shader = try platform_graphics.Shader.initDefault(.{ .blend_mode = .BLEND });
    batcher = try graphics.batcher.SpriteBatcher.init(.{ .shader = shader });
}

fn cleanup() !void {
    batcher.deinit();
    shader.destroy();
}

fn tick(_: f32) void {
    if (input.isKeyJustPressed(.ESCAPE) or
        input.isKeyJustPressed(.Q))
    {
        d.platform.app.exit();
    }

    const speed: f32 = 5;
    if (input.isKeyPressed(.UP)) {
        pos.y -= speed;
    }
    if (input.isKeyPressed(.DOWN)) {
        pos.y += speed;
    }
    if (input.isKeyPressed(.LEFT)) {
        pos.x -= speed;
    }
    if (input.isKeyPressed(.RIGHT)) {
        pos.x += speed;
    }
}

fn pre_draw() void {
    batcher.reset();
    defer batcher.apply();

    if (d.fonts.getLoadedFont("default")) |font| {
        var pos_x: f32 = pos.x;
        var pos_y: f32 = pos.y;
        addStringToSpriteBatch(
            font,
            &batcher,
            "Test Text 123",
            &pos_x,
            &pos_y,
            0.2,
            d.colors.red,
        );
    }
}

fn draw() void {
    batcher.draw(camera, math.Mat4.identity);
}

//------------------------------------------------------------------------------

fn getView2d() math.Mat4 {
    return math.Mat4.lookat(
        .{ .x = 0, .y = 0, .z = 1 },
        math.Vec3.zero,
        math.Vec3.up,
    );
}

pub fn getCamera2d() platform_graphics.CameraMatrices {
    return platform_graphics.CameraMatrices{
        .view = getView2d(),
        // NOTE: Y-axis of camera projection is flipped.
        .proj = platform_graphics.getProjectionOrtho(-1, 1, true),
    };
}

//------------------------------------------------------------------------------

fn getCharQuad(
    font: *d.fonts.LoadedFont,
    char_index: usize,
    x_pos: *f32,
    y_pos: *f32,
    max_char_height: f32,
) d.fonts.CharQuad {
    var aligned_quad: stbtt.stbtt_aligned_quad = undefined;

    stbtt.stbtt_GetPackedQuad(
        @ptrCast(font.char_info),
        @intCast(font.tex_size),
        @intCast(font.tex_size),
        @intCast(char_index),
        @ptrCast(x_pos),
        @ptrCast(y_pos),
        &aligned_quad,
        1,
    );

    var char_quad: d.fonts.CharQuad = .{
        .tex_region = graphics.sprites.TextureRegion.default(),
        .rect = spatial.Rect.fromSize(math.Vec2.new(1.0, 1.0)),
    };

    // tex region
    char_quad.tex_region.u = aligned_quad.s0;
    char_quad.tex_region.v = aligned_quad.t0;
    char_quad.tex_region.u_2 = aligned_quad.s1;
    char_quad.tex_region.v_2 = aligned_quad.t1;

    // pos rect
    char_quad.rect.x = aligned_quad.x0;
    char_quad.rect.y = aligned_quad.y0;
    char_quad.rect.width = aligned_quad.x1 - aligned_quad.x0;
    char_quad.rect.height = aligned_quad.y1 - aligned_quad.y0;

    // NOTE: Place the char with position at top-left corner of the quad.
    char_quad.rect.y += max_char_height;

    return char_quad;
}

fn getMaxCharHeight(font: *d.fonts.LoadedFont) f32 {
    var max_height: f32 = 0;
    for (font.char_info) |char_info| {
        // std.debug.print("max charheight: {d}\n", .{char_info.y1 - char_info.y0});
        max_height = @max(max_height, @as(f32, @floatFromInt(char_info.y1 - char_info.y0)));
    }
    return max_height;
}

fn addStringToSpriteBatchWithKerning(
    font: *d.fonts.LoadedFont,
    sprite_batch: *graphics.batcher.SpriteBatcher,
    string: []const u8,
    x_pos: *f32,
    y_pos: *f32,
    line_height_mod: f32,
    kerning_mod: f32,
    scale: f32,
    color: d.colors.Color,
) void {
    sprite_batch.useTexture(font.texture);

    const max_char_height = getMaxCharHeight(font);
    const orig_x: f32 = x_pos.*;

    for (string) |char| {
        if (char == '\n') {
            x_pos.* = orig_x;
            y_pos.* += font.font_size + line_height_mod;
            continue;
        }

        const char_quad = getCharQuad(
            font,
            char - 32,
            x_pos,
            y_pos,
            max_char_height,
        );

        // NOTE: Necessary when Y-axis of camera projection is flipped.
        const region = char_quad.tex_region.flipY();

        sprite_batch.addRectangle(
            char_quad.rect.scale(scale),
            region,
            color,
        );

        x_pos.* += kerning_mod;
    }

    x_pos.* = orig_x;
    y_pos.* += font.font_size + line_height_mod;
}

fn addStringToSpriteBatch(
    font: *d.fonts.LoadedFont,
    sprite_batch: *graphics.batcher.SpriteBatcher,
    string: []const u8,
    x_pos: *f32,
    y_pos: *f32,
    scale: f32,
    color: d.colors.Color,
) void {
    addStringToSpriteBatchWithKerning(
        font,
        sprite_batch,
        string,
        x_pos,
        y_pos,
        0,
        0,
        scale,
        color,
    );
}

Note how I'm using getMaxCharHeight() to get the max height of a char for the font. I think there is actually a stbtt API function for that stbtt_GetFontBoundingBox(), which operates on a stbtt_fontinfo struct and does not require iterating each charinfo element. So, one would need to get the max char height when loading the font and set it in the LoadedFont struct.

Hope this helps.

Interrupt commented 1 week ago

Thanks, I should roll these changes into the base code. Was the Y-Axis being flipped the main culprit of the two view matrices being offset, or was there another mismatch? Maybe there should be a y-axis flip toggle in there somewhere.

stefanpartheym commented 1 week ago

Was the Y-Axis being flipped the main culprit of the two view matrices being offset, or was there another mismatch?

Initially, I tried it with a non-flipped y-axis. The result however wasn't 100% correct either (as seen in my initial post). I think it has to do with the way the chars are positioned on screen in the original getCharQuad function. The function tries to correctly position the chars on a "baseline". The baseline is offset however, which makes the whole text appear in different position than expected. I think in order to correctly position the text in a "non-y-axis-flipped" scenario, one would still need information about the max height of a char.

And yes, I think a y-axis flip toggle would be convenient.