From c6a5aaf5c4a020eaefe24943ecb6cf9d4eb26836 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:57:22 -0300 Subject: Post chain: Shadertoy passes compiled by glslc; Crt, Ripple and Glitch rewritten as Shadertoy files Stage 9 of docs/render-pipeline.md. src/gui/Post.zig runs the chain over the finished frame: each pass reads the one before, ping-pong textures between, the last writes the window; an empty chain is the direct path. The prefix (shaders/post/prefix.glsl) is ghostty's uniform block, name for name and offset for offset (tested against a copy of its Uniforms, the selection colours by their GLSL names), at SDL GPU's bindings, y down. A user's file (Shader ) is compiled by glslc with compileGlsl's own flags on a thread of its own, taken in at the next loop step; errors name the file's own lines; a failed compile keeps the last good pipeline; glslc missing is said once. The bundled passes compile with the build, through the same prefix. Crt, Ripple and Glitch are Shadertoy files, rewritten: no barrel (input is identity, tested), everything in linear light and dithered, keyed to iTime. scene_effects, crt.zig and crt.frag.glsl are gone; the core keeps no clock for the chain. ShaderAnimation off|on|always drives redraw level A: the chain alone over the retained frame (34 us CPU a redraw, measured). Shared files touched: pardes.zig (nextWake loses the scene line, disableSceneEffects empties the chain, two tests), builtins.zig (one switch arm), ninep/ctl.zig (two switch arms), macos.zig (flags from the chain), gui.zig. Not touched: Messages.zig, mouse.zig, detached/*, host_io, tty. --- src/gui/Post.zig | 559 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100644 src/gui/Post.zig (limited to 'src/gui/Post.zig') diff --git a/src/gui/Post.zig b/src/gui/Post.zig new file mode 100644 index 00000000..d92623d7 --- /dev/null +++ b/src/gui/Post.zig @@ -0,0 +1,559 @@ +//! 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 bundled (Crt, Ripple, Glitch: +//! 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 host_io = @import("../host_io.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"), + .ripple = @embedFile("post-ripple.frag.spv"), + .glitch = @embedFile("post-glitch.frag.spv"), +}); + +/// 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 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 = .{}, +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)) catch null); + next[i] = .{ .scene = scene, .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, Ripple and Glitch do)"); + }, + .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) 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 exactly as +/// the build calls it (build.zig compileGlsl), its 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, i); +} + +fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8, index: usize) 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); + const dir = if (libc.getenv("XDG_RUNTIME_DIR")) |d| std.mem.span(d) else "/tmp"; + var source_buf: [4096]u8 = undefined; + const source = std.fmt.bufPrintSentinel(&source_buf, "{s}/pardes-post-{d}-{d}.frag.glsl", .{ dir, libc.getpid(), index }, 0) catch return .none; + defer _ = libc.unlink(source); + { + const fd = libc.open(source, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o600)); + if (fd < 0) return .{ .failed = gpa.dupe(u8, "cannot write its source for glslc") catch return .none }; + defer _ = libc.close(fd); + if (!host_io.writeFd(fd, prefix) or !host_io.writeFd(fd, body)) + return .{ .failed = gpa.dupe(u8, "cannot write its source for glslc") catch return .none }; + } + const run = std.process.run(gpa, io, .{ + .argv = &.{ "glslc", "-fshader-stage=fragment", source, "-o", "-" }, + .stdout_limit = .limited(16 << 20), + .stderr_limit = .limited(64 << 10), + }) catch |err| return switch (err) { + error.FileNotFound => .missing, + else => .{ .failed = std.fmt.allocPrint(gpa, "glslc did not run ({t})", .{err}) catch return .none }, + }; + const clean = switch (run.term) { + .exited => |code| code == 0, + else => false, + }; + if (clean) { + gpa.free(run.stderr); + return .{ .spirv = run.stdout }; + } + gpa.free(run.stdout); + // glslc names the temporary source; the file is what a person knows. + defer gpa.free(run.stderr); + const named = std.mem.replaceOwned(u8, gpa, run.stderr, source, 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) !*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); + 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, + 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; + 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; +} + +/// 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) }; + const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), 0, 0, 0 }; + + 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; + 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)); + c.SDL_PushGPUFragmentUniformData(cmd, 1, &own, @sizeOf(@TypeOf(own))); + c.SDL_DrawGPUPrimitives(pass_rp, 3, 1, 0, 0); + c.SDL_EndGPURenderPass(pass_rp); + input = output; + } + u.frame +%= 1; +} + +pub fn deinit(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice) void { + 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); + 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().?, " ;"); + 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.expect(lines.next() == 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" }, 0..) |name, i| { + 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, i); + 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 the temporary source glslc read. + try std.testing.expect(std.mem.indexOf(u8, text, path) != null); + try std.testing.expect(std.mem.indexOf(u8, text, "pardes-post-") == null); + }, + .none => return error.TestUnexpectedResult, + } + } +} -- cgit v1.3 From be09d1ec7abd71f4e52f05942a941d76b6d420f2 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:26:08 -0300 Subject: Post chain review fixes: glslc on stdin, level A at the display's refresh Stage 9 review: a user's Shadertoy file goes to glslc on its stdin, so no source file is written anywhere (the old predictable name under XDG_RUNTIME_DIR or /tmp was a symlink-clobber hazard). std's spawn does all its allocation before fork and searches PATH on the stack after it, said at the call. Level A redraws are paced by the display's refresh rate (SDL_GetCurrentDisplayMode), not a fixed 16 ms. Shared files touched: gui.zig. --- docs/render-pipeline.md | 6 ++-- src/gui/Post.zig | 83 +++++++++++++++++++++++++++++-------------------- src/gui/gui.zig | 21 +++++++++++-- 3 files changed, 71 insertions(+), 39 deletions(-) (limited to 'src/gui/Post.zig') diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index 254187a3..d991a1ce 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -492,11 +492,11 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`, `crt.zig` is deleted. If the user insists on barrel in the bundled CRT, keep `crt.zig`'s inverse keyed to "bundled crt active". - **Compile** (decision 3): a user's file is compiled by spawning `glslc` with - the build's own invocation (`-fshader-stage=fragment -o -`, SPIR-V on - stdout), on a thread of its own when the file joins the chain, never on the + the build's own flags, prefix and file on its stdin, SPIR-V on its stdout + (no file of ours anywhere; std's spawn allocates before fork), on a thread of its own when the file joins the chain, never on the frame path; the result is taken in at the next loop step. The prefix ends in `#line 1`, so glslc's errors count the file's own lines, and they name the - file, not the temporary source. A failed compile keeps the file's last good + file, not ``. Level A redraws once a refresh of the window's display. A failed compile keeps the file's last good pipeline and says glslc's first line as a message; glslc missing is said once, and only files stay off (the bundled passes are compiled with the build, through the same prefix). No file watching: `Shader ` twice diff --git a/src/gui/Post.zig b/src/gui/Post.zig index d92623d7..c529efc0 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -15,7 +15,6 @@ const libc = std.c; const ghostty_vt = @import("ghostty-vt"); const pardes = @import("../pardes.zig"); const filesystem = @import("../fs.zig"); -const host_io = @import("../host_io.zig"); const gui = @import("gui.zig"); const c = gui.c; @@ -191,8 +190,9 @@ fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format } } -/// The compile thread: each file behind the prefix, through glslc exactly as -/// the build calls it (build.zig compileGlsl), its SPIR-V on stdout. +/// 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 { @@ -200,10 +200,10 @@ fn compile(job: *Job) void { 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, i); + 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, index: usize) Job.Result { +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 @@ -212,37 +212,54 @@ fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8, index: usize 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); - const dir = if (libc.getenv("XDG_RUNTIME_DIR")) |d| std.mem.span(d) else "/tmp"; - var source_buf: [4096]u8 = undefined; - const source = std.fmt.bufPrintSentinel(&source_buf, "{s}/pardes-post-{d}-{d}.frag.glsl", .{ dir, libc.getpid(), index }, 0) catch return .none; - defer _ = libc.unlink(source); - { - const fd = libc.open(source, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o600)); - if (fd < 0) return .{ .failed = gpa.dupe(u8, "cannot write its source for glslc") catch return .none }; - defer _ = libc.close(fd); - if (!host_io.writeFd(fd, prefix) or !host_io.writeFd(fd, body)) - return .{ .failed = gpa.dupe(u8, "cannot write its source for glslc") catch return .none }; - } - const run = std.process.run(gpa, io, .{ - .argv = &.{ "glslc", "-fshader-stage=fragment", source, "-o", "-" }, - .stdout_limit = .limited(16 << 20), - .stderr_limit = .limited(64 << 10), + // 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 }, }; - const clean = switch (run.term) { + 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) { - gpa.free(run.stderr); - return .{ .spirv = run.stdout }; - } - gpa.free(run.stdout); - // glslc names the temporary source; the file is what a person knows. - defer gpa.free(run.stderr); - const named = std.mem.replaceOwned(u8, gpa, run.stderr, source, path) catch return .{ .failed = gpa.dupe(u8, "glslc failed") catch return .none }; + 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 }; } @@ -535,9 +552,9 @@ test "ghostty's test shaders compile behind the prefix, and its invalid one fail 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" }, 0..) |name, i| { + 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, i); + const result = compileOne(gpa, io, path); switch (result) { .missing => return error.SkipZigTest, // no glslc here .spirv => |spirv| { @@ -549,9 +566,9 @@ test "ghostty's test shaders compile behind the prefix, and its invalid one fail .failed => |text| { defer gpa.free(text); try std.testing.expectEqualStrings("invalid", name); - // Named by the file, not by the temporary source glslc read. + // 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, "pardes-post-") == null); + try std.testing.expect(std.mem.indexOf(u8, text, "") == null); }, .none => return error.TestUnexpectedResult, } diff --git a/src/gui/gui.zig b/src/gui/gui.zig index ab1875b7..aa39cf32 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -4086,7 +4086,12 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const minimized = !s.test_mode and c.SDL_GetWindowFlags(g.window) & (c.SDL_WINDOW_MINIMIZED | c.SDL_WINDOW_OCCLUDED) != 0; // A latency trace is fed on stdin, whose poll below paces the loop: // waiting here too would make every tick cost two frames. - const ms: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @intCast(timeout_ms) else if (polls) 16 else -1; + // A chain that moves on its own wakes once a display refresh. + const poll_ms: c_int = if (g.post.animating(core.settings.shader_animation)) + @intCast(@max(1, refreshNs(g) / std.time.ns_per_ms)) + else + 16; + const ms: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @min(@as(c_int, @intCast(@min(timeout_ms, std.math.maxInt(c_int)))), if (polls) poll_ms else std.math.maxInt(c_int)) else if (polls) poll_ms else -1; // The wait is the 9P connections' turn with the core. pardes.turn.rest(); const got = c.SDL_WaitEventTimeout(&sev, ms); @@ -4168,9 +4173,19 @@ fn pollFrame(ctx: ?*anyopaque) void { if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height)) resetScroll(g); stepScroll(g, core, s.gpa); g.post.sync(s.gpa, s.io, g.device, g.swapchain_format, core); - // Between the core's frames, a chain that moves on its own redraws alone. + // Between the core's frames, a chain that moves on its own redraws + // alone, once a refresh of the display the window is on (a millisecond + // early is on time: the wait that paces it is in whole milliseconds). if (g.post.animating(core.settings.shader_animation) and !core.needs_frame and s.presented and - c.SDL_GetTicksNS() -| g.post.last_ns >= pardes.animation.frame_ns) redrawPost(g, s.gpa); + c.SDL_GetTicksNS() -| g.post.last_ns + std.time.ns_per_ms >= refreshNs(g)) redrawPost(g, s.gpa); +} + +/// One refresh of the display the window is on, in ns; a frame of the +/// core's where the display does not say. +fn refreshNs(g: *const Gui) u64 { + const mode = c.SDL_GetCurrentDisplayMode(c.SDL_GetDisplayForWindow(g.window)) orelse return pardes.animation.frame_ns; + if (!(mode.*.refresh_rate > 1)) return pardes.animation.frame_ns; + return @intFromFloat(@as(f64, std.time.ns_per_s) / mode.*.refresh_rate); } fn finishWindowOpacity(core: *pardes.Pardes, applied: *u8, failure: ?[]const u8) void { -- cgit v1.3 From 1ed34e1538f45daecc2c8a551dc0fb7aba5f3f54 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:30:57 -0300 Subject: Crt, Ripple and Glitch take a level, 0 to 3, tunable at run time Coordinator's request: `Crt 0..3` (likewise Ripple, Glitch), 0 off, `on` the default level 2 (the polished rewrite), `off` and a bare word as before. The level is one uniform (pardesLevel, beside pardesOpaque in the prefix's own block); each pass picks its constants by it. Level 3 is at least as strong as the old effects, measured as pixels changed by more than 8 in a channel against a plain frame: Crt 18.3% (old 17.4%), Ripple 11.1% (9.5%), Glitch 9.3% a frame (8.1%); Glitch's level raises how often it bursts as well as how hard. Config and ctl report `Crt 2` / `Crt off`. Shared files touched: ninep/ctl.zig (the report line). Not touched: pardes.zig, Messages.zig, mouse.zig, gui.zig, detached/*. --- docs/render-pipeline.md | 8 +++- shaders/post/crt.glsl | 40 ++++++++++-------- shaders/post/glitch.glsl | 36 +++++++++------- shaders/post/prefix.glsl | 11 ++++- shaders/post/ripple.glsl | 23 +++++----- shaders/prebuilt/post-crt.frag.glsl | 51 +++++++++++++--------- shaders/prebuilt/post-crt.frag.spv | Bin 9480 -> 12424 bytes shaders/prebuilt/post-glitch.frag.glsl | 47 +++++++++++++-------- shaders/prebuilt/post-glitch.frag.spv | Bin 8308 -> 10764 bytes shaders/prebuilt/post-ripple.frag.glsl | 34 +++++++++------ shaders/prebuilt/post-ripple.frag.spv | Bin 7180 -> 9096 bytes src/config.zig | 75 +++++++++++++++++++++++++++++---- src/gui/Post.zig | 6 ++- src/ninep/ctl.zig | 2 +- test/output.zig | 3 +- 15 files changed, 227 insertions(+), 109 deletions(-) (limited to 'src/gui/Post.zig') diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index d991a1ce..2fd6045b 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -507,7 +507,13 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`, the shell redraws its retained Surface (3.1), never a core tick or render. - **Existing crt/ripple/glitch** become bundled Shadertoy files in `shaders/post/` selected by the same builtins (decision 2: kept, polished); - `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. The chain is + `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. Each bundled pass + takes a level, `Crt 0..3` (0 off, `on` the default 2, `off` and bare still + work), passed as `pardesLevel`: 2 is the polished rewrite, 3 at least as + strong as the old effects (pixels changed by more than 8 in a channel, + against a plain frame: Crt 18.3% vs 17.4% old, Ripple 11.1% vs 9.5%, Glitch + 9.3% a frame vs 8.1%); a Glitch level raises how often it slips as well as + how hard. The chain is `Crt`, `Ripple`, `Glitch` and `Shader ` in the order they were put in (`Shader off` empties it of files), `ShaderAnimation off|on|always` (default on). The core keeps no clock for it: level A redraws are the diff --git a/shaders/post/crt.glsl b/shaders/post/crt.glsl index 16a5cdd9..768157ef 100644 --- a/shaders/post/crt.glsl +++ b/shaders/post/crt.glsl @@ -7,19 +7,23 @@ // Shadertoy file: pardes puts its prefix before it (shaders/post/prefix.glsl). const float TAU = 6.28318530718; -const float CHROMA_PX = 0.35; // red and blue apart, at the corners -const float BLOOM_RADIUS_PX = 1.75; -const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak +// Level 3 glows wider and from dimmer ink. +float bloomRadiusPx() { return pardesByLevel(1.75, 1.75, 3.0); } +float bloomThreshold() { return pardesByLevel(0.6, 0.6, 0.3); } // linear-light channel peak const float BLOOM_SOFTNESS = 0.25; -const float BLOOM_STRENGTH = 0.05; const float SCAN_PERIOD_PX = 3.0; -const float SCAN_STRENGTH = 0.07; const float MASK_PERIOD_PX = 3.0; -const float MASK_STRENGTH = 0.018; -const float VIGNETTE_STRENGTH = 0.12; // at the far corners only -const float HUM_STRENGTH = 0.004; const float HUM_HZ = 0.11; -const float EXPOSURE = 1.015; + +// Each strength at levels 1, 2 and 3 (`Crt 1`..`Crt 3`): 2 is the default, +// 3 as strong as the old barrel CRT looked. +float chromaPx() { return pardesByLevel(0.2, 0.35, 2.2); } // red and blue apart, at the corners +float bloomStrength() { return pardesByLevel(0.03, 0.05, 0.3); } +float scanStrength() { return pardesByLevel(0.035, 0.07, 0.42); } +float maskStrength() { return pardesByLevel(0.01, 0.018, 0.12); } +float vignetteStrength() { return pardesByLevel(0.06, 0.12, 0.42); } // at the far corners only +float humStrength() { return pardesByLevel(0.002, 0.004, 0.012); } +float exposure() { return pardesByLevel(1.008, 1.015, 1.1); } vec3 toLinear(vec3 c) { return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); @@ -37,7 +41,7 @@ vec3 scene(vec2 uv) { vec3 brightPass(vec2 uv) { vec3 c = scene(uv); float peak = max(c.r, max(c.g, c.b)); - return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak); + return c * smoothstep(bloomThreshold(), bloomThreshold() + BLOOM_SOFTNESS, peak); } // Interleaved gradient noise: a dither with no visible pattern. @@ -54,24 +58,24 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { // The beam converges least at the corners: red and blue drift apart // there, by a third of a pixel. - vec2 chroma = cc * (CHROMA_PX * r2 * 0.5) * texel; + vec2 chroma = cc * (chromaPx() * r2 * 0.5) * texel; vec3 col = vec3(scene(uv + chroma).r, scene(uv).g, scene(uv - chroma).b); - vec2 b = texel * BLOOM_RADIUS_PX; + vec2 b = texel * bloomRadiusPx(); vec3 bloom = brightPass(uv + vec2(b.x, 0.0)) + brightPass(uv - vec2(b.x, 0.0)) + brightPass(uv + vec2(0.0, b.y)) + brightPass(uv - vec2(0.0, b.y)); - col += bloom * (0.25 * BLOOM_STRENGTH); + col += bloom * (0.25 * bloomStrength()); // A beam is brightest on its line and falls off between lines; the // mask is three phosphor stripes a triad. Both average to one, so the // theme keeps its brightness. float line = 0.5 + 0.5 * cos(fragCoord.y * TAU / SCAN_PERIOD_PX); - col *= 1.0 - SCAN_STRENGTH * (line * line - 0.375) / 0.625; - col *= 1.0 + MASK_STRENGTH * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)); + col *= 1.0 - scanStrength() * (line * line - 0.375) / 0.625; + col *= 1.0 + maskStrength() * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)); - float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); - float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU); - col *= vignette * hum * EXPOSURE; + float vignette = 1.0 - vignetteStrength() * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); + float hum = 1.0 + humStrength() * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU); + col *= vignette * hum * exposure(); vec3 srgb = toSrgb(col); srgb += (ign(fragCoord + float(iFrame % 64) * vec2(5.588238, 5.588238)) - 0.5) / 255.0; diff --git a/shaders/post/glitch.glsl b/shaders/post/glitch.glsl index ff9ebcb8..8f420d9c 100644 --- a/shaders/post/glitch.glsl +++ b/shaders/post/glitch.glsl @@ -1,4 +1,5 @@ -// Glitch: now and then the signal slips. Every couple of seconds a short +// Glitch: now and then the signal slips. At the default level, every couple +// of seconds a short // burst tears a few horizontal bands sideways and pulls their red and blue // apart; the bands are sized in pixels and the burst eases in and out over // a fraction of a second, so it reads as one event rather than a flicker, @@ -7,12 +8,15 @@ // A torn band's brightness dips in linear light. It is a Shadertoy file: // pardes puts its prefix before it. -const float PERIOD = 2.4; // seconds between bursts, on average -const float BURST = 0.28; // seconds a burst lasts -const float STEP_HZ = 24.0; // how often a burst re-tears -const float SHIFT_PX = 14.0; -const float SPLIT_PX = 2.5; -const float DIP = 0.12; +// Each at levels 1, 2 and 3 (`Glitch 1`..`Glitch 3`); 2 is the default. A +// higher level slips more often and harder. +float period() { return pardesByLevel(4.5, 2.4, 0.55); } // seconds between bursts, on average +float burst() { return pardesByLevel(0.2, 0.28, 0.5); } // seconds a burst lasts +float stepHz() { return pardesByLevel(20.0, 24.0, 30.0); } // how often a burst re-tears +float shiftPx() { return pardesByLevel(8.0, 14.0, 34.0); } +float splitPx() { return pardesByLevel(1.5, 2.5, 6.0); } +float dip() { return pardesByLevel(0.08, 0.12, 0.22); } +float tearFrom() { return pardesByLevel(0.8, 0.72, 0.35); } // how few bands tear float hash(vec2 p) { vec3 q = fract(vec3(p.xyx) * 0.1031); @@ -34,27 +38,27 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / size; // Which burst window this is, when in it the burst starts, and how far // into the burst we are: 0 outside one. - float window = floor(iTime / PERIOD); - float start = window * PERIOD + hash(vec2(window, 7.0)) * (PERIOD - BURST); - float t = (iTime - start) / BURST; + float window = floor(iTime / period()); + float start = window * period() + hash(vec2(window, 7.0)) * (period() - burst()); + float t = (iTime - start) / burst(); float live = step(0.0, t) * step(t, 1.0); float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); if (envelope <= 0.0) { fragColor = texture(iChannel0, uv); return; } - // Bands of 6 to 40 px, re-cut at STEP_HZ during the burst; about one + // Bands of 6 to 40 px, re-cut at stepHz() during the burst; about one // band in four tears. - float tick = floor(iTime * STEP_HZ); + float tick = floor(iTime * stepHz()); float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); float band = floor(fragCoord.y / band_px); float roll = hash(vec2(band, tick)); - float tear = smoothstep(0.72, 0.9, roll) * envelope; - float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * SHIFT_PX * tear; + float tear = smoothstep(tearFrom(), tearFrom() + 0.18, roll) * envelope; + float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * shiftPx() * tear; vec2 at = vec2(fragCoord.x + shift, fragCoord.y); - vec2 split = vec2(SPLIT_PX * tear, 0.0); + vec2 split = vec2(splitPx() * tear, 0.0); vec4 center = texture(iChannel0, at / size); vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); - col = toSrgb(toLinear(col) * (1.0 - DIP * tear)); + col = toSrgb(toLinear(col) * (1.0 - dip() * tear)); fragColor = vec4(col, center.a); } diff --git a/shaders/post/prefix.glsl b/shaders/post/prefix.glsl index efa49483..e718a98f 100644 --- a/shaders/post/prefix.glsl +++ b/shaders/post/prefix.glsl @@ -39,12 +39,19 @@ layout(set = 3, binding = 0, std140) uniform Globals { uniform vec3 iSelectionBackgroundColor; }; -// pardes's own: 1 when the window is opaque, so a pass cannot punch holes -// in it through its alpha. +// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass +// cannot punch holes in it through its alpha; pardesLevel is a bundled +// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. layout(set = 3, binding = 1, std140) uniform PardesPost { float pardesOpaque; + float pardesLevel; }; +// A bundled pass's constant at levels 1, 2 and 3. +float pardesByLevel(float one, float two, float three) { + return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); +} + #define CURSORSTYLE_BLOCK 0 #define CURSORSTYLE_BLOCK_HOLLOW 1 #define CURSORSTYLE_BAR 2 diff --git a/shaders/post/ripple.glsl b/shaders/post/ripple.glsl index ffecbf70..3236d339 100644 --- a/shaders/post/ripple.glsl +++ b/shaders/post/ripple.glsl @@ -1,18 +1,21 @@ // Ripple: the page as a still pool a slow swell crosses. Rings travel out // from the middle of the window at a fixed speed in pixels, so they look -// the same at any size and frame rate; the displacement is at most a couple -// of pixels, so text stays readable, and it eases in over its first half +// the same at any size and frame rate; at the default level the displacement +// is at most a couple of pixels, so text stays readable, and it eases in over its first half // second instead of snapping on. Each ring catches the light a little on its // rising side, in linear light, and the result is dithered so the shading // never bands. It is a Shadertoy file: pardes puts its prefix before it. const float TAU = 6.28318530718; -const float WAVELENGTH_PX = 150.0; -const float SPEED_PX = 55.0; // outward, per second -const float AMPLITUDE_PX = 2.2; -const float SHADE = 0.035; // light on a slope, linear const float EASE_IN = 0.5; // seconds +// Each at levels 1, 2 and 3 (`Ripple 1`..`Ripple 3`); 2 is the default. +float wavelengthPx() { return pardesByLevel(170.0, 150.0, 120.0); } +float speedPx() { return pardesByLevel(40.0, 55.0, 80.0); } // outward, per second +float amplitudePx() { return pardesByLevel(1.2, 2.2, 5.0); } +float shade() { return pardesByLevel(0.02, 0.035, 0.09); } // light on a slope, linear +float reachEnd() { return pardesByLevel(0.85, 0.95, 1.3); } // how far out it dies away + vec3 toLinear(vec3 c) { return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); } @@ -34,16 +37,16 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { float reach = 0.5 * length(size); // Strongest a third of the way out, fading to nothing at the middle // and at the edges, so the window's frame never wobbles. - float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, 0.95, radius / reach)); + float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, reachEnd(), radius / reach)); float ease = smoothstep(0.0, EASE_IN, iTime); - float phase = (radius - iTime * SPEED_PX) * TAU / WAVELENGTH_PX; + float phase = (radius - iTime * speedPx()) * TAU / wavelengthPx(); float height = sin(phase); float slope = cos(phase); vec2 dir = offset / max(radius, 1e-3); - vec2 moved = fragCoord + dir * height * AMPLITUDE_PX * envelope * ease; + vec2 moved = fragCoord + dir * height * amplitudePx() * envelope * ease; vec4 s = texture(iChannel0, moved / size); - vec3 col = toLinear(s.rgb) * (1.0 + SHADE * slope * envelope * ease); + vec3 col = toLinear(s.rgb) * (1.0 + shade() * slope * envelope * ease); vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); } diff --git a/shaders/prebuilt/post-crt.frag.glsl b/shaders/prebuilt/post-crt.frag.glsl index 11b95272..c2cadfa2 100644 --- a/shaders/prebuilt/post-crt.frag.glsl +++ b/shaders/prebuilt/post-crt.frag.glsl @@ -39,12 +39,19 @@ layout(set = 3, binding = 0, std140) uniform Globals { uniform vec3 iSelectionBackgroundColor; }; -// pardes's own: 1 when the window is opaque, so a pass cannot punch holes -// in it through its alpha. +// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass +// cannot punch holes in it through its alpha; pardesLevel is a bundled +// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. layout(set = 3, binding = 1, std140) uniform PardesPost { float pardesOpaque; + float pardesLevel; }; +// A bundled pass's constant at levels 1, 2 and 3. +float pardesByLevel(float one, float two, float three) { + return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); +} + #define CURSORSTYLE_BLOCK 0 #define CURSORSTYLE_BLOCK_HOLLOW 1 #define CURSORSTYLE_BAR 2 @@ -74,19 +81,23 @@ void main() { // Shadertoy file: pardes puts its prefix before it (shaders/post/prefix.glsl). const float TAU = 6.28318530718; -const float CHROMA_PX = 0.35; // red and blue apart, at the corners -const float BLOOM_RADIUS_PX = 1.75; -const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak +// Level 3 glows wider and from dimmer ink. +float bloomRadiusPx() { return pardesByLevel(1.75, 1.75, 3.0); } +float bloomThreshold() { return pardesByLevel(0.6, 0.6, 0.3); } // linear-light channel peak const float BLOOM_SOFTNESS = 0.25; -const float BLOOM_STRENGTH = 0.05; const float SCAN_PERIOD_PX = 3.0; -const float SCAN_STRENGTH = 0.07; const float MASK_PERIOD_PX = 3.0; -const float MASK_STRENGTH = 0.018; -const float VIGNETTE_STRENGTH = 0.12; // at the far corners only -const float HUM_STRENGTH = 0.004; const float HUM_HZ = 0.11; -const float EXPOSURE = 1.015; + +// Each strength at levels 1, 2 and 3 (`Crt 1`..`Crt 3`): 2 is the default, +// 3 as strong as the old barrel CRT looked. +float chromaPx() { return pardesByLevel(0.2, 0.35, 2.2); } // red and blue apart, at the corners +float bloomStrength() { return pardesByLevel(0.03, 0.05, 0.3); } +float scanStrength() { return pardesByLevel(0.035, 0.07, 0.42); } +float maskStrength() { return pardesByLevel(0.01, 0.018, 0.12); } +float vignetteStrength() { return pardesByLevel(0.06, 0.12, 0.42); } // at the far corners only +float humStrength() { return pardesByLevel(0.002, 0.004, 0.012); } +float exposure() { return pardesByLevel(1.008, 1.015, 1.1); } vec3 toLinear(vec3 c) { return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); @@ -104,7 +115,7 @@ vec3 scene(vec2 uv) { vec3 brightPass(vec2 uv) { vec3 c = scene(uv); float peak = max(c.r, max(c.g, c.b)); - return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak); + return c * smoothstep(bloomThreshold(), bloomThreshold() + BLOOM_SOFTNESS, peak); } // Interleaved gradient noise: a dither with no visible pattern. @@ -121,24 +132,24 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { // The beam converges least at the corners: red and blue drift apart // there, by a third of a pixel. - vec2 chroma = cc * (CHROMA_PX * r2 * 0.5) * texel; + vec2 chroma = cc * (chromaPx() * r2 * 0.5) * texel; vec3 col = vec3(scene(uv + chroma).r, scene(uv).g, scene(uv - chroma).b); - vec2 b = texel * BLOOM_RADIUS_PX; + vec2 b = texel * bloomRadiusPx(); vec3 bloom = brightPass(uv + vec2(b.x, 0.0)) + brightPass(uv - vec2(b.x, 0.0)) + brightPass(uv + vec2(0.0, b.y)) + brightPass(uv - vec2(0.0, b.y)); - col += bloom * (0.25 * BLOOM_STRENGTH); + col += bloom * (0.25 * bloomStrength()); // A beam is brightest on its line and falls off between lines; the // mask is three phosphor stripes a triad. Both average to one, so the // theme keeps its brightness. float line = 0.5 + 0.5 * cos(fragCoord.y * TAU / SCAN_PERIOD_PX); - col *= 1.0 - SCAN_STRENGTH * (line * line - 0.375) / 0.625; - col *= 1.0 + MASK_STRENGTH * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)); + col *= 1.0 - scanStrength() * (line * line - 0.375) / 0.625; + col *= 1.0 + maskStrength() * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)); - float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); - float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU); - col *= vignette * hum * EXPOSURE; + float vignette = 1.0 - vignetteStrength() * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); + float hum = 1.0 + humStrength() * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU); + col *= vignette * hum * exposure(); vec3 srgb = toSrgb(col); srgb += (ign(fragCoord + float(iFrame % 64) * vec2(5.588238, 5.588238)) - 0.5) / 255.0; diff --git a/shaders/prebuilt/post-crt.frag.spv b/shaders/prebuilt/post-crt.frag.spv index e4d98138..6599abd9 100644 Binary files a/shaders/prebuilt/post-crt.frag.spv and b/shaders/prebuilt/post-crt.frag.spv differ diff --git a/shaders/prebuilt/post-glitch.frag.glsl b/shaders/prebuilt/post-glitch.frag.glsl index 46538452..7b288237 100644 --- a/shaders/prebuilt/post-glitch.frag.glsl +++ b/shaders/prebuilt/post-glitch.frag.glsl @@ -39,12 +39,19 @@ layout(set = 3, binding = 0, std140) uniform Globals { uniform vec3 iSelectionBackgroundColor; }; -// pardes's own: 1 when the window is opaque, so a pass cannot punch holes -// in it through its alpha. +// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass +// cannot punch holes in it through its alpha; pardesLevel is a bundled +// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. layout(set = 3, binding = 1, std140) uniform PardesPost { float pardesOpaque; + float pardesLevel; }; +// A bundled pass's constant at levels 1, 2 and 3. +float pardesByLevel(float one, float two, float three) { + return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); +} + #define CURSORSTYLE_BLOCK 0 #define CURSORSTYLE_BLOCK_HOLLOW 1 #define CURSORSTYLE_BAR 2 @@ -65,7 +72,8 @@ void main() { // The shader's own lines count from 1, so glslc's errors name them. #line 1 -// Glitch: now and then the signal slips. Every couple of seconds a short +// Glitch: now and then the signal slips. At the default level, every couple +// of seconds a short // burst tears a few horizontal bands sideways and pulls their red and blue // apart; the bands are sized in pixels and the burst eases in and out over // a fraction of a second, so it reads as one event rather than a flicker, @@ -74,12 +82,15 @@ void main() { // A torn band's brightness dips in linear light. It is a Shadertoy file: // pardes puts its prefix before it. -const float PERIOD = 2.4; // seconds between bursts, on average -const float BURST = 0.28; // seconds a burst lasts -const float STEP_HZ = 24.0; // how often a burst re-tears -const float SHIFT_PX = 14.0; -const float SPLIT_PX = 2.5; -const float DIP = 0.12; +// Each at levels 1, 2 and 3 (`Glitch 1`..`Glitch 3`); 2 is the default. A +// higher level slips more often and harder. +float period() { return pardesByLevel(4.5, 2.4, 0.55); } // seconds between bursts, on average +float burst() { return pardesByLevel(0.2, 0.28, 0.5); } // seconds a burst lasts +float stepHz() { return pardesByLevel(20.0, 24.0, 30.0); } // how often a burst re-tears +float shiftPx() { return pardesByLevel(8.0, 14.0, 34.0); } +float splitPx() { return pardesByLevel(1.5, 2.5, 6.0); } +float dip() { return pardesByLevel(0.08, 0.12, 0.22); } +float tearFrom() { return pardesByLevel(0.8, 0.72, 0.35); } // how few bands tear float hash(vec2 p) { vec3 q = fract(vec3(p.xyx) * 0.1031); @@ -101,27 +112,27 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / size; // Which burst window this is, when in it the burst starts, and how far // into the burst we are: 0 outside one. - float window = floor(iTime / PERIOD); - float start = window * PERIOD + hash(vec2(window, 7.0)) * (PERIOD - BURST); - float t = (iTime - start) / BURST; + float window = floor(iTime / period()); + float start = window * period() + hash(vec2(window, 7.0)) * (period() - burst()); + float t = (iTime - start) / burst(); float live = step(0.0, t) * step(t, 1.0); float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); if (envelope <= 0.0) { fragColor = texture(iChannel0, uv); return; } - // Bands of 6 to 40 px, re-cut at STEP_HZ during the burst; about one + // Bands of 6 to 40 px, re-cut at stepHz() during the burst; about one // band in four tears. - float tick = floor(iTime * STEP_HZ); + float tick = floor(iTime * stepHz()); float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); float band = floor(fragCoord.y / band_px); float roll = hash(vec2(band, tick)); - float tear = smoothstep(0.72, 0.9, roll) * envelope; - float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * SHIFT_PX * tear; + float tear = smoothstep(tearFrom(), tearFrom() + 0.18, roll) * envelope; + float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * shiftPx() * tear; vec2 at = vec2(fragCoord.x + shift, fragCoord.y); - vec2 split = vec2(SPLIT_PX * tear, 0.0); + vec2 split = vec2(splitPx() * tear, 0.0); vec4 center = texture(iChannel0, at / size); vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); - col = toSrgb(toLinear(col) * (1.0 - DIP * tear)); + col = toSrgb(toLinear(col) * (1.0 - dip() * tear)); fragColor = vec4(col, center.a); } diff --git a/shaders/prebuilt/post-glitch.frag.spv b/shaders/prebuilt/post-glitch.frag.spv index 8a224958..619616a8 100644 Binary files a/shaders/prebuilt/post-glitch.frag.spv and b/shaders/prebuilt/post-glitch.frag.spv differ diff --git a/shaders/prebuilt/post-ripple.frag.glsl b/shaders/prebuilt/post-ripple.frag.glsl index 28059def..d29a4c22 100644 --- a/shaders/prebuilt/post-ripple.frag.glsl +++ b/shaders/prebuilt/post-ripple.frag.glsl @@ -39,12 +39,19 @@ layout(set = 3, binding = 0, std140) uniform Globals { uniform vec3 iSelectionBackgroundColor; }; -// pardes's own: 1 when the window is opaque, so a pass cannot punch holes -// in it through its alpha. +// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass +// cannot punch holes in it through its alpha; pardesLevel is a bundled +// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. layout(set = 3, binding = 1, std140) uniform PardesPost { float pardesOpaque; + float pardesLevel; }; +// A bundled pass's constant at levels 1, 2 and 3. +float pardesByLevel(float one, float two, float three) { + return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); +} + #define CURSORSTYLE_BLOCK 0 #define CURSORSTYLE_BLOCK_HOLLOW 1 #define CURSORSTYLE_BAR 2 @@ -67,19 +74,22 @@ void main() { #line 1 // Ripple: the page as a still pool a slow swell crosses. Rings travel out // from the middle of the window at a fixed speed in pixels, so they look -// the same at any size and frame rate; the displacement is at most a couple -// of pixels, so text stays readable, and it eases in over its first half +// the same at any size and frame rate; at the default level the displacement +// is at most a couple of pixels, so text stays readable, and it eases in over its first half // second instead of snapping on. Each ring catches the light a little on its // rising side, in linear light, and the result is dithered so the shading // never bands. It is a Shadertoy file: pardes puts its prefix before it. const float TAU = 6.28318530718; -const float WAVELENGTH_PX = 150.0; -const float SPEED_PX = 55.0; // outward, per second -const float AMPLITUDE_PX = 2.2; -const float SHADE = 0.035; // light on a slope, linear const float EASE_IN = 0.5; // seconds +// Each at levels 1, 2 and 3 (`Ripple 1`..`Ripple 3`); 2 is the default. +float wavelengthPx() { return pardesByLevel(170.0, 150.0, 120.0); } +float speedPx() { return pardesByLevel(40.0, 55.0, 80.0); } // outward, per second +float amplitudePx() { return pardesByLevel(1.2, 2.2, 5.0); } +float shade() { return pardesByLevel(0.02, 0.035, 0.09); } // light on a slope, linear +float reachEnd() { return pardesByLevel(0.85, 0.95, 1.3); } // how far out it dies away + vec3 toLinear(vec3 c) { return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); } @@ -101,16 +111,16 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { float reach = 0.5 * length(size); // Strongest a third of the way out, fading to nothing at the middle // and at the edges, so the window's frame never wobbles. - float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, 0.95, radius / reach)); + float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, reachEnd(), radius / reach)); float ease = smoothstep(0.0, EASE_IN, iTime); - float phase = (radius - iTime * SPEED_PX) * TAU / WAVELENGTH_PX; + float phase = (radius - iTime * speedPx()) * TAU / wavelengthPx(); float height = sin(phase); float slope = cos(phase); vec2 dir = offset / max(radius, 1e-3); - vec2 moved = fragCoord + dir * height * AMPLITUDE_PX * envelope * ease; + vec2 moved = fragCoord + dir * height * amplitudePx() * envelope * ease; vec4 s = texture(iChannel0, moved / size); - vec3 col = toLinear(s.rgb) * (1.0 + SHADE * slope * envelope * ease); + vec3 col = toLinear(s.rgb) * (1.0 + shade() * slope * envelope * ease); vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); } diff --git a/shaders/prebuilt/post-ripple.frag.spv b/shaders/prebuilt/post-ripple.frag.spv index 1a105a33..5f05f9f3 100644 Binary files a/shaders/prebuilt/post-ripple.frag.spv and b/shaders/prebuilt/post-ripple.frag.spv differ diff --git a/src/config.zig b/src/config.zig index 1d4043ab..e8c5a705 100644 --- a/src/config.zig +++ b/src/config.zig @@ -628,7 +628,12 @@ pub const Runtime = struct { pub const Entry = struct { scene: ?Scene = null, path: Text(limits.host_path_cap) = .{}, + /// A bundled pass's strength, 1 to `max_level`; 0 for a file. + level: u8 = 0, }; + pub const max_level = 3; + /// What `on` and a bare word turn a bundled pass on at. + pub const default_level = 2; entries: [max]Entry = @splat(.{}), len: u8 = 0, /// Bumped on every change: a shell compiles the chain again when it @@ -640,8 +645,28 @@ pub const Runtime = struct { } pub fn has(post: *const Post, scene: Scene) bool { - for (post.list()) |entry| if (entry.scene == scene) return true; - return false; + return post.level(scene) != 0; + } + + /// A bundled pass's level; 0 when it is not in the chain. + pub fn level(post: *const Post, scene: Scene) u8 { + for (post.list()) |entry| if (entry.scene == scene) return entry.level; + return 0; + } + + /// Sets a bundled pass's level: 0 takes it out, anything else puts it + /// in at the end or changes it where it is. + pub fn setLevel(post: *Post, scene: Scene, to: u8) bool { + std.debug.assert(to <= max_level); + if (to == 0) return post.set(scene, "", false); + for (post.entries[0..post.len]) |*entry| if (entry.scene == scene) { + if (entry.level != to) post.generation +%= 1; + entry.level = to; + return true; + }; + if (!post.set(scene, "", true)) return false; + post.entries[post.len - 1].level = to; + return true; } /// Puts the pass in the chain, at its end, or takes it out; `want` @@ -949,8 +974,19 @@ pub const Runtime = struct { } }, .scene => |effect| { - const want = switchedTo(argument) catch return false; - return state.post.set(effect, "", want); + // A level, 0 (off) to 3; `on` is the default level, `off` 0, + // and bare flips between the two. + const text = std.mem.trim(u8, argument orelse "", " \t\r\n"); + const to: u8 = if (text.len == 0) + (if (state.post.has(effect)) 0 else Post.default_level) + else if (std.mem.eql(u8, text, "on")) + Post.default_level + else if (std.mem.eql(u8, text, "off")) + 0 + else + std.fmt.parseInt(u8, text, 10) catch return false; + if (to > Post.max_level) return false; + return state.post.setLevel(effect, to); }, .shader => { const path = std.mem.trim(u8, argument orelse return false, " \t\r\n"); @@ -1119,7 +1155,8 @@ pub const Runtime = struct { try out.print("{s} unsupported\n", .{setting.word}); continue; } - try out.print("{s} {s}\n", .{ setting.word, onOff(state.post.has(effect)) }); + const level = state.post.level(effect); + if (level == 0) try out.print("{s} off\n", .{setting.word}) else try out.print("{s} {d}\n", .{ setting.word, level }); }, .shader => { if (!setting.enabled(context.capabilities)) { @@ -1246,6 +1283,28 @@ pub const Runtime = struct { try std.testing.expect(!apply(&state, find("ShaderAnimation").?, "sometimes")); } + test "a bundled pass takes a level: on is the default, 0 and off take it out, and the old words still flip" { + var state: Runtime = .{}; + const crt = find("Crt").?; + try std.testing.expect(apply(&state, crt, "on")); + try std.testing.expectEqual(@as(u8, Post.default_level), state.post.level(.crt)); + try std.testing.expect(apply(&state, find("Glitch").?, "1")); + const moved = state.post.generation; + try std.testing.expect(apply(&state, crt, "3")); + try std.testing.expectEqual(@as(u8, 3), state.post.level(.crt)); + // A new level keeps its place in the chain. + try std.testing.expectEqual(Scene.crt, state.post.list()[0].scene.?); + try std.testing.expect(state.post.generation != moved); + try std.testing.expect(!apply(&state, crt, "4")); + try std.testing.expect(!apply(&state, crt, "loud")); + try std.testing.expect(apply(&state, crt, "0")); + try std.testing.expect(!state.post.has(.crt)); + try std.testing.expect(apply(&state, crt, null)); + try std.testing.expectEqual(@as(u8, Post.default_level), state.post.level(.crt)); + try std.testing.expect(apply(&state, crt, "off")); + try std.testing.expectEqual(@as(u8, 0), state.post.level(.crt)); + } + test "tagline size validates before mutating live state" { const setting = find("TaglineSize").?; var state: Runtime = .{}; @@ -1412,9 +1471,9 @@ pub const Runtime = struct { .typewriter => "Panel transition PanelType\n", }, .scene => |effect| switch (effect) { - .crt => "Crt on\n", - .ripple => "Ripple on\n", - .glitch => "Glitch on\n", + .crt => "Crt 2\n", + .ripple => "Ripple 2\n", + .glitch => "Glitch 2\n", }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", diff --git a/src/gui/Post.zig b/src/gui/Post.zig index c529efc0..d9d3d53e 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -69,6 +69,8 @@ pub const Uniforms = extern struct { 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, @@ -117,7 +119,7 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUD 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); - next[i] = .{ .scene = scene, .pipeline = post.bundled.get(scene) }; + 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. @@ -405,7 +407,6 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, 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) }; - const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), 0, 0, 0 }; var input = source; var index: usize = 0; @@ -423,6 +424,7 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, 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), 0, 0 }; c.SDL_PushGPUFragmentUniformData(cmd, 1, &own, @sizeOf(@TypeOf(own))); c.SDL_DrawGPUPrimitives(pass_rp, 3, 1, 0, 0); c.SDL_EndGPURenderPass(pass_rp); diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index ed10dd70..cd327d03 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -263,7 +263,7 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { // Bare, it names the default directory. .dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}), .transition => |effect| try w.print("{s} {s}\n", .{ word, if (s.panel_transition == effect) "on" else "off" }), - .scene => |effect| try w.print("{s} {s}\n", .{ word, if (s.post.has(effect)) "on" else "off" }), + .scene => |effect| if (s.post.level(effect) == 0) try w.print("{s} off\n", .{word}) else try w.print("{s} {d}\n", .{ word, s.post.level(effect) }), .shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }), .shader_animation => try w.print("{s} {s}\n", .{ word, @tagName(s.shader_animation) }), } diff --git a/test/output.zig b/test/output.zig index 400e9508..0fdd9f4d 100644 --- a/test/output.zig +++ b/test/output.zig @@ -667,7 +667,8 @@ test "every enabled setting builtin mutates the State Config reports" { else "Panel transition unsupported\n"; try std.testing.expect(std.mem.indexOf(u8, report, transition) != null); - const scene_status = if (builtins.capabilities.scene_shaders) "on" else "unsupported"; + // A bare word turns a bundled pass on at its default level. + const scene_status = if (builtins.capabilities.scene_shaders) "2" else "unsupported"; for ([_][]const u8{ "Crt", "Ripple", "Glitch" }) |name| { const line = try std.fmt.bufPrint(&buf, "{s} {s}\n", .{ name, scene_status }); try std.testing.expect(std.mem.indexOf(u8, report, line) != null); -- cgit v1.3 From a007457cb007b03f310bca55c339e8cd5d9baafc Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:44:51 -0300 Subject: Ripple and Glitch are gone; Crt stays The user tried them live and wants them removed entirely: the builtins, their levels, config keys and leader paths (tR, tg), their slots in the built-in chain, their shaders (source and prebuilt), EffectCode paths, and on macOS their scene flags (pardes.h), the Metal kernel's coordinate warps and the Swift pointer mapping that mirrored them. Docs say so (decision 8 in docs/render-pipeline.md); config.md documents Crt's levels, Shader and ShaderAnimation instead. Shared files touched: macos.zig (flags). Not touched: pardes.zig, Messages.zig, mouse.zig, gui.zig, detached/*. --- build.zig | 5 +- docs/config.md | 15 ++-- docs/design.typ | 5 +- docs/macos.md | 21 ++--- docs/render-pipeline.md | 19 ++-- docs/web.md | 2 +- shaders/crt.ci.metal | 75 ++-------------- shaders/post/glitch.glsl | 64 ------------- shaders/post/ripple.glsl | 52 ----------- shaders/prebuilt/post-glitch.frag.glsl | 138 ----------------------------- shaders/prebuilt/post-glitch.frag.spv | Bin 10764 -> 0 bytes shaders/prebuilt/post-ripple.frag.glsl | 126 -------------------------- shaders/prebuilt/post-ripple.frag.spv | Bin 9096 -> 0 bytes src/config.zig | 22 ++--- src/effect_sources.zig | 8 +- src/gui/Post.zig | 8 +- src/macos.zig | 17 +--- src/macos/Sources/PardesView.swift | 2 +- src/macos/Sources/ScenePostprocessor.swift | 48 +--------- src/macos/pardes.h | 5 +- test/output.zig | 2 +- 21 files changed, 61 insertions(+), 573 deletions(-) delete mode 100644 shaders/post/glitch.glsl delete mode 100644 shaders/post/ripple.glsl delete mode 100644 shaders/prebuilt/post-glitch.frag.glsl delete mode 100644 shaders/prebuilt/post-glitch.frag.spv delete mode 100644 shaders/prebuilt/post-ripple.frag.glsl delete mode 100644 shaders/prebuilt/post-ripple.frag.spv (limited to 'src/gui/Post.zig') diff --git a/build.zig b/build.zig index c1117593..97d51a51 100644 --- a/build.zig +++ b/build.zig @@ -51,10 +51,9 @@ const gui_shaders = [_][]const u8{ "overlay.vert", "overlay.frag", "image.vert", "image.frag", "post.vert", - // The bundled post passes: shaders/post/.glsl behind the Shadertoy + // The bundled post pass: shaders/post/crt.glsl behind the Shadertoy // prefix (shaderSource). - "post-crt.frag", "post-ripple.frag", - "post-glitch.frag", + "post-crt.frag", }; pub fn build(b: *std.Build) void { diff --git a/docs/config.md b/docs/config.md index 9c51f329..16fd59e5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -513,18 +513,19 @@ immediately and remains the one authoritative geometry. DOM web intentionally does not expose these builtins: its renderer is selectable HTML/CSS and has no canvas or shader stage. -The scene effects are independent switches and can be combined: +The scene effect is `Crt`, at a level from 0 (off) to 3; `on` is 2: ```text Crt -Ripple -Glitch +Crt 3 ``` -They share one full-scene shader pass in SDL and macOS. With all three off the -pass is bypassed. CRT works in linear light with restrained scanlines, mask, -bloom, curvature, and noise rather than remapping the theme to a strong fixed -palette; Ripple and Glitch primarily perturb sample coordinates. +The SDL GUI runs it as the bundled pass of its post chain, which also takes +Shadertoy files written for ghostty (`Shader ~/crt.glsl`, `Shader off`), and +`ShaderAnimation off|on|always` says when the chain animates on its own. With +the chain empty the pass is bypassed. CRT works in linear light with restrained +scanlines, mask, bloom and vignette, and no curvature, so clicks land where +they are drawn. `EffectCode PanelAscii` or `EffectCode Crt` lists the current backend's build-embedded source paths under `/virtual`. Look opens each full file; diff --git a/docs/design.typ b/docs/design.typ index 234c3692..caa35b37 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -913,8 +913,9 @@ while its geometry moves and placed again on the last active sample. SDL supplies old/new glyph data, diff flags, final/presented boxes, and effect parameters to the glyph and native-image shaders. Pixel attachments bypass ASCII because they have no character byte. macOS passes the same records across its -plain C ABI and composites old/new panel images in Metal and Core Image. Scene -`Crt`, `Ripple` and `Glitch` bits share one full-window pass in each native GUI. +plain C ABI and composites old/new panel images in Metal and Core Image. The +scene `Crt` is one full-window pass in each native GUI; the SDL GUI's post +chain also runs Shadertoy files (`Shader`). DOM web is a separate platform, not a shader GUI: retaining selectable HTML and CSS is more important than duplicating the renderer in canvas, so it exposes diff --git a/docs/macos.md b/docs/macos.md index e104d3a6..5166e2d5 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -102,8 +102,8 @@ asking for a thin insert caret instead of a block. `Surface.images` crosses separately as `pardes_frame_images` / `pardes_frame_image_list` — see "Pixel attachments" below. -`pardes_scene` is the matching full-window snapshot: a bit each for CRT, -ripple, and glitch, plus the 60 Hz time/frame that animates them. It is returned +`pardes_scene` is the matching full-window snapshot: a bit for CRT, plus the +60 Hz time/frame that animates it. It is returned by value, so the renderer never holds a pointer into live core state. A zero flags word is also the fast-path contract: draw the CoreText frame directly. @@ -662,8 +662,7 @@ longer leaves a dark titlebar over a cream grid. ## Scene effects -`Crt`, `Ripple`, and `Glitch` are three switches over one postprocess, not three -stacked filters. Their canonical macOS source is `shaders/crt.ci.metal`, which +`Crt` is one full-window postprocess. Its canonical macOS source is `shaders/crt.ci.metal`, which the build installs as `pardes.app/Contents/Resources/crt.ci.metal`. The Swift shell loads that exact asset with `CIKernel.kernels(withMetalString:)` and runs it through a `CIContext` created from the system Metal device. The source is @@ -673,14 +672,12 @@ The ordinary CoreText/attachment/cursor pass is one function. With any scene bit or panel track it targets a retained, backing-scale bitmap; kernels then sample the complete frame into the flipped view. PDFs, image panes, taglines, rules, and the caret therefore receive the same effect. With every bit off and -no panel track the bitmap and Core Image context are bypassed entirely. Ripple -and glitch alter sample coordinates only, preserving theme colors; CRT works +no panel track the bitmap and Core Image context are bypassed entirely. CRT works in linear light with restrained bloom, scan/mask, vignette, hum, and noise instead of applying a broad color remap. The scene kernel alone receives a `clampedToExtent` image and the final output -is cropped back to the original finite extent. Coordinate tears and chroma -samples therefore clamp to the edge exactly like SDL's scene sampler instead +is cropped back to the original finite extent. Chroma samples therefore clamp to the edge exactly like SDL's scene sampler instead of acquiring transparent-black seams from Core Image's finite source image. The Zig side owns the clock. `pardes_animation_tick` increments its wrapped @@ -689,8 +686,8 @@ from that integer. Input bursts cannot accelerate the shader. Pointer input follows the same destination-to-source transform as the last presented scene frame before it is divided by the cell metrics. The view keeps -that exact `pardes_scene_s` snapshot and mirrors the Metal barrel, ripple and -glitch sampling arithmetic in `ScenePostprocessor.sourcePoint`; pixels outside +that exact `pardes_scene_s` snapshot and mirrors the Metal barrel arithmetic +in `ScenePostprocessor.sourcePoint`; pixels outside the CRT tube have no cell. The resulting displayed-grid cell then reaches the core, whose panel-track mapping resolves it to canonical pane content. @@ -716,8 +713,8 @@ while `pardesComposedInCore` effects only clip the already-composed core cells; pixel attachments have no character value and pass through unchanged. Because the input is the finished bitmap rather than a glyph-only batch, backgrounds, glyphs, taglines, rules, the caret, PDF pages, and image -panes move and dissolve together. Scene CRT/ripple/glitch runs once after the -panel composition. +panes move and dissolve together. The scene CRT runs once after the panel +composition. With no scene bit and no panel track the retained bitmap, Core Image context, and Metal passes are bypassed. `EffectCode Panel*` links to this Metal diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index 2fd6045b..7c94fbea 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -505,16 +505,14 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`, `custom-shader-animation`): off = redraw only when content changes; on = continuous while the window is focused; always = continuous. Continuous means the shell redraws its retained Surface (3.1), never a core tick or render. -- **Existing crt/ripple/glitch** become bundled Shadertoy files in - `shaders/post/` selected by the same builtins (decision 2: kept, polished); - `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. Each bundled pass - takes a level, `Crt 0..3` (0 off, `on` the default 2, `off` and bare still - work), passed as `pardesLevel`: 2 is the polished rewrite, 3 at least as - strong as the old effects (pixels changed by more than 8 in a channel, - against a plain frame: Crt 18.3% vs 17.4% old, Ripple 11.1% vs 9.5%, Glitch - 9.3% a frame vs 8.1%); a Glitch level raises how often it slips as well as - how hard. The chain is - `Crt`, `Ripple`, `Glitch` and `Shader ` in the order they were put in +- **Existing crt** becomes a bundled Shadertoy file in `shaders/post/` under + the same builtin; `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. + It takes a level, `Crt 0..3` (0 off, `on` the default 2, `off` and bare + still work), passed as `pardesLevel`: 2 is the polished rewrite, 3 at least + as strong as the old CRT (pixels changed by more than 8 in a channel against + a plain frame: 18.3% vs 17.4%). Ripple and Glitch were rewritten too, then + removed at the user's word after a live look (decision 8). The chain is + `Crt` and `Shader ` in the order they were put in (`Shader off` empties it of files), `ShaderAnimation off|on|always` (default on). The core keeps no clock for it: level A redraws are the shell's. @@ -905,3 +903,4 @@ goldens; ssh/tty byte budgets; wire version bump breaks mixed-version attach. 5. The animation pacing fix goes into the no-visual-change phase. 6. Cursor blink is on by default. 7. The bundled CRT has no barrel distortion, so clicks stay exact. +8. (After trying them live) Ripple and Glitch are removed entirely; Crt stays. diff --git a/docs/web.md b/docs/web.md index 3a68f958..bbbed8a3 100644 --- a/docs/web.md +++ b/docs/web.md @@ -42,7 +42,7 @@ shells, and two comptime capability flags say so once each. It exposes no `PanelAscii`, `PanelVertical`, `PanelEdges`, `PanelFall`, `PanelWave`, `PanelCurtain`, `PanelScramble`, `PanelType`) because `capabilities.panel_transitions` is `pardes.hosted`, and no -`Crt`/`Ripple`/`Glitch` because `capabilities.scene_shaders` is +`Crt` or `Shader` because `capabilities.scene_shaders` is `platform == .gui or platform == .macos` (`builtins.capabilities`; the words themselves carry those availabilities in `config.Runtime.settings`). Applying either faithfully would require a second canvas renderer and give up diff --git a/shaders/crt.ci.metal b/shaders/crt.ci.metal index c7e91a7e..9159af18 100644 --- a/shaders/crt.ci.metal +++ b/shaders/crt.ci.metal @@ -1,7 +1,6 @@ // Native macOS panel and scene postprocess. Panel kernels consume the core's -// plain tracks; the final full-window kernel ports crt.frag.glsl and combines -// CRT, ripple and glitch. Scene constants and sampling geometry stay in step -// with GLSL, while panel easing/noise stays in step with panel_animation.zig. +// plain tracks; the final full-window kernel is the CRT. Panel easing/noise +// stays in step with panel_animation.zig. // // The three entry points are `extern "C" [[stitchable]]`. Both halves are // load-bearing: this source is compiled at runtime by @@ -38,24 +37,6 @@ float pardesCellNoise(float2 serialParts, float colValue, float rowValue) { return float(x & 0xffffu) / 65535.0; } -float2 pardesSceneUV(float2 uv, float timeSeconds, float rippleOn, float glitchOn) { - if (rippleOn > 0.5) { - float2 centered = uv - 0.5; - float radius = length(centered); - // Reversed smoothstep edges are undefined in GLSL and Metal alike. - float envelope = 1.0 - smoothstep(0.04, 0.72, radius); - float wave = sin(radius * 42.0 - timeSeconds * 5.2); - uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032; - } - if (glitchOn > 0.5) { - float band = floor(uv.y * 96.0); - float seed = pardesNoise(float2(band, floor(timeSeconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - uv.x += (pardesNoise(float2(seed, band)) - 0.5) * tear * 0.020; - } - return uv; -} - float4 pardesSceneSample( coreimage::sampler source, float2 origin, @@ -66,42 +47,13 @@ float4 pardesSceneSample( source.sample(source.transform(origin + uv * size))); } -// Keep this ordering identical to crt.frag.glsl's sceneSrgb: every caller -// supplies its own pre-warp coordinate, then ripple/glitch move that sample, -// then glitch splits its red/blue channels around the moved center. -float4 pardesSceneSrgb( - coreimage::sampler source, - float2 origin, - float2 size, - float2 uv, - float timeSeconds, - float rippleOn, - float glitchOn -) { - float2 warped = pardesSceneUV(uv, timeSeconds, rippleOn, glitchOn); - float4 center = pardesSceneSample(source, origin, size, warped); - if (glitchOn <= 0.5) return center; - - float band = floor(warped.y * 96.0); - float seed = pardesNoise(float2(band, floor(timeSeconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - float2 chroma = float2((0.35 + 1.25 * tear) / size.x, 0.0); - float4 red = pardesSceneSample(source, origin, size, warped + chroma); - float4 blue = pardesSceneSample(source, origin, size, warped - chroma); - return float4(red.r, center.g, blue.b, center.a); -} - float3 pardesSceneLinear( coreimage::sampler source, float2 origin, float2 size, - float2 uv, - float timeSeconds, - float rippleOn, - float glitchOn + float2 uv ) { - return coreimage::srgb_to_linear(pardesSceneSrgb( - source, origin, size, uv, timeSeconds, rippleOn, glitchOn).rgb); + return coreimage::srgb_to_linear(pardesSceneSample(source, origin, size, uv).rgb); } bool pardesInside(float2 p, float4 box) { @@ -242,8 +194,6 @@ extern "C" [[stitchable]] float4 pardesScene( float timeSeconds, float frame, float crtOn, - float rippleOn, - float glitchOn, float2 sceneSize, float2 sceneOrigin, coreimage::destination destination @@ -277,24 +227,18 @@ extern "C" [[stitchable]] float4 pardesScene( // transparent theme. if (crtOn > 0.5 && edge <= 0.0) return float4(0.0, 0.0, 0.0, 1.0); - float4 center = pardesSceneSrgb( - source, origin, size, uv, timeSeconds, rippleOn, glitchOn); + float4 center = pardesSceneSample(source, origin, size, uv); float3 scene = center.rgb; - // Ripple/glitch alone are coordinate effects. Returning the sampled sRGB - // values directly keeps exact theme colors exact. + // Without the CRT the scene passes through, exact. if (crtOn <= 0.5) { return coreimage::premultiply(float4(clamp(scene, 0.0, 1.0), center.a)); } float3 centerLinear = coreimage::srgb_to_linear(scene); float2 radialChroma = cc * (chromaPx * r2) * texel; - float3 redSample = pardesSceneLinear( - source, origin, size, uv + radialChroma, - timeSeconds, rippleOn, glitchOn); - float3 blueSample = pardesSceneLinear( - source, origin, size, uv - radialChroma, - timeSeconds, rippleOn, glitchOn); + float3 redSample = pardesSceneLinear(source, origin, size, uv + radialChroma); + float3 blueSample = pardesSceneLinear(source, origin, size, uv - radialChroma); float3 col = float3( redSample.r, centerLinear.g, @@ -308,8 +252,7 @@ extern "C" [[stitchable]] float4 pardesScene( float2(0.0, bloom.y), float2(0.0, -bloom.y) }; for (uint i = 0; i < 4; i++) { - // Deliberately raw, like GLSL brightPass: bloom belongs to the source - // image and does not recursively warp through the scene effects. + // Deliberately raw, like GLSL brightPass. float4 sampleValue = pardesSceneSample( source, origin, size, uv + bloomOffsets[i]); float3 linearValue = coreimage::srgb_to_linear(sampleValue.rgb); diff --git a/shaders/post/glitch.glsl b/shaders/post/glitch.glsl deleted file mode 100644 index 8f420d9c..00000000 --- a/shaders/post/glitch.glsl +++ /dev/null @@ -1,64 +0,0 @@ -// Glitch: now and then the signal slips. At the default level, every couple -// of seconds a short -// burst tears a few horizontal bands sideways and pulls their red and blue -// apart; the bands are sized in pixels and the burst eases in and out over -// a fraction of a second, so it reads as one event rather than a flicker, -// and it looks the same at 60 or 144 Hz because every step is keyed to -// iTime, not to frames. Between bursts the page is left exactly as it is. -// A torn band's brightness dips in linear light. It is a Shadertoy file: -// pardes puts its prefix before it. - -// Each at levels 1, 2 and 3 (`Glitch 1`..`Glitch 3`); 2 is the default. A -// higher level slips more often and harder. -float period() { return pardesByLevel(4.5, 2.4, 0.55); } // seconds between bursts, on average -float burst() { return pardesByLevel(0.2, 0.28, 0.5); } // seconds a burst lasts -float stepHz() { return pardesByLevel(20.0, 24.0, 30.0); } // how often a burst re-tears -float shiftPx() { return pardesByLevel(8.0, 14.0, 34.0); } -float splitPx() { return pardesByLevel(1.5, 2.5, 6.0); } -float dip() { return pardesByLevel(0.08, 0.12, 0.22); } -float tearFrom() { return pardesByLevel(0.8, 0.72, 0.35); } // how few bands tear - -float hash(vec2 p) { - vec3 q = fract(vec3(p.xyx) * 0.1031); - q += dot(q, q.yzx + 33.33); - return fract((q.x + q.y) * q.z); -} - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 size = iResolution.xy; - vec2 uv = fragCoord / size; - // Which burst window this is, when in it the burst starts, and how far - // into the burst we are: 0 outside one. - float window = floor(iTime / period()); - float start = window * period() + hash(vec2(window, 7.0)) * (period() - burst()); - float t = (iTime - start) / burst(); - float live = step(0.0, t) * step(t, 1.0); - float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); - if (envelope <= 0.0) { - fragColor = texture(iChannel0, uv); - return; - } - // Bands of 6 to 40 px, re-cut at stepHz() during the burst; about one - // band in four tears. - float tick = floor(iTime * stepHz()); - float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); - float band = floor(fragCoord.y / band_px); - float roll = hash(vec2(band, tick)); - float tear = smoothstep(tearFrom(), tearFrom() + 0.18, roll) * envelope; - float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * shiftPx() * tear; - vec2 at = vec2(fragCoord.x + shift, fragCoord.y); - vec2 split = vec2(splitPx() * tear, 0.0); - vec4 center = texture(iChannel0, at / size); - vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); - col = toSrgb(toLinear(col) * (1.0 - dip() * tear)); - fragColor = vec4(col, center.a); -} diff --git a/shaders/post/ripple.glsl b/shaders/post/ripple.glsl deleted file mode 100644 index 3236d339..00000000 --- a/shaders/post/ripple.glsl +++ /dev/null @@ -1,52 +0,0 @@ -// Ripple: the page as a still pool a slow swell crosses. Rings travel out -// from the middle of the window at a fixed speed in pixels, so they look -// the same at any size and frame rate; at the default level the displacement -// is at most a couple of pixels, so text stays readable, and it eases in over its first half -// second instead of snapping on. Each ring catches the light a little on its -// rising side, in linear light, and the result is dithered so the shading -// never bands. It is a Shadertoy file: pardes puts its prefix before it. - -const float TAU = 6.28318530718; -const float EASE_IN = 0.5; // seconds - -// Each at levels 1, 2 and 3 (`Ripple 1`..`Ripple 3`); 2 is the default. -float wavelengthPx() { return pardesByLevel(170.0, 150.0, 120.0); } -float speedPx() { return pardesByLevel(40.0, 55.0, 80.0); } // outward, per second -float amplitudePx() { return pardesByLevel(1.2, 2.2, 5.0); } -float shade() { return pardesByLevel(0.02, 0.035, 0.09); } // light on a slope, linear -float reachEnd() { return pardesByLevel(0.85, 0.95, 1.3); } // how far out it dies away - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -float ign(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 size = iResolution.xy; - vec2 center = 0.5 * size; - vec2 offset = fragCoord - center; - float radius = length(offset); - float reach = 0.5 * length(size); - // Strongest a third of the way out, fading to nothing at the middle - // and at the edges, so the window's frame never wobbles. - float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, reachEnd(), radius / reach)); - float ease = smoothstep(0.0, EASE_IN, iTime); - float phase = (radius - iTime * speedPx()) * TAU / wavelengthPx(); - float height = sin(phase); - float slope = cos(phase); - vec2 dir = offset / max(radius, 1e-3); - vec2 moved = fragCoord + dir * height * amplitudePx() * envelope * ease; - - vec4 s = texture(iChannel0, moved / size); - vec3 col = toLinear(s.rgb) * (1.0 + shade() * slope * envelope * ease); - vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; - fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); -} diff --git a/shaders/prebuilt/post-glitch.frag.glsl b/shaders/prebuilt/post-glitch.frag.glsl deleted file mode 100644 index 7b288237..00000000 --- a/shaders/prebuilt/post-glitch.frag.glsl +++ /dev/null @@ -1,138 +0,0 @@ -#version 450 -// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every -// post pass: a bundled one in shaders/post/ at build time, a user's file at -// run time. The uniform names, types and std140 order are ghostty 1.3.2's -// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for -// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's -// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as -// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor -// rectangles share one top-left space, and iCurrentCursor is (left x, -// BOTTOM-edge y, width, height) in framebuffer pixels. - -layout(set = 3, binding = 0, std140) uniform Globals { - uniform vec3 iResolution; - uniform float iTime; - uniform float iTimeDelta; - uniform float iFrameRate; - uniform int iFrame; - uniform float iChannelTime[4]; - uniform vec3 iChannelResolution[4]; - uniform vec4 iMouse; - uniform vec4 iDate; - uniform float iSampleRate; - uniform vec4 iCurrentCursor; - uniform vec4 iPreviousCursor; - uniform vec4 iCurrentCursorColor; - uniform vec4 iPreviousCursorColor; - uniform int iCurrentCursorStyle; - uniform int iPreviousCursorStyle; - uniform int iCursorVisible; - uniform float iTimeCursorChange; - uniform float iTimeFocus; - uniform int iFocus; - uniform vec3 iPalette[256]; - uniform vec3 iBackgroundColor; - uniform vec3 iForegroundColor; - uniform vec3 iCursorColor; - uniform vec3 iCursorText; - uniform vec3 iSelectionForegroundColor; - uniform vec3 iSelectionBackgroundColor; -}; - -// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass -// cannot punch holes in it through its alpha; pardesLevel is a bundled -// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. -layout(set = 3, binding = 1, std140) uniform PardesPost { - float pardesOpaque; - float pardesLevel; -}; - -// A bundled pass's constant at levels 1, 2 and 3. -float pardesByLevel(float one, float two, float three) { - return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); -} - -#define CURSORSTYLE_BLOCK 0 -#define CURSORSTYLE_BLOCK_HOLLOW 1 -#define CURSORSTYLE_BAR 2 -#define CURSORSTYLE_UNDERLINE 3 -#define CURSORSTYLE_LOCK 4 - -layout(set = 2, binding = 0) uniform sampler2D iChannel0; - -layout(location = 0) out vec4 _fragColor; - -#define texture2D texture - -void mainImage(out vec4 fragColor, in vec2 fragCoord); -void main() { - mainImage(_fragColor, gl_FragCoord.xy); - if (pardesOpaque > 0.5) _fragColor.a = 1.0; -} - -// The shader's own lines count from 1, so glslc's errors name them. -#line 1 -// Glitch: now and then the signal slips. At the default level, every couple -// of seconds a short -// burst tears a few horizontal bands sideways and pulls their red and blue -// apart; the bands are sized in pixels and the burst eases in and out over -// a fraction of a second, so it reads as one event rather than a flicker, -// and it looks the same at 60 or 144 Hz because every step is keyed to -// iTime, not to frames. Between bursts the page is left exactly as it is. -// A torn band's brightness dips in linear light. It is a Shadertoy file: -// pardes puts its prefix before it. - -// Each at levels 1, 2 and 3 (`Glitch 1`..`Glitch 3`); 2 is the default. A -// higher level slips more often and harder. -float period() { return pardesByLevel(4.5, 2.4, 0.55); } // seconds between bursts, on average -float burst() { return pardesByLevel(0.2, 0.28, 0.5); } // seconds a burst lasts -float stepHz() { return pardesByLevel(20.0, 24.0, 30.0); } // how often a burst re-tears -float shiftPx() { return pardesByLevel(8.0, 14.0, 34.0); } -float splitPx() { return pardesByLevel(1.5, 2.5, 6.0); } -float dip() { return pardesByLevel(0.08, 0.12, 0.22); } -float tearFrom() { return pardesByLevel(0.8, 0.72, 0.35); } // how few bands tear - -float hash(vec2 p) { - vec3 q = fract(vec3(p.xyx) * 0.1031); - q += dot(q, q.yzx + 33.33); - return fract((q.x + q.y) * q.z); -} - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 size = iResolution.xy; - vec2 uv = fragCoord / size; - // Which burst window this is, when in it the burst starts, and how far - // into the burst we are: 0 outside one. - float window = floor(iTime / period()); - float start = window * period() + hash(vec2(window, 7.0)) * (period() - burst()); - float t = (iTime - start) / burst(); - float live = step(0.0, t) * step(t, 1.0); - float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); - if (envelope <= 0.0) { - fragColor = texture(iChannel0, uv); - return; - } - // Bands of 6 to 40 px, re-cut at stepHz() during the burst; about one - // band in four tears. - float tick = floor(iTime * stepHz()); - float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); - float band = floor(fragCoord.y / band_px); - float roll = hash(vec2(band, tick)); - float tear = smoothstep(tearFrom(), tearFrom() + 0.18, roll) * envelope; - float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * shiftPx() * tear; - vec2 at = vec2(fragCoord.x + shift, fragCoord.y); - vec2 split = vec2(splitPx() * tear, 0.0); - vec4 center = texture(iChannel0, at / size); - vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); - col = toSrgb(toLinear(col) * (1.0 - dip() * tear)); - fragColor = vec4(col, center.a); -} diff --git a/shaders/prebuilt/post-glitch.frag.spv b/shaders/prebuilt/post-glitch.frag.spv deleted file mode 100644 index 619616a8..00000000 Binary files a/shaders/prebuilt/post-glitch.frag.spv and /dev/null differ diff --git a/shaders/prebuilt/post-ripple.frag.glsl b/shaders/prebuilt/post-ripple.frag.glsl deleted file mode 100644 index d29a4c22..00000000 --- a/shaders/prebuilt/post-ripple.frag.glsl +++ /dev/null @@ -1,126 +0,0 @@ -#version 450 -// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every -// post pass: a bundled one in shaders/post/ at build time, a user's file at -// run time. The uniform names, types and std140 order are ghostty 1.3.2's -// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for -// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's -// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as -// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor -// rectangles share one top-left space, and iCurrentCursor is (left x, -// BOTTOM-edge y, width, height) in framebuffer pixels. - -layout(set = 3, binding = 0, std140) uniform Globals { - uniform vec3 iResolution; - uniform float iTime; - uniform float iTimeDelta; - uniform float iFrameRate; - uniform int iFrame; - uniform float iChannelTime[4]; - uniform vec3 iChannelResolution[4]; - uniform vec4 iMouse; - uniform vec4 iDate; - uniform float iSampleRate; - uniform vec4 iCurrentCursor; - uniform vec4 iPreviousCursor; - uniform vec4 iCurrentCursorColor; - uniform vec4 iPreviousCursorColor; - uniform int iCurrentCursorStyle; - uniform int iPreviousCursorStyle; - uniform int iCursorVisible; - uniform float iTimeCursorChange; - uniform float iTimeFocus; - uniform int iFocus; - uniform vec3 iPalette[256]; - uniform vec3 iBackgroundColor; - uniform vec3 iForegroundColor; - uniform vec3 iCursorColor; - uniform vec3 iCursorText; - uniform vec3 iSelectionForegroundColor; - uniform vec3 iSelectionBackgroundColor; -}; - -// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass -// cannot punch holes in it through its alpha; pardesLevel is a bundled -// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. -layout(set = 3, binding = 1, std140) uniform PardesPost { - float pardesOpaque; - float pardesLevel; -}; - -// A bundled pass's constant at levels 1, 2 and 3. -float pardesByLevel(float one, float two, float three) { - return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); -} - -#define CURSORSTYLE_BLOCK 0 -#define CURSORSTYLE_BLOCK_HOLLOW 1 -#define CURSORSTYLE_BAR 2 -#define CURSORSTYLE_UNDERLINE 3 -#define CURSORSTYLE_LOCK 4 - -layout(set = 2, binding = 0) uniform sampler2D iChannel0; - -layout(location = 0) out vec4 _fragColor; - -#define texture2D texture - -void mainImage(out vec4 fragColor, in vec2 fragCoord); -void main() { - mainImage(_fragColor, gl_FragCoord.xy); - if (pardesOpaque > 0.5) _fragColor.a = 1.0; -} - -// The shader's own lines count from 1, so glslc's errors name them. -#line 1 -// Ripple: the page as a still pool a slow swell crosses. Rings travel out -// from the middle of the window at a fixed speed in pixels, so they look -// the same at any size and frame rate; at the default level the displacement -// is at most a couple of pixels, so text stays readable, and it eases in over its first half -// second instead of snapping on. Each ring catches the light a little on its -// rising side, in linear light, and the result is dithered so the shading -// never bands. It is a Shadertoy file: pardes puts its prefix before it. - -const float TAU = 6.28318530718; -const float EASE_IN = 0.5; // seconds - -// Each at levels 1, 2 and 3 (`Ripple 1`..`Ripple 3`); 2 is the default. -float wavelengthPx() { return pardesByLevel(170.0, 150.0, 120.0); } -float speedPx() { return pardesByLevel(40.0, 55.0, 80.0); } // outward, per second -float amplitudePx() { return pardesByLevel(1.2, 2.2, 5.0); } -float shade() { return pardesByLevel(0.02, 0.035, 0.09); } // light on a slope, linear -float reachEnd() { return pardesByLevel(0.85, 0.95, 1.3); } // how far out it dies away - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -float ign(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 size = iResolution.xy; - vec2 center = 0.5 * size; - vec2 offset = fragCoord - center; - float radius = length(offset); - float reach = 0.5 * length(size); - // Strongest a third of the way out, fading to nothing at the middle - // and at the edges, so the window's frame never wobbles. - float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, reachEnd(), radius / reach)); - float ease = smoothstep(0.0, EASE_IN, iTime); - float phase = (radius - iTime * speedPx()) * TAU / wavelengthPx(); - float height = sin(phase); - float slope = cos(phase); - vec2 dir = offset / max(radius, 1e-3); - vec2 moved = fragCoord + dir * height * amplitudePx() * envelope * ease; - - vec4 s = texture(iChannel0, moved / size); - vec3 col = toLinear(s.rgb) * (1.0 + shade() * slope * envelope * ease); - vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; - fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); -} diff --git a/shaders/prebuilt/post-ripple.frag.spv b/shaders/prebuilt/post-ripple.frag.spv deleted file mode 100644 index 5f05f9f3..00000000 Binary files a/shaders/prebuilt/post-ripple.frag.spv and /dev/null differ diff --git a/src/config.zig b/src/config.zig index e8c5a705..d1e5f23d 100644 --- a/src/config.zig +++ b/src/config.zig @@ -129,8 +129,6 @@ pub const leader_path = paths: { } if (builtins.capabilities.scene_shaders) { table.set(.Crt, "tr"); - table.set(.Ripple, "tR"); - table.set(.Glitch, "tg"); table.set(.Shader, null); table.set(.ShaderAnimation, null); } @@ -622,7 +620,7 @@ pub const Runtime = struct { pub const ShaderAnimation = enum { off, on, always }; - /// Each pass a bundled effect (Crt, Ripple, Glitch) or a Shadertoy file. + /// Each pass the bundled Crt or a Shadertoy file. pub const Post = struct { pub const max = 8; pub const Entry = struct { @@ -774,8 +772,8 @@ pub const Runtime = struct { pub const MessageTime = enum { linger, fall, dissolve }; pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures }; - /// The bundled post passes, each a Shadertoy file in shaders/post/. - pub const Scene = enum { crt, ripple, glitch }; + /// The bundled post pass, a Shadertoy file in shaders/post/. + pub const Scene = enum { crt }; pub const Action = union(enum) { toggle: Toggle, @@ -843,8 +841,6 @@ pub const Runtime = struct { .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions }, .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions }, .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, - .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders }, - .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders }, .{ .word = "Shader", .action = .shader, .availability = .scene_shaders }, .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders }, }; @@ -1263,18 +1259,18 @@ pub const Runtime = struct { test "the post chain runs in the order its passes were put in, and Shader off empties it of files" { var state: Runtime = .{}; try std.testing.expect(apply(&state, find("Shader").?, " ~/a.glsl\n")); - try std.testing.expect(apply(&state, find("Glitch").?, "on")); + try std.testing.expect(apply(&state, find("Crt").?, "on")); try std.testing.expect(apply(&state, find("Shader").?, "/b.glsl")); const chain = state.post.list(); try std.testing.expectEqual(@as(usize, 3), chain.len); try std.testing.expectEqualStrings("~/a.glsl", chain[0].path.get()); - try std.testing.expectEqual(Scene.glitch, chain[1].scene.?); + try std.testing.expectEqual(Scene.crt, chain[1].scene.?); try std.testing.expectEqualStrings("/b.glsl", chain[2].path.get()); const moved = state.post.generation; // A file named again leaves; the others keep their order. try std.testing.expect(apply(&state, find("Shader").?, "~/a.glsl")); try std.testing.expect(state.post.generation != moved); - try std.testing.expectEqual(Scene.glitch, state.post.list()[0].scene.?); + try std.testing.expectEqual(Scene.crt, state.post.list()[0].scene.?); try std.testing.expect(apply(&state, find("Shader").?, "off")); try std.testing.expectEqual(@as(usize, 1), state.post.len); try std.testing.expect(!apply(&state, find("Shader").?, null)); @@ -1288,7 +1284,7 @@ pub const Runtime = struct { const crt = find("Crt").?; try std.testing.expect(apply(&state, crt, "on")); try std.testing.expectEqual(@as(u8, Post.default_level), state.post.level(.crt)); - try std.testing.expect(apply(&state, find("Glitch").?, "1")); + try std.testing.expect(apply(&state, find("Shader").?, "/after.glsl")); const moved = state.post.generation; try std.testing.expect(apply(&state, crt, "3")); try std.testing.expectEqual(@as(u8, 3), state.post.level(.crt)); @@ -1472,8 +1468,6 @@ pub const Runtime = struct { }, .scene => |effect| switch (effect) { .crt => "Crt 2\n", - .ripple => "Ripple 2\n", - .glitch => "Glitch 2\n", }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", @@ -1544,8 +1538,6 @@ pub const Runtime = struct { try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested") == null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); diff --git a/src/effect_sources.zig b/src/effect_sources.zig index 6ce11160..83bd1313 100644 --- a/src/effect_sources.zig +++ b/src/effect_sources.zig @@ -12,10 +12,8 @@ const ui_fragment_path = shader_dir ++ "ui.frag.glsl"; const image_vertex_path = shader_dir ++ "image.vert.glsl"; const image_fragment_path = shader_dir ++ "image.frag.glsl"; const post_vertex_path = shader_dir ++ "post.vert.glsl"; -// The bundled post passes as compiled: the prefix and the pass together. +// The bundled post pass as compiled: the prefix and the pass together. const post_crt_path = shader_dir ++ "post-crt.frag.glsl"; -const post_ripple_path = shader_dir ++ "post-ripple.frag.glsl"; -const post_glitch_path = shader_dir ++ "post-glitch.frag.glsl"; pub const Backend = @TypeOf(build_config.platform); pub const backend: Backend = build_config.platform; @@ -55,8 +53,6 @@ pub const files = switch (backend) { .{ .path = image_fragment_path, .contents = @embedFile("effect-source-image.frag.glsl") }, .{ .path = post_vertex_path, .contents = @embedFile("effect-source-post.vert.glsl") }, .{ .path = post_crt_path, .contents = @embedFile("effect-source-post-crt.frag.glsl") }, - .{ .path = post_ripple_path, .contents = @embedFile("effect-source-post-ripple.frag.glsl") }, - .{ .path = post_glitch_path, .contents = @embedFile("effect-source-post-glitch.frag.glsl") }, }, .macos => [_]filesystem.Source{ .{ .path = "src/macos/Sources/PardesView.swift", .contents = @embedFile("macos/Sources/PardesView.swift") }, @@ -77,8 +73,6 @@ pub fn forSetting(setting: config.Runtime.Setting) ?[]const []const u8 { .scene => |effect| switch (backend) { .gui => switch (effect) { .crt => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_crt_path }, - .ripple => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_ripple_path }, - .glitch => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_glitch_path }, }, .macos => &.{ "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" }, .tty, .web, .esp32p4 => null, diff --git a/src/gui/Post.zig b/src/gui/Post.zig index d9d3d53e..f715965f 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -1,7 +1,7 @@ //! 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 bundled (Crt, Ripple, Glitch: -//! shaders/post/, compiled with the build) or a user's file (Shader ), +//! 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. @@ -28,8 +28,6 @@ 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"), - .ripple = @embedFile("post-ripple.frag.spv"), - .glitch = @embedFile("post-glitch.frag.spv"), }); /// ghostty's Shadertoy uniform block, member for member in the GLSL block's @@ -170,7 +168,7 @@ fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format .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, Ripple and Glitch do)"); + core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt does)"); }, .failed => |text| { defer gpa.free(text); diff --git a/src/macos.zig b/src/macos.zig index 0bad86ee..6cd7838f 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -112,8 +112,6 @@ const cell_flag_default: u8 = 1; const cell_flag_tagline: u8 = 2; const cell_flag_hover: u8 = 4; const scene_flag_crt: u32 = 1 << 0; -const scene_flag_ripple: u32 = 1 << 1; -const scene_flag_glitch: u32 = 1 << 2; const scene_frame_hz: u32 = 60; const scene_wrap_ns: u64 = 4096 * std.time.ns_per_s; @@ -850,13 +848,11 @@ export fn pardes_should_quit() bool { return st.core.quit; } -// The Metal shell keeps its own CRT, ripple and glitch kernel: the bundled -// passes of the GUI's post chain, as flags. +// The Metal shell keeps its own CRT kernel: the GUI's bundled post pass, as +// a flag. fn encodeSceneEffects(post: *const pardes.config.Runtime.Post) u32 { var flags: u32 = 0; if (post.has(.crt)) flags |= scene_flag_crt; - if (post.has(.ripple)) flags |= scene_flag_ripple; - if (post.has(.glitch)) flags |= scene_flag_glitch; return flags; } @@ -2842,8 +2838,6 @@ test "pardes.h matches the Zig boundary" { try expectEqual(@as(u8, c.PARDES_CELL_HOVER), cell_flag_hover); try expectEqual(@as(u16, c.PARDES_TOPBAR_H), pardes.TOPBAR_H); try expectEqual(@as(u32, c.PARDES_SCENE_CRT), scene_flag_crt); - try expectEqual(@as(u32, c.PARDES_SCENE_RIPPLE), scene_flag_ripple); - try expectEqual(@as(u32, c.PARDES_SCENE_GLITCH), scene_flag_glitch); try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(animation.Phase.opening)); try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(animation.Phase.moving)); try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(animation.Phase.closing)); @@ -2918,13 +2912,6 @@ test "scene effect flags and display clock are compact and independent" { try expectEqual(@as(u32, 0), encodeSceneEffects(&post)); _ = post.set(.crt, "", true); try expectEqual(scene_flag_crt, encodeSceneEffects(&post)); - _ = post.set(.ripple, "", true); - _ = post.set(.glitch, "", true); - try expectEqual(scene_flag_crt | scene_flag_ripple | scene_flag_glitch, encodeSceneEffects(&post)); - try expectEqual( - scene_flag_crt | scene_flag_ripple | scene_flag_glitch, - encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }), - ); var st: State = undefined; st.scene_ns = scene_wrap_ns - (std.time.ns_per_ms * 5); diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 70f467e3..8ba9c84b 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -684,7 +684,7 @@ final class PardesView: NSView { private var latchedButton: pardes_mouse_button_e? private var latchedCell: GridPoint? /// Last physical point delivered by AppKit and its mapping through the - /// accepted scene. Ripple/glitch can move a source cell under a stationary + /// accepted scene. The CRT's barrel moves a source cell under a stationary /// hand, so successful scene presentations reconcile this point again. private var pointerPoint: CGPoint? private var pointerCell: GridPoint? diff --git a/src/macos/Sources/ScenePostprocessor.swift b/src/macos/Sources/ScenePostprocessor.swift index cf7892b3..41d54e34 100644 --- a/src/macos/Sources/ScenePostprocessor.swift +++ b/src/macos/Sources/ScenePostprocessor.swift @@ -205,11 +205,9 @@ final class ScenePostprocessor { if scene.flags != 0 { let crt: Float = scene.flags & UInt32(PARDES_SCENE_CRT) != 0 ? 1 : 0 - let ripple: Float = scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 ? 1 : 0 - let glitch: Float = scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 ? 1 : 0 // SDL samples the scene with CLAMP_TO_EDGE. A finite Core Image // instead supplies transparent black outside its extent, which - // would make ripple/glitch tear dark seams at the window edge. + // would tear dark seams at the window edge. // Clamp the input only; the kernel still emits true black before // sampling when a destination lies outside the CRT tube. let sceneInput = filtered.clampedToExtent() @@ -218,7 +216,7 @@ final class ScenePostprocessor { roiCallback: { _, _ in extent }, arguments: [ sceneInput, scene.time_seconds, Float(scene.frame), - crt, ripple, glitch, + crt, CIVector(x: extent.width, y: extent.height), CIVector(x: extent.minX, y: extent.minY), ]) @@ -291,7 +289,7 @@ final class ScenePostprocessor { /// The scene kernel is a destination-to-source mapping. Pointer input must /// apply that same mapping before the grid and its panel tracks see a cell, - /// otherwise a visible word under ripple/glitch is not the word Look gets. + /// otherwise a visible word under the barrel is not the word Look gets. static func sourcePoint( _ point: CGPoint, size: CGSize, @@ -311,27 +309,8 @@ final class ScenePostprocessor { ccY *= barrel uvX = ccX * 0.5 + 0.5 uvY = ccY * 0.5 + 0.5 - // Only CRT has a black tube. Ripple/glitch without CRT sample through - // the texture's clamp-to-edge policy and remain clickable there. + // The CRT's black tube has no cell under it. if crt > 0.5 && min(min(uvX, uvY), min(1 - uvX, 1 - uvY)) <= 0 { return nil } - - let time = scene.time_seconds - if scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 { - let centeredX = uvX - 0.5 - let centeredY = uvY - 0.5 - let radius = sqrtf(centeredX * centeredX + centeredY * centeredY) - let envelope = 1 - smoothstep(0.04, 0.72, radius) - let wave = sinf(radius * 42 - time * 5.2) - let travel = wave * envelope * 0.0032 / max(radius, 0.00001) - uvX += centeredX * travel - uvY += centeredY * travel - } - if scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 { - let band = floorf(uvY * 96) - let seed = noise(band, floorf(time * 13)) - let tear = smoothstep(0.955, 1, seed) - uvX += (noise(seed, band) - 0.5) * tear * 0.020 - } uvX = min(max(uvX, 0), max(0, 1 - 1 / Float(size.width))) uvY = min(max(uvY, 0), max(0, 1 - 1 / Float(size.height))) return CGPoint( @@ -339,23 +318,4 @@ final class ScenePostprocessor { y: CGFloat(1 - uvY) * size.height) } - private static func smoothstep(_ edge0: Float, _ edge1: Float, _ x: Float) -> Float { - let t = min(max((x - edge0) / (edge1 - edge0), 0), 1) - return t * t * (3 - 2 * t) - } - - /// Scalar spelling of pardesNoise in crt.ci.metal. Float arithmetic is - /// deliberate: the shader's band decision must not move at a Double round. - private static func noise(_ x: Float, _ y: Float) -> Float { - var qx = fract(x * 0.1031) - var qy = fract(y * 0.1031) - var qz = fract(x * 0.1031) - let d = qx * (qy + 33.33) + qy * (qz + 33.33) + qz * (qx + 33.33) - qx += d - qy += d - qz += d - return fract((qx + qy) * qz) - } - - private static func fract(_ x: Float) -> Float { x - floorf(x) } } diff --git a/src/macos/pardes.h b/src/macos/pardes.h index f74330e5..29f8a37d 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -82,8 +82,7 @@ typedef struct { // from the background colour, which a real selection shares. #define PARDES_CELL_HOVER 0x04u -// One coherent snapshot for the full-window shader pass. The bitmask lets the -// host run CRT, ripple and glitch in one kernel instead of stacking filters; +// One coherent snapshot for the full-window shader pass (CRT); // time/frame advance only on pardes_animation_tick's display clock. typedef struct { uint32_t flags; @@ -92,8 +91,6 @@ typedef struct { } pardes_scene_s; #define PARDES_SCENE_CRT 0x01u -#define PARDES_SCENE_RIPPLE 0x02u -#define PARDES_SCENE_GLITCH 0x04u // One backend-neutral pane transition. Boxes are logical cell rectangles; // the native shader converts them to backing pixels using the same measured diff --git a/test/output.zig b/test/output.zig index 0fdd9f4d..e49ec4ff 100644 --- a/test/output.zig +++ b/test/output.zig @@ -669,7 +669,7 @@ test "every enabled setting builtin mutates the State Config reports" { try std.testing.expect(std.mem.indexOf(u8, report, transition) != null); // A bare word turns a bundled pass on at its default level. const scene_status = if (builtins.capabilities.scene_shaders) "2" else "unsupported"; - for ([_][]const u8{ "Crt", "Ripple", "Glitch" }) |name| { + for ([_][]const u8{"Crt"}) |name| { const line = try std.fmt.bufPrint(&buf, "{s} {s}\n", .{ name, scene_status }); try std.testing.expect(std.mem.indexOf(u8, report, line) != null); } -- cgit v1.3