// Shaders — all MSL source, compiled at runtime (device.makeLibrary(source:)),
// so no Xcode `metal` build-time compiler is required. Grows across tasks
// (scene pass now; bloom + CRT composite added later).
//
// Uniform structs here MUST match the Swift mirrors in Scene.swift / Renderer.swift
// field-for-field. We use only float4x4 / float4 (16-byte aligned) to avoid any
// struct-layout ambiguity between Swift and MSL.
enum Shaders {
static let source = """
#include <metal_stdlib>
using namespace metal;
// ---- shared structs ----------------------------------------------------
struct SceneUniforms {
float4x4 mvp;
float4x4 modelView;
float4 color; // rgb, a = opacity
float4 params; // x=fogDensity, y=pointSizeWorld, z=pointScale, w=isPoint
};
struct PostUniforms {
float4 p0; // exposure, bloomStrength, chromaOffset, scanOpacity
float4 p1; // scanDriftY(px), vignetteInner, vignetteStrength, time
float4 p2; // hTearAmount, hTearBandY, brightness, scanPulse (glitch)
float4 p3; // vRoll, blackFrame, skewX, staticAmt (glitch)
float4 p4; // collapse, flash, curvature, bezelSharpness
float4 p5; // maskStrength, maskCellPx, grain, flicker
float4 p6; // ntsc, halation, _, _
};
inline float hash21(float2 p) {
return fract(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453);
}
// ACES filmic tonemap (Narkowicz approximation).
inline float3 aces(float3 x) {
const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
return saturate((x * (a * x + b)) / (x * (c * x + d) + e));
}
// ---- scene pass (lines + points, unlit accent, FogExp2) ----------------
struct SceneVOut {
float4 pos [[position]];
float pointSize [[point_size]];
float3 color;
float opacity;
float viewZ;
float fogDensity;
};
vertex SceneVOut scene_v(uint vid [[vertex_id]],
device const packed_float3* positions [[buffer(0)]],
constant SceneUniforms& u [[buffer(1)]]) {
float3 p = float3(positions[vid]);
float4 viewPos = u.modelView * float4(p, 1.0);
SceneVOut o;
o.pos = u.mvp * float4(p, 1.0);
o.viewZ = -viewPos.z;
o.color = u.color.rgb;
o.opacity = u.color.a;
o.fogDensity = u.params.x;
if (u.params.w > 0.5) {
float ps = u.params.y * u.params.z / max(o.viewZ, 0.001); // size attenuation
o.pointSize = clamp(ps, 1.0, 48.0);
} else {
o.pointSize = 1.0;
}
return o;
}
fragment float4 scene_f(SceneVOut in [[stage_in]]) {
float fd = in.viewZ;
float fog = 1.0 - exp(-in.fogDensity * in.fogDensity * fd * fd); // FogExp2
float3 col = in.color * in.opacity;
col = mix(col, float3(0.0), fog); // fog toward black
return float4(col, 1.0);
}
// ---- fullscreen triangle (uv with origin top-left, Metal texture coords) ----
struct FSQOut { float4 pos [[position]]; float2 uv; };
vertex FSQOut fsq_v(uint vid [[vertex_id]]) {
float2 v[3] = { float2(-1, -1), float2(-1, 3), float2(3, -1) };
FSQOut o;
o.pos = float4(v[vid], 0, 1);
o.uv = float2(v[vid].x * 0.5 + 0.5, -v[vid].y * 0.5 + 0.5);
return o;
}
// ---- phosphor trails (feedback: max of scene vs decayed previous frame) --
fragment float4 trails_f(FSQOut in [[stage_in]],
texture2d<float> sceneTex [[texture(0)]],
texture2d<float> persistTex [[texture(1)]],
constant float& decay [[buffer(0)]]) {
constexpr sampler s(filter::linear, address::clamp_to_edge);
float3 sc = sceneTex.sample(s, in.uv).rgb;
float3 pr = persistTex.sample(s, in.uv).rgb;
return float4(max(sc, pr * decay), 1.0);
}
// ---- bloom threshold (keep bright pixels; MPS blurs the result) --------
fragment float4 threshold_f(FSQOut in [[stage_in]],
texture2d<float> sceneTex [[texture(0)]],
constant PostUniforms& u [[buffer(0)]]) {
constexpr sampler s(filter::linear, address::clamp_to_edge);
float3 c = sceneTex.sample(s, in.uv).rgb;
float luma = dot(c, float3(0.299, 0.587, 0.114));
float t = u.p0.x; // threshold
float soft = smoothstep(t, t + 0.15, luma);
return float4(c * soft, 1.0);
}
// ---- composite: scene + bloom, then the CRT look ----------------------
// p0: exposure, bloomStrength, chromaOffset, scanOpacity
// p1: scanDriftY(px), vignetteInner, vignetteStrength, time
// p2: hTearAmount, hTearBandY, brightness, scanPulse
fragment float4 composite_f(FSQOut in [[stage_in]],
texture2d<float> sceneTex [[texture(0)]],
texture2d<float> bloomTex [[texture(1)]],
constant PostUniforms& u [[buffer(0)]]) {
constexpr sampler s(filter::linear, address::clamp_to_edge);
float bs = u.p0.y;
float2 uv = in.uv;
float sy = in.uv.y; // original screen y (for line/scan)
// CRT screen curvature (radial barrel): warps the SAMPLING uv only;
// scanlines/vignette/static stay screen-space (they use in.pos/in.uv).
float bezel = 1.0; bool offGlass = false;
float curv = u.p4.z;
if (curv > 0.0001) {
float aspect = float(sceneTex.get_width()) / float(sceneTex.get_height());
float2 cc = uv * 2.0 - 1.0; cc.x *= aspect;
float2 warp = cc * (1.0 + curv * dot(cc, cc));
warp.x /= aspect;
uv = warp * 0.5 + 0.5;
float bp = max(0.4, u.p4.w);
float2 fw = smoothstep(0.0, 0.02 * bp, uv) * smoothstep(0.0, 0.02 * bp, 1.0 - uv);
bezel = fw.x * fw.y;
offGlass = (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0);
}
// geometry glitches: h-tear band, v-roll, wave skew
float tear = u.p2.x;
if (tear != 0.0) { uv.x += tear * exp(-pow((uv.y - u.p2.y) * 6.0, 2.0)); }
uv.y = fract(uv.y + u.p3.x); // v-roll (wraps)
uv.x += (uv.y - 0.5) * u.p3.z; // wave skew
// crash collapse: squeeze the image vertically toward a center line
float collapse = u.p4.x;
if (collapse > 0.0) {
float sq = 1.0 - collapse * 0.997;
uv.y = 0.5 + (uv.y - 0.5) / sq;
}
bool outside = (collapse > 0.0) && (uv.y < 0.0 || uv.y > 1.0);
// chromatic aberration: radial RGB split about screen center
float off = u.p0.z;
float2 dr = (uv - 0.5) * off;
float3 col;
col.r = sceneTex.sample(s, uv - dr).r + bloomTex.sample(s, uv - dr).r * bs;
col.g = sceneTex.sample(s, uv).g + bloomTex.sample(s, uv).g * bs;
col.b = sceneTex.sample(s, uv + dr).b + bloomTex.sample(s, uv + dr).b * bs;
if (outside) col = float3(0.0);
// halation: warm phosphor glow bleed around bright areas (extra warm bloom)
col += bloomTex.sample(s, uv).rgb * u.p6.y * 3.0 * float3(1.0, 0.55, 0.25);
col = aces(col * u.p0.x); // exposure + tonemap
col *= u.p2.z; // brightness flicker (default 1)
col *= (1.0 + u.p4.y); // crash reboot flash
// scanlines: sinusoidal darkening, 4px period, drifting; + glitch pulse
float scanAmt = u.p0.w * (1.0 + u.p2.w);
float s2 = 0.5 + 0.5 * sin((in.pos.y + u.p1.x) * 3.14159265 / 2.0);
col *= (1.0 - scanAmt * s2);
// vignette: radial smoothstep darkening toward the edges
float r = length(in.uv - 0.5);
float vig = smoothstep(u.p1.y, 0.72, r);
col *= (1.0 - vig * 0.72 * u.p1.z);
// static burst (screen-blended hash noise)
if (u.p3.w > 0.0) {
float n = hash21(in.uv * 800.0 + u.p1.w);
col = 1.0 - (1.0 - col) * (1.0 - n * u.p3.w);
}
// crash power-off line (bright pinch at center while collapsing)
if (collapse > 0.0) {
float line = exp(-pow((sy - 0.5) * 220.0, 2.0)) * collapse;
col += float3(0.78, 0.95, 0.84) * line; // CRT phosphor-white pinch
}
// NTSC composite shimmer: dot-crawl chroma wiggle + slight luma smear
float ntsc = u.p6.x;
if (ntsc > 0.0) {
float crawl = sin(in.pos.y * 1.7 + in.pos.x * 0.9 + u.p1.w * 18.0) * ntsc * 0.11;
col.r += crawl; col.b -= crawl;
float lum = dot(col, float3(0.299, 0.587, 0.114));
col = mix(col, float3(lum), ntsc * 0.26);
}
// aperture grille (energy-preserving cos lobes) — RGB phosphor stripes
float maskStr = u.p5.x;
if (maskStr > 0.001) {
float cell = max(2.0, u.p5.y);
float tx = fract(in.pos.x / cell);
float3 m = float3(0.5 + 0.5 * cos(6.2831853 * tx),
0.5 + 0.5 * cos(6.2831853 * (tx - 0.33333)),
0.5 + 0.5 * cos(6.2831853 * (tx - 0.66667))) * 2.0; // mean ~1
col *= mix(float3(1.0), m, maskStr);
}
// constant analog grain + gentle brightness shimmer
// 24 Hz filmic cadence (was 60): at 60 the field was reseeded every frame and
// "boiled" at the display refresh rate (visible strobe on 60/120 Hz). A film-rate
// step holds each field ~2-3 frames so it reads as organic grain, not buzz.
float grain = u.p5.z;
if (grain > 0.0) { col += (hash21(in.pos.xy + floor(u.p1.w * 24.0)) - 0.5) * grain; }
float flick = u.p5.w;
if (flick > 0.0) { col *= 1.0 - flick * (0.5 + 0.5 * sin(u.p1.w * 38.0)); }
// CRT glass edge (curvature bezel + outside-glass black)
col *= bezel;
if (offGlass) col = float3(0.0);
// black-frame drop (wins over everything)
if (u.p3.y > 0.5) col = float3(0.0);
return float4(col, 1.0);
}
// ---- text overlay (font-atlas coverage tinted by accent) ----------------
// Vertex buffer is flat floats, 8 per vertex: posX, posY (clip), u, v, r,g,b,a.
struct TextVOut { float4 pos [[position]]; float2 uv; float4 color; };
vertex TextVOut text_v(uint vid [[vertex_id]], device const float* verts [[buffer(0)]]) {
uint b = vid * 8;
TextVOut o;
o.pos = float4(verts[b], verts[b + 1], 0, 1);
o.uv = float2(verts[b + 2], verts[b + 3]);
o.color = float4(verts[b + 4], verts[b + 5], verts[b + 6], verts[b + 7]);
return o;
}
fragment float4 text_f(TextVOut in [[stage_in]], texture2d<float> atlas [[texture(0)]]) {
constexpr sampler s(filter::linear, address::clamp_to_edge);
float cov = atlas.sample(s, in.uv).r;
return float4(in.color.rgb, in.color.a * cov);
}
"""
}