diff --git a/examples/public/tiger.riv b/examples/public/tiger.riv new file mode 100644 index 00000000..5751a86a Binary files /dev/null and b/examples/public/tiger.riv differ diff --git a/examples/src/components/Inkbleed.stories.ts b/examples/src/components/Inkbleed.stories.ts new file mode 100644 index 00000000..152a0e37 --- /dev/null +++ b/examples/src/components/Inkbleed.stories.ts @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Inkbleed from './Inkbleed'; + +const meta = { + title: 'Inkbleed', + component: Inkbleed, + parameters: { + layout: 'fullscreen', + }, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/examples/src/components/Inkbleed.tsx b/examples/src/components/Inkbleed.tsx new file mode 100644 index 00000000..94150ff5 --- /dev/null +++ b/examples/src/components/Inkbleed.tsx @@ -0,0 +1,142 @@ +import React, { useState } from 'react'; +import { useRive, Fit, Alignment, Layout } from '@rive-app/react-webgl2'; +import type { UseRiveParameters, UseRiveOptions } from '@rive-app/react-webgl2'; + +/** + * Inkbleed — experimental 1x analytic antialiasing (webgl2 only) + */ + +const CARD_SIZE = 380; +const LAYOUT = new Layout({ fit: Fit.Contain, alignment: Alignment.Center }); + +interface CardProps { + title: string; + expectInkbleed: boolean; + params: UseRiveParameters; + opts?: Partial; +} + +const Card = ({ title, expectInkbleed, params, opts }: CardProps) => { + const { rive, RiveComponent } = useRive(params, opts); + // What this card currently asks for; the Toggle button flips it. + const [expected, setExpected] = useState(expectInkbleed); + + let label = 'loading…'; + let color: string = COLORS.textMuted; + + if (rive) { + const on = rive.inkbleed; + const ok = on === expected; + label = `inkbleed ${on ? 'on' : 'off'} ${ + ok ? '✓' : `✗ expected ${expected ? 'on' : 'off'}` + }`; + color = ok ? COLORS.pass : COLORS.fail; + } + + return ( +
+

{title}

+
+ +
+
+ {label} + {params?.enableInkbleed && ( + + )} +
+
+ ); +}; + +const params = (extra?: Partial): UseRiveParameters => ({ + src: 'tiger.riv', + autoplay: true, + layout: LAYOUT, + ...extra, +}); + +const Inkbleed = () => ( +
+
+

Inkbleed

+

+ Inkbleed replaces MSAA with 1x analytic antialiasing. Everything bleeds + out half a pixel, which is most visible on thin strokes like the + whiskers. Fills that don't use the clockwise fill rule are converted to + clockwise on a best-effort basis. +

+ +
+
+ +
+
+
+
+); + +const COLORS = { + bg: '#1a1a1a', + panel: '#252525', + border: '#3a3a3a', + text: '#e0e0e0', + textMuted: '#999', + pass: '#4caf50', + fail: '#ff4d40', +}; + +const styles: Record = { + page: { + background: COLORS.bg, + color: COLORS.text, + fontFamily: 'system-ui, sans-serif', + minHeight: '100vh', + boxSizing: 'border-box', + padding: 24, + }, + inner: { width: 'min(92%, 1240px)', margin: '0 auto' }, + h1: { margin: '0 0 6px', fontSize: 22, fontWeight: 600 }, + intro: { + color: COLORS.textMuted, + fontSize: 14, + lineHeight: 1.6, + margin: '0 0 20px', + }, + section: { marginBottom: 28 }, + grid: { display: 'flex', flexWrap: 'wrap', gap: 16 }, + card: { + background: COLORS.panel, + border: `1px solid ${COLORS.border}`, + borderRadius: 8, + padding: 12, + }, + cardTitle: { fontSize: 13, fontWeight: 600, margin: '0 0 8px' }, + footer: { + marginTop: 8, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + status: { fontSize: 13, fontWeight: 600 }, + button: { fontSize: 12, padding: '2px 10px' }, +}; + +export default Inkbleed; diff --git a/package-lock.json b/package-lock.json index 3c71b560..1b9c9bd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "4.36.0", "license": "MIT", "dependencies": { - "@rive-app/canvas": "2.44.0", - "@rive-app/canvas-lite": "2.44.0", - "@rive-app/webgl2": "2.44.0" + "@rive-app/canvas": "2.45.0", + "@rive-app/canvas-lite": "2.45.0", + "@rive-app/webgl2": "2.45.0" }, "devDependencies": { "@babel/core": "^7.18.0", @@ -1357,21 +1357,21 @@ } }, "node_modules/@rive-app/canvas": { - "version": "2.44.0", - "resolved": "https://registry.npmjs.org/@rive-app/canvas/-/canvas-2.44.0.tgz", - "integrity": "sha512-ak0GGwCbuWubi6rP9qB5YPy5CxYuTY3AiuTV4i1Vi7GXhuK2XB+M+4WJMuGcYGfGLzojaVMKaxtunD5OjjGzOQ==", + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/@rive-app/canvas/-/canvas-2.45.0.tgz", + "integrity": "sha512-GnfJI75Z990maZC7NtXUzuZG2C66j/qIQvFmFAUDS+fPy8k4genTPTrCSgpVpGQDuedFhhmVcPtRZks5lAPf3w==", "license": "MIT" }, "node_modules/@rive-app/canvas-lite": { - "version": "2.44.0", - "resolved": "https://registry.npmjs.org/@rive-app/canvas-lite/-/canvas-lite-2.44.0.tgz", - "integrity": "sha512-ywsdoYLsC+8RIZ+aMCvHQQgGRxnBHojReaOxDqI5ZL2Y/nRKdySAbLFnSrJIAbGygdkuQdJVUNF3AF01mxGhEA==", + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/@rive-app/canvas-lite/-/canvas-lite-2.45.0.tgz", + "integrity": "sha512-zgJ2kGpEV32cMONDoOo3rUZmgxG8+Yo8k+4ro3lhtppsJFmYVSyyl8SIg21DWTY43cvS04oFtyboX3SX/+MnKg==", "license": "MIT" }, "node_modules/@rive-app/webgl2": { - "version": "2.44.0", - "resolved": "https://registry.npmjs.org/@rive-app/webgl2/-/webgl2-2.44.0.tgz", - "integrity": "sha512-BIlutEsgyPRwMLEat2dDVBannAqIH75TeCCyrYA4KjUMMM+2bFJ2qfwqJACUAsUE+EYdcQaONycfTFRQ4JGLqg==", + "version": "2.45.0", + "resolved": "https://registry.npmjs.org/@rive-app/webgl2/-/webgl2-2.45.0.tgz", + "integrity": "sha512-uoPFjIeKMISabc9lPE5rXMEWCsnh3IZ4NErwmD0ag8wvM+8zDklcgr7P5C6u7ULnfzH5MkCw/hi00Kg1HhJe9g==", "license": "MIT" }, "node_modules/@rollup/plugin-commonjs": { diff --git a/package.json b/package.json index fb4d3aed..3ce8d545 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ }, "homepage": "https://github.com/rive-app/rive-react#readme", "dependencies": { - "@rive-app/canvas": "2.44.0", - "@rive-app/canvas-lite": "2.44.0", - "@rive-app/webgl2": "2.44.0" + "@rive-app/canvas": "2.45.0", + "@rive-app/canvas-lite": "2.45.0", + "@rive-app/webgl2": "2.45.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0" diff --git a/src/components/Rive.tsx b/src/components/Rive.tsx index 6848ab9d..3cf0ed08 100644 --- a/src/components/Rive.tsx +++ b/src/components/Rive.tsx @@ -46,9 +46,12 @@ export interface RiveProps { * - `true` normally, so multiple Rive graphics on a page share one context. * - `false` when `enableGPUCanvas` is on, because GPU Canvas needs a context * of its own for each ``. + * - `false` when `enableInkbleed` is on, because inkbleed needs a context of + * its own for each ``. * * Setting it explicitly always wins. Setting it to `true` alongside - * `enableGPUCanvas` means GPU Canvas content will not draw. + * `enableGPUCanvas` means GPU Canvas content will not draw, and alongside + * `enableInkbleed` means inkbleed stays off. */ useOffscreenRenderer?: boolean; /** @@ -66,6 +69,18 @@ export interface RiveProps { * have (the limit varies by browser), so enabling it on too many at once can throw. */ enableGPUCanvas?: boolean; + /** + * @experimental (WebGL2 only) Renders with inkbleed antialiasing instead + * of MSAA. Fills that don't use the clockwise fill rule are converted to clockwise on a + * best-effort basis. False by default. + * + * IMPORTANT: Turning this on makes `useOffscreenRenderer` default to `false` for this + * component if it was not specified, since inkbleed needs a WebGL context of its own. + * + * Each of these takes its own WebGL context, and browsers cap how many a page may + * have (the limit varies by browser), so enabling it on too many at once can throw. + */ + enableInkbleed?: boolean; /** * Specify whether to disable Rive listeners on the canvas, thus preventing any event listeners to be attached to the canvas element */ @@ -104,6 +119,7 @@ const Rive = ({ // undefined, it falls through to defaultOptions — which will still be `true`. useOffscreenRenderer, enableGPUCanvas, + enableInkbleed, shouldDisableRiveListeners = false, shouldResizeCanvasToContainer = true, automaticallyHandleEvents = false, @@ -121,6 +137,7 @@ const Rive = ({ shouldDisableRiveListeners, automaticallyHandleEvents, enableGPUCanvas, + enableInkbleed, }; const options = { diff --git a/src/hooks/useRive.tsx b/src/hooks/useRive.tsx index 78cdd295..db568636 100644 --- a/src/hooks/useRive.tsx +++ b/src/hooks/useRive.tsx @@ -87,6 +87,9 @@ export default function useRive( * is React runtime's (see `defaultOptions`). So opting into GPU Canvas only has * to unwind React's default — an explicit value from the caller still wins, and * still loses GPU Canvas, which the effect below warns about. + * + * Inkbleed is the same: it needs the canvas's own context, created with + * depth/stencil and without MSAA, which the shared offscreen context isn't. */ const explicitOffscreenRenderer = riveParams?.useOffscreenRenderer ?? opts.useOffscreenRenderer; @@ -99,9 +102,13 @@ export default function useRive( Boolean(riveParams?.enableGPUCanvas) || Boolean(riveParams?.riveFile?.deferredRequested); + const wantsInkbleed = Boolean(riveParams?.enableInkbleed); + const useOffscreenRenderer = explicitOffscreenRenderer ?? - (wantsGPUCanvas ? false : defaultOptions.useOffscreenRenderer); + (wantsGPUCanvas || wantsInkbleed + ? false + : defaultOptions.useOffscreenRenderer); // The runtime warns about this too, but from where it sits it cannot say that // a hook option is what turned the offscreen renderer on. @@ -117,6 +124,18 @@ export default function useRive( } }, [wantsGPUCanvas, useOffscreenRenderer]); + useEffect(() => { + if (wantsInkbleed && useOffscreenRenderer) { + console.warn( + '[Rive] `enableInkbleed` and `useOffscreenRenderer` cannot both be on. ' + + 'Inkbleed needs a WebGL context of its own, while the offscreen renderer ' + + 'shares one context across every on the page. This instance ' + + 'renders without inkbleed — drop the explicit `useOffscreenRenderer: true` ' + + 'to use it.' + ); + } + }, [wantsInkbleed, useOffscreenRenderer]); + const devicePixelRatio = useDevicePixelRatio(); /** diff --git a/src/types.ts b/src/types.ts index 6628741a..0a18f097 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,9 +28,12 @@ export type UseRiveOptions = { * - `true` normally, so multiple Rive graphics on a page share one context. * - `false` when `enableGPUCanvas` is on, because GPU Canvas needs a context * of its own for each ``. + * - `false` when `enableInkbleed` is on, because inkbleed needs a context of + * its own for each ``. * * Setting it explicitly always wins. Setting it to `true` alongside - * `enableGPUCanvas` means GPU Canvas content will not draw. + * `enableGPUCanvas` means GPU Canvas content will not draw, and alongside + * `enableInkbleed` means inkbleed stays off. */ useOffscreenRenderer: boolean; shouldResizeCanvasToContainer: boolean; diff --git a/test/useRive.test.tsx b/test/useRive.test.tsx index 9c140adb..d66f1540 100644 --- a/test/useRive.test.tsx +++ b/test/useRive.test.tsx @@ -4,6 +4,7 @@ import { renderHook, act, render, waitFor } from '@testing-library/react'; import useRive from '../src/hooks/useRive'; import * as rive from '@rive-app/canvas'; +import { UseRiveOptions, UseRiveParameters } from '../src/types'; jest.mock('@rive-app/canvas', () => ({ Rive: jest.fn().mockImplementation(() => ({ @@ -656,3 +657,159 @@ describe('useRive', () => { expect(canvasSpy.height).toBe(200); }); }); + +/** + * GPU Canvas and inkbleed both need the canvas's own WebGL context, while the + * offscreen renderer shares one GL context across every canvas on the page. + */ +describe('useRive — offscreen renderer resolution', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + mocked(rive.Rive).mockClear(); + // @ts-ignore — a bare stub is enough; nothing here drives playback. + mocked(rive.Rive).mockImplementation(() => ({ + on: jest.fn(), + stop: jest.fn(), + cleanup: jest.fn(), + })); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + /** Mounts the hook, attaches a canvas, and returns the Rive constructor args. */ + async function constructorParams( + params: UseRiveParameters, + opts?: Partial + ) { + const canvasSpy = document.createElement('canvas'); + const { result } = renderHook(() => useRive(params, opts)); + + await act(async () => { + result.current.setCanvasRef(canvasSpy); + }); + await waitFor(() => { + expect(mocked(rive.Rive)).toHaveBeenCalled(); + }); + + return mocked(rive.Rive).mock.calls[0][0]; + } + + it('keeps the offscreen renderer on by default', async () => { + const args = await constructorParams({ src: 'file-src' }); + expect(args.useOffscreenRenderer).toBe(true); + }); + + it('turns the offscreen renderer off when enableGPUCanvas is set', async () => { + const args = await constructorParams({ + src: 'file-src', + enableGPUCanvas: true, + }); + expect(args.useOffscreenRenderer).toBe(false); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('turns it off for a riveFile imported with GPU Canvas, even when the instance did not ask', async () => { + // The file's mode is fixed at import and wins over the instance's flag, so + // the renderer has to follow the file. + const riveFile = { deferredRequested: true } as unknown as rive.RiveFile; + + const args = await constructorParams({ riveFile }); + expect(args.useOffscreenRenderer).toBe(false); + }); + + it('leaves it on for a riveFile imported without GPU Canvas', async () => { + const riveFile = { deferredRequested: false } as unknown as rive.RiveFile; + + const args = await constructorParams({ riveFile }); + expect(args.useOffscreenRenderer).toBe(true); + }); + + it('lets an explicit option win over GPU Canvas, and warns', async () => { + const args = await constructorParams( + { src: 'file-src', enableGPUCanvas: true }, + { useOffscreenRenderer: true } + ); + + expect(args.useOffscreenRenderer).toBe(true); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('lets an explicit riveParams value win over GPU Canvas', async () => { + const args = await constructorParams({ + src: 'file-src', + enableGPUCanvas: true, + useOffscreenRenderer: true, + }); + + expect(args.useOffscreenRenderer).toBe(true); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('honors an explicit false with no GPU Canvas in play', async () => { + const args = await constructorParams( + { src: 'file-src' }, + { useOffscreenRenderer: false } + ); + + expect(args.useOffscreenRenderer).toBe(false); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('forwards enableGPUCanvas through to the runtime', async () => { + const args = await constructorParams({ + src: 'file-src', + enableGPUCanvas: true, + }); + expect(args).toMatchObject({ enableGPUCanvas: true }); + }); + + it('turns the offscreen renderer off when enableInkbleed is set', async () => { + const args = await constructorParams({ + src: 'file-src', + enableInkbleed: true, + }); + expect(args.useOffscreenRenderer).toBe(false); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('keeps the offscreen renderer on when enableInkbleed is false', async () => { + const args = await constructorParams({ + src: 'file-src', + enableInkbleed: false, + }); + expect(args.useOffscreenRenderer).toBe(true); + }); + + it('lets an explicit option win over inkbleed, and warns', async () => { + const args = await constructorParams( + { src: 'file-src', enableInkbleed: true }, + { useOffscreenRenderer: true } + ); + + expect(args.useOffscreenRenderer).toBe(true); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('lets an explicit riveParams value win over inkbleed', async () => { + const args = await constructorParams({ + src: 'file-src', + enableInkbleed: true, + useOffscreenRenderer: true, + }); + + expect(args.useOffscreenRenderer).toBe(true); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('forwards enableInkbleed through to the runtime', async () => { + const args = await constructorParams({ + src: 'file-src', + enableInkbleed: true, + }); + expect(args).toMatchObject({ enableInkbleed: true }); + }); +}); diff --git a/test/useRiveGpuCanvas.test.tsx b/test/useRiveGpuCanvas.test.tsx deleted file mode 100644 index f5973245..00000000 --- a/test/useRiveGpuCanvas.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { mocked } from 'jest-mock'; -import { renderHook, act, waitFor } from '@testing-library/react'; - -import useRive from '../src/hooks/useRive'; -import * as rive from '@rive-app/canvas'; -import { UseRiveOptions, UseRiveParameters } from '../src/types'; - -/** - * GPU Canvas and the offscreen renderer are mutually exclusive: a GPU Canvas - * session records for a single canvas, while the offscreen renderer shares one - * GL context across every canvas on the page (so it has no `attachSession`). - * - * The JS runtime defaults `useOffscreenRenderer` to false; rive-react defaults it - * to true. These tests pin the resolution rule that reconciles the two, since - * getting it wrong means GPU Canvas silently never draws for React users. - */ -describe('useRive — GPU Canvas / offscreen renderer resolution', () => { - let warnSpy: jest.SpyInstance; - - beforeEach(() => { - mocked(rive.Rive).mockClear(); - // @ts-ignore — a bare stub is enough; nothing here drives playback. - mocked(rive.Rive).mockImplementation(() => ({ - on: jest.fn(), - stop: jest.fn(), - cleanup: jest.fn(), - })); - warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - /** Mounts the hook, attaches a canvas, and returns the Rive constructor args. */ - async function constructorParams( - params: UseRiveParameters, - opts?: Partial - ) { - const canvasSpy = document.createElement('canvas'); - const { result } = renderHook(() => useRive(params, opts)); - - await act(async () => { - result.current.setCanvasRef(canvasSpy); - }); - await waitFor(() => { - expect(mocked(rive.Rive)).toHaveBeenCalled(); - }); - - return mocked(rive.Rive).mock.calls[0][0]; - } - - it('keeps the offscreen renderer on by default', async () => { - const args = await constructorParams({ src: 'file-src' }); - expect(args.useOffscreenRenderer).toBe(true); - }); - - it('turns the offscreen renderer off when enableGPUCanvas is set', async () => { - const args = await constructorParams({ - src: 'file-src', - enableGPUCanvas: true, - }); - expect(args.useOffscreenRenderer).toBe(false); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - it('turns it off for a riveFile imported with GPU Canvas, even when the instance did not ask', async () => { - // The file's mode is fixed at import and wins over the instance's flag, so - // the renderer has to follow the file. - const riveFile = { deferredRequested: true } as unknown as rive.RiveFile; - - const args = await constructorParams({ riveFile }); - expect(args.useOffscreenRenderer).toBe(false); - }); - - it('leaves it on for a riveFile imported without GPU Canvas', async () => { - const riveFile = { deferredRequested: false } as unknown as rive.RiveFile; - - const args = await constructorParams({ riveFile }); - expect(args.useOffscreenRenderer).toBe(true); - }); - - it('lets an explicit option win over GPU Canvas, and warns', async () => { - const args = await constructorParams( - { src: 'file-src', enableGPUCanvas: true }, - { useOffscreenRenderer: true } - ); - - expect(args.useOffscreenRenderer).toBe(true); - expect(warnSpy).toHaveBeenCalled(); - }); - - it('lets an explicit riveParams value win over GPU Canvas', async () => { - const args = await constructorParams({ - src: 'file-src', - enableGPUCanvas: true, - useOffscreenRenderer: true, - }); - - expect(args.useOffscreenRenderer).toBe(true); - expect(warnSpy).toHaveBeenCalled(); - }); - - it('honors an explicit false with no GPU Canvas in play', async () => { - const args = await constructorParams( - { src: 'file-src' }, - { useOffscreenRenderer: false } - ); - - expect(args.useOffscreenRenderer).toBe(false); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - it('forwards enableGPUCanvas through to the runtime', async () => { - const args = await constructorParams({ - src: 'file-src', - enableGPUCanvas: true, - }); - expect(args).toMatchObject({ enableGPUCanvas: true }); - }); -});