From 3a56e6258ed630232fdb3bfd5997b94da45f8520 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Wed, 22 Jul 2026 11:20:02 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(=F0=9F=A7=8A):=20WebGPU=20bindings=20im?= =?UTF-8?q?provements=20for=20Graphite=20(#3963)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skia/android/cpp/jni/JniWebGPUView.cpp | 59 +- .../reactnative/skia/WebGPUSurfaceView.java | 4 +- .../reactnative/skia/WebGPUTextureView.java | 16 +- .../shopify/reactnative/skia/WebGPUView.java | 17 +- .../reactnative/skia/WebGPUViewAPI.java | 9 +- .../reactnative/skia/WebGPUViewManager.java | 2 +- packages/skia/apple/WebGPUMetalView.mm | 55 +- packages/skia/cpp/rnskia/RNSkManager.cpp | 6 + packages/skia/cpp/rnwgpu/SurfaceRegistry.h | 600 ++++++++++++++---- packages/skia/cpp/rnwgpu/api/GPUAdapter.cpp | 13 +- packages/skia/cpp/rnwgpu/api/GPUAdapter.h | 9 +- packages/skia/cpp/rnwgpu/api/GPUBuffer.cpp | 14 +- packages/skia/cpp/rnwgpu/api/GPUBuffer.h | 5 +- .../skia/cpp/rnwgpu/api/GPUCanvasContext.cpp | 52 +- .../skia/cpp/rnwgpu/api/GPUCanvasContext.h | 4 +- packages/skia/cpp/rnwgpu/api/GPUDevice.cpp | 49 +- packages/skia/cpp/rnwgpu/api/GPUDevice.h | 16 +- packages/skia/cpp/rnwgpu/api/GPUQueue.cpp | 8 +- packages/skia/cpp/rnwgpu/api/GPUQueue.h | 6 +- .../skia/cpp/rnwgpu/api/GPUShaderModule.cpp | 9 +- .../skia/cpp/rnwgpu/api/GPUShaderModule.h | 6 +- packages/skia/cpp/rnwgpu/api/RNWebGPU.h | 20 + .../skia/cpp/rnwgpu/async/RuntimeContext.cpp | 5 + .../skia/cpp/rnwgpu/async/RuntimeContext.h | 17 +- packages/skia/src/views/WebGPUCanvas.tsx | 18 +- 25 files changed, 791 insertions(+), 228 deletions(-) diff --git a/packages/skia/android/cpp/jni/JniWebGPUView.cpp b/packages/skia/android/cpp/jni/JniWebGPUView.cpp index e586ac13e9..9d94bfbec1 100644 --- a/packages/skia/android/cpp/jni/JniWebGPUView.cpp +++ b/packages/skia/android/cpp/jni/JniWebGPUView.cpp @@ -2,9 +2,35 @@ #include #ifdef SK_GRAPHITE +#include +#include + +#include + #include "rnskia/RNDawnContext.h" #include "rnwgpu/SurfaceRegistry.h" +#include "rnwgpu/async/RuntimeContext.h" #include "webgpu/webgpu_cpp.h" + +namespace { +// Applies a surface attach latched by the platform UI thread (see +// SurfaceInfo::applyPendingAttach) from the JS thread. Surface attaches are +// normally adopted at the next frame boundary by whichever thread renders; +// this flush covers contexts that are not actively rendering (static +// content), so the last offscreen frame still makes it on screen. Mirrors +// react-native-webgpu's RNWebGPUManager::flushPendingSurfaceTransition. +void flushPendingSurfaceTransition(std::shared_ptr info) { + if (info == nullptr) { + return; + } + auto invoker = rnwgpu::async::RuntimeContext::mainCallInvoker(); + if (invoker == nullptr) { + return; + } + invoker->invokeAsync( + [info = std::move(info)] { info->applyPendingAttach(); }); +} +} // namespace #endif extern "C" JNIEXPORT void JNICALL @@ -12,7 +38,12 @@ Java_com_shopify_reactnative_skia_WebGPUView_onSurfaceCreate( JNIEnv *env, jobject thiz, jobject jSurface, jint contextId, jfloat width, jfloat height) { #ifdef SK_GRAPHITE + // ANativeWindow_fromSurface acquires a reference; SurfaceInfo releases it + // (via the releaser below) once it is done with the window. auto window = ANativeWindow_fromSurface(env, jSurface); + if (window == nullptr) { + return; + } auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); auto &dawnContext = RNSkia::DawnContext::getInstance(); auto gpu = dawnContext.getWGPUInstance(); @@ -24,10 +55,17 @@ Java_com_shopify_reactnative_skia_WebGPUView_onSurfaceCreate( surfaceDescriptor.nextInChain = &androidSurfaceDesc; auto surface = gpu.CreateSurface(&surfaceDescriptor); - registry - .getSurfaceInfoOrCreate(contextId, gpu, static_cast(width), - static_cast(height)) - ->switchToOnscreen(window, surface); + // Find-or-create + attach runs atomically under the registry lock so a + // concurrent destroyContext cannot orphan this surface. + auto info = registry.attachSurface( + contextId, gpu, static_cast(width), static_cast(height), window, + surface, [](void *nativeSurface) { + ANativeWindow_release(static_cast(nativeSurface)); + }); + // The attach is adopted at the next frame boundary by the rendering thread; + // schedule a flush so contexts that are not currently rendering still pick + // it up (and present their last offscreen frame). + flushPendingSurfaceTransition(info); #endif } @@ -57,11 +95,18 @@ Java_com_shopify_reactnative_skia_WebGPUView_switchToOffscreenSurface( } extern "C" JNIEXPORT void JNICALL -Java_com_shopify_reactnative_skia_WebGPUView_onSurfaceDestroy(JNIEnv *env, - jobject thiz, - jint contextId) { +Java_com_shopify_reactnative_skia_WebGPUView_onViewDestroyed(JNIEnv *env, + jobject thiz, + jint contextId) { #ifdef SK_GRAPHITE + // The view dies with its Canvas (contextIds are never reused), so view + // teardown retires the registry entry. The JS-side cleanup + // (RNWebGPU.destroyContext) only handles entries that never had a native + // surface; see RNWebGPU::destroyContext for the ownership split. auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); + if (auto info = registry.getSurfaceInfo(contextId)) { + info->detachSurface(); + } registry.removeSurfaceInfo(contextId); #endif } diff --git a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUSurfaceView.java b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUSurfaceView.java index 787bc0ac97..23b6f7008e 100644 --- a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUSurfaceView.java +++ b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUSurfaceView.java @@ -21,7 +21,9 @@ public WebGPUSurfaceView(Context context, WebGPUViewAPI api) { @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); - mApi.surfaceDestroyed(); + // surfaceDestroyed() normally fires during detach as well; going offscreen + // is idempotent, so this is just a safety net for paths where it does not. + mApi.surfaceOffscreen(); } @Override diff --git a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUTextureView.java b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUTextureView.java index 747cd1b210..c2b52d1a2c 100644 --- a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUTextureView.java +++ b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUTextureView.java @@ -11,6 +11,7 @@ public class WebGPUTextureView extends TextureView implements TextureView.SurfaceTextureListener { WebGPUViewAPI mApi; + private Surface mSurface; public WebGPUTextureView(Context context, WebGPUViewAPI api) { super(context); @@ -21,19 +22,24 @@ public WebGPUTextureView(Context context, WebGPUViewAPI api) { @Override public void onSurfaceTextureAvailable(@NonNull SurfaceTexture surfaceTexture, int width, int height) { - Surface surface = new Surface(surfaceTexture); - mApi.surfaceCreated(surface); + mSurface = new Surface(surfaceTexture); + mApi.surfaceCreated(mSurface); } @Override public void onSurfaceTextureSizeChanged(@NonNull SurfaceTexture surfaceTexture, int width, int height) { - Surface surface = new Surface(surfaceTexture); - mApi.surfaceChanged(surface); + mApi.surfaceChanged(mSurface); } @Override public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture surfaceTexture) { - mApi.surfaceDestroyed(); + // Detach first (synchronous through JNI) so the native side has dropped + // its window reference before we release ours. + mApi.surfaceOffscreen(); + if (mSurface != null) { + mSurface.release(); + mSurface = null; + } return true; } diff --git a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUView.java b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUView.java index b8f75d3ca2..b4cb1330b5 100644 --- a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUView.java +++ b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUView.java @@ -61,16 +61,19 @@ public void surfaceChanged(Surface surface) { onSurfaceChanged(surface, mContextId, width, height); } - @Override - public void surfaceDestroyed() { - onSurfaceDestroy(mContextId); - } - @Override public void surfaceOffscreen() { switchToOffscreenSurface(mContextId); } + /** + * Called from WebGPUViewManager.onDropViewInstance when React removes this + * view: the view dies with its Canvas, so it retires the registry entry. + */ + public void destroy() { + onViewDestroyed(mContextId); + } + @DoNotStrip private native void onSurfaceCreate( Surface surface, @@ -88,8 +91,8 @@ private native void onSurfaceChanged( ); @DoNotStrip - private native void onSurfaceDestroy(int contextId); + private native void switchToOffscreenSurface(int contextId); @DoNotStrip - private native void switchToOffscreenSurface(int contextId); + private native void onViewDestroyed(int contextId); } diff --git a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUViewAPI.java b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUViewAPI.java index 897a8bcb52..76a8338170 100644 --- a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUViewAPI.java +++ b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUViewAPI.java @@ -2,13 +2,18 @@ import android.view.Surface; +/** + * Surface lifecycle events a WebGPU child view reports. The registry entry + * itself is owned by the JS Canvas component (created lazily, removed via + * RNWebGPU.destroyContext on unmount); views only attach and detach surfaces. + * A detached context keeps rendering into an offscreen texture whose content + * is blitted onto the next attached surface. + */ public interface WebGPUViewAPI { void surfaceCreated(Surface surface); void surfaceChanged(Surface surface); - void surfaceDestroyed(); - void surfaceOffscreen(); } diff --git a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUViewManager.java b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUViewManager.java index 3ca301a9c8..df3fc2f720 100644 --- a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUViewManager.java +++ b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/WebGPUViewManager.java @@ -53,6 +53,6 @@ public void setContextId(WebGPUView view, int value) { @Override public void onDropViewInstance(@NonNull ReactViewGroup view) { super.onDropViewInstance(view); - ((WebGPUView) view).surfaceDestroyed(); + ((WebGPUView) view).destroy(); } } diff --git a/packages/skia/apple/WebGPUMetalView.mm b/packages/skia/apple/WebGPUMetalView.mm index 0e4d119f66..ad31c7013b 100644 --- a/packages/skia/apple/WebGPUMetalView.mm +++ b/packages/skia/apple/WebGPUMetalView.mm @@ -5,8 +5,30 @@ #import "webgpu/webgpu_cpp.h" #import +#import + #import "rnskia/RNDawnContext.h" #import "rnwgpu/SurfaceRegistry.h" +#import "rnwgpu/async/RuntimeContext.h" + +namespace { +// Applies a surface attach latched by the platform UI thread (see +// SurfaceInfo::applyPendingAttach) from the JS thread. Surface attaches are +// normally adopted at the next frame boundary by whichever thread renders; +// this flush covers contexts that are not actively rendering (static +// content), so the last offscreen frame still makes it on screen. Mirrors +// react-native-webgpu's RNWebGPUManager::flushPendingSurfaceTransition. +void flushPendingSurfaceTransition(std::shared_ptr info) { + if (info == nullptr) { + return; + } + auto invoker = rnwgpu::async::RuntimeContext::mainCallInvoker(); + if (invoker == nullptr) { + return; + } + invoker->invokeAsync([info = std::move(info)] { info->applyPendingAttach(); }); +} +} // namespace @implementation WebGPUMetalView { BOOL _isConfigured; @@ -29,7 +51,11 @@ - (instancetype)init { - (void)configure { auto size = self.frame.size; - void *nativeSurface = (__bridge void *)self.layer; + // Retain the layer for as long as SurfaceInfo holds the pointer: the + // latched attach (and the flush lambda that adopts it) can outlive this + // view, e.g. across a dev reload where the registry is cleared before + // dealloc runs. Balanced by the releaser below. + void *nativeSurface = (void *)CFBridgingRetain(self.layer); auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); auto &dawnContext = RNSkia::DawnContext::getInstance(); auto gpu = dawnContext.getWGPUInstance(); @@ -41,10 +67,21 @@ - (void)configure { surfaceDescriptor.nextInChain = &metalSurfaceDesc; auto surface = gpu.CreateSurface(&surfaceDescriptor); - registry - .getSurfaceInfoOrCreate([_contextId intValue], gpu, size.width, - size.height) - ->switchToOnscreen(nativeSurface, surface); + // Find-or-create + attach runs atomically under the registry lock so a + // concurrent destroyContext cannot orphan this surface. + auto info = registry.attachSurface( + [_contextId intValue], gpu, size.width, size.height, nativeSurface, + surface, [](void *layer) { + // The releaser can run on the rendering thread; CALayer teardown + // belongs on the main thread. + dispatch_async(dispatch_get_main_queue(), ^{ + CFBridgingRelease(layer); + }); + }); + // The attach is adopted at the next frame boundary by the rendering thread; + // schedule a flush so contexts that are not currently rendering still pick + // it up (and present their last offscreen frame). + flushPendingSurfaceTransition(info); } - (void)update { @@ -57,8 +94,14 @@ - (void)update { } - (void)dealloc { + // The view dies with its Canvas (contextIds are never reused), so view + // teardown retires the registry entry. The JS-side cleanup + // (RNWebGPU.destroyContext) only handles entries that never had a native + // surface; see RNWebGPU::destroyContext for the ownership split. auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); - // Remove the surface info from the registry + if (auto info = registry.getSurfaceInfo([_contextId intValue])) { + info->detachSurface(); + } registry.removeSurfaceInfo([_contextId intValue]); } diff --git a/packages/skia/cpp/rnskia/RNSkManager.cpp b/packages/skia/cpp/rnskia/RNSkManager.cpp index 03b51a0246..dda43c14fd 100644 --- a/packages/skia/cpp/rnskia/RNSkManager.cpp +++ b/packages/skia/cpp/rnskia/RNSkManager.cpp @@ -51,6 +51,12 @@ RNSkManager::RNSkManager( } RNSkManager::~RNSkManager() { +#ifdef SK_GRAPHITE + // Drop all canvas registry entries: after a reload the JS side restarts its + // contextId counter, and stale entries would alias new canvases onto dead + // surfaces. + rnwgpu::SurfaceRegistry::getInstance().clear(); +#endif // Free up any references _viewApi = nullptr; _jsRuntime = nullptr; diff --git a/packages/skia/cpp/rnwgpu/SurfaceRegistry.h b/packages/skia/cpp/rnwgpu/SurfaceRegistry.h index 3a3758f61d..72affffb2b 100644 --- a/packages/skia/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/skia/cpp/rnwgpu/SurfaceRegistry.h @@ -1,9 +1,14 @@ #pragma once +#include +#include #include +#include #include +#include #include #include +#include #include "webgpu/webgpu_cpp.h" @@ -28,190 +33,487 @@ struct Size { int height; }; +// Invoked with the platform's native surface pointer once SurfaceInfo is done +// with it, so the platform can drop the reference it acquired on our behalf +// (ANativeWindow_release on Android, CFBridgingRelease of the retained +// CAMetalLayer on Apple platforms). May run on any thread. +using NativeSurfaceReleaser = std::function; + +// Bridges the asynchronous native surface lifecycle (surfaces appear and +// disappear on the platform UI thread) with the synchronous WebGPU canvas API +// (the JS render loop must always be able to acquire a texture). +// +// Ownership & threading model: +// - A registry entry is created on first use (by whichever of JS/native gets +// there first) and lives exactly as long as its JS Canvas: contextIds are +// never reused. It is removed by the native view's teardown (WebGPUMetalView +// dealloc / WebGPUViewManager.onDropViewInstance) when a surface is +// attached, or by RNWebGPU.destroyContext (the Canvas unmount cleanup) when +// none ever was — see RNWebGPU::destroyContext for why the split. Surface +// destruction alone (backgrounding, TextureView teardown) never removes an +// entry; it only detaches the surface. +// - Attaching a surface is LATCHED: the UI thread stores it as pending +// (attachSurface) and it is adopted at the next frame boundary — start of +// getCurrentTexture or end of presentFrame — on whichever thread renders +// (main JS, Reanimated UI, or a worklet runtime). This preserves Dawn +// surface thread-affinity and guarantees a surface is never swapped in the +// middle of a frame. For contexts that are not actively rendering, the +// platform view schedules applyPendingAttach on the JS thread instead (see +// flushPendingSurfaceTransition in WebGPUMetalView / JniWebGPUView). +// - Detaching (switchToOffscreen) is IMMEDIATE, because the platform destroys +// the surface as soon as its callback returns. A configured context falls +// back to rendering into an offscreen texture, so a running render loop +// keeps working; the in-flight frame, if any, is dropped at present(). When +// a new surface attaches, the latest offscreen frame is blitted onto it so +// content appears without waiting for the next render — the same mechanism +// that gives a fast time-to-first-frame when rendering starts before the +// native surface exists. class SurfaceInfo { public: SurfaceInfo(wgpu::Instance gpu, int width, int height) - : gpu(std::move(gpu)), width(width), height(height) {} + : _gpu(std::move(gpu)), _width(width), _height(height) {} + + ~SurfaceInfo() { + // Drop the Dawn objects before releasing the native surfaces they borrow. + _surface = nullptr; + _pendingSurface = nullptr; + _texture = nullptr; + if (_pendingReleaser && _pendingNativeSurface) { + _pendingReleaser(_pendingNativeSurface); + } + if (_releaser && _nativeSurface) { + _releaser(_nativeSurface); + } + } - ~SurfaceInfo() { surface = nullptr; } + // --- Platform UI thread --------------------------------------------------- - void reconfigure(int newWidth, int newHeight) { - std::unique_lock lock(_mutex); - config.width = newWidth; - config.height = newHeight; - _configure(); + // Store a newly created on-screen surface. It becomes active at the next + // frame boundary (applyPendingAttach); callers should follow up with + // flushPendingSurfaceTransition so contexts that are not currently rendering + // also pick it up. + void attachSurface(void *nativeSurface, wgpu::Surface surface, + NativeSurfaceReleaser releaser) { + void *replacedSurface = nullptr; + NativeSurfaceReleaser replacedReleaser; + { + std::unique_lock lock(_mutex); + if (_hasPendingAttach) { + // Replaced before it was ever adopted. + replacedSurface = _pendingNativeSurface; + replacedReleaser = std::move(_pendingReleaser); + } + _hasPendingAttach = true; + _pendingNativeSurface = nativeSurface; + _pendingSurface = std::move(surface); + _pendingReleaser = std::move(releaser); + } + if (replacedReleaser && replacedSurface) { + replacedReleaser(replacedSurface); + } } - void configure(wgpu::SurfaceConfiguration &newConfig) { + // The platform surface is being destroyed: detach immediately. If the + // context is configured, rendering continues into an offscreen texture whose + // content is blitted to the next attached surface; present() no-ops until + // then. Safe to call when already offscreen. + void switchToOffscreen() { detach(/* createFallbackTexture = */ true); } + + // Detach without creating the offscreen fallback: used when the context is + // being destroyed and nothing will consume further frames. + void detachSurface() { detach(/* createFallbackTexture = */ false); } + + // Reflects native view layout changes. Does not resize the drawing buffer: + // that tracks canvas.width/height (like on the web), see + // GPUCanvasContext::getCurrentTexture. + void resize(int newWidth, int newHeight) { std::unique_lock lock(_mutex); - config = newConfig; - config.width = width; - config.height = height; - config.presentMode = wgpu::PresentMode::Fifo; - _configure(); + _width = newWidth; + _height = newHeight; } - void unconfigure() { - std::unique_lock lock(_mutex); - if (surface) { - surface.Unconfigure(); - } else { - texture = nullptr; + // --- Frame boundary (rendering thread, or the JS thread via + // flushPendingSurfaceTransition) ------------------------------------------- + + // Adopt a pending surface if no frame is in flight: configure it and, if + // frames were rendered offscreen, blit the most recent one onto it and + // present it, so content shows up without waiting for the render loop. + // Safe to call from any thread; no-ops when there is nothing pending. + // + // supersedeInFlightFrame is set by the rendering thread when it starts a new + // frame: a previous frame that never presented is abandoned and must not + // block adoption. The flush path (other threads) leaves it false so it never + // swaps the surface under a frame that is genuinely in flight. + void applyPendingAttach(bool supersedeInFlightFrame = false) { + bool presentBlit = false; + uint64_t blitEpoch = 0; + wgpu::Device device = nullptr; + void *replacedSurface = nullptr; + NativeSurfaceReleaser replacedReleaser; + { + std::unique_lock lock(_mutex); + if (supersedeInFlightFrame) { + _frameInFlight = false; + _acquiredFromSurface = false; + } + if (!_hasPendingAttach || _frameInFlight) { + return; + } + // Attach over attach without a detach in between: replace. Ownership + // tracks the native window pointer, not the Dawn surface handle (which + // can be null if surface creation failed). + replacedSurface = _nativeSurface; + replacedReleaser = std::move(_releaser); + _surface = std::move(_pendingSurface); + _nativeSurface = _pendingNativeSurface; + _releaser = std::move(_pendingReleaser); + _hasPendingAttach = false; + _pendingNativeSurface = nullptr; + _frameEpoch++; + + // _surface can be null here when Dawn surface creation failed for a + // valid native window; the context then just keeps rendering offscreen. + if (_config.device != nullptr && _surface) { + bool blit = _texture != nullptr; + // The blit needs CopyDst on the surface. Configure with a widened + // copy while keeping _config at the usage the user asked for, so any + // later reconfigure drops the extra flag again. + wgpu::SurfaceConfiguration config = _config; + if (blit) { + config.usage |= wgpu::TextureUsage::CopyDst; + } + _surface.Configure(&config); +#ifdef __APPLE__ + RNSkia::applyCAMetalLayerColorSpace(_nativeSurface, _config.format); +#endif + if (blit) { + presentBlit = blitOffscreenToSurfaceLocked(); + device = _config.device; + // Consumed either way; on failure the next frame renders fresh. + _texture = nullptr; + blitEpoch = _frameEpoch; + } + } } + if (replacedReleaser && replacedSurface) { + replacedReleaser(replacedSurface); + } + if (presentBlit) { +#ifdef __APPLE__ + if (device) { + dawn::native::metal::WaitForCommandsToBeScheduled(device.Get()); + } +#endif + std::unique_lock lock(_mutex); + // Present only if the blitted texture is still the surface's current + // one. The epoch changes on any acquire, present, configure, detach, or + // adoption, so a frame that started - even one that already completed - + // or any other transition while we were unlocked skips this present + // (their newer content stands; presenting here would be a Dawn + // present-without-acquire error). + if (_surface && !_frameInFlight && _frameEpoch == blitEpoch) { + _surface.Present(); + } + } + } + + // --- Rendering thread + // ------------------------------------------------------- + + void configure(wgpu::SurfaceConfiguration &newConfig, + std::vector viewFormats) { + applyPendingAttach(/* supersedeInFlightFrame = */ true); + std::unique_lock lock(_mutex); + _viewFormats = std::move(viewFormats); + _config = newConfig; + // The caller's viewFormats storage dies with the call; point the stored + // configuration at our own copy. + _config.viewFormats = _viewFormats.empty() ? nullptr : _viewFormats.data(); + _config.viewFormatCount = _viewFormats.size(); + // The drawing buffer starts at the canvas size. Clamp so a canvas that has + // not been laid out yet (0x0) configures instead of erroring. + _config.width = std::max(1, _width); + _config.height = std::max(1, _height); + _config.presentMode = wgpu::PresentMode::Fifo; + _texture = nullptr; + _frameEpoch++; + _configureLocked(); } - void *switchToOffscreen() { + // Resize the drawing buffer (canvas.width/height changed). + void reconfigure(int newWidth, int newHeight) { std::unique_lock lock(_mutex); - // We only do this if the onscreen surface is configured. - auto isConfigured = config.device != nullptr; - if (isConfigured) { - wgpu::TextureDescriptor textureDesc; - textureDesc.usage = wgpu::TextureUsage::RenderAttachment | - wgpu::TextureUsage::CopySrc | - wgpu::TextureUsage::TextureBinding; - textureDesc.format = config.format; - textureDesc.size.width = config.width; - textureDesc.size.height = config.height; - texture = config.device.CreateTexture(&textureDesc); + if (_config.device == nullptr) { + return; } - surface = nullptr; - return nativeSurface; + _config.width = std::max(1, newWidth); + _config.height = std::max(1, newHeight); + _texture = nullptr; + _frameEpoch++; + _configureLocked(); } - void switchToOnscreen(void *newNativeSurface, wgpu::Surface newSurface) { + void unconfigure() { std::unique_lock lock(_mutex); - nativeSurface = newNativeSurface; - surface = std::move(newSurface); - // If we are comming from an offscreen context, we need to configure the new - // surface - if (texture != nullptr) { - config.usage = config.usage | wgpu::TextureUsage::CopyDst; - _configure(); - // We flush the offscreen texture to the onscreen one - wgpu::CommandEncoderDescriptor encoderDesc; - auto device = config.device; - wgpu::CommandEncoder encoder = device.CreateCommandEncoder(&encoderDesc); - - wgpu::TexelCopyTextureInfo sourceTexture = {}; - sourceTexture.texture = texture; - - wgpu::TexelCopyTextureInfo destinationTexture = {}; - wgpu::SurfaceTexture surfaceTexture; - surface.GetCurrentTexture(&surfaceTexture); - destinationTexture.texture = surfaceTexture.texture; - - wgpu::Extent3D size = {sourceTexture.texture.GetWidth(), - sourceTexture.texture.GetHeight(), - sourceTexture.texture.GetDepthOrArrayLayers()}; - - encoder.CopyTextureToTexture(&sourceTexture, &destinationTexture, &size); - - wgpu::CommandBuffer commands = encoder.Finish(); - wgpu::Queue queue = device.GetQueue(); - queue.Submit(1, &commands); - surface.Present(); - texture = nullptr; + if (_surface) { + _surface.Unconfigure(); } + _texture = nullptr; + _config = {}; + _viewFormats.clear(); + _acquiredFromSurface = false; + _frameEpoch++; } - void resize(int newWidth, int newHeight) { + bool isConfigured() { + std::shared_lock lock(_mutex); + return _config.device != nullptr; + } + + // True while a native view owns a surface for this context (attached or + // pending adoption). Used to decide which side retires the registry entry: + // the native view's teardown when a surface exists, the JS Canvas cleanup + // otherwise (see RNWebGPU::destroyContext). + bool hasNativeSurface() { + std::shared_lock lock(_mutex); + return _nativeSurface != nullptr || _hasPendingAttach; + } + + // Returns the texture for the current frame: the surface's swapchain texture + // when a surface is attached and healthy, an offscreen texture otherwise. + // Never returns null; throws when called before configure(). + wgpu::Texture getCurrentTexture() { + // Start-of-frame boundary; a new acquire supersedes any previous frame + // that never presented. + applyPendingAttach(/* supersedeInFlightFrame = */ true); std::unique_lock lock(_mutex); - width = newWidth; - height = newHeight; + if (_config.device == nullptr) { + throw std::runtime_error( + "[WebGPU] getCurrentTexture() called on a canvas context that is " + "not configured; call context.configure() first"); + } + _frameInFlight = true; + _acquiredFromSurface = false; + _frameEpoch++; + if (_surface) { + auto texture = acquireSurfaceTextureLocked(); + if (texture) { + _acquiredFromSurface = true; + return texture; + } + // The surface is transiently unusable (e.g. mid-resize, lost while + // backgrounding): fall back to an offscreen texture so the render loop + // survives; this frame is simply not presented. + } + if (!_texture) { + _texture = createOffscreenTextureLocked(); + } + return _texture; } - // Present the current surface texture. Called synchronously from the thread - // that did getCurrentTexture / submit (via GPUCanvasContext::present), so it - // preserves Dawn surface thread-affinity. No-op when offscreen / unconfigured - // (no surface). + // Present the current frame. Runs synchronously on the thread that did + // getCurrentTexture/submit (main JS, Reanimated UI, or a worklet runtime), + // preserving Dawn surface thread-affinity. Frames whose texture was not + // acquired from the attached surface (offscreen, detached mid-frame, or + // acquire failure) are dropped. This is also the end-of-frame boundary: it + // adopts a surface that attached while the frame was in flight. void presentFrame() { #ifdef __APPLE__ // Ensure command buffers are scheduled before presenting. Read the device // under a shared lock, then wait without holding it (the wait can block). - // The device may be reconfigured between the two locks; that is safe - // because present() is called on the rendering thread right after submit(), - // the wait just flushes that thread's already-submitted work, and the - // Present() below re-checks `surface` under the unique lock before touching - // it. wgpu::Device device; { std::shared_lock lock(_mutex); - device = config.device; + device = _config.device; } if (device) { dawn::native::metal::WaitForCommandsToBeScheduled(device.Get()); } #endif - std::unique_lock lock(_mutex); - if (surface) { - surface.Present(); + { + std::unique_lock lock(_mutex); + if (_surface && _acquiredFromSurface) { + _surface.Present(); + } + _acquiredFromSurface = false; + _frameInFlight = false; + _frameEpoch++; } + applyPendingAttach(); } - // True when an on-screen wgpu::Surface is attached (vs offscreen texture). - bool hasSurface() { + NativeInfo getNativeInfo() { std::shared_lock lock(_mutex); - return surface != nullptr; + // A surface that is still pending adoption is the one callers should see. + void *native = _hasPendingAttach ? _pendingNativeSurface : _nativeSurface; + return {.nativeSurface = native, .width = _width, .height = _height}; } - wgpu::Texture getCurrentTexture() { + Size getSize() { std::shared_lock lock(_mutex); - if (surface) { - wgpu::SurfaceTexture surfaceTexture; - surface.GetCurrentTexture(&surfaceTexture); - return surfaceTexture.texture; - } else { - return texture; - } + return {.width = _width, .height = _height}; } - NativeInfo getNativeInfo() { + wgpu::SurfaceConfiguration getConfig() { std::shared_lock lock(_mutex); - return {.nativeSurface = nativeSurface, .width = width, .height = height}; + return _config; } - Size getSize() { - std::shared_lock lock(_mutex); - return {.width = width, .height = height}; +private: + void detach(bool createFallbackTexture) { + void *releasedSurfaces[2] = {nullptr, nullptr}; + NativeSurfaceReleaser releasers[2]; + { + std::unique_lock lock(_mutex); + // The platform is tearing surfaces down; a not-yet-adopted attach is + // stale, cancel it. + if (_hasPendingAttach) { + _hasPendingAttach = false; + _pendingSurface = nullptr; + releasedSurfaces[0] = _pendingNativeSurface; + releasers[0] = std::move(_pendingReleaser); + _pendingNativeSurface = nullptr; + } + if (_surface) { + if (createFallbackTexture && _config.device != nullptr) { + _texture = createOffscreenTextureLocked(); + } + _surface = nullptr; + // The in-flight frame (if any) rendered into the destroyed surface; + // presentFrame() must not present it. + _acquiredFromSurface = false; + } + _frameEpoch++; + // Window ownership is independent of the Dawn surface handle (which can + // be null if surface creation failed): always return the window. + releasedSurfaces[1] = _nativeSurface; + releasers[1] = std::move(_releaser); + _nativeSurface = nullptr; + } + // Release outside the lock: the platform may do real work here. + for (int i = 0; i < 2; i++) { + if (releasers[i] && releasedSurfaces[i]) { + releasers[i](releasedSurfaces[i]); + } + } } - wgpu::SurfaceConfiguration getConfig() { - std::shared_lock lock(_mutex); - return config; + // All *Locked helpers below require _mutex to be held exclusively. + + wgpu::Texture createOffscreenTextureLocked() { + wgpu::TextureDescriptor descriptor; + // Union with the user's usage so offscreen frames stay compatible with + // whatever they configured (e.g. CopySrc readbacks). RenderAttachment | + // CopySrc | TextureBinding is what the fallback itself needs (rendering, + // the attach blit, sampling). + descriptor.usage = _config.usage | wgpu::TextureUsage::RenderAttachment | + wgpu::TextureUsage::CopySrc | + wgpu::TextureUsage::TextureBinding; + descriptor.format = _config.format; + descriptor.size.width = std::max(1u, _config.width); + descriptor.size.height = std::max(1u, _config.height); + descriptor.viewFormats = _config.viewFormats; + descriptor.viewFormatCount = _config.viewFormatCount; + return _config.device.CreateTexture(&descriptor); } - wgpu::Device getDevice() { - std::shared_lock lock(_mutex); - return config.device; + // Acquire the surface's current texture, reconfiguring once when the surface + // reports it is stale (rotation, resize, coming back from background). + wgpu::Texture acquireSurfaceTextureLocked() { + wgpu::SurfaceTexture surfaceTexture; + _surface.GetCurrentTexture(&surfaceTexture); + if (!isAcquireSuccess(surfaceTexture)) { + if (surfaceTexture.status == + wgpu::SurfaceGetCurrentTextureStatus::Error) { + return nullptr; + } + _surface.Configure(&_config); + _surface.GetCurrentTexture(&surfaceTexture); + if (!isAcquireSuccess(surfaceTexture)) { + return nullptr; + } + } + return surfaceTexture.texture; } -private: - void _configure() { - if (surface) { - surface.Configure(&config); + static bool isAcquireSuccess(const wgpu::SurfaceTexture &surfaceTexture) { + return (surfaceTexture.status == + wgpu::SurfaceGetCurrentTextureStatus::SuccessOptimal || + surfaceTexture.status == + wgpu::SurfaceGetCurrentTextureStatus::SuccessSuboptimal) && + surfaceTexture.texture != nullptr; + } + + // Copy the last offscreen frame onto the freshly attached surface. Returns + // true when the copy was submitted and the surface should be presented. + bool blitOffscreenToSurfaceLocked() { + wgpu::SurfaceTexture surfaceTexture; + _surface.GetCurrentTexture(&surfaceTexture); + if (!isAcquireSuccess(surfaceTexture)) { + return false; + } + + wgpu::TexelCopyTextureInfo source = {}; + source.texture = _texture; + wgpu::TexelCopyTextureInfo destination = {}; + destination.texture = surfaceTexture.texture; + + // The offscreen frame and the new surface can disagree on size (e.g. the + // device rotated while detached); copy the shared region. + wgpu::Extent3D size = { + std::min(_texture.GetWidth(), surfaceTexture.texture.GetWidth()), + std::min(_texture.GetHeight(), surfaceTexture.texture.GetHeight()), 1}; + + wgpu::CommandEncoderDescriptor encoderDescriptor; + wgpu::CommandEncoder encoder = + _config.device.CreateCommandEncoder(&encoderDescriptor); + encoder.CopyTextureToTexture(&source, &destination, &size); + wgpu::CommandBuffer commands = encoder.Finish(); + _config.device.GetQueue().Submit(1, &commands); + return true; + } + + void _configureLocked() { + if (_surface) { + _surface.Configure(&_config); #ifdef __APPLE__ - RNSkia::applyCAMetalLayerColorSpace(nativeSurface, config.format); + RNSkia::applyCAMetalLayerColorSpace(_nativeSurface, _config.format); #endif } else { - wgpu::TextureDescriptor textureDesc; - textureDesc.format = config.format; - textureDesc.size.width = config.width; - textureDesc.size.height = config.height; - textureDesc.usage = wgpu::TextureUsage::RenderAttachment | - wgpu::TextureUsage::CopySrc | - wgpu::TextureUsage::TextureBinding; - texture = config.device.CreateTexture(&textureDesc); + _texture = createOffscreenTextureLocked(); } } mutable std::shared_mutex _mutex; - void *nativeSurface = nullptr; - wgpu::Surface surface = nullptr; - wgpu::Texture texture = nullptr; - wgpu::Instance gpu; - wgpu::SurfaceConfiguration config; - int width; - int height; + // Attached on-screen surface (null while offscreen). + void *_nativeSurface = nullptr; + wgpu::Surface _surface = nullptr; + NativeSurfaceReleaser _releaser; + // Offscreen fallback drawing buffer. + wgpu::Texture _texture = nullptr; + // Surface attached by the UI thread, awaiting adoption at a frame boundary. + bool _hasPendingAttach = false; + void *_pendingNativeSurface = nullptr; + wgpu::Surface _pendingSurface = nullptr; + NativeSurfaceReleaser _pendingReleaser; + // Frame state: set by getCurrentTexture, cleared by presentFrame. + bool _frameInFlight = false; + bool _acquiredFromSurface = false; + // Bumped on every acquire, present, configure/reconfigure/unconfigure, + // adoption, and detach. The deferred blit-present in applyPendingAttach + // revalidates against it so it never presents a texture that stopped being + // the surface's current one while the lock was released. + uint64_t _frameEpoch = 0; + // device == nullptr means "not configured". _viewFormats owns the storage + // that _config.viewFormats points at. + wgpu::SurfaceConfiguration _config; + std::vector _viewFormats; + // Keeps the Dawn instance alive for as long as any canvas exists. + wgpu::Instance _gpu; + // Native view size in dp (surfaced as clientWidth/clientHeight on the JS + // canvas). + int _width; + int _height; }; class SurfaceRegistry { @@ -238,18 +540,56 @@ class SurfaceRegistry { _registry.erase(id); } - std::shared_ptr addSurfaceInfo(int id, wgpu::Instance gpu, - int width, int height) { + std::shared_ptr + getSurfaceInfoOrCreate(int id, wgpu::Instance gpu, int width, int height) { std::unique_lock lock(_mutex); - auto info = std::make_shared(gpu, width, height); - _registry[id] = info; + return getSurfaceInfoOrCreateLocked(id, gpu, width, height); + } + + // Find-or-create + attach as one atomic step under the registry lock, so it + // serializes with removeSurfaceInfoIfDetached: an attach can never land on + // an entry that a concurrent destroyContext is erasing (it either marks the + // entry attached before the check, or re-creates the entry after the + // erase). Lock order is registry -> SurfaceInfo, matching every other path. + std::shared_ptr attachSurface(int id, wgpu::Instance gpu, + int width, int height, + void *nativeSurface, + wgpu::Surface surface, + NativeSurfaceReleaser releaser) { + std::unique_lock lock(_mutex); + auto info = getSurfaceInfoOrCreateLocked(id, gpu, width, height); + info->attachSurface(nativeSurface, std::move(surface), std::move(releaser)); return info; } - std::shared_ptr - getSurfaceInfoOrCreate(int id, wgpu::Instance gpu, int width, int height) { + // Erase the entry only if no native surface is attached or pending; the + // atomic counterpart of attachSurface above (see RNWebGPU::destroyContext + // for the ownership split this implements). + void removeSurfaceInfoIfDetached(int id) { std::unique_lock lock(_mutex); auto it = _registry.find(id); + if (it == _registry.end() || it->second->hasNativeSurface()) { + return; + } + _registry.erase(it); + } + + // Drops all entries. Called when the RN instance tears down (dev reload): + // JS context ids restart from scratch, so surviving entries would alias new + // canvases onto dead surfaces. + void clear() { + std::unique_lock lock(_mutex); + _registry.clear(); + } + +private: + SurfaceRegistry() = default; + + std::shared_ptr getSurfaceInfoOrCreateLocked(int id, + wgpu::Instance gpu, + int width, + int height) { + auto it = _registry.find(id); if (it != _registry.end()) { return it->second; } @@ -258,8 +598,6 @@ class SurfaceRegistry { return info; } -private: - SurfaceRegistry() = default; mutable std::shared_mutex _mutex; std::unordered_map> _registry; }; diff --git a/packages/skia/cpp/rnwgpu/api/GPUAdapter.cpp b/packages/skia/cpp/rnwgpu/api/GPUAdapter.cpp index 1e6c5645c3..ba724338de 100644 --- a/packages/skia/cpp/rnwgpu/api/GPUAdapter.cpp +++ b/packages/skia/cpp/rnwgpu/api/GPUAdapter.cpp @@ -18,6 +18,7 @@ namespace rnwgpu { async::AsyncTaskHandle GPUAdapter::requestDevice( + jsi::Runtime &runtime, std::optional> descriptor) { wgpu::DeviceDescriptor aDescriptor; Convertor conv; @@ -104,9 +105,15 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( descriptor.has_value() ? descriptor.value()->label.value_or("") : ""; auto creationRuntime = getCreationRuntime(); - return _async->postTask( + // Post to the CALLING runtime's context so the promise settles on the + // thread that requested it (see GPUBuffer::mapAsync). The GPUDevice is also + // bound to this context, honoring the contract that a device belongs to the + // runtime that requested it. + auto context = + async::RuntimeContext::getOrCreate(runtime, _async->instance()); + return context->postTask( [this, aDescriptor, descriptor, label = std::move(label), - deviceLostBinding, + deviceLostBinding, context, creationRuntime](const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction &reject) { // Build a local mutable copy so we can chain Dawn's device toggles. @@ -138,7 +145,7 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( } _instance.RequestDevice( &deviceDesc, wgpu::CallbackMode::AllowProcessEvents, - [context = _async, resolve, reject, label, creationRuntime, + [context, resolve, reject, label, creationRuntime, deviceLostBinding](wgpu::RequestDeviceStatus status, wgpu::Device device, wgpu::StringView message) { diff --git a/packages/skia/cpp/rnwgpu/api/GPUAdapter.h b/packages/skia/cpp/rnwgpu/api/GPUAdapter.h index 327c76419a..8c56eb9205 100644 --- a/packages/skia/cpp/rnwgpu/api/GPUAdapter.h +++ b/packages/skia/cpp/rnwgpu/api/GPUAdapter.h @@ -33,8 +33,9 @@ class GPUAdapter : public NativeObject { public: std::string getBrand() { return CLASS_NAME; } - async::AsyncTaskHandle - requestDevice(std::optional> descriptor); + async::AsyncTaskHandle requestDevice( + jsi::Runtime &runtime, + std::optional> descriptor); std::unordered_set getFeatures(); std::shared_ptr getLimits(); @@ -42,8 +43,8 @@ class GPUAdapter : public NativeObject { static void definePrototype(jsi::Runtime &runtime, jsi::Object &prototype) { installGetter(runtime, prototype, "__brand", &GPUAdapter::getBrand); - installMethod(runtime, prototype, "requestDevice", - &GPUAdapter::requestDevice); + installMethodWithRuntime(runtime, prototype, "requestDevice", + &GPUAdapter::requestDevice); installGetter(runtime, prototype, "features", &GPUAdapter::getFeatures); installGetter(runtime, prototype, "limits", &GPUAdapter::getLimits); installGetter(runtime, prototype, "info", &GPUAdapter::getInfo); diff --git a/packages/skia/cpp/rnwgpu/api/GPUBuffer.cpp b/packages/skia/cpp/rnwgpu/api/GPUBuffer.cpp index ae93dcaac2..54b8aa1021 100644 --- a/packages/skia/cpp/rnwgpu/api/GPUBuffer.cpp +++ b/packages/skia/cpp/rnwgpu/api/GPUBuffer.cpp @@ -37,7 +37,8 @@ GPUBuffer::getMappedRange(std::optional o, std::optional size) { void GPUBuffer::destroy() { _instance.Destroy(); } -async::AsyncTaskHandle GPUBuffer::mapAsync(uint64_t modeIn, +async::AsyncTaskHandle GPUBuffer::mapAsync(jsi::Runtime &runtime, + uint64_t modeIn, std::optional offset, std::optional size) { Convertor conv; @@ -51,7 +52,16 @@ async::AsyncTaskHandle GPUBuffer::mapAsync(uint64_t modeIn, auto bufferHandle = _instance; uint64_t resolvedOffset = offset.value_or(0); - return _async->postTask( + // Post to the CALLING runtime's context, not the one captured at buffer + // creation (_async): the buffer may have been created on another runtime and + // boxed across (e.g. device created on the main JS runtime, mapAsync called + // from a worklet). The returned Promise lives on the calling runtime, so it + // must be settled from that runtime's own thread — and postTask itself + // schedules the pump through its context's runtime (setTimeout), which is + // only safe for the runtime we are currently executing on. + auto context = + async::RuntimeContext::getOrCreate(runtime, _async->instance()); + return context->postTask( [bufferHandle, mode, resolvedOffset, rangeSize](const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction &reject) { diff --git a/packages/skia/cpp/rnwgpu/api/GPUBuffer.h b/packages/skia/cpp/rnwgpu/api/GPUBuffer.h index c07504bcf5..b0ed1e95d7 100644 --- a/packages/skia/cpp/rnwgpu/api/GPUBuffer.h +++ b/packages/skia/cpp/rnwgpu/api/GPUBuffer.h @@ -33,7 +33,7 @@ class GPUBuffer : public NativeObject { public: std::string getBrand() { return CLASS_NAME; } - async::AsyncTaskHandle mapAsync(uint64_t modeIn, + async::AsyncTaskHandle mapAsync(jsi::Runtime &runtime, uint64_t modeIn, std::optional offset, std::optional size); std::shared_ptr getMappedRange(std::optional offset, @@ -53,7 +53,8 @@ class GPUBuffer : public NativeObject { static void definePrototype(jsi::Runtime &runtime, jsi::Object &prototype) { installGetter(runtime, prototype, "__brand", &GPUBuffer::getBrand); - installMethod(runtime, prototype, "mapAsync", &GPUBuffer::mapAsync); + installMethodWithRuntime(runtime, prototype, "mapAsync", + &GPUBuffer::mapAsync); installMethod(runtime, prototype, "getMappedRange", &GPUBuffer::getMappedRange); installMethod(runtime, prototype, "unmap", &GPUBuffer::unmap); diff --git a/packages/skia/cpp/rnwgpu/api/GPUCanvasContext.cpp b/packages/skia/cpp/rnwgpu/api/GPUCanvasContext.cpp index c47521d2d0..b14dce1297 100644 --- a/packages/skia/cpp/rnwgpu/api/GPUCanvasContext.cpp +++ b/packages/skia/cpp/rnwgpu/api/GPUCanvasContext.cpp @@ -1,6 +1,9 @@ #include "GPUCanvasContext.h" #include "Convertors.h" +#include #include +#include +#include namespace rnwgpu { @@ -9,37 +12,50 @@ void GPUCanvasContext::configure( Convertor conv; wgpu::SurfaceConfiguration surfaceConfiguration; surfaceConfiguration.device = configuration->device->get(); - if (configuration->viewFormats.has_value()) { - if (!conv(surfaceConfiguration.viewFormats, - surfaceConfiguration.viewFormatCount, - configuration->viewFormats.value())) { - throw std::runtime_error("Error with SurfaceConfiguration"); - } - } if (!conv(surfaceConfiguration.usage, configuration->usage) || !conv(surfaceConfiguration.format, configuration->format)) { throw std::runtime_error("Error with SurfaceConfiguration"); } + // viewFormats are deep-copied into SurfaceInfo (which outlives this call); + // Convertor-allocated arrays would dangle. + std::vector viewFormats; + if (configuration->viewFormats.has_value()) { + viewFormats = configuration->viewFormats.value(); + } #ifdef __APPLE__ surfaceConfiguration.alphaMode = configuration->alphaMode; #endif surfaceConfiguration.presentMode = wgpu::PresentMode::Fifo; - _surfaceInfo->configure(surfaceConfiguration); + _surfaceInfo->configure(surfaceConfiguration, std::move(viewFormats)); } -void GPUCanvasContext::unconfigure() {} +void GPUCanvasContext::unconfigure() { _surfaceInfo->unconfigure(); } std::shared_ptr GPUCanvasContext::getCurrentTexture() { + if (!_surfaceInfo->isConfigured()) { + // Web parity: on the web this is an InvalidStateError, not a crash. + throw std::runtime_error( + "[WebGPU] getCurrentTexture() called on a canvas context that is not " + "configured; call context.configure() first"); + } + // The drawing buffer tracks canvas.width/height (like on the web); resize it + // lazily when they changed. Sizes are clamped to 1 so a canvas that has not + // been laid out yet (0x0) keeps working. auto prevSize = _surfaceInfo->getConfig(); - auto width = _canvas->getWidth(); - auto height = _canvas->getHeight(); - auto sizeHasChanged = prevSize.width != width || prevSize.height != height; + auto width = std::max(1, _canvas->getWidth()); + auto height = std::max(1, _canvas->getHeight()); + auto sizeHasChanged = prevSize.width != static_cast(width) || + prevSize.height != static_cast(height); if (sizeHasChanged) { _surfaceInfo->reconfigure(width, height); } auto texture = _surfaceInfo->getCurrentTexture(); + if (texture == nullptr) { + throw std::runtime_error( + "[WebGPU] getCurrentTexture() failed to acquire a texture"); + } auto size = _surfaceInfo->getSize(); _canvas->setClientWidth(size.width); @@ -49,13 +65,11 @@ std::shared_ptr GPUCanvasContext::getCurrentTexture() { } void GPUCanvasContext::present() { - // Present runs synchronously on the calling thread (the one that did - // getCurrentTexture / submit), preserving Dawn surface thread-affinity. - // Required on every runtime (main JS, Reanimated UI, dedicated worklet); - // offscreen surfaces have no wgpu::Surface so they no-op. - if (_surfaceInfo->hasSurface()) { - _surfaceInfo->presentFrame(); - } + // presentFrame() is the end-of-frame boundary: it presents when this frame's + // texture was acquired from the on-screen surface (offscreen and dropped + // frames are skipped), clears the frame state, and adopts any surface that + // attached while the frame was in flight. + _surfaceInfo->presentFrame(); } } // namespace rnwgpu diff --git a/packages/skia/cpp/rnwgpu/api/GPUCanvasContext.h b/packages/skia/cpp/rnwgpu/api/GPUCanvasContext.h index 83eb349424..4952d742c3 100644 --- a/packages/skia/cpp/rnwgpu/api/GPUCanvasContext.h +++ b/packages/skia/cpp/rnwgpu/api/GPUCanvasContext.h @@ -56,7 +56,9 @@ class GPUCanvasContext : public NativeObject { std::shared_ptr getCurrentTexture(); // Present is explicit on every runtime (main JS, Reanimated UI, and dedicated // worklet runtimes). It runs synchronously on the calling thread, preserving - // Dawn surface thread-affinity; offscreen surfaces no-op. + // Dawn surface thread-affinity; frames rendered offscreen (no surface + // attached) are skipped. It is also the end-of-frame boundary at which a + // newly attached native surface is adopted (see SurfaceInfo). void present(); private: diff --git a/packages/skia/cpp/rnwgpu/api/GPUDevice.cpp b/packages/skia/cpp/rnwgpu/api/GPUDevice.cpp index b62b71c53e..b9ca531e44 100644 --- a/packages/skia/cpp/rnwgpu/api/GPUDevice.cpp +++ b/packages/skia/cpp/rnwgpu/api/GPUDevice.cpp @@ -327,6 +327,7 @@ std::shared_ptr GPUDevice::importSharedFence( } async::AsyncTaskHandle GPUDevice::createComputePipelineAsync( + jsi::Runtime &runtime, std::shared_ptr descriptor) { wgpu::ComputePipelineDescriptor desc{}; Convertor conv; @@ -339,12 +340,16 @@ async::AsyncTaskHandle GPUDevice::createComputePipelineAsync( descriptor->label.has_value() ? descriptor->label.value() : ""); auto pipelineHolder = std::make_shared(nullptr, label); - return _async->postTask([device = _instance, desc, descriptor, - pipelineHolder]( - const async::AsyncTaskHandle::ResolveFunction - &resolve, - const async::AsyncTaskHandle::RejectFunction - &reject) { + // Post to the CALLING runtime's context so the promise settles on the + // thread that requested it (see GPUBuffer::mapAsync). + auto context = + async::RuntimeContext::getOrCreate(runtime, _async->instance()); + return context->postTask([device = _instance, desc, descriptor, + pipelineHolder]( + const async::AsyncTaskHandle::ResolveFunction + &resolve, + const async::AsyncTaskHandle::RejectFunction + &reject) { (void)descriptor; device.CreateComputePipelineAsync( &desc, wgpu::CallbackMode::AllowProcessEvents, @@ -368,6 +373,7 @@ async::AsyncTaskHandle GPUDevice::createComputePipelineAsync( } async::AsyncTaskHandle GPUDevice::createRenderPipelineAsync( + jsi::Runtime &runtime, std::shared_ptr descriptor) { wgpu::RenderPipelineDescriptor desc{}; Convertor conv; @@ -381,12 +387,16 @@ async::AsyncTaskHandle GPUDevice::createRenderPipelineAsync( descriptor->label.has_value() ? descriptor->label.value() : ""); auto pipelineHolder = std::make_shared(nullptr, label); - return _async->postTask([device = _instance, desc, descriptor, - pipelineHolder]( - const async::AsyncTaskHandle::ResolveFunction - &resolve, - const async::AsyncTaskHandle::RejectFunction - &reject) { + // Post to the CALLING runtime's context so the promise settles on the + // thread that requested it (see GPUBuffer::mapAsync). + auto context = + async::RuntimeContext::getOrCreate(runtime, _async->instance()); + return context->postTask([device = _instance, desc, descriptor, + pipelineHolder]( + const async::AsyncTaskHandle::ResolveFunction + &resolve, + const async::AsyncTaskHandle::RejectFunction + &reject) { (void)descriptor; device.CreateRenderPipelineAsync( &desc, wgpu::CallbackMode::AllowProcessEvents, @@ -413,13 +423,18 @@ void GPUDevice::pushErrorScope(wgpu::ErrorFilter filter) { _instance.PushErrorScope(filter); } -async::AsyncTaskHandle GPUDevice::popErrorScope() { +async::AsyncTaskHandle GPUDevice::popErrorScope(jsi::Runtime &runtime) { auto device = _instance; - return _async->postTask([device](const async::AsyncTaskHandle::ResolveFunction - &resolve, - const async::AsyncTaskHandle::RejectFunction - &reject) { + // Post to the CALLING runtime's context so the promise settles on the + // thread that requested it (see GPUBuffer::mapAsync). + auto context = + async::RuntimeContext::getOrCreate(runtime, _async->instance()); + return context->postTask([device]( + const async::AsyncTaskHandle::ResolveFunction + &resolve, + const async::AsyncTaskHandle::RejectFunction + &reject) { device.PopErrorScope( wgpu::CallbackMode::AllowProcessEvents, [resolve, reject](wgpu::PopErrorScopeStatus status, diff --git a/packages/skia/cpp/rnwgpu/api/GPUDevice.h b/packages/skia/cpp/rnwgpu/api/GPUDevice.h index 6910f23cd7..e20cc402a2 100644 --- a/packages/skia/cpp/rnwgpu/api/GPUDevice.h +++ b/packages/skia/cpp/rnwgpu/api/GPUDevice.h @@ -141,8 +141,10 @@ class GPUDevice : public NativeObject { std::shared_ptr createRenderPipeline(std::shared_ptr descriptor); async::AsyncTaskHandle createComputePipelineAsync( + jsi::Runtime &runtime, std::shared_ptr descriptor); async::AsyncTaskHandle createRenderPipelineAsync( + jsi::Runtime &runtime, std::shared_ptr descriptor); std::shared_ptr createCommandEncoder( std::optional> descriptor); @@ -151,7 +153,7 @@ class GPUDevice : public NativeObject { std::shared_ptr createQuerySet(std::shared_ptr descriptor); void pushErrorScope(wgpu::ErrorFilter filter); - async::AsyncTaskHandle popErrorScope(); + async::AsyncTaskHandle popErrorScope(jsi::Runtime &runtime); std::unordered_set getFeatures(); std::shared_ptr getLimits(); @@ -197,10 +199,10 @@ class GPUDevice : public NativeObject { &GPUDevice::createComputePipeline); installMethod(runtime, prototype, "createRenderPipeline", &GPUDevice::createRenderPipeline); - installMethod(runtime, prototype, "createComputePipelineAsync", - &GPUDevice::createComputePipelineAsync); - installMethod(runtime, prototype, "createRenderPipelineAsync", - &GPUDevice::createRenderPipelineAsync); + installMethodWithRuntime(runtime, prototype, "createComputePipelineAsync", + &GPUDevice::createComputePipelineAsync); + installMethodWithRuntime(runtime, prototype, "createRenderPipelineAsync", + &GPUDevice::createRenderPipelineAsync); installMethod(runtime, prototype, "createCommandEncoder", &GPUDevice::createCommandEncoder); installMethod(runtime, prototype, "createRenderBundleEncoder", @@ -209,8 +211,8 @@ class GPUDevice : public NativeObject { &GPUDevice::createQuerySet); installMethod(runtime, prototype, "pushErrorScope", &GPUDevice::pushErrorScope); - installMethod(runtime, prototype, "popErrorScope", - &GPUDevice::popErrorScope); + installMethodWithRuntime(runtime, prototype, "popErrorScope", + &GPUDevice::popErrorScope); installGetter(runtime, prototype, "features", &GPUDevice::getFeatures); installGetter(runtime, prototype, "limits", &GPUDevice::getLimits); installGetter(runtime, prototype, "queue", &GPUDevice::getQueue); diff --git a/packages/skia/cpp/rnwgpu/api/GPUQueue.cpp b/packages/skia/cpp/rnwgpu/api/GPUQueue.cpp index 6d321be33d..d16029d653 100644 --- a/packages/skia/cpp/rnwgpu/api/GPUQueue.cpp +++ b/packages/skia/cpp/rnwgpu/api/GPUQueue.cpp @@ -79,9 +79,13 @@ void GPUQueue::writeBuffer(std::shared_ptr buffer, static_cast(size64)); } -async::AsyncTaskHandle GPUQueue::onSubmittedWorkDone() { +async::AsyncTaskHandle GPUQueue::onSubmittedWorkDone(jsi::Runtime &runtime) { auto queue = _instance; - return _async->postTask( + // Post to the CALLING runtime's context so the promise settles on the + // thread that requested it (see GPUBuffer::mapAsync). + auto context = + async::RuntimeContext::getOrCreate(runtime, _async->instance()); + return context->postTask( [queue](const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction &reject) { queue.OnSubmittedWorkDone( diff --git a/packages/skia/cpp/rnwgpu/api/GPUQueue.h b/packages/skia/cpp/rnwgpu/api/GPUQueue.h index 29f1282fff..2e82f68fc4 100644 --- a/packages/skia/cpp/rnwgpu/api/GPUQueue.h +++ b/packages/skia/cpp/rnwgpu/api/GPUQueue.h @@ -40,7 +40,7 @@ class GPUQueue : public NativeObject { std::string getBrand() { return CLASS_NAME; } void submit(std::vector> commandBuffers); - async::AsyncTaskHandle onSubmittedWorkDone(); + async::AsyncTaskHandle onSubmittedWorkDone(jsi::Runtime &runtime); void writeBuffer(std::shared_ptr buffer, uint64_t bufferOffset, std::shared_ptr data, std::optional dataOffsetElements, @@ -63,8 +63,8 @@ class GPUQueue : public NativeObject { static void definePrototype(jsi::Runtime &runtime, jsi::Object &prototype) { installGetter(runtime, prototype, "__brand", &GPUQueue::getBrand); installMethod(runtime, prototype, "submit", &GPUQueue::submit); - installMethod(runtime, prototype, "onSubmittedWorkDone", - &GPUQueue::onSubmittedWorkDone); + installMethodWithRuntime(runtime, prototype, "onSubmittedWorkDone", + &GPUQueue::onSubmittedWorkDone); installMethod(runtime, prototype, "writeBuffer", &GPUQueue::writeBuffer); installMethod(runtime, prototype, "writeTexture", &GPUQueue::writeTexture); installMethod(runtime, prototype, "copyExternalImageToTexture", diff --git a/packages/skia/cpp/rnwgpu/api/GPUShaderModule.cpp b/packages/skia/cpp/rnwgpu/api/GPUShaderModule.cpp index e6950bf104..763ae95c02 100644 --- a/packages/skia/cpp/rnwgpu/api/GPUShaderModule.cpp +++ b/packages/skia/cpp/rnwgpu/api/GPUShaderModule.cpp @@ -7,10 +7,15 @@ namespace rnwgpu { -async::AsyncTaskHandle GPUShaderModule::getCompilationInfo() { +async::AsyncTaskHandle +GPUShaderModule::getCompilationInfo(jsi::Runtime &runtime) { auto module = _instance; - return _async->postTask( + // Post to the CALLING runtime's context so the promise settles on the + // thread that requested it (see GPUBuffer::mapAsync). + auto context = + async::RuntimeContext::getOrCreate(runtime, _async->instance()); + return context->postTask( [module](const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction &reject) { auto result = std::make_shared(); diff --git a/packages/skia/cpp/rnwgpu/api/GPUShaderModule.h b/packages/skia/cpp/rnwgpu/api/GPUShaderModule.h index 88cd353880..6b3684cef8 100644 --- a/packages/skia/cpp/rnwgpu/api/GPUShaderModule.h +++ b/packages/skia/cpp/rnwgpu/api/GPUShaderModule.h @@ -31,7 +31,7 @@ class GPUShaderModule : public NativeObject { public: std::string getBrand() { return CLASS_NAME; } - async::AsyncTaskHandle getCompilationInfo(); + async::AsyncTaskHandle getCompilationInfo(jsi::Runtime &runtime); std::string getLabel() { return _label; } void setLabel(const std::string &label) { @@ -41,8 +41,8 @@ class GPUShaderModule : public NativeObject { static void definePrototype(jsi::Runtime &runtime, jsi::Object &prototype) { installGetter(runtime, prototype, "__brand", &GPUShaderModule::getBrand); - installMethod(runtime, prototype, "getCompilationInfo", - &GPUShaderModule::getCompilationInfo); + installMethodWithRuntime(runtime, prototype, "getCompilationInfo", + &GPUShaderModule::getCompilationInfo); installGetterSetter(runtime, prototype, "label", &GPUShaderModule::getLabel, &GPUShaderModule::setLabel); } diff --git a/packages/skia/cpp/rnwgpu/api/RNWebGPU.h b/packages/skia/cpp/rnwgpu/api/RNWebGPU.h index a0fdcdbddf..28a3d5ac8e 100644 --- a/packages/skia/cpp/rnwgpu/api/RNWebGPU.h +++ b/packages/skia/cpp/rnwgpu/api/RNWebGPU.h @@ -45,11 +45,31 @@ class RNWebGPU : public NativeObject { nativeInfo.height); } + // Retires a canvas context from the JS side (Canvas unmount cleanup). + // Registry entries have two owners, split by whether a native surface is + // attached: + // - A native view currently owns a surface (or one is pending): its own + // teardown (WebGPUMetalView dealloc / WebGPUViewManager.onDropViewInstance) + // removes the entry. Skipping here keeps React StrictMode safe: its + // simulated unmount re-runs JS effects without unmounting native views, + // and removing the entry then would orphan the still-attached surface. + // - No native surface: the JS side is the last owner and removes the entry. + // The check-and-erase runs atomically under the registry lock + // (removeSurfaceInfoIfDetached), serialized against the UI thread's + // find-or-create + attach (SurfaceRegistry::attachSurface), so a concurrent + // attach can never be orphaned by this removal. + void destroyContext(int contextId) { + auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); + registry.removeSurfaceInfoIfDetached(contextId); + } + static void definePrototype(jsi::Runtime &runtime, jsi::Object &prototype) { installGetter(runtime, prototype, "fabric", &RNWebGPU::getFabric); installGetter(runtime, prototype, "gpu", &RNWebGPU::getGPU); installMethod(runtime, prototype, "getNativeSurface", &RNWebGPU::getNativeSurface); + installMethod(runtime, prototype, "destroyContext", + &RNWebGPU::destroyContext); installMethod(runtime, prototype, "MakeWebGPUCanvasContext", &RNWebGPU::MakeWebGPUCanvasContext); } diff --git a/packages/skia/cpp/rnwgpu/async/RuntimeContext.cpp b/packages/skia/cpp/rnwgpu/async/RuntimeContext.cpp index 41bb6048bc..ab2de624ab 100644 --- a/packages/skia/cpp/rnwgpu/async/RuntimeContext.cpp +++ b/packages/skia/cpp/rnwgpu/async/RuntimeContext.cpp @@ -39,6 +39,11 @@ void RuntimeContext::registerMainRuntime( sMainInvoker = std::move(invoker); } +std::shared_ptr +RuntimeContext::mainCallInvoker() { + return sMainInvoker; +} + RuntimeContext::RuntimeContext(jsi::Runtime &runtime, wgpu::Instance instance) : _runtime(runtime), _instance(std::move(instance)) {} diff --git a/packages/skia/cpp/rnwgpu/async/RuntimeContext.h b/packages/skia/cpp/rnwgpu/async/RuntimeContext.h index c98d04d6f1..e7767e5129 100644 --- a/packages/skia/cpp/rnwgpu/async/RuntimeContext.h +++ b/packages/skia/cpp/rnwgpu/async/RuntimeContext.h @@ -55,8 +55,15 @@ namespace rnwgpu::async { * ProcessEvents on one instance is not guaranteed reentrant. * * Threading contract: a RuntimeContext must only be pumped from the runtime it - * was created for. Create and use a GPUDevice (and the buffers/queues derived - * from it) on the same runtime that requested the adapter. + * was created for. Request/response async entry points (mapAsync, + * onSubmittedWorkDone, popErrorScope, createComputePipelineAsync, + * createRenderPipelineAsync, getCompilationInfo, requestAdapter, + * requestDevice) must NOT use a context captured when the object was created — + * the object may have been boxed across to another runtime. They resolve the + * CALLING runtime's context via getOrCreate(runtime, instance) so the promise + * is settled on the thread it was created on. Spontaneous events (device.lost, + * uncapturederror) remain bound to the device's own context (best-effort, main + * runtime only; see the class doc above). */ class RuntimeContext : public std::enable_shared_from_this { public: @@ -84,6 +91,12 @@ class RuntimeContext : public std::enable_shared_from_this { return _callInvoker; } + // The main JS runtime's CallInvoker (registered on install), or null before + // install. Used by the platform views to apply latched surface attaches from + // the JS thread for contexts that are not actively rendering (see + // SurfaceInfo::applyPendingAttach). + static std::shared_ptr mainCallInvoker(); + // The wgpu::Instance bound to this runtime. wgpu::Instance instance() const { return _instance; } diff --git a/packages/skia/src/views/WebGPUCanvas.tsx b/packages/skia/src/views/WebGPUCanvas.tsx index 1456b4e088..024bdd3dbe 100644 --- a/packages/skia/src/views/WebGPUCanvas.tsx +++ b/packages/skia/src/views/WebGPUCanvas.tsx @@ -1,4 +1,4 @@ -import React, { useImperativeHandle, useRef, useState } from "react"; +import React, { useEffect, useImperativeHandle, useRef, useState } from "react"; import type { ViewProps } from "react-native"; import { View, Platform } from "react-native"; @@ -19,6 +19,9 @@ declare global { width: number, height: number ) => RNCanvasContext; + // Retires a canvas context; called by the Canvas on unmount (the Canvas + // owns the native registry entry for its contextId). + destroyContext: (contextId: number) => void; }; } @@ -55,6 +58,19 @@ export const WebGPUCanvas = ({ const viewRef = useRef(null); const [contextId] = useState(() => generateContextId()); + // Retire the native registry entry for this contextId on unmount. When a + // native surface is still attached, this is a no-op and the native view's + // own teardown retires the entry instead — which keeps StrictMode's + // simulated unmount (which re-runs effects without unmounting native views) + // from orphaning a live surface. + useEffect(() => { + return () => { + if (typeof RNWebGPU !== "undefined") { + RNWebGPU.destroyContext(contextId); + } + }; + }, [contextId]); + useImperativeHandle(ref, () => ({ getContextId: () => contextId, getNativeSurface: () => { From 6815e59302a30b2f16b18b069e6feb84b52faeda Mon Sep 17 00:00:00 2001 From: William Candillon Date: Wed, 22 Jul 2026 12:25:31 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(=F0=9F=90=9B):=20accept=20undefined=20p?= =?UTF-8?q?aragraph=20style=20values,=20document=20line=20height=20via=20h?= =?UTF-8?q?eightMultiplier=20(#3949)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skia has no absolute lineHeight property, but TextStyle.heightMultiplier sets the line height to exactly heightMultiplier × fontSize. Document the lineHeight / fontSize mapping (including normalizing line heights across mixed fonts and font sizes) and add e2e tests that establish the exact relationship, the CSS emulation formula, and the halfLeading behavior. Fixes #2561 --- apps/docs/docs/text/paragraph.md | 67 ++++- apps/example/ios/Podfile.lock | 140 ++++----- packages/skia/cpp/api/JsiSkParagraphStyle.h | 30 +- packages/skia/cpp/api/JsiSkStrutStyle.h | 26 +- packages/skia/cpp/api/JsiSkTextStyle.h | 58 ++-- .../e2e/ParagraphLineHeight.spec.tsx | 280 ++++++++++++++++++ 6 files changed, 487 insertions(+), 114 deletions(-) create mode 100644 packages/skia/src/renderer/__tests__/e2e/ParagraphLineHeight.spec.tsx diff --git a/apps/docs/docs/text/paragraph.md b/apps/docs/docs/text/paragraph.md index a0eebc91ec..fba6c9c5f2 100644 --- a/apps/docs/docs/text/paragraph.md +++ b/apps/docs/docs/text/paragraph.md @@ -380,8 +380,8 @@ These properties are used to style specific segments of text within a paragraph. | `fontStyle` | Font style (weight, width, slant). | | `fontVariations` | Font variations. | | `foregroundColor` | Foreground color (for effects like gradients). | -| `heightMultiplier` | Multiplier for line height. | -| `halfLeading` | Controls half-leading value. | +| `heightMultiplier` | Line height as a multiple of the font size (see [Line Height](#line-height)). | +| `halfLeading` | Distributes the extra line height evenly above and below the text. | | `letterSpacing` | Space between characters. | | `locale` | Locale for the text (affects things like sorting). | | `shadows` | List of text shadows. | @@ -437,3 +437,66 @@ const MyParagraph = () => { #### Result + +## Line Height + +Skia has no absolute `lineHeight` property like CSS or React Native. +Instead, the text style has a `heightMultiplier` property: the line height is exactly `heightMultiplier * fontSize` pixels. +This means you can emulate `lineHeight` with the following formula: + +```tsx +heightMultiplier = lineHeight / fontSize +``` + +For instance, `fontSize: 24` with `heightMultiplier: 40 / 24` produces lines that are exactly 40 pixels tall. +When `heightMultiplier` is not set, the line height comes from the font metrics (ascent + descent), which is usually larger than the font size and differs from font to font. + +Since `heightMultiplier` is a text style property, it can also be used to normalize line heights when mixing fonts or font sizes: give each style a `heightMultiplier` of `lineHeight / fontSize` and every line will have the same height, regardless of the natural metrics of each font. + +```tsx twoslash +import { useMemo } from "react"; +import { Paragraph, Skia, useFonts, Canvas } from "@shopify/react-native-skia"; + +const MyParagraph = () => { + const customFontMgr = useFonts({ + Roboto: [require("path/to/Roboto-Regular.ttf")], + "Noto Sans SC": [require("path/to/NotoSansSC-Regular.otf")], + }); + + const paragraph = useMemo(() => { + if (!customFontMgr) { + return null; + } + // Every line will be exactly 40px tall, like lineHeight: 40 in CSS + const lineHeight = 40; + const paragraphBuilder = Skia.ParagraphBuilder.Make({}, customFontMgr); + paragraphBuilder + .pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 24, + heightMultiplier: lineHeight / 24, + }) + .addText("Hello\n") + .pop() + .pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Noto Sans SC"], + fontSize: 16, + heightMultiplier: lineHeight / 16, + }) + .addText("你好") + .pop(); + return paragraphBuilder.build(); + }, [customFontMgr]); + + return ( + + + + ); +}; +``` + +By default, the extra space added by `heightMultiplier` is distributed proportionally to the font's ascent and descent. +Setting `halfLeading: true` splits the extra space evenly above and below the text instead (like CSS half-leading); the line height stays the same but the text sits higher within the line. diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index dba6f8cd75..91b6a07c4e 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -3208,88 +3208,88 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 11b010917f5f15150b2c015abddef1573d2bb05d - RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 + RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f RCTDeprecation: a41bbdd9af30bf2e5715796b313e44ec43eefff1 RCTRequired: 7be34aabb0b77c3cefe644528df0fa0afad4e4d0 RCTSwiftUI: a6c7271c39098bf00dbdad8f8ed997a59bbfbe44 - RCTSwiftUIWrapper: ff9098ccf7727e58218f2f8ea110349863f43438 + RCTSwiftUIWrapper: 5ec163e8fde163d3fba714a992b50a266e1ece37 RCTTypeSafety: 27927d0ca04e419ed9467578b3e6297e37210b5c React: 4bc1f928568ad4bcfd147260f907b4ea5873a03b React-callinvoker: 87f8728235a0dc62e9dc19b3851c829d9347d015 - React-Core: 76bed73b02821e5630e7f2cb2e82432ee964695d - React-CoreModules: 752dbfdaeb096658aa0adc4a03ba6214815a08df - React-cxxreact: b6798528aa601c6db66e6adc7e2da2b059c8be74 + React-Core: 19e0183e28d7a6613ecacebd7525fe6650efa3b6 + React-CoreModules: 73cc86f2a0ff84b93d6325073ad2e4874d21ad40 + React-cxxreact: 4bf734645c77c9b86e2f3e933e0411cf2f14d1ba React-debug: 8978deb306f6f38c28b5091e52b0ac9f942b157e - React-defaultsnativemodule: 682b77ef4acfb298017be15f4f93c1d998deb174 - React-domnativemodule: 4c4b44f7eb68dbc3a2218db088bef318a7302017 - React-Fabric: b6f82a4d8498ce4475586f71ca8397a771fe292d - React-FabricComponents: c8695f4b11918a127c4560d66f7d3fdb01a17986 - React-FabricImage: d64f48830f63830e8ffaaf69fa487116856fbbf1 - React-featureflags: 2a46b229903e906d33dbaf9207ce57c59306c369 - React-featureflagsnativemodule: cba6c0814051a0934f8bcee4a436ee2a6bcc9754 - React-graphics: 3d0435051e1ab8904d065f8ffbe981a9fc202841 - React-hermes: 32fc9c231c1aa5c2fcfe851b0d19ee9269f88f4c - React-idlecallbacksnativemodule: f8ee42581795c4844d97147596bcc2d824c0f188 - React-ImageManager: e8f7377ef0585fd2df05559a17e01a03e187d5cf - React-intersectionobservernativemodule: b1bea12ca29accdd2eda60c87605a6030b894eb9 - React-jserrorhandler: 1a86df895b4eaf4e771abe8cf34cbb26d821f771 - React-jsi: adf8527fec197ad9d0480cc5b8945eb56de627f0 - React-jsiexecutor: 315fa2f879b43e3a9ca08f5f4b733472f7e4e8a4 - React-jsinspector: b4fd1933666bcb2549b566b40656c1e45e9d7632 - React-jsinspectorcdp: 80141710f2668e5b8f00417298d9b76b4abf90fa - React-jsinspectornetwork: 1d3ea717dbbec316cd8c21a0af53928a7bf74901 - React-jsinspectortracing: 4ce745374d4b2bfbd164cce9f8de8383d3d818a0 - React-jsitooling: fc4ac4c3b1f3f9f7fedf0c777c6ff3f244f568bd - React-jsitracing: bff08a6faeef4a9bd286487da191f5e5329e21a9 - React-logger: b8483fa08e0d62e430c76d864309d90576ca2f68 - React-Mapbuffer: 7b72a669e94662359dad4f42b5af005eb24b4e83 - React-microtasksnativemodule: cdc02da075f2857803ed63f24f5f72fc40e094c0 - react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460 - react-native-skia: 82260e6ce556ff33eb29360905eb775383d01a8c - React-NativeModulesApple: a2c3d2cbec893956a5b3e4060322db2984fff75b - React-networking: 3f98bd96893a294376e7e03730947a08d474c380 + React-defaultsnativemodule: 724eb9ec388d494f1e2057d83355ee8fe6f1d780 + React-domnativemodule: 9068f41092f725acd09950233d2847364c731947 + React-Fabric: 945cc8abf08d9d0966acef605bffce7b501c49d9 + React-FabricComponents: 4c4ad6f0d16c964a68f945e029505e2eeec6654b + React-FabricImage: a8b628fd98db21b9f8588e06f14a9194dda11b40 + React-featureflags: 0937601c1af1cc125851ec5bbf4654285d47a3e7 + React-featureflagsnativemodule: ac1a3e0353e1a6e15411b17ed6c7122adb0468a4 + React-graphics: cca521e06463608be46207a4aa160f8a7f725f8b + React-hermes: ec50b9fcea2c3bfdd42f8cec845eac3f35888572 + React-idlecallbacksnativemodule: effcae5b7b4473211adb154aaa321d5d9e2fbcc9 + React-ImageManager: b38459e538f1840fa5c3e7612a4bcb0029a3c366 + React-intersectionobservernativemodule: 8d33366661971200cf2e151727f6fe007b62ae7b + React-jserrorhandler: f94c688a0dbe2e045b91b992722b92e97d56f77f + React-jsi: 3216c876cd4c571a57909e22d77c8fd9530aa067 + React-jsiexecutor: 475563c0042841a85930a455d3199f6b1483a5fe + React-jsinspector: bc484fb32bf1b9fed80afe8793e614eba4f7b39e + React-jsinspectorcdp: 5a574d1d35016968a67e78e6b8a7917473ffbb77 + React-jsinspectornetwork: dce3a5a1351b527ee8c28ad4a8bdd211507e1a45 + React-jsinspectortracing: 65f6b166bd67e5adc31eba027e1570bacf7a3cc7 + React-jsitooling: d5463f5489a31640b0fa0ec4e31566ca8aa86c13 + React-jsitracing: 3c7fc18821aba64855acb8658aa857ca6a7fddf6 + React-logger: 6ac901f5c7f7321d2be1a40b203bccc2e23411e3 + React-Mapbuffer: 2e0e7cc5b7064eaed9c8b8afc3a87621cb7ef5cd + React-microtasksnativemodule: dd4d33b251b57e5027c572c6d0b45cbfbcfaa386 + react-native-safe-area-context: 54d812805f3c4e08a4580ad086cbde1d8780c2e4 + react-native-skia: b28747dc1cf8c344ad041f14a618eda7f429bfeb + React-NativeModulesApple: 7f2f2fed3f6c858889eb61d09941be965d52df58 + React-networking: 43e5e6773ac2ca2a93261a1388fed269c9fce092 React-oscompat: 80166b66da22e7af7fad94474e9997bd52d4c8c6 - React-perflogger: d6797918d2b1031e91a9d8f5e7fdd2c8728fb390 - React-performancecdpmetrics: 5570be61e2f97c4741c5d432c91570e8e5a39892 - React-performancetimeline: 5763499ae1991fc18dcf416e340ce7bc829bb298 + React-perflogger: 63c90e0d8c24df87ffa14dad01aeafc352847dd0 + React-performancecdpmetrics: 5a9b81c08f75045635127d626440d9ada01e774b + React-performancetimeline: 31cebfff69ec9174b3fb54b0606fcb12ef91cbad React-RCTActionSheet: 3bd5f5db9f983cf38d51bb9a7a198e2ebea94821 - React-RCTAnimation: 46a9978f27dc434dbeed16afa7b82619b690a9af - React-RCTAppDelegate: 62ecd60a2b2a8cae26ce6a066bfa59cfde97af01 - React-RCTBlob: 8285c859513023ee3cc8c806d9b59d4da078c4ba - React-RCTFabric: 05ed09347e938de985052f791a6a0698816d5761 - React-RCTFBReactNativeSpec: 83ba579fca9a51e774ac32578ef5dd3262edd7e2 - React-RCTImage: a5364d0f098692cfbf5bef1e8a63e7712ecb14b7 - React-RCTLinking: 34b63b0aa0e92d30f5d7aa2c255a8f95fa75ee8f - React-RCTNetwork: 1ef88b7a5310b8f915d3556b5b247def113191ed - React-RCTRuntime: ed29cf68a46782fec891e5afe1d8d758ca6ccd9b - React-RCTSettings: 2c45623d6c0f30851a123f621eb9d32298bcbb0c - React-RCTText: 0ee70f5dc18004b4d81b2c214267c6cbec058587 - React-RCTVibration: 88557e21e7cc3fe76b5b174cba28ff45c6def997 + React-RCTAnimation: 346865a809fa5132f6c594c8b376c6cf46b44e88 + React-RCTAppDelegate: b2d1e0d3663c987f49f45094883b9e36fcbf0181 + React-RCTBlob: 74759ebb7ff9077d19f60c301782c1f8c3eb2813 + React-RCTFabric: 7b4b14dad21ca99333ebcbc0bf5c205647a315a8 + React-RCTFBReactNativeSpec: 39151968adb68b8c59f29a8bd4223d4d7780a793 + React-RCTImage: 60763f56e8a5e45d861d7c4777e428bb820ec52a + React-RCTLinking: 52aee78b0b3163167c7fcf58f80a42943c03a056 + React-RCTNetwork: f5e1e8ae5eff6982efff6289b06ec0a76d0a6ac2 + React-RCTRuntime: 0e99199322afd372e74b95ae5c58f4e074cc2855 + React-RCTSettings: 298bb40d3412bf32e0b4f0797e48416b0b7278a1 + React-RCTText: dfb74800e27d792d1188fa975a3b9807c3362e3e + React-RCTVibration: ffe5fd4f50a835e353a3b6869eb005dab11eea44 React-rendererconsistency: d280314a3e7f0097152f89e815b4de821c2be8b9 - React-renderercss: f8cbf83d95c2c2bbf893d37fe50c73f046584411 - React-rendererdebug: 37216ddfcd38e49d1e92bf9052ea4bc9d7b932e5 - React-RuntimeApple: 1c0e7cb8e1c2c5775585afcaaa666ec151629a8d - React-RuntimeCore: 925fe2ca24cf8e6ed87586dbb92827306b93b83f - React-runtimeexecutor: 962dae024f6df760d029512a7d99e3f73d570935 - React-RuntimeHermes: 19a7c59ec1bc9908516f0bbc29b49425f6ec64ba - React-runtimescheduler: 62f21127cd97f4d8f164eee5150d3ce53dd36f66 - React-timing: 8757bf6fb96227c264f2d1609f4ba5c68217b8ce - React-utils: 8ab26781c2f5c2f7fafb2022c8ab39d39f231b80 - React-webperformancenativemodule: 7953b7fe519f76fa595111fe18ff3d5de131bfe9 - ReactAppDependencyProvider: 0eb286cc274abb059ee601b862ebddac2e681d01 - ReactCodegen: b8e56b780fffe6edd6405be0af4a1e3049a937f7 - ReactCommon: ac934cb340aee91282ecd6f273a26d24d4c55cae - ReactNativeHost: eef98ec49b55d88ad4cabf5a4378a12b42b551ee - ReactTestApp-DevSupport: ea18f446cff64b6c9a3e28788600c82ecf51bde6 + React-renderercss: 8a1a346f3665fd5ea7a7be7b3b9f95d4743e1180 + React-rendererdebug: af74afdfb3d6c5382ebab35562efd8eb9e690473 + React-RuntimeApple: 06e33d291e72fd0c73ac47046c3536d77d5aeedd + React-RuntimeCore: 99273d2af072062eb07f0b2d2d4a0f2de697ea14 + React-runtimeexecutor: 2063c03c18810ee57939d138142e6493333360ef + React-RuntimeHermes: 2253a7f4c8d56b449230b330b0b15383ed4b3df4 + React-runtimescheduler: ff37ac6720a943da91645c06274282ac46b71f23 + React-timing: 831d7e081ba4c332ca5cccf389b88e363f13f2b4 + React-utils: 25db6c17598c4fed22b5956d7551bb8bddf1f95b + React-webperformancenativemodule: 57e41e6193cfb815bde0b5534bef68673f1270eb + ReactAppDependencyProvider: bfb12ead469222b022a2024f32aba47ce50de512 + ReactCodegen: 9ca1bd49eee1eccf6e427e406d2163f49e9c48c0 + ReactCommon: 05ad684db7d88e194272ae26baddf6300e30b8b7 + ReactNativeHost: e7e0a518b0120f0070b3e1f13c7006d3e0e8ee13 + ReactTestApp-DevSupport: 6994b53b5b81139a8ce63e0776c726c95de079a1 ReactTestApp-Resources: 1bd9ff10e4c24f2ad87101a32023721ae923bccf - RNGestureHandler: cd4be101cfa17ea6bbd438710caa02e286a84381 - RNReanimated: c26dfcd831add485c2ed93de9d7bfb90b035eeaa - RNScreens: 714e10b6b554f7dc7ad9f78dcf36dc8e3fc73415 - RNSVG: 11354d28dd6cb71a59570b68c91ba6772a2d781d - RNWorklets: b89b501d37972e6419d6f87effe41d6d76157648 + RNGestureHandler: 77eecab5fd636666ca73a55bb61e2f1a685b7e84 + RNReanimated: d1a7a4c20eefc371e062990ce1debeaff4f1b9be + RNScreens: b2a5c76af24a02a2fd71bfce42780fdd9c79cc6d + RNSVG: ea9cbf6dcdbebdfff5822b0ad9311bbc4510a0b7 + RNWorklets: 5f6e5664c1819eac103ca75cc2f36191f55aa110 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - Yoga: 5456bb010373068fc92221140921b09d126b116e + Yoga: 5bd0956bf9cb16f75101e78b5e852c7577bc5a45 PODFILE CHECKSUM: dca89d921c9f2a2d3d405a5fca0bfb60b30b5022 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/packages/skia/cpp/api/JsiSkParagraphStyle.h b/packages/skia/cpp/api/JsiSkParagraphStyle.h index d4587f050f..8a0fa392fd 100644 --- a/packages/skia/cpp/api/JsiSkParagraphStyle.h +++ b/packages/skia/cpp/api/JsiSkParagraphStyle.h @@ -42,6 +42,16 @@ class JsiSkParagraphStyle { textHeightBehavior?: SkTextHeightBehavior; textStyle?: SkTextStyle; */ + // A property set to undefined or null is treated as absent, like on web. + static bool hasValue(jsi::Runtime &runtime, const jsi::Object &object, + const char *name) { + if (!object.hasProperty(runtime, name)) { + return false; + } + auto propValue = object.getProperty(runtime, name); + return !propValue.isUndefined() && !propValue.isNull(); + } + static para::ParagraphStyle fromValue(jsi::Runtime &runtime, const jsi::Value &value) { para::ParagraphStyle retVal; @@ -59,52 +69,52 @@ class JsiSkParagraphStyle { auto object = value.asObject(runtime); - if (object.hasProperty(runtime, "disableHinting")) { + if (hasValue(runtime, object, "disableHinting")) { auto propValue = object.getProperty(runtime, "disableHinting"); if (asBool(runtime, propValue)) { retVal.turnHintingOff(); } } - if (object.hasProperty(runtime, "ellipsis")) { + if (hasValue(runtime, object, "ellipsis")) { auto propValue = object.getProperty(runtime, "ellipsis"); auto inStr = propValue.asString(runtime).utf8(runtime); std::u16string uStr; fromUTF8(inStr, uStr); retVal.setEllipsis(uStr); } - if (object.hasProperty(runtime, "heightMultiplier")) { + if (hasValue(runtime, object, "heightMultiplier")) { auto propValue = object.getProperty(runtime, "heightMultiplier"); retVal.setHeight(propValue.asNumber()); } - if (object.hasProperty(runtime, "maxLines")) { + if (hasValue(runtime, object, "maxLines")) { auto propValue = object.getProperty(runtime, "maxLines"); if (propValue.asNumber() != 0) { retVal.setMaxLines(propValue.asNumber()); } } - if (object.hasProperty(runtime, "replaceTabCharacters")) { + if (hasValue(runtime, object, "replaceTabCharacters")) { auto propValue = object.getProperty(runtime, "replaceTabCharacters"); retVal.setReplaceTabCharacters(asBool(runtime, propValue)); } - if (object.hasProperty(runtime, "textAlign")) { + if (hasValue(runtime, object, "textAlign")) { auto propValue = object.getProperty(runtime, "textAlign"); retVal.setTextAlign(static_cast(propValue.asNumber())); } - if (object.hasProperty(runtime, "textDirection")) { + if (hasValue(runtime, object, "textDirection")) { auto propValue = object.getProperty(runtime, "textDirection"); retVal.setTextDirection( static_cast(propValue.asNumber())); } - if (object.hasProperty(runtime, "textHeightBehavior")) { + if (hasValue(runtime, object, "textHeightBehavior")) { auto propValue = object.getProperty(runtime, "textHeightBehavior"); retVal.setTextHeightBehavior( static_cast(propValue.asNumber())); } - if (object.hasProperty(runtime, "strutStyle")) { + if (hasValue(runtime, object, "strutStyle")) { auto propValue = object.getProperty(runtime, "strutStyle"); retVal.setStrutStyle(JsiSkStrutStyle::fromValue(runtime, propValue)); } - if (object.hasProperty(runtime, "textStyle")) { + if (hasValue(runtime, object, "textStyle")) { auto propValue = object.getProperty(runtime, "textStyle"); retVal.setTextStyle(JsiSkTextStyle::fromValue(runtime, propValue)); } diff --git a/packages/skia/cpp/api/JsiSkStrutStyle.h b/packages/skia/cpp/api/JsiSkStrutStyle.h index 6ecb441b94..8c4055a086 100644 --- a/packages/skia/cpp/api/JsiSkStrutStyle.h +++ b/packages/skia/cpp/api/JsiSkStrutStyle.h @@ -32,6 +32,16 @@ bool asBool(jsi::Runtime &runtime, const jsi::Value &value) { */ class JsiSkStrutStyle { public: + // A property set to undefined or null is treated as absent, like on web. + static bool hasValue(jsi::Runtime &runtime, const jsi::Object &object, + const char *name) { + if (!object.hasProperty(runtime, name)) { + return false; + } + auto propValue = object.getProperty(runtime, name); + return !propValue.isUndefined() && !propValue.isNull(); + } + static para::StrutStyle fromValue(jsi::Runtime &runtime, const jsi::Value &value) { // Read values from the argument - expected to be a TextStyle shaped object @@ -52,11 +62,11 @@ class JsiSkStrutStyle { para::StrutStyle retVal; - if (object.hasProperty(runtime, "strutEnabled")) { + if (hasValue(runtime, object, "strutEnabled")) { auto propValue = object.getProperty(runtime, "strutEnabled"); retVal.setStrutEnabled(asBool(runtime, propValue)); } - if (object.hasProperty(runtime, "fontFamilies")) { + if (hasValue(runtime, object, "fontFamilies")) { auto propValue = object.getProperty(runtime, "fontFamilies") .asObject(runtime) .asArray(runtime); @@ -70,28 +80,28 @@ class JsiSkStrutStyle { } } - if (object.hasProperty(runtime, "fontStyle")) { + if (hasValue(runtime, object, "fontStyle")) { auto propValue = object.getProperty(runtime, "fontStyle"); retVal.setFontStyle(*JsiSkFontStyle::fromValue(runtime, propValue).get()); } - if (object.hasProperty(runtime, "fontSize")) { + if (hasValue(runtime, object, "fontSize")) { auto propValue = object.getProperty(runtime, "fontSize"); retVal.setFontSize(propValue.asNumber()); } - if (object.hasProperty(runtime, "heightMultiplier")) { + if (hasValue(runtime, object, "heightMultiplier")) { auto propValue = object.getProperty(runtime, "heightMultiplier"); retVal.setHeight(propValue.asNumber()); retVal.setHeightOverride(true); } - if (object.hasProperty(runtime, "halfLeading")) { + if (hasValue(runtime, object, "halfLeading")) { auto propValue = object.getProperty(runtime, "halfLeading"); retVal.setHalfLeading(asBool(runtime, propValue)); } - if (object.hasProperty(runtime, "leading")) { + if (hasValue(runtime, object, "leading")) { auto propValue = object.getProperty(runtime, "leading"); retVal.setLeading(propValue.asNumber()); } - if (object.hasProperty(runtime, "forceStrutHeight")) { + if (hasValue(runtime, object, "forceStrutHeight")) { auto propValue = object.getProperty(runtime, "forceStrutHeight"); retVal.setForceStrutHeight(asBool(runtime, propValue)); } diff --git a/packages/skia/cpp/api/JsiSkTextStyle.h b/packages/skia/cpp/api/JsiSkTextStyle.h index 031c49f410..2dc712b407 100644 --- a/packages/skia/cpp/api/JsiSkTextStyle.h +++ b/packages/skia/cpp/api/JsiSkTextStyle.h @@ -26,6 +26,16 @@ namespace para = skia::textlayout; */ class JsiSkTextStyle { public: + // A property set to undefined or null is treated as absent, like on web. + static bool hasValue(jsi::Runtime &runtime, const jsi::Object &object, + const char *name) { + if (!object.hasProperty(runtime, name)) { + return false; + } + auto propValue = object.getProperty(runtime, name); + return !propValue.isUndefined() && !propValue.isNull(); + } + static para::TextStyle fromValue(jsi::Runtime &runtime, const jsi::Value &value) { @@ -43,35 +53,35 @@ class JsiSkTextStyle { auto object = value.asObject(runtime); - if (object.hasProperty(runtime, "backgroundColor")) { + if (hasValue(runtime, object, "backgroundColor")) { auto propValue = object.getProperty(runtime, "backgroundColor"); SkPaint p; p.setColor(JsiSkColor::fromValue(runtime, propValue)); retVal.setBackgroundPaint(p); } - if (object.hasProperty(runtime, "color")) { + if (hasValue(runtime, object, "color")) { auto propValue = object.getProperty(runtime, "color"); retVal.setColor(JsiSkColor::fromValue(runtime, propValue)); } - if (object.hasProperty(runtime, "decoration")) { + if (hasValue(runtime, object, "decoration")) { auto propValue = object.getProperty(runtime, "decoration"); retVal.setDecoration( static_cast(propValue.asNumber())); } - if (object.hasProperty(runtime, "decorationColor")) { + if (hasValue(runtime, object, "decorationColor")) { auto propValue = object.getProperty(runtime, "decorationColor"); retVal.setDecorationColor(JsiSkColor::fromValue(runtime, propValue)); } - if (object.hasProperty(runtime, "decorationThickness")) { + if (hasValue(runtime, object, "decorationThickness")) { auto propValue = object.getProperty(runtime, "decorationThickness"); retVal.setDecorationThicknessMultiplier(propValue.asNumber()); } - if (object.hasProperty(runtime, "decorationStyle")) { + if (hasValue(runtime, object, "decorationStyle")) { auto propValue = object.getProperty(runtime, "decorationStyle"); retVal.setDecorationStyle( static_cast(propValue.asNumber())); } - if (object.hasProperty(runtime, "fontFamilies")) { + if (hasValue(runtime, object, "fontFamilies")) { auto propValue = object.getProperty(runtime, "fontFamilies") .asObject(runtime) .asArray(runtime); @@ -85,7 +95,7 @@ class JsiSkTextStyle { } retVal.setFontFamilies(families); } - if (object.hasProperty(runtime, "fontFeatures")) { + if (hasValue(runtime, object, "fontFeatures")) { auto propValue = object.getProperty(runtime, "fontFeatures") .asObject(runtime) .asArray(runtime); @@ -100,58 +110,58 @@ class JsiSkTextStyle { retVal.addFontFeature(SkString(name), value); } } - if (object.hasProperty(runtime, "fontSize")) { + if (hasValue(runtime, object, "fontSize")) { auto propValue = object.getProperty(runtime, "fontSize"); retVal.setFontSize(propValue.asNumber()); } - if (object.hasProperty(runtime, "fontStyle")) { + if (hasValue(runtime, object, "fontStyle")) { auto propValue = object.getProperty(runtime, "fontStyle").asObject(runtime); auto weight = static_cast( - propValue.hasProperty(runtime, "weight") + hasValue(runtime, propValue, "weight") ? propValue.getProperty(runtime, "weight").asNumber() : static_cast(SkFontStyle::Weight::kNormal_Weight)); auto width = static_cast( - propValue.hasProperty(runtime, "width") + hasValue(runtime, propValue, "width") ? propValue.getProperty(runtime, "width").asNumber() : static_cast(SkFontStyle::Width::kNormal_Width)); auto slant = static_cast( - propValue.hasProperty(runtime, "slant") + hasValue(runtime, propValue, "slant") ? propValue.getProperty(runtime, "slant").asNumber() : static_cast(SkFontStyle::Slant::kUpright_Slant)); retVal.setFontStyle(SkFontStyle(weight, width, slant)); } - if (object.hasProperty(runtime, "foregroundColor")) { + if (hasValue(runtime, object, "foregroundColor")) { auto propValue = object.getProperty(runtime, "foregroundColor"); SkPaint p; p.setColor(JsiSkColor::fromValue(runtime, propValue)); retVal.setForegroundColor(p); } - if (object.hasProperty(runtime, "heightMultiplier")) { + if (hasValue(runtime, object, "heightMultiplier")) { auto propValue = object.getProperty(runtime, "heightMultiplier"); retVal.setHeight(propValue.asNumber()); retVal.setHeightOverride(true); } - if (object.hasProperty(runtime, "halfLeading")) { + if (hasValue(runtime, object, "halfLeading")) { auto propValue = object.getProperty(runtime, "halfLeading"); retVal.setHalfLeading(propValue.getBool()); } - if (object.hasProperty(runtime, "letterSpacing")) { + if (hasValue(runtime, object, "letterSpacing")) { auto propValue = object.getProperty(runtime, "letterSpacing"); retVal.setLetterSpacing(propValue.asNumber()); } - if (object.hasProperty(runtime, "wordSpacing")) { + if (hasValue(runtime, object, "wordSpacing")) { auto propValue = object.getProperty(runtime, "wordSpacing"); retVal.setWordSpacing(propValue.asNumber()); } - if (object.hasProperty(runtime, "locale")) { + if (hasValue(runtime, object, "locale")) { auto propValue = object.getProperty(runtime, "locale"); retVal.setLocale(SkString(propValue.asString(runtime).utf8(runtime))); } - if (object.hasProperty(runtime, "shadows")) { + if (hasValue(runtime, object, "shadows")) { auto propValue = object.getProperty(runtime, "shadows") .asObject(runtime) .asArray(runtime); @@ -159,24 +169,24 @@ class JsiSkTextStyle { retVal.resetShadows(); for (size_t i = 0; i < size; ++i) { auto element = propValue.getValueAtIndex(runtime, i).asObject(runtime); - auto color = element.hasProperty(runtime, "color") + auto color = hasValue(runtime, element, "color") ? JsiSkColor::fromValue( runtime, element.getProperty(runtime, "color")) : SK_ColorBLACK; SkPoint offset = - element.hasProperty(runtime, "offset") + hasValue(runtime, element, "offset") ? *JsiSkPoint::fromValue(runtime, element.getProperty(runtime, "offset")) .get() : SkPoint::Make(0, 0); auto blurSigma = - element.hasProperty(runtime, "blurRadius") + hasValue(runtime, element, "blurRadius") ? element.getProperty(runtime, "blurRadius").asNumber() : 0; retVal.addShadow(para::TextShadow(color, offset, blurSigma)); } } - if (object.hasProperty(runtime, "textBaseline")) { + if (hasValue(runtime, object, "textBaseline")) { auto propValue = object.getProperty(runtime, "textBaseline"); retVal.setTextBaseline( static_cast(propValue.asNumber())); diff --git a/packages/skia/src/renderer/__tests__/e2e/ParagraphLineHeight.spec.tsx b/packages/skia/src/renderer/__tests__/e2e/ParagraphLineHeight.spec.tsx new file mode 100644 index 0000000000..fc9fc6bc99 --- /dev/null +++ b/packages/skia/src/renderer/__tests__/e2e/ParagraphLineHeight.spec.tsx @@ -0,0 +1,280 @@ +import type { SkTextStyle } from "../../../skia/types"; +import { resolveFile, surface } from "../setup"; + +const RobotoRegular = Array.from( + resolveFile("skia/__tests__/assets/Roboto-Regular.ttf") +); + +const NotoSansSC = Array.from( + resolveFile("skia/__tests__/assets/NotoSansSC-Regular.otf") +); + +// Use case from https://github.com/Shopify/react-native-skia/issues/2561: +// Skia has no absolute lineHeight property (like CSS or React Native), but +// TextStyle has heightMultiplier: the line height becomes exactly +// heightMultiplier × fontSize. Therefore lineHeight: X can be emulated with +// heightMultiplier: X / fontSize. These tests establish that relationship +// empirically. +describe("Paragraph line height via heightMultiplier (#2561)", () => { + it("sets the line height to exactly heightMultiplier × fontSize", async () => { + const result = await surface.eval( + (Skia, ctx) => { + const typeface = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(typeface, "Roboto"); + const measure = (heightMultiplier?: number) => { + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 24, + heightMultiplier, + }); + builder.addText("Hello"); + const paragraph = builder.build(); + paragraph.layout(512); + const metrics = paragraph.getLineMetrics()[0]; + return { + height: paragraph.getHeight(), + lineHeight: metrics.height, + ascent: metrics.ascent, + descent: metrics.descent, + }; + }; + return { + unset: measure(), + multiplier1: measure(1), + multiplier2: measure(2), + }; + }, + { RobotoRegular } + ); + // Without heightMultiplier, the line height comes from the font metrics: + // for Roboto at fontSize 24, ascent (22.266) + descent (5.859) = 28.125, + // i.e. ~1.17 × fontSize (rounded to 28) — not fontSize itself. + expect(result.unset.height).toBe(28); + expect(result.unset.ascent + result.unset.descent).toBeCloseTo(28.125, 3); + // heightMultiplier: 1 forces the line height to exactly 1 × fontSize, + // scaling ascent/descent proportionally (19 + 5 = 24). + expect(result.multiplier1.height).toBe(24); + expect(result.multiplier1.lineHeight).toBe(24); + // heightMultiplier: 2 gives exactly 2 × fontSize. + expect(result.multiplier2.height).toBe(48); + expect(result.multiplier2.lineHeight).toBe(48); + // The extra space is distributed proportionally to the font's + // ascent/descent ratio (38 + 10 = 48 keeps the 22.266/5.859 ratio). + expect(result.multiplier2.ascent).toBeCloseTo(38, 3); + expect(result.multiplier2.descent).toBeCloseTo(10, 3); + }); + + it("emulates CSS lineHeight with heightMultiplier = lineHeight / fontSize", async () => { + const result = await surface.eval( + (Skia, ctx) => { + const roboto = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const noto = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.NotoSansSC)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(roboto, "Roboto"); + provider.registerFont(noto, "Noto Sans SC"); + const lineHeight = 40; + const measure = ( + text: string, + fontFamily: string, + fontSize: number, + normalize: boolean + ) => { + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: [fontFamily], + fontSize, + heightMultiplier: normalize ? lineHeight / fontSize : undefined, + }); + builder.addText(text); + const paragraph = builder.build(); + paragraph.layout(512); + return { + height: paragraph.getHeight(), + lines: paragraph.getLineMetrics().map((m) => m.height), + }; + }; + return { + roboto: measure("Hello", "Roboto", 24, true), + notoNatural: measure("你好", "Noto Sans SC", 24, false), + noto: measure("你好", "Noto Sans SC", 24, true), + multiline: measure( + "Hello World\nHello World\nHello World", + "Roboto", + 24, + true + ), + }; + }, + { RobotoRegular, NotoSansSC } + ); + // lineHeight: 40 at fontSize 24 → heightMultiplier: 40 / 24 gives a line + // of exactly 40 pixels, regardless of the font's natural metrics. + expect(result.roboto.height).toBe(40); + // Noto Sans SC has much taller natural metrics (35 at fontSize 24)... + expect(result.notoNatural.height).toBe(35); + // ...but the same formula still lands exactly on 40. + expect(result.noto.height).toBe(40); + // Each line of a multiline paragraph is exactly lineHeight tall and the + // paragraph height is the sum of the lines. + expect(result.multiline.lines).toEqual([40, 40, 40]); + expect(result.multiline.height).toBe(120); + }); + + it("normalizes line heights across mixed fonts and font sizes", async () => { + const result = await surface.eval( + (Skia, ctx) => { + const roboto = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const noto = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.NotoSansSC)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(roboto, "Roboto"); + provider.registerFont(noto, "Noto Sans SC"); + const measure = (lineHeight?: number) => { + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 24, + heightMultiplier: lineHeight ? lineHeight / 24 : undefined, + }); + builder.addText("Hello\n"); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Noto Sans SC"], + fontSize: 16, + heightMultiplier: lineHeight ? lineHeight / 16 : undefined, + }); + builder.addText("你好"); + const paragraph = builder.build(); + paragraph.layout(512); + return { + height: paragraph.getHeight(), + lines: paragraph.getLineMetrics().map((m) => m.height), + }; + }; + return { + natural: measure(), + normalized: measure(40), + }; + }, + { RobotoRegular, NotoSansSC } + ); + // This is the exact problem reported in the issue thread: with mixed + // fonts/sizes each line gets a different height from its font metrics + // (Roboto at 24 → 28, Noto Sans SC at 16 → 23). + expect(result.natural.lines).toEqual([28, 23]); + // Setting heightMultiplier per style to lineHeight / fontSize normalizes + // every line to the same 40 pixels. + expect(result.normalized.lines).toEqual([40, 40]); + expect(result.normalized.height).toBe(80); + }); + + it("treats undefined style values the same as absent keys (web parity)", async () => { + const result = await surface.eval( + (Skia, ctx) => { + const typeface = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(typeface, "Roboto"); + const measure = (style: SkTextStyle) => { + const builder = Skia.ParagraphBuilder.Make( + { heightMultiplier: undefined, strutStyle: undefined }, + provider + ); + builder.pushStyle(style); + builder.addText("Hello"); + const paragraph = builder.build(); + paragraph.layout(512); + return paragraph.getHeight(); + }; + return { + absent: measure({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 24, + }), + explicitUndefined: measure({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 24, + heightMultiplier: undefined, + halfLeading: undefined, + letterSpacing: undefined, + }), + }; + }, + { RobotoRegular } + ); + // On web, CanvasKit treats a key set to undefined like a missing key; the + // JSI implementation must not throw and must produce the same layout. + expect(result.absent).toBe(28); + expect(result.explicitUndefined).toBe(28); + }); + + it("halfLeading changes where the extra space goes, not the line height", async () => { + const result = await surface.eval( + (Skia, ctx) => { + const typeface = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(typeface, "Roboto"); + const measure = (halfLeading: boolean) => { + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 24, + heightMultiplier: 2, + halfLeading, + }); + builder.addText("Hello"); + const paragraph = builder.build(); + paragraph.layout(512); + const metrics = paragraph.getLineMetrics()[0]; + return { + height: paragraph.getHeight(), + ascent: metrics.ascent, + descent: metrics.descent, + baseline: metrics.baseline, + }; + }; + return { + proportional: measure(false), + halfLeading: measure(true), + }; + }, + { RobotoRegular } + ); + // The line height is 2 × 24 = 48 in both cases. + expect(result.proportional.height).toBe(48); + expect(result.halfLeading.height).toBe(48); + // Without halfLeading, ascent and descent are scaled proportionally to + // the font metrics: 38 + 10 = 48, pushing the baseline down to 38. + expect(result.proportional.ascent).toBeCloseTo(38, 3); + expect(result.proportional.descent).toBeCloseTo(10, 3); + // With halfLeading, the extra space (48 - 28.125 = 19.875) is split + // evenly above and below the natural metrics (like CSS half-leading): + // ascent = 22.266 + 9.9375 = 32.203, descent = 5.859 + 9.9375 = 15.797. + // The text sits higher within the same line box. + expect(result.halfLeading.ascent).toBeCloseTo(32.203125, 3); + expect(result.halfLeading.descent).toBeCloseTo(15.796875, 3); + expect(result.halfLeading.baseline).toBeLessThan( + result.proportional.baseline + ); + }); +});