🎲 threejs

webgpu 튜토리얼

읏차 2024. 11. 9. 14:42

 

web에서 3d 작업을 하는 일이 많아졌다. 지금은 주로 threejs를 사용해서 작업을 하고 있지만 앞으로를 대비해서 webgpu를 사용해서 이것저것 해볼 생각이다.

 

먼저 webgpu sample 코드를 자세히 살펴보겠다.

	const meshRef = useRef<THREE.Points>(null);

        if (!navigator.gpu) {
          throw new Error("WebGPU is not supported");
        }

        const adapter = await navigator.gpu.requestAdapter();
        if (!adapter) {
          throw new Error("No appropriate GPUAdapter found");
        }

        const device = await adapter.requestDevice();

        // GPU 메모리 제한 계산
        const maxBufferSize = adapter.limits.maxStorageBufferBindingSize;
        const vertexSize = 3 * 4; // vec3<f32> = 12 bytes
        const chunkSize = calculateOptimalChunkSize(vertexCount, vertexSize, maxBufferSize);
        const chunks = Array.from({ length: Math.ceil(vertexCount / chunkSize) }, (_, i) => i * chunkSize).map((start) => Math.min(chunkSize, vertexCount - start));

        const allVertices = new Float32Array(vertexCount * 3);
        let processedCount = 0;

        // 청크별로 처리
        for (const currentChunkSize of chunks) {
          const vertexBuffer = device.createBuffer({
            size: currentChunkSize * vertexSize,
            usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
          });

          const computeShaderModule = device.createShaderModule({
            code: `
              @group(0) @binding(0) var<storage, read_write> vertices: array<vec3<f32>>;

              @compute @workgroup_size(256)
              fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
                let index = global_id.x;
                if (index >= ${currentChunkSize}u) {
                  return;
                }
                let globalIndex = ${processedCount}u + index;
                vertices[index] = vec3<f32>(
                  sin(random(f32(globalIndex) + 0.0)) * 2000.0 - 1000.0,
                  sin(random(f32(globalIndex) + 1000.0)) * 2000.0 - 1000.0,
                  sin(random(f32(globalIndex) + 2000.0)) * 2000.0 - 1000.0
                );
              }

              fn random(seed: f32) -> f32 {
                return fract(sin(seed) * 43758.5453);
              }
            `,
          });

          const computePipeline = device.createComputePipeline({
            layout: "auto",
            compute: {
              module: computeShaderModule,
              entryPoint: "main",
            },
          });

          const bindGroup = device.createBindGroup({
            layout: computePipeline.getBindGroupLayout(0),
            entries: [
              {
                binding: 0,
                resource: {
                  buffer: vertexBuffer,
                },
              },
            ],
          });

          const commandEncoder = device.createCommandEncoder();
          const passEncoder = commandEncoder.beginComputePass();
          passEncoder.setPipeline(computePipeline);
          passEncoder.setBindGroup(0, bindGroup);
          passEncoder.dispatchWorkgroups(Math.ceil(currentChunkSize / 256));
          passEncoder.end();

          const gpuReadBuffer = device.createBuffer({
            size: currentChunkSize * vertexSize,
            usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
          });

          commandEncoder.copyBufferToBuffer(vertexBuffer, 0, gpuReadBuffer, 0, currentChunkSize * vertexSize);

          device.queue.submit([commandEncoder.finish()]);

          await gpuReadBuffer.mapAsync(GPUMapMode.READ);
          const arrayBuffer = gpuReadBuffer.getMappedRange();
          const chunkVertices = new Float32Array(arrayBuffer);
          allVertices.set(chunkVertices, processedCount * 3);

          gpuReadBuffer.unmap();
          vertexBuffer.destroy();
          gpuReadBuffer.destroy();

          processedCount += currentChunkSize;
        }

        if (meshRef.current) {
          meshRef.current.geometry.setAttribute("position", new THREE.BufferAttribute(allVertices, 3));
          meshRef.current.geometry.attributes.position.needsUpdate = true;
        }

 

threejs와 webgpu를 사용하여 렌더링을 하였다.

 

 

if (!navigator.gpu) {
  throw new Error("WebGPU is not supported");
}

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
  throw new Error("No appropriate GPUAdapter found");
}

const device = await adapter.requestDevice();

 

브라우저의 webgpu 지원여부를 확인한다. GPU 어댑터 요청 및 디바이스를 초기화하는 부분이다.

 

const adapter = await navigator.gpu.requestAdapter()

 

requestAdapter를 통해 여러 GPU 장치를 사용하는 경우 저전력 옵션이나 고성능 옵션 설정을 통해 가장 알맞은 GPU 장치를 가져 올 수 있다.

 

const maxBufferSize = adapter.limits.maxStorageBufferBindingSize;
const vertexSize = 3 * 4; // vec3<f32> = 12 bytes
const chunkSize = calculateOptimalChunkSize(vertexCount, vertexSize, maxBufferSize);
const chunks = Array.from({ length: Math.ceil(vertexCount / chunkSize) }, (_, i) => i * chunkSize)
  .map((start) => Math.min(chunkSize, vertexCount - start));

 

해당 코드는 vertex를 chunk단위로 나눠서 처리할 수 있게 해준다. chunk로 나눠서 처리하는 이유는 GPU가 한번에 처리할 수 있는 용량에 제한이 있기 때문에 대용량 처리를 위해서는 해당 작업을 해줘야 한다.

일반적으로 maxStorageBufferBindingSize는 1GB의 값을 가진다. 각 정점이 12바이트를 가지므로 이를 고려해서 청크를 설정해야한다. 청크가 너무 많으면 오버헤드가 있을 수 있으므로 적당한 청크 설정을 해줘야 한다.

 

const vertexBuffer = device.createBuffer({
  size: currentChunkSize * vertexSize,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});

 

다음은 GPU에서 사용할 버퍼를 생성하는 작업이다.

usage 속성을 설정할때 buffer의 사용 목적에 맞는 옵션을 설정해야 제대로 동작하게 된다.

 

  • GPUBufferUsage.STORAGE  // 스토리지 버퍼로 사용(읽기/쓰기 가능, compute shader)
  • GPUBufferUsage.VERTEX  // 정점 버퍼로 사용
  • GPUBufferUsage.INDEX // 인덱스 버퍼로 사용
  • GPUBufferUsage.UNIFORM  // 유니폼 버퍼로 사용
  • GPUBufferUsage.COPY_SRC  // 다른 버퍼로 복사할 때 소스로 사용
  • GPUBufferUsage.COPY_DST  // 다른 버퍼에서 복사받을 대상으로 사용
  • GPUBufferUsage.MAP_READ  // CPU에서 읽기 가능 (WASM의 일부 역할(병렬 처리 위주 작업)을 대체할 수 있을까??)
  • GPUBufferUsage.MAP_WRITE  // CPU에서 쓰기 가능 (WASM의 일부 역할 (병렬 처리 위주 작업) 을 대체할 수 있을까??)

사용 예시

// 계산용 STORAGE 버퍼
const computeBuffer = device.createBuffer({
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});

// 렌더링용 VERTEX 버퍼
const renderBuffer = device.createBuffer({
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

// 인덱스 버퍼 생성
const indexBuffer = device.createBuffer({
  size: indices.byteLength,
  usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
  mappedAtCreation: true,
});

// 행렬 연산을 위한 UNIFORM 버퍼
const uniformBuffer = device.createBuffer({
  size: 64, // 4x4 행렬 (16 * 4 bytes)
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  mappedAtCreation: true,
});

 

const computeShaderModule = device.createShaderModule({
  code: `
    @group(0) @binding(0) var<storage, read_write> vertices: array<vec3<f32>>;
    @compute @workgroup_size(256)
    fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
      // 정점 위치 계산 로직
    }
  `,
});

 

wgsl(WebGPU Shading Language)로 작성된 compute shader로 @group(0)과 @binding(0)에 대해 좀 더 알아보자.

 

group과 binding은 shader에서 사용할 데이터들을 연결해주는 역할을 한다.

group은 일반적으로 0~3번을 사용하며 binding은 그룹 당 설정되는 슬롯 번호로 0~15번을 주로 사용한다. 이는 GPU 장치에 따라 달라질 수 있다.

 

group과 binding을 설정하는 방법은 데이터의 용도에 따라 비슷한 성격의 데이터를 묶어서 처리하여 메모리 관점에서 성능상에서 이득을 보는 방향으로 정하는 것이 일반적이다.

다음은 일반적인 데이터 설정 및 사용 예시이다.

 

  • Group 0 (글로벌/정적 데이터)
    - 전체 애플리케이션 설정
    - 거의 변경되지 않는 데이터
    - 공유 리소스
  • Group 1 (프레임 데이터)
    - 카메라 매트릭스
    - 시간 정보
    - 프레임별 업데이트 데이터
  • Group 2 (인스턴스/동적 데이터)
    - 개별 오브젝트 정보
    - 자주 변경되는 데이터
  • Group 3 (특수 목적)
    - 필요한 경우 추가 데이터
// Group 0: 자주 변경되지 않는 전역 데이터
@group(0) @binding(0) var<uniform> globalSettings: GlobalSettings;
@group(0) @binding(1) var<storage> staticData: StaticData;

// Group 1: 프레임마다 변경되는 데이터
@group(1) @binding(0) var<uniform> frameUniforms: FrameUniforms;
@group(1) @binding(1) var mainTexture: texture_2d<f32>;

// Group 2: 오브젝트별 데이터
@group(2) @binding(0) var<storage> objectData: ObjectData;

 

 

const computePipeline = device.createComputePipeline({
  layout: "auto",
  compute: { module: computeShaderModule, entryPoint: "main" },
});

const bindGroup = device.createBindGroup({
  layout: computePipeline.getBindGroupLayout(0),
  entries: [{ binding: 0, resource: { buffer: vertexBuffer } }],
});

const commandEncoder = device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(computePipeline);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.dispatchWorkgroups(Math.ceil(currentChunkSize / 256));
passEncoder.end();

 

compute pipeline를 설정하는 코드이다. compute shader와 buffer를 연결해준다. 쉽게 설명하면 GPU에서 데이터 처리를 하기위해 해줘야 하는 작업이다. 이해를 돕기 위해 렌더링 시 데이터 흐름 구조를 살펴 보면 다음과 같다.

 

CPU

   ↓  

Compute Pipeline (GPU)

   ↓  

Vertex/Index Buffers

   ↓

Render Pipeline(GPU)

   ↓

Screen Output

 

passEncoder.dispatchWorkgroups(Math.ceil(currentChunkSize / 256));

 

마지막에서 두번째 줄을 보면 workgroup을 설정하는 부분이 있다. 이 부분은 개발자가 GPU 아키텍쳐를 고려하여 최적화해야하는 부분이다. 인자안의 값은 GPUDevice의 maxComputeWorkgroupsPerDimension 이하의 값이여야 passEncoder가 유효한 동작을 수행하게 된다.

 

await gpuReadBuffer.mapAsync(GPUMapMode.READ);
const arrayBuffer = gpuReadBuffer.getMappedRange();
const chunkVertices = new Float32Array(arrayBuffer);
allVertices.set(chunkVertices, processedCount * 3);

 

마지막 코드에서는 GPU에서 처리한 정점데이터를 CPU 메모리로 복사하는 부분이다. 이후 Threejs를 통해 렌더링을 하게 된다.

 

앞으로 buffer 설정에 따른 성능 비교나 wasm와 동일한 작업을 처리해보며 성능비교를 할 거 같다.

 

 

전체코드

https://github.com/ohddang/js-forest

'🎲 threejs' 카테고리의 다른 글

GL_INVALID_OPERATION: Insufficient buffer size 이슈 해결  (0) 2024.10.25