📖react-bits 파헤치기·5/6단계
83%

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 (Canvas 2D)
순차 처리 — 1번에 1픽셀
0/100 (0%)

GPU (WebGL Shader)
병렬 처리 — 모든 픽셀 동시
0/100 (0%)

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 값의미
fov45시야각(도). 45도는 인간 시야의 약 절반
aspectwidth / height화면 비율. 컨테이너 크기에 맞춤
near1이것보다 가까운 물체는 안 보임
far1000이것보다 먼 물체는 안 보임

💡 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에 컴파일해서 실행한다. 두 가지 종류가 있다:

  1. Vertex Shader — 각 **정점(vertex)**마다 한 번 실행된다. 3D 공간의 정점 좌표를 받아서 최종 화면 위치를 결정한다. 36×36 격자라면 1,369개 정점이 동시에 각자의 Vertex Shader를 실행한다.
  2. Fragment Shader — 각 **픽셀(fragment)**마다 한 번 실행된다. 그 픽셀이 어떤 색상이어야 하는지 계산한다. 800×600 화면이라면 480,000개 픽셀이 동시에 각자의 Fragment Shader를 실행한다.

JavaScript에서 데이터를 보내고, Vertex Shader가 정점을 배치하고, Fragment Shader가 색을 칠하고, 화면에 출력된다 — 이 흐름을 단계별로 살펴보자:

→
→
→
→

JavaScript에서 시간·텍스처·설정값을 Shader로 전달

💡 GLSL 핵심 키워드 3가지

키워드방향용도
uniformJS → Shader모든 정점/픽셀에서 같은 값. 시간, 마우스, 텍스처 등
attributeJS → Vertex Shader정점마다 다른 값. 위치, UV 좌표 등 (Three.js가 자동 전달)
varyingVertex → FragmentVertex 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 문자로 렌더링되고, 마우스 방향에 따라 색상이 회전한다.

8px
200px

전체 데이터 흐름:

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) = 166
  • rows = 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으로 글자가 그려지는 효과를 만든다.

  • CPU vs GPU — 렌더링 파이프라인의 차이
  • Three.js 기초 — Scene, Camera, Renderer
  • 💡 Scene, Camera, Renderer — 3D 렌더링의 삼총사
  • 💡 PerspectiveCamera의 4가지 파라미터
  • 💡 Mesh = Geometry + Material
  • Shader 파이프라인 — Vertex에서 Fragment까지
  • 💡 Shader란 무엇인가?
  • 💡 GLSL 핵심 키워드 3가지
  • 💡 ASCIIText의 Vertex Shader — 파도 효과
  • 💡 ASCIIText의 Fragment Shader — 색수차 효과
  • ASCIIText 구현 — 4개 클래스의 협업
  • 1단계: CanvasTxt — 텍스트를 텍스처로 만들기
  • 2단계: CanvAscii — Three.js 씬 구성
  • 3단계: AsciiFilter — GPU 렌더 결과를 ASCII로 변환
  • 4단계: React 컴포넌트 — 생명주기 관리
  • React에서 Three.js 쓸 때 주의점
  • 다음 포스트에서는
  • CPU vs GPU — 렌더링 파이프라인의 차이
  • Three.js 기초 — Scene, Camera, Renderer
  • 💡 Scene, Camera, Renderer — 3D 렌더링의 삼총사
  • 💡 PerspectiveCamera의 4가지 파라미터
  • 💡 Mesh = Geometry + Material
  • Shader 파이프라인 — Vertex에서 Fragment까지
  • 💡 Shader란 무엇인가?
  • 💡 GLSL 핵심 키워드 3가지
  • 💡 ASCIIText의 Vertex Shader — 파도 효과
  • 💡 ASCIIText의 Fragment Shader — 색수차 효과
  • ASCIIText 구현 — 4개 클래스의 협업
  • 1단계: CanvasTxt — 텍스트를 텍스처로 만들기
  • 2단계: CanvAscii — Three.js 씬 구성
  • 3단계: AsciiFilter — GPU 렌더 결과를 ASCII로 변환
  • 4단계: React 컴포넌트 — 생명주기 관리
  • React에서 Three.js 쓸 때 주의점
  • 다음 포스트에서는
📚

react-bits 파헤치기

  1. ✅1. CSS만으로 글리치 텍스트 만들기
  2. ✅2. Motion: React에서 선언적으로 애니메이션하기
  3. ✅3. GSAP + ScrollTrigger: 타임라인으로 정밀하게 제어하기
  4. ✅4. Canvas 2D: 텍스트를 픽셀로 다루기
  5. 🔍5. WebGL & Shader: GPU로 텍스트 렌더링하기
  6. ⏳6. Variable Font & SVG: 브라우저 내장 기술의 가능성
←Canvas 2D: 텍스트를 픽셀로 다루기
Variable Font & SVG: 브라우저 내장 기술의 가능성→

관련 포스트

이 포스트와 관련된 다른 글들을 확인해보세요

WebGL & Shader: GPU로 텍스트 렌더링하기

Three.js로 3D 씬을 렌더링하고, Fragment Shader로 픽셀을 ASCII 문자로 변환하는 과정을 단계별로 구현한다.

2026년 2월 22일•13분
webglthree-jsshader+2

Variable Font & SVG: 브라우저 내장 기술의 가능성

font-variation-settings로 마우스 근접도에 반응하는 텍스트, SVG textPath로 곡선 위를 흐르는 텍스트를 구현한다.

2026년 2월 23일•10분
variable-fontsvgcss+2

Canvas 2D: 텍스트를 픽셀로 다루기

왜 텍스트를 Canvas에 그리는가? 픽셀 조작으로 Fuzzy 효과를 만들고, Matter.js 물리 엔진으로 글자를 떨어뜨리는 과정을 구현한다.

2026년 2월 21일•9분
canvashtml5physics-engine+2

Comments