From 8c4ab4903db2baaf41765e4796875d3cad693780 Mon Sep 17 00:00:00 2001
From: Alireza Hadjar <57192409+AlirezaHadjar@users.noreply.github.com>
Date: Thu, 27 Aug 2026 17:59:36 +0200
Subject: [PATCH] =?UTF-8?q?feat(=F0=9F=A4=96):=20let=20a=20transparent=20c?=
=?UTF-8?q?anvas=20opt=20into=20a=20topmost=20SurfaceView?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A transparent canvas renders through a TextureView, so HWUI samples the
whole canvas into the app window before SurfaceFlinger composes it. For a
full-screen canvas that animates every vsync that second pass dominates.
androidTransparencyMode="surface-overlay" puts the canvas on its own
translucent SurfaceFlinger layer instead. The tradeoff is z-order: the
layer sits above the app window, so no React Native view can draw over it.
That is why it is opt-in and "texture" stays the default.
---
apps/example/src/App.tsx | 2 +
.../src/Diagnostics/DiagnosticsList.tsx | 4 +
.../src/Diagnostics/TransparencyMode.tsx | 112 ++++++++++++++++++
apps/example/src/Route.ts | 1 +
.../java/com/webgpu/WebGPUSurfaceView.java | 11 ++
.../src/main/java/com/webgpu/WebGPUView.java | 45 ++++---
.../java/com/webgpu/WebGPUViewManager.java | 6 +
packages/webgpu/src/Canvas.tsx | 14 ++-
.../webgpu/src/WebGPUViewNativeComponent.ts | 9 +-
.../src/WebGPUViewNativeComponent.web.ts | 9 +-
10 files changed, 195 insertions(+), 18 deletions(-)
create mode 100644 apps/example/src/Diagnostics/TransparencyMode.tsx
diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx
index 821b30d6ef..6071fac009 100644
--- a/apps/example/src/App.tsx
+++ b/apps/example/src/App.tsx
@@ -43,6 +43,7 @@ import { ViewFormatsUseAfterFree } from "./Diagnostics/ViewFormatsUseAfterFree";
import { RenderAfterUnmount } from "./Diagnostics/RenderAfterUnmount";
import { BackgroundDetach } from "./Diagnostics/BackgroundDetach";
import { SurfaceChurn } from "./Diagnostics/SurfaceChurn";
+import { TransparencyMode } from "./Diagnostics/TransparencyMode";
import { StorageBufferVertices } from "./StorageBufferVertices";
import { SharedTextureMemory } from "./SharedTextureMemory";
import { ImportExternalTexture } from "./ImportExternalTexture";
@@ -126,6 +127,7 @@ function App() {
/>
+
{
diff --git a/apps/example/src/Diagnostics/TransparencyMode.tsx b/apps/example/src/Diagnostics/TransparencyMode.tsx
new file mode 100644
index 0000000000..034dbad104
--- /dev/null
+++ b/apps/example/src/Diagnostics/TransparencyMode.tsx
@@ -0,0 +1,112 @@
+import React, { useEffect, useRef, useState } from "react";
+import { Button, Pressable, StyleSheet, Text, View } from "react-native";
+import type { CanvasRef } from "react-native-webgpu";
+import { Canvas } from "react-native-webgpu";
+
+type Mode = "default" | "texture" | "surface-overlay";
+
+const ClearCanvas = ({ mode }: { mode: Mode }) => {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ let live = true;
+ (async () => {
+ const adapter = await navigator.gpu.requestAdapter();
+ const device = await adapter!.requestDevice();
+ const context = ref.current?.getContext("webgpu");
+ if (!context || !live) {
+ return;
+ }
+ context.configure({
+ device,
+ format: navigator.gpu.getPreferredCanvasFormat(),
+ alphaMode: "premultiplied",
+ });
+ const frame = () => {
+ if (!live) {
+ return;
+ }
+ const encoder = device.createCommandEncoder();
+ const pass = encoder.beginRenderPass({
+ colorAttachments: [
+ {
+ view: context.getCurrentTexture().createView(),
+ clearValue: [0.5, 0, 0, 0.5],
+ loadOp: "clear",
+ storeOp: "store",
+ },
+ ],
+ });
+ pass.end();
+ device.queue.submit([encoder.finish()]);
+ context.present();
+ setTimeout(frame, 200);
+ };
+ frame();
+ })().catch((error) => {
+ console.error(`[transparency] setup failed: ${error}`);
+ });
+ return () => {
+ live = false;
+ };
+ }, [mode]);
+
+ return (
+
+ );
+};
+
+export const TransparencyMode = () => {
+ const [mode, setMode] = useState("default");
+ const [taps, setTaps] = useState(0);
+
+ return (
+
+
+ Half-transparent red canvas over a blue backdrop. In "texture"
+ mode the yellow overlay below stays visible on top of the canvas. In
+ "surface-overlay" mode the canvas gets its own layer and
+ covers the overlay.
+
+
+
+
+ mode: {mode} overlay taps: {taps}
+
+
+
+ setTaps((t) => t + 1)}>
+ overlay (tap me)
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { flex: 1, padding: 16, gap: 8, paddingTop: 64 },
+ copy: { fontSize: 13 },
+ buttons: { flexDirection: "row", gap: 12 },
+ stage: { flex: 1, backgroundColor: "blue" },
+ canvas: { flex: 1 },
+ overlay: {
+ position: "absolute",
+ left: 24,
+ right: 24,
+ bottom: 40,
+ padding: 12,
+ alignItems: "center",
+ backgroundColor: "yellow",
+ },
+});
diff --git a/apps/example/src/Route.ts b/apps/example/src/Route.ts
index 8022a09ab5..fb48841678 100644
--- a/apps/example/src/Route.ts
+++ b/apps/example/src/Route.ts
@@ -36,6 +36,7 @@ export type Routes = {
RenderAfterUnmount: undefined;
BackgroundDetach: undefined;
SurfaceChurn: undefined;
+ TransparencyMode: undefined;
StorageBufferVertices: undefined;
SharedTextureMemory: undefined;
ImportExternalTexture: undefined;
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceView.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceView.java
index 3ef30c1bb7..a0374fb441 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceView.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUSurfaceView.java
@@ -2,6 +2,7 @@
import android.annotation.SuppressLint;
import android.content.Context;
+import android.graphics.PixelFormat;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
@@ -13,8 +14,18 @@ public class WebGPUSurfaceView extends SurfaceView implements SurfaceHolder.Call
WebGPUAPI mApi;
public WebGPUSurfaceView(Context context, WebGPUAPI api) {
+ this(context, api, false);
+ }
+
+ public WebGPUSurfaceView(Context context, WebGPUAPI api, boolean transparent) {
super(context);
mApi = api;
+ if (transparent) {
+ // Own SurfaceFlinger layer, so frames skip the app's HWUI renderer. The
+ // cost is that this layer sits above every other view in the window.
+ setZOrderOnTop(true);
+ getHolder().setFormat(PixelFormat.TRANSLUCENT);
+ }
getHolder().addCallback(this);
}
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java
index c0c2a2eec1..1bbe65254d 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUView.java
@@ -14,6 +14,7 @@ public class WebGPUView extends ReactViewGroup implements WebGPUAPI {
private int mContextId;
private boolean mTransparent = false;
+ private boolean mTransparentSurfaceLayer = false;
private WebGPUModule mModule;
private View mView = null;
@@ -32,23 +33,37 @@ public void setContextId(int contextId) {
}
public void setTransparent(boolean value) {
+ if (value == mTransparent && mView != null) {
+ return;
+ }
+ mTransparent = value;
+ rebuildView();
+ }
+
+ // "surface-overlay" trades z-ordering for a cheaper composition path; see
+ // the androidTransparencyMode prop on Canvas. Ignored when not transparent.
+ public void setTransparencyMode(String mode) {
+ boolean surfaceLayer = "surface-overlay".equals(mode);
+ if (surfaceLayer == mTransparentSurfaceLayer && mView != null) {
+ return;
+ }
+ mTransparentSurfaceLayer = surfaceLayer;
+ rebuildView();
+ }
+
+ private void rebuildView() {
Context ctx = getContext();
- if (value != mTransparent || mView == null) {
- if (mView != null) {
- removeView(mView);
- }
- mTransparent = value;
- if (mTransparent) {
-// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
-// mView = new WebGPUAHBView(ctx, this);
-// } else {
- mView = new WebGPUTextureView(ctx, this);
-// }
- } else {
- mView = new WebGPUSurfaceView(ctx, this);
- }
- addView(mView);
+ if (mView != null) {
+ removeView(mView);
+ }
+ if (mTransparent) {
+ mView = mTransparentSurfaceLayer
+ ? new WebGPUSurfaceView(ctx, this, true)
+ : new WebGPUTextureView(ctx, this);
+ } else {
+ mView = new WebGPUSurfaceView(ctx, this);
}
+ addView(mView);
}
@Override
diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUViewManager.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUViewManager.java
index a7eae8ddc6..d5b861c8bf 100644
--- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUViewManager.java
+++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUViewManager.java
@@ -36,6 +36,12 @@ public void setTransparent(WebGPUView view, boolean value) {
view.setTransparent(value);
}
+ @Override
+ @ReactProp(name = "androidTransparencyMode")
+ public void setAndroidTransparencyMode(WebGPUView view, String value) {
+ view.setTransparencyMode(value);
+ }
+
@Override
@ReactProp(name = "contextId")
public void setContextId(WebGPUView view, int value) {
diff --git a/packages/webgpu/src/Canvas.tsx b/packages/webgpu/src/Canvas.tsx
index d19ccdf1e6..ad6fc008ed 100644
--- a/packages/webgpu/src/Canvas.tsx
+++ b/packages/webgpu/src/Canvas.tsx
@@ -47,10 +47,21 @@ export interface CanvasRef {
interface CanvasProps extends ViewProps {
transparent?: boolean;
+ // Android only, and ignored unless `transparent` is set. "texture"
+ // (default) draws through a TextureView and stacks like any other view.
+ // "surface-overlay" gives the canvas its own translucent SurfaceFlinger
+ // layer, which skips a composition pass but draws above every React Native
+ // view in the window, so nothing can be layered on top of it.
+ androidTransparencyMode?: "texture" | "surface-overlay";
ref?: React.Ref;
}
-export const Canvas = ({ transparent, ref, ...props }: CanvasProps) => {
+export const Canvas = ({
+ transparent,
+ androidTransparencyMode,
+ ref,
+ ...props
+}: CanvasProps) => {
const viewRef = useRef(null);
const [contextId, _] = useState(() => generateContextId());
// Retire the native registry entry for this contextId on unmount. When a
@@ -96,6 +107,7 @@ export const Canvas = ({ transparent, ref, ...props }: CanvasProps) => {
style={{ flex: 1 }}
contextId={contextId}
transparent={!!transparent}
+ androidTransparencyMode={androidTransparencyMode}
/>
);
diff --git a/packages/webgpu/src/WebGPUViewNativeComponent.ts b/packages/webgpu/src/WebGPUViewNativeComponent.ts
index 63c14ddb16..0110fa92a3 100644
--- a/packages/webgpu/src/WebGPUViewNativeComponent.ts
+++ b/packages/webgpu/src/WebGPUViewNativeComponent.ts
@@ -1,10 +1,17 @@
import { codegenNativeComponent } from "react-native";
-import type { Int32 } from "react-native/Libraries/Types/CodegenTypes";
+import type {
+ Int32,
+ WithDefault,
+} from "react-native/Libraries/Types/CodegenTypes";
import type { ViewProps } from "react-native";
export interface NativeProps extends ViewProps {
contextId: Int32;
transparent: boolean;
+ androidTransparencyMode?: WithDefault<
+ "texture" | "surface-overlay",
+ "texture"
+ >;
}
// eslint-disable-next-line import/no-default-export
diff --git a/packages/webgpu/src/WebGPUViewNativeComponent.web.ts b/packages/webgpu/src/WebGPUViewNativeComponent.web.ts
index af77bdb847..b99de37702 100644
--- a/packages/webgpu/src/WebGPUViewNativeComponent.web.ts
+++ b/packages/webgpu/src/WebGPUViewNativeComponent.web.ts
@@ -8,6 +8,7 @@ import { contextIdToId } from "./utils";
export interface NativeProps extends ViewProps {
contextId: Int32;
transparent: boolean;
+ androidTransparencyMode?: "texture" | "surface-overlay";
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -54,7 +55,13 @@ function resizeCanvas(canvas: HTMLCanvasElement | null) {
export default function WebGPUViewNativeComponent(
props: NativeProps,
): React.JSX.Element {
- const { contextId, style, transparent, ...rest } = props;
+ const {
+ contextId,
+ style,
+ transparent,
+ androidTransparencyMode: _androidTransparencyMode,
+ ...rest
+ } = props;
const canvasElm = useRef();