Cheondi
개발 · 웹

브라우저 그래픽 런타임의 경계

WebGPU, WGSL, WebAssembly를 한 덩어리의 신기술로 보지 않고 Unity Web 빌드의 각 층에 놓아 본 기록입니다.

  • #webgpu
  • #wgsl
  • #webassembly
  • #unity
  • #web

Unity로 만든 화면을 브라우저에 올리기 시작했을 때는 빌드가 되느냐 안 되느냐가 제일 큰 문제였다. 에디터에서는 잘 보이는데 Web 빌드에서는 메모리가 다르게 움직이고, 입력이나 해상도도 브라우저 영향을 받았다. 그때는 WebGL 빌드라는 말을 거의 하나의 플랫폼 이름처럼 썼다.

조금 지나고 보니 그 안에도 여러 층이 있었다. Unity에서 작성한 C# 코드가 WebAssembly로 실행되는 부분, 엔진이 브라우저 그래픽 API를 호출하는 부분, 셰이더가 각 환경에 맞게 처리되는 부분이 따로 움직였다. 한 화면이 느리다는 말만으로는 어느 층을 봐야 할지 알 수 없었다.

이번에는 W3C WebGPU 문서, WGSL 문서, Wasm 3.0 발표를 같이 읽었다. 셋 다 브라우저 안에서 동작하지만 같은 문제를 해결하는 기술은 아니었다.

WebGPU — 상태 변경보다 작업을 먼저 구성하는 API

WebGL을 처음 만졌을 때 가장 낯설었던 건 전역 상태를 차례대로 바꾼 뒤 draw call을 보내는 방식이었다. 어떤 texture와 buffer가 현재 묶여 있는지 알아야 다음 호출을 이해할 수 있었다. 호출 순서가 곧 상태라서, 중간 한 줄이 바뀌면 뒤의 결과도 달라질 수 있었다.

WebGPU 문서에서 먼저 눈에 들어온 건 GPUDevice, GPUQueue, GPUCommandEncoder, pipeline처럼 책임이 나뉜 객체였다. 필요한 명령을 encoder에 기록하고 command buffer를 queue에 제출한다. GPU가 할 일을 미리 구성해 넘기는 방향이 더 분명하다.

if (!navigator.gpu) {
  startExistingWebGLBuild();
  return;
}

const adapter = await navigator.gpu.requestAdapter();
const device = adapter && await adapter.requestDevice();

if (!device) {
  startExistingWebGLBuild();
}

이 코드는 WebGPU로 바로 전환하자는 예제가 아니다. 내가 중요하게 본 부분은 기능 감지와 fallback이 호출 경계에 드러난다는 점이다. navigator.gpu가 있다는 사실만으로 실제 adapter와 device를 얻을 수 있다고 가정하면 안 되고, 얻지 못했을 때 기존 경로로 돌아갈 방법도 필요하다.

Unity Web 빌드에서 이 차이를 바로 사용할 수 있는지는 별개의 문제다. 엔진 버전, 브라우저 지원, 빌드 파이프라인이 모두 맞아야 한다. 그래서 제품 코드에 WebGPU 호출을 먼저 넣기보다 브라우저와 엔진 사이에 어떤 계약이 추가되는지를 읽는 게 우선이라고 생각했다.

WGSL — 셰이더 앞에 놓인 검증 경계

WebGPU와 함께 WGSL이 별도 명세로 움직이는 것도 흥미로웠다. 셰이더는 GPU에서 실행되지만 웹에서는 페이지가 전달한 코드를 브라우저가 받아 여러 그래픽 백엔드로 연결해야 한다. 운영체제와 GPU 드라이버가 달라도 브라우저는 가능한 한 같은 규칙으로 코드를 검사해야 한다.

WGSL은 타입, 변수의 주소 공간, 리소스 바인딩, 제어 흐름을 명시한다. 처음 보면 문법 하나를 더 배우는 일처럼 보인다. 그런데 명세를 읽을수록 핵심은 문법 취향보다 브라우저가 실행 전에 무엇을 거절할 수 있는가에 가까웠다.

@group(0) @binding(0)
var<uniform> color: vec4<f32>;

@fragment
fn fragment_main() -> @location(0) vec4<f32> {
  return color;
}

groupbinding이 명시되면 shader가 어떤 resource를 기대하는지 pipeline 쪽에서 확인할 수 있다. 엔진이 자동으로 생성해 주는 셰이더만 사용하면 이런 연결이 잘 보이지 않는다. 하지만 특정 기기에서만 shader compile이 실패하거나 소재가 깨지면, 엔진 설정만 볼 게 아니라 변환된 shader와 브라우저 검증 단계도 살펴야 한다.

예전에는 그래픽 오류를 만나면 “WebGL 쪽 문제”라고 크게 묶었다. 지금은 최소한 shader source 생성, shader validation, pipeline 구성, GPU 실행으로 나눠서 증거를 모으는 편이 낫다는 생각이 든다.

Wasm 3.0 — 그래픽 API와 다른 실행 층

WebAssembly는 Unity Web 빌드에서 C#과 엔진 코드가 브라우저에서 실행되게 만드는 중요한 런타임 층이다. Wasm 3.0에는 memory64, multiple memories, garbage collection 같은 기능이 포함됐다. 기능 이름만 보면 메모리 문제를 한 번에 해결해 줄 것 같지만, 실제 제품에 들어오는 시점은 toolchain과 engine 지원에 달려 있다.

여기서 그래픽 API와 Wasm을 구분하는 게 중요했다. Wasm은 주로 CPU에서 실행되는 프로그램과 메모리 모델의 문제를 다루고, WebGPU는 GPU 작업과 리소스를 제출하는 문제를 다룬다. 화면이 끊기는 증상 하나도 어느 쪽에서 시간이 걸리는지에 따라 접근이 완전히 달라진다.

입력과 게임 로직
  → WebAssembly 런타임
  → 엔진 렌더링 코드
  → WebGL 또는 WebGPU
  → 브라우저 그래픽 백엔드
  → GPU

예를 들어 큰 JSON을 읽고 오브젝트를 만드는 순간에 프레임이 멈춘다면 CPU 쪽 parsing과 allocation을 먼저 의심할 수 있다. 반대로 같은 오브젝트 수에서도 특정 material 조합에서만 문제가 생기면 shader와 pipeline을 먼저 보는 게 자연스럽다. 둘을 모두 “브라우저 성능”으로 묶으면 확인할 항목만 늘어난다.

Unity Web — 새 API보다 먼저 볼 경계

세 문서를 읽고 나서 당장 Unity 프로젝트의 렌더러를 바꿔야겠다는 결론은 내리지 않았다. 오히려 현재 빌드에서 어디까지가 엔진 책임이고 어디부터가 브라우저 책임인지 기록하는 일이 먼저였다.

내가 다음 Web 빌드에서 확인해 보고 싶은 건 이 정도다.

경계 먼저 남길 증거
C# 로직 → Wasm 긴 task, allocation, 호출 시점
엔진 → 그래픽 API draw call, texture 크기, shader variant
브라우저 → GPU 브라우저/OS/GPU 조합, console 오류
빌드 → 사용자 환경 엔진 버전, 빌드 옵션, 배포 artifact

브라우저 기능 감지도 제품 시작 시점에 한 번 모아두면 좋겠다. 다만 사용자 식별 정보까지 섞지 않고 환경과 기능 지원 여부만 남겨야 한다.

const graphicsCapabilities = {
  webgpu: Boolean(navigator.gpu),
  webgl2: Boolean(document.createElement('canvas').getContext('webgl2')),
  crossOriginIsolated: window.crossOriginIsolated
};

console.info('graphics-capabilities', graphicsCapabilities);

WebGPU와 Wasm 3.0은 분명 흥미로운 변화다. 그래도 새 이름을 제품에 빨리 넣는 것보다, 현재 문제를 어느 층에서 확인할지 구분할 수 있게 된 것이 나한테는 더 큰 수확이었다.

Unity Web에서 생기는 문제는 Unity만의 문제도, 브라우저만의 문제도 아닌 경우가 많다. 다음에 비슷한 증상을 만나면 WebGL이라는 한 단어로 묶지 않고 runtime, shader, graphics API, device 경계로 나눠서 살펴보려고 한다.


참고한 글과 코드