flow/src/gui/gpu/gpu.zig

490 lines
17 KiB
Zig

// sokol_gfx GPU backend for the wio-based renderer.
//
// Threading: all public functions must be called from the wio/GL thread
// (the thread that called sg.setup()).
const std = @import("std");
const sg = @import("sokol").gfx;
const Rasterizer = @import("rasterizer");
const GlyphIndexCache = @import("GlyphIndexCache");
const gui_cell = @import("Cell");
const XY = @import("xy").XY;
const builtin_shader = @import("builtin.glsl.zig");
pub const Font = Rasterizer.Font;
pub const GlyphKind = Rasterizer.GlyphKind;
pub const RasterizerBackend = Rasterizer.Backend;
pub const Cell = gui_cell.Cell;
pub const Color = gui_cell.Rgba8;
const Rgba8 = gui_cell.Rgba8;
pub const CursorShape = enum(i32) { block = 0, beam = 1, underline = 2 };
pub const CursorInfo = struct {
vis: bool = false,
row: u16 = 0,
col: u16 = 0,
shape: CursorShape = .block,
color: Color = Color.initRgb(255, 255, 255),
};
const log = std.log.scoped(.gpu);
// Maximum glyph atlas dimension. 4096 is universally supported and gives
// 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) {
return .{
.x = @intCast(@divTrunc(max_atlas_dim, cell_size.x)),
.y = @intCast(@divTrunc(max_atlas_dim, cell_size.y)),
};
}
// Shader cell layout for the RGBA32UI cell texture.
// Each texel encodes one terminal cell:
// .r = glyph_index (u32)
// .g = bg packed (Rgba8 bit-cast to u32: r<<24|g<<16|b<<8|a)
// .b = fg packed (same)
// .a = 0 (reserved)
const ShaderCell = extern struct {
glyph_index: u32,
bg: u32,
fg: u32,
_pad: u32 = 0,
};
const global = struct {
var init_called: bool = false;
var rasterizer: Rasterizer = undefined;
var pip: sg.Pipeline = .{};
var glyph_sampler: sg.Sampler = .{};
var cell_sampler: sg.Sampler = .{};
var glyph_cache_arena: std.heap.ArenaAllocator = undefined;
var background: Rgba8 = .{ .r = 19, .g = 19, .b = 19, .a = 255 };
};
pub fn init(allocator: std.mem.Allocator) !void {
std.debug.assert(!global.init_called);
global.init_called = true;
global.rasterizer = try Rasterizer.init(allocator);
global.glyph_cache_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
// Build shader + pipeline
const shd = sg.makeShader(builtin_shader.shaderDesc(sg.queryBackend()));
var pip_desc: sg.PipelineDesc = .{ .shader = shd };
pip_desc.primitive_type = .TRIANGLE_STRIP;
pip_desc.color_count = 1;
global.pip = sg.makePipeline(pip_desc);
// Nearest-neighbour samplers (no filtering)
global.glyph_sampler = sg.makeSampler(.{
.min_filter = .NEAREST,
.mag_filter = .NEAREST,
.mipmap_filter = .NEAREST,
.wrap_u = .CLAMP_TO_EDGE,
.wrap_v = .CLAMP_TO_EDGE,
});
global.cell_sampler = sg.makeSampler(.{
.min_filter = .NEAREST,
.mag_filter = .NEAREST,
.mipmap_filter = .NEAREST,
.wrap_u = .CLAMP_TO_EDGE,
.wrap_v = .CLAMP_TO_EDGE,
});
}
pub fn deinit() void {
std.debug.assert(global.init_called);
global.init_called = false;
sg.destroyPipeline(global.pip);
sg.destroySampler(global.glyph_sampler);
sg.destroySampler(global.cell_sampler);
global.glyph_cache_arena.deinit();
global.rasterizer.deinit();
}
pub fn loadFont(name: []const u8, size_px: u16) !Font {
return global.rasterizer.loadFont(name, size_px);
}
pub fn setRasterizerBackend(backend: RasterizerBackend) void {
global.rasterizer.setBackend(backend);
}
pub fn setFontWeight(font: *Font, w: u8) void {
Rasterizer.setFontWeight(font, w);
}
pub fn setBackground(color: Rgba8) void {
global.background = color;
}
// ── WindowState ────────────────────────────────────────────────────────────
pub const WindowState = struct {
// GL window size in pixels
size: XY(u32) = .{ .x = 0, .y = 0 },
// Glyph atlas (R8 2D texture + view)
glyph_image: sg.Image = .{},
glyph_view: sg.View = .{},
glyph_image_size: XY(u16) = .{ .x = 0, .y = 0 },
// Cell grid (RGBA32UI 2D texture + view), updated each frame
cell_image: sg.Image = .{},
cell_view: sg.View = .{},
cell_image_size: XY(u16) = .{ .x = 0, .y = 0 },
cell_buf: std.ArrayListUnmanaged(ShaderCell) = .{},
// Glyph index cache
glyph_cache_cell_size: ?XY(u16) = 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 {
std.debug.assert(global.init_called);
return .{};
}
pub fn deinit(state: *WindowState) void {
if (state.glyph_view.id != 0) sg.destroyView(state.glyph_view);
if (state.glyph_image.id != 0) sg.destroyImage(state.glyph_image);
if (state.cell_view.id != 0) sg.destroyView(state.cell_view);
if (state.cell_image.id != 0) sg.destroyImage(state.cell_image);
state.cell_buf.deinit(global.glyph_cache_arena.allocator());
if (state.glyph_index_cache) |*c| {
c.deinit(global.glyph_cache_arena.allocator());
}
state.* = undefined;
}
// Ensure the glyph atlas image is (at least) the requested pixel size.
// Returns true if the image was retained, false if (re)created.
fn updateGlyphImage(state: *WindowState, pixel_size: XY(u16)) bool {
if (state.glyph_image_size.eql(pixel_size)) return true;
if (state.glyph_view.id != 0) sg.destroyView(state.glyph_view);
if (state.glyph_image.id != 0) sg.destroyImage(state.glyph_image);
state.glyph_image = sg.makeImage(.{
.width = pixel_size.x,
.height = pixel_size.y,
.pixel_format = .R8,
.usage = .{ .dynamic_update = true },
});
state.glyph_view = sg.makeView(.{
.texture = .{ .image = state.glyph_image },
});
state.glyph_image_size = pixel_size;
return false;
}
// Ensure the cell texture is the requested size.
fn updateCellImage(state: *WindowState, allocator: std.mem.Allocator, cols: u16, rows: u16) void {
const needed: u32 = @as(u32, cols) * @as(u32, rows);
const sz: XY(u16) = .{ .x = cols, .y = rows };
if (!state.cell_image_size.eql(sz)) {
if (state.cell_view.id != 0) sg.destroyView(state.cell_view);
if (state.cell_image.id != 0) sg.destroyImage(state.cell_image);
state.cell_image = sg.makeImage(.{
.width = cols,
.height = rows,
.pixel_format = .RGBA32UI,
.usage = .{ .dynamic_update = true },
});
state.cell_view = sg.makeView(.{
.texture = .{ .image = state.cell_image },
});
state.cell_image_size = sz;
}
if (state.cell_buf.items.len < needed) {
state.cell_buf.resize(allocator, needed) catch |e| oom(e);
}
}
pub fn generateGlyph(
state: *WindowState,
font: Font,
codepoint: u21,
kind: Rasterizer.GlyphKind,
) u32 {
const atlas_cell_count = getAtlasCellCount(font.cell_size);
const atlas_total: u32 = @as(u32, atlas_cell_count.x) * @as(u32, atlas_cell_count.y);
const atlas_pixel_size: XY(u16) = .{
.x = atlas_cell_count.x * font.cell_size.x,
.y = atlas_cell_count.y * font.cell_size.y,
};
const atlas_retained = state.updateGlyphImage(atlas_pixel_size);
const cache_valid = if (state.glyph_cache_cell_size) |s| s.eql(font.cell_size) else false;
state.glyph_cache_cell_size = font.cell_size;
if (!atlas_retained or !cache_valid) {
if (state.glyph_index_cache) |*c| {
c.deinit(global.glyph_cache_arena.allocator());
_ = global.glyph_cache_arena.reset(.retain_capacity);
state.glyph_index_cache = null;
// cell_buf was allocated from the arena; clear it so the next
// resize doesn't memcpy from the now-freed memory.
state.cell_buf = .{};
}
}
const cache = blk: {
if (state.glyph_index_cache) |*c| break :blk c;
state.glyph_index_cache = GlyphIndexCache.init(
global.glyph_cache_arena.allocator(),
atlas_total,
) catch |e| oom(e);
break :blk &(state.glyph_index_cache.?);
};
const right_half: bool = switch (kind) {
.single, .left => false,
.right => true,
};
switch (cache.reserve(
global.glyph_cache_arena.allocator(),
codepoint,
right_half,
) catch |e| oom(e)) {
.newly_reserved => |reserved| {
// Rasterize into a staging buffer then upload the relevant
// portion to the atlas.
const staging_w: u32 = @as(u32, font.cell_size.x) * 2;
const staging_h: u32 = font.cell_size.y;
var staging_buf = global.glyph_cache_arena.allocator().alloc(
u8,
staging_w * staging_h,
) catch |e| oom(e);
defer global.glyph_cache_arena.allocator().free(staging_buf);
@memset(staging_buf, 0);
global.rasterizer.render(font, codepoint, kind, staging_buf);
// Atlas cell position for this glyph index
const atlas_col: u16 = @intCast(reserved.index % atlas_cell_count.x);
const atlas_row: u16 = @intCast(reserved.index / atlas_cell_count.x);
const atlas_x: u16 = atlas_col * font.cell_size.x;
const atlas_y: u16 = atlas_row * font.cell_size.y;
// Source region in the staging buffer
const src_x: u16 = if (right_half) font.cell_size.x else 0;
const glyph_w: u16 = font.cell_size.x;
const glyph_h: u16 = font.cell_size.y;
// Build a sub-region buffer for sokol updateImage
var region_buf = global.glyph_cache_arena.allocator().alloc(
u8,
@as(u32, glyph_w) * @as(u32, glyph_h),
) catch |e| oom(e);
defer global.glyph_cache_arena.allocator().free(region_buf);
for (0..glyph_h) |row_i| {
const src_off = row_i * staging_w + src_x;
const dst_off = row_i * glyph_w;
@memcpy(region_buf[dst_off .. dst_off + glyph_w], staging_buf[src_off .. src_off + glyph_w]);
}
// Write into the CPU-side atlas shadow. The GPU upload is
// deferred to paint() so it happens at most once per frame.
blitAtlasCpu(state, atlas_x, atlas_y, glyph_w, glyph_h, region_buf);
state.glyph_atlas_dirty = true;
return reserved.index;
},
.already_reserved => |index| return index,
}
}
};
// CPU-side shadow copy of the glyph atlas (R8, row-major).
// Kept alive for the process lifetime; resized when the atlas image grows.
var atlas_cpu: ?[]u8 = null;
var atlas_cpu_size: XY(u16) = .{ .x = 0, .y = 0 };
// Blit one glyph cell into the CPU-side atlas shadow.
fn blitAtlasCpu(
state: *const WindowState,
x: u16,
y: u16,
w: u16,
h: u16,
pixels: []const u8,
) void {
const asz = state.glyph_image_size;
const total: usize = @as(usize, asz.x) * @as(usize, asz.y);
if (!atlas_cpu_size.eql(asz)) {
if (atlas_cpu) |old| std.heap.page_allocator.free(old);
atlas_cpu = std.heap.page_allocator.alloc(u8, total) catch |e| oom(e);
@memset(atlas_cpu.?, 0);
atlas_cpu_size = asz;
}
const buf = atlas_cpu.?;
for (0..h) |row_i| {
const src_off = row_i * w;
const dst_off = (@as(usize, y) + row_i) * asz.x + x;
@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;
var img_data: sg.ImageData = .{};
img_data.mip_levels[0] = .{ .ptr = buf.ptr, .size = total };
sg.updateImage(state.glyph_image, img_data);
state.glyph_atlas_dirty = false;
}
pub fn paint(
state: *WindowState,
client_size: XY(u32),
font: Font,
row_count: u16,
col_count: u16,
top: u16,
cells: []const Cell,
cursor: CursorInfo,
secondary_cursors: []const CursorInfo,
) void {
const shader_col_count: u16 = @intCast(@divTrunc(client_size.x, font.cell_size.x));
const shader_row_count: u16 = @intCast(@divTrunc(client_size.y, font.cell_size.y));
const copy_col_count: u16 = @min(col_count, shader_col_count);
const blank_glyph_index = state.generateGlyph(font, ' ', .single);
const alloc = global.glyph_cache_arena.allocator();
state.updateCellImage(alloc, shader_col_count, shader_row_count);
const shader_cells = state.cell_buf.items[0 .. @as(u32, shader_col_count) * @as(u32, shader_row_count)];
for (0..shader_row_count) |row_i| {
const src_row = blk: {
const r = top + @as(u16, @intCast(row_i));
break :blk if (r < row_count) r else 0;
};
const src_row_offset = @as(usize, src_row) * col_count;
const dst_row_offset = @as(usize, row_i) * shader_col_count;
const copy_len = if (row_i < row_count) copy_col_count else 0;
for (0..copy_len) |ci| {
const src = cells[src_row_offset + ci];
shader_cells[dst_row_offset + ci] = .{
.glyph_index = src.glyph_index,
.bg = @bitCast(src.background),
.fg = @bitCast(src.foreground),
};
}
for (copy_len..shader_col_count) |ci| {
shader_cells[dst_row_offset + ci] = .{
.glyph_index = blank_glyph_index,
.bg = @bitCast(global.background),
.fg = @bitCast(global.background),
};
}
}
// Mark secondary cursor cells in the _pad field (read by fragment shader).
for (secondary_cursors) |sc| {
if (!sc.vis) continue;
if (sc.row >= shader_row_count or sc.col >= shader_col_count) continue;
shader_cells[@as(usize, sc.row) * shader_col_count + sc.col]._pad = 1;
}
// Upload glyph atlas to GPU if any new glyphs were rasterized this frame.
if (state.glyph_atlas_dirty) flushGlyphAtlas(state);
// Upload cell texture
var cell_data: sg.ImageData = .{};
const cell_bytes = std.mem.sliceAsBytes(shader_cells);
cell_data.mip_levels[0] = .{ .ptr = cell_bytes.ptr, .size = cell_bytes.len };
sg.updateImage(state.cell_image, cell_data);
// Render pass
var pass_action: sg.PassAction = .{};
pass_action.colors[0] = .{
.load_action = .CLEAR,
.clear_value = .{
.r = @as(f32, @floatFromInt(global.background.r)) / 255.0,
.g = @as(f32, @floatFromInt(global.background.g)) / 255.0,
.b = @as(f32, @floatFromInt(global.background.b)) / 255.0,
.a = 1.0,
},
};
sg.beginPass(.{
.swapchain = .{
.width = @intCast(client_size.x),
.height = @intCast(client_size.y),
.sample_count = 1,
.color_format = .RGBA8,
.depth_format = .NONE,
.gl = .{ .framebuffer = 0 },
},
.action = pass_action,
});
sg.applyPipeline(global.pip);
var bindings: sg.Bindings = .{};
bindings.views[0] = state.glyph_view;
bindings.views[1] = state.cell_view;
bindings.samplers[0] = global.glyph_sampler;
bindings.samplers[1] = global.cell_sampler;
sg.applyBindings(bindings);
const sec_color: Color = if (secondary_cursors.len > 0)
secondary_cursors[0].color
else
Color.initRgb(255, 255, 255);
const fs_params = builtin_shader.FsParams{
.cell_size_x = font.cell_size.x,
.cell_size_y = font.cell_size.y,
.col_count = shader_col_count,
.row_count = shader_row_count,
.viewport_height = @intCast(client_size.y),
.cursor_col = cursor.col,
.cursor_row = cursor.row,
.cursor_shape = @intFromEnum(cursor.shape),
.cursor_vis = if (cursor.vis) 1 else 0,
.cursor_color = colorToVec4(cursor.color),
.sec_cursor_color = colorToVec4(sec_color),
};
sg.applyUniforms(0, .{
.ptr = &fs_params,
.size = @sizeOf(builtin_shader.FsParams),
});
sg.draw(0, 4, 1);
sg.endPass();
// Note: caller (app.zig) calls sg.commit() and window.swapBuffers()
}
fn colorToVec4(c: Color) [4]f32 {
return .{
@as(f32, @floatFromInt(c.r)) / 255.0,
@as(f32, @floatFromInt(c.g)) / 255.0,
@as(f32, @floatFromInt(c.b)) / 255.0,
@as(f32, @floatFromInt(c.a)) / 255.0,
};
}
fn oom(e: error{OutOfMemory}) noreturn {
@panic(@errorName(e));
}