From eeed7a88d4e7a545e49ab95b1ad123c4d5900365 Mon Sep 17 00:00:00 2001 From: ryu <213640356+ryuhzk@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:05:02 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(=F0=9F=90=9B):=20pump=20an=20imported?= =?UTF-8?q?=20device's=20async=20work=20on=20the=20instance=20that=20owns?= =?UTF-8?q?=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit importDevice() bound the imported device to a RuntimeContext for the shared WebGPURuntime instance. When the exporter's device lives on a different instance (react-native-skia loaded after react-native-webgpu, or any other exporter), that pump never processes the device's events and createRenderPipelineAsync, popErrorScope and mapAsync stay pending. Resolve the device's own instance through its adapter, and keep one RuntimeContext per (runtime, instance) pair so each instance has a pump. --- packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h | 14 +++++++---- .../cpp/rnwgpu/async/RuntimeContext.cpp | 24 +++++++++++++------ .../webgpu/cpp/rnwgpu/async/RuntimeContext.h | 16 ++++++++----- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h b/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h index 5fcaa5760..8a1de407c 100644 --- a/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h +++ b/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h @@ -86,10 +86,10 @@ class RNWebGPU : public NativeObject { // Wrap an externally created WGPUDevice (passed as a BigInt pointer, e.g. // Skia's Graphite device from Skia.getNativeDevice()) in a GPUDevice. // AddRefs the handle, so the original owner keeps its reference. Only sound - // because the process links a single Dawn copy and the GPU instance is - // shared with the exporter (see the GPU constructor): the calling runtime's - // pump serves the instance the device lives on, so its async callbacks - // settle normally. + // because the process links a single Dawn copy. The device's async + // callbacks are pumped on the instance that owns it, which is not + // necessarily the shared one: WebGPURuntime only adopts Skia's instance when + // Skia is loaded first, and other exporters have their own. std::shared_ptr importDevice(jsi::Runtime &runtime, void *pointer) { if (pointer == nullptr) { @@ -99,7 +99,11 @@ class RNWebGPU : public NativeObject { auto raw = reinterpret_cast(pointer); wgpuDeviceAddRef(raw); wgpu::Device device = wgpu::Device::Acquire(raw); - auto ctx = async::RuntimeContext::getOrCreate(runtime, _gpu->get()); + auto instance = device.GetAdapter().GetInstance(); + if (!instance) { + throw std::runtime_error("importDevice: device has no owning instance"); + } + auto ctx = async::RuntimeContext::getOrCreate(runtime, instance); return std::make_shared(device, ctx, "Imported Device"); } diff --git a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp index db4aab4ff..f751c8a24 100644 --- a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -14,7 +15,7 @@ namespace rnwgpu::async { namespace { struct RuntimeData { - std::shared_ptr context; + std::unordered_map> contexts; }; constexpr const char *TAG = "RuntimeContext"; @@ -46,17 +47,20 @@ RuntimeContext::RuntimeContext(jsi::Runtime &runtime, wgpu::Instance instance) Logger::logToConsole("[%s] Created (runtime=%p)", TAG, &runtime); } -std::shared_ptr RuntimeContext::get(jsi::Runtime &runtime) { +std::shared_ptr RuntimeContext::get(jsi::Runtime &runtime, + wgpu::Instance instance) { auto data = runtime.getRuntimeData(runtimeDataUUID()); if (!data) { return nullptr; } - return std::static_pointer_cast(data)->context; + const auto &contexts = std::static_pointer_cast(data)->contexts; + auto existing = contexts.find(instance.Get()); + return existing != contexts.end() ? existing->second : nullptr; } std::shared_ptr RuntimeContext::getOrCreate(jsi::Runtime &runtime, wgpu::Instance instance) { - if (auto existing = get(runtime)) { + if (auto existing = get(runtime, instance)) { return existing; } auto context = std::make_shared(runtime, std::move(instance)); @@ -65,9 +69,15 @@ RuntimeContext::getOrCreate(jsi::Runtime &runtime, wgpu::Instance instance) { if (&runtime == sMainRuntime) { context->_callInvoker = sMainInvoker; } - auto data = std::make_shared(); - data->context = context; - runtime.setRuntimeData(runtimeDataUUID(), data); + auto data = std::static_pointer_cast( + runtime.getRuntimeData(runtimeDataUUID())); + if (!data) { + data = std::make_shared(); + runtime.setRuntimeData(runtimeDataUUID(), data); + } + // The context retains its instance, keeping this handle stable for the + // runtime's lifetime. Each instance's callbacks reach the matching pump. + data->contexts.emplace(context->instance().Get(), context); return context; } diff --git a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h index 6787a60db..0bfd23b2a 100644 --- a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h +++ b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h @@ -22,11 +22,14 @@ class CallInvoker; namespace rnwgpu::async { /** - * Per-runtime coordinator for asynchronous WebGPU operations. + * Per-runtime, per-instance coordinator for asynchronous WebGPU operations. * - * Each JS runtime that uses WebGPU gets its own RuntimeContext, stored in the - * runtime's runtimeData. Async Dawn operations are registered with - * CallbackMode::AllowProcessEvents and driven to completion by pumping + * Each (JS runtime, Dawn instance) pair gets its own RuntimeContext, stored in + * the runtime's runtimeData. A runtime usually has a single one, for the + * shared WebGPURuntime instance; a device passed to importDevice() can live on + * another instance, which then gets its own context and pump. Async Dawn + * operations are registered with CallbackMode::AllowProcessEvents and driven + * to completion by pumping * `instance.ProcessEvents()` on the runtime's OWN thread via a self- * rescheduling tick (scheduled through that runtime's setTimeout). Because * ProcessEvents invokes the Dawn callbacks synchronously on the pumping thread, @@ -78,7 +81,8 @@ class RuntimeContext : public std::enable_shared_from_this { RuntimeContext(jsi::Runtime &runtime, wgpu::Instance instance); - static std::shared_ptr get(jsi::Runtime &runtime); + static std::shared_ptr get(jsi::Runtime &runtime, + wgpu::Instance instance); static std::shared_ptr getOrCreate(jsi::Runtime &runtime, wgpu::Instance instance); @@ -97,7 +101,7 @@ class RuntimeContext : public std::enable_shared_from_this { return _callInvoker; } - // The wgpu::Instance bound to this runtime. + // The wgpu::Instance served by this context on its owning runtime. wgpu::Instance instance() const { return _instance; } // The runtime this context belongs to. Safe for pointer-identity checks from From 89c432fe5fd28ada3558c61b37255664c58e6ef2 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Fri, 23 Oct 2026 10:36:22 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(=F0=9F=90=9B):=20per-instance=20Process?= =?UTF-8?q?Events=20mutex=20and=20an=20e2e=20spec=20for=20importDevice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the per-(runtime, instance) RuntimeContext: - ProcessEvents() was serialized by one process-wide mutex. Pumping an imported device's instance can block on that device's own lock (Skia's Graphite device during a submit), which stalled the pumps of our own instance on other runtimes. The mutex is now per instance, resolved once per context. - getOrCreate() reads the runtimeData slot once and fills the map in a single pass; get() is gone, instance() returns a const reference and the unreachable null-instance check in importDevice is removed. - Comments and the public JSDoc no longer claim that importDevice needs the exporter to share webgpu's instance, or that native init order does not matter (on Android it decides whether Skia's instance is adopted). - ImportDevice.spec.ts imports Skia's Graphite device on the Tests screen and awaits popErrorScope, createRenderPipelineAsync, onSubmittedWorkDone and mapAsync on it. The example app installs webgpu before Skia, so on Android the device lives on a second Dawn instance: the spec hangs on the previous binding and passes with this one (verified on a Pixel 8, full suite green). --- apps/example/src/Tests.tsx | 11 +++ packages/webgpu/cpp/rnwgpu/WebGPURuntime.cpp | 19 ++-- packages/webgpu/cpp/rnwgpu/WebGPURuntime.h | 4 +- packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h | 15 ++- .../cpp/rnwgpu/async/RuntimeContext.cpp | 68 +++++++------ .../webgpu/cpp/rnwgpu/async/RuntimeContext.h | 18 ++-- .../webgpu/src/__tests__/ImportDevice.spec.ts | 96 +++++++++++++++++++ packages/webgpu/src/__tests__/setup.ts | 4 + packages/webgpu/src/importDevice.ts | 5 +- packages/webgpu/src/index.tsx | 2 +- 10 files changed, 183 insertions(+), 59 deletions(-) create mode 100644 packages/webgpu/src/__tests__/ImportDevice.spec.ts diff --git a/apps/example/src/Tests.tsx b/apps/example/src/Tests.tsx index e62dfd551..c2f06efe8 100644 --- a/apps/example/src/Tests.tsx +++ b/apps/example/src/Tests.tsx @@ -9,6 +9,7 @@ import { } from "react-native"; import { GPUOffscreenCanvas, + importDevice, // The flags as the package exports them, not the globals of the same name // (see the `exportedFlags` spec in Constants.spec.ts). GPUBufferUsage as exportedBufferUsage, @@ -17,6 +18,12 @@ import { GPUShaderStage as exportedShaderStage, GPUTextureUsage as exportedTextureUsage, } from "react-native-webgpu"; +// Imported after react-native-webgpu on purpose: webgpu installs natively on +// import and settles on a Dawn instance of its own while Skia's library is not +// loaded yet. On Android, Skia's device then lives on a second instance, and +// ImportDevice.spec.ts relies on that split to cover importDevice() across +// instances. +import { Skia } from "react-native-skia"; import { mat4, vec3, mat3 } from "wgpu-matrix"; import { useClient } from "./useClient"; @@ -132,6 +139,10 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => { }, workletDeviceStress, workletSerialization, + importSkiaDevice: () => + Skia.gpu.isAvailable + ? importDevice(Skia.getNativeDevice()) + : null, exportedFlags: { GPUBufferUsage: exportedBufferUsage, GPUColorWrite: exportedColorWrite, diff --git a/packages/webgpu/cpp/rnwgpu/WebGPURuntime.cpp b/packages/webgpu/cpp/rnwgpu/WebGPURuntime.cpp index df7a38b7c..97934fccc 100644 --- a/packages/webgpu/cpp/rnwgpu/WebGPURuntime.cpp +++ b/packages/webgpu/cpp/rnwgpu/WebGPURuntime.cpp @@ -9,15 +9,18 @@ namespace rnwgpu { namespace { wgpu::Instance createInstance() { - // If a Graphite build of react-native-skia is loaded in this + // If a Graphite build of react-native-skia is already loaded in this // process, adopt its wgpu::Instance instead of creating our own, so - // Graphite's device and every JS-created device live on one instance. That - // makes pointer-based handoff (RNWebGPU.importDevice of Skia's device) - // sound: the async pump serves the shared instance, so callbacks on - // imported devices settle. Discovered at runtime via dlsym so there is no - // build-time coupling and native-module init order does not matter. Both - // packages must link the same single Dawn copy (they vendor the same - // artifact) for the handle to be meaningful. + // Graphite's device and every JS-created device live on one instance. + // Discovered at runtime via dlsym so there is no build-time coupling. The + // adoption is best-effort and decided once, when the instance is first + // used: on Android it only succeeds when librnskia.so is loaded before + // react-native-webgpu installs (see the RTLD_NOLOAD lookup below), so the + // native-module init order decides whether the instance is shared. + // RNWebGPU.importDevice does not depend on it: an imported device's async + // work is pumped on the instance that owns it (see async::RuntimeContext). + // Both packages must link the same single Dawn copy (they vendor the same + // artifact) for the handles they exchange to be meaningful. using GetInstanceFn = WGPUInstance (*)(); void *getter = dlsym(RTLD_DEFAULT, "rnskia_getWGPUInstance"); #ifdef __ANDROID__ diff --git a/packages/webgpu/cpp/rnwgpu/WebGPURuntime.h b/packages/webgpu/cpp/rnwgpu/WebGPURuntime.h index 5f8bce888..2b76b29e4 100644 --- a/packages/webgpu/cpp/rnwgpu/WebGPURuntime.h +++ b/packages/webgpu/cpp/rnwgpu/WebGPURuntime.h @@ -15,8 +15,8 @@ class WebGPURuntime { public: static WebGPURuntime &shared(); - // The process-wide instance: Skia Graphite's when that is loaded, otherwise - // our own. Created on first use. Safe to call from any thread. + // The process-wide instance: Skia Graphite's when that is already loaded on + // first use, otherwise our own. Safe to call from any thread. wgpu::Instance instance(); // Creates a surface on the shared instance for a CAMetalLayer (Apple) or an diff --git a/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h b/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h index 8a1de407c..c0e7acd1a 100644 --- a/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h +++ b/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h @@ -87,9 +87,11 @@ class RNWebGPU : public NativeObject { // Skia's Graphite device from Skia.getNativeDevice()) in a GPUDevice. // AddRefs the handle, so the original owner keeps its reference. Only sound // because the process links a single Dawn copy. The device's async - // callbacks are pumped on the instance that owns it, which is not - // necessarily the shared one: WebGPURuntime only adopts Skia's instance when - // Skia is loaded first, and other exporters have their own. + // callbacks are pumped on the instance that owns it (RuntimeContext is per + // runtime and per instance), which is not necessarily the shared one: + // WebGPURuntime adopts Skia's instance only when Skia's getter is reachable + // as the instance is first used (on Android, only once librnskia.so is + // loaded), and other exporters have their own. std::shared_ptr importDevice(jsi::Runtime &runtime, void *pointer) { if (pointer == nullptr) { @@ -99,11 +101,8 @@ class RNWebGPU : public NativeObject { auto raw = reinterpret_cast(pointer); wgpuDeviceAddRef(raw); wgpu::Device device = wgpu::Device::Acquire(raw); - auto instance = device.GetAdapter().GetInstance(); - if (!instance) { - throw std::runtime_error("importDevice: device has no owning instance"); - } - auto ctx = async::RuntimeContext::getOrCreate(runtime, instance); + auto ctx = async::RuntimeContext::getOrCreate( + runtime, device.GetAdapter().GetInstance()); return std::make_shared(device, ctx, "Imported Device"); } diff --git a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp index f751c8a24..f456769b0 100644 --- a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp @@ -26,12 +26,27 @@ constexpr const char *TAG = "RuntimeContext"; jsi::Runtime *sMainRuntime = nullptr; std::shared_ptr sMainInvoker; -// Serializes ProcessEvents() across all runtimes that share a wgpu::Instance. -// Held only across the ProcessEvents call itself, never while running JS / mailbox +// ProcessEvents() on one wgpu::Instance is serialized across the runtimes that +// share it by a mutex per instance, which each context resolves once, on +// creation. Pumping another library's instance (the one owning a device passed +// to importDevice) can block on that library's device lock; with one mutex per +// instance that never stalls the pumps of our own instance. A mutex is held +// only across the ProcessEvents call itself, never while running JS / mailbox // settle-actions, so it cannot deadlock against the per-context mailbox mutex. -std::mutex &processEventsMutex() { - static std::mutex mutex; - return mutex; +std::shared_ptr processEventsMutexFor(WGPUInstance instance) { + static std::mutex registryMutex; + static std::unordered_map> registry; + std::lock_guard lock(registryMutex); + auto &slot = registry[instance]; + if (auto existing = slot.lock()) { + return existing; + } + // Contexts retain their instance, so its handle cannot be reused while one + // of them is alive: an expired slot belonged to an instance whose runtimes + // are all gone, and an instance reusing that address starts afresh. + auto created = std::make_shared(); + slot = created; + return created; } } // namespace @@ -43,41 +58,32 @@ void RuntimeContext::registerMainRuntime( } RuntimeContext::RuntimeContext(jsi::Runtime &runtime, wgpu::Instance instance) - : _runtime(runtime), _instance(std::move(instance)) { + : _runtime(runtime), _instance(std::move(instance)), + _processEventsMutex(processEventsMutexFor(_instance.Get())) { Logger::logToConsole("[%s] Created (runtime=%p)", TAG, &runtime); } -std::shared_ptr RuntimeContext::get(jsi::Runtime &runtime, - wgpu::Instance instance) { - auto data = runtime.getRuntimeData(runtimeDataUUID()); - if (!data) { - return nullptr; - } - const auto &contexts = std::static_pointer_cast(data)->contexts; - auto existing = contexts.find(instance.Get()); - return existing != contexts.end() ? existing->second : nullptr; -} - std::shared_ptr -RuntimeContext::getOrCreate(jsi::Runtime &runtime, wgpu::Instance instance) { - if (auto existing = get(runtime, instance)) { - return existing; - } - auto context = std::make_shared(runtime, std::move(instance)); - // Only the main JS runtime's context carries the CallInvoker; it is used to - // deliver spontaneous events (device.lost) without the pump. - if (&runtime == sMainRuntime) { - context->_callInvoker = sMainInvoker; - } +RuntimeContext::getOrCreate(jsi::Runtime &runtime, + const wgpu::Instance &instance) { auto data = std::static_pointer_cast( runtime.getRuntimeData(runtimeDataUUID())); if (!data) { data = std::make_shared(); runtime.setRuntimeData(runtimeDataUUID(), data); } - // The context retains its instance, keeping this handle stable for the - // runtime's lifetime. Each instance's callbacks reach the matching pump. - data->contexts.emplace(context->instance().Get(), context); + // Contexts retain their instance, so its handle is a stable key for the + // runtime's lifetime, and each instance's callbacks reach the matching pump. + // A slot left null by a throwing constructor is simply filled next time. + auto &context = data->contexts[instance.Get()]; + if (!context) { + context = std::make_shared(runtime, instance); + // Only the main JS runtime's context carries the CallInvoker; it is used to + // deliver spontaneous events (device.lost) without the pump. + if (&runtime == sMainRuntime) { + context->_callInvoker = sMainInvoker; + } + } return context; } @@ -183,7 +189,7 @@ void RuntimeContext::tick() { { // Serialize ProcessEvents across runtimes sharing this instance. Callbacks // fired here only deposit into mailboxes (postSettle), they do not run JS. - std::lock_guard lock(processEventsMutex()); + std::lock_guard lock(*_processEventsMutex); _instance.ProcessEvents(); } // Settle this runtime's ready promises on this thread, outside the pump lock. diff --git a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h index 0bfd23b2a..7c5411bd3 100644 --- a/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h +++ b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h @@ -59,8 +59,9 @@ namespace rnwgpu::async { * deposits a settle-action (a plain C++ closure, no JSI) into the OWNING * context's thread-safe mailbox via postSettle(), and each context drains its * own mailbox on its own thread during tick(). ProcessEvents() itself is - * serialized across runtimes by a process-wide mutex, since concurrent - * ProcessEvents on one instance is not guaranteed reentrant. + * serialized across the runtimes sharing an instance by a mutex per instance, + * since concurrent ProcessEvents on one instance is not guaranteed reentrant; + * pumps of different instances never wait on each other. * * Threading contract: a RuntimeContext must only be pumped from the runtime it * was created for. Request/response async entry points (mapAsync, @@ -81,10 +82,10 @@ class RuntimeContext : public std::enable_shared_from_this { RuntimeContext(jsi::Runtime &runtime, wgpu::Instance instance); - static std::shared_ptr get(jsi::Runtime &runtime, - wgpu::Instance instance); - static std::shared_ptr getOrCreate(jsi::Runtime &runtime, - wgpu::Instance instance); + // The calling runtime's context for `instance`, created on first use and + // kept in the runtime's runtimeData for the runtime's lifetime. + static std::shared_ptr + getOrCreate(jsi::Runtime &runtime, const wgpu::Instance &instance); // Register the main JS runtime and its CallInvoker. The RuntimeContext // created for this runtime gets the invoker (callInvoker() returns it); every @@ -102,7 +103,7 @@ class RuntimeContext : public std::enable_shared_from_this { } // The wgpu::Instance served by this context on its owning runtime. - wgpu::Instance instance() const { return _instance; } + const wgpu::Instance &instance() const { return _instance; } // The runtime this context belongs to. Safe for pointer-identity checks from // any thread; only touch JSI through it on the runtime's own thread. @@ -130,6 +131,9 @@ class RuntimeContext : public std::enable_shared_from_this { jsi::Runtime &_runtime; wgpu::Instance _instance; + // Serializes ProcessEvents() on _instance across the runtimes that share it; + // every context of that instance holds the same mutex. + std::shared_ptr _processEventsMutex; // Non-null only for the main JS runtime's context (see registerMainRuntime). std::shared_ptr _callInvoker; std::atomic _pumpTasks{0}; diff --git a/packages/webgpu/src/__tests__/ImportDevice.spec.ts b/packages/webgpu/src/__tests__/ImportDevice.spec.ts new file mode 100644 index 000000000..3de1663e5 --- /dev/null +++ b/packages/webgpu/src/__tests__/ImportDevice.spec.ts @@ -0,0 +1,96 @@ +import { client } from "./setup"; + +// importDevice() wraps a device another library created, here Skia's Graphite +// device. Its async operations settle through Dawn's ProcessEvents pump, which +// has to run on the Dawn instance that owns the device. That is not webgpu's +// own instance when react-native-skia installs after react-native-webgpu (the +// example app's order, see apps/example/src/Tests.tsx), so a pump bound to the +// wrong instance leaves every promise below pending forever. The web and node +// runners have no device to import and return null, which skips the +// assertions; so does a native run on a device where Skia has no GPU. +describe("importDevice", () => { + it("settles async operations on a device imported from Skia", async () => { + const result = await client.eval( + ({ importSkiaDevice, shaders: { triangleVertWGSL, redFragWGSL } }) => { + const device = importSkiaDevice ? importSkiaDevice() : null; + if (device === null) { + return null; + } + // Each operation races a timer, so a hang names the operation instead + // of running into the harness' eval timeout. + const within = (promise: Promise) => + Promise.race([ + promise.then((value) => ({ settled: true, value })), + new Promise<{ settled: false; value: null }>((resolve) => + setTimeout(() => resolve({ settled: false, value: null }), 5000), + ), + ]); + + device.pushErrorScope("validation"); + // Invalid, maxAnisotropy must be at least 1. + device.createSampler({ maxAnisotropy: 0 }); + const errorScope = within(device.popErrorScope()).then((r) => ({ + settled: r.settled, + hasError: r.settled && r.value !== null, + })); + + const pipeline = within( + device.createRenderPipelineAsync({ + layout: "auto", + vertex: { + module: device.createShaderModule({ code: triangleVertWGSL }), + }, + fragment: { + module: device.createShaderModule({ code: redFragWGSL }), + targets: [{ format: "rgba8unorm" }], + }, + }), + ).then((r) => ({ + settled: r.settled, + isPipeline: + r.settled && typeof r.value.getBindGroupLayout === "function", + })); + + const src = device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }); + const dst = device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + device.queue.writeBuffer(src, 0, new Uint32Array([0xc0ffee, 1, 2, 3])); + const encoder = device.createCommandEncoder(); + encoder.copyBufferToBuffer(src, 0, dst, 0, 16); + device.queue.submit([encoder.finish()]); + const workDone = within(device.queue.onSubmittedWorkDone()).then( + (r) => r.settled, + ); + const mapped = within(dst.mapAsync(GPUMapMode.READ)).then((r) => { + if (!r.settled) { + return { settled: false, first: -1 }; + } + const first = new DataView(dst.getMappedRange()).getUint32(0, true); + dst.unmap(); + return { settled: true, first }; + }); + + return Promise.all([errorScope, pipeline, workDone, mapped]).then( + (results) => ({ + errorScope: results[0], + pipeline: results[1], + workDoneSettled: results[2], + mapped: results[3], + }), + ); + }, + ); + if (result === null) { + return; + } + expect(result.errorScope).toEqual({ settled: true, hasError: true }); + expect(result.pipeline).toEqual({ settled: true, isPipeline: true }); + expect(result.workDoneSettled).toBe(true); + expect(result.mapped).toEqual({ settled: true, first: 0xc0ffee }); + }); +}); diff --git a/packages/webgpu/src/__tests__/setup.ts b/packages/webgpu/src/__tests__/setup.ts index 9e54054d0..fb1bf9a74 100644 --- a/packages/webgpu/src/__tests__/setup.ts +++ b/packages/webgpu/src/__tests__/setup.ts @@ -106,6 +106,10 @@ interface GPUTestingContext { probeSize: number; pixelRatio: number; }; + // Native app harness only: Skia's Graphite device wrapped with + // importDevice(), or null when Skia has no GPU. See ImportDevice.spec.ts + // and apps/example/src/Tests.tsx. + importSkiaDevice?: () => GPUDevice | null; mat4: typeof mat4; vec3: typeof vec3; mat3: typeof mat3; diff --git a/packages/webgpu/src/importDevice.ts b/packages/webgpu/src/importDevice.ts index 69a2c92da..7feb61668 100644 --- a/packages/webgpu/src/importDevice.ts +++ b/packages/webgpu/src/importDevice.ts @@ -9,8 +9,9 @@ * ``` * * This is only sound when the exporting library links the same single Dawn - * copy as react-native-webgpu and shares its wgpu::Instance (react-native-skia - * does both). + * copy as react-native-webgpu (react-native-skia does). The device stays on + * its owner's Dawn instance and its async operations are pumped there, so the + * two libraries do not have to share an instance. */ export const importDevice = (pointer: bigint): GPUDevice => { if (typeof RNWebGPU === "undefined") { diff --git a/packages/webgpu/src/index.tsx b/packages/webgpu/src/index.tsx index deb0732ec..93eb1242b 100644 --- a/packages/webgpu/src/index.tsx +++ b/packages/webgpu/src/index.tsx @@ -100,7 +100,7 @@ declare global { createVideoFrameFromNativeBuffer: (pointer: bigint) => NativeVideoFrame; // Wrap an externally created WGPUDevice pointer (e.g. Skia's Graphite // device from Skia.getNativeDevice()) into a GPUDevice. Requires the - // exporter to share this process's single Dawn instance. + // exporter to link this process's single Dawn copy. importDevice: (pointer: bigint) => GPUDevice; // Wrap an externally created WGPUTexture pointer into a GPUTexture, // taking ownership of one reference (pair with producers that return a