fix(world): the voxel field is in ACTOR space -- make every entry point agree

The city's hole is not at world (0,0), so the AVoxelWorld actor has to move.
It couldn't: the plugin was only HALF actor-relative, and had been for a long
time without symptoms because the actor always sat at the origin.

Already correct: GetBiomeAtWorldLocation, GetVoxelSurfaceHeightAt,
UpdateDecorations, UpdateLandmarks, UpdateWater, UVoxelDensityVolume::Update,
the passage debug draw, the clipmap material.

Not correct: the streaming centre, the streaming anchors, all six carve/fill
entry points, GetStrateAtPosition, and UVoxelAtmosphereManager::UpdateForPlayer
-- whose own comment documented the assumption out loud ("actor at
origin/identity"). Carving would have landed offset; the strate lookup would
have been quietly wrong by the actor's Z.

The trap worth recording: because the content managers already convert
internally, converting PlayerLastPos once at the source would have DOUBLE-
converted four call sites. The fix has to be per-boundary. That was only
visible after reading each consumer -- the estimate before that read was wrong
in both directions.

WorldToLocalCm / WorldToLocalVoxel / LocalVoxelToWorld are now declared as the
only sanctioned boundary between Unreal coordinates and voxel coordinates, and
the real deliverable is the invariant next to them: no `/ VOXEL_SIZE` applied
to a parameter named `World*` outside those three functions. A grep can check
that. The risk was never a missed site among the ones found -- it is the next
one nobody notices.

Also adds a translation-only guard in BeginPlay. Tiles, ClassifyBox verdicts,
the clipmap window, culling and streaming distance all assume world-axis-
aligned boxes; a rotation or non-unit scale breaks that structurally rather
than gracefully, so it logs an Error naming what was set.

With the actor at the origin every conversion is identity, so this is a no-op
until the actor actually moves. Built and confirmed working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-08-23 17:47:23 +02:00
1 parent dfa1d5f5b1
commit 302cf16681
5 files changed
+608 -21

No files matched your search

+20
View File
@@ -213,6 +213,19 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
`ChunkLODs`, `ProcessQueue` (TQueue), `PendingChunkCoord` (TSet) (VoxelWorld.h:85-312). `ChunkLODs`, `ProcessQueue` (TQueue), `PendingChunkCoord` (TSet) (VoxelWorld.h:85-312).
**Async state:** `bShuttingDown`, `ActiveTaskCount` (atomics), `GenerationEpoch`. **Async state:** `bShuttingDown`, `ActiveTaskCount` (atomics), `GenerationEpoch`.
**⭐ Espace ACTEUR / ACTOR SPACE (2026-08-17).** Le champ de densite est defini en espace **acteur** :
`(0,0)` est l origine de l acteur `AVoxelWorld`, pas celle du monde Unreal. `WorldToLocalCm` /
`WorldToLocalVoxel` / `LocalVoxelToWorld` sont la **SEULE frontiere autorisee** entre coordonnees
Unreal et coordonnees voxel. **Invariant verifiable par grep : aucun `/ VOXEL_SIZE` applique a un
parametre nomme `World*` hors de ces trois fonctions.** Avant cette date le plugin etait *a moitie*
actor-relative (biome/deco/eau/volume/debug convertissaient ; le **centre de streaming**, les **ancres**,
les **6 entrees carve/fill**, `GetStrateAtPosition` et `UVoxelAtmosphereManager::UpdateForPlayer` ne le
faisaient pas) — ce qui ne marchait que tant que l acteur restait a l origine.
⚠️ **TRANSLATION SEULEMENT** : tuiles, `ClassifyBox`, fenetre clipmap, culling et distance de streaming
supposent tous des boites **alignees sur les axes du MONDE**. Une rotation ou une echelle non unitaire
casse cette arithmetique *structurellement* — `BeginPlay` loggue une **Error** si on en met une.
| Method | .cpp line | Role | | Method | .cpp line | Role |
|--------|-----------|------| |--------|-----------|------|
| `AVoxelWorld()` ctor | 12 | Enables Tick. | | `AVoxelWorld()` ctor | 12 | Enables Tick. |
@@ -439,6 +452,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. **Fixed 2026-07-29:** its `EffectOverBox` used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now rebuilds the connectors the way `GetCells` does (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed; sweeping `[box cells] ± 1` is a superset of any 3×3's pairs) and tests the real capsule, with **Z exact** (horizontal capsule at `Zc`) and XY conservative. The sampler was also widened from ±48 voxels to ±440 — it was under one `ShaftSpacing`, the same trap as the tunnel test's ±32-vs-80. Every proved tile is brute-forced over its full lattice. | | `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. **Fixed 2026-07-29:** its `EffectOverBox` used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now rebuilds the connectors the way `GetCells` does (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed; sweeping `[box cells] ± 1` is a superset of any 3×3's pairs) and tests the real capsule, with **Z exact** (horizontal capsule at `Zc`) and XY conservative. The sampler was also widened from ±48 voxels to ±440 — it was under one `ShaftSpacing`, the same trap as the tunnel test's ±32-vs-80. Every proved tile is brute-forced over its full lattice. |
| `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. | | `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. |
| `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. | | `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. |
| `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). |
## 4. The density pipeline (most-edited hot path) ## 4. The density pipeline (most-edited hot path)
@@ -510,6 +524,12 @@ Stage order (negative=solid throughout). Each stage's anchor:
different layers — trust the MC convention at the mesher. different layers — trust the MC convention at the mesher.
- **Coordinate units:** density functions take **voxel coords** (not cm). World↔voxel - **Coordinate units:** density functions take **voxel coords** (not cm). World↔voxel
conversions live in `VoxelTypes.h`. Mesher converts before calling the generator. conversions live in `VoxelTypes.h`. Mesher converts before calling the generator.
- **Espace ACTEUR / actor space:** le champ de densite est en espace **acteur** — `(0,0)` = origine de
l acteur `AVoxelWorld`, pas du monde Unreal. Tout point venant d Unreal (pion, trace, ancre) passe par
`AVoxelWorld::WorldToLocalCm` / `WorldToLocalVoxel` / `LocalVoxelToWorld`. **Regle grep : aucun
`/ VOXEL_SIZE` sur un parametre `World*` hors de ces trois fonctions.** ⚠️ **Translation seulement** —
rotation/echelle cassent les boites alignees sur les axes (tuiles, `ClassifyBox`, clipmap, culling) ;
`BeginPlay` loggue une Error. The density field is in ACTOR space; translation only.
- **Determinism:** all randomness is hash-of-(coord, seed, strateIndex) — no RNG state. - **Determinism:** all randomness is hash-of-(coord, seed, strateIndex) — no RNG state.
Same seed ⇒ identical world. Player edits are the only non-deterministic overlay. Same seed ⇒ identical world. Player edits are the only non-deterministic overlay.
- **Async safety:** background tasks must check `bShuttingDown` and only touch the - **Async safety:** background tasks must check `bShuttingDown` and only touch the
+475
View File
@@ -759,3 +759,478 @@ has to be defined — only **collected**:
blending near the corpus, and the system slowly stops surprising you. Same failure mode as the blending near the corpus, and the system slowly stops surprising you. Same failure mode as the
build-vote pool, same counter: keep extrapolating past the corpus, and keep some unrated weird build-vote pool, same counter: keep extrapolating past the corpus, and keep some unrated weird
candidates in circulation. candidates in circulation.
---
## 2026-08-17 — STACK ASSEMBLY: how the composer builds an op stack
### What the three real builders actually look like (read from `VoxelDensityOpStack.cpp`)
```
TunnelNetwork (19) rock source → room graph (SDF) → SdfCarve → 12 density modifiers → worm source → structural
Maze (7) rock source → lattice corridors (SDF) → SdfRoughness (SDF) → SdfCarve → structural
FloatingIslands (7) VOID source → island blobs (SDF) → SdfRoughness (SDF) → SdfFill → structural
```
### ⭐ 1. Most ordering is MECHANICAL, not aesthetic — derive it, don't author it
Roughness sits on the **SDF** before conversion in Maze, and on **density** after conversion in
TunnelNetwork — CODEMAP confirms they are literally two different ops for that reason. An op's legal
position is fixed by **which channel it reads and which it writes**.
⇒ **Every operator declares its channel reads/writes.** Dependencies then form a DAG and **any
topological sort of that DAG is a legal stack.** No authored order, no judgement, and it generates
orderings no human ever wrote down.
### ⭐ 2. Root polarity is a one-bit identity lever
FloatingIslands is the same op classes as Maze but rooted in a **void** source with a **fill** instead
of a rock source with a carve — and you get islands instead of tunnels. One bit flips the entire
character of a strate.
### 3. `FFloorBiasMod` — delete the problem instead of documenting it
FloorBias exists purely to undo what `FCaveRoughnessMod` did to floors. Both are density-space, so
channel rules permit either order, but semantically one must follow the other.
⇒ **An operator whose only purpose is to correct another operator is not a separate operator. FUSE
them** into one op with two internal phases. Removes the invalid state rather than annotating it, and
leaves the rest genuinely free to shuffle. *Fifteen ops that compose in any legal order beat eighteen
with a footnote.* (Fusing with identical order and math leaves the world bit-identical.)
### 4. One small declaration covers what's left
Each op is **additive** (small displacement, commutes freely with peers ⇒ shuffle at will) or
**transformative** (clamps, multiplies, gates ⇒ position matters, needs explicit placement).
### 5. The structure roll
`root polarity → shape source → conversion (follows from polarity) → draw k modifiers legal in the
resulting channel space (k itself rolled) → structural post appended automatically.`
With ~5 shape sources and ~15 modifiers choosing 4–8, that is **tens of thousands of structurally
distinct stacks before a single parameter is touched** — which is what carries early variety while the
corpus is small.
### ⚠️ 6. THE BLOCKER — box verdicts are only correct *in the stack they ship in*
CODEMAP, on the lattice corridor source: *"Its `EffectOverBox` answers for the source+carve **pair**
(Phase 1 simplification) so it must be told the downstream `ExtraReach`."* The op interface says the
same in its own comment: a source that writes only `Sdf` doesn't touch density itself, so it answers
on behalf of the converter that follows it.
That is fine when a human wrote the pair. It **breaks the moment the composer assembles a combination
nobody verified**, and the failure is the silent one: a tile wrongly proved uniform ⇒ no geometry, no
collision, no error, player falls through the floor.
⇒ **Prerequisite before free composition is safe: every operator's `EffectOverBox` must be correct in
ISOLATION**, not correct-given-its-neighbours. That means propagating an **SDF interval** through the
box query instead of letting the source answer for the pair — which the interface comment already
names as the Phase 3 version of the contract. Not huge, but load-bearing, and it must land first.
### ✏️ Correction to yesterday's note
The X-macro / struct diff said *"the only struct scalar missing from the macro is `Alpha`, which is the
blend weight and correctly excluded."* **`Alpha` is not a field at all** — it is the `float Alpha`
parameter of `FStrateGenerationParams::Lerp` (line 1033), which the regex caught by accident.
**The macro and the struct are in PERFECT sync, zero exceptions.** Now guarded by a test — see below.
---
## 2026-08-17 — the X-macro guard (built, green)
`Source/VoxelForge/Private/Tests/VoxelForgeStrateParamCoverageTest.cpp` →
**`VoxelForge.Determinism.StrateParamBlendCoverage`**. Built clean first try (Jahni, 2026-08-17).
Expands `VF_STRATE_PARAM_FIELDS` a **third** way (after LERP and SNAP) into a name list and diffs it
against `FStrateGenerationParams`' UObject reflection. No fixture, no world — a shape test.
`GExemptFieldNames` is **empty**: every reflected field is covered, and an exemption must be written
down as a decision. Matters more under the composer, which invents parameter sets through that same
`Lerp` — a missing field would be **constant across every invented strate** with nothing to notice it
by. CODEMAP §3.12 row added.
---
## 2026-08-17 — ⚠️ THE PRIMORDIAL LAW HAS A REAL HOLE (verified in code)
### What passages do today
`UVoxelStrateManager::GeneratePassages` places every inter-strate passage as:
`FRandomStream Rng(CachedSeed ^ 0x50A55A6E)` → **random angle** around the (0,0) spine → **random
distance** in the config range → **random reach** into each strate. Endpoints are `(PX, PY, TopZ)`
and `(PX, PY, BottomZ)`. Carving is a structural-post invariant (`VF_ApplyPassageCarving`), so the
tube itself IS air by construction.
### ⚠️ The hole
**Nothing consults the destination strate's actual cave layout.** The tube is air, but whether its
lower mouth *joins the lower strate's connected space* is pure luck. **A player can descend a passage
and arrive in a sealed bubble** — legal geometry, air all the way, and nowhere to go. The primordial
law is currently a hope, not a guarantee. It gets worse under invented strates, where nobody has
eyeballed the layout.
Also: that `FRandomStream` walks **sequentially across all strates**, so changing an early strate's
connection count shifts **every later passage**. Same order-dependence as the strate pool
⇒ use `hash(seed, strateIndex, connIndex)` instead.
### ⭐ Fix 1 — don't let the passage hope; make the strate OFFER a landing site
Add to the field-source contract something like **`SuggestOpenPoint(WorldX, WorldY) → optional Z`**.
Every source already knows where its own air is:
| source | open point |
|---|---|
| `FRoomGraphSource` | nearest room centre (rooms are hash-placed ⇒ cheap to find) |
| `MakeSlabVoidSource` | anywhere in the void band |
| `FIslandBlobSource` | just above an island top |
| `FSurfaceColumnSource` | just above `TerrainZ` |
| `MakeLatticeCorridorSource` | the nearest lattice node |
Passage placement becomes: pick XY → **ask the lower strate's source for an open Z near it** → aim
there. Deterministic, cheap, and it keeps working for **invented** strates because any new source must
answer. Nice inversion: the strate offers the landing site instead of the passage gambling.
### Fix 2 — verification as the net (reuses the measurement pass)
The measurement pass already flood-fills the air. So: **does the upper mouth's air component reach the
lower mouth's?** If not, re-roll the passage (`hash(..., attempt)`). Coarse to find, full resolution to
confirm the one corridor — as established for the connectivity metric.
### Fix 3 — FINDABILITY, and most of it is data, not code
✅ **`ELandmarkAnchor::PassageMouth` already exists** — landmarks can anchor at passage mouths, and
`FStrateLandmark` already carries a **Light-Orb block**. A glowing landmark at every passage mouth is
authorable **today, with no code at all.**
But *findable* means being drawn there from a distance, not marked once you arrive. Three layers,
cheapest first:
- **Light.** A glow down a tunnel is the strongest pull in a cave. Already supported.
- **Sound.** An emitter with a long attenuation radius at the mouth. Works **around corners**, which
light does not — in a cave you hear the draft before you see the hole. (Ties to F9 audio.)
- ⭐ **Make the mouth a PLACE, not a hole.** Fix 1 already lands the passage *in a room*; make that
room distinctive — bigger, lit, decorated. Places are memorable, cracks in walls are not. This is
the one that actually makes it findable on the second visit, which is what quests need.
- *Parked (strongest, most invasive):* bias the cave network to converge toward the mouth, so simply
following tunnels tends to lead there.
---
## 2026-08-17 — RULING + BUILD ORDER
**Jahni's ruling:** guaranteeing an open landing place is **needed**. And the passage mouth should be
**a real landmark — a special place**, so it *shows* and is *less dismissed*. Not merely a lit marker.
Also: *"not all to be implemented in a row."*
### The sequencing principle
⭐ **Every tier must be worth doing on its own, before the next one exists.** Nothing gets built purely
as machinery for a later tier. (This is the direct antidote to the 2026-07/08 refactor, where three
weeks of correct work produced a world unchanged by a single voxel.)
### Tier 0 — safety nets, true regardless of the composer
- ✅ **X-macro guard test** — DONE, built green 2026-08-17.
- **Order-independent placement:** replace the sequential `FRandomStream` in `Initialize` and
`GeneratePassages` with `hash(seed, slotIndex[, connIndex, attempt])`. *Standalone value:* Jahni can
reorder or tidy the strate pool asset **without changing every world**. Small, isolated.
### Tier 1 — the primordial law (a BUG FIX, not composer work)
- **`SuggestOpenPoint(X, Y) → optional Z`** on field sources; aim passages at it.
- **Passage mouth becomes a room + a distinctive landmark** (per the ruling).
*Standalone value:* fixes a **live hole in the shipping game** — today a player can descend into a
sealed pocket. Worth doing **even if the composer never happens**, and it makes the current world
better immediately.
### Tier 2 — the measurement pass
Coarse sample + one flood fill + the feature vector (air fraction, largest component, walkable surface,
feature scale, clearance, reachability, fall exposure, openness, traversal mix, tortuosity).
*Standalone value:* it **is** F1, `fable-idea.md`'s top-pick world-preview tool — Jahni can finally see
what parameters do instead of tuning blind. It also verifies Tier 1 (does the mouth actually connect?).
### Tier 3 — op-system prerequisites for free composition
- **Channel read/write declarations** on every op ⇒ ordering derives from a DAG.
- **Fuse corrective ops** (`FFloorBiasMod` into `FCaveRoughnessMod`) — an op that only repairs another
op is not an op.
- ⚠️ **`EffectOverBox` correct in ISOLATION** (SDF interval propagation instead of "the source answers
for the pair"). **This is the gate on free composition** — without it, a novel stack can wrongly
prove a tile uniform ⇒ no geometry, no collision, no error.
*Standalone value:* the fuse and the isolation fix are both correctness improvements to the code that
ships today.
### Tier 4 — the composer proper
Structure roll (root polarity → shape source → k modifiers) · parameter roll (blend the corpus via
`Lerp`) · reject-and-resample driven by Tier 2 · the **offline season pipeline** with Jahni's review
and veto.
### Tier 5 — the long game
**Promotion** (good strates rejoin the corpus) · theme/tag draws for materials, creatures, audio ·
eventually the model. All of it optional, all of it compounding.
---
## 2026-08-17 — WORLD SHAPE + TRAVERSAL (the two holes Codex found, now closed)
Codex read `COMPOSER-NOTES.md` cold, in isolation, and correctly identified the project, the goal
(the **corrected** one — the system invents strates, Jahni supplies vocabulary), and the intended
feel. It found all three marked reversals and no unmarked one. Two of its five "could not determine"
items were real holes; Jahni closed them:
### Decided
- ⭐ **The world has NO BOTTOM.** Endless descent.
- **Boss strates are INTERMEDIARY** — roughly every 5 strates. Bosses punctuate, they do not terminate.
- **Return trip, two ways:** walk back up through the tunnels (that is content, not a chore), **or**
once a strate has been visited it opens a passage onward, so transport (elevator/other) can carry you
down, **choosing which strate to travel to**.
- **Unlock is LOCAL (per player) for now.** Base scope = a **local server** fed by **Jahni's database**
for community information. **Global/world-level unlock is a possible future** — gated on strates
being large enough that finding the next passage is not "a 10 minute endeavour of searching".
### Consequences
- ⚠️ **No bottom breaks "generate the whole world offline at season start."** Fix: pre-generate a
**deep buffer** (~30 strates) offline; extend in another offline batch if players approach its end.
A season probably never exhausts it, and the **review/veto step survives intact**.
- ⚠️ **Danger needs a ceiling.** `danger = f(depth)` with no bottom grows forever ⇒ everything eventually
unsurvivable. It must plateau or approach an asymptote. Open question that shapes the whole curve:
**does strate 40 differ from strate 30 in danger, or only in what KIND of place it is?**
- **"Every 5th slot is a boss" needs a PERIODIC fixed-slot rule.** `Settings->FixedStrates` is a
`TMap<int32, …>` of absolute indices today — the composer needs `slot % N` as well.
- The boss is therefore a **GATE**, which is where the transport network naturally hangs (the route past
strate 10 opens when the boss at 10 falls). Gives the descent a rhythm: five strates, then a wall.
- **"Select which strate to travel to" is a menu — and that menu is the WORLD MANIFEST** (names, danger,
discovered state). Third time the manifest has paid for itself.
- ⭐ **The global-vs-local call is MEASURABLE, not a guess.** The measurement pass already computes
entrance→exit path length and tortuosity; a live season gives the real number by telemetry. Decide it
from data later. (Same principle as everything else here: measure, don't declare.)
### ⚠️ Architecture consequence — the community layer must be OPTIONAL AT RUNTIME
Local server + external database means a DB outage, a network hiccup or maintenance must **degrade**,
never break. The world is generated from the seed and needs no DB at all. So: **procedural names are
the fallback** when community names cannot be fetched (the deterministic name always exists by
construction), shared builds simply do not appear, votes queue locally and sync later. Cheap now,
genuinely painful to retrofit once a hundred call sites assume the fetch succeeded.
### ⚠️ Clarification — elevator unlocks DIE WITH THE WIPE
The wipe is world-only and "everything else survives", **but the unlock network is world-scoped**: it
references this season's strates, and next season strate 7 is a different place. Carrying it forward
would also skip the new season's frontier, which is the point of wiping. **Named here before a player
discovers it the hard way.**
---
## 2026-08-17 — DEPTH ECONOMY + material placement (and the manifest is confirmed a NEED)
### Decided
- **Gear stats have a CEILING (~depth 30).** Past it, loot has the *same* stats but better natural
**rolls** (Trove-style stat-quality %) ⇒ **less material to level up**. Depth buys **less grind and
unique access, never bigger numbers.**
- **Past the ceiling, going deeper is about pride, challenge, and seeing new things.** Confirmed
intent, not an accident.
- **Trading will exist** — lightly at first, better once the surface becomes a social hub.
- ⭐ **Depth-locked materials are tagged by TIER, not by strate** — e.g. a `post difficulty content`
tag — **so placement stays random.**
- ✅ **The world manifest is confirmed a NEED**, not a proposal.
### Why the tier tag is better than a fixed depth (Jahni's call, and it is the stronger one)
A fixed depth is **solved once and stays solved forever**. A random placement inside a difficulty tier
must be **found again every wipe**, and in a one-seed shared world the answer spreads. *"Where is X this
season?"* becomes a renewable community question — exactly what the shared-knowledge design wants, at
zero extra cost.
### Consequences
- **Guarantee what recipes depend on; leave the rest to chance.** One flag on the material: *essential*
⇒ the composer must place it somewhere in the generated buffer; *optional* ⇒ the dice decide, and a
season lacking it gets an identity ("the one with no X"). Essential-but-absent is a dead end nobody
can fix.
- ⭐ **MANIFEST ≠ PLAYER KNOWLEDGE.** The manifest holds ground truth (the composer placed it, and
quests/validation/fast-travel need it). **Discovery state is a separate overlay**, per player. Easy
now; awkward to unpick if the client is handed the whole manifest up front.
- ⚠️ **You cannot hide the manifest anyway** — the client generates terrain from the seed, so players
hold the generator and can enumerate it offline. Not a leak to prevent; a fact to design around, as
Minecraft does with seed maps. And it barely matters here: community knowledge spreading **is** the
intent, datamining is only a faster route to it, and **the wipe is what makes it not matter** — a map
of season 4 is worthless in season 5.
- ⚠️ **The danger growth rate past the gear ceiling IS the endgame difficulty curve.** Below 30 gear and
danger climb together; above it gear is capped and danger is not, so skill/strategy/consumables are
the only levers left. That single number decides where the wall sits. Everything else is content;
that is tuning.
- **Reward converges while danger does not** — a roll-quality % asymptotes at 100. Deliberate: the deep
game is sustained by scarcity and pride, not progression.
- **Trading is a community-layer feature** ⇒ it must degrade gracefully when the database is
unreachable, like shared builds and community names.
---
## 2026-08-17 — THE WORLD MANIFEST: design
### ⭐ It is TWO things, not one
**Tier A — the SPINE. Finite, tiny, actually stored.** Strate slots + passages. ~30 strates plus a few
passages each ⇒ ~100 entries, kilobytes. **This already exists** as `StrateLayout` + the passage list,
computed once in `UVoxelStrateManager::Initialize`. It is the world's skeleton.
**Tier B — the FIELD. Infinite in XY, therefore NEVER stored.** Landmarks, rooms, material deposits,
build sites. The world does not end sideways, so this cannot be a table — it is a **function**: give it
a region, it enumerates what is there. Deterministic, hash-based, **zero bytes**.
⇒ That split is what makes "must not be expensive" true rather than aspirational.
### ⭐ The trick: THE ID ENCODES THE POSITION
The hard requirement is resolving an ID **back** to a place (a saved quest target, a fast-travel
destination, a wiki citation) — normally a lookup table, which is exactly what we cannot have.
Landmarks are placed on a **hash lattice**, so build the ID from **lattice cell + type + slot index
within the cell**. Then **ID → position is ARITHMETIC**: no table, no database, no storage. A quest
stores `strate 4, cell (−128, 73), type Lake, index 2` and resolves it instantly, forever, on any
machine, offline.
**Names fall out of the same thing:** `hash(id, seed)` → word pool. Every entry in an infinite world
has a stable, globally identical name that nobody stored and nobody generated in advance — including
entries no player will ever visit. (This is the substrate that makes quests and wikis possible; see the
two-layer naming note, community names override on top.)
### What earns an entry
**One test: can a system need to ask "where is it?" or "what is it called?"** If yes, it is an entry.
✅ strates · passage mouths · landmarks · notable rooms · material deposits · build sites · boss arenas ·
the (0,0) spine. ❌ individual rocks · every tunnel segment · terrain that merely emerged from noise.
**Underneath that — ADDRESSABLE vs NOTABLE.** Everything hash-placed is *addressable* for free (it has
an ID by construction). **Notability is a filter on top, and it is exactly what the measurement pass
produces**: biggest chamber in a region, the one with a lake, the one at a passage mouth. "Notable
rooms" needs no new placement logic, only a threshold.
### Discovery sits ON TOP, never inside
A player's discovered set is a set of **IDs** — small integers, cheap to store, cheap to sync. Community
discovery is the union. **The manifest never changes; only who has seen what.** (Confirms MANIFEST ≠
PLAYER KNOWLEDGE from the depth-economy note.)
### ⚠️ Two cautions
- **The hash functions become part of the FROZEN SEASON CONTRACT.** Change how a lattice cell hashes and
every quest target in that season moves. Fine — worlds are frozen within a season — but these
functions are **not implementation details to tidy mid-season**.
- **Tier B is cheap, not FREE.** Enumerating rooms in a region needs that region's room-placement pass.
Fine for a beacon or nearby markers; a full-continent map view would hurt. ⇒ **LOD the query**: coarse
regions return only high-notability entries — which is what a zoomed-out map wants anyway.
---
## 2026-08-17 — NEAR-INFINITE STRATES: three consequences
Jahni: some strates (surface-like especially) are **near-infinite in XY** — storage is impossible.
Confirms the Tier A / Tier B split. But it also quietly weakens three things already decided:
### ⭐ 1. The (0,0) SPINE is what makes an infinite world navigable — state it as a law
In a bounded cave you eventually stumble onto the way down. In an infinite surface strate you never
will — the exit is a point in an endless plane. But `GeneratePassages` already places passages at a
random **angle + distance from the origin**, within a configured range, so **passages cluster near the
spine by construction.**
⇒ **The descent is anchored to the spine; infinity is OPTIONAL content.** A lost player walks back to
the spine and finds the way down. Everything outward is exploration you choose.
⚠️ This is currently an **accident of how the code happens to work**, not a stated invariant. It should
be written down as a law alongside the entrance/exit one.
### 2. The measurement pass must sample WHERE PLAYERS GO
An infinite strate cannot be measured, only sampled — and a box a million units out measures a place
nobody will ever stand in. ⇒ **validate the neighbourhood of the spine and the passage mouths**, the
load-bearing part. A strate may be beautiful at the origin and mush ten km out; that is genuinely fine.
Also: report measurements as **distributions (median + spread), not single values** — regions really
will differ, especially once biomes vary the surface. A mean pretending to describe infinity is a lie.
### ⚠️ 3. CORRECTION — essential materials need a DENSITY, not a location
Earlier note said the composer must place an essential material "somewhere in the buffer". **In an
infinite strate "somewhere" is unfindable** — it could be a thousand km out. The guarantee must be a
**placement density within the reachable band**: near enough to the spine, common enough to actually
meet. Otherwise a season ships a recipe nobody can complete and it reads as a bug, not as scarcity.
Conveniently this is measurable: *expected number of deposits within N of the spine* is a number the
composer can check.
---
## 2026-08-17 — the CITY is a plain UE level (no seam problem after all)
**Correction to my "authored city meets generated surface" worry:** the hand-authored city is a
**normal Unreal level**, not voxel terrain. VoxelForge sits **under the hole in the city**, or
possibly **instantiated in a whole separate level** to be free of the city's constraints.
⇒ **There is no authored/generated seam.** They are not the same kind of thing.
### Open / to confirm
- ❓ **Is the voxel world therefore entirely SUBTERRANEAN?** If the real outdoors is the city level,
then `SurfaceWorld` is not a surface — it is a cathedral-sized underground space that *reads* as
outdoors (consistent with the existing design where surface/sky strates are enclosed, lit from
within by skylight + gem-star ceiling, never by a sun). **Asked, not assumed.**
- ⚠️ **Separate level vs sublevel is a real tradeoff, worth settling before both exist.** A distinct map
gives the voxel world its own world settings, lighting, kill-Z and streaming, free of the city's —
genuinely valuable. **The cost is that the descent becomes a LOAD rather than a walk.** For a game
whose core verb is descending, a seamless transition through the hole would feel far better, and UE
can do it with both as sublevels of one world — but then the voxel world inherits the city's world
settings, which is exactly the constraint being escaped. *"Free of constraints"* and *"you can walk
down into it"* are in tension.
- **Infinite vs bounded world is UNDECIDED.** Jahni: *"I might not make infinite world also, but for now
I might, so I prefer saying it."*
⇒ **Keep designing for INFINITE**: it degrades gracefully to bounded, while a bounded-assuming design
(stored manifest, complete map, exhaustive validation) breaks the moment the world is not.
⇒ But note bounded is a **lever, not merely a limit** — it would buy complete measurement instead of
sampling, a real map, and much easier findability. A live option to pull deliberately one day.
---
## 2026-08-17 — BOUNDED world, SUBTERRANEAN confirmed, and the descent shaft
### Decided
- ⭐ **The world is BOUNDED.** Still gigantic maps, but finite.
- ✅ **The voxel world is entirely SUBTERRANEAN** — everything is under the surface; some strates only
*mimic* being surface-like. (The real outdoors is the city's UE level.)
- **Transition:** likely a **master level** holding shared elements (lighting etc.), switching when the
player enters the hole, with a transition playing. **The elevator can be as long as needed** — but
Jahni intends to place the **first strate well below** so travel time covers generation naturally.
### What BOUNDED buys
- ⭐ **The primordial law becomes PROVABLE, not sampled.** The composer can flood-fill an *entire*
strate at coarse resolution, so entrance→exit reachability is a **proof**, not a probability. Same
for largest-connected-component and walkable surface — true statistics, not estimates from boxes.
For the one law Jahni called primordial, that is the difference between a check and a guarantee.
- **Budget note:** 8 km × 8 km at 4 m sampling ≈ 200 M cells (too many); at 16 m ≈ 3 M (nothing).
Resolution scales with map size. Aliasing caveat still applies — **coarse to find the route, full
resolution to confirm the one corridor.**
- **"Explored" becomes a real number** — a finite volume means *"the community has explored 34 % of
season 5"*. A genuine collective progress bar for a shared world, free once bounded.
### ⚠️ NEW REQUIREMENT — an XY world-edge seal, which does not exist
Verified: `VF_ApplyBoundarySeal(float& Density, float WorldZ, float StrateTopZ, float StrateBottomZ,
float Thickness, float BaseDensity)` is **purely VERTICAL** — it seals each strate's ceiling and floor
so nothing punches through except passages. There is **no horizontal equivalent**, because until now
the world had no sides. A bounded world does.
⇒ Build it as a **fourth structural invariant** alongside spine → seal → passage: a **forcing** op,
appended automatically, not author-omittable. Two reasons that shape is right: an invented strate
cannot forget it, and because forcing ops prove `AllSolid` in their band, **the world's outer shell
becomes free to skip** in tile classification.
### The transition, and ⭐ the descent shaft as the game's front door
- **Distance gives a FIXED time budget, not an adaptive one** — fall speed × depth is the same
wall-clock on every machine, so a slow disk still lands in ungenerated space. **An elevator can
stall for a ready signal; a free fall cannot.** If it's a drop, size it for the worst machine.
- ⭐ **Whatever sits between the hole and the first strate is the highest-traffic space in the game** —
every player, every session, forever. It is currently an *accident* (just the gap streaming needed).
It should be authored deliberately, because it is already doing several jobs:
- **sets the tone** for everything below (a featureless drop reads as a loading screen; a worked
shaft with old machinery and lights going into the dark reads as "people have been here");
- **it is the elevator lobby** ⇒ the natural home of the manifest-driven **strate-select menu**, as a
real place rather than a floating panel;
- ⭐ **it is where depth becomes legible** — wall markings, and **the community's depth record for the
season marked on the shaft**, passed on the way down. The season's collective arc made physical, in
the one place everybody looks;
- later: shops, NPCs, surface-side infrastructure.
- Costs nothing architecturally (city-level authored geometry, or the top of the spine) but turns a
technical necessity into the game's threshold.
---
## 2026-08-17 — ACTOR-SPACE sweep (built, working)
**Why:** the city's hole is not at world (0,0), so the `AVoxelWorld` actor must be placed under it.
Jahni: *"we'll have it to be fully actor relative, just in case."*
**What was actually true before:** the plugin was **half** actor-relative. Correct already —
`GetBiomeAtWorldLocation`, `GetVoxelSurfaceHeightAt`, `UpdateDecorations`, `UpdateLandmarks`,
`UpdateWater`, `UVoxelDensityVolume::Update`, the passage debug draw, the clipmap material. **Not**
correct — the streaming centre, the streaming anchors, all six carve/fill entry points,
`GetStrateAtPosition`, and `UVoxelAtmosphereManager::UpdateForPlayer` (whose own comment *documented*
the assumption: *"actor at origin/identity"*). It only ever worked because the actor sat at the origin.
⚠️ **The trap avoided:** the content managers already convert internally, so converting `PlayerLastPos`
at the source would have **double-converted four call sites**. The fix had to be per-boundary, not at
the top. Found only by reading each consumer — the estimate before that read was wrong in both
directions.
**Landed:**
- `AVoxelWorld::WorldToLocalCm` / `WorldToLocalVoxel` / `LocalVoxelToWorld` (BlueprintPure) — declared
as **the only sanctioned boundary** between Unreal coordinates and voxel coordinates.
- ⭐ **A grep-checkable invariant, which is the real deliverable:** *no `/ VOXEL_SIZE` applied to a
parameter named `World*` outside those three functions.* The risk was never a missed site among the
ones we found — it is the **next** one nobody notices.
- 11 call sites converted (counted before and after; every old pattern now zero).
- ⚠️ **Translation-only guard in `BeginPlay`** — tiles, `ClassifyBox`, the clipmap window, culling and
streaming distance all assume **world-axis-aligned** boxes. A rotation or non-unit scale breaks that
*structurally*, not gracefully. Logs an Error naming the offending rotation/scale.
- CODEMAP §3.5 and §6 document the convention.
**Nice property:** with the actor at the origin every conversion is identity, so this is a **no-op**
until the actor moves. Verify by building, confirming nothing changed, *then* moving the actor.
Jahni built it: **"seems to work fine."**
@@ -42,17 +42,27 @@ void UVoxelAtmosphereManager::UpdateForPlayer(const FVector& PlayerWorldPos)
AActor* O = Owner.Get(); AActor* O = Owner.Get();
if (!O) return; if (!O) return;
const int32 Idx = StrateManager->GetStrateIndex(PlayerWorldPos.Z); // Le champ de densite est en espace ACTEUR : les requetes de strate et de biome veulent des
const UVoxelStrateDefinition* Def = StrateManager->GetStrateAt(PlayerWorldPos.Z); // coordonnees LOCALES. Le placement d acteurs, lui, veut du MONDE. Les deux cohabitent ici, ce
// qui est exactement pourquoi ce fichier supposait "acteur a l origine" avant le 2026-08-17.
// The density field is in ACTOR space: strate and biome queries want LOCAL coordinates, while
// actor placement wants WORLD. Both live in this function - which is exactly why it used to
// assume "actor at origin / identity".
const FTransform Xf = O->GetActorTransform();
const FVector LocalPos = Xf.InverseTransformPosition(PlayerWorldPos);
const int32 Idx = StrateManager->GetStrateIndex(LocalPos.Z);
const UVoxelStrateDefinition* Def = StrateManager->GetStrateAt(LocalPos.Z);
// Player's dominant biome (XY field) — atmosphere can vary by biome within a strate. // Player's dominant biome (XY field) — atmosphere can vary by biome within a strate.
// Convention matches GetStrateAt: world cm = voxel * VOXEL_SIZE (actor at origin/identity). // Requete en espace ACTEUR (voir LocalPos ci-dessus) : cm locaux -> voxels.
// Queried in ACTOR space (see LocalPos above): local cm -> voxels.
const UVoxelBiomeDefinition* Biome = nullptr; const UVoxelBiomeDefinition* Biome = nullptr;
if (Generator && Def) if (Generator && Def)
{ {
const int32 ChunkZ = FMath::FloorToInt((PlayerWorldPos.Z / VOXEL_SIZE) / CHUNK_SIZE); const int32 ChunkZ = FMath::FloorToInt((LocalPos.Z / VOXEL_SIZE) / CHUNK_SIZE);
Biome = Generator->GetDominantBiomeAt(PlayerWorldPos.X / VOXEL_SIZE, Biome = Generator->GetDominantBiomeAt(LocalPos.X / VOXEL_SIZE,
PlayerWorldPos.Y / VOXEL_SIZE, ChunkZ); LocalPos.Y / VOXEL_SIZE, ChunkZ);
} }
// React on strate change (full apply) OR biome change within a strate (fog/sky only — // React on strate change (full apply) OR biome change within a strate (fog/sky only —
@@ -81,18 +91,18 @@ void UVoxelAtmosphereManager::UpdateForPlayer(const FVector& PlayerWorldPos)
if (Def) if (Def)
{ {
float TopZ, BottomZ; float TopZ, BottomZ;
if (StrateManager->GetStrateUnrealZRange(PlayerWorldPos.Z, TopZ, BottomZ)) if (StrateManager->GetStrateUnrealZRange(LocalPos.Z, TopZ, BottomZ))
{ {
if (CeilingActor) if (CeilingActor)
{ {
CeilingActor->SetActorLocationAndRotation( CeilingActor->SetActorLocationAndRotation(
FVector(PlayerWorldPos.X, PlayerWorldPos.Y, TopZ + Def->CeilingLayerZOffset), Xf.TransformPosition(FVector(LocalPos.X, LocalPos.Y, TopZ + Def->CeilingLayerZOffset)),
Def->CeilingLayerRotation); Def->CeilingLayerRotation);
} }
if (FloorActor) if (FloorActor)
{ {
FloorActor->SetActorLocationAndRotation( FloorActor->SetActorLocationAndRotation(
FVector(PlayerWorldPos.X, PlayerWorldPos.Y, BottomZ + Def->FloorLayerZOffset), Xf.TransformPosition(FVector(LocalPos.X, LocalPos.Y, BottomZ + Def->FloorLayerZOffset)),
Def->FloorLayerRotation); Def->FloorLayerRotation);
} }
} }
+59 -12
View File
@@ -458,6 +458,29 @@ void AVoxelWorld::BeginPlay()
} }
// Générateur + mesher (UObjects légers) // Générateur + mesher (UObjects légers)
// TRANSLATION SEULEMENT / TRANSLATION ONLY.
// Tout le systeme raisonne en boites ALIGNEES SUR LES AXES DU MONDE : bornes de tuile, verdicts
// ClassifyBox, fenetre du clipmap, culling, distance de streaming. Une rotation ou une echelle
// non unitaire fait qu une tuile cesse d etre alignee sur les axes en espace monde, et TOUTE
// cette arithmetique devient fausse -- pas degradee : structurellement fausse.
// The whole system reasons in WORLD-AXIS-ALIGNED boxes: tile bounds, ClassifyBox verdicts, the
// clipmap window, culling, streaming distance. A rotation or non-unit scale means a tile is no
// longer axis-aligned in world space and none of that arithmetic survives - it does not degrade,
// it breaks structurally. Translating the actor IS supported (that is the point of the
// WorldToLocal* helpers); rotating or scaling it is not.
{
const FTransform ActorXf = GetActorTransform();
if (!ActorXf.GetRotation().Rotator().IsNearlyZero(0.01f) ||
!ActorXf.GetScale3D().Equals(FVector::OneVector, 0.001f))
{
UE_LOG(LogTemp, Error,
TEXT("[VoxelWorld] Actor has rotation %s / scale %s. VoxelForge supports TRANSLATION ONLY - ")
TEXT("tile bounds, ClassifyBox verdicts, the clipmap window and culling all assume ")
TEXT("world-axis-aligned boxes. Reset rotation to 0 and scale to 1."),
*ActorXf.GetRotation().Rotator().ToString(), *ActorXf.GetScale3D().ToString());
}
}
Generator = NewObject<UVoxelGenerator>(this); Generator = NewObject<UVoxelGenerator>(this);
Mesher = NewObject<UVoxelMarchingCubesMesher>(this); Mesher = NewObject<UVoxelMarchingCubesMesher>(this);
@@ -583,6 +606,29 @@ void AVoxelWorld::Tick(float DeltaTime)
#endif #endif
} }
//=============================================================================
// ESPACE MONDE <-> ESPACE ACTEUR / WORLD <-> ACTOR SPACE
//=============================================================================
// Le champ de densite est en espace ACTEUR. Ces trois fonctions sont la SEULE frontiere
// autorisee entre les coordonnees Unreal et les coordonnees voxel (cf. VoxelWorld.h).
// The density field is in ACTOR space. These three are the ONLY sanctioned boundary between
// Unreal coordinates and voxel coordinates (see VoxelWorld.h).
FVector AVoxelWorld::WorldToLocalCm(FVector WorldPos) const
{
return GetActorTransform().InverseTransformPosition(WorldPos);
}
FVector AVoxelWorld::WorldToLocalVoxel(FVector WorldPos) const
{
return GetActorTransform().InverseTransformPosition(WorldPos) / VOXEL_SIZE;
}
FVector AVoxelWorld::LocalVoxelToWorld(FVector VoxelPos) const
{
return GetActorTransform().TransformPosition(VoxelPos * VOXEL_SIZE);
}
FVector AVoxelWorld::GetPlayerPosition() const FVector AVoxelWorld::GetPlayerPosition() const
{ {
// This one is tricky with Unreal's API, so I'll give you more help: // This one is tricky with Unreal's API, so I'll give you more help:
@@ -1100,7 +1146,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition)
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateChunks); TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateChunks);
const int32 MaxTasks = GetMaxConcurrentTasks(); const int32 MaxTasks = GetMaxConcurrentTasks();
const FIntVector CenterChunk = WorldToChunkCoord(CenterPosition); // player's level-0 tile const FIntVector CenterChunk = WorldToChunkCoord(WorldToLocalCm(CenterPosition)); // player's level-0 tile
CurrentCenterChunk = CenterChunk; CurrentCenterChunk = CenterChunk;
// Streaming anchors (AI / remote players, §9.3): prune dead ones + detect chunk crossings so the // Streaming anchors (AI / remote players, §9.3): prune dead ones + detect chunk crossings so the
@@ -1116,7 +1162,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition)
bAnchorsMoved = true; bAnchorsMoved = true;
continue; continue;
} }
const FIntVector AC = WorldToChunkCoord(A->GetActorLocation()); const FIntVector AC = WorldToChunkCoord(WorldToLocalCm(A->GetActorLocation()));
if (AC != StreamingAnchors[i].LastChunk) if (AC != StreamingAnchors[i].LastChunk)
{ {
StreamingAnchors[i].LastChunk = AC; StreamingAnchors[i].LastChunk = AC;
@@ -1818,8 +1864,9 @@ int32 AVoxelWorld::GetStrateAtPosition(FVector WorldPosition) const
{ {
if (!StrateManager) return -1; if (!StrateManager) return -1;
// GetStrateIndex expects Unreal world units — it converts internally. // GetStrateIndex veut des cm ACTEUR-LOCAUX (il convertit cm -> voxels lui-meme).
return StrateManager->GetStrateIndex(WorldPosition.Z); // GetStrateIndex wants actor-LOCAL cm (it does the cm -> voxel conversion itself).
return StrateManager->GetStrateIndex(WorldToLocalCm(WorldPosition).Z);
} }
FVoxelBiomeQuery AVoxelWorld::GetBiomeAtWorldLocation(FVector WorldLocation) const FVoxelBiomeQuery AVoxelWorld::GetBiomeAtWorldLocation(FVector WorldLocation) const
@@ -1878,7 +1925,7 @@ bool AVoxelWorld::GetVoxelSurfaceHeightAt(FVector WorldLocation, float& OutSurfa
void AVoxelWorld::CarveAtPosition(FVector Position, float Radius, float Strength) void AVoxelWorld::CarveAtPosition(FVector Position, float Radius, float Strength)
{ {
FVoxelModification Mod; FVoxelModification Mod;
Mod.Center = Position / VOXEL_SIZE; // world cm → voxel space Mod.Center = WorldToLocalVoxel(Position); // world cm → voxel space
Mod.Radius = Radius; Mod.Radius = Radius;
Mod.Strength = -FMath::Abs(Strength); // force negative for carving Mod.Strength = -FMath::Abs(Strength); // force negative for carving
ApplyModification(Mod); ApplyModification(Mod);
@@ -1887,7 +1934,7 @@ void AVoxelWorld::CarveAtPosition(FVector Position, float Radius, float Strength
void AVoxelWorld::FillAtPosition(FVector Position, float Radius, float Strength) void AVoxelWorld::FillAtPosition(FVector Position, float Radius, float Strength)
{ {
FVoxelModification Mod; FVoxelModification Mod;
Mod.Center = Position / VOXEL_SIZE; Mod.Center = WorldToLocalVoxel(Position);
Mod.Radius = Radius; Mod.Radius = Radius;
Mod.Strength = FMath::Abs(Strength); // force positive for filling Mod.Strength = FMath::Abs(Strength); // force positive for filling
ApplyModification(Mod); ApplyModification(Mod);
@@ -1932,7 +1979,7 @@ void AVoxelWorld::CarveBox(FVector Position, FVector ExtentVoxels, float Strengt
{ {
FVoxelModification Mod; FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Box; Mod.Shape = EVoxelBrushShape::Box;
Mod.Center = Position / VOXEL_SIZE; Mod.Center = WorldToLocalVoxel(Position);
Mod.BoxExtent = ExtentVoxels; Mod.BoxExtent = ExtentVoxels;
Mod.Radius = ExtentVoxels.GetMax(); // budget proxy Mod.Radius = ExtentVoxels.GetMax(); // budget proxy
Mod.Strength = -FMath::Abs(Strength); // carve Mod.Strength = -FMath::Abs(Strength); // carve
@@ -1943,7 +1990,7 @@ void AVoxelWorld::FillBox(FVector Position, FVector ExtentVoxels, float Strength
{ {
FVoxelModification Mod; FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Box; Mod.Shape = EVoxelBrushShape::Box;
Mod.Center = Position / VOXEL_SIZE; Mod.Center = WorldToLocalVoxel(Position);
Mod.BoxExtent = ExtentVoxels; Mod.BoxExtent = ExtentVoxels;
Mod.Radius = ExtentVoxels.GetMax(); Mod.Radius = ExtentVoxels.GetMax();
Mod.Strength = FMath::Abs(Strength); // fill Mod.Strength = FMath::Abs(Strength); // fill
@@ -1954,8 +2001,8 @@ void AVoxelWorld::CarveCapsule(FVector WorldA, FVector WorldB, float RadiusVoxel
{ {
FVoxelModification Mod; FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Capsule; Mod.Shape = EVoxelBrushShape::Capsule;
Mod.Center = WorldA / VOXEL_SIZE; Mod.Center = WorldToLocalVoxel(WorldA);
Mod.CapsuleEnd = WorldB / VOXEL_SIZE; Mod.CapsuleEnd = WorldToLocalVoxel(WorldB);
Mod.Radius = RadiusVoxels; Mod.Radius = RadiusVoxels;
Mod.Strength = -FMath::Abs(Strength); Mod.Strength = -FMath::Abs(Strength);
ApplyModification(Mod); ApplyModification(Mod);
@@ -1965,8 +2012,8 @@ void AVoxelWorld::FillCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels
{ {
FVoxelModification Mod; FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Capsule; Mod.Shape = EVoxelBrushShape::Capsule;
Mod.Center = WorldA / VOXEL_SIZE; Mod.Center = WorldToLocalVoxel(WorldA);
Mod.CapsuleEnd = WorldB / VOXEL_SIZE; Mod.CapsuleEnd = WorldToLocalVoxel(WorldB);
Mod.Radius = RadiusVoxels; Mod.Radius = RadiusVoxels;
Mod.Strength = FMath::Abs(Strength); Mod.Strength = FMath::Abs(Strength);
ApplyModification(Mod); ApplyModification(Mod);
+35
View File
@@ -318,6 +318,41 @@ public:
* @param WorldPosition - Position in world space (Unreal units) * @param WorldPosition - Position in world space (Unreal units)
* @return Strate index (0 = topmost), or -1 if outside all strates * @return Strate index (0 = topmost), or -1 if outside all strates
*/ */
//=========================================================================
// ESPACE MONDE <-> ESPACE ACTEUR / WORLD <-> ACTOR SPACE
//=========================================================================
// Le champ de densite est defini en espace ACTEUR : (0,0) est l origine de CET acteur, pas
// celle du monde Unreal. Tout point venant d Unreal (position du pion, hit de trace, ancre de
// streaming) DOIT passer par ces helpers avant de toucher quoi que ce soit cote voxel.
//
// The density field is authored in ACTOR space: (0,0) is THIS actor s origin, not Unreal s
// world origin. Any point arriving from Unreal (pawn position, trace hit, streaming anchor)
// MUST go through these helpers before touching anything voxel-side.
//
// INVARIANT VERIFIABLE PAR GREP / GREP-CHECKABLE INVARIANT:
// aucun `/ VOXEL_SIZE` applique a un parametre nomme `World*` hors de ces fonctions.
// no `/ VOXEL_SIZE` applied to a parameter named `World*` outside these functions.
// C est la vraie garantie : le risque n est pas un site rate parmi ceux deja trouves, c est
// le SITE SUIVANT que personne ne verra. The risk is not a missed site among those already
// found - it is the NEXT one nobody notices.
//
// La racine est Static (voir BeginPlay) => la transform ne bouge pas en jeu, donc la relire a
// chaque appel est gratuit et ne demande aucune invalidation.
// The root is Static (see BeginPlay), so the transform cannot change during play: re-reading
// it per call is free and needs no invalidation.
/** World cm -> actor-local cm. */
UFUNCTION(BlueprintPure, Category = "Voxel World|Space")
FVector WorldToLocalCm(FVector WorldPos) const;
/** World cm -> actor-local VOXEL coords (what every density / diff-layer API expects). */
UFUNCTION(BlueprintPure, Category = "Voxel World|Space")
FVector WorldToLocalVoxel(FVector WorldPos) const;
/** Actor-local VOXEL coords -> world cm (placing actors, debug draws, UI markers). */
UFUNCTION(BlueprintPure, Category = "Voxel World|Space")
FVector LocalVoxelToWorld(FVector VoxelPos) const;
UFUNCTION(BlueprintPure, Category = "Voxel World|Strate") UFUNCTION(BlueprintPure, Category = "Voxel World|Strate")
int32 GetStrateAtPosition(FVector WorldPosition) const; int32 GetStrateAtPosition(FVector WorldPosition) const;