Fractal Garden Learnings – Part 2
Interesting technical details and a bunch of links
This is part 2 of a series of posts on building the Fractal Garden, my first 1 month project. In this post I will dive into the technical learnings and decisions that went into making the Fractal Garden website.
While Part 1 focused more on the general meta lessons I took from this project about how to approach working and improving my efficiency, this post will dive into the technical learnings and decisions behind the project and share a bunch of links that I found useful while building this project.
Facts about Fractals
Now I understand what the Chaos Game is and how it works, and what L-Systems are and how they work, and what an IFS - iterated function system is and how it works.
In this post I want to look at how to implement an L-System because the bulk of the fractals in the garden are generated by this intriguing and simple to understand algorithm.
The main idea behind an L-System is very simple. You start with a sequence of letters – say "AB" and a bunch of rules for replacing these letters with new ones. Say every A should turn into AB and every B should turn into an A. Let's put those rules into an object like this:
const replacementRules = {
A: "AB",
B: "A",
}
Now, the whole idea of an L-System is to replace all the letters in our sequence with the new letters from the replacement rules. This gives us our next string.
AB -> ABA
Cool. Now, let's do this again.
ABA -> ABAAB
And again.
ABAAB -> ABAABABA
And again... Ok, no. This get's tedious. Let's write a little piece of code so that the computer does this replacing for us!
function applyRules(oldSequence, replacementRules) {
let newSequence = ""
// loop over all characters of the oldSequence
for (let char of oldSequence) {
// get the replacement characters
const replacement = replacementRules[char]
// append them to our new sequence
newSequence += replacement
}
// and finally return the result.
return newSequence
}
And tada. We've created our very first L-System. Now, this doesn't draw anything to the screen yet! So let's fix it. We could add a lookup table that translates between drawing commands like "drawForward" and "turnRight" and our "alphabet" i.e. the letters we use in our sequences.
The one I use in the fractal garden looks like this:
let ctx: CanvasRenderingContext2D;
const drawForward = () => {
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, -len);
ctx.stroke();
ctx.closePath();
ctx.translate(0, -len);
};
const drawRules: Record<string, () => void> = {
V: () => {},
W: () => {},
X: () => {},
Y: () => {},
Z: () => {},
G: drawForward,
F: drawForward,
f: () => ctx.translate(0, -len),
"+": () => ctx.rotate(angle * rotationDirection),
"-": () => ctx.rotate(angle * -rotationDirection),
"|": () => ctx.rotate(180),
"[": () => ctx.push(),
"]": () => ctx.pop(),
"#": () => (ctx.lineWidth = weight += weightIncrement) ,
"!": () => (ctx.lineWidth = weight -= weightIncrement) ,
">": () => (len *= scale),
"<": () => (len /= scale),
"&": () => (rotationDirection = -rotationDirection),
"(": () => (angle += angleIncrement),
")": () => (angle -= angleIncrement),
};
function drawSequence(sequence) {
for (let char of sequence) {
// pick the correct function from the rules
const drawFn = drawRules[char];
// use it to draw!
drawFn();
}
}
This is a little more complicated, but allows for the generation of very pretty patterns, because the sequences we generate can do all kinds of stuff now. Like increasing the line width or weight or changing the direction into which a line is drawn.
The only thing missing is the instruction set for each of the fractals we would like to generate. For each one we at least need a starting sequence and replacement rules. Usually we would also like to rotate by a certain angle. And so we can generate another object that holds this information for each of our fractals. Here's the one for the Lévy Curve from the Garden
const levyCurveRules: Ruleset = {
color: "#54bffc",
axiom: "F",
replacementRules: {
F: "-F++F-",
},
angle: 45,
};
Putting all of this together would yield a piece of code like the following:
const angle = 45;
const len = 10;
let sequence = levyCurveRules.axiom
// call apply rules 10 times, overwriting the start sequence over and over again.
for (let i = 1; i < 10; i++) {
sequence = applyRules(sequence, levyCurveRules.replacementRules)
}
drawSequence(sequence)
If we were to run the above in an environment that has a ctx variable that gives us a CanvasRenderingContext2D this should draw the 10th iteration of the Lévy Curve Fractal to the screen. And this is pretty close to the code that I have in the Fractal Garden in the end. The coolest thing about this is that you only have to change a little bit of code => the replacement rules + axiom, to get wildly different outcomes.
So this is a short tour of L-Systems. The biggest technical learning I had during this project.
Furthermore, I also learned that many fractals have funky properties – like infinite length or 0 area and that a lot of fractals are intrically connected with each other.
A few that come to mind are the Pythagoras Tree and the Lévy Curve, the Sierpinski Triangle and the Arrowhead Curve as well as the Sierpinski Triangle and the Fractal Tree.
Lastly, the coolest factoid about fractals: The Sierpinski Triangle or Pythagoras Trees can be generated from any initial shape. They are "super-fractals". And to prove that point, Sierpinski created his famous triangle out of a fish! And there is also a Pythagoras Tree created from an image of Pythagoras himself, which to me is just the most wonderfully recursive nerd humor, ever.
Technical Challenges:
I am not good at design. You might even say, I suck at it. It's something I still want to get better at, but where I don't see a real path forward yet. Luckily there are people who are really good designers, and they build cool sites like lawsofux, where I can go and take inspiration from. So that at least my projects don't look like shit...
On to the interesting, technical stuff!
The initial stack I chose was a mix of p5 + HTML + JS + Webcomponents. Eventually this turned into a mess, and then into a clean refactor of next.js + react + TypeScript in the end.
That means... I did a lot of rework.
The initial reasoning was solid: The project is just a quick hack, I want to get started with coding fractals immediately and don't want to worry about React and Next.js Boilerplating... And I already have some sketches of old fractal code around, that uses p5.js... So. p5 embedded into normal HTML it is. Webcomponents as well, so that I can have a Navbar that I don't have to copy paste. But well.
Eventually I wanted to write Markdown for Fractal Descriptions, wanted to use TypeScript for my growing API, and would have liked to share UI components and JS functionality between different pages, as well as to remove that annoying flickering when navigating between pages. In short, I needed to change to a better tech stack.
I think this rework could have been avoided, because it resulted from picking the route that seemed easier to setup in the beginning but then started breaking as the complexity of the project increased.
Note to Self:
Next time, just start with a solid tooling and superb dev experience from the beginning. The pain of not having TypeScript in the project or not being able to use NPM projects easily is not worth the cost, not even on "small" 1-month projects.
A few notes on WebGL
During this project I had to dive deeper into WebGL and learn more about shaders. The webglfundamentals series of tutorials were an invaluable help for understanding the basics. This was necessary only for the Mandelbrot set.
The main takeaways follow:
There is a lot of boilerplate to setup shaders from JavaScript. Shaders are little programs written in a specific language (called GLSL) that is executed on the GPU.
Most programs need two shaders, one vertex and one fragment shader. The vertex shader calculates the geometry, and feeds values to the fragment shader. Fragment Shaders then turn this information + other information provided from JS into specific colors of pixels on the screen.
To draw the Mandelbrot set, the steps necessary are to compile and link the shaders correctly, then to set up some geometry (a triangle covering the screen), and then to color that triangle based on the X-Y coordinates of the surface. Once we have X-Y coordinates, we can use the normal Mandelbrot logic of iteratively squaring numbers from the complex plane (our X-Y coordinates) and coloring in the result with the fragment shader, based on some color palette.
More Resources
Some Inspiration and beautiful things I found along the way:
The Barnsley Fern Matrices came from these two:
And the Algorithm Archive visualization of the Barnsley Fern transformations is epic and you should check it out!
The Mandelbrot Shader is largely based on understanding and stitching together these two implementations, Wikipedia also helped:
- https://gpfault.net/posts/mandelbrot-webgl.txt.html
- https://www.unindented.org/playground/mandelbrot-set-with-webgl-shaders/
- https://en.wikipedia.org/wiki/Plotting_algorithms_for_the_Mandelbrot_set
Some more research + other cool Mandelbrot viewers I found:
- https://project-archive.inf.ed.ac.uk/ug4/20201796/ug4_proj.pdf
- https://www.shadertoy.com/view/4df3Rn
- http://hvidtfeldts.net/WebGL/webgl.html
- https://math.hws.edu/eck/js/mandelbrot/MB.html
- https://mandelbrot.ophir.dev/#
- https://www.shadertoy.com/view/ttVSDW
- https://www.shadertoy.com/view/fs33zM
- https://mandelbrot.robertolechowski.com/#
There are some problems with resizing the Canvas and Pixel Density that you have when using Canvas natively and not from some library like p5. These helped in solving those: