Fractals

The full source code for this part of the tutorial is available in the file root/tutorials/s3d/mandelbrot.bs. You can run it using storm -f tutorials.s3d.mandelbrot.

This part of the tutorial is intended to illustrate how the 3D library can be used to perform more complex computations on the GPU. In this case, we render the Mandelbrot fractal on the GPU in a pixel shader. As such, it does not build upon the previous steps but does something a bit different. The text below focuses on highlighting the key ideas. As before, the code is fairly well documented, so it should be fairly easy to follow along.

Rendering

The goal is to run the pixel shader for every pixel on the screen. That way we can write the code that computes the corresponding color of the Mandelbrot fractal in the pixel shader. To achieve this, the painter (MandelbrotPainter) simply renders two triangles that fills the entire screen. Since we will not transform the points in the vertex shader, the coordinates will be in clip-space. This means that we can simply place the triangle between (-1, -1) and (1, 1) to cover the entire screen. Also note that we don't use a custom vertex type as input. Rather, we just store an array of Points in the vertex buffer.

We do need an intermediate vertex type, however. The reason for this is that the GPU needs a Vector4 marked as position. Therefore, we create a vertex type and a vertex shader as below:

vertex MandelbrotVertex {
    // Screen position for the GPU.
    Vector4 screen : position;

    // Original position. Marked with linear interpolation for clarity.
    Point pos : linear;
}

vertex shader for MandelbrotPipeline: MandelbrotVertex mandelbrotVertex(Point pos) {
    MandelbrotVertex(Vector4(pos), pos);
}

Note that screen will be updated with screen-space coordinates (i.e. pixels) in the pixel shader. Because of that, we need to store the original position separately in our vertex type.

Note that the actual code has a slightly more complex mandelbrotVertex shader. It accounts for the size of the window so that it can ensure that the output is square even though the window is not square.

Computing the Fractal

Once we have ensured that the pixel shader is executed for all pixels and that we have the original coordinates (in the range -1 to 1), we can write the code for the Mandelbrot fractal as a function:

shader Int mandelbrot(Complex c, Int maxIters) {
    Complex z;
    for (Int i = 0; i < maxIters; i++) {
        z = z*z + c;

        if (z.length > 2.0)
            return i;
    }
    return -1;
}

We can then call it from the pixel shader. One interesting detail is how to map iteration counts to colors. In this case we do that by creating a 1 pixel high texture that contains the colors. That way we can use it as an array in the pixel shaders to get colors from. Note that texture coordinates always range from 0 to 1, so we need to convert the integers to floats and scale them appropriately. The final code looks like this:

pixel shader for MandelbrotPipeline: Output mandelbrotPixel(MandelbrotVertex v) {
    // Grab the interpolated 'pos', since 'screen' will be in screen coordinates.
    // Scale it to -2..2 for nicer visuals.
    Complex c(v.pos.x * 2.0, v.pos.y * 2.0);

    // Compute the number of iterations.
    Int iters = mandelbrot(c, colorCount);

    // Pick a color if iters is positive. Black is default.
    Color color = black;
    if (iters >= 0) {
        // Note: We sample at an offset of 0.5 to avoid rounding errors.
        Float xPos = 0.5 + iters.float;
        color = colors.sample(Point(xPos / colorCount.float, 0.0));
    }

    return Output(color);
}