16
Mandelbulb Point Cloud
drag to orbit · move cursor up/down to scrub exponent p · click to reseed
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.