View Issue Details

IDProjectCategoryView StatusLast Update
0005992The Dark ModCodingpublic09.07.2022 11:11
Reporterstgatilov Assigned Tostgatilov  
PrioritynormalSeveritynormalReproducibilityN/A
Status resolvedResolutionfixed 
Product VersionTDM 2.10 
Target VersionTDM 2.11Fixed in VersionTDM 2.11 
Summary0005992: Experiment with game tics longer than 17 ms
DescriptionWhile uncapped FPS mode works well on high FPS, it boils down to modelling several game tics per frame on low FPS.
That's because many things were found wrong with long gametic:
  0004924: rope physics
  0004983: AI dying randomly
  (actually, many more)
As the result, if PC cannot keep up with game modelling, we are still caught in the "spiral of death".

Perhaps we can somehow fix this and use longer gametics?
Steps To Reproduce1) com_fixedTic 1 = uncapped FPS, since otherwise gametics will be fixed 16 ms
2) com_maxTicTimestep 50 = allows to use gametics up to 50 ms long without splitting them into many shorter ones
3) com_maxFps 22 = forces 22 FPS, since otherwise you won't be able to get low FPS consistently
TagsNo tags attached.

Relationships

related to 0004983 closed AIs randomly dying with uncapped and low FPS 
related to 0004924 resolvedstgatilov Rope physics mad when FPS is low and uncapped 
related to 0004409 resolvedduzenko Add an option to lift the 60 fps lock 

Activities

stgatilov

stgatilov

09.07.2022 10:47

administrator   ~0014979

The internal discussion about the problem is here:
  https://forums.thedarkmod.com/index.php?/topic/21465-at-lucys-quest-ai-optimisation/&do=findComment&comment=475913

Initially, I tried to actually extend game tics from 17 ms (current upper bound) to 50 ms.
In order to make physics stable, I tried to do several "physics subtics" per one game tic.
Moreover, I did all subtics for one entity at once, then switched to another entity, rinse and repeat --- that's just easier to implement.

I met many problems with physics this way:
  * Any force lasts for one Evaluate call, but should last for one game tic --- it starts to matter if we do several physics subtics per fame tic.
  * Some forces (like grabber) should be reevaluated every physics subtic, evaluating them once per game tic makes them unstable at 50ms step.
  * Some forces (including grabber and dragging) in fact don't apply force, then set velocity or ??add impulse?? Not clear what should be reapplied and what not...
  * Movers interact with things on them in some non-obvious way, and this is broken too.

I managed to solve the first 3 problems I think (with some mess in the code), but gave up on the last one.

From this attempt, I only committed refactoring related to forces on physics:
  r9976. Some physics-related cleaning.
  r9977. Minor cleaning.
  r9980. Now all forces are tagged with identifier, and we never apply force with same identifier more than once.
Now when you apply force to physics object, you tag if with "identifier".
Applying force with same identifier will replace the old application instead of duplicating it (of course if you do it on same game tic, since all forces are cleared once per game tic).

For historical reasons, I'm attaching my patch (based on svn rev 9980) here.
5992_physics_subtics.patch (10,590 bytes)   
Index: framework/Session.cpp
===================================================================
--- framework/Session.cpp	(revision 9980)
+++ framework/Session.cpp	(working copy)
@@ -39,6 +39,11 @@
 	"Never do more than this number of game tics per one frame. "
 	"When frames take too much time, allow game time to run slower than astronomical time.",
 1, 1000);
+idCVar com_maxPhysicsTimestep("com_maxPhysicsTimestep", "17", CVAR_SYSTEM | CVAR_INTEGER,
+	"Timestep in physics modelling must not exceed this number of milliseconds. "
+	"If game tic takes longer, then several physics timesteps are modelled in one tic.\n"
+	"Ideally, this cvar should be ceil(com_maxTicTimestep / k) for some k.",
+1, 1000);
 idCVar	idSessionLocal::com_maxFPS( "com_maxFPS", "166", CVAR_SYSTEM | CVAR_ARCHIVE | CVAR_INTEGER, "define the maximum FPS cap", 2, 1000 );
 idCVar	idSessionLocal::com_showDemo("com_showDemo", "0", CVAR_SYSTEM | CVAR_BOOL, "");
 idCVar	idSessionLocal::com_skipGameDraw( "com_skipGameDraw", "0", CVAR_SYSTEM | CVAR_BOOL, "" );
Index: game/Entity.cpp
===================================================================
--- game/Entity.cpp	(revision 9980)
+++ game/Entity.cpp	(working copy)
@@ -5400,8 +5400,31 @@
 		if ( part->physics )
 		{
 			// run physics
-			moved = part->physics->Evaluate( endTime - startTime, endTime );
+			int deltaTime = endTime - startTime;
+			int numTics = 1;
+			if ( deltaTime > com_maxPhysicsTimestep.GetInteger() )
+				numTics = ( (deltaTime - 1) / com_maxPhysicsTimestep.GetInteger() ) + 1;
+			// for AI, force one physics step: that's compatible with interleaved thinking
+			if ( IsType(idAI::Type) && !((idAI*)this)->IsActiveAF() )
+				numTics = 1;
 
+			moved = false;
+			for (int s = 0; s < numTics; s++) {
+				int ticBeg = startTime + deltaTime * (s + 0) / numTics;
+				int ticEnd = startTime + deltaTime * (s + 1) / numTics;
+
+				// hack: update grabber velocity/force every physics subtic
+				// also update player's push force (it sets velocity)
+				if ( gameLocal.m_Grabber->GetSelected() == this )
+					gameLocal.m_Grabber->ReEvaluate( ticEnd );
+				if ( m_pushedBy.IsValid() && m_pushedBy.GetEntity()->IsType(idPlayer::Type) )
+					((idPlayer*)m_pushedBy.GetEntity())->GetPlayerPhysics()->ReEvaluatePushForce( ticEnd );
+				// TODO: do this for all forces acting on the physics object?
+
+				if ( part->physics->Evaluate( ticEnd - ticBeg, ticEnd ) )
+					moved = true;
+			}
+
 			// check if the object is blocked
 			blockingEntity = part->physics->GetBlockingEntity();
 			if ( blockingEntity ) {
Index: game/Force_Grab.cpp
===================================================================
--- game/Force_Grab.cpp	(revision 9980)
+++ game/Force_Grab.cpp	(working copy)
@@ -628,6 +628,20 @@
 
 /*
 ================
+CForce_Grab::ReEvaluate
+================
+*/
+void CForce_Grab::ReEvaluate( int time ) {
+	// Evaluate only sets velocities and applies forces
+	// so we can call if as many times as we want on every game tic
+	// calling it after every physics substep allows to make grabbing stable
+	// (even on low FPS = several physics subtics per game tic)
+	Evaluate( time );
+}
+
+
+/*
+================
 CForce_Grab::RemovePhysics
 ================
 */
Index: game/Force_Grab.h
===================================================================
--- game/Force_Grab.h	(revision 9980)
+++ game/Force_Grab.h	(working copy)
@@ -74,6 +74,7 @@
 
 	public: // common force interface
 		virtual void		Evaluate( int time ) override;
+		virtual void		ReEvaluate( int time ) override;
 		virtual void		RemovePhysics( const idPhysics *phys ) override;
 
 	protected:
Index: game/gamesys/SysCvar.h
===================================================================
--- game/gamesys/SysCvar.h	(revision 9980)
+++ game/gamesys/SysCvar.h	(working copy)
@@ -268,6 +268,7 @@
 extern idCVar cv_weapon_next_on_empty;
 
 // physics
+extern idCVar com_maxPhysicsTimestep;
 extern idCVar cv_collision_damage_scale_vert;
 extern idCVar cv_collision_damage_scale_horiz;
 extern idCVar cv_dragged_item_highlight;
Index: game/Grabber.cpp
===================================================================
--- game/Grabber.cpp	(revision 9980)
+++ game/Grabber.cpp	(working copy)
@@ -617,6 +617,11 @@
 	return;
 }
 
+void CGrabber::ReEvaluate( int time ) 
+{
+	m_drag.ReEvaluate( time );
+}
+
 void CGrabber::StartDrag( idPlayer *player, idEntity *newEnt, int bodyID, bool preservePosition ) 
 {
 	idVec3 viewPoint, origin, COM, COMWorld, delta2;
Index: game/Grabber.h
===================================================================
--- game/Grabber.h	(revision 9980)
+++ game/Grabber.h	(working copy)
@@ -52,6 +52,8 @@
 		**/
 		void					Update( idPlayer *player, bool hold = false, bool preservePosition = false );
 
+		void					ReEvaluate( int time );
+
 		void					Save( idSaveGame *savefile ) const;
 		void					Restore( idRestoreGame *savefile );
 
Index: game/physics/Force.cpp
===================================================================
--- game/physics/Force.cpp	(revision 9980)
+++ game/physics/Force.cpp	(working copy)
@@ -75,6 +75,14 @@
 
 /*
 ================
+idForce::ReEvaluate
+================
+*/
+void idForce::ReEvaluate( int time ) {
+}
+
+/*
+================
 idForce::RemovePhysics
 ================
 */
Index: game/physics/Force.h
===================================================================
--- game/physics/Force.h	(revision 9980)
+++ game/physics/Force.h	(working copy)
@@ -42,6 +42,9 @@
 public: // common force interface
 						// evaluate the force up to the given time
 	virtual void		Evaluate( int time );
+						// stgatilov: evaluate again, probably refining velocity/force
+						// this is sed for Force_Grab on low FPS, so that it has full control on very physics subtic
+	virtual void		ReEvaluate( int time );
 						// removes any pointers to the physics object
 	virtual void		RemovePhysics( const idPhysics *phys );
 
Index: game/physics/Force_Push.cpp
===================================================================
--- game/physics/Force_Push.cpp	(revision 9980)
+++ game/physics/Force_Push.cpp	(working copy)
@@ -29,7 +29,8 @@
 	id(0),
 	impactVelocity(vec3_zero),
 	startPushTime(-1),
-	owner(NULL)
+	owner(NULL),
+	pushingByVelocity(false)
 {
 	lastPushEnt = NULL;
 	memset(&contactInfo, 0, sizeof(contactInfo));
@@ -99,6 +100,8 @@
 {
 	if (owner == NULL) return;
 
+	pushingByVelocity = false;
+
 	if (pushEnt == NULL) 
 	{
 		// nothing to do, but update the owning actor's push state
@@ -218,6 +221,8 @@
 
 			// Apply the mass scale and the acceleration scale to the capped velocity
 			physics->SetLinearVelocity(pushVelocity * velocity * accelScale * massScale * entityScale);
+			// stgatilov: in case we model several physics subtics per game tic, reset this velocity constantly
+			pushingByVelocity = true;
 
 			DM_LOG(LC_MOVEMENT, LT_INFO)LOGSTRING("Pushing obstacle %s\r", pushEnt->name.c_str());
 
@@ -242,6 +247,15 @@
 	pushEnt = NULL;
 }
 
+void CForcePush::ReEvaluate( int time )
+{
+	if (pushingByVelocity) {
+		// we need to reset object's velocity every physics subtic
+		pushEnt = lastPushEnt.GetEntity();
+		Evaluate(time);
+	}
+}
+
 void CForcePush::SetOwnerIsPushing(bool isPushing)
 {
 	// Update the owning actor's push state if it has changed
Index: game/physics/Force_Push.h
===================================================================
--- game/physics/Force_Push.h	(revision 9980)
+++ game/physics/Force_Push.h	(working copy)
@@ -44,6 +44,7 @@
 
 public: // common force interface
 	virtual void		Evaluate( int time ) override;
+	virtual void		ReEvaluate( int time ) override;
 
 private:
 	void				SetOwnerIsPushing(bool isPushing);
@@ -56,6 +57,7 @@
 	trace_t				contactInfo;	// the contact info of the object we're pushing
 	idVec3				impactVelocity;	// the velocity the owner had at impact time
 	int					startPushTime;	// the time we started to push the physics object
+	bool				pushingByVelocity;	// true if we need to constantly set velocity (false if we just apply impulses)
 
 	idEntity*			owner;			// the owning entity
 };
Index: game/physics/Physics_AF.cpp
===================================================================
--- game/physics/Physics_AF.cpp	(revision 9980)
+++ game/physics/Physics_AF.cpp	(working copy)
@@ -6686,8 +6686,9 @@
 		// we'll take them into account on the next physics subtic
 		body->current->externalForce.Zero();
 		body->next->externalForce.Zero();
-		// drop list of forces
-		body->forceApplications.Clear();
+		// drop list of forces if this is the last Evaluate on the current game tic
+		if ( endTimeMSec == gameLocal.time )
+			body->forceApplications.Clear();
 	}
 
 	// apply contact force to other entities
Index: game/physics/Physics_Player.cpp
===================================================================
--- game/physics/Physics_Player.cpp	(revision 9980)
+++ game/physics/Physics_Player.cpp	(working copy)
@@ -3583,6 +3583,16 @@
 
 /*
 ================
+idPhysics_Player::ReEvaluatePushForce
+================
+*/
+void idPhysics_Player::ReEvaluatePushForce( int time ) {
+	// stgatilov: update push force, letting it set desired velocity again
+	m_PushForce->ReEvaluate( time );
+}
+
+/*
+================
 idPhysics_Player::UpdateTime
 ================
 */
Index: game/physics/Physics_Player.h
===================================================================
--- game/physics/Physics_Player.h	(revision 9980)
+++ game/physics/Physics_Player.h	(working copy)
@@ -243,6 +243,8 @@
 	bool					CheckPushEntity(idEntity *entity); // grayman #4603
 	void					ClearPushEntity(); // grayman #4603
 
+	void					ReEvaluatePushForce( int time );
+
 private:
 	// player physics state
 	playerPState_t			current;
Index: game/physics/Physics_RigidBody.cpp
===================================================================
--- game/physics/Physics_RigidBody.cpp	(revision 9980)
+++ game/physics/Physics_RigidBody.cpp	(working copy)
@@ -1672,9 +1672,11 @@
 		current.i.position + centerOfMass * current.i.orientation,
 		&externalForce, &externalTorque, &externalForcePoint
 	);
-	// end of game tic: clear force list
-	// forces will not show up on next game tic unless reapplied via AddForce
-	current.forceApplications.Clear();
+	if ( endTimeMSec == gameLocal.time ) {
+		// end of game tic: clear force list
+		// forces will not show up on next game tic unless reapplied via AddForce
+		current.forceApplications.Clear();
+	}
 
 	if ( hasMaster ) {
 		oldOrigin = current.i.position;
5992_physics_subtics.patch (10,590 bytes)   
stgatilov

stgatilov

09.07.2022 10:57

administrator   ~0014980

Then I decided to choose cleaner, easier and safer approach with physics, although it might bring less performance benefits.

Instead of running physics many times per otherwise normal game tic, I'd run many game tics per frame as before, but skip the toughest work during some game tics.
So now if FPS is low and several tics are modelled, we treat all tics except first one as "minor" and can skip some processing during them.

Right now I skip only the toughest part: AI thinking.
  r9981. Allow AIs to think only on the first gametic per frame (controlled by com_useMinorTics).
Luckily, we have "Interleaved Thinking Optimization" enabled by default for years, so the support for "AI does not think every gametic" is already here.
Maybe in future we'll skip more stuff, but I'd say better don't mess with it without strong need.


A related problem from long game tics was AIs accidentally dying.
It was simply caused by the fact that "Interleaved Thinking Optimization" counts frames instead of game time milliseconds.
I changed that and the problem is gone:
  0005992. AI interleaved thinking interval is now fixed in ms (instead of in frames).
A side-product is that the game now works faster in high-FPS conditions because AIs think once per 160 ms instead of once per 10 frames.
stgatilov

stgatilov

09.07.2022 11:11

administrator   ~0014981

The last change is actually for debugging only, although it is a very important one.
  r9975. Fixing debug cvars for forcing dormancy and interleaved thinking of AIs.

The game has two ways of optimizing AIs:
1) Dormancy --- AI simply does not think/behave until player gets near or something happens.
  Originally implemented in Doom 3, it is usually disabled in TDM, because dormant AIs stop patrolling.
  But it can be enabled back with "neverDormant 0" spawnarg, and some maps (old maps and Lucy's Quest) use it.
2) Interleaved Thinking Optimization --- AIs think rarely, like once per 160 ms.
  But when they do, they fully cover this long timestep.
  This is enabled by default, and implemented specifically in TDM.

For some reason, tdm_ai_opt_forceopt cvar was actually forcing dormancy on AIs, even though tdm_ai_opt_XXX cvars are usually about ITO.
I renamed this cvar to tdm_ai_opt_forcedormant to avoid confusion.
Also added "-1" value, in which case all AIs never dormant, even if mapper allowed them to.

On the other hand, a way to debug ITO issues is necessary, so I implemented new tdm_ai_opt_forceopt cvar.
Now it influences ITO in such a way as if player is infinitely far and in different PVS, so AIs behave with max interleaving.
But player can be near them, seeing what they do and e.g. what kills them.
In fact, I can easily reproduce AI deaths from long gametics with this cvar, while looking directly at dying AI.

Issue History

Date Modified Username Field Change
02.07.2022 16:19 stgatilov New Issue
02.07.2022 16:19 stgatilov Status new => assigned
02.07.2022 16:19 stgatilov Assigned To => stgatilov
02.07.2022 16:21 stgatilov Steps to Reproduce Updated
02.07.2022 16:22 stgatilov Relationship added related to 0004983
02.07.2022 16:22 stgatilov Relationship added related to 0004924
02.07.2022 16:23 stgatilov Relationship added related to 0004409
09.07.2022 10:47 stgatilov Note Added: 0014979
09.07.2022 10:47 stgatilov File Added: 5992_physics_subtics.patch
09.07.2022 10:57 stgatilov Note Added: 0014980
09.07.2022 11:11 stgatilov Note Added: 0014981
09.07.2022 11:11 stgatilov Status assigned => resolved
09.07.2022 11:11 stgatilov Resolution open => fixed
09.07.2022 11:11 stgatilov Fixed in Version => TDM 2.11