The essence of perturbation is to find the difference between the high precision values of a function at two nearby points, while using only the low precision value of the difference between the points. In this post I'll write the high precision points in CAPITALS and the low precision deltas in lowercase. There are two auxiliary operations needed to define the perturbation \(P\), \(B\) replaces all variables by their high precision version, and \(W\) replaces all variables by the sum of the high precision version and the low precision delta. Then \(P = W - B\):

\[\begin{aligned} B(f) &= f(X) &\text{ (emBiggen)}\\ W(f) &= f(X + x) &\text{ (Widen)}\\ P(f) &= W(f) - B(f) \\ &= f(X + x) - f(X) &\text{ (Perturb)} \end{aligned}\]

For example, perturbation of \(f(z, c) = z^2 + c\), ie, \(P(f)\), works out like this:

\[\begin{aligned} & P(f) \\ \to & f(Z + z, C + c) - f(z, c) \\ \to & (Z + z)^2 + (C + c) - (Z^2 + C) \\ \to & Z^2 + 2 Z z + z^2 + C + c - Z^2 - C \\ \to & 2 Z z + z^2 + c \end{aligned}\]

where in the final result the additions of \(Z\) and \(z\) have mostly cancelled out and all the terms are "small".

For polynomials, regular algebraic manipulation can lead to successful outcomes, but for other functions it seems some "tricks" are needed. For example, \(|x|\) (over \(\mathbb{R}\)) can be perturbed with a "diffabs" function proceeding via case analysis:

// evaluate |X + x| - |X| without catastrophic cancellation function diffabs(X, x) { if (X >= 0) { if (X + x >= 0) { return x; } else { return -(2 * X + x); } } else { if (X + x > 0) { return 2 * X + x; } else { return -x; } } }

This formulation was developed by laser blaster at fractalforums.com.

For transcendental functions, other tricks are needed. Here for example is a derivation of \(P(\sin)\):

\[\begin{aligned} & P(\sin) \\ \to & \sin(X + x) - \sin(X) \\ \to & \sin(X) \cos(x) + \cos(X) \sin(x) - \sin(X) \\ \to & \sin(X) (\cos(x) - 1) + \cos(X) \sin(x) \\ \to & \sin(X) \left(-2\sin^2\left(\frac{x}{2}\right)\right) + \cos(X) \sin(x) \\ \to & \sin(X) \left(-2\sin^2\left(\frac{x}{2}\right)\right) + \cos(X) \left(2 \cos\left(\frac{x}{2}\right) \sin\left(\frac{x}{2}\right)\right) \\ \to & 2 \sin\left(\frac{x}{2}\right) \left(-\sin(X) \sin\left(\frac{x}{2}\right) + \cos(X) \cos\left(\frac{x}{2}\right)\right) \\ \to & 2 \sin\left(\frac{x}{2}\right) \cos\left(X + \frac{x}{2}\right) \end{aligned}\]

Knowing when to apply the sum- and double-angle-formulae, is a bit of a mystery, especially if the end goal is not known beforehand. This makes implementing a symbolic algebra program that can perform these derivations quite a challenge.

In lieu of a complete symbolic algebra program that does it all on demand, here are a few formulae that I calculated, some by hand, some using Wolfram Alpha:

\[\begin{aligned} P(a) &= 0 \\ P(a f) &= a P(f) \\ P(f + g) &= P(f) + P(g) \\ P(f g) &= P(f) W(g) + B(f) P(g) \\ P\left(\frac{1}{f}\right) &= -\frac{P(f)}{B(f)W(f)} \\ P(|f|) &= \operatorname{diffabs}(B(f), P(f)) \\ P(\exp) &= \exp(X) \operatorname{expm1}(x) \\ P(\log) &= \operatorname{log1p}\left(\frac{x}{X}\right) \\ P(\sin \circ f) &= \phantom{-}2 \sin\left(\frac{P(f)}{2}\right)\cos\left(\frac{W(f)+B(f)}{2}\right) \\ P(\cos \circ f) &= -2 \sin\left(\frac{P(f)}{2}\right)\sin\left(\frac{W(f)+B(f)}{2}\right) \\ P(\tan \circ f) &= \frac{\sin(P(f))}{\cos(B(f))\cos(W(f))} \\ P(\sinh \circ f) &= 2 \sinh\left(\frac{P(f)}{2}\right)\cosh\left(\frac{W(f)+B(f)}{2}\right) \\ P(\cosh \circ f) &= 2 \sinh\left(\frac{P(f)}{2}\right)\sinh\left(\frac{W(f)+B(f)}{2}\right) \\ P(\tanh \circ f) &= \frac{\sinh(P(f))}{\cosh(B(f))\cosh(W(f))} \\ \end{aligned}\]

I hope to find time to add these to et soon.

**EDIT** there is a simpler and more general way to derive \(P(\sin)\)
and so on, using \(\sin(a) \pm \sin(b)\) formulae...

Earlier today I wrote about
atom domain coordinates,
and thought about extending it to
Misiurewicz domains.
By simple analogy, define the **Misiurewicz domain coordinate** \(G\) with
\(0 \le r \lt q\) and \(1 \le p\):

\[G(c, p, q, r) = \frac{F^{q + p}(0, c) - F^{q}(0, c)}{F^{r + p}(0, c) - F^{r}(0, c)}\]

Calculated similarly to the
atom domain size estimate,
the **Misiurewicz domain size estimate** is:

\[|h| = \left| \frac{F^{r + p}(0, c) - F^{r}(0, c)}{\frac{\partial}{\partial c}F^{q + p}(0, c) - \frac{\partial}{\partial c}F^{q}(0, c)} \right| \]

Like the atom domain coordinate, Newton's method can be used to find a point with a given Misiurewicz domain coordinate. Implementing this is left as an exercise (expect an implementation in my mandelbrot-numerics repository at some point soon).

]]>Previously I wrote about
atom domain size estimates
in the Mandelbrot set. A logical step given \(G(c) = 0\) at the center and
\(|G(c)| = 1\) on the boundary is to take \(G(c)\) as the
**atom domain coordinate**
for \(c\). It turns out to make sense to make \(1 \le q \lt p\) arguments to
the function, and evaluate them at the central nucleus, because otherwise the
assumption that \(p, q\) are constant throughout the domain can be violated
(particularly with neighbouring domains in embedded Julia sets, where the higher
period one is not "influenced" by the medium period one it overlaps, but instead
by the lower period "parent" of both):

\[G(c, q, p) = \frac{F^p(0, c)}{F^q(0, c)}\]

In my efficient automated Julia morphing experiments recently I used the atom domain coordinates for guessing an initial point for Newton's method to find Misiurewicz points. This worked because each next level of morph had an atom domain coordinate approximately the previous raised to the power \(\frac{3}{2}\). To do this I needed to implement Newton's method iterations to find \(c\) given \(G(c), p, q\). Pseudo-code for that looks like this:

double _Complex m_domain_coord ( double _Complex c0 , double _Complex G , int q , int p , int n ) { double _Complex c = c0; for (int j = 0; j < n; ++j) { double _Complex zp = 0; double _Complex dcp = 0; double _Complex z = 0; double _Complex dc = 0; for (int i = 1; i <= p; ++i) { dc = 2 * z * dc + 1; z = z * z + c; if (i == q) { zp = z; dcp = dc; } } double _Complex f = z / zp - G; double _Complex df = (dc * zp - z * dcp) / (zp * zp); c = c - f / df; } return c; }

You can find fuller implementations (including arbitrary precision) in my mandelbrot-numerics repository.

]]>Previously I wrote about an automated Julia morphing method extrapolating patterns in the binary representation of external angles, and then tracing external rays. However this was impractical as it was \(O(p^2)\) for final period \(p\) and the period typically more than doubles at each next level of morphing. This week I devised an \(O(p)\) algorithm, which requires a little bit of setting up and doesn't always work but when it works it works very well.

The first key insight was that in embedded Julia sets, the primary spirals and tips are distinguishable by the preperiods of the Misiurewicz points at their centers. Moreover when using the "full" Newton's method algorithm for Misiurewicz points that rejects lower preperiods by division, the basins of attraction comfortably enclose the center of the embedded Julia set itself.

So, we can choose the appropriate (pre)period to get to the center of the spiral either inwards towards the main body of the Mandelbrot set or outwards towards its tips. Now, from a Misiurewicz center of a spiral, Newton's method for periodic nucleus finding will work for any of the periods that form the structural spine of the spiral - these go up by a multiple of the period of the influencing island. From these nuclei we can jump to the Misiurewicz spiral on the other side, using Newton's method again. In this way we can algorithmically find any nucleus or Misiurewicz point in the structure of the embedded Julia set.

Some images should make this clearer at this point: blue means Newton's method for nucleus, red means Newton's method for Misiurewicz point, nuclei are labeled with their period, Misiurewicz points with preperiod and period in that order, separated by 'p'.

The second key insight was that the atom domain coordinate of the tip of the treeward branch at each successive level was scaled by a power of 1.5 from the one at the previous level. Because atom domain coordinates correspond to the unit disc, this means they are closer to the nucleus. This allowed an initial guess for finding the Misiurewicz point at the tip more precisely (the first insight does only apply to "top-level" embedded Julia sets, not their morphings - there is a "symmetry trap" that breaks Newton's method because the boundary of the basins of attraction passes through the point we want to start from). I implemented a Newton's method iteration to find a point with a given atom domain coordinate. This relationship is only true in the limit, so the input to the automatic morphing algorithm starts at the first morphing, rather than the top level embedded Julia set.

My first test was quite challenging: to morph a tree with length 7 arms, from an embeddded Julia set at angled internal address:

1_{1/2}→2_{1/2}→3_{2/5}→15_{4/7}→88

The C code (full link at the bottom) that sets up the parameters for this morphing looks like this:

#ifdef EXAMPLE_1 const char *embedded_julia_ray = ".011100011100011011100011011100011100011100011011100011011100011100011100011011100011100001110001101110010010010010010010010001110001110001101110001101110001110001110001101110001101110001110001110001101110001110000111000110111(001)"; int ray_preperiod = 225; int ray_period = 3; double _Complex ray_endpoint = -1.76525599938987623396492597243303e+00 + 1.04485517375987067290733632798876e-02 * I; int influencing_island_period = 3; int embedded_julia_set_period = 88; int denominator_of_rotation = 5; int arm_length = 7; double view_size_multiplier = 3600; #endif

The ray lands on the treeward-tip Misiurewicz point of the first morphed Julia set, this end point is cached to avoid long ray tracing computations. The next 4 numbers are involved in the iterative morphing calculations of the relevant periods and preperiods, with the arm length being the primary variable to adjust once the Julia set is found. The view size multiplier sets how to zoom out from the central morphed figure to frame the result nicely, maybe I can find a good heuristic to determine this based on arm length.

The morphing looks like this:

The second example is similar, starting with the island with this angled internal address, with tree morphing arm length 9.

1_{1/2}→2_{1/2}→3_{1/2}→4_{1/2}→8_{1/15}→116_{1/2}→119

The third and final example (for now) is simpler still, starting at the island with this internal address, with tree morphing arm length 1.

1_{1/3}→3_{1/2}→4_{11/23}→89

The code for example 3 contains an ugly hack, because the method for guessing the location of the next Misiurewicz point (for starting Newton's method iterations) isn't good enough - the radius is accurate, but the angle is not - my atom domain coordinate method is clearly not the correct one in general...

Here are the timings in seconds for calculating the coordinates (not parallelized) and rendering the images (I used m-perturbator-offline at 1280x720, the parallel efficiency is somewhat low because it doesn't know the center point is already a good reference and it tries to find one in the view - it would be much faster if I let it take the primary reference as external input - more things TODO):

morph | coordinates | image rendering | |||||||
---|---|---|---|---|---|---|---|---|---|

eg1 | eg2 | eg3 | eg1 | eg2 | eg3 | ||||

real | user | real | user | real | user | ||||

1 | 0 | 0 | 0 | 0.633 | 2.04 | 0.625 | 2.00 | 0.551 | 1.74 |

2 | 0 | 0 | 0 | 0.817 | 2.41 | 0.943 | 2.31 | 0.932 | 3.19 |

3 | 0 | 0 | 0 | 1.16 | 3.56 | 1.43 | 4.53 | 1.26 | 4.19 |

4 | 1 | 0 | 0 | 1.37 | 4.38 | 1.86 | 6.06 | 1.73 | 5.86 |

5 | 1 | 2 | 1 | 2.29 | 7.45 | 3.43 | 11.4 | 2.67 | 8.78 |

6 | 2 | 4 | 2 | 3.95 | 12.3 | 5.73 | 18.4 | 4.26 | 14.9 |

7 | 10 | 14 | 2 | 7.42 | 23.6 | 8.66 | 26.7 | 6.90 | 21.1 |

8 | 24 | 37 | 7 | 28.2 | 95.1 | 42.4 | 142 | 12.6 | 36.4 |

9 | 92 | 155 | 27 | 63.7 | 257 | 92.6 | 292 | 21.5 | 63.5 |

10 | 288 | 442 | 51 | 141 | 419 | 207 | 609 | 77.5 | 263 |

total | 418 | 654 | 90 | 259 | 774 | 372 | 1120 | 137 | 430 |

The code is part of my mandelbrot-numerics project. You also need my mandelbrot-symbolics project to compile the example program, and you may also want mandelbrot-perturbator to render the output (note: the GTK version is currently hardcoded to 65536 maximum iteration count, which isn't enough for deeper morphed Julia sets - adding runtime configuration for this is my next priority). Other deep zoomers are available, for example my Kalles Fraktaler 2 + GMP fork with Windows binaries available (that also work in WINE on Linux).

]]>The Mandelbrot set contains hyperbolic components (cardioid-like and circle-like regions), each with a super-attracting periodic nucleus at its center. The image above labels some with their periods (click for bigger).

The Mandelbrot set contains smaller copies of itself, with the periods all multiplied by a factor. The image above is the period 3 island in the antenna of the main set. The multiplication is called tuning or renormalization, and occurs because \(P\) iterations of \(z^2 + c\) are locally equivalent to \(1\) iteration of \(Z^2 + C\) where \(Z, C\) are a linear change of variable from \(z, c\).

Above are the two upper period 15 child bulbs at internal angles 1/5 and 2/5, with parent the period 3 cardioid. You can see that the islands in the primary antennae (of which there are 5) attached to the 1/5 bulb go up by 3 in period when you go 1 step around, but the corresponding ones of the 2/5 bulb go up by 3 in period when you go 2 steps around. This is described in the paper:

Geometry of the Antennas in the Mandelbrot Set

R.L. Devaney and M. Moreno-Rocha

April 11, 2000

In the Mandelbrot set, the bulbs attached directly to the main cardioid are called the p/q -bulbs. The reason for this is that the largest component of the interior of these bulbs consists of c-values for which the quadratic function \(Q_c (z) = z^2 + c\) admits an attracting cycle with rotation number p/q. In this paper we give a geometric method to read off p/q from the geometry of the antenna attached to the bulb.

The island copies are decorated with hairy filaments, and around islands in the hairs are embedded Julia sets, structures of filaments that look similar to Julia sets at the corresponding location to the influencing island. Hairs in the "seahorse valley" of an island will have embedded Julia sets that look like those for parameters in the "seahorse valley" of the main part of the Mandelbrot set (near \(-0.75 + 0.00 i\)).

This pattern carries over to the embedded Julia sets surrounding the period 88 islands in the hairs either side of the period 15 bulbs. Above are the 1/5, the periods go up by 3 going 1 step around the spirals with 5 arms. Below are the 2/5, the periods go up by 3 going 2 steps around the spirals. Note also that the spirals turn in opposite ways according to which side of the bulb the embedded Julia set is, but the direction of increase of the periods is the same in both cases.

Finally, note that the rays at each increasing period divide the embedded Julia set in half, and there is one island of period 3 higher in each part. So there is 1 island of period 88, 2 of 91, 4 of 94, 8 of 97, and so on. This binary subdivision property is not unique to these specific Julia sets. I conjecture that it holds for all of them, apart from possibly those in the filaments heading to the cardioid cusp or from the antenna tip of an island.

Future work includes investigating more embedded Julia sets to check that the conjecture holds, possibly proving it via combinatorial arguments to do with external angles and rays, and extending the investigation to doubly embedded Julia sets which appear when passing close to two islands in a deeper zoom.

PS: the diagrams were made with the new GTK GUI for mandelbrot-perturbator, which I ported from my old book project code. I added "plain" rendering to the library too, it's much faster at low zoom levels, especially because of the interior checking (I still need to add interior checking to the perturbation codepath...). You can download the parameters used for the images in this post: patterns-in-embedded-julia-sets.tbz.

]]>I benchmarked some Mandelbrot set renderers. Click pictures for bigger versions. The corresponding deep zoom images are these:

Location credits:

Olbaid-ST Deep Mandelbrot Zoom 023 |
Dinkydau Evolution Of Trees |
Dinkydau Ssssssssss |

(self-made) | (self-made) | (self-made) |

Traditional deep zoom rendering with arbitrary precision calculations has a constant cost per pixel. This is visiable on the graph as horizontal lines for the renderer MDZ (Mandelbrot Deep Zoom), version 0.1.3 is an unreleased version with some small changes I made to allow the benchmarks to be run from a shell script. On the graph bottom right you can see native machine precision in MDZ is much faster than any perturbation renderer.

Perturbation techniques allow native precision to be used for the bulk of the calculations, as deltas from an arbitrary precision reference. Series approximation techniques allow many per-pixel iterations to be skipped entirely. There is some per-image overhead, but the eventual per-pixel cost is lower when the image size increases. This is visible on the graph as downward sloping lines for the renderer Kalles Fraktaler, version 2.12.5 will be released next month with some bug fixes and command line rendering support, and the renderer mandelbrot-perturbator, which is still highly experimental.

Each of these renderers has two lines, with different thresholds for Pauldelbrot's glitch detection heuristic. One conclusion to be drawn is that the threshold has minimal impact on mandelbrot-perturbator render times, while the higher threshold (necessary for correctness with some locations) can slow Kalles Fraktaler down by a significant amount. Kalles Fraktaler is typically slower than mandelbrot-perturbator with this more accurate mode.

A final conclusion is that while mandelbrot-perturbator flattens out to a constant low cost per pixel as the image size increases, at some locations Kalles Fraktaler starts to slow down further (the lines slope upwards). This indicates some performance bug that I hope to investigate at some point next month. In the mean time tiled rendering might be cheaper.

These benchmarks represent 20 days of (single core) CPU time on a quad core AMD Athlon II X4 640 Processor underclocked to 2.3GHz due to thermal issues. The benchmark data is in the Kalles Fraktaler 2 source code repository.

]]>The Mandelbrot set is approximately self-similar, containing miniature baby Mandelbrot set copies. However, all of these copies are distorted, because there is only one perfect circle in the Mandelbrot set. The complex-valued size estimate can be used as a multiplier for looping zoom animations, though the difference in decorations and visible distortion make the seam a little jarring. Here are some examples:

period \(3\) near \(-2\)

period \(4\) near \(i\)

period \(5\) near \(-1.5 + 0.5 i\)

The trick to the looping zoom is to find an appropriate center: if the nucleus of the baby is \(c\) and the complex size is \(r\), there is another miniature copy near the baby around \(c + r c\) with size approximately \(r^2\). Taking the limit gives a geometric progression:

\[c + r c + r^2 c + \cdots = \frac{c}{1 - r}\]

Here's the code used to render the images (also found in the mandelbrot-graphics repository):

#include <stdio.h> #include <mandelbrot-graphics.h> int main(int argc, char **argv) { (void) argc; (void) argv; const double _Complex r0 = 1; const double _Complex c0 = 0; int periods[3] = { 3, 4, 5 }; double _Complex c1s[3] = { -2, I, -1.5 + I * 0.5 }; int w = 512; int h = 512; m_pixel_t red = m_pixel_rgba(1, 0, 0, 1); m_pixel_t black = m_pixel_rgba(0, 0, 0, 1); m_pixel_t white = m_pixel_rgba(1, 1, 1, 1); double er = 600; int maxiters = 1000; m_image *image = m_image_new(w, h); if (image) { m_d_colour_t *colour = m_d_colour_minimal(red, black, white); if (colour) { for (int k = 0; k < 3; ++k) { int period = periods[k]; double _Complex c1 = c1s[k]; m_d_nucleus(&c1, c1, period, 64); double _Complex r1 = m_d_size(c1, period); for (int frame = 0; frame < 50; ++frame) { double f = (frame + 0.5) / 50; double _Complex r = cpow((r1), f) * cpow((r0), 1 - f); double _Complex c = c1 / (1 - r1); m_d_transform *rect = m_d_transform_rectangular(w, h, 0, 1); m_d_transform *move1 = m_d_transform_linear(- c / 2.25, 1); m_d_transform *zoom = m_d_transform_linear(0, r * 2.25); m_d_transform *move2 = m_d_transform_linear(c, 1); m_d_transform *rm1 = m_d_transform_compose(rect, move1); m_d_transform *zm2 = m_d_transform_compose(zoom, move2); m_d_transform *transform = m_d_transform_compose(rm1, zm2); m_d_render_scanline(image, transform, er, maxiters, colour); char filename[100]; snprintf(filename, 100, "%d-%02d.png", k, frame); m_image_save_png(image, filename); m_d_transform_delete(transform); m_d_transform_delete(zm2); m_d_transform_delete(rm1); m_d_transform_delete(move2); m_d_transform_delete(zoom); m_d_transform_delete(move1); m_d_transform_delete(rect); } } m_d_colour_delete(colour); } m_image_delete(image); } return 0; }

I used ImageMagick to convert each PNG to GIF, then gifsicle to combine into animations.

]]>The Mandelbrot is asymptotically self-similar about pre-periodic Misiurewicz points. The derivative of the cycle (with respect to \(z\)) can be used as a multiplier for seamlessly looping zoom animations. Here are some examples:

const dvec2 c0 = dvec2(-0.22815549365396179LF, 1.1151425080399373LF); const int pre = 3; const int per = 1;

const dvec2 c0 = dvec2(-0.10109636384562216LF, 0.9562865108091415LF); const int pre = 4; const int per = 3;

(The above example's Misiurewicz point has period 1, but using 3 here avoids rapid spinning.)

const dvec2 c0 = dvec2(-0.77568377LF, 0.13646737LF); const int pre = 24; const int per = 2;

Here is the rest of the code that made the images, it's for Fragmentarium with
my (as yet unreleased, but coming soon) `Complex.frag`

enhancements
for dual-numbers and double-precision:

]]>#version 400 core #include "Complex.frag" #include "Progressive2D.frag" uniform float time; // insert snippets from above in here to choose image const double r0 = 0.00001LF; vec3 color(vec2 p) { // calculate multiplier for zoom dvec4 z = cVar(0.0LF); dvec4 c = cConst(c0); for (int i = 0; i < pre; ++i) z = cSqr(z) + c; z = cVar(cVar(z)); for (int i = 0; i < per; ++i) z = cSqr(z) + c; dvec2 m = r0 * dvec2(cPow(vec2(cInverse(cDeriv(z))), mod(time, float(per)) / float(per))); const int maxiters = 1000; const double er2 = 1000.0LF; c = cVar(c0 + cMul(m, p)); z = cConst(0.0LF); double pixelsize = cAbs(m) * double(length(vec4(dFdx(p), dFdy(p)))); int i; for (i = 0; i < maxiters; ++i) { z = cSqr(z) + c; if (cNorm(z) > er2) { break; } } if (i == maxiters) { return vec3(1.0, 0.7, 0.0); } else { double de = 2.0 * cAbs(z) * double(log(float(cAbs(z)))) / cAbs(cDeriv(z)); float grey = tanh(clamp( float(de/pixelsize), 0.0, 8.0 )); return vec3(grey); } }

Wolf Jung's Mandel's "algorithm 9" allows locating zeroes of the iterated polynomial at a certain period where 4 colours meet. But I wanted to find the zeroes for lots of periods all at once. Previously I did this in a way that didn't scale efficiently to deep zooms, so I adapted the "algorithm 9" technique. Not implemented yet is the extension of this code to use perturbation techniques for deep zooms, but it should be perfectly possible.

The first thing to do is initialize the array of \(c\) values, here I use my mandelbrot-graphics library as the support code (not shown here) uses it for imaging:

void initialize_cs(int m, int n, m_d_transform *t, double _Complex *cs) { #pragma omp parallel for for (int j = 0; j < n; ++j) { for (int i = 0; i < m; ++i) { double _Complex c = i + I * j; double _Complex dc = 1; m_d_transform_forward(t, &c, &dc); int k = i + j * m; cs[k] = c; } } }

Then in the iteration step, calculate a flag for which quadrant the \(z\) iterate is in. This is set as a bit mask, so ORing many masks together corresponds to set union:

void step_zs(int mn, char *qs, double _Complex *zs, const double _Complex *cs) { #pragma omp parallel for for (int i = 0; i < mn; ++i) { // load double _Complex c = cs[i]; double _Complex z = zs[i]; // step z = z * z + c; // compute quadrant char q = 1 << ((creal(z) > 0) | ((cimag(z) > 0) << 1)); // store zs[i] = z; qs[i] = q; } }

Now the meat of the algorithm: it scans across the data with a 3x3 window, to find where all 4 colours meet in one small square. Then if that happens, check that the 3x3 square has a local minimum at its center, which means that the point found is really near a zero (a proof for that assertion follows immediately from the minimum modulus principle).

int scan_for_zeroes(int m, int n, int ip, int *ops, double _Complex *ocs, const char *qs, const double _Complex *zs, const double _Complex *ics) { int o = 0; // loop over image interior, to avoid tests in inner 3x3 loop #pragma omp parallel for for (int j = 1; j < n - 1; ++j) { for (int i = 1; i < m - 1; ++i) { // find where 4 quadrants meet in 3x3 region char q = 0; for (int dj = -1; dj <= 1; ++dj) { int jdj = j + dj; for (int di = -1; di <= 1; ++di) { int idi = i + di; int kdk = idi + jdj * m; q |= qs[kdk]; } } if (q == 0xF) { // 4 quadrants meet, check for local minimum at center double minmz = 1.0/0.0; for (int dj = -1; dj <= 1; ++dj) { int jdj = j + dj; for (int di = -1; di <= 1; ++di) { int idi = i + di; int kdk = idi + jdj * m; double mz = cabs(zs[kdk]); minmz = mz < minmz ? mz : minmz; } } int k = i + j * m; double mz = cabs(zs[k]); if (mz <= minmz && minmz < 1.0/0.0) { // we found a probable zero, output it double _Complex ic = ics[k]; int out; #pragma omp atomic capture out = o++; ops[out] = ip; ocs[out] = ic; } } } } return o; }

To be safe, the output arrays should be sized at least the desired number of elements plus the number of pixels in the image (which is the maximum number that can be output in one pass). Most of the extra space will be unused by the time the stopping condition (enough space left) is reached.

An earlier version was several times slower, partly due to caching
`cabs()`

calls for every pixel, though only very few pixels
are near a zero at any given iteration.

It is known that in the Mandelbrot set, the smallest hyperbolic component of a given period is in the utter west of the antenna. Each atom is 16 times smaller and 4 times nearer the tip, which also means each successive atom domains is 4 times smaller (they all meet at the tip). This gives atom size \(O(16^{-p})\) and domain size \(O(4^{-p})\) where \(p\) is the period. I verified this relationship with some code:

#include <stdio.h> #include <mandelbrot-numerics.h> int main() { for (int period = 1; period < 64; period += 1) { printf("# period %d\n", period); fflush(stdout); mpfr_prec_t prec = 16 * period + 8; mpc_t guess, nucleus, size; mpc_init2(guess, prec); mpc_init2(nucleus, prec); mpc_init2(size, prec); mpc_set_d(guess, -2, MPC_RNDNN); while (prec > 2) { mpfr_prec_round(mpc_realref(guess), prec, MPFR_RNDN); mpfr_prec_round(mpc_imagref(guess), prec, MPFR_RNDN); m_r_nucleus(nucleus, guess, period, 64); printf("%ld ", prec); fflush(stdout); m_r_size(size, nucleus, period); mpc_norm(mpc_realref(size), size, MPFR_RNDN); mpfr_log2(mpc_realref(size), mpc_realref(size), MPFR_RNDN); mpfr_div_2ui(mpc_realref(size), mpc_realref(size), 1, MPFR_RNDN); mpfr_printf("%Re ", mpc_realref(size)); fflush(stdout); m_r_domain_size(mpc_realref(size), nucleus, period); mpfr_prec_round(mpc_realref(size), 53, MPFR_RNDN); mpfr_log2(mpc_realref(size), mpc_realref(size), MPFR_RNDN); mpfr_printf("%Re\n", mpc_realref(size)); fflush(stdout); prec--; mpfr_prec_round(mpc_realref(nucleus), prec, MPFR_RNDN); mpfr_prec_round(mpc_imagref(nucleus), prec, MPFR_RNDN); } printf("\n\n"); fflush(stdout); } return 0; }

Plotting the output shows it is so:

This makes a rough worst case estimate of the precision required to
accurately compute a size estimate (whether for atom or domain) from the
nucleus of the atom be around \(4 p\) bits. *(EDIT 2017-11-08 an earlier version of
this post incorrectly stated \(16 p\) bits, but I forgot to take the log2
properly when converting from size to bits required.)* But it turns out that
(at aleast for this sequence of atoms heading to the tip of the antenna) a
lot less precision is actually required. Here's a graph showing a seam
where the size estimate breaks down when the precision gets too low, and
doing some maths shows that the seam is at precision \(2 p\), a factor of
\(2\) better than the first guess:

It remains to investigate other sequences of atoms, to see how they behave. Chances are the \(2 p\) bits of precision required estimate is only necessary in rare cases like heading toward filament tips, and that aesthetically-chosen iterated Julia morphing atoms (for example) will be very much larger than would be expected from their period.

]]>Above is what atom domain rendering typicaly looks like. But there is a wealth of information in the periods of islands in the filaments, which isn't visible unless you scan for periods and annotate the image with labels (as in my previous post). After some experimentation, I figured out a way to make their domains visible and moreover get a domain size estimate (useful for labelling).

The hack I came up with is in two parts: the first part is in the iteration calculations. Atom domain calculation typically works like this:

for (iteration = 1; iterating; ++iteration) { z = z * z + c; if (abs(z) < minimum) { minimum = abs(z); p = iteration; zp = z; } ... }

The filtering hack adds another pair of \((q, z_q)\) to the existing \((p, z_p)\), only this time filtered by a function of iteration number:

... if (abs(z) < minimum2 && accept(iteration)) { minimum2 = abs(z); q = iteration; zq = z; } ...

For the image above the `accept()`

filter function was:

bool accept(int p) { return p >= 129 && (p % 4) != 1; }

The second part of the hack is filtering when colouring, to allow the original regular atom domains to be visible too (without this part they get squashed by the new domains). Here's the colouring hack for the image above:

... int p = (computed p); double _Complex zp = (computed zp); if (reject(p)) { p = (computed q); zp = (computed zq); } // colour using p and zp ... bool reject(int p) { return p < 129; }

The image below uses slightly different filters:

bool accept(int p) { return p > 129 && (p % 4) == 2; } bool reject(int p) { return p <= 129 || (p % 4) == 1; }

I did manage to find some filters that showed domains in the filaments at this deeper zoom level, but I lost them while making other changes and I've been unable to recreate them - very frustrating.

These images show another minor development, colouring the domains according to the quadrants of \(z_p\), which meet at the nucleus. I was inspired to do this by Algorithm 9 of Wolf Jung's Mandel which shows the zeros of a particular period (I wanted to show all periods at once). This forms the basis of an improved periodicity scan algorithm, which iterates an image one step at a time, scanning for meeting quadrants at a local minimum of \(z_p\) and recording their locations and periods to an output buffer. This new algorithm is much more scalable to deep zooms (it will work fine with perturbation techniques, though I haven't implemented that yet - the previous algorithm definitely needed lots of arbitrary precision calculations). It might even be possible to accelerate on GPU, its parallelism is amenable.

The size estimate for the filtered domains is fairly similar to the atom domain size estimate I derived previously:

double filtered_domain_size(double _Complex nucleus, int period) { double _Complex z = 0; double _Complex dc = 0; double zq = 1.0/0.0; for (int q = 1; q <= period; ++q) { dc = 2 * z * dc + 1; z = z * z + nucleus; double zp = cabs(z); if (q < period && zp < zq && accept(q)) { zq = zp; } } return zq / cabs(dc); }

This can return infinity if the filter is too restrictive, but domain size of the period 1 cardioid is infinite too, so it's not a big deal for the caller to check and deal with it as appropriate.

I'll clean up the code a bit and push to my mandelbrot-* repositories soon.

]]>The Mandelbrot set contains many hyperbolic components (cardioid-like and disc-like regions), with hairy filaments connecting them in a tree-like way. Each component has a nucleus at its center, which has a periodic orbit containing 0. Each component is surrounded by an atom domain, which for discs has about 4 times the radius (the relationship for cardioids is less regular, but often has about the square root of the size). Labelling a picture of the Mandelbrot set with the periods can provide insights into its deeper structure, and most of the time using the atom domain size as the label size works pretty well.

Inspired by a feature of Power MANDELZOOM (scroll down to the 3rd image titled "Embedded Julia set") that locates periodic points that are too deep to see, I implemented a grid scan algorithm to find periodic points. I vaguelly recall Robert P. Munafo explaining this algorithm to me in private email, so most of the credit belongs with him. The font size variation is all mine though.

Using my mandelbrot-numerics and mandelbrot-graphics libraries, the period scan works like this:

// scan successively finer grids for periodsfor (int grid = mingridsize << 8; grid >= mingridsize; grid >>= 1) for (int y = grid/2; y < h; y += grid) for (int x = grid/2; x < w; x += grid) { double _Complex c0 = x + I * y; double _Complex dc0 = grid;// transform pixel coodinates to the 'c' planem_d_transform_forward(transform, &c0, &dc0);// find the period of a nucleus within a large box// uses Robert P. Munafo's Jordan curve methodint p = m_d_box_period_do(c0, 4.0 * cabs(dc0), maxiters); if (p > 0)// refine the nucleus location (uses Newton's method)if (m_converged == m_d_nucleus(&c0, c0, p, 16)) {// verify the period with a small box// if the period is wrong, the size estimates will be way offas[atoms].period = m_d_box_period_do(c0, 0.001 * cabs(dc0), 2 * p); if (as[atoms].period > 0) { as[atoms].nucleus = c0;// size of component using algorithm from ibiblio.org M-set e-notesas[atoms].size = cabs(m_d_size(c0, as[atoms].period));// size of atom domain using algorithm from an earlier blog post of mineas[atoms].domain_size = m_d_domain_size(c0, as[atoms].period);// shape of component (either cardioid or disc) after Dolotin and Morozov (2008 eq. 5.8)as[atoms].shape = m_d_shape_discriminant(m_d_shape_estimate(c0, as[atoms].period)); atoms++; } } }

This does give duplicates in the output array, but these can be removed later (I found it better to use a mask image (2D array) in which I marked circles around each label, after checking whether the location has already been marked, than to use a quadratic-time loop comparing locations with a threshold distance). Depending on the size of the circles, this also helps prevents messy label overlaps.

One problem is that the range of atom domain sizes can be huge, with domains in filaments being orders of magnitude smaller than the sizes present in embedded Julia sets. This can be fixed with some hacks:

The image above calculates the font size like this:

// convert to pixel coordinatesint p = as[a].period; double _Complex c0 = as[a].nucleus; double _Complex dc0 = p == 1 ? 1 : as[a].domain_size;// period 1 domain is infinitem_d_transform_reverse(transform, &c0, &dc0);// shrink disc labels a bit to avoid overlapsdouble fs = (as[a].shape == m_cardioid ? 1 : 0.5) * cabs(dc0);// rescale filament labels using properties of periods in this particular embedded Julia setif ((p % 4) != (129 % 4)) fs = 8 * log2(fs) + maxfontsize;// ensure a minimum label sizefs = fmax(fs, minfontsize);

The image below replaces the specific period property
`(p % 4) != (129 % 4)`

with `(p % 4) != 0`

. I'll
figure out how best to generalize this and allow command-line arguments,
at the moment I've just been editing the code and recompiling to adapt
to different views, hardly ideal.

You can click the pictures for bigger versions (a few MB each). The
last 3 images are centered on
`-1.9409856638151786271684397e+00 + 6.4820395780451436662598436e-04 i`

.
After a few more cleanups I'll push the code to my mandelbrot-graphics
git repository linked above.