feat: height-space operator family — SurfaceWorld step 1, and C10 is solved
Maze and Slab now report BIT-IDENTICAL: FPSemantics = Precise, set for cross-platform play, dissolved the ULP residue. Hypothesis 3 had the right mechanism all along — under /fp:fast the compiler transforms by surrounding context with no isolable axis, which is exactly why five one-variable experiments all came back negative. Removing the permission removed the difference. Nobody solved C10; C9 got fixed for an unrelated reason and C10 fell out of it. SurfaceWorld step 1 forced an architectural decision. DECOMPOSITION section 5 notes the height ops operate on Z values rather than density, then lists them as children of FHeightfieldSource. Writing them made the consequence unavoidable: they do not fit IVoxelDensityOp. No input Z (they produce one), XY-pure per column rather than per voxel, and they write neither channel. Forcing them in would need a per-voxel channel for a column property, or one opaque op — section 2.5's failure mode. So height space gets its own contract: VoxelHeightOp.h (FVoxelHeightSample with Height + Relief, IVoxelHeightOp, FVoxelHeightStack) and five ops. Relief is the original's M — produced by the structural source, consumed by the terrace gate. Section 0.1 found density needed a second channel; this found terrain needs a second space. The type system now forbids for free what AUDIT 6.3 warns about: a height stack cannot hold Z-dependent data because there is no Z in the signature. Deliberately staged — this touches nothing on the density path. If height space had not decomposed cleanly, it shows up here for one test rather than after building the adapter, the column cache integration and the dispatch on top. The test runs twice; the second pass is load-bearing because the F20 terrain ops are off by default, so a defaults-only run leaves all four modifiers untested. It also brute-forces MaxDisplacement, since a false bound would later be a hole. ComputeSurfaceTerrainZ moved private -> public for the test, same justification as GetSlabDensity. Old declaration removed. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
// VoxelForgeHeightStackTest.cpp
|
||||
// LA QUESTION D'ARCHITECTURE DE LA PHASE 2, POSÉE AVANT D'ÉCRIRE CE QUI EN DÉPEND.
|
||||
// PHASE 2'S ARCHITECTURAL QUESTION, ASKED BEFORE WRITING WHAT DEPENDS ON THE ANSWER.
|
||||
//
|
||||
// SurfaceWorld a forcé une décision que ni Maze ni Slab n'avaient forcée : ses opérateurs de
|
||||
// terrain (cliff / terrace / layer lines / plage) n'opèrent PAS sur la densité. Ils lisent et
|
||||
// écrivent **une altitude**. Ils ne rentrent donc pas dans `IVoxelDensityOp`, et les y forcer
|
||||
// voudrait dire soit un canal par-voxel pour une propriété de COLONNE, soit un seul opérateur
|
||||
// opaque — ce que `OPSTACK-PLAN §2.5` appelle exactement l'échec du refactor.
|
||||
//
|
||||
// D'où une seconde famille, `VoxelHeightOp.h`. **Ce test est ce qui dit si elle était une bonne
|
||||
// idée** — la même méthode que la Phase 1 a appliquée à la densité : décomposer, puis MESURER
|
||||
// contre l'original, avant de construire par-dessus.
|
||||
//
|
||||
// ⚠️ Ce test ne touche PAS au chemin densité. `FHeightfieldSource` / `FSkyCapSource` /
|
||||
// `FOverhangShelfMod` (OPSTACK-DECOMPOSITION §5) sont l'étape SUIVANTE, délibérément séparée : si
|
||||
// l'espace-hauteur ne se décomposait pas proprement, on l'apprendrait ici, pour le prix d'un test,
|
||||
// et pas après avoir écrit l'adaptateur, le cache de colonne et le branchement.
|
||||
//
|
||||
// LA BARRE : **bit à bit.** Depuis `FPSemantics = Precise` (AUDIT §C9/§C10), Maze et Slab sont
|
||||
// bit-identiques à leur original ; il n'y a plus de « plancher ULP » à tolérer. Un écart ici est
|
||||
// donc une vraie trouvaille — un offset de bruit faux, un ordre d'op inversé, un gate oublié.
|
||||
// Ces fonctions sont des ALTITUDES en voxels, pas des densités : un écart d'un demi-voxel est un
|
||||
// terrain visiblement différent, pas du bruit d'arrondi.
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
#include "Async/ParallelFor.h"
|
||||
#include "HAL/PlatformMisc.h"
|
||||
|
||||
#include "VoxelForgeTestFixture.h"
|
||||
#include "VoxelHeightOp.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeHeightStackTest,
|
||||
"VoxelForge.OpStack.SurfaceHeightEquivalence",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int32 NumHeightSamples = 20000;
|
||||
|
||||
/** Les params du terrain ne sont intéressants que si les ops sont ALLUMÉS. Ceux de la fixture
|
||||
* sont les défauts, et `§5` note que les ops F20 sont « all off by default ». Un test qui ne
|
||||
* ferait tourner que les défauts vérifierait la source structurelle et RIEN des quatre
|
||||
* modificateurs — c'est-à-dire l'essentiel de ce qui est nouveau ici. */
|
||||
void EnableAllTerrainOps(FSurfaceGenerationParams& P)
|
||||
{
|
||||
P.CliffStrength = 0.6f;
|
||||
P.CliffSampleDist = 2.0f;
|
||||
P.CliffSlopeThreshold = 0.15f;
|
||||
P.CliffSharpness = 1.4f;
|
||||
|
||||
P.TerraceStrength = 0.7f;
|
||||
P.TerraceHeight = 9.0f;
|
||||
P.TerraceHardness = 0.8f;
|
||||
|
||||
P.LayerLineDepth = 1.3f;
|
||||
P.LayerLineSpacing = 7.0f;
|
||||
|
||||
// ⚠️ `WaterLevelRelative` DOIT être > 0, sinon `FBeachHeightMod` sort immédiatement et le
|
||||
// cinquième op n'est jamais exercé — un test vert qui n'a rien testé. Le défaut de la
|
||||
// struct est 0.0f, donc l'oublier est le piège naturel ici.
|
||||
// The beach op early-outs unless WaterLevelRelative > 0, so without this the fifth op is
|
||||
// never exercised at all — a green test that measured nothing.
|
||||
P.WaterLevelRelative = 0.30f;
|
||||
P.BeachWidth = 6.0f;
|
||||
}
|
||||
}
|
||||
|
||||
bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
FTestWorld World;
|
||||
World.Build();
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
|
||||
if (!World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, TopVoxelZ, BottomVoxelZ))
|
||||
{
|
||||
AddError(TEXT("The fixture layout has no SurfaceWorld slot. Check FTestWorld::Build's ")
|
||||
TEXT("Archetypes[] against FTestWorld::SlotSurfaceWorld."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
|
||||
|
||||
// Les points d'échantillonnage : XY seulement, la hauteur ne dépend pas de Z (c'est le point).
|
||||
TArray<FVector2D> Points;
|
||||
Points.Reserve(NumHeightSamples);
|
||||
{
|
||||
FRandomStream Rng(90210);
|
||||
for (int32 i = 0; i < NumHeightSamples; ++i)
|
||||
{
|
||||
Points.Add(FVector2D(
|
||||
(float)Rng.RandRange(-6 * CHUNK_SIZE, 6 * CHUNK_SIZE),
|
||||
(float)Rng.RandRange(-6 * CHUNK_SIZE, 6 * CHUNK_SIZE)));
|
||||
}
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// LA BATTERIE, PARAMÉTRÉE PAR JEU DE PARAMS
|
||||
//=========================================================================
|
||||
auto RunForParams = [&](const FSurfaceGenerationParams& P, const TCHAR* Label, int32 SeedSalt)
|
||||
{
|
||||
FVoxelHeightStack Stack;
|
||||
VoxelHeightOps::BuildSurfaceHeightStack(Stack, P, World.Settings->Seed);
|
||||
|
||||
// Une DÉCOMPOSITION, pas une enveloppe : source + 4 modificateurs.
|
||||
TestEqual(*FString::Printf(TEXT("%s: the height stack is decomposed into 5 ops"), Label),
|
||||
Stack.Num(), 5);
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// 1. ÉQUIVALENCE — contre ComputeSurfaceTerrainZ, en ALTITUDE
|
||||
//---------------------------------------------------------------------
|
||||
int32 NumDiff = 0, WorstIdx = -1;
|
||||
float WorstDelta = 0.0f, WorstOld = 0.0f;
|
||||
|
||||
for (int32 i = 0; i < NumHeightSamples; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y;
|
||||
|
||||
const float Old = Gen->ComputeSurfaceTerrainZ(X, Y, P);
|
||||
const float New = Stack.EvalHeight(X, Y);
|
||||
|
||||
if (!BitEqual(Old, New))
|
||||
{
|
||||
++NumDiff;
|
||||
const float Delta = FMath::Abs(Old - New);
|
||||
if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; WorstOld = Old; }
|
||||
}
|
||||
}
|
||||
|
||||
if (NumDiff == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("%s: bit-identical across %d samples. The height-space decomposition ")
|
||||
TEXT("reproduces ComputeSurfaceTerrainZ exactly."), Label, NumHeightSamples));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Pas de gradation ULP ici, à dessein : ce sont des ALTITUDES. Depuis /fp:precise la
|
||||
// barre est l'égalité binaire, et un écart de hauteur se voit dans le monde.
|
||||
AddError(FString::Printf(
|
||||
TEXT("%s: %d of %d samples differ from ComputeSurfaceTerrainZ (largest |delta| ")
|
||||
TEXT("%.9g voxels at (%.0f, %.0f), where the reference height is %.4f). These are ")
|
||||
TEXT("ALTITUDES, not densities -- this is a real port error, not rounding. Check, ")
|
||||
TEXT("in order: the op ORDER (structural -> cliff -> terrace -> layer lines -> ")
|
||||
TEXT("beach), the terrace's `* Relief` gate (that is the original's `* M`), the ")
|
||||
TEXT("cliff resampling the STRUCTURAL field rather than the modified height, and ")
|
||||
TEXT("the noise offsets (3.1/5.7/0.7, 11/22/1.3, 99/77/0.9, 7.3/2.1/0.5)."),
|
||||
Label, NumDiff, NumHeightSamples, WorstDelta,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||
WorstOld));
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// 2. INVARIANCE DE FENÊTRE
|
||||
//---------------------------------------------------------------------
|
||||
// Une pile de hauteur alimente le cache de colonne T1.a, qui est PARTAGÉ sur toute la pile
|
||||
// verticale de chunks. Une impureté ici ne fait pas une couture locale : elle se propage à
|
||||
// tous les Z d'un coup (AUDIT §6.3).
|
||||
{
|
||||
std::atomic<int32> Impure{ 0 };
|
||||
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||
|
||||
TArray<float> Ref;
|
||||
Ref.SetNumUninitialized(NumHeightSamples);
|
||||
for (int32 i = 0; i < NumHeightSamples; ++i)
|
||||
{
|
||||
Ref[i] = Stack.EvalHeight((float)Points[i].X, (float)Points[i].Y);
|
||||
}
|
||||
|
||||
ParallelFor(NumBlocks, [&](int32 Block)
|
||||
{
|
||||
TArray<int32> LocalOrder;
|
||||
BuildShuffledOrder(NumHeightSamples, 1200 + Block + SeedSalt, LocalOrder);
|
||||
for (const int32 i : LocalOrder)
|
||||
{
|
||||
const float V = Stack.EvalHeight((float)Points[i].X, (float)Points[i].Y);
|
||||
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
|
||||
}
|
||||
});
|
||||
|
||||
TestEqual(*FString::Printf(
|
||||
TEXT("%s: the height stack is window-invariant across order and threads"), Label),
|
||||
Impure.load(), 0);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// 3. LES MAJORANTS DE DÉPLACEMENT SONT-ILS HONNÊTES ?
|
||||
//---------------------------------------------------------------------
|
||||
// `MaxDisplacement` servira à borner une colonne pour un `ClassifyBox` de heightfield, la
|
||||
// même mécanique qui fait prouver 36-40 tuiles sur 60 à la dalle. Un majorant FAUX serait
|
||||
// un TROU, donc on le teste par force brute AVANT de construire quoi que ce soit dessus.
|
||||
//
|
||||
// On mesure le déplacement des trois mods bornables en comparant la pile complète à une
|
||||
// pile tronquée (source + cliff seuls) : la différence est exactement ce que terrace +
|
||||
// layer lines + plage ont déplacé.
|
||||
{
|
||||
FVoxelHeightStack Base;
|
||||
const IVoxelHeightOp* Structural = nullptr;
|
||||
Base.Add(VoxelHeightOps::MakeStructuralHeightSource(P, World.Settings->Seed, &Structural));
|
||||
Base.Add(VoxelHeightOps::MakeCliffHeightMod(P, Structural));
|
||||
|
||||
FVoxelHeightStack Bounded;
|
||||
const IVoxelHeightOp* Structural2 = nullptr;
|
||||
Bounded.Add(VoxelHeightOps::MakeStructuralHeightSource(P, World.Settings->Seed, &Structural2));
|
||||
Bounded.Add(VoxelHeightOps::MakeCliffHeightMod(P, Structural2));
|
||||
Bounded.Add(VoxelHeightOps::MakeTerraceHeightMod(P));
|
||||
Bounded.Add(VoxelHeightOps::MakeLayerLineHeightMod(P));
|
||||
Bounded.Add(VoxelHeightOps::MakeBeachHeightMod(P));
|
||||
|
||||
const float Claimed = FMath::Max(P.TerraceStrength > 0.0f ? P.TerraceHeight : 0.0f, 0.0f)
|
||||
+ FMath::Max(P.LayerLineSpacing > 0.0f ? P.LayerLineDepth : 0.0f, 0.0f)
|
||||
+ FMath::Max(P.WaterLevelRelative > 0.0f ? P.BeachWidth : 0.0f, 0.0f);
|
||||
|
||||
float WorstObserved = 0.0f;
|
||||
int32 NumOverBound = 0;
|
||||
for (int32 i = 0; i < NumHeightSamples; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y;
|
||||
const float Moved = FMath::Abs(Bounded.EvalHeight(X, Y) - Base.EvalHeight(X, Y));
|
||||
WorstObserved = FMath::Max(WorstObserved, Moved);
|
||||
if (Moved > Claimed) { ++NumOverBound; }
|
||||
}
|
||||
|
||||
TestEqual(*FString::Printf(
|
||||
TEXT("%s: no sample exceeds the claimed MaxDisplacement (a false bound is a hole)"),
|
||||
Label),
|
||||
NumOverBound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("%s: MaxDisplacement claims %.3f voxels, worst observed %.3f (%.0f%% of the ")
|
||||
TEXT("claim). A loose bound only costs CPU later; a tight-but-wrong one would be a hole."),
|
||||
Label, Claimed, WorstObserved,
|
||||
Claimed > 0.0f ? 100.0f * WorstObserved / Claimed : 0.0f));
|
||||
}
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// DEUX PASSES — et la seconde est celle qui compte
|
||||
//=========================================================================
|
||||
const UVoxelStrateDefinition* SurfaceDef =
|
||||
World.StrateManager->GetStrateForChunk(FIntVector(0, 0, MidChunkZ));
|
||||
if (!SurfaceDef)
|
||||
{
|
||||
AddError(TEXT("No strate definition resolved for the SurfaceWorld slot's mid chunk."));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Les bornes Z de runtime sont posées à la main : `GetSlabParamsForChunk` a un équivalent pour
|
||||
// la dalle, mais le chemin surface passe par `ResolveSurfaceChunkParams`, qui est privé et
|
||||
// mêle la résolution de biome. La pile de hauteur ne dépend que des params + du seed, donc
|
||||
// fournir les params directement est à la fois suffisant et plus lisible en cas d'échec.
|
||||
FSurfaceGenerationParams Defaults = SurfaceDef->SurfaceParams;
|
||||
Defaults.StrateTopWorldZ = (float)TopVoxelZ;
|
||||
Defaults.StrateBottomWorldZ = (float)BottomVoxelZ;
|
||||
|
||||
RunForParams(Defaults, TEXT("SurfaceWorld(defaults)"), 0);
|
||||
|
||||
// ⚠️ LA PASSE LOAD-BEARING. Les ops de terrain F20 sont éteints par défaut, donc la passe
|
||||
// ci-dessus n'exerce que la source structurelle et laisse les QUATRE modificateurs — c'est-à-
|
||||
// dire tout ce qui est nouveau dans cette décomposition — non testés. Celle-ci les allume.
|
||||
FSurfaceGenerationParams AllOps = Defaults;
|
||||
EnableAllTerrainOps(AllOps);
|
||||
RunForParams(AllOps, TEXT("SurfaceWorld(all terrain ops on)"), 64);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -8,8 +8,26 @@
|
||||
// sans contorsion, le SDF est reproduit BIT POUR BIT, et aucun échantillon ne change de côté de
|
||||
// l'isosurface.
|
||||
//
|
||||
// ═════════════════════════════════════════════════════════════════════════════════════════
|
||||
// ✅ MISE À JOUR 2026-07-27 : LE PLANCHER ULP N'EXISTE PLUS. C'ÉTAIT `/fp:fast`.
|
||||
// ═════════════════════════════════════════════════════════════════════════════════════════
|
||||
// `FPSemantics = Precise` sur le module (AUDIT §C9, posé pour le cross-play Linux/Windows) fait
|
||||
// passer ce test à **BIT-IDENTIQUE**. La section ci-dessous décrit un état RÉVOLU ; elle est gardée
|
||||
// parce qu'elle explique pourquoi les cinq expériences d'isolation avaient toutes échoué (sous
|
||||
// `/fp:fast` le compilateur transforme selon le CONTEXTE — il n'y avait aucune variable à isoler)
|
||||
// et parce qu'elle dit quoi regarder si la bit-identité régresse un jour.
|
||||
//
|
||||
// **Conséquence pratique : ce test est maintenant un instrument BEAUCOUP plus fin.** Le moindre
|
||||
// écart est désormais une vraie trouvaille, pas du bruit à noter. La machinerie de gradation ULP
|
||||
// est conservée exprès — c'est elle qui signalerait une régression du modèle flottant.
|
||||
//
|
||||
// UPDATE: the ULP floor is GONE — FPSemantics = Precise makes this test bit-identical. The section
|
||||
// below describes a past state, kept because it explains why five isolation experiments all failed
|
||||
// (under /fp:fast the compiler transforms by CONTEXT — there was no variable to isolate) and what to
|
||||
// look at if bit-identity ever regresses.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ⚠️ LE PLANCHER ULP — lire ceci avant de « corriger » un écart résiduel
|
||||
// ⚠️ LE PLANCHER ULP (HISTORIQUE) — lire ceci avant de « corriger » un écart résiduel
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// La pile reproduit `GetMazeDensity` à ~1-2 ULP près sur ~2 % des échantillons (ceux qui tombent
|
||||
// dans la coquille de blend du SDF, où `Blend - Sdf` annule catastrophiquement et amplifie le
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
// CE QUE CE TEST DOIT PROUVER / WHAT THIS TEST HAS TO PROVE
|
||||
// Trois choses, et la troisième est la raison d'être du portage :
|
||||
//
|
||||
// 1. ÉQUIVALENCE — la pile reproduit `GetSlabDensity`. Même barre que Maze : un changement de
|
||||
// côté d'isosurface est un ÉCHEC DUR, un écart d'ULP est le plancher accepté.
|
||||
// 1. ÉQUIVALENCE — la pile reproduit `GetSlabDensity`. ✅ **BIT-IDENTIQUE depuis 2026-07-27**,
|
||||
// quand `FPSemantics = Precise` (AUDIT §C9/§C10) a supprimé le résidu d'ULP : il venait de
|
||||
// `/fp:fast`. Un changement de côté d'isosurface reste l'ÉCHEC DUR ; la gradation ULP est
|
||||
// gardée comme détecteur de régression du modèle flottant, pas comme tolérance attendue.
|
||||
// 2. UN OPÉRATEUR, DEUX ARCHÉTYPES — la MÊME pile est vérifiée contre FlatPlain ET
|
||||
// CrystalChamber. `GetSlabDensity` ne les distingue par aucun branchement ; si la pile a
|
||||
// besoin d'en faire un, la fusion est fausse et ce test le dit.
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
// VoxelHeightOpStack.cpp
|
||||
// Les cinq opérateurs d'espace-hauteur de SurfaceWorld.
|
||||
// The five height-space operators of SurfaceWorld.
|
||||
//
|
||||
// FIDÉLITÉ / FIDELITY
|
||||
// Chaque corps est une transcription LITTÉRALE du bloc correspondant de
|
||||
// `SampleSurfaceStructuralZ` / `ComputeSurfaceTerrainZ` — mêmes offsets, mêmes octaves, même ordre
|
||||
// d'opérations flottantes. Depuis que `FPSemantics = Precise` est posé (AUDIT §C9), l'égalité
|
||||
// BIT À BIT est atteignable et atteinte pour Maze et Slab : c'est donc la barre ici aussi, et
|
||||
// `VoxelForge.OpStack.SurfaceHeightEquivalence` la vérifie.
|
||||
//
|
||||
// ⚠️ LE DÉTOUR PAR `FVector` EST DÉLIBÉRÉ, comme ailleurs dans ce refactor : `FractalNoise3D` prend
|
||||
// un `FVector` (donc des DOUBLES en UE5) et re-descend en float. Passer directement des floats
|
||||
// saute un arrondi. Reproduire le détour, c'est reproduire l'arrondi.
|
||||
// The FVector round-trip is deliberate: FVector is double in UE5, so the original rounds through a
|
||||
// double. Going straight through floats skips a rounding step.
|
||||
|
||||
#include "VoxelHeightOp.h"
|
||||
|
||||
#include "VoxelNoise.h" // VoxelNoise::FBM / Ridged / Perlin3D
|
||||
#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE
|
||||
|
||||
namespace
|
||||
{
|
||||
//=========================================================================
|
||||
// HELPERS — les mêmes enveloppes que VoxelGenerator.cpp, transcrites
|
||||
//=========================================================================
|
||||
// `FractalNoise3D` et `RidgedNoise3D` sont `static` dans VoxelGenerator.cpp, donc invisibles
|
||||
// ici. Elles sont recopiées à l'identique plutôt qu'exportées : les exporter changerait leur
|
||||
// contexte d'inlining, et sous /fp:precise comme sous /fp:fast la règle est la même — on ne
|
||||
// touche à rien de ce qui entoure une expression flottante qu'on veut reproduire.
|
||||
FORCEINLINE float HFractalNoise3D(const FVector& Position, int32 Octaves = 4,
|
||||
float Lacunarity = 2.0f, float Persistence = 0.5f)
|
||||
{
|
||||
return VoxelNoise::FBM((float)Position.X, (float)Position.Y, (float)Position.Z,
|
||||
Octaves, Lacunarity, Persistence);
|
||||
}
|
||||
|
||||
FORCEINLINE float HRidgedNoise3D(const FVector& Position, int32 Octaves = 4,
|
||||
float Lacunarity = 2.0f, float Persistence = 0.5f)
|
||||
{
|
||||
return VoxelNoise::Ridged((float)Position.X, (float)Position.Y, (float)Position.Z,
|
||||
Octaves, Lacunarity, Persistence);
|
||||
}
|
||||
|
||||
/** Transcription de `UVoxelGenerator::SampleRelief`. Champ [0,1] partagé avec la carte de
|
||||
* biomes, pour que la géographie et le terrain qu'elle module restent d'accord. */
|
||||
FORCEINLINE float HSampleRelief(float WorldX, float WorldY, float SeedF,
|
||||
float Frequency, float Contrast)
|
||||
{
|
||||
float R = HFractalNoise3D(FVector(
|
||||
WorldX * Frequency + SeedF * 7.3f,
|
||||
WorldY * Frequency + SeedF * 2.1f,
|
||||
SeedF * 0.5f), 2) * 0.5f + 0.5f; // [0,1]
|
||||
R = FMath::Clamp((R - 0.5f) * Contrast + 0.5f, 0.0f, 1.0f);
|
||||
return SmoothStep01(R);
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// SOURCE — CHAMP STRUCTUREL / STRUCTURAL HEIGHT FIELD
|
||||
//=========================================================================
|
||||
// Continents + montagnes + détail, sous une frame de domain-warp. Produit les DEUX canaux.
|
||||
class FStructuralHeightSource final : public IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
FStructuralHeightSource(const FSurfaceGenerationParams& InP, int32 InSeed)
|
||||
: P(InP), SeedF((float)InSeed) {}
|
||||
|
||||
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
|
||||
{
|
||||
float M = 1.0f;
|
||||
InOut.Height = SampleZ(WorldX, WorldY, M); // Replace : racine de pile
|
||||
InOut.Relief = M;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le champ nu, exposé parce que `FCliffHeightMod` doit le RÉ-ÉCHANTILLONNER en différences
|
||||
* centrées. C'est une dépendance réelle du code d'origine (`ComputeSurfaceTerrainZ` appelle
|
||||
* `SampleSurfaceStructuralZ` quatre fois de plus), pas un raccourci : la pente doit venir du
|
||||
* champ STRUCTUREL, sans rétroaction des ops, sinon le cliff se nourrirait de lui-même.
|
||||
*/
|
||||
float SampleZ(float WorldX, float WorldY, float& OutM) const
|
||||
{
|
||||
const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ;
|
||||
const float BottomZ = P.StrateBottomWorldZ;
|
||||
|
||||
const float GroundBase = BottomZ + H * P.BaseGroundRelative;
|
||||
|
||||
// Domain-warp des coords STRUCTURELLES (continents + montagnes). Le bruit de détail
|
||||
// reste sur le vrai XY pour que les bosses fines restent nettes et décorrélées.
|
||||
float QX = WorldX, QY = WorldY;
|
||||
if (P.HeightWarpStrength > 0.0f)
|
||||
{
|
||||
const float WF = P.HeightWarpFrequency;
|
||||
const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + SeedF * 0.31f, WorldY * WF + 4.2f, SeedF * 1.7f));
|
||||
const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 8.6f, WorldY * WF + SeedF * 0.53f, SeedF * 2.9f));
|
||||
QX += wx * VOXEL_NOISE_SCALE * P.HeightWarpStrength;
|
||||
QY += wy * VOXEL_NOISE_SCALE * P.HeightWarpStrength;
|
||||
}
|
||||
|
||||
const float Relief = HSampleRelief(WorldX, WorldY, SeedF, P.ReliefFrequency, P.ReliefContrast);
|
||||
const float M = FMath::Lerp(1.0f, Relief, P.ReliefStrength);
|
||||
|
||||
float Cont = HFractalNoise3D(FVector(
|
||||
QX * P.ContinentFrequency + SeedF * 3.1f,
|
||||
QY * P.ContinentFrequency + SeedF * 5.7f,
|
||||
SeedF * 0.7f), 4); // [-1,1]
|
||||
|
||||
float Detail = HFractalNoise3D(FVector(
|
||||
WorldX * P.DetailFrequency + 11.0f,
|
||||
WorldY * P.DetailFrequency + 22.0f,
|
||||
SeedF * 1.3f), 3); // [-1,1]
|
||||
|
||||
float Mountain = 0.0f;
|
||||
if (P.MountainStrength > 0.0f)
|
||||
{
|
||||
float Ridge = HRidgedNoise3D(FVector(
|
||||
QX * P.MountainFrequency + 99.0f,
|
||||
QY * P.MountainFrequency + 77.0f,
|
||||
SeedF * 0.9f), 4); // [-1,1]
|
||||
Ridge = Ridge * 0.5f + 0.5f; // [0,1] sommets
|
||||
Mountain = Ridge * P.MountainStrength * M; // les montagnes ne montent qu'en haut relief
|
||||
}
|
||||
|
||||
// Les plaines gardent une fraction du gonflement continental ; les hautes terres tout.
|
||||
const float ContScale = FMath::Lerp(0.45f, 1.0f, M);
|
||||
|
||||
float Terrain = GroundBase
|
||||
+ Cont * P.ElevationRange * 0.5f * ContScale
|
||||
+ Mountain * P.ElevationRange
|
||||
+ Detail * P.SurfaceRoughness;
|
||||
|
||||
OutM = M;
|
||||
return Terrain;
|
||||
}
|
||||
|
||||
// Une SOURCE pose l'altitude, elle ne la déplace pas : la notion de « déplacement max » ne
|
||||
// s'applique pas. La borne d'une colonne se calcule à partir de la source elle-même
|
||||
// (GroundBase ± ElevationRange ± SurfaceRoughness), pas ici — d'où FLT_MAX, honnête.
|
||||
float MaxDisplacement() const override { return FLT_MAX; }
|
||||
|
||||
private:
|
||||
FSurfaceGenerationParams P;
|
||||
float SeedF;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// MOD — FALAISE / CLIFF (raidissement conditionné par la pente)
|
||||
//=========================================================================
|
||||
class FCliffHeightMod final : public IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
FCliffHeightMod(const FSurfaceGenerationParams& InP, const FStructuralHeightSource* InSrc)
|
||||
: P(InP), Src(InSrc) {}
|
||||
|
||||
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
|
||||
{
|
||||
if (P.CliffStrength <= 0.0f || Src == nullptr) { return; }
|
||||
|
||||
const float D = FMath::Max(P.CliffSampleDist, 0.5f);
|
||||
float Ms; // relief scratch — on ne veut que les hauteurs
|
||||
const float Zxp = Src->SampleZ(WorldX + D, WorldY, Ms);
|
||||
const float Zxm = Src->SampleZ(WorldX - D, WorldY, Ms);
|
||||
const float Zyp = Src->SampleZ(WorldX, WorldY + D, Ms);
|
||||
const float Zym = Src->SampleZ(WorldX, WorldY - D, Ms);
|
||||
const float dZdX = (Zxp - Zxm) / (2.0f * D);
|
||||
const float dZdY = (Zyp - Zym) / (2.0f * D);
|
||||
const float Slope = FMath::Sqrt(dZdX * dZdX + dZdY * dZdY);
|
||||
|
||||
const float Thr = FMath::Max(P.CliffSlopeThreshold, 0.05f);
|
||||
const float SlopeGate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f);
|
||||
if (SlopeGate > 0.0f)
|
||||
{
|
||||
const float Ref = 0.25f * (Zxp + Zxm + Zyp + Zym);
|
||||
const float Gain = P.CliffStrength * SlopeGate * P.CliffSharpness;
|
||||
InOut.Height += (InOut.Height - Ref) * Gain;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
FSurfaceGenerationParams P;
|
||||
const FStructuralHeightSource* Src;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// MOD — TERRASSES / TERRACE (gaté par le relief : le canal Relief sert ICI)
|
||||
//=========================================================================
|
||||
class FTerraceHeightMod final : public IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
explicit FTerraceHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {}
|
||||
|
||||
void Eval(float, float, FVoxelHeightSample& InOut) const override
|
||||
{
|
||||
if (P.TerraceStrength <= 0.0f || P.TerraceHeight <= 0.0f) { return; }
|
||||
|
||||
const float StepH = P.TerraceHeight;
|
||||
const float T = InOut.Height / StepH;
|
||||
const float K = FMath::FloorToFloat(T);
|
||||
const float Frac = T - K;
|
||||
const float W = FMath::Lerp(0.5f, 0.03f, FMath::Clamp(P.TerraceHardness, 0.0f, 1.0f));
|
||||
const float Fs = SmoothStep01(FMath::Clamp((Frac - (0.5f - W)) / (2.0f * W), 0.0f, 1.0f));
|
||||
const float Stepped = (K + Fs) * StepH;
|
||||
// `* InOut.Relief` : c'est le `* M` de l'original — la raison d'être du second canal.
|
||||
InOut.Height = FMath::Lerp(InOut.Height, Stepped, P.TerraceStrength * InOut.Relief);
|
||||
}
|
||||
|
||||
// Le terrace interpole VERS une hauteur quantifiée : l'écart ne dépasse jamais un palier.
|
||||
float MaxDisplacement() const override
|
||||
{
|
||||
return (P.TerraceStrength > 0.0f) ? FMath::Max(P.TerraceHeight, 0.0f) : 0.0f;
|
||||
}
|
||||
|
||||
private:
|
||||
FSurfaceGenerationParams P;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// MOD — LIGNES DE STRATES / LAYER LINES
|
||||
//=========================================================================
|
||||
class FLayerLineHeightMod final : public IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
explicit FLayerLineHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {}
|
||||
|
||||
void Eval(float, float, FVoxelHeightSample& InOut) const override
|
||||
{
|
||||
if (P.LayerLineDepth <= 0.0f || P.LayerLineSpacing <= 0.0f) { return; }
|
||||
|
||||
const float Phase = InOut.Height * (2.0f * PI / P.LayerLineSpacing);
|
||||
InOut.Height -= FMath::Sin(Phase) * P.LayerLineDepth;
|
||||
}
|
||||
|
||||
// `sin` ∈ [-1,1] ⇒ borne exacte.
|
||||
float MaxDisplacement() const override
|
||||
{
|
||||
return (P.LayerLineSpacing > 0.0f) ? FMath::Max(P.LayerLineDepth, 0.0f) : 0.0f;
|
||||
}
|
||||
|
||||
private:
|
||||
FSurfaceGenerationParams P;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// MOD — PLAGE / BEACH (aplatissement vers la ligne d'eau)
|
||||
//=========================================================================
|
||||
class FBeachHeightMod final : public IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
explicit FBeachHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {}
|
||||
|
||||
void Eval(float, float, FVoxelHeightSample& InOut) const override
|
||||
{
|
||||
// Le niveau d'eau est GLOBAL à la strate (forcé depuis la strate) pour que le plan
|
||||
// d'eau reste continu — d'où le calcul depuis les bornes de strate, pas depuis un param
|
||||
// par biome.
|
||||
const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ;
|
||||
const float WaterZ = P.StrateBottomWorldZ + H * P.WaterLevelRelative;
|
||||
if (P.WaterLevelRelative <= 0.0f || P.BeachWidth <= 0.0f) { return; }
|
||||
|
||||
const float DAbs = FMath::Abs(InOut.Height - WaterZ);
|
||||
if (DAbs < P.BeachWidth)
|
||||
{
|
||||
float T = SmoothStep01(DAbs / P.BeachWidth);
|
||||
InOut.Height = FMath::Lerp(WaterZ, InOut.Height, T);
|
||||
}
|
||||
}
|
||||
|
||||
// N'agit que dans `BeachWidth` de l'eau, et ne fait qu'y RAPPROCHER.
|
||||
float MaxDisplacement() const override
|
||||
{
|
||||
return (P.WaterLevelRelative > 0.0f) ? FMath::Max(P.BeachWidth, 0.0f) : 0.0f;
|
||||
}
|
||||
|
||||
private:
|
||||
FSurfaceGenerationParams P;
|
||||
};
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// FABRIQUES / FACTORIES
|
||||
//=============================================================================
|
||||
|
||||
namespace VoxelHeightOps
|
||||
{
|
||||
TUniquePtr<IVoxelHeightOp> MakeStructuralHeightSource(const FSurfaceGenerationParams& P, int32 Seed,
|
||||
const IVoxelHeightOp** OutSource)
|
||||
{
|
||||
TUniquePtr<FStructuralHeightSource> Src = MakeUnique<FStructuralHeightSource>(P, Seed);
|
||||
if (OutSource) { *OutSource = Src.Get(); }
|
||||
return Src;
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelHeightOp> MakeCliffHeightMod(const FSurfaceGenerationParams& P,
|
||||
const IVoxelHeightOp* StructuralSource)
|
||||
{
|
||||
// `static_cast` plutôt que `Cast<>` : ce ne sont pas des UObject, et le contrat de la
|
||||
// fabrique est qu'on lui rend exactement le pointeur sorti de MakeStructuralHeightSource.
|
||||
return MakeUnique<FCliffHeightMod>(
|
||||
P, static_cast<const FStructuralHeightSource*>(StructuralSource));
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelHeightOp> MakeTerraceHeightMod(const FSurfaceGenerationParams& P)
|
||||
{
|
||||
return MakeUnique<FTerraceHeightMod>(P);
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelHeightOp> MakeLayerLineHeightMod(const FSurfaceGenerationParams& P)
|
||||
{
|
||||
return MakeUnique<FLayerLineHeightMod>(P);
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelHeightOp> MakeBeachHeightMod(const FSurfaceGenerationParams& P)
|
||||
{
|
||||
return MakeUnique<FBeachHeightMod>(P);
|
||||
}
|
||||
|
||||
void BuildSurfaceHeightStack(FVoxelHeightStack& OutStack, const FSurfaceGenerationParams& P, int32 Seed)
|
||||
{
|
||||
// L'ORDRE EST CELUI DE `ComputeSurfaceTerrainZ`, et il porte du sens :
|
||||
// le cliff raidit le champ brut, le terrace quantifie le résultat raidi, les lignes de
|
||||
// strates se posent dessus, et la plage écrase tout près de l'eau.
|
||||
const IVoxelHeightOp* Structural = nullptr;
|
||||
OutStack.Add(MakeStructuralHeightSource(P, Seed, &Structural));
|
||||
OutStack.Add(MakeCliffHeightMod(P, Structural));
|
||||
OutStack.Add(MakeTerraceHeightMod(P));
|
||||
OutStack.Add(MakeLayerLineHeightMod(P));
|
||||
OutStack.Add(MakeBeachHeightMod(P));
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,17 @@ public:
|
||||
*/
|
||||
float SampleRelief(float WorldX, float WorldY, float Frequency, float Contrast) const;
|
||||
|
||||
/**
|
||||
* La chaîne de hauteur complète de SurfaceWorld : structural → cliff → terrace → layer lines →
|
||||
* plage. Rend une ALTITUDE monde en voxels, pas une densité.
|
||||
*
|
||||
* PUBLIQUE pour la même raison que `GetSlabDensity` / `GetMazeDensity` : permettre un test
|
||||
* isolé. C'est la référence de `VoxelForge.OpStack.SurfaceHeightEquivalence`, qui compare la
|
||||
* pile d'opérateurs de hauteur (`VoxelHeightOp.h`) à cette fonction point par point.
|
||||
* Public so the height-op stack can be measured against it — same reason as GetSlabDensity.
|
||||
*/
|
||||
float ComputeSurfaceTerrainZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const;
|
||||
|
||||
/**
|
||||
* Moisture field at a world XY → [0,1]. The second climate axis for biome placement.
|
||||
*/
|
||||
@@ -267,9 +278,9 @@ private:
|
||||
/** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */
|
||||
int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const;
|
||||
|
||||
/** The SurfaceWorld heightfield: world XY → terrain surface Z (voxel coords). Pure
|
||||
* per-XY; the part that's evaluated per biome and blended in GetSurfaceDensity. */
|
||||
float ComputeSurfaceTerrainZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const;
|
||||
// ComputeSurfaceTerrainZ a été DÉPLACÉE en `public` (voir plus haut) pour que
|
||||
// VoxelForge.OpStack.SurfaceHeightEquivalence puisse s'y comparer. Une seule déclaration.
|
||||
// Moved to public above so the height-stack test can compare against it. One declaration only.
|
||||
|
||||
/** F20 — the RAW structural heightfield (continents + mountains + detail), BEFORE any
|
||||
* terrain op (cliff/terrace/layer-lines/beach). Ops in ComputeSurfaceTerrainZ build on
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// VoxelHeightOp.h
|
||||
// L'ESPACE DES HAUTEURS — une seconde famille d'opérateurs, et pourquoi elle DOIT exister.
|
||||
// HEIGHT SPACE — a second operator family, and why it has to exist.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// LE CONSTAT QUI FORCE CE FICHIER
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// `OPSTACK-DECOMPOSITION §5` décompose SurfaceWorld ainsi :
|
||||
//
|
||||
// FHeightfieldSource ← toute la chaîne de colonne, XY-pure
|
||||
// ├─ FStructuralHeightField
|
||||
// ├─ FCliffHeightMod
|
||||
// ├─ FTerraceHeightMod
|
||||
// ├─ FLayerLineHeightMod
|
||||
// └─ FBeachHeightMod
|
||||
//
|
||||
// et note, sans en tirer la conséquence : *« les ops de hauteur opèrent sur des valeurs Z dans la
|
||||
// colonne, pas sur la densité »*. En lisant `ComputeSurfaceTerrainZ`, c'est littéralement vrai :
|
||||
// c'est une suite de blocs qui lisent et écrivent **un seul float `Terrain`**, une altitude.
|
||||
//
|
||||
// **Ils ne rentrent donc PAS dans `IVoxelDensityOp`.** Sa signature est
|
||||
// `Eval(x, y, z, FVoxelOpSample&)` — par voxel, deux canaux densité/SDF. Un op de hauteur n'a pas
|
||||
// de Z d'entrée (il en PRODUIT un), ne veut pas être appelé par voxel (il est XY-pur, une fois par
|
||||
// colonne), et n'écrit ni densité ni SDF. Les forcer dans le contrat densité demanderait soit un
|
||||
// troisième canal par voxel — alors que la hauteur est une propriété de COLONNE, pas de voxel —,
|
||||
// soit de replier les cinq en un seul op opaque, ce que `§2.5` appelle précisément l'échec du
|
||||
// refactor.
|
||||
//
|
||||
// **Donc : une seconde famille, dans son propre espace.** C'est la même leçon que `§0.1` (il fallait
|
||||
// un canal SDF en plus de la densité), un cran plus loin : certaines choses ne sont pas un canal de
|
||||
// plus, elles sont un ESPACE de plus.
|
||||
//
|
||||
// The height ops read and write a single float ALTITUDE. They have no input Z (they produce one),
|
||||
// are XY-pure (once per column, not per voxel), and write neither density nor SDF. Forcing them into
|
||||
// IVoxelDensityOp would need either a per-voxel third channel for what is a COLUMN property, or
|
||||
// collapsing all five into one opaque op — which §2.5 calls the failure mode. Hence a second family.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// CE QUE ÇA ACHÈTE / WHAT IT BUYS
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// • **Le cache de colonne T1.a tombe naturellement.** Une pile de hauteur est XY-pure PAR
|
||||
// CONSTRUCTION — il n'y a pas de Z à mettre dedans par erreur. `AUDIT §6.3` avertit qu'une donnée
|
||||
// dépendante de Z glissée dans `FSurfaceColumn` corrompt silencieusement toute la pile verticale
|
||||
// de chunks, et que `ValidateDeterminism` ne le verrait pas. Ici c'est le TYPE qui l'interdit.
|
||||
// • **La composition d'idées de terrain devient de l'authoring**, comme pour la densité.
|
||||
// • Les mêmes ops resserviront à VerticalShafts (ledges) et FloatingIslands.
|
||||
//
|
||||
// ⚠️ CE FICHIER NE TOUCHE PAS AU JEU. Il est bâti et exercé par
|
||||
// `VoxelForge.OpStack.SurfaceHeightEquivalence`, qui le compare à `ComputeSurfaceTerrainZ` point par
|
||||
// point. Le branchement dans le chemin densité est l'étape SUIVANTE (§5 : `FHeightfieldSource`,
|
||||
// `FSkyCapSource`, `FOverhangShelfMod`), délibérément séparée pour que la question d'architecture
|
||||
// — *« l'espace des hauteurs se décompose-t-il vraiment ? »* — reçoive une réponse MESURÉE avant
|
||||
// qu'on écrive l'adaptateur qui en dépend.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Templates/UniquePtr.h"
|
||||
#include "VoxelStrateTypes.h" // FSurfaceGenerationParams
|
||||
|
||||
/**
|
||||
* L'état qui traverse une pile de hauteur. DEUX canaux, exactement comme `FVoxelOpSample` — et
|
||||
* pour la même raison : le code le fait déjà.
|
||||
*
|
||||
* `Relief` (le `M` de `SampleSurfaceStructuralZ`) est PRODUIT par la source structurelle et CONSOMMÉ
|
||||
* par le gate du terrace (`TerraceStrength * M`). Sans ce second canal, le terrace devrait
|
||||
* ré-échantillonner le champ de relief — plus lent, et surtout une occasion de diverger de la valeur
|
||||
* que la source a réellement utilisée.
|
||||
*
|
||||
* Two channels, for the same reason as FVoxelOpSample: Relief (the `M` of the structural field) is
|
||||
* produced by the source and consumed by the terrace gate. Threading it beats resampling it.
|
||||
*/
|
||||
struct FVoxelHeightSample
|
||||
{
|
||||
/** Altitude monde en VOXELS (pas cm). */
|
||||
float Height = 0.0f;
|
||||
|
||||
/** « Montagnosité » [0,1]. 1 = uniforme (ReliefStrength = 0). */
|
||||
float Relief = 1.0f;
|
||||
};
|
||||
|
||||
/**
|
||||
* Un opérateur d'espace-hauteur. Trois différences avec `IVoxelDensityOp`, toutes voulues :
|
||||
* • pas de Z d'entrée — la pile en PRODUIT un ;
|
||||
* • XY-pur par construction, donc pas de `IsXYPure()` à déclarer ni à oublier ;
|
||||
* • pas de `PrepareChunk` — ces ops sont déjà appelés une fois par colonne, ce qui EST la
|
||||
* granularité que `PrepareChunk` sert à obtenir côté densité.
|
||||
*/
|
||||
class IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
virtual ~IVoxelHeightOp() = default;
|
||||
|
||||
/**
|
||||
* INVARIANCE DE FENÊTRE (ARCHITECTURE §8.4) : fonction PURE de (X, Y, seed, params). Le même XY
|
||||
* évalué depuis une autre tuile, un autre ordre, un autre thread doit rendre le float
|
||||
* BIT-IDENTIQUE — le cache de colonne T1.a est partagé sur toute la pile verticale de chunks,
|
||||
* donc une impureté ici se propage à tous les Z d'un coup.
|
||||
*/
|
||||
virtual void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const = 0;
|
||||
|
||||
/**
|
||||
* Majorant CONSERVATIF du déplacement vertical que cet op peut ajouter, en voxels.
|
||||
* Sert à borner la colonne pour un futur `ClassifyBox` exact du heightfield — la même logique
|
||||
* que les bandes de `FSlabVoidSource`, qui prouvent 36-40 tuiles sur 60.
|
||||
* Rendre trop grand coûte du CPU ; rendre trop petit serait un TROU. `FLT_MAX` = « je ne sais
|
||||
* pas », toujours sûr, et c'est le défaut.
|
||||
*/
|
||||
virtual float MaxDisplacement() const { return FLT_MAX; }
|
||||
};
|
||||
|
||||
/**
|
||||
* Pile de hauteur : source → modificateurs, dans l'ordre. Déplaçable, pas copiable, exactement
|
||||
* comme `FVoxelOpStack` et pour la même raison (elle POSSÈDE ses opérateurs).
|
||||
*/
|
||||
class FVoxelHeightStack
|
||||
{
|
||||
public:
|
||||
FVoxelHeightStack() = default;
|
||||
FVoxelHeightStack(FVoxelHeightStack&&) = default;
|
||||
FVoxelHeightStack& operator=(FVoxelHeightStack&&) = default;
|
||||
FVoxelHeightStack(const FVoxelHeightStack&) = delete;
|
||||
FVoxelHeightStack& operator=(const FVoxelHeightStack&) = delete;
|
||||
|
||||
void Add(TUniquePtr<IVoxelHeightOp> Op) { Ops.Add(MoveTemp(Op)); }
|
||||
int32 Num() const { return Ops.Num(); }
|
||||
|
||||
/** L'altitude après toute la pile. */
|
||||
float EvalHeight(float WorldX, float WorldY) const
|
||||
{
|
||||
return EvalSample(WorldX, WorldY).Height;
|
||||
}
|
||||
|
||||
/** L'état complet (altitude + relief). */
|
||||
FVoxelHeightSample EvalSample(float WorldX, float WorldY) const
|
||||
{
|
||||
FVoxelHeightSample S;
|
||||
for (const TUniquePtr<IVoxelHeightOp>& Op : Ops) { Op->Eval(WorldX, WorldY, S); }
|
||||
return S;
|
||||
}
|
||||
|
||||
/** Somme des majorants. `FLT_MAX` dès qu'un seul op ne sait pas répondre. */
|
||||
float MaxTotalDisplacement() const
|
||||
{
|
||||
float Total = 0.0f;
|
||||
for (const TUniquePtr<IVoxelHeightOp>& Op : Ops)
|
||||
{
|
||||
const float D = Op->MaxDisplacement();
|
||||
if (D >= FLT_MAX) { return FLT_MAX; }
|
||||
Total += D;
|
||||
}
|
||||
return Total;
|
||||
}
|
||||
|
||||
private:
|
||||
TArray<TUniquePtr<IVoxelHeightOp>> Ops;
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// FABRIQUES / FACTORIES
|
||||
//=============================================================================
|
||||
|
||||
namespace VoxelHeightOps
|
||||
{
|
||||
/**
|
||||
* La source structurelle : continents + montagnes + détail, sous une frame de warp.
|
||||
* Produit `Height` ET `Relief`. Transcription littérale de `SampleSurfaceStructuralZ`.
|
||||
*
|
||||
* ⚠️ Rend un pointeur NON-POSSÉDANT via `OutSource` : `FCliffHeightMod` doit pouvoir
|
||||
* RÉ-ÉCHANTILLONNER ce champ (4 fois, en différences centrées) et doit le faire sur la MÊME
|
||||
* fonction, pas sur une copie qui pourrait dériver. La pile garde la propriété ; la source vit
|
||||
* donc aussi longtemps que le modificateur qui la référence, parce que le constructeur de pile
|
||||
* les ajoute ensemble et que la pile ne réordonne jamais.
|
||||
*/
|
||||
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeStructuralHeightSource(
|
||||
const FSurfaceGenerationParams& P, int32 Seed, const IVoxelHeightOp** OutSource);
|
||||
|
||||
/** Raidissement conditionné par la pente. Le seul op qui coûte des échantillons en plus
|
||||
* (4 resamples structurels), et seulement quand il est activé. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeCliffHeightMod(
|
||||
const FSurfaceGenerationParams& P, const IVoxelHeightOp* StructuralSource);
|
||||
|
||||
/** Plateaux quantifiés, gatés par le relief (`TerraceStrength * M`) — d'où le canal Relief. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeTerraceHeightMod(const FSurfaceGenerationParams& P);
|
||||
|
||||
/** Bandes sédimentaires : `Height -= sin(Height · 2π / Spacing) · Depth`. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeLayerLineHeightMod(const FSurfaceGenerationParams& P);
|
||||
|
||||
/** Aplatissement vers la ligne d'eau dans `BeachWidth`. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeBeachHeightMod(const FSurfaceGenerationParams& P);
|
||||
|
||||
/**
|
||||
* La pile de hauteur complète de SurfaceWorld, dans l'ordre de `ComputeSurfaceTerrainZ` :
|
||||
* structural → cliff → terrace → layer lines → beach
|
||||
*
|
||||
* L'ordre n'est PAS négociable : le terrace quantifie une hauteur que le cliff a déjà raidie,
|
||||
* les layer lines se posent sur le résultat, et la plage écrase tout près de l'eau. C'est
|
||||
* l'ordre du code d'origine, et le test échouerait bruyamment sur toute permutation.
|
||||
*/
|
||||
VOXELFORGE_API void BuildSurfaceHeightStack(FVoxelHeightStack& OutStack,
|
||||
const FSurfaceGenerationParams& P, int32 Seed);
|
||||
}
|
||||
Reference in New Issue
Block a user