[Canvas-불꽃놀이-09] 파티클 관리와 객체 폴링

이전 글 : [Canvas-불꽃놀이-08] 애니메이션 기본 틀잡기

 

[Canvas-불꽃놀이-08] 애니메이션 기본 틀잡기

이전 글 : 2024.11.18 - [개발/Canvas] - [Canvas-불꽃놀이-07] 불꽃놀이 Particle들의 부모 클래스 [Canvas-불꽃놀이-07] 불꽃놀이 Particle들의 부모 클래스이전 글 : 2024.11.18 - [개발/Canvas] - [Canvas-불꽃놀이-06] T

jinsk-joy.tistory.com

 

파티클 매니저와 객체 폴링

1. 객체 폴링

  • 불꽃놀이 이벤트에서는 수많은 파티클들이 동시에 화면에 표시되었다 사라진다. 특히 텍스트를 구현하기 위해서는 한 글자당 최소 700개 정도의 파티클이 필요하다.
  • 최대 입력기준인 10글자 입력하면 최소 6~7,000개 이상의 파티클이 필요하다.
    텍스트 파티클 뒤에는 원형 파티클이 불꽃 효과를 내기 위해 같이 사용되므로 한 불꽃당 최소 7~9,000개 이상의 파티클이 필요하게 된다. 짧은 글자일수록 여러 개의 불꽃이 동시에 렌더링 되므로, 실제 사용되는 파티클 수는 10,000개 이상으로 증가할 수 있다.
  • 이렇게 많은 파티클의 생성과 소멸이 매 프레임마다 반복되면 심각한 성능 저하가 발생할 수 있다. 이를 해결하기 위해 객체를 재사용하는 객체 폴링(Object Pooling)을 도입하였다.
  • 파티클 매니저는 이 재사용한 객체 풀을 관리하며, 각 타입에 맞는 파티클을 반환하거나 초기화하는 역할을 한다.

1-1. 객체 폴링 사용 시 장점 

  • 성능 최적화
    파티클 객체의 생성/소멸은 연산 비용이 크기 때문에 이를 최소화하면 CPU의 부하를 줄일 수 있다.
    결과적으로 애니메이션이 프레임 드롭 없이 안정적으로 실행된다.
  • 메모리 사용 효율화
    객체를 반복적으로 생성하거나 소멸하지 않고 미리 정의된 객체 풀을 사용하여 메모리 사용량을 일정하게 유지할 수 있다.
    이를 통해 메모리 사용량의 급격한 변화로 인한 성능 문제를 줄일 수 있다.
  • 재사용성
    한번 생성된 객체는 사용 후 초기화하여 재사용이 가능하므로 GC가 빈번하게 실행되는 상황을 방지할 수 있다. 
    단, 객체를 초기화할 때 모든 속성을 정확히 리셋해야 하며, 그렇지 않을 경우 이후 사용 시 의도치 않은 문제가 발생할 수 있다.

 

2. 파티클 매니저

  • 파티클 매니저는 각 파티클들의 객체 풀을 초기화하고 관리한다.
  • 객체 풀은 애니메이션 시작 시 미리 정의된 크기로 초기화되며, 글자 수에 따라 유동적으로 크기가 조정될 수 있다.

2-1. 동작 흐름

  • 객체 요청
    애니메이션 실행 시 필요한 파티클 객체를 풀에서 가져오고, 풀이 비어있을 경우 새로 생성하여 반환한다.
  • 객체 반환
    사용이 끝난 파티클 객체는 초기화 후 다시 풀에 반환된다.
  • 유동적인 풀 크기 조정
    필요이상의 객체를 생성하지 않도록 최대 크기를 초과한 경우 초과된 객체를 삭제한다.

2-2. 코드 구현

  • 파티클들의 타입과 기본 풀 사이즈 상수로 추가
// constants.js
export const PARTICLE = {
    TYPE_TAIL: "tail",
    TYPE_SPARK: "spark",
    TYPE_TEXT: "text",
    TYPE_CIRCLE: "circle",
    TAIL_POOL: 5,
    SPARK_POOL: 600,
    TEXT_POOL: 2100,
    CIRCLE_POOL: 1000,
    // 생략...
}

  • 각 파티클 클래스 생성 (자세한 설정은 추후에)
// Tail
class TailParticle extends Particle {
    constructor({ ctx, isSmallScreen }) {
        super({ ctx, isSmallScreen });
    }
}

// Spark
class SparkParticle extends Particle {
    constructor({ ctx, isSmallScreen }) {
        super({ ctx, isSmallScreen });
    }
}

// Text
class TextParticle extends Particle {
    constructor({ ctx, isSmallScreen }) {
        super(ctx, isSmallScreen);
    }
}

// Circle
class CircleParticle extends Particle {
    constructor({ ctx, isSmallScreen }) {
        super({ ctx, isSmallScreen });
    }
}

  • 파티클 매니저 클래스 생성
// 각 파일 import는 생략

// 구조 분해 할당으로 파티클 관련된 상수 가져오기
const { TYPE_TAIL, TYPE_SPARK, TYPE_TEXT, TYPE_CIRCLE, TAIL_POOL, SPARK_POOL, TEXT_POOL, CIRCLE_POOL } = PARTICLE;

// 파티클 생성을 편하게 하기위해 만든 객체
const PARTICLE_MAP = {
    [TYPE_TAIL]: TailParticle,
    [TYPE_SPARK]: SparkParticle,
    [TYPE_TEXT]: TextParticle,
    [TYPE_CIRCLE]: CircleParticle,
};

class ParticleManager {
    // 모든 파티클의 공통 속성인 ctx, isSmallScreen을 인자로 받아와 파티클 생성시 전달한다.
    constructor(ctx, isSmallScreen) {
        this.ctx = ctx;
        this.isSmallScreen = isSmallScreen;

        this.initParticleManagerVars();
    }

    // 파티클 매니저의 초기화가 필요한 멤버 변수
    initParticleManagerVars() {
    	// 각 파티클 풀들의 최대 크기
        this.maxPoolSize = {
            [TYPE_TAIL]: TAIL_POOL,
            [TYPE_SPARK]: SPARK_POOL,
            [TYPE_TEXT]: TEXT_POOL,
            [TYPE_CIRCLE]: CIRCLE_POOL,
        };
        
        // 실제 파티클 객체를 가지고 있을 배열
        this.pool = {
            [TYPE_TAIL]: [],
            [TYPE_SPARK]: [],
            [TYPE_TEXT]: [],
            [TYPE_CIRCLE]: [],
        };
    }
}

  • 잘못된 파티클 타입이 들어올 수 있으므로 오류 방지를 위한 파티클 타입 유효성 검사
/**
 * @param {string} type tail, spark, text, circle
 */
isValidParticleType(type) {
    if (!this.pool.hasOwnProperty(type)) {
        throw new Error(`${type} 유효하지 않는 파티클 타입입니다.`);
    }
}

  • 파티클 풀을 관리하여 생성과 재사용을 관리하는 메서드
/**
 * @param {string} type tail, spark, text, circle
 * @param {object} params 파티클별로 생성에 필요한 파라미터 객체
 * @returns {TailParticle | SparkParticle | TextParticle | CircleParticle}
 * 파티클 풀에 해당 파티클이 존재하면 리셋해서 반환, 존재하지 않으면 새로운 파티클을 생성해서 반환
 */
acquireParticle(type, params) {
    // 먼저 파티클 타입의 유효성 검사 진행
    this.isValidParticleType(type);

    // 파티클 풀에 객체가 있으면 마지막 요소를 풀에서 pop후 리셋하여 반환하고
    // 파티클 풀에 사용할 수 있는 객체가 없으면 새로운 파티클을 생성하여 반환한다
    const pool = this.pool[type];
    if (pool.length > 0) {
        const particle = pool.pop();
        particle.reset(params);

        return particle;
    } else {
        params.ctx = this.ctx;
        params.isSmallScreen = this.isSmallScreen;

        return new PARTICLE_MAP[type](params);
    }
}

  • 사용이 완료된 파티클을 초기화 후 풀에 다시 반환하는 메서드
/**
 * @param {string} type tail, spark, text, circle
 * @param {TailParticle | SparkParticle | TextParticle | CircleParticle} particle
 */
returnToPool(type, particle) {
    // 파티클 타입의 유효성 검사
    this.isValidParticleType(type);

    // 파티클들의 제한 사이즈를 넘지 않을 경우에만 리셋 후 풀에 다시 반환한다
    const pool = this.pool[type];
    if (pool.length < this.maxPoolSize[type]) {
        particle.reset();
        pool.push(particle);
    }
}

 

3. 파티클 매니저 사용을 위한 초기화와 각 파티클 풀 사이즈 조정

  • 파티클 매니저는 객체 풀링을 사용하여 파티클의 생성과 소멸을 최소화하고 재사용이 가능하도록 하여 성능을 최적화하는 역할을 한다. 따라서 전체 애니메이션을 총괄하는 Canvas 클래스에서 파티클 매니저의 인스턴스를 생성하고 멤버변수로 추가하도록 한다.
  • 불꽃놀이 화면으로 진입하거나 리사이즈 이벤트가 발생되면 캔버스 크기 관련 속성이 재정의된다. 파티클 매니저의 크기와 관련 속성도 이에 맞춰 업데이트해야 정확한 애니메이션을 렌더링 할 수 있다. 
  • 특히 TextParticle 풀, SparkParticle 풀의 사이즈는 글자 수에 따라 유동적으로 조정해야 하므로 화면 진입 시 재정의 해줘야 성능을 최적화할 수 있다.
// Canvas.js
init() {
    // 생략...
    
    // 파티클 매니저 모바일 화면 여부값 재정의
    this.pm.isSmallScreen = this.isSmallScreen;
    
    // 글자 수에 따라 Text 풀 사이즈 조정
    this.pm.maxPoolSize[TYPE_TEXT] *= this.textLength;
    
    // 글자 수에 따라 Spark 풀 사이즈 조정
    this.pm.maxPoolSize[TYPE_SPARK] *= this.textLength;
}

initCanvasVars() {
    this.animationId = undefined;

    // 생략...

    // 파티클 매니저 인스턴스를 Canvas 멤버변수로 추가
    this.pm = new ParticleManager(this.ctx, this.isSmallScreen);
}

 

4. 파티클 초기 상태 관리와 재사용

  • 파티클의 재사용을 위해 각 파티클들이 가지고 있는 초기값을 정확히 복원해줘야 한다.
  • initialState
    -. 파티클이 처음 생성되었을 때의 초기 상태를 저장한다.
    -. 객체를 풀에 반환 시 해당 상태로 복원하여 항상 동일한 초기 상태에서 시작할 수 있도록 보장해 주는 역할을 한다.
    -. 부모 클래스에 initialState을 추가하고, 각 자식 클래스에서 생성 시 파티클 고유의 초기값을 설정해 주면 된다.
  • reset 메서드
    -. 풀에 반환 시 처음 상태로의 초기화, 풀에서 다시 가져올 때는 재사용을 위한 초기화 역할을 한다.
    -. 만약 자식 클래스에 initialState 값이 있을 경우, 풀에 반납하기 위한 reset, 풀에서 다시 가져와 사용하기 위한 reset 모두 initialState에 있는 값을 사용해야 한다. 모든 경우에 포함되므로 initialState 객체를 순회하여 값이 있을 경우 params에 추가하도록 한다.
class Particle {
    constructor({ ctx, isSmallScreen, x, y, vx, vy, radius, opacity, friction, color }) {
        this.ctx = ctx;
        this.isSmallScreen = isSmallScreen;
        this.initParticleVars({ x, y, vx, vy, radius, opacity, friction, color });

        // 각 파티클의 고유의 초기 기본값을 저장하는 객체 멤버변수
        this.initialState = {};
    }
    
    // 생략...
    
    /**
    * 파티클 공통 멤버변수 초기화 및 재사용을 위한 리셋
    * @param {object} [params]
    */
    reset(params={}) {
        // 풀에 반납하기 위해 reset, 풀에서 가져와 사용하기 위해 reset 
        // 모두 initialState에 있는 값이 params에 포함되어야 하므로 
        // initialState 객체를 순회하여 params에 추가해준다.
        for (const key in this.initialState) params[key] = this.initialState[key];
        this.initParticleVars(params);
    }
}

 

Github 링크

 

GitHub - jinsk9268/text-fireworks

Contribute to jinsk9268/text-fireworks development by creating an account on GitHub.

github.com