From c22c4d4c021f13b4719a34c6a2aaaca61cca02fd Mon Sep 17 00:00:00 2001 From: William Candillon Date: Thu, 9 Jul 2026 16:51:15 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(=F0=9F=8C=88):=20support=20for=20onscre?= =?UTF-8?q?en=20high=20color=20depth=20=20(#3926)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/docs/docs/canvas/canvas.md | 31 +++++++ apps/example/src/App.tsx | 3 + .../Examples/HighBitDepth/HighBitDepth.tsx | 90 +++++++++++++++++++ .../src/Examples/HighBitDepth/index.ts | 1 + apps/example/src/Examples/index.ts | 1 + apps/example/src/Home/HomeScreen.tsx | 5 ++ apps/example/src/types.ts | 1 + .../android/cpp/jni/include/JniSkiaBaseView.h | 10 ++- .../cpp/jni/include/JniSkiaPictureView.h | 14 +-- .../cpp/rnskia-android/OpenGLContext.h | 11 ++- .../cpp/rnskia-android/RNSkAndroidView.h | 16 ++-- .../RNSkOpenGLCanvasProvider.cpp | 14 +-- .../rnskia-android/RNSkOpenGLCanvasProvider.h | 6 +- .../reactnative/skia/SkiaBaseView.java | 60 ++++++++++--- .../reactnative/skia/SkiaBaseViewManager.java | 5 ++ .../reactnative/skia/SkiaPictureView.java | 4 +- packages/skia/apple/MetalContext.h | 13 +-- packages/skia/apple/MetalLayerColorSpace.mm | 39 ++++++++ .../skia/apple/MetalLayerColorSpaceUtils.h | 34 +++++++ packages/skia/apple/MetalWindowContext.h | 4 +- packages/skia/apple/MetalWindowContext.mm | 17 ++-- packages/skia/apple/RNSkAppleView.h | 6 ++ packages/skia/apple/RNSkMetalCanvasProvider.h | 2 + .../skia/apple/RNSkMetalCanvasProvider.mm | 17 +++- packages/skia/apple/SkiaPictureView.mm | 1 + packages/skia/apple/SkiaPictureViewManager.mm | 5 ++ packages/skia/apple/SkiaUIView.h | 1 + packages/skia/apple/SkiaUIView.mm | 9 ++ packages/skia/cpp/rnskia/RNDawnContext.h | 7 +- packages/skia/cpp/rnskia/RNDawnUtils.h | 31 +++++-- .../skia/cpp/rnskia/RNDawnWindowContext.h | 54 ++++++++--- .../skia/cpp/rnskia/RNMetalLayerColorSpace.h | 16 ++++ packages/skia/cpp/rnwgpu/SurfaceRegistry.h | 14 ++- packages/skia/react-native-skia.podspec | 1 + packages/skia/src/mock/index.ts | 1 + packages/skia/src/renderer/Canvas.tsx | 11 +++ .../specs/SkiaPictureViewNativeComponent.ts | 1 + packages/skia/src/views/SkiaPictureView.tsx | 2 + packages/skia/src/views/formats.ts | 21 +++++ packages/skia/src/views/index.ts | 1 + packages/skia/src/views/types.ts | 7 ++ 41 files changed, 507 insertions(+), 80 deletions(-) create mode 100644 apps/example/src/Examples/HighBitDepth/HighBitDepth.tsx create mode 100644 apps/example/src/Examples/HighBitDepth/index.ts create mode 100644 packages/skia/apple/MetalLayerColorSpace.mm create mode 100644 packages/skia/apple/MetalLayerColorSpaceUtils.h create mode 100644 packages/skia/cpp/rnskia/RNMetalLayerColorSpace.h create mode 100644 packages/skia/src/views/formats.ts diff --git a/apps/docs/docs/canvas/canvas.md b/apps/docs/docs/canvas/canvas.md index 4a9835ec01..4b065188d1 100644 --- a/apps/docs/docs/canvas/canvas.md +++ b/apps/docs/docs/canvas/canvas.md @@ -14,6 +14,7 @@ Behind the scenes, it is using its own React renderer. | style? | `ViewStyle` | View style | | ref? | `Ref` | Reference to the `SkiaView` object | | onSize? | `SharedValue` | Reanimated value to which the canvas size will be assigned (see [canvas size](#canvas-size)) | +| highBitDepth? | `boolean` | Render into a surface with more than 8 bits per channel (see [high bit depth](#high-bit-depth)) | | androidWarmup? | `boolean` | Draw the first frame directly on the Android compositor. Use it for static icons or fully opaque drawings—animated or translucent canvases can misrender, so it remains opt-in. | ## Canvas size @@ -85,6 +86,36 @@ const Demo = () => { ``` +## High bit depth + +By default the canvas renders into an 8-bit surface. With only 256 levels per channel, subtle gradients quantize into visible bands, especially in dark tones and on OLED displays. +With `highBitDepth`, the canvas renders into a 16-bit float surface on iOS and a 10-bit surface on Android. +Colors are identical to the default surface, only with more precision: this is about bit depth, not HDR. + +```tsx twoslash +import {Canvas, Fill, LinearGradient, vec} from "@shopify/react-native-skia"; + +const Demo = () => { + return ( + + + + + + ); +}; +``` + +:::warning + +On Android, `highBitDepth` requires the Graphite backend; with the default OpenGL backend the canvas falls back to 8-bit. + +::: + ## Getting a Canvas Snapshot You can save your drawings as an image by using the `makeImageSnapshotAsync` method. This method returns a promise that resolves to an [Image](/docs/images). diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index a03b723bb8..4c85fab0de 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -35,6 +35,7 @@ import { LiquidGlass, Pictures, WebGPU, + HighBitDepth, } from "./Examples"; import { CI, Tests } from "./Tests"; import { HomeScreen } from "./Home"; @@ -75,6 +76,7 @@ const linking: LinkingOptions = { Chat: "chat", Pictures: "pictures", WebGPU: "webgpu", + HighBitDepth: "high-bit-depth", }, }, prefixes: ["rnskia://"], @@ -244,6 +246,7 @@ const App = () => { header: () => null, }} /> + diff --git a/apps/example/src/Examples/HighBitDepth/HighBitDepth.tsx b/apps/example/src/Examples/HighBitDepth/HighBitDepth.tsx new file mode 100644 index 0000000000..cb75b1b154 --- /dev/null +++ b/apps/example/src/Examples/HighBitDepth/HighBitDepth.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { + Canvas, + Fill, + LinearGradient, + vec, + useClock, +} from "@shopify/react-native-skia"; +import type { SharedValue } from "react-native-reanimated"; +import { useDerivedValue, useSharedValue } from "react-native-reanimated"; + +// A dark-blue vertical gradient (the classic "night sky" worst case for +// banding) that slowly drifts up and down. The endpoints are integer 8-bit +// values with the same delta (+8/255) on every channel, so all three channels +// quantize at the same height: each band edge is a simultaneous r+g+b step, +// the strongest possible contour. On the default 8-bit canvas ~8 fat bands +// crawl across the screen; with highBitDepth the gradient stays smooth. Both +// canvases must show the same colors. +const colorA = "rgb(51, 56, 77)"; +const colorB = "rgb(59, 64, 85)"; + +interface GradientCanvasProps { + highBitDepth: boolean; + label: string; + clock: SharedValue; +} + +const GradientCanvas = ({ + highBitDepth, + label, + clock, +}: GradientCanvasProps) => { + const size = useSharedValue({ width: 0, height: 0 }); + const start = useDerivedValue(() => { + const phase = 0.15 * Math.sin(clock.value / 2000); + return vec(0, -phase * size.value.height); + }); + const end = useDerivedValue(() => { + const phase = 0.15 * Math.sin(clock.value / 2000); + return vec(0, (1 - phase) * size.value.height); + }); + return ( + + + + + + + {label} + + ); +}; + +export const HighBitDepth = () => { + const clock = useClock(); + return ( + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: "row", + }, + column: { + flex: 1, + }, + canvas: { + flex: 1, + }, + label: { + textAlign: "center", + padding: 8, + fontWeight: "bold", + }, +}); diff --git a/apps/example/src/Examples/HighBitDepth/index.ts b/apps/example/src/Examples/HighBitDepth/index.ts new file mode 100644 index 0000000000..0c719a64dc --- /dev/null +++ b/apps/example/src/Examples/HighBitDepth/index.ts @@ -0,0 +1 @@ +export * from "./HighBitDepth"; diff --git a/apps/example/src/Examples/index.ts b/apps/example/src/Examples/index.ts index fcae0abd4a..975ecfaf60 100644 --- a/apps/example/src/Examples/index.ts +++ b/apps/example/src/Examples/index.ts @@ -30,3 +30,4 @@ export * from "./Video"; export * from "./Chat"; export * from "./Pictures"; export * from "./WebGPU"; +export * from "./HighBitDepth"; diff --git a/apps/example/src/Home/HomeScreen.tsx b/apps/example/src/Home/HomeScreen.tsx index 73dde719f3..a9e7023523 100644 --- a/apps/example/src/Home/HomeScreen.tsx +++ b/apps/example/src/Home/HomeScreen.tsx @@ -137,6 +137,11 @@ export const HomeScreen = () => { route="WebGPU" /> )} + ); }; diff --git a/apps/example/src/types.ts b/apps/example/src/types.ts index bf8d530a0d..ed0671c515 100644 --- a/apps/example/src/types.ts +++ b/apps/example/src/types.ts @@ -32,4 +32,5 @@ export type StackParamList = { Chat: undefined; Pictures: undefined; WebGPU: undefined; + HighBitDepth: undefined; }; diff --git a/packages/skia/android/cpp/jni/include/JniSkiaBaseView.h b/packages/skia/android/cpp/jni/include/JniSkiaBaseView.h index 2bd2349982..b02aacb46f 100644 --- a/packages/skia/android/cpp/jni/include/JniSkiaBaseView.h +++ b/packages/skia/android/cpp/jni/include/JniSkiaBaseView.h @@ -31,13 +31,15 @@ class JniSkiaBaseView { protected: virtual void surfaceAvailable(jobject surface, int width, int height, - bool opaque) { - _skiaAndroidView->surfaceAvailable(surface, width, height, opaque); + bool opaque, bool highBitDepth) { + _skiaAndroidView->surfaceAvailable(surface, width, height, opaque, + highBitDepth); } virtual void surfaceSizeChanged(jobject surface, int width, int height, - bool opaque) { - _skiaAndroidView->surfaceSizeChanged(surface, width, height, opaque); + bool opaque, bool highBitDepth) { + _skiaAndroidView->surfaceSizeChanged(surface, width, height, opaque, + highBitDepth); } virtual void surfaceDestroyed() { _skiaAndroidView->surfaceDestroyed(); } diff --git a/packages/skia/android/cpp/jni/include/JniSkiaPictureView.h b/packages/skia/android/cpp/jni/include/JniSkiaPictureView.h index 616db8721e..0a394da6b7 100644 --- a/packages/skia/android/cpp/jni/include/JniSkiaPictureView.h +++ b/packages/skia/android/cpp/jni/include/JniSkiaPictureView.h @@ -63,14 +63,16 @@ class JniSkiaPictureView : public jni::HybridClass, } protected: - void surfaceAvailable(jobject surface, int width, int height, - bool opaque) override { - JniSkiaBaseView::surfaceAvailable(surface, width, height, opaque); + void surfaceAvailable(jobject surface, int width, int height, bool opaque, + bool highBitDepth) override { + JniSkiaBaseView::surfaceAvailable(surface, width, height, opaque, + highBitDepth); } - void surfaceSizeChanged(jobject surface, int width, int height, - bool opaque) override { - JniSkiaBaseView::surfaceSizeChanged(surface, width, height, opaque); + void surfaceSizeChanged(jobject surface, int width, int height, bool opaque, + bool highBitDepth) override { + JniSkiaBaseView::surfaceSizeChanged(surface, width, height, opaque, + highBitDepth); } void surfaceDestroyed() override { JniSkiaBaseView::surfaceDestroyed(); } diff --git a/packages/skia/android/cpp/rnskia-android/OpenGLContext.h b/packages/skia/android/cpp/rnskia-android/OpenGLContext.h index 21c8fed0a2..6a5e2e73ac 100644 --- a/packages/skia/android/cpp/rnskia-android/OpenGLContext.h +++ b/packages/skia/android/cpp/rnskia-android/OpenGLContext.h @@ -179,8 +179,17 @@ class OpenGLContext { } // TODO: remove width, height - std::unique_ptr MakeWindow(ANativeWindow *window) { + std::unique_ptr MakeWindow(ANativeWindow *window, + bool highBitDepth = false) { auto display = OpenGLSharedContext::getInstance().getDisplay(); + if (highBitDepth) { + // A 10-bit window surface would require the shared EGL context to be + // created without a config (EGL_KHR_no_config_context) for every app, + // which is too risky for existing 8-bit clients on brittle drivers. + RNSkLogger::logToConsole( + "highBitDepth is not supported on the OpenGL backend, falling back " + "to the 8-bit format (the Graphite backend supports it)"); + } return std::make_unique( _directContext.get(), display, _glContext.get(), window, OpenGLSharedContext::getInstance().getConfig()); diff --git a/packages/skia/android/cpp/rnskia-android/RNSkAndroidView.h b/packages/skia/android/cpp/rnskia-android/RNSkAndroidView.h index e0533bf5d9..720d531c9f 100644 --- a/packages/skia/android/cpp/rnskia-android/RNSkAndroidView.h +++ b/packages/skia/android/cpp/rnskia-android/RNSkAndroidView.h @@ -12,12 +12,12 @@ namespace RNSkia { class RNSkBaseAndroidView { public: virtual void surfaceAvailable(jobject surface, int width, int height, - bool opaque) = 0; + bool opaque, bool highBitDepth) = 0; virtual void surfaceDestroyed() = 0; virtual void surfaceSizeChanged(jobject surface, int width, int height, - bool opaque) = 0; + bool opaque, bool highBitDepth) = 0; virtual float getPixelDensity() = 0; @@ -34,10 +34,10 @@ class RNSkAndroidView : public T, public RNSkBaseAndroidView { std::make_shared( std::bind(&RNSkia::RNSkView::requestRedraw, this), context)) {} - void surfaceAvailable(jobject surface, int width, int height, - bool opaque) override { + void surfaceAvailable(jobject surface, int width, int height, bool opaque, + bool highBitDepth) override { std::static_pointer_cast(T::getCanvasProvider()) - ->surfaceAvailable(surface, width, height, opaque); + ->surfaceAvailable(surface, width, height, opaque, highBitDepth); RNSkView::redraw(); } @@ -46,10 +46,10 @@ class RNSkAndroidView : public T, public RNSkBaseAndroidView { ->surfaceDestroyed(); } - void surfaceSizeChanged(jobject surface, int width, int height, - bool opaque) override { + void surfaceSizeChanged(jobject surface, int width, int height, bool opaque, + bool highBitDepth) override { std::static_pointer_cast(T::getCanvasProvider()) - ->surfaceSizeChanged(surface, width, height, opaque); + ->surfaceSizeChanged(surface, width, height, opaque, highBitDepth); // This is only need for the first time to frame, this renderImmediate call // will invoke updateTexImage for the previous frame RNSkView::redraw(); diff --git a/packages/skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.cpp b/packages/skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.cpp index e8e4c0314b..34d0fc75ef 100644 --- a/packages/skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.cpp +++ b/packages/skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.cpp @@ -78,7 +78,8 @@ bool RNSkOpenGLCanvasProvider::renderToCanvas( void RNSkOpenGLCanvasProvider::surfaceAvailable(jobject jSurfaceTexture, int width, int height, - bool opaque) { + bool opaque, + bool highBitDepth) { // Release the old surface _surfaceHolder = nullptr; @@ -108,9 +109,11 @@ void RNSkOpenGLCanvasProvider::surfaceAvailable(jobject jSurfaceTexture, window = ANativeWindow_fromSurface(env, jSurfaceTexture); } #if defined(SK_GRAPHITE) - _surfaceHolder = DawnContext::getInstance().MakeWindow(window, width, height); + _surfaceHolder = DawnContext::getInstance().MakeWindow(window, width, height, + highBitDepth); #else - _surfaceHolder = OpenGLContext::getInstance().MakeWindow(window); + _surfaceHolder = + OpenGLContext::getInstance().MakeWindow(window, highBitDepth); #endif // Post redraw request to ensure we paint in the next draw cycle. @@ -128,7 +131,8 @@ void RNSkOpenGLCanvasProvider::surfaceDestroyed() { } void RNSkOpenGLCanvasProvider::surfaceSizeChanged(jobject jSurface, int width, - int height, bool opaque) { + int height, bool opaque, + bool highBitDepth) { if (width == 0 && height == 0) { // Setting width/height to zero is nothing we need to care about when // it comes to invalidating the surface. @@ -137,7 +141,7 @@ void RNSkOpenGLCanvasProvider::surfaceSizeChanged(jobject jSurface, int width, if (_surfaceHolder == nullptr) { _surfaceHolder = nullptr; - surfaceAvailable(jSurface, width, height, opaque); + surfaceAvailable(jSurface, width, height, opaque, highBitDepth); } else { _surfaceHolder->resize(width, height); } diff --git a/packages/skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.h b/packages/skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.h index ca5768de24..213e18e49c 100644 --- a/packages/skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.h +++ b/packages/skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.h @@ -27,11 +27,13 @@ class RNSkOpenGLCanvasProvider bool renderToCanvas(const std::function &cb) override; - void surfaceAvailable(jobject surface, int width, int height, bool opaque); + void surfaceAvailable(jobject surface, int width, int height, bool opaque, + bool highBitDepth); void surfaceDestroyed(); - void surfaceSizeChanged(jobject jSurface, int width, int height, bool opaque); + void surfaceSizeChanged(jobject jSurface, int width, int height, bool opaque, + bool highBitDepth); private: std::unique_ptr _surfaceHolder = nullptr; diff --git a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/SkiaBaseView.java b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/SkiaBaseView.java index f012c45ccb..61def2ea42 100644 --- a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/SkiaBaseView.java +++ b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/SkiaBaseView.java @@ -13,6 +13,8 @@ public abstract class SkiaBaseView extends ReactViewGroup implements SkiaViewAPI { private View mView; + private boolean mHighBitDepth = false; + private final boolean debug = false; private final String tag = "SkiaView"; @@ -34,14 +36,48 @@ public boolean dispatchTouchEvent(MotionEvent ev) { public void setOpaque(boolean value) { if (value && mView instanceof SkiaTextureView) { - removeView(mView); - mView = new SkiaSurfaceView(getContext(), this, debug); - addView(mView); + recreateView(true); } else if (!value && mView instanceof SkiaSurfaceView) { - removeView(mView); - mView = new SkiaTextureView(getContext(), this, debug); - addView(mView); + recreateView(false); + } + } + + public void setHighBitDepth(boolean value) { + if (mHighBitDepth == value) { + return; + } + mHighBitDepth = value; + // The flag only affects the opaque SurfaceView path (see + // highBitDepthIfOpaque), so only that surface needs to be recreated + // with the new buffer format. + if (mView instanceof SkiaSurfaceView) { + recreateView(true); + } + } + + private void recreateView(boolean useSurfaceView) { + removeView(mView); + mView = useSurfaceView + ? new SkiaSurfaceView(getContext(), this, debug) + : new SkiaTextureView(getContext(), this, debug); + addView(mView); + // React Native sizes native children explicitly through onLayout, so + // the requestLayout triggered by addView is ignored; size the new + // child ourselves or it stays 0x0 and never gets a surface. + if (getWidth() > 0 || getHeight() > 0) { + mView.layout(0, 0, getWidth(), getHeight()); + } + } + + private boolean highBitDepthIfOpaque(boolean opaque) { + if (mHighBitDepth && !opaque) { + // The 10-bit buffer format only has 2 bits of alpha, which would + // visibly break translucency; the extra precision would also be + // lost in the 8-bit composition pass. + Log.w(tag, "highBitDepth requires the opaque prop on Android, falling back to the 8-bit format"); + return false; } + return mHighBitDepth; } void dropInstance() { @@ -59,24 +95,24 @@ protected void onLayout(boolean changed, int left, int top, int right, int botto @Override public void onSurfaceCreated(Surface surface, int width, int height) { - surfaceAvailable(surface, width, height, true); + surfaceAvailable(surface, width, height, true, mHighBitDepth); } @Override public void onSurfaceChanged(Surface surface, int width, int height) { Log.i(tag, "onSurfaceTextureSizeChanged " + width + "/" + height); - surfaceSizeChanged(surface, width, height, true); + surfaceSizeChanged(surface, width, height, true, mHighBitDepth); } @Override public void onSurfaceTextureCreated(SurfaceTexture surface, int width, int height) { - surfaceAvailable(surface, width, height, false); + surfaceAvailable(surface, width, height, false, highBitDepthIfOpaque(false)); } @Override public void onSurfaceTextureChanged(SurfaceTexture surface, int width, int height) { Log.i(tag, "onSurfaceTextureSizeChanged " + width + "/" + height); - surfaceSizeChanged(surface, width, height, false); + surfaceSizeChanged(surface, width, height, false, highBitDepthIfOpaque(false)); } @Override @@ -84,9 +120,9 @@ public void onSurfaceDestroyed() { surfaceDestroyed(); } - protected abstract void surfaceAvailable(Object surface, int width, int height, boolean opaque); + protected abstract void surfaceAvailable(Object surface, int width, int height, boolean opaque, boolean highBitDepth); - protected abstract void surfaceSizeChanged(Object surface, int width, int height, boolean opaque); + protected abstract void surfaceSizeChanged(Object surface, int width, int height, boolean opaque, boolean highBitDepth); protected abstract void surfaceDestroyed(); diff --git a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/SkiaBaseViewManager.java b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/SkiaBaseViewManager.java index 08e07b8489..341af3c8d0 100644 --- a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/SkiaBaseViewManager.java +++ b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/SkiaBaseViewManager.java @@ -28,6 +28,11 @@ public void setOpaque(T view, boolean value) { ((SkiaBaseView)view).setOpaque(value); } + @ReactProp(name = "highBitDepth") + public void setHighBitDepth(T view, boolean value) { + ((SkiaBaseView)view).setHighBitDepth(value); + } + @ReactProp(name = ViewProps.POINTER_EVENTS) public void setPointerEvents(T view, @Nullable String pointerEventsStr) { view.setPointerEvents(PointerEvents.parsePointerEvents(pointerEventsStr)); diff --git a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/SkiaPictureView.java b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/SkiaPictureView.java index 8a8e9854ee..c4e23172af 100644 --- a/packages/skia/android/src/main/java/com/shopify/reactnative/skia/SkiaPictureView.java +++ b/packages/skia/android/src/main/java/com/shopify/reactnative/skia/SkiaPictureView.java @@ -67,9 +67,9 @@ protected void onDraw(Canvas canvas) { private native HybridData initHybrid(SkiaManager skiaManager); - protected native void surfaceAvailable(Object surface, int width, int height, boolean opaque); + protected native void surfaceAvailable(Object surface, int width, int height, boolean opaque, boolean highBitDepth); - protected native void surfaceSizeChanged(Object surface, int width, int height, boolean opaque); + protected native void surfaceSizeChanged(Object surface, int width, int height, boolean opaque, boolean highBitDepth); protected native void surfaceDestroyed(); diff --git a/packages/skia/apple/MetalContext.h b/packages/skia/apple/MetalContext.h index 3fd4e908ca..7915af193b 100644 --- a/packages/skia/apple/MetalContext.h +++ b/packages/skia/apple/MetalContext.h @@ -95,13 +95,14 @@ class MetalContext { } } - std::unique_ptr - MakeWindow(CALayer *window, int width, int height, - bool useP3ColorSpace = true) { + std::unique_ptr MakeWindow(CALayer *window, int width, + int height, + bool useP3ColorSpace = true, + bool highBitDepth = false) { auto device = _device; - return std::make_unique(_directContext.get(), device, - _commandQueue, window, width, - height, useP3ColorSpace); + return std::make_unique( + _directContext.get(), device, _commandQueue, window, width, height, + useP3ColorSpace, highBitDepth); } GrDirectContext *getDirectContext() { return _directContext.get(); } diff --git a/packages/skia/apple/MetalLayerColorSpace.mm b/packages/skia/apple/MetalLayerColorSpace.mm new file mode 100644 index 0000000000..d8533509a4 --- /dev/null +++ b/packages/skia/apple/MetalLayerColorSpace.mm @@ -0,0 +1,39 @@ +#ifdef SK_GRAPHITE + +#import +#import +#import +#import + +#include "MetalLayerColorSpaceUtils.h" +#include "rnskia/RNMetalLayerColorSpace.h" + +namespace RNSkia { + +// WebGPU canvas values are sRGB-encoded regardless of the texture format +// (GPUCanvasConfiguration.colorSpace defaults to "srgb"), so the same shader +// output must display identically on bgra8unorm and rgba16float surfaces. +// Tagging the float layer as (gamma-encoded) extended sRGB matches the +// browser behavior: identical colors, with the extra precision of float16. +void applyCAMetalLayerColorSpace(void *nativeSurface, + wgpu::TextureFormat format) { + CALayer *layer = (__bridge CALayer *)nativeSurface; + if (![layer isKindOfClass:[CAMetalLayer class]]) { + return; + } + auto metalLayer = static_cast(layer); + setCAMetalLayerColorSpace(metalLayer, + format == wgpu::TextureFormat::RGBA16Float, false); + // The change must be set synchronously so the first present already sees + // it, and it must reach the render server. On a non-main thread (RN JS or + // worklet runtime) the property lands in that thread's implicit + // CATransaction, which may never commit on threads without a spinning + // runloop, so flush it now. On the main thread the runloop commits it. + if (!NSThread.isMainThread) { + [CATransaction flush]; + } +} + +} // namespace RNSkia + +#endif // SK_GRAPHITE diff --git a/packages/skia/apple/MetalLayerColorSpaceUtils.h b/packages/skia/apple/MetalLayerColorSpaceUtils.h new file mode 100644 index 0000000000..2b7d3bb149 --- /dev/null +++ b/packages/skia/apple/MetalLayerColorSpaceUtils.h @@ -0,0 +1,34 @@ +#pragma once + +#import +#import + +namespace RNSkia { + +// The surface writes sRGB-encoded (SDR) values regardless of the texture +// format. With an 8-bit format a nil colorspace displays them as-is, but Core +// Animation interprets a float-format layer with a nil colorspace as extended +// linear sRGB, which displays the same values noticeably brighter. Tag the +// float layer with the matching gamma-encoded (extended) colorspace so colors +// are identical to the 8-bit path, only with more precision. +inline void setCAMetalLayerColorSpace(CAMetalLayer *layer, bool isFloatFormat, + bool useP3ColorSpace) { + if (isFloatFormat) { + CGColorSpaceRef colorSpace = CGColorSpaceCreateWithName( + useP3ColorSpace ? kCGColorSpaceExtendedDisplayP3 + : kCGColorSpaceExtendedSRGB); + layer.colorspace = colorSpace; + CGColorSpaceRelease(colorSpace); + } else if (useP3ColorSpace) { + CGColorSpaceRef colorSpace = + CGColorSpaceCreateWithName(kCGColorSpaceDisplayP3); + layer.colorspace = colorSpace; + CGColorSpaceRelease(colorSpace); + } else if (layer.colorspace != nil) { + // Restore the default (no color matching) when reconfiguring a layer + // back to an 8-bit format. + layer.colorspace = nil; + } +} + +} // namespace RNSkia diff --git a/packages/skia/apple/MetalWindowContext.h b/packages/skia/apple/MetalWindowContext.h index 867f6db89b..4fa3fd2d3f 100644 --- a/packages/skia/apple/MetalWindowContext.h +++ b/packages/skia/apple/MetalWindowContext.h @@ -10,7 +10,8 @@ class MetalWindowContext : public RNSkia::WindowContext { public: MetalWindowContext(GrDirectContext *directContext, id device, id commandQueue, CALayer *layer, - int width, int height, bool useP3ColorSpace = true); + int width, int height, bool useP3ColorSpace = true, + bool highBitDepth = false); ~MetalWindowContext() = default; sk_sp getSurface() override; @@ -37,4 +38,5 @@ class MetalWindowContext : public RNSkia::WindowContext { #pragma clang diagnostic pop id _currentDrawable = nil; bool _useP3ColorSpace = false; + bool _highBitDepth = false; }; diff --git a/packages/skia/apple/MetalWindowContext.mm b/packages/skia/apple/MetalWindowContext.mm index 4cbfafa69f..b93d4c37a3 100644 --- a/packages/skia/apple/MetalWindowContext.mm +++ b/packages/skia/apple/MetalWindowContext.mm @@ -1,6 +1,7 @@ #include "MetalWindowContext.h" #include "MetalContext.h" +#include "MetalLayerColorSpaceUtils.h" #include "RNSkLog.h" #include "include/core/SkColorSpace.h" @@ -8,8 +9,9 @@ id device, id commandQueue, CALayer *layer, int width, int height, - bool useP3ColorSpace) - : _directContext(directContext), _commandQueue(commandQueue) { + bool useP3ColorSpace, bool highBitDepth) + : _directContext(directContext), _commandQueue(commandQueue), + _highBitDepth(highBitDepth) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wunguarded-availability-new" _layer = (CAMetalLayer *)layer; @@ -22,7 +24,8 @@ #else _layer.contentsScale = [NSScreen mainScreen].backingScaleFactor; #endif // !TARGET_OS_OSX - _layer.pixelFormat = MTLPixelFormatBGRA8Unorm; + _layer.pixelFormat = + _highBitDepth ? MTLPixelFormatRGBA16Float : MTLPixelFormatBGRA8Unorm; _layer.contentsGravity = kCAGravityBottomLeft; _layer.drawableSize = CGSizeMake(width, height); BOOL supportsWideColor = NO; @@ -43,12 +46,9 @@ #endif // !TARGET_OS_OSX } if (supportsWideColor) { - CGColorSpaceRef colorSpace = - CGColorSpaceCreateWithName(kCGColorSpaceDisplayP3); - _layer.colorspace = colorSpace; - CGColorSpaceRelease(colorSpace); _useP3ColorSpace = true; } + RNSkia::setCAMetalLayerColorSpace(_layer, _highBitDepth, _useP3ColorSpace); } sk_sp MetalWindowContext::getSurface() { @@ -77,7 +77,8 @@ : nullptr; _skSurface = SkSurfaces::WrapBackendRenderTarget( _directContext, backendRT, kTopLeft_GrSurfaceOrigin, - kBGRA_8888_SkColorType, skColorSpace, nullptr); + _highBitDepth ? kRGBA_F16_SkColorType : kBGRA_8888_SkColorType, + skColorSpace, nullptr); return _skSurface; } diff --git a/packages/skia/apple/RNSkAppleView.h b/packages/skia/apple/RNSkAppleView.h index cb7537ca02..540b22325a 100644 --- a/packages/skia/apple/RNSkAppleView.h +++ b/packages/skia/apple/RNSkAppleView.h @@ -11,6 +11,7 @@ class RNSkBaseAppleView { virtual CALayer *getLayer() = 0; virtual void setSize(int width, int height) = 0; virtual void setUseP3ColorSpace(bool useP3ColorSpace) = 0; + virtual void setHighBitDepth(bool highBitDepth) = 0; virtual std::shared_ptr getDrawView() = 0; }; @@ -37,6 +38,11 @@ template class RNSkAppleView : public RNSkBaseAppleView, public T { ->setUseP3ColorSpace(useP3ColorSpace); } + void setHighBitDepth(bool highBitDepth) override { + std::static_pointer_cast(this->getCanvasProvider()) + ->setHighBitDepth(highBitDepth); + } + std::shared_ptr getDrawView() override { return this->shared_from_this(); } diff --git a/packages/skia/apple/RNSkMetalCanvasProvider.h b/packages/skia/apple/RNSkMetalCanvasProvider.h index 205e7ad99d..5f6a240332 100644 --- a/packages/skia/apple/RNSkMetalCanvasProvider.h +++ b/packages/skia/apple/RNSkMetalCanvasProvider.h @@ -28,6 +28,7 @@ class RNSkMetalCanvasProvider : public RNSkia::RNSkCanvasProvider { void setSize(int width, int height); void setUseP3ColorSpace(bool useP3ColorSpace); + void setHighBitDepth(bool highBitDepth); CALayer *getLayer(); private: @@ -38,4 +39,5 @@ class RNSkMetalCanvasProvider : public RNSkia::RNSkCanvasProvider { CAMetalLayer *_layer; #pragma clang diagnostic pop bool _useP3ColorSpace = true; + bool _highBitDepth = false; }; diff --git a/packages/skia/apple/RNSkMetalCanvasProvider.mm b/packages/skia/apple/RNSkMetalCanvasProvider.mm index cc5007733e..90ad9e64fc 100644 --- a/packages/skia/apple/RNSkMetalCanvasProvider.mm +++ b/packages/skia/apple/RNSkMetalCanvasProvider.mm @@ -96,9 +96,10 @@ auto h = height * _context->getPixelDensity(); #if defined(SK_GRAPHITE) _ctx = RNSkia::DawnContext::getInstance().MakeWindow((__bridge void *)_layer, - w, h); + w, h, _highBitDepth); #else - _ctx = MetalContext::getInstance().MakeWindow(_layer, w, h, _useP3ColorSpace); + _ctx = MetalContext::getInstance().MakeWindow(_layer, w, h, _useP3ColorSpace, + _highBitDepth); #endif _requestRedraw(); } @@ -108,3 +109,15 @@ void RNSkMetalCanvasProvider::setUseP3ColorSpace(bool useP3ColorSpace) { _useP3ColorSpace = useP3ColorSpace; } + +void RNSkMetalCanvasProvider::setHighBitDepth(bool highBitDepth) { + if (_highBitDepth == highBitDepth) { + return; + } + _highBitDepth = highBitDepth; + if (_ctx) { + // Recreate the window context so the layer's pixel format matches the + // new bit depth. + setSize(_layer.frame.size.width, _layer.frame.size.height); + } +} diff --git a/packages/skia/apple/SkiaPictureView.mm b/packages/skia/apple/SkiaPictureView.mm index 85b4499750..e277798dee 100644 --- a/packages/skia/apple/SkiaPictureView.mm +++ b/packages/skia/apple/SkiaPictureView.mm @@ -55,6 +55,7 @@ - (void)updateProps:(const Props::Shared &)props [self setNativeId:nativeId]; [self setDebugMode:newProps.debug]; [self setOpaque:newProps.opaque]; + [self setHighBitDepth:newProps.highBitDepth]; if (newProps.colorSpace == "" || newProps.colorSpace == "srgb") { bool useP3 = false; [self setUseP3ColorSpace:useP3]; diff --git a/packages/skia/apple/SkiaPictureViewManager.mm b/packages/skia/apple/SkiaPictureViewManager.mm index e36d7a4420..4c711e79ee 100644 --- a/packages/skia/apple/SkiaPictureViewManager.mm +++ b/packages/skia/apple/SkiaPictureViewManager.mm @@ -37,6 +37,11 @@ - (SkiaManager *)skiaManager { [(SkiaUIView *)view setOpaque:opaque]; } +RCT_CUSTOM_VIEW_PROPERTY(highBitDepth, BOOL, SkiaUIView) { + bool highBitDepth = json != NULL ? [RCTConvert BOOL:json] : false; + [(SkiaUIView *)view setHighBitDepth:highBitDepth]; +} + #if !TARGET_OS_OSX - (UIView *)view { #else diff --git a/packages/skia/apple/SkiaUIView.h b/packages/skia/apple/SkiaUIView.h index f38d5e6fc4..78121bb3b9 100644 --- a/packages/skia/apple/SkiaUIView.h +++ b/packages/skia/apple/SkiaUIView.h @@ -42,5 +42,6 @@ - (void)setOpaque:(bool)opaque; - (void)setNativeId:(size_t)nativeId; - (void)setUseP3ColorSpace:(bool)useP3ColorSpace; +- (void)setHighBitDepth:(bool)highBitDepth; @end diff --git a/packages/skia/apple/SkiaUIView.mm b/packages/skia/apple/SkiaUIView.mm index 69d45a8017..33a67871cb 100644 --- a/packages/skia/apple/SkiaUIView.mm +++ b/packages/skia/apple/SkiaUIView.mm @@ -18,6 +18,7 @@ @implementation SkiaUIView { bool _debugMode; bool _opaque; bool _useP3ColorSpace; + bool _highBitDepth; size_t _nativeId; } @@ -76,6 +77,7 @@ - (void)viewWillMoveToSuperview:(NSView *)newSuperView { } _impl->getDrawView()->setShowDebugOverlays(_debugMode); _impl->setUseP3ColorSpace(_useP3ColorSpace); + _impl->setHighBitDepth(_highBitDepth); } } } @@ -176,6 +178,13 @@ - (void)setUseP3ColorSpace:(bool)useP3ColorSpace { } } +- (void)setHighBitDepth:(bool)highBitDepth { + _highBitDepth = highBitDepth; + if (_impl != nullptr) { + _impl->setHighBitDepth(_highBitDepth); + } +} + #pragma mark External API - (std::shared_ptr)impl { diff --git a/packages/skia/cpp/rnskia/RNDawnContext.h b/packages/skia/cpp/rnskia/RNDawnContext.h index 94b27cff8a..aabf4bf0ab 100644 --- a/packages/skia/cpp/rnskia/RNDawnContext.h +++ b/packages/skia/cpp/rnskia/RNDawnContext.h @@ -325,8 +325,8 @@ class DawnContext { } // Create onscreen surface with window - std::unique_ptr MakeWindow(void *window, int width, - int height) { + std::unique_ptr MakeWindow(void *window, int width, int height, + bool highBitDepth = false) { // 1. Create Surface wgpu::SurfaceDescriptor surfaceDescriptor; #ifdef __APPLE__ @@ -341,7 +341,8 @@ class DawnContext { auto surface = wgpu::Instance(instance->Get()).CreateSurface(&surfaceDescriptor); return std::make_unique( - getRecorder(), backendContext.fDevice, surface, width, height); + getRecorder(), backendContext.fDevice, surface, window, width, height, + highBitDepth); } skgpu::graphite::Recorder *getRecorder() { diff --git a/packages/skia/cpp/rnskia/RNDawnUtils.h b/packages/skia/cpp/rnskia/RNDawnUtils.h index dffc54a9ea..d1a0f2190d 100644 --- a/packages/skia/cpp/rnskia/RNDawnUtils.h +++ b/packages/skia/cpp/rnskia/RNDawnUtils.h @@ -5,9 +5,9 @@ #include "dawn/dawn_proc.h" #include "dawn/native/DawnNative.h" -#include "utils/RNSkLog.h" #include "include/core/SkColorType.h" #include "include/gpu/graphite/dawn/DawnBackendContext.h" +#include "utils/RNSkLog.h" namespace DawnUtils { @@ -21,6 +21,24 @@ static const wgpu::TextureFormat PreferredTextureFormat = wgpu::TextureFormat::RGBA8Unorm; #endif +// On-screen format used when the view requests high bit depth. The values +// stay sRGB-encoded (SDR), only with more precision than 8 bits per channel; +// this is about banding, not HDR. +// - Apple: 16-bit float, displayed through the extended sRGB layer colorspace +// so colors match the 8-bit path exactly. +// - Android: 10-bit unorm. SurfaceFlinger quantizes SDR float16 layers during +// composition, but RGBA_1010102 buffers keep their precision through +// composition, including direct scanout on 10-bit panels. +#ifdef __APPLE__ +static const SkColorType HighBitDepthColorType = kRGBA_F16_SkColorType; +static const wgpu::TextureFormat HighBitDepthTextureFormat = + wgpu::TextureFormat::RGBA16Float; +#else +static const SkColorType HighBitDepthColorType = kRGBA_1010102_SkColorType; +static const wgpu::TextureFormat HighBitDepthTextureFormat = + wgpu::TextureFormat::RGB10A2Unorm; +#endif + // Find the best matching GPU adapter for the current platform. // Sorts by adapter type (DiscreteGPU > IntegratedGPU > CPU) and selects the // first adapter matching the platform backend (Metal on Apple, Vulkan on @@ -207,11 +225,12 @@ createDawnBackendContext(dawn::native::Instance *instance) { #ifdef __APPLE__ wgpu::FeatureName::SharedTextureMemoryIOSurface, // Required to call SharedTextureMemory::EndAccess on Metal (it exports a - // MTLSharedEvent fence). importExternalTexture / importSharedTextureMemory - // end the access window after submit; without this EndAccess errors with - // "Required feature (SharedFenceMTLSharedEvent) is missing". Safe here - // because we always queue.submit() before EndAccess (the secondary device - // omits it on purpose — its camera path doesn't commit first). + // MTLSharedEvent fence). importExternalTexture / + // importSharedTextureMemory end the access window after submit; without + // this EndAccess errors with "Required feature + // (SharedFenceMTLSharedEvent) is missing". Safe here because we always + // queue.submit() before EndAccess (the secondary device omits it on + // purpose; its camera path doesn't commit first). wgpu::FeatureName::SharedFenceMTLSharedEvent, wgpu::FeatureName::DawnMultiPlanarFormats, wgpu::FeatureName::MultiPlanarFormatP010, diff --git a/packages/skia/cpp/rnskia/RNDawnWindowContext.h b/packages/skia/cpp/rnskia/RNDawnWindowContext.h index c9e6422521..a73ca0fa81 100644 --- a/packages/skia/cpp/rnskia/RNDawnWindowContext.h +++ b/packages/skia/cpp/rnskia/RNDawnWindowContext.h @@ -1,6 +1,7 @@ #pragma once #include "RNDawnUtils.h" +#include "RNMetalLayerColorSpace.h" #include "RNWindowContext.h" #include "dawn/native/MetalBackend.h" @@ -24,9 +25,22 @@ namespace RNSkia { class DawnWindowContext : public WindowContext { public: DawnWindowContext(skgpu::graphite::Recorder *recorder, wgpu::Device device, - wgpu::Surface surface, int width, int height) - : _recorder(recorder), _device(device), _surface(surface), _width(width), - _height(height) { + wgpu::Surface surface, void *nativeSurface, int width, + int height, bool highBitDepth = false) + : _recorder(recorder), _device(device), _surface(surface), + _nativeSurface(nativeSurface), _width(width), _height(height) { + _format = DawnUtils::PreferredTextureFormat; + _colorType = DawnUtils::PreferedColorType; + if (highBitDepth) { + if (surfaceSupportsFormat(DawnUtils::HighBitDepthTextureFormat)) { + _format = DawnUtils::HighBitDepthTextureFormat; + _colorType = DawnUtils::HighBitDepthColorType; + } else { + RNSkLogger::logToConsole( + "High bit depth was requested but the surface does not support " + "it, falling back to the 8-bit format"); + } + } configureSurface(); } @@ -38,15 +52,13 @@ class DawnWindowContext : public WindowContext { return nullptr; } skgpu::graphite::DawnTextureInfo info( - skgpu::graphite::SampleCount::k1, skgpu::Mipmapped::kNo, - DawnUtils::PreferredTextureFormat, texture.GetUsage(), - wgpu::TextureAspect::All); + skgpu::graphite::SampleCount::k1, skgpu::Mipmapped::kNo, _format, + texture.GetUsage(), wgpu::TextureAspect::All); auto backendTex = skgpu::graphite::BackendTextures::MakeDawn(texture.Get()); sk_sp colorSpace = SkColorSpace::MakeSRGB(); SkSurfaceProps surfaceProps; - auto surface = SkSurfaces::WrapBackendTexture(_recorder, backendTex, - DawnUtils::PreferedColorType, - colorSpace, &surfaceProps); + auto surface = SkSurfaces::WrapBackendTexture( + _recorder, backendTex, _colorType, colorSpace, &surfaceProps); return surface; } @@ -66,7 +78,7 @@ class DawnWindowContext : public WindowContext { void configureSurface() { wgpu::SurfaceConfiguration config; config.device = _device; - config.format = DawnUtils::PreferredTextureFormat; + config.format = _format; config.width = _width; config.height = _height; config.presentMode = wgpu::PresentMode::Fifo; @@ -74,12 +86,34 @@ class DawnWindowContext : public WindowContext { config.alphaMode = wgpu::CompositeAlphaMode::Premultiplied; #endif _surface.Configure(&config); +#ifdef __APPLE__ + // Float formats need the layer tagged as (gamma-encoded) extended sRGB so + // the sRGB-encoded values display identically to the 8-bit path. + applyCAMetalLayerColorSpace(_nativeSurface, _format); +#endif + } + + bool surfaceSupportsFormat(wgpu::TextureFormat format) { + wgpu::SurfaceCapabilities capabilities; + if (_surface.GetCapabilities(_device.GetAdapter(), &capabilities) != + wgpu::Status::Success) { + return false; + } + for (size_t i = 0; i < capabilities.formatCount; i++) { + if (capabilities.formats[i] == format) { + return true; + } + } + return false; } skgpu::graphite::Recorder *_recorder; // TODO: keep device in DawnContext? Do we need it for resizing? wgpu::Device _device; wgpu::Surface _surface; + [[maybe_unused]] void *_nativeSurface; + wgpu::TextureFormat _format; + SkColorType _colorType; int _width; int _height; }; diff --git a/packages/skia/cpp/rnskia/RNMetalLayerColorSpace.h b/packages/skia/cpp/rnskia/RNMetalLayerColorSpace.h new file mode 100644 index 0000000000..ed8b8916ae --- /dev/null +++ b/packages/skia/cpp/rnskia/RNMetalLayerColorSpace.h @@ -0,0 +1,16 @@ +#pragma once + +#ifdef __APPLE__ + +#include "webgpu/webgpu_cpp.h" + +namespace RNSkia { + +// Tags the CAMetalLayer with the colorspace matching the configured texture +// format. Implemented in apple/MetalLayerColorSpace.mm. +void applyCAMetalLayerColorSpace(void *nativeSurface, + wgpu::TextureFormat format); + +} // namespace RNSkia + +#endif // __APPLE__ diff --git a/packages/skia/cpp/rnwgpu/SurfaceRegistry.h b/packages/skia/cpp/rnwgpu/SurfaceRegistry.h index 279dbb1a54..3a3758f61d 100644 --- a/packages/skia/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/skia/cpp/rnwgpu/SurfaceRegistry.h @@ -8,6 +8,8 @@ #include "webgpu/webgpu_cpp.h" #ifdef __APPLE__ +#include "rnskia/RNMetalLayerColorSpace.h" + namespace dawn::native::metal { void WaitForCommandsToBeScheduled(WGPUDevice device); } // namespace dawn::native::metal @@ -126,10 +128,11 @@ class SurfaceInfo { #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. + // 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); @@ -186,6 +189,9 @@ class SurfaceInfo { void _configure() { if (surface) { surface.Configure(&config); +#ifdef __APPLE__ + RNSkia::applyCAMetalLayerColorSpace(nativeSurface, config.format); +#endif } else { wgpu::TextureDescriptor textureDesc; textureDesc.format = config.format; diff --git a/packages/skia/react-native-skia.podspec b/packages/skia/react-native-skia.podspec index f075e62b33..0c71aa3ab6 100644 --- a/packages/skia/react-native-skia.podspec +++ b/packages/skia/react-native-skia.podspec @@ -143,6 +143,7 @@ Pod::Spec.new do |s| graphite_exclusions = [ 'cpp/rnskia/RNDawnContext.h', 'cpp/rnskia/RNDawnUtils.h', + 'cpp/rnskia/RNMetalLayerColorSpace.h', 'cpp/rnskia/RNDawnWindowContext.h', 'cpp/rnskia/RNDawnWindowContext.cpp', 'cpp/rnskia/RNImageProvider.h', diff --git a/packages/skia/src/mock/index.ts b/packages/skia/src/mock/index.ts index 4866c02449..32a7b34565 100644 --- a/packages/skia/src/mock/index.ts +++ b/packages/skia/src/mock/index.ts @@ -24,6 +24,7 @@ export const Mock = (CanvasKit: CanvasKit) => { ...require("../dom/nodes"), Canvas: require("react-native").View, WebGPUCanvas: require("react-native").View, + getPreferredHighBitDepthCanvasFormat: () => "rgba16float", SkiaPictureView: require("react-native").View, JsiSkImage: JsiSkImage, drawAsPicture: Noop, diff --git a/packages/skia/src/renderer/Canvas.tsx b/packages/skia/src/renderer/Canvas.tsx index 8c630d69a5..184f763ab5 100644 --- a/packages/skia/src/renderer/Canvas.tsx +++ b/packages/skia/src/renderer/Canvas.tsx @@ -69,6 +69,15 @@ export interface CanvasProps extends Omit { opaque?: boolean; onSize?: SharedValue; colorSpace?: "p3" | "srgb"; + /** + * Renders into a surface with more than 8 bits per channel (16-bit float on + * iOS, 10-bit on Android) to avoid banding in subtle gradients. Colors are + * identical to the default 8-bit surface, only with more precision (this is + * about bit depth, not HDR). On Android the extra precision survives + * composition only when combined with `opaque`, and the prop must be set + * before the canvas is mounted. + */ + highBitDepth?: boolean; ref?: React.Ref; androidWarmup?: boolean; __destroyWebGLContextAfterRender?: boolean; @@ -80,6 +89,7 @@ export const Canvas = ({ children, onSize, colorSpace = "p3", + highBitDepth = false, androidWarmup = false, ref, onLayout, @@ -180,6 +190,7 @@ export const Canvas = ({ debug={debug} opaque={opaque} colorSpace={colorSpace} + highBitDepth={highBitDepth} androidWarmup={androidWarmup} onLayout={ Platform.OS === "web" && (onSize || onLayout) ? onLayoutWeb : onLayout diff --git a/packages/skia/src/specs/SkiaPictureViewNativeComponent.ts b/packages/skia/src/specs/SkiaPictureViewNativeComponent.ts index 57945939eb..f46efd87e7 100644 --- a/packages/skia/src/specs/SkiaPictureViewNativeComponent.ts +++ b/packages/skia/src/specs/SkiaPictureViewNativeComponent.ts @@ -7,6 +7,7 @@ export interface NativeProps extends ViewProps { debug?: boolean; opaque?: boolean; colorSpace?: string; + highBitDepth?: boolean; androidWarmup?: boolean; pointerEvents?: WithDefault< "auto" | "none" | "box-none" | "box-only", diff --git a/packages/skia/src/views/SkiaPictureView.tsx b/packages/skia/src/views/SkiaPictureView.tsx index e4516840eb..5d66961eb7 100644 --- a/packages/skia/src/views/SkiaPictureView.tsx +++ b/packages/skia/src/views/SkiaPictureView.tsx @@ -87,6 +87,7 @@ export class SkiaPictureView extends React.Component { mode, debug = false, opaque = false, + highBitDepth = false, androidWarmup = false, ...viewProps } = this.props; @@ -96,6 +97,7 @@ export class SkiaPictureView extends React.Component { nativeID={`${this._nativeId}`} debug={debug} opaque={opaque} + highBitDepth={highBitDepth} androidWarmup={androidWarmup} {...viewProps} /> diff --git a/packages/skia/src/views/formats.ts b/packages/skia/src/views/formats.ts new file mode 100644 index 0000000000..867aab940c --- /dev/null +++ b/packages/skia/src/views/formats.ts @@ -0,0 +1,21 @@ +import { Platform } from "react-native"; + +// Non-spec helper: the canvas texture format most likely to reach the display +// with more than 8 bits per channel on the current platform. The returned +// format keeps standard (SDR) sRGB semantics: values are encoded exactly like +// on an 8-bit canvas, only with more precision, so colors match across +// formats. This is about bit depth, not HDR (no extended range). +// +// - Apple platforms: "rgba16float" (16-bit float per channel). The library +// tags the CAMetalLayer with the extended sRGB colorspace automatically so +// the values display identically to an 8-bit canvas. +// - Android: "rgb10a2unorm". A float16 swapchain works but is tagged as an +// SDR sRGB layer, and SurfaceFlinger quantizes SDR layers to the display +// pipeline depth during composition, discarding the extra precision. +// RGBA_1010102 buffers keep SDR semantics and can pass through composition, +// including direct scanout on 10-bit panels. Requires the Vulkan surface to +// expose the 10-bit format (common on modern devices). +// - Web: "rgba16float", the float canvas format supported by browsers. +export const getPreferredHighBitDepthCanvasFormat = (): GPUTextureFormat => { + return Platform.OS === "android" ? "rgb10a2unorm" : "rgba16float"; +}; diff --git a/packages/skia/src/views/index.ts b/packages/skia/src/views/index.ts index 56181da28c..618dc1103e 100644 --- a/packages/skia/src/views/index.ts +++ b/packages/skia/src/views/index.ts @@ -1,3 +1,4 @@ export * from "./SkiaPictureView"; export * from "./types"; export * from "./WebGPUCanvas"; +export * from "./formats"; diff --git a/packages/skia/src/views/types.ts b/packages/skia/src/views/types.ts index 32ab2423d8..93e7262ee2 100644 --- a/packages/skia/src/views/types.ts +++ b/packages/skia/src/views/types.ts @@ -32,6 +32,13 @@ export interface SkiaBaseViewProps extends ViewProps { opaque?: boolean; + /** + * Renders into a surface with more than 8 bits per channel (16-bit float on + * iOS, 10-bit on Android) to avoid banding in subtle gradients. On Android + * the extra precision survives composition only when combined with `opaque`. + */ + highBitDepth?: boolean; + // On web, only 16 WebGL contextes are allowed. If the drawing is non-animated, set // __destroyWebGLContextAfterRender to true to release the context after each draw. __destroyWebGLContextAfterRender?: boolean; From 28df6fbb5295595c7400a6718cdf100a8fc9e0d1 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Thu, 9 Jul 2026 22:14:58 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore(=F0=9F=93=9D):=20document=20Paragraph?= =?UTF-8?q?=20height=20behavior=20and=20per-glyph=20bounding=20boxes=20(#3?= =?UTF-8?q?930)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds specs reproducing Shopify/react-native-skia#3493: after layout, Paragraph.getHeight()/getRectsForRange()/getLineMetrics() all report heights derived from font metrics (ascent + descent), so 'Hello' and 'Typography' measure the same height, while Font.measureText returns bounds tight to the actual glyphs but has no font fallback. Also covers that Paragraph resolves CJK text through fallback fonts where the Font API returns .notdef glyphs. Also renders a paragraph using Roboto with Noto Sans SC as fallback and strokes the rect reported by getRectsForRange for each glyph, snapshotting the result as a PNG like the other paragraph test suites. --- .../paragraph-glyph-bounding-boxes-node.png | Bin 0 -> 17025 bytes .../__tests__/e2e/ParagraphHeights.spec.tsx | 244 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 packages/skia/src/__tests__/snapshots/paragraph/paragraph-glyph-bounding-boxes-node.png create mode 100644 packages/skia/src/renderer/__tests__/e2e/ParagraphHeights.spec.tsx diff --git a/packages/skia/src/__tests__/snapshots/paragraph/paragraph-glyph-bounding-boxes-node.png b/packages/skia/src/__tests__/snapshots/paragraph/paragraph-glyph-bounding-boxes-node.png new file mode 100644 index 0000000000000000000000000000000000000000..5335125b5e29285c00aca168ee97177f7b399d9a GIT binary patch literal 17025 zcmeHvXH=7E*KSZ5#|A1*x?@8@KtRw?ZHS^2rT0-llp-S1Az^e>l%~>DngfGKCqQTd zQEAc#fzXLkg#e)?l#t}xH}5$=&syilIp12}TJx@1neiP+p67n{zV=mi=9!_s&aUku z+c6l-uFID$T*qKG!#{4u{Q3+0E$RaO696I8BqAKdowU-6)1p*fF z2d;z#U9Q`EkwM_AHrLX#*Sl~)*5T4Oe;j__ktRP9Wg=nuerwt6a{tUg1+{#ro1Sgt zr(sK@dfCt$o0^*7>MosQT^EAO;E#kC%(gx7vj4YD@OtP1yafOGE4;qg%>}P}55UWr zb6eo`61wnzfB4_i__sEo5B}|xf1%)CDEM!Hf^Yeq+!*3N9R@mLf%h`9_M3$Y=>)P@ zOY%fdMVJT7TsOOrTmIl#>FW4(GpD5XXw*a0X25)>r>KSM+=$`T#RHhycWmz7`=}!KcM*LWW!#ycki}f-uau_ zD5q$LUN<{?#3g&4(oLr_aa|9nxmHu#gH>Di?*H$diGSbte6eaO)1CBnFg^KVb24X! z>eU^7d#IvQV2twR@g7P1`V95G-a(b2(i4lvtppW<*5Hs%hVimI^C;QeKKwR0+akMaZHmJ2p?8@@c9~k`bROO`rvwE< zey!-J>q=rV2rNdw4sED1)pMe?{jc*+&4cJ&S<16EK7#_Af7ux#q2YfaT|Y4;h{naxUV7cSJC=hfin)mZvxS&LHO*z|L#GGOPv z6Z^dfJnAc0s#6cKp5qR#6z_hwP}3clm=R&jJJXfnQ2F7htUPu@TU)Z0mfXkbf}evQ zeJU7zE-@0zP4c9$es>bfssC2<>pf<6L0)hiA9R(w9g_nWF4r++i+fUT6ETKZXh0ct zMvuL)ysDbdtXQI&PwrBw(fg~<4qu5i?ioUNdn?{eTH~(GDHn2f*o>@I@o`I8j4`m} zPT#SyvH3XC`eFOzgOTcrrWTGdx{<7JSL7XCX(+pm*DmbnUwxo8sgn@#`a!0>wU)-wunM zx@-N9$PheU-{nD{9jZE-&F}`cPz}Lnc1d4@5{+e`34pRArN6dWAZ(*!X)IBUzuKegP zyF`rV?rnJyjyV(lfD*J^+j5GMK%~G8WCqR)?NRWW>fEQ|YEH(nn9Gr~gCEWOBrp)8onM*Ifh8-bxO9$@e`D{P3TZ3F$Zax%Dg5@9>X=Sv7Wac&uI8B zg(Uh6mFe1(7F*SX+jbq5kqIq!>o>hKT|S!{soB_5)o>)9KPR?tJVI?Y#LU+3a9xDB z)!5cuSC{-f_id`-`JoaIr`96ZSzJX5?2wo156C+S%Gl_zd|u)oBLI zmN~`L>3o@bN8{P=8 zu&|ge9f>dz`IsvsFCPidvG@x^&ZfNPtxK*&=UeA^nY;?cV(8`Taa@P8N4b7)cy-ug zDkFWobNTNm{mhqK-zglnk3Q*%jd!m$CRhx*SzMULj*E$@J-b!hcx=9KhJ~N|yj3Ua zh+d{T&W6z-m0SZjBI7EQpr{5Fmug4CrO9o+lyLaWnec_xiR5}H5Jd)dEZXR1q8B;i z+sc(Vx$yasdZ%goRfF0gJckuF1F+(CZR+&G*Mq4Q6%`i)gI34lu&g(C`itEX;OLYW zE(*R7gcH7Yw~|f{<9$=?PN}l=;n?|4riE<{COUUXt9nnHkIhrdU7xzU5o)1z(~ca4 z`}b_cN=e|?=d*+ABg92T@0Q-UHnr(1C{X7xzr}IKN+y1xK}x6aR!gROJ>Xe1fyIko zIiFoVm1!bD%55rIm)bkP^%E7$4!P3(E~NHUoqCCY>1)w%F$D-2MjB=EnoDVTgtR`i zArlD>LNRLYM>%`RCcI)(QP9+pc-Ot~*gdXMkEM0n;WeO*qKOpFGKYot089`{I}&XD zE40+y8a1!}jZ@MaLfebq&S%o^&HjBk>gGmAmN<81nrhUP2d*x>R&lcoTx!?3W@~FZ zw<>tt{Dnsb9H`AJ3kthd$gYM^FCn@((bm?J=hB_40QhO{7-RC>RP>C>VG8%Jzt=t& zm`~SY9kWl?SRN?slS&`*Y|{|#PuGh#b@Ac!I>y^W(G_Ype@u60C%K&D7 zJO9L(DmSGg8N`2T;NJNdid{$L+?O5mUv|g`az;=esy_Jnf*#bS$#ezMHGyq;dB=qu z5(0ldKKqeYl~A{aT^w%-o$N^0fon>IfkuFVs6A2_o~3U4mv!KLtp^NArx~B>U8gQ& zndUsd&UeNUPV_QhpqWQ2b^;(b%Pj9_AJK(4?l(=S&!n^aj&wius1Fy7EBQTohHJL5 z?_Tw;krEQiZKt?uQQfrMQa6dMjP5a;m0LD0!pqvE9P5M$epUYCiRdt#b92u`4WB_a zxo-ic>BNKj;@qFm1TZcuIvtJsrIG{pc3XSu9k;D0?M%C{KEd^~nn&%p3&}_ydw6Y` zW(Vbyc@Bfo9S2O0gad*rEODpwcy-%kDYuP8Y9%QR5_;sna4KQ`OfgzULSs^U~`QjWv_75vJ)z~S;vAT6=SEnX2QNddFM^r^0_1$CHr z9y7#l(^VGnTD*#zuFW~y9GFOl6Y-4cUXP!DJ*{)etBfU3pu72nY<+Kp2j1oS-4Vvc z-cy4Qj1VEPV46$}YaQ6Q?K?^34o$Kp$Gx`sbr9_9U)dU>LH^6meY*h<8hi3|A|*U( z1lk} zE1Bda>!rEAn9ONjo1+0PqS2Q{R)51&|2(7zZewxHaJIuh%jSj^m7J3bRirK5FkOz@b{iT)-v%Kh)P6%C*#Uom&i z8qcB&GsXNyUv~A`7V!(pS-+m=3wA3RRH1pczE(Ime{#`iS#U2=K{(%6s3QnyFzCfT-aI7`L=U+r?RH@l~@$OZAMXtSy z#RK=7Zg=O}0PJ*?tEG80q$lGNx5CE&u@HK$NC8kChFfgvahm)7xgGwL z(ivr-f&4~$GxUim2FaSjS~kx@dV-b*?blc436t;R z1=Hk$&+W$S&G(%jUSU!=62PMk$ja?N1Xp^KDR)2441a&b8v$o6;P8w~iw@+aR;f*$ z4>+0~Ini~zE!plyzb128Fm;r;m`w*ru|?fpJ|cha51Ijw3m;~p{$l#08_mf}cAkk-fKm52G1gQp617>d(Z zMk4EN17-y9E7g*jLGv7Yggfg~W~ArvOLY}~iwmmWjaBOlO{E|o5c(^)_T)cwjlx0! z+yN-QoDcayA7_fQ7J62TL0f%X**dk8>s^FJ>Af2WSaB?>UA>Wk zhRHu=a;4uNqH$6ISH+zu#f`KF)W#`&_4%3<>*L9I_G)|Dl{jCRHQQC{fgz$Ed)5#o zCFB|r6B8GQC=r_M!pvRbswdyVMMu9~a04bbKN@9V3WaFkt;iiQ)ry6A67~_}_f_!S z33#9mfD;4`n_2HSk!V(gwF{UbqN^D2p#*8i7s?$2z5@;Mn>QSMHZ^h)P~SEb3b1x8 z&Q1bELmRaR>CHU{FV+nDP!fr>Jvw49l^Rs_$aUDVPScA%ND}z;!$&d=M?~yrxq--CLNol6AS>#LPyZ`pK@W zB_@yBbUs*7@%9U&4GKQ91LlAh$L*@rtJrJQ*BSxd6$_0r3NUAX!Z=8)uloLQ$IZ?U z*J5y#Mv1_=&!>OBdvt#)0LIJ3rdZi5WxESC;irc7jnqfB^`u`*y||$Tf}SSaY%1FP z4_FdS6>V{DoBxOZllmSa^C@uT`_ zJ&rlET-dbV)XI%$?ndmo39Kwk#jUS~YpdAqgBQa>5IMdWw(n(!V$3n4_cg$Hh0Scd zOge$0>5_8GXW{>N24koEIt@$BE*ySwo+o22;x^EWl*>^^2yl}+PkF>sKxKH;2|Lxm zs2)l1Y;EvMLZ1!uNgM%c+EgeVT%2y(yytyC+>ZgzHrXk1`r`!{ zD5gEdEh_z^K~-0&9E2S?PCQj`v|&xZsCi9|Qg#P#$`~xE-h0Y_>1QcCH_s`xd*46# zGJ2g-VJ5^_ZmxvETbq%dB!UnCRH}0NvbAT6vc>WZgDymPH*e)>F2e&ozX{ljuGiQf zTN7(_R~U4e_thkIEPENxIW@5?4n1g5f?Frx*^9~GUu_-d-y5(zD=wAfdo!)B&Efl} zKjz`OwSoWQi-Wptd|LV+)uk2PtbP7k*@fIz>{ifl#7~Q>6h_^M+_g%7dM0@x2VeoJ z$_xZ%#zGIq;RvSlI-bL%AmWIni(~IQScAIl>Z^t^SrVZ}t5EW1OusSWfFL-P#~VWy zNY#mqfY~aOUiDPM|Mt~{(}X@LdCo!{DGX+l)Eb={XT;&&Ay^~ql&}YCsmU(_nAs^Z z96&a!v9a-H^J@jdRA$z=y0@IF*Azdm*7{r5S|HG_8>gCE5XKjdU6rv`|(1cB%C2M^;&n zeWXSKqjLPM%a&k{dHI7o2{31{^cixFRhY2S5qBXD#R4@PoI7~alJ(F;Z;2c5nHp@UUfUd>p}rI0=S=c>LY+JIGm6xc$$&FxEu#8$OW6 z&%rDbSv>X`aYY)Q6EFbC<}v8iT?@2tHxO%|y5&)3o9=}gAhnh^tdN*yHGc+XCGF}0 z1?vj6N#U$iE)RtQPpPzAbX2mh3k#dct6FnD+h#rq6AWAzIc`zO-yMn4807hN9*!OW zRvy(c$gx`=7V?;{6%rc#@(!N3$G6K;wXYTI6&dlTJ#Jo2R3+|z%Gaf0pg}j*pE!i< zQ-t5(*%NnMuLqGJ4j=W5M_kpxCQl2wn(#|vfP&a?8xoFn8axS{3MRpeY=MN3Y1wQe z-1k{Ap8=0{kYm|+wj?laTHHm%Z3rFuYN-EGO|neEV6ZZF7RsJ7&;R`_a!}|0InP^L z>M?@5*V7H^P;9W=tBJWc`6^`LN<$8(fly_6C4GUa7>0Ms%4dPPr1Rdj4g@4I=~I?T zKBD(FTA|3e8=&DB2BSS99Bes0nV#Ib*6w$q=1zK|AbK#Lr%sQ)IDda?;>LG()|foL zA%l|4UP`|W?B=Y?9bhu#IYuJbI|bL=L6w)Bu*3wpTLX^i^=Q?q zMKC7oL*=W?Zv^CuZZaMPLe&bSMOKe%MV{+jY{qTBTIm1A`&BMhCLku<^nD({&}2U( zUlXkWV&wtT&S|E*+>4qrc$n6mXNTNu3#JBVeNk9`3%(U-$vi9Fi#c6r5GJ8f{=p~D zlX&mPmr@`WN{nOV$G3}VOl=_>CY zjzR;dk}aGPA9i;DmSqP?0Ls2r^SS%kC8Oc~E||5uqas1TL?|{4Y*L1{V2yKWG0HHhT zD+`U$;#>8VhFku4d!W=KraoCS$iMD{(cr`m%vXKP2krZQ7`KT;AGsBg1)3{VZ#!uB zY}OEP)|5^ozDnSfTINo+FQK7NSQfilM8B;n1P(Q^bi96PvZL_swpIHaOCr)#CaL9L z0sw23ip_kInT>q_Ns3RFw@Uc#A4yyd^n*!qb3+Yq+BoY5#JVaTWqxc6a#VKhIYE6^i}dzq>Q{!7N*rnNjYGh@vhtD5FA_0^S1MqT zYxRncPVh6AngwMo9cH4Ma<#ooa!HKqGybor$EtgIa9M5z+;6Lbn1VBYVKMZd?FUDs z&VsQdvqDJ+pD+7MG4N*-7asyL3j-rE;OT39beluH0(zO7)mUub`%Egy{GU}%jR(B@ zC7rvl7=&vlUCkGDaY*NaDaf68u>Ut@$}uRd+u~Zq5E3}fEJES{UYyMH;-vB;4P#%* zfO;WUOCT*t(h5dKD!6bK^0?=OrN3Vn%(zB3o%MN~Unk;Hqfjr#hYCt&+OuBVrv~B` zvlpuqE%9+4)Q%~M>1A-$Aa#Kh#d$R18pcbn<>TAdcOaUS7w|l_rTHunrT3>wdT+%G zRK&)N6?CsX=0y0>w^8m01bm-aWs$V0;0EN8l$v z5Y*O;>wvu$XueKtDwKV^@R7ojv&WRetRDuHIQ~`Nt~ZvGO{f#;64C1S6iVA}i^;f5 zi*=Qap{lMalg$r;el;t~yJ{trJB{9OoOWhr5hm6|k!x0`mBd{rP%ItVzim4_c0^}| zkLCA2V%g{~Vl(}+??3oA96|9K8xzbNz#}X>d z@@%yOUQ`|EjpdIJJtar8dMIaIdIGe4aW<<}i?iMX;$s8RJ6DkX<$L53hs}y&M(=)S zyd~K3Cg4h%++UW@{hr^^d+GAC!BN=cw=q?`$?O{W(>=aQ`IoDi(BYCjvRFM>3hvhBRR}wx8%CgerKtiaCoVIrZeP>~)( zeiOA6HlrkYFp4vA+@jzR+`J?te(MHFL95_DqIj}(g?-LFb>}BuxWO~ILbp4Qm)Z7o z10VcOba+L59F!p$mXqshWypY#%V9#nc=X*Pp2i+c&|D9L%E=%TeE}gy@~e58RyE@6 zaDGTr+d@;*@Us9GAz);itt5da)ShTf%(9Wkl%ZSe!~KZKw2mW3ueJK;CZC8|$bJ}D z3ci<8ad%CW`7)5dFy1>_ph)KdIs{zV;VOMcb74Ru-kfStcne$U^zHA<1Q?R_z(R!q zo`en9;T%`lK44<%COgl_K}o&?n8DITH3NOmMrP)1`ZaVR*c)h;Jy383-?{Gzlqk2RF36;og-g`9kqu(`&bHE@01e|f6GxR8 z;@8O)ie4RRNGs3;TQXq{ncLiXX}iaPyvEu3->K8=l`FV%2f#86hECNN23V4}2A?R| zA)!*b^zF(Bkk6(jUkEMbSCvj+j_n160KMukqvW^Xz`L`1KM2;l`Yhd`M$g>ExV4($ z%yKwndw?E+Vql4yIC_?y0P+2QsN=m)T*6Fnf%vp@HnLwG3BN-skQz%zc9eNetWY>C zN(K3sGj|a@bm*OG>2rd%MJhg}^Srm-+mj8Gn7LLX%x=P zQ-YBYL{3oN2W9OGu8_jbqlAP9G@UM~%Z1#n3mc|b-s1tc!WI>PariNl zIm=8X8u%85bCwG;C|?W{!z$V*gXNKHnsBBch;z2nCI_{NcZP_rp+T}?S@@%zfGP& zTBxVrFBT!GRrRy~4;+jL3*I}=LvBORmD8W;F;wCAzcMtSkX@xwc%@!2 zI|%>N2pb5d8Egcj2=S7F7`+d>wtO;x1GNhZbkGZ60DLp^BuHw#jO=W}zRn z49e_bca(uVY5o3xoM*A1rS^6pMk!s<^1Yhe$Colb39Q13zzB-H7MO9zE%qXoR@d>C z>#HO*dM`}H`)vN-N58%#fOYn;yQl!U1eyR?DXkWw4}Sjh8+C0aBm}lz6wm-o>D-9e z(jA46rKhhZ+{F)bDnhCC^g$O=pZ79Ht=GHN4}wm6n4;QQQtZN*Y3xJ-9~jZO40FI( z#QVea&!s>G6CgfVTl%4cAWKKgqs5JE&D!GH9sP;JpbCCcjmX^C5da z4N4c~A6U91OpqKbYZ*m6FcVa$Dz7IJJoS+mqMensyCAW_Fk9RUZgC%aE0H4~y=O#F z{~@6XMu2Fzh;rkd_02q|_ajeI-DJn7MnJ?5kRD=bcA5|?p|zkPNgituBgmy9blV27 zz7OLaos#@+iTVHhOWQ8W+K4{gtF*?Li8rELkt;j&ghyN?R8Y3DX#hDxg{`VG)M^5y z7YquQu4)o8+{im9$ooeIDxBp}Bu(o!1RNM6g%8R34h^qRHZlMXrZ5wHwc~PCE5m3N zs|L^JS%*ZssWfr2(SLc?=38PoW=a~$-Wd)Ekun10!sDHI7&^#m+^6hp90-)DD~SWF zDjeFV4tRSMkp>BnQBk6sb2%&lfDL!>zNR1J)fKsC=v+W)Whv8uiin~88ML^N&Te_D z9E$unw5H_TVs#b$8;Igh+b1IzU&JN*TK22$w_23Dn?+g+#Dh)6(QXiYh!SE$8#@oo zsKHn;bRtWk`tGjzCHp; z{fYG|ncUI?xYZ^*Q|H|4AZ%;~Af@vF^KX5ck%V^D03g#35e3d^ODVa7TZCBrgqWgC z7TB3+6N>(VxMFZL>nC3xvRG%KLlUMEK;a^HgPUW#dH-SX&d9*eN`Iz=O4E}N#^Au| ztXJuG<*RW5+vx1Q9F1@rU8cvO1Z! zS`${I=-)v#N1_Rb=nyOl=9t<*g$3@E)Ko_zEXQLi6K!2Vorj(V|HTv6hV)=csNCAw zpG_0Xh)g_(8b~MO+t6r6g$;0U_!nLYF(K=FXVK17r{kCjB4uTsOR9&b?!cGa^1qV{{PYFz>4&)^Wp`8-FOi$kVcIe42 zQPUg(nu@T>lIN`lHhLIXz2r>g(OPo;jq1~2RHZ<1T#OL281q=5Yp6kq+rzAF2P)wl zX=3z%HlzWb8o#Jp(Y1^#4Ikm582fMg-9#3066D|RQ}?~6&yu?O`h{24x0o5IC=akl zOjqvwK<*v#G+RfwUf2w-URA%bOXOq-cz?2Wur0DE81PsT49a~llhKmU;ha>J+Xw>S zjF5#KT@(S=S;sA8ZI~{{CUtGf@JFMEC_+Sw4xyTO*yuuA7z&Uv`LJdF0?x4!48ddD)9+Sdb8X@- z&}DT9{XH6wrAMouzP=a;DDMIFSPx9{tq})V*RY{sIJOGjkq`hOV_9n!HW-k7Oq@F8 z9NY&I(jIu;?nw28OJU+_3}$7?ju{lvp&15kU=h7Kl>+5;FbT?VAN0qyaik#-&qX@~mSgUYCoA^KMv0(3?weRg~!pa%Nv z0WK;tX`Ty7n7^n`!~Xb_?Dh`#a2Lzlql*Mf!fVxT#tl3Ew&w6zkpTeC5U}@I1l3HiDyy4L%q$n z>qsKsHdt?Q>irnD6m1a?zNJW zKJxZA);P+kxuU4Y;muVxwzD849+=GgnLp) zt$}YKv$TdqKCJjGvEYh) zlCA{iXfC|T#i!uQ0=r0)d#q~?xOo#+b3>q7Q#;c-{m_CkuXn*6nBoXoU|Uv=7btu=;0Bnu-ESV0?Ja z$aqV9J$fGD@I3**tcx%$Qf^c~xdxUdT0Vqzc~?o{T@8dnw*j$kwjIm#taU$i4fYf4X;><3_)4iO#o>>7`DrB`zFk%NTg8YM^JJCeUlJJ*nR^xpPr*V0w4{WFj(F+~=HC(P?}Xelj8}w|vT}AMAcX z`O2m1X!M{fPw6(bfWXKfXpdB$y#$PMZ1SHw4XxmNfm+v*CkzZQhJv<>5!e#->tx@< zqE)xn>Bl8tO?be9Xgy3qBWi5bDD~`WtgKZXz@-q3enLmfAFB1|KVh^Eb`$4iT;TNA zfAHc%z%@Ym&5rA4e<8Qu1KhQGK`U1vQej8GUVnSD2Oc|qZnuvk(kkUXGzoDv?=XlR zH`a(=h;ZrZ!NOi9WY8YaQYJlB$U`M2(O^B{2oCJ~8gN8Ypomc9SqI6%8cSFhI@dHC z9yeMTLUFr2Nd9nmM5<_M`;~u|OSm#Q1fI5r%HV?zOOJaN+NeI^9z2j9FwHifXJI8_ z+b0z0;sq$Kn=vM)&%l+HMF$Aq%J4(05QN!sDmcU0*QOS@o~JO_GQ6GJz?bw+fV??P zZbS+N@u*ibnvQ0=@^0=HMB^>v3g$z*I{Fgn|BG*CzIdYjW~`bQly31+qb_GV+$yCT zRQ}+)2J`zPPL*wW(LGY#WCwm_{q#og|d>pnt@%Huau z2LLw@#vWf~0QTyfbT%$6bAAq5#+OBRw}Yb0$l}6C|8^yh6x-(JU@Ws@IS z3vz`)i(>xgyf_AYYQtbQ@uB}N!2g`9{`X4%|JT@-RXcwAt&HCeqvF1-t$!i+-0u(n E3$LCwNdN!< literal 0 HcmV?d00001 diff --git a/packages/skia/src/renderer/__tests__/e2e/ParagraphHeights.spec.tsx b/packages/skia/src/renderer/__tests__/e2e/ParagraphHeights.spec.tsx new file mode 100644 index 0000000000..12ac8f2379 --- /dev/null +++ b/packages/skia/src/renderer/__tests__/e2e/ParagraphHeights.spec.tsx @@ -0,0 +1,244 @@ +import { resolveFile, surface } from "../setup"; +import { checkImage, itRunsE2eOnly } from "../../../__tests__/setup"; +import { PaintStyle } from "../../../skia/types"; + +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/3493: +// measuring text with font fallback (Paragraph) while also getting bounds +// that are tight to the actual glyphs (like Font.measureText), e.g. "Hello" +// (no ascenders above cap height, no descenders) should measure smaller than +// "Typography" (y/p/g descenders). +describe("Paragraph height measurement (#3493)", () => { + it("draws the reported bounding box around each glyph of 'Hello你好'", async () => { + const img = await surface.drawOffscreen( + (Skia, canvas, 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 text = "Hello你好"; + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + // "Hello" is shaped with Roboto, "你好" falls back to Noto Sans SC. + fontFamilies: ["Roboto", "Noto Sans SC"], + fontSize: 36, + }); + builder.addText(text); + const paragraph = builder.build(); + paragraph.layout(ctx.width); + canvas.clear(Skia.Color("white")); + canvas.translate(8, (ctx.height - paragraph.getHeight()) / 2); + paragraph.paint(canvas, 0, 0); + const paint = Skia.Paint(); + paint.setColor(Skia.Color("red")); + paint.setStyle(ctx.PaintStyle.Stroke); + paint.setStrokeWidth(1); + for (let i = 0; i < text.length; i++) { + paragraph.getRectsForRange(i, i + 1).forEach((rect) => { + canvas.drawRect(rect, paint); + }); + } + }, + { + RobotoRegular, + NotoSansSC, + PaintStyle, + width: surface.width, + height: surface.height, + } + ); + checkImage( + img, + `snapshots/paragraph/paragraph-glyph-bounding-boxes-${surface.OS}.png` + ); + }); + + it("returns the same metrics-based height for 'Hello' and 'Typography'", 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 = (text: string) => { + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 24, + }); + builder.addText(text); + const paragraph = builder.build(); + paragraph.layout(512); + const rects = paragraph.getRectsForRange(0, text.length).map((r) => ({ + x: r.x, + y: r.y, + width: r.width, + height: r.height, + })); + const lineMetrics = paragraph.getLineMetrics()[0]; + return { + height: paragraph.getHeight(), + rects, + ascent: lineMetrics.ascent, + descent: lineMetrics.descent, + lineHeight: lineMetrics.height, + }; + }; + return { + hello: measure("Hello"), + typography: measure("Typography"), + }; + }, + { RobotoRegular } + ); + expect(result.hello.rects).toHaveLength(1); + expect(result.typography.rects).toHaveLength(1); + // The paragraph height and the rects returned by getRectsForRange are + // derived from the font metrics (ascent/descent), not from the glyphs + // that are actually present in the text. Both strings therefore measure + // exactly the same height even though "Hello" has no descenders. + expect(result.hello.height).toBeCloseTo(result.typography.height, 3); + expect(result.hello.rects[0].height).toBeCloseTo( + result.typography.rects[0].height, + 3 + ); + expect(result.hello.lineHeight).toBeCloseTo( + result.typography.lineHeight, + 3 + ); + // The rect height matches the line metrics (ascent + descent), which for + // Roboto at fontSize 24 is larger than the tight bounds of "Hello" + // (~17.5) or even "Typography" (~23.5). + expect(result.hello.rects[0].height).toBeCloseTo( + result.hello.ascent + result.hello.descent, + 0 + ); + expect(result.hello.rects[0].height).toBeGreaterThan(24); + }); + + it("applies font fallback in Paragraph while the Font API does not", 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"); + // The Font API has no fallback: Roboto has no glyphs for CJK. + const font = Skia.Font(roboto, 24); + const robotoGlyphs = font.getGlyphIDs("你好"); + const measure = (text: string) => { + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto", "Noto Sans SC"], + fontSize: 24, + }); + builder.addText(text); + const paragraph = builder.build(); + paragraph.layout(512); + return paragraph; + }; + const toPlain = (r: { + x: number; + y: number; + width: number; + height: number; + }) => ({ + x: r.x, + y: r.y, + width: r.width, + height: r.height, + }); + const mixed = measure("Hello你好"); + const latinOnly = measure("Hello"); + return { + robotoGlyphs, + mixedHeight: mixed.getHeight(), + latinOnlyHeight: latinOnly.getHeight(), + latinRects: mixed.getRectsForRange(0, 5).map(toPlain), + cjkRects: mixed.getRectsForRange(5, 7).map(toPlain), + }; + }, + { RobotoRegular, NotoSansSC } + ); + // Roboto alone cannot shape CJK text (glyph id 0 is .notdef/tofu)... + expect(result.robotoGlyphs.every((id) => id === 0)).toBe(true); + // ...but the paragraph shaped it through the fallback font. + expect(result.cjkRects).toHaveLength(1); + expect(result.cjkRects[0].width).toBeGreaterThan(0); + // The heights are still driven by font metrics: the fallback font + // metrics on the line affect the reported heights for the whole + // paragraph, and the rects of the latin part are not tight to its glyphs. + expect(result.mixedHeight).toBeGreaterThanOrEqual(result.latinOnlyHeight); + expect(result.latinRects[0].height).toBeGreaterThan(24); + }); + + // Font.measureText is only implemented on iOS/Android. + itRunsE2eOnly( + "Font.measureText returns bounds tight to the glyphs, unlike Paragraph", + async () => { + const result = await surface.eval( + (Skia, ctx) => { + const typeface = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const font = Skia.Font(typeface, 24); + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(typeface, "Roboto"); + const paragraphRectHeight = (text: string) => { + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 24, + }); + builder.addText(text); + const paragraph = builder.build(); + paragraph.layout(512); + return paragraph.getRectsForRange(0, text.length)[0].height; + }; + return { + helloInkHeight: font.measureText("Hello").height, + typographyInkHeight: font.measureText("Typography").height, + helloRectHeight: paragraphRectHeight("Hello"), + typographyRectHeight: paragraphRectHeight("Typography"), + }; + }, + { RobotoRegular } + ); + // measureText is tight to the glyph ink: "Hello" has no descenders and + // measures smaller than "Typography". + expect(result.helloInkHeight).toBeLessThan(result.typographyInkHeight); + // The paragraph rects are metrics-based: identical for both strings and + // taller than the tight bounds. + expect(result.helloRectHeight).toBeCloseTo( + result.typographyRectHeight, + 3 + ); + expect(result.helloInkHeight).toBeLessThan(result.helloRectHeight); + expect(result.typographyInkHeight).toBeLessThan( + result.typographyRectHeight + ); + } + ); +});