UNPKG

@playcanvas/react

Version:

A React renderer for PlayCanvas – build interactive 3D applications using React's declarative paradigm.

185 lines 9.07 kB
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