diff --git a/packages/core/src/physics/CharacterController.ts b/packages/core/src/physics/CharacterController.ts index f3c9e6f048..50de43c706 100644 --- a/packages/core/src/physics/CharacterController.ts +++ b/packages/core/src/physics/CharacterController.ts @@ -19,7 +19,7 @@ export class CharacterController extends Collider { /** * The step offset for the controller, the value must be greater than or equal to 0. - * @remarks Character can overcome obstacle less than the height(stepOffset + contractOffset of the shape). + * @remarks Character can overcome obstacle less than the height(stepOffset + contactOffset of the shape). */ get stepOffset(): number { return this._stepOffset; diff --git a/packages/core/src/physics/Collider.ts b/packages/core/src/physics/Collider.ts index a8a591fe56..f1af944d3b 100644 --- a/packages/core/src/physics/Collider.ts +++ b/packages/core/src/physics/Collider.ts @@ -1,4 +1,4 @@ -import { ICollider, IStaticCollider } from "@galacean/engine-design"; +import { ICollider, IColliderShape, IStaticCollider } from "@galacean/engine-design"; import { BoolUpdateFlag } from "../BoolUpdateFlag"; import { deepClone, ignoreClone } from "../clone/CloneManager"; import { ICustomClone } from "../clone/ComponentCloner"; @@ -196,6 +196,21 @@ export class Collider extends Component implements ICustomClone { } } + /** + * Replace an attached native shape while keeping attachment ownership in the collider. + * @internal + */ + _replaceNativeShape(shape: ColliderShape, nativeShape: IColliderShape | null): void { + const previousShape = shape._nativeShape; + if (previousShape === nativeShape) return; + + previousShape && this._nativeCollider.removeShape(previousShape); + shape._nativeShape = nativeShape; + nativeShape && this._nativeCollider.addShape(nativeShape); + previousShape?.destroy(); + this._handleShapesChanged(ColliderShapeChangeFlag.Property); + } + private _setCollisionLayer(): void { this._nativeCollider.setCollisionLayer(this._collisionLayerIndex); } diff --git a/packages/core/src/physics/DynamicCollider.ts b/packages/core/src/physics/DynamicCollider.ts index 5c8725c604..08d98564d8 100644 --- a/packages/core/src/physics/DynamicCollider.ts +++ b/packages/core/src/physics/DynamicCollider.ts @@ -33,7 +33,7 @@ export class DynamicCollider extends Collider { private _isKinematic = false; private _constraints: DynamicColliderConstraints = 0; private _collisionDetectionMode: CollisionDetectionMode = CollisionDetectionMode.Discrete; - private _sleepThreshold = 5e-3; + private _sleepThreshold: number | undefined; private _automaticCenterOfMass = true; private _automaticInertiaTensor = true; @@ -221,9 +221,11 @@ export class DynamicCollider extends Collider { /** * The mass-normalized energy threshold, below which objects start going to sleep. + * @remarks The default is determined by the physics backend. PhysX uses + * `5e-5 * tolerancesScale.speed * tolerancesScale.speed`. */ get sleepThreshold(): number { - return this._sleepThreshold; + return this._sleepThreshold ?? Engine._nativePhysics?.getDefaultSleepThreshold?.() ?? 5e-3; } set sleepThreshold(value: number) { @@ -498,7 +500,9 @@ export class DynamicCollider extends Collider { } (this._nativeCollider).setMaxAngularVelocity(this._maxAngularVelocity); (this._nativeCollider).setMaxDepenetrationVelocity(this._maxDepenetrationVelocity); - (this._nativeCollider).setSleepThreshold(this._sleepThreshold); + if (this._sleepThreshold !== undefined) { + (this._nativeCollider).setSleepThreshold(this._sleepThreshold); + } (this._nativeCollider).setSolverIterations(this._solverIterations); (this._nativeCollider).setUseGravity(this._useGravity); (this._nativeCollider).setIsKinematic(this._isKinematic); diff --git a/packages/core/src/physics/PhysicsMaterial.ts b/packages/core/src/physics/PhysicsMaterial.ts index 3ac16e66d8..40f4e131f4 100644 --- a/packages/core/src/physics/PhysicsMaterial.ts +++ b/packages/core/src/physics/PhysicsMaterial.ts @@ -21,8 +21,8 @@ export class PhysicsMaterial { this._staticFriction, this._dynamicFriction, this._bounciness, - this._bounceCombine, - this._frictionCombine + this._frictionCombine, + this._bounceCombine ); } diff --git a/packages/core/src/physics/shape/ColliderShape.ts b/packages/core/src/physics/shape/ColliderShape.ts index 2ac82319fd..0585d6e1a1 100644 --- a/packages/core/src/physics/shape/ColliderShape.ts +++ b/packages/core/src/physics/shape/ColliderShape.ts @@ -21,13 +21,14 @@ export abstract class ColliderShape implements ICustomClone { @ignoreClone protected _id: number; + @ignoreClone protected _material: PhysicsMaterial; private _isTrigger: boolean = false; @deepClone private _rotation: Vector3 = new Vector3(); @deepClone private _position: Vector3 = new Vector3(); - private _contactOffset: number = 0.02; + private _contactOffset: number | undefined; /** * @internal @@ -52,10 +53,10 @@ export abstract class ColliderShape implements ICustomClone { /** * Contact offset for this shape, the value must be greater than or equal to 0. - * @defaultValue 0.02 + * @remarks The default is determined by the physics backend. PhysX uses `0.02 * tolerancesScale.length`. */ get contactOffset(): number { - return this._contactOffset; + return this._contactOffset ?? Engine._nativePhysics?.getDefaultContactOffset?.() ?? 0.02; } set contactOffset(value: number) { @@ -165,7 +166,10 @@ export abstract class ColliderShape implements ICustomClone { * @internal */ _cloneTo(target: ColliderShape) { + const defaultMaterial = target._material; + target._material = this._material; target._syncNative(); + defaultMaterial.destroy(); } /** @@ -180,16 +184,23 @@ export abstract class ColliderShape implements ICustomClone { } protected _syncNative(): void { - if (!this._nativeShape) return; - this._nativeShape.setPosition(this._position); - this._nativeShape.setRotation(this._rotation); - this._nativeShape.setContactOffset(this._contactOffset); - this._nativeShape.setIsTrigger(this._isTrigger); - this._nativeShape.setMaterial(this._material._nativeMaterial); + const nativeShape = this._nativeShape; + if (!nativeShape) return; + this._syncNativeShape(nativeShape); this._collider?._handleShapesChanged(ColliderShapeChangeFlag.Property); } + protected _syncNativeShape(nativeShape: IColliderShape): void { + nativeShape.setPosition(this._position); + nativeShape.setRotation(this._rotation); + if (this._contactOffset !== undefined) { + nativeShape.setContactOffset(this._contactOffset); + } + nativeShape.setIsTrigger(this._isTrigger); + nativeShape.setMaterial(this._material._nativeMaterial); + } + @ignoreClone private _setPosition(): void { this._nativeShape?.setPosition(this._position); diff --git a/packages/core/src/physics/shape/MeshColliderShape.ts b/packages/core/src/physics/shape/MeshColliderShape.ts index 51a46ab0e0..6e4c2fb19b 100644 --- a/packages/core/src/physics/shape/MeshColliderShape.ts +++ b/packages/core/src/physics/shape/MeshColliderShape.ts @@ -1,21 +1,31 @@ import { IMeshColliderShape } from "@galacean/engine-design"; +import { Vector3 } from "@galacean/engine-math"; import { Engine } from "../../Engine"; +import { assignmentClone } from "../../clone/CloneManager"; import { ModelMesh } from "../../mesh/ModelMesh"; -import { Vector3 } from "@galacean/engine-math"; import { DynamicCollider } from "../DynamicCollider"; import { MeshColliderShapeCookingFlag } from "../enums/MeshColliderShapeCookingFlag"; import { ColliderShape } from "./ColliderShape"; +type MeshData = { + positions: Vector3[]; + indices: Uint8Array | Uint16Array | Uint32Array | null; +}; + /** * Collider shape based on mesh geometry, supporting both convex hull and triangle mesh modes. */ export class MeshColliderShape extends ColliderShape { + private static readonly _unitScale = new Vector3(1, 1, 1); + + @assignmentClone private _mesh: ModelMesh = null; private _isConvex = false; + @assignmentClone private _positions: Vector3[] = null; + @assignmentClone private _indices: Uint8Array | Uint16Array | Uint32Array | null = null; private _cookingFlags = MeshColliderShapeCookingFlag.Cleaning | MeshColliderShapeCookingFlag.VertexWelding; - private _isShapeAttached = false; /** * Cooking flags for this mesh collider shape. @@ -26,12 +36,16 @@ export class MeshColliderShape extends ColliderShape { set cookingFlags(value: MeshColliderShapeCookingFlag) { if (this._cookingFlags !== value) { - this._cookingFlags = value; - if (this._nativeShape) { - this._updateNativeShapeData(); - } else if (this._mesh && this._extractMeshData(this._mesh)) { - this._createNativeShape(); + if (!this._mesh) { + this._cookingFlags = value; + return; } + + const nativeShape = this._createNativeShape(this._positions, this._indices, this._isConvex, value); + if (!nativeShape) return; + + this._replaceNativeShape(nativeShape); + this._cookingFlags = value; } } @@ -47,17 +61,28 @@ export class MeshColliderShape extends ColliderShape { set isConvex(value: boolean) { if (this._isConvex !== value) { - this._isConvex = value; const mesh = this._mesh; - if (mesh) { - // PxShape.setGeometry requires matching geometry type, so recreate when isConvex changes - this._destroyNativeShape(); - if (this._extractMeshData(mesh)) { - this._createNativeShape(); - } else { - this._clearMeshData(); - } + if (!mesh) { + this._isConvex = value; + return; } + + let positions = this._positions; + let indices = this._indices; + if (!value && !indices) { + const meshData = this._getMeshData(mesh, false); + if (!meshData) return; + positions = meshData.positions; + indices = meshData.indices; + } + + const nativeShape = this._createNativeShape(positions, indices, value, this._cookingFlags); + if (!nativeShape) return; + + this._replaceNativeShape(nativeShape); + this._isConvex = value; + this._positions = positions; + this._indices = indices; } } @@ -71,19 +96,29 @@ export class MeshColliderShape extends ColliderShape { set mesh(value: ModelMesh) { if (this._mesh !== value) { - this._mesh?._addReferCount(-1); - value?._addReferCount(1); - this._mesh = value; - if (value && this._extractMeshData(value)) { - if (this._nativeShape) { - this._updateNativeShapeData(); - } else { - this._createNativeShape(); - } + if (value) { + const meshData = this._getMeshData(value, this._isConvex); + if (!meshData) return; + + const nativeShape = this._createNativeShape( + meshData.positions, + meshData.indices, + this._isConvex, + this._cookingFlags + ); + if (!nativeShape) return; + + this._replaceNativeShape(nativeShape); + this._positions = meshData.positions; + this._indices = meshData.indices; } else { - this._destroyNativeShape(); + this._replaceNativeShape(null); this._clearMeshData(); } + + this._mesh?._addReferCount(-1); + value?._addReferCount(1); + this._mesh = value; } } @@ -115,100 +150,79 @@ export class MeshColliderShape extends ColliderShape { this._indices = null; } - private _destroyNativeShape(): void { - if (this._nativeShape) { - if (this._isShapeAttached) { - this._detachFromCollider(); - } - this._nativeShape.destroy(); - this._nativeShape = null; - } - } - - private _extractMeshData(mesh: ModelMesh): boolean { + private _getMeshData(mesh: ModelMesh, isConvex: boolean): MeshData | null { if (!mesh.accessible) { console.warn("MeshColliderShape: Mesh data is not accessible. Set 'keepMeshData' before uploading."); - return false; + return null; } const positions = mesh.getPositions(); if (!positions || positions.length === 0) { console.warn("MeshColliderShape: Mesh has no position data."); - return false; + return null; } - this._positions = positions; - this._indices = null; - - if (!this._isConvex) { - const indices = mesh.getIndices(); + let indices: Uint8Array | Uint16Array | Uint32Array | null = null; + if (!isConvex) { + indices = mesh.getIndices(); if (!indices) { console.warn("MeshColliderShape: Non-convex mesh requires indices."); - return false; - } - this._indices = indices; - } - - return true; - } - - private _updateNativeShapeData(): void { - if ( - (this._nativeShape).setMeshData( - this._positions, - this._indices, - this._isConvex, - this._cookingFlags - ) - ) { - // Re-add to collider if previously removed due to cooking failure - if (this._collider && !this._isShapeAttached) { - this._attachToCollider(); + return null; } - } else if (this._isShapeAttached) { - this._detachFromCollider(); } - } - private _detachFromCollider(): void { - this._collider._nativeCollider.removeShape(this._nativeShape); - this._isShapeAttached = false; + return { positions, indices }; } - private _attachToCollider(): void { - this._collider._nativeCollider.addShape(this._nativeShape); - this._isShapeAttached = true; - } - - private _createNativeShape(): void { + private _createNativeShape( + positions: Vector3[], + indices: Uint8Array | Uint16Array | Uint32Array | null, + isConvex: boolean, + cookingFlags: MeshColliderShapeCookingFlag + ): IMeshColliderShape | null { // Non-convex MeshColliderShape is only supported on StaticCollider or kinematic DynamicCollider - if (!this._isConvex && this._collider instanceof DynamicCollider && !this._collider.isKinematic) { + if (!isConvex && this._collider instanceof DynamicCollider && !this._collider.isKinematic) { console.error("MeshColliderShape: Non-convex mesh is not supported on non-kinematic DynamicCollider."); - return; + return null; } const nativeShape = Engine._nativePhysics.createMeshColliderShape( this._id, - this._positions, - this._indices, - this._isConvex, + positions, + indices, + isConvex, this._material._nativeMaterial, - this._cookingFlags + cookingFlags, + this._collider?.entity.transform.lossyWorldScale ?? MeshColliderShape._unitScale ); - if (!nativeShape) { - return; - } - - this._nativeShape = nativeShape; - - // Sync base class properties (position, rotation, contactOffset, isTrigger, material) - super._syncNative(); + nativeShape && this._syncNativeShape(nativeShape); + return nativeShape; + } - // If already attached to a collider, add the newly created native shape to it + private _replaceNativeShape(nativeShape: IMeshColliderShape | null): void { if (this._collider) { - nativeShape.setWorldScale(this._collider.entity.transform.lossyWorldScale); - this._attachToCollider(); + this._collider._replaceNativeShape(this, nativeShape); + } else { + this._nativeShape?.destroy(); + this._nativeShape = nativeShape; + } + } + + /** + * @internal + * Rebuild the ignored native shape from the shared cached mesh data. + */ + override _cloneTo(target: MeshColliderShape): void { + super._cloneTo(target); + target._mesh?._addReferCount(1); + if (target._positions) { + target._nativeShape = target._createNativeShape( + target._positions, + target._indices, + target._isConvex, + target._cookingFlags + ); } } } diff --git a/packages/design/src/physics/IPhysics.ts b/packages/design/src/physics/IPhysics.ts index 17c9b6b466..9341e4007c 100644 --- a/packages/design/src/physics/IPhysics.ts +++ b/packages/design/src/physics/IPhysics.ts @@ -36,6 +36,16 @@ export interface IPhysics { */ createPhysicsScene(physicsManager: IPhysicsManager): IPhysicsScene; + /** + * Get the default contact offset for collider shapes. + */ + getDefaultContactOffset?(): number; + + /** + * Get the default sleep threshold for dynamic colliders. + */ + getDefaultSleepThreshold?(): number; + /** * Create dynamic collider. * @param position - The global position @@ -116,6 +126,7 @@ export interface IPhysics { * @param isConvex - Whether to create convex mesh (true) or triangle mesh (false) * @param material - The material of this shape * @param cookingFlags - Cooking flags + * @param worldScale - World scale of the shape */ createMeshColliderShape( uniqueID: number, @@ -123,7 +134,8 @@ export interface IPhysics { indices: Uint8Array | Uint16Array | Uint32Array | null, isConvex: boolean, material: IPhysicsMaterial, - cookingFlags: number + cookingFlags: number, + worldScale: Vector3 ): IMeshColliderShape | null; /** diff --git a/packages/design/src/physics/shape/IMeshColliderShape.ts b/packages/design/src/physics/shape/IMeshColliderShape.ts index 77b9d5db55..5288b9163c 100644 --- a/packages/design/src/physics/shape/IMeshColliderShape.ts +++ b/packages/design/src/physics/shape/IMeshColliderShape.ts @@ -1,22 +1,6 @@ -import { Vector3 } from "@galacean/engine-math"; import { IColliderShape } from "./IColliderShape"; /** * Interface for mesh collider shape. */ -export interface IMeshColliderShape extends IColliderShape { - /** - * Set mesh data for this collider shape. - * @param positions - Vertex positions - * @param indices - The index array (Uint16Array or Uint32Array), required for triangle mesh - * @param isConvex - Whether to use convex mesh (true) or triangle mesh (false) - * @param cookingFlags - Cooking flags - * @returns Whether the mesh data was successfully set - */ - setMeshData( - positions: Vector3[], - indices: Uint8Array | Uint16Array | Uint32Array | null, - isConvex: boolean, - cookingFlags: number - ): boolean; -} +export interface IMeshColliderShape extends IColliderShape {} diff --git a/packages/physics-physx/src/PhysXCharacterController.ts b/packages/physics-physx/src/PhysXCharacterController.ts index 38b5228062..ef2a2a7590 100644 --- a/packages/physics-physx/src/PhysXCharacterController.ts +++ b/packages/physics-physx/src/PhysXCharacterController.ts @@ -94,7 +94,6 @@ export class PhysXCharacterController implements ICharacterController { this._pxManager && this._createPXController(this._pxManager, shape); this._shape = shape; shape._controllers.add(this); - this._pxController?.setContactOffset(shape._contractOffset); this._scene?._addColliderShape(shape._id); } @@ -159,6 +158,7 @@ export class PhysXCharacterController implements ICharacterController { this._pxController = pxManager._getControllerManager().createController(desc); desc.delete(); + this._pxController.setContactOffset(shape._contactOffset); this._pxController.setUUID(shape._id); this._updateNativePosition(); diff --git a/packages/physics-physx/src/PhysXPhysics.ts b/packages/physics-physx/src/PhysXPhysics.ts index 8bcf8d510a..71630e111d 100644 --- a/packages/physics-physx/src/PhysXPhysics.ts +++ b/packages/physics-physx/src/PhysXPhysics.ts @@ -54,16 +54,36 @@ export class PhysXPhysics implements IPhysics { private _initializePromise: Promise; private _defaultErrorCallback: any; private _allocator: any; - private _tolerancesScale: any; private _wasmSIMDModeUrl: string; private _wasmModeUrl: string; + private readonly _tolerancesScaleLength: number; + private readonly _tolerancesScaleSpeed: number; /** * Create a PhysXPhysics instance. * @param runtimeMode - Runtime mode, `Auto` prefers WebAssembly SIMD if supported @see {@link PhysXRuntimeMode} * @param runtimeUrls - Manually specify the runtime URLs + * @param options - PhysX options. */ - constructor(runtimeMode: PhysXRuntimeMode = PhysXRuntimeMode.Auto, runtimeUrls?: PhysXRuntimeUrls) { + constructor(runtimeMode?: PhysXRuntimeMode, runtimeUrls?: PhysXRuntimeUrls, options?: PhysXPhysicsOptions); + constructor(options?: PhysXPhysicsOptions); + constructor( + runtimeModeOrOptions: PhysXRuntimeMode | PhysXPhysicsOptions = PhysXRuntimeMode.Auto, + runtimeUrls?: PhysXRuntimeUrls, + options?: PhysXPhysicsOptions + ) { + const isOptionsObject = typeof runtimeModeOrOptions === "object"; + const runtimeMode = isOptionsObject ? PhysXRuntimeMode.Auto : (runtimeModeOrOptions ?? PhysXRuntimeMode.Auto); + const resolvedOptions = isOptionsObject ? runtimeModeOrOptions : options; + const tolerancesScale = resolvedOptions?.tolerancesScale; + if (tolerancesScale !== undefined && (tolerancesScale === null || typeof tolerancesScale !== "object")) { + throw new Error("PhysXPhysics tolerancesScale must be an object."); + } + const length = tolerancesScale?.length === undefined ? 1 : tolerancesScale.length; + const speed = tolerancesScale?.speed === undefined ? 10 : tolerancesScale.speed; + this._assertPositiveFinite(length, "tolerancesScale.length"); + this._assertPositiveFinite(speed, "tolerancesScale.speed"); + this._runTimeMode = runtimeMode; this._wasmSIMDModeUrl = runtimeUrls?.wasmSIMDModeUrl ?? @@ -71,6 +91,8 @@ export class PhysXPhysics implements IPhysics { this._wasmModeUrl = runtimeUrls?.wasmModeUrl ?? "https://mdn.alipayobjects.com/rms/uri/file/as/apwallet/1781696156399/suyi/physx.release.js"; + this._tolerancesScaleLength = length; + this._tolerancesScaleSpeed = speed; } /** @@ -138,7 +160,6 @@ export class PhysXPhysics implements IPhysics { this._pxFoundation.release(); this._defaultErrorCallback.delete(); this._allocator.delete(); - this._tolerancesScale.delete(); } /** @@ -156,6 +177,20 @@ export class PhysXPhysics implements IPhysics { return scene; } + /** + * {@inheritDoc IPhysics.getDefaultContactOffset } + */ + getDefaultContactOffset(): number { + return 0.02 * this._tolerancesScaleLength; + } + + /** + * {@inheritDoc IPhysics.getDefaultSleepThreshold } + */ + getDefaultSleepThreshold(): number { + return 5e-5 * this._tolerancesScaleSpeed * this._tolerancesScaleSpeed; + } + /** * {@inheritDoc IPhysics.createStaticCollider } */ @@ -232,9 +267,19 @@ export class PhysXPhysics implements IPhysics { indices: Uint8Array | Uint16Array | Uint32Array | null, isConvex: boolean, material: PhysXPhysicsMaterial, - cookingFlags: number + cookingFlags: number, + worldScale: Vector3 ): IMeshColliderShape | null { - const shape = new PhysXMeshColliderShape(this, uniqueID, positions, indices, isConvex, material, cookingFlags); + const shape = new PhysXMeshColliderShape( + this, + uniqueID, + positions, + indices, + isConvex, + material, + cookingFlags, + worldScale + ); return shape._pxShape ? shape : null; } @@ -279,12 +324,16 @@ export class PhysXPhysics implements IPhysics { const allocator = new physX.PxDefaultAllocator(); const pxFoundation = physX.PxCreateFoundation(version, allocator, defaultErrorCallback); const tolerancesScale = new physX.PxTolerancesScale(); + tolerancesScale.length = this._tolerancesScaleLength; + tolerancesScale.speed = this._tolerancesScaleSpeed; const pxPhysics = physX.PxCreatePhysics(version, pxFoundation, tolerancesScale, false, null); physX.PxInitExtensions(pxPhysics, null); // Initialize cooking for mesh colliders const cookingParams = new physX.PxCookingParams(tolerancesScale); + // PxPhysics and PxCookingParams copy the scale. + tolerancesScale.delete(); physX.setCookingMeshPreprocessParams(cookingParams, 1); // eWELD_VERTICES cookingParams.meshWeldTolerance = 0.001; // BVH34 midphase requires SSE2; SIMD WASM provides SSE2 via WASM SIMD @@ -300,7 +349,12 @@ export class PhysXPhysics implements IPhysics { this._pxCookingParams = cookingParams; this._defaultErrorCallback = defaultErrorCallback; this._allocator = allocator; - this._tolerancesScale = tolerancesScale; + } + + private _assertPositiveFinite(value: number, name: string): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`PhysXPhysics ${name} must be a positive finite number.`); + } } } @@ -316,3 +370,15 @@ interface PhysXRuntimeUrls { /*** The URL of `PhysXRuntimeMode.WebAssemblySIMD` mode. */ wasmSIMDModeUrl?: string; } + +export interface PhysXTolerancesScale { + /** Approximate object length in the simulation unit. Must be positive and finite. PhysX default is 1. */ + length?: number; + /** Typical object speed in the simulation unit. Must be positive and finite. PhysX default is 10. */ + speed?: number; +} + +export interface PhysXPhysicsOptions { + /** PhysX world unit scale, copied before PxPhysics, PxSceneDesc and PxCookingParams are created. */ + tolerancesScale?: PhysXTolerancesScale; +} diff --git a/packages/physics-physx/src/index.ts b/packages/physics-physx/src/index.ts index aa3dd875c3..1798c64b70 100644 --- a/packages/physics-physx/src/index.ts +++ b/packages/physics-physx/src/index.ts @@ -1,4 +1,5 @@ export { PhysXPhysics } from "./PhysXPhysics"; +export type { PhysXPhysicsOptions, PhysXTolerancesScale } from "./PhysXPhysics"; export { PhysXRuntimeMode } from "./enum/PhysXRuntimeMode"; //@ts-ignore diff --git a/packages/physics-physx/src/shape/PhysXColliderShape.ts b/packages/physics-physx/src/shape/PhysXColliderShape.ts index ea5a3df8b4..9d782f73fb 100644 --- a/packages/physics-physx/src/shape/PhysXColliderShape.ts +++ b/packages/physics-physx/src/shape/PhysXColliderShape.ts @@ -31,7 +31,7 @@ export abstract class PhysXColliderShape implements IColliderShape { /** @internal */ _controllers: DisorderedArray = new DisorderedArray(); /** @internal */ - _contractOffset: number = 0.02; + _contactOffset: number = 0.02; /** @internal */ _worldScale: Vector3 = new Vector3(1, 1, 1); @@ -56,6 +56,7 @@ export abstract class PhysXColliderShape implements IColliderShape { constructor(physXPhysics: PhysXPhysics) { this._physXPhysics = physXPhysics; + this._contactOffset = physXPhysics.getDefaultContactOffset(); } /** @@ -106,7 +107,7 @@ export abstract class PhysXColliderShape implements IColliderShape { * @default 0.02f * PxTolerancesScale::length */ setContactOffset(offset: number): void { - this._contractOffset = offset; + this._contactOffset = offset; const controllers = this._controllers; if (controllers.length) { for (let i = 0, n = controllers.length; i < n; i++) { diff --git a/packages/physics-physx/src/shape/PhysXMeshColliderShape.ts b/packages/physics-physx/src/shape/PhysXMeshColliderShape.ts index 9a25c2fd89..0d70ddb776 100644 --- a/packages/physics-physx/src/shape/PhysXMeshColliderShape.ts +++ b/packages/physics-physx/src/shape/PhysXMeshColliderShape.ts @@ -8,7 +8,7 @@ import { PhysXColliderShape, ShapeFlag } from "./PhysXColliderShape"; * Mesh collider shape in PhysX. */ export class PhysXMeshColliderShape extends PhysXColliderShape implements IMeshColliderShape { - private static _tightBoundsFlag = 1; // eTIGHT_BOUNDS = 1 (1<<0) + private static readonly _tightBoundsFlag = 1; // eTIGHT_BOUNDS = 1 (1<<0) private _pxMesh: any = null; private _isConvex: boolean; @@ -20,12 +20,15 @@ export class PhysXMeshColliderShape extends PhysXColliderShape implements IMeshC indices: Uint8Array | Uint16Array | Uint32Array | null, isConvex: boolean, material: PhysXPhysicsMaterial, - cookingFlags: number + cookingFlags: number, + worldScale: Vector3 ) { super(physXPhysics); this._isConvex = isConvex; + this._worldScale.set(Math.abs(worldScale.x), Math.abs(worldScale.y), Math.abs(worldScale.z)); - if (!this._cookMesh(positions, indices, cookingFlags)) { + const pxMesh = this._cookMesh(positions, indices, isConvex, cookingFlags); + if (!pxMesh) { return; } @@ -35,50 +38,33 @@ export class PhysXMeshColliderShape extends PhysXColliderShape implements IMeshC const meshFlag = isConvex ? PhysXMeshColliderShape._tightBoundsFlag : 0; const createShapeFn = isConvex ? physX.createConvexMeshShape : physX.createTriMeshShape; - this._pxShape = createShapeFn( - this._pxMesh, - scaleX, - scaleY, - scaleZ, - meshFlag, - shapeFlags, - material._pxMaterial, - physics - ); + const pxShape = createShapeFn(pxMesh, scaleX, scaleY, scaleZ, meshFlag, shapeFlags, material._pxMaterial, physics); + if (!pxShape) { + pxMesh.release(); + return; + } + this._pxShape = pxShape; this._id = uniqueID; this._pxMaterial = material._pxMaterial; + this._pxMesh = pxMesh; + this._pxGeometry = isConvex + ? physX.createConvexMeshGeometry(pxMesh, scaleX, scaleY, scaleZ, meshFlag) + : physX.createTriMeshGeometry(pxMesh, scaleX, scaleY, scaleZ, meshFlag); this._pxShape.setUUID(uniqueID); this._setLocalPose(); } - /** - * {@inheritDoc IMeshColliderShape.setMeshData } - */ - setMeshData( - positions: Vector3[], - indices: Uint8Array | Uint16Array | Uint32Array | null, - isConvex: boolean, - cookingFlags: number - ): boolean { - this._pxMesh?.release(); - this._pxGeometry?.delete(); - this._pxMesh = null; - this._pxGeometry = null; - this._isConvex = isConvex; - - if (!this._cookMesh(positions, indices, cookingFlags)) { - return false; - } - - this._pxShape.setGeometry(this._pxGeometry); - return true; - } - /** * {@inheritDoc IColliderShape.setWorldScale } */ override setWorldScale(scale: Vector3): void { + const worldScale = this._worldScale; + const x = Math.abs(scale.x); + const y = Math.abs(scale.y); + const z = Math.abs(scale.z); + if (worldScale.x === x && worldScale.y === y && worldScale.z === z) return; + super.setWorldScale(scale); this._updateGeometry(); } @@ -94,8 +80,9 @@ export class PhysXMeshColliderShape extends PhysXColliderShape implements IMeshC private _cookMesh( positions: Vector3[], indices: Uint8Array | Uint16Array | Uint32Array | null, + isConvex: boolean, cookingFlags: number - ): boolean { + ): any | null { const { _physX: physX, _pxPhysics: physics, @@ -115,48 +102,36 @@ export class PhysXMeshColliderShape extends PhysXColliderShape implements IMeshC cooking.setParams(cookingParams); const verticesPtr = this._allocatePositions(positions); + let pxMesh: any = null; - if (this._isConvex) { - this._pxMesh = cooking.createConvexMesh(verticesPtr, positions.length, physics); + if (isConvex) { + pxMesh = cooking.createConvexMesh(verticesPtr, positions.length, physics); physX._free(verticesPtr); - if (!this._pxMesh) { + if (!pxMesh) { this._logConvexCookingError(physX); - return false; + return null; } } else { if (!indices) { physX._free(verticesPtr); console.error("PhysXMeshColliderShape: Triangle mesh requires indices."); - return false; + return null; } const isU32 = indices instanceof Uint32Array; const indicesPtr = this._allocateIndices(indices, isU32); - this._pxMesh = cooking.createTriMesh( - verticesPtr, - positions.length, - indicesPtr, - indices.length / 3, - !isU32, - physics - ); + pxMesh = cooking.createTriMesh(verticesPtr, positions.length, indicesPtr, indices.length / 3, !isU32, physics); physX._free(verticesPtr); physX._free(indicesPtr); - if (!this._pxMesh) { + if (!pxMesh) { this._logTriMeshCookingError(physX); - return false; + return null; } } - const { x: scaleX, y: scaleY, z: scaleZ } = this._worldScale; - const meshFlag = this._isConvex ? PhysXMeshColliderShape._tightBoundsFlag : 0; - this._pxGeometry = this._isConvex - ? physX.createConvexMeshGeometry(this._pxMesh, scaleX, scaleY, scaleZ, meshFlag) - : physX.createTriMeshGeometry(this._pxMesh, scaleX, scaleY, scaleZ, meshFlag); - - return true; + return pxMesh; } private _logConvexCookingError(physX: any): void { @@ -221,8 +196,14 @@ export class PhysXMeshColliderShape extends PhysXColliderShape implements IMeshC ? physX.createConvexMeshGeometry(this._pxMesh, scaleX, scaleY, scaleZ, meshFlag) : physX.createTriMeshGeometry(this._pxMesh, scaleX, scaleY, scaleZ, meshFlag); - this._pxGeometry.delete(); + const oldGeometry = this._pxGeometry; + try { + this._pxShape.setGeometry(newGeometry); + } catch (error) { + newGeometry?.delete(); + throw error; + } this._pxGeometry = newGeometry; - this._pxShape.setGeometry(this._pxGeometry); + oldGeometry?.delete(); } } diff --git a/tests/src/core/physics/CharacterController.test.ts b/tests/src/core/physics/CharacterController.test.ts index e5eb69e543..0c39dfeb34 100644 --- a/tests/src/core/physics/CharacterController.test.ts +++ b/tests/src/core/physics/CharacterController.test.ts @@ -177,6 +177,27 @@ describe("CharacterController", function () { expect(formatValue(roleEntity.transform.position.y)).eq(1.5); }); + it("preserves contactOffset when the native controller is recreated", () => { + const controller = roleEntity.getComponent(CharacterController); + controller.shapes[0].contactOffset = 0.3; + + controller.enabled = false; + controller.enabled = true; + controller.move(new Vector3(0, 0, 0.1), 0.0001, 1); + controller.move(new Vector3(0, 0, 0.1), 0.0001, 1); + engine.update(); + + expect(formatValue(roleEntity.transform.position.y)).eq(0.8); + }); + + it("recreates the native controller when contactOffset is zero", () => { + const controller = roleEntity.getComponent(CharacterController); + controller.shapes[0].contactOffset = 0; + controller.enabled = false; + + expect(() => (controller.enabled = true)).not.toThrow(); + }); + it("slopeLimit notPass", () => { const { fixedTimeStep } = engine.sceneManager.activeScene.physics; const moveScript = roleEntity.getComponent(MoveScript); diff --git a/tests/src/core/physics/MeshColliderShape.test.ts b/tests/src/core/physics/MeshColliderShape.test.ts index 9506569526..1ed9d0cd65 100644 --- a/tests/src/core/physics/MeshColliderShape.test.ts +++ b/tests/src/core/physics/MeshColliderShape.test.ts @@ -182,6 +182,52 @@ describe("MeshColliderShape PhysX", () => { defaultMaterial?.destroy(); material?.destroy(); }); + + it("cloned MeshColliderShape rebuilds its native PhysX shape", async () => { + const groundEntity = root.createChild("meshGroundForClone"); + groundEntity.transform.setPosition(0, 0, 0); + const groundCollider = groundEntity.addComponent(StaticCollider); + const meshShape = new MeshColliderShape(); + const meshMaterial = meshShape.material; + const mesh = createModelMesh(engine, [-10, 0, -10, 10, 0, -10, -10, 0, 10, 10, 0, 10], [0, 2, 1, 1, 2, 3]); + meshShape.mesh = mesh; + groundCollider.addShape(meshShape); + const meshRefCount = mesh.refCount; + + const clonedGround = groundEntity.clone(); + // Move the original aside so the cloned ground is the only surface below the sphere. + groundEntity.transform.setPosition(1000, 0, 0); + root.addChild(clonedGround); + clonedGround.transform.setPosition(0, 0, 0); + const clonedCollider = clonedGround.getComponent(StaticCollider); + + expect(mesh.refCount).toBe(meshRefCount + 1); + expect((clonedCollider as any)._nativeCollider._shapes).toHaveLength(1); + + const sphereEntity = root.createChild("sphereForClone"); + sphereEntity.transform.setPosition(0, 2, 0); + const dynamicCollider = sphereEntity.addComponent(DynamicCollider); + const sphereShape = new SphereColliderShape(); + const sphereMaterial = sphereShape.material; + sphereShape.radius = 0.5; + dynamicCollider.addShape(sphereShape); + + for (let i = 0; i < 60; i++) { + physicsScene._update(1 / 60); + } + + // Sphere lands on cloned ground (y > -1), not falls forever (y < -10). + const sphereY = sphereEntity.transform.position.y; + expect(sphereY).toBeGreaterThan(-1); + expect(sphereY).toBeLessThan(2); + + clonedGround.destroy(); + expect(mesh.refCount).toBe(meshRefCount); + groundEntity.destroy(); + sphereEntity.destroy(); + meshMaterial?.destroy(); + sphereMaterial?.destroy(); + }); }); describe("Convex Mesh (Dynamic)", () => { @@ -205,6 +251,36 @@ describe("MeshColliderShape PhysX", () => { defaultMaterial?.destroy(); }); + it("keeps the convex shape when non-convex mode is unsupported", () => { + const entity = root.createChild("unsupportedDynamicMesh"); + const dynamicCollider = entity.addComponent(DynamicCollider); + + const meshShape = new MeshColliderShape(); + const meshMaterial = meshShape.material; + const convexMesh = createModelMesh( + engine, + [0, 1, 0, -1, 0, -1, 1, 0, -1, 0, 0, 1], + [0, 1, 2, 0, 2, 3, 0, 3, 1, 1, 3, 2] + ); + meshShape.isConvex = true; + meshShape.mesh = convexMesh; + dynamicCollider.addShape(meshShape); + const nativeShape = (meshShape as any)._nativeShape; + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + meshShape.isConvex = false; + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(meshShape.isConvex).toBe(true); + expect((meshShape as any)._nativeShape).toBe(nativeShape); + expect((dynamicCollider as any)._nativeCollider._shapes).toEqual([nativeShape]); + } finally { + consoleErrorSpy.mockRestore(); + entity.destroy(); + meshMaterial?.destroy(); + } + }); + it("should allow convex mesh on dynamic collider", async () => { // Create ground const groundEntity = root.createChild("ground2"); @@ -397,7 +473,7 @@ describe("MeshColliderShape PhysX", () => { }); describe("Mesh Data Update", () => { - it("should update mesh data", () => { + it("should replace mesh data with one native shape", () => { const entity = root.createChild("updateMesh"); const staticCollider = entity.addComponent(StaticCollider); @@ -408,16 +484,68 @@ describe("MeshColliderShape PhysX", () => { const mesh1 = createModelMesh(engine, [0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2]); meshShape.mesh = mesh1; staticCollider.addShape(meshShape); + const firstNativeShape = (meshShape as any)._nativeShape; // Update mesh const mesh2 = createModelMesh(engine, [0, 0, 0, 2, 0, 0, 0, 2, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], [0, 1, 2, 3, 4, 5]); meshShape.mesh = mesh2; expect(staticCollider.shapes.length).toBe(1); + expect((meshShape as any)._nativeShape).not.toBe(firstNativeShape); + expect((staticCollider as any)._nativeCollider._shapes).toEqual([(meshShape as any)._nativeShape]); entity.destroy(); defaultMaterial?.destroy(); }); + + it("keeps the existing native mesh when runtime mesh recooking fails", () => { + const groundEntity = root.createChild("transactionalMeshUpdateGround"); + const staticCollider = groundEntity.addComponent(StaticCollider); + const meshShape = new MeshColliderShape(); + const meshMaterial = meshShape.material; + const groundMesh = createModelMesh(engine, [-10, 0, -10, 10, 0, -10, -10, 0, 10, 10, 0, 10], [0, 2, 1, 1, 2, 3]); + meshShape.mesh = groundMesh; + staticCollider.addShape(meshShape); + + const nativeShape = (meshShape as any)._nativeShape; + const cooking = nativeShape._physXPhysics._pxCooking; + const originalCreateTriMesh = cooking.createTriMesh; + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + let sphereEntity: Entity | undefined; + let sphereMaterial: PhysicsMaterial | undefined; + + try { + cooking.createTriMesh = () => null; + const replacementMesh = createModelMesh(engine, [-2, 0, -2, 2, 0, -2, -2, 0, 2, 2, 0, 2], [0, 2, 1, 1, 2, 3]); + meshShape.mesh = replacementMesh; + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to create triangle mesh")); + expect(meshShape.mesh).toBe(groundMesh); + expect((meshShape as any)._nativeShape).toBe(nativeShape); + expect((staticCollider as any)._nativeCollider._shapes).toEqual([nativeShape]); + + sphereEntity = root.createChild("transactionalMeshUpdateSphere"); + sphereEntity.transform.setPosition(0, 2, 0); + const dynamicCollider = sphereEntity.addComponent(DynamicCollider); + const sphereShape = new SphereColliderShape(); + sphereMaterial = sphereShape.material; + sphereShape.radius = 0.5; + dynamicCollider.addShape(sphereShape); + + for (let i = 0; i < 60; i++) { + physicsScene._update(1 / 60); + } + + expect(sphereEntity.transform.position.y).toBeGreaterThan(-1); + } finally { + cooking.createTriMesh = originalCreateTriMesh; + consoleErrorSpy.mockRestore(); + sphereEntity?.destroy(); + groundEntity.destroy(); + meshMaterial?.destroy(); + sphereMaterial?.destroy(); + } + }); }); describe("Triangle Mesh with DynamicCollider", () => { @@ -604,7 +732,7 @@ describe("MeshColliderShape PhysX", () => { defaultMaterial?.destroy(); }); - it("should disable shape when switching to non-convex and mesh has no indices", () => { + it("should keep the convex shape when switching requires missing indices", () => { const warnSpy = vi.spyOn(console, "warn"); const entity = root.createChild("switchConvexNoIndices"); @@ -622,15 +750,16 @@ describe("MeshColliderShape PhysX", () => { meshShape.mesh = mesh; staticCollider.addShape(meshShape); - // @ts-ignore - expect(meshShape._nativeShape).not.toBeNull(); + const nativeShape = (meshShape as any)._nativeShape; + expect(nativeShape).not.toBeNull(); // Switch to non-convex - should fail because no indices meshShape.isConvex = false; expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Non-convex mesh requires indices")); - // @ts-ignore - shape should be destroyed (failure = disable) - expect(meshShape._nativeShape).toBeNull(); + expect(meshShape.isConvex).toBe(true); + expect((meshShape as any)._nativeShape).toBe(nativeShape); + expect((staticCollider as any)._nativeCollider._shapes).toEqual([nativeShape]); warnSpy.mockRestore(); entity.destroy(); @@ -639,14 +768,14 @@ describe("MeshColliderShape PhysX", () => { }); describe("Set Mesh Null", () => { - it("should destroy native shape when setting mesh to null", () => { - const entity = root.createChild("nullMesh"); - const staticCollider = entity.addComponent(StaticCollider); + it("should detach the native shape when setting mesh to null", () => { + const groundEntity = root.createChild("nullMesh"); + const staticCollider = groundEntity.addComponent(StaticCollider); const meshShape = new MeshColliderShape(); const defaultMaterial = meshShape.material; - const mesh = createModelMesh(engine, [0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2]); + const mesh = createModelMesh(engine, [-10, 0, -10, 10, 0, -10, -10, 0, 10, 10, 0, 10], [0, 2, 1, 1, 2, 3]); meshShape.mesh = mesh; staticCollider.addShape(meshShape); @@ -659,9 +788,25 @@ describe("MeshColliderShape PhysX", () => { // @ts-ignore expect(meshShape._nativeShape).toBeNull(); expect(meshShape.mesh).toBeNull(); + expect((staticCollider as any)._nativeCollider._shapes).toHaveLength(0); - entity.destroy(); + const sphereEntity = root.createChild("nullMeshSphere"); + sphereEntity.transform.setPosition(0, 2, 0); + const dynamicCollider = sphereEntity.addComponent(DynamicCollider); + const sphereShape = new SphereColliderShape(); + const sphereMaterial = sphereShape.material; + dynamicCollider.addShape(sphereShape); + + for (let i = 0; i < 60; i++) { + physicsScene._update(1 / 60); + } + + expect(sphereEntity.transform.position.y).toBeLessThan(-1); + + sphereEntity.destroy(); + groundEntity.destroy(); defaultMaterial?.destroy(); + sphereMaterial?.destroy(); }); it("should still work after setting mesh to null and then setting a new mesh", () => { @@ -695,7 +840,30 @@ describe("MeshColliderShape PhysX", () => { }); describe("CookingFlags", () => { - it("should reuse existing native shape when changing cookingFlags", () => { + it("should switch to convex using cached data after mesh upload data is released", () => { + const entity = root.createChild("releasedMeshData"); + const staticCollider = entity.addComponent(StaticCollider); + const meshShape = new MeshColliderShape(); + const defaultMaterial = meshShape.material; + const mesh = createModelMesh( + engine, + [0, 1, 0, -1, 0, -1, 1, 0, -1, 0, 0, 1], + [0, 1, 2, 0, 2, 3, 0, 3, 1, 1, 3, 2] + ); + meshShape.mesh = mesh; + staticCollider.addShape(meshShape); + mesh.uploadData(true); + + meshShape.isConvex = true; + + expect(meshShape.isConvex).toBe(true); + expect((staticCollider as any)._nativeCollider._shapes).toEqual([(meshShape as any)._nativeShape]); + + entity.destroy(); + defaultMaterial?.destroy(); + }); + + it("should replace the native shape when changing cookingFlags", () => { const entity = root.createChild("cookingFlags"); const staticCollider = entity.addComponent(StaticCollider); @@ -710,11 +878,11 @@ describe("MeshColliderShape PhysX", () => { const nativeShapeBefore = meshShape._nativeShape; expect(nativeShapeBefore).not.toBeNull(); - // Change cooking flags - should reuse existing shape via setMeshData meshShape.cookingFlags = MeshColliderShapeCookingFlag.Cleaning; - // @ts-ignore - same native shape instance (reused, not recreated) - expect(meshShape._nativeShape).toBe(nativeShapeBefore); + const nativeShapeAfter = (meshShape as any)._nativeShape; + expect(nativeShapeAfter).not.toBe(nativeShapeBefore); + expect((staticCollider as any)._nativeCollider._shapes).toEqual([nativeShapeAfter]); entity.destroy(); defaultMaterial?.destroy(); diff --git a/tests/src/core/physics/PhysXPhysics.test.ts b/tests/src/core/physics/PhysXPhysics.test.ts new file mode 100644 index 0000000000..20ec9d0e4e --- /dev/null +++ b/tests/src/core/physics/PhysXPhysics.test.ts @@ -0,0 +1,44 @@ +import { BoxColliderShape, DynamicCollider, Engine } from "@galacean/engine-core"; +import { PhysXPhysics } from "@galacean/engine-physics-physx"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +describe("PhysXPhysics", () => { + let engine: Engine; + let physics: PhysXPhysics; + + beforeAll(async () => { + physics = new PhysXPhysics({ tolerancesScale: { length: 2, speed: 20 } }); + engine = await WebGLEngine.create({ canvas: document.createElement("canvas"), physics }); + }); + + afterAll(() => { + engine.destroy(); + physics.destroy(); + }); + + it("validates and snapshots tolerancesScale at construction", () => { + expect(() => new PhysXPhysics({ tolerancesScale: { length: 0 } })).toThrow(); + expect(() => new PhysXPhysics({ tolerancesScale: { length: null as unknown as number } })).toThrow(); + + const tolerancesScale = { length: 2, speed: 20 }; + const uninitializedPhysics = new PhysXPhysics({ tolerancesScale }); + tolerancesScale.length = 3; + + expect(uninitializedPhysics.getDefaultContactOffset()).toBeCloseTo(0.04); + }); + + it("applies tolerancesScale to native physics and core defaults", () => { + const nativeScale = physics._pxPhysics.getTolerancesScale(); + expect(nativeScale.length).toBeCloseTo(2); + expect(nativeScale.speed).toBeCloseTo(20); + + const entity = engine.sceneManager.activeScene.createRootEntity("scaledDefaults"); + const collider = entity.addComponent(DynamicCollider); + const shape = new BoxColliderShape(); + collider.addShape(shape); + + expect(shape.contactOffset).toBeCloseTo(0.04); + expect(collider.sleepThreshold).toBeCloseTo(0.02); + }); +}); diff --git a/tests/src/core/physics/PhysicsMaterial.test.ts b/tests/src/core/physics/PhysicsMaterial.test.ts index 676f917fe3..e277ecf92e 100644 --- a/tests/src/core/physics/PhysicsMaterial.test.ts +++ b/tests/src/core/physics/PhysicsMaterial.test.ts @@ -11,7 +11,7 @@ import { import { WebGLEngine } from "@galacean/engine"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; import { Vector3 } from "@galacean/engine-math"; -import { describe, beforeAll, beforeEach, expect, it } from "vitest"; +import { describe, beforeAll, beforeEach, expect, it, vi } from "vitest"; describe("PhysicsMaterial", () => { let rootEntity: Entity; @@ -79,6 +79,24 @@ describe("PhysicsMaterial", () => { expect(formatValue(boxEntity2.transform.position.y)).eq(0); }); + it("cloned collider shape shares material and releases its constructor material", () => { + const sourceEntity = addBox(new Vector3(1, 1, 1), StaticCollider, new Vector3()); + const sourceMaterial = sourceEntity.getComponent(StaticCollider).shapes[0].material; + const destroySpy = vi.spyOn(PhysicsMaterial.prototype, "destroy"); + const cloneEntity = sourceEntity.clone(); + try { + const cloneMaterial = cloneEntity.getComponent(StaticCollider).shapes[0].material; + expect(cloneMaterial).toBe(sourceMaterial); + expect(destroySpy).toHaveBeenCalledTimes(1); + expect(destroySpy.mock.instances[0]).not.toBe(sourceMaterial); + } finally { + destroySpy.mockRestore(); + cloneEntity.destroy(); + sourceEntity.destroy(); + sourceMaterial.destroy(); + } + }); + it("bounceCombine Average", () => { const boxEntity = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(0, 5, 0)); const ground = addPlane(0, -0.5, 0);