Browse Source

Implemented contact persisted / removal callbacks for CharacterVirtual (#1446)

* Added CharacterID to identify characters after deletion and to deterministically sort contacts between characters
* Implemented OnContactPersisted, OnContactRemoved, OnCharacterContactPersisted and OnCharacterContactRemoved functions
* Fixed Contact::mIsSensorB not being persisted in SaveState
* Fixed Contact::mHadContact not being true for collisions with sensors. They will still be marked as mWasDiscarded to prevent any further interaction.
Jorrit Rouwe 6 tháng trước cách đây
mục cha
commit
0ce6093250

+ 1 - 0
Jolt/Jolt.cmake

@@ -178,6 +178,7 @@ set(JOLT_PHYSICS_SRC_FILES
 	${JOLT_PHYSICS_ROOT}/Physics/Character/Character.h
 	${JOLT_PHYSICS_ROOT}/Physics/Character/CharacterBase.cpp
 	${JOLT_PHYSICS_ROOT}/Physics/Character/CharacterBase.h
+	${JOLT_PHYSICS_ROOT}/Physics/Character/CharacterID.h
 	${JOLT_PHYSICS_ROOT}/Physics/Character/CharacterVirtual.cpp
 	${JOLT_PHYSICS_ROOT}/Physics/Character/CharacterVirtual.h
 	${JOLT_PHYSICS_ROOT}/Physics/Collision/AABoxCast.h

+ 97 - 0
Jolt/Physics/Character/CharacterID.h

@@ -0,0 +1,97 @@
+// Jolt Physics Library (https://github.com/jrouwe/JoltPhysics)
+// SPDX-FileCopyrightText: 2025 Jorrit Rouwe
+// SPDX-License-Identifier: MIT
+
+#pragma once
+
+#include <Jolt/Core/HashCombine.h>
+
+JPH_NAMESPACE_BEGIN
+
+/// ID of a character. Used primarily to identify deleted characters and to sort deterministically.
+class JPH_EXPORT CharacterID
+{
+public:
+	JPH_OVERRIDE_NEW_DELETE
+
+	static constexpr uint32	cInvalidCharacterID = 0xffffffff;	///< The value for an invalid character ID
+
+	/// Construct invalid character ID
+							CharacterID() :
+		mID(cInvalidCharacterID)
+	{
+	}
+
+	/// Construct from index and sequence number combined in a single uint32 (use with care!)
+	explicit				CharacterID(uint32 inID) :
+		mID(inID)
+	{
+	}
+
+	inline uint32			GetValue() const
+	{
+		return mID;
+	}
+
+	/// Check if the ID is valid
+	inline bool				IsInvalid() const
+	{
+		return mID == cInvalidCharacterID;
+	}
+
+	/// Equals check
+	inline bool				operator == (const CharacterID &inRHS) const
+	{
+		return mID == inRHS.mID;
+	}
+
+	/// Not equals check
+	inline bool				operator != (const CharacterID &inRHS) const
+	{
+		return mID != inRHS.mID;
+	}
+
+	/// Smaller than operator, can be used for sorting characters
+	inline bool				operator < (const CharacterID &inRHS) const
+	{
+		return mID < inRHS.mID;
+	}
+
+	/// Greater than operator, can be used for sorting characters
+	inline bool				operator > (const CharacterID &inRHS) const
+	{
+		return mID > inRHS.mID;
+	}
+
+	/// Get the hash for this character ID
+	inline uint64			GetHash() const
+	{
+		return Hash<uint32>{} (mID);
+	}
+
+	/// Generate the next available character ID
+	static CharacterID		sNextCharacterID()
+	{
+		for (;;)
+		{
+			uint32 next = sNextID.fetch_add(1, std::memory_order_relaxed);
+			if (next != cInvalidCharacterID)
+				return CharacterID(next);
+		}
+	}
+
+	/// Set the next available character ID, can be used after destroying all character to prepare for a second deterministic run
+	static void				sSetNextCharacterID(uint32 inNextValue = 1)
+	{
+		sNextID.store(inNextValue, std::memory_order_relaxed);
+	}
+
+private:
+	/// Next character ID to be assigned
+	inline static atomic<uint32> sNextID = 1;
+
+	/// ID value
+	uint32					mID;
+};
+
+JPH_NAMESPACE_END

+ 157 - 33
Jolt/Physics/Character/CharacterVirtual.cpp

@@ -14,6 +14,7 @@
 #include <Jolt/Physics/Collision/Shape/ScaledShape.h>
 #include <Jolt/Physics/Collision/CollisionDispatch.h>
 #include <Jolt/Core/QuickSort.h>
+#include <Jolt/Core/ScopeExit.h>
 #include <Jolt/Geometry/ConvexSupport.h>
 #include <Jolt/Geometry/GJKClosestPoint.h>
 #ifdef JPH_DEBUG_RENDERER
@@ -86,6 +87,7 @@ void CharacterVsCharacterCollisionSimple::CastCharacter(const CharacterVirtual *
 
 CharacterVirtual::CharacterVirtual(const CharacterVirtualSettings *inSettings, RVec3Arg inPosition, QuatArg inRotation, uint64 inUserData, PhysicsSystem *inSystem) :
 	CharacterBase(inSettings, inSystem),
+	mID(inSettings->mID),
 	mBackFaceMode(inSettings->mBackFaceMode),
 	mPredictiveContactDistance(inSettings->mPredictiveContactDistance),
 	mMaxCollisionIterations(inSettings->mMaxCollisionIterations),
@@ -102,6 +104,8 @@ CharacterVirtual::CharacterVirtual(const CharacterVirtualSettings *inSettings, R
 	mRotation(inRotation),
 	mUserData(inUserData)
 {
+	JPH_ASSERT(!mID.IsInvalid());
+
 	// Copy settings
 	SetMaxStrength(inSettings->mMaxStrength);
 	SetMass(inSettings->mMass);
@@ -203,12 +207,13 @@ void CharacterVirtual::sFillContactProperties(const CharacterVirtual *inCharacte
 	outContact.mMaterial = inCollector.GetContext()->GetMaterial(inResult.mSubShapeID2);
 }
 
-void CharacterVirtual::sFillCharacterContactProperties(Contact &outContact, CharacterVirtual *inOtherCharacter, RVec3Arg inBaseOffset, const CollideShapeResult &inResult)
+void CharacterVirtual::sFillCharacterContactProperties(Contact &outContact, const CharacterVirtual *inOtherCharacter, RVec3Arg inBaseOffset, const CollideShapeResult &inResult)
 {
 	outContact.mPosition = inBaseOffset + inResult.mContactPointOn2;
 	outContact.mLinearVelocity = inOtherCharacter->GetLinearVelocity();
 	outContact.mSurfaceNormal = outContact.mContactNormal = -inResult.mPenetrationAxis.NormalizedOr(Vec3::sZero());
 	outContact.mDistance = -inResult.mPenetrationDepth;
+	outContact.mCharacterIDB = inOtherCharacter->GetID();
 	outContact.mCharacterB = inOtherCharacter;
 	outContact.mSubShapeIDB = inResult.mSubShapeID2;
 	outContact.mMotionTypeB = EMotionType::Kinematic; // Other character is kinematic, we can't directly move it
@@ -305,8 +310,8 @@ void CharacterVirtual::ContactCastCollector::AddHit(const ShapeCastResult &inRes
 		&& inResult.mPenetrationAxis.Dot(mDisplacement) > 0.0f) // Ignore penetrations that we're moving away from
 	{
 		// Test if this contact should be ignored
-		for (const IgnoredContact &c : mIgnoredContacts)
-			if (c.mBodyID == inResult.mBodyID2 && c.mSubShapeID == inResult.mSubShapeID2)
+		for (const ContactKey &c : mIgnoredContacts)
+			if (c.mBodyB == inResult.mBodyID2 && c.mSubShapeIDB == inResult.mSubShapeID2)
 				return;
 
 		Contact contact;
@@ -428,14 +433,14 @@ void CharacterVirtual::RemoveConflictingContacts(TempContactList &ioContacts, Ig
 					if (contact1.mDistance < contact2.mDistance)
 					{
 						// Discard the 2nd contact
-						outIgnoredContacts.emplace_back(contact2.mBodyB, contact2.mSubShapeIDB);
+						outIgnoredContacts.emplace_back(contact2);
 						ioContacts.erase(ioContacts.begin() + c2);
 						c2--;
 					}
 					else
 					{
 						// Discard the first contact
-						outIgnoredContacts.emplace_back(contact1.mBodyB, contact1.mSubShapeIDB);
+						outIgnoredContacts.emplace_back(contact1);
 						ioContacts.erase(ioContacts.begin() + c1);
 						c1--;
 						break;
@@ -456,14 +461,38 @@ bool CharacterVirtual::ValidateContact(const Contact &inContact) const
 		return mListener->OnContactValidate(this, inContact.mBodyB, inContact.mSubShapeIDB);
 }
 
-void CharacterVirtual::ContactAdded(const Contact &inContact, CharacterContactSettings &ioSettings) const
+void CharacterVirtual::ContactAdded(const Contact &inContact, CharacterContactSettings &ioSettings)
 {
 	if (mListener != nullptr)
 	{
-		if (inContact.mCharacterB != nullptr)
-			mListener->OnCharacterContactAdded(this, inContact.mCharacterB, inContact.mSubShapeIDB, inContact.mPosition, -inContact.mContactNormal, ioSettings);
+		// Check if we already know this contact
+		ListenerContacts::iterator it = mListenerContacts.find(inContact);
+		if (it != mListenerContacts.end())
+		{
+			// Max 1 contact persisted callback
+			if (++it->second.mCount == 1)
+			{
+				if (inContact.mCharacterB != nullptr)
+					mListener->OnCharacterContactPersisted(this, inContact.mCharacterB, inContact.mSubShapeIDB, inContact.mPosition, -inContact.mContactNormal, ioSettings);
+				else
+					mListener->OnContactPersisted(this, inContact.mBodyB, inContact.mSubShapeIDB, inContact.mPosition, -inContact.mContactNormal, ioSettings);
+				it->second.mSettings = ioSettings;
+			}
+			else
+			{
+				// Reuse the settings from the last call
+				ioSettings = it->second.mSettings;
+			}
+		}
 		else
-			mListener->OnContactAdded(this, inContact.mBodyB, inContact.mSubShapeIDB, inContact.mPosition, -inContact.mContactNormal, ioSettings);
+		{
+			// New contact
+			if (inContact.mCharacterB != nullptr)
+				mListener->OnCharacterContactAdded(this, inContact.mCharacterB, inContact.mSubShapeIDB, inContact.mPosition, -inContact.mContactNormal, ioSettings);
+			else
+				mListener->OnContactAdded(this, inContact.mBodyB, inContact.mSubShapeIDB, inContact.mPosition, -inContact.mContactNormal, ioSettings);
+			mListenerContacts.insert(ListenerContacts::value_type(inContact, ioSettings));
+		}
 	}
 }
 
@@ -538,7 +567,7 @@ bool CharacterVirtual::GetFirstContactForSweep(RVec3Arg inPosition, Vec3Arg inDi
 		mCharacterVsCharacterCollision->CastCharacter(this, start, inDisplacement, settings, base_offset, collector);
 	}
 
-	if (contact.mBodyB.IsInvalid() && contact.mCharacterB == nullptr)
+	if (contact.mBodyB.IsInvalid() && contact.mCharacterIDB.IsInvalid())
 		return false;
 
 	// Store contact
@@ -630,7 +659,7 @@ void CharacterVirtual::DetermineConstraints(TempContactList &inContacts, float i
 	}
 }
 
-bool CharacterVirtual::HandleContact(Vec3Arg inVelocity, Constraint &ioConstraint, float inDeltaTime) const
+bool CharacterVirtual::HandleContact(Vec3Arg inVelocity, Constraint &ioConstraint, float inDeltaTime)
 {
 	Contact &contact = *ioConstraint.mContact;
 
@@ -638,6 +667,9 @@ bool CharacterVirtual::HandleContact(Vec3Arg inVelocity, Constraint &ioConstrain
 	if (!ValidateContact(contact))
 		return false;
 
+	// We collided
+	contact.mHadCollision = true;
+
 	// Send contact added event
 	CharacterContactSettings settings;
 	ContactAdded(contact, settings);
@@ -702,7 +734,7 @@ void CharacterVirtual::SolveConstraints(Vec3Arg inVelocity, float inDeltaTime, f
 #ifdef JPH_DEBUG_RENDERER
 	, bool inDrawConstraints
 #endif // JPH_DEBUG_RENDERER
-	) const
+	)
 {
 	// If there are no constraints we can immediately move to our target
 	if (ioConstraints.empty())
@@ -795,21 +827,16 @@ void CharacterVirtual::SolveConstraints(Vec3Arg inVelocity, float inDeltaTime, f
 			if (c->mContact->mWasDiscarded)
 				continue;
 
-			// Check if we made contact with this before
-			if (!c->mContact->mHadCollision)
+			// Handle the contact
+			if (!c->mContact->mHadCollision
+				&& !HandleContact(velocity, *c, inDeltaTime))
 			{
-				// Handle the contact
-				if (!HandleContact(velocity, *c, inDeltaTime))
-				{
-					// Constraint should be ignored, remove it from the list
-					c->mContact->mWasDiscarded = true;
-
-					// Mark it as ignored for GetFirstContactForSweep
-					ioIgnoredContacts.emplace_back(c->mContact->mBodyB, c->mContact->mSubShapeIDB);
-					continue;
-				}
+				// Constraint should be ignored, remove it from the list
+				c->mContact->mWasDiscarded = true;
 
-				c->mContact->mHadCollision = true;
+				// Mark it as ignored for GetFirstContactForSweep
+				ioIgnoredContacts.emplace_back(*c->mContact);
+				continue;
 			}
 
 			// Cancel velocity of constraint if it cannot push the character
@@ -970,8 +997,12 @@ void CharacterVirtual::UpdateSupportingContact(bool inSkipContactVelocityCheck,
 			&& c.mDistance < mCollisionTolerance
 			&& (inSkipContactVelocityCheck || c.mSurfaceNormal.Dot(mLinearVelocity - c.mLinearVelocity) <= 1.0e-4f))
 		{
-			if (ValidateContact(c) && !c.mIsSensorB)
+			if (ValidateContact(c))
+			{
+				CharacterContactSettings dummy;
+				ContactAdded(c, dummy);
 				c.mHadCollision = true;
+			}
 			else
 				c.mWasDiscarded = true;
 		}
@@ -990,7 +1021,7 @@ void CharacterVirtual::UpdateSupportingContact(bool inSkipContactVelocityCheck,
 	const Contact *deepest_contact = nullptr;
 	float smallest_distance = FLT_MAX;
 	for (const Contact &c : mActiveContacts)
-		if (c.mHadCollision)
+		if (c.mHadCollision && !c.mWasDiscarded)
 		{
 			// Calculate the angle between the plane normal and the up direction
 			float cos_angle = c.mSurfaceNormal.Dot(mUp);
@@ -1138,16 +1169,20 @@ void CharacterVirtual::UpdateSupportingContact(bool inSkipContactVelocityCheck,
 
 void CharacterVirtual::StoreActiveContacts(const TempContactList &inContacts, TempAllocator &inAllocator)
 {
+	StartTrackingContactChanges();
+
 	mActiveContacts.assign(inContacts.begin(), inContacts.end());
 
 	UpdateSupportingContact(true, inAllocator);
+
+	FinishTrackingContactChanges();
 }
 
 void CharacterVirtual::MoveShape(RVec3 &ioPosition, Vec3Arg inVelocity, float inDeltaTime, ContactList *outActiveContacts, const BroadPhaseLayerFilter &inBroadPhaseLayerFilter, const ObjectLayerFilter &inObjectLayerFilter, const BodyFilter &inBodyFilter, const ShapeFilter &inShapeFilter, TempAllocator &inAllocator
 #ifdef JPH_DEBUG_RENDERER
 	, bool inDrawConstraints
 #endif // JPH_DEBUG_RENDERER
-	) const
+	)
 {
 	JPH_DET_LOG("CharacterVirtual::MoveShape: pos: " << ioPosition << " vel: " << inVelocity << " dt: " << inDeltaTime);
 
@@ -1248,6 +1283,7 @@ Vec3 CharacterVirtual::CancelVelocityTowardsSteepSlopes(Vec3Arg inDesiredVelocit
 	Vec3 desired_velocity = inDesiredVelocity;
 	for (const Contact &c : mActiveContacts)
 		if (c.mHadCollision
+			&& !c.mWasDiscarded
 			&& IsSlopeTooSteep(c.mSurfaceNormal))
 		{
 			// Note that we use the contact normal to allow for better sliding as the surface normal may be in the opposite direction of movement.
@@ -1264,12 +1300,72 @@ Vec3 CharacterVirtual::CancelVelocityTowardsSteepSlopes(Vec3Arg inDesiredVelocit
 	return desired_velocity;
 }
 
+void CharacterVirtual::StartTrackingContactChanges()
+{
+	// Check if we're starting for the first time
+	if (++mTrackingContactChanges > 1)
+		return;
+
+	// No need to track anything if we don't have a listener
+	JPH_ASSERT(mListenerContacts.empty());
+	if (mListener == nullptr)
+		return;
+
+	// Mark all current contacts as not seen
+	mListenerContacts.reserve(ListenerContacts::size_type(mActiveContacts.size()));
+	for (const Contact &c : mActiveContacts)
+		if (c.mHadCollision)
+			mListenerContacts.insert(ListenerContacts::value_type(c, ListenerContactValue()));
+}
+
+void CharacterVirtual::FinishTrackingContactChanges()
+{
+	// Check if we have to do anything
+	int count = --mTrackingContactChanges;
+	JPH_ASSERT(count >= 0, "Called FinishTrackingContactChanges more times than StartTrackingContactChanges");
+	if (count > 0)
+		return;
+
+	// No need to track anything if we don't have a listener
+	if (mListener == nullptr)
+		return;
+
+	// Since we can do multiple operations (e.g. Update followed by WalkStairs)
+	// we can end up with contacts that were marked as active to the listener but that are
+	// no longer in the active contact list. We go over all contacts and mark them again
+	// to ensure that these lists are in sync.
+	for (ListenerContacts::value_type &c : mListenerContacts)
+		c.second.mCount = 0;
+	for (const Contact &c : mActiveContacts)
+		if (c.mHadCollision)
+		{
+			ListenerContacts::iterator it = mListenerContacts.find(c);
+			JPH_ASSERT(it != mListenerContacts.end());
+			it->second.mCount = 1;
+		}
+
+	// Call contact removal callbacks
+	for (ListenerContacts::iterator it = mListenerContacts.begin(); it != mListenerContacts.end(); ++it)
+		if (it->second.mCount == 0)
+		{
+			const ContactKey &c = it->first;
+			if (!c.mCharacterIDB.IsInvalid())
+				mListener->OnCharacterContactRemoved(this, c.mCharacterIDB, c.mSubShapeIDB);
+			else
+				mListener->OnContactRemoved(this, c.mBodyB, c.mSubShapeIDB);
+		}
+	mListenerContacts.ClearAndKeepMemory();
+}
+
 void CharacterVirtual::Update(float inDeltaTime, Vec3Arg inGravity, const BroadPhaseLayerFilter &inBroadPhaseLayerFilter, const ObjectLayerFilter &inObjectLayerFilter, const BodyFilter &inBodyFilter, const ShapeFilter &inShapeFilter, TempAllocator &inAllocator)
 {
 	// If there's no delta time, we don't need to do anything
 	if (inDeltaTime <= 0.0f)
 		return;
 
+	StartTrackingContactChanges();
+	JPH_SCOPE_EXIT([this]() { FinishTrackingContactChanges(); });
+
 	// Remember delta time for checking if we're supported by the ground
 	mLastDeltaTime = inDeltaTime;
 
@@ -1416,6 +1512,7 @@ bool CharacterVirtual::CanWalkStairs(Vec3Arg inLinearVelocity) const
 	// Check contacts for steep slopes
 	for (const Contact &c : mActiveContacts)
 		if (c.mHadCollision
+			&& !c.mWasDiscarded
 			&& c.mSurfaceNormal.Dot(horizontal_velocity - c.mLinearVelocity) < 0.0f // Pushing into the contact
 			&& IsSlopeTooSteep(c.mSurfaceNormal)) // Slope too steep
 			return true;
@@ -1425,6 +1522,9 @@ bool CharacterVirtual::CanWalkStairs(Vec3Arg inLinearVelocity) const
 
 bool CharacterVirtual::WalkStairs(float inDeltaTime, Vec3Arg inStepUp, Vec3Arg inStepForward, Vec3Arg inStepForwardTest, Vec3Arg inStepDownExtra, const BroadPhaseLayerFilter &inBroadPhaseLayerFilter, const ObjectLayerFilter &inObjectLayerFilter, const BodyFilter &inBodyFilter, const ShapeFilter &inShapeFilter, TempAllocator &inAllocator)
 {
+	StartTrackingContactChanges();
+	JPH_SCOPE_EXIT([this]() { FinishTrackingContactChanges(); });
+
 	// Move up
 	Vec3 up = inStepUp;
 	Contact contact;
@@ -1453,6 +1553,7 @@ bool CharacterVirtual::WalkStairs(float inDeltaTime, Vec3Arg inStepUp, Vec3Arg i
 	steep_slope_normals.reserve(mActiveContacts.size());
 	for (const Contact &c : mActiveContacts)
 		if (c.mHadCollision
+			&& !c.mWasDiscarded
 			&& c.mSurfaceNormal.Dot(horizontal_velocity - c.mLinearVelocity) < 0.0f // Pushing into the contact
 			&& IsSlopeTooSteep(c.mSurfaceNormal)) // Slope too steep
 			steep_slope_normals.push_back(c.mSurfaceNormal);
@@ -1562,6 +1663,9 @@ bool CharacterVirtual::WalkStairs(float inDeltaTime, Vec3Arg inStepUp, Vec3Arg i
 
 bool CharacterVirtual::StickToFloor(Vec3Arg inStepDown, const BroadPhaseLayerFilter &inBroadPhaseLayerFilter, const ObjectLayerFilter &inObjectLayerFilter, const BodyFilter &inBodyFilter, const ShapeFilter &inShapeFilter, TempAllocator &inAllocator)
 {
+	StartTrackingContactChanges();
+	JPH_SCOPE_EXIT([this]() { FinishTrackingContactChanges(); });
+
 	// Try to find the floor
 	Contact contact;
 	IgnoredContactList dummy_ignored_contacts(inAllocator);
@@ -1587,6 +1691,9 @@ bool CharacterVirtual::StickToFloor(Vec3Arg inStepDown, const BroadPhaseLayerFil
 
 void CharacterVirtual::ExtendedUpdate(float inDeltaTime, Vec3Arg inGravity, const ExtendedUpdateSettings &inSettings, const BroadPhaseLayerFilter &inBroadPhaseLayerFilter, const ObjectLayerFilter &inObjectLayerFilter, const BodyFilter &inBodyFilter, const ShapeFilter &inShapeFilter, TempAllocator &inAllocator)
 {
+	StartTrackingContactChanges();
+	JPH_SCOPE_EXIT([this]() { FinishTrackingContactChanges(); });
+
 	// Update the velocity
 	Vec3 desired_velocity = mLinearVelocity;
 	mLinearVelocity = CancelVelocityTowardsSteepSlopes(desired_velocity);
@@ -1663,37 +1770,54 @@ void CharacterVirtual::ExtendedUpdate(float inDeltaTime, Vec3Arg inGravity, cons
 	}
 }
 
+void CharacterVirtual::ContactKey::SaveState(StateRecorder &inStream) const
+{
+	inStream.Write(mBodyB);
+	inStream.Write(mCharacterIDB);
+	inStream.Write(mSubShapeIDB);
+}
+
+void CharacterVirtual::ContactKey::RestoreState(StateRecorder &inStream)
+{
+	inStream.Read(mBodyB);
+	inStream.Read(mCharacterIDB);
+	inStream.Read(mSubShapeIDB);
+}
+
 void CharacterVirtual::Contact::SaveState(StateRecorder &inStream) const
 {
+	ContactKey::SaveState(inStream);
+
 	inStream.Write(mPosition);
 	inStream.Write(mLinearVelocity);
 	inStream.Write(mContactNormal);
 	inStream.Write(mSurfaceNormal);
 	inStream.Write(mDistance);
 	inStream.Write(mFraction);
-	inStream.Write(mBodyB);
-	inStream.Write(mSubShapeIDB);
 	inStream.Write(mMotionTypeB);
+	inStream.Write(mIsSensorB);
 	inStream.Write(mHadCollision);
 	inStream.Write(mWasDiscarded);
 	inStream.Write(mCanPushCharacter);
-	// Cannot store user data (may be a pointer) and material
+	// Cannot store pointers to character B, user data and material
 }
 
 void CharacterVirtual::Contact::RestoreState(StateRecorder &inStream)
 {
+	ContactKey::RestoreState(inStream);
+
 	inStream.Read(mPosition);
 	inStream.Read(mLinearVelocity);
 	inStream.Read(mContactNormal);
 	inStream.Read(mSurfaceNormal);
 	inStream.Read(mDistance);
 	inStream.Read(mFraction);
-	inStream.Read(mBodyB);
-	inStream.Read(mSubShapeIDB);
 	inStream.Read(mMotionTypeB);
+	inStream.Read(mIsSensorB);
 	inStream.Read(mHadCollision);
 	inStream.Read(mWasDiscarded);
 	inStream.Read(mCanPushCharacter);
+	mCharacterB = nullptr; // Cannot restore character B
 	mUserData = 0; // Cannot restore user data
 	mMaterial = PhysicsMaterial::sDefault; // Cannot restore material
 }

+ 123 - 29
Jolt/Physics/Character/CharacterVirtual.h

@@ -5,6 +5,7 @@
 #pragma once
 
 #include <Jolt/Physics/Character/CharacterBase.h>
+#include <Jolt/Physics/Character/CharacterID.h>
 #include <Jolt/Physics/Body/MotionType.h>
 #include <Jolt/Physics/Body/BodyFilter.h>
 #include <Jolt/Physics/Collision/BroadPhase/BroadPhaseLayer.h>
@@ -23,6 +24,9 @@ class JPH_EXPORT CharacterVirtualSettings : public CharacterBaseSettings
 public:
 	JPH_OVERRIDE_NEW_DELETE
 
+	/// ID to give to this character. This is used for deterministically sorting and as an identifier to represent the character in the contact removal callback.
+	CharacterID							mID = CharacterID::sNextCharacterID();
+
 	/// Character mass (kg). Used to push down objects with gravity when the character is standing on top.
 	float								mMass = 70.0f;
 
@@ -88,7 +92,7 @@ public:
 	/// Same as OnContactValidate but when colliding with a CharacterVirtual
 	virtual bool						OnCharacterContactValidate(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2) { return true; }
 
-	/// Called whenever the character collides with a body.
+	/// Called whenever the character collides with a body for the first time.
 	/// @param inCharacter Character that is being solved
 	/// @param inBodyID2 Body ID of body that is being hit
 	/// @param inSubShapeID2 Sub shape ID of shape that is being hit
@@ -97,9 +101,32 @@ public:
 	/// @param ioSettings Settings returned by the contact callback to indicate how the character should behave
 	virtual void						OnContactAdded(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) { /* Default do nothing */ }
 
+	/// Called whenever the character persists colliding with a body.
+	/// @param inCharacter Character that is being solved
+	/// @param inBodyID2 Body ID of body that is being hit
+	/// @param inSubShapeID2 Sub shape ID of shape that is being hit
+	/// @param inContactPosition World space contact position
+	/// @param inContactNormal World space contact normal
+	/// @param ioSettings Settings returned by the contact callback to indicate how the character should behave
+	virtual void						OnContactPersisted(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) { /* Default do nothing */ }
+
+	/// Called whenever the character loses contact with a body.
+	/// Note that there is no guarantee that the body or its sub shape still exists at this point. The body may have been deleted since the last update.
+	/// @param inCharacter Character that is being solved
+	/// @param inBodyID2 Body ID of body that is being hit
+	/// @param inSubShapeID2 Sub shape ID of shape that is being hit
+	virtual void						OnContactRemoved(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2) { /* Default do nothing */ }
+
 	/// Same as OnContactAdded but when colliding with a CharacterVirtual
 	virtual void						OnCharacterContactAdded(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) { /* Default do nothing */ }
 
+	/// Same as OnContactPersisted but when colliding with a CharacterVirtual
+	virtual void						OnCharacterContactPersisted(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) { /* Default do nothing */ }
+
+	/// Same as OnContactRemoved but when colliding with a CharacterVirtual
+	/// Note that inOtherCharacterID can be the ID of a character that has been deleted. This happens if the character was in contact with this character during the last update, but has been deleted since.
+	virtual void						OnCharacterContactRemoved(const CharacterVirtual *inCharacter, const CharacterID &inOtherCharacterID, const SubShapeID &inSubShapeID2) { /* Default do nothing */ }
+
 	/// Called whenever a contact is being used by the solver. Allows the listener to override the resulting character velocity (e.g. by preventing sliding along certain surfaces).
 	/// @param inCharacter Character that is being solved
 	/// @param inBodyID2 Body ID of body that is being hit
@@ -184,6 +211,9 @@ public:
 	/// Destructor
 	virtual								~CharacterVirtual() override;
 
+	/// The ID of this character
+	CharacterID							GetID() const											{ return mID; }
+
 	/// Set the contact listener
 	void								SetListener(CharacterContactListener *inListener)		{ mListener = inListener; }
 
@@ -270,6 +300,15 @@ public:
 	/// @return A new velocity vector that won't make the character move up steep slopes
 	Vec3								CancelVelocityTowardsSteepSlopes(Vec3Arg inDesiredVelocity) const;
 
+	/// This function is internally called by Update, WalkStairs, StickToFloor and ExtendedUpdate and is responsible for tracking if contacts are added, persisted or removed.
+	/// If you want to do multiple operations on a character (e.g. first Update then WalkStairs), you can surround the code with a StartTrackingContactChanges and FinishTrackingContactChanges pair
+	/// to only receive a single callback per contact on the CharacterContactListener. If you don't do this then you could for example receive a contact added callback during the Update and a
+	/// contact persisted callback during WalkStairs.
+	void								StartTrackingContactChanges();
+
+	/// This call triggers contact removal callbacks and is used in conjunction with StartTrackingContactChanges.
+	void								FinishTrackingContactChanges();
+
 	/// This is the main update function. It moves the character according to its current velocity (the character is similar to a kinematic body in the sense
 	/// that you set the velocity and the character will follow unless collision is blocking the way). Note it's your own responsibility to apply gravity to the character velocity!
 	/// Different surface materials (like ice) can be emulated by getting the ground material and adjusting the velocity and/or the max slope angle accordingly every frame.
@@ -387,15 +426,53 @@ public:
 	static inline bool					sDrawStickToFloor = false;								///< Draw the state of the stick to floor algorithm
 #endif
 
-	// Encapsulates a collision contact
-	struct Contact
+	/// Uniquely identifies a contact between a character and another body or character
+	class ContactKey
 	{
+	public:
+		/// Constructor
+										ContactKey() = default;
+										ContactKey(const ContactKey &inContact) = default;
+										ContactKey(const BodyID &inBodyB, const SubShapeID &inSubShapeID) : mBodyB(inBodyB), mSubShapeIDB(inSubShapeID) { }
+										ContactKey(const CharacterID &inCharacterIDB, const SubShapeID &inSubShapeID) : mCharacterIDB(inCharacterIDB), mSubShapeIDB(inSubShapeID) { }
+		ContactKey &					operator = (const ContactKey &inContact) = default;
+
+		/// Checks if two contacts refer to the same body (or virtual character)
+		inline bool						IsSameBody(const ContactKey &inOther) const				{ return mBodyB == inOther.mBodyB && mCharacterIDB == inOther.mCharacterIDB; }
+
+		/// Equality operator
+		bool							operator == (const ContactKey &inRHS) const
+		{
+			return mBodyB == inRHS.mBodyB && mCharacterIDB == inRHS.mCharacterIDB && mSubShapeIDB == inRHS.mSubShapeIDB;
+		}
+
+		bool							operator != (const ContactKey &inRHS) const
+		{
+			return !(*this == inRHS);
+		}
+
+		/// Hash of this structure
+		uint64							GetHash() const
+		{
+			static_assert(sizeof(BodyID) + sizeof(CharacterID) + sizeof(SubShapeID) == sizeof(ContactKey), "No padding expected");
+			return HashBytes(this, sizeof(ContactKey));
+		}
+
 		// Saving / restoring state for replay
 		void							SaveState(StateRecorder &inStream) const;
 		void							RestoreState(StateRecorder &inStream);
 
-		// Checks if two contacts refer to the same body (or virtual character)
-		inline bool						IsSameBody(const Contact &inOther) const				{ return mBodyB == inOther.mBodyB && mCharacterB == inOther.mCharacterB; }
+		BodyID							mBodyB;													///< ID of body we're colliding with (if not invalid)
+		CharacterID						mCharacterIDB;											///< Character we're colliding with (if not invalid)
+		SubShapeID						mSubShapeIDB;											///< Sub shape ID of body or character we're colliding with
+	};
+
+	/// Encapsulates a collision contact
+	struct Contact : public ContactKey
+	{
+		// Saving / restoring state for replay
+		void							SaveState(StateRecorder &inStream) const;
+		void							RestoreState(StateRecorder &inStream);
 
 		RVec3							mPosition;												///< Position where the character makes contact
 		Vec3							mLinearVelocity;										///< Velocity of the contact point
@@ -403,15 +480,13 @@ public:
 		Vec3							mSurfaceNormal;											///< Surface normal of the contact
 		float							mDistance;												///< Distance to the contact <= 0 means that it is an actual contact, > 0 means predictive
 		float							mFraction;												///< Fraction along the path where this contact takes place
-		BodyID							mBodyB;													///< ID of body we're colliding with (if not invalid)
-		CharacterVirtual *				mCharacterB = nullptr;									///< Character we're colliding with (if not null)
-		SubShapeID						mSubShapeIDB;											///< Sub shape ID of body we're colliding with
 		EMotionType						mMotionTypeB;											///< Motion type of B, used to determine the priority of the contact
 		bool							mIsSensorB;												///< If B is a sensor
+		const CharacterVirtual *		mCharacterB = nullptr;									///< Character we're colliding with (if not nullptr). Note that this may be a dangling pointer when accessed through GetActiveContacts(), use mCharacterIDB instead.
 		uint64							mUserData;												///< User data of B
 		const PhysicsMaterial *			mMaterial;												///< Material of B
 		bool							mHadCollision = false;									///< If the character actually collided with the contact (can be false if a predictive contact never becomes a real one)
-		bool							mWasDiscarded = false;									///< If the contact validate callback chose to discard this contact
+		bool							mWasDiscarded = false;									///< If the contact validate callback chose to discard this contact or when the body is a sensor
 		bool							mCanPushCharacter = true;								///< When true, the velocity of the contact point can push the character
 	};
 
@@ -419,9 +494,10 @@ public:
 	using ContactList = Array<Contact>;
 
 	/// Access to the internal list of contacts that the character has found.
+	/// Note that only contacts that have their mHadCollision flag set are actual contacts.
 	const ContactList &					GetActiveContacts() const								{ return mActiveContacts; }
 
-	/// Check if the character is currently in contact with or has collided with another body in the last time step
+	/// Check if the character is currently in contact with or has collided with another body in the last operation (e.g. Update or WalkStairs)
 	bool								HasCollidedWith(const BodyID &inBody) const
 	{
 		for (const CharacterVirtual::Contact &c : mActiveContacts)
@@ -430,15 +506,21 @@ public:
 		return false;
 	}
 
-	/// Check if the character is currently in contact with or has collided with another character in the last time step
-	bool								HasCollidedWith(const CharacterVirtual *inCharacter) const
+	/// Check if the character is currently in contact with or has collided with another character in the last time step (e.g. Update or WalkStairs)
+	bool								HasCollidedWith(const CharacterID &inCharacterID) const
 	{
 		for (const CharacterVirtual::Contact &c : mActiveContacts)
-			if (c.mHadCollision && c.mCharacterB == inCharacter)
+			if (c.mHadCollision && c.mCharacterIDB == inCharacterID)
 				return true;
 		return false;
 	}
 
+	/// Check if the character is currently in contact with or has collided with another character in the last time step (e.g. Update or WalkStairs)
+	bool								HasCollidedWith(const CharacterVirtual *inCharacter) const
+	{
+		return HasCollidedWith(inCharacter->GetID());
+	}
+
 private:
 	// Sorting predicate for making contact order deterministic
 	struct ContactOrderingPredicate
@@ -448,21 +530,14 @@ private:
 			if (inLHS.mBodyB != inRHS.mBodyB)
 				return inLHS.mBodyB < inRHS.mBodyB;
 
+			if (inLHS.mCharacterIDB != inRHS.mCharacterIDB)
+				return inLHS.mCharacterIDB < inRHS.mCharacterIDB;
+
 			return inLHS.mSubShapeIDB.GetValue() < inRHS.mSubShapeIDB.GetValue();
 		}
 	};
 
-	// A contact that needs to be ignored
-	struct IgnoredContact
-	{
-										IgnoredContact() = default;
-										IgnoredContact(const BodyID &inBodyID, const SubShapeID &inSubShapeID) : mBodyID(inBodyID), mSubShapeID(inSubShapeID) { }
-
-		BodyID							mBodyID;												///< ID of body we're colliding with
-		SubShapeID						mSubShapeID;											///< Sub shape of body we're colliding with
-	};
-
-	using IgnoredContactList = Array<IgnoredContact, STLTempAllocator<IgnoredContact>>;
+	using IgnoredContactList = Array<ContactKey, STLTempAllocator<ContactKey>>;
 
 	// A constraint that limits the movement of the character
 	struct Constraint
@@ -521,20 +596,20 @@ private:
 	// Helper function to convert a Jolt collision result into a contact
 	template <class taCollector>
 	inline static void					sFillContactProperties(const CharacterVirtual *inCharacter, Contact &outContact, const Body &inBody, Vec3Arg inUp, RVec3Arg inBaseOffset, const taCollector &inCollector, const CollideShapeResult &inResult);
-	inline static void					sFillCharacterContactProperties(Contact &outContact, CharacterVirtual *inOtherCharacter, RVec3Arg inBaseOffset, const CollideShapeResult &inResult);
+	inline static void					sFillCharacterContactProperties(Contact &outContact, const CharacterVirtual *inOtherCharacter, RVec3Arg inBaseOffset, const CollideShapeResult &inResult);
 
 	// Move the shape from ioPosition and try to displace it by inVelocity * inDeltaTime, this will try to slide the shape along the world geometry
 	void								MoveShape(RVec3 &ioPosition, Vec3Arg inVelocity, float inDeltaTime, ContactList *outActiveContacts, const BroadPhaseLayerFilter &inBroadPhaseLayerFilter, const ObjectLayerFilter &inObjectLayerFilter, const BodyFilter &inBodyFilter, const ShapeFilter &inShapeFilter, TempAllocator &inAllocator
 	#ifdef JPH_DEBUG_RENDERER
 		, bool inDrawConstraints = false
 	#endif // JPH_DEBUG_RENDERER
-		) const;
+		);
 
 	// Ask the callback if inContact is a valid contact point
 	bool								ValidateContact(const Contact &inContact) const;
 
 	// Trigger the contact callback for inContact and get the contact settings
-	void								ContactAdded(const Contact &inContact, CharacterContactSettings &ioSettings) const;
+	void								ContactAdded(const Contact &inContact, CharacterContactSettings &ioSettings);
 
 	// Tests the shape for collision around inPosition
 	void								GetContactsAtPosition(RVec3Arg inPosition, Vec3Arg inMovementDirection, const Shape *inShape, TempContactList &outContacts, const BroadPhaseLayerFilter &inBroadPhaseLayerFilter, const ObjectLayerFilter &inObjectLayerFilter, const BodyFilter &inBodyFilter, const ShapeFilter &inShapeFilter) const;
@@ -550,7 +625,7 @@ private:
 	#ifdef JPH_DEBUG_RENDERER
 		, bool inDrawConstraints = false
 	#endif // JPH_DEBUG_RENDERER
-		) const;
+		);
 
 	// Get the velocity of a body adjusted by the contact listener
 	void								GetAdjustedBodyVelocity(const Body& inBody, Vec3 &outLinearVelocity, Vec3 &outAngularVelocity) const;
@@ -561,7 +636,7 @@ private:
 	Vec3								CalculateCharacterGroundVelocity(RVec3Arg inCenterOfMass, Vec3Arg inLinearVelocity, Vec3Arg inAngularVelocity, float inDeltaTime) const;
 
 	// Handle contact with physics object that we're colliding against
-	bool								HandleContact(Vec3Arg inVelocity, Constraint &ioConstraint, float inDeltaTime) const;
+	bool								HandleContact(Vec3Arg inVelocity, Constraint &ioConstraint, float inDeltaTime);
 
 	// Does a swept test of the shape from inPosition with displacement inDisplacement, returns true if there was a collision
 	bool								GetFirstContactForSweep(RVec3Arg inPosition, Vec3Arg inDisplacement, Contact &outContact, const IgnoredContactList &inIgnoredContacts, const BroadPhaseLayerFilter &inBroadPhaseLayerFilter, const ObjectLayerFilter &inObjectLayerFilter, const BodyFilter &inBodyFilter, const ShapeFilter &inShapeFilter) const;
@@ -590,6 +665,9 @@ private:
 	// Move the inner rigid body to the current position
 	void								UpdateInnerBodyTransform();
 
+	// ID
+	CharacterID							mID;
+
 	// Our main listener for contacts
 	CharacterContactListener *			mListener = nullptr;
 
@@ -630,6 +708,22 @@ private:
 	// List of contacts that were active in the last frame
 	ContactList							mActiveContacts;
 
+	// Remembers how often we called StartTrackingContactChanges
+	int									mTrackingContactChanges = 0;
+
+	// View from a contact listener perspective on which contacts have been added/removed
+	struct ListenerContactValue
+	{
+										ListenerContactValue() = default;
+		explicit						ListenerContactValue(const CharacterContactSettings &inSettings) : mSettings(inSettings) { }
+
+		CharacterContactSettings		mSettings;
+		int								mCount = 0;
+	};
+
+	using ListenerContacts = UnorderedMap<ContactKey, ListenerContactValue>;
+	ListenerContacts					mListenerContacts;
+
 	// Remembers the delta time of the last update
 	float								mLastDeltaTime = 1.0f / 60.0f;
 

+ 107 - 2
Samples/Tests/Character/CharacterVirtualTest.cpp

@@ -8,9 +8,12 @@
 #include <Jolt/Physics/Collision/Shape/CapsuleShape.h>
 #include <Jolt/Physics/Collision/Shape/RotatedTranslatedShape.h>
 #include <Layers.h>
+#include <Utils/Log.h>
 #include <Renderer/DebugRendererImp.h>
 #include <Application/DebugUI.h>
 
+//#define CHARACTER_TRACE_CONTACTS
+
 JPH_IMPLEMENT_RTTI_VIRTUAL(CharacterVirtualTest)
 {
 	JPH_ADD_BASE_CLASS(CharacterVirtualTest, CharacterBaseTest)
@@ -86,6 +89,18 @@ void CharacterVirtualTest::PrePhysicsUpdate(const PreUpdateParams &inParams)
 		{ },
 		*mTempAllocator);
 
+#ifdef JPH_ENABLE_ASSERTS
+	// Validate that our contact list is in sync with that of the character
+	uint num_contacts = 0;
+	for (const CharacterVirtual::Contact &c : mCharacter->GetActiveContacts())
+		if (c.mHadCollision)
+		{
+			JPH_ASSERT(std::find(mActiveContacts.begin(), mActiveContacts.end(), c) != mActiveContacts.end());
+			num_contacts++;
+		}
+	JPH_ASSERT(num_contacts == mActiveContacts.size());
+#endif
+
 	// Calculate effective velocity
 	RVec3 new_position = mCharacter->GetPosition();
 	Vec3 velocity = Vec3(new_position - old_position) / inParams.mDeltaTime;
@@ -209,6 +224,7 @@ void CharacterVirtualTest::SaveState(StateRecorder &inStream) const
 
 	inStream.Write(mAllowSliding);
 	inStream.Write(mDesiredVelocity);
+	inStream.Write(mActiveContacts);
 }
 
 void CharacterVirtualTest::RestoreState(StateRecorder &inStream)
@@ -226,6 +242,7 @@ void CharacterVirtualTest::RestoreState(StateRecorder &inStream)
 
 	inStream.Read(mAllowSliding);
 	inStream.Read(mDesiredVelocity);
+	inStream.Read(mActiveContacts);
 }
 
 void CharacterVirtualTest::OnAdjustBodyVelocity(const CharacterVirtual *inCharacter, const Body &inBody2, Vec3 &ioLinearVelocity, Vec3 &ioAngularVelocity)
@@ -235,7 +252,7 @@ void CharacterVirtualTest::OnAdjustBodyVelocity(const CharacterVirtual *inCharac
 		ioLinearVelocity += Vec3(0, 0, 2);
 }
 
-void CharacterVirtualTest::OnContactAdded(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings)
+void CharacterVirtualTest::OnContactCommon(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings)
 {
 	// Draw a box around the character when it enters the sensor
 	if (inBodyID2 == mSensorBody)
@@ -260,7 +277,51 @@ void CharacterVirtualTest::OnContactAdded(const CharacterVirtual *inCharacter, c
 		mAllowSliding = true;
 }
 
-void CharacterVirtualTest::OnCharacterContactAdded(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings)
+void CharacterVirtualTest::OnContactAdded(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings)
+{
+	OnContactCommon(inCharacter, inBodyID2, inSubShapeID2, inContactPosition, inContactNormal, ioSettings);
+
+	if (inCharacter == mCharacter)
+	{
+	#ifdef CHARACTER_TRACE_CONTACTS
+		Trace("Contact added with body %08x, sub shape %08x", inBodyID2.GetIndexAndSequenceNumber(), inSubShapeID2.GetValue());
+	#endif
+		CharacterVirtual::ContactKey c(inBodyID2, inSubShapeID2);
+		if (std::find(mActiveContacts.begin(), mActiveContacts.end(), c) != mActiveContacts.end())
+			FatalError("Got an add contact that should have been a persisted contact");
+		mActiveContacts.push_back(c);
+	}
+}
+
+void CharacterVirtualTest::OnContactPersisted(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings)
+{
+	OnContactCommon(inCharacter, inBodyID2, inSubShapeID2, inContactPosition, inContactNormal, ioSettings);
+
+	if (inCharacter == mCharacter)
+	{
+	#ifdef CHARACTER_TRACE_CONTACTS
+		Trace("Contact persisted with body %08x, sub shape %08x", inBodyID2.GetIndexAndSequenceNumber(), inSubShapeID2.GetValue());
+	#endif
+		if (std::find(mActiveContacts.begin(), mActiveContacts.end(), CharacterVirtual::ContactKey(inBodyID2, inSubShapeID2)) == mActiveContacts.end())
+			FatalError("Got a persisted contact that should have been an add contact");
+	}
+}
+
+void CharacterVirtualTest::OnContactRemoved(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2)
+{
+	if (inCharacter == mCharacter)
+	{
+	#ifdef CHARACTER_TRACE_CONTACTS
+		Trace("Contact removed with body %08x, sub shape %08x", inBodyID2.GetIndexAndSequenceNumber(), inSubShapeID2.GetValue());
+	#endif
+		ContactSet::iterator it = std::find(mActiveContacts.begin(), mActiveContacts.end(), CharacterVirtual::ContactKey(inBodyID2, inSubShapeID2));
+		if (it == mActiveContacts.end())
+			FatalError("Got a remove contact that has not been added");
+		mActiveContacts.erase(it);
+	}
+}
+
+void CharacterVirtualTest::OnCharacterContactCommon(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings)
 {
 	// Characters can only be pushed in their own update
 	if (sPlayerCanPushOtherCharacters)
@@ -275,6 +336,50 @@ void CharacterVirtualTest::OnCharacterContactAdded(const CharacterVirtual *inCha
 		mAllowSliding = true;
 }
 
+void CharacterVirtualTest::OnCharacterContactAdded(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings)
+{
+	OnCharacterContactCommon(inCharacter, inOtherCharacter, inSubShapeID2, inContactPosition, inContactNormal, ioSettings);
+
+	if (inCharacter == mCharacter)
+	{
+	#ifdef CHARACTER_TRACE_CONTACTS
+		Trace("Contact added with character %08x, sub shape %08x", inOtherCharacter->GetID().GetValue(), inSubShapeID2.GetValue());
+	#endif
+		CharacterVirtual::ContactKey c(inOtherCharacter->GetID(), inSubShapeID2);
+		if (std::find(mActiveContacts.begin(), mActiveContacts.end(), c) != mActiveContacts.end())
+			FatalError("Got an add contact that should have been a persisted contact");
+		mActiveContacts.push_back(c);
+	}
+}
+
+void CharacterVirtualTest::OnCharacterContactPersisted(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings)
+{
+	OnCharacterContactCommon(inCharacter, inOtherCharacter, inSubShapeID2, inContactPosition, inContactNormal, ioSettings);
+
+	if (inCharacter == mCharacter)
+	{
+	#ifdef CHARACTER_TRACE_CONTACTS
+		Trace("Contact persisted with character %08x, sub shape %08x", inOtherCharacter->GetID().GetValue(), inSubShapeID2.GetValue());
+	#endif
+		if (std::find(mActiveContacts.begin(), mActiveContacts.end(), CharacterVirtual::ContactKey(inOtherCharacter->GetID(), inSubShapeID2)) == mActiveContacts.end())
+			FatalError("Got a persisted contact that should have been an add contact");
+	}
+}
+
+void CharacterVirtualTest::OnCharacterContactRemoved(const CharacterVirtual *inCharacter, const CharacterID &inOtherCharacterID, const SubShapeID &inSubShapeID2)
+{
+	if (inCharacter == mCharacter)
+	{
+	#ifdef CHARACTER_TRACE_CONTACTS
+		Trace("Contact removed with character %08x, sub shape %08x", inOtherCharacterID.GetValue(), inSubShapeID2.GetValue());
+	#endif
+		ContactSet::iterator it = std::find(mActiveContacts.begin(), mActiveContacts.end(), CharacterVirtual::ContactKey(inOtherCharacterID, inSubShapeID2));
+		if (it == mActiveContacts.end())
+			FatalError("Got a remove contact that has not been added");
+		mActiveContacts.erase(it);
+	}
+}
+
 void CharacterVirtualTest::OnContactSolve(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, Vec3Arg inContactVelocity, const PhysicsMaterial *inContactMaterial, Vec3Arg inCharacterVelocity, Vec3 &ioNewCharacterVelocity)
 {
 	// Ignore callbacks for other characters than the player

+ 20 - 0
Samples/Tests/Character/CharacterVirtualTest.h

@@ -28,13 +28,29 @@ public:
 	// Called whenever the character collides with a body.
 	virtual void			OnContactAdded(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) override;
 
+	// Called whenever the character persists colliding with a body.
+	virtual void			OnContactPersisted(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) override;
+
+	// Called whenever the character loses contact with a body.
+	virtual void			OnContactRemoved(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2) override;
+
 	// Called whenever the character collides with a virtual character.
 	virtual void			OnCharacterContactAdded(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) override;
 
+	// Called whenever the character persists colliding with a virtual character.
+	virtual void			OnCharacterContactPersisted(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) override;
+
+	// Called whenever the character loses contact with a virtual character.
+	virtual void			OnCharacterContactRemoved(const CharacterVirtual *inCharacter, const CharacterID &inOtherCharacterID, const SubShapeID &inSubShapeID2) override;
+
 	// Called whenever the character movement is solved and a constraint is hit. Allows the listener to override the resulting character velocity (e.g. by preventing sliding along certain surfaces).
 	virtual void			OnContactSolve(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, Vec3Arg inContactVelocity, const PhysicsMaterial *inContactMaterial, Vec3Arg inCharacterVelocity, Vec3 &ioNewCharacterVelocity) override;
 
 protected:
+	// Common function to be called when contacts are added/persisted
+	void					OnContactCommon(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings);
+	void					OnCharacterContactCommon(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings);
+
 	// Get position of the character
 	virtual RVec3			GetCharacterPosition() const override				{ return mCharacter->GetPosition(); }
 
@@ -75,4 +91,8 @@ private:
 
 	// True when the player is pressing movement controls
 	bool					mAllowSliding = false;
+
+	// Track active contacts for debugging purposes
+	using ContactSet = Array<CharacterVirtual::ContactKey>;
+	ContactSet				mActiveContacts;
 };

+ 39 - 15
UnitTests/LoggingCharacterContactListener.h

@@ -16,7 +16,11 @@ public:
 		ValidateBody,
 		ValidateCharacter,
 		AddBody,
+		PersistBody,
+		RemoveBody,
 		AddCharacter,
+		PersistCharacter,
+		RemoveCharacter
 	};
 
 	// Entry written when a contact callback happens
@@ -25,30 +29,50 @@ public:
 		EType						mType;
 		const CharacterVirtual *	mCharacter;
 		BodyID						mBody2;
-		const CharacterVirtual *	mCharacter2;
+		CharacterID					mCharacterID2;
 		SubShapeID					mSubShapeID2;
 	};
 
 	virtual bool					OnContactValidate(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2) override
 	{
-		mLog.push_back({ EType::ValidateBody, inCharacter, inBodyID2, nullptr, inSubShapeID2 });
+		mLog.push_back({ EType::ValidateBody, inCharacter, inBodyID2, CharacterID(), inSubShapeID2 });
 		return true;
 	}
 
 	virtual bool					OnCharacterContactValidate(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2) override
 	{
-		mLog.push_back({ EType::ValidateCharacter, inCharacter, BodyID(), inOtherCharacter, inSubShapeID2 });
+		mLog.push_back({ EType::ValidateCharacter, inCharacter, BodyID(), inOtherCharacter->GetID(), inSubShapeID2 });
 		return true;
 	}
 
 	virtual void					OnContactAdded(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) override
 	{
-		mLog.push_back({ EType::AddBody, inCharacter, inBodyID2, nullptr, inSubShapeID2 });
+		mLog.push_back({ EType::AddBody, inCharacter, inBodyID2, CharacterID(), inSubShapeID2 });
+	}
+
+	virtual void					OnContactPersisted(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) override
+	{
+		mLog.push_back({ EType::PersistBody, inCharacter, inBodyID2, CharacterID(), inSubShapeID2 });
+	}
+
+	virtual void					OnContactRemoved(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2) override
+	{
+		mLog.push_back({ EType::RemoveBody, inCharacter, inBodyID2, CharacterID(), inSubShapeID2 });
 	}
 
 	virtual void					OnCharacterContactAdded(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) override
 	{
-		mLog.push_back({ EType::AddCharacter, inCharacter, BodyID(), inOtherCharacter, inSubShapeID2 });
+		mLog.push_back({ EType::AddCharacter, inCharacter, BodyID(), inOtherCharacter->GetID(), inSubShapeID2 });
+	}
+
+	virtual void					OnCharacterContactPersisted(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) override
+	{
+		mLog.push_back({ EType::PersistCharacter, inCharacter, BodyID(), inOtherCharacter->GetID(), inSubShapeID2 });
+	}
+
+	virtual void					OnCharacterContactRemoved(const CharacterVirtual *inCharacter, const CharacterID &inOtherCharacterID, const SubShapeID &inSubShapeID2) override
+	{
+		mLog.push_back({ EType::RemoveCharacter, inCharacter, BodyID(), inOtherCharacterID, inSubShapeID2 });
 	}
 
 	void							Clear()
@@ -72,7 +96,7 @@ public:
 		for (size_t i = 0; i < mLog.size(); ++i)
 		{
 			const LogEntry &e = mLog[i];
-			if (e.mType == inType && e.mCharacter == inCharacter && e.mBody2 == inBody2 && e.mCharacter2 == nullptr)
+			if (e.mType == inType && e.mCharacter == inCharacter && e.mBody2 == inBody2 && e.mCharacterID2.IsInvalid())
 				return int(i);
 		}
 
@@ -86,12 +110,12 @@ public:
 	}
 
 	// Find first event with a particular type and involving a particular character vs character
-	int								Find(EType inType, const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter) const
+	int								Find(EType inType, const CharacterVirtual *inCharacter, const CharacterID &inOtherCharacterID) const
 	{
 		for (size_t i = 0; i < mLog.size(); ++i)
 		{
 			const LogEntry &e = mLog[i];
-			if (e.mType == inType && e.mCharacter == inCharacter && e.mBody2.IsInvalid() && e.mCharacter2 == inOtherCharacter)
+			if (e.mType == inType && e.mCharacter == inCharacter && e.mBody2.IsInvalid() && e.mCharacterID2 == inOtherCharacterID)
 				return int(i);
 		}
 
@@ -99,9 +123,9 @@ public:
 	}
 
 	// Check if event with a particular type and involving a particular character vs character
-	bool							Contains(EType inType, const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter) const
+	bool							Contains(EType inType, const CharacterVirtual *inCharacter, const CharacterID &inOtherCharacterID) const
 	{
-		return Find(inType, inCharacter, inOtherCharacter) >= 0;
+		return Find(inType, inCharacter, inOtherCharacterID) >= 0;
 	}
 
 	// Find first event with a particular type and involving a particular character vs body and sub shape ID
@@ -110,7 +134,7 @@ public:
 		for (size_t i = 0; i < mLog.size(); ++i)
 		{
 			const LogEntry &e = mLog[i];
-			if (e.mType == inType && e.mCharacter == inCharacter && e.mBody2 == inBody2 && e.mCharacter2 == nullptr && e.mSubShapeID2 == inSubShapeID2)
+			if (e.mType == inType && e.mCharacter == inCharacter && e.mBody2 == inBody2 && e.mCharacterID2.IsInvalid() && e.mSubShapeID2 == inSubShapeID2)
 				return int(i);
 		}
 
@@ -124,12 +148,12 @@ public:
 	}
 
 	// Find first event with a particular type and involving a particular character vs character and sub shape ID
-	int								Find(EType inType, const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2) const
+	int								Find(EType inType, const CharacterVirtual *inCharacter, const CharacterID &inOtherCharacterID, const SubShapeID &inSubShapeID2) const
 	{
 		for (size_t i = 0; i < mLog.size(); ++i)
 		{
 			const LogEntry &e = mLog[i];
-			if (e.mType == inType && e.mCharacter == inCharacter && e.mBody2.IsInvalid() && e.mCharacter2 == inOtherCharacter && e.mSubShapeID2 == inSubShapeID2)
+			if (e.mType == inType && e.mCharacter == inCharacter && e.mBody2.IsInvalid() && e.mCharacterID2 == inOtherCharacterID && e.mSubShapeID2 == inSubShapeID2)
 				return int(i);
 		}
 
@@ -137,9 +161,9 @@ public:
 	}
 
 	// Check if a particular type and involving a particular character vs character and sub shape ID exists
-	bool							Contains(EType inType, const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2) const
+	bool							Contains(EType inType, const CharacterVirtual *inCharacter, const CharacterID &inOtherCharacterID, const SubShapeID &inSubShapeID2) const
 	{
-		return Find(inType, inCharacter, inOtherCharacter, inSubShapeID2) >= 0;
+		return Find(inType, inCharacter, inOtherCharacterID, inSubShapeID2) >= 0;
 	}
 
 private:

+ 24 - 3
UnitTests/Physics/CharacterVirtualTests.cpp

@@ -159,11 +159,31 @@ TEST_SUITE("CharacterVirtualTests")
 			mContactLog.OnContactAdded(inCharacter, inBodyID2, inSubShapeID2, inContactPosition, inContactNormal, ioSettings);
 		}
 
+		virtual void			OnContactPersisted(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) override
+		{
+			mContactLog.OnContactPersisted(inCharacter, inBodyID2, inSubShapeID2, inContactPosition, inContactNormal, ioSettings);
+		}
+
+		virtual void			OnContactRemoved(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2) override
+		{
+			mContactLog.OnContactRemoved(inCharacter, inBodyID2, inSubShapeID2);
+		}
+
 		virtual void			OnCharacterContactAdded(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) override
 		{
 			mContactLog.OnCharacterContactAdded(inCharacter, inOtherCharacter, inSubShapeID2, inContactPosition, inContactNormal, ioSettings);
 		}
 
+		virtual void			OnCharacterContactPersisted(const CharacterVirtual *inCharacter, const CharacterVirtual *inOtherCharacter, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, CharacterContactSettings &ioSettings) override
+		{
+			mContactLog.OnCharacterContactPersisted(inCharacter, inOtherCharacter, inSubShapeID2, inContactPosition, inContactNormal, ioSettings);
+		}
+
+		virtual void			OnCharacterContactRemoved(const CharacterVirtual *inCharacter, const CharacterID &inOtherCharacterID, const SubShapeID &inSubShapeID2) override
+		{
+			mContactLog.OnCharacterContactRemoved(inCharacter, inOtherCharacterID, inSubShapeID2);
+		}
+
 		virtual void			OnContactSolve(const CharacterVirtual *inCharacter, const BodyID &inBodyID2, const SubShapeID &inSubShapeID2, RVec3Arg inContactPosition, Vec3Arg inContactNormal, Vec3Arg inContactVelocity, const PhysicsMaterial *inContactMaterial, Vec3Arg inCharacterVelocity, Vec3 &ioNewCharacterVelocity) override
 		{
 			// Don't allow sliding if the character doesn't want to move
@@ -346,7 +366,7 @@ TEST_SUITE("CharacterVirtualTests")
 					// Should have received callbacks
 					CHECK(character.mContactLog.GetEntryCount() == 2);
 					CHECK(character.mContactLog.Contains(LoggingCharacterContactListener::EType::ValidateBody, character.mCharacter, floor.GetID()));
-					CHECK(character.mContactLog.Contains(LoggingCharacterContactListener::EType::AddBody, character.mCharacter, floor.GetID()));
+					CHECK(character.mContactLog.Contains(LoggingCharacterContactListener::EType::PersistBody, character.mCharacter, floor.GetID()));
 					character.mContactLog.Clear();
 				}
 				else
@@ -354,8 +374,9 @@ TEST_SUITE("CharacterVirtualTests")
 					// Should be off ground
 					CHECK(character.mCharacter->GetGroundState() == CharacterBase::EGroundState::InAir);
 
-					// No callbacks
-					CHECK(character.mContactLog.GetEntryCount() == 0);
+					// Remove callbacks
+					CHECK(character.mContactLog.GetEntryCount() == 1);
+					CHECK(character.mContactLog.Contains(LoggingCharacterContactListener::EType::RemoveBody, character.mCharacter, floor.GetID()));
 				}
 			}