[click on image to start zoomer]
favimage

jsFractalZoom

Fractal zoomer written in JavaScript

There is also an image gallery.

Instead of an ad with tracking…
Like to donate opencollective some appreciation for the use or inspiration this gives you?

Welcome to the Wonderful World of rendering on demand

when insufficient resources force you to prioritize which pixels to render first

Experience the fractal zoomer

Jump to https://rockingship.github.io/jsFractalZoom

How to use:

Saving:

Saving a multi-monitor desktop wallpaper:

Tips for using in 4K:

For desktop use (primary design target):

For touchscreen use:

Table of contents

The fractal zoomer

zoomer is a rendering engine that paints pixels on demand.
Pixels are repositioned/duplicated until they drift too far away while moving and are recalculated.

Two sample implementations are included: an interactive fractal navigator and a non-interactive fractal viewer.
It is also used to display transverse Mercator projection with the ccbc project.

zoomer utilises a state machine using phase-locked-loops to construct frames.
This means that frame construction is split into different steps where timing metrics predict how long each step takes.
Timer predictions allows the event queue to maximize computations while the user interface stays responsive.
Three coordinate systems are also used throughout construction.

Implementation wise, the only requirement is to supply:

    /**
     * This is done for every pixel. optimize well!
     * Easy extendable for 3D.
     * Return the pixel value for the given floating point coordinate.
     * Zoomer will use it to fill integer pixel positions. 
     * The positions are ordered in decreasing significance.
     *
     * @param {Zoomer}      zoomer  - 😊
     * @param {ZoomerFrame} frame   - Pixels/Palette/Rotate
     * @param {float}       x       - X coordinate
     * @param {float}       y       - Y coordinate
     * @return {int} - Pixel value           
     */
    onUpdatePixel: (zoomer, frame, x, y) => {
        [YourCodeHere];
    }

Rulers

Rulers contain pixel metadata and are used to determine “hot”/”cold” regions with lots/less of changes.
Hot regions which focus on action are calculated first which cools them down. Cooler regions focus on quality.
Rulers are created by comparing the last rendered frame with the next desired frame.
The goal is to maximize the amount of hot pixels (representing significant scene changes) before the frame construction time limit expires.

Rulers are used to implement the following:

Rulers contain the following information:

There are rulers for every dimensional axis.
Initial frame population performs scaling and shifting which introduces motion-blur.
Scan-line calculations determines exact pixel values for coordinates which introduces sharpness.

NOTE: Determining the ordering of scan-lines is determined exclusive by ruler metrics and not pixels values.

Coordinates

Pixel values use three different types of coordinates:

Which unit is applicable depends on the position in the data path:

formula <-xy-> backingStore <-ij-> clip/rotation <-uv-> screen/canvas

Directional vector

The directional vector is what you see and how you move.
This is a different concept than the motion vector used for macro blocks. Updating the vector is user-defined, the engine considers it read-only.

The vector consists of three components:

States

zoomer is a timed state machine to construct frames.
Frame construction has been split into phases/states.

The states are:

State timings:

The COPY, UPDATE and PAINT states are run from the main event queue, RENDER is done by web-workers. The duration of a complete frame is max(COPY+UPDATE+PAINT,RENDER). The Phased Locked Loop should tune COPY+UPDATE+PAINT to equal the requested frame rate

Running on an AMD FX-8320E, state durations (mSec) have been grossly averaged in the following table:

platform COPY UPDATE RENDER PAINT MAX FPS
Firefox 1080p 7 30 11 9 21
Firefox 4K 50 30 150 62 7
Chrome 1080p 11 29 9 2 23
Chrome 4K 38 28 31 12 12

The timings were measured with a requested FPS of 20.
The 4K is too much to handle, the engine will automatically reduce FPS until balance is reached.

The choice to perform RENDER as web-worker is because:

NOTE: requestAnimationFrame is basically unusable because (at least) Firefox has added jitter as anti-fingerprinting feature.
It also turns out that a stable interval between frames is more important than the moment they are displayed.

Phased Locked Loop

The computation time needed for COPY, RENDER and PAINT is constant depending on screen resolution.
The UPDATE timings for calculating a pixel is variable and undetermined.
Querying timers is considered a performance hit and should be avoided, especially after calculating each pixel.
The stability of framerate depends on the accuracy of timing predictions.

Phased Locked Loop predicts the number of calculations/iterations based on averages from the past.
Two time measurements are made, before and after a predetermined number of iterations.
The number of iterations for the next round is adjusted accordingly.

Phased Lock Loops are self adapting to environmental changes like Javascript engine, hardware and display resolutions.

Backing store

Backing store (data storage) has three functions:

Rotation

rotate-400x400.webp

When rotating is enabled the pixel storage (backing store) needs to hold all the pixels for all angles.
The size of the storage is the diagonal of the screen/canvas squared.
Rotation uses fixed point sin/cos.
The sin/cos is loop unrolled to make clipping/rotating high speed.

Rotation has two penalties:

Zoomer is designed to easily enable/disable rotational mode on demand.
However, disabling will delete the out-of-sight pixels and enabling needs to recalculate them.

memcpy()

Javascript as a language does not support acceleration of array copy.
In languages like C/C++, it is advertised as library function memcpy().
With Javascript, the only access to memcpy() is through Array.subarray().

    /**
     * zoomerMemcpy Accelerated array copy.
     *
     * @param {ArrayBuffer} dst       - Destination array
     * @param {int}         dstOffset - Starting offset in destination
     * @param {ArrayBuffer} src       - Source array
     * @param {int}         srcOffset - Starting offset in source
     * @param {int}         length    - Number of elements to be copyied
     */
    function zoomerMemcpy(dst, dstOffset, src, srcOffset, length) {
        src = src.subarray(srcOffset, srcOffset + length);
        dst.set(src, dstOffset);
    }

Within zoomer, three variations of memcpy() are used:

Indexed

Indexed memcpy transforms the contents using a lookup table.
Palettes are lookup tables translating from pixel value to RGBA.
Copying/scaling/shifting pixel values from the previous frame to next after ruler creation.

A conceptual implementation:

    function memcpyIndexed(dst, src, cnt) {
      for (let i=0; i<cnt; i++)
        dst[i] = SomeLookupTable[src[i]];
    }  

Interleaved

There are two kinds of scan-lines: scan-rows and scan-columns.
Only scan-rows can profit from hardware acceleration.
CPU instruction-set lacks multi dimensional/interleave instruction support.
Auto-increment is always word based.
Acceleration support for arbitrary offset is missing.

A conceptual implementation:

    // increment can be negative
    // an option could be to have separate increments for source/destination
    function memcpyInterleave(dst, src, cnt, offset) {
      for (let i=0; i<cnt; i++)
          dst[i*offset] = src[i*offset];
    }  

Angled

Clip and rotate when copying pixels from the backing store to RGBA. Fixed point/integer and loop unrolling are major optimisation techniques.

A conceptual implementation:

    /**
     * memcpy with clip and rotate. (partially optimised)
     *
     * @param {ArrayBuffer} dst         - Destination array (rgba[]) 
     * @param {ArrayBuffer} src         - Source array (pixel[])
     * @param {int}         viewWidth   - Viewport width (pixels)
     * @param {int}         viewHeight  - Viewport height (pixels)
     * @param {int}         pixelWidth  - Backing store width (pixels)
     * @param {int}         pixelHeight - Backing store height (pixels)
     */
    function memcpyAngle(dst, src, angle, viewWidth, viewHeight, pixelWidth, pixelHeight) {
        // Loop unroll slating increments
        // Fixed point floats
        // with 4K displays rounding errors are negligible. 
        const rsin = Math.sin(angle * Math.PI / 180); // sine for view angle
        const rcos = Math.cos(angle * Math.PI / 180); // cosine for view angle
        const xstart = Math.floor((pixelWidth - viewHeight * rsin - viewWidth * rcos) * 32768);
        const ystart = Math.floor((pixelHeight - viewHeight * rcos + viewWidth * rsin) * 32768);
        const xstep = Math.floor(rcos * 65536);
        const ystep = Math.floor(rsin * 65536);

        // copy pixels
        let vu = 0;
        for (let j = 0, x = xstart, y = ystart; j < viewHeight; j++, x += xstep, y += ystep) {
            for (let i = 0, ix = x, iy = y; i < viewWidth; i++, ix += ystep, iy -= xstep) {
                dst[vu++] = src[(iy >> 16) * pixelWidth + (ix >> 16)];
            }
        }
    }

Application components

A main design principle is to separate pixel data (frame), render logic (view) and UI resources (callbacks).

An application implementing zoomer consists of five areas:

Sample/skeleton implementation HTML/CSS

The following is a minimalistic template:

<!DOCTYPE html>
<html lang="en">
<head>
    <title>Example</title>
    <meta charset="UTF-8">
    <style>
        body {
            position: absolute;
            border: none;
            margin: auto;
            padding: 0;
            height: 100%;
            width: 100%;
            top: 0;
            right: 0;
            bottom: 0;
            left: 0;
        }
        #idZoomer {
            position: absolute;
            border: none;
            margin: auto;
            padding: 0;
            width: 100%;
            height: 100%;
            top: 0;
            right: 0;
            bottom: 0;
            left: 0;
        }
    </style>
    <script src="zoomer.js"></script>
</head>
<body>
<canvas id="idZoomer"> </canvas>
<script>
    "use strict";

    window.addEventListener("load", function () {

        /**
         * Get canvas to draw on (mandatory)
         * @type {HTMLElement}
         */
        const domZoomer = document.getElementById("idZoomer");

        /**
         * Get context 2D (mandatory), "desynchronized" is faster but may glitch hovering mouse (optional)
         * @type {CanvasRenderingContext2D}
         */
        const ctx = domZoomer.getContext("2d", {desynchronized: true});

        // get available client area
        const realWidth = Math.round(document.body.clientWidth * window.pixelDensityRatio);
        const realHeight = Math.round(document.body.clientHeight * window.pixelDensityRatio);

        // set canvas size (mandatory)		
        domZoomer.width = realWidth;
        domZoomer.height = realHeight;

        /**
         * Create zoomer (mandatory)
         * @type {Zoomer}
         */
        const zoomer = new Zoomer(realWidth, realHeight, false, OPTIONS);

        /**
         * Create a small key frame (mandatory)
         * @type {ZoomerView}
         */
        const keyView = new ZoomerView(64, 64, 64, 64);

        // Calculate all the pixels (optional), or choose any other content 
        keyView.fill(initialX, initialY, initialRadius, initialAngle, zoomer, zoomer.onUpdatePixel);

        // set initial position and inject key frame (mandatory)
        zoomer.setPosition(initialX, initialY, initialRadius, initialAngle, keyView);

        // start engine (mandatory)
        zoomer.start();
    });
</script>
</body>
</html>

Sample/skeleton implementation Javascript

zoomer accesses DOM through callbacks.
This also allows accessing user-defined data such as palettes.
All callbacks have the zoomer instance as first argument for easy engine access.

Invoking zoomer requires the presence of an option object.
The option object presets zoomer properties.
All properties are public, callbacks can change any value whenever they like.

Most important properties/callbacks are:

const OPTIONS = {
    /**
     * Frames per second.
     * Rendering frames is expensive, too high setting might render more than calculate.
     * If a too high setting causes a frame to drop, `zoomer` will lower this setting with 10%
     *
     * @member {float} - Frames per second
     */
    frameRate: 20,

    /**
     * Disable web-workers.
     * Offload frame rendering to web-workers.
     * When ever the default changes, you will appreciate it explicitly being noted.
     * You cannot use webworkers if you add protected recources to frames.
     *
     * @member {boolean} - disable/Enable web workers.
     */
    disableWW: false,

    /**
     * Additional resources added to new frames.
     * Frames are passed to webworkers.
     * Frames are re-used without reinitialising.
     *
     * Most commonly, setup optional palette,
     *
     * @param {Zoomer}      zoomer - Running engine
     * @param {ZoomerFrame} frame  - Frame being initialized.
     */
    onInitFrame: (zoomer, frame) => {
        // allocate RGBA palette.

        /* frame.palette = new Uint32Array(65536); */
    },

    /**
     * Start of a new frame.
     * Process timed updates (piloting), set x,y,radius,angle.
     *
     * @param {Zoomer}      zoomer    - Running engine
     * @param {ZoomerView}  calcView  - View about to be constructed
     * @param {ZoomerFrame} calcFrame - Frame about to be constructed
     * @param {ZoomerView}  dispView  - View to extract rulers
     * @param {ZoomerFrame} dispFrame - Frame to extract pixels
     */
    onBeginFrame: (zoomer, calcView, calcFrame, dispView, dispFrame) => {
        // set navigation direction
        
        /* zoomer.setPosition(centerX, centerY, radius, angle); */
    },

   /**
     * This is done for every pixel. optimize well!
     * Easy extendable for 3D.
     * Return the pixel value for the given floating point coordinate.
     * Zoomer will use it to fill integer pixel positions. 
     * The positions are ordered in decreasing significance.
     *
     * @param {Zoomer}      zoomer  - Running engine
     * @param {ZoomerFrame} frame   - Pixel/Palette/Rotate
     * @param {float}       x       - X coordinate
     * @param {float}       y       - Y coordinate
     * @return {int} - Pixel value           
     */
    onUpdatePixel: (zoomer, frame, x, y) => {
        // calculate pixel
        
        return 0; /* your code here */
    },

    /**
     * Start extracting (rotated) RGBA values from (paletted) pixels.
     * Extract rotated view from pixels and store them in specified imagedata.
     * Called just before submitting the frame to a web-worker.
     *
     * @param {Zoomer}      zoomer - Running engine
     * @param {ZoomerFrame} frame  - Frame about to render
     */
    onRenderFrame: (zoomer, frame) => {
        // update palette
        
        /* updatePalette(frame.palette); */
    },

    /**
     * Frame construction complete. Update statistics. Check resize.
     *
     * @param {Zoomer}      zoomer - Running engine
     * @param {ZoomerFrame} frame  - Frame before releasing to pool
     */
    onEndFrame: (zoomer, frame) => {
        // statistics
        
        /* console.log('fps', zoomer.avgFrameRate); */
    },

    /**
     * Inject frame into canvas.
     * This is a callback to keep all canvas resource handling/passing out of Zoomer context.
     *
     * @param {Zoomer}      zoomer - Running engine
     * @param {ZoomerFrame} frame  - Frame to inject
     */
    onPutImageData: (zoomer, frame) => {
        // get final buffer
        const imageData = new ImageData(new Uint8ClampedArray(frame.rgba.buffer), frame.viewWidth, frame.viewHeight);

        // draw frame onto canvas. `ctx` is namespace of caller.
        ctx.putImageData(imagedata, 0, 0);
    }

}

Function declaration

There are two styles of function declaration, traditional and arrow notation.
Both are identical in functionality and performance.
The difference is the binding of this.

With function() the bind is the web-worker event queue, with () => { } the bind is the DOM event queue.

  (a,b,c) => { }       - Strongly advised
  function(a,b,c) { }  - Expert mode

To aid in scope de-referencing all callbacks have as first parameter a reference to the engine internals.

    let domStatus = document.getElementById("idStatus");

    let zoomer = new Zoomer(width, height, enableAngle, {
        onEndFrame: (zoomer, frame) => {
            /*
             * `this` references the caller scope
             * `zoomer` references engine scope
             * `frame` references web-worker pixel data
             */
            domStatusLoad.innerText = "FPS:" + zoomer.frameRate;
        }
    });

[click on the image to watch the HD version with lower framerate]
ade-border-840x472.webp
[illustrates the incremental change between two frames]

History

jsFractalZoom was originally created in May 2011.

The original engine created GIF images using an ultra-fast GIF encoder which is available separately: https://github.com/xyzzy/jsGifEncoder.

Included are two legacy (and unmaintained) implementations:

Manifest

Source code

Grab one of the tarballs at https://github.com/RockingShip/jsFractalZoom/releases or checkout the latest code:

  git clone https://github.com/RockingShip/jsFractalZoom.git

Versioning

This project adheres to Semantic Versioning. For the versions available, see the tags on this repository.

License

This project is licensed under Affero GPLv3 - see the LICENSE file for details.

Acknowledgments