Tạo ứng dụng bằng WebGPU

François Beaufort
François Beaufort

Đối với các nhà phát triển web, WebGPU là một API đồ hoạ web cho phép truy cập hợp nhất và nhanh chóng vào GPU. WebGPU thể hiện các tính năng phần cứng hiện đại, đồng thời cho phép thực hiện các hoạt động kết xuất và tính toán trên GPU, tương tự như Direct3D 12, Metal và Vulkan.

Mặc dù đúng nhưng câu chuyện đó chưa hoàn chỉnh. WebGPU là kết quả của nỗ lực cộng tác, bao gồm cả các công ty lớn như Apple, Google, Intel, Mozilla và Microsoft. Trong số đó, một số người nhận ra rằng WebGPU có thể không chỉ là API JavaScript, mà còn là API đồ hoạ đa nền tảng dành cho nhà phát triển trên các hệ sinh thái, chứ không phải trên web.

Để đáp ứng trường hợp sử dụng chính, chúng tôi đã ra mắt API JavaScript trong Chrome 113. Tuy nhiên, một dự án quan trọng khác đã được phát triển cùng với dự án này, đó là: API C webgpu.h. Tệp tiêu đề C này liệt kê tất cả các quy trình và cấu trúc dữ liệu có sẵn của WebGPU. Đây là lớp trừu tượng phần cứng không phụ thuộc vào nền tảng, cho phép bạn xây dựng các ứng dụng dành riêng cho nền tảng bằng cách cung cấp một giao diện nhất quán trên nhiều nền tảng.

Trong tài liệu này, bạn sẽ tìm hiểu cách viết một ứng dụng C++ nhỏ bằng WebGPU chạy trên cả web và các nền tảng cụ thể. Cảnh báo tiết lộ nội dung, bạn sẽ nhận được cùng một hình tam giác màu đỏ xuất hiện trong cửa sổ trình duyệt và cửa sổ máy tính để bàn với những điều chỉnh tối thiểu đối với cơ sở mã của bạn.

Ảnh chụp màn hình hình tam giác màu đỏ do WebGPU hỗ trợ trong một cửa sổ trình duyệt và một cửa sổ máy tính trên macOS.
Cùng một hình tam giác sử dụng WebGPU trong một cửa sổ trình duyệt và một cửa sổ máy tính.

Cách hoạt động của tính năng này

Để xem ứng dụng đã hoàn thành, hãy xem kho lưu trữ ứng dụng đa nền tảng WebGPU.

Ứng dụng là một ví dụ C++ tối giản minh hoạ cách dùng WebGPU để tạo ứng dụng cho máy tính và ứng dụng web từ một cơ sở mã duy nhất. Trong trường hợp này, nó sử dụng webgpu.h của WebGPU làm lớp trừu tượng phần cứng không phụ thuộc vào nền tảng thông qua trình bao bọc C++ có tên là webgpu_cpp.h.

Trên web, ứng dụng được xây dựng dựa trên Emscripten, có các liên kết triển khai webgpu.h ở đầu API JavaScript. Trên các nền tảng cụ thể như macOS hoặc Windows, bạn có thể xây dựng dự án này dựa trên Dawn, phương thức triển khai WebGPU nhiều nền tảng của Chromium. Cần nhắc đến wgpu-native (một phương thức triển khai Rust của webgpu.h) cũng tồn tại nhưng không được sử dụng trong tài liệu này.

Bắt đầu

Để bắt đầu, bạn cần có một trình biên dịch C++ và CMake để xử lý các bản dựng trên nhiều nền tảng theo cách chuẩn. Bên trong một thư mục chuyên dụng, hãy tạo một tệp nguồn main.cpp và một tệp bản dựng CMakeLists.txt.

Hiện tại, tệp main.cpp phải chứa một hàm main() trống.

int main() {}

Tệp CMakeLists.txt chứa thông tin cơ bản về dự án. Dòng cuối cùng chỉ định tên thực thi là "app" và mã nguồn của ứng dụng là 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")

Chạy cmake -B build để tạo các tệp bản dựng trong thư mục con "build/" và cmake --build build để thực sự tạo ứng dụng và tạo tệp thực thi.

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

# Run the app.
$ ./build/app

Ứng dụng chạy nhưng chưa có kết quả, vì bạn cần một cách để vẽ mọi thứ trên màn hình.

Bình minh

Để vẽ hình tam giác, bạn có thể tận dụng Dawn, cách triển khai WebGPU nhiều nền tảng của Chromium. Thao tác này bao gồm thư viện C++ GLFW để vẽ lên màn hình. Một cách để tải Dawn xuống là thêm nó dưới dạng một mô-đun con git vào kho lưu trữ của bạn. Các lệnh sau đây tìm nạp thư mục này trong thư mục con "dawn/".

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

Sau đó, hãy thêm vào tệp CMakeLists.txt như sau:

  • Tuỳ chọn DAWN_FETCH_DEPENDENCIES của CMake tìm nạp tất cả các phần phụ thuộc Dawn.
  • Thư mục con dawn/ có trong mục tiêu.
  • Ứng dụng của bạn sẽ phụ thuộc vào các mục tiêu webgpu_cpp, webgpu_dawnwebgpu_glfw để bạn có thể sử dụng các mục tiêu đó trong tệp main.cpp sau này.
…
set(DAWN_FETCH_DEPENDENCIES ON)
add_subdirectory("dawn" EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE webgpu_cpp webgpu_dawn webgpu_glfw)

Mở cửa sổ

Giờ đây, khi Dawn đã xuất hiện, hãy sử dụng GLFW để vẽ mọi thứ trên màn hình. Thư viện này có trong webgpu_glfw để thuận tiện, cho phép bạn viết mã không phụ thuộc vào nền tảng để quản lý cửa sổ.

Để mở cửa sổ có tên "Cửa sổ WebGPU" có độ phân giải 512x512, hãy cập nhật tệp main.cpp như bên dưới. Xin lưu ý rằng glfwWindowHint() được dùng ở đây để yêu cầu không yêu cầu khởi chạy API đồ hoạ cụ thể.

#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();
}

Việc tạo lại và chạy ứng dụng như trước đây sẽ dẫn đến một cửa sổ trống. Bạn đang có tiến bộ!

Ảnh chụp màn hình cửa sổ macOS trống.
Cửa sổ trống.

Tải thiết bị GPU

Trong JavaScript, navigator.gpu là điểm nhập để truy cập vào GPU. Trong C++, bạn cần tạo biến wgpu::Instance theo cách thủ công dùng cho cùng một mục đích. Để thuận tiện, hãy khai báo instance ở đầu tệp main.cpp và gọi wgpu::CreateInstance() bên trong main().

…
#include <webgpu/webgpu_cpp.h>

wgpu::Instance instance;
…

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

Việc truy cập GPU không đồng bộ do hình dạng của API JavaScript. Trong C++, hãy tạo một hàm trợ giúp GetDevice() để nhận đối số hàm callback và gọi đối số đó bằng wgpu::Device kết quả.

#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));
}

Để truy cập dễ dàng hơn, hãy khai báo biến wgpu::Device ở đầu tệp main.cpp rồi cập nhật hàm main() để gọi GetDevice() và chỉ định lệnh gọi lại kết quả cho device trước khi gọi Start().

wgpu::Device device;
…

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

Vẽ hình tam giác

Chuỗi hoán đổi không hiển thị trong API JavaScript vì trình duyệt sẽ xử lý chuỗi này. Trong C++, bạn cần tạo mã theo cách thủ công. Một lần nữa, để thuận tiện, hãy khai báo biến wgpu::SwapChain ở đầu tệp main.cpp. Ngay sau khi tạo cửa sổ GLFW trong Start(), hãy gọi hàm wgpu::glfw::CreateSurfaceForWindow() tiện dụng để tạo wgpu::Surface (tương tự như canvas HTML) và sử dụng hàm này để thiết lập chuỗi hoán đổi bằng cách gọi hàm SetupSwapChain() trợ giúp mới trong InitGraphics(). Bạn cũng cần gọi swapChain.Present() để trình bày hoạ tiết tiếp theo trong vòng lặp while. Việc này không tạo ra hiệu ứng rõ ràng vì chưa có quá trình kết xuất nào.

#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();
  }
}

Giờ là thời điểm thích hợp để tạo quy trình kết xuất bằng mã dưới đây. Để truy cập dễ dàng hơn, hãy khai báo biến wgpu::RenderPipeline ở đầu tệp main.cpp và gọi hàm trợ giúp CreateRenderPipeline() trong InitGraphics().

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();
}

Cuối cùng, hãy gửi các lệnh kết xuất đến GPU trong hàm Render() được gọi là từng khung.

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);
}

Tạo lại ứng dụng bằng CMake và chạy ngay bây giờ sẽ dẫn đến hình tam giác màu đỏ mà bạn mong đợi từ lâu trong một cửa sổ! Hãy nghỉ giải lao một chút nhé.

Ảnh chụp màn hình một hình tam giác màu đỏ trong cửa sổ macOS.
Một hình tam giác màu đỏ trong cửa sổ máy tính.

Biên dịch thành WebAssembly

Bây giờ, hãy xem những thay đổi tối thiểu cần thiết để điều chỉnh cơ sở mã hiện có của bạn nhằm vẽ hình tam giác màu đỏ này trong cửa sổ trình duyệt. Xin nhắc lại, ứng dụng được xây dựng dựa trên Emscripten, một công cụ để biên dịch các chương trình C/C++ thành WebAssembly. Công cụ này có các liên kết triển khai webgpu.h trên API JavaScript.

Cập nhật các chế độ cài đặt CMake

Sau khi cài đặt Emscripten, hãy cập nhật tệp bản dựng CMakeLists.txt như sau. Mã được đánh dấu là thứ duy nhất bạn cần thay đổi.

  • set_target_properties được sử dụng để tự động thêm phần mở rộng tệp "html" vào tệp đích. Nói cách khác, bạn sẽ tạo một tệp "app.html".
  • Bạn phải chọn tuỳ chọn đường liên kết trong ứng dụng USE_WEBGPU để bật tính năng hỗ trợ WebGPU trong Emscripten. Nếu không có, tệp main.cpp của bạn không thể truy cập vào tệp webgpu/webgpu_cpp.h.
  • Bạn cũng phải thêm tuỳ chọn đường liên kết đến ứng dụng USE_GLFW tại đây để có thể sử dụng lại mã 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()

Cập nhật mã

Trong Emscripten, việc tạo wgpu::surface yêu cầu phải có phần tử canvas HTML. Để thực hiện việc này, hãy gọi instance.CreateSurface() và chỉ định bộ chọn #canvas để khớp với phần tử canvas HTML thích hợp trong trang HTML do Emscripten tạo.

Thay vì sử dụng vòng lặp while, hãy gọi emscripten_set_main_loop(Render) để đảm bảo hàm Render() được gọi ở tốc độ mượt mà thích hợp, phù hợp với trình duyệt và giám sát.

#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
}

Tạo ứng dụng bằng Emscripten

Thay đổi duy nhất cần thiết để tạo ứng dụng bằng Emscripten là thêm các lệnh cmake bằng tập lệnh shell kỳ diệu emcmake. Lần này, hãy tạo ứng dụng trong thư mục con build-web và khởi động một máy chủ HTTP. Cuối cùng, hãy mở trình duyệt rồi truy cập 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
Ảnh chụp màn hình một hình tam giác màu đỏ trong cửa sổ trình duyệt.
Một hình tam giác màu đỏ trong cửa sổ trình duyệt.

Bước tiếp theo

Dưới đây là những gì bạn có thể dự kiến trong tương lai:

  • Cải tiến về độ ổn định của các API webgpu.h và webgpu_cpp.h.
  • Hỗ trợ ban đầu của Dawn cho Android và iOS.

Trong thời gian chờ đợi, vui lòng gửi các vấn đề về WebGPU đối với Emscriptencác vấn đề về Daawn cùng với nội dung đề xuất và câu hỏi.

Tài nguyên

Hãy thoải mái khám phá mã nguồn của ứng dụng này.

Nếu bạn muốn tìm hiểu thêm về cách tạo ứng dụng 3D gốc trong C++ từ đầu bằng WebGPU, hãy xem Tìm hiểu tài liệu về WebGPU cho C++Ví dụ về WebGPU gốc của Daawn.

Nếu quan tâm đến Rust, bạn cũng có thể khám phá thư viện đồ hoạ wgpu dựa trên WebGPU. Hãy xem bản minh hoạ hello-triangle của họ.

Thư cảm ơn

Bài viết này đã được Corentin Wallez, Kai NinomiyaRachel Andrew xem xét.

Ảnh chụp của Marc-Olivier Jodoin trên Unsplash.