Three.js is a flexible JavaScript library that makes using WebGL easier and more intuitive. It lets developers create detailed 3D graphics for the web without having to deal with the complex details and low-level API of WebGL. Three.js has a variety of features, such as tools for controlling 3D objects, materials, lighting, cameras, and animations. It is created with user-friendly APIs, comprehensive documentation, and a big user base, making it not just simple for beginners to learn and use but also powerful enough for advanced projects. Three.js is a good option for creating eye-catching visual effects, interactive 3D experiences, or just simple animations.
In this blog post, I will share my approach to creating a magical effect where tiny circles (particles) rearrange themselves to form a PNG image.
Before we dive into the code, let's see the result: countless tiny particles dynamically rearrange themselves to form a PNG image, creating a magical transformation effect.
1. Setting up the environment
Before starting, you need to set up Node.js and install the Three.js library:
npm install three
2. Creating the HTML & adding CSS
Add the following HTML and CSS file (index.html
):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transforming tiny circles into a beautiful image</title>
<style>
/* Reset margin and padding for consistent layout */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Set up a container for the canvas (Full viewport) */
.wrapper {
position: relative;
width: 100%;
height: 100vh;
}
/* Style the canvas to cover the entire container */
#canvas {
position: absolute;
top: 0;
left: 0;
}
</style>
</head>
<body>
<div class="wrapper">
<!-- The canvas where the Three.js scene will be rendered -->
<canvas id="canvas"></canvas>
</div>
<script src="./script.js" type="module"></script>
</body>
</html>
3. Initializing Three.js
Declaring variables and setting up the renderer, scene, and camera in script.js
file:
// Variables for Three.js components
let renderer, scene, canvas, camera, sizes, particles, texture, geometry;
// A vector for calculating center alignment
const centerVector = new THREE.Vector3(0, 0, 0);
// Getting the canvas element from the DOM
const wrapper = document.querySelector(".wrapper");
canvas = document.querySelector("#canvas");
// Getting the canvas sizes
sizes = {
width: wrapper.getBoundingClientRect().width,
height: wrapper.getBoundingClientRect().height,
};
// Initialize Three.js renderer, scene, and camera
function init() {
renderer = new THREE.WebGLRenderer({
canvas, // Specify the HTML canvas element to render on
antialias: true // Make the scene visually smoother
});
renderer.setSize(sizes.width, sizes.height); // Set the canvas size
scene = new THREE.Scene();
// Camera setup with perspective projection
camera = new THREE.PerspectiveCamera(
45, // The field of view (FOV) in degrees
sizes.width / sizes.height, // Aspect ratio to match the canvas size
0.1, // the near clipping plane
1000 // the far clipping plane
);
camera.position.set(0, 0, 50); // Position the camera
camera.lookAt(centerVector); // Make the camera look at the center
scene.add(camera);
// Handle resizing of the window
window.addEventListener("resize", handleResize);
}
// Function to handle resizing of the viewport
function handleResize() {
// Setting the sizes of the rendering area to match the sizes of the browser window.
sizes.width = window.innerWidth;
sizes.height = window.innerHeight;
camera.aspect = sizes.width / sizes.height; // Update aspect ratio
camera.updateProjectionMatrix(); // Recalculate projection matrix
renderer.setSize(sizes.width, sizes.height); // Adjust renderer size
}
init();
4. Creating the particle texture
We use a canvas to create a circular particle texture:
const TEXTURE_SIZE = 32.0;
// Draw a circular gradient for the particle texture
function drawRadialGradation(ctx, canvasRadius, canvasW, canvasH) {
ctx.save(); // Save the canvas state
const gradient = ctx.createRadialGradient(
canvasRadius, // Center x-coordinate
canvasRadius, // Center y-coordinate
0, // Inner radius (solid color)
canvasRadius, // Outer x-coordinate
canvasRadius, // Outer y-coordinate
canvasRadius // Outer radius (transparent edge)
);
// Make a circle that fades from inside to outside
gradient.addColorStop(0, "rgba(255,255,255,1.0)"); // Solid white at the center
gradient.addColorStop(0.75, "rgba(255,255,255,1)"); // Soft white
gradient.addColorStop(1, "rgba(255,255,255,0)"); // Transparent at the edges
ctx.fillStyle = gradient; // Apply the gradient as the fill style
ctx.fillRect(0, 0, canvasW, canvasH); // Fill the entire canvas with the gradient
ctx.restore(); // Restore the canvas state
}
// Generate the particle texture using the canvas
function getParticleTexture() {
// Create an off-screen canvas
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = TEXTURE_SIZE; // Set canvas width to match the diameter
canvas.height = TEXTURE_SIZE; // Set canvas height to match the diameter
const canvasRadius = TEXTURE_SIZE / 2; // Calculate the radius
// Draw the gradient texture
drawRadialGradation(ctx, canvasRadius, canvas.width, canvas.height);
const texture = new THREE.Texture(canvas); // Convert the canvas into a Three.js texture
texture.type = THREE.FloatType; // For smooth rendering
texture.needsUpdate = true; // Mark the texture as ready for rendering
return texture; // Return the generated texture
}
5. Add extracting image data function
Getting image data is really important when we want to make particles from a picture. By looking closely at the pixel information, we can see which parts of the image are visible and use that to position the particles. This helps us bring the image to life in a fun and interactive manner by turning the visible pixels into particles.
// A function that takes image data and reduces its size by a scale ratio.
// Return the extracted pixel data of image
function getImageData(image, scaleRatio = 0.7) {
const imgCanvas = document.createElement("canvas");
imgCanvas.width = Math.round(image.width * scaleRatio); // Reduce image width
imgCanvas.height = Math.round(image.height * scaleRatio); // Reduce image height
const ctx = imgCanvas.getContext("2d");
ctx.drawImage(image, 0, 0, imgCanvas.width, imgCanvas.height);
return ctx.getImageData(0, 0, imgCanvas.width, imgCanvas.height); // Extract pixel data
}
6. Creating the particle effect
Creating the shape of the particles means using the data from image pixels to figure out where to put them (the tiny circles). Each pixel that we can see, depending on its alpha channel, gets turned into a particle. The main idea is the particles begin in random spots and then move to their places, which makes it look like the image is being created right in front of us. In this process, we also decide how fast (velocities) each particle should move and where it needs to go (destinations).
// Function to create geometry based on the image pixel data
function createGeometryFromImageData(imagedata) {
const initialPositions = []; // Random starting positions of particles
const vertices = []; // Actual positions of particles in the scene
const destinations = []; // Target positions based on image pixel coordinates
const velocities = []; // Speed at which particles move
// Process each pixel of the image
for (let h = 0; h < imagedata.height; h++) {
for (let w = 0; w < imagedata.width; w++) {
// Calculate the alpha value for the current pixel
// - 'w + h * imagedata.width' computes the pixel index in the data array based on its row and column
// - '* 4' accounts for the 4 channels (R, G, B, A) per pixel
// - '+ 3' accesses the alpha channel, which is the 4th value
constalpha=imagedata.data[(w+h*imagedata.width)*4+3];
// Alpha channel determines visibility
// Process only visible pixels
// 128 is 50% transparency (it looks similar to opacity: 50%)
if(alpha>128){
const x = THREE.MathUtils.randFloatSpread(1000); // Random x position
const y = THREE.MathUtils.randFloatSpread(1000); // Random y position
const z = THREE.MathUtils.randFloatSpread(1000); // Random z position
vertices.push(x, y, z);
initialPositions.push(x, y, z);
const desX = w - imagedata.width / 2; // Center destination x-coordinate
const desY = -h + imagedata.height / 2; // Center destination y-coordinate
const desZ = -imagedata.width + THREE.MathUtils.randFloatSpread(20); // Randomize z-depth
destinations.push(desX, desY, desZ);
// Control circle moving speed
constvelocity=Math.random()/200+0.015;
velocities.push(velocity);
}
}
}
return { vertices, initialPositions, destinations, velocities };
}
// Function to draw particles and add them to the scene
function drawObject(imagedata) {
geometry = new THREE.BufferGeometry(); // Create buffer geometry for particles
const { vertices, initialPositions, destinations, velocities } = createGeometryFromImageData(imagedata);
// Add particle positions to the geometry
geometry.setAttribute("position", new THREE.Float32BufferAttribute(vertices, 3)); // The current position of particles
geometry.setAttribute("initialPosition", new THREE.Float32BufferAttribute(initialPositions, 3)); // Where particles started
geometry.setAttribute("destination", new THREE.Float32BufferAttribute(destinations, 3)); // Target positions from the image
geometry.setAttribute("velocity", new THREE.Float32BufferAttribute(velocities, 1)); // Speed of particle movement
// Define the material for particles
const material = new THREE.PointsMaterial({
size: 5, // Size of each particle
color: 0xffff48, // Color for particles
map: getParticleTexture(), // Circular texture for particles
transparent: true, // Allow transparency
opacity: 0.7, // Set partial transparency
depthWrite: false, // Disable depth writing
sizeAttenuation: true, // Make particle size depend on perspective
});
// Create the particle system and add it to the scene
particles = new THREE.Points(geometry, material);
scene.add(particles); // Add particles to the scene
animate(); // Start animating the particles
}
7. Animation
In the drawObject
function, we use the animate function to start the animation. Next, we will begin to define what the animate function does.
function animate() {
// Function to continuously update particle positions and re-render the scene
const tick = () => {
const positions = geometry.attributes.position; // Current particle positions
const destinations = geometry.attributes.destination; // Target positions from image data
const velocities = geometry.attributes.velocity; // Movement speeds for each particle
// Create vector representations of current, destination, and initial positions
let v3Position = new THREE.Vector3();
let v3Destination = new THREE.Vector3();
// Loop over the particles, re-positions them
for (let i = 0; i < positions.count; i++) {
v3Position.fromBufferAttribute(positions, i);
v3Destination.fromBufferAttribute(destinations, i);
// Transit smoothly between the current position and the destination based on velocity
// The closer the particle is to the target location, the slower it moves.
v3Position.lerp(v3Destination, velocities.array[i])
// Update the particle's position in the geometry
positions.setXYZ(i, v3Position.x, v3Position.y, v3Position.z);
}
// Notify Three.js that the position attribute has been updated
geometry.attributes.position.needsUpdate = true;
// Render the updated scene with the camera
renderer.render(scene, camera);
// Call tick again on the next frame
requestAnimationFrame(tick);
};
tick(); // Start the animation loop
}
8. Loading the texture and drawing the particles
Initialize the particle system in the init
function. To ensure the particle system is drawn after the texture is loaded, use THREE.TextureLoader
to load the texture asynchronously:
const textureLoader = new THREE.TextureLoader();
textureLoader.load(
"./texture.png", // Path to the image file
(texture) => {
const imagedata = getImageData(texture.image); // Extract image data
drawObject(imagedata);
}
);

9. Improving the effect
function animate() {
const minDistance = 5; // Used to determine whether the particle continues to move**
// Function to continuously update particle positions and re-render the scene
const tick = () => {
const positions = geometry.attributes.position; // Current particle positions
const destinations = geometry.attributes.destination; // Target positions from image data
const velocities = geometry.attributes.velocity; // Movement speeds for each particle
// Create vector representations of current, destination, and initial positions
let v3Position = new THREE.Vector3();
let v3Destination = new THREE.Vector3();
// Loop over the particles, re-positions them
for (let i = 0; i < positions.count; i++) {
v3Position.fromBufferAttribute(positions, i);
v3Destination.fromBufferAttribute(destinations, i);
// Compute the distance to destination
const distance = v3Position.distanceTo(v3Destination);
if (distance > minDistance) {
// Transit smoothly between the current position and the destination based on velocity
// The closer the particle is to the target location, the slower it moves.
v3Position.lerp(v3Destination, velocities.array[i])
} else {
// Back-and-forth movement: Reverse direction
velocities.array[i] = -velocities.array[i]; // Reverse velocity
v3Position.x += THREE.MathUtils.randFloatSpread(0.05);
v3Position.y += THREE.MathUtils.randFloatSpread(0.05);
v3Position.z += THREE.MathUtils.randFloatSpread(0.05);
}
// Update the particle's position in the geometry
positions.setXYZ(i, v3Position.x, v3Position.y, v3Position.z);
}
// Notify Three.js that the position attribute has been updated
geometry.attributes.position.needsUpdate = true;
// Render the updated scene with the camera
renderer.render(scene, camera);
// Call tick again on the next frame
requestAnimationFrame(tick);
};
tick(); // Start the animation loop
}
Conclusion
