diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-16 15:49:12 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-18 23:44:42 -0300 |
| commit | 1551e409c31992437cb2fa864f576d45c8433801 (patch) | |
| tree | e2fae8451f87b735a1360c7c2e383fdc40165789 /shaders | |
| parent | be2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff) | |
| download | pardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz pardes-1551e409c31992437cb2fa864f576d45c8433801.zip | |
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
Diffstat (limited to 'shaders')
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 Binary files differindex 326aa534..057f0e06 100644 --- a/shaders/prebuilt/crt.frag.spv +++ b/shaders/prebuilt/crt.frag.spv 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 Binary files differindex 84a174ad..c3d87e58 100644 --- a/shaders/prebuilt/image.frag.spv +++ b/shaders/prebuilt/image.frag.spv 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 Binary files differindex 1105bae2..35165d69 100644 --- a/shaders/prebuilt/image.vert.spv +++ b/shaders/prebuilt/image.vert.spv 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 Binary files differindex 8d57242a..b97495b3 100644 --- a/shaders/prebuilt/ui.frag.spv +++ b/shaders/prebuilt/ui.frag.spv 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 Binary files differindex 186a91fc..f0d5f653 100644 --- a/shaders/prebuilt/ui.vert.spv +++ b/shaders/prebuilt/ui.vert.spv 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); } |
