Home

Simulating 3D motion with sine waves


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.

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.

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

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 - try using the slider to change the value of c:

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;
        }

Nice!