I’ve noticed non-deterministic cook results for some actors in UPhysicsConstraintComponent.ConstraintInstance properties. This turned out to be a pretty complex issue, but I think I understand the root cause well at this point:
- The prerequisite is having a world-partition level, and an actor in the always-loaded cell that contains UStaticMeshComponent and UPhysicsConstraintComponent that has the mesh component as one of it’s ‘frames’ (say Frame2).
- When cooking, interesting things happen in UCookOnTheFlyServer::BeginCacheObjectsToMove.
- First, UCookOnTheFlyServer::BeginCacheObjectsToMove calls FGenerationHelper::TryCallPopulateGeneratorPackage, which leads to UWorldPartition::PrepareGeneratorPackageForCook iterating over always-loaded cells and calling UWorldPartitionRuntimeLevelStreamingCell::OnPrepareGeneratorPackageForCook. This loads the actors and then calls FWorldPartitionLevelHelper::MoveExternalActorsToLevel, which then leads to UActorComponent::ExecuteRegisterEvents (first when actors are moved to the destination level, then optionally - if components are created by construction scripts - again from RerunConstructionScripts.
- For UStaticMeshComponent, interesting things happen in CreatePhysicsState: this calls UStaticMeshComponent::ShouldCreatePhysicsState, which returns true or false depending on whether async compilation task is finished. Depending on cook order and timings, this can be either true or false. If this returns true, we call OnCreatePhysicsState, which then inits BodyInstance.ActorHandle.
- For UPhysicsConstraintComponent, interesting things happen in UPhysicsConstraintComponent::OnRegister: it calls InitComponentConstraint -> UpdateConstraintFrames, which reads transforms using GetBodyTransform -> GetBodyTransformInternal. If the UStaticMeshComponent.BodyInstance.ActorHandle was initialized, the transform is read from the physics body, otherwise it’s read directly from ComponentToWorld.
- The first immediate problem here is that ComponentToWorld stores rotation as quaternion of doubles, while physics body stores it as quaternion of floats, meaning that depending on whether physics body was initialized, we might or might not get double -> float -> double roundtrip. This can be hacked around relatively easily by adding similar roundtrip conversion into GetBodyTransformInternal, on the path where it reads it from ComponentToWorld.
- Once the transform is retrieved, it’s then used to convert constraint’s world position into local position relative to each frame (Pos1 and Pos2 fields).
- The second problem happens later in UpdateConstraintFrames: if (and only if) the corresponding frame’s physical object is created, the local position is further divided by the scale of the constraint component. Now, nothing about that block makes sense to me:
- When calculating the transform, we already remove the scaling from the matrix; the rotation and translation part are the same (sans rounding error introduced by roundtrip), and in fact there’s no way for the mesh to even know about constraint’s scale when building it’s physics body. So before the RefScale division, both Pos1 and Pos2 are already unscaled.
- Also note that UStaticMeshComponent and UPhysicsConstraintComponent can be initialized in arbitrary order (in fact, in the example that I was investigating the order was different when moving the actor to the world and then when later rerunning simple construction scripts). It sounds wild that division by constraint scale will happen or will not happen depending on whether constraint or ‘anchor’ is registered first.
- I would expect for this division to either happen or not happen regardless of whether physics body happened to be created.
- Later, UCookOnTheFlyServer::BeginCacheObjectsToMove will call CallBeginCacheOnObjects, which will pause further cooking until static mesh compilation tasks finish - but it’s too late, the constraint properties are already initialized by then. This looks like a bigger problem with order of operations when cooking world partitions.