feat: Phase 1 — Maze ported to the operator stack, OFF the hot path
Maze decomposes into seven ops with no contortion:
ConstantRockSource -> LatticeCorridorSource -> SdfRoughnessMod -> SdfCarve
-> OriginSpine -> BoundarySeal -> PassageCarve
That is the answer to Phase 1's actual question (OPSTACK-PLAN section 4's
stop-trigger: "does the source/modifier split fall out naturally?"). It does.
Three of those ops are already shared: ConstantRockSource is the first line of
TunnelNetwork, Maze AND VerticalShafts; SdfCarve is the same six lines in all
three; the structural post is identical across all six density functions.
GetDensityAt and ClassifyTile are NOT touched. The archetype switch is still the
only path feeding the game, so nothing in a running world can change. The port
is validated instead by VoxelForge.OpStack.MazeEquivalence, which compares the
stack against GetMazeDensity over 20k points, re-checks purity across worker
threads, and brute-forces every box verdict the stack emits.
Two contract decisions, delegated and taken:
1. Eval is now two-channel (FVoxelOpSample { Density, Sdf }). Maze forces it:
its roughness perturbs the SDF, not the density, and on density the same
noise scales with the local gradient and is a visibly different effect. It is
also what lets two different sources SmoothMin together later, which is the
difference between a composed idea belonging somewhere and being punched into
it.
2. The stack's density channel is INTERNAL convention (positive = solid),
negated once by the caller. This REVERSES what the header said yesterday.
Every archetype body is already written that way, so each port becomes a
literal transcription instead of a sign-flip of every line -- on the plugin's
documented #1 source of confusion. The SDF channel keeps standard SDF
convention, so min() means opposite things on the two channels; the header
says so loudly.
Also extracts spine/seal/passage from VoxelGenerator.cpp into
Public/VoxelDensityPrimitives.h so the generator and the ops share ONE copy of
three world invariants. Forwarders keep the local names, so not one of the ~20
call sites changes; bodies are byte-identical.
One thing found while writing the seal's ClassifyBox and NOT silently fixed: at
the inner edge of a seal band, 1 - Dist/Thickness can round to exactly 0.0f, so
SealFactor*BaseDensity is 0, internal density lands on 0, and the mesher counts
that as AIR. Claiming AllSolid there would be a hole. The new op keeps a 1-voxel
safety margin before it forces. Today's ClassifyTile has no such margin -- the
window is hairline and needs the archetype to produce air at exactly that z, but
it is real. Reported rather than patched, since Phase 1 does not touch that path.
UNVERIFIED: not compiled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
// VoxelDensityPrimitives.h
|
||||
// Les trois post-traitements STRUCTURELS partagés par chaque archétype.
|
||||
// The three STRUCTURAL post-processes every archetype shares.
|
||||
//
|
||||
// POURQUOI CE FICHIER EXISTE / WHY THIS FILE EXISTS
|
||||
// Ces trois fonctions étaient `static` dans VoxelGenerator.cpp et appelées à l'identique par les six
|
||||
// fonctions de densité. La pile d'opérateurs a besoin des MÊMES, donc elles montent ici : UNE copie,
|
||||
// partagée par le générateur et par les opérateurs. Dupliquer serait garantir qu'elles divergent —
|
||||
// et ce sont des INVARIANTS de monde (la descente doit rester possible, les seals doivent tenir, les
|
||||
// passages doivent percer), pas des choix créatifs.
|
||||
//
|
||||
// They were `static` in VoxelGenerator.cpp and called identically by all six density functions. The
|
||||
// operator stack needs the same ones, so they move here: ONE copy, shared. Duplicating would
|
||||
// guarantee divergence, and these are world INVARIANTS, not creative choices.
|
||||
//
|
||||
// ⚠️ CONVENTION DE SIGNE — la source n°1 de confusion du plugin.
|
||||
// Ces trois fonctions travaillent en convention INTERNE : **positif = SOLIDE, négatif = AIR**.
|
||||
// C'est la convention dans laquelle chaque fonction d'archétype est écrite ; la négation vers la
|
||||
// convention marching-cubes (négatif = solide) se fait UNE FOIS, sur le `return`.
|
||||
// SIGN CONVENTION: these work in INTERNAL convention — **positive = SOLID**. The negate to MC
|
||||
// convention happens ONCE, at the caller's return.
|
||||
//
|
||||
// Aucun changement de comportement en les déplaçant : corps identiques, FORCEINLINE au lieu de
|
||||
// static, mêmes appelants. / No behavioural change: identical bodies, FORCEINLINE instead of static.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "VoxelTypes.h" // SmoothStep01
|
||||
|
||||
//=============================================================================
|
||||
// SEAL DE FRONTIÈRE / BOUNDARY SEAL
|
||||
//=============================================================================
|
||||
// Seal solide aux bords haut et bas de la strate. Fade smoothstep sur `Thickness` voxels depuis
|
||||
// chaque bord. N'AJOUTE que de la densité (FMath::Max), jamais n'en enlève → le joueur ne peut
|
||||
// jamais percer le seal "par accident", seulement via les passages.
|
||||
//
|
||||
// ⚠️ C'est un opérateur FORÇANT, pas seulement un FillOnly : à l'intérieur de la bande, avec
|
||||
// SealFactor > 0 et BaseDensity > 0, le résultat est solide GARANTI quelle qu'ait été l'entrée.
|
||||
// C'est exactement ce qu'encode `IVoxelDensityOp::ClassifyBox` (voir VoxelDensityOp.h), et la
|
||||
// raison pour laquelle cette méthode existe.
|
||||
FORCEINLINE void VF_ApplyBoundarySeal(float& Density, float WorldZ,
|
||||
float StrateTopZ, float StrateBottomZ,
|
||||
float Thickness, float BaseDensity)
|
||||
{
|
||||
if (Thickness <= 0.0f) return;
|
||||
|
||||
const float DistTop = StrateTopZ - WorldZ; // + si on est sous le plafond
|
||||
const float DistBot = WorldZ - StrateBottomZ; // + si on est au-dessus du sol
|
||||
|
||||
if (DistTop >= 0.0f && DistTop < Thickness)
|
||||
{
|
||||
float SealFactor = 1.0f - (DistTop / Thickness);
|
||||
SealFactor = SmoothStep01(SealFactor);
|
||||
Density = FMath::Max(Density, SealFactor * BaseDensity);
|
||||
}
|
||||
if (DistBot >= 0.0f && DistBot < Thickness)
|
||||
{
|
||||
float SealFactor = 1.0f - (DistBot / Thickness);
|
||||
SealFactor = SmoothStep01(SealFactor);
|
||||
Density = FMath::Max(Density, SealFactor * BaseDensity);
|
||||
}
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// CARVE DE PASSAGE / PASSAGE CARVING
|
||||
//=============================================================================
|
||||
// Creuse un passage inter-strates. Évalué APRÈS le seal pour que les passages puissent percer à
|
||||
// travers le bouchon solide. Le rayon de blend hard-codé à 4.0f correspond à l'ancienne valeur —
|
||||
// à exposer via UVoxelSettings si on veut pouvoir le tweaker.
|
||||
FORCEINLINE void VF_ApplyPassageCarving(float& Density, float ModSDF,
|
||||
float BaseDensity, float SealThickness)
|
||||
{
|
||||
constexpr float PASSAGE_BLEND_RADIUS = 4.0f;
|
||||
if (ModSDF >= PASSAGE_BLEND_RADIUS) return;
|
||||
|
||||
float CarveFactor = FMath::Clamp(
|
||||
(PASSAGE_BLEND_RADIUS - ModSDF) / (PASSAGE_BLEND_RADIUS * 2.0f),
|
||||
0.0f, 1.0f);
|
||||
CarveFactor = SmoothStep01(CarveFactor);
|
||||
|
||||
// FORCE the density toward guaranteed AIR so the passage punches through ANYTHING in
|
||||
// its path (seals, columns, surface roughness, terrain ops). A plain subtraction can
|
||||
// be out-paced by stacked density additions, leaving solid plugs mid-tunnel — which is
|
||||
// why the shaft "didn't go all the way through". Lerp toward a strongly negative target
|
||||
// and take the min so we only ever make it MORE air (never refill an existing cave).
|
||||
const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f);
|
||||
Density = FMath::Min(Density, FMath::Lerp(Density, AirTarget, CarveFactor));
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// SPINE DE DESCENTE (0,0) / (0,0) DESCENT SPINE
|
||||
//=============================================================================
|
||||
// Creuse une colonne verticale garantie ouverte au XY monde (0,0) dans l'INTÉRIEUR de la strate
|
||||
// (entre les seals haut et bas). Les seals sont laissés intacts pour que le joueur doive encore
|
||||
// creuser à travers pour descendre — ceci ne fait qu'un espace d'atterrissage propre, indépendant
|
||||
// de l'archétype, aligné à travers toutes les strates.
|
||||
FORCEINLINE void VF_ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ,
|
||||
float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius)
|
||||
{
|
||||
if (Radius <= 0.0f) return;
|
||||
|
||||
// Stay within the interior — never touch the seal bands.
|
||||
const float InnerTop = StrateTopZ - SealThickness;
|
||||
const float InnerBot = StrateBottomZ + SealThickness;
|
||||
if (WorldZ <= InnerBot || WorldZ >= InnerTop) return;
|
||||
|
||||
const float DistXY = FMath::Sqrt(WorldX * WorldX + WorldY * WorldY);
|
||||
const float SDF = DistXY - Radius; // < 0 inside the column
|
||||
const float Blend = 3.0f;
|
||||
if (SDF < Blend)
|
||||
{
|
||||
float Carve = FMath::Clamp((Blend - SDF) / (Blend * 2.0f), 0.0f, 1.0f);
|
||||
Carve = SmoothStep01(Carve);
|
||||
Density -= Carve * (BaseDensity * 2.0f + SealThickness);
|
||||
}
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// PORTÉES / REACHES — les rayons dont ClassifyTile et EffectOverBox ont besoin
|
||||
//=============================================================================
|
||||
// Les constantes de blend ci-dessus (3.0 pour la spine, 4.0 pour les passages) sont dupliquées à la
|
||||
// main dans ClassifyTile aujourd'hui. Les nommer ici pour qu'un futur test de bornes ne puisse pas
|
||||
// les désynchroniser. / The blend constants above are hand-duplicated inside ClassifyTile today.
|
||||
// Naming them here so a future bounds test cannot let the two drift apart.
|
||||
namespace VoxelDensityReach
|
||||
{
|
||||
constexpr float SpineBlend = 3.0f;
|
||||
constexpr float PassageBlend = 4.0f;
|
||||
}
|
||||
Reference in New Issue
Block a user