A Mandelbrot exlorer with 10^300 depth. Demonstrates Web workers, WebAssembly, WebGL2, custom urls.
A Mandelbrot exlorer with 10^300 depth. Demonstrates Web workers, WebAssembly, WebGL2, custom urls. ## Using Domeleon Domeleon is a compact abstraction over any virtual DOM, including Preact (the default), React, and Vue. A Domeleon Component is a class representing application state rather than a wrapper around a DOM element. Components may define a pure view function of their state, and compose into hierarchies that can be updated, serialized, validated, or routed. ### Example **HTML** ```html <div id="app"></div> ``` **Code** ```ts import { App, Component, div, button, formField, inputRange, canvas } from "domeleon" import { inputNumber } from "domeleon/maskito" class Counter extends Component { count: number role: string #canvasEl: HTMLCanvasElement #canvasCtx: CanvasRenderingContext2D constructor(initial: number, role: string) { super() this.count = initial this.role = role } view() { return div({ class: "counter" }, formField ({ // databound input target: this, inputFn: inputRange, // inputSelect, inputText, inputTextArea, inputCheckbox etc. prop: () => this.count, label: this.role, fieldAttrs: { class: "field-class"}, labelAttrs: { class: "label-class"}, inputProps: { attrs: { min: 100, max: 250, step: 1 } } }), button({ onClick: () => this.inc(+1) }, "+"), // always use closure to bind to preserve "this" button({ onClick: () => this.inc(-1) }, "-"), this.count, canvas({ width: 400, height: 20, onMounted: el => { this.#canvasEl = el as HTMLCanvasElement this.#canvasCtx = this.#canvasEl.getContext("2d") return () => { // dispose this.#canvasEl = undefined this.#canvasCtx = undefined } } }) ) } inc(x: number) { this.count+=x this.update() // triggers render on app } // `onRendered` called after VDOM has created/updated DOM elements // useful for operations that require raw DOM access, such as canvas operations override onRendered () { this.drawOnCanvas() } drawOnCanvas() { const canvas = this.#canvasEl const cc = this.#canvasCtx cc.clearRect(0, 0, canvas.width, canvas.height) const barWidth = (this.count / 250) * canvas.width cc.fillStyle = "#4a90e2" cc.fillRect(0, 0, barWidth, canvas.height) } } class Game extends Component { wins = new Counter(10, "wins") losses = new Counter(5, "losses") view() { return div( this.wins.view(), this.losses.view() ) } } new App({ root: new Game(), id: "app"}) ```
/**
* A Mandelbrot exlorer with 10^300 depth.
* This is an example of non trivial app.
* Web workers, WebAssembly, WebGL2.
*/
import { App, Component, div, h1, button, inputSelect, canvas, type UpdateEvent, type HValues, type VAttributes} from "domeleon"
import Decimal from "decimal.js"
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): (...args: Parameters<T>) => void {
let t: number | undefined
return function (this: ThisParameterType<T>, ...args: Parameters<T>): void {
if (t !== undefined) clearTimeout(t)
t = window.setTimeout(() => fn.apply(this, args), ms)
}
}
function getUrlParam(key: string): string | null {
return new URLSearchParams(window.location.search).get(key)
}
function parseDecimal(str: string | null, defaultValue: Decimal): Decimal {
if (!str) return defaultValue
try {
const val = new Decimal(str)
return val.isNaN() ? defaultValue : val
} catch {
return defaultValue
}
}
function parseInteger(
str: string | null,
defaultValue: number,
constraints?: { min?: number; max?: number }
): number {
const val = str ? parseInt(str) : NaN
if (Number.isNaN(val)) return defaultValue
const { min = -Infinity, max = Infinity } = constraints ?? {}
return Math.min(Math.max(val, min), max)
}
function clearCanvas(canvasEl: HTMLCanvasElement): void {
const ctx = canvasEl.getContext("2d")
if (ctx) ctx.clearRect(0, 0, canvasEl.width, canvasEl.height)
}
function controlButton(...values: HValues[]) {
return button({ class: "controlButton" }, ...values)
}
type TipState = "not_shown" | "shown" | "dismissed"
class Tip extends Component {
element?: HTMLElement;
tipState: TipState = "not_shown"
view(attrs: Record<string, any>, ...content: HValues[]) {
return div({
...attrs,
onMounted: e => {
this.element = e as HTMLElement
this.element.style.opacity = "0"
this.element.style.visibility = "hidden"
}
}, content);
}
show(): void {
if (this.tipState === "not_shown") {
this.tipState = "shown"
this.element.style.opacity = "1"
this.element.style.visibility = "visible"
}
}
hide(): void {
if (this.tipState === "shown") {
this.tipState = "dismissed"
this.element.style.opacity = "0"
this.element.style.visibility = "hidden"
}
}
}
interface Coords { x: number; y: number; }
interface Rect { left: number; top: number; width: number; height: number; }
type PointerEventLike = PointerEvent | TouchEvent
class Marquee extends Component {
active = false;
startPos: Coords = { x: 0, y: 0 }
currentPos: Coords = { x: 0, y: 0 }
canvasEl?: HTMLCanvasElement
onStart?: () => void
onEnd?: (rect: Rect) => void
view(attrs: VAttributes) {
return canvas({
...attrs,
onPointerDown: e => this.handlePointerDown(e),
onPointerMove: e => this.handlePointerMove(e),
onPointerUp: e => this.handlePointerUp(e),
onMounted: el => {
this.canvasEl = el as HTMLCanvasElement;
el.addEventListener("touchstart", e => { e.preventDefault(); this.handlePointerDown(e as any); }, { passive: false })
el.addEventListener("touchmove", e => { e.preventDefault(); this.handlePointerMove(e as any); }, { passive: false })
el.addEventListener("touchend", e => { e.preventDefault(); this.handlePointerUp(e as any); }, { passive: false })
}
});
}
getEventCoords(e: any): Coords {
const rect = this.canvasEl.getBoundingClientRect()
if (e.touches && e.touches.length) {
return { x: e.touches[0].clientX - rect.left, y: e.touches[0].clientY - rect.top }
} else if (e.changedTouches && e.changedTouches.length) {
return { x: e.changedTouches[0].clientX - rect.left, y: e.changedTouches[0].clientY - rect.top }
}
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
handlePointerDown(e: any): void {
const coords = this.getEventCoords(e)
this.active = true
this.startPos = { ...coords }
this.currentPos = { ...coords }
clearCanvas(this.canvasEl)
this.onStart && this.onStart()
}
handlePointerMove(e: PointerEventLike): void {
if (!this.active) return
this.currentPos = this.getEventCoords(e)
this.draw()
}
handlePointerUp(e: PointerEventLike): void {
if (!this.active) return
this.active = false
const rect = this.getRect()
clearCanvas(this.canvasEl)
this.onEnd && this.onEnd(rect)
}
draw(): void {
if (!this.canvasEl) return
const ctx = this.canvasEl.getContext("2d")
clearCanvas(this.canvasEl)
const left = Math.min(this.startPos.x, this.currentPos.x)
const top = Math.min(this.startPos.y, this.currentPos.y)
const width = Math.abs(this.currentPos.x - this.startPos.x)
const height = Math.abs(this.currentPos.y - this.startPos.y)
ctx.save()
ctx.strokeStyle = "#444"
ctx.lineWidth = 2
ctx.setLineDash([6])
ctx.strokeRect(left, top, width, height)
ctx.restore()
}
getRect(): Rect {
return {
left: Math.min(this.startPos.x, this.currentPos.x),
top: Math.min(this.startPos.y, this.currentPos.y),
width: Math.abs(this.currentPos.x - this.startPos.x),
height: Math.abs(this.currentPos.y - this.startPos.y)
}
}
}
class MandelbrotExplorer extends Component {
defaultCenterX = new Decimal(-0.75)
defaultCenterY = new Decimal(0.0)
defaultZ = new Decimal(1)
centerX?: Decimal
centerY?: Decimal
z?: Decimal
pleasantCanvasRatio: number
canvasWidth = window.innerWidth
canvasHeight = window.innerHeight
maxIterDefault = 1000
maxIter = this.maxIterDefault
colorMax = 1200
colorScale = 24.2
animating = false
rendering = false
zoomOutFactor = 2
maxZ = new Decimal(1E300)
prevZ = undefined
zoomStack = []
zoomDuration = 600
tip = new Tip()
marquee = new Marquee()
mandelbrotRenderer?: MandelbrotRenderer
canvasEl: HTMLCanvasElement
get zActual () { return this.z.times(this.pleasantCanvasRatio) }
set zActual (value) { this.z = value.dividedBy(this.pleasantCanvasRatio) }
get zoomAnimationsEnabled () { return this.maxIter <= 5000 }
busy () {
return this.animating || this.rendering
}
onAttached() {
setTimeout(() => this.init(), 1)
window.addEventListener("resize", debounce(() => this.updateLayout(), 100))
}
async init() {
this.mandelbrotRenderer = new MandelbrotRenderer()
await this.mandelbrotRenderer.ready()
this.marquee.onStart = () => this.tip.show()
this.marquee.onEnd = rect => this.processMarquee(rect)
this.updateLayout(() => this.initFromURL())
}
updateLayout(afterLayout?: () => void) {
this.canvasWidth = this.canvasEl.width = this.marquee.canvasEl.width = window.innerWidth
this.canvasHeight = this.canvasEl.height = this.marquee.canvasEl.height = window.innerHeight
this.pleasantCanvasRatio = this.canvasWidth / this.canvasHeight >= 1 ? 0.8 : 0.6
if (afterLayout)
afterLayout()
this.renderFractal()
}
initFromURL() {
this.centerX = parseDecimal(getUrlParam("x"), new Decimal(this.defaultCenterX))
this.centerY = parseDecimal(getUrlParam("y"), new Decimal(this.defaultCenterY))
this.z = this.prevZ = parseDecimal(getUrlParam("z"), new Decimal(this.defaultZ))
this.maxIter = parseInteger(getUrlParam("iterations"), this.maxIterDefault, { min: 100, max: 1000000 })
this.update()
}
zArg() {
return this.z.lessThan(1000000) ?
this.z.toSignificantDigits(3).toString() :
this.z.toSignificantDigits(3).toExponential().replace("+", "")
}
getParamString() {
const params = new URLSearchParams()
const sf = this.z.toFixed(2).length
params.set("z", this.zArg())
params.set("iterations", "" + this.maxIter)
params.set("x", this.centerX.toSignificantDigits(sf).toString())
params.set("y", this.centerY.toSignificantDigits(sf).toString())
return params.toString()
}
updateURL() {
if (
this.centerX.equals(this.defaultCenterX) &&
this.centerY.equals(this.defaultCenterY) &&
this.z.equals(this.defaultZ) &&
this.maxIter === this.maxIterDefault
) {
history.replaceState(null, "", window.location.pathname)
} else {
history.replaceState(null, "", window.location.pathname + "?" + this.getParamString())
}
}
onUpdated(event: UpdateEvent) {
const sel = document.getElementById("maxIter") as HTMLSelectElement
if (event.key == "maxIter") {
this.renderFractal()
sel.blur()
}
sel.value =""+this.maxIter
}
async renderFractal(options?: any) {
const zoomingIn = this.prevZ && this.prevZ.lessThan(this.z);
const zoomingOut = this.prevZ && this.prevZ.greaterThan(this.z)
if (!this.animating) {
Decimal.set({ precision: Math.max(this.prevZ.e, this.zActual.e) + 6 })
this.prevZ = this.z
}
if (zoomingOut && this.z.lessThan(this.defaultZ.add(0.01))) {
this.reset()
} else {
const args = {
width: this.canvasWidth,
height: this.canvasHeight,
x: this.centerX.toString(),
y: this.centerY.toString(),
r: new Decimal(1).dividedBy(this.zActual).toString(),
iterations: this.maxIter,
colorScale: this.colorScale,
colorMax: this.colorMax,
lowRes: this.animating,
...options
}
try {
this.rendering = true
await this.mandelbrotRenderer.startRenderer(args)
const ctx = this.canvasEl.getContext("2d")
while (true) {
const { done, value } = await this.mandelbrotRenderer.nextFrame()
if (done) break
const { bitmap, sourceRect, destinationRect, progress } = value
if (bitmap) {
ctx.drawImage(bitmap,
sourceRect.x, sourceRect.y, sourceRect.width, sourceRect.height,
destinationRect.x, destinationRect.y, destinationRect.width, destinationRect.height
)
}
}
}
catch (e) {
console.error(e)
}
finally {
this.rendering = false
}
if (!this.animating) {
this.updateURL()
}
}
}
processMarquee(rect) {
const { left, top, width, height } = rect
if (width < 5 || height < 5) return
this.tip.hide()
const aspect = this.canvasWidth / this.canvasHeight
const pixelToFractal = (px, py) => {
const normX = (px + 0.5) / this.canvasWidth
const normY = (py + 0.5) / this.canvasHeight
const canvasX = normX * (aspect >= 1 ? 2 * aspect : 2) + (aspect >= 1 ? -aspect : -1)
const canvasY = normY * (aspect >= 1 ? -2 : -2 / aspect) + (aspect >= 1 ? 1 : 1 / aspect)
const fractalX = this.centerX.plus(new Decimal(canvasX).dividedBy(this.zActual))
const fractalY = this.centerY.plus(new Decimal(canvasY).dividedBy(this.zActual))
return { x: fractalX, y: fractalY }
};
const p1 = pixelToFractal(left, top)
const p2 = pixelToFractal(left + width, top + height)
const newCenterX = p1.x.plus(p2.x).dividedBy(2)
const newCenterY = p1.y.plus(p2.y).dividedBy(2)
const fractalWidth = p2.x.minus(p1.x).abs()
const fractalHeight = p1.y.minus(p2.y).abs()
const newActualWidth = fractalWidth.dividedBy(aspect >= 1 ? (2 * aspect) : 2)
const newActualHeight = fractalHeight.dividedBy(aspect >= 1 ? 2 : (2 / aspect))
const newR = Decimal.max(newActualWidth, newActualHeight)
const newZActual = new Decimal(1).dividedBy(newR)
if (newZActual.greaterThan(this.maxZ) || !newZActual.isFinite()) return
if (!this.zoomAnimationsEnabled) {
this.centerX = newCenterX
this.centerY = newCenterY
this.zActual = newZActual
this.renderFractal()
} else {
const prevState = { centerX: this.centerX, centerY: this.centerY, zActual: this.zActual }
const targetState = { centerX: newCenterX, centerY: newCenterY, zActual: newZActual }
this.zoomStack.push({ prevState, targetState })
this.animateZoom(prevState, targetState)
}
}
animateZoom(fromState, toState) {
this.animating = true
const zoom = toState.zActual.dividedBy(fromState.zActual)
const startTime = performance.now()
const animateFrame = now => {
let t = (now - startTime) / this.zoomDuration
if (t > 1) t = 1
const interpolatedZoomFactor = new Decimal(1).plus(new Decimal(t).times(zoom.minus(1)))
const factor = new Decimal(t).times(zoom).dividedBy(interpolatedZoomFactor)
this.centerX = fromState.centerX.minus(factor.times(fromState.centerX.minus(toState.centerX)))
this.centerY = fromState.centerY.minus(factor.times(fromState.centerY.minus(toState.centerY)))
this.zActual = fromState.zActual.times(interpolatedZoomFactor)
if (t === 1) {
this.animating = false
}
this.renderFractal({forcePreview: t == 1})
if (t < 1) {
requestAnimationFrame(animateFrame)
}
}
requestAnimationFrame(animateFrame)
}
zoomOut() {
if (!this.zoomAnimationsEnabled) {
this.zoomStack = []
}
if (this.zoomAnimationsEnabled && this.zoomStack.length > 0) {
const zoomEntry = this.zoomStack.pop()
this.animateZoom(zoomEntry.targetState, zoomEntry.prevState)
}
else {
this.z = this.z.dividedBy(this.zoomOutFactor)
this.renderFractal()
}
}
reset() {
this.zoomStack = []
this.z = this.prevZ = this.defaultZ
this.centerX = this.defaultCenterX
this.centerY = this.defaultCenterY
this.maxIter = this.maxIterDefault
this.update()
setTimeout(() => this.renderFractal(), 1)
}
saveImage() {
if (this.busy()) return
const dataURL = this.canvasEl.toDataURL("image/jpeg")
let filename = "mandelbrot_" + this.getParamString() + ".jpeg"
const link = document.createElement("a")
link.href = dataURL
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
iterationOptions = [
{ label: "1K Anim.", value: 1000 },
{ label: "5K Anim.", value: 5000 },
{ label: "10K Still", value: 10000 },
{ label: "20K Still", value: 20000 },
{ label: "50K 💀", value: 50000 }
];
view() {
return div(
canvas({
id: "mainCanvas",
width: this.canvasWidth,
height: this.canvasHeight,
onMounted: el => { this.canvasEl = el as HTMLCanvasElement }
}),
this.marquee.view({ id: "marqueeCanvas", width: this.canvasWidth, height: this.canvasHeight }),
this.tip.view({ class: "tipOverlay" }, "click and drag to zoom"),
div({ class: "infoBox" },
h1({ class: "title" }, "∞Mandelbrot Ex²plorer"),
div({ class: "controls" },
controlButton({ onClick: () => this.saveImage() }, "Snapshot"),
inputSelect({ target: this, prop: () => this.maxIter, options: this.iterationOptions, id: "maxIter" }),
controlButton({ onClick: () => this.reset() }, "Reset"),
controlButton({ onClick: () => this.zoomOut() }, "Zoom Out")
)
)
)
}
}
class WorkerApi {
messageId = 0
pending = new Map()
worker: Worker
constructor (worker: Worker) {
this.worker = worker
worker.addEventListener("message", event => {
const { id, result, error } = event.data
const deferred = this.pending.get(id)
if (deferred) {
error ? deferred.reject(new Error(error)) : deferred.resolve(result)
this.pending.delete(id)
}
});
}
call(method:string, args?: {}) {
return new Promise((resolve, reject) => {
const id = this.messageId++
this.pending.set(id, { resolve, reject })
this.worker.postMessage({ id, method, args })
});
}
}
type FrameRect = {x: number, y: number, width: number, height: number}
class MandelbrotRenderer extends WorkerApi {
static getWorker() {
const workerUrl = URL.createObjectURL(new Blob([getInlineWorkerCode()], { type: "application/javascript" }))
return new Worker(workerUrl, { type: "module" })
}
constructor () { super (MandelbrotRenderer.getWorker()) }
ready() { return this.call("ready") }
startRenderer(args) { return this.call("startRenderer", args) }
nextFrame() { return this.call("nextFrame") as Promise<{
done: boolean,
value: { bitmap: ImageBitmap, sourceRect: FrameRect, destinationRect: FrameRect, progress: number }}>
}
}
new App({root: new MandelbrotExplorer(), id: "app"})
function getInlineWorkerCode() { return `
/* ------------------------------------------------------------------------------------------
* Algorithm adapted from Hastings Greer that allows 10^300 zoom depth; significant revisions:
*
* - Progressive rendering, w/ low resolution previews & tiling
* - Faster calculation heuristic by skipping calculation on probably costly pixels
* - Animation friendly w/ lower resolution for in-between frames
* - Works with fractional zoom levels, not only integer powers of two
* - Works with any aspect ratio, automatically filling the available space
* - Consistent colouring independent of max iterations
* - Renders asynchronously on an OffscreenCanvas & web worker enabled
* - Stateless render w/ cached lookups & throttling
*/
import { mat4 } from "https://esm.sh/gl-matrix";
import { init } from "https://esm.sh/[email protected]";
let binding = null; // Global MPFR binding
/* ------------------ Worker API ------------------ */
class ApiWorker {
constructor() {
self.onmessage = async event => {
const { id, method, args } = event.data;
try {
const result = await this[method](args);
self.postMessage({ id, result });
} catch (e) {
self.postMessage({ id, error: e.message });
}
};
this.currentIterator = null;
}
}
class MandelbrotWorker extends ApiWorker {
constructor() { super(); this.startRenderer = takeLatest(this.startRenderer.bind(this)); }
async ready() {
const result = await init();
binding = result.binding;
}
async startRenderer(args) { this.currentIterator = progressiveRenderMandelbrot(args);}
async nextFrame() {
if (!this.currentIterator) throw new Error("Renderer not started");
return this.currentIterator.next();
}
}
new MandelbrotWorker();
function takeLatest(fn) {
let latestCallId = 0;
return async function(...args) {
const callId = ++latestCallId;
const result = await fn(...args);
if (callId === latestCallId) { return result; }
};
}
/* ------------------ Utility Functions ------------------ */
function uniforms(gl, program, definitions) {
const uniformsMap = {};
definitions.forEach(({ name, type }) => {
const location = gl.getUniformLocation(program, name);
if (location === null) {
console.warn("Uniform " + name + " not found in the shader.");
}
uniformsMap[name] = { location, type, value: undefined };
});
function applyUniform(name, value) {
const { location, type } = uniformsMap[name];
if (!location) return;
switch (type) {
case "1f": gl.uniform1f(location, value); break;
case "1i": gl.uniform1i(location, value); break;
case "2f": gl.uniform2f(location, value[0], value[1]); break;
case "3f": gl.uniform3f(location, value[0], value[1], value[2]); break;
case "4f": gl.uniform4f(location, value[0], value[1], value[2], value[3]); break;
case "Matrix4fv": gl.uniformMatrix4fv(location, false, value); break;
default: console.error("Uniform type " + type + " not implemented.");
}
}
return new Proxy({}, {
get(target, prop) {
return prop in uniformsMap ? uniformsMap[prop].value : undefined;
},
set(target, prop, value) {
if (prop in uniformsMap) {
uniformsMap[prop].value = value;
applyUniform(prop, value);
return true;
}
target[prop] = value;
return true;
}
});
}
function loadShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
throw new Error("Error compiling shader: " + gl.getShaderInfoLog(shader));
return shader;
}
function initShaderProgram(gl, vsSource, fsSource) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
const shaderProgram = gl.createProgram();
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS))
throw new Error("Unable to initialize shader program: " + gl.getProgramInfoLog(shaderProgram));
return shaderProgram;
}
function createAndSetupTexture(gl, unit, width, height, internalFormat, format, type, data = null) {
const tex = gl.createTexture();
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, width, height, 0, format, type, data);
return tex;
}
function createFramebufferForTexture(gl, tex) {
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE)
throw new Error("Framebuffer not complete.");
return fbo;
}
function drawPass(gl, framebuffer, width, height) {
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.viewport(0, 0, width, height);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.finish();
}
function blit(gl, framebuffer, targetWidth, targetHeight, canvas, finalTex) {
gl.bindFramebuffer(gl.READ_FRAMEBUFFER, framebuffer);
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.blitFramebuffer(
0, 0, targetWidth, targetHeight,
0, 0, canvas.width, canvas.height,
gl.COLOR_BUFFER_BIT, gl.NEAREST
);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.deleteFramebuffer(framebuffer);
gl.deleteTexture(finalTex);
}
/* ------------------ Mandelbrot Rendering ------------------ */
function getLayout(width, height) {
const aspect = width / height;
const right = Math.max(aspect, 1);
const top = Math.max(1, 1 / aspect);
return { left: -right, right, top, bottom: -top };
}
/* ------------------ Shader Sources ------------------ */
// --- Vertex Shader ---
// Updated to include uTileOffset and uTileScale and to output tileDelta.
const vsSource = \`#version 300 es
in vec4 aVertexPosition;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform vec2 uTileOffset; // Tile offset in fractal space.
uniform vec2 uTileScale; // Tile scale factor.
out highp vec2 tileDelta; // Transformed coordinate passed to fragment shader.
void main() {
gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition;
// Map the vertex positions from local (tile) space into global fractal space.
tileDelta = (aVertexPosition.xy * uTileScale) + uTileOffset;
}\`;
// --- Fragment Shader ---
// Updated to use tileDelta and uFractalDomain for mask lookup.
const fsSource = \`#version 300 es
precision highp float;
uniform int uOrbitSize;
in highp vec2 tileDelta;
out vec4 fragColor;
uniform vec4 uState;
uniform float uColorMax;
//uniform int uColorAlgo;
uniform bool uUseHeuristic;
uniform bool uMaskTestSeeSkippedPixels;
uniform sampler2D uLowResMask;
uniform vec4 poly1;
uniform vec4 poly2;
uniform sampler2D sequence;
uniform vec4 uFractalDomain;
float get_orbit_x(int i) {
i = i * 3;
int row = i / uOrbitSize;
return texelFetch(sequence, ivec2(i % uOrbitSize, row), 0)[0];
}
float get_orbit_y(int i) {
i = i * 3 + 1;
int row = i / uOrbitSize;
return texelFetch(sequence, ivec2(i % uOrbitSize, row), 0)[0];
}
float get_orbit_scale(int i) {
i = i * 3 + 2;
int row = i / uOrbitSize;
return texelFetch(sequence, ivec2(i % uOrbitSize, row), 0)[0];
}
void main() {
if (uUseHeuristic) {
// Compute normalized coordinates for the mask by mapping the global fractal coordinate.
vec2 uv = (tileDelta - vec2(uFractalDomain.x, uFractalDomain.y)) /
(vec2(uFractalDomain.z, uFractalDomain.w) - vec2(uFractalDomain.x, uFractalDomain.y));
if (texture(uLowResMask, uv).r > 0.5) {
fragColor = vec4(uMaskTestSeeSkippedPixels ? 1.0 : 0.0, 0.0, 0.0, 1.0);
return;
}
}
float q = uState[2] - 1.0;
float cq = q;
q = q + poly2[3];
float S = pow(2.0, q);
float dcx = tileDelta.x;
float dcy = tileDelta.y;
float x;
float y;
float sqrx = (dcx * dcx - dcy * dcy);
float sqry = (2.0 * dcx * dcy);
float dx = poly1[0] * dcx - poly1[1] * dcy + poly1[2] * sqrx - poly1[3] * sqry;
float dy = poly1[0] * dcy + poly1[1] * dcx + poly1[2] * sqry + poly1[3] * sqrx;
int k = int(poly2[2]);
int j = k;
x = get_orbit_x(k);
y = get_orbit_y(k);
for (int i = k; float(i) < uState[3]; i++) {
j++; k++;
float os = get_orbit_scale(k - 1);
dcx = tileDelta.x * pow(2.0, -q + cq - os);
dcy = tileDelta.y * pow(2.0, -q + cq - os);
float unS = pow(2.0, q - os);
if (isinf(unS)) unS = 0.0;
float tx = 2.0 * x * dx - 2.0 * y * dy + unS * dx * dx - unS * dy * dy + dcx;
dy = 2.0 * x * dy + 2.0 * y * dx + unS * 2.0 * dx * dy + dcy;
dx = tx;
q = q + os;
S = pow(2.0, q);
x = get_orbit_x(k);
y = get_orbit_y(k);
float fx = x * pow(2.0, get_orbit_scale(k)) + S * dx;
float fy = y * pow(2.0, get_orbit_scale(k)) + S * dy;
if (fx * fx + fy * fy > 4.0) break;
if (dx * dx + dy * dy > 1000000.0) {
dx /= 2.0;
dy /= 2.0;
q = q + 1.0;
S = pow(2.0, q);
dcx = tileDelta.x * pow(2.0, -q + cq);
dcy = tileDelta.y * pow(2.0, -q + cq);
}
if ((fx * fx + fy * fy < S * S * dx * dx + S * S * dy * dy) || (x == -1.0 && y == -1.0)) {
dx = fx;
dy = fy;
q = 0.0;
S = pow(2.0, q);
dcx = tileDelta.x * pow(2.0, -q + cq);
dcy = tileDelta.y * pow(2.0, -q + cq);
k = 0;
x = get_orbit_x(0);
y = get_orbit_y(0);
}
}
// coloring
float iter = float(j);
float maxIter = uState[3];
float scaleFactor = uState[1];
float normIter = (uColorMax - iter) / scaleFactor;
fragColor = iter >= maxIter ?
vec4(0.0, 0.0, 0.0, 1.0) :
vec4(
(cos(normIter) / -2.0) + 0.5,
(cos(1.1214 * normIter) / -2.0) + 0.5,
(cos(0.8 * normIter) / -2.0) + 0.5,
1.0
);
}\`;
function mpfr_zero() {
const zero = binding.mpfr_t();
binding.mpfr_init2(zero, 1200);
binding.mpfr_set_d(zero, 0, 0);
return zero;
}
function get_exp(val) {
const tmp = mpfr_zero();
binding.mpfr_log2(tmp, val, 0);
return binding.mpfr_get_d(tmp, 0);
}
function alignExponents(a, b) {
let [am, ae] = a, [bm, be] = b;
const retE = Math.max(ae, be);
if (retE > ae) am *= Math.pow(2, ae - retE);
else bm *= Math.pow(2, be - retE);
return [am, bm, retE];
}
function sub(a, b) {
let [am, bm, e] = alignExponents(a, b);
return [am - bm, e];
}
function add(a, b) {
let [am, bm, e] = alignExponents(a, b);
return [am + bm, e];
}
function mul(a, b) {
let [am, ae] = a, [bm, be] = b;
let m = am * bm, e = ae + be;
if (m !== 0) {
const logm = Math.round(Math.log2(Math.abs(m)));
m = m / Math.pow(2, logm);
e += logm;
}
return [m, e];
}
function maxabs(a, b) {
let [am, bm, e] = alignExponents(a, b);
return [Math.max(Math.abs(am), Math.abs(bm)), e];
}
function gt(a, b) {
const [am, bm] = alignExponents(a, b);
return am > bm;
}
function floaty(d) {
return Math.pow(2, d[1]) * d[0];
}
function createReferenceOrbit(cx, cy, radius, iterations, orbitSize) {
const orbit = getOrCreateFloat32Array("orbit", orbitSize * orbitSize);
orbit.fill(-1);
let x = mpfr_zero(), y = mpfr_zero();
const txx = mpfr_zero(), txy = mpfr_zero(), tyy = mpfr_zero();
let polylim = 0,
Bx = [0, 0], By = [0, 0],
Cx = [0, 0], Cy = [0, 0],
Dx = [0, 0], Dy = [0, 0],
poly = [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0]],
not_failed = true;
for (let i = 0; i < iterations; i++) {
const x_exponent = binding.mpfr_get_exp(x);
const y_exponent = binding.mpfr_get_exp(y);
let scale_exponent = Math.max(x_exponent, y_exponent);
if (scale_exponent < -10000) scale_exponent = 0;
let tmp = 0;
orbit[3 * i] = binding.mpfr_get_d_2exp(tmp, x, 0) / Math.pow(2, scale_exponent - x_exponent);
orbit[3 * i + 1] = binding.mpfr_get_d_2exp(tmp, y, 0) / Math.pow(2, scale_exponent - y_exponent);
orbit[3 * i + 2] = scale_exponent;
const fx = [orbit[3 * i], orbit[3 * i + 2]];
const fy = [orbit[3 * i + 1], orbit[3 * i + 2]];
binding.mpfr_mul(txx, x, x, 0);
binding.mpfr_mul(txy, x, y, 0);
binding.mpfr_mul(tyy, y, y, 0);
binding.mpfr_sub(x, txx, tyy, 0);
binding.mpfr_add(x, x, cx, 0);
binding.mpfr_add(y, txy, txy, 0);
binding.mpfr_add(y, y, cy, 0);
const prev_poly = [Bx, By, Cx, Cy, Dx, Dy];
[Bx, By, Cx, Cy, Dx, Dy] = [
add(mul([2, 0], sub(mul(fx, Bx), mul(fy, By))), [1, 0]),
mul([2, 0], add(mul(fx, By), mul(fy, Bx))),
sub(add(mul([2, 0], sub(mul(fx, Cx), mul(fy, Cy))), mul(Bx, Bx)), mul(By, By)),
add(mul([2, 0], add(mul(fx, Cy), mul(fy, Cx))), mul(mul([2, 0], Bx), By)),
mul([2, 0], add(sub(mul(fx, Dx), mul(fy, Dy)), sub(mul(Cx, Bx), mul(Cy, By)))),
mul([2, 0], add(add(add(mul(fx, Dy), mul(fy, Dx)), mul(Cx, By)), mul(Cy, Bx)))
];
tmp = 0;
const fx_val = [binding.mpfr_get_d_2exp(tmp, x, 0), binding.mpfr_get_exp(x)];
const fy_val = [binding.mpfr_get_d_2exp(tmp, y, 0), binding.mpfr_get_exp(y)];
if (i === 0 || gt(maxabs(Cx, Cy), mul([1000, binding.mpfr_get_exp(radius)], maxabs(Dx, Dy)))) {
if (not_failed) {
poly = prev_poly;
polylim = i;
}
} else {
not_failed = false;
}
if (gt(add(mul(fx_val, fx_val), mul(fy_val, fy_val)), [400, 0])) break;
}
return [orbit, poly, polylim];
}
function computeOrbitAndPoly(x, y, r, iterations, orbitSize) {
const center = [mpfr_zero(), mpfr_zero()];
const radius = mpfr_zero();
binding.mpfr_set_string(center[0], x, 10, 0);
binding.mpfr_set_string(center[1], y, 10, 0);
binding.mpfr_set_string(radius, r, 10, 0);
const [orbit, poly, polylim] = createReferenceOrbit(center[0], center[1], radius, iterations, orbitSize);
const rexp = binding.mpfr_get_exp(radius);
const r_val = binding.mpfr_get_d_2exp(0, radius, 0);
const poly_scale_exp = mul([1, 0], maxabs(poly[0], poly[1]));
const poly_scale = [1, -poly_scale_exp[1]];
const poly_scaled = [
mul(poly_scale, poly[0]),
mul(poly_scale, poly[1]),
mul(poly_scale, mul([r_val, rexp], poly[2])),
mul(poly_scale, mul([r_val, rexp], poly[3])),
mul(poly_scale, mul([r_val, rexp], mul([r_val, rexp], poly[4]))),
mul(poly_scale, mul([r_val, rexp], mul([r_val, rexp], poly[5]))),
].map(floaty);
return { orbit, poly_scaled, polylim, poly_scale_exp, radius };
}
function spiralOutFromCenter(flatArray) {
const n = Math.sqrt(flatArray.length);
const center = (n - 1) / 2;
const cells = flatArray.map((value, idx) => {
const i = Math.floor(idx / n);
const j = idx % n;
return { value, dist: Math.hypot(i - center, j - center) };
});
cells.sort((a, b) => a.dist - b.dist);
return cells.map(cell => cell.value);
}
function generateTileRects(width, height, tilesX, tilesY) {
const tiles = [];
const tileWidth = Math.floor(width / tilesX);
const tileHeight = Math.floor(height / tilesY);
for (let row = 0; row < tilesY; row++) {
const y = row * tileHeight;
for (let col = 0; col < tilesX; col++) {
const x = col * tileWidth;
const w = col === tilesX - 1 ? (width - x) : tileWidth;
const h = row === tilesY - 1 ? (height - y) : tileHeight;
tiles.push({ x, y, width: w, height: h });
}
}
return spiralOutFromCenter (tiles);
}
/**
* Computes the transformation for a tile by mapping its rectangle from canvas
* coordinates to the fractal domain and then to the target layout.
*
* @param {Object} tileRect - The tile rectangle in canvas coordinates {x, y, width, height}.
* @param {Object} canvasDims - The canvas dimensions {width, height}.
* @param {Object} fractalDomain - The fractal domain {left, bottom, right, top}.
* @param {Object} targetLayout - The target layout for rendering {left, bottom, right, top}.
* @returns {{offset: number[], scale: number[]}} The computed transform with offset and scale.
*/
function computeTileTransform(tileRect, canvasDims, fractalDomain, targetLayout) {
if (!tileRect) return { offset: [0, 0], scale: [1, 1] };
const normalizeRect = ({ x, y, width, height }) => ({
x: x / canvasDims.width,
y: (canvasDims.height - y - height) / canvasDims.height,
width: width / canvasDims.width,
height: height / canvasDims.height
});
const mapToFractalDomain = ({ x, y, width, height }) => ({
x: fractalDomain.left + x * (fractalDomain.right - fractalDomain.left),
y: fractalDomain.bottom + y * (fractalDomain.top - fractalDomain.bottom),
width: width * (fractalDomain.right - fractalDomain.left),
height: height * (fractalDomain.top - fractalDomain.bottom)
});
const normTile = normalizeRect(tileRect);
const tileFractal = mapToFractalDomain(normTile);
const scaleX = tileFractal.width / (targetLayout.right - targetLayout.left);
const scaleY = tileFractal.height / (targetLayout.top - targetLayout.bottom);
const offsetX = tileFractal.x - targetLayout.left * scaleX;
const offsetY = tileFractal.y - targetLayout.bottom * scaleY;
return { offset: [offsetX, offsetY], scale: [scaleX, scaleY] };
}
function computeMaskFromPixels(pixelBuffer, width, height, settings) {
const { foveaWidth: w, foveaDetail: d } = settings;
const mask = new Uint8Array(width * height);
for (let y = w; y < height - w; y++) {
for (let x = w; x < width - w; x++) {
let allBlack = true;
for (let j = -w; j <= w && allBlack; j += d) {
for (let i = -w; i <= w; i += d) {
const idx = 4 * ((y + j) * width + (x + i));
if (!(pixelBuffer[idx] === 0 &&
pixelBuffer[idx + 1] === 0 &&
pixelBuffer[idx + 2] === 0 &&
pixelBuffer[idx + 3] === 255)) {
allBlack = false;
break;
}
}
}
mask[y * width + x] = allBlack ? 255 : 0;
}
}
return mask;
}
const getOrCreateFloat32Array = (key, length) =>
getOrCreate(key, o => o.length == length, () => new Float32Array(length));
const getOrCreate = (() => {
const cache = new Map();
return (key, isCompatible, createNew, cleanup) => {
const cachedObject = cache.get(key);
if (cachedObject && isCompatible(cachedObject)) {
return cachedObject;
}
if (cachedObject && cleanup) {
cleanup(cachedObject);
}
const newObject = createNew();
cache.set(key, newObject);
return newObject;
};
})();
const getOrCreateOffscreenCanvas = (key, width, height) =>
getOrCreate(
key,
canvas => canvas.width === width && canvas.height === height,
() => new OffscreenCanvas(width, height)
);
function cleanupWebGLResources(resource) {
const { gl, buffers, programInfo } = resource;
if (!gl) return;
if (buffers) {
for (const key in buffers) {
gl.deleteBuffer(buffers[key]);
}
}
if (programInfo && programInfo.program) {
gl.deleteProgram(programInfo.program);
}
const ext = gl.getExtension("WEBGL_lose_context");
if (ext) {
ext.loseContext();
}
}
function fetchWebGLResources(canvas, canvasKey) {
const resourceKey = canvasKey + "_resources";
const resources = getOrCreate(
resourceKey,
resource => resource.canvas === canvas &&
resource.canvas.width === canvas.width &&
resource.canvas.height === canvas.height,
() => {
const gl = canvas.getContext("webgl2", {
antialias: false,
preserveDrawingBuffer: true
});
if (!gl) throw new Error("WebGL2 not supported.");
const shaderProgram = initShaderProgram(gl, vsSource, fsSource);
gl.useProgram(shaderProgram);
const programInfo = {
program: shaderProgram,
attribLocations: {
vertexPosition: gl.getAttribLocation(shaderProgram, "aVertexPosition")
},
uniforms: uniforms(gl, shaderProgram, [
{ name: "uProjectionMatrix", type: "Matrix4fv" },
{ name: "uModelViewMatrix", type: "Matrix4fv" },
{ name: "uState", type: "4f" },
{ name: "uColorMax", type: "1f" },
//{ name: "uColorAlgo", type: "1i" },
{ name: "uUseHeuristic", type: "1i" },
{ name: "uMaskTestSeeSkippedPixels", type: "1i" },
{ name: "uLowResMask", type: "1i" },
{ name: "poly1", type: "4f" },
{ name: "poly2", type: "4f" },
{ name: "sequence", type: "1i" },
{ name: "uOrbitSize", type: "1i" },
{ name: "uTileOffset", type: "2f" },
{ name: "uTileScale", type: "2f" },
{ name: "uFractalDomain", type: "4f" }
])
};
const buffers = {};
gl.viewport(0, 0, canvas.width, canvas.height);
const rect = getLayout(canvas.width, canvas.height);
const projectionMatrix = mat4.create();
mat4.ortho(projectionMatrix, rect.left, rect.right, rect.bottom, rect.top, -1, 1);
const modelViewMatrix = mat4.create();
gl.useProgram(programInfo.program);
programInfo.uniforms.uProjectionMatrix = projectionMatrix;
programInfo.uniforms.uModelViewMatrix = modelViewMatrix;
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const layout = [
rect.right, rect.top,
rect.left, rect.top,
rect.right, rect.bottom,
rect.left, rect.bottom
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(layout), gl.STATIC_DRAW);
buffers.position = positionBuffer;
return { gl, programInfo, canvas, buffers };
},
cleanupWebGLResources // Cleanup callback when replacing an old resource.
);
// Sizing
const { gl, programInfo } = resources;
gl.viewport(0, 0, canvas.width, canvas.height);
const rect = getLayout(canvas.width, canvas.height);
const projectionMatrix = mat4.create();
mat4.ortho(projectionMatrix, rect.left, rect.right, rect.bottom, rect.top, -1, 1);
const modelViewMatrix = mat4.create();
gl.useProgram(programInfo.program);
programInfo.uniforms.uProjectionMatrix = projectionMatrix;
programInfo.uniforms.uModelViewMatrix = modelViewMatrix;
return resources;
}
/* renders a mask in low resolution of black pixels presumed not worth computing in higher resolution, as also presumed to be black */
function renderMask(gl, scaleFactor, fullWidth, fullHeight, cachedObjects, updateUniforms) {
// maskSettings: optimal blend of accuracy and performance
const maskSettings = { active: true, testSeeSkippedPixels: false, scaleFactor: 3, foveaWidth: 3, foveaDetail: 1 };
const appliedScale = scaleFactor * maskSettings.scaleFactor; // scale compounds
const maskWidth = Math.floor(fullWidth / appliedScale);
const maskHeight = Math.floor(fullHeight / appliedScale);
let cachedMask = cachedObjects.maskData; // caching is essential for tiling; we only need 1 mask computation for the full image
if (!cachedMask || cachedMask.maskWidth !== maskWidth || cachedMask.maskHeight !== maskHeight) {
const heuristicTex = createAndSetupTexture(gl, 3, maskWidth, maskHeight, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE);
const heuristicFbo = createFramebufferForTexture(gl, heuristicTex);
updateUniforms({ uUseHeuristic: 0 }); // the mask itself doesn't need/use the masking heuristic
drawPass(gl, heuristicFbo, maskWidth, maskHeight); // render a scaled down mandelbrot
const pixelBuffer = new Uint8Array(maskWidth * maskHeight * 4);
gl.bindFramebuffer(gl.FRAMEBUFFER, heuristicFbo);
// readPixels is costly; crosses the JS/WebGL2 boundary. trade-off is render will be marginally slower on
// regions with few black pixels, but far faster on regions with many black pixels. tried pushing
// this into a WebGL2 shader, but ironically performance was actually worse
const tComputationStart = performance.now();
gl.readPixels(0, 0, maskWidth, maskHeight, gl.RGBA, gl.UNSIGNED_BYTE, pixelBuffer);
const computationTime = performance.now() - tComputationStart;
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
const mask = computeMaskFromPixels(pixelBuffer, maskWidth, maskHeight, maskSettings);
gl.deleteFramebuffer(heuristicFbo);
gl.deleteTexture(heuristicTex);
cachedMask = { mask, maskWidth, maskHeight, computationTime };
cachedObjects.maskData = cachedMask;
}
createAndSetupTexture(gl, 2, cachedMask.maskWidth, cachedMask.maskHeight, gl.R8, gl.RED, gl.UNSIGNED_BYTE, cachedMask.mask);
updateUniforms({
uLowResMask: 2,
uUseHeuristic: maskSettings.active,
uMaskTestSeeSkippedPixels: maskSettings.testSeeSkippedPixels
});
}
function renderMandelbrot(args) {
const defaults = { lowRes: false, scaleFactor: 3, orbitSize: 1024, cachedObjects: {} };
args = { ...defaults, ...args };
const { width, height, tileRect } = args;
const finalRect = tileRect ?? { x: 0, y: 0, width, height };
const orbitData = args.cachedObjects.orbitData ||
(args.cachedObjects.orbitData = computeOrbitAndPoly(args.x, args.y, args.r, args.iterations, args.orbitSize));
const canvasKey = tileRect ? "tileCanvas" : "mainCanvas";
const offscreenCanvas = getOrCreateOffscreenCanvas(canvasKey, finalRect.width, finalRect.height);
const { gl, programInfo, buffers } = fetchWebGLResources(offscreenCanvas, canvasKey);
gl.useProgram(programInfo.program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
gl.vertexAttribPointer(programInfo.attribLocations.vertexPosition, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition);
createAndSetupTexture(gl, 0, args.orbitSize, args.orbitSize, gl.R32F, gl.RED, gl.FLOAT, new Float32Array(orbitData.orbit));
const updateUniforms = (newUniforms) => Object.assign(programInfo.uniforms, newUniforms);
const fullLayout = getLayout(width, height);
const targetLayout = getLayout(finalRect.width, finalRect.height);
const transform = computeTileTransform(tileRect, { width, height }, fullLayout, targetLayout);
updateUniforms({
uFractalDomain: [fullLayout.left, fullLayout.bottom, fullLayout.right, fullLayout.top],
uTileOffset: transform.offset,
uTileScale: transform.scale,
uState: [0, args.colorScale, 1 + get_exp(orbitData.radius), args.iterations],
uColorMax: args.colorMax || args.iterations,
uColorAlgo: args.colorAlgo || 0,
poly1: orbitData.poly_scaled.slice(0, 4),
poly2: [orbitData.poly_scaled[4], orbitData.poly_scaled[5], orbitData.polylim, orbitData.poly_scale_exp[1]],
uOrbitSize: args.orbitSize
});
const appliedScale = args.lowRes ? args.scaleFactor : 1;
const targetWidth = Math.floor(finalRect.width / appliedScale);
const targetHeight = Math.floor(finalRect.height / appliedScale);
renderMask(gl, appliedScale, width, height, args.cachedObjects, updateUniforms);
if (args.maskOnlyRender) {
return {cachedObjects: args.cachedObjects };
}
else {
const finalTex = !args.lowRes ? null : createAndSetupTexture(gl, 4, targetWidth, targetHeight, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE);
const framebuffer = !args.lowRes ? null : createFramebufferForTexture(gl, finalTex);
gl.viewport(finalRect.x, finalRect.y, finalRect.width, finalRect.height);
drawPass(gl, framebuffer, targetWidth, targetHeight);
if (args.lowRes) {
blit(gl, framebuffer, targetWidth, targetHeight, offscreenCanvas, finalTex);
gl.deleteFramebuffer(framebuffer);
gl.deleteTexture(finalTex);
}
return { bitmap: offscreenCanvas.transferToImageBitmap(), cachedObjects: args.cachedObjects };
}
}
/**
* Returns a Mandelbrot set as a bitmap.
* @param {Object} args - Rendering parameters.
* @param {number} args.width - Pixel width.
* @param {number} args.height - Pixel height.
* @param {string} args.x - The arbitrary precision x-coordinate of the Mandelbrot center as a string.
* @param {string} args.y - The arbitrary precision y-coordinate of the Mandelbrot center as a string.
* @param {string} args.r - The arbitrary precision radius/zoom magnification.
* @param {number} args.iterations - Maximum iterations.
* @param {number} args.colorScale - Color map scale factor.
* @param {number} args.colorMax - Fixed iteration count for coloring; defaults to iterations.
* @param {number} [args.colorAlgo=0] - Color algorithm. // todo.
* @param {boolean} [args.lowRes=false] - Render in low resolution if true; full resolution if false.
* @param {number} [args.scaleFactor=3] - Scale factor to use when lowRes is true.
* @param {number} [args.orbitSize=1024] - The size of the orbit texture.
* @param {number} [args.forcePreview=false] - Whether to force a low resolution preview, even if performing quickly.
* @returns {ImageBitmap}
*/
async function* progressiveRenderMandelbrot(args) {
const { width, height, lowRes, forcePreview, scaleFactor = 3 } = args;
const canvasRect = { x: 0, y: 0, width, height };
// Even if lowRes isn't selected, we render in lowRes first; performance is tested & early visual feedback may be yielded
const lowResResult = renderMandelbrot({ ...args, lowRes: true });
const progressCalc = imagesDone => lowRes ? 1 : Math.round(100 * (imagesDone / (scaleFactor * scaleFactor + 1))) / 100;
// technically we shouldn't hard code 20, it's dependent on maskSettings.scaleFactor^2 & analysis of mask pixels, but this is ok for now
if (!lowRes && lowResResult.computationTime < 20 && ! forcePreview) {
// Fast enough – render final high-res version, no need to yield low res preview unless explicitly asked for
const highResResult = renderMandelbrot({ ...args, lowRes: false, cachedObjects: lowResResult.cachedObjects });
yield { bitmap: highResResult.bitmap, sourceRect: canvasRect, destinationRect: canvasRect, progress: 1 };
return;
}
yield { bitmap: lowResResult.bitmap, sourceRect: canvasRect, destinationRect: canvasRect, progress: progressCalc(1) };
if (lowRes) return;
const maskOnlyResult = renderMandelbrot({ ...args, maskOnlyRender: true, lowRes: false, cachedObjects: lowResResult.cachedObjects });
// Each tile reuses the cached full image mask computed by the mask only result
const tileRects = generateTileRects(width, height, scaleFactor, scaleFactor);
for (const tileInfo of tileRects) {
const highResTileResult = renderMandelbrot({ ...args, lowRes: false, tileRect: tileInfo, cachedObjects: maskOnlyResult.cachedObjects });
yield {
bitmap: highResTileResult.bitmap,
sourceRect: { x: 0, y: 0, width: tileInfo.width, height: tileInfo.height },
destinationRect: { x: tileInfo.x, y: tileInfo.y, width: tileInfo.width, height: tileInfo.height },
progress: progressCalc(tileRects.indexOf(tileInfo) + 2)
};
}
}`};