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/__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 0000000000..5335125b5e Binary files /dev/null and b/packages/skia/src/__tests__/snapshots/paragraph/paragraph-glyph-bounding-boxes-node.png differ 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/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 + ); + } + ); +}); 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;