//! 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 ), //! compiled when it joins the chain by the build's own glslc invocation on a //! thread of its own, never on the frame path. A file whose compile fails //! keeps its last good pipeline, and glslc missing is said once. //! //! 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 filesystem = @import("../fs.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 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, }; /// One compile of a chain's new files, on its own thread. const Job = struct { const Result = union(enum) { none, spirv: []u8, failed: []u8, missing }; io: std.Io, gpa: std.mem.Allocator, paths: [max][]u8 = @splat(&.{}), results: [max]Result = @splat(.none), len: usize = 0, done: std.atomic.Value(bool) = .init(false), thread: std.Thread = undefined, }; passes: [max]Pass = @splat(.{}), len: usize = 0, /// The chain generation `passes` follow; null before the first. generation: ?u32 = null, bundled: std.EnumArray(Scene, ?*Pipeline) = .initFill(null), job: ?*Job = null, /// Said once: without glslc, files never compile. missing_said: bool = false, 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 the core holds, and takes in a finished /// compile. Called each loop step, off the frame path. pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes) void { if (post.job) |job| { if (!job.done.load(.acquire)) return; job.thread.join(); post.job = null; post.collect(gpa, device, format, core, job); } const chain = &core.settings.post; if (post.generation == chain.generation) return; post.generation = chain.generation; var next: [max]Pass = @splat(.{}); for (chain.list(), 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.get())) continue; next[i] = pass.*; pass.* = .{}; break; } else next[i] = .{ .path = gpa.dupe(u8, entry.path.get()) catch &.{} }; } 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; } // The new files compile together, on a thread of their own. const job = gpa.create(Job) catch return; job.* = .{ .io = io, .gpa = gpa }; for (post.passes[0..post.len]) |pass| { if (pass.scene != null or pass.pipeline != null or pass.path.len == 0) continue; job.paths[job.len] = gpa.dupe(u8, pass.path) catch continue; job.len += 1; } if (job.len == 0) return gpa.destroy(job); job.thread = std.Thread.spawn(.{}, compile, .{job}) catch { for (job.paths[0..job.len]) |path| gpa.free(path); return gpa.destroy(job); }; post.job = job; } fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes, job: *Job) void { defer gpa.destroy(job); for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { defer gpa.free(path); const pass = for (post.passes[0..post.len]) |*pass| { if (pass.scene == null and std.mem.eql(u8, pass.path, path)) break pass; } else null; var buf: [512]u8 = undefined; switch (result) { .none => {}, .missing => if (!post.missing_said) { post.missing_said = true; core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt does)"); }, .failed => |text| { defer gpa.free(text); // glslc's first line says where: in the file's own lines. const first = std.mem.sliceTo(text, '\n'); core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: {s}", .{ path, first[0..@min(first.len, 400)] }) catch "Shader: compile failed"); }, .spirv => |spirv| { defer gpa.free(spirv); const target = pass orelse continue; const made = makePipeline(device, format, spirv, 1, 4) catch { core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{path}) catch "Shader: the GPU refused it"); continue; }; if (target.pipeline) |old| c.SDL_ReleaseGPUGraphicsPipeline(device, old); target.pipeline = made; }, } } } /// The compile thread: each file behind the prefix, through glslc with the /// build's own flags (build.zig compileGlsl), source on stdin, SPIR-V on /// stdout. fn compile(job: *Job) void { defer job.done.store(true, .release); defer { var event = std.mem.zeroes(c.SDL_Event); event.type = c.SDL_EVENT_USER; _ = c.SDL_PushEvent(&event); } for (job.paths[0..job.len], 0..) |path, i| job.results[i] = compileOne(job.gpa, job.io, path); } fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8) Job.Result { var home_buf: [4096]u8 = undefined; const expanded = if (std.mem.startsWith(u8, path, "~/")) if (libc.getenv("HOME")) |home| std.fmt.bufPrint(&home_buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path else path else path; const body = filesystem.readFile(gpa, expanded) catch |err| return .{ .failed = std.fmt.allocPrint(gpa, "cannot read it ({t})", .{err}) catch return .none }; defer gpa.free(body); // The prefix and the file go to glslc on its stdin: no file of ours is // written anywhere. std's spawn does every allocation before fork and // searches PATH on the stack after it (Io/Threaded.zig spawnPosix, // posixExecv), so a child forked from this thread cannot wedge on a // lock another thread held (the forkShell hazard). var child = std.process.spawn(io, .{ .argv = &.{ "glslc", "-fshader-stage=fragment", "-o", "-", "-" }, .stdin = .pipe, .stdout = .pipe, .stderr = .pipe, }) catch |err| return switch (err) { error.FileNotFound => .missing, else => .{ .failed = std.fmt.allocPrint(gpa, "glslc did not run ({t})", .{err}) catch return .none }, }; defer child.kill(io); // glslc reads all of its input before it writes a byte, so writing it // first cannot deadlock against a full output pipe. { var stdin = child.stdin.?; child.stdin = null; defer stdin.close(io); var buf: [4096]u8 = undefined; var writer = stdin.writer(io, &buf); writer.interface.writeAll(prefix) catch {}; writer.interface.writeAll(body) catch {}; writer.interface.flush() catch {}; } var streams: std.Io.File.MultiReader.Buffer(2) = undefined; var reader: std.Io.File.MultiReader = undefined; reader.init(gpa, io, streams.toStreams(), &.{ child.stdout.?, child.stderr.? }); defer reader.deinit(); while (reader.fill(64, .none)) |_| { if (reader.reader(0).buffered().len > 16 << 20 or reader.reader(1).buffered().len > 64 << 10) return .{ .failed = gpa.dupe(u8, "glslc said too much") catch return .none }; } else |err| switch (err) { error.EndOfStream => {}, else => return .{ .failed = gpa.dupe(u8, "reading glslc failed") catch return .none }, } const term = child.wait(io) catch return .{ .failed = gpa.dupe(u8, "glslc did not finish") catch return .none }; const clean = switch (term) { .exited => |code| code == 0, else => false, }; if (clean) return .{ .spirv = reader.toOwnedSlice(0) catch return .none }; const said = reader.toOwnedSlice(1) catch return .none; // glslc calls its input ; the file is what a person knows. defer gpa.free(said); const named = std.mem.replaceOwned(u8, gpa, said, "", path) catch return .{ .failed = gpa.dupe(u8, "glslc failed") catch return .none }; return .{ .failed = named }; } 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); if (post.job) |job| { job.thread.join(); for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { gpa.free(path); switch (result) { .spirv, .failed => |bytes| gpa.free(bytes), .none, .missing => {}, } } gpa.destroy(job); } 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); } test "ghostty's test shaders compile behind the prefix, and its invalid one fails with glslc's words" { const gpa = std.testing.allocator; const io = std.testing.io; // ghostty's own test shaders, from its package in zig-pkg. var pkg = std.Io.Dir.cwd().openDir(io, "zig-pkg", .{ .iterate = true }) catch return error.SkipZigTest; defer pkg.close(io); var it = pkg.iterate(); const ghostty = while (it.next(io) catch null) |entry| { if (std.mem.startsWith(u8, entry.name, "ghostty-")) break entry.name; } else return error.SkipZigTest; var buf: [512]u8 = undefined; for ([_][]const u8{ "crt", "focus", "invalid" }) |name| { const path = try std.fmt.bufPrint(&buf, "zig-pkg/{s}/src/renderer/shaders/test_shadertoy_{s}.glsl", .{ ghostty, name }); const result = compileOne(gpa, io, path); switch (result) { .missing => return error.SkipZigTest, // no glslc here .spirv => |spirv| { defer gpa.free(spirv); try std.testing.expect(!std.mem.eql(u8, name, "invalid")); // SPIR-V's magic number, little-endian. try std.testing.expectEqual(@as(u32, 0x07230203), std.mem.readInt(u32, spirv[0..4], .little)); }, .failed => |text| { defer gpa.free(text); try std.testing.expectEqualStrings("invalid", name); // Named by the file, not by glslc's . try std.testing.expect(std.mem.indexOf(u8, text, path) != null); try std.testing.expect(std.mem.indexOf(u8, text, "") == null); }, .none => return error.TestUnexpectedResult, } } }