mason
mason-log.

GitHub 로그인 중

masoneffect NPM 개발 기록.(ver1)
mason

masonfe-hyunsu

✏️ 1. 개요 : 개발 배경과 방향 정리.

  • 개인 사이드 프로젝트 개발 중에 인트로 화면에 사용할 애니메이션 시각 효과를 구현하면서 텍스트를 파티클로 분해하는 Canvas 로직을 만들었고, 재사용할 수 있도록 패키지화하면 좋겠다고 생각했다.

기존 레퍼런스 체크.

  • 먼저, 기존 관련 라이브러리의 시장 조사를 해보았다.

    • particles.js / tsparticles : 일반 파티클 효과 중심, 텍스트 변환 기능 없음.

    • three.js : 강력하지만 무겁고 학습 비용이 큼.

    • anime.js / GSAP : 범용 애니메이션 라이브러리, 파티클 전용 구조 아님.

결론!

텍스트를 파티클로 변환하고 인터랙션까지 제공하는 라이브러리는 찾지 못했다.

  • 해서! masoneffect NPM Package를 만들게 되었고, 개발 과정에 대해 기록해 본다.


🤔 2. 구상 : 패키지화를 고려하며 정리한 것.

  • 막상 재사용이 가능한 패키지 배포를 기준으로 바라보니, 고민해야 할 지점이 많았다.

  • 코드의 역할과 경계를 다시 정의하게 되었고, 그 과정에서 특히 아래와 같은 부분들을 중점적으로 정리해 보았다.

정리

역할 정리.

  • 특정 프로젝트에 종속되지 않고 타 패키지에 의존성 없는 순수한 core 로직 분리.

  • canvas 크기, 밀도, 인터랙션을 제어할 수 있는 옵션 구조.

  • react / vue / vanilla JS에서 모두 사용할 수 있는 일관된 사용 방식.

  • 빌드 결과물을 고려한 모듈 포맷(CJS / ESM / UMD) 설계.

  • 외부 사용자를 고려한 최소한의 문서와 예제 코드.

구현 스펙 정리.

  • 텍스트 -> 파티클 모핑 효과.

  • 마우스 기반 인터랙션 (반발 / 흡입).

  • 반응형 canvas 레이아웃.

  • React / Vue / Vanilla JS 지원.


🎯 3. 구현 개요 : 구조와 핵심 로직 요약.

목표 : 텍스트를 픽셀 단위로 분해하고, 그 픽셀 좌표를 파티클의 목표 위치로 사용한다.

  • 이를 위해 canvas의 픽셀 데이터를 직접 다루는 방식으로 접근해 본다.

구현개요

(1) 텍스트 -> 파티클 변환 로직.

  • canvas 2D API의 getImageData()로 픽셀 데이터 추출.

  • getImageData()를 사용하면 지정한 영역의 픽셀 정보를 RGBA 형태로 모두 가져올 수 있다.

  • (* canvas 2D API는 내부적으로 RGBA(4채널, 8bit) 포맷을 사용하기 때문에, 다른 색상 포맷으로 직접 가져오는 것은 불가능하다. 필요한 경우 RGBA 데이터를 기반으로 직접 가공해 사용해야 한다.)

const imageData = offCtx.getImageData(0, 0, W, H);
const img = imageData.data;
  • imageData.data는 길이가 W(가로) * H(세로) * 4(RGBA)1차원 배열.

  • 각 픽셀은 [R, G, B, A] 순서로 저장된다.

  • 예를 들어 W = 3, H = 2일 경우 구조는 다음과 같다.

[
  p0R,
  p0G,
  p0B,
  p0A, // (0,0)
  p1R,
  p1G,
  p1B,
  p1A, // (1,0)
  p2R,
  p2G,
  p2B,
  p2A, // (2,0)
  p3R,
  p3G,
  p3B,
  p3A, // (0,1)
  p4R,
  p4G,
  p4B,
  p4A, // (1,1)
  p5R,
  p5G,
  p5B,
  p5A, // (2,1)
];
  • 2차원 좌표 (x, y)에 해당하는 인덱스는 다음 공식으로 계산한다.

const i = (y * W + x) * 4;
const r = img[i];
const g = img[i + 1];
const b = img[i + 2];
const a = img[i + 3];

(2) 텍스트 픽셀 판별 기준.

  • 오프스크린 canvas에 텍스트를 그릴 때 기본 설정은 다음과 같다.

offCtx.fillStyle = '#ffffff';
offCtx.fillText(text, centerX, centerY);
  • 텍스트 영역: (255, 255, 255)

  • 배경 영역: (0, 0, 0) 또는 투명.

  • 이를 이용해 “이 픽셀이 글자의 일부인가?” 를 판단한다.

if (r + g + b > 600) {
  // 글자 영역
}
  • RGB 합의 최대값은 255 * 3 = 765

  • 일정 threshold 이상이면 글자로 판단.

  • 안티앨리어싱으로 인한 회색 픽셀은 threshold 조절로 제어 가능.

  • 안티앨리어싱이란?

안티앨리어싱
  • 텍스트를 캔버스에 그릴 때, 브라우저는 안티앨리어싱(anti-aliasing) 을 자동으로 적용함.

  • 글자 테두리가 정해진 컬러값 그대로만 표기하면 깨져 보이게 되면서 경계 부분의 픽셀에 글자색과 배경 을 섞어서 중간톤을 그려줌.


(3) 좌표 샘플링.

좌표샘플링
  • 모든 픽셀을 파티클로 만들면 성능 부담이 크기 때문에 일정 간격으로 샘플링하는 방식이 필요했고, step 라는 값을 정의한 뒤 이 step 만큼의 간격으로 샘플링한다.

for (let y = 0; y < H; y += step) {
  for (let x = 0; x < W; x += step) {
    const i = (y * W + x) * 4;
    if (img[i] + img[i + 1] + img[i + 2] > 600) {
      targets.push({ x, y });
    }
  }
}

처리 흐름.

  1. canvas에 텍스트 렌더링.

  2. getImageData()로 픽셀 정보 추출.

  3. 밝은 픽셀만 샘플링.

  4. 파티클의 목표 좌표로 사용.


🌊 4. 파티클 애니메이션 : 텍스트를 파티클로 표현하는 방식.

파티클애니메이션
  • 각 파티클은 다음 요소의 영향을 받는다.

    • 목표 좌표로 끌어당기는 힘.

    • 마우스 반발 / 흡입.

    • 감속 및 미세 진동.

// 1. 목표 좌표(target)로 끌어당기는 기본 힘.
// 현재 위치(p.x, p.y)에서 목표 위치(p.tx, p.ty)까지의 거리 차이.
// ax, ay : 파티클이 목표 지점으로 천천히 끌려가도록 하는 힘.
// ease : 이 값이 작을수록 부드럽고 느리게 이동.

let ax = (p.tx - p.x) * ease;
let ay = (p.ty - p.y) * ease;

// 2. 마우스 인터랙션. (반발 / 흡입)

if (mouse.x || mouse.y) {
  // 파티클과 마우스 사이의 x, y 방향 거리 차이.
  const dx = p.x - mouse.x; // 파티클의 현재 위치 x 좌표 - 마우스 위치의 x 좌표.
  const dy = p.y - mouse.y; // 파티클의 현재 위치 y 좌표 - 마우스 위치의 y 좌표.

  // 두 점 사이의 실제 거리 : 루트 [dx의 제곱 + dy의 제곱]
  // 거리의 제곱 계산 (Math.sqrt 연산을 줄이기 위한 최적화)
  const d2 = dx * dx + dy * dy;

  // 반응 반경 (Device Pixel Ratio - DPR을 고려한 실제 반경)
  const r = repelRadius * DPR;

  // 파티클이 마우스 영향 반경 안에 들어왔을 때만 처리.
  if (d2 < r * r) {
    // 실제 거리
    const d = Math.sqrt(d2);

    // 마우스 상태에 따른 힘의 방향.
    // mouse.down === true  -> 흡입 액션!
    // mouse.down === false -> 반발 액션!
    const f = (mouse.down ? -1 : 1) * repelStrength * (1 - d / r);

    // 마우스 방향으로부터 밀거나 당기는 힘을 가속도에 추가.
    ax += (dx / d) * f * 6.0;
    ay += (dy / d) * f * 6.0;
  }
}

// 3. 속도 및 위치 업데이트.
// 기존 속도에 가속도를 더하고 랜덤 감쇠를 적용해 미세한 흔들림을 구현.

p.vx = (p.vx + ax) * Math.random();
p.vy = (p.vy + ay) * Math.random();

// 최종 위치 업데이트.
p.x += p.vx;
p.y += p.vy;

Morph (텍스트 변경)

  • morph("Hello") : 텍스트 변경 후 새로운 목표 좌표로 파티클 이동.

Scatter (초기 상태 복귀)

  • initialX / initialY 를 목표 좌표로 설정.

  • 파티클이 흩어지며 원래 위치로 이동.


🦄 5. 프레임워크 통합. (React, Vue, Vanilla JS)

  • masoneffect는 특정 프레임워크에 종속되지 않도록, core 로직을 중심으로 react, vue, vanilla js 환경에 자연스럽게 통합될 수 있도록 구성했다.

프레임워크통합

아키텍처 설계.

  1. core: 모든 효과는 vanilla js로 먼저 구현.

  2. wrapper pattern: 각 프레임워크는 core를 래핑하는 컴포넌트 제공.

🏗️ 프로젝트 구조.

src/
├── core/       # Vanilla JS 코어 구현
├── react/      # React 컴포넌트
└── vue/        # Vue 컴포넌트

(1) core.

  • core/index.js는 masoneffect의 가장 기본이 되는 로직으로, 프레임워크와 무관하게 동작하는 canvas 기반 파티클 애니메이션 로직을 담당한다.

  • React, Vue, vanilla js 래퍼는 모두 이 core 클래스를 감싸는 형태로 구성하는 것을 목표!

정리 : core의 역할.

  • DOM 컨테이너를 기준으로 canvas를 생성하고 관리.

  • 텍스트를 픽셀 단위로 분석해 파티클 목표 좌표 생성.

  • 파티클의 이동, 상호작용, 애니메이션 루프 처리.

  • 외부 환경(프레임워크)과 분리된 순수 로직 제공.

core : 초기화 flow.

  • core 클래스는 new MasonEffect(container, options) 형태로 생성된다.

  • 초기화 과정은 아래와 같은 순서로 진행된다.

  1. 컨테이너 결정 : selector 문자열 또는 HTMLElement 지원, 컨테이너가 없을 경우 에러 케이스로 판단하고 이후 로직 실행 중단.

  2. 설정 병합 : 기본값 + 사용자 옵션 병합. (크기, 텍스트, 파티클 밀도, 인터랙션 관련 옵션 포함)

  3. canvas 구성 : 메인 canvas: 실제 렌더링. (offscreen canvas 텍스트 렌더링 픽셀 중 일정 간격으로 파티클 위치로 변환)

  4. 초기 상태 세팅 : DPR(Device Pixel Ratio) 계산. 파티클 배열, 마우스 상태, 애니메이션 플래그 초기화.

  5. init() 실행 : resize -> 이벤트 바인딩 -> 애니메이션 시작. (onReady 콜백 호출)


React.

  • React에서는 DOM이 실제로 렌더링되기 전까지 컨테이너의 크기를 신뢰할 수 없기 때문에, 레이아웃이 안정된 이후에 MasonEffect 인스턴스를 생성하도록 처리했다.

useEffect(() => {
  const init = () => {
    const rect = container.getBoundingClientRect();
    if (!rect.width) return requestAnimationFrame(init);
    instanceRef.current = new MasonEffect(container, options);
  };
  requestAnimationFrame(init);
}, []);
  • useEffect를 통해 마운트 이후 실행.

  • 인스턴스는 ref로 관리.


Vue 3

  • Vue 3에서는 Composition API 기반으로 통합했다.

  • setup() 내부에서 인스턴스 생성 및 관리.

  • expose()를 통해 외부에서 메서드 호출 가능.

  • watch()로 옵션 변경 감지 및 반영.

  • 프레임워크 특성을 해치지 않으면서 Vue스럽게 사용할 수 있는 구조를 유지하는 데 초점을 맞췄다.


바닐라 JavaScript.

  • 프레임워크 없이도 가장 단순한 형태로 사용할 수 있도록 core API는 그대로 노출한다.

const effect = new MasonEffect(container, options);
effect.morph('Hello');
effect.scatter();
  • 별도의 래퍼 없이 즉시 사용 가능.

  • 모든 프레임워크 통합의 기준이 되는 형태.


정리.

  • core/index.js렌더링, 애니메이션, 인터랙션을 모두 책임지는 순수 엔진 레이어다.

이 구조 덕분에,

  • 프레임워크와 무관한 재사용이 가능하고,

  • React / Vue 래퍼는 수명주기와 상태 관리에만 집중할 수 있으며,

  • 성능 및 로직 변경도 core 한 곳에서 일관되게 관리할 수 있다.


😔 6. Troubleshooting : React resize 렌더링.

크기 및 해상도 로직. (resize)

  • core는 컨테이너 크기를 기준으로 canvas를 동적으로 설정한다.

  • width / height 옵션이 있으면 우선 사용.

  • 없을 경우 컨테이너 크기 기준으로 계산.

  • DPR을 적용해 실제 캔버스 해상도 보정.

  • 리사이즈 시 자동으로 타겟 좌표 재계산.

🚨 문제 발생!.

  • 반응형을 고려한 이 구조 때문에 컨테이너 크기가 0이면 파티클이 생성되지 않는 문제가 발생되었고, 이 때문에 React 통합 과정에서 트러블 슈팅이 발생되었다. (ㅠㅠ)

프레임워크통합

원인 체크.

  • React 컴포넌트가 마운트되는 시점에 MasonEffect가 렌더링될 컨테이너의 크기가 0 이거나, 아직 레이아웃 계산이 완료되지 않은 상태일 수 있다.

  • 이 경우 캔버스 초기화가 정상적으로 이루어지지 않아 아무것도 표시되지 않는다.

  • window.resize 처리도 했는데... 왜지? 왜 안되는거지?

  • window.resize는 브라우저 창 크기만 감지하고 요소 자체의 크기 변경은 감지하지 못한다. 문제는 컨테이너 크기가 0인 상태에서 레이아웃 계산이 진행되지 않았다는 점이었다.

해결 방향.

  • 물론 컨테이너에 명시적인 크기를 지정해주기만 하면 해결되긴 한다.

// 컨테이너에 명시적인 크기 지정.
<div style={{ width: '100%', height: '500px' }}>
  <MasonEffect text="Hello" />
</div>

// 또는, 뷰포트 기준으로 지정.
<div style={{ width: '100%', height: '70vh' }}>
  <MasonEffect text="Hello" />
</div>

// 또는, height prop 사용.
<MasonEffect text="Hello" height={500} />

// 이렇게도 가능! - style prop으로 크기 지정.
<MasonEffect text="Hello" style={{ width: '100%', height: '500px' }} />
  • 하지만! 유연한 패키지로 거듭나기 위해선, 이런 허들은 용납할 수 없지.

  • 내장 JavaScript API 인 ResizeObserver를 사용하였고, ResizeObserver 는 window 크기와 상관없이 특정 요소 자체의 width / height 변경을 감지할 수 있다.

  • canvas 기반 렌더링처럼 컨테이너 크기에 직접 의존하는 경우 유용하게 사용된다.

const observer = new ResizeObserver(() => {
  instance.resize();
});

observer.observe(container);
  • 이제 컨테이너에 명시적으로 크기를 지정하지 않아도, 렌더링이 무사히 진행된다.


🏂 7. 마무리 : 회고.

마무리
  • 여기까지 Text -> Particle 효과로 변환하기 위해 어떤 방식으로 접근했고, 그 과정을 어떻게 패키지 형태로 정리했는지를 정리해 보았다.

  • 이번 과정을 통해 프레임워크 통합의 핵심은 “언제 DOM이 준비되는가”를 정확히 판단하는 일이 중요했다. 동시에 독립적인 core를 유지하면서 여러 프레임워크에서 안정적으로 동작하는 경계를 확인할 수 있었다.

  • 개발을 진행하면서 AI를 통해 다양한 아이디어를 얻고, 구현과 정리 과정에서 많은 도움을 받았다.

  • AI가 제시한 코드나 방향을 그대로 사용하는 것이 아니라, 왜 이런 접근을 제안했는지, 어떤 의도를 담고 있는지를 하나씩 이해하고 정리하는 과정이 중요하다고 느꼈고 많이 배웠다.


🔗 8. 프로젝트 링크.

이전 글
렌더링 타입 (CSR, SSR, SSG, ISR).

댓글

불러오는 중...
목록으로