이전 글 : [Canvas-불꽃놀이-12] text 픽셀 데이터 생성
TextParticle 클래스 구현과 화면에 렌더 하기
1. TextParticle 클래스 세부 구현
기존에 틀만 만들어 놓은 TextParticle의 세부적인 내용을 정의한다.
1-1. 생성자와 멤버변수
|
// constants.js
export const TEXT = {
GRAVITY: 0.015, // 중력값 (파티클의 하강 속도 제어)
}
class TextParticle extends Particle {
constructor({ ctx, isSmallScreen, x, y, vx, vy, color }) {
super({ ctx, isSmallScreen, x, y, vx, vy, color });
this.gravity = TEXT.GRAVITY;
}
}
1-2. draw와 update
|
export const TEXT = {
GRAVITY: 0.015,
OPACITY_OFFSET: 0.01, // 투명도 감소값
}
// TextParticle.js
update() {
// 일정 비율로 투명도 감소
this.opacity -= TEXT.OPACITY_OFFSET;
// y좌표에 중력값을 더해 시간이 지남에 따라 아래로 하강하도록 설정한다.
this.vy += this.gravity;
// 부모의 update 메서드 호출 (공통 속성 업데이트)
super.update();
}
2. TextParticle 생성과 업데이트
TextParticle 클래스를 구현했으니 이제 파티클을 생성하여 애니메이션을 적용할 차례다.
애니메이션 시작 전 미리 생성된 텍스트 픽셀 데이터는 효율을 위해 고정된 캔버스 위치에 생성된다.
만약 이 픽셀 데이터의 위치값을 그대로 사용하면, 꼬리가 사라진 부분에서 텍스트 파티클이 퍼지는 것이 아니라
파티클들이 기존 텍스트 데이터를 생성했던 고정된 위치로 이동하며 퍼지는 것처럼 보이게 된다.
그러므로 의도한 애니메이션을 구현하기 위해 픽셀 데이터의 위치를 TextParticle을 생성하는 위치에 맞춰 변환해주어야 한다.
2-1. TextParticle 생성
|
isMain
- 꼬리가 사라진 부분의 x좌표의 위치에 따라 메인 텍스트인지 서브 텍스트인지 구분해야 한다.
메인과 서브의 폰트 크기가 다르므로 파티클 생성 시 위치에 맞는 텍스트 데이터를 사용해야 하기 때문이다.
// Canvas.js
// x 좌표값을 받아
isMain(x) {
// x가 꼬리의 왼쪽 좌표배열의 마지막 값보다 크고
// x가 꼬리의 오른쪽 좌표 배열의 첫번째 값보다 작으면 true를 반환하고 아니면 false를 반환한다.
return x > this.tailsLeftPosX.at(-1) && x < this.tailsRightPosX.at(0);
}
isEdge
- TextParticle의 개수를 감소시키기 위해 외곽선의 픽셀값만 가져오도록 한다.
- 이를 판별하기 위해서 픽셀의 위, 아래, 좌, 우 중 하나라도 알파값이 0이라면 외곽선으로 볼 수 있으니 true를 반환해 파티클을 생성하도록 한다.
- 인덱스를 구할 때 2차원 배열에서 (w, h) 좌표를 아래 식을 이용하여 1차원 배열의 인덱스로 변환할 수 있다.
// 변환 공식 (2차원 -> 1차원)
index = (h * width + w) * 4
- h * width : 해당 h행 이전의 모든 픽셀 수
- w : 현재 행(h)에서 가로 방향으로 몇 번째 픽셀인지
- 4 : 각 픽셀이 (r, g, b, a) 4개의 값을 가지므로, 픽셀 데이터를 정확히 찾기 위해 4를 곱함.
// Canvas.js
/**
* @param {Uint8ClampedArray} data
* @param {number} w
* @param {number} h
* @param {number} width
* @returns 픽셀이 외곽선이면 true, 아니면 false를 반환
*/
isEdge(data, w, h, width) {
// 2D의 인덱스를 1D 인덱스로 변환하여 alpha 값만 추출
const index = (h * width + w) * 4;
const alpha = data[index + 3];
// alpha 값이 0 이하면 파티클을 생성하지 않는다.
if (alpha <= 0) return false;
const direction = [
((h - 1) * width + w) * 4, // Up
((h + 1) * width + w) * 4, // Bottom
(h * width + (w - 1)) * 4, // Left
(h * width + (w + 1)) * 4, // Right
];
// 픽셀의 위, 아래, 좌, 우 방향 중 하나라도 투명도가 0이하면 외곽선으로 볼 수 있으니 true를 반환, 아니면 false를 반환
return direction.some((dir) => data[dir + 3] <= 0);
}
calculateTextParticleVelocity
- 텍스트 파티클이 목표 위치(글자 모양)로 향하는 초기 속도를 계산한다.
- 텍스트 파티클은 생성 시 지정된 위치(x, y)에서 시작해 텍스트 모양으로 퍼지며 이동한다.
- 한 점에서 시작해 퍼져 나가는 방식으로 동작하므로 속도는 이동거리를 기준으로 속도를 계산한다. (이동거리 / 시간)
-. 이동 거리 : 도달해야 되는 목표위치(targetX, targetY)에서 현재 위치(x, y)를 뺀 값
-. 시간 : 프레임 간격 (interval)
// Canvas.js
/**
* @param {object} params
* @param {number} params.stringCenterX 문자열 중심의 x좌표 (물리적 크기)
* @param {number} params.stringCenterY 문자열 중심의 y좌표 (물리적 크기)
* @param {number} params.w 픽셀 데이터의 현재 가로 위치 (물리적 크기)
* @param {number} params.h 필셀 데이터의 현재 세로 위치 (물리적 크기)
* @param {number} params.x 꼬리의 x좌표 (css 크기)
* @param {number} params.y 꼬리의 y좌표 (css 크기)
* @returns {object} TextParticle x,y 좌표의 초기 속도 반환
*/
calculateTextParticleVelocity({ stringCenterX, stringCenterY, w, h, x, y }) {
// 문자열 중심에서 각 픽셀의 목표 좌표 계산 (css 크기로 변환)
const targetX = (stringCenterX + w) / this.dpr;
const targetY = (stringCenterY + h) / this.dpr;
// 초기 속도 계산 : 이동 거리 / 프레임 간격
return { vx: (targetX - x) / this.interval, vy: (targetY - y) / this.interval };
}
createTextParticle
- 텍스트 파티클을 생성한다.
1. TextParticle 생성에 필요한 값을 픽셀 데이터에서 구조분해 할당으로 가져오기
- x의 위치에 따라 사용할 textData가 달라지므로 조건문을 통해 알맞은 데이터를 가져오도록 한다.
// Canvas.js
// 꼬리가 사라질 때 x, y 위치를 받아 TextParticle을 생성한다.
createTextParticle(x, y) {
// x 좌표가 main 위치면 mainTextData, main 이외의 위치 subTextData 사용
const { data, width, height, fontBoundingBoxAscent, fontBoundingBoxDescent }
= this.isMain(x) ? this.mainTextData : this.subTextData;
// 생략...
}
2. TextParticle로 문자열을 그리기 위해서 생성 좌표 위치를 기준으로 픽셀 데이터(문자열)의 가로, 세로 중심을 구하기
- 텍스트 데이터는 캔버스 물리적 크기 기준으로 데이터가 생성되며 효율을 위해 캔버스 고정 위치값을 가지고 있다.
- 이를 기반으로 텍스트 파티클을 생성하려면 꼬리가 사라진 위치 (x, y)를 중심으로 픽셀의 위치를 변환해야 한다.
- 생성 위치(x, y)에 맞춰 문자열의 중심 좌표를 계산한 후 이 좌표를 기준으로 애니메이션을 실행해야 원하는 효과를 얻을 수 있다.
- 생성 위치인 x, y는 css 크기 기준이므로 dpr을 곱해 물리적 크기로 변환하여 문자열의 중심 좌표를 계산한다.
- stringCenterX
-. 물리적 크기로 환산한 x좌표 위치에서 문자열 가로의 절반을 차감하면 문자열 x축의 중심을 구할 수 있다. - stringCenterY
-. 물리적 크기로 환산한 y좌표 위치에서 문자열의 세로의 절반을 차감하면 문자열 y축의 중심을 구할 수 있다.
-. 문자열의 세로는 픽셀 데이터를 생성할 때 추가한 안전 마진 값들을 더해줘야 정확한 중심을 구할 수 있다.
createTextParticle(x, y) {
// 생략...
// 문자열의 중심 좌표 계산
const stringCenterX = x * this.dpr - width / 2;
const stringCenterY = y * this.dpr - (height + fontBoundingBoxAscent + fontBoundingBoxDescent) / 2;
// 생략...
}
3. 반지름 조정
- 글자 수가 많을수록 반지름 크기를 줄여 가독성을 키워준다.
createTextParticle(x, y) {
// 생략...
// 글자 길이별 반지름 조정
const radius = PARTICLE.RADIUS - TEXT.RADIUS_OFFSET * this.textLength;
// 생략...
}
4. 픽셀 데이터를 순회하면서 텍스트 파티클을 생성하기
- ctx.getImageData()를 통해 가져오는 픽셀 데이터는 1차원 배열로 저장된다.
이미지는 원래 가로(width)와 세로(height)로 구성된 2차원 배열이지만,
컴퓨터는 메모리 데이터를 연속적으로 저장하므로 2차원 배열을 1차원 배열로 변환하여 저장한다.
이 방식은 메모리 공간을 효율적으로 사용하며, 연속적인 데이터 접근을 통해 CPU 캐시 효율을 극대화한다.
따라서 1차원 배열을 순회하면서도 2차원적인 좌표(w, h)를 계산하여 처리하는 것이 성능적으로 유리하다. - 배열의 각 요소는 이미지의 가로(width) 방향으로 채워진 다음, 세로(height) 방향으로 순차적으로 이어지는 형태이다.
예를 들어 텍스트 데이터의 width가 336, height이 219면 픽셀 데이터 비율은 아래와 같이 저장된다.
// 원래는 이미지는 2차원 배열의 형태인데
[
// 열 0 ............. 열 336*4-1 (width: 336 * rgba: 4)
[r, g, b, a, r, g, b, a, r, g, b, a, ..., r, g, b, a], // 행 0
[r, g, b, a, r, g, b, a, r, g, b, a, ..., r, g, b, a], // ...
[r, g, b, a, r, g, b, a, r, g, b, a, ..., r, g, b, a] // 행 219-1 (height: 219)
]
// 이미지 데이터의 형태 : 메모리 효율성을 위해 1차원 배열로 연속적으로 저장
// 열 0
[r, g, b, a, r, g, b, a, r, g, b, a, ..., r, g, b, a, // 행 0
r, g, b, a, r, g, b, a, r, g, b, a, ..., r, g, b, a, // ...
r, g, b, a, r, g, b, a, r, g, b, a, ..., r, g, b, a] // 행 219-1
// 열 336*4*219-1
- 우리가 실제로 다루는 이미지는 2차원이다.
1차원인 픽셀 데이터를 순회할 때 2차원 배열처럼 순회하면 데이터에 효과적으로 접근할 수 있다.
-. 바깥 루프가 height (세로 방향): 행 단위로 데이터를 처리
-. 안쪽 루프가 width (가로 방향): 각 행의 픽셀을 순서대로 처리
// constants.js
export const TEXT = {
// 생략...
SMALL_CREATE_RATIO: 0.35,
CREATE_RATIO: 0.5,
RADIUS_OFFSET: 0.02,
MIN_HUE: 50,
MAX_HUE: 60,
};
// Canvas.js
createTextParticle(x, y) {
// 생략...
// 텍스트 픽셀 데이터를 순회하며 TextParticle을 생성
for (let h = 0; h < height; h++) { // 세로 (height) 방향 순회
for (let w = 0; w < width; w++) { // 가로 (width) 방향 순회
// 파티클 수 조정을 위해 랜덤 값이 일정 비율 이하일때만 파티클을 생성한다.
const shouldRender = Math.random() < (this.isSmallScreen ? TEXT.SMALL_CREATE_RATIO : TEXT.CREATE_RATIO);
// 외곽선과 렌더 여부가 참일때만 파티클을 생성한다.
if (this.isEdge(data, w, h, width) && shouldRender) {
// 파티클 초기 속도 계산
const { vx, vy } = this.calculateTextParticleVelocity({ centerX, centerY, w, h, x, y });
// TextParticle의 속성과 색상 설정
// color는 hsla값으로 하며 지정한 hue 값 사이에서 랜덤하게 결정
const params = { x, y, vx, vy, color: setHslaColor({ hue: randomInt(TEXT.MIN_HUE, TEXT.MAX_HUE) }) };
// 파티클을 생성또는 재활용하여 활성화된 파티클 배열에 추가
this.textParticles.push(this.pm.acquireParticle(TYPE_TEXT, params));
}
}
}
}
2-2. TextParticle 업데이트
|
- 파티클의 투명도는 파티클 클래스에서 관리하므로 투명도가 제한선 이하인지 판별하는 메서드를 공통 기능으로 추가한다.
기본값을 0으로 두어 투명도 제한선을 관리하도록 한다.
// Particle.js
/**
* @param {number} [opacityLimit]
* @returns 파티클의 투명도가 제한 기준 이하면 true를 반환, 초과면 false를 반환
*/
belowOpacityLimit(opacityLimit = 0) {
return this.opacity <= opacityLimit;
}
- 캔버스 경계와 관련된 로직과 요소는 CanvasOption 클래스에서 관리하고 있다.
파티클이 캔버스 영역을 벗어 낫는지 확인하는 메서드는 CanvasOption에 추가하여 사용하도록 한다.
// CanvasOption.js
/**
* @param {Particle} particle
* @returns 파티클이 캔버스 영역을 벗어날 경우 true를 반환, 영역안에 있으면 false를 반환
*/
isOutOfCanvasArea(particle) {
return particle.x < 0 || particle.x > this.canvasCssWidth
|| particle.y < 0 || particle.y > this.canvasCssHeight;
}
- TextParticle의 상태를 업데이트하고 사용을 다하면 풀에 반납한다.
// Canvas.js
updateTextParticle() {
for (let i = this.textParticles.length - 1; i >= 0; i--) {
const text = this.textParticles[i];
text.update();
text.draw();
// 투명도가 0이하면 활성배열에서 제거해 풀에 반납한다.
if (text.belowOpacityLimit() || this.outOfCanvasArea()) {
this.textParticles.splice(i, 1);
this.pm.returnToPool(TYPE_TEXT, text);
}
}
}
- 전체 애니메이션을 총괄하는 animateFireworks 메서드에서 TextParticle의 역할을 추가한다
1. 캔버스를 지우고 새로운 프레임을 준비한다.
2. 일정한 프레임 간격으로 TailParticle을 생성한다.
3. TailParticle이 사라질 때 TextParticle을 생성하고 이를 업데이트하여 파티클이 글자모양으로 퍼지게 한다.
// Canvas.js
animateFireworks() {
let then = document.timeline.currentTime;
let frameCount = 0;
const bgCleanUp = setRgbaColor(SCREEN.BG_RGB, SCREEN.ALPHA_CLEANUP);
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();
// TextParticle 업데이트 실행
this.updateTextParticle();
frameCount = (frameCount + 1) % addFrameCountDivisor;
then = now - (delta % this.interval);
}
this.animationId = requestAnimationFrame(frame);
};
this.animationId = requestAnimationFrame(frame);
}
3. 실행 화면
영상과 같이 꼬리가 사라진 부분에서 점점 확대되는 텍스트 파티클의 애니메이션을 볼 수 있다.
Github 링크
'Canvas > 불꽃놀이 프로젝트' 카테고리의 다른 글
[Canvas-불꽃놀이-15] CircleParticle 구현과 업데이트 (1) | 2024.11.30 |
---|---|
[Canvas-불꽃놀이-14] 원형 불꽃 효과 생성 (2) | 2024.11.29 |
[Canvas-불꽃놀이-12] text 픽셀 데이터 생성 (1) | 2024.11.25 |
[Canvas-불꽃놀이-11] 불꽃놀이 꼬리 완성 (1) | 2024.11.24 |
[Canvas-불꽃놀이-10] 불꽃놀이 꼬리의 위치와 속도 구현 (0) | 2024.11.22 |