Calculating collision force with AfterCollision/NormalImpulse is unreliable when IgnoreCCD = false?

Dec 13, 2012 at 5:02 PM
Edited Dec 13, 2012 at 5:04 PM

I'm using Farseer 3.3.1 in a very simple XNA 4 test game.

In this game, I'm creating two bodies. The first body is created using BodyFactory.CreateCircle and BodyType.Dynamic. This body can be moved around using the keyboard (which sets Body.LinearVelocity). The second body is created using BodyFactory.CreateRectangle and BodyType.Static. This body is static and never moves.

Then I'm using this code to calculate the force of collision when the two bodies collide:

 

staticBody.FixtureList[0].AfterCollision += new AfterCollisionEventHandler(AfterCollision);

protected void AfterCollision(Fixture fixtureA, Fixture fixtureB, Contact contact)
{
	float maxImpulse = 0f;
	for (int i = 0; i < contact.Manifold.PointCount; i++)
		maxImpulse = Math.Max(maxImpulse, contact.Manifold.Points[i].NormalImpulse);

	// maxImpulse should contain the force of the collision
}

 

Here's the problem: If I set both of these bodies to IgnoreCCD=true, then I can calculate the force of collision between them 100% reliably. It works perfectly.

But if I set them to IgnoreCCD=false, that code becomes wildly unpredictable. AfterCollision is called every time, but for some weird reason the NormalImpulse is 0.0 about 75% of the time, so only about one in four collisions is registered. Again, this does not happen if IgnoreCCD=true on both bodies.

Why is this happening?

Developer
Dec 13, 2012 at 5:16 PM

Hi,

if Continuous Collision Detection (CCD) is enabled AfterCollision can be called multiple times per time step, since it kinda does sub time steps to increase the accuracy for fast moving objects. Either turn CCD off (can be done for the whole simulation in the settings) or store your maxImpulse outside of the AfterCollision Event and reset it before each time step, to get the MaxImpulse from all calls to AfterCollision.

Dec 13, 2012 at 5:41 PM

Thanks for the response!

Unfortunately, I had thought that might be the case, so I also tried this code:

protected bool collisionOccurred = false;
protected float currentImpulse = 0f;

public MyClassConstructor()
{
	// create the body here...

	staticBody.FixtureList[0].AfterCollision += new AfterCollisionEventHandler(AfterCollision);
}

protected void AfterCollision(Fixture fixtureA, Fixture fixtureB, Contact contact)
{
	collisionOccurred = true;

	float maxImpulse = 0f;
	for (int i = 0; i < contact.Manifold.PointCount; i++)
		maxImpulse = Math.Max(maxImpulse, contact.Manifold.Points[i].NormalImpulse);

	currentImpulse = Math.Max(currentImpulse, maxImpulse);
}

public void Update(GameTime gameTime)
{
	if (collisionOccurred)
	{
		// PROBLEM: currentImpulse is still randomly zero here about 75% of the time

		collisionOccurred = false;
		currentImpulse = 0f;
	}
}

...and that produces exactly the same problem. The impulse is still 0 about 75% of the time. It seems to be completely random when it's set to zero. I can hit the object 10 times in a row in exactly the same way, and the collision will only register (NormalImpulse>0) about 2 or 3 times out of 10.

Unfortunately, I need continuous collision detection, so disabling CCD is not an option for me.

Dec 13, 2012 at 7:23 PM

OK, I just put together an incredibly simple game that demonstrates this problem. Use your keyboard to move the white box around and collide with the gray box a few times. With IgnoreCCD=false, sometimes the collision impulse is zero, sometimes it's correct. If you change both bodies to IgnoreCCD=true, then the collision impulse works every time.

Here's a link to the Visual Studio 2010 solution:

http://www.mediafire.com/?a1w242q9sna54j4

And here is the full game code for Game1.cs. It's an XNA 4 game, and it references Farseer 3.3.1. There is a simple 16x16 texture (called "Texture1") and a SpriteFont (called "SpriteFont1") included in the Content project.

So is this a bug in Farseer? I will happily donate to the project if someone can help me fix this.

 

using System;
using FarseerPhysics.Dynamics;
using FarseerPhysics.Dynamics.Contacts;
using FarseerPhysics.Factories;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace FarseerCCDTest
{
	public class Game1 : Microsoft.Xna.Framework.Game
	{
		GraphicsDeviceManager graphics;
		SpriteBatch spriteBatch;
		Texture2D texture;
		SpriteFont font;

		World world;
		Body dynamicBody;
		Body staticBody;

		bool collisionOccurred = false;
		float currentImpulse = 0f;

		string debugString = "";
		TimeSpan showDebugStringTimeTotal = TimeSpan.FromSeconds(1f);
		TimeSpan showDebugStringTimeElapsed = TimeSpan.Zero;

		public Game1()
		{
			graphics = new GraphicsDeviceManager(this);
			Content.RootDirectory = "Content";
		}

		protected override void LoadContent()
		{
			spriteBatch = new SpriteBatch(GraphicsDevice);

			texture = Content.Load<Texture2D>("Texture1");
			font = Content.Load<SpriteFont>("SpriteFont1");

			world = new World(Vector2.Zero);

			dynamicBody = BodyFactory.CreateRectangle(world, ConvertUnits.ToSimUnits(50), ConvertUnits.ToSimUnits(50), 0f);
			dynamicBody.BodyType = BodyType.Dynamic;
			dynamicBody.Restitution = 1f;
			dynamicBody.Position = ConvertUnits.ToSimUnits(new Vector2(150, 200));
			dynamicBody.IgnoreCCD = false;
			dynamicBody.LinearDamping = 1f;

			staticBody = BodyFactory.CreateRectangle(world, ConvertUnits.ToSimUnits(50), ConvertUnits.ToSimUnits(50), 0f);
			staticBody.BodyType = BodyType.Static;
			staticBody.Restitution = 1f;
			staticBody.Position = ConvertUnits.ToSimUnits(new Vector2(400, 200));
			staticBody.IgnoreCCD = false;

			staticBody.FixtureList[0].AfterCollision += new AfterCollisionEventHandler(AfterCollision);
		}

		protected void AfterCollision(Fixture fixtureA, Fixture fixtureB, Contact contact)
		{
			collisionOccurred = true;

			float maxImpulse = 0f;
			for (int i = 0; i < contact.Manifold.PointCount; i++)
				maxImpulse = Math.Max(maxImpulse, contact.Manifold.Points[i].NormalImpulse);

			currentImpulse = Math.Max(currentImpulse, maxImpulse);
		}

		protected override void Update(GameTime gameTime)
		{
			world.Step((float)(gameTime.ElapsedGameTime.TotalMilliseconds * 0.001d));

			if (Keyboard.GetState().IsKeyDown(Keys.Escape))
				this.Exit();

			if (Keyboard.GetState().IsKeyDown(Keys.Left))
				dynamicBody.ApplyForce(new Vector2(-5, 0));
			if (Keyboard.GetState().IsKeyDown(Keys.Right))
				dynamicBody.ApplyForce(new Vector2(5, 0));
			if (Keyboard.GetState().IsKeyDown(Keys.Up))
				dynamicBody.ApplyForce(new Vector2(0, -5));
			if (Keyboard.GetState().IsKeyDown(Keys.Down))
				dynamicBody.ApplyForce(new Vector2(0, 5));

			if (collisionOccurred)
			{
				debugString = currentImpulse.ToString();

				collisionOccurred = false;
				currentImpulse = 0f;
			}

			if (!String.IsNullOrEmpty(debugString))
			{
				showDebugStringTimeElapsed += gameTime.ElapsedGameTime;
				if (showDebugStringTimeElapsed >= showDebugStringTimeTotal)
				{
					showDebugStringTimeElapsed = TimeSpan.Zero;
					debugString = "";
				}
			}

			base.Update(gameTime);
		}

		protected override void Draw(GameTime gameTime)
		{
			GraphicsDevice.Clear(Color.Black);

			spriteBatch.Begin();

			spriteBatch.Draw(
					texture,
					new Rectangle((int)ConvertUnits.ToDisplayUnits(dynamicBody.Position.X), (int)ConvertUnits.ToDisplayUnits(dynamicBody.Position.Y), 50, 50),
					Color.White
				);

			spriteBatch.Draw(
					texture,
					new Rectangle((int)ConvertUnits.ToDisplayUnits(staticBody.Position.X), (int)ConvertUnits.ToDisplayUnits(staticBody.Position.Y), 50, 50),
					Color.Gray
				);

			spriteBatch.DrawString(font, "Force: " + debugString, new Vector2(10, 10), Color.White);

			spriteBatch.DrawString(font, "Instructions:", new Vector2(600, 10), Color.White);
			spriteBatch.DrawString(font, "- Arrow keys to move", new Vector2(600, 40), Color.White);
			spriteBatch.DrawString(font, "- ESC to quit", new Vector2(600, 70), Color.White);

			spriteBatch.End();


			base.Draw(gameTime);
		}
	}

	public static class ConvertUnits
	{
		private static float _displayUnitsToSimUnitsRatio = 100f;
		private static float _simUnitsToDisplayUnitsRatio = 1 / _displayUnitsToSimUnitsRatio;

		public static void SetDisplayUnitToSimUnitRatio(float displayUnitsPerSimUnit)
		{
			_displayUnitsToSimUnitsRatio = displayUnitsPerSimUnit;
			_simUnitsToDisplayUnitsRatio = 1 / displayUnitsPerSimUnit;
		}

		public static float ToDisplayUnits(float simUnits)
		{
			return simUnits * _displayUnitsToSimUnitsRatio;
		}

		public static float ToDisplayUnits(int simUnits)
		{
			return simUnits * _displayUnitsToSimUnitsRatio;
		}

		public static Vector2 ToDisplayUnits(Vector2 simUnits)
		{
			return simUnits * _displayUnitsToSimUnitsRatio;
		}

		public static void ToDisplayUnits(ref Vector2 simUnits, out Vector2 displayUnits)
		{
			Vector2.Multiply(ref simUnits, _displayUnitsToSimUnitsRatio, out displayUnits);
		}

		public static Vector3 ToDisplayUnits(Vector3 simUnits)
		{
			return simUnits * _displayUnitsToSimUnitsRatio;
		}

		public static Vector2 ToDisplayUnits(float x, float y)
		{
			return new Vector2(x, y) * _displayUnitsToSimUnitsRatio;
		}

		public static void ToDisplayUnits(float x, float y, out Vector2 displayUnits)
		{
			displayUnits = Vector2.Zero;
			displayUnits.X = x * _displayUnitsToSimUnitsRatio;
			displayUnits.Y = y * _displayUnitsToSimUnitsRatio;
		}

		public static float ToSimUnits(float displayUnits)
		{
			return displayUnits * _simUnitsToDisplayUnitsRatio;
		}

		public static float ToSimUnits(double displayUnits)
		{
			return (float)displayUnits * _simUnitsToDisplayUnitsRatio;
		}

		public static float ToSimUnits(int displayUnits)
		{
			return displayUnits * _simUnitsToDisplayUnitsRatio;
		}

		public static Vector2 ToSimUnits(Vector2 displayUnits)
		{
			return displayUnits * _simUnitsToDisplayUnitsRatio;
		}

		public static Vector3 ToSimUnits(Vector3 displayUnits)
		{
			return displayUnits * _simUnitsToDisplayUnitsRatio;
		}

		public static void ToSimUnits(ref Vector2 displayUnits, out Vector2 simUnits)
		{
			Vector2.Multiply(ref displayUnits, _simUnitsToDisplayUnitsRatio, out simUnits);
		}

		public static Vector2 ToSimUnits(float x, float y)
		{
			return new Vector2(x, y) * _simUnitsToDisplayUnitsRatio;
		}

		public static Vector2 ToSimUnits(double x, double y)
		{
			return new Vector2((float)x, (float)y) * _simUnitsToDisplayUnitsRatio;
		}

		public static void ToSimUnits(float x, float y, out Vector2 simUnits)
		{
			simUnits = Vector2.Zero;
			simUnits.X = x * _simUnitsToDisplayUnitsRatio;
			simUnits.Y = y * _simUnitsToDisplayUnitsRatio;
		}
	}
}

Coordinator
Dec 16, 2012 at 1:49 AM
Edited Dec 16, 2012 at 1:50 AM

The contacts created by the TOI are solved right away, so what you see is the TOI solver solves the collision and it does not store the forces. The reason we don't do this is for stability. The TOI solver picks up the work when two bodies overlap too much, and in that case, we will have huge forces stored in the warm starting algorithm.

I can provide you with a very simple solution, provided that you are aware with 2 things:

  1. The contacts now store a force that might not be correct. Multiple steps with the TOI solver will first set the value of the collision to be X, but might set it to 0 right after.
  2. Due to number 1, if you expect a collision to only have a single AfterCollision call with a force always larger than 0, your code will fail.

The solution is to store the forces in the contacts for warm starting right after the TOI is done with an iteration. Go to line 390 in Island.cs where I have a comment about NOT storing forces, and place this code: "_contactSolver.StoreImpulses();"

I will look forward to that donation :)

Edit: Thanks for the sample code. It is always better to understand what is required when I have code to test it with. You could have created a small test inside the Testbed project. They already provide a good foundation and you don't have to draw any textures.

Dec 17, 2012 at 4:26 PM

THANK YOU for this reply. I'm home for the holidays right now, so I won't be able to test it out for a week or two, but I'll report back as soon as I've had a chance to try this out. And yes, I will donate! :) Outside of a minor hiccup here and there, Farseer has been a wonderful library. Thank you for all of your hard work.