From 487434a0b55fa7cf479f39481b51da0a7eda79e1 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 13:13:53 -0300 Subject: gui: draw image surfaces as GPU quads via glsl shaders --- src/gui/gui.zig | 265 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 262 insertions(+), 3 deletions(-) (limited to 'src/gui') 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 { -- cgit v1.3