Let’s make another Marvel character disappear!
Recently I watched the Avengers movies on Disney+ (again!), and when I saw Thanos snap his fingers and turn almost everyone into dust, I was reminded of an old animation I made years ago with Adobe Flash. created. It was a shattering effect of an image that has uncanny parallels with the film.
This article will teach us how to create particles from image pixels and animate them individually.
So, this is the effect we are going to create:
Great, let’s get started!
A quick note: Even though the title says React, the animation code is written in bare JavaScript, so you can easily replace the React parts with document.querySelector or equivalent.
Our victim for this project, as you saw in the video, will be Wanda Maximoff aka Scarlet Witch. Of course, you can choose any MCU character you like. Just keep in mind that the background of the image should be transparent and not too large for performance reasons. Something like 600×400 should be fine.
Let’s start by adding a canvas and loading our image into it. Here’s the code:
The code here is very straight forward. First of all, we make ref
to our canvas, then in our useEffect
We check whether our canvas is loaded or not. If so, we access the context and set the width and height of our canvas to the intrinsic size of the window. This makes our canvas take up the entire screen (the size of the canvas must be defined in units, CSS units alone will not suffice).
All styles are 100% in width and height, so we’ll be working in the full viewport.
Then we load our image with the image element and draw it to our canvas via context drawImage
way. The second and third arguments are the position of the image, so with a simple calculation, we can position our image in the center of our page.
So far, nothing flashy. We’re showing our Wanda in the middle of the screen. Now we will generate particles and generators (or say, controllers if you want) for those particles.
To create the particles, we’ll write classes from OOP languages you may be familiar with. This will clean up our code and allow us to make changes more easily.
First of all, we start with a Particle
class like this. You can place this code outside the React function anywhere you want.
class Particle
constructor(x, y, color)
this.color = color;
this.x = x;
this.y = y;
this.size = 5;
draw(context)
context.fillStyle = this.color;
context.fillRect(this.x, this.y, this.size, this.size);
update()
our particle will be a small rectangle inside 5px
who takes x
And y
parameter to determine where to place itself in the canvas and which color
What color to fill the arguments. draw
The method displays our rectangle in a specified size and color. Also, we add an update method that we will implement soon.
moving forward with us Generator
add this code right after Particle
square:
class Generator
constructor(width, height)
this.width = width;
this.height = height;
this.particlesArray = [];
init(context)
this.particlesArray.push(new Particle(10, 10, 'red'));
draw(context)
this.particlesArray.forEach((particle) => particle.draw(context));
update()
this.particlesArray.forEach((particle) => particle.update());
generator
The class is also fairly straightforward. It behaves like a controller and a container with similar methods Particle
class, which allows us to generate, draw, and update all particles on the screen. We do this by creating an empty array and pushing each generated particle into it, then looping through it and calling the particle’s own draw and update methods. This draws a red particle, but we’ll convert that to our image pixel later.
Good! So far, I hope nothing seems unclear as we dive into more challenging stuff.
As mentioned earlier, we will generate particles per pixel of our image and reconstruct the image by those particles. To accomplish this, Canvas
element is getImageData
Method that returns the color data of all pixels in the specified canvas area (more info here: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getImageData,
Now, replace the line where we generate a particle with the line below Generator
‘s init
method:
init(context)
const pixels = context.getImageData(0, 0, this.width, this.height).data;
if we console.log(pixels)
At this point and checking the developer tools, we’ll see a lot of nested arrays like this one:
that’s because getImageData
returns raw data in Uint8ClampedArray
(Here clamp means that its value is between 0 and 255) Not formatted. Now, if you check the values of that array, you will see that most of them are 0
, But if you select an array range in the middle of the list, you can see some values like this:
I think it is clear enough. Those values are the pixel’s color data. 255
There is an alpha value, and every three values before them are red, green, and blue, respectively. Since our image is located in the middle of the screen, it’s no surprise that we get values in the middle rows.
Ok, so how do we handle those arrays? Since they are nested, we need a nested structure to parse them. Revised init
like below:
init(context)
const pixels = context.getImageData(0, 0, this.width, this.height).data;
for (let y = 0; y < this.height; y++)
for (let x = 0; x < this.width; x++)
const index = (y * this.width + x) * 4;
const red = pixels[index];
const green = pixels[index + 1];
const blue = pixels[index + 2];
const alpha = pixels[index + 3];
const color = `rgb($red, $green, $blue)`;if (alpha > 0)
this.particlesArray.push(new Particle(x, y, color));
Hell of a code! Well, all I can say is that you don’t necessarily need to understand this, but we’re doing what they call “scanlines” in image algorithms. We’re looping through every pixel of our canvas and get an *anchor* for every 4th value. Then we choose values in between them and assign them to rgba variables consecutively. Finally, we generate a particle for each pixel with an alpha value – meaning not empty.
When you run the code, you’ll see a copy of our image displayed above our original image, but this time, it’s made up of our particles. At the beginning of the animation, we’ll delete the original image so that we can only see our particles.
Now, let’s go back to our useEffect
and add the following lines:
useEffect(() =>
const canvas = canvasRef.current;if (canvas)
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const generator = new Generator(canvas.width, canvas.height);
const image = new Image();
image.onload = function ()
ctx.drawImage(
image,
canvas.width / 2 - image.width / 2,
canvas.height / 2 - image.height / 2
);
generator.init(ctx);
startAnimation(
generator,
ctx,
canvas.width,
canvas.height,
);
;
image.src = "/images/wanda.png";
, []);
Here, we initialize our Generator
pass us the canvas size and context init
, Also, we define our animation function separately. we’ll probably keep it in useEffect
, but we need to make sure it runs once. So, let’s wrap up this part useCallback
and keep it up useEffect
,
const startAnimation = useCallback((generator, ctx, w, h, count) =>
function animate()
ctx.clearRect(0, 0, w, h);
generator.draw(ctx);
generator.update();
requestAnimationFrame(animate);
setTimeout(() => animate(), 2000);
, []);
This is where animation happens. First of all, we start setTimeout
To give the viewer some time to see the original image so that they know how the animation begins. later ouranimate()
The function runs and first clears our canvas (our original image disappears like that), then calls Generator
Key’s draw and update method, which essentially calls Particle
Draw and Update method to draw the entire canvas with updated particles. with and window.requestAnimationFrame
or just, requestAnimationFrame
We rinse and repeat the procedure.
Ok so before running your code, we need to update our Particle
The class would otherwise be nothing:
lass Particle
constructor(x, y, color)
this.color = color;
this.x = x;
this.y = y;
this.size = 5;
this.vx = -Math.random() * 2;
this.vy = -Math.random() * 2;
this.vFactor = 1.01;
draw(context)
context.fillStyle = this.color;
context.fillRect(this.x, this.y, this.size, this.size);
update()
this.x += this.vx;
this.y += this.vy;
this.vx *= this.vFactor;
this.vy *= this.vFactor;
Here, we add the velocity value vx
And vy
and let there be a random value between them 0
And 2
, Also note that those negative values allow the particle to move to the top and left of the screen. This would give the impression that some wind is blowing on the right side. I also added a velocity multiplier, vFactor
Which accelerates the particles with time.
Ok, let’s see what we have so far:
Good! Now, if we briefly look at Wanda’s image when we run the code, it will completely explode into particles instead of dissolving. It’s not exactly what we’d like, and it’s sluggish, too.
This happens because we add more particles than necessary. is the particle size 5
, but we generate particles for each pixel. To fix this, introduce a gap
variable where it allows us to generate fewer particles like this:
class Generator {
constructor(width, height)
this.width = width;
this.height = height;
this.particlesArray = [];
this.gap = 5;
init(context)
const pixels = context.getImageData(0, 0, this.width, this.height).data;
for (let y = 0; y < this.height; y += this.gap)
for (let x = 0; x < this.width; x += this.gap)
const index = (y * this.width + x) * 4;
const red = pixels[index];
const green = pixels[index + 1];
const blue = pixels[index + 2];
const alpha = pixels[index + 3];
const color = `rgb($red, $green, $blue)`;
if (alpha > 0)
this.particlesArray.push(new Particle(x, y, color));
...
The difference value is equal to the pixel size. In this way, we can get the color values of every 5th pixel of the image, no more, and generate particles accordingly.
If you run it now, you see that the animation has been fixed, but it’s still wrong. Rather we will see a disruptive effect like the movie that starts from the head and ends at the toes. Let’s move on to the next chapter and do that.
Ok, so what’s the way to start the animation at the top and end it at the bottom? We should tell the pixel on top to animate immediately, and wait for the others to take their turn. The way to delay things in Javascript is to wrap them setTimeout
function, which would be too risky to use here. it’s because we just have requestAnimationFrame
, which runs on the millisecond. So, why don’t we use this function? Let’s modify some parts:
First let’s get the length of the particles Generator
,
init(context)
const pixels = context.getImageData(0, 0, this.width, this.height).data;
for (let y = 0; y < this.height; y += this.gap)
for (let x = 0; x < this.width; x += this.gap)
....
return this.particlesArray.length;
then get this total and pass it startAnimation
Feather useEffect
,
...
const particlesCount = generator.init(ctx);
startAnimation(
generator,
ctx,
canvas.width,
canvas.height,
particlesCount
);
...
Now, add a counter variable to our startAnimation
Celebration. Since we know how many particles there are, we can stop counting when we reach the total particle length.
const startAnimation = useCallback((generator, ctx, w, h, count) =>
let d = 0;
function animate()
ctx.clearRect(0, 0, w, h);
generator.draw(ctx);
generator.update(d);
if (d <= count) d++;
requestAnimationFrame(animate);
setTimeout(() => animate(), 2000);
, []);
edit at the end Generator
Update function based on counter value like this:
update(counter)
this.particlesArray.forEach((particle, index) =>
if (index < counter) particle.update();
);
In this code, we’re saying, “If the particle’s position is less than the counter value, don’t do anything because it’s not this particle’s turn yet.” Otherwise, start updating, and so on until the counter value is greater than the position. Yes, till then keep updating.
Ok, if we run the code at this point, we can see that the animation from top to bottom is the way we want. Still, the pixels start animating in the exact same order, and it looks unnatural. The whole effect looks like a kind of frayed fabric.
Now, let’s fix that and add some randomness to our code. We first start by modifying the order of the pixels. my solution is to copy particlesArray
and split them into some smaller arrays containing 50 elements or so, shuffle them individually and flatten them into a single array. The lodash library has super useful functions for this kind of operation, so let’s install it:
npm install lodash --save
then add those lines to the bottom Generator
‘s init
Celebration.
import chunk, shuffle from 'lodash';...
init(context)
...
let chunks = chunk(this.particlesArray, 50);
this.particlesArray = [];
chunks.forEach((chunk) =>
this.particlesArray.push(...shuffle(chunk));
);
return this.particlesArray.length;
We don’t want the entire array to be shuffled because we don’t want the pixels below it to be animated first. So in this way, we maintain randomness and order at the same time.
Last but not least, you may want to speed up the particles towards the end. This way, our animation can look more realistic. change counter value in startAnimation
multiplying over time.
const startAnimation = useCallback((generator, ctx, w, h, count) =>
let d = 0;
function animate()
...
if (d <= count) d += 1 + d /100;
requestAnimationFrame(animate);
...
, []);
You can experiment with the speed here with different values other than 100.
And that’s it! The final code would look like this. I’ve also added a button to reload the page and replay the effect easily.
Working with canvas and animations is a whole lot of fun. Here we learned how to create the dissolving effect by obtaining image data on a pixel by pixel basis. Once you have reconstructed the image with your own particles, you can experiment with different effects by adding some simple algorithms and values.
And don’t feel bad about Wanda. MCU always revives characters!
Till next time!