diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed65ed49b0..fa557526e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -375,10 +375,25 @@ jobs: working-directory: packages/skia run: yarn build && yarn pack + - name: Log package.json before change + working-directory: externals/skia-web-app + run: | + echo "=== package.json BEFORE change ===" + grep -n "@shopify/react-native-skia" package.json || echo "Pattern not found" + echo "==================================" + - name: Update package.json to use local Skia package working-directory: externals/skia-web-app run: | - sed -i '' 's/"@shopify\/react-native-skia": "[^"]*"/"@shopify\/react-native-skia": "file:..\/..\/packages\/skia\/package.tgz"/' package.json + # More robust regex that handles different whitespace + sed -i '' 's/"@shopify\/react-native-skia"[[:space:]]*:[[:space:]]*"[^"]*"/"@shopify\/react-native-skia": "file:..\/..\/packages\/skia\/package.tgz"/' package.json + + - name: Log package.json after change + working-directory: externals/skia-web-app + run: | + echo "=== package.json AFTER change ===" + grep -n "@shopify/react-native-skia" package.json || echo "Pattern not found" + echo "=================================" - name: Install dependencies for skia-web-app working-directory: externals/skia-web-app diff --git a/apps/docs/docs/getting-started/web.mdx b/apps/docs/docs/getting-started/web.mdx index 4793527679..200cc387eb 100644 --- a/apps/docs/docs/getting-started/web.mdx +++ b/apps/docs/docs/getting-started/web.mdx @@ -156,6 +156,37 @@ LoadSkiaWeb({ }); ``` +## WebGL Contextes + +Web browsers limit the number of WebGL contexts to 16 per webpage. +Usually developers will see this error when they exceed this limit: + +``` +WARNING: Too many active WebGL contexts. Oldest context will be lost. +``` +If you canvas is static and doesn't contain animation values, you can use the `__destroyWebGLContextAfterRender={true}` prop on your Canvas components to destroy the WebGL context after rendering. +This even works with animated canvases but it will come with a performance cost as the context will be recreated on each render. + +```tsx twoslash +import { View } from 'react-native'; +import { Canvas, Fill } from "@shopify/react-native-skia"; + +export default function App() { + return ( + + { + // 20 Skia Canvases with __destroyWebGLContextAfterRender={true} + new Array(20).fill(0).map((_, i) => ( + + + + )) + } + + ); +} +``` + ## Unsupported Features The following React Native Skia APIs are currently unsupported on React Native Web. diff --git a/apps/example/src/Tests/Tests.tsx b/apps/example/src/Tests/Tests.tsx index e0c5129945..4651cc78fa 100644 --- a/apps/example/src/Tests/Tests.tsx +++ b/apps/example/src/Tests/Tests.tsx @@ -32,7 +32,7 @@ export const Tests = ({ assets }: TestsProps) => { const [client, hostname] = useClient(); const [drawing, setDrawing] = useState(null); const [screen, setScreen] = useState(null); - + useEffect(() => { if (client !== null) { // Define the message handler as a separate function @@ -66,16 +66,16 @@ export const Tests = ({ assets }: TestsProps) => { }; // Use addEventListener instead of onmessage - client.addEventListener('message', handleMessage); + client.addEventListener("message", handleMessage); // Clean up: remove the specific event listener return () => { - client.removeEventListener('message', handleMessage); + client.removeEventListener("message", handleMessage); }; } return; }, [assets, client]); - + useEffect(() => { if (drawing && client) { const it = setTimeout(() => { @@ -104,7 +104,7 @@ export const Tests = ({ assets }: TestsProps) => { } return; }, [client, drawing, ref]); - + useEffect(() => { if (screen && client) { const it = setTimeout(async () => { @@ -120,7 +120,7 @@ export const Tests = ({ assets }: TestsProps) => { } return; }, [client, screen]); - + return ( diff --git a/packages/skia/src/renderer/Canvas.tsx b/packages/skia/src/renderer/Canvas.tsx index 7ba53a5efa..78beb9e906 100644 --- a/packages/skia/src/renderer/Canvas.tsx +++ b/packages/skia/src/renderer/Canvas.tsx @@ -56,6 +56,7 @@ export interface CanvasProps extends Omit { onSize?: SharedValue; colorSpace?: "p3" | "srgb"; ref?: React.Ref; + __destroyWebGLContextAfterRender?: boolean; } export const Canvas = ({ diff --git a/packages/skia/src/renderer/__tests__/e2e/ParagraphMethods.spec.tsx b/packages/skia/src/renderer/__tests__/e2e/ParagraphMethods.spec.tsx index d39ba491ec..2a814577f6 100644 --- a/packages/skia/src/renderer/__tests__/e2e/ParagraphMethods.spec.tsx +++ b/packages/skia/src/renderer/__tests__/e2e/ParagraphMethods.spec.tsx @@ -11,109 +11,109 @@ const RobotoRegular = Array.from( describe("Paragraph Methods", () => { describe("getRectsForPlaceholders", () => { - it("should handle multiple placeholders with different alignments", async () => { - const placeholderRects = await surface.eval( - (Skia, ctx) => { - const robotoRegular = Skia.Typeface.MakeFreeTypeFaceFromData( - Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) - )!; - const provider = Skia.TypefaceFontProvider.Make(); - provider.registerFont(robotoRegular, "Roboto"); - - const builder = Skia.ParagraphBuilder.Make( - { - textStyle: { - color: Skia.Color("black"), - fontFamilies: ["Roboto"], - fontSize: 16, + it("should handle multiple placeholders with different alignments", async () => { + const placeholderRects = await surface.eval( + (Skia, ctx) => { + const robotoRegular = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(robotoRegular, "Roboto"); + + const builder = Skia.ParagraphBuilder.Make( + { + textStyle: { + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 16, + }, }, - }, - provider - ); - - builder.addText("Start "); - builder.addPlaceholder( - 20, - 20, - ctx.PlaceholderAlignment.Baseline, - ctx.TextBaseline.Alphabetic - ); - builder.addText(" middle "); - builder.addPlaceholder( - 15, - 15, - ctx.PlaceholderAlignment.Top, - ctx.TextBaseline.Alphabetic - ); - builder.addText(" end"); - - const paragraph = builder.build(); - paragraph.layout(200); - - const rects = paragraph.getRectsForPlaceholders(); - return rects.map((r) => ({ - x: r.rect.x, - y: r.rect.y, - width: r.rect.width, - height: r.rect.height, - direction: r.direction, - })); - }, - { - RobotoRegular, - PlaceholderAlignment, - TextBaseline, - } - ); - - expect(placeholderRects).toHaveLength(2); - expect(placeholderRects[0].width).toBe(20); - expect(placeholderRects[0].height).toBe(20); - expect(placeholderRects[1].width).toBeCloseTo(15); - expect(placeholderRects[1].height).toBeCloseTo(15); - }); + provider + ); - it("should return correct direction for placeholders", async () => { - const placeholderInfo = await surface.eval( - (Skia, ctx) => { - const robotoRegular = Skia.Typeface.MakeFreeTypeFaceFromData( - Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) - )!; - const provider = Skia.TypefaceFontProvider.Make(); - provider.registerFont(robotoRegular, "Roboto"); - - const builder = Skia.ParagraphBuilder.Make( - { - textStyle: { - color: Skia.Color("black"), - fontFamilies: ["Roboto"], - fontSize: 16, + builder.addText("Start "); + builder.addPlaceholder( + 20, + 20, + ctx.PlaceholderAlignment.Baseline, + ctx.TextBaseline.Alphabetic + ); + builder.addText(" middle "); + builder.addPlaceholder( + 15, + 15, + ctx.PlaceholderAlignment.Top, + ctx.TextBaseline.Alphabetic + ); + builder.addText(" end"); + + const paragraph = builder.build(); + paragraph.layout(200); + + const rects = paragraph.getRectsForPlaceholders(); + return rects.map((r) => ({ + x: r.rect.x, + y: r.rect.y, + width: r.rect.width, + height: r.rect.height, + direction: r.direction, + })); + }, + { + RobotoRegular, + PlaceholderAlignment, + TextBaseline, + } + ); + + expect(placeholderRects).toHaveLength(2); + expect(placeholderRects[0].width).toBe(20); + expect(placeholderRects[0].height).toBe(20); + expect(placeholderRects[1].width).toBeCloseTo(15); + expect(placeholderRects[1].height).toBeCloseTo(15); + }); + + it("should return correct direction for placeholders", async () => { + const placeholderInfo = await surface.eval( + (Skia, ctx) => { + const robotoRegular = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(robotoRegular, "Roboto"); + + const builder = Skia.ParagraphBuilder.Make( + { + textStyle: { + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 16, + }, }, - }, - provider - ); - - builder.addText("Text with "); - builder.addPlaceholder(30, 30); - builder.addText(" placeholder"); - - const paragraph = builder.build(); - paragraph.layout(300); - - const rects = paragraph.getRectsForPlaceholders(); - return rects.map((r) => ({ - direction: r.direction === ctx.TextDirection.LTR ? "LTR" : "RTL", - })); - }, - { - RobotoRegular, - TextDirection, - } - ); - - expect(placeholderInfo).toHaveLength(1); - expect(placeholderInfo[0].direction).toBe("LTR"); - }); + provider + ); + + builder.addText("Text with "); + builder.addPlaceholder(30, 30); + builder.addText(" placeholder"); + + const paragraph = builder.build(); + paragraph.layout(300); + + const rects = paragraph.getRectsForPlaceholders(); + return rects.map((r) => ({ + direction: r.direction === ctx.TextDirection.LTR ? "LTR" : "RTL", + })); + }, + { + RobotoRegular, + TextDirection, + } + ); + + expect(placeholderInfo).toHaveLength(1); + expect(placeholderInfo[0].direction).toBe("LTR"); + }); it("should return empty array when no placeholders", async () => { const placeholderCount = await surface.eval( @@ -194,7 +194,7 @@ describe("Paragraph Methods", () => { expect(lineMetrics[0].ascent).toBeGreaterThan(0); expect(lineMetrics[0].descent).toBeGreaterThan(0); // Note: Even single lines without explicit breaks may report isHardBreak as true - expect(typeof lineMetrics[0].isHardBreak).toBe('boolean'); + expect(typeof lineMetrics[0].isHardBreak).toBe("boolean"); }); it("should return line metrics for multi-line text with wrapping", async () => { @@ -232,12 +232,12 @@ describe("Paragraph Methods", () => { ); expect(lineMetrics.length).toBeGreaterThan(1); - + // Check first line expect(lineMetrics[0].lineNumber).toBe(0); expect(lineMetrics[0].startIndex).toBe(0); expect(lineMetrics[0].width).toBeLessThanOrEqual(100); - + // Check second line expect(lineMetrics[1].lineNumber).toBe(1); expect(lineMetrics[1].startIndex).toBeGreaterThan(0); @@ -277,12 +277,12 @@ describe("Paragraph Methods", () => { ); expect(lineMetrics).toHaveLength(3); - + // All lines report isHardBreak as true in this implementation expect(lineMetrics[0].isHardBreak).toBe(true); expect(lineMetrics[1].isHardBreak).toBe(true); expect(lineMetrics[2].isHardBreak).toBe(true); - + // Check line numbers expect(lineMetrics[0].lineNumber).toBe(0); expect(lineMetrics[1].lineNumber).toBe(1); @@ -322,19 +322,24 @@ describe("Paragraph Methods", () => { ); expect(lineMetrics).toHaveLength(2); - + // First line const firstLine = lineMetrics[0]; // Height should be close to ascent + descent expect(firstLine.height).toBeGreaterThan(0); - expect(Math.abs(firstLine.height - (firstLine.ascent + firstLine.descent))).toBeLessThan(1); + expect( + Math.abs(firstLine.height - (firstLine.ascent + firstLine.descent)) + ).toBeLessThan(1); expect(firstLine.left).toBe(0); expect(firstLine.baseline).toBeGreaterThan(0); - + // Second line should be below the first const secondLine = lineMetrics[1]; expect(secondLine.baseline).toBeGreaterThan(firstLine.baseline); - expect(secondLine.baseline - firstLine.baseline).toBeCloseTo(firstLine.height, 1); + expect(secondLine.baseline - firstLine.baseline).toBeCloseTo( + firstLine.height, + 1 + ); }); it("should handle empty lines correctly", async () => { @@ -370,7 +375,7 @@ describe("Paragraph Methods", () => { ); expect(lineMetrics).toHaveLength(3); - + // Middle line should be empty but still have metrics const emptyLine = lineMetrics[1]; // Empty line might have startIndex != endIndex depending on implementation diff --git a/packages/skia/src/specs/NativeSkiaModule.web.ts b/packages/skia/src/specs/NativeSkiaModule.web.ts index 86d94e6c23..5dfb9c2e1c 100644 --- a/packages/skia/src/specs/NativeSkiaModule.web.ts +++ b/packages/skia/src/specs/NativeSkiaModule.web.ts @@ -1,19 +1,19 @@ /* eslint-disable import/no-anonymous-default-export */ import type { SkPicture, SkRect } from "../skia/types"; import type { ISkiaViewApi } from "../views/types"; -import type { SkiaPictureView } from "../views/SkiaPictureView.web"; +import type { SkiaPictureViewHandle } from "../views/SkiaPictureView.web"; export type ISkiaViewApiWeb = ISkiaViewApi & { - views: Record; + views: Record; deferedPictures: Record; - registerView(nativeId: string, view: SkiaPictureView): void; + registerView(nativeId: string, view: SkiaPictureViewHandle): void; }; global.SkiaViewApi = { views: {}, deferedPictures: {}, web: true, - registerView(nativeId: string, view: SkiaPictureView) { + registerView(nativeId: string, view: SkiaPictureViewHandle) { // Maybe a picture for this view was already set if (this.deferedPictures[nativeId]) { view.setPicture(this.deferedPictures[nativeId] as SkPicture); diff --git a/packages/skia/src/views/SkiaBaseWebView.tsx b/packages/skia/src/views/SkiaBaseWebView.tsx deleted file mode 100644 index d7f16d5ef2..0000000000 --- a/packages/skia/src/views/SkiaBaseWebView.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/* global HTMLCanvasElement */ -import React from "react"; -import type { LayoutChangeEvent } from "react-native"; - -import type { SkRect, SkCanvas } from "../skia/types"; -import { JsiSkSurface } from "../skia/web/JsiSkSurface"; -import { Platform } from "../Platform"; - -import type { SkiaBaseViewProps } from "./types"; - -const pd = Platform.PixelRatio; - -export abstract class SkiaBaseWebView< - TProps extends SkiaBaseViewProps -> extends React.Component { - constructor(props: TProps) { - super(props); - } - - private _surface: JsiSkSurface | null = null; - private _unsubscriptions: Array<() => void> = []; - private _canvas: SkCanvas | null = null; - private _canvasRef = React.createRef(); - private _redrawRequests = 0; - private requestId = 0; - - protected width = 0; - protected height = 0; - - private unsubscribeAll() { - this._unsubscriptions.forEach((u) => u()); - this._unsubscriptions = []; - } - - private onLayoutEvent(evt: LayoutChangeEvent) { - const { CanvasKit } = global; - // Reset canvas / surface on layout change - const canvas = this._canvasRef.current; - if (canvas) { - this.width = canvas.clientWidth; - this.height = canvas.clientHeight; - canvas.width = this.width * pd; - canvas.height = this.height * pd; - const surface = CanvasKit.MakeWebGLCanvasSurface(canvas); - const ctx = canvas.getContext("webgl2"); - if (ctx) { - ctx.drawingBufferColorSpace = "display-p3"; - } - if (!surface) { - throw new Error("Could not create surface"); - } - this._surface = new JsiSkSurface(CanvasKit, surface); - this._canvas = this._surface.getCanvas(); - this.redraw(); - } - // Call onLayout callback if it exists - if (this.props.onLayout) { - this.props.onLayout(evt); - } - } - - getSize() { - return { width: this.width, height: this.height }; - } - - componentDidMount() { - // Start render loop - this.tick(); - } - - componentDidUpdate() { - this.redraw(); - } - - componentWillUnmount() { - this.unsubscribeAll(); - cancelAnimationFrame(this.requestId); - // eslint-disable-next-line max-len - // https://stackoverflow.com/questions/23598471/how-do-i-clean-up-and-unload-a-webgl-canvas-context-from-gpu-after-use - // https://developer.mozilla.org/en-US/docs/Web/API/WEBGL_lose_context - // We delete the context, only if the context has been intialized - if (this._surface) { - this._canvasRef.current - ?.getContext("webgl2") - ?.getExtension("WEBGL_lose_context") - ?.loseContext(); - } - } - - /** - * Creates a snapshot from the canvas in the surface - * @param rect Rect to use as bounds. Optional. - * @returns An Image object. - */ - public makeImageSnapshot(rect?: SkRect) { - this._canvas!.clear(CanvasKit.TRANSPARENT); - this.renderInCanvas(this._canvas!); - this._surface?.ref.flush(); - return this._surface?.makeImageSnapshot(rect); - } - - /** - * Override to render - */ - protected abstract renderInCanvas(canvas: SkCanvas): void; - - /** - * Sends a redraw request to the native SkiaView. - */ - private tick() { - if (this._redrawRequests > 0) { - this._redrawRequests = 0; - if (this._canvas) { - const canvas = this._canvas!; - canvas.clear(Float32Array.of(0, 0, 0, 0)); - canvas.save(); - canvas.scale(pd, pd); - this.renderInCanvas(canvas); - canvas.restore(); - this._surface?.ref.flush(); - } - } - this.requestId = requestAnimationFrame(this.tick.bind(this)); - } - - public redraw() { - this._redrawRequests++; - } - - private onLayout = this.onLayoutEvent.bind(this); - - render() { - const { debug = false, ...viewProps } = this.props; - return ( - - - - ); - } -} diff --git a/packages/skia/src/views/SkiaPictureView.web.tsx b/packages/skia/src/views/SkiaPictureView.web.tsx index 16523ee026..0425518502 100644 --- a/packages/skia/src/views/SkiaPictureView.web.tsx +++ b/packages/skia/src/views/SkiaPictureView.web.tsx @@ -1,31 +1,325 @@ -import type { SkCanvas, SkPicture } from "../skia/types"; +/* global HTMLCanvasElement */ +import React, { + useRef, + useEffect, + useCallback, + useImperativeHandle, + forwardRef, +} from "react"; +import type { LayoutChangeEvent } from "react-native"; + +import type { SkRect, SkPicture, SkImage } from "../skia/types"; +import { JsiSkSurface } from "../skia/web/JsiSkSurface"; +import { Platform } from "../Platform"; import type { ISkiaViewApiWeb } from "../specs/NativeSkiaModule.web"; import type { SkiaPictureViewNativeProps } from "./types"; -import { SkiaBaseWebView } from "./SkiaBaseWebView"; +import { SkiaViewNativeId } from "./SkiaViewNativeId"; + +interface Renderer { + onResize(): void; + draw(picture: SkPicture): void; + makeImageSnapshot(picture: SkPicture, rect?: SkRect): SkImage | null; + dispose(): void; +} + +class WebGLRenderer implements Renderer { + private surface: JsiSkSurface | null = null; + + constructor(private canvas: HTMLCanvasElement, private pd: number) { + this.onResize(); + } + + makeImageSnapshot(picture: SkPicture, rect?: SkRect): SkImage | null { + if (!this.surface) { + return null; + } + const canvas = this.surface.getCanvas(); + canvas!.clear(CanvasKit.TRANSPARENT); + this.draw(picture); + this.surface.ref.flush(); + return this.surface.makeImageSnapshot(rect); + } + + onResize() { + const { canvas, pd } = this; + canvas.width = canvas.clientWidth * pd; + canvas.height = canvas.clientHeight * pd; + const surface = CanvasKit.MakeWebGLCanvasSurface(canvas); + const ctx = canvas.getContext("webgl2"); + if (ctx) { + ctx.drawingBufferColorSpace = "display-p3"; + } + if (!surface) { + throw new Error("Could not create surface"); + } + this.surface = new JsiSkSurface(CanvasKit, surface); + } + + draw(picture: SkPicture) { + if (this.surface) { + const canvas = this.surface.getCanvas(); + canvas.clear(Float32Array.of(0, 0, 0, 0)); + canvas.save(); + canvas.scale(pd, pd); + canvas.drawPicture(picture); + canvas.restore(); + this.surface.ref.flush(); + } + } -export class SkiaPictureView extends SkiaBaseWebView { - private picture: SkPicture | null = null; + dispose(): void { + if (this.surface) { + this.canvas + ?.getContext("webgl2") + ?.getExtension("WEBGL_lose_context") + ?.loseContext(); + this.surface.ref.delete(); + this.surface = null; + } + } +} + +class StaticWebGLRenderer implements Renderer { + private cachedImage: SkImage | null = null; + + constructor(private canvas: HTMLCanvasElement, private pd: number) {} + + onResize(): void { + this.cachedImage = null; + } + + private renderPictureToSurface( + picture: SkPicture + ): { surface: JsiSkSurface; tempCanvas: OffscreenCanvas } | null { + const tempCanvas = new OffscreenCanvas( + this.canvas.clientWidth * this.pd, + this.canvas.clientHeight * this.pd + ); + + let surface: JsiSkSurface | null = null; + + try { + const webglSurface = CanvasKit.MakeWebGLCanvasSurface(tempCanvas); + const ctx = tempCanvas.getContext("webgl2"); + if (ctx) { + ctx.drawingBufferColorSpace = "display-p3"; + } + + if (!webglSurface) { + throw new Error("Could not create WebGL surface"); + } + + surface = new JsiSkSurface(CanvasKit, webglSurface); + + const skiaCanvas = surface.getCanvas(); + skiaCanvas.clear(Float32Array.of(0, 0, 0, 0)); + skiaCanvas.save(); + skiaCanvas.scale(this.pd, this.pd); + skiaCanvas.drawPicture(picture); + skiaCanvas.restore(); + surface.ref.flush(); + + return { surface, tempCanvas }; + } catch (error) { + if (surface) { + surface.ref.delete(); + } + this.cleanupWebGLContext(tempCanvas); + return null; + } + } - constructor(props: SkiaPictureViewNativeProps) { - super(props); - const { nativeID } = props; - if (!nativeID) { - throw new Error("SkiaPictureView requires a nativeID prop"); + private cleanupWebGLContext(tempCanvas: OffscreenCanvas): void { + const ctx = tempCanvas.getContext("webgl2"); + if (ctx) { + const loseContext = ctx.getExtension("WEBGL_lose_context"); + if (loseContext) { + loseContext.loseContext(); + } } - (global.SkiaViewApi as ISkiaViewApiWeb).registerView(nativeID, this); } - public setPicture(picture: SkPicture) { - this.picture = picture; - this.redraw(); + draw(picture: SkPicture): void { + const renderResult = this.renderPictureToSurface(picture); + if (!renderResult) { + return; + } + const { tempCanvas } = renderResult; + const ctx2d = this.canvas.getContext("2d"); + if (!ctx2d) { + throw new Error("Could not get 2D context"); + } + + // Set canvas dimensions to match pixel density + this.canvas.width = this.canvas.clientWidth * this.pd; + this.canvas.height = this.canvas.clientHeight * this.pd; + + // Draw the tempCanvas scaled down to the display size + ctx2d.drawImage( + tempCanvas, + 0, + 0, + tempCanvas.width, + tempCanvas.height, + 0, + 0, + this.canvas.clientWidth * this.pd, + this.canvas.clientHeight * this.pd + ); + + this.cleanupWebGLContext(tempCanvas); } - protected renderInCanvas(canvas: SkCanvas): void { - if (this.props.picture) { - canvas.drawPicture(this.props.picture); - } else if (this.picture) { - canvas.drawPicture(this.picture); + makeImageSnapshot(picture: SkPicture, rect?: SkRect): SkImage | null { + if (!this.cachedImage) { + const renderResult = this.renderPictureToSurface(picture); + if (!renderResult) { + return null; + } + + const { surface, tempCanvas } = renderResult; + + try { + this.cachedImage = surface.makeImageSnapshot(rect); + } catch (error) { + console.error("Error creating image snapshot:", error); + } finally { + surface.ref.delete(); + this.cleanupWebGLContext(tempCanvas); + } } + + return this.cachedImage; + } + + dispose(): void { + this.cachedImage?.dispose(); + this.cachedImage = null; } } + +const pd = Platform.PixelRatio; + +export interface SkiaPictureViewHandle { + setPicture(picture: SkPicture): void; + getSize(): { width: number; height: number }; + redraw(): void; + makeImageSnapshot(rect?: SkRect): SkImage | null; +} + +export const SkiaPictureView = forwardRef< + SkiaPictureViewHandle, + SkiaPictureViewNativeProps +>((props, ref) => { + const canvasRef = useRef(null); + const renderer = useRef(null); + const redrawRequestsRef = useRef(0); + const requestIdRef = useRef(0); + const pictureRef = useRef(null); + + const { picture, onLayout } = props; + + const redraw = useCallback(() => { + redrawRequestsRef.current++; + }, []); + + const getSize = useCallback(() => { + return { + width: canvasRef.current?.clientWidth || 0, + height: canvasRef.current?.clientHeight || 0, + }; + }, []); + + const setPicture = useCallback( + (newPicture: SkPicture) => { + pictureRef.current = newPicture; + redraw(); + }, + [redraw] + ); + + const makeImageSnapshot = useCallback((rect?: SkRect) => { + if (renderer.current && pictureRef.current) { + return renderer.current.makeImageSnapshot(pictureRef.current, rect); + } + return null; + }, []); + + const tick = useCallback(() => { + if (redrawRequestsRef.current > 0) { + redrawRequestsRef.current = 0; + if (renderer.current && pictureRef.current) { + renderer.current.draw(pictureRef.current); + } + } + requestIdRef.current = requestAnimationFrame(tick); + }, []); + + const onLayoutEvent = useCallback( + (evt: LayoutChangeEvent) => { + const canvas = canvasRef.current; + if (canvas) { + renderer.current = + props.__destroyWebGLContextAfterRender === true + ? new StaticWebGLRenderer(canvas, pd) + : new WebGLRenderer(canvas, pd); + if (pictureRef.current) { + renderer.current.draw(pictureRef.current); + } + } + if (onLayout) { + onLayout(evt); + } + }, + [onLayout, props.__destroyWebGLContextAfterRender] + ); + + useImperativeHandle( + ref, + () => ({ + setPicture, + getSize, + redraw, + makeImageSnapshot, + }), + [setPicture, getSize, redraw, makeImageSnapshot] + ); + + useEffect(() => { + const nativeID = props.nativeID ?? `${SkiaViewNativeId.current++}`; + (global.SkiaViewApi as ISkiaViewApiWeb).registerView(nativeID, { + setPicture, + getSize, + redraw, + makeImageSnapshot, + } as SkiaPictureViewHandle); + if (props.picture) { + setPicture(props.picture); + } + }, [setPicture, getSize, redraw, makeImageSnapshot, props]); + + useEffect(() => { + tick(); + return () => { + cancelAnimationFrame(requestIdRef.current); + if (renderer.current) { + renderer.current.dispose(); + renderer.current = null; + } + }; + }, [tick]); + + useEffect(() => { + if (renderer.current && pictureRef.current) { + renderer.current.draw(pictureRef.current); + } + }, [picture, redraw]); + + const { debug = false, ...viewProps } = props; + return ( + + + + ); +}); diff --git a/packages/skia/src/views/types.ts b/packages/skia/src/views/types.ts index 224024a3f6..dcc084be0d 100644 --- a/packages/skia/src/views/types.ts +++ b/packages/skia/src/views/types.ts @@ -31,6 +31,10 @@ export interface SkiaBaseViewProps extends ViewProps { onSize?: SharedValue; opaque?: 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; } export interface SkiaPictureViewNativeProps extends SkiaBaseViewProps {