<!DOCTYPE html>
<html lang="en-us">
<head>
<meta charset="utf-8">
<meta name="robots" content="noindex">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Mandelbrot Keyboard Navigation</title>
<style>
html,
body,
canvas {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script id="vertexShader" type="x-shader/x-vertex">
#version 300 es
in vec4 vertexPosition;
void main() { // no-op vertex shader
gl_Position = vertexPosition;
}
</script>
<script id="fragmentShader" type="x-shader/x-fragment">
#version 300 es
precision highp float;
uniform vec2 canvasSize;
uniform vec3 offset;
out vec4 fragColor;
vec2 complexMultiply(vec2 a, vec2 b) {
return vec2(a.x*b.x - a.y*b.y, a.x*b.y + a.y*b.x);
}
const float MAX_ITERATIONS = 200.;
const float QUALITY = 2.; // anti-aliasing amount
vec3 draw(vec2 c) {
vec2 z = vec2(0, 0);
float i = 0.;
while (i < MAX_ITERATIONS) {
z = complexMultiply(z, z) + c;
if (length(z) > 2.) break;
i++;
}
if (i >= MAX_ITERATIONS) {
return vec3(0,0,0);
} else {
float shade = (i - log2(log(length(z)))) / MAX_ITERATIONS;
return vec3(shade, pow(shade,0.5), 1.-shade);
}
}
void main() {
vec3 color = vec3(0,0,0);
float samples = 0.;
float subpixel = 1./float(QUALITY);
for (float x=0.; x<1.; x+=subpixel) {
for (float y=0.; y<1.; y+=subpixel) {
vec2 fragCoord = gl_FragCoord.xy + vec2(x,y);
vec2 coord = (2.*fragCoord - canvasSize)/min(canvasSize.x, canvasSize.y);
vec2 c = coord * offset.z - offset.xy;
color += draw(c);
samples++;
}
}
fragColor = vec4(color/samples, 1);
}
</script>
<script type="text/javascript">
const canvas = document.querySelector('canvas');
const vertexShader = document.querySelector('script[type="x-shader/x-vertex"]');
const fragmentShader = document.querySelector('script[type="x-shader/x-fragment"]');
const gl = canvas.getContext("webgl2");
if (!gl) {
document.body.innerHTML ='<p>Error: WebGL2 is <a href="https://get.webgl.org/webgl2/">not supported by your browser</a></p>';
throw "WebGL2 not supported";
}
function createShader(shaderType, sourceCode) {
const shader = gl.createShader(shaderType);
gl.shaderSource(shader, sourceCode);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw gl.getShaderInfoLog(shader);
return shader;
}
const program = gl.createProgram();
gl.attachShader(program, createShader(gl.VERTEX_SHADER, vertexShader.textContent.trim()));
gl.attachShader(program, createShader(gl.FRAGMENT_SHADER, fragmentShader.textContent.trim()));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw gl.getProgramInfoLog(program);
gl.useProgram(program);
const vertices = [[-1, -1], [1, -1], [-1, 1], [1, 1]];
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices.flat()), gl.STATIC_DRAW);
const vertexPosition = gl.getAttribLocation(program, "vertexPosition");
gl.enableVertexAttribArray(vertexPosition);
gl.vertexAttribPointer(vertexPosition, 2, gl.FLOAT, false, 0, 0);
const offsetUniform = gl.getUniformLocation(program, 'offset');
const canvasSizeUniform = gl.getUniformLocation(program, 'canvasSize');
// load the offset from the URL
const urlHash = location.hash.substring(1) || null;
this.offset = urlHash?.split(',').slice(0, 3).map((v) => parseFloat(v) || 0) || [];
// default x,y offset is (0.5,0) and zoom=1x
this.offset[0] ??= 0.5;
this.offset[1] ??= 0;
this.offset[2] ??= 1;
function draw() {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
canvas.width = width;
canvas.height = height;
gl.viewport(0, 0, width, height);
gl.uniform2f(canvasSizeUniform, width, height);
gl.uniform3f(offsetUniform, ...this.offset);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, vertices.length);
}
draw();
const navigationKeys = ['ArrowLeft', 'ArrowRight', 'ArrowDown', 'ArrowUp', 'z', 'x'];
window.onkeydown = (event) => {
if (navigationKeys.includes(event.key)) {
event.preventDefault();
}
else return;
switch (event.key) {
case 'ArrowLeft': this.offset[0] += 0.03 * this.offset[2]; break;
case 'ArrowRight': this.offset[0] -= 0.03 * this.offset[2]; break;
case 'ArrowDown': this.offset[1] += 0.03 * this.offset[2]; break;
case 'ArrowUp': this.offset[1] -= 0.03 * this.offset[2]; break;
case 'z': this.offset[2] *= 0.95; break;
case 'x': this.offset[2] /= 0.95; break;
}
draw();
// and save the offset in the URL:
if (this.delay) clearTimeout(this.delay);
this.delay = setTimeout(() => {
history.replaceState(null, '', `#${this.offset.join(',')}`);
this.delay = null;
}, 200);
}
window.addEventListener('resize', () => draw());
</script>
</body>
<!--
© Adam Murray 2022
https://adammurray.blog/
Creative Commons License
Attribution-NonCommercial-ShareAlike 4.0 International
https://creativecommons.org/licenses/by-nc-sa/4.0/
-->