From 212159d2f37965b1b10e1f5ac50e9c89ef299c68 Mon Sep 17 00:00:00 2001 From: Jack Lavigne Date: Tue, 2 Jun 2026 00:24:55 -0400 Subject: [PATCH 1/3] feat: shared texture fences --- .../SharedTextureMemory.tsx | 12 ++-- packages/webgpu/android/CMakeLists.txt | 1 + .../webgpu/cpp/rnwgpu/RNWebGPUManager.cpp | 2 + packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp | 49 +++++++++++++ packages/webgpu/cpp/rnwgpu/api/GPUDevice.h | 5 ++ .../webgpu/cpp/rnwgpu/api/GPUSharedFence.cpp | 63 ++++++++++++++++ .../webgpu/cpp/rnwgpu/api/GPUSharedFence.h | 53 ++++++++++++++ .../cpp/rnwgpu/api/GPUSharedTextureMemory.cpp | 71 ++++++++++++++++--- .../cpp/rnwgpu/api/GPUSharedTextureMemory.h | 22 +++--- .../descriptors/GPUSharedFenceDescriptor.h | 58 +++++++++++++++ .../api/descriptors/GPUSharedFenceState.h | 51 +++++++++++++ .../src/__tests__/SharedTextureMemory.spec.ts | 8 +-- packages/webgpu/src/index.tsx | 9 +++ packages/webgpu/src/types.ts | 63 +++++++++++++++- 14 files changed, 431 insertions(+), 36 deletions(-) create mode 100644 packages/webgpu/cpp/rnwgpu/api/GPUSharedFence.cpp create mode 100644 packages/webgpu/cpp/rnwgpu/api/GPUSharedFence.h create mode 100644 packages/webgpu/cpp/rnwgpu/api/descriptors/GPUSharedFenceDescriptor.h create mode 100644 packages/webgpu/cpp/rnwgpu/api/descriptors/GPUSharedFenceState.h diff --git a/apps/example/src/SharedTextureMemory/SharedTextureMemory.tsx b/apps/example/src/SharedTextureMemory/SharedTextureMemory.tsx index 7805cb7ff..b64fff142 100644 --- a/apps/example/src/SharedTextureMemory/SharedTextureMemory.tsx +++ b/apps/example/src/SharedTextureMemory/SharedTextureMemory.tsx @@ -197,11 +197,7 @@ export const SharedTextureMemory = () => { label: "video-frame", }); const texture = memory.createTexture(); - if (!memory.beginAccess(texture, true)) { - texture.destroy(); - frame.release(); - return null; - } + memory.beginAccess(texture, true); const uniformBuffer = device.createBuffer({ size: 16, // vec2 padded to 16-byte uniform alignment usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, @@ -225,7 +221,11 @@ export const SharedTextureMemory = () => { }; const releaseBound = (b: Bound) => { - b.memory.endAccess(b.texture); + try { + b.memory.endAccess(b.texture); + } catch (e) { + console.warn("[SharedTextureMemory] endAccess failed:", e); + } b.texture.destroy(); b.uniformBuffer.destroy(); b.frame.release(); diff --git a/packages/webgpu/android/CMakeLists.txt b/packages/webgpu/android/CMakeLists.txt index fcab0baa1..eb3e5ba51 100644 --- a/packages/webgpu/android/CMakeLists.txt +++ b/packages/webgpu/android/CMakeLists.txt @@ -37,6 +37,7 @@ add_library(${PACKAGE_NAME} SHARED ../cpp/rnwgpu/api/GPUCommandEncoder.cpp ../cpp/rnwgpu/api/GPUQuerySet.cpp ../cpp/rnwgpu/api/GPUTexture.cpp + ../cpp/rnwgpu/api/GPUSharedFence.cpp ../cpp/rnwgpu/api/GPUSharedTextureMemory.cpp ../cpp/rnwgpu/api/GPURenderBundleEncoder.cpp ../cpp/rnwgpu/api/GPURenderPassEncoder.cpp diff --git a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp index 38f675d37..b9d9dad1c 100644 --- a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp +++ b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp @@ -31,6 +31,7 @@ #include "GPURenderPassEncoder.h" #include "GPURenderPipeline.h" #include "GPUSampler.h" +#include "GPUSharedFence.h" #include "GPUSharedTextureMemory.h" #include "GPUShaderModule.h" #include "GPUSupportedLimits.h" @@ -100,6 +101,7 @@ RNWebGPUManager::RNWebGPUManager( GPURenderPassEncoder::installConstructor(*_jsRuntime); GPURenderPipeline::installConstructor(*_jsRuntime); GPUSampler::installConstructor(*_jsRuntime); + GPUSharedFence::installConstructor(*_jsRuntime); GPUSharedTextureMemory::installConstructor(*_jsRuntime); GPUShaderModule::installConstructor(*_jsRuntime); GPUSupportedLimits::installConstructor(*_jsRuntime); diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp index f80d7fadf..cd97db1af 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp @@ -284,6 +284,55 @@ std::shared_ptr GPUDevice::importSharedTextureMemory( std::move(label)); } +std::shared_ptr GPUDevice::importSharedFence( + std::shared_ptr descriptor) { + if (!descriptor || descriptor->handle == nullptr) { + throw std::runtime_error("GPUDevice::importSharedFence(): handle must be a " + "non-null native handle"); + } + + wgpu::SharedFenceDescriptor desc{}; + std::string label = descriptor->label.value_or(""); + if (!label.empty()) { + desc.label = wgpu::StringView(label.c_str(), label.size()); + } + + // The chained platform descriptor must outlive the synchronous + // ImportSharedFence() below; declare them all and chain the matching one. + wgpu::SharedFenceMTLSharedEventDescriptor mtlDesc{}; + wgpu::SharedFenceSyncFDDescriptor syncFdDesc{}; + wgpu::SharedFenceVkSemaphoreOpaqueFDDescriptor vkFdDesc{}; + + const std::string &type = descriptor->type; + if (type == "mtl-shared-event") { + // handle is an id pointer. + mtlDesc.sharedEvent = descriptor->handle; + desc.nextInChain = &mtlDesc; + } else if (type == "sync-fd") { + // handle is an OS file descriptor. + syncFdDesc.handle = + static_cast(reinterpret_cast(descriptor->handle)); + desc.nextInChain = &syncFdDesc; + } else if (type == "vk-semaphore-opaque-fd") { + vkFdDesc.handle = + static_cast(reinterpret_cast(descriptor->handle)); + desc.nextInChain = &vkFdDesc; + } else { + throw std::runtime_error( + "GPUDevice::importSharedFence(): unsupported fence type '" + type + + "' (expected 'mtl-shared-event', 'sync-fd' or " + "'vk-semaphore-opaque-fd')"); + } + + auto fence = _instance.ImportSharedFence(&desc); + if (fence == nullptr) { + throw std::runtime_error( + "GPUDevice::importSharedFence(): ImportSharedFence returned null - is " + "the matching 'shared-fence-*' feature enabled on the device?"); + } + return std::make_shared(std::move(fence), std::move(label)); +} + async::AsyncTaskHandle GPUDevice::createComputePipelineAsync( std::shared_ptr descriptor) { wgpu::ComputePipelineDescriptor desc{}; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h index 2ab1ddd14..ed5ff98ef 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h @@ -45,6 +45,7 @@ #include "GPURenderPipelineDescriptor.h" #include "GPUSampler.h" #include "GPUSamplerDescriptor.h" +#include "GPUSharedFenceDescriptor.h" #include "GPUSharedTextureMemory.h" #include "GPUSharedTextureMemoryDescriptor.h" #include "GPUShaderModule.h" @@ -120,6 +121,8 @@ class GPUDevice : public NativeObject { std::shared_ptr descriptor); std::shared_ptr importSharedTextureMemory( std::shared_ptr descriptor); + std::shared_ptr importSharedFence( + std::shared_ptr descriptor); std::shared_ptr createBindGroupLayout( std::shared_ptr descriptor); std::shared_ptr @@ -175,6 +178,8 @@ class GPUDevice : public NativeObject { &GPUDevice::importExternalTexture); installMethod(runtime, prototype, "importSharedTextureMemory", &GPUDevice::importSharedTextureMemory); + installMethod(runtime, prototype, "importSharedFence", + &GPUDevice::importSharedFence); installMethod(runtime, prototype, "createBindGroupLayout", &GPUDevice::createBindGroupLayout); installMethod(runtime, prototype, "createPipelineLayout", diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUSharedFence.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUSharedFence.cpp new file mode 100644 index 000000000..c9a17994e --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/api/GPUSharedFence.cpp @@ -0,0 +1,63 @@ +#include "GPUSharedFence.h" + +#include +#include + +namespace rnwgpu { + +namespace { + +// Kebab-case names matching the shared-fence-* feature strings (see Unions.h / +// GPUFeatures.h). +std::string sharedFenceTypeToString(wgpu::SharedFenceType type) { + switch (type) { + case wgpu::SharedFenceType::MTLSharedEvent: + return "mtl-shared-event"; + case wgpu::SharedFenceType::SyncFD: + return "sync-fd"; + case wgpu::SharedFenceType::VkSemaphoreOpaqueFD: + return "vk-semaphore-opaque-fd"; + case wgpu::SharedFenceType::VkSemaphoreZirconHandle: + return "vk-semaphore-zircon-handle"; + case wgpu::SharedFenceType::DXGISharedHandle: + return "dxgi-shared-handle"; + case wgpu::SharedFenceType::EGLSync: + return "egl-sync"; + default: + return ""; + } +} + +} // namespace + +jsi::Value GPUSharedFence::exportInfo(jsi::Runtime &runtime, const jsi::Value &, + const jsi::Value *, size_t) { + wgpu::SharedFenceExportInfo info{}; + uint64_t handle = 0; + +#if defined(__APPLE__) + // Apple: the handle is an id pointer. + wgpu::SharedFenceMTLSharedEventExportInfo mtlInfo{}; + info.nextInChain = &mtlInfo; + _instance.ExportInfo(&info); + handle = reinterpret_cast(mtlInfo.sharedEvent); +#elif defined(__ANDROID__) + // Android: the handle is an OS file descriptor (sync_fd). + wgpu::SharedFenceSyncFDExportInfo fdInfo{}; + info.nextInChain = &fdInfo; + _instance.ExportInfo(&info); + handle = static_cast(static_cast(fdInfo.handle)); +#else + _instance.ExportInfo(&info); +#endif + + jsi::Object result(runtime); + result.setProperty( + runtime, "type", + jsi::String::createFromUtf8(runtime, sharedFenceTypeToString(info.type))); + result.setProperty(runtime, "handle", + jsi::BigInt::fromUint64(runtime, handle)); + return result; +} + +} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUSharedFence.h b/packages/webgpu/cpp/rnwgpu/api/GPUSharedFence.h new file mode 100644 index 000000000..a44af32d2 --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/api/GPUSharedFence.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +#include "NativeObject.h" + +#include "webgpu/webgpu_cpp.h" + +namespace rnwgpu { + +namespace jsi = facebook::jsi; + +// Wraps a wgpu::SharedFence: a native GPU sync primitive (id on +// Apple, sync-fd / VkSemaphore on Android). +class GPUSharedFence : public NativeObject { +public: + static constexpr const char *CLASS_NAME = "GPUSharedFence"; + + explicit GPUSharedFence(wgpu::SharedFence instance, std::string label) + : NativeObject(CLASS_NAME), _instance(std::move(instance)), + _label(std::move(label)) {} + +public: + std::string getBrand() { return CLASS_NAME; } + + // export() -> { type, handle }: exposes the native handle (as a BigInt) so + // app code can wait on or signal the fence. The caller owns the returned + // handle (e.g. an exported sync-fd must be close()d). + jsi::Value exportInfo(jsi::Runtime &runtime, const jsi::Value &thisVal, + const jsi::Value *args, size_t count); + + std::string getLabel() { return _label; } + void setLabel(const std::string &label) { + _label = label; + _instance.SetLabel(_label.c_str()); + } + + static void definePrototype(jsi::Runtime &runtime, jsi::Object &prototype) { + installGetter(runtime, prototype, "__brand", &GPUSharedFence::getBrand); + installMethod(runtime, prototype, "export", &GPUSharedFence::exportInfo); + installGetterSetter(runtime, prototype, "label", &GPUSharedFence::getLabel, + &GPUSharedFence::setLabel); + } + + inline const wgpu::SharedFence get() { return _instance; } + +private: + wgpu::SharedFence _instance; + std::string _label; +}; + +} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUSharedTextureMemory.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUSharedTextureMemory.cpp index c7cf6bdee..6694263d0 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUSharedTextureMemory.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUSharedTextureMemory.cpp @@ -27,8 +27,9 @@ std::shared_ptr GPUSharedTextureMemory::createTexture( descriptor.value()->label.value_or("")); } -bool GPUSharedTextureMemory::beginAccess(std::shared_ptr texture, - bool initialized) { +void GPUSharedTextureMemory::beginAccess( + std::shared_ptr texture, bool initialized, + std::optional>> fences) { if (!texture) { throw std::runtime_error( "GPUSharedTextureMemory::beginAccess(): texture is null"); @@ -36,9 +37,28 @@ bool GPUSharedTextureMemory::beginAccess(std::shared_ptr texture, wgpu::SharedTextureMemoryBeginAccessDescriptor desc{}; desc.initialized = initialized; desc.concurrentRead = false; - desc.fenceCount = 0; - desc.fences = nullptr; - desc.signaledValues = nullptr; + + // Built in lockstep so fenceCount covers both arrays, and kept in locals so + // the raw pointers outlive the synchronous BeginAccess() below. + std::vector rawFences; + std::vector values; + if (fences.has_value()) { + for (const auto &state : *fences) { + if (state && state->fence) { + rawFences.push_back(state->fence->get()); + values.push_back(state->signaledValue); + } + } + } + if (!rawFences.empty()) { + desc.fenceCount = rawFences.size(); + desc.fences = rawFences.data(); + desc.signaledValues = values.data(); + } else { + desc.fenceCount = 0; + desc.fences = nullptr; + desc.signaledValues = nullptr; + } #if defined(__ANDROID__) // Dawn's Vulkan backend (AHardwareBuffer) validates that the begin-access @@ -55,14 +75,21 @@ bool GPUSharedTextureMemory::beginAccess(std::shared_ptr texture, #endif auto status = _instance.BeginAccess(texture->get(), &desc); - return static_cast(status); + if (!status) { + throw std::runtime_error("GPUSharedTextureMemory::beginAccess() failed"); + } } -bool GPUSharedTextureMemory::endAccess(std::shared_ptr texture) { - if (!texture) { - throw std::runtime_error( - "GPUSharedTextureMemory::endAccess(): texture is null"); +jsi::Value GPUSharedTextureMemory::endAccess(jsi::Runtime &runtime, + const jsi::Value &, + const jsi::Value *args, + size_t count) { + if (count < 1 || !args[0].isObject()) { + throw jsi::JSError( + runtime, "GPUSharedTextureMemory::endAccess(): expected (texture)"); } + auto texture = GPUTexture::fromValue(runtime, args[0]); + wgpu::SharedTextureMemoryEndAccessState state{}; #if defined(__ANDROID__) @@ -74,7 +101,29 @@ bool GPUSharedTextureMemory::endAccess(std::shared_ptr texture) { #endif auto status = _instance.EndAccess(texture->get(), &state); - return static_cast(status); + if (!status) { + throw jsi::JSError(runtime, "GPUSharedTextureMemory::endAccess() failed"); + } + + // Copy each wgpu::SharedFence (ref-counted) into its own GPUSharedFence + // wrapper before `state` is destroyed. + jsi::Array fences(runtime, state.fenceCount); + for (size_t i = 0; i < state.fenceCount; i++) { + wgpu::SharedFence fence = state.fences[i]; + auto wrapper = std::make_shared(std::move(fence), ""); + jsi::Object entry(runtime); + entry.setProperty(runtime, "fence", + GPUSharedFence::create(runtime, std::move(wrapper))); + entry.setProperty(runtime, "signaledValue", + jsi::BigInt::fromUint64(runtime, state.signaledValues[i])); + fences.setValueAtIndex(runtime, i, std::move(entry)); + } + + jsi::Object result(runtime); + result.setProperty(runtime, "initialized", + jsi::Value(static_cast(state.initialized))); + result.setProperty(runtime, "fences", std::move(fences)); + return result; } } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUSharedTextureMemory.h b/packages/webgpu/cpp/rnwgpu/api/GPUSharedTextureMemory.h index 02b5f7c62..88afa8447 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUSharedTextureMemory.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUSharedTextureMemory.h @@ -1,13 +1,17 @@ #pragma once +#include #include #include #include +#include #include "NativeObject.h" #include "webgpu/webgpu_cpp.h" +#include "GPUSharedFence.h" +#include "GPUSharedFenceState.h" #include "GPUTexture.h" #include "GPUTextureDescriptor.h" @@ -30,16 +34,16 @@ class GPUSharedTextureMemory : public NativeObject { std::shared_ptr createTexture(std::optional> descriptor); - // Returns true on success. Marks the shared memory as initialized so the - // texture's content is preserved (or not). Callers that want fence-based - // synchronization should pass fences via beginAccess descriptor (not yet - // exposed - we currently take the implicit/no-fence path that matches the - // most common RN use cases: still images, single-producer video frames). - bool beginAccess(std::shared_ptr texture, bool initialized); + // Optional `fences` are wait fences: Dawn waits for each to reach its + // signaledValue before writing the surface. Throws on failure. + void beginAccess( + std::shared_ptr texture, bool initialized, + std::optional>> fences); - // Returns true on success. Drops any fences produced by end-access (we do - // not yet surface them to JS). - bool endAccess(std::shared_ptr texture); + // endAccess(texture) -> { initialized, fences: { fence, signaledValue }[] } + // Surfaces the fences Dawn produced for the access. Throws on failure. + jsi::Value endAccess(jsi::Runtime &runtime, const jsi::Value &thisVal, + const jsi::Value *args, size_t count); std::string getLabel() { return _label; } void setLabel(const std::string &label) { diff --git a/packages/webgpu/cpp/rnwgpu/api/descriptors/GPUSharedFenceDescriptor.h b/packages/webgpu/cpp/rnwgpu/api/descriptors/GPUSharedFenceDescriptor.h new file mode 100644 index 000000000..d051a7eb1 --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/api/descriptors/GPUSharedFenceDescriptor.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include + +#include "webgpu/webgpu_cpp.h" + +#include "JSIConverter.h" + +namespace jsi = facebook::jsi; + +namespace rnwgpu { + +// Descriptor for GPUDevice.importSharedFence. `handle` is the native handle +// (an id pointer on Apple, an OS file descriptor on Android), +// passed from JS as a BigInt. +struct GPUSharedFenceDescriptor { + std::string type; + void *handle = nullptr; + std::optional label; +}; + +} // namespace rnwgpu + +namespace rnwgpu { + +template <> +struct JSIConverter> { + static std::shared_ptr + fromJSI(jsi::Runtime &runtime, const jsi::Value &arg, bool outOfBounds) { + auto result = std::make_unique(); + if (!outOfBounds && arg.isObject()) { + auto value = arg.getObject(runtime); + if (value.hasProperty(runtime, "type")) { + result->type = + value.getProperty(runtime, "type").asString(runtime).utf8(runtime); + } + if (value.hasProperty(runtime, "handle")) { + auto prop = value.getProperty(runtime, "handle"); + result->handle = JSIConverter::fromJSI(runtime, prop, false); + } + if (value.hasProperty(runtime, "label")) { + auto prop = value.getProperty(runtime, "label"); + result->label = JSIConverter>::fromJSI( + runtime, prop, false); + } + } + return result; + } + static jsi::Value + toJSI(jsi::Runtime & /*runtime*/, + std::shared_ptr /*arg*/) { + throw std::runtime_error("Invalid GPUSharedFenceDescriptor::toJSI()"); + } +}; + +} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/descriptors/GPUSharedFenceState.h b/packages/webgpu/cpp/rnwgpu/api/descriptors/GPUSharedFenceState.h new file mode 100644 index 000000000..381d188ae --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/api/descriptors/GPUSharedFenceState.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +#include "webgpu/webgpu_cpp.h" + +#include "JSIConverter.h" + +#include "GPUSharedFence.h" + +namespace jsi = facebook::jsi; + +namespace rnwgpu { + +// A fence and the timeline value to wait for / signal at. +struct GPUSharedFenceState { + std::shared_ptr fence; + uint64_t signaledValue = 0; +}; + +} // namespace rnwgpu + +namespace rnwgpu { + +template <> struct JSIConverter> { + static std::shared_ptr + fromJSI(jsi::Runtime &runtime, const jsi::Value &arg, bool outOfBounds) { + auto result = std::make_unique(); + if (!outOfBounds && arg.isObject()) { + auto value = arg.getObject(runtime); + if (value.hasProperty(runtime, "fence")) { + auto prop = value.getProperty(runtime, "fence"); + result->fence = JSIConverter>::fromJSI( + runtime, prop, false); + } + if (value.hasProperty(runtime, "signaledValue")) { + auto prop = value.getProperty(runtime, "signaledValue"); + result->signaledValue = + JSIConverter::fromJSI(runtime, prop, false); + } + } + return result; + } + static jsi::Value toJSI(jsi::Runtime &runtime, + std::shared_ptr arg) { + throw std::runtime_error("Invalid GPUSharedFenceState::toJSI()"); + } +}; + +} // namespace rnwgpu diff --git a/packages/webgpu/src/__tests__/SharedTextureMemory.spec.ts b/packages/webgpu/src/__tests__/SharedTextureMemory.spec.ts index 2e1e3fad4..39598905b 100644 --- a/packages/webgpu/src/__tests__/SharedTextureMemory.spec.ts +++ b/packages/webgpu/src/__tests__/SharedTextureMemory.spec.ts @@ -41,13 +41,7 @@ describe("SharedTextureMemory", () => { label: "test-frame", }); const texture = memory.createTexture(); - if (!memory.beginAccess(texture, true)) { - frame.release(); - return { - kind: "fail", - reason: `beginAccess returned false`, - }; - } + memory.beginAccess(texture, true); const module = device.createShaderModule({ code: /* wgsl */ ` diff --git a/packages/webgpu/src/index.tsx b/packages/webgpu/src/index.tsx index 4672dfc8b..140865424 100644 --- a/packages/webgpu/src/index.tsx +++ b/packages/webgpu/src/index.tsx @@ -1,5 +1,7 @@ /// import type { + GPUSharedFence, + GPUSharedFenceDescriptor, GPUSharedTextureMemory, GPUSharedTextureMemoryDescriptor, NativeCanvas, @@ -12,8 +14,14 @@ export * from "./main"; export type { VideoFrame, VideoPlayer, + GPUSharedFence, + GPUSharedFenceDescriptor, + GPUSharedFenceExportInfo, + GPUSharedFenceState, + GPUSharedFenceType, GPUSharedTextureMemory, GPUSharedTextureMemoryDescriptor, + GPUSharedTextureMemoryEndAccessState, } from "./types"; declare global { @@ -44,6 +52,7 @@ declare global { importSharedTextureMemory( descriptor: GPUSharedTextureMemoryDescriptor, ): GPUSharedTextureMemory; + importSharedFence(descriptor: GPUSharedFenceDescriptor): GPUSharedFence; } // Extend createImageBitmap to accept ArrayBuffer/TypedArray (encoded image bytes) diff --git a/packages/webgpu/src/types.ts b/packages/webgpu/src/types.ts index b015747c5..7b1cf0579 100644 --- a/packages/webgpu/src/types.ts +++ b/packages/webgpu/src/types.ts @@ -52,6 +52,55 @@ export interface GPUSharedTextureMemoryDescriptor { label?: string; } +// The kind of native synchronization primitive a GPUSharedFence wraps, matching +// the shared-fence-* device feature names. Limited to the kinds react-native-webgpu +// targets (iOS/Metal and Android/Vulkan); importSharedFence accepts these and +// export() reports them. +export type GPUSharedFenceType = + | "mtl-shared-event" + | "sync-fd" + | "vk-semaphore-opaque-fd"; + +export interface GPUSharedFenceDescriptor { + // The fence kind to import. Must match a shared-fence-* feature enabled on + // the device. + type: GPUSharedFenceType; + // The raw native handle as a BigInt: an id pointer for + // "mtl-shared-event", or an OS file descriptor for the *-fd kinds. + handle: bigint; + label?: string; +} + +export interface GPUSharedFenceExportInfo { + type: GPUSharedFenceType; + // An id pointer (Apple) or file descriptor (Android), as a + // BigInt. The caller takes ownership; e.g. an exported sync-fd must be + // closed once consumed. + handle: bigint; +} + +// A native GPU synchronization primitive shared across queues/APIs. Produced by +// GPUSharedTextureMemory.endAccess(), consumed by beginAccess(), or imported +// from a consumer's fence with GPUDevice.importSharedFence(). +export interface GPUSharedFence { + readonly __brand: "GPUSharedFence"; + label: string; + export(): GPUSharedFenceExportInfo; +} + +// A fence and the timeline value to wait for (0n for binary sync-fd fences). +export interface GPUSharedFenceState { + fence: GPUSharedFence; + signaledValue: bigint; +} + +// The result of GPUSharedTextureMemory.endAccess(): each fence is signaled at +// its signaledValue once Dawn's GPU work for the access completes. +export interface GPUSharedTextureMemoryEndAccessState { + initialized: boolean; + fences: GPUSharedFenceState[]; +} + // A piece of shared GPU memory backed by a native surface. Use createTexture() // to obtain a regular GPUTexture that aliases the surface's pixels. The // returned texture must be bracketed by beginAccess/endAccess around any @@ -62,7 +111,15 @@ export interface GPUSharedTextureMemory { createTexture(descriptor?: GPUTextureDescriptor): GPUTexture; // `initialized` declares whether the surface already holds meaningful pixels // (true for an incoming video/camera frame, false if the next pass will fully - // overwrite it). - beginAccess(texture: GPUTexture, initialized: boolean): boolean; - endAccess(texture: GPUTexture): boolean; + // overwrite it). Optional `fences` are wait fences: Dawn waits for each to + // reach its signaledValue before writing the surface. Throws if the access + // could not begin. + beginAccess( + texture: GPUTexture, + initialized: boolean, + fences?: GPUSharedFenceState[], + ): void; + // Ends the access and returns the fences Dawn produced for it. Throws if the + // access could not be ended. + endAccess(texture: GPUTexture): GPUSharedTextureMemoryEndAccessState; } From 471e5a49ddc911ea3f3c39d9b24ee97f0f81b62a Mon Sep 17 00:00:00 2001 From: Jack Lavigne Date: Tue, 2 Jun 2026 00:31:17 -0400 Subject: [PATCH 2/3] chore: add GPUSharedFence spec Co-Authored-By: Claude Opus 4.8 (1M context) --- .../webgpu/src/__tests__/SharedFence.spec.ts | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 packages/webgpu/src/__tests__/SharedFence.spec.ts diff --git a/packages/webgpu/src/__tests__/SharedFence.spec.ts b/packages/webgpu/src/__tests__/SharedFence.spec.ts new file mode 100644 index 000000000..b25fa868f --- /dev/null +++ b/packages/webgpu/src/__tests__/SharedFence.spec.ts @@ -0,0 +1,207 @@ +import { client } from "./setup"; + +// Exercises the GPUSharedFence loop: endAccess produces { fence, signaledValue } +// pairs, export() yields a native handle, importSharedFence re-imports it, and +// it feeds back into beginAccess as a wait fence. The cross-queue wait semantics +// aren't unit-testable here (they need a second consumer on another queue), so +// this checks the API shape and that handles round-trip through native. + +// What the on-device eval reports back. BigInt isn't JSON-serializable across +// the eval boundary, so handle and signaledValue come back as decimal strings. +// The round-trip fields are null unless endAccess produced at least one fence. +interface FenceProbe { + fenceCount: number; + brand: string | null; + type: string | null; + handle: string | null; + signaledValue: string | null; + reimportedBrand: string | null; + beganWithWaitFence: boolean; +} + +type EvalResult = + | { kind: "skip"; reason: string } + | { kind: "fail"; reason: string } + | ({ kind: "ok" } & FenceProbe); + +describe("SharedFence", () => { + it("endAccess surfaces { fence, signaledValue } pairs that export, re-import, and feed beginAccess", async () => { + const result = await client.eval, EvalResult>( + ({ device }) => { + const FEATURE = "rnwebgpu/shared-texture-memory"; + if (!device.features.has(FEATURE as GPUFeatureName)) { + return { + kind: "skip", + reason: `${FEATURE} not enabled on this device`, + }; + } + if (typeof RNWebGPU?.createTestVideoFrame !== "function") { + return { + kind: "skip", + reason: "RNWebGPU.createTestVideoFrame is unavailable", + }; + } + + let frame: ReturnType | null = + null; + try { + frame = RNWebGPU.createTestVideoFrame(256, 256); + const memory = device.importSharedTextureMemory({ + handle: frame.handle, + label: "fence-test", + }); + const texture = memory.createTexture(); + memory.beginAccess(texture, true); + + // endAccess only emits fences if the queue actually used the shared + // texture during the access; a bare begin/end submits no work and + // returns none. Sample the texture in a real pass so a fence is + // produced. + const module = device.createShaderModule({ + code: /* wgsl */ ` + @vertex fn vs(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4f { + var p = array( + vec2f(-1.0, -3.0), vec2f(-1.0, 1.0), vec2f(3.0, 1.0), + ); + return vec4f(p[vid], 0.0, 1.0); + } + @group(0) @binding(0) var srcTex: texture_2d; + @group(0) @binding(1) var srcSampler: sampler; + @fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f { + return textureSample(srcTex, srcSampler, pos.xy / 256.0); + } + `, + }); + const pipeline = device.createRenderPipeline({ + layout: "auto", + vertex: { module, entryPoint: "vs" }, + fragment: { + module, + entryPoint: "fs", + targets: [{ format: "rgba8unorm" }], + }, + primitive: { topology: "triangle-list" }, + }); + const sampler = device.createSampler({ + magFilter: "linear", + minFilter: "linear", + }); + const bindGroup = device.createBindGroup({ + layout: pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: texture.createView() }, + { binding: 1, resource: sampler }, + ], + }); + const target = device.createTexture({ + size: [64, 64], + format: "rgba8unorm", + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + const encoder = device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: target.createView(), + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + loadOp: "clear", + storeOp: "store", + }, + ], + }); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.draw(3); + pass.end(); + device.queue.submit([encoder.finish()]); + + const state = memory.endAccess(texture); + + if ( + typeof state !== "object" || + state === null || + typeof state.initialized !== "boolean" || + !Array.isArray(state.fences) + ) { + return { + kind: "fail", + reason: "endAccess state has the wrong shape", + }; + } + + const probe: FenceProbe = { + fenceCount: state.fences.length, + brand: null, + type: null, + handle: null, + signaledValue: null, + reimportedBrand: null, + beganWithWaitFence: false, + }; + + // If Dawn produced signal fences, round-trip one through native: + // export the handle, re-import it, and consume it as a wait fence. + if (state.fences.length > 0) { + // eslint-disable-next-line prefer-destructuring + const first = state.fences[0]; + const { fence, signaledValue } = first; + const info = fence.export(); + const reimported = device.importSharedFence({ + type: info.type, + handle: info.handle, + }); + try { + memory.beginAccess(texture, true, [ + { fence: reimported, signaledValue }, + ]); + probe.beganWithWaitFence = true; + memory.endAccess(texture); // close the second access window + } catch { + probe.beganWithWaitFence = false; + } + probe.brand = fence.__brand; + probe.type = info.type; + probe.handle = info.handle.toString(); + probe.signaledValue = signaledValue.toString(); + probe.reimportedBrand = reimported.__brand; + } + + target.destroy(); + texture.destroy(); + return { kind: "ok" as const, ...probe }; + } catch (e) { + return { kind: "fail", reason: `${(e as Error).message ?? e}` }; + } finally { + frame?.release(); + } + }, + ); + + if (result.kind === "skip") { + console.log(`SharedFence: skipping (${result.reason})`); + return; + } + if (result.kind === "fail") { + throw new Error(`SharedFence: ${result.reason}`); + } + + if (result.fenceCount > 0) { + expect(result.brand).toBe("GPUSharedFence"); + expect([ + "mtl-shared-event", + "sync-fd", + "vk-semaphore-opaque-fd", + ]).toContain(result.type); + expect(result.handle).toMatch(/^\d+$/); + expect(result.handle).not.toBe("0"); + expect(result.signaledValue).toMatch(/^\d+$/); + expect(result.reimportedBrand).toBe("GPUSharedFence"); + expect(result.beganWithWaitFence).toBe(true); + } else { + // Don't silently pass over missing coverage. + console.log( + "SharedFence: endAccess produced no fences on this platform; round-trip not exercised", + ); + } + }); +}); From 072bd0d163e548cf70dca321cbc06c41456dd202 Mon Sep 17 00:00:00 2001 From: Jack Lavigne Date: Wed, 3 Jun 2026 02:51:51 -0400 Subject: [PATCH 3/3] fix(android): dup the exported sync-fd in GPUSharedFence.export() --- packages/webgpu/cpp/rnwgpu/api/GPUSharedFence.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUSharedFence.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUSharedFence.cpp index c9a17994e..5f0730df1 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUSharedFence.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUSharedFence.cpp @@ -3,6 +3,10 @@ #include #include +#if defined(__ANDROID__) +#include +#endif + namespace rnwgpu { namespace { @@ -42,11 +46,16 @@ jsi::Value GPUSharedFence::exportInfo(jsi::Runtime &runtime, const jsi::Value &, _instance.ExportInfo(&info); handle = reinterpret_cast(mtlInfo.sharedEvent); #elif defined(__ANDROID__) - // Android: the handle is an OS file descriptor (sync_fd). + // Android: the handle is an OS file descriptor (sync_fd). Dawn's ExportInfo returns a BORROWED fd — it is + // owned by the SharedFence and closed when the fence is destroyed. This exported handle is documented as + // caller-owned (the caller must close() it), so dup() it. Without the dup the same fd is closed twice — + // once by the caller and once by Dawn on fence destruction — tripping Android's fdsan (double-close abort). wgpu::SharedFenceSyncFDExportInfo fdInfo{}; info.nextInChain = &fdInfo; _instance.ExportInfo(&info); - handle = static_cast(static_cast(fdInfo.handle)); + int exportedFd = + fdInfo.handle >= 0 ? ::fcntl(fdInfo.handle, F_DUPFD_CLOEXEC, 0) : fdInfo.handle; + handle = static_cast(static_cast(exportedFd)); #else _instance.ExportInfo(&info); #endif