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 });
}
}
}처리 흐름.
canvas에 텍스트 렌더링.
getImageData()로 픽셀 정보 추출.밝은 픽셀만 샘플링.
파티클의 목표 좌표로 사용.
🌊 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 환경에 자연스럽게 통합될 수 있도록 구성했다.

아키텍처 설계.
core: 모든 효과는 vanilla js로 먼저 구현.
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)형태로 생성된다.초기화 과정은 아래와 같은 순서로 진행된다.
컨테이너 결정 : selector 문자열 또는 HTMLElement 지원, 컨테이너가 없을 경우 에러 케이스로 판단하고 이후 로직 실행 중단.
설정 병합 : 기본값 + 사용자 옵션 병합. (크기, 텍스트, 파티클 밀도, 인터랙션 관련 옵션 포함)
canvas 구성 : 메인 canvas: 실제 렌더링. (offscreen canvas 텍스트 렌더링 픽셀 중 일정 간격으로 파티클 위치로 변환)
초기 상태 세팅 : DPR(Device Pixel Ratio) 계산. 파티클 배열, 마우스 상태, 애니메이션 플래그 초기화.
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가 제시한 코드나 방향을 그대로 사용하는 것이 아니라, 왜 이런 접근을 제안했는지, 어떤 의도를 담고 있는지를 하나씩 이해하고 정리하는 과정이 중요하다고 느꼈고 많이 배웠다.
댓글