Infinite Mandelbrot

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"}) ```

code.tsx

/**
 * 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)
    };
  }
}`};

Markdown source · More bulbs by samples · Typebulb home