//! The post chain (docs/render-pipeline.md §6): Shadertoy passes over the //! finished frame, each reading the one before it (the frame first) and the //! last one writing the window. A pass is the bundled Crt (shaders/post/, //! compiled with the build) or a user's file (Shader ), whose SPIR-V //! comes from shader_build.zig: compiled by the process that holds the core, //! this one or the detached session this GUI is attached to. A file whose //! compile fails keeps its last good pipeline. //! //! The uniforms are ghostty's (Uniforms), so a shader written for ghostty runs //! here. Their clock is the shell's and moves only when the chain draws: //! with a frame of the core's, or on its own while ShaderAnimation asks for //! it (redraw level A, §5.5: the chain alone, over the retained frame). const std = @import("std"); const libc = std.c; const ghostty_vt = @import("ghostty-vt"); const pardes = @import("../pardes.zig"); const gui = @import("gui.zig"); const c = gui.c; const Post = @This(); const Scene = pardes.config.Runtime.Scene; const Chain = pardes.config.Runtime.Post; const Pipeline = c.SDL_GPUGraphicsPipeline; pub const max = Chain.max; const prefix = @embedFile("post-prefix.glsl"); const Built = @import("../shader_build.zig").Pass; const vert_spv = @embedFile("post.vert.spv"); const bundled_spv = std.EnumArray(Scene, []const u8).init(.{ .crt = @embedFile("post-crt.frag.spv"), .bloom = @embedFile("post-bloom.frag.spv"), .vignette = @embedFile("post-vignette.frag.spv"), .grain = @embedFile("post-grain.frag.spv"), }); const bloom_down_spv = @embedFile("bloom-down.frag.spv"); const bloom_up_spv = @embedFile("bloom-up.frag.spv"); /// Bloom's steps below the frame, at levels 1 to 3: a half, a quarter, ... const bloom_steps = [_]u8{ 3, 4, 5 }; const bloom_format = c.SDL_GPU_TEXTUREFORMAT_R16G16B16A16_FLOAT; /// pardes's own block (shaders/post/prefix.glsl PardesPost), std140. pub const Own = extern struct { opaque_window: f32 = 1, level: f32 = 0, scale: f32 = 1, cap: f32 = 0, spare_count: f32 = 0, _pad: [3]f32 = @splat(0), spare: [max_spare][4]f32 = @splat(@splat(0)), }; pub const max_spare = 32; /// ghostty's Shadertoy uniform block, member for member in the GLSL block's /// order and std140 layout (shaders/post/prefix.glsl). Filled by the GLSL /// names: ghostty's own Zig mirror calls the second-to-last slot /// selection_background, so there the two selection colours arrive swapped /// (docs §6, open point 1); here each is what its GLSL name says. pub const Uniforms = extern struct { resolution: [3]f32 align(16) = .{ 0, 0, 1 }, time: f32 align(4) = 0, time_delta: f32 align(4) = 0, frame_rate: f32 align(4) = 60, frame: i32 align(4) = 0, channel_time: [4][4]f32 align(16) = @splat(@splat(0)), channel_resolution: [4][4]f32 align(16) = @splat(@splat(0)), mouse: [4]f32 align(16) = @splat(0), date: [4]f32 align(16) = @splat(0), sample_rate: f32 align(4) = 44100, current_cursor: [4]f32 align(16) = @splat(0), previous_cursor: [4]f32 align(16) = @splat(0), current_cursor_color: [4]f32 align(16) = @splat(0), previous_cursor_color: [4]f32 align(16) = @splat(0), current_cursor_style: i32 align(4) = 0, previous_cursor_style: i32 align(4) = 0, cursor_visible: i32 align(4) = 0, cursor_change_time: f32 align(4) = 0, time_focus: f32 align(4) = 0, focus: i32 align(4) = 1, palette: [256][4]f32 align(16) = @splat(@splat(0)), background_color: [4]f32 align(16) = @splat(0), foreground_color: [4]f32 align(16) = @splat(0), cursor_color: [4]f32 align(16) = @splat(0), cursor_text: [4]f32 align(16) = @splat(0), selection_foreground_color: [4]f32 align(16) = @splat(0), selection_background_color: [4]f32 align(16) = @splat(0), }; pub const Pass = struct { scene: ?Scene = null, /// A bundled pass's strength (pardesLevel); 0 for a file. level: u8 = 0, /// A user's file, owned; empty for a bundled pass. path: []u8 = &.{}, pipeline: ?*Pipeline = null, /// The SPIR-V revision `pipeline` was made from (shader_build.Pass). revision: u32 = 0, }; passes: [max]Pass = @splat(.{}), len: usize = 0, /// The chain key `passes` follow (sync); null before the first. key: ?u32 = null, bundled: std.EnumArray(Scene, ?*Pipeline) = .initFill(null), ping: [2]?*c.SDL_GPUTexture = .{ null, null }, ping_w: u32 = 0, ping_h: u32 = 0, uniforms: Uniforms = .{}, /// The display's density, for a pass whose pattern is in logical pixels /// (pardesScale); the shell sets it. scale: f32 = 1, /// What a bundled pass may do on this theme (pardesCap), each scene's, and /// the rectangles every bundled pass leaves alone (pardesSpare): both from /// the last frame described. caps: std.EnumArray(Scene, f32) = .initFill(0), spare: [max_spare][4]f32 = @splat(@splat(0)), spare_len: usize = 0, /// Bloom's float chain, a half the frame and down, and its two steps. bloom_tex: [bloom_steps[bloom_steps.len - 1]]?*c.SDL_GPUTexture = @splat(null), bloom_w: u32 = 0, bloom_h: u32 = 0, bloom_down: ?*Pipeline = null, bloom_up: ?*Pipeline = null, started_ns: ?u64 = null, last_ns: u64 = 0, /// Brings the passes to the chain a shell was given (shader_build.zig: a /// local core's, or an attached session's over the wire), whose `key` moves /// whenever the chain or a file's SPIR-V does. A file whose SPIR-V has not /// moved keeps its pipeline; one the GPU refuses keeps its last, and is said /// to `core`, or logged where there is none. Each loop step, off the frame /// path. pub fn sync(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, chain: []const Built, key: u32, core: ?*pardes.Pardes) void { if (post.key == key) return; post.key = key; var next: [max]Pass = @splat(.{}); for (chain, 0..) |entry, i| { if (entry.scene) |scene| { if (post.bundled.get(scene) == null) post.bundled.set(scene, makePipeline(device, format, bundled_spv.get(scene), if (scene == .bloom) 2 else 1, 4) catch null); if (scene == .bloom and post.bloom_down == null) { post.bloom_down = makePipeline(device, bloom_format, bloom_down_spv, 1, 1) catch null; post.bloom_up = makePipeline(device, bloom_format, bloom_up_spv, 1, 1) catch null; } next[i] = .{ .scene = scene, .level = entry.level, .pipeline = post.bundled.get(scene) }; continue; } // A file already in the chain keeps its pipeline and its path. for (post.passes[0..post.len]) |*pass| { if (pass.scene != null or !std.mem.eql(u8, pass.path, entry.path)) continue; next[i] = pass.*; pass.* = .{}; break; } else next[i] = .{ .path = gpa.dupe(u8, entry.path) catch &.{} }; const pass = &next[i]; if (pass.revision == entry.revision or entry.spirv.len == 0) continue; pass.revision = entry.revision; const made = makePipeline(device, format, entry.spirv, 1, 4) catch { var buf: [512]u8 = undefined; const text = std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{entry.path}) catch "Shader: the GPU refused it"; if (core) |p| p.setMessage(p.active, text) else std.log.err("{s}", .{text}); continue; }; if (pass.pipeline) |old| c.SDL_ReleaseGPUGraphicsPipeline(device, old); pass.pipeline = made; } for (post.passes[0..post.len]) |pass| { if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline); gpa.free(pass.path); } post.passes = next; post.len = chain.len; // An emptied chain's clock starts again with the next pass put in it. if (post.len == 0) { post.started_ns = null; post.last_ns = 0; post.uniforms.frame = 0; } } fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8, samplers: u32, uniform_buffers: u32) !*Pipeline { const vs = try gui.makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); defer c.SDL_ReleaseGPUShader(device, vs); const fs = try gui.makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, samplers, uniform_buffers); defer c.SDL_ReleaseGPUShader(device, fs); var target = c.SDL_GPUColorTargetDescription{ .format = format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; 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.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; info.target_info = .{ .color_target_descriptions = &target, .num_color_targets = 1 }; return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } /// A pass is ready to draw. pub fn ready(post: *const Post) bool { for (post.passes[0..post.len]) |pass| if (pass.pipeline != null) return true; return false; } /// The chain redraws on its own (level A) as ShaderAnimation says. pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation) bool { return post.ready() and switch (mode) { .off => false, .on => post.uniforms.focus != 0, .always => true, }; } /// What a frame of the core's tells the passes: where its cursor is drawn /// and in what, and the theme's colours. pub const Frame = struct { pub const Cursor = struct { x0: f32, y0: f32, x1: f32, y1: f32, rgb: [3]u8, text: [3]u8, bar: bool }; cursor: ?Cursor, /// What a bundled pass leaves alone, in framebuffer pixels: the tags, /// grips and notices (and the cursor, added here). spare: []const [4]f32 = &.{}, background: [3]u8, foreground: [3]u8, selection_foreground: [3]u8, selection_background: [3]u8, }; fn rgba(rgb: [3]u8) [4]f32 { return .{ @as(f32, @floatFromInt(rgb[0])) / 255, @as(f32, @floatFromInt(rgb[1])) / 255, @as(f32, @floatFromInt(rgb[2])) / 255, 1 }; } /// Takes in a frame of the core's. The cursor's rectangle is (left, BOTTOM /// edge, width, height) in framebuffer pixels, y down; a move or a new /// colour makes the old one the previous and stamps the change. pub fn describe(post: *Post, frame: Frame) void { const u = &post.uniforms; post.caps = capsOf(frame); post.spare_len = 0; for (frame.spare) |rect| post.addSpare(rect); if (frame.cursor) |cursor| post.addSpare(.{ cursor.x0, cursor.y0, cursor.x1, cursor.y1 }); u.background_color = rgba(frame.background); u.foreground_color = rgba(frame.foreground); u.selection_foreground_color = rgba(frame.selection_foreground); u.selection_background_color = rgba(frame.selection_background); if (u.palette[1][3] == 0) for (&u.palette, 0..) |*slot, i| { const color = ghostty_vt.color.default[i]; slot.* = rgba(.{ color.r, color.g, color.b }); }; u.cursor_visible = @intFromBool(frame.cursor != null); const cursor = frame.cursor orelse return; const rect: [4]f32 = .{ cursor.x0, cursor.y1, cursor.x1 - cursor.x0, cursor.y1 - cursor.y0 }; const style: i32 = if (cursor.bar) 2 else 0; const color = rgba(cursor.rgb); u.cursor_color = color; u.cursor_text = rgba(cursor.text); if (std.meta.eql(rect, u.current_cursor) and std.meta.eql(color, u.current_cursor_color) and style == u.current_cursor_style) return; u.previous_cursor = u.current_cursor; u.previous_cursor_color = u.current_cursor_color; u.previous_cursor_style = u.current_cursor_style; u.current_cursor = rect; u.current_cursor_color = color; u.current_cursor_style = style; u.cursor_change_time = u.time; } /// A rectangle a bundled pass leaves alone; past `max_spare` the last one /// grows to cover the rest (sparing more, never less). fn addSpare(post: *Post, rect: [4]f32) void { if (rect[2] <= rect[0] or rect[3] <= rect[1]) return; if (post.spare_len < max_spare) { post.spare[post.spare_len] = rect; post.spare_len += 1; return; } const last = &post.spare[max_spare - 1]; last.* = .{ @min(last[0], rect[0]), @min(last[1], rect[1]), @max(last[2], rect[2]), @max(last[3], rect[3]) }; } /// sRGB's relative luminance (WCAG) and a pair's contrast. fn luminance(rgb: [3]f32) f32 { var sum: f32 = 0; for (rgb, [3]f32{ 0.2126, 0.7152, 0.0722 }) |v, weight| sum += weight * (if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4)); return sum; } fn contrastOf(a: f32, b: f32) f32 { return (@max(a, b) + 0.05) / (@min(a, b) + 0.05); } fn unit(rgb: [3]u8) [3]f32 { return .{ @as(f32, @floatFromInt(rgb[0])) / 255, @as(f32, @floatFromInt(rgb[1])) / 255, @as(f32, @floatFromInt(rgb[2])) / 255 }; } /// Each bundled pass's ceiling on this theme (pardesCap, docs/effects.md): /// the most it may do while the text on the page, and the selection's text /// on its own ground, keep min(their contrast, 4.5). Bloom adds light to /// both of a pair (linear, as a share of full white); a vignette darkens /// both (a share); grain moves the page alone, either way (sRGB units). pub fn capsOf(frame: Frame) std.EnumArray(Scene, f32) { const pairs = [_][2][3]f32{ .{ unit(frame.foreground), unit(frame.background) }, .{ unit(frame.selection_foreground), unit(frame.selection_background) } }; var bloom: f32 = 1; var shade: f32 = 1; for (pairs) |pair| { const a = luminance(pair[0]); const b = luminance(pair[1]); const light = @max(a, b); const dark = @min(a, b); const target = @min(contrastOf(a, b), 4.5); // (light + e + .05) / (dark + e + .05) >= target bloom = @min(bloom, @max(0, (light + 0.05 - target * (dark + 0.05)) / @max(target - 1, 1e-3))); // (k light + .05) / (k dark + .05) >= target, darkened by 1 - k const room = light - target * dark; shade = @min(shade, if (room <= 0) 0 else std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1)); } // Grain: the largest step off the page, up and down, that keeps the text // on it at its contrast or 4.5 (searched in 1/1020 steps up to 8/255). const ink = luminance(unit(frame.foreground)); const page = unit(frame.background); const target = @min(contrastOf(ink, luminance(page)), 4.5); var grain: f32 = 0; var step: f32 = 0.25 / 255.0; while (step <= 8.0 / 255.0) : (step += 0.25 / 255.0) { var up = page; var down = page; for (&up, &down) |*u, *d| { u.* = @min(1, u.* + step); d.* = @max(0, d.* - step); } if (contrastOf(ink, luminance(up)) < target - 1e-4 or contrastOf(ink, luminance(down)) < target - 1e-4) break; grain = step; } var caps: std.EnumArray(Scene, f32) = .initFill(1); caps.set(.bloom, bloom); caps.set(.vignette, shade); caps.set(.grain, grain); return caps; } test "no bundled pass at its ceiling takes a native theme's text or selection below its contrast or 4.5" { for (pardes.themes[0..pardes.colors.native_count]) |*theme| { const frame: Frame = .{ .cursor = null, .background = theme.bg orelse .{ 18, 18, 18 }, .foreground = theme.fg orelse .{ 204, 204, 204 }, .selection_foreground = theme.sel_fg, .selection_background = theme.sel_bg }; const caps = capsOf(frame); for ([_][2][3]u8{ .{ frame.foreground, frame.background }, .{ frame.selection_foreground, frame.selection_background } }, 0..) |pair, i| { const a = luminance(unit(pair[0])); const b = luminance(unit(pair[1])); const target = @min(contrastOf(a, b), 4.5) - 1e-3; // Bloom at its ceiling, over both. const e = @min(caps.get(.bloom), 0.05); try std.testing.expect(contrastOf(a + e, b + e) >= target); // The vignette at its ceiling, over both. const k = 1 - @min(caps.get(.vignette), 0.28); try std.testing.expect(contrastOf(a * k, b * k) >= target); // Grain moves the page alone. if (i == 0) { var up = unit(pair[1]); for (&up) |*v| v.* = @min(1, v.* + caps.get(.grain)); try std.testing.expect(contrastOf(a, luminance(up)) >= target); } } } } /// The window gained or lost the focus. pub fn focus(post: *Post, focused: bool) void { if (focused and post.uniforms.focus == 0) post.uniforms.time_focus = post.uniforms.time; post.uniforms.focus = @intFromBool(focused); } /// The pointer, in framebuffer pixels: Shadertoy's iMouse, xy where it is, /// zw where the last press was, negative once released. pub fn mouse(post: *Post, x: f32, y: f32, press: ?bool) void { const m = &post.uniforms.mouse; m[0] = x; m[1] = y; if (press) |down| { if (down) { m[2] = x; m[3] = y; } else { m[2] = -@abs(m[2]); m[3] = -@abs(m[3]); } } } /// Runs the passes over `source`, the finished frame, into `target`: each /// reads the one before, the ones between write two ping-pong textures that /// start cleared, and iTime and iFrame move by this one draw. pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, sampler: *c.SDL_GPUSampler, format: c.SDL_GPUTextureFormat, source: *c.SDL_GPUTexture, target: *c.SDL_GPUTexture, w: u32, h: u32, now_ns: u64, opaque_window: bool) !void { var live: usize = 0; for (post.passes[0..post.len]) |pass| live += @intFromBool(pass.pipeline != null); if (live == 0) return; if (live > 1 and (post.ping[0] == null or post.ping_w != w or post.ping_h != h)) { for (&post.ping) |*texture| { if (texture.*) |old| c.SDL_ReleaseGPUTexture(device, old); var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); info.type = c.SDL_GPU_TEXTURETYPE_2D; info.format = format; info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER; info.width = w; info.height = h; info.layer_count_or_depth = 1; info.num_levels = 1; info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; texture.* = c.SDL_CreateGPUTexture(device, &info) orelse return error.GpuCreate; } post.ping_w = w; post.ping_h = h; } const u = &post.uniforms; const started = post.started_ns orelse now_ns; post.started_ns = started; const elapsed = @as(f64, @floatFromInt(now_ns -| started)) / std.time.ns_per_s; const delta: f32 = if (post.last_ns == 0) 0 else @floatCast(@as(f64, @floatFromInt(now_ns -| post.last_ns)) / std.time.ns_per_s); post.last_ns = now_ns; u.time = @floatCast(elapsed); u.time_delta = delta; u.frame_rate = if (delta > 0) 1 / delta else 60; u.resolution = .{ @floatFromInt(w), @floatFromInt(h), 1 }; u.channel_resolution[0] = .{ @floatFromInt(w), @floatFromInt(h), 1, 0 }; // UTC: the year, the month from 0, the day, the second of the day. var wall: libc.timespec = undefined; if (libc.clock_gettime(.REALTIME, &wall) != 0) wall.sec = 0; const epoch: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, wall.sec)) }; const day = epoch.getEpochDay().calculateYearDay(); const month = day.calculateMonthDay(); u.date = .{ @floatFromInt(day.year), @floatFromInt(month.month.numeric() - 1), @floatFromInt(month.day_index + 1), @floatFromInt(epoch.getDaySeconds().secs) }; var input = source; var index: usize = 0; for (post.passes[0..post.len]) |pass| { const pipeline = pass.pipeline orelse continue; index += 1; const last = index == live; const output = if (last) target else post.ping[index % 2].?; var info = std.mem.zeroes(c.SDL_GPUColorTargetInfo); info.texture = output; info.load_op = if (last) c.SDL_GPU_LOADOP_DONT_CARE else c.SDL_GPU_LOADOP_CLEAR; info.store_op = c.SDL_GPU_STOREOP_STORE; // Bloom's glow first, from this pass's input, at half size and down. // A chain that could not be made adds no glow, and the pass is the // frame as it was. const glow = if (pass.scene == .bloom) post.bloomChain(device, cmd, sampler, input, w, h, pass.level) catch null else null; const pass_rp = c.SDL_BeginGPURenderPass(cmd, &info, 1, null); c.SDL_BindGPUGraphicsPipeline(pass_rp, pipeline); const bindings = [2]c.SDL_GPUTextureSamplerBinding{ .{ .texture = input, .sampler = sampler }, .{ .texture = glow orelse input, .sampler = sampler } }; c.SDL_BindGPUFragmentSamplers(pass_rp, 0, &bindings, if (pass.scene == .bloom) 2 else 1); pushUniforms(cmd, u); var own: Own = .{ .opaque_window = @floatFromInt(@intFromBool(opaque_window)), .level = @floatFromInt(pass.level), .scale = post.scale, // A user's file has no ceiling of ours, and no spared rectangles. .cap = if (pass.scene) |scene| (if (scene == .bloom and glow == null) 0 else post.caps.get(scene)) else 1, .spare_count = if (pass.scene != null) @floatFromInt(post.spare_len) else 0, }; if (pass.scene != null) @memcpy(own.spare[0..post.spare_len], post.spare[0..post.spare_len]); c.SDL_PushGPUFragmentUniformData(cmd, 3, &own, @sizeOf(Own)); c.SDL_DrawGPUPrimitives(pass_rp, 3, 1, 0, 0); c.SDL_EndGPURenderPass(pass_rp); input = output; } u.frame +%= 1; } /// ghostty's block as the prefix cuts it (SDL GPU binds at most 4 KiB of a /// block): the globals up to the palette, the palette, the colours after it, /// each a slice of the one struct, at set 3 bindings 0 to 2. fn pushUniforms(cmd: *c.SDL_GPUCommandBuffer, u: *const Uniforms) void { const bytes = std.mem.asBytes(u); const palette = @offsetOf(Uniforms, "palette"); const colors = @offsetOf(Uniforms, "background_color"); c.SDL_PushGPUFragmentUniformData(cmd, 0, bytes.ptr, @intCast(palette)); c.SDL_PushGPUFragmentUniformData(cmd, 1, bytes.ptr + palette, @intCast(colors - palette)); c.SDL_PushGPUFragmentUniformData(cmd, 2, bytes.ptr + colors, @intCast(@sizeOf(Uniforms) - colors)); } test "each slice of the uniforms fits the 4 KiB SDL GPU binds of a block" { try std.testing.expect(@offsetOf(Uniforms, "palette") <= 4096); try std.testing.expect(@offsetOf(Uniforms, "background_color") - @offsetOf(Uniforms, "palette") <= 4096); try std.testing.expect(@sizeOf(Uniforms) - @offsetOf(Uniforms, "background_color") <= 4096); try std.testing.expect(@sizeOf(Own) <= 4096); } /// Bloom's glow of `input` (w x h): a bright pass and dual Kawase steps /// down to 1/2^n of the frame and back up to a half, in float textures; /// the half-size result, for the composite (shaders/post/bloom.glsl). fn bloomChain(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, sampler: *c.SDL_GPUSampler, input: *c.SDL_GPUTexture, w: u32, h: u32, level: u8) !*c.SDL_GPUTexture { const down = post.bloom_down orelse return error.GpuCreate; const up = post.bloom_up orelse return error.GpuCreate; const steps = bloom_steps[std.math.clamp(level, 1, 3) - 1]; if (post.bloom_tex[0] == null or post.bloom_w != w or post.bloom_h != h) { for (&post.bloom_tex, 0..) |*texture, i| { if (texture.*) |old| c.SDL_ReleaseGPUTexture(device, old); var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); info.type = c.SDL_GPU_TEXTURETYPE_2D; info.format = bloom_format; info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER; info.width = @max(1, w >> @intCast(i + 1)); info.height = @max(1, h >> @intCast(i + 1)); info.layer_count_or_depth = 1; info.num_levels = 1; info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; texture.* = c.SDL_CreateGPUTexture(device, &info) orelse return error.GpuCreate; } post.bloom_w = w; post.bloom_h = h; } const Step = extern struct { texels: [4]f32, pass: [4]f32 }; const size = struct { fn of(full: u32, i: usize) f32 { return @floatFromInt(if (i == 0) full else @max(1, full >> @intCast(i))); } }; // Down: the frame (0) to 1, 1 to 2, ...; the first through the bright // pass (a channel's linear peak over 0.6 to 0.75, by level). const threshold: f32 = switch (std.math.clamp(level, 1, 3)) { 1 => 0.75, 2 => 0.7, else => 0.6, }; // Only what is brighter than the page glows: on a light page, where the // ground itself would pass, nothing does. const page_peak = peak: { var peak: f32 = 0; for (post.uniforms.background_color[0..3]) |v| peak = @max(peak, if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4)); break :peak peak; }; for (0..steps) |i| { const from = if (i == 0) input else post.bloom_tex[i - 1].?; const to = post.bloom_tex[i].?; const params: Step = .{ .texels = .{ 1 / size.of(w, i), 1 / size.of(h, i), 1 / size.of(w, i + 1), 1 / size.of(h, i + 1) }, .pass = .{ if (i == 0) @max(threshold, page_peak + 0.15) else 0, 0.25, 0, 0 }, }; bloomStep(cmd, down, sampler, from, to, ¶ms, @sizeOf(Step)); } // Up: each level into the one above it, back to the half. var i: usize = steps - 1; while (i > 0) : (i -= 1) { const params: Step = .{ .texels = .{ 1 / size.of(w, i + 1), 1 / size.of(h, i + 1), 1 / size.of(w, i), 1 / size.of(h, i) }, .pass = @splat(0), }; bloomStep(cmd, up, sampler, post.bloom_tex[i].?, post.bloom_tex[i - 1].?, ¶ms, @sizeOf(Step)); } return post.bloom_tex[0].?; } fn bloomStep(cmd: *c.SDL_GPUCommandBuffer, pipeline: *Pipeline, sampler: *c.SDL_GPUSampler, from: *c.SDL_GPUTexture, to: *c.SDL_GPUTexture, params: *const anyopaque, len: u32) void { var info = std.mem.zeroes(c.SDL_GPUColorTargetInfo); info.texture = to; info.load_op = c.SDL_GPU_LOADOP_DONT_CARE; info.store_op = c.SDL_GPU_STOREOP_STORE; const rp = c.SDL_BeginGPURenderPass(cmd, &info, 1, null); c.SDL_BindGPUGraphicsPipeline(rp, pipeline); const binding = c.SDL_GPUTextureSamplerBinding{ .texture = from, .sampler = sampler }; c.SDL_BindGPUFragmentSamplers(rp, 0, &binding, 1); c.SDL_PushGPUFragmentUniformData(cmd, 0, params, len); c.SDL_DrawGPUPrimitives(rp, 3, 1, 0, 0); c.SDL_EndGPURenderPass(rp); } pub fn deinit(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice) void { for (post.bloom_tex) |texture| if (texture) |t| c.SDL_ReleaseGPUTexture(device, t); for ([_]?*Pipeline{ post.bloom_down, post.bloom_up }) |pipeline| if (pipeline) |p| c.SDL_ReleaseGPUGraphicsPipeline(device, p); for (post.passes[0..post.len]) |pass| { if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline); gpa.free(pass.path); } for (post.bundled.values) |pipeline| if (pipeline) |p| c.SDL_ReleaseGPUGraphicsPipeline(device, p); for (post.ping) |texture| if (texture) |t| c.SDL_ReleaseGPUTexture(device, t); } /// ghostty 1.3.2's Uniforms (src/renderer/shadertoy.zig), copied: the offsets /// ours must match. Its last two names are swapped against its own GLSL. const GhosttyUniforms = extern struct { resolution: [3]f32 align(16), time: f32 align(4), time_delta: f32 align(4), frame_rate: f32 align(4), frame: i32 align(4), channel_time: [4][4]f32 align(16), channel_resolution: [4][4]f32 align(16), mouse: [4]f32 align(16), date: [4]f32 align(16), sample_rate: f32 align(4), current_cursor: [4]f32 align(16), previous_cursor: [4]f32 align(16), current_cursor_color: [4]f32 align(16), previous_cursor_color: [4]f32 align(16), current_cursor_style: i32 align(4), previous_cursor_style: i32 align(4), cursor_visible: i32 align(4), cursor_change_time: f32 align(4), time_focus: f32 align(4), focus: i32 align(4), palette: [256][4]f32 align(16), background_color: [4]f32 align(16), foreground_color: [4]f32 align(16), cursor_color: [4]f32 align(16), cursor_text: [4]f32 align(16), selection_background_color: [4]f32 align(16), selection_foreground_color: [4]f32 align(16), }; test "the uniform block is ghostty's, offset for offset, and the prefix names it in that order" { const ours = @typeInfo(Uniforms).@"struct".fields; const theirs = @typeInfo(GhosttyUniforms).@"struct".fields; try std.testing.expectEqual(theirs.len, ours.len); try std.testing.expectEqual(@sizeOf(GhosttyUniforms), @sizeOf(Uniforms)); inline for (ours, theirs) |a, b| { try std.testing.expectEqual(@offsetOf(GhosttyUniforms, b.name), @offsetOf(Uniforms, a.name)); try std.testing.expectEqual(b.type, a.type); } // By NAME: iSelectionForegroundColor sits where ghostty's Zig says // selection_background, which is where its GLSL puts the foreground. try std.testing.expectEqual(@offsetOf(GhosttyUniforms, "selection_background_color"), @offsetOf(Uniforms, "selection_foreground_color")); // The prefix's block, member by member, in ghostty's GLSL names and types. const glsl = [_]struct { []const u8, []const u8 }{ .{ "vec3", "iResolution" }, .{ "float", "iTime" }, .{ "float", "iTimeDelta" }, .{ "float", "iFrameRate" }, .{ "int", "iFrame" }, .{ "float", "iChannelTime[4]" }, .{ "vec3", "iChannelResolution[4]" }, .{ "vec4", "iMouse" }, .{ "vec4", "iDate" }, .{ "float", "iSampleRate" }, .{ "vec4", "iCurrentCursor" }, .{ "vec4", "iPreviousCursor" }, .{ "vec4", "iCurrentCursorColor" }, .{ "vec4", "iPreviousCursorColor" }, .{ "int", "iCurrentCursorStyle" }, .{ "int", "iPreviousCursorStyle" }, .{ "int", "iCursorVisible" }, .{ "float", "iTimeCursorChange" }, .{ "float", "iTimeFocus" }, .{ "int", "iFocus" }, .{ "vec3", "iPalette[256]" }, .{ "vec3", "iBackgroundColor" }, .{ "vec3", "iForegroundColor" }, .{ "vec3", "iCursorColor" }, .{ "vec3", "iCursorText" }, .{ "vec3", "iSelectionForegroundColor" }, .{ "vec3", "iSelectionBackgroundColor" }, }; try std.testing.expectEqual(ours.len, glsl.len); // Its three blocks (Globals, Palette, Colors) read as one, in order. const start = std.mem.indexOf(u8, prefix, "uniform Globals {").?; const end = std.mem.indexOf(u8, prefix, "uniform PardesPost {").?; var lines = std.mem.tokenizeScalar(u8, prefix[start..end], '\n'); var n: usize = 0; while (lines.next()) |line| { if (!std.mem.startsWith(u8, line, " uniform ")) continue; var words = std.mem.tokenizeAny(u8, line, " ;"); try std.testing.expectEqualStrings("uniform", words.next().?); try std.testing.expectEqualStrings(glsl[n][0], words.next().?); try std.testing.expectEqualStrings(glsl[n][1], words.next().?); n += 1; } try std.testing.expectEqual(glsl.len, n); // Each block starts where its first member is in the one struct. try std.testing.expect(std.mem.indexOf(u8, prefix, "uniform Palette {\n uniform vec3 iPalette[256];\n};") != null); } test "a moved cursor becomes the previous one, stamped with the time of the move" { var post: Post = .{}; const frame: Frame = .{ .cursor = .{ .x0 = 10, .y0 = 20, .x1 = 11, .y1 = 40, .rgb = .{ 255, 0, 0 }, .text = .{ 0, 0, 0 }, .bar = true }, .background = .{ 0, 0, 0 }, .foreground = .{ 255, 255, 255 }, .selection_foreground = .{ 1, 2, 3 }, .selection_background = .{ 4, 5, 6 } }; post.uniforms.time = 1.5; post.describe(frame); // Left, BOTTOM edge, width, height, in framebuffer pixels, y down. try std.testing.expectEqual([4]f32{ 10, 40, 1, 20 }, post.uniforms.current_cursor); try std.testing.expectEqual(@as(i32, 2), post.uniforms.current_cursor_style); try std.testing.expectEqual(@as(f32, 1.5), post.uniforms.cursor_change_time); post.uniforms.time = 2; post.describe(frame); try std.testing.expectEqual(@as(f32, 1.5), post.uniforms.cursor_change_time); var moved = frame; moved.cursor.?.x0 = 20; moved.cursor.?.x1 = 21; post.describe(moved); try std.testing.expectEqual([4]f32{ 10, 40, 1, 20 }, post.uniforms.previous_cursor); try std.testing.expectEqual(@as(f32, 2), post.uniforms.cursor_change_time); try std.testing.expectEqual([4]f32{ 1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 1 }, post.uniforms.selection_foreground_color); }