Skip to content

Commit 443d020

Browse files
marklundinCopilot
andauthored
Lazy Physics (#86)
* Add physics context and integrate into Application and RigidBody components - Introduced PhysicsProvider to manage physics state and loading. - Updated ApplicationWithoutCanvas to wrap children with PhysicsProvider. - Enhanced RigidBody component to check for physics context and handle errors. - Removed direct dependency on global Ammo instance in favor of context management. * Update package dependencies and improve error handling in RigidBody component - Added sync-ammo as a peer dependency in package.json and package-lock.json. - Updated error message in RigidBody component to guide users on installing sync-ammo if not already done. - Cleaned up package.json by removing unnecessary dependencies section. * Enhance physics components and error handling - Removed outdated comment from physics documentation. - Updated PhysicsProvider to accept an app prop and manage physics instance count for cleanup. - Improved error handling in Collision and RigidBody components to use warnOnce utility for better user guidance. - Modified useComponent hook to handle null component types gracefully. - Introduced warnOnce utility for logging warnings without repetition. * Add callout for enabling physics in Collision and RigidBody documentation - Introduced an info callout in both Collision and RigidBody documentation to guide users on installing `sync-ammo` and enabling physics in the Application. - Removed outdated note in RigidBody documentation for clarity. * Update packages/lib/src/contexts/physics-context.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update PhysicsProvider to include app dependency in effect hook - Modified the useEffect hook in PhysicsProvider to include the app dependency for improved state management. * Add info callout for enabling physics in RigidBody documentation - Included a callout in the RigidBody documentation to inform users about installing `sync-ammo` and enabling physics in the Application. - This enhances user guidance and improves clarity in the documentation. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent fc3d96b commit 443d020

File tree

11 files changed

+204
-47
lines changed

11 files changed

+204
-47
lines changed

package-lock.json

Lines changed: 5 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/docs/src/app/docs/api/components/collision/page.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Collision
22

3+
import { Callout } from 'nextra/components'
4+
5+
<Callout type="info" emoji="️💡">
6+
Enable physics by installing `npm i sync-ammo` and enabling physics on the Application using`<Application usePhysics/>`.
7+
</Callout>
8+
39
The `Collision` component attaches a PlayCanvas [Collision Component](https://api.playcanvas.com/classes/Engine.CollisionComponent.html) to an [`Entity`](/api/entity).
410

511
It allows an [`Entity`](/api/entity) to participate in collision detection with other entities that have collision components. This is useful for physics simulations, trigger zones, and other gameplay mechanics that require detecting when objects intersect.

packages/docs/src/app/docs/api/components/rigidbody/page.mdx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
# Rigidbody
22

3+
import { Callout } from 'nextra/components'
4+
5+
<Callout type="info" emoji="️💡">
6+
Enable physics by installing `npm i sync-ammo` and enabling physics on the Application using`<Application usePhysics/>`.
7+
</Callout>
8+
39
The `Rigidbody` component enables physics for an [`Entity`](/api/entity), allowing it to interact with the global physics simulation. You can apply forces, torques, and other physics behaviours to an entity with a `Rigidbody` component and it will respond accordingly.
410

511
You can learn more about how physics work in PlayCanvas in the [Physics docs](https://developer.playcanvas.com/user-manual/physics/).
612

7-
> [!NOTE]
8-
>
9-
> To use physics you must enable it on the Application using`<Application usePhysics/>`. See the [Application docs](/api/application) for more information.
10-
1113
## Usage
1214

1315
```jsx

packages/docs/src/content/physics.mdx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ export const ShapeCollider = ({ children, scale = 1, material, type = 'sphere',
1212

1313
export const Physics = () => {
1414

15-
// This is a simple physics example and close port of this awesome r3f example - https://codesandbox.io/p/sandbox/bestservedbold-christmas-baubles-forked-wqyzx5
16-
1715
const app = useApp();
1816
const matA = useMaterial({ diffuse: "#c0a0a0", emissive: "red" });
1917
const matB = useMaterial({ });

packages/lib/package.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,16 +79,19 @@
7979
"peerDependencies": {
8080
"playcanvas": "^2.3.3",
8181
"react": "^18.3.1",
82-
"react-dom": "^18.3.1"
82+
"react-dom": "^18.3.1",
83+
"sync-ammo": "^0.1.2"
84+
},
85+
"peerDependenciesMeta": {
86+
"sync-ammo":{
87+
"optional": true
88+
}
8389
},
8490
"publishConfig": {
8591
"access": "public"
8692
},
8793
"devDependencies": {
8894
"pkg-pr-new": "^0.0.41",
8995
"typescript": "5.8.2"
90-
},
91-
"dependencies": {
92-
"sync-ammo": "^0.1.2"
9396
}
9497
}

packages/lib/src/Application.tsx

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import React, { FC, PropsWithChildren, useLayoutEffect, useMemo, useRef, useState } from 'react';
2-
import * as Ammo from 'sync-ammo';
32
import {
43
FILLMODE_NONE,
54
FILLMODE_FILL_WINDOW,
@@ -14,6 +13,7 @@ import {
1413
import { AppContext, ParentContext } from './hooks';
1514
import { PointerEventsContext } from './contexts/pointer-events-context';
1615
import { usePicker } from './utils/picker';
16+
import { PhysicsProvider } from './contexts/physics-context';
1717

1818
interface GraphicsOptions {
1919
/** Boolean that indicates if the canvas contains an alpha buffer. */
@@ -102,7 +102,6 @@ export const ApplicationWithoutCanvas: FC<ApplicationWithoutCanvasProps> = ({
102102
usePhysics = false,
103103
...otherProps
104104
}) => {
105-
106105
const graphicsDeviceOptions = {
107106
alpha: true,
108107
depth: true,
@@ -126,10 +125,6 @@ export const ApplicationWithoutCanvas: FC<ApplicationWithoutCanvasProps> = ({
126125
useLayoutEffect(() => {
127126
const canvas = canvasRef.current;
128127
if (canvas && !appRef.current) {
129-
130-
// @ts-expect-error The PC Physics system expects a global Ammo instance
131-
if (usePhysics) globalThis.Ammo = Ammo.default
132-
133128
const localApp = new PlayCanvasApplication(canvas, {
134129
mouse: new Mouse(canvas),
135130
touch: new TouchDevice(canvas),
@@ -146,13 +141,8 @@ export const ApplicationWithoutCanvas: FC<ApplicationWithoutCanvasProps> = ({
146141

147142
return () => {
148143
if (!appRef.current) return;
149-
150144
appRef.current.destroy();
151145
appRef.current = null;
152-
153-
// @ts-expect-error Clean up the global Ammo instance
154-
if (usePhysics && globalThis.Ammo) delete globalThis.Ammo;
155-
156146
setApp(null);
157147
};
158148
}, [canvasRef, fillMode, resolutionMode, ...Object.values(graphicsDeviceOptions)]);
@@ -167,12 +157,14 @@ export const ApplicationWithoutCanvas: FC<ApplicationWithoutCanvasProps> = ({
167157
if (!app) return null;
168158

169159
return (
170-
<AppContext.Provider value={appRef.current}>
171-
<PointerEventsContext.Provider value={pointerEvents}>
172-
<ParentContext.Provider value={appRef.current?.root as PcEntity}>
173-
{children}
174-
</ParentContext.Provider>
175-
</PointerEventsContext.Provider>
176-
</AppContext.Provider>
160+
<PhysicsProvider enabled={usePhysics} app={app}>
161+
<AppContext.Provider value={appRef.current}>
162+
<PointerEventsContext.Provider value={pointerEvents}>
163+
<ParentContext.Provider value={appRef.current?.root as PcEntity}>
164+
{children}
165+
</ParentContext.Provider>
166+
</PointerEventsContext.Provider>
167+
</AppContext.Provider>
168+
</PhysicsProvider>
177169
);
178170
};
Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,42 @@
11
"use client"
22

3-
import { FC } from "react";
4-
import { useComponent } from "../hooks";
3+
import { FC, useEffect } from "react";
4+
import { useComponent, useParent } from "../hooks";
5+
import { usePhysics } from "../contexts/physics-context";
6+
import { warnOnce } from "../utils/warn-once";
57

68
type CollisionProps = {
79
[key: string]: unknown;
10+
type?: string;
811
}
912

1013
export const Collision: FC<CollisionProps> = (props) => {
14+
const entity = useParent();
15+
const { isPhysicsEnabled, isPhysicsLoaded, physicsError } = usePhysics();
1116

12-
useComponent("collision", props);
13-
return null
14-
17+
useEffect(() => {
18+
if (!isPhysicsEnabled) {
19+
warnOnce(
20+
'The `<Collision>` component requires `usePhysics` to be set on the Application. ' +
21+
'Please add `<Application usePhysics/>` to your root component.',
22+
false // Show in both dev and prod
23+
);
24+
}
25+
26+
if (physicsError) {
27+
warnOnce(
28+
`Failed to initialize physics: ${physicsError.message}. ` +
29+
"Run `npm install sync-ammo` in your project, if you haven't done so already.",
30+
false // Show in both dev and prod
31+
);
32+
}
33+
}, [isPhysicsEnabled, physicsError]);
34+
35+
// If no type is defined, infer if possible from a render component
36+
const type = entity.render && props.type === undefined ? entity.render.type : props.type;
37+
38+
// Always call useComponent - it will handle component lifecycle internally
39+
useComponent(isPhysicsLoaded ? "collision" : null, { ...props, type });
40+
41+
return null;
1542
}
Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,45 @@
1-
import { FC } from "react";
1+
import { FC, useEffect } from "react";
22
import { useComponent, useParent } from "../hooks";
3+
import { usePhysics } from "../contexts/physics-context";
4+
import { warnOnce } from "../utils/warn-once";
35

46
type RigidBodyProps = {
57
[key: string]: unknown;
68
type?: string;
79
}
810

911
export const RigidBody: FC<RigidBodyProps> = (props) => {
10-
1112
const entity = useParent();
13+
const { isPhysicsEnabled, isPhysicsLoaded, physicsError } = usePhysics();
14+
15+
useEffect(() => {
16+
if (!isPhysicsEnabled) {
17+
warnOnce(
18+
'The `<RigidBody>` component requires `usePhysics` to be set on the Application. ' +
19+
'Please add `<Application usePhysics/>` to your root component.',
20+
false // Show in both dev and prod
21+
);
22+
}
23+
24+
if (physicsError) {
25+
warnOnce(
26+
`Failed to initialize physics: ${physicsError.message}. ` +
27+
"Run `npm install sync-ammo` in your project, if you haven't done so already.",
28+
false // Show in both dev and prod
29+
);
30+
}
31+
}, [isPhysicsEnabled, physicsError]);
1232

1333
// @ts-expect-error Ammo is defined in the global scope in the browser
14-
if(!globalThis.Ammo && process.env.NODE_ENV !== 'production' ) {
34+
if(isPhysicsLoaded && !globalThis.Ammo ) {
1535
throw new Error('The `<RigidBody>` component requires `usePhysics` to be set on the Application. `<Application usePhysics/>` ')
1636
}
1737

1838
// If no type is defined, infer if possible from a render component
1939
const type = entity.render && props.type === undefined ? entity.render.type : props.type;
2040

21-
useComponent("rigidbody", { ...props, type } );
22-
return null
23-
41+
// Always call useComponent - it will handle component lifecycle internally
42+
useComponent(isPhysicsLoaded ? "rigidbody" : null, { ...props, type });
43+
44+
return null;
2445
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import React, { createContext, useContext, useEffect, useState } from 'react';
2+
import { AppBase } from 'playcanvas';
3+
4+
interface PhysicsContextType {
5+
isPhysicsEnabled: boolean;
6+
isPhysicsLoaded: boolean;
7+
physicsError: Error | null;
8+
}
9+
10+
const PhysicsContext = createContext<PhysicsContextType>({
11+
isPhysicsEnabled: false,
12+
isPhysicsLoaded: false,
13+
physicsError: null,
14+
});
15+
16+
// Track how many Application instances are using physics
17+
let physicsInstanceCount = 0;
18+
19+
export const usePhysics = () => useContext(PhysicsContext);
20+
21+
interface PhysicsProviderProps {
22+
children: React.ReactNode;
23+
enabled: boolean;
24+
app: AppBase;
25+
}
26+
27+
export const PhysicsProvider: React.FC<PhysicsProviderProps> = ({ children, enabled, app }) => {
28+
const [isPhysicsLoaded, setIsPhysicsLoaded] = useState(false);
29+
const [physicsError, setPhysicsError] = useState<Error | null>(null);
30+
31+
useEffect(() => {
32+
if (!enabled) {
33+
setIsPhysicsLoaded(false);
34+
setPhysicsError(null);
35+
return;
36+
}
37+
38+
const loadPhysics = async () => {
39+
try {
40+
// @ts-expect-error The PC Physics system expects a global Ammo instance
41+
if (!globalThis.Ammo) {
42+
const Ammo = await import('sync-ammo');
43+
// @ts-expect-error The PC Physics system expects a global Ammo instance
44+
globalThis.Ammo = Ammo.default;
45+
}
46+
47+
// Only initialize the library if not already done so
48+
if(!app.systems.rigidbody?.dispatcher) {
49+
app.systems.rigidbody?.onLibraryLoaded();
50+
}
51+
52+
setIsPhysicsLoaded(true);
53+
setPhysicsError(null);
54+
physicsInstanceCount++;
55+
56+
} catch (error) {
57+
const err = error instanceof Error ? error : new Error('Failed to load physics library');
58+
setPhysicsError(err);
59+
setIsPhysicsLoaded(false);
60+
}
61+
};
62+
63+
loadPhysics();
64+
65+
return () => {
66+
// Only clean up Ammo if this is the last instance using physics
67+
if (enabled) {
68+
physicsInstanceCount--;
69+
if (physicsInstanceCount === 0) {
70+
// @ts-expect-error Clean up the global Ammo instance
71+
if (globalThis.Ammo) delete globalThis.Ammo;
72+
}
73+
}
74+
};
75+
}, [enabled, app]);
76+
77+
return (
78+
<PhysicsContext.Provider
79+
value={{
80+
isPhysicsEnabled: enabled,
81+
isPhysicsLoaded,
82+
physicsError,
83+
}}
84+
>
85+
{children}
86+
</PhysicsContext.Provider>
87+
);
88+
};

packages/lib/src/hooks/use-component.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ type ComponentProps = {
77
[key: string]: unknown;
88
}
99

10-
export const useComponent = (ctype: string, props: ComponentProps): void => {
10+
export const useComponent = (ctype: string | null, props: ComponentProps): void => {
1111
const componentRef = useRef<Component | null>();
1212
const parent : Entity = useParent();
1313
const app : Application = useApp();
1414

1515
useLayoutEffect(() => {
16+
if(!ctype) {
17+
return;
18+
}
19+
1620
if (parent) {
1721
// Only add the component if it hasn't been added yet
1822
if (!componentRef.current) {
@@ -38,6 +42,10 @@ export const useComponent = (ctype: string, props: ComponentProps): void => {
3842
// Update component props
3943
useLayoutEffect(() => {
4044

45+
if(!ctype) {
46+
return
47+
}
48+
4149
const comp: Component | null | undefined = componentRef.current
4250
// Ensure componentRef.current exists before updating props
4351
if (!comp) return;
@@ -48,5 +56,5 @@ export const useComponent = (ctype: string, props: ComponentProps): void => {
4856

4957
Object.assign(comp, filteredProps)
5058

51-
}, [props]);
59+
}, [props, ctype]);
5260
};

0 commit comments

Comments
 (0)