Canvas 2D: 텍스트를 픽셀로 다루기
이전 포스트에서 GSAP의 Timeline, ScrollTrigger, SplitText로 시간축 위에 애니메이션을 배치하는 방법을 살펴봤다. 6개 컴포넌트 모두 DOM 요소를 대상으로 했다 — CSS transform, opacity, GSAP 트윈이 조작하는 것은 결국 HTML 요소의 스타일 속성이다. 이번 포스트에서는 DOM을 벗어나 Canvas 2D로 텍스트를 다룬다.
DOM 텍스트 vs Canvas 텍스트
DOM에서 텍스트는 박스 모델 안에 살아간다. <span>, <p>, <div> — 브라우저가 레이아웃을 계산하고, CSS로 색상/크기/위치/투명도를 조작한다. 선택, 복사, 스크린 리더 접근이 모두 가능하다.
Canvas에서 텍스트는 픽셀 버퍼 위의 그림이다. fillText()로 찍으면 그 순간 비트맵으로 래스터화되고, 이후에는 그냥 픽셀 덩어리다. 텍스트라는 의미 정보는 사라진다.
| 기준 | DOM 텍스트 | Canvas 텍스트 |
|---|---|---|
| 조작 단위 | HTML 요소 (박스) | 픽셀 (비트맵) |
| 스타일링 | CSS 속성 | ctx.fillStyle, ctx.font |
| 애니메이션 | CSS/JS로 속성 변경 | 매 프레임 다시 그리기 |
| 접근성 | 스크린 리더, 텍스트 선택 가능 | 불가능 (fallback 필요) |
| 픽셀 조작 | 불가능 | getImageData, drawImage |
| SEO | 크롤러 인식 가능 | 크롤러 인식 불가 |
왜 텍스트를 Canvas에 그리나? DOM으로는 할 수 없는 것이 두 가지 있다:
- 픽셀 단위 조작 — 행 단위로 오프셋을 주거나, 특정 영역의 색상을 반전하거나, 노이즈를 섞는 것은 CSS로 불가능하다.
- 물리 시뮬레이션 렌더링 — 물리 엔진의 바디를 시각적으로 디버깅하려면 Canvas가 필요하다 (DOM 동기화는 별도).
이 포스트에서 다루는 두 컴포넌트가 정확히 이 두 가지다:
- FuzzyText — Canvas
drawImage로 행 단위 스캔라인 왜곡 - FallingText — Matter.js 물리 바디 + DOM 요소 위치 동기화 (Canvas는 디버그용)
Canvas 2D API 기초 + FuzzyText
💡 Canvas 2D API — 텍스트를 비트맵으로 바꾸기
Canvas 2D의 텍스트 관련 핵심 API:
// 1. 컨텍스트 획득
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 2. 폰트 설정 + 텍스트 측정
ctx.font = '900 64px sans-serif';
ctx.textBaseline = 'alphabetic';
const metrics = ctx.measureText('FUZZY');
// metrics.width = 전체 폭
// metrics.actualBoundingBoxAscent = 기준선 위 높이 (예: 47px)
// metrics.actualBoundingBoxDescent = 기준선 아래 높이 (예: 1px)
// 3. 텍스트를 비트맵으로 렌더링
ctx.fillStyle = '#fff';
ctx.fillText('FUZZY', 0, metrics.actualBoundingBoxAscent);
// 4. 비트맵을 다른 캔버스로 복사 (행 단위 가능)
targetCtx.drawImage(
sourceCanvas, // 소스
sx, sy, sw, sh, // 소스 영역 (x, y, width, height)
dx, dy, dw, dh // 대상 영역
);
drawImage의 9-인자 형태가 핵심이다 — 소스 캔버스의 특정 영역만 잘라서 대상의 다른 위치에 그릴 수 있다. FuzzyText는 이걸로 행 높이 1px씩 잘라서 랜덤 오프셋으로 붙여넣는다.
💡 requestAnimationFrame 루프 — 프레임 제한
Canvas 애니메이션은 매 프레임 직접 그려야 한다. requestAnimationFrame(rAF)은 브라우저의 화면 갱신 주기(보통 60Hz)에 맞춰 콜백을 호출한다.
let lastFrameTime = 0;
const frameDuration = 1000 / 60; // 60fps = 16.67ms 간격
const run = (timestamp: number) => {
// 목표 fps보다 빠르면 스킵
if (timestamp - lastFrameTime < frameDuration) {
requestAnimationFrame(run);
return;
}
lastFrameTime = timestamp;
// 여기서 clearRect + drawImage 반복
ctx.clearRect(0, 0, canvas.width, canvas.height);
// ... 그리기 로직 ...
requestAnimationFrame(run);
};
requestAnimationFrame(run);
timestamp는 밀리초 단위의 고해상도 시간이다. 이전 프레임과의 차이가 frameDuration보다 작으면 그리기를 건너뛰고, 넘으면 그린다. 120Hz 모니터에서 60fps로 제한하고 싶을 때 이 패턴을 쓴다.
참고: rAF vs setInterval — 왜 rAF를 쓰는가?
setInterval(fn, 16)도 60fps처럼 보이지만 차이가 크다:
- rAF: 브라우저의 repaint 직전에 실행된다. 화면 갱신과 동기화되므로 jank가 없다. 탭이 비활성화되면 자동 정지한다.
- setInterval: 브라우저 repaint와 무관하게 실행된다. 타이밍이 어긋나면 jank가 발생한다. 비활성 탭에서도 계속 실행된다.
성능 면에서 rAF가 압도적으로 유리하다. Canvas 애니메이션에서 setInterval을 쓸 이유가 없다.
FuzzyText — 스캔라인 왜곡
텍스트에 마우스를 올리면 왜곡이 강해지고, glitch 모드를 켜면 주기적으로 텍스트가 흔들린다 — intensity와 fuzz 범위를 조절해보자.
1단계: 오프스크린 캔버스에 텍스트 그리기
// 오프스크린 캔버스 생성 — 원본 텍스트를 한 번만 그려놓고 재사용
const offscreen = document.createElement('canvas');
const offCtx = offscreen.getContext('2d');
offCtx.font = `${fontWeight} ${fontSizeStr} ${computedFontFamily}`;
offCtx.textBaseline = 'alphabetic';
// measureText로 정확한 바운딩 박스 계산
const metrics = offCtx.measureText(text);
const actualAscent = metrics.actualBoundingBoxAscent; // 예: 47px
const actualDescent = metrics.actualBoundingBoxDescent; // 예: 1px
const tightHeight = Math.ceil(actualAscent + actualDescent); // 48px
offscreen.width = textBoundingWidth + 10; // 여유분 10px
offscreen.height = tightHeight;
// alphabetic 기준선에 맞춰 텍스트 렌더링
offCtx.fillText(text, xOffset, actualAscent);
왜 오프스크린 캔버스를 따로 만드나? 매 프레임마다 fillText를 호출하면 폰트 래스터화가 반복된다. 오프스크린에 한 번 그려놓고 drawImage로 복사하는 것이 훨씬 효율적이다.
measureText의 actualBoundingBoxAscent/actualBoundingBoxDescent를 사용하면 폰트의 실제 렌더링 영역에 딱 맞는 크기를 얻는다. fontSize만으로는 위아래 여백이 포함되어 정확하지 않다.
2단계: 행 단위 스캔라인 왜곡
for (let j = 0; j < tightHeight; j++) {
let dx = 0, dy = 0;
if (direction === 'horizontal' || direction === 'both') {
// intensity 0.18, fuzzRange 30일 때 최대 오프셋: ±2.7px
dx = Math.floor(currentIntensity * (Math.random() - 0.5) * fuzzRange);
}
if (direction === 'vertical' || direction === 'both') {
dy = Math.floor(currentIntensity * (Math.random() - 0.5) * fuzzRange * 0.5);
}
// 오프스크린의 j번째 행(높이 1px)을 dx/dy만큼 밀어서 그린다
ctx.drawImage(offscreen, 0, j, offscreenWidth, 1, dx, j + dy, offscreenWidth, 1);
}
이것이 FuzzyText의 핵심이다. 48px 높이 텍스트라면 48번 반복하면서, 각 행(1px 높이)을 랜덤한 수평 오프셋으로 그린다.
오프셋 공식: intensity * (Math.random() - 0.5) * fuzzRange
| intensity | fuzzRange | 최대 오프셋 | 시각적 효과 |
|---|---|---|---|
| 0.18 (기본) | 30 | ±2.7px | 약한 떨림 |
| 0.5 (hover) | 30 | ±7.5px | 눈에 띄는 왜곡 |
| 1.0 (click/glitch) | 30 | ±15px | 강한 글리치 |
Math.random() - 0.5는 -0.5 ~ 0.5 범위의 균일 분포다. 매 프레임마다 새 난수가 생성되므로 행마다 다른 방향으로 밀린다 — 이것이 "퍼지" 느낌을 만든다.
3단계: intensity 상태 전환
// 상태 우선순위: click > glitch > hover > base
if (isClicking) {
targetIntensity = 1;
} else if (isGlitching) {
targetIntensity = 1;
} else if (isHovering) {
targetIntensity = hoverIntensity; // 기본 0.5
} else {
targetIntensity = baseIntensity; // 기본 0.18
}
데이터 흐름:
mousemove -> isInsideTextArea(x, y)? -> isHovering = true/false
-> targetIntensity 결정
-> currentIntensity 보간 (transitionDuration > 0일 때)
-> dx = currentIntensity * random * fuzzRange
-> drawImage(offscreen, 0, j, ..., dx, j, ...)
isInsideTextArea는 마우스 좌표가 텍스트의 바운딩 박스 안에 있는지 판별한다. Canvas 전체가 아닌 텍스트 영역 위에 올려야 hover가 활성화된다.
4단계: cleanup — rAF, timeout, 이벤트 리스너
return () => {
isCancelled = true;
window.cancelAnimationFrame(animationFrameId);
clearTimeout(glitchTimeoutId);
clearTimeout(glitchEndTimeoutId);
clearTimeout(clickTimeoutId);
if (canvas && canvas.cleanupFuzzyText) {
canvas.cleanupFuzzyText(); // canvas 요소에 직접 붙인 커스텀 메서드 — 이벤트 리스너 제거
}
};
정리해야 하는 리소스가 5종류다:
cancelAnimationFrame— rAF 루프 중단clearTimeoutx 3 — glitch 시작, glitch 종료, click 복원 타이머removeEventListener— mousemove, mouseleave, touchmove, touchend, click
isCancelled 플래그가 있는 이유는 init()가 async이기 때문이다. 폰트 로딩(document.fonts.load)을 await하는 동안 컴포넌트가 언마운트될 수 있다. await 이후에 isCancelled를 체크해서 이미 정리된 캔버스에 접근하는 것을 방지한다.
물리 엔진 + FallingText
💡 Matter.js — 2D 물리 엔진
Matter.js는 브라우저용 2D 물리 엔진이다. 중력, 충돌, 반발, 마찰을 시뮬레이션한다.
핵심 모듈:
import Matter from 'matter-js';
const { Engine, Render, World, Bodies, Runner, Mouse, MouseConstraint } = Matter;
| 모듈 | 역할 |
|---|---|
| Engine | 물리 시뮬레이션의 핵심 — 매 틱마다 바디 위치/속도 계산 |
| World | 바디들의 컨테이너 — 중력(gravity.y) 설정 |
| Bodies | 바디 생성 팩토리 — rectangle, circle 등 |
| Render | Canvas 렌더러 — wireframes 모드로 디버깅 |
| Runner | Engine.update()를 자동 반복 실행 |
| Mouse + MouseConstraint | 마우스로 바디를 드래그하는 제약 조건 |
기본적인 월드 구성:
const engine = Engine.create();
engine.world.gravity.y = 1; // 1 = 기본 중력, 0 = 무중력, 3 = 3배 중력
// 정적 바닥 — isStatic: true면 중력/충돌에 영향받지 않는다
const floor = Bodies.rectangle(width / 2, height + 25, width, 50, {
isStatic: true
});
// 동적 바디 — 중력에 의해 떨어지고 다른 바디와 충돌한다
const box = Bodies.rectangle(100, 50, 80, 40, {
restitution: 0.8, // 반발 계수: 0=안 튀김, 1=완전 탄성 충돌
frictionAir: 0.01, // 공기 저항: 0=없음, 높을수록 빨리 감속
friction: 0.2 // 접촉 마찰: 0=미끄러움, 높을수록 빨리 멈춤
});
World.add(engine.world, [floor, box]);
💡 물리 바디와 DOM 동기화
Matter.js가 계산하는 것은 **바디의 position(x, y)과 angle(라디안)**이다. 시각적 렌더링은 두 가지 방법이 있다:
- Matter.Render — 엔진이 직접 Canvas에 바디를 그린다 (디버그용)
- DOM 동기화 — rAF 루프에서 바디의 position/angle을 읽어 DOM 요소의 CSS로 반영
FallingText는 둘 다 쓴다. Render는 wireframes 모드 디버그용이고, 실제 텍스트 표시는 DOM 동기화로 한다.
// 매 프레임: 물리 바디의 위치/각도를 DOM 요소에 반영
wordBodies.forEach(({ body, elem }) => {
const { x, y } = body.position;
elem.style.left = `${x}px`;
elem.style.top = `${y}px`;
elem.style.transform = `translate(-50%, -50%) rotate(${body.angle}rad)`;
});
translate(-50%, -50%)는 DOM 요소의 중심을 바디의 position에 맞추기 위한 것이다. Matter.js의 position은 바디의 중심점이지만, CSS의 left/top은 요소의 좌상단이다.
FallingText — 물리 시뮬레이션
텍스트가 중력에 의해 떨어지고, 벽에 부딪혀 튕기고, 마우스로 드래그할 수 있다 — 중력과 stiffness를 조절해보자.
1단계: 단어를 span으로 분리 + DOM 주입
useEffect(() => {
if (!textRef.current) return;
const words = text.split(' ');
// 각 단어를 span.word로 감싸서 DOM에 추가
const newContent = words
.map(word => {
const isHighlighted = highlightWords.some(hw => word.startsWith(hw));
return `<span class="word ${isHighlighted ? highlightClass : ''}">${word}</span>`;
})
.join(' ');
textRef.current.textContent = '';
// DOM에 단어별 span을 주입한다
const template = document.createElement('template');
template.innerHTML = newContent;
textRef.current.appendChild(template.content);
}, [text, highlightWords, highlightClass]);
text.split(' ')으로 공백 기준 분리 후, 각 단어를 <span class="word">로 감싼다. CSS에서 .word { display: inline-block }으로 설정해야 getBoundingClientRect()가 정확한 크기를 반환한다.
2단계: Matter.js 월드 구성
// 정적 벽 4개 — 바닥, 좌벽, 우벽, 천장
const floor = Bodies.rectangle(width / 2, height + 25, width, 50, boundaryOptions);
const leftWall = Bodies.rectangle(-25, height / 2, 50, height, boundaryOptions);
const rightWall = Bodies.rectangle(width + 25, height / 2, 50, height, boundaryOptions);
const ceiling = Bodies.rectangle(width / 2, -25, width, 50, boundaryOptions);
컨테이너 바깥에 50px 두께의 투명한 벽을 배치한다. height + 25은 컨테이너 하단에서 25px 아래에 바닥의 중심이 오므로, 바닥 상단은 정확히 컨테이너 하단과 맞닿는다.
// 각 단어 span의 위치/크기로 물리 바디를 생성
const wordBodies = Array.from(wordSpans).map(elem => {
const rect = elem.getBoundingClientRect();
const x = rect.left - containerRect.left + rect.width / 2;
const y = rect.top - containerRect.top + rect.height / 2;
const body = Bodies.rectangle(x, y, rect.width, rect.height, {
restitution: 0.8, // 반발 계수
frictionAir: 0.01, // 공기 저항
friction: 0.2 // 접촉 마찰
});
// 초기 속도로 흩뿌리기
Matter.Body.setVelocity(body, { x: (Math.random() - 0.5) * 5, y: 0 });
Matter.Body.setAngularVelocity(body, (Math.random() - 0.5) * 0.05);
return { elem, body };
});
데이터 흐름:
span.getBoundingClientRect() -> (x, y, width, height)
-> Bodies.rectangle(x, y, w, h) = 물리 바디
-> setVelocity(random) = 초기에 흩뿌리기
-> World.add(engine.world, bodies)
-> rAF: Matter.Engine.update(engine) 반복
-> body.position -> elem.style.left/top/transform
3단계: MouseConstraint — 드래그 인터랙션
// 마우스로 단어를 드래그할 수 있는 제약 조건
const mouse = Mouse.create(containerRef.current);
const mouseConstraint = MouseConstraint.create(engine, {
mouse,
constraint: {
stiffness: mouseConstraintStiffness,
render: { visible: false }
}
});
render.mouse = mouse;
세 줄이 하는 일:
-
Mouse.create(element)— 컨테이너 DOM 요소에mousemove,mousedown,mouseup이벤트를 바인딩한다. Matter.js가 직접 이벤트를 관리한다. -
MouseConstraint.create(engine, ...)— 마우스와 물리 바디를 연결하는 "스프링"을 만든다.stiffness— 마우스에 얼마나 딱딱하게 붙는지.1.0이면 즉시 따라오고,0.1같은 낮은 값은 고무줄처럼 느슨하게 따라온다.render: { visible: false }— 마우스와 바디를 잇는 선을 숨긴다. wireframes 디버그 모드에서 보이는 그 선이다.
-
render.mouse = mouse— Matter.js의Render가 마우스 좌표계를 알게 한다. 생략하면RenderCanvas와 실제 마우스 포인터의 좌표가 어긋나서 드래그가 엉뚱한 위치에서 동작한다.
4단계: rAF 루프에서 body.position을 elem.style로 동기화
const updateLoop = () => {
wordBodies.forEach(({ body, elem }) => {
const { x, y } = body.position;
elem.style.left = `${x}px`;
elem.style.top = `${y}px`;
elem.style.transform = `translate(-50%, -50%) rotate(${body.angle}rad)`;
});
Matter.Engine.update(engine);
requestAnimationFrame(updateLoop);
};
updateLoop();
매 프레임마다 세 가지가 일어난다:
- 각 단어의 물리 바디에서
position과angle을 읽는다 - 해당 DOM 요소의
left,top,transform에 반영한다 Engine.update()로 다음 물리 틱을 계산한다
Canvas의 한계 — 접근성과 SEO
Canvas 텍스트는 강력하지만 분명한 trade-off가 있다.
접근성(a11y)
- 스크린 리더가 Canvas 내용을 읽을 수 없다.
<canvas>안에 fallback 텍스트를 넣거나aria-label을 추가해야 한다. - 텍스트 선택/복사가 불가능하다. 사용자가 텍스트를 복사하려 하면 아무 반응이 없다.
- 키보드 네비게이션이 작동하지 않는다.
SEO
- 크롤러가 Canvas에 그려진 텍스트를 인식하지 못한다. 중요한 텍스트라면
<canvas>옆에 숨겨진 텍스트(sr-only)를 배치해야 한다.
성능
- Canvas rAF 루프는 탭이 보이는 동안 계속 CPU를 사용한다. FuzzyText처럼 매 프레임 전체를 다시 그리는 컴포넌트는 IntersectionObserver로 뷰포트를 벗어나면 일시 정지하는 것이 좋다.
결론: Canvas 텍스트는 장식적 효과에 적합하다. 핵심 콘텐츠가 아닌 히어로 섹션, 인트로 애니메이션, 인터랙티브 데모에서 쓰고, 실제 콘텐츠는 DOM으로 제공한다.
다음 포스트에서는
Canvas 2D에서 한 단계 더 — WebGL Shader로 GPU에서 직접 텍스트를 렌더링한다. ASCII 문자로 이미지를 표현하는 ASCIIText의 vertex/fragment shader를 단계별로 분석한다.