summaryrefslogtreecommitdiff
path: root/src/gui
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 13:13:53 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit487434a0b55fa7cf479f39481b51da0a7eda79e1 (patch)
treece410e0c52eb030e1de76079834e6697975c9c17 /src/gui
parentd59397d4146ffa23adbbe6dc99c64ebb22a93b2d (diff)
downloadpardes-487434a0b55fa7cf479f39481b51da0a7eda79e1.tar.gz
pardes-487434a0b55fa7cf479f39481b51da0a7eda79e1.zip
gui: draw image surfaces as GPU quads via glsl shaders
Diffstat (limited to 'src/gui')
-rw-r--r--src/gui/gui.zig265
1 files changed, 262 insertions, 3 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index a26fdd1c..c08c27fe 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -54,6 +54,8 @@ const vert_spv = if (is_emscripten) "" else @embedFile("ui.vert.spv");
const frag_spv = if (is_emscripten) "" else @embedFile("ui.frag.spv");
const overlay_vert_spv = if (is_emscripten) "" else @embedFile("overlay.vert.spv");
const overlay_frag_spv = if (is_emscripten) "" else @embedFile("overlay.frag.spv");
+const image_vert_spv = if (is_emscripten) "" else @embedFile("image.vert.spv");
+const image_frag_spv = if (is_emscripten) "" else @embedFile("image.frag.spv");
const crt_vert_spv = if (is_emscripten) "" else @embedFile("crt.vert.spv");
const crt_frag_spv = if (is_emscripten) "" else @embedFile("crt.frag.spv");
// web: GL ES shader SOURCES, compiled by WebGL at init (no SPIR-V there)
@@ -184,6 +186,22 @@ const CellInstance = extern struct {
const OverlayVertex = extern struct { x: f32, y: f32, r: f32, g: f32, b: f32, a: f32 };
const OverlayColor = struct { r: f32, g: f32, b: f32, a: f32 };
+const ImageInstance = extern struct {
+ x0: f32,
+ y0: f32,
+ x1: f32,
+ y1: f32,
+ u0: f32,
+ v0: f32,
+ u1: f32,
+ v1: f32,
+};
+
+const NativeImage = struct {
+ serial: u32,
+ texture: *c.SDL_GPUTexture,
+};
+
// ---- touch: per-finger tracking + shared scroll/tap machines ----
const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 };
@@ -812,6 +830,7 @@ const Gui = struct {
swapchain_format: c.SDL_GPUTextureFormat,
pipeline: *c.SDL_GPUGraphicsPipeline,
overlay_pipeline: *c.SDL_GPUGraphicsPipeline,
+ image_pipeline: *c.SDL_GPUGraphicsPipeline,
crt_pipeline: *c.SDL_GPUGraphicsPipeline,
atlas_tex: *c.SDL_GPUTexture,
atlas_sampler: *c.SDL_GPUSampler,
@@ -822,6 +841,9 @@ const Gui = struct {
overlay_vbuf: *c.SDL_GPUBuffer,
overlay_vxfer: *c.SDL_GPUTransferBuffer,
overlay_vertices: []OverlayVertex,
+ image_vbuf: *c.SDL_GPUBuffer,
+ image_vxfer: *c.SDL_GPUTransferBuffer,
+ native_images: [pardes.MAX_PANES]?NativeImage = @splat(null),
font: *c.UIFont,
/// the file behind `font`, when it is one the Font builtin loaded. Empty
@@ -1052,6 +1074,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
const pipeline = try makePipeline(device, swapchain_format);
const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format);
+ const image_pipeline = try makeImagePipeline(device, swapchain_format);
const crt_pipeline = try makeCrtPipeline(device, swapchain_format);
const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex));
@@ -1060,6 +1083,12 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
var ovx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = overlay_buffer_size, .props = 0 };
const overlay_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ovx_info) orelse return error.GpuCreate;
+ const image_buffer_size: u32 = @intCast(pardes.MAX_PANES * @sizeOf(ImageInstance));
+ var ivb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = image_buffer_size, .props = 0 };
+ const image_vbuf = c.SDL_CreateGPUBuffer(device, &ivb_info) orelse return error.GpuCreate;
+ var ivx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = image_buffer_size, .props = 0 };
+ const image_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ivx_info) orelse return error.GpuCreate;
+
const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h);
defer gpa.free(atlas_stage);
@memset(atlas_stage, 0);
@@ -1072,6 +1101,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.swapchain_format = swapchain_format,
.pipeline = pipeline,
.overlay_pipeline = overlay_pipeline,
+ .image_pipeline = image_pipeline,
.crt_pipeline = crt_pipeline,
.atlas_tex = atlas_tex,
.atlas_sampler = atlas_sampler,
@@ -1079,6 +1109,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.overlay_vbuf = overlay_vbuf,
.overlay_vxfer = overlay_vxfer,
.overlay_vertices = overlay_vertices,
+ .image_vbuf = image_vbuf,
+ .image_vxfer = image_vxfer,
.font = font,
.px = px,
.scale = scale,
@@ -1091,6 +1123,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.capture_dir = capture_dir orelse "",
};
defer g.glyphs.deinit();
+ defer clearNativeImages(&g);
+ defer c.SDL_ReleaseGPUTransferBuffer(device, image_vxfer);
+ defer c.SDL_ReleaseGPUBuffer(device, image_vbuf);
+ defer c.SDL_ReleaseGPUGraphicsPipeline(device, image_pipeline);
defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran
defer gpa.free(g.scroll_edge); // grown on demand by stepScroll
// slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only)
@@ -1121,8 +1157,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
} else try pardes.Pardes.init(gpa, opts);
defer core.deinit();
- // core.kitty_ok stays false: no kitty graphics in SDL, petscii covers images.
- // ponytail: Surface.images pixel attachments could blit as textures later.
+ // SDL is itself a native-pixel backend. This is deliberately set after
+ // construction: argv image panes no longer freeze the startup capability
+ // into their PETSCII preference, so their first render emits attachments.
+ core.native_images = true;
// shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move)
writeFile(pardes.bash_rc);
@@ -1231,6 +1269,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// `watch off` effects go into a queue nobody drains — drop the lot
// here. The new core emits its own `on`s as it builds its panes.
for (0..watches.len) |wid| watchPane(inotify_fd, &watches, @intCast(wid), null, 0);
+ clearNativeImages(&g);
+ nc.native_images = true;
core.deinit();
core = nc;
}
@@ -1481,6 +1521,7 @@ fn runWeb(opts_in: pardes.Options) !void {
.swapchain_format = undefined,
.pipeline = undefined,
.overlay_pipeline = undefined,
+ .image_pipeline = undefined,
.crt_pipeline = undefined,
.atlas_tex = undefined,
.atlas_sampler = undefined,
@@ -1488,6 +1529,8 @@ fn runWeb(opts_in: pardes.Options) !void {
.overlay_vbuf = undefined,
.overlay_vxfer = undefined,
.overlay_vertices = overlay_vertices,
+ .image_vbuf = undefined,
+ .image_vxfer = undefined,
.gl_program = program,
.gl_overlay_program = overlay_program,
.gl_atlas_tex = atlas_tex,
@@ -2699,6 +2742,171 @@ fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect
// render: Surface → instanced quads → SDL GPU
// =====================================================================
+fn releaseNativeImage(g: *Gui, pane: usize) void {
+ if (g.native_images[pane]) |cached| {
+ c.SDL_ReleaseGPUTexture(g.device, cached.texture);
+ g.native_images[pane] = null;
+ }
+}
+
+fn clearNativeImages(g: *Gui) void {
+ for (0..g.native_images.len) |pane| releaseNativeImage(g, pane);
+}
+
+fn validImageBytes(place: pardes.ImagePlace) ?u32 {
+ if (place.iw == 0 or place.ih == 0 or
+ place.iw > std.math.maxInt(u32) or place.ih > std.math.maxInt(u32)) return null;
+ const pixels = std.math.mul(usize, place.iw, place.ih) catch return null;
+ const bytes = std.math.mul(usize, pixels, 4) catch return null;
+ if (place.rgba.len != bytes or bytes > std.math.maxInt(u32)) return null;
+ return @intCast(bytes);
+}
+
+fn uploadNativeTexture(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, place: pardes.ImagePlace) !void {
+ const byte_len = validImageBytes(place) orelse return error.BadImage;
+ const pane: usize = place.pane;
+ if (g.native_images[pane]) |cached| {
+ if (cached.serial == place.serial) return;
+ releaseNativeImage(g, pane);
+ }
+
+ var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
+ tex_info.type = c.SDL_GPU_TEXTURETYPE_2D;
+ tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM;
+ tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER;
+ tex_info.width = @intCast(place.iw);
+ tex_info.height = @intCast(place.ih);
+ tex_info.layer_count_or_depth = 1;
+ tex_info.num_levels = 1;
+ tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ const texture = c.SDL_CreateGPUTexture(g.device, &tex_info) orelse return error.GpuCreate;
+ errdefer c.SDL_ReleaseGPUTexture(g.device, texture);
+
+ var xf_info = c.SDL_GPUTransferBufferCreateInfo{
+ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD,
+ .size = byte_len,
+ .props = 0,
+ };
+ const transfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate;
+ errdefer c.SDL_ReleaseGPUTransferBuffer(g.device, transfer);
+ const mapped: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, transfer, false) orelse return error.GpuMap);
+ @memcpy(mapped[0..byte_len], place.rgba);
+ c.SDL_UnmapGPUTransferBuffer(g.device, transfer);
+
+ const copy = c.SDL_BeginGPUCopyPass(cmd);
+ const src = c.SDL_GPUTextureTransferInfo{
+ .transfer_buffer = transfer,
+ .offset = 0,
+ .pixels_per_row = @intCast(place.iw),
+ .rows_per_layer = @intCast(place.ih),
+ };
+ const dst = c.SDL_GPUTextureRegion{
+ .texture = texture,
+ .mip_level = 0,
+ .layer = 0,
+ .x = 0,
+ .y = 0,
+ .z = 0,
+ .w = @intCast(place.iw),
+ .h = @intCast(place.ih),
+ .d = 1,
+ };
+ c.SDL_UploadToGPUTexture(copy, &src, &dst, false);
+ c.SDL_EndGPUCopyPass(copy);
+ // SDL defers destruction until the submitted copy is done; the staging
+ // allocation is never needed again, so do not retain a second full image
+ // beside the texture for the life of the pane.
+ c.SDL_ReleaseGPUTransferBuffer(g.device, transfer);
+ g.native_images[pane] = .{ .serial = place.serial, .texture = texture };
+}
+
+/// Upload new pane generations and the small per-frame placement buffer.
+/// Returns the number of image instances drawNativeImagesGpu will consume.
+fn prepareNativeImages(
+ g: *Gui,
+ cmd: *c.SDL_GPUCommandBuffer,
+ surface: *pardes.Surface,
+ sw: u32,
+ sh: u32,
+) u32 {
+ var seen: [pardes.MAX_PANES]bool = @splat(false);
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ if (validImageBytes(place) == null) continue;
+ seen[place.pane] = true;
+ uploadNativeTexture(g, cmd, place) catch continue;
+ }
+ for (seen, 0..) |is_seen, pane| if (!is_seen) releaseNativeImage(g, pane);
+
+ var count: u32 = 0;
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ if (g.native_images[place.pane]) |cached| {
+ if (cached.serial == place.serial) count += 1;
+ }
+ }
+ if (count == 0) return 0;
+
+ const ptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.image_vxfer, false) orelse return 0);
+ const instances: [*]ImageInstance = @ptrCast(@alignCast(ptr));
+ const win_w: f32 = @floatFromInt(sw);
+ const win_h: f32 = @floatFromInt(sh);
+ var idx: u32 = 0;
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ const cached = g.native_images[place.pane] orelse continue;
+ if (cached.serial != place.serial) continue;
+
+ const bound_w = @as(u32, place.w) * g.cell_w;
+ const bound_h = @as(u32, place.h) * g.cell_h;
+ const fit = pardes.image.contain(place.iw, place.ih, bound_w, bound_h);
+ if (fit.w == 0 or fit.h == 0) continue;
+ const px0 = @as(u32, place.x) * g.cell_w;
+ const py0 = @as(u32, place.y) * g.cell_h;
+ const x0 = (@as(f32, @floatFromInt(px0)) / win_w) * 2.0 - 1.0;
+ const x1 = (@as(f32, @floatFromInt(px0 + fit.w)) / win_w) * 2.0 - 1.0;
+ const y0 = 1.0 - (@as(f32, @floatFromInt(py0)) / win_h) * 2.0;
+ const y1 = 1.0 - (@as(f32, @floatFromInt(py0 + fit.h)) / win_h) * 2.0;
+ instances[idx] = .{
+ .x0 = x0,
+ .y0 = y0,
+ .x1 = x1,
+ .y1 = y1,
+ .u0 = 0,
+ .v0 = 0,
+ .u1 = 1,
+ .v1 = 1,
+ };
+ idx += 1;
+ }
+ c.SDL_UnmapGPUTransferBuffer(g.device, g.image_vxfer);
+ if (idx == 0) return 0;
+
+ const copy = c.SDL_BeginGPUCopyPass(cmd);
+ const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.image_vxfer, .offset = 0 };
+ const dst = c.SDL_GPUBufferRegion{ .buffer = g.image_vbuf, .offset = 0, .size = idx * @sizeOf(ImageInstance) };
+ c.SDL_UploadToGPUBuffer(copy, &src, &dst, false);
+ c.SDL_EndGPUCopyPass(copy);
+ return idx;
+}
+
+fn drawNativeImagesGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surface) void {
+ const pass = rp orelse return;
+ c.SDL_BindGPUGraphicsPipeline(pass, g.image_pipeline);
+ var idx: u32 = 0;
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ const cached = g.native_images[place.pane] orelse continue;
+ if (cached.serial != place.serial) continue;
+ const sampler = c.SDL_GPUTextureSamplerBinding{ .texture = cached.texture, .sampler = g.atlas_sampler };
+ c.SDL_BindGPUFragmentSamplers(pass, 0, &sampler, 1);
+ const binding = c.SDL_GPUBufferBinding{ .buffer = g.image_vbuf, .offset = idx * @sizeOf(ImageInstance) };
+ c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1);
+ c.SDL_DrawGPUPrimitives(pass, 6, 1, 0, 0);
+ idx += 1;
+ }
+}
+
fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on: bool, debug_on: bool) !void {
const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return;
var sw: u32 = 0;
@@ -2735,6 +2943,7 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on
const overlay_count = buildTouchOverlay(g, sw, sh, debug_on);
if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count);
+ const image_count = prepareNativeImages(g, cmd, surface, sw, sh);
const cells: u32 = @as(u32, surface.cols) * surface.rows;
// ponytail: ×2, because the smooth-scroll pane is drawn a second time,
@@ -2805,11 +3014,20 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on
const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) };
c.SDL_SetGPUScissor(rp, &whole);
}
- drawOverlayGpu(g, rp, overlay_count);
c.SDL_EndGPURenderPass(rp);
} else {
// no grid yet: just clear to the page bg
const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null);
+ c.SDL_EndGPURenderPass(rp);
+ }
+
+ // Images belong above the opaque cell backgrounds and below interaction
+ // overlays. A second load pass expresses that ordering without teaching
+ // the canonical cell surface about alpha or backend textures.
+ if (image_count != 0 or overlay_count != 0) {
+ color_target.load_op = c.SDL_GPU_LOADOP_LOAD;
+ const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null);
+ if (image_count != 0) drawNativeImagesGpu(g, rp, surface);
drawOverlayGpu(g, rp, overlay_count);
c.SDL_EndGPURenderPass(rp);
}
@@ -3114,6 +3332,47 @@ fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureF
return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
}
+fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
+ const vs = try makeShader(device, image_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0);
+ defer c.SDL_ReleaseGPUShader(device, vs);
+ const fs = try makeShader(device, image_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1);
+ defer c.SDL_ReleaseGPUShader(device, fs);
+
+ var vbuf_desc = c.SDL_GPUVertexBufferDescription{
+ .slot = 0,
+ .pitch = @sizeOf(ImageInstance),
+ .input_rate = c.SDL_GPU_VERTEXINPUTRATE_INSTANCE,
+ .instance_step_rate = 0,
+ };
+ var attrs = [_]c.SDL_GPUVertexAttribute{
+ .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "x0") },
+ .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "u0") },
+ };
+ var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState);
+ blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA;
+ blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
+ blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD;
+ blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE;
+ blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
+ blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD;
+ blend.enable_blend = true;
+ var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend };
+ var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
+ info.vertex_shader = vs;
+ info.fragment_shader = fs;
+ info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
+ info.vertex_input_state.vertex_buffer_descriptions = &vbuf_desc;
+ info.vertex_input_state.num_vertex_buffers = 1;
+ info.vertex_input_state.vertex_attributes = &attrs;
+ info.vertex_input_state.num_vertex_attributes = attrs.len;
+ info.target_info.color_target_descriptions = &col_desc;
+ info.target_info.num_color_targets = 1;
+ info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL;
+ info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE;
+ info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
+}
+
// the CRT pass: a fullscreen triangle sampling the scene texture, no vertex
// buffers at all (positions from gl_VertexIndex)
fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {