feat(camera): third-person rig, V toggle, visible body

One pawn, both rigs (ADR-9): a skeletal body hidden in first person, a
collision-tested spring arm, and a toggle that swaps the active camera and the
body's visibility. Shipped default stays first person; -thirdperson starts the
pawn in the third-person rig so the review shot is reproducible, and the
screenshot run passes it.

Three defects found and two fixed:
- The skin material had no SkeletalMesh usage flag, so Unreal silently substituted
  the default grey. That is why the body rendered as clay.
- render_screenshots.sh still called ./Engine/... on this branch (the fix lives on
  the facade branch and never reached main); applied here so the shots generate.
- OPEN: the idle clip is bound and PlayAnimation is called - the run logs the clip
  name - but the body still renders in bind pose, arms out. Recorded with that
  evidence in TODO.md rather than called done.

Shot 5 (demo-third-person) added and documented; camera collision is implemented
but unproven, since the spawn has open space behind it.
This commit is contained in:
zwitschi committed 2026-11-09 10:33:06 +01:00
1 parent 96238bdb6a
commit 05c1488c00
49 files changed
+333 -65

No files matched your search

@@ -1,6 +1,9 @@
#include "GigLineCharacter.h"
#include "Animation/AnimSequence.h"
#include "Camera/CameraComponent.h"
#include "Components/SkeletalMeshComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "Components/CapsuleComponent.h"
#include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h"
@@ -28,7 +31,7 @@ static TAutoConsoleVariable<int32> CVarGigLineSetupLogs(
AGigLineCharacter::AGigLineCharacter()
{
PrimaryActorTick.bCanEverTick = false;
PrimaryActorTick.bCanEverTick = true;
// 1.75 m tall, 0.42 m wide, human scale in Unreal units (1 m = 100 uu).
GetCapsuleComponent()->InitCapsuleSize(42.0f, 87.5f);
@@ -51,9 +54,59 @@ AGigLineCharacter::AGigLineCharacter()
FirstPersonCamera->SetRelativeLocation(FVector(0.0f, 0.0f, 72.5f)); // eye height 1.60 m
FirstPersonCamera->bUsePawnControlRotation = true;
// An eye-level mesh is not needed for 0.1.0; the body is a capsule with a camera.
GetMesh()->SetHiddenInGame(true);
// Visible body (0.2.0). The animated import carries its own skeleton, so the
// clips resolve without a retarget step; it is hidden while the first-person
// camera is active and shown in third person.
static ConstructorHelpers::FObjectFinder<USkeletalMesh> BodyFinder(
TEXT("/Game/Test/Character/Character_Anim.Character_Anim"));
if (BodyFinder.Succeeded())
{
GetMesh()->SetSkeletalMesh(BodyFinder.Object);
}
// The animated import carries no material of its own; without this the body is
// grey clay. The warm skin material was created by the character import.
static ConstructorHelpers::FObjectFinder<UMaterialInterface> SkinFinder(
TEXT("/Game/Test/Character/M_Character_Export.M_Character_Export"));
if (SkinFinder.Succeeded())
{
GetMesh()->SetMaterial(0, SkinFinder.Object);
}
static ConstructorHelpers::FObjectFinder<UAnimSequence> IdleFinder(
TEXT("/Game/Test/Character/Character_AnimHuman_rig_GigLine_Idle.Character_AnimHuman_rig_GigLine_Idle"));
if (IdleFinder.Succeeded())
{
IdleAnimation = IdleFinder.Object;
}
static ConstructorHelpers::FObjectFinder<UAnimSequence> WalkFinder(
TEXT("/Game/Test/Character/Character_AnimHuman_rig_GigLine_Walk.Character_AnimHuman_rig_GigLine_Walk"));
if (WalkFinder.Succeeded())
{
WalkAnimation = WalkFinder.Object;
}
GetMesh()->SetRelativeLocation(FVector(0.0f, 0.0f, -87.5f));
GetMesh()->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
GetMesh()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
GetMesh()->SetHiddenInGame(true);
// Third-person boom. bDoCollisionTest is the camera-collision feature: the arm
// shortens when something is between the pivot and the desired camera position.
ThirdPersonArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("ThirdPersonArm"));
ThirdPersonArm->SetupAttachment(GetCapsuleComponent());
ThirdPersonArm->SetRelativeLocation(FVector(0.0f, 0.0f, 60.0f));
ThirdPersonArm->TargetArmLength = ThirdPersonBoomLength;
ThirdPersonArm->bUsePawnControlRotation = true;
ThirdPersonArm->bDoCollisionTest = true;
ThirdPersonArm->ProbeSize = 12.0f;
ThirdPersonArm->ProbeChannel = ECollisionChannel::ECC_Camera;
ThirdPersonArm->bEnableCameraLag = true;
ThirdPersonArm->CameraLagSpeed = 8.0f;
ThirdPersonArm->SetAutoActivate(false);
ThirdPersonCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("ThirdPersonCamera"));
ThirdPersonCamera->SetupAttachment(ThirdPersonArm, USpringArmComponent::SocketName);
ThirdPersonCamera->bUsePawnControlRotation = false;
ThirdPersonCamera->SetAutoActivate(false);
ThirdPersonCamera->SetFieldOfView(90.0f);
// Hard-reference the Enhanced Input assets with ConstructorHelpers so the
// cooker always includes them and they load during CDO construction. Runtime
@@ -78,6 +131,8 @@ AGigLineCharacter::AGigLineCharacter()
TEXT("/Game/Input/IA_Jump.IA_Jump"));
static ConstructorHelpers::FObjectFinder<UInputAction> ResetF(
TEXT("/Game/Input/IA_Reset.IA_Reset"));
static ConstructorHelpers::FObjectFinder<UInputAction> CamF(
TEXT("/Game/Input/IA_ToggleCamera.IA_ToggleCamera"));
static ConstructorHelpers::FObjectFinder<UInputAction> PauseF(
TEXT("/Game/Input/IA_Pause.IA_Pause"));
@@ -89,6 +144,7 @@ AGigLineCharacter::AGigLineCharacter()
LookAction = LookF.Object;
JumpAction = JumpF.Object;
ResetAction = ResetF.Object;
ToggleCameraAction = CamF.Object;
PauseAction = PauseF.Object;
if (!MappingContext || !MoveAction || !MoveBackAction || !StrafeAction || !StrafeLeftAction
@@ -138,6 +194,23 @@ void AGigLineCharacter::BeginPlay()
StartLocation = GetActorLocation();
StartRotation = GetActorRotation();
// `-thirdperson` starts the pawn in the third-person rig. The first-person view
// stays the default (owner decision for 0.1.0), but the screenshot run needs the
// body visible without a human pressing V, so the review is reproducible.
if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
UE_LOG(LogTemp, Log,
TEXT("GigLine: BeginPlay thirdperson=%d body=%s idle=%s walk=%s"),
FParse::Param(FCommandLine::Get(), TEXT("thirdperson")) ? 1 : 0,
GetMesh()->GetSkeletalMeshAsset() ? TEXT("ok") : TEXT("nil"),
IdleAnimation ? TEXT("ok") : TEXT("nil"),
WalkAnimation ? TEXT("ok") : TEXT("nil"));
}
if (FParse::Param(FCommandLine::Get(), TEXT("thirdperson")))
{
ToggleCamera();
}
// The PlayerStart sits above grade so the capsule cannot spawn embedded, so
// the BeginPlay position is mid-air. Re-capture once the pawn has landed,
// otherwise Reset drops the player 1.4 m back into the air every press.
@@ -557,6 +630,10 @@ void AGigLineCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputCo
{
Input->BindAction(PauseAction, ETriggerEvent::Started, this, &AGigLineCharacter::TogglePause);
}
if (ToggleCameraAction)
{
Input->BindAction(ToggleCameraAction, ETriggerEvent::Started, this, &AGigLineCharacter::ToggleCamera);
}
if (JumpAction)
{
Input->BindAction(JumpAction, ETriggerEvent::Started, this, &AGigLineCharacter::StartJump);
@@ -644,6 +721,68 @@ void AGigLineCharacter::Look(const FInputActionValue& Value)
AddControllerPitchInput(Axis.Y * LookSensitivity * (bInvertLookY ? -1.0f : 1.0f));
}
void AGigLineCharacter::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
// Speed-driven animation state: correct whether the player walked, was pushed,
// or fell, and independent of which input produced the movement.
UpdateBodyAnimation();
}
void AGigLineCharacter::ToggleCamera()
{
// Both rigs exist from construction; the toggle only swaps which one is active
// and whether the body is drawn. Deactivating the other camera matters: with
// two active cameras the view is whichever was registered last.
bThirdPerson = !bThirdPerson;
FirstPersonCamera->SetActive(!bThirdPerson);
ThirdPersonCamera->SetActive(bThirdPerson);
GetMesh()->SetHiddenInGame(!bThirdPerson);
if (bThirdPerson)
{
UpdateBodyAnimation();
}
if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
UE_LOG(LogTemp, Log, TEXT("GigLine: camera %s"), bThirdPerson ? TEXT("third person") : TEXT("first person"));
}
}
void AGigLineCharacter::UpdateBodyAnimation()
{
if (!bThirdPerson || !GetMesh())
{
return;
}
const bool bMoving = GetVelocity().Size2D() > WalkAnimationThreshold;
UAnimSequence* Wanted = bMoving ? WalkAnimation.Get() : IdleAnimation.Get();
if (!Wanted)
{
// Logged once: a missing clip presents as an A-posed body, which looks like
// an animation problem rather than a missing asset reference.
static bool bWarnedMissingClip = false;
if (!bWarnedMissingClip)
{
bWarnedMissingClip = true;
UE_LOG(LogTemp, Warning,
TEXT("GigLine: body clip missing (idle=%s walk=%s) - body stays in bind pose"),
IdleAnimation ? TEXT("ok") : TEXT("nil"),
WalkAnimation ? TEXT("ok") : TEXT("nil"));
}
return;
}
if (Wanted == PlayingAnimation)
{
return;
}
PlayingAnimation = Wanted;
if (CVarGigLineSetupLogs.GetValueOnGameThread())
{
UE_LOG(LogTemp, Log, TEXT("GigLine: playing body clip %s"), *Wanted->GetName());
}
GetMesh()->PlayAnimation(Wanted, true);
}
void AGigLineCharacter::ResetToStart()
{
// Reset is a level-restart in miniature: put the body back where it spawned,