1. Introduction
A couple of years ago, I wrote 2d animated particle constellations in pure JS, which became quite popular. Today we'll build an interactive 3D version using Three.js. You'll learn how to create floating particles, connect them dynamically, and implement intuitive rotation and zoom controls that work on both desktop and mobile devices.
What You'll Learn:
- Three.js fundamentals (scene, camera, renderer)
- Particle systems and BufferGeometry
- Dynamic line connections between particles
- Mouse and touch event handling
- Smooth animation and interpolation
2. Project Setup
1
HTML Structure
Start with a basic HTML file and include Three.js from a CDN:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Particle Constellations</title>
<style>
body {
margin: 0;
overflow: hidden;
background: #000;
}
canvas {
display: block;
touch-action: none;
}
</style>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// Our code will go here
</script>
</body>
</html>
Important: The
touch-action: none CSS property prevents default touch behaviors, allowing us to handle touch events ourselves.
3. Creating the 3D Scene
2
Initialize Core Three.js Components
let scene, camera, renderer, particles, lines;
function init() {
// Create the scene
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000000, 0.001);
// Setup camera
camera = new THREE.PerspectiveCamera(
75, // Field of view
window.innerWidth / window.innerHeight, // Aspect ratio
1, // Near clipping plane
3000 // Far clipping plane
);
camera.position.z = 500;
// Create renderer
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
}
Scene Components Explained:
- Scene: Container for all 3D objects
- Camera: Defines viewpoint and perspective
- Renderer: Draws the scene to the canvas
- Fog: Adds depth perception with exponential falloff
4. Building the Particle System
3
Create Particles with BufferGeometry
const particleCount = 200;
const particlePositions = [];
const particleVelocities = [];
function createParticles() {
const geometry = new THREE.BufferGeometry();
const positions = [];
const colors = [];
// Generate random particle positions
for (let i = 0; i < particleCount; i++) {
const x = Math.random() * 800 - 400;
const y = Math.random() * 800 - 400;
const z = Math.random() * 800 - 400;
positions.push(x, y, z);
particlePositions.push(new THREE.Vector3(x, y, z));
// Random velocities for animation
particleVelocities.push({
x: (Math.random() - 0.5) * 0.3,
y: (Math.random() - 0.5) * 0.3,
z: (Math.random() - 0.5) * 0.3
});
// Create color gradient
const hue = (i / particleCount) * 360;
const color = new THREE.Color(`hsl(${hue}, 70%, 60%)`);
colors.push(color.r, color.g, color.b);
}
// Set geometry attributes
geometry.setAttribute('position',
new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('color',
new THREE.Float32BufferAttribute(colors, 3));
// Create material
const material = new THREE.PointsMaterial({
size: 4,
vertexColors: true,
transparent: true,
opacity: 0.8,
sizeAttenuation: true
});
particles = new THREE.Points(geometry, material);
scene.add(particles);
}
BufferGeometry Benefits:
BufferGeometry stores vertex data directly in GPU memory, making it much more efficient than regular Geometry. We use Float32Array for optimal performance.
5. Dynamic Connections
4
Create Lines Between Nearby Particles
const connectionDistance = 100;
function createLines() {
const lineGeometry = new THREE.BufferGeometry();
const linePositions = new Float32Array(
particleCount * particleCount * 3 * 2
);
lineGeometry.setAttribute('position',
new THREE.BufferAttribute(linePositions, 3));
const lineMaterial = new THREE.LineBasicMaterial({
color: 0x4488ff,
transparent: true,
opacity: 0.3,
blending: THREE.AdditiveBlending
});
lines = new THREE.LineSegments(lineGeometry, lineMaterial);
scene.add(lines);
}
function updateConnections() {
const positions = lines.geometry.attributes.position.array;
let vertexIndex = 0;
// Check distance between all particle pairs
for (let i = 0; i < particleCount; i++) {
for (let j = i + 1; j < particleCount; j++) {
const dist = particlePositions[i].distanceTo(
particlePositions[j]
);
// Connect if within distance threshold
if (dist < connectionDistance) {
positions[vertexIndex++] = particlePositions[i].x;
positions[vertexIndex++] = particlePositions[i].y;
positions[vertexIndex++] = particlePositions[i].z;
positions[vertexIndex++] = particlePositions[j].x;
positions[vertexIndex++] = particlePositions[j].y;
positions[vertexIndex++] = particlePositions[j].z;
}
}
}
lines.geometry.setDrawRange(0, vertexIndex / 3);
lines.geometry.attributes.position.needsUpdate = true;
}
Performance Note: Checking all pairs is O(n²). For more particles, consider spatial partitioning techniques like octrees.
6. Animation Loop
5
Animate Particles and Update Scene
function animate() {
requestAnimationFrame(animate);
// Animate particle positions
const positions = particles.geometry.attributes.position.array;
for (let i = 0; i < particleCount; i++) {
particlePositions[i].x += particleVelocities[i].x;
particlePositions[i].y += particleVelocities[i].y;
particlePositions[i].z += particleVelocities[i].z;
// Bounce off boundaries
if (Math.abs(particlePositions[i].x) > 400)
particleVelocities[i].x *= -1;
if (Math.abs(particlePositions[i].y) > 400)
particleVelocities[i].y *= -1;
if (Math.abs(particlePositions[i].z) > 400)
particleVelocities[i].z *= -1;
// Update geometry
positions[i * 3] = particlePositions[i].x;
positions[i * 3 + 1] = particlePositions[i].y;
positions[i * 3 + 2] = particlePositions[i].z;
}
particles.geometry.attributes.position.needsUpdate = true;
updateConnections();
renderer.render(scene, camera);
}
// Start the animation
init();
animate();
7. Mouse & Touch Controls
6
Implement Rotation Controls
let mouseDown = false;
let mouseX = 0, mouseY = 0;
let targetRotationX = 0, targetRotationY = 0;
let currentRotationX = 0, currentRotationY = 0;
// Mouse events
document.addEventListener('mousedown', (e) => {
mouseDown = true;
mouseX = e.clientX;
mouseY = e.clientY;
});
document.addEventListener('mousemove', (e) => {
if (mouseDown) {
const deltaX = e.clientX - mouseX;
const deltaY = e.clientY - mouseY;
targetRotationY += deltaX * 0.005;
targetRotationX += deltaY * 0.005;
mouseX = e.clientX;
mouseY = e.clientY;
}
});
document.addEventListener('mouseup', () => {
mouseDown = false;
});
// Add smooth interpolation in animate()
currentRotationX += (targetRotationX - currentRotationX) * 0.05;
currentRotationY += (targetRotationY - currentRotationY) * 0.05;
particles.rotation.x = currentRotationX;
particles.rotation.y = currentRotationY;
lines.rotation.x = currentRotationX;
lines.rotation.y = currentRotationY;
7
Add Touch Support with Pinch-to-Zoom
let lastTouchDistance = 0;
function getTouchDistance(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
document.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
mouseDown = true;
mouseX = e.touches[0].clientX;
mouseY = e.touches[0].clientY;
} else if (e.touches.length === 2) {
mouseDown = false;
lastTouchDistance = getTouchDistance(e.touches);
}
});
document.addEventListener('touchmove', (e) => {
e.preventDefault();
if (e.touches.length === 1 && mouseDown) {
// Single finger rotation
const deltaX = e.touches[0].clientX - mouseX;
const deltaY = e.touches[0].clientY - mouseY;
targetRotationY += deltaX * 0.005;
targetRotationX += deltaY * 0.005;
mouseX = e.touches[0].clientX;
mouseY = e.touches[0].clientY;
} else if (e.touches.length === 2) {
// Two finger pinch zoom
const distance = getTouchDistance(e.touches);
const delta = distance - lastTouchDistance;
camera.position.z -= delta * 2;
camera.position.z = Math.max(200, Math.min(1000, camera.position.z));
lastTouchDistance = distance;
}
});
document.addEventListener('touchend', () => {
mouseDown = false;
lastTouchDistance = 0;
});
8
Add Mouse Wheel Zoom
document.addEventListener('wheel', (e) => {
e.preventDefault();
camera.position.z += e.deltaY * 0.5;
camera.position.z = Math.max(200, Math.min(1000, camera.position.z));
});
8. Optimization Tips
Performance Best Practices:
- Use BufferGeometry: Direct GPU access for better performance
- Limit Connection Checks: O(n²) can slow down with many particles
- Set needsUpdate Wisely: Only flag for update when data changes
- Use setDrawRange: Avoid rendering unused vertices
- Throttle Updates: Consider updating connections every few frames
- Device Pixel Ratio: Balance quality vs performance on high-DPI screens
9
Handle Window Resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
Complete Code Structure
// 1. Global variables
let scene, camera, renderer, particles, lines;
const particleCount = 200;
// 2. Initialize Three.js
function init() {
// Create scene, camera, renderer
// Setup fog and lighting
}
// 3. Create particles
function createParticles() {
// Generate positions and colors
// Create BufferGeometry and material
}
// 4. Create connection lines
function createLines() {
// Setup line geometry
}
// 5. Update connections each frame
function updateConnections() {
// Check distances and draw lines
}
// 6. Animation loop
function animate() {
requestAnimationFrame(animate);
// Update particles
// Apply rotation
// Render scene
}
// 7. Event listeners
// Mouse and touch controls
// Window resize handler
// 8. Start everything
init();
createParticles();
createLines();
animate();
Next Steps:
- Experiment with different particle counts and colors
- Add keyboard controls for camera movement
- Implement different connection patterns
- Add particle attraction/repulsion physics
- Create multiple constellation layers