I have a 30-year-old PC that I last used in 2008, at which time it was running SmallLinux2. That project is abandoned, modern Linux no longer supports the hardware, and I forgot the password, so I reinstalled the machine with FreeDOS 1.3, which is still actively developed and worked fine. I used a USB floppy disk drive on my modern Linux machine to write the 6 high density floppy disks needed to install it - make sure to verify the disks or the installer may give up with an uninformative error message if a disk has an error, and you'll have to start from the beginning again.

FreeDOS 386 version took about 15MB of the 386's 50MB hard drive. FreeDOS
recommends OpenWATCOM C compiler, but the DOS installer is 80MB. So I went
with Borland Turbo C 2.01, repacking the TC folder of the
11MB megapack zip so it would fit on a single floppy; I figured if I need the
24MB of sample code I would transfer it later as needed. You can try the IDE
`tc.exe`

on the Internet Archive page (DOSBOX in a web browser).

Programming in (pre-C89) C is a bit thankless, so I decided to install
Lua. I tried to compile in the src folder with `c:\tc\tcc.exe -c *.c`

.
Straight away I got an error about "line 1 macro expansion too long"
or similar, running "unix2dos" on the source files on Linux and copying
across made that go away, but there were lots of errors. Eventually I
worked out that Borland Turbo C's offsetof() implementation was broken,
so I replaced that with a `#define OFFSETOF(T,f) ...`

(see
attached patch file for full details). Remaining errors involved missing
`locale.h`

, so I stubbed out the required functionality at the end of
`luaconf.h`

(and touched `locale.h`

so that it could be included). I also
needed to `#define CLOCKS_PER_SEC CLK_TCK`

. Another few things:
no `long long`

, Windows-style \ path separator, ...

I got it compiling eventually (had to add the directory `c:\tc`

to the `path`

),
but it wouldn't link, because the _TEXT was bigger than 64kB or something.
I recompiled with Huge model (32bit pointers in all circumstances) using `-ms- -mh`

flags,
then it failed to link because `lua.obj`

and `luac.obj`

both defined main, and there were missing symbols for locale functions
(this tale is a little out of temporal order...). As I was editing
`luaconf.h`

to stub out locale functions, the 386 crashed hard,
then subsequent reboots failed with memory parity errors, even after powering
off overnight in case it was overheating. The machine is broken :(

I didn't have a backup of the code changes, but I did have notes on paper, so I recreated the code diff in an emulator:

qemu-img create -f qcow2 C.img 1G cp FD13-FloppyEdition/144m/x86* . mv x86BOOT.img A0.img mv x86DSK01.img A1.img mv x86DSK02.img A2.img mv x86DSK03.img A3.img mv x86DSK04.img A4.img mv x86DSK05.img A5.img qemu-system-i386 -cpu 486 -m 16M -hdc C.img -fda A0.img -boot a # install FreeDOS 1.3 # use ctrl-alt-2, "change floppy0 A1.img", ctrl-alt-1 to change disks # to transfer from vm guestmount --format=qcow2 -a C.img -m /dev/sda1 --ro /mnt/c guestunmount /mnt/c # to transfer to vm # I used physical floppy disk with qemu-system-i386 ... -fda /dev/sdX

To prepare Lua sources (on Linux) for compilation in DOS:

tar xaf lua-5.4.4.tar.gz cd lua-5.4.4 patch -p1 < ../lua-5.4.4-for-dos-with-borland-turbo-c-2.01.patch cd src touch locale.h unix2dos * cd ../.. mv lua-5.4.4 lua zip -9 -r lua.zip lua

To compile the prepared Lua sources in DOS:

path # add c:\tc to existing path path=...;c:\tc cd c:\lua\src tcc -w- -ms- -mh -I. -c *.c del luac.obj tcc -w- -ms- -mh -elua.exe *.obj del lua.obj tcc -w- -ms- -mh -I. -c luac.c tcc -w- -ms- -mh -eluac.exe *.obj cd .. mkdir bin move src/lua.exe bin move src/luac.exe bin cd bin lua for i = 1,10 do print(i) end # ctrl-c to exit

Download the patch: lua-5.4.4-for-dos-with-borland-turbo-c-2.01.patch

If anyone knows a cause or fix for this error on a Commodore 386 with a Phoenix BIOS from 1991 (4MB RAM, 50MB HDD, 20MHz CPU, no FPU), I'd love to know:

]]>064K Base Memory, 00000K extended Memory parity failure at 010000-01FFFF Decreasing available memory Memory parity failure at 100000-10FFFF Decreasing available memory Invalid configuration information I/O card parity interrupt at F000:B9A6 Type (S)hut of NMI, (R)eboot, other keys to continue

Event coming up in 4 weeks today:

Celebrating ten years of Algorave over two rooms at Corsica Studios London, 2022-03-31!

Featuring:

Bad Circulation

Claude Heiland-Allen

darch

Deep Vain

digital selves

Eye Measure

Heavy Lifting vs Graham Dunning

hellocatfood

hmurd

Hortense

ideoforms

innocent

Joana Chicau

Luuma

Mahalia HR

Michael-Jon Mizra

rumblesan

Ulysses Popple

Yaxu

+ more TBA!

Tickets up -> ra.co/events/1499155more info at algorave.com/london

I'll be using Clive, my system for livecoding audio in the C programming language, which also turned 10 years old last month.

**EDIT**: you can listen to a digital recording
diffcast
of my set, I got some numbers wrong in the middle by a factor of 100 or so
leading to unintentional noise. Misses the atmosphere of the live event, and
the big sound system (unless you have one at home).

Since last year's article on deep zoom theory and practice, two new developments have been proposed by Zhuoran on fractalforums.org: Another solution to perturbation glitches.

The first technique ("**rebasing**"), explained in the
first post of the forum thread, means resetting the reference iteration to the
start when the pixel orbit (i.e. \(Z+z\), the reference plus delta) gets
near a critical point (like \(0+0i\) for the Mandelbrot set). If there is
more than one critical point, you need reference orbits starting at each
of them, and this test can switch to a different reference orbit. For
this case, pick the orbit \(o\) that minimizes \(|(Z-Z_o)+z|\), among the
current reference orbit at iteration whatever, and the critical point
orbits at iteration number \(0\). Rebasing means you only need as many
reference orbits as critical points (which for simple formulas like the
Mandelbrot set and Burning Ship means only one), and glitches are avoided
rather than detected, needing to be corrected later. This is a big
boost to efficiency (which is nice) and correctness (which is much more
important).

Rebasing also works for hybrids, though you need more reference orbits, because the reference iteration can be reset at any phase in the hybrid loop. For example, if you have a hybrid loop of "(M,BS,M,M)", you need reference orbits for each of "(M,BS,M,M)", "(BS,M,M,M)", "(M,M,M,BS)" and "(M,M,BS,M)". Similarly if there is a pre-periodic part, you need references for each iteration (though for a zoomed in view, the minimum escaping iteration in the image determines whether they will be used in practice): "M,M,(BS,BS,M,BS)" needs reference orbits "M,M,(BS,BS,M,BS)", "M,(BS,BS,M,BS)" and the four rotations of "(BS,BS,M,BS)". Each of these phases needs as many reference orbits as the starting formula has critical points. As each reference orbit calculation is intrinsically serial work, and modern computers typically have many cores, the extra wall-clock time taken by the additional references is minimal because they can be computed in parallel.

The second technique ("**bilinear approximation**") is
only hinted at in the thread. If you have a deep zoom, the region of
\(z\) values starts very small, and bounces around the plane typically
staying small and close together, in a mostly linear way, except for
when the region gets close to a critical point (e.g. \(x=0\) and \(y=0\) for the
Mandelbrot set) or line (e.g. either \(x=0\) or \(y=0\) for the Burning Ship),
when non-linear stuff happens (like complex squaring, or absolute
folding). For example for the Mandelbrot set, the perturbed iteration

\[ z \to 2 Z z + z^2 + c \]

when \(Z\) is not small and \(z\) is small, can be approximated by

\[ z \to 2 Z z + c \]

which is linear in \(z\) and \(c\) (two variables call this "bilinear"). In particular, this approximation is valid when \( z^2 << 2 Z z + c \), which can be rearranged with some handwaving (for critical point at \(0\)) to

\[ z < r = \max\left(0, \epsilon \frac{\left|Z\right| - \max_{\text{image}} \left\{|c|\right\}}{\left|J_f(Z)\right| + 1}\right) \]

where \(\epsilon\) is the hardware precision (e.g. \(2^{-24}\)), and \(J_f(Z) = 2Z\) for the Mandelbrot set. For Burning Ship replace \(|Z|\) with \(\min(|X|,|Y|)\) where \(Z=X+iY\). In practice I divide \(|Z|\) by \(2\) just to be extra safe. For non-complex-analytic functions I use the operator norm for the Jacobian matrix, implemented in C++ by:

template <typename real> inline constexpr real norm(const mat2<real> &a) { using std::max; using std::sqrt, ::sqrt; const mat2<real> aTa = transpose(a) * a; const real T = trace(aTa); const real D = determinant(aTa); return (T + sqrt(max(real(0), sqr(T) - 4 * D))) / 2; } template <typename real> inline constexpr real abs(const mat2<real> &a) { using std::sqrt, ::sqrt; return sqrt(norm(a)); }

This gives a bilinear approximation for one iteration, which is not so useful. The acceleration comes from combining neighbouring BLAs into a BLA that skips many iterations at once. For neighbouring BLAs \(x\) and \(y\), where \(x\) happens first in iteration order, skipping \(l\) iterations via \(z \to A z + B c\), one gets:

\[\begin{aligned} l_{y \circ x} &= l_y + l_x \\ A_{y \circ x} &= A_y A_x \\ B_{y \circ x} &= A_y B_x + B_y \\ r_{y \circ x} &= \min\left(r_x, \max\left(0, \frac{r_y - |B_x| \max_{\text{image}}\left\{|c|\right\}}{|A_x|}\right) \right) \end{aligned}\]

This is a bit handwavy again, higher order terms of Taylor expansion are probably necessary to get a bulletproof radius calculation, but it seems to work ok in practice.

For a reference orbit iterated to \(M\) iterations, one can construct a BLA table with \(2M\) entries. The first level has \(M\) 1-step BLAs for each iteration, the next level has \(M/2\) combining neighbours (without overlap), the next \(M/4\), etc. It's best for each level to start from iteration \(1\), because iteration \(0\) is always starting from a critical point (which makes the radius of BLA validity \(0\)). Now when iterating, pick the BLA that skips the most iterations, among those starting at the current reference iteration that satisfy \(|z| < |r|\). In between, if no BLA is valid, do regular perturbation iterations, rebasing as required. You need one BLA table for each reference orbit, which can be computed in parallel (and each level of reduction can be done in parallel too, perhaps using OpenCL on GPU).

BLA is an alternative to series approximation for the Mandelbrot set, but it's conceptually simpler, easier to implement, easier to parallelize, has better understood stopping conditions, is more general (applies to other formulas like Burning Ship, hybrids, ...) - need to do benchmarks to see how it compares speed-wise before declaring an overall winner.

It remains to research the BLA initialisation for critical points not at \(0\), and check rebasing with multiple critical points: so far I've only actually implemented it for formulas with a single critical point at \(0\), so there may be bugs or subtleties lurking in the corners.

]]>Back in 2009 I posted a short video Reflex preview of truncated 4D polytopes. Today I revisited that old code, reimplementing the core algorithms in OpenGL Shader Language (GLSL) using the FragM environment.

3D polytopes are also known as polyhedra. They can be defined by their Schlaefli symbol, which looks like {4,3} for a cube. This can be understood as having faces made up of squares ({4}), arranged in triangles around each vertex ({3}). This notation extends to 4D, where a hypercube (aka tesseract) has symbol {4,3,3}, made up of cubes {4,3} with {3} around each vertex.

These symbols are all well and good, but to render pictures you need concrete measurements: angles, lengths, etc. In H.S.M. Coxeter's book Regular Polytopes, there is the solution in the form of some recursive equations. Luckily the recursion depth is bounded by the number of dimensions, which is small (either 3 or 4 in my case, though in principle you can go higher). This is important for implementation in GLSL, which disallows recursion. In any case, GLSL doesn't have dynamic length lists, so different functions are needed for different length symbols.

I don't really understand the maths behind it (I don't think I did even when I first wrote the code in Haskell in 2009), but I can describe the implementation. The goal is to find 4 vectors, which are normal to the fundamental region of the tiling of the (hyper)sphere. Given a point anywhere on the sphere, repeated reflections through the planes of the fundamental region can eventually (if you pick the right ones) get you into the fundamental region. Then you can do some signed distance field things to put shapes there, which will be tiled around the whole space when the algorithm is completed.

Starting from the Schlaefli symbol {p,q,r} (doing 4D because it has some tricksy bits, 3D is largely similar), the main task is to find the radii of the sub polytopes ({p,q}, {p}, {q,r}, etc). This is because these radii can be used to calculate the angles of the planes of the fundamental region, using trigonometry. The recursive formula starts here:

float radius(int j, ivec3 p)
{
return sqrt(radius2_j(j, p));
}

Here j will range from 0 to 3 inclusive, and the vector is {p,q,r}. Then radius2_j() evaluates using the radii squared of the relevant subpolytopes, according to j. I think this is an application of Pythagoras' theorem.

float radius2_j(int j, ivec3 p)
{
switch (j)
{
case 0: return radius2_0(p);
case 1: return radius2_0(p) - radius2_0();
case 2: return radius2_0(p) - radius2_0(p.x);
case 3: return radius2_0(p) - radius2_0(p.xy);
}
return 0.0;
}

The function radius2_0() is overloaded for different length inputs, from 0 to 3:

float radius2_0() { return 1.0; }
float radius2_0(int p) { return delta() / delta(p); }
float radius2_0(ivec2 p) { return delta(p.y) / delta(p); }
float radius2_0(ivec3 p) { return delta(p.yz) / delta(p); }

Here it starts to get mysterious, the delta funtion uses trigonometry to find the lengths. I don't know how/why this works, I copy/pasted from the book. Note that it looks recursive at first glance, but in fact each delta calls a different delta(s) with strictly shorter input vectors, so it's just a non-recursive chain of function calls.

float delta()
{
return 1.0;
}
float delta(int p)
{
float s = sin(pi / float(p));
return s * s;
}
float delta(ivec2 p)
{
float c = cos(pi / float(p.x));
return delta(p.y) - delta() * c * c;
}
float delta(ivec3 p)
{
float c = cos(pi / float(p.x));
return delta(p.yz) - delta(p.z) * c * c;
}

Now comes the core function to find the fundamental region: the cosines of the angles are found by ratios of successive radii, the sines are found by Pythagoras' theorem, 3 rotation matrices are constructed, then one axis vector is transformed. Finally these vectors are combined using the 4D cross product (which has 3 inputs), giving the final fundamental region (I'm not sure why the cross products are necessary, but I do know the main property of cross product is that the output is perpendicular to all of the inputs.). Note that some signs need wibbling, either that or permute the order of the inputs.

mat4 fr4(ivec3 pqr)
{
float r0 = radius(0, pqr);
float r1 = radius(1, pqr);
float r2 = radius(2, pqr);
float r3 = radius(3, pqr);
float c1 = r1 / r0;
float c2 = r2 / r1;
float c3 = r3 / r2;
float s1 = sqrt(1.0 - c1 * c1);
float s2 = sqrt(1.0 - c2 * c2);
float s3 = sqrt(1.0 - c3 * c3);
mat4 m1 = mat4(c1, s1, 0, 0, -s1, c1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
mat4 m2 = mat4(c2, 0, s2, 0, 0, 1, 0, 0, -s2, 0, c2, 0, 0, 0, 0, 1);
mat4 m3 = mat4(c3, 0, 0, s3, 0, 1, 0, 0, 0, 0, 1, 0, -s3, 0, 0, c3);
vec4 v0 = vec4(1, 0, 0, 0);
vec4 v1 = m1 * v0;
vec4 v2 = m1 * m2 * v0;
vec4 v3 = m1 * m2 * m3 * v0;
return (mat4
( normalize(cross4(v1, v2, v3))
, -normalize(cross4(v0, v2, v3))
, normalize(cross4(v0, v1, v3))
, -normalize(cross4(v0, v1, v2))
));
}

4D cross product can be implemented in terms of 3D determinants:

vec4 cross4(vec4 u, vec4 v, vec4 w)
{
mat3 m0 = mat3(u[1], u[2], u[3], v[1], v[2], v[3], w[1], w[2], w[3]);
mat3 m1 = mat3(u[0], u[2], u[3], v[0], v[2], v[3], w[0], w[2], w[3]);
mat3 m2 = mat3(u[0], u[1], u[3], v[0], v[1], v[3], w[0], w[1], w[3]);
mat3 m3 = mat3(u[0], u[1], u[2], v[0], v[1], v[2], w[0], w[1], w[2]);
return vec4(determinant(m0), -determinant(m1), determinant(m2), -determinant(m3));
}

The projection from 4D to 3D is stereographic, because signed distance fields are implicit we need both directions (one to go from input point in 3D to 4D, then after transformation / tesselation we need to go back to 3D to calculate distances):

vec4 unstereo(vec3 pos)
{
float r = length(pos);
return vec4(2.0 * pos, 1.0 - r * r) / (1.0 + r * r);
}
vec3 stereo(vec4 pos)
{
pos = normalize(pos);
return pos.xyz / (1 - pos.w);
}
float sdistance(vec4 a, vec4 b)
{
return distance(stereo(a), stereo(b));
}

The tiling is done iteratively (the limit of 600 is there because unbounded loops on a GPU are not really advisable, if this limit is set too low, e.g. 100, then visible artifacts occur - it's probably possible to prove an optimal bound somehow):

float poly4(vec4 r)
{
for (int i = 0, j = 0; i < 4 && j < 600; ++j)
{
if (dot(r, FR4[i]) < 0)
{
r = reflect(r, FR4[i]);
i = 0;
}
else
{
i++;
}
}
float de = 1.0 / 0.0;
// signed distance field stuff goes here
return de;
}

The user interface part of the code has some variables for selecting dimension and symmetry group (Schlaefli symbol). It also has 4 sliders for selecting the truncation amount in barycentric coordinates (which makes settings transfer in a meaningful way between polytopes), and 6 checkboxes for enabling different planes (there are 4 ways to choose the first axis and 3 left to choose from for the second axis, divided by 2 because the order doesn't matter).

vec4 s = inverse(transpose(FR4)) * vec4(BX, BY, BZ, BW);

The signed distance field stuff is quite straightforward in the end, though it took a lot of trial and error to get there. The first way I tried was to render tubes for line segments, by projecting the input point 'r' onto the planes of the fundamental region and doing an SDF circle:

float d = sdistance(s, r - FR4[0] * dot(r, FR4[0])) - thickness;

The planes are drawn by projecting 's' onto the cross product of the plane with 'r'. I don't know why this works:

vec4 p = normalize(cross4(FR4[0], FR4[1], r));
float d = sdistance(s, s - p * dot(s, p)) - 0.01;

Finally the DE() function for plugging into the DE-Raytracer.frag that comes with FragM has some animated rotation based on time, and the baseColor() function textures the solid with light and dark circles (actually slices of hyperspheres).

Full code download: reflex.frag.

]]>Fractal rendering using perturbation techniques for deep zooming needs floating point number types with large exponent range. The number types available are typically float (8 bit exponent), double (11 bit exponent), long double (usually either x87 80bit or 128 bit quad double, depending on CPU architecture) (15 bit exponent). Even 15 bits is not enough for very deep zooms, so software implementations are useful. Two are analyzed here: floatexp (which is a float with a separate 32bit exponent), and softfloat (two unsigned 32bit ints, with one containing sign and 31bit exponent).

I measured the CPU time in seconds to render 1920x1080 unzoomed Mandelbrot set with 100 iterations and 16 subframes, using a C++ perturbation technique inner loop templated on number type, on a selection of CPU architectures:

Ryzen 2700x desktop | Core 2 Duo laptop | AArch64 tablet | AArch64 raspi3 | ArmHF raspi4 | |||||
---|---|---|---|---|---|---|---|---|---|

clang-11 | gcc-10 | clang-11 | clang-11 | gcc-10 | clang-11 | gcc-10 | clang-11 | gcc-10 | |

float | 35.0 | 33.1 | 25.5 | 73.8 | 73.1 | 96.7 | 88.0 | 42.5 | 57.2 |

double | 42.46 | 33.6 | 26.7 | 82.4 | 78.0 | 96.7 | 87.8 | 42.1 | 67.5 |

long double | 88.8 | 42.1 | 49.9 | 1324 | 1343 | 1464 | 1464 | n/a | n/a |

floatexp | 216 | 209 | 453 | 936 | 1138 | 1081 | 1332 | 655 | 1131 |

softfloat | 172 | 120 | 265 | 634 | 670 | 762 | 770 | 365 | 606 |

Relative time vs float for each column:

Ryzen 2700x desktop | Core 2 Duo laptop | AArch64 tablet | AArch64 raspi3 | ArmHF raspi4 | |||||
---|---|---|---|---|---|---|---|---|---|

clang-11 | gcc-10 | clang-11 | clang-11 | gcc-10 | clang-11 | gcc-10 | clang-11 | gcc-10 | |

float | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |

double | 1.21 | 1.02 | 1.05 | 1.12 | 1.07 | 1 | 1 | 0.99 | 1.18 |

long double | 2.54 | 1.27 | 1.96 | 17.9 | 18.4 | 15.1 | 16.6 | n/a | n/a |

floatexp | 6.17 | 6.31 | 17.8 | 12.7 | 15.6 | 11.2 | 15.1 | 15.4 | 16.8 |

softfloat | 4.91 | 3.63 | 10.4 | 8.59 | 9.17 | 7.88 | 8.75 | 8.59 | 10.6 |

Conclusions:

- Use the fastest whose range is big enough.
- On x86_64, the best types are float, double, long double, softfloat.
- On aarch64, the best types are float, double, softfloat. Here long double is very slow.
- On armhf, the best types are float, double, softfloat. Here long double appears to be an alias for double.
- On no architecture is floatexp useful.
- Code compiled with gcc is generally faster than clang, except for armhf.

Still to be investigated is the relative performance when using OpenCL (including both CPU and GPU devices), and when compiled for web assembly using Emscripten.

]]>A new release: kf-2.15.5. Kalles Fraktaler 2 + is fast deep zooming Free Software for fractal graphics (Mandelbrot, Burning Ship, etc). Full change log:

kf-2.15.5 (2021-12-05)

- new: single-reference implementation for avoiding glitches (thanks Zhuoran https://fractalforums.org/f/28/t/4360/msg29835#msg29835); enabled by default; also supported in nanomb1

- known issue: does not work with every hybrid formula (only very simple cases work)
- known issue: may fail if there is more than one critical point
- new: start of support for convergent formulas

- known issue: convergent formulas are not supported in OpenCL
- known issue: convergent formulas are not supported with derivatives (this means neither analytic DE nor analytic slopes)
- new: many new formulas (thanks to Alexandre Vachon aka FractalAlex)
- new: Nova formula; variant implemented with critical point at 0 instead of 1, to avoid precision loss when deep zooming

- known issue: no OpenCL support yet
- known issue: no derivatives support yet
- known issue: Newton zooming does not work properly
- new: Separated Perpendicular formula (thanks Mr Rebooted); variant implemented with critical point at 0, and custom function to avoid precision loss when deep zooming

- known issue: no OpenCL support yet
- known issue: single reference method does not cure all glitches
- new: hybrid formulas support division operator (thanks FractalAlex)

- known issue: implementation is incomplete
- new: Triangle Inequality Average colouring algorithm can be enabled in Formula dialog; requires OpenCL; replaces final angle in phase T channel data

- known issue: likely to change in future versions, use at own risk
- known issue: disable Series Approximation for predictable results
- fix: Newton dialog uses a compact layout (by popular request)
- fix: Newton zooming functions are correctly linked into the EXE (only kf-2.15.4 was broken)
- fix: control-click to zoom correctly views framed rectangle (thanks CFJH)
- fix: NR zoom log should no longer go out of the window (reported by Uma410)
- fix: typo bug in general power Mandelbrot series approximation (thanks superheal)
- fix: some typo bugs in CFixedFloat operators (maybe did not affect anything in the old code, if only by chance)
- fix: some typo bugs in the build system
- fix: name Polarbrot correctly everywhere
- fix: there is no long long in OpenCL (thunks shapeweaver)
- fix: command line detailed status reporting works for all frames of zoom out sequence
- fix: be more robust about stopping rendering before changing internal state; should fix some crashes like changing approximation terms (reported by CFJH)
- internal: support for custom reference orbit values for caching repeated computations (time/space trade-off)

- known issue: no OpenCL support yet
- internal: output stream operators for more types
- internal: refactor smooth iterations handling
- internal: delete obsolete GlitchIter handling
- internal: more functions for CFixedFloat(): log() cosh() sinh()
- internal: more functions for floatexp: cosh() (thanks FractalAlex)
- internal: more functions for complex: sin() cos() cosh() (thanks FractalAlex)
- internal: more functions for preprocessor: cosh() sqrt() (thanks FractalAlex)
- internal: hack for fractions in preprocessor
- internal: complex constructor taking int to allow complex<T> x = 0
- internal: custom glitch tests in formula XML
- internal: brute force (high precision) renderers for tests

Get it from mathr.co.uk/kf/kf.html.

This is likely to be the last KF release from me for the foreseeable future as I'm increasingly busy with other things.

]]>The Mandelbrot set \(M\)is formed by iterations of the function \(z \to z^2 + c\) starting from \(z = 0\). If this remains bounded for a given \(c\), then \(c\) is in \(M\), otherwise (if it escapes to infinity) then \(c\) is not in \(M\). The interior of \(M\) is characterized by collections of cardioid-like and disk-like shapes, these are hyperbolic components each associated with an integer, its period. For the \(c\) at the center of each component, if the period is \(p\) then after \(p\) iterations, \(z\) returns to \(0\), and the iterations repeat (hence the name period). For points in the complex plane (either in \(M\) or not) sufficiently near to a hyperbolic compoenent of period \(p\), \(|z|\) reaches a new minimum (discounting the initial \(z=0\)) at iteration \(p\). The region for which this is true is called the atom domain associated with the hyperbolic component.

To find the center (sometimes called nucleus) of a hyperbolic component, one can use Newton's root-finding method in one complex variable. Iterate the derivative with respect to \(c\) along with \(z\) (using \(\frac{\partial z}{\partial c} \to 2 \frac{\partial z}{\partial c} z + 1\)) for \(p\) iterations, then update \(c \to c - \frac{z}{\frac{\partial z}{\partial c}}\) until it converges. However, Newton's method requires a good initial guess for \(c\). As there are multiple roots, and if you are near to a root Newton's method brings you nearer to it, there must be a boundary where which root is reached depends sensitively on the initial guess. It turns out (if there are more than 2 roots) that the boundary is fractal, and for any point on the boundary, an arbitrarily small neighbourhood will be spread to all the roots. See 3blue1brown's videos on YouTube about the Newton fractal for further information. Which comes to my conjecture:

Conjecture: points in the complement of the Mandelbrot in an atom domain of period \(p\) are good initial guesses for Newton's method to find the root of period \(p\) at the center of that atom domain.

It turns out that **this conjecture is false**. The proof
is by counter-example. The counter-example is the period \(18\) island
with angled internal address \(1 \to_{1/2} 2 \to_{1/8} 16 \to_{1/2} 18\),
whose upper external angle is \(.(010101010101100101)\) when
expressed in binary. I found this counter-example by brute force search:
for every period increasing from \(1\), trace every ray pair of that
period until the endpoints reach the atom domain. Then from each use
Newton's method to find the center of the hyperbolic component. Compare
the two centers reached, if they aren't the same then we have found a
counter-example. Here is a picture:

The Mandelbrot set is shown in black, using distance estimation to make its filaments visible. The fractal boundary of the Newton basins of period \(18\) is shown in white. The atom domain is shown in red. The complement of the Mandelbrot set is shown with a binary decomposition grid that follows the external rays and equipotentials. You can see that the path of the ray that goes from the cusp of the mini Mandelbrot island will intersect the Newton basin boundary at the corner of the atom domain, so that the eventual point of convergence of Newton's method is unpredictable. In my experiment it converged to the child bulb with angled internal address \(1 \to_{1/2} 2 \to_{1/9} 18\).

The above image was using regular Newton's method, without factoring out the roots of lower period that divide the target period. With the reduced polynomials, the basins are typically a little bigger, but in this case it made no difference and the problem persists with this counter-example:

I uploaded a short video showing the counter-example with both variants of Newton's method: watch on diode.zone. You can download the FragM source code for the visualisation.

This counter-example shows that the strategy of tracing rays until the atom domain is reached, before switching to Newton's method to find the root, is unsafe. A guaranteed safe strategy remains to be investigated.

]]>*smoltech* is a movement to reduce waste from technology,
principally by using existing technology for as long as possible to
reduce the overall environmental cost: the cost of device manufacture
typically outweighs the cost of ongoing usage 10-fold.

Curated by Laura Netz, this series of events includes an exhibition at CT20, Folkestone UK, two workshops on live coding sound and visuals at IKLECTIK, London UK, and a closing concert also at IKLECTIK. The exhibition opens on Friday 26th November 7pm, and I'll be giving an artist talk on Saturday 4th December 5pm. The exhibition will run until Saturday 11th December.

The exhibition of my work has two new pieces as well as some older
ones. The two new works are hopefully the last of their era: I've found
that the computation performed by my pieces tends to expand to fill the
available computational resources, no matter whether the computer is
fast (*Rodney* autonomous fractal explorer bot running on a late 2010s desktop computer at 3 GHz)
or slow (*Deep Disco* unpink noise generator running on a mid 1980s home computer at 7 MHz).
Moving forward I aim to be more frugal and economical with computing
resources in my digital art, so that having a powerful new computer is
not a prerequisite, and old devices can be used just as well.

In the new year I'll be leading a workshop on Clive for live-coding audio in C on Linux. I've been toying with Debian Live Builder to make a USB-bootable distro in case participants don't have a Linux installed or have trouble installing the dependencies and configuring sound. Building the image is quite straight-forward now, much easier than the Puredyne days, but I haven't needed to make many customisations (mainly setting it up to use the PipeWire audio system with bridges to JACK, PulseAudio, ALSA and OSS). If anyone wants to beta-test then get in touch and I'll be delighted by your help making sure it works smoothly.

The other workshop on LiveCodeLab is led by Rumble-San, and should be a much easier tech setup for participants. We'll both play at the final concert along with xname, digital selves and Heavy Lifting.

]]>The Mandelbrot set fractal is formed by iterations of \(z \to z^2 + c\) where \(c\) is determined by the coordinates of the pixel and \(z\) starts at the critical point \(0\). The critical point is where the derivative w.r.t. \(z\) is \(0\). The image is usually coloured by how quickly \(z\) escapes to infinity, regions that remain bounded forever are typically coloured black. It has a distinctive shape, with a cardioid adjoined by many disks decreasing in size, each with further disks attached. Looking closely there are smaller "island" copies of the whole, but they are actually attached to the main "continent" by thin filaments.

The Newton fractal is formed by applying Newton's method to the cube roots of unity, iterating \(z \to z - \frac{z^3 - 1}{3z^2}\) where the initial \(z\) is determined by the coordinates of the pixel. The image is usually coloured by which of the 3 complex roots of unity is reached, with brightness and saturation showing how quickly it converged. It has its own distinctive shape, with three rays extending from the origin towards infinity separating the immediate basins of attraction, each with an intricate shape: at each point on the Julia set, different points in an arbitrarily small neighbourhood will converge to all three of the roots.

The Nova fractal mashes these two fractals together: the Newton fractal is perturbed with a \(+ c\) term that is determined by the pixel, and the iteration starts from a critical point (any of the cube roots of unity). The image is coloured by how quickly the iteration converges to a fixed point (a different point for each pixel) and points that don't converge (or converge to a cycle of length greater than 1) are usually coloured black. The fractal appearance combines features of the Newton fractal and the Mandelbrot set, with mini-Mandelbrot set islands appearing in the filaments.

Deep zooms of the Mandelbrot set can be rendered efficiently using perturbation techniques: consider \[z = (Z+z) - Z \to ((Z + z)^2 + (C + c)) - (Z^2 + C) = (2 Z + z) z + c \] where \(Z, C\) is a "large" high precision reference and \(z, c\) is a "small" low precision delta for nearby pixels. Going deeper one can notice "glitches" around mini-Mandelbrot sets when the reference is not suitable, but these can be detected with Pauldelbrot's criterion \(|Z+z| << |Z|\), at which point you can use a different reference that might be more appropriate for the glitched pixels.

Trying to do the same thing for the Nova fractal works at first, but going deeper (to about \(10^{30}\) zoom factor) it breaks down and glitches occur that are not fixed by using a nearer reference. These glitches are due to the non-zero critical point recurring in the periodic mini-Mandelbrot sets: precision loss occurs when mixing tiny values with large values. They also occur when affine-conjugating the quadratic Mandelbrot set to have a critical point away from zero (e.g. \(z \to z^2 - 2 z + c\) has a critical point at \(z = 1\)). Affine-conjugation means using an affine function \(m(z) = a z + b\) to conjugate two functions \(f, F\) like \(m(f(z)) = F(m(z))\).

The solution is to affine-conjugate the Nova fractal formula, to move the starting critical point from \(1\) to \(0\). One way of doing it gives the modified Nova formula \[ z \to \frac{ \frac{2}{3} z^3 - 2 z - 1 }{ (z + 1)^2 } + c + 1 \] which seems to work fine when going beyond \(10^{30}\) at the same locations where the variant with critical point \(z=1\) fails. For example, see the ends of the following two short videos:

]]>Another small techno piece I made a few weeks ago. Created with Clive, my thing for livecoding audio in the C programming language. Rhythms loosely based on the techniques behind my visual art piece Gradient.

Source code at code.mathr.co.uk/clive.

**Stroboscopic video** made with FragM,
a scene with multiple waves shifting from black and white to red vs
cyan. I derived inspiration from the coloured fringes I get when
looking at high contrast edges wearing glasses and moving my head from
side to side.

In a previous post I presented a neat form for the series approximation coefficient updates for the quadratic Mandelbrot set. Note that the series for the derivative is redundant, as you can just take the derivative of the main series term by term. Recently superheal on fractalforums.org asked about series approximation for other powers. I explored a bit with SageMath (which is based on Python) and came up with this code (empirically derived formula highlighted):

def a(k): return var("a_" + str(k)) m = 10 for p in range(2,5): f(c, z) = z^p + c print(f) g(C, Z, c, z) = (f(C + c, Z + z) - f(C, Z)).expand().simplify() print(g) h0(C, Z, c) = sum( a(k) * c^k for k in range(1, m) ).series(c, m) print(h0) h1(C, Z, c) = g(C, Z, c, h0(C, Z, c)).expand().series(c, m) print(h1) def x(C, Z, c, k): return h1(C, Z, c).coefficient(c^k)def y(C, Z, c, k): return ((1 if k == 1 else 0) + sum([binomial(p, t) * Z^(p-t) * sum([Permutations(qs).cardinality() * product([a(q) for q in qs]) for qs in Partitions(k, length=t).list()]) for t in range(1, k + 1)]))for k in range(1, m): print((x(C, Z, c, k) - y(C, Z, c, k)).full_simplify(), " : ", a(k)," := ", x(C, Z, c, k)) print()

Example output:

(c, z) |--> z^2 + c (C, Z, c, z) |--> 2*Z*z + z^2 + c (C, Z, c) |--> (a_1)*c + (a_2)*c^2 + (a_3)*c^3 + (a_4)*c^4 + (a_5)*c^5 + (a_6)*c^6 + (a_7)*c^7 + (a_8)*c^8 + (a_9)*c^9 + Order(c^10) (C, Z, c) |--> (2*Z*a_1 + 1)*c + (a_1^2 + 2*Z*a_2)*c^2 + (2*a_1*a_2 + 2*Z*a_3)*c^3 + (a_2^2 + 2*a_1*a_3 + 2*Z*a_4)*c^4 + (2*a_2*a_3 + 2*a_1*a_4 + 2*Z*a_5)*c^5 + (a_3^2 + 2*a_2*a_4 + 2*a_1*a_5 + 2*Z*a_6)*c^6 + (2*a_3*a_4 + 2*a_2*a_5 + 2*a_1*a_6 + 2*Z*a_7)*c^7 + (a_4^2 + 2*a_3*a_5 + 2*a_2*a_6 + 2*a_1*a_7 + 2*Z*a_8)*c^8 + (2*a_4*a_5 + 2*a_3*a_6 + 2*a_2*a_7 + 2*a_1*a_8 + 2*Z*a_9)*c^9 + Order(c^10) 0 : a_1 := 2*Z*a_1 + 1 0 : a_2 := a_1^2 + 2*Z*a_2 0 : a_3 := 2*a_1*a_2 + 2*Z*a_3 0 : a_4 := a_2^2 + 2*a_1*a_3 + 2*Z*a_4 0 : a_5 := 2*a_2*a_3 + 2*a_1*a_4 + 2*Z*a_5 0 : a_6 := a_3^2 + 2*a_2*a_4 + 2*a_1*a_5 + 2*Z*a_6 0 : a_7 := 2*a_3*a_4 + 2*a_2*a_5 + 2*a_1*a_6 + 2*Z*a_7 0 : a_8 := a_4^2 + 2*a_3*a_5 + 2*a_2*a_6 + 2*a_1*a_7 + 2*Z*a_8 0 : a_9 := 2*a_4*a_5 + 2*a_3*a_6 + 2*a_2*a_7 + 2*a_1*a_8 + 2*Z*a_9 (c, z) |--> z^3 + c (C, Z, c, z) |--> 3*Z^2*z + 3*Z*z^2 + z^3 + c (C, Z, c) |--> (a_1)*c + (a_2)*c^2 + (a_3)*c^3 + (a_4)*c^4 + (a_5)*c^5 + (a_6)*c^6 + (a_7)*c^7 + (a_8)*c^8 + (a_9)*c^9 + Order(c^10) (C, Z, c) |--> (3*Z^2*a_1 + 1)*c + (3*Z*a_1^2 + 3*Z^2*a_2)*c^2 + (a_1^3 + 6*Z*a_1*a_2 + 3*Z^2*a_3)*c^3 + (3*a_1^2*a_2 + 3*Z*a_2^2 + 6*Z*a_1*a_3 + 3*Z^2*a_4)*c^4 + (3*a_1*a_2^2 + 3*a_1^2*a_3 + 6*Z*a_2*a_3 + 6*Z*a_1*a_4 + 3*Z^2*a_5)*c^5 + (a_2^3 + 6*a_1*a_2*a_3 + 3*Z*a_3^2 + 3*a_1^2*a_4 + 6*Z*a_2*a_4 + 6*Z*a_1*a_5 + 3*Z^2*a_6)*c^6 + (3*a_2^2*a_3 + 3*a_1*a_3^2 + 6*a_1*a_2*a_4 + 6*Z*a_3*a_4 + 3*a_1^2*a_5 + 6*Z*a_2*a_5 + 6*Z*a_1*a_6 + 3*Z^2*a_7)*c^7 + (3*a_2*a_3^2 + 3*a_2^2*a_4 + 6*a_1*a_3*a_4 + 3*Z*a_4^2 + 6*a_1*a_2*a_5 + 6*Z*a_3*a_5 + 3*a_1^2*a_6 + 6*Z*a_2*a_6 + 6*Z*a_1*a_7 + 3*Z^2*a_8)*c^8 + (a_3^3 + 6*a_2*a_3*a_4 + 3*a_1*a_4^2 + 3*a_2^2*a_5 + 6*a_1*a_3*a_5 + 6*Z*a_4*a_5 + 6*a_1*a_2*a_6 + 6*Z*a_3*a_6 + 3*a_1^2*a_7 + 6*Z*a_2*a_7 + 6*Z*a_1*a_8 + 3*Z^2*a_9)*c^9 + Order(c^10) 0 : a_1 := 3*Z^2*a_1 + 1 0 : a_2 := 3*Z*a_1^2 + 3*Z^2*a_2 0 : a_3 := a_1^3 + 6*Z*a_1*a_2 + 3*Z^2*a_3 0 : a_4 := 3*a_1^2*a_2 + 3*Z*a_2^2 + 6*Z*a_1*a_3 + 3*Z^2*a_4 0 : a_5 := 3*a_1*a_2^2 + 3*a_1^2*a_3 + 6*Z*a_2*a_3 + 6*Z*a_1*a_4 + 3*Z^2*a_5 0 : a_6 := a_2^3 + 6*a_1*a_2*a_3 + 3*Z*a_3^2 + 3*a_1^2*a_4 + 6*Z*a_2*a_4 + 6*Z*a_1*a_5 + 3*Z^2*a_6 0 : a_7 := 3*a_2^2*a_3 + 3*a_1*a_3^2 + 6*a_1*a_2*a_4 + 6*Z*a_3*a_4 + 3*a_1^2*a_5 + 6*Z*a_2*a_5 + 6*Z*a_1*a_6 + 3*Z^2*a_7 0 : a_8 := 3*a_2*a_3^2 + 3*a_2^2*a_4 + 6*a_1*a_3*a_4 + 3*Z*a_4^2 + 6*a_1*a_2*a_5 + 6*Z*a_3*a_5 + 3*a_1^2*a_6 + 6*Z*a_2*a_6 + 6*Z*a_1*a_7 + 3*Z^2*a_8 0 : a_9 := a_3^3 + 6*a_2*a_3*a_4 + 3*a_1*a_4^2 + 3*a_2^2*a_5 + 6*a_1*a_3*a_5 + 6*Z*a_4*a_5 + 6*a_1*a_2*a_6 + 6*Z*a_3*a_6 + 3*a_1^2*a_7 + 6*Z*a_2*a_7 + 6*Z*a_1*a_8 + 3*Z^2*a_9 (c, z) |--> z^4 + c (C, Z, c, z) |--> 4*Z^3*z + 6*Z^2*z^2 + 4*Z*z^3 + z^4 + c (C, Z, c) |--> (a_1)*c + (a_2)*c^2 + (a_3)*c^3 + (a_4)*c^4 + (a_5)*c^5 + (a_6)*c^6 + (a_7)*c^7 + (a_8)*c^8 + (a_9)*c^9 + Order(c^10) (C, Z, c) |--> (4*Z^3*a_1 + 1)*c + (6*Z^2*a_1^2 + 4*Z^3*a_2)*c^2 + (4*Z*a_1^3 + 12*Z^2*a_1*a_2 + 4*Z^3*a_3)*c^3 + (a_1^4 + 12*Z*a_1^2*a_2 + 6*Z^2*a_2^2 + 12*Z^2*a_1*a_3 + 4*Z^3*a_4)*c^4 + (4*a_1^3*a_2 + 12*Z*a_1*a_2^2 + 12*Z*a_1^2*a_3 + 12*Z^2*a_2*a_3 + 12*Z^2*a_1*a_4 + 4*Z^3*a_5)*c^5 + (6*a_1^2*a_2^2 + 4*Z*a_2^3 + 4*a_1^3*a_3 + 24*Z*a_1*a_2*a_3 + 6*Z^2*a_3^2 + 12*Z*a_1^2*a_4 + 12*Z^2*a_2*a_4 + 12*Z^2*a_1*a_5 + 4*Z^3*a_6)*c^6 + (4*a_1*a_2^3 + 12*a_1^2*a_2*a_3 + 12*Z*a_2^2*a_3 + 12*Z*a_1*a_3^2 + 4*a_1^3*a_4 + 24*Z*a_1*a_2*a_4 + 12*Z^2*a_3*a_4 + 12*Z*a_1^2*a_5 + 12*Z^2*a_2*a_5 + 12*Z^2*a_1*a_6 + 4*Z^3*a_7)*c^7 + (a_2^4 + 12*a_1*a_2^2*a_3 + 6*a_1^2*a_3^2 + 12*Z*a_2*a_3^2 + 12*a_1^2*a_2*a_4 + 12*Z*a_2^2*a_4 + 24*Z*a_1*a_3*a_4 + 6*Z^2*a_4^2 + 4*a_1^3*a_5 + 24*Z*a_1*a_2*a_5 + 12*Z^2*a_3*a_5 + 12*Z*a_1^2*a_6 + 12*Z^2*a_2*a_6 + 12*Z^2*a_1*a_7 + 4*Z^3*a_8)*c^8 + (4*a_2^3*a_3 + 12*a_1*a_2*a_3^2 + 4*Z*a_3^3 + 12*a_1*a_2^2*a_4 + 12*a_1^2*a_3*a_4 + 24*Z*a_2*a_3*a_4 + 12*Z*a_1*a_4^2 + 12*a_1^2*a_2*a_5 + 12*Z*a_2^2*a_5 + 24*Z*a_1*a_3*a_5 + 12*Z^2*a_4*a_5 + 4*a_1^3*a_6 + 24*Z*a_1*a_2*a_6 + 12*Z^2*a_3*a_6 + 12*Z*a_1^2*a_7 + 12*Z^2*a_2*a_7 + 12*Z^2*a_1*a_8 + 4*Z^3*a_9)*c^9 + Order(c^10) 0 : a_1 := 4*Z^3*a_1 + 1 0 : a_2 := 6*Z^2*a_1^2 + 4*Z^3*a_2 0 : a_3 := 4*Z*a_1^3 + 12*Z^2*a_1*a_2 + 4*Z^3*a_3 0 : a_4 := a_1^4 + 12*Z*a_1^2*a_2 + 6*Z^2*a_2^2 + 12*Z^2*a_1*a_3 + 4*Z^3*a_4 0 : a_5 := 4*a_1^3*a_2 + 12*Z*a_1*a_2^2 + 12*Z*a_1^2*a_3 + 12*Z^2*a_2*a_3 + 12*Z^2*a_1*a_4 + 4*Z^3*a_5 0 : a_6 := 6*a_1^2*a_2^2 + 4*Z*a_2^3 + 4*a_1^3*a_3 + 24*Z*a_1*a_2*a_3 + 6*Z^2*a_3^2 + 12*Z*a_1^2*a_4 + 12*Z^2*a_2*a_4 + 12*Z^2*a_1*a_5 + 4*Z^3*a_6 0 : a_7 := 4*a_1*a_2^3 + 12*a_1^2*a_2*a_3 + 12*Z*a_2^2*a_3 + 12*Z*a_1*a_3^2 + 4*a_1^3*a_4 + 24*Z*a_1*a_2*a_4 + 12*Z^2*a_3*a_4 + 12*Z*a_1^2*a_5 + 12*Z^2*a_2*a_5 + 12*Z^2*a_1*a_6 + 4*Z^3*a_7 0 : a_8 := a_2^4 + 12*a_1*a_2^2*a_3 + 6*a_1^2*a_3^2 + 12*Z*a_2*a_3^2 + 12*a_1^2*a_2*a_4 + 12*Z*a_2^2*a_4 + 24*Z*a_1*a_3*a_4 + 6*Z^2*a_4^2 + 4*a_1^3*a_5 + 24*Z*a_1*a_2*a_5 + 12*Z^2*a_3*a_5 + 12*Z*a_1^2*a_6 + 12*Z^2*a_2*a_6 + 12*Z^2*a_1*a_7 + 4*Z^3*a_8 0 : a_9 := 4*a_2^3*a_3 + 12*a_1*a_2*a_3^2 + 4*Z*a_3^3 + 12*a_1*a_2^2*a_4 + 12*a_1^2*a_3*a_4 + 24*Z*a_2*a_3*a_4 + 12*Z*a_1*a_4^2 + 12*a_1^2*a_2*a_5 + 12*Z*a_2^2*a_5 + 24*Z*a_1*a_3*a_5 + 12*Z^2*a_4*a_5 + 4*a_1^3*a_6 + 24*Z*a_1*a_2*a_6 + 12*Z^2*a_3*a_6 + 12*Z*a_1^2*a_7 + 12*Z^2*a_2*a_7 + 12*Z^2*a_1*a_8 + 4*Z^3*a_9

You can try it online. The first column should be all 0 if the formula is correct, which it seems to be for all the cases I've tried. But it remains to be proven rigourously that it is correct for all terms of all powers.

Efficiently implementing the general series coefficient update formula would probably need a multi-stage process: first (one-time cost given power and number of terms) calculate tables of constants (binomials, partitions as (index, multiplicity) pairs, partition permutation cardinalities). Second stage (once per iteration) calculate tables of powers of series coefficient variables (only going as far as the highest needed multiplicity for each index), and powers of Z. Third stage (once per series coefficient variable) combine all the powers and constants. Final stage, add 1 to the first variable.

It should be possible to generate OpenCL code for this at runtime. The expressions for each variable are of very different sizes but bundling a_k with a_(m-k) might give a more uniform load per execution unit.

]]>Newton's method can be used to trace external rays in the Mandelbrot set. See:

An algorithm to draw external rays of the Mandelbrot set

Tomoki Kawahira

April 23, 2009

AbstractIn this note I explain an algorithm to draw the external rays of the Mandelbrot set with an error estimate. Newton’s method is the main tool. (I learned its principle by M. Shishikura, but this idea of using Newton’s method is probably well-known for many other people working on complex dynamics.)

The algorithm uses \(S\) points in each dwell band, this number is called the "sharpness". Increasing the sharpness presumably makes the algorithm more robust when using the previous ray point \(c_n\) as the initial guess for Newton's method to find the next ray point \(c_{n+1}\) as the points are closer together.

I hypothesized that it might be better (faster) to use a different method for choosing the initial guess for the next ray point. I devised 3 new methods in addition to the existing one:

- nearest
- \( c_{n+1} := c_n \)
- linear
- \( c_{n+1} := c_n + (c_n - c_{n-1}) \)
- hybrid
- \( c_{n+1} := c_n + (c_n - c_{n-1}) \left| \frac{c_n - c_{n-1}}{c_{n-1} - c_{n-2}} \right| \)
- geometric
- \( c_{n+1} := c_n + \frac{(c_n - c_{n-1})^2}{c_{n-1} - c_{n-2}} \)

I implemented the methods in a branch of my mandelbrot-numerics repository:

git clone https://code.mathr.co.uk/mandelbrot-numerics.git cd mandelbrot-numerics git checkout exray-methods git diff HEAD~1

I wrote a test program for real-world use of ray tracing, namely tracing rays of preperiod + period ~= 500 to dwell ~1000, with all 4 methods and varying sharpness. I tested for correctness by comparing with the previous method, which was known to work well with sharpness around 4 through 8.

Results were disappointing. The hybrid and geometric methods failed in all cases, no matter the sharpness. The linear method failed for sharpness below 7, but when it worked (sharpness 7 or 8) it took about 68% of the time of the nearest method. However, the nearest method at sharpness 4 took 62% of the time of nearest at sharpness 8, so this is not so impressive.

The nearest method seemed to work all the way down to sharpness 2, which was surprising, and warrants further investigation: nearest at sharpness 2 took only 41% of the time of nearest at sharpness 8, if it turns out to be reliable this would be a good speed boost.

You can download my raw data.

Reporting this failed experiment in the interests of science.

]]>