Merge pull request #17 from bspeice/flam3

Flam3 - Blog post
This commit is contained in:
bspeice 2024-12-16 18:34:10 -08:00 committed by GitHub
commit e49ba23e3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
75 changed files with 6486 additions and 422 deletions

2
.gitignore vendored
View File

@ -10,11 +10,13 @@
# Misc # Misc
.DS_Store .DS_Store
.idea
.env.local .env.local
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
.styles .styles
.vscode
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*

View File

@ -0,0 +1,4 @@
{
"tabWidth": 2,
"semi": true
}

View File

@ -0,0 +1,21 @@
import { PainterContext, SquareCanvas } from "../src/Canvas";
import { useContext, useEffect } from "react";
export function Render({ f }) {
const { width, height, setPainter } = useContext(PainterContext);
useEffect(() => {
if (width && height) {
const painter = f({ width, height });
setPainter(painter);
}
}, [width, height]);
return <></>;
}
export default function Gasket({ f }) {
return (
<SquareCanvas name={"gasket"}>
<Render f={f} />
</SquareCanvas>
);
}

View File

@ -0,0 +1,49 @@
import { useContext, useEffect, useState } from "react";
import { PainterContext } from "../src/Canvas";
import { chaosGameWeighted } from "./chaosGameWeighted";
import TeX from "@matejmazur/react-katex";
import styles from "../src/css/styles.module.css";
type Transform = (x: number, y: number) => [number, number];
export default function GasketWeighted() {
const [f0Weight, setF0Weight] = useState<number>(1);
const [f1Weight, setF1Weight] = useState<number>(1);
const [f2Weight, setF2Weight] = useState<number>(1);
const f0: Transform = (x, y) => [x / 2, y / 2];
const f1: Transform = (x, y) => [(x + 1) / 2, y / 2];
const f2: Transform = (x, y) => [x / 2, (y + 1) / 2];
const { width, height, setPainter } = useContext(PainterContext);
useEffect(() => {
const transforms: [number, Transform][] = [
[f0Weight, f0],
[f1Weight, f1],
[f2Weight, f2]
];
setPainter(chaosGameWeighted({ width, height, transforms }));
}, [f0Weight, f1Weight, f2Weight]);
const weightInput = (title, weight, setWeight) => (
<>
<div className={styles.inputElement}>
<p><TeX>{title}</TeX>: {weight}</p>
<input type={"range"} min={0} max={1} step={.01} value={weight}
onInput={e => setWeight(Number(e.currentTarget.value))} />
</div>
</>
);
return (
<>
<div className={styles.inputGroup} style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr" }}>
{weightInput("F_0", f0Weight, setF0Weight)}
{weightInput("F_1", f1Weight, setF1Weight)}
{weightInput("F_2", f2Weight, setF2Weight)}
</div>
</>
);
}

View File

@ -0,0 +1,10 @@
export function camera(
x: number,
y: number,
size: number
): [number, number] {
return [
Math.floor(x * size),
Math.floor(y * size)
];
}

View File

@ -0,0 +1,34 @@
// Hint: try changing the iteration count
const iterations = 100000;
// Hint: negating `x` and `y` creates some cool images
const xforms = [
(x, y) => [x / 2, y / 2],
(x, y) => [(x + 1) / 2, y / 2],
(x, y) => [x / 2, (y + 1) / 2]
];
function* chaosGame({ width, height }) {
let img =
new ImageData(width, height);
let [x, y] = [
randomBiUnit(),
randomBiUnit()
];
for (let i = 0; i < iterations; i++) {
const index =
randomInteger(0, xforms.length);
[x, y] = xforms[index](x, y);
if (i > 20)
plot(x, y, img);
if (i % 1000 === 0)
yield img;
}
yield img;
}
render(<Gasket f={chaosGame} />);

View File

@ -0,0 +1,43 @@
// hidden-start
import { randomBiUnit } from "../src/randomBiUnit";
import { randomChoice } from "../src/randomChoice";
import { plot } from "./plot";
import { Transform } from "../src/transform";
const quality = 0.5;
const step = 1000;
// hidden-end
export type Props = {
width: number,
height: number,
transforms: [number, Transform][]
}
export function* chaosGameWeighted(
{ width, height, transforms }: 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++) {
// highlight-start
const [_, xform] =
randomChoice(transforms);
// highlight-end
[x, y] = xform(x, y);
if (i > 20)
plot(x, y, img);
if (i % step === 0)
yield img;
}
yield img;
}

View File

@ -0,0 +1,303 @@
---
slug: 2024/11/playing-with-fire
title: "Playing with fire: The fractal flame algorithm"
date: 2024-12-16 21:30:00
authors: [bspeice]
tags: []
---
Wikipedia describes [fractal flames](https://en.wikipedia.org/wiki/Fractal_flame) as:
> a member of the iterated function system class of fractals
It's tedious, but technically correct. I choose to think of them a different way: beauty in mathematics.
import isDarkMode from '@site/src/isDarkMode'
import banner from '../banner.png'
<center>
<img src={banner} style={{filter: isDarkMode() ? '' : 'invert(1)'}}/>
</center>
<!-- truncate -->
I don't remember when exactly I first learned about fractal flames, but I do remember being entranced by the images they created.
I also remember their unique appeal to my young engineering mind; this was an art form I could participate in.
The [Fractal Flame Algorithm paper](https://flam3.com/flame_draves.pdf) describing their structure was too much
for me to handle at the time (I was ~12 years old), so I was content to play around and enjoy the pictures.
But the desire to understand it stuck around. Now, with a graduate degree under my belt, I wanted to revisit it.
This guide is my attempt to explain how fractal flames work so that younger me &mdash; and others interested in the art &mdash;
can understand without too much prior knowledge.
---
## Iterated function systems
:::note
This post covers section 2 of the Fractal Flame Algorithm paper
:::
As mentioned, fractal flames are a type of "[iterated function system](https://en.wikipedia.org/wiki/Iterated_function_system),"
or IFS. The formula for an IFS is short, but takes some time to work through:
$$
S = \bigcup_{i=0}^{n-1} F_i(S)
$$
### Solution set
First, $S$. $S$ is the set of points in two dimensions (in math terms, $S \in \mathbb{R}^2$)
that represent a "solution" of some kind to our equation.
Our goal is to find all the points in $S$, plot them, and display that image.
For example, if we say $S = \{(0,0), (1, 1), (2, 2)\}$, there are three points to plot:
import {VictoryChart, VictoryTheme, VictoryScatter, VictoryLegend} from "victory";
export const simpleData = [
{x: 0, y: 0},
{x: 1, y: 1},
{x: 2, y: 2}
]
<VictoryChart theme={VictoryTheme.clean}>
<VictoryScatter data={simpleData} size={5} style={{data: {fill: "blue"}}}/>
</VictoryChart>
With fractal flames, rather than listing individual points, we use functions to describe the solution.
This means there are an infinite number of points, but if we find _enough_ points to plot, we get a nice picture.
And if the functions change, the solution also changes, and we get something new.
### Transform functions
Second, the $F_i(S)$ functions, also known as "transforms."
Each transform takes in a 2-dimensional point and gives a new point back
(in math terms, $F_i \in \mathbb{R}^2 \rightarrow \mathbb{R}^2$).
While you could theoretically use any function, we'll focus on a specific kind of function
called an "[affine transformation](https://en.wikipedia.org/wiki/Affine_transformation)." Every transform uses the same formula:
$$
F_i(a_i x + b_i y + c_i, d_i x + e_i y + f_i)
$$
import transformSource from "!!raw-loader!../src/transform"
import CodeBlock from '@theme/CodeBlock'
<CodeBlock language="typescript">{transformSource}</CodeBlock>
The parameters ($a_i$, $b_i$, etc.) are values we choose.
For example, we can define a "shift" function like this:
$$
\begin{align*}
a &= 1 \\
b &= 0 \\
c &= 0.5 \\
d &= 0 \\
e &= 1 \\
f &= 1.5 \\
F_{shift}(x, y) &= (1 \cdot x + 0.5, 1 \cdot y + 1.5)
\end{align*}
$$
Applying this transform to the original points gives us a new set of points:
import {applyCoefs} from "../src/transform"
export const coefs = {a: 1, b: 0, c: 0.5, d: 0, e: 1, f: 1.5}
export const toData = ([x, y]) => ({x, y})
export const shiftData = simpleData.map(({x, y}) => toData(applyCoefs(x, y, coefs)))
<VictoryChart theme={VictoryTheme.clean}>
<VictoryScatter data={simpleData} size={5} style={{data: {fill: "blue"}}}/>
<VictoryScatter data={shiftData} size={5} style={{data: {fill: "orange"}}}/>
<VictoryLegend
data={[
{name: "(x,y)", symbol: {fill: "blue"}},
{name: "F(x,y)", symbol: {fill: "orange"}}
]}
orientation={"vertical"}
x={75}
y={10}
/>
</VictoryChart>
Fractal flames use more complex functions, but they all start with this structure.
### Fixed set
With those definitions in place, let's revisit the initial problem:
$$
S = \bigcup_{i=0}^{n-1} F_i(S)
$$
Or, in English, we might say:
> Our solution, $S$, is the union of all sets produced by applying each function, $F_i$,
> to points in the solution.
There's just one small problem: to find the solution, we must already know which points are in the solution.
What?
John E. Hutchinson provides an explanation in the [original paper](https://maths-people.anu.edu.au/~john/Assets/Research%20Papers/fractals_self-similarity.pdf)
defining the mathematics of iterated function systems:
> Furthermore, $S$ is compact and is the closure of the set of fixed points $s_{i_1...i_p}$
> of finite compositions $F_{i_1...i_p}$ of members of $F$.
Before your eyes glaze over, let's unpack this:
- **Furthermore, $S$ is [compact](https://en.wikipedia.org/wiki/Compact_space)...**: All points in our solution will be in a finite range
- **...and is the [closure](https://en.wikipedia.org/wiki/Closure_(mathematics)) of the set of [fixed points](https://en.wikipedia.org/wiki/Fixed_point_(mathematics))**:
Applying our functions to points in the solution will give us other points that are in the solution
- **...of finite compositions $F_{i_1...i_p}$ of members of $F$**: By composing our functions (that is,
using the output of one function as input to the next), we will arrive at the points in the solution
Thus, by applying the functions to fixed points of our system, we will find the other points we care about.
<details>
<summary>If you want a bit more math...</summary>
...then there are some extra details I've glossed over so far.
First, the Hutchinson paper requires that the functions $F_i$ be _contractive_ for the solution set to exist.
That is, applying the function to a point must bring it closer to other points. However, as the fractal flame
algorithm demonstrates, we only need functions to be contractive _on average_. At worst, the system will
degenerate and produce a bad image.
Second, we're focused on $\mathbb{R}^2$ because we're generating images, but the math
allows for arbitrary dimensions; you could also have 3-dimensional fractal flames.
Finally, there's a close relationship between fractal flames and [attractors](https://en.wikipedia.org/wiki/Attractor).
Specifically, the fixed points of $S$ act as attractors for the chaos game (explained below).
</details>
This is still a bit vague, so let's work through an example.
## [Sierpinski's gasket](https://www.britannica.com/biography/Waclaw-Sierpinski)
The Fractal Flame paper gives three functions to use for a first IFS:
$$
F_0(x, y) = \left({x \over 2}, {y \over 2} \right) \\
~\\
F_1(x, y) = \left({{x + 1} \over 2}, {y \over 2} \right) \\
~\\
F_2(x, y) = \left({x \over 2}, {{y + 1} \over 2} \right)
$$
### The chaos game
Now, how do we find the "fixed points" mentioned earlier? The paper lays out an algorithm called the "[chaos game](https://en.wikipedia.org/wiki/Chaos_game)"
that gives us points in the solution:
$$
\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} \text{plot}(x,y) \text{ if iterations} > 20 \\
\}
\end{align*}
$$
:::note
The chaos game algorithm is effectively the "finite compositions of $F_{i_1..i_p}$" mentioned earlier.
:::
Let's turn this into code, one piece at a time.
To start, we need to generate some random numbers. The "bi-unit square" is the range $[-1, 1]$,
and we can do this using an existing API:
import biunitSource from '!!raw-loader!../src/randomBiUnit'
<CodeBlock language="typescript">{biunitSource}</CodeBlock>
Next, we need to choose a random integer from $0$ to $n - 1$:
import randintSource from '!!raw-loader!../src/randomInteger'
<CodeBlock language="typescript">{randintSource}</CodeBlock>
### Plotting
Finally, implementing the `plot` function. This blog series is interactive,
so everything displays directly in the browser. As an alternative,
software like `flam3` and Apophysis can "plot" by saving an image to disk.
To see the results, we'll use the [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API).
This allows us to manipulate individual pixels in an image and show it on screen.
First, we need to convert from fractal flame coordinates to pixel coordinates.
To simplify things, we'll assume that we're plotting a square image
with range $[0, 1]$ for both $x$ and $y$:
import cameraSource from "!!raw-loader!./cameraGasket"
<CodeBlock language="typescript">{cameraSource}</CodeBlock>
Next, we'll store the pixel data in an [`ImageData` object](https://developer.mozilla.org/en-US/docs/Web/API/ImageData).
Each pixel on screen has a corresponding index in the `data` array.
To plot a point, we set that pixel to be black:
import plotSource from '!!raw-loader!./plot'
<CodeBlock language="typescript">{plotSource}</CodeBlock>
Putting it all together, we have our first image:
import Playground from '@theme/Playground'
import Scope from './scope'
import chaosGameSource from '!!raw-loader!./chaosGame'
<Playground scope={Scope} noInline={true}>{chaosGameSource}</Playground>
<hr/>
<small>
The image here is slightly different than in the paper.
I think the paper has an error, so I'm plotting the image
like the [reference implementation](https://github.com/scottdraves/flam3/blob/7fb50c82e90e051f00efcc3123d0e06de26594b2/rect.c#L440-L441).
</small>
### Weights
There's one last step before we finish the introduction. So far, each transform has
the same chance of being picked in the chaos game.
We can change that by giving them a "weight" ($w_i$) instead:
import randomChoiceSource from '!!raw-loader!../src/randomChoice'
<CodeBlock language={'typescript'}>{randomChoiceSource}</CodeBlock>
If we let the chaos game run forever, these weights wouldn't matter.
But because the iteration count is limited, changing the weights
means we don't plot some parts of the image:
import chaosGameWeightedSource from "!!raw-loader!./chaosGameWeighted";
<CodeBlock language={'typescript'}>{chaosGameWeightedSource}</CodeBlock>
:::tip
Double-click the image if you want to save a copy!
:::
import GasketWeighted from "./GasketWeighted";
import {SquareCanvas} from "../src/Canvas";
<SquareCanvas name={"gasket_weighted"}><GasketWeighted/></SquareCanvas>
## Summary
Studying the foundations of fractal flames is challenging,
but we now have an understanding of the mathematics
and the implementation of iterated function systems.
In the next post, we'll look at the first innovation of fractal flame algorithm: variations.

View File

@ -0,0 +1,44 @@
// hidden-start
import { camera } from "./cameraGasket";
// hidden-end
function imageIndex(
x: number,
y: number,
width: number
) {
return y * (width * 4) + x * 4;
}
export function plot(
x: number,
y: number,
img: ImageData
) {
let [pixelX, pixelY] =
camera(x, y, img.width);
// Skip coordinates outside the display
if (
pixelX < 0 ||
pixelX >= img.width ||
pixelY < 0 ||
pixelY >= img.height
)
return;
const i = imageIndex(
pixelX,
pixelY,
img.width
);
// Set the pixel to black by setting
// the first three elements to 0
// (red, green, and blue, respectively),
// and 255 to the last element (alpha)
img.data[i] = 0;
img.data[i + 1] = 0;
img.data[i + 2] = 0;
img.data[i + 3] = 0xff;
}

View File

@ -0,0 +1,12 @@
import Gasket from "./Gasket";
import { plot } from "./plot";
import { randomBiUnit } from "../src/randomBiUnit";
import { randomInteger } from "../src/randomInteger";
const Scope = {
Gasket,
plot,
randomBiUnit,
randomInteger
};
export default Scope;

View File

@ -0,0 +1,52 @@
import TeX from "@matejmazur/react-katex";
import { Coefs } from "../src/transform";
import styles from "../src/css/styles.module.css";
export interface Props {
title: String;
isPost: boolean;
coefs: Coefs;
setCoefs: (coefs: Coefs) => void;
resetCoefs: () => void;
}
export const CoefEditor = ({ title, isPost, coefs, setCoefs, resetCoefs }: Props) => {
const resetButton = <button className={styles.inputReset} onClick={resetCoefs}>Reset</button>;
return (
<div className={styles.inputGroup} style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr" }}>
<p className={styles.inputTitle} style={{ gridColumn: "1/-1" }}>{title} {resetButton}</p>
<div className={styles.inputElement}>
<p>{isPost ? <TeX>\alpha</TeX> : "a"}: {coefs.a}</p>
<input type={"range"} min={-2} max={2} step={0.01} value={coefs.a}
onInput={e => setCoefs({ ...coefs, a: Number(e.currentTarget.value) })} />
</div>
<div className={styles.inputElement}>
<p>{isPost ? <TeX>\beta</TeX> : "b"}: {coefs.b}</p>
<input type={"range"} min={-2} max={2} step={0.01} value={coefs.b}
onInput={e => setCoefs({ ...coefs, b: Number(e.currentTarget.value) })} />
</div>
<div className={styles.inputElement}>
<p>{isPost ? <TeX>\gamma</TeX> : "c"}: {coefs.c}</p>
<input type={"range"} min={-2} max={2} step={0.01} value={coefs.c}
onInput={e => setCoefs({ ...coefs, c: Number(e.currentTarget.value) })} />
</div>
<div className={styles.inputElement}>
<p>{isPost ? <TeX>\delta</TeX> : "d"}: {coefs.d}</p>
<input type={"range"} min={-2} max={2} step={0.01} value={coefs.d}
onInput={e => setCoefs({ ...coefs, d: Number(e.currentTarget.value) })} />
</div>
<div className={styles.inputElement}>
<p>{isPost ? <TeX>\epsilon</TeX> : "e"}: {coefs.e}</p>
<input type={"range"} min={-2} max={2} step={0.01} value={coefs.e}
onInput={e => setCoefs({ ...coefs, e: Number(e.currentTarget.value) })} />
</div>
<div className={styles.inputElement}>
<p>{isPost ? <TeX>\zeta</TeX> : "f"}: {coefs.f}</p>
<input type={"range"} min={-2} max={2} step={0.01} value={coefs.f}
onInput={e => setCoefs({ ...coefs, f: Number(e.currentTarget.value) })} />
</div>
</div>
);
};

View File

@ -0,0 +1,68 @@
import { useContext, useEffect, useState } from "react";
import { Transform } from "../src/transform";
import * as params from "../src/params";
import { PainterContext } from "../src/Canvas";
import { chaosGameFinal } from "./chaosGameFinal";
import { VariationEditor, VariationProps } from "./VariationEditor";
import { applyTransform } from "../src/applyTransform";
import { buildBlend } from "./buildBlend";
export default function FlameBlend() {
const { width, height, setPainter } = useContext(PainterContext);
const xform1VariationsDefault: VariationProps = {
linear: 0,
julia: 1,
popcorn: 0,
pdj: 0
};
const [xform1Variations, setXform1Variations] = useState(xform1VariationsDefault);
const resetXform1Variations = () => setXform1Variations(xform1VariationsDefault);
const xform2VariationsDefault: VariationProps = {
linear: 1,
julia: 0,
popcorn: 1,
pdj: 0
};
const [xform2Variations, setXform2Variations] = useState(xform2VariationsDefault);
const resetXform2Variations = () => setXform2Variations(xform2VariationsDefault);
const xform3VariationsDefault: VariationProps = {
linear: 0,
julia: 0,
popcorn: 0,
pdj: 1
};
const [xform3Variations, setXform3Variations] = useState(xform3VariationsDefault);
const resetXform3Variations = () => setXform3Variations(xform3VariationsDefault);
const identityXform: Transform = (x, y) => [x, y];
useEffect(() => {
const transforms: [number, Transform][] = [
[params.xform1Weight, applyTransform(params.xform1Coefs, buildBlend(params.xform1Coefs, xform1Variations))],
[params.xform2Weight, applyTransform(params.xform2Coefs, buildBlend(params.xform2Coefs, xform2Variations))],
[params.xform3Weight, applyTransform(params.xform3Coefs, buildBlend(params.xform3Coefs, xform3Variations))]
];
const gameParams = {
width,
height,
transforms,
final: identityXform
};
setPainter(chaosGameFinal(gameParams));
}, [xform1Variations, xform2Variations, xform3Variations]);
return (
<>
<VariationEditor title={"Transform 1"} variations={xform1Variations} setVariations={setXform1Variations}
resetVariations={resetXform1Variations} />
<VariationEditor title={"Transform 2"} variations={xform2Variations} setVariations={setXform2Variations}
resetVariations={resetXform2Variations} />
<VariationEditor title={"Transform 3"} variations={xform3Variations} setVariations={setXform3Variations}
resetVariations={resetXform3Variations} />
</>
);
}

View File

@ -0,0 +1,46 @@
import { useContext, useEffect, useState } from "react";
import { Coefs } from "../src/transform";
import * as params from "../src/params";
import { PainterContext } from "../src/Canvas";
import { buildBlend } from "./buildBlend";
import { chaosGameFinal } from "./chaosGameFinal";
import { VariationEditor, VariationProps } from "./VariationEditor";
import { CoefEditor } from "./CoefEditor";
import { applyPost, applyTransform } from "../src/applyTransform";
export default function FlameFinal() {
const { width, height, setPainter } = useContext(PainterContext);
const [xformFinalCoefs, setXformFinalCoefs] = useState<Coefs>(params.xformFinalCoefs);
const resetXformFinalCoefs = () => setXformFinalCoefs(params.xformFinalCoefs);
const xformFinalVariationsDefault: VariationProps = {
linear: 0,
julia: 1,
popcorn: 0,
pdj: 0
};
const [xformFinalVariations, setXformFinalVariations] = useState<VariationProps>(xformFinalVariationsDefault);
const resetXformFinalVariations = () => setXformFinalVariations(xformFinalVariationsDefault);
const [xformFinalCoefsPost, setXformFinalCoefsPost] = useState<Coefs>(params.xformFinalCoefsPost);
const resetXformFinalCoefsPost = () => setXformFinalCoefsPost(params.xformFinalCoefsPost);
useEffect(() => {
const finalBlend = buildBlend(xformFinalCoefs, xformFinalVariations);
const finalXform = applyPost(xformFinalCoefsPost, applyTransform(xformFinalCoefs, finalBlend));
setPainter(chaosGameFinal({ width, height, transforms: params.xforms, final: finalXform }));
}, [xformFinalCoefs, xformFinalVariations, xformFinalCoefsPost]);
return (
<>
<CoefEditor title={"Final Transform"} isPost={false} coefs={xformFinalCoefs} setCoefs={setXformFinalCoefs}
resetCoefs={resetXformFinalCoefs} />
<VariationEditor title={"Final Transform Variations"} variations={xformFinalVariations}
setVariations={setXformFinalVariations} resetVariations={resetXformFinalVariations} />
<CoefEditor title={"Final Transform Post"} isPost={true} coefs={xformFinalCoefsPost}
setCoefs={setXformFinalCoefsPost} resetCoefs={resetXformFinalCoefsPost} />
</>
);
}

View File

@ -0,0 +1,46 @@
import { useContext, useEffect, useState } from "react";
import { applyTransform } from "../src/applyTransform";
import { Coefs, Transform } from "../src/transform";
import * as params from "../src/params";
import { PainterContext } from "../src/Canvas";
import { chaosGameFinal, Props as ChaosGameFinalProps } from "./chaosGameFinal";
import { CoefEditor } from "./CoefEditor";
import { transformPost } from "./post";
export default function FlamePost() {
const { width, height, setPainter } = useContext(PainterContext);
const [xform1CoefsPost, setXform1CoefsPost] = useState<Coefs>(params.xform1CoefsPost);
const resetXform1CoefsPost = () => setXform1CoefsPost(params.xform1CoefsPost);
const [xform2CoefsPost, setXform2CoefsPost] = useState<Coefs>(params.xform2CoefsPost);
const resetXform2CoefsPost = () => setXform2CoefsPost(params.xform2CoefsPost);
const [xform3CoefsPost, setXform3CoefsPost] = useState<Coefs>(params.xform3CoefsPost);
const resetXform3CoefsPost = () => setXform3CoefsPost(params.xform3CoefsPost);
const identityXform: Transform = (x, y) => [x, y];
const gameParams: ChaosGameFinalProps = {
width,
height,
transforms: [
[params.xform1Weight, transformPost(applyTransform(params.xform1Coefs, params.xform1Variations), xform1CoefsPost)],
[params.xform2Weight, transformPost(applyTransform(params.xform2Coefs, params.xform2Variations), xform2CoefsPost)],
[params.xform3Weight, transformPost(applyTransform(params.xform3Coefs, params.xform3Variations), xform3CoefsPost)]
],
final: identityXform
};
useEffect(() => setPainter(chaosGameFinal(gameParams)), [xform1CoefsPost, xform2CoefsPost, xform3CoefsPost]);
return (
<>
<CoefEditor title={"Transform 1 Post"} isPost={true} coefs={xform1CoefsPost} setCoefs={setXform1CoefsPost}
resetCoefs={resetXform1CoefsPost} />
<CoefEditor title={"Transform 2 Post"} isPost={true} coefs={xform2CoefsPost} setCoefs={setXform2CoefsPost}
resetCoefs={resetXform2CoefsPost} />
<CoefEditor title={"Transform 3 Post"} isPost={true} coefs={xform3CoefsPost} setCoefs={setXform3CoefsPost}
resetCoefs={resetXform3CoefsPost} />
</>
);
}

View File

@ -0,0 +1,45 @@
import styles from "../src/css/styles.module.css";
export interface VariationProps {
linear: number;
julia: number;
popcorn: number;
pdj: number;
}
export interface Props {
title: String;
variations: VariationProps;
setVariations: (variations: VariationProps) => void;
resetVariations: () => void;
}
export const VariationEditor = ({ title, variations, setVariations, resetVariations }: Props) => {
const resetButton = <button className={styles.inputReset} onClick={resetVariations}>Reset</button>;
return (
<div className={styles.inputGroup} style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
<p className={styles.inputTitle} style={{ gridColumn: "1/-1" }}>{title} {resetButton}</p>
<div className={styles.inputElement}>
<span>Linear: {variations.linear}</span>
<input type={"range"} min={0} max={1} step={0.01} style={{ width: "100%" }} value={variations.linear}
onInput={e => setVariations({ ...variations, linear: Number(e.currentTarget.value) })} />
</div>
<div className={styles.inputElement}>
<span>Julia: {variations.julia}</span>
<input type={"range"} min={0} max={1} step={0.01} style={{ width: "100%" }} value={variations.julia}
onInput={e => setVariations({ ...variations, julia: Number(e.currentTarget.value) })} />
</div>
<div className={styles.inputElement}>
<span>Popcorn: {variations.popcorn}</span>
<input type={"range"} min={0} max={1} step={0.01} style={{ width: "100%" }} value={variations.popcorn}
onInput={e => setVariations({ ...variations, popcorn: Number(e.currentTarget.value) })} />
</div>
<div className={styles.inputElement}>
<span>PDJ: {variations.pdj}</span>
<input type={"range"} min={0} max={1} step={0.01} style={{ width: "100%" }} value={variations.pdj}
onInput={e => setVariations({ ...variations, pdj: Number(e.currentTarget.value) })} />
</div>
</div>
);
};

View File

@ -0,0 +1,17 @@
import { Coefs } from "../src/transform";
import { VariationProps } from "./VariationEditor";
import { linear } from "../src/linear";
import { julia } from "../src/julia";
import { popcorn } from "../src/popcorn";
import { pdj } from "../src/pdj";
import { pdjParams } from "../src/params";
import { Blend } from "../src/blend";
export function buildBlend(coefs: Coefs, variations: VariationProps): Blend {
return [
[variations.linear, linear],
[variations.julia, julia],
[variations.popcorn, popcorn(coefs)],
[variations.pdj, pdj(pdjParams)]
];
}

View File

@ -0,0 +1,51 @@
// hidden-start
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;
// hidden-end
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);
// highlight-start
const [finalX, finalY] = final(x, y);
// highlight-end
if (i > 20)
// highlight-start
plot(finalX, finalY, img);
// highlight-end
if (i % step === 0)
yield img;
}
yield img;
}

View File

@ -0,0 +1,216 @@
---
slug: 2024/11/playing-with-fire-transforms
title: "Playing with fire: Transforms and variations"
date: 2024-12-16 21:31:00
authors: [bspeice]
tags: []
---
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.
<!-- truncate -->
:::info
This post uses [reference parameters](../params.flame) to demonstrate the fractal flame algorithm.
If you're interested in tweaking the parameters, or creating your own, [Apophysis](https://sourceforge.net/projects/apophysis/)
can load that file.
:::
## Variations
:::note
This post covers section 3 of the Fractal Flame Algorithm paper
:::
import CodeBlock from '@theme/CodeBlock'
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":
$$
F_i(x, y) = V_j(a_i x + b_i y + c_i, d_i x + e_i y + f_i)
$$
import variationSource from '!!raw-loader!../src/variation'
<CodeBlock language="typescript">{variationSource}</CodeBlock>
Just like transforms, variations ($V_j$) are functions that take in $(x, y)$ coordinates
and give back new $(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](https://github.com/scottdraves/flam3/blob/7fb50c82e90e051f00efcc3123d0e06de26594b2/variations.c).
To draw our reference image, we'll focus on just four:
### Linear (variation 0)
This variation is dead simple: return the $x$ and $y$ coordinates as-is.
$$
V_0(x,y) = (x,y)
$$
import linearSrc from '!!raw-loader!../src/linear'
<CodeBlock language={'typescript'}>{linearSrc}</CodeBlock>
:::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:
$$
\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 juliaSrc from '!!raw-loader!../src/julia'
<CodeBlock language={'typescript'}>{juliaSrc}</CodeBlock>
### Popcorn (variation 17)
Some variations rely on knowing the transform's affine coefficients; they're called "dependent variations."
For this variation, we use $c$ and $f$:
$$
V_{17}(x,y) = (x + c\ \text{sin}(\text{tan }3y), y + f\ \text{sin}(\text{tan }3x))
$$
import popcornSrc from '!!raw-loader!../src/popcorn'
<CodeBlock language={'typescript'}>{popcornSrc}</CodeBlock>
### 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:
$$
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 pdjSrc from '!!raw-loader!../src/pdj'
<CodeBlock language={'typescript'}>{pdjSrc}</CodeBlock>
## Blending
Now, one variation is fun, but we can also combine variations in a process called "blending."
Each variation receives the same $x$ and $y$ inputs, and we add together each variation's $x$ and $y$ outputs.
We'll also give each variation a weight ($v_{ij}$) that changes how much it contributes to the result:
$$
F_i(x,y) = \sum_{j} v_{ij} V_j(x, y)
$$
The formula looks intimidating, but it's not hard to implement:
import blendSource from "!!raw-loader!../src/blend";
<CodeBlock language={'typescript'}>{blendSource}</CodeBlock>
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.
:::
import {SquareCanvas} from "../src/Canvas";
import FlameBlend from "./FlameBlend";
<SquareCanvas name={"flame_blend"}><FlameBlend/></SquareCanvas>
## 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:
$$
\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 postSource from '!!raw-loader!./post'
<CodeBlock language="typescript">{postSource}</CodeBlock>
The image below uses the same transforms/variations as the previous fractal flame,
but allows changing the post-transform coefficients:
<details>
<summary>If you want to test your understanding...</summary>
- What post-transform coefficients will give us the previous image?
- What post-transform coefficients will give us a _mirrored_ image?
</details>
import FlamePost from "./FlamePost";
<SquareCanvas name={"flame_post"}><FlamePost/></SquareCanvas>
## Final transforms
The last step is to introduce a "final transform" ($F_{final}$) that is applied
regardless of which regular transform ($F_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:
$$
\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 chaosGameFinalSource from "!!raw-loader!./chaosGameFinal"
<CodeBlock language="typescript">{chaosGameFinalSource}</CodeBlock>
This image uses the same normal/post transforms as above, but allows modifying
the coefficients and variations of the final transform:
import FlameFinal from "./FlameFinal";
<SquareCanvas name={"flame_final"}><FlameFinal/></SquareCanvas>
## 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.

View File

@ -0,0 +1,11 @@
// hidden-start
import { applyCoefs, Coefs, Transform } from "../src/transform";
// hidden-end
export const transformPost = (
transform: Transform,
coefs: Coefs
): Transform =>
(x, y) => {
[x, y] = transform(x, y);
return applyCoefs(x, y, coefs);
}

View File

@ -0,0 +1,181 @@
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
import * as params from "../src/params";
import { PainterContext } from "../src/Canvas";
import { colorFromPalette } from "./colorFromPalette";
import { chaosGameColor, Props as ChaosGameColorProps, TransformColor } from "./chaosGameColor";
import styles from "../src/css/styles.module.css";
import { histIndex } from "../src/camera";
import { useColorMode } from "@docusaurus/theme-common";
type PaletteBarProps = {
height: number;
palette: number[];
children?: React.ReactNode;
}
export const PaletteBar: React.FC<PaletteBarProps> = ({ height, palette, children }) => {
const sizingRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
if (sizingRef) {
setWidth(sizingRef.current.offsetWidth);
}
}, [sizingRef]);
const canvasRef = useRef<HTMLCanvasElement>(null);
const paletteImage = useMemo(() => {
if (width === 0) {
return;
}
const image = new ImageData(width, height);
for (let x = 0; x < width; x++) {
const colorIndex = x / width;
const [r, g, b] = colorFromPalette(palette, colorIndex);
for (let y = 0; y < height; y++) {
const pixelIndex = histIndex(x, y, width, 4);
image.data[pixelIndex] = r * 0xff;
image.data[pixelIndex + 1] = g * 0xff;
image.data[pixelIndex + 2] = b * 0xff;
image.data[pixelIndex + 3] = 0xff;
}
}
return image;
}, [width, height, palette]);
useEffect(() => {
if (canvasRef && paletteImage) {
canvasRef.current.getContext("2d").putImageData(paletteImage, 0, 0);
}
}, [canvasRef, paletteImage]);
const canvasStyle = { filter: useColorMode().colorMode === "dark" ? "invert(1)" : "" };
return (
<>
<div ref={sizingRef} style={{ width: "100%", height }}>
{width > 0 ? <canvas ref={canvasRef} width={width} height={height} style={canvasStyle} /> : null}
</div>
{children}
</>
);
};
type ColorEditorProps = {
title: string;
palette: number[];
transformColor: TransformColor;
setTransformColor: (transformColor: TransformColor) => void;
resetTransformColor: () => void;
children?: React.ReactNode;
}
const ColorEditor: React.FC<ColorEditorProps> = (
{
title,
palette,
transformColor,
setTransformColor,
resetTransformColor,
children
}) => {
const resetButton = <button className={styles.inputReset} onClick={resetTransformColor}>Reset</button>;
const [r, g, b] = colorFromPalette(palette, transformColor.color);
const colorCss = `rgb(${Math.floor(r * 0xff)},${Math.floor(g * 0xff)},${Math.floor(b * 0xff)})`;
const { colorMode } = useColorMode();
return (
<>
<div className={styles.inputGroup} style={{ display: "grid", gridTemplateColumns: "2fr 2fr 1fr" }}>
<p className={styles.inputTitle} style={{ gridColumn: "1/-1" }}>{title} {resetButton}</p>
<div className={styles.inputElement}>
<p>Color: {transformColor.color}</p>
<input type={"range"} min={0} max={1} step={.001} value={transformColor.color}
onInput={e => setTransformColor({ ...transformColor, color: Number(e.currentTarget.value) })} />
</div>
<div className={styles.inputElement}>
<p>Speed: {transformColor.colorSpeed}</p>
<input type={"range"} min={0} max={1} step={.001} value={transformColor.colorSpeed}
onInput={e => setTransformColor({ ...transformColor, colorSpeed: Number(e.currentTarget.value) })} />
</div>
<div className={styles.inputElement} style={{
width: "100%",
height: "100%",
backgroundColor: colorCss,
filter: colorMode === "dark" ? "invert(1)" : ""
}} />
</div>
{children}
</>
);
};
type Props = {
children?: React.ReactElement;
}
export default function FlameColor({ children }: Props) {
const { width, height, setPainter } = useContext(PainterContext);
const xform1ColorDefault: TransformColor = { color: params.xform1Color, colorSpeed: 0.5 };
const [xform1Color, setXform1Color] = useState(xform1ColorDefault);
const resetXform1Color = () => setXform1Color(xform1ColorDefault);
const xform2ColorDefault: TransformColor = { color: params.xform2Color, colorSpeed: 0.5 };
const [xform2Color, setXform2Color] = useState(xform2ColorDefault);
const resetXform2Color = () => setXform2Color(xform2ColorDefault);
const xform3ColorDefault: TransformColor = { color: params.xform3Color, colorSpeed: 0.5 };
const [xform3Color, setXform3Color] = useState(xform3ColorDefault);
const resetXform3Color = () => setXform3Color(xform3ColorDefault);
const xformFinalColorDefault: TransformColor = { color: params.xformFinalColor, colorSpeed: 0 };
const [xformFinalColor, setXformFinalColor] = useState(xformFinalColorDefault);
const resetXformFinalColor = () => setXformFinalColor(xformFinalColorDefault);
useEffect(() => {
const gameParams: ChaosGameColorProps = {
width,
height,
transforms: params.xforms,
final: params.xformFinal,
palette: params.palette,
colors: [xform1Color, xform2Color, xform3Color],
finalColor: xformFinalColor
};
setPainter(chaosGameColor(gameParams));
}, [xform1Color, xform2Color, xform3Color, xformFinalColor]);
return (
<>
<PaletteBar height={40} palette={params.palette} />
<ColorEditor
title={"Transform 1"}
palette={params.palette}
transformColor={xform1Color}
setTransformColor={setXform1Color}
resetTransformColor={resetXform1Color} />
<ColorEditor
title={"Transform 2"}
palette={params.palette}
transformColor={xform2Color}
setTransformColor={setXform2Color}
resetTransformColor={resetXform2Color} />
<ColorEditor
title={"Transform 3"}
palette={params.palette}
transformColor={xform3Color}
setTransformColor={setXform3Color}
resetTransformColor={resetXform3Color} />
<ColorEditor
title={"Transform Final"}
palette={params.palette}
transformColor={xformFinalColor}
setTransformColor={setXformFinalColor}
resetTransformColor={resetXformFinalColor} />
{children}
</>
);
}

View File

@ -0,0 +1,25 @@
import React, { useContext, useEffect } from "react";
import { xformFinal as final, xforms as transforms } from "../src/params";
import { PainterContext } from "../src/Canvas";
import { chaosGameHistogram } from "./chaosGameHistogram";
type Props = {
paint: (width: number, height: number, histogram: number[]) => ImageData;
children?: React.ReactElement;
}
export default function FlameHistogram({ paint, children }: Props) {
const { width, height, setPainter } = useContext(PainterContext);
useEffect(() => {
const gameParams = {
width,
height,
transforms,
final,
paint
};
setPainter(chaosGameHistogram(gameParams));
}, [width, height]);
return children;
}

View File

@ -0,0 +1,135 @@
// hidden-start
import { Props as ChaosGameFinalProps } from "../2-transforms/chaosGameFinal";
import { randomBiUnit } from "../src/randomBiUnit";
import { randomChoice } from "../src/randomChoice";
import { camera, histIndex } from "../src/camera";
import { colorFromPalette } from "./colorFromPalette";
import { mixColor } from "./mixColor";
import { paintColor } from "./paintColor";
const quality = 15;
const step = 100_000;
// hidden-end
export type TransformColor = {
color: number;
colorSpeed: number;
}
export type Props = ChaosGameFinalProps & {
palette: number[];
colors: TransformColor[];
finalColor: TransformColor;
}
export function* chaosGameColor(
{
width,
height,
transforms,
final,
palette,
colors,
finalColor
}: Props
) {
const pixels = width * height;
// highlight-start
const imgRed = Array<number>(pixels)
.fill(0);
const imgGreen = Array<number>(pixels)
.fill(0);
const imgBlue = Array<number>(pixels)
.fill(0);
const imgAlpha = Array<number>(pixels)
.fill(0);
const plotColor = (
x: number,
y: number,
c: number
) => {
const [pixelX, pixelY] =
camera(x, y, width);
if (
pixelX < 0 ||
pixelX >= width ||
pixelY < 0 ||
pixelY >= width
)
return;
const hIndex =
histIndex(pixelX, pixelY, width, 1);
const [r, g, b] =
colorFromPalette(palette, c);
imgRed[hIndex] += r;
imgGreen[hIndex] += g;
imgBlue[hIndex] += b;
imgAlpha[hIndex] += 1;
}
// highlight-end
let [x, y] = [
randomBiUnit(),
randomBiUnit()
];
let c = Math.random();
const iterations = quality * pixels;
for (let i = 0; i < iterations; i++) {
const [transformIndex, transform] =
randomChoice(transforms);
[x, y] = transform(x, y);
// highlight-start
const transformColor =
colors[transformIndex];
c = mixColor(
c,
transformColor.color,
transformColor.colorSpeed
);
// highlight-end
const [finalX, finalY] = final(x, y);
// highlight-start
const finalC = mixColor(
c,
finalColor.color,
finalColor.colorSpeed
);
// highlight-end
if (i > 20)
plotColor(
finalX,
finalY,
finalC
)
if (i % step === 0)
yield paintColor(
width,
height,
imgRed,
imgGreen,
imgBlue,
imgAlpha
);
}
yield paintColor(
width,
height,
imgRed,
imgGreen,
imgBlue,
imgAlpha
);
}

View File

@ -0,0 +1,78 @@
// hidden-start
import { randomBiUnit } from "../src/randomBiUnit";
import { randomChoice } from "../src/randomChoice";
import { Props as ChaosGameFinalProps } from "../2-transforms/chaosGameFinal";
import { camera, histIndex } from "../src/camera";
const quality = 10;
const step = 100_000;
// hidden-end
type Props = ChaosGameFinalProps & {
paint: (
width: number,
height: number,
histogram: number[]
) => ImageData;
}
export function* chaosGameHistogram(
{
width,
height,
transforms,
final,
paint
}: Props
) {
const pixels = width * height;
const iterations = quality * pixels;
// highlight-start
const hist = Array<number>(pixels)
.fill(0);
const plotHist = (
x: number,
y: number
) => {
const [pixelX, pixelY] =
camera(x, y, width);
if (
pixelX < 0 ||
pixelX >= width ||
pixelY < 0 ||
pixelY >= height
)
return;
const hIndex =
histIndex(pixelX, pixelY, width, 1);
hist[hIndex] += 1;
};
// highlight-end
let [x, y] = [
randomBiUnit(),
randomBiUnit()
];
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) {
// highlight-start
plotHist(finalX, finalY);
// highlight-end
}
if (i % step === 0)
yield paint(width, height, hist);
}
yield paint(width, height, hist);
}

View File

@ -0,0 +1,14 @@
export function colorFromPalette(
palette: number[],
colorIndex: number
): [number, number, number] {
const numColors = palette.length / 3;
const paletteIndex = Math.floor(
colorIndex * (numColors)
) * 3;
return [
palette[paletteIndex], // red
palette[paletteIndex + 1], // green
palette[paletteIndex + 2] // blue
];
}

View File

@ -0,0 +1,222 @@
---
slug: 2024/11/playing-with-fire-log-density
title: "Playing with fire: Tone mapping and color"
date: 2024-12-16 21:32:00
authors: [bspeice]
tags: []
---
So far, our `plot()` function has been fairly simple: map a fractal flame coordinate to a specific pixel,
and color in that pixel. This works well for simple function systems (like Sierpinski's Gasket),
but more complex systems (like the reference parameters) produce grainy images.
In this post, we'll refine the image quality and add color to really make things shine.
<!-- truncate -->
## Image histograms
:::note
This post covers sections 4 and 5 of the Fractal Flame Algorithm paper
:::
One problem with the current chaos game algorithm is that we waste work
because pixels are either "on" (opaque) or "off" (transparent).
If the chaos game encounters the same pixel twice, nothing changes.
To demonstrate how much work is wasted, we'll count each time the chaos game
visits a pixel while iterating. This gives us a kind of image "histogram":
import chaosGameHistogramSource from "!!raw-loader!./chaosGameHistogram"
<CodeBlock language="typescript">{chaosGameHistogramSource}</CodeBlock>
When the chaos game finishes, we find the pixel encountered most often.
Finally, we "paint" the image by setting each pixel's alpha (transparency) value
to the ratio of times visited divided by the maximum:
import CodeBlock from "@theme/CodeBlock";
import paintLinearSource from "!!raw-loader!./paintLinear"
<CodeBlock language="typescript">{paintLinearSource}</CodeBlock>
import {SquareCanvas} from "../src/Canvas";
import FlameHistogram from "./FlameHistogram";
import {paintLinear} from "./paintLinear";
<SquareCanvas><FlameHistogram paint={paintLinear}/></SquareCanvas>
## Tone mapping
While using a histogram reduces the "graining," it also leads to some parts vanishing entirely.
In the reference parameters, the outer circle is still there, but the interior is gone!
To fix this, we'll introduce the second major innovation of the fractal flame algorithm: [tone mapping](https://en.wikipedia.org/wiki/Tone_mapping).
This is a technique used in computer graphics to compensate for differences in how
computers represent brightness, and how people actually see brightness.
As a concrete example, high-dynamic-range (HDR) photography uses this technique to capture
scenes with a wide range of brightnesses. To take a picture of something dark,
you need a long exposure time. However, long exposures lead to "hot spots" (sections that are pure white).
By taking multiple pictures with different exposure times, we can combine them to create
a final image where everything is visible.
In fractal flames, this "tone map" is accomplished by scaling brightness according to the _logarithm_
of how many times we encounter a pixel. This way, "cold spots" (pixels the chaos game visits infrequently)
are still visible, and "hot spots" (pixels the chaos game visits frequently) won't wash out.
<details>
<summary>Log-scale vibrancy also explains fractal flames appear to be 3D...</summary>
As mentioned in the paper:
> Where one branch of the fractal crosses another, one may appear to occlude the other
> if their densities are different enough because the lesser density is inconsequential in sum.
> For example, branches of densities 1000 and 100 might have brightnesses of 30 and 20.
> Where they cross the density is 1100, whose brightness is 30.4, which is
> hardly distinguishable from 30.
</details>
import paintLogarithmicSource from "!!raw-loader!./paintLogarithmic"
<CodeBlock language="typescript">{paintLogarithmicSource}</CodeBlock>
import {paintLogarithmic} from './paintLogarithmic'
<SquareCanvas><FlameHistogram paint={paintLogarithmic}/></SquareCanvas>
## Color
Now we'll introduce the last innovation of the fractal flame algorithm: color.
By including a third coordinate ($c$) in the chaos game, we can illustrate the transforms
responsible for the image.
### Color coordinate
Color in a fractal flame is continuous on the range $[0, 1]$. This is important for two reasons:
- It helps blend colors together in the final image. Slight changes in the color value lead to
slight changes in the actual color
- It allows us to swap in new color palettes easily. We're free to choose what actual colors
each value represents
We'll give each transform a color value ($c_i$) in the $[0, 1]$ range.
The final transform gets a value too ($c_f$).
Then, at each step in the chaos game, we'll set the current color
by blending it with the previous color:
$$
\begin{align*}
&(x, y) = \text{random point in the bi-unit square} \\
&c = \text{random point from [0,1]} \\
&\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} c = (c + c_i) / 2 \\
&\hspace{1cm} \text{plot}(x_f,y_f,c_f) \text{ if iterations} > 20 \\
\}
\end{align*}
$$
### Color speed
:::warning
Color speed isn't introduced in the Fractal Flame Algorithm paper.
It is included here because [`flam3` implements it](https://github.com/scottdraves/flam3/blob/7fb50c82e90e051f00efcc3123d0e06de26594b2/variations.c#L2140),
and because it's fun to play with.
:::
Next, we'll add a parameter to each transform that controls how much it changes the current color.
This is known as the "color speed" ($s_i$):
$$
c = c \cdot (1 - s_i) + c_i \cdot s_i
$$
import mixColorSource from "!!raw-loader!./mixColor"
<CodeBlock language="typescript">{mixColorSource}</CodeBlock>
Color speed values work just like transform weights. A value of 1
means we take the transform color and ignore the previous color state.
A value of 0 means we keep the current color state and ignore the
transform color.
### Palette
Now, we need to map the color coordinate to a pixel color. Fractal flames typically use
256 colors (each color has 3 values - red, green, blue) to define a palette.
The color coordinate then becomes an index into the palette.
There's one small complication: the color coordinate is continuous, but the palette
uses discrete colors. How do we handle situations where the color coordinate is
"in between" the colors of our palette?
One way to handle this is a step function. In the code below, we multiply the color coordinate
by the number of colors in the palette, then truncate that value. This gives us a discrete index:
import colorFromPaletteSource from "!!raw-loader!./colorFromPalette";
<CodeBlock language="typescript">{colorFromPaletteSource}</CodeBlock>
<details>
<summary>As an alternative...</summary>
...you could interpolate between colors in the palette.
For example, `flam3` uses [linear interpolation](https://github.com/scottdraves/flam3/blob/7fb50c82e90e051f00efcc3123d0e06de26594b2/rect.c#L483-L486)
</details>
In the diagram below, each color in the palette is plotted on a small vertical strip.
Putting the strips side by side shows the full palette used by the reference parameters:
import * as params from "../src/params"
import {PaletteBar} from "./FlameColor"
<PaletteBar height="40" palette={params.palette}/>
### Plotting
We're now ready to plot our $(x_f,y_f,c_f)$ coordinates. This time, we'll use a histogram
for each color channel (red, green, blue, alpha). After translating from color coordinate ($c_f$)
to RGB value, add that to the histogram:
import chaosGameColorSource from "!!raw-loader!./chaosGameColor"
<CodeBlock language="typescript">{chaosGameColorSource}</CodeBlock>
Finally, painting the image. With tone mapping, logarithms scale the image brightness to match
how it is perceived. With color, we use a similar method, but scale each color channel
by the alpha channel:
import paintColorSource from "!!raw-loader!./paintColor"
<CodeBlock language="typescript">{paintColorSource}</CodeBlock>
And now, at long last, a full-color fractal flame:
import FlameColor from "./FlameColor";
<SquareCanvas><FlameColor/></SquareCanvas>
## Summary
Tone mapping is the second major innovation of the fractal flame algorithm.
By tracking how often the chaos game encounters each pixel, we can adjust
brightness/transparency to reduce the visual "graining" of previous images.
Next, introducing a third coordinate to the chaos game makes color images possible,
the third major innovation of the fractal flame algorithm. Using a continuous
color scale and color palette adds a splash of excitement to the image.
The Fractal Flame Algorithm paper goes on to describe more techniques
not covered here. For example, image quality can be improved with density estimation
and filtering. New parameters can be generated by "mutating" existing
fractal flames. And fractal flames can even be animated to produce videos!
That said, I think this is a good place to wrap up. We went from
an introduction to the mathematics of fractal systems all the way to
generating full-color images. Fractal flames are a challenging topic,
but it's extremely rewarding to learn about how they work.

View File

@ -0,0 +1,8 @@
export function mixColor(
color1: number,
color2: number,
colorSpeed: number
) {
return color1 * (1 - colorSpeed) +
color2 * colorSpeed;
}

View File

@ -0,0 +1,34 @@
export function paintColor(
width: number,
height: number,
red: number[],
green: number[],
blue: number[],
alpha: number[]
): ImageData {
const pixels = width * height;
const img =
new ImageData(width, height);
for (let i = 0; i < pixels; i++) {
const scale =
Math.log10(alpha[i]) /
(alpha[i] * 1.5);
const pixelIndex = i * 4;
const rVal = red[i] * scale * 0xff;
img.data[pixelIndex] = rVal;
const gVal = green[i] * scale * 0xff;
img.data[pixelIndex + 1] = gVal;
const bVal = blue[i] * scale * 0xff;
img.data[pixelIndex + 2] = bVal;
const aVal = alpha[i] * scale * 0xff;
img.data[pixelIndex + 3] = aVal;
}
return img;
}

View File

@ -0,0 +1,26 @@
export function paintLinear(
width: number,
height: number,
hist: number[]
) {
const img =
new ImageData(width, height);
let hMax = 0;
for (let value of hist) {
hMax = Math.max(hMax, value);
}
for (let i = 0; i < hist.length; i++) {
const pixelIndex = i * 4;
img.data[pixelIndex] = 0;
img.data[pixelIndex + 1] = 0;
img.data[pixelIndex + 2] = 0;
const alpha = hist[i] / hMax * 0xff;
img.data[pixelIndex + 3] = alpha;
}
return img;
}

View File

@ -0,0 +1,29 @@
export function paintLogarithmic(
width: number,
height: number,
hist: number[]
) {
const img =
new ImageData(width, height);
const histLog = hist.map(Math.log);
let hLogMax = -Infinity;
for (let value of histLog) {
hLogMax = Math.max(hLogMax, value);
}
for (let i = 0; i < hist.length; i++) {
const pixelIndex = i * 4;
img.data[pixelIndex] = 0; // red
img.data[pixelIndex + 1] = 0; // green
img.data[pixelIndex + 2] = 0; // blue
const alpha =
histLog[i] / hLogMax * 0xff;
img.data[pixelIndex + 3] = alpha;
}
return img;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

View File

@ -0,0 +1,120 @@
<Flames name="params">
<flame name="post xform" version="Apophysis 2.08 beta" size="600 600" center="0 0" scale="150" oversample="1" filter="0.2" quality="1" background="0 0 0" brightness="4" gamma="4" >
<xform weight="0.564534951145298" color="0" julia="1" coefs="-1.381068 1.381068 -1.381068 -1.381068 0 0" post="1 0 0 1 2 0"/>
<xform weight="0.0131350067581356" color="0" linear="1" popcorn="1" coefs="0.031393 -0.031367 0.031367 0.031393 0 0" post="1 0 0 1 0.24 0.27" />
<xform weight="0.422330042096567" color="0" pdj="1" coefs="1.51523 0.740356 -3.048677 -1.455964 0.724135 -0.362059" pdj_a="1.09358" pdj_b="2.13048" pdj_c="2.54127" pdj_d="2.37267" />
<palette count="256" format="RGB">
3130323635383B3A3D403F424644484B494D504E52565358
5B585D605D626562686B676D706C737571787B767D807B83
8580888A858D908A93958F989A949DA099A3A59EA8AAA3AD
AFA8B3B5ADB8BAB2BEBFB7C3C5BCC8CAC1CECFC6D3D4CBD8
DAD0DEDFD5E3DFD2E0DFCEDDE0CBDAE0C8D7E0C4D3E0C1D0
E1BECDE1BBCAE1B7C7E1B4C4E1B1C1E2ADBEE2AABAE2A7B7
E2A3B4E2A0B1E39DAEE399ABE396A8E393A5E490A1E48C9E
E4899BE48698E48295E57F92E57C8FE5788CE57589E57285
E66E82E66B7FE6687CE66479E76176E75E73E75B70E7576C
E75469E85166E84D63E84A60E4495EE0485CDC475BD84659
D44557D04455CB4353C74252C34150BF404EBB3F4CB73E4B
B33D49AF3C47AB3B45A73A43A339429F38409B373E97363C
92353A8E34398A33378632358231337E30327A2F30762E2E
722D2C6E2C2A6A2B29662A276229255E2823592721552620
51251E4D241C49231A4522194121173D20153C1F153A1F14
391E14381E14361D14351C13341C13321B13311B132F1A12
2E19122D19122B18122A1811291711271611261611251510
23151022141021140F1F130F1E120F1C120F1B110E1A110E
18100E170F0E160F0D140E0D130E0D120D0D100C0C0F0C0C
0E0B0C0C0B0C0B0A0B09090B08090B07080B05080A04070A
0606090804090A03088C46728A457087446D85436B824369
8042667D41647B4061793F5F763E5D743D5A713D586F3C56
6C3B536A3A5168394F65384C63374A6037485E36455B3543
59344057333E54323C5231394F31374D30354A2F32482E30
462D2E432C2B412B293E2B273C2A2439292237281F35271D
32261B3025182D25162B241428231126220F25210F24210E
23200E221F0E221E0D211E0D201D0D1F1C0D1E1B0C1D1B0C
1C1A0C1B190B1B180B1A180B19170A18160A17150A161509
1514091413091413081312081211081110081010070F0F07
0E0E070D0D060C0D060C0C060B0B050A0A05090A05080904
070804060704050704050603040503030403020402010302
0608070C0D0D1112121617171B1C1D2121222626272B2B2D
</palette>
</flame>
<flame name="baseline" version="Apophysis 2.08 beta" size="600 600" center="0 0" scale="150" oversample="1" filter="0.2" quality="1" background="0 0 0" brightness="4" gamma="4" >
<xform weight="0.564534951145298" color="0.13" julia="1" coefs="-1.381068 1.381068 -1.381068 -1.381068 0 0" />
<xform weight="0.0131350067581356" color="0.844" linear="1" popcorn="1" coefs="0.031393 -0.031367 0.031367 0.031393 0 0" />
<xform weight="0.422330042096567" color="0" pdj="1" coefs="1.51523 0.740356 -3.048677 -1.455964 0.724135 -0.362059" pdj_a="1.09358" pdj_b="2.13048" pdj_c="2.54127" pdj_d="2.37267" />
<palette count="256" format="RGB">
3130323635383B3A3D403F424644484B494D504E52565358
5B585D605D626562686B676D706C737571787B767D807B83
8580888A858D908A93958F989A949DA099A3A59EA8AAA3AD
AFA8B3B5ADB8BAB2BEBFB7C3C5BCC8CAC1CECFC6D3D4CBD8
DAD0DEDFD5E3DFD2E0DFCEDDE0CBDAE0C8D7E0C4D3E0C1D0
E1BECDE1BBCAE1B7C7E1B4C4E1B1C1E2ADBEE2AABAE2A7B7
E2A3B4E2A0B1E39DAEE399ABE396A8E393A5E490A1E48C9E
E4899BE48698E48295E57F92E57C8FE5788CE57589E57285
E66E82E66B7FE6687CE66479E76176E75E73E75B70E7576C
E75469E85166E84D63E84A60E4495EE0485CDC475BD84659
D44557D04455CB4353C74252C34150BF404EBB3F4CB73E4B
B33D49AF3C47AB3B45A73A43A339429F38409B373E97363C
92353A8E34398A33378632358231337E30327A2F30762E2E
722D2C6E2C2A6A2B29662A276229255E2823592721552620
51251E4D241C49231A4522194121173D20153C1F153A1F14
391E14381E14361D14351C13341C13321B13311B132F1A12
2E19122D19122B18122A1811291711271611261611251510
23151022141021140F1F130F1E120F1C120F1B110E1A110E
18100E170F0E160F0D140E0D130E0D120D0D100C0C0F0C0C
0E0B0C0C0B0C0B0A0B09090B08090B07080B05080A04070A
0606090804090A03088C46728A457087446D85436B824369
8042667D41647B4061793F5F763E5D743D5A713D586F3C56
6C3B536A3A5168394F65384C63374A6037485E36455B3543
59344057333E54323C5231394F31374D30354A2F32482E30
462D2E432C2B412B293E2B273C2A2439292237281F35271D
32261B3025182D25162B241428231126220F25210F24210E
23200E221F0E221E0D211E0D201D0D1F1C0D1E1B0C1D1B0C
1C1A0C1B190B1B180B1A180B19170A18160A17150A161509
1514091413091413081312081211081110081010070F0F07
0E0E070D0D060C0D060C0C060B0B050A0A05090A05080904
070804060704050704050603040503030403020402010302
0608070C0D0D1112121617171B1C1D2121222626272B2B2D
</palette>
</flame>
<flame name="final xform" version="Apophysis 2.08 beta" size="600 600" center="0 0" scale="150" oversample="1" filter="0.2" quality="1" background="1 1 1" brightness="4" gamma="4" >
<xform weight="0.564534951145298" color="0" julia="1" coefs="-1.381068 1.381068 -1.381068 -1.381068 0 0" />
<xform weight="0.0131350067581356" color="0.766" linear="1" popcorn="1" coefs="0.031393 -0.031367 0.031367 0.031393 0 0" post="1 0 0 1 0.24 0.27" />
<xform weight="0.422330042096567" color="0.349" pdj="1" coefs="1.51523 0.740356 -3.048677 -1.455964 0.724135 -0.362059" pdj_a="1.09358" pdj_b="2.13048" pdj_c="2.54127" pdj_d="2.37267" />
<finalxform color="0" symmetry="1" julia="1" coefs="2 0 0 2 0 0" />
<palette count="256" format="RGB">
3130323635383B3A3D403F424644484B494D504E52565358
5B585D605D626562686B676D706C737571787B767D807B83
8580888A858D908A93958F989A949DA099A3A59EA8AAA3AD
AFA8B3B5ADB8BAB2BEBFB7C3C5BCC8CAC1CECFC6D3D4CBD8
DAD0DEDFD5E3DFD2E0DFCEDDE0CBDAE0C8D7E0C4D3E0C1D0
E1BECDE1BBCAE1B7C7E1B4C4E1B1C1E2ADBEE2AABAE2A7B7
E2A3B4E2A0B1E39DAEE399ABE396A8E393A5E490A1E48C9E
E4899BE48698E48295E57F92E57C8FE5788CE57589E57285
E66E82E66B7FE6687CE66479E76176E75E73E75B70E7576C
E75469E85166E84D63E84A60E4495EE0485CDC475BD84659
D44557D04455CB4353C74252C34150BF404EBB3F4CB73E4B
B33D49AF3C47AB3B45A73A43A339429F38409B373E97363C
92353A8E34398A33378632358231337E30327A2F30762E2E
722D2C6E2C2A6A2B29662A276229255E2823592721552620
51251E4D241C49231A4522194121173D20153C1F153A1F14
391E14381E14361D14351C13341C13321B13311B132F1A12
2E19122D19122B18122A1811291711271611261611251510
23151022141021140F1F130F1E120F1C120F1B110E1A110E
18100E170F0E160F0D140E0D130E0D120D0D100C0C0F0C0C
0E0B0C0C0B0C0B0A0B09090B08090B07080B05080A04070A
0606090804090A03088C46728A457087446D85436B824369
8042667D41647B4061793F5F763E5D743D5A713D586F3C56
6C3B536A3A5168394F65384C63374A6037485E36455B3543
59344057333E54323C5231394F31374D30354A2F32482E30
462D2E432C2B412B293E2B273C2A2439292237281F35271D
32261B3025182D25162B241428231126220F25210F24210E
23200E221F0E221E0D211E0D201D0D1F1C0D1E1B0C1D1B0C
1C1A0C1B190B1B180B1A180B19170A18160A17150A161509
1514091413091413081312081211081110081010070F0F07
0E0E070D0D060C0D060C0C060B0B050A0A05090A05080904
070804060704050704050603040503030403020402010302
0608070C0D0D1112121617171B1C1D2121222626272B2B2D
</palette>
</flame>
</Flames>

View File

@ -0,0 +1,110 @@
import React, {useEffect, useState, createContext, useRef, MouseEvent} from "react";
import {useColorMode} from "@docusaurus/theme-common";
type PainterProps = {
width: number;
height: number;
setPainter: (painter: Iterator<ImageData>) => void;
}
export const PainterContext = createContext<PainterProps>(null)
const downloadImage = (name: string) =>
(e: MouseEvent) => {
const link = document.createElement("a");
link.download = `${name}.png`;
link.href = (e.target as HTMLCanvasElement).toDataURL("image/png");
link.click();
}
type CanvasProps = {
name: string;
style?: any;
children?: React.ReactElement
}
export const Canvas: React.FC<CanvasProps> = ({name, style, children}) => {
const sizingRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
useEffect(() => {
if (sizingRef.current) {
setWidth(sizingRef.current.offsetWidth);
setHeight(sizingRef.current.offsetHeight);
}
}, [sizingRef]);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (!canvasRef.current) {
return;
}
const observer = new IntersectionObserver((entries) => {
const [entry] = entries;
if (entry.isIntersecting) {
setIsVisible(true);
}
});
observer.observe(canvasRef.current);
return () => {
if (canvasRef.current) {
observer.unobserve(canvasRef.current);
}
}
}, [canvasRef.current]);
const [imageHolder, setImageHolder] = useState<[ImageData]>(null);
useEffect(() => {
if (canvasRef.current && imageHolder) {
canvasRef.current.getContext("2d").putImageData(imageHolder[0], 0, 0);
}
}, [canvasRef, imageHolder]);
const [painterHolder, setPainterHolder] = useState<[Iterator<ImageData>]>(null);
useEffect(() => {
if (!isVisible || !painterHolder) {
return;
}
const painter = painterHolder[0];
const nextImage = painter.next().value;
if (nextImage) {
setImageHolder([nextImage]);
setPainterHolder([painter]);
} else {
setPainterHolder(null);
}
}, [isVisible, painterHolder]);
const [painter, setPainter] = useState<Iterator<ImageData>>(null);
useEffect(() => {
if (painter) {
setPainterHolder([painter]);
}
}, [painter]);
const canvasProps = {
ref: canvasRef,
width,
height,
style: {filter: useColorMode().colorMode === 'dark' ? 'invert(1)' : ''}
}
return (
<>
<center>
<div ref={sizingRef} style={style}>
{width > 0 ? <canvas {...canvasProps} onDoubleClick={downloadImage(name)}/> : null}
</div>
</center>
<PainterContext.Provider value={{width, height, setPainter}}>
{width > 0 ? children : null}
</PainterContext.Provider>
</>
)
}
export const SquareCanvas: React.FC<CanvasProps> = ({name, style, children}) => {
return <center><Canvas name={name} style={{width: '75%', aspectRatio: '1/1', ...style}} children={children}/></center>
}

View File

@ -0,0 +1,8 @@
import { applyCoefs, Coefs, Transform } from "./transform";
import { blend, Blend } from "./blend";
export const applyTransform = (coefs: Coefs, variations: Blend): Transform =>
(x, y) => blend(...applyCoefs(x, y, coefs), variations)
export const applyPost = (coefsPost: Coefs, transform: Transform): Transform =>
(x, y) => applyCoefs(...transform(x, y), coefsPost);

View File

@ -0,0 +1,20 @@
// hidden-start
import { Variation } from "./variation";
// hidden-end
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];
}

View File

@ -0,0 +1,42 @@
/**
* Translate values in the flame coordinate system to pixel coordinates
*
* The way `flam3` actually calculates the "camera" for mapping a point
* to its pixel coordinate is fairly involved - it also needs to calculate
* zoom and rotation (see the bucket accumulator code in rect.c).
* We simplify things here by assuming a square image
*
* The reference parameters were designed in Apophysis, which uses the
* range [-2, 2] by default (the `scale` parameter in XML defines the
* "pixels per unit", and with the default zoom, is chosen to give a
* range of [-2, 2]).
*
* @param x point in the range [-2, 2]
* @param y point in the range [-2, 2]
* @param size image width/height in pixels
* @returns pair of pixel coordinates
*/
export function camera(x: number, y: number, size: number): [number, number] {
return [Math.floor(((x + 2) * size) / 4), Math.floor(((y + 2) * size) / 4)];
}
/**
* Translate values in pixel coordinates to a 1-dimensional array index
*
* Unlike the camera function, this mapping doesn't assume a square image,
* and only requires knowing the image width.
*
* The stride parameter is used to calculate indices that take into account
* how many "values" each pixel has. For example, in an ImageData, each pixel
* has a red, green, blue, and alpha component per pixel, so a stride of 4
* is appropriate. For situations where there are separate red/green/blue/alpha
* arrays per pixel, a stride of 1 is appropriate
*
* @param x point in the range [0, size)
* @param y point in the range [0, size)
* @param width width of image in pixel units
* @param stride values per pixel coordinate
*/
export function histIndex(x: number, y: number, width: number, stride: number): number {
return y * (width * stride) + x * stride;
}

View File

@ -0,0 +1,37 @@
.inputGroup {
padding: .2em;
margin-top: .5em;
margin-bottom: .5em;
border: 1px solid;
border-radius: var(--ifm-global-radius);
border-color: var(--ifm-color-emphasis-500);
}
.inputTitle {
border: 0 solid;
border-bottom: 1px solid;
border-color: var(--ifm-color-emphasis-500);
margin-bottom: .5em;
}
.inputElement {
padding-left: .5em;
padding-right: 1em;
}
.inputElement > p {
margin: 0
}
.inputElement > input[type=range] {
width: 100%;
height: 4px;
appearance: none;
background: var(--ifm-color-emphasis-400);
border-radius: var(--ifm-global-radius);
}
.inputReset {
display: flex;
float: right;
}

View File

@ -0,0 +1,21 @@
// hidden-start
import { Variation } from "./variation";
// hidden-end
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)
];
};

View File

@ -0,0 +1,5 @@
// hidden-start
import {Variation} from "./variation"
//hidden-end
export const linear: Variation =
(x, y) => [x, y];

View File

@ -0,0 +1,120 @@
/**
* Parameters taken from the reference .flame file,
* translated into something that's easier to work with.
*/
import { Blend } from "./blend";
import { linear } from "./linear";
import { julia } from "./julia";
import { popcorn } from "./popcorn";
import { pdj, PdjParams } from "./pdj";
import { Coefs, Transform } from "./transform";
import { applyPost, applyTransform } from "./applyTransform";
export const identityCoefs: Coefs = {
a: 1, b: 0, c: 0,
d: 0, e: 1, f: 0
};
export const pdjParams: PdjParams = {
a: 1.09358, b: 2.13048, c: 2.54127, d: 2.37267
};
export const xform1Weight = 0.56453495;
export const xform1Coefs = {
a: -1.381068, b: -1.381068, c: 0,
d: 1.381068, e: -1.381068, f: 0
};
export const xform1CoefsPost = identityCoefs;
export const xform1Variations: Blend = [
[1, julia]
];
export const xform1Color = 0;
export const xform2Weight = 0.013135;
export const xform2Coefs = {
a: 0.031393, b: 0.031367, c: 0,
d: -0.031367, e: 0.031393, f: 0
};
export const xform2CoefsPost = {
a: 1, b: 0, c: 0.24,
d: 0, e: 1, f: 0.27
};
export const xform2Variations: Blend = [
[1, linear],
[1, popcorn(xform2Coefs)]
];
export const xform2Color = 0.844;
export const xform3Weight = 0.42233;
export const xform3Coefs = {
a: 1.51523, b: -3.048677, c: 0.724135,
d: 0.740356, e: -1.455964, f: -0.362059
};
export const xform3CoefsPost = identityCoefs;
export const xform3Variations: Blend = [
[1, pdj(pdjParams)]
];
export const xform3Color = 0.349;
export const xformFinalCoefs = {
a: 2, b: 0, c: 0,
d: 0, e: 2, f: 0
};
export const xformFinalCoefsPost = identityCoefs;
export const xformFinalVariations: Blend = [
[1, julia]
];
export const xformFinalColor = 0;
export const xforms: [number, Transform][] = [
[xform1Weight, applyPost(xform1CoefsPost, applyTransform(xform1Coefs, xform1Variations))],
[xform2Weight, applyPost(xform2CoefsPost, applyTransform(xform2Coefs, xform2Variations))],
[xform3Weight, applyPost(xform3CoefsPost, applyTransform(xform3Coefs, xform3Variations))]
];
export const xformFinal: Transform = applyPost(xformFinalCoefsPost, applyTransform(xformFinalCoefs, xformFinalVariations));
export const paletteString =
"3130323635383B3A3D403F424644484B494D504E52565358" +
"5B585D605D626562686B676D706C737571787B767D807B83" +
"8580888A858D908A93958F989A949DA099A3A59EA8AAA3AD" +
"AFA8B3B5ADB8BAB2BEBFB7C3C5BCC8CAC1CECFC6D3D4CBD8" +
"DAD0DEDFD5E3DFD2E0DFCEDDE0CBDAE0C8D7E0C4D3E0C1D0" +
"E1BECDE1BBCAE1B7C7E1B4C4E1B1C1E2ADBEE2AABAE2A7B7" +
"E2A3B4E2A0B1E39DAEE399ABE396A8E393A5E490A1E48C9E" +
"E4899BE48698E48295E57F92E57C8FE5788CE57589E57285" +
"E66E82E66B7FE6687CE66479E76176E75E73E75B70E7576C" +
"E75469E85166E84D63E84A60E4495EE0485CDC475BD84659" +
"D44557D04455CB4353C74252C34150BF404EBB3F4CB73E4B" +
"B33D49AF3C47AB3B45A73A43A339429F38409B373E97363C" +
"92353A8E34398A33378632358231337E30327A2F30762E2E" +
"722D2C6E2C2A6A2B29662A276229255E2823592721552620" +
"51251E4D241C49231A4522194121173D20153C1F153A1F14" +
"391E14381E14361D14351C13341C13321B13311B132F1A12" +
"2E19122D19122B18122A1811291711271611261611251510" +
"23151022141021140F1F130F1E120F1C120F1B110E1A110E" +
"18100E170F0E160F0D140E0D130E0D120D0D100C0C0F0C0C" +
"0E0B0C0C0B0C0B0A0B09090B08090B07080B05080A04070A" +
"0606090804090A03088C46728A457087446D85436B824369" +
"8042667D41647B4061793F5F763E5D743D5A713D586F3C56" +
"6C3B536A3A5168394F65384C63374A6037485E36455B3543" +
"59344057333E54323C5231394F31374D30354A2F32482E30" +
"462D2E432C2B412B293E2B273C2A2439292237281F35271D" +
"32261B3025182D25162B241428231126220F25210F24210E" +
"23200E221F0E221E0D211E0D201D0D1F1C0D1E1B0C1D1B0C" +
"1C1A0C1B190B1B180B1A180B19170A18160A17150A161509" +
"1514091413091413081312081211081110081010070F0F07" +
"0E0E070D0D060C0D060C0C060B0B050A0A05090A05080904" +
"070804060704050704050603040503030403020402010302" +
"0608070C0D0D1112121617171B1C1D2121222626272B2B2D";
function hexToBytes(hex: string) {
let bytes: number[] = [];
for (let i = 0; i < hex.length; i += 2) {
bytes.push(parseInt(hex.substring(i, i + 2), 16));
}
return bytes;
}
export const palette = hexToBytes(paletteString).map(value => value / 0xff);

View File

@ -0,0 +1,15 @@
// hidden-start
import { Variation } from './variation'
//hidden-end
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)
]

View File

@ -0,0 +1,14 @@
import { camera, histIndex } from "./camera";
export function plotBinary(x: number, y: number, image: ImageData) {
const [pixelX, pixelY] = camera(x, y, image.width);
if (pixelX < 0 || pixelX >= image.width || pixelY < 0 || pixelY >= image.height)
return;
const pixelIndex = histIndex(pixelX, pixelY, image.width, 4);
image.data[pixelIndex] = 0;
image.data[pixelIndex + 1] = 0;
image.data[pixelIndex + 2] = 0;
image.data[pixelIndex + 3] = 0xff;
}

View File

@ -0,0 +1,10 @@
// hidden-start
import { Coefs } from "./transform";
import { Variation } from "./variation";
// hidden-end
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))
];

View File

@ -0,0 +1,3 @@
export function randomBiUnit() {
return Math.random() * 2 - 1;
}

View File

@ -0,0 +1,21 @@
export function randomChoice<T>(
choices: [number, T][]
): [number, T] {
const weightSum = choices.reduce(
(sum, [weight, _]) => sum + weight,
0
);
let choice = Math.random() * weightSum;
for (const entry of choices.entries()) {
const [idx, elem] = entry;
const [weight, t] = elem;
if (choice < weight) {
return [idx, t];
}
choice -= weight;
}
const index = choices.length - 1;
return [index, choices[index][1]];
}

View File

@ -0,0 +1,7 @@
export function randomInteger(
min: number,
max: number
) {
let v = Math.random() * (max - min);
return Math.floor(v) + min;
}

View File

@ -0,0 +1,23 @@
export type Transform =
(x: number, y: number) =>
[number, number];
export interface Coefs {
a: number,
b: number,
c: number,
d: number,
e: number,
f: number
}
export function applyCoefs(
x: number,
y: number,
coefs: Coefs
): [number, number] {
return [
(x * coefs.a + y * coefs.b + coefs.c),
(x * coefs.d + y * coefs.e + coefs.f)
];
}

View File

@ -0,0 +1,4 @@
export type Variation = (
x: number,
y: number
) => [number, number];

View File

@ -28,13 +28,14 @@ const config: Config = {
locales: ['en'], locales: ['en'],
}, },
themes: ['@docusaurus/theme-live-codeblock'],
presets: [ presets: [
[ [
'classic', 'classic',
{ {
docs: false, docs: false,
blog: { blog: {
routeBasePath: "/", routeBasePath: '/',
blogSidebarTitle: 'All posts', blogSidebarTitle: 'All posts',
blogSidebarCount: 'ALL', blogSidebarCount: 'ALL',
showReadingTime: true, showReadingTime: true,
@ -80,17 +81,26 @@ const config: Config = {
prism: { prism: {
theme: prismThemes.oneLight, theme: prismThemes.oneLight,
darkTheme: prismThemes.oneDark, darkTheme: prismThemes.oneDark,
additionalLanguages: ['bash', 'java', 'julia', 'nasm'] additionalLanguages: ['bash', 'java', 'julia', 'nasm'],
magicComments: [
// Remember to extend the default highlight class name as well!
{
className: 'theme-code-block-highlighted-line',
line: 'highlight-next-line',
block: {start: 'highlight-start', end: 'highlight-end'},
},
{
className: 'code-block-hidden',
block: {start: 'hidden-start', end: 'hidden-end'}
}
]
}, },
} satisfies Preset.ThemeConfig, } satisfies Preset.ThemeConfig,
plugins: [require.resolve('docusaurus-lunr-search')], plugins: [require.resolve('docusaurus-lunr-search')],
stylesheets: [ stylesheets: [
{ {
href: 'https://cdn.jsdelivr.net/npm/katex@0.13.24/dist/katex.min.css', href: '/katex/katex.min.css',
type: 'text/css', type: 'text/css',
integrity:
'sha384-odtC+0UGzzFL/6PNoE8rX/SPcQDXBJ+uRepguP4QkPCm2LBxH3FA3y+fKSiJ+AmM',
crossorigin: 'anonymous',
}, },
], ],
future: { future: {

4253
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"docusaurus": "docusaurus", "docusaurus": "docusaurus",
"start": "docusaurus start", "start": "docusaurus start --host 0.0.0.0",
"build": "docusaurus build", "build": "docusaurus build",
"swizzle": "docusaurus swizzle", "swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy", "deploy": "docusaurus deploy",
@ -15,17 +15,21 @@
"typecheck": "tsc" "typecheck": "tsc"
}, },
"dependencies": { "dependencies": {
"@docusaurus/core": "^3.6.0", "@docusaurus/core": "^3.6.1",
"@docusaurus/faster": "^3.5.2", "@docusaurus/faster": "^3.6.1",
"@docusaurus/preset-classic": "^3.6.0", "@docusaurus/preset-classic": "^3.6.1",
"@docusaurus/theme-live-codeblock": "^3.6.1",
"@matejmazur/react-katex": "^3.1.3",
"@mdx-js/react": "^3.0.0", "@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"docusaurus-lunr-search": "^3.5.0", "docusaurus-lunr-search": "^3.5.0",
"prism-react-renderer": "^2.3.0", "prism-react-renderer": "^2.3.0",
"raw-loader": "^4.0.2",
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-math": "^6.0.0" "remark-math": "^6.0.0",
"victory": "^37.3.2"
}, },
"devDependencies": { "devDependencies": {
"@docusaurus/module-type-aliases": "^3.6.0", "@docusaurus/module-type-aliases": "^3.6.0",

View File

@ -3,6 +3,19 @@
--ifm-container-width-xl: 1440px; --ifm-container-width-xl: 1440px;
--ifm-footer-padding-vertical: .5rem; --ifm-footer-padding-vertical: .5rem;
--ifm-spacing-horizontal: .8rem; --ifm-spacing-horizontal: .8rem;
/* Reduce padding on code blocks */
--ifm-pre-padding: .6rem;
/* More readable code highlight background */
--docusaurus-highlighted-code-line-bg: var(--ifm-color-emphasis-200);
/*--ifm-code-font-size: 85%;*/
}
.katex {
/* Default is 1.21, this helps with fitting on mobile screens */
font-size: 1.16em;
} }
.header-github-link:hover { .header-github-link:hover {
@ -22,3 +35,18 @@
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E") background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")
no-repeat; no-repeat;
} }
/*
Inspired by https://github.com/plotly/plotly.js/issues/2006#issuecomment-2081535168,
adapted for Victory charts
*/
[data-theme='dark'] .VictoryContainer {
filter: invert(75%) hue-rotate(180deg);
}
/*
Custom magic comment for Prism - hide parts of the code in display
*/
.code-block-hidden {
display: none;
}

6
src/isDarkMode.ts Normal file
View File

@ -0,0 +1,6 @@
import {useColorMode} from "@docusaurus/theme-common";
export default function isDarkMode() {
const {colorMode} = useColorMode();
return colorMode === "dark";
}

View File

@ -0,0 +1,135 @@
import React from 'react';
import clsx from 'clsx';
import useIsBrowser from '@docusaurus/useIsBrowser';
import {LiveProvider, LiveEditor, LiveError, LivePreview} from 'react-live';
import Translate from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import BrowserOnly from '@docusaurus/BrowserOnly';
import {
ErrorBoundaryErrorMessageFallback,
usePrismTheme,
} from '@docusaurus/theme-common';
import ErrorBoundary from '@docusaurus/ErrorBoundary';
import type {Props} from '@theme/Playground';
import type {ThemeConfig} from '@docusaurus/theme-live-codeblock';
import styles from './styles.module.css';
function Header({children}: {children: React.ReactNode}) {
return <div className={clsx(styles.playgroundHeader)}>{children}</div>;
}
function LivePreviewLoader() {
// Is it worth improving/translating?
// eslint-disable-next-line @docusaurus/no-untranslated-text
return <div>Loading...</div>;
}
function Preview() {
// No SSR for the live preview
// See https://github.com/facebook/docusaurus/issues/5747
return (
<BrowserOnly fallback={<LivePreviewLoader />}>
{() => (
<>
<ErrorBoundary
fallback={(params) => (
<ErrorBoundaryErrorMessageFallback {...params} />
)}>
<LivePreview />
</ErrorBoundary>
<LiveError />
</>
)}
</BrowserOnly>
);
}
function ResultWithHeader() {
return (
<>
<Header>
<Translate
id="theme.Playground.result"
description="The result label of the live codeblocks">
Result
</Translate>
</Header>
{/* https://github.com/facebook/docusaurus/issues/5747 */}
<div className={styles.playgroundPreview}>
<Preview />
</div>
</>
);
}
function ThemedLiveEditor() {
const isBrowser = useIsBrowser();
return (
<LiveEditor
// We force remount the editor on hydration,
// otherwise dark prism theme is not applied
key={String(isBrowser)}
className={styles.playgroundEditor}
/>
);
}
function EditorWithHeader() {
return (
<>
<Header>
<Translate
id="theme.Playground.liveEditor"
description="The live editor label of the live codeblocks">
Live Editor
</Translate>
</Header>
<ThemedLiveEditor />
</>
);
}
// this should rather be a stable function
// see https://github.com/facebook/docusaurus/issues/9630#issuecomment-1855682643
const DEFAULT_TRANSFORM_CODE = (code: string) => `${code};`;
export default function Playground({
children,
transformCode,
...props
}: Props): JSX.Element {
const {
siteConfig: {themeConfig},
} = useDocusaurusContext();
const {
liveCodeBlock: {playgroundPosition},
} = themeConfig as ThemeConfig;
const prismTheme = usePrismTheme();
const noInline = props.metastring?.includes('noInline') ?? false;
return (
<div className={styles.playgroundContainer}>
<LiveProvider
code={children?.replace(/\n$/, '')}
noInline={noInline}
transformCode={transformCode ?? DEFAULT_TRANSFORM_CODE}
theme={prismTheme}
{...props}>
{playgroundPosition === 'top' ? (
<>
<ResultWithHeader />
<EditorWithHeader />
</>
) : (
<>
<EditorWithHeader />
<ResultWithHeader />
</>
)}
</LiveProvider>
</div>
);
}

View File

@ -0,0 +1,43 @@
.playgroundContainer {
margin-bottom: var(--ifm-leading);
border-radius: var(--ifm-global-radius);
box-shadow: var(--ifm-global-shadow-lw);
overflow: hidden;
}
.playgroundHeader {
letter-spacing: 0.08rem;
padding: 0.75rem;
text-transform: uppercase;
font-weight: bold;
background: var(--ifm-color-emphasis-200);
color: var(--ifm-color-content);
font-size: var(--ifm-code-font-size);
}
.playgroundHeader:first-of-type {
background: var(--ifm-color-emphasis-600);
color: var(--ifm-color-content-inverse);
}
.playgroundEditor {
font: var(--ifm-code-font-size) / var(--ifm-pre-line-height)
var(--ifm-font-family-monospace) !important;
/* rtl:ignore */
direction: ltr;
}
.playgroundPreview {
padding: 1rem;
background-color: var(--ifm-pre-background);
}
/*
Docusaurus global CSS applies a `border-radius` to `pre` that leads to a minor graphical issue
where the "LIVE EDITOR" title bar and code block meet - https://github.com/facebook/docusaurus/issues/6032#issuecomment-2481803877
This change disables the border radius so the edges properly join together
*/
.playgroundEditor > pre {
border-radius: 0;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
static/katex/katex.min.css vendored Normal file

File diff suppressed because one or more lines are too long