본문 바로가기

Gen Art

[Javascript] 폭죽 이펙트 (fireworks)

 

 

 

Intro

한동안 web3쪽 공부만 하다보니, 이펙트 구현 욕구가 스멀스멀 밀려오길래 만들어 봤습니다 :)

간만에 만들고 이것저것 취향에 맞게 건들이다보니 이번 휴일은 순삭이었습니다.. (흑흑)

 

 

 

Intuition

해당 이펙트의 무브먼트를 생각해보면 사실 크게 어려운건 없습니다.

 

1. viewport 하단에서 폭죽이 뿅 쏘아올려집니다.

2. 최고점에 도달할 시, 사방 팔방 파편이 튕겨 나가집니다.

 

이 정도입니다.

 

 

Method

 

구현하기 위해 3가지 클래스를 만들었습니다. (정확히는 1가지 더 있는데, 얘는 이따 설명하겠습니다)

 

1. Firework - 말 그대로 폭죽 이펙트 하나에 대응하는 클래스 입니다.

2. Particle - 눈에 보이는 입자 하나에 대응하는 클래스 입니다.

3. Vector - 유틸리티성에 가까운 클래스 입니다만, 움직임, 및 힘을 표현하기 위해 사용합니다.

 

 

먼저 Vector 클래스 입니다, 거의 유틸리티에 가까운 기능이기 때문에 상세한 설명은 생략하겠습니다.

class Vector{
  constructor(x,y){
    this.x = x;
    this.y = y;
  }
  add(dv){
    this.x += dv.x;
    this.y += dv.y;
  }
  mult(val = 1){
    if(typeof val === 'object'){
      const {x,y} = val;
      this.x *= x
      this.y *= y
    }else{
      this.x *= val;
      this.y *= val;
    }      
  }
  
  static multiply(baseVector, val){
    return new Vector(baseVector.x * val , baseVector.y * val);
  }
  static random(bandWidthX = 0.5 , strengthX = 1 , bandWidthY = 0.5, strengthY = 1){
    const x = ((Math.random() - bandWidthX) * strengthX)
    const y =  (((Math.random() - bandWidthY)* strengthY));
    return new Vector(x,y);
  }
  
}

 

 

 

먼저 첫번째 미션은 당연히 canvas 초기화를 하는것이겠지요? 화면 canvas 사이즈를 viewport 사이즈와 맞추고 하는김에 색상까지 입혀놓겠습니다.

 

  const canvas = document.getElementById('stage');
  const ctx = canvas.getContext('2d');
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  const dimension = {
    width : canvas.width,
    height: canvas.height
  }
  ctx.fillStyle = '#000000'
  ctx.fillRect(0,0, dimension.width, dimension.height)

 

 

두번째 미션은 일단 눈에 뭐든 보여야할테니, 입자(Particle)를 만들어야합니다. 

 

canvas에서 원을 만드는 방법은 arc메서드를 이용하는 방법이 있습니다. 

 

ctx.fillStyle = '#ffffff'
ctx.beginPath(); 
ctx.arc(100, 100, 4, 0, Math.PI * 2, false);
ctx.fill();

 

 

작고 귀여운 점이 보이니, 성공한것 같습니다.

 

세번째 미션은 점에 움직임을 부여하는 것입니다. 이를 위해 Particle 클래스를 만들고 requestAnimationFrame 실행을 통해, 해당 인스턴스를 업데이트 하는 식으로 구현합니다.

 

import {Vector} from './Vector.js';
class Particle{
  constructor(ctx, x,y, color){
    this.pos = new Vector(x,y);
    this.color = color;
    this.vel = new Vector(0,-(Math.random() * 4) - 8);
    this.acc = new Vector(0,0);
    this.size = 4;
    this.ctx = ctx;
  }

  addForce(force){
    this.acc.add(force)
  }
  update(lifespan){
    this.pos.add(this.vel)
    this.vel.add(this.acc)
    this.acc.mult(0);
  }
  render(){
    this.ctx.lineWidth = this.size
    this.ctx.fillStyle = this.color
    this.ctx.beginPath();
    this.ctx.arc(this.pos.x, this.pos.y, this.ctx.lineWidth / 2, 0, Math.PI * 2, false);
    this.ctx.fill();
    this.ctx.closePath()
  }
}

 

 

 

처음 생성될 때, 위로 솟구쳐야하므로, vel(속도) 값을 -8 ~ -12 사이의 값을 할당하고, 매 업데이트 마다 속도를 감속 시킵니다.

 

 

네번째 미션은 이제 폭죽을 터뜨리는 것입니다. 이를 위해, Firework 클래스를 선언하고 해당 클래스가 하나의 폭죽 이펙트를 매니징하는 형태로 구성하겠습니다.

 

먼저, 폭죽이 위로 솟구치는 부분, 이 폭죽을 root라고 부르겠습니다.

 

import { Particle } from './Particle.js'
import {Vector} from './Vector.js'

const colorPalete = ['#7033FE' , '#02b7fd', '#e80b0d' , '#ffa30c' , '#feff0c' , '#0FFAA1' , '#F4F4F4' , '#F48FF2'];

class Firework{
  constructor(ctx,storage , field , baseDeaccelerator){
    this.ctx = ctx
    this.field = field;
    this.color = colorPalete[Math.floor(Math.random() * colorPalete.length)]
    this.root = new Particle(this.ctx, Math.random()* this.field.width , this.field.height , true, this.color);
    this.explode = false;
    this.baseDeaccelerator = baseDeaccelerator;
  }
  update(){
    if(!this.root){
      return;
    }
    if(this.root.vel.y >= 0){
      this.explode = true;
      console.log('root on the roof!!!')
      return;
    }
    this.root.addForce(this.baseDeaccelerator);
    this.root.update()
  }

  render(){
      this.root?.render();
  }
}

 

코드를 살펴보면, 처음 Firework 인스턴스가 생성될 때, 루트에 하나의 파티클을 할당하고, 해당 루트가 정점 (vel >= 0)일 때를 기점으로 하여, explode 하는 것을 확인 할 수 있습니다.

 

 

이제 본격적으로 폭발 이펙트를 넣어야하는데, 생각해보면 폭발 이펙트 또한 파티클의 움직임으로 표현가능합니다.

 

저 같은 경우, root가 최고점에 도달할 시, Particle 인스턴스를 100개 생성하여, 각 Particle에 대해, 초기 속도값으로 임의의 벡터값을 주었습니다. 그럼 사방 팔방으로 입자들이 튕겨지는 것을 상상할 수 있겠지요?

 

여기다가, 약간의 재미로, 10퍼센트의 확률로 폭죽이 터질 때, 100개의 파티클이 랜덤한 색상을 갖도록 하겠습니다.

 

import { Particle } from './Particle.js'
import {Vector} from './Vector.js'

const colorPalete = ['#7033FE' , '#02b7fd', '#e80b0d' , '#ffa30c' , '#feff0c' , '#0FFAA1' , '#F4F4F4' , '#F48FF2'];

export class Firework{
  constructor(ctx, field , baseDeaccelerator){
    this.ctx = ctx
    this.field = field;
    this.color = colorPalete[Math.floor(Math.random() * colorPalete.length)]
    this.root = new Particle(this.ctx, Math.random()* this.field.width , this.field.height , true, this.color);
    this.isRare = Math.random() < 0.1;
    this.particles = [];
    this.explode = false;
    this.finish = false;
    this.lifespan = 4;
    this.baseDeaccelerator = baseDeaccelerator;

    
  }
  update(){
    if(this.finish){
      return;
    }
    if(this.explode){
      this.lifespan -= 0.03

      for(let i = 0 ; i < this.particles.length ; i ++){
        const force = Vector.multiply(this.baseDeaccelerator , 0.3)
        this.particles[i].addForce(force)
        this.particles[i].update(this.lifespan);
      }
      if(this.lifespan <= 0){
        this.finish = true;
        return;
      }
    }
    if(!this.root){
      return;
    }
    if(this.root.vel.y >= 0){
      this.explode = true;
      this.blast();
      this.root = null;
      return;
    }

    this.root.addForce(this.baseDeaccelerator);
    this.root.update()
  }
  blast(){
    for(let i = 0 ; i < 100 ; i ++){
      this.particles.push(new Particle(this.ctx, this.root.pos.x , this.root.pos.y, false , this.isRare ?  colorPalete[Math.floor(Math.random() * colorPalete.length)] : this.color ));
    }
  }
  render(){
    if(!this.explode){
      this.root?.render();
    }else{
      for(let i = 0 ; i < this.particles.length ; i ++){
        this.particles[i].render();
      }
    }
  }
}

 

Particle 클래스도 업데이트 시킵니다.

 

import {Vector} from './Vector.js';
export class Particle{
  constructor(ctx, x,y, isRoot = true , color){
    this.pos = new Vector(x,y);
    this.color = color;
    if(isRoot){
      this.vel = new Vector(0,-(Math.random()*4) - 8);
    }else{
      this.vel = Vector.random(0.5 ,24 , 0 , -10);
      this.vel.mult({
        x: 0.3,
        y: 0.5
      })
    }
    this.acc = new Vector(0,0);
    this.size = 4;
    this.ctx = ctx;
  }

  addForce(force){
    this.acc.add(force)
  }
  update(lifespan){
    this.pos.add(this.vel)
    this.vel.add(this.acc)
    this.acc.mult(0);
    if(lifespan){
      this.size = lifespan
    }
  }
  render(){
    this.ctx.lineWidth = this.size
    if(this.size < 0){
      return;
    }
    this.ctx.fillStyle = this.color
    this.ctx.beginPath();
    this.ctx.arc(this.pos.x, this.pos.y, this.ctx.lineWidth / 2, 0, Math.PI * 2, false);
    this.ctx.fill();
    this.ctx.closePath()
  }
}

 

그리고, 애니메이션 프레임을 돌릴 스크립트를 짜고 돌립니다.

 

  const fireworks = [];
  function d(){
    ctx.fillStyle = 'rgba(0,0,0,0.15)'
    ctx.fillRect(0,0,dimension.width ,dimension.height)
    if(Math.random() < 0.03){
      fireworks.push(new Firework(ctx, fireworks , dimension, GRAVITY))
    }
    for(let i= 0 ; i < fireworks.length;  i++){
      fireworks[i]?.update();
      fireworks[i]?.render()
  }
    requestAnimationFrame(d)
  }
  d()

 

이제 재밌게 잘 돌리면!

 

 

 

Optimization

 

당연하지만, 지금 로직상 문제는, 각 폭죽 인스턴스들이 끝나고도 삭제가 되질 않습니다.

 

현재 fireworks array에 각 인스턴스들을 넣어서 사용했습니다만, 문제점이 있습니다.

 

먼저 생성된 애니메이션이 먼저 끝나지 않습니다. (사실 사용하는 입장에서, 폭죽을 미친듯이 많이 생성하진 않기에, 실상 배열을 사용해도 무방합니다.)

 

좀 여러 생각을 하다가 생각한 방안이,

 

1. doubly Linked list + hash map

2. Minimum Heap

 

위 두가지 방법이었는데, 개인적으로 최소 힙의 변형을 사용하는게 더 적합하다 생각했습니다.

 

일단 Firework의 생명주기 자체는 전적으로 'lifespan'에 의존합니다.  결국 뭐가 어찌 되었든, lifespan 값이 0 이하면,  눈으로 볼 수 없고, 이는 곧 참조 해제의 대상입니다. 그 뜻은 'lifespan'값을 데이터 삼은 최소힙의 루트 노드는, 해당 힙의 모든 노드들 중, 'lifespan' 값이 최소인 노드를 보장하겠지요.

 

그러면 결국 매 프레임 마다, 힙의 노드들을 검사하여, 루트 노드가 만료되었는지 확인하고 만료 되었다면 제거하면 될듯 합니다.

(아래 링크 참조)

https://github.com/dovigod/artworks/tree/master/fireworks/src

 

GitHub - dovigod/artworks

Contribute to dovigod/artworks development by creating an account on GitHub.

github.com

 

 

 

 

'Gen Art' 카테고리의 다른 글

[WebGL] - case study : The Avener  (0) 2024.07.15
Shattering Text Animation 제작기  (2) 2024.06.06
[Three.js] case study: m-trust  (1) 2023.07.31