← Mike Higgins Home
Custom Code Experiment •

Mandelbulb Distortion

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mandelbulb Distortion</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body {
    width: 100%; height: 100%;
    background: #0a0a0f;
    overflow: hidden;
    font-family: system-ui, sans-serif;
  }

  #dropzone {
    position: fixed;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 14px;
    cursor: pointer;
    border: 1px dashed #333;
    margin: 32px;
    border-radius: 12px;
    transition: border-color 0.2s, background 0.2s;
    color: #555;
  }
  #dropzone:hover, #dropzone.drag-over {
    border-color: #7f77dd;
    background: rgba(127,119,221,0.05);
  }
  #dropzone p { font-size: 15px; }
  #dropzone span { font-size: 12px; color: #3a3a3a; letter-spacing: 0.05em; }
  #dropArrow { font-size: 40px; line-height: 1; color: #333; }
  #fileInput { display: none; }

  .pick-btn {
    background: transparent;
    border: 1px solid #333;
    color: #888;
    padding: 8px 22px;
    font-size: 12px;
    letter-spacing: 0.08em;
    cursor: pointer;
    border-radius: 4px;
    transition: border-color 0.2s, color 0.2s;
  }
  .pick-btn:hover { border-color: #7f77dd; color: #ccc; }

  #canvas-wrap {
    display: none;
    position: fixed;
    inset: 0;
  }
  canvas {
    width: 100%;
    height: 100%;
    display: block;
    object-fit: cover;
  }

  #controls {
    display: none;
    position: fixed;
    bottom: 28px;
    left: 50%;
    transform: translateX(-50%);
    background: rgba(10,10,15,0.72);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    border: 1px solid rgba(255,255,255,0.08);
    border-radius: 12px;
    padding: 14px 24px;
    gap: 28px;
    align-items: center;
    flex-wrap: wrap;
    justify-content: center;
    white-space: nowrap;
  }
  #controls.visible { display: flex; }

  .ctrl {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 5px;
  }
  .ctrl label {
    font-size: 10px;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: #555;
  }
  input[type=range] {
    width: 110px;
    accent-color: #7f77dd;
  }
  .val {
    font-size: 12px;
    color: #888;
    min-width: 30px;
    text-align: center;
  }
  .ctrl-btn {
    background: transparent;
    border: 1px solid rgba(255,255,255,0.12);
    color: #888;
    padding: 5px 16px;
    font-size: 11px;
    letter-spacing: 0.08em;
    cursor: pointer;
    border-radius: 4px;
    transition: border-color 0.2s, color 0.2s;
  }
  .ctrl-btn:hover { border-color: #7f77dd; color: #ccc; }
  .ctrl-btn.active { border-color: #7f77dd; color: #7f77dd; }

  .sep {
    width: 1px;
    height: 36px;
    background: rgba(255,255,255,0.08);
  }
</style>
</head>
<body>

<div id="dropzone" onclick="fileInput.click()">
  <div id="dropArrow">&#8679;</div>
  <p>Drop an image here, or click to choose</p>
  <span>JPG · PNG · GIF · WEBP</span>
  <button class="pick-btn" onclick="event.stopPropagation();fileInput.click()">Choose image file</button>
</div>
<input type="file" id="fileInput" accept="image/*">

<div id="canvas-wrap">
  <canvas id="c"></canvas>
</div>

<div id="controls">
  <div class="ctrl">
    <label>Power (n)</label>
    <input type="range" id="nSlider" min="0.5" max="8" step="0.01" value="2">
    <span class="val" id="nVal">2.00</span>
  </div>
  <div class="ctrl">
    <label>Strength</label>
    <input type="range" id="strSlider" min="0" max="1" step="0.01" value="0.5">
    <span class="val" id="strVal">0.50</span>
  </div>
  <div class="ctrl">
    <label>Speed</label>
    <input type="range" id="spdSlider" min="0" max="3" step="0.1" value="1">
    <span class="val" id="spdVal">1.0</span>
  </div>
  <div class="sep"></div>
  <div class="ctrl">
    <label>Animate</label>
    <button class="ctrl-btn active" id="playBtn">Pause</button>
  </div>
  <div class="ctrl">
    <label>Image</label>
    <button class="ctrl-btn" onclick="fileInput.click()">Change</button>
  </div>
</div>

<script>
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d");
const dropzone = document.getElementById("dropzone");
const canvasWrap = document.getElementById("canvas-wrap");
const controls = document.getElementById("controls");
const nSlider = document.getElementById("nSlider");
const strSlider = document.getElementById("strSlider");
const spdSlider = document.getElementById("spdSlider");
const nVal = document.getElementById("nVal");
const strVal = document.getElementById("strVal");
const spdVal = document.getElementById("spdVal");
const playBtn = document.getElementById("playBtn");
const fileInput = document.getElementById("fileInput");

let srcPixels = null;
let W = 0, H = 0;
let time = 0;
let animating = true;
let lastTimestamp = null;
let rafId = null;

function loadFromFile(file) {
  if (!file || !file.type.startsWith("image/")) return;
  const reader = new FileReader();
  reader.onload = e => {
    const image = new Image();
    image.onload = () => {
      W = window.innerWidth;
      H = window.innerHeight;
      canvas.width = W;
      canvas.height = H;

      const off = document.createElement("canvas");
      off.width = W; off.height = H;
      const offCtx = off.getContext("2d");

      const imgRatio = image.naturalWidth / image.naturalHeight;
      const scrRatio = W / H;
      let dw, dh, dx, dy;
      if (imgRatio > scrRatio) {
        dh = H; dw = dh * imgRatio;
        dx = (W - dw) / 2; dy = 0;
      } else {
        dw = W; dh = dw / imgRatio;
        dx = 0; dy = (H - dh) / 2;
      }
      offCtx.drawImage(image, dx, dy, dw, dh);
      srcPixels = offCtx.getImageData(0, 0, W, H);

      dropzone.style.display = "none";
      canvasWrap.style.display = "block";
      controls.classList.add("visible");

      if (rafId) cancelAnimationFrame(rafId);
      time = 0; lastTimestamp = null;
      rafId = requestAnimationFrame(loop);
    };
    image.src = e.target.result;
  };
  reader.readAsDataURL(file);
}

fileInput.addEventListener("change", () => loadFromFile(fileInput.files[0]));
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag-over"); });
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag-over"));
dropzone.addEventListener("drop", e => {
  e.preventDefault();
  dropzone.classList.remove("drag-over");
  loadFromFile(e.dataTransfer.files[0]);
});

function mandelbulbPower(x, y, z, n) {
  const r = Math.sqrt(x*x + y*y + z*z);
  if (r === 0) return [0, 0, 0];
  const theta = Math.atan2(Math.sqrt(x*x + y*y), z);
  const phi   = Math.atan2(y, x);
  const rn    = Math.pow(r, n);
  const sinTN = Math.sin(theta * n);
  return [
    rn * sinTN * Math.cos(phi * n),
    rn * sinTN * Math.sin(phi * n),
    rn * Math.cos(theta * n)
  ];
}

function render(t) {
  if (!srcPixels) return;
  const n        = parseFloat(nSlider.value);
  const strength = parseFloat(strSlider.value);
  const dest = ctx.createImageData(W, H);
  const src  = srcPixels.data;
  const dst  = dest.data;
  const cx = W * 0.5;
  const cy = H * 0.5;
  const maxR = Math.sqrt(cx*cx + cy*cy);

  for (let py = 0; py < H; py++) {
    for (let px = 0; px < W; px++) {
      const nx = (px - cx) / maxR;
      const ny = (py - cy) / maxR;
      const nz = Math.sin(t * 0.8 + nx * 1.5 + ny * 1.5) * 0.4;
      const [mx, my] = mandelbulbPower(nx, ny, nz, n);
      const dx = (mx - nx) * strength * maxR;
      const dy = (my - ny) * strength * maxR;
      const sx = Math.round(px + dx);
      const sy = Math.round(py + dy);
      const di = (py * W + px) * 4;
      if (sx >= 0 && sx < W && sy >= 0 && sy < H) {
        const si = (sy * W + sx) * 4;
        dst[di]   = src[si];
        dst[di+1] = src[si+1];
        dst[di+2] = src[si+2];
        dst[di+3] = src[si+3];
      } else {
        dst[di+3] = 255;
      }
    }
  }
  ctx.putImageData(dest, 0, 0);
}

function loop(ts) {
  if (lastTimestamp === null) lastTimestamp = ts;
  const dt = (ts - lastTimestamp) / 1000;
  lastTimestamp = ts;
  if (animating) time += dt * parseFloat(spdSlider.value);
  render(time);
  rafId = requestAnimationFrame(loop);
}

nSlider.addEventListener("input",   () => { nVal.textContent   = parseFloat(nSlider.value).toFixed(2); });
strSlider.addEventListener("input", () => { strVal.textContent = parseFloat(strSlider.value).toFixed(2); });
spdSlider.addEventListener("input", () => { spdVal.textContent = parseFloat(spdSlider.value).toFixed(1); });

playBtn.addEventListener("click", () => {
  animating = !animating;
  playBtn.textContent = animating ? "Pause" : "Play";
  playBtn.classList.toggle("active", animating);
  if (animating) lastTimestamp = null;
});

window.addEventListener("resize", () => {
  if (!srcPixels) return;
  fileInput.dispatchEvent(new Event("change"));
});
</script>
</body>
</html>