yum-slop/yummers.dev
source code for https://yummers.dev
git clone https://git.yummers.dev/yum-slop/yummers.dev
3a34477
master
title: yummers lang: en description: Deriving a fast scalar FFT implementation. url: https://yummers.dev/a-fast-cpu-fft.html image: https://yummers.dev/images/2026_09_05/Screenshot%20From%202026-09-05%2017-18-00.png
a fast CPU FFT {data-date="8 Sep 2026"}
The fast Fourier transform (FFT) is one of the most important algorithms in computer science. Modern telephony, image compression, signal analysis, and many other applications rely on the FFT at their core. It's correspondingly well researched, and has been implemented many times over. I will be reimplementing it myself, (1) because it's fun, and (2) because I'll need an unusual version of it for my ocean water simulation. This document serves as a follow-along derivation of my optimized CPU implementation, which exceeds the performance of rustfft's scalar code at the input sizes I care about.
::: {.article-toc} :::
Background
The FFT is just an efficient way of computing something called the "discrete Fourier transform." What is that, and why do we care? Essentially, a Fourier transform decomposes an input "signal", such as a sound wave or an image, into a bunch of sines and cosines. When you add those sines and cosines together, you get back the original signal. In the real world, signals are typically considered to be "continuous", meaning they aren't composed of blocks of a minimum size (if you ignore quantum mechanics). The classical Fourier transform deals with those kinds of signals. However, our digital lives are composed of blocks of minimum size: 1s and 0s, or bits. The discrete Fourier transform (DFT) deals with this kind of signal.
This representation is extremely useful in a number of applications. For example, to compress an image, you can simply strip away all the high-frequency components of the signal. It turns out the human eye can't really tell, and you can make an image much, much smaller before it becomes obvious that it's been compressed. The same goes for music, telephony, and video. I will be using it to implement a realtime ocean water simulation - it turns out that the sum-of-sines representation is actually a highly accurate way to represent the dynamics of so-called "fully developed" oceans. More on that in a followup article - for now, we focus on the FFT.
The DFT
The DFT is defined as follows1
$$ X_k = \sum_{n=0}^{N-1} x_n e^{-\frac{2 \pi i}{N} k n} $$
Let's unpack that.
Our input signal $x$ is comprised of $N$ samples: $x = [x_0, x_1, ... x_{N-1}]$. A typical 1-second sound wave would have 44,100 samples, where each sample represents the air pressure that a microphone measured at that point in time. For that reason, we say that the input signal is in the time domain.
The DFT version of our signal, $X$, is also comprised of $N$ samples, but we index them with $k$ instead: $X_k = [X_0, X_1, ... X_{N-1}]$.
We can simplify our expression a bit to get a sense of what's happening:
$$ X_k = \sum_{n=0}^{N-1} x_n W^{nk}_N $$
To get the $k$th term of the DFT, we have to add up every component of the input signal multiplied by some term $W^{nk}_N$. Since there are $N$ terms in the DFT, we must do (N additions of the input signal) * (N times for the output signal). Thus this is an $O(N^2)$ algorithm. We will get back to this later!
The inner term, $e^{-i ...}$ might be a head scratcher. What does it mean to exponentiate by an imaginary number? Where are the sines and cosines? Well, there's a famous formula2 from calculus which tells us that:
$$ e^{it} = \cos{t} + i \sin{t} $$
(This formula drops out of the Maclaurin series for $e^x$, $\cos x$, and $\sin x$. By rearranging terms you wind up with this identity.)
So although our expression is expressed in the form $e^{i \dots}$, it is really representing a sum of sines and cosines. Neat!
Finally, there is something unintuitive to reflect on. In the real numbers, there are at most two solutions to this equation, assuming that $k$ is a natural number (1, 2, 3...):
$$ 1 = x^k $$
If $k$ is even, the only solution is $x = 1$; if $k$ is even, there is also the solution $x = -1$.
In the complex numbers, we can have more than one solution. In general, for any natural number $k$, there are $k$ solutions, and they are of the form:
$$ 1 = e^{\frac{2 \pi p}{k} i} $$
... where $p \in [1, k]$
(Read as "$p$ is an element of the range of numbers starting at 1 and ending at $k$").
For $k=1$:
$$ e^{\frac{2 \pi i}{1}} = \cos{2\pi} + i \sin{2\pi} = 1 + 0 = 1 $$
For $k=2$:
$$ \begin{array}{rclclcl} e^{\frac{2 \pi i}{2} 1} &=& \cos{\pi} + i \sin{\pi} &=& -1 + 0 &=& -1 \ e^{\frac{2 \pi i}{2} 2} &=& \cos{2\pi} + i \sin{2\pi} &=& 1 + 0 &=& 1 \end{array} $$
So for a given $k$, the set of complex numbers that satisfy the relationship $1 = x^k$ are called the $k$th roots of unity, and there are $k$ of them. ("Unity" is just another word for 1.) We can visualize them as simply dividing a circle in the complex plane:
In summary:
- $x_n$ is the $n$th term of the input signal. There are $N$ total input terms.
- $X_k$ is the $k$th term of the DFT. There are $N$ total terms.
- $W^{nk}_N = e^{-\frac{2 \pi i}{N} nk}$. We observe that this is an $N$th root of unity.
- Each term of the DFT multiplies each term of the input by $W^{nk}_N$.
- Naively evaluating a DFT takes O(N^2) time.
Implementing the DFT
The DFT is fairly straightforward to implement in code. Here it is in Rust:
#Converts a `usize` to a float. fnusize_to_float <T : Float >( value: usize) ->T { num :: cast ( value). unwrap () } #Evaluates theDFT of `data`. fnnaive_dft <T : Float +FloatConst >( data: & mut[ Complex <T >]) { let big_n = data. len (); let mut result =vec! [ Complex :: new ( T :: zero (), T :: zero ()); big_n]; for kin 0 ..big_n{ for nin 0 ..big_n{ let k_t =usize_to_float ::< T >( k); let n_t =usize_to_float ::< T >( n); let big_n_t =usize_to_float ::< T >( big_n); let phase = -T :: TAU () * k_t* n_t / big_n_t; let factor =Complex ::< T >:: cis ( phase); result[ k] = result[ k] + data[ n] * factor; } } data. copy_from_slice ( & result); }
This is technically correct, but there are many problems with this code:
- The factors $W_N^{nk}$ are recomputed each time we call this function, even
though they do not change with respect to
data. We should hoist that computation out. - $W_N^{nk}$, also called twiddles, are computed with type
T, which may be a low-precision float. We should compute them in high precision, then cast to low-precision at the end. Hoisting them out of this function also justifies running that computation in high precision, since it's no longer on the hot path. - We accumulate floating point adds sequentially, which accumulates more error than if we accumulated them via a binary tree.
- The phase calculation does several floating point multiplications and divisions in the hot path. Had we hoisted our twiddles out, we could get away with no divisons and a single multiply. More on that later.
- The copy at the end is expensive, and we'd like to avoid it if possible.
- Converting floats to ints in the hot path is not free.
- We allocate and initialize an array,
result, on the hot path. It's better than doing it on the heap, but it's still slow. The allocation should be hoisted out.
We won't be addressing those until we get into our fast Fourier transform, but I want to start pointing out the kinds of issues we need to think about. The name of the game is doing as little work as possible in the hot path.
DFT Evaluation
Let's take a look at the DFT's numerical accuracy and speed. We will be comparing against rust's rustfft crate as our speed of light. We will also be using a 4096-element array of randomized elements to measure both our numeric accuracy and speed. When measuring performance, we use Criterion to minimize the effects of cache hotness, scheduling noise, etc. To measure error, we use rustfft on a 64-bit signal as our source of truth. Finally, we will disable all vectorization (AVX/SSE) when measuring performance, since our end goal is a GPU-friendly algorithm which won't have access to those intrinsics.
The results are as follows:
| Algorithm | Duration | Max. error | Avg. error |
|---|---|---|---|
| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 |
| rustfft | 14.791 us | 0.00009481 | 0.00000397 |
(Input size 4096, type f32.)
The speed-of-light implementation is not only ~5,690x faster, it's ~2,190x more accurate in the worst case, and ~2,353x more accurate on average.
So, how are we going to bridge this gap?
The fast Fourier transform
As highlighted above, naively evaluating a DFT takes $O(N^2)$ time, where $N$ is the length of the input signal. There is an algorithm appropriately named the fast Fourier transform (FFT) which evaluates the same result in $O(N \log N)$ time. It works by dividing the input into two parts, evaluating the FFT on each part (which is now half as big), then using some clever math to efficiently combine the results. Let's get into it.
Recall the definition of the DFT:
$$ X_k = \sum_{n=0}^{N-1} x_n e^{-\frac{2 \pi i}{N} k n} $$
We can split this by even and odd indices $n$:
$$ X_k = \sum_{n=0}^{N/2-1} x_{2n} e^{-\frac{2 \pi i}{N} k (2n)} + \sum_{n=0}^{N/2-1} x_{2n+1} e^{-\frac{2 \pi i}{N} k (2n+1)} $$
Next, factor out $e^{-\frac{2\pi i}{N}k}$ from the second sum:
$$ X_k = \sum_{n=0}^{N/2-1} x_{2n} e^{-\frac{2 \pi i}{N} k 2n} + e^{-\frac{2\pi i}{N}k} \sum_{n=0}^{N/2-1} x_{2n+1} e^{-\frac{2 \pi i}{N} k 2n} $$
(This factoring follows from the fact that, in general, $a^{b+1} = a a^b$.)
Inside the sum, multiply the exponent by $\frac{1/2}{1/2}$, i.e. 1:
$$ \begin{align*} X_k &= \sum_{n=0}^{N/2-1} x_{2n} e^{-\frac{2 \pi i}{N/2} k n} + e^{-\frac{2\pi i}{N}k} \sum_{n=0}^{N/2-1} x_{2n+1} e^{-\frac{2 \pi i}{N/2} k n} \ &= E_k + e^{-\frac{2\pi i}{N}k} O_k \end{align*} $$
Note what just happened: we have represented the $k$th term of the DFT in terms of the sums of two DFT's with half as many terms! That is the essence of how the FFT runs in $O(N \log N)$ time. The only lurking issue is that this only holds for $k$ in the range $[0, N/2)$. To get $k$ in the range $[N/2, N)$, we have to do some analysis. We will replace every instance of $k$ with $k+N/2$, then attempt to refactor the expression to get a result that only deals with indices of $k$:
::: {.wide-math} $$ \begin{array}{rcllllll} X_{k+N/2} &=& \sum_{n=0}^{\frac{N}{2}-1} x_{2n} & e^{-\frac{2 \pi i}{N/2} (k + \frac{N}{2}) n} & + & e^{-\frac{2\pi i}{N} (k + \frac{N}{2})} & \sum_{n=0}^{\frac{N}{2}-1} x_{2n+1} e^{-\frac{2 \pi i}{N/2} (k + \frac{N}{2}) n} & \ &=& \dots & e^{-\frac{2 \pi i}{N/2} nk} e^{-\frac{2 \pi i}{N/2} n \frac{N}{2}} & + & \dots & & \ &=& \dots & e^{-\frac{2 \pi i}{N/2} nk} e^{-2 \pi i n} & + & \dots & & \ &=& \dots & e^{-\frac{2 \pi i}{N/2} nk} & + & \dots & \ &=& \dots & & + & e^{-\frac{2\pi i}{N} k} e^{-\frac{2\pi i}{N}\frac{N}{2}} & \dots & \ &=& \dots & & + & e^{-\frac{2\pi i}{N} k} e^{-\pi i} & \dots & \ &=& \dots & & + & e^{-\frac{2\pi i}{N} k} (-1) & \dots & \ &=& \dots & & + & \dots & \sum_{n=0}^{\frac{N}{2}-1} x_{2n+1} e^{-\frac{2 \pi i}{N/2} nk} & e^{-\frac{2 \pi i}{N/2} n\frac{N}{2}} \ &=& \dots & & + & \dots & & e^{2 \pi i n} \ &=& \dots & & + & \dots & & 1 \ &=& \sum_{n=0}^{\frac{N}{2}-1} x_{2n} & e^{-\frac{2\pi i}{N/2} nk} & - & e^{-\frac{2\pi i}{N} k} & \sum_{n=0}^{\frac{N}{2}-1} x_{2n+1} e^{-\frac{2 \pi i}{N/2} n k} & \ \end{array} $$ :::
In conclusion:
$$ \begin{align*} X_k &= E_k + W_N^K O_k \ X_{k+N/2} &= E_k - W_N^K O_k \end{align} $$
Let's reflect on a couple things.
First, we divide the input into evens and odds. This only works if the input is divisible by 2. Since we're going to be doing this recursively, we actually need it to be a power of 2. We can relax this by dividing the input into thirds, fourths, fifths, etc., which we'll have to get into later. If at all possible, you should try to FFT an input signal with a length whose prime factors are small. This lets us apply various analytic tricks to make it fast. It's common to pad with 0s, although that can create artifacts in the frequency-domain spectrum.
Second, splitting the input into even and odd terms isn't the only choice. This approach is called decimation in time, because you still have samples near the beginning and end, but half as many overall. Your sample rate has halved, but the time interval is about the same. We might instead split it into a lower and upper half. This approach is called decimation in frequency: your time intervals halve, but the frequency rate in each half is the same.
Implementing the FFT
The FFT is far less trivial to implement than the DFT. Here is the simplest code I could come up with:
// Checks that `n = k^p`, for some natural number `p`. fn is_power_of_k ( n : usize , k : usize ) ->bool { match n{ 0 =>false , 1 =>true , _ => n % k ==0 &&is_power_of_k ( n / k, k), } } // Helper to naive_fft. Takes `data` along with 3 numbers that let us recreate an even-odd subset: // - `start_idx` tells us where the subset begins; // - `big_n` is the number of elements in the subset; // - `stride` is the distance between elements. // We also use a double buffer, `scratch`, to avoid clobbering data while merging results. # [ rustfmt :: skip ] fn _naive_fft < T : Float +FloatConst >( data : & mut [ Complex < T >], start_idx : usize , big_n : usize , stride : usize , scratch : & mut [ Complex < T >]) { if big_n ==1 { return ; } // Compute DFT of even elements. _naive_fft ( data, start_idx, big_n/2 , stride* 2 , scratch); // Odd elements. _naive_fft ( data, start_idx+stride, big_n/2 , stride* 2 , scratch); for kin 0 ..( big_n/2 ) { let p = data[ start_idx +2 * k* stride]; let q = data[ start_idx +( 2 * k +1 ) * stride]; let k_t =usize_to_float ::< T >( k); let big_n_t =usize_to_float ::< T >( big_n); let phase = -T :: TAU () * k_t / big_n_t; let factor =Complex ::< T >:: cis ( phase); scratch[ start_idx + k* stride] = p + q* factor; scratch[ start_idx +( k + big_n /2 ) * stride] = p - q* factor; } data. copy_from_slice ( scratch); } // Naive implementation of Cooley-Tukey FFT. Modifies `data`in place. Panics if data.len() is not a power of two. # [ allow ( dead_code )] fn naive_fft < T : Float +FloatConst >( data : & mut [ Complex < T >]) { assert! ( is_power_of_k ( data. len (), 2 )); let mut scratch =Vec :: from ( data. as_ref ()); _naive_fft ( data, 0 , data. len (), 1 , & mut scratch); }
This code is obviously highly suboptimal, for many of the same reasons as the DFT code. In addition, we also copy the entire array once per recursive call. There are $O(N)$ recursive calls, so this is extremely wasteful. We'll fix that later by double-buffering.
Inefficiencies aside, this code still performs vastly better than the naive DFT:
| Algorithm | Duration | Max. error | Avg. error |
|---|---|---|---|
| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 |
| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 |
| rustfft | 14.791 us | 0.00009481 | 0.00000397 |
(Input size 4096, type f32.)
We get a nice 62x speedup, and 1335x improvement on average error. However, the speed-of-light implementation is still ~91x faster than ours, and 1.7x more accurate. Most of our work is going to focus on bridging these two gaps, while retaining as simple an implementation as possible.
Note for a moment the impact of sequential adds. The naive DFT performed 4096 sequential adds for each term, and wound up ~2000x less accurate than the speed-of-light. Due to the FFT's recursive structure, we use log(4096) = 12 sequential adds, and that brings our accuracy within a factor of 2 of optimal. Quite the stark difference!
Opt. 1: Precompute twiddles
The twiddle factors, $W_N^{nk}$, do not depend on the input to the FFT, so we can (and should!) hoist them out of the hot path. Production FFT libraries like fftw and rustfft do this, and we'll follow in their footsteps. This also lets us precompute the twiddles in high precision before casting to low precision, which as we'll see, improves the precision of the end result.
First, let's precompute our twiddle factors:
// Calculates the "twiddle factors" for an n-element FFT, aka all of the nth roots of unity. fn precompute_twiddles < T : Float +FloatConst >( n : usize ) ->Vec < Complex < T >> { let mut result =vec! [ Complex ::< T >:: new ( T :: zero (), T :: zero ()); n]; let n_f64 =usize_to_float ::< f64 >( n); for iin 0 ..n{ let tw_f64 =Complex ::< f64 >:: cis ( -f64:: TAU () * usize_to_float ::< f64 >( i) /( n_f64)); result[ i] =Complex :: new ( T :: from ( tw_f64. re ). unwrap (), T :: from ( tw_f64. im ). unwrap ()); } result}
Next, adjust our function to take these twiddles as input:
fn _fft_v1_hoist < T : Float +FloatConst >( data : & mut [ Complex < T >], start_idx : usize , big_n : usize , stride : usize , scratch : & mut [ Complex < T >], twiddles : & [ Complex < T >], ) { if big_n ==1 { return ; } // Compute DFT of even elements. _fft_v1_hoist ( data, start_idx, big_n /2 , stride* 2 , scratch, twiddles); // Odd elements. _fft_v1_hoist ( data, start_idx + stride, big_n /2 , stride* 2 , scratch, twiddles, ); for kin 0 ..( big_n /2 ) { let p = data[ start_idx +2 * k* stride]; let q = data[ start_idx +( 2 * k +1 ) * stride]; let factor = twiddles[ k* stride]; scratch[ start_idx + k* stride] = p + q* factor; scratch[ start_idx +( k + big_n /2 ) * stride] = p - q* factor; } data. copy_from_slice ( scratch); } // Modification of fft_naive: hoist out and precompute twiddles. pub fn fft_v1_hoist < T : Float +FloatConst >( data : & mut [ Complex < T >], twiddles : & [ Complex < T >]) { assert! ( is_power_of_k ( data. len (), 2 )); let mut scratch =Vec :: from ( data. as_ref ()); _fft_v1_hoist ( data, 0 , data. len (), 1 , & mut scratch, & twiddles); }
We see a modest performance uplift, but our average-case error is now within spitting distance of the speed-of-light, and our worst-case error matches exactly:
| Algorithm | Duration | Max. error | Avg. error |
|---|---|---|---|
| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 |
| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 |
| FFT v1 | 1.2813 ms | 0.00009481 | 0.00000410 |
| rustfft | 14.791 us | 0.00009481 | 0.00000397 |
Opt. 2: Double buffering
Our FFT algorithms thus far have done a fully length-$N$ copy at each recurisive step. Because each recursive step divides the length of the array by 2, we make a total of $1 + 2 + 4 + \dots N/2$ function calls, which sums to $N-1$ total calls. Each one does a copy of length $N$, so if each copy takes $O(N)$ itme, we spend $O(N^2)$ time copying buffers overall. Not good!
We can fix this pretty easily with double buffering:
fn _fft_v2_double_buffer < T : Float +FloatConst >( src : & mut [ Complex < T >], dst : & mut [ Complex < T >], start_idx : usize , big_n : usize , stride : usize , twiddles : & [ Complex < T >], ) { if big_n ==1 { return ; } // Compute DFT of even elements. _fft_v2_double_buffer ( dst, src, start_idx, big_n /2 , stride* 2 , twiddles); // Odd elements. _fft_v2_double_buffer ( dst, src, start_idx + stride, big_n /2 , stride* 2 , twiddles, ); for kin 0 ..( big_n /2 ) { let p = src[ start_idx +2 * k* stride]; let q = src[ start_idx +( 2 * k +1 ) * stride]; let factor = twiddles[ k* stride]; dst[ start_idx + k* stride] = p + q* factor; dst[ start_idx +( k + big_n /2 ) * stride] = p - q* factor; } } # [ allow ( dead_code )] pub fn fft_v2_double_buffer < T : Float +FloatConst >( src : & mut [ Complex < T >], dst : & mut [ Complex < T >], twiddles : & [ Complex < T >], ) { assert! ( is_power_of_k ( src. len (), 2 )); dst. copy_from_slice ( src); // Switching `src` and `dst` means that at the end, the result is in `src` - which is actually // what we want! We will be hiding `dst` and `twiddles` in a struct later on :) _fft_v2_double_buffer ( dst, src, 0 , src. len (), 1 , twiddles); }
Note that we only hoist out the allocation of the double-buffer. Initialization still occurs in the hot path.
Accuracy numbers are identical to before, as expected, and performance is vastly improved:
| Algorithm | Duration | Max. error | Avg. error |
|---|---|---|---|
| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 |
| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 |
| FFT v1 | 1.2813 ms | 0.00009481 | 0.00000410 |
| FFT v2 | 39.944 us | 0.00009481 | 0.00000410 |
| rustfft | 14.791 us | 0.00009481 | 0.00000397 |
Pretty remarkable result. Minimizing memory writes gets us within a factor of 3 of the state of the art.
Still... we can go faster!
Opt. 3: Iterative instead of recursive
The recursive implementation we're using is good for the classroom, but bad for performance. If we switch to an iterative implementation, we'll be able to share work each time we step down a layer of recursion. It will also make it much easier to map this algorithm to the GPU (more on that later).
Let's do it:
pub fn fft_v3_iterative < T : Float +FloatConst >( src : & mut [ Complex < T >], dst : & mut [ Complex < T >], twiddles : & [ Complex < T >], ) { assert! ( is_power_of_k ( src. len (), 2 )); dst. copy_from_slice ( src); let n_iter =log_k_of ::< 2 >( src. len ()); if n_iter %2 !=0 { dst. copy_from_slice ( src); } let ( mut input, mut output) =if n_iter %2 ==0 { ( dst, src) } else { ( src, dst) }; let mut stride = input. len (); let mut big_n =1 ; for _in 0 ..n_iter{ stride /=2 ; big_n *=2 ; std:: mem:: swap ( & mut input, & mut output); for start_idxin 0 ..stride{ for kin 0 ..big_n /2 { // Get odd and even elements. let p = input[ start_idx +2 * k* stride]; let q = input[ start_idx +( 2 * k +1 ) * stride]; // Combine. let factor = twiddles[ k* stride]; output[ start_idx + k* stride] = p + q* factor; output[ start_idx +( k + big_n /2 ) * stride] = p - q* factor; } } } }
This is essentially identical to the v2 code, except that we use iteration instead of recursion. Regardless, the performance uplift is dramatic:
| Algorithm | Duration | Max. error | Avg. error |
|---|---|---|---|
| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 |
| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 |
| FFT v1 | 1.2813 ms | 0.00009481 | 0.00000410 |
| FFT v2 | 39.944 us | 0.00009481 | 0.00000410 |
| FFT v3 | 23.626 us | 0.00009481 | 0.00000410 |
| rustfft | 14.791 us | 0.00009481 | 0.00000397 |
We're well within a factor of 2 of SOTA now! No, we're not done.
Aside: the radix-4 FFT
Let's think, for a moment, what our FFT would look like if instead of splitting the input into 2 parts at each stage, we broke it into 4:
$$ \begin{array}{rcll} X_k = & \sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \ & \sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n+1)k} & + \ & \sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n+2)k} & + \ & \sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n+3)k} & \end{array} $$
Apply the usual factoring trick:
$$ \begin{array}{rclll} X_k = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \ & e^{-\frac{2\pi i}{N}k} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)k} & + \ & e^{-\frac{2\pi i}{N}2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)k} & + \ & e^{-\frac{2\pi i}{N}3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)k} & \end{array} $$
This is only valid for $k$ on $[0, N/4)$. To get the others we have to do the same analysis as before - replace every $k$ with $k + N/4$, then do some eliminations and factoring.
Calculating $k+N/4$
$$ \begin{array}{rclll} X_{k+N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)(k+N/4)} & + \ & e^{-\frac{2\pi i}{N}(k+N/4)} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)(k+N/4)} & + \ & e^{-\frac{2\pi i}{N}2(k+N/4)} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)(k+N/4)} & + \ & e^{-\frac{2\pi i}{N}3(k+N/4)} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)(k+N/4)} & \end{array} $$
Simplify the shared inner term:
$$ \begin{align*} e^{-\frac{2\pi i}{N}(4n)(k+N/4)} &= e^{-\frac{2\pi i}{N}(4n)k} e^{-\frac{2\pi i}{N}(4n)N/4} \ &= e^{-\frac{2\pi i}{N}(4n)k} e^{-2\pi i n} \ &= e^{-\frac{2\pi i}{N}(4n)k} \end{align*} $$
Simplify the first outer term:
$$ \begin{align*} e^{-\frac{2\pi i}{N}(k+N/4)} &= e^{-\frac{2\pi i}{N}k} e^{-\frac{2\pi i}{N}N/4} \ &= e^{-\frac{2\pi i}{N}k} e^{-\frac{2\pi i}{4}} \ &= e^{-\frac{2\pi i}{N}k} (-i) \ \end{align*} $$
By inspection, we can see that the second and third terms will be of this form as well. We're basically just multiplying by a vector that's rotating 90 degrees clockwise in the complex plane:
$$ \begin{align*} e^{-\frac{2\pi i}{N}(2k+2N/4)} &= e^{-\frac{2\pi i}{N}2k} e^{-\frac{2\pi i 2}{4}} \ &= e^{-\frac{2\pi i}{N}2k} (-1) \ e^{-\frac{2\pi i}{N}(3k+3N/4)} &= e^{-\frac{2\pi i}{N}3k} e^{-\frac{2\pi i 3}{4}} \ &= e^{-\frac{2\pi i}{N}3k} (i) \ \end{align*} $$
Plugging in:
$$ \begin{array}{rrlll} X_{k+N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \ & (-i) e^{-\frac{2\pi i}{N}k} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)k} & + \ & (-1) e^{-\frac{2\pi i}{N}2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)k} & + \ & (i) e^{-\frac{2\pi i}{N}3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)k} & \end{array} $$
Using $W$ syntax:
$$ \begin{array}{rrlll} X_{k+N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & W_N^{4nk} & + \ & (-i) W_N^k &\sum_{n=0}^{N/4-1} x_{4n+1} & W_N^{4nk} & + \ & (-1) W_N^{2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & W_N^{4nk} & + \ & (i) W_N^{3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & W_N^{4nk} & \end{array} $$
Calculating $k+N/2$
$$ \begin{array}{rclll} X_{k+N/2} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)(k+N/2)} & + \ & e^{-\frac{2\pi i}{N}(k+N/2)} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)(k+N/2)} & + \ & e^{-\frac{2\pi i}{N}2(k+N/2)} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)(k+N/2)} & + \ & e^{-\frac{2\pi i}{N}3(k+N/2)} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)(k+N/2)} & \end{array} $$
Simplify the shared inner term:
$$ \begin{align*} e^{-\frac{2\pi i}{N}(4n)(k+N/2)} &= e^{-\frac{2\pi i}{N}(4n)k} e^{-\frac{2\pi i}{N}(4n)N/2} \ &= e^{-\frac{2\pi i}{N}(4n)k} e^{-2\pi i 2n} \ &= e^{-\frac{2\pi i}{N}(4n)k} \end{align*} $$
(We can see from the above that the last quarter will also have the same simplification applied, so we will skip deriving it later.)
Simplify the first outer term:
$$ \begin{align*} e^{-\frac{2\pi i}{N}(k+N/2)} &= e^{-\frac{2\pi i}{N}k} e^{-\frac{2\pi i}{N}N/2} \ &= e^{-\frac{2\pi i}{N}k} e^{-\frac{2\pi i}{2}} \ &= e^{-\frac{2\pi i}{N}k} (-1) \ \end{align*} $$
Let's pause here to reflect. In the $[0, N/4)$, we rotated our outer terms by a quarter turn in the complex plane for each term. Now we're rotating by a half turn. The next leg, we will rotate by 3/4 of a turn.
I will truncate the derivation there. The reader may do the rest as an exercise if needed.
Plugging in:
$$ \begin{array}{rrlll} X_{k+N/2} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \ & (-1) e^{-\frac{2\pi i}{N}k} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)k} & + \ & (+1) e^{-\frac{2\pi i}{N}2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)k} & + \ & (-1) e^{-\frac{2\pi i}{N}3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)k} & \end{array} $$
Using $W$ syntax:
$$ \begin{array}{rrlll} X_{k+N/2} = & &\sum_{n=0}^{N/4-1} x_{4n} & W_N^{4nk} & + \ & (-1) W_N^k &\sum_{n=0}^{N/4-1} x_{4n+1} & W_N^{4nk} & + \ & (+1) W_N^{2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & W_N^{4nk} & + \ & (-1) W_N^{3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & W_N^{4nk} & \end{array} $$
Calculating $k+3N/4$
Per the lemmas in the last section, we can jump right to the result:
$$ \begin{array}{rrlll} X_{k+3N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \ & (+i) e^{-\frac{2\pi i}{N}k} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)k} & + \ & (-1) e^{-\frac{2\pi i}{N}2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)k} & + \ & (-i) e^{-\frac{2\pi i}{N}3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)k} & \end{array} $$
Using $W$ syntax:
$$ \begin{array}{rrlll} X_{k+3N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & W_N^{4nk} & + \ & (+i) W_N^k &\sum_{n=0}^{N/4-1} x_{4n+1} & W_N^{4nk} & + \ & (-1) W_N^{2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & W_N^{4nk} & + \ & (-i) W_N^{3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & W_N^{4nk} & \end{array} $$
Summary
The radix-4 FFT's merge step works as follows:
| $k$ range | Term 0 | Term 1 | Term 2 | Term 3 |
|---|---|---|---|---|
| $[0,N/4)$ | +1 | +1 | +1 | +1 |
| $[N/4,N/2)$ | +1 | -i | -1 | +i |
| $[N/2,3N/4)$ | +1 | -1 | +1 | -1 |
| $[3N/4,N)$ | +1 | +i | -1 | -i |
And for each stage, the twiddles are:
- Term 0: 1
- Term 1: $W_N^k$
- Term 2: $W_N^{2k}$
- Term 3: $W_N^{3k}$
Opt. 4: Radix-4
With the above in mind, we can now implement the radix-4 FFT:
# [ inline ( always )] fn mul_ni < T : Float +FloatConst >( x : Complex < T >) ->Complex < T > { Complex :: new ( x. im , -x. re ) } pub fn fft_v4_radix_4 < T : Float +FloatConst >( src : & mut [ Complex < T >], dst : & mut [ Complex < T >], twiddles : & [ Complex < T >], ) { assert! ( is_power_of_k ( src. len (), 4 )); let n_iter =log_k_of ::< 4 >( src. len ()); dst. copy_from_slice ( src); let ( mut input, mut output) =if n_iter %2 ==0 { ( dst, src) } else { ( src, dst) }; let big_n = input. len (); let mut stride = big_n; let mut big_n =1 ; for _in 0 ..n_iter{ stride /=4 ; big_n *=4 ; std:: mem:: swap ( & mut input, & mut output); for start_idxin 0 ..stride{ for kin 0 ..big_n /4 { // Collect inputs. let i0 = input[ start_idx +4 * k* stride]; let i1 = input[ start_idx +( 4 * k +1 ) * stride]; let i2 = input[ start_idx +( 4 * k +2 ) * stride]; let i3 = input[ start_idx +( 4 * k +3 ) * stride]; // Collect relevant twiddles. let ot1 = twiddles[ 1 * k* stride]; let ot2 = twiddles[ 2 * k* stride]; let ot3 = twiddles[ 3 * k* stride]; let a = i0; let b = ot1* i1; let c = ot2* i2; let d = ot3* i3; // To derive this, write the expression below in terms of // a/b/c/d, then factor out! let ac_sum = a + c; let ac_diff = a - c; let bd_sum = b + d; let bd_diff_ni =mul_ni ( b - d); output[ start_idx + k* stride] = ac_sum + bd_sum; output[ start_idx +( k + big_n /4 ) * stride] = ac_diff + bd_diff_ni; output[ start_idx +( k + big_n /2 ) * stride] = ac_sum - bd_sum; output[ start_idx +( k +3 * big_n /4 ) * stride] = ac_diff - bd_diff_ni; } } } }
As a quick aside - note that we could simply multiply [a, b, c, d] by a 4x4 matrix holding the terms we derived in the previous section. Possibly useful for a GPU implementation!
With this we pick up another ~10% speedup, and actually beat the reference implementation's average-case error!
| Algorithm | Duration | Max. error | Avg. error |
|---|---|---|---|
| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 |
| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 |
| FFT v1 | 1.2813 ms | 0.00009481 | 0.00000410 |
| FFT v2 | 39.944 us | 0.00009481 | 0.00000410 |
| FFT v3 | 23.626 us | 0.00009481 | 0.00000410 |
| FFT v4 | 20.231 us | 0.00009481 | 0.00000396 |
| rustfft | 14.791 us | 0.00009481 | 0.00000397 |
A few remarks:
- Obviously, this can only be used on inputs with a size that's a power of 4. We could get fancy and do some split-radix stuff (some stages are radix a, others radix b, etc.) but I'm not planning to.
- This is faster because we save on complex multiplies and adds.
Opt. 5: Special-case first stage
The twiddles we look up in our inner loop are just $W_N^{k s}$. For the
first iteration, big_n $= 4$, so $k$ is always 0 -- therefore, we use
$W_N^0$, which is just 1. We can special-case this and save a few more complex
multiplies:
fn fft_butterfly_radix_4 < T : Float +FloatConst >( input : & mut [ Complex < T >], output : & mut [ Complex < T >], stride : usize , big_n : usize , twiddles : & [ Complex < T >], ) { for start_idxin 0 ..stride{ for kin 0 ..big_n /4 { // Collect inputs. let i0 = input[ start_idx +4 * k* stride]; let i1 = input[ start_idx +( 4 * k +1 ) * stride]; let i2 = input[ start_idx +( 4 * k +2 ) * stride]; let i3 = input[ start_idx +( 4 * k +3 ) * stride]; // Collect relevant twiddles. let ot1 = twiddles[ 1 * k* stride]; let ot2 = twiddles[ 2 * k* stride]; let ot3 = twiddles[ 3 * k* stride]; let a = i0; let b = ot1* i1; let c = ot2* i2; let d = ot3* i3; // To derive this, write the output assignments in terms of // a/b/c/d, then factor out! let ac_sum = a + c; let ac_diff = a - c; let bd_sum = b + d; let bd_diff_ni =mul_ni ( b - d); output[ start_idx + k* stride] = ac_sum + bd_sum; output[ start_idx +( k + big_n /4 ) * stride] = ac_diff + bd_diff_ni; output[ start_idx +( k + big_n /2 ) * stride] = ac_sum - bd_sum; output[ start_idx +( k +3 * big_n /4 ) * stride] = ac_diff - bd_diff_ni; } } } fn fft_butterfly_radix_4_s0 < T : Float +FloatConst >( input : & mut [ Complex < T >], output : & mut [ Complex < T >], twiddles : & [ Complex < T >], ) { let stride = input. len () /4 ; let big_n =4 ; for start_idxin 0 ..stride{ for kin 0 ..big_n /4 { // Collect inputs. let i0 = input[ start_idx +4 * k* stride]; let i1 = input[ start_idx +( 4 * k +1 ) * stride]; let i2 = input[ start_idx +( 4 * k +2 ) * stride]; let i3 = input[ start_idx +( 4 * k +3 ) * stride]; let a = i0; let b = i1; let c = i2; let d = i3; // To derive this, write the output assignments in terms of // a/b/c/d, then factor out! let ac_sum = a + c; let ac_diff = a - c; let bd_sum = b + d; let bd_diff_ni =mul_ni ( b - d); output[ start_idx + k* stride] = ac_sum + bd_sum; output[ start_idx +( k + big_n /4 ) * stride] = ac_diff + bd_diff_ni; output[ start_idx +( k + big_n /2 ) * stride] = ac_sum - bd_sum; output[ start_idx +( k +3 * big_n /4 ) * stride] = ac_diff - bd_diff_ni; } } } pub fn fft_v5_s0_opt < T : Float +FloatConst >( src : & mut [ Complex < T >], dst : & mut [ Complex < T >], twiddles : & [ Complex < T >], ) { assert! ( is_power_of_k ( src. len (), 4 )); let n_iter =log_k_of ::< 4 >( src. len ()); dst. copy_from_slice ( src); let ( mut input, mut output) =if n_iter %2 ==0 { ( dst, src) } else { ( src, dst) }; let big_n = input. len (); let mut stride = big_n; let mut big_n =1 ; for stagein 0 ..n_iter{ stride /=4 ; big_n *=4 ; std:: mem:: swap ( & mut input, & mut output); if stage ==0 { fft_butterfly_radix_4_s0 ( input, output, twiddles); } else { fft_butterfly_radix_4 ( input, output, stride, big_n, twiddles); } } }
Here I refactored the inner loop of our FFT - called a butterfly in FFT research parlance - and made a variant which avoids those complex multiplies in stage 1. We get a few more microseconds out of this, with no change to our accuracy:
| Algorithm | Duration | Max. error | Avg. error |
|---|---|---|---|
| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 |
| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 |
| FFT v1 | 1.2813 ms | 0.00009481 | 0.00000410 |
| FFT v2 | 39.944 us | 0.00009481 | 0.00000410 |
| FFT v3 | 23.626 us | 0.00009481 | 0.00000410 |
| FFT v4 | 20.231 us | 0.00009481 | 0.00000396 |
| FFT v5 | 16.383 us | 0.00009481 | 0.00000396 |
| rustfft | 14.791 us | 0.00009481 | 0.00000397 |
Within 12% of our speed-of-light! No, we're not done yet :)
Opt. 6: Unsafe
Our butterfly does 8 array lookups, each of which Rust will bounds-check for
us. However, we know by inspection that they will never go out of bounds. So we
can tell Rust this with the unsafe keyword, and enable more compiler
optimizations.
fn fft_butterfly_radix_4_unsafe < T : Float +FloatConst >( input : & mut [ Complex < T >], output : & mut [ Complex < T >], stride : usize , big_n : usize , twiddles : & [ Complex < T >], ) { let input_ptr = input. as_ptr (); let output_ptr = output. as_mut_ptr (); for start_idxin 0 ..stride{ for kin 0 ..big_n /4 { unsafe { // Collect inputs. let i0 =* input_ptr. add ( start_idx +4 * k* stride); let i1 =* input_ptr. add ( start_idx +( 4 * k +1 ) * stride); let i2 =* input_ptr. add ( start_idx +( 4 * k +2 ) * stride); let i3 =* input_ptr. add ( start_idx +( 4 * k +3 ) * stride); // Collect relevant twiddles. let ot1 = twiddles. get_unchecked ( 1 * k* stride); let ot2 = twiddles. get_unchecked ( 2 * k* stride); let ot3 = twiddles. get_unchecked ( 3 * k* stride); let a = i0; let b = ot1* i1; let c = ot2* i2; let d = ot3* i3; // To derive this, write the output assignments in terms of // a/b/c/d, then factor out! let ac_sum = a + c; let ac_diff = a - c; let bd_sum = b + d; let bd_diff_ni =mul_ni ( b - d); * output_ptr. add ( start_idx + k* stride) = ac_sum + bd_sum; * output_ptr. add ( start_idx +( k + big_n /4 ) * stride) = ac_diff + bd_diff_ni; * output_ptr. add ( start_idx +( k + big_n /2 ) * stride) = ac_sum - bd_sum; * output_ptr. add ( start_idx +( k +3 * big_n /4 ) * stride) = ac_diff - bd_diff_ni; } } } } fn fft_butterfly_radix_4_s0_unsafe < T : Float +FloatConst >( input : & mut [ Complex < T >], output : & mut [ Complex < T >], ) { let stride = input. len () /4 ; let big_n =4 ; let input_ptr = input. as_ptr (); let output_ptr = output. as_mut_ptr (); for start_idxin 0 ..stride{ for kin 0 ..big_n /4 { unsafe { // Collect inputs. let i0 = input[ start_idx +4 * k* stride]; let i1 = input[ start_idx +( 4 * k +1 ) * stride]; let i2 = input[ start_idx +( 4 * k +2 ) * stride]; let i3 = input[ start_idx +( 4 * k +3 ) * stride]; let a = i0; let b = i1; let c = i2; let d = i3; // To derive this, write the output assignments in terms of // a/b/c/d, then factor out! let ac_sum = a + c; let ac_diff = a - c; let bd_sum = b + d; let bd_diff_ni =mul_ni ( b - d); * output_ptr. add ( start_idx + k* stride) = ac_sum + bd_sum; * output_ptr. add ( start_idx +( k + big_n /4 ) * stride) = ac_diff + bd_diff_ni; * output_ptr. add ( start_idx +( k + big_n /2 ) * stride) = ac_sum - bd_sum; * output_ptr. add ( start_idx +( k +3 * big_n /4 ) * stride) = ac_diff - bd_diff_ni; } } } } pub fn fft_v6_unsafe < T : Float +FloatConst >( src : & mut [ Complex < T >], dst : & mut [ Complex < T >], twiddles : & [ Complex < T >], ) { assert! ( is_power_of_k ( src. len (), 4 )); assert_eq! ( src. len (), dst. len ()); assert_eq! ( twiddles. len (), src. len ()); let n_iter =log_k_of ::< 4 >( src. len ()); dst. copy_from_slice ( src); let ( mut input, mut output) =if n_iter %2 ==0 { ( dst, src) } else { ( src, dst) }; let big_n = input. len (); let mut stride = big_n; let mut big_n =1 ; for stagein 0 ..n_iter{ stride /=4 ; big_n *=4 ; std:: mem:: swap ( & mut input, & mut output); if stage ==0 { fft_butterfly_radix_4_s0_unsafe ( input, output); } else { fft_butterfly_radix_4_unsafe ( input, output, stride, big_n, twiddles); } } }
Note that "add" just means "add a value to this pointer." Seems to be the canonical way to do pointer arithmetic in Rust. With this, we have nearly reached the speed of light!
| Algorithm | Duration | Max. error | Avg. error |
|---|---|---|---|
| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 |
| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 |
| FFT v1 | 1.2813 ms | 0.00009481 | 0.00000410 |
| FFT v2 | 39.944 us | 0.00009481 | 0.00000410 |
| FFT v3 | 23.626 us | 0.00009481 | 0.00000410 |
| FFT v4 | 20.231 us | 0.00009481 | 0.00000396 |
| FFT v5 | 16.383 us | 0.00009481 | 0.00000396 |
| FFT v6 | 14.830 us | 0.00009481 | 0.00000396 |
| rustfft | 14.791 us | 0.00009481 | 0.00000397 |
No, we're not done.
Opt. 7: Radix-8
Why stop at radix-4? If we extend to radix-8, we still get the desirable analytic property of our factors not requiring complex multiplies, as they're just 45-degree rotations, but we also reduce the number of stages.
For a length-4096 input, aka $2^12$, radix-4 requires 6 stages, where radix-8 requires only 4. If each stage does 3 and 7 complex multiplies respectively, we wind up with 18$s$ vs. $14$ total complex multiplies.
We also reduce the number of times that we need to read the full data buffer from 6 to 4.
I can derive the radix-8 twiddles by inspection - it's left as an exercise to the reader if needed. (Tip: visualize the rotations through the complex plane.)
Let $p = \frac{1}{\sqrt{2}}$. Then:
| $k$ range | Term 0 | Term 1 | Term 2 | Term 3 | Term 4 | Term 5 | Term 6 | Term 7 |
|---|---|---|---|---|---|---|---|---|
| $[0,N/8)$ | +1 | +1 | +1 | +1 | +1 | +1 | +1 | +1 |
| $[N/8,N/4)$ | +1 | $+p-ip$ | -i | $-p-ip$ | -1 | $-p+ip$ | +i | $+p+ip$ |
| $[N/4,3N/8)$ | +1 | -i | -1 | +i | +1 | -i | -1 | +i |
| $[3N/8,N/2)$ | +1 | $-p-ip$ | +i | $p-ip$ | -1 | $p+ip$ | -i | $-p+ip$ |
| $[N/2,5N/8)$ | +1 | -1 | +1 | -1 | +1 | -1 | +1 | -1 |
| $[5N/8,3N/4)$ | +1 | $-p+ip$ | -i | $p+ip$ | -1 | $p-ip$ | +i | $-p-ip$ |
| $[3N/4,7N/8)$ | +1 | +i | -1 | -i | +1 | +i | -1 | -i |
| $[7N/8,N)$ | +1 | $p+ip$ | +i | $-p+ip$ | -1 | $-p-ip$ | -i | $p-ip$ |
And the twiddles are $1, W_N^k, W_N^{2k}, \dots, W_N^{7k}$.
First, we need an optimized way to rotate by 45 degrees, as well as every multiple of 90 degrees. The standard 2D rotation matrix3 makes this easy:
# [ inline ( always )] fn rot_45 < T : Float +FloatConst >( c : Complex < T >) ->Complex < T > { let s =T :: FRAC_1_SQRT_2 (); // The standard 2D rotation matrix gives: // [ cos(pi/4) -sin(pi/4)] [ s -s ] // [ sin(pi/4) cos(pi/4)] = [ s s ] Complex ::< T >:: new ( c. re - c. im , c. re + c. im ) * s} # [ inline ( always )] fn rot_90 < T : Float +FloatConst >( c : Complex < T >) ->Complex < T > { // The standard 2D rotation matrix gives: // [ cos(pi/2) -sin(pi/2)] [ 0 -1 ] // [ sin(pi/2) cos(pi/2)] = [ 1 0 ] Complex ::< T >:: new ( -c. im , c. re ) } # [ inline ( always )] fn rot_180 < T : Float +FloatConst >( c : Complex < T >) ->Complex < T > { // The standard 2D rotation matrix gives: // [ cos(pi) -sin(pi)] [ -1 0 ] // [ sin(pi) cos(pi)] = [ 0 -1 ] -c} # [ inline ( always )] fn rot_270 < T : Float +FloatConst >( c : Complex < T >) ->Complex < T > { // The standard 2D rotation matrix gives: // [ cos(3pi/2) -sin(3pi/2)] [ 0 1 ] // [ sin(3pi/2) cos(3pi/2)] = [ -1 0 ] Complex ::< T >:: new ( c. im , -c. re ) }
Next, we just write out our big radix-8 butterflies:
fn fft_butterfly_radix_8_unsafe < T : Float +FloatConst >( input : & mut [ Complex < T >], output : & mut [ Complex < T >], stride : usize , big_n : usize , twiddles : & [ Complex < T >], ) { let input_ptr = input. as_ptr (); let output_ptr = output. as_mut_ptr (); for start_idxin 0 ..stride{ for kin 0 ..big_n /8 { unsafe { // Collect inputs. let i0 =* input_ptr. add ( start_idx +8 * k* stride); let i1 =* input_ptr. add ( start_idx +( 8 * k +1 ) * stride); let i2 =* input_ptr. add ( start_idx +( 8 * k +2 ) * stride); let i3 =* input_ptr. add ( start_idx +( 8 * k +3 ) * stride); let i4 =* input_ptr. add ( start_idx +( 8 * k +4 ) * stride); let i5 =* input_ptr. add ( start_idx +( 8 * k +5 ) * stride); let i6 =* input_ptr. add ( start_idx +( 8 * k +6 ) * stride); let i7 =* input_ptr. add ( start_idx +( 8 * k +7 ) * stride); // Collect relevant twiddles. let ot1 = twiddles. get_unchecked ( 1 * k* stride); let ot2 = twiddles. get_unchecked ( 2 * k* stride); let ot3 = twiddles. get_unchecked ( 3 * k* stride); let ot4 = twiddles. get_unchecked ( 4 * k* stride); let ot5 = twiddles. get_unchecked ( 5 * k* stride); let ot6 = twiddles. get_unchecked ( 6 * k* stride); let ot7 = twiddles. get_unchecked ( 7 * k* stride); let a = i0; let b = ot1* i1; let c = ot2* i2; let d = ot3* i3; let e = ot4* i4; let f = ot5* i5; let g = ot6* i6; let h = ot7* i7; let ae_sum = a + e; let ae_diff = a - e; let bf_sum = b + f; let bf_diff = b - f; let cg_sum = c + g; let cg_diff = c - g; let dh_sum = d + h; let dh_diff = d - h; let w00 = ae_sum + cg_sum; let w01 = ae_sum - cg_sum; let w10 = ae_diff +rot_270 ( cg_diff); let w11 = ae_diff -rot_270 ( cg_diff); let x00 = bf_sum + dh_sum; let x01 =rot_270 ( bf_sum) +rot_90 ( dh_sum); let x10 =rot_45 ( rot_270 ( bf_diff) +rot_180 ( dh_diff)); let x11 =rot_45 ( rot_180 ( bf_diff) +rot_270 ( dh_diff)); * output_ptr. add ( start_idx + k* stride) = w00 + x00; * output_ptr. add ( start_idx +( k + big_n /8 ) * stride) = w10 + x10; * output_ptr. add ( start_idx +( k + big_n /4 ) * stride) = w01 + x01; * output_ptr. add ( start_idx +( k +3 * big_n /8 ) * stride) = w11 + x11; * output_ptr. add ( start_idx +( k + big_n /2 ) * stride) = w00 - x00; * output_ptr. add ( start_idx +( k +5 * big_n /8 ) * stride) = w10 - x10; * output_ptr. add ( start_idx +( k +3 * big_n /4 ) * stride) = w01 - x01; * output_ptr. add ( start_idx +( k +7 * big_n /8 ) * stride) = w11 - x11; } } } } fn fft_butterfly_radix_8_s0_unsafe < T : Float +FloatConst >( input : & mut [ Complex < T >], output : & mut [ Complex < T >], ) { let stride = input. len () /8 ; let big_n =8 ; let input_ptr = input. as_ptr (); let output_ptr = output. as_mut_ptr (); for start_idxin 0 ..stride{ for kin 0 ..big_n /8 { unsafe { // Collect inputs. let i0 =* input_ptr. add ( start_idx +8 * k* stride); let i1 =* input_ptr. add ( start_idx +( 8 * k +1 ) * stride); let i2 =* input_ptr. add ( start_idx +( 8 * k +2 ) * stride); let i3 =* input_ptr. add ( start_idx +( 8 * k +3 ) * stride); let i4 =* input_ptr. add ( start_idx +( 8 * k +4 ) * stride); let i5 =* input_ptr. add ( start_idx +( 8 * k +5 ) * stride); let i6 =* input_ptr. add ( start_idx +( 8 * k +6 ) * stride); let i7 =* input_ptr. add ( start_idx +( 8 * k +7 ) * stride); let a = i0; let b = i1; let c = i2; let d = i3; let e = i4; let f = i5; let g = i6; let h = i7; let ae_sum = a + e; let ae_diff = a - e; let bf_sum = b + f; let bf_diff = b - f; let cg_sum = c + g; let cg_diff = c - g; let dh_sum = d + h; let dh_diff = d - h; let w00 = ae_sum + cg_sum; let w01 = ae_sum - cg_sum; let w10 = ae_diff +rot_270 ( cg_diff); let w11 = ae_diff -rot_270 ( cg_diff); let x00 = bf_sum + dh_sum; let x01 =rot_270 ( bf_sum) +rot_90 ( dh_sum); let x10 =rot_45 ( rot_270 ( bf_diff) +rot_180 ( dh_diff)); let x11 =rot_45 ( rot_180 ( bf_diff) +rot_270 ( dh_diff)); * output_ptr. add ( start_idx + k* stride) = w00 + x00; * output_ptr. add ( start_idx +( k + big_n /8 ) * stride) = w10 + x10; * output_ptr. add ( start_idx +( k + big_n /4 ) * stride) = w01 + x01; * output_ptr. add ( start_idx +( k +3 * big_n /8 ) * stride) = w11 + x11; * output_ptr. add ( start_idx +( k + big_n /2 ) * stride) = w00 - x00; * output_ptr. add ( start_idx +( k +5 * big_n /8 ) * stride) = w10 - x10; * output_ptr. add ( start_idx +( k +3 * big_n /4 ) * stride) = w01 - x01; * output_ptr. add ( start_idx +( k +7 * big_n /8 ) * stride) = w11 - x11; } } } } pub fn fft_v7_radix_8 < T : Float +FloatConst >( src : & mut [ Complex < T >], dst : & mut [ Complex < T >], twiddles : & [ Complex < T >], ) { assert! ( is_power_of_k ( src. len (), 8 )); assert_eq! ( src. len (), dst. len ()); assert_eq! ( twiddles. len (), src. len ()); let n_iter =log_k_of ::< 8 >( src. len ()); dst. copy_from_slice ( src); let ( mut input, mut output) =if n_iter %2 ==0 { ( dst, src) } else { ( src, dst) }; let big_n = input. len (); let mut stride = big_n; let mut big_n =1 ; for stagein 0 ..n_iter{ stride /=8 ; big_n *=8 ; std:: mem:: swap ( & mut input, & mut output); if stage ==0 { fft_butterfly_radix_8_s0_unsafe ( input, output); } else { fft_butterfly_radix_8_unsafe ( input, output, stride, big_n, twiddles); } } }
FYI, I started by just writing the naive expressions based on the table at the top of this section. Then I did one level of subexpression elimination, pairing up a with e, b with f, etc. Then I did another level, giving us the final result. Without the common subexpression elimination, this performs worse than the radix-4 kernel!
With this in place - we actually beat the speed-of-light!
| Algorithm | Duration | Max. error | Avg. error |
|---|---|---|---|
| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 |
| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 |
| FFT v1 | 1.2813 ms | 0.00009481 | 0.00000410 |
| FFT v2 | 39.944 us | 0.00009481 | 0.00000410 |
| FFT v3 | 23.626 us | 0.00009481 | 0.00000410 |
| FFT v4 | 20.231 us | 0.00009481 | 0.00000396 |
| FFT v5 | 16.383 us | 0.00009481 | 0.00000396 |
| FFT v6 | 14.830 us | 0.00009481 | 0.00000396 |
| FFT v7 | 13.235 us | 0.00009481 | 0.00000398 |
| rustfft | 14.791 us | 0.00009481 | 0.00000397 |
Our average-case error has slightly regressed, but honestly I don't care.
Validating other input sizes
For my use-case, I only care about FFTs of size 256, 512, 1024, and 4096. Let's check how we perform vs. rustfft:
| Input size | Algorithm | Runtime |
|---|---|---|
| 256 | FFT v6 | 587.71 ns |
| 256 | rustfft | 620.66 ns |
| 512 | FFT v7 | 1.1278 us |
| 512 | rustfft | 1.3608 us |
| 1024 | FFT v6 | 2.9249 us |
| 1024 | rustfft | 3.0233 us |
| 4096 | FFT v7 | 13.235 us |
| 4096 | rustfft | 14.791 us |
Our algorithms mog rustfft at every relevant input size, and are exceptionally simple. Our work here is done.
Closing thoughts
These algorithms - v6 and v7 - will not scale well to large inputs (say, above 16k or so). An in-place algorithm would exhibit far better cache locality and would scale better. I did try that out, but for my input sizes, it wound up costing more than it saves.
Additionally, I did not take the time to study mixed-radix solutions. These would be needed to support e.g. size-2048 inputs, or non-power-of-2 inputs. I will probably revisit this later, but today's not that day. My greatest aspiration for this project was to get within a factor of 2 of rustfft's scalar performance with simple code; exceeding it was a very pleasant surprise.
Source code
All source code is available here.
AI disclosure
I used AI to check my code for errors and investigate likely high-value optimizations. All committed code, and all prose and math in this article, was written entirely by me. (Even the typesetting! 😩)
Wikipedia. Discrete Fourier transform. Accessed 5 Sep 2026. Webpage
Wikipedia. Euler's formula. Accessed 5 Sep 2026. Webpage
Wikipedia. Rotation matrix. Accessed 8 Sep 2026. Webpage
fft water: the plan {data-date="7 Aug 2026"}
About a year ago I took a stab at reimplementing Tessendorf's ocean water4. I got some decent results, but the implementation was sloppy and not particularly organized. I also never implemented Bruneton's "geometry to BRDF" method 5, partially because the implementation was sloppy.
I've gotten the itch to take another stab at implementing this water system. This time, I will try harder to proceed along principled, logical steps, and check my work more thoroughly along the way.
I've also decided to document this process. I expect it to take a few months to complete this project. Hopefully in the future, these notes might help someone trying to implement some nice deep-ocean water in their engine/game/whatever.
I'll implement this renderer as follows (this list is likely to change):
- Derive a high-performance GPU-based FFT implementation on CPU.
- Check against a simple reference implementation of Cooley-Tukey.
- Measure the impact of radix on the numerical precision of the FFT.
- Ideally, generate some graphs.
- Also look at the impact of float precision - 8-bit, 16-bit, etc.
- Implement this FFT algorithm in slang + webGPU. Render unlit. Measure
performance.
- Implement image export from webGPU harness. Measure error - verify that it matches expectations.
- Generate a wave energy spectrum using Horvath's viscous shallow water wave dispersion relation.
- Generate one frame of wave displacement using slang + webGPU.
- Validate feature scale, energy, etc. You probably want some histograms.
- Generate one frame of analytic normals using slang + webGPU.
- Validate using finite differences of the heightmap as an approximation of ground truth. The two images should match within some small epsilon.
- Generate chop and chop normals.
- Validate feature size and normals using finite differences (again).
- Implement stdev (per geometry-to-brdf paper).
- (Note to self: this is a static image based on the energy spectrum. We calculate the ddx/ddy of the offset [meters/px], then divide 2 * pi by that number to get a wave number. That is then used as the index to the LUT. The LUT contains, for each wave number, the sum of the variances of all waves with higher or equal wave numbers.)
- Render a simple scene in webGPU and in Mitsuba 3.
- Implement a simple brdf.
- Implement frame export.
- Implement image diffing / measurement.
- Implement hard shadows.
- Implement soft shadows.
- Validate point lighting.
- Validate directional lighting.
- Implement and validate IBL.
- Implement and validate DFG LUT (energy-preserving roughness).
- Implement vertex deformation and normals using baked heightmap & tangents.
Validate against Mitsuba.
- Make a new scene with a highly subdivided quad.
- Port to Unity.
- Implement tooling to blit a texture through a RenderTexture using a shader.
- Automation should generate quads, materials, and rendertextures on behalf of the user.
- Port compute shader to shaderlab pixel shader. Validate.
- Port lit shader to shaderlab.
- Sample scene, frame export, exhaustive validation... the works.
- Validate point, directional, and IBL.
- Add light volumes.
- Add LTCGI.
- Implement tooling to blit a texture through a RenderTexture using a shader.
So... yeah. A lot of work. I'll get started tomorrow!
Tessendorf, Jerry. Simulating Ocean Water. 2004. PDF.
Bruneton, Eric et. al. Real-time Realistic Ocean Lighting using Seamless Transitions from Geometry to BRDF. 2010. PDF.
how do you evenly tile a column? {data-date="12 Jul 2026"}
While walking through town the other day, I saw a pillar that looks a bit like this:

In other words, it was a circular vertical column decorated with flat tiles. I got to thinking: how do you make such a column? I would probably make a cylindrical base, then stick the tiles to it. But how would I know how big each tile should be so that they exactly divide the circumference of the pillar?
The problem is that, since the column is circular, and the tiles are straight, you can't just divide the circumference of the pillar by the number of tiles. Each tile creates a tiny gap vs. the cylindrical pillar, and those gaps would add up over the circumference of the pillar. Your tiles wouldn't exactly meet up when you get back to where you started!

There should be a simple, mathematical relation between the circumference of the pillar, and the perimeter of the regular polygon with $n$ vertices which circumscribes it.
Let's draw a couple pictures. To keep things easy to visualize, we'll look at a case where $n = 3$, but we'll keep our math generalizable to any $n$.

Our circle has radius $r$, and the circumscribing polygon has edge length $e$.
Our task is to come up with some relationship between $r$ and $e$. (Or more precisely, an expression for $\frac{n e}{2 \pi r}$ solely in terms of $n$.)
Zooming in on the bottom-right corner of our circle, we can define a few more interesting quantities:

We define:
- $\sigma$: the central angle of the polygon.
- $h$: the height of the intersection point over the horizontal base of the polygon.
- $\theta$: the interior angle of the polygon.
Finally, if we focus on the region outlined by $r$, $h$ and the bottom of the polygon:

We define one final quantity, $\phi$, the interior angle of the right triangle formed by $h-r$ and $r$.
Here is a summary of the quantities defined so far:
$$ \begin{align*} r & && \text{Inscribed circle radius.}\ n & && \text{Number of vertices in circumscribing polygon.}\ e & && \text{Edge length of circumscribing polygon.}\ h & && \text{Height of next intersection point with respect to previous edge.}\ \sigma & && \text{Central angle of circumscribing polygon.}\ \theta & && \text{Interior angle of circumscribing polygon.}\ \end{align*} $$
Let's start defining these quantities in terms of each other - preferably exclusively in terms of $n$ where possible.
$$ \begin{align*} \theta &= \frac{\pi (n-2)}{n} && \text{Interior angle of a regular polygon.}\ \sigma &= \frac{2 \pi}{n} && \text{Central angle.} \ \phi &= \sigma - \frac{\pi}{2} && \text{Follows from figure 3.} \ \sin{\theta} &= \frac{2h}{e} && \text{Figure 3, definition of sine.} \ h &= \frac{e}{2} \sin{\theta} && \text{Rearrange previous equation.} \ \sin{\phi} &= \frac{h -r}{r} && \text{Figure 3, definition of sine.} \ \sin{\phi} &= \frac{h}{r} - 1 && \text{Simplify previous equation.} \ h &= r(\sin{\phi} + 1) && \text{Rearrange previous equation.} \ \frac{e}{2} \sin{\theta} &= r(\sin{\phi} + 1) && \text{Set } h \text{ equations equal to each other.} \ \frac{e}{r} &= 2 \frac{\sin{\phi} + 1}{\sin{\theta}} && \text{Rearrange terms.} \end{align*} $$
We have come up with an expression relating $e$ and $r$ but it's far from the elegant solution we were searching for. Here is where I chucked it into wolframalpha and got a nice solution, then asked a clanker to derive it for me. The simplification process is:
$$ \begin{align*} \frac{e}{r} &= 2 \frac{\sin{(\frac{2 \pi}{n} - \frac{\pi}{2})} + 1}{\sin{\frac{\pi (n-2)}{n}}} && \text{Plug in definitions of } \phi \text{ and } \theta \text{.} \ &= 2 \frac{1 - \cos{\frac{2\pi}{n}}}{\dots} && \text{In general, } \sin{(x-\frac{\pi}{2})} = -\cos{x} \ &= 2 \frac{2 \sin^2{\frac{\pi}{n}}}{\dots} && \text{Double angle formula.} \ &= 2 \frac{\dots}{\sin{(\pi - \frac{2 \pi}{n})}} && \text{Simplify.} \ &= 2 \frac{\dots}{\sin{\frac{2\pi}{n}}} && \text{In general, } \sin{(\pi-x)} = \sin{x} \ &= 2 \frac{\dots}{2 \sin{\frac{\pi}{n}} \cos{\frac{\pi}{n}}} && \text{Double angle formula.} \ &= 2 \frac{2 \sin^2{\frac{\pi}{n}}}{2 \sin{\frac{\pi}{n}} \cos{\frac{\pi}{n}}} && \text{Write explicitly.} \ &= 2 \frac{\sin{\frac{\pi}{n}}}{\cos{\frac{\pi}{n}}} && \text{Cancel terms.} \ &= 2 \tan{\frac{\pi}{n}} && \text{Definition of tangent.} \end{align*} $$
We're in the final stretch!
Let $P = e \cdot n$, $C = 2 \pi r$. Then:
$$ \begin{align*} \frac{P}{C} &= \frac{e \cdot n}{2 \pi r} && \text{Plug in definitions.} \ &= \frac{n}{2 \pi} \frac{e}{r} && \text{Group terms.} \ &= \frac{n}{2 \pi} 2 \tan{\frac{\pi}{n}} && \text{Plug in equation from before.} \ &= \frac{n}{\pi} \tan{\frac{\pi}{n}} && \text{Simplify.} \quad \square \end{align*} $$
This represents the ratio of these two shapes' circumferences, so we expect that at the limit of n, it should be 1. Therefore we subtract 1 to get an error function. This is the graph of $P/C-1$:

As expected, the error starts out very large with few tiles, then quickly drops towards 0 (the ratio converging to 1).
Our column-builders are more interested in the error with respect to the length of a tile. To illustrate the point: $P/C-1$ tends towards 0, but so does the length of our tiles. Which one converges faster, and by how much?
To get the error per tile, we use the formula $(P/C - 1) \cdot n$. (Intuitively: each tile is small, so the amount of error it sees is inversely proportional to its size $\frac{1}{n}$).
TODO: I think that this measure of relative error is wrong.

Here are the values of $P/C-1$ and $(P/C-1) \cdot n$ for up to 30 tiles:
| # of tiles | P/C-1 | (P/C-1)*n |
|---|---|---|
| 3 | 0.653986686 | 1.961960059 |
| 4 | 0.273239545 | 1.092958179 |
| 5 | 0.156328347 | 0.7816417349 |
| 6 | 0.102657791 | 0.6159467451 |
| 7 | 0.073029735 | 0.511208143 |
| 8 | 0.054786175 | 0.4382894013 |
| 9 | 0.042697915 | 0.3842812313 |
| 10 | 0.034251515 | 0.3425151527 |
| 11 | 0.028106371 | 0.3091700813 |
| 12 | 0.023490523 | 0.2818862802 |
| 13 | 0.019932427 | 0.2591215493 |
| 14 | 0.017130161 | 0.2398222536 |
| 15 | 0.014882824 | 0.2232423644 |
| 16 | 0.013052368 | 0.2088378934 |
| 17 | 0.011541311 | 0.1962022837 |
| 18 | 0.010279181 | 0.185025256 |
| 19 | 0.009213984 | 0.1750656961 |
| 20 | 0.008306663 | 0.1661332692 |
| 21 | 0.007527411 | 0.1580756349 |
| 22 | 0.006853153 | 0.1507693603 |
| 23 | 0.006265797 | 0.1441133352 |
| 24 | 0.005750997 | 0.1380239172 |
| 25 | 0.005297252 | 0.1324312968 |
| 26 | 0.004895259 | 0.1272767379 |
| 27 | 0.004537424 | 0.1225104564 |
| 28 | 0.004217499 | 0.1180899697 |
| 29 | 0.003930303 | 0.1139788006 |
| 30 | 0.003671515 | 0.1101454484 |
As we can see, the ratio of $P/C$ quickly drops below 1% (taking only 19 tiles) but even with 30 tiles the per-tile error still doesn't drops below 10%. Therefore in real-world conditions, you actually need to account for this source of error, or live with a narrower-than-intended tile on your column.
Finally, let's address our problem statement directly. I have a column of radius $r$, and I want to wrap it with $n$ tiles. What should the edge length $e$ of each tile be so that the tiles wrap the column exactly?
Rearranging an equation given above:
$$ e = 2r \tan{\frac{\pi}{n}} $$
histogram-preserving tri-planar projection {data-date="31 March 2026"}
I've been messing around with Burley's "On Histogram-Preserving Blending for Randomized Texture Tiling" (link) for a couple days. The core idea is to pre-process images into a "Gaussianized" form where the histogram of the image's colors follows a Gaussian distribution. Once in Gaussian form, there is a closed-form way to blend multiple samples with barycentric weights such that the Gaussian's variance is preserved (Equation 2 in the paper). Finally, you can run the blended colors through a lookup table (LUT) to get a result in the original image's color space. The results are outstanding. (These ideas build on those laid out by Heitz and Neyret in an earlier paper. I will reference Heitz a few times.)

It was love at first sight - you can use this to seamlessly tile large areas with textures that themselves don't even need to be seamless. However, the method uses 4 taps per pixel (3 overlapping hexagons per pixel, plus 1 3D lookup table tap).
I've been thinking about terrains for a week or two, since I need to make a large-scale environment for a project. I really like the idea of using tri-planar projection for grass, stone etc., but I've never been satisfied with the quality I get from it. It always creates this awful loss of contrast between layers and creates weird ghosting artifacts.
Wait a minute, isn't that kind of what Heitz's technique addresses?
It turns out that yeah, you can use the exact same machinery described by Heitz and Burley to perform histogram-preserving tri-planar projection. You just use standard tri-planar projection to get barycentric coordinates instead of playing with a UV-space triangle grid. Results are shown below.

I also noticed that the gamma term described in Burley's Equation 5 can significantly reduce contrast. At low values, where ghosting is more visible, contrast is better preserved; at high values, it's more diminished.

Perhaps blending in YCbCr would ameliorate the loss in contrast, but I haven't tried that yet.
The astute reader might find that just increasing contrast after the blend would produce a similar result, and I'm inclined to agree. The only possible advantage that this method has is that it doesn't demand fine-tuning.
using linux as a desktop os in 2026 {data-date="9 Feb 2026"}
About a month ago, my PC's boot drive died. I had been running Windows 11 with moderate dissatisfaction for a few months, so I decided to switch over to Linux as my primary OS. These are some notes on that process. My motivation is to give an accurate portrayal of what to expect out of the switching process and the day-to-day operation.
TLDR: The Linux desktop is way better in 2026 than it was in 2016. Native app support is far more common, and Proton is really good. If dual booting was not still necessary for VR, I would wholeheartedly recommend it.
Dual boot setup
I knew immediately that I'd be dual booting. My memory told me that some apps just would not work well, and the virtualization tax is high, so I'd want a native Windows install. So I made my first mistake: I installed Linux, then Windows. The opposite order is far more streamlined. So I just overwrote my install with Win11. I left half my drive as unallocated space for the Linux install.
After rebooting normally to make sure Windows was really working, it was time to install Linux. My new install would not let me get into BIOS - my keyboard inputs did not work. There are one-time flags you can set via shell (PowerShell and BASH) to do various boot-related tasks without keyboard input. To get into BIOS:
- PowerShell:
shutdown /r /fw /t 0 - bash:
sudo systemctl reboot --firmware-setup
My keyboard did work once in the BIOS, so I was then able to enter my Linux
bootable USB. I had some trouble getting the bootable USB to work. I had to
install the media via Rufus's dd mode instead of the default.
I installed my distro as normal in the unallocated space, then rebooted. GRUB showed up, showing my Linux install as default and the Windows boot manager below. Somewhat unsurprisingly, my keyboard didn't work in GRUB. I heard that disabling fast boot and xhci handoff in the BIOS can help, but this only temporarily helped before the issue resurfaced and then resolved itself. My current config has fast boot off and xhci handoff off.
My solution to the pre-BIOS/GRUB keyboard issue is just to use shell commands
to reboot. To get from Windows to Linux, I just reboot as normal since Linux
has prio by default in my install. To go from Linux to Windows, I installed
efibootmgr, ran it to get the numeric ID of the Windows boot manager (0000),
then crafted this one liner:
- bash:
sudo efibootmgr --bootnext 0000 && reboot
I use ctrl+R to find it every time I need to reboot.
Sidebar: this method does not play nicely with Windows updates. Since Windows needs to reboot 19 times to do anything, and each reboot takes you into Linux, you'll be stuck booting back into Windows manually. Next time Windows demands an update, I'll probably just unplug my PC from the wall.
Linux setup
I'm using the Ubuntu 2024 LTS as my distro. My first point of confusion getting started was the apparent surfeit of package managers: apt (the standard), snap (canonical's thing), and flatpak (some semi popular community thing). snap and flatpak are sandboxed by default, which is really just a massive fucking pain in the ass for GUI apps. So I use apt wherever possible, and raw .deb files for the rest.
Audio
Audio's a little scuffed, but seems like we've mostly gotten on the pulse audio train (thank God).
For whatever reason, my motherboard's audio output sets itself to 39% volume. I
have to use alsamixer to increase this to 100%.
I used pavucontrol to disable irrelevant speakers and mics s.a. monitor speakers.
Firefox
Firefox comes pre-installed on Ubuntu. Firefox is slowly going the way of Windows, but Just The Browser has some easy one liners to de-shittify it. Waterfox is also interesting, but I haven't tried it yet.
Discord
I used the raw .deb to install Discord. It will ask you to manually update every few days. I wrote this shell script to speed that up:
#!/usr/bin/env bash # updisc: update discord set -o errexitset -o xtracecd $ HOME /Downloadswget --content-disposition "https://discord.com/api/download/stable?platform=linux&format=deb" latest =$( ls -v | grep discord | tail -n1 ) sudo dpkg-i " $ latest "
Spotify
The snap works fine for this. Installed it through the App Center (Canonical's app store).
(Preachy note: Spotify kind of sucks. Avoid using their auto generated playlists. Spotify has something called the Perfect Fit Program which commissions and pushes music to listeners based on non-public preference data. Artists involved in this program are not well compensated. Read about it in Liz Pelly's expose.)
Steam
I use the raw .deb to install Steam. I tried the flatpak at first, but the sandboxing doesn't play nicely with proton. Steam will keep itself up to date so the raw .deb is fine.
Games
I had some issues with graphics drivers in certain games and had to roll back
my driver from 590 to 570. You can list your driver with nvidia-smi, and
install some other version (e.g. 570) with sudo apt install nvidia-driver-570. It will ask for a password - this only has to be entered
once after reboot, after which the driver will be trusted forever.
If your game uses Easy Anti Cheat and Proton, you'll need to install the Proton EasyAntiCheat runtime. It should be listed in your library by default.
Proton has a heavy FPS hit vs. Windows native (like 30%), but I'm not a competitive gamer and my computer is very over-built, so I don't care.
Blender
I downloaded the LTS .tar.xz from the website and put it in my bin directory. I think it's probably smarter to use Steam for this. Do not use the snap version - it won't let you install addons from the web.
If you use an NDOF input device like a spacemouse, install spacenavd via apt. You might have to relaunch blender.
Otherwise, pretty much identical experience.
Unity
Superficially, Unity basically just works. Install the hub using the official Unity3D documentation.
My problems with Unity so far are:
- Slow shader compile times.
- Unity uses OpenGL by default on Linux, and Vulkan is very crashy in my
experience.
- OpenGL uses a different depth buffer format than DX11/DX12, making it hard to develop for that platform on the OpenGL version.
- GPU profiler doesn't work out of the box, showing 1 ms for every frame.
- Weird permissions issues if you just mount a project created in Windows. Had to copy it over.
- Have to delete Library/ if the project was created/used on Windows. (Shouldn't be a big deal. Just slows down first time startup.)
- Slow scrolling performance in Inspector pane
- Dragging sliders in game mode is not smooth, like it is in Windows
You can use ALCOM/vrc-get to create VRChat projects. Get it from github.
Adobe
I've already been on Krita (and GIMP before that), which natively supports Linux. No problems there.
Substance painter is a massive issue. Adobe claims to have a native Ubuntu build, and even sell it through Steam. However, it simply did not launch on my system. It was missing half a dozen shared object files (.so), and after manually fixing that it still fails to launch.
Thankfully the fuckwits at Adobe couldn't be bothered to strip their binary:
$ file ./Adobe\ Substance\ 3D\ Painter ./Adobe Substance 3D Painter: ELF 64-bit LSB pie executable, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=cf49a257fa3bcf0f40d860a8a45a67c873571421, with debug_info, not stripped $ $ du -h ./Adobe\ Substance\ 3D\ Painter 305M ./Adobe Substance 3D Painter
So the next time I have a free afternoon I'll be looking through that.
I did try ArmorPaint, but it is very clearly still quite early in development. I do not think that it's a viable alternative to Substance Painter yet. (For example: you cannot drag and drop in textures, nor can you import more than one at a time. Functional, sure, but barely.) To its credit: unlike Substance Painter, it actually launches.
Pleasant surprises
Linux is way, way more polished than I remember it. Back in college I was fucking around with Arch (and didn't know what I was doing) so I was expecting a far more painful setup process. Instead it was very seamless. Audio works well. There are very few weird audio/graphical bugs. NVIDIA drivers are easy to install. Gaming basically just works - I have yet to encounter a game which I can't play (I've only tried maybe a dozen).
The amount of native app support is really heartwarming. Skipping over the big apps like Firefox and Steam, here are some smaller apps I was surprised to see native support for:
- OBS (video recording/streaming tool)
- Factorio (indie game)
- r2modman (mod manager)
- Chatterino (twitch chat app)
- PureRef (artist reference app)
- Lorien (infinite canvas drawing app)
Complaints
Canonical uses the "yes/maybe later" pattern which degrades the notion of consent. Nearly every large firm does it now since Google about-faced a couple years ago, but it is still a grave degradation of user rights that shouldn't be glossed over.
SteamVR does not work for me. My knuckles did not have an internally
consistent coordinate system, preventing me from completing room
calibration.
I was never able to figure this out. This, along with Unity's crashy behavior
on Vulkan,* is what's keeping me from just deleting Windows.
* I've actually been able to use the OpenGL build to do graphical programming without issue for a few weeks now. So, not an issue.
Conclusions
The Linux desktop is really good. You should give it a try.
hemi-octahedral impostors {data-date="14 Jan 2026"}
Note: this blog post is only like half way complete. I may or may not circle back to it. The stuff on octahedral mappings is all finished, but the impostor application below is not.
Ryan Brucks published an article describing "octahedral impostors" in 2018. The basic idea is to to take photos of some subject at octahedral lattice points, record them to an atlas, then reconstruct those photos in a particle.

But why octahedrons?
The octahedral mapping is simply one way to convert between a flat coordinate system and a spherical coordinate system. It is notable because it does not use any trig functions, making it suitable for use in realtime graphics.
This is what an octahedron looks like:

It is a polyhedron with 8 triangular faces and 6 vertices. The equator is a square.
Let's work out how we'd convert this octahedron to a plane. First, we project the upper hemisphere onto the xz plane:

Next, we effectively need to "rotate" the triangles in the lower half around those diagonal edges. We can cheat by first reflecting the bottom vertex of each triangle about its diagonal edge:

Finally, we can just project those points in the lower hemisphere onto the xz plane:

Viewed head on, we can see a very beautifully symmetric unwrapping:

Note that we never actually did any rotations, so there no trig! Here's the same procedure in code:
// Convert unit octahedron to a [-1,1] x [-1,1] patch on xz plane. float3 octahedron_to_plane (float3 p ) {if (p .y >=0 ) {// Project upper hemisphere onto xz plane. p .y = 0 ;return p ; }// First, reflect the lower hemisphere's points about their diagonal. p .x = sign (p .x )* (1 - abs (p .x ));p .z = sign (p .z )* (1 - abs (p .z ));// Then project onto the xz plane. p .y = 0 ;return p ; }
We can generalize this procedure to unwrap any spherical object by just switching norms:
// Convert unit sphere to a [-1,1] x [-1,1] patch on xz plane. float3 octahedron_to_plane (float3 p ) {// Switch from L2 to L1 norm. This basically bends a sphere to an octahedron. float l1_norm = abs (p .x )+ abs (p .y )+ abs (p .z );p /=l1_norm ;// Then unwrap. if (p .y < 0 ) {p .x = sign (p .x )* (1 - abs (p .x ));p .z = sign (p .z )* (1 - abs (p .z )); }p .y = 0 ;return p ; }
Here's a quick demo showing what that norm conversion does to a unit sphere:
Going from plane to octahedron is just the same thing backwards:
// Convert a [-1,1] x [-1,1] patch on xz plane to a unit sphere. float3 plane_to_octahedron (float3 p ) {float l1_norm = abs (p .x )+ abs (p .z );if (l1_norm > 1 ) {// Reflect lower hemisphere's point about their diagonal. p .x = sign (p .x )* (1 - abs (p .x ));p .z = sign (p .z )* (1 - abs (p .z )); }p .y = 1 - l1_norm ;return normalize (p ); }
If you'd like more discussion on this topic, I recommend the spherical geometry section in the PBR book.)
The hemi octahedron
We might only want to map the upper hemisphere to a plane. In that case, we can first note that in the standard octahedral mapping, the inner diamond of the [-1,1] x [-1,1] square gets mapped to the upper hemisphere. So all we have to do is first remap our input to that diamond via a scale and 45 degree rotation, map it, then rotate it back. The code is still very simple:
// Convert unit sphere to a [-1,1] x [-1,1] patch on xz plane. float3 hemi_octahedron_to_plane (float3 p ) {// Rotate 45° and scale to fit square into diamond float x_rot = (p .x + p .z )* 0.5 ;float z_rot = (p .z - p .x )* 0.5 ;p .x = x_rot ;p .z = z_rot ;float l1_norm = abs (p .x )+ abs (p .y )+ abs (p .z );p /=l1_norm ;if (p .y < 0 ) {p .x = sign (p .x )* (1 - abs (p .x ));p .z = sign (p .z )* (1 - abs (p .z )); }p .y = 0 ;// Rotate back. x_rot = p .x - p .z ;z_rot = p .x + p .z ;p .x = x_rot ;p .z = z_rot ;return p ; }
Here is that transform, visualized:
If we didn't do that scale and rotate, this is what it would look like:
I will leave the plane -> hemi-octahedron code as an exercise for the reader.
Impostor v1
With this mapping, we can write some code to spawn cameras at the lattice points of an octahedral-mapped hemisphere, pointing in at some target object, and generate an atlas of images taken at different angles:


We can then write a naive particle shader which computes its nearest lattice point and simply renders that image.
We can simply compute the direction from the camera to the particle's center, map that to 2D using the hemi-octahedral mapping, then find the nearest lattice point by rounding. We can also rotate the particle to the same orientation that the photo was taken at to avoid any weird behavior when viewed top down.
That looks like this:
The popping is pretty awful! Can we do better?
Impostor v2
Brucks describes a "virtual frame projection" method. I'll let him explain it:
Looking back to the 'virtual grid mesh' above, we can see that for any triangle on the grid, it has 3 vertices. So if we want to blend smoothly across this grid, we need to be able to identify the 3 nearest frames. And remember how using a sprite caused messed up projection of just one frame? Well it turns out the same thing happens when you try to reuse the projection from one frame for another! This is a pain. So you actually have to render a virtual frame projection for the other 2 frames to simulate their geometry. While using the mesh UVs for one projection and 'solving' the other two does work, it falls apart for lower (~8x8) frame counts because the angular difference can be so great between cards that you see the card start to clip at grazing angles (not shown in any videos yet). As a compromise, the shader does not use ANY UVs right now. It solves all 3 frames using virtual frame projection in the vertex shader and then uses a traditional sprite vertex shader. The only downside is at close distances you occasionally see some minor clipping on the edge but it is much more acceptable this way.
Lost? Me too! I found this paragraph extremely confusing - it's what motivated me to write this article.
As near as I can tell, what he's describing is that you retrieve the nearest 3 lattice points and do a barycentric interpolation. He's also trying to clarify that you can't just use the uvs from one lattice point to sample another - you have to calculate each lattice point's uvs separately. (I suppose that that level of optimization-first thinking is required when you're building for Fortnite!)
You then render the blended color that on a standard facing quad primitive.
To start, I calculate the ray from the camera to the origin of the particle's coordinate system. I use that position for my barycentric interpolation.
That looks like this:
Huh. Looks a lot worse than his demo. What are we doing wrong?
Could it just be our choice of mesh that makes our results look bad? Here's Suzanne:
Maybe it looks a little better?
The mesh that Brucks shows off in his blog post has radial symmetry and smooth normals, which might be responsible.
You can also see some artifacts appearing in open space. This was caused by a couple things:
- The other mesh was toggled on when I generated my impostor atlas.
- The bounding sphere around my mesh had very little padding.
- The particle can rotate, and if you don't clip the parts outside the impostor's bounding sphere, you can wind up rendering them.
3 is crucial - with that correction in place, you can pack your atlas pretty tightly. Here's Suzanne with that correction in place:
Here's the atlas. Pretty tight packing - could probably be optimized a little further though:

Impostor v3
After stepping away for a bath, the issue occurred to me.
I was calculating the lattice point based on the direction from the camera to
the particle center. With barycentric interpolation in place, we would be
better off using a per-pixel ray intersection with the impostor's bounding
sphere. Concretely: we want to sample the lattice points whose cameras have a
direction most closely matching the standard view direction. This is found by
simply going from the particle's bounding sphere origin to the surface along
-viewDir, projecting that to 2d, then rounding to lattice points as normal.
This seems to help a bit, but it's not night and day. I didn't capture any videos here, but the next gen uses this tech.
Impostor v4
So far we've only been rendering pre-lit images of our subject on an unlit particle. Can we do better? What if we captured the albedo, normal, metallic gloss, and position, then lit it with a standard surface shader?
The results look a bit better - specular is much better approximated now:
Impostor v5
I continued to spin my wheels for a couple days. I re-read Brucks' article several more times, and came to a couple conclusions:
- He is using the camera-origin ray, not a per pixel view direction ray.
- He is doing some form of parallax occlusion mapping to limit popping.
I found his description on this video useful:
This version blends the three nearest frames using a single parallax offset (similar to a bump offset). This is the version of impostors used in FNBR on PC and Consoles. It was used on mobile originally but switched back to single frame at last minute since we were compositing them into HLODs and thus rendering lots of them.
That single parallax offset is explained by this image:
My buggy implementation looks promising - see how the eyes are much sharper now?
It is still very, very poppy, unlike Brucks' demo. I must be doing something wrong.
Note: I stepped away from this project and don't plan to revisit it soon. If I do, I'll post updates in a followup and link to it from here.
6 wave dispersion relations with derivatives {data-date="21 Sep 2025"}
Tessendorf's 2005 paper "Simulating Ocean Water" describes three basic dispersion relations:
-
The deep water dispersion relation:
$$ \omega^2 = gk $$
where $\omega$ is the wave's temporal frequency in $\text{rad}/s$, $g$ is gravity in $m/s^2$, and $k$ is the spatial frequency in $m/s$.
-
The shallow water dispersion relation:
$$ \omega^2 = gk \tanh kh $$
where $h$ is the water mean depth in $m$.
-
The deep water relation with viscosity correction:
$$ \omega^2 = gk (1 + k^2 L^2) $$
where $L$ is the scale in $m$ at which the viscosity term operates. At 0, it has no effect.
Horvath's 2015 paper "Empirical directional wave spectra for computer graphics" formulates the viscosity term in terms of different physical units, and applies it to the shallow water dispersion relation:
$$ \omega^2 = (gk + \frac{\sigma}{\rho} k^3) \tanh kh $$
where $\sigma$ is the surface tension in $N/m$, and $\rho$ is the water density in $kg/m^3$.
It is useful to have derivatives of the dispersion relation. Horvath's paper describes how we can calculate the spectrum term $S(k_x, k_y)$ from $S(\omega, \theta)$ and the derivative of the dispersion relation $\frac{\partial \omega}{\partial k}$:
$$ S(k_x, k_y) = S(\omega, \theta) \frac{\partial \omega}{\partial k} / k $$
So, with that motivation, we would like the derivatives of our dispersion relations. You should autodifferentiate if that's an option. If not, here are derivations of each derivative:
-
Deep water:
$$ \begin{align*} \omega^2 &= gk \ \omega &= (gk)^\frac{1}{2} \ \frac{\partial \omega}{\partial k} &= \frac{1}{2} (gk)^{-\frac{1}{2}} g \ &= \frac{g}{2\sqrt{gk}} \ &= \frac{1}{2} \sqrt{\frac{g}{k}} \end{align*} $$
Wolfram here.
-
Shallow water:
First we will need $\frac{\partial}{\partial k} \tanh kh$:
$$ \begin{align*} \frac{\partial}{\partial k} \tanh kh &= \frac{\partial}{\partial k} [\frac{e^{kh} - e^{-kh}}{e^{kh}+e^{-kh}}] \ &= \frac{\partial}{\partial k} [(e^{kh} - e^{-kh})(e^{kh}+e^{-kh})^{-1}] \ &= (he^{kh}-he^{-kh})(e^{kh}+e^{-kh})^{-1} + (e^{kh}-e^{-kh})[-(e^{kh}+e^{-kh})^{-2}(he^{kh}-he^{-kh})] \ &= h(1-[\frac{e^{kh}-e^{-kh}}{e^{kh}+e^{-kh}}]^2 \ &= h(1-\tanh^2 kh) \end{align*} $$
With that identity, let's proceed:
$$ \begin{align*} \omega^2 &= gk \tanh kh \ \omega &= (gk \tanh kh)^{\frac{1}{2}} \ \frac{\partial \omega}{\partial k} &= \frac{1}{2} [gk \tanh kh]^{-\frac{1}{2}} [g \tanh (kh) + gkh(1 - \tanh ^2 kh] \ &= \frac{g(\tanh kh + kh(1 - \tanh ^2 kh))}{2 \sqrt{gk \tanh kh}} \ &= \frac{g \tanh kh + gkh (1 - \tanh^2 kh)}{2 \sqrt{gk \tanh kh}} \ &= \frac{1}{2} [\sqrt{g \tanh kh} + \frac {gkh(1 - \tanh^2 kh)}{\sqrt{gk \tanh kh}}] \ &= \frac {g \tanh kh + gkh(1 - \tanh^2 kh)}{2\sqrt{gk \tanh kh}} \ &= \frac {g (\tanh kh + kh \operatorname{sech}^2 kh)}{2\sqrt{gk \tanh kh}} \end{align*} $$
Wolfram here. (Recall that $\operatorname{sech}^2 x = 1 - \tanh^2 x$.)
-
Viscous deep water (Tessendorf version):
$$ \begin{align*} \omega^2 &= gk [1 + k^2 L^2] \ \omega &= (gk [1 + k^2 L^2])^{\frac{1}{2}} \ \frac{\partial \omega}{\partial k} &= \frac{1}{2}(gk [1 + k^2 L^2])^{-\frac{1}{2}} [g+3gk^2 L^2] \ &= \frac{g+3gk^2L^2}{2\sqrt{gk[1+k^2L^2]}} \end{align*} $$
Wolfram here.
-
Viscous deep water (Horvath version):
$$ \begin{align*} \omega^2 &= gk + \frac{\sigma}{\rho}k^3 \ \omega &= (gk + \frac{\sigma}{\rho}k^3)^{\frac{1}{2}} \ \frac{\partial \omega}{\partial k} &= \frac{1}{2}(gk + \frac{\sigma}{\rho}k^3)^{-\frac{1}{2}} [g+3\frac{\sigma}{\rho}k^2] \ &= \frac{g + 3 \frac{\sigma}{\rho}k^2}{2 \sqrt{gk+\frac{\sigma}{\rho}k^3}} \end{align*} $$
Wolfram here.
-
Viscous shallow water (Tessendorf version):
FYI - use the Horvath version instead. This relation sucks.
We'll want $\frac{\partial}{\partial k} \sqrt{\tanh kh}$:
$$ \begin{align*} \frac{\partial}{\partial k} \sqrt{\tanh kh} &= \frac{\partial}{\partial k} (\tanh kh)^{\frac{1}{2}} \ &= \frac{1}{2} (\tanh kh)^{-\frac{1}{2}} \frac{\partial}{\partial k} \tanh kh \ &= \frac{1}{2} (\tanh kh)^{-\frac{1}{2}} h(1 - \tanh^2 kh) \ &= h \frac{1 - \tanh^2 kh}{2 \sqrt{\tanh kh}} \ &= h \frac{\operatorname{sech}^2 kh}{2 \sqrt{\tanh kh}} \end{align*} $$
Now we can proceed:
$$ \begin{align*} \omega^2 &= gk (1 + k^2 L^2) \tanh kh \ \omega &= (gk (1 + k^2 L^2) \tanh kh)^{\frac{1}{2}} \ \frac{\partial \omega}{\partial k} &= (\frac{\partial}{\partial k} [gk (1 + k^2 L^2)]) \tanh kh + [gk (1 + k^2 L^2)] \frac{\partial}{\partial k} \tanh kh \ &= \frac{g (3 + k^2 L^2)}{2 \sqrt{k} \sqrt{g (1 + k^2 L^2)}} \dots \ &= \frac{1}{2} \sqrt{\frac{g(3+k^2 L^2)}{k}} \sqrt{\tanh kh} + \sqrt{gk (1+k^2 L^2)} [\frac{h (1 - \tanh^2 kh)}{2 \sqrt{\tanh kh}}] \end{align*} $$
We can apply some transformations to get a common denominator and agree with Wolfram:
$$ \begin{align*} \frac{\partial \omega}{\partial k} &= \frac{g (3 + k^2 L^2)}{2 \sqrt{gk(1+k^2 L^2)}} \sqrt{\tanh kh} + \dots \ &= \frac{g (3 + k^2 L^2) \tanh kh}{2 \sqrt{gk(1+k^2 L^2) \tanh kh}} + \dots \ &= \dots + \sqrt{gk (1+k^2 L^2)} [\frac{h (1 - \tanh^2 kh)}{2 \sqrt{\tanh kh}}] \ &= \dots + \frac{gk(1+k^2 L^2)}{\sqrt{gk(1+k^2 L^2)}} [\frac{h (1 - \tanh^2 kh)}{2 \sqrt{\tanh kh}}] \ &= \dots + \frac{gk(1+k^2 L^2) h (1 - \tanh^2 kh)}{2 \sqrt{gk(1+k^2 L^2) \tanh kh}} \ &= \dots + \frac{ghk(1+k^2 L^2)(1-\tanh^2 kh)}{2 \sqrt{gk(1+k^2 L^2) \tanh kh}} \ &= \frac{g(3+k^2L^2) \tanh kh + ghk(1+k^2 L^2)(1-\tanh^2 kh)}{2 \sqrt{gk(1+k^2 L^2) \tanh kh}} \ &= \frac{g(3+k^2L^2) \tanh kh + ghk(1+k^2 L^2)(\operatorname{sech}^2 kh)}{2 \sqrt{gk(1+k^2 L^2) \tanh kh}} \end{align*} $$
Wolfram here.
-
Viscous shallow water (Horvath version):
$$ \begin{align*} \omega^2 &= (gk + \frac{\sigma}{\rho}k^3) \tanh kh \ \omega &= ((gk + \frac{\sigma}{\rho}k^3) \tanh kh)^{\frac{1}{2}} \ \frac{\partial \omega}{\partial k} &= [\frac{\partial}{\partial k}(gk + \frac{\sigma}{\rho}k^3)] \tanh^{\frac{1}{2}} kh + (gk + \frac{\sigma}{\rho}k^3)^{\frac{1}{2}} \frac{\partial}{\partial k} \tanh^{\frac{1}{2}} kh \ &= [\frac{1}{2}(gk+\frac{\sigma}{\rho}k^3)^{-\frac{1}{2}}(g+3\frac{\sigma}{\rho}k^2)] \tanh^{\frac{1}{2}} kh + (gk + \frac{\sigma}{\rho}k^3)^{\frac{1}{2}}h\frac{1-\tanh^2 kh}{2 \sqrt{\tanh kh}} \end{align*} $$
Let's try to corral this into a form closer to what Wolfram gives us:
$$ \begin{align*} \frac{\partial \omega}{\partial k} &= [\frac{1}{2}(gk+\frac{\sigma}{\rho}k^3)^{-\frac{1}{2}}(g+3\frac{\sigma}{\rho}k^2)] \sqrt{\tanh{kh}} + (gk + \frac{\sigma}{\rho}k^3)^{\frac{1}{2}}h\frac{1-\tanh^2 kh}{2 \sqrt{\tanh kh}} \ &= \frac{g+3\frac{\sigma}{\rho}k^2}{2\sqrt{gk+\frac{\sigma}{\rho}k^3}} \sqrt{\tanh{kh}} + \dots \ &= \frac{(g+3\frac{\sigma}{\rho}k^2) \tanh{kh}}{2\sqrt{(gk+\frac{\sigma}{\rho}k^3)\tanh{kh}}} + \dots \ &= \dots + (gk + \frac{\sigma}{\rho}k^3)^{\frac{1}{2}}h\frac{1-\tanh^2 kh}{2 \sqrt{\tanh kh}} \ &= \dots + (gk + \frac{\sigma}{\rho}k^3)h\frac{1-\tanh^2 kh}{2 \sqrt{(gk + \frac{\sigma}{\rho}k^3) \tanh kh}} \ &= \dots + \frac{h (gk+\frac{\sigma}{\rho}k^3) (1 - \tanh^2 kh)}{2 \sqrt{(gk+\frac{\sigma}{\rho}k^3)\tanh kh}} \ &= \frac{(g+3\frac{\sigma}{\rho}k^2) \tanh{kh} + h (gk+\frac{\sigma}{\rho}k^3) (1 - \tanh^2 kh)}{2 \sqrt{(gk+\frac{\sigma}{\rho}k^3)\tanh kh}} \ &= \frac{(g+3\frac{\sigma}{\rho}k^2) \tanh{kh} + h (gk+\frac{\sigma}{\rho}k^3) \operatorname{sech}^2{kh}}{2 \sqrt{(gk+\frac{\sigma}{\rho}k^3)\tanh kh}} \end{align*} $$
Wolfram here. Divide numerator and denominator by $\rho$ (or p in wolfram) to make them match.
meow meow meow meow {data-date="10 Sep 2025"}
meow meow meow meow meow meow meow meow'meow meow meow meow meow. meow meow meow meow.
meow meow
- meow meow meow 3 meow meow 65 meow meow meow.
- 3% meow meow meow meow meow meow meow 3 meow.
- meow meow meow meow meow meow meow meow meow 65 meow.
- meow meow meow meow meow meow 1-10 meow meow meow.
- meow meow meow meow meow'meow meow meow meow meow meow-meow meow meow meow meow meow meow meow meow. meow, meow meow meow meow meow meow meow.
- meow meow > 3 meow meow meow meow meow meow meow meow.
- meow meow meow meow meow 10 meow/meow^2 meow'meow meow. meow'meow meow meow meow meow meow meow.
meow, meow: meow meow meow meow meow (2007)
meow
- meow 1993, meow meow meow meow meow meow meow meow meow meow.
- meow 1997, meow meow meow meow meow 560 meow meow. 76% meow meow meow meow meow. (meow'meow meow meow meow meow 1, meow 38)
- meow 2012, meow meow meow meow meow meow meow 30 meow meow.
- meow meow meow, meow meow meow 2, meow meow 2 meow meow meow meow meow meow.
- meow 1 meow 4,000 meow meow meow meow meow meow (meow).
- meow meow meow meow 5% meow meow meow meow.
meow
- meow meow meow meow meow meow.
- meow meow meow meow meow meow meow meow meow meow.
- 25% meow meow meow meow meow meow meow meow 20meow meow 30meow.
- 25% meow meow meow meow meow meow meow. meow meow meow meow meow meow.
meow meow meow
- 79%: meow meow meow
- 10%: meow meow
- 6%: meow meow
- 5%: meow meow
meow meow
- meow meow meow meow meow meow (meow)
- meow meow meow meow meow meow meow meow meow
- meow meow meow 1362 meow meow meow meow
- (meow: 1 meow/meow^2 meow meow meow 1 meow)
- meow meow meow meow 5 meow meow meow, 5 meow meow meow.
- meow meow meow 0-12 meow. meow meow meow.
- meow meow, meow meow meow meow meow meow meow meow meow 3 meow 65 meow.
- 49% meow meow meow meow meow meow meow 50 meow, meow meow meow meow meow meow meow meow meow meow meow meow meow (meow meow meow meow).
- meow meow meow meow meow meow meow meow meow.
- 67% meow meow meow meow meow meow meow meow meow meow.
- meow
meow meow
- meow meow meow meow meow
- meow meow meow meow meow meow meow meow meow meow meow meow meow
- meow meow meow meow meow meow
- meow meow meow meow 1-10 meow meow meow
- meow: meow 120 meow meow, meow meow meow meow meow meow 1200 meow meow 2400 meow meow. meow!
- meow meow meow meow meow meow 10-20 meow meow meow meow.
- meow meow meow meow meow meow meow meow.
meow meow
- meow meow > 3 meow meow meow meow meow meow meow 25% meow meow meow meow
meow.
- meow meow meow meow meow meow meow meow meow 10 meow meow meow.
- meow meow meow meow meow meow 3 meow meow meow meow meow.
meow, meow: meow meow meow meow meow meow meow
meow (2002)
meow
- meow 1 meow 6000 meow meow meow meow meow
- meow meow meow meow meow 7 meow 20 meow meow
- (meow meow meow meow meow meow meow)
- meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow, meow meow meow meow.
meow
- meow meow meow, meow meow-meow meow, meow meow meow meow meow meow meow
- meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow 1 meow 50 meow. meow meow meow meow meow meow (meow meow meow meow meow - 0% = meow meow, 100% = meow meow meow meow) meow meow meow 50% (meow meow).
- meow meow meow. meow meow meow meow meow meow meow meow meow meow meow meow 10 meow/meow^2 meow 200 meow/meow^2. meow 10meow/meow^2, meow meow meow meow; meow 200 meow/meow^2, meow meow. "... meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow."
- meow meow 5 meow/meow^2 meow meow meow meow meow meow meow meow.
meow meow 20 meow/meow^2 meow meow meow meow meow meow meow 100 meow/meow^2.
- meow: meow meow = meow meow meow.
- meow meow meow meow meow meow meow meow meow meow meow, meow 8.8% meow meow meow meow meow ~55% meow meow meow meow.
rasterized ray marching at scale {data-date="11 Jun 2025"}
I've long had the dream of creating high resolution chains on characters with raymarching. The problem is that Unity's object transform is based on the character's hip bone, so making raymarched geometry "stick" to characters is impossible.
The idea I've been toying with for a long time is to raymarch inside a rasterized box. If you store information in that box's verts, you could do a raymarch inside a wholly self contained coordinate system. I've pulled this off, but not in a way which is useful for characters (yet).
{width=80%}
TLDR:
- Create a Blender plugin to bake the location and orientation of submeshes. Plugin available here.
- Create a Unity script to visualize the baked data. Script available here.
- Provide HLSL code showing how to use the baked data.
Main ideas and HLSL
The core idea is to make it possible for each fragment of a material to learn an origin point's location and orientation. If you can recover an origin point and a rotation, then you can raymarch inside that coordinate system, then translate back to object coordinates at the end.
For each submesh* in a mesh, I bake an origin point and an orientation.
* A submesh is just a set of vertices connected by edges. A mesh might contain many unconnected submeshes. For example, in blender, you can combine two objects with ctrl+J. I call those two combined but unconnected things submeshes.
The orientation of the submesh is derived from the face normals. I sort the faces in the submesh by their area. The largest area face is used as the first basis vector of our rotated coordinate system. Then I get the next face which is sufficiently orthogonal to the first basis vector (absolute value of dot product is > some epsilon). I orthogonalize those two basis vectors with graham-schmidt, then generate the third with a cross product. I ensure right-handedness by checking that the determinant is positive, then convert to a quaternion. I then store that quaternion in 2 UV channels.
The rotation quaternion is recovered on the GPU as follows:
float4 GetRotation (v2f i ,float2 uv_channels ) {float4 quat ;quat .xy = get_uv_by_channel (i ,uv_channels .x );quat .zw = get_uv_by_channel (i ,uv_channels .y );return quat ; } ...RayMarcherOutput MyRayMarcher (v2f i ) { ...float2 uv_channels = float2 (1 ,2 );float4 quat = GetRotation (i ,uv_channels );float4 iquat = float4 (- quat .xyz ,quat .w ); }
It's worth lingering here for a second. Each submesh is conceptualized as a rotated bounding box. We just deduced an orthonormal basis for that rotated coordinate system. That means that the artist can rotate their bounding boxes however they want in Blender, and the plugin will automatically work out how to orient things. You can arbitrarily move and rotate your bounding boxes and it Just Works.
The origin point is simply the average of all the vertex locations. I encode it as a vector from each vertex to that location, and stuff it into vertex colors. Since vertex colors can only encode numbers in the range [0, 1], I use the alpha channel to scale the length of each vertex.
I made two non obvious decisions in the way I bake the vertex offsets:
-
The offsets are encoded in terms of the rotated coordinate system. This saves one quaternion rotation in the shader.
-
The offsets are scaled according to the L-infinity norm (Manhattan distance) rather than the standard L2 norm (Euclidian distance). This lets the artist think in terms of the bounding box dimensions rather than the square root of the sum of squares of the box's dimensions. Like if your box is 1x0.6x0.2, then you can just raymarch a primitive with those dimensions and your simulation Just Works.
The origin point is recovered on the GPU as follows:
float3 GetFragToOrigin (v2f i ) {return (i .color * 2.0f - 1.0f ) /i .color .a ; }RayMarcherOutput MyRayMarcher (v2f i ) { ...float3 frag_to_origin = GetFragToOrigin (i ); }
With those pieces in place, the raymarcher is pretty standard, but some care has to be taken when getting into and out of the coordinate system. Here's a complete example in HLSL:
RayMarcherOutput MyRayMarcher (v2f i ) {float3 obj_space_camera_pos = mul (unity_WorldToObject ,float4 (_WorldSpaceCameraPos ,1.0 ));float3 frag_to_origin = GetFragToOrigin (i );float2 uv_channels = float2 (1 ,2 );float4 quat = GetRotation (i ,uv_channels );float4 iquat = float4 (- quat .xyz ,quat .w );// ro is already expressed in terms of rotated basis vectors, so we // don't have to rotate it again. float3 ro = - frag_to_origin ;float3 rd = normalize (i .objPos - obj_space_camera_pos );rd = rotate_vector (rd ,iquat );float d ;float d_acc = 0 ;const float epsilon = 1e-3f ;const float max_d = 1 ;[ loop ] for ( uint ii ; ii < CUSTOM30_MAX_STEPS ; ++ ii ) { float3 p = ro + rd * d_acc ; d = map ( p ); d_acc += d ; if ( d < epsilon ) break ; if ( d_acc > max_d ) break ; } clip ( epsilon - d ); float3 localHit = ro + rd * d_acc ; float3 objHit = rotate_vector ( localHit , quat ); float3 objCenterOffset = rotate_vector ( frag_to_origin , quat ); RayMarcherOutput o ; o . objPos = objHit + ( i . objPos + objCenterOffset ); float4 clipPos = UnityObjectToClipPos ( o . objPos ); o . depth = clipPos . z / clipPos . w ; // Calculate normal in rotated space using standard raymarcher // gradient technique float3 sdfNormal = calc_normal ( localHit ); float3 objNormal = rotate_vector ( sdfNormal , quat ); o . normal = UnityObjectToWorldNormal ( objNormal ); return o ; }
Scalability and limitations
-
This technique is extremely scalable. I have a world with 16,000 bounding boxes that runs at ~800 microseconds/frame without volumetrics.
-
You can have overlapping raymarched geometry without paying the usual 8x slowdown of domain repetition.
{width=80%}
You still pay the price of overdraw, and unlike domain repetition, there's no built-in compute budgeting. I.e. with domain repetition you'd hit your iteration cap and stop. With this you won't.
-
The workflow is artist friendly. You can move, scale, and rotate your geometry freely. Re-bake once you're done and everything just works.
-
Shearing works, but doesn't permit re-baking.
{width=80%}
{width=80%}
{width=80%}
{width=80%}
Blender and Unity tooling
I've written a Blender plugin to permit myself to bake the vectors and quaternions as described above.
{width=80%}
The plugin supports baking vectors and quaternions on extremely large meshes primarily through caching. If your mesh contains many submeshes that are simply translated in space, then baking should take less than a second. If those submeshes are scaled, skewed, or rotated, then they won't cache and baking will take longer.
The baker lets you rotate the baked quaternion around the basis vectors. I had to fuck with this a fair bit, and eventually found that 180 degrees worked. Try going through every combo of 90 degrees (64 total) if you run into trouble. Use quick exporter to speed up the process. You can visualize the vectors with my Unity script, which is described below.
{width=80%}
It also supports a bunch of other workflows, mostly designed for the voxel world creation workflow:
-
Select all linked submeshes. This just does ctrl+L for each submesh with at least one vert, edge, or face selected. Blender's built in ctrl+L seems to be inconsistent in its behavior.
-
Select linked across boundaries. This basically does ctrl+L, but lets the meshes be disconnected at as long as they have a vert that's within some epsilon of a selected vert. That epsilon is configurable. It's scalable up to thousands of submeshes.
-
Deduplicate submeshes. This just looks for submeshes where all their verts are close to others. The closeness parameter (epsilon) is configurable. It works via spatial hashing so it's extremely scalable.
-
Merge by distance per submesh. This just iterates over all submeshes and does a merge by distance on each. When working with large collections of submeshes, it's easy to accidentally duplicate a face/edge/vert along the way, and these duplications can stack up. This lets you recover.
-
Pack UV island by submesh Z. This lets you pack UV islands for large collections of submeshes and sort them by their Blender z axis height. Buggy as shit rn, sorry!
This is less relevant, but I wanted some way to instance axis-aligned geometry along a curve and sort each instance's UVs by Z height. These nodes do that. Put them on a curve and select your instance. Then use the "Pack UV island by submesh Z" plugin tool to actually pack them.
{width=80%}
Finally, I have a Unity script which lets you visualize the raw baked vectors, and the "corrected" baked vectors, i.e. those rotated with the baked quaternion. Simply attach "Decode vertex vectors" to your gameobject. The light blue vectors are raw vectors, and the orange ones are the corrected ones. The orange ones should converge at the center of each submesh. (It's okay if they overshoot/undershoot, you can correct for that in your SDF.)
{width=80%}
how much CO2 do American cars produce? {data-date="23 May 2025"}
TLDR: About $1.520 \cdot 10^{12}$ kg/year. This increases the CO$_2$ in the atmosphere by about $0.048$% per year.
Let's gather some facts:
- The average American (16 or older) drives about 13,476 miles per year (US DoT).
- There are 265,653,749 Americans aged 16 or older (US 2020 Census).
- Finished motor gasoline releases about 18.73 pounds of CO$_2$ per gallon (US Energy Information Administration).
- New light duty vehicles (those weighing 10,000 pounds or less) get about 26.0 miles per gallon (mpg) as of 2024 (US DoE).
- Freight trucks are much, much worse, at around 5-7 mpg. (US DoE)
Assume that the weighted average car is getting 20 mpg. This includes passenger and freight. Passenger cars are higher and freight vehicles are lower.
Then:
$$ \begin{align*} & (265,653,749 \text{ Americans}) \ &\cdot (13,476 \text{ miles} / (\text{year} \cdot \text{American})) \ &\cdot (18.73 \text{ pounds of CO$_2$} / \text{gallon of gas}) \ &\div (20.0 \text{ miles} / \text{gallon}) \ &= 3.352 * 10^{12} \text{ pounds/year} \ &= 1.520 * 10^{12} \text{ kg/year} \end{align*} $$
Quick unit analysis to sanity check that equation:
$$ \begin{align*} &(\text{people})\cdot(\text{miles/(people$\cdot$year)}) \ \rightarrow &\text{miles/year} \ &(\text{miles/year})/(\text{miles/gallon}) \ \rightarrow &\text{gallon/year} \ &(\text{gallon/year})\cdot(\text{pounds/gallon}) \ \rightarrow &\text{pounds / year} \end{align*} $$
Checks out.
The atmosphere weighs about $5.15 \cdot 10^{18}$ kg (Lide, David R. Handbook of Chemistry and Physics. Boca Raton, FL: CRC, 1996: 14–17).
By mole fraction, the atmosphere is about 78.08% $N_2$, 20.95% $O_2$, 0.93% $Ar$, and 0.04% CO$_2$ (wikipedia).
Using the periodic table, one mole of each molecule weighs: $$ \begin{align*} N_2 = 14.0072 &= 28.014 g \ O_2 = 15.9992 &= 31.998 g \ Ar &= 39.95 g \ CO_2 = 12.011 + 15.9992 &= 44.009 g \ \end{align} $$
The weight of one mole of atmosphere is then:
$$ \begin{align*} &0.7808 \cdot 28.014 g\
- &0.2095 \cdot 31.998 g\
- &0.0093 \cdot 39.95 g\
- &0.0004 \cdot 44.009 g\ = &28.966 g \end{align*} $$
Since the atmosphere is 0.04% CO$_2$, we can compute the fractional weight of CO$_2$ in atmosphere as $44.009 g \cdot 0.0004 / 28.966 g = 0.0006077$. This number tells us what fraction of the mass of the atmosphere is CO$_2$. We established above that this number is $5.15 \cdot 10^{18}$ kg, so the weight of all the CO$_2$ in the atmosphere is therefore $3.129 \cdot 10^{15}$ kg.
We know that Americans emit $1.520 \cdot 10^{12}$ kg/year of CO$_2$. We know that the CO$_2$ in the atmosphere weighs $3.129 \cdot 10^{15} kg$. Therefore, every year, Americans increase the CO$_2$ in the atmosphere by a factor of:
$$ (1.520 \cdot 10^{12}) / (3.129 \cdot 10^{15}) = 0.00048 $$
or 0.048%.
$\blacksquare$
This guy used CO$_2$ ppm readings + the known mass of the atmosphere to arrive at a figure of 3,208 Gt, matching my 3,129 figure very closely. Wikipedia cites a figure of 3,341 Gt using the same ppm + total mass technique. So we're all within a pretty tight range of each other.
That Wikipedia article also claims that we've only increased the CO$_2$ in the atmosphere by ~50% since the beginning of the Industrial Revolution. If so, that kinda tracks with our figures. If we assume that Americans have been emitting at the current rate (fewer but shittier cars in the past) for about 50 years, that works out to a total contribution of 2.5% just from our cars.
We know that cars are not the dominant form of CO$_2$ emissions. British Petroleum publishes an amazing, annual statistical review of global energy trends. Let's pore over the 2022 document (link). In 2022, Americans emitted 4.701 Gt of CO$_2$ (page 12). Thus cars contributed 32.33% of our total CO$_2$ budget. In the same year, China emitted about 10.523 GT of CO$_2$ (page 12). Much of that can be seen as Americans offloading their emissions to China in the form of manufacturing. Finally, we see that the entire world's emissions amount to about 33.884 Gt of CO$_2$ per year. American drivers are therefore responsible for about 4.485% of that budget.
If we synthesize our "2.5% of the CO2 in the air is from American drivers" number with the above figure that we're emitting about 5% of the global budget, we get a global cumulative emission of about 50%. That also matches what Wikipedia claims: that CO2 in the atmosphere has increased by about 50% since the start of the Industrial Revolution.
So through basic analysis of public data and a couple reasonable inferences, we have arrived at the same conclusion as the "entrenched academics": that the change in CO$_2$ in the atmosphere over the last 200 years is due to human activity.
"big llms are memory bound" {data-date="22 May 2025"}
There is wisdom oft repeated that "big neural nets are limited by memory bandwidth." This is utter horseshit and I will show why.
LLMs are typically implemented as autoregressive feed-forward neural nets. This means that to generate a sentence, you provide a prompt which the neural net then uses to generate the next token. That prompt + token is fed back into the neural net repeatedly until it produces an EOF token, marking the end of generation.
We want to derive an equation predicting token rate $T$. Let's define some variables:
$T$: token rate (tokens / second)
$M$: memory bandwidth (bytes / second)
$P$: model size (parameters)
$C$: compute throughput (parameters / second)
$Q$: model quantization (bytes / parameter)
Since each token requires accessing the entire model's parameters, then on an infinitely powerful computer:
$$T = \frac{M}{P \cdot Q}$$
As the model size $P$ grows, token rate $T$ drops; as memory bandwidth $M$ grows, token rate $T$ increases. Likewise, quantizing the model eases memory pressure, so reducing bytes/param $Q$ increases token rate $T$. This is all expected.
However, most of our computers do not have infinite compute throughput. We must then adjust our equation:
$$T = \frac{\min(\frac{M}{Q}, C)}{P}$$
Token rate $T$ increases until we saturate compute $C$ or memory bandwidth $\frac{M}{Q}$, then it stops. Totally reasonable.
Notably, token rate uniformly drops as parameter count increases. The common wisdom that "big models are memory bound lol" is complete horseshit.
This equation helps you balance your compute against your memory bandwidth. You can calculate your system's memory bandwidth as follows, assuming you have DDR5:
$M_c$: memory channels
$M_s$: memory speed (GT/s)
$$M = M_s \cdot 8 \cdot M_c$$
(Source: wikipedia)
So if you have 12 channels of DDR5 @ 6000 MT/s, that works out to $12 \cdot 8 \cdot 6 = 576$ GB/s.
Consider a model like DeepSeek-V3-0324 in 2.42 bit quant. This bad boy is a mixture of experts (MoE) with 37B activated parameters per token. So at 2.42 bits / parameter, that works out to ~11.19 GB / token. Assuming infinite compute, the upper bound on token generation rate is 576 / 12.53 = 51.46 tokens / second.
I hate to be the bearer of bad news. You will not see this token rate. On my shitass server with an EPYC 9115 CPU and 12 channels of ECC DDR5 @ 6000 MT/s, I only see 4.6 tok/s. That implies that my CPU is more than 10x less than what I need to saturate my memory subsystem. I'm using a recent build of llama-cli for this test, and a relatively small context window (8k max).
In conclusion:
- The theory behind token rate is very simple once you grok that LLMs are just autoregressors, and they need to page every active parameter into memory once per token to operate.
- You can extrapolate expected performance from smaller models, since memory bandwidth and compute dictate throughput in inverse proportion to model size.
- People on the internet (especially redditors) are fucking stupid.
meow meow meow meow {data-date="14 Apr 2025"}
meow meow meow meow meow meow meow meow. meow meow meow meow, meow meow meow meow meow meow meow.
meow meow meow meow meow. meow meow meow meow meow meow meow, meow meow meow. meow meow meow. meow meow meow meow meow meow meow meow meow. meow meow meow; meow, meow meow meow meow meow meow meow.
meow meow meow meow meow. meow meow meow. meow meow.
riding crop {data-date="7 Apr 2025"}

Click here to download my riding crop from gumroad. See the gumroad page for setup instructions.
Gumroad suspended my account over this product. Yes, over a fucking riding crop. That's why it's hosted here. Enjoy the 100% discount <3
a panoply of frameworks {data-date="3 Apr 2025"}
I want to use electron. I know that raw CSS sucks dick so let's use a framework. Bootstrap sucks so let's use tailwind. Oh wait tailwind has a build step? Okay let's use the CLI. Wait, I'm going to need to be able to plumb runtime data eventually. I think that's what react is for right? Uhhh if I'm using react is the tailwind CLI going to be good enough? It seems like vite is what people are using for tailwind+react. Okay let's just commit to that. Hmm this is a lot of setup, should I use a template? Oh wait the main template people are using advertises "full access to node.js apis from the renderer process." That seems like a terrible fucking idea. Good thing I actually read the electron docs.
I want to die.
electron first impressions {data-date="1 Apr 2025"}
Occasionally I want to build some throwaway app for use by other people. CLIs are nice and all, but they're hard to launch from VR, and most people have never interacted with a terminal. So I need some way to write a GUI. Enter electron.
Electron is a cross-platform UI framework. It bundles an entire chromium install (gross) but in return you can basically just use standard web dev practices.
It exposes a two-process model: one main process, and one renderer process. The main process has basically unfettered access to the OS, and the renderer process has unfettered access to the DOM (document object model - the runtime structure of an HTML webpage). The two processes talk to each other through channels.
Generating a distributable is easy with forge-cli. My main nitpick here
is that I think the default maker should be the zip maker, not the
installer. Installers give me the headache that I have to remember to
uninstall the thing once it most likely fails to work. Isolated
environments with no hidden side effects are simply better.
Switching to zip is simple matter of editing the default forge.config.js
and moving 'win32' to the maker-zip block. The generated .zip works
basically as expected: it contains a bunch of dependencies, and an .exe.
Put the .zip in a directory, extract it, double click the .exe, and you app
opens. (One more nit: the zip should contain a subdirectory so you can
extract without manually creating a directory for it.)
The hello world package is heavy but not as bad as I expected: 10.6MB
disk (compressed), 282MB disk (uncompressed), 0.0% CPU, 65MB memory. Memory
is basically in line with what I was getting with wxWidgets - I think that
was around 30 MB with my entire STT app built in. Worse but IMO within the
realm of reasonability. Time to first draw is pretty good - under a
second according to the eyeball test.
hello world :3 {data-date="20 Mar 2025"}
<video autoplay loop muted playsinline> <source src="https://yummers.dev/images/danser.webm" type="video/webm"> me rn </video>1--- 2title : yummers 3lang : en 4description : Deriving a fast scalar FFT implementation. 5url : https://yummers.dev/a-fast-cpu-fft.html 6image : https://yummers.dev/images/2026_09_05/Screenshot%20From%202026-09-05%2017-18-00.png 7--- 8# a fast CPU FFT {data-date="8 Sep 2026"} 9 10The fast Fourier transform (FFT) is one of the most important algorithms in computer science. Modern telephony, image compression, signal analysis, and many other applications rely on the FFT at their core. It's correspondingly well researched, and has been implemented many times over. I will be reimplementing it myself, (1) because it's fun, and (2) because I'll need an unusual version of it for my ocean water simulation. This document serves as a follow-along derivation of my optimized CPU implementation, which exceeds the performance of rustfft's scalar code at the input sizes I care about. 11 12::: {.article-toc} 13::: 14 15## Background 16 17The FFT is just an efficient way of computing something called the "discrete Fourier transform." What is that, and why do we care? Essentially, a Fourier transform decomposes an input "signal", such as a sound wave or an image, into a bunch of sines and cosines. When you add those sines and cosines together, you get back the original signal. In the real world, signals are typically considered to be "continuous", meaning they aren't composed of blocks of a minimum size (if you ignore quantum mechanics). The classical Fourier transform deals with those kinds of signals. However, our digital lives *are* composed of blocks of minimum size: 1s and 0s, or bits. The discrete Fourier transform (DFT) deals with this kind of signal. 18 19This representation is extremely useful in a number of applications. For example, to compress an image, you can simply strip away all the high-frequency components of the signal. It turns out the human eye can't really tell, and you can make an image much, much smaller before it becomes obvious that it's been compressed. The same goes for music, telephony, and video. I will be using it to implement a realtime ocean water simulation - it turns out that the sum-of-sines representation is actually a highly accurate way to represent the dynamics of so-called "fully developed" oceans. More on that in a followup article - for now, we focus on the FFT. 20 21## The DFT 22 23The DFT is defined as follows[^dft] 24 25$$ 26X_k = \sum_{n=0}^{N-1} x_n e^{-\frac{2 \pi i}{N} k n} 27$$ 28 29Let's unpack that. 30 31Our input signal $x$ is comprised of $N$ samples: $x = [x_0, x_1, ... x_{N-1}]$. 32A typical 1-second sound wave would have 44,100 samples, where each sample 33represents the air pressure that a microphone measured at that point in time. 34For that reason, we say that the input signal is in the *time domain.* 35 36The DFT version of our signal, $X$, is also comprised of $N$ samples, but we 37index them with $k$ instead: $X_k = [X_0, X_1, ... X_{N-1}]$. 38 39We can simplify our expression a bit to get a sense of what's happening: 40 41$$ 42X_k = \sum_{n=0}^{N-1} x_n W^{nk}_N 43$$ 44 45To get the $k$th term of the DFT, we have to add up every component of the 46input signal multiplied by some term $W^{nk}_N$. Since there are $N$ terms in 47the DFT, we must do (N additions of the input signal) * (N times for the output 48signal). Thus this is an $O(N^2)$ algorithm. We will get back to this later! 49 50The inner term, $e^{-i ...}$ might be a head scratcher. What does it mean to 51exponentiate by an imaginary number? Where are the sines and cosines? Well, 52there's a famous formula[^eulers_formula] from calculus which tells us that: 53 54$$ 55e^{it} = \cos{t} + i \sin{t} 56$$ 57 58(This formula drops out of the Maclaurin series for $e^x$, $\cos x$, and 59$\sin x$. By rearranging terms you wind up with this identity.) 60 61So although our expression is expressed in the form $e^{i \dots}$, it is really 62representing a sum of sines and cosines. Neat! 63 64Finally, there is something unintuitive to reflect on. In the real numbers, 65there are at most two solutions to this equation, assuming that $k$ is a 66natural number (1, 2, 3...): 67 68$$ 1 = x^k $$ 69 70If $k$ is even, the only solution is $x = 1$; if $k$ is even, there is also the 71solution $x = -1$. 72 73In the complex numbers, we can have more than one solution. In general, for any 74natural number $k$, there are $k$ solutions, and they are of the form: 75 76$$ 1 = e^{\frac{2 \pi p}{k} i} $$ 77 78... where $p \in [1, k]$ 79 80(Read as "$p$ is an element of the range of numbers starting at 1 and ending at $k$"). 81 82For $k=1$: 83 84$$ 85e^{\frac{2 \pi i}{1}} = \cos{2\pi} + i \sin{2\pi} = 1 + 0 = 1 86$$ 87 88For $k=2$: 89 90$$ 91\begin{array}{rclclcl} 92e^{\frac{2 \pi i}{2} 1} &=& \cos{\pi} + i \sin{\pi} &=& -1 + 0 &=& -1 \\ 93e^{\frac{2 \pi i}{2} 2} &=& \cos{2\pi} + i \sin{2\pi} &=& 1 + 0 &=& 1 94\end{array} 95$$ 96 97So for a given $k$, the set of complex numbers that satisfy the relationship 98$1 = x^k$ are called the $k$th roots of unity, and there are $k$ of them. 99("Unity" is just another word for 1.) 100We can visualize them as simply dividing a circle in the complex plane: 101 102 103 104In summary: 105 106- $x_n$ is the $n$th term of the input signal. There are $N$ total input terms. 107- $X_k$ is the $k$th term of the DFT. There are $N$ total terms. 108- $W^{nk}_N = e^{-\frac{2 \pi i}{N} nk}$. We observe that this is an $N$th 109root of unity. 110 - Each term of the DFT multiplies each term of the input by $W^{nk}_N$. 111- Naively evaluating a DFT takes O(N^2) time. 112 113## Implementing the DFT 114 115The DFT is fairly straightforward to implement in code. Here it is in Rust: 116 117``` rust 118# Converts a `usize` to a float. 119fn usize_to_float < T : Float >(value: usize) -> T { 120num :: cast (value). unwrap () 121} 122 123# Evaluates the DFT of `data`. 124fn naive_dft < T : Float + FloatConst >(data: & mut [ Complex < T >]) { 125let big_n = data. len (); 126let mut result = vec! [ Complex :: new ( T :: zero (), T :: zero ()); big_n]; 127for k in 0 ..big_n { 128for n in 0 ..big_n { 129let k_t = usize_to_float ::< T >(k); 130let n_t = usize_to_float ::< T >(n); 131let big_n_t = usize_to_float ::< T >(big_n); 132let phase = - T :: TAU () * k_t * n_t / big_n_t; 133let factor = Complex ::< T >:: cis (phase); 134result[k] = result[k] + data[n] * factor; 135} 136} 137data. copy_from_slice ( & result); 138} 139``` 140 141This is technically correct, but there are many problems with this code: 142 1431. The factors $W_N^{nk}$ are recomputed each time we call this function, even 144though they do not change with respect to `data`. We should hoist that 145computation out. 1462. $W_N^{nk}$, also called **twiddles**, are computed with type `T`, which may be a 147low-precision float. We should compute them in high precision, then cast to 148low-precision at the end. Hoisting them out of this function also justifies 149running that computation in high precision, since it's no longer on the hot 150path. 1513. We accumulate floating point adds sequentially, which accumulates more error 152than if we accumulated them via a binary tree. 1534. The phase calculation does several floating point multiplications and 154divisions in the hot path. Had we hoisted our twiddles out, we could get 155away with no divisons and a single multiply. More on that later. 1565. The copy at the end is expensive, and we'd like to avoid it if possible. 1576. Converting floats to ints in the hot path is not free. 1587. We allocate and initialize an array, `result`, on the hot path. It's better 159than doing it on the heap, but it's still slow. The allocation should be 160hoisted out. 161 162We won't be addressing those until we get into our fast Fourier transform, but 163I want to start pointing out the kinds of issues we need to think about. The 164name of the game is doing as little work as possible in the hot path. 165 166## DFT Evaluation 167 168Let's take a look at the DFT's numerical accuracy and speed. We will be 169comparing against rust's [rustfft](https://docs.rs/rustfft/latest/rustfft/) 170crate as our speed of light. We will also be 171using a 4096-element array of randomized elements to measure both our numeric 172accuracy and speed. When measuring performance, we use 173[Criterion](https://docs.rs/criterion/latest/criterion/) to minimize 174the effects of cache hotness, scheduling noise, etc. To measure error, we use 175rustfft on a 64-bit signal as our source of truth. Finally, we will disable all 176vectorization (AVX/SSE) when measuring performance, since our end goal is a 177GPU-friendly algorithm which won't have access to those intrinsics. 178 179The results are as follows: 180 181| Algorithm | Duration | Max. error | Avg. error | 182|---------------|---------------|------------|------------| 183| **Naive DFT** | 83.513 ms | 0.33024592 | 0.00950057 | 184| rustfft | 14.791 us | 0.00009481 | 0.00000397 | 185 186(Input size 4096, type `f32`.) 187 188The speed-of-light implementation is not only ~5,690x faster, it's ~2,190x more 189accurate in the worst case, and ~2,353x more accurate on average. 190 191So, how are we going to bridge this gap? 192 193## The fast Fourier transform 194 195As highlighted above, naively evaluating a DFT takes $O(N^2)$ time, where $N$ 196is the length of the input signal. There is an algorithm appropriately named 197the *fast* Fourier transform (FFT) which evaluates the same result in 198$O(N \log N)$ time. It works by dividing the input into two parts, evaluating 199the FFT on each part (which is now half as big), then using some clever math 200to efficiently combine the results. Let's get into it. 201 202Recall the definition of the DFT: 203 204$$ 205X_k = \sum_{n=0}^{N-1} x_n e^{-\frac{2 \pi i}{N} k n} 206$$ 207 208We can split this by *even* and *odd* indices $n$: 209 210$$ 211X_k = \sum_{n=0}^{N/2-1} x_{2n} e^{-\frac{2 \pi i}{N} k (2n)} + \sum_{n=0}^{N/2-1} x_{2n+1} e^{-\frac{2 \pi i}{N} k (2n+1)} 212$$ 213 214Next, factor out $e^{-\frac{2\pi i}{N}k}$ from the second sum: 215 216$$ 217X_k = \sum_{n=0}^{N/2-1} x_{2n} e^{-\frac{2 \pi i}{N} k 2n} + e^{-\frac{2\pi i}{N}k} \sum_{n=0}^{N/2-1} x_{2n+1} e^{-\frac{2 \pi i}{N} k 2n} 218$$ 219 220(This factoring follows from the fact that, in general, $a^{b+1} = a a^b$.) 221 222Inside the sum, multiply the exponent by $\frac{1/2}{1/2}$, i.e. 1: 223 224$$ 225\begin{align*} 226X_k &= \sum_{n=0}^{N/2-1} x_{2n} e^{-\frac{2 \pi i}{N/2} k n} + e^{-\frac{2\pi i}{N}k} \sum_{n=0}^{N/2-1} x_{2n+1} e^{-\frac{2 \pi i}{N/2} k n} \\ 227&= E_k + e^{-\frac{2\pi i}{N}k} O_k 228\end{align*} 229$$ 230 231Note what just happened: we have represented the $k$th term of the DFT in 232terms of the sums of two DFT's with half as many terms! That is the essence of 233how the FFT runs in $O(N \log N)$ time. The only lurking issue is that 234this only holds for $k$ in the range $[0, N/2)$. To get $k$ in the range $[N/2, 235N)$, we have to do some analysis. We will replace every instance of $k$ with 236$k+N/2$, then attempt to refactor the expression to get a result that only 237deals with indices of $k$: 238 239::: {.wide-math} 240$$ 241\begin{array}{rcllllll} 242X_{k+N/2} &=& \sum_{n=0}^{\frac{N}{2}-1} x_{2n} & e^{-\frac{2 \pi i}{N/2} (k + \frac{N}{2}) n} & + & e^{-\frac{2\pi i}{N} (k + \frac{N}{2})} & \sum_{n=0}^{\frac{N}{2}-1} x_{2n+1} e^{-\frac{2 \pi i}{N/2} (k + \frac{N}{2}) n} & \\ 243&=& \dots & e^{-\frac{2 \pi i}{N/2} nk} e^{-\frac{2 \pi i}{N/2} n \frac{N}{2}} & + & \dots & & \\ 244&=& \dots & e^{-\frac{2 \pi i}{N/2} nk} e^{-2 \pi i n} & + & \dots & & \\ 245&=& \dots & e^{-\frac{2 \pi i}{N/2} nk} & + & \dots & \\ 246&=& \dots & & + & e^{-\frac{2\pi i}{N} k} e^{-\frac{2\pi i}{N}\frac{N}{2}} & \dots & \\ 247&=& \dots & & + & e^{-\frac{2\pi i}{N} k} e^{-\pi i} & \dots & \\ 248&=& \dots & & + & e^{-\frac{2\pi i}{N} k} (-1) & \dots & \\ 249&=& \dots & & + & \dots & \sum_{n=0}^{\frac{N}{2}-1} x_{2n+1} e^{-\frac{2 \pi i}{N/2} nk} & e^{-\frac{2 \pi i}{N/2} n\frac{N}{2}} \\ 250&=& \dots & & + & \dots & & e^{2 \pi i n} \\ 251&=& \dots & & + & \dots & & 1 \\ 252&=& \sum_{n=0}^{\frac{N}{2}-1} x_{2n} & e^{-\frac{2\pi i}{N/2} nk} & - & e^{-\frac{2\pi i}{N} k} & \sum_{n=0}^{\frac{N}{2}-1} x_{2n+1} e^{-\frac{2 \pi i}{N/2} n k} & \\ 253\end{array} 254$$ 255::: 256 257In conclusion: 258 259$$ 260\begin{align*} 261X_k &= E_k + W_N^K O_k \\ 262X_{k+N/2} &= E_k - W_N^K O_k 263\end{align} 264$$ 265 266Let's reflect on a couple things. 267 268First, we divide the input into evens and odds. This only works if the input is divisible by 2. Since we're going to be doing this *recursively*, we actually need it to be a power of 2. We can relax this by dividing the input into thirds, fourths, fifths, etc., which we'll have to get into later. If at all possible, you should try to FFT an input signal with a length whose prime factors are small. This lets us apply various analytic tricks to make it fast. It's common to pad with 0s, although that can create artifacts in the frequency-domain spectrum. 269 270Second, splitting the input into *even* and *odd* terms isn't the only choice. This approach is called *decimation in time*, because you still have samples near the beginning and end, but half as many overall. Your sample rate has halved, but the time interval is about the same. We might instead split it into a lower and upper half. This approach is called *decimation in frequency*: your time intervals halve, but the frequency rate in each half is the same. 271 272## Implementing the FFT 273 274The FFT is far less trivial to implement than the DFT. Here is the simplest code I could come up with: 275 276``` rust 277// Checks that `n = k^p`, for some natural number `p`. 278fn is_power_of_k ( n : usize , k : usize ) -> bool { 279match n { 2800 => false , 2811 => true , 282_ => n % k == 0 && is_power_of_k (n / k, k), 283} 284} 285 286// Helper to naive_fft. Takes `data` along with 3 numbers that let us recreate an even-odd subset: 287// - `start_idx` tells us where the subset begins; 288// - `big_n` is the number of elements in the subset; 289// - `stride` is the distance between elements. 290// We also use a double buffer, `scratch`, to avoid clobbering data while merging results. 291#[rustfmt::skip] 292fn _naive_fft < T : Float + FloatConst >( data : & mut [ Complex < T >], start_idx : usize , big_n : usize , stride : usize , scratch : & mut [ Complex < T >]) { 293if big_n == 1 { 294return ; 295} 296// Compute DFT of even elements. 297_naive_fft (data, start_idx, big_n/ 2 , stride * 2 , scratch); 298// Odd elements. 299_naive_fft (data, start_idx+stride, big_n/ 2 , stride * 2 , scratch); 300for k in 0 ..(big_n/ 2 ) { 301let p = data[start_idx + 2 * k * stride]; 302let q = data[start_idx + ( 2 * k + 1 ) * stride]; 303let k_t = usize_to_float ::< T >(k); 304let big_n_t = usize_to_float ::< T >(big_n); 305let phase = - T :: TAU () * k_t / big_n_t; 306let factor = Complex ::< T >:: cis (phase); 307scratch[start_idx + k * stride] = p + q * factor; 308scratch[start_idx + (k + big_n / 2 ) * stride] = p - q * factor; 309} 310data. copy_from_slice (scratch); 311} 312 313// Naive implementation of Cooley-Tukey FFT. Modifies `data`in place. Panics if data.len() is not a power of two. 314#[allow(dead_code)] 315fn naive_fft < T : Float + FloatConst >( data : & mut [ Complex < T >]) { 316assert! ( is_power_of_k (data. len (), 2 )); 317let mut scratch = Vec :: from (data. as_ref ()); 318_naive_fft (data, 0 , data. len (), 1 , & mut scratch); 319} 320``` 321 322This code is obviously highly suboptimal, for many of the same reasons as the DFT code. In addition, we also copy the entire array once per recursive call. There are $O(N)$ recursive calls, so this is extremely wasteful. We'll fix that later by double-buffering. 323 324Inefficiencies aside, this code still performs vastly better than the naive DFT: 325 326| Algorithm | Duration | Max. error | Avg. error | 327|---------------|---------------|------------|------------| 328| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 | 329| **Naive FFT** | 1.3469 ms | 0.00018436 | 0.00000708 | 330| rustfft | 14.791 us | 0.00009481 | 0.00000397 | 331 332(Input size 4096, type `f32`.) 333 334We get a nice 62x speedup, and 1335x improvement on average error. However, the 335speed-of-light implementation is still ~91x faster than ours, and 1.7x more 336accurate. Most of our work is going to focus on bridging these two gaps, while 337retaining as simple an implementation as possible. 338 339Note for a moment the impact of sequential adds. The naive DFT performed 4096 sequential adds for each term, and wound up ~2000x less accurate than the speed-of-light. Due to the FFT's recursive structure, we use log(4096) = 12 sequential adds, and that brings our accuracy within a factor of 2 of optimal. Quite the stark difference! 340 341## Opt. 1: Precompute twiddles 342 343The twiddle factors, $W_N^{nk}$, do not depend on the input to the FFT, so we can (and should!) hoist them out of the hot path. Production FFT libraries like fftw and rustfft do this, and we'll follow in their footsteps. This also lets us precompute the twiddles in high precision before casting to low precision, which as we'll see, improves the precision of the end result. 344 345First, let's precompute our twiddle factors: 346``` rust 347// Calculates the "twiddle factors" for an n-element FFT, aka all of the nth roots of unity. 348fn precompute_twiddles < T : Float + FloatConst >( n : usize ) -> Vec < Complex < T >> { 349let mut result = vec! [ Complex ::< T >:: new ( T :: zero (), T :: zero ()); n]; 350 351let n_f64 = usize_to_float ::< f64 >(n); 352for i in 0 ..n { 353let tw_f64 = Complex ::< f64 >:: cis (-f64:: TAU () * usize_to_float ::< f64 >(i) / (n_f64)); 354result[i] = Complex :: new ( T :: from (tw_f64. re ). unwrap (), T :: from (tw_f64. im ). unwrap ()); 355} 356 357result 358} 359``` 360 361Next, adjust our function to take these twiddles as input: 362 363``` rust 364fn _fft_v1_hoist < T : Float + FloatConst >( 365data : & mut [ Complex < T >], 366start_idx : usize , 367big_n : usize , 368stride : usize , 369scratch : & mut [ Complex < T >], 370twiddles : & [ Complex < T >], 371) { 372if big_n == 1 { 373return ; 374} 375// Compute DFT of even elements. 376_fft_v1_hoist (data, start_idx, big_n / 2 , stride * 2 , scratch, twiddles); 377// Odd elements. 378_fft_v1_hoist ( 379data, 380start_idx + stride, 381big_n / 2 , 382stride * 2 , 383scratch, 384twiddles, 385); 386for k in 0 ..(big_n / 2 ) { 387let p = data[start_idx + 2 * k * stride]; 388let q = data[start_idx + ( 2 * k + 1 ) * stride]; 389let factor = twiddles[k * stride]; 390scratch[start_idx + k * stride] = p + q * factor; 391scratch[start_idx + (k + big_n / 2 ) * stride] = p - q * factor; 392} 393data. copy_from_slice (scratch); 394} 395 396// Modification of fft_naive: hoist out and precompute twiddles. 397pub fn fft_v1_hoist < T : Float + FloatConst >( data : & mut [ Complex < T >], twiddles : & [ Complex < T >]) { 398assert! ( is_power_of_k (data. len (), 2 )); 399let mut scratch = Vec :: from (data. as_ref ()); 400_fft_v1_hoist (data, 0 , data. len (), 1 , & mut scratch, & twiddles); 401} 402``` 403 404We see a modest performance uplift, but our average-case error is now within 405spitting distance of the speed-of-light, and our worst-case error matches 406exactly: 407 408| Algorithm | Duration | Max. error | Avg. error | 409|---------------|---------------|------------|------------| 410| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 | 411| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 | 412| **FFT v1** | 1.2813 ms | 0.00009481 | 0.00000410 | 413| rustfft | 14.791 us | 0.00009481 | 0.00000397 | 414 415## Opt. 2: Double buffering 416 417Our FFT algorithms thus far have done a fully length-$N$ copy at each 418recurisive step. Because each recursive step divides the length of the array by 4192, we make a total of $1 + 2 + 4 + \dots N/2$ function calls, which sums to $N-1$ 420total calls. Each one does a copy of length $N$, so if each copy takes $O(N)$ 421itme, we spend $O(N^2)$ time copying buffers overall. Not good! 422 423We can fix this pretty easily with double buffering: 424 425``` rust 426fn _fft_v2_double_buffer < T : Float + FloatConst >( 427src : & mut [ Complex < T >], 428dst : & mut [ Complex < T >], 429start_idx : usize , 430big_n : usize , 431stride : usize , 432twiddles : & [ Complex < T >], 433) { 434if big_n == 1 { 435return ; 436} 437// Compute DFT of even elements. 438_fft_v2_double_buffer (dst, src, start_idx, big_n / 2 , stride * 2 , twiddles); 439// Odd elements. 440_fft_v2_double_buffer ( 441dst, 442src, 443start_idx + stride, 444big_n / 2 , 445stride * 2 , 446twiddles, 447); 448for k in 0 ..(big_n / 2 ) { 449let p = src[start_idx + 2 * k * stride]; 450let q = src[start_idx + ( 2 * k + 1 ) * stride]; 451let factor = twiddles[k * stride]; 452dst[start_idx + k * stride] = p + q * factor; 453dst[start_idx + (k + big_n / 2 ) * stride] = p - q * factor; 454} 455} 456 457#[allow(dead_code)] 458pub fn fft_v2_double_buffer < T : Float + FloatConst >( 459src : & mut [ Complex < T >], 460dst : & mut [ Complex < T >], 461twiddles : & [ Complex < T >], 462) { 463assert! ( is_power_of_k (src. len (), 2 )); 464dst. copy_from_slice (src); 465// Switching `src` and `dst` means that at the end, the result is in `src` - which is actually 466// what we want! We will be hiding `dst` and `twiddles` in a struct later on :) 467_fft_v2_double_buffer (dst, src, 0 , src. len (), 1 , twiddles); 468} 469``` 470 471Note that we only hoist out the *allocation* of the double-buffer. 472Initialization still occurs in the hot path. 473 474Accuracy numbers are identical to before, as expected, and performance is 475vastly improved: 476 477| Algorithm | Duration | Max. error | Avg. error | 478|---------------|---------------|------------|------------| 479| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 | 480| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 | 481| FFT v1 | 1.2813 ms | 0.00009481 | 0.00000410 | 482| **FFT v2** | 39.944 us | 0.00009481 | 0.00000410 | 483| rustfft | 14.791 us | 0.00009481 | 0.00000397 | 484 485Pretty remarkable result. Minimizing memory writes gets us within a factor of 3 486of the state of the art. 487 488Still... we can go faster! 489 490## Opt. 3: Iterative instead of recursive 491 492The recursive implementation we're using is good for the classroom, but bad for 493performance. If we switch to an iterative implementation, we'll be able to 494share work each time we step down a layer of recursion. It will also make it 495much easier to map this algorithm to the GPU (more on that later). 496 497Let's do it: 498 499``` rust 500pub fn fft_v3_iterative < T : Float + FloatConst >( 501src : & mut [ Complex < T >], 502dst : & mut [ Complex < T >], 503twiddles : & [ Complex < T >], 504) { 505assert! ( is_power_of_k (src. len (), 2 )); 506dst. copy_from_slice (src); 507let n_iter = log_k_of ::< 2 >(src. len ()); 508 509if n_iter % 2 != 0 { 510dst. copy_from_slice (src); 511} 512 513let ( mut input, mut output) = if n_iter % 2 == 0 { 514(dst, src) 515} else { 516(src, dst) 517}; 518let mut stride = input. len (); 519let mut big_n = 1 ; 520for _ in 0 ..n_iter { 521stride /= 2 ; 522big_n *= 2 ; 523std::mem:: swap ( & mut input, & mut output); 524 525for start_idx in 0 ..stride { 526for k in 0 ..big_n / 2 { 527// Get odd and even elements. 528let p = input[start_idx + 2 * k * stride]; 529let q = input[start_idx + ( 2 * k + 1 ) * stride]; 530// Combine. 531let factor = twiddles[k * stride]; 532output[start_idx + k * stride] = p + q * factor; 533output[start_idx + (k + big_n / 2 ) * stride] = p - q * factor; 534} 535} 536} 537} 538``` 539 540This is essentially identical to the v2 code, except that we use iteration 541instead of recursion. Regardless, the performance uplift is dramatic: 542 543| Algorithm | Duration | Max. error | Avg. error | 544|---------------|---------------|------------|------------| 545| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 | 546| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 | 547| FFT v1 | 1.2813 ms | 0.00009481 | 0.00000410 | 548| FFT v2 | 39.944 us | 0.00009481 | 0.00000410 | 549| **FFT v3** | 23.626 us | 0.00009481 | 0.00000410 | 550| rustfft | 14.791 us | 0.00009481 | 0.00000397 | 551 552We're well within a factor of 2 of SOTA now! No, we're not done. 553 554## Aside: the radix-4 FFT 555 556Let's think, for a moment, what our FFT would look like if instead of splitting 557the input into 2 parts at each stage, we broke it into 4: 558 559$$ 560\begin{array}{rcll} 561X_k = & \sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ 562& \sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n+1)k} & + \\ 563& \sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n+2)k} & + \\ 564& \sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n+3)k} & 565\end{array} 566$$ 567 568Apply the usual factoring trick: 569 570$$ 571\begin{array}{rclll} 572X_k = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ 573& e^{-\frac{2\pi i}{N}k} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ 574& e^{-\frac{2\pi i}{N}2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ 575& e^{-\frac{2\pi i}{N}3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)k} & 576\end{array} 577$$ 578 579This is only valid for $k$ on $[0, N/4)$. To get the others we have to do the 580same analysis as before - replace every $k$ with $k + N/4$, then do some 581eliminations and factoring. 582 583### Calculating $k+N/4$ 584 585$$ 586\begin{array}{rclll} 587X_{k+N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)(k+N/4)} & + \\ 588& e^{-\frac{2\pi i}{N}(k+N/4)} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)(k+N/4)} & + \\ 589& e^{-\frac{2\pi i}{N}2(k+N/4)} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)(k+N/4)} & + \\ 590& e^{-\frac{2\pi i}{N}3(k+N/4)} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)(k+N/4)} & 591\end{array} 592$$ 593 594Simplify the shared inner term: 595 596$$ 597\begin{align*} 598e^{-\frac{2\pi i}{N}(4n)(k+N/4)} &= e^{-\frac{2\pi i}{N}(4n)k} e^{-\frac{2\pi i}{N}(4n)N/4} \\ 599&= e^{-\frac{2\pi i}{N}(4n)k} e^{-2\pi i n} \\ 600&= e^{-\frac{2\pi i}{N}(4n)k} 601\end{align*} 602$$ 603 604Simplify the first outer term: 605 606$$ 607\begin{align*} 608e^{-\frac{2\pi i}{N}(k+N/4)} &= e^{-\frac{2\pi i}{N}k} e^{-\frac{2\pi i}{N}N/4} \\ 609&= e^{-\frac{2\pi i}{N}k} e^{-\frac{2\pi i}{4}} \\ 610&= e^{-\frac{2\pi i}{N}k} (-i) \\ 611\end{align*} 612$$ 613 614By inspection, we can see that the second and third terms will be of this form 615as well. We're basically just multiplying by a vector that's rotating 90 616degrees clockwise in the complex plane: 617 618$$ 619\begin{align*} 620e^{-\frac{2\pi i}{N}(2k+2N/4)} &= e^{-\frac{2\pi i}{N}2k} e^{-\frac{2\pi i 2}{4}} \\ 621&= e^{-\frac{2\pi i}{N}2k} (-1) \\ 622e^{-\frac{2\pi i}{N}(3k+3N/4)} &= e^{-\frac{2\pi i}{N}3k} e^{-\frac{2\pi i 3}{4}} \\ 623&= e^{-\frac{2\pi i}{N}3k} (i) \\ 624\end{align*} 625$$ 626 627Plugging in: 628 629$$ 630\begin{array}{rrlll} 631X_{k+N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ 632& (-i) e^{-\frac{2\pi i}{N}k} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ 633& (-1) e^{-\frac{2\pi i}{N}2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ 634& (i) e^{-\frac{2\pi i}{N}3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)k} & 635\end{array} 636$$ 637 638Using $W$ syntax: 639 640$$ 641\begin{array}{rrlll} 642X_{k+N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & W_N^{4nk} & + \\ 643& (-i) W_N^k &\sum_{n=0}^{N/4-1} x_{4n+1} & W_N^{4nk} & + \\ 644& (-1) W_N^{2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & W_N^{4nk} & + \\ 645& (i) W_N^{3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & W_N^{4nk} & 646\end{array} 647$$ 648 649### Calculating $k+N/2$ 650 651$$ 652\begin{array}{rclll} 653X_{k+N/2} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)(k+N/2)} & + \\ 654& e^{-\frac{2\pi i}{N}(k+N/2)} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)(k+N/2)} & + \\ 655& e^{-\frac{2\pi i}{N}2(k+N/2)} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)(k+N/2)} & + \\ 656& e^{-\frac{2\pi i}{N}3(k+N/2)} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)(k+N/2)} & 657\end{array} 658$$ 659 660Simplify the shared inner term: 661 662$$ 663\begin{align*} 664e^{-\frac{2\pi i}{N}(4n)(k+N/2)} &= e^{-\frac{2\pi i}{N}(4n)k} e^{-\frac{2\pi i}{N}(4n)N/2} \\ 665&= e^{-\frac{2\pi i}{N}(4n)k} e^{-2\pi i 2n} \\ 666&= e^{-\frac{2\pi i}{N}(4n)k} 667\end{align*} 668$$ 669 670(We can see from the above that the last quarter will also have the same 671simplification applied, so we will skip deriving it later.) 672 673Simplify the first outer term: 674 675$$ 676\begin{align*} 677e^{-\frac{2\pi i}{N}(k+N/2)} &= e^{-\frac{2\pi i}{N}k} e^{-\frac{2\pi i}{N}N/2} \\ 678&= e^{-\frac{2\pi i}{N}k} e^{-\frac{2\pi i}{2}} \\ 679&= e^{-\frac{2\pi i}{N}k} (-1) \\ 680\end{align*} 681$$ 682 683Let's pause here to reflect. In the $[0, N/4)$, we rotated our outer terms by a 684quarter turn in the complex plane for each term. Now we're rotating by a half 685turn. The next leg, we will rotate by 3/4 of a turn. 686 687I will truncate the derivation there. The reader may do the rest as an exercise 688if needed. 689 690Plugging in: 691 692$$ 693\begin{array}{rrlll} 694X_{k+N/2} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ 695& (-1) e^{-\frac{2\pi i}{N}k} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ 696& (+1) e^{-\frac{2\pi i}{N}2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ 697& (-1) e^{-\frac{2\pi i}{N}3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)k} & 698\end{array} 699$$ 700 701Using $W$ syntax: 702 703$$ 704\begin{array}{rrlll} 705X_{k+N/2} = & &\sum_{n=0}^{N/4-1} x_{4n} & W_N^{4nk} & + \\ 706& (-1) W_N^k &\sum_{n=0}^{N/4-1} x_{4n+1} & W_N^{4nk} & + \\ 707& (+1) W_N^{2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & W_N^{4nk} & + \\ 708& (-1) W_N^{3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & W_N^{4nk} & 709\end{array} 710$$ 711 712### Calculating $k+3N/4$ 713 714Per the lemmas in the last section, we can jump right to the result: 715 716$$ 717\begin{array}{rrlll} 718X_{k+3N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ 719& (+i) e^{-\frac{2\pi i}{N}k} &\sum_{n=0}^{N/4-1} x_{4n+1} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ 720& (-1) e^{-\frac{2\pi i}{N}2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & e^{-\frac{2\pi i}{N}(4n)k} & + \\ 721& (-i) e^{-\frac{2\pi i}{N}3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & e^{-\frac{2\pi i}{N}(4n)k} & 722\end{array} 723$$ 724 725Using $W$ syntax: 726 727$$ 728\begin{array}{rrlll} 729X_{k+3N/4} = & &\sum_{n=0}^{N/4-1} x_{4n} & W_N^{4nk} & + \\ 730& (+i) W_N^k &\sum_{n=0}^{N/4-1} x_{4n+1} & W_N^{4nk} & + \\ 731& (-1) W_N^{2k} &\sum_{n=0}^{N/4-1} x_{4n+2} & W_N^{4nk} & + \\ 732& (-i) W_N^{3k} &\sum_{n=0}^{N/4-1} x_{4n+3} & W_N^{4nk} & 733\end{array} 734$$ 735 736### Summary 737 738The radix-4 FFT's merge step works as follows: 739 740| $k$ range | Term 0 | Term 1 | Term 2 | Term 3 | 741|--------------|--------|--------|--------|--------| 742| $[0,N/4)$ | +1 | +1 | +1 | +1 | 743| $[N/4,N/2)$ | +1 | -i | -1 | +i | 744| $[N/2,3N/4)$ | +1 | -1 | +1 | -1 | 745| $[3N/4,N)$ | +1 | +i | -1 | -i | 746 747And for each stage, the twiddles are: 748 749- Term 0: 1 750- Term 1: $W_N^k$ 751- Term 2: $W_N^{2k}$ 752- Term 3: $W_N^{3k}$ 753 754## Opt. 4: Radix-4 755 756With the above in mind, we can now implement the radix-4 FFT: 757 758``` rust 759#[inline(always)] 760fn mul_ni < T : Float + FloatConst >( x : Complex < T >) -> Complex < T > { 761Complex :: new (x. im , -x. re ) 762} 763 764pub fn fft_v4_radix_4 < T : Float + FloatConst >( 765src : & mut [ Complex < T >], 766dst : & mut [ Complex < T >], 767twiddles : & [ Complex < T >], 768) { 769assert! ( is_power_of_k (src. len (), 4 )); 770let n_iter = log_k_of ::< 4 >(src. len ()); 771 772dst. copy_from_slice (src); 773 774let ( mut input, mut output) = if n_iter % 2 == 0 { 775(dst, src) 776} else { 777(src, dst) 778}; 779let big_n = input. len (); 780let mut stride = big_n; 781let mut big_n = 1 ; 782for _ in 0 ..n_iter { 783stride /= 4 ; 784big_n *= 4 ; 785std::mem:: swap ( & mut input, & mut output); 786 787for start_idx in 0 ..stride { 788for k in 0 ..big_n / 4 { 789// Collect inputs. 790let i0 = input[start_idx + 4 * k * stride]; 791let i1 = input[start_idx + ( 4 * k + 1 ) * stride]; 792let i2 = input[start_idx + ( 4 * k + 2 ) * stride]; 793let i3 = input[start_idx + ( 4 * k + 3 ) * stride]; 794// Collect relevant twiddles. 795let ot1 = twiddles[ 1 * k * stride]; 796let ot2 = twiddles[ 2 * k * stride]; 797let ot3 = twiddles[ 3 * k * stride]; 798 799let a = i0; 800let b = ot1 * i1; 801let c = ot2 * i2; 802let d = ot3 * i3; 803 804// To derive this, write the expression below in terms of 805// a/b/c/d, then factor out! 806let ac_sum = a + c; 807let ac_diff = a - c; 808let bd_sum = b + d; 809let bd_diff_ni = mul_ni (b - d); 810 811output[start_idx + k * stride] = ac_sum + bd_sum; 812output[start_idx + (k + big_n / 4 ) * stride] = 813ac_diff + bd_diff_ni; 814output[start_idx + (k + big_n / 2 ) * stride] = 815ac_sum - bd_sum; 816output[start_idx + (k + 3 * big_n / 4 ) * stride] = 817ac_diff - bd_diff_ni; 818} 819} 820} 821} 822``` 823 824As a quick aside - note that we could simply multiply [a, b, c, d] by a 4x4 825matrix holding the terms we derived in the previous section. Possibly useful 826for a GPU implementation! 827 828With this we pick up another ~10% speedup, and actually *beat* the reference 829implementation's average-case error! 830 831| Algorithm | Duration | Max. error | Avg. error | 832|---------------|---------------|------------|------------| 833| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 | 834| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 | 835| FFT v1 | 1.2813 ms | 0.00009481 | 0.00000410 | 836| FFT v2 | 39.944 us | 0.00009481 | 0.00000410 | 837| FFT v3 | 23.626 us | 0.00009481 | 0.00000410 | 838| **FFT v4** | 20.231 us | 0.00009481 | 0.00000396 | 839| rustfft | 14.791 us | 0.00009481 | 0.00000397 | 840 841A few remarks: 842 843- Obviously, this can only be used on inputs with a size that's a power of 4. 844We could get fancy and do some split-radix stuff (some stages are radix a, 845others radix b, etc.) but I'm not planning to. 846- This is faster because we save on complex multiplies and adds. 847 848## Opt. 5: Special-case first stage 849 850The twiddles we look up in our inner loop are just $W_N^{k s}$. For the 851first iteration, `big_n` $= 4$, so $k$ is always 0 -- therefore, we use 852$W_N^0$, which is just 1. We can special-case this and save a few more complex 853multiplies: 854 855``` rust 856fn fft_butterfly_radix_4 < T : Float + FloatConst >( 857input : & mut [ Complex < T >], 858output : & mut [ Complex < T >], 859stride : usize , 860big_n : usize , 861twiddles : & [ Complex < T >], 862) { 863for start_idx in 0 ..stride { 864for k in 0 ..big_n / 4 { 865// Collect inputs. 866let i0 = input[start_idx + 4 * k * stride]; 867let i1 = input[start_idx + ( 4 * k + 1 ) * stride]; 868let i2 = input[start_idx + ( 4 * k + 2 ) * stride]; 869let i3 = input[start_idx + ( 4 * k + 3 ) * stride]; 870// Collect relevant twiddles. 871let ot1 = twiddles[ 1 * k * stride]; 872let ot2 = twiddles[ 2 * k * stride]; 873let ot3 = twiddles[ 3 * k * stride]; 874 875let a = i0; 876let b = ot1 * i1; 877let c = ot2 * i2; 878let d = ot3 * i3; 879 880// To derive this, write the output assignments in terms of 881// a/b/c/d, then factor out! 882let ac_sum = a + c; 883let ac_diff = a - c; 884let bd_sum = b + d; 885let bd_diff_ni = mul_ni (b - d); 886 887output[start_idx + k * stride] = ac_sum + bd_sum; 888output[start_idx + (k + big_n / 4 ) * stride] = ac_diff + bd_diff_ni; 889output[start_idx + (k + big_n / 2 ) * stride] = ac_sum - bd_sum; 890output[start_idx + (k + 3 * big_n / 4 ) * stride] = ac_diff - bd_diff_ni; 891} 892} 893} 894 895fn fft_butterfly_radix_4_s0 < T : Float + FloatConst >( 896input : & mut [ Complex < T >], 897output : & mut [ Complex < T >], 898twiddles : & [ Complex < T >], 899) { 900let stride = input. len () / 4 ; 901let big_n = 4 ; 902 903for start_idx in 0 ..stride { 904for k in 0 ..big_n / 4 { 905// Collect inputs. 906let i0 = input[start_idx + 4 * k * stride]; 907let i1 = input[start_idx + ( 4 * k + 1 ) * stride]; 908let i2 = input[start_idx + ( 4 * k + 2 ) * stride]; 909let i3 = input[start_idx + ( 4 * k + 3 ) * stride]; 910 911let a = i0; 912let b = i1; 913let c = i2; 914let d = i3; 915 916// To derive this, write the output assignments in terms of 917// a/b/c/d, then factor out! 918let ac_sum = a + c; 919let ac_diff = a - c; 920let bd_sum = b + d; 921let bd_diff_ni = mul_ni (b - d); 922 923output[start_idx + k * stride] = ac_sum + bd_sum; 924output[start_idx + (k + big_n / 4 ) * stride] = ac_diff + bd_diff_ni; 925output[start_idx + (k + big_n / 2 ) * stride] = ac_sum - bd_sum; 926output[start_idx + (k + 3 * big_n / 4 ) * stride] = ac_diff - bd_diff_ni; 927} 928} 929} 930 931pub fn fft_v5_s0_opt < T : Float + FloatConst >( 932src : & mut [ Complex < T >], 933dst : & mut [ Complex < T >], 934twiddles : & [ Complex < T >], 935) { 936assert! ( is_power_of_k (src. len (), 4 )); 937let n_iter = log_k_of ::< 4 >(src. len ()); 938 939dst. copy_from_slice (src); 940 941let ( mut input, mut output) = if n_iter % 2 == 0 { 942(dst, src) 943} else { 944(src, dst) 945}; 946let big_n = input. len (); 947let mut stride = big_n; 948let mut big_n = 1 ; 949for stage in 0 ..n_iter { 950stride /= 4 ; 951big_n *= 4 ; 952std::mem:: swap ( & mut input, & mut output); 953 954if stage == 0 { 955fft_butterfly_radix_4_s0 (input, output, twiddles); 956} else { 957fft_butterfly_radix_4 (input, output, stride, big_n, twiddles); 958} 959} 960} 961``` 962 963Here I refactored the inner loop of our FFT - called a **butterfly** in FFT 964research parlance - and made a variant which avoids those complex multiplies in 965stage 1. We get a few more microseconds out of this, with no change to our 966accuracy: 967 968| Algorithm | Duration | Max. error | Avg. error | 969|---------------|---------------|------------|------------| 970| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 | 971| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 | 972| FFT v1 | 1.2813 ms | 0.00009481 | 0.00000410 | 973| FFT v2 | 39.944 us | 0.00009481 | 0.00000410 | 974| FFT v3 | 23.626 us | 0.00009481 | 0.00000410 | 975| FFT v4 | 20.231 us | 0.00009481 | 0.00000396 | 976| **FFT v5** | 16.383 us | 0.00009481 | 0.00000396 | 977| rustfft | 14.791 us | 0.00009481 | 0.00000397 | 978 979Within 12% of our speed-of-light! No, we're not done yet :) 980 981## Opt. 6: Unsafe 982 983Our butterfly does 8 array lookups, each of which Rust will bounds-check for 984us. However, we know by inspection that they will never go out of bounds. So we 985can tell Rust this with the `unsafe` keyword, and enable more compiler 986optimizations. 987 988``` rust 989fn fft_butterfly_radix_4_unsafe < T : Float + FloatConst >( 990input : & mut [ Complex < T >], 991output : & mut [ Complex < T >], 992stride : usize , 993big_n : usize , 994twiddles : & [ Complex < T >], 995) { 996let input_ptr = input. as_ptr (); 997let output_ptr = output. as_mut_ptr (); 998for start_idx in 0 ..stride { 999for k in 0 ..big_n / 4 { 1000unsafe { 1001// Collect inputs. 1002let i0 = * input_ptr. add (start_idx + 4 * k * stride); 1003let i1 = * input_ptr. add (start_idx + ( 4 * k + 1 ) * stride); 1004let i2 = * input_ptr. add (start_idx + ( 4 * k + 2 ) * stride); 1005let i3 = * input_ptr. add (start_idx + ( 4 * k + 3 ) * stride); 1006// Collect relevant twiddles. 1007let ot1 = twiddles. get_unchecked ( 1 * k * stride); 1008let ot2 = twiddles. get_unchecked ( 2 * k * stride); 1009let ot3 = twiddles. get_unchecked ( 3 * k * stride); 1010 1011let a = i0; 1012let b = ot1 * i1; 1013let c = ot2 * i2; 1014let d = ot3 * i3; 1015 1016// To derive this, write the output assignments in terms of 1017// a/b/c/d, then factor out! 1018let ac_sum = a + c; 1019let ac_diff = a - c; 1020let bd_sum = b + d; 1021let bd_diff_ni = mul_ni (b - d); 1022 1023* output_ptr. add (start_idx + k * stride) = ac_sum + bd_sum; 1024* output_ptr. add (start_idx + (k + big_n / 4 ) * stride) = ac_diff + bd_diff_ni; 1025* output_ptr. add (start_idx + (k + big_n / 2 ) * stride) = ac_sum - bd_sum; 1026* output_ptr. add (start_idx + (k + 3 * big_n / 4 ) * stride) = ac_diff - bd_diff_ni; 1027} 1028} 1029} 1030} 1031 1032fn fft_butterfly_radix_4_s0_unsafe < T : Float + FloatConst >( 1033input : & mut [ Complex < T >], 1034output : & mut [ Complex < T >], 1035) { 1036let stride = input. len () / 4 ; 1037let big_n = 4 ; 1038let input_ptr = input. as_ptr (); 1039let output_ptr = output. as_mut_ptr (); 1040for start_idx in 0 ..stride { 1041for k in 0 ..big_n / 4 { 1042unsafe { 1043// Collect inputs. 1044let i0 = input[start_idx + 4 * k * stride]; 1045let i1 = input[start_idx + ( 4 * k + 1 ) * stride]; 1046let i2 = input[start_idx + ( 4 * k + 2 ) * stride]; 1047let i3 = input[start_idx + ( 4 * k + 3 ) * stride]; 1048 1049let a = i0; 1050let b = i1; 1051let c = i2; 1052let d = i3; 1053 1054// To derive this, write the output assignments in terms of 1055// a/b/c/d, then factor out! 1056let ac_sum = a + c; 1057let ac_diff = a - c; 1058let bd_sum = b + d; 1059let bd_diff_ni = mul_ni (b - d); 1060 1061* output_ptr. add (start_idx + k * stride) = ac_sum + bd_sum; 1062* output_ptr. add (start_idx + (k + big_n / 4 ) * stride) = ac_diff + bd_diff_ni; 1063* output_ptr. add (start_idx + (k + big_n / 2 ) * stride) = ac_sum - bd_sum; 1064* output_ptr. add (start_idx + (k + 3 * big_n / 4 ) * stride) = ac_diff - bd_diff_ni; 1065} 1066} 1067} 1068} 1069 1070pub fn fft_v6_unsafe < T : Float + FloatConst >( 1071src : & mut [ Complex < T >], 1072dst : & mut [ Complex < T >], 1073twiddles : & [ Complex < T >], 1074) { 1075assert! ( is_power_of_k (src. len (), 4 )); 1076assert_eq! (src. len (), dst. len ()); 1077assert_eq! (twiddles. len (), src. len ()); 1078let n_iter = log_k_of ::< 4 >(src. len ()); 1079 1080dst. copy_from_slice (src); 1081 1082let ( mut input, mut output) = if n_iter % 2 == 0 { 1083(dst, src) 1084} else { 1085(src, dst) 1086}; 1087let big_n = input. len (); 1088let mut stride = big_n; 1089let mut big_n = 1 ; 1090for stage in 0 ..n_iter { 1091stride /= 4 ; 1092big_n *= 4 ; 1093std::mem:: swap ( & mut input, & mut output); 1094 1095if stage == 0 { 1096fft_butterfly_radix_4_s0_unsafe (input, output); 1097} else { 1098fft_butterfly_radix_4_unsafe (input, output, stride, big_n, twiddles); 1099} 1100} 1101} 1102``` 1103 1104Note that "add" just means "add a value to this pointer." Seems to be the 1105canonical way to do pointer arithmetic in Rust. With this, we have *nearly* 1106reached the speed of light! 1107 1108| Algorithm | Duration | Max. error | Avg. error | 1109|---------------|---------------|------------|------------| 1110| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 | 1111| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 | 1112| FFT v1 | 1.2813 ms | 0.00009481 | 0.00000410 | 1113| FFT v2 | 39.944 us | 0.00009481 | 0.00000410 | 1114| FFT v3 | 23.626 us | 0.00009481 | 0.00000410 | 1115| FFT v4 | 20.231 us | 0.00009481 | 0.00000396 | 1116| FFT v5 | 16.383 us | 0.00009481 | 0.00000396 | 1117| **FFT v6** | 14.830 us | 0.00009481 | 0.00000396 | 1118| rustfft | 14.791 us | 0.00009481 | 0.00000397 | 1119 1120No, we're not done. 1121 1122## Opt. 7: Radix-8 1123 1124Why stop at radix-4? If we extend to radix-8, we still get the desirable 1125analytic property of our factors not requiring complex multiplies, as they're 1126just 45-degree rotations, but we *also* reduce the number of stages. 1127 1128For a length-4096 input, aka $2^12$, radix-4 requires 6 stages, where radix-8 1129requires only 4. If each stage does 3 and 7 complex multiplies respectively, we 1130wind up with 18$s$ vs. $14$ total complex multiplies. 1131 1132We also reduce the number of times that we need to read the full data buffer 1133from 6 to 4. 1134 1135I can derive the radix-8 twiddles by inspection - it's left as an exercise to 1136the reader if needed. (Tip: visualize the rotations through the complex plane.) 1137 1138Let $p = \frac{1}{\sqrt{2}}$. Then: 1139 1140| $k$ range | Term 0 | Term 1 | Term 2 | Term 3 | Term 4 | Term 5 | Term 6 | Term 7 | 1141|---------------|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:| 1142| $[0,N/8)$ | +1 | +1 | +1 | +1 | +1 | +1 | +1 | +1 | 1143| $[N/8,N/4)$ | +1 | $+p-ip$ | -i | $-p-ip$ | -1 | $-p+ip$ | +i | $+p+ip$ | 1144| $[N/4,3N/8)$ | +1 | -i | -1 | +i | +1 | -i | -1 | +i | 1145| $[3N/8,N/2)$ | +1 | $-p-ip$ | +i | $p-ip$ | -1 | $p+ip$ | -i | $-p+ip$ | 1146| $[N/2,5N/8)$ | +1 | -1 | +1 | -1 | +1 | -1 | +1 | -1 | 1147| $[5N/8,3N/4)$ | +1 | $-p+ip$ | -i | $p+ip$ | -1 | $p-ip$ | +i | $-p-ip$ | 1148| $[3N/4,7N/8)$ | +1 | +i | -1 | -i | +1 | +i | -1 | -i | 1149| $[7N/8,N)$ | +1 | $p+ip$ | +i | $-p+ip$ | -1 | $-p-ip$ | -i | $p-ip$ | 1150 1151And the twiddles are $1, W_N^k, W_N^{2k}, \dots, W_N^{7k}$. 1152 1153First, we need an optimized way to rotate by 45 degrees, as well as every 1154multiple of 90 degrees. The standard 2D rotation matrix[^rotation_matrix] makes this easy: 1155 1156``` rust 1157#[inline(always)] 1158fn rot_45 < T : Float + FloatConst >( c : Complex < T >) -> Complex < T > { 1159let s = T :: FRAC_1_SQRT_2 (); 1160// The standard 2D rotation matrix gives: 1161// [ cos(pi/4) -sin(pi/4)] [ s -s ] 1162// [ sin(pi/4) cos(pi/4)] = [ s s ] 1163Complex ::< T >:: new (c. re - c. im , c. re + c. im ) * s 1164} 1165 1166#[inline(always)] 1167fn rot_90 < T : Float + FloatConst >( c : Complex < T >) -> Complex < T > { 1168// The standard 2D rotation matrix gives: 1169// [ cos(pi/2) -sin(pi/2)] [ 0 -1 ] 1170// [ sin(pi/2) cos(pi/2)] = [ 1 0 ] 1171Complex ::< T >:: new (-c. im , c. re ) 1172} 1173 1174#[inline(always)] 1175fn rot_180 < T : Float + FloatConst >( c : Complex < T >) -> Complex < T > { 1176// The standard 2D rotation matrix gives: 1177// [ cos(pi) -sin(pi)] [ -1 0 ] 1178// [ sin(pi) cos(pi)] = [ 0 -1 ] 1179-c 1180} 1181 1182#[inline(always)] 1183fn rot_270 < T : Float + FloatConst >( c : Complex < T >) -> Complex < T > { 1184// The standard 2D rotation matrix gives: 1185// [ cos(3pi/2) -sin(3pi/2)] [ 0 1 ] 1186// [ sin(3pi/2) cos(3pi/2)] = [ -1 0 ] 1187Complex ::< T >:: new (c. im , -c. re ) 1188} 1189``` 1190 1191Next, we just write out our big radix-8 butterflies: 1192 1193``` rust 1194fn fft_butterfly_radix_8_unsafe < T : Float + FloatConst >( 1195input : & mut [ Complex < T >], 1196output : & mut [ Complex < T >], 1197stride : usize , 1198big_n : usize , 1199twiddles : & [ Complex < T >], 1200) { 1201let input_ptr = input. as_ptr (); 1202let output_ptr = output. as_mut_ptr (); 1203for start_idx in 0 ..stride { 1204for k in 0 ..big_n / 8 { 1205unsafe { 1206// Collect inputs. 1207let i0 = * input_ptr. add (start_idx + 8 * k * stride); 1208let i1 = * input_ptr. add (start_idx + ( 8 * k + 1 ) * stride); 1209let i2 = * input_ptr. add (start_idx + ( 8 * k + 2 ) * stride); 1210let i3 = * input_ptr. add (start_idx + ( 8 * k + 3 ) * stride); 1211let i4 = * input_ptr. add (start_idx + ( 8 * k + 4 ) * stride); 1212let i5 = * input_ptr. add (start_idx + ( 8 * k + 5 ) * stride); 1213let i6 = * input_ptr. add (start_idx + ( 8 * k + 6 ) * stride); 1214let i7 = * input_ptr. add (start_idx + ( 8 * k + 7 ) * stride); 1215 1216// Collect relevant twiddles. 1217let ot1 = twiddles. get_unchecked ( 1 * k * stride); 1218let ot2 = twiddles. get_unchecked ( 2 * k * stride); 1219let ot3 = twiddles. get_unchecked ( 3 * k * stride); 1220let ot4 = twiddles. get_unchecked ( 4 * k * stride); 1221let ot5 = twiddles. get_unchecked ( 5 * k * stride); 1222let ot6 = twiddles. get_unchecked ( 6 * k * stride); 1223let ot7 = twiddles. get_unchecked ( 7 * k * stride); 1224 1225let a = i0; 1226let b = ot1 * i1; 1227let c = ot2 * i2; 1228let d = ot3 * i3; 1229let e = ot4 * i4; 1230let f = ot5 * i5; 1231let g = ot6 * i6; 1232let h = ot7 * i7; 1233 1234let ae_sum = a + e; 1235let ae_diff = a - e; 1236let bf_sum = b + f; 1237let bf_diff = b - f; 1238let cg_sum = c + g; 1239let cg_diff = c - g; 1240let dh_sum = d + h; 1241let dh_diff = d - h; 1242 1243let w00 = ae_sum + cg_sum; 1244let w01 = ae_sum - cg_sum; 1245let w10 = ae_diff + rot_270 (cg_diff); 1246let w11 = ae_diff - rot_270 (cg_diff); 1247let x00 = bf_sum + dh_sum; 1248let x01 = rot_270 (bf_sum) + rot_90 (dh_sum); 1249let x10 = rot_45 ( rot_270 (bf_diff) + rot_180 (dh_diff)); 1250let x11 = rot_45 ( rot_180 (bf_diff) + rot_270 (dh_diff)); 1251 1252* output_ptr. add (start_idx + k * stride) = w00 + x00; 1253* output_ptr. add (start_idx + (k + big_n / 8 ) * stride) = w10 + x10; 1254* output_ptr. add (start_idx + (k + big_n / 4 ) * stride) = w01 + x01; 1255* output_ptr. add (start_idx + (k + 3 * big_n / 8 ) * stride) = w11 + x11; 1256* output_ptr. add (start_idx + (k + big_n / 2 ) * stride) = w00 - x00; 1257* output_ptr. add (start_idx + (k + 5 * big_n / 8 ) * stride) = w10 - x10; 1258* output_ptr. add (start_idx + (k + 3 * big_n / 4 ) * stride) = w01 - x01; 1259* output_ptr. add (start_idx + (k + 7 * big_n / 8 ) * stride) = w11 - x11; 1260} 1261} 1262} 1263} 1264 1265fn fft_butterfly_radix_8_s0_unsafe < T : Float + FloatConst >( 1266input : & mut [ Complex < T >], 1267output : & mut [ Complex < T >], 1268) { 1269let stride = input. len () / 8 ; 1270let big_n = 8 ; 1271let input_ptr = input. as_ptr (); 1272let output_ptr = output. as_mut_ptr (); 1273for start_idx in 0 ..stride { 1274for k in 0 ..big_n / 8 { 1275unsafe { 1276// Collect inputs. 1277let i0 = * input_ptr. add (start_idx + 8 * k * stride); 1278let i1 = * input_ptr. add (start_idx + ( 8 * k + 1 ) * stride); 1279let i2 = * input_ptr. add (start_idx + ( 8 * k + 2 ) * stride); 1280let i3 = * input_ptr. add (start_idx + ( 8 * k + 3 ) * stride); 1281let i4 = * input_ptr. add (start_idx + ( 8 * k + 4 ) * stride); 1282let i5 = * input_ptr. add (start_idx + ( 8 * k + 5 ) * stride); 1283let i6 = * input_ptr. add (start_idx + ( 8 * k + 6 ) * stride); 1284let i7 = * input_ptr. add (start_idx + ( 8 * k + 7 ) * stride); 1285 1286let a = i0; 1287let b = i1; 1288let c = i2; 1289let d = i3; 1290let e = i4; 1291let f = i5; 1292let g = i6; 1293let h = i7; 1294 1295let ae_sum = a + e; 1296let ae_diff = a - e; 1297let bf_sum = b + f; 1298let bf_diff = b - f; 1299let cg_sum = c + g; 1300let cg_diff = c - g; 1301let dh_sum = d + h; 1302let dh_diff = d - h; 1303 1304let w00 = ae_sum + cg_sum; 1305let w01 = ae_sum - cg_sum; 1306let w10 = ae_diff + rot_270 (cg_diff); 1307let w11 = ae_diff - rot_270 (cg_diff); 1308let x00 = bf_sum + dh_sum; 1309let x01 = rot_270 (bf_sum) + rot_90 (dh_sum); 1310let x10 = rot_45 ( rot_270 (bf_diff) + rot_180 (dh_diff)); 1311let x11 = rot_45 ( rot_180 (bf_diff) + rot_270 (dh_diff)); 1312 1313* output_ptr. add (start_idx + k * stride) = w00 + x00; 1314* output_ptr. add (start_idx + (k + big_n / 8 ) * stride) = w10 + x10; 1315* output_ptr. add (start_idx + (k + big_n / 4 ) * stride) = w01 + x01; 1316* output_ptr. add (start_idx + (k + 3 * big_n / 8 ) * stride) = w11 + x11; 1317* output_ptr. add (start_idx + (k + big_n / 2 ) * stride) = w00 - x00; 1318* output_ptr. add (start_idx + (k + 5 * big_n / 8 ) * stride) = w10 - x10; 1319* output_ptr. add (start_idx + (k + 3 * big_n / 4 ) * stride) = w01 - x01; 1320* output_ptr. add (start_idx + (k + 7 * big_n / 8 ) * stride) = w11 - x11; 1321} 1322} 1323} 1324} 1325 1326pub fn fft_v7_radix_8 < T : Float + FloatConst >( 1327src : & mut [ Complex < T >], 1328dst : & mut [ Complex < T >], 1329twiddles : & [ Complex < T >], 1330) { 1331assert! ( is_power_of_k (src. len (), 8 )); 1332assert_eq! (src. len (), dst. len ()); 1333assert_eq! (twiddles. len (), src. len ()); 1334let n_iter = log_k_of ::< 8 >(src. len ()); 1335 1336dst. copy_from_slice (src); 1337 1338let ( mut input, mut output) = if n_iter % 2 == 0 { 1339(dst, src) 1340} else { 1341(src, dst) 1342}; 1343let big_n = input. len (); 1344let mut stride = big_n; 1345let mut big_n = 1 ; 1346for stage in 0 ..n_iter { 1347stride /= 8 ; 1348big_n *= 8 ; 1349std::mem:: swap ( & mut input, & mut output); 1350 1351if stage == 0 { 1352fft_butterfly_radix_8_s0_unsafe (input, output); 1353} else { 1354fft_butterfly_radix_8_unsafe (input, output, stride, big_n, twiddles); 1355} 1356} 1357} 1358``` 1359 1360FYI, I started by just writing the naive expressions based on the table at the top 1361of this section. Then I did one level of subexpression elimination, pairing 1362up a with e, b with f, etc. Then I did another level, giving us the final 1363result. Without the common subexpression elimination, this performs worse 1364than the radix-4 kernel! 1365 1366With this in place - we actually *beat* the speed-of-light! 1367 1368| Algorithm | Duration | Max. error | Avg. error | 1369|---------------|---------------|------------|------------| 1370| Naive DFT | 83.513 ms | 0.33024592 | 0.00950057 | 1371| Naive FFT | 1.3469 ms | 0.00018436 | 0.00000708 | 1372| FFT v1 | 1.2813 ms | 0.00009481 | 0.00000410 | 1373| FFT v2 | 39.944 us | 0.00009481 | 0.00000410 | 1374| FFT v3 | 23.626 us | 0.00009481 | 0.00000410 | 1375| FFT v4 | 20.231 us | 0.00009481 | 0.00000396 | 1376| FFT v5 | 16.383 us | 0.00009481 | 0.00000396 | 1377| FFT v6 | 14.830 us | 0.00009481 | 0.00000396 | 1378| **FFT v7** | 13.235 us | 0.00009481 | 0.00000398 | 1379| rustfft | 14.791 us | 0.00009481 | 0.00000397 | 1380 1381Our average-case error has slightly regressed, but honestly I don't care. 1382 1383## Validating other input sizes 1384 1385For my use-case, I only care about FFTs of size 256, 512, 1024, and 4096. Let's 1386check how we perform vs. rustfft: 1387 1388| Input size | Algorithm | Runtime | 1389|------------|---------------|-----------| 1390| 256 | FFT v6 | 587.71 ns | 1391| 256 | rustfft | 620.66 ns | 1392| 512 | FFT v7 | 1.1278 us | 1393| 512 | rustfft | 1.3608 us | 1394| 1024 | FFT v6 | 2.9249 us | 1395| 1024 | rustfft | 3.0233 us | 1396| 4096 | FFT v7 | 13.235 us | 1397| 4096 | rustfft | 14.791 us | 1398 1399Our algorithms mog rustfft at every relevant input size, and are exceptionally 1400simple. Our work here is done. 1401 1402## Closing thoughts 1403 1404These algorithms - v6 and v7 - will not scale well to large inputs (say, above 140516k or so). An in-place algorithm would exhibit far better cache locality and 1406would scale better. I did try that out, but for my input sizes, it wound up 1407costing more than it saves. 1408 1409Additionally, I did not take the time to study mixed-radix solutions. These 1410would be needed to support e.g. size-2048 inputs, or non-power-of-2 inputs. 1411I will probably revisit this later, but today's not that day. My greatest 1412aspiration for this project was to get within a factor of 2 of rustfft's scalar 1413performance with simple code; exceeding it was a very pleasant surprise. 1414 1415## Source code 1416 1417All source code is available [here](https://git.yummers.dev/yum/gpu_fft/). 1418 1419## AI disclosure 1420 1421I used AI to check my code for errors and investigate likely high-value 1422optimizations. All committed code, and all prose and math in this article, was 1423written entirely by me. (Even the typesetting! 😩) 1424 1425[^dft]: Wikipedia. *Discrete Fourier transform.* Accessed 5 Sep 2026. [Webpage](https://en.wikipedia.org/wiki/Discrete_Fourier_transform) 1426[^eulers_formula]: Wikipedia. *Euler's formula.* Accessed 5 Sep 2026. [Webpage](https://en.wikipedia.org/wiki/Euler's_formula) 1427[^rotation_matrix]: Wikipedia. *Rotation matrix.* Accessed 8 Sep 2026. [Webpage](https://en.wikipedia.org/wiki/Rotation_matrix) 1428 1429# fft water: the plan {data-date="7 Aug 2026"} 1430 1431About a year ago I took a stab at reimplementing Tessendorf's ocean 1432water[^tessendorf]. I got some decent results, but the implementation was 1433sloppy and not particularly organized. I also never implemented Bruneton's 1434"geometry to BRDF" method [^bruneton], partially because the implementation was 1435sloppy. 1436 1437I've gotten the itch to take another stab at implementing this water 1438system. This time, I will try harder to proceed along principled, logical 1439steps, and check my work more thoroughly along the way. 1440 1441I've also decided to document this process. I expect it to take a few months to 1442complete this project. Hopefully in the future, these notes might help someone 1443trying to implement some nice deep-ocean water in their engine/game/whatever. 1444 1445I'll implement this renderer as follows (this list is likely to change): 1446 1447* Derive a high-performance GPU-based FFT implementation on CPU. 1448* Check against a simple reference implementation of Cooley-Tukey. 1449* Measure the impact of radix on the numerical precision of the FFT. 1450* Ideally, generate some graphs. 1451* Also look at the impact of float precision - 8-bit, 16-bit, etc. 1452* Implement this FFT algorithm in slang + webGPU. Render unlit. Measure 1453performance. 1454* Implement image export from webGPU harness. Measure error - verify that it 1455matches expectations. 1456* Generate a wave energy spectrum using Horvath's viscous shallow water wave 1457dispersion relation. 1458* Generate one frame of wave displacement using slang + webGPU. 1459* Validate feature scale, energy, etc. You probably want some histograms. 1460* Generate one frame of analytic normals using slang + webGPU. 1461* Validate using finite differences of the heightmap as an approximation of 1462ground truth. The two images should match within some small epsilon. 1463* Generate chop and chop normals. 1464* Validate feature size and normals using finite differences (again). 1465* Implement stdev (per geometry-to-brdf paper). 1466* (Note to self: this is a static image based on the energy spectrum. We 1467calculate the ddx/ddy of the offset [meters/px], then divide 2 \* pi by 1468that number to get a wave number. That is then used as the index to the 1469LUT. The LUT contains, for each wave number, the sum of the variances of 1470all waves with higher or equal wave numbers.) 1471* Render a simple scene in webGPU and in Mitsuba 3. 1472* Implement a simple brdf. 1473* Implement frame export. 1474* Implement image diffing / measurement. 1475* Implement hard shadows. 1476* Implement soft shadows. 1477* Validate point lighting. 1478* Validate directional lighting. 1479* Implement and validate IBL. 1480* Implement and validate DFG LUT (energy-preserving roughness). 1481* Implement vertex deformation and normals using baked heightmap & tangents. 1482Validate against Mitsuba. 1483* Make a new scene with a highly subdivided quad. 1484* Port to Unity. 1485* Implement tooling to blit a texture through a RenderTexture using a shader. 1486* Automation should generate quads, materials, and rendertextures on behalf 1487of the user. 1488* Port compute shader to shaderlab pixel shader. Validate. 1489* Port lit shader to shaderlab. 1490* Sample scene, frame export, exhaustive validation... the works. 1491* Validate point, directional, and IBL. 1492* Add light volumes. 1493* Add LTCGI. 1494 1495So... yeah. A lot of work. I'll get started tomorrow! 1496 1497--- 1498 1499[^tessendorf]: Tessendorf, Jerry. *Simulating Ocean Water*. 2004. [PDF](https://people.computing.clemson.edu/~jtessen/reports/papers_files/coursenotes2004.pdf). 1500[^bruneton]: Bruneton, Eric et. al. *Real-time Realistic Ocean Lighting using Seamless Transitions from Geometry to BRDF*. 2010. [PDF](https://inria.hal.science/inria-00443630/PDF/article-1.pdf). 1501 1502# how do you evenly tile a column? {data-date="12 Jul 2026"} 1503 1504While walking through town the other day, I saw a pillar that looks a bit like this: 1505 1506 1507 1508In other words, it was a circular vertical column decorated with flat 1509tiles. I got to thinking: how do you make such a column? I would probably 1510make a cylindrical base, then stick the tiles to it. But how would I know 1511how big each tile should be so that they exactly divide the circumference 1512of the pillar? 1513 1514The problem is that, since the column is circular, and the tiles are 1515straight, you can't just divide the circumference of the pillar by the 1516number of tiles. Each tile creates a tiny gap vs. the cylindrical pillar, 1517and those gaps would add up over the circumference of the pillar. Your 1518tiles wouldn't exactly meet up when you get back to where you started! 1519 1520 1521 1522There should be a simple, mathematical relation between the circumference 1523of the pillar, and the perimeter of the regular polygon with $n$ 1524vertices which circumscribes it. 1525 1526Let's draw a couple pictures. To keep things easy to visualize, we'll look 1527at a case where $n = 3$, but we'll keep our math generalizable to any $n$. 1528 1529 1531 1532Our circle has radius $r$, and the circumscribing polygon has edge length $e$. 1533 1534Our task is to come up with some relationship between $r$ and $e$. (Or more 1535precisely, an expression for $\frac{n e}{2 \pi r}$ solely in terms of $n$.) 1536 1537Zooming in on the bottom-right corner of our circle, we can define a few more 1538interesting quantities: 1539 1540 1541 1542We define: 1543 1544- $\sigma$: the central angle of the polygon. 1545- $h$: the height of the intersection point over the horizontal base of the 1546polygon. 1547- $\theta$: the interior angle of the polygon. 1548 1549Finally, if we focus on the region outlined by $r$, $h$ and the bottom of 1550the polygon: 1551 1552 1553 1554We define one final quantity, $\phi$, the interior angle of the right 1555triangle formed by $h-r$ and $r$. 1556 1557Here is a summary of the quantities defined so far: 1558 1559$$ 1560\begin{align*} 1561r & && \text{Inscribed circle radius.}\\ 1562n & && \text{Number of vertices in circumscribing polygon.}\\ 1563e & && \text{Edge length of circumscribing polygon.}\\ 1564h & && \text{Height of next intersection point with respect to previous edge.}\\ 1565\sigma & && \text{Central angle of circumscribing polygon.}\\ 1566\theta & && \text{Interior angle of circumscribing polygon.}\\ 1567\end{align*} 1568$$ 1569 1570Let's start defining these quantities in terms of each other - preferably 1571exclusively in terms of $n$ where possible. 1572 1573$$ 1574\begin{align*} 1575\theta &= \frac{\pi (n-2)}{n} && \text{Interior angle of a regular polygon.}\\ 1576\sigma &= \frac{2 \pi}{n} && \text{Central angle.} \\ 1577\phi &= \sigma - \frac{\pi}{2} && \text{Follows from figure 3.} \\ 1578\sin{\theta} &= \frac{2h}{e} && \text{Figure 3, definition of sine.} \\ 1579h &= \frac{e}{2} \sin{\theta} && \text{Rearrange previous equation.} \\ 1580\sin{\phi} &= \frac{h -r}{r} && \text{Figure 3, definition of sine.} \\ 1581\sin{\phi} &= \frac{h}{r} - 1 && \text{Simplify previous equation.} \\ 1582h &= r(\sin{\phi} + 1) && \text{Rearrange previous equation.} \\ 1583\frac{e}{2} \sin{\theta} &= r(\sin{\phi} + 1) && \text{Set } h \text{ equations equal 1584to each other.} \\ 1585\frac{e}{r} &= 2 \frac{\sin{\phi} + 1}{\sin{\theta}} && \text{Rearrange terms.} 1586\end{align*} 1587$$ 1588 1589We have come up with an expression relating $e$ and $r$ but it's far from the 1590elegant solution we were searching for. Here is where I chucked it into 1591wolframalpha and got a nice solution, then asked a clanker to derive it for me. 1592The simplification process is: 1593 1594$$ 1595\begin{align*} 1596\frac{e}{r} &= 2 \frac{\sin{(\frac{2 \pi}{n} - \frac{\pi}{2})} + 1}{\sin{\frac{\pi (n-2)}{n}}} && \text{Plug in definitions of } \phi \text{ and } \theta \text{.} \\ 1597&= 2 \frac{1 - \cos{\frac{2\pi}{n}}}{\dots} && \text{In general, } \sin{(x-\frac{\pi}{2})} = -\cos{x} \\ 1598&= 2 \frac{2 \sin^2{\frac{\pi}{n}}}{\dots} && \text{Double angle formula.} \\ 1599&= 2 \frac{\dots}{\sin{(\pi - \frac{2 \pi}{n})}} && \text{Simplify.} \\ 1600&= 2 \frac{\dots}{\sin{\frac{2\pi}{n}}} && \text{In general, } \sin{(\pi-x)} = \sin{x} \\ 1601&= 2 \frac{\dots}{2 \sin{\frac{\pi}{n}} \cos{\frac{\pi}{n}}} && \text{Double angle formula.} \\ 1602&= 2 \frac{2 \sin^2{\frac{\pi}{n}}}{2 \sin{\frac{\pi}{n}} \cos{\frac{\pi}{n}}} && \text{Write explicitly.} \\ 1603&= 2 \frac{\sin{\frac{\pi}{n}}}{\cos{\frac{\pi}{n}}} && \text{Cancel terms.} \\ 1604&= 2 \tan{\frac{\pi}{n}} && \text{Definition of tangent.} 1605\end{align*} 1606$$ 1607 1608We're in the final stretch! 1609 1610Let $P = e \cdot n$, $C = 2 \pi r$. Then: 1611 1612$$ 1613\begin{align*} 1614\frac{P}{C} &= \frac{e \cdot n}{2 \pi r} && \text{Plug in definitions.} \\ 1615&= \frac{n}{2 \pi} \frac{e}{r} && \text{Group terms.} \\ 1616&= \frac{n}{2 \pi} 2 \tan{\frac{\pi}{n}} && \text{Plug in equation from before.} \\ 1617&= \frac{n}{\pi} \tan{\frac{\pi}{n}} && \text{Simplify.} \quad \square 1618\end{align*} 1619$$ 1620 1621This represents the ratio of these two shapes' circumferences, so we expect that at the limit of n, it should be 1. Therefore we subtract 1 to get an error function. This is the graph of $P/C-1$: 1622 1623 1624 1625As expected, the error starts out very large with few tiles, then quickly drops 1626towards 0 (the ratio converging to 1). 1627 1628Our column-builders are more interested in the error with respect to the length of a tile. 1629To illustrate the point: $P/C-1$ tends towards 0, but so does the length of our 1630tiles. Which one converges faster, and by how much? 1631 1632To get the error per tile, we use the formula $(P/C - 1) \cdot n$. 1633(Intuitively: each tile is small, so the amount of error it sees is inversely 1634proportional to its size $\frac{1}{n}$). 1635 1636> *TODO: I think that this measure of relative error is wrong.* 1637 1638 1639 1640Here are the values of $P/C-1$ and $(P/C-1) \cdot n$ for up to 30 tiles: 1641 1642|# of tiles | P/C-1 | (P/C-1)*n | 1643|------------|-----|----------| 1644|3 |0.653986686 |1.961960059| 1645|4 |0.273239545 |1.092958179| 1646|5 |0.156328347 |0.7816417349| 1647|6 |0.102657791 |0.6159467451| 1648|7 |0.073029735 |0.511208143| 1649|8 |0.054786175 |0.4382894013| 1650|9 |0.042697915 |0.3842812313| 1651|10 |0.034251515 |0.3425151527| 1652|11 |0.028106371 |0.3091700813| 1653|12 |0.023490523 |0.2818862802| 1654|13 |0.019932427 |0.2591215493| 1655|14 |0.017130161 |0.2398222536| 1656|15 |0.014882824 |0.2232423644| 1657|16 |0.013052368 |0.2088378934| 1658|17 |0.011541311 |0.1962022837| 1659|18 |0.010279181 |0.185025256| 1660|19 |0.009213984 |0.1750656961| 1661|20 |0.008306663 |0.1661332692| 1662|21 |0.007527411 |0.1580756349| 1663|22 |0.006853153 |0.1507693603| 1664|23 |0.006265797 |0.1441133352| 1665|24 |0.005750997 |0.1380239172| 1666|25 |0.005297252 |0.1324312968| 1667|26 |0.004895259 |0.1272767379| 1668|27 |0.004537424 |0.1225104564| 1669|28 |0.004217499 |0.1180899697| 1670|29 |0.003930303 |0.1139788006| 1671|30 |0.003671515 |0.1101454484| 1672 1673As we can see, the ratio of $P/C$ quickly drops below 1% (taking only 19 tiles) 1674but even with 30 tiles the per-tile error still doesn't drops below 10%. 1675Therefore in real-world conditions, you actually need to account for this 1676source of error, or live with a narrower-than-intended tile on your column. 1677 1678Finally, let's address our problem statement directly. I have a column of 1679radius $r$, and I want to wrap it with $n$ tiles. What should the edge length 1680$e$ of each tile be so that the tiles wrap the column exactly? 1681 1682Rearranging an equation given above: 1683 1684$$ 1685e = 2r \tan{\frac{\pi}{n}} 1686$$ 1687 1688# histogram-preserving tri-planar projection {data-date="31 March 2026"} 1689 1690I've been messing around with Burley's "On Histogram-Preserving Blending for 1691Randomized Texture Tiling" ([link](https://jcgt.org/published/0008/04/02/)) for 1692a couple days. The core idea is to pre-process images into a "Gaussianized" 1693form where the histogram of the image's colors follows a Gaussian distribution. 1694Once in Gaussian form, there is a closed-form way to blend multiple samples with 1695barycentric weights such that the Gaussian's variance is preserved (Equation 2 1696in the paper). Finally, you can run the blended colors through a lookup table 1697(LUT) to get a result in the original image's color space. The results are 1698outstanding. (These ideas build on those laid out by Heitz and 1699Neyret in an earlier paper. I will reference Heitz a few times.) 1700 1701 1703 1704It was love at first sight - you can use this to seamlessly tile large areas 1705with textures that themselves don't even need to be seamless. However, the 1706method uses 4 taps per pixel (3 overlapping hexagons per pixel, plus 1 3D 1707lookup table tap). 1708 1709I've been thinking about terrains for a week or two, since I need to make a 1710large-scale environment for a project. I really like the idea of using 1711tri-planar projection for grass, stone etc., but I've never been satisfied with 1712the quality I get from it. It always creates this awful loss of contrast 1713between layers and creates weird ghosting artifacts. 1714 1715Wait a minute, isn't that kind of what Heitz's technique addresses? 1716 1717It turns out that yeah, you can use the exact same machinery described by Heitz 1718and Burley to perform histogram-preserving tri-planar projection. 1719You just use standard tri-planar projection to get barycentric 1720coordinates instead of playing with a UV-space triangle grid. Results are shown 1721below. 1722 1723 1725 1726I also noticed that the gamma term described in Burley's Equation 5 can 1727significantly reduce contrast. At low values, where ghosting is more visible, 1728contrast is better preserved; at high values, it's more diminished. 1729 1730 1731 1732 1733Perhaps blending in YCbCr would ameliorate the loss in contrast, but I haven't 1734tried that yet. 1735 1736The astute reader might find that just increasing contrast after the blend 1737would produce a similar result, and I'm inclined to agree. The only possible 1738advantage that this method has is that it doesn't demand fine-tuning. 1739 1740# using linux as a desktop os in 2026 {data-date="9 Feb 2026"} 1741 1742About a month ago, my PC's boot drive died. I had been running Windows 11 with 1743moderate dissatisfaction for a few months, so I decided to switch over to 1744Linux as my primary OS. These are some notes on that process. My 1745motivation is to give an accurate portrayal of what to expect out of the 1746switching process and the day-to-day operation. 1747 1748TLDR: The Linux desktop is *way* better in 2026 than it was in 2016. Native app 1749support is far more common, and Proton is really good. If dual booting was not 1750still necessary for VR, I would wholeheartedly recommend it. 1751 1752## Dual boot setup 1753 1754I knew immediately that I'd be dual booting. My memory told me that some apps 1755just would not work well, and the virtualization tax is high, so I'd want a 1756native Windows install. So I made my first mistake: I installed Linux, *then* 1757Windows. The opposite order is far more streamlined. So I just overwrote my 1758install with Win11. I left half my drive as unallocated space for the Linux 1759install. 1760 1761After rebooting normally to make sure Windows was really working, it was time 1762to install Linux. My new install would not let me get into BIOS - my keyboard 1763inputs did not work. There are one-time flags you can set via shell (PowerShell 1764and BASH) to do various boot-related tasks without keyboard input. To get into 1765BIOS: 1766 1767* PowerShell: `shutdown /r /fw /t 0` 1768* bash: `sudo systemctl reboot --firmware-setup` 1769 1770My keyboard did work once in the BIOS, so I was then able to enter my Linux 1771bootable USB. I had some trouble getting the bootable USB to work. I had to 1772install the media via Rufus's `dd` mode instead of the default. 1773 1774I installed my distro as normal in the unallocated space, then rebooted. GRUB 1775showed up, showing my Linux install as default and the Windows boot manager 1776below. Somewhat unsurprisingly, my keyboard didn't work in GRUB. I heard that 1777disabling fast boot and 1778[xhci](https://en.wikipedia.org/wiki/Extensible_Host_Controller_Interface) 1779handoff in the BIOS can help, but this only temporarily helped before the issue 1780resurfaced and then resolved itself. My current config has fast boot off and 1781xhci handoff off. 1782 1783My solution to the pre-BIOS/GRUB keyboard issue is just to use shell commands 1784to reboot. To get from Windows to Linux, I just reboot as normal since Linux 1785has prio by default in my install. To go from Linux to Windows, I installed 1786`efibootmgr`, ran it to get the numeric ID of the Windows boot manager (0000), 1787then crafted this one liner: 1788 1789* bash: `sudo efibootmgr --bootnext 0000 && reboot` 1790 1791I use ctrl+R to find it every time I need to reboot. 1792 1793> Sidebar: this method does not play nicely with Windows updates. Since 1794> Windows needs to reboot 19 times to do anything, and each reboot takes you 1795> into Linux, you'll be stuck booting back into Windows manually. Next time 1796> Windows demands an update, I'll probably just unplug my PC from the wall. 1797 1798## Linux setup 1799 1800I'm using the Ubuntu 2024 LTS as my distro. My first point of confusion getting 1801started was the apparent surfeit of package managers: apt (the standard), 1802snap (canonical's thing), and flatpak (some semi popular community thing). 1803snap and flatpak are sandboxed by default, which is really just a massive 1804fucking pain in the ass for GUI apps. So I use apt wherever possible, and raw 1805.deb files for the rest. 1806 1807### Audio 1808 1809Audio's a little scuffed, but seems like we've mostly gotten on the pulse audio 1810train (thank God). 1811 1812For whatever reason, my motherboard's audio output sets itself to 39% volume. I 1813have to use `alsamixer` to increase this to 100%. 1814 1815I used `pavucontrol` to disable irrelevant speakers and mics s.a. monitor speakers. 1816 1817### Firefox 1818 1819Firefox comes pre-installed on Ubuntu. Firefox is slowly going the way of 1820Windows, but [Just The Browser](https://justthebrowser.com/) has some easy one 1821liners to de-shittify it. Waterfox is also interesting, but I haven't tried it 1822yet. 1823 1824### Discord 1825 1826I used the raw .deb to install Discord. It will ask you to manually update 1827every few days. I wrote this shell script to speed that up: 1828 1829``` bash 1830#!/usr/bin/env bash 1831# updisc: update discord 1832 1833set -o errexit 1834set -o xtrace 1835 1836cd $ HOME /Downloads 1837wget --content-disposition "https://discord.com/api/download/stable?platform=linux&format=deb" 1838latest= $( ls -v | grep discord | tail -n1 ) 1839sudo dpkg -i " $ latest " 1840``` 1841 1842### Spotify 1843 1844The snap works fine for this. Installed it through the App Center (Canonical's 1845app store). 1846 1847(Preachy note: Spotify kind of sucks. Avoid using their auto generated 1848playlists. Spotify has something called the Perfect Fit Program which 1849commissions and pushes music to listeners based on non-public preference data. 1850Artists involved in this program are not well compensated. Read about it in Liz 1851Pelly's 1852[expose](https://harpers.org/archive/2025/01/the-ghosts-in-the-machine-liz-pelly-spotify-musicians/).) 1853 1854### Steam 1855 1856I use the raw .deb to install Steam. I tried the flatpak at first, but the 1857sandboxing doesn't play nicely with proton. Steam will keep itself up to date 1858so the raw .deb is fine. 1859 1860### Games 1861 1862I had some issues with graphics drivers in certain games and had to roll back 1863my driver from 590 to 570. You can list your driver with `nvidia-smi`, and 1864install some other version (e.g. 570) with `sudo apt install 1865nvidia-driver-570`. It will ask for a password - this only has to be entered 1866once after reboot, after which the driver will be trusted forever. 1867 1868If your game uses Easy Anti Cheat and Proton, you'll need to install the Proton 1869EasyAntiCheat runtime. It should be listed in your library by default. 1870 1871Proton has a heavy FPS hit vs. Windows native (like 30%), but I'm not a 1872competitive gamer and my computer is very over-built, so I don't care. 1873 1874### Blender 1875 1876I downloaded the LTS .tar.xz from the website and put it in my bin directory. I 1877think it's probably smarter to use Steam for this. Do *not* use the snap 1878version - it won't let you install addons from the web. 1879 1880If you use an NDOF input device like a spacemouse, install 1881[spacenavd](https://github.com/FreeSpacenav/spacenavd) via apt. You might have 1882to relaunch blender. 1883 1884Otherwise, pretty much identical experience. 1885 1886### Unity 1887 1888Superficially, Unity basically just works. Install the hub using the official 1889Unity3D [documentation](https://docs.unity3d.com/hub/manual/InstallHub.html#install-hub-linux). 1890 1891My problems with Unity so far are: 1892 1893* Slow shader compile times. 1894* Unity uses OpenGL by default on Linux, and Vulkan is very crashy in my 1895experience. 1896* OpenGL uses a different depth buffer format than DX11/DX12, making it hard 1897to develop for that platform on the OpenGL version. 1898* GPU profiler doesn't work out of the box, showing 1 ms for every frame. 1899* Weird permissions issues if you just mount a project created in Windows. Had 1900to copy it over. 1901* Have to delete Library/ if the project was created/used on Windows. 1902(Shouldn't be a big deal. Just slows down first time startup.) 1903* Slow scrolling performance in Inspector pane 1904* Dragging sliders in game mode is not smooth, like it is in Windows 1905 1906You can use ALCOM/vrc-get to create VRChat projects. Get it [from 1907github](https://github.com/vrc-get/vrc-get). 1908 1909### Adobe 1910 1911I've already been on Krita (and GIMP before that), which natively supports 1912Linux. No problems there. 1913 1914Substance painter is a massive issue. Adobe claims to have a native Ubuntu build, 1915and even sell it through Steam. However, it simply did not launch on my 1916system. It was missing half a dozen shared object files (.so), and after 1917manually fixing that it still fails to launch. 1918 1919Thankfully the fuckwits at Adobe couldn't be bothered to strip their binary: 1920 1921``` 1922$ file ./Adobe\ Substance\ 3D\ Painter 1923./Adobe Substance 3D Painter: ELF 64-bit LSB pie executable, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=cf49a257fa3bcf0f40d860a8a45a67c873571421, with debug_info, not stripped 1924$ $ du -h ./Adobe\ Substance\ 3D\ Painter 1925305M ./Adobe Substance 3D Painter 1926``` 1927 1928So the next time I have a free afternoon I'll be looking through that. 1929 1930I did try ArmorPaint, but it is very clearly still quite early in development. 1931I do not think that it's a viable alternative to Substance Painter yet. (For 1932example: you cannot drag and drop in textures, nor can you import more than one 1933at a time. Functional, sure, but barely.) To its credit: unlike Substance 1934Painter, it actually launches. 1935 1936## Pleasant surprises 1937 1938Linux is way, way more polished than I remember it. Back in college I was 1939fucking around with Arch (and didn't know what I was doing) so I was expecting 1940a far more painful setup process. Instead it was very seamless. Audio works 1941well. There are very few weird audio/graphical bugs. NVIDIA drivers are easy to 1942install. Gaming basically just works - I have yet to encounter a game which I 1943can't play (I've only tried maybe a dozen). 1944 1945The amount of native app support is really heartwarming. Skipping over the big 1946apps like Firefox and Steam, here are some smaller apps I was surprised to see 1947native support for: 1948 1949* OBS (video recording/streaming tool) 1950* Factorio (indie game) 1951* r2modman (mod manager) 1952* Chatterino (twitch chat app) 1953* PureRef (artist reference app) 1954* Lorien (infinite canvas drawing app) 1955 1956## Complaints 1957 1958Canonical uses the "yes/maybe later" pattern which degrades the notion of 1959consent. Nearly every large firm does it now since Google about-faced a couple 1960years ago, but it is still a grave degradation of user rights that shouldn't be 1961glossed over. 1962 1963SteamVR does not work for me. My knuckles did not have an internally 1964consistent coordinate system, preventing me from completing room 1965calibration. 1966I was never able to figure this out. This~~, along with Unity's crashy behavior 1967on Vulkan,~~* is what's keeping me from just deleting Windows. 1968 1969\* I've actually been able to use the OpenGL build to do graphical programming 1970without issue for a few weeks now. So, not an issue. 1971 1972## Conclusions 1973 1974The Linux desktop is really good. You should give it a try. 1975 1976# hemi-octahedral impostors {data-date="14 Jan 2026"} 1977 1978*Note: this blog post is only like half way complete. I may or may not circle 1979back to it. The stuff on octahedral mappings is all finished, but the 1980impostor application below is not.* 1981 1982Ryan Brucks published [an 1983article](https://shaderbits.com/blog/octahedral-impostors) describing 1984"octahedral impostors" in 2018. The basic idea is to to take photos of some 1985subject at octahedral lattice points, record them to an atlas, then reconstruct 1986those photos in a particle. 1987 1988 1990 1991## But why octahedrons? 1992 1993The octahedral mapping is simply one way to convert between a flat coordinate 1994system and a spherical coordinate system. It is notable because it does not use 1995any trig functions, making it suitable for use in realtime graphics. 1996 1997This is what an octahedron looks like: 1998 1999 2001 2002It is a polyhedron with 8 triangular faces and 6 vertices. The equator is a 2003square. 2004 2005Let's work out how we'd convert this octahedron to a plane. 2006First, we project the upper hemisphere onto the xz plane: 2007 2008 2010 2011Next, we effectively need to "rotate" the triangles in the lower half around 2012those diagonal edges. We can cheat by first *reflecting* the bottom vertex of each 2013triangle about its diagonal edge: 2014 2015 2016 2017Finally, we can just project those points in the lower hemisphere onto the xz 2018plane: 2019 2020 2021 2022Viewed head on, we can see a very beautifully symmetric unwrapping: 2023 2024 2025 2026Note that we never actually did any rotations, so there no trig! Here's the 2027same procedure in code: 2028 2029``` c 2030// Convert unit octahedron to a [-1,1] x [-1,1] patch on xz plane. 2031float3 octahedron_to_plane ( float3 p ) { 2032if ( p . y >= 0 ) { 2033// Project upper hemisphere onto xz plane. 2034p . y = 0 ; 2035return p ; 2036} 2037// First, reflect the lower hemisphere's points about their diagonal. 2038p . x = sign ( p . x ) * ( 1 - abs ( p . x )); 2039p . z = sign ( p . z ) * ( 1 - abs ( p . z )); 2040// Then project onto the xz plane. 2041p . y = 0 ; 2042return p ; 2043} 2044``` 2045 2046We can generalize this procedure to unwrap *any* spherical object by just 2047switching norms: 2048 2049``` c 2050// Convert unit sphere to a [-1,1] x [-1,1] patch on xz plane. 2051float3 octahedron_to_plane ( float3 p ) { 2052// Switch from L2 to L1 norm. This basically bends a sphere to an octahedron. 2053float l1_norm = abs ( p . x ) + abs ( p . y ) + abs ( p . z ); 2054p /= l1_norm ; 2055// Then unwrap. 2056if ( p . y < 0 ) { 2057p . x = sign ( p . x ) * ( 1 - abs ( p . x )); 2058p . z = sign ( p . z ) * ( 1 - abs ( p . z )); 2059} 2060p . y = 0 ; 2061return p ; 2062} 2063``` 2064 2065Here's a quick demo showing what that norm conversion does to a unit sphere: 2066 2067 2068 2069Going from plane to octahedron is just the same thing backwards: 2070 2071``` c 2072// Convert a [-1,1] x [-1,1] patch on xz plane to a unit sphere. 2073float3 plane_to_octahedron ( float3 p ) { 2074float l1_norm = abs ( p . x ) + abs ( p . z ); 2075if ( l1_norm > 1 ) { 2076// Reflect lower hemisphere's point about their diagonal. 2077p . x = sign ( p . x ) * ( 1 - abs ( p . x )); 2078p . z = sign ( p . z ) * ( 1 - abs ( p . z )); 2079} 2080p . y = 1 - l1_norm ; 2081return normalize ( p ); 2082} 2083``` 2084 2085If you'd like more discussion on this topic, I recommend the spherical geometry section in [the PBR book](https://www.pbr-book.org/4ed/Geometry_and_Transformations/Spherical_Geometry#x3-OctahedralEncoding).) 2086 2087## The hemi octahedron 2088 2089We might only want to map the upper hemisphere to a plane. In that case, we can 2090first note that in the standard octahedral mapping, the inner diamond of the 2091[-1,1] x [-1,1] square gets mapped to the upper hemisphere. So all we have to 2092do is first remap our input to that diamond via a scale and 45 degree rotation, map it, 2093then rotate it back. The code is still very simple: 2094 2095``` c 2096// Convert unit sphere to a [-1,1] x [-1,1] patch on xz plane. 2097float3 hemi_octahedron_to_plane ( float3 p ) { 2098// Rotate 45° and scale to fit square into diamond 2099float x_rot = ( p . x + p . z ) * 0.5 ; 2100float z_rot = ( p . z - p . x ) * 0.5 ; 2101p . x = x_rot ; 2102p . z = z_rot ; 2103 2104float l1_norm = abs ( p . x ) + abs ( p . y ) + abs ( p . z ); 2105p /= l1_norm ; 2106if ( p . y < 0 ) { 2107p . x = sign ( p . x ) * ( 1 - abs ( p . x )); 2108p . z = sign ( p . z ) * ( 1 - abs ( p . z )); 2109} 2110p . y = 0 ; 2111 2112// Rotate back. 2113x_rot = p . x - p . z ; 2114z_rot = p . x + p . z ; 2115p . x = x_rot ; 2116p . z = z_rot ; 2117 2118return p ; 2119} 2120``` 2121 2122Here is that transform, visualized: 2123 2124 2125 2126If we didn't do that scale and rotate, this is what it would look like: 2127 2128 2129 2130I will leave the plane -> hemi-octahedron code as an exercise for the reader. 2131 2132## Impostor v1 2133 2134With this mapping, we can write some code to spawn cameras at the lattice 2135points of an octahedral-mapped hemisphere, pointing in at some target object, 2136and generate an atlas of images taken at different angles: 2137 2138 2139 2140 2141 2142We can then write a naive particle shader which computes its nearest lattice 2143point and simply renders that image. 2144 2145We can simply compute the direction from the camera to the particle's center, 2146map that to 2D using the hemi-octahedral mapping, then find the nearest lattice 2147point by rounding. We can also rotate the particle to the same orientation that 2148the photo was taken at to avoid any weird behavior when viewed top down. 2149 2150That looks like this: 2151 2152 2153 2154The popping is pretty awful! Can we do better? 2155 2156## Impostor v2 2157 2158Brucks describes a "virtual frame projection" method. I'll let him explain it: 2159 2160> Looking back to the 'virtual grid mesh' above, we can see that for any triangle on the grid, it has 3 vertices. So if we want to blend smoothly across this grid, we need to be able to identify the 3 nearest frames. And remember how using a sprite caused messed up projection of just one frame? Well it turns out the same thing happens when you try to reuse the projection from one frame for another! This is a pain. So you actually have to render a virtual frame projection for the other 2 frames to simulate their geometry. While using the mesh UVs for one projection and 'solving' the other two does work, it falls apart for lower (~8x8) frame counts because the angular difference can be so great between cards that you see the card start to clip at grazing angles (not shown in any videos yet). As a compromise, the shader does not use ANY UVs right now. It solves all 3 frames using virtual frame projection in the vertex shader and then uses a traditional sprite vertex shader. The only downside is at close distances you occasionally see some minor clipping on the edge but it is much more acceptable this way. 2161 2162Lost? Me too! I found this paragraph extremely confusing - it's what motivated 2163me to write this article. 2164 2165As near as I can tell, what he's describing is that you retrieve 2166the nearest 3 lattice points and do a barycentric interpolation. He's also 2167trying to clarify that you can't just use the uvs from one lattice point to 2168sample another - you have to calculate each lattice point's uvs separately. 2169(I suppose that that level of optimization-first thinking is required when 2170you're building for Fortnite!) 2171 2172You then render the blended color that on a standard facing quad primitive. 2173 2174To start, I calculate the ray from the camera to the origin of the particle's 2175coordinate system. I use that position for my barycentric interpolation. 2176 2177That looks like this: 2178 2179 2180 2181Huh. Looks a lot worse than his demo. What are we doing wrong? 2182 2183Could it just be our choice of mesh that makes our results look bad? Here's Suzanne: 2184 2185 2186 2187Maybe it looks a little better? 2188 2189The mesh that Brucks shows off in his blog post has radial symmetry and smooth 2190normals, which might be responsible. 2191 2192You can also see some artifacts appearing in open space. This was caused by a 2193couple things: 2194 21951. The other mesh was toggled on when I generated my impostor atlas. 21962. The bounding sphere around my mesh had very little padding. 21973. The particle can rotate, and if you don't clip the parts outside the 2198impostor's bounding sphere, you can wind up rendering them. 2199 22003 is crucial - with that correction in place, you can pack your atlas 2201pretty tightly. Here's Suzanne with that correction in place: 2202 2203 2204 2205Here's the atlas. Pretty tight packing - could probably be optimized a little 2206further though: 2207 2208 2209 2210## Impostor v3 2211 2212After stepping away for a bath, the issue occurred to me. 2213 2214I was calculating the lattice point based on the direction from the camera to 2215the particle center. With barycentric interpolation in place, we would be 2216better off using a per-pixel ray intersection with the impostor's bounding 2217sphere. Concretely: we want to sample the lattice points whose cameras have a 2218direction most closely matching the standard view direction. This is found by 2219simply going from the particle's bounding sphere origin to the surface along 2220`-viewDir`, projecting that to 2d, then rounding to lattice points as normal. 2221 2222This seems to help a bit, but it's not night and day. I didn't capture any 2223videos here, but the next gen uses this tech. 2224 2225## Impostor v4 2226 2227So far we've only been rendering pre-lit images of our subject on an unlit 2228particle. Can we do better? What if we captured the albedo, normal, metallic 2229gloss, and position, then lit it with a standard surface shader? 2230 2231The results look a bit better - specular is much better approximated now: 2232 2233 2234 2235## Impostor v5 2236 2237I continued to spin my wheels for a couple days. I re-read Brucks' article 2238several more times, and came to a couple conclusions: 2239 22401. He is using the camera-origin ray, not a per pixel view direction ray. 22412. He is doing some form of parallax occlusion mapping to limit popping. 2242 2243I found his description on 2244[this video](https://www.youtube.com/watch?v=6rsXe6kKTC4) useful: 2245 2246> This version blends the three nearest frames using a single parallax offset (similar to a bump offset). This is the version of impostors used in FNBR on PC and Consoles. It was used on mobile originally but switched back to single frame at last minute since we were compositing them into HLODs and thus rendering lots of them. 2247 2248That single parallax offset is explained by this image: 2249 2250 2252 2253My buggy implementation looks promising - see how the eyes are much sharper 2254now? 2255 2256 2257 2258It is still very, very poppy, unlike Brucks' demo. I must be doing something 2259wrong. 2260 2261*Note: I stepped away from this project and don't plan to revisit it soon. If I 2262do, I'll post updates in a followup and link to it from here.* 2263 2264# 6 wave dispersion relations with derivatives {data-date="21 Sep 2025"} 2265 2266Tessendorf's 2005 paper 2267"[Simulating Ocean Water](https://people.computing.clemson.edu/~jtessen/reports/papers_files/coursenotes2004.pdf)" 2268describes three basic dispersion relations: 2269 22701. The deep water dispersion relation: 2271 2272$$ 2273\omega^2 = gk 2274$$ 2275 2276where $\omega$ is the wave's temporal frequency in $\text{rad}/s$, $g$ is gravity 2277in $m/s^2$, and $k$ is the spatial frequency in $m/s$. 2278 22792. The shallow water dispersion relation: 2280 2281$$ 2282\omega^2 = gk \tanh kh 2283$$ 2284 2285where $h$ is the water mean depth in $m$. 2286 22873. The deep water relation with viscosity correction: 2288 2289$$ 2290\omega^2 = gk (1 + k^2 L^2) 2291$$ 2292 2293where $L$ is the scale in $m$ at which the viscosity term operates. At 0, 2294it has no effect. 2295 2296Horvath's 2015 paper 2297"[Empirical directional wave spectra for computer graphics](https://dl.acm.org/doi/10.1145/2791261.2791267)" 2298formulates the viscosity term in terms of different physical units, and applies 2299it to the shallow water dispersion relation: 2300 2301$$ 2302\omega^2 = (gk + \frac{\sigma}{\rho} k^3) \tanh kh 2303$$ 2304 2305where $\sigma$ is the surface tension in $N/m$, and $\rho$ is the water density 2306in $kg/m^3$. 2307 2308It is useful to have derivatives of the dispersion relation. Horvath's paper 2309describes how we can calculate the spectrum term $S(k_x, k_y)$ from 2310$S(\omega, \theta)$ and the derivative of the dispersion relation 2311$\frac{\partial \omega}{\partial k}$: 2312 2313$$ 2314S(k_x, k_y) = S(\omega, \theta) \frac{\partial \omega}{\partial k} / k 2315$$ 2316 2317So, with that motivation, we would like the derivatives of our dispersion 2318relations. You should autodifferentiate if that's an option. If not, here are 2319derivations of each derivative: 2320 2321 23221. Deep water: 2323 2324$$ 2325\begin{align*} 2326\omega^2 &= gk \\ 2327\omega &= (gk)^\frac{1}{2} \\ 2328\frac{\partial \omega}{\partial k} &= \frac{1}{2} (gk)^{-\frac{1}{2}} g \\ 2329&= \frac{g}{2\sqrt{gk}} \\ 2330&= \frac{1}{2} \sqrt{\frac{g}{k}} 2331\end{align*} 2332$$ 2333 2334Wolfram [here](https://www.wolframalpha.com/input?i=d%2Fdk+%28%28gk%29%5E%281%2F2%29%29). 2335 23362. Shallow water: 2337 2338First we will need $\frac{\partial}{\partial k} \tanh kh$: 2339 2340$$ 2341\begin{align*} 2342\frac{\partial}{\partial k} \tanh kh 2343&= \frac{\partial}{\partial k} [\frac{e^{kh} - e^{-kh}}{e^{kh}+e^{-kh}}] \\ 2344&= \frac{\partial}{\partial k} [(e^{kh} - e^{-kh})(e^{kh}+e^{-kh})^{-1}] \\ 2345&= (he^{kh}-he^{-kh})(e^{kh}+e^{-kh})^{-1} + 2346(e^{kh}-e^{-kh})[-(e^{kh}+e^{-kh})^{-2}(he^{kh}-he^{-kh})] \\ 2347&= h(1-[\frac{e^{kh}-e^{-kh}}{e^{kh}+e^{-kh}}]^2 \\ 2348&= h(1-\tanh^2 kh) 2349\end{align*} 2350$$ 2351 2352With that identity, let's proceed: 2353 2354$$ 2355\begin{align*} 2356\omega^2 &= gk \tanh kh \\ 2357\omega &= (gk \tanh kh)^{\frac{1}{2}} \\ 2358\frac{\partial \omega}{\partial k} &= \frac{1}{2} [gk \tanh kh]^{-\frac{1}{2}} [g \tanh (kh) + gkh(1 - \tanh ^2 kh] \\ 2359&= \frac{g(\tanh kh + kh(1 - \tanh ^2 kh))}{2 \sqrt{gk \tanh kh}} \\ 2360&= \frac{g \tanh kh + gkh (1 - \tanh^2 kh)}{2 \sqrt{gk \tanh kh}} \\ 2361&= \frac{1}{2} [\sqrt{g \tanh kh} + \frac {gkh(1 - \tanh^2 kh)}{\sqrt{gk \tanh kh}}] \\ 2362&= \frac {g \tanh kh + gkh(1 - \tanh^2 kh)}{2\sqrt{gk \tanh kh}} \\ 2363&= \frac {g (\tanh kh + kh \operatorname{sech}^2 kh)}{2\sqrt{gk \tanh kh}} 2364\end{align*} 2365$$ 2366 2367Wolfram [here](https://www.wolframalpha.com/input?i=d%2Fdk+%5Bsqrt%28gk+tanh+%28kh%29%29%5D). 2368(Recall that $\operatorname{sech}^2 x = 1 - \tanh^2 x$.) 2369 23703. Viscous deep water (Tessendorf version): 2371 2372$$ 2373\begin{align*} 2374\omega^2 &= gk [1 + k^2 L^2] \\ 2375\omega &= (gk [1 + k^2 L^2])^{\frac{1}{2}} \\ 2376\frac{\partial \omega}{\partial k} &= \frac{1}{2}(gk [1 + k^2 L^2])^{-\frac{1}{2}} [g+3gk^2 L^2] \\ 2377&= \frac{g+3gk^2L^2}{2\sqrt{gk[1+k^2L^2]}} 2378\end{align*} 2379$$ 2380 2381Wolfram [here](https://www.wolframalpha.com/input?i=d%2Fdk+%5B%28gk+%281+%2B+%28k%5E2%29+%28L%5E2%29%29%29+%5E+%281%2F2%29%5D). 2382 23834. Viscous deep water (Horvath version): 2384 2385$$ 2386\begin{align*} 2387\omega^2 &= gk + \frac{\sigma}{\rho}k^3 \\ 2388\omega &= (gk + \frac{\sigma}{\rho}k^3)^{\frac{1}{2}} \\ 2389\frac{\partial \omega}{\partial k} &= 2390\frac{1}{2}(gk + \frac{\sigma}{\rho}k^3)^{-\frac{1}{2}} [g+3\frac{\sigma}{\rho}k^2] \\ 2391&= \frac{g + 3 \frac{\sigma}{\rho}k^2}{2 \sqrt{gk+\frac{\sigma}{\rho}k^3}} 2392\end{align*} 2393$$ 2394 2395Wolfram [here](https://www.wolframalpha.com/input?i=d%2Fdk+%5B%28gk%2Bs%28k%5E3%29%2Fp%29%5E%281%2F2%29%5D). 2396 23975. Viscous shallow water (Tessendorf version): 2398 2399FYI - use the Horvath version instead. This relation sucks. 2400 2401We'll want $\frac{\partial}{\partial k} \sqrt{\tanh kh}$: 2402 2403$$ 2404\begin{align*} 2405\frac{\partial}{\partial k} \sqrt{\tanh kh} 2406&= \frac{\partial}{\partial k} (\tanh kh)^{\frac{1}{2}} \\ 2407&= \frac{1}{2} (\tanh kh)^{-\frac{1}{2}} \frac{\partial}{\partial k} \tanh kh \\ 2408&= \frac{1}{2} (\tanh kh)^{-\frac{1}{2}} h(1 - \tanh^2 kh) \\ 2409&= h \frac{1 - \tanh^2 kh}{2 \sqrt{\tanh kh}} \\ 2410&= h \frac{\operatorname{sech}^2 kh}{2 \sqrt{\tanh kh}} 2411\end{align*} 2412$$ 2413 2414Now we can proceed: 2415 2416$$ 2417\begin{align*} 2418\omega^2 &= gk (1 + k^2 L^2) \tanh kh \\ 2419\omega &= (gk (1 + k^2 L^2) \tanh kh)^{\frac{1}{2}} \\ 2420\frac{\partial \omega}{\partial k} 2421&= (\frac{\partial}{\partial k} [gk (1 + k^2 L^2)]) \tanh kh + 2422[gk (1 + k^2 L^2)] \frac{\partial}{\partial k} \tanh kh \\ 2423&= \frac{g (3 + k^2 L^2)}{2 \sqrt{k} \sqrt{g (1 + k^2 L^2)}} \dots \\ 2424&= \frac{1}{2} \sqrt{\frac{g(3+k^2 L^2)}{k}} \sqrt{\tanh kh} + 2425\sqrt{gk (1+k^2 L^2)} [\frac{h (1 - \tanh^2 kh)}{2 \sqrt{\tanh kh}}] 2426\end{align*} 2427$$ 2428 2429We can apply some transformations to get a common denominator and agree 2430with Wolfram: 2431 2432$$ 2433\begin{align*} 2434\frac{\partial \omega}{\partial k} 2435&= \frac{g (3 + k^2 L^2)}{2 \sqrt{gk(1+k^2 L^2)}} \sqrt{\tanh kh} + \dots \\ 2436&= \frac{g (3 + k^2 L^2) \tanh kh}{2 \sqrt{gk(1+k^2 L^2) \tanh kh}} + \dots \\ 2437&= \dots + \sqrt{gk (1+k^2 L^2)} [\frac{h (1 - \tanh^2 kh)}{2 \sqrt{\tanh kh}}] \\ 2438&= \dots + \frac{gk(1+k^2 L^2)}{\sqrt{gk(1+k^2 L^2)}} [\frac{h (1 - \tanh^2 kh)}{2 \sqrt{\tanh kh}}] \\ 2439&= \dots + \frac{gk(1+k^2 L^2) h (1 - \tanh^2 kh)}{2 \sqrt{gk(1+k^2 L^2) \tanh kh}} \\ 2440&= \dots + \frac{ghk(1+k^2 L^2)(1-\tanh^2 kh)}{2 \sqrt{gk(1+k^2 L^2) \tanh kh}} \\ 2441&= \frac{g(3+k^2L^2) \tanh kh + ghk(1+k^2 L^2)(1-\tanh^2 kh)}{2 \sqrt{gk(1+k^2 L^2) \tanh kh}} \\ 2442&= \frac{g(3+k^2L^2) \tanh kh + ghk(1+k^2 L^2)(\operatorname{sech}^2 kh)}{2 \sqrt{gk(1+k^2 L^2) \tanh kh}} 2443\end{align*} 2444$$ 2445 2446Wolfram [here](https://www.wolframalpha.com/input?i=d%2Fdk+%5Bsqrt%28gk+%281+%2B+%28k%5E2%29%28L%5E2%29%29+tanh+%28kh%29%29%5D). 2447 24486. Viscous shallow water (Horvath version): 2449 2450$$ 2451\begin{align*} 2452\omega^2 &= (gk + \frac{\sigma}{\rho}k^3) \tanh kh \\ 2453\omega &= ((gk + \frac{\sigma}{\rho}k^3) \tanh kh)^{\frac{1}{2}} \\ 2454\frac{\partial \omega}{\partial k} 2455&= [\frac{\partial}{\partial k}(gk + \frac{\sigma}{\rho}k^3)] \tanh^{\frac{1}{2}} kh + 2456(gk + \frac{\sigma}{\rho}k^3)^{\frac{1}{2}} \frac{\partial}{\partial k} \tanh^{\frac{1}{2}} kh \\ 2457&= [\frac{1}{2}(gk+\frac{\sigma}{\rho}k^3)^{-\frac{1}{2}}(g+3\frac{\sigma}{\rho}k^2)] \tanh^{\frac{1}{2}} kh + 2458(gk + \frac{\sigma}{\rho}k^3)^{\frac{1}{2}}h\frac{1-\tanh^2 kh}{2 \sqrt{\tanh kh}} 2459\end{align*} 2460$$ 2461 2462Let's try to corral this into a form closer to what Wolfram gives us: 2463 2464$$ 2465\begin{align*} 2466\frac{\partial \omega}{\partial k} 2467&= [\frac{1}{2}(gk+\frac{\sigma}{\rho}k^3)^{-\frac{1}{2}}(g+3\frac{\sigma}{\rho}k^2)] \sqrt{\tanh{kh}} + 2468(gk + \frac{\sigma}{\rho}k^3)^{\frac{1}{2}}h\frac{1-\tanh^2 kh}{2 \sqrt{\tanh kh}} \\ 2469&= \frac{g+3\frac{\sigma}{\rho}k^2}{2\sqrt{gk+\frac{\sigma}{\rho}k^3}} \sqrt{\tanh{kh}} + \dots \\ 2470&= \frac{(g+3\frac{\sigma}{\rho}k^2) \tanh{kh}}{2\sqrt{(gk+\frac{\sigma}{\rho}k^3)\tanh{kh}}} + \dots \\ 2471&= \dots + (gk + \frac{\sigma}{\rho}k^3)^{\frac{1}{2}}h\frac{1-\tanh^2 kh}{2 \sqrt{\tanh kh}} \\ 2472&= \dots + (gk + \frac{\sigma}{\rho}k^3)h\frac{1-\tanh^2 kh}{2 \sqrt{(gk + \frac{\sigma}{\rho}k^3) \tanh kh}} \\ 2473&= \dots + \frac{h (gk+\frac{\sigma}{\rho}k^3) (1 - \tanh^2 kh)}{2 \sqrt{(gk+\frac{\sigma}{\rho}k^3)\tanh kh}} \\ 2474&= \frac{(g+3\frac{\sigma}{\rho}k^2) \tanh{kh} + h (gk+\frac{\sigma}{\rho}k^3) (1 - \tanh^2 kh)}{2 \sqrt{(gk+\frac{\sigma}{\rho}k^3)\tanh kh}} \\ 2475&= \frac{(g+3\frac{\sigma}{\rho}k^2) \tanh{kh} + h (gk+\frac{\sigma}{\rho}k^3) \operatorname{sech}^2{kh}}{2 \sqrt{(gk+\frac{\sigma}{\rho}k^3)\tanh kh}} 2476\end{align*} 2477$$ 2478 2479 2480Wolfram [here](https://www.wolframalpha.com/input?i=d%2Fdk+%5B%28%28gk%2Bs%28k%5E3%29%2Fp%29tanh%28kh%29%29%5E%281%2F2%29%5D). 2481Divide numerator and denominator by $\rho$ (or p in wolfram) to make them 2482match. 2483 2484# meow meow meow meow {data-date="10 Sep 2025"} 2485 2486meow meow meow meow meow meow meow meow'meow meow meow meow meow. meow 2487meow meow meow. 2488 2489## meow meow 2490 2491* meow meow meow 3 meow meow 65 meow meow meow. 2492* 3% meow meow meow meow meow meow meow 3 meow. 2493* meow meow meow meow meow meow meow meow meow 65 meow. 2494* meow meow meow meow meow meow 1-10 meow meow meow. 2495* meow meow meow meow meow'meow meow meow meow meow meow-meow meow meow meow meow 2496meow meow meow meow. meow, meow meow meow meow meow meow meow. 2497* meow meow > 3 meow meow meow meow meow meow meow meow. 2498* meow meow meow meow meow 10 meow/meow^2 meow'meow meow. meow'meow meow 2499meow meow meow meow meow. 2500 2501## meow, meow: meow meow meow meow meow (2007) 2502 2503[meow meow meow.](meow://meow.meow.meow.meow/meow/meow/meow/meow43-48-meow2007.meow) 2504 2505meow 2506 2507* meow 1993, meow meow meow meow meow meow meow meow meow meow. 2508* meow 1997, meow meow meow meow meow 560 meow meow. 76% meow meow meow 2509meow meow. (meow'meow meow meow meow meow 1, meow 38) 2510* meow 2012, meow meow meow meow meow meow meow 30 meow meow. 2511* meow meow meow, meow meow meow 2, meow meow 2 meow meow 2512meow meow meow meow. 2513* meow 1 meow 4,000 meow meow meow meow meow meow (meow). 2514* meow meow meow meow 5% meow meow meow meow. 2515 2516meow 2517 2518* meow meow meow meow meow meow. 2519* meow meow meow meow meow meow meow meow meow meow. 2520* 25% meow meow meow meow meow meow meow meow 20meow meow 30meow. 2521* 25% meow meow meow meow meow meow meow. meow meow meow meow 2522meow meow. 2523 2524meow meow meow 2525 2526* 79%: meow meow meow 2527* 10%: meow meow 2528* 6%: meow meow 2529* 5%: meow meow 2530 2531meow meow 2532 2533* meow meow meow meow meow meow (meow) 2534* meow meow meow meow meow meow meow meow meow 2535* meow meow meow 1362 meow meow meow meow 2536* (meow: 1 meow/meow^2 meow meow meow 1 *meow*) 2537* meow meow meow meow 5 meow meow meow, 5 meow meow meow. 2538* meow meow meow 0-12 meow. meow meow meow. 2539* meow meow, meow meow meow meow meow meow meow meow meow 3 meow 65 2540meow. 2541* 49% meow meow meow meow meow meow meow 50 meow, meow meow meow meow 2542meow meow meow meow meow meow meow meow meow (meow meow meow 2543meow). 2544* meow meow meow meow meow meow meow meow meow. 2545* 67% meow meow meow meow meow meow meow meow meow meow. 2546* meow 2547 2548meow meow 2549 2550* meow meow meow meow meow 2551* meow meow meow meow meow meow meow meow meow meow meow meow meow 2552* meow meow meow meow meow meow 2553* meow meow meow meow 1-10 meow meow meow 2554* meow: meow 120 meow meow, meow meow meow meow meow meow 1200 meow meow 2400 2555meow *meow*. meow! 2556* meow meow meow meow meow meow 10-20 meow meow meow meow. 2557* meow meow meow meow meow meow meow meow. 2558 2559meow meow 2560 2561* meow meow > 3 meow meow meow meow meow meow meow 25% meow meow meow meow 2562meow. 2563* meow meow meow meow meow meow meow meow meow 10 meow meow meow. 2564* meow meow meow meow meow meow 3 meow meow meow meow *meow*. 2565 2566## meow, meow: meow meow meow meow meow meow meow 2567meow (2002) 2568 2569[meow meow meow.](meow://meow.meow/2001-150.meow) 2570 2571meow 2572 2573* meow 1 meow 6000 meow meow meow meow meow 2574* meow meow meow meow meow 7 meow 20 meow meow 2575* (meow meow meow meow meow meow meow) 2576* meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow 2577meow meow meow meow meow meow, meow meow meow meow. 2578 2579meow 2580 2581* meow meow meow, meow meow-meow meow, meow meow meow meow meow 2582meow meow 2583* meow meow meow meow meow meow meow meow meow meow meow meow meow 2584meow meow meow meow meow meow 1 meow 50 meow. meow meow meow 2585meow meow meow (meow meow meow meow meow - 0% = meow meow, 100% = 2586meow meow meow meow) meow meow meow 50% (meow meow). 2587* meow meow meow. meow meow meow meow meow meow meow meow meow 2588meow meow meow 10 meow/meow^2 meow 200 meow/meow^2. meow 10meow/meow^2, meow meow meow 2589meow; meow 200 meow/meow^2, meow meow. "... meow meow meow meow meow meow 2590meow meow meow meow meow meow meow meow meow meow meow." 2591* meow meow 5 meow/meow^2 meow meow meow meow meow meow meow meow. 2592meow meow 20 meow/meow^2 meow meow meow meow meow meow meow 100 meow/meow^2. 2593* meow: meow meow = meow meow meow. 2594* meow meow meow meow meow meow meow meow meow meow meow, 2595meow 8.8% meow meow meow meow meow ~55% meow meow meow meow. 2596 2597# rasterized ray marching at scale {data-date="11 Jun 2025"} 2598 2599I've long had the dream of creating high resolution chains on characters with 2600raymarching. The problem is that Unity's object transform is based on the 2601character's hip bone, so making raymarched geometry "stick" to characters 2602is impossible. 2603 2604The idea I've been toying with for a long time is to raymarch inside a 2605rasterized box. If you store information in that box's verts, you could do a 2606raymarch inside a wholly self contained coordinate system. 2607I've pulled this off, but not in a way which is useful for characters (yet). 2608 2609 2610{width=80%} 2611 2612TLDR: 2613 2614* Create a Blender plugin to bake the location and orientation of submeshes. 2615Plugin available 2616[here](https://github.com/yum-food/2ner/blob/master/Scripts/BakeVertexData.py). 2617* Create a Unity script to visualize the baked data. Script available 2618[here](https://github.com/yum-food/2ner/blob/master/Scripts/Editor/DecodeVertexData.cs). 2619* Provide HLSL code showing how to use the baked data. 2620 2621## Main ideas and HLSL 2622 2623The core idea is to make it possible for each fragment of a material to learn 2624an origin point's location and orientation. If you can recover an origin point 2625and a rotation, then you can raymarch inside that coordinate system, then 2626translate back to object coordinates at the end. 2627 2628For each submesh\* in a mesh, I bake an origin point and an orientation. 2629 2630\* A submesh is just a set of vertices connected by edges. A mesh might contain 2631many unconnected submeshes. For example, in blender, you can combine two 2632objects with ctrl+J. I call those two combined but unconnected things 2633*submeshes*. 2634 2635The orientation of the submesh is derived from the face normals. I sort the 2636faces in the submesh by their area. The largest area face is used as the first 2637basis vector of our rotated coordinate system. Then I get the next face which 2638is sufficiently orthogonal to the first basis vector (absolute value of dot 2639product is > some epsilon). I orthogonalize those two basis vectors with 2640[graham-schmidt](https://en.wikipedia.org/wiki/Gram%E2%80%93Schmidt_process), 2641then generate the third with a cross product. I ensure right-handedness by 2642checking that the determinant is positive, then [convert to a 2643quaternion](https://en.wikipedia.org/wiki/Rotation_matrix#Conversion_from_rotation_matrix_to_axis%E2%80%93angle). 2644I then store that quaternion in 2 UV channels. 2645 2646The rotation quaternion is recovered on the GPU as follows: 2647 2648``` c 2649float4 GetRotation ( v2f i , float2 uv_channels ) { 2650float4 quat ; 2651quat . xy = get_uv_by_channel ( i , uv_channels . x ); 2652quat . zw = get_uv_by_channel ( i , uv_channels . y ); 2653return quat ; 2654} 2655... 2656RayMarcherOutput MyRayMarcher ( v2f i ) { 2657... 2658float2 uv_channels = float2 ( 1 , 2 ); 2659float4 quat = GetRotation ( i , uv_channels ); 2660float4 iquat = float4 ( - quat . xyz , quat . w ); 2661} 2662``` 2663 2664It's worth lingering here for a second. Each submesh is conceptualized as a 2665rotated bounding box. We just deduced an orthonormal basis for that rotated 2666coordinate system. That means that the artist can rotate their bounding boxes 2667however they want in Blender, and the plugin will automatically work out how to 2668orient things. You can arbitrarily move and rotate your bounding boxes and it 2669Just Works. 2670 2671The origin point is simply the average of all the vertex locations. I encode it 2672as a vector from each vertex to that location, and stuff it into vertex colors. 2673Since vertex colors can only encode numbers in the range [0, 1], I use the 2674alpha channel to scale the length of each vertex. 2675 2676I made two non obvious decisions in the way I bake the vertex offsets: 2677 26781. The offsets are encoded in terms of the rotated coordinate system. This saves 2679one quaternion rotation in the shader. 2680 26812. The offsets are scaled according to the L-infinity norm (Manhattan distance) 2682rather than the standard L2 norm (Euclidian distance). This lets the artist 2683think in terms of the bounding box dimensions rather than the square root of 2684the sum of squares of the box's dimensions. Like if your box is 1x0.6x0.2, 2685then you can just raymarch a primitive with those dimensions and your 2686simulation Just Works. 2687 2688The origin point is recovered on the GPU as follows: 2689 2690``` c 2691float3 GetFragToOrigin ( v2f i ) { 2692return ( i . color * 2.0f - 1.0f ) / i . color . a ; 2693} 2694RayMarcherOutput MyRayMarcher ( v2f i ) { 2695... 2696float3 frag_to_origin = GetFragToOrigin ( i ); 2697} 2698``` 2699 2700With those pieces in place, the raymarcher is pretty standard, but some care 2701has to be taken when getting into and out of the coordinate system. Here's a 2702complete example in HLSL: 2703 2704``` c 2705RayMarcherOutput MyRayMarcher ( v2f i ) { 2706float3 obj_space_camera_pos = mul ( unity_WorldToObject , 2707float4 ( _WorldSpaceCameraPos , 1.0 )); 2708float3 frag_to_origin = GetFragToOrigin ( i ); 2709 2710float2 uv_channels = float2 ( 1 , 2 ); 2711float4 quat = GetRotation ( i , uv_channels ); 2712float4 iquat = float4 ( - quat . xyz , quat . w ); 2713 2714// ro is already expressed in terms of rotated basis vectors, so we 2715// don't have to rotate it again. 2716float3 ro = - frag_to_origin ; 2717float3 rd = normalize ( i . objPos - obj_space_camera_pos ); 2718rd = rotate_vector ( rd , iquat ); 2719 2720float d ; 2721float d_acc = 0 ; 2722const float epsilon = 1e-3f ; 2723const float max_d = 1 ; 2724 2725[ loop ] 2726for ( uint ii ; ii < CUSTOM30_MAX_STEPS ; ++ ii ) { 2727float3 p = ro + rd * d_acc ; 2728d = map ( p ); 2729d_acc += d ; 2730if ( d < epsilon ) break ; 2731if ( d_acc > max_d ) break ; 2732} 2733clip ( epsilon - d ); 2734 2735float3 localHit = ro + rd * d_acc ; 2736float3 objHit = rotate_vector ( localHit , quat ); 2737float3 objCenterOffset = rotate_vector ( frag_to_origin , quat ); 2738 2739RayMarcherOutput o ; 2740o . objPos = objHit + ( i . objPos + objCenterOffset ); 2741float4 clipPos = UnityObjectToClipPos ( o . objPos ); 2742o . depth = clipPos . z / clipPos . w ; 2743 2744// Calculate normal in rotated space using standard raymarcher 2745// gradient technique 2746float3 sdfNormal = calc_normal ( localHit ); 2747float3 objNormal = rotate_vector ( sdfNormal , quat ); 2748o . normal = UnityObjectToWorldNormal ( objNormal ); 2749 2750return o ; 2751} 2752``` 2753 2754## Scalability and limitations 2755 27561. This technique is extremely scalable. I have a world with 16,000 bounding boxes 2757that runs at ~800 microseconds/frame without volumetrics. 2758 27592. You can have overlapping raymarched geometry without paying the usual 8x 2760slowdown of [domain 2761repetition](https://iquilezles.org/articles/sdfrepetition/). 2762 2763{width=80%} 2764 2765You still pay the price of overdraw, and unlike domain repetition, there's no 2766built-in compute budgeting. I.e. with domain repetition you'd hit your 2767iteration cap and stop. With this you won't. 2768 27693. The workflow is artist friendly. You can move, scale, and rotate your 2770geometry freely. Re-bake once you're done and everything just works. 2771 27724. Shearing works, but doesn't permit re-baking. 2773 2774{width=80%} 2775 2776{width=80%} 2778 2779{width=80%} 2781 2782{width=80%} 2783 2784## Blender and Unity tooling 2785 2786I've written a Blender plugin to permit myself to bake the vectors and 2787quaternions as described above. 2788 2789{width=80%} 2790 2791The plugin supports baking vectors and quaternions on extremely large meshes 2792primarily through caching. If your mesh contains many submeshes that are 2793simply translated in space, then baking should take less than a second. If 2794those submeshes are scaled, skewed, or rotated, then they won't cache and 2795baking will take longer. 2796 2797The baker lets you rotate the baked quaternion around the basis vectors. I had 2798to fuck with this a fair bit, and eventually found that 180 degrees worked. Try 2799going through every combo of 90 degrees (64 total) if you run into trouble. Use 2800[quick exporter](https://github.com/Wildergames/blender-quick-exporter) to 2801speed up the process. You can visualize the vectors with my Unity script, which 2802is described below. 2803 2804{width=80%} 2805 2806It also supports a bunch of other workflows, mostly designed for the voxel 2807world creation workflow: 2808 28091. Select all linked submeshes. This just does ctrl+L for each submesh with at 2810least one vert, edge, or face selected. Blender's built in ctrl+L seems to be 2811inconsistent in its behavior. 2812 28132. Select linked across boundaries. This basically does ctrl+L, but lets the 2814meshes be disconnected at as long as they have a vert that's within some 2815epsilon of a selected vert. That epsilon is configurable. It's scalable 2816up to thousands of submeshes. 2817 28183. Deduplicate submeshes. This just looks for submeshes where all their verts 2819are close to others. The closeness parameter (epsilon) is configurable. It 2820works via spatial hashing so it's extremely scalable. 2821 28224. Merge by distance per submesh. This just iterates over all submeshes and 2823does a merge by distance on each. When working with large collections of 2824submeshes, it's easy to accidentally duplicate a face/edge/vert along the way, 2825and these duplications can stack up. This lets you recover. 2826 28275. Pack UV island by submesh Z. This lets you pack UV islands for large 2828collections of submeshes and sort them by their Blender z axis height. Buggy as 2829shit rn, sorry! 2830 2831This is less relevant, but I wanted some way to instance axis-aligned geometry 2832along a curve and sort each instance's UVs by Z height. These nodes do that. 2833Put them on a curve and select your instance. Then use the "Pack UV island by 2834submesh Z" plugin tool to actually pack them. 2835 2836{width=80%} 2838 2839Finally, I have a Unity script which lets you visualize the raw baked vectors, 2840and the "corrected" baked vectors, i.e. those rotated with the baked 2841quaternion. Simply attach "Decode vertex vectors" to your gameobject. The light 2842blue vectors are raw vectors, and the orange ones are the corrected ones. The 2843orange ones should converge at the center of each submesh. 2844(It's okay if they overshoot/undershoot, you 2845can correct for that in your SDF.) 2846 2847{width=80%} 2849 2850# how much CO2 do American cars produce? {data-date="23 May 2025"} 2851 2852TLDR: About $1.520 \cdot 10^{12}$ kg/year. This increases the CO$_2$ in the 2853atmosphere by about $0.048$% per year. 2854 2855Let's gather some facts: 2856 2857* The average American (16 or older) drives about 13,476 miles per year 2858([US DoT](https://www.fhwa.dot.gov/ohim/onh00/bar8.htm)). 2859* There are 265,653,749 Americans aged 16 or older 2860([US 2020 Census](https://www2.census.gov/programs-surveys/popest/tables/2020-2023/national/asrh/nc-est2023-agesex.xlsx)). 2861* Finished motor gasoline releases about 18.73 pounds of CO$_2$ per gallon 2862([US Energy Information Administration](https://www.eia.gov/environment/emissions/co2_vol_mass.php)). 2863* New light duty vehicles (those weighing 10,000 pounds or less) get about 26.0 2864miles per gallon (mpg) as of 2024 2865([US DoE](https://www.energy.gov/eere/vehicles/articles/fotw-1330-february-19-2024-epa-data-show-average-fuel-economy-new-light-duty)). 2866* Freight trucks are much, much worse, at around 5-7 mpg. 2867([US DoE](https://afdc.energy.gov/data/10310)) 2868 2869Assume that the weighted average car is getting 20 mpg. This includes passenger 2870and freight. Passenger cars are higher and freight vehicles are lower. 2871 2872Then: 2873 2874$$ 2875\begin{align*} 2876& (265,653,749 \text{ Americans}) \\ 2877&\cdot (13,476 \text{ miles} / (\text{year} \cdot \text{American})) \\ 2878&\cdot (18.73 \text{ pounds of CO$_2$} / \text{gallon of gas}) \\ 2879&\div (20.0 \text{ miles} / \text{gallon}) \\ 2880&= 3.352 * 10^{12} \text{ pounds/year} \\ 2881&= 1.520 * 10^{12} \text{ kg/year} 2882\end{align*} 2883$$ 2884 2885Quick unit analysis to sanity check that equation: 2886 2887$$ 2888\begin{align*} 2889&(\text{people})\cdot(\text{miles/(people$\cdot$year)}) \\ 2890\rightarrow &\text{miles/year} \\ 2891&(\text{miles/year})/(\text{miles/gallon}) \\ 2892\rightarrow &\text{gallon/year} \\ 2893&(\text{gallon/year})\cdot(\text{pounds/gallon}) \\ 2894\rightarrow &\text{pounds / year} 2895\end{align*} 2896$$ 2897 2898Checks out. 2899 2900The atmosphere weighs about $5.15 \cdot 10^{18}$ kg (Lide, David R. Handbook of 2901Chemistry and Physics. Boca Raton, FL: CRC, 1996: 14–17). 2902 2903By mole fraction, the atmosphere is about 78.08% $N_2$, 20.95% $O_2$, 0.93% $Ar$, and 0.04% CO$_2$ 2904([wikipedia](https://en.wikipedia.org/wiki/Atmosphere_of_Earth)). 2905 2906Using the periodic table, one mole of each molecule weighs: 2907$$ 2908\begin{align*} 2909N_2 = 14.007*2 &= 28.014 g \\ 2910O_2 = 15.999*2 &= 31.998 g \\ 2911Ar &= 39.95 g \\ 2912CO_2 = 12.011 + 15.999*2 &= 44.009 g \\ 2913\end{align*} 2914$$ 2915 2916The weight of one mole of atmosphere is then: 2917 2918$$ 2919\begin{align*} 2920&0.7808 \cdot 28.014 g\\ 2921+ &0.2095 \cdot 31.998 g\\ 2922+ &0.0093 \cdot 39.95 g\\ 2923+ &0.0004 \cdot 44.009 g\\ 2924= &28.966 g 2925\end{align*} 2926$$ 2927 2928Since the atmosphere is 0.04% CO$_2$, we can compute the fractional weight of 2929CO$_2$ in atmosphere as $44.009 g \cdot 0.0004 / 28.966 g = 0.0006077$. This 2930number tells us what fraction of the *mass* of the atmosphere is CO$_2$. We 2931established above that this number is $5.15 \cdot 10^{18}$ kg, so the weight of 2932all the CO$_2$ in the atmosphere is therefore $3.129 \cdot 10^{15}$ kg. 2933 2934We know that Americans emit $1.520 \cdot 10^{12}$ kg/year of CO$_2$. We know that 2935the CO$_2$ in the atmosphere weighs $3.129 \cdot 10^{15} kg$. Therefore, every 2936year, Americans increase the CO$_2$ in the atmosphere by a factor of: 2937 2938$$ 2939(1.520 \cdot 10^{12}) / (3.129 \cdot 10^{15}) = 0.00048 2940$$ 2941 2942or 0.048%. 2943 2944$\blacksquare$ 2945 2946[This guy](https://www.grisanik.com/blog/how-much-carbon-is-in-the-atmosphere/) 2947used CO$_2$ ppm readings + the known mass of the atmosphere to arrive at a figure 2948of 3,208 Gt, matching my 3,129 figure very closely. 2949[Wikipedia cites](https://en.wikipedia.org/wiki/Carbon_dioxide_in_Earth%27s_atmosphere) 2950a figure of 3,341 Gt using the same ppm + total mass technique. 2951So we're all within a pretty tight range of each other. 2952 2953That Wikipedia article also claims that we've only increased the CO$_2$ in the 2954atmosphere by ~50% since the beginning of the Industrial Revolution. If so, 2955that kinda tracks with our figures. If we assume that Americans have been 2956emitting at the current rate (fewer but shittier cars in the past) for about 50 2957years, that works out to a total contribution of 2.5% just from our cars. 2958 2959We know that cars are not the dominant form of CO$_2$ emissions. British 2960Petroleum publishes an amazing, annual statistical review of global energy 2961trends. Let's pore over the 2022 document 2962([link](https://www.bp.com/content/dam/bp/business-sites/en/global/corporate/pdfs/energy-economics/statistical-review/bp-stats-review-2022-full-report.pdf)). 2963In 2022, Americans emitted 4.701 Gt of CO$_2$ (page 12). Thus cars contributed 296432.33% of our total CO$_2$ budget. In the same year, China emitted about 10.523 2965GT of CO$_2$ (page 12). Much of that can be seen as Americans offloading their 2966emissions to China in the form of manufacturing. Finally, we see that the 2967entire world's emissions amount to about 33.884 Gt of CO$_2$ per year. American 2968drivers are therefore responsible for about 4.485% of that budget. 2969 2970If we synthesize our "2.5% of the CO2 in the air is from American drivers" 2971number with the above figure that we're emitting about 5% of the global budget, 2972we get a global cumulative emission of about 50%. That also matches what 2973Wikipedia claims: that CO2 in the atmosphere has increased by about 50% since 2974the start of the Industrial Revolution. 2975 2976So through basic analysis of public data and a couple reasonable inferences, 2977we have arrived at the same conclusion as the "entrenched academics": that the 2978change in CO$_2$ in the atmosphere over the last 200 years is due to human 2979activity. 2980 2981# "big llms are memory bound" {data-date="22 May 2025"} 2982 2983There is wisdom oft repeated that "big neural nets are limited by memory bandwidth." 2984This is utter horseshit and I will show why. 2985 2986LLMs are typically implemented as autoregressive feed-forward neural nets. This 2987means that to generate a sentence, you provide a *prompt* which the neural net 2988then uses to generate the next *token*. That prompt + token is fed back into 2989the neural net repeatedly until it produces an EOF token, marking the end of 2990generation. 2991 2992We want to derive an equation predicting token rate $T$. Let's define some 2993variables: 2994 2995$T$: token rate (tokens / second) 2996 2997$M$: memory bandwidth (bytes / second) 2998 2999$P$: model size (parameters) 3000 3001$C$: compute throughput (parameters / second) 3002 3003$Q$: model quantization (bytes / parameter) 3004 3005Since each token requires accessing the entire model's parameters, then on an 3006infinitely powerful computer: 3007 3008$$T = \frac{M}{P \cdot Q}$$ 3009 3010As the model size $P$ grows, token rate $T$ drops; as memory bandwidth $M$ grows, 3011token rate $T$ increases. Likewise, quantizing the model eases memory pressure, 3012so reducing bytes/param $Q$ increases token rate $T$. This is all expected. 3013 3014However, most of our computers do not have infinite compute throughput. We must 3015then adjust our equation: 3016 3017$$T = \frac{\min(\frac{M}{Q}, C)}{P}$$ 3018 3019Token rate $T$ increases until we saturate compute $C$ or memory 3020bandwidth $\frac{M}{Q}$, then it stops. Totally reasonable. 3021 3022Notably, *token rate uniformly drops as parameter count increases.* The common 3023wisdom that "big models are memory bound lol" is complete horseshit. 3024 3025This equation helps you balance your compute against your memory bandwidth. You 3026can calculate your system's memory bandwidth as follows, assuming you have DDR5: 3027 3028$M_c$: memory channels 3029 3030$M_s$: memory speed (GT/s) 3031 3032$$M = M_s \cdot 8 \cdot M_c$$ 3033 3034(Source: [wikipedia](https://en.wikipedia.org/wiki/DDR5_SDRAM)) 3035 3036So if you have 12 channels of DDR5 @ 6000 MT/s, that works out to 3037$12 \cdot 8 \cdot 6 = 576$ GB/s. 3038 3039Consider a model like [DeepSeek-V3-0324 in 2.42 bit quant](https://huggingface.co/unsloth/DeepSeek-V3-0324-GGUF). 3040This bad boy is a mixture of experts (MoE) with 37B activated parameters per 3041token. So at 2.42 bits / parameter, that works out to ~11.19 GB / token. 3042Assuming infinite compute, the upper bound on token generation rate is 3043576 / 12.53 = 51.46 tokens / second. 3044 3045I hate to be the bearer of bad news. You will not see this token rate. On my 3046shitass server with an 3047[EPYC 9115](https://www.amd.com/en/products/processors/server/epyc/9005-series/amd-epyc-9115.html) 3048CPU and 12 channels of ECC DDR5 @ 6000 MT/s, I only see 4.6 tok/s. That 3049implies that my CPU is *more than 10x less than what I need* to saturate my 3050memory subsystem. I'm using a recent build of llama-cli for this test, and a 3051relatively small context window (8k max). 3052 3053In conclusion: 3054 30551. The theory behind token rate is very simple once you grok that LLMs are just 3056autoregressors, and they need to page every active parameter into memory once 3057per token to operate. 30582. You can extrapolate expected performance from smaller models, since memory 3059bandwidth and compute dictate throughput in inverse proportion to model size. 30603. People on the internet (especially redditors) are fucking stupid. 3061 3062# meow meow meow meow {data-date="14 Apr 2025"} 3063 3064meow meow meow meow meow meow meow meow. meow meow meow meow, meow meow 3065meow meow meow meow meow. 3066 3067meow meow meow meow meow. meow meow meow meow meow meow meow, meow meow 3068meow. meow meow meow. meow meow meow meow meow meow meow meow meow. meow 3069meow meow; meow, meow meow meow meow meow meow meow. 3070 3071meow meow meow meow meow. meow meow meow. meow meow. 3072 3073# riding crop {data-date="7 Apr 2025"} 3074 3075 3076 3077[Click here](./vr_assets/riding_crop/riding_crop_v06.unitypackage) to download 3078my riding crop [from gumroad](https://yumfood.gumroad.com/l/riding_crop). See 3079the gumroad page for setup instructions. 3080 3081Gumroad suspended my account over this product. Yes, over a fucking 3082*riding crop*. That's why it's hosted here. Enjoy the 100% discount <3 3083 3084# a panoply of frameworks {data-date="3 Apr 2025"} 3085 3086I want to use electron. I know that raw CSS sucks dick so let's use a 3087framework. Bootstrap sucks so let's use tailwind. Oh wait tailwind has a 3088build step? Okay let's use the CLI. Wait, I'm going to need to be able to 3089plumb runtime data eventually. I think that's what react is for right? Uhhh 3090if I'm using react is the tailwind CLI going to be good enough? It seems 3091like vite is what people are using for tailwind+react. Okay let's just 3092commit to that. Hmm this is a lot of setup, should I use a template? Oh 3093wait the main template people are using advertises "full access to node.js 3094apis from the renderer process." That seems like a terrible fucking idea. 3095Good thing I actually read the electron docs. 3096 3097I want to die. 3098 3099# electron first impressions {data-date="1 Apr 2025"} 3100 3101Occasionally I want to build some throwaway app for use by other people. 3102CLIs are nice and all, but they're hard to launch from VR, and most people 3103have never interacted with a terminal. So I need some way to write a 3104GUI. Enter electron. 3105 3106Electron is a cross-platform UI framework. It bundles an entire chromium 3107install (gross) but in return you can basically just use standard web dev 3108practices. 3109 3110It exposes a two-process model: one main process, and one renderer 3111process. The main process has basically unfettered access to the OS, and 3112the renderer process has unfettered access to the DOM (document object 3113model - the runtime structure of an HTML webpage). The two processes talk 3114to each other through channels. 3115 3116Generating a distributable is easy with forge-cli. My main nitpick here 3117is that I think the default maker should be the zip maker, not the 3118installer. Installers give me the headache that I have to remember to 3119uninstall the thing once it most likely fails to work. Isolated 3120environments with no hidden side effects are simply better. 3121Switching to zip is simple matter of editing the default `forge.config.js` 3122and moving 'win32' to the maker-zip block. The generated .zip works 3123basically as expected: it contains a bunch of dependencies, and an .exe. 3124Put the .zip in a directory, extract it, double click the .exe, and you app 3125opens. (One more nit: the zip should contain a subdirectory so you can 3126extract without manually creating a directory for it.) 3127The hello world package is heavy but not as bad as I expected: 10.6MB 3128disk (compressed), 282MB disk (uncompressed), 0.0% CPU, 65MB memory. Memory 3129is basically in line with what I was getting with wxWidgets - I think that 3130was around 30 MB with my entire STT app built in. Worse but IMO within the 3131realm of reasonability. Time to first draw is pretty good - under a 3132second according to the eyeball test. 3133 3134# hello world :3 {data-date="20 Mar 2025"} 3135 3136< video autoplay loop muted playsinline > 3137< source src =" https://yummers.dev/images/danser.webm " type =" video/webm "> 3138me rn 3139</ video >