2
\$\begingroup\$

I am coding a JS Mandelbrot Set viewer and am looking for suggestions on how to improve my code. I already did some cleanup and got the execution time from 7s to about 160ms on my computer.

Some info about my Code:

  • Complex numbers are stored as an array where the first number is the real part and the second one is the imaginary part.
  • the isInBulb checks if the coordinate is inside one of the 2 bigger white areas and instantly returns maxIterations

If you have any further questions feel free to ask :)

I haven't implemented any optimizations based on symmetry, I plan on adding the ability to zoom in once I am happy with the performance and any optimizations based on that wouldn't have much impact zoomed in.

I am mainly looking for suggestions increasing the speed or making the code more elegant while not making it any slower.

Thank you :)

https://codepen.io/Teiem/pen/YzNWXeR

Main.js:

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

const width = 1600;
const height = 1200;

const scale = 0.4 * Math.min(width, height);
const offsetX = width / 2;
const offsetY = height / 2;

const maxIterations = 512;

canvas.width = width;
canvas.height = height;

const workers = [];

const createWorkers = () => {
    for (let i = 0; i < navigator.hardwareConcurrency; i++) {
        const worker = new Worker("./script/worker/worker.js");
        workers.push(worker);
        worker.onmessage = renderDone;
    }
};

let tW;
const manageRender = () => {
    const sab = new SharedArrayBuffer(width * height * 4);

    perHeight = height / workers.length;

    rowsDone = 0;
    workers.forEach((worker, i) => worker.postMessage([
        sab,
        width,
        height,
        offsetX,
        offsetY,
        scale,
        maxIterations,
        Math.floor(perHeight * i),
        Math.floor(perHeight * (i + 1)),
    ]))

    tW = performance.now();
};

let doneWorkers = 0;
const renderDone = ({data}) => {
    // console.log(rowsDone, performance.now() - tW, "ms");
    if (++rowsDone !== workers.length) return;

    ctx.putImageData(new ImageData(new Uint8ClampedArray(data).slice(), width, height), 0, 0);

    // const t1 = performance.now();
    // console.log(t1 - t0 + ' ms');
};

createWorkers();

const t0 = performance.now();
manageRender();

Worker.js:

let width, height, offsetX, offsetY, scale, maxIterations;

const addComplex = (a, b) => {
    a[0] += b[0];
    a[1] += b[1];
    return a;
};

const squareComplex = (a) => {
    const oldReal = a[0];
    a[0] = a[0] ** 2 - a[1] ** 2;
    a[1] = 2 * oldReal * a[1];

    return a;
};

const a = [0, 0, 0];
const b = [0.59, 0.55, 0.75];
const c = [0.1, 0.2, 0.3];
const d = [0.75, 0.75, 0.75];
const generateColor = step => [0, 1, 2].map(i => (a[i] + b[i] * Math.cos(6.28318 * (c[i] * step + d[i]))) * 255);

// const period = 20;
const testIfEscapes = c => {
    let z = [0, 0];
    // let xOld = 0;
    // let yOld = 0;

    for (let i = 0; i < maxIterations; i++) {
        z = squareComplex(addComplex(z, c));
        const absZ = z[0] ** 2 + z[1]**2;

        if (absZ > 4) return i;
        // if (z[0] === xOld && z[1] === yOld) return maxIterations;
        // if (i % period === 0) {
            // xOld = z[0];
            // yOld = z[1];
        // }

    }

    return maxIterations;
};

const isInBulb = c => {
    const x = c[0];
    const y = c[1];

    const q = (x - 0.25) ** 2 + y ** 2;
    return (q * (q + (x - 0.25))) <= (0.25 * y ** 2) || ((x + 1) ** 2 + y ** 2) <= (0.0625) ? maxIterations : testIfEscapes(c);
};

const getColorAtPosition = (x, y) => {
    const escapesIn = isInBulb([(x - offsetX) / scale, (y - offsetY) / scale]);

    if (escapesIn === maxIterations) return [230, 247, 255];
    if (escapesIn === 0) return [0, 0, 0];
    return generateColor(escapesIn / 512);
};

let once = true;
const render = (uint8ClampedArray, fromY, toY) => {
    for (let x = 0; x < width; x++) {
        for (let y = fromY; y < toY; y++) {
            const [r, g, b] = getColorAtPosition(x, y);

            const off = (y * width + x) * 4;
            uint8ClampedArray[off] = r;
            uint8ClampedArray[off + 1] = g;
            uint8ClampedArray[off + 2] = b;
            uint8ClampedArray[off + 3] = 255;
        }
    }
};

onmessage = ({data: [buffer, _width, _height, _offsetX, _offsetY, _scale, _maxIterations, fromY, toY]}) => {
    width = _width;
    height = _height;
    offsetX = _offsetX;
    offsetY = _offsetY;
    scale = _scale;
    maxIterations = _maxIterations;
    // uint8ClampedArray = buffer ?? new Uint8ClampedArray(buffer);

    // const t0 = performance.now();
    render(new Uint8ClampedArray(buffer), fromY, toY);
    // const t1 = performance.now();
    // console.log(t1 - t0 + ' ms', fromY, toY);

    postMessage(buffer);
}
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Review

Some bad habits and inappropriate data structures used.

  • It is a very bad habit to leave commented code in your source.

  • Only use arrays when all the items represent the same thing.

    Using arrays to hold structured data makes code very hard to read and sensitive to changes like item order, number of properties, etc. You use arrays for the data passed to the workers, defining the color channel modulation frequencies, and to hold complex numbers.

    The rewrite uses Objects with named properties rather than arrays.

  • Do not use let in global scope as they become globals (not scoped to the script but to the page) and can cause your code or 3rd party code to throw errors because either attempt to redefine the variable or access the variable while it is in dead space.

    Note it is safe to use let in modules and workers (local) scope

  • You have some undeclared variables. (perHeight and rowsDone) You should use Strict_mode to prevent common bad practices from becoming bad habits. Also strict mode code runs faster.

Workers

Good to see you are aware of navigator.hardwareConcurrency and use it to get the number of hardware cores to use.

Some further points when using workers.

  • When creating workers it is best to use only available hardware cores.

    Rather than create navigator.hardwareConcurrency workers, use one less, leaving a core free to handle the main thread.

  • When you no longer need a worker you should remove it.

    Even when worker threads are idle they still exist an as such have a resource foot print. Call the worker.terminate terminate function to remove the worker.

  • When you first use a worker there is a considerable overhead as the page needs to setup a context for the workers. As such timing performance outside the worker will give an inaccurate representation of the time taken to do the work.

    See rewrite. Moves the timing measurement into the worker which passes the time back to the main thread. This will give you a far more accurate time to render pixels.

  • The shared array buffer reference does not need to be passed back to the main thread. You do so as you need to communicate the buffer reference to the function handling the workers incoming message.

    The rewrite handles the outgoing and incoming messages in the same scope thus the shared buffer reference does not need to be passed back from the worker. The incoming message only has the time.

    Also there is no need to create a view of the shared buffer when sending the reference to the worker.

  • Always use addEventListener to add listeners rather than directly assign a listener to a on property. This ensures that the listener is not overwritten or overwriting others that directly assign listeners.

Pixels

The example uses Uint32Array view of the shard pixel data. This allows fast writes to the pixel data as you can set a pixel in a single expression rather than 4 (when done per RGBA channel)

Note that RGBA32 has channels from highest to lowest bytes Alpha 0xFF000000, Blue 0x00FF0000, Green 0x0000FF00, Red 0x000000FF (numbers are bit masks for each channel)

Rewrite

The example creates a worker from a function as CR snippets do not allow linking scripts. The worker functions is marked with string directives "##START##" and "##END##' to simplify trimming the function string. These are arbitrary directives and not required if creating workers from a file reference.

I have also modified the behavior to render the canvas sized to fit the page rather than set to a fixed size.

Note the rewrite was not written with performance in mind. Rather it was was written to improve data handling, and worker management.

const ctx = canvas.getContext('2d');
const width = canvas.width = innerWidth;
const height = canvas.height = innerHeight;
const scale = 0.4 * Math.min(width, height);
const offsetX = width / 2;
const offsetY = height / 2;
const maxIterations = 512;
const workers = [];
createWorkers(MBRenderWorker);
manageRender(renderDone);

function createWorkers(workerFunction) {
    const functionStr = workerFunction.toString().replace(/.*?##START##";|"##END##";.*/g, "");
    var i = Math.max(1, (navigator.hardwareConcurrency ?? 2) - 1);
    while (i--) {
        workers.push(new Worker(URL.createObjectURL(new Blob([functionStr], {type : 'application/javascript'}))));
    }
}
function manageRender(done){
    const sab = new SharedArrayBuffer(width * height * 4);
    const perHeight = height / workers.length;
    var workerCount = workers.length, totalTime = 0;
    const jobsDone = workerCount;
    workers.forEach((worker, i) => {
        worker.postMessage({
            pixels: sab, 
            width, height, offsetX, offsetY,
            scale, maxIterations,
            fromY: Math.floor(perHeight * i),
            toY: Math.floor(perHeight * (i + 1)),
        });
        worker.addEventListener("message", message => {
            worker.terminate();
            totalTime += message.data.time;
            if (!--workerCount) { 
                done(sab);
                info.textContent = "Time to render " + totalTime.toFixed(2) + "ms using " + jobsDone + " threads";
            }
        });
    });
}
function renderDone(array) {
    ctx.putImageData(new ImageData(new Uint8ClampedArray(array).slice(), width, height), 0, 0);
}






function MBRenderWorker() {"##START##";"use strict";
    const RGB = (r, g, b) => ({r, g, b});
    const a = RGB(0, 0, 0);
    const b = RGB(0.59, 0.55, 0.75);
    const c = RGB(0.1, 0.2, 0.3);
    const d = RGB(0.75, 0.75, 0.75);
    const generateColor = step => 0xFF000000 +
       (((a.r + b.r * Math.cos(6.28318 * (c.r * (step / 512) + d.r))) * 255) & 0xFF) +
       (((a.g + b.g * Math.cos(6.28318 * (c.g * (step / 512) + d.g))) * 0xFF00) & 0xFF00) +
       (((a.b + b.b * Math.cos(6.28318 * (c.b * (step / 512) + d.b))) * 0xFF0000) & 0xFF0000);
    const ComplexNum = (a = 0, i = 0) => ({a, i});
    const add = (a, b) => {
        a.a += b.a;
        a.i += b.i;
        return a;
    }
    const square = cplx => {
        [cplx.a, cplx.i] = [cplx.a ** 2 - cplx.i ** 2,  2 * cplx.a * cplx.i];
        return cplx;
    }
    const distSqr = cplx => cplx.a * cplx.a + cplx.i * cplx.i;
    const testIfEscapes = (c, maxIterations) => {
        var i = 0;
        const z = ComplexNum();
        while (i++ <= maxIterations) { if (distSqr(square(add(z, c))) > 4) { break } }
        return --i;
    }
    const isInBulb = pos => {
        const q = (pos.a - 0.25) ** 2 + pos.i ** 2;
        return (q * (q + (pos.a - 0.25))) <= (0.25 * pos.i ** 2) || ((pos.a + 1) ** 2 + pos.i ** 2) <= (0.0625);
    }
    const getColorAtPosition = (data, x, y) => {
        const pos = ComplexNum((x - data.offsetX) / data.scale, (y - data.offsetY) / data.scale);
        const inBulb = isInBulb(pos);
        if (inBulb) { return 0xFFFFF8E0 }
        const iterations = testIfEscapes(pos, data.maxIterations);
        return !iterations ? 0xFF000000 : generateColor(iterations);
    }
    const render = (data) => {
        const pixels = new Uint32Array(data.pixels);
        var x, y;
        for (x = 0; x < data.width; x++) {
            for (y = data.fromY; y < data.toY; y++) {            
                pixels[y * data.width + x] = getColorAtPosition(data, x, y);
            }
        }
    }
    addEventListener("message", message => {
            const start = performance.now();
            render(message.data);
            postMessage({time: performance.now() - start});
        });
"##END##";}
canvas {
    position: absolute;
    top: 0px;
    left: 0px;
}
div {
    position: absolute;
    top: 10px;
    left: 10px;
    color: white;
    font-family: arial;
}
<canvas id="canvas"></canvas>
<div id="info">fghfh</div>

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.