Web VFX

Canvas confetti effect (Part 3): Refactoring and testing

Welcome back to the final part of the confettie series! In this part we’ll dive into cleaning up our code, adding some tests and optimizing performance. So let’s get started.

Cleanup 🧑‍💻

I’m a huge advocate of the code philosophy of “Make it work, make it right, make it fast” so I intentionally left the code unoptimized. I wanted to show a realistic approach to coding these type of effects and how it can get messy real quick. But now that we’ve got the particles and the effect in place, let’s see where can we improve.

Our first improvement is around the constant values we’ve declared globally. While this approach works in our contained example, it’s not ideal because it makes the code harder to maintain, reduces reusability if we want to split the code into different files, and doesn’t scale well if we decide to allow users to change these values.

An easy solution is to consolidate all our configuration values into a single variable that can be accessed everywhere

const confettiConfig = {
  // Amount of particles created per click
  particleCount: 100,
  // Array of active Particle instances
  activeParticles: [],
  // Amount of frames particles should live
  lifespanFrames: 200,
  // Gravity affecting particles
  gravity: 0.06,
  // Air resistance affecting particles
  airResistance: 0.02,
  // Preset colors for confetti in RGB
  colorPresets: [
    "255, 99, 71", // Tomato (Red)
    "70, 130, 180", // Steel Blue (Blue)
    "255, 215, 0", // Gold (Yellow)
    "255, 192, 203", // Pink (Pastel Pink)
    "173, 216, 230", // Light Blue (Pastel Blue)
    "255, 218, 185", // Peach (Pastel Orange)
    "240, 128, 128", // Light Coral (Pastel Red)
  ],
  // Spread or width of the firing cone
  firingSpread: 20,
};

Don’t forget to update the rest of the code. Wherever we used one of those constants, we’ll now need to access them from this new configuration variable instead.

const radFiringSpread = confettiConfig.firingSpread * (Math.PI / 180);

Great, now that our configuration variables are clean, let’s switch our attention the Particle class. Currently, it holds too much responsability and has different conditionals to update and draw our shapes.

While this might work for now, it makes it harder to test pieces in isolation. Additionally, if we want to introduce new shapes, we’d need to add even more logic to our class. Instead, let’s clean the base Particle class and create some additional classes to extend the base one.

We can do this by identifying which attributes and code blocks are generic and can be used to all shapes. Properties like the position, velocity and lifespan are perfect for our base class. Howerver, attributes like size or radius are more specific to certain shape, so we should separate those out.

class Particle {
  constructor(options) {
    this.x = options.x;
    this.y = options.y;
    this.lifespan = 0;
    this.color = confettiConfig.colorPresets[Math.floor(Math.random() * confettiConfig.colorPresets.length)];

    const radFiringSpread = confettiConfig.firingSpread * (Math.PI / 180);
    const randomAngle = -(Math.random() * radFiringSpread) * (Math.random() < 0.5 ? -1 : 1);

    this.vy = -(Math.random() * 12 + 9);
    this.vx = Math.tan(randomAngle) * Math.abs(this.vy);

    this.rotationAngle = Math.random() * Math.PI * 2;
    this.rotationSpeed = Math.random() > 0.5 ? 0.02 : -0.02;

    this.shrinking = Math.random() < 0.5 ? true : false;
    this.sizeIncrement = 0.2;
  }
}

class Circle extends Particle {
  constructor(options) {
    super(options);

    this.radiusY = 5;
    this.radiusX = Math.floor(Math.random() * 6);
  }
}

class Rectangle extends Particle {
  constructor(options) {
    super(options);

    this.height = 10;
    this.width = Math.floor(Math.random() * 11);
  }
}

This is much more logical now. The base class encapsulates all the physics properties, while other classes inherit these and add additional properties to manage the draw and update methods.

We can also identify methods that can be reused by the shape classes and extract them, following the single responsibility principle.

class Particle {
  constructor(options) {}

  removeIfExpired() {
    if (this.lifespan > confettiConfig.lifespanFrames) {
      const index = confettiConfig.activeParticles.indexOf(this);
      if (index !== -1) {
        confettiConfig.activeParticles.splice(index, 1);
        return;
      }
    }
  }

  getFillStyle() {
    const progress = this.lifespan / confettiConfig.lifespanFrames;
    const alpha = 1 - progress;

    return `rgba(${this.color},${alpha})`;
  }

  updatePhysics() {
    // Apply gravity
    this.vy += GRAVITY;

    // Apply air resistance
    this.vx *= 1 - confettiConfig.airResistance;
    this.vy *= 1 - confettiConfig.airResistance;

    // Update position based on the velocity
    this.x += this.vx;
    this.y += this.vy;

    // Apply rotation speed
    this.rotationAngle += this.rotationSpeed;

    // Increase lifespan
    this.lifespan++;
  }

  updateSize(propertyName, maxValue) {
    if (this.shrinking) {
      this[propertyName] -= sizeIncrement;
    } else {
      this[propertyName] += sizeIncrement;
    }

    if (this[propertyName] <= 0) {
      this[propertyName] = 0;
      this.shrinking = false;
    } else if (this[propertyName] >= maxValue) {
      this[propertyName] = maxValue;
      this.shrinking = true;
    }
  }

  draw() {
    throw new Error("draw method must be implemented by subclass.");
  }

  update() {
    throw new Error("update method must be implemented by subclass.");
  }
}

With these new methods in place, we can now finish the draw and update methods for each shape.

class Circle extends Particle {
  constructor(options) {
    ...
  }

  draw() {
    context.beginPath();
    context.save();

    const centerX = this.x + 5;
    const centerY = this.y + 5;

    context.translate(centerX, centerY);
    context.rotate(this.rotationAngle);
    context.translate(-centerX, -centerY);
    context.ellipse(this.x, this.y, this.radiusX, this.radiusY, 0, 0, 2 * Math.PI);

    context.fillStyle = this.getFillStyle();
    context.fill();

    context.restore();
    context.closePath();
  }

  update() {
    this.removeIfExpired();
    this.updatePhysics();
    this.updateSize("radiusX", 5);
    this.draw();
  }
}

class Rectangle extends Particle {
  constructor(options) {
    ...
  }

  draw() {
    context.beginPath();
    context.save();

    const centerX = this.x + this.width / 2;
    const centerY = this.y + this.height / 2;

    context.translate(centerX, centerY);
    context.rotate(this.rotationAngle);
    context.translate(-centerX, -centerY);

    context.rect(this.x, this.y, this.width, this.height);

    context.fillStyle = this.getFillStyle();
    context.fill();

    context.restore();
    context.closePath();
  }

  update() {
    this.removeIfExpired();
    this.updatePhysics();
    this.updateSize("width", 10);
    this.draw();
  }
}

Now, we have a base class that manages all the physics logic, and smaller subclasses in charge of updating and drawing the particle shapes as needed. At first, this might seem like more work, but it’s actually much more scalable, and it simplifies our work later, since it’s easier to test and debug.

There’s still room for improvement, like creating a function to select the random color from the array, or even to create a more abstract function like getRandomFromArray. But I’ll leave those improvements up to you.

The key point here is to leave a better code for our future selves or anyone else who might read it down the line. You might forget how or why we are calculating the randomAngle, so it would be wise to clarify this with cleaner code or to add a comment for future reference. Remember, there’s no perfect way to do this, so don’t overcommit to cleaning and refactoring, we want to leave a better code but not to spend days on this.

Some issues are obvious and easier to fix, but others are not and will appear later when adding new features or changes, so don’t stress too much on it.

Performance 🚀

Performance is often a crucial aspect when working with effects that render multiple elements, such as confetti particles. Fortunately, our code handles a significant number of particles quite efficiently so I won’t go into much detail about performance. In future posts we’ll cover canvas performance more in depth, so stay tunned for those.

However, there are still specific calculations and minor adjustments we can make to reduce overhead and enhance performance now.

The first piece we can optimize is not necesarily a canvas specific optimization, but rather a general JS performance change. It’s how we remove particles from the canvas, or more specifically from an array.

Currently, each particle calls the removeIfExpired method during its update cycle. This method uses find and splice to remove the particle if its lifespan exceeds the limit. A more efficient approach would be to apply a filter to all particles just once after they’ve been updated. To implement this, we need to change our animationLoop function to include the filtering.

const animationLoop = () => {
  // clear canvas
  context.clearRect(0, 0, innerWidth, innerHeight);

  // update and draw particles
  for (let i = 0; i < confettiConfig.activeParticles.length; i++) {
    confettiConfig.activeParticles[i].update();
  }

  // Remove particles
  confettiConfig.activeParticles = confettiConfig.activeParticles.filter((particle) => particle.lifespan <= confettiConfig.lifespanFrames);

  // Schedule next animation frame by invoking requestAnimationFrame
  requestAnimationFrame(animationLoop);
};

We can now get rid of the removeIfExpired method, which further simplifies our base class. As mentioned earlier, I encourage you to extract this logic into a separate function to handle the particle deletion logic. This approach keeps our code cleaner and more organized.

One canvas specific optimization we can apply is to use an OffscreenCanvas fto draw our particles before rendering them onto the main canvas. This helps reducing the load of the main thread during the animation loop, which is specially useful when dealing a large number of particles.

To do this we first need to setup the OffscreenCanvas, similar to how we initialized or canvas.

const canvas = document.querySelector("canvas");
const context = canvas.getContext("2d");

// Set up an OffscreenCanvas
const offscreenCanvas = new OffscreenCanvas(canvas.width, canvas.height);
const offscreenContext = offscreenCanvas.getContext("2d");

// set canvas size
canvas.height = window.innerHeight;
canvas.width = window.innerWidth;

// add resize handler
window.addEventListener("resize", () => {
  canvas.height = window.innerHeight;
  canvas.width = window.innerWidth;
  offscreenCanvas.width = canvas.width;
  offscreenCanvas.height = canvas.height;
});

As you can see, we’ve created a new instance of the OffscreenCanvas and also modified the resize handler to change it’s size. We now need to update our Particle sub classes to use this new offscreen canvas instead of the main one during the draw method.

class Circle extends Particle {
  constructor(options) {
    ...
  }

  draw() {
    offscreenContext.beginPath();
    offscreenContext.save();

    const centerX = this.x + 5;
    const centerY = this.y + 5;

    offscreenContext.translate(centerX, centerY);
    offscreenContext.rotate(this.rotationAngle);
    offscreenContext.translate(-centerX, -centerY);
    offscreenContext.ellipse(this.x, this.y, this.radiusX, this.radiusY, 0, 0, 2 * Math.PI);

    offscreenContext.fillStyle = this.getFillStyle();
    offscreenContext.fill();

    offscreenContext.restore();
    offscreenContext.closePath();
  }
}

Now our Circle class is drawing the shape in this offscreen context, don’t forget to also update the Rectangle class.

Finally, we want to change our animation loop to clear and draw the OffscreenCanvas for each frame and then transfer the offscreen canvas’s content to the main one.

const animationLoop = () => {
  // Clear the offscreen canvas
  offscreenContext.clearRect(0, 0, canvas.width, canvas.height);

  // Update and draw each particle offscreen
  for (let i = 0; i < confettiConfig.activeParticles.length; i++) {
    confettiConfig.activeParticles[i].update();
  }

  // Clear the main canvas and draw the offscreen canvas onto it
  context.clearRect(0, 0, canvas.width, canvas.height);
  context.drawImage(offscreenCanvas, 0, 0);

  // Schedule the next frame
  requestAnimationFrame(animationLoop);
};

Perfect, we’ve now optimized our JavaScript code to be more efficient when cleaning up active particles and we’ve also made changes to our drawing process in order to minimize the performance impact on the main document’s render thread.

As a final note on performance, while I’ve showcased some potential optimizations, keep in mind that they may not always be necessary. Depending on the devices you support and the number of particles you are firing, this code might be perfectly fine. One piece of advice: avoid prematurely optimizing. Address performance issues only when they actually arise.

Testing 🕵️‍♂️

Testing is an essential part of development, unfortunately, a lot of FrontEnd work is not well tested. This might be due to the belief that it’s easier to visually test or verify the behavior of the UI. However, with so many methods and elements that interact with each other, relying only on visual tests can lead to overlooked bugs. 🐛

In order to make changes to our codebase with confidence, we’ll need to have a robust test foundation in place. I’ll be using Jest for writing our tests, but feel free to choose any other frameworks that you prefer.

We’ll start with our base Particle class. We need to ensure that its constructor and methods function as expected. Let’s begin by defining our test cases:

describe("Particle class", () => {
  describe("constructor", () => {
    it("should initialize with specified options", () => {});
  });

  describe("getFillStyle method", () => {
    it("should calculate correct fill style", () => {});
  });

  describe("updatePhysics method", () => {
    it("should update physics correctly", () => {});
  });

  describe("updateSize method", () => {
    it("should increment the size property if not shrinking", () => {});
    it("should decrement the size property if shrinking", () => {});
    it("should toggle shrinking to false when size reaches 0", () => {});
    it("should toggle shrinking to true when size reaches the maximum value", () => {});
    it("should not set size below 0 or above max value", () => {});
  });

  describe("draw and update not implemented methods", () => {
    it("should throw an error when calling draw", () => {});
    it("should throw an error when calling update", () => {});
  });
});

As you can see, we’ve outlines a very comprehensive test suite for our Particle class, covering all of it’s methods. For this I tend to prefer creating new describeblocks of each method to make the test output easier to read and to quickly identify issues when something fails.

You might be wondering how can we make sure everything works as expected, given the amount of random values we generate on the constructor. One simply way to make our tests more deterministic is by mocking the Math.random function and specifyint the value we want it to return before each test runs. This approach allow us to control the environment and test different scenarios.

Math.random = jest.fn();

describe("Particle class", () => {
  beforeAll(() => {
    Math.random.mockReturnValue(0.5);
  });
}

Great, now that we’ve made our “random” numbers more predictable, we can begin working on the actual tests. Let’s dive into what the first test for the constructor might look like:

it("should initialize with specified options", () => {
  const particle = new Particle({ x: 100, y: 150 });

  expect(particle.x).toBe(100);
  expect(particle.y).toBe(150);
  expect(particle.color).toBe("0,255,0"); // Middle value from confettiColorPresets
  expect(particle.vy).toBeCloseTo(-10.5);
  expect(particle.vx).toBeCloseTo(0);
  expect(particle.rotationAngle).toBeCloseTo(Math.PI);
  expect(particle.rotationSpeed).toBeCloseTo(0.02);
  expect(particle.shrinking).toBeTruthy();
});

We are making sure all of the properties are being initialized with the expected values. It’s important to note that we are using the toBeCloseTo Jest method. This is important when dealing with floating-point numbers, since they can introduce slight rounding errors that could make our assertions fail.

Now, let’s see how to test other methods lik updatePhysics. You might think it’s harder to test since we don’t know the value of some of the properties, but it’s actually fairly easy to do so.

describe("updatePhysics", () => {
  it("should update physics correctly", () => {
    const particle = new Particle({ x: 0, y: 0 });
    particle.vx = 5;
    particle.vy = 5;

    particle.updatePhysics();
    expect(particle.vx).toBeCloseTo(4.5);
    expect(particle.vy).toBeCloseTo(5.6); // 5 + GRAVITY - 10% air resistance
    expect(particle.x).toBeCloseTo(4.5);
    expect(particle.y).toBeCloseTo(5.6);
  });
});

As you can see, testing this method isn’t difficult. First, we a new particle, then update its X and Y velocities, and finally, we verify that the values have been updated as expected.

We can use a similar approach to test other methods, we can freely update or create instance values and trigger the method we want. Let’s see another example but for the updateSize

it("should increment the size property if not shrinking", () => {
  const particle = new Particle({ x: 0, y: 0 });
  const propertyName = "size";
  particle[propertyName] = 5 10; // mocked property
  particle.shrinking = false;
  const maxValue = 10;

  article.updateSize(propertyName, maxValue);
  expect(particle[propertyName]).toBe(5.2);
});

We’ve created a new particle and added a property called size that we can then pass to the updateSize method and assert its value afterwards. Let’s add the missing tests following this same logic.

it("should decrement the size property if shrinking", () => {
  const propertyName = "size";
  particle[propertyName] = 5;
  particle.shrinking = true;
  const maxValue = 10;

  particle.updateSize(propertyName, maxValue);
  expect(particle[propertyName]).toBe(4.8);
});

it("should toggle shrinking to false when size reaches 0", () => {
  const propertyName = "size";
  particle[propertyName] = 0.1;
  particle.shrinking = true;
  const maxValue = 10;

  particle.updateSize(propertyName, maxValue);
  expect(particle[propertyName]).toBe(0);
  expect(particle.shrinking).toBe(false);
});

it("should toggle shrinking to true when size reaches the maximum value", () => {
  const propertyName = "size";
  particle[propertyName] = 9.9;
  particle.shrinking = false;
  const maxValue = 10;

  particle.updateSize(propertyName, maxValue);
  expect(particle[propertyName]).toBe(10);
  expect(particle.shrinking).toBe(true);
});

it("should not set size below 0 or above max value", () => {
  const propertyName = "size";
  particle[propertyName] = -0.1; // Testing below 0
  particle.shrinking = true;
  const maxValue = 10;

  particle.updateSize(propertyName, maxValue);
  expect(particle[propertyName]).toBe(0);

  particle[propertyName] = 10.1; // Testing above max
  particle.shrinking = false;
  particle.updateSize(propertyName, maxValue);
  expect(particle[propertyName]).toBe(10);
});

We now have a very robust test suit for our updateSize method. For the sake of brevity, I won’t list all the tests here. If you’re interested in seeing the full test file, you can check the final code here. However, I encourage you to play around and try writing the missing ones yourself and also adding new cases for the other shape classes.

As you can see, adding tests and verifying behavior is quite straightforward. Now, whenever you make changes to the code, you can be more confident that other parts aren’t breaking because of it.

With our tests now in place, we’ve concluded the last part of this series. We’ve come a long way since the first one, where we created the basic structure to fire confetti, through the second part where we added more realism to the effect, and finally to this final one where we cleaned up the code, optimized some parts, and added tests.

I hope you’ve learned something valuable during this series and now understand how to tackle these effects for your own projects.

As always, you can find the code for this part here

🎉 Don’t hesitate to sprinkle some confetti in your future projects and have fun! 🎉

#Javascript #Canvas #Beginner #Test