Skip to content

Instantly share code, notes, and snippets.

@EFHIII
Last active February 19, 2024 18:49
Show Gist options
  • Save EFHIII/686b091ef232dd36d96511cba4fd6e4c to your computer and use it in GitHub Desktop.
Save EFHIII/686b091ef232dd36d96511cba4fd6e4c to your computer and use it in GitHub Desktop.
Net3arth. What is it and how to use it.

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: ArcSinhSplits

Aswell as things like this: UtahTreepot

And even this (though it's not great at it): Mandelbrot

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);

output

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);

image

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);

Cantor Set

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/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment