Skip to content

Commit

Permalink
Feature/performance improvement resuable workers (#6)
Browse files Browse the repository at this point in the history
* feature: major performance improvements by reusing workers; fix: removed memory leak; refactoring

* improved canvas onscroll behavior

* removed commented code

* added copy of license in the release pipeline
  • Loading branch information
Zazzik1 committed Aug 16, 2023
1 parent eee5bd6 commit b6ff276
Show file tree
Hide file tree
Showing 7 changed files with 157 additions and 79 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/pass_tests_and_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ jobs:
- name: Create build
run: npm run build

- name: Copy license
run: cp LICENSE build

- name: Generate release tag
id: generate_release_tag
uses: alexvingg/next-release-tag@v1.0.4
Expand Down
101 changes: 68 additions & 33 deletions src/Mandelbrot.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { DEFAULT_RGB } from "~/constants";
import { RGBColorPalette, Task } from "~/types";
import { DEFAULT_COLOR_OFFSET, DEFAULT_RGB, DEFAULT_WORKERS_NO } from "~/constants";
import { MandelbrotMessageData, MandelbrotWorkerMessageData, RGBColorPalette, Task } from "~/types";
import MandelbrotWorker from "~/workers/mandelbrot.worker";

export default class Mandelbrot {
protected canvas: HTMLCanvasElement;
protected ctx: CanvasRenderingContext2D;
protected done?: Function;
protected task?: Task;
public iterations: number = 120;
protected workersNumber = 16;
protected workersNo = DEFAULT_WORKERS_NO;
protected workersFinished: boolean[] = [];
protected workers: Worker[] = [];
protected rgb: RGBColorPalette = DEFAULT_RGB;
public colorOffset: number = 0;
public colorOffset: number = DEFAULT_COLOR_OFFSET;
protected isRunning: boolean = false;

constructor(canvas: HTMLCanvasElement, doneCallback?: Function) {
if (!canvas) throw new Error("canvas was not provided")
Expand All @@ -21,55 +21,90 @@ export default class Mandelbrot {
if (!ctx) throw new Error('ctx == null');
this.ctx = ctx
this.done = doneCallback;

for(let i=0; i < this.workersNo; i++){
this.workersFinished[i] = false;
let worker = new MandelbrotWorker() as Worker;
worker.addEventListener("message", e => {
const data = e.data as MandelbrotMessageData;
if(data.type == "finish") {
this.workersFinished[i] = true;
this.tryToFinish();
} else if(data.type == "draw_line") {
const { y, lineBuffer } = data.payload;
this.drawLine(y, lineBuffer);
}
});
this.workers.push(worker);
}
}
public draw(x1: number, y1: number, x2: number, y2: number) {
public async draw(x1: number, y1: number, x2: number, y2: number) {
const { width, height } = this.canvas;
if (this.workersFinished.includes(false)) this.removeWorkers();
if (this.isRunning) await this.forceStopWorkers();
this.ctx.clearRect(0, 0, width, height);
this.task = {
this.isRunning = true;

const task: Task = {
x1: x1,
y1: y1,
x2: x2,
y2: y2,
w: width,
h: height,
width,
height,
da: (x2 - x1) / width,
db: (y2 - y1) / height,
iterations: this.iterations,
colorOffset: this.colorOffset,
imageData: this.ctx.getImageData(0, 0, width, height),
}
this.workersFinished = Array(this.workersNumber);
const linesForOneWorker = height / this.workersNumber;
for(let i=0; i<this.workersNumber; i++){

const { workersNo } = this;
this.workers.forEach((worker, i) => {
this.workersFinished[i] = false;
let w = new MandelbrotWorker() as Worker;
w.postMessage([this.task, i, linesForOneWorker, height* i/this.workersNumber, this.rgb])
w.addEventListener("message", e => {
if(e.data.action == "finish") {
this.workersFinished[e.data.id] = true;
this.tryToFinish();
} else if(e.data.action == "drawLine") {
this.drawLine(e.data.y, e.data.line);
const message: MandelbrotWorkerMessageData = {
type: 'calculate',
payload: {
task: task,
workerId: i,
linesToDo: height / workersNo,
startingLine: height * i / workersNo,
rgb: this.rgb,
}
});
this.workers.push(w);
}
}
worker.postMessage(message);
})
}

protected removeWorkers() {
this.workers.forEach(w => w.terminate());
this.workers = [];
public forceStopWorkers() {
return new Promise<void>(resolve => {
const message: MandelbrotWorkerMessageData = { type: 'force_stop' };
this.workers.forEach(worker => worker.postMessage(message));
let timeout: ReturnType<typeof setTimeout>;
const loop = () => {
if (this.areAllWorkersFinished()) {
clearTimeout(timeout);
resolve();
return;
}
timeout = setTimeout(loop);
}
loop();
})
}

public drawLine(y: number, line: ImageData) {
public drawLine(y: number, lineBuffer: ArrayBufferLike) {
const line = new ImageData(new Uint8ClampedArray(lineBuffer), this.canvas.width, 1);
this.ctx.putImageData(line, 0, y)
}

public areAllWorkersFinished() {
return !this.workersFinished.includes(false);
}

protected tryToFinish() {
if (this.workersFinished.includes(false)) return;

this.removeWorkers();
if(typeof this.done === "function") this.done();
if (typeof this.done !== "function") return;
if (this.areAllWorkersFinished()) {
this.isRunning = false;
this.done();
}
}
}
6 changes: 4 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export const CANVAS_SIZES = [
{ name: '600x600', value: '600x600' },
{ name: '700x700', value: '700x700' },
{ name: '800x800', value: '800x800' },
{ name: '400x400', value: '400x400' },
{ name: '360x640', value: '360x640' },
{ name: '640x480', value: '640x480' },
{ name: '1280x720 HD', value: '1280x720' },
Expand All @@ -39,4 +38,7 @@ export enum ZOOM_MULTIPLIER {
CLICK_ZOOM_OUT = 0.5,
SCROLL_ZOOM_IN = 1.3,
SCROLL_ZOOM_OUT = 1 / 1.3,
}
}

export const DEFAULT_WORKERS_NO = 32;
export const DEFAULT_COLOR_OFFSET = 0;
1 change: 1 addition & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class NotRunningError extends Error {}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@ function addListeners() {
});

canvas.addEventListener("wheel", e => {
if(!wheel.checked) return;
e.preventDefault();
e.stopPropagation();
if(!wheel.checked) return;

if(e.deltaY < 0) {
click(e.offsetX / canvas.width, e.offsetY / canvas.height, ZOOM_MULTIPLIER.SCROLL_ZOOM_IN);
Expand Down
31 changes: 28 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,42 @@ export interface Task {
y1: number;
x2: number;
y2: number;
w: number;
h: number;
width: number;
height: number;
da: number;
db: number;
iterations: number;
imageData?: ImageData;
colorOffset: number,
}

export type RGBColorPalette = [number, number, number][];

declare global {
interface Window { mandelbrot?: Mandelbrot }
}

export type CalculateActionPayload = {
task: Task,
workerId: number,
linesToDo: number,
startingLine: number,
rgb: RGBColorPalette,
}

export type RunningMandelbrotWorkerData = CalculateActionPayload & { isRunning: true };
export type WorkerData = RunningMandelbrotWorkerData | { isRunning: false };

export type MandelbrotWorkerMessageData = {
type: 'calculate',
payload: CalculateActionPayload,
} | {
type: 'force_stop',
}

export type MandelbrotMessageData = { type: 'finish' } | {
type: 'draw_line',
payload: {
y: number,
lineBuffer: ArrayBufferLike,
}
}
92 changes: 52 additions & 40 deletions src/workers/mandelbrot.worker.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,60 @@
import { RGBColorPalette, Task } from "~/types";
import { NotRunningError } from "~/errors";
import { CalculateActionPayload, MandelbrotMessageData, MandelbrotWorkerMessageData, RGBColorPalette, Task, WorkerData } from "~/types";
import { isInSet } from "~/utils/utils";

class MandelbrotWorker {
protected id: number;
protected task: Task;
protected linesToDo: number;
protected startingLine: number;
protected rgb: RGBColorPalette;
protected isRunning: boolean = false;

constructor(task: Task, id: number, linesToDo: number, startingLine: number, rgb: RGBColorPalette) {
this.id = id;
this.task = task;
this.linesToDo = linesToDo;
this.startingLine = startingLine;
this.rgb = rgb;
protected loopTimeout?: ReturnType<typeof setTimeout>;
protected data: WorkerData = {
isRunning: false,
}
public run() {
if (this.isRunning) return;

this.isRunning = true
this._runLoop();
public run(details: CalculateActionPayload) {
if (this.data.isRunning) return;
this.data = {
...details,
isRunning: true,
}

this._runLoop(details.startingLine);
}
protected _runLoop = (line = this.startingLine) => {
if (line >= this.startingLine + this.linesToDo) return this.finish();
protected _runLoop = (lineNo: number) => {
const { data } = this;
if (!data.isRunning) return this.finish();
if (lineNo >= data.startingLine + data.linesToDo) return this.finish();

ctx.postMessage({
action: "drawLine",
line: this.calculateLine(line),
y: line
});
setTimeout(this._runLoop, 0, line + 1)
const line = this.calculateLine(lineNo);
const message = {
type: "draw_line",
payload: {
lineBuffer: line.data.buffer,
y: lineNo,
}
} as MandelbrotMessageData
ctx.postMessage(message, [line.data.buffer]); // transferable array is crucial to prevent memory leaks
this.loopTimeout = setTimeout(this._runLoop, 0, lineNo + 1);
}
/** Stops without notifying. */
public stop() {
this.data = { isRunning: false };
if (this.loopTimeout) clearTimeout(this.loopTimeout);
}
protected finish() {
ctx.postMessage({
action: "finish",
id: this.id
});
/** Stops with notifying, but only if isRunning. */
public finish() {
if (!this.data.isRunning) return;
ctx.postMessage({ type: "finish" });
this.stop();
}
protected calculateLine(y: number): ImageData {
const { x1, y1, da, db, iterations, w, colorOffset } = this.task;
const line = new ImageData(w, 1);
if (!this.data.isRunning) throw new NotRunningError();
const { x1, y1, da, db, iterations, width, colorOffset } = this.data.task;
const line = new ImageData(width, 1);
let c: [number, number, number];
for(let x = 0; x < w*4; x+=4){
for(let x = 0; x < width*4; x+=4){
let diverge = isInSet(x1 + (x/4 * da), y1 + (y * db), iterations);
if (!diverge) {
c = [0, 0, 0]; // point belongs to the set
} else {
let color = (diverge + colorOffset) % this.rgb.length;
c = this.rgb[color]; // colors outer points
let color = (diverge + colorOffset) % this.data.rgb.length;
c = this.data.rgb[color]; // colors outer points
}
line.data[x] = c[0];
line.data[x+1] = c[1];
Expand All @@ -61,10 +67,16 @@ class MandelbrotWorker {

var ctx: Worker = self as any;

const worker = new MandelbrotWorker();

ctx.onmessage = e => {
const [task, id, linesToDo, startingLine, rgb] = e.data;
const worker = new MandelbrotWorker(task, id, linesToDo, startingLine, rgb);
worker.run();
const data = e.data as MandelbrotWorkerMessageData;
switch (data.type) {
case 'calculate':
return worker.run(data.payload);
case 'force_stop':
return worker.finish();
}
}

export default null as any;

0 comments on commit b6ff276

Please sign in to comment.