AI로 HTML 게임을 만들려다 WebGPU Compute Shaders를 배우게 된 사연
요약
AI가 생성한 HTML 게임 코드의 성능 한계를 극복하기 위해 WebGPU Compute Shader를 도입하여 입자 효과를 최적화하는 과정을 다룹니다. CPU 중심의 Canvas 2D 연산 병목 현상을 GPU 병렬 연산으로 해결하는 기술적 경험을 공유합니다.
핵심 포인트
- AI 생성 코드는 실행 가능하지만 성능 최적화에는 한계가 있음
- Canvas 2D의 반복적인 API 호출은 CPU 성능 병목의 주원인
- WebGPU Compute Shader를 통해 대규모 병렬 연산을 GPU로 오프로드 가능
- 수천 개의 입자 연산을 GPU에서 동시에 처리하여 프레임 레이트 개선
사실 이렇습니다.
지난주에 저는 간단한 입자 불꽃놀이 효과를 만들고 싶었습니다. 화면을 클릭하면 수백 개의 알록달록한 입자들이 폭발하고, 떨어지며, 서서히 사라지는 그런 효과 말이죠. 전에도 Canvas 2D를 사용해 비슷한 것을 만들어 본 적이 있어서 어떻게 해야 하는지는 알고 있었습니다. 하지만 이번에는 좀 게으르게 행동하기로 했습니다. 요구 사항을 Claude에 쏟아붓고 즉석에서 완전한 HTML 파일을 생성해 달라고 요청했습니다.
AI는 아마 15초 정도 만에 작동하는 코드를 내놓았습니다. 깔끔하고 주석도 잘 달려 있었으며, 심지어 그라데이션 색상이 적용된 입자들까지 포함되어 있었습니다. 브라우저에서 파일을 열고 몇 번 클릭해 보았습니다. 입자 200개일 때는 버터처럼 부드러웠습니다. 그러다 개수를 2,000개로 늘렸더니 프레임 레이트(frame rate)가 ~35 fps로 곤두박질쳤습니다. 5,000개의 입자에서는 12 fps로 슬라이드쇼처럼 변해버렸습니다.
그때 깨달았습니다. AI는 실행 가능한 코드를 작성할 수는 있지만, 그 코드가 형편없이 돌아갈 때는 당신을 도와주지 않는다는 사실을 말이죠.
그래서 저는 좌절스럽지만 믿을 수 없을 정도로 보람찬 디버깅(debugging)의 늪에 빠졌습니다. 그리고 마침내 저를 구해준 것은 더 똑똑한 AI가 아니라, 바로 **WebGPU의 컴퓨트 셰이더 (compute shaders)**였습니다.
1단계: 진짜 병목 현상(Bottleneck) 찾기
AI가 생성한 코드는 다음과 같았습니다 (단순화됨):
const particles = [];
function init(count) {
...
로직은 간단합니다. 업데이트 (update) 단계에서는 CPU에서 5,000개의 입자를 루프 돌며 위치와 속도를 재계산하고, 그리기 (draw) 단계에서는 다시 루프를 돌며 Canvas 2D API를 호출하여 원을 그립니다.
그렇다면 문제는 어디에 있을까요? beginPath + arc + fill에 대한 5,000번의 호출은 Canvas 2D에게 너무 무거운 작업입니다. Chrome의 성능(Performance) 패널을 확인해 보니, 그리기(draw) 함수가 프레임 시간의 78%를 소비하고 있었으며, 그 대부분은 fill 함수 내부에서 발생하고 있었습니다.
저는 일반적인 최적화 방법들을 모두 시도해 보았습니다:
- 배치 그리기 (Batch drawing)? 불가능했습니다. 모든 입자가 각기 다른 색상과 알파(alpha) 값을 가지고 있었기 때문입니다.
- ImageData를 사용하여 픽셀을 직접 조작하기? 너무 번거롭고 안티앨리어싱 (anti-aliasing) 효과를 잃게 됩니다.
- 입자 개수 줄이기? 그러면 불꽃놀이가 충분히 멋지게 보이지 않을 것입니다. 저에게는 연산과 무거운 그리기 작업을 모두 CPU에서 GPU로 오프로드 (offload) 할 방법이 필요했습니다.
2단계: WebGPU Compute Shader란 대체 무엇인가? (쉬운 설명)
WebGPU에는 두 가지 주요 사용 사례가 있습니다:
- 렌더링 (Render Pass) – 삼각형, 텍스처 등을 그리는 작업 – WebGL의 대체제입니다.
- 연산 (Compute Pass) – GPU에서 대규모 병렬 수학 연산을 수행하고 그 결과를 다시 읽어오는 작업. Compute shader (컴퓨트 셰이더)는 기본적으로 "그래픽 카드에서 실행되는 미니 프로그램"입니다. 이는 그리기에는 관심이 없으며, 오직 숫자 계산만 수행합니다. 그리고 이 기술의 초능력은 각 요소가 완전히 독립된 상태에서 수천 개의 데이터 요소에 대해 동시에 정확히 동일한 연산을 수행할 수 있다는 점입니다.
저의 5,000개 입자(particles)는 모두 정확히 동일한 업데이트 로직을 따릅니다: position += velocity, velocity += gravity, life -= 0.01. 이는 병렬 처리 (parallelism)에 완벽하게 부합합니다.
그래서 저는 update 함수를 WebGPU compute shader로 다시 작성했습니다. 매 프레임마다 한 번씩 제출하여 GPU가 5,000개의 새로운 입자 상태를 한 번에 계산하도록 했으며, 그 결과를 CPU로 다시 읽어오거나 (또는 렌더링 파이프라인으로 직접 전달하거나) 했습니다.
여기가 핵심 부분입니다.
3단계: 최소 기능 작동 WebGPU Compute Shader
먼저, 입자 데이터 구조를 (WebGPU의 셰이딩 언어인 WGSL로) 정의했습니다:
1.// 각 입자는 16바이트를 차지합니다 (16-byte 정렬)
2.struct Particle {
3. pos: vec2<f32>,
...
그 다음 compute shader 엔트리 포인트 (entry point)입니다:
1.@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
2.
3.@compute @workgroup_size(64)
...
이 셰이더는 컴파일되며, 저는 매 프레임마다 이를 GPU에 제출합니다 – 단 한 번의 제출로 모든 입자가 업데이트됩니다. _@workgroup_size(64)_는 각 워크그룹 (workgroup)이 64개의 입자를 처리한다는 의미이며, GPU는 수많은 스레드 (threads)를 자동으로 병렬 스케줄링합니다.
JavaScript에서는 호출 방식이 다음과 같습니다:
const computePass = encoder.beginComputePass();
computePass.setPipeline(computePipeline);
computePass.setBindGroup(0, bindGroup);
...
그게 전부입니다. GPU가 백그라운드에서 힘든 작업을 수행하며, CPU는 더 이상 5,000개의 아이템을 루프(loop) 돌며 처리할 필요가 없습니다.
4단계: 진짜 고통은 코드를 작성하는 것이 아니었다
이 시점에서는 모든 것이 순조롭게 진행되었다고 생각할 수도 있습니다. 하지만 전체 과정에서 가장 힘들었던 부분은 WGSL을 작성하는 것이 아니었습니다. 바로 디버깅(debugging)이었습니다.
WebGPU 컴퓨트 셰이더(compute shaders)에는 디버깅 도구가 거의 없습니다. 입자(particle)의 위치를 console.log로 출력할 수도 없고, 셰이더 내부에서 중단점(breakpoints)을 설정할 수도 없습니다. 결국 다음과 같은 방법들만 남게 됩니다:
- 결과 버퍼(result buffer)를 CPU로 다시 읽어와 JavaScript로 로그를 남기는 것 – 이는 성능을 저하시킵니다.
- 시각적 출력으로 추측하는 것 – 예를 들어, 입자들이 화면 밖으로 날아간다면 좌표 정규화(coordinate normalization) 문제나 버퍼 정렬(buffer alignment) 실수를 의심해야 합니다.
저는 한 가지 어처구니없는 문제로 꼬박 두 시간을 허비했습니다: WGSL의 vec2는 8바이트 정렬(8-byte alignment)을 가지는데, JavaScript의 Float32Array는 데이터를 순차적으로 저장한다는 점을 간과하고 버퍼를 생성할 때 올바른 스트라이드(stride)를 설정하는 것을 잊어버린 것입니다. 입자들의 위치는 마치 캔버스 밖으로 무작위로 던져진 것처럼 완전히 뒤섞여 버렸습니다.
해결책은 매우 원시적인 방식에서 나왔습니다. GPU 버퍼 데이터를 CPU로 다시 복사하는 작은 유틸리티를 작성하여 처음 10개 입자의 위치를 출력하고, 이를 JavaScript 버전과 비교해 보았고, 그때 정렬 불일치(misalignment)를 발견했습니다.
이 경험은 저에게 뼈아픈 교훈을 주었습니다: WebGPU는 강력하지만, 초보자에게는 믿기지 않을 정도로 가혹합니다. AI는 디버깅을 도와주지 않으며, 검색 엔진도 당신의 정확한 오류를 찾아주지 못합니다. 그저 이를 악물고 명세서(spec)를 읽어야 합니다.
5단계: 최종 결과 (지어낸 수치가 아닌, 저의 솔직한 인상)
제 MacBook Pro (M1 Pro)에서:
- Canvas 2D로 5,000개의 입자 (particles) 처리 – 약 12–15 fps가 나왔으며, 페이지가 매우 느리게 느껴졌습니다.
- WebGPU Compute Shaders로 전환하여 최종 위치값만 Canvas 2D로 전달해 그리는 방식으로 변경 후 – 매우 안정적인 60 fps를 유지했으며, CPU 사용량이 **~70%**에서 **~8%**로 급감했습니다. 참고로 저는 렌더링(drawing) 자체를 GPU로 옮기지는 않았습니다(그렇게 하려면 전체 렌더링 파이프라인 (render pipeline)이 필요합니다). 저는 오직 연산(computation)만을 GPU로 옮겼고, 렌더링에는 여전히 Canvas 2D의 fillRect 또는 _arc_를 사용했습니다. 그럼에도 불구하고 성능 향상은 천지 차이였습니다.
만약 제가 렌더링까지 WebGPU로 옮겼다면 (수천 개의 삼각형을 그리기 위해 _drawIndexed_를 사용하는 방식), 이론적으로는 초당 수천 프레임에 도달할 수도 있었을 것입니다. 하지만 모니터 주사율이 60 Hz뿐이므로 그럴 실익은 없습니다.
그렇다면 AI는 실제로 얼마나 도움이 되었을까요?
- 초기 코드 생성 – 네, 빨랐습니다. 하지만 그것은
마지막으로, WebXR에 대해서는 말하지 않겠습니다
왜냐하면 만약 제가 "WebXR이 브라우저로 메타버스를 가져온다"는 식의 횡설수설을 시작한다면, 다시 공허한 수사(empty-rhetoric)의 영역으로 돌아가게 될 것임을 알고 있기 때문입니다.
그래서 질문 하나만 남기며 마무리하겠습니다:
HTML 게임을 제작하는 동안 "CPU가 따라오지 못하는" 벽에 부딪혀 본 적이 있나요? 만약 그렇다면, 어떻게 해결하셨나요?
만약 여러분도 WebGPU를 고려하고 계신다면, 이 글에 나온 전체 실행 가능한 코드(WGSL + JS bindings)를 담은 작은 저장소(repository)를 정리해서 공유해 드릴 수 있습니다. 그것이야말로 진짜 알짜배기 정보가 될 것입니다. 😊
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기