//! 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).?, ); }