Julia morph orbit in the hairs
1920x1080p60 MKV (945MB), 4m10s, stereo sound
Julia morphing, an artistic Mandelbrot set zooming technique, gives angled internal addresses that end with a regular structure like:
\[ \cdots p \to_{1/3} 2p+k \to_{1/3} 2(2p+k)+k \cdots \]
Back in 2013, I made an animation of an embedded Julia set orbit in the hairs around several period 7 islands. Embedded Julia sets are (relatively) surface features with simple angled internal addresses, while Julia morphs are much deeper and the addresses are more complicated.
My first attempt simply replaced a prefix of a template Julia morph with 2N prefixes based on the period patterns in the hairs and while I got images (the angled internal addresses were realizable by actual locations in the Mandelbrot set) they didn't form a coherent animation in any way. I asked a question on mathoverflow about this.
I went back to the graphical Mandelbrot set explorers, and figured out that the suffix needed to change when changing the prefix, because k (as in 2p+k) changes according to which child bulb the hair is attached to. Moreover, there is another case to consider, whether the child bulb's period appears in the address (=> address has more numbers) or not (=> address is the simple 2p+k pattern).
I did the initial graphical analysis in m-perturbator-gtk, heavily using its annotation features such as periodic point marking and external ray tracing to find external angles. Then I used mandelbrot-symbolics in ghci, to convert between angles and addresses. Here's some of the Haskell code:
-- ...
main = do
[depth] <- map read <$> getArgs
let prefixes = (".(" ++) . (++ "0111)") . concat <$> replicateM depth [lo, hi]
julia = 3 * depth + 4
morph = 4
lo = "011"
hi = "100"
period (Sym.Angled _ _ a) = period a
period (Sym.Unangled p) = p
writeFile "a.txt" . unlines . map (\prefix ->
let Just addressPrefix@(Sym.Angled 1 _ (Sym.Angled 2 _ (Sym.Angled 3 pq _))) = (Sym.angledAddress . Sym.rational) =<< Txt.parse prefix
addressSuffix (Sym.Angled p t a) = Sym.Angled p t (addressSuffix a)
addressSuffix (Sym.Unangled p) = Sym.Angled p (1 Sym.% 2) (go morph p0)
where
p0 | m < julia = julia + m
| m > julia = 2 * p
go 0 p | m < julia = Sym.Unangled p
go n p | m < julia = Sym.Angled p (1 Sym.% 3) (go (n - 1) (2 * p + m))
go 0 p | m > julia = Sym.Angled p (1 Sym.% 2) (Sym.Angled (p + 1) (1 Sym.% 2) (Sym.Unangled (p + 2)))
go n p | m > julia = Sym.Angled p (1 Sym.% 2) (Sym.Angled (p + 1) (1 Sym.% 2) (Sym.Angled (p + 2) (1 Sym.% 3) (go (n - 1) (2 * (p + 2) + m - 2))))
m = 3 * fromIntegral (Sym.denominator pq)
address = addressSuffix addressPrefix
node (Sym.Angled p1 _ (Sym.Unangled p2)) | m < julia = p1 + p2
node (Sym.Angled p1 _ (Sym.Angled _ _ (Sym.Angled _ _ (Sym.Unangled p2)))) | m > julia = p1 + p2
node (Sym.Angled _ _ a) = node a
Just ray = Txt.plain . Sym.binary . <$> Sym.addressAngles address
in ray ++ "\t" ++ show (period address) ++ "\t" ++ show (node address)) $ prefixes
That outputs one of the external angles of the angled internal address,
of the Julia morph corresponding to each prefix, as well as its period, and
the period of a neighbouring node in the Julia morph which I used in some
C code to align all the morphs in screen-space. I use the command line tool
parallel -k to parallelize tracing these external rays using
m-exray-in from mandelbrot-numerics, then found
the periodic nucleus of the minibrot islands with m-nucleus, and
the approximate zoom level using m-domain-size and some
ghc -e scripting (starting ghc is slow, better
to run it once to handle multiple inputs). I fed this to some custom C code
that aligns the view with the node period:
#include <stdio.h>
#include <mandelbrot-numerics.h>
int main(int argc, char **argv)
{
if (! (argc > 1)) return 1;
int Period = atoi(argv[1]);
// hardcoded precision, should be based on zoom level
mpfr_t Re, Im, Zoom;
mpfr_init2(Re, 1000);
mpfr_init2(Im, 1000);
mpfr_init2(Zoom, 53);
// parse KFR input (no error checking)
getc(stdin); getc(stdin); getc(stdin); getc(stdin);
mpfr_inp_str(Re, stdin, 10, MPFR_RNDN);
getc(stdin);
getc(stdin); getc(stdin); getc(stdin); getc(stdin);
mpfr_inp_str(Im, stdin, 10, MPFR_RNDN);
getc(stdin);
getc(stdin); getc(stdin); getc(stdin); getc(stdin); getc(stdin); getc(stdin);
mpfr_inp_str(Zoom, stdin, 10, MPFR_RNDN);
getc(stdin);
// find nearby node nucleus and use it to align view
mpfr_d_div(Zoom, 2, Zoom, MPFR_RNDN);
mpfr_mul_d(Zoom, Zoom, 0.3, MPFR_RNDN);
mpc_t c1;
mpc_init2(c1, 1000);
mpfr_add(mpc_realref(c1), Re, Zoom, MPFR_RNDN);
mpfr_set(mpc_imagref(c1), Im, MPFR_RNDN);
m_r_nucleus(c1, c1, Period, 64, 1);
mpfr_sub(mpc_realref(c1), mpc_realref(c1), Re, MPFR_RNDN);
mpfr_sub(mpc_imagref(c1), mpc_imagref(c1), Im, MPFR_RNDN);
mpc_abs(Zoom, c1, MPFR_RNDN);
mpfr_div_d(Zoom, Zoom, 0.3, MPFR_RNDN);
mpfr_d_div(Zoom, 2, Zoom, MPFR_RNDN);
mpfr_printf("Re: %Re\r\nIm: %Re\r\nZoom: %.18e\r\n",
Re, Im, mpfr_get_d(Zoom, MPFR_RNDN));
mpc_arg(Zoom, c1, MPFR_RNDN);
printf("RotateAngle: %.18f\r\nIterations: 15000\r\n",
mpfr_get_d(Zoom, MPFR_RNDN) / (2 * 3.141592653589793) * 360);
return 0;
}
Then I rendered the final images with kf.x86_64.exe.
All of this was orchestrated by a Bash shell script, a.txt
generated by the Haskell with depth 12 (4096 lines) (this took the most
time, almost 2 days):
#!/bin/bash
prec=1000
cat a.txt |
while read ray period node
do
echo "m-exray-in 100 '${ray}' 8 $((2 * 8 * ${period}))"
done |
parallel -k |
paste a.txt - |
tee b.txt
cat b.txt |
while read ray period node re im
do
echo m-nucleus $prec $re $im $period 64 1
done |
parallel -k |
paste b.txt - |
tee c.txt
cat c.txt |
while read ray period node ere eim nre nim
do
echo m-domain-size $prec $nre $nim $period
done |
parallel -k |
paste c.txt - | tee d.txt
cat d.txt |
while read ray period node ere eim nre nim ds
do
echo $ds
done |
ghc -e "interact $ unlines . map (show . (\s -> 2 / (10 * s**1.125)) . read) . lines" |
paste d.txt - |
tee e.txt
cat -n e.txt |
while read n ray period node ere eim nre nim ds zoom
do
echo -e "Re: $nre\r\nIm: $nim\r\nZoom: $zoom\r" |
m-align-nodes ${node} > $(printf %04d.kfr $n)
done
for i in ????.kfr
do
kf.x86_64.exe -s settings.kfs -l "$i" -c palette.kfp -t "$i.tif" 2>&1 |
grep -v fixme # silence Wine error flood
convert -colorspace RGB $i.tif -geometry 1920x1080 -colorspace sRGB "${i%kfr}tif" &&
rm "$i.tif"
done
ffmpeg -r 60 -i "%04d.tif" -pix_fmt yuv420p -profile:v high -level:v 5.1 -crf:v 20 spin.mkv
I asked another question about the magic number 1.125 in this script.
While the rays were tracing (using the CPU) I ran some of the commands
on the first ray to get the first KFR file, and I rendered a zoom video
(using the GPU) to the minibrot at its center, controlling the zoom speed
and depth in zoomasm so that the zoom video slowed down and
paused at the exact zoom depth of the spin video (I tried to use maths
to work out the numbers to enter into zoomasm, but couldn't figure it out
so I eventually used binary search by hand and got it close enough).
I also made a soundtrack, using the pairs of rays of each spin video
frame, angles expressed in binary, converted to wavetables and stretched
to the length of the frame using FFT/zeropad/IFFT. I processed the result
of this in audacity, to extend it to the length of the whole
video (making sure to keep the spin section correctly aligned in time):
#include <complex.h>
#include <math.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sndfile.h>
#include <fftw3.h>
int main(int argc, char **argv)
{
const int SR = 192000;
const int FPS = 60;
const int length = SR / FPS;
float audio[length][2];
double _Complex *ifft_in = fftw_malloc(length * sizeof(*ifft_in));
double _Complex *ifft_out = fftw_malloc(length * sizeof(*ifft_out));
fftw_plan backward = fftw_plan_dft_1d(length, ifft_in, ifft_out, FFTW_BACKWARD, FFTW_PATIENT | FFTW_DESTROY_INPUT);
int old_period = 0;
double _Complex *fft_in = 0;
double _Complex *fft_out = 0;
fftw_plan forward = 0;
SF_INFO onfo = { 0, SR, 2, SF_FORMAT_WAV | SF_FORMAT_FLOAT, 0, 0 };
SNDFILE *ofile = sf_open("audio.wav", SFM_WRITE, &onfo);
while (true)
{
int period = 0;
char *ray[2] = { 0, 0 };
if (3 != scanf("%d\t%ms\t%ms\n", &period, &ray[0], &ray[1]))
{
break;
}
if (period != old_period)
{
if (fft_in)
{
fftw_free(fft_in);
}
if (fft_out)
{
fftw_free(fft_out);
}
if (forward)
{
fftw_destroy_plan(forward);
}
fft_in = fftw_malloc(period * sizeof(*fft_in));
fft_out = fftw_malloc(period * sizeof(*fft_out));
forward = fftw_plan_dft_1d(period, fft_in, fft_out, FFTW_FORWARD, FFTW_MEASURE | FFTW_DESTROY_INPUT);
}
for (int c = 0; c < 2; ++c)
{
int v = 0;
for (int p = 0; p < period; ++p)
{
fft_in[p] = v;
v += ray[c][p] == '0' ? -1 : 1;
}
double s = v / (double) period;
double _Complex dc = 0;
for (int p = 0; p < period; ++p)
{
dc += (fft_in[p] -= p * s);
}
dc /= period;
for (int p = 0; p < period; ++p)
{
fft_in[p] -= dc;
}
fftw_execute(forward);
memset(ifft_in, 0, length * sizeof(*ifft_in));
for (int p = 0; p < period/2; ++p)
{
ifft_in[p] = fft_out[p];
ifft_in[length - 1 - p] = fft_out[period - 1 - p];
}
fftw_execute(backward);
double rms = 0;
for (int p = 0; p < length; ++p)
{
audio[p][c] = creal(ifft_out[p]);
rms += audio[p][c] * audio[p][c];
}
rms = 0.25 / sqrt(rms / length);
for (int p = 0; p < length; ++p)
{
audio[p][c] *= rms;
}
free(ray[c]);
}
sf_writef_float(ofile, &audio[0][0], length);
old_period = period;
}
sf_close(ofile);
return 0;
}
Then I split the zoom video at this point and splicing in the spin video:
ffmpeg -i zoom.mkv -t 136 -codec:v copy zoom-1.mkv ffmpeg -i zoom.mkv -ss 136 -codec:v copy zoom-2.mkv cat > zoom.txt <<EOF file 'zoom-1.mkv' file 'spin.mkv' file 'zoom-2.mkv' EOF ffmpeg -f concat -i zoom.txt -i soundtrack.m4a \ -codec:v copy -codec:a copy "Julia morph orbit in the hairs.mkv"
Then two-pass encoding to a lower bitrate version for web streaming:
ffmpeg -i "Julia morph orbit in the hairs.mkv" \ -codec:a copy -profile:v high -level:v 5.1 -g 120 \ -x264opts keyint=120:no-scenecut -b:v 6M -movflags +faststart \ -pass 1 -y "Julia morph orbit in the hairs.mp4" ffmpeg -i "Julia morph orbit in the hairs.mkv" \ -codec:a copy -profile:v high -level:v 5.1 -g 120 \ -x264opts keyint=120:no-scenecut -b:v 6M -movflags +faststart \ -pass 2 -y "Julia morph orbit in the hairs.mp4"
And that's the video linked from the image at the top of the page.
Meanwhile, a test of motion blur:
1920x360p10 MP4 (17MB), 13s, no sound
I was considering whether to add motion blur, and if so, how much. 1024 frames in total were rendered, 128 frames in the output video at 10fps. The left video takes groups of 8 frames, outputting the first (0% shutter speed). The middle video takes groups of 8 frames, averaging the first 4 of them (50% shutter speed). The right video takes groups of 8 frames, averaging all of them (100% shutter speed).
Also, a test of soundtrack algorithm:
1920x1080p15 MP4 (37MB), 1m08s, stereo sound
More work in progress, this time on soundtrack. 2^12 pairs of external angles of various periods are sonified as wavetables, all stretched to the same length using FFT, zero-pad, IFFT. Meanwhile the video as 2^10 frames at 15fps, the coordinates of the final set of 2^12 frames at 60fps are still calculating and are yet to be rendered.