ghostty glsl

This commit is contained in:
elwrcl committed 2026-09-03 02:20:59 +03:00
1 parent de99052d95
commit beebcce70c
7 files changed
+568 -1

No files matched your search

+3 -1
View File
@@ -21,4 +21,6 @@
!*.md
!*.png
!*.yml
!*.json
!*.json
!*.glsl
!*.py
+2
View File
@@ -28,6 +28,8 @@ in
imports = [
config.flake.homeModules.home-base
config.flake.homeModules.home-ghostty
config.flake.homeModules.home-ghostty-shaders
config.flake.homeModules.home-degauss-sound
config.flake.homeModules.home-nushell
config.flake.homeModules.home-nushell-bash-compat
config.flake.homeModules.home-nushell-completions
+173
View File
@@ -0,0 +1,173 @@
///cursor-smear.glsl
const float DURATION = 0.45;
const float TRAIL_OPACITY = 0.65;
void processEdge(vec2 p, vec2 a, vec2 b, inout float minDist, inout float inside) {
vec2 edge = b - a;
vec2 pa = p - a;
float lenSq = dot(edge, edge);
float invLenSq = 1.0 / lenSq;
float t = clamp(dot(pa, edge) * invLenSq, 0.0, 1.0);
vec2 diff = pa - edge * t;
minDist = min(minDist, dot(diff, diff));
float crossZ = edge.x * pa.y - edge.y * pa.x;
inside = min(inside, step(0.0, crossZ));
}
// signed distance field for hexagon (negative inside, positive outside)
float sdHexagon(in vec2 p, in vec2 v0, in vec2 v1, in vec2 v2, in vec2 v3, in vec2 v4, in vec2 v5) {
float minDist = 1e20;
float inside = 1.0;
processEdge(p, v0, v1, minDist, inside);
processEdge(p, v1, v2, minDist, inside);
processEdge(p, v2, v3, minDist, inside);
processEdge(p, v3, v4, minDist, inside);
processEdge(p, v4, v5, minDist, inside);
processEdge(p, v5, v0, minDist, inside);
float dist = sqrt(max(minDist, 0.0));
return mix(dist, -dist, inside);
}
// signed distance field for rectangle (negative inside, positive outside)
float sdRectangle(in vec2 p, in vec2 center, in vec2 halfSize) {
vec2 d = abs(p - center) - halfSize;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
// represents cursor as a quad with four corners
struct Quad {
vec2 topLeft;
vec2 topRight;
vec2 bottomLeft;
vec2 bottomRight;
};
// construct quad from top-left position and size
Quad getQuad(vec2 pos, vec2 size) {
Quad q;
q.topLeft = pos;
q.topRight = pos + vec2(size.x, 0.0);
q.bottomLeft = pos - vec2(0.0, size.y);
q.bottomRight = pos + vec2(size.x, -size.y);
return q;
}
// selecting 3 corners from quad based on movement direction
// sel.x: 0=left, 1=right | sel.y: 0=top, 1=bottom
void selectTrailCorners(Quad q, vec2 sel, out vec2 p1, out vec2 p2, out vec2 p3) {
p1 = mix(mix(q.topRight, q.topLeft, sel.x),
mix(q.bottomRight, q.bottomLeft, sel.x),
sel.y);
p2 = mix(mix(q.topLeft, q.bottomLeft, sel.x),
mix(q.topRight, q.bottomRight, sel.x),
sel.y);
p3 = mix(mix(q.bottomRight, q.topRight, sel.x),
mix(q.bottomLeft, q.topLeft, sel.x),
sel.y);
}
// select 4 corners from quad based on movement direction
void selectCorners(Quad q, vec2 sel, out vec2 p1, out vec2 p2, out vec2 p3, out vec2 p4) {
selectTrailCorners(q, sel, p1, p2, p3);
p4 = mix(mix(q.bottomLeft, q.bottomRight, sel.x),
mix(q.topLeft, q.topRight, sel.x),
sel.y);
}
// cubic ease-out function for smooth animation (expects clamped input)
float easeClamped(float x) {
float t = 1.0 - x;
return 1.0 - t * t * t;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec4 background = texture(iChannel0, uv);
fragColor = background;
// an unfocused split still reports cursor changes, and its frames arrive far
// apart, so a trail there is both wrong and jumpy.
if (iFocus == 0) {
return;
}
// calculate animation progress with easing
float baseProgress = clamp((iTime - iTimeCursorChange) / DURATION, 0.0, 1.0);
// skip further work when animation is complete
if (baseProgress >= 1.0) {
return;
}
// precompute reused values
float invResY = 1.0 / iResolution.y;
float scale = 2.0 * invResY;
float aaWidth = scale;
vec2 normOffset = iResolution.xy * invResY;
// normalize cursor positions and sizes to screen-independent coordinates
vec2 currentPos = iCurrentCursor.xy * scale - normOffset;
vec2 previousPos = iPreviousCursor.xy * scale - normOffset;
vec2 currentSize = iCurrentCursor.zw * scale;
vec2 previousSize = iPreviousCursor.zw * scale;
// determine movement direction and construct cursor quads
vec2 deltaPos = currentPos - previousPos;
Quad currentCursor = getQuad(currentPos, currentSize);
Quad previousCursor = getQuad(previousPos, previousSize);
vec2 selector = step(vec2(0.0), deltaPos);
// select corners based on movement direction
vec2 currP1, currP2, currP3, currP4;
vec2 prevP1, prevP2, prevP3;
selectCorners(currentCursor, selector, currP1, currP2, currP3, currP4);
selectTrailCorners(previousCursor, selector, prevP1, prevP2, prevP3);
float easedProgress = easeClamped(baseProgress);
float stretchedProgress = min(baseProgress * 2.0, 1.0);
float easedProgressDouble = easeClamped(stretchedProgress);
// create trailing effect by moving diagonal point slower
vec2 trailP1 = mix(prevP1, currP1, easedProgress);
vec2 trailP2 = mix(prevP2, currP2, easedProgressDouble);
vec2 trailP3 = mix(prevP3, currP3, easedProgressDouble);
// compute hexagon SDF and convert to alpha with antialiasing
vec2 normCoord = fragCoord * scale - normOffset;
float sdfHex = sdHexagon(normCoord, trailP1, trailP2, currP2, currP4, currP3, trailP3);
float alpha = 1.0 - smoothstep(-aaWidth, aaWidth, sdfHex);
// computing current cursor SDF
vec2 halfCurrentSize = currentSize * 0.5;
vec2 currentCenter = currentPos + vec2(halfCurrentSize.x, -halfCurrentSize.y);
float sdfCurrentCursor = sdRectangle(normCoord, currentCenter, halfCurrentSize);
// calculating line length (distance between cursor centers)
vec2 previousCenter = previousPos + vec2(previousSize.x * 0.5, -previousSize.y * 0.5);
// max() because a cursor change that does not move it (a recolour, say)
// gives a zero-length line, and smoothstep with equal edges is undefined.
float lineLength = max(distance(currentCenter, previousCenter), 1e-4);
// cursor already carries the palette accent, so the trail needs no color
// of its own and never fights the theme.
vec3 trailColor = iCurrentCursorColor.rgb;
// fade along the length, squared so the tail
float distFromCursor = distance(normCoord, currentCenter);
float lengthFade = 1.0 - smoothstep(0.0, lineLength, distFromCursor);
float timeFade = 1.0 - baseProgress * baseProgress;
float strength = TRAIL_OPACITY * lengthFade * lengthFade * timeFade;
// blend trail color with background
vec3 originalColor = fragColor.rgb;
fragColor.rgb = mix(fragColor.rgb, trailColor, alpha * strength);
// remove trail where it overlaps with current cursor
fragColor.rgb = mix(fragColor.rgb, originalColor, step(sdfCurrentCursor, 0.0));
}
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
degauss-sound "thwoonngg" every time.
degauss-sound # play it
degauss-sound -o out.wav # write a file instead
degauss-sound --seed 42 # reproducible, for tuning
degauss-sound --duration 1.5 # match your shader's DURATION
"""
import argparse
import array
import math
import os
import random
import subprocess
import sys
import tempfile
import wave
SR = 48000
TABLE_BITS = 12
TABLE_SIZE = 1 << TABLE_BITS
TABLE_MASK = TABLE_SIZE - 1
SINE = [math.sin(2.0 * math.pi * i / TABLE_SIZE) for i in range(TABLE_SIZE)]
def synth(duration, rng):
n = int(SR * duration)
out = array.array("d", bytes(8 * n))
# randomized tube personality
f0 = rng.uniform(46.0, 74.0)
glide = rng.uniform(3.0, 14.0)
h2 = rng.uniform(0.30, 0.60)
h3 = rng.uniform(0.12, 0.32)
h4 = rng.uniform(0.00, 0.09)
h5 = rng.uniform(0.03, 0.15)
warble_hz = rng.uniform(5.5, 10.5)
warble_depth = rng.uniform(0.28, 0.55)
wander_hz = rng.uniform(1.3, 3.4)
wander_depth = rng.uniform(0.4, 1.1)
decay = rng.uniform(2.1, 3.4)
rattle_amt = rng.uniform(0.025, 0.095)
rattle_decay = rng.uniform(6.0, 14.0)
thump_f = rng.uniform(33.0, 54.0)
thump_amt = rng.uniform(0.30, 0.70)
thump_decay = rng.uniform(11.0, 22.0)
second_kick = rng.random() < 0.35
kick_at = rng.uniform(0.22, 0.45)
kick_amt = rng.uniform(0.25, 0.55)
# phase accumulators
scale = TABLE_SIZE / SR
ph = 0.0
ph_warble = 0.0
ph_wander = 0.0
ph_thump = 0.0
dt = 1.0 / SR
inv_dur = 1.0 / duration
peak = 0.0
for i in range(n):
t = i * dt
k = t * inv_dur
# coil tone, gliding down
f = f0 - glide * k
ph += f * scale
p = int(ph)
tone = SINE[p & TABLE_MASK]
tone += h2 * SINE[(p << 1) & TABLE_MASK]
tone += h3 * SINE[(p * 3) & TABLE_MASK]
tone += h4 * SINE[(p << 2) & TABLE_MASK]
tone += h5 * SINE[(p * 5) & TABLE_MASK]
# warble, itself slowly wandering
ph_wander += wander_hz * scale
ph_warble += (warble_hz + wander_depth * SINE[int(ph_wander) & TABLE_MASK]) * scale
warble = 1.0 - warble_depth + warble_depth * SINE[int(ph_warble) & TABLE_MASK]
# envelope: near-instant attack, exponential decay
env = math.exp(-decay * t) * (1.0 - math.exp(-220.0 * t))
if second_kick:
d = t - kick_at
if d > 0.0:
env += kick_amt * math.exp(-decay * 1.6 * d) * (1.0 - math.exp(-160.0 * d))
# opening clunk
ph_thump += thump_f * scale
thump = thump_amt * SINE[int(ph_thump) & TABLE_MASK] * math.exp(-thump_decay * t)
# shadow-mask rattle
rattle = rattle_amt * (rng.random() * 2.0 - 1.0) * math.exp(-rattle_decay * t)
s = tone * warble * env + thump + rattle
out[i] = s
a = s if s >= 0.0 else -s
if a > peak:
peak = a
# normalize, then fade the tail so it never clicks
gain = 0.85 / peak if peak > 0.0 else 0.0
fade = int(SR * 0.04)
pcm = array.array("h", bytes(2 * n))
for i in range(n):
s = out[i] * gain
if i >= n - fade:
s *= (n - i) / fade
v = int(s * 32767.0)
pcm[i] = 32767 if v > 32767 else (-32768 if v < -32768 else v)
return pcm
def write_wav(path, pcm):
with wave.open(path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SR)
w.writeframes(pcm.tobytes())
def play(path, volume):
for cmd in (["pw-play", "--volume=%s" % volume, path],
["paplay", path],
["aplay", "-q", path]):
try:
return subprocess.run(cmd, stderr=subprocess.DEVNULL).returncode
except FileNotFoundError:
continue
print("degauss-sound: no player found (pw-play/paplay/aplay)", file=sys.stderr)
return 1
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-o", "--out", help="write a wav here instead of playing")
ap.add_argument("-d", "--duration", type=float, default=1.5,
help="seconds; match your shader's DURATION (default 1.5)")
ap.add_argument("-s", "--seed", type=int, help="reproducible output")
ap.add_argument("-v", "--volume", default="0.5", help="pw-play volume")
args = ap.parse_args()
rng = random.Random(args.seed)
pcm = synth(args.duration, rng)
if args.out:
write_wav(args.out, pcm)
return 0
tmpdir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir()
fd, path = tempfile.mkstemp(suffix=".wav", dir=tmpdir)
os.close(fd)
try:
write_wav(path, pcm)
return play(path, args.volume)
finally:
os.unlink(path)
if __name__ == "__main__":
sys.exit(main())
+107
View File
@@ -0,0 +1,107 @@
///degauss.glsl
const float BOOT_DELAY = 0.45;
// How hard the coil hits.
const float WOBBLE_AMP = 0.055;
const float WAVE_FREQ = 9.0;
const float WAVE_SPEED = 3.2;
const float WAVE_GLIDE = 0.35;
const float WARBLE_HZ = 7.0;
const float WARBLE_DEPTH = 0.25;
const float CHROMA_AMP = 0.016;
const float FLASH_AMP = 0.55;
const float JITTER_AMP = 0.006;
const float SQUEEZE_AMP = 0.075;
const float BAND_SWEEPS = 2.0;
const bool JUMP_DEGAUSS = false;
const float CURSOR_JUMP = 0.60;
#define TAU 6.28318530718
float envelope(float t) {
if (t <= 0.0 || t >= DURATION) return 0.0;
float k = t / DURATION;
float attack = 1.0 - exp(-45.0 * t);
float ring = exp(-2.3 * k);
float tail = 1.0 - k * k * k * k;
return attack * ring * tail;
}
float coilPhase(float t) {
float k = clamp(t / DURATION, 0.0, 1.0);
return WAVE_SPEED * t * (1.0 - 0.5 * WAVE_GLIDE * k);
}
float hash11(float x) {
return fract(sin(x * 78.233) * 43758.5453);
}
vec4 sampleTerm(vec2 uv) {
vec2 halfTexel = 0.5 / iResolution.xy;
return texture(iChannel0, clamp(uv, halfTexel, 1.0 - halfTexel));
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
float amp = 0.0;
float t = 0.0;
float boot = iTime - BOOT_DELAY;
float bootAmp = envelope(boot);
if (bootAmp > amp) { amp = bootAmp; t = boot; }
if (JUMP_DEGAUSS) {
float jump = abs(iCurrentCursor.y - iPreviousCursor.y);
float elapsed = iTime - iTimeCursorChange;
if (jump > CURSOR_JUMP * iResolution.y) {
float e = envelope(elapsed);
if (e > amp) { amp = e; t = elapsed; }
}
}
if (amp <= 0.0001) {
fragColor = texture(iChannel0, uv);
return;
}
// coil's own tremble, so the picture pulses along with the warble in the
// sound instead of sliding smoothly under it.
amp *= 1.0 - WARBLE_DEPTH + WARBLE_DEPTH * sin(t * TAU * WARBLE_HZ);
float ph = coilPhase(t);
// magnetic distortion
vec2 p = uv - 0.5;
float wave = sin(p.y * WAVE_FREQ * TAU - ph * TAU);
float wave2 = sin(p.y * WAVE_FREQ * 2.3 * TAU + ph * 0.62 * TAU);
float warp = wave * 0.7 + wave2 * 0.3;
// vertical breathing
p.y /= 1.0 + amp * SQUEEZE_AMP * sin(t * TAU * 3.0);
p *= 1.0 - amp * 0.04 * sin(t * TAU * 2.0);
// per-scanline jitter
float line = floor(uv.y * iResolution.y);
float jitter = (hash11(line + floor(t * 90.0)) - 0.5) * 2.0;
p.x += amp * (WOBBLE_AMP * warp + JITTER_AMP * jitter);
vec2 duv = p + 0.5;
// misconvergence
float ca = amp * CHROMA_AMP * (0.5 + abs(warp));
vec4 cr = sampleTerm(duv + vec2(ca, 0.0));
vec4 cg = sampleTerm(duv);
vec4 cb = sampleTerm(duv - vec2(ca, 0.0));
vec4 col = vec4(cr.r, cg.g, cb.b, cg.a);
// coil pulse sweeping down the tube
float bandY = 1.0 - fract(t / DURATION * BAND_SWEEPS);
float band = exp(-pow((uv.y - bandY) * 9.0, 2.0));
// background-opacity to solid for the length of the flash.
float glow = amp * FLASH_AMP * (0.25 + 0.75 * band);
col.rgb += glow * (iForegroundColor * 0.22 + col.rgb * 0.9);
fragColor = col;
}
@@ -0,0 +1,43 @@
{ ... }:
{
flake.homeModules.home-degauss-sound =
{ pkgs, lib, ... }:
let
duration = "1.5";
variants = 8;
generatorSource = lib.concatStringsSep "\n" (
lib.drop 1 (lib.splitString "\n" (builtins.readFile ./degauss-sound.py))
);
generator = pkgs.writers.writePython3Bin "degauss-gen" {
flakeIgnore = [
"E501"
"W291"
];
} generatorSource;
samples = pkgs.runCommandLocal "degauss-samples" { nativeBuildInputs = [ generator ]; } ''
mkdir -p $out
for i in $(seq 1 ${toString variants}); do
degauss-gen --seed "$i" --duration ${duration} --out "$out/degauss-$i.wav"
done
'';
degaussSound = pkgs.writeShellApplication {
name = "degauss-sound";
runtimeInputs = [ pkgs.pipewire ];
text = ''
n=$(( (RANDOM % ${toString variants}) + 1 ))
exec pw-play --volume="''${DEGAUSS_VOLUME:-0.35}" "${samples}/degauss-$n.wav"
'';
};
in
{
home.packages = [ degaussSound ];
programs.nushell.extraConfig = lib.mkBefore ''
if $nu.is-interactive and ($env.TERM_PROGRAM? == "ghostty") and ($env.ZELLIJ? | is-empty) {
job spawn { ^${lib.getExe degaussSound} } | ignore
}
'';
};
}
@@ -0,0 +1,76 @@
{ ... }:
{
flake.homeModules.home-ghostty-shaders =
{ config, pkgs, ... }:
let
preamble = pkgs.writeText "ghostty-shader-preamble.glsl" ''
#version 330 core
#define CURSORSTYLE_BLOCK 0
#define CURSORSTYLE_BLOCK_HOLLOW 1
#define CURSORSTYLE_BAR 2
#define CURSORSTYLE_UNDERLINE 3
#define CURSORSTYLE_LOCK 4
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 sampler2D iChannel0;
uniform vec4 iCurrentCursor;
uniform vec4 iPreviousCursor;
uniform vec4 iCurrentCursorColor;
uniform vec4 iPreviousCursorColor;
uniform vec4 iCurrentCursorStyle;
uniform vec4 iPreviousCursorStyle;
uniform vec4 iCursorVisible;
uniform float iTimeCursorChange;
uniform float iTimeFocus;
uniform int iFocus;
uniform vec3 iPalette[256];
uniform vec3 iBackgroundColor;
uniform vec3 iForegroundColor;
uniform vec3 iCursorColor;
uniform vec3 iCursorText;
uniform vec3 iSelectionBackgroundColor;
uniform vec3 iSelectionForegroundColor;
out vec4 _ghosttyFragColor;
#line 1
'';
entryPoint = pkgs.writeText "ghostty-shader-entry.glsl" ''
void main() { mainImage(_ghosttyFragColor, gl_FragCoord.xy); }
'';
compiled =
name: src:
pkgs.runCommandLocal "ghostty-shader-${name}.glsl"
{
nativeBuildInputs = [ pkgs.glslang ];
}
''
cat ${preamble} ${src} ${entryPoint} > wrapped.frag
glslangValidator -S frag wrapped.frag
cp ${src} $out
'';
shaderDir = "${config.xdg.configHome}/ghostty/shaders";
in
{
xdg.configFile."ghostty/shaders/cursor-smear.glsl".source =
compiled "cursor-smear" ./cursor-smear.glsl;
xdg.configFile."ghostty/shaders/degauss.glsl".source = compiled "degauss" ./degauss.glsl;
programs.ghostty.settings = {
custom-shader = [
"${shaderDir}/cursor-smear.glsl"
"${shaderDir}/degauss.glsl"
];
custom-shader-animation = true;
};
};
}