summaryrefslogtreecommitdiff
path: root/shaders
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 05:44:11 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit4452a8f467765cbfa0dd0444a9bf416ac8bd0bf5 (patch)
tree0806bb947d2a0d17dcb784d9fc017d66859186db /shaders
parentfde030a699b3d6667d1fa21ae192b5011e3f373f (diff)
downloadpardes-4452a8f467765cbfa0dd0444a9bf416ac8bd0bf5.tar.gz
pardes-4452a8f467765cbfa0dd0444a9bf416ac8bd0bf5.zip
G4: bloom, vignette and grain in the post chain
Three bundled Shadertoy passes beside Crt, each `Bloom|Vignette|Grain 0..3` (off by default, nothing drawn while off), in shaders/post/: - Bloom: what is brighter than the page (a channel's linear peak over 0.75/0.7/0.6 and over the page's own + 0.15, so a light page does not bloom) glows, 2/3.5/5% (§8.2's budget). The glow is a dual Kawase blur in RGBA16F textures at half size and down, 3/4/5 steps by level (shaders/bloom-down, bloom-up; Post.bloomChain), composited in linear light and dithered. - Vignette: round, from halfway to a corner, 10/18/28% at the far corners, in linear light, dithered. - Grain: a still, triangular-spread grain of 1.5/3/5 of 255 on page-coloured pixels only, a logical pixel big; still, so it asks for no frames. Focus rule: the shell passes every bundled pass the rectangles of the tags with their grips, the column and workspace tags, the notices and the cursor (pardesSpare, at most 32, neighbours on one row merged), and a bundled pass leaves them as they are; and a per-theme ceiling (pardesCap, Post.capsOf) keeps the page's text and the selection's text at min(their contrast, 4.5) under each pass at its strongest. User files get no ceiling and no spared rectangles. Fix found on the way: SDL GPU binds at most 4 KiB of a uniform block, so every Shadertoy colour after iPalette (iBackgroundColor, iForegroundColor, the cursor and selection colours, iPalette[>=4]) read zero. The prefix's block is cut in three (Globals, Palette, Colors) with ghostty's names and order, each pushed as a slice of the one struct; pardes's own block moves to binding 3. Tests: the caps hold over every native theme; each uniform slice fits 4 KiB; the prefix's members in ghostty's order across its blocks. Gates: tty/gui unit-test and core-test pass; snap fails only nested-optout; GUI goldens 01-08 byte-identical (09-18 wait on wkzlsqvy, as in somtrmsz); web builds. Feel review material: .scratch/render/{bloom,vignette,grain}/ (levels on forge, dusk, acme, crops, a levels mp4).
Diffstat (limited to 'shaders')
-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
18 files changed, 703 insertions, 4 deletions
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