I was able to get it working by manually setting the VRPlayer as the owning net client actor for the VRHands using SetOwner():
VRPlayer.h (for context)
UPROPERTY(BlueprintReadOnly, Category = "Components")
UChildActorComponent* LeftHandComponent;
UPROPERTY(BlueprintReadOnly, Category = "Components")
UChildActorComponent* RightHandComponent;
VRPlayer.cpp
void AVRPlayer::BeginPlay()
{
Super::BeginPlay();
LeftHand = Cast<AVRHand>(LeftHandComponent->GetChildActor());
RightHand = Cast<AVRHand>(RightHandComponent->GetChildActor());
LeftHand->SetOwner(this);
RightHand->SetOwner(this);
}
Here’s an explanation of how I came to this solution (I provide links to engine source, you can get access here):
I set a breakpoint in UMotionControllerComponent::TickComponent where the location is updated based on tracking:
const bool bNewTrackedState = PollControllerState(Position, Orientation, WorldToMeters);
if (bNewTrackedState)
{
SetRelativeLocationAndRotation(Position, Orientation);
}
The breakpoint told me that PollControllerState() was returning false. So I stepped into that function to see why.
The bulk of the polling function is guarded by a check to see if the client has net authority:
bHasAuthority = MyOwner->HasLocalNetOwner();
bHasAuthority was evaluating false, so I stepped into HasLocalNetOwner() to see why as well.
This function returns false if the owning actor is neither a Pawn nor Controller (my AVRHand class is neither of those because it only derives from AActor):
// Top owner will normally be a Pawn or a Controller
if (const APawn* Pawn = Cast<APawn>(TopOwner))
{
return Pawn->IsLocallyControlled();
}
const AController* Controller = Cast<AController>(TopOwner);
return Controller && Controller->IsLocalController();
Fortunately this function has logic to traverse ownership upwards, so by using SetOwner() to make VRPlayer the owner (which is a Pawn), we can tell the motion controller that we do have net authority and to update.