summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig10
-rw-r--r--docs/config.md16
-rw-r--r--docs/effects.md18
-rw-r--r--docs/render-pipeline.md4
-rw-r--r--shaders/bloom-down.frag.glsl39
-rw-r--r--shaders/bloom-up.frag.glsl26
-rw-r--r--shaders/post/bloom.glsl40
-rw-r--r--shaders/post/grain.glsl32
-rw-r--r--shaders/post/prefix.glsl27
-rw-r--r--shaders/post/vignette.glsl38
-rw-r--r--shaders/prebuilt/bloom-down.frag.glsl39
-rw-r--r--shaders/prebuilt/bloom-down.frag.spvbin0 -> 3876 bytes
-rw-r--r--shaders/prebuilt/bloom-up.frag.glsl26
-rw-r--r--shaders/prebuilt/bloom-up.frag.spvbin0 -> 3504 bytes
-rw-r--r--shaders/prebuilt/post-bloom.frag.glsl141
-rw-r--r--shaders/prebuilt/post-bloom.frag.spvbin0 -> 8500 bytes
-rw-r--r--shaders/prebuilt/post-crt.frag.glsl27
-rw-r--r--shaders/prebuilt/post-crt.frag.spvbin12668 -> 13092 bytes
-rw-r--r--shaders/prebuilt/post-grain.frag.glsl133
-rw-r--r--shaders/prebuilt/post-grain.frag.spvbin0 -> 8236 bytes
-rw-r--r--shaders/prebuilt/post-vignette.frag.glsl139
-rw-r--r--shaders/prebuilt/post-vignette.frag.spvbin0 -> 8652 bytes
-rw-r--r--src/config.zig15
-rw-r--r--src/effect_sources.zig13
-rw-r--r--src/gui/Post.zig291
-rw-r--r--src/gui/gui.zig42
26 files changed, 1089 insertions, 27 deletions
diff --git a/build.zig b/build.zig
index e63b7f4b..d4f3e120 100644
--- a/build.zig
+++ b/build.zig
@@ -51,9 +51,15 @@ const gui_shaders = [_][]const u8{
"overlay.vert", "overlay.frag",
"image.vert", "image.frag",
"post.vert",
- // The bundled post pass: shaders/post/crt.glsl behind the Shadertoy
- // prefix (shaderSource).
+ // The bundled post passes: shaders/post/<name>.glsl behind the
+ // Shadertoy prefix (shaderSource).
"post-crt.frag",
+ "post-bloom.frag",
+ "post-vignette.frag",
+ "post-grain.frag",
+ // Bloom's steps below the frame (src/gui/Post.zig bloomChain).
+ "bloom-down.frag",
+ "bloom-up.frag",
};
pub fn build(b: *std.Build) void {
diff --git a/docs/config.md b/docs/config.md
index 40d0fb24..b3cff3a4 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -122,7 +122,7 @@ such a shell there when it is the column's only one and nobody has typed
into it (no scrollback, cursor still on the first prompt line), the boot's
placeholder giving its rows to the document. Bare `BootShell` flips it, as
bare `Placement` does: a setting that chooses among words (`on`/`off`
-switches, `Placement`, `BootShell`, `Crt`, `Lift`, `Motion`,
+switches, `Placement`, `BootShell`, `Crt`, `Bloom`, `Vignette`, `Grain`, `Lift`, `Motion`,
`ShaderAnimation`) steps to its
next value when written bare, as its word clicked in a tag does, and a value
it does not take is refused with the values it takes, which `/commands` also
@@ -542,6 +542,20 @@ 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.
+Three more bundled passes take the same levels, each off by default and
+each costing nothing while off:
+
+```text
+Bloom 2 the brightest ink glows a little (only what is brighter than
+ the page; a dual Kawase blur at half size and down)
+Vignette 2 the window's corners fall a little into shade
+Grain 2 the page's own ground takes a fine, still grain, like paper
+```
+
+None of them touches a tag, a grip, a notice or the cursor, and each is
+capped per theme so text and the selection keep their own contrast or 4.5
+(docs/effects.md).
+
The focused pane can stand off the page (SDL GUI, off by default):
```text
diff --git a/docs/effects.md b/docs/effects.md
index c8e5c7a7..8073c50a 100644
--- a/docs/effects.md
+++ b/docs/effects.md
@@ -37,6 +37,7 @@ why. The verdict is the user's.
| Ripple, Glitch | — | removed | live window | dropped by the user after a live look |
| G1 lift | `Lift shadow\|rim\|auto` (off), `InactiveDim <percent>` | opt-in until the focus-lift default is decided | lift-shots-2 (off, shadow, rim, auto on acme, dusk, forge); frame series + mp4 per Motion flavour | shadow on acme: keep as is. Round 1 dropped glow and surface (surface lowered the focused text's contrast) and made rim a hairline just above the focused tag (light on a dark page, shade on a light one). auto on a dark page recedes the others (InactiveDim 30) |
| G3 cursor | follows `Motion`; `CursorBlink on\|off` (on) | glide on by default through the default flavour (smooth); blink on (decision 6) | .scratch/render/cursor/cursor-flavours-grid.mp4 (and quarter speed), raw/<flavour>/<motion>/ | pending |
+| G4 bloom, vignette, grain | `Bloom`, `Vignette`, `Grain` `0..3` (off) | bundled post passes; none touches a tag, grip, notice or the cursor (pardesSpare); each capped per theme (pardesCap) | .scratch/render/{bloom,vignette,grain}/ (levels 0-3 on forge, dusk, acme; crops; mp4 stepping through the levels) | pending |
## Motion flavours
@@ -96,3 +97,20 @@ forge's text goes from 15.5:1 to 11.2:1 and dusk's from 6.4:1 to 4.8:1,
plainly quieter and still easy reading; at 10 forge barely moves (14.1:1).
On a light page the same percent bites far harder (acme: 7.0:1 at 10, the
4.5 floor at 30), which is why `auto` shades there instead of dimming.
+
+Notes on G4: the three are post passes, so each is a frame of cost only
+when it is in the chain. None moves (grain is still, by choice: moving
+grain would redraw every frame for a texture), so none asks for frames.
+The focus rule is kept by construction: the shell hands every pass the
+rectangles of the tags (with their grips), the column and workspace tags,
+the notices and the cursor, and a bundled pass leaves them as they are; its
+strength is capped per theme so the text on the page and the selection's
+text keep min(their contrast, 4.5) (Post.capsOf, tested over the native
+themes). Bloom takes only what is brighter than the page, so a light page
+does not bloom at all; its glow is a dual Kawase blur in float textures at
+half size and down (3 to 5 steps by level), within §8.2's 5%. The vignette
+is round, starts halfway to a corner and reaches 10, 18 and 28%. Grain is
+1.5, 3 and 5 of 255 on page-coloured pixels only, a logical pixel big.
+Found on the way: SDL GPU binds at most 4 KiB of a uniform block, so every
+Shadertoy colour after the palette (iBackgroundColor and on) read zero; the
+block is now cut in three with ghostty's names and order kept.
diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md
index 7c94fbea..b8f735d9 100644
--- a/docs/render-pipeline.md
+++ b/docs/render-pipeline.md
@@ -460,7 +460,9 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`,
iBackgroundColor, iForegroundColor, iCursorColor, iCursorText,
iSelectionForegroundColor, iSelectionBackgroundColor; CURSORSTYLE_* defines;
`#define texture2D texture`), but SDL GPU bindings: `iChannel0` at set 2
- binding 0, the block at set 3 binding 0. `main()` calls
+ binding 0, the block at set 3 bindings 0 to 2 (cut before and after
+ iPalette: SDL GPU binds at most 4 KiB of a block, so one block left
+ the colours after the palette at zero until G4), pardes's own at 3. `main()` calls
`mainImage(_fragColor, gl_FragCoord.xy)`. Trap found: ghostty's Zig
`Uniforms` (shadertoy.zig:13) orders selection_background before
selection_foreground while its GLSL block orders Foreground before Background,
diff --git a/shaders/bloom-down.frag.glsl b/shaders/bloom-down.frag.glsl
new file mode 100644
index 00000000..3465926d
--- /dev/null
+++ b/shaders/bloom-down.frag.glsl
@@ -0,0 +1,39 @@
+#version 450
+
+// Bloom, down (docs/effects.md G4): one step of a dual Kawase filter, a
+// source half as big again sampled at five points, the first step over the
+// frame through a bright pass in linear light. Written to a float target.
+
+layout(set = 2, binding = 0) uniform sampler2D source;
+layout(std140, set = 3, binding = 0) uniform BloomStep {
+ // xy: a source texel; zw: a target texel.
+ vec4 texels;
+ // x: the bright pass's threshold on a channel's linear peak (above the
+ // page's own), 0 for none (a later step); y: its soft knee.
+ vec4 pass;
+} u;
+
+layout(location = 0) out vec4 o_col;
+
+vec3 toLinear(vec3 c) {
+ return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045)));
+}
+
+vec3 tap(vec2 uv) {
+ vec3 c = texture(source, uv).rgb;
+ if (u.pass.x <= 0.0) return c;
+ c = toLinear(c);
+ float peak = max(c.r, max(c.g, c.b));
+ return c * smoothstep(u.pass.x, u.pass.x + u.pass.y, peak);
+}
+
+void main() {
+ vec2 uv = gl_FragCoord.xy * u.texels.zw;
+ vec2 h = 0.5 * u.texels.xy;
+ vec3 sum = tap(uv) * 4.0;
+ sum += tap(uv - h);
+ sum += tap(uv + h);
+ sum += tap(uv + vec2(h.x, -h.y));
+ sum += tap(uv - vec2(h.x, -h.y));
+ o_col = vec4(sum / 8.0, 1.0);
+}
diff --git a/shaders/bloom-up.frag.glsl b/shaders/bloom-up.frag.glsl
new file mode 100644
index 00000000..368076ed
--- /dev/null
+++ b/shaders/bloom-up.frag.glsl
@@ -0,0 +1,26 @@
+#version 450
+
+// Bloom, up (docs/effects.md G4): one step of a dual Kawase filter back up,
+// a tent of eight points over the smaller level. Float target.
+
+layout(set = 2, binding = 0) uniform sampler2D source;
+layout(std140, set = 3, binding = 0) uniform BloomStep {
+ vec4 texels;
+ vec4 pass;
+} u;
+
+layout(location = 0) out vec4 o_col;
+
+void main() {
+ vec2 uv = gl_FragCoord.xy * u.texels.zw;
+ vec2 h = 0.5 * u.texels.xy;
+ vec3 sum = texture(source, uv + vec2(-2.0 * h.x, 0.0)).rgb;
+ sum += texture(source, uv + vec2(-h.x, h.y)).rgb * 2.0;
+ sum += texture(source, uv + vec2(0.0, 2.0 * h.y)).rgb;
+ sum += texture(source, uv + vec2(h.x, h.y)).rgb * 2.0;
+ sum += texture(source, uv + vec2(2.0 * h.x, 0.0)).rgb;
+ sum += texture(source, uv + vec2(h.x, -h.y)).rgb * 2.0;
+ sum += texture(source, uv + vec2(0.0, -2.0 * h.y)).rgb;
+ sum += texture(source, uv + vec2(-h.x, -h.y)).rgb * 2.0;
+ o_col = vec4(sum / 12.0, 1.0);
+}
diff --git a/shaders/post/bloom.glsl b/shaders/post/bloom.glsl
new file mode 100644
index 00000000..d54a0aac
--- /dev/null
+++ b/shaders/post/bloom.glsl
@@ -0,0 +1,40 @@
+// Bloom: the brightest ink glows, a little. The glow itself is made by the
+// shell at half resolution and below (bloom-down, bloom-up: a dual Kawase
+// filter over a bright pass, in linear light) and arrives here in
+// pardesBloom; this pass adds it to the frame. Only true highlights pass
+// the threshold, and the strength stays in §8.2's budget (at most 5%) and
+// under pardesCap, so no text or selection loses contrast below its own or
+// 4.5. Tags, grips, notices and the cursor are left alone (pardesSpare).
+// It is a Shadertoy file: pardes puts its prefix before it.
+
+layout(set = 2, binding = 1) uniform sampler2D pardesBloom;
+
+float strength() { return min(pardesByLevel(0.02, 0.035, 0.05), pardesCap); }
+
+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 uv = fragCoord / iResolution.xy;
+ vec4 frame = texture(iChannel0, uv);
+ if (pardesSpared(fragCoord)) {
+ fragColor = frame;
+ return;
+ }
+ vec3 glow = texture(pardesBloom, uv).rgb;
+ vec3 col = toLinear(frame.rgb) + glow * strength();
+ vec3 srgb = toSrgb(col);
+ // Dithered: a glow's long falloff never bands.
+ srgb += (ign(fragCoord) - 0.5) / 255.0 * step(1e-4, dot(glow, vec3(1.0)));
+ fragColor = vec4(clamp(srgb, 0.0, 1.0), frame.a);
+}
diff --git a/shaders/post/grain.glsl b/shaders/post/grain.glsl
new file mode 100644
index 00000000..61c51492
--- /dev/null
+++ b/shaders/post/grain.glsl
@@ -0,0 +1,32 @@
+// Grain: the page's own ground takes a fine, still grain, like paper. Only
+// pixels of the page's colour (iBackgroundColor) are grained: no glyph, no
+// selection, no tag or rule, and nothing in pardesSpare. The grain averages
+// to the page, is a logical pixel big (pardesScale), and never moves, so it
+// costs no frames. At most 1.5, 3 and 5 of 255 at levels 1 to 3, and never
+// more than pardesCap, which keeps the text on it at its own contrast or 4.5.
+// It is a Shadertoy file: pardes puts its prefix before it.
+
+float amplitude() { return min(pardesByLevel(1.5, 3.0, 5.0) / 255.0, pardesCap); }
+
+// An integer hash of the grain cell: white, with no pattern to see.
+float hash(uvec2 p) {
+ uint h = p.x * 1664525u + p.y * 1013904223u;
+ h ^= h >> 16;
+ h *= 2246822519u;
+ h ^= h >> 13;
+ h *= 3266489917u;
+ h ^= h >> 16;
+ return float(h) / 4294967295.0;
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 uv = fragCoord / iResolution.xy;
+ vec4 frame = texture(iChannel0, uv);
+ fragColor = frame;
+ vec3 page = iBackgroundColor.rgb;
+ if (any(greaterThan(abs(frame.rgb - page), vec3(0.75 / 255.0))) || pardesSpared(fragCoord)) return;
+ uvec2 cell = uvec2(fragCoord / max(1.0, pardesScale));
+ // Two hashes summed: a triangular spread, softer than a flat one.
+ float n = hash(cell) + hash(cell + uvec2(7919u, 104729u)) - 1.0;
+ fragColor = vec4(clamp(page + n * amplitude(), 0.0, 1.0), frame.a);
+}
diff --git a/shaders/post/prefix.glsl b/shaders/post/prefix.glsl
index 7a50936e..44c5e0f8 100644
--- a/shaders/post/prefix.glsl
+++ b/shaders/post/prefix.glsl
@@ -30,7 +30,14 @@ layout(set = 3, binding = 0, std140) uniform Globals {
uniform float iTimeCursorChange;
uniform float iTimeFocus;
uniform int iFocus;
+};
+// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a
+// uniform block (Vulkan), and the palette alone is 4 KiB. The names and
+// their order are ghostty's; each block is a slice of the same memory.
+layout(set = 3, binding = 1, std140) uniform Palette {
uniform vec3 iPalette[256];
+};
+layout(set = 3, binding = 2, std140) uniform Colors {
uniform vec3 iBackgroundColor;
uniform vec3 iForegroundColor;
uniform vec3 iCursorColor;
@@ -44,13 +51,29 @@ layout(set = 3, binding = 0, std140) uniform Globals {
// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file; pardesScale
// is the display's density (2 on a 2x screen), for a pass whose pattern is
// in logical pixels. iResolution, fragCoord and iMouse stay in the display's
-// pixels, as ghostty's do.
-layout(set = 3, binding = 1, std140) uniform PardesPost {
+// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on
+// this theme and still leave its text, and its selection's, at their own
+// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount
+// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves
+// alone: every tag, grip and notice, and the cursor, so no pass ever touches
+// a focus indicator.
+layout(set = 3, binding = 3, std140) uniform PardesPost {
float pardesOpaque;
float pardesLevel;
float pardesScale;
+ float pardesCap;
+ float pardesSpareCount;
+ vec4 pardesSpare[32];
};
+bool pardesSpared(vec2 p) {
+ for (int i = 0; i < int(pardesSpareCount); i++) {
+ vec4 r = pardesSpare[i];
+ if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true;
+ }
+ return false;
+}
+
// 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);
diff --git a/shaders/post/vignette.glsl b/shaders/post/vignette.glsl
new file mode 100644
index 00000000..30dc2e0f
--- /dev/null
+++ b/shaders/post/vignette.glsl
@@ -0,0 +1,38 @@
+// Vignette: the window's corners fall a little into shade, in linear light,
+// so the eye settles on the middle. Round, whatever the window's shape;
+// nothing within half the way to a corner is touched. At most 28% at the
+// far corners (level 3), and never more than pardesCap, so text and its
+// selection keep their own contrast or 4.5. Tags, grips, notices and the
+// cursor are left alone (pardesSpare): the focus indicators never darken.
+// It is a Shadertoy file: pardes puts its prefix before it.
+
+float strength() { return min(pardesByLevel(0.10, 0.18, 0.28), pardesCap); }
+
+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 uv = fragCoord / iResolution.xy;
+ vec4 frame = texture(iChannel0, uv);
+ if (pardesSpared(fragCoord)) {
+ fragColor = frame;
+ return;
+ }
+ // 0 at the middle, 1 at a corner, on a circle's measure.
+ vec2 half_size = 0.5 * iResolution.xy;
+ float r = length(fragCoord - half_size) / length(half_size);
+ float shade = strength() * pow(smoothstep(0.5, 1.0, r), 1.5);
+ vec3 srgb = toSrgb(toLinear(frame.rgb) * (1.0 - shade));
+ srgb += (ign(fragCoord) - 0.5) / 255.0 * step(1e-4, shade);
+ fragColor = vec4(clamp(srgb, 0.0, 1.0), frame.a);
+}
diff --git a/shaders/prebuilt/bloom-down.frag.glsl b/shaders/prebuilt/bloom-down.frag.glsl
new file mode 100644
index 00000000..3465926d
--- /dev/null
+++ b/shaders/prebuilt/bloom-down.frag.glsl
@@ -0,0 +1,39 @@
+#version 450
+
+// Bloom, down (docs/effects.md G4): one step of a dual Kawase filter, a
+// source half as big again sampled at five points, the first step over the
+// frame through a bright pass in linear light. Written to a float target.
+
+layout(set = 2, binding = 0) uniform sampler2D source;
+layout(std140, set = 3, binding = 0) uniform BloomStep {
+ // xy: a source texel; zw: a target texel.
+ vec4 texels;
+ // x: the bright pass's threshold on a channel's linear peak (above the
+ // page's own), 0 for none (a later step); y: its soft knee.
+ vec4 pass;
+} u;
+
+layout(location = 0) out vec4 o_col;
+
+vec3 toLinear(vec3 c) {
+ return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045)));
+}
+
+vec3 tap(vec2 uv) {
+ vec3 c = texture(source, uv).rgb;
+ if (u.pass.x <= 0.0) return c;
+ c = toLinear(c);
+ float peak = max(c.r, max(c.g, c.b));
+ return c * smoothstep(u.pass.x, u.pass.x + u.pass.y, peak);
+}
+
+void main() {
+ vec2 uv = gl_FragCoord.xy * u.texels.zw;
+ vec2 h = 0.5 * u.texels.xy;
+ vec3 sum = tap(uv) * 4.0;
+ sum += tap(uv - h);
+ sum += tap(uv + h);
+ sum += tap(uv + vec2(h.x, -h.y));
+ sum += tap(uv - vec2(h.x, -h.y));
+ o_col = vec4(sum / 8.0, 1.0);
+}
diff --git a/shaders/prebuilt/bloom-down.frag.spv b/shaders/prebuilt/bloom-down.frag.spv
new file mode 100644
index 00000000..5ca327fe
--- /dev/null
+++ b/shaders/prebuilt/bloom-down.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/bloom-up.frag.glsl b/shaders/prebuilt/bloom-up.frag.glsl
new file mode 100644
index 00000000..368076ed
--- /dev/null
+++ b/shaders/prebuilt/bloom-up.frag.glsl
@@ -0,0 +1,26 @@
+#version 450
+
+// Bloom, up (docs/effects.md G4): one step of a dual Kawase filter back up,
+// a tent of eight points over the smaller level. Float target.
+
+layout(set = 2, binding = 0) uniform sampler2D source;
+layout(std140, set = 3, binding = 0) uniform BloomStep {
+ vec4 texels;
+ vec4 pass;
+} u;
+
+layout(location = 0) out vec4 o_col;
+
+void main() {
+ vec2 uv = gl_FragCoord.xy * u.texels.zw;
+ vec2 h = 0.5 * u.texels.xy;
+ vec3 sum = texture(source, uv + vec2(-2.0 * h.x, 0.0)).rgb;
+ sum += texture(source, uv + vec2(-h.x, h.y)).rgb * 2.0;
+ sum += texture(source, uv + vec2(0.0, 2.0 * h.y)).rgb;
+ sum += texture(source, uv + vec2(h.x, h.y)).rgb * 2.0;
+ sum += texture(source, uv + vec2(2.0 * h.x, 0.0)).rgb;
+ sum += texture(source, uv + vec2(h.x, -h.y)).rgb * 2.0;
+ sum += texture(source, uv + vec2(0.0, -2.0 * h.y)).rgb;
+ sum += texture(source, uv + vec2(-h.x, -h.y)).rgb * 2.0;
+ o_col = vec4(sum / 12.0, 1.0);
+}
diff --git a/shaders/prebuilt/bloom-up.frag.spv b/shaders/prebuilt/bloom-up.frag.spv
new file mode 100644
index 00000000..b2c64b80
--- /dev/null
+++ b/shaders/prebuilt/bloom-up.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/post-bloom.frag.glsl b/shaders/prebuilt/post-bloom.frag.glsl
new file mode 100644
index 00000000..a84cf88a
--- /dev/null
+++ b/shaders/prebuilt/post-bloom.frag.glsl
@@ -0,0 +1,141 @@
+#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;
+};
+// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a
+// uniform block (Vulkan), and the palette alone is 4 KiB. The names and
+// their order are ghostty's; each block is a slice of the same memory.
+layout(set = 3, binding = 1, std140) uniform Palette {
+ uniform vec3 iPalette[256];
+};
+layout(set = 3, binding = 2, std140) uniform Colors {
+ 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; pardesScale
+// is the display's density (2 on a 2x screen), for a pass whose pattern is
+// in logical pixels. iResolution, fragCoord and iMouse stay in the display's
+// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on
+// this theme and still leave its text, and its selection's, at their own
+// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount
+// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves
+// alone: every tag, grip and notice, and the cursor, so no pass ever touches
+// a focus indicator.
+layout(set = 3, binding = 3, std140) uniform PardesPost {
+ float pardesOpaque;
+ float pardesLevel;
+ float pardesScale;
+ float pardesCap;
+ float pardesSpareCount;
+ vec4 pardesSpare[32];
+};
+
+bool pardesSpared(vec2 p) {
+ for (int i = 0; i < int(pardesSpareCount); i++) {
+ vec4 r = pardesSpare[i];
+ if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true;
+ }
+ return false;
+}
+
+// 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
+// Bloom: the brightest ink glows, a little. The glow itself is made by the
+// shell at half resolution and below (bloom-down, bloom-up: a dual Kawase
+// filter over a bright pass, in linear light) and arrives here in
+// pardesBloom; this pass adds it to the frame. Only true highlights pass
+// the threshold, and the strength stays in §8.2's budget (at most 5%) and
+// under pardesCap, so no text or selection loses contrast below its own or
+// 4.5. Tags, grips, notices and the cursor are left alone (pardesSpare).
+// It is a Shadertoy file: pardes puts its prefix before it.
+
+layout(set = 2, binding = 1) uniform sampler2D pardesBloom;
+
+float strength() { return min(pardesByLevel(0.02, 0.035, 0.05), pardesCap); }
+
+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 uv = fragCoord / iResolution.xy;
+ vec4 frame = texture(iChannel0, uv);
+ if (pardesSpared(fragCoord)) {
+ fragColor = frame;
+ return;
+ }
+ vec3 glow = texture(pardesBloom, uv).rgb;
+ vec3 col = toLinear(frame.rgb) + glow * strength();
+ vec3 srgb = toSrgb(col);
+ // Dithered: a glow's long falloff never bands.
+ srgb += (ign(fragCoord) - 0.5) / 255.0 * step(1e-4, dot(glow, vec3(1.0)));
+ fragColor = vec4(clamp(srgb, 0.0, 1.0), frame.a);
+}
diff --git a/shaders/prebuilt/post-bloom.frag.spv b/shaders/prebuilt/post-bloom.frag.spv
new file mode 100644
index 00000000..7b71dbdd
--- /dev/null
+++ b/shaders/prebuilt/post-bloom.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/post-crt.frag.glsl b/shaders/prebuilt/post-crt.frag.glsl
index 7d2266c0..ca44d014 100644
--- a/shaders/prebuilt/post-crt.frag.glsl
+++ b/shaders/prebuilt/post-crt.frag.glsl
@@ -30,7 +30,14 @@ layout(set = 3, binding = 0, std140) uniform Globals {
uniform float iTimeCursorChange;
uniform float iTimeFocus;
uniform int iFocus;
+};
+// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a
+// uniform block (Vulkan), and the palette alone is 4 KiB. The names and
+// their order are ghostty's; each block is a slice of the same memory.
+layout(set = 3, binding = 1, std140) uniform Palette {
uniform vec3 iPalette[256];
+};
+layout(set = 3, binding = 2, std140) uniform Colors {
uniform vec3 iBackgroundColor;
uniform vec3 iForegroundColor;
uniform vec3 iCursorColor;
@@ -44,13 +51,29 @@ layout(set = 3, binding = 0, std140) uniform Globals {
// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file; pardesScale
// is the display's density (2 on a 2x screen), for a pass whose pattern is
// in logical pixels. iResolution, fragCoord and iMouse stay in the display's
-// pixels, as ghostty's do.
-layout(set = 3, binding = 1, std140) uniform PardesPost {
+// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on
+// this theme and still leave its text, and its selection's, at their own
+// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount
+// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves
+// alone: every tag, grip and notice, and the cursor, so no pass ever touches
+// a focus indicator.
+layout(set = 3, binding = 3, std140) uniform PardesPost {
float pardesOpaque;
float pardesLevel;
float pardesScale;
+ float pardesCap;
+ float pardesSpareCount;
+ vec4 pardesSpare[32];
};
+bool pardesSpared(vec2 p) {
+ for (int i = 0; i < int(pardesSpareCount); i++) {
+ vec4 r = pardesSpare[i];
+ if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true;
+ }
+ return false;
+}
+
// 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);
diff --git a/shaders/prebuilt/post-crt.frag.spv b/shaders/prebuilt/post-crt.frag.spv
index d6cc6c37..fe2c3edb 100644
--- a/shaders/prebuilt/post-crt.frag.spv
+++ b/shaders/prebuilt/post-crt.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/post-grain.frag.glsl b/shaders/prebuilt/post-grain.frag.glsl
new file mode 100644
index 00000000..a4a02e99
--- /dev/null
+++ b/shaders/prebuilt/post-grain.frag.glsl
@@ -0,0 +1,133 @@
+#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;
+};
+// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a
+// uniform block (Vulkan), and the palette alone is 4 KiB. The names and
+// their order are ghostty's; each block is a slice of the same memory.
+layout(set = 3, binding = 1, std140) uniform Palette {
+ uniform vec3 iPalette[256];
+};
+layout(set = 3, binding = 2, std140) uniform Colors {
+ 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; pardesScale
+// is the display's density (2 on a 2x screen), for a pass whose pattern is
+// in logical pixels. iResolution, fragCoord and iMouse stay in the display's
+// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on
+// this theme and still leave its text, and its selection's, at their own
+// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount
+// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves
+// alone: every tag, grip and notice, and the cursor, so no pass ever touches
+// a focus indicator.
+layout(set = 3, binding = 3, std140) uniform PardesPost {
+ float pardesOpaque;
+ float pardesLevel;
+ float pardesScale;
+ float pardesCap;
+ float pardesSpareCount;
+ vec4 pardesSpare[32];
+};
+
+bool pardesSpared(vec2 p) {
+ for (int i = 0; i < int(pardesSpareCount); i++) {
+ vec4 r = pardesSpare[i];
+ if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true;
+ }
+ return false;
+}
+
+// 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
+// Grain: the page's own ground takes a fine, still grain, like paper. Only
+// pixels of the page's colour (iBackgroundColor) are grained: no glyph, no
+// selection, no tag or rule, and nothing in pardesSpare. The grain averages
+// to the page, is a logical pixel big (pardesScale), and never moves, so it
+// costs no frames. At most 1.5, 3 and 5 of 255 at levels 1 to 3, and never
+// more than pardesCap, which keeps the text on it at its own contrast or 4.5.
+// It is a Shadertoy file: pardes puts its prefix before it.
+
+float amplitude() { return min(pardesByLevel(1.5, 3.0, 5.0) / 255.0, pardesCap); }
+
+// An integer hash of the grain cell: white, with no pattern to see.
+float hash(uvec2 p) {
+ uint h = p.x * 1664525u + p.y * 1013904223u;
+ h ^= h >> 16;
+ h *= 2246822519u;
+ h ^= h >> 13;
+ h *= 3266489917u;
+ h ^= h >> 16;
+ return float(h) / 4294967295.0;
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 uv = fragCoord / iResolution.xy;
+ vec4 frame = texture(iChannel0, uv);
+ fragColor = frame;
+ vec3 page = iBackgroundColor.rgb;
+ if (any(greaterThan(abs(frame.rgb - page), vec3(0.75 / 255.0))) || pardesSpared(fragCoord)) return;
+ uvec2 cell = uvec2(fragCoord / max(1.0, pardesScale));
+ // Two hashes summed: a triangular spread, softer than a flat one.
+ float n = hash(cell) + hash(cell + uvec2(7919u, 104729u)) - 1.0;
+ fragColor = vec4(clamp(page + n * amplitude(), 0.0, 1.0), frame.a);
+}
diff --git a/shaders/prebuilt/post-grain.frag.spv b/shaders/prebuilt/post-grain.frag.spv
new file mode 100644
index 00000000..e4c893af
--- /dev/null
+++ b/shaders/prebuilt/post-grain.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/post-vignette.frag.glsl b/shaders/prebuilt/post-vignette.frag.glsl
new file mode 100644
index 00000000..d5737901
--- /dev/null
+++ b/shaders/prebuilt/post-vignette.frag.glsl
@@ -0,0 +1,139 @@
+#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;
+};
+// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a
+// uniform block (Vulkan), and the palette alone is 4 KiB. The names and
+// their order are ghostty's; each block is a slice of the same memory.
+layout(set = 3, binding = 1, std140) uniform Palette {
+ uniform vec3 iPalette[256];
+};
+layout(set = 3, binding = 2, std140) uniform Colors {
+ 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; pardesScale
+// is the display's density (2 on a 2x screen), for a pass whose pattern is
+// in logical pixels. iResolution, fragCoord and iMouse stay in the display's
+// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on
+// this theme and still leave its text, and its selection's, at their own
+// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount
+// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves
+// alone: every tag, grip and notice, and the cursor, so no pass ever touches
+// a focus indicator.
+layout(set = 3, binding = 3, std140) uniform PardesPost {
+ float pardesOpaque;
+ float pardesLevel;
+ float pardesScale;
+ float pardesCap;
+ float pardesSpareCount;
+ vec4 pardesSpare[32];
+};
+
+bool pardesSpared(vec2 p) {
+ for (int i = 0; i < int(pardesSpareCount); i++) {
+ vec4 r = pardesSpare[i];
+ if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true;
+ }
+ return false;
+}
+
+// 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
+// Vignette: the window's corners fall a little into shade, in linear light,
+// so the eye settles on the middle. Round, whatever the window's shape;
+// nothing within half the way to a corner is touched. At most 28% at the
+// far corners (level 3), and never more than pardesCap, so text and its
+// selection keep their own contrast or 4.5. Tags, grips, notices and the
+// cursor are left alone (pardesSpare): the focus indicators never darken.
+// It is a Shadertoy file: pardes puts its prefix before it.
+
+float strength() { return min(pardesByLevel(0.10, 0.18, 0.28), pardesCap); }
+
+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 uv = fragCoord / iResolution.xy;
+ vec4 frame = texture(iChannel0, uv);
+ if (pardesSpared(fragCoord)) {
+ fragColor = frame;
+ return;
+ }
+ // 0 at the middle, 1 at a corner, on a circle's measure.
+ vec2 half_size = 0.5 * iResolution.xy;
+ float r = length(fragCoord - half_size) / length(half_size);
+ float shade = strength() * pow(smoothstep(0.5, 1.0, r), 1.5);
+ vec3 srgb = toSrgb(toLinear(frame.rgb) * (1.0 - shade));
+ srgb += (ign(fragCoord) - 0.5) / 255.0 * step(1e-4, shade);
+ fragColor = vec4(clamp(srgb, 0.0, 1.0), frame.a);
+}
diff --git a/shaders/prebuilt/post-vignette.frag.spv b/shaders/prebuilt/post-vignette.frag.spv
new file mode 100644
index 00000000..4dfb6125
--- /dev/null
+++ b/shaders/prebuilt/post-vignette.frag.spv
Binary files differ
diff --git a/src/config.zig b/src/config.zig
index fb38e4dd..43d2cfda 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -26,6 +26,7 @@ const undecided: []const u8 = "<undecided>";
// null means word/chord-only. Every enabled builtin must explicitly choose a path.
pub const leader_path = paths: {
+ @setEvalBranchQuota(2000);
var table = std.EnumArray(Builtin, ?[]const u8).initDefault(@as(?[]const u8, undecided), .{
.Help = &[_]u8{leader_help},
.Hover = "lk",
@@ -139,6 +140,9 @@ pub const leader_path = paths: {
}
if (builtins.capabilities.scene_shaders) {
table.set(.Crt, "tr");
+ table.set(.Bloom, null);
+ table.set(.Vignette, null);
+ table.set(.Grain, null);
table.set(.Shader, null);
table.set(.ShaderAnimation, null);
}
@@ -869,8 +873,9 @@ 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, syntax_bold, verbose, message_animation, ligatures, cursor_blink };
- /// The bundled post pass, a Shadertoy file in shaders/post/.
- pub const Scene = enum { crt };
+ /// A bundled post pass, a Shadertoy file in shaders/post/: the CRT, and
+ /// G4's bloom, vignette and grain (docs/effects.md).
+ pub const Scene = enum { crt, bloom, vignette, grain };
pub const Action = union(enum) {
toggle: Toggle,
@@ -950,6 +955,9 @@ 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 = "Bloom", .action = .{ .scene = .bloom }, .availability = .scene_shaders },
+ .{ .word = "Vignette", .action = .{ .scene = .vignette }, .availability = .scene_shaders },
+ .{ .word = "Grain", .action = .{ .scene = .grain }, .availability = .scene_shaders },
.{ .word = "Shader", .action = .shader, .availability = .scene_shaders },
.{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders },
.{ .word = "Placement", .action = .{ .choice = .placement } },
@@ -1678,6 +1686,9 @@ pub const Runtime = struct {
},
.scene => |effect| switch (effect) {
.crt => "Crt 2\n",
+ .bloom => "Bloom 2\n",
+ .vignette => "Vignette 2\n",
+ .grain => "Grain 2\n",
},
.shader => "Shader ~/crt.glsl\n",
.shader_animation => "ShaderAnimation always\n",
diff --git a/src/effect_sources.zig b/src/effect_sources.zig
index 83bd1313..328ce730 100644
--- a/src/effect_sources.zig
+++ b/src/effect_sources.zig
@@ -14,6 +14,11 @@ const image_fragment_path = shader_dir ++ "image.frag.glsl";
const post_vertex_path = shader_dir ++ "post.vert.glsl";
// The bundled post pass as compiled: the prefix and the pass together.
const post_crt_path = shader_dir ++ "post-crt.frag.glsl";
+const post_bloom_path = shader_dir ++ "post-bloom.frag.glsl";
+const post_vignette_path = shader_dir ++ "post-vignette.frag.glsl";
+const post_grain_path = shader_dir ++ "post-grain.frag.glsl";
+const bloom_down_path = shader_dir ++ "bloom-down.frag.glsl";
+const bloom_up_path = shader_dir ++ "bloom-up.frag.glsl";
pub const Backend = @TypeOf(build_config.platform);
pub const backend: Backend = build_config.platform;
@@ -53,6 +58,11 @@ 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_bloom_path, .contents = @embedFile("effect-source-post-bloom.frag.glsl") },
+ .{ .path = post_vignette_path, .contents = @embedFile("effect-source-post-vignette.frag.glsl") },
+ .{ .path = post_grain_path, .contents = @embedFile("effect-source-post-grain.frag.glsl") },
+ .{ .path = bloom_down_path, .contents = @embedFile("effect-source-bloom-down.frag.glsl") },
+ .{ .path = bloom_up_path, .contents = @embedFile("effect-source-bloom-up.frag.glsl") },
},
.macos => [_]filesystem.Source{
.{ .path = "src/macos/Sources/PardesView.swift", .contents = @embedFile("macos/Sources/PardesView.swift") },
@@ -73,6 +83,9 @@ 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 },
+ .bloom => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, bloom_down_path, bloom_up_path, post_bloom_path },
+ .vignette => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_vignette_path },
+ .grain => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_grain_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 45798f8d..61ebc2cd 100644
--- a/src/gui/Post.zig
+++ b/src/gui/Post.zig
@@ -28,7 +28,27 @@ const prefix = @embedFile("post-prefix.glsl");
const vert_spv = @embedFile("post.vert.spv");
const bundled_spv = std.EnumArray(Scene, []const u8).init(.{
.crt = @embedFile("post-crt.frag.spv"),
+ .bloom = @embedFile("post-bloom.frag.spv"),
+ .vignette = @embedFile("post-vignette.frag.spv"),
+ .grain = @embedFile("post-grain.frag.spv"),
});
+const bloom_down_spv = @embedFile("bloom-down.frag.spv");
+const bloom_up_spv = @embedFile("bloom-up.frag.spv");
+/// Bloom's steps below the frame, at levels 1 to 3: a half, a quarter, ...
+const bloom_steps = [_]u8{ 3, 4, 5 };
+const bloom_format = c.SDL_GPU_TEXTUREFORMAT_R16G16B16A16_FLOAT;
+
+/// pardes's own block (shaders/post/prefix.glsl PardesPost), std140.
+pub const Own = extern struct {
+ opaque_window: f32 = 1,
+ level: f32 = 0,
+ scale: f32 = 1,
+ cap: f32 = 0,
+ spare_count: f32 = 0,
+ _pad: [3]f32 = @splat(0),
+ spare: [max_spare][4]f32 = @splat(@splat(0)),
+};
+pub const max_spare = 32;
/// ghostty's Shadertoy uniform block, member for member in the GLSL block's
/// order and std140 layout (shaders/post/prefix.glsl). Filled by the GLSL
@@ -101,6 +121,18 @@ uniforms: Uniforms = .{},
/// The display's density, for a pass whose pattern is in logical pixels
/// (pardesScale); the shell sets it.
scale: f32 = 1,
+/// What a bundled pass may do on this theme (pardesCap), each scene's, and
+/// the rectangles every bundled pass leaves alone (pardesSpare): both from
+/// the last frame described.
+caps: std.EnumArray(Scene, f32) = .initFill(0),
+spare: [max_spare][4]f32 = @splat(@splat(0)),
+spare_len: usize = 0,
+/// Bloom's float chain, a half the frame and down, and its two steps.
+bloom_tex: [bloom_steps[bloom_steps.len - 1]]?*c.SDL_GPUTexture = @splat(null),
+bloom_w: u32 = 0,
+bloom_h: u32 = 0,
+bloom_down: ?*Pipeline = null,
+bloom_up: ?*Pipeline = null,
started_ns: ?u64 = null,
last_ns: u64 = 0,
@@ -119,7 +151,11 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUD
var next: [max]Pass = @splat(.{});
for (chain.list(), 0..) |entry, i| {
if (entry.scene) |scene| {
- if (post.bundled.get(scene) == null) post.bundled.set(scene, makePipeline(device, format, bundled_spv.get(scene)) catch null);
+ if (post.bundled.get(scene) == null) post.bundled.set(scene, makePipeline(device, format, bundled_spv.get(scene), if (scene == .bloom) 2 else 1, 4) catch null);
+ if (scene == .bloom and post.bloom_down == null) {
+ post.bloom_down = makePipeline(device, bloom_format, bloom_down_spv, 1, 1) catch null;
+ post.bloom_up = makePipeline(device, bloom_format, bloom_up_spv, 1, 1) catch null;
+ }
next[i] = .{ .scene = scene, .level = entry.level, .pipeline = post.bundled.get(scene) };
continue;
}
@@ -182,7 +218,7 @@ fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format
.spirv => |spirv| {
defer gpa.free(spirv);
const target = pass orelse continue;
- const made = makePipeline(device, format, spirv) catch {
+ const made = makePipeline(device, format, spirv, 1, 4) catch {
core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{path}) catch "Shader: the GPU refused it");
continue;
};
@@ -266,10 +302,10 @@ fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8) Job.Result {
return .{ .failed = named };
}
-fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8) !*Pipeline {
+fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8, samplers: u32, uniform_buffers: u32) !*Pipeline {
const vs = try gui.makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0);
defer c.SDL_ReleaseGPUShader(device, vs);
- const fs = try gui.makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 2);
+ const fs = try gui.makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, samplers, uniform_buffers);
defer c.SDL_ReleaseGPUShader(device, fs);
var target = c.SDL_GPUColorTargetDescription{ .format = format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) };
var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
@@ -303,6 +339,9 @@ pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation)
pub const Frame = struct {
pub const Cursor = struct { x0: f32, y0: f32, x1: f32, y1: f32, rgb: [3]u8, text: [3]u8, bar: bool };
cursor: ?Cursor,
+ /// What a bundled pass leaves alone, in framebuffer pixels: the tags,
+ /// grips and notices (and the cursor, added here).
+ spare: []const [4]f32 = &.{},
background: [3]u8,
foreground: [3]u8,
selection_foreground: [3]u8,
@@ -318,6 +357,10 @@ fn rgba(rgb: [3]u8) [4]f32 {
/// colour makes the old one the previous and stamps the change.
pub fn describe(post: *Post, frame: Frame) void {
const u = &post.uniforms;
+ post.caps = capsOf(frame);
+ post.spare_len = 0;
+ for (frame.spare) |rect| post.addSpare(rect);
+ if (frame.cursor) |cursor| post.addSpare(.{ cursor.x0, cursor.y0, cursor.x1, cursor.y1 });
u.background_color = rgba(frame.background);
u.foreground_color = rgba(frame.foreground);
u.selection_foreground_color = rgba(frame.selection_foreground);
@@ -343,6 +386,102 @@ pub fn describe(post: *Post, frame: Frame) void {
u.cursor_change_time = u.time;
}
+/// A rectangle a bundled pass leaves alone; past `max_spare` the last one
+/// grows to cover the rest (sparing more, never less).
+fn addSpare(post: *Post, rect: [4]f32) void {
+ if (rect[2] <= rect[0] or rect[3] <= rect[1]) return;
+ if (post.spare_len < max_spare) {
+ post.spare[post.spare_len] = rect;
+ post.spare_len += 1;
+ return;
+ }
+ const last = &post.spare[max_spare - 1];
+ last.* = .{ @min(last[0], rect[0]), @min(last[1], rect[1]), @max(last[2], rect[2]), @max(last[3], rect[3]) };
+}
+
+/// sRGB's relative luminance (WCAG) and a pair's contrast.
+fn luminance(rgb: [3]f32) f32 {
+ var sum: f32 = 0;
+ for (rgb, [3]f32{ 0.2126, 0.7152, 0.0722 }) |v, weight|
+ sum += weight * (if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4));
+ return sum;
+}
+fn contrastOf(a: f32, b: f32) f32 {
+ return (@max(a, b) + 0.05) / (@min(a, b) + 0.05);
+}
+fn unit(rgb: [3]u8) [3]f32 {
+ return .{ @as(f32, @floatFromInt(rgb[0])) / 255, @as(f32, @floatFromInt(rgb[1])) / 255, @as(f32, @floatFromInt(rgb[2])) / 255 };
+}
+
+/// Each bundled pass's ceiling on this theme (pardesCap, docs/effects.md):
+/// the most it may do while the text on the page, and the selection's text
+/// on its own ground, keep min(their contrast, 4.5). Bloom adds light to
+/// both of a pair (linear, as a share of full white); a vignette darkens
+/// both (a share); grain moves the page alone, either way (sRGB units).
+pub fn capsOf(frame: Frame) std.EnumArray(Scene, f32) {
+ const pairs = [_][2][3]f32{ .{ unit(frame.foreground), unit(frame.background) }, .{ unit(frame.selection_foreground), unit(frame.selection_background) } };
+ var bloom: f32 = 1;
+ var shade: f32 = 1;
+ for (pairs) |pair| {
+ const a = luminance(pair[0]);
+ const b = luminance(pair[1]);
+ const light = @max(a, b);
+ const dark = @min(a, b);
+ const target = @min(contrastOf(a, b), 4.5);
+ // (light + e + .05) / (dark + e + .05) >= target
+ bloom = @min(bloom, @max(0, (light + 0.05 - target * (dark + 0.05)) / @max(target - 1, 1e-3)));
+ // (k light + .05) / (k dark + .05) >= target, darkened by 1 - k
+ const room = light - target * dark;
+ shade = @min(shade, if (room <= 0) 0 else std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1));
+ }
+ // Grain: the largest step off the page, up and down, that keeps the text
+ // on it at its contrast or 4.5 (searched in 1/1020 steps up to 8/255).
+ const ink = luminance(unit(frame.foreground));
+ const page = unit(frame.background);
+ const target = @min(contrastOf(ink, luminance(page)), 4.5);
+ var grain: f32 = 0;
+ var step: f32 = 0.25 / 255.0;
+ while (step <= 8.0 / 255.0) : (step += 0.25 / 255.0) {
+ var up = page;
+ var down = page;
+ for (&up, &down) |*u, *d| {
+ u.* = @min(1, u.* + step);
+ d.* = @max(0, d.* - step);
+ }
+ if (contrastOf(ink, luminance(up)) < target - 1e-4 or contrastOf(ink, luminance(down)) < target - 1e-4) break;
+ grain = step;
+ }
+ var caps: std.EnumArray(Scene, f32) = .initFill(1);
+ caps.set(.bloom, bloom);
+ caps.set(.vignette, shade);
+ caps.set(.grain, grain);
+ return caps;
+}
+
+test "no bundled pass at its ceiling takes a native theme's text or selection below its contrast or 4.5" {
+ for (pardes.themes[0..pardes.colors.native_count]) |*theme| {
+ const frame: Frame = .{ .cursor = null, .background = theme.bg orelse .{ 18, 18, 18 }, .foreground = theme.fg orelse .{ 204, 204, 204 }, .selection_foreground = theme.sel_fg, .selection_background = theme.sel_bg };
+ const caps = capsOf(frame);
+ for ([_][2][3]u8{ .{ frame.foreground, frame.background }, .{ frame.selection_foreground, frame.selection_background } }, 0..) |pair, i| {
+ const a = luminance(unit(pair[0]));
+ const b = luminance(unit(pair[1]));
+ const target = @min(contrastOf(a, b), 4.5) - 1e-3;
+ // Bloom at its ceiling, over both.
+ const e = @min(caps.get(.bloom), 0.05);
+ try std.testing.expect(contrastOf(a + e, b + e) >= target);
+ // The vignette at its ceiling, over both.
+ const k = 1 - @min(caps.get(.vignette), 0.28);
+ try std.testing.expect(contrastOf(a * k, b * k) >= target);
+ // Grain moves the page alone.
+ if (i == 0) {
+ var up = unit(pair[1]);
+ for (&up) |*v| v.* = @min(1, v.* + caps.get(.grain));
+ try std.testing.expect(contrastOf(a, luminance(up)) >= target);
+ }
+ }
+ }
+}
+
/// The window gained or lost the focus.
pub fn focus(post: *Post, focused: bool) void {
if (focused and post.uniforms.focus == 0) post.uniforms.time_focus = post.uniforms.time;
@@ -420,13 +559,25 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer,
info.texture = output;
info.load_op = if (last) c.SDL_GPU_LOADOP_DONT_CARE else c.SDL_GPU_LOADOP_CLEAR;
info.store_op = c.SDL_GPU_STOREOP_STORE;
+ // Bloom's glow first, from this pass's input, at half size and down.
+ // A chain that could not be made adds no glow, and the pass is the
+ // frame as it was.
+ const glow = if (pass.scene == .bloom) post.bloomChain(device, cmd, sampler, input, w, h, pass.level) catch null else null;
const pass_rp = c.SDL_BeginGPURenderPass(cmd, &info, 1, null);
c.SDL_BindGPUGraphicsPipeline(pass_rp, pipeline);
- const binding = c.SDL_GPUTextureSamplerBinding{ .texture = input, .sampler = sampler };
- c.SDL_BindGPUFragmentSamplers(pass_rp, 0, &binding, 1);
- c.SDL_PushGPUFragmentUniformData(cmd, 0, u, @sizeOf(Uniforms));
- const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), @floatFromInt(pass.level), post.scale, 0 };
- c.SDL_PushGPUFragmentUniformData(cmd, 1, &own, @sizeOf(@TypeOf(own)));
+ const bindings = [2]c.SDL_GPUTextureSamplerBinding{ .{ .texture = input, .sampler = sampler }, .{ .texture = glow orelse input, .sampler = sampler } };
+ c.SDL_BindGPUFragmentSamplers(pass_rp, 0, &bindings, if (pass.scene == .bloom) 2 else 1);
+ pushUniforms(cmd, u);
+ var own: Own = .{
+ .opaque_window = @floatFromInt(@intFromBool(opaque_window)),
+ .level = @floatFromInt(pass.level),
+ .scale = post.scale,
+ // A user's file has no ceiling of ours, and no spared rectangles.
+ .cap = if (pass.scene) |scene| (if (scene == .bloom and glow == null) 0 else post.caps.get(scene)) else 1,
+ .spare_count = if (pass.scene != null) @floatFromInt(post.spare_len) else 0,
+ };
+ if (pass.scene != null) @memcpy(own.spare[0..post.spare_len], post.spare[0..post.spare_len]);
+ c.SDL_PushGPUFragmentUniformData(cmd, 3, &own, @sizeOf(Own));
c.SDL_DrawGPUPrimitives(pass_rp, 3, 1, 0, 0);
c.SDL_EndGPURenderPass(pass_rp);
input = output;
@@ -434,7 +585,107 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer,
u.frame +%= 1;
}
+/// ghostty's block as the prefix cuts it (SDL GPU binds at most 4 KiB of a
+/// block): the globals up to the palette, the palette, the colours after it,
+/// each a slice of the one struct, at set 3 bindings 0 to 2.
+fn pushUniforms(cmd: *c.SDL_GPUCommandBuffer, u: *const Uniforms) void {
+ const bytes = std.mem.asBytes(u);
+ const palette = @offsetOf(Uniforms, "palette");
+ const colors = @offsetOf(Uniforms, "background_color");
+ c.SDL_PushGPUFragmentUniformData(cmd, 0, bytes.ptr, @intCast(palette));
+ c.SDL_PushGPUFragmentUniformData(cmd, 1, bytes.ptr + palette, @intCast(colors - palette));
+ c.SDL_PushGPUFragmentUniformData(cmd, 2, bytes.ptr + colors, @intCast(@sizeOf(Uniforms) - colors));
+}
+
+test "each slice of the uniforms fits the 4 KiB SDL GPU binds of a block" {
+ try std.testing.expect(@offsetOf(Uniforms, "palette") <= 4096);
+ try std.testing.expect(@offsetOf(Uniforms, "background_color") - @offsetOf(Uniforms, "palette") <= 4096);
+ try std.testing.expect(@sizeOf(Uniforms) - @offsetOf(Uniforms, "background_color") <= 4096);
+ try std.testing.expect(@sizeOf(Own) <= 4096);
+}
+
+/// Bloom's glow of `input` (w x h): a bright pass and dual Kawase steps
+/// down to 1/2^n of the frame and back up to a half, in float textures;
+/// the half-size result, for the composite (shaders/post/bloom.glsl).
+fn bloomChain(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, sampler: *c.SDL_GPUSampler, input: *c.SDL_GPUTexture, w: u32, h: u32, level: u8) !*c.SDL_GPUTexture {
+ const down = post.bloom_down orelse return error.GpuCreate;
+ const up = post.bloom_up orelse return error.GpuCreate;
+ const steps = bloom_steps[std.math.clamp(level, 1, 3) - 1];
+ if (post.bloom_tex[0] == null or post.bloom_w != w or post.bloom_h != h) {
+ for (&post.bloom_tex, 0..) |*texture, i| {
+ if (texture.*) |old| c.SDL_ReleaseGPUTexture(device, old);
+ var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
+ info.type = c.SDL_GPU_TEXTURETYPE_2D;
+ info.format = bloom_format;
+ info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER;
+ info.width = @max(1, w >> @intCast(i + 1));
+ info.height = @max(1, h >> @intCast(i + 1));
+ info.layer_count_or_depth = 1;
+ info.num_levels = 1;
+ info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ texture.* = c.SDL_CreateGPUTexture(device, &info) orelse return error.GpuCreate;
+ }
+ post.bloom_w = w;
+ post.bloom_h = h;
+ }
+ const Step = extern struct { texels: [4]f32, pass: [4]f32 };
+ const size = struct {
+ fn of(full: u32, i: usize) f32 {
+ return @floatFromInt(if (i == 0) full else @max(1, full >> @intCast(i)));
+ }
+ };
+ // Down: the frame (0) to 1, 1 to 2, ...; the first through the bright
+ // pass (a channel's linear peak over 0.6 to 0.75, by level).
+ const threshold: f32 = switch (std.math.clamp(level, 1, 3)) {
+ 1 => 0.75,
+ 2 => 0.7,
+ else => 0.6,
+ };
+ // Only what is brighter than the page glows: on a light page, where the
+ // ground itself would pass, nothing does.
+ const page_peak = peak: {
+ var peak: f32 = 0;
+ for (post.uniforms.background_color[0..3]) |v| peak = @max(peak, if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4));
+ break :peak peak;
+ };
+ for (0..steps) |i| {
+ const from = if (i == 0) input else post.bloom_tex[i - 1].?;
+ const to = post.bloom_tex[i].?;
+ const params: Step = .{
+ .texels = .{ 1 / size.of(w, i), 1 / size.of(h, i), 1 / size.of(w, i + 1), 1 / size.of(h, i + 1) },
+ .pass = .{ if (i == 0) @max(threshold, page_peak + 0.15) else 0, 0.25, 0, 0 },
+ };
+ bloomStep(cmd, down, sampler, from, to, &params, @sizeOf(Step));
+ }
+ // Up: each level into the one above it, back to the half.
+ var i: usize = steps - 1;
+ while (i > 0) : (i -= 1) {
+ const params: Step = .{
+ .texels = .{ 1 / size.of(w, i + 1), 1 / size.of(h, i + 1), 1 / size.of(w, i), 1 / size.of(h, i) },
+ .pass = @splat(0),
+ };
+ bloomStep(cmd, up, sampler, post.bloom_tex[i].?, post.bloom_tex[i - 1].?, &params, @sizeOf(Step));
+ }
+ return post.bloom_tex[0].?;
+}
+
+fn bloomStep(cmd: *c.SDL_GPUCommandBuffer, pipeline: *Pipeline, sampler: *c.SDL_GPUSampler, from: *c.SDL_GPUTexture, to: *c.SDL_GPUTexture, params: *const anyopaque, len: u32) void {
+ var info = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
+ info.texture = to;
+ info.load_op = c.SDL_GPU_LOADOP_DONT_CARE;
+ info.store_op = c.SDL_GPU_STOREOP_STORE;
+ const rp = c.SDL_BeginGPURenderPass(cmd, &info, 1, null);
+ c.SDL_BindGPUGraphicsPipeline(rp, pipeline);
+ const binding = c.SDL_GPUTextureSamplerBinding{ .texture = from, .sampler = sampler };
+ c.SDL_BindGPUFragmentSamplers(rp, 0, &binding, 1);
+ c.SDL_PushGPUFragmentUniformData(cmd, 0, params, len);
+ c.SDL_DrawGPUPrimitives(rp, 3, 1, 0, 0);
+ c.SDL_EndGPURenderPass(rp);
+}
+
pub fn deinit(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice) void {
+ for (post.bloom_tex) |texture| if (texture) |t| c.SDL_ReleaseGPUTexture(device, t);
+ for ([_]?*Pipeline{ post.bloom_down, post.bloom_up }) |pipeline| if (pipeline) |p| c.SDL_ReleaseGPUGraphicsPipeline(device, p);
if (post.job) |job| {
job.thread.join();
for (job.paths[0..job.len], job.results[0..job.len]) |path, result| {
@@ -511,16 +762,22 @@ test "the uniform block is ghostty's, offset for offset, and the prefix names it
.{ "vec3", "iCursorText" }, .{ "vec3", "iSelectionForegroundColor" }, .{ "vec3", "iSelectionBackgroundColor" },
};
try std.testing.expectEqual(ours.len, glsl.len);
- const block = prefix[std.mem.indexOf(u8, prefix, "uniform Globals {").?..];
- var lines = std.mem.tokenizeScalar(u8, block[0..std.mem.indexOfScalar(u8, block, '}').?], '\n');
- _ = lines.next();
- for (glsl) |member| {
- var words = std.mem.tokenizeAny(u8, lines.next().?, " ;");
+ // Its three blocks (Globals, Palette, Colors) read as one, in order.
+ const start = std.mem.indexOf(u8, prefix, "uniform Globals {").?;
+ const end = std.mem.indexOf(u8, prefix, "uniform PardesPost {").?;
+ var lines = std.mem.tokenizeScalar(u8, prefix[start..end], '\n');
+ var n: usize = 0;
+ while (lines.next()) |line| {
+ if (!std.mem.startsWith(u8, line, " uniform ")) continue;
+ var words = std.mem.tokenizeAny(u8, line, " ;");
try std.testing.expectEqualStrings("uniform", words.next().?);
- try std.testing.expectEqualStrings(member[0], words.next().?);
- try std.testing.expectEqualStrings(member[1], words.next().?);
+ try std.testing.expectEqualStrings(glsl[n][0], words.next().?);
+ try std.testing.expectEqualStrings(glsl[n][1], words.next().?);
+ n += 1;
}
- try std.testing.expect(lines.next() == null);
+ try std.testing.expectEqual(glsl.len, n);
+ // Each block starts where its first member is in the one struct.
+ try std.testing.expect(std.mem.indexOf(u8, prefix, "uniform Palette {\n uniform vec3 iPalette[256];\n};") != null);
}
test "a moved cursor becomes the previous one, stamped with the time of the move" {
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index fdcd7b2a..bde9aecd 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -5732,8 +5732,10 @@ fn renderFrame(
if (core) |p| {
var boxes: [max_cursors]CursorBox = undefined;
const cursors = if (cursors_shown) cursorBoxes(g, surface, layout, win_h, &boxes) else 0;
+ var spare: [Post.max_spare][4]f32 = undefined;
g.post.describe(.{
.cursor = if (cursors > 0) boxes[0] else null,
+ .spare = spare[0..postSpare(surface, layout, &spare)],
.background = chrome.page orelse bg_default,
.foreground = chrome.fg orelse fg_default,
.selection_foreground = p.theme().sel_fg,
@@ -8594,6 +8596,46 @@ test "decor is read from the regions, each pane's in its own group" {
try std.testing.expectEqual(@as(u32, 0), groups.items[groups.ink].decor_count);
}
+/// What a bundled post pass leaves alone (Post.Frame.spare), in window
+/// pixels: every tag with its grip, the column and workspace tags, and the
+/// notices, the chrome the focus indicators live in. A grip, the gap after
+/// it and the tag beside it are one rectangle; past the list's end the last
+/// grows to cover the rest.
+fn postSpare(surface: *const pardes.Surface, layout: CellLayout, out: *[Post.max_spare][4]f32) usize {
+ var n: usize = 0;
+ for (surface.regionList()) |region| {
+ switch (region.kind) {
+ .grip, .tag, .notice, .column_grip, .column_tag, .workspace_tag => {},
+ else => continue,
+ }
+ if (region.rect.w == 0 or region.rect.h == 0) continue;
+ const rect: [4]f32 = .{
+ @as(f32, @floatFromInt(region.rect.x)) * layout.w,
+ @as(f32, @floatFromInt(region.rect.y)) * layout.h,
+ @as(f32, @floatFromInt(region.rect.x + region.rect.w)) * layout.w,
+ @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h,
+ };
+ const joined = for (out[0..n]) |*other| {
+ // The same rows, a gap cell or two apart (a grip, the gap after
+ // it, its tag): one band.
+ const gap = 2 * layout.w + 0.5;
+ if (other[1] == rect[1] and other[3] == rect[3] and rect[0] <= other[2] + gap and other[0] <= rect[2] + gap) {
+ other.* = .{ @min(other[0], rect[0]), rect[1], @max(other[2], rect[2]), rect[3] };
+ break true;
+ }
+ } else false;
+ if (joined) continue;
+ if (n == out.len) {
+ const last = &out[n - 1];
+ last.* = .{ @min(last[0], rect[0]), @min(last[1], rect[1]), @max(last[2], rect[2]), @max(last[3], rect[3]) };
+ continue;
+ }
+ out[n] = rect;
+ n += 1;
+ }
+ return n;
+}
+
const CursorBox = Post.Frame.Cursor;
const max_cursors = 1 + pardes.MAX_PANES + pardes.MAX_TAG_LAYERS;