diff --git a/.clangd b/.clangd new file mode 100644 index 0000000..ef10389 --- /dev/null +++ b/.clangd @@ -0,0 +1,3 @@ +# clangd reads the compile commands of the "dev" preset (run `cmake --preset dev` once) +CompileFlags: + CompilationDatabase: build/dev diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b9ad0a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + push: + branches: [main, cleanroom] + pull_request: + +jobs: + # the same clang-format version as in CONTRIBUTING.md, other versions format slightly differently + format: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Check formatting + run: | + git ls-files 'include/*' 'src/*.cpp' 'src/*.h' 'src/platform/*' 'samples/*.cpp' 'tests/*.cpp' 'tests/*.h' \ + | xargs pipx run clang-format==23.1.1 --dry-run --Werror + + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { name: ubuntu-gcc, os: ubuntu-24.04, cc: gcc-14, cxx: g++-14 } + - { name: ubuntu-clang, os: ubuntu-24.04, cc: clang-18, cxx: clang++-18 } + - { name: windows-msvc, os: windows-latest } + + steps: + - uses: actions/checkout@v4 + + # GLFW is built from source on the runner and needs its build dependencies. + # The runners have no GPU: mesa-vulkan-drivers provides lavapipe, a software Vulkan driver. + - name: Install dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y ninja-build libwayland-dev libxkbcommon-dev xorg-dev mesa-vulkan-drivers + + - name: Select compiler (Linux) + if: runner.os == 'Linux' + run: | + echo "CC=${{ matrix.cc }}" >> "$GITHUB_ENV" + echo "CXX=${{ matrix.cxx }}" >> "$GITHUB_ENV" + + # puts cl.exe on the PATH so that the Ninja generator of the presets finds MSVC + - name: Set up MSVC (Windows) + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + + - name: Configure + run: cmake --preset release + + - name: Build + run: cmake --build --preset release + + # tests that open a window are skipped, everything else renders offscreen + - name: Test + run: ctest --preset release -LE display + + # the samples as web pages, built like the gallery on GitHub Pages + web: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: mymindstorm/setup-emsdk@v14 + with: + version: 6.0.10 + - name: Configure + run: emcmake cmake --preset web + - name: Build + run: cmake --build --preset web diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..dfb9bf2 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,43 @@ +# Builds the samples for the web and publishes them as a gallery on GitHub Pages. +# Needs Pages enabled for the repository with "GitHub Actions" as the source. +name: Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: mymindstorm/setup-emsdk@v14 + with: + version: 6.0.10 + - name: Configure + run: emcmake cmake --preset web + - name: Build + run: cmake --build --preset web + - uses: actions/upload-pages-artifact@v3 + with: + path: build/web/site + + deploy: + needs: build + runs-on: ubuntu-24.04 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index e7a396d..b2f4ac2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ build*/ -**/__pycache__/ +.cache/ +__pycache__/ +CMakeUserPresets.json diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json deleted file mode 100644 index 77c2de7..0000000 --- a/.vscode/c_cpp_properties.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "configurations": [ - { - "name": "Win32", - "includePath": [ - "${workspaceFolder}/**" - ], - "defines": [ - "_DEBUG", - "UNICODE", - "_UNICODE" - ], - "cppStandard": "c++20", - "intelliSenseMode": "windows-msvc-x64" - } - ], - "version": 4 -} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 32415b8..12adb4a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,6 +11,17 @@ "configureAll": false, "clean": false, }, + { + // launch cmake target based on exe source file active in editor + "name": "(linux) launch", + "program": "${command:cmake.launchTargetDirectory}/${fileBasenameNoExtension}", + "cwd": "${workspaceFolder}/samples", + "request": "launch", + "type": "cppdbg", + "MIMode": "gdb", + "stopAtEntry": false, + "args": [], + }, { // launch cmake target based on exe source file active in editor "name": "(win) launch", diff --git a/.vscode/settings.json b/.vscode/settings.json index 247fa9c..42d0a91 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "cmake.generator": "Ninja", + "cmake.useCMakePresets": "always", "cmake.configureOnOpen": false, "files.associations": { "lab": "cpp" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..02ecae1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,89 @@ +# Changelog + +## 0.5.0 + +### Added +- The samples run in the browser: `emcmake cmake --preset web` builds a page per sample + and a gallery, which a GitHub Pages workflow publishes. The lab uses Dawn's Emscripten + port of the same Dawn release as the native build, and the contrib.glfw3 port for + windows. `while (lab::tick())` stays as it is: on the web, `tick()` waits for the + browser's next animation frame. +- `LAB_WINDOW_SYSTEM=x11|wayland` on Linux. + +### Fixed +- The depth buffer of a surface lagged a frame behind after the window was resized. +- On Windows, the shader compiler DLLs that Dawn loads from the directory of the executable + are downloaded and copied next to every executable (`lab_copy_runtime_dlls()` does it for + executables of other projects). + +## 0.4.0 + +The API was redesigned. [docs/migrating-from-0.2.md](docs/migrating-from-0.2.md) explains +what changed and how to carry code over. + +### Added +- `lab::RenderPass` and `lab::Frame`: any number of draw calls per pass and passes per frame, + onto a window or into a texture. +- `lab::Draw`: the buffers, bind groups and counts of a draw call, reusable every frame. +- `lab::ComputePipeline` and `lab::ComputePass`. +- Depth buffers (`SurfaceOptions::depth`, `PassOptions::depth`) and samplers (`lab::sampler`). +- `Buffer::read()`, `Buffer::read_async()`, `Texture::read()`, `Texture::save_png()`. +- Mouse input, and polling for keyboard and mouse state next to the callbacks. +- `lab::Error`: mistakes are reported as exceptions that name the object, where they are made. +- `lab::log` with levels and a replaceable sink, `LAB_LOG`. +- `lab::find_file()`, `lab::delta_seconds()`. +- `LAB_CAPTURE_DIR`: every window saves one frame as a PNG. +- Samples `10_house` (3D model, depth, camera), `11_boids` (compute) and `12_offscreen`. +- Tests that render into textures and compare pixels. + +### Changed +- `lab::Webgpu` is now `lab::Gpu`, and it is the first argument wherever one is needed. +- `lab::Pipeline` is immutable and built for one render target. Vertex layouts are described + with `lab::vertex()` and `lab::instance()`, bind groups are made by `pipeline.bind_group()`. +- Every object gives access to the WebGPU object it wraps through `handle()`. +- Objects can be moved, and destroyed in any order. +- A surface follows the size of its window on its own and prefers an 8-bit format. +- Shaders are compiled when they are created and found from the working directory or from next + to the executable. +- The samples are a numbered sequence, one concept each. +- Public headers live in `include/`, flat: ``, or single ones such as ``. + +### Removed +- `Pipeline::finalize()`, `add_vertex_buffer()`, `add_uniform_buffer()` and the other `add_*` functions, + `Pipeline::config`, `render_config` and `render_func`. +- `MappedVRAM` and the thread based `to_device()` / `from_device()`. +- `lab::init_lab()`, `lab::state`, `Webgpu::capabilities`. + +## 0.3.0 + +wgpu-lab builds again, and for the first time on Linux. + +### Added +- Linux support, on Wayland and X11. +- CMake presets, tests (`ctest`), CI on Linux and Windows. +- `LAB_EXIT_AFTER_FRAMES` to run a program unattended. + +### Changed +- Dawn is pinned to a release from October 2026 and downloaded as a prebuilt library by default + (`-DLAB_DAWN=prebuilt|source|system`). The Python scripts are gone, as is the long first build. +- C++23 and CMake 3.28 are required. +- `` no longer injects `using namespace std::chrono_literals`. +- A missing shader file is an error. +- Without a GPU, a software adapter is used. + +### Fixed +- Crash at exit on Wayland: the display connection was closed while the GPU still used it. +- `Pipeline::add_uniform_buffer()` ignored its binding index. +- Wrong arguments for indexed drawing. +- Vertex format sizes were looked up with outdated enum values. +- The surface was sized in window coordinates instead of pixels (wrong on scaled displays). +- `Buffer` kept a dangling label pointer and accepted element types that are not plain data. +- `sample_uniform_buffer` never exited, `test_windows` destroyed objects twice. + +## 0.2.0 + +Dependencies are fetched automatically when CMake configures the project. + +## 0.1.0-alpha + +First public release. diff --git a/CMakeLists.txt b/CMakeLists.txt index 9613a40..e6c709f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,114 +1,155 @@ -cmake_minimum_required(VERSION 3.13...3.24 FATAL_ERROR) +cmake_minimum_required(VERSION 3.28) project( wgpu-lab - VERSION 0.2.0 + VERSION 0.5.0 LANGUAGES CXX C) -# check if python is installed and call script to fetch lab's dependencies ------- -find_package(Python3 REQUIRED) -message(STATUS "wgpu-lab: using python at ${Python3_EXECUTABLE}") -message(STATUS "Running lab_fetch_dependencies.py:") -execute_process(COMMAND - ${Python3_EXECUTABLE} - "${PROJECT_SOURCE_DIR}/scripts/lab_fetch_dependencies.py" - --root ${PROJECT_SOURCE_DIR}) - -# dawn config ------------------------------------------------------------------ -set(DAWN_FETCH_DEPENDENCIES ON) -set(DAWN_BUILD_SAMPLES OFF) - -set(TINT_BUILD_SPV_READER OFF) -set(TINT_BUILD_CMD_TOOLS OFF) -set(TINT_BUILD_TESTS OFF) -set(TINT_BUILD_IR_BINARY OFF) - -add_subdirectory(libs/dawn) - -# glm config ------------------------------------------------------------------- -set(GLM_ENABLE_CXX_20 ON CACHE INTERNAL "" FORCE) - -add_subdirectory(libs/glm) - -# tinygltf config -------------------------------------------------------------- -set(TINYGLTF_HEADER_ONLY ON CACHE INTERNAL "" FORCE) -set(TINYGLTF_INSTALL OFF CACHE INTERNAL "" FORCE) - -add_subdirectory(libs/tinygltf) +# options ---------------------------------------------------------------------- +# samples and tests are only built when wgpu-lab is the top-level project, so that pulling +# the lab into another project (add_subdirectory / FetchContent) yields just the library +option(LAB_BUILD_SAMPLES "Build the sample executables" ${PROJECT_IS_TOP_LEVEL}) +if(EMSCRIPTEN) + # the tests need a native GPU and a file system, the web gets the samples as a gallery + set(LAB_BUILD_TESTS OFF) +else() + option(LAB_BUILD_TESTS "Build the tests (run them with ctest)" ${PROJECT_IS_TOP_LEVEL}) +endif() + +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +include(FetchContent) + +# the lab uses headers, not C++ modules: skip the per-file module dependency scan +set(CMAKE_CXX_SCAN_FOR_MODULES OFF) + +if(PROJECT_IS_TOP_LEVEL) + set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + + find_program(CCACHE_PROGRAM ccache) + if(CCACHE_PROGRAM) + set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + message(STATUS "wgpu-lab: using ccache at ${CCACHE_PROGRAM}") + endif() +endif() + +if(EMSCRIPTEN) + # the web (configure with `emcmake cmake --preset web`) ------------------------ + # WebGPU is the browser's own. webgpu_cpp.h comes from Dawn's Emscripten port of + # the very same Dawn release the native build is pinned to; GLFW from the + # contrib.glfw3 port. Both are Emscripten "ports": --use-port fetches and builds them. + add_library(dawn::webgpu_dawn INTERFACE IMPORTED) + target_compile_options(dawn::webgpu_dawn INTERFACE "--use-port=${CMAKE_CURRENT_SOURCE_DIR}/cmake/emdawnwebgpu.remoteport.py") + target_link_options(dawn::webgpu_dawn INTERFACE "--use-port=${CMAKE_CURRENT_SOURCE_DIR}/cmake/emdawnwebgpu.remoteport.py") + add_library(glfw INTERFACE IMPORTED) + target_compile_options(glfw INTERFACE --use-port=contrib.glfw3) + target_link_options(glfw INTERFACE --use-port=contrib.glfw3) +else() + # dawn ------------------------------------------------------------------------- + include(LabDawn) + if(WIN32) + include(LabWindowsRuntime) # the DLLs Dawn loads at run time + endif() -# wgpu-lab library ------------------------------------------------------------- -add_library(wgpu-lab - src/detail/lab_pipeline.cpp - src/detail/lab_shader.cpp - src/detail/lab_surface.cpp - src/detail/lab_webgpu.cpp - src/detail/lab_window.cpp - src/detail/lab.cpp - - src/extra/lab_enums.h - src/extra/lab_mapped_vram.h - src/extra/lab_objects.h - src/extra/lab_public.h - src/extra/lab_state.h - - src/objects/lab_buffer.h - src/objects/lab_pipeline.h - src/objects/lab_shader.h - src/objects/lab_surface.h - src/objects/lab_webgpu.h - src/objects/lab_window.h - - src/lab) - -target_include_directories(wgpu-lab PUBLIC src) -target_link_libraries(wgpu-lab PUBLIC - webgpu_cpp - dawn_internal_config - dawn::dawn_common - dawn::dawn_proc - dawn::dawn_glfw - dawn::dawn_native - glfw) - -# sample executables ----------------------------------------------------------- -file(GLOB SAMPLE_FILES samples/*.cpp) -set(SAMPLE_TARGETS) -foreach(SAMPLE_FILE IN LISTS SAMPLE_FILES) - get_filename_component(SAMPLE_FILENAME ${SAMPLE_FILE} NAME_WE) - list(APPEND SAMPLE_TARGETS ${SAMPLE_FILENAME}) - add_executable(${SAMPLE_FILENAME} - samples/${SAMPLE_FILENAME}.cpp) - target_include_directories(${SAMPLE_FILENAME} PRIVATE libs/tinygltf src samples) - target_link_libraries(${SAMPLE_FILENAME} PRIVATE wgpu-lab glm::glm) -endforeach(SAMPLE_FILE) - -# copy shader files to binary dir ---------------------------------------------- -file(GLOB SHADER_FILES samples/shaders/*.wgsl) -foreach(SHADER_FILE IN LISTS SHADER_FILES) - get_filename_component(SHADER_FILENAME ${SHADER_FILE} NAME) - configure_file(${SHADER_FILE} - ${CMAKE_CURRENT_BINARY_DIR}/shaders/${SHADER_FILENAME} COPYONLY) -endforeach(SHADER_FILE) - -# copy asset files to binary dir ----------------------------------------------- -file(GLOB ASSET_FILES samples/assets/**) -foreach(ASSET_FILE IN LISTS ASSET_FILES) - get_filename_component(ASSET_FILENAME ${ASSET_FILE} NAME) - configure_file(${ASSET_FILE} - ${CMAKE_CURRENT_BINARY_DIR}/assets/${ASSET_FILENAME} COPYONLY) -endforeach(ASSET_FILE) - -# set properties and compile options for all targets --------------------------- -set(ALL_TARGETS - wgpu-lab ${SAMPLE_TARGETS}) -foreach(LAB_TARGET IN LISTS ALL_TARGETS) + # glfw: an installed 3.4+ is used if there is one, otherwise it is fetched ------ + set(GLFW_BUILD_DOCS OFF) + set(GLFW_BUILD_TESTS OFF) + set(GLFW_BUILD_EXAMPLES OFF) + set(GLFW_INSTALL OFF) + FetchContent_Declare(glfw + GIT_REPOSITORY https://github.com/glfw/glfw + GIT_TAG 3.4 + GIT_SHALLOW ON + EXCLUDE_FROM_ALL + SYSTEM + FIND_PACKAGE_ARGS 3.4 NAMES glfw3) + FetchContent_MakeAvailable(glfw) +endif() + +# compile options shared by the library and its samples ------------------------- +function(lab_target_defaults LAB_TARGET) set_target_properties(${LAB_TARGET} PROPERTIES - CXX_STANDARD 20 - CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF COMPILE_WARNING_AS_ERROR OFF) - if (MSVC) + if(MSVC) target_compile_options(${LAB_TARGET} PRIVATE /W3) else() target_compile_options(${LAB_TARGET} PRIVATE -Wall -Wextra -pedantic) endif() -endforeach(LAB_TARGET) + if(EMSCRIPTEN) + # exceptions are off by default on the web, the lab reports mistakes with them + target_compile_options(${LAB_TARGET} PRIVATE -fexceptions) + target_link_options(${LAB_TARGET} PRIVATE -fexceptions) + endif() + get_target_property(LAB_TARGET_TYPE ${LAB_TARGET} TYPE) + if(WIN32 AND LAB_TARGET_TYPE STREQUAL "EXECUTABLE") + lab_copy_runtime_dlls(${LAB_TARGET}) + endif() +endfunction() + +# wgpu-lab library ------------------------------------------------------------- +add_library(wgpu-lab + src/lab_buffer.cpp + src/lab_compute.cpp + src/lab_frame.cpp + src/lab_gpu.cpp + src/lab_image.cpp + src/lab_pipeline.cpp + src/lab_runtime.cpp + src/lab_shader.cpp + src/lab_surface.cpp + src/lab_texture.cpp + src/lab_window.cpp + src/lab_detail.h + + src/platform/lab_platform.h + + include/lab + include/lab_buffer.h + include/lab_compute.h + include/lab_error.h + include/lab_frame.h + include/lab_gpu.h + include/lab_input.h + include/lab_label.h + include/lab_log.h + include/lab_pipeline.h + include/lab_shader.h + include/lab_surface.h + include/lab_texture.h + include/lab_window.h) +add_library(lab::lab ALIAS wgpu-lab) + +# the platform specific part: desktop window systems, or the browser +if(EMSCRIPTEN) + target_sources(wgpu-lab PRIVATE src/platform/lab_surface_web.cpp) +else() + target_sources(wgpu-lab PRIVATE src/platform/lab_paths.cpp src/platform/lab_surface_glfw.cpp) +endif() + +# include/ is the public interface, src/ is private +target_include_directories(wgpu-lab PUBLIC include PRIVATE src) +target_compile_features(wgpu-lab PUBLIC cxx_std_23) +target_link_libraries(wgpu-lab PUBLIC dawn::webgpu_dawn glfw) +lab_target_defaults(wgpu-lab) + +# which window systems the surface glue is compiled for (Linux only) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + option(LAB_USE_WAYLAND "Support Wayland windows (needs a GLFW built with Wayland)" ON) + option(LAB_USE_X11 "Support X11 windows (needs a GLFW built with X11)" ON) + if(LAB_USE_WAYLAND) + target_compile_definitions(wgpu-lab PRIVATE LAB_USE_WAYLAND) + endif() + if(LAB_USE_X11) + target_compile_definitions(wgpu-lab PRIVATE LAB_USE_X11) + endif() +endif() + +# tests and sample executables -------------------------------------------------- +if(LAB_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + +if(LAB_BUILD_SAMPLES) + add_subdirectory(samples) +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..cdd20f9 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,60 @@ +{ + "version": 6, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}" + }, + { + "name": "dev", + "displayName": "Debug", + "inherits": "base", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } + }, + { + "name": "release", + "displayName": "Release", + "inherits": "base", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } + }, + { + "name": "web", + "displayName": "Web (configure with: emcmake cmake --preset web)", + "inherits": "base", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } + }, + { + "name": "dev-asan", + "displayName": "Debug + AddressSanitizer + UBSan (GCC/Clang)", + "inherits": "dev", + "cacheVariables": { + "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer", + "CMAKE_EXE_LINKER_FLAGS": "-fsanitize=address,undefined" + } + } + ], + "buildPresets": [ + { "name": "dev", "configurePreset": "dev" }, + { "name": "release", "configurePreset": "release" }, + { "name": "web", "configurePreset": "web" }, + { "name": "dev-asan", "configurePreset": "dev-asan" } + ], + "testPresets": [ + { + "name": "base", + "hidden": true, + "output": { "outputOnFailure": true } + }, + { "name": "dev", "inherits": "base", "configurePreset": "dev" }, + { "name": "release", "inherits": "base", "configurePreset": "release" }, + { + "name": "dev-asan", + "displayName": "Leak detection is off: GPU drivers leak a little when they are unloaded", + "inherits": "base", + "configurePreset": "dev-asan", + "environment": { "ASAN_OPTIONS": "detect_leaks=0" } + } + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dc9951f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,70 @@ +# Contributing to wgpu-lab + +Contributions are welcome: bug reports, samples, platform support, documentation. + +## Building and testing + +```sh +cmake --preset dev +cmake --build --preset dev +ctest --preset dev # everything; samples and window tests open windows briefly +ctest --preset dev -LE display # only the tests that need no window +``` + +`dev-asan` is the same build with AddressSanitizer and UBSan, worth a run after changes to +object lifetimes: + +```sh +cmake --preset dev-asan && cmake --build --preset dev-asan && ctest --preset dev-asan +``` + +The web build needs [Emscripten](https://emscripten.org/) on the PATH: + +```sh +emcmake cmake --preset web && cmake --build --preset web +cd build/web/site && python -m http.server 8000 # http://localhost:8000/ +``` + +To see what the samples render without watching them, let them save a frame each: + +```sh +cd build/dev +LAB_CAPTURE_DIR=../captures LAB_EXIT_AFTER_FRAMES=60 ./07_graph +``` + +## Where things are + +| Path | What | +|---|---| +| `include/` | the public API, `include/lab` includes all of it | +| `src/` | the implementation, `src/lab_detail.h` holds the state behind the public objects | +| `src/platform/` | everything that differs between window systems | +| `samples/` | one program per `.cpp` file, shaders in `samples/shaders/`, named after their sample | +| `tests/` | doctest tests, they render into textures and compare pixels | +| `cmake/LabDawn.cmake` | the pinned Dawn version and where it is downloaded from | + +## Conventions + +- Format code with clang-format 23.1.1 (`pipx run clang-format==23.1.1 -i `), CI checks it. + Other versions format a few constructs differently. +- A lab class exists where the lab adds state. Where a `wgpu::` handle is all there is + (samplers, bind groups, texture views), a function that returns the handle is enough. +- Lab objects do not hold references to each other. They share state through `shared_ptr` + (see `src/lab_detail.h`), which keeps them movable and lets programs destroy them in any order. +- Every object exposes the WebGPU object it wraps through `handle()`. +- Mistakes the lab can detect are reported with `detail::fail(label, message)`, which throws + `lab::Error`. Say what is wrong, with the numbers, and what to look at. +- A new feature comes with a test, and with a sample if it is something to learn from. +- One logical change per commit, with a message that says why. + +## Supporting another platform + +Creating a surface for a window is the only platform specific code, see +`src/platform/lab_surface_glfw.cpp`. macOS needs a `CAMetalLayer` for the window there +(`wgpu::SurfaceSourceMetalLayer`), and is the most wanted contribution. + +## Updating Dawn + +`cmake/LabDawn.cmake` names one Dawn release: its tag, its commit and the hashes of the archives. +Pick a newer release from , update those values, build +and run the tests. `webgpu_cpp.h` still changes between releases, so expect a few renames. diff --git a/README.md b/README.md index a2b9f0c..38b55cf 100644 --- a/README.md +++ b/README.md @@ -13,100 +13,218 @@ and the API is likely to undergo significant changes. **Contributions are welcome!** +

+ a lit house model on textured ground + flocks simulated in a compute shader + a graph of glowing nodes and edges + a triangle rendered with few pixels and magnified +

-## Simple Usage Sample +All of these are [samples](#samples) of a hundred lines or so, and they run in the browser +as well: **[live samples](https://bv7dev.github.io/wgpu-lab/)**. + + +## Simple Usage Sample ```c++ #include -struct MyVertexFormat { +#include + +using enum wgpu::VertexFormat; + +struct MyVertex { float pos[2]; float color[3]; }; int main() { - lab::Webgpu webgpu("My WebGPU Context"); - lab::Shader shader("My Shader", "shaders/draw_colored.wgsl"); - - lab::Pipeline pipeline(shader, webgpu); // the rendering pipeline + lab::Gpu gpu; + lab::Window window("Hello Triangle", 640, 400); + lab::Surface surface(gpu, window); // colored triangle data - std::vector vertex_data = { + std::vector vertex_data = { // X Y R G B {.pos = {-0.5f, -0.5f}, .color = {0.8f, 0.2f, 0.2f}}, {.pos = {+0.5f, -0.5f}, .color = {0.8f, 0.8f, 0.2f}}, {.pos = {+0.0f, +0.5f}, .color = {0.2f, 0.8f, 0.4f}}, }; - // vertex buffer (sends copy of data to GPU memory) - lab::Buffer vertex_buffer("My Vertex Buffer", vertex_data, webgpu); + // vertex buffer (sends a copy of the data to GPU memory) + lab::Buffer vertices(gpu, vertex_data); - // pipeline needs to know about buffers and their memory layouts (vertex attributes) - pipeline.add_vertex_buffer(vertex_buffer); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); // position - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x3, 1); // color - pipeline.finalize(); // make ready for rendering + lab::Shader shader(gpu, "shaders/03_vertex_buffer.wgsl"); - lab::Window window("Hello Triangle", 640, 400); - - lab::Surface surface(window, webgpu); // surface to render onto + // the pipeline needs to know how a vertex is laid out in memory: + // two floats for @location(0) position, three floats for @location(1) color + lab::Pipeline pipeline(gpu, shader, + {.vertex_buffers = {lab::vertex({Float32x2, Float32x3})}, .target = surface}); // main application loop while (lab::tick()) { - pipeline.render_frame(surface, 3, 1); // 3 vertices, 1 instance + pipeline.render_frame(surface, {.vertex_buffers = {vertices}}); // draws all 3 vertices of the buffer } } ``` +`render_frame()` is the short form for a frame with a single draw call. +With a `lab::RenderPass`, any number of pipelines draw into the same frame: -## Getting Started +```c++ + while (lab::tick()) { + lab::RenderPass pass(surface); // a pass onto the window, it is one frame + pass.draw(edge_pipeline, draw_edges); // a lab::Draw names the buffers and bind groups to use + pass.draw(node_pipeline, draw_nodes); + } // the pass ends here: the frame is submitted and shown +``` -### Windows +The lab never stands between you and WebGPU: `wgpu::` types are used as they are, +and every lab object hands out the WebGPU object it wraps through `handle()`. -**Setup:** -1. Install [Visual Studio](https://visualstudio.microsoft.com/vs/community/) for MSVC compiler and CMake, or a C++ compiler and build tools of your choice (configure it yourself and feel free to share your setup by creating an issue or a pull request) -1. Install [VS Code](https://code.visualstudio.com/) (optional - this project is configured to work well within VS Code) - open VS Code and install recommended extensions (a pop-up should appear) - look into the extensions tab to see if `C/C++`, `CMake` and `CMake Tools` are installed -1. Install [Python](https://www.python.org/downloads/) which is required to download dependencies in wgpu-lab and dawn +Mistakes are reported where they are made, as a `lab::Error` that names the object: -**Build:** -1. Clone this repository: - ```sh - git clone https://github.com/bv7dev/wgpu-lab.git - ``` - or alternatively, download a release build -1. Open the cloned directory or the unzipped release in VS Code (File -> Open Folder...) -1. Hit the `⚙ Build` button in Code's bottom toolbar (provided by `CMake Tools` extension) or use `CMake` manually to configure and build +``` +pipeline(03_vertex_buffer.wgsl): draw: vertex buffer 0 has elements of 4 bytes, but the pipeline +declares 20 bytes for it: is it the buffer the layout was written for? +``` -The first build takes a long time for downloading, generating and building dawn. -### Linux (coming soon) +## Samples -### Mac (help wanted) +The samples in `samples/` build on each other, one concept at a time: + +| Sample | Shows | +|---|---| +| `01_window` | a window and keyboard input, no GPU involved yet | +| `02_triangle` | the smallest program that renders something, pipeline settings | +| `03_vertex_buffer` | vertices from a buffer (the example above) | +| `04_uniforms` | a uniform buffer and a bind group, animation | +| `05_texture` | a texture filled with pixels, read through a sampler | +| `06_instancing` | one mesh drawn many times, live updates, keyboard control | +| `07_graph` | several pipelines in one render pass, indexed drawing | +| `08_readback` | getting data back from the GPU, without a window | +| `09_multi_window` | one GPU rendering into several windows | +| `10_house` | a 3D scene: depth buffer, camera, lighting, a glTF model and an image file | +| `11_boids` | a compute shader simulates a flock that is drawn in the same frame | +| `12_offscreen` | rendering into a texture and using it in a second pass | + + +## Getting Started + +You need a C++23 compiler, [CMake](https://cmake.org/) 3.28 or newer, +[Ninja](https://ninja-build.org/) and git. + +```sh +git clone https://github.com/bv7dev/wgpu-lab.git +cd wgpu-lab +cmake --preset dev +cmake --build --preset dev +``` + +The first `cmake --preset dev` downloads a prebuilt Dawn (about 40 MB on Linux), +so there is no long Dawn build to wait for. **Run sample executables:** +The samples end up at the top of the build directory, next to the shaders they load: + +```sh +cd build/dev +./03_vertex_buffer +``` + For VS Code users, there's a shared `.vscode/launch.json` configuration file. This setup allows you to build and run any `.cpp` source file that's located in the `samples/` directory, simply by opening it in the editor and pressing `F5`. This runs the code in debug mode (set breakpoints and step through the code to learn how it works). To get started, you can add your own `.cpp` file, tinker around and step through the code. Use CMake Tools to reconfigure the project after adding new files. +**Run the tests:** + +```sh +ctest --preset dev # everything; each sample opens its window for a second +ctest --preset dev -LE display # only the tests that need no window (they render into textures) +``` + +A few environment variables help with running and inspecting programs: + +| Variable | Effect | +|---|---| +| `LAB_EXIT_AFTER_FRAMES=120` | all windows close after 120 frames | +| `LAB_CAPTURE_DIR=shots` | every window saves one frame as `shots/.png` (frame 30, or `LAB_CAPTURE_FRAME`) | +| `LAB_LOG=debug` | more output (`debug`, `info`, `warn`, `error` or `off`) | +| `LAB_WINDOW_SYSTEM=x11` | on Linux: use X11 (through XWayland) or `wayland`, instead of what GLFW picks | + +### Linux + +Wayland and X11 are both supported. If GLFW 3.4 or newer is installed +(`glfw` on Arch, `libglfw3-dev` on recent Debian/Ubuntu) it is used, otherwise GLFW is built +from source and needs its build dependencies, for example on Debian/Ubuntu: + +```sh +sudo apt install libwayland-dev libxkbcommon-dev xorg-dev +``` + +### Windows + +Install [Visual Studio](https://visualstudio.microsoft.com/vs/community/) with the +"Desktop development with C++" workload (it includes MSVC, CMake and Ninja) and run the +commands above from a "Developer PowerShell for VS". +Alternatively, open the folder in [VS Code](https://code.visualstudio.com/) with the +recommended extensions and pick the `dev` preset. + +Dawn's Direct3D backend loads the DirectX Shader Compiler (`dxcompiler.dll`, `dxil.dll`) +from the directory of the executable. The build downloads it and copies the DLLs next to +the samples and tests. A project that uses the lab calls `lab_copy_runtime_dlls(my_app)` +for its own executables. + +### Web + +The same programs run in a browser, through its WebGPU. With +[Emscripten](https://emscripten.org/) installed (`emcc` on the PATH): + +```sh +emcmake cmake --preset web +cmake --build --preset web +cd build/web/site && python -m http.server 8000 # then open http://localhost:8000/ +``` + +`build/web/site/` holds one page per sample and an index page, which is what the +GitHub Pages workflow publishes. The `while (lab::tick())` loop stays as it is: on the +web, `tick()` hands control back to the browser until the next frame. + +### Mac (help wanted) + +### Choosing where Dawn comes from + +| `-DLAB_DAWN=` | What happens | +|---|---| +| `prebuilt` (default) | downloads the release archive that Dawn's CI publishes for the pinned version | +| `source` | fetches and builds Dawn itself (takes a while, needs Python for Dawn's build scripts) | +| `system` | uses a Dawn you installed yourself, found through `find_package(Dawn)` | + +The pinned Dawn version is set at the top of `cmake/LabDawn.cmake`. The web build uses +Dawn's Emscripten port of the same release (`cmake/emdawnwebgpu.remoteport.py`). + ### Dependencies -The library currently only depends on [WebGPU Dawn](https://dawn.googlesource.com/dawn) and uses -[GLFW](https://www.glfw.org/) for windowing, which already comes included with dawn. -wgpu-lab also makes heavy use of the C++ STL (see `src/extra/lab_public.h`). -However, to build all of the sample executables, the libraries -[GLM](https://github.com/g-truc/glm) and [tinygltf](https://github.com/syoyo/tinygltf) -are also included. +The library only depends on [WebGPU Dawn](https://dawn.googlesource.com/dawn) and +[GLFW](https://www.glfw.org/) for windowing. +Some of the samples use [GLM](https://github.com/g-truc/glm) for vector math and +[tinygltf](https://github.com/syoyo/tinygltf) to read a model, and the tests use +[doctest](https://github.com/doctest/doctest). These are downloaded when samples and tests +are built, which is only the case when wgpu-lab is the top-level project. ## Roadmap - [x] address build system issues -- [ ] re-design render pipeline (too chaotic at the moment) -- [ ] replace render_frame() function by smaller, composable mechanisms -- [ ] add compute pipeline support -- [ ] add emscripten support for WebAssembly -- [ ] unify and finalize lab API -- [ ] write documentation and tests +- [x] re-design render pipeline +- [x] replace render_frame() function by smaller, composable mechanisms +- [x] write tests +- [x] add depth buffers, samplers and a 3D sample +- [x] add compute pipeline support +- [x] add emscripten support for WebAssembly +- [ ] unify and finalize lab API +- [ ] write documentation - [ ] release stable 1.0 version + +Coming from version 0.2? See [docs/migrating-from-0.2.md](docs/migrating-from-0.2.md). diff --git a/cmake/LabDawn.cmake b/cmake/LabDawn.cmake new file mode 100644 index 0000000..56f7d04 --- /dev/null +++ b/cmake/LabDawn.cmake @@ -0,0 +1,110 @@ +# Provides the imported target dawn::webgpu_dawn. +# +# wgpu-lab is pinned to one Dawn release. To move to a newer one, pick a tag from +# https://github.com/google/dawn/releases, then update LAB_DAWN_TAG, LAB_DAWN_SHA +# (the commit in the archive names) and the archive hashes below (the "sha256" +# digests shown next to each release asset). + +set(LAB_DAWN_TAG "v20261002.154047") +set(LAB_DAWN_SHA "b1236a9bb4a47f1262bc3acb253bb1c2181e90dd") + +set(LAB_DAWN "prebuilt" CACHE STRING + "Where Dawn comes from: prebuilt (release archive), source (build it), system (find_package)") +set_property(CACHE LAB_DAWN PROPERTY STRINGS prebuilt source system) + +include(FetchContent) +find_package(Threads REQUIRED) + +# prebuilt: release archives published by Dawn's CI ------------------------------ +# Returns the archive name suffix and hash for the host, or leaves them empty if +# Dawn publishes no archive for it. +function(lab_dawn_prebuilt_archive out_name out_hash) + set(name "") + set(hash "") + if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$") + # no C runtime flavours on Linux: the small Release archive serves every build type + set(name "ubuntu-latest-Release") + set(hash "7c855f601755455c6f2b653e7da08fc6957988de580baafef207d2de045a5c39") + elseif(WIN32 AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$") + # MSVC debug and release runtimes do not mix, so the archive follows the build type + if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(name "windows-latest-Debug") + set(hash "baaf4360d183006ca766d1343ee4c7961538dd4f6704ba2da8fb0f0ed11fd1ef") + else() + set(name "windows-latest-Release") + set(hash "33d2bc3fb381d5dd3c765e0fa15947901687876c8b8ae9a53c0c0bebeb4a7802") + get_property(multi_config GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) + if(multi_config) + message(WARNING "wgpu-lab: prebuilt Dawn is the Release build. Debug configurations " + "of a multi-config generator will not link against it; use a single-config " + "generator (Ninja) or -DLAB_DAWN=source.") + endif() + endif() + elseif(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64") + set(name "macos-latest-Release") + set(hash "fa453ded7d1f19e1d01885976f222cc00b5726053a1b026c24ff9d034ae1c0e1") + elseif(APPLE) + set(name "macos-15-intel-Release") + set(hash "a13ec91e362127770324f7ec3ae07126e9e8eb13c70e4f4c18057910445b6e75") + endif() + set(${out_name} "${name}" PARENT_SCOPE) + set(${out_hash} "${hash}" PARENT_SCOPE) +endfunction() + +if(LAB_DAWN STREQUAL "prebuilt") + lab_dawn_prebuilt_archive(lab_dawn_archive lab_dawn_hash) + if(NOT lab_dawn_archive) + message(STATUS "wgpu-lab: no prebuilt Dawn for ${CMAKE_SYSTEM_NAME}/${CMAKE_SYSTEM_PROCESSOR}, building from source") + set(LAB_DAWN "source") + endif() +endif() + +if(LAB_DAWN STREQUAL "prebuilt") + message(STATUS "wgpu-lab: using prebuilt Dawn ${LAB_DAWN_TAG} (${lab_dawn_archive})") + FetchContent_Declare(dawn_prebuilt + URL "https://github.com/google/dawn/releases/download/${LAB_DAWN_TAG}/Dawn-${LAB_DAWN_SHA}-${lab_dawn_archive}.tar.gz" + URL_HASH "SHA256=${lab_dawn_hash}" + INACTIVITY_TIMEOUT 60 # fail instead of hanging forever if the download stalls + DOWNLOAD_EXTRACT_TIMESTAMP ON) + FetchContent_MakeAvailable(dawn_prebuilt) + # the package config sits in lib/ or lib64/ depending on the archive, and CMake + # does not search lib64/ on every distribution, so it is located explicitly + file(GLOB lab_dawn_config "${dawn_prebuilt_SOURCE_DIR}/lib*/cmake/Dawn/DawnConfig.cmake") + if(NOT lab_dawn_config) + message(FATAL_ERROR "wgpu-lab: the Dawn archive has no DawnConfig.cmake, try -DLAB_DAWN=source") + endif() + list(GET lab_dawn_config 0 lab_dawn_config) + cmake_path(GET lab_dawn_config PARENT_PATH lab_dawn_config_dir) + find_package(Dawn CONFIG REQUIRED PATHS "${lab_dawn_config_dir}" NO_DEFAULT_PATH) + +elseif(LAB_DAWN STREQUAL "source") + message(STATUS "wgpu-lab: building Dawn ${LAB_DAWN_TAG} from source (this takes a while)") + set(DAWN_FETCH_DEPENDENCIES ON) + set(DAWN_ENABLE_INSTALL OFF) + set(DAWN_BUILD_SAMPLES OFF) + set(DAWN_BUILD_TESTS OFF) + set(DAWN_BUILD_PROTOBUF OFF) + set(DAWN_USE_GLFW OFF) # the lab brings its own GLFW and surface glue + set(TINT_BUILD_TESTS OFF) + set(TINT_BUILD_CMD_TOOLS OFF) + set(TINT_BUILD_IR_BINARY OFF) + # The source archive of the tag is used instead of a git clone: Dawn has thousands + # of branches and tags, and even a shallow clone fetches the tip of each of them. + FetchContent_Declare(dawn + URL "https://github.com/google/dawn/archive/refs/tags/${LAB_DAWN_TAG}.tar.gz" + INACTIVITY_TIMEOUT 60 + DOWNLOAD_EXTRACT_TIMESTAMP ON + EXCLUDE_FROM_ALL + SYSTEM) + FetchContent_MakeAvailable(dawn) + if(NOT TARGET dawn::webgpu_dawn) + add_library(dawn::webgpu_dawn ALIAS webgpu_dawn) + endif() + +elseif(LAB_DAWN STREQUAL "system") + find_package(Dawn CONFIG REQUIRED) + message(STATUS "wgpu-lab: using Dawn found at ${Dawn_DIR}") + +else() + message(FATAL_ERROR "wgpu-lab: LAB_DAWN must be prebuilt, source or system (got '${LAB_DAWN}')") +endif() diff --git a/cmake/LabWindowsRuntime.cmake b/cmake/LabWindowsRuntime.cmake new file mode 100644 index 0000000..d87212e --- /dev/null +++ b/cmake/LabWindowsRuntime.cmake @@ -0,0 +1,58 @@ +# DLLs that Dawn's D3D12 backend loads at run time on Windows. +# +# Dawn compiles shaders through the DirectX Shader Compiler (dxcompiler.dll and +# dxil.dll) or the older d3dcompiler_47.dll, and it looks for them next to the +# executable only. Neither is part of the Dawn archive. The shader compiler is +# downloaded from its own releases, d3dcompiler_47.dll is taken from the Windows +# SDK if it is installed, and lab_copy_runtime_dlls() puts them next to a target. + +include(FetchContent) + +FetchContent_Declare(dxc + URL "https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.9.2609/dxc_2026_09_29.zip" + URL_HASH "SHA256=ad31b1fc8443175d204f77a611fdb3ef2ec42759bdc2f1167368de24a4a7e7f1" + INACTIVITY_TIMEOUT 60 + DOWNLOAD_EXTRACT_TIMESTAMP ON) +FetchContent_MakeAvailable(dxc) + +# the archive holds bin/x64/, bin/x86/ and bin/arm64/ +if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(ARM64|aarch64)$") + set(lab_dxc_arch "arm64") +else() + set(lab_dxc_arch "x64") +endif() + +set(LAB_RUNTIME_DLLS "") +foreach(dll dxcompiler.dll dxil.dll) + file(GLOB_RECURSE lab_dxc_candidates "${dxc_SOURCE_DIR}/*${dll}") + list(FILTER lab_dxc_candidates INCLUDE REGEX "${lab_dxc_arch}") + if(NOT lab_dxc_candidates) + message(FATAL_ERROR "wgpu-lab: ${dll} for ${lab_dxc_arch} not found in the DirectX Shader Compiler archive") + endif() + list(GET lab_dxc_candidates 0 lab_dxc_dll) + list(APPEND LAB_RUNTIME_DLLS "${lab_dxc_dll}") +endforeach() + +find_file(LAB_D3DCOMPILER_DLL d3dcompiler_47.dll + PATHS + "$ENV{WindowsSdkDir}Redist/D3D/${lab_dxc_arch}" + "$ENV{WindowsSdkDir}/Redist/D3D/${lab_dxc_arch}" + "[HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows Kits\\Installed Roots;KitsRoot10]/Redist/D3D/${lab_dxc_arch}" + "[HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows Kits\\Installed Roots;KitsRoot10]/Redist/D3D/${lab_dxc_arch}" + NO_DEFAULT_PATH + DOC "d3dcompiler_47.dll of the Windows SDK, loaded by Dawn next to the executable") +if(LAB_D3DCOMPILER_DLL) + list(APPEND LAB_RUNTIME_DLLS "${LAB_D3DCOMPILER_DLL}") +else() + message(STATUS "wgpu-lab: d3dcompiler_47.dll not found (no Windows SDK?), Dawn will use the DirectX Shader Compiler only") +endif() +message(STATUS "wgpu-lab: run time DLLs: ${LAB_RUNTIME_DLLS}") + +# Copies the DLLs next to the executable `TARGET` after it is built. +# Programs that use the lab from another project call this for their executables. +function(lab_copy_runtime_dlls TARGET) + add_custom_command(TARGET ${TARGET} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${LAB_RUNTIME_DLLS} $ + COMMENT "Copying the DLLs Dawn needs next to ${TARGET}" + VERBATIM) +endfunction() diff --git a/cmake/emdawnwebgpu.remoteport.py b/cmake/emdawnwebgpu.remoteport.py new file mode 100644 index 0000000..6fad11c --- /dev/null +++ b/cmake/emdawnwebgpu.remoteport.py @@ -0,0 +1,181 @@ +# Copyright 2025 The Emscripten Authors. All rights reserved. +# Emscripten is available under two separate licenses, the MIT license and the +# University of Illinois/NCSA Open Source License. Both these licenses can be +# found in the LICENSE file. + +# https://dawn.googlesource.com/dawn/+/b1236a9bb4a47f1262bc3acb253bb1c2181e90dd/src/emdawnwebgpu/pkg/README.md +r""" +This "remote port" instructs Emscripten (4.0.10+) how to automatically download +the actual port for Emdawnwebgpu. See README below for instructions. + +# Emdawnwebgpu + +Emdawnwebgpu is Dawn's implementation of webgpu.h for Emscripten (on top of the +WebGPU JS API). It is a fork of Emscripten's original `USE_WEBGPU` bindings, +and while it is maintained in Dawn, it works in any browser supporting WebGPU +(modulo individual feature support). + +Emdawnwebgpu provides everything necessary to use `` and the +Dawn-style `` with Emscripten. + + + +If you find an issue in this release, please download the latest Emdawnwebgpu +release (see below) and latest Emscripten and verify the bug, then report it at +. + +## API Stability + +Core parts of `webgpu.h` (defined in +) are considered stable APIs +and should not change, except for bugfixes (though guarantees are not made). +Dawn/Emscripten-specific parts, and all of `webgpu_cpp.h`, are **NOT** +considered stable, and may change. + +## How to use Emdawnwebgpu + +Emdawnwebgpu is distributed in several ways. Choose the one that works for you. + +In all cases, it is important to enable Closure to reduce code size in release +builds. Pass the following flag to `emcc` during linking: + + --closure=1 + +### Targeting Web only + +#### Easiest: "Remote" port built into Emscripten + +Recent releases of Emscripten vendor a copy of a "remote" port which +automatically downloads a pinned version of Emdawnwebgpu and configures it. + +Pass the following flag to `emcc` during both compilation and linking: + + --use-port=emdawnwebgpu + +#### Latest: "Remote" port from Dawn release + +This is the same as the built-in port, but you can download a newer version if +you need recent bugfixes or features in Emdawnwebgpu that haven't been rolled +into Emscripten yet. **Requires Emscripten 4.0.10+.** + +Download and extract the `emdawnwebgpu-*.remoteport.py` file from +. + +Pass the following flag to `emcc` during both compilation and linking: + + --use-port=path/to/emdawnwebgpu_remoteport_file.py + +#### Latest, without automatic downloading: "Local" port from Dawn release + +Use this method if your build system requires sources to be local (e.g. checked +into your repository) instead of automatically downloaded, or if you use +Emscripten before 4.0.10. +**Note that Emdawnwebgpu may not work with older Emscripten releases.** + +Download and extract the `emdawnwebgpu_pkg-*.zip` package from +. +(Note the package is text-only and does not contain any binaries, but see below +if you need to build the package from the original source.) + +Pass the following flag to `emcc` during both compilation and linking: + + --use-port=path/to/emdawnwebgpu_pkg/emdawnwebgpu.port.py + +If (and only if) using Emscripten before 4.0.7, pass this flag during linking: + + --closure-args=--externs=path/to/emdawnwebgpu_pkg/webgpu/src/webgpu-externs.js + +#### Without using a port file (**Unsupported!**) + +It is possible to integrate the Emdawnwebgpu sources directly into your build +process, which may be necessary for certain build systems, but this is not +officially supported. Using a port file instead is strongly recommended. + +If you do this, the port files or Dawn's GN or CMake files can serve as a +reference for the steps needed. Note that in all cases, the sources include both +C++ and JS code. While it is possible to precompile the C++ code to `.a`, the JS +code cannot be precompiled and must be provided at the final link step. + +### Cross-targeting Web/Native + +#### Using CMake + +Use this method if your project uses CMake and targets both Emscripten and +native platforms. + + + +#### Building the package locally to use with a non-CMake project + +If your project already has Dawn source, or you otherwise want to cross-target +Web and native with your non-CMake project, you can use CMake to build +`emdawnwebgpu_pkg` locally (similar to how you would build binary libraries +to link with in native), then use the "Local" port instructions above. + + + +## Port options + +Options can be set by appending `:key1=value:key2=value` to `--use-port`. +For information about port options, run: + + emcc --use-port=emdawnwebgpu:help + emcc --use-port=path/to/emdawnwebgpu_remoteport_file.py:help + emcc --use-port=path/to/emdawnwebgpu_pkg/emdawnwebgpu.port.py:help + +### C++ bindings + +By default, C++ bindings are provided in the include path. Note that unlike +`webgpu.h`, these are not intended to be fully stable. If you don't want these +for any reason (you have custom bindings, you're using a pinned snapshot of +`webgpu_cpp.h`, etc.), you can set the option `cpp_bindings=false`: + + --use-port=emdawnwebgpu:cpp_bindings=false + --use-port=path/to/emdawnwebgpu_remoteport_file.py:cpp_bindings=false + --use-port=path/to/emdawnwebgpu_pkg/emdawnwebgpu.port.py:cpp_bindings=false + +## Embuilder + +If your build process needs a separate step to build the port before linking, +use Emscripten's `embuilder`. + +Under `embuilder`, some options cannot be set automatically, so they must be +set manually. For details, see `OPTIONS` in `emdawnwebgpu.port.py` (in the +package zip). +""" + +import sys + +if __name__ == '__main__': + print('Please see documentation inside this file for details on how to use this port.') + sys.exit(1) + +_VERSION = 'v20261002.154047' + +# Remote-specific port information + +# - Where to download the port +EXTERNAL_PORT = f'https://github.com/google/dawn/releases/download/{_VERSION}/emdawnwebgpu_pkg-{_VERSION}.zip' +# - Hash to verify the download integrity +SHA512 = 'b1aae0acacf260e666beaf96ef5317af5ed6cef4b07384ceb829b88af4d1b4504d4bdca2b0b9a365cff7ea7735cbe4b9d07291567f166dc16681505730b49a98' +# - Path of the port inside the zip file +PORT_FILE = 'emdawnwebgpu_pkg/emdawnwebgpu.port.py' + +# General port information + +# - Visible in emcc --show-ports and emcc --use-port=emdawnwebgpu:help +LICENSE = "Some files: BSD 3-Clause License. Other files: Emscripten's license (available under both MIT License and University of Illinois/NCSA Open Source License)" + +# - Visible in emcc --use-port=emdawnwebgpu:help +DESCRIPTION = "Emdawnwebgpu implements webgpu.h on WebGPU, replacing -sUSE_WEBGPU. **For info on usage and filing feedback, see link below.**" +URL = 'https://dawn.googlesource.com/dawn/+/b1236a9bb4a47f1262bc3acb253bb1c2181e90dd/src/emdawnwebgpu/pkg/README.md' + + +# Emscripten <4.0.10 won't notice EXTERNAL_PORT and will try to use this. +def get(ports, settings, shared): + raise Exception('Remote ports require Emscripten 4.0.10+.') + + +# (Make this look like a port so that the error message above can be hit.) +def clear(ports, settings, shared): + pass diff --git a/docs/images/02_triangle.png b/docs/images/02_triangle.png new file mode 100644 index 0000000..798df02 Binary files /dev/null and b/docs/images/02_triangle.png differ diff --git a/docs/images/03_vertex_buffer.png b/docs/images/03_vertex_buffer.png new file mode 100644 index 0000000..4957d85 Binary files /dev/null and b/docs/images/03_vertex_buffer.png differ diff --git a/docs/images/04_uniforms.png b/docs/images/04_uniforms.png new file mode 100644 index 0000000..29716d1 Binary files /dev/null and b/docs/images/04_uniforms.png differ diff --git a/docs/images/05_texture.png b/docs/images/05_texture.png new file mode 100644 index 0000000..c6a9ce2 Binary files /dev/null and b/docs/images/05_texture.png differ diff --git a/docs/images/06_instancing.png b/docs/images/06_instancing.png new file mode 100644 index 0000000..9e62c7f Binary files /dev/null and b/docs/images/06_instancing.png differ diff --git a/docs/images/07_graph.png b/docs/images/07_graph.png new file mode 100644 index 0000000..28fd5f3 Binary files /dev/null and b/docs/images/07_graph.png differ diff --git a/docs/images/10_house.png b/docs/images/10_house.png new file mode 100644 index 0000000..a689fba Binary files /dev/null and b/docs/images/10_house.png differ diff --git a/docs/images/11_boids.png b/docs/images/11_boids.png new file mode 100644 index 0000000..182cdd7 Binary files /dev/null and b/docs/images/11_boids.png differ diff --git a/docs/images/12_offscreen.png b/docs/images/12_offscreen.png new file mode 100644 index 0000000..d30a16e Binary files /dev/null and b/docs/images/12_offscreen.png differ diff --git a/docs/migrating-from-0.2.md b/docs/migrating-from-0.2.md new file mode 100644 index 0000000..5b87555 --- /dev/null +++ b/docs/migrating-from-0.2.md @@ -0,0 +1,133 @@ +# Migrating from wgpu-lab 0.2 + +Version 0.3 revived the lab on a current Dawn with the old API intact. +The version after it redesigned the API. This page lists what changed and why, +so that code written against 0.2 or 0.3 can be carried over. + +## What stayed + +- `#include ` and `while (lab::tick()) { ... }` +- a handful of RAII objects set up in a few lines +- `wgpu::` types are used directly, nothing of WebGPU is hidden + +## The ideas behind the changes + +1. **A pipeline is immutable and made for one target.** There is no `finalize()` + anymore and no format shared behind the scenes. A pipeline is complete when its + constructor returns, or the constructor throws. +2. **Buffers and bind groups belong to the draw call, not to the pipeline.** + A pipeline describes the *layout* of its vertex buffers. Which buffers are drawn + is said per draw call, in a `lab::Draw`. Two pipelines can share a mesh, and + one pipeline can draw many meshes. +3. **Bind group layouts come from the shader.** The shader already declares its + bindings, so `pipeline.bind_group(0, {{0, uniforms}})` is all there is to write. +4. **Rendering is composable.** `lab::RenderPass` takes any number of draw calls, + `lab::Frame` any number of passes, and a pass can render into a `lab::Texture` + instead of a window. +5. **Objects do not refer to each other.** They share reference-counted state, so + they can be destroyed in any order and moved around (into a `std::vector`, for example). +6. **No threads inside the library.** +7. **Mistakes are exceptions.** `lab::Error` is thrown where the mistake is made + and names the object. Errors that WebGPU reports later are thrown by the next + `lab::tick()`. + +## Renamed + +| 0.2 | now | +|---|---| +| `lab::Webgpu webgpu("label")` | `lab::Gpu gpu;` (options: `lab::Gpu gpu({.label = "label"})`) | +| `webgpu.device`, `.queue`, `.instance`, `.adapter` | `gpu.device()`, `gpu.queue()`, `gpu.instance()`, `gpu.adapter()` | +| `buffer.wgpu_buffer`, `surface.wgpu_surface`, `pipeline.wgpu_pipeline`, `texture.wgpu_texture`, `window.glfw_window_handle` | `handle()` on every object | +| `lab::Surface surface(window, webgpu)` | `lab::Surface surface(gpu, window)` (the Gpu always comes first) | +| `lab::Shader shader("label", "file.wgsl")` | `lab::Shader shader(gpu, "file.wgsl")`, compiled right away | +| `shader.source = "..."` | `lab::Shader::from_source(gpu, "...")` | +| `lab::Buffer buffer("label", data, usage, webgpu)` | `lab::Buffer buffer(gpu, data, usage, "label")` | +| `window.set_key_callback(f)`, `set_resize_callback(f)` | `window.on_key(f)`, `window.on_resize(f)` | +| `window.ratio()` (height / width) | `window.aspect()`, `surface.aspect()` (width / height) | +| `texture.to_device(pixels)` | `texture.write(pixels)` | +| `texture.create_view()` | `texture.view()` | +| `lab::ModKey::shift` as a plain enum | `enum class`, combined with `\|` and tested with `&` | +| `include `, `` | ``, or single headers such as `` | + +## Replaced + +**Setting up a pipeline** + +```c++ +// 0.2 +lab::Pipeline pipeline(shader, webgpu); +pipeline.add_vertex_buffer(vertex_buffer); +pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); +pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x3, 1); +pipeline.add_uniform_buffer(uniform_buffer, 0, wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment); +pipeline.add_texture(texture, 1); +pipeline.config.primitiveState.topology = wgpu::PrimitiveTopology::LineStrip; +pipeline.finalize(); + +// now +lab::Pipeline pipeline(gpu, shader, { + .vertex_buffers = {lab::vertex({Float32x2, Float32x3})}, + .target = surface, + .topology = wgpu::PrimitiveTopology::LineStrip, +}); +lab::Draw draw{ + .vertex_buffers = {vertex_buffer}, + .bind_groups = {pipeline.bind_group(0, {{0, uniform_buffer}, {1, texture}})}, +}; +``` + +A per-instance buffer is declared with `lab::instance({...})` in place of +`add_vertex_buffer(buffer, wgpu::VertexStepMode::Instance)`, an index buffer is +set as `Draw::index_buffer`. Anything `PipelineDesc` has no field for can be set +on the raw descriptor in `PipelineDesc::customize`. + +**Rendering** + +```c++ +// 0.2 +pipeline.render_frame(surface, 3, 1); // or {3, 1} +pipeline.render_config.renderPassColorAttachment.clearValue = {0.2, 0.2, 0.2, 1.0}; + +// now: counts come from the buffers of the draw call +pipeline.render_frame(surface, draw); +pipeline.render_frame(surface, 3); // pipelines without vertex buffers + +// several draw calls, another clear color +lab::RenderPass pass(surface, {.clear = wgpu::Color{0.2, 0.2, 0.2, 1.0}}); +pass.draw(pipeline, draw); +pass.draw(other_pipeline, other_draw); +``` + +Custom render functions (`Pipeline::render_func`) and +`get_current_render_texture_view()` are no longer needed: use `lab::RenderPass`, +or `surface.current_view()` and `surface.present()` to do it all by hand. + +**Reading and writing buffers** + +```c++ +// 0.2: the mapped memory was handed to another thread +auto thread = buffer.to_device([](lab::MappedVRAM vmap) { vmap.push(1); }, 256, usage); +auto reader = buffer.from_device([](lab::MappedVRAM vmap) { ... }); + +// now +lab::Buffer buffer(gpu, 256, usage, [](std::span mapped) { mapped[0] = 1; }); +std::vector values = buffer.read(); // waits for the gpu +buffer.read_async([](std::span values) { ... }); // or: called by a later tick() +``` + +The lab adds the usage flags that `write()` and `read()` need, so +`wgpu::BufferUsage::Uniform` is enough where 0.2 wanted `Uniform | CopyDst`. + +**Resizing.** A surface follows the size of its window on its own. Calls to +`surface.reconfigure()` in resize callbacks can be deleted. + +**Chrono literals.** `` no longer injects `using namespace std::chrono_literals`, +write it yourself where you want `100ms`. The macro `LAB_USER_DISABLE_CHRONO_LITERALS` is gone. + +## Removed without replacement + +- `lab::init_lab()`, `lab::state` and the handle typedefs (`WindowHandle`, ...) +- `Webgpu::capabilities` +- `Shader::transfer()`, `Pipeline::transfer()`, `Texture::transfer()`: + the objects own what they create, `handle()` gives access +- `Buffer` and other element types that are not plain data diff --git a/include/lab b/include/lab new file mode 100644 index 0000000..d168070 --- /dev/null +++ b/include/lab @@ -0,0 +1,81 @@ +#ifndef WGPU_LAB_MAIN_H +#define WGPU_LAB_MAIN_H + +// wgpu-lab: rapid prototyping of native WebGPU applications +// +// This header includes the whole library. Every lab object gives access to the +// plain WebGPU object it wraps through `handle()`, so the lab never stands +// between you and WebGPU. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace lab { + +// Processes window events and keeps the GPU's callbacks and error reporting going +// - call it once per iteration of the main loop +// - returns `true` as long as at least one Window is open +// - throws lab::Error if a Gpu has reported an error (see GpuOptions::throw_on_error) +// - closes all windows after N calls if the environment variable +// LAB_EXIT_AFTER_FRAMES=N is set (to run a program unattended) +// ```cpp +// while (lab::tick()) { +// pipeline.render_frame(surface, 3); +// } +// ``` +bool tick(); + +// Seconds between the two most recent calls of `tick()` +float delta_seconds(); + +// Seconds since the program started, or since the last `restart_timer()` +// ```cpp +// uniforms.time = lab::elapsed_seconds(); // float +// double precise = lab::elapsed_seconds(); +// ``` +namespace detail { +double elapsed_seconds(); +} +template +T elapsed_seconds() { + return static_cast(detail::elapsed_seconds()); +} +void restart_timer(); + +// Finds a file that belongs to the program, like an image or a model +// - a relative path is looked up from the working directory first, then from the +// directory of the executable, so it does not matter where a program is started from +// - throws lab::Error if the file is in neither place +// ```cpp +// auto path = lab::find_file("assets/house.glb"); +// ``` +std::filesystem::path find_file(const std::filesystem::path& file); + +// Wrapper for visual simplicity around `std::this_thread::sleep_for(duration);` +// ```cpp +// using namespace std::chrono_literals; +// lab::sleep(100ms); +// ``` +template +inline void sleep(const std::chrono::duration& duration) { + std::this_thread::sleep_for(duration); +} + +} // namespace lab + +#endif // WGPU_LAB_MAIN_H diff --git a/include/lab_buffer.h b/include/lab_buffer.h new file mode 100644 index 0000000..9c3eb04 --- /dev/null +++ b/include/lab_buffer.h @@ -0,0 +1,141 @@ +#ifndef WGPU_LAB_BUFFER_H +#define WGPU_LAB_BUFFER_H + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lab { + +// Types whose bytes can be copied to and from GPU memory as they are +template +concept GpuData = std::is_trivially_copyable_v && std::is_standard_layout_v; + +namespace detail { + +// The part of Buffer that does not depend on T +struct BufferCore { + using ReadCallback = std::function; + + BufferCore(Gpu& gpu, uint64_t byte_size, wgpu::BufferUsage usage, Label label, const void* data, + const std::function& fill); + + void write(uint64_t byte_offset, const void* data, uint64_t byte_count) const; + void read(uint64_t byte_offset, void* out, uint64_t byte_count) const; + void read_async(uint64_t byte_offset, uint64_t byte_count, ReadCallback callback) const; + + std::shared_ptr gpu; + wgpu::Buffer handle; + wgpu::BufferUsage usage; + uint64_t byte_size = 0; + std::string label; +}; + +} // namespace detail + +// A typed array in GPU memory: vertices, indices, uniforms, storage, ... +// ```cpp +// lab::Buffer vertices(gpu, vertex_data); // Buffer, a vertex buffer +// lab::Buffer uniforms(gpu, {my_uniforms}, wgpu::BufferUsage::Uniform); +// uniforms.write(my_uniforms); +// std::vector result = storage.read(); +// ``` +// The lab adds the usage flags that `write()` and `read()` need, so only the +// purpose of the buffer has to be given. +template +class Buffer { +public: + static constexpr size_t all = ~size_t{0}; + + // Creates a buffer that holds a copy of `data` + Buffer(Gpu& gpu, std::span data, wgpu::BufferUsage usage = wgpu::BufferUsage::Vertex, Label label = {}) + : core{gpu, data.size_bytes(), usage, std::move(label).as("buffer"), data.data(), nullptr}, + element_count{data.size()} {} + + Buffer(Gpu& gpu, std::initializer_list data, wgpu::BufferUsage usage = wgpu::BufferUsage::Vertex, Label label = {}) + : Buffer{gpu, std::span{data.begin(), data.size()}, usage, std::move(label)} {} + + // Creates a buffer of `count` elements with all bytes set to zero + Buffer(Gpu& gpu, size_t count, wgpu::BufferUsage usage, Label label = {}) + : core{gpu, count * sizeof(T), usage, std::move(label).as("buffer"), nullptr, nullptr}, element_count{count} {} + + // Creates a buffer of `count` elements and lets `fill` write them straight into GPU memory + // ```cpp + // lab::Buffer ramp(gpu, 256, wgpu::BufferUsage::Storage, + // [](std::span mapped) { std::ranges::iota(mapped, 0.0f); }); + // ``` + Buffer(Gpu& gpu, size_t count, wgpu::BufferUsage usage, const std::function)>& fill, + Label label = {}) + : core{gpu, count * sizeof(T), + usage, std::move(label).as("buffer"), + nullptr, [&](void* mapped) { fill(std::span{static_cast(mapped), count}); }}, + element_count{count} {} + + Buffer(Buffer&&) = default; + Buffer& operator=(Buffer&&) = default; + + // Replaces elements of the buffer, starting at index `first` + void write(std::span data, size_t first = 0) { + check_range(first, data.size(), "write"); + core.write(first * sizeof(T), data.data(), data.size_bytes()); + } + + // Replaces the element at `index` + void write(const T& element, size_t index = 0) { write(std::span{&element, 1}, index); } + + // Copies `count` elements starting at `first` back from the GPU + // - blocks until the GPU has caught up, use `read_async()` to keep rendering meanwhile + std::vector read(size_t first = 0, size_t count = all) const { + count = count == all ? element_count - std::min(first, element_count) : count; + check_range(first, count, "read"); + std::vector result(count); + core.read(first * sizeof(T), result.data(), count * sizeof(T)); + return result; + } + + // Like `read()`, but returns immediately: `callback` is called with the elements + // from within a later `tick()` (or `Gpu::poll()`) + void read_async(std::function)> callback, size_t first = 0, size_t count = all) const { + count = count == all ? element_count - std::min(first, element_count) : count; + check_range(first, count, "read_async"); + core.read_async( + first * sizeof(T), count * sizeof(T), [callback = std::move(callback)](const void* data, uint64_t byte_count) { + callback(std::span{static_cast(data), static_cast(byte_count / sizeof(T))}); + }); + } + + // number of elements + size_t size() const { return element_count; } + + const wgpu::Buffer& handle() const { return core.handle; } + const std::string& label() const { return core.label; } + +private: + void check_range(size_t first, size_t count, const char* what) const { + if (first > element_count || count > element_count - first) { + detail::fail(core.label, std::format("{}: elements [{}, {}) are outside of the buffer, which has {} elements", + what, first, first + count, element_count)); + } + } + + detail::BufferCore core; + size_t element_count = 0; +}; + +// lets `lab::Buffer buffer(gpu, my_vector)` find out the element type +template +Buffer(Gpu&, R&&, wgpu::BufferUsage = wgpu::BufferUsage::Vertex, Label = {}) + -> Buffer>>; + +} // namespace lab + +#endif // WGPU_LAB_BUFFER_H diff --git a/include/lab_compute.h b/include/lab_compute.h new file mode 100644 index 0000000..da6ce13 --- /dev/null +++ b/include/lab_compute.h @@ -0,0 +1,94 @@ +#ifndef WGPU_LAB_COMPUTE_H +#define WGPU_LAB_COMPUTE_H + +#include +#include +#include +#include + +#include +#include +#include + +namespace lab { + +struct ComputeDesc { + // only needed if the shader has more than one @compute entry point + std::string entry = {}; + + // Bind group layouts. Leave empty to derive them from the shader (`layout: auto`), + // which is what `ComputePipeline::bind_group()` builds on. + std::vector layouts = {}; + + // defaults to the label of the shader + std::string label = {}; +}; + +// A compute shader, ready to run on the gpu. +// ```cpp +// lab::Buffer data(gpu, values, wgpu::BufferUsage::Storage); +// lab::ComputePipeline square(gpu, shader); +// square.run({square.bind_group(0, {{0, data}})}, data.size() / 64); +// std::vector result = data.read(); +// ``` +class ComputePipeline { +public: + // Throws lab::Error if WebGPU rejects the pipeline + ComputePipeline(Gpu& gpu, const Shader& shader, ComputeDesc desc = {}); + + ComputePipeline(ComputePipeline&&) = default; + ComputePipeline& operator=(ComputePipeline&&) = default; + + // Creates the bind group for `@group(group)` of the shader, see Pipeline::bind_group + wgpu::BindGroup bind_group(uint32_t group, std::initializer_list bindings, + std::string_view label = {}) const; + + // Runs the shader on x * y * z workgroups and submits that right away + // - a workgroup is as many invocations as the shader's @workgroup_size says + // - to run it as part of a frame, use `lab::ComputePass` + void run(std::initializer_list bind_groups, uint32_t x, uint32_t y = 1, uint32_t z = 1) const; + + const wgpu::ComputePipeline& handle() const { return pipeline; } + const std::string& label() const { return name; } + +private: + std::shared_ptr gpu; + wgpu::ComputePipeline pipeline; + std::string name; +}; + +// A sequence of compute dispatches within a frame. Destroying the pass ends it. +// ```cpp +// lab::Frame frame(gpu); +// { +// lab::ComputePass compute(frame); +// compute.dispatch(simulation, {particles_group}, particle_count / 64); +// } +// lab::RenderPass pass(frame, surface); // renders what was just computed +// pass.draw(pipeline, draw_particles); +// ``` +class ComputePass { +public: + explicit ComputePass(Frame& frame, std::string label = "lab compute pass"); + + ComputePass(const ComputePass&) = delete; + ComputePass& operator=(const ComputePass&) = delete; + + // ends the pass if `end()` was not called + ~ComputePass(); + + void dispatch(const ComputePipeline& pipeline, std::initializer_list bind_groups, uint32_t x, + uint32_t y = 1, uint32_t z = 1); + + void end(); + + const wgpu::ComputePassEncoder& handle() const { return encoder; } + +private: + Frame* frame = nullptr; + wgpu::ComputePassEncoder encoder; +}; + +} // namespace lab + +#endif // WGPU_LAB_COMPUTE_H diff --git a/include/lab_error.h b/include/lab_error.h new file mode 100644 index 0000000..0b62bb1 --- /dev/null +++ b/include/lab_error.h @@ -0,0 +1,26 @@ +#ifndef WGPU_LAB_ERROR_H +#define WGPU_LAB_ERROR_H + +#include +#include + +namespace lab { + +// Thrown when the lab detects a mistake or cannot go on: a file that does not +// exist, a shader that does not compile, a vertex buffer that does not fit the +// pipeline it is drawn with, an error reported by the GPU, ... +// - `what()` starts with the label of the object the error is about +struct Error : std::runtime_error { + using std::runtime_error::runtime_error; +}; + +namespace detail { + +// Logs the message as an error and throws lab::Error(": ") +[[noreturn]] void fail(std::string_view object, std::string_view message); + +} // namespace detail + +} // namespace lab + +#endif // WGPU_LAB_ERROR_H diff --git a/include/lab_frame.h b/include/lab_frame.h new file mode 100644 index 0000000..2a81502 --- /dev/null +++ b/include/lab_frame.h @@ -0,0 +1,123 @@ +#ifndef WGPU_LAB_FRAME_H +#define WGPU_LAB_FRAME_H + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace lab { + +// A batch of work for the GPU: one or more passes that are submitted together. +// Destroying the frame submits it and presents every surface that was rendered to. +// ```cpp +// { +// lab::Frame frame(gpu); +// { +// lab::RenderPass shadows(frame, shadow_map); +// shadows.draw(shadow_pipeline, scene); +// } +// lab::RenderPass main(frame, surface); +// main.draw(pipeline, scene); +// } // main ends, the frame is submitted, the surface is presented +// ``` +// A frame with a single pass onto a surface does not need a Frame object, see `lab::RenderPass`. +class Frame { +public: + explicit Frame(const Gpu& gpu); + explicit Frame(const Surface& surface); // a frame on the Gpu of this surface + + Frame(const Frame&) = delete; + Frame& operator=(const Frame&) = delete; + + // submits the frame if `submit()` was not called + ~Frame(); + + // Hands the recorded passes to the GPU and presents the surfaces that were rendered to + // - all passes of the frame have to be ended before + void submit(); + + const wgpu::CommandEncoder& handle() const { return encoder; } + +private: + friend class RenderPass; + friend class ComputePass; + + std::shared_ptr gpu; + wgpu::CommandEncoder encoder; + std::vector> surfaces; // to present after submitting + bool pass_open = false; + bool submitted = false; +}; + +struct PassOptions { + // color the target is cleared to before drawing, nullopt keeps what is in it + std::optional clear = wgpu::Color{0.08, 0.08, 0.085, 1.0}; + + // A depth buffer for passes into a texture: a texture with a depth format and the size + // of the target. A surface brings its own, see SurfaceOptions::depth. + const Texture* depth = nullptr; + + // depth the depth buffer is cleared to, nullopt keeps what is in it + std::optional clear_depth = 1.0f; + + std::string label = "lab render pass"; +}; + +// A sequence of draw calls into one target. Destroying the pass ends it. +// ```cpp +// while (lab::tick()) { +// lab::RenderPass pass(surface); // one frame with one pass +// pass.draw(node_pipeline, nodes); +// pass.draw(edge_pipeline, edges); +// } // submitted and presented here +// ``` +class RenderPass { +public: + // A pass that is a whole frame by itself: ending it submits and presents + explicit RenderPass(Surface& surface, PassOptions options = {}); + + // A pass within a frame, onto a surface or into a texture + RenderPass(Frame& frame, Surface& surface, PassOptions options = {}); + RenderPass(Frame& frame, const Texture& target, PassOptions options = {}); + + RenderPass(const RenderPass&) = delete; + RenderPass& operator=(const RenderPass&) = delete; + + // ends the pass if `end()` was not called + ~RenderPass(); + + // Draws with a pipeline. Throws lab::Error if the pipeline was built for + // another target format or if the buffers do not fit its vertex layouts. + void draw(const Pipeline& pipeline, const Draw& draw); + // for pipelines without vertex buffers + void draw(const Pipeline& pipeline, uint32_t vertex_count, uint32_t instance_count = 1); + + void end(); + + // False if there is nothing to render onto this time (see Surface::current_view). + // Draw calls on such a pass do nothing, so checking is only needed before using `handle()`. + explicit operator bool() const { return encoder != nullptr; } + + // for everything the lab does not wrap: `pass.handle().SetViewport(...)` + const wgpu::RenderPassEncoder& handle() const { return encoder; } + +private: + void begin(Frame& frame, wgpu::TextureView view, wgpu::TextureView depth_view, TargetFormat format, + const PassOptions& options); + + std::optional own_frame; + Frame* frame = nullptr; + wgpu::RenderPassEncoder encoder; + TargetFormat target{wgpu::TextureFormat::Undefined}; + std::string label; +}; + +} // namespace lab + +#endif // WGPU_LAB_FRAME_H diff --git a/include/lab_gpu.h b/include/lab_gpu.h new file mode 100644 index 0000000..5fbb89a --- /dev/null +++ b/include/lab_gpu.h @@ -0,0 +1,78 @@ +#ifndef WGPU_LAB_GPU_H +#define WGPU_LAB_GPU_H + +#include + +#include +#include +#include + +namespace lab { + +namespace detail { +struct GpuState; +} + +struct GpuOptions { + // which GPU to prefer if there is more than one + wgpu::PowerPreference power = wgpu::PowerPreference::HighPerformance; + + // `Undefined` leaves the choice to Dawn (Vulkan on Linux, D3D12 on Windows) + wgpu::BackendType backend = wgpu::BackendType::Undefined; + + // use the software adapter even if there is a GPU + // - if there is no GPU at all, the software adapter is used anyway + bool fallback_adapter = false; + + // device features the program cannot do without, creating the Gpu fails if one is missing + std::vector features = {}; + + // An error reported by the GPU (failed validation, out of memory) is thrown as + // lab::Error by the next `tick()` or `poll()`. + // - if false, errors are only logged and collected in `errors()` + bool throw_on_error = true; + + std::string label = "lab"; +}; + +// The connection to the graphics card: WebGPU instance, adapter, device and queue. +// Everything else that lives on the GPU is created from a Gpu. +// ```cpp +// lab::Gpu gpu; +// wgpu::Device device = gpu.device(); // plain WebGPU is always within reach +// ``` +class Gpu { +public: + // Throws lab::Error if no adapter or device can be created + explicit Gpu(GpuOptions options = {}); + + Gpu(Gpu&&) = default; + Gpu& operator=(Gpu&&) = default; + + const wgpu::Instance& instance() const; + const wgpu::Adapter& adapter() const; + const wgpu::Device& device() const; + const wgpu::Queue& queue() const; + + // Runs pending WebGPU callbacks and throws lab::Error if the GPU has reported + // an error since the last call (see GpuOptions::throw_on_error) + // - `lab::tick()` does this for every Gpu, so only programs without + // a main loop need to call it themselves + void poll(); + + // Blocks until the GPU has executed everything that was submitted so far + void wait_idle(); + + // Messages of all errors the GPU has reported so far + const std::vector& errors() const; + + // internal: shared with every object that is created from this Gpu + const std::shared_ptr& state() const { return shared_state; } + +private: + std::shared_ptr shared_state; +}; + +} // namespace lab + +#endif // WGPU_LAB_GPU_H diff --git a/src/extra/lab_enums.h b/include/lab_input.h similarity index 75% rename from src/extra/lab_enums.h rename to include/lab_input.h index 4eb2409..a6b7335 100644 --- a/src/extra/lab_enums.h +++ b/include/lab_input.h @@ -1,5 +1,5 @@ -#ifndef WGPU_LAB_ENUMS_H -#define WGPU_LAB_ENUMS_H +#ifndef WGPU_LAB_INPUT_H +#define WGPU_LAB_INPUT_H // Copied and re-formatted from GLFW/glfw3.h // @@ -152,11 +152,11 @@ enum class KeyCode { unknown = -1, }; -// regular enum to allow for bit-wise combinations +// Modifier keys that were held down, can be combined and tested bit-wise // ```cpp -// auto mod = ModKey::shift | ModKey::ctrl; +// if ((event.mod & (ModKey::shift | ModKey::control)) != ModKey::none) { ... } // ``` -enum ModKey { +enum class ModKey { none = 0x0000, shift = 0x0001, control = 0x0002, @@ -166,6 +166,13 @@ enum ModKey { num_lock = 0x0020, }; +constexpr ModKey operator|(ModKey a, ModKey b) { + return static_cast(static_cast(a) | static_cast(b)); +} +constexpr ModKey operator&(ModKey a, ModKey b) { + return static_cast(static_cast(a) & static_cast(b)); +} + enum class MouseButton { left = 0, right = 1, @@ -184,8 +191,17 @@ struct KeyEvent { int scancode = 0; }; -bool operator==(const KeyEvent& lhs, const KeyEvent& rhs); +// Two key events are equal if key, action and modifiers match (the scancode is not compared) +constexpr bool operator==(const KeyEvent& lhs, const KeyEvent& rhs) { + return lhs.key == rhs.key && lhs.mod == rhs.mod && lhs.action == rhs.action; +} + +struct MouseButtonEvent { + MouseButton button = MouseButton::left; + KeyAction action = KeyAction::none; + ModKey mod = ModKey::none; +}; } // namespace lab -#endif // WGPU_LAB_ENUMS_H +#endif // WGPU_LAB_INPUT_H diff --git a/include/lab_label.h b/include/lab_label.h new file mode 100644 index 0000000..ce0f651 --- /dev/null +++ b/include/lab_label.h @@ -0,0 +1,33 @@ +#ifndef WGPU_LAB_LABEL_H +#define WGPU_LAB_LABEL_H + +#include +#include +#include + +namespace lab { + +// The name of a GPU object, it shows up in error messages of the lab and of WebGPU. +// An object that is given no name is named after the place where it is created: +// ```cpp +// lab::Buffer vertices(gpu, vertex_data); // "buffer main.cpp:23" +// lab::Buffer vertices(gpu, vertex_data, wgpu::BufferUsage::Vertex, "triangle"); // "triangle" +// ``` +struct Label { + std::string text; + + Label(std::source_location where = std::source_location::current()); + Label(const char* text) : text{text} {} + Label(std::string_view text) : text{text} {} + Label(std::string text) : text{std::move(text)} {} + + // prefixes a label that was derived from the source location with the kind of object + Label&& as(std::string_view kind) &&; + +private: + bool from_location = false; +}; + +} // namespace lab + +#endif // WGPU_LAB_LABEL_H diff --git a/include/lab_log.h b/include/lab_log.h new file mode 100644 index 0000000..405e7ca --- /dev/null +++ b/include/lab_log.h @@ -0,0 +1,27 @@ +#ifndef WGPU_LAB_LOG_H +#define WGPU_LAB_LOG_H + +#include +#include + +namespace lab { + +enum class LogLevel { debug, info, warn, error, off }; + +// Messages below this level are dropped +// - the default is `info`, or what the environment variable LAB_LOG says +// (debug, info, warn, error or off) +void set_log_level(LogLevel level); +LogLevel log_level(); + +// Decides where messages go, by default they are printed to stderr +// ```cpp +// lab::set_log_sink([&](lab::LogLevel level, std::string_view message) { my_console.add(message); }); +// ``` +void set_log_sink(std::function sink); + +void log(LogLevel level, std::string_view message); + +} // namespace lab + +#endif // WGPU_LAB_LOG_H diff --git a/include/lab_pipeline.h b/include/lab_pipeline.h new file mode 100644 index 0000000..43d319e --- /dev/null +++ b/include/lab_pipeline.h @@ -0,0 +1,318 @@ +#ifndef WGPU_LAB_PIPELINE_H +#define WGPU_LAB_PIPELINE_H + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace lab { + +// returns the size in bytes of a wgpu::VertexFormat +constexpr uint64_t vertex_format_size(wgpu::VertexFormat format) { + using enum wgpu::VertexFormat; + switch (format) { + case Uint8: + case Sint8: + case Unorm8: + case Snorm8: + return 1; + case Uint8x2: + case Sint8x2: + case Unorm8x2: + case Snorm8x2: + case Uint16: + case Sint16: + case Unorm16: + case Snorm16: + case Float16: + return 2; + case Uint8x4: + case Sint8x4: + case Unorm8x4: + case Snorm8x4: + case Unorm8x4BGRA: + case Uint16x2: + case Sint16x2: + case Unorm16x2: + case Snorm16x2: + case Float16x2: + case Float32: + case Uint32: + case Sint32: + case Unorm10_10_10_2: + case Snorm10_10_10_2: + return 4; + case Uint16x4: + case Sint16x4: + case Unorm16x4: + case Snorm16x4: + case Float16x4: + case Float32x2: + case Uint32x2: + case Sint32x2: + return 8; + case Float32x3: + case Uint32x3: + case Sint32x3: + return 12; + case Float32x4: + case Uint32x4: + case Sint32x4: + return 16; + } + return 0; +} + +// ------------------------------------------------------------------------------------------------- +// What a pipeline renders into --------------------------------------------------------------------- + +// The texture formats of a render target. A pipeline is built for exactly one +// combination and can only draw in passes onto a matching target. +struct TargetFormat { + wgpu::TextureFormat color; + wgpu::TextureFormat depth = wgpu::TextureFormat::Undefined; // Undefined: no depth buffer + + TargetFormat(const Surface& surface) : color{surface.format()}, depth{surface.depth_format()} {} + TargetFormat(const Texture& texture) : color{texture.format()} {} + TargetFormat(const Texture& color, const Texture& depth) : color{color.format()}, depth{depth.format()} {} + TargetFormat(wgpu::TextureFormat color, wgpu::TextureFormat depth = wgpu::TextureFormat::Undefined) + : color{color}, depth{depth} {} + + bool operator==(const TargetFormat&) const = default; +}; + +// ------------------------------------------------------------------------------------------------- +// How vertex buffers are laid out ----------------------------------------------------------------- + +// One attribute of a vertex. Just naming the format is enough in most cases: +// attributes follow each other in memory and shader locations count up from 0. +struct VertexAttribute { + static constexpr uint32_t next_location = ~uint32_t{0}; // one after the previous attribute + static constexpr uint64_t next_offset = ~uint64_t{0}; // right behind the previous attribute + + wgpu::VertexFormat format; + uint32_t location = next_location; // `@location(n)` in the shader + uint64_t offset = next_offset; // byte offset within the vertex + + VertexAttribute(wgpu::VertexFormat format, uint32_t location = next_location, uint64_t offset = next_offset) + : format{format}, location{location}, offset{offset} {} +}; + +// The layout of one vertex buffer slot of a pipeline, made by `lab::vertex()` or `lab::instance()` +struct VertexLayout { + std::vector attributes; + uint64_t stride = 0; + wgpu::VertexStepMode step_mode = wgpu::VertexStepMode::Vertex; +}; + +namespace detail { +VertexLayout make_vertex_layout(std::initializer_list attributes, uint64_t stride, + wgpu::VertexStepMode step_mode); +} + +// Describes a buffer of T that is advanced once per vertex +// ```cpp +// struct MyVertex { float pos[2]; float color[3]; }; +// lab::vertex({Float32x2, Float32x3}) // position at @location(0), color at @location(1) +// ``` +// Throws lab::Error if the attributes need more bytes than a T has. +template +VertexLayout vertex(std::initializer_list attributes) { + return detail::make_vertex_layout(attributes, sizeof(T), wgpu::VertexStepMode::Vertex); +} + +// Describes a buffer of T that is advanced once per instance +template +VertexLayout instance(std::initializer_list attributes) { + return detail::make_vertex_layout(attributes, sizeof(T), wgpu::VertexStepMode::Instance); +} + +// ------------------------------------------------------------------------------------------------- +// What to draw -------------------------------------------------------------------------------------- + +// A vertex buffer for a draw call, any `lab::Buffer` converts to it +struct VertexBufferRef { + wgpu::Buffer buffer; + uint64_t stride = 0; + uint64_t count = 0; + + template + VertexBufferRef(const Buffer& buffer) : buffer{buffer.handle()}, stride{sizeof(T)}, count{buffer.size()} {} + VertexBufferRef(wgpu::Buffer buffer, uint64_t stride, uint64_t count) + : buffer{std::move(buffer)}, stride{stride}, count{count} {} +}; + +// An index buffer for a draw call, `lab::Buffer` and `lab::Buffer` convert to it +struct IndexBufferRef { + wgpu::Buffer buffer; + wgpu::IndexFormat format = wgpu::IndexFormat::Undefined; + uint64_t count = 0; + + IndexBufferRef() = default; + IndexBufferRef(const Buffer& buffer) + : buffer{buffer.handle()}, format{wgpu::IndexFormat::Uint16}, count{buffer.size()} {} + IndexBufferRef(const Buffer& buffer) + : buffer{buffer.handle()}, format{wgpu::IndexFormat::Uint32}, count{buffer.size()} {} +}; + +// Everything a draw call needs besides the pipeline: which buffers, which bind +// groups, how much of them. It only holds handles, so it is cheap to keep around +// and to reuse every frame. +// ```cpp +// lab::Draw triangle{.vertex_buffers = {vertices}}; +// lab::Draw nodes{.vertex_buffers = {mesh, node_instances}, .index_buffer = mesh_indices, .bind_groups = {group}}; +// ``` +struct Draw { + static constexpr uint32_t all = ~uint32_t{0}; + + std::vector vertex_buffers = {}; // one per vertex buffer slot of the pipeline + IndexBufferRef index_buffer = {}; // optional + std::vector bind_groups = {}; // `@group(n)` in the shader is bind_groups[n] + + uint32_t count = all; // vertices (or indices, with an index buffer); `all`: as many as the buffers hold + uint32_t instances = all; // `all`: as many as the per-instance buffers hold, 1 if there are none + uint32_t first = 0; // first vertex (or first index) + uint32_t first_instance = 0; + int32_t base_vertex = 0; // added to each index, only with an index buffer +}; + +// ------------------------------------------------------------------------------------------------- +// Pipeline ------------------------------------------------------------------------------------------ + +// One resource of a bind group, see `Pipeline::bind_group()` +struct Binding { + uint32_t binding; // `@binding(n)` in the shader + wgpu::Buffer buffer = nullptr; + uint64_t offset = 0; + uint64_t size = wgpu::kWholeSize; + wgpu::TextureView texture_view = nullptr; + wgpu::Sampler sampler = nullptr; + + template + Binding(uint32_t binding, const Buffer& buffer) : binding{binding}, buffer{buffer.handle()} {} + Binding(uint32_t binding, wgpu::Buffer buffer, uint64_t offset = 0, uint64_t size = wgpu::kWholeSize) + : binding{binding}, buffer{std::move(buffer)}, offset{offset}, size{size} {} + Binding(uint32_t binding, const Texture& texture) : binding{binding}, texture_view{texture.view()} {} + Binding(uint32_t binding, wgpu::TextureView view) : binding{binding}, texture_view{std::move(view)} {} + Binding(uint32_t binding, wgpu::Sampler sampler) : binding{binding}, sampler{std::move(sampler)} {} +}; + +namespace detail { +// used by Pipeline and ComputePipeline +wgpu::BindGroup make_bind_group(const std::shared_ptr& gpu, const wgpu::BindGroupLayout& layout, + std::initializer_list bindings, std::string_view label); +} // namespace detail + +inline constexpr wgpu::BlendState alpha_blend{ + .color{.operation = wgpu::BlendOperation::Add, + .srcFactor = wgpu::BlendFactor::SrcAlpha, + .dstFactor = wgpu::BlendFactor::OneMinusSrcAlpha}, + .alpha{.operation = wgpu::BlendOperation::Add, + .srcFactor = wgpu::BlendFactor::One, + .dstFactor = wgpu::BlendFactor::OneMinusSrcAlpha}, +}; + +// How a pipeline is set up. Only `target` has to be given. +// ```cpp +// lab::Pipeline pipeline(gpu, shader, { +// .vertex_buffers = {lab::vertex({Float32x2, Float32x3})}, +// .target = surface, +// }); +// ``` +struct PipelineDesc { + // layout of each vertex buffer the shader reads, in the order they are passed to draw calls + std::vector vertex_buffers = {}; + + // the surface, texture or format this pipeline renders into + TargetFormat target; + + wgpu::PrimitiveTopology topology = wgpu::PrimitiveTopology::TriangleList; + wgpu::CullMode cull = wgpu::CullMode::None; + + // how the output is mixed with what is already in the target, nullopt replaces it + std::optional blend = alpha_blend; + + // Depth test, only used if the target has a depth buffer: a fragment is kept if its + // depth compares like this to what is stored, and then stored itself if depth_write is set + wgpu::CompareFunction depth_compare = wgpu::CompareFunction::Less; + bool depth_write = true; + + // only needed if the shader has more than one entry point per stage + std::string vertex_entry = {}; + std::string fragment_entry = {}; + + // Bind group layouts. Leave empty to derive them from the shader (`layout: auto`), + // which is what `Pipeline::bind_group()` builds on. + std::vector layouts = {}; + + // last word on the raw descriptor, for everything this struct does not cover + std::function customize = {}; + + // defaults to the label of the shader + std::string label = {}; +}; + +// A render pipeline: a shader plus the fixed state it is drawn with. +// It is immutable. What it draws (buffers, bind groups) is given per draw call. +class Pipeline { +public: + // Throws lab::Error if WebGPU rejects the pipeline, e.g. because the vertex + // layouts do not provide what the shader expects + Pipeline(Gpu& gpu, const Shader& shader, PipelineDesc desc); + + Pipeline(Pipeline&&) = default; + Pipeline& operator=(Pipeline&&) = default; + + // Creates the bind group for `@group(group)` of the shader + // ```cpp + // wgpu::BindGroup group = pipeline.bind_group(0, {{0, uniform_buffer}, {1, texture}}); + // ``` + // A bind group made this way belongs to this pipeline (unless PipelineDesc::layouts was given). + // Throws lab::Error if the bindings do not match what the shader declares. + wgpu::BindGroup bind_group(uint32_t group, std::initializer_list bindings, + std::string_view label = {}) const; + + // Renders one whole frame with a single draw call, which is all a simple program needs: + // ```cpp + // while (lab::tick()) { + // pipeline.render_frame(surface, {.vertex_buffers = {vertices}}); + // } + // ``` + // Returns false if the frame was skipped (see Surface::current_view). + // For more than one draw call per frame, use `lab::RenderPass`. + bool render_frame(Surface& surface, const Draw& draw) const; + // for pipelines without vertex buffers + bool render_frame(Surface& surface, uint32_t vertex_count, uint32_t instance_count = 1) const; + + const wgpu::RenderPipeline& handle() const { return pipeline; } + const std::string& label() const { return name; } + const TargetFormat& target() const { return target_format; } + + // stride and step mode of each vertex buffer slot, used to check draw calls + struct Slot { + uint64_t stride; + wgpu::VertexStepMode step_mode; + }; + const std::vector& slots() const { return vertex_slots; } + +private: + std::shared_ptr gpu; + wgpu::RenderPipeline pipeline; + TargetFormat target_format; + std::vector vertex_slots; + std::string name; +}; + +} // namespace lab + +#endif // WGPU_LAB_PIPELINE_H diff --git a/include/lab_shader.h b/include/lab_shader.h new file mode 100644 index 0000000..5ea126d --- /dev/null +++ b/include/lab_shader.h @@ -0,0 +1,45 @@ +#ifndef WGPU_LAB_SHADER_H +#define WGPU_LAB_SHADER_H + +#include + +#include +#include +#include + +namespace lab { + +// A compiled WGSL shader module. +// ```cpp +// lab::Shader shader(gpu, "shaders/triangle.wgsl"); +// auto other = lab::Shader::from_source(gpu, "@vertex fn vs_main() ..."); +// ``` +class Shader { +public: + // Loads and compiles a WGSL file + // - a relative path is looked up from the working directory first, + // then from the directory of the executable + // - throws lab::Error if the file is not found or does not compile, + // the message contains the compiler's diagnostics + Shader(Gpu& gpu, const std::filesystem::path& wgsl_file, std::string_view label = {}); + + // Compiles WGSL source code, throws lab::Error if it does not compile + static Shader from_source(Gpu& gpu, std::string_view wgsl, std::string_view label = "shader"); + + Shader(Shader&&) = default; + Shader& operator=(Shader&&) = default; + + const wgpu::ShaderModule& handle() const { return module; } + const std::string& label() const { return name; } + +private: + Shader() = default; + void compile(Gpu& gpu, std::string_view wgsl); + + wgpu::ShaderModule module; + std::string name; +}; + +} // namespace lab + +#endif // WGPU_LAB_SHADER_H diff --git a/include/lab_surface.h b/include/lab_surface.h new file mode 100644 index 0000000..90b2943 --- /dev/null +++ b/include/lab_surface.h @@ -0,0 +1,71 @@ +#ifndef WGPU_LAB_SURFACE_H +#define WGPU_LAB_SURFACE_H + +#include +#include + +namespace lab { + +namespace detail { +struct SurfaceState; +} + +struct SurfaceOptions { + // `Fifo` waits for the display (vsync), `Immediate` and `Mailbox` do not + wgpu::PresentMode present_mode = wgpu::PresentMode::Fifo; + + // `Undefined` picks BGRA8Unorm or RGBA8Unorm, whichever the window system prefers. + // Other formats it supports can be asked for, e.g. RGBA16Float for HDR output. + wgpu::TextureFormat format = wgpu::TextureFormat::Undefined; + + // A depth format (e.g. Depth24Plus) gives the surface a depth buffer, which it keeps + // at the size of the window. Render passes onto the surface then test against it. + wgpu::TextureFormat depth = wgpu::TextureFormat::Undefined; +}; + +// What a Gpu renders onto to make it appear in a Window. +// The surface follows the size of its window on its own. +// ```cpp +// lab::Surface surface(gpu, window); +// while (lab::tick()) { +// lab::RenderPass pass(surface); +// pass.draw(pipeline, 3); +// } +// ``` +class Surface { +public: + Surface(Gpu& gpu, Window& window, SurfaceOptions options = {}); + + Surface(Surface&&) = default; + Surface& operator=(Surface&&) = default; + + wgpu::TextureFormat format() const; + wgpu::TextureFormat depth_format() const; // Undefined if the surface has no depth buffer + Size size() const; // in pixels + float aspect() const; // width divided by height + + // The texture view to render the current frame into + // - stays the same until `present()` is called + // - is null if there is nothing to render onto at the moment (the window is + // minimized or the surface has to adapt to a new size first): skip the frame + // - `lab::RenderPass` and `lab::Frame` call this and `present()` for you + wgpu::TextureView current_view(); + + // The depth buffer that goes with `current_view()`, null if the surface has none + wgpu::TextureView depth_view(); + + // Shows what has been rendered into `current_view()` + void present(); + + const wgpu::Surface& handle() const; + + // internal + const std::shared_ptr& state() const { return shared_state; } + +private: + std::shared_ptr shared_state; +}; + +} // namespace lab + +#endif // WGPU_LAB_SURFACE_H diff --git a/include/lab_texture.h b/include/lab_texture.h new file mode 100644 index 0000000..0190717 --- /dev/null +++ b/include/lab_texture.h @@ -0,0 +1,99 @@ +#ifndef WGPU_LAB_TEXTURE_H +#define WGPU_LAB_TEXTURE_H + +#include +#include +#include +#include + +#include +#include +#include + +namespace lab { + +struct TextureOptions { + // The default lets a texture be sampled in shaders, rendered into, written and read back + wgpu::TextureUsage usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::RenderAttachment | + wgpu::TextureUsage::CopyDst | wgpu::TextureUsage::CopySrc; +}; + +// A 2D image in GPU memory: something to sample in a shader or to render into. +// ```cpp +// lab::Texture texture(gpu, wgpu::TextureFormat::RGBA8Unorm, 256, 256); +// texture.write(pixels); // std::vector of 4-byte pixels +// auto group = pipeline.bind_group(0, {{0, texture}}); +// ``` +class Texture { +public: + Texture(Gpu& gpu, wgpu::TextureFormat format, uint32_t width, uint32_t height, TextureOptions options = {}, + Label label = {}); + + Texture(Texture&&) = default; + Texture& operator=(Texture&&) = default; + + // Replaces all pixels. P is the type of one pixel and has to match the format + // in size, e.g. a struct of four uint8_t for RGBA8Unorm. + template + void write(std::span pixels) { + write_bytes(pixels.data(), pixels.size_bytes(), sizeof(P)); + } + template + void write(const std::vector

& pixels) { + write(std::span{pixels}); + } + + // Copies all pixels back from the GPU, row by row from the top left + // - blocks until the GPU has caught up + template + std::vector

read() const { + std::vector

pixels(static_cast(extent.width) * extent.height); + read_bytes(pixels.data(), pixels.size() * sizeof(P), sizeof(P)); + return pixels; + } + + // Writes the texture to an image file (for 8-bit RGBA and BGRA formats) + // - blocks until the GPU has caught up + void save_png(const std::filesystem::path& path) const; + + // a view of the whole texture, for bind groups and render passes + wgpu::TextureView view() const; + + wgpu::TextureFormat format() const { return pixel_format; } + Size size() const { return {static_cast(extent.width), static_cast(extent.height)}; } + float aspect() const { return static_cast(extent.width) / static_cast(extent.height); } + + const wgpu::Texture& handle() const { return texture; } + const std::string& label() const { return name; } + +private: + void write_bytes(const void* data, uint64_t byte_count, uint64_t pixel_size); + void read_bytes(void* out, uint64_t byte_count, uint64_t pixel_size) const; + + std::shared_ptr gpu; + wgpu::Texture texture; + wgpu::TextureFormat pixel_format; + wgpu::Extent3D extent; + std::string name; +}; + +struct SamplerOptions { + // `Linear` blends neighbouring pixels, `Nearest` shows them as blocks + wgpu::FilterMode filter = wgpu::FilterMode::Linear; + + // what happens outside of the texture: `Repeat`, `MirrorRepeat` or `ClampToEdge` + wgpu::AddressMode address_mode = wgpu::AddressMode::Repeat; + + std::string label = "lab sampler"; +}; + +// Creates a sampler: the way a shader reads a texture with `textureSample()` +// ```cpp +// wgpu::Sampler sampler = lab::sampler(gpu); +// auto group = pipeline.bind_group(0, {{0, texture}, {1, sampler}}); +// ``` +wgpu::Sampler sampler(Gpu& gpu, SamplerOptions options = {}); + +} // namespace lab + +#endif // WGPU_LAB_TEXTURE_H diff --git a/include/lab_window.h b/include/lab_window.h new file mode 100644 index 0000000..c2cc4e0 --- /dev/null +++ b/include/lab_window.h @@ -0,0 +1,94 @@ +#ifndef WGPU_LAB_WINDOW_H +#define WGPU_LAB_WINDOW_H + +#include + +#include +#include +#include + +struct GLFWwindow; + +namespace lab { + +namespace detail { +struct WindowState; +} + +struct Size { + int width = 0, height = 0; + bool operator==(const Size&) const = default; +}; + +struct Vec2 { + float x = 0.0f, y = 0.0f; +}; + +struct WindowOptions { + bool resizable = true; + bool visible = true; +}; + +// A window on the desktop, with its keyboard and mouse input. +// A window knows nothing about the GPU, a `lab::Surface` connects the two. +// ```cpp +// lab::Window window("Hello", 640, 400); +// while (lab::tick()) { +// if (window.key_pressed(lab::KeyCode::escape)) window.close(); +// } +// ``` +class Window { +public: + // Throws lab::Error if the window cannot be created (e.g. there is no display) + Window(std::string_view title, int width, int height, WindowOptions options = {}); + + Window(Window&&) = default; + Window& operator=(Window&&) = default; + + // `tick()` returns true as long as at least one window is open + bool is_open() const; + void close(); + + void set_title(std::string_view title); + + // size in screen coordinates, which is what the window manager works with + Size size() const; + // size in pixels, which is what gets rendered: larger than `size()` on scaled displays + Size framebuffer_size() const; + // width divided by height + float aspect() const; + + // Keyboard and mouse state, updated once per `tick()` --------------------------- + + bool key(KeyCode key) const; // is held down + bool key_pressed(KeyCode key) const; // went down since the previous tick + bool key_released(KeyCode key) const; // went up since the previous tick + + bool mouse_button(MouseButton button) const; + bool mouse_pressed(MouseButton button) const; + bool mouse_released(MouseButton button) const; + + Vec2 mouse() const; // cursor position in pixels, from the top left corner + Vec2 mouse_delta() const; // cursor movement since the previous tick + Vec2 scroll() const; // scroll wheel movement since the previous tick + + // Callbacks, called from within `tick()` as the events arrive ------------------- + // (pass nullptr to remove one) + + void on_key(std::function callback); + void on_mouse_button(std::function callback); + void on_scroll(std::function callback); + void on_resize(std::function callback); + + GLFWwindow* handle() const; + + // internal: shared with the surfaces of this window + const std::shared_ptr& state() const { return shared_state; } + +private: + std::shared_ptr shared_state; +}; + +} // namespace lab + +#endif // WGPU_LAB_WINDOW_H diff --git a/samples/01_window.cpp b/samples/01_window.cpp new file mode 100644 index 0000000..d576cf3 --- /dev/null +++ b/samples/01_window.cpp @@ -0,0 +1,22 @@ +// 01: a window and keyboard input, no GPU involved yet + +#include + +int main() { + lab::Window window("press space to say hello", 640, 400); + + // an event callback: called from within lab::tick() whenever a key changes + window.on_key([&window](const lab::KeyEvent& event) { + if (event == lab::KeyEvent{lab::KeyCode::space, lab::KeyAction::release}) { + window.set_title("world!"); + } + }); + + // tick() processes the events of all windows and returns false once every window is closed + while (lab::tick()) { + // the state of a key can also be asked for directly + if (window.key_pressed(lab::KeyCode::escape)) { + window.close(); + } + } +} diff --git a/samples/02_triangle.cpp b/samples/02_triangle.cpp new file mode 100644 index 0000000..e2e4b85 --- /dev/null +++ b/samples/02_triangle.cpp @@ -0,0 +1,31 @@ +// 02: the smallest program that renders something + +#include + +int main() { + lab::Gpu gpu; // the connection to the graphics card + lab::Window window("hold space for the outline", 640, 400); + lab::Surface surface(gpu, window); // lets the gpu render into the window + + lab::Shader shader(gpu, "shaders/02_triangle.wgsl"); + + // A pipeline is a shader plus everything that is fixed while drawing with it. + // It is made for one kind of render target, here the surface. + lab::Pipeline filled(gpu, shader, {.target = surface}); + + // the same shader with another setting is another pipeline + lab::Pipeline outline(gpu, shader, {.target = surface, .topology = wgpu::PrimitiveTopology::LineStrip}); + + while (lab::tick()) { + if (window.key_pressed(lab::KeyCode::escape)) { + window.close(); + } + + // render_frame() draws one frame with one draw call: 3 or 4 vertices here + if (window.key(lab::KeyCode::space)) { + outline.render_frame(surface, 4); + } else { + filled.render_frame(surface, 3); + } + } +} diff --git a/samples/03_vertex_buffer.cpp b/samples/03_vertex_buffer.cpp new file mode 100644 index 0000000..6b3dee3 --- /dev/null +++ b/samples/03_vertex_buffer.cpp @@ -0,0 +1,41 @@ +// 03: vertices come from a buffer (this is the example from the README) + +#include + +#include + +using enum wgpu::VertexFormat; + +struct MyVertex { + float pos[2]; + float color[3]; +}; + +int main() { + lab::Gpu gpu; + lab::Window window("Hello Triangle", 640, 400); + lab::Surface surface(gpu, window); + + // colored triangle data + std::vector vertex_data = { + // X Y R G B + {.pos = {-0.5f, -0.5f}, .color = {0.8f, 0.2f, 0.2f}}, + {.pos = {+0.5f, -0.5f}, .color = {0.8f, 0.8f, 0.2f}}, + {.pos = {+0.0f, +0.5f}, .color = {0.2f, 0.8f, 0.4f}}, + }; + + // vertex buffer (sends a copy of the data to GPU memory) + lab::Buffer vertices(gpu, vertex_data); + + lab::Shader shader(gpu, "shaders/03_vertex_buffer.wgsl"); + + // the pipeline needs to know how a vertex is laid out in memory: + // two floats for @location(0) position, three floats for @location(1) color + lab::Pipeline pipeline(gpu, shader, + {.vertex_buffers = {lab::vertex({Float32x2, Float32x3})}, .target = surface}); + + // main application loop + while (lab::tick()) { + pipeline.render_frame(surface, {.vertex_buffers = {vertices}}); // draws all 3 vertices of the buffer + } +} diff --git a/samples/04_uniforms.cpp b/samples/04_uniforms.cpp new file mode 100644 index 0000000..6454c83 --- /dev/null +++ b/samples/04_uniforms.cpp @@ -0,0 +1,55 @@ +// 04: a uniform buffer passes values to the shader that change over time + +#include + +#include +#include + +struct MyVertex { + float x, y; +}; + +// has to match `struct Uniforms` in the shader +struct MyUniforms { + float ratio[2]; // scales x, so that the triangle keeps its shape whatever the shape of the window + float time; + float scale; +}; + +int main() { + lab::Gpu gpu; + lab::Window window("Uniforms", 640, 400); + lab::Surface surface(gpu, window); + + // an equilateral triangle + std::vector vertex_data = {{0.f, 1.f}, {-std::sqrt(3.f) / 2.f, -0.5f}, {std::sqrt(3.f) / 2.f, -0.5f}}; + lab::Buffer vertices(gpu, vertex_data); + + MyUniforms uniforms{.ratio = {1.0f / surface.aspect(), 1.0f}, .time = 0.0f, .scale = 0.4f}; + lab::Buffer uniform_buffer(gpu, {uniforms}, wgpu::BufferUsage::Uniform); + + lab::Shader shader(gpu, "shaders/04_uniforms.wgsl"); + lab::Pipeline pipeline( + gpu, shader, {.vertex_buffers = {lab::vertex({wgpu::VertexFormat::Float32x2})}, .target = surface}); + + // The shader declares `@group(0) @binding(0) var uniforms: Uniforms;` + // A bind group tells the pipeline which buffer that is. + wgpu::BindGroup bind_group = pipeline.bind_group(0, {{0, uniform_buffer}}); + + // everything a draw call needs, set up once and reused every frame + lab::Draw triangle{.vertex_buffers = {vertices}, .bind_groups = {bind_group}}; + + while (lab::tick()) { + // start the animation over every 7 seconds + if (lab::elapsed_seconds() > 7.0f) { + lab::restart_timer(); + } + + // send the current values to the gpu + uniforms.ratio[0] = 1.0f / surface.aspect(); // follows the size of the window + uniforms.time = lab::elapsed_seconds(); + uniform_buffer.write(uniforms); + + pipeline.render_frame(surface, triangle); + } +} diff --git a/samples/05_texture.cpp b/samples/05_texture.cpp new file mode 100644 index 0000000..f39a708 --- /dev/null +++ b/samples/05_texture.cpp @@ -0,0 +1,77 @@ +// 05: a texture is filled with pixels and sampled by the fragment shader + +#include + +#include +#include + +using enum wgpu::VertexFormat; + +struct MyVertex { + float x, y; + float u, v; +}; + +struct MyUniforms { + float ratio[2]; + float time; + float scale; +}; + +// one pixel of an RGBA8Unorm texture +struct MyPixel { + uint8_t r, g, b, a; +}; + +int main() { + lab::Gpu gpu; + lab::Window window("Texture", 640, 400); + lab::Surface surface(gpu, window); + + // --------------------------------------------------------------------------- + // create a procedural texture and upload it to the gpu + lab::Texture texture(gpu, wgpu::TextureFormat::RGBA8Unorm, 256, 256); + + auto wave = [](int v, float s = 1.f) { + return uint8_t(s * (20.f + uint8_t(std::sin(float(v) / 255.f * 20.f) + 1.f) * 40.f)); + }; + + std::vector pixel_data; + pixel_data.reserve(256 * 256); + for (int y = 0; y < 256; ++y) { + for (int x = 0; x < 256; ++x) { + pixel_data.push_back({wave(x + y, .6f), wave(x, .3f), wave(y, .2f), 255}); + } + } + texture.write(pixel_data); + + // --------------------------------------------------------------------------- + // an equilateral triangle with texture coordinates + // x y u v + std::vector vertex_data = { + {0.f, 1.f, 0.f, 0.f}, {-std::sqrt(3.f) / 2.f, -0.5f, 0.f, 1.f}, {+std::sqrt(3.f) / 2.f, -0.5f, 1.f, 1.f}}; + lab::Buffer vertices(gpu, vertex_data); + + MyUniforms uniforms{.ratio = {1.0f / surface.aspect(), 1.0f}, .time = 0.0f, .scale = 0.4f}; + lab::Buffer uniform_buffer(gpu, {uniforms}, wgpu::BufferUsage::Uniform); + + lab::Shader shader(gpu, "shaders/05_texture.wgsl"); + lab::Pipeline pipeline(gpu, shader, + {.vertex_buffers = {lab::vertex({Float32x2, Float32x2})}, // position, uv + .target = surface}); + + // the bind group connects the resources the shader declares: + // `@binding(0) var uniforms`, `@binding(1) var gradientTexture` and `@binding(2) var gradientSampler` + lab::Draw triangle{ + .vertex_buffers = {vertices}, + .bind_groups = {pipeline.bind_group(0, {{0, uniform_buffer}, {1, texture}, {2, lab::sampler(gpu)}})}, + }; + + while (lab::tick()) { + uniforms.ratio[0] = 1.0f / surface.aspect(); + uniforms.time = lab::elapsed_seconds(); + uniform_buffer.write(uniforms); + + pipeline.render_frame(surface, triangle); + } +} diff --git a/samples/06_instancing.cpp b/samples/06_instancing.cpp new file mode 100644 index 0000000..9eef8e3 --- /dev/null +++ b/samples/06_instancing.cpp @@ -0,0 +1,88 @@ +// 06: one mesh drawn many times, with a second vertex buffer that advances per instance +// arrow keys move the selected triangle, space selects the next one + +#include + +#include + +#include +#include + +using enum wgpu::VertexFormat; + +struct MyVertex { + glm::vec2 pos; +}; + +struct MyInstance { + glm::vec2 pos; +}; + +// WGSL aligns a struct to its largest member, vec2f is aligned to 8 bytes: +// the struct in the shader is 24 bytes, alignas makes this one 24 bytes as well +struct alignas(8) MyUniforms { + glm::vec2 ratio; + float time; + float scale; + uint32_t selected; +}; + +int main() { + lab::Gpu gpu; + lab::Window window("arrow keys move, space selects the next triangle", 640, 400); + lab::Surface surface(gpu, window); + + // the mesh: an equilateral triangle + std::vector vertex_data = { + {{0.f, 1.f}}, {{-std::sqrt(3.f) / 2.f, -0.5f}}, {{+std::sqrt(3.f) / 2.f, -0.5f}}}; + lab::Buffer vertices(gpu, vertex_data); + + // one position per triangle + std::vector instance_data = {{{0.f, 0.f}}, {{.5f, .5f}}, {{-.2f, .4f}}, {{-.7f, -.2f}}, {{.5f, -.6f}}}; + lab::Buffer instances(gpu, instance_data); + + MyUniforms uniforms{.ratio = {1.0f / surface.aspect(), 1.f}, .time = 0.f, .scale = .2f, .selected = 0}; + lab::Buffer uniform_buffer(gpu, {uniforms}, wgpu::BufferUsage::Uniform); + + lab::Shader shader(gpu, "shaders/06_instancing.wgsl"); + lab::Pipeline pipeline( + gpu, shader, + {.vertex_buffers = {lab::vertex({Float32x2}), // @location(0), advances per vertex + lab::instance({Float32x2})}, // @location(1), advances per instance + .target = surface}); + + // the draw call takes its counts from the buffers: 3 vertices, 5 instances + lab::Draw triangles{ + .vertex_buffers = {vertices, instances}, + .bind_groups = {pipeline.bind_group(0, {{0, uniform_buffer}})}, + }; + + const float force = 4.f, friction = 1.f; + glm::vec2 velocity = {0.f, 0.f}; + + while (lab::tick()) { + if (window.key_pressed(lab::KeyCode::escape)) { + window.close(); + } + if (window.key_pressed(lab::KeyCode::space)) { + uniforms.selected = (uniforms.selected + 1) % instance_data.size(); + } + + // the arrow keys accelerate the selected triangle + const float dt = lab::delta_seconds(); + const glm::vec2 axis{float(window.key(lab::KeyCode::right)) - float(window.key(lab::KeyCode::left)), + float(window.key(lab::KeyCode::up)) - float(window.key(lab::KeyCode::down))}; + velocity = velocity * (1.f - friction * dt) + axis * force * dt; + + // only the element that changed is sent to the gpu + MyInstance& selected = instance_data[uniforms.selected]; + selected.pos += velocity * dt; + instances.write(selected, uniforms.selected); + + uniforms.ratio.x = 1.0f / surface.aspect(); + uniforms.time = lab::elapsed_seconds(); + uniform_buffer.write(uniforms); + + pipeline.render_frame(surface, triangles); + } +} diff --git a/samples/07_graph.cpp b/samples/07_graph.cpp new file mode 100644 index 0000000..8a1f83f --- /dev/null +++ b/samples/07_graph.cpp @@ -0,0 +1,108 @@ +// 07: several pipelines in one render pass +// nodes and edges of a graph share one mesh and one uniform buffer, but have their own shaders + +#include + +#include + +#include +#include +#include + +using enum wgpu::VertexFormat; + +struct NodeInstance { + glm::vec2 pos; + float scale; +}; + +struct EdgeInstance { + glm::vec2 pos_a, pos_b; + float scale; +}; + +struct alignas(8) UniformParams { + glm::vec2 ratio; + float time; +}; + +int main() { + lab::Gpu gpu; + lab::Window window("graph visualizer", 900, 600); + lab::Surface surface(gpu, window); + + // a hexagon, drawn with an index buffer: 6 vertices form 4 triangles + const float k = 2.0f * std::sqrt(3.0f) / 3.0f; + std::vector mesh{ + {-k * 0.5f, 1.0}, {-k, 0.0f}, {-k * 0.5f, -1.0}, // left + {k * 0.5f, -1.0}, {k, 0.0f}, {k * 0.5f, 1.0}, // right + }; + std::vector mesh_indices{ + 0, 1, 2, // left tri + 0, 2, 3, // center tri 1 + 3, 5, 0, // center tri 2 + 3, 4, 5, // right tri + }; + lab::Buffer mesh_vertices(gpu, mesh); + lab::Buffer mesh_index_buffer(gpu, mesh_indices, wgpu::BufferUsage::Index); + + // the graph: nodes on a circle, every node connected to the next and to the one three further + const int node_count = 12; + std::vector nodes; + std::vector edges; + auto node_position = [&](int i) { + const float angle = float(i % node_count) / float(node_count) * 2.0f * std::numbers::pi_v; + return 0.7f * glm::vec2{std::cos(angle), std::sin(angle)}; + }; + for (int i = 0; i < node_count; ++i) { + nodes.push_back({.pos = node_position(i), .scale = 0.07f}); + edges.push_back({.pos_a = node_position(i), .pos_b = node_position(i + 1), .scale = 0.012f}); + edges.push_back({.pos_a = node_position(i), .pos_b = node_position(i + 3), .scale = 0.006f}); + } + lab::Buffer node_instances(gpu, nodes); + lab::Buffer edge_instances(gpu, edges); + + UniformParams uniforms{.ratio = {1.0f / surface.aspect(), 1.0f}, .time = 0.0f}; + lab::Buffer uniform_buffer(gpu, {uniforms}, wgpu::BufferUsage::Uniform); + + // both pipelines read the mesh the same way and differ in their per-instance data + const lab::VertexLayout mesh_layout = lab::vertex({Float32x2}); + + lab::Shader node_shader(gpu, "shaders/07_graph_nodes.wgsl"); + lab::Pipeline node_pipeline( + gpu, node_shader, + {.vertex_buffers = {mesh_layout, lab::instance({Float32x2, Float32})}, .target = surface}); + + lab::Shader edge_shader(gpu, "shaders/07_graph_edges.wgsl"); + lab::Pipeline edge_pipeline( + gpu, edge_shader, + {.vertex_buffers = {mesh_layout, lab::instance({Float32x2, Float32x2, Float32})}, + .target = surface}); + + // what each pipeline draws: the same mesh and indices, its own instances + lab::Draw draw_nodes{ + .vertex_buffers = {mesh_vertices, node_instances}, + .index_buffer = mesh_index_buffer, + .bind_groups = {node_pipeline.bind_group(0, {{0, uniform_buffer}})}, + }; + lab::Draw draw_edges{ + .vertex_buffers = {mesh_vertices, edge_instances}, + .index_buffer = mesh_index_buffer, + .bind_groups = {edge_pipeline.bind_group(0, {{0, uniform_buffer}})}, + }; + + while (lab::tick()) { + if (window.key_pressed(lab::KeyCode::escape)) { + window.close(); + } + + uniforms.ratio.x = 1.0f / surface.aspect(); + uniform_buffer.write(uniforms); + + // A render pass collects draw calls into one target. This one is a whole frame: + // when it goes out of scope, the frame is sent to the gpu and shown in the window. + lab::RenderPass pass(surface); + pass.draw(edge_pipeline, draw_edges); + pass.draw(node_pipeline, draw_nodes); // drawn second, on top of the edges + } +} diff --git a/samples/08_readback.cpp b/samples/08_readback.cpp new file mode 100644 index 0000000..db7ea82 --- /dev/null +++ b/samples/08_readback.cpp @@ -0,0 +1,48 @@ +// 08: getting data back from the gpu, without a window + +#include + +#include +#include +#include +#include + +template +void print(const char* what, std::span values) { + std::cout << what << ":"; + for (const T& value : values) { + std::cout << " " << value; + } + std::cout << "\n"; +} + +int main() { + lab::Gpu gpu; + + // A buffer of 16 numbers that are written directly into gpu memory when it is created. + // "Storage" is the kind of buffer a compute shader would work on. + lab::Buffer buffer(gpu, 16, wgpu::BufferUsage::Storage, + [](std::span mapped) { std::iota(mapped.begin(), mapped.end(), 0); }); + + // write() replaces elements: one at index 3, then three starting at index 8 + buffer.write(-1, 3); + buffer.write(std::vector{100, 200, 300}, 8); + + // read() copies elements back and waits until the gpu is done with them + std::vector everything = buffer.read(); + print("the whole buffer", everything); + + std::vector part = buffer.read(8, 3); + print("elements 8 to 10", part); + + // read_async() does not wait: the callback runs during a later poll() (or lab::tick()). + // A program that renders at the same time would not skip a beat. + bool done = false; + buffer.read_async([&done](std::span elements) { + print("read without blocking", elements); + done = true; + }); + while (!done) { + gpu.poll(); + } +} diff --git a/samples/09_multi_window.cpp b/samples/09_multi_window.cpp new file mode 100644 index 0000000..711e4d8 --- /dev/null +++ b/samples/09_multi_window.cpp @@ -0,0 +1,64 @@ +// 09: one Gpu renders into several windows +// space opens another window, closing the last window ends the program + +#include + +#include +#include + +struct Color { + float r, g, b, a; +}; + +// what belongs to one window: lab objects can be moved, so they can live in a vector +struct View { + lab::Window window; + lab::Surface surface; + lab::Buffer color; + wgpu::BindGroup bind_group = nullptr; +}; + +int main() { + lab::Gpu gpu; + std::vector views; + + auto open_window = [&] { + const int number = static_cast(views.size()) + 1; + const Color color{0.3f * float(number % 3), 0.25f * float(number % 4), 0.8f - 0.15f * float(number % 5), 1.0f}; + + lab::Window window(std::format("window {} - press space for another one", number), 480, 300); + lab::Surface surface(gpu, window); // every window needs its own surface + lab::Buffer color_buffer(gpu, {color}, wgpu::BufferUsage::Uniform); + views.push_back({std::move(window), std::move(surface), std::move(color_buffer)}); + }; + open_window(); + open_window(); + + // all surfaces of a gpu on the same desktop have the same format, so one pipeline serves them all + lab::Shader shader(gpu, "shaders/09_multi_window.wgsl"); + lab::Pipeline pipeline(gpu, shader, {.target = views.front().surface}); + + while (lab::tick()) { + bool space_pressed = false; + for (View& view : views) { + space_pressed = space_pressed || view.window.key_pressed(lab::KeyCode::space); + } + if (space_pressed) { + open_window(); + } + + // forget the windows that were closed, this destroys their surfaces and buffers as well + std::erase_if(views, [](const View& view) { return !view.window.is_open(); }); + + // one frame for all windows: every window gets a render pass, + // and all of them are shown when the frame goes out of scope + lab::Frame frame(gpu); + for (View& view : views) { + if (!view.bind_group) { + view.bind_group = pipeline.bind_group(0, {{0, view.color}}); + } + lab::RenderPass pass(frame, view.surface); + pass.draw(pipeline, {.bind_groups = {view.bind_group}, .count = 3}); + } + } +} diff --git a/samples/10_house.cpp b/samples/10_house.cpp new file mode 100644 index 0000000..6e35710 --- /dev/null +++ b/samples/10_house.cpp @@ -0,0 +1,167 @@ +// 10: a 3D scene: depth buffer, camera, lighting, a model file and an image file +// drag with the left mouse button to look around, scroll to zoom + +// glm then produces depth values from 0 to 1, as WebGPU expects them (OpenGL uses -1 to 1) +#define GLM_FORCE_DEPTH_ZERO_TO_ONE +#include +#include + +// tinygltf reads the model, the stb_image that comes with it reads the texture +#define TINYGLTF_IMPLEMENTATION +#define STB_IMAGE_IMPLEMENTATION +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include + +#include + +#include +#include +#include +#include +#include + +using enum wgpu::VertexFormat; + +struct ModelVertex { + glm::vec3 position; + glm::vec3 normal; +}; + +struct GroundVertex { + glm::vec3 position; + glm::vec2 uv; +}; + +// has to match `struct Scene` in both shaders +struct Scene { + glm::mat4 view_projection; + glm::vec4 light_direction; +}; + +struct Model { + std::vector vertices; + std::vector indices; +}; + +// Reads positions, normals and indices of the first mesh in a binary glTF file +Model load_model(const std::filesystem::path& path) { + tinygltf::TinyGLTF loader; + tinygltf::Model gltf; + std::string error, warning; + if (!loader.LoadBinaryFromFile(&gltf, &error, &warning, path.string())) { + throw lab::Error(std::format("{}: {}", path.string(), error)); + } + const tinygltf::Primitive& primitive = gltf.meshes.at(0).primitives.at(0); + + // an accessor describes a run of typed elements inside one of the binary buffers of the file + auto bytes_of = [&gltf](const tinygltf::Accessor& accessor) { + const tinygltf::BufferView& view = gltf.bufferViews.at(accessor.bufferView); + return gltf.buffers.at(view.buffer).data.data() + view.byteOffset + accessor.byteOffset; + }; + const tinygltf::Accessor& positions = gltf.accessors.at(primitive.attributes.at("POSITION")); + const tinygltf::Accessor& normals = gltf.accessors.at(primitive.attributes.at("NORMAL")); + const tinygltf::Accessor& indices = gltf.accessors.at(primitive.indices); + if (indices.componentType != TINYGLTF_COMPONENT_TYPE_UNSIGNED_SHORT) { + throw lab::Error(std::format("{}: this sample only reads 16 bit indices", path.string())); + } + + Model model; + model.vertices.resize(positions.count); + for (size_t i = 0; i < positions.count; ++i) { + std::memcpy(&model.vertices[i].position, bytes_of(positions) + i * sizeof(glm::vec3), sizeof(glm::vec3)); + std::memcpy(&model.vertices[i].normal, bytes_of(normals) + i * sizeof(glm::vec3), sizeof(glm::vec3)); + } + model.indices.resize(indices.count); + std::memcpy(model.indices.data(), bytes_of(indices), indices.count * sizeof(uint16_t)); + return model; +} + +// Reads an image file into a texture +lab::Texture load_texture(lab::Gpu& gpu, const std::filesystem::path& path) { + int width, height, channels; + stbi_uc* pixels = stbi_load(path.string().c_str(), &width, &height, &channels, 4); // 4: always as RGBA + if (!pixels) { + throw lab::Error(std::format("{}: {}", path.string(), stbi_failure_reason())); + } + lab::Texture texture(gpu, wgpu::TextureFormat::RGBA8Unorm, width, height); + texture.write(std::span{reinterpret_cast(pixels), size_t(width) * height}); + stbi_image_free(pixels); + return texture; +} + +int main() { + lab::Gpu gpu; + lab::Window window("drag to look around, scroll to zoom", 900, 600); + + // with a depth buffer, near surfaces hide far ones, whatever the order they are drawn in + lab::Surface surface(gpu, window, {.depth = wgpu::TextureFormat::Depth24Plus}); + + // the house + const Model model = load_model(lab::find_file("assets/house.glb")); + lab::Buffer model_vertices(gpu, model.vertices); + lab::Buffer model_indices(gpu, model.indices, wgpu::BufferUsage::Index); + + // the ground: two triangles, the texture is repeated 12 times across them + const float size = 6.0f, repeat = 12.0f; + std::vector ground = { + {{-size, 0.0f, -size}, {0.0f, 0.0f}}, {{+size, 0.0f, -size}, {repeat, 0.0f}}, + {{+size, 0.0f, +size}, {repeat, repeat}}, {{-size, 0.0f, -size}, {0.0f, 0.0f}}, + {{+size, 0.0f, +size}, {repeat, repeat}}, {{-size, 0.0f, +size}, {0.0f, repeat}}, + }; + lab::Buffer ground_vertices(gpu, ground); + lab::Texture ground_texture = load_texture(gpu, lab::find_file("assets/ground.png")); + + // camera and light are the same for both pipelines + Scene scene{}; + lab::Buffer scene_buffer(gpu, {scene}, wgpu::BufferUsage::Uniform); + + // the surface has a depth buffer, so pipelines made for it test against it + lab::Shader model_shader(gpu, "shaders/10_house.wgsl"); + lab::Pipeline model_pipeline( + gpu, model_shader, {.vertex_buffers = {lab::vertex({Float32x3, Float32x3})}, .target = surface}); + + lab::Shader ground_shader(gpu, "shaders/10_house_ground.wgsl"); + lab::Pipeline ground_pipeline( + gpu, ground_shader, {.vertex_buffers = {lab::vertex({Float32x3, Float32x2})}, .target = surface}); + + lab::Draw draw_model{ + .vertex_buffers = {model_vertices}, + .index_buffer = model_indices, + .bind_groups = {model_pipeline.bind_group(0, {{0, scene_buffer}})}, + }; + lab::Draw draw_ground{ + .vertex_buffers = {ground_vertices}, + .bind_groups = {ground_pipeline.bind_group(0, {{0, scene_buffer}, {1, ground_texture}, {2, lab::sampler(gpu)}})}, + }; + + // the camera circles around a point above the ground + float yaw = 0.6f, pitch = 0.35f, distance = 7.0f; + + while (lab::tick()) { + if (window.key_pressed(lab::KeyCode::escape)) { + window.close(); + } + + if (window.mouse_button(lab::MouseButton::left)) { + yaw -= window.mouse_delta().x * 0.005f; + pitch = std::clamp(pitch + window.mouse_delta().y * 0.005f, 0.05f, 1.5f); + } else { + yaw += 0.2f * lab::delta_seconds(); // left alone, the camera keeps circling slowly + } + distance = std::clamp(distance - window.scroll().y * 0.5f, 3.0f, 15.0f); + + const glm::vec3 target{0.0f, 1.0f, 0.0f}; + const glm::vec3 eye = target + distance * glm::vec3{std::cos(pitch) * std::sin(yaw), std::sin(pitch), + std::cos(pitch) * std::cos(yaw)}; + const glm::mat4 view = glm::lookAt(eye, target, glm::vec3{0.0f, 1.0f, 0.0f}); + const glm::mat4 projection = glm::perspective(glm::radians(45.0f), surface.aspect(), 0.1f, 100.0f); + + scene.view_projection = projection * view; + scene.light_direction = glm::vec4{glm::normalize(glm::vec3{-0.5f, -1.0f, -0.3f}), 0.0f}; + scene_buffer.write(scene); + + lab::RenderPass pass(surface, {.clear = wgpu::Color{0.55, 0.70, 0.90, 1.0}}); // the sky + pass.draw(ground_pipeline, draw_ground); + pass.draw(model_pipeline, draw_model); + } +} diff --git a/samples/11_boids.cpp b/samples/11_boids.cpp new file mode 100644 index 0000000..84e5346 --- /dev/null +++ b/samples/11_boids.cpp @@ -0,0 +1,95 @@ +// 11: a compute shader simulates a flock, the result is drawn in the same frame + +#include + +#include + +#include +#include +#include + +using enum wgpu::VertexFormat; + +// has to match `struct Boid` in the shaders +struct Boid { + glm::vec2 position; + glm::vec2 velocity; +}; + +// has to match `struct Params` in the compute shader +struct Params { + float delta_time = 0.0f; + float cohesion_distance = 0.10f; + float separation_distance = 0.025f; + float alignment_distance = 0.06f; + float cohesion_scale = 0.01f; + float separation_scale = 0.05f; + float alignment_scale = 0.03f; +}; + +int main() { + lab::Gpu gpu; + lab::Window window("Boids", 700, 700); + lab::Surface surface(gpu, window); + + // a flock of boids at random places, flying in random directions + const uint32_t boid_count = 2000; + std::mt19937 random(42); + std::uniform_real_distribution anywhere(-1.0f, 1.0f); + std::vector boids(boid_count); + for (Boid& boid : boids) { + boid.position = {anywhere(random), anywhere(random)}; + boid.velocity = 0.1f * glm::vec2{anywhere(random), anywhere(random)}; + } + + // Two buffers: each step of the simulation reads one and writes the other, then they + // swap roles. The compute shader sees them as storage buffers, the render pipeline + // reads the very same buffers as per-instance vertex buffers. + const wgpu::BufferUsage usage = wgpu::BufferUsage::Storage | wgpu::BufferUsage::Vertex; + lab::Buffer boid_buffers[2] = {lab::Buffer(gpu, boids, usage), lab::Buffer(gpu, boids, usage)}; + + Params params; + lab::Buffer params_buffer(gpu, {params}, wgpu::BufferUsage::Uniform); + + // the simulation + lab::Shader simulate_shader(gpu, "shaders/11_boids_simulate.wgsl"); + lab::ComputePipeline simulate(gpu, simulate_shader); + + // one bind group per direction: [0] reads buffer 0 and writes buffer 1, [1] the other way round + const wgpu::BindGroup step[2] = { + simulate.bind_group(0, {{0, params_buffer}, {1, boid_buffers[0]}, {2, boid_buffers[1]}}), + simulate.bind_group(0, {{0, params_buffer}, {1, boid_buffers[1]}, {2, boid_buffers[0]}}), + }; + + // drawing: a small triangle per boid + std::vector boid_shape = {{-0.008f, -0.016f}, {0.008f, -0.016f}, {0.0f, 0.016f}}; + lab::Buffer shape(gpu, boid_shape); + + lab::Shader draw_shader(gpu, "shaders/11_boids_draw.wgsl"); + lab::Pipeline draw_pipeline( + gpu, draw_shader, + {.vertex_buffers = {lab::vertex({Float32x2}), lab::instance({Float32x2, Float32x2})}, + .target = surface}); + + uint32_t latest = 0; // which of the two buffers holds the current state + + while (lab::tick()) { + if (window.key_pressed(lab::KeyCode::escape)) { + window.close(); + } + + params.delta_time = std::min(lab::delta_seconds(), 0.05f); // a hiccup must not make the boids jump + params_buffer.write(params); + + // a frame with two passes: first compute the next state, then draw it + lab::Frame frame(gpu); + { + lab::ComputePass compute(frame); + compute.dispatch(simulate, {step[latest]}, (boid_count + 63) / 64); // in workgroups of 64 boids + } + latest = 1 - latest; + + lab::RenderPass pass(frame, surface); + pass.draw(draw_pipeline, {.vertex_buffers = {shape, boid_buffers[latest]}}); + } +} diff --git a/samples/12_offscreen.cpp b/samples/12_offscreen.cpp new file mode 100644 index 0000000..91d6a87 --- /dev/null +++ b/samples/12_offscreen.cpp @@ -0,0 +1,49 @@ +// 12: rendering into a texture and using it in a second pass +// the scene is rendered with few pixels and shown magnified, press S to save it as an image + +#include + +#include + +int main() { + lab::Gpu gpu; + lab::Window window("offscreen - press S to save the small image", 960, 600); + lab::Surface surface(gpu, window); + + // a texture can be rendered into just like a surface + lab::Texture small_image(gpu, wgpu::TextureFormat::RGBA8Unorm, 96, 60); + + // the scene, a pipeline made for the texture + lab::Buffer time_buffer(gpu, {0.0f}, wgpu::BufferUsage::Uniform); + lab::Shader scene_shader(gpu, "shaders/12_offscreen_scene.wgsl"); + lab::Pipeline scene_pipeline(gpu, scene_shader, {.target = small_image}); + lab::Draw draw_scene{.bind_groups = {scene_pipeline.bind_group(0, {{0, time_buffer}})}, .count = 3}; + + // showing the texture in the window, a pipeline made for the surface + // (a "nearest" sampler does not blend between pixels, so they stay visible as blocks) + lab::Shader show_shader(gpu, "shaders/12_offscreen_show.wgsl"); + lab::Pipeline show_pipeline(gpu, show_shader, {.target = surface}); + wgpu::Sampler blocky = lab::sampler(gpu, {.filter = wgpu::FilterMode::Nearest}); + lab::Draw draw_image{.bind_groups = {show_pipeline.bind_group(0, {{0, small_image}, {1, blocky}})}, .count = 3}; + + while (lab::tick()) { + if (window.key_pressed(lab::KeyCode::escape)) { + window.close(); + } + if (window.key_pressed(lab::KeyCode::S)) { + small_image.save_png("offscreen.png"); // what the previous frame rendered into it + std::cout << "saved offscreen.png" << std::endl; + } + + time_buffer.write(lab::elapsed_seconds()); + + // one frame, two passes: the first one renders into the texture, the second one uses it + lab::Frame frame(gpu); + { + lab::RenderPass scene_pass(frame, small_image, {.clear = wgpu::Color{0.10, 0.10, 0.20, 1.0}}); + scene_pass.draw(scene_pipeline, draw_scene); + } + lab::RenderPass window_pass(frame, surface); + window_pass.draw(show_pipeline, draw_image); + } +} diff --git a/samples/CMakeLists.txt b/samples/CMakeLists.txt new file mode 100644 index 0000000..be55b3a --- /dev/null +++ b/samples/CMakeLists.txt @@ -0,0 +1,94 @@ +# libraries that only the samples use ------------------------------------------ +# Both are used header-only, so they are only downloaded: SOURCE_SUBDIR points at a +# directory without a CMakeLists.txt, which keeps their own build scripts out of ours. +FetchContent_Declare(glm + GIT_REPOSITORY https://github.com/g-truc/glm + GIT_TAG 1.0.1 + GIT_SHALLOW ON + SOURCE_SUBDIR headers-only) +FetchContent_Declare(tinygltf + GIT_REPOSITORY https://github.com/syoyo/tinygltf + GIT_TAG v2.9.3 + GIT_SHALLOW ON + SOURCE_SUBDIR headers-only) +FetchContent_MakeAvailable(glm tinygltf) + +add_library(lab-sample-libs INTERFACE) +target_include_directories(lab-sample-libs SYSTEM INTERFACE ${glm_SOURCE_DIR} ${tinygltf_SOURCE_DIR}) + +# one executable per .cpp file in this directory -------------------------------- +# (add your own .cpp here and re-run the CMake configure step) +file(GLOB SAMPLE_FILES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) +set(SAMPLE_NAMES) +foreach(SAMPLE_FILE IN LISTS SAMPLE_FILES) + get_filename_component(SAMPLE_NAME ${SAMPLE_FILE} NAME_WE) + if(EMSCRIPTEN AND SAMPLE_NAME STREQUAL "09_multi_window") + continue() # a browser page has one canvas + endif() + list(APPEND SAMPLE_NAMES ${SAMPLE_NAME}) + add_executable(${SAMPLE_NAME} ${SAMPLE_FILE}) + target_link_libraries(${SAMPLE_NAME} PRIVATE lab::lab lab-sample-libs) + # executables go to the top of the build directory, next to shaders/ and assets/ + set_target_properties(${SAMPLE_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) + lab_target_defaults(${SAMPLE_NAME}) + + if(EMSCRIPTEN) + # a web page per sample, in site/, with the shaders and assets packed into it + set_target_properties(${SAMPLE_NAME} PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/site + SUFFIX .html) + target_link_options(${SAMPLE_NAME} PRIVATE + # the blocking calls of the lab (waiting for the adapter, reading buffers back) and + # the `while (lab::tick())` loop need the program to be able to pause for the browser + -sASYNCIFY=1 + -sASYNCIFY_STACK_SIZE=65536 + -sALLOW_MEMORY_GROWTH=1 + "--shell-file=${CMAKE_SOURCE_DIR}/web/shell.html" + "--preload-file=${CMAKE_CURRENT_SOURCE_DIR}/shaders@/shaders" + "--preload-file=${CMAKE_CURRENT_SOURCE_DIR}/assets/house.glb@/assets/house.glb" + "--preload-file=${CMAKE_CURRENT_SOURCE_DIR}/assets/ground.png@/assets/ground.png") + set_property(TARGET ${SAMPLE_NAME} APPEND PROPERTY LINK_DEPENDS ${CMAKE_SOURCE_DIR}/web/shell.html) + endif() + + # Every sample doubles as a smoke test: it has to run 60 frames without reporting an error. + # These tests open windows, so they carry the label "display": `ctest -LE display` skips them. + if(LAB_BUILD_TESTS) + add_test(NAME sample.${SAMPLE_NAME} COMMAND ${SAMPLE_NAME} WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) + set_tests_properties(sample.${SAMPLE_NAME} PROPERTIES + LABELS display + ENVIRONMENT LAB_EXIT_AFTER_FRAMES=60 + FAIL_REGULAR_EXPRESSION "lab \\[(warn|error)\\]" + TIMEOUT 30 + RUN_SERIAL ON) + endif() +endforeach() + +# the gallery page that links to all samples (web only) --------------------------- +if(EMSCRIPTEN) + set(LAB_GALLERY_ITEMS "") + foreach(SAMPLE_NAME IN LISTS SAMPLE_NAMES) + set(IMAGE "${CMAKE_SOURCE_DIR}/docs/images/${SAMPLE_NAME}.png") + if(EXISTS ${IMAGE}) + configure_file(${IMAGE} ${CMAKE_BINARY_DIR}/site/images/${SAMPLE_NAME}.png COPYONLY) + set(PICTURE "\"\"") + else() + set(PICTURE "

") + endif() + string(APPEND LAB_GALLERY_ITEMS " ${PICTURE}${SAMPLE_NAME}\n") + endforeach() + configure_file(${CMAKE_SOURCE_DIR}/web/index.html.in ${CMAKE_BINARY_DIR}/site/index.html @ONLY) +endif() + +# copy shader and asset files to the build directory ---------------------------- +# (a sample looks for its files in the working directory first, then next to its executable) +file(GLOB SHADER_FILES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/shaders/*.wgsl) +foreach(SHADER_FILE IN LISTS SHADER_FILES) + get_filename_component(SHADER_FILENAME ${SHADER_FILE} NAME) + configure_file(${SHADER_FILE} ${CMAKE_BINARY_DIR}/shaders/${SHADER_FILENAME} COPYONLY) +endforeach() + +file(GLOB ASSET_FILES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/assets/*) +foreach(ASSET_FILE IN LISTS ASSET_FILES) + get_filename_component(ASSET_FILENAME ${ASSET_FILE} NAME) + configure_file(${ASSET_FILE} ${CMAKE_BINARY_DIR}/assets/${ASSET_FILENAME} COPYONLY) +endforeach() diff --git a/samples/assets/ground.png b/samples/assets/ground.png new file mode 100644 index 0000000..07d7dc5 Binary files /dev/null and b/samples/assets/ground.png differ diff --git a/samples/assets/house.blend1 b/samples/assets/house.blend1 deleted file mode 100644 index 77d826e..0000000 Binary files a/samples/assets/house.blend1 and /dev/null differ diff --git a/samples/sample_buffer.cpp b/samples/sample_buffer.cpp deleted file mode 100644 index 30d8d8a..0000000 --- a/samples/sample_buffer.cpp +++ /dev/null @@ -1,56 +0,0 @@ -#include - -using namespace std; -using namespace lab; - -int main() { - Webgpu webgpu("My Instance"); - - // Multi-threaded reading and writing onto mapped GPU memory ---------------- - cout << "\n\nWriting Buffer...\n"; - Buffer buffer("My Buffer", webgpu); - { - bool writing_done = false; - auto init_buffer = [&writing_done](MappedVRAM vmap) { - int start_point = 6; - vmap.resize(start_point); - for (int i = 0; i < 10; ++i) { - cout << vmap.push(i + 5) << " "; // stack numbers on the buffer - sleep(100ms); - } - cout << "\nbuffer initialized" << endl; - writing_done = true; - }; - auto thread = buffer.to_device(init_buffer, 256, wgpu::BufferUsage::MapRead); - - cout << "Main thread waiting..."; - while (!writing_done) { - cout << '.'; - sleep(50ms); - } - } - - // Read the buffer back to CPU ----------------------------------------------- - { - bool reading_done = false; - cout << "\n\nReading Buffer...\n"; - auto read_buffer = [&reading_done](MappedVRAM vmap) { - for (auto e : vmap) { - cout << e << " "; // print numbers received from gpu - sleep(100ms); - } - cout << "\nbuffer read" << endl; - vmap.unmap(); - reading_done = true; - }; - auto thread = buffer.from_device(2, 20, read_buffer); - - cout << "Main thread waiting..."; - while (!reading_done) { - cout << "."; - sleep(200ms); - } - } - - cout << "\nFIN !!!\n" << endl; -} diff --git a/samples/sample_dynamic_instances.cpp b/samples/sample_dynamic_instances.cpp deleted file mode 100644 index c6c35ea..0000000 --- a/samples/sample_dynamic_instances.cpp +++ /dev/null @@ -1,125 +0,0 @@ -#include -#include - -int main() { - struct MyInstancePos { - glm::vec2 pos; - }; - - struct MyVertexFormat { - glm::vec2 pos; - glm::vec2 uv; - }; - - struct MyPixelFormat { - uint8_t r, g, b, a; - }; - - struct MyUniformData { - glm::vec2 ratio; - float time, scale; - }; - - struct MyInputState { - bool arrows[4]; - glm::vec2 axis; - uint32_t current_instance; - void update_axis(lab::KeyCode key, bool state) { - arrows[static_cast(key) - 262u] = state; - axis.x = static_cast(arrows[0] - arrows[1]); - axis.y = static_cast(arrows[3] - arrows[2]); - } - }; - - // --------------------------------------------------------------------------- - // Window and controls ------------------------------------------------------- - MyInputState input{}; - lab::Window window("Press arrow keys to move and space to cycle through", 640, 400); - window.set_key_callback([&window, &input](lab::KeyEvent event) { - if (event.action != lab::KeyAction::repeat) { - if (event.key >= lab::KeyCode::right && event.key <= lab::KeyCode::up) { - input.update_axis(event.key, event.action == lab::KeyAction::press); - } else if (event.key == lab::KeyCode::space && event.action == lab::KeyAction::release) { - ++input.current_instance; - } else if (event.key == lab::KeyCode::escape) { - window.close(); - } - } - }); - - // --------------------------------------------------------------------------- - // Webgpu Setup -------------------------------------------------------------- - lab::Webgpu webgpu("My Instance"); - lab::Surface surface(window, webgpu); - - lab::Shader shader("My Shader", "shaders/sample_dynamic_instances.wgsl"); - lab::Pipeline pipeline(shader, webgpu); - - lab::Texture texture(webgpu, wgpu::TextureFormat::RGBA8Unorm, 256, 256); - - // create procedural texture and upload to gpu - std::vector pixel_data; - pixel_data.reserve(texture.width() * texture.height()); - - auto transform = [](int v, float s = 1.f) { - return uint8_t(s * (20.f + uint8_t(sin(float(v) / 255.f * 20.f) + 1.f) * 40.f)); - }; - - for (int y = 0; y < texture.height(); ++y) { - for (int x = 0; x < texture.width(); ++x) { - pixel_data.emplace_back(transform(x + y, .6f), transform(x, .3f), transform(y, .2f), uint8_t(255u)); - } - } - texture.to_device(pixel_data); - pipeline.add_texture(texture, 1, wgpu::ShaderStage::Fragment); - - // create vertex buffer with 3 vertices which form an equilateral triangle - std::vector vertex_data = {{.pos{0.f, 1.f}, .uv{0.f, 0.f}}, - {.pos{-sqrtf(3.f) / 2.f, -3.f / 6.f}, .uv{0.f, 1.f}}, - {.pos{+sqrtf(3.f) / 2.f, -3.f / 6.f}, .uv{1.f, 1.f}}}; - lab::Buffer vertex_buffer("My vertex buffer", vertex_data, webgpu); - - // positions of triangle instances - std::vector instance_data = {{{0.f, 0.f}}, {{.5f, .5f}}, {{-.2f, .4f}}, {{-.7f, -.2f}}, {{.5f, -.6f}}}; - lab::Buffer instance_buffer("My instance buffer", instance_data, - wgpu::BufferUsage::Vertex | wgpu::BufferUsage::CopyDst, webgpu); - - pipeline.add_vertex_buffer(vertex_buffer); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); // position - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 1); // uv - - pipeline.add_vertex_buffer(instance_buffer, wgpu::VertexStepMode::Instance); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 2); // instance_position - - MyUniformData uniforms{.ratio = {window.ratio(), 1.f}, .scale = .2f}; - lab::Buffer uniform_buffer("My uniform buffer", {uniforms}, - wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst, webgpu); - - pipeline.add_uniform_buffer(uniform_buffer, 0, wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment); - pipeline.finalize(); - - // --------------------------------------------------------------------------- - // Main loop ----------------------------------------------------------------- - const float force = 4.f, friction = 1.f; - glm::vec2 velocity = {0.f, 0.f}; - float deltatime = 0.f; - - float t0 = lab::elapsed_seconds(); - uniforms.time = t0; - - while (lab::tick()) { - pipeline.render_frame(surface, int(vertex_data.size()), int(instance_data.size())); - - const int i = input.current_instance % instance_data.size(); - velocity = velocity * (1.f - friction * deltatime) + input.axis * force * deltatime; - instance_data[i].pos += velocity * deltatime; - instance_buffer.write(instance_data[i], i); - - uniforms.ratio = {window.ratio(), 1.f}; - uniform_buffer.write(uniforms); - - uniforms.time = lab::elapsed_seconds(); - deltatime = uniforms.time - t0; - t0 = uniforms.time; - } -} diff --git a/samples/sample_graph_visualization.cpp b/samples/sample_graph_visualization.cpp deleted file mode 100644 index 1eb7b1e..0000000 --- a/samples/sample_graph_visualization.cpp +++ /dev/null @@ -1,168 +0,0 @@ -#include - -#include - -#include - -struct NodeInstance { - glm::vec2 pos; - float scale; -}; - -struct EdgeInstance { - glm::vec2 pos_a, pos_b; - float scale; -}; - -struct alignas(16) UniformParams { - glm::vec2 ratio; - float time; -}; - -int main() { - lab::Webgpu webgpu("wgpu context"); - - lab::Shader node_shader("node shader", "shaders/sample_graph.wgsl"); - lab::Pipeline node_pipeline(node_shader, webgpu); - - lab::Shader edge_shader("edge shader", "shaders/sample_graph_edge_shader.wgsl"); - lab::Pipeline edge_pipeline(edge_shader, webgpu); - - const float k = 2.0f * sqrt(3.0f) / 3.0f; - std::vector mesh{ - {-k * 0.5f, 1.0}, {-k, 0.0f}, {-k * 0.5f, -1.0}, // left - {k * 0.5f, -1.0}, {k, 0.0f}, {k * 0.5f, 1.0}, // right - }; - std::vector mesh_indices{ - 0, 1, 2, // left tri - 0, 2, 3, // center tri 1 - 3, 5, 0, // center tri 2 - 3, 4, 5, // right tri - }; - - lab::Buffer mesh_vertex_buffer("mesh vertex buffer", mesh, webgpu); - lab::Buffer mesh_index_buffer("mesh index buffer", mesh_indices, wgpu::BufferUsage::Index, webgpu); - - node_pipeline.add_vertex_buffer(mesh_vertex_buffer); - node_pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); - - node_pipeline.add_index_buffer(mesh_index_buffer, wgpu::IndexFormat::Uint16); - - edge_pipeline.add_vertex_buffer(mesh_vertex_buffer); - edge_pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); - - edge_pipeline.add_index_buffer(mesh_index_buffer, wgpu::IndexFormat::Uint16); - // TODO: add mechanism to avoid duplicate setups like above: - // idea: add_vertex_buffer({node_pipe, edge_pipe}, mesh_vertex_buffer); - - // std::vector node_instances; - // node_instances.reserve(1000); - - // std::default_random_engine prng{0}; - // for (int i = 0; i < node_instances.capacity(); ++i) { - // std::normal_distribution dst_pos{0.0f, 0.2f}; - // std::normal_distribution dst_scl{0.02f, 0.005f}; - // node_instances.push_back({.pos = {dst_pos(prng), dst_pos(prng)}, .scale = dst_scl(prng)}); - // } - std::vector node_instances; - node_instances.push_back({.pos = {0.0f, 0.0f}, .scale = 1.0f}); - lab::Buffer node_instance_buffer("node instance buffer", node_instances, webgpu); - - node_pipeline.add_vertex_buffer(node_instance_buffer, wgpu::VertexStepMode::Instance); - node_pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 1); - node_pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32, 2); - - std::vector edge_instances; - edge_instances.push_back({.pos_a = {0.2f, 0.3f}, .pos_b = {0.8f, 0.5f}, .scale = 0.2f}); - - lab::Buffer edge_instance_buffer("edge instance buffer", edge_instances, webgpu); - - edge_pipeline.add_vertex_buffer(edge_instance_buffer, wgpu::VertexStepMode::Instance); - edge_pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 1); - edge_pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 2); - edge_pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32, 3); - - lab::Window window("graph visualizer", 900, 600); - lab::Surface surface(window, webgpu); - - UniformParams uniform_buffer{.ratio{window.ratio(), 1.0}}; - lab::Buffer node_uniform_buffer("node uniform buffer", {uniform_buffer}, - wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst, webgpu); - - node_pipeline.add_uniform_buffer(node_uniform_buffer, 0, wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment); - node_pipeline.finalize(); - - edge_pipeline.add_uniform_buffer(node_uniform_buffer, 0, wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment); - edge_pipeline.finalize(); - - window.set_key_callback([&window](auto event) { - if (event.key == lab::KeyCode::escape) window.close(); - }); - - window.set_resize_callback([&uniform_buffer, &node_uniform_buffer, &window, &surface](int w, int h) { - std::cout << "window resized to: " << w << "x" << h << "\n"; - uniform_buffer.ratio.x = static_cast(h) / w; - node_uniform_buffer.write(uniform_buffer); - surface.reconfigure(w, h); - }); - - while (lab::tick()) { - wgpu::TextureView targetView = lab::get_current_render_texture_view(surface.wgpu_surface); - - wgpu::CommandEncoderDescriptor encoderDesc = {.label = "my command encoder"}; - wgpu::CommandEncoder encoder = node_pipeline.webgpu.device.CreateCommandEncoder(&encoderDesc); - - node_pipeline.render_config.renderPassColorAttachment.view = targetView; - wgpu::RenderPassDescriptor renderPassDesc{ - .label = "my render pass", - .colorAttachmentCount = 1, - .colorAttachments = &node_pipeline.render_config.renderPassColorAttachment, - }; - - wgpu::RenderPassEncoder renderPass = encoder.BeginRenderPass(&renderPassDesc); - renderPass.SetPipeline(node_pipeline.wgpu_pipeline); - - for (uint32_t i = 0; i < node_pipeline.vb_configs.size(); ++i) { - renderPass.SetVertexBuffer(i, node_pipeline.vb_configs[i].buffer, node_pipeline.vb_configs[i].offset, - node_pipeline.vb_configs[i].buffer.GetSize()); - } - - for (uint32_t i = 0; i < node_pipeline.ib_configs.size(); ++i) { - const auto& ibc = node_pipeline.ib_configs[i]; - renderPass.SetIndexBuffer(ibc.buffer, ibc.format, ibc.offset, ibc.buffer.GetSize()); - } - - for (uint32_t i = 0; i < node_pipeline.bindGroups.size(); ++i) { - renderPass.SetBindGroup(i, node_pipeline.bindGroups[i], 0, nullptr); - } - - renderPass.DrawIndexed(uint32_t(mesh_indices.size()), uint32_t(node_instances.size())); - - renderPass.SetPipeline(edge_pipeline.wgpu_pipeline); - - for (uint32_t i = 0; i < edge_pipeline.vb_configs.size(); ++i) { - renderPass.SetVertexBuffer(i, edge_pipeline.vb_configs[i].buffer, edge_pipeline.vb_configs[i].offset, - edge_pipeline.vb_configs[i].buffer.GetSize()); - } - - for (uint32_t i = 0; i < edge_pipeline.ib_configs.size(); ++i) { - const auto& ibc = edge_pipeline.ib_configs[i]; - renderPass.SetIndexBuffer(ibc.buffer, ibc.format, ibc.offset, ibc.buffer.GetSize()); - } - - for (uint32_t i = 0; i < edge_pipeline.bindGroups.size(); ++i) { - renderPass.SetBindGroup(i, edge_pipeline.bindGroups[i], 0, nullptr); - } - - renderPass.DrawIndexed(uint32_t(mesh_indices.size()), uint32_t(edge_instances.size())); - - renderPass.End(); - - wgpu::CommandBufferDescriptor cmdBufferDescriptor = {.label = "my command buffer"}; - wgpu::CommandBuffer commands = encoder.Finish(&cmdBufferDescriptor); - - webgpu.queue.Submit(1, &commands); - - surface.wgpu_surface.Present(); - } -} diff --git a/samples/sample_graph_visualization2.cpp b/samples/sample_graph_visualization2.cpp deleted file mode 100644 index 3ca1d41..0000000 --- a/samples/sample_graph_visualization2.cpp +++ /dev/null @@ -1,169 +0,0 @@ -#include - -#include - -#include - -struct Vertex { - glm::vec2 pos; - float side; -}; - -struct NodeInstance { - glm::vec2 pos; - float scale; -}; - -struct alignas(16) UniformParams { - glm::vec2 ratio; - float time; -}; - -int main() { - lab::Webgpu webgpu("wgpu context"); - - lab::Shader node_shader("node shader"); - node_shader.source = R"( - struct Uniforms { - ratio : vec2f, time : f32, - }; - - struct Vertex { - @location(0) pos : vec2f, - @location(1) side : f32, - }; - - struct Instance { - @location(2) pos : vec2f, - @location(3) scale : f32, - }; - - struct VsOutput { - @builtin(position) position : vec4f, - @location(0) pos : vec2f, - @location(1) side: f32, - }; - - @group(0) @binding(0) var uniforms : Uniforms; - - @vertex fn vs_main(vtx: Vertex, ins: Instance) -> VsOutput { - var out : VsOutput; - var offset = vec2f(0.0); - if (vtx.side > 0.0) { - offset.x += 0.4; - } - out.position = vec4f(((ins.pos + vtx.pos*ins.scale) + offset)*uniforms.ratio, 0.0, 1.0); - out.pos = vtx.pos; - out.side = vtx.side; - return out; - } - - @fragment fn fs_main(in : VsOutput) -> @location(0) vec4f { - let intensity = pow(1.0 - length(in.pos), 2.0); - - // // colorize sides - // if (intensity <= 0.02) { - // return vec4f(0.2, 0.0, 0.0, 0.2); - // } - // return vec4f(0.2 + intensity * 0.8)*(vec4f(1.0, 0.7, 0.7, 1.0) * max(in.side, 0.4)) + - // vec4f(0.2 + intensity * 0.8)*(vec4f(0.7, 0.7, 1.0, 1.0) * max(-in.side, 0.4)); - - return vec4f(intensity); - } - )"; - - lab::Pipeline node_pipeline(node_shader, webgpu); - - const float k = sqrt(3.0f) / 3.0f; - std::vector mesh{ - {{-1.0f, k}, -1.0}, {{-1.0f, -k}, -1.0}, {{0.0f, -2 * k}, -1.0}, {{0.0f, 2 * k}, -1.0}, // left quad - {{0.0f, 2 * k}, 1.0}, {{0.0f, -2 * k}, 1.0}, {{1.0f, -k}, 1.0}, {{1.0f, k}, 1.0}, // right quad - }; - - std::vector mesh_indices{ - 0, 1, 2, 2, 3, 0, // left quad - 3, 2, 5, 5, 4, 3, // central quad - 4, 5, 6, 6, 7, 4, // right quad - }; - - lab::Buffer mesh_vertex_buffer("mesh vertex buffer", mesh, webgpu); - lab::Buffer mesh_index_buffer("mesh index buffer", mesh_indices, wgpu::BufferUsage::Index, webgpu); - - node_pipeline.add_vertex_buffer(mesh_vertex_buffer); - node_pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); - node_pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32, 1); - - node_pipeline.add_index_buffer(mesh_index_buffer, wgpu::IndexFormat::Uint16); - - std::vector node_instances; - node_instances.push_back({.pos = {0.0f, 0.0f}, .scale = 0.08f}); - - lab::Buffer node_instance_buffer("node instance buffer", node_instances, webgpu); - - node_pipeline.add_vertex_buffer(node_instance_buffer, wgpu::VertexStepMode::Instance); - node_pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 2); - node_pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32, 3); - - lab::Window window("graph visualizer", 900, 600); - lab::Surface surface(window, webgpu); - - UniformParams uniform_params{.ratio{window.ratio(), 1.0}}; - lab::Buffer uniform_buffer("node uniform buffer", {uniform_params}, - wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst, webgpu); - - node_pipeline.add_uniform_buffer(uniform_buffer, 0, wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment); - node_pipeline.finalize(); - - window.set_key_callback([&window](auto event) { - if (event.key == lab::KeyCode::escape) window.close(); - }); - - window.set_resize_callback([&uniform_params, &uniform_buffer, &window, &surface](int w, int h) { - std::cout << "window resized to: " << w << "x" << h << "\n"; - uniform_params.ratio.x = static_cast(h) / w; - uniform_buffer.write(uniform_params); - surface.reconfigure(w, h); - }); - - while (lab::tick()) { - wgpu::TextureView targetView = lab::get_current_render_texture_view(surface.wgpu_surface); - - wgpu::CommandEncoderDescriptor encoderDesc = {.label = "my command encoder"}; - wgpu::CommandEncoder encoder = node_pipeline.webgpu.device.CreateCommandEncoder(&encoderDesc); - - node_pipeline.render_config.renderPassColorAttachment.view = targetView; - wgpu::RenderPassDescriptor renderPassDesc{ - .label = "my render pass", - .colorAttachmentCount = 1, - .colorAttachments = &node_pipeline.render_config.renderPassColorAttachment, - }; - - wgpu::RenderPassEncoder renderPass = encoder.BeginRenderPass(&renderPassDesc); - renderPass.SetPipeline(node_pipeline.wgpu_pipeline); - - for (uint32_t i = 0; i < node_pipeline.vb_configs.size(); ++i) { - renderPass.SetVertexBuffer(i, node_pipeline.vb_configs[i].buffer, node_pipeline.vb_configs[i].offset, - node_pipeline.vb_configs[i].buffer.GetSize()); - } - - for (uint32_t i = 0; i < node_pipeline.ib_configs.size(); ++i) { - const auto& ibc = node_pipeline.ib_configs[i]; - renderPass.SetIndexBuffer(ibc.buffer, ibc.format, ibc.offset, ibc.buffer.GetSize()); - } - - for (uint32_t i = 0; i < node_pipeline.bindGroups.size(); ++i) { - renderPass.SetBindGroup(i, node_pipeline.bindGroups[i], 0, nullptr); - } - - renderPass.DrawIndexed(uint32_t(mesh_indices.size()), uint32_t(node_instances.size())); - - renderPass.End(); - - wgpu::CommandBufferDescriptor cmdBufferDescriptor = {.label = "my command buffer"}; - wgpu::CommandBuffer commands = encoder.Finish(&cmdBufferDescriptor); - - webgpu.queue.Submit(1, &commands); - - surface.wgpu_surface.Present(); - } -} diff --git a/samples/sample_instancing.cpp b/samples/sample_instancing.cpp deleted file mode 100644 index 22d1a59..0000000 --- a/samples/sample_instancing.cpp +++ /dev/null @@ -1,48 +0,0 @@ -#include - -#include - -struct Position { - float x, y; -}; - -std::vector make_more(const std::vector& A, const std::vector& B) { - std::vector result; - for (auto& b : B) { - for (auto& a : A) { - result.push_back({a.x + b.x, a.y + b.y}); - } - } - return result; -} - -int main() { - lab::Webgpu webgpu("My Instance"); - lab::Shader shader("My Shader", "shaders/test_instancing.wgsl"); - lab::Pipeline pipeline(shader, webgpu); - - lab::Window window("Instancing Experiment", 512, 512); - lab::Surface surface(window, webgpu); - - std::vector vertex_data{{0.f, 1.f}, {-sqrtf(3.f) / 2.f, -3.f / 6.f}, {sqrtf(3.f) / 2.f, -3.f / 6.f}}; - - auto more = make_more(vertex_data, vertex_data); - auto even_more = make_more(more, vertex_data); - auto instance_data = make_more(even_more, vertex_data); - - lab::Buffer vertex_buffer("My vertex buffer", vertex_data, webgpu); - lab::Buffer instance_positions("My instance positions", instance_data, webgpu); - - pipeline.add_vertex_buffer(vertex_buffer.wgpu_buffer); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); - - pipeline.add_vertex_buffer(instance_positions.wgpu_buffer, wgpu::VertexStepMode::Instance); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 1); - - pipeline.finalize(); - - while (lab::tick()) { - pipeline.render_frame(surface, - {static_cast(vertex_data.size()), static_cast(instance_data.size())}); - } -} diff --git a/samples/sample_pipeline.cpp b/samples/sample_pipeline.cpp deleted file mode 100644 index 0e89518..0000000 --- a/samples/sample_pipeline.cpp +++ /dev/null @@ -1,36 +0,0 @@ -#include - -int main() { - using namespace lab; - - Webgpu webgpu("My WebGPU Context"); - Shader shader("My Shader Module", "shaders/test1.wgsl"); - - Window window("Triangle Wireframe Demo", 640, 400); - - Surface surface(window, webgpu); - - window.set_key_callback([&window](const KeyEvent& event) { - if (event.key == KeyCode::escape) window.close(); - }); - - // Configure Render Pipeline ------------------------------------------------- - Pipeline pipeline(shader, webgpu); - - // draw line strip - auto& primitive_cfg = pipeline.config.primitiveState; - primitive_cfg.topology = wgpu::PrimitiveTopology::LineStrip; - primitive_cfg.stripIndexFormat = wgpu::IndexFormat::Uint32; - - // initialize after configuration - pipeline.finalize(); - - // render config only affects render_frame - auto& color_cfg = pipeline.render_config.renderPassColorAttachment; - color_cfg.clearValue = wgpu::Color(0.25, 0.2, 0.2, 1.0); - - while (tick()) { - Pipeline::DrawCallParams draw_params(3, 1); // draw 3 vertices and 1 instance - pipeline.render_frame(surface, draw_params); - } -} diff --git a/samples/sample_texture.cpp b/samples/sample_texture.cpp deleted file mode 100644 index 762abe8..0000000 --- a/samples/sample_texture.cpp +++ /dev/null @@ -1,75 +0,0 @@ -#include - -struct MyVertex { - float x, y; - float u, v; -}; - -struct MyUniforms { - float ratio[2]; - float time; - float scale; -}; - -struct MyPixelFormat { - uint8_t r, g, b, a; -}; - -int main() { - lab::Window window("Texture Demo", 640, 400); - - lab::Webgpu webgpu("My Instance"); - lab::Surface surface(window, webgpu); - - lab::Shader shader("My Shader", "shaders/sample_texture.wgsl"); - lab::Pipeline pipeline(shader, webgpu); - - lab::Texture texture(webgpu, wgpu::TextureFormat::RGBA8Unorm, 256, 256); - - // --------------------------------------------------------------------------- - // create procedural texture and upload to gpu - std::vector pixel_data; - pixel_data.reserve(texture.width() * texture.height()); - - auto transform = [](int v, float s = 1.f) { - return uint8_t(s * (20.f + uint8_t(sinf(float(v) / 255.f * 20.f) + 1.f) * 40.f)); - }; - - for (int y = 0; y < texture.height(); ++y) { - for (int x = 0; x < texture.width(); ++x) { - pixel_data.emplace_back(transform(x + y, .6f), transform(x, .3f), transform(y, .2f), uint8_t(255u)); - } - } - - texture.to_device(pixel_data); - pipeline.add_texture(texture, 1, wgpu::ShaderStage::Fragment); - - // --------------------------------------------------------------------------- - // create vertex buffer with 3 vertices which form an equilateral triangle - // x y u v x y u v - std::vector vertex_data = { - {0.f, 1.f, 0.f, 0.f}, {-sqrtf(3.f) / 2.f, -3.f / 6.f, 0.f, 1.f}, {+sqrtf(3.f) / 2.f, -3.f / 6.f, 1.f, 1.f}}; - - lab::Buffer vertex_buffer("My vertex buffer", vertex_data, webgpu); - - pipeline.add_vertex_buffer(vertex_buffer); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); // pos - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 1); // uv - - MyUniforms uniforms{.ratio = {window.ratio(), 1.0f}, .time = 0.0f, .scale = 0.4f}; - lab::Buffer uniform_buffer("My uniform buffer", {uniforms}, - wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst, webgpu); - - pipeline.add_uniform_buffer(uniform_buffer, 0, wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment); - - pipeline.finalize(); - - while (lab::tick()) { - uniform_buffer.write(uniforms); - - pipeline.render_frame(surface, vertex_data.size(), 1); - - uniforms.ratio[0] = window.ratio(); - uniforms.time = lab::elapsed_seconds(); - } -} diff --git a/samples/sample_uniform_buffer.cpp b/samples/sample_uniform_buffer.cpp deleted file mode 100644 index 2c6457b..0000000 --- a/samples/sample_uniform_buffer.cpp +++ /dev/null @@ -1,61 +0,0 @@ -#include - -struct MyVertex { - float x, y; -}; - -struct MyUniforms { - float ratio[2]; - float time; - float scale; -}; - -int main() { - lab::Window window("Vertex Buffer & Uniforms Demo", 640, 400); - - std::vector vertex_data = {{0.f, 1.f}, {-sqrtf(3.f) / 2.f, -3.f / 6.f}, {sqrtf(3.f) / 2.f, -3.f / 6.f}}; - - lab::Webgpu webgpu("My Instance"); - lab::Buffer vertex_buffer("My vertex buffer", vertex_data, webgpu); - - MyUniforms uniforms{.ratio = {window.ratio(), 1.0f}, .time = 0.0f, .scale = 0.4f}; - lab::Buffer uniform_buffer("My uniform buffer", {uniforms}, - wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst, webgpu); - - lab::Shader shader("My Shader", "shaders/sample_uniform_buffer.wgsl"); - lab::Pipeline pipeline(shader, webgpu); - - pipeline.add_vertex_buffer(vertex_buffer); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); - - pipeline.add_uniform_buffer(uniform_buffer, 0, wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment); - pipeline.finalize(); - - lab::Surface surface(window, webgpu); - - std::thread time_resetter{[]() { - std::cout << "Info: time_resetter: spawned a thread which resets time roughly every 7 seconds" << std::endl; - while (true) { - std::cout << "time reset at: " << lab::elapsed_seconds() << std::endl; - lab::restart_timer(); - while (lab::elapsed_seconds() < 6.997) { - // when accuracy doesn't matter, save system resources - lab::sleep(10ms); - } - } - }}; - - while (lab::tick()) { - // send uniform data to gpu - uniform_buffer.write(uniforms); - - // render the scene to surface - pipeline.render_frame(surface, {3, 1}); - - // update uniform values for next draw call - uniforms.ratio[0] = window.ratio(); - uniforms.time = lab::elapsed_seconds(); - } - - time_resetter.join(); -} diff --git a/samples/sample_vertex_buffer.cpp b/samples/sample_vertex_buffer.cpp deleted file mode 100644 index 365c6f5..0000000 --- a/samples/sample_vertex_buffer.cpp +++ /dev/null @@ -1,39 +0,0 @@ -#include - -struct MyVertexFormat { - float pos[2]; - float color[3]; -}; - -int main() { - lab::Webgpu webgpu("My WebGPU Context"); - lab::Shader shader("My Shader", "shaders/draw_colored.wgsl"); - - lab::Pipeline pipeline(shader, webgpu); // the rendering pipeline - - // colored triangle data - std::vector vertex_data = { - // X Y R G B - {.pos = {-0.5f, -0.5f}, .color = {0.8f, 0.2f, 0.2f}}, - {.pos = {+0.5f, -0.5f}, .color = {0.8f, 0.8f, 0.2f}}, - {.pos = {+0.0f, +0.5f}, .color = {0.2f, 0.8f, 0.4f}}, - }; - - // vertex buffer (sends copy of data to GPU memory) - lab::Buffer vertex_buffer("My Vertex Buffer", vertex_data, webgpu); - - // pipeline needs to know about buffers and their memory layouts (vertex attributes) - pipeline.add_vertex_buffer(vertex_buffer); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); // position - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x3, 1); // color - pipeline.finalize(); // make ready for rendering - - lab::Window window("Hello Triangle", 640, 400); - - lab::Surface surface(window, webgpu); // surface to render onto - - // main application loop - while (lab::tick()) { - pipeline.render_frame(surface, 3, 1); // 3 vertices, 1 instance - } -} diff --git a/samples/sample_window.cpp b/samples/sample_window.cpp deleted file mode 100644 index c0b6bf4..0000000 --- a/samples/sample_window.cpp +++ /dev/null @@ -1,14 +0,0 @@ -#include -using namespace lab; - -int main() { - Window window("press space to say hello", 640, 400); - - window.set_key_callback([&window](auto& event) { - if (event == KeyEvent{KeyCode::space, KeyAction::release}) { - window.set_title("world!"); - } - }); - - while (tick()) {} -} diff --git a/samples/sample_window_shader.cpp b/samples/sample_window_shader.cpp deleted file mode 100644 index c5c349b..0000000 --- a/samples/sample_window_shader.cpp +++ /dev/null @@ -1,16 +0,0 @@ -#include -using namespace lab; - -int main() { - Webgpu webgpu("My Instance"); - Shader shader("My Shader", "shaders/test1.wgsl"); - Window window("A Window and a Triangle", 640, 400); - - Surface surface(window, webgpu); - Pipeline pipeline(shader, webgpu); - pipeline.finalize(); - - while (tick()) { - pipeline.render_frame(surface, {3, 1}); - } -} diff --git a/samples/shaders/02_triangle.wgsl b/samples/shaders/02_triangle.wgsl new file mode 100644 index 0000000..3a347fb --- /dev/null +++ b/samples/shaders/02_triangle.wgsl @@ -0,0 +1,13 @@ +// The triangle is defined right here, so the program does not need any buffers. +// The index wraps around, which lets a line strip of 4 vertices close the outline. + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4f { + var positions = array(vec2f(-0.5, -0.5), vec2f(0.5, -0.5), vec2f(0.0, 0.5)); + return vec4f(positions[vertex_index % 3], 0.0, 1.0); +} + +@fragment +fn fs_main() -> @location(0) vec4f { + return vec4f(0.0, 0.4, 1.0, 1.0); +} diff --git a/samples/shaders/draw_colored.wgsl b/samples/shaders/03_vertex_buffer.wgsl similarity index 100% rename from samples/shaders/draw_colored.wgsl rename to samples/shaders/03_vertex_buffer.wgsl diff --git a/samples/shaders/sample_uniform_buffer.wgsl b/samples/shaders/04_uniforms.wgsl similarity index 100% rename from samples/shaders/sample_uniform_buffer.wgsl rename to samples/shaders/04_uniforms.wgsl diff --git a/samples/shaders/sample_texture.wgsl b/samples/shaders/05_texture.wgsl similarity index 76% rename from samples/shaders/sample_texture.wgsl rename to samples/shaders/05_texture.wgsl index da584af..9402397 100644 --- a/samples/shaders/sample_texture.wgsl +++ b/samples/shaders/05_texture.wgsl @@ -16,6 +16,7 @@ struct VertexOutput { @group(0) @binding(0) var uniforms: Uniforms; @group(0) @binding(1) var gradientTexture: texture_2d; +@group(0) @binding(2) var gradientSampler: sampler; @vertex fn vs_main(in: VertexInput) -> VertexOutput { @@ -29,6 +30,7 @@ fn vs_main(in: VertexInput) -> VertexOutput { @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4f { - let color: vec3f = textureLoad(gradientTexture, vec2i(in.uv*256), 0).rgb; + // the sampler decides how the texture is read between its pixels and beyond its edges + let color: vec3f = textureSample(gradientTexture, gradientSampler, in.uv).rgb; return vec4f(color*((sin(uniforms.time)+1.0)*2.5), 1.0); } diff --git a/samples/shaders/06_instancing.wgsl b/samples/shaders/06_instancing.wgsl new file mode 100644 index 0000000..452533a --- /dev/null +++ b/samples/shaders/06_instancing.wgsl @@ -0,0 +1,41 @@ +struct Uniforms { + ratio: vec2f, + time: f32, + scale: f32, + selected: u32, // index of the instance the arrow keys move +}; + +struct VertexInput { + @location(0) position: vec2f, // per vertex: the same for every triangle + @location(1) instance_position: vec2f, // per instance: where this triangle is +}; + +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) color: vec3f, +}; + +@group(0) @binding(0) var uniforms: Uniforms; + +@vertex +fn vs_main(in: VertexInput, @builtin(instance_index) instance: u32) -> VertexOutput { + // every instance spins with its own phase + let angle = uniforms.time + f32(instance) * 0.3; + let rotated = in.position * mat2x2(cos(angle), -sin(angle), sin(angle), cos(angle)); + + var out: VertexOutput; + out.position = vec4f((in.instance_position + rotated * uniforms.scale) * uniforms.ratio, 0.0, 1.0); + + // a color per instance, the selected one is bright + let hue = f32(instance) * 0.9; + out.color = 0.25 + 0.2 * vec3f(sin(hue), sin(hue + 2.1), sin(hue + 4.2)); + if (instance == uniforms.selected) { + out.color = vec3f(1.0, 0.9, 0.4); + } + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4f { + return vec4f(in.color, 1.0); +} diff --git a/samples/shaders/sample_graph_edge_shader.wgsl b/samples/shaders/07_graph_edges.wgsl similarity index 100% rename from samples/shaders/sample_graph_edge_shader.wgsl rename to samples/shaders/07_graph_edges.wgsl diff --git a/samples/shaders/sample_graph.wgsl b/samples/shaders/07_graph_nodes.wgsl similarity index 72% rename from samples/shaders/sample_graph.wgsl rename to samples/shaders/07_graph_nodes.wgsl index c835124..0052ab2 100644 --- a/samples/shaders/sample_graph.wgsl +++ b/samples/shaders/07_graph_nodes.wgsl @@ -26,7 +26,8 @@ fn vs_main(in: VsInput) -> VsOutput { @fragment fn fs_main(in: VsOutput) -> @location(0) vec4f { - let intensity = pow(1.0 - length(in.pos), 1.0); - if (intensity <= 0.0) { return vec4f(0.2, 0.0, 0.0, 0.2); } - return vec4f(0.2+intensity*0.8); + // a soft disc inside the hexagon mesh, the corners of the hexagon stay transparent + let intensity = 1.0 - length(in.pos); + if (intensity <= 0.0) { return vec4f(0.0); } + return vec4f(0.2 + intensity * 0.8); } diff --git a/samples/shaders/09_multi_window.wgsl b/samples/shaders/09_multi_window.wgsl new file mode 100644 index 0000000..7331e94 --- /dev/null +++ b/samples/shaders/09_multi_window.wgsl @@ -0,0 +1,12 @@ +@group(0) @binding(0) var color: vec4f; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4f { + var positions = array(vec2f(-0.7, -0.5), vec2f(0.4, -0.2), vec2f(0.0, 0.8)); + return vec4f(positions[vertex_index], 0.0, 1.0); +} + +@fragment +fn fs_main() -> @location(0) vec4f { + return color; +} diff --git a/samples/shaders/10_house.wgsl b/samples/shaders/10_house.wgsl new file mode 100644 index 0000000..28978fa --- /dev/null +++ b/samples/shaders/10_house.wgsl @@ -0,0 +1,36 @@ +struct Scene { + view_projection: mat4x4f, + light_direction: vec4f, // xyz: the direction the light travels in +}; + +@group(0) @binding(0) var scene: Scene; + +struct VsOutput { + @builtin(position) position: vec4f, + @location(0) normal: vec3f, + @location(1) height: f32, +}; + +@vertex +fn vs_main(@location(0) position: vec3f, @location(1) normal: vec3f) -> VsOutput { + var out: VsOutput; + out.position = scene.view_projection * vec4f(position, 1.0); + out.normal = normal; + out.height = position.y; + return out; +} + +@fragment +fn fs_main(in: VsOutput) -> @location(0) vec4f { + let normal = normalize(in.normal); + + // the model has no materials: surfaces above the walls that face upwards are the roof + let walls = vec3f(0.92, 0.88, 0.78); + let roof = vec3f(0.70, 0.27, 0.20); + let is_roof = step(1.05, in.height) * step(0.2, normal.y); + let base = mix(walls, roof, is_roof); + + // surfaces that face the light are bright, the rest keeps some ambient light + let light = max(dot(normal, -scene.light_direction.xyz), 0.0); + return vec4f(base * (0.35 + 0.65 * light), 1.0); +} diff --git a/samples/shaders/10_house_ground.wgsl b/samples/shaders/10_house_ground.wgsl new file mode 100644 index 0000000..477d84e --- /dev/null +++ b/samples/shaders/10_house_ground.wgsl @@ -0,0 +1,29 @@ +struct Scene { + view_projection: mat4x4f, + light_direction: vec4f, +}; + +@group(0) @binding(0) var scene: Scene; +@group(0) @binding(1) var ground_texture: texture_2d; +@group(0) @binding(2) var ground_sampler: sampler; + +struct VsOutput { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +}; + +@vertex +fn vs_main(@location(0) position: vec3f, @location(1) uv: vec2f) -> VsOutput { + var out: VsOutput; + out.position = scene.view_projection * vec4f(position, 1.0); + out.uv = uv; + return out; +} + +@fragment +fn fs_main(in: VsOutput) -> @location(0) vec4f { + // uv runs beyond 1, the sampler repeats the texture + let color = textureSample(ground_texture, ground_sampler, in.uv).rgb; + let light = max(dot(vec3f(0.0, 1.0, 0.0), -scene.light_direction.xyz), 0.0); + return vec4f(color * (0.35 + 0.65 * light), 1.0); +} diff --git a/samples/shaders/11_boids_draw.wgsl b/samples/shaders/11_boids_draw.wgsl new file mode 100644 index 0000000..291fbe2 --- /dev/null +++ b/samples/shaders/11_boids_draw.wgsl @@ -0,0 +1,26 @@ +struct VsOutput { + @builtin(position) position: vec4f, + @location(0) color: vec3f, +}; + +@vertex +fn vs_main( + @location(0) corner: vec2f, // per vertex: the shape of a boid + @location(1) position: vec2f, // per instance: straight from the buffer the simulation writes + @location(2) velocity: vec2f, +) -> VsOutput { + // turn the shape so that it points where the boid is flying + let angle = -atan2(velocity.x, velocity.y); + let turned = vec2f(corner.x * cos(angle) - corner.y * sin(angle), corner.x * sin(angle) + corner.y * cos(angle)); + + var out: VsOutput; + out.position = vec4f(turned + position, 0.0, 1.0); + // the color tells the direction + out.color = 0.55 + 0.45 * vec3f(sin(angle), sin(angle + 2.1), sin(angle + 4.2)); + return out; +} + +@fragment +fn fs_main(in: VsOutput) -> @location(0) vec4f { + return vec4f(in.color, 1.0); +} diff --git a/samples/shaders/11_boids_simulate.wgsl b/samples/shaders/11_boids_simulate.wgsl new file mode 100644 index 0000000..7f6afe9 --- /dev/null +++ b/samples/shaders/11_boids_simulate.wgsl @@ -0,0 +1,77 @@ +// One step of a flock: every boid looks at the boids around it and +// - moves towards their center (cohesion) +// - keeps a distance to the closest ones (separation) +// - flies in their direction (alignment) + +struct Boid { + position: vec2f, + velocity: vec2f, +}; + +struct Params { + delta_time: f32, + cohesion_distance: f32, + separation_distance: f32, + alignment_distance: f32, + cohesion_scale: f32, + separation_scale: f32, + alignment_scale: f32, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var boids_in: array; +@group(0) @binding(2) var boids_out: array; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) id: vec3u) { + let count = arrayLength(&boids_in); + let index = id.x; + if (index >= count) { + return; // the last workgroup reaches beyond the end of the array + } + + var position = boids_in[index].position; + var velocity = boids_in[index].velocity; + + var center = vec2f(0.0); + var center_count = 0u; + var away = vec2f(0.0); + var heading = vec2f(0.0); + var heading_count = 0u; + + for (var i = 0u; i < count; i++) { + if (i == index) { + continue; + } + let other = boids_in[i]; + let d = distance(other.position, position); + if (d < params.cohesion_distance) { + center += other.position; + center_count++; + } + if (d < params.separation_distance) { + away -= other.position - position; + } + if (d < params.alignment_distance) { + heading += other.velocity; + heading_count++; + } + } + if (center_count > 0u) { + center = center / f32(center_count) - position; + } + if (heading_count > 0u) { + heading /= f32(heading_count); + } + + velocity += center * params.cohesion_scale + away * params.separation_scale + heading * params.alignment_scale; + + // neither too fast nor standing still + velocity = normalize(velocity) * clamp(length(velocity), 0.05, 0.4); + position += velocity * params.delta_time; + + // what leaves on one side comes back in on the other + position = (position + 3.0) % 2.0 - 1.0; + + boids_out[index] = Boid(position, velocity); +} diff --git a/samples/shaders/12_offscreen_scene.wgsl b/samples/shaders/12_offscreen_scene.wgsl new file mode 100644 index 0000000..d16339f --- /dev/null +++ b/samples/shaders/12_offscreen_scene.wgsl @@ -0,0 +1,27 @@ +// The scene that is rendered into the small texture: a spinning triangle + +@group(0) @binding(0) var time: f32; + +struct VsOutput { + @builtin(position) position: vec4f, + @location(0) color: vec3f, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VsOutput { + var corners = array(vec2f(0.0, 0.8), vec2f(-0.7, -0.4), vec2f(0.7, -0.4)); + var colors = array(vec3f(1.0, 0.3, 0.2), vec3f(0.2, 1.0, 0.4), vec3f(0.3, 0.4, 1.0)); + + let corner = corners[vertex_index]; + let turned = vec2f(corner.x * cos(time) - corner.y * sin(time), corner.x * sin(time) + corner.y * cos(time)); + + var out: VsOutput; + out.position = vec4f(turned * vec2f(0.625, 1.0), 0.0, 1.0); // the texture is 96 x 60 pixels + out.color = colors[vertex_index]; + return out; +} + +@fragment +fn fs_main(in: VsOutput) -> @location(0) vec4f { + return vec4f(in.color, 1.0); +} diff --git a/samples/shaders/12_offscreen_show.wgsl b/samples/shaders/12_offscreen_show.wgsl new file mode 100644 index 0000000..39665f7 --- /dev/null +++ b/samples/shaders/12_offscreen_show.wgsl @@ -0,0 +1,26 @@ +// Shows a texture across the whole target + +@group(0) @binding(0) var image: texture_2d; +@group(0) @binding(1) var image_sampler: sampler; + +struct VsOutput { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VsOutput { + // one triangle that is large enough to cover the target + var corners = array(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0)); + let corner = corners[vertex_index]; + + var out: VsOutput; + out.position = vec4f(corner, 0.0, 1.0); + out.uv = vec2f(corner.x * 0.5 + 0.5, 0.5 - corner.y * 0.5); // textures count their rows from the top + return out; +} + +@fragment +fn fs_main(in: VsOutput) -> @location(0) vec4f { + return textureSample(image, image_sampler, in.uv); +} diff --git a/samples/shaders/sample_dynamic_instances.wgsl b/samples/shaders/sample_dynamic_instances.wgsl deleted file mode 100644 index 32d2b4f..0000000 --- a/samples/shaders/sample_dynamic_instances.wgsl +++ /dev/null @@ -1,54 +0,0 @@ -struct Uniforms { - ratio: vec2f, - time: f32, - scale: f32, -}; - -struct VertexInput { - @location(0) position: vec2f, - @location(1) uv: vec2f, - @location(2) instance_position: vec2f, -}; - -struct VertexOutput { - @builtin(position) position: vec4f, - @location(0) uv: vec2f, - @location(1) inst_shift: f32, -}; - -@group(0) @binding(0) var uniforms: Uniforms; -@group(0) @binding(1) var gradientTexture: texture_2d; - -@vertex -fn vs_main(in: VertexInput, @builtin(vertex_index) vert_id: u32, @builtin(instance_index) inst_id: u32) -> VertexOutput { - var t: f32 = uniforms.time; - var cos_t: f32 = cos(t + f32(inst_id)*0.3); - var sin_t: f32 = sin(t + f32(inst_id)*0.3); - - var vertex_rotated: vec2f = in.position*mat2x2(cos_t, -sin_t, sin_t, cos_t); - - var out: VertexOutput; - out.position = vec4f((in.instance_position+(vertex_rotated*uniforms.scale + - vec2f(cos_t, sin_t)*uniforms.scale*0.5)) * - uniforms.ratio, 0.0, 1.0); - - // shift some vertices to change per instance texture appearance - var inst_uv: vec2f = in.uv; - if (vert_id == 0) { - inst_uv.x += f32(inst_id)*0.2; - } - if (vert_id == 2) { - inst_uv.y -= f32(inst_id)*0.1; - } - out.uv = inst_uv; - - out.inst_shift = f32(inst_id)*0.7 + f32(vert_id)*0.4; - - return out; -} - -@fragment -fn fs_main(in: VertexOutput) -> @location(0) vec4f { - let color: vec3f = textureLoad(gradientTexture, vec2i(in.uv*256), 0).rgb; - return vec4f(color*((sin(uniforms.time+in.inst_shift)+1.0)*2.5), 1.0); -} diff --git a/samples/shaders/sample_graph_2.wgsl b/samples/shaders/sample_graph_2.wgsl deleted file mode 100644 index a5d8478..0000000 --- a/samples/shaders/sample_graph_2.wgsl +++ /dev/null @@ -1,37 +0,0 @@ -struct Uniforms { - ratio: vec2f, - time: f32, -}; - -struct VsInput { - @location(0) vertex_pos: vec2f, - @location(1) vertex_side: f32, - @location(2) pos: vec2f, - @location(3) scale: f32, -}; - -struct VsOutput { - @builtin(position) position: vec4f, - @location(0) pos: vec2f, -}; - -@group(0) @binding(0) var uniforms: Uniforms; - -@vertex -fn vs_main(in: VsInput) -> VsOutput { - var out: VsOutput; - var vpos = in.vertex_pos; - if (in.vertex_side > 0.0) { - vpos.x += 0.4; - } - out.position = vec4f((in.pos + vpos*in.scale)*uniforms.ratio, 0.0, 1.0); - out.pos = in.vertex_pos; - return out; -} - -@fragment -fn fs_main(in: VsOutput) -> @location(0) vec4f { - let intensity = pow(1.0 - length(in.pos), 1.0); - if (intensity <= 0.0) { return vec4f(0.2, 0.0, 0.0, 0.2); } - return vec4f(0.2+intensity*0.8); -} diff --git a/samples/shaders/test1.wgsl b/samples/shaders/test1.wgsl deleted file mode 100644 index cec44b5..0000000 --- a/samples/shaders/test1.wgsl +++ /dev/null @@ -1,17 +0,0 @@ -@vertex -fn vs_main(@builtin(vertex_index) in_vertex_index: u32) -> @builtin(position) vec4f { - var p = vec2f(0.0, 0.0); - if (in_vertex_index == 0u) { - p = vec2f(-0.5, -0.5); - } else if (in_vertex_index == 1u) { - p = vec2f(0.5, -0.5); - } else { - p = vec2f(0.0, 0.5); - } - return vec4f(p, 0.0, 1.0); -} - -@fragment -fn fs_main() -> @location(0) vec4f { - return vec4f(0.0, 0.4, 1.0, 1.0); -} diff --git a/samples/shaders/test2.wgsl b/samples/shaders/test2.wgsl deleted file mode 100644 index 460815e..0000000 --- a/samples/shaders/test2.wgsl +++ /dev/null @@ -1,17 +0,0 @@ -@vertex -fn vs_main(@builtin(vertex_index) in_vertex_index: u32) -> @builtin(position) vec4f { - var p = vec2f(0.0, 0.0); - if (in_vertex_index == 0u) { - p = vec2f(-0.7, -0.5); - } else if (in_vertex_index == 1u) { - p = vec2f(0.4, -0.2); - } else { - p = vec2f(0.0, 0.8); - } - return vec4f(p, 0.0, 1.0); -} - -@fragment -fn fs_main() -> @location(0) vec4f { - return vec4f(0.3, 0.2, 0.5, 1.0); -} diff --git a/samples/shaders/test_instancing.wgsl b/samples/shaders/test_instancing.wgsl deleted file mode 100644 index 258072d..0000000 --- a/samples/shaders/test_instancing.wgsl +++ /dev/null @@ -1,10 +0,0 @@ -@vertex -fn vs_main(@location(0) in_vertex_position: vec2f, @location(1) instance_pos: vec2f) -> @builtin(position) vec4f { - return vec4f((instance_pos + in_vertex_position*1.0)*0.2, 0.0, 1.0); -} - -@fragment -fn fs_main() -> @location(0) vec4f { - return vec4f(0.0, 0.4, 1.0, 1.0); -} - diff --git a/samples/shaders/test_uniform.wgsl b/samples/shaders/test_uniform.wgsl deleted file mode 100644 index 6335957..0000000 --- a/samples/shaders/test_uniform.wgsl +++ /dev/null @@ -1,28 +0,0 @@ -@group(0) @binding(0) var time: f32; -@group(1) @binding(0) var x: f32; -@group(1) @binding(1) var y: f32; - -struct VertexInput { - @location(0) position: vec2f, - @location(1) grayscale: f32, -}; - -struct VertexOutput { - @builtin(position) position: vec4f, - @location(0) color: vec3f, -}; - -@vertex -fn vs_main(in: VertexInput) -> VertexOutput { - var out: VertexOutput; - var pos: vec2f = vec2f(in.position) + vec2f(x, 0.0) + vec2f(cos(time), sin(time))*0.5; - out.position = vec4f(pos, 0.0, 1.0); - out.color = vec3f(in.grayscale); - return out; -} - -@fragment -fn fs_main(in: VertexOutput) -> @location(0) vec4f { - var color: vec3f = in.color + vec3f(0.0, sin(y), 0.0); - return vec4f(color, 1.0); -} diff --git a/samples/shaders/vbuf.wgsl b/samples/shaders/vbuf.wgsl deleted file mode 100644 index fe26ec4..0000000 --- a/samples/shaders/vbuf.wgsl +++ /dev/null @@ -1,9 +0,0 @@ -@vertex -fn vs_main(@location(0) in_vertex_position: vec2f) -> @builtin(position) vec4f { - return vec4f(in_vertex_position, 0.0, 1.0); -} - -@fragment -fn fs_main() -> @location(0) vec4f { - return vec4f(0.0, 0.4, 1.0, 1.0); -} diff --git a/samples/test_buffers.cpp b/samples/test_buffers.cpp deleted file mode 100644 index 10659fb..0000000 --- a/samples/test_buffers.cpp +++ /dev/null @@ -1,66 +0,0 @@ -#include - -// It's foolish to send std::strings to a gpu buffer. -// But why not =) - -int main() { - lab::Webgpu webgpu("My Instance"); - - // Init Buffer --------------------------------------------------------------- - std::cout << "\n\nWriting Buffer...\n"; - lab::Buffer buffer("My Buffer", webgpu); - { - bool writing_done = false; - auto init_buffer = [&writing_done](lab::MappedVRAM vmap) { - int start_point = 6; - vmap.resize(start_point); - for (int i = 0; i < 100; ++i) { - lab::sleep(10ms); // simulate slow loading, converting, etc. - std::cout << vmap.push(std::to_string(i + 5)) << " "; - } - std::cout << "\nbuffer initialized\n"; - writing_done = true; - }; - auto thread = buffer.to_device(init_buffer, 256, wgpu::BufferUsage::MapRead); - std::cout << "Main thread waiting..."; - while (!writing_done) { - lab::sleep(20ms); - std::cout << "."; - } - } - // this buffer is just to see if something breaks - lab::Buffer another("Stress Test", std::vector{"1", "2", "3", "4+"}, - wgpu::BufferUsage::MapRead, webgpu); - - // Read buffer --------------------------------------------------------------- - bool reading_done = false; - auto read_buffer = [&reading_done](lab::MappedVRAM vmap) { - for (auto& e : vmap) { - lab::sleep(50ms); // simulate slow processing of received data - std::cout << e << " "; - } - - // unmap can be called when done reading or writing, otherwise - vmap.unmap(); // it will be automatically unmapped when exiting this function - - std::cout << std::endl; - reading_done = true; - }; - - std::cout << "\n\nReading Buffer...\n"; - { - auto thread = buffer.from_device(2, 200, read_buffer); - - std::cout << "Main thread waiting..."; - while (!reading_done) { - lab::sleep(10ms); - std::cout << "."; - } - } - - reading_done = false; - auto thread = another.from_device(read_buffer); - while (!reading_done) { - } - std::cout << "\nFIN !!!\n" << std::endl; -} diff --git a/samples/test_tinygltf.cpp b/samples/test_tinygltf.cpp deleted file mode 100644 index 5965244..0000000 --- a/samples/test_tinygltf.cpp +++ /dev/null @@ -1,31 +0,0 @@ -// Define these only in *one* .cc file. -#define TINYGLTF_IMPLEMENTATION -#define STB_IMAGE_IMPLEMENTATION -#define STB_IMAGE_WRITE_IMPLEMENTATION -// #define TINYGLTF_NOEXCEPTION // optional. disable exception handling. -#include "tiny_gltf.h" - -using namespace tinygltf; - -int main() { - Model model; - TinyGLTF loader; - std::string err; - std::string warn; - - // bool ret = loader.LoadASCIIFromFile(&model, &err, &warn, "assets/house.gltf"); - bool ret = loader.LoadBinaryFromFile(&model, &err, &warn, "assets/house.glb"); // for binary glTF(.glb) - - if (!warn.empty()) { - printf("Warn: %s\n", warn.c_str()); - } - - if (!err.empty()) { - printf("Err: %s\n", err.c_str()); - } - - if (!ret) { - printf("Failed to parse glTF\n"); - return -1; - } -} diff --git a/samples/test_uniform_buffers.cpp b/samples/test_uniform_buffers.cpp deleted file mode 100644 index 4366233..0000000 --- a/samples/test_uniform_buffers.cpp +++ /dev/null @@ -1,61 +0,0 @@ -#include - -struct alignas(256) buffer_t { - float val; -}; - -int main() { - lab::Webgpu webgpu("My Instance"); - lab::Shader shader("My Shader", "shaders/test_uniform.wgsl"); - lab::Pipeline pipeline(shader, webgpu); - - lab::Window window("Vertex Buffer Demo", 640, 400); - lab::Surface surface(window, webgpu); - - std::vector vertex_data = {-0.5f, -0.5f, 1.0f, +0.5f, -0.5f, 0.6f, +0.0f, 0.5f, 0.2f, - -0.55f, -0.5f, 1.0f, -0.05f, +0.5f, 0.6f, -0.55f, 0.5f, 0.2f}; - uint32_t vertex_count = static_cast(vertex_data.size() / 3); - - lab::Buffer vertex_buffer("My vertex buffer", vertex_data, webgpu); - - lab::Buffer uniform_buffer("My uniform buffer", {1.0}, wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst, - webgpu); - - lab::Buffer uniform_buffer2("My uniform buffer 2", {{}, {}}, - wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst, webgpu); - - pipeline.add_vertex_buffer(vertex_buffer); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32, 1, lab::vertex_format_size(wgpu::VertexFormat::Float32x2)); - - pipeline.add_bind_group_buffer_entry(uniform_buffer.wgpu_buffer, 0, sizeof(float)); - pipeline.add_bind_group_layout_buffer_entry(0, wgpu::ShaderStage::Vertex, wgpu::BufferBindingType::Uniform, - sizeof(float)); - pipeline.finalize_bind_group(); - - pipeline.add_bind_group_buffer_entry(uniform_buffer2.wgpu_buffer, 0, sizeof(buffer_t)); - pipeline.add_bind_group_layout_buffer_entry(0, wgpu::ShaderStage::Vertex, wgpu::BufferBindingType::Uniform, - sizeof(buffer_t)); - - pipeline.add_bind_group_buffer_entry(uniform_buffer2.wgpu_buffer, 1, sizeof(buffer_t), sizeof(buffer_t)); - pipeline.add_bind_group_layout_buffer_entry(1, wgpu::ShaderStage::Fragment, wgpu::BufferBindingType::Uniform, - sizeof(buffer_t)); - - pipeline.finalize_bind_group("Bind Group 2"); - - pipeline.finalize(); - - float time = 0.0f; - buffer_t x = {0.0f}; - buffer_t y = {0.0f}; - - while (lab::tick()) { - uniform_buffer.write(time); - uniform_buffer2.write(x); - uniform_buffer2.write(y, 1); - pipeline.render_frame(surface, {vertex_count, 1}); - time += 0.01f; - x.val += 0.001f; - y.val += 0.01f; - } -} diff --git a/samples/test_windows.cpp b/samples/test_windows.cpp deleted file mode 100644 index e8c7e6f..0000000 --- a/samples/test_windows.cpp +++ /dev/null @@ -1,132 +0,0 @@ -#include - -#include - -#include - -int main() { - - // TEST 1 - press space to change pipeline of window 2 - { - lab::Webgpu webgpu{"My Webgpu Instance"}; - - lab::Window window_2{"My Window 2", 320, 200}; - lab::Window window_1{"My Window 1 Press Space to toggle My Window 2 pipeline", 640, 400}; - - lab::Shader shader_1{"My Shader 1", "shaders/test1.wgsl"}; - lab::Shader shader_2{"My Shader 2", "shaders/test2.wgsl"}; - - lab::Surface surface_1{window_1, webgpu}; - lab::Surface surface_2{window_2, webgpu}; - - lab::Pipeline pipeline_1{shader_1, webgpu, true}; - lab::Pipeline pipeline_2{shader_2, webgpu, true}; - - bool toggle_pipeline = false; - - window_1.set_key_callback([&toggle_pipeline](const lab::KeyEvent& event) { - if (event.key == lab::KeyCode::space && event.action == lab::KeyAction::press) { - toggle_pipeline = !toggle_pipeline; - } - }); - - while (lab::tick()) { - if (window_1.is_open()) pipeline_1.render_frame(surface_1, {3, 1}); - - if (toggle_pipeline) { - if (window_2.is_open()) pipeline_2.render_frame(surface_2, {3, 1}); - } else { - if (window_2.is_open()) pipeline_1.render_frame(surface_2, {3, 1}); - } - } - } - - // TEST 2 - press space to open second window - { - using namespace lab; - - Webgpu webgpu{"My Instance"}; - Window window{"My Window", 640, 400}; - Shader shader{"My Shader", "shaders/test1.wgsl"}; - - Surface surface{window, webgpu}; - Pipeline pipeline{shader, webgpu, true}; - - std::unique_ptr sometimes_open; - std::unique_ptr sometimes_surf; - - window.set_key_callback([&](const KeyEvent& event) { - if (event.key == KeyCode::space && event.action == KeyAction::press) { - if (!sometimes_open) { - sometimes_open = std::make_unique("test", 400, 300); - sometimes_surf = std::make_unique(*sometimes_open.get(), webgpu); - - sometimes_open.get()->set_key_callback([&](const KeyEvent& event) { - if (event.key == KeyCode::tab && event.action == KeyAction::release) { - sometimes_surf.get()->~Surface(); - sometimes_open.get()->~Window(); - sometimes_surf.release(); - sometimes_open.release(); - } - }); - } - } - }); - - while (tick()) { - pipeline.render_frame(surface, {3, 1}); - - if (sometimes_open && sometimes_open.get()->is_open()) { - pipeline.render_frame(*sometimes_surf.get(), {3, 1}); - } - } - } - - // TEST 3 - press space to close the first window - { - lab::Window first_window{"My Window", 640, 400}; - lab::Window second_window{"Press space to close My Window", 500, 200}; - - second_window.set_key_callback([&](const lab::KeyEvent& event) { - if (event.key == lab::KeyCode::space) { - first_window.~Window(); - } - }); - - lab::Webgpu webgpu{"My instance"}; - lab::Shader shader{"My shader", "shaders/test1.wgsl"}; - lab::Pipeline pipeline{shader, webgpu, true}; - - lab::Surface first_surface{first_window, webgpu}; - lab::Surface second_surface{second_window, webgpu}; - - while (lab::tick()) { - if (first_window.is_open()) pipeline.render_frame(first_surface, {3, 1}); - if (second_window.is_open()) pipeline.render_frame(second_surface, {3, 1}); - } - } - - // TEST 4 - test multiple webgpu instances - { - lab::Window first_window{"uses Webgpu instance 1", 640, 400}; - lab::Window second_window{"uses Webgpu instance 2", 300, 200}; - lab::Shader shader{"My Shader", "shaders/test2.wgsl"}; - - // Todo: Investigate: - // Maybe 2 webgpu instances could be useful in multi-threaded contexts - lab::Webgpu webgpu1{"Inst 1"}; - lab::Webgpu webgpu2{"Inst 2"}; - - lab::Pipeline pipe1{shader, webgpu1, true}; - lab::Pipeline pipe2{shader, webgpu2, true}; - - lab::Surface first_surface{first_window, webgpu1}; - lab::Surface second_surface{second_window, webgpu2}; - - while (lab::tick()) { - if (first_window.is_open()) pipe1.render_frame(first_surface, {3, 1}); - if (second_window.is_open()) pipe2.render_frame(second_surface, {3, 1}); - } - } - -} diff --git a/scripts/lab_fetch_dependencies.py b/scripts/lab_fetch_dependencies.py deleted file mode 100644 index c06d866..0000000 --- a/scripts/lab_fetch_dependencies.py +++ /dev/null @@ -1,25 +0,0 @@ -import os -import sys - -from lab_helpers import dir_create_then_enter, git_clone - -try: - assert len(sys.argv) == 3, 'Not enough arguments' - assert sys.argv[1] == '--root', 'Missing "--root" argument' - - os.chdir(sys.argv[2]) # change into wgpu-lab's root directory - - if dir_create_then_enter('libs'): - print("fetching wgpu-labs's dependencies... \n") - - git_clone('https://dawn.googlesource.com/dawn', 'chromium/6670') - git_clone('https://github.com/g-truc/glm', '1.0.1') - git_clone('https://github.com/syoyo/tinygltf', 'v2.9.3') - - else: - print("already got wgpu-lab's dependencies") - print("if broken, delete libs directory to reset") - -except AssertionError as err: - print('Error:', err) - print('Usage: "python scripts/lab_fetch_dependencies.py --root [LAB_ROOT_DIR]"') diff --git a/scripts/lab_helpers.py b/scripts/lab_helpers.py deleted file mode 100644 index 46df138..0000000 --- a/scripts/lab_helpers.py +++ /dev/null @@ -1,15 +0,0 @@ -import os -import subprocess - -def dir_create_then_enter(path): - if os.path.isdir(path): - return False - os.mkdir(path) - os.chdir(path) - return True - -def git_clone(repo_url, branch_or_tag_name=None): - args = ['git', 'clone', repo_url] - if branch_or_tag_name is not None: - args.extend(['-b', branch_or_tag_name]) - subprocess.run(args) diff --git a/src/detail/lab.cpp b/src/detail/lab.cpp deleted file mode 100644 index 374140a..0000000 --- a/src/detail/lab.cpp +++ /dev/null @@ -1,57 +0,0 @@ -#include -#include -#include - -#include -#include - -#include - -namespace lab { - -State state; - -bool init_lab() { - if (!state.lab_init) { - // initialize glfw - if (!glfwInit()) { - std::cerr << "Error: GLFW: Failed to initialize!" << std::endl; - return false; - } - std::cout << "Info: GLFW: Initialized!" << std::endl; - - // initialize dawn proc tables - dawnProcSetProcs(&dawn::native::GetProcs()); - - // initialize only once - state.lab_init = true; - } - return state.lab_init; -} - -bool tick() { - glfwPollEvents(); - - std::vector to_close; - to_close.reserve(state.window_map.size()); - - for (auto [hnd, wnd] : state.window_map) { - if (glfwWindowShouldClose(hnd)) { - to_close.push_back(wnd); - } - } - for (auto wnd : to_close) { - wnd->close(); - } - - return state.window_map.size() > 0; -} - -bool operator==(const KeyEvent& lhs, const KeyEvent& rhs) { - return lhs.key == rhs.key && lhs.mod == rhs.mod && lhs.action == rhs.action; -} - -void restart_timer() { state.start_tp = std::chrono::steady_clock::now(); } -std::chrono::steady_clock::time_point get_timer_start() { return state.start_tp; } - -} // namespace lab diff --git a/src/detail/lab_pipeline.cpp b/src/detail/lab_pipeline.cpp deleted file mode 100644 index e1817aa..0000000 --- a/src/detail/lab_pipeline.cpp +++ /dev/null @@ -1,189 +0,0 @@ -#include - -#include -#include -#include - -namespace lab { - -static constexpr uint64_t vertex_format_sizes[32] = { - 0, // Undefined - 2, // Uint8x2 - 4, // Uint8x4 - 2, // Sint8x2 - 4, // Sint8x4 - 2, // Unorm8x2 - 4, // Unorm8x4 - 2, // Snorm8x2 - 4, // Snorm8x4 - 4, // Uint16x2 - 8, // Uint16x4 - 4, // Sint16x2 - 8, // Sint16x4 - 4, // Unorm16x2 - 8, // Unorm16x4 - 4, // Snorm16x2 - 8, // Snorm16x4 - 4, // Float16x2 - 8, // Float16x4 - 4, // Float32 - 8, // Float32x2 - 12, // Float32x3 - 16, // Float32x4 - 4, // Uint32 - 8, // Uint32x2 - 12, // Uint32x3 - 16, // Uint32x4 - 4, // Sint32 - 8, // Sint32x2 - 12, // Sint32x3 - 16, // Sint32x4 - 4, // Unorm10_10_10_2 -}; - -constexpr uint64_t vertex_format_size(const wgpu::VertexFormat& format) { - return vertex_format_sizes[static_cast(format)]; -} - -uint64_t vertex_attributes_stride(const std::vector& vertexAttributes) { - uint64_t totalStride = 0; - for (const auto& va : vertexAttributes) { - totalStride += vertex_format_size(va.format); - } - return totalStride; -} - -wgpu::TextureView get_current_render_texture_view(wgpu::Surface surface) { - wgpu::SurfaceTexture surfaceTexture; - surface.GetCurrentTexture(&surfaceTexture); - if (surfaceTexture.status != wgpu::SurfaceGetCurrentTextureStatus::Success) { - std::cerr << "Error: Pipeline: Could not get current render texture" << std::endl; - return nullptr; - } - wgpu::TextureViewDescriptor viewDescriptor{ - .label = "lab default texture view", - .format = surfaceTexture.texture.GetFormat(), - .dimension = wgpu::TextureViewDimension::e2D, - .baseMipLevel = 0, - .mipLevelCount = 1, - .baseArrayLayer = 0, - .arrayLayerCount = 1, - .aspect = wgpu::TextureAspect::All, - }; - return surfaceTexture.texture.CreateView(&viewDescriptor); -} - -void Pipeline::finalize_config(wgpu::ShaderModule shaderModule) { - if (label.empty()) { - label = std::format("Default Pipeline({} on {})", shader.label, webgpu.label); - } - - for (int i = 0; i < vb_configs.size(); ++i) { - vb_layouts.push_back({ - .arrayStride = vertex_attributes_stride(vb_configs[i].vertexAttributes), - .stepMode = vb_configs[i].mode, - .attributeCount = vb_configs[i].vertexAttributes.size(), - .attributes = vb_configs[i].vertexAttributes.data(), - }); - } - config.vertexState.bufferCount = vb_layouts.size(); - config.vertexState.buffers = vb_layouts.data(); - - config.colorTarget.format = webgpu.capabilities.formats[0]; - config.colorTarget.blend = &config.blendState; - - // todo: make number of color targets configurable - config.fragmentState.targetCount = 1; - config.fragmentState.targets = &config.colorTarget; - - config.vertexState.module = shaderModule; - config.fragmentState.module = shaderModule; -} - -wgpu::RenderPipeline Pipeline::transfer() const { - wgpu::PipelineLayout pipelineLayout = nullptr; - - if (bindGroupLayouts.size() > 0) { - wgpu::PipelineLayoutDescriptor layoutDesc{}; - - layoutDesc.bindGroupLayoutCount = bindGroupLayouts.size(); - layoutDesc.bindGroupLayouts = bindGroupLayouts.data(); - - pipelineLayout = webgpu.device.CreatePipelineLayout(&layoutDesc); - } - - wgpu::RenderPipelineDescriptor pipelineDesc = { - .label = label.c_str(), - .layout = pipelineLayout, - .vertex = config.vertexState, - .primitive = config.primitiveState, - .multisample = config.multisampleState, - .fragment = &config.fragmentState, - }; - - return webgpu.device.CreateRenderPipeline(&pipelineDesc); -} - -void Pipeline::reset() { - if (wgpu_pipeline) { - wgpu_pipeline = nullptr; - } -} - -Pipeline::~Pipeline() { reset(); } - -bool Pipeline::default_render(PipelineHandle self, wgpu::Surface surface, const DrawCallParams& draw_params) { - assert(self->wgpu_pipeline != nullptr); - - wgpu::TextureView targetView = get_current_render_texture_view(surface); - - wgpu::CommandEncoderDescriptor encoderDesc = {.label = "lab default command encoder"}; - wgpu::CommandEncoder encoder = self->webgpu.device.CreateCommandEncoder(&encoderDesc); - - self->render_config.renderPassColorAttachment.view = targetView; - - wgpu::RenderPassDescriptor renderPassDesc = { - .label = "lab default render pass", - .colorAttachmentCount = 1, - .colorAttachments = &self->render_config.renderPassColorAttachment, - }; - - wgpu::RenderPassEncoder renderPass = encoder.BeginRenderPass(&renderPassDesc); - renderPass.SetPipeline(self->wgpu_pipeline); - - for (uint32_t i = 0; i < self->vb_configs.size(); ++i) { - renderPass.SetVertexBuffer(i, self->vb_configs[i].buffer, self->vb_configs[i].offset, - self->vb_configs[i].buffer.GetSize()); - } - - for (uint32_t i = 0; i < self->bindGroups.size(); ++i) { - // TODO: think about how to make use of dynamic offset - // useful for multiple drawcalls with different uniform data - renderPass.SetBindGroup(i, self->bindGroups[i], 0, nullptr); - } - - for (uint32_t i = 0; i < self->ib_configs.size(); ++i) { - const auto& ibc = self->ib_configs[i]; - renderPass.SetIndexBuffer(ibc.buffer, ibc.format, ibc.offset, ibc.buffer.GetSize()); - } - - // TODO: only a temporary fix (avoid too many branches in render code) - if (self->ib_configs.size() == 0) { - renderPass.Draw(draw_params.vertexCount, draw_params.instanceCount, draw_params.firstVertex, - draw_params.firstInstance); - } else { - renderPass.DrawIndexed(draw_params.vertexCount, draw_params.instanceCount, draw_params.firstVertex); - } - - renderPass.End(); - - wgpu::CommandBufferDescriptor cmdBufferDescriptor = {.label = "lab default command buffer"}; - wgpu::CommandBuffer commands = encoder.Finish(&cmdBufferDescriptor); - - self->webgpu.queue.Submit(1, &commands); - surface.Present(); - - return true; -}; - -} // namespace lab diff --git a/src/detail/lab_shader.cpp b/src/detail/lab_shader.cpp deleted file mode 100644 index 2bcffc6..0000000 --- a/src/detail/lab_shader.cpp +++ /dev/null @@ -1,41 +0,0 @@ -#include - -#include -#include -#include -#include - -namespace lab { - -Shader::Shader(const std::string& lbl) : label{lbl} {} - -Shader::Shader(const std::string& lbl, const std::string& path) : label{lbl} { - std::ifstream file(path); - if (!file.is_open()) { - std::cerr << "Error: Shader: Could not open \"" << path << "\"" << std::endl; - return; - } - std::stringstream buffer; - buffer << file.rdbuf(); - source = buffer.str(); -} - -wgpu::ShaderModule Shader::transfer(wgpu::Device device, wgpu::SType struct_type) const { - switch (struct_type) { - case wgpu::SType::ShaderModuleSPIRVDescriptor: - std::cout << "Error: Shader: SPIR-V Shader Module not yet implemented. Please use WGSL instead." << std::endl; - return nullptr; - case wgpu::SType::ShaderModuleWGSLDescriptor: - wgpu::ShaderModuleWGSLDescriptor wgslDesc; - wgslDesc.sType = struct_type; - wgslDesc.code = source.c_str(); - wgpu::ShaderModuleDescriptor shaderDesc; - shaderDesc.nextInChain = &wgslDesc; - shaderDesc.label = label.c_str(); - return device.CreateShaderModule(&shaderDesc); - } - std::cout << "Error: Shader: struct_type not supported." << std::endl; - return nullptr; -} - -} // namespace lab diff --git a/src/detail/lab_surface.cpp b/src/detail/lab_surface.cpp deleted file mode 100644 index 099b354..0000000 --- a/src/detail/lab_surface.cpp +++ /dev/null @@ -1,41 +0,0 @@ -#include - -#include -#include - -namespace lab { - -Surface::Surface(Window& wnd, Webgpu& wgpu) - : window{wnd}, webgpu{wgpu}, - wgpu_surface{wgpu::glfw::CreateSurfaceForWindow(wgpu.instance, wnd.glfw_window_handle)} { - reconfigure(); - window.set_resize_callback([&](int width, int height) { - if (width > 0 && height > 0) { - this->reconfigure(width, height); - } - }); -} - -void Surface::reconfigure(int w, int h) { - wgpu::SurfaceConfiguration surfaceConfig = { - .device = webgpu.device, - .format = webgpu.capabilities.formats[0], - .usage = wgpu::TextureUsage::RenderAttachment, - .alphaMode = wgpu::CompositeAlphaMode::Auto, - .width = static_cast(w), - .height = static_cast(h), - .presentMode = wgpu::PresentMode::Fifo, - }; - wgpu_surface.Configure(&surfaceConfig); -} - -void Surface::reconfigure() { reconfigure(window.width(), window.height()); } - -Surface::~Surface() { - if (wgpu_surface) { - wgpu_surface.Unconfigure(); - wgpu_surface = nullptr; - } -} - -} // namespace lab diff --git a/src/detail/lab_webgpu.cpp b/src/detail/lab_webgpu.cpp deleted file mode 100644 index 93ae78a..0000000 --- a/src/detail/lab_webgpu.cpp +++ /dev/null @@ -1,96 +0,0 @@ -#include - -#include - -#include - -#include - -namespace lab { - -bool init_lab(); - -Webgpu::Webgpu(const std::string& lbl, wgpu::PowerPreference power_pref) : label{lbl} { - init_lab(); - - glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); - glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); - glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE); - - auto window = glfwCreateWindow(64, 64, "", nullptr, nullptr); - wgpu::Surface surface = wgpu::glfw::CreateSurfaceForWindow(instance, window); - - wgpu::InstanceDescriptor instanceDesc{.features{.timedWaitAnyEnable{true}}}; - instance = wgpu::CreateInstance(&instanceDesc); - if (!instance) { - std::cerr << "Error: WGPU: Could not create Instance!" << std::endl; - return; - } - - wgpu::RequestAdapterOptions adapterOpts = { - .compatibleSurface = surface, - .powerPreference = power_pref, - }; - - wgpu::Future future = instance.RequestAdapter( - &adapterOpts, wgpu::CallbackMode::WaitAnyOnly, - [](wgpu::RequestAdapterStatus status, wgpu::Adapter adapter, const char* message, wgpu::Adapter* userdata) { - if (status == wgpu::RequestAdapterStatus::Success) { - std::cout << "Info: WGPU: Successfully got adapter!" << std::endl; - } else { - std::cerr << "Error: WGPU: Failed to get adapter: "; - if (message) std::cerr << message; - std::cerr << std::endl; - } - *userdata = std::move(adapter); - }, - &adapter); - instance.WaitAny(future, UINT64_MAX); - - wgpu::DeviceDescriptor deviceDesc; - deviceDesc.label = "lab default device"; - deviceDesc.defaultQueue.label = "lab default queue"; - deviceDesc.deviceLostCallbackInfo.mode = wgpu::CallbackMode::AllowSpontaneous; - deviceDesc.deviceLostCallbackInfo.callback = [](const WGPUDevice* device, WGPUDeviceLostReason reason, - char const* message, void*) { - std::cout << "Info: WGPU: Device " << device << " lost: reason " << reason; - if (message) std::cout << " (" << message << ")"; - std::cout << std::endl; - }; - deviceDesc.uncapturedErrorCallbackInfo.callback = [](WGPUErrorType type, char const* message, void* instance) { - std::cerr << "Error: WGPU(" << instance << "): " << type; - if (message) std::cerr << " (" << message << ")"; - std::cerr << std::endl; - }; - - future = adapter.RequestDevice( - &deviceDesc, wgpu::CallbackMode::WaitAnyOnly, - [](wgpu::RequestDeviceStatus status, wgpu::Device device, char const* message, wgpu::Device* userdata) { - if (status == wgpu::RequestDeviceStatus::Success) { - std::cout << "Info: WGPU: Successfully got device!" << std::endl; - } else { - std::cerr << "Error: WGPU: Failed to get device: "; - if (message) std::cerr << message; - std::cerr << std::endl; - } - *userdata = std::move(device); - }, - &device); - instance.WaitAny(future, UINT64_MAX); - - surface.GetCapabilities(adapter, &capabilities); - glfwDestroyWindow(window); - - queue = device.GetQueue(); -} - -Webgpu::~Webgpu() { - if (instance) { - instance = nullptr; - adapter = nullptr; - device = nullptr; - queue = nullptr; - } -} - -} // namespace lab diff --git a/src/detail/lab_window.cpp b/src/detail/lab_window.cpp deleted file mode 100644 index b268b00..0000000 --- a/src/detail/lab_window.cpp +++ /dev/null @@ -1,89 +0,0 @@ -#include - -#include -#include - -#include - -#include - -namespace lab { - -bool init_lab(); - -Window::Window(const char* title, int width, int height) { - init_lab(); - glfwWindowHint(GLFW_VISIBLE, GLFW_TRUE); - glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); - glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE); - glfw_window_handle = glfwCreateWindow(width, height, title, nullptr, nullptr); - if (!glfw_window_handle) { - std::cerr << "Error: GLFW: Failed to create Window!" << std::endl; - return; - } - std::cout << "Info: GLFW: Window(" << glfwGetWindowTitle(glfw_window_handle) << " - " << glfw_window_handle - << ") created!" << std::endl; - state.window_map[glfw_window_handle] = this; -} - -void Window::set_key_callback(std::function kcb) { - user_key_callback = kcb; - glfwSetKeyCallback(glfw_window_handle, [](GLFWwindow* wnd, int key, int scancode, int action, int mod) { - state.window_map[wnd]->user_key_callback( - {static_cast(key), static_cast(action), static_cast(mod), scancode}); - }); -} - -void Window::set_resize_callback(std::function rcb) { - user_resize_callback = rcb; - glfwSetWindowSizeCallback(glfw_window_handle, - [](GLFWwindow* wnd, int w, int h) { state.window_map[wnd]->user_resize_callback(w, h); }); -} - -void Window::clear_key_callback() { - glfwSetKeyCallback(glfw_window_handle, nullptr); - user_key_callback = nullptr; -} - -void Window::clear_resize_callback() { - glfwSetWindowSizeCallback(glfw_window_handle, nullptr); - user_resize_callback = nullptr; -} - -void Window::set_title(const char* title) { glfwSetWindowTitle(glfw_window_handle, title); } - -int Window::width() const { - int width; - glfwGetWindowSize(glfw_window_handle, &width, nullptr); - return width; -} - -int Window::height() const { - int height; - glfwGetWindowSize(glfw_window_handle, nullptr, &height); - return height; -} - -float Window::ratio() const { return (1.f / width()) * height(); } - -bool Window::is_open() const { return glfw_window_handle != nullptr; } - -void Window::close() { - if (glfw_window_handle) { - std::cout << "Info: GLFW: Window(" << glfwGetWindowTitle(glfw_window_handle) << " - " << glfw_window_handle - << ") destroyed!" << std::endl; - glfwDestroyWindow(glfw_window_handle); - state.window_map.erase(glfw_window_handle); - glfw_window_handle = nullptr; - if (state.window_map.size() == 0) { - if (state.lab_init) { - glfwTerminate(); - state.lab_init = false; - std::cout << "Info: GLFW: Terminated!" << std::endl; - } - } - } -} -Window::~Window() { close(); } - -} // namespace lab diff --git a/src/extra/lab_mapped_vram.h b/src/extra/lab_mapped_vram.h deleted file mode 100644 index 9632006..0000000 --- a/src/extra/lab_mapped_vram.h +++ /dev/null @@ -1,100 +0,0 @@ -#ifndef WGPU_LAB_MAPPED_VRAM -#define WGPU_LAB_MAPPED_VRAM - -#include - -#include - -namespace lab { - -// Convenience wrapper around `std::span` and `wgpu::Buffer` -// which unmaps buffer automatically on destruction. -// It's interface is similar to that of std::vector. -// Example: -// ```cpp -// auto write_func = [](MappedVRAM vmap) { -// for (int i = 0; i < vmap.capacity(); ++i) { -// vmap.push(my_data[i]); // write data into vmap -// } -// } -// const size_t capacity = 1024; -// buffer.to_device(capacity, write_func); -// ``` -template -struct MappedVRAM { - MappedVRAM(std::span view, size_t view_size, wgpu::Buffer wgpu_buffer) - : data_view{view}, view_size{view_size}, wgpu_buffer{wgpu_buffer} {} - MappedVRAM(MappedVRAM&& other) - : data_view{std::move(other.data_view)}, view_size{std::move(other.view_size)}, - wgpu_buffer{std::move(other.wgpu_buffer)} { - other.data_view = {}; - other.view_size = 0; - other.wgpu_buffer = nullptr; - } - MappedVRAM& operator=(MappedVRAM&& other) { - data_view = std::move(other.data_view); - view_size = std::move(other.view_size); - wgpu_buffer = std::move(other.wgpu_buffer); - other.data_view = {}; - other.view_size = 0; - other.wgpu_buffer = nullptr; - return *this; - } - - MappedVRAM(const MappedVRAM&) = delete; - MappedVRAM& operator=(const MappedVRAM&) = delete; - - std::span get_view() const { - assert(wgpu_buffer && view_size > 0); - return {data_view.begin(), view_size}; - } - - size_t capacity() const { return data_view.size(); } - size_t size() const { return view_size; } - - const T& operator[](size_t index) const { - assert(wgpu_buffer && index < view_size); - return data_view[index]; - } - T& operator[](size_t index) { - assert(wgpu_buffer && index < view_size); - return data_view[index]; - } - - void resize(size_t size) { - assert(wgpu_buffer && size <= capacity()); - view_size = size; - } - const T& push(const T& e) { - assert(wgpu_buffer && view_size < capacity()); - return data_view[view_size++] = e; - } - T& pop() { - assert(wgpu_buffer && view_size > 0); - return data_view[--view_size]; - } - - auto begin() const { return data_view.begin(); } - auto end() const { return data_view.begin() + view_size; } - auto begin() { return data_view.begin(); } - auto end() { return data_view.begin() + view_size; } - - bool is_mapped() { return wgpu_buffer != nullptr; } - void unmap() { - if (wgpu_buffer) { - wgpu_buffer.Unmap(); - wgpu_buffer = nullptr; - view_size = 0; - data_view = {}; - } - } - ~MappedVRAM() { unmap(); } - - wgpu::Buffer wgpu_buffer; - std::span data_view; - size_t view_size; -}; - -} // namespace lab - -#endif // WGPU_LAB_MAPPED_VRAM diff --git a/src/extra/lab_objects.h b/src/extra/lab_objects.h deleted file mode 100644 index 2708b4d..0000000 --- a/src/extra/lab_objects.h +++ /dev/null @@ -1,42 +0,0 @@ -#ifndef WGPU_LAB_OBJECTS_H -#define WGPU_LAB_OBJECTS_H - -namespace lab { - -// Forward declarations of public lab objects and corresponding handle types. -// The idea is to use these handles in maps to model relations between objects. -// See `LabWindowMap` in `lab_state.h` which maps `GLFWwindow*` to `Window*` - -using Handle = void*; // any Handle - -// lab structs ----------------------------------- - -struct Webgpu; -using WebgpuHandle = Webgpu*; - -struct Window; -using WindowHandle = Window*; - -struct Shader; -using ShaderHandle = Shader*; - -struct Surface; -using SurfaceHandle = Surface*; - -struct Pipeline; -using PipelineHandle = Pipeline*; - -template -struct Buffer; - -template -using BufferHandle = Buffer*; - -} // namespace lab - -// external structs ------------------------------ - -struct GLFWwindow; -using GLFWwindowHandle = GLFWwindow*; - -#endif // WGPU_LAB_OBJECTS_H diff --git a/src/extra/lab_pipeline_defaults.h b/src/extra/lab_pipeline_defaults.h deleted file mode 100644 index 4ba1532..0000000 --- a/src/extra/lab_pipeline_defaults.h +++ /dev/null @@ -1,55 +0,0 @@ -#include - -namespace lab::pipeline_defaults { - -struct InitConfig { - wgpu::BlendState blendState = { - .color = - { - .operation = wgpu::BlendOperation::Add, - .srcFactor = wgpu::BlendFactor::SrcAlpha, - .dstFactor = wgpu::BlendFactor::OneMinusSrcAlpha, - }, - .alpha = - { - .operation = wgpu::BlendOperation::Add, - .srcFactor = wgpu::BlendFactor::Zero, - .dstFactor = wgpu::BlendFactor::One, - }, - }; - - wgpu::ColorTargetState colorTarget = { - .writeMask = wgpu::ColorWriteMask::All, - }; - - wgpu::FragmentState fragmentState = { - .entryPoint = "fs_main", - }; - - wgpu::VertexState vertexState = { - .entryPoint = "vs_main", - }; - - wgpu::PrimitiveState primitiveState = { - .topology = wgpu::PrimitiveTopology::TriangleList, - .stripIndexFormat = wgpu::IndexFormat::Undefined, - .frontFace = wgpu::FrontFace::CCW, - .cullMode = wgpu::CullMode::None, - }; - - wgpu::MultisampleState multisampleState = { - .count = 1, - .mask = ~0u, - }; -}; - -struct RenderConfig { - wgpu::RenderPassColorAttachment renderPassColorAttachment = { - .depthSlice = WGPU_DEPTH_SLICE_UNDEFINED, - .loadOp = wgpu::LoadOp::Clear, - .storeOp = wgpu::StoreOp::Store, - .clearValue = wgpu::Color{0.08, 0.08, 0.085, 1.0}, - }; -}; - -} // namespace lab::pipeline_defaults diff --git a/src/extra/lab_public.h b/src/extra/lab_public.h deleted file mode 100644 index e2ae376..0000000 --- a/src/extra/lab_public.h +++ /dev/null @@ -1,38 +0,0 @@ -// To keep an overview over all public dependencies -// that are directly exposed to the user - -// STL dependencies ------------------------------ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef LAB_USE_STL_CHRONO_LITERALS -using namespace std::chrono_literals; -#endif - -// Dawn wgpu C++ header -#include - -// Lab objects and enums ------------------------- - -#include -#include -#include -#include -#include -#include -#include - -#include diff --git a/src/extra/lab_state.h b/src/extra/lab_state.h deleted file mode 100644 index 1bdf112..0000000 --- a/src/extra/lab_state.h +++ /dev/null @@ -1,24 +0,0 @@ -#ifndef WGPU_LAB_STATE_H -#define WGPU_LAB_STATE_H - -#include - -#include -#include - -// Note: user should not interact with state directly -// so it's not included in lab.h - -namespace lab { - -using WindowMap = std::unordered_map; - -extern struct State { - WindowMap window_map{}; - bool lab_init = false; - std::chrono::steady_clock::time_point start_tp = std::chrono::steady_clock::now(); -} state; - -} // namespace lab - -#endif // WGPU_LAB_STATE_H diff --git a/src/lab b/src/lab deleted file mode 100644 index 10245c4..0000000 --- a/src/lab +++ /dev/null @@ -1,59 +0,0 @@ -#ifndef WGPU_LAB_MAIN_H -#define WGPU_LAB_MAIN_H - -#ifndef LAB_USER_DISABLE_CHRONO_LITERALS -// User may disable `using namespace std::chrono_literals;` -// by `#define LAB_USER_DISABLE_CHRONO_LITERALS` before `#include ` -// It allows for timing based eye candy like auto time = 12ms; -#define LAB_USE_STL_CHRONO_LITERALS -#endif - -#include - -namespace lab { - -// ------------------------------------------------------------------------------------------------- -// Functions with side-effects, operating on the global application state -------------------------- - -// Processes GLFW Events, handles open Windows and closes them -// - generally should be called once per main-loop iteration -// - returns `true` as long as at least one Window is open -// ```cpp -// while(lab::tick()) { -// pipeline.render_frame(surface); // render artworks -// } -// ``` -bool tick(); - -// Automatically called by Window and Webgpu constructors -// - returns `true` if init was successfully performed -// - only inits once per program invocation -bool init_lab(); - -// ------------------------------------------------------------------------------------------------- -// Utils independent from all other lab-objects ---------------------------------------------------- - -// Wrapper for visual simplicity around `std::this_thread::sleep_for(duration);` -// - is very inaccurate and should only be used for playing around in different threads for example -// - as far as I understand, it lowers a threads priority in a queue and can lead to longer than -// expected delays and it probably differs widely on different hardware and OSs -// ```cpp -// lab::sleep(100ms); -// ``` -template -inline void sleep(const std::chrono::duration<_Rep, _Period>& d) { - std::this_thread::sleep_for(d); -} - -void restart_timer(); -std::chrono::steady_clock::time_point get_timer_start(); - -template -T elapsed_seconds() { - return static_cast(static_cast((std::chrono::steady_clock::now() - get_timer_start()).count()) / - std::chrono::steady_clock::time_point::period::den); -} - -} // namespace lab - -#endif // WGPU_LAB_MAIN_H diff --git a/src/lab_buffer.cpp b/src/lab_buffer.cpp new file mode 100644 index 0000000..66d50e8 --- /dev/null +++ b/src/lab_buffer.cpp @@ -0,0 +1,163 @@ +#include "lab_detail.h" + +#include + +#include +#include + +namespace lab::detail { + +namespace { + +// WebGPU wants buffer sizes, copy ranges and mapped ranges in multiples of 4 bytes +constexpr uint64_t align_up_4(uint64_t value) { return (value + 3) & ~uint64_t{3}; } + +// The usage flags that make write() and read() work, on top of what the buffer is for. +// A mappable buffer cannot be combined with much, so it only gets what WebGPU allows. +wgpu::BufferUsage complete_usage(wgpu::BufferUsage usage) { + if (usage & wgpu::BufferUsage::MapRead) { + return usage | wgpu::BufferUsage::CopyDst; + } + if (usage & wgpu::BufferUsage::MapWrite) { + return usage | wgpu::BufferUsage::CopySrc; + } + return usage | wgpu::BufferUsage::CopyDst | wgpu::BufferUsage::CopySrc; +} + +// A range of a buffer that is ready to be mapped: either the buffer itself or a copy of the range +struct MappableRange { + wgpu::Buffer buffer; + uint64_t offset; // where the mapping starts in `buffer` + uint64_t size; // how much to map + uint64_t skip; // bytes between the start of the mapping and the requested data +}; + +MappableRange make_mappable(const BufferCore& core, uint64_t byte_offset, uint64_t byte_count) { + // mapping wants an offset that is a multiple of 8 and a size that is a multiple of 4 + const uint64_t begin = byte_offset & ~uint64_t{7}; + const uint64_t end = align_up_4(byte_offset + byte_count); + + if (core.usage & wgpu::BufferUsage::MapRead) { + return {core.handle, begin, end - begin, byte_offset - begin}; + } + + // not mappable itself: copy the range into a staging buffer that is + wgpu::BufferDescriptor desc{ + .label = "lab read staging buffer", + .usage = wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst, + .size = end - begin, + }; + wgpu::Buffer staging = core.gpu->device.CreateBuffer(&desc); + wgpu::CommandEncoder encoder = core.gpu->device.CreateCommandEncoder(); + encoder.CopyBufferToBuffer(core.handle, begin, staging, 0, end - begin); + wgpu::CommandBuffer commands = encoder.Finish(); + core.gpu->queue.Submit(1, &commands); + return {staging, 0, end - begin, byte_offset - begin}; +} + +} // namespace + +BufferCore::BufferCore(Gpu& gpu_object, uint64_t size, wgpu::BufferUsage requested_usage, Label name, const void* data, + const std::function& fill) + : gpu{gpu_object.state()}, usage{complete_usage(requested_usage)}, byte_size{size}, label{std::move(name.text)} { + const bool initialize = data != nullptr || fill != nullptr; + wgpu::BufferDescriptor desc{ + .label = std::string_view(label), + .usage = usage, + .size = std::max(align_up_4(byte_size), 4), + .mappedAtCreation = initialize, + }; + handle = gpu->device.CreateBuffer(&desc); + if (!handle) { + fail(label, std::format("could not create a buffer of {} bytes", byte_size)); + } + if (initialize) { + void* mapped = handle.GetMappedRange(); + if (data) { + std::memcpy(mapped, data, byte_size); + } else { + fill(mapped); + } + handle.Unmap(); + } +} + +void BufferCore::write(uint64_t byte_offset, const void* data, uint64_t byte_count) const { + if (byte_count == 0) { + return; + } + if (byte_offset % 4 != 0 || byte_count % 4 != 0) { + // the end of the buffer is padded, so a write that reaches it may be rounded up: + // the part that is a multiple of 4 bytes goes as it is, the 1 to 3 bytes left over in a padded word + if (byte_offset % 4 == 0 && byte_offset + byte_count == byte_size) { + const uint64_t whole = byte_count & ~uint64_t{3}; + if (whole > 0) { + gpu->queue.WriteBuffer(handle, byte_offset, data, whole); + } + char last_word[4] = {}; + std::memcpy(last_word, static_cast(data) + whole, byte_count - whole); + gpu->queue.WriteBuffer(handle, byte_offset + whole, last_word, sizeof(last_word)); + return; + } + fail(label, std::format("write: WebGPU writes buffers in multiples of 4 bytes, but this write covers bytes " + "[{}, {}). Write an even number of elements.", + byte_offset, byte_offset + byte_count)); + } + gpu->queue.WriteBuffer(handle, byte_offset, data, byte_count); +} + +void BufferCore::read(uint64_t byte_offset, void* out, uint64_t byte_count) const { + if (byte_count == 0) { + return; + } + const MappableRange range = make_mappable(*this, byte_offset, byte_count); + + bool mapped = false; + gpu->wait(range.buffer.MapAsync( + wgpu::MapMode::Read, range.offset, range.size, wgpu::CallbackMode::WaitAnyOnly, + [](wgpu::MapAsyncStatus status, wgpu::StringView, bool* mapped) { + *mapped = status == wgpu::MapAsyncStatus::Success; + }, + &mapped)); + if (!mapped) { + fail(label, "read: the buffer could not be mapped"); + } + + const auto* bytes = static_cast(range.buffer.GetConstMappedRange(range.offset, range.size)); + std::memcpy(out, bytes + range.skip, byte_count); + range.buffer.Unmap(); +} + +void BufferCore::read_async(uint64_t byte_offset, uint64_t byte_count, ReadCallback callback) const { + if (byte_count == 0) { + callback(nullptr, 0); + return; + } + + // everything the callback needs, kept alive until it has run + struct Request { + MappableRange range; + uint64_t byte_count; + ReadCallback callback; + std::string label; + }; + auto* request = new Request{make_mappable(*this, byte_offset, byte_count), byte_count, std::move(callback), label}; + + request->range.buffer.MapAsync( + wgpu::MapMode::Read, request->range.offset, request->range.size, wgpu::CallbackMode::AllowProcessEvents, + [](wgpu::MapAsyncStatus status, wgpu::StringView message, Request* raw) { + std::unique_ptr request{raw}; + if (status != wgpu::MapAsyncStatus::Success) { + // also reached when the Gpu is destroyed before the read has finished + log(LogLevel::warn, "{}: read_async did not finish: {}", request->label, std::string_view(message)); + return; + } + const MappableRange& range = request->range; + const auto* bytes = static_cast(range.buffer.GetConstMappedRange(range.offset, range.size)); + request->callback(bytes + range.skip, request->byte_count); + range.buffer.Unmap(); + }, + request); +} + +} // namespace lab::detail diff --git a/src/lab_compute.cpp b/src/lab_compute.cpp new file mode 100644 index 0000000..f441578 --- /dev/null +++ b/src/lab_compute.cpp @@ -0,0 +1,90 @@ +#include "lab_detail.h" + +#include + +namespace lab { + +ComputePipeline::ComputePipeline(Gpu& gpu_object, const Shader& shader, ComputeDesc desc) + : gpu{gpu_object.state()}, + name{desc.label.empty() ? std::format("compute pipeline({})", shader.label()) : desc.label} { + wgpu::ComputePipelineDescriptor pipeline_desc; + pipeline_desc.label = std::string_view(name); + pipeline_desc.compute.module = shader.handle(); + if (!desc.entry.empty()) { + pipeline_desc.compute.entryPoint = std::string_view(desc.entry); + } + if (!desc.layouts.empty()) { + wgpu::PipelineLayoutDescriptor layout_desc; + layout_desc.bindGroupLayoutCount = desc.layouts.size(); + layout_desc.bindGroupLayouts = desc.layouts.data(); + pipeline_desc.layout = gpu->device.CreatePipelineLayout(&layout_desc); + } + + if (auto error = detail::capture_error(*gpu, [&] { pipeline = gpu->device.CreateComputePipeline(&pipeline_desc); })) { + detail::fail(name, *error); + } +} + +wgpu::BindGroup ComputePipeline::bind_group(uint32_t group, std::initializer_list bindings, + std::string_view label) const { + const std::string group_label = label.empty() ? std::format("{} bind group {}", name, group) : std::string{label}; + + wgpu::BindGroupLayout layout; + if (auto error = detail::capture_error(*gpu, [&] { layout = pipeline.GetBindGroupLayout(group); })) { + detail::fail(group_label, *error); + } + return detail::make_bind_group(gpu, layout, bindings, group_label); +} + +void ComputePipeline::run(std::initializer_list bind_groups, uint32_t x, uint32_t y, + uint32_t z) const { + wgpu::CommandEncoder encoder = gpu->device.CreateCommandEncoder(); + wgpu::ComputePassEncoder pass = encoder.BeginComputePass(); + pass.SetPipeline(pipeline); + uint32_t index = 0; + for (const wgpu::BindGroup& group : bind_groups) { + pass.SetBindGroup(index++, group); + } + pass.DispatchWorkgroups(x, y, z); + pass.End(); + wgpu::CommandBuffer commands = encoder.Finish(); + gpu->queue.Submit(1, &commands); +} + +ComputePass::ComputePass(Frame& target_frame, std::string label) : frame{&target_frame} { + if (frame->submitted) { + detail::fail(label, "the frame of this pass has already been submitted"); + } + if (frame->pass_open) { + detail::fail(label, "another pass of the same frame is still open, end it first"); + } + wgpu::ComputePassDescriptor desc; + desc.label = std::string_view(label); + encoder = frame->encoder.BeginComputePass(&desc); + frame->pass_open = true; +} + +ComputePass::~ComputePass() { end(); } + +void ComputePass::end() { + if (encoder) { + encoder.End(); + encoder = nullptr; + frame->pass_open = false; + } +} + +void ComputePass::dispatch(const ComputePipeline& pipeline, std::initializer_list bind_groups, + uint32_t x, uint32_t y, uint32_t z) { + if (!encoder) { + detail::fail(pipeline.label(), "dispatch: the compute pass has already ended"); + } + encoder.SetPipeline(pipeline.handle()); + uint32_t index = 0; + for (const wgpu::BindGroup& group : bind_groups) { + encoder.SetBindGroup(index++, group); + } + encoder.DispatchWorkgroups(x, y, z); +} + +} // namespace lab diff --git a/src/lab_detail.h b/src/lab_detail.h new file mode 100644 index 0000000..83b88d4 --- /dev/null +++ b/src/lab_detail.h @@ -0,0 +1,175 @@ +#ifndef WGPU_LAB_DETAIL_H +#define WGPU_LAB_DETAIL_H + +// Private to the library: the state behind the public objects. +// +// No lab object holds a reference to another lab object. They share these state +// structs through shared_ptr instead, so the order in which a program destroys +// its objects does not matter. + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +struct GLFWwindow; + +namespace lab::detail { + +// logging with std::format --------------------------------------------------------------------- + +template +void log(LogLevel level, std::format_string format, Args&&... args) { + if (level >= log_level()) { + lab::log(level, std::format(format, std::forward(args)...)); + } +} + +// Gpu ------------------------------------------------------------------------------------------ + +struct GpuState { + wgpu::Instance instance; + wgpu::Adapter adapter; + wgpu::Device device; + wgpu::Queue queue; + + std::string label; + bool throw_on_error = true; + std::vector errors; // every error the device has reported + size_t errors_thrown = 0; // how many of them poll() has thrown already + + GpuState(); + ~GpuState(); + GpuState(const GpuState&) = delete; + GpuState& operator=(const GpuState&) = delete; + + // blocks until the future is done + void wait(wgpu::Future future) const; + + // processes callbacks and throws the errors that have not been thrown yet + void poll(); +}; + +// Runs `create` and returns the validation error it caused, if any. Used to turn +// WebGPU errors into exceptions right where an object is created, instead of +// having them reported later without context. +std::optional capture_error(const GpuState& gpu, const std::function& create); + +// Copies a texture back from the GPU and writes it to a PNG file (8-bit RGBA and BGRA formats) +void save_texture_png(const GpuState& gpu, const wgpu::Texture& texture, const std::filesystem::path& path, + std::string_view label); + +// Finds `file` as it is or relative to the directory of the executable, see lab::find_file. +// `label` names the object the file is for, in the error that is thrown if it is not found. +std::filesystem::path locate_file(const std::filesystem::path& file, std::string_view label); + +// Window --------------------------------------------------------------------------------------- + +struct WindowState { + GLFWwindow* handle = nullptr; + bool open = false; + + // input, the "pressed" and "released" sets are cleared by every tick() + static constexpr size_t key_count = 512; + static constexpr size_t button_count = 8; + std::bitset keys_down, keys_pressed, keys_released; + std::bitset buttons_down, buttons_pressed, buttons_released; + Vec2 mouse, mouse_delta, scroll; + bool mouse_known = false; // false until the first cursor position arrives + + std::function on_key; + std::function on_mouse_button; + std::function on_scroll; + std::function on_resize; + + WindowState() = default; + ~WindowState(); + WindowState(const WindowState&) = delete; + WindowState& operator=(const WindowState&) = delete; + + void close(); + void begin_tick(); // forget what happened during the previous tick +}; + +// Surface -------------------------------------------------------------------------------------- + +struct SurfaceState { + std::shared_ptr gpu; + std::shared_ptr window; + wgpu::Surface handle; + wgpu::TextureFormat format = wgpu::TextureFormat::Undefined; + wgpu::PresentMode present_mode = wgpu::PresentMode::Fifo; + wgpu::CompositeAlphaMode alpha_mode = wgpu::CompositeAlphaMode::Auto; + Size configured_size; // {0, 0} while not configured + wgpu::Texture texture; // of the frame being rendered, null between frames + wgpu::TextureView view; // a view of `texture` + wgpu::TextureFormat depth_format = wgpu::TextureFormat::Undefined; + wgpu::Texture depth_texture; // as large as the surface, null without a depth format + std::string label; + + // Frame capture: with the environment variable LAB_CAPTURE_DIR set, every surface + // saves one frame as a PNG file into that directory (the frame number can be chosen + // with LAB_CAPTURE_FRAME, it is 30 by default). Used to check what programs render + // without looking at them, and to make screenshots. + std::filesystem::path capture_path; // empty: no capture + long capture_frame = 0; + long frames_presented = 0; + + ~SurfaceState(); + + Size framebuffer_size() const; + void configure(Size size); + wgpu::TextureView current_view(); + void present(); +}; + +// Runtime: what is global by nature ------------------------------------------------------------- +// GLFW is one per process, tick() has to reach every window and every Gpu, +// and there is one clock. + +struct Runtime { + static Runtime& get(); + + bool glfw_ready = false; + std::vector windows; // open windows + std::vector retired_windows; // closed, waiting to be destroyed + std::vector gpus; // all live GPUs + + using Clock = std::chrono::steady_clock; + Clock::time_point timer_start = Clock::now(); + Clock::time_point last_tick = Clock::now(); + float delta_seconds = 0.0f; + + long exit_after_frames = 0; // from LAB_EXIT_AFTER_FRAMES, 0: never + int surfaces_created = 0; // numbers the capture files of programs with several surfaces + long tick_count = 0; + bool presented = false; // has a surface been presented since the last tick()? + + void init_glfw(); + + // A native window has to outlive the swapchain that presents into it, and the + // GPU releases swapchains late: when its device is destroyed. A window that is + // no longer needed is therefore only destroyed once no Gpu is alive. + void retire_window(GLFWwindow* window); + void destroy_retired_windows(); + + ~Runtime(); + +private: + Runtime(); +}; + +} // namespace lab::detail + +#endif // WGPU_LAB_DETAIL_H diff --git a/src/lab_frame.cpp b/src/lab_frame.cpp new file mode 100644 index 0000000..a37e505 --- /dev/null +++ b/src/lab_frame.cpp @@ -0,0 +1,225 @@ +#include "lab_detail.h" + +#include + +#include + +#include +#include +#include + +namespace lab { + +namespace { + +std::string describe(const TargetFormat& format) { + std::ostringstream stream; + stream << format.color; + if (format.depth != wgpu::TextureFormat::Undefined) { + stream << " with depth " << format.depth; + } + return stream.str(); +} + +} // namespace + +// Frame ----------------------------------------------------------------------------------------- + +Frame::Frame(const Gpu& gpu_object) : gpu{gpu_object.state()}, encoder{gpu->device.CreateCommandEncoder()} {} + +Frame::Frame(const Surface& surface) : gpu{surface.state()->gpu}, encoder{gpu->device.CreateCommandEncoder()} {} + +Frame::~Frame() { + if (!submitted) { + try { + submit(); + } catch (const Error&) { + // already logged, and a destructor must not throw + } + } +} + +void Frame::submit() { + if (submitted) { + return; + } + if (pass_open) { + detail::fail("frame", "submit: a pass of this frame has not been ended yet"); + } + submitted = true; + + wgpu::CommandBuffer commands = encoder.Finish(); + gpu->queue.Submit(1, &commands); + + for (const auto& surface : surfaces) { + surface->present(); + } + surfaces.clear(); +} + +// RenderPass ------------------------------------------------------------------------------------ + +void RenderPass::begin(Frame& target_frame, wgpu::TextureView view, wgpu::TextureView depth_view, TargetFormat format, + const PassOptions& options) { + frame = &target_frame; + target = format; + label = options.label; + + if (!view) { + return; // nothing to render onto: the pass stays inactive and draw calls do nothing + } + if (frame->submitted) { + detail::fail(label, "the frame of this pass has already been submitted"); + } + if (frame->pass_open) { + detail::fail(label, "another pass of the same frame is still open, end it first"); + } + + wgpu::RenderPassColorAttachment attachment{ + .view = view, + .loadOp = options.clear ? wgpu::LoadOp::Clear : wgpu::LoadOp::Load, + .storeOp = wgpu::StoreOp::Store, + .clearValue = options.clear.value_or(wgpu::Color{}), + }; + wgpu::RenderPassDescriptor desc{ + .label = std::string_view(label), + .colorAttachmentCount = 1, + .colorAttachments = &attachment, + }; + + wgpu::RenderPassDepthStencilAttachment depth_attachment; + if (depth_view) { + depth_attachment.view = depth_view; + depth_attachment.depthLoadOp = options.clear_depth ? wgpu::LoadOp::Clear : wgpu::LoadOp::Load; + depth_attachment.depthStoreOp = wgpu::StoreOp::Store; + depth_attachment.depthClearValue = options.clear_depth.value_or(1.0f); + if (format.depth == wgpu::TextureFormat::Depth24PlusStencil8 || + format.depth == wgpu::TextureFormat::Depth32FloatStencil8) { + depth_attachment.stencilLoadOp = wgpu::LoadOp::Clear; + depth_attachment.stencilStoreOp = wgpu::StoreOp::Store; + } + desc.depthStencilAttachment = &depth_attachment; + } + + encoder = frame->encoder.BeginRenderPass(&desc); + frame->pass_open = true; +} + +RenderPass::RenderPass(Surface& surface, PassOptions options) { + own_frame.emplace(surface); + // the color view comes first: getting it is what makes the surface adapt to a new window size + wgpu::TextureView view = surface.current_view(); + begin(*own_frame, view, surface.depth_view(), surface, options); + if (encoder) { + own_frame->surfaces.push_back(surface.state()); + } +} + +RenderPass::RenderPass(Frame& target_frame, Surface& surface, PassOptions options) { + wgpu::TextureView view = surface.current_view(); // first, see above + begin(target_frame, view, surface.depth_view(), surface, options); + if (encoder && std::ranges::find(frame->surfaces, surface.state()) == frame->surfaces.end()) { + frame->surfaces.push_back(surface.state()); + } +} + +RenderPass::RenderPass(Frame& target_frame, const Texture& texture, PassOptions options) { + if (options.depth) { + begin(target_frame, texture.view(), options.depth->view(), TargetFormat{texture, *options.depth}, options); + } else { + begin(target_frame, texture.view(), nullptr, texture, options); + } +} + +RenderPass::~RenderPass() { + try { + end(); + } catch (const Error&) { + // already logged, and a destructor must not throw + } +} + +void RenderPass::end() { + if (encoder) { + encoder.End(); + encoder = nullptr; + frame->pass_open = false; + } + if (own_frame) { + own_frame->submit(); + } +} + +void RenderPass::draw(const Pipeline& pipeline, const Draw& draw) { + if (!encoder) { + return; + } + + if (pipeline.target() != target) { + detail::fail(pipeline.label(), + std::format("draw: the pipeline was built for the target format {}, but \"{}\" renders to {}. " + "A pipeline can only draw to the kind of target it was created for.", + describe(pipeline.target()), label, describe(target))); + } + + const auto& slots = pipeline.slots(); + if (draw.vertex_buffers.size() != slots.size()) { + detail::fail(pipeline.label(), std::format("draw: the pipeline declares {} vertex buffer(s), but the draw call " + "provides {}", + slots.size(), draw.vertex_buffers.size())); + } + + // how much the buffers hold, in case the draw call leaves the counts open + constexpr uint64_t unknown = std::numeric_limits::max(); + uint64_t vertices_available = unknown; + uint64_t instances_available = unknown; + + encoder.SetPipeline(pipeline.handle()); + for (uint32_t slot = 0; slot < slots.size(); ++slot) { + const VertexBufferRef& buffer = draw.vertex_buffers[slot]; + if (buffer.stride != slots[slot].stride) { + detail::fail(pipeline.label(), + std::format("draw: vertex buffer {} has elements of {} bytes, but the pipeline declares {} bytes " + "for it: is it the buffer the layout was written for?", + slot, buffer.stride, slots[slot].stride)); + } + uint64_t& available = + slots[slot].step_mode == wgpu::VertexStepMode::Instance ? instances_available : vertices_available; + available = std::min(available, buffer.count); + encoder.SetVertexBuffer(slot, buffer.buffer); + } + for (uint32_t group = 0; group < draw.bind_groups.size(); ++group) { + encoder.SetBindGroup(group, draw.bind_groups[group]); + } + + const uint32_t instances = + draw.instances != Draw::all ? draw.instances + : instances_available == unknown + ? 1 + : static_cast(instances_available - std::min(draw.first_instance, instances_available)); + + if (draw.index_buffer.buffer) { + const IndexBufferRef& indices = draw.index_buffer; + const uint32_t count = draw.count != Draw::all + ? draw.count + : static_cast(indices.count - std::min(draw.first, indices.count)); + encoder.SetIndexBuffer(indices.buffer, indices.format); + encoder.DrawIndexed(count, instances, draw.first, draw.base_vertex, draw.first_instance); + } else { + if (draw.count == Draw::all && vertices_available == unknown) { + detail::fail(pipeline.label(), "draw: there is no vertex buffer to take the number of vertices from, " + "set Draw::count"); + } + const uint32_t count = + draw.count != Draw::all + ? draw.count + : static_cast(vertices_available - std::min(draw.first, vertices_available)); + encoder.Draw(count, instances, draw.first, draw.first_instance); + } +} + +void RenderPass::draw(const Pipeline& pipeline, uint32_t vertex_count, uint32_t instance_count) { + draw(pipeline, Draw{.count = vertex_count, .instances = instance_count}); +} + +} // namespace lab diff --git a/src/lab_gpu.cpp b/src/lab_gpu.cpp new file mode 100644 index 0000000..ae17b11 --- /dev/null +++ b/src/lab_gpu.cpp @@ -0,0 +1,175 @@ +#include "lab_detail.h" + +#include + +#include +#include + +namespace lab { + +namespace detail { + +namespace { + +// webgpu_cpp_print.h can only print to streams +template +std::string to_string(const T& value) { + std::ostringstream stream; + stream << value; + return stream.str(); +} + +} // namespace + +GpuState::GpuState() { Runtime::get().gpus.push_back(this); } + +GpuState::~GpuState() { + // release the device before the windows it may still hold swapchains for + queue = nullptr; + device = nullptr; + adapter = nullptr; + instance = nullptr; + + Runtime& runtime = Runtime::get(); + std::erase(runtime.gpus, this); + runtime.destroy_retired_windows(); +} + +void GpuState::wait(wgpu::Future future) const { + if (instance.WaitAny(future, UINT64_MAX) != wgpu::WaitStatus::Success) { + fail(label, "waiting for the GPU failed"); + } +} + +void GpuState::poll() { + instance.ProcessEvents(); + if (throw_on_error && errors_thrown < errors.size()) { + std::string message = errors[errors_thrown]; + if (errors.size() - errors_thrown > 1) { + message += std::format("\n(and {} more errors, see the log)", errors.size() - errors_thrown - 1); + } + errors_thrown = errors.size(); + throw Error(std::format("{}: the GPU reported an error: {}", label, message)); + } +} + +std::optional capture_error(const GpuState& gpu, const std::function& create) { + gpu.device.PushErrorScope(wgpu::ErrorFilter::Validation); + create(); + std::optional error; + gpu.wait(gpu.device.PopErrorScope( + wgpu::CallbackMode::WaitAnyOnly, + [](wgpu::PopErrorScopeStatus, wgpu::ErrorType type, wgpu::StringView message, std::optional* error) { + if (type != wgpu::ErrorType::NoError) { + *error = std::string(std::string_view(message)); + } + }, + &error)); + return error; +} + +} // namespace detail + +Gpu::Gpu(GpuOptions options) : shared_state{std::make_shared()} { + detail::GpuState& state = *shared_state; + state.label = options.label; + state.throw_on_error = options.throw_on_error; + + // needed for the blocking waits (creating the device, reading buffers back, ...) + static const wgpu::InstanceFeatureName instance_features[] = {wgpu::InstanceFeatureName::TimedWaitAny}; + wgpu::InstanceDescriptor instance_desc{ + .requiredFeatureCount = std::size(instance_features), + .requiredFeatures = instance_features, + }; + state.instance = wgpu::CreateInstance(&instance_desc); + if (!state.instance) { + detail::fail(state.label, "could not create a WebGPU instance"); + } + + // the software adapter is the last resort on machines without a GPU (CI, virtual machines) + for (bool fallback : {options.fallback_adapter, true}) { + wgpu::RequestAdapterOptions adapter_options{ + .powerPreference = options.power, + .forceFallbackAdapter = fallback, + .backendType = options.backend, + }; + state.wait(state.instance.RequestAdapter( + &adapter_options, wgpu::CallbackMode::WaitAnyOnly, + [](wgpu::RequestAdapterStatus, wgpu::Adapter adapter, wgpu::StringView, wgpu::Adapter* result) { + *result = std::move(adapter); + }, + &state.adapter)); + if (state.adapter) { + break; + } + } + if (!state.adapter) { + detail::fail(state.label, "no graphics adapter found: neither a GPU nor a software adapter"); + } + + wgpu::AdapterInfo info; + state.adapter.GetInfo(&info); + detail::log(LogLevel::info, "{}: using {} ({}, {})", state.label, std::string_view(info.device), + detail::to_string(info.backendType), detail::to_string(info.adapterType)); + + wgpu::DeviceDescriptor device_desc; + device_desc.label = std::string_view(state.label); + device_desc.requiredFeatureCount = options.features.size(); + device_desc.requiredFeatures = options.features.data(); + + // Both callbacks can run long after this constructor. They only get a raw pointer + // to the state, which is safe: the state owns the device and so outlives its callbacks. + device_desc.SetDeviceLostCallback( + wgpu::CallbackMode::AllowSpontaneous, + [](const wgpu::Device&, wgpu::DeviceLostReason reason, wgpu::StringView message, detail::GpuState* state) { + // a device that goes away with its Gpu object is business as usual + if (reason != wgpu::DeviceLostReason::Destroyed && reason != wgpu::DeviceLostReason::CallbackCancelled) { + detail::log(LogLevel::error, "{}: device lost ({}): {}", state->label, detail::to_string(reason), + std::string_view(message)); + } + }, + &state); + device_desc.SetUncapturedErrorCallback( + [](const wgpu::Device&, wgpu::ErrorType type, wgpu::StringView message, detail::GpuState* state) { + // Thrown later by poll(): this callback is called from inside Dawn, + // and an exception must not travel through its stack frames. + std::string text = std::format("{}: {}", detail::to_string(type), std::string_view(message)); + detail::log(LogLevel::error, "{}: {}", state->label, text); + state->errors.push_back(std::move(text)); + }, + &state); + + struct DeviceRequest { + wgpu::Device device; + std::string message; + } request; + state.wait(state.adapter.RequestDevice( + &device_desc, wgpu::CallbackMode::WaitAnyOnly, + [](wgpu::RequestDeviceStatus, wgpu::Device device, wgpu::StringView message, DeviceRequest* request) { + request->device = std::move(device); + request->message = std::string(std::string_view(message)); + }, + &request)); + if (!request.device) { + detail::fail(state.label, std::format("could not create a device: {}", request.message)); + } + state.device = std::move(request.device); + + state.queue = state.device.GetQueue(); +} + +const wgpu::Instance& Gpu::instance() const { return shared_state->instance; } +const wgpu::Adapter& Gpu::adapter() const { return shared_state->adapter; } +const wgpu::Device& Gpu::device() const { return shared_state->device; } +const wgpu::Queue& Gpu::queue() const { return shared_state->queue; } + +void Gpu::poll() { shared_state->poll(); } + +void Gpu::wait_idle() { + shared_state->wait(shared_state->queue.OnSubmittedWorkDone(wgpu::CallbackMode::WaitAnyOnly, + [](wgpu::QueueWorkDoneStatus, wgpu::StringView) {})); +} + +const std::vector& Gpu::errors() const { return shared_state->errors; } + +} // namespace lab diff --git a/src/lab_image.cpp b/src/lab_image.cpp new file mode 100644 index 0000000..902e60a --- /dev/null +++ b/src/lab_image.cpp @@ -0,0 +1,167 @@ +#include "lab_detail.h" + +#include + +#include +#include +#include +#include + +// A minimal PNG writer, so that saving a screenshot needs no image library. +// The pixel data is stored without compression. + +namespace lab { + +namespace { + +uint32_t crc32(uint32_t crc, const uint8_t* data, size_t size) { + static const std::array table = [] { + std::array table{}; + for (uint32_t n = 0; n < 256; ++n) { + uint32_t c = n; + for (int k = 0; k < 8; ++k) { + c = (c & 1) ? 0xEDB88320u ^ (c >> 1) : c >> 1; + } + table[n] = c; + } + return table; + }(); + crc = ~crc; + for (size_t i = 0; i < size; ++i) { + crc = table[(crc ^ data[i]) & 0xFF] ^ (crc >> 8); + } + return ~crc; +} + +void push_u32(std::vector& out, uint32_t value) { + for (int shift = 24; shift >= 0; shift -= 8) { + out.push_back(static_cast(value >> shift)); + } +} + +void write_chunk(std::ofstream& file, const char type[4], const std::vector& data) { + std::vector chunk; + push_u32(chunk, static_cast(data.size())); + chunk.insert(chunk.end(), type, type + 4); + chunk.insert(chunk.end(), data.begin(), data.end()); + push_u32(chunk, crc32(0, chunk.data() + 4, chunk.size() - 4)); // the checksum covers type and data + file.write(reinterpret_cast(chunk.data()), static_cast(chunk.size())); +} + +} // namespace + +namespace detail { + +void write_png(const std::filesystem::path& path, uint32_t width, uint32_t height, const uint8_t* rgba) { + std::ofstream file(path, std::ios::binary); + if (!file) { + fail("save_png", std::format("could not open \"{}\" for writing", path.string())); + } + static constexpr uint8_t signature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}; + file.write(reinterpret_cast(signature), sizeof(signature)); + + std::vector header; + push_u32(header, width); + push_u32(header, height); + header.insert(header.end(), {8, 6, 0, 0, 0}); // 8 bits per channel, RGBA, no interlacing + write_chunk(file, "IHDR", header); + + // every row is preceded by a filter byte (0: none) + std::vector raw; + raw.reserve((size_t{width} * 4 + 1) * height); + for (uint32_t y = 0; y < height; ++y) { + raw.push_back(0); + raw.insert(raw.end(), rgba + size_t{y} * width * 4, rgba + size_t{y + 1} * width * 4); + } + + // a zlib stream of "stored" deflate blocks, which hold at most 65535 bytes each + std::vector zlib = {0x78, 0x01}; + uint32_t adler_a = 1, adler_b = 0; + for (size_t offset = 0; offset < raw.size();) { + const size_t block = std::min(65535, raw.size() - offset); + const bool last = offset + block == raw.size(); + zlib.push_back(last ? 1 : 0); + zlib.push_back(static_cast(block & 0xFF)); + zlib.push_back(static_cast(block >> 8)); + zlib.push_back(static_cast(~block & 0xFF)); + zlib.push_back(static_cast((~block >> 8) & 0xFF)); + for (size_t i = offset; i < offset + block; ++i) { + adler_a = (adler_a + raw[i]) % 65521; + adler_b = (adler_b + adler_a) % 65521; + } + zlib.insert(zlib.end(), raw.begin() + static_cast(offset), + raw.begin() + static_cast(offset + block)); + offset += block; + } + push_u32(zlib, (adler_b << 16) | adler_a); + write_chunk(file, "IDAT", zlib); + write_chunk(file, "IEND", {}); +} + +void save_texture_png(const GpuState& gpu, const wgpu::Texture& texture, const std::filesystem::path& path, + std::string_view label) { + const wgpu::TextureFormat format = texture.GetFormat(); + const bool bgra = format == wgpu::TextureFormat::BGRA8Unorm || format == wgpu::TextureFormat::BGRA8UnormSrgb; + const bool rgba = format == wgpu::TextureFormat::RGBA8Unorm || format == wgpu::TextureFormat::RGBA8UnormSrgb; + if (!bgra && !rgba) { + fail(label, "save_png: only textures with 8-bit RGBA or BGRA formats can be saved"); + } + const uint32_t width = texture.GetWidth(), height = texture.GetHeight(); + + // rows in the buffer a texture is copied to have to start at multiples of 256 bytes + const uint64_t row_bytes = uint64_t{width} * 4; + const uint64_t padded_row_bytes = (row_bytes + 255) & ~uint64_t{255}; + wgpu::BufferDescriptor buffer_desc{ + .label = "lab save_png staging buffer", + .usage = wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst, + .size = padded_row_bytes * height, + }; + wgpu::Buffer staging = gpu.device.CreateBuffer(&buffer_desc); + + wgpu::TexelCopyTextureInfo source; + source.texture = texture; + wgpu::TexelCopyBufferInfo destination; + destination.buffer = staging; + destination.layout.bytesPerRow = static_cast(padded_row_bytes); + destination.layout.rowsPerImage = height; + const wgpu::Extent3D extent{width, height, 1}; + + wgpu::CommandEncoder encoder = gpu.device.CreateCommandEncoder(); + encoder.CopyTextureToBuffer(&source, &destination, &extent); + wgpu::CommandBuffer commands = encoder.Finish(); + gpu.queue.Submit(1, &commands); + + bool mapped = false; + gpu.wait(staging.MapAsync( + wgpu::MapMode::Read, 0, wgpu::kWholeMapSize, wgpu::CallbackMode::WaitAnyOnly, + [](wgpu::MapAsyncStatus status, wgpu::StringView, bool* mapped) { + *mapped = status == wgpu::MapAsyncStatus::Success; + }, + &mapped)); + if (!mapped) { + fail(label, "save_png: the texture could not be copied back"); + } + + const auto* bytes = static_cast(staging.GetConstMappedRange()); + std::vector pixels(row_bytes * height); + for (uint32_t y = 0; y < height; ++y) { + const uint8_t* row = bytes + y * padded_row_bytes; + uint8_t* out = pixels.data() + y * row_bytes; + for (uint32_t x = 0; x < width; ++x) { + out[x * 4 + 0] = row[x * 4 + (bgra ? 2 : 0)]; + out[x * 4 + 1] = row[x * 4 + 1]; + out[x * 4 + 2] = row[x * 4 + (bgra ? 0 : 2)]; + out[x * 4 + 3] = row[x * 4 + 3]; + } + } + staging.Unmap(); + + write_png(path, width, height, pixels.data()); + log(LogLevel::debug, "{}: saved \"{}\"", label, path.string()); +} + +} // namespace detail + +void Texture::save_png(const std::filesystem::path& path) const { detail::save_texture_png(*gpu, texture, path, name); } + +} // namespace lab diff --git a/src/lab_pipeline.cpp b/src/lab_pipeline.cpp new file mode 100644 index 0000000..4998d3b --- /dev/null +++ b/src/lab_pipeline.cpp @@ -0,0 +1,158 @@ +#include "lab_detail.h" + +#include +#include + +#include + +#include +#include + +namespace lab { + +namespace detail { + +VertexLayout make_vertex_layout(std::initializer_list attributes, uint64_t stride, + wgpu::VertexStepMode step_mode) { + VertexLayout layout{.attributes = attributes, .stride = stride, .step_mode = step_mode}; + + // attributes without an offset follow the one before them + uint64_t next_offset = 0; + for (VertexAttribute& attribute : layout.attributes) { + if (attribute.offset == VertexAttribute::next_offset) { + attribute.offset = next_offset; + } + next_offset = attribute.offset + vertex_format_size(attribute.format); + if (next_offset > stride) { + fail("vertex layout", std::format("the attributes reach byte {} of a vertex, but the vertex type has only " + "{} bytes: do the formats match the members of the type?", + next_offset, stride)); + } + } + return layout; +} + +wgpu::BindGroup make_bind_group(const std::shared_ptr& gpu, const wgpu::BindGroupLayout& layout, + std::initializer_list bindings, std::string_view label) { + std::vector entries; + entries.reserve(bindings.size()); + for (const Binding& binding : bindings) { + wgpu::BindGroupEntry entry; + entry.binding = binding.binding; + entry.buffer = binding.buffer; + entry.offset = binding.offset; + entry.size = binding.size; + entry.sampler = binding.sampler; + entry.textureView = binding.texture_view; + entries.push_back(entry); + } + + wgpu::BindGroupDescriptor desc; + desc.label = label; + desc.layout = layout; + desc.entryCount = entries.size(); + desc.entries = entries.data(); + + wgpu::BindGroup group; + if (auto error = capture_error(*gpu, [&] { group = gpu->device.CreateBindGroup(&desc); })) { + fail(label, std::format("{}\nNote: a binding that the shader declares but does not use is not part of the " + "layout WebGPU derives from the shader.", + *error)); + } + return group; +} + +} // namespace detail + +Pipeline::Pipeline(Gpu& gpu_object, const Shader& shader, PipelineDesc desc) + : gpu{gpu_object.state()}, target_format{desc.target}, + name{desc.label.empty() ? std::format("pipeline({})", shader.label()) : desc.label} { + // vertex buffers: shader locations count up across all slots unless they are given + std::vector> attributes(desc.vertex_buffers.size()); + std::vector buffer_layouts; + uint32_t next_location = 0; + for (size_t slot = 0; slot < desc.vertex_buffers.size(); ++slot) { + const VertexLayout& layout = desc.vertex_buffers[slot]; + for (const VertexAttribute& attribute : layout.attributes) { + const uint32_t location = + attribute.location == VertexAttribute::next_location ? next_location : attribute.location; + next_location = location + 1; + attributes[slot].push_back({.format = attribute.format, .offset = attribute.offset, .shaderLocation = location}); + } + buffer_layouts.push_back({ + .stepMode = layout.step_mode, + .arrayStride = layout.stride, + .attributeCount = attributes[slot].size(), + .attributes = attributes[slot].data(), + }); + vertex_slots.push_back({layout.stride, layout.step_mode}); + } + + wgpu::ColorTargetState color_target; + color_target.format = desc.target.color; + color_target.blend = desc.blend ? &*desc.blend : nullptr; + + wgpu::FragmentState fragment; + fragment.module = shader.handle(); + fragment.targetCount = 1; + fragment.targets = &color_target; + if (!desc.fragment_entry.empty()) { + fragment.entryPoint = std::string_view(desc.fragment_entry); + } + + wgpu::DepthStencilState depth_stencil; + depth_stencil.format = desc.target.depth; + depth_stencil.depthWriteEnabled = desc.depth_write ? wgpu::OptionalBool::True : wgpu::OptionalBool::False; + depth_stencil.depthCompare = desc.depth_compare; + + wgpu::RenderPipelineDescriptor pipeline_desc; + pipeline_desc.label = std::string_view(name); + pipeline_desc.vertex.module = shader.handle(); + pipeline_desc.vertex.bufferCount = buffer_layouts.size(); + pipeline_desc.vertex.buffers = buffer_layouts.data(); + if (!desc.vertex_entry.empty()) { + pipeline_desc.vertex.entryPoint = std::string_view(desc.vertex_entry); + } + pipeline_desc.primitive.topology = desc.topology; + pipeline_desc.primitive.cullMode = desc.cull; + pipeline_desc.fragment = &fragment; + if (desc.target.depth != wgpu::TextureFormat::Undefined) { + pipeline_desc.depthStencil = &depth_stencil; + } + if (!desc.layouts.empty()) { + wgpu::PipelineLayoutDescriptor layout_desc; + layout_desc.bindGroupLayoutCount = desc.layouts.size(); + layout_desc.bindGroupLayouts = desc.layouts.data(); + pipeline_desc.layout = gpu->device.CreatePipelineLayout(&layout_desc); + } + if (desc.customize) { + desc.customize(pipeline_desc); + } + + if (auto error = detail::capture_error(*gpu, [&] { pipeline = gpu->device.CreateRenderPipeline(&pipeline_desc); })) { + detail::fail(name, *error); + } +} + +wgpu::BindGroup Pipeline::bind_group(uint32_t group, std::initializer_list bindings, + std::string_view label) const { + const std::string group_label = label.empty() ? std::format("{} bind group {}", name, group) : std::string{label}; + + wgpu::BindGroupLayout layout; + if (auto error = detail::capture_error(*gpu, [&] { layout = pipeline.GetBindGroupLayout(group); })) { + detail::fail(group_label, *error); + } + return detail::make_bind_group(gpu, layout, bindings, group_label); +} + +bool Pipeline::render_frame(Surface& surface, const Draw& draw) const { + RenderPass pass(surface); + pass.draw(*this, draw); + return static_cast(pass); +} + +bool Pipeline::render_frame(Surface& surface, uint32_t vertex_count, uint32_t instance_count) const { + return render_frame(surface, Draw{.count = vertex_count, .instances = instance_count}); +} + +} // namespace lab diff --git a/src/lab_runtime.cpp b/src/lab_runtime.cpp new file mode 100644 index 0000000..31c4481 --- /dev/null +++ b/src/lab_runtime.cpp @@ -0,0 +1,204 @@ +#include "lab_detail.h" + +#include "platform/lab_platform.h" + +#include + +#include + +#include +#include +#include +#include +#include + +namespace lab { + +// logging --------------------------------------------------------------------------------------- + +namespace { + +LogLevel initial_log_level() { + const char* value = std::getenv("LAB_LOG"); + const std::string name = value ? value : ""; + if (name == "debug") return LogLevel::debug; + if (name == "warn") return LogLevel::warn; + if (name == "error") return LogLevel::error; + if (name == "off") return LogLevel::off; + return LogLevel::info; +} + +LogLevel current_log_level = initial_log_level(); + +void print_to_stderr(LogLevel level, std::string_view message) { + static constexpr const char* names[] = {"debug", "info", "warn", "error"}; + std::cerr << "lab [" << names[static_cast(level)] << "] " << message << std::endl; +} + +std::function log_sink = print_to_stderr; + +} // namespace + +void set_log_level(LogLevel level) { current_log_level = level; } +LogLevel log_level() { return current_log_level; } + +void set_log_sink(std::function sink) { + log_sink = sink ? std::move(sink) : print_to_stderr; +} + +void log(LogLevel level, std::string_view message) { + if (level >= current_log_level && level != LogLevel::off) { + log_sink(level, message); + } +} + +// errors and labels ------------------------------------------------------------------------------ + +namespace detail { + +void fail(std::string_view object, std::string_view message) { + std::string what = std::format("{}: {}", object, message); + lab::log(LogLevel::error, what); + throw Error(what); +} + +} // namespace detail + +Label::Label(std::source_location where) + : text{std::format("{}:{}", std::filesystem::path(where.file_name()).filename().string(), where.line())}, + from_location{true} {} + +Label&& Label::as(std::string_view kind) && { + if (from_location) { + text = std::format("{} {}", kind, text); + from_location = false; + } + return std::move(*this); +} + +// runtime --------------------------------------------------------------------------------------- + +namespace detail { + +Runtime& Runtime::get() { + static Runtime runtime; + return runtime; +} + +Runtime::Runtime() { + if (const char* value = std::getenv("LAB_EXIT_AFTER_FRAMES")) { + exit_after_frames = std::atol(value); + } +} + +Runtime::~Runtime() { + if (glfw_ready) { + glfwTerminate(); // destroys the windows that are left, too + } +} + +void Runtime::init_glfw() { + if (glfw_ready) { + return; + } + glfwSetErrorCallback( + [](int code, const char* description) { detail::log(LogLevel::warn, "GLFW error {}: {}", code, description); }); + + // LAB_WINDOW_SYSTEM=x11 or =wayland overrides the window system GLFW would pick on Linux, + // e.g. to run a program through XWayland in a Wayland session + if (const char* value = std::getenv("LAB_WINDOW_SYSTEM")) { + const std::string name = value; + if (name == "x11") { + glfwInitHint(GLFW_PLATFORM, GLFW_PLATFORM_X11); + } else if (name == "wayland") { + glfwInitHint(GLFW_PLATFORM, GLFW_PLATFORM_WAYLAND); + } else { + log(LogLevel::warn, "LAB_WINDOW_SYSTEM={} is ignored, it can be x11 or wayland", name); + } + } + + if (!glfwInit()) { + fail("lab", "GLFW could not be initialized, is there a display?"); + } + glfw_ready = true; + log(LogLevel::debug, "GLFW {} initialized, window system: {}", glfwGetVersionString(), + glfwGetPlatform() == GLFW_PLATFORM_WAYLAND ? "Wayland" + : glfwGetPlatform() == GLFW_PLATFORM_X11 ? "X11" + : "native"); +} + +void Runtime::retire_window(GLFWwindow* window) { + retired_windows.push_back(window); + destroy_retired_windows(); +} + +void Runtime::destroy_retired_windows() { + if (!gpus.empty()) { + return; // a device might still own a swapchain of one of these windows + } + for (GLFWwindow* window : retired_windows) { + glfwDestroyWindow(window); + } + retired_windows.clear(); +} + +double elapsed_seconds() { + return std::chrono::duration(Runtime::Clock::now() - Runtime::get().timer_start).count(); +} + +} // namespace detail + +// main loop --------------------------------------------------------------------------------------- + +bool tick() { + detail::Runtime& runtime = detail::Runtime::get(); + + for (detail::WindowState* window : runtime.windows) { + window->begin_tick(); + } + + if (runtime.glfw_ready) { +#if defined(__EMSCRIPTEN__) + glfwPollEvents(); // the browser paces the loop, see platform::end_of_tick() +#else + if (runtime.presented || runtime.windows.empty()) { + glfwPollEvents(); + } else { + // Nothing was drawn since the last tick: no vsync is pacing the loop (windows + // are minimized, or the program does not render). Wait a little for events + // instead of spinning at full speed. + glfwWaitEventsTimeout(0.01); + } +#endif + } + runtime.presented = false; + + const bool exit_requested = runtime.exit_after_frames > 0 && ++runtime.tick_count > runtime.exit_after_frames; + if (exit_requested) { + runtime.tick_count = 0; + } + + // closing a window removes it from the list, so work on a copy + for (detail::WindowState* window : std::vector(runtime.windows)) { + if (exit_requested || glfwWindowShouldClose(window->handle)) { + window->close(); + } + } + + for (detail::GpuState* gpu : std::vector(runtime.gpus)) { + gpu->poll(); + } + + const auto now = detail::Runtime::Clock::now(); + runtime.delta_seconds = std::chrono::duration(now - runtime.last_tick).count(); + runtime.last_tick = now; + + platform::end_of_tick(); + return !runtime.windows.empty(); +} + +float delta_seconds() { return detail::Runtime::get().delta_seconds; } + +void restart_timer() { detail::Runtime::get().timer_start = detail::Runtime::Clock::now(); } + +} // namespace lab diff --git a/src/lab_shader.cpp b/src/lab_shader.cpp new file mode 100644 index 0000000..c182b94 --- /dev/null +++ b/src/lab_shader.cpp @@ -0,0 +1,69 @@ +#include "lab_detail.h" + +#include "platform/lab_platform.h" + +#include + +#include +#include + +namespace lab { + +namespace detail { + +std::filesystem::path locate_file(const std::filesystem::path& file, std::string_view label) { + std::error_code ignored; + if (std::filesystem::is_regular_file(file, ignored)) { + return file; + } + std::string tried = std::format("\"{}\"", std::filesystem::absolute(file, ignored).string()); + if (file.is_relative()) { + const std::filesystem::path beside_executable = platform::executable_directory() / file; + if (std::filesystem::is_regular_file(beside_executable, ignored)) { + return beside_executable; + } + tried += std::format(" and \"{}\"", beside_executable.string()); + } + fail(label, std::format("file not found, looked for {}", tried)); +} + +} // namespace detail + +std::filesystem::path find_file(const std::filesystem::path& file) { + return detail::locate_file(file, file.filename().string()); +} + +Shader::Shader(Gpu& gpu, const std::filesystem::path& wgsl_file, std::string_view label) + : name{label.empty() ? wgsl_file.filename().string() : std::string{label}} { + const std::filesystem::path path = detail::locate_file(wgsl_file, name); + std::ifstream file(path); + if (!file) { + detail::fail(name, std::format("could not read \"{}\"", path.string())); + } + std::stringstream source; + source << file.rdbuf(); + compile(gpu, source.str()); +} + +Shader Shader::from_source(Gpu& gpu, std::string_view wgsl, std::string_view label) { + Shader shader; + shader.name = label; + shader.compile(gpu, wgsl); + return shader; +} + +void Shader::compile(Gpu& gpu, std::string_view wgsl) { + wgpu::ShaderSourceWGSL source; + source.code = wgsl; + wgpu::ShaderModuleDescriptor desc; + desc.nextInChain = &source; + desc.label = std::string_view(name); + + // the error WebGPU reports for a shader that does not compile contains the compiler's + // diagnostics with line and column, it only lacks the name of the shader + if (auto error = detail::capture_error(*gpu.state(), [&] { module = gpu.device().CreateShaderModule(&desc); })) { + detail::fail(name, std::format("does not compile\n{}", *error)); + } +} + +} // namespace lab diff --git a/src/lab_surface.cpp b/src/lab_surface.cpp new file mode 100644 index 0000000..d82a8e3 --- /dev/null +++ b/src/lab_surface.cpp @@ -0,0 +1,181 @@ +#include "lab_detail.h" + +#include "platform/lab_platform.h" + +#include + +#include +#include +#include + +namespace lab { + +namespace detail { + +SurfaceState::~SurfaceState() { + view = nullptr; + texture = nullptr; + if (handle && configured_size != Size{}) { + handle.Unconfigure(); + } +} + +Size SurfaceState::framebuffer_size() const { + Size size; + glfwGetFramebufferSize(window->handle, &size.width, &size.height); + return size; +} + +void SurfaceState::configure(Size size) { + wgpu::SurfaceConfiguration config{ + .device = gpu->device, + .format = format, + // a surface that is captured has to be readable, too + .usage = capture_path.empty() ? wgpu::TextureUsage::RenderAttachment + : wgpu::TextureUsage::RenderAttachment | wgpu::TextureUsage::CopySrc, + .width = static_cast(size.width), + .height = static_cast(size.height), + .alphaMode = alpha_mode, + .presentMode = present_mode, + }; + handle.Configure(&config); + configured_size = size; + + if (depth_format != wgpu::TextureFormat::Undefined) { + wgpu::TextureDescriptor depth_desc{ + .label = "lab surface depth buffer", + .usage = wgpu::TextureUsage::RenderAttachment, + .size = {static_cast(size.width), static_cast(size.height), 1}, + .format = depth_format, + }; + depth_texture = gpu->device.CreateTexture(&depth_desc); + } +} + +wgpu::TextureView SurfaceState::current_view() { + if (view) { + return view; + } + + // a minimized window has no area to render onto + const Size size = framebuffer_size(); + if (size.width <= 0 || size.height <= 0) { + return nullptr; + } + if (size != configured_size) { + configure(size); + } + + wgpu::SurfaceTexture surface_texture; + handle.GetCurrentTexture(&surface_texture); + switch (surface_texture.status) { + case wgpu::SurfaceGetCurrentTextureStatus::SuccessOptimal: + case wgpu::SurfaceGetCurrentTextureStatus::SuccessSuboptimal: + break; + case wgpu::SurfaceGetCurrentTextureStatus::Timeout: + return nullptr; // try again next frame + case wgpu::SurfaceGetCurrentTextureStatus::Outdated: + case wgpu::SurfaceGetCurrentTextureStatus::Lost: + // the window changed under the surface: set it up again and skip this frame + surface_texture.texture = nullptr; + configured_size = {}; + return nullptr; + default: + fail(label, "could not get a texture to render into"); + } + + texture = std::move(surface_texture.texture); + view = texture.CreateView(); + return view; +} + +void SurfaceState::present() { + if (!view) { + return; + } + if (!capture_path.empty() && ++frames_presented == capture_frame) { + save_texture_png(*gpu, texture, capture_path, label); + } + view = nullptr; + texture = nullptr; + platform::present(handle); + Runtime::get().presented = true; +} + +} // namespace detail + +Surface::Surface(Gpu& gpu, Window& window, SurfaceOptions options) + : shared_state{std::make_shared()} { + detail::SurfaceState& state = *shared_state; + state.gpu = gpu.state(); + state.window = window.state(); + state.label = std::format("surface of window \"{}\"", glfwGetWindowTitle(window.handle())); + state.present_mode = options.present_mode; + state.depth_format = options.depth; + + state.handle = platform::create_surface(gpu.instance(), window.handle()); + if (!state.handle) { + detail::fail(state.label, "could not be created"); + } + + wgpu::SurfaceCapabilities capabilities; + state.handle.GetCapabilities(gpu.adapter(), &capabilities); + const std::span formats{capabilities.formats, capabilities.formatCount}; + if (formats.empty()) { + detail::fail(state.label, std::format("{} cannot render to this window", gpu.state()->label)); + } + if (options.format == wgpu::TextureFormat::Undefined) { + // An 8-bit format if there is one, like `navigator.gpu.getPreferredCanvasFormat()` on the web. + // The format a window system lists first can be a wide one (RGBA16Float on HDR + // capable desktops), which costs more and shows the same colors differently. + const auto eight_bit = std::ranges::find_if(formats, [](wgpu::TextureFormat format) { + return format == wgpu::TextureFormat::BGRA8Unorm || format == wgpu::TextureFormat::RGBA8Unorm; + }); + state.format = eight_bit != formats.end() ? *eight_bit : formats.front(); + } else if (std::ranges::find(formats, options.format) != formats.end()) { + state.format = options.format; + } else { + detail::fail(state.label, "the requested texture format is not supported by this window"); + } + + // frame capture, see SurfaceState + if (const char* directory = std::getenv("LAB_CAPTURE_DIR")) { + const char* frame = std::getenv("LAB_CAPTURE_FRAME"); + state.capture_frame = frame ? std::atol(frame) : 30; + + // named after the program, numbered if it has more than one surface + const int number = ++detail::Runtime::get().surfaces_created; + std::string name = platform::executable_path().stem().string(); + if (number > 1) { + name += std::format("_{}", number); + } + std::error_code ignored; + std::filesystem::create_directories(directory, ignored); + state.capture_path = std::filesystem::path(directory) / (name + ".png"); + } + + const Size size = state.framebuffer_size(); + if (size.width > 0 && size.height > 0) { + state.configure(size); + } +} + +wgpu::TextureFormat Surface::format() const { return shared_state->format; } +wgpu::TextureFormat Surface::depth_format() const { return shared_state->depth_format; } + +Size Surface::size() const { return shared_state->framebuffer_size(); } + +float Surface::aspect() const { + const Size size = shared_state->framebuffer_size(); + return size.height > 0 ? static_cast(size.width) / static_cast(size.height) : 1.0f; +} + +wgpu::TextureView Surface::current_view() { return shared_state->current_view(); } +wgpu::TextureView Surface::depth_view() { + return shared_state->depth_texture ? shared_state->depth_texture.CreateView() : nullptr; +} +void Surface::present() { shared_state->present(); } + +const wgpu::Surface& Surface::handle() const { return shared_state->handle; } + +} // namespace lab diff --git a/src/lab_texture.cpp b/src/lab_texture.cpp new file mode 100644 index 0000000..da973db --- /dev/null +++ b/src/lab_texture.cpp @@ -0,0 +1,176 @@ +#include "lab_detail.h" + +#include + +#include + +#include +#include +#include + +namespace lab { + +namespace { + +// Bytes per pixel of the formats that write() and read() support, 0 for all others +uint64_t pixel_size(wgpu::TextureFormat format) { + using enum wgpu::TextureFormat; + switch (format) { + case R8Unorm: + case R8Snorm: + case R8Uint: + case R8Sint: + return 1; + case R16Uint: + case R16Sint: + case R16Float: + case RG8Unorm: + case RG8Snorm: + case RG8Uint: + case RG8Sint: + return 2; + case R32Float: + case R32Uint: + case R32Sint: + case RG16Uint: + case RG16Sint: + case RG16Float: + case RGBA8Unorm: + case RGBA8UnormSrgb: + case RGBA8Snorm: + case RGBA8Uint: + case RGBA8Sint: + case BGRA8Unorm: + case BGRA8UnormSrgb: + case RGB10A2Unorm: + case RGB10A2Uint: + case RG11B10Ufloat: + return 4; + case RG32Float: + case RG32Uint: + case RG32Sint: + case RGBA16Uint: + case RGBA16Sint: + case RGBA16Float: + return 8; + case RGBA32Float: + case RGBA32Uint: + case RGBA32Sint: + return 16; + default: + return 0; + } +} + +std::string format_name(wgpu::TextureFormat format) { + std::ostringstream stream; + stream << format; + return stream.str(); +} + +// throws unless `pixel_count` pixels of `given_size` bytes are exactly the content of the texture +void check_pixels(const std::string& label, const char* what, wgpu::TextureFormat format, wgpu::Extent3D extent, + uint64_t byte_count, uint64_t given_size) { + const uint64_t expected_size = pixel_size(format); + if (expected_size == 0) { + detail::fail(label, std::format("{}: not supported for textures of format {}", what, format_name(format))); + } + if (given_size != expected_size) { + detail::fail(label, std::format("{}: a pixel of format {} has {} bytes, but the pixel type has {}", what, + format_name(format), expected_size, given_size)); + } + const uint64_t expected_bytes = uint64_t{extent.width} * extent.height * expected_size; + if (byte_count != expected_bytes) { + detail::fail(label, std::format("{}: the texture has {}x{} = {} pixels, but {} were given", what, extent.width, + extent.height, uint64_t{extent.width} * extent.height, byte_count / given_size)); + } +} + +} // namespace + +Texture::Texture(Gpu& gpu_object, wgpu::TextureFormat format, uint32_t width, uint32_t height, TextureOptions options, + Label label) + : gpu{gpu_object.state()}, pixel_format{format}, extent{width, height, 1}, + name{std::move(label).as("texture").text} { + wgpu::TextureDescriptor desc{ + .label = std::string_view(name), + .usage = options.usage, + .dimension = wgpu::TextureDimension::e2D, + .size = extent, + .format = format, + }; + if (auto error = detail::capture_error(*gpu, [&] { texture = gpu->device.CreateTexture(&desc); })) { + detail::fail(name, *error); + } +} + +wgpu::TextureView Texture::view() const { return texture.CreateView(); } + +wgpu::Sampler sampler(Gpu& gpu, SamplerOptions options) { + wgpu::SamplerDescriptor desc; + desc.label = std::string_view(options.label); + desc.addressModeU = options.address_mode; + desc.addressModeV = options.address_mode; + desc.addressModeW = options.address_mode; + desc.magFilter = options.filter; + desc.minFilter = options.filter; + return gpu.device().CreateSampler(&desc); +} + +void Texture::write_bytes(const void* data, uint64_t byte_count, uint64_t given_pixel_size) { + check_pixels(name, "write", pixel_format, extent, byte_count, given_pixel_size); + + wgpu::TexelCopyTextureInfo destination; + destination.texture = texture; + wgpu::TexelCopyBufferLayout layout{ + .bytesPerRow = static_cast(extent.width * given_pixel_size), + .rowsPerImage = extent.height, + }; + gpu->queue.WriteTexture(&destination, data, byte_count, &layout, &extent); +} + +void Texture::read_bytes(void* out, uint64_t byte_count, uint64_t given_pixel_size) const { + check_pixels(name, "read", pixel_format, extent, byte_count, given_pixel_size); + + // rows in the buffer a texture is copied to have to start at multiples of 256 bytes + const uint64_t row_bytes = extent.width * given_pixel_size; + const uint64_t padded_row_bytes = (row_bytes + 255) & ~uint64_t{255}; + + wgpu::BufferDescriptor buffer_desc{ + .label = "lab texture read staging buffer", + .usage = wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst, + .size = padded_row_bytes * extent.height, + }; + wgpu::Buffer staging = gpu->device.CreateBuffer(&buffer_desc); + + wgpu::TexelCopyTextureInfo source; + source.texture = texture; + wgpu::TexelCopyBufferInfo destination; + destination.buffer = staging; + destination.layout.bytesPerRow = static_cast(padded_row_bytes); + destination.layout.rowsPerImage = extent.height; + + wgpu::CommandEncoder encoder = gpu->device.CreateCommandEncoder(); + encoder.CopyTextureToBuffer(&source, &destination, &extent); + wgpu::CommandBuffer commands = encoder.Finish(); + gpu->queue.Submit(1, &commands); + + bool mapped = false; + gpu->wait(staging.MapAsync( + wgpu::MapMode::Read, 0, wgpu::kWholeMapSize, wgpu::CallbackMode::WaitAnyOnly, + [](wgpu::MapAsyncStatus status, wgpu::StringView, bool* mapped) { + *mapped = status == wgpu::MapAsyncStatus::Success; + }, + &mapped)); + if (!mapped) { + detail::fail(name, "read: the texture could not be copied back"); + } + + const auto* bytes = static_cast(staging.GetConstMappedRange()); + for (uint32_t row = 0; row < extent.height; ++row) { + std::memcpy(static_cast(out) + row * row_bytes, bytes + row * padded_row_bytes, row_bytes); + } + staging.Unmap(); +} + +} // namespace lab diff --git a/src/lab_window.cpp b/src/lab_window.cpp new file mode 100644 index 0000000..469c489 --- /dev/null +++ b/src/lab_window.cpp @@ -0,0 +1,214 @@ +#include "lab_detail.h" + +#include "platform/lab_platform.h" + +#include + +#include +#include + +namespace lab { + +namespace detail { + +namespace { + +WindowState& state_of(GLFWwindow* window) { return *static_cast(glfwGetWindowUserPointer(window)); } + +void key_callback(GLFWwindow* window, int key, int scancode, int action, int mods) { + WindowState& state = state_of(window); + if (key >= 0 && static_cast(key) < WindowState::key_count) { + if (action == GLFW_PRESS) { + state.keys_down.set(key); + state.keys_pressed.set(key); + } else if (action == GLFW_RELEASE) { + state.keys_down.reset(key); + state.keys_released.set(key); + } + } + if (state.on_key) { + state.on_key({static_cast(key), static_cast(action), static_cast(mods), scancode}); + } +} + +void mouse_button_callback(GLFWwindow* window, int button, int action, int mods) { + WindowState& state = state_of(window); + if (button >= 0 && static_cast(button) < WindowState::button_count) { + if (action == GLFW_PRESS) { + state.buttons_down.set(button); + state.buttons_pressed.set(button); + } else if (action == GLFW_RELEASE) { + state.buttons_down.reset(button); + state.buttons_released.set(button); + } + } + if (state.on_mouse_button) { + state.on_mouse_button( + {static_cast(button), static_cast(action), static_cast(mods)}); + } +} + +void cursor_callback(GLFWwindow* window, double x, double y) { + WindowState& state = state_of(window); + + // GLFW reports the cursor in screen coordinates, the lab works in pixels + int window_width, window_height, pixel_width, pixel_height; + glfwGetWindowSize(window, &window_width, &window_height); + glfwGetFramebufferSize(window, &pixel_width, &pixel_height); + const float scale_x = window_width > 0 ? static_cast(pixel_width) / static_cast(window_width) : 1.0f; + const float scale_y = window_height > 0 ? static_cast(pixel_height) / static_cast(window_height) : 1.0f; + const Vec2 position{static_cast(x) * scale_x, static_cast(y) * scale_y}; + + if (state.mouse_known) { + state.mouse_delta.x += position.x - state.mouse.x; + state.mouse_delta.y += position.y - state.mouse.y; + } + state.mouse = position; + state.mouse_known = true; +} + +void scroll_callback(GLFWwindow* window, double x, double y) { + WindowState& state = state_of(window); + const Vec2 offset{static_cast(x), static_cast(y)}; + state.scroll.x += offset.x; + state.scroll.y += offset.y; + if (state.on_scroll) { + state.on_scroll(offset); + } +} + +void framebuffer_size_callback(GLFWwindow* window, int width, int height) { + WindowState& state = state_of(window); + if (state.on_resize) { + state.on_resize({width, height}); + } +} + +} // namespace + +void WindowState::begin_tick() { + keys_pressed.reset(); + keys_released.reset(); + buttons_pressed.reset(); + buttons_released.reset(); + mouse_delta = {}; + scroll = {}; +} + +void WindowState::close() { + if (!open) { + return; + } + open = false; + glfwHideWindow(handle); + std::erase(Runtime::get().windows, this); + log(LogLevel::debug, "window \"{}\" closed", glfwGetWindowTitle(handle)); +} + +WindowState::~WindowState() { + if (!handle) { + return; + } + close(); + // no callback may reach this state anymore + glfwSetKeyCallback(handle, nullptr); + glfwSetMouseButtonCallback(handle, nullptr); + glfwSetCursorPosCallback(handle, nullptr); + glfwSetScrollCallback(handle, nullptr); + glfwSetFramebufferSizeCallback(handle, nullptr); + glfwSetWindowUserPointer(handle, nullptr); + Runtime::get().retire_window(handle); +} + +} // namespace detail + +Window::Window(std::string_view title, int width, int height, WindowOptions options) + : shared_state{std::make_shared()} { + detail::Runtime& runtime = detail::Runtime::get(); + runtime.init_glfw(); + + glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); // WebGPU renders, not OpenGL + glfwWindowHint(GLFW_RESIZABLE, options.resizable ? GLFW_TRUE : GLFW_FALSE); + glfwWindowHint(GLFW_VISIBLE, options.visible ? GLFW_TRUE : GLFW_FALSE); + + const std::string title_string{title}; + GLFWwindow* handle = glfwCreateWindow(width, height, title_string.c_str(), nullptr, nullptr); + if (!handle) { + detail::fail(std::format("window \"{}\"", title), "could not be created"); + } + + detail::WindowState& state = *shared_state; + state.handle = handle; + state.open = true; + glfwSetWindowUserPointer(handle, &state); + glfwSetKeyCallback(handle, detail::key_callback); + glfwSetMouseButtonCallback(handle, detail::mouse_button_callback); + glfwSetCursorPosCallback(handle, detail::cursor_callback); + glfwSetScrollCallback(handle, detail::scroll_callback); + glfwSetFramebufferSizeCallback(handle, detail::framebuffer_size_callback); + + platform::window_created(handle); + + runtime.windows.push_back(&state); + detail::log(LogLevel::debug, "window \"{}\" created", title); +} + +bool Window::is_open() const { return shared_state->open; } +void Window::close() { shared_state->close(); } + +void Window::set_title(std::string_view title) { glfwSetWindowTitle(shared_state->handle, std::string{title}.c_str()); } + +Size Window::size() const { + Size size; + glfwGetWindowSize(shared_state->handle, &size.width, &size.height); + return size; +} + +Size Window::framebuffer_size() const { + Size size; + glfwGetFramebufferSize(shared_state->handle, &size.width, &size.height); + return size; +} + +float Window::aspect() const { + const Size size = framebuffer_size(); + return size.height > 0 ? static_cast(size.width) / static_cast(size.height) : 1.0f; +} + +namespace { + +template +bool test(const std::bitset& bits, int index) { + return index >= 0 && static_cast(index) < N && bits.test(static_cast(index)); +} + +} // namespace + +bool Window::key(KeyCode key) const { return test(shared_state->keys_down, static_cast(key)); } +bool Window::key_pressed(KeyCode key) const { return test(shared_state->keys_pressed, static_cast(key)); } +bool Window::key_released(KeyCode key) const { return test(shared_state->keys_released, static_cast(key)); } + +bool Window::mouse_button(MouseButton button) const { + return test(shared_state->buttons_down, static_cast(button)); +} +bool Window::mouse_pressed(MouseButton button) const { + return test(shared_state->buttons_pressed, static_cast(button)); +} +bool Window::mouse_released(MouseButton button) const { + return test(shared_state->buttons_released, static_cast(button)); +} + +Vec2 Window::mouse() const { return shared_state->mouse; } +Vec2 Window::mouse_delta() const { return shared_state->mouse_delta; } +Vec2 Window::scroll() const { return shared_state->scroll; } + +void Window::on_key(std::function callback) { shared_state->on_key = std::move(callback); } +void Window::on_mouse_button(std::function callback) { + shared_state->on_mouse_button = std::move(callback); +} +void Window::on_scroll(std::function callback) { shared_state->on_scroll = std::move(callback); } +void Window::on_resize(std::function callback) { shared_state->on_resize = std::move(callback); } + +GLFWwindow* Window::handle() const { return shared_state->handle; } + +} // namespace lab diff --git a/src/objects/lab_buffer.h b/src/objects/lab_buffer.h deleted file mode 100644 index 8c4b3e1..0000000 --- a/src/objects/lab_buffer.h +++ /dev/null @@ -1,116 +0,0 @@ -#ifndef WGPU_LAB_BUFFER_H -#define WGPU_LAB_BUFFER_H - -#include - -#include -#include - -#include -#include -#include - -namespace lab { - -template -struct Buffer { - Buffer(const char* label, Webgpu& instance) : webgpu{instance}, label{label} {} - - Buffer(const char* label, const std::vector& data, Webgpu& instance) : Buffer{label, instance} { - to_device(data, wgpu::BufferUsage::Vertex); - } - - Buffer(const char* label, const std::vector& data, wgpu::BufferUsage usage, Webgpu& instance) - : Buffer{label, instance} { - to_device(data, usage); - } - - Buffer(const Buffer&) = delete; - Buffer& operator=(const Buffer&) = delete; - - void to_device(const std::vector& data, wgpu::BufferUsage usage) { - assert(wgpu_buffer == nullptr); - wgpu::BufferDescriptor bufferDesc{ - .label = label, - .usage = usage, - .size = sizeof(T) * data.size(), - .mappedAtCreation = true, - }; - current_capacity = data.size(); - wgpu_buffer = webgpu.device.CreateBuffer(&bufferDesc); - void* map = wgpu_buffer.GetMappedRange(0, sizeof(T) * data.size()); - memcpy(map, data.data(), sizeof(T) * data.size()); - wgpu_buffer.Unmap(); - } - - using WriteCallback = std::function)>; - std::jthread to_device(WriteCallback write_func, size_t capacity, wgpu::BufferUsage usage) { - assert(wgpu_buffer == nullptr); - wgpu::BufferDescriptor bufferDesc{ - .label = label, - .usage = usage, - .size = sizeof(T) * capacity, - .mappedAtCreation = true, - }; - wgpu_buffer = webgpu.device.CreateBuffer(&bufferDesc); - current_capacity = capacity; - return std::jthread{[](WriteCallback write_func, size_t capacity, wgpu::Buffer buffer) { - auto map = reinterpret_cast(buffer.GetMappedRange(0, sizeof(T) * capacity)); - MappedVRAM vmap{{map, capacity}, 0, buffer}; - write_func(std::move(vmap)); - }, - write_func, capacity, wgpu_buffer}; - } - - using ReadCallback = std::function)>; - std::jthread from_device(size_t offset, size_t num_elems, ReadCallback read_func) { - assert(wgpu_buffer != nullptr); - wgpu::Future future = wgpu_buffer.MapAsync( - wgpu::MapMode::Read, sizeof(T) * offset, sizeof(T) * num_elems, wgpu::CallbackMode::WaitAnyOnly, - [](wgpu::MapAsyncStatus status, char const* message, Buffer* self) { - if (status == wgpu::MapAsyncStatus::Success) { - std::cout << "Info: Buffer: " << self->label << " successfully mapped memory!" << std::endl; - } else { - std::cerr << "Error: Buffer: " << message << std::endl; - } - }, - this); - webgpu.instance.WaitAny(future, UINT64_MAX); - return std::jthread{[](ReadCallback read_func, size_t offset, size_t num_elems, wgpu::Buffer buffer) { - MappedVRAM vmap{{reinterpret_cast(buffer.GetConstMappedRange( - sizeof(T) * offset, sizeof(T) * num_elems)), - num_elems}, - num_elems, - buffer}; - read_func(std::move(vmap)); - }, - read_func, offset, num_elems, wgpu_buffer}; - } - inline std::jthread from_device(ReadCallback read_func) { return from_device(0, current_capacity, read_func); } - - // write a single element to an existing buffer (needs CopyDst flag) - void write(const T& elem, uint64_t offset = 0) { - webgpu.queue.WriteBuffer(wgpu_buffer, offset * sizeof(T), &elem, sizeof(T)); - } - - // write a vector of elements to an existing buffer (needs CopyDst flag) - void write(const std::vector& data, uint64_t offset = 0) { - webgpu.queue.WriteBuffer(wgpu_buffer, offset * sizeof(T), data.data(), data.size() * sizeof(T)); - } - - ~Buffer() { - if (wgpu_buffer) { - wgpu_buffer.Destroy(); - wgpu_buffer = nullptr; - } - } - - wgpu::Buffer wgpu_buffer = nullptr; - size_t current_capacity; - const char* label; - Webgpu& webgpu; -}; - -} // namespace lab - -#endif // WGPU_LAB_BUFFER_H diff --git a/src/objects/lab_pipeline.h b/src/objects/lab_pipeline.h deleted file mode 100644 index 46f9cdf..0000000 --- a/src/objects/lab_pipeline.h +++ /dev/null @@ -1,267 +0,0 @@ -#ifndef WGPU_LAB_PIPELINE_H -#define WGPU_LAB_PIPELINE_H - -#include -#include - -#include -#include -#include -#include -#include - -#include - -namespace lab { - -// returns size in bytes of given wgpu::VertexFormat -constexpr uint64_t vertex_format_size(const wgpu::VertexFormat& format); - -// returns the total stride of a given vector of wgpu::VertexAttribute objects -uint64_t vertex_attributes_stride(const std::vector& vertexAttributes); - -// todo: remove ? -wgpu::TextureView get_current_render_texture_view(wgpu::Surface surface); - -// Is responsible to manage the process of rendering frames onto surfaces -struct Pipeline { - // Creates a new pipeline object - // ```cpp - // lab::Pipeline pip(my_shader, my_webgpu); - // - // // explore and modify config objects. - // pip.config.primitiveState.topology = wgpu::PrimitiveTopology::LineStrip; - // - // pip.finalize(); // now ready to use for rendering - // ``` - // render_frame stage can always be configured by changing `render_config` - // and, if needed, by `set_custom_renderfunc(func)` - // - // set param `final = true` if there is no need to configure anything - - // this makes the pipeline ready to use. - Pipeline(Shader& sh, Webgpu& wg, bool final = false) : shader{sh}, webgpu{wg} { - if (final) finalize(); - } - - Pipeline(const Pipeline&) = delete; - Pipeline& operator=(const Pipeline&) = delete; - - // Initializes a pipeline - // 1. creates shader module on gpu - // 2. combines default + user configs - // 3. creates texture and buffer bindings - // 4. creates render pipeline on gpu - // 5. releases shader module - void finalize() { - reset(); - wgpu::ShaderModule shaderModule = shader.transfer(webgpu.device); - finalize_config(shaderModule); - finalize_bind_group(); - wgpu_pipeline = transfer(); - } - - struct DrawCallParams { - uint32_t vertexCount, instanceCount; - uint32_t firstVertex, firstInstance; - }; - using RenderFunction = std::function; - - // Render onto surface (DrawCallParams{vertexCount, instanceCount, firstVertex, firstInstance}) - bool render_frame(const Surface& surface, const DrawCallParams& params) { - return render_func(this, surface.wgpu_surface, params); - } - - // Render `vertex_count` vertices onto a surface - bool render_frame(const Surface& surface, std::integral auto vertex_count) { - return render_func(this, surface.wgpu_surface, {static_cast(vertex_count), 1, 0, 0}); - } - - // Render `vertex_count` vertices onto a surface `instance_count` times - bool render_frame(const Surface& surface, std::integral auto vertex_count, std::integral auto instance_count) { - return render_func(this, surface.wgpu_surface, - {static_cast(vertex_count), static_cast(instance_count), 0, 0}); - } - - // Warning! User is responsible to `.release()` the returned render pipeline - // - be careful not to leak resources if you need to use it directly - // - by default, it's automatically managed, see: `Pipeline::init()` - [[nodiscard]] wgpu::RenderPipeline transfer() const; - - // Bundles together user_config and default_config - void finalize_config(wgpu::ShaderModule); - - static bool default_render(PipelineHandle self, wgpu::Surface surface, const DrawCallParams& draw_params); - - void reset(); - ~Pipeline(); - - // ----------------------------------------------------------------------------------------------- - // Configurable structures and functions with default values ------------------------------------- - - // All config fields that have not been assigned a default value other than null - // are non-configurable and will be overwritten in `finalize_config()` to guarantee consistency - pipeline_defaults::InitConfig config{}; - - // All config fields that have not been assigned a default value other than null - // are non-configurable and will be overwritten in `finalize_config()` - pipeline_defaults::RenderConfig render_config{}; - - // Can be reassigned with custom render function - RenderFunction render_func = default_render; - // ----------------------------------------------------------------------------------------------- - - wgpu::RenderPipeline wgpu_pipeline; - - // Default label: `Pipeline(shader.label, webgpu.label)` - // if required, can be customized by assigning before `init()` - std::string label; - - Webgpu& webgpu; - Shader& shader; - - // Work in progress ------------------------------ - struct VertexBufferConfig { - wgpu::Buffer buffer; - wgpu::VertexStepMode mode; - uint64_t offset; - std::vector vertexAttributes; - }; - std::vector vb_configs; - std::vector vb_layouts; - - void add_vertex_buffer(wgpu::Buffer wgpu_buffer, wgpu::VertexStepMode mode = wgpu::VertexStepMode::Vertex, - uint64_t offset = 0) { - assert(wgpu_buffer.GetUsage() & wgpu::BufferUsage::Vertex); - vb_configs.push_back({wgpu_buffer, mode, offset}); - } - - template - void add_vertex_buffer(const Buffer& buffer, wgpu::VertexStepMode mode = wgpu::VertexStepMode::Vertex, - uint64_t offset = 0) { - add_vertex_buffer(buffer.wgpu_buffer, mode, offset); - } - - void add_vertex_attrib(wgpu::VertexFormat format, uint32_t shader_location, uint64_t offset = ~0, - uint64_t buffer_index = ~0) { - uint64_t bi = buffer_index == ~0 ? vb_configs.size() - 1 : buffer_index; - offset = offset == ~0 ? vertex_attributes_stride(vb_configs.at(bi).vertexAttributes) : offset; - vb_configs.at(bi).vertexAttributes.push_back({ - .format = format, - .offset = offset, - .shaderLocation = shader_location, - }); - } - - struct IndexBufferConfig { - wgpu::Buffer buffer; - wgpu::IndexFormat format; - uint64_t offset; - }; - - std::vector ib_configs; - - void add_index_buffer(wgpu::Buffer buffer, wgpu::IndexFormat format, uint64_t offset = 0) { - ib_configs.push_back({buffer, format, offset}); - } - - template - void add_index_buffer(const Buffer& buffer, wgpu::IndexFormat format, uint64_t offset = 0) { - add_index_buffer(buffer.wgpu_buffer, format, offset); - } - - template - void add_uniform_buffer(const Buffer& uniform_buffer, uint32_t binding_index, wgpu::ShaderStage visibility) { - add_bind_group_layout_buffer_entry(0, visibility, wgpu::BufferBindingType::Uniform, sizeof(T)); - add_bind_group_buffer_entry(uniform_buffer.wgpu_buffer, binding_index, sizeof(T)); - } - - void add_texture(const Texture& texture, uint32_t binding, - wgpu::ShaderStage visibility = wgpu::ShaderStage::Fragment) { - add_bind_group_layout_texture_entry(binding, visibility); - add_bind_group_texture_entry(texture.create_view(), binding); - } - - std::vector bindGroupLayoutEntries{}; - std::vector bindGroupEntries{}; - - void add_bind_group_layout_buffer_entry(uint32_t binding, wgpu::ShaderStage visibility, - wgpu::BufferBindingType buffer_type, uint64_t min_binding_size) { - wgpu::BindGroupLayoutEntry bindGroupLayoutEntry; - bindGroupLayoutEntry.binding = binding; - bindGroupLayoutEntry.visibility = visibility; - - bindGroupLayoutEntry.buffer.type = buffer_type; - bindGroupLayoutEntry.buffer.minBindingSize = min_binding_size; - - bindGroupLayoutEntries.push_back(bindGroupLayoutEntry); - } - - void add_bind_group_buffer_entry(wgpu::Buffer wgpu_buffer, uint32_t binding_index, uint64_t size, - uint64_t offset = 0) { - wgpu::BindGroupEntry bindGroupEntry; - bindGroupEntry.buffer = wgpu_buffer; - bindGroupEntry.binding = binding_index; - - bindGroupEntry.size = size; - bindGroupEntry.offset = offset; - - bindGroupEntries.push_back(bindGroupEntry); - } - - void add_bind_group_layout_texture_entry(uint32_t binding, wgpu::ShaderStage visibility, - wgpu::TextureSampleType sampleType = wgpu::TextureSampleType::Float, - wgpu::TextureViewDimension viewDimension = wgpu::TextureViewDimension::e2D, - wgpu::Bool multisampled = false) { - wgpu::BindGroupLayoutEntry bindGroupLayoutEntry; - bindGroupLayoutEntry.binding = binding; - bindGroupLayoutEntry.visibility = visibility; - - bindGroupLayoutEntry.texture.multisampled = multisampled; - bindGroupLayoutEntry.texture.sampleType = sampleType; - bindGroupLayoutEntry.texture.viewDimension = viewDimension; - - bindGroupLayoutEntries.push_back(bindGroupLayoutEntry); - } - - void add_bind_group_texture_entry(wgpu::TextureView texture_view, uint32_t binding_index) { - wgpu::BindGroupEntry bindGroupEntry; - bindGroupEntry.textureView = texture_view; - bindGroupEntry.binding = binding_index; - - bindGroupEntries.push_back(bindGroupEntry); - } - - std::vector bindGroups{}; - std::vector bindGroupLayouts{}; - - void finalize_bind_group(const char* group_label = "lab default bind group") { - std::string groupLabel{label}; - groupLabel.append(" - "); - groupLabel.append(group_label); - - std::string layoutLabel{groupLabel}; - layoutLabel.append(" Layout"); - - wgpu::BindGroupLayoutDescriptor bindGroupLayoutDesc; - bindGroupLayoutDesc.entryCount = bindGroupLayoutEntries.size(); - bindGroupLayoutDesc.entries = bindGroupLayoutEntries.data(); - bindGroupLayoutDesc.label = layoutLabel.c_str(); - - bindGroupLayouts.push_back(webgpu.device.CreateBindGroupLayout(&bindGroupLayoutDesc)); - - wgpu::BindGroupDescriptor bindGroupDesc; - bindGroupDesc.layout = bindGroupLayouts.back(); - bindGroupDesc.entryCount = bindGroupEntries.size(); - bindGroupDesc.entries = bindGroupEntries.data(); - bindGroupDesc.label = groupLabel.c_str(); - - bindGroups.push_back(webgpu.device.CreateBindGroup(&bindGroupDesc)); - - bindGroupLayoutEntries.clear(); - bindGroupEntries.clear(); - } -}; - -} // namespace lab - -#endif // WGPU_LAB_PIPELINE_H diff --git a/src/objects/lab_shader.h b/src/objects/lab_shader.h deleted file mode 100644 index dee2a45..0000000 --- a/src/objects/lab_shader.h +++ /dev/null @@ -1,26 +0,0 @@ -#ifndef WGPU_LAB_SHADER_H -#define WGPU_LAB_SHADER_H - -#include - -#include - -namespace lab { - -struct Shader { - std::string label, source; - - Shader(const std::string& label); - - Shader(const std::string& label, const std::string& path); - - // Warning! User is responsible to `.release()` the returned shader module - // - be careful not to leak resources if you need to use it directly - // - by default, it's automatically managed, see: `Pipeline::init()` - [[nodiscard]] wgpu::ShaderModule transfer(wgpu::Device device, - wgpu::SType struct_type = wgpu::SType::ShaderModuleWGSLDescriptor) const; -}; - -} // namespace lab - -#endif // WGPU_LAB_SHADER_H diff --git a/src/objects/lab_surface.h b/src/objects/lab_surface.h deleted file mode 100644 index 2fbb198..0000000 --- a/src/objects/lab_surface.h +++ /dev/null @@ -1,28 +0,0 @@ -#ifndef WGPU_LAB_SURFACE_H -#define WGPU_LAB_SURFACE_H - -#include -#include - -namespace lab { - -struct Surface { - Window& window; - Webgpu& webgpu; - - wgpu::Surface wgpu_surface; - - Surface(Window& wnd, Webgpu& wgpu); - - Surface(const Surface&) = delete; - Surface& operator=(const Surface&) = delete; - - void reconfigure(int width, int height); - void reconfigure(); - - ~Surface(); -}; - -} // namespace lab - -#endif // WGPU_LAB_SURFACE_H diff --git a/src/objects/lab_texture.h b/src/objects/lab_texture.h deleted file mode 100644 index af7c356..0000000 --- a/src/objects/lab_texture.h +++ /dev/null @@ -1,94 +0,0 @@ -#ifndef WGPU_LAB_TEXTURE_H -#define WGPU_LAB_TEXTURE_H - -#include - -namespace lab { - -// WIP todos: -// - allow multi-threaded (read/write) mapping like lab::Buffer (reuse MappedVRAM) -// - support 3d textures -// - support texture arrays -struct Texture { - Webgpu& webgpu; - - wgpu::TextureDescriptor descriptor = { - .label = "lab default texture", - .usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopyDst, - .mipLevelCount = 1, - .sampleCount = 1, - .viewFormatCount = 0, - .viewFormats = nullptr, - }; - - Texture(Webgpu& instance, wgpu::TextureFormat format, uint32_t width, uint32_t height = 1, - uint32_t depthOrArrayLayers = 1) - : webgpu{instance} { - if (height > 1 && depthOrArrayLayers > 1) { - descriptor.dimension = wgpu::TextureDimension::e3D; - } else if (height > 1 && depthOrArrayLayers == 1) { - descriptor.dimension = wgpu::TextureDimension::e2D; - } else if (height == 1 && depthOrArrayLayers == 1) { - descriptor.dimension = wgpu::TextureDimension::e1D; - } - descriptor.size = {width, height, depthOrArrayLayers}; - descriptor.format = format; - } - - Texture(const Texture&) = delete; - Texture& operator=(const Texture&) = delete; - - [[nodiscard]] wgpu::Texture transfer() { return webgpu.device.CreateTexture(&descriptor); } - - wgpu::ImageCopyTexture target = { - .mipLevel = 0, - .origin = {0, 0, 0}, - .aspect = wgpu::TextureAspect::All, - }; - - wgpu::Texture wgpu_texture = nullptr; - - template - void to_device(const std::vector& pixels) { - wgpu_texture = transfer(); - target.texture = wgpu_texture; - - wgpu::TextureDataLayout layout = { - .offset = 0, - .bytesPerRow = sizeof(T) * descriptor.size.width, - .rowsPerImage = descriptor.size.height, - }; - - webgpu.queue.WriteTexture(&target, pixels.data(), pixels.size() * sizeof(T), &layout, &descriptor.size); - } - - inline int width() const noexcept { return static_cast(descriptor.size.width); } - inline int height() const noexcept { return static_cast(descriptor.size.height); } - - mutable wgpu::TextureViewDescriptor textureViewDesc = { - .label = "lab default texture view", - .dimension = wgpu::TextureViewDimension::e2D, - .baseMipLevel = 0, - .mipLevelCount = 1, - .baseArrayLayer = 0, - .arrayLayerCount = 1, - .aspect = wgpu::TextureAspect::All, - }; - - [[nodiscard]] wgpu::TextureView create_view() const { - assert(wgpu_texture != nullptr); - textureViewDesc.format = descriptor.format; - return wgpu_texture.CreateView(&textureViewDesc); - } - - ~Texture() { - if (wgpu_texture) { - wgpu_texture.Destroy(); - wgpu_texture = nullptr; - } - } -}; - -} // namespace lab - -#endif // WGPU_LAB_TEXTURE_H diff --git a/src/objects/lab_webgpu.h b/src/objects/lab_webgpu.h deleted file mode 100644 index 5901853..0000000 --- a/src/objects/lab_webgpu.h +++ /dev/null @@ -1,30 +0,0 @@ -#ifndef WGPU_LAB_WEBGPU_H -#define WGPU_LAB_WEBGPU_H - -#include - -#include - -namespace lab { - -struct Webgpu { - Webgpu(const std::string& label, wgpu::PowerPreference = wgpu::PowerPreference::HighPerformance); - - Webgpu(const Webgpu&) = delete; - Webgpu& operator=(const Webgpu&) = delete; - - ~Webgpu(); - - wgpu::SurfaceCapabilities capabilities{}; - - wgpu::Instance instance = nullptr; - wgpu::Adapter adapter = nullptr; - wgpu::Device device = nullptr; - wgpu::Queue queue = nullptr; - - std::string label; -}; - -} // namespace lab - -#endif // WGPU_LAB_WEBGPU_H diff --git a/src/objects/lab_window.h b/src/objects/lab_window.h deleted file mode 100644 index ed71784..0000000 --- a/src/objects/lab_window.h +++ /dev/null @@ -1,41 +0,0 @@ -#ifndef WGPU_LAB_WINDOW_H -#define WGPU_LAB_WINDOW_H - -#include -#include - -#include - -namespace lab { - -struct Window { - Window(const char* title, int width, int height); - - Window(const Window&) = delete; - Window& operator=(const Window&) = delete; - - void set_key_callback(std::function); - void clear_key_callback(); - - void set_resize_callback(std::function); - void clear_resize_callback(); - - void set_title(const char* title); - - int width() const; - int height() const; - float ratio() const; - - bool is_open() const; - void close(); - ~Window(); - - std::function user_resize_callback; - std::function user_key_callback; - - GLFWwindowHandle glfw_window_handle; -}; - -} // namespace lab - -#endif // WGPU_LAB_WINDOW_H diff --git a/src/platform/lab_paths.cpp b/src/platform/lab_paths.cpp new file mode 100644 index 0000000..b2c6acd --- /dev/null +++ b/src/platform/lab_paths.cpp @@ -0,0 +1,41 @@ +#include "platform/lab_platform.h" + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#endif + +namespace lab::platform { + +std::filesystem::path executable_path() { +#if defined(_WIN32) + wchar_t buffer[MAX_PATH]; + const DWORD length = GetModuleFileNameW(nullptr, buffer, MAX_PATH); + if (length > 0 && length < MAX_PATH) { + return std::filesystem::path(buffer); + } +#elif defined(__linux__) + std::error_code error; + const std::filesystem::path executable = std::filesystem::read_symlink("/proc/self/exe", error); + if (!error) { + return executable; + } +#endif + return {}; +} + +std::filesystem::path executable_directory() { + const std::filesystem::path executable = executable_path(); + if (!executable.empty()) { + return executable.parent_path(); + } + std::error_code ignored; + return std::filesystem::current_path(ignored); +} + +} // namespace lab::platform diff --git a/src/platform/lab_platform.h b/src/platform/lab_platform.h new file mode 100644 index 0000000..c222625 --- /dev/null +++ b/src/platform/lab_platform.h @@ -0,0 +1,40 @@ +#ifndef WGPU_LAB_PLATFORM_H +#define WGPU_LAB_PLATFORM_H + +#include + +#include + +struct GLFWwindow; + +// Everything that differs between window systems lives behind these functions. +// Supporting a new platform means implementing them for it. + +namespace lab::platform { + +// Creates the wgpu::Surface which presents into the given GLFW window +// - returns nullptr and prints an error if the window system is not supported +wgpu::Surface create_surface(const wgpu::Instance& instance, GLFWwindow* window); + +// Called right after a window was created, for platform specific setup +void window_created(GLFWwindow* window); + +// Shows what was rendered into the surface. On the web the browser does that by +// itself when the frame is over, and the surface must not be told to. +void present(const wgpu::Surface& surface); + +// Called at the end of every lab::tick(). On the web this is where the program +// hands control back to the browser until the next frame. +void end_of_tick(); + +// The path of the running executable +// - returns an empty path if it cannot be determined +std::filesystem::path executable_path(); + +// The directory that contains the running executable +// - returns the working directory if it cannot be determined +std::filesystem::path executable_directory(); + +} // namespace lab::platform + +#endif // WGPU_LAB_PLATFORM_H diff --git a/src/platform/lab_surface_glfw.cpp b/src/platform/lab_surface_glfw.cpp new file mode 100644 index 0000000..a2d0923 --- /dev/null +++ b/src/platform/lab_surface_glfw.cpp @@ -0,0 +1,89 @@ +#include "platform/lab_platform.h" + +#include + +#include + +#if defined(_WIN32) + +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#define GLFW_EXPOSE_NATIVE_WIN32 +#include + +#elif defined(__linux__) + +// GLFW's native access functions are declared by hand instead of including +// . That header pulls in Xlib.h, whose macros (Success, None, +// Bool, Status, ...) collide with names used in webgpu_cpp.h, and it requires the +// X11 and Wayland development headers to be installed. +extern "C" { +#if defined(LAB_USE_WAYLAND) +struct wl_display* glfwGetWaylandDisplay(void); +struct wl_surface* glfwGetWaylandWindow(GLFWwindow* window); +#endif +#if defined(LAB_USE_X11) +struct _XDisplay* glfwGetX11Display(void); +unsigned long glfwGetX11Window(GLFWwindow* window); +#endif +} + +#else +#error "wgpu-lab: creating a surface is not implemented for this platform yet, see src/platform/lab_surface_glfw.cpp" +#endif + +namespace lab::platform { + +wgpu::Surface create_surface(const wgpu::Instance& instance, GLFWwindow* window) { + wgpu::SurfaceDescriptor descriptor{.label = "lab surface"}; + +#if defined(_WIN32) + + wgpu::SurfaceSourceWindowsHWND source; + source.hinstance = GetModuleHandleW(nullptr); + source.hwnd = glfwGetWin32Window(window); + descriptor.nextInChain = &source; + return instance.CreateSurface(&descriptor); + +#elif defined(__linux__) + + switch (glfwGetPlatform()) { +#if defined(LAB_USE_WAYLAND) + case GLFW_PLATFORM_WAYLAND: { + wgpu::SurfaceSourceWaylandSurface source; + source.display = glfwGetWaylandDisplay(); + source.surface = glfwGetWaylandWindow(window); + descriptor.nextInChain = &source; + return instance.CreateSurface(&descriptor); + } +#endif +#if defined(LAB_USE_X11) + case GLFW_PLATFORM_X11: { + wgpu::SurfaceSourceXlibWindow source; + source.display = glfwGetX11Display(); + source.window = glfwGetX11Window(window); + descriptor.nextInChain = &source; + return instance.CreateSurface(&descriptor); + } +#endif + default: + std::cerr << "Error: Surface: wgpu-lab was built without support for this window system " + "(see LAB_USE_WAYLAND / LAB_USE_X11)" + << std::endl; + return nullptr; + } + +#endif +} + +void window_created(GLFWwindow*) {} + +void present(const wgpu::Surface& surface) { surface.Present(); } + +void end_of_tick() {} + +} // namespace lab::platform diff --git a/src/platform/lab_surface_web.cpp b/src/platform/lab_surface_web.cpp new file mode 100644 index 0000000..37cefc4 --- /dev/null +++ b/src/platform/lab_surface_web.cpp @@ -0,0 +1,44 @@ +// The web: the program runs in a browser, the window is an HTML canvas and WebGPU +// is the browser's own. Dawn's Emscripten port (emdawnwebgpu) provides webgpu_cpp.h +// on top of it, the contrib.glfw3 port provides GLFW. + +#include "platform/lab_platform.h" + +#include +#include +#include + +namespace lab::platform { + +wgpu::Surface create_surface(const wgpu::Instance& instance, GLFWwindow*) { + // the canvas the page's Module.canvas points at, which is the one GLFW draws into + wgpu::EmscriptenSurfaceSourceCanvasHTMLSelector source; + source.selector = "#canvas"; + wgpu::SurfaceDescriptor descriptor{.nextInChain = &source, .label = "lab surface"}; + return instance.CreateSurface(&descriptor); +} + +void window_created(GLFWwindow* window) { + // the canvas fills the browser window and follows its size + emscripten::glfw3::MakeCanvasResizable(window, "window"); +} + +// Gives control back to the browser until it is about to draw the next frame. +// This is what makes `while (lab::tick())` work on the web: the loop sleeps between +// iterations instead of blocking the page (it needs ASYNCIFY, see samples/CMakeLists.txt). +// (no arrow function in here: the C preprocessor would turn `=>` into `= >`) +EM_ASYNC_JS(void, lab_await_next_frame, (), + { await new Promise(function(resolve) { requestAnimationFrame(resolve); }); }); + +void present(const wgpu::Surface&) {} // the browser shows the canvas at the next animation frame + +void end_of_tick() { lab_await_next_frame(); } + +std::filesystem::path executable_path() { return {}; } + +std::filesystem::path executable_directory() { + std::error_code ignored; + return std::filesystem::current_path(ignored); +} + +} // namespace lab::platform diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..d6c0c19 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,37 @@ +# doctest is used header-only and therefore only downloaded, +# see samples/CMakeLists.txt for the SOURCE_SUBDIR trick +FetchContent_Declare(doctest + GIT_REPOSITORY https://github.com/doctest/doctest + GIT_TAG v2.4.12 + GIT_SHALLOW ON + SOURCE_SUBDIR headers-only) +FetchContent_MakeAvailable(doctest) + +# registers every TEST_CASE as its own CTest test +include(${doctest_SOURCE_DIR}/scripts/cmake/doctest.cmake) + +function(lab_add_tests TEST_TARGET) + add_executable(${TEST_TARGET} test_main.cpp common.h ${ARGN}) + target_include_directories(${TEST_TARGET} SYSTEM PRIVATE ${doctest_SOURCE_DIR}) + target_link_libraries(${TEST_TARGET} PRIVATE lab::lab) + lab_target_defaults(${TEST_TARGET}) + if(NOT MSVC) + # doctest includes , which libstdc++ answers with a #warning since C++20 + target_compile_options(${TEST_TARGET} PRIVATE -Wno-cpp) + endif() +endfunction() + +# tests that need a GPU (or a software adapter), but no window: they render into textures +lab_add_tests(lab-tests + test_buffer.cpp + test_compute.cpp + test_pipeline.cpp + test_render.cpp + test_vertex_format.cpp) +doctest_discover_tests(lab-tests) + +# Tests that open windows and so need a display. They carry the label "display", +# as do the sample smoke tests: `ctest -LE display` runs everything else. +lab_add_tests(lab-window-tests + test_window.cpp) +doctest_discover_tests(lab-window-tests PROPERTIES LABELS display RUN_SERIAL ON) diff --git a/tests/common.h b/tests/common.h new file mode 100644 index 0000000..47f276d --- /dev/null +++ b/tests/common.h @@ -0,0 +1,44 @@ +#ifndef WGPU_LAB_TESTS_COMMON_H +#define WGPU_LAB_TESTS_COMMON_H + +#include + +#include + +#include +#include +#include +#include + +// One RGBA8 pixel as read back from a texture +struct Pixel { + uint8_t r, g, b, a; + bool operator==(const Pixel&) const = default; +}; + +inline doctest::String toString(const Pixel& p) { + return doctest::String(std::format("({}, {}, {}, {})", p.r, p.g, p.b, p.a).c_str()); +} + +// The render tests draw into a square texture of this many pixels, cleared to opaque red +inline constexpr uint32_t target_size = 64; +inline constexpr wgpu::TextureFormat target_format = wgpu::TextureFormat::RGBA8Unorm; +inline constexpr wgpu::Color clear_color{1.0, 0.0, 0.0, 1.0}; +inline constexpr Pixel clear_pixel{255, 0, 0, 255}; + +// index of the pixel at (x, y), counted from the top left +inline constexpr size_t at(uint32_t x, uint32_t y) { return y * target_size + x; } +inline constexpr size_t center = at(target_size / 2, target_size / 2); +inline constexpr size_t corner = at(0, 0); + +inline lab::Texture make_target(lab::Gpu& gpu) { return lab::Texture(gpu, target_format, target_size, target_size); } + +// a vertex shader that covers the whole target with a single triangle and needs no buffers +inline const std::string fullscreen_vertex_shader = R"( + @vertex fn vs_main(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + var positions = array(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0)); + return vec4f(positions[i], 0.0, 1.0); + } +)"; + +#endif // WGPU_LAB_TESTS_COMMON_H diff --git a/tests/test_buffer.cpp b/tests/test_buffer.cpp new file mode 100644 index 0000000..387f350 --- /dev/null +++ b/tests/test_buffer.cpp @@ -0,0 +1,90 @@ +#include "common.h" + +#include + +TEST_CASE("Buffer: the element type is deduced from the data") { + lab::Gpu gpu; + std::vector data{1.0f, 2.0f, 3.0f}; + + lab::Buffer buffer(gpu, data); + + static_assert(std::is_same_v>); + CHECK(buffer.size() == 3); + CHECK(buffer.read() == data); +} + +TEST_CASE("Buffer: written elements can be read back") { + lab::Gpu gpu; + lab::Buffer buffer(gpu, {1, 2, 3, 4, 5, 6, 7, 8}, wgpu::BufferUsage::Storage); + + buffer.write(42, 2); // one element at index 2 + buffer.write(std::vector{7, 7}, 6); // two elements starting at index 6 + + CHECK(buffer.read() == std::vector{1, 2, 42, 4, 5, 6, 7, 7}); + CHECK(buffer.read(1, 3) == std::vector{2, 42, 4}); // a part that starts at an odd byte offset + CHECK(buffer.read(8).empty()); +} + +TEST_CASE("Buffer: a buffer that can be mapped is read without a copy") { + lab::Gpu gpu; + lab::Buffer buffer(gpu, {1, 2, 3, 4, 5}, wgpu::BufferUsage::MapRead); + + CHECK(buffer.read(3, 2) == std::vector{4, 5}); +} + +TEST_CASE("Buffer: created with a count, it is zeroed or filled in place") { + lab::Gpu gpu; + + lab::Buffer zeroed(gpu, 4, wgpu::BufferUsage::Storage); + CHECK(zeroed.read() == std::vector{0, 0, 0, 0}); + + lab::Buffer filled(gpu, 5, wgpu::BufferUsage::Storage, + [](std::span mapped) { std::iota(mapped.begin(), mapped.end(), 10.0f); }); + CHECK(filled.read() == std::vector{10.0f, 11.0f, 12.0f, 13.0f, 14.0f}); +} + +TEST_CASE("Buffer: element sizes that are not a multiple of 4 bytes") { + lab::Gpu gpu; + lab::Buffer indices(gpu, {0, 1, 2, 2, 3}, wgpu::BufferUsage::Index); // 10 bytes + + CHECK(indices.read() == std::vector{0, 1, 2, 2, 3}); + + indices.write(std::vector{9, 9}, 2); // 4 bytes at byte 4: fine + CHECK(indices.read() == std::vector{0, 1, 9, 9, 3}); + + indices.write(uint16_t{7}, 4); // 2 bytes, but up to the end of the buffer: fine + CHECK(indices.read() == std::vector{0, 1, 9, 9, 7}); + + // 2 bytes in the middle cannot be written + CHECK_THROWS_WITH_AS(indices.write(uint16_t{5}, 0), doctest::Contains("multiples of 4 bytes"), lab::Error); +} + +TEST_CASE("Buffer: reading and writing outside of the buffer is an error") { + lab::Gpu gpu; + lab::Buffer buffer(gpu, {1, 2, 3}, wgpu::BufferUsage::Storage, "three ints"); + + CHECK_THROWS_WITH_AS(buffer.write(5, 3), doctest::Contains("three ints"), lab::Error); + CHECK_THROWS_WITH_AS(buffer.read(2, 2), doctest::Contains("has 3 elements"), lab::Error); +} + +TEST_CASE("Buffer: read_async delivers the elements from a later poll") { + lab::Gpu gpu; + lab::Buffer buffer(gpu, {1, 2, 3, 4}, wgpu::BufferUsage::Storage); + + std::vector result; + buffer.read_async([&](std::span elements) { result.assign(elements.begin(), elements.end()); }, 1, 2); + CHECK(result.empty()); // not yet + + gpu.wait_idle(); + gpu.poll(); + CHECK(result == std::vector{2, 3}); +} + +TEST_CASE("Buffer: an unnamed buffer is labelled with the place it was created at") { + lab::Gpu gpu; + lab::Buffer unnamed(gpu, {1}); + lab::Buffer named(gpu, {1}, wgpu::BufferUsage::Vertex, "my buffer"); + + CHECK(unnamed.label() == std::format("buffer test_buffer.cpp:{}", __LINE__ - 3)); + CHECK(named.label() == "my buffer"); +} diff --git a/tests/test_compute.cpp b/tests/test_compute.cpp new file mode 100644 index 0000000..5f291a2 --- /dev/null +++ b/tests/test_compute.cpp @@ -0,0 +1,78 @@ +#include "common.h" + +#include + +static const char* const square_shader = R"( + @group(0) @binding(0) var data: array; + + @compute @workgroup_size(64) + fn main(@builtin(global_invocation_id) id: vec3u) { + if (id.x < arrayLength(&data)) { + data[id.x] = data[id.x] * data[id.x]; + } + } +)"; + +TEST_CASE("compute: a shader works on a storage buffer") { + lab::Gpu gpu; + std::vector input(1000); + std::iota(input.begin(), input.end(), 0.0f); + lab::Buffer data(gpu, input, wgpu::BufferUsage::Storage); + + auto shader = lab::Shader::from_source(gpu, square_shader, "square"); + lab::ComputePipeline square(gpu, shader); + + // 1000 elements in workgroups of 64: 16 groups, the shader ignores what is beyond the array + square.run({square.bind_group(0, {{0, data}})}, (data.size() + 63) / 64); + + std::vector result = data.read(); + CHECK(result[3] == 9.0f); + CHECK(result[999] == 999.0f * 999.0f); + CHECK(gpu.errors().empty()); +} + +TEST_CASE("compute: a compute pass and a render pass in one frame") { + lab::Gpu gpu; + lab::Texture target = make_target(gpu); + + // the compute shader writes the color that the render pass then fills the target with + lab::Buffer color(gpu, 4, wgpu::BufferUsage::Storage); + auto compute_shader = lab::Shader::from_source(gpu, R"( + @group(0) @binding(0) var color: array; + @compute @workgroup_size(1) fn main() { + color = array(0.0, 1.0, 1.0, 1.0); + } + )"); + lab::ComputePipeline compute(gpu, compute_shader); + + auto render_shader = lab::Shader::from_source(gpu, fullscreen_vertex_shader + R"( + @group(0) @binding(0) var color: array; + @fragment fn fs_main() -> @location(0) vec4f { + return vec4f(color[0], color[1], color[2], color[3]); + } + )"); + lab::Pipeline pipeline(gpu, render_shader, {.target = target}); + + { + lab::Frame frame(gpu); + { + lab::ComputePass pass(frame); + pass.dispatch(compute, {compute.bind_group(0, {{0, color}})}, 1); + } + lab::RenderPass pass(frame, target); + pass.draw(pipeline, {.bind_groups = {pipeline.bind_group(0, {{0, color}})}, .count = 3}); + } + + CHECK(target.read()[center] == Pixel{0, 255, 255, 255}); + CHECK(gpu.errors().empty()); +} + +TEST_CASE("compute: a shader that does not fit is an error where the pipeline is created") { + lab::Gpu gpu; + auto shader = lab::Shader::from_source(gpu, "@fragment fn fs_main() -> @location(0) vec4f { return vec4f(1.0); }", + "no compute stage"); + + CHECK_THROWS_WITH_AS(lab::ComputePipeline(gpu, shader), doctest::Contains("compute pipeline(no compute stage)"), + lab::Error); + CHECK(gpu.errors().empty()); +} diff --git a/tests/test_main.cpp b/tests/test_main.cpp new file mode 100644 index 0000000..0a3f254 --- /dev/null +++ b/tests/test_main.cpp @@ -0,0 +1,2 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include diff --git a/tests/test_pipeline.cpp b/tests/test_pipeline.cpp new file mode 100644 index 0000000..406db6d --- /dev/null +++ b/tests/test_pipeline.cpp @@ -0,0 +1,125 @@ +#include "common.h" + +using enum wgpu::VertexFormat; + +static const std::string blue_fragment_shader = R"( + @fragment fn fs_main() -> @location(0) vec4f { + return vec4f(0.0, 0.0, 1.0, 1.0); + } +)"; + +TEST_CASE("Shader: a missing file is reported with the places that were searched") { + lab::Gpu gpu; + CHECK_THROWS_WITH_AS(lab::Shader(gpu, "does/not/exist.wgsl"), doctest::Contains("does/not/exist.wgsl"), lab::Error); +} + +TEST_CASE("Shader: a compile error names the shader and the line") { + lab::Gpu gpu; + const char* broken = "@fragment fn fs_main() -> @location(0) vec4f {\n return vec4f(1.0)\n}"; // missing ';' + + try { + lab::Shader::from_source(gpu, broken, "broken shader"); + FAIL("the shader should not have compiled"); + } catch (const lab::Error& error) { + const std::string what = error.what(); + CHECK(what.starts_with("broken shader: does not compile")); + CHECK(what.find(":3:1") != std::string::npos); // line 3, column 1: where the ';' is missed + } + CHECK(gpu.errors().empty()); // reported as an exception, not as a stray GPU error +} + +TEST_CASE("Pipeline: a vertex layout that does not provide what the shader reads is an error") { + lab::Gpu gpu; + auto shader = lab::Shader::from_source(gpu, R"( + @vertex fn vs_main(@location(0) position: vec2f) -> @builtin(position) vec4f { + return vec4f(position, 0.0, 1.0); + } + )" + blue_fragment_shader, + "needs a position"); + + // no vertex buffers declared + CHECK_THROWS_WITH_AS(lab::Pipeline(gpu, shader, {.target = target_format}), + doctest::Contains("pipeline(needs a position)"), lab::Error); + CHECK(gpu.errors().empty()); +} + +TEST_CASE("Pipeline: bind_group reports bindings that do not match the shader") { + lab::Gpu gpu; + auto shader = lab::Shader::from_source(gpu, fullscreen_vertex_shader + R"( + @group(0) @binding(2) var color: vec4f; + @fragment fn fs_main() -> @location(0) vec4f { + return color; + } + )"); + lab::Pipeline pipeline(gpu, shader, {.target = target_format}); + lab::Buffer color(gpu, {0.0f, 1.0f, 0.0f, 1.0f}, wgpu::BufferUsage::Uniform); + + CHECK_NOTHROW(pipeline.bind_group(0, {{2, color}})); + CHECK_THROWS_AS(pipeline.bind_group(0, {{0, color}}), lab::Error); // the shader has no binding 0 + CHECK_THROWS_AS(pipeline.bind_group(1, {{2, color}}), lab::Error); // the shader has no group 1 + CHECK(gpu.errors().empty()); +} + +TEST_CASE("RenderPass: draw calls that do not fit the pipeline are errors") { + lab::Gpu gpu; + lab::Texture target = make_target(gpu); + auto shader = lab::Shader::from_source(gpu, R"( + @vertex fn vs_main(@location(0) position: vec2f) -> @builtin(position) vec4f { + return vec4f(position, 0.0, 1.0); + } + )" + blue_fragment_shader); + + struct Position { + float x, y; + }; + lab::Pipeline pipeline(gpu, shader, {.vertex_buffers = {lab::vertex({Float32x2})}, .target = target}); + lab::Buffer positions(gpu, {{0.0f, 0.0f}, {1.0f, 0.0f}, {0.0f, 1.0f}}); + lab::Buffer floats(gpu, {0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f}); + + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target); + + CHECK_NOTHROW(pass.draw(pipeline, {.vertex_buffers = {positions}})); + + SUBCASE("a missing vertex buffer") { + CHECK_THROWS_WITH_AS(pass.draw(pipeline, 3), doctest::Contains("declares 1 vertex buffer"), lab::Error); + } + SUBCASE("a vertex buffer with another element size") { + CHECK_THROWS_WITH_AS(pass.draw(pipeline, {.vertex_buffers = {floats}}), doctest::Contains("elements of 4 bytes"), + lab::Error); + } + SUBCASE("a pipeline that was built for another target format") { + lab::Pipeline other( + gpu, shader, + {.vertex_buffers = {lab::vertex({Float32x2})}, .target = wgpu::TextureFormat::BGRA8Unorm}); + CHECK_THROWS_WITH_AS(pass.draw(other, {.vertex_buffers = {positions}}), doctest::Contains("BGRA8Unorm"), + lab::Error); + } +} + +TEST_CASE("Gpu: errors reported by WebGPU are thrown by poll") { + // a buffer that is both mappable for reading and a vertex buffer is not allowed + const wgpu::BufferDescriptor invalid{ + .usage = wgpu::BufferUsage::MapRead | wgpu::BufferUsage::Vertex, + .size = 16, + }; + + SUBCASE("by default") { + lab::Gpu gpu; + lab::set_log_level(lab::LogLevel::off); // the error is expected, keep the test output clean + gpu.device().CreateBuffer(&invalid); + lab::set_log_level(lab::LogLevel::info); + + CHECK_THROWS_WITH_AS(gpu.poll(), doctest::Contains("the GPU reported an error"), lab::Error); + CHECK_NOTHROW(gpu.poll()); // each error is thrown once + } + SUBCASE("or only collected, with throw_on_error = false") { + lab::Gpu gpu({.throw_on_error = false}); + lab::set_log_level(lab::LogLevel::off); + gpu.device().CreateBuffer(&invalid); + lab::set_log_level(lab::LogLevel::info); + + CHECK_NOTHROW(gpu.poll()); + CHECK(gpu.errors().size() == 1); + } +} diff --git a/tests/test_render.cpp b/tests/test_render.cpp new file mode 100644 index 0000000..4725bec --- /dev/null +++ b/tests/test_render.cpp @@ -0,0 +1,410 @@ +#include "common.h" + +#include +#include +#include + +using enum wgpu::VertexFormat; + +// Each test renders into a texture and compares pixels that were read back. + +static bool all_pixels_are(const std::vector& pixels, Pixel expected) { + return std::ranges::all_of(pixels, [&](const Pixel& pixel) { return pixel == expected; }); +} + +// a pipeline that fills the whole target with the given color and needs no buffers +static lab::Pipeline make_fill_pipeline(lab::Gpu& gpu, const lab::Texture& target, const char* wgsl_color) { + auto shader = lab::Shader::from_source( + gpu, fullscreen_vertex_shader + + std::format("@fragment fn fs_main() -> @location(0) vec4f {{ return {}; }}", wgsl_color)); + return lab::Pipeline(gpu, shader, {.target = target}); +} + +TEST_CASE("render: a triangle is drawn over the clear color") { + lab::Gpu gpu; + lab::Texture target = make_target(gpu); + + // the same triangle as samples/shaders/test1.wgsl + auto shader = lab::Shader::from_source(gpu, R"( + @vertex fn vs_main(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + var positions = array(vec2f(-0.5, -0.5), vec2f(0.5, -0.5), vec2f(0.0, 0.5)); + return vec4f(positions[i], 0.0, 1.0); + } + @fragment fn fs_main() -> @location(0) vec4f { + return vec4f(0.0, 0.4, 1.0, 1.0); + } + )"); + lab::Pipeline pipeline(gpu, shader, {.target = target}); + + { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target, {.clear = clear_color}); + pass.draw(pipeline, 3); + } + auto pixels = target.read(); + + CHECK(pixels[center] == Pixel{0, 102, 255, 255}); + CHECK(pixels[corner] == clear_pixel); + CHECK(gpu.errors().empty()); +} + +TEST_CASE("render: vertex buffer attributes reach the shader") { + lab::Gpu gpu; + lab::Texture target = make_target(gpu); + + auto shader = lab::Shader::from_source(gpu, R"( + struct VsOutput { + @builtin(position) position: vec4f, + @location(0) color: vec3f, + }; + @vertex fn vs_main(@location(0) position: vec2f, @location(1) color: vec3f) -> VsOutput { + return VsOutput(vec4f(position, 0.0, 1.0), color); + } + @fragment fn fs_main(in: VsOutput) -> @location(0) vec4f { + return vec4f(in.color, 1.0); + } + )"); + + struct Vertex { + float pos[2]; + float color[3]; + }; + std::vector vertex_data = { + {.pos = {-0.5f, -0.5f}, .color = {1.0f, 1.0f, 0.0f}}, + {.pos = {+0.5f, -0.5f}, .color = {1.0f, 1.0f, 0.0f}}, + {.pos = {+0.0f, +0.5f}, .color = {1.0f, 1.0f, 0.0f}}, + }; + lab::Buffer vertices(gpu, vertex_data); + lab::Pipeline pipeline(gpu, shader, + {.vertex_buffers = {lab::vertex({Float32x2, Float32x3})}, .target = target}); + + { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target, {.clear = clear_color}); + pass.draw(pipeline, {.vertex_buffers = {vertices}}); // 3 vertices, taken from the buffer + } + auto pixels = target.read(); + + CHECK(pixels[center] == Pixel{255, 255, 0, 255}); + CHECK(pixels[corner] == clear_pixel); + CHECK(gpu.errors().empty()); +} + +TEST_CASE("render: a uniform buffer is bound at its binding index") { + lab::Gpu gpu; + lab::Texture target = make_target(gpu); + + auto shader = lab::Shader::from_source(gpu, fullscreen_vertex_shader + R"( + @group(0) @binding(2) var color: vec4f; + @fragment fn fs_main() -> @location(0) vec4f { + return color; + } + )"); + lab::Pipeline pipeline(gpu, shader, {.target = target}); + + struct Color { + float r, g, b, a; + }; + lab::Buffer uniforms(gpu, {{0.0f, 1.0f, 0.0f, 1.0f}}, wgpu::BufferUsage::Uniform); + lab::Draw draw{.bind_groups = {pipeline.bind_group(0, {{2, uniforms}})}, .count = 3}; + + { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target); + pass.draw(pipeline, draw); + } + CHECK(all_pixels_are(target.read(), {0, 255, 0, 255})); + + // the same draw again after the uniform has changed + uniforms.write({0.0f, 0.0f, 1.0f, 1.0f}); + { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target); + pass.draw(pipeline, draw); + } + CHECK(all_pixels_are(target.read(), {0, 0, 255, 255})); + CHECK(gpu.errors().empty()); +} + +TEST_CASE("render: indexed and instanced drawing, with counts taken from the buffers") { + lab::Gpu gpu; + lab::Texture target = make_target(gpu); + + // a quad per instance: the first instance covers the left half, the second one the right half + auto shader = lab::Shader::from_source(gpu, R"( + struct VsOutput { + @builtin(position) position: vec4f, + @location(0) color: vec3f, + }; + @vertex fn vs_main(@location(0) corner: vec2f, @location(1) offset: f32, @location(2) color: vec3f) -> VsOutput { + return VsOutput(vec4f(corner.x * 0.5 + offset, corner.y, 0.0, 1.0), color); + } + @fragment fn fs_main(in: VsOutput) -> @location(0) vec4f { + return vec4f(in.color, 1.0); + } + )"); + + struct Corner { + float x, y; + }; + struct Instance { + float offset; + float color[3]; + }; + lab::Buffer corners(gpu, {{-1.0f, -1.0f}, {1.0f, -1.0f}, {1.0f, 1.0f}, {-1.0f, 1.0f}}); + lab::Buffer indices(gpu, {0, 1, 2, 2, 3, 0}, wgpu::BufferUsage::Index); + lab::Buffer instances(gpu, {{-0.5f, {0.0f, 0.0f, 1.0f}}, {+0.5f, {0.0f, 1.0f, 1.0f}}}); + + lab::Pipeline pipeline(gpu, shader, + {.vertex_buffers = {lab::vertex({Float32x2}), // location 0 + lab::instance({Float32, Float32x3})}, // locations 1 and 2 + .target = target}); + + { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target, {.clear = clear_color}); + pass.draw(pipeline, {.vertex_buffers = {corners, instances}, .index_buffer = indices}); // 6 indices, 2 instances + } + auto pixels = target.read(); + + CHECK(pixels[at(target_size / 4, target_size / 2)] == Pixel{0, 0, 255, 255}); // left half + CHECK(pixels[at(target_size * 3 / 4, target_size / 2)] == Pixel{0, 255, 255, 255}); // right half + CHECK(gpu.errors().empty()); +} + +TEST_CASE("render: two bind groups, one of them bound to a part of a buffer") { + lab::Gpu gpu; + lab::Texture target = make_target(gpu); + + auto shader = lab::Shader::from_source(gpu, fullscreen_vertex_shader + R"( + @group(0) @binding(0) var red: f32; + @group(1) @binding(0) var green_blue: vec2f; + @fragment fn fs_main() -> @location(0) vec4f { + return vec4f(red, green_blue, 1.0); + } + )"); + lab::Pipeline pipeline(gpu, shader, {.target = target}); + + lab::Buffer red(gpu, {1.0f}, wgpu::BufferUsage::Uniform); + + // two blocks in one buffer: a uniform binding has to start at a multiple of 256 bytes + struct alignas(256) Block { + float green, blue; + }; + lab::Buffer blocks(gpu, {{0.0f, 0.0f}, {0.0f, 1.0f}}, wgpu::BufferUsage::Uniform); + + lab::Draw draw{ + .bind_groups = {pipeline.bind_group(0, {{0, red}}), + pipeline.bind_group(1, {{0, blocks.handle(), sizeof(Block), sizeof(Block)}})}, // the second block + .count = 3, + }; + { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target); + pass.draw(pipeline, draw); + } + CHECK(all_pixels_are(target.read(), {255, 0, 255, 255})); + CHECK(gpu.errors().empty()); +} + +TEST_CASE("render: several pipelines in one pass, several passes in one frame") { + lab::Gpu gpu; + lab::Texture target = make_target(gpu); + lab::Pipeline green = make_fill_pipeline(gpu, target, "vec4f(0.0, 1.0, 0.0, 1.0)"); + lab::Pipeline half_blue = make_fill_pipeline(gpu, target, "vec4f(0.0, 0.0, 1.0, 0.5)"); + + SUBCASE("a second pipeline draws over the first, blended") { + { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target, {.clear = clear_color}); + pass.draw(green, 3); + pass.draw(half_blue, 3); + } + Pixel mixed = target.read()[center]; + CHECK(mixed.r == 0); + CHECK(mixed.g == doctest::Approx(128).epsilon(0.02)); + CHECK(mixed.b == doctest::Approx(128).epsilon(0.02)); + } + + SUBCASE("a second pass can keep what the first one drew") { + { + lab::Frame frame(gpu); + { + lab::RenderPass first(frame, target, {.clear = clear_color}); + first.draw(green, 3); + } + lab::RenderPass second(frame, target, {.clear = std::nullopt}); + } + CHECK(all_pixels_are(target.read(), {0, 255, 0, 255})); + } + + SUBCASE("or clear it again") { + { + lab::Frame frame(gpu); + lab::RenderPass first(frame, target); + first.draw(green, 3); + first.end(); + lab::RenderPass second(frame, target, {.clear = clear_color}); + } + CHECK(all_pixels_are(target.read(), clear_pixel)); + } + CHECK(gpu.errors().empty()); +} + +TEST_CASE("Texture: written pixels can be read back and sampled") { + lab::Gpu gpu; + + // 2x2 pixels: red, green / blue, white + lab::Texture image(gpu, wgpu::TextureFormat::RGBA8Unorm, 2, 2); + const std::vector image_pixels = {{255, 0, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}, {255, 255, 255, 255}}; + image.write(image_pixels); + + CHECK(image.read() == image_pixels); + + SUBCASE("a pixel type of the wrong size is an error") { + CHECK_THROWS_WITH_AS(image.read(), doctest::Contains("has 4 bytes"), lab::Error); + CHECK_THROWS_WITH_AS(image.write(std::vector(3)), doctest::Contains("2x2 = 4 pixels"), lab::Error); + } + + SUBCASE("a shader reads it, stretched over the target") { + lab::Texture target = make_target(gpu); + auto shader = lab::Shader::from_source(gpu, fullscreen_vertex_shader + R"( + @group(0) @binding(0) var image: texture_2d; + @fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { + return textureLoad(image, vec2u(position.xy) / 32, 0); + } + )"); + lab::Pipeline pipeline(gpu, shader, {.target = target, .blend = std::nullopt}); + { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target); + pass.draw(pipeline, {.bind_groups = {pipeline.bind_group(0, {{0, image}})}, .count = 3}); + } + auto pixels = target.read(); + + CHECK(pixels[at(16, 16)] == image_pixels[0]); // top left + CHECK(pixels[at(48, 16)] == image_pixels[1]); // top right + CHECK(pixels[at(16, 48)] == image_pixels[2]); // bottom left + CHECK(pixels[at(48, 48)] == image_pixels[3]); // bottom right + } + CHECK(gpu.errors().empty()); +} + +TEST_CASE("render: with a depth buffer, the nearer fragment wins whatever the drawing order") { + lab::Gpu gpu; + lab::Texture target = make_target(gpu); + lab::Texture depth(gpu, wgpu::TextureFormat::Depth24Plus, target_size, target_size); + + // a fullscreen triangle at a depth and in a color that both come from a uniform + auto shader = lab::Shader::from_source(gpu, R"( + struct Layer { color: vec4f, depth: f32 }; + @group(0) @binding(0) var layer: Layer; + @vertex fn vs_main(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + var positions = array(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0)); + return vec4f(positions[i], layer.depth, 1.0); + } + @fragment fn fs_main() -> @location(0) vec4f { + return layer.color; + } + )"); + lab::Pipeline pipeline(gpu, shader, {.target = {target, depth}}); + + struct alignas(16) Layer { + float color[4]; + float depth; + }; + lab::Buffer near_layer(gpu, {{{0.0f, 1.0f, 0.0f, 1.0f}, 0.2f}}, wgpu::BufferUsage::Uniform); + lab::Buffer far_layer(gpu, {{{0.0f, 0.0f, 1.0f, 1.0f}, 0.8f}}, wgpu::BufferUsage::Uniform); + lab::Draw draw_near{.bind_groups = {pipeline.bind_group(0, {{0, near_layer}})}, .count = 3}; + lab::Draw draw_far{.bind_groups = {pipeline.bind_group(0, {{0, far_layer}})}, .count = 3}; + + SUBCASE("near first, far second") { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target, {.depth = &depth}); + pass.draw(pipeline, draw_near); + pass.draw(pipeline, draw_far); + pass.end(); + frame.submit(); + CHECK(all_pixels_are(target.read(), {0, 255, 0, 255})); + } + SUBCASE("far first, near second") { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target, {.depth = &depth}); + pass.draw(pipeline, draw_far); + pass.draw(pipeline, draw_near); + pass.end(); + frame.submit(); + CHECK(all_pixels_are(target.read(), {0, 255, 0, 255})); + } + + SUBCASE("a pipeline without depth cannot draw into a pass that has a depth buffer") { + lab::Pipeline no_depth(gpu, shader, {.target = target}); + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target, {.depth = &depth}); + CHECK_THROWS_WITH_AS(pass.draw(no_depth, draw_near), doctest::Contains("with depth"), lab::Error); + } + CHECK(gpu.errors().empty()); +} + +TEST_CASE("Texture: a sampler filters between pixels") { + lab::Gpu gpu; + lab::Texture target = make_target(gpu); + + // one black and one white pixel, side by side + lab::Texture image(gpu, wgpu::TextureFormat::RGBA8Unorm, 2, 1); + image.write(std::vector{{0, 0, 0, 255}, {255, 255, 255, 255}}); + + auto shader = lab::Shader::from_source(gpu, fullscreen_vertex_shader + R"( + @group(0) @binding(0) var image: texture_2d; + @group(0) @binding(1) var image_sampler: sampler; + @fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f { + return textureSample(image, image_sampler, vec2f(position.x / 64.0, 0.5)); + } + )"); + lab::Pipeline pipeline(gpu, shader, {.target = target, .blend = std::nullopt}); + + auto render_with = [&](wgpu::Sampler sampler) { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target); + pass.draw(pipeline, {.bind_groups = {pipeline.bind_group(0, {{0, image}, {1, sampler}})}, .count = 3}); + }; + + const wgpu::AddressMode clamp = wgpu::AddressMode::ClampToEdge; + + render_with(lab::sampler(gpu, {.filter = wgpu::FilterMode::Nearest, .address_mode = clamp})); + auto nearest = target.read(); + CHECK(nearest[at(31, 32)].r == 0); // a hard edge in the middle + CHECK(nearest[at(32, 32)].r == 255); + + render_with(lab::sampler(gpu, {.filter = wgpu::FilterMode::Linear, .address_mode = clamp})); + auto linear = target.read(); + CHECK(linear[at(32, 32)].r == doctest::Approx(128).epsilon(0.05)); // a gradient from black to white + CHECK(linear[at(24, 32)].r < linear[at(40, 32)].r); + CHECK(gpu.errors().empty()); +} + +TEST_CASE("Texture: save_png writes a PNG file") { + lab::Gpu gpu; + lab::Texture target = make_target(gpu); + lab::Pipeline green = make_fill_pipeline(gpu, target, "vec4f(0.0, 1.0, 0.0, 1.0)"); + { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target); + pass.draw(green, 3); + } + + const auto path = std::filesystem::temp_directory_path() / "wgpu-lab-test-save.png"; + std::filesystem::remove(path); + target.save_png(path); + + char signature[8] = {}; + { + std::ifstream file(path, std::ios::binary); + REQUIRE(file); + file.read(signature, sizeof(signature)); + } // closed before the file is removed, Windows does not delete open files + CHECK(std::string_view(signature, 8) == "\x89PNG\r\n\x1a\n"); + // stored without compression: a bit more than 4 bytes per pixel + CHECK(std::filesystem::file_size(path) > target_size * target_size * 4); + std::filesystem::remove(path); +} diff --git a/tests/test_vertex_format.cpp b/tests/test_vertex_format.cpp new file mode 100644 index 0000000..2eff9de --- /dev/null +++ b/tests/test_vertex_format.cpp @@ -0,0 +1,54 @@ +#include "common.h" + +using enum wgpu::VertexFormat; + +// vertex_format_size is usable at compile time +static_assert(lab::vertex_format_size(Float32x3) == 12); + +TEST_CASE("vertex_format_size: components times bytes per component") { + CHECK(lab::vertex_format_size(Uint8) == 1); + CHECK(lab::vertex_format_size(Snorm8x2) == 2); + CHECK(lab::vertex_format_size(Unorm8x4) == 4); + CHECK(lab::vertex_format_size(Unorm8x4BGRA) == 4); + CHECK(lab::vertex_format_size(Uint16) == 2); + CHECK(lab::vertex_format_size(Float16x2) == 4); + CHECK(lab::vertex_format_size(Sint16x4) == 8); + CHECK(lab::vertex_format_size(Float32) == 4); + CHECK(lab::vertex_format_size(Float32x2) == 8); + CHECK(lab::vertex_format_size(Float32x3) == 12); + CHECK(lab::vertex_format_size(Float32x4) == 16); + CHECK(lab::vertex_format_size(Uint32x3) == 12); + CHECK(lab::vertex_format_size(Sint32x4) == 16); + CHECK(lab::vertex_format_size(Unorm10_10_10_2) == 4); +} + +struct Vertex { + float position[2]; + float color[3]; + uint8_t flags[4]; +}; + +TEST_CASE("vertex layout: attributes follow each other") { + lab::VertexLayout layout = lab::vertex({Float32x2, Float32x3, Unorm8x4}); + + CHECK(layout.stride == sizeof(Vertex)); + CHECK(layout.step_mode == wgpu::VertexStepMode::Vertex); + REQUIRE(layout.attributes.size() == 3); + CHECK(layout.attributes[0].offset == 0); + CHECK(layout.attributes[1].offset == 8); + CHECK(layout.attributes[2].offset == 20); +} + +TEST_CASE("vertex layout: an explicit offset moves the attributes after it") { + // skips the color + lab::VertexLayout layout = lab::instance({Float32x2, {Unorm8x4, 5, offsetof(Vertex, flags)}}); + + CHECK(layout.step_mode == wgpu::VertexStepMode::Instance); + REQUIRE(layout.attributes.size() == 2); + CHECK(layout.attributes[1].offset == 20); + CHECK(layout.attributes[1].location == 5); +} + +TEST_CASE("vertex layout: attributes that do not fit into the vertex type are an error") { + CHECK_THROWS_WITH_AS(lab::vertex({Float32x4, Float32x4}), doctest::Contains("has only 24 bytes"), lab::Error); +} diff --git a/tests/test_window.cpp b/tests/test_window.cpp new file mode 100644 index 0000000..38d7880 --- /dev/null +++ b/tests/test_window.cpp @@ -0,0 +1,139 @@ +#include "common.h" + +#include + +#include + +// These tests open windows, so they need a display. They are labelled "display" +// in CTest: `ctest -LE display` leaves them out. + +static lab::Pipeline make_triangle_pipeline(lab::Gpu& gpu, const lab::Surface& surface) { + auto shader = lab::Shader::from_source( + gpu, fullscreen_vertex_shader + "@fragment fn fs_main() -> @location(0) vec4f { return vec4f(1.0); }"); + return lab::Pipeline(gpu, shader, {.target = surface}); +} + +TEST_CASE("window: render_frame draws onto a surface") { + lab::Gpu gpu; + lab::Window window("test", 200, 150); + lab::Surface surface(gpu, window); + lab::Pipeline pipeline = make_triangle_pipeline(gpu, surface); + + CHECK(surface.size() == window.framebuffer_size()); + CHECK(surface.aspect() == doctest::Approx(200.0 / 150.0).epsilon(0.02)); + + int frames = 0; + for (int i = 0; i < 5 && lab::tick(); ++i) { + frames += pipeline.render_frame(surface, 3); + } + CHECK(frames == 5); + CHECK(gpu.errors().empty()); +} + +TEST_CASE("window: tick returns false once all windows are closed") { + lab::Window first("first", 200, 150); + lab::Window second("second", 200, 150); + CHECK(lab::tick()); + + first.close(); + CHECK_FALSE(first.is_open()); + CHECK(lab::tick()); + + second.close(); + CHECK_FALSE(lab::tick()); +} + +// The native window has to outlive the swapchain the gpu keeps for it, whatever +// the order in which a program lets go of its objects. +TEST_CASE("window: gpu, window and surface can be destroyed in any order") { + auto gpu = std::make_unique(); + auto window = std::make_unique("test", 200, 150); + auto surface = std::make_unique(*gpu, *window); + { + lab::Pipeline pipeline = make_triangle_pipeline(*gpu, *surface); + REQUIRE(lab::tick()); + REQUIRE(pipeline.render_frame(*surface, 3)); + } + + SUBCASE("surface, window, gpu") { + surface.reset(); + window.reset(); + gpu.reset(); + } + SUBCASE("gpu, surface, window") { + gpu.reset(); + surface.reset(); + window.reset(); + } + SUBCASE("window, surface, gpu") { + window.reset(); + surface.reset(); + gpu.reset(); + } + SUBCASE("window, gpu, surface") { + window.reset(); + gpu.reset(); + surface.reset(); + } + SUBCASE("gpu, window, surface") { + gpu.reset(); + window.reset(); + surface.reset(); + } + SUBCASE("surface, gpu, window") { + surface.reset(); + gpu.reset(); + window.reset(); + } +} + +TEST_CASE("window: a surface keeps working after its window was closed and moved") { + lab::Gpu gpu; + lab::Window window("test", 200, 150); + lab::Surface surface(gpu, window); + lab::Pipeline pipeline = make_triangle_pipeline(gpu, surface); + + // lab objects are handles: moving them does not disturb what is going on + lab::Window moved_window = std::move(window); + lab::Surface moved_surface = std::move(surface); + CHECK(lab::tick()); + CHECK(pipeline.render_frame(moved_surface, 3)); + + moved_window.close(); + CHECK_FALSE(lab::tick()); + CHECK_NOTHROW(pipeline.render_frame(moved_surface, 3)); // rendering into a hidden window is harmless + CHECK(gpu.errors().empty()); +} + +TEST_CASE("window: a surface with a depth buffer follows the size of its window") { + lab::Gpu gpu; + lab::Window window("test", 200, 150); + lab::Surface surface(gpu, window, {.depth = wgpu::TextureFormat::Depth24Plus}); + lab::Pipeline pipeline = make_triangle_pipeline(gpu, surface); + REQUIRE(lab::tick()); + REQUIRE(pipeline.render_frame(surface, 3)); + + // A window manager may honor a resize request, ignore it, or apply it and then take it + // back. Whatever it does, color and depth buffer have to stay in step with the window. + glfwSetWindowSize(window.handle(), 320, 240); + int frames = 0; + for (int i = 0; i < 10; ++i) { + lab::tick(); + frames += pipeline.render_frame(surface, 3); + CHECK(surface.size() == window.framebuffer_size()); + } + + CHECK(frames >= 8); // a frame may be skipped while the surface adapts + CHECK(gpu.errors().empty()); +} + +TEST_CASE("window: keyboard and mouse state start out empty") { + lab::Window window("test", 200, 150); + REQUIRE(lab::tick()); + + CHECK_FALSE(window.key(lab::KeyCode::space)); + CHECK_FALSE(window.key_pressed(lab::KeyCode::space)); + CHECK_FALSE(window.key(lab::KeyCode::unknown)); // out of range, must not crash + CHECK_FALSE(window.mouse_button(lab::MouseButton::left)); + CHECK(window.scroll().y == 0.0f); +} diff --git a/web/index.html.in b/web/index.html.in new file mode 100644 index 0000000..a207220 --- /dev/null +++ b/web/index.html.in @@ -0,0 +1,29 @@ + + + + + + wgpu-lab samples + + + +
+

wgpu-lab samples

+

The wgpu-lab samples, running in the browser through WebGPU. + The same C++ source renders natively on the desktop.

+ +
+ + diff --git a/web/shell.html b/web/shell.html new file mode 100644 index 0000000..a9dbb2d --- /dev/null +++ b/web/shell.html @@ -0,0 +1,35 @@ + + + + + + wgpu-lab + + + + ← all samples + +

+  
+  {{{ SCRIPT }}}
+
+