Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions apps/example/src/Tests.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Dimensions,
Text,
Expand All @@ -7,7 +7,8 @@ import {
PixelRatio,
findNodeHandle,
} from "react-native";
import { GPUOffscreenCanvas } from "react-native-webgpu";
import { Canvas, GPUOffscreenCanvas } from "react-native-webgpu";
import type { CanvasRef } from "react-native-webgpu";
import { mat4, vec3, mat3 } from "wgpu-matrix";

import { useClient } from "./useClient";
Expand All @@ -24,6 +25,32 @@ const { width } = Dimensions.get("window");
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
const PROBE_SIZE = 64;
const QUADRANT = PROBE_SIZE / 2;
const SCALED_CANVAS_SIZE = { width: 64, height: 32 };

// Re-renders the screen; each request resolves once a commit has rendered it,
// so requests that share a commit all resolve.
const useRerender = () => {
const [renderedRequest, setRenderedRequest] = useState(0);
const lastRequest = useRef(0);
const pendingRequests = useRef(new Map<number, () => void>());
useEffect(() => {
pendingRequests.current.forEach((resolve, request) => {
if (request <= renderedRequest) {
pendingRequests.current.delete(request);
resolve();
}
});
}, [renderedRequest]);
return useCallback(
() =>
new Promise<void>((resolve) => {
lastRequest.current += 1;
pendingRequests.current.set(lastRequest.current, resolve);
setRenderedRequest(lastRequest.current);
}),
[],
);
};

export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => {
const [texture, setTexture] = useState<GPUTexture | null>(null);
Expand All @@ -33,6 +60,10 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => {
// A fixed native view the drawElementImageToTexture spec snapshots: four
// 32x32pt solid quadrants (red, lime, blue, white), 64x64pt in total.
const probeRef = useRef<View>(null);
// The Canvas spec measures this canvas, drawn at half its layout size, and
// re-renders the screen to check that it keeps its ref handle.
const scaledCanvasRef = useRef<CanvasRef>(null);
const rerender = useRerender();
useEffect(() => {
let cancelled = false;
(async () => {
Expand Down Expand Up @@ -120,6 +151,9 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => {
probe: findNodeHandle(probeRef.current),
probeSize: PROBE_SIZE,
pixelRatio: PixelRatio.get(),
scaledCanvas: () => scaledCanvasRef.current,
scaledCanvasSize: SCALED_CANVAS_SIZE,
rerender,
},
workletDeviceStress,
workletSerialization,
Expand Down Expand Up @@ -162,7 +196,7 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => {
};
}
return;
}, [adapter, client, device, di3D, moon, saturn]);
}, [adapter, client, device, di3D, moon, rerender, saturn]);
if (setupError) {
return (
<View style={{ flex: 1, padding: 16, backgroundColor: "white" }}>
Expand Down Expand Up @@ -213,6 +247,10 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => {
}}
/>
</View>
<Canvas
ref={scaledCanvasRef}
style={{ ...SCALED_CANVAS_SIZE, transform: [{ scale: 0.5 }] }}
/>
<Texture
texture={texture}
device={device}
Expand Down
69 changes: 40 additions & 29 deletions packages/webgpu/src/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useImperativeHandle, useRef, useState } from "react";
import type { ViewProps } from "react-native";
import { View } from "react-native";
import { Platform, View } from "react-native";

import WebGPUNativeView from "./WebGPUViewNativeComponent";

Expand Down Expand Up @@ -87,14 +87,29 @@ const resolveSurfaceType = (
? surfaceType
: "auto";

// A view measured relative to itself gets its layout size, without the
// transforms getBoundingClientRect() includes; measureLayout() calls back
// synchronously on the new architecture. On the web the polyfill sizes the
// context from its own canvas element (see WebPolyfillGPUModule).
const measureLayout = (view: View) => {
if (Platform.OS === "web") {
return { width: 0, height: 0 };
}
let size = { width: 0, height: 0 };
view.measureLayout(view, (_left, _top, width, height) => {
size = { width, height };
});
return size;
};

export const Canvas = ({
opaque = true,
android,
ref,
...props
}: CanvasProps) => {
const viewRef = useRef(null);
const [contextId, _] = useState(() => generateContextId());
const viewRef = useRef<View>(null);
const [contextId] = useState(() => generateContextId());
// Retire the native registry entry for this contextId on unmount. When a
// native surface is still attached, this is a no-op and the native view's
// own teardown retires the entry instead — which keeps StrictMode's
Expand All @@ -108,32 +123,28 @@ export const Canvas = ({
RNWebGPU.destroyContext(contextId);
};
}, [contextId]);
useImperativeHandle(ref, () => ({
getContextId: () => contextId,
getNativeSurface: () => {
return RNWebGPU.getNativeSurface(contextId);
},
getContext(contextName: "webgpu"): RNCanvasContext | null {
if (contextName !== "webgpu") {
throw new Error(`[WebGPU] Unsupported context: ${contextName}`);
}
if (!viewRef.current) {
throw new Error("[WebGPU] Cannot get context before mount");
}
// getBoundingClientRect became stable in RN 0.83
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const view = viewRef.current as any;
const size =
"getBoundingClientRect" in view
? view.getBoundingClientRect()
: view.unstable_getBoundingClientRect();
return RNWebGPU.MakeWebGPUCanvasContext(
contextId,
size.width,
size.height,
);
},
}));
useImperativeHandle(
ref,
() => ({
getContextId: () => contextId,
getNativeSurface: () => RNWebGPU.getNativeSurface(contextId),
getContext(contextName: "webgpu"): RNCanvasContext | null {
if (contextName !== "webgpu") {
throw new Error(`[WebGPU] Unsupported context: ${contextName}`);
}
if (!viewRef.current) {
throw new Error("[WebGPU] Cannot get context before mount");
}
const size = measureLayout(viewRef.current);
return RNWebGPU.MakeWebGPUCanvasContext(
contextId,
size.width,
size.height,
);
},
}),
[contextId],
);

return (
<View collapsable={false} ref={viewRef} {...props}>
Expand Down
34 changes: 18 additions & 16 deletions packages/webgpu/src/WebPolyfillGPUModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ import { contextIdToId } from "./utils";

const fabric = true;

function getNativeSurface(contextId: number) {
const canvas = document.getElementById(
contextIdToId(contextId),
) as HTMLCanvasElement;
function getCanvasElement(contextId: number) {
return document.getElementById(contextIdToId(contextId)) as HTMLCanvasElement;
}

const { height, width } = canvas.getBoundingClientRect()!;
// The layout size; getBoundingClientRect() would include CSS transforms.
function getLayoutSize(canvas: HTMLCanvasElement) {
return { width: canvas.clientWidth, height: canvas.clientHeight };
}

function getNativeSurface(contextId: number) {
const canvas = getCanvasElement(contextId);
const { width, height } = getLayoutSize(canvas);

return {
surface: BigInt(contextId),
Expand All @@ -24,18 +30,14 @@ function getNativeSurface(contextId: number) {
};
}

function makeWebGPUCanvasContext(
contextId: number,
width: number,
height: number,
) {
const canvas = document.getElementById(
contextIdToId(contextId),
) as HTMLCanvasElement;
// Sized from the canvas element, not from the size Canvas passes in.
function makeWebGPUCanvasContext(contextId: number) {
const canvas = getCanvasElement(contextId);
const { width, height } = getLayoutSize(canvas);

const dpr = window.devicePixelRatio || 1;
const pixelWidth = (width * dpr).toString();
const pixelHeight = (height * dpr).toString();
const pixelRatio = window.devicePixelRatio || 1;
const pixelWidth = (width * pixelRatio).toString();
const pixelHeight = (height * pixelRatio).toString();

if (
canvas.getAttribute("width") !== pixelWidth ||
Expand Down
46 changes: 46 additions & 0 deletions packages/webgpu/src/__tests__/Canvas.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { client, isNativeApp } from "./setup";

// The harness mounts a canvas laid out at a known size and drawn at half that
// size under a scale transform (apps/example/src/Tests.tsx).

describe("Canvas", () => {
it("sizes the context's canvas from its layout, not its transformed box", async () => {
if (!isNativeApp()) {
return;
}
const result = await client.eval(({ views }) => {
const context = views?.scaledCanvas()?.getContext("webgpu");
if (!views || !context) {
throw new Error("The Tests screen has no scaled canvas");
}
const canvas = context.canvas as HTMLCanvasElement;
return {
layoutSize: views.scaledCanvasSize,
size: { width: canvas.width, height: canvas.height },
clientSize: { width: canvas.clientWidth, height: canvas.clientHeight },
};
});
expect(result.size).toEqual(result.layoutSize);
expect(result.clientSize).toEqual(result.layoutSize);
});

it("keeps one ref handle across re-renders", async () => {
if (!isNativeApp()) {
return;
}
const result = await client.eval(({ views }) => {
if (!views) {
throw new Error("The Tests screen has no scaled canvas");
}
const { scaledCanvas, rerender } = views;
const handle = scaledCanvas();
return Promise.all([rerender(), rerender()])
.then(() => rerender())
.then(() => ({
attached: handle !== null,
kept: scaledCanvas() === handle,
}));
});
expect(result).toEqual({ attached: true, kept: true });
});
});
8 changes: 4 additions & 4 deletions packages/webgpu/src/__tests__/CanvasContext.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { client } from "./setup";
import { client, isNativeApp } from "./setup";

describe("Native canvas context", () => {
it("returns the same texture within a frame, even after destroy", async () => {
if (client.OS !== "ios" && client.OS !== "android") {
if (!isNativeApp()) {
return;
}
const result = await client.eval(({ device, gpu }) => {
Expand Down Expand Up @@ -31,7 +31,7 @@ describe("Native canvas context", () => {
// per SurfaceInfo, so presenting through one context ends the other's frame
// only when they share it.
it("keeps the surface of a destroyed context for its id while the context lives", async () => {
if (client.OS !== "ios" && client.OS !== "android") {
if (!isNativeApp()) {
return;
}
const result = await client.eval(({ device, gpu }) => {
Expand All @@ -57,7 +57,7 @@ describe("Native canvas context", () => {
it.each(["present", "configure", "unconfigure", "resize"] as const)(
"returns a new texture after %s",
async (operation) => {
if (client.OS !== "ios" && client.OS !== "android") {
if (!isNativeApp()) {
return;
}
const result = await client.eval(
Expand Down
13 changes: 9 additions & 4 deletions packages/webgpu/src/__tests__/ImageBitmapOptions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { checkImage, client, encodeImage, fixtureUrl } from "./setup";
import {
checkImage,
client,
encodeImage,
fixtureUrl,
isNativeApp,
} from "./setup";

// createImageBitmap options beyond premultiplyAlpha (which is covered by
// ImageBitmapAlpha.spec.ts): the crop-rect overload, resizeWidth/resizeHeight,
Expand Down Expand Up @@ -138,10 +144,9 @@ describe("createImageBitmap options", () => {
cropRect ?? null,
options ?? null,
);
const isReference = client.OS === "web" || client.OS === "node";
checkImage(
encodeImage(result),
isReference ? referenceSnapshot : identitySnapshot,
isNativeApp() ? identitySnapshot : referenceSnapshot,
);
});

Expand All @@ -155,7 +160,7 @@ describe("createImageBitmap options", () => {
// (CoreGraphics, device RGB) decode paths; these cases are skipped there
// until that behavior is measured on-device and pinned down.
it("colorSpaceConversion none returns raw pixel values", async () => {
if (client.OS !== "web" && client.OS !== "node") {
if (isNativeApp()) {
return;
}
const result = await runCase("p3-gradient.png", null, {
Expand Down
14 changes: 6 additions & 8 deletions packages/webgpu/src/__tests__/ViewSnapshot.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { client } from "./setup";
import { client, isNativeApp } from "./setup";

// The harness mounts a 64x64pt probe view made of four solid quadrants
// (apps/example/src/Tests.tsx): red top-left, lime top-right, blue
Expand All @@ -18,11 +18,9 @@ const expectColor = (actual: number[], expected: RGBA, tolerance = 3) => {
});
};

const nativeOnly = () => client.OS === "ios" || client.OS === "android";

describe("drawElementImageToTexture", () => {
it("draws a native view at its natural pixel size", async () => {
if (!nativeOnly()) {
if (!isNativeApp()) {
return;
}
const result = await client.eval(({ device, views }) => {
Expand Down Expand Up @@ -75,7 +73,7 @@ describe("drawElementImageToTexture", () => {
});

it("scales the view to destination.size and honours origin", async () => {
if (!nativeOnly()) {
if (!isNativeApp()) {
return;
}
const result = await client.eval(({ device, views }) => {
Expand Down Expand Up @@ -143,7 +141,7 @@ describe("drawElementImageToTexture", () => {
});

it("crops to the source rectangle and swizzles into bgra8unorm", async () => {
if (!nativeOnly()) {
if (!isNativeApp()) {
return;
}
const result = await client.eval(({ device, views }) => {
Expand Down Expand Up @@ -205,7 +203,7 @@ describe("drawElementImageToTexture", () => {
});

it("rejects when the natural size does not fit the texture", async () => {
if (!nativeOnly()) {
if (!isNativeApp()) {
return;
}
const result = await client.eval(({ device, views }) => {
Expand All @@ -225,7 +223,7 @@ describe("drawElementImageToTexture", () => {
});

it("rejects an unknown view and an unsupported format", async () => {
if (!nativeOnly()) {
if (!isNativeApp()) {
return;
}
const result = await client.eval(({ device }) => {
Expand Down
Loading
Loading