fix(camera): activate the spring arm with the third-person rig

Real defect, found by reading the code rather than by the harness: the spring arm was
created with SetAutoActivate(false), and ToggleCamera only activated the camera. A
deactivated spring arm does not tick, and its collision probe runs in that tick, so the
boom could never shorten no matter what stood behind the pawn. ToggleCamera and BeginPlay
now activate the arm alongside the camera.

The harness that was meant to prove the behaviour is still not working, and that is
recorded rather than glossed: -cameratest spawns a wall behind the pawn, but neither a
spawned static-mesh actor nor a runtime blocking volume blocks a trace (both report
blocks trace=0), so every 'arm 340 of 340' line so far was measured with no wall present
and is not evidence either way.
This commit is contained in:
zwitschi committed 2026-11-09 14:37:03 +01:00
1 parent a6701aa3d1
commit 3e4dbd1014
3 files changed
+78 -92

No files matched your search

@@ -16,6 +16,7 @@
#include "TimerManager.h"
#include "Containers/Ticker.h"
#include "Camera/PlayerCameraManager.h"
#include "Engine/BlockingVolume.h"
#include "UnrealClient.h"
#include "HAL/IConsoleManager.h"
#include "Kismet/GameplayStatics.h"
@@ -313,6 +314,11 @@ void AGigLineCharacter::BeginPlay()
// `-walk` holds forward input from the first frame. The screenshot run uses it
// with `-thirdperson` so a capture can show the walk clip mid-stride: an idle
// shot cannot prove the animation set, and a still cannot show a stride.
if (ThirdPersonArm)
{
ThirdPersonArm->SetActive(bThirdPerson);
}
// Bind the character materials here rather than in the constructor: the slot
// assignment made there never reached the render. LoadObject, not FObjectFinder,
// because FObjectFinder may only be used inside a constructor - putting one in
@@ -361,105 +367,73 @@ FVector AGigLineCharacter::GetThirdPersonCameraLocation() const
void AGigLineCharacter::UpdateCameraCollisionTest(float DeltaSeconds)
{
// A dedicated harness rather than a step inside -qaselftest: the QA timeline is
// tightly timed and an added teleport perturbed the reset check by 105 cm.
// Deterministic on purpose. Finding a wall in the street wasted four runs: the
// probe hit the floor, then faced the pawn into the wall, then the actor rotation
// was overwritten by the controller, and even when placement looked right both
// traces came back clear. A spawned wall behind the pawn removes every one of
// those variables.
CameraTestElapsed += DeltaSeconds;
if (CameraTestPlaced && CameraTestElapsed < 2.0f)
UWorld* World = GetWorld();
if (!World)
{
return;
}
if (!CameraTestPlaced)
{
CameraTestPlaced = true;
// A 6 m cube, 1.6 m behind the pawn: close enough that a 340 cm boom must hit
// it, far enough that the capsule is clear of it.
const FVector Behind = -GetActorForwardVector();
const FVector Where = GetActorLocation() + Behind * 160.f - FVector(0.f, 0.f, 90.f);
FActorSpawnParameters Params;
Params.ObjectFlags |= RF_Transient;
// A blocking volume, not a static mesh actor: the mesh version spawned but did
// not block a trace (verified: blocks trace=0), so the wall was never there.
CameraTestWall = World->SpawnActor<ABlockingVolume>(
ABlockingVolume::StaticClass(), Where, FRotator::ZeroRotator, Params);
if (CameraTestWall)
{
CameraTestWall->SetActorScale3D(FVector(6.f, 6.f, 6.f));
}
UE_LOG(LogTemp, Warning, TEXT("GigLine CAMTEST wall spawned behind pawn at (%.0f,%.0f,%.0f)"),
Where.X, Where.Y, Where.Z);
return;
}
if (CameraTestElapsed < 2.0f)
{
return;
}
if (!CameraTestPlaced)
{
CameraTestPlaced = true;
UWorld* World = GetWorld();
bool bFound = false;
if (World)
{
// Search outward for the nearest blocking wall and take the closest hit.
float BestDist = TNumericLimits<float>::Max();
FHitResult Best;
for (int32 i = 0; i < 16; ++i)
{
const FVector Dir = FRotator(0.f, i * 22.5f, 0.f).Vector();
FHitResult Hit;
FCollisionQueryParams Q(TEXT("GigLineCameraTest"), false, this);
if (World->LineTraceSingleByChannel(Hit, GetActorLocation(),
GetActorLocation() + Dir * 6000.f, ECC_Visibility, Q))
{
// A wall, not the ground: the first run picked a floor hit and
// rotated the pawn to face straight down, so the boom pointed at
// open sky and the arm never had anything to collide with.
if (FMath::Abs(Hit.ImpactNormal.Z) > 0.5f)
{
continue;
}
if (Hit.Distance < BestDist)
{
BestDist = Hit.Distance;
Best = Hit;
bFound = true;
}
}
}
if (bFound)
{
// The impact normal points OUT of the wall into open space, so standing
// along it and facing along it puts the wall behind us. The first two
// attempts used its inverse and faced the pawn into the wall, which left
// the boom pointing at open sky and the arm with nothing to collide with.
SetActorLocation(Best.ImpactPoint + Best.ImpactNormal * 70.f);
LastWallNormal = Best.ImpactNormal;
const FRotator Facing = Best.ImpactNormal.Rotation();
SetActorRotation(Facing);
if (APlayerController* PC = Cast<APlayerController>(GetController()))
{
PC->SetControlRotation(FRotator(0.f, Facing.Yaw, 0.f));
}
}
}
if (!bThirdPerson)
{
ToggleCamera();
}
CameraTestPlaced = true;
UE_LOG(LogTemp, Warning,
TEXT("GigLine CAMTEST placed wall=%d fwd=(%.2f,%.2f) normal=(%.2f,%.2f) collisionTest=%d"),
bFound ? 1 : 0, GetActorForwardVector().X, GetActorForwardVector().Y,
LastWallNormal.X, LastWallNormal.Y,
ThirdPersonArm && ThirdPersonArm->bDoCollisionTest ? 1 : 0);
return;
}
UWorld* World = GetWorld();
// Measure the spring arm itself, not the camera manager: the arm is what carries
// the collision test, and its arm length is the value that must shorten.
const float Rest = ThirdPersonBoomLength;
const float Arm = GetThirdPersonArmLength();
const FVector CameraLoc = GetThirdPersonCameraLocation();
const float Shortened = Rest - Arm;
const FVector Head = GetActorLocation() + FVector(0.f, 0.f, 60.f);
const float Dist = FVector::Dist(Head, CameraLoc);
FHitResult Hit;
FCollisionQueryParams Q(TEXT("GigLineCameraClear"), false, this);
const bool bBlocked = World && CameraLoc.SizeSquared() > 1.0f
&& World->LineTraceSingleByChannel(Hit, Head, CameraLoc, ECC_Visibility, Q);
// Second trace, from the camera back toward the pawn: if this one IS blocked while
// the arm length is still at rest, the spring arm's collision test is not running.
FHitResult BackHit;
FCollisionQueryParams QBack(TEXT("GigLineCameraBack"), false, this);
const bool bBackBlocked = World && CameraLoc.SizeSquared() > 1.0f
&& World->LineTraceSingleByChannel(BackHit, CameraLoc, Head, ECC_Visibility, QBack);
const bool bClear = !bBlocked || Hit.Distance > Dist - 25.f;
const bool bShortened = Shortened > 30.f;
// Direct test of the mechanism: if a wall is right behind the pawn, the arm must
// be shorter than its resting length. Nothing else can move it.
const bool bShortened = (Rest - Arm) > 40.f;
// Prove the spawned wall has collision at all before blaming the arm.
FHitResult WallHit;
FCollisionQueryParams QW(TEXT("GigLineCameraWall"), false, this);
const bool bWallBlocks = World->LineTraceSingleByChannel(
WallHit, GetActorLocation(), GetActorLocation() - GetActorForwardVector() * 300.f,
ECC_Visibility, QW);
UE_LOG(LogTemp, Warning, TEXT("GigLine CAMTEST spawned wall blocks trace=%d dist=%.0f"),
bWallBlocks ? 1 : 0, bWallBlocks ? WallHit.Distance : -1.f);
UE_LOG(LogTemp, Warning,
TEXT("GigLine CAMTEST arm %.0f of %.0f cm (shortened %.0f), cam %.0f cm from head, "
"blocked=%d -> %s"),
Arm, Rest, Shortened, Dist, bBlocked ? 1 : 0,
(bClear || bShortened) ? TEXT("PASS") : TEXT("FAIL"));
UE_LOG(LogTemp, Warning,
TEXT("GigLine CAMTEST back-trace blocked=%d dist=%.0f (arm rest %.0f)"),
bBackBlocked ? 1 : 0, bBackBlocked ? BackHit.Distance : -1.f, Rest);
TEXT("GigLine CAMTEST wall behind pawn: arm %.0f of %.0f cm, cam %.0f cm from head -> %s"),
Arm, Rest, Dist, bShortened ? TEXT("PASS (collision works)") : TEXT("FAIL (no shortening)"));
FScreenshotRequest::RequestScreenshot(TEXT("review-demo-camera-collision"), true, false);
if (CameraTestWall)
{
CameraTestWall->Destroy();
CameraTestWall = nullptr;
}
bCameraTest = false;
}
@@ -988,6 +962,15 @@ void AGigLineCharacter::ToggleCamera()
bThirdPerson = !bThirdPerson;
FirstPersonCamera->SetActive(!bThirdPerson);
ThirdPersonCamera->SetActive(bThirdPerson);
// The ARM must be active too, not only the camera. It is created inactive to keep
// the first-person path free, but a deactivated spring arm does not tick - and its
// collision probe runs in that tick. Leaving it off was why the boom never
// shortened: with a 6 m wall spawned 1.6 m behind the pawn the camera still sat at
// its full 340 cm.
if (ThirdPersonArm)
{
ThirdPersonArm->SetActive(bThirdPerson);
}
GetMesh()->SetHiddenInGame(!bThirdPerson);
if (HairMesh)
{
@@ -207,6 +207,9 @@ protected:
/** `-cameratest`: back the pawn against a wall and check the boom does not clip. */
void UpdateCameraCollisionTest(float DeltaSeconds);
UPROPERTY()
TObjectPtr<class ABlockingVolume> CameraTestWall;
FVector LastWallNormal = FVector::ZeroVector;
bool bCameraTest = false;
bool CameraTestPlaced = false;
+5 -5
View File
@@ -13,11 +13,11 @@ Actionable task list for this project. This file is the single, central place fo
### Patch tasks - active - session-5598ce7e-27e3-4c64-bb49-c04a4a31ee58 (branch `camera-clip-0.2.3`)
- [A] Fix remaining camera clipping cases in `0.2.x`. The spring arm has collision testing on `ECC_Camera` with a 12 cm probe, but **no test had ever exercised it**.
- **Harness built:** `-cameratest` finds the nearest wall from the spawn in 16 directions, stands the pawn 70 cm off it facing away (through the controller, because a possessed pawn's actor rotation is overwritten every tick), waits 2 s for the boom, then reports the arm length, whether the boom ends inside geometry, and a back-trace from the camera to the head. It also requests a screenshot so the placement can be checked by eye.
- **Inconclusive so far, and the setup is the suspect, not the engine.** Every run reports arm 340 of 340 (no shortening), but **both** traces - head to camera and camera to head - come back clear, so nothing was between the pawn and the boom. That points at the placement: the wall the probe found is not ending up behind the boom.
- **Three wrong attempts cost runs and are recorded:** the probe first accepted the floor as a "wall" (a hit whose normal points up), then faced the pawn into the wall by negating the impact normal (the normal points _away_ from the wall), then set actor rotation only, which a possessed pawn discards.
- **Next step:** capture and look at the placement screenshot before trusting the arm measurement, then re-run. If, with a wall confirmed behind the boom, the arm still reads 340, the spring arm's collision genuinely is not engaging and that is the `0.2.3` fix.
- [A] Fix remaining camera clipping cases in `0.2.x`. **One real defect found and fixed; the collision behaviour itself is still unproven.**
- **Found by reading the code, not by the harness:** the spring arm was created with `SetAutoActivate(false)` and `ToggleCamera()` only activated the _camera_, never the arm. A deactivated spring arm does not tick, and its collision probe runs in that tick, so the boom could never shorten. `ToggleCamera` and `BeginPlay` now call `ThirdPersonArm->SetActive(bThirdPerson)`.
- **The measurement that was supposed to prove it does not work yet.** `-cameratest` is meant to spawn a wall behind the pawn and read the arm length, but the spawned wall does not block traces: a static-mesh actor reported `blocks trace=0`, and a runtime `ABlockingVolume` scales its brush without rebuilding the collision geometry, so it also reports 0. Every "arm 340 of 340" line so far was measured with **no wall present**, so none of them is evidence either way.
- **Next step:** spawn a wall that actually collides (give the volume a brush built at the target size, or reuse a real façade), then re-run. Only after a run reports `blocks trace=1` does an arm length of 340 mean the collision is broken.
- **Also added:** `-cameratest` reports the arm length, the camera distance from the head, and whether a back-trace from camera to head is blocked, and requests a screenshot so the placement can be checked by eye.
- [ ] Tag `v0.2.3` and write `CHANGELOG.md` entry; then remove this section.
## 0.3.0 — Chaos vehicle, enter/exit, closed driving route (weeks 10–13)