fix(gui): resolve crashes and glyph rendering bugs from M3 smoke test

This commit is contained in:
CJ van den Berg 2026-03-30 00:24:12 +02:00
parent 996e6714ba
commit 4291ccf2c5
Signed by: neurocyte
GPG key ID: 8EB1E1BB660E3FB9
4 changed files with 105 additions and 66 deletions

View file

@ -17,7 +17,7 @@ pub const FsParams = extern struct {
}; };
const vs_src = const vs_src =
\\#version 410 \\#version 330 core
\\void main() { \\void main() {
\\ int id = gl_VertexID; \\ int id = gl_VertexID;
\\ float x = 2.0 * (float(id & 1) - 0.5); \\ float x = 2.0 * (float(id & 1) - 0.5);
@ -27,7 +27,7 @@ const vs_src =
; ;
const fs_src = const fs_src =
\\#version 410 \\#version 330 core
\\uniform int cell_size_x; \\uniform int cell_size_x;
\\uniform int cell_size_y; \\uniform int cell_size_y;
\\uniform int col_count; \\uniform int col_count;

View file

@ -12,14 +12,16 @@ const XY = @import("xy").XY;
const builtin_shader = @import("builtin.glsl.zig"); const builtin_shader = @import("builtin.glsl.zig");
pub const Font = Rasterizer.Font; pub const Font = Rasterizer.Font;
pub const GlyphKind = Rasterizer.GlyphKind;
pub const Cell = gui_cell.Cell; pub const Cell = gui_cell.Cell;
pub const Color = gui_cell.Rgba8; pub const Color = gui_cell.Rgba8;
const Rgba8 = gui_cell.Rgba8; const Rgba8 = gui_cell.Rgba8;
const log = std.log.scoped(.gpu); const log = std.log.scoped(.gpu);
// Maximum glyph atlas dimension (matching D3D11_REQ_TEXTURE2D_U_OR_V_DIMENSION) // Maximum glyph atlas dimension. 4096 is universally supported and gives
const max_atlas_dim: u16 = 16384; // 65536+ glyph slots at typical cell sizes far more than needed in practice.
const max_atlas_dim: u16 = 4096;
fn getAtlasCellCount(cell_size: XY(u16)) XY(u16) { fn getAtlasCellCount(cell_size: XY(u16)) XY(u16) {
return .{ return .{
@ -121,6 +123,8 @@ pub const WindowState = struct {
// Glyph index cache // Glyph index cache
glyph_cache_cell_size: ?XY(u16) = null, glyph_cache_cell_size: ?XY(u16) = null,
glyph_index_cache: ?GlyphIndexCache = null, glyph_index_cache: ?GlyphIndexCache = null,
// Set when the CPU atlas shadow was updated; cleared after GPU upload.
glyph_atlas_dirty: bool = false,
pub fn init() WindowState { pub fn init() WindowState {
std.debug.assert(global.init_called); std.debug.assert(global.init_called);
@ -269,15 +273,10 @@ pub const WindowState = struct {
@memcpy(region_buf[dst_off .. dst_off + glyph_w], staging_buf[src_off .. src_off + glyph_w]); @memcpy(region_buf[dst_off .. dst_off + glyph_w], staging_buf[src_off .. src_off + glyph_w]);
} }
// Upload to atlas via a temporary full-atlas image data struct. // Write into the CPU-side atlas shadow. The GPU upload is
// sokol's updateImage uploads the whole mip0 slice. We use a // deferred to paint() so it happens at most once per frame.
// trick: create a temporary single-cell-sized image, upload it, blitAtlasCpu(state, atlas_x, atlas_y, glyph_w, glyph_h, region_buf);
// then actually, sokol doesn't expose sub-rect uploads. state.glyph_atlas_dirty = true;
//
// Workaround: upload the WHOLE atlas with only the new cell
// updated. For M2 this is acceptable; a smarter approach
// (ping-pong or persistent mapped buffer) can be added later.
uploadGlyphAtlasCell(state, atlas_x, atlas_y, glyph_w, glyph_h, region_buf);
return reserved.index; return reserved.index;
}, },
@ -286,15 +285,13 @@ pub const WindowState = struct {
} }
}; };
// Upload one cell's pixels into the glyph atlas. // CPU-side shadow copy of the glyph atlas (R8, row-major).
// Because sokol only supports full-image updates via sg.updateImage, we // Kept alive for the process lifetime; resized when the atlas image grows.
// maintain a CPU-side copy of the atlas and re-upload it each time.
// (M2 budget approach acceptable for the stub rasterizer that always
// produces blank glyphs anyway.)
var atlas_cpu: ?[]u8 = null; var atlas_cpu: ?[]u8 = null;
var atlas_cpu_size: XY(u16) = .{ .x = 0, .y = 0 }; var atlas_cpu_size: XY(u16) = .{ .x = 0, .y = 0 };
fn uploadGlyphAtlasCell( // Blit one glyph cell into the CPU-side atlas shadow.
fn blitAtlasCpu(
state: *const WindowState, state: *const WindowState,
x: u16, x: u16,
y: u16, y: u16,
@ -305,7 +302,6 @@ fn uploadGlyphAtlasCell(
const asz = state.glyph_image_size; const asz = state.glyph_image_size;
const total: usize = @as(usize, asz.x) * @as(usize, asz.y); const total: usize = @as(usize, asz.x) * @as(usize, asz.y);
// Resize cpu shadow if needed
if (!atlas_cpu_size.eql(asz)) { if (!atlas_cpu_size.eql(asz)) {
if (atlas_cpu) |old| std.heap.page_allocator.free(old); if (atlas_cpu) |old| std.heap.page_allocator.free(old);
atlas_cpu = std.heap.page_allocator.alloc(u8, total) catch |e| oom(e); atlas_cpu = std.heap.page_allocator.alloc(u8, total) catch |e| oom(e);
@ -313,18 +309,25 @@ fn uploadGlyphAtlasCell(
atlas_cpu_size = asz; atlas_cpu_size = asz;
} }
// Blit the cell into the cpu shadow
const buf = atlas_cpu.?; const buf = atlas_cpu.?;
for (0..h) |row_i| { for (0..h) |row_i| {
const src_off = row_i * w; const src_off = row_i * w;
const dst_off = (@as(usize, y) + row_i) * asz.x + x; const dst_off = (@as(usize, y) + row_i) * asz.x + x;
@memcpy(buf[dst_off .. dst_off + w], pixels[src_off .. src_off + w]); @memcpy(buf[dst_off .. dst_off + w], pixels[src_off .. src_off + w]);
} }
}
// Upload the CPU shadow to the GPU. Called once per frame if dirty.
// Must be called outside a sokol render pass.
fn flushGlyphAtlas(state: *WindowState) void {
const asz = state.glyph_image_size;
const total: usize = @as(usize, asz.x) * @as(usize, asz.y);
const buf = atlas_cpu orelse return;
// Re-upload the full atlas
var img_data: sg.ImageData = .{}; var img_data: sg.ImageData = .{};
img_data.mip_levels[0] = .{ .ptr = buf.ptr, .size = total }; img_data.mip_levels[0] = .{ .ptr = buf.ptr, .size = total };
sg.updateImage(state.glyph_image, img_data); sg.updateImage(state.glyph_image, img_data);
state.glyph_atlas_dirty = false;
} }
pub fn paint( pub fn paint(
@ -373,6 +376,9 @@ pub fn paint(
} }
} }
// Upload glyph atlas to GPU if any new glyphs were rasterized this frame.
if (state.glyph_atlas_dirty) flushGlyphAtlas(state);
// Upload cell texture // Upload cell texture
var cell_data: sg.ImageData = .{}; var cell_data: sg.ImageData = .{};
const cell_bytes = std.mem.sliceAsBytes(shader_cells); const cell_bytes = std.mem.sliceAsBytes(shader_cells);

View file

@ -88,10 +88,9 @@ pub fn render(
if (dims.width == 0 or dims.height == 0) return; if (dims.width == 0 or dims.height == 0) return;
const buf_w: i32 = switch (kind) { // Always use 2*cell_w as the row stride so it matches the staging buffer
.single => @intCast(font.cell_size.x), // width allocated by generateGlyph (which always allocates 2*cell_w wide).
.left, .right => @as(i32, @intCast(font.cell_size.x)) * 2, const buf_w: i32 = @as(i32, @intCast(font.cell_size.x)) * 2;
};
const buf_h: i32 = @intCast(font.cell_size.y); const buf_h: i32 = @intCast(font.cell_size.y);
const x_offset: i32 = switch (kind) { const x_offset: i32 = switch (kind) {

View file

@ -24,9 +24,9 @@ const log = std.log.scoped(.wio_app);
const ScreenSnapshot = struct { const ScreenSnapshot = struct {
cells: []gpu.Cell, cells: []gpu.Cell,
codepoints: []u21,
width: u16, width: u16,
height: u16, height: u16,
font: gpu.Font,
}; };
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{}; var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{};
@ -37,6 +37,10 @@ var tui_pid: thespian.pid = undefined;
var font_size_px: u16 = 16; var font_size_px: u16 = 16;
var font_name_buf: [256]u8 = undefined; var font_name_buf: [256]u8 = undefined;
var font_name_len: usize = 0; var font_name_len: usize = 0;
var font_dirty: std.atomic.Value(bool) = .init(true);
// Current font written and read only from the wio thread (after gpu.init).
var wio_font: gpu.Font = .{ .cell_size = .{ .x = 8, .y = 16 } };
// Public API (called from tui thread) // Public API (called from tui thread)
@ -58,28 +62,39 @@ pub fn updateScreen(vx_screen: *const vaxis.Screen) void {
const cell_count: usize = @as(usize, vx_screen.width) * @as(usize, vx_screen.height); const cell_count: usize = @as(usize, vx_screen.width) * @as(usize, vx_screen.height);
const new_cells = allocator.alloc(gpu.Cell, cell_count) catch return; const new_cells = allocator.alloc(gpu.Cell, cell_count) catch return;
const new_font = getFont(); const new_codepoints = allocator.alloc(u21, cell_count) catch {
allocator.free(new_cells);
return;
};
// Convert vaxis cells gpu.Cell (glyph + colours) // Convert vaxis cells gpu.Cell (colours only; glyph indices filled on GPU thread).
// Glyph indices are filled in on the GPU thread; here we just store 0. for (vx_screen.buf[0..cell_count], new_cells, new_codepoints) |*vc, *gc, *cp| {
for (vx_screen.buf[0..cell_count], new_cells) |*vc, *gc| {
gc.* = .{ gc.* = .{
.glyph_index = 0, .glyph_index = 0,
.background = colorFromVaxis(vc.style.bg), .background = colorFromVaxis(vc.style.bg),
.foreground = colorFromVaxis(vc.style.fg), .foreground = colorFromVaxis(vc.style.fg),
}; };
// Decode first codepoint from the grapheme cluster.
const g = vc.char.grapheme;
cp.* = if (g.len > 0) blk: {
const seq_len = std.unicode.utf8ByteSequenceLength(g[0]) catch break :blk ' ';
break :blk std.unicode.utf8Decode(g[0..@min(seq_len, g.len)]) catch ' ';
} else ' ';
} }
screen_mutex.lock(); screen_mutex.lock();
defer screen_mutex.unlock(); defer screen_mutex.unlock();
// Free the previous snapshot // Free the previous snapshot
if (screen_snap) |old| allocator.free(old.cells); if (screen_snap) |old| {
allocator.free(old.cells);
allocator.free(old.codepoints);
}
screen_snap = .{ screen_snap = .{
.cells = new_cells, .cells = new_cells,
.codepoints = new_codepoints,
.width = vx_screen.width, .width = vx_screen.width,
.height = vx_screen.height, .height = vx_screen.height,
.font = new_font,
}; };
screen_pending.store(true, .release); screen_pending.store(true, .release);
@ -93,6 +108,7 @@ pub fn requestRender() void {
pub fn setFontSize(size_px: f32) void { pub fn setFontSize(size_px: f32) void {
font_size_px = @intFromFloat(@max(4, size_px)); font_size_px = @intFromFloat(@max(4, size_px));
font_dirty.store(true, .release);
requestRender(); requestRender();
} }
@ -105,14 +121,24 @@ pub fn setFontFace(name: []const u8) void {
const copy_len = @min(name.len, font_name_buf.len); const copy_len = @min(name.len, font_name_buf.len);
@memcpy(font_name_buf[0..copy_len], name[0..copy_len]); @memcpy(font_name_buf[0..copy_len], name[0..copy_len]);
font_name_len = copy_len; font_name_len = copy_len;
font_dirty.store(true, .release);
requestRender(); requestRender();
} }
// Internal helpers // Internal helpers (wio thread only)
fn getFont() gpu.Font { // Reload wio_font from current settings. Called only from the wio thread.
fn reloadFont() void {
const name = if (font_name_len > 0) font_name_buf[0..font_name_len] else "monospace"; const name = if (font_name_len > 0) font_name_buf[0..font_name_len] else "monospace";
return gpu.loadFont(name, font_size_px) catch gpu.Font{ .cell_size = .{ .x = 8, .y = 16 } }; wio_font = gpu.loadFont(name, font_size_px) catch return;
}
// Check dirty flag and reload if needed.
fn maybeReloadFont(win_size: wio.Size, state: *gpu.WindowState, cell_width: *u16, cell_height: *u16) void {
if (font_dirty.swap(false, .acq_rel)) {
reloadFont();
sendResize(win_size, state, cell_width, cell_height);
}
} }
fn colorFromVaxis(color: vaxis.Cell.Color) gpu.Color { fn colorFromVaxis(color: vaxis.Cell.Color) gpu.Color {
@ -174,6 +200,9 @@ fn wioLoop() void {
}; };
defer gpu.deinit(); defer gpu.deinit();
// Load the initial font on the wio thread (gpu.init must be done first).
reloadFont();
var state = gpu.WindowState.init(); var state = gpu.WindowState.init();
defer state.deinit(); defer state.deinit();
@ -194,6 +223,9 @@ fn wioLoop() void {
while (running) { while (running) {
wio.wait(.{}); wio.wait(.{});
// Reload font if settings changed (font_dirty set by TUI thread).
maybeReloadFont(win_size, &state, &cell_width, &cell_height);
while (window.getEvent()) |event| { while (window.getEvent()) |event| {
switch (event) { switch (event) {
.close => { .close => {
@ -209,11 +241,10 @@ fn wioLoop() void {
if (input_translate.mouseButtonId(btn)) |mb_id| { if (input_translate.mouseButtonId(btn)) |mb_id| {
const col: i32 = @intCast(mouse_pos.x); const col: i32 = @intCast(mouse_pos.x);
const row: i32 = @intCast(mouse_pos.y); const row: i32 = @intCast(mouse_pos.y);
const font = getFont(); const col_cell: i32 = @intCast(@divTrunc(col, wio_font.cell_size.x));
const col_cell: i32 = @intCast(@divTrunc(col, font.cell_size.x)); const row_cell: i32 = @intCast(@divTrunc(row, wio_font.cell_size.y));
const row_cell: i32 = @intCast(@divTrunc(row, font.cell_size.y)); const xoff: i32 = @intCast(@mod(col, wio_font.cell_size.x));
const xoff: i32 = @intCast(@mod(col, font.cell_size.x)); const yoff: i32 = @intCast(@mod(row, wio_font.cell_size.y));
const yoff: i32 = @intCast(@mod(row, font.cell_size.y));
tui_pid.send(.{ tui_pid.send(.{
"RDR", "B", "RDR", "B",
@as(u8, 1), // press @as(u8, 1), // press
@ -225,14 +256,14 @@ fn wioLoop() void {
}) catch {}; }) catch {};
} else { } else {
const cp = input_translate.codepointFromButton(btn, mods); const cp = input_translate.codepointFromButton(btn, mods);
sendKey(1, cp, cp, mods); if (cp != 0) sendKey(1, cp, cp, mods);
} }
}, },
.button_repeat => |btn| { .button_repeat => |btn| {
const mods = input_translate.Mods.fromButtons(held_buttons); const mods = input_translate.Mods.fromButtons(held_buttons);
if (input_translate.mouseButtonId(btn) == null) { if (input_translate.mouseButtonId(btn) == null) {
const cp = input_translate.codepointFromButton(btn, mods); const cp = input_translate.codepointFromButton(btn, mods);
sendKey(2, cp, cp, mods); if (cp != 0) sendKey(2, cp, cp, mods);
} }
}, },
.button_release => |btn| { .button_release => |btn| {
@ -241,14 +272,13 @@ fn wioLoop() void {
if (input_translate.mouseButtonId(btn)) |mb_id| { if (input_translate.mouseButtonId(btn)) |mb_id| {
const col: i32 = @intCast(mouse_pos.x); const col: i32 = @intCast(mouse_pos.x);
const row: i32 = @intCast(mouse_pos.y); const row: i32 = @intCast(mouse_pos.y);
const font = getFont(); const col_cell: i32 = @intCast(@divTrunc(col, wio_font.cell_size.x));
const col_cell: i32 = @intCast(@divTrunc(col, font.cell_size.x)); const row_cell: i32 = @intCast(@divTrunc(row, wio_font.cell_size.y));
const row_cell: i32 = @intCast(@divTrunc(row, font.cell_size.y)); const xoff: i32 = @intCast(@mod(col, wio_font.cell_size.x));
const xoff: i32 = @intCast(@mod(col, font.cell_size.x)); const yoff: i32 = @intCast(@mod(row, wio_font.cell_size.y));
const yoff: i32 = @intCast(@mod(row, font.cell_size.y));
tui_pid.send(.{ tui_pid.send(.{
"RDR", "B", "RDR", "B",
@as(u8, 0), // release @as(u8, 3), // release
mb_id, mb_id,
col_cell, col_cell,
row_cell, row_cell,
@ -257,7 +287,7 @@ fn wioLoop() void {
}) catch {}; }) catch {};
} else { } else {
const cp = input_translate.codepointFromButton(btn, mods); const cp = input_translate.codepointFromButton(btn, mods);
sendKey(3, cp, cp, mods); if (cp != 0) sendKey(3, cp, cp, mods);
} }
}, },
.char => |cp| { .char => |cp| {
@ -266,11 +296,10 @@ fn wioLoop() void {
}, },
.mouse => |pos| { .mouse => |pos| {
mouse_pos = pos; mouse_pos = pos;
const font = getFont(); const col_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(pos.x)), wio_font.cell_size.x));
const col_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(pos.x)), font.cell_size.x)); const row_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(pos.y)), wio_font.cell_size.y));
const row_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(pos.y)), font.cell_size.y)); const xoff: i32 = @intCast(@mod(@as(i32, @intCast(pos.x)), wio_font.cell_size.x));
const xoff: i32 = @intCast(@mod(@as(i32, @intCast(pos.x)), font.cell_size.x)); const yoff: i32 = @intCast(@mod(@as(i32, @intCast(pos.y)), wio_font.cell_size.y));
const yoff: i32 = @intCast(@mod(@as(i32, @intCast(pos.y)), font.cell_size.y));
tui_pid.send(.{ tui_pid.send(.{
"RDR", "M", "RDR", "M",
col_cell, row_cell, col_cell, row_cell,
@ -279,33 +308,39 @@ fn wioLoop() void {
}, },
.scroll_vertical => |dy| { .scroll_vertical => |dy| {
const btn_id: u8 = if (dy < 0) 64 else 65; // up / down scroll const btn_id: u8 = if (dy < 0) 64 else 65; // up / down scroll
const font = getFont(); const col_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(mouse_pos.x)), wio_font.cell_size.x));
const col_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(mouse_pos.x)), font.cell_size.x)); const row_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(mouse_pos.y)), wio_font.cell_size.y));
const row_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(mouse_pos.y)), font.cell_size.y));
tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, col_cell, row_cell, @as(i32, 0), @as(i32, 0) }) catch {}; tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, col_cell, row_cell, @as(i32, 0), @as(i32, 0) }) catch {};
}, },
else => {}, else => {},
} }
} }
// Paint if the tui pushed new screen data // Paint if the tui pushed new screen data.
// Take ownership of the snap (set screen_snap = null under the mutex)
// so the TUI thread cannot free the backing memory while we use it.
if (screen_pending.swap(false, .acq_rel)) { if (screen_pending.swap(false, .acq_rel)) {
screen_mutex.lock(); screen_mutex.lock();
const snap = screen_snap; const snap = screen_snap;
screen_snap = null; // wio thread now owns this allocation
screen_mutex.unlock(); screen_mutex.unlock();
if (snap) |s| { if (snap) |s| {
defer {
allocator.free(s.cells);
allocator.free(s.codepoints);
}
state.size = .{ .x = win_size.width, .y = win_size.height }; state.size = .{ .x = win_size.width, .y = win_size.height };
const font = s.font; const font = wio_font;
// Regenerate glyph indices using the GPU state // Regenerate glyph indices using the GPU state
const cells_with_glyphs = allocator.alloc(gpu.Cell, s.cells.len) catch continue; const cells_with_glyphs = allocator.alloc(gpu.Cell, s.cells.len) catch continue;
defer allocator.free(cells_with_glyphs); defer allocator.free(cells_with_glyphs);
@memcpy(cells_with_glyphs, s.cells); @memcpy(cells_with_glyphs, s.cells);
for (cells_with_glyphs) |*cell| { for (cells_with_glyphs, s.codepoints) |*cell, cp| {
// TODO: carry codepoint/width from the vaxis screen snapshot. cell.glyph_index = state.generateGlyph(font, cp, .single);
cell.glyph_index = state.generateGlyph(font, ' ', .single);
} }
gpu.paint( gpu.paint(
@ -332,9 +367,8 @@ fn sendResize(
cell_width: *u16, cell_width: *u16,
cell_height: *u16, cell_height: *u16,
) void { ) void {
const font = getFont(); cell_width.* = @intCast(@divTrunc(sz.width, wio_font.cell_size.x));
cell_width.* = @intCast(@divTrunc(sz.width, font.cell_size.x)); cell_height.* = @intCast(@divTrunc(sz.height, wio_font.cell_size.y));
cell_height.* = @intCast(@divTrunc(sz.height, font.cell_size.y));
state.size = .{ .x = sz.width, .y = sz.height }; state.size = .{ .x = sz.width, .y = sz.height };
tui_pid.send(.{ tui_pid.send(.{
"RDR", "Resize", "RDR", "Resize",