From ae0fc8b3a6fc77ddf32bb7c89a41df6112a28f8d Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 16:52:37 +0300 Subject: [PATCH] =?UTF-8?q?chore(=F0=9F=90=99):=20unit=20test=20the=20surf?= =?UTF-8?q?ace=20registry=20and=20the=20frame=20rate=20vote's=20cadence=20?= =?UTF-8?q?on=20the=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SurfaceRegistry.h and AnimationReporter had no unit tests: CI built them and the e2e suite on a device was the only check. tests/cpp compiles the registry for the host against FakeWebGPU.cpp, a stand-in for the WebGPU C functions it calls that records every call Dawn would reject, and `yarn test:cpp` builds and runs it with CMake and GoogleTest. AnimationReporter's timing rule moves unchanged into AnimationCadence, which takes the time as an argument, so the same tests drive it. The Android CI job runs them before the Gradle build. --- .github/workflows/ci.yml | 4 + packages/webgpu/CONTRIBUTING.md | 2 +- .../webgpu/android/cpp/AnimationCadence.h | 51 ++++ packages/webgpu/android/cpp/cpp-adapter.cpp | 33 +-- packages/webgpu/package.json | 1 + .../webgpu/tests/cpp/AnimationCadenceTest.cpp | 88 ++++++ packages/webgpu/tests/cpp/CMakeLists.txt | 48 +++ packages/webgpu/tests/cpp/FakeWebGPU.cpp | 271 +++++++++++++++++ packages/webgpu/tests/cpp/FakeWebGPU.h | 50 ++++ packages/webgpu/tests/cpp/SurfaceInfoTest.cpp | 275 ++++++++++++++++++ .../webgpu/tests/cpp/SurfaceRegistryTest.cpp | 117 ++++++++ 11 files changed, 913 insertions(+), 27 deletions(-) create mode 100644 packages/webgpu/android/cpp/AnimationCadence.h create mode 100644 packages/webgpu/tests/cpp/AnimationCadenceTest.cpp create mode 100644 packages/webgpu/tests/cpp/CMakeLists.txt create mode 100644 packages/webgpu/tests/cpp/FakeWebGPU.cpp create mode 100644 packages/webgpu/tests/cpp/FakeWebGPU.h create mode 100644 packages/webgpu/tests/cpp/SurfaceInfoTest.cpp create mode 100644 packages/webgpu/tests/cpp/SurfaceRegistryTest.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aab42b91a..5269c17bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -348,6 +348,10 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Run the C++ unit tests + working-directory: packages/webgpu + run: yarn test:cpp + - name: Install JDK uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: diff --git a/packages/webgpu/CONTRIBUTING.md b/packages/webgpu/CONTRIBUTING.md index f09b42e39..3d9a3b23b 100644 --- a/packages/webgpu/CONTRIBUTING.md +++ b/packages/webgpu/CONTRIBUTING.md @@ -12,7 +12,7 @@ Example app (from `apps/example/`): `yarn start` · `yarn ios` · `yarn android` From the repo root: `yarn lint` · `yarn tsc` · `yarn build:docs` -Tests (from `packages/webgpu/`): `yarn test:ref` (Chrome reference) · `yarn test` (E2E) · `yarn test:plugin` (Expo config plugin unit tests) +Tests (from `packages/webgpu/`): `yarn test:ref` (Chrome reference) · `yarn test` (E2E) · `yarn test:plugin` (Expo config plugin unit tests) · `yarn test:cpp` (C++ unit tests of `cpp/rnwgpu/SurfaceRegistry.h` and `android/cpp/AnimationCadence.h`, built for the host with CMake and a C++20 compiler against a fake WebGPU, `tests/cpp/FakeWebGPU.cpp`; one test after a build: `ctest --test-dir node_modules/.cache/cpp-tests -R `) `yarn test` needs the example app open on its "Tests" screen and connected to Metro first: start Metro with `CI=true yarn start` (from `apps/example/`) — `initialRouteName` in `apps/example/src/App.tsx` is `"Tests"` when `process.env.CI === "true"`, inlined at bundle time via the `transform-inline-environment-variables` babel plugin — then launch the app (`yarn ios` / `yarn android`, or install+launch an existing build). The screen shows "Connecting to localhost..." once it's ready; `yarn test` then picks it up automatically. If a run fails mid-suite, reload the app (`curl -X POST http://localhost:8081/reload`) before the next run — a broken `GPUDevice` from the failed run makes unrelated later tests fail too. The app and the test server talk over port 4242 by default; set `E2E_PORT` for both Metro (it is inlined into the bundle, so start Metro with `--reset-cache` after changing it) and `yarn test` to use another one, as CI does to keep clear of the other projects sharing its runner. diff --git a/packages/webgpu/android/cpp/AnimationCadence.h b/packages/webgpu/android/cpp/AnimationCadence.h new file mode 100644 index 000000000..afb1a03a6 --- /dev/null +++ b/packages/webgpu/android/cpp/AnimationCadence.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +namespace rnwgpu { + +// Decides from the times a canvas presents whether it animates, for its +// refresh rate vote: a frame presented right after its predecessor is an +// animation frame, and the first of an animation is the one to report. Once no +// animation frame came for the idle time the caller asks about, the animation +// has stopped and the next one is reported again. Lock-free, since it runs on +// every present. +class AnimationCadence { +public: + // Presents this close together are an animation (faster than 40 fps). + static constexpr int64_t kAnimatingIntervalNanos = 25'000'000; + + // A frame presented at nowNanos. True when it starts an animation that has + // not been reported yet. + bool onFramePresented(int64_t nowNanos) { + const int64_t previous = _lastPresent.exchange(nowNanos); + if (nowNanos - previous > kAnimatingIntervalNanos) { + return false; + } + _lastAnimating.store(nowNanos); + return !_reported.exchange(true); + } + + // How much longer after nowNanos the animation must stay stopped before it + // counts as idle, or 0 once it has: the next animation frame is then + // reported again. + int64_t releaseIfIdle(int64_t nowNanos, int64_t idleAfterNanos) { + const int64_t idle = nowNanos - _lastAnimating.load(); + if (idle < idleAfterNanos) { + return idleAfterNanos - idle; + } + _reported.store(false); + return 0; + } + +private: + // INT64_MIN / 2 rather than a sentinel: the first present is never "right + // after" it, and the subtractions above don't overflow. + std::atomic _lastPresent{INT64_MIN / 2}; + std::atomic _lastAnimating{INT64_MIN / 2}; + // The current animation has been reported. + std::atomic _reported{false}; +}; + +} // namespace rnwgpu diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp index b3768488c..35bff6bdd 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -21,6 +21,7 @@ #include #include "AndroidPlatformContext.h" +#include "AnimationCadence.h" #include "GPUCanvasContext.h" #include "RNWebGPUManager.h" #include "WebGPURuntime.h" @@ -212,23 +213,16 @@ struct WeakViewCall { // into Java on the render path costs a JNI transition and attaches a presenting // thread the JVM doesn't know for the length of the call, so there is one call // per animation: the first frame presented right after its predecessor calls, -// the frames after it only record the time. The view polls that time from the -// main thread (releaseIfIdle) and, once the animation has stopped, takes its -// vote back and re-arms the call for the next animation. Lock-free, since it -// runs on every present. +// the frames after it only record the time (AnimationCadence). The view polls +// that time from the main thread (releaseIfIdle) and, once the animation has +// stopped, takes its vote back and re-arms the call for the next animation. class AnimationReporter { public: AnimationReporter(JNIEnv *env, jobject view) : _onAnimating(env, view, "onAnimating") {} void onFramePresented() { - const int64_t now = nowNanos(); - const int64_t previous = _lastPresent.exchange(now); - if (now - previous > kAnimatingIntervalNanos) { - return; - } - _lastAnimating.store(now); - if (!_reported.exchange(true)) { + if (_cadence.onFramePresented(nowNanos())) { _onAnimating(); } } @@ -237,18 +231,10 @@ class AnimationReporter { // before it counts as idle, or 0 once it has: the next animating frame then // calls into Java again. int64_t releaseIfIdle(int64_t idleAfterNanos) { - const int64_t idle = nowNanos() - _lastAnimating.load(); - if (idle < idleAfterNanos) { - return idleAfterNanos - idle; - } - _reported.store(false); - return 0; + return _cadence.releaseIfIdle(nowNanos(), idleAfterNanos); } private: - // Presents this close together are an animation (faster than 40 fps). - static constexpr int64_t kAnimatingIntervalNanos = 25'000'000; - static int64_t nowNanos() { return std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) @@ -256,12 +242,7 @@ class AnimationReporter { } WeakViewCall _onAnimating; - // INT64_MIN / 2 rather than a sentinel: the first present is never "right - // after" it, and the subtractions above don't overflow. - std::atomic _lastPresent{INT64_MIN / 2}; - std::atomic _lastAnimating{INT64_MIN / 2}; - // Java has been told about the current animation. - std::atomic _reported{false}; + rnwgpu::AnimationCadence _cadence; }; // The reporters by contextId, for WebGPUView.releaseFrameRateVoteIfIdle. The diff --git a/packages/webgpu/package.json b/packages/webgpu/package.json index 9e390cd62..93224f82f 100644 --- a/packages/webgpu/package.json +++ b/packages/webgpu/package.json @@ -30,6 +30,7 @@ "test:ref": "REFERENCE=true NODE_OPTIONS='--experimental-require-module' jest -i", "test:node": "NODE_WEBGPU=true NODE_OPTIONS='--experimental-require-module' jest -i", "test:plugin": "jest -c plugin/jest.config.js", + "test:cpp": "cmake -S tests/cpp -B node_modules/.cache/cpp-tests && cmake --build node_modules/.cache/cpp-tests --parallel && ctest --test-dir node_modules/.cache/cpp-tests --output-on-failure", "lint": "eslint . --ext .ts,.tsx --max-warnings 0 --cache --fix", "tsc": "tsc --noEmit", "build": "bob build && yarn build:plugin", diff --git a/packages/webgpu/tests/cpp/AnimationCadenceTest.cpp b/packages/webgpu/tests/cpp/AnimationCadenceTest.cpp new file mode 100644 index 000000000..fe8a265c2 --- /dev/null +++ b/packages/webgpu/tests/cpp/AnimationCadenceTest.cpp @@ -0,0 +1,88 @@ +#include + +#include + +#include "AnimationCadence.h" + +namespace { + +using rnwgpu::AnimationCadence; + +constexpr int64_t kMillis = 1'000'000; +// An arbitrary start: the steady clock has no fixed origin. +constexpr int64_t kStart = 7'000 * kMillis; +constexpr int64_t kIdleAfter = 500 * kMillis; + +TEST(AnimationCadenceTest, TheFirstPresentIsNotAnAnimationFrame) { + AnimationCadence cadence; + EXPECT_FALSE(cadence.onFramePresented(kStart)); +} + +TEST(AnimationCadenceTest, AnAnimationIsReportedOnceAtItsFirstFrame) { + AnimationCadence cadence; + cadence.onFramePresented(kStart); + + EXPECT_TRUE(cadence.onFramePresented(kStart + 16 * kMillis)); + EXPECT_FALSE(cadence.onFramePresented(kStart + 33 * kMillis)); + EXPECT_FALSE(cadence.onFramePresented(kStart + 50 * kMillis)); +} + +TEST(AnimationCadenceTest, AnimatingNeedsPresentsAtMostTwentyFiveMillisecondsApart) { + AnimationCadence atTheThreshold; + atTheThreshold.onFramePresented(kStart); + AnimationCadence pastTheThreshold; + pastTheThreshold.onFramePresented(kStart); + + EXPECT_TRUE(atTheThreshold.onFramePresented(kStart + AnimationCadence::kAnimatingIntervalNanos)); + EXPECT_FALSE(pastTheThreshold.onFramePresented(kStart + AnimationCadence::kAnimatingIntervalNanos + 1)); +} + +TEST(AnimationCadenceTest, AFewRedrawsASecondNeverAnimate) { + AnimationCadence cadence; + for (int64_t present = 0; present < 10; present++) { + EXPECT_FALSE(cadence.onFramePresented(kStart + present * 200 * kMillis)); + } + EXPECT_EQ(cadence.releaseIfIdle(kStart + 1'800 * kMillis, kIdleAfter), 0); +} + +TEST(AnimationCadenceTest, StopsAnimatingTheIdleTimeAfterTheLastAnimationFrame) { + AnimationCadence cadence; + cadence.onFramePresented(kStart); + cadence.onFramePresented(kStart + 16 * kMillis); + + EXPECT_EQ(cadence.releaseIfIdle(kStart + 100 * kMillis, kIdleAfter), kIdleAfter - 84 * kMillis); + EXPECT_EQ(cadence.releaseIfIdle(kStart + 16 * kMillis + kIdleAfter, kIdleAfter), 0); +} + +TEST(AnimationCadenceTest, SlowRedrawsDoNotKeepItAnimating) { + AnimationCadence cadence; + cadence.onFramePresented(kStart); + cadence.onFramePresented(kStart + 16 * kMillis); + + cadence.onFramePresented(kStart + 300 * kMillis); + cadence.onFramePresented(kStart + 600 * kMillis); + + EXPECT_EQ(cadence.releaseIfIdle(kStart + 600 * kMillis, kIdleAfter), 0); +} + +TEST(AnimationCadenceTest, AnAnimationStillRunningIsNotReleased) { + AnimationCadence cadence; + cadence.onFramePresented(kStart); + EXPECT_TRUE(cadence.onFramePresented(kStart + 16 * kMillis)); + + EXPECT_GT(cadence.releaseIfIdle(kStart + 20 * kMillis, kIdleAfter), 0); + + EXPECT_FALSE(cadence.onFramePresented(kStart + 32 * kMillis)); +} + +TEST(AnimationCadenceTest, TheNextAnimationIsReportedOnceTheLastWasReleased) { + AnimationCadence cadence; + cadence.onFramePresented(kStart); + EXPECT_TRUE(cadence.onFramePresented(kStart + 16 * kMillis)); + EXPECT_EQ(cadence.releaseIfIdle(kStart + 16 * kMillis + kIdleAfter, kIdleAfter), 0); + + cadence.onFramePresented(kStart + 2'000 * kMillis); + EXPECT_TRUE(cadence.onFramePresented(kStart + 2'016 * kMillis)); +} + +} // namespace diff --git a/packages/webgpu/tests/cpp/CMakeLists.txt b/packages/webgpu/tests/cpp/CMakeLists.txt new file mode 100644 index 000000000..db773e724 --- /dev/null +++ b/packages/webgpu/tests/cpp/CMakeLists.txt @@ -0,0 +1,48 @@ +cmake_minimum_required(VERSION 3.22) +project(react_native_webgpu_cpp_tests LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED True) + +# Dawn's headers come from the react-native-webgpu-dawn package, as in the +# Android and iOS builds. The tests link FakeWebGPU.cpp instead of Dawn. +execute_process( + COMMAND node -e "process.stdout.write(require('path').dirname(require.resolve('react-native-webgpu-dawn/package.json')))" + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE DAWN_DIR + RESULT_VARIABLE DAWN_DIR_RESULT +) +if(NOT DAWN_DIR_RESULT EQUAL 0) + message(FATAL_ERROR "react-native-webgpu: the react-native-webgpu-dawn package was not found. Install dependencies (yarn install) first.") +endif() + +include(FetchContent) +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/releases/download/v1.18.0/googletest-1.18.0.tar.gz + URL_HASH SHA256=6e3191c1455468b3fc35a417fb565c1c5071aee1b7e7f85e30cf48a98d37d8b5 + DOWNLOAD_EXTRACT_TIMESTAMP True +) +set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest) + +add_executable( + cpp-tests + AnimationCadenceTest.cpp + FakeWebGPU.cpp + SurfaceInfoTest.cpp + SurfaceRegistryTest.cpp +) +target_include_directories( + cpp-tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../../cpp/rnwgpu + ${CMAKE_CURRENT_SOURCE_DIR}/../../android/cpp + ${DAWN_DIR}/include +) +target_compile_options(cpp-tests PRIVATE -Wall -Wextra) +target_link_libraries(cpp-tests PRIVATE GTest::gtest_main) + +enable_testing() +include(GoogleTest) +gtest_discover_tests(cpp-tests) diff --git a/packages/webgpu/tests/cpp/FakeWebGPU.cpp b/packages/webgpu/tests/cpp/FakeWebGPU.cpp new file mode 100644 index 000000000..d531918c0 --- /dev/null +++ b/packages/webgpu/tests/cpp/FakeWebGPU.cpp @@ -0,0 +1,271 @@ +#include "FakeWebGPU.h" + +#include +#include +#include +#include + +namespace { + +struct Ledger { + int nextId = 1; + int liveObjects = 0; + std::vector copies; + std::vector errors; +}; + +Ledger &ledger() { + static Ledger instance; + return instance; +} + +void reject(std::string call) { ledger().errors.push_back(std::move(call)); } + +} // namespace + +namespace fake_webgpu::detail { + +struct RefCounted { + RefCounted() : id(ledger().nextId++) { ledger().liveObjects++; } + RefCounted(const RefCounted &) = delete; + RefCounted &operator=(const RefCounted &) = delete; + virtual ~RefCounted() { ledger().liveObjects--; } + int id; + int references = 1; +}; + +template void addReference(T *object) { object->references++; } + +template void releaseReference(T *object) { + if (--object->references == 0) { + delete object; + } +} + +} // namespace fake_webgpu::detail + +using fake_webgpu::detail::addReference; +using fake_webgpu::detail::RefCounted; +using fake_webgpu::detail::releaseReference; + +struct WGPUInstanceImpl : RefCounted {}; + +struct WGPUQueueImpl : RefCounted {}; + +struct WGPUDeviceImpl : RefCounted {}; + +struct WGPUTextureImpl : RefCounted { + uint32_t width = 0; + uint32_t height = 0; + // The surface whose swapchain made the texture; 0 for a device texture. + int surfaceId = 0; +}; + +struct WGPUSurfaceImpl : RefCounted { + ~WGPUSurfaceImpl() override { + if (current != nullptr) { + releaseReference(current); + } + } + void dropCurrentTexture() { + if (current != nullptr) { + releaseReference(current); + current = nullptr; + } + } + bool configured = false; + WGPUSurfaceConfiguration configuration = {}; + // The texture acquired since the last present, configure or unconfigure. + WGPUTextureImpl *current = nullptr; + std::optional nextFailure; + int configureCount = 0; + int presentCount = 0; +}; + +struct WGPUCommandEncoderImpl : RefCounted { + std::vector copies; +}; + +struct WGPUCommandBufferImpl : RefCounted { + std::vector copies; +}; + +extern "C" { + +void wgpuInstanceAddRef(WGPUInstance instance) { addReference(instance); } +void wgpuInstanceRelease(WGPUInstance instance) { releaseReference(instance); } + +void wgpuDeviceAddRef(WGPUDevice device) { addReference(device); } +void wgpuDeviceRelease(WGPUDevice device) { releaseReference(device); } + +WGPUQueue wgpuDeviceGetQueue(WGPUDevice) { return new WGPUQueueImpl(); } +void wgpuQueueRelease(WGPUQueue queue) { releaseReference(queue); } + +WGPUTexture wgpuDeviceCreateTexture(WGPUDevice, + WGPUTextureDescriptor const *descriptor) { + auto *texture = new WGPUTextureImpl(); + texture->width = descriptor->size.width; + texture->height = descriptor->size.height; + return texture; +} + +void wgpuTextureAddRef(WGPUTexture texture) { addReference(texture); } +void wgpuTextureRelease(WGPUTexture texture) { releaseReference(texture); } +uint32_t wgpuTextureGetWidth(WGPUTexture texture) { return texture->width; } +uint32_t wgpuTextureGetHeight(WGPUTexture texture) { return texture->height; } + +WGPUCommandEncoder +wgpuDeviceCreateCommandEncoder(WGPUDevice, + WGPUCommandEncoderDescriptor const *) { + return new WGPUCommandEncoderImpl(); +} + +void wgpuCommandEncoderCopyTextureToTexture( + WGPUCommandEncoder encoder, WGPUTexelCopyTextureInfo const *source, + WGPUTexelCopyTextureInfo const *destination, WGPUExtent3D const *copySize) { + encoder->copies.push_back({source->texture->id, destination->texture->id, + destination->texture->surfaceId, copySize->width, + copySize->height}); +} + +WGPUCommandBuffer +wgpuCommandEncoderFinish(WGPUCommandEncoder encoder, + WGPUCommandBufferDescriptor const *) { + auto *commands = new WGPUCommandBufferImpl(); + commands->copies = std::move(encoder->copies); + return commands; +} + +void wgpuCommandEncoderRelease(WGPUCommandEncoder encoder) { + releaseReference(encoder); +} + +void wgpuCommandBufferRelease(WGPUCommandBuffer commands) { + releaseReference(commands); +} + +void wgpuQueueSubmit(WGPUQueue, size_t commandCount, + WGPUCommandBuffer const *commands) { + for (size_t index = 0; index < commandCount; index++) { + for (const auto © : commands[index]->copies) { + ledger().copies.push_back(copy); + } + } +} + +void wgpuSurfaceConfigure(WGPUSurface surface, + WGPUSurfaceConfiguration const *configuration) { + if (configuration->device == nullptr) { + reject("Surface.Configure without a device"); + return; + } + surface->dropCurrentTexture(); + surface->configured = true; + surface->configuration = *configuration; + surface->configuration.nextInChain = nullptr; + surface->configuration.viewFormats = nullptr; + surface->configureCount++; +} + +void wgpuSurfaceUnconfigure(WGPUSurface surface) { + if (!surface->configured) { + reject("Surface.Unconfigure on a surface that is not configured"); + return; + } + surface->dropCurrentTexture(); + surface->configured = false; +} + +void wgpuSurfaceGetCurrentTexture(WGPUSurface surface, + WGPUSurfaceTexture *surfaceTexture) { + surfaceTexture->texture = nullptr; + surfaceTexture->status = WGPUSurfaceGetCurrentTextureStatus_Error; + if (!surface->configured) { + reject("Surface.GetCurrentTexture on a surface that is not configured"); + return; + } + if (surface->nextFailure.has_value()) { + surfaceTexture->status = *surface->nextFailure; + surface->nextFailure.reset(); + return; + } + if (surface->current == nullptr) { + surface->current = new WGPUTextureImpl(); + surface->current->width = surface->configuration.width; + surface->current->height = surface->configuration.height; + surface->current->surfaceId = surface->id; + } + addReference(surface->current); + surfaceTexture->texture = surface->current; + surfaceTexture->status = WGPUSurfaceGetCurrentTextureStatus_SuccessOptimal; +} + +WGPUStatus wgpuSurfacePresent(WGPUSurface surface) { + if (!surface->configured) { + reject("Surface.Present on a surface that is not configured"); + return WGPUStatus_Error; + } + if (surface->current == nullptr) { + reject("Surface.Present without an acquired texture"); + return WGPUStatus_Error; + } + surface->dropCurrentTexture(); + surface->presentCount++; + return WGPUStatus_Success; +} + +void wgpuSurfaceAddRef(WGPUSurface surface) { addReference(surface); } +void wgpuSurfaceRelease(WGPUSurface surface) { releaseReference(surface); } + +} // extern "C" + +namespace fake_webgpu { + +void reset() { + ledger().copies.clear(); + ledger().errors.clear(); +} + +wgpu::Device createDevice() { + return wgpu::Device::Acquire(new WGPUDeviceImpl()); +} + +wgpu::Surface createSurface() { + return wgpu::Surface::Acquire(new WGPUSurfaceImpl()); +} + +int textureId(const wgpu::Texture &texture) { return texture.Get()->id; } + +int surfaceId(const wgpu::Surface &surface) { return surface.Get()->id; } + +int surfaceOf(const wgpu::Texture &texture) { return texture.Get()->surfaceId; } + +int configureCount(const wgpu::Surface &surface) { + return surface.Get()->configureCount; +} + +int presentCount(const wgpu::Surface &surface) { + return surface.Get()->presentCount; +} + +bool isConfigured(const wgpu::Surface &surface) { + return surface.Get()->configured; +} + +WGPUSurfaceConfiguration lastConfiguration(const wgpu::Surface &surface) { + return surface.Get()->configuration; +} + +void failNextAcquire(const wgpu::Surface &surface, + wgpu::SurfaceGetCurrentTextureStatus status) { + surface.Get()->nextFailure = + static_cast(status); +} + +std::vector submittedCopies() { return ledger().copies; } + +std::vector errors() { return ledger().errors; } + +int liveObjects() { return ledger().liveObjects; } + +} // namespace fake_webgpu diff --git a/packages/webgpu/tests/cpp/FakeWebGPU.h b/packages/webgpu/tests/cpp/FakeWebGPU.h new file mode 100644 index 000000000..95eb648fd --- /dev/null +++ b/packages/webgpu/tests/cpp/FakeWebGPU.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +#include "webgpu/webgpu_cpp.h" + +// A stand-in for the WebGPU C functions SurfaceRegistry.h calls, linked in +// place of Dawn. It keeps what a surface, a device and a queue were asked to +// do, and records as an error every call Dawn would reject. +namespace fake_webgpu { + +struct Copy { + int sourceTexture; + int destinationTexture; + // The surface whose swapchain made the destination; 0 for a device texture. + int destinationSurface; + uint32_t width; + uint32_t height; +}; + +// Forgets every record; objects still alive keep working. +void reset(); + +wgpu::Device createDevice(); +wgpu::Surface createSurface(); + +int textureId(const wgpu::Texture &texture); +int surfaceId(const wgpu::Surface &surface); +// The surface whose swapchain made the texture; 0 for a device texture. +int surfaceOf(const wgpu::Texture &texture); + +int configureCount(const wgpu::Surface &surface); +int presentCount(const wgpu::Surface &surface); +bool isConfigured(const wgpu::Surface &surface); +WGPUSurfaceConfiguration lastConfiguration(const wgpu::Surface &surface); +// The status the surface's next GetCurrentTexture answers with, once, instead +// of a texture. +void failNextAcquire(const wgpu::Surface &surface, + wgpu::SurfaceGetCurrentTextureStatus status); + +// Every copy submitted to a queue, in order. +std::vector submittedCopies(); +// Every call Dawn would have rejected. +std::vector errors(); +// The fake objects not yet released. +int liveObjects(); + +} // namespace fake_webgpu diff --git a/packages/webgpu/tests/cpp/SurfaceInfoTest.cpp b/packages/webgpu/tests/cpp/SurfaceInfoTest.cpp new file mode 100644 index 000000000..3f3dd61bd --- /dev/null +++ b/packages/webgpu/tests/cpp/SurfaceInfoTest.cpp @@ -0,0 +1,275 @@ +#include + +#include +#include +#include +#include + +#include "FakeWebGPU.h" +#include "SurfaceRegistry.h" + +namespace { + +using rnwgpu::SurfaceInfo; + +constexpr int kWidth = 100; +constexpr int kHeight = 50; + +// A platform window: SurfaceInfo hands it back through its releaser. +struct NativeWindow { + int releases = 0; +}; + +rnwgpu::NativeSurfaceReleaser releaserFor(NativeWindow *window) { + return [window](void *) { window->releases++; }; +} + +void configure(SurfaceInfo *info, const wgpu::Device &device) { + wgpu::SurfaceConfiguration configuration; + configuration.device = device; + configuration.format = wgpu::TextureFormat::RGBA8Unorm; + configuration.usage = wgpu::TextureUsage::RenderAttachment; + info->configure(configuration, {}); +} + +void renderFrame(SurfaceInfo *info) { + info->getCurrentTexture(); + info->presentFrame(); +} + +// Every test ends with no WebGPU call Dawn would reject and no object leaked. +class SurfaceInfoTest : public ::testing::Test { +protected: + void SetUp() override { + fake_webgpu::reset(); + _liveObjects = fake_webgpu::liveObjects(); + } + + void TearDown() override { + EXPECT_EQ(fake_webgpu::errors(), std::vector{}); + EXPECT_EQ(fake_webgpu::liveObjects(), _liveObjects); + } + +private: + int _liveObjects = 0; +}; + +TEST_F(SurfaceInfoTest, RendersOffscreenUntilASurfaceAttaches) { + auto device = fake_webgpu::createDevice(); + SurfaceInfo info(nullptr, kWidth, kHeight); + configure(&info, device); + + auto texture = info.getCurrentTexture(); + + ASSERT_NE(texture, nullptr); + EXPECT_EQ(fake_webgpu::surfaceOf(texture), 0); + EXPECT_EQ(texture.GetWidth(), static_cast(kWidth)); + EXPECT_EQ(texture.GetHeight(), static_cast(kHeight)); + info.presentFrame(); + EXPECT_TRUE(fake_webgpu::submittedCopies().empty()); +} + +TEST_F(SurfaceInfoTest, RefusesAFrameBeforeConfigure) { + SurfaceInfo info(nullptr, kWidth, kHeight); + + EXPECT_THROW(info.getCurrentTexture(), std::runtime_error); +} + +TEST_F(SurfaceInfoTest, PresentsEveryFrameOnTheSurfaceItWasAcquiredFrom) { + auto device = fake_webgpu::createDevice(); + auto surface = fake_webgpu::createSurface(); + NativeWindow window; + SurfaceInfo info(nullptr, kWidth, kHeight); + info.attachSurface(&window, surface, releaserFor(&window)); + configure(&info, device); + + for (int frame = 1; frame <= 3; frame++) { + auto texture = info.getCurrentTexture(); + EXPECT_EQ(fake_webgpu::surfaceOf(texture), fake_webgpu::surfaceId(surface)); + info.presentFrame(); + EXPECT_EQ(fake_webgpu::presentCount(surface), frame); + } + EXPECT_EQ(fake_webgpu::configureCount(surface), 1); +} + +TEST_F(SurfaceInfoTest, AdoptsAnAttachedSurfaceOnlyAtAFrameBoundary) { + auto device = fake_webgpu::createDevice(); + auto surface = fake_webgpu::createSurface(); + NativeWindow window; + SurfaceInfo info(nullptr, kWidth, kHeight); + configure(&info, device); + info.getCurrentTexture(); + + info.attachSurface(&window, surface, releaserFor(&window)); + // What RNWebGPUManager::flushPendingSurfaceTransition does from the JS + // thread while another thread renders. + info.applyPendingAttach(); + + EXPECT_EQ(fake_webgpu::configureCount(surface), 0); + info.presentFrame(); + EXPECT_EQ(fake_webgpu::configureCount(surface), 1); +} + +TEST_F(SurfaceInfoTest, ShowsTheFrameRenderedBeforeTheSurfaceOnIt) { + auto device = fake_webgpu::createDevice(); + auto surface = fake_webgpu::createSurface(); + NativeWindow window; + SurfaceInfo info(nullptr, kWidth, kHeight); + configure(&info, device); + auto offscreen = info.getCurrentTexture(); + info.attachSurface(&window, surface, releaserFor(&window)); + + info.presentFrame(); + + auto copies = fake_webgpu::submittedCopies(); + ASSERT_EQ(copies.size(), 1u); + EXPECT_EQ(copies[0].sourceTexture, fake_webgpu::textureId(offscreen)); + EXPECT_EQ(copies[0].destinationSurface, fake_webgpu::surfaceId(surface)); + EXPECT_EQ(copies[0].width, static_cast(kWidth)); + EXPECT_EQ(copies[0].height, static_cast(kHeight)); + EXPECT_EQ(fake_webgpu::presentCount(surface), 1); + // The copy needs CopyDst on the surface, on top of the usage configured. + auto usage = fake_webgpu::lastConfiguration(surface).usage; + EXPECT_NE(usage & WGPUTextureUsage_CopyDst, 0u); + EXPECT_NE(usage & WGPUTextureUsage_RenderAttachment, 0u); +} + +TEST_F(SurfaceInfoTest, DropsAFrameWhoseSurfaceIsDestroyedMidFrame) { + auto device = fake_webgpu::createDevice(); + auto surface = fake_webgpu::createSurface(); + NativeWindow window; + SurfaceInfo info(nullptr, kWidth, kHeight); + info.attachSurface(&window, surface, releaserFor(&window)); + configure(&info, device); + auto texture = info.getCurrentTexture(); + ASSERT_EQ(fake_webgpu::surfaceOf(texture), fake_webgpu::surfaceId(surface)); + + info.switchToOffscreen(); + info.presentFrame(); + + EXPECT_EQ(fake_webgpu::presentCount(surface), 0); + EXPECT_EQ(window.releases, 1); + auto next = info.getCurrentTexture(); + EXPECT_EQ(fake_webgpu::surfaceOf(next), 0); + info.presentFrame(); +} + +TEST_F(SurfaceInfoTest, ReleasesAPendingSurfaceThatIsReplacedBeforeAdoption) { + auto device = fake_webgpu::createDevice(); + auto replaced = fake_webgpu::createSurface(); + auto surface = fake_webgpu::createSurface(); + NativeWindow replacedWindow; + NativeWindow window; + SurfaceInfo info(nullptr, kWidth, kHeight); + + info.attachSurface(&replacedWindow, replaced, releaserFor(&replacedWindow)); + info.attachSurface(&window, surface, releaserFor(&window)); + EXPECT_EQ(replacedWindow.releases, 1); + configure(&info, device); + renderFrame(&info); + + EXPECT_EQ(fake_webgpu::configureCount(replaced), 0); + EXPECT_EQ(fake_webgpu::configureCount(surface), 1); + EXPECT_EQ(fake_webgpu::presentCount(surface), 1); + EXPECT_EQ(window.releases, 0); +} + +TEST_F(SurfaceInfoTest, ReleasesEveryWindowItHoldsWhenDestroyed) { + auto device = fake_webgpu::createDevice(); + NativeWindow adoptedWindow; + NativeWindow pendingWindow; + { + SurfaceInfo info(nullptr, kWidth, kHeight); + info.attachSurface(&adoptedWindow, fake_webgpu::createSurface(), + releaserFor(&adoptedWindow)); + configure(&info, device); + info.attachSurface(&pendingWindow, fake_webgpu::createSurface(), + releaserFor(&pendingWindow)); + } + + EXPECT_EQ(adoptedWindow.releases, 1); + EXPECT_EQ(pendingWindow.releases, 1); +} + +TEST_F(SurfaceInfoTest, ReconfiguresTheSurfaceOnceWhenItIsOutdated) { + auto device = fake_webgpu::createDevice(); + auto surface = fake_webgpu::createSurface(); + NativeWindow window; + SurfaceInfo info(nullptr, kWidth, kHeight); + info.attachSurface(&window, surface, releaserFor(&window)); + configure(&info, device); + fake_webgpu::failNextAcquire(surface, + wgpu::SurfaceGetCurrentTextureStatus::Outdated); + + auto texture = info.getCurrentTexture(); + + EXPECT_EQ(fake_webgpu::surfaceOf(texture), fake_webgpu::surfaceId(surface)); + EXPECT_EQ(fake_webgpu::configureCount(surface), 2); + info.presentFrame(); + EXPECT_EQ(fake_webgpu::presentCount(surface), 1); +} + +TEST_F(SurfaceInfoTest, RendersOffscreenWhileTheSurfaceCannotAcquire) { + auto device = fake_webgpu::createDevice(); + auto surface = fake_webgpu::createSurface(); + NativeWindow window; + SurfaceInfo info(nullptr, kWidth, kHeight); + info.attachSurface(&window, surface, releaserFor(&window)); + configure(&info, device); + fake_webgpu::failNextAcquire(surface, + wgpu::SurfaceGetCurrentTextureStatus::Error); + + auto texture = info.getCurrentTexture(); + EXPECT_EQ(fake_webgpu::surfaceOf(texture), 0); + info.presentFrame(); + EXPECT_EQ(fake_webgpu::presentCount(surface), 0); + + renderFrame(&info); + EXPECT_EQ(fake_webgpu::presentCount(surface), 1); +} + +TEST_F(SurfaceInfoTest, ResizesTheDrawingBufferOnReconfigure) { + auto device = fake_webgpu::createDevice(); + auto surface = fake_webgpu::createSurface(); + NativeWindow window; + SurfaceInfo offscreen(nullptr, kWidth, kHeight); + SurfaceInfo onscreen(nullptr, kWidth, kHeight); + configure(&offscreen, device); + onscreen.attachSurface(&window, surface, releaserFor(&window)); + configure(&onscreen, device); + + offscreen.reconfigure(20, 10); + onscreen.reconfigure(20, 10); + + auto texture = offscreen.getCurrentTexture(); + EXPECT_EQ(texture.GetWidth(), 20u); + EXPECT_EQ(texture.GetHeight(), 10u); + offscreen.presentFrame(); + EXPECT_EQ(fake_webgpu::lastConfiguration(surface).width, 20u); + EXPECT_EQ(fake_webgpu::lastConfiguration(surface).height, 10u); +} + +TEST_F(SurfaceInfoTest, AFrameIsCurrentUntilItEndsOrTheSurfaceChanges) { + auto device = fake_webgpu::createDevice(); + NativeWindow window; + SurfaceInfo info(nullptr, kWidth, kHeight); + configure(&info, device); + uint64_t frame = 0; + + info.getCurrentTexture(&frame); + EXPECT_TRUE(info.isCurrentFrame(frame)); + info.presentFrame(); + EXPECT_FALSE(info.isCurrentFrame(frame)); + + info.getCurrentTexture(&frame); + info.reconfigure(20, 10); + EXPECT_FALSE(info.isCurrentFrame(frame)); + + info.getCurrentTexture(&frame); + info.attachSurface(&window, fake_webgpu::createSurface(), + releaserFor(&window)); + EXPECT_FALSE(info.isCurrentFrame(frame)); + info.presentFrame(); +} + +} // namespace diff --git a/packages/webgpu/tests/cpp/SurfaceRegistryTest.cpp b/packages/webgpu/tests/cpp/SurfaceRegistryTest.cpp new file mode 100644 index 000000000..f15311f1b --- /dev/null +++ b/packages/webgpu/tests/cpp/SurfaceRegistryTest.cpp @@ -0,0 +1,117 @@ +#include + +#include +#include +#include + +#include "FakeWebGPU.h" +#include "SurfaceRegistry.h" + +namespace { + +using rnwgpu::SurfaceInfo; +using rnwgpu::SurfaceRegistry; + +constexpr int kWidth = 100; +constexpr int kHeight = 50; + +struct NativeWindow { + int releases = 0; +}; + +// The registry is a process-wide singleton: every test starts and ends with it +// empty, with no WebGPU call Dawn would reject and no object leaked. The window +// outlives the entries, which the registry keeps until TearDown. +class SurfaceRegistryTest : public ::testing::Test { +protected: + void SetUp() override { + registry().clear(); + fake_webgpu::reset(); + _liveObjects = fake_webgpu::liveObjects(); + } + + void TearDown() override { + registry().clear(); + EXPECT_EQ(fake_webgpu::errors(), std::vector{}); + EXPECT_EQ(fake_webgpu::liveObjects(), _liveObjects); + } + + static SurfaceRegistry ®istry() { return SurfaceRegistry::getInstance(); } + + std::shared_ptr attachWindow(int id) { + return registry().attachSurface(id, nullptr, kWidth, kHeight, &_window, + fake_webgpu::createSurface(), + [this](void *) { _window.releases++; }); + } + + NativeWindow _window; + +private: + int _liveObjects = 0; +}; + +TEST_F(SurfaceRegistryTest, FindsTheEntryItCreated) { + auto created = registry().getSurfaceInfoOrCreate(1, nullptr, kWidth, kHeight); + + EXPECT_EQ(registry().getSurfaceInfo(1), created); + EXPECT_EQ(registry().getSurfaceInfoOrCreate(1, nullptr, kWidth, kHeight), + created); + EXPECT_EQ(registry().getSurfaceInfo(2), nullptr); +} + +// React's StrictMode runs the Canvas cleanup right after mount and keeps the +// context the first effect run made. +TEST_F(SurfaceRegistryTest, HandsARetiredEntryBackToTheContextThatHoldsIt) { + auto held = registry().getSurfaceInfoOrCreate(1, nullptr, kWidth, kHeight); + + registry().removeSurfaceInfoIfDetached(1); + + EXPECT_EQ(registry().getSurfaceInfo(1), nullptr); + EXPECT_EQ(registry().getSurfaceInfoOrCreate(1, nullptr, kWidth, kHeight), + held); +} + +TEST_F(SurfaceRegistryTest, ForgetsARetiredEntryNothingHolds) { + std::weak_ptr retired = + registry().getSurfaceInfoOrCreate(1, nullptr, kWidth, kHeight); + + registry().removeSurfaceInfoIfDetached(1); + + EXPECT_TRUE(retired.expired()); + EXPECT_NE(registry().getSurfaceInfoOrCreate(1, nullptr, kWidth, kHeight), + nullptr); +} + +TEST_F(SurfaceRegistryTest, KeepsAnEntryWhoseViewOwnsASurface) { + attachWindow(1); + + registry().removeSurfaceInfoIfDetached(1); + EXPECT_NE(registry().getSurfaceInfo(1), nullptr); + EXPECT_EQ(_window.releases, 0); + + registry().removeSurfaceInfo(1); + EXPECT_EQ(registry().getSurfaceInfo(1), nullptr); + EXPECT_EQ(_window.releases, 1); +} + +TEST_F(SurfaceRegistryTest, AttachesToTheEntryAContextAlreadyHolds) { + auto context = registry().getSurfaceInfoOrCreate(1, nullptr, kWidth, kHeight); + + auto attached = attachWindow(1); + + EXPECT_EQ(attached, context); + EXPECT_TRUE(context->hasNativeSurface()); + EXPECT_EQ(context->getNativeInfo().nativeSurface, &_window); +} + +TEST_F(SurfaceRegistryTest, ClearForgetsEveryEntry) { + registry().getSurfaceInfoOrCreate(1, nullptr, kWidth, kHeight); + registry().getSurfaceInfoOrCreate(2, nullptr, kWidth, kHeight); + + registry().clear(); + + EXPECT_EQ(registry().getSurfaceInfo(1), nullptr); + EXPECT_EQ(registry().getSurfaceInfo(2), nullptr); +} + +} // namespace