fix(measure): the fragmentation was the instrument -- 309 pockets -> 96
Jahni walked the maze end to end while the metric called it 309 disconnected pockets whose largest held 176 cells, with the arrival mouth in a THREE-CELL pocket. Direct observation beat the instrument, again. Cause, found exactly: the stencil demanded a solid cell at precisely (X,Y,Z-1). No supporting-surface search, no slope tolerance, no step height. At step 1 a floor that slopes even slightly loses its solid cell under most of its air, so a walkable corridor dissolved into specks. Now: a bounded downward search for a COHERENT SUPPORTING SURFACE within a step-height tolerance (MaxStepHeight 45 cm = 1.8 voxels), a walkable-slope normal test (WalkableFloorAngle 44 deg), then the full capsule re-tested at the resolved support height. Deliberately NOT "any solid within N cells", which would accept ledges and isolated pixels. ⭐ ON THE WALKED MAZE (seed 0, index 50, slot 4): components 309 -> 96 largest 176 -> 1401 fit cells 2801 -> 6668 mouth comps 43/81 -> 147/1401 The 300-pocket world was a measurement artifact. Fifth time this session the instrument was the suspect; fourth time it was the culprit. ⚠️ AND IT IS NOT SELF-FLATTERING -- a metric change that only ever makes numbers rise is worthless, so it ships with controls that must REJECT: vertical shaft wall 0 fit cells isolated 1-voxel ledge 0 fit cells overhang underside 0 fit cells ramp steeper than 44 deg 0 fit cells coarse step 4 refused, not guessed Open flat-floor control: 6.13% of air cells fit, ONE component of 12,168 -- sensible, since only near-floor poses qualify in a tall room. ⚠️ BUT THE ROUTE STILL DOES NOT EXIST, and that part of H3 survives. The law is 1/8 before and after -- what changed is its honesty: the one connection used to be FloatingIslands through FOUR cells, and is now FlatPlain through 2,221 with a zero gap. Seven archetypes still have both mouths in separate player-fit components 56-169 voxels (14-42 m) apart, and those components are now large and real (663-2902 cells), not specks. ⇒ So the world HAS substantial connected player-sized space. The passage MOUTHS are not landing in it. That is the Tier 1 landing problem again, one level down: VF_SuggestLandingPoint aims at open AIR, and air is not where a body fits. Next task. Codex found three errors in my baseline quoting, all mine: I wrote "0/8 connected" while also saying one connects through four cells (it was 1/8), misquoted SurfaceWorld's gap as 1101 when the saved value is 110.648, and mixed provenance on the VerticalShafts row. Build clean, 31 succeeded / 0 failed. The 2 warnings are both deliberate: the lateral-regions gate, and a test reporting that its own sample "barely asked its question". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K8V9my1PL68pmQb2gutZZV
This commit is contained in:
1 parent
cf5f83e9f0
commit
110ae9d0ed
4 files changed
+790
-53
No files matched your search
@@ -4,6 +4,10 @@
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
|
||||
#include "Components/CapsuleComponent.h"
|
||||
#include "GameFramework/Character.h"
|
||||
#include "GameFramework/CharacterMovementComponent.h"
|
||||
|
||||
#include "VoxelCaveMorphology.h"
|
||||
#include "VoxelDensityOpStack.h"
|
||||
#include "VoxelForgeTestFixture.h"
|
||||
@@ -557,6 +561,126 @@ namespace
|
||||
const UVoxelGenerator& Generator;
|
||||
};
|
||||
|
||||
class FVerticalWallSampler final : public IVoxelStrateDensitySampler
|
||||
{
|
||||
public:
|
||||
float SampleDensity(float WorldX, float, float) const override
|
||||
{
|
||||
// A wall that spans the whole measured height has no lower supporting surface.
|
||||
// It catches a stencil that treats a nearby solid column as a floor pixel.
|
||||
return FMath::Abs(WorldX) < 1.0f ? -1.0f : 1.0f;
|
||||
}
|
||||
};
|
||||
|
||||
class FIsolatedLedgeSampler final : public IVoxelStrateDensitySampler
|
||||
{
|
||||
public:
|
||||
float SampleDensity(float WorldX, float WorldY, float WorldZ) const override
|
||||
{
|
||||
// With one-voxel sampling and bounds [-8,8), this is exactly one solid floor cell
|
||||
// at the (0.5,0.5,0.5) sample. It must fail the 4-of-5 support patch.
|
||||
const bool bSingleCell = WorldZ < 1.0f
|
||||
&& FMath::Abs(WorldX) < 1.0f
|
||||
&& FMath::Abs(WorldY) < 1.0f;
|
||||
return bSingleCell ? -1.0f : 1.0f;
|
||||
}
|
||||
};
|
||||
|
||||
class FOverhangSampler final : public IVoxelStrateDensitySampler
|
||||
{
|
||||
public:
|
||||
float SampleDensity(float, float, float WorldZ) const override
|
||||
{
|
||||
// Air below an overhang/ceiling, with solid continuing above the measured window.
|
||||
// A downward-only support search must not use that ceiling as a floor.
|
||||
return WorldZ >= 4.0f ? -1.0f : 1.0f;
|
||||
}
|
||||
};
|
||||
|
||||
class FSteepRampSampler final : public IVoxelStrateDensitySampler
|
||||
{
|
||||
public:
|
||||
float SampleDensity(float WorldX, float, float WorldZ) const override
|
||||
{
|
||||
// One voxel of rise per one voxel of run is 45 degrees. The project CDO's 44.8
|
||||
// degree limit must reject the support patch instead of averaging this into floor.
|
||||
const float FloorHeight = 8.0f + WorldX;
|
||||
return WorldZ < FloorHeight ? -1.0f : 1.0f;
|
||||
}
|
||||
};
|
||||
|
||||
bool VF_ApplyProjectCharacterFitDefaults(
|
||||
FAutomationTestBase& Test,
|
||||
FVoxelStrateMeasureSettings& InOutSettings)
|
||||
{
|
||||
constexpr const TCHAR* CharacterCDOPath =
|
||||
TEXT("/Game/FirstPerson/Blueprints/BP_FirstPersonCharacter.BP_FirstPersonCharacter_C");
|
||||
UClass* CharacterClass = LoadObject<UClass>(nullptr, CharacterCDOPath);
|
||||
if (CharacterClass == nullptr || !CharacterClass->IsChildOf(ACharacter::StaticClass()))
|
||||
{
|
||||
Test.AddError(FString::Printf(
|
||||
TEXT("Could not load the project character CDO at %s."), CharacterCDOPath));
|
||||
return false;
|
||||
}
|
||||
|
||||
const ACharacter* CharacterCDO = CharacterClass->GetDefaultObject<ACharacter>();
|
||||
const UCapsuleComponent* Capsule = CharacterCDO != nullptr
|
||||
? CharacterCDO->GetCapsuleComponent() : nullptr;
|
||||
const UCharacterMovementComponent* Movement = CharacterCDO != nullptr
|
||||
? CharacterCDO->GetCharacterMovement() : nullptr;
|
||||
if (Capsule == nullptr || Movement == nullptr)
|
||||
{
|
||||
Test.AddError(TEXT("The project character CDO has no capsule or movement component."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const float RadiusCentimeters = Capsule->GetUnscaledCapsuleRadius();
|
||||
const float HalfHeightCentimeters = Capsule->GetUnscaledCapsuleHalfHeight();
|
||||
const float MaxStepHeightMeters = Movement->MaxStepHeight / 100.0f;
|
||||
const float WalkableFloorAngleDegrees = Movement->GetWalkableFloorAngle();
|
||||
if (!FMath::IsFinite(RadiusCentimeters) || RadiusCentimeters <= 0.0f
|
||||
|| !FMath::IsFinite(HalfHeightCentimeters) || HalfHeightCentimeters <= 0.0f
|
||||
|| !FMath::IsFinite(MaxStepHeightMeters) || MaxStepHeightMeters < 0.0f
|
||||
|| !FMath::IsFinite(WalkableFloorAngleDegrees)
|
||||
|| WalkableFloorAngleDegrees < 0.0f || WalkableFloorAngleDegrees > 90.0f)
|
||||
{
|
||||
Test.AddError(TEXT("The project character CDO has invalid fit parameters."));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep the existing 34 cm / 88 cm metric capsule fixed for before/after comparability.
|
||||
// The task-specific movement limits below are read from the project character CDO.
|
||||
Test.AddInfo(FString::Printf(
|
||||
TEXT("PLAYER_CHARACTER capsule CDO reports radius=%.1f cm, half_height=%.1f cm; "
|
||||
"measurement dimensions remain radius=%.1f cm, half_height=%.1f cm "
|
||||
"to isolate the stencil change"),
|
||||
RadiusCentimeters,
|
||||
HalfHeightCentimeters,
|
||||
FVoxelPlayerCapsuleConstants::RadiusCentimeters,
|
||||
FVoxelPlayerCapsuleConstants::HalfHeightCentimeters));
|
||||
InOutSettings.PlayerMaxStepHeightMeters = MaxStepHeightMeters;
|
||||
InOutSettings.PlayerWalkableFloorAngleDegrees = WalkableFloorAngleDegrees;
|
||||
return true;
|
||||
}
|
||||
|
||||
int32 VF_CountSupportFootprintColumns(float RadiusVoxels)
|
||||
{
|
||||
const int32 MaxOffset = FMath::CeilToInt(RadiusVoxels);
|
||||
int32 Count = 0;
|
||||
for (int32 Y = -MaxOffset; Y <= MaxOffset; ++Y)
|
||||
{
|
||||
for (int32 X = -MaxOffset; X <= MaxOffset; ++X)
|
||||
{
|
||||
if (static_cast<float>(X * X + Y * Y)
|
||||
<= RadiusVoxels * RadiusVoxels + KINDA_SMALL_NUMBER)
|
||||
{
|
||||
++Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Count;
|
||||
}
|
||||
|
||||
float VF_BoundarySealForCandidate(const FVoxelStrateComposerCandidate& Candidate)
|
||||
{
|
||||
switch (Candidate.Archetype)
|
||||
@@ -639,6 +763,11 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
TestTrue(TEXT("scale diagnosis keeps WorldRadiusVoxels at zero"),
|
||||
World.Settings->WorldRadiusVoxels == 0.0f
|
||||
&& World.Generator->WorldRadiusVoxels == 0.0f);
|
||||
TestFalse(TEXT("scale diagnosis keeps lateral regions gated off"),
|
||||
VF_LateralRegionsAreShippable());
|
||||
|
||||
const FVoxelStrateComposerCandidate MazeCandidate = VF_RollStrateCandidate(
|
||||
Corpus, DiagnosisSeed, MazeCandidateIndex, false);
|
||||
@@ -757,9 +886,42 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
|
||||
ShaftCore.Negative.CrossingRadiusVoxels * 0.25f));
|
||||
}
|
||||
|
||||
FVoxelStrateMeasureSettings CharacterFitSettings;
|
||||
if (!VF_ApplyProjectCharacterFitDefaults(*this, CharacterFitSettings))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const float MinimumWalkableNormalZ = FMath::Cos(FMath::DegreesToRadians(
|
||||
CharacterFitSettings.PlayerWalkableFloorAngleDegrees));
|
||||
const int32 SupportFootprintColumns = VF_CountSupportFootprintColumns(
|
||||
CharacterFitSettings.PlayerCapsuleRadiusVoxels);
|
||||
const int32 RequiredSupportColumns = FMath::Clamp(
|
||||
FMath::CeilToInt(CharacterFitSettings.PlayerSupportPatchMinCoverageFraction
|
||||
* static_cast<float>(SupportFootprintColumns)),
|
||||
1, SupportFootprintColumns);
|
||||
const int32 MaxDownwardSearchCells = FMath::CeilToInt(
|
||||
CharacterFitSettings.PlayerMaxStepHeightMeters
|
||||
/ FVoxelPlayerCapsuleConstants::VoxelSizeMeters);
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("PLAYER_CHARACTER movement source=%s: MaxStepHeight=%.3f m "
|
||||
"(%.3f vox; search<=%d cells), WalkableFloorAngle=%.1f deg "
|
||||
"(normal_z>=%.6f); support_patch=%.1f%% requires %d/%d integer footprint columns "
|
||||
"and always requires the centre"),
|
||||
TEXT("/Game/FirstPerson/Blueprints/BP_FirstPersonCharacter CDO"),
|
||||
CharacterFitSettings.PlayerMaxStepHeightMeters,
|
||||
CharacterFitSettings.PlayerMaxStepHeightMeters
|
||||
/ FVoxelPlayerCapsuleConstants::VoxelSizeMeters,
|
||||
MaxDownwardSearchCells,
|
||||
CharacterFitSettings.PlayerWalkableFloorAngleDegrees,
|
||||
MinimumWalkableNormalZ,
|
||||
CharacterFitSettings.PlayerSupportPatchMinCoverageFraction * 100.0f,
|
||||
RequiredSupportColumns,
|
||||
SupportFootprintColumns));
|
||||
|
||||
// H2 — a known-open control with a solid floor. This must produce non-zero fit cells.
|
||||
FEmptyRoomSampler EmptyRoom;
|
||||
FVoxelStrateMeasureSettings ControlSettings;
|
||||
FVoxelStrateMeasureSettings ControlSettings = CharacterFitSettings;
|
||||
ControlSettings.SampleStep = 1;
|
||||
ControlSettings.RadiusInVoxels = 40; // 80 voxels = 20 m across.
|
||||
ControlSettings.CenterXY = FVector2D::ZeroVector;
|
||||
@@ -781,12 +943,65 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
|
||||
ControlMetrics.NumTraversableComponents,
|
||||
static_cast<long long>(ControlMetrics.LargestTraversableComponentCells),
|
||||
ControlMetrics.TraversableComponentShare));
|
||||
|
||||
FVoxelStrateMeasureSettings CoarseControlSettings = ControlSettings;
|
||||
CoarseControlSettings.SampleStep = 4;
|
||||
const FVoxelStrateMetrics CoarseControlMetrics = VF_MeasureStrateWithSampler(
|
||||
EmptyRoom, 0, 32, 0.0f, CoarseControlSettings, nullptr);
|
||||
const bool bCoarseRefused = CoarseControlMetrics.bValid
|
||||
&& !CoarseControlMetrics.bPlayerFitResolved
|
||||
&& CoarseControlMetrics.PlayerFitRefusalReason.Contains(TEXT("SampleStep=4"));
|
||||
TestTrue(TEXT("coarse player-fit control refuses unresolved capsule fit"), bCoarseRefused);
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("CONTROL coarse_step4_player_fit: refused=%s reason=%s"),
|
||||
bCoarseRefused ? TEXT("true") : TEXT("false"),
|
||||
CoarseControlMetrics.PlayerFitRefusalReason.IsEmpty()
|
||||
? TEXT("none") : *CoarseControlMetrics.PlayerFitRefusalReason));
|
||||
|
||||
// A permissive stencil must fail these deliberately pathological supports. The wall has no
|
||||
// downward transition, the ledge covers only 1/5 of the footprint, and the overhang has
|
||||
// solid above rather than below the candidate. All controls use the same character-derived
|
||||
// settings and a bounded 16x16x12-voxel window.
|
||||
FVoxelStrateMeasureSettings RejectionSettings = CharacterFitSettings;
|
||||
RejectionSettings.SampleStep = 1;
|
||||
RejectionSettings.RadiusInVoxels = 8;
|
||||
RejectionSettings.CenterXY = FVector2D::ZeroVector;
|
||||
RejectionSettings.MaxCells = 100000;
|
||||
RejectionSettings.InteriorMarginVoxels = 0;
|
||||
auto RunStencilRejectionControl = [this, &RejectionSettings](
|
||||
const TCHAR* Label, const IVoxelStrateDensitySampler& Sampler)
|
||||
{
|
||||
const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler(
|
||||
Sampler, 0, 12, 0.0f, RejectionSettings, nullptr);
|
||||
const bool bRejected = Metrics.bValid && Metrics.bPlayerFitResolved
|
||||
&& Metrics.NumPlayerFitCells == 0;
|
||||
TestTrue(FString::Printf(TEXT("stencil rejects %s"), Label), bRejected);
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("CONTROL stencil_rejection %s: rejected=%s fit_cells=%lld "
|
||||
"resolved=%s reason=%s"),
|
||||
Label,
|
||||
bRejected ? TEXT("true") : TEXT("false"),
|
||||
static_cast<long long>(Metrics.NumPlayerFitCells),
|
||||
Metrics.bPlayerFitResolved ? TEXT("true") : TEXT("false"),
|
||||
Metrics.PlayerFitRefusalReason.IsEmpty()
|
||||
? TEXT("none") : *Metrics.PlayerFitRefusalReason));
|
||||
};
|
||||
FVerticalWallSampler VerticalWall;
|
||||
FIsolatedLedgeSampler IsolatedLedge;
|
||||
FOverhangSampler Overhang;
|
||||
FSteepRampSampler SteepRamp;
|
||||
RunStencilRejectionControl(TEXT("vertical_shaft_wall"), VerticalWall);
|
||||
RunStencilRejectionControl(TEXT("isolated_1_voxel_ledge"), IsolatedLedge);
|
||||
RunStencilRejectionControl(TEXT("overhang_underside"), Overhang);
|
||||
RunStencilRejectionControl(TEXT("over_44_degree_ramp"), SteepRamp);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("PLAYER_STENCIL radius=%.3f voxels (%.2f m), half_height=%.3f voxels "
|
||||
"(%.2f m), height=%.3f voxels (%.2f m); rows=ceil(height)=%d "
|
||||
"relative rows 0..%d; floor=air anchor with exactly one solid cell at Z-1; "
|
||||
"no floor tolerance; horizontal integer offsets are center plus four cardinals "
|
||||
"in rows 0..6 and center-only forced in row 7; HeadroomCells=%d is legacy-only"),
|
||||
"relative rows 0..%d; support=nearest scalar solid-to-air surface searched "
|
||||
"down <=%.3f m/%d cells; support patch=%d/%d columns with centre required; "
|
||||
"walkable slope<=%.1f deg; capsule clearance is re-tested at resolved support; "
|
||||
"HeadroomCells=%d is legacy-only"),
|
||||
ControlSettings.PlayerCapsuleRadiusVoxels,
|
||||
ControlSettings.PlayerCapsuleRadiusVoxels * 0.25f,
|
||||
ControlSettings.PlayerCapsuleHalfHeightVoxels,
|
||||
@@ -795,6 +1010,11 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
|
||||
FVoxelPlayerCapsuleConstants::HeightVoxels * 0.25f,
|
||||
FMath::CeilToInt(2.0f * ControlSettings.PlayerCapsuleHalfHeightVoxels),
|
||||
FMath::CeilToInt(2.0f * ControlSettings.PlayerCapsuleHalfHeightVoxels) - 1,
|
||||
ControlSettings.PlayerMaxStepHeightMeters,
|
||||
MaxDownwardSearchCells,
|
||||
RequiredSupportColumns,
|
||||
SupportFootprintColumns,
|
||||
ControlSettings.PlayerWalkableFloorAngleDegrees,
|
||||
ControlSettings.HeadroomCells));
|
||||
|
||||
// H3 — the eight fixed showcase identities, measured at step 1 on a mouth-fitted window.
|
||||
@@ -818,13 +1038,44 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
|
||||
{ ECaveGeneratorType::VerticalShafts, 0, 34, FTestWorld::SlotFlatPlain, TEXT("VerticalShafts") },
|
||||
};
|
||||
|
||||
// Baseline rows from the pre-fix fitted-window report. The supplied VerticalShafts row is
|
||||
// retained verbatim (it came from the owner's scale report); it is deliberately labelled as
|
||||
// a comparison row rather than recomputed by this test.
|
||||
struct FShowcaseBaseline
|
||||
{
|
||||
int64 FitCells;
|
||||
int32 Components;
|
||||
int64 LargestComponent;
|
||||
int64 ArrivalComponent;
|
||||
int64 DepartureComponent;
|
||||
float MouthGap;
|
||||
const TCHAR* Law;
|
||||
};
|
||||
const FShowcaseBaseline Baselines[] = {
|
||||
{655, 197, 24, 1, 1, 47.529f, TEXT("NotConnectedAtThisResolution")},
|
||||
{1116, 45, 259, 1, 1, 48.104f, TEXT("NotConnectedAtThisResolution")},
|
||||
{4, 2, 3, 1, 1, 0.000f, TEXT("Connected")},
|
||||
{2801, 309, 176, 43, 81, 206.630f, TEXT("NotConnectedAtThisResolution")},
|
||||
{713, 520, 6, 1, 1, 110.648f, TEXT("NotConnectedAtThisResolution")},
|
||||
{2748, 467, 1292, 2, 1, 164.405f, TEXT("NotConnectedAtThisResolution")},
|
||||
{337, 86, 35, 3, 11, 142.499f, TEXT("NotConnectedAtThisResolution")},
|
||||
{1156, 112, 96, 3, 36, 94.000f, TEXT("NotConnectedAtThisResolution")},
|
||||
};
|
||||
static_assert(UE_ARRAY_COUNT(Selections) == UE_ARRAY_COUNT(Baselines),
|
||||
"H3 before/after rows must remain paired.");
|
||||
int32 NumH3Connected = 0;
|
||||
int32 NumH3DegenerateConnected = 0;
|
||||
int32 NumH3Measured = 0;
|
||||
|
||||
for (const FShowcaseSelection& Selection : Selections)
|
||||
{
|
||||
const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate(
|
||||
Corpus, Selection.Seed, Selection.CandidateIndex, false);
|
||||
TestTrue(FString::Printf(TEXT("H3 %s candidate identity is valid"), Selection.Label),
|
||||
Candidate.bValid && Candidate.Archetype == Selection.Archetype);
|
||||
if (!Candidate.bValid || Candidate.Archetype != Selection.Archetype)
|
||||
Candidate.bValid && Candidate.Archetype == Selection.Archetype
|
||||
&& Candidate.Regions.IsSingleRegion());
|
||||
if (!Candidate.bValid || Candidate.Archetype != Selection.Archetype
|
||||
|| !Candidate.Regions.IsSingleRegion())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -856,7 +1107,7 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
|
||||
continue;
|
||||
}
|
||||
|
||||
FVoxelStrateMeasureSettings Settings;
|
||||
FVoxelStrateMeasureSettings Settings = CharacterFitSettings;
|
||||
Settings.SampleStep = 1;
|
||||
Settings.RadiusInVoxels = 64;
|
||||
Settings.CenterXY = FVector2D(
|
||||
@@ -887,8 +1138,57 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
|
||||
VF_BoundarySealForCandidate(Candidate), ArrivalPoint, DeparturePoint,
|
||||
Settings, &Metrics);
|
||||
VF_ReportPlayerFitRow(*this, Selection.Label, Metrics, Diagnostics);
|
||||
++NumH3Measured;
|
||||
if (Diagnostics.Result == EVoxelConnectivityResult::Connected)
|
||||
{
|
||||
++NumH3Connected;
|
||||
if (Metrics.NumPlayerFitCells <= 4)
|
||||
{
|
||||
++NumH3DegenerateConnected;
|
||||
}
|
||||
}
|
||||
const FShowcaseBaseline& Before = Baselines[&Selection - Selections];
|
||||
FString DeltaReport = TEXT("H3_DELTA step1 ");
|
||||
DeltaReport += Selection.Label;
|
||||
DeltaReport += FString::Printf(
|
||||
TEXT(": before=%lld/%d/%lld/%lld/%lld/%.3f/"),
|
||||
static_cast<long long>(Before.FitCells), Before.Components,
|
||||
static_cast<long long>(Before.LargestComponent),
|
||||
static_cast<long long>(Before.ArrivalComponent),
|
||||
static_cast<long long>(Before.DepartureComponent), Before.MouthGap);
|
||||
DeltaReport += Before.Law;
|
||||
DeltaReport += FString::Printf(
|
||||
TEXT("; after=%lld/%d/%lld/%lld/%lld/%.3f/"),
|
||||
static_cast<long long>(Metrics.NumPlayerFitCells), Metrics.NumTraversableComponents,
|
||||
static_cast<long long>(Metrics.LargestTraversableComponentCells),
|
||||
static_cast<long long>(Diagnostics.StartComponentCells),
|
||||
static_cast<long long>(Diagnostics.GoalComponentCells),
|
||||
Diagnostics.StartToGoalComponentDistanceCells);
|
||||
DeltaReport += VF_DiagnosisConnectivityName(Diagnostics.Result);
|
||||
DeltaReport += FString::Printf(
|
||||
TEXT("; delta_fit=%lld delta_components=%d delta_largest=%lld "
|
||||
"delta_arrival=%lld delta_departure=%lld delta_gap=%.3f"),
|
||||
static_cast<long long>(Metrics.NumPlayerFitCells - Before.FitCells),
|
||||
Metrics.NumTraversableComponents - Before.Components,
|
||||
static_cast<long long>(Metrics.LargestTraversableComponentCells - Before.LargestComponent),
|
||||
static_cast<long long>(Diagnostics.StartComponentCells - Before.ArrivalComponent),
|
||||
static_cast<long long>(Diagnostics.GoalComponentCells - Before.DepartureComponent),
|
||||
Diagnostics.StartToGoalComponentDistanceCells - Before.MouthGap);
|
||||
AddInfo(DeltaReport);
|
||||
}
|
||||
|
||||
// The old tally's one Connected row was the four-cell FloatingIslands pocket. Keep that
|
||||
// degenerate fact visible beside the new tally so a tiny connected island is not mistaken for
|
||||
// an instrument-wide success.
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("LAW_TALLY restricted step1: before_connected=1/8 "
|
||||
"before_connected_through_4_cells=1; after_connected=%d/%d "
|
||||
"after_connected_through_4_cells=%d measured=%d/8"),
|
||||
NumH3Connected,
|
||||
UE_ARRAY_COUNT(Selections),
|
||||
NumH3DegenerateConnected,
|
||||
NumH3Measured));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1957,7 +1957,15 @@ namespace VoxelSeasonCompositionPrivate
|
||||
&& FMath::IsFinite(Settings.PlayerCapsuleRadiusVoxels)
|
||||
&& Settings.PlayerCapsuleRadiusVoxels > 0.0f
|
||||
&& FMath::IsFinite(Settings.PlayerCapsuleHalfHeightVoxels)
|
||||
&& Settings.PlayerCapsuleHalfHeightVoxels > 0.0f;
|
||||
&& Settings.PlayerCapsuleHalfHeightVoxels > 0.0f
|
||||
&& FMath::IsFinite(Settings.PlayerMaxStepHeightMeters)
|
||||
&& Settings.PlayerMaxStepHeightMeters >= 0.0f
|
||||
&& FMath::IsFinite(Settings.PlayerWalkableFloorAngleDegrees)
|
||||
&& Settings.PlayerWalkableFloorAngleDegrees >= 0.0f
|
||||
&& Settings.PlayerWalkableFloorAngleDegrees <= 90.0f
|
||||
&& FMath::IsFinite(Settings.PlayerSupportPatchMinCoverageFraction)
|
||||
&& Settings.PlayerSupportPatchMinCoverageFraction > 0.0f
|
||||
&& Settings.PlayerSupportPatchMinCoverageFraction <= 1.0f;
|
||||
}
|
||||
|
||||
bool IsBossSlot(int32 DepthIndex, int32 TotalStrates, int32 Interval)
|
||||
|
||||
@@ -34,7 +34,8 @@ namespace VoxelStrateMeasurePrivate
|
||||
|
||||
// 1 = air, 0 = solid. The polarity is deliberately explicit at the sampling site below.
|
||||
TArray<uint8> Air;
|
||||
// Optional exact scalar samples retained only for an explicit preview capture.
|
||||
// Exact scalar samples retained when the player-fit stencil needs a bounded local
|
||||
// surface/clearance interpolation, or when an explicit preview capture requests them.
|
||||
TArray<float> Density;
|
||||
|
||||
FORCEINLINE int32 Index(int32 X, int32 Y, int32 Z) const
|
||||
@@ -587,6 +588,302 @@ namespace VoxelStrateMeasurePrivate
|
||||
TArray<FIntPoint> HorizontalOffsets;
|
||||
};
|
||||
|
||||
bool HasScalarDensity(const FSampleGrid& Grid)
|
||||
{
|
||||
return Grid.Density.Num() == Grid.CellCount;
|
||||
}
|
||||
|
||||
float CellDensity(const FSampleGrid& Grid, int32 Cell)
|
||||
{
|
||||
return HasScalarDensity(Grid)
|
||||
? Grid.Density[Cell]
|
||||
: (Grid.Air[Cell] != 0u ? 1.0f : -1.0f);
|
||||
}
|
||||
|
||||
bool SampleGridDensity(
|
||||
const FSampleGrid& Grid,
|
||||
const FVector& Point,
|
||||
float& OutDensity)
|
||||
{
|
||||
if (!Grid.ContainsPoint(Point))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const float Step = static_cast<float>(Grid.SampleStep);
|
||||
const float LocalX = (Point.X - Grid.MinX) / Step - 0.5f;
|
||||
const float LocalY = (Point.Y - Grid.MinY) / Step - 0.5f;
|
||||
const float LocalZ = (Point.Z - Grid.MinZ) / Step - 0.5f;
|
||||
|
||||
if (!HasScalarDensity(Grid))
|
||||
{
|
||||
const int32 X = FMath::Clamp(
|
||||
FMath::FloorToInt((Point.X - Grid.MinX) / Step), 0, Grid.NumX - 1);
|
||||
const int32 Y = FMath::Clamp(
|
||||
FMath::FloorToInt((Point.Y - Grid.MinY) / Step), 0, Grid.NumY - 1);
|
||||
const int32 Z = FMath::Clamp(
|
||||
FMath::FloorToInt((Point.Z - Grid.MinZ) / Step), 0, Grid.NumZ - 1);
|
||||
OutDensity = CellDensity(Grid, Grid.Index(X, Y, Z));
|
||||
return FMath::IsFinite(OutDensity);
|
||||
}
|
||||
|
||||
const int32 X0 = FMath::Clamp(FMath::FloorToInt(LocalX), 0, Grid.NumX - 1);
|
||||
const int32 Y0 = FMath::Clamp(FMath::FloorToInt(LocalY), 0, Grid.NumY - 1);
|
||||
const int32 Z0 = FMath::Clamp(FMath::FloorToInt(LocalZ), 0, Grid.NumZ - 1);
|
||||
const int32 X1 = FMath::Min(X0 + 1, Grid.NumX - 1);
|
||||
const int32 Y1 = FMath::Min(Y0 + 1, Grid.NumY - 1);
|
||||
const int32 Z1 = FMath::Min(Z0 + 1, Grid.NumZ - 1);
|
||||
const float AlphaX = FMath::Clamp(LocalX - static_cast<float>(X0), 0.0f, 1.0f);
|
||||
const float AlphaY = FMath::Clamp(LocalY - static_cast<float>(Y0), 0.0f, 1.0f);
|
||||
const float AlphaZ = FMath::Clamp(LocalZ - static_cast<float>(Z0), 0.0f, 1.0f);
|
||||
|
||||
const float D000 = CellDensity(Grid, Grid.Index(X0, Y0, Z0));
|
||||
const float D100 = CellDensity(Grid, Grid.Index(X1, Y0, Z0));
|
||||
const float D010 = CellDensity(Grid, Grid.Index(X0, Y1, Z0));
|
||||
const float D110 = CellDensity(Grid, Grid.Index(X1, Y1, Z0));
|
||||
const float D001 = CellDensity(Grid, Grid.Index(X0, Y0, Z1));
|
||||
const float D101 = CellDensity(Grid, Grid.Index(X1, Y0, Z1));
|
||||
const float D011 = CellDensity(Grid, Grid.Index(X0, Y1, Z1));
|
||||
const float D111 = CellDensity(Grid, Grid.Index(X1, Y1, Z1));
|
||||
const float D00 = FMath::Lerp(D000, D100, AlphaX);
|
||||
const float D10 = FMath::Lerp(D010, D110, AlphaX);
|
||||
const float D01 = FMath::Lerp(D001, D101, AlphaX);
|
||||
const float D11 = FMath::Lerp(D011, D111, AlphaX);
|
||||
const float D0 = FMath::Lerp(D00, D10, AlphaY);
|
||||
const float D1 = FMath::Lerp(D01, D11, AlphaY);
|
||||
OutDensity = FMath::Lerp(D0, D1, AlphaZ);
|
||||
return FMath::IsFinite(OutDensity);
|
||||
}
|
||||
|
||||
bool FindSupportSurfaceBelow(
|
||||
const FSampleGrid& Grid,
|
||||
int32 X,
|
||||
int32 Y,
|
||||
int32 CandidateAnchorZ,
|
||||
int32 MaxDownwardSearchCells,
|
||||
float MaxStepHeightVoxels,
|
||||
float& OutSurfaceHeight,
|
||||
int32& OutSurfaceAnchorZ)
|
||||
{
|
||||
const float Step = static_cast<float>(Grid.SampleStep);
|
||||
const float CandidateBottom = Grid.MinZ
|
||||
+ static_cast<float>(CandidateAnchorZ) * Step;
|
||||
const float LowestAllowedSurface = CandidateBottom - MaxStepHeightVoxels;
|
||||
const int32 LowestAnchorZ = FMath::Max(
|
||||
1, CandidateAnchorZ - MaxDownwardSearchCells);
|
||||
|
||||
// Search from the free pose downwards. The first transition in a column is the nearest
|
||||
// supporting surface; lower transitions are relevant only after the candidate has been
|
||||
// lifted by the bounded step-height tolerance.
|
||||
for (int32 SurfaceAnchorZ = CandidateAnchorZ;
|
||||
SurfaceAnchorZ >= LowestAnchorZ;
|
||||
--SurfaceAnchorZ)
|
||||
{
|
||||
const int32 SolidZ = SurfaceAnchorZ - 1;
|
||||
if (SolidZ < 0 || SurfaceAnchorZ >= Grid.NumZ)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const int32 SolidCell = Grid.Index(X, Y, SolidZ);
|
||||
const int32 AirCell = Grid.Index(X, Y, SurfaceAnchorZ);
|
||||
if (Grid.Air[SolidCell] != 0u || Grid.Air[AirCell] == 0u)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float SurfaceHeight = Grid.MinZ + static_cast<float>(SurfaceAnchorZ) * Step;
|
||||
if (HasScalarDensity(Grid))
|
||||
{
|
||||
const float LowerDensity = CellDensity(Grid, SolidCell);
|
||||
const float UpperDensity = CellDensity(Grid, AirCell);
|
||||
const float Denominator = UpperDensity - LowerDensity;
|
||||
if (!FMath::IsFinite(LowerDensity) || !FMath::IsFinite(UpperDensity)
|
||||
|| !FMath::IsFinite(Denominator) || Denominator <= 0.0f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const float Fraction = FMath::Clamp(
|
||||
-LowerDensity / Denominator, 0.0f, 1.0f);
|
||||
SurfaceHeight = Grid.CellCenter(X, Y, SolidZ).Z + Fraction * Step;
|
||||
}
|
||||
|
||||
if (FMath::IsFinite(SurfaceHeight)
|
||||
&& SurfaceHeight <= CandidateBottom + KINDA_SMALL_NUMBER
|
||||
&& SurfaceHeight >= LowestAllowedSurface - KINDA_SMALL_NUMBER)
|
||||
{
|
||||
OutSurfaceHeight = SurfaceHeight;
|
||||
OutSurfaceAnchorZ = SurfaceAnchorZ;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool BuildPlayerSupportPatchOffsets(
|
||||
const FVoxelStrateMeasureSettings& Settings,
|
||||
TArray<FIntPoint>& OutOffsets,
|
||||
int32& OutCentreOffsetIndex,
|
||||
FString& OutReason)
|
||||
{
|
||||
OutOffsets.Reset();
|
||||
OutCentreOffsetIndex = INDEX_NONE;
|
||||
const double Radius = static_cast<double>(Settings.PlayerCapsuleRadiusVoxels);
|
||||
if (!FMath::IsFinite(Radius) || Radius <= 0.0)
|
||||
{
|
||||
return Refuse(OutReason,
|
||||
TEXT("Player capsule radius must be finite and greater than zero."));
|
||||
}
|
||||
|
||||
const double MaxOffsetReal = FMath::CeilToDouble(Radius);
|
||||
constexpr double MaxSupportPatchOffsets = static_cast<double>(1ll << 20);
|
||||
if (!FMath::IsFinite(MaxOffsetReal) || MaxOffsetReal > 4096.0)
|
||||
{
|
||||
return Refuse(OutReason,
|
||||
TEXT("The player support patch radius exceeds the bounded fit stencil."));
|
||||
}
|
||||
const int32 MaxOffset = static_cast<int32>(MaxOffsetReal);
|
||||
int64 NumOffsets = 0;
|
||||
for (int32 OffsetY = -MaxOffset; OffsetY <= MaxOffset; ++OffsetY)
|
||||
{
|
||||
for (int32 OffsetX = -MaxOffset; OffsetX <= MaxOffset; ++OffsetX)
|
||||
{
|
||||
if (static_cast<double>(OffsetX * OffsetX + OffsetY * OffsetY)
|
||||
> Radius * Radius + KINDA_SMALL_NUMBER)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (NumOffsets >= static_cast<int64>(MaxSupportPatchOffsets))
|
||||
{
|
||||
return Refuse(
|
||||
OutReason,
|
||||
TEXT("The player support patch exceeds its bounded work limit."));
|
||||
}
|
||||
if (OffsetX == 0 && OffsetY == 0)
|
||||
{
|
||||
OutCentreOffsetIndex = OutOffsets.Num();
|
||||
}
|
||||
OutOffsets.Add(FIntPoint(OffsetX, OffsetY));
|
||||
++NumOffsets;
|
||||
}
|
||||
}
|
||||
if (OutCentreOffsetIndex == INDEX_NONE || OutOffsets.IsEmpty())
|
||||
{
|
||||
return Refuse(OutReason, TEXT("The player support patch has no footprint samples."));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IsWalkableSupportPatch(
|
||||
const TArray<FIntPoint>& SupportOffsets,
|
||||
const TArray<float>& SupportHeights,
|
||||
const TArray<uint8>& bSupported,
|
||||
int32 CentreOffsetIndex,
|
||||
int32 RequiredSupportCount,
|
||||
float MinimumWalkableNormalZ,
|
||||
int32& OutSupportCount,
|
||||
float& OutSupportHeight,
|
||||
int32& OutSupportAnchorZ,
|
||||
const TArray<int32>& SupportAnchorZs)
|
||||
{
|
||||
OutSupportCount = 0;
|
||||
OutSupportHeight = -FLT_MAX;
|
||||
OutSupportAnchorZ = INDEX_NONE;
|
||||
if (!bSupported.IsValidIndex(CentreOffsetIndex)
|
||||
|| bSupported[CentreOffsetIndex] == 0u)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int32 Index = 0; Index < SupportOffsets.Num(); ++Index)
|
||||
{
|
||||
if (bSupported[Index] == 0u)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
++OutSupportCount;
|
||||
if (SupportHeights[Index] > OutSupportHeight
|
||||
|| (SupportHeights[Index] == OutSupportHeight
|
||||
&& (OutSupportAnchorZ == INDEX_NONE
|
||||
|| SupportAnchorZs[Index] > OutSupportAnchorZ)))
|
||||
{
|
||||
OutSupportHeight = SupportHeights[Index];
|
||||
OutSupportAnchorZ = SupportAnchorZs[Index];
|
||||
}
|
||||
}
|
||||
if (OutSupportCount < RequiredSupportCount || OutSupportAnchorZ == INDEX_NONE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// The worst pairwise secant is intentionally conservative: an isolated height jump in
|
||||
// the footprint cannot hide behind an average plane normal. For the default 1.36-voxel
|
||||
// capsule this checks the centre and the four cardinal support samples.
|
||||
float MaximumGradient = 0.0f;
|
||||
for (int32 First = 0; First < SupportOffsets.Num(); ++First)
|
||||
{
|
||||
if (bSupported[First] == 0u)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (int32 Second = First + 1; Second < SupportOffsets.Num(); ++Second)
|
||||
{
|
||||
if (bSupported[Second] == 0u)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const float DX = static_cast<float>(
|
||||
SupportOffsets[Second].X - SupportOffsets[First].X);
|
||||
const float DY = static_cast<float>(
|
||||
SupportOffsets[Second].Y - SupportOffsets[First].Y);
|
||||
const float HorizontalDistance = FMath::Sqrt(DX * DX + DY * DY);
|
||||
if (HorizontalDistance <= KINDA_SMALL_NUMBER)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
MaximumGradient = FMath::Max(
|
||||
MaximumGradient,
|
||||
FMath::Abs(SupportHeights[Second] - SupportHeights[First])
|
||||
/ HorizontalDistance);
|
||||
}
|
||||
}
|
||||
const float SupportNormalZ = 1.0f / FMath::Sqrt(
|
||||
1.0f + MaximumGradient * MaximumGradient);
|
||||
return FMath::IsFinite(SupportNormalZ)
|
||||
&& SupportNormalZ + KINDA_SMALL_NUMBER >= MinimumWalkableNormalZ;
|
||||
}
|
||||
|
||||
bool CapsuleClearAtSupportHeight(
|
||||
const FSampleGrid& Grid,
|
||||
const TArray<FPlayerCapsuleStencilRow>& StencilRows,
|
||||
int32 X,
|
||||
int32 Y,
|
||||
float SupportHeight)
|
||||
{
|
||||
const float Step = static_cast<float>(Grid.SampleStep);
|
||||
const float BaseX = Grid.MinX + (static_cast<float>(X) + 0.5f) * Step;
|
||||
const float BaseY = Grid.MinY + (static_cast<float>(Y) + 0.5f) * Step;
|
||||
for (const FPlayerCapsuleStencilRow& Row : StencilRows)
|
||||
{
|
||||
const float SampleZ = SupportHeight + static_cast<float>(Row.RelativeZ) + 0.5f * Step;
|
||||
for (const FIntPoint& Offset : Row.HorizontalOffsets)
|
||||
{
|
||||
float Density = 0.0f;
|
||||
if (!SampleGridDensity(
|
||||
Grid,
|
||||
FVector(
|
||||
BaseX + static_cast<float>(Offset.X) * Step,
|
||||
BaseY + static_cast<float>(Offset.Y) * Step,
|
||||
SampleZ),
|
||||
Density)
|
||||
|| !(Density > 0.0f))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IsPointInsideCapsule(
|
||||
float RelativeZ,
|
||||
int32 OffsetX,
|
||||
@@ -594,9 +891,9 @@ namespace VoxelStrateMeasurePrivate
|
||||
float Radius,
|
||||
float HalfHeight)
|
||||
{
|
||||
// The capsule is upright and its centre is HalfHeight above the top face of the solid
|
||||
// floor cell. This is a direct capsule test in the fine voxel lattice; it deliberately
|
||||
// does not infer occupancy from Manhattan distance.
|
||||
// The capsule is upright and this row is evaluated relative to the resolved support
|
||||
// surface. This is a direct capsule test in the fine voxel lattice; it deliberately does
|
||||
// not infer occupancy from Manhattan distance.
|
||||
const float AxisHalfLength = FMath::Max(0.0f, HalfHeight - Radius);
|
||||
const float DistanceToAxis = FMath::Max(
|
||||
FMath::Abs(RelativeZ) - AxisHalfLength, 0.0f);
|
||||
@@ -682,10 +979,9 @@ namespace VoxelStrateMeasurePrivate
|
||||
}
|
||||
}
|
||||
|
||||
// The full-height floor rule still requires one clear voxel in rows whose voxel
|
||||
// centre lies outside the rounded cap (normally the top partial voxel of a 7.04
|
||||
// voxel player). This is what makes 176 cm a full-height requirement rather than a
|
||||
// 7-centre-point approximation.
|
||||
// Keep a centre sample for every row. This matters for the top partial row of a
|
||||
// capsule whose height is not an integer number of voxels: the full-height clearance
|
||||
// requirement must not silently become a seven-centre-point approximation.
|
||||
if (Row.HorizontalOffsets.IsEmpty())
|
||||
{
|
||||
Row.HorizontalOffsets.Add(FIntPoint::ZeroValue);
|
||||
@@ -712,6 +1008,62 @@ namespace VoxelStrateMeasurePrivate
|
||||
Grid.SampleStep));
|
||||
}
|
||||
|
||||
if (!FMath::IsFinite(Settings.PlayerMaxStepHeightMeters)
|
||||
|| Settings.PlayerMaxStepHeightMeters < 0.0f)
|
||||
{
|
||||
return Refuse(
|
||||
OutReason,
|
||||
TEXT("PlayerMaxStepHeightMeters must be finite and non-negative."));
|
||||
}
|
||||
if (!FMath::IsFinite(Settings.PlayerWalkableFloorAngleDegrees)
|
||||
|| Settings.PlayerWalkableFloorAngleDegrees < 0.0f
|
||||
|| Settings.PlayerWalkableFloorAngleDegrees > 90.0f)
|
||||
{
|
||||
return Refuse(
|
||||
OutReason,
|
||||
TEXT("PlayerWalkableFloorAngleDegrees must be finite and within [0,90]."));
|
||||
}
|
||||
if (!FMath::IsFinite(Settings.PlayerSupportPatchMinCoverageFraction)
|
||||
|| Settings.PlayerSupportPatchMinCoverageFraction <= 0.0f
|
||||
|| Settings.PlayerSupportPatchMinCoverageFraction > 1.0f)
|
||||
{
|
||||
return Refuse(
|
||||
OutReason,
|
||||
TEXT("PlayerSupportPatchMinCoverageFraction must be within (0,1]."));
|
||||
}
|
||||
|
||||
const double MaxStepHeightVoxelsReal =
|
||||
static_cast<double>(Settings.PlayerMaxStepHeightMeters)
|
||||
/ static_cast<double>(FVoxelPlayerCapsuleConstants::VoxelSizeMeters);
|
||||
constexpr double MaxSupportSearchHeightVoxels = 4096.0;
|
||||
if (!FMath::IsFinite(MaxStepHeightVoxelsReal)
|
||||
|| MaxStepHeightVoxelsReal < 0.0
|
||||
|| MaxStepHeightVoxelsReal > MaxSupportSearchHeightVoxels)
|
||||
{
|
||||
return Refuse(
|
||||
OutReason,
|
||||
TEXT("PlayerMaxStepHeightMeters exceeds the bounded support search."));
|
||||
}
|
||||
const int32 MaxDownwardSearchCells = static_cast<int32>(
|
||||
FMath::CeilToDouble(MaxStepHeightVoxelsReal));
|
||||
const float MaxStepHeightVoxels = static_cast<float>(MaxStepHeightVoxelsReal);
|
||||
const float MinimumWalkableNormalZ = FMath::Cos(FMath::DegreesToRadians(
|
||||
Settings.PlayerWalkableFloorAngleDegrees));
|
||||
|
||||
TArray<FIntPoint> SupportOffsets;
|
||||
int32 CentreOffsetIndex = INDEX_NONE;
|
||||
if (!BuildPlayerSupportPatchOffsets(
|
||||
Settings, SupportOffsets, CentreOffsetIndex, OutReason))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const int32 RequiredSupportCount = FMath::Clamp(
|
||||
FMath::CeilToInt(
|
||||
Settings.PlayerSupportPatchMinCoverageFraction
|
||||
* static_cast<float>(SupportOffsets.Num())),
|
||||
1,
|
||||
SupportOffsets.Num());
|
||||
|
||||
TArray<FPlayerCapsuleStencilRow> StencilRows;
|
||||
if (!BuildPlayerCapsuleStencil(Settings, StencilRows, OutReason))
|
||||
{
|
||||
@@ -719,6 +1071,12 @@ namespace VoxelStrateMeasurePrivate
|
||||
}
|
||||
|
||||
OutPlayerFit.Init(0u, Grid.CellCount);
|
||||
TArray<float> SupportHeights;
|
||||
SupportHeights.SetNumUninitialized(SupportOffsets.Num());
|
||||
TArray<int32> SupportAnchorZs;
|
||||
SupportAnchorZs.SetNumUninitialized(SupportOffsets.Num());
|
||||
TArray<uint8> bSupported;
|
||||
bSupported.Init(0u, SupportOffsets.Num());
|
||||
for (int32 Z = 1; Z < Grid.NumZ; ++Z)
|
||||
{
|
||||
for (int32 Y = 0; Y < Grid.NumY; ++Y)
|
||||
@@ -726,44 +1084,95 @@ namespace VoxelStrateMeasurePrivate
|
||||
for (int32 X = 0; X < Grid.NumX; ++X)
|
||||
{
|
||||
const int32 Current = Grid.Index(X, Y, Z);
|
||||
if (Grid.Air[Current] == 0u
|
||||
|| Grid.Air[Grid.Index(X, Y, Z - 1)] != 0u)
|
||||
// This is the free-pose candidate. Its support is resolved independently
|
||||
// below, so an air cell one or two voxels above a surface can be lowered to
|
||||
// that surface when the character's named step height permits it.
|
||||
if (Grid.Air[Current] == 0u)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool bFits = true;
|
||||
for (const FPlayerCapsuleStencilRow& Row : StencilRows)
|
||||
int32 NumSupported = 0;
|
||||
for (int32 SupportIndex = 0;
|
||||
SupportIndex < SupportOffsets.Num();
|
||||
++SupportIndex)
|
||||
{
|
||||
const int32 TargetZ = Z + Row.RelativeZ;
|
||||
if (TargetZ < 0 || TargetZ >= Grid.NumZ)
|
||||
const FIntPoint& Offset = SupportOffsets[SupportIndex];
|
||||
const int32 SupportX = X + Offset.X;
|
||||
const int32 SupportY = Y + Offset.Y;
|
||||
bSupported[SupportIndex] = 0u;
|
||||
if (SupportX < 0 || SupportX >= Grid.NumX
|
||||
|| SupportY < 0 || SupportY >= Grid.NumY)
|
||||
{
|
||||
bFits = false;
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
for (const FIntPoint& Offset : Row.HorizontalOffsets)
|
||||
|
||||
float SurfaceHeight = 0.0f;
|
||||
int32 SurfaceAnchorZ = INDEX_NONE;
|
||||
if (FindSupportSurfaceBelow(
|
||||
Grid,
|
||||
SupportX,
|
||||
SupportY,
|
||||
Z,
|
||||
MaxDownwardSearchCells,
|
||||
MaxStepHeightVoxels,
|
||||
SurfaceHeight,
|
||||
SurfaceAnchorZ))
|
||||
{
|
||||
const int32 TargetX = X + Offset.X;
|
||||
const int32 TargetY = Y + Offset.Y;
|
||||
if (TargetX < 0 || TargetX >= Grid.NumX
|
||||
|| TargetY < 0 || TargetY >= Grid.NumY
|
||||
|| Grid.Air[Grid.Index(TargetX, TargetY, TargetZ)] == 0u)
|
||||
{
|
||||
bFits = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!bFits)
|
||||
{
|
||||
break;
|
||||
bSupported[SupportIndex] = 1u;
|
||||
SupportHeights[SupportIndex] = SurfaceHeight;
|
||||
SupportAnchorZs[SupportIndex] = SurfaceAnchorZ;
|
||||
++NumSupported;
|
||||
}
|
||||
}
|
||||
|
||||
if (bFits)
|
||||
if (NumSupported < RequiredSupportCount
|
||||
|| bSupported[CentreOffsetIndex] == 0u)
|
||||
{
|
||||
OutPlayerFit[Current] = 1u;
|
||||
++OutNumPlayerFitCells;
|
||||
continue;
|
||||
}
|
||||
|
||||
int32 SupportCount = 0;
|
||||
float SupportHeight = 0.0f;
|
||||
int32 SupportAnchorZ = INDEX_NONE;
|
||||
if (!IsWalkableSupportPatch(
|
||||
SupportOffsets,
|
||||
SupportHeights,
|
||||
bSupported,
|
||||
CentreOffsetIndex,
|
||||
RequiredSupportCount,
|
||||
MinimumWalkableNormalZ,
|
||||
SupportCount,
|
||||
SupportHeight,
|
||||
SupportAnchorZ,
|
||||
SupportAnchorZs))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the pose to the highest coherent support in the patch and run the
|
||||
// full capsule stencil again there. The support search alone is not allowed
|
||||
// to turn a ledge or an overhang into a fit result. Keep the original free
|
||||
// pose cell in the mask: every accepted pose is a valid point in the
|
||||
// existing 6-neighbour traversal graph, and retaining the bounded vertical
|
||||
// band is what lets adjacent gently sloped surfaces overlap instead of
|
||||
// becoming diagonal, disconnected floor anchors.
|
||||
if (!CapsuleClearAtSupportHeight(
|
||||
Grid, StencilRows, X, Y, SupportHeight))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SupportAnchorZ < 1 || SupportAnchorZ >= Grid.NumZ)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (OutPlayerFit[Current] != 0u)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
OutPlayerFit[Current] = 1u;
|
||||
++OutNumPlayerFitCells;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1341,8 +1750,8 @@ namespace VoxelStrateMeasurePrivate
|
||||
|
||||
if (EligibilityMask != nullptr)
|
||||
{
|
||||
// A player-fit route is deliberately a graph over floor anchors, not a second air
|
||||
// route with a coarse-cell illusion. The mask was built at SampleStep=1 and every
|
||||
// A player-fit route is deliberately a graph over supported free poses, not a second
|
||||
// air route with a coarse-cell illusion. The mask was built at SampleStep=1 and every
|
||||
// edge below is between adjacent player-fitting cells.
|
||||
for (int32 PathIndex = 0; PathIndex < Path.Num(); ++PathIndex)
|
||||
{
|
||||
@@ -1797,7 +2206,7 @@ namespace VoxelStrateMeasurePrivate
|
||||
if (!BuildSampleGrid(Generator, Manager, Sampler, StrateIndex,
|
||||
ExplicitBottomWorldZ, ExplicitTopWorldZ,
|
||||
ExplicitBoundarySealThickness, bUseExplicitBounds,
|
||||
Settings, false, Grid, RefusalReason))
|
||||
Settings, bPlayerFitRestricted, Grid, RefusalReason))
|
||||
{
|
||||
if (OutDiagnostics != nullptr)
|
||||
{
|
||||
@@ -1909,7 +2318,8 @@ FVoxelStrateMetrics VF_MeasureStrate(
|
||||
VoxelStrateMeasurePrivate::FSampleGrid Grid;
|
||||
if (!VoxelStrateMeasurePrivate::BuildSampleGrid(
|
||||
&Generator, &Manager, nullptr, StrateIndex, 0, 0, 0.0f, false,
|
||||
Settings, OutSampleGrid != nullptr, Grid, Result.RefusalReason))
|
||||
Settings, OutSampleGrid != nullptr || Settings.SampleStep == 1,
|
||||
Grid, Result.RefusalReason))
|
||||
{
|
||||
return Result;
|
||||
}
|
||||
@@ -1953,7 +2363,8 @@ FVoxelStrateMetrics VF_MeasureStrateWithSampler(
|
||||
VoxelStrateMeasurePrivate::FSampleGrid Grid;
|
||||
if (!VoxelStrateMeasurePrivate::BuildSampleGrid(
|
||||
nullptr, nullptr, &Sampler, INDEX_NONE, StrateBottomWorldZ, StrateTopWorldZ,
|
||||
BoundarySealThickness, true, Settings, OutSampleGrid != nullptr,
|
||||
BoundarySealThickness, true, Settings,
|
||||
OutSampleGrid != nullptr || Settings.SampleStep == 1,
|
||||
Grid, Result.RefusalReason))
|
||||
{
|
||||
return Result;
|
||||
|
||||
@@ -8,18 +8,27 @@ class UVoxelGenerator;
|
||||
class UVoxelStrateManager;
|
||||
|
||||
/**
|
||||
* The confirmed base ACharacter capsule in voxel-space defaults.
|
||||
* The pinned metric capsule dimensions in voxel-space.
|
||||
*
|
||||
* 25 cm/voxel is the authored world scale: radius = 34 cm / 25 = 1.36 voxels and
|
||||
* half-height = 88 cm / 25 = 3.52 voxels, hence a 176 cm / 25 = 7.04 voxel total height.
|
||||
* The measure settings copy these values so a future character can change the query without
|
||||
* changing generation.
|
||||
* changing generation. The diagnosis test separately reads movement limits from the project's
|
||||
* character CDO.
|
||||
*/
|
||||
struct VOXELFORGE_API FVoxelPlayerCapsuleConstants
|
||||
{
|
||||
static constexpr float VoxelSizeCentimeters = 25.0f;
|
||||
static constexpr float VoxelSizeMeters = VoxelSizeCentimeters / 100.0f;
|
||||
static constexpr float RadiusCentimeters = 34.0f;
|
||||
static constexpr float HalfHeightCentimeters = 88.0f;
|
||||
// UCharacterMovementComponent's UE 5.7 default. Callers that have a character CDO should
|
||||
// copy its authored value into PlayerMaxStepHeightMeters.
|
||||
static constexpr float MaxStepHeightCentimeters = 45.0f;
|
||||
static constexpr float MaxStepHeightMeters = MaxStepHeightCentimeters / 100.0f;
|
||||
// UE 5.7's WalkableFloorZ=0.71 resolves to about 44.8 degrees; a character CDO may expose
|
||||
// the precise angle. The diagnosis test copies that precise project value when available.
|
||||
static constexpr float WalkableFloorAngleDegrees = 44.8f;
|
||||
static constexpr float RadiusVoxels = RadiusCentimeters / VoxelSizeCentimeters;
|
||||
static constexpr float HalfHeightVoxels = HalfHeightCentimeters / VoxelSizeCentimeters;
|
||||
static constexpr float HeightVoxels = 2.0f * HalfHeightVoxels;
|
||||
@@ -61,7 +70,7 @@ struct VOXELFORGE_API FVoxelStrateMeasureSettings
|
||||
float CoverMarginVoxels = 48.0f;
|
||||
int32 MaxCells = 8000000; // Refuse a grid larger than this many cells.
|
||||
int32 MaxRouteRetries = 16; // Alternate coarse routes to full-resolution-check after the first route.
|
||||
int32 HeadroomCells = 2; // Air cells above a floor cell required for walkable.
|
||||
int32 HeadroomCells = 2; // Legacy walkable-column metric only.
|
||||
int32 InteriorMarginVoxels = -1; // <0 = derive from BoundarySealThickness (2x, clamped); explicit 0 includes the seal.
|
||||
|
||||
// Base ACharacter defaults at 25 cm/voxel: 34 cm radius / 25 = 1.36 voxels and
|
||||
@@ -69,6 +78,15 @@ struct VOXELFORGE_API FVoxelStrateMeasureSettings
|
||||
// generation values, so another playable capsule can be measured without a code change.
|
||||
float PlayerCapsuleRadiusVoxels = FVoxelPlayerCapsuleConstants::RadiusVoxels;
|
||||
float PlayerCapsuleHalfHeightVoxels = FVoxelPlayerCapsuleConstants::HalfHeightVoxels;
|
||||
|
||||
// Character-derived fit parameters. MaxStepHeight is deliberately named and stored in
|
||||
// metres, matching UCharacterMovementComponent's authored unit. The measurement converts it
|
||||
// to voxels only for the bounded downward support search.
|
||||
float PlayerMaxStepHeightMeters = FVoxelPlayerCapsuleConstants::MaxStepHeightMeters;
|
||||
float PlayerWalkableFloorAngleDegrees = FVoxelPlayerCapsuleConstants::WalkableFloorAngleDegrees;
|
||||
// A support patch is sampled at the integer columns covered by the capsule radius. 0.75 with
|
||||
// the default 1.36-voxel radius requires 4 of 5 columns, including the centre column.
|
||||
float PlayerSupportPatchMinCoverageFraction = 0.75f;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -163,16 +181,16 @@ struct VOXELFORGE_API FVoxelStrateMetrics
|
||||
int32 MedianVerticalClearance = 0;
|
||||
|
||||
// Player-fit metrics are answered only by a one-voxel grid. A coarse grid refuses to infer
|
||||
// clearance because SampleStep=4 cannot resolve a 1.36-voxel radius capsule.
|
||||
// support or clearance because SampleStep=4 cannot resolve a 1.36-voxel radius capsule.
|
||||
bool bPlayerFitResolved = false;
|
||||
FString PlayerFitRefusalReason;
|
||||
int64 NumPlayerFitCells = 0;
|
||||
float PlayerFitFraction = 0.0f; // Player-fitting floor anchors / all air cells.
|
||||
float PlayerFitFraction = 0.0f; // Accepted free supported poses / all air cells.
|
||||
int32 NumTraversableComponents = 0;
|
||||
int64 LargestTraversableComponentCells = 0;
|
||||
float TraversableComponentShare = 0.0f; // Largest player-fit component / player-fit cells.
|
||||
// Minimum horizontal Chebyshev clearance, in voxels, among player-fit floor anchors. This
|
||||
// is a diagnostic only; player occupancy itself uses a direct capsule stencil, never the old
|
||||
// Minimum horizontal Chebyshev clearance, in voxels, among accepted supported poses. This is
|
||||
// a diagnostic only; player occupancy itself uses a direct capsule stencil, never the old
|
||||
// Manhattan distance transform.
|
||||
float MinimumPlayerClearanceVoxels = 0.0f;
|
||||
|
||||
|
||||
Reference in new issue
Block a user