summaryrefslogtreecommitdiff
path: root/src/effects
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-03-08 23:38:11 +0100
committerskal <pascal.massimino@gmail.com>2026-03-08 23:38:11 +0100
commitad0f386e1b5cd9148ad278c7f016734af00cd0bc (patch)
treebdbcaaf5cd2c568a90f2c480fb82be60173fc5e9 /src/effects
parentc43addd93191db1ed3e79af7e065004f7c52bf48 (diff)
ntsc effect for real
Diffstat (limited to 'src/effects')
-rw-r--r--src/effects/ntsc.wgsl207
1 files changed, 163 insertions, 44 deletions
diff --git a/src/effects/ntsc.wgsl b/src/effects/ntsc.wgsl
index 4e72f0a..8d8d536 100644
--- a/src/effects/ntsc.wgsl
+++ b/src/effects/ntsc.wgsl
@@ -1,74 +1,193 @@
-// NTSC post-process effect with fisheye/barrel distortion
#include "sequence_uniforms"
#include "render/fullscreen_uv_vs"
#include "math/noise"
#include "debug/debug_print"
-const vignetteRounding = 160.0f;
-const vignetteSmoothness = 0.7f;
-const fisheyeStrength = vec2f(.1, .24);
+const PI = 3.14159265;
+const TAU = 6.28318530718;
+
+const XSIZE = 54.0 * 8.;
+const YSIZE = 33.0 * 8.;
+const SCAN_FLICKER = 2.33;
+const X_INTERFERENCE = 1.1;
+const Y_INTERFERENCE = 0.101;
+const LUMA_BRIGHTNESS = 1.1;
+const CHROMA_SATURATION = 1.6;
+const BLUR_SIZE = 0.2;
+const LUMA_BLUR = 1.7;
+const CHROMA_BLUR = 0.7;
+const CHROMA_SIZE = 6.0;
+const SUB_CARRIER = 2.1;
+const CROSS_TALK = 0.1;
+const CROSS_Y_INTERFERENCE = 30.;
+const CHROMA_MOD_FREQ = (0.4 * PI);
+
+const C64Colors = array<vec3f, 16>(
+ vec3f(13.0/255.0, 13.0/255.0, 13.0/255.0), // 0 black
+ vec3f(242.0/255.0, 242.0/255.0, 242.0/255.0), // 1 white
+ vec3f(89.0/255.0, 39.0/255.0, 33.0/255.0), // 2 red
+ vec3f(170.0/255.0, 220.0/255.0, 240.0/255.0), // 3 cyan
+
+ vec3f(101.0/255.0, 27.0/255.0, 109.0/255.0), // 4 purple
+ vec3f(120.0/255.0, 198.0/255.0, 112.0/255.0), // 5 green
+ vec3f(54.0/255.0, 53.0/255.0, 152.0/255.0), // 6 blue
+ vec3f(226.0/255.0, 229.0/255.0, 115.0/255.0), // 7 yellow
+
+ vec3f(141.0/255.0, 85.0/255.0, 50.0/255.0), // 8 orange
+ vec3f(89.0/255.0, 63.0/255.0, 13.0/255.0), // 9 brown
+ vec3f(189.0/255.0, 110.0/255.0, 115.0/255.0), // 10 pink
+ vec3f(80.0/255.0, 80.0/255.0, 80.0/255.0), // 11 dark gray
+
+ vec3f(124.0/255.0, 124.0/255.0, 124.0/255.0), // 12 gray
+ vec3f(165.0/255.0, 242.0/255.0, 156.0/255.0), // 13 bright green
+ vec3f(114.0/255.0, 111.0/255.0, 224.0/255.0), // 14 light blue
+ vec3f(128.0/255.0, 128.0/255.0, 128.0/255.0) // 15 middle gray
+);
@group(0) @binding(0) var input_sampler: sampler;
@group(0) @binding(1) var input_texture: texture_2d<f32>;
@group(0) @binding(2) var<uniform> uniforms: UniformsSequenceParams;
// Barrel (fisheye) distortion: strength > 0 = barrel, < 0 = pincushion
-fn fisheye(uv: vec2f, strength: f32) -> vec2f {
- let r2 = uv * uv;
- return uv * 1.2 * (1.0 + fisheyeStrength * strength * r2);
+fn fisheye(uv: vec2f, scale: f32) -> vec2f {
+ const strength = vec2f(0.1, 0.24);
+ return uv * (1.0 + scale * strength * (uv * uv).yx) * 0.65 + .5;
}
fn vignette(uv: vec2f) -> f32 {
- let uv2 = uv * 1.99;
- let amount = 1.0 - sqrt(pow(abs(uv2.x), vignetteRounding) + pow(abs(uv2.y), vignetteRounding));
- let vhard = smoothstep(0., vignetteSmoothness, amount);
- return vhard;
+ const vignetteRounding = 160.0;
+ const vignetteSmoothness = 0.7;
+ let uv2 = 2.0 * uv - 1.0;
+ let amount = 1.0 - sqrt(pow(abs(uv2.x), vignetteRounding) + pow(abs(uv2.y), vignetteRounding));
+ return smoothstep(0., vignetteSmoothness, amount);
+}
+
+fn rgba_to_yiqa(rgba: vec4f) -> vec4f {
+ const rgb_yiq_mat = mat4x4f(
+ 0.299, 0.596, 0.211, 0.,
+ 0.587, -0.274, -0.523, 0.,
+ 0.114, -0.322, 0.312, 0.,
+ 0., 0., 0., 1.,
+ );
+ return rgb_yiq_mat * rgba;
+}
+
+fn yiqa_to_rgba(yiq: vec4f) -> vec4f {
+ const yiq_rgb_mat = mat4x4f(
+ 1.000, 1.000, 1.000, 0.,
+ 0.956, -0.272, -1.106, 0.,
+ 0.621, -0.647, 1.703, 0.,
+ 0., 0., 0., 1.,
+ );
+ return yiq_rgb_mat * yiq;
+}
+
+// returns Luma, chroma subcarrier level, phase, transparency
+fn get_luma_chroma_phase_a(uv: vec2f) -> vec4f {
+ let rgba = textureSample(input_texture, input_sampler, uv);
+ let yiq = rgba_to_yiqa(textureSample(input_texture, input_sampler, uv));
+ let chroma_level = length(yiq.yz);
+
+ // 22.5 degrees phase shift between lines
+// let mscanl = (uv.y * uniforms.resolution.y / 4.) % 2.0;
+ let mscanl = (uv.y * YSIZE) % 2.0;
+ let phase = atan2(yiq.y, yiq.z) - 0.3926991 + mscanl * 0.19634954;
+ return vec4f(yiq.x, chroma_level, phase / TAU, yiq.a);
+}
+
+fn get_value(uv: vec2f, off: f32, yscale: f32) -> vec4f {
+ return get_luma_chroma_phase_a(uv + off * vec2f(1., yscale));
+}
+
+fn peak(y: f32, ypos: f32, scale: f32) -> f32 {
+ return clamp((y - 1.) * scale * log(abs(y - ypos)), 0.0, 1.0);
+}
+
+// 12-taps horizontal filtering
+const luma_filter = array<f32, 2 * 12 + 1>(
+ 0.0105, 0.0134, 0.0057,-0.0242,-0.0824,
+ -0.1562,-0.2078,-0.1850,-0.0546, 0.1626,
+ 0.3852, 0.5095, 0.5163, 0.4678, 0.2844,
+ 0.0515,-0.1308,-0.2082,-0.1891,-0.1206,
+ -0.0511,-0.0065, 0.0114, 0.0127, 0.008
+);
+const chroma_filter = array<f32, 2 * 12 + 1>(
+ 0.001, 0.0010, 0.0001, 0.0002, -0.0003,
+ 0.0062, 0.0120,-0.0079, 0.0978, 0.1059,
+ -0.0394, 0.2732, 0.2941, 0.1529, -0.021,
+ 0.1347, 0.0415,-0.0032, 0.0115, 0.002,
+ -0.0001, 0.0002, 0.001, 0.001, 0.001
+);
+
+fn randomized_f32(p: vec2f, t: f32) -> f32 {
+ return hash_2f_alt(vec2f(p * 0.152 + t * 1500. + 50.0));
}
@fragment fn fs_main(in: VertexOutput) -> @location(0) vec4f {
let t = uniforms.time;
+ let bt = uniforms.beat_phase;
// Fisheye/barrel distortion
- let uv = (fisheye(in.st, 0.18) + 1.) * .5;
-// uv = vignette(uv);
+ var uv = fisheye(in.st, 0.18);
+ let mframe = round(bt * 2.) % 2.;
+ uv.y += mframe * SCAN_FLICKER / uniforms.resolution.y; // flicker at target resolution
- // Black outside screen edges
- if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
- return vec4f(0.0, 0.0, 0.0, 1.0);
- }
- // Chroma separation (horizontal RGB bleeding)
- let bleed = 2.5 / uniforms.resolution.x;
- let r = textureSample(input_texture, input_sampler, uv + vec2f( bleed, 0.0)).r;
- let g = textureSample(input_texture, input_sampler, uv ).g;
- let b = textureSample(input_texture, input_sampler, uv - vec2f( bleed, 0.0)).b;
- var col = vec3f(r, g, b);
+ // interference
+ let cur_line = round(uv.y * YSIZE);
+ var r = randomized_f32(vec2f(0.0, cur_line), uniforms.time);
+ if (r > 0.995) { r *= 3.0; }
- // Scanlines
- let scan = sin(uv.y * uniforms.resolution.y * 3.14159265) * 0.5 + 0.5;
- col *= 0.82 + 0.18 * scan;
+ let x_interf = X_INTERFERENCE * r / XSIZE;
+ let y_interf = Y_INTERFERENCE * r * peak(uv.y, 0.2, 0.03);
+ uv.x += x_interf - y_interf;
+ uv = clamp(uv, vec2f(0.), vec2f(1.));
- // Per-pixel temporal noise
- let pixel = floor(uv * uniforms.resolution);
- let n = hash_2f(pixel + vec2f(t * 47.3, t * 31.7)) * 0.08 - 0.04;
- col += n;
-
- // Horizontal jitter line (random scanline rolling artifact)
- let jitter_y = fract(t * 0.37);
- let jitter_band = abs(uv.y - jitter_y);
- if (jitter_band < 0.003) {
- col += hash_1f(uv.x * 100.0 + t) * 0.15;
+ // luma fringing
+ let d = (BLUR_SIZE + y_interf * 100.0) / XSIZE;
+ var lc_signal = vec4f(0.0);
+ for (var i = 0; i < 25; i += 1) {
+ let offset = f32(i) - 12.0;
+ let suml = luma_filter[i] * get_value(uv, offset * d, 0.67);
+ let sumc = chroma_filter[i] * get_value(uv, offset * d * CHROMA_SIZE, 0.67);
+ lc_signal += vec4f(suml.x, sumc.y, sumc.z, suml.a);
}
+ let base = get_luma_chroma_phase_a(uv);
+ var signal = mix(base, lc_signal, vec4f(LUMA_BLUR, CHROMA_BLUR, CHROMA_BLUR, 1.));
- // Vignette (stronger at corners due to fisheye)
- let vig = in.uv * 2.0 - 1.0;
- col *= 1.0 - dot(vig, vig) * 0.45;
+ // luma / chroma saturation
+ let lchroma = signal.y * CHROMA_SATURATION;
+ let phase = signal.z * TAU;
+ signal.x *= LUMA_BRIGHTNESS;
+ signal.y = lchroma * sin(phase);
+ signal.z = lchroma * cos(phase);
// Slight NTSC warm tint (boost red/green, attenuate blue)
- col.r *= 1.04;
- col.g *= 1.01;
- col.b *= 0.94;
+ signal *= vec4f(1.04, 1.01, .94, 1.);
+
+ // color subcarrier signal, crosstalk
+ let chroma_phase = t * 60.0 * PI * 0.6667;
+ let mod_phase = chroma_phase + dot(uv, vec2f(1.0, 0.1)) * CHROMA_MOD_FREQ * XSIZE * 2.0;
+ let scarrier = SUB_CARRIER * lchroma;
+ let i_mod = cos(mod_phase);
+ let q_mod = sin(mod_phase);
+
+ signal.x *= 1.0 + CROSS_TALK * scarrier * q_mod - y_interf * CROSS_Y_INTERFERENCE;
+ signal.y *= 1.0 + scarrier * i_mod;
+ signal.z *= 1.0 + scarrier * q_mod;
- col = debug_f32(col, in.position.xy / uniforms.resolution, vec2f(10., 10.), 3.2);
- return vec4f(clamp(col, vec3f(0.0), vec3f(1.0)), 1.0);
+
+ let v_strength = vignette(uv);
+ let scanl = (0.82 + 0.18 * sin(PI * uv.y * uniforms.resolution.y / 2.));
+ var col = clamp(scanl * yiqa_to_rgba(signal), vec4f(0.0), vec4f(1.0));
+ let id = round(hash_1f(round(t * .02 + uv.x + YSIZE * uv.y / 8.) + 3. * round(hash_1f(t * 533.) * 24.)) * 16.);
+ let border_col = vec4f(C64Colors[u32(id)], 1.);
+ col = mix(border_col, col, v_strength);
+ // Black outside screen edges
+ if (uv.x <= 0.0 || uv.x >= 1.0 || uv.y <= 0.0 || uv.y >= 1.0) {
+ // discard;
+ }
+ col = debug_f32(col, in.position.xy / 2., vec2f(100., 75.), uniforms.beat_time);
+ col = debug_str(col, in.position.xy / 2., vec2f(100., 150.), vec4u(0x48656C6Cu, 0x6F000000u, 0u, 0u), 5u);
+ return col;
}