Frontend/Canvas

[Canvas] 불꽃놀이 만들기

gamzaggang7 2024. 6. 10. 18:52
728x90

2024.05.24 - [Canvas] - [Canvas] 보일러 플레이트 작성 (캔버스 클래스)

 

[Canvas] 보일러 플레이트 작성 (캔버스 클래스)

index.js  CanvasOption.jsexport default class canvasOption { constructor() { this.canvas = document.querySelector('canvas') this.ctx = this.canvas.getContext('2d') this.dpr = window.devicePixelRatio this.fps = 60 this.interval = 1000 / this.fps this.canva

gamzaggang7.tistory.com

 

1. 파티클 생성

js 폴더 내에 Particle.js 을 만들고 저번에 만든 CanvasOption.js를 붙여 기본 캔버스 값을 사용할 수 있도록 한다.

import canvasOption from "./CanvasOption";

export default class Particle extends canvasOption {
  constructor() {

  }

  update() {

  }

  draw() {

  }
}

 

우선 파티클 인자는 x, y만 받도록 하고 draw()에서 원을 그린다.

constructor(x, y) {
    super()
    this.x = x
    this.y = y
  }

  update() {

  }

  draw() {
    this.ctx.beginPath()
    this.ctx.arc(this.x, this.y, 10, 0, Math.PI * 2)
    this.ctx.fill()
    this.ctx.closePath()
  }

 

캔버스 클래스에서 빈 파티클을 배열을 생성하고 createParticles() 함수를 생성한다. 파티클 개수는 우선 1개로 하고 파티클 수만큼 x=300, y=300인 파티클을 생성해 파티클 배열에 넣는다.

// index.js
constructor() {
    super()

    this.particles = []
  }
// index.js
createParticles() {
    const PARTICLE_NUM = 1
    for (let i = 0; i < PARTICLE_NUM; i++) {
      const x = 300
      const y = 300
      this.particles.push(new Particle(x, y))
    }
  }

init 함수에서 createParticles 함수를 실행시킨다.

this.createParticles()

render 함수에서 파티클을 그려준다.

this.particles.forEach(particle => {
     particle.update()
     particle.drew()
})

 

여기서 update 함수에 y에 1씩 더해 파티클이 아래로 떨어지는 효과를 넣으면 아래처럼 그림을 지우지않고 매 프레임마다 씌우고 있기 때문에 길게 늘어지는 것처럼 보인다.

clear를 사용해 지울 수 있지만 배경색으로 덮어씌우는 방법도 있다.

CanvasOptrion에서 캔버스의 배경색을 지정한다.

this.bgColor = '#000000'

render함수의 return 뒤에 사각형의 색상을 bgColor로 하고 사각형의 크기는 화면 크기만큼 지정한다.

if (delta < this.interval) return
      this.ctx.fillStyle = this.bgColor
      this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight)

그럼 배경이 까맣게 되어 파티클이 안보이는데 파티클의 색상을 흰색으로 바꾼다.

draw() {
    this.ctx.fillStyle = '#fff'

흰색 원이 아래도 떨어진다. 이제 y+=1 부분은 지운다.

 

2. 원 형태로 퍼져나가는 효과

파티클을 랜덤한 위치에서 생성하기 위해 js 폴더안에 utils.js 파일을 생성하고 최댓값과 최솟값 사이의 랜덤한 값을 생성하는 함수를 작성한다.

export const randomNumBetween = (min, max) => {
  return Math.random() * (max - min) + min
}

파티클의 개수를 10개로 늘리고 x, y 위치를 랜덤값으로 바꾼다.

createParticles() {
    const PARTICLE_NUM = 10
    for (let i = 0; i < PARTICLE_NUM; i++) {
      const x = randomNumBetween(0, this.canvasWidth)
      const y = randomNumBetween(0, this.canvasHeight)
      this.particles.push(new Particle(x, y))
    }
  }

이제 불꽃놀이처럼 한 점에서 파티클들이 퍼져 나가게 해야한다.

x와 y를 for 문 밖으로 꺼내면 10개의 파티클들이 한 점에 모여서 생성된다.

createParticles() {
    const PARTICLE_NUM = 10
    const x = randomNumBetween(0, this.canvasWidth)
    const y = randomNumBetween(0, this.canvasHeight)
    for (let i = 0; i < PARTICLE_NUM; i++) {
      this.particles.push(new Particle(x, y))
    }
  }

for문 안에서 x, y의 속도 vx, vy를 랜덤값으로 생성한다.

for (let i = 0; i < PARTICLE_NUM; i++) {
      const vx = randomNumBetween(-5, 5)
      const vy = randomNumBetween(-5, 5)
      this.particles.push(new Particle(x, y, vx, vy))
    }

update 함수에서 각각의 속도를 더해준다.

constructor(x, y, vx, vy) {
    super()
    this.x = x
    this.y = y
    this.vx = vx
    this.vy = vy
  }

  update() {
    this.x += this.vx
    this.y += this.vy
  }

실행시켜보면 10개의 파티클들이 한 점에서부터 각각 다르게 퍼져나간다. 하지만 파티클을 1000개로 늘려보면 파티클들이 사각형의 형태로 퍼져나간다.

그리고 파티클이 화면 밖으로 나가도 render 내에서 update와 draw를 계속 실행한다. 화면에서 보이지 않아도 cpu 연산은 계속 돌아가고 있어 성능에 문제가 될 것이다. 이 문제를 해결하기 위해 파티클에 opacity를 설정하고 0으로 수렴하게 한 다음 opacity값이 0이 된 파티클은 파티클 배열에서 지우도록 한다.

 

먼저 파티클에 opacity를 생성하고 매 프레임마다 0.01씩 감소하도록 한다.

constructor(x, y, vx, vy) {
    super()
    ...
    this.opacity = 1
  }

  update() {
    ...
    this.opacity -= 0.01
  }

  draw() {
    this.ctx.fillStyle = `rgba(255, 255, 255, ${this.opacity})`
    ...
  }

파티클들이 퍼져나가면서 점점 사라져간다. 이제 투명도가 0이 된 파티클들은 배열에서 삭제한다.

두번째 인자로 index값도 가져와 index에서 1개만큼을 배열에서 제거한다.

this.particles.forEach((particle, index) => {
    particle.update()
    particle.draw()

    if (particle.opacity < 0) this.particles.splice(index, 1)
})

개발자도구에서 more tools -> Performance monitor에 들어가 CPU 사용량을 보면 약 23%로 시작해 파티클들이 사라질 때쯤엔 0.1% ~ 0%가 되는 걸 볼 수 있다.

 

이제 파티클들이 사각형 모양이 아닌 원 모양으로 퍼져나가게 한다.

파티클들이 한 점에서 원 형태로 퍼지게 하려면 각 파티클들이 중심점에서 특정 각도로 퍼져나가게 해야 한다. x는 반지름 r * cos(angle)이 되고 y는 r * sin(angle)이 된다. 반지름과 각도값을 랜덤으로 설정한다.

for (let i = 0; i < PARTICLE_NUM; i++) {
      const r = randomNumBetween(2, 50) * 0.2
      const angle = Math.PI / 180 * randomNumBetween(0, 360)

      const vx = r * Math.cos(angle)
      const vy = r * Math.sin(angle)
      this.particles.push(new Particle(x, y, vx, vy))
    }

파티클 수는 400개 정도로 줄이고 파티클의 반지름도 2로 줄인다.

728x90

 

3. 중력, 마찰, 잔상

더 자연스럽게 하기 위해 파티클에 중력과 마찰을 적용한다.

constructor(x, y, vx, vy) {
    super()
    ...
    this.gravity = 0.12
    this.friction = 0.97
  }

  update() {
    this.vy += this.gravity
 
    this.vx *= this.friction
    this.vy *= this.friction
    ...
  }

이렇게 하면 파티클들이 점점 아래로 떨어지면서 속도는 0에 수렴해 서서히 느려진다.

 

파티클들이 동시에 같이 사라지는 것을 해결하기 위해 opacity값도 랜덤으로 설정한다.

const opacity = randomNumBetween(0.5, 1)
this.particles.push(new Particle(x, y, vx, vy, opacity))
constructor(x, y, vx, vy, opacity) {
    super()
    this.x = x
    this.y = y
    this.vx = vx
    this.vy = vy
    this.opacity = opacity
    this.gravity = 0.12
    this.friction = 0.93
  }

 

render 함수에서 배경색에 + '40'을 더하면 이는 #00000040과 같고 배경색에 투명도 40을 준 것이다. 이렇게 하면 불꽃놀이의 잔상 효과를 나타낼 수 있다.

this.ctx.fillStyle = this.bgColor + '40'

 

현재 불꽃의 크기는 화면의 크기에 상관없이 똑같기 때문에 불꽃 크기를 화면 크기에 비례하도록 한다. 파타고라스의 정리를 이용해 화면의 대각선 길이를 구한다.

//utils.js
export const hypotenuse = (x, y) => {
  return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2))
}
const r = randomNumBetween(2, 50) * hypotenuse(innerWidth, innerHeight) * 0.0002

 

4. 꼬리 생성

js 폴더에 Tail.js 파일을 만든다. constructor에서는 x와 vy, color만 받으면 된다.

import canvasOption from "./CanvasOption.js";

export default class Tail extends canvasOption {
  constructor(x, vy, color) {
    super()
    this.x = x
    this.y = this.canvasHeight
    this.vy = vy
    this.color = color
  }
}

update에서 y에 vy를 더하고 draw에서 작은 원을 그린다.

export default class Tail extends canvasOption {
  constructor(x, vy, color) {
    ...
  }
  update() {
    this.y += vy
  }
  draw() {
    this.ctx.fillStyle = this.color
    this.ctx.beginPath()
    this.ctx.arc(this.x, this.y, 1, 0, Math.PI * 2)
    this.ctx.fill()
    this.ctx.closePath()
  }
}

 

캔버스 클래스에서 빈 tails 배열을 만들고 createTail 함수를 생성한다.

class Canvas extends canvasOption {
  constructor() {
    super()

    this.tails = []
    this.particles = []
  }

  init() {
    ...
  }

  createTail() {
   
  }
...

createTail 함수는 createParticles 함수처럼 작성하면 된다.

createTail() {
    const x=randomNumBetween(this.canvasWidth * 0.2, this.canvasWidth * 0.8)
    const vy=-20
    const color='red'
    this.tails.push(new Tail(x, vy, color))
  }

frame 함수 내에서 꼬리를 그려준다.

const frame = () => {
      ...
      this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
     
      this.createTail()

      this.tails.forEach((tail, index) => {
        tail.update()
        tail.draw()
      })

      ...
    }

이렇게 하면 꼬리가 쉴 틈없이 실행된다. createTail을 랜덤으로 실행시켜준다.

if (Math.random() < 0.03) this.createTail()

 

속도에 마찰이 없어 화면 위로 사라지기 때문에 꼬리에 마찰을 더해준다.

constructor(x, vy, color) {
    super()
    ...
    this.friction = 0.96
  }
  update() {
    this.vy *= this.friction
    this.y += this.vy
  }

꼬리들이 화면 상단쯤에 모인다. 이제 각각 다른 vy값을 가지게 한다.

createTail() {
    ...
    const vy = randomNumBetween(15, 20) * -1
    ...
  }

현재 속도가 고정값이기 때문에 화면을 작게 하면 보이지 않는 곳에 편착한다. vy값을 화면 비율에 맞춰 가변값으로 바꿔준다.

const vy = this.canvasHeight * randomNumBetween(0.01, 0.015) * -1

편착 위치에 따라 friction 값을 조절한다.

this.friction = 0.98

이제 화면 크기에 상관없이 상단에서 꼬리들이 멈춘다.

 

이제 꼬리가 멈출때쯤에 불꽃이 터지게 하면 된다. 꼬리의 속도가 0이 되는 시점에 tails 배열에서 tail을 지우고 createParticles로 파티클을 생성하면 된다. 기존의 파티클 x, y 값은 지우고 꼬리가 사라질 때의 x, y 지점에서 파티클이 생성되도록 한다. color 값도 함께 받도록 한다.

index.js

      this.tails.forEach((tail, index) => {
        tail.update()
        tail.draw()

        if (tail.vy > -1) {
          this.tails.splice(index, 1)
          this.createParticles()
        }
      })
  createParticles(x, y, color) {
    const PARTICLE_NUM = 400
    for (let i = 0; i < PARTICLE_NUM; i++) {
      const r = randomNumBetween(2, 50) * hypotenuse(innerWidth, innerHeight) * 0.0002
      const angle = Math.PI / 180 * randomNumBetween(0, 360)
      const vx = r * Math.cos(angle)
      const vy = r * Math.sin(angle)
      const opacity = randomNumBetween(0.5, 1)
      this.particles.push(new Particle(x, y, vx, vy, opacity, color))
    }
  }

 

Particle.js

  constructor(x, y, vx, vy, opacity, color) {
    super()
    this.x = x
    this.y = y
    this.vx = vx
    this.vy = vy
    this.opacity = opacity
    this.gravity = 0.12
    this.friction = 0.93
    this.color = color
  }
...
  draw() {
    this.ctx.fillStyle = `rgba(${this.color}, ${this.opacity})`

 

color를 rgb 형식으로 바꾼다.

index.js

  createTail() {
    const x = randomNumBetween(this.canvasWidth * 0.2, this.canvasWidth * 0.8)
    const vy = this.canvasHeight * randomNumBetween(0.01, 0.015) * -1
    const color = '255 255 255'
    this.tails.push(new Tail(x, vy, color))
  }

 

Tails.js

  draw() {
    this.ctx.fillStyle = `rgba(${this.color}, 1)`

 

이렇게 하면 꼬리가 멈추는 지점에 폭죽이 터진다.


5.꼬리 다듬기

꼬리가 멈출수록 투명도가 낮아지게 한다.

Tail.js

  update() {
    this.vy *= this.friction
    this.y += this.vy
    this.opacity = -this.vy
  }
  draw() {
    this.ctx.fillStyle = `rgba(${this.color}, ${this.opacity})`

 

vy는 랜덤값 음수로 시작해서 0으로 수렴한다. 하지만 vy가 -1보다 커지면 꼬리를 삭제하도록 하고 있으니 opacity가 적용되기 전에 꼬리가 사라진다.

꼬리가 사라지는 지점을 0.7 정도로 수정하고 this.opacity에 0.1을 곱하면 꼬리가 위로 올라갈수록 서서히 투명해진다.

index.js

      this.tails.forEach((tail, index) => {
        tail.update()
        tail.draw()

        if (tail.vy > -0.7) {
          this.tails.splice(index, 1)
          this.createParticles(tail.x, tail.y, tail.color)
        }
      })

Tail.js

  update() {
    this.vy *= this.friction
    this.y += this.vy
    this.opacity = -this.vy * 0.1
  }

 

꼬리가 일직선이 아니라 꼬불꼬불 움직이면서 올라가도록 만든다.

-1과 1 사이의 값을 계속 더해주면 된다. Math.sin이나 Math.cos을 이용한다.

  constructor(x, vy, color) {
    super()
    this.x = x
    this.y = this.canvasHeight
    this.vy = vy
    this.color = color
    this.angle = randomNumBetween(0, 2)
    this.friction = 0.982
  }
  update() {
    this.vy *= this.friction
    this.y += this.vy
    this.angle += 1
    this.x += Math.cos(this.angle) * 0.5
    this.opacity = -this.vy * 0.1
  }

올라갈수록 꼬불거리는게 0으로 수렴하게 만들기 위해 this.vy을 곱해준다.

this.x += Math.cos(this.angle) * 0.1 * this.vy

 

6. 스파크 효과

js 폴더에 Spark.js 파일을 만든다. x, y만 받아 스파크를 생성하도록 하고 기본 원을 그리는 코드를 draw() 메서드에 작성한다.

import canvasOption from "./CanvasOption.js";

export default class Spark extends canvasOption {
  constructor(x, y) {
    super()
    this.x = x
    this.y = y
  }
  update() {

  }
  draw() {
    this.ctx.beginPath()
    this.ctx.arc(this.x, this.y, 1, 0, Math.PI * 2)
    this.ctx.fillStyle = 'gold'
    this.ctx.fill()
    this.ctx.closePath()
  }
}

 

index.js에서 다른 꼬리와 파티클처럼 빈 스파클 배열을 생성하고 foreach로 update()와 draw()메서드를 실행한다.

  constructor() {
    super()

    this.tails = []
    this.particles = []
    this.sparks = []
  }
      this.sparks.forEach((spark, index) => {
        spark.update()
        spark.draw()
      })

 

파티클이 생성되면 스파크들이 잔상처럼 보이게 하기 위해 파티클이 draw되면 스파크 배열에 새 인스턴스가 들어오게 한다. 스파크의 x, y값은 파티클의 x, y값으로 한다.

      this.particles.forEach((particle, index) => {
        particle.update()
        particle.draw()

        this.sparks.push(new Spark(particle.x, particle.y))

        if (particle.opacity < 0) this.particles.splice(index, 1)
      })

스파크들이 잘 생성은 되지만 한번에 400개의 스파크들이 매 프레임마다 생성되기 때문에 메모리 사용량이 빠르게 증가한다. 스파크의 생성 수를 줄인다.

        if (Math.random() <0.1) {
          this.sparks.push(new Spark(particle.x, particle.y))
        }

 

스파크에도 opacity를 적용하여 파티클처럼 서서히 사라지게 하고 사라진 스파크는 splice하여 배열에서 삭제한다.

export default class Spark extends canvasOption {
  constructor(x, y, opacity) {
    super()
    this.x = x
    this.y = y
    this.opacity = opacity
  }
  update() {
    this.opacity -= 0.02
  }
  draw() {
    this.ctx.beginPath()
    this.ctx.arc(this.x, this.y, 1, 0, Math.PI * 2)
    this.ctx.fillStyle = `rgba(255, 255, 0, ${this.opacity})`
    this.ctx.fill()
    this.ctx.closePath()
  }
}
      this.particles.forEach((particle, index) => {
        ...

        if (Math.random() < 0.1) {
          this.sparks.push(new Spark(particle.x, particle.y, 0.3))
        }
        ...
      })

      this.sparks.forEach((spark, index) => {
        ...

        if (spark.opacity < 0) this.sparks.splice(index, 1)
      })

 

역동성을 위해 스파크에 vx, vy를 추가한다.

  constructor(x, y, vx, vy, opacity) {
    super()
    this.x = x
    this.y = y
    this.vx = vx
    this.vy = vy
    this.opacity = opacity
  }
  update() {
    this.opacity -= 0.02

    this.x += this.vx
    this.y += this.vy
  }
        if (Math.random() < 0.1) {
          this.sparks.push(new Spark(particle.x, particle.y, 0, 0, 0.3))
        }

 

꼬리에도 스파크 효과를 넣는다. for 문으로 꼬리 프레임이 이동할 때마다 스파클을 만든다. 올라갈수록 스파크 개수가 줄어들게 하기 위해 tail.vy값에 비례하게 생성되도록 한다.

      this.tails.forEach((tail, index) => {
        tail.update()
        tail.draw()

        for (let i = 0; i < Math.round(-tail.vy * 0.5); i++) {
          const vx = randomNumBetween(-5, 5) * 0.05;
          const vy = randomNumBetween(-5, 5) * 0.05;
          const opacity = Math.min(-tail.vy, 0.5);
          this.sparks.push(new Spark(tail.x, tail.y, vx, vy, opacity))
        }

        if (tail.vy > -0.7) {
          this.tails.splice(index, 1)
          this.createParticles(tail.x, tail.y, tail.color)
        }
      })

 

7. 기타 꾸미기

불꽃이 랜덤한 색상들로 다양하게 생성되도록 한다. utls.js에서 랜덤 rgb값을 반환하는 randomColor() 함수를 작성한다.

export const randomColor = () => {
  const r = Math.floor(Math.random() * 256)
  const g = Math.floor(Math.random() * 256)
  const b = Math.floor(Math.random() * 256)
  return `${r}, ${g}, ${b}`
}

 

tail 색상을 랜덤컬러로 생성하고 꼬리에 붙는 스파크도 꼬리 색과 같게 하기 위해 spark에 color 인자를 추가한다.

  createTail() {
    const x = randomNumBetween(this.canvasWidth * 0.2, this.canvasWidth * 0.8)
    const vy = this.canvasHeight * randomNumBetween(0.01, 0.015) * -1
    const color = randomColor()
    this.tails.push(new Tail(x, vy, color))
  }
export default class Spark extends canvasOption {
  constructor(x, y, vx, vy, opacity, color) {
    super()
    ...
    this.color=color
  }
  update() {
    ...
  }
  draw() {
    ...
    this.ctx.fillStyle = `rgba(${this.color}, ${this.opacity})`
    ...
  }
}
this.sparks.push(new Spark(tail.x, tail.y, vx, vy, opacity, tail.color))

 

파티클에 붙는 스파크는 노란색을 갖도록 한다.

this.sparks.push(new Spark(particle.x, particle.y, 0, 0, 0.3, '255, 255, 1'))

 

마지막으로 불꽃이 터질때 배경이 밝아지도록 한다. 파티클 수에 맞춰 배경색을 바꾸면 된다.

frame함수에서 배경색을 칠하는 코드 아래에 파티클 수 기반으로 배경색을 다시 칠하는 코드를 추가한다.

      this.ctx.fillStyle = this.bgColor + '40'
      this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight)

      this.ctx.fillStyle = `rgba(255, 255,255, ${this.particles.length / 50000})`
      this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight)

이렇게 하면 파티클 수가 많을 수록 흰색의 불투명도가 증가하고 그 레이어가 캔버스를 덮게된다.

728x90