Search code examples
javascriptperformancefractalsmath.js

Javascript Julia Fractal slow and not detailed


I am trying to generate a Julia fractal in a canvas in javascript using math.js

Unfortunately every time the fractal is drawn on the canvas, it is rather slow and not very detailed.

Can anyone tell me if there is a specific reason this script is so slow or is it just to much to ask of a browser? (note: the mouse move part is disabled and it is still kinda slow)

I have tried raising and lowering the “bail_num” but everything above 1 makes the browser crash and everything below 0.2 makes everything black.

// Get the canvas and context
var canvas = document.getElementById("myCanvas"); 
var context = canvas.getContext("2d");

// Width and height of the image
var imagew = canvas.width;
var imageh = canvas.height;

// Image Data (RGBA)
var imagedata = context.createImageData(imagew, imageh);

// Pan and zoom parameters
var offsetx = -imagew/2;
var offsety = -imageh/2;
var panx = -2000;
var pany = -1000;
var zoom = 12000;

// c complexnumber
var c = math.complex(-0.310, 0.353);

// Palette array of 256 colors
var palette = [];

// The maximum number of iterations per pixel
var maxiterations = 200;
var bail_num = 1;


// Initialize the game
function init() {

//onmousemove listener
canvas.addEventListener('mousemove', onmousemove);

    // Generate image
    generateImage();

    // Enter main loop
    main(0);
}

// Main loop
function main(tframe) {
    // Request animation frames
    window.requestAnimationFrame(main);

    // Draw the generate image
    context.putImageData(imagedata, 0, 0);
}

// Generate the fractal image
function generateImage() {
    // Iterate over the pixels
    for (var y=0; y<imageh; y++) {
        for (var x=0; x<imagew; x++) {
            iterate(x, y, maxiterations);
        }
    }
}

// Calculate the color of a specific pixel
function iterate(x, y, maxiterations) {
    // Convert the screen coordinate to a fractal coordinate
    var x0 = (x + offsetx + panx) / zoom;
    var y0 = (y + offsety + pany) / zoom;
    var cn = math.complex(x0, y0);

    // Iterate
    var iterations = 0;
    while (iterations < maxiterations && math.norm(math.complex(cn))< bail_num ) {
        cn = math.add( math.sqrt(cn) , c);   
        iterations++;
    }

    // Get color based on the number of iterations
    var color;
    if (iterations == maxiterations) {
        color = { r:0, g:0, b:0}; // Black
    } else {
        var index = Math.floor((iterations / (maxiterations)) * 255);
        color = index;
    }

    // Apply the color
    var pixelindex = (y * imagew + x) * 4;
    imagedata.data[pixelindex] = color;
    imagedata.data[pixelindex+1] = color;
    imagedata.data[pixelindex+2] = color;
    imagedata.data[pixelindex+3] = 255;
}


function onmousemove(e){
var pos = getMousePos(canvas, e);
//c = math.complex(-0.3+pos.x/imagew, 0.413-pos.y/imageh);

//console.log( 'Mouse position: ' + pos.x/imagew + ',' + pos.y/imageh );

// Generate a new image
    generateImage();

}

function getMousePos(canvas, e) {
    var rect = canvas.getBoundingClientRect();
    return {
        x: Math.round((e.clientX - rect.left)/(rect.right - rect.left)*canvas.width),
        y: Math.round((e.clientY - rect.top)/(rect.bottom - rect.top)*canvas.height)
    };
}
init();

Solution

  • The part of the code that is executed most is this piece:

    while (iterations < maxiterations && math.norm(math.complex(cn))< bail_num ) {
        cn = math.add( math.sqrt(cn) , c);   
        iterations++;
    }
    

    For the given canvas size and offsets you use, the above while body is executed 19,575,194 times. Therefore there are some obvious ways to improve performance:

    • somehow reduce the number of points for which the loop must be executed
    • somehow reduce the number of times these statements are executed per point
    • somehow improve these statements so they execute faster

    The first idea is easy: reduce the canvas dimensions. But this is maybe not something you'd like to do.

    The second idea can be achieved by reducing the value for bail_num, because then the while condition will be violated sooner (given that the norm of a complex number is always a positive real number). However, this will just result in more blackness, and gives the same visual effect as zooming out of the center of the fractal. Try for instance with 0.225: there just remains a "distant star". When bail_num is reduced too much, you wont even find the fractal anymore, as everything turns black. So to compensate you would then probably want to change your offset and zoom factors to get a closer view at the center of the fractal (which is still there, BTW!). But towards the center of the fractal, points need more iterations to get below bail_num, so in the end nothing is gained: you'll be back at square one with this method. It's not really a solution.

    Another way to work along the second idea is to reduce maxiterations. However, this will reduce the resolution accordingly. It is clear that you will have fewer colors at your disposal, as this number directly corresponds to the number of iterations you can have at the most.

    The third idea means that you would somehow optimise the calculations with complex numbers. It turns out to give a lot of gain:

    Use efficient calculations

    The norm that is calculated in the while condition could be used as an intermediate value for calculating the square root of the same number, which is needed in the next statement. This is the formula for getting the square root from a complex number, if you already have its norm:

               __________________
    root.re = √ ½(cn.re + norm)
    root.im = ½cn.im/root.re
    

    Where the re and im properties denote the real and imaginary components of the respective complex numbers. You can find the background for these formulas in this answer on math.stackexchange.

    As in your code the square root is calculated separately, without taking benefit of the previous calculation of the norm, this will certainly bring a benefit.

    Also, in the while condition you don't really need the norm (which involves a square root) for comparing with bail_num. You could omit the square root operation and compare with the square of bail_num, which comes down to the same thing. Obviously you would have to calculate the square of bail_num only once at the start of your code. This way you can delay that square root operation for when the condition is found true. The formula for calculating the square of the norm is as follows:

    square_norm = cn.re² + cn.im²
    

    The calls of methods on the math object have some overhead, since this library allows different types of arguments in several of its methods. So it would help performance if you would code the calculations directly without relying on math.js. The above improvements already started doing that anyway. In my attempts this also resulted in a considerable gain in performance.

    Predefine colours

    Although not related to the costly while loop, you can probably gain a litte bit more by calculating all possible colors (per number of iterations) at the start of the code, and store them in an array keyed by number of iterations. That way you can just perform a look-up during the actual calculations.

    Some other similar things can be done to save on calculations: For instance, you could avoid translating the screen y coordinate to world coordinates while moving along the X axis, as it will always be the same value.

    Here is the code that reduced the original time to complete by a factor of 10, on my PC:

    Added intialisation:

    // Pre-calculate the square of bail_num:
    var bail_num_square = bail_num*bail_num;
    // Pre-calculate the colors:
    colors = [];
    for (var iterations = 0; iterations <= maxiterations; iterations++) {
        // Note that I have stored colours in the opposite direction to 
        // allow for a more efficient "countdown" loop later
        colors[iterations] = 255 - Math.floor((iterations / maxiterations) * 255);
    }
    // Instead of using math for initialising c:
    var cx = -0.310;
    var cy = 0.353;
    

    Replace functions generateImage and iterate by this one function

    // Generate the fractal image
    function generateImage() {
        // Iterate over the pixels
        var pixelindex = 0,
            step = 1/zoom,
            worldX, worldY,
            sq, rootX, rootY, x0, y0;
        for (var y=0; y<imageh; y++) {
            worldY = (y + offsety + pany)/zoom;
            worldX = (offsetx + panx)/zoom;
            for (var x=0; x<imagew; x++) {
                x0 = worldX;
                y0 = worldY;
                // For this point: iterate to determine color index
                for (var iterations = maxiterations; iterations && (sq = (x0*x0+y0*y0)) < bail_num_square; iterations-- ) {
                    // root of complex number
                    rootX = Math.sqrt((x0 + Math.sqrt(sq))/2);
                    rootY = y0/(2*rootX);
                    x0 = rootX + cx;
                    y0 = rootY + cy;
                }
                // Apply the color
                imagedata.data[pixelindex++] = 
                    imagedata.data[pixelindex++] = 
                    imagedata.data[pixelindex++] = colors[iterations];
                imagedata.data[pixelindex++] = 255;
                worldX += step;
            }
        }
    }
    

    With the above code you don't need to include math.js anymore.

    Here is a smaller sized snippet with mouse events handled:

    // Get the canvas and context
    var canvas = document.getElementById("myCanvas"); 
    var context = canvas.getContext("2d");
    
    // Width and height of the image
    var imagew = canvas.width;
    var imageh = canvas.height;
    
    // Image Data (RGBA)
    var imagedata = context.createImageData(imagew, imageh);
    
    // Pan and zoom parameters
    var offsetx = -512
    var offsety = -430;
    var panx = -2000;
    var pany = -1000;
    var zoom = 12000;
    
    // Palette array of 256 colors
    var palette = [];
    
    // The maximum number of iterations per pixel
    var maxiterations = 200;
    var bail_num = 0.8; //0.225; //1.15;//0.25;
    
    // Pre-calculate the square of bail_num:
    var bail_num_square = bail_num*bail_num;
    // Pre-calculate the colors:
    colors = [];
    for (var iterations = 0; iterations <= maxiterations; iterations++) {
        colors[iterations] = 255 - Math.floor((iterations / maxiterations) * 255);
    }
    // Instead of using math for initialising c:
    var cx = -0.310;
    var cy = 0.353;
    
    // Initialize the game
    function init() {
    
        // onmousemove listener
        canvas.addEventListener('mousemove', onmousemove);
    
        // Generate image
        generateImage();
    
        // Enter main loop
        main(0);
    }
    
    // Main loop
    function main(tframe) {
        // Request animation frames
        window.requestAnimationFrame(main);
    
        // Draw the generate image
        context.putImageData(imagedata, 0, 0);
    }
    
    // Generate the fractal image
    function generateImage() {
        // Iterate over the pixels
        console.log('generate', cx, cy);
        var pixelindex = 0,
            step = 1/zoom,
            worldX, worldY,
            sq_norm, rootX, rootY, x0, y0;
        for (var y=0; y<imageh; y++) {
            worldY = (y + offsety + pany)/zoom;
            worldX = (offsetx + panx)/zoom;
            for (var x=0; x<imagew; x++) {
                x0 = worldX;
                y0 = worldY;
                // For this point: iterate to determine color index
                for (var iterations = maxiterations; iterations && (sq_norm = (x0*x0+y0*y0)) < bail_num_square; iterations-- ) {
                    // root of complex number
                    rootX = Math.sqrt((x0 + Math.sqrt(sq_norm))/2);
                    rootY = y0/(2*rootX);
                    x0 = rootX + cx;
                    y0 = rootY + cy;
                }
                // Apply the color
                imagedata.data[pixelindex++] = 
                    imagedata.data[pixelindex++] = 
                    imagedata.data[pixelindex++] = colors[iterations];
                imagedata.data[pixelindex++] = 255;
                worldX += step;
            }
        }
        console.log(pixelindex);
    }
    
    
    function onmousemove(e){
        var pos = getMousePos(canvas, e);
        cx = -0.31+pos.x/imagew/150;
        cy = 0.35-pos.y/imageh/30;
        
    
        generateImage();
    }
    
    function getMousePos(canvas, e) {
        var rect = canvas.getBoundingClientRect();
        return {
            x: Math.round((e.clientX - rect.left)/(rect.right - rect.left)*canvas.width),
            y: Math.round((e.clientY - rect.top)/(rect.bottom - rect.top)*canvas.height)
        };
    }
    init();
    <canvas id="myCanvas" width="512" height="200"></canvas>