Skip to content

Commit 894ca08

Browse files
authored
Add deviceTypes API for WebGPU support (#176)
* Add deviceTypes API for WebGPU support and update documentation - Introduced a new `deviceTypes` prop in the Application component to specify graphics device types, allowing for fallback options between WebGPU and WebGL2. - Updated the Application documentation to include details on the new `deviceTypes` prop and its usage. - Enhanced the Application component to initialize the graphics device based on the specified device types. * changeset * Refactor Application tests to include deviceTypes prop - Updated Application component tests to pass the new deviceTypes prop, ensuring compatibility with the latest graphics device initialization. - Modified Container, Entity, Screen, and Script component tests to include deviceTypes for consistent testing across components. - Cleaned up unused code and improved test structure for better readability and maintainability. * Fix import path for GraphicsDeviceOptions in create-graphics-device.ts * Update Application component to default deviceTypes to WebGL2 and optimize graphics device initialization - Set default value for deviceTypes to [DEVICETYPE_WEBGL2] in the ApplicationWithoutCanvas component. - Introduced memoization for deviceTypes to enhance performance during graphics device creation. - Updated graphics device initialization to use memoized deviceTypes for improved consistency. * Enhance Application component with deviceTypes validation and testing - Added a test case to warn when an invalid deviceTypes prop is provided, ensuring proper validation. - Refactored the Application component to improve graphics device initialization and memoization of deviceTypes. - Updated the create-graphics-device utility to allow explicit device type specification without injecting additional devices. - Adjusted validation error messages for deviceTypes to provide clearer feedback on incorrect usage. * Update documentation for deviceTypes prop in Application component - Clarified the description of the deviceTypes prop to specify its role in determining the graphics device order. - Added information about the "null" device type for testing purposes, enhancing the documentation's comprehensiveness.
1 parent 8f58b6f commit 894ca08

File tree

14 files changed

+286
-101
lines changed

14 files changed

+286
-101
lines changed

.changeset/rich-worlds-sleep.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@playcanvas/react": minor
3+
---
4+
5+
Added new deviceTypes api for WebGPU and fallback devices

packages/docs/content/docs/api/application.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ Enables physics for the application. Learn more about [Physics in Playcanvas](ht
5555
Type: `boolean`
5656
Default: `false`
5757

58+
### `deviceTypes`
59+
The `deviceTypes` prop determines the graphics device to use. It accepts an array of strings, each representing a different device which sets an order of fallback devices. The first device that gets successfully created, will be used.
60+
61+
For example, if you want to use WebGPU first, but fall back to WebGL2 when WebGPU is not supported, you can use `<Application deviceTypes={["webgpu", "webgl2"]} />`.
62+
63+
- `"webgpu"` - Use the WebGPU device type
64+
- `"webgl2"` - Use the WebGL2 device type
65+
- `"null"` - Use the Null device type. Useful for testing.
66+
5867
### `fillMode`
5968
This prop determines how the canvas fills its container. It accepts one of the following values from PlayCanvas:
6069

packages/lib/src/Application.test.tsx

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,65 @@
11
import { render, screen } from '@testing-library/react';
2-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3-
import { Application, ApplicationWithoutCanvas } from './Application.tsx';
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import '@testing-library/jest-dom';
44
import React from 'react';
5+
import { Application, ApplicationWithoutCanvas } from './Application.tsx';
56

67
describe('Application', () => {
78
beforeEach(() => {
89
vi.clearAllMocks();
9-
vi.stubEnv('NODE_ENV', 'development');
10-
});
11-
12-
afterEach(() => {
13-
vi.unstubAllEnvs();
1410
});
1511

1612
it('The Application component renders with default props', () => {
17-
render(<Application />);
13+
render(<Application deviceTypes={["null"]} />);
1814

1915
const canvas = screen.getByLabelText('Interactive 3D Scene');
2016
expect(canvas).toBeInTheDocument();
2117
});
2218

2319
it('The Application component applies className prop correctly', () => {
24-
render(<Application className="test-class" />);
20+
render(<Application className="test-class" deviceTypes={["null"]} />);
2521

2622
const canvas = screen.getByLabelText('Interactive 3D Scene');
2723
expect(canvas).toHaveClass('test-class');
2824
});
2925

3026
it('The Application component applies style prop correctly', () => {
31-
render(<Application style={{ width: '200px', height: '200px' }} />);
27+
render(<Application style={{ width: '200px', height: '200px' }} deviceTypes={["null"]} />);
3228

3329
const canvas = screen.getByLabelText('Interactive 3D Scene');
3430
expect(canvas).toHaveStyle({ width: '200px', height: '200px' });
3531
});
3632

33+
34+
it('warns when invalid deviceTypes prop is provided', async () => {
35+
// Spy on console.warn
36+
const warnSpy = vi
37+
.spyOn(console, 'warn')
38+
.mockImplementation(() => {});
39+
40+
try {
41+
// @ts-expect-error - we want to test the warning
42+
render(<Application deviceTypes={['invalid_device_type']} />);
43+
44+
// Wait for the warning appears
45+
await vi.waitFor(() =>
46+
expect(warnSpy).toHaveBeenCalledWith(
47+
"%c[PlayCanvas React]:",
48+
expect.any(String), // the CSS string
49+
expect.stringContaining(
50+
'deviceTypes must be an array containing one or more of:'
51+
)
52+
)
53+
);
54+
} finally {
55+
warnSpy.mockRestore();
56+
}
57+
});
58+
3759
it.skip('The Application component applies canvasRef prop correctly', () => {
3860
const canvasRef = { current: document.createElement('canvas') };
3961
canvasRef.current.setAttribute('aria-label', 'test-canvas');
40-
render(<ApplicationWithoutCanvas canvasRef={canvasRef} />);
62+
render(<ApplicationWithoutCanvas canvasRef={canvasRef} deviceTypes={["null"]} />);
4163

4264
const canvas = screen.getByLabelText('test-canvas');
4365
expect(canvas).toBe(canvasRef.current);

packages/lib/src/Application.tsx

Lines changed: 90 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import {
99
TouchDevice,
1010
Entity as PcEntity,
1111
RESOLUTION_FIXED,
12-
NullGraphicsDevice
12+
DEVICETYPE_WEBGL2,
13+
DEVICETYPE_WEBGPU,
14+
DEVICETYPE_NULL,
1315
} from 'playcanvas';
1416
import { AppContext, ParentContext } from './hooks/index.ts';
1517
import { PointerEventsContext } from './contexts/pointer-events-context.tsx';
@@ -18,6 +20,7 @@ import { PhysicsProvider } from './contexts/physics-context.tsx';
1820
import { validatePropsWithDefaults, createComponentDefinition, Schema, getNullApplication, applyProps } from './utils/validation.ts';
1921
import { PublicProps } from './utils/types-utils.ts';
2022
import { GraphicsDeviceOptions, defaultGraphicsDeviceOptions } from './types/graphics-device-options.ts';
23+
import { DeviceType, internalCreateGraphicsDevice } from './utils/create-graphics-device.ts';
2124

2225
/**
2326
* The **Application** component is the root node of the PlayCanvas React API. It creates a canvas element
@@ -47,7 +50,6 @@ export const Application: React.FC<ApplicationProps> = ({
4750
ref={canvasRef}
4851
/>
4952

50-
5153
<ApplicationWithoutCanvas canvasRef={canvasRef} {...props}>
5254
{children}
5355
</ApplicationWithoutCanvas>
@@ -98,13 +100,33 @@ export const ApplicationWithoutCanvas: FC<ApplicationWithoutCanvasProps> = (prop
98100
resolutionMode = RESOLUTION_AUTO,
99101
usePhysics = false,
100102
graphicsDeviceOptions,
103+
deviceTypes = [DEVICETYPE_WEBGL2],
101104
...otherProps
102105
} = validatedProps;
103106

104-
const localGraphicsDeviceOptions = {
105-
...defaultGraphicsDeviceOptions,
106-
...graphicsDeviceOptions
107-
};
107+
// Create a deviceTypes key to avoid re-rendering the application when the deviceTypes prop changes.
108+
const deviceTypeKey = deviceTypes.join('-');
109+
110+
/**
111+
* Also create a key for the graphicsDeviceOptions object to avoid
112+
* re-rendering the application when the graphicsDeviceOptions prop changes.
113+
* We need to sort the keys to create a stable key.
114+
*/
115+
const graphicsOptsKey = useMemo(() => {
116+
if (!graphicsDeviceOptions) return 'none';
117+
return Object.entries(graphicsDeviceOptions)
118+
.sort(([a], [b]) => a.localeCompare(b)) // order-insensitive
119+
.map(([k, v]) => `${k}:${String(v)}`)
120+
.join('|');
121+
}, [graphicsDeviceOptions]);
122+
123+
/**
124+
* Memoize the graphicsDeviceOptions object to avoid re-rendering the application when the graphicsDeviceOptions prop changes.
125+
*/
126+
const graphicsOpts = useMemo(
127+
() => ({ ...defaultGraphicsDeviceOptions, ...graphicsDeviceOptions }),
128+
[graphicsDeviceOptions] // ← only changes when *values* change
129+
);
108130

109131
const [app, setApp] = useState<PlayCanvasApplication | null>(null);
110132
const appRef = useRef<PlayCanvasApplication | null>(null);
@@ -113,28 +135,48 @@ export const ApplicationWithoutCanvas: FC<ApplicationWithoutCanvasProps> = (prop
113135
usePicker(appRef.current, canvasRef.current, pointerEvents);
114136

115137
useLayoutEffect(() => {
116-
const canvas = canvasRef.current;
117-
if (canvas && !appRef.current) {
118-
const localApp = new PlayCanvasApplication(canvas, {
138+
139+
// Tracks if the component is unmounted while awaiting for the graphics device to be created
140+
let cancelled = false;
141+
142+
(async () => {
143+
const canvas = canvasRef.current;
144+
if (!canvas || appRef.current) return;
145+
146+
// Create the graphics device
147+
const dev = await internalCreateGraphicsDevice(canvas, {
148+
deviceTypes,
149+
...graphicsOpts
150+
});
151+
152+
// Check if the component unmounted while we were awaiting, and destroy the device immediately and bail out
153+
if (cancelled) {
154+
dev.destroy?.();
155+
return;
156+
}
157+
158+
// Proceed with normal PlayCanvas init
159+
const pcApp = new PlayCanvasApplication(canvas, {
119160
mouse: new Mouse(canvas),
120161
touch: new TouchDevice(canvas),
121-
graphicsDevice: process.env.NODE_ENV === 'test' ? new NullGraphicsDevice(canvas) : undefined,
122-
graphicsDeviceOptions: localGraphicsDeviceOptions
162+
graphicsDevice: dev
123163
});
124-
125-
localApp.start();
126-
127-
appRef.current = localApp;
128-
setApp(localApp);
129-
}
130-
164+
pcApp.start();
165+
166+
appRef.current = pcApp;
167+
setApp(pcApp);
168+
})();
169+
170+
// Cleanup create a cancellation flag to avoid re-rendering the application when the component is unmounted.
131171
return () => {
132-
if (!appRef.current) return;
133-
appRef.current.destroy();
134-
appRef.current = null;
135-
setApp(null);
172+
cancelled = true;
173+
if (appRef.current) {
174+
appRef.current.destroy();
175+
appRef.current = null;
176+
setApp(null);
177+
}
136178
};
137-
}, [canvasRef, ...Object.values(localGraphicsDeviceOptions)]);
179+
}, [graphicsOptsKey, deviceTypeKey]); // ← stable deps
138180

139181
// Separate useEffect for these props to avoid re-rendering
140182
useEffect(() => {
@@ -177,7 +219,6 @@ type CanvasProps = {
177219
*/
178220
style?: Record<string, unknown>
179221
}
180-
181222
interface ApplicationProps extends Partial<PublicProps<PlayCanvasApplication>>, CanvasProps {
182223

183224
/**
@@ -197,6 +238,20 @@ interface ApplicationProps extends Partial<PublicProps<PlayCanvasApplication>>,
197238
* @default false
198239
*/
199240
usePhysics?: boolean,
241+
242+
/**
243+
* The device types to use for the graphics device. This allows you to set an order of preference for the graphics device.
244+
* The first device type in the array that is supported by the browser will be used.
245+
*
246+
* @example
247+
* <Application deviceTypes={[DEVICETYPE_WEBGPU, DEVICETYPE_WEBGL2]} />
248+
*
249+
* This will use the WebGPU device if it is supported, otherwise it will use the WebGL2 device.
250+
*
251+
* @default [DEVICETYPE_WEBGL2]
252+
*/
253+
deviceTypes?: DeviceType[],
254+
200255
/** Graphics Settings */
201256
graphicsDeviceOptions?: GraphicsDeviceOptions,
202257
/** The children of the application */
@@ -217,6 +272,17 @@ const componentDefinition = createComponentDefinition(
217272

218273
componentDefinition.schema = {
219274
...componentDefinition.schema,
275+
deviceTypes: {
276+
validate: (value: unknown) => Array.isArray(value) && value.every((v: unknown) => typeof v === 'string' && [DEVICETYPE_WEBGPU, DEVICETYPE_WEBGL2, DEVICETYPE_NULL].includes(v as typeof DEVICETYPE_WEBGPU | typeof DEVICETYPE_WEBGL2 | typeof DEVICETYPE_NULL)),
277+
errorMsg: (value: unknown) => {
278+
return `deviceTypes must be an array containing one or more of: '${DEVICETYPE_WEBGPU}', '${DEVICETYPE_WEBGL2}', '${DEVICETYPE_NULL}'. Received: ['${value}']`
279+
},
280+
/**
281+
* In test environments, we default to a Null device, because we don't cant use WebGL2/WebGPU.
282+
* This is just for testing purposes so we can test the fallback logic, without initializing WebGL2/WebGPU.
283+
*/
284+
default: process.env.NODE_ENV === 'test' ? [DEVICETYPE_NULL] : [DEVICETYPE_WEBGL2]
285+
},
220286
className: {
221287
validate: (value: unknown) => typeof value === 'string',
222288
errorMsg: (value: unknown) => `className must be a string. Received: ${value}`,

packages/lib/src/Container.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { Container } from './Container.tsx';
44
import { Asset, Entity } from 'playcanvas';
55
import { ReactNode } from 'react';
66
import { Application } from './Application.tsx';
7+
import React from 'react';
78

89
const renderWithProviders = (ui: ReactNode) => {
910
return render(
10-
<Application>
11+
<Application deviceTypes={["null"]}>
1112
{ui}
1213
</Application>
1314
);

packages/lib/src/Entity.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import { ReactNode, useContext } from 'react';
66
import { Entity as PcEntity } from 'playcanvas';
77
import { TEST_ENTITY_PROPS } from '../test/constants.ts';
88
import { Application } from './Application.tsx';
9+
import React from 'react';
910

1011
const renderWithProviders = (ui: ReactNode) => {
1112
return render(
12-
<Application>
13+
<Application deviceTypes={["null"]}>
1314
{ui}
1415
</Application>
1516
);

packages/lib/src/components/Screen.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Application } from '../Application.tsx';
77

88
const renderWithProviders = (ui: React.ReactNode) => {
99
return render(
10-
<Application>
10+
<Application deviceTypes={["null"]}>
1111
<Entity>
1212
{ui}
1313
</Entity>

0 commit comments

Comments
 (0)