summaryrefslogtreecommitdiff
path: root/shaders/crt.ci.metal
blob: 9159af1852942f0d64f0de75fa4b12946af6c39b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
// Native macOS panel and scene postprocess. Panel kernels consume the core's
// plain tracks; the final full-window kernel is the CRT. Panel easing/noise
// stays in step with panel_animation.zig.
//
// The three entry points are `extern "C" [[stitchable]]`. Both halves are
// load-bearing: this source is compiled at runtime by
// CIKernel.kernels(withMetalString:), which looks for stitchable functions and
// fails the whole compile with "cannot find a valid stitchable Metal function
// in the source" if it finds none — leaving the view with no postprocessor and
// every effect silently off.
#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;
}

float4 pardesSceneSample(
    coreimage::sampler source,
    float2 origin,
    float2 size,
    float2 uv
) {
    return coreimage::unpremultiply(
        source.sample(source.transform(origin + uv * size)));
}

float3 pardesSceneLinear(
    coreimage::sampler source,
    float2 origin,
    float2 size,
    float2 uv
) {
    return coreimage::srgb_to_linear(pardesSceneSample(source, origin, size, uv).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;
}

// Character effects (ASCII byte walks, glyph motion, per-cell locks) are
// composed in Pardes core, so this kernel never eases them: it clips their
// panel batch and draws the finished glyphs.
bool pardesComposedInCore(float effect) {
    return effect == 4.0 || effect >= 6.0;
}

float pardesPanelProgress(float effect, float frame) {
    float frames = effect == 1.0 ? 12.0 :
        effect == 2.0 ? 14.0 :
        effect == 3.0 ? 10.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; // core-composed cells need no progress; retained for ABI completeness
}

// 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" [[stitchable]] 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" [[stitchable]] 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));
    // ASCII bytes were already composed in Pardes core before CoreText built
    // `current`. Pixel attachments have no character value and intentionally
    // pass through unchanged, so this kernel only clips the pane batch.
    if (pardesComposedInCore(effect)) {
        if (!pardesInside(coord, target)) return underneath;
        return current.sample(current.transform(coord));
    }

    // Dissolve stays at canonical geometry. Unchanged cells bypass the effect
    // completely; changed cells retain the exact old raster until their
    // deterministic threshold is crossed.
    if (effect == 3.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);
        return noise < progress ? newPixel : oldPixel;
    }

    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" [[stitchable]] float4 pardesScene(
    coreimage::sampler source,
    float timeSeconds,
    float frame,
    float crtOn,
    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 = pardesSceneSample(source, origin, size, uv);
    float3 scene = center.rgb;

    // Without the CRT the scene passes through, 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);
    float3 blueSample = pardesSceneLinear(source, origin, size, uv - radialChroma);
    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.
        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));
}