Avengers Snap Effect With Canvas and React | by Burak Erdem | Dec, 2022

Let’s make another Marvel character disappear!

Image from Avengers: Infinity War

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 Generatoradd 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:

what!??

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:

Now we’re going somewhere!

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 Generatorpass 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 GeneratorKey’s draw and update method, which essentially calls ParticleDraw 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, vFactorWhich 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 GeneratorUpdate 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!

Leave a Reply