[Canvas-불꽃놀이-11] 불꽃놀이 꼬리 완성

이전 글 : [Canvas-불꽃놀이-10] 불꽃놀이 꼬리의 위치와 속도 구현

 

[Canvas-불꽃놀이-10] 불꽃놀이 꼬리의 위치와 속도 구현

이전 글 : [Canvas-불꽃놀이-09] 파티클 관리와 객체 폴링 [Canvas-불꽃놀이-09] 파티클 관리와 객체 폴링이전 글 : [Canvas-불꽃놀이-08] 애니메이션 기본 틀잡기 [Canvas-불꽃놀이-08] 애니메이션 기본 틀

jinsk-joy.tistory.com

 

불꽃놀이의 꼬리 최종 완성

 

이전글에서 꼬리의 위치와 속도를 정의했다.
이번 글에선 TailParticle을 생성하고 세부 값들을 조절하여 화면에 어떻게 구현되는지 확인해 보도록 한다.

 


 

1. TailParticle 구현

TailParticle 클래스는 불꽃놀이의 꼬리를 나타내며, 캔버스 하단에서부터 시작해 x의 진동(코사인 주기)과 y의 감속 효과를 결합해 자연스러운 물결 모양으로 올라가도록 구현하였다.

 

1-1. 생성자와 멤버변수

  • 인스턴스 생성 시 Canvas클래스에서 계산된 x, y좌표값과 vy값을 넘겨받는다.
  • 구불구불하게 올라가는 vx의 값은 vy값에 영향을 받으므로 update 메서드에서 따로 계산하도록 한다.
  • vx 계산을 위해 중요한 지표인 radian을 TailParticle 만의 멤버변수로 추가한다. 
  • 애니메이션 진행중, 초기 x 좌표 위치에서 벗어난 TailParticle을 다시 초기값 복귀시키기 위해 초기 x좌표를 TailParticle만의 멤버 변수로 추가한다.

 

// constants.js
export const TAIL = {
    // 생략...
    RADIAN: 0, // 라디안 값 초기화
};
class TailParticle extends Particle {
    constructor({ ctx, isSmallScreen, x, y, vy }) {
    	// 부모클래스에 값을 전달
        // x, y, vy는 canvas클래스에서 계산된 값을 넘겨받는다.
        super({ ctx, isSmallScreen, x, y, vy);
        
        // TailParticle 고유 멤버 변수 초기화
        this.initTailParticleVars();
    }

    initTailParticleVars() {
        // x좌표가 업데이트 시 초기 값으로 복귀할 수 있도록 최초 x 좌표 저장
        this.initialX = this.x; 
        
        // vx계산시 필요한 라디안 초기값
        this.radian = TAIL.RADIAN; 
    }
}

 

1-2. Draw와 Update 메서드

  • draw 메서드는 특별한 사항이 없으므로 부모 클래스의 draw 메서드를 그대로 사용한다.
  • update 메서드는 추가로 계산이 필요한 항목이 있으므로 따로 구현하도록 한다.
    -. vy의 값에 영향을 받으므로 vx는 update 메서드에서 계산한다.
    -. 좌우로 왔다갔다 하는 모션을 만들기 위해 삼각함수 코사인값을 이용해 계산하도록 한다.
    -. x가 좌우 진동을 그리기 위해 속도가 붙는데 그럼 처음 초기값에서 벗어날 수 밖에 없다. 이를 최소화 하기 위에 업데이트 시 초기값에 가까워지도록 보정값을 더한다.
// constants.js
export const TAIL = {
    // 생략...
    RADIAN_OFFSET: 1,
    X_ADJUST_RATE: 0.4,
    INITIAL_X_RETURN_RATE: 0.1, // 초기값으로 돌아가는 속도, 작을수록 부드럽게 이동한다.
};
// TailParticle.js
update() {
    // 위로 올라갈수록 마찰력(friction)으로 인해 y의 속도가 줄어든다.
    this.vy *= this.friction;
    
    // y의 속도(vy)에 따른 x의 좌우진동(cos) 크기(X_ADJUST_RATE)를 계산해 vx에 반영한다.
    this.vx = Math.cos(this.radian) * this.vy * TAIL.X_ADJUST_RATE;

    // 위로 갈수록 투명도가 감소한다.
    this.opacity = -this.vy;
    
    // 라디안값을 일정하게 증가시켜 시간이 지남에 따라 좌우진동 모션이 자연스럽게 보이도록 한다.
    this.radian += TAIL.RADIAN_OFFSET;

    // x,y 좌표의 위치를 업데이트 한다.
    this.updatePosition();
    
    // x 좌표가 속도(vx)에 따라 초기값(initialX)에서 벗어나므로
    // 부드럽게 초기값에 복귀하도록 보정값(INITIAL_X_RETURN_RATE)을 추가 (중심 복귀 효과)
    this.x += (this.initialX - this.x) * TAIL.INITIAL_X_RETURN_RATE;
}

 

update 메서드 좀 더 자세한 설명

this.vy *= this.friction;
  • friction은 마찰력이다. 부모에 있는 마찰력 값(0.93)을 그대로 쓰므로 y의 속도에 마찰력을 곱하면 속도가 점점 줄어들게 된다.
  • 시간이 지날수록 꼬리가 점점 더 천천히 올라가게해 자연스러운 감속효과를 구현한다.

 

this.vx = Math.cos(this.radian) * this.vy * TAIL.X_ADJUST_RATE;
  • Math.cos(this.radian)
    -. 코사인 값은 단위원에서 x축 좌표를 나타내므로, x에 대해 진동하는 좌우 흔들림(파동)을 표현하기에 적당하다.
    -. 라디안의 초기값이 0이면 cos(0)은 1이므로 오른쪽 끝에서부터 진동이 시작돼 자연스러운 파동을 볼 수 있다.
각도 코사인 값 그래프
cos(0°, 0 라디안) 1
cos(90°, π/2 라디안) 0
cos(180°, π 라디안) -1
cos(270°, 3π/2 라디안) 0
cos(360°, 2π 라디안) 1
  • this.vy
    -. vx에 vy를 곱해주는 이유는 y의 속도에 비례해 x의 움직임의 크기(수직방향)도 조절되기 때문이다.
    -. vy이 값이 클수록 x의 움직임의 크기도 커지고, 줄어들수록 x의 움직임도 작아져 y와 더불어 자연스럽게 감속하는 효과를 만든다.
  • TAIL.X_ADJUST_RATE
    -. X_ADJUST_RATE는 x의 좌우 흔들림의 폭인 진폭의 크기(수평방향)를 조절하기 때문에 마지막으로 곱해준다.
    -. 이 값이 작을 수록 x의 좌우 흔들림이 적고 클수록 좌우 흔들림이 커진다
    -. 예) 0.2인 작은 좌우 진동 -> 꼬리가 거의 직선으로 올라가는 느낌 (왼쪽 사진 참고)
    -. 예) 0.8인 큰 좌우 진동 -> 꼬리가 강하게 흔들리면서 올라가는 느낌 (오른쪽 사진 참고)

좌: 진폭의 크기가 작을 때 / 우: 진폭의 크기가 클 때

 

this.opacity = -this.vy;
  • 꼬리가 위로 올라갈수록 y의 속도가 점점 감소하니, vy를 투명도로 설정하여 꼬리가 자연스럽게 사라지도록 한다.
  • y의 속도는 아래에서 위로 올라가기 때문에 음수이므로, 부호만 바꿔주면 양수가 되어 opacity가 0에 가까워지면서 꼬리를 사라지게 만들 수 있다.

 

this.radian += TAIL.RADIAN_OFFSET;
  • 처음 0에서 시작해 일정 크기를 더해 자연스러운 파동 모양이 될수록 한다. 1 -> 0 -> -1 -> 0 -> 1 순으로 무한히 반복할 수 있게 한다.

 

1-3. reset 메서드

  • 객체 풀링을 사용하면 매번 새로운 객체를 생성하는 것이 아니라 풀에서 재활용하므로 성능을 향상할 수 있다. 그래서 reset 메서드는 객체 풀링을 사용하는 애니메이션에선 필수적으로 실행해야 한다.
  • 객체를 풀에 반환할때 이전 상태가 남아있으면 예상치 못한 동작이 발생할 수 있으므로 reset을 통해 모든 멤버 변수를 초기화시켜 줘야 새로운 파티클처럼 동작할 수 있게 해 준다. (+ TailParticle은 고유 멤버 변수를 가지므로 초기 상태를 보장하기 위해 부모 클래스의 초기화와 함께 고유멤버변수도 초기화해야 함)
  • 객체를 풀에서 가져와서 사용할 때도 생성 시 상태로 초기화해줘야 하므로 param객체를 통해 공통적으로 사용하는 멤버변수와 TailParticle 만의 고유 멤버변수를 초기화시켜야 활성상태를 만들 수 있다.
// TailParticle.js
reset(params) {
    // 부모의 reset 메서드 실행
    super.reset(params);
    
    // 위에서 선언한 TailParticle의 고유 멤버변수 초기화
    this.initTailParticleVars();
}

 


 

2. TailParticle 애니메이션 적용하기

이제 TailParticle 클래스를 완성하였으니 Canvas 클래스에서 애니메이션을 적용시켜 보도록 하겠다.
애니메이션을 적용하기 위해서 파티클 생성과 업데이트가 필요하다.

요약해서 설명하자면,

  • createTailParticle 메서드는 새로운 파티클을 생성하거나 풀에서 재사용하며 좌우 균등하게 생성하도록 설정한다.
  • updateTailParticle 메서드는 파티클의 상태를 업데이트하며, 투명도에 따라 파티클을 풀에 반납해 효율적으로 관리한다.

 

2-1. TailParticle 생성

  • 양쪽에서 균등하게 꼬리를 생성하기 위한 isLeft
    x좌표의 위치를 결정하는 tailCount
    활동중인 TailParticle을 관리하는 배열 등 꼬리 생성에 필요한 멤버변수, 메서드를 추가했다.

 

  • 생성에 필요한 변수 추가와 초기화
class Canvas extends CanvasOption {
    constructor() {
        super();
        this.initCanvasVars();
    }

    init() {
        super.init();
        // 생략...
    }

    initCanvasVars() {
        // 생략...
        
        // 꼬리의 개수를 카운트하여 화면에 보여주는 꼬리의 위치를 통제
        this.tailCount = 0;
        // x좌표 값이 true면 왼쪽 x좌표 배열, false면 오른쪽 x좌표 배열값들 사이에서 랜덤 선택
        this.isLeft = false;

        // 애니메이션 실행중인 파티클 배열
        this.tailParticles = [];

        // 파티클 생성, 반납을 관리하는 파티클 매니저 인스턴스 생성
        this.pm = new ParticleManager(this.ctx, this.isSmallScreen);
    }
    // 생략...
}

 

  • TailParticle 생성 메서드 구현
    -. x좌표의 위치에 따라 파리마터 값이 달라지므로 공통되는 파라미터를 만든 다음에 조건에 따라 필요한 값들을 대입한다.
    -. 중앙을 제외한 x좌표의 위치는 좌, 우 x좌표 배열들 가운데 랜덤하게 선택해서 다양한 패턴을 보여주도록 한다.
// Canvas.js
// 새로운 TailParticle을 생성하거나 풀에서 재활용
// 초기 상태를 설정하며 x좌표와 vy를 랜덤하게 배치
createTailParticle() {
    // x좌표와 달리 y좌표는 변하지 않으므로 미리 추가
    let params = { y: this.canvasCssHeight };

	// 첫번째로 시작될 때는 메인(중앙)에서 꼬리 생성
    if (this.tailCount === 0) {
        params.x = this.mainX;
        params.vy = this.mainTailVY;

    // 그 이외는 좌우 위치에 따라 랜덤 생성
    } else {
    	// 좌표 배열 마지막 인덱스 
        const max = this.tailQty - 1;

        // isLeft가 true 이면 왼쪽 좌표 배열, false면 오른쪽 좌표 배열에서 랜덤 선택
        params.x = (this.isLeft ? this.tailsLeftPosX : this.tailsRightPosX)[randomInt(0, max)];
        
        // vy도 배열 안에서 랜덤 선택
        params.vy = this.tailsVY[randomInt(0, max)];
    }
    // 활성화된 tailParticle 배열에 새롭게 생성되거나 재사용한 파티클 추가
    this.tailParticles.push(this.pm.acquireParticle(TYPE_TAIL, params));

    // 새로운 파티클 추가 후 개수를 변경해준다. 배열의 길이를 초과하지 않도록 나머지 연산 적용
    this.tailCount = (this.tailCount + 1) % this.tailQty;

    // 좌, 우 균등하게 나오도록 isLeft의 값을 반전시킨다.
    this.isLeft = !this.isLeft;
}

 

2-2. TailParticle 업데이트

  • 파티클을 생성했으면 애니메이션 진행을 위해 매 프레임마다 업데이트를 해줘야 한다.
  • 애니메이션 진행을 위해 생성된 파티클 배열을 역순으로 순회하면서 각 파티클을 업데이트 하고 다시 화면에 그린다.
    -. 배열을 역순으로 순회하는 이유는 애니메이션이 끝난 파티클을 배열에서 제외할 때 인덱스 밀림으로 인한 오류를 방지하기 위해서다.
    정방향으로 순회했을때 배열에서 요소를 제거하면 그 뒤에있던 요소가 앞으로 당겨지기 때문에 예상치 못한 동작이 발생할 수 있기 때문이다.
  • 애니메이션이 끝난 파티클을 풀에 반납하기 위해 투명도를 기준으로 일정 기준선 이하면 파티클 배열에서 제외에 다시 풀에 반납한다.

 

export const TAIL = {
    //생략...
    
    // 파티클 배열에서 제외할 투명도 기준, 0이 아닌 이유는 투명도가 vy 값과 동일하기 때문이다.
    OPACITY_LIMIT: 0.05,
	
    // 생략...
};
// Canvas.js
// 모든 TailParticle의 상태를 업데이트하고 풀에 반환을 관리
updateTailParticle() {
    // 인덱스 밀림을 방지하기 위해 역순으로 순회
    for (let i = this.tailParticles.length - 1; i >= 0; i--) {
        // 각 파티클의 상태를 업데이트하고 다시 그린다.
        const tail = this.tailParticles[i];
        tail.update();
        tail.draw();

        // 파티클의 투명도가 OPACITY_LIMIT 이하면 배열에서 추출하고 다시 풀에 반납한다.
        if (tail.opacity <= TAIL.OPACITY_LIMIT) {
            this.tailParticles.splice(i, 1);
            this.pm.returnToPool(TYPE_TAIL, tail);
        }
    }
}

 

2-3. 애니메이션 적용

  • 이제 애니메이션 메서드에 TailParticle을 생성하고 업데이트 해보겠다.
    -. 배경색으로 전체 캔버스를 덮어 이전 프레임의 잔상을 제거한다.
        덮지 않으면 이전 프레임에서 그린 것이 사라지지 않고 계속 남아있게된다. 잔상효과를 사용하기 위해 투명도를 적당히 조절한다.
    -. 프레임을 카운트 하여 (TAIL_FPS + 글자 수)마다 새로운 TailParticle을 생성한다. 글자가 많아 질수록 많은 파티클이 생성되어 많은 연산과 렌더링이 이루어진다. 이는 성능의 저하를 초래할 수 있기 때문에 생성 간격을 늘려주어 성능 저하를 방지한다.
    -. 프레임 카운트를 콜백함수 내에 배치하여 애니메이션이 시작 되자 마자 꼬리 생성이 이루어지도록 했다. 이후 프레임 변경시에만 프레임 카운트 업데이트가 이루어지므로 더 자연스럽게 보일 것 이다.
    -. 기존 TailParticle을 업데이트 하고 캔버스에 그리며, 투명도 기준값 이하루 떨어지면 풀에 반환한다.

 

필요 상수 추가

// constants.js
export const SCREEN = {
    MAX_DPR: 3,
    SMALL_WIDTH: 480,
    // 덮을 배경색
    BG_RGB: "0, 0, 0", // 기본 배경 rgb
    ALPHA_BASE: 0.1, // alpha 베이스 값
    ALPHA_OFFSET: 0.02, // alpha 조절 값
    SPEED_CONTROL: 10, // 속도 조절 값
    ALPHA_CLEANUP: 0.3, // 잔상효과를 지울 alph 값
};

export const ANIMATION = {
    FPS: 60,
    // 꼬리 생성 간격
    TAIL_FPS: 70,
};

 

전체 화면을 덮을 캔버스 공통 메서드 추가

// CanvasOption.js
fillFullCanvas(color) {
    this.ctx.fillStyle = color;
    this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}

 

애니메이션 메서드에서 꼬리 생성하고 업데이트

  • 이전 프레임의 파티클 지우기
    -. 이전 프레임의 파티클을 지워야 애니메이션이 매끄럽게 연결되며 상태 변화를 확인할 수 있다. 지우지 않으면 화면에 계속 남아있기 때문이다.
    -. 그래서 프레임을 업데이트 할때 fillFullCanvas 메서드로 캔버스를 덮어 이전 프레임의 내용을 지운다.
    -. 이때 알파값을 sin을 이용해 동적으로 변화시키는 이유는 잔상의 강약을 주기적으로 변화시켜 부드럽고 자연스러운 시각효과를 연출할 수 있기 때문이다.
    -. sin은 주기성을 가진 곡선형태로 값이 변하기 때문에 잔상이 선형적으로 줄어드는것 보다 더 부드럽게 줄어들도록 한다.
// Canvas.js
animateFireworks() {
    let then = document.timeline.currentTime;

    // 꼬리 생성 기준을 카운트할 변수 (프레임 기반)
    let frameCount = 0;
    
    // 화면에 남는 잔상을 제거할 색상값
    const bgCleanUp = setRgbaColor(SCREEN.BG_RGB, SCREEN.ALPHA_CLEANUP);
    
    // 꼬리 생성과 화면에 남는 잔상을 제거하기 위한 프레임 생성 간격을 계산한 상수 
    // 기본 FPS 값에 (글자수 * 간격 조정 값)을 더해 산출한다.
    const addFrameCountDivisor = ANIMATION.TAIL_FPS + this.textLength * ANIMATION.TEXT_LEN_MULTIPLIER;

    /**
     * requestAnimationFrame에 전달할 콜백 함수
     * @param {number} now requestAnimationFrame의 timestamp (밀리 초)
     */
    const frame = (now) => {
        const delta = now - then;

        if (delta >= this.interval) {
            // TODO: 애니메이션 코드 업데이트

            // 캔버스를 배경색으로 채워 이전 프레임의 파티클을 지움
            // 기본 알파값과 사인값을 이용하여 이전 파티클들이 부드럽게 사라지도록 설정
            const alpha = SCREEN.ALPHA_BASE + SCREEN.ALPHA_OFFSET * Math.sin(frameCount / SCREEN.SPEED_CONTROL);
            this.fillFullCanvas(setRgbaColor(SCREEN.BG_RGB, alpha));

            // 특정 조건에서 배경에 남아있는 잔상 효과를 지우고 꼬리를 생성
            if (frameCount === 0) {
                this.fillFullCanvas(bgCleanUp);
                this.createTailParticle();
            }

            // 생성된 꼬리들을 업데이트 한다.
            this.updateTailParticle();

            // 프레임 카운트를 업데이트 하며, 나머지 연산으로 addFrameCountDivisor 보다 커지지 않도록 제한
            // 글자 수에 따라 생성 간격을 조정하여 성능 최적화를 도모
            frameCount = (frameCount + 1) % addFrameCountDivisor;

            then = now - (delta % this.interval);
        }

        this.animationId = requestAnimationFrame(frame);
    };

    this.animationId = requestAnimationFrame(frame);
}

 

3. 실행 화면

아직 스파크를 추가하기 전이라 잘 안 보일 수 있지만 원하는 효과가 잘 나온 걸 확인할 수 있다.

 

 

 

 

Github 링크