@playcanvas/react
Version:
A React renderer for PlayCanvas – build interactive 3D applications using React's declarative paradigm.
185 lines • 9.07 kB
JavaScript
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { FILLMODE_NONE, FILLMODE_FILL_WINDOW, FILLMODE_KEEP_ASPECT, RESOLUTION_AUTO, Application as PlayCanvasApplication, Mouse, TouchDevice, RESOLUTION_FIXED, DEVICETYPE_WEBGL2, DEVICETYPE_WEBGPU, DEVICETYPE_NULL, } from 'playcanvas';
import { AppContext, ParentContext } from "./hooks/index.js";
import { PointerEventsContext } from "./contexts/pointer-events-context.js";
import { usePicker } from "./utils/picker.js";
import { PhysicsProvider } from "./contexts/physics-context.js";
import { validatePropsWithDefaults, createComponentDefinition, getNullApplication, applyProps } from "./utils/validation.js";
import { defaultGraphicsDeviceOptions } from "./types/graphics-device-options.js";
import { internalCreateGraphicsDevice } from "./utils/create-graphics-device.js";
import { env } from "./utils/env.js";
/**
* The **Application** component is the root node of the PlayCanvas React API. It creates a canvas element
* and initializes a PlayCanvas application instance.
*
* @param {ApplicationProps} props - The props to pass to the application component.
* @returns {React.ReactNode} - The application component.
*
* @example
* <Application>
* <Entity />
* </Application>
*/
export const Application = ({ children, className = 'pc-app', style = { width: '100%', height: '100%' }, ...props }) => {
const canvasRef = useRef(null);
return (_jsxs(_Fragment, { children: [_jsx(Canvas, { className: className, style: style, ref: canvasRef }), _jsx(ApplicationWithoutCanvas, { canvasRef: canvasRef, ...props, children: children })] }));
};
const Canvas = React.memo(React.forwardRef((props, ref) => {
const { className, style } = props;
return _jsx("canvas", { ref: ref, className: className, style: style, "aria-label": "Interactive 3D Scene" });
}));
/**
* An alternative Application component that does not create a canvas element.
* This allows you to create a canvas independently from PlayCanvas and pass it in as a ref.
*
* @param {ApplicationWithoutCanvasProps} props - The props to pass to the application component.
* @returns {React.ReactNode} The application component.
*
* @example
* const canvasRef = useRef<HTMLCanvasElement>(null);
*
* return (
* <>
* <canvas ref={canvasRef} />
* <ApplicationWithoutCanvas canvasRef={canvasRef}>
* <Entity />
* </ApplicationWithoutCanvas>
* </>
* );
*/
export const ApplicationWithoutCanvas = (props) => {
const { children, ...propsToValidate } = props;
const validatedProps = validatePropsWithDefaults(propsToValidate, componentDefinition);
const { canvasRef, fillMode = FILLMODE_NONE, resolutionMode = RESOLUTION_AUTO, usePhysics = false, graphicsDeviceOptions, deviceTypes = [DEVICETYPE_WEBGL2], ...otherProps } = validatedProps;
// Create a deviceTypes key to avoid re-rendering the application when the deviceTypes prop changes.
const deviceTypeKey = deviceTypes.join('-');
/**
* Also create a key for the graphicsDeviceOptions object to avoid
* re-rendering the application when the graphicsDeviceOptions prop changes.
* We need to sort the keys to create a stable key.
*/
const graphicsOptsKey = useMemo(() => {
if (!graphicsDeviceOptions)
return 'none';
return Object.entries(graphicsDeviceOptions)
.sort(([a], [b]) => a.localeCompare(b)) // order-insensitive
.map(([k, v]) => `${k}:${String(v)}`)
.join('|');
}, [graphicsDeviceOptions]);
/**
* Memoize the graphicsDeviceOptions object to avoid re-rendering the application when the graphicsDeviceOptions prop changes.
*/
const graphicsOpts = useMemo(() => ({ ...defaultGraphicsDeviceOptions, ...graphicsDeviceOptions }), [graphicsDeviceOptions] // ← only changes when *values* change
);
const [app, setApp] = useState(null);
const appRef = useRef(null);
const pointerEvents = useMemo(() => new Set(), []);
usePicker(appRef.current, canvasRef.current, pointerEvents);
useLayoutEffect(() => {
// Tracks if the component is unmounted while awaiting for the graphics device to be created
let cancelled = false;
(async () => {
const canvas = canvasRef.current;
if (!canvas || appRef.current)
return;
// Create the graphics device
const dev = await internalCreateGraphicsDevice(canvas, {
deviceTypes,
...graphicsOpts
});
// Check if the component unmounted while we were awaiting, and destroy the device immediately and bail out
if (cancelled) {
dev.destroy?.();
return;
}
// Proceed with normal PlayCanvas init
const pcApp = new PlayCanvasApplication(canvas, {
mouse: new Mouse(canvas),
touch: new TouchDevice(canvas),
graphicsDevice: dev
});
pcApp.start();
appRef.current = pcApp;
setApp(pcApp);
})();
// Cleanup create a cancellation flag to avoid re-rendering the application when the component is unmounted.
return () => {
cancelled = true;
if (appRef.current) {
appRef.current.destroy();
appRef.current = null;
setApp(null);
}
};
}, [graphicsOptsKey, deviceTypeKey]); // ← stable deps
// Separate useEffect for these props to avoid re-rendering
useEffect(() => {
if (!app)
return;
app.setCanvasFillMode(fillMode);
app.setCanvasResolution(resolutionMode);
}, [app, fillMode, resolutionMode]);
// These app properties can be updated without re-rendering
useLayoutEffect(() => {
if (!app)
return;
applyProps(app, componentDefinition.schema, otherProps);
});
if (!app)
return null;
return (_jsx(PhysicsProvider, { enabled: usePhysics, app: app, children: _jsx(AppContext.Provider, { value: appRef.current, children: _jsx(PointerEventsContext.Provider, { value: pointerEvents, children: _jsx(ParentContext.Provider, { value: appRef.current?.root, children: children }) }) }) }));
};
const componentDefinition = createComponentDefinition("Application", () => getNullApplication(), (app) => app.destroy());
componentDefinition.schema = {
...componentDefinition.schema,
deviceTypes: {
validate: (value) => Array.isArray(value) && value.every((v) => typeof v === 'string' && [DEVICETYPE_WEBGPU, DEVICETYPE_WEBGL2, DEVICETYPE_NULL].includes(v)),
errorMsg: (value) => {
return `deviceTypes must be an array containing one or more of: '${DEVICETYPE_WEBGPU}', '${DEVICETYPE_WEBGL2}', '${DEVICETYPE_NULL}'. Received: ['${value}']`;
},
/**
* In test environments, we default to a Null device, because we don't cant use WebGL2/WebGPU.
* This is just for testing purposes so we can test the fallback logic, without initializing WebGL2/WebGPU.
*/
default: env === 'test' ? [DEVICETYPE_NULL] : [DEVICETYPE_WEBGL2]
},
className: {
validate: (value) => typeof value === 'string',
errorMsg: (value) => `className must be a string. Received: ${value}`,
default: 'pc-app'
},
style: {
validate: (value) => typeof value === 'object' && value !== null,
errorMsg: (value) => `style must be an object. Received: ${value}`,
default: { width: '100%', height: '100%' }
},
canvasRef: {
validate: (value) => value !== null &&
typeof value === 'object' &&
'current' in value,
errorMsg: (value) => `canvasRef must be a React ref object. Received: ${value}`,
default: null
},
usePhysics: {
validate: (value) => typeof value === 'boolean',
errorMsg: (value) => `usePhysics must be a boolean. Received: ${value}`,
default: false
},
fillMode: {
validate: (value) => typeof value === 'string' && [FILLMODE_NONE, FILLMODE_FILL_WINDOW, FILLMODE_KEEP_ASPECT].includes(value),
errorMsg: () => `"fillMode" must be one of: ${FILLMODE_NONE}, ${FILLMODE_FILL_WINDOW}, ${FILLMODE_KEEP_ASPECT}`,
default: FILLMODE_NONE
},
resolutionMode: {
validate: (value) => typeof value === 'string' && [RESOLUTION_AUTO, RESOLUTION_FIXED].includes(value),
errorMsg: () => `"resolutionMode" must be one of: ${RESOLUTION_AUTO}, ${RESOLUTION_FIXED}`,
default: RESOLUTION_AUTO
},
graphicsDeviceOptions: {
validate: (value) => value === undefined || (typeof value === 'object' && value !== null),
errorMsg: (value) => `graphicsDeviceOptions must be an object. Received: ${value}`,
default: undefined
}
};
//# sourceMappingURL=Application.js.map