[2D 메타볼 애니메이션 구현] 5. 캔버스 애니메이션 실행하기

young_pallete·2023년 3월 24일
0
post-thumbnail
post-custom-banner

🔗 이 포스트에 대해 더 궁금하신가요? 다음 주소를 참고해주세요!

🗒️ 이 글의 수정 내역 (마지막 수정 일자: 23.03.27)

  • initializeMetaballs의 분기처리를 수정했어요. (else ifif)

혹시나 따라오는 데 오류가 있으시다면, 위 URL의 레포지토리의 PR 및 코드들을 살펴봐주세요!

requestAnimationFrame

자, 이제 본격적으로 캔버스 애니메이션을 동작시켜볼 거에요.
일단 먼저 window.requestAnimationFrame을 알아보아야 합니다.
이 함수는 말 그대로 프레임을 요청합니다. 그리고 이를 호출하면, 1프레임을 실행한 상태 변경을 만들어내죠.

이 글은 메타볼 애니메이션 구현과 리팩토링하는 것이 주가 되는 글이기에, 설명은 생략하겠습니다. 자세한 것은 MDN - Window.requestAnimationFrame()을 참고해주세요.

우리는 이것을 캔버스에서 실행함으로써, 애니메이션을 구동할 거에요.
다음과 같이 2번 시리즈에서 만들어낸 Canvas 클래스를 수정해주세요.

간단히 설명 드리자면 mountrender, animate 코드가 중요합니다.

  mount($target: Element) {
    $target.appendChild(this.$canvas);

    this.render();

    if (!this.options?.pause && this.options?.autoplay) {
      this.animate();
    }
  }

  render() {
    this.draw(this.canvasGradient);

    this.metaballAnimationSubject.notify();
  }

  animate() {
    if (this.options?.pause) return;

    this.render();

    requestAnimationFrame(this.animate.bind(this));
  

설정된 옵션에 따라서 mount render animate를 진행해주는데요.

이때, animate 안에는 또 requestAnimationFrame의 콜백으로 같은 함수가 호출됩니다.
이를 통해, 재귀적으로 실행하며 무한하게 애니메이션을 호출할 수 있는 거에요.

그런데 이것이 싫을 수 있잖아요?! 그럴 때를 대비하여 메타 정보로 optionspause를 추가해주었어요. 이렇게 하면 애니메이션은 원할 때 해당 property를 변경하여 끌 수 있겠죠. 😉

잘 동작하는지 metaballs의 moveAll 메서드에 콘솔을 넣어 확인해보죠!

export class StaticMetaballs implements Metaballs<StaticMetaball> {
  // ...

  moveAll() {
    /* eslint-disable-next-line no-console */
    console.log('moveAll!');
  }
}
export class DynamicMetaballs implements Metaballs<DynamicMetaball> {
  // ...

  moveAll() {
    /* eslint-disable-next-line no-console */
    console.log('moveAll!');
  }
}

윽! 아직 동작하지 않죠? 😖
정상입니다. 아직 메타볼에 대한 초기값을 넣어주지 않았기 때문이에요.

메타볼 정보 넣어서 초기화하기

자 그러면 이제 메타볼의 속성들을 세팅해줘서 정상적으로 동작하는지를 보겠습니다! 🙆🏻

먼저 App.ts에 다음과 같이 맨 아래에 코드를 넣어 호출해볼게요.


const app = new MetaballAnimation({
  canvas: new MetaballCanvas({
    gradients: ['#123141', '#235234'],
    width: 400,
    height: 400,
    type: ECanvasGradientType.linear,
    options: {
      autoplay: true,
    },
  }),
  dataset: {
    static: [{x: 30, y: 100, r: 20}],
    dynamic: [
      {
        x: 30,
        y: 100,
        r: 20,
        v: {x: 0.1, y: 0.1},
        vWeight: 1,
      },
    ],
  },
});

app.mount($target);

여기서 dataset이라는 property가 추가되었네요. 이 친구가 세팅되는 순간, staticdynamic 메타볼들이 만들도록 호출될 거에요.

사실 Canvas라는 걸 생성할 때 만들어줄까 생각해보았어요.
하지만 가장 구체적인 클래스가 데이터를 가지고, 하위 클래스들에게 메서드를 통해 전달하는 것이 하위 클래스와 데이터에 대한 결합성을 낮추고, 좀 더 유연하게 가져갈 수 있다고 생각하여 다음과 같이 설계했습니다. 😉

그러면 이를 동작시키기 위한 코드를 구현하면 되겠죠?
나머지를 입력해주죠.


export class MetaballAnimation implements CanvasAnimation {
  public canvas: CanvasAnimation['canvas'];

  public dataset: IMetaballDataset;

  constructor({
    canvas,
    dataset,
  }: {
    canvas: CanvasAnimation['canvas'];
    dataset?: IMetaballDataset;
  }) {
    this.canvas = canvas;

    this.dataset = dataset ?? {
      static: [],
      dynamic: [],
    };

    if (this.dataset?.static?.length || this.dataset?.dynamic?.length) {
      this.initializeMetaballs(this.dataset);
    }
  }

  initializeMetaballs(dataset: IMetaballDataset) {
    this.canvas.initializeMetaballs(dataset);
  }
  
  // ... 생략
}

자. 그러면 우리는 initializeMetballs라는 메서드를 options가 시작할 때 데이터가 들어와 있으면 호출해주죠?!

이제 canvas에서는 어떻게 써야 할까요? 한 번 살펴봅시다.

// Canvas.ts

export class MetaballCanvas implements GradientCanvas {
  $canvas: GradientCanvas['$canvas'];

  ctx: GradientCanvas['ctx'];

  type: GradientCanvas['type'];

  width: GradientCanvas['width'];

  height: GradientCanvas['height'];

  gradients: GradientCanvas['gradients'];

  metaballAnimationSubject: AnimationSubject;

  staticMetaballsFactory: StaticMetaballsFactory;

  dynamicMetaballsFactory: DynamicMetaballsFactory;

  options: GradientCanvas['options'];

  constructor({
    type,
    width,
    height,
    gradients,
    options,
  }: Omit<
    GradientCanvas,
    | '$canvas'
    | 'ctx'
    | 'render'
    | 'mount'
    | 'draw'
    | 'getLinearGradient'
    | 'getRadialGradient'
  >) {
    this.$canvas = document.createElement('canvas');

    this.ctx = this.$canvas.getContext('2d') as CanvasRenderingContext2D;

    this.type = type;

    this.width = width;
    this.height = height;

    this.gradients = gradients;

    this.metaballAnimationSubject = new AnimationSubject({ctx: this.ctx});

    this.staticMetaballsFactory = new StaticMetaballsFactory();
    this.dynamicMetaballsFactory = new DynamicMetaballsFactory();

    this.options = options ?? {
      radialGradient: {r0: 0, r1: 0},
      autoplay: false,
      pause: false,
    };
  }
    
  initializeMetaballs(dataset: IMetaballDataset) {
    if (dataset.static) {
      const staticMetaballs = this.staticMetaballsFactory.create({
        options: {
          ctx: this.ctx,
          data: dataset.static,
        },
      });
      
      this.metaballAnimationSubject.subscribe(
        new StaticMetaballsObserver(staticMetaballs, 'static'),
      );
    } 
    
    if (dataset.dynamic) {
      const dynamicMetaballs = this.dynamicMetaballsFactory.create({
        options: {
          ctx: this.ctx,
          data: dataset.dynamic,
        },
      });

      this.metaballAnimationSubject.subscribe(
        new DynamicMetaballsObserver(dynamicMetaballs, 'dynamic'),
      );
    }
  }
    
  // ...
}

이제 여기서 Factory들을 쓰게 되는군요!
이 친구들을 덕분에 일련의 생성 로직들을 생각하지 않고 팩토리 메서드만으로 만들어내니 굉장히 코드가 간결해졌죠?

이후 subscribe 내부에서 옵저버를 생성함과 동시에 달아주는군요! 어썸합니다 🚀

자. 그러면 이제 결과를 볼까요?

오! 잘 작동되는군요.

결과 코드

아무래도 긴 글이다 보니 따라오기 쉽지 않았을 것 같아요.
결과 코드를 공유 드리며, 이마저도 안된다면, commit을 살펴봐주세요!

Canvas

export class MetaballCanvas implements GradientCanvas {
  $canvas: GradientCanvas['$canvas'];

  ctx: GradientCanvas['ctx'];

  type: GradientCanvas['type'];

  width: GradientCanvas['width'];

  height: GradientCanvas['height'];

  gradients: GradientCanvas['gradients'];

  metaballAnimationSubject: AnimationSubject;

  staticMetaballsFactory: StaticMetaballsFactory;

  dynamicMetaballsFactory: DynamicMetaballsFactory;

  options: GradientCanvas['options'];

  constructor({
    type,
    width,
    height,
    gradients,
    options,
  }: Omit<
    GradientCanvas,
    | '$canvas'
    | 'ctx'
    | 'render'
    | 'mount'
    | 'draw'
    | 'getLinearGradient'
    | 'getRadialGradient'
  >) {
    this.$canvas = document.createElement('canvas');

    this.ctx = this.$canvas.getContext('2d') as CanvasRenderingContext2D;

    this.type = type;

    this.width = width;
    this.height = height;

    this.gradients = gradients;

    this.metaballAnimationSubject = new AnimationSubject({ctx: this.ctx});

    this.staticMetaballsFactory = new StaticMetaballsFactory();
    this.dynamicMetaballsFactory = new DynamicMetaballsFactory();

    this.options = options ?? {
      radialGradient: {r0: 0, r1: 0},
      autoplay: false,
      pause: false,
    };
  }

  initializeMetaballs(dataset: IMetaballDataset) {
    if (dataset.static) {
      const staticMetaballs = this.staticMetaballsFactory.create({
        options: {
          ctx: this.ctx,
          data: dataset.static,
        },
      });
      this.metaballAnimationSubject.subscribe(
        new StaticMetaballsObserver(staticMetaballs, 'static'),
      );
    } 
    
    if (dataset.dynamic) {
      const dynamicMetaballs = this.dynamicMetaballsFactory.create({
        options: {
          ctx: this.ctx,
          data: dataset.dynamic,
        },
      });

      this.metaballAnimationSubject.subscribe(
        new DynamicMetaballsObserver(dynamicMetaballs, 'dynamic'),
      );
    }
  }

  getLinearGradient() {
    const result = this.ctx.createLinearGradient(0, 0, 0, this.height);

    this.gradients.forEach((gradient, idx) => {
      result.addColorStop(idx, gradient);
    });

    return result;
  }

  getRadialGradient({r0 = 0, r1 = 0}: IRadialGradientOptions) {
    const result = this.ctx.createRadialGradient(0, 0, r0, 0, this.height, r1);

    this.gradients.forEach((gradient, idx) => {
      result.addColorStop(idx, gradient);
    });

    return result;
  }

  get canvasGradient(): CanvasGradient {
    switch (this.type) {
      case ECanvasGradientType.linear: {
        return this.getLinearGradient();
      }
      case ECanvasGradientType.radial: {
        return this.getRadialGradient(
          this.options?.radialGradient ?? {r0: 0, r1: 0},
        );
      }
      default: {
        return this.getLinearGradient();
      }
    }
  }

  draw(background: CanvasGradient) {
    this.ctx.clearRect(0, 0, this.width, this.height);

    this.ctx.fillStyle = background;

    this.ctx.fillRect(0, 0, this.width, this.height);
  }

  mount($target: Element) {
    $target.appendChild(this.$canvas);

    this.render();

    if (!this.options?.pause && this.options?.autoplay) {
      this.animate();
    }
  }

  render() {
    this.draw(this.canvasGradient);

    this.metaballAnimationSubject.notify();
  }

  animate() {
    if (this.options?.pause) return;

    this.render();

    requestAnimationFrame(this.animate.bind(this));
  }
}

App

import {MetaballCanvas} from './Canvas';

import {ECanvasGradientType, IMetaballDataset} from './types';

export abstract class CanvasAnimation {
  abstract canvas: MetaballCanvas;

  abstract mount($target: Element): void;

  abstract render(): void;
}

export class MetaballAnimation implements CanvasAnimation {
  public canvas: CanvasAnimation['canvas'];

  public dataset: IMetaballDataset;

  constructor({
    canvas,
    dataset,
  }: {
    canvas: CanvasAnimation['canvas'];
    dataset?: IMetaballDataset;
  }) {
    this.canvas = canvas;

    this.dataset = dataset ?? {
      static: [],
      dynamic: [],
    };

    if (this.dataset?.static?.length || this.dataset?.dynamic?.length) {
      this.initializeMetaballs(this.dataset);
    }
  }

  initializeMetaballs(dataset: IMetaballDataset) {
    this.canvas.initializeMetaballs(dataset);
  }

  mount($target: Element) {
    const $metaballAnimation = document.createElement('div');
    $metaballAnimation.className = 'metaball-animation';

    $target.appendChild($metaballAnimation);

    this.canvas.mount($metaballAnimation);
  }

  render() {
    this.canvas.render();
  }
}

const $target = document.body;

const app = new MetaballAnimation({
  canvas: new MetaballCanvas({
    gradients: ['#123141', '#235234'],
    width: 400,
    height: 400,
    type: ECanvasGradientType.linear,
    options: {
      autoplay: true,
    },
  }),
  dataset: {
    static: [{x: 30, y: 100, r: 20}],
    dynamic: [
      {
        x: 30,
        y: 100,
        r: 20,
        v: {x: 0.1, y: 0.1},
        vWeight: 1,
      },
    ],
  },
});

app.mount($target);

🎉 마치며

생각보다 깔끔한 설계란 어려운 것 같아요.
저 역시 이번에 리팩토링을 한 번 거칠 수밖에 없었습니다. 데이터를 전달하는 로직에 대한 착각들이 주였어요.

그렇기에 이번에 깨달은 건, data를 실제로 handle하는 상황을 미리 만든 다음, 이를 구현시킬 수 있도록 짜는 방식으로 해야겠다는 것을 깨달았어요.

그런데 정말 뿌듯한 경험을 했어요.
확실히 의존성을 느슨하게 결합하면서 디자인 패턴을 토대로 구현하니, 각 컴포넌트의 변경에 따라 다른 컴포넌트의 변경이 현저히 줄어들었다는 점입니다.

이게 정말 좋은 설계를 통해 만들어낸 코드의 매력이 아닐까요?
싶으며 혼자 만족하고 취하고(...) 이만 마칩니다. 🤣
이제 모든 설계는 끝났네요. 다음부터는 본격적으로 메타볼들을 만들어보죠! 이상 🌈

profile
People are scared of falling to the bottom but born from there. What they've lost is nth. 😉
post-custom-banner

0개의 댓글