בניית אפליקציה עם WebGPU

François Beaufort
François Beaufort

WebGPU הוא ממשק API של גרפיקה באינטרנט שמספק גישה מאוחדת ומהירה למעבדי GPU עבור מפתחי אינטרנט. WebGPU חושף את יכולות החומרה המודרניות ומאפשר פעולות של רינדור וחישוב ב-GPU, בדומה ל-Direct3D 12, Meta ו-Vulkan.

למרות שהסיפור הוא נכון, הסיפור חלקי. WebGPU הוא תוצאה של מאמץ משותף, כולל חברות גדולות כמו Apple, Google, Intel, Mozilla ו-Microsoft. חלק מהם הבינו ש-WebGPU יכול להיות יותר מ-JavaScript API, אלא ממשק API של גרפיקה בפלטפורמות שונות, שמיועד למפתחים בסביבות שונות, מלבד האינטרנט.

כדי למלא את התרחיש העיקרי לדוגמה, הושק JavaScript API ב-Chrome 113. עם זאת, פותח לצדו פרויקט משמעותי נוסף: ה-C API של webgpu.h. קובץ הכותרת C מפרט את כל הנהלים ומבני הנתונים הזמינים של WebGPU. היא משמשת כשכבה להפשטת חומרה שאינה קשורה לפלטפורמה, ומאפשרת ליצור אפליקציות ספציפיות לפלטפורמה באמצעות ממשק עקבי בפלטפורמות שונות.

במסמך הזה תלמדו איך לכתוב אפליקציית C++ קטנה באמצעות WebGPU שפועלת גם באינטרנט וגם בפלטפורמות ספציפיות. התראת ספוילר, תראו את אותו משולש אדום שמופיע בחלון הדפדפן ובחלון במחשב, עם שינויים קלים ב-codebase.

צילום מסך של משולש אדום שמופעל על ידי WebGPU בחלון דפדפן וחלון במחשב ב-macOS.
אותו משולש שמופעל על ידי WebGPU בחלון דפדפן ובחלון במחשב.

איך זה עובד?

כדי לראות את האפליקציה המלאה, ניתן לעיין במאגר של WebGPU app בפלטפורמות שונות.

האפליקציה היא דוגמה מינימליסטית ל-C++ שמראה איך להשתמש ב-WebGPU כדי לבנות אפליקציות למחשב ואינטרנט על בסיס קוד אחד. מאחורי הקלעים, נעשה שימוש ב-webgpu.h של WebGPU כשכבה להפשטת חומרה שאינה קשורה לפלטפורמה, דרך wrapper של C++ שנקרא webgpu_cpp.h.

באינטרנט, האפליקציה מבוססת על Emscripten, שכולל קישורים שמטמיעים את webgpu.h מעל ה-API של JavaScript. בפלטפורמות ספציפיות כמו macOS או Windows, אפשר לפתח את הפרויקט הזה מול Dawn, הטמעת WebGPU בפלטפורמות שונות של Chromium. כדאי לציין את wgpu-native, יישום Rust של webgpu.h, שגם הוא קיים, אבל לא נמצא בשימוש במסמך זה.

תחילת העבודה

כדי להתחיל, יש צורך במהדר C++ וב-CMake כדי לטפל בגרסאות build בפלטפורמות שונות בדרך רגילה. בתיקייה ייעודית, יוצרים קובץ מקור main.cpp וקובץ build של 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 בתיקיית המשנה "build/" ומריצים את הפקודה cmake --build build כדי ליצור בפועל את האפליקציה וליצור את קובץ ההפעלה.

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

# Run the app.
$ ./build/app

האפליקציה פועלת אבל עדיין אין פלט, כי נדרשת דרך לשרטט דברים על המסך.

שעת השחר

כדי לשרטט את המשולש, אפשר להשתמש ב-Dawn, ההטמעה של WebGPU בפלטפורמות שונות של Chromium. התוכן כולל את ספריית C++ של GLFW לשרטוט במסך. אחת מהדרכים להוריד את שחר היא להוסיף אותו כמודול משנה של Git למאגר. הפקודות הבאות מאחזרות אותו בתיקיית המשנה "dawn/".

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

לאחר מכן, מוסיפים לקובץ CMakeLists.txt באופן הבא:

  • האפשרות CMake DAWN_FETCH_DEPENDENCIES מאחזרת את כל יחסי התלות של שחר.
  • תיקיית המשנה 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 מטעמי נוחות, אפשר לכתוב קוד שלא תלוי בפלטפורמה לניהול החלונות.

כדי לפתוח חלון בשם WebGPU ברזולוציה של 512x512, צריך לעדכן את הקובץ 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 שמשמש לאותה מטרה. מטעמי נוחות, אפשר להצהיר על instance בחלק העליון של הקובץ main.cpp ולהתקשר אל wgpu::CreateInstance() בתוך main().

…
#include <webgpu/webgpu_cpp.h>

wgpu::Instance instance;
…

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

הגישה ל-GPU היא אסינכרונית בגלל הצורה של ממשק ה-API של JavaScript. ב-C++, יוצרים פונקציית GetDevice() מסייעת שמקבלת ארגומנט של פונקציית קריאה חוזרת וקוראת לה עם ה-wgpu::Device שמתקבל.

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

כדי לאפשר גישה קלה יותר, אפשר להצהיר על משתנה wgpu::Device בחלק העליון של הקובץ main.cpp ולעדכן את הפונקציה main() כך שתתבצע קריאה לפונקציה GetDevice() ותקצה את הקריאה החוזרת של התוצאה ל-device לפני הקריאה ל-Start().

wgpu::Device device;
…

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

ציור משולש

שרשרת ההחלפה לא נחשפת ב-JavaScript API כי הדפדפן מטפל בה. ב-C++ צריך ליצור אותה באופן ידני. שוב, לנוחותך, אפשר להצהיר על משתנה wgpu::SwapChain בחלק העליון של הקובץ main.cpp. מיד אחרי שיצרתם את חלון GLFW ב-Start(), צריך לקרוא לפונקציה wgpu::glfw::CreateSurfaceForWindow() השימושית כדי ליצור wgpu::Surface (בדומה לבד HTML) ולהשתמש בה כדי להגדיר את שרשרת ההחלפה על ידי קריאה לפונקציה המסייעת החדשה SetupSwapChain() ב-InitGraphics(). צריך גם לקרוא ל-swapChain.Present() כדי להציג את המרקם הבא בלולאת הזמן. אין לזה השפעה גלויה כי עדיין לא מתבצע רינדור.

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

זה הזמן ליצור את צינור עיבוד הנתונים לעיבוד באמצעות הקוד הבא. כדי לאפשר גישה קלה יותר, אפשר להצהיר על משתנה wgpu::RenderPipeline בחלק העליון של הקובץ main.cpp ולקרוא לפונקציית המסייע CreateRenderPipeline() ב-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();
}

לבסוף, שולחים פקודות עיבוד ל-GPU בפונקציה Render() שנקראת כל מסגרת.

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

עכשיו נבחן את השינויים המינימליים שנדרשים להתאמת ה-codebase הקיים כדי לשרטט את המשולש האדום הזה בחלון דפדפן. שוב, האפליקציה מבוססת על Emscripten, כלי להידור של תוכניות C/C++ ל-WebAssembly. הכלי הזה כולל קישורים שמטמיעים את webgpu.h מעל ה-API ל-JavaScript.

עדכון הגדרות CMake

אחרי התקנת Emscripten, יש לעדכן את קובץ ה-build של CMakeLists.txt באופן הבא. הקוד המודגש הוא הדבר היחיד שעליך לשנות.

  • נעשה שימוש ב-set_target_properties כדי להוסיף באופן אוטומטי את סיומת הקובץ "html" לקובץ היעד. במילים אחרות, תפיק את הקובץ "app.html".
  • האפשרות של הקישור לאפליקציה USE_WEBGPU נדרשת כדי להפעיל תמיכה ב-WebGPU ב-Emscripten. בלי גישה, הקובץ main.cpp לא יוכל לגשת לקובץ webgpu/webgpu_cpp.h.
  • האפשרות של קישור לאפליקציה USE_GLFW נדרשת גם כאן כדי שניתן יהיה להשתמש שוב בקוד ה-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 כך שיתאים לאלמנט ה-HTML המתאים בבד הציור בדף ה-HTML שנוצר על ידי Emscripten.

במקום להשתמש בלולאת זמן, צריך לקרוא לפונקציה 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
צילום מסך של משולש אדום בחלון דפדפן.
משולש אדום בחלון דפדפן.

המאמרים הבאים

הנה מה שצפוי בעתיד:

  • שיפורים בייצוב של ממשקי ה-API webgpu.h ו-webgpu_cpp.h.
  • תמיכה ראשונית ב-D0I ב-Android וב-iOS.

בינתיים, יש לדווח על בעיות ב-WebGPU עבור Emscripten ובעיות ב-Dawn עם הצעות ושאלות.

משאבים

אפשר לעיין בקוד המקור של האפליקציה הזו.

אם אתם רוצים להתעמק ביצירת אפליקציות תלת-ממדיות מקוריות ב-C++ עם WebGPU, תוכלו לקרוא את המאמרים לימודי WebGPU למסמכי C++ ודוגמאות ל-Dawn Native WebGPU.

אם אתם מתעניינים ב-Rust, תוכלו גם לעיין בספריית הגרפיקה של wgpu שמבוססת על WebGPU. כדאי לצפות בהדגמה של משולש שלום.

אימות חתימות

המאמר הזה נבדק על ידי קורנטין וולז, קאי נינומיה וריצ'ל אנדרו.

תמונה מאת Marc-Olivier Jodoin ב-UnFlood.