From 4e224b7c7e67fafcbe7359fcf534048e254b701d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 13:19:08 -0300 Subject: The theme's chrome is worked out once, not per grapheme; still post passes let the GUI rest; a perf gate somtrmsz's contrast floors made ChromeTheme.fromTheme run its focus-tint and separator searches (pow calls each), and Output's RowDecoration.styleAt called it for every grapheme of every highlighted row: the 50k-line file's render went from 0.11 ms to 13 ms. Pardes.bodyChrome now keeps the theme's chrome, worked out again only when the theme differs; recolorSyntax asks for it once a decorated row, and a plain row (nearly every row of a file) never asks. ReleaseFast, medium fixture, median us, before -> after (main): render 13150 -> 76 (111), key-down 14658 -> 79 (110), wheel 13589 -> 76 (109), open 14891 -> 2095 (2055), edit-char 16703 -> 3006 (1251; the rest of that gap is editing and tree-sitter, not this). Post.animating asked for frames whenever the window had focus and any pass was ready, so a still pass kept the GUI drawing at the display's rate (Bloom: 49% of a core idle, in a hidden test window). A pass now says whether it moves on its own: the CRT (its hum and dither) and a Shadertoy file whose source reads iTime, iFrame or iDate (shader_build.readsTime; the flag rides the wire's post message); Bloom, Vignette and Grain are still. Bloom idle: 49% -> 1.2%, as with no pass. zig build perf-gate: the 50k-line file's gestures, each's fastest sample within 3x of the recorded baseline's (test/perf-baseline-- .json, recorded from this build), run with every unit-test. On the regressed code it fails at 180x for render. --- src/File.zig | 3 ++- src/Output.zig | 15 +++++++++++---- src/detached/wire.zig | 11 +++++++++-- src/gui/Post.zig | 27 +++++++++++++++++++++++++-- src/pardes.zig | 12 ++++++++++++ src/shader_build.zig | 34 ++++++++++++++++++++++++++++++---- 6 files changed, 89 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/File.zig b/src/File.zig index 5f6d7097..1d838c4f 100644 --- a/src/File.zig +++ b/src/File.zig @@ -1293,6 +1293,7 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. const row_index: usize = @intCast(@max(0, pane.wrapAt(vr).line)); const metadata: ?locations.Row = if (row_index < f.location_rows.len) f.location_rows[row_index] else null; const decoration = Output.decorateRow(f.output, line, metadata); + const chrome: ?*const pardes.ChromeTheme = if (decoration.plain()) null else p.bodyChrome(); var c: usize = 0; var screen_c: usize = 0; while (hs + c < limit and prefix_width + screen_c < tw) { @@ -1315,7 +1316,7 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. } // Results keep their exact live text. Only the location, // matched source range and diagnostic label gain emphasis. - if (decoration.styleAt(p, hs + c)) |style| { + if (if (chrome) |ch| decoration.styleAt(ch, hs + c) else null) |style| { var fill: usize = 0; while (fill < cells and prefix_width + screen_c + fill < tw) : (fill += 1) { const cell = s.at(tx + @as(u16, @intCast(prefix_width + screen_c + fill)), body_y + vr); diff --git a/src/Output.zig b/src/Output.zig index b8dd5e34..6ea81924 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -54,10 +54,17 @@ const RowDecoration = struct { mark_end: usize = 0, severity: enum { none, err, warning, info, hint } = .none, - pub fn styleAt(row: RowDecoration, p: *const Pardes, byte: usize) ?pardes.CellStyle { - // Body paper changes immediately, so its ink must change with it - // even while the surrounding tags are still fading. - const chrome = pardes.ChromeTheme.fromTheme(p.theme()); + /// Nothing in the row is decorated: most rows of most files. + pub fn plain(row: RowDecoration) bool { + return !row.declaration and row.location_end == 0 and row.prefix_end == 0 and row.mark_end <= row.mark_start; + } + + /// `chrome` is the theme's own (Pardes.bodyChrome): body paper changes + /// immediately, so its ink must change with it even while the + /// surrounding tags are still fading. Once a frame, never a grapheme: + /// working it out takes the contrast searches' pow calls. + pub fn styleAt(row: RowDecoration, chrome: *const pardes.ChromeTheme, byte: usize) ?pardes.CellStyle { + if (row.plain()) return null; if (row.declaration or byte < @max(row.location_end, row.prefix_end)) return .{ .fg = .{ .rgb = chrome.lineno } }; if (byte < row.mark_start or byte >= row.mark_end) return null; diff --git a/src/detached/wire.zig b/src/detached/wire.zig index ee8c72fb..15d3641c 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -1178,6 +1178,7 @@ pub fn encodeServer(out: []u8, msg: ServerMsg) Error![]const u8 { // 0 a file, else a bundled scene and one. try w.putByte(if (pass.scene) |scene| @as(u8, @intFromEnum(scene)) + 1 else 0); try w.putByte(pass.level); + try w.putByte(@intFromBool(pass.animated)); try w.putU32(pass.revision); try w.putSlice16(pass.path); try w.putSlice32(pass.spirv); @@ -1206,7 +1207,7 @@ pub fn serverBound(msg: ServerMsg) usize { .open_link => |u| u.len, .post => |p| blk: { var n: usize = 0; - for (p.list()) |pass| n += 12 + pass.path.len + pass.spirv.len; + for (p.list()) |pass| n += 13 + pass.path.len + pass.spirv.len; break :blk n; }, }; @@ -1280,6 +1281,11 @@ pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg { pass.* = .{ .scene = if (kind == 0) null else std.enums.fromInt(Scene, kind - 1) orelse return error.BadValue, .level = try r.getByte(), + .animated = switch (try r.getByte()) { + 0 => false, + 1 => true, + else => return error.BadValue, + }, .revision = try r.getU32(), .path = try r.getSlice16(), .spirv = try r.getSlice32(), @@ -1406,7 +1412,7 @@ test "detached wire: every server message round-trips" { try testing.expectEqualStrings("https://x", (try roundServer(&buf, .{ .open_link = "https://x" })).open_link); var chain: Post = .{ .animation = .always, .len = 2 }; chain.passes[0] = .{ .scene = .bloom, .level = 3 }; - chain.passes[1] = .{ .path = "~/wave.glsl", .spirv = "\x03\x02\x23\x07spirv", .revision = 7 }; + chain.passes[1] = .{ .path = "~/wave.glsl", .spirv = "\x03\x02\x23\x07spirv", .revision = 7, .animated = true }; const back = (try roundServer(&buf, .{ .post = chain })).post; try testing.expectEqual(ShaderAnimation.always, back.animation); try testing.expectEqual(@as(u8, 2), back.len); @@ -1416,6 +1422,7 @@ test "detached wire: every server message round-trips" { try testing.expectEqualStrings("~/wave.glsl", back.passes[1].path); try testing.expectEqualStrings("\x03\x02\x23\x07spirv", back.passes[1].spirv); try testing.expectEqual(@as(u32, 7), back.passes[1].revision); + try testing.expect(back.passes[1].animated and !back.passes[0].animated); } test "detached wire: only the display's own effects are on the wire" { diff --git a/src/gui/Post.zig b/src/gui/Post.zig index 06310b7f..602b2790 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -94,6 +94,8 @@ pub const Pass = struct { pipeline: ?*Pipeline = null, /// The SPIR-V revision `pipeline` was made from (shader_build.Pass). revision: u32 = 0, + /// It moves on its own (shader_build.Pass). + animated: bool = false, }; passes: [max]Pass = @splat(.{}), @@ -140,7 +142,7 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, forma post.bloom_down = makePipeline(device, bloom_format, bloom_down_spv, 1, 1) catch null; post.bloom_up = makePipeline(device, bloom_format, bloom_up_spv, 1, 1) catch null; } - next[i] = .{ .scene = scene, .level = entry.level, .pipeline = post.bundled.get(scene) }; + next[i] = .{ .scene = scene, .level = entry.level, .pipeline = post.bundled.get(scene), .animated = entry.animated }; continue; } // A file already in the chain keeps its pipeline and its path. @@ -151,6 +153,7 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, forma break; } else next[i] = .{ .path = gpa.dupe(u8, entry.path) catch &.{} }; const pass = &next[i]; + pass.animated = entry.animated; if (pass.revision == entry.revision or entry.spirv.len == 0) continue; pass.revision = entry.revision; const made = makePipeline(device, format, entry.spirv, 1, 4) catch { @@ -200,8 +203,14 @@ pub fn ready(post: *const Post) bool { } /// The chain redraws on its own (level A) as ShaderAnimation says. +/// Only a pass that moves on its own (shader_build.Pass.animated) asks for +/// frames while idle: a still one (Bloom, Vignette, Grain, a file that reads +/// no clock) is drawn again only with a frame of the core's. pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation) bool { - return post.ready() and switch (mode) { + const moving = for (post.passes[0..post.len]) |pass| { + if (pass.pipeline != null and pass.animated) break true; + } else false; + return moving and switch (mode) { .off => false, .on => post.uniforms.focus != 0, .always => true, @@ -664,3 +673,17 @@ test "a moved cursor becomes the previous one, stamped with the time of the move 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 "a still pass asks for no idle frames; one that reads the clock does" { + var post: Post = .{}; + const fake: *Pipeline = @ptrFromInt(0x1000); + post.passes[0] = .{ .scene = .bloom, .level = 2, .pipeline = fake }; + post.len = 1; + try std.testing.expect(!post.animating(.on) and !post.animating(.always)); + post.passes[1] = .{ .scene = .crt, .level = 2, .pipeline = fake, .animated = true }; + post.len = 2; + try std.testing.expect(post.animating(.on) and post.animating(.always) and !post.animating(.off)); + // Unfocused, `on` rests. + post.uniforms.focus = 0; + try std.testing.expect(!post.animating(.on) and post.animating(.always)); +} diff --git a/src/pardes.zig b/src/pardes.zig index 1f136f2b..ae04b4e7 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4380,6 +4380,8 @@ pub const Pardes = struct { tty_filter_palette: panes.Terminal.FilterPalette = .{}, font_request_taken: bool = false, custom_theme: ?Theme = null, + /// bodyChrome's: the theme it was worked out for, and it. + body_chrome: ?struct { theme: Theme, chrome: ChromeTheme } = null, theme_file_path: config.Runtime.Text(limits.host_path_cap) = .{}, theme_file_generation: u32 = 0, theme_file_pane: u8 = 0, @@ -7063,6 +7065,16 @@ pub const Pardes = struct { return &p.chrome_animation.displayed; } + /// The theme's own chrome, not a fade's (`theme()`'s, worked out once + /// per theme: its contrast searches take pow calls, too many for a + /// grapheme or a row). + pub fn bodyChrome(p: *Pardes) *const ChromeTheme { + const th = p.theme(); + if (p.body_chrome) |*cached| if (std.meta.eql(cached.theme, th.*)) return &cached.chrome; + p.body_chrome = .{ .theme = th.*, .chrome = ChromeTheme.fromTheme(th) }; + return &p.body_chrome.?.chrome; + } + /// When core animation next needs a frame, on the `stepped_ns` /// timeline: a frame from now while anything moves, the end of the /// wait while something only waits (a message lingering, a look-hover diff --git a/src/shader_build.zig b/src/shader_build.zig index 8aa7eb5e..c2610fd7 100644 --- a/src/shader_build.zig +++ b/src/shader_build.zig @@ -32,8 +32,24 @@ pub const Pass = struct { path: []const u8 = &.{}, spirv: []const u8 = &.{}, revision: u32 = 0, + /// It moves on its own (reads the clock or the frame count), so the + /// chain redraws while idle; a still pass draws only with a new frame. + animated: bool = false, }; +/// The bundled passes that move on their own: the CRT's hum and dither. +pub fn sceneAnimated(scene: Scene) bool { + return scene == .crt; +} + +/// Whether a Shadertoy source moves on its own: it reads the time +/// (iTime, iTimeDelta and the times of a cursor move or a focus change), +/// the frame count or the date. A mention in a comment counts too. +pub fn readsTime(source: []const u8) bool { + for ([_][]const u8{ "iTime", "iFrame", "iDate" }) |name| if (std.mem.indexOf(u8, source, name) != null) return true; + return false; +} + /// Who to wake when a compile is done: the host's loop. pub const Wake = struct { ctx: ?*anyopaque = null, @@ -47,6 +63,7 @@ const File = struct { source: ?u64 = null, spirv: []u8 = &.{}, revision: u32 = 0, + animated: bool = false, /// Wants a compile: new to the chain, or its directory changed. stale: bool = true, }; @@ -56,7 +73,7 @@ const Result = union(enum) { unchanged, missing, failed: struct { text: []u8, source: u64 }, - spirv: struct { bytes: []u8, source: u64 }, + spirv: struct { bytes: []u8, source: u64, animated: bool }, }; /// One compile of the stale files, on its own thread. @@ -148,8 +165,8 @@ pub fn recheck(b: *Build) void { /// The chain as passes, in its order. pub fn view(b: *const Build, chain: *const Chain, out: *[max]Pass) []const Pass { for (chain.list(), 0..) |entry, i| { - out[i] = if (entry.scene) |scene| .{ .scene = scene, .level = entry.level } else for (b.files[0..b.len]) |file| { - if (std.mem.eql(u8, file.path, entry.path.get())) break .{ .path = file.path, .spirv = file.spirv, .revision = file.revision }; + out[i] = if (entry.scene) |scene| .{ .scene = scene, .level = entry.level, .animated = sceneAnimated(scene) } else for (b.files[0..b.len]) |file| { + if (std.mem.eql(u8, file.path, entry.path.get())) break .{ .path = file.path, .spirv = file.spirv, .revision = file.revision, .animated = file.animated }; } else .{ .path = entry.path.get() }; } return out[0..chain.len]; @@ -211,6 +228,7 @@ fn collect(b: *Build, gpa: std.mem.Allocator, core: *pardes.Pardes, job: *Job) v gpa.free(f.spirv); f.spirv = r.bytes; f.source = r.source; + f.animated = r.animated; f.revision +%= 1; b.revision +%= 1; }, @@ -291,7 +309,7 @@ fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8, known: ?u64) .exited => |code| code == 0, else => false, }; - if (clean) return .{ .spirv = .{ .bytes = reader.toOwnedSlice(0) catch return .none, .source = source } }; + if (clean) return .{ .spirv = .{ .bytes = reader.toOwnedSlice(0) catch return .none, .source = source, .animated = readsTime(body) } }; const said = reader.toOwnedSlice(1) catch return .none; // glslc calls its input ; the file is what a person knows. defer gpa.free(said); @@ -400,3 +418,11 @@ test "a save that changes a file compiles it again, a failed one keeps the last const second = b.view(&core.settings.post, &out)[0]; try std.testing.expect(second.revision != first.revision and b.revision != at); } + +test "a pass animates when it reads the clock or the frame count, and only the CRT of the bundled ones" { + try std.testing.expect(readsTime("o = vec4(sin(iTime));")); + try std.testing.expect(readsTime("float f = float(iFrame % 64);")); + try std.testing.expect(readsTime("x = iTimeCursorChange;")); + try std.testing.expect(!readsTime("o = texture(iChannel0, p / iResolution.xy);")); + for (std.enums.values(Scene)) |scene| try std.testing.expectEqual(scene == .crt, sceneAnimated(scene)); +} -- cgit v1.3