Let’s look at L-Systems ! Also known as Lindenmayer Systems, after their inventor, Aristid Lindenmayer , L-Systems are sets of rules for manipulating symbols. They consist of an alphabet of symbols that can be used to make strings, and a set of rules used to transform those strings. When fed a starting string, it will produce a sequence of new strings based on its rules. This was originally a way to formally describe the growth of fungi, algae, etc.
That doesn’t sound very artistic. Why do we need this?
We’ll get to that! But first, it’s time to sit through some theory…
Fundamentally, an L-System is fairly simple. There are symbols, which we’ll represent as characters in a string, and there are rules to translate those symbols into other symbols. It’s very common that there will symbols that there are no rules for, so they never change.
Let’s start with the usual example that everyone looks at when starting to learn about L-Systems, a model of algae growth (mmm… algae…). There are two symbols, A
and B
. There are two rules:
$$ A \to AB $$ $$ B \to A $$
The rules say that whenever an A
is found, it becomes an AB
, and that whenever a B
if found, it becomes an A
.
Suppose we start with the string “A” and feed it to these rules – the result will be “AB”.
If you take the “AB” and apply the rules again, you get “ABA” (A
becomes AB
and ‘B’ becomes A
.)
Apply the rules to that, and you get “ABAAB”.
If we keep applying the rules over and over, the results will get longer and longer.
What do A
and B
mean? I dunno, I’m not a biologist, but the internet assures me it closely models algae growth somehow. What we want to focus on is how this works. We can use any symbols we want, and they can mean anything we want them to mean. So what can we do with that? It turns out L-Systems are very good at modeling self-similar fractals!
Long ago
, we looked at a particular fractal called a Sierpinski Triangle. We can draw one of those with an L-System! Let’s consider a system with four symbols: F
, G
, +
and -
. +
and -
are constants and never change, while F
and G
are modified by these rules:
$$ F \to F−G+F+G−F $$ $$ G \to GG $$
So whenever we see an “F”, we will change to “F−G+F+G−F”, and whenever we see a “G”, we’ll change it to “GG”.
We’ll start with the string “F−G−G”. Applying the rules gives us “F−G+F+G−F−GG−GG”. Applying them again gives us “F−G+F+G−F−GG+F−G+F+G−F+GG−F−G+F+G−F−GGGG−GGGG”. Warning: this string is going to get very long, very quickly!
Alright, time to wake up! We’re going to Art now!
Let’s attach some meanings to those symbols. We’re going to use the Turtle Graphics we discussed last week. Whenever we see any of the four symbols in our strings, we’ll do the following:
F | Move forward by some amount |
G | Move forward by some amount |
+ | Turn left 120 degrees |
- | Turn right 120 degrees |
F
and G
move forward by the same amount. So why are there two symbols if they do the same thing? It’s because they get modified differently.
The starting string is “F−G−G”, which clearly just draws a triangle.
The next string, “F−G+F+G−F−GG−GG”. draws something that looks like this:
The next string, “F−G+F+G−F−GG+F−G+F+G−F+GG−F−G+F+G−F−GGGG−GGGG”, draws this:
I’m sure you can see where this is going. Here’s a Nannou app which draws the 1st through the 8th strings generated by applying those rules.
use lindenmayer::LSystem;
use nannou::prelude::*;
use turtle::Turtle;
const WIDTH: u32 = 600;
const HEIGHT: u32 = 600;
fn main() {
nannou::app(model)
.size(WIDTH, HEIGHT)
.simple_window(view)
.run();
}
struct Model {
system: LSystem,
}
fn model(_app: &App) -> Model {
let axiom = String::from("F-G-G");
let rules = [
('F', "F-G+F+G-F"),
('G', "GG")
];
Model {
system: LSystem::new(axiom, &rules),
}
}
fn view(app: &App, model: &Model, frame: Frame) {
let mut turtle = Turtle::new()
.direction_deg(90.)
.position(vec2(45. - WIDTH as f32 / 2., 45. - HEIGHT as f32 / 2.))
.color(hsla(1., 1., 0.5, 0.25));
let draw = app.draw();
let t = app.elapsed_frames() as usize + 1;
if t == 1 {
draw.background().color(WHITE);
}
if t <= 8 {
for symbol in model.system.builder(t as usize) {
match symbol {
'F' => turtle.draw(&draw, 2.),
'G' => turtle.draw(&draw, 2.),
'+' => turtle.rotate_deg(120.),
'-' => turtle.rotate_deg(-120.),
_ => {}
}
}
}
draw.to_frame(app, &frame).unwrap()
}
This app uses the
lindenmayer crate
. a Rust library for calculating L-Systems. The model()
function sets up the rules. In this case, there is no update()
function; all the work is done in the view()
function.
In the view()
function, we set up the
Turtle Graphics
and get the elapsed_frames. On the first frame we set the background. For frames 2 through 8, we draw the image using that many applications of the rules. We need only draw the last one, from the string with the depth of 8, to get the full image, but drawing each stage allow us to apply other effects if we want. In this case, I’ve set the color to have some transparency so that the parts that get redrawn get darker.
Here’s our final image:
You can find the full app at https://gitlab.com/theartistshusband/l-systems/-/blob/main/sierpinski_triangle/src/main.rs . Next week, we’ll explore some more interesting things we can do with L-Systems.