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 |