16

Mandelbulb Point Cloud

drag to orbit · move cursor up/down to scrub exponent p · click to reseed

The Mandelbulb is the 3D analog of the Mandelbrot set, popularized by Daniel White and Paul Nylander in 2009. It iterates where the power is taken in spherical coordinates: given with , , , define

The canonical degree is . A point belongs to the set if iterating from keeps bounded — we use bailout and 12 iterations. This sim Monte-Carlo samples a few hundred candidates per frame, accumulating up to 50 000 interior points into an additive-blended point cloud. Color encodes the final radius at the last iteration. Drag to orbit; the view auto-rotates when idle.

idle
246 lines · three
view source
// Three.js Mandelbulb — Monte Carlo point cloud sampler.
//
// Each frame we rejection-sample random points c in [-1.5, 1.5]^3 and
// iterate v_{n+1} = v_n^p + c (with spherical exponentiation). Points
// that never escape |v| > 2 within 12 iterations get added to a growing
// ring-buffer of THREE.Points. Color = normalized radius at last iter.
//
// Interaction:
//   - drag         : orbit camera
//   - mouseY       : scrubs exponent p in [3, 10] (bulb morphs live)
//   - click        : clear-and-reseed the point cloud
//   - idle         : auto-rotate

const BOUND = 1.5;
const MAX_ITER = 12;
const BAILOUT = 2.0;
const SAMPLES_PER_FRAME = 320;
const MAX_POINTS = 50000;

const P_MIN = 3;
const P_MAX = 10;
const P_EPSILON = 0.02;  // hysteresis for "p changed" detection

let positions;       // Float32Array, ring buffer
let colors;          // Float32Array, ring buffer
let pointsGeom, pointsMat, pointsObj;
let head = 0;        // next write index
let filled = 0;      // how many slots written so far (caps at MAX_POINTS)

let currentP = 8;    // canonical Mandelbulb exponent
let lastSampledP = 8; // p at last clear; used to detect change

let userYaw = 0.6;
let userPitch = -0.2;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;  // for tracking drag distance
let dragMoved = false;
let lastMouseX = 0, lastMouseY = 0;
let idleTime = 0;
let viewHeight = 1;   // cached, used to map mouseY -> p
// mouseY defaults to 0 before the user interacts, which would otherwise
// snap p to P_MAX (=10) on load and hide the canonical p=8 mandelbulb.
// Hold off scrubbing until we see a real cursor move or touch.
let userInteracted = false;
let prevMouseX = 0, prevMouseY = 0;

// HUD: a Sprite with a CanvasTexture we redraw when p changes.
let hudCanvas, hudCtx, hudTex, hudSprite;
let lastHudP = -1;

// Hot-loop scratch — avoid per-iteration array allocation inside
// testPoint() which runs MAX_ITER * SAMPLES_PER_FRAME times per frame.
const _stepOut = { x: 0, y: 0, z: 0 };
// One step of the spherical-coordinate exponentiation: v -> v^p + c.
// Writes the next position into _stepOut (reused).
function bulbStep(x, y, z, cx, cy, cz, p) {
  const r = Math.sqrt(x * x + y * y + z * z);
  if (r === 0) {
    _stepOut.x = cx; _stepOut.y = cy; _stepOut.z = cz;
    return;
  }
  // theta = acos(z/r), phi = atan2(y, x).
  const theta = Math.acos(z / r) * p;
  const phi = Math.atan2(y, x) * p;
  const rp = Math.pow(r, p);
  const sinT = Math.sin(theta);
  _stepOut.x = rp * sinT * Math.cos(phi) + cx;
  _stepOut.y = rp * sinT * Math.sin(phi) + cy;
  _stepOut.z = rp * Math.cos(theta) + cz;
}

const _testRes = { inside: false, lastR: 0 };
// Returns the shared _testRes object — sample one candidate c. Caller
// must read inside/lastR before the next call (no aliasing).
function testPoint(cx, cy, cz, p) {
  let x = cx, y = cy, z = cz;
  let lastR = 0;
  for (let i = 0; i < MAX_ITER; i++) {
    bulbStep(x, y, z, cx, cy, cz, p);
    x = _stepOut.x; y = _stepOut.y; z = _stepOut.z;
    lastR = Math.sqrt(x * x + y * y + z * z);
    if (lastR > BAILOUT) {
      _testRes.inside = false; _testRes.lastR = lastR;
      return _testRes;
    }
  }
  _testRes.inside = true; _testRes.lastR = lastR;
  return _testRes;
}

// Writes the RGB triple into _rgbOut (reused — avoids per-sample [].alloc).
const _rgbOut = { r: 0, g: 0, b: 0 };
function hslToRgb(h, s, l) {
  const a = s * Math.min(l, 1 - l);
  const f = (n, k = (n + h * 12) % 12) =>
    l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
  _rgbOut.r = f(0); _rgbOut.g = f(8); _rgbOut.b = f(4);
  return _rgbOut;
}

function clearCloud() {
  head = 0;
  filled = 0;
  pointsGeom.setDrawRange(0, 0);
}

function drawHud(p) {
  const w = hudCanvas.width;
  const h = hudCanvas.height;
  hudCtx.clearRect(0, 0, w, h);
  // Soft dark background pill for legibility on bright bulb regions.
  hudCtx.fillStyle = "rgba(0, 0, 0, 0.55)";
  // Rounded rect.
  const pad = 8;
  const rx = pad, ry = pad, rw = w - pad * 2, rh = h - pad * 2;
  const radius = 12;
  hudCtx.beginPath();
  hudCtx.moveTo(rx + radius, ry);
  hudCtx.lineTo(rx + rw - radius, ry);
  hudCtx.quadraticCurveTo(rx + rw, ry, rx + rw, ry + radius);
  hudCtx.lineTo(rx + rw, ry + rh - radius);
  hudCtx.quadraticCurveTo(rx + rw, ry + rh, rx + rw - radius, ry + rh);
  hudCtx.lineTo(rx + radius, ry + rh);
  hudCtx.quadraticCurveTo(rx, ry + rh, rx, ry + rh - radius);
  hudCtx.lineTo(rx, ry + radius);
  hudCtx.quadraticCurveTo(rx, ry, rx + radius, ry);
  hudCtx.closePath();
  hudCtx.fill();

  hudCtx.fillStyle = "#e8eaf6";
  hudCtx.font = "bold 44px ui-monospace, SFMono-Regular, Menlo, monospace";
  hudCtx.textAlign = "center";
  hudCtx.textBaseline = "middle";
  hudCtx.fillText("p = " + p.toFixed(2), w / 2, h / 2);
  hudTex.needsUpdate = true;
}

function init({ scene, camera, renderer, width, height }) {
  renderer.setClearColor(0x000000, 1);
  camera.position.set(0, 0, 4.2);
  camera.lookAt(0, 0, 0);
  viewHeight = height;

  positions = new Float32Array(MAX_POINTS * 3);
  colors = new Float32Array(MAX_POINTS * 3);

  pointsGeom = new THREE.BufferGeometry();
  pointsGeom.setAttribute(
    "position",
    new THREE.BufferAttribute(positions, 3).setUsage(THREE.DynamicDrawUsage),
  );
  pointsGeom.setAttribute(
    "color",
    new THREE.BufferAttribute(colors, 3).setUsage(THREE.DynamicDrawUsage),
  );
  pointsGeom.setDrawRange(0, 0);

  pointsMat = new THREE.PointsMaterial({
    size: 0.015,
    vertexColors: true,
    transparent: true,
    opacity: 0.7,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
    sizeAttenuation: true,
  });

  pointsObj = new THREE.Points(pointsGeom, pointsMat);
  scene.add(pointsObj);

  // HUD overlay: a billboard sprite, parented to the camera so it
  // stays put in screen space regardless of orbit.
  hudCanvas = document.createElement("canvas");
  hudCanvas.width = 320;
  hudCanvas.height = 96;
  hudCtx = hudCanvas.getContext("2d");
  hudTex = new THREE.CanvasTexture(hudCanvas);
  hudTex.minFilter = THREE.LinearFilter;
  hudTex.magFilter = THREE.LinearFilter;
  const hudMat = new THREE.SpriteMaterial({
    map: hudTex,
    transparent: true,
    depthWrite: false,
    depthTest: false,
  });
  hudSprite = new THREE.Sprite(hudMat);
  // Position in camera-local space: top-left-ish, in front of camera.
  // At z = -2 with fov 50, the visible half-height is ~tan(25°)*2 ≈ 0.93.
  // We pin to upper-left and scale the sprite to ~0.6 world units wide.
  hudSprite.scale.set(0.6, 0.18, 1);
  hudSprite.position.set(-1.05, 0.78, -2);
  hudSprite.renderOrder = 999;
  camera.add(hudSprite);
  // Camera isn't in the scene graph by default in some setups; ensure it is
  // so its children render. scene.add is idempotent for already-parented.
  scene.add(camera);

  drawHud(currentP);
  lastHudP = currentP;

  return { scene, camera };
}

function tick({ dt, scene, camera, renderer, width, height, input }) {
  viewHeight = height;

  // --- Click → clear-and-reseed -----------------------------------------
  // Only count a click that wasn't part of a drag. consumeClicks() returns
  // an array of click events — check length, not truthiness as a number.
  const clicks = input.consumeClicks();
  if (clicks.length > 0 && !dragMoved) {
    clearCloud();
  }

  // --- Mouse → drag (orbit) AND p (mouseY scrub) ------------------------
  // mouseY scrubbing is always live — even while dragging — so the user
  // can simultaneously change shape and viewpoint. mouseX is reserved for
  // drag-orbit.
  if (input.mouseDown) {
    idleTime = 0;
    if (!isDragging) {
      isDragging = true;
      dragMoved = false;
      dragStartX = input.mouseX;
      dragStartY = input.mouseY;
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    } else {
      const dx = input.mouseX - lastMouseX;
      const dy = input.mouseY - lastMouseY;
      userYaw -= dx * 0.006;
      userPitch -= dy * 0.006;
      userPitch = Math.max(-1.3, Math.min(1.3, userPitch));
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
      // If pointer moved more than a few px from press, treat as drag,
      // not a click.
      const totDx = input.mouseX - dragStartX;
      const totDy = input.mouseY - dragStartY;
      if (totDx * totDx + totDy * totDy > 16) dragMoved = true;
    }
  } else {
    isDragging = false;
    dragMoved = false;
    idleTime += dt;
    // Auto-rotate after a brief idle.
    if (idleTime > 0.3) {
      userYaw += dt * 0.22;
    }
  }

  // Detect first real user interaction so we don't snap p away from the
  // canonical 8 just because mouseY defaults to 0 on load.
  if (!userInteracted) {
    if (input.mouseDown
        || input.mouseX !== prevMouseX
        || input.mouseY !== prevMouseY) {
      // First non-zero mouse event: arm scrubbing. The first observed
      // mouse position is the user's resting cursor, not the load default.
      if (prevMouseX !== 0 || prevMouseY !== 0 || input.mouseDown) {
        userInteracted = true;
      }
    }
    prevMouseX = input.mouseX;
    prevMouseY = input.mouseY;
  }

  // Map mouseY ∈ [0, height] → p ∈ [P_MAX, P_MIN] (top = high power).
  // Clamp; tolerate uninitialized mouse position by leaving p untouched.
  if (userInteracted && typeof input.mouseY === "number" && viewHeight > 0) {
    const t = Math.max(0, Math.min(1, input.mouseY / viewHeight));
    const targetP = P_MAX - t * (P_MAX - P_MIN);
    // Smoothly approach target so micro-jitter doesn't constantly clear.
    currentP += (targetP - currentP) * Math.min(1, dt * 6);
  }

  // If p has drifted far enough from the last sampled p, clear and resample.
  if (Math.abs(currentP - lastSampledP) > P_EPSILON) {
    clearCloud();
    lastSampledP = currentP;
  }

  // --- Camera placement -------------------------------------------------
  const r = 4.2;
  const cyaw = Math.cos(userYaw), syaw = Math.sin(userYaw);
  const cp = Math.cos(userPitch), sp = Math.sin(userPitch);
  camera.position.set(r * cp * syaw, r * sp, r * cp * cyaw);
  camera.lookAt(0, 0, 0);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setSize(width, height, false);

  // --- Sample new candidates this frame, using currentP -----------------
  const p = currentP;
  let wroteAny = false;
  for (let s = 0; s < SAMPLES_PER_FRAME; s++) {
    const cx = (Math.random() * 2 - 1) * BOUND;
    const cyc = (Math.random() * 2 - 1) * BOUND;
    const cz = (Math.random() * 2 - 1) * BOUND;
    // Cheap rejection: outside the obvious bounding sphere of the bulb
    // can't be inside, so skip.
    if (cx * cx + cyc * cyc + cz * cz > 1.6 * 1.6) continue;
    const res = testPoint(cx, cyc, cz, p);
    if (!res.inside) continue;

    const idx = head * 3;
    positions[idx]     = cx;
    positions[idx + 1] = cyc;
    positions[idx + 2] = cz;

    // Color: hue from normalized last radius, with a little angular tint.
    const nr = Math.min(1, res.lastR / BAILOUT);
    const hue = (0.62 - 0.55 * nr + 1) % 1; // blue->magenta->red sweep
    const rgb = hslToRgb(hue, 0.85, 0.55);
    colors[idx]     = rgb.r;
    colors[idx + 1] = rgb.g;
    colors[idx + 2] = rgb.b;

    head = (head + 1) % MAX_POINTS;
    if (filled < MAX_POINTS) filled++;
    wroteAny = true;
  }

  if (wroteAny) {
    // Cheapest correct path: just mark the whole buffer dirty. The ring
    // buffer may wrap, so a single contiguous updateRange isn't always
    // valid; flag everything.
    pointsGeom.attributes.position.needsUpdate = true;
    pointsGeom.attributes.color.needsUpdate = true;
    pointsGeom.setDrawRange(0, filled);
  }

  // --- HUD redraw on p change ------------------------------------------
  if (Math.abs(currentP - lastHudP) > 0.01) {
    drawHud(currentP);
    lastHudP = currentP;
  }

  renderer.render(scene, camera);
}

Comments (0)

Log in to comment.