summaryrefslogtreecommitdiff
path: root/src/gui/crt.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/gui/crt.zig')
-rw-r--r--src/gui/crt.zig108
1 files changed, 83 insertions, 25 deletions
diff --git a/src/gui/crt.zig b/src/gui/crt.zig
index 9cf81593..2a5ea7e4 100644
--- a/src/gui/crt.zig
+++ b/src/gui/crt.zig
@@ -6,38 +6,96 @@
const std = @import("std");
-/// MUST match BARREL in shaders/crt.frag.glsl
-pub const barrel: f32 = 0.012;
+/// 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 };
-/// physical window pixels -> the scene pixels the shader displays there,
-/// clamped to the window. The shader's phosphor quantization and chromatic
-/// offset displace at most a few pixels — well under a cell — and are
-/// ignored here.
-pub fn map(x: f32, y: f32, w: f32, h: f32) Point {
- const ccx = x / w * 2.0 - 1.0;
- const ccy = y / h * 2.0 - 1.0;
- const s = 1.0 + barrel * (ccx * ccx + ccy * ccy);
+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((ccx * s + 1.0) * 0.5 * w, 0, w - 1),
- .y = std.math.clamp((ccy * s + 1.0) * 0.5 * h, 0, h - 1),
+ .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
- try std.testing.expectApproxEqAbs(@as(f32, 500), map(500, 250, 1000, 500).x, 0.001);
- try std.testing.expectApproxEqAbs(@as(f32, 250), map(500, 250, 1000, 500).y, 0.001);
- // corners: cc=(±1,±1), r2=2, scale 1.024 pushes past the tube -> clamped
- try std.testing.expectEqual(@as(f32, 0), map(0, 0, 1000, 500).x);
- try std.testing.expectEqual(@as(f32, 0), map(0, 0, 1000, 500).y);
- try std.testing.expectEqual(@as(f32, 999), map(1000, 500, 1000, 500).x);
- try std.testing.expectEqual(@as(f32, 499), map(1000, 500, 1000, 500).y);
- // (100,100) of 1000x500: cc=(-0.8,-0.6), r2=1.0, s=1.012 ->
- // cc'=(-0.8096,-0.6072) -> (95.2, 98.2)
- try std.testing.expectApproxEqAbs(@as(f32, 95.2), map(100, 100, 1000, 500).x, 0.01);
- try std.testing.expectApproxEqAbs(@as(f32, 98.2), map(100, 100, 1000, 500).y, 0.01);
- // (900,250): cc=(0.8,0), r2=0.64, s=1.00768 -> x'=903.072
- try std.testing.expectApproxEqAbs(@as(f32, 903.072), map(900, 250, 1000, 500).x, 0.01);
+ 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).?,
+ );
}