Net3arth is a flame inspired chaos game based fractal rendering program that runs in the browser.
It aims to be fairly versatile, being able to make things like this:
And even this (though it's not great at it):
Net3arth doesn't have much of a UI, instead relying on the user making fractals with code. The code to make fractals is written in a special syntax (sometimes dubbed 3arthLang) which is purpose made. Raw JavaScript can also be included in the code for added flexibility.
An 3arthLang program consists of up to 7 parts:
- constant variables
- custom transforms
- custom JavaScript functions (which can behave like a custom transform)
- a buffer function
- the body
- the camera
- the shader
At the start of runtime, theres one point at the origin. The properties of the point are sent through the body, then the resulting point is copied, one copy going through the camera and rendered to the image through the buffer while the other copy is sent back through the body recursively.
When the image is rendered, each pixel is first sent through the shader.
The body, camera, and shader consists of some combinations of control functions, built-in transforms, and custom transforms.
The control functions are:
- choose
- xaos
- switch
- sum
- product
- sumColor
- productColor
Here's a simple program that uses each component:
buffer() {
return averageBuffer();
}
getFraction(number numerator, number denominator) {
return numerator / denominator;
}
const green = getFraction(1, 20);
transform coloredCircle(object color):
blurCircle()
-> color(color);
body:
choose{
1:
coloredCircle(colorRGB(0.1, green, 0));
1:
coloredCircle(colorRGB(0, green, 0))
-> circleInv();
};
camera:
scale(1 / 2);
shader:
gamma(2.2);
Lets go through it piece by piece.
buffer() {
return averageBuffer();
}
This is the buffer. It's bassically identical in syntax to a custom function but it has to be named 'buffer'. You can write a custom buffer, but there are a few built-in buffers you can use by just returning them, which is what's done here.
The averageBuffer()
built-in buffer makes each pixel the color of the average of all points that have visited that pixel.
There's also zBuffer()
which only keeps the closest pixel by z-value,
firstBuffer()
which only takes the first point to visit a pixel,
lastBuffer()
which only takes the last point to visit a pixel,
and the default buffer when none are specified takes the color sum of all points to visit a pixel.
getFraction(number numerator, number denominator) {
return numerator / denominator;
}
This is a normal custom function. In this case, it's not behaving like a custom transform,
it's just a math helper function (and not a very good one).
Parameters are typed as any of bool
, number
, complex
, array
, object
, or function
.
const green = getFraction(1, 20);
This is a constant value. This value will be computed at compile time and then inlined wherever it's used.
In this case, it's calling our custom function which will return 0.05
.
transform coloredCircle(object color):
blurCircle()
-> color(color);
This is a custom transform. In this case it takes a color parameter, and colors are of type object
.
This custom transform applies blurCircle
which draws a circle, and then a color()
which sets the color of the circle that was drawn.
body:
choose{
1:
coloredCircle(colorRGB(0.1, green, 0));
1:
coloredCircle(colorRGB(0, green, 0))
-> circleInv();
};
This is the body. The first thing in the body is a choose
.
choose
is a control function where within it, it will randomly choose one of the listed control paths, run it,
and then exit to whatever comes next. Each control path starts with a number which is that path's weight. If, for example,
there were two paths
1: // code;
2: // code;
the one weighted 1
would be chosen ~33% of the time and the one weighted 2
would be chosen ~67% of the time.
On the fist path, we have coloredCircle(colorRGB(0.1, green, 0));
. This calls the custom function defined earlier.
As a parameter, it passed colorRGB(0.1, green, 0)
; colorRGB
is a built-in helper function that builds a color object.
In this case, the color made is a brownish color.
On the second path, we have:
coloredCircle(colorRGB(0, green, 0))
-> circleInv();
The ->
operator pipes the result of the left side into what's on the right side.
This path is very similar to the first but it draws a dark green circle and pipes it into a built-in function, cirlceInv
.
circleInv
transforms everything within the unit circle to outside it and everything outside the unit circle to inside it.
The result here is that all of space is filled dark green except for a circle in the middle.
camera:
scale(1 / 2);
The camera here is pretty boring, it just scales everything by a half so that the circle is fully in-frame. This could just as well be in the body since the body isn't doing any recursion stuff, but that's not always the case.
shader:
gamma(2.2);
The shader is also pretty boring, here just doing some normal gamma correction.
This example was intentionally verbose just to show the different parts of the code. You could simplify the code to being:
buffer() {
return averageBuffer();
}
body:
blurCircle()
-> choose{
1:
color(colorRGB(0.1, 0.05, 0));
1:
color(colorRGB(0, 0.05, 0))
-> circleInv();
};
camera:
scale(1 / 2);
shader:
gamma(2.2);
Now let's draw a fractal.
const cos30 = Math.cos(30 * DEGREE);
body:
choose {
1: translate(0, -1);
1: translate(cos30, 0.5);
1: translate(-cos30, 0.5);
}
-> scale(1 / 2);
camera:
translate(0, cos30 / 4)
-> scale(0.5 / cos30);
shader:
normalizeColors()
-> gamma(2.2);
This is pretty standard, this is just a classic example of the Chaos Game. In the body, you have the point randomly moving in one of three directions, and then it's scaled down by 50%. This can also be understood as the point moving halfway to one of three points. The result is the Sierpinski Triangle.
Here, normalizeColors
is used in the shader instead of using the averageBuffer
. Since not all points are hit equally often, using averageBuffer
results in a worse image.
There are many other ways to draw the Sierpinski Triangle; heres one.
body:
translate(0, -1)
-> pointSymmetry(0, 0, 3)
-> scale(1 / 2);
and if you wanted to make the code even shorter, here's one more:
body:
mobius(0.5, -0.5i, 0, 1)
-> pointSymmetry(0, 0, 3);
Moving on to another classic fractal, here's the cantor set construction:
body:
choose{
2:
blurSquare()
-> scale2(1, 0.1);
1:
scale2(1 / 3, 0.5)
-> translate(1 / 3, 0.2)
-> choose{
1: identity();
1: flipX();
};
};
camera:
translate(0, -1 / 5)
-> scale(1.9);
shader:
normalizeColors()
-> gamma(2.2);
The first step is simply creating a rectangle representing the 0th iteration:
blurSquare()
-> scale2(1, 0.1);
That's in a choose, so sometimes we'll go back to the 0th iteration, sometimes we'll go down an iteration.
To go down an iteration, we move the previous iteration down and scale it horizontally by a third:
scale2(1 / 3, 0.5)
-> translate(1 / 3, 0.2)
That gets us half of the next iteration, to get the other half, half the time we flip it across the X-axis.
-> choose{
1: identity();
1: flipX();
};
For more complex examples, see: https://thecrystalize.github.io/Net3arth/gallery/