summaryrefslogtreecommitdiff
path: root/src/gui/crt.zig
blob: 2a5ea7e42aea3c76a4fa0a1ed444907195c672f8 (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
//! The Crt builtin's screen geometry, shared between the gui shell's input
//! mapping and its shader: crt.frag.glsl shows the scene point
//! uv' = cc*(1 + barrel*r2) at screen point cc, so a physical mouse position
//! must pass through the SAME forward transform before it becomes a cell.
//! Pure std, so the unit-test step can pin it against the shader formula.

const std = @import("std");

/// MUST match BARREL in shaders/crt.frag.glsl.
pub const barrel: f32 = 0.016;

pub const Point = struct { x: f32, y: f32 };
pub const Effects = struct { crt: bool = false, ripple: bool = false, glitch: bool = false };
pub const Frame = struct { effects: Effects = .{}, time_seconds: f32 = 0 };

fn fract(x: f32) f32 {
    return x - @floor(x);
}

fn smoothstep(lo: f32, hi: f32, value: f32) f32 {
    const t = std.math.clamp((value - lo) / (hi - lo), 0, 1);
    return t * t * (3 - 2 * t);
}

/// Match `sceneUv` in crt.frag.glsl. This is a forward screen→sample mapping:
/// a physical pointer is sent to the logical scene pixel displayed under it.
pub fn mapScene(x: f32, y: f32, w: f32, h: f32, effects: Effects, time_seconds: f32) ?Point {
    const safe_w = @max(1, w);
    const safe_h = @max(1, h);
    const ccx = x / safe_w * 2.0 - 1.0;
    const ccy = y / safe_h * 2.0 - 1.0;
    const s = 1.0 + barrel * (ccx * ccx + ccy * ccy) * @as(f32, @floatFromInt(@intFromBool(effects.crt)));
    var u = ccx * s * 0.5 + 0.5;
    var v = ccy * s * 0.5 + 0.5;
    // The CRT shader paints no scene when this barrel-mapped coordinate lies
    // on or beyond the tube edge. There is no logical edge cell under those
    // black pixels, so input must be inert there rather than clamped.
    if (effects.crt and (u <= 0 or u >= 1 or v <= 0 or v >= 1)) return null;
    if (effects.ripple) {
        const cx = u - 0.5;
        const cy = v - 0.5;
        const radius = @sqrt(cx * cx + cy * cy);
        const envelope = 1.0 - smoothstep(0.04, 0.72, radius);
        const wave = @sin(radius * 42.0 - time_seconds * 5.2);
        const len = @max(radius, 0.00001);
        u += cx / len * wave * envelope * 0.0032;
        v += cy / len * wave * envelope * 0.0032;
    }
    if (effects.glitch) {
        const band = @floor(v * 96.0);
        const tick = @floor(time_seconds * 13.0);
        const seed = noiseAt(band, tick);
        const tear = smoothstep(0.955, 1.0, seed);
        u += (noiseAt(seed, band) - 0.5) * tear * 0.020;
    }
    return .{
        .x = std.math.clamp(u * safe_w, 0, safe_w - 1),
        .y = std.math.clamp(v * safe_h, 0, safe_h - 1),
    };
}

fn noiseAt(x: f32, y: f32) f32 {
    var qx = fract(x * 0.1031);
    var qy = fract(y * 0.1031);
    var qz = fract(x * 0.1031);
    const d = qx * (qy + 33.33) + qy * (qz + 33.33) + qz * (qx + 33.33);
    qx += d;
    qy += d;
    qz += d;
    return fract((qx + qy) * qz);
}

test "map matches the shader's barrel formula" {
    // center is a fixed point
    const center = mapScene(500, 250, 1000, 500, .{ .crt = true }, 0).?;
    try std.testing.expectApproxEqAbs(@as(f32, 500), center.x, 0.001);
    try std.testing.expectApproxEqAbs(@as(f32, 250), center.y, 0.001);
    // Corners barrel beyond the tube and are black/inert in the shader.
    try std.testing.expect(mapScene(0, 0, 1000, 500, .{ .crt = true }, 0) == null);
    try std.testing.expect(mapScene(1000, 500, 1000, 500, .{ .crt = true }, 0) == null);
    // (100,100) of 1000x500: cc=(-0.8,-0.6), r2=1.0, s=1.016 ->
    // cc'=(-0.8128,-0.6096) -> (93.6, 97.6)
    const near_edge = mapScene(100, 100, 1000, 500, .{ .crt = true }, 0).?;
    try std.testing.expectApproxEqAbs(@as(f32, 93.6), near_edge.x, 0.01);
    try std.testing.expectApproxEqAbs(@as(f32, 97.6), near_edge.y, 0.01);
    // (900,250): cc=(0.8,0), r2=0.64, s=1.01024 -> x'=904.096
    const side = mapScene(900, 250, 1000, 500, .{ .crt = true }, 0).?;
    try std.testing.expectApproxEqAbs(@as(f32, 904.096), side.x, 0.01);
}

test "scene mapping composes ripple and deterministic glitch" {
    const plain = mapScene(300, 200, 800, 600, .{}, 2.5).?;
    try std.testing.expectApproxEqAbs(@as(f32, 300), plain.x, 0.001);
    try std.testing.expectApproxEqAbs(@as(f32, 200), plain.y, 0.001);
    const ripple = mapScene(300, 200, 800, 600, .{ .ripple = true }, 2.5).?;
    try std.testing.expect(ripple.x != plain.x or ripple.y != plain.y);
    try std.testing.expectEqual(
        mapScene(300, 200, 800, 600, .{ .glitch = true }, 2.5).?,
        mapScene(300, 200, 800, 600, .{ .glitch = true }, 2.5).?,
    );
}