summaryrefslogtreecommitdiff
path: root/shaders
diff options
context:
space:
mode:
Diffstat (limited to 'shaders')
-rw-r--r--shaders/crt.ci.metal376
-rw-r--r--shaders/crt.frag.glsl173
-rw-r--r--shaders/image.frag.glsl91
-rw-r--r--shaders/image.vert.glsl16
-rw-r--r--shaders/overlay.frag.es.glsl11
-rw-r--r--shaders/prebuilt/crt.frag.glsl162
-rw-r--r--shaders/prebuilt/crt.frag.spvbin4108 -> 13348 bytes
-rw-r--r--shaders/prebuilt/crt.vert.glsl9
-rw-r--r--shaders/prebuilt/image.frag.glsl99
-rw-r--r--shaders/prebuilt/image.frag.spvbin624 -> 10372 bytes
-rw-r--r--shaders/prebuilt/image.vert.glsl49
-rw-r--r--shaders/prebuilt/image.vert.spvbin3152 -> 5032 bytes
-rw-r--r--shaders/prebuilt/overlay.frag.glsl9
-rw-r--r--shaders/prebuilt/overlay.vert.glsl (renamed from shaders/overlay.vert.es.glsl)6
-rw-r--r--shaders/prebuilt/ui.frag.glsl110
-rw-r--r--shaders/prebuilt/ui.frag.spvbin1148 -> 9712 bytes
-rw-r--r--shaders/prebuilt/ui.vert.glsl72
-rw-r--r--shaders/prebuilt/ui.vert.spvbin3520 -> 5468 bytes
-rw-r--r--shaders/ui.frag.es.glsl16
-rw-r--r--shaders/ui.frag.glsl94
-rw-r--r--shaders/ui.vert.es.glsl41
-rw-r--r--shaders/ui.vert.glsl28
22 files changed, 1262 insertions, 100 deletions
diff --git a/shaders/crt.ci.metal b/shaders/crt.ci.metal
new file mode 100644
index 00000000..32e26b21
--- /dev/null
+++ b/shaders/crt.ci.metal
@@ -0,0 +1,376 @@
+// Native macOS panel and scene postprocess. Panel kernels consume the core's
+// plain tracks; the final full-window kernel ports crt.frag.glsl and combines
+// CRT, ripple and glitch. Scene constants and sampling geometry stay in step
+// with GLSL, while panel easing/noise stays in step with panel_animation.zig.
+#include <metal_stdlib>
+#include <CoreImage/CoreImage.h>
+using namespace metal;
+
+constant float PARDES_TAU = 6.28318530718;
+
+float pardesNoise(float2 p) {
+ float3 q = fract(float3(p.x, p.y, p.x) * 0.1031);
+ q += dot(q, q.yzx + 33.33);
+ return fract((q.x + q.y) * q.z);
+}
+
+// Exact integer hash from panel_animation.cellNoise. Pane serial and logical
+// cell coordinates, not destination pixels, keep TTY and GPU reveal decisions
+// stable across scale factors and geometry transforms.
+float pardesCellNoise(float2 serialParts, float colValue, float rowValue) {
+ uint serial = uint(max(serialParts.x, 0.0)) |
+ (uint(max(serialParts.y, 0.0)) << 16);
+ uint col = uint(max(colValue, 0.0));
+ uint row = uint(max(rowValue, 0.0));
+ uint x = serial ^ (col * 0x9e3779b9u) ^ (row * 0x85ebca6bu);
+ x ^= x >> 16;
+ x *= 0x7feb352du;
+ x ^= x >> 15;
+ x *= 0x846ca68bu;
+ x ^= x >> 16;
+ return float(x & 0xffffu) / 65535.0;
+}
+
+float2 pardesSceneUV(float2 uv, float timeSeconds, float rippleOn, float glitchOn) {
+ if (rippleOn > 0.5) {
+ float2 centered = uv - 0.5;
+ float radius = length(centered);
+ // Reversed smoothstep edges are undefined in GLSL and Metal alike.
+ float envelope = 1.0 - smoothstep(0.04, 0.72, radius);
+ float wave = sin(radius * 42.0 - timeSeconds * 5.2);
+ uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032;
+ }
+ if (glitchOn > 0.5) {
+ float band = floor(uv.y * 96.0);
+ float seed = pardesNoise(float2(band, floor(timeSeconds * 13.0)));
+ float tear = smoothstep(0.955, 1.0, seed);
+ uv.x += (pardesNoise(float2(seed, band)) - 0.5) * tear * 0.020;
+ }
+ return uv;
+}
+
+float4 pardesSceneSample(
+ coreimage::sampler source,
+ float2 origin,
+ float2 size,
+ float2 uv
+) {
+ return coreimage::unpremultiply(
+ source.sample(source.transform(origin + uv * size)));
+}
+
+// Keep this ordering identical to crt.frag.glsl's sceneSrgb: every caller
+// supplies its own pre-warp coordinate, then ripple/glitch move that sample,
+// then glitch splits its red/blue channels around the moved center.
+float4 pardesSceneSrgb(
+ coreimage::sampler source,
+ float2 origin,
+ float2 size,
+ float2 uv,
+ float timeSeconds,
+ float rippleOn,
+ float glitchOn
+) {
+ float2 warped = pardesSceneUV(uv, timeSeconds, rippleOn, glitchOn);
+ float4 center = pardesSceneSample(source, origin, size, warped);
+ if (glitchOn <= 0.5) return center;
+
+ float band = floor(warped.y * 96.0);
+ float seed = pardesNoise(float2(band, floor(timeSeconds * 13.0)));
+ float tear = smoothstep(0.955, 1.0, seed);
+ float2 chroma = float2((0.35 + 1.25 * tear) / size.x, 0.0);
+ float4 red = pardesSceneSample(source, origin, size, warped + chroma);
+ float4 blue = pardesSceneSample(source, origin, size, warped - chroma);
+ return float4(red.r, center.g, blue.b, center.a);
+}
+
+float3 pardesSceneLinear(
+ coreimage::sampler source,
+ float2 origin,
+ float2 size,
+ float2 uv,
+ float timeSeconds,
+ float rippleOn,
+ float glitchOn
+) {
+ return coreimage::srgb_to_linear(pardesSceneSrgb(
+ source, origin, size, uv, timeSeconds, rippleOn, glitchOn).rgb);
+}
+
+bool pardesInside(float2 p, float4 box) {
+ return p.x >= box.x && p.y >= box.y &&
+ p.x < box.x + box.z && p.y < box.y + box.w;
+}
+
+float pardesPanelProgress(float effect, float frame) {
+ float frames = effect == 1.0 ? 12.0 :
+ effect == 2.0 ? 14.0 :
+ effect == 3.0 ? 10.0 :
+ effect == 4.0 ? 16.0 :
+ effect == 5.0 ? 12.0 : 1.0;
+ if (frames <= 1.0 || frame >= frames - 1.0) return 1.0;
+ float t = clamp(frame / (frames - 1.0), 0.0, 1.0);
+ if (effect == 1.0 || effect == 5.0) { // horizontal/vertical slide
+ float u = 1.0 - t;
+ return 1.0 - u * u * u;
+ }
+ if (effect == 2.0) { // zoom: ease-out back, including its overshoot
+ const float c1 = 1.70158;
+ const float c3 = c1 + 1.0;
+ float u = t - 1.0;
+ return 1.0 + c3 * u * u * u + c1 * u * u;
+ }
+ if (effect == 3.0) return t * t * (3.0 - 2.0 * t); // dissolve
+ return t; // ASCII materialization
+}
+
+float pardesAsciiGlyph(float2 uv, int glyph) {
+ float2 p = uv * 2.0 - 1.0;
+ const float thin = 0.12;
+ float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - float2(0.0, 0.45)));
+ float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y));
+ float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x));
+ if (glyph == 0) return dotShape;
+ if (glyph == 1) return max(dotShape,
+ 1.0 - smoothstep(0.10, 0.20, length(p + float2(0.0, 0.45))));
+ if (glyph == 2) return max(barH, barV);
+ if (glyph == 3) {
+ float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y));
+ float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y));
+ return max(max(d0, d1), max(barH, barV));
+ }
+ if (glyph == 4) {
+ float rails = max(
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)),
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34)));
+ return rails;
+ }
+ float ring = 1.0 - smoothstep(thin, thin + 0.08,
+ abs(length(p * float2(0.8, 1.0)) - 0.48));
+ return max(ring, glyph == 6 ? dotShape : 0.0);
+}
+
+// Prepare every geometric target before any translated panel is drawn. A
+// vertical opener restores the old frame in its fixed clip; a closing pane is
+// already absent from `canvas` and therefore needs no clear at all.
+extern "C" float4 pardesPanelClear(
+ coreimage::sampler canvas,
+ coreimage::sampler previous,
+ float4 target,
+ float4 ground,
+ float effect,
+ float phase,
+ coreimage::destination destination
+) {
+ float2 coord = destination.coord();
+ if (!pardesInside(coord, target))
+ return canvas.sample(canvas.transform(coord));
+ if (effect == 1.0 || effect == 2.0) return ground;
+ if (effect == 5.0 && phase == 0.0)
+ return previous.sample(previous.transform(coord));
+ return canvas.sample(canvas.transform(coord));
+}
+
+// One panel draw over the accumulated image. Current and previous are complete
+// CoreText+attachment rasters. That makes a closing pane safe after its core
+// allocation is gone and makes ASCII/dissolve true old-to-new data effects.
+extern "C" float4 pardesPanel(
+ coreimage::sampler current,
+ coreimage::sampler previous,
+ coreimage::sampler changedMask,
+ coreimage::sampler canvas,
+ float4 target,
+ float4 from,
+ float4 to,
+ float effect,
+ float phase,
+ float frame,
+ float2 serialParts,
+ float2 cellSize,
+ float2 gridOrigin,
+ coreimage::destination destination
+) {
+ float2 coord = destination.coord();
+ float4 underneath = canvas.sample(canvas.transform(coord));
+ float progress = pardesPanelProgress(effect, frame);
+
+ float2 safeCell = max(cellSize, float2(1.0));
+ // Content transitions stay at canonical geometry. Unchanged cells bypass
+ // the effect completely; changed cells retain the exact old raster until
+ // their deterministic wave reaches them.
+ if (effect == 3.0 || effect == 4.0) {
+ if (!pardesInside(coord, target)) return underneath;
+ // During a live resize the grid is still anchored at the view's top,
+ // leaving a partial strip below its final logical row. Account for
+ // that strip before indexing the compact cell mask.
+ float2 absoluteCell = floor((coord - gridOrigin) / safeCell);
+ float changed = changedMask.sample(changedMask.transform(
+ absoluteCell + float2(0.5))).r;
+ if (changed < 0.5) return underneath;
+
+ float4 oldPixel = previous.sample(previous.transform(coord));
+ float4 newPixel = current.sample(current.transform(coord));
+ // Exact endpoint guards are not redundant: a noise hash can be zero
+ // or one, and frame zero/last must still be wholly old/new.
+ if (progress <= 0.0) return oldPixel;
+ if (progress >= 1.0) return newPixel;
+
+ // Core/TTY coordinates are relative to the target's TOP-left. Core
+ // Image is bottom-up, hence the reversed row term.
+ float2 cell = float2(
+ floor((coord.x - target.x) / safeCell.x),
+ floor((target.y + target.w - coord.y) / safeCell.y));
+ float2 cellCount = max(round(target.zw / safeCell), float2(1.0));
+ cell = clamp(cell, float2(0.0), cellCount - 1.0);
+ float noise = pardesCellNoise(serialParts, cell.x, cell.y);
+ if (effect == 3.0)
+ return noise < progress ? newPixel : oldPixel;
+
+ float diagonal = fmod(cell.x + cell.y * 2.0, 17.0) / 17.0;
+ float threshold = noise * 0.72 + diagonal * 0.28;
+ if (progress >= threshold) return newPixel;
+ if (progress + 0.22 < threshold) return oldPixel;
+
+ float2 cellUV = fract(float2(
+ (coord.x - target.x) / safeCell.x,
+ (target.y + target.w - coord.y) / safeCell.y));
+ int glyph = min(int(floor(noise * 7.0)), 6);
+ float ink = pardesAsciiGlyph(cellUV, glyph);
+ float2 cellOrigin = float2(
+ target.x + cell.x * safeCell.x,
+ target.y + target.w - (cell.y + 1.0) * safeCell.y);
+ float4 bgSample = current.sample(current.transform(
+ cellOrigin + safeCell * float2(0.12, 0.12)));
+ float4 bg = coreimage::unpremultiply(bgSample);
+ float4 fg = coreimage::unpremultiply(newPixel);
+ // A procedural mark usually crosses pixels where the target glyph has
+ // no ink, so sampling `newPixel` alone can make it background-on-
+ // background and erase the ASCII phase. Preserve target ink where it
+ // is present; otherwise derive restrained contrast from the cell's
+ // own background instead of imposing a fixed palette.
+ float3 contrastInk = dot(bg.rgb, float3(0.2126, 0.7152, 0.0722)) > 0.5
+ ? float3(0.0) : float3(1.0);
+ float sourceContrast = smoothstep(0.025, 0.14, length(fg.rgb - bg.rgb));
+ float3 inkColor = mix(contrastInk, fg.rgb, sourceContrast);
+ float3 dimInk = mix(bg.rgb, inkColor, 0.62);
+ return coreimage::premultiply(float4(
+ mix(bg.rgb, dimInk, ink), max(bg.a, newPixel.a)));
+ }
+
+ float4 shown = mix(from, to, progress);
+ shown.zw = max(shown.zw, float2(0.0));
+ if (!pardesInside(coord, shown) || shown.z <= 0.0 || shown.w <= 0.0)
+ return underneath;
+ // Vertical lifecycle motion is deliberately confined to the pane's own
+ // old/new box. It never travels across or clips a surviving neighbour.
+ if (effect == 5.0 && !pardesInside(coord, target)) return underneath;
+
+ float2 within = (coord - shown.xy) / shown.zw;
+ float2 sourceCoord = target.xy + within * target.zw;
+ if (!pardesInside(sourceCoord, target)) return underneath;
+ return phase == 2.0
+ ? previous.sample(previous.transform(sourceCoord))
+ : current.sample(current.transform(sourceCoord));
+}
+
+extern "C" float4 pardesScene(
+ coreimage::sampler source,
+ float timeSeconds,
+ float frame,
+ float crtOn,
+ float rippleOn,
+ float glitchOn,
+ float2 sceneSize,
+ float2 sceneOrigin,
+ coreimage::destination destination
+) {
+ const float barrel = 0.016;
+ const float edgeSoftnessPx = 3.5;
+ const float chromaPx = 0.28;
+ const float bloomRadiusPx = 1.75;
+ const float bloomThreshold = 0.60;
+ const float bloomSoftness = 0.25;
+ const float bloomStrength = 0.045;
+ const float scanPeriodPx = 3.0;
+ const float scanStrength = 0.055;
+ const float maskPeriodPx = 3.0;
+ const float maskStrength = 0.015;
+ const float vignetteStrength = 0.11;
+ const float humStrength = 0.0025;
+ const float noiseStrength = 0.0020;
+ const float exposure = 1.012;
+
+ float2 size = sceneSize;
+ float2 origin = sceneOrigin;
+ float2 pixel = destination.coord() - origin;
+ float2 texel = 1.0 / size;
+ float2 cc = pixel / size * 2.0 - 1.0;
+ float r2 = dot(cc, cc);
+ float2 uv = cc * (1.0 + barrel * r2 * crtOn) * 0.5 + 0.5;
+ float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y));
+ // Match GLSL's opaque black outside the tube. Transparent black would
+ // reveal AppKit's backdrop and make the same CRT effect have no tube on a
+ // transparent theme.
+ if (crtOn > 0.5 && edge <= 0.0) return float4(0.0, 0.0, 0.0, 1.0);
+
+ float4 center = pardesSceneSrgb(
+ source, origin, size, uv, timeSeconds, rippleOn, glitchOn);
+ float3 scene = center.rgb;
+
+ // Ripple/glitch alone are coordinate effects. Returning the sampled sRGB
+ // values directly keeps exact theme colors exact.
+ if (crtOn <= 0.5) {
+ return coreimage::premultiply(float4(clamp(scene, 0.0, 1.0), center.a));
+ }
+
+ float3 centerLinear = coreimage::srgb_to_linear(scene);
+ float2 radialChroma = cc * (chromaPx * r2) * texel;
+ float3 redSample = pardesSceneLinear(
+ source, origin, size, uv + radialChroma,
+ timeSeconds, rippleOn, glitchOn);
+ float3 blueSample = pardesSceneLinear(
+ source, origin, size, uv - radialChroma,
+ timeSeconds, rippleOn, glitchOn);
+ float3 col = float3(
+ redSample.r,
+ centerLinear.g,
+ blueSample.b
+ );
+
+ float2 bloom = texel * bloomRadiusPx;
+ float3 bloomSum = float3(0.0);
+ float2 bloomOffsets[4] = {
+ float2(bloom.x, 0.0), float2(-bloom.x, 0.0),
+ float2(0.0, bloom.y), float2(0.0, -bloom.y)
+ };
+ for (uint i = 0; i < 4; i++) {
+ // Deliberately raw, like GLSL brightPass: bloom belongs to the source
+ // image and does not recursively warp through the scene effects.
+ float4 sampleValue = pardesSceneSample(
+ source, origin, size, uv + bloomOffsets[i]);
+ float3 linearValue = coreimage::srgb_to_linear(sampleValue.rgb);
+ float peak = max(linearValue.r, max(linearValue.g, linearValue.b));
+ bloomSum += linearValue * smoothstep(bloomThreshold, bloomThreshold + bloomSoftness, peak);
+ }
+ col += bloomSum * (0.25 * bloomStrength);
+
+ float scanWave = 0.5 + 0.5 * cos(pixel.y * PARDES_TAU / scanPeriodPx);
+ col *= 1.0 - scanStrength * scanWave * scanWave;
+ float3 mask = 1.0 + maskStrength * cos(
+ pixel.x * PARDES_TAU / maskPeriodPx +
+ float3(0.0, PARDES_TAU / 3.0, 2.0 * PARDES_TAU / 3.0));
+ col *= mask;
+
+ float2 edgePx = min(uv, 1.0 - uv) * size;
+ float tube = smoothstep(0.0, edgeSoftnessPx, min(edgePx.x, edgePx.y));
+ float vignette = 1.0 - vignetteStrength * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6);
+ float hum = 1.0 + humStrength * sin((uv.y * 1.35 - timeSeconds * 0.11) * PARDES_TAU);
+ col *= tube * vignette * hum * exposure;
+
+ float luma = dot(col, float3(0.2126, 0.7152, 0.0722));
+ float2 noiseSeed = pixel + float2(frame * 17.0, frame * 59.0);
+ col += (pardesNoise(noiseSeed) - 0.5) * noiseStrength *
+ (0.15 + 0.85 * clamp(luma, 0.0, 1.0));
+ return coreimage::premultiply(float4(
+ coreimage::linear_to_srgb(clamp(col, 0.0, 1.0)), center.a));
+}
diff --git a/shaders/crt.frag.glsl b/shaders/crt.frag.glsl
index 1c9b15a8..e43d5b86 100644
--- a/shaders/crt.frag.glsl
+++ b/shaders/crt.frag.glsl
@@ -1,39 +1,162 @@
#version 450
-// The Crt builtin's post-process, one pass, fully static (no animation, no
-// glitches). Screen point cc shows the scene at uv' = cc*(1 + BARREL*r2),
-// and src/crt.zig maps mouse input through that same transform — keep them
-// equal or clicks drift near the edges.
+// The Crt builtin's post-process. Screen point cc shows the scene at
+// uv' = cc*(1 + BARREL*r2), and src/gui/crt.zig maps mouse input through that
+// same transform — keep them equal or clicks drift near the edges.
layout(set = 2, binding = 0) uniform sampler2D u_scene;
+layout(std140, set = 3, binding = 0) uniform CrtUniforms {
+ float time_seconds;
+ float frame;
+ vec2 viewport;
+ // x = CRT, y = ripple, z = glitch. One scene pass can combine them.
+ vec4 effects;
+} u_crt;
layout(location = 0) out vec4 o_col;
-const float BARREL = 0.012; // barrel curvature; MUST match src/crt.zig barrel
-const float VIGNETTE = 0.07; // corner darkening: *= 1 - VIGNETTE*r2^2
-const vec2 PHOSPHOR = vec2(2.0, 2.0); // uv quantize cell in scene px (~1/7 of a glyph cell)
-const float SCAN = 0.15; // scanline depth, one raised-cosine band per PHOSPHOR.y rows
-const float CHROMA = 0.001; // R/B radial sample offset, grows toward the edges
-const float BLOOM = 0.4; // bright-pass neighbor glow added back
-const float BLOOM_MIN = 0.5; // channel level where the bright pass starts
-const float LIFT = 1.08; // output brightness lift (offsets scan+vignette losses)
+const float TAU = 6.28318530718;
+const float BARREL = 0.016; // MUST match src/gui/crt.zig barrel
+const float EDGE_SOFTNESS_PX = 3.5;
+const float CHROMA_PX = 0.28; // radial R/B separation in physical pixels at r2=1
+const float BLOOM_RADIUS_PX = 1.75;
+const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak
+const float BLOOM_SOFTNESS = 0.25;
+const float BLOOM_STRENGTH = 0.045;
+const float SCAN_PERIOD_PX = 3.0;
+const float SCAN_STRENGTH = 0.055;
+const float MASK_PERIOD_PX = 3.0;
+const float MASK_STRENGTH = 0.015;
+const float VIGNETTE_STRENGTH = 0.11; // 11% only at the extreme corners
+const float HUM_STRENGTH = 0.0025;
+const float NOISE_STRENGTH = 0.0020;
+const float EXPOSURE = 1.012;
+
+vec3 srgbToLinear(vec3 c) {
+ vec3 low = c / 12.92;
+ vec3 high = pow((c + 0.055) / 1.055, vec3(2.4));
+ return mix(high, low, lessThanEqual(c, vec3(0.04045)));
+}
+
+vec3 linearToSrgb(vec3 c) {
+ c = max(c, vec3(0.0));
+ vec3 low = c * 12.92;
+ vec3 high = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
+ return mix(high, low, lessThanEqual(c, vec3(0.0031308)));
+}
+
+vec3 sceneLinear(vec2 uv) {
+ return srgbToLinear(texture(u_scene, uv).rgb);
+}
+
+vec3 brightPass(vec2 uv) {
+ vec3 c = sceneLinear(uv);
+ float peak = max(c.r, max(c.g, c.b));
+ return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak);
+}
+
+float noiseAt(vec2 p) {
+ vec3 q = fract(vec3(p.xyx) * 0.1031);
+ q += dot(q, q.yzx + 33.33);
+ return fract((q.x + q.y) * q.z);
+}
+
+// The non-CRT scene effects deliberately distort the source coordinates,
+// rather than remapping the final colors. Themes retain their palette and the
+// effects remain composable with the linear-light CRT treatment below.
+vec2 sceneUv(vec2 uv) {
+ if (u_crt.effects.y > 0.5) {
+ vec2 centered = uv - 0.5;
+ float radius = length(centered);
+ float envelope = 1.0 - smoothstep(0.04, 0.72, radius);
+ float wave = sin(radius * 42.0 - u_crt.time_seconds * 5.2);
+ uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032;
+ }
+ if (u_crt.effects.z > 0.5) {
+ float band = floor(uv.y * 96.0);
+ float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0)));
+ float tear = smoothstep(0.955, 1.0, seed);
+ uv.x += (noiseAt(vec2(seed, band)) - 0.5) * tear * 0.020;
+ }
+ return uv;
+}
+
+vec3 sceneSrgb(vec2 uv) {
+ vec2 warped = sceneUv(uv);
+ vec3 center = texture(u_scene, warped).rgb;
+ if (u_crt.effects.z <= 0.5) return center;
+
+ float band = floor(warped.y * 96.0);
+ float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0)));
+ float tear = smoothstep(0.955, 1.0, seed);
+ vec2 chroma = vec2((0.35 + 1.25 * tear) / u_crt.viewport.x, 0.0);
+ return vec3(
+ texture(u_scene, warped + chroma).r,
+ center.g,
+ texture(u_scene, warped - chroma).b
+ );
+}
+
+vec3 effectSceneLinear(vec2 uv) {
+ return srgbToLinear(sceneSrgb(uv));
+}
void main() {
- vec2 size = vec2(textureSize(u_scene, 0));
+ vec2 size = u_crt.viewport;
+ vec2 texel = 1.0 / size;
vec2 cc = gl_FragCoord.xy / size * 2.0 - 1.0;
float r2 = dot(cc, cc);
- vec2 uv = cc * (1.0 + BARREL * r2) * 0.5 + 0.5;
+ vec2 uv = cc * (1.0 + BARREL * r2 * u_crt.effects.x) * 0.5 + 0.5;
+
+ // Ripple and glitch are useful without the CRT. They only move samples;
+ // skip every tube-specific light/mask/exposure adjustment.
+ if (u_crt.effects.x <= 0.5) {
+ // The scene texture is already sRGB. Avoid a needless transfer-curve
+ // round trip so a coordinate-only effect preserves theme bytes.
+ o_col = vec4(clamp(sceneSrgb(uv), 0.0, 1.0), 1.0);
+ return;
+ }
+
vec3 col = vec3(0.0);
- if (all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0)))) {
- vec2 q = (floor(uv * size / PHOSPHOR) + 0.5) * PHOSPHOR / size;
- vec2 ca = cc * (CHROMA * r2);
- col = vec3(texture(u_scene, q + ca).r, texture(u_scene, q).g, texture(u_scene, q - ca).b);
- vec2 px = PHOSPHOR / size;
- vec3 nb = texture(u_scene, q + vec2(px.x, 0.0)).rgb + texture(u_scene, q - vec2(px.x, 0.0)).rgb +
- texture(u_scene, q + vec2(0.0, px.y)).rgb + texture(u_scene, q - vec2(0.0, px.y)).rgb;
- col += max(nb * 0.25 - BLOOM_MIN, 0.0) * BLOOM;
+ float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y));
+ if (edge > 0.0) {
+ vec3 center = effectSceneLinear(uv);
+ vec2 chroma = cc * (CHROMA_PX * r2) * texel;
+ // Each radial sample follows the same ripple/glitch path from its own
+ // pre-warp coordinate. This is also how the Metal kernel composes the
+ // effects; sampling a once-warped center here made combined effects
+ // diverge between the two GUI backends.
+ col = vec3(
+ effectSceneLinear(uv + chroma).r,
+ center.g,
+ effectSceneLinear(uv - chroma).b
+ );
+
+ vec2 bloom_px = texel * BLOOM_RADIUS_PX;
+ vec3 bloom = brightPass(uv + vec2(bloom_px.x, 0.0)) +
+ brightPass(uv - vec2(bloom_px.x, 0.0)) +
+ brightPass(uv + vec2(0.0, bloom_px.y)) +
+ brightPass(uv - vec2(0.0, bloom_px.y));
+ col += bloom * (0.25 * BLOOM_STRENGTH);
+
+ // The mask has unit mean and the scan loss stays small; neither needs
+ // the old global lift that remapped every theme toward white.
+ float scan_wave = 0.5 + 0.5 * cos(gl_FragCoord.y * TAU / SCAN_PERIOD_PX);
+ col *= 1.0 - SCAN_STRENGTH * scan_wave * scan_wave;
+ vec3 mask = 1.0 + MASK_STRENGTH * cos(
+ gl_FragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)
+ );
+ col *= mask;
+
+ vec2 edge_px = min(uv, 1.0 - uv) * size;
+ float tube = smoothstep(0.0, EDGE_SOFTNESS_PX, min(edge_px.x, edge_px.y));
+ float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6);
+ float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - u_crt.time_seconds * 0.11) * TAU);
+ col *= tube * vignette * hum * EXPOSURE;
+
+ float luma = dot(col, vec3(0.2126, 0.7152, 0.0722));
+ vec2 noise_seed = gl_FragCoord.xy + vec2(u_crt.frame * 17.0, u_crt.frame * 59.0);
+ col += (noiseAt(noise_seed) - 0.5) * NOISE_STRENGTH * (0.15 + 0.85 * clamp(luma, 0.0, 1.0));
}
- col *= 1.0 - VIGNETTE * r2 * r2;
- col *= 1.0 - SCAN * (0.5 + 0.5 * cos(gl_FragCoord.y * 6.2831853 / PHOSPHOR.y));
- o_col = vec4(col * LIFT, 1.0);
+ o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), 1.0);
}
diff --git a/shaders/image.frag.glsl b/shaders/image.frag.glsl
index 927d5f65..747ceef7 100644
--- a/shaders/image.frag.glsl
+++ b/shaders/image.frag.glsl
@@ -3,8 +3,97 @@
layout(set = 2, binding = 0) uniform sampler2D u_image;
layout(location = 0) in vec2 v_uv;
+layout(location = 1) in vec2 v_panel_uv;
+layout(location = 2) flat in uvec4 v_effect;
layout(location = 0) out vec4 o_col;
+float cellNoise(uint serial, uint col, uint row) {
+ uint x = serial ^ (col * 0x9e3779b9u) ^ (row * 0x85ebca6bu);
+ x ^= x >> 16;
+ x *= 0x7feb352du;
+ x ^= x >> 15;
+ x *= 0x846ca68bu;
+ x ^= x >> 16;
+ return float(x & 0xffffu) / 65535.0;
+}
+
+// The same seven procedural ASCII marks as ui.frag.glsl and the macOS Metal
+// panel kernel. Native attachments are content in the panel, so they should
+// not switch to a smaller two-symbol animation when the glyph grid over them
+// is using `. : + * # % @`.
+float asciiGlyph(vec2 uv, int glyph) {
+ vec2 p = uv * 2.0 - 1.0;
+ float thin = 0.12;
+ float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - vec2(0.0, 0.45)));
+ float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y));
+ float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x));
+ if (glyph == 0) return dotShape;
+ if (glyph == 1) return max(dotShape, 1.0 - smoothstep(0.10, 0.20, length(p + vec2(0.0, 0.45))));
+ if (glyph == 2) return max(barH, barV);
+ if (glyph == 3) {
+ float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y));
+ float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y));
+ return max(max(d0, d1), max(barH, barV));
+ }
+ if (glyph == 4) {
+ float rails = max(
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)),
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34))
+ );
+ return rails;
+ }
+ float ring = 1.0 - smoothstep(thin, thin + 0.08, abs(length(p * vec2(0.8, 1.0)) - 0.48));
+ return max(ring, glyph == 6 ? dotShape : 0.0);
+}
+
void main() {
- o_col = texture(u_image, v_uv);
+ vec4 source = texture(u_image, v_uv);
+ uint effect = v_effect.x & 0x7fffffffu;
+ bool oldLayer = (v_effect.x & 0x80000000u) != 0u;
+ float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0);
+ uvec2 dimensions = max(
+ uvec2(v_effect.w & 0xffffu, v_effect.w >> 16),
+ uvec2(1u));
+ uvec2 cellCoord = min(
+ uvec2(floor(max(v_panel_uv, vec2(0.0)) * vec2(dimensions))),
+ dimensions - 1u);
+ float noise = cellNoise(v_effect.z, cellCoord.x, cellCoord.y);
+ if (effect == 3u) {
+ bool reveal = progress >= 1.0 || (progress > 0.0 && noise < progress);
+ if (oldLayer == reveal) discard;
+ o_col = source;
+ return;
+ }
+ if (effect == 4u) {
+ float diagonal = float((cellCoord.x + cellCoord.y * 2u) % 17u) / 17.0;
+ float threshold = noise * 0.72 + diagonal * 0.28;
+ bool reveal = progress >= 1.0 || (progress > 0.0 && progress >= threshold);
+ bool wave = !reveal && progress > 0.0 && progress + 0.22 >= threshold;
+ if (oldLayer) {
+ if (wave || reveal) discard;
+ o_col = source;
+ return;
+ }
+ if (reveal) {
+ o_col = source;
+ return;
+ }
+ if (!wave) discard;
+ vec2 cellUV = fract(v_panel_uv * vec2(dimensions));
+ int glyph = min(int(floor(noise * 7.0)), 6);
+ float mark = asciiGlyph(cellUV, glyph);
+ float luma = dot(source.rgb, vec3(0.2126, 0.7152, 0.0722));
+ vec3 paper = source.rgb * 0.10;
+ // Preserve source colour when it already separates from the dimmed
+ // paper. Solid black pixels have no such separation, so derive a
+ // readable mark from their own luminance instead of letting the ASCII
+ // phase disappear on dark images/PDF regions.
+ vec3 contrastInk = luma > 0.5 ? vec3(0.0) : vec3(1.0);
+ float sourceContrast = smoothstep(0.025, 0.14, length(source.rgb - paper));
+ vec3 ink = mix(contrastInk, source.rgb, sourceContrast);
+ vec3 dimInk = mix(paper, ink, 0.62);
+ o_col = vec4(mix(paper, dimInk, mark), source.a);
+ return;
+ }
+ o_col = source;
}
diff --git a/shaders/image.vert.glsl b/shaders/image.vert.glsl
index ace55a53..04196ce4 100644
--- a/shaders/image.vert.glsl
+++ b/shaders/image.vert.glsl
@@ -4,8 +4,13 @@
// the pane's cell rectangle and the aspect-preserving contain fit to NDC.
layout(location = 0) in vec4 a_rect; // x0, y0, x1, y1
layout(location = 1) in vec4 a_uv_rect; // u0, v0, u1, v1
+layout(location = 2) in vec4 a_panel_rect;
+layout(location = 3) in vec4 a_present_rect;
+layout(location = 4) in uvec4 a_effect;
layout(location = 0) out vec2 v_uv;
+layout(location = 1) out vec2 v_panel_uv;
+layout(location = 2) flat out uvec4 v_effect;
void main() {
vec2 pos;
@@ -29,5 +34,16 @@ void main() {
pos = vec2(a_rect.x, a_rect.w);
v_uv = vec2(a_uv_rect.x, a_uv_rect.w);
}
+ v_panel_uv = v_uv;
+ uint effect = a_effect.x & 0x7fffffffu;
+ if (effect != 0u) {
+ vec2 panel_size = a_panel_rect.zw - a_panel_rect.xy;
+ panel_size.x = abs(panel_size.x) < 0.000001 ? 0.000001 : panel_size.x;
+ panel_size.y = abs(panel_size.y) < 0.000001 ? -0.000001 : panel_size.y;
+ v_panel_uv = (pos - a_panel_rect.xy) / panel_size;
+ if (effect == 1u || effect == 2u || effect == 5u)
+ pos = mix(a_present_rect.xy, a_present_rect.zw, v_panel_uv);
+ }
+ v_effect = a_effect;
gl_Position = vec4(pos, 0.0, 1.0);
}
diff --git a/shaders/overlay.frag.es.glsl b/shaders/overlay.frag.es.glsl
deleted file mode 100644
index 37032c9a..00000000
--- a/shaders/overlay.frag.es.glsl
+++ /dev/null
@@ -1,11 +0,0 @@
-#version 300 es
-
-precision mediump float;
-
-in vec4 v_color;
-
-out vec4 o_col;
-
-void main() {
- o_col = v_color;
-}
diff --git a/shaders/prebuilt/crt.frag.glsl b/shaders/prebuilt/crt.frag.glsl
new file mode 100644
index 00000000..e43d5b86
--- /dev/null
+++ b/shaders/prebuilt/crt.frag.glsl
@@ -0,0 +1,162 @@
+#version 450
+
+// The Crt builtin's post-process. Screen point cc shows the scene at
+// uv' = cc*(1 + BARREL*r2), and src/gui/crt.zig maps mouse input through that
+// same transform — keep them equal or clicks drift near the edges.
+
+layout(set = 2, binding = 0) uniform sampler2D u_scene;
+layout(std140, set = 3, binding = 0) uniform CrtUniforms {
+ float time_seconds;
+ float frame;
+ vec2 viewport;
+ // x = CRT, y = ripple, z = glitch. One scene pass can combine them.
+ vec4 effects;
+} u_crt;
+
+layout(location = 0) out vec4 o_col;
+
+const float TAU = 6.28318530718;
+const float BARREL = 0.016; // MUST match src/gui/crt.zig barrel
+const float EDGE_SOFTNESS_PX = 3.5;
+const float CHROMA_PX = 0.28; // radial R/B separation in physical pixels at r2=1
+const float BLOOM_RADIUS_PX = 1.75;
+const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak
+const float BLOOM_SOFTNESS = 0.25;
+const float BLOOM_STRENGTH = 0.045;
+const float SCAN_PERIOD_PX = 3.0;
+const float SCAN_STRENGTH = 0.055;
+const float MASK_PERIOD_PX = 3.0;
+const float MASK_STRENGTH = 0.015;
+const float VIGNETTE_STRENGTH = 0.11; // 11% only at the extreme corners
+const float HUM_STRENGTH = 0.0025;
+const float NOISE_STRENGTH = 0.0020;
+const float EXPOSURE = 1.012;
+
+vec3 srgbToLinear(vec3 c) {
+ vec3 low = c / 12.92;
+ vec3 high = pow((c + 0.055) / 1.055, vec3(2.4));
+ return mix(high, low, lessThanEqual(c, vec3(0.04045)));
+}
+
+vec3 linearToSrgb(vec3 c) {
+ c = max(c, vec3(0.0));
+ vec3 low = c * 12.92;
+ vec3 high = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
+ return mix(high, low, lessThanEqual(c, vec3(0.0031308)));
+}
+
+vec3 sceneLinear(vec2 uv) {
+ return srgbToLinear(texture(u_scene, uv).rgb);
+}
+
+vec3 brightPass(vec2 uv) {
+ vec3 c = sceneLinear(uv);
+ float peak = max(c.r, max(c.g, c.b));
+ return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak);
+}
+
+float noiseAt(vec2 p) {
+ vec3 q = fract(vec3(p.xyx) * 0.1031);
+ q += dot(q, q.yzx + 33.33);
+ return fract((q.x + q.y) * q.z);
+}
+
+// The non-CRT scene effects deliberately distort the source coordinates,
+// rather than remapping the final colors. Themes retain their palette and the
+// effects remain composable with the linear-light CRT treatment below.
+vec2 sceneUv(vec2 uv) {
+ if (u_crt.effects.y > 0.5) {
+ vec2 centered = uv - 0.5;
+ float radius = length(centered);
+ float envelope = 1.0 - smoothstep(0.04, 0.72, radius);
+ float wave = sin(radius * 42.0 - u_crt.time_seconds * 5.2);
+ uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032;
+ }
+ if (u_crt.effects.z > 0.5) {
+ float band = floor(uv.y * 96.0);
+ float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0)));
+ float tear = smoothstep(0.955, 1.0, seed);
+ uv.x += (noiseAt(vec2(seed, band)) - 0.5) * tear * 0.020;
+ }
+ return uv;
+}
+
+vec3 sceneSrgb(vec2 uv) {
+ vec2 warped = sceneUv(uv);
+ vec3 center = texture(u_scene, warped).rgb;
+ if (u_crt.effects.z <= 0.5) return center;
+
+ float band = floor(warped.y * 96.0);
+ float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0)));
+ float tear = smoothstep(0.955, 1.0, seed);
+ vec2 chroma = vec2((0.35 + 1.25 * tear) / u_crt.viewport.x, 0.0);
+ return vec3(
+ texture(u_scene, warped + chroma).r,
+ center.g,
+ texture(u_scene, warped - chroma).b
+ );
+}
+
+vec3 effectSceneLinear(vec2 uv) {
+ return srgbToLinear(sceneSrgb(uv));
+}
+
+void main() {
+ vec2 size = u_crt.viewport;
+ vec2 texel = 1.0 / size;
+ vec2 cc = gl_FragCoord.xy / size * 2.0 - 1.0;
+ float r2 = dot(cc, cc);
+ vec2 uv = cc * (1.0 + BARREL * r2 * u_crt.effects.x) * 0.5 + 0.5;
+
+ // Ripple and glitch are useful without the CRT. They only move samples;
+ // skip every tube-specific light/mask/exposure adjustment.
+ if (u_crt.effects.x <= 0.5) {
+ // The scene texture is already sRGB. Avoid a needless transfer-curve
+ // round trip so a coordinate-only effect preserves theme bytes.
+ o_col = vec4(clamp(sceneSrgb(uv), 0.0, 1.0), 1.0);
+ return;
+ }
+
+ vec3 col = vec3(0.0);
+ float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y));
+ if (edge > 0.0) {
+ vec3 center = effectSceneLinear(uv);
+ vec2 chroma = cc * (CHROMA_PX * r2) * texel;
+ // Each radial sample follows the same ripple/glitch path from its own
+ // pre-warp coordinate. This is also how the Metal kernel composes the
+ // effects; sampling a once-warped center here made combined effects
+ // diverge between the two GUI backends.
+ col = vec3(
+ effectSceneLinear(uv + chroma).r,
+ center.g,
+ effectSceneLinear(uv - chroma).b
+ );
+
+ vec2 bloom_px = texel * BLOOM_RADIUS_PX;
+ vec3 bloom = brightPass(uv + vec2(bloom_px.x, 0.0)) +
+ brightPass(uv - vec2(bloom_px.x, 0.0)) +
+ brightPass(uv + vec2(0.0, bloom_px.y)) +
+ brightPass(uv - vec2(0.0, bloom_px.y));
+ col += bloom * (0.25 * BLOOM_STRENGTH);
+
+ // The mask has unit mean and the scan loss stays small; neither needs
+ // the old global lift that remapped every theme toward white.
+ float scan_wave = 0.5 + 0.5 * cos(gl_FragCoord.y * TAU / SCAN_PERIOD_PX);
+ col *= 1.0 - SCAN_STRENGTH * scan_wave * scan_wave;
+ vec3 mask = 1.0 + MASK_STRENGTH * cos(
+ gl_FragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)
+ );
+ col *= mask;
+
+ vec2 edge_px = min(uv, 1.0 - uv) * size;
+ float tube = smoothstep(0.0, EDGE_SOFTNESS_PX, min(edge_px.x, edge_px.y));
+ float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6);
+ float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - u_crt.time_seconds * 0.11) * TAU);
+ col *= tube * vignette * hum * EXPOSURE;
+
+ float luma = dot(col, vec3(0.2126, 0.7152, 0.0722));
+ vec2 noise_seed = gl_FragCoord.xy + vec2(u_crt.frame * 17.0, u_crt.frame * 59.0);
+ col += (noiseAt(noise_seed) - 0.5) * NOISE_STRENGTH * (0.15 + 0.85 * clamp(luma, 0.0, 1.0));
+ }
+ o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), 1.0);
+}
diff --git a/shaders/prebuilt/crt.frag.spv b/shaders/prebuilt/crt.frag.spv
index 326aa534..057f0e06 100644
--- a/shaders/prebuilt/crt.frag.spv
+++ b/shaders/prebuilt/crt.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/crt.vert.glsl b/shaders/prebuilt/crt.vert.glsl
new file mode 100644
index 00000000..4e15fcdf
--- /dev/null
+++ b/shaders/prebuilt/crt.vert.glsl
@@ -0,0 +1,9 @@
+#version 450
+
+// CRT post-process pass: one fullscreen triangle from gl_VertexIndex, no
+// vertex buffer. The fragment shader derives uv from gl_FragCoord.
+
+void main() {
+ vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2));
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
+}
diff --git a/shaders/prebuilt/image.frag.glsl b/shaders/prebuilt/image.frag.glsl
new file mode 100644
index 00000000..747ceef7
--- /dev/null
+++ b/shaders/prebuilt/image.frag.glsl
@@ -0,0 +1,99 @@
+#version 450
+
+layout(set = 2, binding = 0) uniform sampler2D u_image;
+
+layout(location = 0) in vec2 v_uv;
+layout(location = 1) in vec2 v_panel_uv;
+layout(location = 2) flat in uvec4 v_effect;
+layout(location = 0) out vec4 o_col;
+
+float cellNoise(uint serial, uint col, uint row) {
+ uint x = serial ^ (col * 0x9e3779b9u) ^ (row * 0x85ebca6bu);
+ x ^= x >> 16;
+ x *= 0x7feb352du;
+ x ^= x >> 15;
+ x *= 0x846ca68bu;
+ x ^= x >> 16;
+ return float(x & 0xffffu) / 65535.0;
+}
+
+// The same seven procedural ASCII marks as ui.frag.glsl and the macOS Metal
+// panel kernel. Native attachments are content in the panel, so they should
+// not switch to a smaller two-symbol animation when the glyph grid over them
+// is using `. : + * # % @`.
+float asciiGlyph(vec2 uv, int glyph) {
+ vec2 p = uv * 2.0 - 1.0;
+ float thin = 0.12;
+ float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - vec2(0.0, 0.45)));
+ float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y));
+ float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x));
+ if (glyph == 0) return dotShape;
+ if (glyph == 1) return max(dotShape, 1.0 - smoothstep(0.10, 0.20, length(p + vec2(0.0, 0.45))));
+ if (glyph == 2) return max(barH, barV);
+ if (glyph == 3) {
+ float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y));
+ float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y));
+ return max(max(d0, d1), max(barH, barV));
+ }
+ if (glyph == 4) {
+ float rails = max(
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)),
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34))
+ );
+ return rails;
+ }
+ float ring = 1.0 - smoothstep(thin, thin + 0.08, abs(length(p * vec2(0.8, 1.0)) - 0.48));
+ return max(ring, glyph == 6 ? dotShape : 0.0);
+}
+
+void main() {
+ vec4 source = texture(u_image, v_uv);
+ uint effect = v_effect.x & 0x7fffffffu;
+ bool oldLayer = (v_effect.x & 0x80000000u) != 0u;
+ float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0);
+ uvec2 dimensions = max(
+ uvec2(v_effect.w & 0xffffu, v_effect.w >> 16),
+ uvec2(1u));
+ uvec2 cellCoord = min(
+ uvec2(floor(max(v_panel_uv, vec2(0.0)) * vec2(dimensions))),
+ dimensions - 1u);
+ float noise = cellNoise(v_effect.z, cellCoord.x, cellCoord.y);
+ if (effect == 3u) {
+ bool reveal = progress >= 1.0 || (progress > 0.0 && noise < progress);
+ if (oldLayer == reveal) discard;
+ o_col = source;
+ return;
+ }
+ if (effect == 4u) {
+ float diagonal = float((cellCoord.x + cellCoord.y * 2u) % 17u) / 17.0;
+ float threshold = noise * 0.72 + diagonal * 0.28;
+ bool reveal = progress >= 1.0 || (progress > 0.0 && progress >= threshold);
+ bool wave = !reveal && progress > 0.0 && progress + 0.22 >= threshold;
+ if (oldLayer) {
+ if (wave || reveal) discard;
+ o_col = source;
+ return;
+ }
+ if (reveal) {
+ o_col = source;
+ return;
+ }
+ if (!wave) discard;
+ vec2 cellUV = fract(v_panel_uv * vec2(dimensions));
+ int glyph = min(int(floor(noise * 7.0)), 6);
+ float mark = asciiGlyph(cellUV, glyph);
+ float luma = dot(source.rgb, vec3(0.2126, 0.7152, 0.0722));
+ vec3 paper = source.rgb * 0.10;
+ // Preserve source colour when it already separates from the dimmed
+ // paper. Solid black pixels have no such separation, so derive a
+ // readable mark from their own luminance instead of letting the ASCII
+ // phase disappear on dark images/PDF regions.
+ vec3 contrastInk = luma > 0.5 ? vec3(0.0) : vec3(1.0);
+ float sourceContrast = smoothstep(0.025, 0.14, length(source.rgb - paper));
+ vec3 ink = mix(contrastInk, source.rgb, sourceContrast);
+ vec3 dimInk = mix(paper, ink, 0.62);
+ o_col = vec4(mix(paper, dimInk, mark), source.a);
+ return;
+ }
+ o_col = source;
+}
diff --git a/shaders/prebuilt/image.frag.spv b/shaders/prebuilt/image.frag.spv
index 84a174ad..c3d87e58 100644
--- a/shaders/prebuilt/image.frag.spv
+++ b/shaders/prebuilt/image.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/image.vert.glsl b/shaders/prebuilt/image.vert.glsl
new file mode 100644
index 00000000..04196ce4
--- /dev/null
+++ b/shaders/prebuilt/image.vert.glsl
@@ -0,0 +1,49 @@
+#version 450
+
+// One instance per Surface image attachment. The CPU has already converted
+// the pane's cell rectangle and the aspect-preserving contain fit to NDC.
+layout(location = 0) in vec4 a_rect; // x0, y0, x1, y1
+layout(location = 1) in vec4 a_uv_rect; // u0, v0, u1, v1
+layout(location = 2) in vec4 a_panel_rect;
+layout(location = 3) in vec4 a_present_rect;
+layout(location = 4) in uvec4 a_effect;
+
+layout(location = 0) out vec2 v_uv;
+layout(location = 1) out vec2 v_panel_uv;
+layout(location = 2) flat out uvec4 v_effect;
+
+void main() {
+ vec2 pos;
+ int corner = gl_VertexIndex % 6;
+ if (corner == 0) {
+ pos = vec2(a_rect.x, a_rect.y);
+ v_uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ } else if (corner == 1) {
+ pos = vec2(a_rect.z, a_rect.y);
+ v_uv = vec2(a_uv_rect.z, a_uv_rect.y);
+ } else if (corner == 2) {
+ pos = vec2(a_rect.z, a_rect.w);
+ v_uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ } else if (corner == 3) {
+ pos = vec2(a_rect.x, a_rect.y);
+ v_uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ } else if (corner == 4) {
+ pos = vec2(a_rect.z, a_rect.w);
+ v_uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ } else {
+ pos = vec2(a_rect.x, a_rect.w);
+ v_uv = vec2(a_uv_rect.x, a_uv_rect.w);
+ }
+ v_panel_uv = v_uv;
+ uint effect = a_effect.x & 0x7fffffffu;
+ if (effect != 0u) {
+ vec2 panel_size = a_panel_rect.zw - a_panel_rect.xy;
+ panel_size.x = abs(panel_size.x) < 0.000001 ? 0.000001 : panel_size.x;
+ panel_size.y = abs(panel_size.y) < 0.000001 ? -0.000001 : panel_size.y;
+ v_panel_uv = (pos - a_panel_rect.xy) / panel_size;
+ if (effect == 1u || effect == 2u || effect == 5u)
+ pos = mix(a_present_rect.xy, a_present_rect.zw, v_panel_uv);
+ }
+ v_effect = a_effect;
+ gl_Position = vec4(pos, 0.0, 1.0);
+}
diff --git a/shaders/prebuilt/image.vert.spv b/shaders/prebuilt/image.vert.spv
index 1105bae2..35165d69 100644
--- a/shaders/prebuilt/image.vert.spv
+++ b/shaders/prebuilt/image.vert.spv
Binary files differ
diff --git a/shaders/prebuilt/overlay.frag.glsl b/shaders/prebuilt/overlay.frag.glsl
new file mode 100644
index 00000000..86c0188b
--- /dev/null
+++ b/shaders/prebuilt/overlay.frag.glsl
@@ -0,0 +1,9 @@
+#version 450
+
+layout(location = 0) in vec4 v_color;
+
+layout(location = 0) out vec4 o_col;
+
+void main() {
+ o_col = v_color;
+}
diff --git a/shaders/overlay.vert.es.glsl b/shaders/prebuilt/overlay.vert.glsl
index edea124d..5f6be593 100644
--- a/shaders/overlay.vert.es.glsl
+++ b/shaders/prebuilt/overlay.vert.glsl
@@ -1,11 +1,9 @@
-#version 300 es
-
-precision mediump float;
+#version 450
layout(location = 0) in vec2 a_pos;
layout(location = 1) in vec4 a_color;
-out vec4 v_color;
+layout(location = 0) out vec4 v_color;
void main() {
v_color = a_color;
diff --git a/shaders/prebuilt/ui.frag.glsl b/shaders/prebuilt/ui.frag.glsl
new file mode 100644
index 00000000..2113f6ae
--- /dev/null
+++ b/shaders/prebuilt/ui.frag.glsl
@@ -0,0 +1,110 @@
+#version 450
+
+// Sample the glyph atlas (R = coverage 0..1) and mix the cell's bg up to its fg
+// by that coverage. Fully opaque: the swapchain is SDR, no blending needed.
+
+layout(set = 2, binding = 0) uniform sampler2D u_atlas;
+
+layout(location = 0) in vec2 v_uv;
+layout(location = 1) in vec3 v_fg;
+layout(location = 2) in vec3 v_bg;
+layout(location = 3) in vec2 v_cell_uv;
+layout(location = 4) flat in uvec4 v_effect;
+
+layout(location = 0) out vec4 o_col;
+
+float cellNoise(uint serial, uint col, uint row) {
+ uint x = serial ^ (col * 0x9e3779b9u) ^ (row * 0x85ebca6bu);
+ x ^= x >> 16;
+ x *= 0x7feb352du;
+ x ^= x >> 15;
+ x *= 0x846ca68bu;
+ x ^= x >> 16;
+ return float(x & 0xffffu) / 65535.0;
+}
+
+float asciiGlyph(vec2 uv, int glyph) {
+ vec2 p = uv * 2.0 - 1.0;
+ float thin = 0.12;
+ float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - vec2(0.0, 0.45)));
+ float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y));
+ float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x));
+ if (glyph == 0) return dotShape;
+ if (glyph == 1) return max(dotShape, 1.0 - smoothstep(0.10, 0.20, length(p + vec2(0.0, 0.45))));
+ if (glyph == 2) return max(barH, barV);
+ if (glyph == 3) {
+ float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y));
+ float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y));
+ return max(max(d0, d1), max(barH, barV));
+ }
+ if (glyph == 4) {
+ float rails = max(
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)),
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34))
+ );
+ return rails;
+ }
+ float ring = 1.0 - smoothstep(thin, thin + 0.08, abs(length(p * vec2(0.8, 1.0)) - 0.48));
+ return max(ring, glyph == 6 ? dotShape : 0.0);
+}
+
+void main() {
+ float a = texture(u_atlas, v_uv).r;
+ bool oldLayer = (v_effect.x & 0x80000000u) != 0u;
+ uint effect = v_effect.x & 0x7fffffffu;
+ float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0);
+ uint cellCol = v_effect.w & 0xffffu;
+ uint cellRow = v_effect.w >> 16;
+ float noise = cellNoise(v_effect.z, cellCol, cellRow);
+
+ vec3 cell = mix(v_bg, v_fg, a);
+
+ // 3 = data dissolve. Changed cells retain the frozen old cell until their
+ // stable threshold, then atomically become the new cell. Exact endpoints
+ // mirror panel_animation.dissolveRevealed, including the zero hash.
+ if (effect == 3u) {
+ bool reveal = progress >= 1.0 || (progress > 0.0 && noise < progress);
+ if (oldLayer ? reveal : !reveal) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+
+ // 4 = ASCII materialization. A diagonal/noise threshold matches the TTY
+ // compositor's idea: cells briefly become punctuation, then their real
+ // atlas glyph arrives. The procedural marks keep the GUI implementation
+ // entirely shader-side.
+ if (effect == 4u) {
+ if (progress <= 0.0) {
+ if (!oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+ if (progress >= 1.0) {
+ if (oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+ float diagonal = float((cellCol + cellRow * 2u) % 17u) / 17.0;
+ float threshold = noise * 0.72 + diagonal * 0.28;
+ if (progress >= threshold) {
+ if (oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+ if (progress + 0.22 >= threshold) {
+ if (oldLayer) discard;
+ int glyph = int(floor(noise * 7.0));
+ float ink = asciiGlyph(v_cell_uv, min(glyph, 6));
+ vec3 dimInk = mix(v_bg, v_fg, 0.62);
+ o_col = vec4(mix(v_bg, dimInk, ink), 1.0);
+ return;
+ }
+ if (!oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+
+ // FreeType's hinted grayscale bitmap is already the intended coverage.
+ // Preserve it exactly; weight-changing gamma hacks blur small stems.
+ o_col = vec4(cell, 1.0);
+}
diff --git a/shaders/prebuilt/ui.frag.spv b/shaders/prebuilt/ui.frag.spv
index 8d57242a..b97495b3 100644
--- a/shaders/prebuilt/ui.frag.spv
+++ b/shaders/prebuilt/ui.frag.spv
Binary files differ
diff --git a/shaders/prebuilt/ui.vert.glsl b/shaders/prebuilt/ui.vert.glsl
new file mode 100644
index 00000000..08627dc0
--- /dev/null
+++ b/shaders/prebuilt/ui.vert.glsl
@@ -0,0 +1,72 @@
+#version 450
+
+// Terminal cell renderer. The CPU uploads one instance per visible cell; this
+// shader expands each instance into a two-triangle quad with gl_VertexIndex.
+// The fragment shader mixes fg/bg by the atlas's alpha (a glyph mask in the R
+// channel), so background fills and glyph foregrounds draw in a single pass.
+
+layout(location = 0) in vec4 a_rect; // x0, y0, x1, y1
+layout(location = 1) in vec4 a_uv_rect; // u0, v0, u1, v1
+layout(location = 2) in vec3 a_fg;
+layout(location = 3) in vec3 a_bg;
+// Final and presented panel rectangles in NDC. The CPU still uploads logical
+// final cells; this shader is the only place transition geometry is applied.
+layout(location = 4) in vec4 a_panel_rect;
+layout(location = 5) in vec4 a_present_rect;
+// effect id, eased-progress float bits, pane serial, local row/column packed
+// into one uint. The high effect bit marks the frozen-old cell layer; integer
+// transport preserves the exact cross-backend hash.
+layout(location = 6) in uvec4 a_effect;
+
+layout(location = 0) out vec2 v_uv;
+layout(location = 1) out vec3 v_fg;
+layout(location = 2) out vec3 v_bg;
+layout(location = 3) out vec2 v_cell_uv;
+layout(location = 4) flat out uvec4 v_effect;
+
+void main() {
+ vec2 pos;
+ vec2 uv;
+ int corner = gl_VertexIndex % 6;
+ if (corner == 0) {
+ pos = vec2(a_rect.x, a_rect.y);
+ uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ v_cell_uv = vec2(0.0, 0.0);
+ } else if (corner == 1) {
+ pos = vec2(a_rect.z, a_rect.y);
+ uv = vec2(a_uv_rect.z, a_uv_rect.y);
+ v_cell_uv = vec2(1.0, 0.0);
+ } else if (corner == 2) {
+ pos = vec2(a_rect.z, a_rect.w);
+ uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ v_cell_uv = vec2(1.0, 1.0);
+ } else if (corner == 3) {
+ pos = vec2(a_rect.x, a_rect.y);
+ uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ v_cell_uv = vec2(0.0, 0.0);
+ } else if (corner == 4) {
+ pos = vec2(a_rect.z, a_rect.w);
+ uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ v_cell_uv = vec2(1.0, 1.0);
+ } else {
+ pos = vec2(a_rect.x, a_rect.w);
+ uv = vec2(a_uv_rect.x, a_uv_rect.w);
+ v_cell_uv = vec2(0.0, 1.0);
+ }
+
+ uint effect = a_effect.x & 0x7fffffffu;
+ if (effect == 1u || effect == 2u || effect == 5u) {
+ // NDC y points up, so a top-left/bottom-right box has a NEGATIVE y
+ // delta. Preserve that sign or every local y is mirrored/outside.
+ vec2 panel_size = a_panel_rect.zw - a_panel_rect.xy;
+ panel_size.x = abs(panel_size.x) < 0.000001 ? 0.000001 : panel_size.x;
+ panel_size.y = abs(panel_size.y) < 0.000001 ? -0.000001 : panel_size.y;
+ vec2 within = (pos - a_panel_rect.xy) / panel_size;
+ pos = mix(a_present_rect.xy, a_present_rect.zw, within);
+ }
+ v_uv = uv;
+ v_fg = a_fg;
+ v_bg = a_bg;
+ v_effect = a_effect;
+ gl_Position = vec4(pos, 0.0, 1.0);
+}
diff --git a/shaders/prebuilt/ui.vert.spv b/shaders/prebuilt/ui.vert.spv
index 186a91fc..f0d5f653 100644
--- a/shaders/prebuilt/ui.vert.spv
+++ b/shaders/prebuilt/ui.vert.spv
Binary files differ
diff --git a/shaders/ui.frag.es.glsl b/shaders/ui.frag.es.glsl
deleted file mode 100644
index 736e48f5..00000000
--- a/shaders/ui.frag.es.glsl
+++ /dev/null
@@ -1,16 +0,0 @@
-#version 300 es
-
-precision mediump float;
-
-uniform sampler2D u_atlas;
-
-in vec2 v_uv;
-in vec3 v_fg;
-in vec3 v_bg;
-
-out vec4 o_col;
-
-void main() {
- float a = texture(u_atlas, v_uv).r;
- o_col = vec4(mix(v_bg, v_fg, a), 1.0);
-}
diff --git a/shaders/ui.frag.glsl b/shaders/ui.frag.glsl
index d00a0601..2113f6ae 100644
--- a/shaders/ui.frag.glsl
+++ b/shaders/ui.frag.glsl
@@ -8,13 +8,103 @@ layout(set = 2, binding = 0) uniform sampler2D u_atlas;
layout(location = 0) in vec2 v_uv;
layout(location = 1) in vec3 v_fg;
layout(location = 2) in vec3 v_bg;
+layout(location = 3) in vec2 v_cell_uv;
+layout(location = 4) flat in uvec4 v_effect;
layout(location = 0) out vec4 o_col;
+float cellNoise(uint serial, uint col, uint row) {
+ uint x = serial ^ (col * 0x9e3779b9u) ^ (row * 0x85ebca6bu);
+ x ^= x >> 16;
+ x *= 0x7feb352du;
+ x ^= x >> 15;
+ x *= 0x846ca68bu;
+ x ^= x >> 16;
+ return float(x & 0xffffu) / 65535.0;
+}
+
+float asciiGlyph(vec2 uv, int glyph) {
+ vec2 p = uv * 2.0 - 1.0;
+ float thin = 0.12;
+ float dotShape = 1.0 - smoothstep(0.10, 0.20, length(p - vec2(0.0, 0.45)));
+ float barH = 1.0 - smoothstep(thin, thin + 0.08, abs(p.y));
+ float barV = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x));
+ if (glyph == 0) return dotShape;
+ if (glyph == 1) return max(dotShape, 1.0 - smoothstep(0.10, 0.20, length(p + vec2(0.0, 0.45))));
+ if (glyph == 2) return max(barH, barV);
+ if (glyph == 3) {
+ float d0 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x - p.y));
+ float d1 = 1.0 - smoothstep(thin, thin + 0.08, abs(p.x + p.y));
+ return max(max(d0, d1), max(barH, barV));
+ }
+ if (glyph == 4) {
+ float rails = max(
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.x) - 0.34)),
+ 1.0 - smoothstep(thin, thin + 0.08, abs(abs(p.y) - 0.34))
+ );
+ return rails;
+ }
+ float ring = 1.0 - smoothstep(thin, thin + 0.08, abs(length(p * vec2(0.8, 1.0)) - 0.48));
+ return max(ring, glyph == 6 ? dotShape : 0.0);
+}
+
void main() {
float a = texture(u_atlas, v_uv).r;
+ bool oldLayer = (v_effect.x & 0x80000000u) != 0u;
+ uint effect = v_effect.x & 0x7fffffffu;
+ float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0);
+ uint cellCol = v_effect.w & 0xffffu;
+ uint cellRow = v_effect.w >> 16;
+ float noise = cellNoise(v_effect.z, cellCol, cellRow);
+
+ vec3 cell = mix(v_bg, v_fg, a);
+
+ // 3 = data dissolve. Changed cells retain the frozen old cell until their
+ // stable threshold, then atomically become the new cell. Exact endpoints
+ // mirror panel_animation.dissolveRevealed, including the zero hash.
+ if (effect == 3u) {
+ bool reveal = progress >= 1.0 || (progress > 0.0 && noise < progress);
+ if (oldLayer ? reveal : !reveal) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+
+ // 4 = ASCII materialization. A diagonal/noise threshold matches the TTY
+ // compositor's idea: cells briefly become punctuation, then their real
+ // atlas glyph arrives. The procedural marks keep the GUI implementation
+ // entirely shader-side.
+ if (effect == 4u) {
+ if (progress <= 0.0) {
+ if (!oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+ if (progress >= 1.0) {
+ if (oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+ float diagonal = float((cellCol + cellRow * 2u) % 17u) / 17.0;
+ float threshold = noise * 0.72 + diagonal * 0.28;
+ if (progress >= threshold) {
+ if (oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+ if (progress + 0.22 >= threshold) {
+ if (oldLayer) discard;
+ int glyph = int(floor(noise * 7.0));
+ float ink = asciiGlyph(v_cell_uv, min(glyph, 6));
+ vec3 dimInk = mix(v_bg, v_fg, 0.62);
+ o_col = vec4(mix(v_bg, dimInk, ink), 1.0);
+ return;
+ }
+ if (!oldLayer) discard;
+ o_col = vec4(cell, 1.0);
+ return;
+ }
+
// FreeType's hinted grayscale bitmap is already the intended coverage.
// Preserve it exactly; weight-changing gamma hacks blur small stems.
- vec3 col = mix(v_bg, v_fg, a);
- o_col = vec4(col, 1.0);
+ o_col = vec4(cell, 1.0);
}
diff --git a/shaders/ui.vert.es.glsl b/shaders/ui.vert.es.glsl
deleted file mode 100644
index 271524be..00000000
--- a/shaders/ui.vert.es.glsl
+++ /dev/null
@@ -1,41 +0,0 @@
-#version 300 es
-
-precision mediump float;
-
-layout(location = 0) in vec4 a_rect;
-layout(location = 1) in vec4 a_uv_rect;
-layout(location = 2) in vec3 a_fg;
-layout(location = 3) in vec3 a_bg;
-
-out vec2 v_uv;
-out vec3 v_fg;
-out vec3 v_bg;
-
-void main() {
- vec2 pos;
- vec2 uv;
- int corner = gl_VertexID % 6;
- if (corner == 0) {
- pos = vec2(a_rect.x, a_rect.y);
- uv = vec2(a_uv_rect.x, a_uv_rect.y);
- } else if (corner == 1) {
- pos = vec2(a_rect.z, a_rect.y);
- uv = vec2(a_uv_rect.z, a_uv_rect.y);
- } else if (corner == 2) {
- pos = vec2(a_rect.z, a_rect.w);
- uv = vec2(a_uv_rect.z, a_uv_rect.w);
- } else if (corner == 3) {
- pos = vec2(a_rect.x, a_rect.y);
- uv = vec2(a_uv_rect.x, a_uv_rect.y);
- } else if (corner == 4) {
- pos = vec2(a_rect.z, a_rect.w);
- uv = vec2(a_uv_rect.z, a_uv_rect.w);
- } else {
- pos = vec2(a_rect.x, a_rect.w);
- uv = vec2(a_uv_rect.x, a_uv_rect.w);
- }
- v_uv = uv;
- v_fg = a_fg;
- v_bg = a_bg;
- gl_Position = vec4(pos, 0.0, 1.0);
-}
diff --git a/shaders/ui.vert.glsl b/shaders/ui.vert.glsl
index c7567a8f..08627dc0 100644
--- a/shaders/ui.vert.glsl
+++ b/shaders/ui.vert.glsl
@@ -9,10 +9,20 @@ layout(location = 0) in vec4 a_rect; // x0, y0, x1, y1
layout(location = 1) in vec4 a_uv_rect; // u0, v0, u1, v1
layout(location = 2) in vec3 a_fg;
layout(location = 3) in vec3 a_bg;
+// Final and presented panel rectangles in NDC. The CPU still uploads logical
+// final cells; this shader is the only place transition geometry is applied.
+layout(location = 4) in vec4 a_panel_rect;
+layout(location = 5) in vec4 a_present_rect;
+// effect id, eased-progress float bits, pane serial, local row/column packed
+// into one uint. The high effect bit marks the frozen-old cell layer; integer
+// transport preserves the exact cross-backend hash.
+layout(location = 6) in uvec4 a_effect;
layout(location = 0) out vec2 v_uv;
layout(location = 1) out vec3 v_fg;
layout(location = 2) out vec3 v_bg;
+layout(location = 3) out vec2 v_cell_uv;
+layout(location = 4) flat out uvec4 v_effect;
void main() {
vec2 pos;
@@ -21,24 +31,42 @@ void main() {
if (corner == 0) {
pos = vec2(a_rect.x, a_rect.y);
uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ v_cell_uv = vec2(0.0, 0.0);
} else if (corner == 1) {
pos = vec2(a_rect.z, a_rect.y);
uv = vec2(a_uv_rect.z, a_uv_rect.y);
+ v_cell_uv = vec2(1.0, 0.0);
} else if (corner == 2) {
pos = vec2(a_rect.z, a_rect.w);
uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ v_cell_uv = vec2(1.0, 1.0);
} else if (corner == 3) {
pos = vec2(a_rect.x, a_rect.y);
uv = vec2(a_uv_rect.x, a_uv_rect.y);
+ v_cell_uv = vec2(0.0, 0.0);
} else if (corner == 4) {
pos = vec2(a_rect.z, a_rect.w);
uv = vec2(a_uv_rect.z, a_uv_rect.w);
+ v_cell_uv = vec2(1.0, 1.0);
} else {
pos = vec2(a_rect.x, a_rect.w);
uv = vec2(a_uv_rect.x, a_uv_rect.w);
+ v_cell_uv = vec2(0.0, 1.0);
+ }
+
+ uint effect = a_effect.x & 0x7fffffffu;
+ if (effect == 1u || effect == 2u || effect == 5u) {
+ // NDC y points up, so a top-left/bottom-right box has a NEGATIVE y
+ // delta. Preserve that sign or every local y is mirrored/outside.
+ vec2 panel_size = a_panel_rect.zw - a_panel_rect.xy;
+ panel_size.x = abs(panel_size.x) < 0.000001 ? 0.000001 : panel_size.x;
+ panel_size.y = abs(panel_size.y) < 0.000001 ? -0.000001 : panel_size.y;
+ vec2 within = (pos - a_panel_rect.xy) / panel_size;
+ pos = mix(a_present_rect.xy, a_present_rect.zw, within);
}
v_uv = uv;
v_fg = a_fg;
v_bg = a_bg;
+ v_effect = a_effect;
gl_Position = vec4(pos, 0.0, 1.0);
}