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:
1 parent
dfa1d5f5b1
commit
302cf16681
5 files changed
+608
-21
No files matched your search
+20
@@ -213,6 +213,19 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
|
||||
`ChunkLODs`, `ProcessQueue` (TQueue), `PendingChunkCoord` (TSet) (VoxelWorld.h:85-312).
|
||||
**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 |
|
||||
|--------|-----------|------|
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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)
|
||||
|
||||
@@ -510,6 +524,12 @@ Stage order (negative=solid throughout). Each stage's anchor:
|
||||
different layers — trust the MC convention at the mesher.
|
||||
- **Coordinate units:** density functions take **voxel coords** (not cm). World↔voxel
|
||||
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.
|
||||
Same seed ⇒ identical world. Player edits are the only non-deterministic overlay.
|
||||
- **Async safety:** background tasks must check `bShuttingDown` and only touch the
|
||||
|
||||
@@ -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
|
||||
build-vote pool, same counter: keep extrapolating past the corpus, and keep some unrated weird
|
||||
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();
|
||||
if (!O) return;
|
||||
|
||||
const int32 Idx = StrateManager->GetStrateIndex(PlayerWorldPos.Z);
|
||||
const UVoxelStrateDefinition* Def = StrateManager->GetStrateAt(PlayerWorldPos.Z);
|
||||
// Le champ de densite est en espace ACTEUR : les requetes de strate et de biome veulent des
|
||||
// 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.
|
||||
// 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;
|
||||
if (Generator && Def)
|
||||
{
|
||||
const int32 ChunkZ = FMath::FloorToInt((PlayerWorldPos.Z / VOXEL_SIZE) / CHUNK_SIZE);
|
||||
Biome = Generator->GetDominantBiomeAt(PlayerWorldPos.X / VOXEL_SIZE,
|
||||
PlayerWorldPos.Y / VOXEL_SIZE, ChunkZ);
|
||||
const int32 ChunkZ = FMath::FloorToInt((LocalPos.Z / VOXEL_SIZE) / CHUNK_SIZE);
|
||||
Biome = Generator->GetDominantBiomeAt(LocalPos.X / VOXEL_SIZE,
|
||||
LocalPos.Y / VOXEL_SIZE, ChunkZ);
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
float TopZ, BottomZ;
|
||||
if (StrateManager->GetStrateUnrealZRange(PlayerWorldPos.Z, TopZ, BottomZ))
|
||||
if (StrateManager->GetStrateUnrealZRange(LocalPos.Z, TopZ, BottomZ))
|
||||
{
|
||||
if (CeilingActor)
|
||||
{
|
||||
CeilingActor->SetActorLocationAndRotation(
|
||||
FVector(PlayerWorldPos.X, PlayerWorldPos.Y, TopZ + Def->CeilingLayerZOffset),
|
||||
Xf.TransformPosition(FVector(LocalPos.X, LocalPos.Y, TopZ + Def->CeilingLayerZOffset)),
|
||||
Def->CeilingLayerRotation);
|
||||
}
|
||||
if (FloorActor)
|
||||
{
|
||||
FloorActor->SetActorLocationAndRotation(
|
||||
FVector(PlayerWorldPos.X, PlayerWorldPos.Y, BottomZ + Def->FloorLayerZOffset),
|
||||
Xf.TransformPosition(FVector(LocalPos.X, LocalPos.Y, BottomZ + Def->FloorLayerZOffset)),
|
||||
Def->FloorLayerRotation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,6 +458,29 @@ void AVoxelWorld::BeginPlay()
|
||||
}
|
||||
|
||||
// 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);
|
||||
Mesher = NewObject<UVoxelMarchingCubesMesher>(this);
|
||||
|
||||
@@ -583,6 +606,29 @@ void AVoxelWorld::Tick(float DeltaTime)
|
||||
#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
|
||||
{
|
||||
// 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);
|
||||
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;
|
||||
|
||||
// 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;
|
||||
continue;
|
||||
}
|
||||
const FIntVector AC = WorldToChunkCoord(A->GetActorLocation());
|
||||
const FIntVector AC = WorldToChunkCoord(WorldToLocalCm(A->GetActorLocation()));
|
||||
if (AC != StreamingAnchors[i].LastChunk)
|
||||
{
|
||||
StreamingAnchors[i].LastChunk = AC;
|
||||
@@ -1818,8 +1864,9 @@ int32 AVoxelWorld::GetStrateAtPosition(FVector WorldPosition) const
|
||||
{
|
||||
if (!StrateManager) return -1;
|
||||
|
||||
// GetStrateIndex expects Unreal world units — it converts internally.
|
||||
return StrateManager->GetStrateIndex(WorldPosition.Z);
|
||||
// GetStrateIndex veut des cm ACTEUR-LOCAUX (il convertit cm -> voxels lui-meme).
|
||||
// GetStrateIndex wants actor-LOCAL cm (it does the cm -> voxel conversion itself).
|
||||
return StrateManager->GetStrateIndex(WorldToLocalCm(WorldPosition).Z);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
FVoxelModification Mod;
|
||||
Mod.Center = Position / VOXEL_SIZE; // world cm → voxel space
|
||||
Mod.Center = WorldToLocalVoxel(Position); // world cm → voxel space
|
||||
Mod.Radius = Radius;
|
||||
Mod.Strength = -FMath::Abs(Strength); // force negative for carving
|
||||
ApplyModification(Mod);
|
||||
@@ -1887,7 +1934,7 @@ void AVoxelWorld::CarveAtPosition(FVector Position, float Radius, float Strength
|
||||
void AVoxelWorld::FillAtPosition(FVector Position, float Radius, float Strength)
|
||||
{
|
||||
FVoxelModification Mod;
|
||||
Mod.Center = Position / VOXEL_SIZE;
|
||||
Mod.Center = WorldToLocalVoxel(Position);
|
||||
Mod.Radius = Radius;
|
||||
Mod.Strength = FMath::Abs(Strength); // force positive for filling
|
||||
ApplyModification(Mod);
|
||||
@@ -1932,7 +1979,7 @@ void AVoxelWorld::CarveBox(FVector Position, FVector ExtentVoxels, float Strengt
|
||||
{
|
||||
FVoxelModification Mod;
|
||||
Mod.Shape = EVoxelBrushShape::Box;
|
||||
Mod.Center = Position / VOXEL_SIZE;
|
||||
Mod.Center = WorldToLocalVoxel(Position);
|
||||
Mod.BoxExtent = ExtentVoxels;
|
||||
Mod.Radius = ExtentVoxels.GetMax(); // budget proxy
|
||||
Mod.Strength = -FMath::Abs(Strength); // carve
|
||||
@@ -1943,7 +1990,7 @@ void AVoxelWorld::FillBox(FVector Position, FVector ExtentVoxels, float Strength
|
||||
{
|
||||
FVoxelModification Mod;
|
||||
Mod.Shape = EVoxelBrushShape::Box;
|
||||
Mod.Center = Position / VOXEL_SIZE;
|
||||
Mod.Center = WorldToLocalVoxel(Position);
|
||||
Mod.BoxExtent = ExtentVoxels;
|
||||
Mod.Radius = ExtentVoxels.GetMax();
|
||||
Mod.Strength = FMath::Abs(Strength); // fill
|
||||
@@ -1954,8 +2001,8 @@ void AVoxelWorld::CarveCapsule(FVector WorldA, FVector WorldB, float RadiusVoxel
|
||||
{
|
||||
FVoxelModification Mod;
|
||||
Mod.Shape = EVoxelBrushShape::Capsule;
|
||||
Mod.Center = WorldA / VOXEL_SIZE;
|
||||
Mod.CapsuleEnd = WorldB / VOXEL_SIZE;
|
||||
Mod.Center = WorldToLocalVoxel(WorldA);
|
||||
Mod.CapsuleEnd = WorldToLocalVoxel(WorldB);
|
||||
Mod.Radius = RadiusVoxels;
|
||||
Mod.Strength = -FMath::Abs(Strength);
|
||||
ApplyModification(Mod);
|
||||
@@ -1965,8 +2012,8 @@ void AVoxelWorld::FillCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels
|
||||
{
|
||||
FVoxelModification Mod;
|
||||
Mod.Shape = EVoxelBrushShape::Capsule;
|
||||
Mod.Center = WorldA / VOXEL_SIZE;
|
||||
Mod.CapsuleEnd = WorldB / VOXEL_SIZE;
|
||||
Mod.Center = WorldToLocalVoxel(WorldA);
|
||||
Mod.CapsuleEnd = WorldToLocalVoxel(WorldB);
|
||||
Mod.Radius = RadiusVoxels;
|
||||
Mod.Strength = FMath::Abs(Strength);
|
||||
ApplyModification(Mod);
|
||||
|
||||
@@ -318,6 +318,41 @@ public:
|
||||
* @param WorldPosition - Position in world space (Unreal units)
|
||||
* @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")
|
||||
int32 GetStrateAtPosition(FVector WorldPosition) const;
|
||||
|
||||
|
||||
Reference in new issue
Block a user