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와 동일한 작업을 처리해보며 성능비교를 할 거 같다.
전체코드
'🎲 threejs' 카테고리의 다른 글
GL_INVALID_OPERATION: Insufficient buffer size 이슈 해결 (0) | 2024.10.25 |
---|