From 7d2178022e721f3eac023f3e3be56c1bb1e49927 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 20:24:56 -0300 Subject: shader_build.zig is ShaderBuild.zig: a file that is a struct (its fields are the post chain's builds) takes a type's name The naming the split agreed on: a file with fields is a type, TitleCase, as Messages.zig and Layer.zig are. Its importers bind it as ShaderBuild; comments and docs/render-pipeline.md name the new file. No behaviour changes. Co-Authored-By: Claude Opus 5.5 --- src/ShaderBuild.zig | 428 ++++++++++++++++++++++++++++++++++++++++++++++++ src/detached/server.zig | 4 +- src/detached/wire.zig | 10 +- src/file_watch.zig | 2 +- src/gui/Post.zig | 12 +- src/gui/gui.zig | 8 +- src/main.zig | 2 +- src/shader_build.zig | 428 ------------------------------------------------ 8 files changed, 447 insertions(+), 447 deletions(-) create mode 100644 src/ShaderBuild.zig delete mode 100644 src/shader_build.zig (limited to 'src') diff --git a/src/ShaderBuild.zig b/src/ShaderBuild.zig new file mode 100644 index 00000000..c2610fd7 --- /dev/null +++ b/src/ShaderBuild.zig @@ -0,0 +1,428 @@ +//! The post chain's Shadertoy files as SPIR-V (docs/render-pipeline.md §6), +//! for whichever process holds the core: a local GUI makes its pipelines +//! from them, a detached session sends them to the GUIs attached to it +//! (wire `post`), which never read a disk or run a program themselves. +//! +//! Each file compiles behind the prefix by the build's own glslc invocation, +//! on a thread of its own and never on the frame path, when it joins the +//! chain and again whenever a save changes its bytes: the files' directories +//! are watched (file_watch.watchShaders) and a change there has every file +//! read and hashed, an unchanged one compiling nothing. A compile that fails +//! keeps the last good SPIR-V and is said once, and glslc missing is said +//! once for good. +const std = @import("std"); +const libc = std.c; +const pardes = @import("pardes.zig"); +const file_watch = @import("file_watch.zig"); + +const Build = @This(); +const Chain = pardes.config.Runtime.Post; +const Scene = pardes.config.Runtime.Scene; +pub const max = Chain.max; + +const prefix = @embedFile("post-prefix.glsl"); + +/// A pass as a shell draws it: a bundled scene at its level, or a file with +/// its last good SPIR-V (empty until a compile succeeds), whose `revision` +/// moves each time that SPIR-V does. Slices borrow the build's (or, in an +/// attached GUI, the client's) storage until its next change. +pub const Pass = struct { + scene: ?Scene = null, + level: u8 = 0, + 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, + call: ?*const fn (?*anyopaque) void = null, +}; + +const File = struct { + path: []u8 = &.{}, + /// The hash of the source last compiled, good or not: the same bytes + /// saved again compile nothing and say nothing twice. + 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, +}; + +const Result = union(enum) { + none, + unchanged, + missing, + failed: struct { text: []u8, source: u64 }, + spirv: struct { bytes: []u8, source: u64, animated: bool }, +}; + +/// One compile of the stale files, on its own thread. +const Job = struct { + io: std.Io, + gpa: std.mem.Allocator, + wake: Wake, + paths: [max][]u8 = @splat(&.{}), + known: [max]?u64 = @splat(null), + results: [max]Result = @splat(.none), + len: usize = 0, + done: std.atomic.Value(bool) = .init(false), + thread: std.Thread = undefined, +}; + +files: [max]File = @splat(.{}), +len: usize = 0, +/// The chain generation `files` follow; null before the first. +generation: ?u32 = null, +/// Moves whenever what `view` gives does: the chain or a file's SPIR-V. +revision: u32 = 0, +job: ?*Job = null, +missing_said: bool = false, + +/// Brings the files to the chain the core holds, watches their directories, +/// takes in a finished compile and starts the next. Each loop step, off the +/// frame path. True when a compile was taken in: what it said wants a frame. +pub fn sync(b: *Build, gpa: std.mem.Allocator, io: std.Io, core: *pardes.Pardes, fd: c_int, watches: *file_watch.Table, wake: Wake) bool { + var collected = false; + if (b.job) |job| { + if (!job.done.load(.acquire)) return false; + job.thread.join(); + b.job = null; + b.collect(gpa, core, job); + collected = true; + } + const chain = &core.settings.post; + if (b.generation != chain.generation) { + b.generation = chain.generation; + b.revision +%= 1; + var next: [max]File = @splat(.{}); + var len: usize = 0; + var paths: [max][]const u8 = undefined; + for (chain.list()) |entry| { + if (entry.scene != null) continue; + next[len] = for (b.files[0..b.len]) |*file| { + if (!std.mem.eql(u8, file.path, entry.path.get())) continue; + const kept = file.*; + file.* = .{}; + break kept; + } else .{ .path = gpa.dupe(u8, entry.path.get()) catch continue }; + paths[len] = next[len].path; + len += 1; + } + for (b.files[0..b.len]) |file| free(gpa, file); + b.files = next; + b.len = len; + var bufs: [max][4096]u8 = undefined; + var expanded: [max][]const u8 = undefined; + for (paths[0..len], 0..) |path, i| expanded[i] = expand(&bufs[i], path); + file_watch.watchShaders(fd, watches, expanded[0..len]); + } + var stale = false; + for (b.files[0..b.len]) |file| stale = stale or file.stale; + if (!stale) return collected; + const job = gpa.create(Job) catch return collected; + job.* = .{ .io = io, .gpa = gpa, .wake = wake }; + for (b.files[0..b.len]) |*file| { + if (!file.stale) continue; + job.paths[job.len] = gpa.dupe(u8, file.path) catch continue; + job.known[job.len] = file.source; + job.len += 1; + file.stale = false; + } + job.thread = std.Thread.spawn(.{}, compile, .{job}) catch { + for (job.paths[0..job.len]) |path| gpa.free(path); + gpa.destroy(job); + return collected; + }; + b.job = job; + return collected; +} + +/// A watched directory changed: every file is read again at the next sync. +pub fn recheck(b: *Build) void { + for (b.files[0..b.len]) |*file| file.stale = true; +} + +/// 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, .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]; +} + +pub fn deinit(b: *Build, gpa: std.mem.Allocator, fd: c_int, watches: *file_watch.Table) void { + if (b.job) |job| { + job.thread.join(); + freeJob(gpa, job); + } + for (b.files[0..b.len]) |file| free(gpa, file); + b.len = 0; + file_watch.watchShaders(fd, watches, &.{}); +} + +fn free(gpa: std.mem.Allocator, file: File) void { + gpa.free(file.path); + gpa.free(file.spirv); +} + +fn freeJob(gpa: std.mem.Allocator, job: *Job) void { + for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { + gpa.free(path); + switch (result) { + .spirv => |r| gpa.free(r.bytes), + .failed => |r| gpa.free(r.text), + .none, .unchanged, .missing => {}, + } + } + gpa.destroy(job); +} + +fn collect(b: *Build, gpa: std.mem.Allocator, 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 file = for (b.files[0..b.len]) |*file| { + if (std.mem.eql(u8, file.path, path)) break file; + } else null; + var buf: [512]u8 = undefined; + switch (result) { + .none, .unchanged => {}, + .missing => if (!b.missing_said) { + b.missing_said = true; + core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt does)"); + }, + .failed => |r| { + defer gpa.free(r.text); + if (file) |f| f.source = r.source; + // glslc's first line says where: in the file's own lines. + const first = std.mem.sliceTo(r.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 => |r| { + const f = file orelse { + gpa.free(r.bytes); + continue; + }; + gpa.free(f.spirv); + f.spirv = r.bytes; + f.source = r.source; + f.animated = r.animated; + f.revision +%= 1; + b.revision +%= 1; + }, + } + } +} + +/// `~/` is the home directory. +fn expand(buf: []u8, path: []const u8) []const u8 { + if (!std.mem.startsWith(u8, path, "~/")) return path; + const home = libc.getenv("HOME") orelse return path; + return std.fmt.bufPrint(buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path; +} + +/// 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 { + for (job.paths[0..job.len], 0..) |path, i| job.results[i] = compileOne(job.gpa, job.io, path, job.known[i]); + job.done.store(true, .release); + if (job.wake.call) |call| call(job.wake.ctx); +} + +fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8, known: ?u64) Result { + var home_buf: [4096]u8 = undefined; + // Not fs.zig's readFile: that hands the core's turn over for the read, + // a turn this thread never holds. + const body = std.Io.Dir.cwd().readFileAlloc(io, expand(&home_buf, path), gpa, .limited(1 << 20)) catch |err| + return .{ .failed = .{ .text = std.fmt.allocPrint(gpa, "cannot read it ({t})", .{err}) catch return .none, .source = 0 } }; + defer gpa.free(body); + const source = std.hash.Wyhash.hash(0, body); + if (known == source) return .unchanged; + // 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 = .{ .text = std.fmt.allocPrint(gpa, "glslc did not run ({t})", .{err}) catch return .none, .source = source } }, + }; + 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(); + const failed = struct { + fn of(a: std.mem.Allocator, text: []const u8, hash: u64) Result { + return .{ .failed = .{ .text = a.dupe(u8, text) catch return .none, .source = hash } }; + } + }.of; + while (reader.fill(64, .none)) |_| { + if (reader.reader(0).buffered().len > 16 << 20 or reader.reader(1).buffered().len > 64 << 10) + return failed(gpa, "glslc said too much", source); + } else |err| switch (err) { + error.EndOfStream => {}, + else => return failed(gpa, "reading glslc failed", source), + } + const term = child.wait(io) catch return failed(gpa, "glslc did not finish", source); + const clean = switch (term) { + .exited => |code| code == 0, + else => false, + }; + 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); + const named = std.mem.replaceOwned(u8, gpa, said, "", path) catch return failed(gpa, "glslc failed", source); + return .{ .failed = .{ .text = named, .source = source } }; +} + +test "ghostty's test shaders compile behind the prefix, and its invalid one fails with glslc's words" { + const gpa = std.testing.allocator; + const io = std.testing.io; + // ghostty's own test shaders, from its package in zig-pkg. + var pkg = std.Io.Dir.cwd().openDir(io, "zig-pkg", .{ .iterate = true }) catch return error.SkipZigTest; + defer pkg.close(io); + var it = pkg.iterate(); + const ghostty = while (it.next(io) catch null) |entry| { + if (std.mem.startsWith(u8, entry.name, "ghostty-")) break entry.name; + } else return error.SkipZigTest; + var buf: [512]u8 = undefined; + for ([_][]const u8{ "crt", "focus", "invalid" }) |name| { + const path = try std.fmt.bufPrint(&buf, "zig-pkg/{s}/src/renderer/shaders/test_shadertoy_{s}.glsl", .{ ghostty, name }); + switch (compileOne(gpa, io, path, null)) { + .missing => return error.SkipZigTest, // no glslc here + .spirv => |r| { + defer gpa.free(r.bytes); + 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, r.bytes[0..4], .little)); + // The same bytes again compile nothing. + try std.testing.expectEqual(Result.unchanged, compileOne(gpa, io, path, r.source)); + }, + .failed => |r| { + defer gpa.free(r.text); + try std.testing.expectEqualStrings("invalid", name); + // Named by the file, not by glslc's . + try std.testing.expect(std.mem.indexOf(u8, r.text, path) != null); + try std.testing.expect(std.mem.indexOf(u8, r.text, "") == null); + }, + .none, .unchanged => return error.TestUnexpectedResult, + } + } +} + +test "a save that changes a file compiles it again, a failed one keeps the last good SPIR-V and is said once" { + const gpa = std.testing.allocator; + const io = std.testing.io; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + const pane = core.panes[core.active].?; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(io, &dir_buf)]; + var path_buf: [4200]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "{s}/wave.glsl", .{dir}); + const good = "void mainImage(out vec4 o, in vec2 p) { o = texture(iChannel0, p / iResolution.xy); }\n"; + try tmp.dir.writeFile(io, .{ .sub_path = "wave.glsl", .data = good }); + try std.testing.expect(core.settings.post.set(null, path, true)); + + const fd = file_watch.init(true); + defer if (fd >= 0) { + _ = libc.close(fd); + }; + var watches: file_watch.Table = @splat(null); + var b: Build = .{}; + defer b.deinit(gpa, fd, &watches); + const settle = struct { + fn run(build: *Build, c: *pardes.Pardes, f: c_int, w: *file_watch.Table) void { + _ = build.sync(std.testing.allocator, std.testing.io, c, f, w, .{}); + while (build.job) |job| { + while (!job.done.load(.acquire)) std.Thread.yield() catch {}; + _ = build.sync(std.testing.allocator, std.testing.io, c, f, w, .{}); + } + } + }.run; + settle(&b, core, fd, &watches); + if (b.missing_said) return error.SkipZigTest; // no glslc here + var out: [max]Pass = undefined; + const first = b.view(&core.settings.post, &out)[0]; + try std.testing.expect(first.spirv.len != 0); + if (file_watch.supported) try std.testing.expect(watches[file_watch.shader_slot] != null); + + // Unchanged bytes: nothing moves. + const at = b.revision; + b.recheck(); + settle(&b, core, fd, &watches); + try std.testing.expectEqual(at, b.revision); + + // A bad save: the last good SPIR-V stays, the error is said, and the + // same bad bytes again say nothing more. + try tmp.dir.writeFile(io, .{ .sub_path = "wave.glsl", .data = "void mainImage(" }); + pane.msg_len = 0; + b.recheck(); + settle(&b, core, fd, &watches); + try std.testing.expectEqual(at, b.revision); + try std.testing.expect(std.mem.startsWith(u8, pane.msg[0..pane.msg_len], "Shader ")); + pane.msg_len = 0; + b.recheck(); + settle(&b, core, fd, &watches); + try std.testing.expectEqual(@as(u16, 0), pane.msg_len); + + // A good save again: new SPIR-V, a new revision. + try tmp.dir.writeFile(io, .{ .sub_path = "wave.glsl", .data = "// saved again\n" ++ good }); + b.recheck(); + settle(&b, core, fd, &watches); + 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)); +} diff --git a/src/detached/server.zig b/src/detached/server.zig index b536e4a7..de26db13 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -11,7 +11,7 @@ const host_io = @import("../host_io.zig"); const selection_pipe = @import("../selection_pipe.zig"); const file_watch = @import("../file_watch.zig"); -const shader_build = @import("../shader_build.zig"); +const ShaderBuild = @import("../ShaderBuild.zig"); const ninep_io = @import("../9p_io.zig"); @@ -177,7 +177,7 @@ pub const Session = struct { ninep_wake: std.atomic.Value(bool) = .init(false), watches: file_watch.Table = @splat(null), /// The post chain's files, compiled here for the GUIs attached. - shaders: shader_build = .{}, + shaders: ShaderBuild = .{}, check_files: bool = false, in_loop: bool = false, greet_deadline_ms: u32 = greet_deadline_default_ms, diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 15d3641c..051e9317 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -3,7 +3,7 @@ const std = @import("std"); const pardes = @import("../pardes.zig"); const limits = @import("../memory.zig").limits; -const shader_build = @import("../shader_build.zig"); +const ShaderBuild = @import("../ShaderBuild.zig"); const Scene = pardes.config.Runtime.Scene; const ShaderAnimation = pardes.config.Runtime.ShaderAnimation; @@ -364,15 +364,15 @@ pub const ServerMsg = union(enum) { /// The session's post chain, as a GUI attached to it draws it: its passes in /// order, each a bundled scene at its level or a Shadertoy file's SPIR-V -/// (compiled by the session, shader_build.zig: a frontend reads no disk and +/// (compiled by the session, ShaderBuild.zig: a frontend reads no disk and /// runs no program), and when it animates on its own. Slices borrow the /// payload. pub const Post = struct { animation: ShaderAnimation = .on, - passes: [shader_build.max]shader_build.Pass = @splat(.{}), + passes: [ShaderBuild.max]ShaderBuild.Pass = @splat(.{}), len: u8 = 0, - pub fn list(p: *const Post) []const shader_build.Pass { + pub fn list(p: *const Post) []const ShaderBuild.Pass { return p.passes[0..p.len]; } }; @@ -1275,7 +1275,7 @@ pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg { .post => blk: { var p: Post = .{ .animation = try r.getTag(ShaderAnimation) }; p.len = try r.getByte(); - if (p.len > shader_build.max) return error.Overlong; + if (p.len > ShaderBuild.max) return error.Overlong; for (p.passes[0..p.len]) |*pass| { const kind = try r.getByte(); pass.* = .{ diff --git a/src/file_watch.zig b/src/file_watch.zig index 148bc836..6c746e74 100644 --- a/src/file_watch.zig +++ b/src/file_watch.zig @@ -70,7 +70,7 @@ pub const Watch = struct { /// Sharing the table also shares directory descriptors correctly when a user /// happens to open that .zon as an ordinary document. pub const theme_slot = pardes.MAX_PANES; -/// After it, one slot a post chain file (shader_build.zig): marked only so +/// After it, one slot a post chain file (ShaderBuild.zig): marked only so /// a save there wakes the host, which has the files read again; no /// generation of theirs is kept here. pub const shader_slot = theme_slot + 1; diff --git a/src/gui/Post.zig b/src/gui/Post.zig index 602b2790..fc256135 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -2,7 +2,7 @@ //! finished frame, each reading the one before it (the frame first) and the //! last one writing the window. A pass is the bundled Crt (shaders/post/, //! compiled with the build) or a user's file (Shader ), whose SPIR-V -//! comes from shader_build.zig: compiled by the process that holds the core, +//! comes from ShaderBuild.zig: compiled by the process that holds the core, //! this one or the detached session this GUI is attached to. A file whose //! compile fails keeps its last good pipeline. //! @@ -24,7 +24,7 @@ const Pipeline = c.SDL_GPUGraphicsPipeline; pub const max = Chain.max; const prefix = @embedFile("post-prefix.glsl"); -const Built = @import("../shader_build.zig").Pass; +const Built = @import("../ShaderBuild.zig").Pass; const vert_spv = @embedFile("post.vert.spv"); const bundled_spv = std.EnumArray(Scene, []const u8).init(.{ .crt = @embedFile("post-crt.frag.spv"), @@ -92,9 +92,9 @@ pub const Pass = struct { /// A user's file, owned; empty for a bundled pass. path: []u8 = &.{}, pipeline: ?*Pipeline = null, - /// The SPIR-V revision `pipeline` was made from (shader_build.Pass). + /// The SPIR-V revision `pipeline` was made from (ShaderBuild.Pass). revision: u32 = 0, - /// It moves on its own (shader_build.Pass). + /// It moves on its own (ShaderBuild.Pass). animated: bool = false, }; @@ -125,7 +125,7 @@ bloom_up: ?*Pipeline = null, started_ns: ?u64 = null, last_ns: u64 = 0, -/// Brings the passes to the chain a shell was given (shader_build.zig: a +/// Brings the passes to the chain a shell was given (ShaderBuild.zig: a /// local core's, or an attached session's over the wire), whose `key` moves /// whenever the chain or a file's SPIR-V does. A file whose SPIR-V has not /// moved keeps its pipeline; one the GPU refuses keeps its last, and is said @@ -203,7 +203,7 @@ 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 +/// Only a pass that moves on its own (ShaderBuild.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 { diff --git a/src/gui/gui.zig b/src/gui/gui.zig index b34b314d..df565b94 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -11,7 +11,7 @@ const config = @import("../config.zig"); const look = @import("../look.zig"); const message = pardes.Messages.Message; const file_watch = @import("../file_watch.zig"); -const shader_build = @import("../shader_build.zig"); +const ShaderBuild = @import("../ShaderBuild.zig"); const deck = @import("deck.zig"); const Post = @import("Post.zig"); const pet = @import("pet.zig"); @@ -4016,7 +4016,7 @@ const Shell = struct { inotify_fd: c_int, watches: *file_watch.Table, /// The post chain's files as SPIR-V. - shaders: shader_build = .{}, + shaders: ShaderBuild = .{}, threads_ok: bool = false, test_mode: bool = false, feed: StdinFeed = .{}, @@ -4240,7 +4240,7 @@ const Shell = struct { } }; -/// A post chain compile is done (shader_build.zig): the loop's SDL wait ends. +/// A post chain compile is done (ShaderBuild.zig): the loop's SDL wait ends. fn wakeSdl(_: ?*anyopaque) void { var event = std.mem.zeroes(c.SDL_Event); event.type = c.SDL_EVENT_USER; @@ -4391,7 +4391,7 @@ fn pollFrame(ctx: ?*anyopaque) void { stepScroll(g, core, s.gpa); _ = s.shaders.sync(s.gpa, s.io, core, s.inotify_fd, s.watches, .{ .call = wakeSdl }); { - var passes: [Post.max]shader_build.Pass = undefined; + var passes: [Post.max]ShaderBuild.Pass = undefined; g.post.sync(s.gpa, g.device, g.swapchain_format, s.shaders.view(&core.settings.post, &passes), s.shaders.revision, core); } if (s.test_mode) writeTestStatus(g, core, s.presented); diff --git a/src/main.zig b/src/main.zig index ab6f1527..efc24a11 100644 --- a/src/main.zig +++ b/src/main.zig @@ -524,7 +524,7 @@ test { _ = @import("locations_cache.zig"); _ = @import("fs.zig"); _ = @import("detached/wire.zig"); - _ = @import("shader_build.zig"); + _ = @import("ShaderBuild.zig"); _ = @import("detached/server.zig"); _ = @import("detached/client.zig"); _ = @import("9p_io.zig"); diff --git a/src/shader_build.zig b/src/shader_build.zig deleted file mode 100644 index c2610fd7..00000000 --- a/src/shader_build.zig +++ /dev/null @@ -1,428 +0,0 @@ -//! The post chain's Shadertoy files as SPIR-V (docs/render-pipeline.md §6), -//! for whichever process holds the core: a local GUI makes its pipelines -//! from them, a detached session sends them to the GUIs attached to it -//! (wire `post`), which never read a disk or run a program themselves. -//! -//! Each file compiles behind the prefix by the build's own glslc invocation, -//! on a thread of its own and never on the frame path, when it joins the -//! chain and again whenever a save changes its bytes: the files' directories -//! are watched (file_watch.watchShaders) and a change there has every file -//! read and hashed, an unchanged one compiling nothing. A compile that fails -//! keeps the last good SPIR-V and is said once, and glslc missing is said -//! once for good. -const std = @import("std"); -const libc = std.c; -const pardes = @import("pardes.zig"); -const file_watch = @import("file_watch.zig"); - -const Build = @This(); -const Chain = pardes.config.Runtime.Post; -const Scene = pardes.config.Runtime.Scene; -pub const max = Chain.max; - -const prefix = @embedFile("post-prefix.glsl"); - -/// A pass as a shell draws it: a bundled scene at its level, or a file with -/// its last good SPIR-V (empty until a compile succeeds), whose `revision` -/// moves each time that SPIR-V does. Slices borrow the build's (or, in an -/// attached GUI, the client's) storage until its next change. -pub const Pass = struct { - scene: ?Scene = null, - level: u8 = 0, - 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, - call: ?*const fn (?*anyopaque) void = null, -}; - -const File = struct { - path: []u8 = &.{}, - /// The hash of the source last compiled, good or not: the same bytes - /// saved again compile nothing and say nothing twice. - 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, -}; - -const Result = union(enum) { - none, - unchanged, - missing, - failed: struct { text: []u8, source: u64 }, - spirv: struct { bytes: []u8, source: u64, animated: bool }, -}; - -/// One compile of the stale files, on its own thread. -const Job = struct { - io: std.Io, - gpa: std.mem.Allocator, - wake: Wake, - paths: [max][]u8 = @splat(&.{}), - known: [max]?u64 = @splat(null), - results: [max]Result = @splat(.none), - len: usize = 0, - done: std.atomic.Value(bool) = .init(false), - thread: std.Thread = undefined, -}; - -files: [max]File = @splat(.{}), -len: usize = 0, -/// The chain generation `files` follow; null before the first. -generation: ?u32 = null, -/// Moves whenever what `view` gives does: the chain or a file's SPIR-V. -revision: u32 = 0, -job: ?*Job = null, -missing_said: bool = false, - -/// Brings the files to the chain the core holds, watches their directories, -/// takes in a finished compile and starts the next. Each loop step, off the -/// frame path. True when a compile was taken in: what it said wants a frame. -pub fn sync(b: *Build, gpa: std.mem.Allocator, io: std.Io, core: *pardes.Pardes, fd: c_int, watches: *file_watch.Table, wake: Wake) bool { - var collected = false; - if (b.job) |job| { - if (!job.done.load(.acquire)) return false; - job.thread.join(); - b.job = null; - b.collect(gpa, core, job); - collected = true; - } - const chain = &core.settings.post; - if (b.generation != chain.generation) { - b.generation = chain.generation; - b.revision +%= 1; - var next: [max]File = @splat(.{}); - var len: usize = 0; - var paths: [max][]const u8 = undefined; - for (chain.list()) |entry| { - if (entry.scene != null) continue; - next[len] = for (b.files[0..b.len]) |*file| { - if (!std.mem.eql(u8, file.path, entry.path.get())) continue; - const kept = file.*; - file.* = .{}; - break kept; - } else .{ .path = gpa.dupe(u8, entry.path.get()) catch continue }; - paths[len] = next[len].path; - len += 1; - } - for (b.files[0..b.len]) |file| free(gpa, file); - b.files = next; - b.len = len; - var bufs: [max][4096]u8 = undefined; - var expanded: [max][]const u8 = undefined; - for (paths[0..len], 0..) |path, i| expanded[i] = expand(&bufs[i], path); - file_watch.watchShaders(fd, watches, expanded[0..len]); - } - var stale = false; - for (b.files[0..b.len]) |file| stale = stale or file.stale; - if (!stale) return collected; - const job = gpa.create(Job) catch return collected; - job.* = .{ .io = io, .gpa = gpa, .wake = wake }; - for (b.files[0..b.len]) |*file| { - if (!file.stale) continue; - job.paths[job.len] = gpa.dupe(u8, file.path) catch continue; - job.known[job.len] = file.source; - job.len += 1; - file.stale = false; - } - job.thread = std.Thread.spawn(.{}, compile, .{job}) catch { - for (job.paths[0..job.len]) |path| gpa.free(path); - gpa.destroy(job); - return collected; - }; - b.job = job; - return collected; -} - -/// A watched directory changed: every file is read again at the next sync. -pub fn recheck(b: *Build) void { - for (b.files[0..b.len]) |*file| file.stale = true; -} - -/// 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, .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]; -} - -pub fn deinit(b: *Build, gpa: std.mem.Allocator, fd: c_int, watches: *file_watch.Table) void { - if (b.job) |job| { - job.thread.join(); - freeJob(gpa, job); - } - for (b.files[0..b.len]) |file| free(gpa, file); - b.len = 0; - file_watch.watchShaders(fd, watches, &.{}); -} - -fn free(gpa: std.mem.Allocator, file: File) void { - gpa.free(file.path); - gpa.free(file.spirv); -} - -fn freeJob(gpa: std.mem.Allocator, job: *Job) void { - for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { - gpa.free(path); - switch (result) { - .spirv => |r| gpa.free(r.bytes), - .failed => |r| gpa.free(r.text), - .none, .unchanged, .missing => {}, - } - } - gpa.destroy(job); -} - -fn collect(b: *Build, gpa: std.mem.Allocator, 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 file = for (b.files[0..b.len]) |*file| { - if (std.mem.eql(u8, file.path, path)) break file; - } else null; - var buf: [512]u8 = undefined; - switch (result) { - .none, .unchanged => {}, - .missing => if (!b.missing_said) { - b.missing_said = true; - core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt does)"); - }, - .failed => |r| { - defer gpa.free(r.text); - if (file) |f| f.source = r.source; - // glslc's first line says where: in the file's own lines. - const first = std.mem.sliceTo(r.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 => |r| { - const f = file orelse { - gpa.free(r.bytes); - continue; - }; - gpa.free(f.spirv); - f.spirv = r.bytes; - f.source = r.source; - f.animated = r.animated; - f.revision +%= 1; - b.revision +%= 1; - }, - } - } -} - -/// `~/` is the home directory. -fn expand(buf: []u8, path: []const u8) []const u8 { - if (!std.mem.startsWith(u8, path, "~/")) return path; - const home = libc.getenv("HOME") orelse return path; - return std.fmt.bufPrint(buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path; -} - -/// 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 { - for (job.paths[0..job.len], 0..) |path, i| job.results[i] = compileOne(job.gpa, job.io, path, job.known[i]); - job.done.store(true, .release); - if (job.wake.call) |call| call(job.wake.ctx); -} - -fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8, known: ?u64) Result { - var home_buf: [4096]u8 = undefined; - // Not fs.zig's readFile: that hands the core's turn over for the read, - // a turn this thread never holds. - const body = std.Io.Dir.cwd().readFileAlloc(io, expand(&home_buf, path), gpa, .limited(1 << 20)) catch |err| - return .{ .failed = .{ .text = std.fmt.allocPrint(gpa, "cannot read it ({t})", .{err}) catch return .none, .source = 0 } }; - defer gpa.free(body); - const source = std.hash.Wyhash.hash(0, body); - if (known == source) return .unchanged; - // 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 = .{ .text = std.fmt.allocPrint(gpa, "glslc did not run ({t})", .{err}) catch return .none, .source = source } }, - }; - 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(); - const failed = struct { - fn of(a: std.mem.Allocator, text: []const u8, hash: u64) Result { - return .{ .failed = .{ .text = a.dupe(u8, text) catch return .none, .source = hash } }; - } - }.of; - while (reader.fill(64, .none)) |_| { - if (reader.reader(0).buffered().len > 16 << 20 or reader.reader(1).buffered().len > 64 << 10) - return failed(gpa, "glslc said too much", source); - } else |err| switch (err) { - error.EndOfStream => {}, - else => return failed(gpa, "reading glslc failed", source), - } - const term = child.wait(io) catch return failed(gpa, "glslc did not finish", source); - const clean = switch (term) { - .exited => |code| code == 0, - else => false, - }; - 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); - const named = std.mem.replaceOwned(u8, gpa, said, "", path) catch return failed(gpa, "glslc failed", source); - return .{ .failed = .{ .text = named, .source = source } }; -} - -test "ghostty's test shaders compile behind the prefix, and its invalid one fails with glslc's words" { - const gpa = std.testing.allocator; - const io = std.testing.io; - // ghostty's own test shaders, from its package in zig-pkg. - var pkg = std.Io.Dir.cwd().openDir(io, "zig-pkg", .{ .iterate = true }) catch return error.SkipZigTest; - defer pkg.close(io); - var it = pkg.iterate(); - const ghostty = while (it.next(io) catch null) |entry| { - if (std.mem.startsWith(u8, entry.name, "ghostty-")) break entry.name; - } else return error.SkipZigTest; - var buf: [512]u8 = undefined; - for ([_][]const u8{ "crt", "focus", "invalid" }) |name| { - const path = try std.fmt.bufPrint(&buf, "zig-pkg/{s}/src/renderer/shaders/test_shadertoy_{s}.glsl", .{ ghostty, name }); - switch (compileOne(gpa, io, path, null)) { - .missing => return error.SkipZigTest, // no glslc here - .spirv => |r| { - defer gpa.free(r.bytes); - 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, r.bytes[0..4], .little)); - // The same bytes again compile nothing. - try std.testing.expectEqual(Result.unchanged, compileOne(gpa, io, path, r.source)); - }, - .failed => |r| { - defer gpa.free(r.text); - try std.testing.expectEqualStrings("invalid", name); - // Named by the file, not by glslc's . - try std.testing.expect(std.mem.indexOf(u8, r.text, path) != null); - try std.testing.expect(std.mem.indexOf(u8, r.text, "") == null); - }, - .none, .unchanged => return error.TestUnexpectedResult, - } - } -} - -test "a save that changes a file compiles it again, a failed one keeps the last good SPIR-V and is said once" { - const gpa = std.testing.allocator; - const io = std.testing.io; - const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); - defer core.deinit(); - _ = try core.newShell(1, ""); - const pane = core.panes[core.active].?; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var dir_buf: [4096]u8 = undefined; - const dir = dir_buf[0..try tmp.dir.realPath(io, &dir_buf)]; - var path_buf: [4200]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, "{s}/wave.glsl", .{dir}); - const good = "void mainImage(out vec4 o, in vec2 p) { o = texture(iChannel0, p / iResolution.xy); }\n"; - try tmp.dir.writeFile(io, .{ .sub_path = "wave.glsl", .data = good }); - try std.testing.expect(core.settings.post.set(null, path, true)); - - const fd = file_watch.init(true); - defer if (fd >= 0) { - _ = libc.close(fd); - }; - var watches: file_watch.Table = @splat(null); - var b: Build = .{}; - defer b.deinit(gpa, fd, &watches); - const settle = struct { - fn run(build: *Build, c: *pardes.Pardes, f: c_int, w: *file_watch.Table) void { - _ = build.sync(std.testing.allocator, std.testing.io, c, f, w, .{}); - while (build.job) |job| { - while (!job.done.load(.acquire)) std.Thread.yield() catch {}; - _ = build.sync(std.testing.allocator, std.testing.io, c, f, w, .{}); - } - } - }.run; - settle(&b, core, fd, &watches); - if (b.missing_said) return error.SkipZigTest; // no glslc here - var out: [max]Pass = undefined; - const first = b.view(&core.settings.post, &out)[0]; - try std.testing.expect(first.spirv.len != 0); - if (file_watch.supported) try std.testing.expect(watches[file_watch.shader_slot] != null); - - // Unchanged bytes: nothing moves. - const at = b.revision; - b.recheck(); - settle(&b, core, fd, &watches); - try std.testing.expectEqual(at, b.revision); - - // A bad save: the last good SPIR-V stays, the error is said, and the - // same bad bytes again say nothing more. - try tmp.dir.writeFile(io, .{ .sub_path = "wave.glsl", .data = "void mainImage(" }); - pane.msg_len = 0; - b.recheck(); - settle(&b, core, fd, &watches); - try std.testing.expectEqual(at, b.revision); - try std.testing.expect(std.mem.startsWith(u8, pane.msg[0..pane.msg_len], "Shader ")); - pane.msg_len = 0; - b.recheck(); - settle(&b, core, fd, &watches); - try std.testing.expectEqual(@as(u16, 0), pane.msg_len); - - // A good save again: new SPIR-V, a new revision. - try tmp.dir.writeFile(io, .{ .sub_path = "wave.glsl", .data = "// saved again\n" ++ good }); - b.recheck(); - settle(&b, core, fd, &watches); - 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