2026.08.07 · 7분 읽기
WebGPU는 브라우저에서 GPU에 더 직접적으로 접근할 수 있게 해주는 저수준 API이다.
navigator.gpu 로 접근한다.
브라우저에서 3D 그래픽을 그리려면 결국 CPU로는 역부족이기에 GPU에게 부탁을 해야한다. 그것을 시키는 것이 바로 WebGPU다.
WebGL이란 것도 있으나, 이건 CPU 와 GPU 통신 간에 오버헤드가 있고, 몇 가지 단점이 더 있어서 WebGPU가 더 현대식 아키텍쳐에 알맞다고 볼 수 있다고 한다.
WebGPU에는 몇 가지 중요한 개념들이 있는데, 직접 삼각형 하나 그려보면서 정리해봤다.
어댑터는 실제 물리적 GPU 장치에 대한 객체이다. navigator.gpu.requestAdapter() 로 가져올 수 있다.
Type: GPUAdapter
디바이스는 논리적인 GPU 인터페이스로, 어댑터를 통해 불러올 수 있고, 실제 GPU와 통신하기 위해 사용한다.
Type: GPUDevice
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
return console.error("GPU adapter 얻기 실패");
}
const device = await adapter.requestDevice();
// 디바이스가 소실되었을 때 에러 처리
device.lost.then((info) => {
console.error(`Device 소실:`, info.message, `| reason:`, info.reason);
});캔버스에 WebGPU로 그림을 그리려면 캔버스에서 "webgpu" 컨텍스트를 얻어야 한다. canvas.getContext("webgpu")로 가져오고, context.configure({ device, format })로 어떤 디바이스와 어떤 텍스처 포맷을 쓸지 설정해줘야 한다.
Type: GPUCanvasContext
const canvas = document.getElementById("gpuCanvas");
const context = canvas.getContext("webgpu");
if (!context) {
return console.error("캔버스로부터 WebGPU 컨텍스트를 얻을 수 없음");
}
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format, alphaMode: "opaque" });버퍼는 GPU 메모리에 올라가는 데이터 덩어리이다. device.createBuffer({ size, usage })로 만들며, usage에 따라 용도가 갈린다.
GPUBufferUsage.VERTEX: 정점 데이터용GPUBufferUsage.UNIFORM: 셰이더에 전달할 유니폼 데이터용 (model/view/projection 행렬 같은 것)GPUBufferUsage.COPY_DST: CPU에서 데이터를 복사해 넣을 수 있는 버퍼Type: GPUBuffer
const vertices = new Float32Array([
0.0, 0.6, 0.0, -0.5, -0.4, 0.0, 0.5, -0.4, 0.0,
]);
const vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
const MATRIX_SIZE = 16 * 4;
const UNIFORM_SIZE = 3 * MATRIX_SIZE;
const uniformBuffer = device.createBuffer({
size: UNIFORM_SIZE,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});여기서 재밌던 포인트: model/view/projection 행렬은 WebGPU가 대신 계산해주지 않는다. mat4Identity, mat4LookAt, mat4Perspective 같은 함수를 직접 짜서 Float32Array로 만들어야 한다. 카메라 하나 세팅하는 데만 행렬 곱셈 코드가 수십 줄이었다.
큐는 GPU에 실제로 작업(커맨드)을 제출하는 통로이다. 디바이스로부터 얻으며, device.queue.writeBuffer()로 버퍼에 데이터를 써넣거나 device.queue.submit()으로 커맨드 버퍼를 GPU에 제출할 때 사용한다.
Type: GPUQueue
device.queue.writeBuffer(vertexBuffer, 0, vertices);
device.queue.writeBuffer(uniformBuffer, 0, model);
device.queue.writeBuffer(uniformBuffer, MATRIX_SIZE, view);
device.queue.writeBuffer(uniformBuffer, MATRIX_SIZE * 2, projection);셰이더는 GPU에서 실행되는 프로그램이다. WebGPU는 WGSL(WebGPU Shading Language)이라는 전용 언어로 셰이더를 작성한다.
@vertex fn vs_main())@fragment fn fs_main())Type: GPUShaderModule
const VERTEX_SHADER = /* wgsl */ `
struct Matrices {
model : mat4x4<f32>,
view : mat4x4<f32>,
projection : mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> mvp : Matrices;
struct VertexInput {
@location(0) position : vec3<f32>,
};
struct VertexOutput {
@builtin(position) clip_position : vec4<f32>,
};
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
let mvp_matrix = mvp.projection * mvp.view * mvp.model;
out.clip_position = mvp_matrix * vec4<f32>(in.position, 1.0);
return out;
}
`;
const FRAGMENT_SHADER = /* wgsl */ `
@fragment
fn fs_main() -> @location(0) vec4<f32> {
return vec4<f32>(1.0, 1.0, 0.0, 1.0);
}
`;
const vertexModule = device.createShaderModule({ code: VERTEX_SHADER });
const fragmentModule = device.createShaderModule({ code: FRAGMENT_SHADER });노란 삼각형 하나 그리는 셰이더인데도 struct 정의, group/binding 명시, MVP 곱셈까지 전부 직접 써야 한다.
셰이더에 유니폼 데이터(행렬 같은 것)를 전달하려면 두 단계를 거쳐야 한다.
device.createBindGroupLayout()으로 정의.device.createBindGroup()으로 생성.셰이더 코드의 @group(0) @binding(0)이 바로 이 레이아웃/바인드그룹의 번호와 매칭된다.
Type: GPUBindGroupLayout / GPUBindGroup
const bindGroupLayout = device.createBindGroupLayout({
entries: [{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: { type: "uniform" },
}],
});
const bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [{
binding: 0,
resource: { buffer: uniformBuffer },
}],
});렌더 파이프라인은 "이 데이터를 이 셰이더로 이렇게 그리겠다"는 전체 그리기 설정을 미리 컴파일해둔 객체이다. vertex 셰이더, fragment 셰이더, 정점 데이터의 형식(buffers), 삼각형을 어떻게 구성할지(primitive.topology) 등을 한 번에 정의한다.
매 프레임마다 새로 만들 필요 없이 한 번만 만들어두고 재사용한다.
Type: GPURenderPipeline
const pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [bindGroupLayout],
}),
vertex: {
module: vertexModule,
entryPoint: "vs_main",
buffers: [{
arrayStride: 3 * 4,
stepMode: "vertex",
attributes: [{
shaderLocation: 0,
offset: 0,
format: "float32x3",
}],
}],
},
fragment: {
module: fragmentModule,
entryPoint: "fs_main",
targets: [{ format }],
},
primitive: {
topology: "triangle-list",
},
});GPU에게 시킬 작업(커맨드)들은 바로바로 실행되는 게 아니라, 먼저 커맨드 인코더에 "기록"된다. 기록이 끝나면 finish()로 커맨드 버퍼를 굳히고, queue.submit()으로 제출해야 실제로 GPU가 실행한다.
Type: GPUCommandEncoder / GPURenderPassEncoder / GPUCommandBuffer
function render() {
const commandEncoder = device.createCommandEncoder();
const renderPass = commandEncoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0.05, g: 0.05, b: 0.08, a: 1.0 },
loadOp: "clear",
storeOp: "store",
}],
});
renderPass.setPipeline(pipeline);
renderPass.setBindGroup(0, bindGroup);
renderPass.setVertexBuffer(0, vertexBuffer);
renderPass.draw(3);
renderPass.end();
const commandBuffer = commandEncoder.finish();
device.queue.submit([commandBuffer]);
}
render();"기록만 해두고 마지막에 한 번에 던진다"는 방식이 WebGPU의 핵심 아키텍처다. WebGL처럼 명령 하나 보낼 때마다 CPU-GPU가 확인을 주고받는 게 아니라, 계획을 다 세운 뒤 한 번에 밀어 넣는 구조라서 오버헤드가 적다.
requestAdapter() → requestDevice()로 GPU 확보canvas.getContext("webgpu") → context.configure()로 그릴 대상 확보Buffer로 만들어서 queue.writeBuffer()로 GPU 메모리에 복사ShaderModule로 컴파일BindGroupLayout으로 데이터 연결 설계도를 만들고, BindGroup으로 실제 버퍼 연결RenderPipeline 생성CommandEncoder에 그리기 명령 기록 → finish()로 굳혀서 → queue.submit()으로 제출노란 삼각형 하나 화면에 띄우는 데 이 정도 코드가 필요했다. 그런데 여기서 든 생각은, "이게 정말 WebGPU 자체를 깊게 알아야 하는 지식인가?" 였다.

완성본...ㅋㅋ
위 코드를 한 줄도 안 빼고 다 합치면 150줄 가까이 된다. 근데 이 안에서 실제로 "내 의도"가 담긴 부분은 사실 셋뿐이다.
나머지 — adapter/device 확보, 캔버스 컨텍스트 설정, 버퍼 만들고 데이터 복사, WGSL로 MVP 곱셈 셰이더 작성, 바인드그룹 설계, 파이프라인 컴파일, 매 프레임 커맨드 인코딩/제출 — 은 전부 "GPU와 대화하기 위한 의식(ritual)"이지 내가 그리고 싶은 그림과는 무관한 보일러플레이트다.
three.js로 똑같은 걸 만들면 이렇게 된다.
import * as THREE from "three/webgpu";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 100);
camera.position.set(0, 0, 2);
camera.lookAt(0, 0, 0);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(
[0.0, 0.6, 0.0, -0.5, -0.4, 0.0, 0.5, -0.4, 0.0],
3,
),
);
const material = new THREE.MeshBasicMaterial({ color: 0xffff00 });
const triangle = new THREE.Mesh(geometry, material);
scene.add(triangle);
const renderer = new THREE.WebGPURenderer({ canvas });
renderer.setSize(width, height);
renderer.setAnimationLoop(() => renderer.render(scene, camera));20줄이 안 된다. 그리고 위에서 정리한 개념들이 어디로 갔는지 대응시켜보면:
| WebGPU 개념 | three.js에서는 |
|---|---|
| Adapter / Device 확보 | WebGPURenderer 생성자가 내부적으로 처리 |
| Canvas Context 설정 | WebGPURenderer 생성자가 내부적으로 처리 |
| Vertex Buffer 생성 + writeBuffer | BufferGeometry.setAttribute() |
| Uniform Buffer + model/view/projection 계산 | Object3D.matrix, Camera.matrixWorldInverse, Camera.projectionMatrix — render() 호출 시 자동 갱신 |
| WGSL Vertex/Fragment Shader 직접 작성 | MeshBasicMaterial 같은 내장 머티리얼이 내부적으로 생성 (커스텀이 필요하면 TSL로 JS 문법에 가깝게 작성) |
| Bind Group Layout / Bind Group | 머티리얼과 지오메트리를 넣으면 렌더러가 자동으로 바인딩 |
| Render Pipeline 생성 | geometry + material 조합마다 렌더러가 자동 생성·캐싱 |
| Command Encoder / Render Pass / Submit | renderer.render(scene, camera) 한 줄 |
three.js의 Renderer, Geometry, Material, Camera 같은 API가 "무엇을 대신 해주고 있는지"가 훨씬 선명하게 보인다.
three.js는 정말로 이 복잡성을 통째로 삼켜버린다. 물론 높은 정교함을 요구하는 작업에서는 WebGPU의 세부적인 동작 과정을 이해하고, 내부 코드를 이해하는게 중요하겠지만, 일단은 three.js 공부에 집중하도록 해볼 예정이다.
React, Next.js, React Native로 만들고 기록합니다.
giscus로 동작하며 GitHub Discussions에 저장됩니다.