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.

Three.js example

 

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);
  }
);
 
Our page now should look like this
Three.js example

 

9. Improving the effect

The effect appears to lack smoothness and unnatural. Let's give it some effects after the particle has successfully formed the image.
 
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

The complete code is ready to create the particle effect that forms a PNG image. Try it now and customize the effect for your project!
Three.js example
 

 

References

1. Three.js Documentation.(https://threejs.org/docs/)
2. Three.js Journey. (https://threejs-journey.com/)
3. Three.js Examples. (https://threejs.org/examples/)
Header image is from pexels.com
The PNG image that is used in the code designed by callmetak of Freepik (https://www.freepik.com/free-vector/merry-christmas-vector-logo-red-decorative-logo-with-swash-isolated-white-background_28877876.htm#fromView=search&page=1&position=36&uuid=3cd18ae2-9bb1-455e-b2df-d38c01cc8046&new_detail=true)