Visualizing Complex Numbers in Blender

This tutorial introduces how to make patterns with complex numbers in Blender. It builds off of tutorials on creative coding in Blender and scripting Cycles nodes. For that reason, it assumes some prior acquaintance with Blender’s Python API and Open Shading Language (OSL).

In this tutorial, we’ll go over

  • the maths behind complex numbers;

Last, we’ll look briefly at how a fractal pattern can be mixed with other patterns (for shaders) and modifiers (for meshes generated by Python).

Since the details of implementing complex number functions can take a while, readers are encouraged to skip the initial section of this tutorial where they are defined, look over what patterns can be made with these functions, then return if interested.

This tutorial was written for Blender 2.8 beta.

Background

First, a refresher on the math behind complex numbers. A complex number is written as a+bi. a and b are real scalars. i is an imaginary number, the square root of -1. When i is raised to an even whole number -1 results,

with zero being the exception. When graphed with an Argand Diagram, i.e., on the complex plane, the real axis is horizontal and the imaginary axis is vertical. That means a complex number z 0.3248+0.1875i would look like so:

A complex number can also be expressed in polar coordinates. The number z is expressed as a phase of 30 degrees and an absolute of 0.375.

The angle is called the argument in some implementations, the phase in others. The radius is alternatively called the modulus or absolute (abs). In math notation, the absolute is signaled with one or two vertical bars.

The phase is often symbolized by the Greek letter phi, φ.

Notes on Implementation

The similarity between complex numbers and two-dimensional (2D) vectors means that vectors can be used to store and to visualize them. In Cartesian coordinates, the x component carries the real number; the y component, the imaginary. A complex number’s argument matches the vector’s heading; the modulus, a vector’s length or magnitude. For vector implementations where 2D vectors are not distinguished from 3D, care must taken that the z component be treated as zero.

We’ll be implementing complex number operations in Python, OSL and Blender Cycles nodes until we get to pattern-making. A few notes on how these languages will influence our implementation:

  • As of Blender 2.8, Cycles math nodes include atan2, ceil, floor, fract and sqrt. Nodes are not conducive to iterative algorithms, so we won’t be creating fractal patterns with nodes.
  • Python handles complex numbers out of the box. Just as real number functions are found in the math library, so too with complex functions in the cmath library. numpy also supports complex operations, but we will not be using it for this tutorial.

Those differences noted, we break complex operations down into four stages:

  1. Unary operations, pt. 1: absolute, phase, reciprocal;

We adopt this order because conversions between polar and rectilinear (or Cartesian) coordinates depend on the absolute and phase; division depends on the reciprocal; exponent and logarithm depend on polar-rectilinear conversions. Last, the ability to create a fractal pattern depends on exponent and logarithm at work in the complex power operation.

Unary Operations, Pt. 1

In this section, we cover abssq, abs, phase, conjugate and reciprocal.

Abs Sq / Modulus Sq / R Sq

Depends on: add, multiply, separate xyz
Needed for: abs, reciprocal

abssq(a) := add(mult(re(a), re(a)), mult(im(a), im(a)))

This is analogous to a vector’s magnitude squared. If we know that the input vector a has a z component equal to 0.0, then we can use dot(a, a) in OSL. With Vectors in Python, we have the expressions a @ a, a.dot(a) or Vector.dot(a, a). This can be used to avoid the sqrt implicit in magnitude.

Abs / Modulus / R

Depends on: abssq, sqrt (or power)
Needed for: mandelbrot (and variants), polar

abs(a) := sqrt(abssq(a))

This is analogous to a vector’s magnitude. If we know that the input a has a z component equal to 0.0, then we can use length(a) (OSL) or a.magnitude (Python). In Python, len(a) returns the number of components a vector has, not its magnitude; for example, a 2D vector has 2 components, x and y. With Cycles nodes, if sqrt is not available, pow(abssq(z), 0.5) can be used. pow(dot(a, a), 0.5) will work if we know that z is 0.0.

In the context of visualizing fractals, the abs function is key to finding when the complex number escapes, and thus when the iteration should cease. The usual test is that z has escaped when abs(z) > 2.0.

Phase / Arg / Phi

Depends on: atan2 (or atan), separate xyz
Needed for: polar

phase(a) := atan2(im(a), re(a))

atan2 takes the y or imag component as its first argument; the x or real component as its second. It returns a value in the range [-π, π]. Use mod (OSL) or% (Python) to shift it to [0, τ]. The Modulo function in the math node is based off trunc, not floor, and works more like OSL’s fmod. For older versions of Blender, we need to use atan and handle the domain of our inputs as expressed in the formula

(modified from the Wikipedia entry on complex numbers). Shader languages typically handle division-by-zero by returning zero regardless of the numerator’s value. Compare this behavior to throwing an exception (Python) or returning +/-Infinity or NaN (JavaScript). atan(0.0)returns 0.0.

phase(a) := mix(mult(pi, lt(re(a), 0.0)), mult(atan(div(im(a), add(abs(z), re(a)))), 2.0), max(gt(re(a), 0.0), neq(im(a), 0.0)))

Since the abs is calculated along the way to finding the phase, we can include it as an output, making this the same as polar below. The function depends on two others. The first, neq (not equals) is defined as

neq(a, b) := max(gt(a, b), lt(a, b))

In scenarios where real numbers store Boolean (true or false) values, maximum can be used for logical OR. The second, mix (unclamped linear interpolation) is defined as

mix(a, b, fac) := add(mult(a, sub(1.0, fac)), mult(b, fac))

With Booleans, mix acts like a ternary operator. For example, the expression float a = c > 0.0 ? 2.0 : 3.0; is analogous to float a = mix(3.0, 2.0, c > 0.0);. mix isn't needed when one of the alternatives is 0.0. This is the case with phase above: float a = mix(0.0, M_PI, real < 0.0); can be simplified to float a = M_PI * (real < 0.0);.

Conjugate

Depends on: combine xyz, multiply, separate xyz

conj(a) := complex(re(a), mult(im(a), -1.0))

conjugate is usually too trivial to implement. It can, however, be an extra layer of abstraction to clarify reciprocal below.

In math notation, the conjugate of z has a horizontal bar over it. The conjugate can also be used to find the absolute,

as will become clearer after we address complex multiplication.

Reciprocal / Inverse

Depends on: abssq
Needed for: div

reciprocal(a) := complex(div(re(a), abssq(a), div(mult(im(a), -1.0), abssq(a)))

The reciprocal is most useful in defining division of one complex number by another. Sometimes referred to as an inverse, its math notation is a superscript negative one.

Given that complex division is already defined in Python, we won’t need to use it directly in that environment.

Conversions

In this section we cover the conversions polar and rect.

Polar

Depends on: abs, phase
Needed for: log

polar(a) := abs(a), phase(a)

Rect

Depends on: combine xyz, cosine, sine, multiply
Needed for: exp

rect(a) := complex(mult(r, cos(phi)), mult(r, sin(phi)))

Binary Operations, Pt. 1

In this section, we cover add, sub, mult and div. We also define pow, though it depends on exp and log, which have not yet been defined.

Add

Depends on: add, combine xyz, separate xyz
Needed for: mandelbrot (and variants), mobius

add(a, b) := complex(add(re(a), re(b)), add(im(a), im(b)))

Sub

Depends on: combine xyz, subtract, separate xyz

sub(a, b) := complex(sub(re(a), re(b)), sub(im(a), im(b)))

Mult

Depends on: add, combine xyz, multiply, separate xyz
Needed for: div, mobius, pow

mult(a, b) := complex(sub(mult(re(a), re(b)), mult(im(a), im(b))), add(mult(re(a), im(b)), mult(im(a), re(b))))

Div

Depends on: mult, reciprocal
Needed for: mobius

div(a, b) := mult(a, reciprocal(b))

Pow

Depends on: exp, log, mult
Needed for: mandelbrot (and variants)

pow(a, b) := exp(mult(log(a), b))

Unary Operations, Pt. 2

This section covers exp, log, cos and sin.

Exp

Depends on: power (exp), rect, separate xyz
Needed for: pow

exp(a) := rect(pow(e, re(a)), im(a))

Log

Depends on: logarithm (ln), polar
Needed for: pow

log(a) := complex(log(r(polar(a)), e), phi(polar(a)))

Cos

Depends on: combine xyz, cos, cosh, multiply, separate xyz, sin, sinh

complex(mult(cos(a.real), cosh(a.imag)), mult(mult(sin(a.real), -1.0), sinh(a.imag)))

To implement the above in Cycles nodes, we need hyperbolic cosine (cosh)

cosh(a) := mult(add(pow(e, r), pow(e, mult(r, -1.0))), 0.5)

and hyperbolic sine (sinh).

sinh(a) := mult(sub(pow(e, r), pow(e, mult(r, -1.0))), 0.5)

To offer a sense of the pattern created by cos, we use a diagnostic texture on a plane to animate the following:

Sin

Depends on: combine xyz, cos, cosh, multiply, separate xyz, sin, sinh

sin(a) := complex(mult(sin(a.real), cosh(a.imag)), mult(cos(a.real), sinh(a.imag)))

2D Patterns

Once we start with fractals, we’ll stop using Cycles nodes. This is because fractal algorithms require us to iterate a core algorithm.

Mobius Transformation

The Mobius transformation is named after August Möbius, the same man behind the Mobius strip, and is key to math behind projections.

mobius(a, b, c, d, z) := div(add(mult(a, z), b), add(mult(c, z), d))

Since we know that the vectors returned by cMult will have z set to 0.0, we can safely use vector addition.

The animated gif, above, illustrates the type of pattern generated by this algorithm when we plug in an object’s coordinates to z. The manner in which the constants a, b, c and d are changed yields different transformations.

A 16x16 lattice, viewed from above.

One way we can visualize this in Python is to create a lattice; we can then add a lattice modifier to a mesh object. First, we import some tools.

Then, we need a general purpose function that adds an object to a collection (the scene’s main collection by default). This object will be an Empty if no data is supplied.

The next function we need sets the new object’s transform. Blender’s quaternion takes w, the real component, as its first argument, not its last. The identity quaternion (1.0, 0.0, 0.0, 0.0) is equal to Euler angles (0.0, 0.0, 0.0) and an axis-angle of 0.0about the axis (0.0, 1.0, 0.0). Quaternions are used because they allow animation without gimbal lock.

Depending on which rotation mode is preferred, the quaternion can be converted using either to_axis_angle or to_euler. We include options for all three rotation modes above, but only one is needed.

Next, we create a lattice data by script. The lattice contains a list of points, each of which is a LatticePoint. The coordinates u, v, and w correspond to x, y and z. We use them when determining the dimensions, or resolution.

We next create a function which loops through the original points in the lattice — which are read-only — and then setting the lattice’s deformed point to the results of the Mobius transformation.

Since the 2D nature of the function nullifies any depth, we can restore some volume by setting the z coordinate by an arbitrary value derived from the x and y dimension.

With a lattice in place, any object with a lattice modifier that targets that lattice will be deformed accordingly. The lattice modifier includes a Strength factor, ranging from [0, 1] which can be animated with keyframes.

Mandelbrot Set

One of the most iconic fractals generated by complex numbers, the Mandelbrot set is named after Benoit Mandelbrot. Mandelbrot’s discovery was guided by a belief that roughness, not only the smooth curves of calculus, is fundamental to natural patterns. In mathematical notation, the function we iterate looks like so:

where c is a complex constant. This function is repeated until |z| exceeds the upper limit, 2.0, or the maximum number of iterations is reached. This can be generalized by replacing the exponent 2 with a variable, labeled e below.

If we want, we can subdivide this variation into smaller ones: where e is a positive integer (2, 3, 4…), e is a positive real number (2.0, 2.1, 2.2…), e is a signed real number (-2.0, -1.5, 0.0, 1.5…). Paul Bourke’s article “Mandelbrot at higher powers” offers a nice gallery of these variations. We’ll recreate higher-power Mandelbrot sets from there.

Raised to the powers 2.0 (red, top-left), 7.0 (blue, top-right), 2.7 (purple, bottom-right).

OSL code is below, but a demo of a node-based approach can be found in the video “Fractals in Blender (Cycles, Volumetrics)” from Kostack Studio.

We use floats in place of Booleans; when ExcludeUpper is equal to zero, it is false. When ExcludeUpper is true and i has reached the maximum before escaping, then Fac is left at 0.0. In this case, the bulb’s interior remains black. The global variable P is the point currently being sampled.

As Bourke observes, raising the fractal to a positive whole number b results in b - 1 bulbs rotated around a pivot. Raising the fractal to a power with a fractional component results in horizontal mirroring. The maximum number of iterations is kin to the ‘refinement’ of the pattern. Set at a low number, the visual is a blob. The OSL script can be hooked up to other nodes, like so:

Fac is calculated by dividing the last iteration by the maximum allowed. This can lead to color banding; we won’t implement a smoothing algorithm, but it is discussed at Wikipedia and by Inigo Quilez. Not all programming languages support log by an arbitrary base; OSL does. This means code such as log(a) / log(b); can be condensed to log(a, b);. A different algorithm, based on the Lyapunov exponent, is needed to depict negative exponents properly; see this discussion for more.

Z at its last iteration and its Abs are output by this shader as well. The pattern formed by Z is illustrated below (it has been converted to a color with the formula 0.5 + 0.5 * normalize(z);).

To visualize this in Python, we’ll create a 2D grid that we populate with cubes.

First we break out the Mandelbrot algorithm into its own function. This function isolates the conversion between complex numbers and vectors. In the function definitions above, we used complex literals; to create a complex number in Python from preexisting numbers, we use complex.

To match an OSL shader’s ability to return multiple outputs, any value with aesthetic potential is included in a dictionary. This function is then called by one which manages the for loop.

The list points is expected to contain vectors. We will create this list from nested for-loops:

The outer loop runs through each row, and therefore determines the y coordinate. The inner loop runs through each column, and determines the x coordinate. The offset and scale variables refer to the complex plane.

To visualize the fractal with cubes, we work with data instead of ops or BMesh functions. For a more detailed walk-through of this approach, Diego Gangl’s “Meshes with Python & Blender” is a handy reference.

First, we create a list of six tuples. The four indices per tuple indicate the corners of a quadrilateral face on the cube. These indices will be used to find a vector in a list that we will be creating later.

Since a mesh data containing multiple cubes will store all the faces in one cumulative list, we allow an offset to be added each time we generate a new set of six face indices. We then generate the eight vertices of a unit cube:

For a cube that scales and rotates from an arbitrary pivot, the function accepts a pivot which is subtracted from each vector. We use a matrix to transform the vertices. The matrix workflow consists of creating separate matrices from static functions (Translation, Rotation, Scale), then compositing them via multiplication. We won’t be rotating cubes in this example. Matrix.Scale doesn’t do nonuniform scaling, so we roll our own:

A Matrix is constructed using vectors with 4 components each. With these three functions in place, we create the following to visualize the results of the earlier mandelbrot function:

More on from_pydata and validate can be found in the Python reference. We depend on append_obj_from_data, defined earlier in the Mobius transformation section. The easing function which determines the relative size of a cube based on the return factor, by default, is float_lerp.

An alternative example, float_smooth_step is also included. One last function puts all the above into action:

We have to be careful when scripting due to the high number of iterations required. A Python script could easily tank Blender. For example, a 32 x 32 grid has 1024 loci, which are fed to the mandelbrot function as center. Supposing a worst case scenario — where the for-loop reaches its max iteration — a fractal refined to 150 iterations would require 153,600 calls to mandelbrot_step. One step we can take in visualize_fractal is to prune away cubes where the factor is out of bounds.

The code above is written for parity with OSL and to illustrate the relationship between complex numbers, fractals and vectors. It’s not written for performance, but there are many articles on performant Python specific to fractal generation around the web.

Another limitation, common to both the Python and OSL, is that the pattern is 2D . We’ll look into the 3D Mandelbulb later. For now, two simple tricks are to correlate the factor to an element’s scale on the z-axis

or to cast the 2D plane around a 3D form. In the case of a sphere, above right, the real and complex axis can be converted to spherical coordinates. A technique we’ll explore later on is to create a vertex group, weight the vertices in Python, then target that vertex group with modifiers.

Julia Set

Named after Gaston Julia, the Julia set differs from the Mandelbrot set insofar as the initial coordinate provided to the function is not set to a seed value the same way that z was set to center previously.

Julia set from seed (-0.8+0.156j). Left: detail. Right: Displacement from Fac.

To smooth the color gradient, we implement a technique posted on Stack Overflow by Mike Andrews and Per Alexandersson. The negative Abs of ZN is supplied to the exp function every iteration. Fac records the sum.

The Wikipedia entry on the Julia set includes a gallery of interesting seeds. A handy method to find a seed is to use rect to convert from polar to Cartesian coordinates. Set the radius to around 0.75, then cycle the angle phi until a desired pattern emerges. This is also handy when animating a Julia set.

Julia Set with seed (-0.8+0.156j).

The workflow for the above in Python parallels that of the Mandelbrot set.

To make the dictionary returned by this function consistent with the above, we plug the original coordinate into the 'c' key, even though it’s referred to in the function as z. This is so that the visualize_fractal function can read the results of the julia function below. The rest of the code to generate a Julia set matches that for the Mandelbrot set, so we’ll link to the gist here.

Pickover Stalks

Pickover stalks, named after Clifford Pickover, introduce another way to visualize the algorithms we’ve already created above. Since the wispy strokes of this pattern require a high resolution, we won’t implement it in Python.

Pickover stalks for Julia set, left, and Mandelbrot set, right.

Instead of counting the number of iterations it takes for the abs of a complex number to exceed the limit, we measure its absolute distance from an axis.

Between the horizontal and vertical distance, the minimum is always sought. After we’ve concluded the loop, this minimum is then divided by a number to govern the stroke weight of these stalks. The initial value of TrapDist is an arbitrary large number.

Once we understand how to create Pickover stalks, we can incorporate them in to the same shaders as the original Julia and Mandelbrot sets.

The pattern lends itself well to the pinstriping look: when the Fac generated by the above shader is greater than a lower-bound, it can be plugged into a ColorRamp node in RGB color mode with Constant interpolation. A Displacement node can be used to accentuate the strokes.

The Burning Ship

The Burning Ship fractal is straightforward, but is useful to illustrate the importance of controlling the input’s scale and offset. While the main burning ship figure is interesting, there are smaller, self-similar figures which include additional features, such as the cobwebby columns depicted below.

The burning ship fractal.

To zero in on the section of a fractal we want to see, we can use the vector Mapping node or create our own. One option is to use use OSL’s matrix data type. There’s more than one convention for the order in which these matrices are multiplied. Below, we use RST transformation order.

In OSL we can distinguish between points, vectors and normals. These data types will not be affected by the transform function in the same way. We cannot, however, distinguish between 2D texture coordinates and 3D coordinates. If we rotate a UV coordinate, it will rotate from the bottom-left corner, not from the center, (0.5, 0.5).

Degrees can be converted to radians by multiplying them by π radians / 180 degrees (approximately 0.01745). The axis around which a point rotates should be normalized, but that is done within the shader, so we don’t have to worry about it when plugging in a value.

The rotation matrix above rotates clockwise (CW) when given a positive angle so as to match the behavior of OSL’s rotate. The Mapping node rotates Texture coordinates counter-clockwise (CCW); the other types — Point, Vector and Normal — are rotated CW.

It may be preferable to not make a monolithic transform node at all. Translation is vector addition; scale, component-wise multiplication; rotation is covered by OSL’s rotate. By letting each transformation stand on its own, we leave the transformation order (TRS, SRT, etc.) up to the composer.

Tricorn / Mandelbar

The tricorn or Mandelbar fractal raises the conjugate of z to a power.

The code above also demos how a Julia and Mandelbrot set can be toggled within the same shader using a Boolean float.

3D Patterns

A 3D equivalent to the Mandelbrot set has long been the subject of experimentation. Daniel White and Paul Nylander have come up with a Mandelbulb by extending polar formula of Complex numbers to spherical formula. Left, Jonas Dichelle has a tutorial on how to create this with nodes in Blender’s new Eevee renderer. As with any 3D pattern, we can either emphasize a volume or the intersection of that volume with a surface.

A mandelbulb raised to the power 2.0, rotated on its side and expressed with a volume shader.
A mandelbulb expressed with Pickover stalks as they intersect with a surface.

A handy tool for the volumetric approach is the the relatively new Principled Volume shader node.

Spherical-Cartesian Conversion

In order to recreate the Mandelbulb, we need to define spherical-Cartesian conversions. First, spherical to Cartesian,

The clamp node is min(max(value, lowerbound), upperbound). The above also groups cos and sin nodes together. Next, the reverse.

Beware: OSL’s global variables for angles are confusing. M_PI_2 is π/2. M_2PI is 2π. M_2_PI is 2/π. In non-GPU programming languages, the Cartesian-spherical conversion should protect against a divide-by-zero exception.

Some versions of the formula use cos of the inclination when converting to spherical and acos when converting to Cartesian; acos returns a value in [0, π]. Others use sin of the inclination and asin to reverse; asin returns a value in [-π/2, π/2]. We keep with Barrallo’s “Expanding the Mandelbrot Set into Higher Dimensions,” and use asin in the code to follow. For the spherical-Cartesian conversion, a precaution is to clamp the inclination and to ensure the radius is at least epsilon (ε), a small positive number.

Naming conventions may vary, with some formulae using theta (θ), phi (φ) and rho (ρ). This alternative adds uncertainty insofar as theta and phi may refer to azimuth and inclination or vice versa. Readers are encouraged to compare the world coordinates used by such formula against Blender’s.

Spherical-Cartesian diagnostic. Left, inclination. Right, azimuth.

To troubleshoot these conversions, we cut a corner out of a UV-sphere with the Boolean modifier, then make a hue from the azimuth or the inclination. The sphere in the foreground on the left models inclination; in the background on the right, azimuth.

Using acos, a vector facing up on the z axis, (0, 0, 1), has an inclination of 0, and therefore is red. In addition, we’ve associated the radius with either value (background, right) or saturation (foreground, left).

The Mandelbulb

The main formula entails raising the radius of Z to an exponent; azimuth and inclination are multiplied by the exponent. Spherical coordinates are converted to Cartesian, then added to Center. After this addition, the sum is used to update the spherical coordinates.

For Python, performance is even more of an issue than with 2D plots. The default grid count of 32 yields, worst case, 32, 768 cubes. Blender’s documentation offers a few best practices for writing scripts; particularly sound advice is to use list comprehensions and to time the script.

A mandelbulb, power 8.0, red, on the left; power 2.0, blue, on the right.

First, we spherical-Cartesian conversions in Python:

Then on to the main formula:

Just as we created a 2D grid for the Mandelbrot set, we create a 3D grid for the Mandelbulb:

As before, after these core functions are in place, the rest is a matter of composition. We won’t run through these, but the gist is here.

The same algorithm can be ported to a ‘Julia bulb’, but this simplistic approach doesn’t seem to have much traction compared to the approach that makes a 4D Julia set with quaternions. Inigo Quilez describes his own experiments with 3D Julia sets here.

Mixing A Fractal With Other Patterns

Noise

4D simplex noise mixed with a 3D Julia set.

To demo but one approach for mixing a fractal with other patterns, we create a custom noise node; unlike the Cycles Noise texture, this generates a vector as an output. The greater aesthetic motive behind this approach is to reconcile the smoothness of noise with the exploration of roughness that motivated Mandelbrot in developing fractals.

The return value of the noise function can be either a float, a vector or a color. If we supply a point and an additional float, T, to the function, we can harness 4D noise. Simplex noise returns a value in the range [-1, 1] so we shift the Fac to [0, 1]. The documentation includes a list of other noise alternatives: "cell", "gabor", "hash", "perlin", "uperlin", "usimplex". The ‘u’ prefix stands for unsigned.

The output vector of a noise node can then contribute to the Z or Center input of a fractal shader. Noise in 4D makes it easy to animate, since all we need to do is key-frame the input T. Alternatively, we could animate a point’s transformation, then then feed it into 3D noise.

Other combinations include combining the output Facs of two fractals with logic gates (such as AND, OR, XOR) or supplying the final iteration of ZN as an input coordinate to a texture.

Vertex Weighting

For Python scripts that generate meshes, we can integrate them with Blender modifiers by setting vertex weights according to the 'fac' returned by our fractal functions.

Vertex weights can be viewed while in edit mode by bringing up the Overlays menu and ticking the check box Vertex Group Weights under the Shading header.

First a new vertex group is created. Then the visualize_fractal function can be updated. Then, a list of indices is added to the vertex group. The trick in this case is to ensure a fractal’s 'fac' can easily be tied back to the eight vertices of a cube; we simplify the function above by only creating a vertex group if no cubes have been pruned. With cube pruning, we don’t know how many vertices are in a mesh’s data; without, the list of vertices is of a predictable length.

We can then specify the vertex group in any number of modifiers which support the option. In the animation above, a wave modifier is used.

Vertex Colors

Another technique is to paint vertex colors. We can then pass these colors into a material via an Attribute node.

Left: a Mandelbulb, power 6, painted to simulate vertex weights. Right: a Julia set painted with vectors.

If we don’t mind mutating the results of the fractal in the process of visualizing it, we can update our visualize_fractal function to remove data from the results list when pruning. This way, our abstract data corresponds with the visual data. In this case, the color of vertices is associated with the loops in a mesh’s data.

Unlike other programming languages, in Python, pop — associated with the behavior of a stack data structure — accepts an index as an argument. We could also use del or remove; see this Stack Overflow thread for more. Since we are shifting elements, we loop through the list backwards.

For each cube in the fractal, there will be 24 loops, so that is the stride we’ll be working with. To create a color we could either use Python’s colorsys or Blender’s Color in mathutils. Vertex weights are typically displayed with a color ramp that ranges from blue to red. To match this, we subtract fac from one, then multiply by two-thirds. Not in the code above, we can also convert z (or any other data returned by all fractal patterns) from a vector to a color.

If a material is supplied to the function, we can automate the process of adding an Attribute input node and separating the color into HSV.

Instead of directly rendering vertex colors, we can convert a color back into a factor, [0, 1]. In the image above, such as factor is supplied to a noise texture, to a color ramp and to a displacement node.

Conclusion

If nothing else, learning complex numbers in 2D makes it considerably easier to understand how quaternions — which generalize complex numbers into 4D — make 3D rotations possible. The video from 3Blue1Brown, left, illustrates this connection.

A second take away for this article is how a generative algorithm is iterated. We began with a specific algorithm, zn:= sq(z) + c, then studied variations. Each variation can be subdivided into how to generate new data, and then how to visualize it. The re-creation of these patterns is a starting point, not necessarily an end in itself. By studying past variations, we gain a sense of where opportunities for new visuals are to be found.

For more fractals, the Fractal Forums are a font of information. Paul Bourke’s website contains a gallery of variants: the bedouin, binocular, cactus, crown, lemon, log spiral, magnetic, secant sea and Zubieta fractal. Paul Nylander’s website does as well, particularly the page, “Hypercomplex Fractals.”

Beyond that, there are a number of fractal patterns which require no understanding of Complex numbers whatsoever, but emphasize the recursive, or iterative, process: Menger sponges, Koch curves, fractal flames, and so on. Noteworthy among 3D fractals is the Mandelbox, as it iterates spatial folding algorithms, specifically box- and sphere-folding. Fractal Lab demonstrates how the Mandelbox can be visualized in GLSL. This fractal has been explored by tree3d and DERBENDER at the Blender Artists Forums.

Creative coder from Wisconsin, USA.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store