From 9f53f8cbeb599dafc57ed80193b79e2c48700376 Mon Sep 17 00:00:00 2001 From: skal Date: Mon, 9 Mar 2026 08:44:09 +0100 Subject: refactor: extract YIQ and C64 dither to common WGSL shaders - math/color.wgsl: add rgba_to_yiqa, yiqa_to_rgba, rgba_to_luma_chroma_phase - math/color_c64.wgsl: new file with C64 palette, Bayer 8x8, Dither() - ntsc.wgsl: include both, remove local duplicates; Dither() now takes xsize/ysize handoff(Claude): YIQ/dither helpers now reusable by other effects --- src/effects/ntsc.wgsl | 112 ++-------------------------------------- src/shaders/math/color.wgsl | 36 +++++++++++++ src/shaders/math/color_c64.wgsl | 83 +++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 107 deletions(-) create mode 100644 src/shaders/math/color_c64.wgsl diff --git a/src/effects/ntsc.wgsl b/src/effects/ntsc.wgsl index ad29c79..9901c88 100644 --- a/src/effects/ntsc.wgsl +++ b/src/effects/ntsc.wgsl @@ -1,6 +1,8 @@ #include "sequence_uniforms" #include "render/fullscreen_uv_vs" #include "math/noise" +#include "math/color" +#include "math/color_c64" #include "debug/debug_print" const PI = 3.14159265; @@ -22,28 +24,6 @@ const CROSS_TALK = 0.1; const CROSS_Y_INTERFERENCE = 30.; const CHROMA_MOD_FREQ = (0.4 * PI); -const NUM_COLORS : u32 = 16; -const C64Colors = array( - 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; @@ -63,37 +43,10 @@ fn vignette(uv: vec2f) -> f32 { 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 = Dither(textureSample(input_texture, input_sampler, uv), 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); + let rgba = textureSample(input_texture, input_sampler, uv); + return rgba_to_luma_chroma_phase(rgba, uv.y, YSIZE); } fn get_value(uv: vec2f, off: f32, yscale: f32) -> vec4f { @@ -124,61 +77,6 @@ fn randomized_f32(p: vec2f, t: f32) -> f32 { return hash_2f_alt(vec2f(p * 0.152 + t * 1500. + 50.0)); } -const BAYER_8X8 = array( - 0, 32, 8, 40, 2, 34, 10, 42, - 48, 16, 56, 24, 50, 18, 58, 26, - 12, 44, 4, 36, 14, 46, 6, 38, - 60, 28, 52, 20, 62, 30, 54, 22, - 3, 35, 11, 43, 1, 33, 9, 41, - 51, 19, 59, 27, 49, 17, 57, 25, - 15, 47, 7, 39, 13, 45, 5, 37, - 63, 31, 55, 23, 61, 29, 53, 21 -); - -const DistanceParam = 0.65; -fn colorDistance(color: vec4f, c1: vec4f, c2: vec4f, frac: f32) -> f32 { - return mix(distance(color, mix(c1, c2, frac)), - distance(color, c1) + distance(color, c2), - 0.5 * DistanceParam * DistanceParam); -} - -fn Dither(col: vec4f, uv: vec2f) -> vec4f { - let ix = u32(floor(uv.x * XSIZE / 2.)) % 8; - let iy = u32(floor(uv.y * YSIZE / 2.)) % 8; - let thresh = f32(BAYER_8X8[ix + 8 * iy]) / 64.; - - var color = min(col, vec4f(1.)); - color *= color; // ~gamma - - var c1 = vec4f(0.); - var c2 = vec4f(0.); - var frac = 0.0; - var best = 1.e5; - - for (var i = 0u; i < NUM_COLORS; i += 1u) { - var p1 = vec4f(C64Colors[i], 1.); - p1 *= p1; - for (var j = i + 1u; j < NUM_COLORS; j += 1u) { - var p2 = vec4f(C64Colors[j], 1.); - p2 *= p2; - - // min-L2 optim - let dc = p1 - p2; - var a = dot(dc, p1 - color) / dot(dc, dc); - a = clamp(a, 0., 1.); - let dist = colorDistance(color, p1, p2, a); - if (dist < best) { - best = dist; - c1 = p1; - c2 = p2; - frac = a; - } - } - } - color = mix(c1, c2, f32(frac > thresh)); -// color = mix(c1, c2, frac); - return sqrt(color); -} fn get_border_col(uv: vec2f) -> vec4f { let t = uniforms.beat_time; @@ -241,7 +139,7 @@ fn get_border_col(uv: vec2f) -> vec4f { signal.z *= 1.0 + scarrier * q_mod; var col = yiqa_to_rgba(signal); - col = Dither(col, uv); + col = Dither(col, uv, XSIZE, YSIZE); let border_col = get_border_col(uv); diff --git a/src/shaders/math/color.wgsl b/src/shaders/math/color.wgsl index 9352053..0639bf7 100644 --- a/src/shaders/math/color.wgsl +++ b/src/shaders/math/color.wgsl @@ -1,4 +1,40 @@ // Common color space and tone mapping functions. +// Includes: sRGB, ACES, HSV, YIQ (NTSC) conversions. +// Use rgba_to_luma_chroma_phase() to encode YIQ into a temp buffer for NTSC-style effects. + +const TAU_COLOR = 6.28318530718; + +// RGB <-> YIQ (NTSC) color space +fn rgba_to_yiqa(rgba: vec4f) -> vec4f { + const m = 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 m * rgba; +} + +fn yiqa_to_rgba(yiq: vec4f) -> vec4f { + const m = 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 m * yiq; +} + +// Convert RGBA to packed luma/chroma/phase signal for NTSC processing. +// Returns vec4f(luma, chroma_level, phase/TAU, alpha). +// ysize: virtual scanline count (e.g. 33.*8.); drives 22.5-degree inter-line phase shift. +fn rgba_to_luma_chroma_phase(rgba: vec4f, uv_y: f32, ysize: f32) -> vec4f { + let yiq = rgba_to_yiqa(rgba); + let chroma_level = length(yiq.yz); + 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_COLOR, yiq.a); +} // sRGB to Linear approximation // Note: Assumes input is in sRGB color space. diff --git a/src/shaders/math/color_c64.wgsl b/src/shaders/math/color_c64.wgsl new file mode 100644 index 0000000..ef8f073 --- /dev/null +++ b/src/shaders/math/color_c64.wgsl @@ -0,0 +1,83 @@ +// C64 palette and ordered-dither quantization. +// Provides Dither() to snap any RGBA color to the nearest C64 color pair +// via 8x8 Bayer threshold, suitable for NTSC and retro post-process effects. + +const NUM_COLORS : u32 = 16; +const C64Colors = array( + 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 +); + +const BAYER_8X8 = array( + 0, 32, 8, 40, 2, 34, 10, 42, + 48, 16, 56, 24, 50, 18, 58, 26, + 12, 44, 4, 36, 14, 46, 6, 38, + 60, 28, 52, 20, 62, 30, 54, 22, + 3, 35, 11, 43, 1, 33, 9, 41, + 51, 19, 59, 27, 49, 17, 57, 25, + 15, 47, 7, 39, 13, 45, 5, 37, + 63, 31, 55, 23, 61, 29, 53, 21 +); + +const DistanceParam = 0.65; +fn colorDistance(color: vec4f, c1: vec4f, c2: vec4f, frac: f32) -> f32 { + return mix(distance(color, mix(c1, c2, frac)), + distance(color, c1) + distance(color, c2), + 0.5 * DistanceParam * DistanceParam); +} + +// Quantize col to the nearest C64 color pair using 8x8 Bayer dithering. +// xsize/ysize: virtual pixel grid dimensions (e.g. 54.*8., 33.*8.). +fn Dither(col: vec4f, uv: vec2f, xsize: f32, ysize: f32) -> vec4f { + let ix = u32(floor(uv.x * xsize / 2.)) % 8; + let iy = u32(floor(uv.y * ysize / 2.)) % 8; + let thresh = f32(BAYER_8X8[ix + 8 * iy]) / 64.; + + var color = min(col, vec4f(1.)); + color *= color; // ~gamma + + var c1 = vec4f(0.); + var c2 = vec4f(0.); + var frac = 0.0; + var best = 1.e5; + + for (var i = 0u; i < NUM_COLORS; i += 1u) { + var p1 = vec4f(C64Colors[i], 1.); + p1 *= p1; + for (var j = i + 1u; j < NUM_COLORS; j += 1u) { + var p2 = vec4f(C64Colors[j], 1.); + p2 *= p2; + + // min-L2 optim + let dc = p1 - p2; + var a = dot(dc, p1 - color) / dot(dc, dc); + a = clamp(a, 0., 1.); + let dist = colorDistance(color, p1, p2, a); + if (dist < best) { + best = dist; + c1 = p1; + c2 = p2; + frac = a; + } + } + } + color = mix(c1, c2, f32(frac > thresh)); + return sqrt(color); +} -- cgit v1.2.3