feat: SurfaceWorld complete — biome blending wired, guard removed
The two biome checks added last commit printed nothing on success, so a passing run was indistinguishable from a block that never executed — the exact flaw I flagged twice this session and then wrote myself. Both now report their coverage. Step 2c closes SurfaceWorld: - FSurfaceColumnSource takes per-biome params and an OWNED IVoxelBiomeField. Empty params leaves the original path bit-for-bit unchanged. - The field is owned by the stack rather than borrowed: the adapter points at GetDensityAt's thread_local biome context and cache, and the stack is itself thread_local rebuilt in the same refetch block, so all three live and die together. Structural ownership beats a convention the next reader has to infer. - The overhang amp blends across biomes — Lerp(Amp(PD), Amp(PN), W) with slope and threshold from the dominant only, as ComputeSurfaceColumn does. Interpolating the slope would be meaningless; it measures the terrain rather than configuring it. - FGeneratorBiomeField lives in VoxelGenerator.cpp, on the side that knows the generator. The op sees a capability, never an owner — which is what lets it become an asset in Phase 3. - The no-biome guard is removed from UsesOperatorStackForChunk. Also: the two constructors now delegate to one body with one id counter. The first draft had two competing counters, one tagged with a high bit to avoid collision, which is a smell rather than a design. 5 of 8 archetypes ported: Maze, FlatPlain, CrystalChamber, SurfaceWorld. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1585,3 +1585,49 @@ and the generator-side adapter wraps `ResolveBiomeSampleAt`. That drops the biom
|
|||||||
`FConstantRockSource` + `FSdfRoughnessMod` + `FSdfCarve` and should be the cheapest port yet.
|
`FConstantRockSource` + `FSdfRoughnessMod` + `FSdfCarve` and should be the cheapest port yet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-27 — SurfaceWorld COMPLETE (biomes included). 5 of 8 archetypes ported.
|
||||||
|
|
||||||
|
All 10 tests green again. **But the two new biome checks printed nothing** — because I wrote them to
|
||||||
|
report only on failure. **That is the exact flaw I flagged twice in this session** (the
|
||||||
|
`WaterLevelRelative` early-out, the overhang window count) and then committed myself: a silent pass
|
||||||
|
is indistinguishable from a check that never ran. Both now `AddInfo` their coverage, so the next run
|
||||||
|
shows the blend actually executed over 2000 (weight, point) pairs.
|
||||||
|
|
||||||
|
### Step 2c — the density side, and SurfaceWorld is closed
|
||||||
|
|
||||||
|
- **`FSurfaceColumnSource` takes per-biome params + an owned `IVoxelBiomeField`.** Empty params ⇒
|
||||||
|
the original path, bit-for-bit unchanged (which is why the existing tests should stay green).
|
||||||
|
- **The field is OWNED by the stack, not borrowed.** The real adapter points at `GetDensityAt`'s
|
||||||
|
`thread_local` `CP_BiomeCtx` / `CP_BiomeCache`; the stack is itself `thread_local` and rebuilt in
|
||||||
|
the *same* refetch block, so all three are born and die together on one thread. Making ownership
|
||||||
|
structural beats leaving survival to a convention the next reader has to infer.
|
||||||
|
- **The overhang amp now blends across biomes** — `Lerp(Amp(PD), Amp(PN), W)` with the slope and
|
||||||
|
threshold from the **dominant** only, exactly as `ComputeSurfaceColumn` does. Interpolating the
|
||||||
|
*slope* would be meaningless: it is a measurement of the terrain, not a setting.
|
||||||
|
- **`FGeneratorBiomeField` lives in `VoxelGenerator.cpp`,** on the side that knows the generator.
|
||||||
|
That is the whole point of the interface — the op sees a *capability*, never an owner, which is
|
||||||
|
what lets it become an asset in Phase 3.
|
||||||
|
- **The biome guard is gone** from `UsesOperatorStackForChunk`.
|
||||||
|
|
||||||
|
Also cleaned up while there: the two constructors now **delegate to one body with one id counter**
|
||||||
|
instead of each initialising separately (two init paths is two places to forget a member — the first
|
||||||
|
draft already had two competing counters, one of them tagged with a high bit to avoid collision,
|
||||||
|
which is a smell rather than a design).
|
||||||
|
|
||||||
|
**5 of 8 archetypes ported:** Maze · FlatPlain · CrystalChamber · SurfaceWorld.
|
||||||
|
|
||||||
|
**UNVERIFIED:** not compiled. Likely spots: the delegating constructor; `TUniquePtr<IVoxelBiomeField>`
|
||||||
|
as a defaulted parameter in the public header; `VoxelHeightOp.h` newly included by
|
||||||
|
`VoxelDensityOpStack.h` (a public→public include); the `case` block now needing braces for its local
|
||||||
|
declarations; and `FGeneratorBiomeField` being defined before `UVoxelGenerator`'s member functions
|
||||||
|
while calling `ResolveBiomeSampleAt`.
|
||||||
|
|
||||||
|
**What to try after the build:** tick `bUseOperatorStack` on a SurfaceWorld strate **with biomes**
|
||||||
|
now. Both paths compute the same function, so expect it identical — biome borders included, which is
|
||||||
|
the case that was guarded off until now.
|
||||||
|
|
||||||
|
**Next single action:** build. Then `VerticalShafts` (§6) — it reuses `FConstantRockSource`,
|
||||||
|
`FSdfRoughnessMod` and `FSdfCarve` unchanged from Maze, so it should be the cheapest of the eight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -557,6 +557,18 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
|
|||||||
TestEqual(TEXT("biome blend: heights lerp between the two biomes' full stacks, bit-exactly"),
|
TestEqual(TEXT("biome blend: heights lerp between the two biomes' full stacks, bit-exactly"),
|
||||||
NumWrong, 0);
|
NumWrong, 0);
|
||||||
|
|
||||||
|
// ⚠️ Rapporter le SUCCÈS, pas seulement l'échec. Un `TestEqual` qui passe n'écrit rien, et
|
||||||
|
// une vérification silencieuse est indiscernable d'une vérification qui n'a jamais tourné —
|
||||||
|
// exactement le piège signalé pour `WaterLevelRelative` et la fenêtre d'overhang, dans
|
||||||
|
// lequel ce bloc-ci était tombé au premier jet. Le compte rend l'exécution visible.
|
||||||
|
// Report success, not just failure: a silent pass is indistinguishable from a check that
|
||||||
|
// never ran.
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Biome blend: %d (weight, point) pairs across weights 0/0.25/0.5/0.75/1.0 all match ")
|
||||||
|
TEXT("Lerp of the two biomes' full height stacks bit-exactly. Weight 0 returns the ")
|
||||||
|
TEXT("dominant untouched and weight 1 the neighbour, so the lerp is not inverted."),
|
||||||
|
(int32)UE_ARRAY_COUNT(Weights) * 400));
|
||||||
|
|
||||||
if (NumWrong > 0)
|
if (NumWrong > 0)
|
||||||
{
|
{
|
||||||
AddError(FString::Printf(
|
AddError(FString::Printf(
|
||||||
@@ -584,6 +596,11 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
|
|||||||
}
|
}
|
||||||
TestEqual(TEXT("biome ceiling SELECTS the dominant (never blends), even at weight 1"),
|
TestEqual(TEXT("biome ceiling SELECTS the dominant (never blends), even at weight 1"),
|
||||||
NumCeilWrong, 0);
|
NumCeilWrong, 0);
|
||||||
|
|
||||||
|
AddInfo(TEXT("Biome ceiling: 200 points at neighbour-weight 1.0 still return the ")
|
||||||
|
TEXT("DOMINANT biome's sky cap, i.e. it selects rather than blends -- the "
|
||||||
|
"original's behaviour, and the case a \"blend everything\" refactor would "
|
||||||
|
"silently break."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -417,8 +417,59 @@ namespace
|
|||||||
class FSurfaceColumnSource final : public IVoxelDensityOp
|
class FSurfaceColumnSource final : public IVoxelDensityOp
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
/**
|
||||||
|
* @param PerBiome vide ⇒ pas de biomes, chemin d'origine inchangé. Non vide ⇒ le sol est
|
||||||
|
* mélangé et le plafond sélectionné par `InField`.
|
||||||
|
* @param InField **POSSÉDÉ** — délibérément, plutôt qu'un pointeur nu. L'adaptateur réel
|
||||||
|
* pointe vers des `thread_local` du générateur ; lier sa durée de vie à
|
||||||
|
* celle de la pile (elle-même `thread_local`, reconstruite au même moment)
|
||||||
|
* rend la question de survie structurelle au lieu de la laisser à une
|
||||||
|
* convention que le prochain lecteur devrait deviner.
|
||||||
|
* OWNED on purpose rather than borrowed: tying its lifetime to the stack's
|
||||||
|
* makes the survival question structural instead of conventional.
|
||||||
|
*/
|
||||||
|
FSurfaceColumnSource(const FSurfaceGenerationParams& InP, int32 Seed,
|
||||||
|
const TArray<FSurfaceGenerationParams>& PerBiome,
|
||||||
|
TUniquePtr<IVoxelBiomeField> InField)
|
||||||
|
: P(InP), BiomeParams(PerBiome), Field(MoveTemp(InField))
|
||||||
|
{
|
||||||
|
if (BiomeParams.Num() > 0)
|
||||||
|
{
|
||||||
|
// Chemin BIOMES : une pile complète par biome, sol mélangé / plafond sélectionné.
|
||||||
|
TerrainStack.Add(VoxelHeightOps::MakeBiomeBlendHeightSource(BiomeParams, Seed, Field.Get()));
|
||||||
|
CeilingStack.Add(VoxelHeightOps::MakeBiomeSelectCeilingSource(BiomeParams, Seed, Field.Get()));
|
||||||
|
|
||||||
|
// Un champ structurel PAR BIOME : la pente de l'overhang doit venir du champ du
|
||||||
|
// biome DOMINANT (l'original échantillonne `*PD`), pas d'un champ moyen.
|
||||||
|
PerBiomeStructural.Reserve(BiomeParams.Num());
|
||||||
|
for (const FSurfaceGenerationParams& BP : BiomeParams)
|
||||||
|
{
|
||||||
|
const IVoxelHeightOp* Raw = nullptr;
|
||||||
|
PerBiomeStructural.Add(VoxelHeightOps::MakeStructuralHeightSource(BP, Seed, &Raw));
|
||||||
|
}
|
||||||
|
Structural = PerBiomeStructural.Num() > 0 ? PerBiomeStructural[0].Get() : nullptr;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
BuildSingleBiome(InP, Seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identité unique et NON RECYCLÉE — la clé du mémo par colonne. `this` ne suffirait
|
||||||
|
// pas : une pile détruite puis une autre allouée à la même adresse avec d'autres params
|
||||||
|
// donnerait un faux positif silencieux. Un compteur qui ne redescend jamais l'interdit.
|
||||||
|
// Assignée ICI et nulle part ailleurs : l'autre constructeur délègue à celui-ci.
|
||||||
|
// A unique, never-recycled id — assigned here only; the other ctor delegates.
|
||||||
|
static std::atomic<uint64> NextId{ 1 };
|
||||||
|
InstanceId = NextId.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sans biomes — délègue, pour qu'il n'existe qu'UN corps de construction et UN compteur
|
||||||
|
* d'identité. Deux constructeurs qui s'initialisent chacun de leur côté, c'est deux
|
||||||
|
* endroits où oublier un membre. */
|
||||||
FSurfaceColumnSource(const FSurfaceGenerationParams& InP, int32 Seed)
|
FSurfaceColumnSource(const FSurfaceGenerationParams& InP, int32 Seed)
|
||||||
: P(InP)
|
: FSurfaceColumnSource(InP, Seed, TArray<FSurfaceGenerationParams>(), nullptr) {}
|
||||||
|
|
||||||
|
void BuildSingleBiome(const FSurfaceGenerationParams& InP, int32 Seed)
|
||||||
{
|
{
|
||||||
// Construite à la main (pas via BuildSurfaceHeightStack) pour GARDER le pointeur vers la
|
// Construite à la main (pas via BuildSurfaceHeightStack) pour GARDER le pointeur vers la
|
||||||
// source structurelle : l'overhang en a besoin, pour son gradient de pente comme pour
|
// source structurelle : l'overhang en a besoin, pour son gradient de pente comme pour
|
||||||
@@ -430,15 +481,6 @@ namespace
|
|||||||
TerrainStack.Add(VoxelHeightOps::MakeBeachHeightMod(InP));
|
TerrainStack.Add(VoxelHeightOps::MakeBeachHeightMod(InP));
|
||||||
|
|
||||||
VoxelHeightOps::BuildSurfaceCeilingStack(CeilingStack, InP, Seed);
|
VoxelHeightOps::BuildSurfaceCeilingStack(CeilingStack, InP, Seed);
|
||||||
|
|
||||||
// Identité unique et NON RECYCLÉE. Clé du mémo par colonne ci-dessous : `this` ne
|
|
||||||
// suffirait pas — une pile détruite puis une autre allouée à la même adresse avec
|
|
||||||
// d'autres params donnerait un faux positif silencieux. Un compteur qui ne redescend
|
|
||||||
// jamais rend ça impossible.
|
|
||||||
// A unique, never-recycled id: `this` would allow a freed-then-reallocated stack to
|
|
||||||
// collide with the previous one's memo. A monotonic counter cannot.
|
|
||||||
static std::atomic<uint64> NextId{ 1 };
|
|
||||||
InstanceId = NextId.fetch_add(1, std::memory_order_relaxed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** La colonne complète, exactement les cinq sorties de `ComputeSurfaceColumn`.
|
/** La colonne complète, exactement les cinq sorties de `ComputeSurfaceColumn`.
|
||||||
@@ -483,17 +525,48 @@ namespace
|
|||||||
// STRUCTUREL, à l'échelle de la portée mais CLAMPÉE à [4,16]. Sans ce clamp, une
|
// STRUCTUREL, à l'échelle de la portée mais CLAMPÉE à [4,16]. Sans ce clamp, une
|
||||||
// grande `Reach` moyenne la pente sur une énorme portée et lit même une vraie
|
// grande `Reach` moyenne la pente sur une énorme portée et lit même une vraie
|
||||||
// falaise comme plate — le bug « grande Reach = rien ». Transcrit tel quel.
|
// falaise comme plate — le bug « grande Reach = rien ». Transcrit tel quel.
|
||||||
if (P.OverhangStrength > 0.0f && Structural != nullptr)
|
// Quel jeu de params gouverne cette colonne ? Sans biomes, `P`. Avec, le DOMINANT
|
||||||
|
// pour la pente et le seuil, et une interpolation de l'AMPLITUDE vers le voisin —
|
||||||
|
// exactement ce que fait `ComputeSurfaceColumn` (`Lerp(Amp(PD), Amp(PN), W)`, pente
|
||||||
|
// depuis `*PD` seul). Interpoler la pente n'aurait pas de sens : c'est une mesure du
|
||||||
|
// terrain, pas un réglage.
|
||||||
|
const FSurfaceGenerationParams* PD = &P;
|
||||||
|
const FSurfaceGenerationParams* PN = nullptr;
|
||||||
|
float W = 0.0f;
|
||||||
|
const IVoxelHeightOp* SlopeField = Structural;
|
||||||
|
|
||||||
|
if (BiomeParams.Num() > 0 && Field)
|
||||||
{
|
{
|
||||||
const float SD = FMath::Clamp(P.OverhangReach, 4.0f, 16.0f);
|
const FVoxelBiomeWeights BW = Field->SampleAt(WorldX, WorldY);
|
||||||
const float Z0 = SampleStructural(WorldX, WorldY);
|
const int32 Di = BiomeParams.IsValidIndex(BW.Dominant) ? BW.Dominant : 0;
|
||||||
const float GX = (SampleStructural(WorldX + SD, WorldY) - Z0) / SD;
|
PD = &BiomeParams[Di];
|
||||||
const float GY = (SampleStructural(WorldX, WorldY + SD) - Z0) / SD;
|
if (PerBiomeStructural.IsValidIndex(Di)) { SlopeField = PerBiomeStructural[Di].Get(); }
|
||||||
|
if (BW.NeighborWeight > 0.0f && BiomeParams.IsValidIndex(BW.Neighbor))
|
||||||
|
{
|
||||||
|
PN = &BiomeParams[BW.Neighbor];
|
||||||
|
W = BW.NeighborWeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool bAnyOverhang = (PD->OverhangStrength > 0.0f)
|
||||||
|
|| (PN && PN->OverhangStrength > 0.0f);
|
||||||
|
|
||||||
|
if (bAnyOverhang && SlopeField != nullptr)
|
||||||
|
{
|
||||||
|
const float SD = FMath::Clamp(PD->OverhangReach, 4.0f, 16.0f);
|
||||||
|
const float Z0 = SampleStructuralOf(SlopeField, WorldX, WorldY);
|
||||||
|
const float GX = (SampleStructuralOf(SlopeField, WorldX + SD, WorldY) - Z0) / SD;
|
||||||
|
const float GY = (SampleStructuralOf(SlopeField, WorldX, WorldY + SD) - Z0) / SD;
|
||||||
const float Slope = FMath::Sqrt(GX * GX + GY * GY);
|
const float Slope = FMath::Sqrt(GX * GX + GY * GY);
|
||||||
|
|
||||||
const float Thr = FMath::Max(P.OverhangSlopeThreshold, 0.05f);
|
auto Amp = [Slope](const FSurfaceGenerationParams& Q) -> float
|
||||||
|
{
|
||||||
|
if (Q.OverhangStrength <= 0.0f) { return 0.0f; }
|
||||||
|
const float Thr = FMath::Max(Q.OverhangSlopeThreshold, 0.05f);
|
||||||
const float Gate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f);
|
const float Gate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f);
|
||||||
C.OverhangAmp = P.OverhangStrength * Gate; // [0,1]
|
return Q.OverhangStrength * Gate; // [0,1]
|
||||||
|
};
|
||||||
|
C.OverhangAmp = (W > 0.0f && PN) ? FMath::Lerp(Amp(*PD), Amp(*PN), W) : Amp(*PD);
|
||||||
|
|
||||||
// Direction amont unitaire (le gradient pointe vers le haut). Dégénérée sur le
|
// Direction amont unitaire (le gradient pointe vers le haut). Dégénérée sur le
|
||||||
// plat — mais l'amplitude y vaut 0 de toute façon.
|
// plat — mais l'amplitude y vaut 0 de toute façon.
|
||||||
@@ -503,11 +576,25 @@ namespace
|
|||||||
return S.C;
|
return S.C;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Le champ structurel nu — l'overhang s'en sert pour emprunter la roche amont. */
|
/** Le champ structurel nu — l'overhang s'en sert pour emprunter la roche amont.
|
||||||
|
* Avec des biomes, c'est celui du biome DOMINANT en ce point (l'original emprunte à `*PD`). */
|
||||||
float SampleStructural(float WorldX, float WorldY) const
|
float SampleStructural(float WorldX, float WorldY) const
|
||||||
{
|
{
|
||||||
|
const IVoxelHeightOp* Src = Structural;
|
||||||
|
if (BiomeParams.Num() > 0 && Field)
|
||||||
|
{
|
||||||
|
const FVoxelBiomeWeights BW = Field->SampleAt(WorldX, WorldY);
|
||||||
|
const int32 Di = PerBiomeStructural.IsValidIndex(BW.Dominant) ? BW.Dominant : 0;
|
||||||
|
if (PerBiomeStructural.IsValidIndex(Di)) { Src = PerBiomeStructural[Di].Get(); }
|
||||||
|
}
|
||||||
|
return SampleStructuralOf(Src, WorldX, WorldY);
|
||||||
|
}
|
||||||
|
|
||||||
|
static float SampleStructuralOf(const IVoxelHeightOp* Src, float WorldX, float WorldY)
|
||||||
|
{
|
||||||
|
if (!Src) { return 0.0f; }
|
||||||
FVoxelHeightSample S;
|
FVoxelHeightSample S;
|
||||||
Structural->Eval(WorldX, WorldY, S);
|
Src->Eval(WorldX, WorldY, S);
|
||||||
return S.Height;
|
return S.Height;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,6 +636,12 @@ namespace
|
|||||||
FVoxelHeightStack TerrainStack;
|
FVoxelHeightStack TerrainStack;
|
||||||
FVoxelHeightStack CeilingStack;
|
FVoxelHeightStack CeilingStack;
|
||||||
const IVoxelHeightOp* Structural = nullptr; // NON possédant : la pile terrain le possède
|
const IVoxelHeightOp* Structural = nullptr; // NON possédant : la pile terrain le possède
|
||||||
|
|
||||||
|
// Chemin BIOMES. Vide ⇒ chemin d'origine, inchangé bit pour bit.
|
||||||
|
TArray<FSurfaceGenerationParams> BiomeParams;
|
||||||
|
TUniquePtr<IVoxelBiomeField> Field; // POSSÉDÉ (voir le constructeur)
|
||||||
|
TArray<TUniquePtr<IVoxelHeightOp>> PerBiomeStructural; // pente d'overhang par biome
|
||||||
|
|
||||||
uint64 InstanceId = 0;
|
uint64 InstanceId = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1060,15 +1153,14 @@ namespace VoxelDensityOps
|
|||||||
}
|
}
|
||||||
|
|
||||||
void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P,
|
void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P,
|
||||||
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
|
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager,
|
||||||
|
const TArray<FSurfaceGenerationParams>& PerBiomeParams,
|
||||||
|
TUniquePtr<IVoxelBiomeField> BiomeField)
|
||||||
{
|
{
|
||||||
// ⚠️ PAS ENCORE L'ARCHÉTYPE COMPLET : il manque le **MÉLANGE DE BIOMES** — le sol est
|
// ARCHÉTYPE COMPLET depuis l'étape 2c : vide + overhang + mélange de biomes.
|
||||||
// évalué pour le biome dominant puis lerpé vers le voisin. En termes de pile c'est le
|
// `PerBiomeParams` vide ⇒ chemin sans biomes, strictement inchangé.
|
||||||
// combiner `Mask`, et `§5` en fait le prototype de la Phase 3 : ça mérite sa propre étape,
|
TUniquePtr<FSurfaceColumnSource> ColumnSource =
|
||||||
// pas un paramètre de plus ici. **Ne pas brancher dans un monde à biomes avant.**
|
MakeUnique<FSurfaceColumnSource>(P, Seed, PerBiomeParams, MoveTemp(BiomeField));
|
||||||
//
|
|
||||||
// L'overhang, lui, est là depuis l'étape 2b.
|
|
||||||
TUniquePtr<FSurfaceColumnSource> ColumnSource = MakeUnique<FSurfaceColumnSource>(P, Seed);
|
|
||||||
const FSurfaceColumnSource* ColumnPtr = ColumnSource.Get();
|
const FSurfaceColumnSource* ColumnPtr = ColumnSource.Get();
|
||||||
OutStack.Add(MoveTemp(ColumnSource));
|
OutStack.Add(MoveTemp(ColumnSource));
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,51 @@
|
|||||||
#include "VoxelNoise.h" // T2.a: float, SIMD-batched gradient-noise core
|
#include "VoxelNoise.h" // T2.a: float, SIMD-batched gradient-noise core
|
||||||
#include "VoxelDensityPrimitives.h" // spine / seal / passage — shared with the operator stack
|
#include "VoxelDensityPrimitives.h" // spine / seal / passage — shared with the operator stack
|
||||||
#include "VoxelDensityOpStack.h" // OPSTACK Phase 1: the opt-in per-strate operator stack
|
#include "VoxelDensityOpStack.h" // OPSTACK Phase 1: the opt-in per-strate operator stack
|
||||||
|
#include "VoxelHeightOp.h" // IVoxelBiomeField — the adapter below implements it
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// L'ADAPTATEUR DE CHAMP DE BIOMES / THE BIOME FIELD ADAPTER
|
||||||
|
//=============================================================================
|
||||||
|
// Il vit ICI, du côté qui connaît le générateur, et PAS dans la pile d'opérateurs. C'est tout
|
||||||
|
// l'intérêt de `IVoxelBiomeField` : le résolveur réel est une Voronoï warpée avec un cache par
|
||||||
|
// chunk sur `UVoxelGenerator`, et un opérateur qui tiendrait ce pointeur ne pourrait jamais devenir
|
||||||
|
// un asset (Phase 3). En le confinant ici, l'opérateur ne connaît qu'une capacité, pas un
|
||||||
|
// propriétaire.
|
||||||
|
//
|
||||||
|
// ⚠️ DURÉES DE VIE : cet adaptateur pointe vers les `thread_local` `CP_BiomeCtx` / `CP_BiomeCache`
|
||||||
|
// de `GetDensityAt`. Il est POSSÉDÉ par la pile, elle-même `thread_local` et reconstruite dans le
|
||||||
|
// MÊME bloc de refetch que ces deux caches — les trois naissent et meurent ensemble, sur le même
|
||||||
|
// thread. Un pointeur vers un thread_local depuis un objet thread_local du même bloc est sûr ;
|
||||||
|
// l'échapper ailleurs ne le serait pas.
|
||||||
|
//
|
||||||
|
// Lives here, not in the op stack: the real resolver is generator state, and an op holding that
|
||||||
|
// pointer could never become an asset. LIFETIME: it points at GetDensityAt's thread_locals and is
|
||||||
|
// owned by the stack, which is itself thread_local and rebuilt in the same refetch block.
|
||||||
|
class FGeneratorBiomeField final : public IVoxelBiomeField
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
FGeneratorBiomeField(const UVoxelGenerator* InGen, const FBiomeContext* InCtx,
|
||||||
|
FChunkBiomeCache* InCache, int32 InChunkZ)
|
||||||
|
: Gen(InGen), Ctx(InCtx), Cache(InCache), ChunkZ(InChunkZ) {}
|
||||||
|
|
||||||
|
FVoxelBiomeWeights SampleAt(float WorldX, float WorldY) const override
|
||||||
|
{
|
||||||
|
FVoxelBiomeWeights Out;
|
||||||
|
if (!Gen || !Ctx || !Cache) { return Out; } // dégradation sûre : biome 0 partout
|
||||||
|
|
||||||
|
const FBiomeSample S = Gen->ResolveBiomeSampleAt(WorldX, WorldY, ChunkZ, *Ctx, *Cache);
|
||||||
|
Out.Dominant = FMath::Max(S.DominantIndex, 0); // -1 ⇒ 0, comme le chemin d'origine
|
||||||
|
Out.Neighbor = S.NeighborIndex;
|
||||||
|
Out.NeighborWeight = S.NeighborWeight;
|
||||||
|
return Out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
const UVoxelGenerator* Gen;
|
||||||
|
const FBiomeContext* Ctx;
|
||||||
|
FChunkBiomeCache* Cache;
|
||||||
|
int32 ChunkZ;
|
||||||
|
};
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// SURFACE COLUMN CACHE (T1.a) — kill the per-Z heightfield redundancy
|
// SURFACE COLUMN CACHE (T1.a) — kill the per-Z heightfield redundancy
|
||||||
@@ -580,24 +625,33 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case ECaveGeneratorType::SurfaceWorld:
|
case ECaveGeneratorType::SurfaceWorld:
|
||||||
// Même garde dégénérée que les autres, et une SECONDE garde : la pile ne sait
|
{
|
||||||
// pas encore mélanger les biomes. `UsesOperatorStackForChunk` refuse déjà les
|
if (CP_Surface.StrateTopWorldZ - CP_Surface.StrateBottomWorldZ <= 0.0f)
|
||||||
// strates à biomes, mais un `CP_BiomeCtx` valide ici voudrait dire que les deux
|
|
||||||
// sources d'information se contredisent — auquel cas on retombe sur le `switch`,
|
|
||||||
// parce qu'un monde non porté est récupérable et un monde faux ne l'est pas.
|
|
||||||
// A valid biome context here would mean the two sources of truth disagree; fall
|
|
||||||
// back rather than generate a world with seams at every biome border.
|
|
||||||
if (CP_Surface.StrateTopWorldZ - CP_Surface.StrateBottomWorldZ <= 0.0f
|
|
||||||
|| CP_BiomeCtx.IsValid())
|
|
||||||
{
|
{
|
||||||
CP_UseOpStack = false;
|
CP_UseOpStack = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
OpCtx.StrateTopWorldZ = CP_Surface.StrateTopWorldZ;
|
OpCtx.StrateTopWorldZ = CP_Surface.StrateTopWorldZ;
|
||||||
OpCtx.StrateBottomWorldZ = CP_Surface.StrateBottomWorldZ;
|
OpCtx.StrateBottomWorldZ = CP_Surface.StrateBottomWorldZ;
|
||||||
|
|
||||||
|
// Le champ de biomes est fabriqué ICI, du côté qui connaît le générateur, et
|
||||||
|
// TRANSFÉRÉ à la pile. L'opérateur ne voit qu'une `IVoxelBiomeField` : c'est ce
|
||||||
|
// qui lui permet de devenir un asset en Phase 3 sans traîner le générateur.
|
||||||
|
// Built here, on the side that knows the generator, and handed to the stack.
|
||||||
|
TUniquePtr<IVoxelBiomeField> Field;
|
||||||
|
TArray<FSurfaceGenerationParams> PerBiome;
|
||||||
|
if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0)
|
||||||
|
{
|
||||||
|
PerBiome = CP_SurfaceBiomeParams;
|
||||||
|
Field = MakeUnique<FGeneratorBiomeField>(
|
||||||
|
this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z);
|
||||||
|
}
|
||||||
|
|
||||||
VoxelDensityOps::BuildSurfaceStack(CP_OpStack, CP_Surface, Seed,
|
VoxelDensityOps::BuildSurfaceStack(CP_OpStack, CP_Surface, Seed,
|
||||||
OriginSpineRadius, StrateManager);
|
OriginSpineRadius, StrateManager,
|
||||||
|
PerBiome, MoveTemp(Field));
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
// UsesOperatorStackForChunk ne rend true que pour les archétypes portés, donc
|
// UsesOperatorStackForChunk ne rend true que pour les archétypes portés, donc
|
||||||
// on ne devrait jamais arriver ici. Si ça arrive, retomber sur le `switch`
|
// on ne devrait jamais arriver ici. Si ça arrive, retomber sur le `switch`
|
||||||
|
|||||||
@@ -576,19 +576,12 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord
|
|||||||
case ECaveGeneratorType::CrystalChamber: return true; // UNE seule pile (BuildSlabStack)
|
case ECaveGeneratorType::CrystalChamber: return true; // UNE seule pile (BuildSlabStack)
|
||||||
|
|
||||||
case ECaveGeneratorType::SurfaceWorld:
|
case ECaveGeneratorType::SurfaceWorld:
|
||||||
// ⚠️ PORTÉ, MAIS PAS AVEC LES BIOMES. `BuildSurfaceStack` évalue UN jeu de params ; le
|
// ✅ La garde « pas de biomes » est TOMBÉE (étape 2c) : le combiner `Mask` existe, donc une
|
||||||
// chemin d'origine évalue le biome dominant puis interpole les HAUTEURS vers le voisin
|
// strate à biomes mélange bien ses hauteurs comme le chemin d'origine. Les trois archétypes
|
||||||
// dans la bande de frontière (le combiner `Mask`, prototype de la Phase 3 — §5). Sans lui,
|
// du dessus plus celui-ci font 5 des 8 portés.
|
||||||
// une strate à biomes perdrait ses transitions : pas un décalage subtil, une couture nette
|
// The no-biome guard is GONE: the Mask combiner exists, so a biome strate blends its heights
|
||||||
// à chaque frontière de biome.
|
// exactly as the original path does.
|
||||||
//
|
return true;
|
||||||
// Donc la garde est ici, dans la MÊME fonction que la liste des archétypes portés, plutôt
|
|
||||||
// que dispersée dans `GetDensityAt` : « cette strate peut-elle prendre la pile ? » reste
|
|
||||||
// une seule question, posée à un seul endroit.
|
|
||||||
// Ported, but NOT with biomes: the stack evaluates ONE param set, while the original blends
|
|
||||||
// heights toward the neighbouring biome. Without the Mask combiner a biome strate would lose
|
|
||||||
// its transitions — a hard seam at every biome border, not a subtle shift.
|
|
||||||
return Def->Biomes.Num() == 0;
|
|
||||||
|
|
||||||
default: return false;
|
default: return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
#include "VoxelDensityOp.h"
|
#include "VoxelDensityOp.h"
|
||||||
#include "VoxelStrateTypes.h" // FMazeGenerationParams
|
#include "VoxelStrateTypes.h" // FMazeGenerationParams
|
||||||
|
#include "VoxelHeightOp.h" // IVoxelBiomeField — BuildSurfaceStack takes ownership of one
|
||||||
|
|
||||||
class UVoxelStrateManager;
|
class UVoxelStrateManager;
|
||||||
|
|
||||||
@@ -200,16 +201,22 @@ namespace VoxelDensityOps
|
|||||||
int32 Seed);
|
int32 Seed);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SurfaceWorld — ⚠️ PAS ENCORE COMPLET, et c'est délibéré. Équivaut exactement à
|
* SurfaceWorld, COMPLET : colonne (sol + voûte) → densité, overhang 3D, post structurel, et le
|
||||||
* `GetSurfaceDensity`, c.-à-d. la version **sans overhang** et **sans mélange de biomes** :
|
* mélange de biomes quand `PerBiomeParams` est non vide.
|
||||||
* • l'overhang a besoin d'une donnée par colonne que `GetSurfaceDensity` ne calcule pas
|
*
|
||||||
* (il passe `OverhangAmp = 0`) — sa référence est le chemin caché ;
|
* @param PerBiomeParams vide ⇒ pas de biomes, chemin d'origine strictement inchangé. Non vide
|
||||||
* • le mélange de biomes est le combiner `Mask`, prototype de la Phase 3 (§5).
|
* ⇒ une pile de hauteur COMPLÈTE par biome, sol mélangé / voûte
|
||||||
* Les deux arrivent à l'étape 2b. Ne pas brancher dans un monde à biomes avant.
|
* sélectionnée, amplitude d'overhang interpolée (§5, combiner `Mask`).
|
||||||
|
* @param BiomeField **transféré** à la pile, qui le possède. Doit répondre pour les mêmes
|
||||||
|
* indices que `PerBiomeParams`. `nullptr` avec des params non vides ⇒
|
||||||
|
* biome 0 partout (dégradation sûre, pas un crash).
|
||||||
*/
|
*/
|
||||||
VOXELFORGE_API void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P,
|
VOXELFORGE_API void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P,
|
||||||
int32 Seed, float SpineRadius,
|
int32 Seed, float SpineRadius,
|
||||||
const UVoxelStrateManager* StrateManager);
|
const UVoxelStrateManager* StrateManager,
|
||||||
|
const TArray<FSurfaceGenerationParams>& PerBiomeParams =
|
||||||
|
TArray<FSurfaceGenerationParams>(),
|
||||||
|
TUniquePtr<IVoxelBiomeField> BiomeField = nullptr);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** :
|
* FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** :
|
||||||
|
|||||||
Reference in New Issue
Block a user