Skip to main content

Playing with fire: Transforms and variations

· 5 min read
Bradlee Speice

Now that we've learned about the chaos game, it's time to spice things up. Variations create the shapes and patterns that fractal flames are known for.

info

This post uses reference parameters to demonstrate the fractal flame algorithm. If you're interested in tweaking the parameters, or creating your own, Apophysis can load that file.

Variations

note

This post covers section 3 of the Fractal Flame Algorithm paper

We previously introduced transforms as the "functions" of an "iterated function system," and showed how playing the chaos game gives us an image of Sierpinski's Gasket. Even though we used simple functions, the image it generates is intriguing. But what would happen if we used something more complex?

This leads us to the first big innovation of the fractal flame algorithm: adding non-linear functions after the affine transform. These functions are called "variations":

Fi(x,y)=Vj(aix+biy+ci,dix+eiy+fi)F_i(x, y) = V_j(a_i x + b_i y + c_i, d_i x + e_i y + f_i)
export type Variation = (
x: number,
y: number
) => [number, number];

Just like transforms, variations (VjV_j) are functions that take in (x,y)(x, y) coordinates and give back new (x,y)(x, y) coordinates. However, the sky is the limit for what happens between input and output. The Fractal Flame paper lists 49 variation functions, and the official flam3 implementation supports 98 different variations.

To draw our reference image, we'll focus on just four:

Linear (variation 0)

This variation is dead simple: return the xx and yy coordinates as-is.

V0(x,y)=(x,y)V_0(x,y) = (x,y)
import {Variation} from "./variation"
export const linear: Variation =
(x, y) => [x, y];
tip

In a way, we've already been using this variation! The transforms that define Sierpinski's Gasket apply the affine coefficients to the input point and use that as the output.

Julia (variation 13)

This variation is a good example of a non-linear function. It uses both trigonometry and probability to produce interesting shapes:

r=x2+y2θ=arctan(x/y)Ω={0w.p. 0.5πw.p. 0.5}V13(x,y)=r(cos(θ/2+Ω),sin(θ/2+Ω))\begin{align*} r &= \sqrt{x^2 + y^2} \\ \theta &= \text{arctan}(x / y) \\ \Omega &= \left\{ \begin{array}{lr} 0 \hspace{0.4cm} \text{w.p. } 0.5 \\ \pi \hspace{0.4cm} \text{w.p. } 0.5 \\ \end{array} \right\} \\ V_{13}(x, y) &= \sqrt{r} \cdot (\text{cos} ( \theta / 2 + \Omega ), \text{sin} ( \theta / 2 + \Omega )) \end{align*}
import { Variation } from "./variation";
const omega =
() => Math.random() > 0.5 ? 0 : Math.PI;

export const julia: Variation =
(x, y) => {
const x2 = Math.pow(x, 2);
const y2 = Math.pow(y, 2);
const r = Math.sqrt(x2 + y2);

const theta = Math.atan2(x, y);

const sqrtR = Math.sqrt(r);
const thetaVal = theta / 2 + omega();
return [
sqrtR * Math.cos(thetaVal),
sqrtR * Math.sin(thetaVal)
];
};

Popcorn (variation 17)

Some variations rely on knowing the transform's affine coefficients; they're called "dependent variations." For this variation, we use cc and ff:

V17(x,y)=(x+c sin(tan 3y),y+f sin(tan 3x))V_{17}(x,y) = (x + c\ \text{sin}(\text{tan }3y), y + f\ \text{sin}(\text{tan }3x))
import { Coefs } from "./transform";
import { Variation } from "./variation";
export const popcorn =
({ c, f }: Coefs): Variation =>
(x, y) => [
x + c * Math.sin(Math.tan(3 * y)),
y + f * Math.sin(Math.tan(3 * x))
];

PDJ (variation 24)

Some variations have extra parameters we can choose; they're called "parametric variations." For the PDJ variation, there are four extra parameters:

p1=pdj.ap2=pdj.bp3=pdj.cp4=pdj.dV24=(sin(p1y)cos(p2x),sin(p3x)cos(p4y))p_1 = \text{pdj.a} \hspace{0.1cm} p_2 = \text{pdj.b} \hspace{0.1cm} p_3 = \text{pdj.c} \hspace{0.1cm} p_4 = \text{pdj.d} \\ V_{24} = (\text{sin}(p_1 y) - \text{cos}(p_2 x), \text{sin}(p_3 x) - \text{cos}(p_4 y))
import { Variation } from './variation'
export type PdjParams = {
a: number,
b: number,
c: number,
d: number
};
export const pdj =
({a, b, c, d}: PdjParams): Variation =>
(x, y) => [
Math.sin(a * y) - Math.cos(b * x),
Math.sin(c * x) - Math.cos(d * y)
]

Blending

Now, one variation is fun, but we can also combine variations in a process called "blending." Each variation receives the same xx and yy inputs, and we add together each variation's xx and yy outputs. We'll also give each variation a weight (vijv_{ij}) that changes how much it contributes to the result:

Fi(x,y)=jvijVj(x,y)F_i(x,y) = \sum_{j} v_{ij} V_j(x, y)

The formula looks intimidating, but it's not hard to implement:

import { Variation } from "./variation";
export type Blend = [number, Variation][];

export function blend(
x: number,
y: number,
varFns: Blend
): [number, number] {
let [outX, outY] = [0, 0];

for (const [weight, varFn] of varFns) {
const [varX, varY] = varFn(x, y);
outX += weight * varX;
outY += weight * varY;
}

return [outX, outY];
}

With that in place, we have enough to render a fractal flame. We'll use the same chaos game as before, but the new transforms and variations produce a dramatically different image:

tip

Try using the variation weights to figure out which parts of the image each transform controls.

Post transforms

Next, we'll introduce a second affine transform applied after variation blending. This is called a "post transform."

We'll use some new variables, but the post transform should look familiar:

Pi(x,y)=(αix+βiy+γi,δix+ϵiy+ζi)Fi(x,y)=Pi(jvijVj(x,y))\begin{align*} P_i(x, y) &= (\alpha_i x + \beta_i y + \gamma_i, \delta_i x + \epsilon_i y + \zeta_i) \\ F_i(x, y) &= P_i\left(\sum_{j} v_{ij} V_j(x, y)\right) \end{align*}
import { applyCoefs, Coefs, Transform } from "../src/transform";
export const transformPost = (
transform: Transform,
coefs: Coefs
): Transform =>
(x, y) => {
[x, y] = transform(x, y);
return applyCoefs(x, y, coefs);
}

The image below uses the same transforms/variations as the previous fractal flame, but allows changing the post-transform coefficients:

If you want to test your understanding...
  • What post-transform coefficients will give us the previous image?
  • What post-transform coefficients will give us a mirrored image?

Final transforms

The last step is to introduce a "final transform" (FfinalF_{final}) that is applied regardless of which regular transform (FiF_i) the chaos game selects. It's just like a normal transform (composition of affine transform, variation blend, and post transform), but it doesn't affect the chaos game state.

After adding the final transform, our chaos game algorithm looks like this:

(x,y)=random point in the bi-unit squareiterate {i=random integer from 0 to n1(x,y)=Fi(x,y)(xf,yf)=Ffinal(x,y)plot(xf,yf) if iterations>20}\begin{align*} &(x, y) = \text{random point in the bi-unit square} \\ &\text{iterate } \{ \\ &\hspace{1cm} i = \text{random integer from 0 to } n - 1 \\ &\hspace{1cm} (x,y) = F_i(x,y) \\ &\hspace{1cm} (x_f,y_f) = F_{final}(x,y) \\ &\hspace{1cm} \text{plot}(x_f,y_f) \text{ if iterations} > 20 \\ \} \end{align*}
import { randomBiUnit } from "../src/randomBiUnit";
import { randomChoice } from "../src/randomChoice";
import { plotBinary as plot } from "../src/plotBinary";
import { Transform } from "../src/transform";
import { Props as WeightedProps } from "../1-introduction/chaosGameWeighted";

const quality = 0.5;
const step = 1000;
export type Props = WeightedProps & {
final: Transform,
}

export function* chaosGameFinal(
{
width,
height,
transforms,
final
}: Props
) {
let img =
new ImageData(width, height);
let [x, y] = [
randomBiUnit(),
randomBiUnit()
];

const pixels = width * height;
const iterations = quality * pixels;
for (let i = 0; i < iterations; i++) {
const [_, transform] =
randomChoice(transforms);
[x, y] = transform(x, y);

const [finalX, finalY] = final(x, y);

if (i > 20)
plot(finalX, finalY, img);

if (i % step === 0)
yield img;
}

yield img;
}

This image uses the same normal/post transforms as above, but allows modifying the coefficients and variations of the final transform:

Summary

Variations are the fractal flame algorithm's first major innovation. By blending variation functions and post/final transforms, we generate unique images.

However, these images are grainy and unappealing. In the next post, we'll clean up the image quality and add some color.