Mandelbulb Meadows
Ray-marched Mandelbulb fractal field with interactive first-person controls
Created: March 3, 2024
View Source Code
import * as THREE from "three";
import { PointerLockControls } from "three/examples/jsm/controls/PointerLockControls";
const keys: Record<string, boolean> = {
KeyW: false,
KeyA: false,
KeyS: false,
KeyD: false,
KeyQ: false,
KeyE: false,
ShiftRight: false,
ShiftLeft: false,
ArrowUp: false,
ArrowDown: false,
ArrowLeft: false,
ArrowRight: false
};
const vertexShader = `
void main() {
gl_Position = vec4(position, 1.0);
}
`;
const fragmentShader = `
uniform float iTime;
uniform vec2 iResolution;
uniform vec3 iCamPos;
uniform mat4 iCamInvProj;
uniform mat4 iCamInvView;
#define MAX_STEPS 64
#define MAX_DIST 100000.0
#define EPSILON 0.005
#define CELL_SIZE 5.0
#define FLOOR_CELL_SIZE 20.0
#define STEM_HEIGHT 3.0
#define STEM_RADIUS 0.15
#define FLOOR_HEIGHT -2.0
#define NEON_INTENSITY 2.5
#define NEON_SATURATION 1.8
#define TIME_LOOP 10.0
#define STAR_DENSITY 0.0008
#define STAR_BRIGHTNESS 0.9
#define STAR_TWINKLE_SPEED 0.8
#define STAR_TWINKLE_AMOUNT 0.7
vec2 modPosition2D(vec2 pos, float cellSize) {
return mod(pos, cellSize) - cellSize * 0.5;
}
vec2 floorPosition2D(vec2 pos, float cellSize) {
return floor(pos / cellSize) * cellSize - cellSize * 0.5;
}
float floorDE(vec3 pos) {
vec2 modPos = modPosition2D(pos.xz, FLOOR_CELL_SIZE);
vec2 z = modPos * 0.3;
vec2 c = vec2(-0.8 + 0.2 * sin(iTime * 0.05), 0.156);
float dr = 1.0;
float r = 0.0;
for (int i = 0; i < 6; i++) {
r = length(z);
if (r > 2.0) break;
float theta = atan(z.y, z.x) * 2.0;
float r2 = r * r;
dr = 2.0 * r * dr + 1.0;
z = vec2(r2 * cos(theta), r2 * sin(theta)) + c;
}
float dist = 0.5 * log(r) * r / dr;
return max(dist * 0.1, pos.y - FLOOR_HEIGHT - 0.2 * sin(dist * 3.0));
}
float stemDE(vec3 pos, vec2 cellCenter) {
vec3 stemBase = vec3(cellCenter.x, FLOOR_HEIGHT, cellCenter.y);
vec3 stemTop = stemBase + vec3(0.0, STEM_HEIGHT, 0.0);
float wobble = 0.1 * sin(iTime * 0.5 + pos.y * 0.5);
vec3 wobbleDir = vec3(sin(pos.y * 0.2 + iTime * 0.2), 0.0, cos(pos.y * 0.2 + iTime * 0.2));
stemTop += wobble * wobbleDir;
vec3 pa = pos - stemBase;
vec3 ba = stemTop - stemBase;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
float distToStem = length(pa - ba * h) - STEM_RADIUS * (1.0 + 0.3 * sin(pos.y * 2.0 + iTime * 0.4));
return distToStem;
}
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 78.233);
return fract(p.x * p.y);
}
float hash31(vec3 p) {
p = fract(p * vec3(443.897, 441.423, 437.195));
p += dot(p, p.zxy + 19.19);
return fract(p.x * p.y * p.z);
}
float hashNoise22(vec2 p) {
vec2 k = vec2(127.1, 311.7);
return fract(sin(dot(p, k)) * 43758.5453);
}
float mandelbulbDE(vec3 pos, vec2 cellId) {
vec3 z = pos;
float dr = 1.0;
float r = 0.0;
int iterations = 5;
float loopedTime = sin(iTime * 0.3 + hash21(cellId) * 10.0);
float power = 8.0 + hash21(cellId) * 16.0 * sin(loopedTime);
for (int i = 0; i < iterations; i++) {
r = length(z);
if (r > 2.0) break;
float theta = acos(z.z / r);
float phi = atan(z.y, z.x);
dr = pow(r, power - 1.0) * power * dr + 1.0;
float zr = pow(r, power);
theta *= power;
phi *= power;
z = zr * vec3(sin(theta) * cos(phi),
sin(theta) * sin(phi),
cos(theta));
z += pos;
}
return 0.5 * log(r) * r / dr;
}
float sceneDE(vec3 pos) {
vec2 cell2D = floor(pos.xz / CELL_SIZE) * CELL_SIZE + CELL_SIZE * 0.5;
vec2 cellLocal = modPosition2D(pos.xz, CELL_SIZE);
vec2 cellId = floorPosition2D(pos.xz, CELL_SIZE);
float mandelbulbDist = 999.0;
if (pos.y > FLOOR_HEIGHT + STEM_HEIGHT - 1.0) {
vec3 localPos = vec3(
cellLocal.x,
pos.y - (FLOOR_HEIGHT + STEM_HEIGHT),
cellLocal.y
);
mandelbulbDist = mandelbulbDE(localPos, cellId.xy) * 0.5;
}
float stemDist = stemDE(pos, cell2D);
float floorDist = floorDE(pos);
return min(min(mandelbulbDist, stemDist), floorDist);
}
float rayMarch(vec3 ro, vec3 rd) {
float t = 0.0;
for (int i = 0; i < MAX_STEPS; i++) {
vec3 pos = ro + rd * t;
float dist = sceneDE(pos);
if (dist < EPSILON) return t;
t += dist;
if (t > MAX_DIST) break;
}
return -1.0;
}
vec3 getNormal(vec3 pos) {
float eps = EPSILON;
float d = sceneDE(pos);
vec3 n;
n.x = sceneDE(pos + vec3(eps, 0.0, 0.0)) - d;
n.y = sceneDE(pos + vec3(0.0, eps, 0.0)) - d;
n.z = sceneDE(pos + vec3(0.0, 0.0, eps)) - d;
return normalize(n);
}
int getMaterial(vec3 pos) {
float eps = 0.1;
vec2 cell2D = floor(pos.xz / CELL_SIZE) * CELL_SIZE + CELL_SIZE * 0.5;
vec2 cellLocal = modPosition2D(pos.xz, CELL_SIZE);
vec2 cellId = floorPosition2D(pos.xz, CELL_SIZE);
vec3 localPos = vec3(
cellLocal.x,
pos.y - (FLOOR_HEIGHT + STEM_HEIGHT),
cellLocal.y
);
float mandelbulbDist = 999.0;
if (pos.y > FLOOR_HEIGHT + STEM_HEIGHT - 1.0) {
mandelbulbDist = mandelbulbDE(localPos, cellId) * 0.5;
}
float stemDist = stemDE(pos, cell2D);
float floorDist = floorDE(pos);
if (abs(floorDist - sceneDE(pos)) < eps) return 1;
if (abs(stemDist - sceneDE(pos)) < eps) return 2;
return 3;
}
vec3 cellID(vec3 pos) {
return vec3(floor(pos.x / CELL_SIZE), 0.0, floor(pos.z / CELL_SIZE));
}
vec3 enhanceNeon(vec3 color, float strength) {
vec3 enhanced = pow(color, vec3(1.0 / NEON_SATURATION));
enhanced = enhanced * strength;
float luminance = dot(enhanced, vec3(0.299, 0.587, 0.114));
enhanced = mix(enhanced, enhanced * enhanced, 0.5);
return enhanced;
}
vec3 generateStars(vec3 rayDir) {
if (rayDir.y < 0.025) return vec3(0.0);
vec2 uv = vec2(atan(rayDir.z, rayDir.x), asin(rayDir.y));
vec3 starPos = vec3(uv * 100.0, iTime + 10000.0);
vec3 stars = vec3(0.0);
for (int i = 0; i < 3; i++) {
float layer = float(i) * 100.0;
vec3 p = starPos + layer;
float h = hash31(floor(p * 0.8));
if (h < STAR_DENSITY * (float(i) + 1.0)) {
vec3 cellUV = fract(p * 0.8) - 0.5;
float star = 1.0 - smoothstep(0.0, 0.5, length(cellUV));
float twinkle = sin(iTime * STAR_TWINKLE_SPEED * (h * 5.0 + 0.5)) * 0.5 + 0.5;
twinkle = mix(1.0, twinkle, STAR_TWINKLE_AMOUNT);
vec3 starColor = mix(vec3(1.0), vec3(0.8, 0.9, 1.0) * h, 0.3);
stars += star * twinkle * starColor * STAR_BRIGHTNESS * (1.0 - float(i) * 0.2);
}
}
float brightStarChance = hash31(floor(starPos * 0.3));
if (brightStarChance < 0.002) {
vec3 cellUV = fract(starPos * 0.3) - 0.5;
float brightStar = 1.0 - smoothstep(0.0, 0.4, length(cellUV));
stars += brightStar * 2.0 * vec3(1.0, 0.95, 0.8);
}
return stars;
}
void main() {
vec2 ndc = (gl_FragCoord.xy / iResolution) * 2.0 - 1.0;
vec4 rayClip = vec4(ndc, -1.0, 1.0);
vec4 rayEye = iCamInvProj * rayClip;
rayEye.z = -1.0;
rayEye.w = 0.0;
vec3 rd = normalize((iCamInvView * rayEye).xyz);
vec3 ro = iCamPos;
float loopedTime = iTime;
float t = rayMarch(ro, rd);
vec3 color = vec3(0.01, 0.01, 0.02);
color += generateStars(rd);
if (t > 0.0) {
vec3 pos = ro + rd * t;
vec3 normal = getNormal(pos);
int material = getMaterial(pos);
vec3 id = cellID(pos);
vec3 lightDir = normalize(vec3(0.5, 0.8, -0.2));
float ambient = 0.2;
float diff = max(dot(normal, lightDir), 0.0);
if (material == 1) {
color = vec3(0.0, 0.0, 0.0);
}
else if (material == 2) {
vec3 stemColor = vec3(0.1, 0.4, 0.1) +
0.1 * sin(pos.y * 3.0 + loopedTime * 0.5 + vec3(0.0, 0.5, 1.0));
color = (ambient + (1.0-ambient) * diff) * stemColor;
}
else {
vec3 mColor = 0.5 + 0.5 * cos(loopedTime * 0.4 + id.x + id.z +
pos.xyx * vec3(0.15, 0.2, 0.25) +
vec3(0.0, 2.0, 4.0));
mColor = enhanceNeon(mColor, NEON_INTENSITY);
float neonAmbient = 0.1;
float neonLighting = neonAmbient + (1.0-neonAmbient) * diff;
color = mColor * neonLighting;
float pulse = 0.5 + 0.5 * sin(loopedTime * 3.0 + id.x * 0.5 + id.z * 0.5);
color += 0.5 * mColor * pulse;
float edgeGlow = 1.0 - max(0.0, dot(normal, -rd));
edgeGlow = pow(edgeGlow, 1.0);
color += 0.7 * mColor * edgeGlow;
float rim = 1.0 - max(0.0, dot(normal, -rd));
rim = pow(rim, 1.0);
color += 0.5 * mColor * rim;
}
}
color = color / (1.0 + color);
color = pow(color, vec3(1.0 / 2.2));
gl_FragColor = vec4(color, 1.0);
}
`;
class MandelbulbMeadows {
private scene: THREE.Scene;
private dummyCamera: THREE.PerspectiveCamera;
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;
private controls: PointerLockControls;
private material: THREE.ShaderMaterial;
private quad: THREE.Mesh;
private blocker: HTMLDivElement | null = null;
private velocity: THREE.Vector3;
private direction: THREE.Vector3;
private animationFrameId: number | null = null;
private lastTime: number = 0;
private isInitialized: boolean = false;
private handleResizeFunction: (() => void) | null = null;
private showClickMessage: (() => void) | null = null;
private container: HTMLElement | null = null;
constructor() {
this.scene = new THREE.Scene();
this.dummyCamera = new THREE.PerspectiveCamera();
this.camera = new THREE.PerspectiveCamera();
this.renderer = new THREE.WebGLRenderer();
this.controls = new PointerLockControls(this.camera, this.renderer.domElement);
this.material = new THREE.ShaderMaterial();
this.quad = new THREE.Mesh();
this.velocity = new THREE.Vector3();
this.direction = new THREE.Vector3();
}
private handleMovement(delta: number): void {
if (this.controls && this.controls.isLocked) {
const moveSpeed = (keys.ShiftLeft || keys.ShiftRight) ? 75 * delta : 35 * delta;
const cameraDirection = new THREE.Vector3();
this.camera.getWorldDirection(cameraDirection);
this.direction.set(0, 0, 0);
const movementDirection = cameraDirection.clone();
if (cameraDirection.y < 0) {
movementDirection.y *= 0.5;
movementDirection.normalize();
}
if (keys.KeyW || keys.ArrowUp) {
this.direction.add(movementDirection);
}
if (keys.KeyS || keys.ArrowDown) {
this.direction.sub(movementDirection);
}
if (keys.KeyA || keys.ArrowLeft || keys.KeyD || keys.ArrowRight) {
const right = new THREE.Vector3();
right.crossVectors(this.camera.up, cameraDirection).normalize();
if (keys.KeyA || keys.ArrowLeft) this.direction.add(right);
if (keys.KeyD || keys.ArrowRight) this.direction.sub(right);
}
if (keys.KeyQ) this.direction.y -= 1;
if (keys.KeyE) this.direction.y += 1;
if (this.direction.lengthSq() > 0) {
this.direction.normalize();
this.direction.multiplyScalar(moveSpeed);
this.velocity.lerpVectors(this.velocity, this.direction, 0.1);
} else {
this.velocity.multiplyScalar(0.95);
}
this.camera.position.add(this.velocity);
this.camera.position.y = Math.max(this.camera.position.y, 0.1);
}
}
private animate = (time: number): void => {
if (!this.isInitialized || !this.renderer) return;
const timeInSeconds = time * 0.001;
const delta = Math.min(0.1, timeInSeconds - this.lastTime);
this.lastTime = timeInSeconds;
this.material.uniforms.iTime.value = timeInSeconds * 0.2;
const renderSize = this.renderer.getSize(new THREE.Vector2());
this.material.uniforms.iResolution.value.copy(renderSize);
this.handleMovement(delta);
this.material.uniforms.iCamPos.value.copy(this.camera.position);
this.material.uniforms.iCamInvProj.value.copy(this.camera.projectionMatrixInverse);
this.material.uniforms.iCamInvView.value.copy(this.camera.matrixWorld);
this.renderer.render(this.scene, this.dummyCamera);
this.animationFrameId = requestAnimationFrame(this.animate);
};
public initialize(container: HTMLElement | null): void {
if (!container || this.isInitialized) return;
this.container = container;
this.scene = new THREE.Scene();
const aspectRatio = container.clientWidth / container.clientHeight;
this.dummyCamera = new THREE.PerspectiveCamera(
45,
aspectRatio,
0.1,
1000
);
this.dummyCamera.position.set(0, 6, 10);
this.dummyCamera.lookAt(0, 0, 0);
this.camera = new THREE.PerspectiveCamera(
75,
aspectRatio,
0.1,
1000
);
this.camera.position.set(0, 6, 10);
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({
antialias: true,
});
this.renderer.setSize(container.clientWidth, container.clientHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(this.renderer.domElement);
this.material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
iTime: { value: 0.0 },
iResolution: {
value: new THREE.Vector2(container.clientWidth, container.clientHeight),
},
iCamPos: { value: new THREE.Vector3() },
iCamInvProj: { value: new THREE.Matrix4() },
iCamInvView: { value: new THREE.Matrix4() },
},
});
const geometry = new THREE.PlaneGeometry(2, 2);
this.quad = new THREE.Mesh(geometry, this.material);
this.scene.add(this.quad);
this.controls = new PointerLockControls(this.camera, this.renderer.domElement);
const infoOverlay = document.createElement('div');
infoOverlay.style.position = 'absolute';
infoOverlay.style.bottom = '10px';
infoOverlay.style.right = '10px';
infoOverlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
infoOverlay.style.padding = '8px';
infoOverlay.style.borderRadius = '5px';
infoOverlay.style.color = '#ffffff';
infoOverlay.style.fontSize = '12px';
infoOverlay.style.maxWidth = '200px';
infoOverlay.style.pointerEvents = 'none';
infoOverlay.style.zIndex = '5';
infoOverlay.innerHTML = 'Click to control | WASD: Move | Q/E: Up/Down | Mouse: Look';
document.body.appendChild(infoOverlay);
this.renderer.domElement.addEventListener('click', () => {
this.controls.lock();
});
this.showClickMessage = () => {
const ctx = (this.renderer.domElement as HTMLCanvasElement).getContext('2d');
if (ctx) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, this.renderer.domElement.width, this.renderer.domElement.height);
ctx.font = 'bold 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'white';
const text = 'Click to control';
const textWidth = ctx.measureText(text).width;
const padding = 20;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(
this.renderer.domElement.width / 2 - textWidth / 2 - padding,
this.renderer.domElement.height / 2 - 20,
textWidth + padding * 2,
40
);
ctx.fillStyle = 'white';
ctx.fillText(text, this.renderer.domElement.width / 2, this.renderer.domElement.height / 2);
}
};
this.controls.addEventListener('unlock', () => {
if (this.showClickMessage) this.showClickMessage();
});
this.blocker = infoOverlay;
this.velocity = new THREE.Vector3();
this.direction = new THREE.Vector3();
window.addEventListener("keydown", (e) => {
if (e.code in keys) {
keys[e.code] = true;
}
});
window.addEventListener("keyup", (e) => {
if (e.code in keys) {
keys[e.code] = false;
}
});
this.handleResizeFunction = () => {
if (!this.container || !this.renderer) return;
const width = this.container.clientWidth;
const height = this.container.clientHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.dummyCamera.aspect = width / height;
this.dummyCamera.updateProjectionMatrix();
this.renderer.setSize(width, height);
this.material.uniforms.iResolution.value.set(width, height);
if (this.blocker) {
this.blocker.style.bottom = '10px';
this.blocker.style.right = '10px';
}
if (this.controls && !this.controls.isLocked && this.showClickMessage) {
setTimeout(() => this.showClickMessage!(), 50);
}
};
window.addEventListener('resize', this.handleResizeFunction);
this.lastTime = 0;
this.isInitialized = true;
if (this.showClickMessage) this.showClickMessage();
this.animate(0);
}
public teardown(): void {
if (!this.isInitialized) return;
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.handleResizeFunction) {
window.removeEventListener('resize', this.handleResizeFunction);
this.handleResizeFunction = null;
}
window.removeEventListener("keydown", (e) => {});
window.removeEventListener("keyup", (e) => {});
if (this.blocker && this.blocker.parentNode) {
this.blocker.parentNode.removeChild(this.blocker);
this.blocker = null;
}
if (this.quad) {
this.scene.remove(this.quad);
this.quad.geometry.dispose();
(this.quad.material as THREE.Material).dispose();
}
if (this.controls) {
this.controls.disconnect();
}
if (this.renderer) {
this.renderer.dispose();
}
this.showClickMessage = null;
this.isInitialized = false;
}
}
const mandelbulbMeadows = new MandelbulbMeadows();
export const initialize = (container: HTMLElement | null): void => {
mandelbulbMeadows.initialize(container);
};
export const teardown = (): void => {
mandelbulbMeadows.teardown();
};