// Look scenarios (DESIGN_UE.md sections 7.10 and 7.4, requirements V1, U6, U12): look_sweep (finds the exposure), // look_calibrate, look_deep_lamp, look_deep_dark, look_near_wall, look_throat, look_bloom, look_dry_mask. // Every scenario possesses a fly pawn; its headlamp is the lamp of the test (the Tender-headlamp numbers of section 7.5). #include "Debug/DescentScenario.h" #include "Debug/DescentStateRegistry.h" #include "Core/DescentEnvQuery.h" #include "Core/Tuning/DescentTune_Look.h" #include "World/DescentWorldScenarioSteps.h" #include "Components/StaticMeshComponent.h" using namespace DescentWorldScn; namespace LookTune = Descent::Tune::Look; namespace { /** The Great Void's middle (section 2.8): the calibration places, far from every wall. */ const FVector LookVoidCentre(640.0, -230.0, -700.0); FString LookSnapString(const FDescentScenarioContext& C, const TCHAR* Section, const TCHAR* Field) { const TSharedRef Snap = C.Snapshot(); const TSharedPtr* Sec = nullptr; FString V; if (Snap->TryGetObjectField(Section, Sec) && Sec) { (*Sec)->TryGetStringField(Field, V); } return V; } bool LookSnapBool(const FDescentScenarioContext& C, const TCHAR* Section, const TCHAR* Field) { const TSharedRef Snap = C.Snapshot(); const TSharedPtr* Sec = nullptr; bool V = false; if (Snap->TryGetObjectField(Section, Sec) && Sec) { (*Sec)->TryGetBoolField(Field, V); } return V; } /** Puts the view in front of the rock wall east of the Great Void, Distance metres from it, looking at it; false if no wall is found. */ bool LookPlaceBeforeWall(UWorld* World, double DistanceM, double ZOffset = 0.0) { UDescentTerrainSubsystem* T = UDescentTerrainSubsystem::Get(World); if (!T) { return false; } const FVector Origin(880.0, -230.0, -700.0 + ZOffset); const double Hit = T->Raycast(Origin, FVector(1, 0, 0), 300.0); if (Hit <= 0.0) { return false; } const FVector Wall = Origin + FVector(Hit, 0, 0); return PlaceView(World, Wall - FVector(DistanceM, 0, 0), Wall, 60.f); } /** One calibration measurement: the grey wall (albedo 0.30, 40 x 40 m) at Distance metres in front of the lamp, the lamp's centre luma. */ void LookWallShot(FDescentScenarioBuilder& S, const FString& Label, double Distance) { S.Do(FString::Printf(TEXT("wall at %.0f m"), Distance), [Distance](FDescentScenarioContext& C) { AActor* Wall = C.GetNamed(TEXT("Wall")); if (!Wall) { // Square to the lamp: the plane's normal (+Z) is turned to -X by a pitch of 90 degrees. Wall = SpawnTestSquare(C.World, LookVoidCentre + FVector(Distance, 0, 0), FRotator(90.0, 0.0, 0.0), 40.0, FLinearColor(0.30f, 0.30f, 0.30f)); if (!Wall) { C.Fail(TEXT("could not spawn the test wall")); return; } C.SetNamed(TEXT("Wall"), Wall); } Wall->SetActorLocation(Descent::MetresToUU(LookVoidCentre + FVector(Distance, 0, 0))); }); ShotAllowDark(S, Label); } void LookStartInVoid(FDescentScenarioBuilder& S, bool bLamp) { StartWithFlyPawn(S); S.Do(TEXT("view from the middle of the Great Void"), [bLamp](FDescentScenarioContext& C) { PlaceView(C.World, LookVoidCentre, LookVoidCentre + FVector(100, 0, 0), 60.f); }); S.Exec(bLamp ? TEXT("Descent.Look.Set Lamp 1") : TEXT("Descent.Look.Set Lamp 0")); S.WaitFrames(3); } } DESCENT_SCENARIO(look_sweep, "Not a test: logs the centre luma of the grey wall at 10 / 20 / 40 m for a range of exposure values, to choose descent.Look.ExposureEV.") { S.TimeLimit(900.f); LookStartInVoid(S, true); static const double Evs[] = { -6.0, -5.0, -4.0, -3.0, -2.0, -1.0, 0.0, 1.0 }; for (const double Ev : Evs) { S.Exec(FString::Printf(TEXT("Descent.Look.Set Exposure %g"), Ev)); for (const double D : { 10.0, 20.0, 40.0 }) { LookWallShot(S, FString::Printf(TEXT("ev%g_d%g"), Ev, D), D); S.Do(TEXT("log"), [Ev, D](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("SWEEP ev=%g d=%g centre=%.4f mean=%.4f p99=%.4f"), Ev, D, C.LastShotStats.CentreLuma, C.LastShotStats.MeanLuma, C.LastShotStats.P99Luma); }); } } S.Quit(); } DESCENT_SCENARIO(look_calibrate, "Grey wall (albedo 0.30, 40 x 40 m) square to the lamp at 10 / 20 / 40 m in the Great Void: centre luma must fall in 0.38-0.50 / 0.25-0.40 / 0.06-0.18.") { S.TimeLimit(300.f); LookStartInVoid(S, true); LookWallShot(S, TEXT("wall10"), 10.0); S.Expect(TEXT("centre_luma at 10 m in 0.38 - 0.50"), [](FDescentScenarioContext& C) { return C.LastShotStats.CentreLuma >= LookTune::CalibWall10Lo && C.LastShotStats.CentreLuma <= LookTune::CalibWall10Hi; }); LookWallShot(S, TEXT("wall20"), 20.0); S.Expect(TEXT("centre_luma at 20 m in 0.25 - 0.40"), [](FDescentScenarioContext& C) { return C.LastShotStats.CentreLuma >= LookTune::CalibWall20Lo && C.LastShotStats.CentreLuma <= LookTune::CalibWall20Hi; }); LookWallShot(S, TEXT("wall40"), 40.0); S.Expect(TEXT("centre_luma at 40 m in 0.06 - 0.18"), [](FDescentScenarioContext& C) { return C.LastShotStats.CentreLuma >= LookTune::CalibWall40Lo && C.LastShotStats.CentreLuma <= LookTune::CalibWall40Hi; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(look_deep_lamp, "In the Great Void, 15 m from a rock wall, lamp on: centre luma 0.06 - 0.35.") { S.TimeLimit(300.f); StartWithFlyPawn(S); S.Do(TEXT("15 m before the east wall"), [](FDescentScenarioContext& C) { if (!LookPlaceBeforeWall(C.World, 15.0)) { C.Fail(TEXT("no wall east of the Great Void")); } }); S.Exec(TEXT("Descent.Look.Set Lamp 1")); WaitStreamIdle(S); ShotAllowDark(S, TEXT("deep_lamp")); S.Expect(TEXT("centre_luma 0.06 - 0.35"), [](FDescentScenarioContext& C) { return C.LastShotStats.CentreLuma >= LookTune::DeepLampLo && C.LastShotStats.CentreLuma <= LookTune::DeepLampHi; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(look_deep_dark, "The same place, lamp off: the frame is black, mean luma < 0.03.") { S.TimeLimit(300.f); StartWithFlyPawn(S); S.Do(TEXT("15 m before the east wall"), [](FDescentScenarioContext& C) { if (!LookPlaceBeforeWall(C.World, 15.0)) { C.Fail(TEXT("no wall east of the Great Void")); } }); S.Exec(TEXT("Descent.Look.Set Lamp 0")); WaitStreamIdle(S); ShotAllowDark(S, TEXT("deep_dark")); S.Expect(TEXT("mean_luma < 0.03"), [](FDescentScenarioContext& C) { return C.LastShotStats.MeanLuma < LookTune::DeepDarkMax; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(look_near_wall, "6 m from a rock wall, lamp on: centre luma at most 0.55 (v2 measured 0.57, 'too bright').") { S.TimeLimit(300.f); StartWithFlyPawn(S); S.Do(TEXT("6 m before the east wall"), [](FDescentScenarioContext& C) { if (!LookPlaceBeforeWall(C.World, 6.0)) { C.Fail(TEXT("no wall east of the Great Void")); } }); S.Exec(TEXT("Descent.Look.Set Lamp 1")); WaitStreamIdle(S); ShotAllowDark(S, TEXT("near_wall")); S.Expect(TEXT("centre_luma <= 0.55"), [](FDescentScenarioContext& C) { return C.LastShotStats.CentreLuma <= LookTune::NearWallMax; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(look_throat, "The Throat at the start depth (Z -30), 250 m down the canyon from the carrier, lamp off, looking up the canyon: mean luma 0.05 - 0.25.") { S.TimeLimit(300.f); StartWithFlyPawn(S); S.Do(TEXT("start depth"), [](FDescentScenarioContext& C) { PlaceView(C.World, FVector(250, 19, -30), FVector(310, 19, 5), 75.f); }); // the carrier sits at (0, 0, -30), 122 m long: stay clear of it S.Exec(TEXT("Descent.Look.Set Lamp 0")); WaitStreamIdle(S); ShotAllowDark(S, TEXT("throat")); S.Expect(TEXT("mean_luma 0.05 - 0.25"), [](FDescentScenarioContext& C) { return C.LastShotStats.MeanLuma >= LookTune::ThroatLo && C.LastShotStats.MeanLuma <= LookTune::ThroatHi; }); S.Expect(TEXT("the sun is on at this depth"), [](FDescentScenarioContext& C) { return LookSnapBool(C, TEXT("env"), TEXT("sun_on")); }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(look_bloom, "U12: a 0.30 m sphere with emissive 6.0 at 10 m in darkness. The ring between 3 and 6 sphere radii is at most 4 percent of the sphere's own luma; with Bloom 0 at most 1 percent.") { S.TimeLimit(300.f); LookStartInVoid(S, false); S.Exec(TEXT("Descent.Look.Set WaterPP 0")); // dry: the sea's haze must not enter the ring S.Do(TEXT("the emitter"), [](FDescentScenarioContext& C) { FActorSpawnParameters P; AStaticMeshActor* A = C.World->SpawnActor(Descent::MetresToUU(LookVoidCentre + FVector(10, 0, 0)), FRotator::ZeroRotator, P); UStaticMeshComponent* M = A->GetStaticMeshComponent(); M->SetMobility(EComponentMobility::Movable); M->SetStaticMesh(LoadObject(nullptr, TEXT("/Engine/BasicShapes/Sphere.Sphere"))); M->SetCollisionEnabled(ECollisionEnabled::NoCollision); M->SetWorldScale3D(FVector(0.6)); // the basic sphere has radius 0.5 m: this is a sphere of radius 0.30 m if (UMaterialInterface* Parent = LoadObject(nullptr, TEXT("/Game/Materials/M_DescentSurface.M_DescentSurface"))) { UMaterialInstanceDynamic* MID = UMaterialInstanceDynamic::Create(Parent, A); MID->SetVectorParameterValue(TEXT("BaseColor"), FLinearColor::Black); MID->SetVectorParameterValue(TEXT("EmissiveColor"), FLinearColor::White); MID->SetScalarParameterValue(TEXT("EmissiveStrength"), 6.0f); M->SetMaterial(0, MID); } }); for (const double Bloom : { 1.0, 0.0 }) { S.Exec(FString::Printf(TEXT("Descent.Look.Set Bloom %g"), Bloom)); ShotAllowDark(S, FString::Printf(TEXT("bloom%g"), Bloom)); S.Do(FString::Printf(TEXT("measure the ring (bloom %g)"), Bloom), [Bloom](FDescentScenarioContext& C) { // 60 degrees across the 720 px height: 10 m away, a 0.30 m radius is 0.03 / (2 tan 30) * 720 = 18.7 px. const double Cx = C.RequestedResolution.X * 0.5, Cy = C.RequestedResolution.Y * 0.5, R = 0.03 / (2.0 * FMath::Tan(FMath::DegreesToRadians(30.0))) * C.RequestedResolution.Y; const FString Stem = C.ScenarioName + FString::Printf(TEXT("_bloom%g"), Bloom); double Sphere = 0.0, Ring = 0.0, Near = 0.0; FString Err; if (!UDescentDebugSubsystem::MeasureShotRing(Stem, Cx, Cy, 0.0, R * 0.8, Sphere, Err) || !UDescentDebugSubsystem::MeasureShotRing(Stem, Cx, Cy, 3.0 * R, 6.0 * R, Ring, Err) || !UDescentDebugSubsystem::MeasureShotRing(Stem, Cx, Cy, 1.3 * R, 2.5 * R, Near, Err)) { C.Fail(Err); return; } const double Limit = Bloom > 0.0 ? LookTune::BloomRingMaxFraction : LookTune::BloomOffRingMaxFraction; UE_LOG(LogDescent, Display, TEXT("BLOOM %g: sphere luma %.4f, near ring (1.3-2.5 radii) luma %.5f, ring (3-6 radii) luma %.5f = %.2f%% (limit %.0f%%), radius %.1f px"), Bloom, Sphere, Near, Ring, Sphere > 0 ? 100.0 * Ring / Sphere : -1.0, Limit * 100.0, R); if (Sphere < 0.2) { C.Fail(FString::Printf(TEXT("the sphere is not bright (luma %.3f): the measurement is meaningless"), Sphere)); } else if (Ring > Limit * Sphere) { C.Fail(FString::Printf(TEXT("bloom %g: ring luma %.4f is more than %.0f%% of the sphere's %.4f"), Bloom, Ring, Limit * 100.0, Sphere)); } }); } S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(look_dry_mask, "U6 synthetic: a stencil-1 box in the sea keeps its pixels when the water post-process goes off (the sea around it changes); a temporary dry volume switches the environment to Dry, 26 C, no volumetric fog.") { S.TimeLimit(300.f); LookStartInVoid(S, true); S.Do(TEXT("a stencil-marked box 30 m ahead, lit by the lamp"), [](FDescentScenarioContext& C) { AStaticMeshActor* A = SpawnTestSquare(C.World, LookVoidCentre + FVector(30, 0, 0), FRotator(90.0, 0.0, 0.0), 14.0, FLinearColor(0.5f, 0.5f, 0.5f), 0.0, 1.0); if (!A) { C.Fail(TEXT("could not spawn the box")); return; } UStaticMeshComponent* M = A->GetStaticMeshComponent(); M->SetRenderCustomDepth(true); M->SetCustomDepthStencilValue(LookTune::DryStencilValue); // the dry mask (section 7.4) // A self-lit square in the open sea, not marked: the water must dim it (the sea at this depth is black, so it needs something to show on). SpawnTestSquare(C.World, LookVoidCentre + FVector(30, -14, 0), FRotator(90.0, 0.0, 0.0), 8.0, FLinearColor::Black, 0.8, 0.0); }); S.Exec(TEXT("Descent.Look.Set Exposure 0")); S.Expect(TEXT("in the sea: env.state is Water"), [](FDescentScenarioContext& C) { return LookSnapString(C, TEXT("env"), TEXT("state")) == TEXT("Water"); }); // The dry volume, registered through the core seam: a 60 m box around the camera. The water post-process spares the stencil-1 pixels // only while the camera is in dry air (section 7.4), so this is the situation the comparison needs. TSharedRef DryHandle = MakeShared(); S.Do(TEXT("register a dry volume around the camera"), [DryHandle](FDescentScenarioContext& C) { UDescentEnvQuerySubsystem* Q = UDescentEnvQuerySubsystem::Get(C.World); if (!Q) { C.Fail(TEXT("no environment query subsystem")); return; } const FVector Centre = Descent::MetresToUU(LookVoidCentre); *DryHandle = Q->RegisterDryVolume(TEXT("TestHull"), [Centre](const FVector& P) { return (P - Centre).GetAbsMax() < Descent::MetresToUU(30.0); }); }); S.WaitSim(1.0); S.Expect(TEXT("dry: env.state is Dry"), [](FDescentScenarioContext& C) { return LookSnapString(C, TEXT("env"), TEXT("state")) == TEXT("Dry"); }); S.Expect(TEXT("dry: no volumetric fog"), [](FDescentScenarioContext& C) { return !LookSnapBool(C, TEXT("env"), TEXT("volumetric_fog")); }); S.Expect(TEXT("dry: temperature 26.0 C"), [](FDescentScenarioContext& C) { return FMath::Abs(SnapNumber(C, TEXT("env"), TEXT("temperature_c")) - 26.0) < 1e-6; }); S.Expect(TEXT("dry: no marine snow visible"), [](FDescentScenarioContext& C) { return SnapNumber(C, TEXT("env"), TEXT("snow_instances_visible")) == 0.0; }); ShotAllowDark(S, TEXT("water_on")); S.Exec(TEXT("Descent.Look.Set WaterPP 0")); ShotAllowDark(S, TEXT("water_off")); S.Do(TEXT("compare the box and the sea around it"), [](FDescentScenarioContext& C) { double InOn = 0, InOff = 0, SeaOn = 0, SeaOff = 0; FString Err; // The box is 14 m at 30 m: 0.47 of the 60-degree frame height... take its middle fifth, and the self-lit square beside it. const bool bOk = UDescentDebugSubsystem::MeasureShotRegion(C.ScenarioName + TEXT("_water_on"), 0.46, 0.44, 0.54, 0.56, InOn, Err) && UDescentDebugSubsystem::MeasureShotRegion(C.ScenarioName + TEXT("_water_off"), 0.46, 0.44, 0.54, 0.56, InOff, Err) && UDescentDebugSubsystem::MeasureShotRegion(C.ScenarioName + TEXT("_water_on"), 0.24, 0.44, 0.31, 0.56, SeaOn, Err) && UDescentDebugSubsystem::MeasureShotRegion(C.ScenarioName + TEXT("_water_off"), 0.24, 0.44, 0.31, 0.56, SeaOff, Err); if (!bOk) { C.Fail(Err); return; } UE_LOG(LogDescent, Display, TEXT("DRYMASK interior on/off %.4f / %.4f (diff %.4f), sea on/off %.4f / %.4f (diff %.4f)"), InOn, InOff, FMath::Abs(InOn - InOff), SeaOn, SeaOff, FMath::Abs(SeaOn - SeaOff)); if (InOn < 0.02) { C.Fail(TEXT("the box is not visible in the shot")); } if (FMath::Abs(InOn - InOff) >= 0.01) { C.Fail(FString::Printf(TEXT("the stencil-marked box changed by %.4f when the water went off (must be < 0.01)"), FMath::Abs(InOn - InOff))); } if (FMath::Abs(SeaOn - SeaOff) < 0.01) { C.Fail(TEXT("the unmarked self-lit square in the sea did not change with the water: the post-process is not working")); } }); S.Exec(TEXT("Descent.Look.Set WaterPP 1")); // Seen from the sea, the same stencil-1 box is in the water like everything else: take the volume away and the box is fogged. S.Do(TEXT("remove the dry volume"), [DryHandle](FDescentScenarioContext& C) { UDescentEnvQuerySubsystem* Q = UDescentEnvQuerySubsystem::Get(C.World); if (Q) { Q->UnregisterDryVolume(*DryHandle); } }); S.WaitSim(0.5); S.Expect(TEXT("outside again: env.state is Water"), [](FDescentScenarioContext& C) { return LookSnapString(C, TEXT("env"), TEXT("state")) == TEXT("Water"); }); ShotAllowDark(S, TEXT("outside")); S.Do(TEXT("seen from the sea the marked box is fogged like the rest"), [](FDescentScenarioContext& C) { double Dry = 0, Wet = 0; FString Err; if (!UDescentDebugSubsystem::MeasureShotRegion(C.ScenarioName + TEXT("_water_on"), 0.46, 0.44, 0.54, 0.56, Dry, Err) || !UDescentDebugSubsystem::MeasureShotRegion(C.ScenarioName + TEXT("_outside"), 0.46, 0.44, 0.54, 0.56, Wet, Err)) { C.Fail(Err); return; } UE_LOG(LogDescent, Display, TEXT("DRYMASK box from inside the dry volume %.4f, from the sea %.4f"), Dry, Wet); if (Wet > Dry * 0.9) { C.Fail(TEXT("the stencil-marked box is not fogged when the camera is in the sea")); } }); S.DumpState(TEXT("end")); S.Quit(); }