WebGPU로 앱 빌드

François Beaufort
François Beaufort

웹 개발자에게 WebGPU는 GPU에 통합되고 빠른 액세스를 제공하는 웹 그래픽 API입니다. WebGPU는 최신 하드웨어 기능을 제공하며 Direct3D 12, Metal, Vulkan과 유사하게 GPU에서 렌더링 및 계산 작업을 허용합니다.

사실이지만 이 이야기는 불완전합니다. WebGPU는 Apple, Google, Intel, Mozilla, Microsoft와 같은 주요 기업을 비롯한 여러 기업의 공동작업의 결과입니다. 그중 일부는 WebGPU가 JavaScript API가 아니라 웹 이외의 생태계 전반의 개발자를 위한 크로스 플랫폼 그래픽 API일 수 있다는 점을 깨달았습니다.

기본 사용 사례를 충족하기 위해 Chrome 113에서 JavaScript API가 도입되었습니다. 하지만 그와 함께 또 다른 중요한 프로젝트인 webgpu.h C API가 개발되었습니다. 이 C 헤더 파일에는 WebGPU에서 사용 가능한 모든 절차와 데이터 구조가 나와 있습니다. 이는 플랫폼에 관계없는 하드웨어 추상화 계층으로서, 여러 플랫폼에서 일관된 인터페이스를 제공하여 플랫폼별 애플리케이션을 빌드할 수 있도록 합니다.

이 문서에서는 웹과 특정 플랫폼에서 모두 실행되는 WebGPU를 사용하여 작은 C++ 앱을 작성하는 방법을 알아봅니다. 스포일러 경고, 코드베이스를 거의 조정하지 않은 채 브라우저 창과 데스크톱 창에 나타나는 것과 동일한 빨간색 삼각형이 표시됩니다.

macOS의 브라우저 창과 데스크톱 창에서 WebGPU를 사용하는 빨간색 삼각형의 스크린샷
브라우저 창과 데스크톱 창에서 WebGPU를 기반으로 하는 동일한 삼각형

기본 원리

완성된 애플리케이션을 보려면 WebGPU 크로스 플랫폼 앱 저장소를 확인하세요.

이 앱은 WebGPU를 사용하여 단일 코드베이스에서 데스크톱 및 웹 앱을 빌드하는 방법을 보여주는 최소한의 C++ 예입니다. 내부적으로 WebGPU의 webgpu.hwebgpu_cpp.h라는 C++ 래퍼를 통해 플랫폼에 관계없는 하드웨어 추상화 레이어로 사용합니다.

웹에서 앱은 JavaScript API를 기반으로 webgpu.h를 구현하는 바인딩이 있는 Emscripten을 기반으로 빌드됩니다. macOS 또는 Windows와 같은 특정 플랫폼에서는 Chromium의 크로스 플랫폼 WebGPU 구현인 Dawn을 기준으로 이 프로젝트를 빌드할 수 있습니다. webgpu.h의 Rust 구현인 wgpu-native도 있지만 이 문서에서는 사용되지 않습니다.

시작하기

시작하려면 크로스 플랫폼 빌드를 표준 방식으로 처리하기 위한 C++ 컴파일러와 CMake가 필요합니다. 전용 폴더 내에서 main.cpp 소스 파일과 CMakeLists.txt 빌드 파일을 생성합니다.

지금은 main.cpp 파일에 빈 main() 함수가 포함되어야 합니다.

int main() {}

CMakeLists.txt 파일에는 프로젝트에 관한 기본 정보가 포함됩니다. 마지막 줄은 실행 파일 이름이 'app'이고 소스 코드가 main.cpp임을 지정합니다.

cmake_minimum_required(VERSION 3.13) # CMake version check
project(app)                         # Create project "app"
set(CMAKE_CXX_STANDARD 20)           # Enable C++20 standard

add_executable(app "main.cpp")

cmake -B build를 실행하여 'build/' 하위 폴더에 빌드 파일을 만들고 cmake --build build를 실행하여 실제로 앱을 빌드하고 실행 파일을 생성합니다.

# Build the app with CMake.
$ cmake -B build && cmake --build build

# Run the app.
$ ./build/app

앱은 실행되지만 아직 출력이 없습니다. 화면에 무언가를 그리는 방법이 필요합니다.

Dawn 가져오기

삼각형을 그리려면 Chromium의 크로스 플랫폼 WebGPU 구현인 Dawn을 활용할 수 있습니다. 여기에는 화면에 그리기 위한 GLFW C++ 라이브러리가 포함됩니다. Dawn을 다운로드하는 한 가지 방법은 저장소에 git 하위 모듈로 추가하는 것입니다. 다음 명령어는 'dawn/' 하위 폴더에서 가져옵니다.

$ git init
$ git submodule add https://dawn.googlesource.com/dawn

그런 다음 다음과 같이 CMakeLists.txt 파일에 추가합니다.

  • CMake DAWN_FETCH_DEPENDENCIES 옵션은 모든 Dawn 종속 항목을 가져옵니다.
  • dawn/ 하위 폴더가 대상에 포함되어 있습니다.
  • 앱은 나중에 main.cpp 파일에서 사용할 수 있도록 dawn::webgpu_dawn, glfw, webgpu_glfw 타겟을 사용합니다.
…
set(DAWN_FETCH_DEPENDENCIES ON)
add_subdirectory("dawn" EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE dawn::webgpu_dawn glfw webgpu_glfw)

창 열기

이제 Dawn을 사용할 수 있으므로 GLFW를 사용하여 화면에 항목을 그립니다. 편의를 위해 webgpu_glfw에 포함된 이 라이브러리를 사용하면 창 관리에 관한 플랫폼에 종속되지 않는 코드를 작성할 수 있습니다.

해상도가 512x512인 'WebGPU 창'이라는 창을 열려면 아래와 같이 main.cpp 파일을 업데이트합니다. 여기서 glfwWindowHint()는 특정 그래픽 API 초기화를 요청하지 않기 위해 사용됩니다.

#include <GLFW/glfw3.h>

const uint32_t kWidth = 512;
const uint32_t kHeight = 512;

void Start() {
  if (!glfwInit()) {
    return;
  }

  glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
  GLFWwindow* window =
      glfwCreateWindow(kWidth, kHeight, "WebGPU window", nullptr, nullptr);

  while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    // TODO: Render a triangle using WebGPU.
  }
}

int main() {
  Start();
}

앱을 다시 빌드하고 이전과 같이 실행하면 빈 창이 표시됩니다. 진전을 보이고 있습니다.

빈 macOS 창의 스크린샷
빈 창입니다.

GPU 기기 가져오기

JavaScript에서 navigator.gpu는 GPU에 액세스하기 위한 진입점입니다. C++에서는 동일한 목적으로 사용되는 wgpu::Instance 변수를 수동으로 만들어야 합니다. 편의를 위해 main.cpp 파일 상단에 instance를 선언하고 main() 내에서 wgpu::CreateInstance()를 호출합니다.

…
#include <webgpu/webgpu_cpp.h>

wgpu::Instance instance;
…

int main() {
  instance = wgpu::CreateInstance();
  Start();
}

GPU 액세스는 JavaScript API의 모양으로 인해 비동기식입니다. C++에서 각각 wgpu::Adapterwgpu::Device가 있는 콜백 함수를 반환하는 GetAdapter()GetDevice()라는 도우미 함수 두 개를 만듭니다.

#include <iostream>
…

void GetAdapter(void (*callback)(wgpu::Adapter)) {
  instance.RequestAdapter(
      nullptr,
      [](WGPURequestAdapterStatus status, WGPUAdapter cAdapter,
         const char* message, void* userdata) {
        if (status != WGPURequestAdapterStatus_Success) {
          exit(0);
        }
        wgpu::Adapter adapter = wgpu::Adapter::Acquire(cAdapter);
        reinterpret_cast<void (*)(wgpu::Adapter)>(userdata)(adapter);
  }, reinterpret_cast<void*>(callback));
}

void GetDevice(void (*callback)(wgpu::Device)) {
  adapter.RequestDevice(
      nullptr,
      [](WGPURequestDeviceStatus status, WGPUDevice cDevice,
          const char* message, void* userdata) {
        wgpu::Device device = wgpu::Device::Acquire(cDevice);
        device.SetUncapturedErrorCallback(
            [](WGPUErrorType type, const char* message, void* userdata) {
              std::cout << "Error: " << type << " - message: " << message;
            },
            nullptr);
        reinterpret_cast<void (*)(wgpu::Device)>(userdata)(device);
  }, reinterpret_cast<void*>(callback));
}

더 쉽게 액세스할 수 있도록 main.cpp 파일 상단에 두 변수 wgpu::Adapterwgpu::Device를 선언합니다. GetAdapter()를 호출하고 결과 콜백을 adapter에 할당하도록 main() 함수를 업데이트한 다음 Start()를 호출하기 전에 GetDevice()를 호출하고 결과 콜백을 device에 할당합니다.

wgpu::Adapter adapter;
wgpu::Device device;
…

int main() {
  instance = wgpu::CreateInstance();
  GetAdapter([](wgpu::Adapter a) {
    adapter = a;
    GetDevice([](wgpu::Device d) {
      device = d;
      Start();
    });
  });
}

삼각형 그리기

스왑 체인은 브라우저가 처리하므로 JavaScript API에서 노출되지 않습니다. C++에서는 수동으로 만들어야 합니다. 편의를 위해 다시 한번 main.cpp 파일 상단에 wgpu::Surface 변수를 선언합니다. Start()에서 GLFW 창을 만든 직후 편리한 wgpu::glfw::CreateSurfaceForWindow() 함수를 호출하여 wgpu::Surface (HTML 캔버스와 유사)를 만들고 InitGraphics()에서 새 도우미 ConfigureSurface() 함수를 호출하여 구성합니다. 또한 surface.Present()를 호출하여 while 루프에서 다음 텍스처를 표시해야 합니다. 아직 렌더링이 이루어지지 않으므로 눈에 띄는 효과는 없습니다.

#include <webgpu/webgpu_glfw.h>
…

wgpu::Surface surface;
wgpu::TextureFormat format;

void ConfigureSurface() {
  wgpu::SurfaceCapabilities capabilities;
  surface.GetCapabilities(adapter, &capabilities);
  format = capabilities.formats[0];

  wgpu::SurfaceConfiguration config{
      .device = device,
      .format = format,
      .width = kWidth,
      .height = kHeight};
  surface.Configure(&config);
}

void InitGraphics() {
  ConfigureSurface();
}

void Render() {
  // TODO: Render a triangle using WebGPU.
}

void Start() {
  …
  surface = wgpu::glfw::CreateSurfaceForWindow(instance, window);

  InitGraphics();

  while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    Render();
    surface.Present();
    instance.ProcessEvents();
  }
}

이제 아래 코드로 렌더링 파이프라인을 만드는 것이 좋습니다. 더 쉽게 액세스하려면 main.cpp 파일 상단에서 wgpu::RenderPipeline 변수를 선언하고 InitGraphics()에서 도우미 함수 CreateRenderPipeline()를 호출합니다.

wgpu::RenderPipeline pipeline;
…

const char shaderCode[] = R"(
    @vertex fn vertexMain(@builtin(vertex_index) i : u32) ->
      @builtin(position) vec4f {
        const pos = array(vec2f(0, 1), vec2f(-1, -1), vec2f(1, -1));
        return vec4f(pos[i], 0, 1);
    }
    @fragment fn fragmentMain() -> @location(0) vec4f {
        return vec4f(1, 0, 0, 1);
    }
)";

void CreateRenderPipeline() {
  wgpu::ShaderModuleWGSLDescriptor wgslDesc{};
  wgslDesc.code = shaderCode;

  wgpu::ShaderModuleDescriptor shaderModuleDescriptor{
      .nextInChain = &wgslDesc};
  wgpu::ShaderModule shaderModule =
      device.CreateShaderModule(&shaderModuleDescriptor);

  wgpu::ColorTargetState colorTargetState{.format = format};

  wgpu::FragmentState fragmentState{.module = shaderModule,
                                    .targetCount = 1,
                                    .targets = &colorTargetState};

  wgpu::RenderPipelineDescriptor descriptor{
      .vertex = {.module = shaderModule},
      .fragment = &fragmentState};
  pipeline = device.CreateRenderPipeline(&descriptor);
}

void InitGraphics() {
  …
  CreateRenderPipeline();
}

마지막으로 각 프레임에서 호출되는 Render() 함수에서 렌더링 명령어를 GPU로 전송합니다.

void Render() {
  wgpu::SurfaceTexture surfaceTexture;
  surface.GetCurrentTexture(&surfaceTexture);

  wgpu::RenderPassColorAttachment attachment{
      .view = surfaceTexture.texture.CreateView(),
      .loadOp = wgpu::LoadOp::Clear,
      .storeOp = wgpu::StoreOp::Store};

  wgpu::RenderPassDescriptor renderpass{.colorAttachmentCount = 1,
                                        .colorAttachments = &attachment};

  wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
  wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&renderpass);
  pass.SetPipeline(pipeline);
  pass.Draw(3);
  pass.End();
  wgpu::CommandBuffer commands = encoder.Finish();
  device.GetQueue().Submit(1, &commands);
}

이제 CMake로 앱을 다시 빌드하고 실행하면 창에 오래 기다려온 빨간색 삼각형이 표시됩니다. 잠시 쉬세요.

macOS 창의 빨간색 삼각형 스크린샷
데스크톱 창의 빨간색 삼각형

WebAssembly로 컴파일

이제 브라우저 창에 이 빨간색 삼각형을 그리도록 기존 코드베이스를 조정하는 데 필요한 최소한의 변경사항을 살펴보겠습니다. 이 앱은 C/C++ 프로그램을 WebAssembly로 컴파일하는 도구인 Emscripten을 기반으로 빌드되었습니다. WebAssembly에는 JavaScript API를 기반으로 webgpu.h를 구현하는 바인딩이 포함되어 있습니다.

CMake 설정 업데이트

Emscripten이 설치되면 다음과 같이 CMakeLists.txt 빌드 파일을 업데이트합니다. 강조 표시된 코드만 변경하면 됩니다.

  • set_target_properties는 'html' 파일 확장자를 타겟 파일에 자동으로 추가하는 데 사용됩니다. 즉, 'app.html' 파일이 생성됩니다.
  • Emscripten에서 WebGPU 지원을 사용 설정하려면 USE_WEBGPU 앱 링크 옵션이 필요합니다. 이 섹션이 없으면 main.cpp 파일이 webgpu/webgpu_cpp.h 파일에 액세스할 수 없습니다.
  • GLFW 코드를 재사용할 수 있도록 USE_GLFW 앱 링크 옵션도 여기에 필요합니다.
cmake_minimum_required(VERSION 3.13) # CMake version check
project(app)                         # Create project "app"
set(CMAKE_CXX_STANDARD 20)           # Enable C++20 standard

add_executable(app "main.cpp")

if(EMSCRIPTEN)
  set_target_properties(app PROPERTIES SUFFIX ".html")
  target_link_options(app PRIVATE "-sUSE_WEBGPU=1" "-sUSE_GLFW=3")
else()
  set(DAWN_FETCH_DEPENDENCIES ON)
  add_subdirectory("dawn" EXCLUDE_FROM_ALL)
  target_link_libraries(app PRIVATE dawn::webgpu_dawn glfw webgpu_glfw)
endif()

코드 업데이트

Emscripten에서 wgpu::surface를 만들려면 HTML 캔버스 요소가 필요합니다. 이를 위해 instance.CreateSurface()를 호출하고 Emscripten에서 생성한 HTML 페이지의 적절한 HTML 캔버스 요소와 일치하도록 #canvas 선택기를 지정합니다.

while 루프를 사용하는 대신 emscripten_set_main_loop(Render)를 호출하여 Render() 함수가 브라우저와 모니터에 맞게 적절하게 매끄러운 속도로 호출되도록 합니다.

#include <GLFW/glfw3.h>
#include <webgpu/webgpu_cpp.h>
#include <iostream>
#if defined(__EMSCRIPTEN__)
#include <emscripten/emscripten.h>
#else
#include <webgpu/webgpu_glfw.h>
#endif
void Start() {
  if (!glfwInit()) {
    return;
  }

  glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
  GLFWwindow* window =
      glfwCreateWindow(kWidth, kHeight, "WebGPU window", nullptr, nullptr);

#if defined(__EMSCRIPTEN__)
  wgpu::SurfaceDescriptorFromCanvasHTMLSelector canvasDesc{};
  canvasDesc.selector = "#canvas";

  wgpu::SurfaceDescriptor surfaceDesc{.nextInChain = &canvasDesc};
  surface = instance.CreateSurface(&surfaceDesc);
#else
  surface = wgpu::glfw::CreateSurfaceForWindow(instance, window);
#endif

  InitGraphics();

#if defined(__EMSCRIPTEN__)
  emscripten_set_main_loop(Render, 0, false);
#else
  while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    Render();
    surface.Present();
    instance.ProcessEvents();
  }
#endif
}

Emscripten을 사용한 앱 빌드

Emscripten으로 앱을 빌드하는 데 필요한 유일한 변경사항은 cmake 명령어 앞에 마법 emcmake 셸 스크립트를 추가하는 것입니다. 이번에는 build-web 하위 폴더에 앱을 생성하고 HTTP 서버를 시작합니다. 마지막으로 브라우저를 열고 build-web/app.html로 이동합니다.

# Build the app with Emscripten.
$ emcmake cmake -B build-web && cmake --build build-web

# Start a HTTP server.
$ npx http-server
브라우저 창에 있는 빨간색 삼각형의 스크린샷
브라우저 창에 빨간색 삼각형이 표시됩니다.

다음 단계

향후 기대할 수 있는 사항은 다음과 같습니다.

  • webgpu.h 및 webgpu_cpp.h API의 안정화 개선
  • Android 및 iOS에 대한 Dawn 초기 지원

그동안 Emscripten의 WebGPU 문제Dawn 문제에 추천 및 질문을 제출해 주세요.

리소스

이 앱의 소스 코드를 자유롭게 살펴보세요.

WebGPU를 사용하여 C++에서 네이티브 3D 애플리케이션을 처음부터 만드는 방법을 자세히 알아보려면 C++용 WebGPU 알아보기 문서Dawn Native WebGPU 예시를 확인하세요.

Rust에 관심이 있다면 WebGPU를 기반으로 하는 wgpu 그래픽 라이브러리도 살펴볼 수 있습니다. hello-triangle 데모를 살펴보세요.

감사의 말

이 도움말은 코렌틴 월레즈, 카이 니노미야, 레이첼 앤드류가 검토했습니다.

사진: Unsplash마크-올리비에 조도인