WebGL & Shader: GPU로 텍스트 렌더링하기
이전 포스트에서 Canvas 2D API로 텍스트를 픽셀 단위로 다뤘다.
fillText()로 그리고,getImageData()로 읽고, Perlin noise로 흔들고, Matter.js 물리 엔진으로 떨어뜨렸다. 모두 CPU가 한 픽셀씩 순차 처리하는 방식이었다. 이번 포스트에서는 같은 "텍스트 렌더링"이지만 접근이 완전히 다르다 — GPU가 수천 픽셀을 동시에 처리하는 WebGL과 Shader의 세계로 들어간다.
CPU vs GPU — 렌더링 파이프라인의 차이
ASCIIText 컴포넌트는 CPU와 GPU 두 세계를 결합한다:
- GPU (Three.js + Shader): 텍스트를 3D 평면에 렌더링하고, Fragment Shader로 색수차 효과 적용
- CPU (Canvas 2D): GPU 렌더 결과를 캡처하여 밝기 → ASCII 문자로 매핑
텍스트 입력 → Canvas 2D 텍스처 생성 → Three.js 씬 렌더링 (GPU)
→ Shader 효과 적용 (GPU) → Canvas로 캡처 (CPU) → 밝기→ASCII 변환 (CPU) → 화면 출력
그렇다면 CPU와 GPU의 처리 방식은 어떻게 다를까? 10x10 격자(100픽셀)의 밝기를 계산한다고 하자. "실행"을 눌러보자 — CPU가 한 칸씩 채워가는 동안, GPU는 한 번에 전부 채운다.
CPU는 범용 연산에 최적화된 코어 몇 개가 순서대로 처리한다. GPU는 단순 연산에 최적화된 코어 수천 개가 동시에 처리한다. 실제 화면은 100x100이 아니라 800x600(480,000픽셀)이므로, 이 격차는 수천 배로 벌어진다. 텍스트를 ASCII 문자로 변환하는 작업처럼 "각 픽셀마다 같은 공식을 적용"하는 경우, GPU가 압도적으로 빠르다.
Three.js 기초 — Scene, Camera, Renderer
ASCIIText가 사용하는 Three.js 구조를 이해하려면 세 가지 핵심 개념이 필요하다.
💡 Scene, Camera, Renderer — 3D 렌더링의 삼총사
Three.js로 무언가를 화면에 그리려면 반드시 세 가지가 필요하다:
- Scene — 3D 공간. 모든 오브젝트(메시, 조명 등)를 담는 컨테이너다.
- Camera — 씬을 어디서 어떻게 바라볼지 결정한다.
PerspectiveCamera는 원근법을 적용한다. - Renderer — 씬과 카메라 정보를 받아서 실제 픽셀로 그린다.
WebGLRenderer는 GPU를 사용한다.
// 최소한의 Three.js 씬
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, width / height, 1, 1000);
camera.position.z = 30; // 카메라를 z축 30 위치에 배치
const renderer = new THREE.WebGLRenderer({ alpha: true });
renderer.setSize(width, height);
// 매 프레임 렌더링
renderer.render(scene, camera);
💡 PerspectiveCamera의 4가지 파라미터
new THREE.PerspectiveCamera(fov, aspect, near, far);
| 파라미터 | ASCIIText 값 | 의미 |
|---|---|---|
fov | 45 | 시야각(도). 45도는 인간 시야의 약 절반 |
aspect | width / height | 화면 비율. 컨테이너 크기에 맞춤 |
near | 1 | 이것보다 가까운 물체는 안 보임 |
far | 1000 | 이것보다 먼 물체는 안 보임 |
💡 Mesh = Geometry + Material
화면에 보이는 3D 오브젝트는 Mesh다. Mesh는 두 가지로 구성된다:
- Geometry — 형태(꼭짓점, 면). "어떤 모양인가?"
- Material — 표면(색상, 텍스처, 셰이더). "어떻게 보이는가?"
ASCIIText에서는:
// 평면 도형: 너비 × 높이, 36×36 세그먼트로 분할
const geometry = new THREE.PlaneGeometry(planeW, planeH, 36, 36);
// 커스텀 셰이더 재질
const material = new THREE.ShaderMaterial({
vertexShader, // 정점 위치 변환 (파도 효과)
fragmentShader, // 픽셀 색상 계산 (색수차 효과)
transparent: true,
uniforms: { // JavaScript → Shader로 전달할 데이터
uTime: { value: 0 },
uTexture: { value: texture },
uEnableWaves: { value: 1.0 }
}
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
PlaneGeometry(w, h, 36, 36)에서 세 번째·네 번째 인자가 세그먼트 수다. 1x1이면 정점 4개짜리 단순 평면이지만, 36x36이면 정점이 37 × 37 = 1,369개로 분할된다. 정점이 많을수록 Vertex Shader의 파도 효과가 부드럽게 적용된다.
참고: ShaderMaterial vs MeshStandardMaterial
Three.js는 여러 Material을 제공한다. MeshStandardMaterial은 PBR(물리 기반 렌더링)로 사실적인 표면을 만들고, MeshBasicMaterial은 조명 없이 단색을 칠한다. ShaderMaterial은 Vertex/Fragment Shader를 직접 작성해서 완전히 커스텀한 렌더링을 한다. ASCIIText는 파도 효과와 색수차라는 특수한 시각 효과가 필요하기 때문에 ShaderMaterial을 사용한다.
Shader 파이프라인 — Vertex에서 Fragment까지
💡 Shader란 무엇인가?
Shader는 GPU에서 실행되는 작은 프로그램이다. "셰이딩(음영 처리)"에서 이름이 왔지만, 현대 셰이더는 색상 계산뿐 아니라 정점 변환, 후처리 등 GPU에서 할 수 있는 거의 모든 시각 처리를 담당한다.
Shader는 GLSL(OpenGL Shading Language)이라는 C 스타일 언어로 작성한다. JavaScript에서 문자열로 셰이더 코드를 작성하고, WebGL(또는 Three.js)이 이를 GPU에 컴파일해서 실행한다. 두 가지 종류가 있다:
- Vertex Shader — 각 **정점(vertex)**마다 한 번 실행된다. 3D 공간의 정점 좌표를 받아서 최종 화면 위치를 결정한다. 36×36 격자라면 1,369개 정점이 동시에 각자의 Vertex Shader를 실행한다.
- Fragment Shader — 각 **픽셀(fragment)**마다 한 번 실행된다. 그 픽셀이 어떤 색상이어야 하는지 계산한다. 800×600 화면이라면 480,000개 픽셀이 동시에 각자의 Fragment Shader를 실행한다.
JavaScript에서 데이터를 보내고, Vertex Shader가 정점을 배치하고, Fragment Shader가 색을 칠하고, 화면에 출력된다 — 이 흐름을 단계별로 살펴보자:
JavaScript에서 시간·텍스처·설정값을 Shader로 전달
💡 GLSL 핵심 키워드 3가지
| 키워드 | 방향 | 용도 |
|---|---|---|
uniform | JS → Shader | 모든 정점/픽셀에서 같은 값. 시간, 마우스, 텍스처 등 |
attribute | JS → Vertex Shader | 정점마다 다른 값. 위치, UV 좌표 등 (Three.js가 자동 전달) |
varying | Vertex → Fragment | Vertex Shader에서 Fragment Shader로 보간 전달 |
varying vec2 vUv를 예로 들면 — Vertex Shader에서 각 정점의 UV 좌표를 vUv에 넣으면, GPU가 정점 사이의 픽셀들에 대해 자동으로 선형 보간한다. 삼각형 꼭짓점 3개의 UV가 (0,0), (1,0), (0,1)이면, 중간 픽셀은 자동으로 (0.33, 0.33) 같은 값을 받는다.
💡 ASCIIText의 Vertex Shader — 파도 효과
varying vec2 vUv;
uniform float uTime;
uniform float uEnableWaves; // 0.0(off) 또는 1.0(on)
void main() {
vUv = uv; // UV 좌표를 Fragment로 전달
float time = uTime * 5.; // 시간 5배 증폭
float waveFactor = uEnableWaves;
vec3 transformed = position; // 원본 정점 위치
// 3축에 sin/cos 파동 적용
transformed.x += sin(time + position.y) * 0.5 * waveFactor;
transformed.y += cos(time + position.z) * 0.15 * waveFactor;
transformed.z += sin(time + position.x) * waveFactor;
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
}
position은 Three.js가 자동으로 전달하는 attribute — 각 정점의 로컬 좌표다. uv도 마찬가지로 텍스처 좌표(0~1)를 자동 전달한다.
파도 효과의 원리: sin(time + position.y)에서 time이 매 프레임 변하므로 sin 파형이 이동한다. position.y가 정점마다 다르므로, 각 정점이 sin 곡선의 다른 위상(phase)에 위치한다. 결과적으로 평면이 물결치듯 흔들린다.
gl_Position은 GLSL 내장 변수 — Vertex Shader의 최종 출력이다. projectionMatrix * modelViewMatrix는 Three.js가 자동으로 전달하는 uniform으로, 3D 좌표를 2D 화면 좌표로 변환한다.
💡 ASCIIText의 Fragment Shader — 색수차 효과
varying vec2 vUv;
uniform float uTime;
uniform sampler2D uTexture; // 텍스트 텍스처
void main() {
float time = uTime;
vec2 pos = vUv;
// RGB 각 채널을 서로 다른 오프셋으로 샘플링
float r = texture2D(uTexture, pos + cos(time * 2. - time + pos.x) * .01).r;
float g = texture2D(uTexture, pos + tan(time * .5 + pos.x - time) * .01).g;
float b = texture2D(uTexture, pos - cos(time * 2. + time + pos.y) * .01).b;
float a = texture2D(uTexture, pos).a;
gl_FragColor = vec4(r, g, b, a);
}
**색수차(Chromatic Aberration)**란 RGB 채널이 약간씩 어긋나는 효과다. 카메라 렌즈에서 빛의 파장별 굴절률 차이로 발생하는 현상을 시뮬레이션한다.
핵심은 texture2D(텍스처, 좌표) — 텍스처에서 특정 좌표의 색상을 읽는 함수다. 각 채널(R, G, B)을 약간씩 다른 좌표에서 읽으면 색이 분리된다:
R 채널: pos + cos(time * 2 - time + pos.x) * 0.01 ← 원본보다 약간 오른쪽
G 채널: pos + tan(time * 0.5 + pos.x - time) * 0.01 ← 다른 방향으로 이동
B 채널: pos - cos(time * 2 + time + pos.y) * 0.01 ← 또 다른 방향으로 이동
오프셋이 0.01(텍스처 좌표 기준 1%)로 매우 작아서 미묘한 색 번짐 효과가 된다. time이 변하면서 오프셋도 변하므로 색수차가 시간에 따라 흔들린다.
참고: texture2D와 sampler2D
sampler2D는 GLSL에서 텍스처를 참조하는 타입이다. JavaScript에서 uniforms.uTexture.value에 THREE.CanvasTexture를 넣으면, GPU 메모리에 텍스처가 업로드되고 Shader에서 sampler2D로 접근할 수 있다. texture2D(sampler, vec2)는 해당 좌표의 RGBA 값을 vec4로 반환한다. .r, .g, .b, .a로 개별 채널을 추출한다.
ASCIIText 구현 — 4개 클래스의 협업
ASCIIText는 614줄짜리 단일 파일에 3개 클래스 + 1개 React 컴포넌트로 구성된다. 먼저 데모에서 마우스를 움직여보자 — 텍스트가 ASCII 문자로 렌더링되고, 마우스 방향에 따라 색상이 회전한다.
전체 데이터 흐름:
CanvasTxt (텍스트→Canvas 텍스처)
↓
CanvAscii (Three.js 씬 + ShaderMaterial)
↓ GPU 렌더링
AsciiFilter (WebGL→Canvas 캡처 → 밝기→ASCII)
↓
<pre> 태그에 ASCII 문자열 출력
↓
React 컴포넌트 (useEffect 생명주기 관리)
1단계: CanvasTxt — 텍스트를 텍스처로 만들기
CanvasTxt는 가장 단순한 클래스다. Canvas 2D에 텍스트를 그려서 Three.js 텍스처의 소스로 제공한다.
class CanvasTxt {
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D | null;
font: string;
constructor(txt: string, { fontSize = 200, fontFamily = 'Arial', color = '#fdf9f3' } = {}) {
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext('2d');
this.font = `600 ${this.fontSize}px ${this.fontFamily}`;
}
// 텍스트 크기를 측정하여 캔버스를 딱 맞는 크기로 설정
resize() {
this.context.font = this.font;
const metrics = this.context.measureText(this.txt);
this.canvas.width = Math.ceil(metrics.width) + 20;
this.canvas.height = Math.ceil(
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent
) + 20;
}
// 캔버스에 텍스트 렌더링
render() {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.fillStyle = this.color;
this.context.font = this.font;
this.context.fillText(this.txt, 10, yPos);
}
get texture() { return this.canvas; } // Three.js CanvasTexture의 소스
}
핵심은 measureText()로 텍스트의 정확한 픽셀 크기를 측정하고, 캔버스를 그 크기에 맞추는 것이다. +20은 텍스트가 잘리지 않도록 하는 여백이다.
actualBoundingBoxAscent는 baseline 위의 높이, actualBoundingBoxDescent는 baseline 아래의 높이다. 두 값을 합치면 텍스트의 실제 렌더링 높이를 정확하게 알 수 있다.
2단계: CanvAscii — Three.js 씬 구성
CanvAscii는 메인 오케스트레이터다. CanvasTxt로 텍스처를 만들고, Three.js 씬에 메시를 배치하고, AsciiFilter를 연결한다.
class CanvAscii {
// 초기화 순서: 폰트 로딩 → 메시 생성 → 렌더러 설정
async init() {
await document.fonts.load('600 200px "IBM Plex Mono"');
await document.fonts.ready;
this.setMesh();
this.setRenderer();
}
setMesh() {
// 1. CanvasTxt로 텍스트 텍스처 생성
this.textCanvas = new CanvasTxt(this.textString, { fontSize: this.textFontSize });
this.textCanvas.resize();
this.textCanvas.render();
// 2. Canvas → Three.js 텍스처
this.texture = new THREE.CanvasTexture(this.textCanvas.texture);
this.texture.minFilter = THREE.NearestFilter; // 픽셀 보간 없이 선명하게
// 3. 텍스트 비율에 맞는 PlaneGeometry
const textAspect = this.textCanvas.width / this.textCanvas.height;
this.geometry = new THREE.PlaneGeometry(baseH * textAspect, baseH, 36, 36);
// 4. 커스텀 ShaderMaterial
this.material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTime: { value: 0 },
uTexture: { value: this.texture },
uEnableWaves: { value: this.enableWaves ? 1.0 : 0.0 }
}
});
this.mesh = new THREE.Mesh(this.geometry, this.material);
this.scene.add(this.mesh);
}
}
데이터 흐름:
CanvasTxt.canvas → THREE.CanvasTexture → ShaderMaterial.uniforms.uTexture
→ Fragment Shader에서 texture2D()로 읽기
NearestFilter는 텍스처를 확대할 때 보간(블러) 없이 가장 가까운 픽셀 값을 그대로 사용한다. ASCII 변환에서는 선명한 경계가 중요하기 때문이다.
애니메이션 루프: 매 프레임마다 render()가 호출된다:
render() {
const time = new Date().getTime() * 0.001;
this.textCanvas.render(); // 텍스트 재렌더링
this.texture.needsUpdate = true; // Three.js에 텍스처 변경 알림
// 시간 uniform 업데이트 — Shader에서 파도와 색수차에 사용
(this.mesh.material as THREE.ShaderMaterial).uniforms.uTime.value = Math.sin(time);
this.updateRotation(); // 마우스 기반 회전
this.filter.render(this.scene, this.camera); // ASCII 필터 렌더
}
texture.needsUpdate = true가 중요하다 — Three.js는 성능을 위해 텍스처를 캐시하므로, 매 프레임 텍스처가 바뀐다는 것을 명시적으로 알려야 한다.
마우스 기반 회전:
updateRotation() {
// 마우스 위치를 -0.5 ~ 0.5 범위로 변환
const x = map(this.mouse.y, 0, this.height, 0.5, -0.5);
const y = map(this.mouse.x, 0, this.width, -0.5, 0.5);
// 현재 회전값과 목표값의 5% 차이만 적용 → 부드러운 추종
this.mesh.rotation.x += (x - this.mesh.rotation.x) * 0.05;
this.mesh.rotation.y += (y - this.mesh.rotation.y) * 0.05;
}
map() 헬퍼는 값의 범위를 변환한다:
map(n, start, stop, start2, stop2)
= ((n - start) / (stop - start)) * (stop2 - start2) + start2
예: map(150, 0, 300, 0.5, -0.5)
= ((150 - 0) / (300 - 0)) * (-0.5 - 0.5) + 0.5
= 0.5 * (-1.0) + 0.5
= 0.0 (정중앙에서 회전 없음)
* 0.05 보간은 easing 효과다. 목표 각도까지 한 번에 가지 않고 매 프레임 5%씩만 이동하므로, 마우스를 빠르게 움직여도 메시는 부드럽게 따라온다.
3단계: AsciiFilter — GPU 렌더 결과를 ASCII로 변환
AsciiFilter가 이 컴포넌트의 핵심이다. Three.js가 GPU로 렌더링한 결과를 Canvas에 캡처하고, 각 픽셀의 밝기를 계산하여 ASCII 문자로 매핑한다.
격자 크기 계산:
reset() {
this.context.font = `${this.fontSize}px ${this.fontFamily}`;
const charWidth = this.context.measureText('A').width;
// 화면 너비를 문자 폭으로 나누어 열 수 계산
this.cols = Math.floor(this.width / (this.fontSize * (charWidth / this.fontSize)));
this.rows = Math.floor(this.height / this.fontSize);
// 캔버스를 격자 크기로 축소 → 1픽셀 = 1문자
this.canvas.width = this.cols;
this.canvas.height = this.rows;
}
예를 들어 컨테이너가 800x300px이고 ASCII 폰트가 8px이면:
charWidth= 약 4.8px ('A'의 폭)cols=floor(800 / (8 * (4.8 / 8)))=floor(800 / 4.8)= 166rows=floor(300 / 8)= 37- 캔버스는 166x37 = 6,142픽셀만 처리하면 된다
이 축소가 성능의 핵심이다. 800x300 = 240,000 픽셀을 처리하는 대신 6,142 픽셀만 처리한다.
렌더링 파이프라인:
render(scene: THREE.Scene, camera: THREE.Camera) {
this.renderer.render(scene, camera); // Three.js GPU 렌더링
// GPU 결과를 축소 캔버스로 복사 (다운샘플링)
this.context.drawImage(this.renderer.domElement, 0, 0, this.cols, this.rows);
this.asciify(this.context, this.cols, this.rows); // 밝기→ASCII 변환
this.hue(); // 색상 회전
}
밝기 → ASCII 문자 매핑:
asciify(ctx, w, h) {
const imgData = ctx.getImageData(0, 0, w, h).data;
let str = '';
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = x * 4 + y * 4 * w; // RGBA → 4바이트씩
const [r, g, b, a] = [imgData[i], imgData[i+1], imgData[i+2], imgData[i+3]];
if (a === 0) { str += ' '; continue; } // 투명 = 공백
// 가중 평균 밝기: 인간 시각은 녹색에 가장 민감
let gray = (0.3 * r + 0.6 * g + 0.1 * b) / 255;
// 밝기를 charset 인덱스로 변환
let idx = Math.floor((1 - gray) * (this.charset.length - 1));
if (this.invert) idx = this.charset.length - idx - 1;
str += this.charset[idx];
}
str += '\n';
}
this.pre.textContent = str;
}
밝기 공식 gray = (0.3*R + 0.6*G + 0.1*B) / 255는 CIE 휘도 계수를 근사한 것이다. 인간의 눈은 녹색에 가장 민감하고 파란색에 가장 둔감하므로, 단순 평균((R+G+B)/3)보다 정확한 밝기를 계산한다.
구체적 예시 — 주황색 픽셀 rgb(200, 150, 100):
gray = (0.3 × 200 + 0.6 × 150 + 0.1 × 100) / 255
= (60 + 90 + 10) / 255
= 160 / 255
= 0.627
charset 길이가 70이라면:
idx = floor((1 - 0.627) × 69) = floor(0.373 × 69) = floor(25.7) = 25
invert가 true이면: idx = 70 - 25 - 1 = 44
charset에서 인덱스별 문자 매핑 (70자):
| 인덱스 범위 | 밝기 | 대표 문자 | 설명 |
|---|---|---|---|
| 0~9 | 가장 어두움 | . ' | 공백, 점 — 획이 거의 없음 |
| 10~24 | 어두움 | : ; ! ~ | 가는 획 |
| 25~44 | 중간 | 1 ( t f n | 중간 밀도 |
| 45~59 | 밝음 | Z m q k a | 복잡한 형태 |
| 60~69 | 가장 밝음 | # M W & @ $ | 획이 가장 많음 |
hue-rotate — 마우스 기반 색상 회전:
hue() {
// 마우스와 중심 사이의 각도 계산 (라디안 → 도)
const deg = (Math.atan2(this.dy, this.dx) * 180) / Math.PI;
// 현재 각도에서 목표 각도로 7.5%씩 보간
this.deg += (deg - this.deg) * 0.075;
this.domElement.style.filter = `hue-rotate(${this.deg.toFixed(1)}deg)`;
}
Math.atan2(dy, dx)는 마우스와 중심점 사이의 각도를 라디안으로 반환한다(-π ~ π). 이를 도(degree)로 변환하여 CSS hue-rotate 필터에 적용하면, 마우스 위치에 따라 ASCII 문자의 색상이 회전한다.
4단계: React 컴포넌트 — 생명주기 관리
React 컴포넌트는 CanvAscii 인스턴스의 생성과 파괴를 useEffect로 관리한다.
export default function ASCIIText({ text, asciiFontSize, ... }: ASCIITextProps) {
const containerRef = useRef<HTMLDivElement>(null);
const asciiRef = useRef<CanvAscii | null>(null);
useEffect(() => {
let cancelled = false;
let observer: IntersectionObserver | null = null;
let ro: ResizeObserver | null = null;
const setup = async () => {
const { width, height } = containerRef.current!.getBoundingClientRect();
// 크기가 0이면 IntersectionObserver로 가시성 대기
if (width === 0 || height === 0) {
observer = new IntersectionObserver(async ([entry]) => {
if (entry.isIntersecting && !cancelled) {
observer?.disconnect();
asciiRef.current = await createAndInit(...);
asciiRef.current.load();
}
}, { threshold: 0.1 });
observer.observe(containerRef.current!);
return;
}
asciiRef.current = await createAndInit(...);
asciiRef.current.load();
// ResizeObserver로 반응형 대응
ro = new ResizeObserver(entries => {
const { width: w, height: h } = entries[0].contentRect;
if (w > 0 && h > 0) asciiRef.current.setSize(w, h);
});
ro.observe(containerRef.current!);
};
setup();
// cleanup: 모든 리소스 해제
return () => {
cancelled = true;
observer?.disconnect();
ro?.disconnect();
asciiRef.current?.dispose();
asciiRef.current = null;
};
}, [text, asciiFontSize, textFontSize, textColor, planeBaseHeight, enableWaves]);
return <div ref={containerRef} style={{ position: 'absolute', width: '100%', height: '100%' }} />;
}
주목할 패턴 두 가지:
지연 초기화 (Lazy Initialization)
컨테이너의 크기가 0일 수 있다 — 예를 들어 탭이 비활성 상태이거나, CSS가 아직 적용되지 않은 경우. 이때 IntersectionObserver로 요소가 실제로 보일 때까지 기다린 후 초기화한다. 크기가 0인 상태에서 WebGL 컨텍스트를 만들면 에러가 발생하기 때문이다.
cancelled 플래그
비동기 초기화(await init()) 도중에 컴포넌트가 언마운트될 수 있다. cancelled 플래그가 없으면 이미 파괴된 DOM에 접근하여 에러가 발생한다. setup() 내부의 모든 비동기 지점에서 cancelled를 확인하는 것이 방어적 패턴이다.
React에서 Three.js 쓸 때 주의점
ASCIIText를 분석하면서 발견한 핵심 패턴들을 정리한다.
dispose() — GPU 메모리 누수 방지
DOM 요소는 React가 자동으로 정리하지만, Three.js 객체는 GPU 메모리에 올라가므로 명시적으로 해제해야 한다:
dispose() {
cancelAnimationFrame(this.animationFrameId); // 애니메이션 루프 중단
this.filter.dispose(); // 이벤트 리스너 제거
this.container.removeChild(this.filter.domElement); // DOM에서 제거
// Three.js 리소스 해제
this.scene.traverse(object => {
const obj = object as THREE.Mesh;
if (!obj.isMesh) return;
obj.geometry.dispose(); // 정점 데이터 해제
[obj.material].flat().forEach(mat => {
mat.dispose(); // 셰이더 프로그램 해제
// material 내부의 텍스처도 순회하며 해제
Object.keys(mat).forEach(key => {
const prop = mat[key];
if (prop?.dispose) prop.dispose();
});
});
});
this.scene.clear();
this.renderer.dispose(); // WebGL 컨텍스트 해제
}
해제 순서: 이벤트 리스너 → DOM → Geometry → Material → Texture → Scene → Renderer. 이 순서를 지키지 않으면 참조 에러가 발생할 수 있다.
폰트 로딩 — document.fonts API
Canvas에 텍스트를 그릴 때 웹 폰트가 로드되기 전이면, 시스템 기본 폰트로 렌더링된다. 텍스처가 잘못된 크기로 생성되면 이후 모든 렌더링이 어긋난다:
async init() {
try {
await document.fonts.load('600 200px "IBM Plex Mono"');
} catch (e) {}
await document.fonts.ready; // 모든 폰트 로딩 완료까지 대기
this.setMesh(); // 이제 안전하게 텍스트 측정 가능
}
ResizeObserver — 반응형 대응
창 크기가 변하면 카메라의 aspect ratio, 렌더러 크기, ASCII 격자 크기를 모두 갱신해야 한다:
ro = new ResizeObserver(entries => {
const { width: w, height: h } = entries[0].contentRect;
if (w > 0 && h > 0) {
asciiRef.current.setSize(w, h); // camera.aspect + filter.setSize + 격자 재계산
}
});
w > 0 && h > 0 체크가 중요하다. 요소가 display: none이 되면 크기가 0으로 보고되는데, 이때 WebGL 렌더러에 0을 전달하면 에러가 발생한다.
다음 포스트에서는
마지막 편 — Variable Font과 SVG로 브라우저가 기본 제공하는 특수 기술을 활용한 텍스트 애니메이션을 다룬다. font-variation-settings로 글자 두께를 실시간으로 변형하고, SVG <textPath>와 stroke-dashoffset으로 글자가 그려지는 효과를 만든다.