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