ntsc.wgsl317 lines · 12.7 KB · raw
1// NTSC samples per color carrier cycle (assumed to be 15 or 12)
2override samples_per_color_cycle: i32;
3
4// FIR filter length (assumed to be between 1 and 84, inclusive)
5override fir_len: i32;
6
7// Number of NTSC samples to generate per frame buffer pixel (assumed to be at least 1)
8override upscale_factor: i32;
9
10// Phase offset to apply when demodulating U and V (should only be non-zero for NES NTSC output)
11override decode_hue_offset: f32 = 0.0;
12
13override decode_brightness: f32 = 1.0;
14override decode_saturation: f32 = 1.0;
15override decode_gamma: f32 = 2.2;
16
17// Used by rgb_to_ntsc
18@group(0) @binding(0) var<uniform> y_encode_lpf_coefficients: array<vec4f, 21>;
19@group(0) @binding(1) var<uniform> uv_encode_lpf_coefficients: array<vec4f, 21>;
20@group(0) @binding(2) var input_frame: texture_2d<f32>;
21@group(0) @binding(3) var ntsc_frame_w: texture_storage_2d<r32float, write>;
22
23// Used by separate_luma_chroma
24@group(0) @binding(4) var<uniform> luma_bsf_coefficients: array<vec4f, 21>;
25@group(0) @binding(5) var<uniform> chroma_bpf_coefficients: array<vec4f, 21>;
26@group(0) @binding(6) var ntsc_frame_r: texture_2d<f32>;
27@group(0) @binding(7) var ntsc_luma_w: texture_storage_2d<r32float, write>;
28@group(0) @binding(8) var ntsc_chroma_w: texture_storage_2d<r32float, write>;
29
30// Used by luma_chroma_to_rgb
31@group(0) @binding(9) var<uniform> y_decode_lpf_coefficients: array<vec4f, 21>;
32@group(0) @binding(10) var<uniform> uv_decode_lpf_coefficients: array<vec4f, 21>;
33@group(0) @binding(11) var ntsc_luma_r: texture_2d<f32>;
34@group(0) @binding(12) var ntsc_chroma_r: texture_2d<f32>;
35@group(0) @binding(13) var output_frame: texture_storage_2d<rgba8unorm, write>;
36
37struct ImmediateParams {
38    frame_phase_offset: i32,
39    per_line_phase_offset: i32,
40}
41
42// This is a uniform instead of an actual immediate because WebGPU does not support immediates (yet)
43@group(1) @binding(0) var<uniform> immediate_params: ImmediateParams;
44
45// https://www.nesdev.org/wiki/NTSC_video#Converting_YUV_to_signal_RGB
46// Y = 0.299*R + 0.587*G + 0.114*B
47// U = 0.492111 * (B - Y)
48// V = 0.877283 * (R - Y)
49const RGB_TO_YUV: mat3x3f = mat3x3f(
50    vec3f(0.299, 0.492111 *   -0.299,      0.877283 *  (1.0 - 0.299) ),
51    vec3f(0.587, 0.492111 *   -0.587,      0.877283 *    -0.587      ),
52    vec3f(0.114, 0.492111 * (1.0 - 0.114), 0.877283 *    -0.114      ),
53);
54
55const YUV_TO_RGB: mat3x3f = mat3x3f(
56    vec3f(1.0, 1.0, 1.0),
57    vec3f(0.0, -0.394642, 2.032062),
58    vec3f(1.139883, -0.580622, 0.0),
59);
60
61const PI: f32 = radians(180.0);
62
63// Extra pixels to render at the horizontal edges, to avoid the NTSC signal sharply cutting off at the borders
64const BACKDROP_PIXELS: i32 = 6;
65
66// Convert from RGB to YUV, apply LPF to each YUV component, encode from YUV to NTSC
67// LPF to Y instead of BSF because I think it looks slightly better, and it's going to get LPFed during decoding anyway
68@compute @workgroup_size(16, 16, 1)
69fn rgb_to_ntsc(@builtin(global_invocation_id) invocation: vec3u) {
70    let ntsc_size = vec2i(textureDimensions(ntsc_frame_w));
71    let position = vec2i(invocation.xy);
72    if position.x >= ntsc_size.x || position.y >= ntsc_size.y {
73        return;
74    }
75
76    let input_size = vec2i(textureDimensions(input_frame));
77
78    let start_x = position.x + fir_len / 2 - upscale_factor * BACKDROP_PIXELS;
79    let input_divisor = vec2i(upscale_factor, 1);
80
81    var filtered_y = vec4f(0.0);
82    var filtered_u = vec4f(0.0);
83    var filtered_v = vec4f(0.0);
84    for (var i = 0; i < fir_len; i += 4) {
85        let rgb_pixels = array(
86            load_input_bounds_checked(vec2i(start_x - i, position.y) / input_divisor, input_size, vec3f(0.0)),
87            load_input_bounds_checked(vec2i(start_x - i - 1, position.y) / input_divisor, input_size, vec3f(0.0)),
88            load_input_bounds_checked(vec2i(start_x - i - 2, position.y) / input_divisor, input_size, vec3f(0.0)),
89            load_input_bounds_checked(vec2i(start_x - i - 3, position.y) / input_divisor, input_size, vec3f(0.0)),
90        );
91
92        let yuv_pixels = array(
93            RGB_TO_YUV * rgb_pixels[0],
94            RGB_TO_YUV * rgb_pixels[1],
95            RGB_TO_YUV * rgb_pixels[2],
96            RGB_TO_YUV * rgb_pixels[3],
97        );
98
99        let y_coefficients = y_encode_lpf_coefficients[i / 4];
100        let uv_coefficients = uv_encode_lpf_coefficients[i / 4];
101
102        filtered_y = fma(
103            y_coefficients,
104            vec4f(yuv_pixels[0].r, yuv_pixels[1].r, yuv_pixels[2].r, yuv_pixels[3].r),
105            filtered_y,
106        );
107        filtered_u = fma(
108            uv_coefficients,
109            vec4f(yuv_pixels[0].g, yuv_pixels[1].g, yuv_pixels[2].g, yuv_pixels[3].g),
110            filtered_u,
111        );
112        filtered_v = fma(
113            uv_coefficients,
114            vec4f(yuv_pixels[0].b, yuv_pixels[1].b, yuv_pixels[2].b, yuv_pixels[3].b),
115            filtered_v,
116        );
117    }
118
119    let yuv = vec3f(
120        dot(filtered_y, vec4f(1.0)),
121        dot(filtered_u, vec4f(1.0)),
122        dot(filtered_v, vec4f(1.0)),
123    );
124
125    let phase_x = position.x
126        + immediate_params.frame_phase_offset
127        + position.y * immediate_params.per_line_phase_offset;
128    let phase = f32(phase_x) / f32(samples_per_color_cycle) * 2.0 * PI;
129    let ntsc = yuv.r + yuv.g * sin(phase) + yuv.b * cos(phase);
130    textureStore(ntsc_frame_w, position, vec4f(ntsc, vec3f(0.0)));
131}
132
133fn load_input_bounds_checked(position: vec2i, input_size: vec2i, default_color: vec3f) -> vec3f {
134    if position.x < 0 || position.y < 0 || position.x >= input_size.x || position.y >= input_size.y {
135        // WGSL spec says implementations may return any texel within the texture if coordinates are out of bounds;
136        // guarantee that a black pixel gets loaded instead
137        return default_color;
138    }
139
140    return textureLoad(input_frame, position, 0).rgb;
141}
142
143// Apply BPF and BSF to NTSC signal
144@compute @workgroup_size(16, 16, 1)
145fn separate_luma_chroma(@builtin(global_invocation_id) invocation: vec3u) {
146    let frame_size = vec2i(textureDimensions(ntsc_frame_r));
147    let position = vec2i(invocation.xy);
148    if position.x >= frame_size.x || position.y >= frame_size.y {
149        return;
150    }
151
152    let start_x = position.x + fir_len / 2;
153
154    var pass_filtered = vec4f(0.0);
155    var stop_filtered = vec4f(0.0);
156    for (var i = 0; i < fir_len; i += 4) {
157        let ntsc_samples = vec4f(
158            textureLoad(ntsc_frame_r, vec2i(start_x - i, position.y), 0).r,
159            textureLoad(ntsc_frame_r, vec2i(start_x - i - 1, position.y), 0).r,
160            textureLoad(ntsc_frame_r, vec2i(start_x - i - 2, position.y), 0).r,
161            textureLoad(ntsc_frame_r, vec2i(start_x - i - 3, position.y), 0).r,
162        );
163
164        pass_filtered = fma(ntsc_samples, chroma_bpf_coefficients[i / 4], pass_filtered);
165        stop_filtered = fma(ntsc_samples, luma_bsf_coefficients[i / 4], stop_filtered);
166    }
167
168    let pass_sample = dot(pass_filtered, vec4f(1.0));
169    let stop_sample = dot(stop_filtered, vec4f(1.0));
170
171    textureStore(ntsc_chroma_w, position, vec4f(pass_sample, vec3f(0.0)));
172    textureStore(ntsc_luma_w, position, vec4f(stop_sample, vec3f(0.0)));
173}
174
175// Decode U and V from chroma, apply LPF to each YUV component, convert from YUV to RGB
176@compute @workgroup_size(16, 16, 1)
177fn luma_chroma_to_rgb(@builtin(global_invocation_id) invocation: vec3u) {
178    let output_size = vec2i(textureDimensions(output_frame));
179    let position = vec2i(invocation.xy);
180    if position.x >= output_size.x || position.y >= output_size.y {
181        return;
182    }
183
184    let start_x = position.x + fir_len / 2 + upscale_factor * BACKDROP_PIXELS;
185
186    var filtered_y = vec4f(0.0);
187    var filtered_u = vec4f(0.0);
188    var filtered_v = vec4f(0.0);
189    for (var i = 0; i < fir_len; i += 4) {
190        let luma_samples = vec4f(
191            textureLoad(ntsc_luma_r, vec2i(start_x - i, position.y), 0).r,
192            textureLoad(ntsc_luma_r, vec2i(start_x - i - 1, position.y), 0).r,
193            textureLoad(ntsc_luma_r, vec2i(start_x - i - 2, position.y), 0).r,
194            textureLoad(ntsc_luma_r, vec2i(start_x - i - 3, position.y), 0).r,
195        );
196
197        let chroma_samples = vec4f(
198             textureLoad(ntsc_chroma_r, vec2i(start_x - i, position.y), 0).r,
199             textureLoad(ntsc_chroma_r, vec2i(start_x - i - 1, position.y), 0).r,
200             textureLoad(ntsc_chroma_r, vec2i(start_x - i - 2, position.y), 0).r,
201             textureLoad(ntsc_chroma_r, vec2i(start_x - i - 3, position.y), 0).r,
202        );
203
204        let y_coefficients = y_decode_lpf_coefficients[i / 4];
205        let uv_coefficients = uv_decode_lpf_coefficients[i / 4];
206
207        let base_phases = vec4i(start_x - i, start_x - i - 1, start_x - i - 2, start_x - i - 3)
208            + immediate_params.frame_phase_offset
209            + position.y * immediate_params.per_line_phase_offset;
210        let phases = vec4f(base_phases) / f32(samples_per_color_cycle) * 2.0 * PI
211            + vec4f(decode_hue_offset);
212
213        // 2.0 multiplier in U/V for chroma saturation correction:
214        //   https://www.nesdev.org/wiki/NTSC_video#Chroma_saturation_correction
215        filtered_y = fma(y_coefficients, luma_samples, filtered_y);
216        filtered_u = fma(uv_coefficients, chroma_samples * sin(phases) * 2.0, filtered_u);
217        filtered_v = fma(uv_coefficients, chroma_samples * cos(phases) * 2.0, filtered_v);
218    }
219
220    var yuv = vec3f(
221        dot(filtered_y, vec4f(1.0)),
222        dot(filtered_u, vec4f(1.0)),
223        dot(filtered_v, vec4f(1.0)),
224    );
225
226    yuv *= vec3f(decode_brightness) * vec3f(1.0, decode_saturation, decode_saturation);
227
228    var rgb = YUV_TO_RGB * yuv;
229    rgb = clamp(rgb, vec3f(0.0), vec3f(1.0));
230    rgb = pow(rgb, vec3f(2.2 / decode_gamma));
231
232    textureStore(output_frame, position, vec4f(rgb, 1.0));
233}
234
235// NES-to-NTSC based on https://www.nesdev.org/wiki/NTSC_video
236const NES_NTSC_BLACK: f32 = 0.312;
237const NES_NTSC_WHITE: f32 = 1.100;
238
239const NES_NTSC_LOW: vec4f = vec4f(0.228, 0.312, 0.552, 0.880);
240const NES_NTSC_HIGH: vec4f = vec4f(0.616, 0.840, 1.100, 1.100);
241const NES_NTSC_LOW_ATTENUATED: vec4f = vec4f(0.192, 0.256, 0.448, 0.712);
242const NES_NTSC_HIGH_ATTENUATED: vec4f = vec4f(0.500, 0.676, 0.896, 0.896);
243
244const NES_COLOR_BLACK: f32 = f32(0x1D) / 255.0;
245
246@compute @workgroup_size(16, 16, 1)
247fn nes_to_ntsc(@builtin(global_invocation_id) invocation: vec3u) {
248    let frame_size = vec2i(textureDimensions(ntsc_frame_w));
249    let position = vec2i(invocation.xy);
250    if position.x >= frame_size.x || position.y >= frame_size.y {
251        return;
252    }
253
254    let phase = immediate_params.frame_phase_offset
255        + position.y * immediate_params.per_line_phase_offset
256        + position.x;
257
258    let input_size = vec2i(textureDimensions(input_frame));
259
260    // Assume input frame buffer contains 6-bit NES colors (R) and 3-bit color emphasis (G) instead of RGB888 colors
261    let input_x = (position.x - upscale_factor * BACKDROP_PIXELS) / upscale_factor;
262    let input_texel = load_input_bounds_checked(vec2i(input_x, position.y), input_size, vec3f(NES_COLOR_BLACK, 0.0, 0.0));
263    let input_rg = vec2i(round(input_texel.rg * 255.0));
264    let nes_color = input_rg.r;
265    let color_emphasis = input_rg.g;
266
267    // Hue is lowest 4 bits of 6-bit color
268    let hue = nes_color & 0xF;
269
270    // Luma is forced to 1 when hue is 14 or 15
271    // Otherwise, highest 2 bits of 6-bit color
272    let luma = select(
273        1,
274        (nes_color >> 4) & 3,
275        hue < 0xE,
276    );
277
278    let emphasis_r = (color_emphasis & (1 << 0)) != 0;
279    let emphasis_g = (color_emphasis & (1 << 1)) != 0;
280    let emphasis_b = (color_emphasis & (1 << 2)) != 0;
281
282    // Color emphasis bits cause the PPU to attenuate half of the signal, or more than half if multiple bits are set
283    // Emphasis bits have no effect when hue is 14 or 15
284    let attenuate = hue < 0xE
285        && ((emphasis_r && nes_in_color_phase(0, phase))
286            || (emphasis_g && nes_in_color_phase(4, phase))
287            || (emphasis_b && nes_in_color_phase(8, phase)));
288
289    // Luma determines the two possible NTSC sample values
290    let low = select(NES_NTSC_LOW[luma], NES_NTSC_LOW_ATTENUATED[luma], attenuate);
291    let high = select(NES_NTSC_HIGH[luma], NES_NTSC_HIGH_ATTENUATED[luma], attenuate);
292
293    // NTSC signal is always high when hue is 0 and always low when hue is 13-15
294    // Otherwise the PPU outputs a square wave, phase shifted based on hue
295    let signal = select(
296        select(
297            select(
298                low,
299                high,
300                nes_in_color_phase(hue, phase),
301            ),
302            low,
303            hue >= 13,
304        ),
305        high,
306        hue == 0,
307    );
308
309    // Normalize so black=0 and white=1, with negative values (darker than black) possible and allowed
310    let normalized = (signal - NES_NTSC_BLACK) / (NES_NTSC_WHITE - NES_NTSC_BLACK);
311
312    textureStore(ntsc_frame_w, position, vec4f(normalized, vec3f(0.0)));
313}
314
315fn nes_in_color_phase(color: i32, phase: i32) -> bool {
316    return ((color + phase) % 12) < 6;
317}