From 78ca539b275537e4365982dd374e3b48d98adb55 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 22:50:40 +0200 Subject: [PATCH 01/30] Build with plain CMake against a pinned, prebuilt Dawn The lab was pinned to Dawn chromium/6670 (Aug 2024), fetched by a Python script that cloned Dawn, glm and tinygltf into libs/ and built Dawn from source. That setup only ever worked on Windows and no longer configures with current toolchains. Build system - Dawn is pinned to release v20261002.154047 in cmake/LabDawn.cmake. LAB_DAWN selects where it comes from: the prebuilt archive Dawn's CI publishes (default), a source build, or an installed Dawn. - GLFW 3.4+ is taken from the system if present, fetched otherwise. - Only dawn::webgpu_dawn is linked; the Dawn-internal targets and the proc table setup are gone. - CMake presets (dev, release, dev-asan) replace the manual steps. Samples are only built when the lab is the top-level project. - The Python scripts are removed. Port to the current webgpu_cpp.h - Surfaces are created by our own glue (src/platform) from GLFW's native handles, for Wayland, X11 and Win32. No packaged Dawn ships webgpu_glfw. - Webgpu no longer opens a hidden window to probe the surface format (it did so with an instance that was not created yet) and no longer needs GLFW at all. - Vertex format sizes are a switch instead of a table indexed by enum value; the enum has been renumbered since. - Surfaces are sized in framebuffer pixels and the resize callback reports pixels. - StringView, ShaderSourceWGSL, TexelCopy*, the new callback signatures, SuccessOptimal/SuccessSuboptimal and reordered descriptor fields. - A frame without a surface texture is skipped instead of rendered to. The library builds without warnings with GCC 16 and Clang 23. C++23 is now required. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 3 +- CMakeLists.txt | 146 ++++++++++++++---------------- CMakePresets.json | 37 ++++++++ cmake/LabDawn.cmake | 108 ++++++++++++++++++++++ samples/CMakeLists.txt | 42 +++++++++ scripts/lab_fetch_dependencies.py | 25 ----- scripts/lab_helpers.py | 15 --- src/detail/lab.cpp | 7 +- src/detail/lab_pipeline.cpp | 53 ++--------- src/detail/lab_shader.cpp | 14 +-- src/detail/lab_surface.cpp | 19 ++-- src/detail/lab_webgpu.cpp | 69 ++++++-------- src/detail/lab_window.cpp | 6 +- src/extra/lab_mapped_vram.h | 8 +- src/extra/lab_pipeline_defaults.h | 2 +- src/extra/lab_public.h | 2 +- src/objects/lab_buffer.h | 11 ++- src/objects/lab_pipeline.h | 77 ++++++++++++++-- src/objects/lab_shader.h | 4 +- src/objects/lab_texture.h | 6 +- src/objects/lab_webgpu.h | 6 +- src/objects/lab_window.h | 1 + src/platform/lab_platform.h | 19 ++++ src/platform/lab_surface_glfw.cpp | 83 +++++++++++++++++ 24 files changed, 514 insertions(+), 249 deletions(-) create mode 100644 CMakePresets.json create mode 100644 cmake/LabDawn.cmake create mode 100644 samples/CMakeLists.txt delete mode 100644 scripts/lab_fetch_dependencies.py delete mode 100644 scripts/lab_helpers.py create mode 100644 src/platform/lab_platform.h create mode 100644 src/platform/lab_surface_glfw.cpp diff --git a/.gitignore b/.gitignore index e7a396d..14f27d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ build*/ -**/__pycache__/ +.cache/ +CMakeUserPresets.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 9613a40..53fca76 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,39 +1,59 @@ -cmake_minimum_required(VERSION 3.13...3.24 FATAL_ERROR) +cmake_minimum_required(VERSION 3.28) project( wgpu-lab VERSION 0.2.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}) +# options ---------------------------------------------------------------------- +# samples 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}) -# dawn config ------------------------------------------------------------------ -set(DAWN_FETCH_DEPENDENCIES ON) -set(DAWN_BUILD_SAMPLES OFF) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +include(FetchContent) -set(TINT_BUILD_SPV_READER OFF) -set(TINT_BUILD_CMD_TOOLS OFF) -set(TINT_BUILD_TESTS OFF) -set(TINT_BUILD_IR_BINARY OFF) +# the lab uses headers, not C++ modules: skip the per-file module dependency scan +set(CMAKE_CXX_SCAN_FOR_MODULES OFF) -add_subdirectory(libs/dawn) +if(PROJECT_IS_TOP_LEVEL) + set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -# 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) + 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() + +# dawn ------------------------------------------------------------------------- +include(LabDawn) + +# 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) + +# compile options shared by the library and its samples ------------------------- +function(lab_target_defaults LAB_TARGET) + set_target_properties(${LAB_TARGET} PROPERTIES + CXX_EXTENSIONS OFF + COMPILE_WARNING_AS_ERROR OFF) + if(MSVC) + target_compile_options(${LAB_TARGET} PRIVATE /W3) + else() + target_compile_options(${LAB_TARGET} PRIVATE -Wall -Wextra -pedantic) + endif() +endfunction() # wgpu-lab library ------------------------------------------------------------- add_library(wgpu-lab @@ -44,6 +64,9 @@ add_library(wgpu-lab src/detail/lab_window.cpp src/detail/lab.cpp + src/platform/lab_surface_glfw.cpp + src/platform/lab_platform.h + src/extra/lab_enums.h src/extra/lab_mapped_vram.h src/extra/lab_objects.h @@ -58,57 +81,26 @@ add_library(wgpu-lab src/objects/lab_window.h src/lab) +add_library(lab::lab ALIAS wgpu-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) +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() # 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) - set_target_properties(${LAB_TARGET} PROPERTIES - CXX_STANDARD 20 - CXX_STANDARD_REQUIRED ON - CXX_EXTENSIONS OFF - COMPILE_WARNING_AS_ERROR OFF) - if (MSVC) - target_compile_options(${LAB_TARGET} PRIVATE /W3) - else() - target_compile_options(${LAB_TARGET} PRIVATE -Wall -Wextra -pedantic) - endif() -endforeach(LAB_TARGET) +if(LAB_BUILD_SAMPLES) + add_subdirectory(samples) +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..938ab84 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,37 @@ +{ + "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": "dev-asan", + "displayName": "Debug + AddressSanitizer + UBSan (GCC/Clang)", + "inherits": "dev", + "cacheVariables": { + "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer", + "CMAKE_EXE_LINKER_FLAGS": "-fsanitize=address,undefined" + } + } + ], + "buildPresets": [ + { "name": "dev", "configurePreset": "dev" }, + { "name": "release", "configurePreset": "release" }, + { "name": "dev-asan", "configurePreset": "dev-asan" } + ] +} diff --git a/cmake/LabDawn.cmake b/cmake/LabDawn.cmake new file mode 100644 index 0000000..8454adf --- /dev/null +++ b/cmake/LabDawn.cmake @@ -0,0 +1,108 @@ +# 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}" + 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" + 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/samples/CMakeLists.txt b/samples/CMakeLists.txt new file mode 100644 index 0000000..a9d2fe9 --- /dev/null +++ b/samples/CMakeLists.txt @@ -0,0 +1,42 @@ +# 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) +foreach(SAMPLE_FILE IN LISTS SAMPLE_FILES) + get_filename_component(SAMPLE_NAME ${SAMPLE_FILE} NAME_WE) + 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}) +endforeach() + +# copy shader and asset files to the build directory ---------------------------- +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/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 index 374140a..0a78e81 100644 --- a/src/detail/lab.cpp +++ b/src/detail/lab.cpp @@ -1,6 +1,4 @@ -#include -#include -#include +#include #include #include @@ -20,9 +18,6 @@ bool init_lab() { } std::cout << "Info: GLFW: Initialized!" << std::endl; - // initialize dawn proc tables - dawnProcSetProcs(&dawn::native::GetProcs()); - // initialize only once state.lab_init = true; } diff --git a/src/detail/lab_pipeline.cpp b/src/detail/lab_pipeline.cpp index e1817aa..d1278ba 100644 --- a/src/detail/lab_pipeline.cpp +++ b/src/detail/lab_pipeline.cpp @@ -6,45 +6,6 @@ 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) { @@ -56,7 +17,8 @@ uint64_t vertex_attributes_stride(const std::vector& vert wgpu::TextureView get_current_render_texture_view(wgpu::Surface surface) { wgpu::SurfaceTexture surfaceTexture; surface.GetCurrentTexture(&surfaceTexture); - if (surfaceTexture.status != wgpu::SurfaceGetCurrentTextureStatus::Success) { + if (surfaceTexture.status != wgpu::SurfaceGetCurrentTextureStatus::SuccessOptimal && + surfaceTexture.status != wgpu::SurfaceGetCurrentTextureStatus::SuccessSuboptimal) { std::cerr << "Error: Pipeline: Could not get current render texture" << std::endl; return nullptr; } @@ -78,10 +40,10 @@ void Pipeline::finalize_config(wgpu::ShaderModule shaderModule) { label = std::format("Default Pipeline({} on {})", shader.label, webgpu.label); } - for (int i = 0; i < vb_configs.size(); ++i) { + for (size_t i = 0; i < vb_configs.size(); ++i) { vb_layouts.push_back({ - .arrayStride = vertex_attributes_stride(vb_configs[i].vertexAttributes), .stepMode = vb_configs[i].mode, + .arrayStride = vertex_attributes_stride(vb_configs[i].vertexAttributes), .attributeCount = vb_configs[i].vertexAttributes.size(), .attributes = vb_configs[i].vertexAttributes.data(), }); @@ -89,7 +51,7 @@ void Pipeline::finalize_config(wgpu::ShaderModule shaderModule) { config.vertexState.bufferCount = vb_layouts.size(); config.vertexState.buffers = vb_layouts.data(); - config.colorTarget.format = webgpu.capabilities.formats[0]; + config.colorTarget.format = webgpu.surface_format; config.colorTarget.blend = &config.blendState; // todo: make number of color targets configurable @@ -113,7 +75,7 @@ wgpu::RenderPipeline Pipeline::transfer() const { } wgpu::RenderPipelineDescriptor pipelineDesc = { - .label = label.c_str(), + .label = std::string_view(label), .layout = pipelineLayout, .vertex = config.vertexState, .primitive = config.primitiveState, @@ -136,6 +98,9 @@ bool Pipeline::default_render(PipelineHandle self, wgpu::Surface surface, const assert(self->wgpu_pipeline != nullptr); wgpu::TextureView targetView = get_current_render_texture_view(surface); + if (!targetView) { + return false; // nothing to render onto this time, e.g. the window is minimized + } wgpu::CommandEncoderDescriptor encoderDesc = {.label = "lab default command encoder"}; wgpu::CommandEncoder encoder = self->webgpu.device.CreateCommandEncoder(&encoderDesc); diff --git a/src/detail/lab_shader.cpp b/src/detail/lab_shader.cpp index 2bcffc6..856e80a 100644 --- a/src/detail/lab_shader.cpp +++ b/src/detail/lab_shader.cpp @@ -22,18 +22,20 @@ Shader::Shader(const std::string& lbl, const std::string& path) : label{lbl} { wgpu::ShaderModule Shader::transfer(wgpu::Device device, wgpu::SType struct_type) const { switch (struct_type) { - case wgpu::SType::ShaderModuleSPIRVDescriptor: + case wgpu::SType::ShaderSourceSPIRV: 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(); + case wgpu::SType::ShaderSourceWGSL: { + wgpu::ShaderSourceWGSL wgslDesc; + wgslDesc.code = std::string_view(source); wgpu::ShaderModuleDescriptor shaderDesc; shaderDesc.nextInChain = &wgslDesc; - shaderDesc.label = label.c_str(); + shaderDesc.label = std::string_view(label); return device.CreateShaderModule(&shaderDesc); } + default: + break; + } std::cout << "Error: Shader: struct_type not supported." << std::endl; return nullptr; } diff --git a/src/detail/lab_surface.cpp b/src/detail/lab_surface.cpp index 099b354..fb9e749 100644 --- a/src/detail/lab_surface.cpp +++ b/src/detail/lab_surface.cpp @@ -1,13 +1,13 @@ #include +#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)} { + : window{wnd}, webgpu{wgpu}, wgpu_surface{platform::create_surface(wgpu.instance, wnd.glfw_window_handle)} { reconfigure(); window.set_resize_callback([&](int width, int height) { if (width > 0 && height > 0) { @@ -19,17 +19,24 @@ Surface::Surface(Window& wnd, Webgpu& wgpu) void Surface::reconfigure(int w, int h) { wgpu::SurfaceConfiguration surfaceConfig = { .device = webgpu.device, - .format = webgpu.capabilities.formats[0], + .format = webgpu.surface_format, .usage = wgpu::TextureUsage::RenderAttachment, - .alphaMode = wgpu::CompositeAlphaMode::Auto, .width = static_cast(w), .height = static_cast(h), + .alphaMode = wgpu::CompositeAlphaMode::Auto, .presentMode = wgpu::PresentMode::Fifo, }; wgpu_surface.Configure(&surfaceConfig); } -void Surface::reconfigure() { reconfigure(window.width(), window.height()); } +void Surface::reconfigure() { + // the surface is measured in pixels, which differ from window coordinates on scaled displays + int width, height; + glfwGetFramebufferSize(window.glfw_window_handle, &width, &height); + if (width > 0 && height > 0) { + reconfigure(width, height); + } +} Surface::~Surface() { if (wgpu_surface) { diff --git a/src/detail/lab_webgpu.cpp b/src/detail/lab_webgpu.cpp index 93ae78a..894038c 100644 --- a/src/detail/lab_webgpu.cpp +++ b/src/detail/lab_webgpu.cpp @@ -1,26 +1,19 @@ #include -#include - -#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}}}; + // needed for the blocking instance.WaitAny() calls below + static const wgpu::InstanceFeatureName required_features[] = {wgpu::InstanceFeatureName::TimedWaitAny}; + wgpu::InstanceDescriptor instanceDesc{ + .requiredFeatureCount = std::size(required_features), + .requiredFeatures = required_features, + }; instance = wgpu::CreateInstance(&instanceDesc); if (!instance) { std::cerr << "Error: WGPU: Could not create Instance!" << std::endl; @@ -28,58 +21,56 @@ Webgpu::Webgpu(const std::string& lbl, wgpu::PowerPreference power_pref) : label } 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) { + [](wgpu::RequestAdapterStatus status, wgpu::Adapter adapter, wgpu::StringView 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; + std::cerr << "Error: WGPU: Failed to get adapter: " << std::string_view(message) << std::endl; } *userdata = std::move(adapter); }, &adapter); instance.WaitAny(future, UINT64_MAX); + if (!adapter) { + return; + } 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; - }; + deviceDesc.SetDeviceLostCallback( + wgpu::CallbackMode::AllowSpontaneous, + [](const wgpu::Device&, wgpu::DeviceLostReason reason, wgpu::StringView message) { + // a device that is destroyed along with its Webgpu object is not worth reporting + if (reason == wgpu::DeviceLostReason::Destroyed || reason == wgpu::DeviceLostReason::CallbackCancelled) { + return; + } + std::cerr << "Error: WGPU: Device lost: " << reason << " (" << std::string_view(message) << ")" << std::endl; + }); + deviceDesc.SetUncapturedErrorCallback([](const wgpu::Device&, wgpu::ErrorType type, wgpu::StringView message) { + std::cerr << "Error: WGPU: " << type << ": " << std::string_view(message) << std::endl; + }); future = adapter.RequestDevice( &deviceDesc, wgpu::CallbackMode::WaitAnyOnly, - [](wgpu::RequestDeviceStatus status, wgpu::Device device, char const* message, wgpu::Device* userdata) { + [](wgpu::RequestDeviceStatus status, wgpu::Device device, wgpu::StringView 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; + std::cerr << "Error: WGPU: Failed to get device: " << std::string_view(message) << std::endl; } *userdata = std::move(device); }, &device); instance.WaitAny(future, UINT64_MAX); - - surface.GetCapabilities(adapter, &capabilities); - glfwDestroyWindow(window); + if (!device) { + return; + } queue = device.GetQueue(); } diff --git a/src/detail/lab_window.cpp b/src/detail/lab_window.cpp index b268b00..cbd745a 100644 --- a/src/detail/lab_window.cpp +++ b/src/detail/lab_window.cpp @@ -36,8 +36,8 @@ void Window::set_key_callback(std::function kcb) { 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); }); + glfwSetFramebufferSizeCallback( + glfw_window_handle, [](GLFWwindow* wnd, int w, int h) { state.window_map[wnd]->user_resize_callback(w, h); }); } void Window::clear_key_callback() { @@ -46,7 +46,7 @@ void Window::clear_key_callback() { } void Window::clear_resize_callback() { - glfwSetWindowSizeCallback(glfw_window_handle, nullptr); + glfwSetFramebufferSizeCallback(glfw_window_handle, nullptr); user_resize_callback = nullptr; } diff --git a/src/extra/lab_mapped_vram.h b/src/extra/lab_mapped_vram.h index 9632006..d3af635 100644 --- a/src/extra/lab_mapped_vram.h +++ b/src/extra/lab_mapped_vram.h @@ -1,7 +1,7 @@ #ifndef WGPU_LAB_MAPPED_VRAM #define WGPU_LAB_MAPPED_VRAM -#include +#include #include @@ -23,10 +23,10 @@ namespace lab { 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} {} + : wgpu_buffer{wgpu_buffer}, data_view{view}, view_size{view_size} {} MappedVRAM(MappedVRAM&& other) - : data_view{std::move(other.data_view)}, view_size{std::move(other.view_size)}, - wgpu_buffer{std::move(other.wgpu_buffer)} { + : wgpu_buffer{std::move(other.wgpu_buffer)}, data_view{std::move(other.data_view)}, + view_size{std::move(other.view_size)} { other.data_view = {}; other.view_size = 0; other.wgpu_buffer = nullptr; diff --git a/src/extra/lab_pipeline_defaults.h b/src/extra/lab_pipeline_defaults.h index 4ba1532..ff7344c 100644 --- a/src/extra/lab_pipeline_defaults.h +++ b/src/extra/lab_pipeline_defaults.h @@ -1,4 +1,4 @@ -#include +#include namespace lab::pipeline_defaults { diff --git a/src/extra/lab_public.h b/src/extra/lab_public.h index e2ae376..f0b924d 100644 --- a/src/extra/lab_public.h +++ b/src/extra/lab_public.h @@ -23,7 +23,7 @@ using namespace std::chrono_literals; #endif // Dawn wgpu C++ header -#include +#include // Lab objects and enums ------------------------- diff --git a/src/objects/lab_buffer.h b/src/objects/lab_buffer.h index 8c4b3e1..9a76217 100644 --- a/src/objects/lab_buffer.h +++ b/src/objects/lab_buffer.h @@ -1,20 +1,23 @@ #ifndef WGPU_LAB_BUFFER_H #define WGPU_LAB_BUFFER_H -#include +#include #include #include +#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, Webgpu& instance) : label{label}, webgpu{instance} {} Buffer(const char* label, const std::vector& data, Webgpu& instance) : Buffer{label, instance} { to_device(data, wgpu::BufferUsage::Vertex); @@ -67,11 +70,11 @@ struct Buffer { 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) { + [](wgpu::MapAsyncStatus status, wgpu::StringView 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; + std::cerr << "Error: Buffer: " << std::string_view(message) << std::endl; } }, this); diff --git a/src/objects/lab_pipeline.h b/src/objects/lab_pipeline.h index 46f9cdf..905ad11 100644 --- a/src/objects/lab_pipeline.h +++ b/src/objects/lab_pipeline.h @@ -10,12 +10,65 @@ #include #include -#include +#include namespace lab { // returns size in bytes of given wgpu::VertexFormat -constexpr uint64_t vertex_format_size(const wgpu::VertexFormat& format); +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; +} // returns the total stride of a given vector of wgpu::VertexAttribute objects uint64_t vertex_attributes_stride(const std::vector& vertexAttributes); @@ -39,7 +92,7 @@ struct Pipeline { // // 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} { + Pipeline(Shader& sh, Webgpu& wg, bool final = false) : webgpu{wg}, shader{sh} { if (final) finalize(); } @@ -61,8 +114,8 @@ struct Pipeline { } struct DrawCallParams { - uint32_t vertexCount, instanceCount; - uint32_t firstVertex, firstInstance; + uint32_t vertexCount = 0, instanceCount = 1; + uint32_t firstVertex = 0, firstInstance = 0; }; using RenderFunction = std::function; @@ -132,7 +185,7 @@ struct Pipeline { 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}); + vb_configs.push_back({wgpu_buffer, mode, offset, {}}); } template @@ -141,10 +194,14 @@ struct Pipeline { 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; + // offset and buffer_index are worked out automatically by default: + // the attribute is appended to the vertex buffer that was added last + static constexpr uint64_t automatic = ~uint64_t{0}; + + void add_vertex_attrib(wgpu::VertexFormat format, uint32_t shader_location, uint64_t offset = automatic, + uint64_t buffer_index = automatic) { + uint64_t bi = buffer_index == automatic ? vb_configs.size() - 1 : buffer_index; + offset = offset == automatic ? vertex_attributes_stride(vb_configs.at(bi).vertexAttributes) : offset; vb_configs.at(bi).vertexAttributes.push_back({ .format = format, .offset = offset, diff --git a/src/objects/lab_shader.h b/src/objects/lab_shader.h index dee2a45..d2a9464 100644 --- a/src/objects/lab_shader.h +++ b/src/objects/lab_shader.h @@ -1,7 +1,7 @@ #ifndef WGPU_LAB_SHADER_H #define WGPU_LAB_SHADER_H -#include +#include #include @@ -18,7 +18,7 @@ struct Shader { // - 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; + wgpu::SType struct_type = wgpu::SType::ShaderSourceWGSL) const; }; } // namespace lab diff --git a/src/objects/lab_texture.h b/src/objects/lab_texture.h index af7c356..8d596a7 100644 --- a/src/objects/lab_texture.h +++ b/src/objects/lab_texture.h @@ -40,7 +40,7 @@ struct Texture { [[nodiscard]] wgpu::Texture transfer() { return webgpu.device.CreateTexture(&descriptor); } - wgpu::ImageCopyTexture target = { + wgpu::TexelCopyTextureInfo target = { .mipLevel = 0, .origin = {0, 0, 0}, .aspect = wgpu::TextureAspect::All, @@ -53,9 +53,9 @@ struct Texture { wgpu_texture = transfer(); target.texture = wgpu_texture; - wgpu::TextureDataLayout layout = { + wgpu::TexelCopyBufferLayout layout = { .offset = 0, - .bytesPerRow = sizeof(T) * descriptor.size.width, + .bytesPerRow = static_cast(sizeof(T) * descriptor.size.width), .rowsPerImage = descriptor.size.height, }; diff --git a/src/objects/lab_webgpu.h b/src/objects/lab_webgpu.h index 5901853..8cab129 100644 --- a/src/objects/lab_webgpu.h +++ b/src/objects/lab_webgpu.h @@ -3,7 +3,7 @@ #include -#include +#include namespace lab { @@ -15,7 +15,9 @@ struct Webgpu { ~Webgpu(); - wgpu::SurfaceCapabilities capabilities{}; + // Texture format shared by all surfaces and pipelines of this instance + // - BGRA8Unorm is what desktop window systems present natively + wgpu::TextureFormat surface_format = wgpu::TextureFormat::BGRA8Unorm; wgpu::Instance instance = nullptr; wgpu::Adapter adapter = nullptr; diff --git a/src/objects/lab_window.h b/src/objects/lab_window.h index ed71784..bb20c45 100644 --- a/src/objects/lab_window.h +++ b/src/objects/lab_window.h @@ -17,6 +17,7 @@ struct Window { void set_key_callback(std::function); void clear_key_callback(); + // width and height are the size of the framebuffer in pixels void set_resize_callback(std::function); void clear_resize_callback(); diff --git a/src/platform/lab_platform.h b/src/platform/lab_platform.h new file mode 100644 index 0000000..3a0bbc7 --- /dev/null +++ b/src/platform/lab_platform.h @@ -0,0 +1,19 @@ +#ifndef WGPU_LAB_PLATFORM_H +#define WGPU_LAB_PLATFORM_H + +#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); + +} // 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..1d9ef7a --- /dev/null +++ b/src/platform/lab_surface_glfw.cpp @@ -0,0 +1,83 @@ +#include + +#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 +} + +} // namespace lab::platform From 5c131887eb2fee95e007bde09d102fe7100431f0 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 22:50:51 +0200 Subject: [PATCH 02/30] Keep native windows alive until the GPU is done with them Closing the last window called glfwTerminate(), which on Wayland drops the display connection. The device releases its swapchain later, when Webgpu is destroyed, and then talked to the dead connection: every windowed sample crashed on exit. A closed window is now only hidden and unregistered. GLFW destroys the native windows and shuts down at program exit, after all GPU objects. Co-Authored-By: Claude Fable 5.1 --- src/detail/lab.cpp | 3 +++ src/detail/lab_window.cpp | 21 +++++++++------------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/detail/lab.cpp b/src/detail/lab.cpp index 0a78e81..bf36511 100644 --- a/src/detail/lab.cpp +++ b/src/detail/lab.cpp @@ -18,6 +18,9 @@ bool init_lab() { } std::cout << "Info: GLFW: Initialized!" << std::endl; + // GLFW stays initialized until the program exits, see Window::close() + std::atexit(glfwTerminate); + // initialize only once state.lab_init = true; } diff --git a/src/detail/lab_window.cpp b/src/detail/lab_window.cpp index cbd745a..1aefee8 100644 --- a/src/detail/lab_window.cpp +++ b/src/detail/lab_window.cpp @@ -66,22 +66,19 @@ int Window::height() const { float Window::ratio() const { return (1.f / width()) * height(); } -bool Window::is_open() const { return glfw_window_handle != nullptr; } +bool Window::is_open() const { return state.window_map.contains(glfw_window_handle); } +// A closed window is only hidden. The native window has to outlive every Surface +// and swapchain that presents into it, and the GPU releases those late (when the +// device is destroyed), so GLFW destroys the native windows at program exit instead. void Window::close() { - if (glfw_window_handle) { + if (is_open()) { std::cout << "Info: GLFW: Window(" << glfwGetWindowTitle(glfw_window_handle) << " - " << glfw_window_handle - << ") destroyed!" << std::endl; - glfwDestroyWindow(glfw_window_handle); + << ") closed!" << std::endl; + glfwSetKeyCallback(glfw_window_handle, nullptr); + glfwSetFramebufferSizeCallback(glfw_window_handle, nullptr); + glfwHideWindow(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(); } From a3b8ffa4406532ff5f1990d53c52c7209ff1d425 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 22:50:51 +0200 Subject: [PATCH 03/30] Add LAB_EXIT_AFTER_FRAMES to run samples unattended With the environment variable set to N, lab::tick() closes all windows after N calls. Every sample can then be run by a script or CI: LAB_EXIT_AFTER_FRAMES=120 ./sample_texture Co-Authored-By: Claude Fable 5.1 --- src/detail/lab.cpp | 19 ++++++++++++++++++- src/lab | 2 ++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/detail/lab.cpp b/src/detail/lab.cpp index bf36511..7aa246d 100644 --- a/src/detail/lab.cpp +++ b/src/detail/lab.cpp @@ -27,14 +27,31 @@ bool init_lab() { return state.lab_init; } +// Number of ticks after which all windows are closed, read from the environment +// variable LAB_EXIT_AFTER_FRAMES. Zero means run until the user closes the windows. +// It lets every sample run unattended: `LAB_EXIT_AFTER_FRAMES=120 ./sample_texture` +static long exit_after_frames() { + static const long frames = [] { + const char* value = std::getenv("LAB_EXIT_AFTER_FRAMES"); + return value ? std::atol(value) : 0L; + }(); + return frames; +} + bool tick() { glfwPollEvents(); std::vector to_close; to_close.reserve(state.window_map.size()); + static long tick_count = 0; + const bool exit_requested = exit_after_frames() > 0 && ++tick_count > exit_after_frames(); + if (exit_requested) { + tick_count = 0; + } + for (auto [hnd, wnd] : state.window_map) { - if (glfwWindowShouldClose(hnd)) { + if (exit_requested || glfwWindowShouldClose(hnd)) { to_close.push_back(wnd); } } diff --git a/src/lab b/src/lab index 10245c4..0be3d61 100644 --- a/src/lab +++ b/src/lab @@ -18,6 +18,8 @@ namespace lab { // 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 +// - closes all windows after N calls if the environment variable +// LAB_EXIT_AFTER_FRAMES=N is set (to run samples unattended) // ```cpp // while(lab::tick()) { // pipeline.render_frame(surface); // render artworks From 3fc8fa1c7eb216e2c0decb0c66d05b09cee12769 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 22:50:51 +0200 Subject: [PATCH 04/30] Fix compiler warnings in the samples Missing designated initializers and unused lambda captures, reported by GCC 16 and Clang 23 with -Wall -Wextra. Co-Authored-By: Claude Fable 5.1 --- samples/sample_dynamic_instances.cpp | 2 +- samples/sample_graph_visualization.cpp | 4 ++-- samples/sample_graph_visualization2.cpp | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/samples/sample_dynamic_instances.cpp b/samples/sample_dynamic_instances.cpp index c6c35ea..9546ce7 100644 --- a/samples/sample_dynamic_instances.cpp +++ b/samples/sample_dynamic_instances.cpp @@ -91,7 +91,7 @@ int main() { 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}; + MyUniformData uniforms{.ratio = {window.ratio(), 1.f}, .time = 0.f, .scale = .2f}; lab::Buffer uniform_buffer("My uniform buffer", {uniforms}, wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst, webgpu); diff --git a/samples/sample_graph_visualization.cpp b/samples/sample_graph_visualization.cpp index 1eb7b1e..fdd04b5 100644 --- a/samples/sample_graph_visualization.cpp +++ b/samples/sample_graph_visualization.cpp @@ -85,7 +85,7 @@ int main() { lab::Window window("graph visualizer", 900, 600); lab::Surface surface(window, webgpu); - UniformParams uniform_buffer{.ratio{window.ratio(), 1.0}}; + UniformParams uniform_buffer{.ratio{window.ratio(), 1.0}, .time = 0.0f}; lab::Buffer node_uniform_buffer("node uniform buffer", {uniform_buffer}, wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst, webgpu); @@ -99,7 +99,7 @@ int main() { if (event.key == lab::KeyCode::escape) window.close(); }); - window.set_resize_callback([&uniform_buffer, &node_uniform_buffer, &window, &surface](int w, int h) { + window.set_resize_callback([&uniform_buffer, &node_uniform_buffer, &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); diff --git a/samples/sample_graph_visualization2.cpp b/samples/sample_graph_visualization2.cpp index 3ca1d41..719da74 100644 --- a/samples/sample_graph_visualization2.cpp +++ b/samples/sample_graph_visualization2.cpp @@ -107,7 +107,7 @@ int main() { lab::Window window("graph visualizer", 900, 600); lab::Surface surface(window, webgpu); - UniformParams uniform_params{.ratio{window.ratio(), 1.0}}; + UniformParams uniform_params{.ratio{window.ratio(), 1.0}, .time = 0.0f}; lab::Buffer uniform_buffer("node uniform buffer", {uniform_params}, wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst, webgpu); @@ -118,7 +118,7 @@ int main() { if (event.key == lab::KeyCode::escape) window.close(); }); - window.set_resize_callback([&uniform_params, &uniform_buffer, &window, &surface](int w, int h) { + window.set_resize_callback([&uniform_params, &uniform_buffer, &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); From 29c85aab8febc3d5bd634fe8aac746f92f7bc57c Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 22:50:51 +0200 Subject: [PATCH 05/30] Make the editor setup work on Linux as well as Windows - launch.json gets a gdb configuration next to the MSVC one, so "F5 runs the sample that is open" works on both. - settings.json uses the CMake presets. - c_cpp_properties.json is removed: it hard-coded a Windows/MSVC IntelliSense setup (see issue #3). - .clangd points clangd at the compile commands of the dev preset. Co-Authored-By: Claude Fable 5.1 --- .clangd | 3 +++ .vscode/c_cpp_properties.json | 18 ------------------ .vscode/launch.json | 11 +++++++++++ .vscode/settings.json | 2 +- 4 files changed, 15 insertions(+), 19 deletions(-) create mode 100644 .clangd delete mode 100644 .vscode/c_cpp_properties.json 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/.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" From d8bb0d6b384699ff2dab9e5c4f61e6ac057b10b8 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 22:50:51 +0200 Subject: [PATCH 06/30] Add CI: build on Linux (GCC, Clang) and Windows (MSVC) Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..26c2d00 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main, cleanroom] + pull_request: + +jobs: + 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, these are its build dependencies + - 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 + + - 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 From 78c7c2d562ef0485211433359427d6bbae631c5d Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 22:50:51 +0200 Subject: [PATCH 07/30] Update the build instructions in the README Co-Authored-By: Claude Fable 5.1 --- README.md | 74 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index a2b9f0c..60147ea 100644 --- a/README.md +++ b/README.md @@ -60,31 +60,27 @@ int main() { ## Getting Started -### Windows - -**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 - -**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 +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 build takes a long time for downloading, generating and building dawn. +The first `cmake --preset dev` downloads a prebuilt Dawn (about 40 MB on Linux), +so there is no long Dawn build to wait for. -### Linux (coming soon) +**Run sample executables:** -### Mac (help wanted) +The samples end up at the top of the build directory, next to the shaders they load: -**Run sample executables:** +```sh +cd build/dev +./sample_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, @@ -92,13 +88,43 @@ simply by opening it in the editor and pressing `F5`. This runs the code in debu 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. +### 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. + +### 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`. + ### 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. +The library only depends on [WebGPU Dawn](https://dawn.googlesource.com/dawn) and +[GLFW](https://www.glfw.org/) for windowing. 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. +are downloaded as well. ## Roadmap From 904c30aa73c9d5988e5e61c545e8f1b32414be5e Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:01:16 +0200 Subject: [PATCH 08/30] Fix uniform buffer binding index and indexed draw arguments - add_uniform_buffer() created the layout entry at binding 0 whatever binding index it was given. Unnoticed so far because every sample binds its uniforms at 0. - The default render function passed firstVertex and dropped firstInstance when calling DrawIndexed. Co-Authored-By: Claude Fable 5.1 --- src/detail/lab_pipeline.cpp | 3 ++- src/objects/lab_pipeline.h | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/detail/lab_pipeline.cpp b/src/detail/lab_pipeline.cpp index d1278ba..0c60b36 100644 --- a/src/detail/lab_pipeline.cpp +++ b/src/detail/lab_pipeline.cpp @@ -137,7 +137,8 @@ bool Pipeline::default_render(PipelineHandle self, wgpu::Surface surface, const 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.DrawIndexed(draw_params.vertexCount, draw_params.instanceCount, draw_params.firstVertex, 0, + draw_params.firstInstance); } renderPass.End(); diff --git a/src/objects/lab_pipeline.h b/src/objects/lab_pipeline.h index 905ad11..c63b6c9 100644 --- a/src/objects/lab_pipeline.h +++ b/src/objects/lab_pipeline.h @@ -113,6 +113,8 @@ struct Pipeline { wgpu_pipeline = transfer(); } + // If the pipeline has an index buffer, `vertexCount` and `firstVertex` + // count indices instead of vertices struct DrawCallParams { uint32_t vertexCount = 0, instanceCount = 1; uint32_t firstVertex = 0, firstInstance = 0; @@ -228,7 +230,7 @@ struct Pipeline { 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_layout_buffer_entry(binding_index, visibility, wgpu::BufferBindingType::Uniform, sizeof(T)); add_bind_group_buffer_entry(uniform_buffer.wgpu_buffer, binding_index, sizeof(T)); } From a4b87448bba3fba69c2e0107ad382860e5186053 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:01:16 +0200 Subject: [PATCH 09/30] Make Buffer own its label and require trivially copyable elements - The label was kept as a const char* that could outlive its string. - current_capacity was read uninitialized on an empty buffer. - Buffer copies the bytes of T to and from GPU memory, so T now has to be trivially copyable. test_buffers.cpp, which uploaded std::string objects, is removed. Co-Authored-By: Claude Fable 5.1 --- samples/test_buffers.cpp | 66 ---------------------------------------- src/extra/lab_objects.h | 3 ++ src/objects/lab_buffer.h | 12 +++++--- 3 files changed, 11 insertions(+), 70 deletions(-) delete mode 100644 samples/test_buffers.cpp 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/src/extra/lab_objects.h b/src/extra/lab_objects.h index 2708b4d..727d372 100644 --- a/src/extra/lab_objects.h +++ b/src/extra/lab_objects.h @@ -1,6 +1,8 @@ #ifndef WGPU_LAB_OBJECTS_H #define WGPU_LAB_OBJECTS_H +#include + namespace lab { // Forward declarations of public lab objects and corresponding handle types. @@ -27,6 +29,7 @@ struct Pipeline; using PipelineHandle = Pipeline*; template + requires std::is_trivially_copyable_v struct Buffer; template diff --git a/src/objects/lab_buffer.h b/src/objects/lab_buffer.h index 9a76217..ca80f9c 100644 --- a/src/objects/lab_buffer.h +++ b/src/objects/lab_buffer.h @@ -10,12 +10,16 @@ #include #include #include +#include #include +#include #include namespace lab { +// T has to be trivially copyable, since its bytes are copied to and from GPU memory template + requires std::is_trivially_copyable_v struct Buffer { Buffer(const char* label, Webgpu& instance) : label{label}, webgpu{instance} {} @@ -34,7 +38,7 @@ struct Buffer { void to_device(const std::vector& data, wgpu::BufferUsage usage) { assert(wgpu_buffer == nullptr); wgpu::BufferDescriptor bufferDesc{ - .label = label, + .label = std::string_view(label), .usage = usage, .size = sizeof(T) * data.size(), .mappedAtCreation = true, @@ -50,7 +54,7 @@ struct Buffer { std::jthread to_device(WriteCallback write_func, size_t capacity, wgpu::BufferUsage usage) { assert(wgpu_buffer == nullptr); wgpu::BufferDescriptor bufferDesc{ - .label = label, + .label = std::string_view(label), .usage = usage, .size = sizeof(T) * capacity, .mappedAtCreation = true, @@ -109,8 +113,8 @@ struct Buffer { } wgpu::Buffer wgpu_buffer = nullptr; - size_t current_capacity; - const char* label; + size_t current_capacity = 0; + std::string label; Webgpu& webgpu; }; From 2ca5725be6847d2e9866c2f5d00949ed8d5605b8 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:01:16 +0200 Subject: [PATCH 10/30] Report a shader file that cannot be opened as an error It used to print a message and carry on with an empty shader, which surfaced much later as a confusing pipeline error. The exception names the file and the working directory, since a relative path that does not resolve is the usual cause. Co-Authored-By: Claude Fable 5.1 --- src/detail/lab_shader.cpp | 8 ++++++-- src/objects/lab_shader.h | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/detail/lab_shader.cpp b/src/detail/lab_shader.cpp index 856e80a..5eb66ad 100644 --- a/src/detail/lab_shader.cpp +++ b/src/detail/lab_shader.cpp @@ -1,8 +1,11 @@ #include +#include +#include #include #include #include +#include #include namespace lab { @@ -12,8 +15,9 @@ 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; + // relative paths depend on where the program is started from, so say where that is + throw std::runtime_error(std::format("lab::Shader \"{}\": could not open \"{}\" (working directory: {})", lbl, path, + std::filesystem::current_path().string())); } std::stringstream buffer; buffer << file.rdbuf(); diff --git a/src/objects/lab_shader.h b/src/objects/lab_shader.h index d2a9464..ad9cf48 100644 --- a/src/objects/lab_shader.h +++ b/src/objects/lab_shader.h @@ -12,6 +12,7 @@ struct Shader { Shader(const std::string& label); + // Reads WGSL source from a file, throws std::runtime_error if it cannot be opened Shader(const std::string& label, const std::string& path); // Warning! User is responsible to `.release()` the returned shader module From d36d949d40671417426693000df507272634d3a2 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:01:16 +0200 Subject: [PATCH 11/30] Stop injecting std::chrono_literals into every user of A header should not add a using-directive to the global namespace. Code that wants `100ms` writes `using namespace std::chrono_literals;` itself. Also rename two template parameters that used reserved names. Co-Authored-By: Claude Fable 5.1 --- src/extra/lab_public.h | 4 ---- src/lab | 12 +++--------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/extra/lab_public.h b/src/extra/lab_public.h index f0b924d..20fa085 100644 --- a/src/extra/lab_public.h +++ b/src/extra/lab_public.h @@ -18,10 +18,6 @@ #include #include -#ifdef LAB_USE_STL_CHRONO_LITERALS -using namespace std::chrono_literals; -#endif - // Dawn wgpu C++ header #include diff --git a/src/lab b/src/lab index 0be3d61..7d89b91 100644 --- a/src/lab +++ b/src/lab @@ -1,13 +1,6 @@ #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 { @@ -40,10 +33,11 @@ bool init_lab(); // - 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 +// using namespace std::chrono_literals; // lab::sleep(100ms); // ``` -template -inline void sleep(const std::chrono::duration<_Rep, _Period>& d) { +template +inline void sleep(const std::chrono::duration& d) { std::this_thread::sleep_for(d); } From d6c5fddf874832e358707ea27de69abc7b084467 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:01:16 +0200 Subject: [PATCH 12/30] Fall back to a software adapter when there is no GPU Makes the lab usable on CI machines and in VMs. The adapter in use is printed at startup. Co-Authored-By: Claude Fable 5.1 --- src/detail/lab_webgpu.cpp | 40 +++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/detail/lab_webgpu.cpp b/src/detail/lab_webgpu.cpp index 894038c..e8715d6 100644 --- a/src/detail/lab_webgpu.cpp +++ b/src/detail/lab_webgpu.cpp @@ -20,26 +20,34 @@ Webgpu::Webgpu(const std::string& lbl, wgpu::PowerPreference power_pref) : label return; } - wgpu::RequestAdapterOptions adapterOpts = { - .powerPreference = power_pref, - }; - - wgpu::Future future = instance.RequestAdapter( - &adapterOpts, wgpu::CallbackMode::WaitAnyOnly, - [](wgpu::RequestAdapterStatus status, wgpu::Adapter adapter, wgpu::StringView 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: " << std::string_view(message) << std::endl; - } - *userdata = std::move(adapter); - }, - &adapter); - instance.WaitAny(future, UINT64_MAX); + // a software adapter is only asked for if there is no GPU (CI machines, VMs) + wgpu::Future future; + for (bool fallback : {false, true}) { + wgpu::RequestAdapterOptions adapterOpts = { + .powerPreference = power_pref, + .forceFallbackAdapter = fallback, + }; + future = instance.RequestAdapter( + &adapterOpts, wgpu::CallbackMode::WaitAnyOnly, + [](wgpu::RequestAdapterStatus, wgpu::Adapter adapter, wgpu::StringView, wgpu::Adapter* userdata) { + *userdata = std::move(adapter); + }, + &adapter); + instance.WaitAny(future, UINT64_MAX); + if (adapter) { + break; + } + } if (!adapter) { + std::cerr << "Error: WGPU: Failed to get adapter: no GPU and no software adapter found" << std::endl; return; } + wgpu::AdapterInfo adapterInfo; + adapter.GetInfo(&adapterInfo); + std::cout << "Info: WGPU: Using adapter " << std::string_view(adapterInfo.device) << " (" << adapterInfo.backendType + << ")" << std::endl; + wgpu::DeviceDescriptor deviceDesc; deviceDesc.label = "lab default device"; deviceDesc.defaultQueue.label = "lab default queue"; From 331ac437678cb96202c6f700c3e7962bdd7c2072 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:01:16 +0200 Subject: [PATCH 13/30] Fix samples that could not exit or destroyed objects twice - sample_uniform_buffer joined a thread running `while (true)`, so the process never ended after its window was closed. The timer is now restarted from the main loop. - test_windows called destructors explicitly on objects that were destroyed again at the end of their scope, one of them from inside a callback owned by the object it destroyed. - sample_buffer shared plain bools between two threads. Co-Authored-By: Claude Fable 5.1 --- samples/sample_buffer.cpp | 6 ++++-- samples/sample_uniform_buffer.cpp | 20 ++++++-------------- samples/test_windows.cpp | 20 ++++++++++++-------- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/samples/sample_buffer.cpp b/samples/sample_buffer.cpp index 30d8d8a..df4b44c 100644 --- a/samples/sample_buffer.cpp +++ b/samples/sample_buffer.cpp @@ -1,5 +1,7 @@ #include +#include + using namespace std; using namespace lab; @@ -10,7 +12,7 @@ int main() { cout << "\n\nWriting Buffer...\n"; Buffer buffer("My Buffer", webgpu); { - bool writing_done = false; + atomic writing_done = false; auto init_buffer = [&writing_done](MappedVRAM vmap) { int start_point = 6; vmap.resize(start_point); @@ -32,7 +34,7 @@ int main() { // Read the buffer back to CPU ----------------------------------------------- { - bool reading_done = false; + atomic reading_done = false; cout << "\n\nReading Buffer...\n"; auto read_buffer = [&reading_done](MappedVRAM vmap) { for (auto e : vmap) { diff --git a/samples/sample_uniform_buffer.cpp b/samples/sample_uniform_buffer.cpp index 2c6457b..847f6ea 100644 --- a/samples/sample_uniform_buffer.cpp +++ b/samples/sample_uniform_buffer.cpp @@ -33,18 +33,6 @@ int main() { 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); @@ -52,10 +40,14 @@ int main() { // render the scene to surface pipeline.render_frame(surface, {3, 1}); + // restart the animation roughly every 7 seconds + if (lab::elapsed_seconds() > 7.0f) { + std::cout << "time reset at: " << lab::elapsed_seconds() << std::endl; + lab::restart_timer(); + } + // update uniform values for next draw call uniforms.ratio[0] = window.ratio(); uniforms.time = lab::elapsed_seconds(); } - - time_resetter.join(); } diff --git a/samples/test_windows.cpp b/samples/test_windows.cpp index e8c7e6f..4ec1e1e 100644 --- a/samples/test_windows.cpp +++ b/samples/test_windows.cpp @@ -54,6 +54,7 @@ int main() { std::unique_ptr sometimes_open; std::unique_ptr sometimes_surf; + bool close_second_window = false; window.set_key_callback([&](const KeyEvent& event) { if (event.key == KeyCode::space && event.action == KeyAction::press) { @@ -63,10 +64,8 @@ int main() { 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(); + // not destroyed right here: this callback is owned by the window it would destroy + close_second_window = true; } }); } @@ -76,8 +75,14 @@ int main() { while (tick()) { pipeline.render_frame(surface, {3, 1}); - if (sometimes_open && sometimes_open.get()->is_open()) { - pipeline.render_frame(*sometimes_surf.get(), {3, 1}); + if (close_second_window) { + sometimes_surf.reset(); + sometimes_open.reset(); + close_second_window = false; + } + + if (sometimes_open && sometimes_open->is_open()) { + pipeline.render_frame(*sometimes_surf, {3, 1}); } } } @@ -89,7 +94,7 @@ int main() { second_window.set_key_callback([&](const lab::KeyEvent& event) { if (event.key == lab::KeyCode::space) { - first_window.~Window(); + first_window.close(); } }); @@ -128,5 +133,4 @@ int main() { if (second_window.is_open()) pipe2.render_frame(second_surface, {3, 1}); } } - } From b12002d65ce7e0e30668564019d8f87b3a7bd1ec Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:01:26 +0200 Subject: [PATCH 14/30] Add tests: unit tests, offscreen render tests, sample smoke tests - tests/ holds doctest tests that need a GPU or software adapter but no window. The render tests draw into a texture and compare pixels: a triangle over the clear color, vertex attributes, a uniform buffer at a non-zero binding, indexed and instanced drawing. - Every sample is registered as a CTest test that has to run 60 frames without printing an error. These open windows and carry the label "samples", so `ctest -LE samples` runs headless. - CI runs the headless tests on a software adapter. - The dev-asan preset aborts on undefined behaviour. Its leak detection is off because GPU drivers leak a little when they are unloaded. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 9 +- CMakeLists.txt | 10 ++- CMakePresets.json | 18 +++- README.md | 10 +++ samples/CMakeLists.txt | 12 +++ tests/CMakeLists.txt | 24 ++++++ tests/offscreen.h | 108 ++++++++++++++++++++++++ tests/test_buffer.cpp | 39 +++++++++ tests/test_main.cpp | 2 + tests/test_pipeline.cpp | 41 +++++++++ tests/test_render.cpp | 157 +++++++++++++++++++++++++++++++++++ tests/test_vertex_format.cpp | 31 +++++++ 12 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 tests/CMakeLists.txt create mode 100644 tests/offscreen.h create mode 100644 tests/test_buffer.cpp create mode 100644 tests/test_main.cpp create mode 100644 tests/test_pipeline.cpp create mode 100644 tests/test_render.cpp create mode 100644 tests/test_vertex_format.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26c2d00..d614efe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,12 +20,13 @@ jobs: steps: - uses: actions/checkout@v4 - # GLFW is built from source on the runner, these are its build dependencies + # 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 + sudo apt-get install -y ninja-build libwayland-dev libxkbcommon-dev xorg-dev mesa-vulkan-drivers - name: Select compiler (Linux) if: runner.os == 'Linux' @@ -43,3 +44,7 @@ jobs: - name: Build run: cmake --build --preset release + + # the samples open windows and are skipped, everything else renders offscreen + - name: Test + run: ctest --preset release -LE samples diff --git a/CMakeLists.txt b/CMakeLists.txt index 53fca76..ee39abf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,9 +5,10 @@ project( LANGUAGES CXX C) # options ---------------------------------------------------------------------- -# samples are only built when wgpu-lab is the top-level project, so that pulling +# 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}) +option(LAB_BUILD_TESTS "Build the tests (run them with ctest)" ${PROJECT_IS_TOP_LEVEL}) list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") include(FetchContent) @@ -100,7 +101,12 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") endif() endif() -# sample executables ----------------------------------------------------------- +# 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 index 938ab84..74fd704 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -24,7 +24,7 @@ "displayName": "Debug + AddressSanitizer + UBSan (GCC/Clang)", "inherits": "dev", "cacheVariables": { - "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer", + "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer", "CMAKE_EXE_LINKER_FLAGS": "-fsanitize=address,undefined" } } @@ -33,5 +33,21 @@ { "name": "dev", "configurePreset": "dev" }, { "name": "release", "configurePreset": "release" }, { "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/README.md b/README.md index 60147ea..b4bf51a 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,16 @@ simply by opening it in the editor and pressing `F5`. This runs the code in debu 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 samples # only the tests that need no window +``` + +Any sample can be run unattended by setting `LAB_EXIT_AFTER_FRAMES`, for example +`LAB_EXIT_AFTER_FRAMES=120 ./sample_texture` closes its window after 120 frames. + ### Linux Wayland and X11 are both supported. If GLFW 3.4 or newer is installed diff --git a/samples/CMakeLists.txt b/samples/CMakeLists.txt index a9d2fe9..7ad5d3b 100644 --- a/samples/CMakeLists.txt +++ b/samples/CMakeLists.txt @@ -26,6 +26,18 @@ foreach(SAMPLE_FILE IN LISTS SAMPLE_FILES) # 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}) + + # Every sample doubles as a smoke test: it has to run 60 frames without reporting an error. + # These tests open windows, so they carry a label: `ctest -LE samples` 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 samples + ENVIRONMENT LAB_EXIT_AFTER_FRAMES=60 + FAIL_REGULAR_EXPRESSION "Error:" + TIMEOUT 30 + RUN_SERIAL ON) + endif() endforeach() # copy shader and asset files to the build directory ---------------------------- diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..be045f2 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,24 @@ +# 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) + +# tests of the library: they need a GPU (or a software adapter), but no window +add_executable(lab-tests + test_main.cpp + test_buffer.cpp + test_pipeline.cpp + test_render.cpp + test_vertex_format.cpp + offscreen.h) +target_include_directories(lab-tests SYSTEM PRIVATE ${doctest_SOURCE_DIR}) +target_link_libraries(lab-tests PRIVATE lab::lab) +lab_target_defaults(lab-tests) + +# registers every TEST_CASE as its own CTest test +include(${doctest_SOURCE_DIR}/scripts/cmake/doctest.cmake) +doctest_discover_tests(lab-tests) diff --git a/tests/offscreen.h b/tests/offscreen.h new file mode 100644 index 0000000..7b9aa08 --- /dev/null +++ b/tests/offscreen.h @@ -0,0 +1,108 @@ +#ifndef WGPU_LAB_TESTS_OFFSCREEN_H +#define WGPU_LAB_TESTS_OFFSCREEN_H + +#include + +#include + +#include +#include + +// Renders with a lab::Pipeline into a texture instead of a window and returns the pixels. +// This is written against raw WebGPU because the lab cannot render offscreen by itself yet. + +namespace offscreen { + +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 target is a square of this many pixels, cleared to opaque red +inline constexpr uint32_t size = 64; +inline constexpr wgpu::TextureFormat format = wgpu::TextureFormat::RGBA8Unorm; +inline constexpr Pixel clear_color{255, 0, 0, 255}; + +inline constexpr size_t center = (size / 2) * size + size / 2; // index of the pixel in the middle +inline constexpr size_t corner = 0; // index of the top left pixel + +// Draws `count` vertices (or indices, if the pipeline has an index buffer) and returns size * size pixels. +// The pipeline has to be finalized with `webgpu.surface_format = offscreen::format`. +inline std::vector render(lab::Pipeline& pipeline, uint32_t count, uint32_t instances = 1) { + wgpu::Device device = pipeline.webgpu.device; + + wgpu::TextureDescriptor textureDesc{ + .usage = wgpu::TextureUsage::RenderAttachment | wgpu::TextureUsage::CopySrc, + .size = {size, size, 1}, + .format = format, + }; + wgpu::Texture texture = device.CreateTexture(&textureDesc); + + wgpu::RenderPassColorAttachment attachment{ + .view = texture.CreateView(), + .loadOp = wgpu::LoadOp::Clear, + .storeOp = wgpu::StoreOp::Store, + .clearValue = {1.0, 0.0, 0.0, 1.0}, + }; + wgpu::RenderPassDescriptor passDesc{.colorAttachmentCount = 1, .colorAttachments = &attachment}; + + wgpu::CommandEncoder encoder = device.CreateCommandEncoder(); + wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&passDesc); + pass.SetPipeline(pipeline.wgpu_pipeline); + for (uint32_t i = 0; i < pipeline.vb_configs.size(); ++i) { + pass.SetVertexBuffer(i, pipeline.vb_configs[i].buffer, pipeline.vb_configs[i].offset); + } + for (uint32_t i = 0; i < pipeline.bindGroups.size(); ++i) { + pass.SetBindGroup(i, pipeline.bindGroups[i]); + } + if (pipeline.ib_configs.empty()) { + pass.Draw(count, instances); + } else { + const auto& index_buffer = pipeline.ib_configs.front(); + pass.SetIndexBuffer(index_buffer.buffer, index_buffer.format, index_buffer.offset); + pass.DrawIndexed(count, instances); + } + pass.End(); + + // copy the texture into a buffer that can be mapped and read + wgpu::BufferDescriptor readbackDesc{ + .usage = wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst, + .size = size * size * sizeof(Pixel), + }; + wgpu::Buffer readback = device.CreateBuffer(&readbackDesc); + + wgpu::TexelCopyTextureInfo source; + source.texture = texture; + wgpu::TexelCopyBufferInfo destination; + destination.buffer = readback; + destination.layout.bytesPerRow = size * sizeof(Pixel); // 256, which is the required row alignment + destination.layout.rowsPerImage = size; + wgpu::Extent3D extent{size, size, 1}; + encoder.CopyTextureToBuffer(&source, &destination, &extent); + + wgpu::CommandBuffer commands = encoder.Finish(); + pipeline.webgpu.queue.Submit(1, &commands); + + bool mapped = false; + wgpu::Future future = readback.MapAsync( + wgpu::MapMode::Read, 0, wgpu::kWholeMapSize, wgpu::CallbackMode::WaitAnyOnly, + [](wgpu::MapAsyncStatus status, wgpu::StringView, bool* mapped) { + *mapped = status == wgpu::MapAsyncStatus::Success; + }, + &mapped); + pipeline.webgpu.instance.WaitAny(future, UINT64_MAX); + REQUIRE(mapped); + + const Pixel* data = static_cast(readback.GetConstMappedRange()); + std::vector pixels(data, data + size * size); + readback.Unmap(); + return pixels; +} + +} // namespace offscreen + +#endif // WGPU_LAB_TESTS_OFFSCREEN_H diff --git a/tests/test_buffer.cpp b/tests/test_buffer.cpp new file mode 100644 index 0000000..a26ecc0 --- /dev/null +++ b/tests/test_buffer.cpp @@ -0,0 +1,39 @@ +#include + +#include + +TEST_CASE("Buffer: written elements can be read back") { + lab::Webgpu webgpu("test"); + REQUIRE(webgpu.device); + + std::vector data{1, 2, 3, 4, 5, 6, 7, 8}; + lab::Buffer buffer("test buffer", data, wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst, webgpu); + + buffer.write(42, 2); // one element at index 2 + buffer.write(std::vector{7, 7}, 6); // two elements starting at index 6 + + std::vector result; + { + auto reader = buffer.from_device( + [&result](lab::MappedVRAM mapped) { result.assign(mapped.begin(), mapped.end()); }); + } // the reader thread is joined here + + CHECK(result == std::vector{1, 2, 42, 4, 5, 6, 7, 7}); +} + +TEST_CASE("Buffer: a part of the buffer can be read back") { + lab::Webgpu webgpu("test"); + REQUIRE(webgpu.device); + + std::vector data{0.5f, 1.5f, 2.5f, 3.5f, 4.5f}; + lab::Buffer buffer("test buffer", data, wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst, webgpu); + + std::vector result; + { + // offsets are multiples of 8 bytes, the alignment WebGPU requires for mapping + auto reader = buffer.from_device( + 2, 2, [&result](lab::MappedVRAM mapped) { result.assign(mapped.begin(), mapped.end()); }); + } + + CHECK(result == std::vector{2.5f, 3.5f}); +} 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..9a1f1a1 --- /dev/null +++ b/tests/test_pipeline.cpp @@ -0,0 +1,41 @@ +#include + +#include + +TEST_CASE("Pipeline: add_uniform_buffer uses the given binding index") { + lab::Webgpu webgpu("test"); + REQUIRE(webgpu.device); + + lab::Shader shader("unused"); + lab::Pipeline pipeline(shader, webgpu); + lab::Buffer uniforms("uniforms", {1.0f}, wgpu::BufferUsage::Uniform, webgpu); + + pipeline.add_uniform_buffer(uniforms, 3, wgpu::ShaderStage::Fragment); + + CHECK(pipeline.bindGroupLayoutEntries.back().binding == 3); + CHECK(pipeline.bindGroupEntries.back().binding == 3); +} + +TEST_CASE("Pipeline: vertex attributes are laid out one after another") { + lab::Webgpu webgpu("test"); + REQUIRE(webgpu.device); + + lab::Shader shader("unused"); + lab::Pipeline pipeline(shader, webgpu); + lab::Buffer vertices("vertices", {0.0f, 0.0f, 0.0f, 0.0f, 0.0f}, webgpu); + + pipeline.add_vertex_buffer(vertices); + pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); + pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x3, 1); + + const auto& attributes = pipeline.vb_configs.back().vertexAttributes; + REQUIRE(attributes.size() == 2); + CHECK(attributes[0].offset == 0); + CHECK(attributes[1].offset == 8); + CHECK(lab::vertex_attributes_stride(attributes) == 20); +} + +TEST_CASE("Shader: a missing file is reported with its path") { + CHECK_THROWS_WITH_AS(lab::Shader("missing", "does/not/exist.wgsl"), doctest::Contains("does/not/exist.wgsl"), + std::runtime_error); +} diff --git a/tests/test_render.cpp b/tests/test_render.cpp new file mode 100644 index 0000000..ff7a8af --- /dev/null +++ b/tests/test_render.cpp @@ -0,0 +1,157 @@ +#include "offscreen.h" + +#include + +using offscreen::Pixel; + +// a triangle that covers the whole render target, see +// https://webgpufundamentals.org/webgpu/lessons/webgpu-large-triangle-to-cover-clip-space.html +static const char* const 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); + } +)"; + +TEST_CASE("render: a triangle is drawn over the clear color") { + lab::Webgpu webgpu("test"); + REQUIRE(webgpu.device); + webgpu.surface_format = offscreen::format; + + // the same shader as samples/shaders/test1.wgsl + lab::Shader shader("triangle"); + shader.source = 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(shader, webgpu, true); + + auto pixels = offscreen::render(pipeline, 3); + + CHECK(pixels[offscreen::center] == Pixel{0, 102, 255, 255}); + CHECK(pixels[offscreen::corner] == offscreen::clear_color); +} + +TEST_CASE("render: vertex buffer attributes reach the shader") { + lab::Webgpu webgpu("test"); + REQUIRE(webgpu.device); + webgpu.surface_format = offscreen::format; + + lab::Shader shader("colored vertices"); + shader.source = 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); + } + )"; + lab::Pipeline pipeline(shader, webgpu); + + struct Vertex { + float pos[2]; + float color[3]; + }; + std::vector vertices = { + {.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 vertex_buffer("vertices", vertices, webgpu); + + pipeline.add_vertex_buffer(vertex_buffer); + pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); + pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x3, 1); + pipeline.finalize(); + + auto pixels = offscreen::render(pipeline, 3); + + CHECK(pixels[offscreen::center] == Pixel{255, 255, 0, 255}); + CHECK(pixels[offscreen::corner] == offscreen::clear_color); +} + +TEST_CASE("render: a uniform buffer is bound at the binding index it was added with") { + lab::Webgpu webgpu("test"); + REQUIRE(webgpu.device); + webgpu.surface_format = offscreen::format; + + lab::Shader shader("uniform color"); + shader.source = std::string(fullscreen_vertex_shader) + R"( + @group(0) @binding(2) var color: vec4f; + @fragment fn fs_main() -> @location(0) vec4f { + return color; + } + )"; + lab::Pipeline pipeline(shader, webgpu); + + struct Color { + float r, g, b, a; + }; + lab::Buffer uniform_buffer("color", {{0.0f, 1.0f, 0.0f, 1.0f}}, wgpu::BufferUsage::Uniform, webgpu); + pipeline.add_uniform_buffer(uniform_buffer, 2, wgpu::ShaderStage::Fragment); + pipeline.finalize(); + + auto pixels = offscreen::render(pipeline, 3); + + CHECK(std::ranges::all_of(pixels, [](const Pixel& p) { return p == Pixel{0, 255, 0, 255}; })); +} + +TEST_CASE("render: indexed and instanced drawing") { + lab::Webgpu webgpu("test"); + REQUIRE(webgpu.device); + webgpu.surface_format = offscreen::format; + + // a quad per instance: the first instance covers the left half, the second one the right half + lab::Shader shader("instanced quads"); + shader.source = 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); + } + )"; + lab::Pipeline pipeline(shader, webgpu); + + struct Corner { + float x, y; + }; + struct Instance { + float offset; + float color[3]; + }; + std::vector corners = {{-1.0f, -1.0f}, {1.0f, -1.0f}, {1.0f, 1.0f}, {-1.0f, 1.0f}}; + std::vector indices = {0, 1, 2, 2, 3, 0}; + std::vector instances = {{-0.5f, {0.0f, 0.0f, 1.0f}}, {+0.5f, {0.0f, 1.0f, 1.0f}}}; + + lab::Buffer corner_buffer("corners", corners, webgpu); + lab::Buffer index_buffer("indices", indices, wgpu::BufferUsage::Index, webgpu); + lab::Buffer instance_buffer("instances", instances, webgpu); + + pipeline.add_vertex_buffer(corner_buffer); + pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); + pipeline.add_index_buffer(index_buffer, wgpu::IndexFormat::Uint16); + pipeline.add_vertex_buffer(instance_buffer, wgpu::VertexStepMode::Instance); + pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32, 1); + pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x3, 2); + pipeline.finalize(); + + auto pixels = offscreen::render(pipeline, 6, 2); + + const size_t row = (offscreen::size / 2) * offscreen::size; + CHECK(pixels[row + offscreen::size / 4] == Pixel{0, 0, 255, 255}); // left half + CHECK(pixels[row + offscreen::size * 3 / 4] == Pixel{0, 255, 255, 255}); // right half +} diff --git a/tests/test_vertex_format.cpp b/tests/test_vertex_format.cpp new file mode 100644 index 0000000..239f2fc --- /dev/null +++ b/tests/test_vertex_format.cpp @@ -0,0 +1,31 @@ +#include + +#include + +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); +} + +TEST_CASE("vertex_attributes_stride: sum of the attribute sizes") { + std::vector attributes{{.format = Float32x2}, {.format = Float32x3}, {.format = Unorm8x4}}; + CHECK(lab::vertex_attributes_stride(attributes) == 8 + 12 + 4); + CHECK(lab::vertex_attributes_stride({}) == 0); +} From 7b324641ddb23e3fdf1404e688ee5355c40b2157 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:01:26 +0200 Subject: [PATCH 15/30] Fetch Dawn's sources as an archive and time out stalled downloads LAB_DAWN=source cloned Dawn with GIT_SHALLOW. CMake's shallow clone still fetches the tip of every branch and tag, and Dawn has thousands of them, so the clone took longer than building Dawn. The source archive of the pinned tag is 27 MB. Both Dawn downloads now fail after 60 seconds without data instead of hanging the configure step forever. Building Dawn from source this way takes about five minutes on a 32-core machine and all tests pass against it. Co-Authored-By: Claude Fable 5.1 --- cmake/LabDawn.cmake | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmake/LabDawn.cmake b/cmake/LabDawn.cmake index 8454adf..56f7d04 100644 --- a/cmake/LabDawn.cmake +++ b/cmake/LabDawn.cmake @@ -64,6 +64,7 @@ if(LAB_DAWN STREQUAL "prebuilt") 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 @@ -91,6 +92,7 @@ elseif(LAB_DAWN STREQUAL "source") # 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) From 28b4920eb3a7c424e8ee9f0049490e447a039f9c Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:01:26 +0200 Subject: [PATCH 16/30] Bump version to 0.3.0 Co-Authored-By: Claude Fable 5.1 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ee39abf..7c08ddf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.28) project( wgpu-lab - VERSION 0.2.0 + VERSION 0.3.0 LANGUAGES CXX C) # options ---------------------------------------------------------------------- From 304e5f5a7a62ea13811ff15b5e978a54414cc904 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:05:16 +0200 Subject: [PATCH 17/30] Separate the public headers from the implementation Headers move from src/objects and src/extra to a flat include/ and sources from src/detail to src/. Only include/ is on the include path of projects using the lab, so they no longer see , or the private lab_state.h. No code changes besides the include paths. Co-Authored-By: Claude Fable 5.1 --- CMakeLists.txt | 43 ++++++++++--------- README.md | 2 +- {src => include}/lab | 2 +- {src/objects => include}/lab_buffer.h | 4 +- {src/extra => include}/lab_enums.h | 0 {src/extra => include}/lab_mapped_vram.h | 0 {src/extra => include}/lab_objects.h | 0 {src/objects => include}/lab_pipeline.h | 16 +++---- .../extra => include}/lab_pipeline_defaults.h | 0 {src/extra => include}/lab_public.h | 16 +++---- {src/objects => include}/lab_shader.h | 0 {src/objects => include}/lab_surface.h | 4 +- {src/objects => include}/lab_texture.h | 2 +- {src/objects => include}/lab_webgpu.h | 0 {src/objects => include}/lab_window.h | 4 +- src/{detail => }/lab.cpp | 2 +- src/{detail => }/lab_pipeline.cpp | 2 +- src/{detail => }/lab_shader.cpp | 2 +- src/{extra => }/lab_state.h | 2 +- src/{detail => }/lab_surface.cpp | 4 +- src/{detail => }/lab_webgpu.cpp | 2 +- src/{detail => }/lab_window.cpp | 6 +-- src/platform/lab_surface_glfw.cpp | 2 +- 23 files changed, 58 insertions(+), 57 deletions(-) rename {src => include}/lab (98%) rename {src/objects => include}/lab_buffer.h (98%) rename {src/extra => include}/lab_enums.h (100%) rename {src/extra => include}/lab_mapped_vram.h (100%) rename {src/extra => include}/lab_objects.h (100%) rename {src/objects => include}/lab_pipeline.h (97%) rename {src/extra => include}/lab_pipeline_defaults.h (100%) rename {src/extra => include}/lab_public.h (67%) rename {src/objects => include}/lab_shader.h (100%) rename {src/objects => include}/lab_surface.h (86%) rename {src/objects => include}/lab_texture.h (98%) rename {src/objects => include}/lab_webgpu.h (100%) rename {src/objects => include}/lab_window.h (93%) rename src/{detail => }/lab.cpp (98%) rename src/{detail => }/lab_pipeline.cpp (99%) rename src/{detail => }/lab_shader.cpp (97%) rename src/{extra => }/lab_state.h (94%) rename src/{detail => }/lab_surface.cpp (94%) rename src/{detail => }/lab_webgpu.cpp (99%) rename src/{detail => }/lab_window.cpp (96%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7c08ddf..3fed2ae 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,33 +58,34 @@ endfunction() # 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/lab.cpp + src/lab_pipeline.cpp + src/lab_shader.cpp + src/lab_surface.cpp + src/lab_webgpu.cpp + src/lab_window.cpp + src/lab_state.h src/platform/lab_surface_glfw.cpp src/platform/lab_platform.h - 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) + include/lab + include/lab_buffer.h + include/lab_enums.h + include/lab_mapped_vram.h + include/lab_objects.h + include/lab_pipeline.h + include/lab_pipeline_defaults.h + include/lab_public.h + include/lab_shader.h + include/lab_surface.h + include/lab_texture.h + include/lab_webgpu.h + include/lab_window.h) add_library(lab::lab ALIAS wgpu-lab) -target_include_directories(wgpu-lab PUBLIC src) +# 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) diff --git a/README.md b/README.md index b4bf51a..f819ef5 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ The pinned Dawn version is set at the top of `cmake/LabDawn.cmake`. ### Dependencies The library only depends on [WebGPU Dawn](https://dawn.googlesource.com/dawn) and [GLFW](https://www.glfw.org/) for windowing. -wgpu-lab also makes heavy use of the C++ STL (see `src/extra/lab_public.h`). +wgpu-lab also makes heavy use of the C++ STL (see `include/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 downloaded as well. diff --git a/src/lab b/include/lab similarity index 98% rename from src/lab rename to include/lab index 7d89b91..4be9b24 100644 --- a/src/lab +++ b/include/lab @@ -1,7 +1,7 @@ #ifndef WGPU_LAB_MAIN_H #define WGPU_LAB_MAIN_H -#include +#include namespace lab { diff --git a/src/objects/lab_buffer.h b/include/lab_buffer.h similarity index 98% rename from src/objects/lab_buffer.h rename to include/lab_buffer.h index ca80f9c..43c3ea7 100644 --- a/src/objects/lab_buffer.h +++ b/include/lab_buffer.h @@ -3,8 +3,8 @@ #include -#include -#include +#include +#include #include #include diff --git a/src/extra/lab_enums.h b/include/lab_enums.h similarity index 100% rename from src/extra/lab_enums.h rename to include/lab_enums.h diff --git a/src/extra/lab_mapped_vram.h b/include/lab_mapped_vram.h similarity index 100% rename from src/extra/lab_mapped_vram.h rename to include/lab_mapped_vram.h diff --git a/src/extra/lab_objects.h b/include/lab_objects.h similarity index 100% rename from src/extra/lab_objects.h rename to include/lab_objects.h diff --git a/src/objects/lab_pipeline.h b/include/lab_pipeline.h similarity index 97% rename from src/objects/lab_pipeline.h rename to include/lab_pipeline.h index c63b6c9..defb25b 100644 --- a/src/objects/lab_pipeline.h +++ b/include/lab_pipeline.h @@ -1,14 +1,14 @@ #ifndef WGPU_LAB_PIPELINE_H #define WGPU_LAB_PIPELINE_H -#include -#include - -#include -#include -#include -#include -#include +#include +#include + +#include +#include +#include +#include +#include #include diff --git a/src/extra/lab_pipeline_defaults.h b/include/lab_pipeline_defaults.h similarity index 100% rename from src/extra/lab_pipeline_defaults.h rename to include/lab_pipeline_defaults.h diff --git a/src/extra/lab_public.h b/include/lab_public.h similarity index 67% rename from src/extra/lab_public.h rename to include/lab_public.h index 20fa085..c40f4b2 100644 --- a/src/extra/lab_public.h +++ b/include/lab_public.h @@ -23,12 +23,12 @@ // Lab objects and enums ------------------------- -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include -#include +#include diff --git a/src/objects/lab_shader.h b/include/lab_shader.h similarity index 100% rename from src/objects/lab_shader.h rename to include/lab_shader.h diff --git a/src/objects/lab_surface.h b/include/lab_surface.h similarity index 86% rename from src/objects/lab_surface.h rename to include/lab_surface.h index 2fbb198..01c70e8 100644 --- a/src/objects/lab_surface.h +++ b/include/lab_surface.h @@ -1,8 +1,8 @@ #ifndef WGPU_LAB_SURFACE_H #define WGPU_LAB_SURFACE_H -#include -#include +#include +#include namespace lab { diff --git a/src/objects/lab_texture.h b/include/lab_texture.h similarity index 98% rename from src/objects/lab_texture.h rename to include/lab_texture.h index 8d596a7..51b30d6 100644 --- a/src/objects/lab_texture.h +++ b/include/lab_texture.h @@ -1,7 +1,7 @@ #ifndef WGPU_LAB_TEXTURE_H #define WGPU_LAB_TEXTURE_H -#include +#include namespace lab { diff --git a/src/objects/lab_webgpu.h b/include/lab_webgpu.h similarity index 100% rename from src/objects/lab_webgpu.h rename to include/lab_webgpu.h diff --git a/src/objects/lab_window.h b/include/lab_window.h similarity index 93% rename from src/objects/lab_window.h rename to include/lab_window.h index bb20c45..0990150 100644 --- a/src/objects/lab_window.h +++ b/include/lab_window.h @@ -1,8 +1,8 @@ #ifndef WGPU_LAB_WINDOW_H #define WGPU_LAB_WINDOW_H -#include -#include +#include +#include #include diff --git a/src/detail/lab.cpp b/src/lab.cpp similarity index 98% rename from src/detail/lab.cpp rename to src/lab.cpp index 7aa246d..27be8ea 100644 --- a/src/detail/lab.cpp +++ b/src/lab.cpp @@ -1,6 +1,6 @@ #include -#include +#include "lab_state.h" #include #include diff --git a/src/detail/lab_pipeline.cpp b/src/lab_pipeline.cpp similarity index 99% rename from src/detail/lab_pipeline.cpp rename to src/lab_pipeline.cpp index 0c60b36..9677e3c 100644 --- a/src/detail/lab_pipeline.cpp +++ b/src/lab_pipeline.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include diff --git a/src/detail/lab_shader.cpp b/src/lab_shader.cpp similarity index 97% rename from src/detail/lab_shader.cpp rename to src/lab_shader.cpp index 5eb66ad..19f1c18 100644 --- a/src/detail/lab_shader.cpp +++ b/src/lab_shader.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include diff --git a/src/extra/lab_state.h b/src/lab_state.h similarity index 94% rename from src/extra/lab_state.h rename to src/lab_state.h index 1bdf112..7b1a347 100644 --- a/src/extra/lab_state.h +++ b/src/lab_state.h @@ -1,7 +1,7 @@ #ifndef WGPU_LAB_STATE_H #define WGPU_LAB_STATE_H -#include +#include #include #include diff --git a/src/detail/lab_surface.cpp b/src/lab_surface.cpp similarity index 94% rename from src/detail/lab_surface.cpp rename to src/lab_surface.cpp index fb9e749..2fc4260 100644 --- a/src/detail/lab_surface.cpp +++ b/src/lab_surface.cpp @@ -1,6 +1,6 @@ -#include +#include -#include +#include "platform/lab_platform.h" #include diff --git a/src/detail/lab_webgpu.cpp b/src/lab_webgpu.cpp similarity index 99% rename from src/detail/lab_webgpu.cpp rename to src/lab_webgpu.cpp index e8715d6..84c4460 100644 --- a/src/detail/lab_webgpu.cpp +++ b/src/lab_webgpu.cpp @@ -1,4 +1,4 @@ -#include +#include #include diff --git a/src/detail/lab_window.cpp b/src/lab_window.cpp similarity index 96% rename from src/detail/lab_window.cpp rename to src/lab_window.cpp index 1aefee8..4b23b16 100644 --- a/src/detail/lab_window.cpp +++ b/src/lab_window.cpp @@ -1,7 +1,7 @@ -#include +#include -#include -#include +#include +#include "lab_state.h" #include diff --git a/src/platform/lab_surface_glfw.cpp b/src/platform/lab_surface_glfw.cpp index 1d9ef7a..d506626 100644 --- a/src/platform/lab_surface_glfw.cpp +++ b/src/platform/lab_surface_glfw.cpp @@ -1,4 +1,4 @@ -#include +#include "platform/lab_platform.h" #include From c3bb1f53c26849d4195b0fec8495a73ef992cb7e Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:27:36 +0200 Subject: [PATCH 18/30] Redesign the API around immutable pipelines and composable passes The old Pipeline was shader, fixed state, the buffers to draw and the bind groups in one mutable object, and render_frame() was the only way to render: one pipeline, one pass, one frame. Anything more meant copying fifty lines of raw WebGPU, as the graph samples did. New shape - lab::Gpu (was Webgpu), lab::Window, lab::Surface, lab::Shader, lab::Buffer, lab::Texture, lab::Pipeline, lab::Frame, lab::RenderPass. Every object exposes the WebGPU object it wraps through handle(). - A Pipeline is immutable and built for one target format. Its description holds vertex layouts (lab::vertex, lab::instance) and fixed state. There is no finalize(). - What is drawn is a lab::Draw: vertex buffers, index buffer, bind groups, counts. Counts default to what the buffers hold. - Bind groups are plain wgpu::BindGroup values made by pipeline.bind_group(); their layouts come from the shader. - A RenderPass takes any number of draw calls, a Frame any number of passes, onto a Surface or into a Texture. render_frame() remains as the one-line form. - Buffer: write(), blocking read(), read_async(), fill in place at creation. The lab adds the usage flags these need. The jthread based mapping API and MappedVRAM are gone. - Shader compiles on construction. Files are looked up from the working directory and from the directory of the executable. Fixed by construction - Objects share reference-counted state instead of holding references to each other, so they are movable and can be destroyed in any order. A native window is kept until no Gpu can still own a swapchain for it. - The surface follows its window size when a frame is acquired and no longer takes over the window's resize callback. It handles outdated and lost surface textures. - The surface format is chosen per surface, preferring an 8-bit format: an HDR capable desktop lists RGBA16Float first. - Global state shrinks to one private runtime (GLFW, open windows, live Gpus, the clock). GLFW callbacks use the window user pointer. Errors and logging - Mistakes the lab can detect throw lab::Error where they are made and name the object: buffers that do not fit a pipeline's layout, a pipeline drawn into another target format, out-of-range writes. - Creating shaders, pipelines and bind groups turns WebGPU validation errors into exceptions on the spot. Errors reported later are thrown by the next tick(). - Objects without a label are named after the source location they are created at. - Log output goes through lab::log with levels, a sink and LAB_LOG. Also new - Window: polling for keys and mouse (key_pressed, mouse_delta, scroll) next to callbacks, framebuffer_size(), aspect(). - lab::delta_seconds(). - Texture::read(), Texture::save_png() and LAB_CAPTURE_DIR, which makes every surface save one frame as a PNG. Samples are rewritten as a numbered sequence, one concept each, with a shader named after its sample. test_uniform_buffers and test_tinygltf became tests or went away, as did the unused shaders and the Blender backup file. Tests: 31 test cases render into textures and compare pixels, check the error messages and exercise window, surface and gpu lifetimes in every destruction order. Tests that open a window carry the CTest label "display". Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 4 +- CMakeLists.txt | 23 +- include/lab | 73 +-- include/lab_buffer.h | 204 ++++---- include/lab_error.h | 26 ++ include/lab_frame.h | 115 +++++ include/lab_gpu.h | 78 ++++ include/{lab_enums.h => lab_input.h} | 30 +- include/lab_label.h | 33 ++ include/lab_log.h | 27 ++ include/lab_mapped_vram.h | 100 ---- include/lab_objects.h | 45 -- include/lab_pipeline.h | 436 +++++++++--------- include/lab_pipeline_defaults.h | 55 --- include/lab_public.h | 34 -- include/lab_shader.h | 46 +- include/lab_surface.h | 61 ++- include/lab_texture.h | 138 +++--- include/lab_webgpu.h | 32 -- include/lab_window.h | 90 +++- samples/01_window.cpp | 22 + samples/02_triangle.cpp | 31 ++ samples/03_vertex_buffer.cpp | 41 ++ samples/04_uniforms.cpp | 55 +++ samples/05_texture.cpp | 77 ++++ samples/06_instancing.cpp | 88 ++++ samples/07_graph.cpp | 108 +++++ samples/08_readback.cpp | 48 ++ samples/09_multi_window.cpp | 64 +++ samples/CMakeLists.txt | 22 +- samples/assets/house.blend1 | Bin 899172 -> 0 bytes samples/sample_buffer.cpp | 58 --- samples/sample_dynamic_instances.cpp | 125 ----- samples/sample_graph_visualization.cpp | 168 ------- samples/sample_graph_visualization2.cpp | 169 ------- samples/sample_instancing.cpp | 48 -- samples/sample_pipeline.cpp | 36 -- samples/sample_texture.cpp | 75 --- samples/sample_uniform_buffer.cpp | 53 --- samples/sample_vertex_buffer.cpp | 39 -- samples/sample_window.cpp | 14 - samples/sample_window_shader.cpp | 16 - samples/shaders/02_triangle.wgsl | 13 + ...raw_colored.wgsl => 03_vertex_buffer.wgsl} | 0 ...e_uniform_buffer.wgsl => 04_uniforms.wgsl} | 0 .../{sample_texture.wgsl => 05_texture.wgsl} | 0 samples/shaders/06_instancing.wgsl | 41 ++ ...h_edge_shader.wgsl => 07_graph_edges.wgsl} | 0 ...{sample_graph.wgsl => 07_graph_nodes.wgsl} | 7 +- samples/shaders/09_multi_window.wgsl | 12 + samples/shaders/sample_dynamic_instances.wgsl | 54 --- samples/shaders/sample_graph_2.wgsl | 37 -- samples/shaders/test1.wgsl | 17 - samples/shaders/test2.wgsl | 17 - samples/shaders/test_instancing.wgsl | 10 - samples/shaders/test_uniform.wgsl | 28 -- samples/shaders/vbuf.wgsl | 9 - samples/test_tinygltf.cpp | 31 -- samples/test_uniform_buffers.cpp | 61 --- samples/test_windows.cpp | 136 ------ src/lab.cpp | 72 --- src/lab_buffer.cpp | 158 +++++++ src/lab_detail.h | 169 +++++++ src/lab_frame.cpp | 202 ++++++++ src/lab_gpu.cpp | 175 +++++++ src/lab_image.cpp | 167 +++++++ src/lab_pipeline.cpp | 251 +++++----- src/lab_runtime.cpp | 180 ++++++++ src/lab_shader.cpp | 82 ++-- src/lab_state.h | 24 - src/lab_surface.cpp | 176 +++++-- src/lab_texture.cpp | 165 +++++++ src/lab_webgpu.cpp | 95 ---- src/lab_window.cpp | 238 +++++++--- src/platform/lab_paths.cpp | 41 ++ src/platform/lab_platform.h | 10 + tests/CMakeLists.txt | 34 +- tests/common.h | 44 ++ tests/offscreen.h | 108 ----- tests/test_buffer.cpp | 101 +++- tests/test_pipeline.cpp | 140 ++++-- tests/test_render.cpp | 318 +++++++++---- tests/test_vertex_format.cpp | 37 +- tests/test_window.cpp | 115 +++++ 84 files changed, 3922 insertions(+), 2660 deletions(-) create mode 100644 include/lab_error.h create mode 100644 include/lab_frame.h create mode 100644 include/lab_gpu.h rename include/{lab_enums.h => lab_input.h} (75%) create mode 100644 include/lab_label.h create mode 100644 include/lab_log.h delete mode 100644 include/lab_mapped_vram.h delete mode 100644 include/lab_objects.h delete mode 100644 include/lab_pipeline_defaults.h delete mode 100644 include/lab_public.h delete mode 100644 include/lab_webgpu.h create mode 100644 samples/01_window.cpp create mode 100644 samples/02_triangle.cpp create mode 100644 samples/03_vertex_buffer.cpp create mode 100644 samples/04_uniforms.cpp create mode 100644 samples/05_texture.cpp create mode 100644 samples/06_instancing.cpp create mode 100644 samples/07_graph.cpp create mode 100644 samples/08_readback.cpp create mode 100644 samples/09_multi_window.cpp delete mode 100644 samples/assets/house.blend1 delete mode 100644 samples/sample_buffer.cpp delete mode 100644 samples/sample_dynamic_instances.cpp delete mode 100644 samples/sample_graph_visualization.cpp delete mode 100644 samples/sample_graph_visualization2.cpp delete mode 100644 samples/sample_instancing.cpp delete mode 100644 samples/sample_pipeline.cpp delete mode 100644 samples/sample_texture.cpp delete mode 100644 samples/sample_uniform_buffer.cpp delete mode 100644 samples/sample_vertex_buffer.cpp delete mode 100644 samples/sample_window.cpp delete mode 100644 samples/sample_window_shader.cpp create mode 100644 samples/shaders/02_triangle.wgsl rename samples/shaders/{draw_colored.wgsl => 03_vertex_buffer.wgsl} (100%) rename samples/shaders/{sample_uniform_buffer.wgsl => 04_uniforms.wgsl} (100%) rename samples/shaders/{sample_texture.wgsl => 05_texture.wgsl} (100%) create mode 100644 samples/shaders/06_instancing.wgsl rename samples/shaders/{sample_graph_edge_shader.wgsl => 07_graph_edges.wgsl} (100%) rename samples/shaders/{sample_graph.wgsl => 07_graph_nodes.wgsl} (72%) create mode 100644 samples/shaders/09_multi_window.wgsl delete mode 100644 samples/shaders/sample_dynamic_instances.wgsl delete mode 100644 samples/shaders/sample_graph_2.wgsl delete mode 100644 samples/shaders/test1.wgsl delete mode 100644 samples/shaders/test2.wgsl delete mode 100644 samples/shaders/test_instancing.wgsl delete mode 100644 samples/shaders/test_uniform.wgsl delete mode 100644 samples/shaders/vbuf.wgsl delete mode 100644 samples/test_tinygltf.cpp delete mode 100644 samples/test_uniform_buffers.cpp delete mode 100644 samples/test_windows.cpp delete mode 100644 src/lab.cpp create mode 100644 src/lab_buffer.cpp create mode 100644 src/lab_detail.h create mode 100644 src/lab_frame.cpp create mode 100644 src/lab_gpu.cpp create mode 100644 src/lab_image.cpp create mode 100644 src/lab_runtime.cpp delete mode 100644 src/lab_state.h create mode 100644 src/lab_texture.cpp delete mode 100644 src/lab_webgpu.cpp create mode 100644 src/platform/lab_paths.cpp create mode 100644 tests/common.h delete mode 100644 tests/offscreen.h create mode 100644 tests/test_window.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d614efe..ea1bd18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,6 @@ jobs: - name: Build run: cmake --build --preset release - # the samples open windows and are skipped, everything else renders offscreen + # tests that open a window are skipped, everything else renders offscreen - name: Test - run: ctest --preset release -LE samples + run: ctest --preset release -LE display diff --git a/CMakeLists.txt b/CMakeLists.txt index 3fed2ae..07368ca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,29 +58,34 @@ endfunction() # wgpu-lab library ------------------------------------------------------------- add_library(wgpu-lab - src/lab.cpp + src/lab_buffer.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_webgpu.cpp + src/lab_texture.cpp src/lab_window.cpp - src/lab_state.h + src/lab_detail.h + src/platform/lab_paths.cpp src/platform/lab_surface_glfw.cpp src/platform/lab_platform.h include/lab include/lab_buffer.h - include/lab_enums.h - include/lab_mapped_vram.h - include/lab_objects.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_pipeline_defaults.h - include/lab_public.h include/lab_shader.h include/lab_surface.h include/lab_texture.h - include/lab_webgpu.h include/lab_window.h) add_library(lab::lab ALIAS wgpu-lab) diff --git a/include/lab b/include/lab index 4be9b24..c0eff25 100644 --- a/include/lab +++ b/include/lab @@ -1,53 +1,68 @@ #ifndef WGPU_LAB_MAIN_H #define WGPU_LAB_MAIN_H -#include +// 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. -namespace lab { +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include -// ------------------------------------------------------------------------------------------------- -// Functions with side-effects, operating on the global application state -------------------------- +namespace lab { -// Processes GLFW Events, handles open Windows and closes them -// - generally should be called once per main-loop iteration +// 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 samples unattended) +// LAB_EXIT_AFTER_FRAMES=N is set (to run a program unattended) // ```cpp -// while(lab::tick()) { -// pipeline.render_frame(surface); // render artworks +// while (lab::tick()) { +// pipeline.render_frame(surface, 3); // } // ``` 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(); +// Seconds between the two most recent calls of `tick()` +float delta_seconds(); -// ------------------------------------------------------------------------------------------------- -// Utils independent from all other lab-objects ---------------------------------------------------- +// 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(); // 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 // using namespace std::chrono_literals; // lab::sleep(100ms); // ``` template -inline void sleep(const std::chrono::duration& 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); +inline void sleep(const std::chrono::duration& duration) { + std::this_thread::sleep_for(duration); } } // namespace lab diff --git a/include/lab_buffer.h b/include/lab_buffer.h index 43c3ea7..8cde6b5 100644 --- a/include/lab_buffer.h +++ b/include/lab_buffer.h @@ -1,123 +1,141 @@ #ifndef WGPU_LAB_BUFFER_H #define WGPU_LAB_BUFFER_H -#include +#include +#include +#include -#include -#include - -#include -#include +#include +#include #include -#include -#include -#include +#include +#include +#include #include #include namespace lab { -// T has to be trivially copyable, since its bytes are copied to and from GPU memory +// Types whose bytes can be copied to and from GPU memory as they are template - requires std::is_trivially_copyable_v -struct Buffer { - Buffer(const char* label, Webgpu& instance) : label{label}, webgpu{instance} {} +concept GpuData = std::is_trivially_copyable_v && std::is_standard_layout_v; - Buffer(const char* label, const std::vector& data, Webgpu& instance) : Buffer{label, instance} { - to_device(data, wgpu::BufferUsage::Vertex); - } +namespace detail { - Buffer(const char* label, const std::vector& data, wgpu::BufferUsage usage, Webgpu& instance) - : Buffer{label, instance} { - to_device(data, usage); - } +// The part of Buffer that does not depend on T +struct BufferCore { + using ReadCallback = std::function; - 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 = std::string_view(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(); - } + BufferCore(Gpu& gpu, uint64_t byte_size, wgpu::BufferUsage usage, Label label, const void* data, + const std::function& fill); - 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 = std::string_view(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}; - } + 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; +}; - 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, wgpu::StringView message, Buffer* self) { - if (status == wgpu::MapAsyncStatus::Success) { - std::cout << "Info: Buffer: " << self->label << " successfully mapped memory!" << std::endl; - } else { - std::cerr << "Error: Buffer: " << std::string_view(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}; +} // 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()); } - 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)); + // 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; } - // 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)); + // 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), byte_count / sizeof(T)}); + }); } - ~Buffer() { - if (wgpu_buffer) { - wgpu_buffer.Destroy(); - wgpu_buffer = nullptr; + // 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)); } } - wgpu::Buffer wgpu_buffer = nullptr; - size_t current_capacity = 0; - std::string label; - Webgpu& webgpu; + 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_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..c9f3a86 --- /dev/null +++ b/include/lab_frame.h @@ -0,0 +1,115 @@ +#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}; + + 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, 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/include/lab_enums.h b/include/lab_input.h similarity index 75% rename from include/lab_enums.h rename to include/lab_input.h index 4eb2409..a6b7335 100644 --- a/include/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_mapped_vram.h b/include/lab_mapped_vram.h deleted file mode 100644 index d3af635..0000000 --- a/include/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) - : wgpu_buffer{wgpu_buffer}, data_view{view}, view_size{view_size} {} - MappedVRAM(MappedVRAM&& other) - : wgpu_buffer{std::move(other.wgpu_buffer)}, data_view{std::move(other.data_view)}, - view_size{std::move(other.view_size)} { - 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/include/lab_objects.h b/include/lab_objects.h deleted file mode 100644 index 727d372..0000000 --- a/include/lab_objects.h +++ /dev/null @@ -1,45 +0,0 @@ -#ifndef WGPU_LAB_OBJECTS_H -#define WGPU_LAB_OBJECTS_H - -#include - -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 - requires std::is_trivially_copyable_v -struct Buffer; - -template -using BufferHandle = Buffer*; - -} // namespace lab - -// external structs ------------------------------ - -struct GLFWwindow; -using GLFWwindowHandle = GLFWwindow*; - -#endif // WGPU_LAB_OBJECTS_H diff --git a/include/lab_pipeline.h b/include/lab_pipeline.h index defb25b..5191f05 100644 --- a/include/lab_pipeline.h +++ b/include/lab_pipeline.h @@ -1,20 +1,23 @@ #ifndef WGPU_LAB_PIPELINE_H #define WGPU_LAB_PIPELINE_H -#include -#include - #include +#include +#include #include #include #include -#include -#include +#include +#include +#include +#include +#include +#include namespace lab { -// returns size in bytes of given wgpu::VertexFormat +// returns the size in bytes of a wgpu::VertexFormat constexpr uint64_t vertex_format_size(wgpu::VertexFormat format) { using enum wgpu::VertexFormat; switch (format) { @@ -70,255 +73,238 @@ constexpr uint64_t vertex_format_size(wgpu::VertexFormat format) { return 0; } -// 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) : webgpu{wg}, shader{sh} { - 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(); - } +// ------------------------------------------------------------------------------------------------- +// What a pipeline renders into --------------------------------------------------------------------- - // If the pipeline has an index buffer, `vertexCount` and `firstVertex` - // count indices instead of vertices - struct DrawCallParams { - uint32_t vertexCount = 0, instanceCount = 1; - uint32_t firstVertex = 0, firstInstance = 0; - }; - 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}); - } +// 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 - // 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, {}}); - } + TargetFormat(const Surface& surface) : color{surface.format()} {} + TargetFormat(const Texture& texture) : color{texture.format()} {} + TargetFormat(wgpu::TextureFormat color, wgpu::TextureFormat depth = wgpu::TextureFormat::Undefined) + : color{color}, depth{depth} {} - 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); - } + bool operator==(const TargetFormat&) const = default; +}; - // offset and buffer_index are worked out automatically by default: - // the attribute is appended to the vertex buffer that was added last - static constexpr uint64_t automatic = ~uint64_t{0}; - - void add_vertex_attrib(wgpu::VertexFormat format, uint32_t shader_location, uint64_t offset = automatic, - uint64_t buffer_index = automatic) { - uint64_t bi = buffer_index == automatic ? vb_configs.size() - 1 : buffer_index; - offset = offset == automatic ? vertex_attributes_stride(vb_configs.at(bi).vertexAttributes) : offset; - vb_configs.at(bi).vertexAttributes.push_back({ - .format = format, - .offset = offset, - .shaderLocation = shader_location, - }); - } +// ------------------------------------------------------------------------------------------------- +// How vertex buffers are laid out ----------------------------------------------------------------- - struct IndexBufferConfig { - wgpu::Buffer buffer; - wgpu::IndexFormat format; - uint64_t offset; - }; +// 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 - std::vector ib_configs; + wgpu::VertexFormat format; + uint32_t location = next_location; // `@location(n)` in the shader + uint64_t offset = next_offset; // byte offset within the vertex - void add_index_buffer(wgpu::Buffer buffer, wgpu::IndexFormat format, uint64_t offset = 0) { - ib_configs.push_back({buffer, format, offset}); - } + VertexAttribute(wgpu::VertexFormat format, uint32_t location = next_location, uint64_t offset = next_offset) + : format{format}, location{location}, offset{offset} {} +}; - template - void add_index_buffer(const Buffer& buffer, wgpu::IndexFormat format, uint64_t offset = 0) { - add_index_buffer(buffer.wgpu_buffer, format, 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; +}; - template - void add_uniform_buffer(const Buffer& uniform_buffer, uint32_t binding_index, wgpu::ShaderStage visibility) { - add_bind_group_layout_buffer_entry(binding_index, visibility, wgpu::BufferBindingType::Uniform, sizeof(T)); - add_bind_group_buffer_entry(uniform_buffer.wgpu_buffer, binding_index, sizeof(T)); - } +namespace detail { +VertexLayout make_vertex_layout(std::initializer_list attributes, uint64_t stride, + wgpu::VertexStepMode step_mode); +} - 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); - } +// 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); +} - std::vector bindGroupLayoutEntries{}; - std::vector bindGroupEntries{}; +// 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); +} - 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; +// ------------------------------------------------------------------------------------------------- +// What to draw -------------------------------------------------------------------------------------- - bindGroupLayoutEntry.buffer.type = buffer_type; - bindGroupLayoutEntry.buffer.minBindingSize = min_binding_size; +// 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; - bindGroupLayoutEntries.push_back(bindGroupLayoutEntry); - } + 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} {} +}; - 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; +// 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()} {} +}; - bindGroupEntry.size = size; - bindGroupEntry.offset = offset; +// 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 +}; - bindGroupEntries.push_back(bindGroupEntry); - } +// ------------------------------------------------------------------------------------------------- +// 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)} {} +}; - 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; +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}, +}; - bindGroupLayoutEntry.texture.multisampled = multisampled; - bindGroupLayoutEntry.texture.sampleType = sampleType; - bindGroupLayoutEntry.texture.viewDimension = viewDimension; +// 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 = {}; - bindGroupLayoutEntries.push_back(bindGroupLayoutEntry); - } + // the surface, texture or format this pipeline renders into + TargetFormat target; - 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; + wgpu::PrimitiveTopology topology = wgpu::PrimitiveTopology::TriangleList; + wgpu::CullMode cull = wgpu::CullMode::None; - bindGroupEntries.push_back(bindGroupEntry); - } + // how the output is mixed with what is already in the target, nullopt replaces it + std::optional blend = alpha_blend; - std::vector bindGroups{}; - std::vector bindGroupLayouts{}; + // only needed if the shader has more than one entry point per stage + std::string vertex_entry = {}; + std::string fragment_entry = {}; - void finalize_bind_group(const char* group_label = "lab default bind group") { - std::string groupLabel{label}; - groupLabel.append(" - "); - groupLabel.append(group_label); + // Bind group layouts. Leave empty to derive them from the shader (`layout: auto`), + // which is what `Pipeline::bind_group()` builds on. + std::vector layouts = {}; - std::string layoutLabel{groupLabel}; - layoutLabel.append(" Layout"); + // last word on the raw descriptor, for everything this struct does not cover + std::function customize = {}; - wgpu::BindGroupLayoutDescriptor bindGroupLayoutDesc; - bindGroupLayoutDesc.entryCount = bindGroupLayoutEntries.size(); - bindGroupLayoutDesc.entries = bindGroupLayoutEntries.data(); - bindGroupLayoutDesc.label = layoutLabel.c_str(); + // defaults to the label of the shader + std::string label = {}; +}; - bindGroupLayouts.push_back(webgpu.device.CreateBindGroupLayout(&bindGroupLayoutDesc)); +// 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); - wgpu::BindGroupDescriptor bindGroupDesc; - bindGroupDesc.layout = bindGroupLayouts.back(); - bindGroupDesc.entryCount = bindGroupEntries.size(); - bindGroupDesc.entries = bindGroupEntries.data(); - bindGroupDesc.label = groupLabel.c_str(); + Pipeline(Pipeline&&) = default; + Pipeline& operator=(Pipeline&&) = default; - bindGroups.push_back(webgpu.device.CreateBindGroup(&bindGroupDesc)); + // 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; - bindGroupLayoutEntries.clear(); - bindGroupEntries.clear(); - } + // 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 diff --git a/include/lab_pipeline_defaults.h b/include/lab_pipeline_defaults.h deleted file mode 100644 index ff7344c..0000000 --- a/include/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/include/lab_public.h b/include/lab_public.h deleted file mode 100644 index c40f4b2..0000000 --- a/include/lab_public.h +++ /dev/null @@ -1,34 +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 - -// Dawn wgpu C++ header -#include - -// Lab objects and enums ------------------------- - -#include -#include -#include -#include -#include -#include -#include - -#include diff --git a/include/lab_shader.h b/include/lab_shader.h index ad9cf48..5ea126d 100644 --- a/include/lab_shader.h +++ b/include/lab_shader.h @@ -1,25 +1,43 @@ #ifndef WGPU_LAB_SHADER_H #define WGPU_LAB_SHADER_H -#include +#include +#include #include +#include namespace lab { -struct Shader { - std::string label, source; - - Shader(const std::string& label); - - // Reads WGSL source from a file, throws std::runtime_error if it cannot be opened - 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::ShaderSourceWGSL) const; +// 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 diff --git a/include/lab_surface.h b/include/lab_surface.h index 01c70e8..cffcbfc 100644 --- a/include/lab_surface.h +++ b/include/lab_surface.h @@ -1,26 +1,61 @@ #ifndef WGPU_LAB_SURFACE_H #define WGPU_LAB_SURFACE_H -#include +#include #include namespace lab { -struct Surface { - Window& window; - Webgpu& webgpu; +namespace detail { +struct SurfaceState; +} - wgpu::Surface wgpu_surface; +struct SurfaceOptions { + // `Fifo` waits for the display (vsync), `Immediate` and `Mailbox` do not + wgpu::PresentMode present_mode = wgpu::PresentMode::Fifo; - Surface(Window& wnd, Webgpu& wgpu); - - Surface(const Surface&) = delete; - Surface& operator=(const Surface&) = delete; - - void reconfigure(int width, int height); - void reconfigure(); + // `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; +}; - ~Surface(); +// 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; + 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(); + + // 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 diff --git a/include/lab_texture.h b/include/lab_texture.h index 51b30d6..3c6b3d1 100644 --- a/include/lab_texture.h +++ b/include/lab_texture.h @@ -1,92 +1,80 @@ #ifndef WGPU_LAB_TEXTURE_H #define WGPU_LAB_TEXTURE_H -#include +#include +#include +#include +#include -namespace lab { +#include +#include +#include -// 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; - } +namespace lab { - Texture(const Texture&) = delete; - Texture& operator=(const Texture&) = delete; +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; +}; - [[nodiscard]] wgpu::Texture transfer() { return webgpu.device.CreateTexture(&descriptor); } +// 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}); + } - wgpu::TexelCopyTextureInfo target = { - .mipLevel = 0, - .origin = {0, 0, 0}, - .aspect = wgpu::TextureAspect::All, - }; + // 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; + } - wgpu::Texture wgpu_texture = nullptr; + // 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; - template - void to_device(const std::vector& pixels) { - wgpu_texture = transfer(); - target.texture = wgpu_texture; + // a view of the whole texture, for bind groups and render passes + wgpu::TextureView view() const; - wgpu::TexelCopyBufferLayout layout = { - .offset = 0, - .bytesPerRow = static_cast(sizeof(T) * descriptor.size.width), - .rowsPerImage = descriptor.size.height, - }; + 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); } - webgpu.queue.WriteTexture(&target, pixels.data(), pixels.size() * sizeof(T), &layout, &descriptor.size); - } + const wgpu::Texture& handle() const { return texture; } + const std::string& label() const { return name; } - 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); - } +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; - ~Texture() { - if (wgpu_texture) { - wgpu_texture.Destroy(); - wgpu_texture = nullptr; - } - } + std::shared_ptr gpu; + wgpu::Texture texture; + wgpu::TextureFormat pixel_format; + wgpu::Extent3D extent; + std::string name; }; } // namespace lab diff --git a/include/lab_webgpu.h b/include/lab_webgpu.h deleted file mode 100644 index 8cab129..0000000 --- a/include/lab_webgpu.h +++ /dev/null @@ -1,32 +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(); - - // Texture format shared by all surfaces and pipelines of this instance - // - BGRA8Unorm is what desktop window systems present natively - wgpu::TextureFormat surface_format = wgpu::TextureFormat::BGRA8Unorm; - - 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/include/lab_window.h b/include/lab_window.h index 0990150..c2cc4e0 100644 --- a/include/lab_window.h +++ b/include/lab_window.h @@ -1,40 +1,92 @@ #ifndef WGPU_LAB_WINDOW_H #define WGPU_LAB_WINDOW_H -#include -#include +#include #include +#include +#include + +struct GLFWwindow; namespace lab { -struct Window { - Window(const char* title, int width, int height); +namespace detail { +struct WindowState; +} - Window(const Window&) = delete; - Window& operator=(const Window&) = delete; +struct Size { + int width = 0, height = 0; + bool operator==(const Size&) const = default; +}; - void set_key_callback(std::function); - void clear_key_callback(); +struct Vec2 { + float x = 0.0f, y = 0.0f; +}; - // width and height are the size of the framebuffer in pixels - void set_resize_callback(std::function); - void clear_resize_callback(); +struct WindowOptions { + bool resizable = true; + bool visible = true; +}; - void set_title(const char* title); +// 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 = {}); - int width() const; - int height() const; - float ratio() const; + 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(); - ~Window(); - std::function user_resize_callback; - std::function user_key_callback; + 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; } - GLFWwindowHandle glfw_window_handle; +private: + std::shared_ptr shared_state; }; } // namespace lab 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..c658ef6 --- /dev/null +++ b/samples/05_texture.cpp @@ -0,0 +1,77 @@ +// 05: a texture is filled with pixels and read 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 both resources the shader declares: + // `@binding(0) var uniforms` and `@binding(1) var gradientTexture` + lab::Draw triangle{ + .vertex_buffers = {vertices}, + .bind_groups = {pipeline.bind_group(0, {{0, uniform_buffer}, {1, texture}})}, + }; + + 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/CMakeLists.txt b/samples/CMakeLists.txt index 7ad5d3b..04ad0f2 100644 --- a/samples/CMakeLists.txt +++ b/samples/CMakeLists.txt @@ -1,20 +1,15 @@ -# 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. +# glm is the only library the samples use besides the lab ---------------------- +# It is header-only, so it is only downloaded: SOURCE_SUBDIR points at a directory +# without a CMakeLists.txt, which keeps glm's 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) +FetchContent_MakeAvailable(glm) add_library(lab-sample-libs INTERFACE) -target_include_directories(lab-sample-libs SYSTEM INTERFACE ${glm_SOURCE_DIR} ${tinygltf_SOURCE_DIR}) +target_include_directories(lab-sample-libs SYSTEM INTERFACE ${glm_SOURCE_DIR}) # one executable per .cpp file in this directory -------------------------------- # (add your own .cpp here and re-run the CMake configure step) @@ -28,19 +23,20 @@ foreach(SAMPLE_FILE IN LISTS SAMPLE_FILES) lab_target_defaults(${SAMPLE_NAME}) # Every sample doubles as a smoke test: it has to run 60 frames without reporting an error. - # These tests open windows, so they carry a label: `ctest -LE samples` skips them. + # 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 samples + LABELS display ENVIRONMENT LAB_EXIT_AFTER_FRAMES=60 - FAIL_REGULAR_EXPRESSION "Error:" + FAIL_REGULAR_EXPRESSION "lab \\[(warn|error)\\]" TIMEOUT 30 RUN_SERIAL ON) endif() endforeach() # 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) diff --git a/samples/assets/house.blend1 b/samples/assets/house.blend1 deleted file mode 100644 index 77d826ea9144b1df31632b6eb23019e29f388aa1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 899172 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 - -#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); - { - atomic 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 ----------------------------------------------- - { - atomic 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 9546ce7..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}, .time = 0.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 fdd04b5..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}, .time = 0.0f}; - 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, &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 719da74..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}, .time = 0.0f}; - 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, &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 847f6ea..0000000 --- a/samples/sample_uniform_buffer.cpp +++ /dev/null @@ -1,53 +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); - - while (lab::tick()) { - // send uniform data to gpu - uniform_buffer.write(uniforms); - - // render the scene to surface - pipeline.render_frame(surface, {3, 1}); - - // restart the animation roughly every 7 seconds - if (lab::elapsed_seconds() > 7.0f) { - std::cout << "time reset at: " << lab::elapsed_seconds() << std::endl; - lab::restart_timer(); - } - - // update uniform values for next draw call - uniforms.ratio[0] = window.ratio(); - uniforms.time = lab::elapsed_seconds(); - } -} 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 100% rename from samples/shaders/sample_texture.wgsl rename to samples/shaders/05_texture.wgsl 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/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_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 4ec1e1e..0000000 --- a/samples/test_windows.cpp +++ /dev/null @@ -1,136 +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; - bool close_second_window = false; - - 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) { - // not destroyed right here: this callback is owned by the window it would destroy - close_second_window = true; - } - }); - } - } - }); - - while (tick()) { - pipeline.render_frame(surface, {3, 1}); - - if (close_second_window) { - sometimes_surf.reset(); - sometimes_open.reset(); - close_second_window = false; - } - - if (sometimes_open && sometimes_open->is_open()) { - pipeline.render_frame(*sometimes_surf, {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.close(); - } - }); - - 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/src/lab.cpp b/src/lab.cpp deleted file mode 100644 index 27be8ea..0000000 --- a/src/lab.cpp +++ /dev/null @@ -1,72 +0,0 @@ -#include - -#include "lab_state.h" -#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; - - // GLFW stays initialized until the program exits, see Window::close() - std::atexit(glfwTerminate); - - // initialize only once - state.lab_init = true; - } - return state.lab_init; -} - -// Number of ticks after which all windows are closed, read from the environment -// variable LAB_EXIT_AFTER_FRAMES. Zero means run until the user closes the windows. -// It lets every sample run unattended: `LAB_EXIT_AFTER_FRAMES=120 ./sample_texture` -static long exit_after_frames() { - static const long frames = [] { - const char* value = std::getenv("LAB_EXIT_AFTER_FRAMES"); - return value ? std::atol(value) : 0L; - }(); - return frames; -} - -bool tick() { - glfwPollEvents(); - - std::vector to_close; - to_close.reserve(state.window_map.size()); - - static long tick_count = 0; - const bool exit_requested = exit_after_frames() > 0 && ++tick_count > exit_after_frames(); - if (exit_requested) { - tick_count = 0; - } - - for (auto [hnd, wnd] : state.window_map) { - if (exit_requested || 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/lab_buffer.cpp b/src/lab_buffer.cpp new file mode 100644 index 0000000..9705230 --- /dev/null +++ b/src/lab_buffer.cpp @@ -0,0 +1,158 @@ +#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 + if (byte_offset % 4 == 0 && byte_offset + byte_count == byte_size) { + std::vector padded(align_up_4(byte_count), 0); + std::memcpy(padded.data(), data, byte_count); + gpu->queue.WriteBuffer(handle, byte_offset, padded.data(), padded.size()); + 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_detail.h b/src/lab_detail.h new file mode 100644 index 0000000..75b0a85 --- /dev/null +++ b/src/lab_detail.h @@ -0,0 +1,169 @@ +#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); + +// 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` + 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..33911a0 --- /dev/null +++ b/src/lab_frame.cpp @@ -0,0 +1,202 @@ +#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, 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, + }; + encoder = frame->encoder.BeginRenderPass(&desc); + frame->pass_open = true; +} + +RenderPass::RenderPass(Surface& surface, PassOptions options) { + own_frame.emplace(surface); + begin(*own_frame, surface.current_view(), surface, options); + if (encoder) { + own_frame->surfaces.push_back(surface.state()); + } +} + +RenderPass::RenderPass(Frame& target_frame, Surface& surface, PassOptions options) { + begin(target_frame, surface.current_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) { + begin(target_frame, texture.view(), 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..e6e4089 --- /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 index 9677e3c..08bf1ff 100644 --- a/src/lab_pipeline.cpp +++ b/src/lab_pipeline.cpp @@ -1,155 +1,158 @@ +#include "lab_detail.h" + +#include #include -#include -#include +#include + +#include #include namespace lab { -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::SuccessOptimal && - surfaceTexture.status != wgpu::SurfaceGetCurrentTextureStatus::SuccessSuboptimal) { - std::cerr << "Error: Pipeline: Could not get current render texture" << std::endl; - return nullptr; +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)); + } } - 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); + return layout; } -void Pipeline::finalize_config(wgpu::ShaderModule shaderModule) { - if (label.empty()) { - label = std::format("Default Pipeline({} on {})", shader.label, webgpu.label); +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); } - for (size_t i = 0; i < vb_configs.size(); ++i) { - vb_layouts.push_back({ - .stepMode = vb_configs[i].mode, - .arrayStride = vertex_attributes_stride(vb_configs[i].vertexAttributes), - .attributeCount = vb_configs[i].vertexAttributes.size(), - .attributes = vb_configs[i].vertexAttributes.data(), - }); + 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)); } - config.vertexState.bufferCount = vb_layouts.size(); - config.vertexState.buffers = vb_layouts.data(); - - config.colorTarget.format = webgpu.surface_format; - 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; + return group; } -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); +} // 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::RenderPipelineDescriptor pipelineDesc = { - .label = std::string_view(label), - .layout = pipelineLayout, - .vertex = config.vertexState, - .primitive = config.primitiveState, - .multisample = config.multisampleState, - .fragment = &config.fragmentState, - }; - - return webgpu.device.CreateRenderPipeline(&pipelineDesc); -} + wgpu::ColorTargetState color_target; + color_target.format = desc.target.color; + color_target.blend = desc.blend ? &*desc.blend : nullptr; -void Pipeline::reset() { - if (wgpu_pipeline) { - wgpu_pipeline = 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); } -} - -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); - if (!targetView) { - return false; // nothing to render onto this time, e.g. the window is minimized + wgpu::DepthStencilState depth_stencil; + depth_stencil.format = desc.target.depth; + depth_stencil.depthWriteEnabled = wgpu::OptionalBool::True; + depth_stencil.depthCompare = wgpu::CompareFunction::Less; + + 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); } - - 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()); + 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; } - - 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); + 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); } - - 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()); + if (desc.customize) { + desc.customize(pipeline_desc); } - // 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, 0, - draw_params.firstInstance); + if (auto error = detail::capture_error(*gpu, [&] { pipeline = gpu->device.CreateRenderPipeline(&pipeline_desc); })) { + detail::fail(name, *error); } +} - renderPass.End(); +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::CommandBufferDescriptor cmdBufferDescriptor = {.label = "lab default command buffer"}; - wgpu::CommandBuffer commands = encoder.Finish(&cmdBufferDescriptor); + 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); +} - self->webgpu.queue.Submit(1, &commands); - surface.Present(); +bool Pipeline::render_frame(Surface& surface, const Draw& draw) const { + RenderPass pass(surface); + pass.draw(*this, draw); + return static_cast(pass); +} - return true; -}; +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..e5f77e8 --- /dev/null +++ b/src/lab_runtime.cpp @@ -0,0 +1,180 @@ +#include "lab_detail.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); }); + if (!glfwInit()) { + fail("lab", "GLFW could not be initialized, is there a display?"); + } + glfw_ready = true; + log(LogLevel::debug, "GLFW {} initialized", glfwGetVersionString()); +} + +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 (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); + } + } + 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; + + 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 index 19f1c18..541ccbb 100644 --- a/src/lab_shader.cpp +++ b/src/lab_shader.cpp @@ -1,47 +1,67 @@ +#include "lab_detail.h" + +#include "platform/lab_platform.h" + #include -#include -#include #include -#include #include -#include -#include namespace lab { -Shader::Shader(const std::string& lbl) : label{lbl} {} +namespace { -Shader::Shader(const std::string& lbl, const std::string& path) : label{lbl} { - std::ifstream file(path); - if (!file.is_open()) { - // relative paths depend on where the program is started from, so say where that is - throw std::runtime_error(std::format("lab::Shader \"{}\": could not open \"{}\" (working directory: {})", lbl, path, - std::filesystem::current_path().string())); +// A relative path is tried from the working directory first and then from the +// directory of the executable, so a program finds its shaders wherever it is started from. +std::filesystem::path locate(const std::filesystem::path& file, const std::string& label) { + std::error_code ignored; + if (std::filesystem::is_regular_file(file, ignored)) { + return file; } - std::stringstream buffer; - buffer << file.rdbuf(); - source = buffer.str(); + 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()); + } + detail::fail(label, std::format("file not found, looked for {}", tried)); } -wgpu::ShaderModule Shader::transfer(wgpu::Device device, wgpu::SType struct_type) const { - switch (struct_type) { - case wgpu::SType::ShaderSourceSPIRV: - std::cout << "Error: Shader: SPIR-V Shader Module not yet implemented. Please use WGSL instead." << std::endl; - return nullptr; - case wgpu::SType::ShaderSourceWGSL: { - wgpu::ShaderSourceWGSL wgslDesc; - wgslDesc.code = std::string_view(source); - wgpu::ShaderModuleDescriptor shaderDesc; - shaderDesc.nextInChain = &wgslDesc; - shaderDesc.label = std::string_view(label); - return device.CreateShaderModule(&shaderDesc); +} // namespace + +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 = locate(wgsl_file, name); + std::ifstream file(path); + if (!file) { + detail::fail(name, std::format("could not read \"{}\"", path.string())); } - default: - break; + 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)); } - std::cout << "Error: Shader: struct_type not supported." << std::endl; - return nullptr; } } // namespace lab diff --git a/src/lab_state.h b/src/lab_state.h deleted file mode 100644 index 7b1a347..0000000 --- a/src/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_surface.cpp b/src/lab_surface.cpp index 2fc4260..b6f00cd 100644 --- a/src/lab_surface.cpp +++ b/src/lab_surface.cpp @@ -1,48 +1,166 @@ -#include +#include "lab_detail.h" #include "platform/lab_platform.h" #include +#include +#include +#include + namespace lab { -Surface::Surface(Window& wnd, Webgpu& wgpu) - : window{wnd}, webgpu{wgpu}, wgpu_surface{platform::create_surface(wgpu.instance, wnd.glfw_window_handle)} { - reconfigure(); - window.set_resize_callback([&](int width, int height) { - if (width > 0 && height > 0) { - this->reconfigure(width, height); - } - }); +namespace detail { + +SurfaceState::~SurfaceState() { + view = nullptr; + texture = nullptr; + if (handle && configured_size != Size{}) { + handle.Unconfigure(); + } } -void Surface::reconfigure(int w, int h) { - wgpu::SurfaceConfiguration surfaceConfig = { - .device = webgpu.device, - .format = webgpu.surface_format, - .usage = wgpu::TextureUsage::RenderAttachment, - .width = static_cast(w), - .height = static_cast(h), - .alphaMode = wgpu::CompositeAlphaMode::Auto, - .presentMode = wgpu::PresentMode::Fifo, +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, }; - wgpu_surface.Configure(&surfaceConfig); + handle.Configure(&config); + configured_size = size; } -void Surface::reconfigure() { - // the surface is measured in pixels, which differ from window coordinates on scaled displays - int width, height; - glfwGetFramebufferSize(window.glfw_window_handle, &width, &height); - if (width > 0 && height > 0) { - reconfigure(width, height); +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; } -Surface::~Surface() { - if (wgpu_surface) { - wgpu_surface.Unconfigure(); - wgpu_surface = nullptr; +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; + handle.Present(); + 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.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; } + +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(); } +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..eabac6e --- /dev/null +++ b/src/lab_texture.cpp @@ -0,0 +1,165 @@ +#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(); } + +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_webgpu.cpp b/src/lab_webgpu.cpp deleted file mode 100644 index 84c4460..0000000 --- a/src/lab_webgpu.cpp +++ /dev/null @@ -1,95 +0,0 @@ -#include - -#include - -#include -#include - -namespace lab { - -Webgpu::Webgpu(const std::string& lbl, wgpu::PowerPreference power_pref) : label{lbl} { - // needed for the blocking instance.WaitAny() calls below - static const wgpu::InstanceFeatureName required_features[] = {wgpu::InstanceFeatureName::TimedWaitAny}; - wgpu::InstanceDescriptor instanceDesc{ - .requiredFeatureCount = std::size(required_features), - .requiredFeatures = required_features, - }; - instance = wgpu::CreateInstance(&instanceDesc); - if (!instance) { - std::cerr << "Error: WGPU: Could not create Instance!" << std::endl; - return; - } - - // a software adapter is only asked for if there is no GPU (CI machines, VMs) - wgpu::Future future; - for (bool fallback : {false, true}) { - wgpu::RequestAdapterOptions adapterOpts = { - .powerPreference = power_pref, - .forceFallbackAdapter = fallback, - }; - future = instance.RequestAdapter( - &adapterOpts, wgpu::CallbackMode::WaitAnyOnly, - [](wgpu::RequestAdapterStatus, wgpu::Adapter adapter, wgpu::StringView, wgpu::Adapter* userdata) { - *userdata = std::move(adapter); - }, - &adapter); - instance.WaitAny(future, UINT64_MAX); - if (adapter) { - break; - } - } - if (!adapter) { - std::cerr << "Error: WGPU: Failed to get adapter: no GPU and no software adapter found" << std::endl; - return; - } - - wgpu::AdapterInfo adapterInfo; - adapter.GetInfo(&adapterInfo); - std::cout << "Info: WGPU: Using adapter " << std::string_view(adapterInfo.device) << " (" << adapterInfo.backendType - << ")" << std::endl; - - wgpu::DeviceDescriptor deviceDesc; - deviceDesc.label = "lab default device"; - deviceDesc.defaultQueue.label = "lab default queue"; - deviceDesc.SetDeviceLostCallback( - wgpu::CallbackMode::AllowSpontaneous, - [](const wgpu::Device&, wgpu::DeviceLostReason reason, wgpu::StringView message) { - // a device that is destroyed along with its Webgpu object is not worth reporting - if (reason == wgpu::DeviceLostReason::Destroyed || reason == wgpu::DeviceLostReason::CallbackCancelled) { - return; - } - std::cerr << "Error: WGPU: Device lost: " << reason << " (" << std::string_view(message) << ")" << std::endl; - }); - deviceDesc.SetUncapturedErrorCallback([](const wgpu::Device&, wgpu::ErrorType type, wgpu::StringView message) { - std::cerr << "Error: WGPU: " << type << ": " << std::string_view(message) << std::endl; - }); - - future = adapter.RequestDevice( - &deviceDesc, wgpu::CallbackMode::WaitAnyOnly, - [](wgpu::RequestDeviceStatus status, wgpu::Device device, wgpu::StringView 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: " << std::string_view(message) << std::endl; - } - *userdata = std::move(device); - }, - &device); - instance.WaitAny(future, UINT64_MAX); - if (!device) { - return; - } - - queue = device.GetQueue(); -} - -Webgpu::~Webgpu() { - if (instance) { - instance = nullptr; - adapter = nullptr; - device = nullptr; - queue = nullptr; - } -} - -} // namespace lab diff --git a/src/lab_window.cpp b/src/lab_window.cpp index 4b23b16..577d4b9 100644 --- a/src/lab_window.cpp +++ b/src/lab_window.cpp @@ -1,86 +1,210 @@ -#include - -#include -#include "lab_state.h" +#include "lab_detail.h" #include -#include +#include +#include namespace lab { -bool init_lab(); +namespace detail { -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; +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}); } - 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 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 Window::set_resize_callback(std::function rcb) { - user_resize_callback = rcb; - glfwSetFramebufferSizeCallback( - glfw_window_handle, [](GLFWwindow* wnd, int w, int h) { state.window_map[wnd]->user_resize_callback(w, h); }); +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 Window::clear_key_callback() { - glfwSetKeyCallback(glfw_window_handle, nullptr); - user_key_callback = nullptr; +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 Window::clear_resize_callback() { - glfwSetFramebufferSizeCallback(glfw_window_handle, nullptr); - user_resize_callback = nullptr; +void framebuffer_size_callback(GLFWwindow* window, int width, int height) { + WindowState& state = state_of(window); + if (state.on_resize) { + state.on_resize({width, height}); + } } -void Window::set_title(const char* title) { glfwSetWindowTitle(glfw_window_handle, title); } +} // namespace + +void WindowState::begin_tick() { + keys_pressed.reset(); + keys_released.reset(); + buttons_pressed.reset(); + buttons_released.reset(); + mouse_delta = {}; + scroll = {}; +} -int Window::width() const { - int width; - glfwGetWindowSize(glfw_window_handle, &width, nullptr); - return width; +void WindowState::close() { + if (!open) { + return; + } + open = false; + glfwHideWindow(handle); + std::erase(Runtime::get().windows, this); + log(LogLevel::debug, "window \"{}\" closed", glfwGetWindowTitle(handle)); } -int Window::height() const { - int height; - glfwGetWindowSize(glfw_window_handle, nullptr, &height); - return height; +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); } -float Window::ratio() const { return (1.f / width()) * height(); } +} // 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(); -bool Window::is_open() const { return state.window_map.contains(glfw_window_handle); } + 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); -// A closed window is only hidden. The native window has to outlive every Surface -// and swapchain that presents into it, and the GPU releases those late (when the -// device is destroyed), so GLFW destroys the native windows at program exit instead. -void Window::close() { - if (is_open()) { - std::cout << "Info: GLFW: Window(" << glfwGetWindowTitle(glfw_window_handle) << " - " << glfw_window_handle - << ") closed!" << std::endl; - glfwSetKeyCallback(glfw_window_handle, nullptr); - glfwSetFramebufferSizeCallback(glfw_window_handle, nullptr); - glfwHideWindow(glfw_window_handle); - state.window_map.erase(glfw_window_handle); + 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); + + runtime.windows.push_back(&state); + detail::log(LogLevel::debug, "window \"{}\" created", title); } -Window::~Window() { close(); } + +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/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 index 3a0bbc7..938b37a 100644 --- a/src/platform/lab_platform.h +++ b/src/platform/lab_platform.h @@ -3,6 +3,8 @@ #include +#include + struct GLFWwindow; // Everything that differs between window systems lives behind these functions. @@ -14,6 +16,14 @@ namespace lab::platform { // - returns nullptr and prints an error if the window system is not supported wgpu::Surface create_surface(const wgpu::Instance& instance, GLFWwindow* window); +// 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/tests/CMakeLists.txt b/tests/CMakeLists.txt index be045f2..4a7d7b5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,18 +7,30 @@ FetchContent_Declare(doctest SOURCE_SUBDIR headers-only) FetchContent_MakeAvailable(doctest) -# tests of the library: they need a GPU (or a software adapter), but no window -add_executable(lab-tests - test_main.cpp +# 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_pipeline.cpp test_render.cpp - test_vertex_format.cpp - offscreen.h) -target_include_directories(lab-tests SYSTEM PRIVATE ${doctest_SOURCE_DIR}) -target_link_libraries(lab-tests PRIVATE lab::lab) -lab_target_defaults(lab-tests) - -# registers every TEST_CASE as its own CTest test -include(${doctest_SOURCE_DIR}/scripts/cmake/doctest.cmake) + 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/offscreen.h b/tests/offscreen.h deleted file mode 100644 index 7b9aa08..0000000 --- a/tests/offscreen.h +++ /dev/null @@ -1,108 +0,0 @@ -#ifndef WGPU_LAB_TESTS_OFFSCREEN_H -#define WGPU_LAB_TESTS_OFFSCREEN_H - -#include - -#include - -#include -#include - -// Renders with a lab::Pipeline into a texture instead of a window and returns the pixels. -// This is written against raw WebGPU because the lab cannot render offscreen by itself yet. - -namespace offscreen { - -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 target is a square of this many pixels, cleared to opaque red -inline constexpr uint32_t size = 64; -inline constexpr wgpu::TextureFormat format = wgpu::TextureFormat::RGBA8Unorm; -inline constexpr Pixel clear_color{255, 0, 0, 255}; - -inline constexpr size_t center = (size / 2) * size + size / 2; // index of the pixel in the middle -inline constexpr size_t corner = 0; // index of the top left pixel - -// Draws `count` vertices (or indices, if the pipeline has an index buffer) and returns size * size pixels. -// The pipeline has to be finalized with `webgpu.surface_format = offscreen::format`. -inline std::vector render(lab::Pipeline& pipeline, uint32_t count, uint32_t instances = 1) { - wgpu::Device device = pipeline.webgpu.device; - - wgpu::TextureDescriptor textureDesc{ - .usage = wgpu::TextureUsage::RenderAttachment | wgpu::TextureUsage::CopySrc, - .size = {size, size, 1}, - .format = format, - }; - wgpu::Texture texture = device.CreateTexture(&textureDesc); - - wgpu::RenderPassColorAttachment attachment{ - .view = texture.CreateView(), - .loadOp = wgpu::LoadOp::Clear, - .storeOp = wgpu::StoreOp::Store, - .clearValue = {1.0, 0.0, 0.0, 1.0}, - }; - wgpu::RenderPassDescriptor passDesc{.colorAttachmentCount = 1, .colorAttachments = &attachment}; - - wgpu::CommandEncoder encoder = device.CreateCommandEncoder(); - wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&passDesc); - pass.SetPipeline(pipeline.wgpu_pipeline); - for (uint32_t i = 0; i < pipeline.vb_configs.size(); ++i) { - pass.SetVertexBuffer(i, pipeline.vb_configs[i].buffer, pipeline.vb_configs[i].offset); - } - for (uint32_t i = 0; i < pipeline.bindGroups.size(); ++i) { - pass.SetBindGroup(i, pipeline.bindGroups[i]); - } - if (pipeline.ib_configs.empty()) { - pass.Draw(count, instances); - } else { - const auto& index_buffer = pipeline.ib_configs.front(); - pass.SetIndexBuffer(index_buffer.buffer, index_buffer.format, index_buffer.offset); - pass.DrawIndexed(count, instances); - } - pass.End(); - - // copy the texture into a buffer that can be mapped and read - wgpu::BufferDescriptor readbackDesc{ - .usage = wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst, - .size = size * size * sizeof(Pixel), - }; - wgpu::Buffer readback = device.CreateBuffer(&readbackDesc); - - wgpu::TexelCopyTextureInfo source; - source.texture = texture; - wgpu::TexelCopyBufferInfo destination; - destination.buffer = readback; - destination.layout.bytesPerRow = size * sizeof(Pixel); // 256, which is the required row alignment - destination.layout.rowsPerImage = size; - wgpu::Extent3D extent{size, size, 1}; - encoder.CopyTextureToBuffer(&source, &destination, &extent); - - wgpu::CommandBuffer commands = encoder.Finish(); - pipeline.webgpu.queue.Submit(1, &commands); - - bool mapped = false; - wgpu::Future future = readback.MapAsync( - wgpu::MapMode::Read, 0, wgpu::kWholeMapSize, wgpu::CallbackMode::WaitAnyOnly, - [](wgpu::MapAsyncStatus status, wgpu::StringView, bool* mapped) { - *mapped = status == wgpu::MapAsyncStatus::Success; - }, - &mapped); - pipeline.webgpu.instance.WaitAny(future, UINT64_MAX); - REQUIRE(mapped); - - const Pixel* data = static_cast(readback.GetConstMappedRange()); - std::vector pixels(data, data + size * size); - readback.Unmap(); - return pixels; -} - -} // namespace offscreen - -#endif // WGPU_LAB_TESTS_OFFSCREEN_H diff --git a/tests/test_buffer.cpp b/tests/test_buffer.cpp index a26ecc0..387f350 100644 --- a/tests/test_buffer.cpp +++ b/tests/test_buffer.cpp @@ -1,39 +1,90 @@ -#include +#include "common.h" -#include +#include -TEST_CASE("Buffer: written elements can be read back") { - lab::Webgpu webgpu("test"); - REQUIRE(webgpu.device); +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); +} - std::vector data{1, 2, 3, 4, 5, 6, 7, 8}; - lab::Buffer buffer("test buffer", data, wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst, webgpu); +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 - std::vector result; - { - auto reader = buffer.from_device( - [&result](lab::MappedVRAM mapped) { result.assign(mapped.begin(), mapped.end()); }); - } // the reader thread is joined here + 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}); - CHECK(result == std::vector{1, 2, 42, 4, 5, 6, 7, 7}); + 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: a part of the buffer can be read back") { - lab::Webgpu webgpu("test"); - REQUIRE(webgpu.device); +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 - std::vector data{0.5f, 1.5f, 2.5f, 3.5f, 4.5f}; - lab::Buffer buffer("test buffer", data, wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst, webgpu); + 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}); +} - std::vector result; - { - // offsets are multiples of 8 bytes, the alignment WebGPU requires for mapping - auto reader = buffer.from_device( - 2, 2, [&result](lab::MappedVRAM mapped) { result.assign(mapped.begin(), mapped.end()); }); - } +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(result == std::vector{2.5f, 3.5f}); + CHECK(unnamed.label() == std::format("buffer test_buffer.cpp:{}", __LINE__ - 3)); + CHECK(named.label() == "my buffer"); } diff --git a/tests/test_pipeline.cpp b/tests/test_pipeline.cpp index 9a1f1a1..406db6d 100644 --- a/tests/test_pipeline.cpp +++ b/tests/test_pipeline.cpp @@ -1,41 +1,125 @@ -#include +#include "common.h" -#include +using enum wgpu::VertexFormat; -TEST_CASE("Pipeline: add_uniform_buffer uses the given binding index") { - lab::Webgpu webgpu("test"); - REQUIRE(webgpu.device); +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); + } +)"; - lab::Shader shader("unused"); - lab::Pipeline pipeline(shader, webgpu); - lab::Buffer uniforms("uniforms", {1.0f}, wgpu::BufferUsage::Uniform, webgpu); +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); +} - pipeline.add_uniform_buffer(uniforms, 3, wgpu::ShaderStage::Fragment); +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 ';' - CHECK(pipeline.bindGroupLayoutEntries.back().binding == 3); - CHECK(pipeline.bindGroupEntries.back().binding == 3); + 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: vertex attributes are laid out one after another") { - lab::Webgpu webgpu("test"); - REQUIRE(webgpu.device); +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"); - lab::Shader shader("unused"); - lab::Pipeline pipeline(shader, webgpu); - lab::Buffer vertices("vertices", {0.0f, 0.0f, 0.0f, 0.0f, 0.0f}, webgpu); + // 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()); +} - pipeline.add_vertex_buffer(vertices); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x3, 1); +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); - const auto& attributes = pipeline.vb_configs.back().vertexAttributes; - REQUIRE(attributes.size() == 2); - CHECK(attributes[0].offset == 0); - CHECK(attributes[1].offset == 8); - CHECK(lab::vertex_attributes_stride(attributes) == 20); + 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("Shader: a missing file is reported with its path") { - CHECK_THROWS_WITH_AS(lab::Shader("missing", "does/not/exist.wgsl"), doctest::Contains("does/not/exist.wgsl"), - std::runtime_error); +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 index ff7a8af..44fddba 100644 --- a/tests/test_render.cpp +++ b/tests/test_render.cpp @@ -1,26 +1,31 @@ -#include "offscreen.h" +#include "common.h" #include +#include +#include -using offscreen::Pixel; +using enum wgpu::VertexFormat; -// a triangle that covers the whole render target, see -// https://webgpufundamentals.org/webgpu/lessons/webgpu-large-triangle-to-cover-clip-space.html -static const char* const 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); - } -)"; +// 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::Webgpu webgpu("test"); - REQUIRE(webgpu.device); - webgpu.surface_format = offscreen::format; + lab::Gpu gpu; + lab::Texture target = make_target(gpu); - // the same shader as samples/shaders/test1.wgsl - lab::Shader shader("triangle"); - shader.source = R"( + // 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); @@ -28,22 +33,26 @@ TEST_CASE("render: a triangle is drawn over the clear color") { @fragment fn fs_main() -> @location(0) vec4f { return vec4f(0.0, 0.4, 1.0, 1.0); } - )"; - lab::Pipeline pipeline(shader, webgpu, true); + )"); + lab::Pipeline pipeline(gpu, shader, {.target = target}); - auto pixels = offscreen::render(pipeline, 3); + { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target, {.clear = clear_color}); + pass.draw(pipeline, 3); + } + auto pixels = target.read(); - CHECK(pixels[offscreen::center] == Pixel{0, 102, 255, 255}); - CHECK(pixels[offscreen::corner] == offscreen::clear_color); + 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::Webgpu webgpu("test"); - REQUIRE(webgpu.device); - webgpu.surface_format = offscreen::format; + lab::Gpu gpu; + lab::Texture target = make_target(gpu); - lab::Shader shader("colored vertices"); - shader.source = R"( + auto shader = lab::Shader::from_source(gpu, R"( struct VsOutput { @builtin(position) position: vec4f, @location(0) color: vec3f, @@ -54,65 +63,75 @@ TEST_CASE("render: vertex buffer attributes reach the shader") { @fragment fn fs_main(in: VsOutput) -> @location(0) vec4f { return vec4f(in.color, 1.0); } - )"; - lab::Pipeline pipeline(shader, webgpu); + )"); struct Vertex { float pos[2]; float color[3]; }; - std::vector vertices = { + 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 vertex_buffer("vertices", vertices, webgpu); - - pipeline.add_vertex_buffer(vertex_buffer); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x3, 1); - pipeline.finalize(); + lab::Buffer vertices(gpu, vertex_data); + lab::Pipeline pipeline(gpu, shader, + {.vertex_buffers = {lab::vertex({Float32x2, Float32x3})}, .target = target}); - auto pixels = offscreen::render(pipeline, 3); + { + 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[offscreen::center] == Pixel{255, 255, 0, 255}); - CHECK(pixels[offscreen::corner] == offscreen::clear_color); + 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 the binding index it was added with") { - lab::Webgpu webgpu("test"); - REQUIRE(webgpu.device); - webgpu.surface_format = offscreen::format; +TEST_CASE("render: a uniform buffer is bound at its binding index") { + lab::Gpu gpu; + lab::Texture target = make_target(gpu); - lab::Shader shader("uniform color"); - shader.source = std::string(fullscreen_vertex_shader) + R"( + 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(shader, webgpu); + )"); + lab::Pipeline pipeline(gpu, shader, {.target = target}); struct Color { float r, g, b, a; }; - lab::Buffer uniform_buffer("color", {{0.0f, 1.0f, 0.0f, 1.0f}}, wgpu::BufferUsage::Uniform, webgpu); - pipeline.add_uniform_buffer(uniform_buffer, 2, wgpu::ShaderStage::Fragment); - pipeline.finalize(); + 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}; - auto pixels = offscreen::render(pipeline, 3); + { + lab::Frame frame(gpu); + lab::RenderPass pass(frame, target); + pass.draw(pipeline, draw); + } + CHECK(all_pixels_are(target.read(), {0, 255, 0, 255})); - CHECK(std::ranges::all_of(pixels, [](const Pixel& p) { return p == Pixel{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") { - lab::Webgpu webgpu("test"); - REQUIRE(webgpu.device); - webgpu.surface_format = offscreen::format; +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 - lab::Shader shader("instanced quads"); - shader.source = R"( + auto shader = lab::Shader::from_source(gpu, R"( struct VsOutput { @builtin(position) position: vec4f, @location(0) color: vec3f, @@ -123,8 +142,7 @@ TEST_CASE("render: indexed and instanced drawing") { @fragment fn fs_main(in: VsOutput) -> @location(0) vec4f { return vec4f(in.color, 1.0); } - )"; - lab::Pipeline pipeline(shader, webgpu); + )"); struct Corner { float x, y; @@ -133,25 +151,165 @@ TEST_CASE("render: indexed and instanced drawing") { float offset; float color[3]; }; - std::vector corners = {{-1.0f, -1.0f}, {1.0f, -1.0f}, {1.0f, 1.0f}, {-1.0f, 1.0f}}; - std::vector indices = {0, 1, 2, 2, 3, 0}; - std::vector instances = {{-0.5f, {0.0f, 0.0f, 1.0f}}, {+0.5f, {0.0f, 1.0f, 1.0f}}}; - - lab::Buffer corner_buffer("corners", corners, webgpu); - lab::Buffer index_buffer("indices", indices, wgpu::BufferUsage::Index, webgpu); - lab::Buffer instance_buffer("instances", instances, webgpu); - - pipeline.add_vertex_buffer(corner_buffer); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x2, 0); - pipeline.add_index_buffer(index_buffer, wgpu::IndexFormat::Uint16); - pipeline.add_vertex_buffer(instance_buffer, wgpu::VertexStepMode::Instance); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32, 1); - pipeline.add_vertex_attrib(wgpu::VertexFormat::Float32x3, 2); - pipeline.finalize(); - - auto pixels = offscreen::render(pipeline, 6, 2); - - const size_t row = (offscreen::size / 2) * offscreen::size; - CHECK(pixels[row + offscreen::size / 4] == Pixel{0, 0, 255, 255}); // left half - CHECK(pixels[row + offscreen::size * 3 / 4] == Pixel{0, 255, 255, 255}); // right half + 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("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); + + std::ifstream file(path, std::ios::binary); + REQUIRE(file); + char signature[8] = {}; + file.read(signature, sizeof(signature)); + 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 index 239f2fc..2eff9de 100644 --- a/tests/test_vertex_format.cpp +++ b/tests/test_vertex_format.cpp @@ -1,6 +1,4 @@ -#include - -#include +#include "common.h" using enum wgpu::VertexFormat; @@ -24,8 +22,33 @@ TEST_CASE("vertex_format_size: components times bytes per component") { CHECK(lab::vertex_format_size(Unorm10_10_10_2) == 4); } -TEST_CASE("vertex_attributes_stride: sum of the attribute sizes") { - std::vector attributes{{.format = Float32x2}, {.format = Float32x3}, {.format = Unorm8x4}}; - CHECK(lab::vertex_attributes_stride(attributes) == 8 + 12 + 4); - CHECK(lab::vertex_attributes_stride({}) == 0); +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..dcfaaab --- /dev/null +++ b/tests/test_window.cpp @@ -0,0 +1,115 @@ +#include "common.h" + +#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: 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); +} From b928e0accda5c74b9b89340106f4e430aad858d7 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:27:36 +0200 Subject: [PATCH 19/30] Document the new API: README and migration notes from 0.2 Co-Authored-By: Claude Fable 5.1 --- README.md | 106 ++++++++++++++++++++--------- docs/migrating-from-0.2.md | 133 +++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 30 deletions(-) create mode 100644 docs/migrating-from-0.2.md diff --git a/README.md b/README.md index f819ef5..13bb669 100644 --- a/README.md +++ b/README.md @@ -14,49 +14,87 @@ and the API is likely to undergo significant changes. **Contributions are welcome!** -## Simple Usage Sample +## 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); - - // 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 + // vertex buffer (sends a copy of the data to GPU memory) + lab::Buffer vertices(gpu, vertex_data); - lab::Window window("Hello Triangle", 640, 400); + lab::Shader shader(gpu, "shaders/03_vertex_buffer.wgsl"); - 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: + +```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 +``` + +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()`. + +Mistakes are reported where they are made, as a `lab::Error` that names the object: + +``` +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? +``` + + +## Samples + +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 and read by the shader | +| `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 | + ## Getting Started @@ -79,7 +117,7 @@ The samples end up at the top of the build directory, next to the shaders they l ```sh cd build/dev -./sample_vertex_buffer +./03_vertex_buffer ``` For VS Code users, there's a shared `.vscode/launch.json` configuration file. @@ -92,11 +130,16 @@ To get started, you can add your own `.cpp` file, tinker around and step through ```sh ctest --preset dev # everything; each sample opens its window for a second -ctest --preset dev -LE samples # only the tests that need no window +ctest --preset dev -LE display # only the tests that need no window (they render into textures) ``` -Any sample can be run unattended by setting `LAB_EXIT_AFTER_FRAMES`, for example -`LAB_EXIT_AFTER_FRAMES=120 ./sample_texture` closes its window after 120 frames. +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`) | ### Linux @@ -131,18 +174,21 @@ The pinned Dawn version is set at the top of `cmake/LabDawn.cmake`. ### Dependencies The library only depends on [WebGPU Dawn](https://dawn.googlesource.com/dawn) and [GLFW](https://www.glfw.org/) for windowing. -wgpu-lab also makes heavy use of the C++ STL (see `include/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 downloaded as well. +Some of the samples use [GLM](https://github.com/g-truc/glm) for vector math, +and the tests use [doctest](https://github.com/doctest/doctest). Both 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 +- [x] re-design render pipeline +- [x] replace render_frame() function by smaller, composable mechanisms +- [x] write tests +- [ ] add depth buffers, samplers and a 3D sample - [ ] add compute pipeline support - [ ] add emscripten support for WebAssembly -- [ ] unify and finalize lab API -- [ ] write documentation and tests +- [ ] 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/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 From cf7ffb94f71dbd5438150bd7968e32f7ef862176 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:30:53 +0200 Subject: [PATCH 20/30] Add depth buffers and samplers Depth - SurfaceOptions::depth gives a surface a depth buffer that it keeps at the size of the window. - A pass into a texture takes one through PassOptions::depth. - TargetFormat carries the depth format, so a pipeline for a target with depth gets a depth test (PipelineDesc::depth_compare and depth_write, by default "less" and writing). Samplers - lab::sampler(gpu, {filter, address_mode}) returns a wgpu::Sampler to put into a bind group. Co-Authored-By: Claude Fable 5.1 --- include/lab_frame.h | 10 ++++- include/lab_pipeline.h | 8 +++- include/lab_surface.h | 10 ++++- include/lab_texture.h | 17 ++++++++ src/lab_detail.h | 2 + src/lab_frame.cpp | 28 +++++++++++-- src/lab_pipeline.cpp | 4 +- src/lab_surface.cpp | 15 +++++++ src/lab_texture.cpp | 11 +++++ tests/test_render.cpp | 93 ++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 189 insertions(+), 9 deletions(-) diff --git a/include/lab_frame.h b/include/lab_frame.h index c9f3a86..2a81502 100644 --- a/include/lab_frame.h +++ b/include/lab_frame.h @@ -59,6 +59,13 @@ 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"; }; @@ -101,7 +108,8 @@ class RenderPass { const wgpu::RenderPassEncoder& handle() const { return encoder; } private: - void begin(Frame& frame, wgpu::TextureView view, TargetFormat format, const PassOptions& options); + void begin(Frame& frame, wgpu::TextureView view, wgpu::TextureView depth_view, TargetFormat format, + const PassOptions& options); std::optional own_frame; Frame* frame = nullptr; diff --git a/include/lab_pipeline.h b/include/lab_pipeline.h index 5191f05..43d319e 100644 --- a/include/lab_pipeline.h +++ b/include/lab_pipeline.h @@ -82,8 +82,9 @@ struct TargetFormat { wgpu::TextureFormat color; wgpu::TextureFormat depth = wgpu::TextureFormat::Undefined; // Undefined: no depth buffer - TargetFormat(const Surface& surface) : color{surface.format()} {} + 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} {} @@ -241,6 +242,11 @@ struct PipelineDesc { // 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 = {}; diff --git a/include/lab_surface.h b/include/lab_surface.h index cffcbfc..a1d05c5 100644 --- a/include/lab_surface.h +++ b/include/lab_surface.h @@ -17,6 +17,10 @@ struct SurfaceOptions { // `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. @@ -36,7 +40,8 @@ class Surface { Surface& operator=(Surface&&) = default; wgpu::TextureFormat format() const; - Size size() const; // in pixels + 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 @@ -46,6 +51,9 @@ class Surface { // - `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(); diff --git a/include/lab_texture.h b/include/lab_texture.h index 3c6b3d1..0190717 100644 --- a/include/lab_texture.h +++ b/include/lab_texture.h @@ -77,6 +77,23 @@ class Texture { 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/src/lab_detail.h b/src/lab_detail.h index 75b0a85..24175b7 100644 --- a/src/lab_detail.h +++ b/src/lab_detail.h @@ -110,6 +110,8 @@ struct SurfaceState { 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 diff --git a/src/lab_frame.cpp b/src/lab_frame.cpp index 33911a0..b9ae179 100644 --- a/src/lab_frame.cpp +++ b/src/lab_frame.cpp @@ -59,7 +59,8 @@ void Frame::submit() { // RenderPass ------------------------------------------------------------------------------------ -void RenderPass::begin(Frame& target_frame, wgpu::TextureView view, TargetFormat format, const PassOptions& options) { +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; @@ -85,27 +86,46 @@ void RenderPass::begin(Frame& target_frame, wgpu::TextureView view, TargetFormat .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); - begin(*own_frame, surface.current_view(), surface, options); + begin(*own_frame, surface.current_view(), surface.depth_view(), surface, options); if (encoder) { own_frame->surfaces.push_back(surface.state()); } } RenderPass::RenderPass(Frame& target_frame, Surface& surface, PassOptions options) { - begin(target_frame, surface.current_view(), surface, options); + begin(target_frame, surface.current_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) { - begin(target_frame, texture.view(), texture, 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() { diff --git a/src/lab_pipeline.cpp b/src/lab_pipeline.cpp index 08bf1ff..bff569e 100644 --- a/src/lab_pipeline.cpp +++ b/src/lab_pipeline.cpp @@ -102,8 +102,8 @@ Pipeline::Pipeline(Gpu& gpu_object, const Shader& shader, PipelineDesc desc) wgpu::DepthStencilState depth_stencil; depth_stencil.format = desc.target.depth; - depth_stencil.depthWriteEnabled = wgpu::OptionalBool::True; - depth_stencil.depthCompare = wgpu::CompareFunction::Less; + 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); diff --git a/src/lab_surface.cpp b/src/lab_surface.cpp index b6f00cd..c867d3e 100644 --- a/src/lab_surface.cpp +++ b/src/lab_surface.cpp @@ -40,6 +40,16 @@ void SurfaceState::configure(Size size) { }; 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() { @@ -101,6 +111,7 @@ Surface::Surface(Gpu& gpu, Window& window, SurfaceOptions options) 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) { @@ -150,6 +161,7 @@ Surface::Surface(Gpu& gpu, Window& window, SurfaceOptions options) } 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(); } @@ -159,6 +171,9 @@ float Surface::aspect() const { } 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; } diff --git a/src/lab_texture.cpp b/src/lab_texture.cpp index eabac6e..79fca0f 100644 --- a/src/lab_texture.cpp +++ b/src/lab_texture.cpp @@ -106,6 +106,17 @@ Texture::Texture(Gpu& gpu_object, wgpu::TextureFormat format, uint32_t width, ui 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); diff --git a/tests/test_render.cpp b/tests/test_render.cpp index 44fddba..2fb5522 100644 --- a/tests/test_render.cpp +++ b/tests/test_render.cpp @@ -290,6 +290,99 @@ TEST_CASE("Texture: written pixels can be read back and sampled") { 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); From d47797b7a73e385943ebe7abaa671e2e5fc8cee2 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:30:53 +0200 Subject: [PATCH 21/30] Add compute pipelines - lab::ComputePipeline(gpu, shader), with bind_group() like the render pipeline and run() to dispatch and submit in one call. - lab::ComputePass dispatches within a lab::Frame, so that a render pass of the same frame can draw what was just computed. Co-Authored-By: Claude Fable 5.1 --- CMakeLists.txt | 2 + include/lab | 1 + include/lab_compute.h | 94 ++++++++++++++++++++++++++++++++++++++++++ src/lab_compute.cpp | 90 ++++++++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_compute.cpp | 78 +++++++++++++++++++++++++++++++++++ 6 files changed, 266 insertions(+) create mode 100644 include/lab_compute.h create mode 100644 src/lab_compute.cpp create mode 100644 tests/test_compute.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 07368ca..6b482fb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,6 +59,7 @@ 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 @@ -76,6 +77,7 @@ add_library(wgpu-lab include/lab include/lab_buffer.h + include/lab_compute.h include/lab_error.h include/lab_frame.h include/lab_gpu.h diff --git a/include/lab b/include/lab index c0eff25..69a5b5d 100644 --- a/include/lab +++ b/include/lab @@ -8,6 +8,7 @@ // between you and WebGPU. #include +#include #include #include #include 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/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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4a7d7b5..d6c0c19 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,7 @@ 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) 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()); +} From 22321bc7b320de0855cda7965eacf8393e703cb7 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:34:41 +0200 Subject: [PATCH 22/30] Add samples for 3D, compute and offscreen rendering - 10_house finally renders the glTF house from samples/assets: depth buffer, orbit camera with mouse and scroll wheel, simple lighting, and a ground textured from an image file through a repeating sampler. - 11_boids simulates a flock in a compute shader and draws it in the same frame. Simulation and rendering share the same two buffers. - 12_offscreen renders into a small texture and shows it magnified in a second pass. S saves the texture as a PNG. - 05_texture reads its texture through a sampler. lab::find_file() locates files of a program from the working directory or from next to the executable, the way lab::Shader already does. Co-Authored-By: Claude Fable 5.1 --- README.md | 16 ++- include/lab | 10 ++ samples/05_texture.cpp | 8 +- samples/10_house.cpp | 167 ++++++++++++++++++++++++ samples/11_boids.cpp | 95 ++++++++++++++ samples/12_offscreen.cpp | 49 +++++++ samples/CMakeLists.txt | 15 ++- samples/assets/ground.png | Bin 0 -> 3684 bytes samples/shaders/05_texture.wgsl | 4 +- samples/shaders/10_house.wgsl | 36 +++++ samples/shaders/10_house_ground.wgsl | 29 ++++ samples/shaders/11_boids_draw.wgsl | 26 ++++ samples/shaders/11_boids_simulate.wgsl | 77 +++++++++++ samples/shaders/12_offscreen_scene.wgsl | 27 ++++ samples/shaders/12_offscreen_show.wgsl | 26 ++++ src/lab_detail.h | 4 + src/lab_shader.cpp | 18 +-- 17 files changed, 583 insertions(+), 24 deletions(-) create mode 100644 samples/10_house.cpp create mode 100644 samples/11_boids.cpp create mode 100644 samples/12_offscreen.cpp create mode 100644 samples/assets/ground.png create mode 100644 samples/shaders/10_house.wgsl create mode 100644 samples/shaders/10_house_ground.wgsl create mode 100644 samples/shaders/11_boids_draw.wgsl create mode 100644 samples/shaders/11_boids_simulate.wgsl create mode 100644 samples/shaders/12_offscreen_scene.wgsl create mode 100644 samples/shaders/12_offscreen_show.wgsl diff --git a/README.md b/README.md index 13bb669..69e7789 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,14 @@ The samples in `samples/` build on each other, one concept at a time: | `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 and read by the shader | +| `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 @@ -174,9 +177,10 @@ The pinned Dawn version is set at the top of `cmake/LabDawn.cmake`. ### Dependencies 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 the tests use [doctest](https://github.com/doctest/doctest). Both are downloaded -when samples and tests are built, which is only the case when wgpu-lab is the top-level project. +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 @@ -184,8 +188,8 @@ when samples and tests are built, which is only the case when wgpu-lab is the to - [x] re-design render pipeline - [x] replace render_frame() function by smaller, composable mechanisms - [x] write tests -- [ ] add depth buffers, samplers and a 3D sample -- [ ] add compute pipeline support +- [x] add depth buffers, samplers and a 3D sample +- [x] add compute pipeline support - [ ] add emscripten support for WebAssembly - [ ] unify and finalize lab API - [ ] write documentation diff --git a/include/lab b/include/lab index 69a5b5d..d168070 100644 --- a/include/lab +++ b/include/lab @@ -22,6 +22,7 @@ #include #include +#include #include namespace lab { @@ -56,6 +57,15 @@ T 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; diff --git a/samples/05_texture.cpp b/samples/05_texture.cpp index c658ef6..f39a708 100644 --- a/samples/05_texture.cpp +++ b/samples/05_texture.cpp @@ -1,4 +1,4 @@ -// 05: a texture is filled with pixels and read by the fragment shader +// 05: a texture is filled with pixels and sampled by the fragment shader #include @@ -60,11 +60,11 @@ int main() { {.vertex_buffers = {lab::vertex({Float32x2, Float32x2})}, // position, uv .target = surface}); - // the bind group connects both resources the shader declares: - // `@binding(0) var uniforms` and `@binding(1) var gradientTexture` + // 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}})}, + .bind_groups = {pipeline.bind_group(0, {{0, uniform_buffer}, {1, texture}, {2, lab::sampler(gpu)}})}, }; while (lab::tick()) { 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..75cd2da --- /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.025f; + float cohesion_scale = 0.02f; + float separation_scale = 0.05f; + float alignment_scale = 0.005f; +}; + +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 index 04ad0f2..603b50c 100644 --- a/samples/CMakeLists.txt +++ b/samples/CMakeLists.txt @@ -1,15 +1,20 @@ -# glm is the only library the samples use besides the lab ---------------------- -# It is header-only, so it is only downloaded: SOURCE_SUBDIR points at a directory -# without a CMakeLists.txt, which keeps glm's own build scripts out of ours. +# 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_MakeAvailable(glm) +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}) +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) diff --git a/samples/assets/ground.png b/samples/assets/ground.png new file mode 100644 index 0000000000000000000000000000000000000000..07d7dc5af9bc752c730260b9502113f7fe652c06 GIT binary patch literal 3684 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 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/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/src/lab_detail.h b/src/lab_detail.h index 24175b7..83b88d4 100644 --- a/src/lab_detail.h +++ b/src/lab_detail.h @@ -70,6 +70,10 @@ std::optional capture_error(const GpuState& gpu, const std::functio 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 { diff --git a/src/lab_shader.cpp b/src/lab_shader.cpp index 541ccbb..c182b94 100644 --- a/src/lab_shader.cpp +++ b/src/lab_shader.cpp @@ -2,18 +2,16 @@ #include "platform/lab_platform.h" -#include +#include #include #include namespace lab { -namespace { +namespace detail { -// A relative path is tried from the working directory first and then from the -// directory of the executable, so a program finds its shaders wherever it is started from. -std::filesystem::path locate(const std::filesystem::path& file, const std::string& label) { +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; @@ -26,14 +24,18 @@ std::filesystem::path locate(const std::filesystem::path& file, const std::strin } tried += std::format(" and \"{}\"", beside_executable.string()); } - detail::fail(label, std::format("file not found, looked for {}", tried)); + fail(label, std::format("file not found, looked for {}", tried)); } -} // namespace +} // 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 = locate(wgsl_file, name); + 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())); From 1e17e5fbc8127ad9e9db3bc671276b51b344e703 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:37:06 +0200 Subject: [PATCH 23/30] Add changelog, contributing notes and screenshots, bump version to 0.4.0 CI gets a job that checks the formatting with a pinned clang-format. The boids sample gets stronger alignment, which makes flocks form within seconds. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 10 +++++ CHANGELOG.md | 73 +++++++++++++++++++++++++++++++ CMakeLists.txt | 2 +- CONTRIBUTING.md | 63 ++++++++++++++++++++++++++ README.md | 9 ++++ docs/images/03_vertex_buffer.png | Bin 0 -> 20646 bytes docs/images/06_instancing.png | Bin 0 -> 1895 bytes docs/images/07_graph.png | Bin 0 -> 28526 bytes docs/images/10_house.png | Bin 0 -> 133329 bytes docs/images/11_boids.png | Bin 0 -> 50080 bytes docs/images/12_offscreen.png | Bin 0 -> 9622 bytes include/lab_surface.h | 2 +- samples/11_boids.cpp | 6 +-- 13 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 docs/images/03_vertex_buffer.png create mode 100644 docs/images/06_instancing.png create mode 100644 docs/images/07_graph.png create mode 100644 docs/images/10_house.png create mode 100644 docs/images/11_boids.png create mode 100644 docs/images/12_offscreen.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea1bd18..036b651 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,16 @@ on: 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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..85723c3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog + +## 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 6b482fb..f5012b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.28) project( wgpu-lab - VERSION 0.3.0 + VERSION 0.4.0 LANGUAGES CXX C) # options ---------------------------------------------------------------------- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f57454d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,63 @@ +# 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 +``` + +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 69e7789..e086a51 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,15 @@ 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 +

+ +All of these are [samples](#samples) of a hundred lines or so. + ## Simple Usage Sample ```c++ diff --git a/docs/images/03_vertex_buffer.png b/docs/images/03_vertex_buffer.png new file mode 100644 index 0000000000000000000000000000000000000000..4957d85d27c73e5b4384e27251c7c04dd6034075 GIT binary patch literal 20646 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]Y13Re;lUIeAo+UQc{xsOibI?@ut+Xe~6?X}o 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 literal 0 HcmV?d00001 diff --git a/docs/images/11_boids.png b/docs/images/11_boids.png new file mode 100644 index 0000000000000000000000000000000000000000..182cdd70528f42ac974248a7470469cc0bbf3f65 GIT binary patch literal 50080 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

") + 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) diff --git a/src/lab_frame.cpp b/src/lab_frame.cpp index 0971d62..a37e505 100644 --- a/src/lab_frame.cpp +++ b/src/lab_frame.cpp @@ -2,7 +2,7 @@ #include -#include +#include #include #include diff --git a/src/lab_gpu.cpp b/src/lab_gpu.cpp index e6e4089..ae17b11 100644 --- a/src/lab_gpu.cpp +++ b/src/lab_gpu.cpp @@ -1,6 +1,6 @@ #include "lab_detail.h" -#include +#include #include #include diff --git a/src/lab_pipeline.cpp b/src/lab_pipeline.cpp index bff569e..4998d3b 100644 --- a/src/lab_pipeline.cpp +++ b/src/lab_pipeline.cpp @@ -3,7 +3,7 @@ #include #include -#include +#include #include #include diff --git a/src/lab_runtime.cpp b/src/lab_runtime.cpp index 7fe4656..31c4481 100644 --- a/src/lab_runtime.cpp +++ b/src/lab_runtime.cpp @@ -1,5 +1,7 @@ #include "lab_detail.h" +#include "platform/lab_platform.h" + #include #include @@ -156,6 +158,9 @@ bool 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 { @@ -164,6 +169,7 @@ bool tick() { // instead of spinning at full speed. glfwWaitEventsTimeout(0.01); } +#endif } runtime.presented = false; @@ -187,6 +193,7 @@ bool tick() { runtime.delta_seconds = std::chrono::duration(now - runtime.last_tick).count(); runtime.last_tick = now; + platform::end_of_tick(); return !runtime.windows.empty(); } diff --git a/src/lab_surface.cpp b/src/lab_surface.cpp index c867d3e..d82a8e3 100644 --- a/src/lab_surface.cpp +++ b/src/lab_surface.cpp @@ -98,7 +98,7 @@ void SurfaceState::present() { } view = nullptr; texture = nullptr; - handle.Present(); + platform::present(handle); Runtime::get().presented = true; } diff --git a/src/lab_texture.cpp b/src/lab_texture.cpp index 79fca0f..da973db 100644 --- a/src/lab_texture.cpp +++ b/src/lab_texture.cpp @@ -2,7 +2,7 @@ #include -#include +#include #include #include diff --git a/src/lab_window.cpp b/src/lab_window.cpp index 577d4b9..469c489 100644 --- a/src/lab_window.cpp +++ b/src/lab_window.cpp @@ -1,5 +1,7 @@ #include "lab_detail.h" +#include "platform/lab_platform.h" + #include #include @@ -145,6 +147,8 @@ Window::Window(std::string_view title, int width, int height, WindowOptions opti 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); } diff --git a/src/platform/lab_platform.h b/src/platform/lab_platform.h index 938b37a..c222625 100644 --- a/src/platform/lab_platform.h +++ b/src/platform/lab_platform.h @@ -16,6 +16,17 @@ namespace lab::platform { // - 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(); diff --git a/src/platform/lab_surface_glfw.cpp b/src/platform/lab_surface_glfw.cpp index d506626..a2d0923 100644 --- a/src/platform/lab_surface_glfw.cpp +++ b/src/platform/lab_surface_glfw.cpp @@ -80,4 +80,10 @@ wgpu::Surface create_surface(const wgpu::Instance& instance, GLFWwindow* window) #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/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 }}}
+
+

From c6f59f929968c56124b3640e6131269086775509 Mon Sep 17 00:00:00 2001
From: bv7dev 
Date: Tue, 6 Oct 2026 15:24:04 +0200
Subject: [PATCH 27/30] Link the live samples from the README

Co-Authored-By: Claude Fable 5.1 
---
 README.md | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 7abba03..17c64a3 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,8 @@ and the API is likely to undergo significant changes.
   a triangle rendered with few pixels and magnified
 

-All of these are [samples](#samples) of a hundred lines or so. +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 From bd6df685c18f0bccbb1628e5dfd52a8451d7c1ac Mon Sep 17 00:00:00 2001 From: bv7dev Date: Tue, 6 Oct 2026 16:07:29 +0200 Subject: [PATCH 28/30] Put the DLLs Dawn needs next to the executables on Windows Dawn's D3D12 backend compiles shaders through dxcompiler.dll and dxil.dll (or d3dcompiler_47.dll), and only looks for them in the directory of the executable: nothing else is accepted by the way it calls LoadLibraryEx. The Dawn archive does not ship them, so on CI every test failed to create a device. cmake/LabWindowsRuntime.cmake downloads the DirectX Shader Compiler release, takes d3dcompiler_47.dll from the Windows SDK if there is one, and lab_copy_runtime_dlls() copies them next to a target, which happens for every executable the lab builds. Projects that use the lab call it for their own executables. Co-Authored-By: Claude Fable 5.1 --- CMakeLists.txt | 7 +++++ README.md | 5 +++ cmake/LabWindowsRuntime.cmake | 58 +++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 cmake/LabWindowsRuntime.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 09bcff4..e6c709f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -46,6 +46,9 @@ if(EMSCRIPTEN) else() # dawn ------------------------------------------------------------------------- include(LabDawn) + if(WIN32) + include(LabWindowsRuntime) # the DLLs Dawn loads at run time + endif() # glfw: an installed 3.4+ is used if there is one, otherwise it is fetched ------ set(GLFW_BUILD_DOCS OFF) @@ -77,6 +80,10 @@ function(lab_target_defaults LAB_TARGET) 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 ------------------------------------------------------------- diff --git a/README.md b/README.md index 17c64a3..38b55cf 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,11 @@ 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 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() From 1a9e2d172b0ccd97364d9591b8d32f071a02f81b Mon Sep 17 00:00:00 2001 From: bv7dev Date: Tue, 6 Oct 2026 16:08:00 +0200 Subject: [PATCH 29/30] Mention the Windows run time DLLs in the changelog Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8dec3..02ecae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ ### 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 From 395e3e7b6913d1059a533a5c9678df89e14163af Mon Sep 17 00:00:00 2001 From: bv7dev Date: Tue, 6 Oct 2026 16:36:07 +0200 Subject: [PATCH 30/30] Close the file before deleting it in the save_png test Windows does not delete a file that is still open, which made this the one test that failed there. Co-Authored-By: Claude Fable 5.1 --- tests/test_render.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_render.cpp b/tests/test_render.cpp index 2fb5522..4725bec 100644 --- a/tests/test_render.cpp +++ b/tests/test_render.cpp @@ -397,10 +397,12 @@ TEST_CASE("Texture: save_png writes a PNG file") { std::filesystem::remove(path); target.save_png(path); - std::ifstream file(path, std::ios::binary); - REQUIRE(file); char signature[8] = {}; - file.read(signature, sizeof(signature)); + { + 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);

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 literal 0 HcmV?d00001 diff --git a/docs/images/12_offscreen.png b/docs/images/12_offscreen.png new file mode 100644 index 0000000000000000000000000000000000000000..d30a16e9cc46c97f7fffc0f3fc82b1c5195ecd48 GIT binary patch literal 9622 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 Date: Mon, 5 Oct 2026 23:40:27 +0200 Subject: [PATCH 24/30] Keep the depth buffer in step with a resized surface A render pass onto a surface asked for the depth view and the color view in one argument list. Getting the color view is what makes the surface adapt to a new window size, and when the depth view happened to be evaluated first it was the one of the old size: WebGPU rejected the frame after a resize. The color view is now fetched first. Found by a new test that resizes a window with a depth buffer. Also adds LAB_WINDOW_SYSTEM=x11|wayland to override the window system GLFW picks. Unsetting WAYLAND_DISPLAY is not enough to get X11 in a Wayland session, so this is what makes the X11 path testable there. Co-Authored-By: Claude Fable 5.1 --- README.md | 1 + src/lab_frame.cpp | 7 +++++-- src/lab_runtime.cpp | 19 ++++++++++++++++++- tests/test_window.cpp | 24 ++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e086a51..6749efb 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ A few environment variables help with running and inspecting programs: | `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 diff --git a/src/lab_frame.cpp b/src/lab_frame.cpp index b9ae179..0971d62 100644 --- a/src/lab_frame.cpp +++ b/src/lab_frame.cpp @@ -107,14 +107,17 @@ void RenderPass::begin(Frame& target_frame, wgpu::TextureView view, wgpu::Textur RenderPass::RenderPass(Surface& surface, PassOptions options) { own_frame.emplace(surface); - begin(*own_frame, surface.current_view(), surface.depth_view(), surface, options); + // 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) { - begin(target_frame, surface.current_view(), surface.depth_view(), surface, 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()); } diff --git a/src/lab_runtime.cpp b/src/lab_runtime.cpp index e5f77e8..7fe4656 100644 --- a/src/lab_runtime.cpp +++ b/src/lab_runtime.cpp @@ -101,11 +101,28 @@ void Runtime::init_glfw() { } 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", glfwGetVersionString()); + 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) { diff --git a/tests/test_window.cpp b/tests/test_window.cpp index dcfaaab..38d7880 100644 --- a/tests/test_window.cpp +++ b/tests/test_window.cpp @@ -1,5 +1,7 @@ #include "common.h" +#include + #include // These tests open windows, so they need a display. They are labelled "display" @@ -103,6 +105,28 @@ TEST_CASE("window: a surface keeps working after its window was closed and moved 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()); From 05b4a65b6e35f437543a94e0bbc4f6a43a0ee749 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Mon, 5 Oct 2026 23:42:37 +0200 Subject: [PATCH 25/30] Write the unaligned tail of a buffer without a temporary copy The release build warned that the padded copy could overflow for absurd sizes. Only the last 1 to 3 bytes need padding, the rest is written as it is. Co-Authored-By: Claude Fable 5.1 --- src/lab_buffer.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/lab_buffer.cpp b/src/lab_buffer.cpp index 9705230..66d50e8 100644 --- a/src/lab_buffer.cpp +++ b/src/lab_buffer.cpp @@ -87,11 +87,16 @@ void BufferCore::write(uint64_t byte_offset, const void* data, uint64_t byte_cou 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 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) { - std::vector padded(align_up_4(byte_count), 0); - std::memcpy(padded.data(), data, byte_count); - gpu->queue.WriteBuffer(handle, byte_offset, padded.data(), padded.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 " From 994f508c6c2214fb829ed48fba79648e53cd9e66 Mon Sep 17 00:00:00 2001 From: bv7dev Date: Tue, 6 Oct 2026 15:23:25 +0200 Subject: [PATCH 26/30] Build the samples for the web and publish them as a gallery `emcmake cmake --preset web` builds every sample as a web page, plus an index page with thumbnails, into build/web/site. A Pages workflow publishes that from main; the CI builds it on every push. - WebGPU is the browser's own, through Dawn's Emscripten port of the very Dawn release the native build is pinned to (cmake/emdawnwebgpu.remoteport.py). GLFW comes from the contrib.glfw3 port, and the canvas follows the size of the browser window. - src/platform/lab_surface_web.cpp is the web counterpart of the GLFW surface glue: the surface is created from the canvas, presenting is left to the browser, and tick() awaits the next animation frame, so the `while (lab::tick())` loop of every sample runs unchanged. That needs ASYNCIFY, which also covers the lab's blocking waits. - Exceptions are switched on for the web build; the lab reports mistakes with them. - 09_multi_window is left out on the web, a page has one canvas. Verified in a headless Firefox 157: the triangle, graph, instancing and house samples render; the house loads its model and texture from the packed files. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 13 +++ .github/workflows/pages.yml | 43 +++++++ .gitignore | 1 + CHANGELOG.md | 13 +++ CMakeLists.txt | 68 +++++++---- CMakePresets.json | 7 ++ CONTRIBUTING.md | 7 ++ README.md | 20 +++- cmake/emdawnwebgpu.remoteport.py | 181 ++++++++++++++++++++++++++++++ docs/images/02_triangle.png | Bin 0 -> 1232 bytes docs/images/04_uniforms.png | Bin 0 -> 1132 bytes docs/images/05_texture.png | Bin 0 -> 3420 bytes include/lab_buffer.h | 8 +- samples/CMakeLists.txt | 39 +++++++ src/lab_frame.cpp | 2 +- src/lab_gpu.cpp | 2 +- src/lab_pipeline.cpp | 2 +- src/lab_runtime.cpp | 7 ++ src/lab_surface.cpp | 2 +- src/lab_texture.cpp | 2 +- src/lab_window.cpp | 4 + src/platform/lab_platform.h | 11 ++ src/platform/lab_surface_glfw.cpp | 6 + src/platform/lab_surface_web.cpp | 44 ++++++++ web/index.html.in | 29 +++++ web/shell.html | 35 ++++++ 26 files changed, 515 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 cmake/emdawnwebgpu.remoteport.py create mode 100644 docs/images/02_triangle.png create mode 100644 docs/images/04_uniforms.png create mode 100644 docs/images/05_texture.png create mode 100644 src/platform/lab_surface_web.cpp create mode 100644 web/index.html.in create mode 100644 web/shell.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 036b651..b9ad0a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,3 +58,16 @@ jobs: # 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 14f27d9..b2f4ac2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ build*/ .cache/ +__pycache__/ CMakeUserPresets.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 85723c3..cc8dec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # 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. + ## 0.4.0 The API was redesigned. [docs/migrating-from-0.2.md](docs/migrating-from-0.2.md) explains diff --git a/CMakeLists.txt b/CMakeLists.txt index f5012b5..09bcff4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,14 +1,19 @@ cmake_minimum_required(VERSION 3.28) project( wgpu-lab - VERSION 0.4.0 + VERSION 0.5.0 LANGUAGES CXX C) # 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}) -option(LAB_BUILD_TESTS "Build the tests (run them with ctest)" ${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) @@ -27,22 +32,35 @@ if(PROJECT_IS_TOP_LEVEL) endif() endif() -# dawn ------------------------------------------------------------------------- -include(LabDawn) - -# 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) +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) + + # 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) @@ -54,6 +72,11 @@ function(lab_target_defaults LAB_TARGET) else() target_compile_options(${LAB_TARGET} PRIVATE -Wall -Wextra -pedantic) endif() + 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() endfunction() # wgpu-lab library ------------------------------------------------------------- @@ -71,8 +94,6 @@ add_library(wgpu-lab src/lab_window.cpp src/lab_detail.h - src/platform/lab_paths.cpp - src/platform/lab_surface_glfw.cpp src/platform/lab_platform.h include/lab @@ -91,6 +112,13 @@ add_library(wgpu-lab 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) diff --git a/CMakePresets.json b/CMakePresets.json index 74fd704..cdd20f9 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -19,6 +19,12 @@ "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)", @@ -32,6 +38,7 @@ "buildPresets": [ { "name": "dev", "configurePreset": "dev" }, { "name": "release", "configurePreset": "release" }, + { "name": "web", "configurePreset": "web" }, { "name": "dev-asan", "configurePreset": "dev-asan" } ], "testPresets": [ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f57454d..dc9951f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,13 @@ object lifetimes: 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 diff --git a/README.md b/README.md index 6749efb..7abba03 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,21 @@ 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. +### 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 @@ -182,7 +197,8 @@ recommended extensions and pick the `dev` preset. | `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 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 only depends on [WebGPU Dawn](https://dawn.googlesource.com/dawn) and @@ -200,7 +216,7 @@ are built, which is only the case when wgpu-lab is the top-level project. - [x] write tests - [x] add depth buffers, samplers and a 3D sample - [x] add compute pipeline support -- [ ] add emscripten support for WebAssembly +- [x] add emscripten support for WebAssembly - [ ] unify and finalize lab API - [ ] write documentation - [ ] release stable 1.0 version 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 0000000000000000000000000000000000000000..798df02ca717ea176aa0498ebf2f0bf6a666ee08 GIT binary patch literal 1232 zcmeAS@N?(olHy`uVBq!ia0y~yV4MJC|KeZ+lKsmCT^JZxrhB?LhE&{odvl{;lY@xs zMPCme4~gmx9MuULO=)XR?|2V^@zF1m7z@w|ZqoLuz zCvL&c<41ZO91_aDcWi8LXmBX9abaO$341U1O+Y~4hK`&P6BASZ@xv?ln40cBR{kcy z!g9pf1*m@i-NSNBOidLX92^`^q|f%gQc$>ITb9W0=->c0VYmBiU7#6I+3qoch;K_S7!d{uKp z0#FCgi7P(}2tw6D9Ahv``l_SDf-OMPn;IH`PGVvz<^^Sr`TRL5X2E`weFnmX8dAf&xZXvH1=y`7N-_ikx{ml;JrTn7QEj3!Is$ g&LgReI8Y`h@?7HP%D;U!z>V!Z literal 0 HcmV?d00001 diff --git a/docs/images/04_uniforms.png b/docs/images/04_uniforms.png new file mode 100644 index 0000000000000000000000000000000000000000..29716d1961b996577c270f5839c611f72624eeb6 GIT binary patch literal 1132 zcmeAS@N?(olHy`uVBq!ia0y~yV4MJC|KeZ+lKsmCT^JZxj6Gc(Ln>~)y|tJ3kb#KX zMIJ#zX6pb0h9tJEV+rhS3zJmB3Q|{!WMWG3 z{OA#*$iWd%QoF-RKtQ3|;(w2OQh>Wd!}`OO^7lCy8N1f;hjTD7@$9qXdBwpo!F0}d z!xhR34(~U|f`om(y*{;|uYtk!@J>frMn<*Wb|BMs*tvleC0NLx0ZKNc?_MXs!ZPFe zuYy(ukl@+vt_}wZ-bLy&F`X%{{V}z*q2cvmkSY6R&&2|@S8RLD<>=6mS{EyMMNq(C zPW83!28Poy@*q=UqTsIncoMIxH+VUVKdi zdIo42(7*!_C)dTU{d0XP$hC)ertbwg7UE>H6Ck%fyn}Et#Ksk=7Up2jd|x9DvMWB4 zgG1sR?<)m`fX@(fzdi(+UXTZ|3Fxgy$_MOYQJf2Mw9V~Op#LBIGFZ_Jv*+O*(I%+p zp*90Wn3$L#Vcu5UY{kUXR(!rnP@n)DKLxc3U`xPOLAjVc<4|yPa=HdY9+ek^zSpvRz3%oA`G6celF{r G5}E*t!z8u< literal 0 HcmV?d00001 diff --git a/docs/images/05_texture.png b/docs/images/05_texture.png new file mode 100644 index 0000000000000000000000000000000000000000..c6a9ce2db5c6f116933db4281083512789942bd3 GIT binary patch literal 3420 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 dEdE-Aw72)_S0>)Ufwv}z!y(6m1^drj`!^$cD)> 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), byte_count / sizeof(T)}); - }); + 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 diff --git a/samples/CMakeLists.txt b/samples/CMakeLists.txt index 603b50c..be55b3a 100644 --- a/samples/CMakeLists.txt +++ b/samples/CMakeLists.txt @@ -19,14 +19,37 @@ target_include_directories(lab-sample-libs SYSTEM INTERFACE ${glm_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) @@ -40,6 +63,22 @@ foreach(SAMPLE_FILE IN LISTS SAMPLE_FILES) 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 "