Geometric Formation

Animated geometric shapes forming complex patterns

Created: December 10, 2023

View Source Code
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

// Global variables
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let controls: OrbitControls;
let particles: THREE.Points;
let clock: THREE.Clock;
let raycaster: THREE.Raycaster;
let mouse: THREE.Vector2;
let mousePosition: THREE.Vector3;
let mouseCursor: THREE.Mesh; // Visual mouse cursor
let rayLine: THREE.Line; // Visualization of the ray
let targetPositions: Float32Array;
let currentPositions: Float32Array;
let velocities: Float32Array;
let colors: Float32Array;
let sizes: Float32Array;
let currentShape: string = 'sphere';
let morphing: boolean = false;
let morphTime: number = 0;
let morphDuration: number = 2.0;
let animationFrameId: number;
let isInitialized: boolean = false;
let mouseDown: boolean = false;

// Configuration
const PARTICLE_COUNT = 15000;
const PARTICLE_SIZE_MIN = 0.05;
const PARTICLE_SIZE_MAX = 0.15;
const MAX_VELOCITY = 0.1;
const DAMPING = 0.95;
const FORMATION_SIZE = 8;

// Available shapes
const shapes = ['sphere', 'cube', 'torus', 'spiral', 'helix', 'galaxy', 'wave'];

// Color palettes for each shape
const colorPalettes = {
  sphere: [new THREE.Color(0x3a86ff), new THREE.Color(0x8338ec), new THREE.Color(0xff006e)],
  cube: [new THREE.Color(0x00f5d4), new THREE.Color(0x00bbf9), new THREE.Color(0xff9e00)],
  torus: [new THREE.Color(0xff0a54), new THREE.Color(0xff477e), new THREE.Color(0xff5c8a)],
  spiral: [new THREE.Color(0x7209b7), new THREE.Color(0x3a0ca3), new THREE.Color(0x4361ee)],
  helix: [new THREE.Color(0x80ffdb), new THREE.Color(0x72efdd), new THREE.Color(0x64dfdf)],
  galaxy: [new THREE.Color(0x390099), new THREE.Color(0x9e0059), new THREE.Color(0xff0054)],
  wave: [new THREE.Color(0xffbe0b), new THREE.Color(0xfb5607), new THREE.Color(0xff006e)]
};

/**
 * Generate positions for different geometric formations
 */
function generateFormation(shape: string, count: number): Float32Array {
  const positions = new Float32Array(count * 3);

  for (let i = 0; i < count; i++) {
    const i3 = i * 3;
    let x: number, y: number, z: number;

    switch (shape) {
      case 'sphere':
        // Spherical formation
        const radius = FORMATION_SIZE * 0.8;
        const theta = Math.random() * Math.PI * 2;
        const phi = Math.acos(2 * Math.random() - 1);

        x = radius * Math.sin(phi) * Math.cos(theta);
        y = radius * Math.sin(phi) * Math.sin(theta);
        z = radius * Math.cos(phi);
        break;

      case 'cube':
        // Cube formation (with more particles near edges)
        const edge = FORMATION_SIZE * 0.8;
        const distribution = Math.random();

        if (distribution < 0.7) {
          // Particles near edges (70%)
          const face = Math.floor(Math.random() * 6);

          switch (face) {
            case 0: x = -edge / 2; y = (Math.random() - 0.5) * edge; z = (Math.random() - 0.5) * edge; break; // Left
            case 1: x = edge / 2; y = (Math.random() - 0.5) * edge; z = (Math.random() - 0.5) * edge; break;  // Right
            case 2: x = (Math.random() - 0.5) * edge; y = -edge / 2; z = (Math.random() - 0.5) * edge; break; // Bottom
            case 3: x = (Math.random() - 0.5) * edge; y = edge / 2; z = (Math.random() - 0.5) * edge; break;  // Top
            case 4: x = (Math.random() - 0.5) * edge; y = (Math.random() - 0.5) * edge; z = -edge / 2; break; // Back
            case 5: x = (Math.random() - 0.5) * edge; y = (Math.random() - 0.5) * edge; z = edge / 2; break;  // Front
          }

          // Add slight variation from perfect face
          // @ts-ignore
          x += (Math.random() - 0.5) * 0.4;
          // @ts-ignore
          y += (Math.random() - 0.5) * 0.4;
          // @ts-ignore
          z += (Math.random() - 0.5) * 0.4;
        } else {
          // Particles inside cube (30%)
          x = (Math.random() - 0.5) * edge;
          y = (Math.random() - 0.5) * edge;
          z = (Math.random() - 0.5) * edge;
        }
        break;

      case 'torus':
        // Torus formation
        const R = FORMATION_SIZE * 0.6; // Major radius
        const r = FORMATION_SIZE * 0.2; // Minor radius

        const u = Math.random() * Math.PI * 2;
        const v = Math.random() * Math.PI * 2;

        x = (R + r * Math.cos(v)) * Math.cos(u);
        y = (R + r * Math.cos(v)) * Math.sin(u);
        z = r * Math.sin(v);
        break;

      case 'spiral':
        // Spiral formation
        const arms = 3;
        const rotations = 3;
        const armIndex = Math.floor(Math.random() * arms);
        const t = Math.random() * rotations;
        const radiusFactor = 0.1 + (t / rotations) * 0.9; // Smaller at center

        const angle = t * Math.PI * 2 + (armIndex * Math.PI * 2) / arms;
        const spiralRadius = FORMATION_SIZE * 0.8 * radiusFactor;

        x = spiralRadius * Math.cos(angle);
        y = spiralRadius * Math.sin(angle);
        z = (Math.random() - 0.5) * FORMATION_SIZE * 0.2;
        break;

      case 'helix':
        // Double helix formation
        const strand = Math.random() > 0.5 ? 1 : -1;
        const coils = 5;
        const height = FORMATION_SIZE * 1.5;
        const h = (Math.random() - 0.5) * height;
        const helixRadius = FORMATION_SIZE * 0.3;
        const helixAngle = (h / height) * Math.PI * 2 * coils;

        x = helixRadius * Math.cos(helixAngle) * strand;
        y = h;
        z = helixRadius * Math.sin(helixAngle);
        break;

      case 'galaxy':
        // Galaxy formation
        const galaxyRadius = Math.random() * FORMATION_SIZE * 0.9;
        const galaxyAngle = Math.random() * Math.PI * 2;
        const armOffset = Math.random() * 0.6; // How tight the arms are

        // Logarithmic spiral
        const spiralAngle = galaxyAngle + armOffset * Math.log(galaxyRadius);

        x = galaxyRadius * Math.cos(spiralAngle);
        y = (Math.random() - 0.5) * FORMATION_SIZE * 0.15;
        z = galaxyRadius * Math.sin(spiralAngle);
        break;

      case 'wave':
        // Wave pattern
        const waveX = (Math.random() - 0.5) * FORMATION_SIZE;
        const waveZ = (Math.random() - 0.5) * FORMATION_SIZE;
        const distance = Math.sqrt(waveX * waveX + waveZ * waveZ);

        x = waveX;
        y = Math.sin(distance * 0.5) * FORMATION_SIZE * 0.25;
        z = waveZ;
        break;

      default:
        // Fallback to random positions
        x = (Math.random() - 0.5) * FORMATION_SIZE;
        y = (Math.random() - 0.5) * FORMATION_SIZE;
        z = (Math.random() - 0.5) * FORMATION_SIZE;
    }

    // Add slight randomness for more natural look
    const jitter = FORMATION_SIZE * 0.02;
    // @ts-ignore
    x += (Math.random() - 0.5) * 0.01;
    // @ts-ignore
    y += (Math.random() - 0.5) * 0.01;
    // @ts-ignore
    z += (Math.random() - 0.5) * 0.01;

    // @ts-ignore
    positions[i3] = x;
    // @ts-ignore
    positions[i3 + 1] = y;
    // @ts-ignore
    positions[i3 + 2] = z;
  }

  return positions;
}

/**
 * Initialize the colors for the current shape
 */
function initializeColors(shape: string): Float32Array {
  const colors = new Float32Array(PARTICLE_COUNT * 3);
  const palette = (colorPalettes as any)[shape] || colorPalettes.sphere;

  for (let i = 0; i < PARTICLE_COUNT; i++) {
    const i3 = i * 3;

    // Choose a color from the palette
    const colorIndex = Math.floor(Math.random() * palette.length);
    const color = palette[colorIndex];

    // Add some variation to colors
    const hsl = { h: 0, s: 0, l: 0 };
    color.getHSL(hsl);

    // Vary hue and lightness slightly
    hsl.h += (Math.random() * 0.1) - 0.05;
    hsl.s = Math.min(Math.max(hsl.s + (Math.random() * 0.2) - 0.1, 0), 1);
    hsl.l = Math.min(Math.max(hsl.l + (Math.random() * 0.2) - 0.1, 0), 1);

    const variedColor = new THREE.Color().setHSL(hsl.h, hsl.s, hsl.l);

    colors[i3] = variedColor.r;
    colors[i3 + 1] = variedColor.g;
    colors[i3 + 2] = variedColor.b;
  }

  return colors;
}

/**
 * Morph to a new shape
 */
function morphToShape(shape: string): void {
  if (shape === currentShape || morphing) return;

  currentShape = shape;
  targetPositions = generateFormation(shape, PARTICLE_COUNT);
  colors = initializeColors(shape);

  // Update colors immediately
  particles.geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

  morphing = true;
  morphTime = 0;
}

export function initialize(container: HTMLElement | null): void {
  if (!container || isInitialized) return;

  // Create scene
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x050520);

  // Setup camera
  const width = container.clientWidth;
  const height = container.clientHeight;
  const aspectRatio = width / height;

  camera = new THREE.PerspectiveCamera(60, aspectRatio, 0.1, 1000);
  camera.position.z = 20;

  // Setup renderer
  renderer = new THREE.WebGLRenderer({
    antialias: true,
    alpha: true
  });
  renderer.setSize(width, height);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // Limit pixel ratio for performance
  container.appendChild(renderer.domElement);

  // Setup controls
  controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  controls.dampingFactor = 0.05;
  controls.enableZoom = true;
  controls.autoRotate = true;
  controls.autoRotateSpeed = 0.5;

  // Initialize raycaster and mouse
  raycaster = new THREE.Raycaster();
  mouse = new THREE.Vector2();
  mousePosition = new THREE.Vector3();

  // Create visual cursor for mouse position
  const cursorGeometry = new THREE.SphereGeometry(0.3, 16, 16);
  const cursorMaterial = new THREE.MeshBasicMaterial({
    color: 0xffffff,
    transparent: true,
    opacity: 0.7,
    depthWrite: false
  });
  mouseCursor = new THREE.Mesh(cursorGeometry, cursorMaterial);
  mouseCursor.visible = false; // Start hidden
  scene.add(mouseCursor);

  // Create ray visualization
  const rayGeometry = new THREE.BufferGeometry().setFromPoints([
    new THREE.Vector3(0, 0, 0),
    new THREE.Vector3(0, 0, -1000)
  ]);
  const rayMaterial = new THREE.LineBasicMaterial({
    color: 0xffffff,
    transparent: true,
    opacity: 0.3,
    blending: THREE.AdditiveBlending
  });
  rayLine = new THREE.Line(rayGeometry, rayMaterial);
  rayLine.visible = false; // Start hidden
  scene.add(rayLine);

  // Initialize clock
  clock = new THREE.Clock();

  // Generate initial positions and velocities
  currentPositions = generateFormation('sphere', PARTICLE_COUNT);
  targetPositions = currentPositions.slice();
  velocities = new Float32Array(PARTICLE_COUNT * 3);

  // Initialize sizes
  sizes = new Float32Array(PARTICLE_COUNT);
  for (let i = 0; i < PARTICLE_COUNT; i++) {
    sizes[i] = PARTICLE_SIZE_MIN + Math.random() * (PARTICLE_SIZE_MAX - PARTICLE_SIZE_MIN);
  }

  // Initialize colors
  colors = initializeColors('sphere');

  // Create particle geometry
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(currentPositions, 3));
  geometry.setAttribute('aColor', new THREE.BufferAttribute(colors, 3)); // Changed from 'color' to 'aColor'
  geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));

  // Create particle material with custom shaders for nicer particles
  const material = new THREE.ShaderMaterial({
    uniforms: {
      time: { value: 0.0 },
      pixelRatio: { value: renderer.getPixelRatio() }
    },
    vertexShader: /*glsl*/`
      attribute float size;
      attribute vec3 aColor;
      varying vec3 vColor;
      uniform float time;
      uniform float pixelRatio;
      
      void main() {
        vColor = aColor;
        
        vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
        gl_PointSize = size * pixelRatio * (300.0 / -mvPosition.z);
        gl_Position = projectionMatrix * mvPosition;
      }
    `,
    fragmentShader: /*glsl*/`
      varying vec3 vColor;
      
      void main() {
        // Calculate distance from center of point
        vec2 center = gl_PointCoord - vec2(0.5);
        float dist = length(center) * 2.0;
        
        // Create soft circle with glow
        float alpha = 1.0 - smoothstep(0.7, 1.0, dist);
        
        // Add glow
        float glow = exp(-dist * 2.5) * 0.35;
        
        // Final color with alpha
        gl_FragColor = vec4(vColor, alpha + glow);
      }
    `,
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    vertexColors: true
  });

  // Create particle system
  particles = new THREE.Points(geometry, material);
  scene.add(particles);

  // Add ambient light
  const ambientLight = new THREE.AmbientLight(0x222233);
  scene.add(ambientLight);

  // Add directional light
  const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
  directionalLight.position.set(1, 1, 1);
  scene.add(directionalLight);

  // Add point lights for better visual effect
  const lightColors = [0x3677ff, 0xff3366, 0x42f5ad];
  const lightPositions = [
    [10, 5, 5],
    [-10, -5, 5],
    [0, 8, -10]
  ];

  for (let i = 0; i < lightColors.length; i++) {
    const light = new THREE.PointLight(lightColors[i], 1, 20);
    // @ts-ignore
    light.position.set(...lightPositions[i]);
    scene.add(light);
  }

  // Add event listeners
  container.addEventListener('mousemove', onMouseMove);
  container.addEventListener('mousedown', () => { mouseDown = true; });
  container.addEventListener('mouseup', () => { mouseDown = false; });
  container.addEventListener('mouseleave', () => { mouseDown = false; });
  container.addEventListener('touchstart', onTouchStart);
  container.addEventListener('touchmove', onTouchMove);
  container.addEventListener('touchend', () => { mouseDown = false; });
  window.addEventListener('resize', onWindowResize);

  // Create shape interval - change shape every 7 seconds
  setInterval(() => {
    const nextShape = shapes[Math.floor(Math.random() * shapes.length)];
    morphToShape(nextShape);
  }, 10000);

  // Start animation loop
  animate();
  isInitialized = true;
}

function onMouseMove(event: MouseEvent): void {
  const rect = renderer.domElement.getBoundingClientRect();

  mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;

  updateMousePosition();
}

function onTouchStart(event: TouchEvent): void {
  mouseDown = true;
  updateTouchPosition(event);
}

function onTouchMove(event: TouchEvent): void {
  updateTouchPosition(event);
}

function updateTouchPosition(event: TouchEvent): void {
  if (event.touches.length > 0) {
    const rect = renderer.domElement.getBoundingClientRect();
    const touch = event.touches[0];

    mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
    mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;

    updateMousePosition();
  }
}

function updateMousePosition(): void {
  raycaster.setFromCamera(mouse, camera);

  // Calculate intersection with a plane perpendicular to the camera direction
  const cameraDirection = new THREE.Vector3();
  camera.getWorldDirection(cameraDirection);

  // Create a plane perpendicular to the camera's view direction
  const planeNormal = cameraDirection.clone();
  const planeDistance = -10; // Negative because the plane normal points toward camera
  const plane = new THREE.Plane(planeNormal, planeDistance);

  // Calculate the intersection point in 3D space
  if (!raycaster.ray.intersectPlane(plane, mousePosition)) {
    // Fallback if no intersection (shouldn't happen)
    mousePosition.set(mouse.x * 10, mouse.y * 10, 0);
  }

  // Update cursor position and make it visible
  mouseCursor.position.copy(mousePosition);
  mouseCursor.visible = true;

  // No visible ray, but the ray is used for particle interaction
  rayLine.visible = false;
}

function onWindowResize(): void {
  const container = renderer.domElement.parentElement;
  if (!container) return;

  const width = container.clientWidth;
  const height = container.clientHeight;

  camera.aspect = width / height;
  camera.updateProjectionMatrix();

  renderer.setSize(width, height);
  (particles.material as THREE.ShaderMaterial).uniforms.pixelRatio.value = renderer.getPixelRatio();
}

function animate(): void {
  animationFrameId = requestAnimationFrame(animate);

  // Update controls
  controls.update();

  // Only auto-rotate when not interacting
  controls.autoRotate = !mouseDown;

  // Update time for shaders
  const time = clock.getElapsedTime();
  (particles.material as THREE.ShaderMaterial).uniforms.time.value = time;

  // Update cursor animation
  if (mouseCursor.visible) {
    // Make cursor pulse with breathing effect
    const baseScale = mouseDown ? 1.4 : 1.0;
    const pulseScale = baseScale + Math.sin(time * 4) * 0.2;
    mouseCursor.scale.set(pulseScale, pulseScale, pulseScale);

    // Also pulse opacity
    (mouseCursor.material as THREE.MeshBasicMaterial).opacity = 0.5 + Math.sin(time * 2) * 0.2;

    // Match cursor color to current shape's color palette
    // @ts-ignore
    const palette = colorPalettes[currentShape] || colorPalettes.sphere;
    const colorIndex = Math.floor(time) % palette.length;
    (mouseCursor.material as THREE.MeshBasicMaterial).color = palette[colorIndex];
  }

  // Handle morphing between shapes
  if (morphing) {
    morphTime += clock.getDelta();
    const progress = Math.min(morphTime / morphDuration, 1.0);

    // Use easing function for smoother transition
    const eased = 1 - Math.pow(1 - progress, 3); // Cubic ease out

    if (progress >= 1.0) {
      morphing = false;
    }

    updateParticles(eased);
  } else {
    updateParticles(0);
  }

  // Animate lights
  scene.children.forEach(child => {
    if (child instanceof THREE.PointLight) {
      child.position.x += Math.sin(time * 0.5) * 0.02;
      child.position.y += Math.cos(time * 0.3) * 0.02;
      child.position.z += Math.sin(time * 0.7) * 0.02;
    }
  });

  // Render scene
  renderer.render(scene, camera);
}

// @ts-ignore
function updateParticles(morphProgress: number): void {
  const positionsArray = particles.geometry.attributes.position.array as Float32Array;
  const sizesArray = particles.geometry.attributes.size.array as Float32Array;

  for (let i = 0; i < PARTICLE_COUNT; i++) {
    const i3 = i * 3;

    if (morphing) {
      // Interpolate towards target positions
      const targetX = targetPositions[i3];
      const targetY = targetPositions[i3 + 1];
      const targetZ = targetPositions[i3 + 2];

      // Calculate velocity towards target with inertia
      velocities[i3] = velocities[i3] * DAMPING + (targetX - positionsArray[i3]) * 0.05;
      velocities[i3 + 1] = velocities[i3 + 1] * DAMPING + (targetY - positionsArray[i3 + 1]) * 0.05;
      velocities[i3 + 2] = velocities[i3 + 2] * DAMPING + (targetZ - positionsArray[i3 + 2]) * 0.05;

      // Limit max velocity
      const speed = Math.sqrt(
        velocities[i3] * velocities[i3] +
        velocities[i3 + 1] * velocities[i3 + 1] +
        velocities[i3 + 2] * velocities[i3 + 2]
      );

      if (speed > MAX_VELOCITY) {
        const scale = MAX_VELOCITY / speed;
        velocities[i3] *= scale;
        velocities[i3 + 1] *= scale;
        velocities[i3 + 2] *= scale;
      }
    } else {
      // Apply slight noise for ambient motion
      velocities[i3] += (Math.random() - 0.5) * 0.01;
      velocities[i3 + 1] += (Math.random() - 0.5) * 0.01;
      velocities[i3 + 2] += (Math.random() - 0.5) * 0.01;

      // Dampen velocities
      velocities[i3] *= 0.95;
      velocities[i3 + 1] *= 0.95;
      velocities[i3 + 2] *= 0.95;
    }

    // Get current particle position
    const particlePosition = new THREE.Vector3(
      positionsArray[i3],
      positionsArray[i3 + 1],
      positionsArray[i3 + 2]
    );

    // Calculate distance to the ray
    const ray = raycaster.ray;
    const closestPointOnRay = ray.closestPointToPoint(particlePosition, new THREE.Vector3());
    const distToRay = particlePosition.distanceTo(closestPointOnRay);

    // Distance to mouse position
    const distToMouse = particlePosition.distanceTo(mousePosition);

    // Very close particles are repelled from mouse position
    if (distToMouse < 2) {
      // Repulse particles from mouse
      const forceDirection = particlePosition.clone().sub(mousePosition).normalize();
      const forceMagnitude = 0.05 * (1 - distToMouse / 2);

      velocities[i3] += forceDirection.x * forceMagnitude;
      velocities[i3 + 1] += forceDirection.y * forceMagnitude;
      velocities[i3 + 2] += forceDirection.z * forceMagnitude;

      // Increase particle size temporarily
      sizesArray[i] = Math.min(sizesArray[i] + 0.01, PARTICLE_SIZE_MAX * 2);
    }
    // Particles near the ray have orbital motion
    else if (distToRay < 15) {
      // Vector from particle to ray
      const toRay = closestPointOnRay.clone().sub(particlePosition);

      // Calculate orbital direction perpendicular to both the ray and toRay vector
      const rayDirection = ray.direction.clone();
      const orbitDirection = new THREE.Vector3().crossVectors(toRay, rayDirection).normalize();

      // Mix of attraction to ray and perpendicular orbital force
      const attractStrength = 0.05 * (1 - distToRay / 15);
      const orbitStrength = 0.07 * (1 - distToRay / 10); // Stronger orbit force than attraction

      // Apply attraction to ray
      const attractForce = toRay.normalize();
      velocities[i3] += attractForce.x * attractStrength;
      velocities[i3 + 1] += attractForce.y * attractStrength;
      velocities[i3 + 2] += attractForce.z * attractStrength;

      // Apply orbital force (perpendicular to attraction)
      velocities[i3] += orbitDirection.x * orbitStrength;
      velocities[i3 + 1] += orbitDirection.y * orbitStrength;
      velocities[i3 + 2] += orbitDirection.z * orbitStrength;

      // Slightly increase size for particles in orbit
      sizesArray[i] = Math.min(sizesArray[i] + 0.005, PARTICLE_SIZE_MAX * 1.5);
    } else if (sizesArray[i] > PARTICLE_SIZE_MAX) {
      // Gradually return to normal size
      sizesArray[i] -= 0.01;
    }

    // Update particle position
    positionsArray[i3] += velocities[i3];
    positionsArray[i3 + 1] += velocities[i3 + 1];
    positionsArray[i3 + 2] += velocities[i3 + 2];
  }

  // Update buffers
  particles.geometry.attributes.position.needsUpdate = true;
  particles.geometry.attributes.size.needsUpdate = true;
}

export function teardown(): void {
  if (!isInitialized) return;

  // Stop animation loop
  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId);
  }

  // Remove event listeners
  window.removeEventListener('resize', onWindowResize);
  if (renderer && renderer.domElement) {
    renderer.domElement.removeEventListener('mousemove', onMouseMove);
    renderer.domElement.removeEventListener('mousedown', () => { mouseDown = true; });
    renderer.domElement.removeEventListener('mouseup', () => { mouseDown = false; });
    renderer.domElement.removeEventListener('touchstart', onTouchStart);
    renderer.domElement.removeEventListener('touchmove', onTouchMove);
    renderer.domElement.removeEventListener('touchend', () => { mouseDown = false; });
  }

  // Dispose of controls
  if (controls) {
    controls.dispose();
  }

  // Dispose of Three.js resources
  if (particles) {
    particles.geometry.dispose();
    if (Array.isArray(particles.material)) {
      particles.material.forEach(material => material.dispose());
    } else {
      particles.material.dispose();
    }
    scene.remove(particles);
  }

  // Remove lights
  scene.children.forEach(child => {
    if (child instanceof THREE.Light) {
      scene.remove(child);
    }
  });

  // Clear scene
  while (scene.children.length > 0) {
    scene.remove(scene.children[0]);
  }

  // Remove renderer DOM element
  if (renderer) {
    renderer.domElement.parentNode?.removeChild(renderer.domElement);
    renderer.dispose();
  }

  // Clear references
  isInitialized = false;
}