1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
|
//! 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 (the one rule, filesystem.expandHome).
fn expand(buf: *[4096]u8, path: []const u8) []const u8 {
return pardes.filesystem.expandHome(path, buf);
}
/// 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 <stdin>; the file is what a person knows.
defer gpa.free(said);
const named = std.mem.replaceOwned(u8, gpa, said, "<stdin>", 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 <stdin>.
try std.testing.expect(std.mem.indexOf(u8, r.text, path) != null);
try std.testing.expect(std.mem.indexOf(u8, r.text, "<stdin>") == 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));
}
|