16
Mandelbulb Point Cloud
drag to orbit
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
141 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^8 + 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.
const BOUND = 1.5;
const POWER = 8;
const MAX_ITER = 12;
const BAILOUT = 2.0;
const SAMPLES_PER_FRAME = 320;
const MAX_POINTS = 50000;
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 userYaw = 0.6;
let userPitch = -0.2;
let isDragging = false;
let lastMouseX = 0, lastMouseY = 0;
let idleTime = 0;
// One step of the spherical-coordinate exponentiation: v -> v^p + c.
// Returns [x, y, z, r].
function bulbStep(x, y, z, cx, cy, cz) {
const r = Math.sqrt(x * x + y * y + z * z);
if (r === 0) {
return [cx, cy, cz, 0];
}
// theta = acos(z/r), phi = atan2(y, x).
const theta = Math.acos(z / r) * POWER;
const phi = Math.atan2(y, x) * POWER;
const rp = Math.pow(r, POWER);
const sinT = Math.sin(theta);
const nx = rp * sinT * Math.cos(phi) + cx;
const ny = rp * sinT * Math.sin(phi) + cy;
const nz = rp * Math.cos(theta) + cz;
return [nx, ny, nz, r];
}
// Returns { inside: bool, lastR: number } ā sample one candidate c.
function testPoint(cx, cy, cz) {
let x = cx, y = cy, z = cz;
let lastR = 0;
for (let i = 0; i < MAX_ITER; i++) {
const [nx, ny, nz, r] = bulbStep(x, y, z, cx, cy, cz);
x = nx; y = ny; z = nz;
lastR = Math.sqrt(x * x + y * y + z * z);
if (lastR > BAILOUT) {
return { inside: false, lastR };
}
}
return { inside: true, lastR };
}
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)));
return [f(0), f(8), f(4)];
}
function init({ scene, camera, renderer, width, height }) {
renderer.setClearColor(0x000000, 1);
camera.position.set(0, 0, 4.2);
camera.lookAt(0, 0, 0);
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);
return { scene, camera };
}
function tick({ dt, scene, camera, renderer, width, height, input }) {
// Drain click queue; we don't act on individual clicks here.
void input.consumeClicks();
if (input.mouseDown) {
idleTime = 0;
if (!isDragging) {
isDragging = true;
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;
}
} else {
isDragging = false;
idleTime += dt;
// Auto-rotate after a brief idle.
if (idleTime > 0.3) {
userYaw += dt * 0.22;
}
}
const r = 4.2;
const cy = Math.cos(userYaw), sy = Math.sin(userYaw);
const cp = Math.cos(userPitch), sp = Math.sin(userPitch);
camera.position.set(r * cp * sy, r * sp, r * cp * cy);
camera.lookAt(0, 0, 0);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
// Sample new candidates this frame.
let writeStart = head;
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);
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 [rr, gg, bb] = hslToRgb(hue, 0.85, 0.55);
colors[idx] = rr;
colors[idx + 1] = gg;
colors[idx + 2] = bb;
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);
}
void writeStart;
renderer.render(scene, camera);
}
Comments (0)
Log in to comment.