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();
};