WebGPU로 브라우저 3D 시각화: Three.js 없이 GPU 직접 제어

프론트엔드

WebGPU3D 시각화셰이더GPU브라우저

이 글은 누구를 위한 것인가

  • 브라우저에서 고성능 3D 시각화나 GPU 계산이 필요한 팀
  • WebGL에서 WebGPU로 전환을 검토하는 개발자
  • 데이터 시각화에 GPU 가속을 활용하려는 프론트엔드 개발자

들어가며

WebGPU는 WebGL의 후계자다. 현대 GPU API(Vulkan/Metal/D3D12)를 웹에서 직접 사용해 WebGL 대비 3-5배 빠른 렌더링과 컴퓨트 셰이더를 지원한다. Chrome 113+, Firefox Nightly, Safari TP에서 지원된다.

이 글은 bluefoxdev.kr의 WebGPU 실전 가이드 를 참고하여 작성했습니다.


1. WebGPU vs WebGL 비교

[WebGL vs WebGPU]

WebGL (OpenGL ES 기반):
  API: OpenGL 스타일 (상태 기계)
  셰이더: GLSL
  멀티스레딩: 제한적
  컴퓨트: 미지원 (WebGL 2는 부분 지원)
  CPU-GPU 동기: 느린 readPixels()

WebGPU (Vulkan/Metal/D3D12 기반):
  API: 모던 GPU API (명시적 제어)
  셰이더: WGSL (WebGPU Shading Language)
  멀티스레딩: Web Worker 지원
  컴퓨트: 컴퓨트 셰이더 완전 지원
  CPU-GPU 동기: 효율적 버퍼 매핑

[WebGPU 핵심 개념]

GPUDevice: GPU 인터페이스
GPUBuffer: GPU 메모리 버퍼
GPUTexture: 텍스처
GPURenderPipeline: 렌더링 파이프라인 (버텍스 + 프래그먼트)
GPUComputePipeline: 컴퓨트 파이프라인
GPUCommandEncoder: GPU 명령 기록
GPUBindGroup: 리소스 바인딩 그룹

[WGSL 주요 특징]
  강타입 (f32, vec3<f32>, mat4x4<f32>)
  built-in 변수: @builtin(position), @builtin(vertex_index)
  바인딩: @group(0) @binding(0)
  셰이더 스테이지: @vertex, @fragment, @compute

2. WebGPU 3D 렌더링 구현

// WebGPU 초기화 및 기본 삼각형 렌더링
class WebGPURenderer {
  private device!: GPUDevice;
  private context!: GPUCanvasContext;
  private format!: GPUTextureFormat;
  
  async initialize(canvas: HTMLCanvasElement) {
    // GPU 어댑터 요청
    const adapter = await navigator.gpu?.requestAdapter({
      powerPreference: 'high-performance',
    });
    
    if (!adapter) throw new Error('WebGPU 미지원');
    
    this.device = await adapter.requestDevice({
      requiredFeatures: [],
      requiredLimits: {
        maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
      },
    });
    
    // Canvas 설정
    this.context = canvas.getContext('webgpu') as GPUCanvasContext;
    this.format = navigator.gpu.getPreferredCanvasFormat();
    
    this.context.configure({
      device: this.device,
      format: this.format,
      alphaMode: 'premultiplied',
    });
  }
  
  createTriangleRenderer() {
    // WGSL 셰이더
    const shaderCode = `
      struct VertexOutput {
        @builtin(position) position: vec4<f32>,
        @location(0) color: vec3<f32>,
      }
      
      @vertex
      fn vsMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
        // 화면 좌표 삼각형 (-1 to 1)
        var positions = array<vec2<f32>, 3>(
          vec2<f32>( 0.0,  0.5),
          vec2<f32>(-0.5, -0.5),
          vec2<f32>( 0.5, -0.5),
        );
        
        var colors = array<vec3<f32>, 3>(
          vec3<f32>(1.0, 0.0, 0.0),  // 빨강
          vec3<f32>(0.0, 1.0, 0.0),  // 초록
          vec3<f32>(0.0, 0.0, 1.0),  // 파랑
        );
        
        var output: VertexOutput;
        output.position = vec4<f32>(positions[vertexIndex], 0.0, 1.0);
        output.color = colors[vertexIndex];
        return output;
      }
      
      @fragment
      fn fsMain(input: VertexOutput) -> @location(0) vec4<f32> {
        return vec4<f32>(input.color, 1.0);
      }
    `;
    
    const shaderModule = this.device.createShaderModule({ code: shaderCode });
    
    const pipeline = this.device.createRenderPipeline({
      layout: 'auto',
      vertex: {
        module: shaderModule,
        entryPoint: 'vsMain',
      },
      fragment: {
        module: shaderModule,
        entryPoint: 'fsMain',
        targets: [{ format: this.format }],
      },
      primitive: {
        topology: 'triangle-list',
      },
    });
    
    return pipeline;
  }
  
  render(pipeline: GPURenderPipeline) {
    const commandEncoder = this.device.createCommandEncoder();
    
    const renderPass = commandEncoder.beginRenderPass({
      colorAttachments: [{
        view: this.context.getCurrentTexture().createView(),
        clearValue: { r: 0.1, g: 0.1, b: 0.1, a: 1.0 },
        loadOp: 'clear',
        storeOp: 'store',
      }],
    });
    
    renderPass.setPipeline(pipeline);
    renderPass.draw(3);  // 3개 버텍스 (삼각형)
    renderPass.end();
    
    this.device.queue.submit([commandEncoder.finish()]);
  }
}
// 컴퓨트 셰이더 - GPU 병렬 계산 (N-body 시뮬레이션)
async function runParticleSimulation(device: GPUDevice) {
  const PARTICLE_COUNT = 1000;
  
  // 입자 초기 데이터 (x, y, vx, vy)
  const particles = new Float32Array(PARTICLE_COUNT * 4);
  for (let i = 0; i < PARTICLE_COUNT; i++) {
    particles[i * 4 + 0] = Math.random() * 2 - 1;  // x
    particles[i * 4 + 1] = Math.random() * 2 - 1;  // y
    particles[i * 4 + 2] = (Math.random() - 0.5) * 0.01;  // vx
    particles[i * 4 + 3] = (Math.random() - 0.5) * 0.01;  // vy
  }
  
  // GPU 버퍼 생성
  const particleBuffer = device.createBuffer({
    size: particles.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
  });
  
  device.queue.writeBuffer(particleBuffer, 0, particles);
  
  // 컴퓨트 셰이더
  const computeShader = `
    struct Particle {
      position: vec2<f32>,
      velocity: vec2<f32>,
    }
    
    @group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
    
    @compute @workgroup_size(64)
    fn main(@builtin(global_invocation_id) id: vec3<u32>) {
      let i = id.x;
      if (i >= arrayLength(&particles)) { return; }
      
      var p = particles[i];
      
      // 중력
      p.velocity.y -= 0.0001;
      
      // 위치 업데이트
      p.position += p.velocity;
      
      // 경계 반사
      if (abs(p.position.x) > 1.0) { p.velocity.x *= -1.0; }
      if (abs(p.position.y) > 1.0) { p.velocity.y *= -1.0; }
      
      particles[i] = p;
    }
  `;
  
  const computePipeline = device.createComputePipeline({
    layout: 'auto',
    compute: {
      module: device.createShaderModule({ code: computeShader }),
      entryPoint: 'main',
    },
  });
  
  const bindGroup = device.createBindGroup({
    layout: computePipeline.getBindGroupLayout(0),
    entries: [{ binding: 0, resource: { buffer: particleBuffer } }],
  });
  
  // 시뮬레이션 실행
  function step() {
    const encoder = device.createCommandEncoder();
    const pass = encoder.beginComputePass();
    
    pass.setPipeline(computePipeline);
    pass.setBindGroup(0, bindGroup);
    pass.dispatchWorkgroups(Math.ceil(PARTICLE_COUNT / 64));
    pass.end();
    
    device.queue.submit([encoder.finish()]);
    requestAnimationFrame(step);
  }
  
  step();
}

// Three.js WebGPU 렌더러 (WebGL 코드를 WebGPU로 전환)
// import * as THREE from 'three/webgpu';

// const renderer = new THREE.WebGPURenderer({ canvas });
// await renderer.init();

// 기존 Three.js 코드 그대로 사용 가능
// const scene = new THREE.Scene();
// const camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000);
// const geometry = new THREE.BoxGeometry();
// const material = new THREE.MeshStandardMaterial({ color: 0x6366f1 });
// scene.add(new THREE.Mesh(geometry, material));

마무리

WebGPU는 Three.js/Babylon.js 같은 라이브러리 없이 GPU를 직접 제어할 수 있는 강력한 API다. 컴퓨트 셰이더는 물리 시뮬레이션, 머신러닝, 이미지 처리를 CPU 대비 수십-수백 배 빠르게 실행한다. 당장 Three.js를 버릴 필요는 없다. Three.js r165+의 WebGPURenderer는 기존 Three.js 코드를 WebGPU로 렌더링하는 마이그레이션 경로를 제공한다.