Creative Shorts

Creative Shorts #1 – Demystifying Our Hero Animation

2024-05-21

9 min reading

Bartosz Słysz

Software Engineer

Assumptions and objectives

The desired outcome we aim to achieve is a defined shape constructed from individual particles. These particles will be set into free motion, gradually fading in color over time. This will result in an interactive animation that portrays a specific figure and imbues it with "life".

Let's start with a ring

A circle is one of the most fundamental shapes in the context of mathematics. Breaking it down into individual points, they can be defined as follows:

  • the distance of each point from the center should be R
  • the angular distance between each point should be (2 * π) / N, where N represents the number of points comprising the circle shape

To determine the coordinates of individual points, you need to add to the center coordinates calculated based on trigonometric functions - sine and cosine. :point_down:

Let's assume that R equals 100, and the number of points we want to obtain is 400. Then each of the points is distant from each other by (2 * π) / 400 radians, and the position of each is:

AD = (2 * π) / 400)
Xₙ = cos(n * AD) * 100
Yₙ = sin(n * AD) * 100

And there we go! That's how we've generated the necessary number of points to kickstart our circle figure. Not so tough, huh?

Why don't we shape it up

We've conquered the circle, so it's time to level up in this game. Let's tackle the company logo now (which, by the way, underwent a small rebranding that breathed new life into it).

To break down the logo into points, we need to grasp how vector graphic elements work – specifically, the SVG format. Primarily, shape definitions consist of paths defined by a set of commands, saved as the [d] attribute of the <path> element.

An example of [d] definition looks as follows:

<svg viewBox="0 0 190 160" xmlns="http://www.w3.org/2000/svg">
  <path d="M 10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80" fill="#000" />
</svg>

So, you could say that different commands take different numbers of arguments, which, depending on their type, have various meanings, e.g. the M command – move, accepts arguments that change the position of the cursor responsible for drawing the shape. The rest of the commands, along with their descriptions, are available here.

Unfortunately, life is an ongoing battle, and so it is in this case as well. We currently face two problems:

  • the first is isolating only the outer shape, without any paths inside the figure
  • the second is performing interpolation to translate the defined [d] parameter into actual points

To address the first issue, we need to transform the logo to contain only the outer stroke. In our case, a single path defined the entirety, featuring three cursor position change commands. Removing all elements starting with the second M operation resulted in the path having only an outer outline – problem solved!

The solution to the second problem can be achieved with a simple script written in Python. By utilizing the svgpathtools library, we can perform linear interpolation of points, allowing us to reach points spaced at a constant distance from each other, fully resolving the problem outlined at the beginning of the chapter.

The script looks as follows:

from svgpathtools import svg2paths, parse_path
import numpy as np

# Convert SVG path string to Path object
svg_path = "M95.4326 16.8268C97.7839 ..."
path = parse_path(svg_path)

# Sample points along the path
num_points = 400
samples = np.linspace(0, 1, num_points)
points = [path.point(t) for t in samples]

# Print points
print(";".join([f"{point.real},{point.imag}" for point in points]))

And another problem bites the dust. We got all the required shape points, let's keep it rolling!

Breathing life into dreary web pages

Among the array of APIs available in browsers, the Canvas API definitely falls into the more intriguing category. It allows for imperative JavaScript code to manipulate bitmap operations. You have control over every single pixel rendered by the browser, making the only limitation to various animations solely our imagination.

Very often, animations of various kinds leverage physics, not necessarily its complex elements. For instance, giving an element a constant velocity requires knowledge of its initial position and the time difference from the start of the cycle. This is where the requestAnimationFrame function comes into play, responsible for executing the passed callback with argument t. This argument defines a timestamp, indicating the passage of time in our realm.

For example, animating a square from left to right using requestAnimationFrame might look like this:

const VELOCITY_IN_PIXELS_PER_SECOND = 32;

const rectPosition = { x: 0, y: 0 };
let lastTimestamp = performance.now();

const renderFrameRecursively = (currentTimestamp: number) => {
  const timeDelta = currentTimestamp - lastTimestamp;
  lastTimestamp = currentTimestamp;

  rectPosition.x += timeDelta * VELOCITY_IN_PIXELS_PER_SECOND;

  // TOODO: render the rect
};

requestAnimationFrame(requestAnimationFrame);

It's worth noting that depending on the hardware used, available computational power, and browser settings, the frequency of executing functions with requestAnimationFrame may vary. However, typically, the function is executed at 60 or 30 times per second.

Let's level it up with React!

The code in the above article referred to the pure JavaScript environment, which nowadays is exceptionally rare. The vast majority of applications are powered by the most popular libraries and frameworks such as React, Vue, or Angular. Let's dive into the first one, utilize available APIs to harness the requestAnimationFrame function in practice.

Let's start by focusing on point preparation. The SVG file, whose path was passed to the svgpathtools tool, had dimensions of 100x40. This means they are the original reference for our figure. In the case of scaling it to a specific area, attention should be paid to properly shifting it, as well as adding an additional offset (so that animations do not exceed the allowable area. Such translation was performed by the following script:

const LOGO_OFFSET = 20;
const ORIGINAL_LOGO_WIDTH = 100;
const ORIGINAL_LOGO_HEIGHT = 40;
const HEIGHT_RATIO = ORIGINAL_LOGO_HEIGHT / ORIGINAL_LOGO_WIDTH;
const TARGET_WIDTH = 600;
const TARGET_HEIGHT = TARGET_WIDTH  *  HEIGHT_RATIO;

// for the presentation, I just picked the first 3 points out of 400
const POINTS_AS_STRING = "4.805,22.31;5.125,22.03;5.475,21.74";

const NUMERIC_POINTS = POINTS_AS_STRING.split(";").map((points) => points.split(",").map(Number));
const SCALED_POINTS = NUMERIC_POINTS.map(([x, y]) => {
  const xFraction = x / ORIGINAL_LOGO_WIDTH;
  const yFraction = y / ORIGINAL_LOGO_HEIGHT;
  const scaledX = xFraction * (TARGET_WIDTH - LOGO_OFFSET * 2) + LOGO_OFFSET;
  const scaledY = yFraction * (TARGET_HEIGHT - LOGO_OFFSET * 2) + LOGO_OFFSET;

  return [scaledX, scaledY];
});

Cool, now let's define the Particle class, which will be responsible for representing a single point. Its goal will be to define behavior in such a way that it "wanders" across the bitmap at a random pace and fades out, i.e. loses the alpha channel of the drawn color over time. Additionally, it will define the way of drawing a single point on the "dirty" bitmap.

const SPEED_FACTOR = 0.1;
const VELOCITY_MULTIPLIER = 0.05;

class Particle {
  private colorDensity = 0.2;
  private rgbColor = [235, 235, 235];
  private velocity = { x: 0, y: 0, min: 3, max: 5 };
  private densityMultiplier = 0.995;

  constructor(private x, private y) {}

  getRandomForce() {
    const FORCE_SPAN = 0.25;

    return Math.random() * (FORCE_SPAN * 2) - FORCE_SPAN;
  }

  update(): void {
    const axes = ["x", "y"] as const;

    axes.forEach((axis) => {
      const forceDirection = this.getRandomForce();
      const newVelocity = this.velocity[axis] + forceDirection;

      if (Math.abs(newVelocity) < this.velocity.max) {
        this.velocity[axis] = newVelocity;
      }
    });

    this.x += this.velocity.x * SPEED_FACTOR;
    this.y += this.velocity.y * SPEED_FACTOR;

    axes.forEach((axis) => {
      if (Math.abs(this.velocity[axis]) > this.velocity.min) {
        this.velocity[axis] *= VELOCITY_MULTIPLIER;
      }
    });

    this.colorDensity *= this.densityMultiplier;
  }

  render(context: CanvasRenderingContext2D): void {
    const rectSize = 1.5;
    const { x, y } = this;

    context.fillStyle = `rgba(${this.rgbColor.join(",")},${this.colorDensity})`;
    context.fillRect(x - rectSize / 2, y - rectSize / 2, rectSize, rectSize);
  }
}

Great, now that we have the points ready and a class responsible for the physics of a single point, the next step is to write a React component that will be responsible for defining the points at the right moment in such a way that the entire animation becomes dynamic.

The desired outcome we want to achieve is for the points to appear evenly from both sides – at the top from the left side and at the bottom from the right side. In the discussed scenario, the points are evenly distributed, meaning the quantity in the upper curve is the same as in the lower one. This allows for alternate appearance of particles from each side for each rendered frame, resulting in a smooth appearance of points.

export function Animation() {
  const ref = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvasElement = ref.current!;
    const canvasContext = canvasElement.getContext("2d")!;
    const particles: Particle[] = [];
    let lastFrameId = -1;
    let rendererIndex = 0;

    const renderRecursively = () => {
      lastFrameId = requestAnimationFrame(renderRecursively);

      if (rendererIndex < SCALED_POINTS.length) {
        rendererIndex++;
        const leftIndex = particles.length;
        const rightIndex = SCALED_POINTS.length / 2 + leftIndex;
        const index = leftIndex % 2 === 0 ? leftIndex : rightIndex;

        if (index >= 0 && index < SCALED_POINTS.length) {
          const point = SCALED_POINTS[index];

          particles.push(new Particle(point[0], point[1]));
        }
      }

      particles.forEach((particle) => {
        particle.update();
        particle.render(canvasContext);
      });
    };

    lastFrameId = requestAnimationFrame(renderRecursively);

    return () => {
      cancelAnimationFrame(lastFrameId);
    };
  }, []);

  return <canvas ref={ref} width={TARGET_WIDTH} height={TARGET_HEIGHT} />;
}

And boom – our animation has gained dynamism, depicting the company logo as lively, wandering particles that fade away over time. Pretty neat, isn't it?

The animation outcome we've achieved in this article represents just a small glimpse of the extensive iteration process we've undergone. If you're curious to explore more of our creative samples and delve deeper into our journey, be sure to check out this link!

Recap

  • Thanks to the available API, we're able to craft applications where animations are only limited by our imagination
  • Maths serves as a crucial component in the construction of any sophisticated animation
  • JavaScript is the go-to language that seamlessly maneuvers through the creation of goodies like animations and direct bitmap manipulation
  • Canvas empowers us to control every single pixel that's rendered by the browser
  • Understanding how SVGs operate allows us to break them down into their prime components and cleverly utilize their desired elements
  • requestAnimationFrame function enables the implementation of physics in our crafted animations