Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion packages/webgpu/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`)

`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.

Expand Down
51 changes: 51 additions & 0 deletions packages/webgpu/android/cpp/AnimationCadence.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#pragma once

#include <atomic>
#include <cstdint>

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<int64_t> _lastPresent{INT64_MIN / 2};
std::atomic<int64_t> _lastAnimating{INT64_MIN / 2};
// The current animation has been reported.
std::atomic<bool> _reported{false};
};

} // namespace rnwgpu
33 changes: 7 additions & 26 deletions packages/webgpu/android/cpp/cpp-adapter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include <webgpu/webgpu_cpp.h>

#include "AndroidPlatformContext.h"
#include "AnimationCadence.h"
#include "GPUCanvasContext.h"
#include "RNWebGPUManager.h"
#include "WebGPURuntime.h"
Expand Down Expand Up @@ -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();
}
}
Expand All @@ -237,31 +231,18 @@ 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::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}

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<int64_t> _lastPresent{INT64_MIN / 2};
std::atomic<int64_t> _lastAnimating{INT64_MIN / 2};
// Java has been told about the current animation.
std::atomic<bool> _reported{false};
rnwgpu::AnimationCadence _cadence;
};

// The reporters by contextId, for WebGPUView.releaseFrameRateVoteIfIdle. The
Expand Down
1 change: 1 addition & 0 deletions packages/webgpu/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
88 changes: 88 additions & 0 deletions packages/webgpu/tests/cpp/AnimationCadenceTest.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#include <gtest/gtest.h>

#include <cstdint>

#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
48 changes: 48 additions & 0 deletions packages/webgpu/tests/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
Loading