본문 바로가기

React

React에서 WebGL 직접 구현하여 인터랙티브 그래픽 만들기

React에서 WebGL을 직접 다루면 의존성 없이 가볍고 빠른 인터랙티브 그래픽을 만들 수 있습니다. 핵심은 React 렌더 트리를 최대한 건드리지 않고, WebGL 상태와 애니메이션 루프를 useRef와 useEffect로 제어하는 것입니다.

1. 아키텍처 개요

React는 캔버스 DOM만 관리하고, 실제 렌더링은 WebGL이 담당합니다. React state는 최소화하고, WebGL 컨텍스트, 셰이더, 버퍼, requestAnimationFrame 루프는 ref에 보관합니다. 리사이즈, 마우스 이벤트는 캔버스에 직접 바인딩합니다.

2. 최소 예제: 풀스크린 트라이앵글 + 마우스 인터랙션

import { useEffect, useRef } from 'react';

function InteractiveGL() {
  const canvasRef = useRef(null);
  const rafRef = useRef(0);
  const stateRef = useRef({ start: performance.now(), mouse: [0, 0] });

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const gl = canvas.getContext('webgl', { antialias: true });
    if (!gl) return;

    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const resize = () => {
      const { clientWidth, clientHeight } = canvas;
      const w = Math.floor(clientWidth * dpr);
      const h = Math.floor(clientHeight * dpr);
      if (canvas.width !== w || canvas.height !== h) {
        canvas.width = w;
        canvas.height = h;
        gl.viewport(0, 0, w, h);
      }
    };
    resize();
    window.addEventListener('resize', resize);

    const vsrc = `
      attribute vec2 a_position;
      void main() {
        gl_Position = vec4(a_position, 0.0, 1.0);
      }
    `;

    const fsrc = `
      precision mediump float;
      uniform vec2 u_resolution;
      uniform vec2 u_mouse;
      uniform float u_time;
      void main() {
        vec2 uv = gl_FragCoord.xy / u_resolution; // 0..1
        vec2 m = u_mouse / u_resolution;          // 0..1
        float d = distance(uv, m);
        float ring = smoothstep(0.0, 0.01, abs(sin(10.0 * d - u_time)));
        vec3 col = mix(vec3(0.1, 0.2, 0.7), vec3(0.9, 0.3, 0.2), ring);
        gl_FragColor = vec4(col, 1.0);
      }
    `;

    const compile = (type, src) => {
      const sh = gl.createShader(type);
      gl.shaderSource(sh, src);
      gl.compileShader(sh);
      if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
        console.error(gl.getShaderInfoLog(sh));
        gl.deleteShader(sh);
        return null;
      }
      return sh;
    };

    const vs = compile(gl.VERTEX_SHADER, vsrc);
    const fs = compile(gl.FRAGMENT_SHADER, fsrc);
    if (!vs || !fs) return;

    const program = gl.createProgram();
    gl.attachShader(program, vs);
    gl.attachShader(program, fs);
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      console.error(gl.getProgramInfoLog(program));
      return;
    }

    gl.useProgram(program);

    const posLoc = gl.getAttribLocation(program, 'a_position');
    const resolutionLoc = gl.getUniformLocation(program, 'u_resolution');
    const mouseLoc = gl.getUniformLocation(program, 'u_mouse');
    const timeLoc = gl.getUniformLocation(program, 'u_time');

    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    // 풀스크린 트라이앵글 (클립스페이스)
    const vertices = new Float32Array([ -1, -1, 3, -1, -1, 3 ]);
    gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
    gl.enableVertexAttribArray(posLoc);
    gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);

    const onMouseMove = (e) => {
      const rect = canvas.getBoundingClientRect();
      const x = (e.clientX - rect.left) * dpr;
      const y = (rect.bottom - e.clientY) * dpr; // WebGL 좌표계 보정
      stateRef.current.mouse = [x, y];
    };
    canvas.addEventListener('mousemove', onMouseMove);

    const draw = (now) => {
      const t = (now - stateRef.current.start) / 1000;
      gl.uniform2f(resolutionLoc, canvas.width, canvas.height);
      gl.uniform2f(mouseLoc, stateRef.current.mouse[0], stateRef.current.mouse[1]);
      gl.uniform1f(timeLoc, t);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      rafRef.current = requestAnimationFrame(draw);
    };
    rafRef.current = requestAnimationFrame(draw);

    return () => {
      cancelAnimationFrame(rafRef.current);
      window.removeEventListener('resize', resize);
      canvas.removeEventListener('mousemove', onMouseMove);
      gl.deleteBuffer(buffer);
      gl.deleteProgram(program);
      gl.deleteShader(vs);
      gl.deleteShader(fs);
    };
  }, []);

  return (
    <canvas
      ref={canvasRef}
      style={{ width: '100%', height: 400, display: 'block', background: '#0b1020' }}
    />
  );
}

export default InteractiveGL;

위 코드는 React 리렌더 없이 마우스에 반응하는 셰이더를 그립니다. 캔버스 리사이즈는 devicePixelRatio를 고려해 선명도를 유지하고, 애니메이션 루프는 requestAnimationFrame으로 관리합니다.

3. 핵심 포인트 정리

풀스크린 트라이앵글을 사용해 프래그먼트 셰이더만으로 화면 전체 픽셀을 계산합니다. 버텍스 버퍼가 간단해지고 픽셀 이펙트 제작에 유리합니다.

devicePixelRatio를 반영해 canvas.width/height를 설정해야 글리치 없이 선명한 렌더링이 가능합니다. gl.viewport도 함께 업데이트합니다.

시간, 해상도, 마우스는 uniform으로 전달합니다. React state로 프레임마다 setState를 호출하지 않고 ref로 값을 보관해 불필요한 렌더를 막습니다.

정리와 메모리 해제는 꼭 수행합니다. 셰이더, 프로그램, 버퍼 삭제와 이벤트 언바인딩을 잊지 않습니다.

4. 인터랙션 확장 아이디어

클릭 위치를 배열에 쌓아 파티클 소스처럼 사용하거나, 휠로 파라미터를 조정해 노이즈 스케일을 바꾸는 등 다양한 인터랙션을 uniform으로 제어할 수 있습니다. 키보드 입력도 window에 직접 바인딩해 ref에 저장하고 셰이더 로직에 반영합니다.

5. 성능 체크리스트

React 상태 업데이트를 프레임 루프에 넣지 않습니다. 셰이더 파라미터는 ref와 uniform으로 전달합니다.

리사이즈와 마우스 이벤트는 requestAnimationFrame 또는 throttle로 보정하여 과도한 작업을 피합니다.

드로우 콜을 1회로 유지하고, 가능한 한 단일 VBO와 단일 프로그램을 사용합니다.

복잡한 이펙트는 텍스처, 프레임버퍼를 활용해 패스 수를 줄입니다. 필요하면 WebGL2로 업그레이드합니다.

저사양에서 품질 토글을 제공합니다. 예: dpr 상한, 이펙트 강도, 패스 수.

6. 훅으로 분리해 재사용하기

import { useEffect, useRef } from 'react';

export function useWebGL(init, draw) {
  const canvasRef = useRef(null);
  const rafRef = useRef(0);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const gl = canvas.getContext('webgl');
    if (!gl) return;

    const cleanupInit = init?.(gl, canvas);

    const loop = (t) => {
      draw?.(gl, canvas, t);
      rafRef.current = requestAnimationFrame(loop);
    };
    rafRef.current = requestAnimationFrame(loop);

    return () => {
      cancelAnimationFrame(rafRef.current);
      cleanupInit && cleanupInit();
    };
  }, [init, draw]);

  return canvasRef;
}

// 사용 예시
// const ref = useWebGL(initFn, drawFn);
// return <canvas ref={ref} style={{ width: '100%', height: 400 }} />;

init에서 셰이더/버퍼를 만들고, draw에서 uniform 업데이트와 그리기를 수행합니다. 이 구조는 페이지 내 여러 WebGL 컴포넌트를 관리하기 쉬워집니다.

7. 디버깅과 툴

Spector.js로 셰이더, 드로우 콜을 검사하면 성능 병목을 빠르게 찾을 수 있습니다. 개발 중에는 gl.getError, 셰이더 컴파일 로그를 항상 확인합니다. 프로덕션에서는 셰이더 문자열을 별도 파일로 분리하거나 템플릿 리터럴로 관리해 가독성을 유지합니다.

8. 마무리

React는 UI 제어, WebGL은 픽셀 렌더링이라는 역할을 분리하면, 가볍고 반응성이 높은 인터랙티브 그래픽을 쉽게 구축할 수 있습니다. 위 예제를 시작점으로 셰이더를 확장하고 입력 이벤트를 추가해 브랜디드 비주얼, 배경 애니메이션, 데이터 시각화를 구현해 보시기 바랍니다.