Simulating 3D Motion With Sine Waves
Lines stacked on each other giving the appearance of three dimensionality.

Simulating 3D Motion With Sine Waves

Check out this post on my blog, where all the animations are generated dynamically and there's limited interactivity

I recently saw a graphic on a website that showed a number of sinewaves stacked in such a way that they created a 3D depth of field effect. As the staunchly anti-3D proponent that I am, I was curious if I could replicate such an effect using only 2D components. As such, I got to work creating a rendering system for drawing sine waves on canvas. Below, you can see three sinewaves with the same frequency, but different amplitudes and colors.

Article content
Three sine waves


These waves were created using a simple class I wrote:


class SineWave {
    angularFrequency: number;
    amplitude: number;
    phase: number;
    color: string;
    constructor({
        frequency,
        amplitude,
        phase,
        color,
    }: {
        frequency: number;
        amplitude: number;
        phase: number;
        color: string;
    }) {
        this.angularFrequency = frequency * 2 * Math.PI;
        this.amplitude = amplitude;
        this.phase = phase;
        this.color = color;
    }
    getYFromX(x: number): number {
        return this.amplitude * Math.sin(this.angularFrequency * x + this.phase);
    }
    }        

and were then rendered using a simple component:


import { useRef, useEffect } from "react";
import { SineWave } from "./SineWave";

export const SineWaveChart = (props: {
  waves: SineWave[];
  startFull?: boolean;
  fullWidth?: boolean;
  height?: number;
  width?: number;
  step?: number;
}) => {
  const { waves, startFull, fullWidth, height, width, step } = props;

  const canvasRef = useRef(null);
  const frameRef = useRef(null);

  function drawWaves(
    waves: SineWave[],
    context: CanvasRenderingContext2D,
    x: number,
    xOffset: number,
    step: number
  ) {
    if (
      context.canvas.width !=
      (fullWidth ? window.innerWidth : window.innerWidth * 0.8)
    ) {
      context.canvas.width = fullWidth
        ? window.innerWidth
        : window.innerWidth * 0.8;
    }
    context.clearRect(0, 0, context.canvas.width, context.canvas.height);
    var yOffset = context.canvas.height / 2;
    for (let i = waves.length - 1; i >= 0; i--) {
      let wave = waves[i];
      let currentX = 0,
        currentY = yOffset;

      context.strokeStyle = wave.color;
      context.beginPath(); 
      context.moveTo(0, yOffset + wave.getYFromX(0 + xOffset, xOffset)); 

      while (currentX < x - xOffset) {
        currentX += step ?? 4;
        currentY = yOffset + wave.getYFromX(currentX + xOffset, xOffset);
        context.lineTo(currentX, currentY);
      }
      context.stroke(); 
    }

    if (x < context.canvas.width) {
      x += 1;

      requestAnimationFrame(() => drawWaves(waves, context, x, xOffset));
    } else {
      x += 1;

      xOffset = x - context.canvas.width;

      frameRef.current = requestAnimationFrame(() =>
        drawWaves(waves, context, x, xOffset)
      );
    }
  }
  useEffect(() => {
    const canvas = canvasRef.current;
    canvas.width = fullWidth ? window.innerWidth : window.innerWidth * 0.8;
    const context = canvas.getContext("2d");
    frameRef.current = requestAnimationFrame(() =>
      drawWaves(waves, context, startFull ? canvas.width : 0, 0, step)
    );
    return () => cancelAnimationFrame(frameRef.current);
  });
  return <canvas ref={canvasRef} width={300} height={height ?? 400} />;
};
        

The example shown above is a very simple use case made with sine waves defined by hand. Below is an example of sine waves created programmatically. Each wave is identical to the one that comes before it in the array of waves, but the amplitude is incremented by 5 and the color is made slightly less opaque.

Article content
Animation of sine waves stacked on each other, their amplitude and color varied.


By further changing the phase, color and amplitude of each wave, we can create just a little bit of depth.

Article content
More waves, but with phase, color and amplitude all varied.


With enough customization to phase, amplitude, and color we get something that looks nice. But these graphics all use static values for a wave's characteristics - what if we refactor our SineWave class to allow for dynamic characteristics?

type WaveCharacteristicFunction = (x: number, xOffset: number) => number;
class SineWave {
  angularFrequency: WaveCharacteristicFunction;
  amplitude: WaveCharacteristicFunction;
  phase: WaveCharacteristicFunction;
  color: string;
  yOffset: WaveCharacteristicFunction;
  constructor({
    frequency,
    amplitude,
    phase,
    color,
    yOffset,
  }: {
    frequency: number | WaveCharacteristicFunction;
    amplitude: number | WaveCharacteristicFunction;
    phase: number | WaveCharacteristicFunction;
    color: string;
    yOffset: number | WaveCharacteristicFunction;
  }) {
    this.angularFrequency =
      frequency instanceof Function
        ? frequency
        : () => {
            return frequency * 2 * Math.PI;
          };
    this.amplitude =
      amplitude instanceof Function ? amplitude : () => amplitude;
    this.phase = phase instanceof Function ? phase : () => phase;
    this.color = color;
    this.yOffset = yOffset instanceof Function ? yOffset : () => yOffset;
  }
  getYFromX(x: number, xOffset: number): number {
    return (
      this.amplitude(x, xOffset) *
        Math.sin(
          this.angularFrequency(x, xOffset) * x + this.phase(x, xOffset)
        ) +
      this.yOffset(x, xOffset)
    );
  }
}
        

Now we've got a way to modulate these characteristics based on a time variable (essentially represented by x and xOffset, whose difference corresponds to a pixel location on the chart). Here's what the last chart looks like, except with

(x, xOffset) => {
          return Math.sin((x - xOffset) / c) * 20 + i;
        }        

giving us our amplitude value (i denotes the wave's 0-index position in the chart, i.e. the third wave has an i of 2. c denotes the coefficient of x, used to control the period of the sine function). The wave in black is the amplitude distortion that is applied to each wave:

Article content
More dynamic waves.


It gives a nice bit of motion to the chart, doesn't it? But a simple sine function gets boring after a repetition or two. Let's see what things looks at when we use a function with a little more variability, by having the value previously represented by c change based on our x variable :

(x, xOffset) => {
          return Math.sin((x - xOffset) / (40 + Math.sin((x) / 10))) * 20 + i * 4;
        }        
Article content
The final result.


Take a look at justinebert.com for more code like this, and also to see a nice implementation of this as the background of my homepage!

To view or add a comment, sign in

Explore content categories