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 6d0ab49f7..5cd95e6c7 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 bb856e47e..cede3b44a 100644 --- a/packages/webgpu/package.json +++ b/packages/webgpu/package.json @@ -31,6 +31,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