2-D FFT — source + log-magnitude spectrum
A 64×64 sum-of-sinusoids field on the left; its log-magnitude spectrum (DC at centre) on the right. The 2-D FFT runs as row-then-column 1-D passes on the same radix-2 kernel the 1-D exhibit uses. The exhibit is a descendant of visual-fft-revolution, ported into the browser at a fraction of the dependency weight (no rustfft, no image, no rayon — just the same butterflies).
view the kernel that wrote this receipt crates/mathground-view/src/fft2d.rs
//! 2-D radix-2 FFT via row-then-column passes.
//!
//! Reuses `fft::fft_in_place`. Layout is row-major interleaved (re, im),
//! `data.len() = 2 · w · h`, both w and h powers of two. V-class L0-closed.
//!
//! Designed to power the "visual FFT" exhibit — a 2-D log-magnitude
//! spectrum next to its source intensity field.
use serde::{Deserialize, Serialize};
use crate::fft::fft_in_place;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fft2dKernelReport {
pub w: u32,
pub h: u32,
pub bit_ops: u64,
}
/// In-place 2-D FFT. `data` is `2 · w · h` long, interleaved (re, im),
/// row-major. Returns `Err` if dimensions are not powers of two.
pub fn fft2d_in_place(data: &mut [f32], w: u32, h: u32) -> Result<Fft2dKernelReport, &'static str> {
let w_us = w as usize;
let h_us = h as usize;
if !w_us.is_power_of_two() || !h_us.is_power_of_two() || w_us == 0 || h_us == 0 {
return Err("fft2d: w and h must be powers of two");
}
if data.len() != 2 * w_us * h_us {
return Err("fft2d: buffer size mismatch");
}
// ── Row pass: FFT each row in place ──
let mut row_bits: u64 = 0;
let row_len = 2 * w_us;
for y in 0..h_us {
let s = y * row_len;
let r = fft_in_place(&mut data[s..s + row_len])?;
row_bits = row_bits.saturating_add(r.bit_ops);
}
// ── Column pass: pack each column, FFT it, write back ──
let mut col_buf = vec![0.0f32; 2 * h_us];
let mut col_bits: u64 = 0;
for x in 0..w_us {
for y in 0..h_us {
let src = y * row_len + 2 * x;
col_buf[2 * y] = data[src];
col_buf[2 * y + 1] = data[src + 1];
}
let r = fft_in_place(&mut col_buf)?;
col_bits = col_bits.saturating_add(r.bit_ops);
for y in 0..h_us {
let dst = y * row_len + 2 * x;
data[dst] = col_buf[2 * y];
data[dst + 1] = col_buf[2 * y + 1];
}
}
Ok(Fft2dKernelReport {
w,
h,
bit_ops: row_bits.saturating_add(col_bits),
})
}
/// Pack `(w · h)` log-magnitude values into `out` (length `w · h`),
/// shifted so DC sits at the centre. Returns the max log-magnitude.
pub fn log_magnitude_shifted(data: &[f32], w: u32, h: u32, out: &mut [f32]) -> f32 {
let w_us = w as usize;
let h_us = h as usize;
debug_assert_eq!(out.len(), w_us * h_us);
let hx = w_us / 2;
let hy = h_us / 2;
let mut maxv = -1e30f32;
for y in 0..h_us {
for x in 0..w_us {
let src_x = (x + hx) % w_us;
let src_y = (y + hy) % h_us;
let i = (src_y * w_us + src_x) * 2;
let re = data[i];
let im = data[i + 1];
let lm = (re * re + im * im + 1e-12).ln();
out[y * w_us + x] = lm;
if lm > maxv { maxv = lm; }
}
}
maxv
}
#[cfg(test)]
mod tests {
use super::*;
fn buf_zero(w: u32, h: u32) -> Vec<f32> {
vec![0.0; (2 * w * h) as usize]
}
#[test]
fn rejects_non_power_of_two_dims() {
let mut b = buf_zero(6, 8);
assert!(fft2d_in_place(&mut b, 6, 8).is_err());
}
#[test]
fn pure_dc_concentrates_at_origin() {
let w = 16;
let h = 16;
let mut b = buf_zero(w, h);
// Constant 1.0 real signal → DC peak at (0,0).
for k in 0..(w * h) as usize {
b[2 * k] = 1.0;
}
fft2d_in_place(&mut b, w, h).unwrap();
let dc_mag = (b[0].powi(2) + b[1].powi(2)).sqrt();
// First non-DC bin should be ~0.
let nz = (b[2].powi(2) + b[3].powi(2)).sqrt();
assert!(dc_mag > 100.0, "DC mag {dc_mag}");
assert!(nz < 1e-3, "non-DC mag should be ~0, got {nz}");
}
#[test]
fn report_scales_with_n_log_n() {
let mut a = buf_zero(16, 16);
let mut b = buf_zero(32, 32);
let ra = fft2d_in_place(&mut a, 16, 16).unwrap();
let rb = fft2d_in_place(&mut b, 32, 32).unwrap();
// Ratio should be ~ (1024 · log2 32) / (256 · log2 16) = 5120/1024 = 5
let ratio = rb.bit_ops as f64 / ra.bit_ops as f64;
assert!(ratio > 4.0 && ratio < 6.0, "ratio was {ratio}");
}
}
This is the exact Rust file compiled into the WASM module the page just loaded. Every line that runs on your device is here. The receipt is a function of this code, not a bespoke benchmark.