diff options
Diffstat (limited to 'src/gui/Post.zig')
| -rw-r--r-- | src/gui/Post.zig | 291 |
1 files changed, 274 insertions, 17 deletions
diff --git a/src/gui/Post.zig b/src/gui/Post.zig index 45798f8d..61ebc2cd 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -28,7 +28,27 @@ 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 @@ -101,6 +121,18 @@ 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, @@ -119,7 +151,11 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUD 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)) catch null); + 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; } @@ -182,7 +218,7 @@ fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format .spirv => |spirv| { defer gpa.free(spirv); const target = pass orelse continue; - const made = makePipeline(device, format, spirv) catch { + 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; }; @@ -266,10 +302,10 @@ fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8) Job.Result { return .{ .failed = named }; } -fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8) !*Pipeline { +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, 1, 2); + 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); @@ -303,6 +339,9 @@ pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation) 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, @@ -318,6 +357,10 @@ fn rgba(rgb: [3]u8) [4]f32 { /// 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); @@ -343,6 +386,102 @@ pub fn describe(post: *Post, frame: Frame) void { 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; @@ -420,13 +559,25 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, 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 binding = c.SDL_GPUTextureSamplerBinding{ .texture = input, .sampler = sampler }; - c.SDL_BindGPUFragmentSamplers(pass_rp, 0, &binding, 1); - c.SDL_PushGPUFragmentUniformData(cmd, 0, u, @sizeOf(Uniforms)); - const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), @floatFromInt(pass.level), post.scale, 0 }; - c.SDL_PushGPUFragmentUniformData(cmd, 1, &own, @sizeOf(@TypeOf(own))); + 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; @@ -434,7 +585,107 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, 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| { @@ -511,16 +762,22 @@ test "the uniform block is ghostty's, offset for offset, and the prefix names it .{ "vec3", "iCursorText" }, .{ "vec3", "iSelectionForegroundColor" }, .{ "vec3", "iSelectionBackgroundColor" }, }; try std.testing.expectEqual(ours.len, glsl.len); - const block = prefix[std.mem.indexOf(u8, prefix, "uniform Globals {").?..]; - var lines = std.mem.tokenizeScalar(u8, block[0..std.mem.indexOfScalar(u8, block, '}').?], '\n'); - _ = lines.next(); - for (glsl) |member| { - var words = std.mem.tokenizeAny(u8, lines.next().?, " ;"); + // 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(member[0], words.next().?); - try std.testing.expectEqualStrings(member[1], 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.expect(lines.next() == null); + 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" { |
