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_cpp.h라는 C++ 래퍼를 통해 플랫폼에 구애받지 않는 하드웨어 추상화 계층으로 WebGPU의 webgpu.h를 사용합니다.

웹에서 이 앱은 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

앱은 실행되지만 화면에 항목을 그리는 방법이 필요하므로 아직 출력이 없습니다.

여명 시간

삼각형을 그리려면 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/ 하위 폴더가 타겟에 포함됩니다.
  • 앱은 webgpu_cpp, webgpu_dawn, webgpu_glfw 타겟을 사용하므로 나중에 main.cpp 파일에서 사용할 수 있습니다.
…
set(DAWN_FETCH_DEPENDENCIES ON)
add_subdirectory("dawn" EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE webgpu_cpp webgpu_dawn webgpu_glfw)

창 열기

새벽을 사용할 수 있게 되었으므로 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::Device와 함께 호출하는 도우미 GetDevice() 함수를 만듭니다.

#include <iostream>
…

void GetDevice(void (*callback)(wgpu::Device)) {
  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);
        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);
            },
            userdata);
      },
      reinterpret_cast<void*>(callback));
}

더 쉽게 액세스하려면 main.cpp 파일 상단에 wgpu::Device 변수를 선언하고 main() 함수를 업데이트하여 GetDevice()를 호출하고 결과 콜백을 device에 할당한 후 Start()를 호출합니다.

wgpu::Device device;
…

int main() {
  instance = wgpu::CreateInstance();
  GetDevice([](wgpu::Device dev) {
    device = dev;
    Start();
  });
}

삼각형 그리기

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

#include <webgpu/webgpu_glfw.h>
…

wgpu::SwapChain swapChain;

void SetupSwapChain(wgpu::Surface surface) {
  wgpu::SwapChainDescriptor scDesc{
      .usage = wgpu::TextureUsage::RenderAttachment,
      .format = wgpu::TextureFormat::BGRA8Unorm,
      .width = kWidth,
      .height = kHeight,
      .presentMode = wgpu::PresentMode::Fifo};
  swapChain = device.CreateSwapChain(surface, &scDesc);
}

void InitGraphics(wgpu::Surface surface) {
  SetupSwapChain(surface);
}

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

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

  InitGraphics(surface);

  while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    Render();
    swapChain.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 = wgpu::TextureFormat::BGRA8Unorm};

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

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

void InitGraphics(wgpu::Surface surface) {
  …
  CreateRenderPipeline();
}

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

void Render() {
  wgpu::RenderPassColorAttachment attachment{
      .view = swapChain.GetCurrentTextureView(),
      .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에 컴파일

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

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 webgpu_cpp webgpu_dawn webgpu_glfw)
endif()

코드 업데이트

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

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};
  wgpu::Surface surface = instance.CreateSurface(&surfaceDesc);
#else
  wgpu::Surface surface =
      wgpu::glfw::CreateSurfaceForWindow(instance, window);
#endif

  InitGraphics(surface);

#if defined(__EMSCRIPTEN__)
  emscripten_set_main_loop(Render, 0, false);
#else
  while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    Render();
    swapChain.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 데모를 살펴보세요.

감사의 말

Corentin Wallez, Kai Ninomiya, Rachel Andrew가 작성한 도움말입니다.

사진: Marc-Olivier Jodoin(Unsplash 제공)