diff --git a/apps/example/src/Tests.tsx b/apps/example/src/Tests.tsx index 388090c6c..c7af3e0d7 100644 --- a/apps/example/src/Tests.tsx +++ b/apps/example/src/Tests.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Dimensions, Text, @@ -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"; @@ -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 void>()); + useEffect(() => { + pendingRequests.current.forEach((resolve, request) => { + if (request <= renderedRequest) { + pendingRequests.current.delete(request); + resolve(); + } + }); + }, [renderedRequest]); + return useCallback( + () => + new Promise((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(null); @@ -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(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(null); + const rerender = useRerender(); useEffect(() => { let cancelled = false; (async () => { @@ -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, @@ -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 ( @@ -213,6 +247,10 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => { }} /> + { + 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(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 @@ -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 ( diff --git a/packages/webgpu/src/WebPolyfillGPUModule.ts b/packages/webgpu/src/WebPolyfillGPUModule.ts index 4f0e0bef7..6abab86f7 100644 --- a/packages/webgpu/src/WebPolyfillGPUModule.ts +++ b/packages/webgpu/src/WebPolyfillGPUModule.ts @@ -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), @@ -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 || diff --git a/packages/webgpu/src/__tests__/Canvas.spec.ts b/packages/webgpu/src/__tests__/Canvas.spec.ts new file mode 100644 index 000000000..10a45d7a9 --- /dev/null +++ b/packages/webgpu/src/__tests__/Canvas.spec.ts @@ -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 }); + }); +}); diff --git a/packages/webgpu/src/__tests__/CanvasContext.spec.ts b/packages/webgpu/src/__tests__/CanvasContext.spec.ts index 5277e5422..560665c91 100644 --- a/packages/webgpu/src/__tests__/CanvasContext.spec.ts +++ b/packages/webgpu/src/__tests__/CanvasContext.spec.ts @@ -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 }) => { @@ -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 }) => { @@ -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( diff --git a/packages/webgpu/src/__tests__/ImageBitmapOptions.spec.ts b/packages/webgpu/src/__tests__/ImageBitmapOptions.spec.ts index e522ee595..edb94848e 100644 --- a/packages/webgpu/src/__tests__/ImageBitmapOptions.spec.ts +++ b/packages/webgpu/src/__tests__/ImageBitmapOptions.spec.ts @@ -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, @@ -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, ); }); @@ -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, { diff --git a/packages/webgpu/src/__tests__/ViewSnapshot.spec.ts b/packages/webgpu/src/__tests__/ViewSnapshot.spec.ts index 7367f8aad..9ed046fa1 100644 --- a/packages/webgpu/src/__tests__/ViewSnapshot.spec.ts +++ b/packages/webgpu/src/__tests__/ViewSnapshot.spec.ts @@ -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 @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { diff --git a/packages/webgpu/src/__tests__/WebPolyfill.spec.ts b/packages/webgpu/src/__tests__/WebPolyfill.spec.ts index 92d2e539a..0573aef15 100644 --- a/packages/webgpu/src/__tests__/WebPolyfill.spec.ts +++ b/packages/webgpu/src/__tests__/WebPolyfill.spec.ts @@ -1,34 +1,68 @@ +import type { RNCanvasContext } from "../Canvas"; + // The web polyfill for the RNWebGPU native module runs against the DOM, so it // is exercised here with a minimal stand-in for `window` and `document`. interface FakeCanvas { context: object | null; attributes: Record; + attributeWrites: number; + calls: unknown[][]; + clientWidth: number; + clientHeight: number; getAttribute(name: string): string | null; setAttribute(name: string, value: string): void; getContext(contextId: string): object | null; + getBoundingClientRect(): { width: number; height: number }; + addEventListener(...args: unknown[]): void; + removeEventListener(...args: unknown[]): void; + dispatchEvent(...args: unknown[]): void; + setPointerCapture(...args: unknown[]): void; + releasePointerCapture(...args: unknown[]): void; } +// Laid out at 100 x 50 CSS pixels and drawn at half that size under a CSS +// scale transform, which getBoundingClientRect() includes. const makeCanvas = (context: object | null): FakeCanvas => ({ context, attributes: {}, + attributeWrites: 0, + calls: [], + clientWidth: 100, + clientHeight: 50, getAttribute(name) { return this.attributes[name] ?? null; }, setAttribute(name, value) { this.attributes[name] = value; + this.attributeWrites += 1; }, getContext() { return this.context; }, + getBoundingClientRect: () => ({ width: 50, height: 25 }), + addEventListener(...args) { + this.calls.push(["addEventListener", ...args]); + }, + removeEventListener(...args) { + this.calls.push(["removeEventListener", ...args]); + }, + dispatchEvent(...args) { + this.calls.push(["dispatchEvent", ...args]); + }, + setPointerCapture(...args) { + this.calls.push(["setPointerCapture", ...args]); + }, + releasePointerCapture(...args) { + this.calls.push(["releasePointerCapture", ...args]); + }, }); -type Polyfill = { +type Polyfill = Pick & { + // Unlike native, the web polyfill returns null without WebGPU support. MakeWebGPUCanvasContext: ( - contextId: number, - width: number, - height: number, - ) => (object & { present: () => void }) | null; + ...args: Parameters + ) => RNCanvasContext | null; }; const loadPolyfill = (canvas: FakeCanvas): Polyfill => { @@ -50,18 +84,74 @@ describe("Web polyfill", () => { it("returns the canvas context with a no-op present()", () => { const context = {}; - const canvas = makeCanvas(context); - const RNWebGPU = loadPolyfill(canvas); - const result = RNWebGPU.MakeWebGPUCanvasContext(1, 100, 50); + const RNWebGPU = loadPolyfill(makeCanvas(context)); + const result = RNWebGPU.MakeWebGPUCanvasContext(1, 0, 0); expect(result).toBe(context); - expect(typeof result!.present).toBe("function"); + expect(result!.present()).toBeUndefined(); + }); + + it("sizes the drawing buffer from the canvas's layout size, not its transformed box", () => { + const canvas = makeCanvas({}); + const RNWebGPU = loadPolyfill(canvas); + const box = canvas.getBoundingClientRect(); + RNWebGPU.MakeWebGPUCanvasContext(1, box.width, box.height); expect(canvas.attributes).toEqual({ width: "200", height: "100" }); }); + // Writing a canvas's width or height clears its drawing buffer. + it("leaves a drawing buffer that already has its size untouched", () => { + const canvas = makeCanvas({}); + const RNWebGPU = loadPolyfill(canvas); + RNWebGPU.MakeWebGPUCanvasContext(1, 0, 0); + const writes = canvas.attributeWrites; + RNWebGPU.MakeWebGPUCanvasContext(1, 0, 0); + expect({ writes, rewrites: canvas.attributeWrites - writes }).toEqual({ + writes: 2, + rewrites: 0, + }); + }); + + it("reports the canvas's layout size as its native surface size", () => { + const RNWebGPU = loadPolyfill(makeCanvas({})); + const { width, height, clientWidth, clientHeight } = + RNWebGPU.getNativeSurface(1); + expect({ width, height, clientWidth, clientHeight }).toEqual({ + width: 100, + height: 50, + clientWidth: 100, + clientHeight: 50, + }); + }); + + it("reads the native surface without touching the drawing buffer", () => { + const canvas = makeCanvas({}); + loadPolyfill(canvas).getNativeSurface(1); + expect(canvas.attributeWrites).toBe(0); + }); + + it("delegates the native surface's DOM methods to the canvas element", () => { + const canvas = makeCanvas({}); + const surface = loadPolyfill(canvas).getNativeSurface(1); + const listener = () => {}; + const event = new Event("pointerdown"); + surface.addEventListener("pointerdown", listener); + surface.removeEventListener("pointerdown", listener); + surface.dispatchEvent(event); + surface.setPointerCapture(7); + surface.releasePointerCapture(7); + expect(canvas.calls).toEqual([ + ["addEventListener", "pointerdown", listener], + ["removeEventListener", "pointerdown", listener], + ["dispatchEvent", event], + ["setPointerCapture", 7], + ["releasePointerCapture", 7], + ]); + }); + it("returns null when the browser has no WebGPU context", () => { // canvas.getContext("webgpu") returns null when WebGPU is unavailable // (e.g. Safari with the WebGPU feature flag turned off). const RNWebGPU = loadPolyfill(makeCanvas(null)); - expect(RNWebGPU.MakeWebGPUCanvasContext(1, 100, 50)).toBeNull(); + expect(RNWebGPU.MakeWebGPUCanvasContext(1, 0, 0)).toBeNull(); }); }); diff --git a/packages/webgpu/src/__tests__/setup.ts b/packages/webgpu/src/__tests__/setup.ts index 2bc7dec0e..2b47c3410 100644 --- a/packages/webgpu/src/__tests__/setup.ts +++ b/packages/webgpu/src/__tests__/setup.ts @@ -11,6 +11,7 @@ import { mat4, vec3, mat3 } from "wgpu-matrix"; import type { Server, WebSocket } from "ws"; import type { Browser, Page } from "puppeteer"; +import type { CanvasRef } from "../Canvas"; import type { GPUOffscreenCanvas } from "../Offscreen"; import { cubeVertexArray } from "./components/cube"; @@ -72,11 +73,16 @@ interface GPUTestingContext { }; // Native app harness only: a mounted probe view for the // drawElementImageToTexture spec (its React tag, its size in points and the - // device pixel ratio). See apps/example/src/Tests.tsx. + // device pixel ratio), and for the Canvas spec a canvas drawn at half its + // layout size, that layout size, and a re-render of the screen that + // resolves once a commit has rendered it. See apps/example/src/Tests.tsx. views?: { probe: number | null; probeSize: number; pixelRatio: number; + scaledCanvas: () => CanvasRef | null; + scaledCanvasSize: { width: number; height: number }; + rerender: () => Promise; }; mat4: typeof mat4; vec3: typeof vec3; @@ -903,6 +909,15 @@ export const checkImage = ( return 0; }; +const NATIVE_APP: Record = { + ios: true, + android: true, + web: false, + node: false, +}; + +export const isNativeApp = () => NATIVE_APP[client.OS]; + export const itSkipsOnWeb = (name: string, fn: () => Promise) => { it(name, async () => { if (client.OS === "web") { @@ -923,7 +938,7 @@ export const itSkipsOnWeb = (name: string, fn: () => Promise) => { // keep the inline form, which leaves the reference clients independent of that // server. export const fixtureUrl = (fileName: string): string => { - if (client.OS === "ios" || client.OS === "android") { + if (isNativeApp()) { return `http://${global.testHost}:${TEST_SERVER_PORT}/${fileName}`; } const p = path.resolve(__dirname, "assets", fileName); diff --git a/packages/webgpu/src/mock.ts b/packages/webgpu/src/mock.ts index 4079306c7..c161c5d00 100644 --- a/packages/webgpu/src/mock.ts +++ b/packages/webgpu/src/mock.ts @@ -38,13 +38,19 @@ export const Canvas = ({ ref, ...props }: CanvasProps) => { - React.useImperativeHandle(ref, () => ({ - getContextId: () => -1, - getContext: (_contextName: "webgpu"): RNCanvasContext | null => null, - getNativeSurface: (): NativeCanvas => { - throw new Error(`Canvas.getNativeSurface() ${NOT_AVAILABLE_UNDER_JEST}`); - }, - })); + React.useImperativeHandle( + ref, + () => ({ + getContextId: () => -1, + getContext: (_contextName: "webgpu"): RNCanvasContext | null => null, + getNativeSurface: (): NativeCanvas => { + throw new Error( + `Canvas.getNativeSurface() ${NOT_AVAILABLE_UNDER_JEST}`, + ); + }, + }), + [], + ); return React.createElement(View, props); };