Compare commits
147
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4ac9969f5 | ||
|
|
fb052716c6 | ||
|
|
de05e293aa | ||
|
|
6ee433efa5 | ||
|
|
a49725d365 | ||
|
|
85a803b873 | ||
|
|
7486c299d4 | ||
|
|
f36bc1f9b2 | ||
|
|
370c39e473 | ||
|
|
ea3f0d9a52 | ||
|
|
30253f0e89 | ||
|
|
d7af2cab97 | ||
|
|
88f3e44402 | ||
|
|
0dd45ca410 | ||
|
|
7af8d0e870 | ||
|
|
cc04c0bd4e | ||
|
|
a7b8d10de6 | ||
|
|
1c642fd182 | ||
|
|
76bc8bce97 | ||
|
|
d01af4606d | ||
|
|
01cb9d47f1 | ||
|
|
4fa3931176 | ||
|
|
0231072cc4 | ||
|
|
bf9384a1d5 | ||
|
|
8722293f6b | ||
|
|
10fd0b6e77 | ||
|
|
cda082fa10 | ||
|
|
cf9904e8b1 | ||
|
|
410788f7dd | ||
|
|
c7b832db4c | ||
|
|
8ef1a3b579 | ||
|
|
cfd81b5ce9 | ||
|
|
a668833ef5 | ||
|
|
0208e93606 | ||
|
|
1d824aaab2 | ||
|
|
ce4d8783e9 | ||
|
|
92607751b1 | ||
|
|
cf2b2add20 | ||
|
|
8879045627 | ||
|
|
39c306b524 | ||
|
|
481e7bbaee | ||
|
|
4c8d06fd8f | ||
|
|
7ece3ceafc | ||
|
|
7a972f513d | ||
|
|
6ccf3718ff | ||
|
|
c132243b30 | ||
|
|
11f0c1335b | ||
|
|
26b34765d5 | ||
|
|
5489b5e311 | ||
|
|
22186473f9 | ||
|
|
ab4bbe3680 | ||
|
|
b839edd3a3 | ||
|
|
cfad3d1c18 | ||
|
|
2de603cd77 | ||
|
|
2990009f3b | ||
|
|
8e40bdd92c | ||
|
|
c65b015f01 | ||
|
|
83a0e4d85c | ||
|
|
667da8df14 | ||
|
|
f2044135ea | ||
|
|
bb8ec51939 | ||
|
|
cf0319d480 | ||
|
|
35de59bac5 | ||
|
|
0388c7dede | ||
|
|
081cc5e459 | ||
|
|
58bdfb32de | ||
|
|
aa62971047 | ||
|
|
5ddd96664d | ||
|
|
adb34dffa9 | ||
|
|
4f8b4a9a27 | ||
|
|
2260c4d47a | ||
|
|
5156084cac | ||
|
|
8e42d6d807 | ||
|
|
e91f1c44bb | ||
|
|
ce53552543 | ||
|
|
ff8bd7a8bc | ||
|
|
383ec86c37 | ||
|
|
412a7ad219 | ||
|
|
d38d4cabef | ||
|
|
0e9df9e5a7 | ||
|
|
2f33870a84 | ||
|
|
e63f016835 | ||
|
|
ee89c4d8c2 | ||
|
|
0576e800c4 | ||
|
|
64f5d61e08 | ||
|
|
e08b49a183 | ||
|
|
d97373c5a8 | ||
|
|
55bf807f60 | ||
|
|
6b916699af | ||
|
|
bd547c35c4 | ||
|
|
413c15de24 | ||
|
|
e4e159f0cd | ||
|
|
2eaceb5984 | ||
|
|
4378385b37 | ||
|
|
110ae9d0ed | ||
|
|
cf5f83e9f0 | ||
|
|
fc43a86709 | ||
|
|
eb47efdec9 | ||
|
|
faa62d419e | ||
|
|
088b40216d | ||
|
|
dd38832bb6 | ||
|
|
42e1f0ec8c | ||
|
|
0b3ec18b43 | ||
|
|
43eaf1ef0e | ||
|
|
d717bc74ff | ||
|
|
90c40c8d5a | ||
|
|
993512f0bc | ||
|
|
f088c19f88 | ||
|
|
859a9b25c1 | ||
|
|
190ff381c0 | ||
|
|
358bd415a9 | ||
|
|
8e346a3054 | ||
|
|
7b982ad5ae | ||
|
|
4f11ce81a4 | ||
|
|
9d2324879d | ||
|
|
44938f3f98 | ||
|
|
271bbdd034 | ||
|
|
9d5633ad54 | ||
|
|
7a20eeed0d | ||
|
|
58e61cdefa | ||
|
|
5519b323c8 | ||
|
|
0bbfdeb7a7 | ||
|
|
cd31dec869 | ||
|
|
efcc8fcb65 | ||
|
|
13399529b5 | ||
|
|
6988e5ace1 | ||
|
|
8cd5face9b | ||
|
|
1ab8c0cae4 | ||
|
|
10a8bbc677 | ||
|
|
a60138b19c | ||
|
|
b781e3bab2 | ||
|
|
b7cf12af4c | ||
|
|
302cf16681 | ||
|
|
dfa1d5f5b1 | ||
|
|
eae7e68caf | ||
|
|
d8f7dabee8 | ||
|
|
451ebcb776 | ||
|
|
91585ea493 | ||
|
|
4d33321bfa | ||
|
|
2303442d8f | ||
|
|
871ca190af | ||
|
|
a2c5e02713 | ||
|
|
05986bd875 | ||
|
|
4ae9e6e72f | ||
|
|
b06c39c077 | ||
|
|
9740d117e0 | ||
|
|
45c61dd00e |
No files matched your search
@@ -9,6 +9,10 @@
|
|||||||
!/Source/
|
!/Source/
|
||||||
!/Source/**
|
!/Source/**
|
||||||
|
|
||||||
|
# The guarded single-entry test harness is source, not generated output.
|
||||||
|
!/Tools/
|
||||||
|
!/Tools/**
|
||||||
|
|
||||||
!VoxelForge.uplugin
|
!VoxelForge.uplugin
|
||||||
|
|
||||||
# Keep every design / doc markdown at any depth (AUDIT P1 — these were untracked)
|
# Keep every design / doc markdown at any depth (AUDIT P1 — these were untracked)
|
||||||
|
|||||||
+447
-36
@@ -11,6 +11,55 @@
|
|||||||
A large A-to-Z expansion. The world is a stack of strates the player descends through;
|
A large A-to-Z expansion. The world is a stack of strates the player descends through;
|
||||||
each strate can be a fundamentally different *archetype*, connected at (0,0).
|
each strate can be a fundamentally different *archetype*, connected at (0,0).
|
||||||
|
|
||||||
|
### 8.0 Human-scale generation contract (2026-09-06)
|
||||||
|
|
||||||
|
All authored lengths are voxel-space values, converted at the boundary by `0.25 m/voxel`. The body
|
||||||
|
reference is a **0.34 m radius, 1.76 m tall** capsule (`1.36 × 7.04` voxels). The default strate
|
||||||
|
height is now **8 chunks = 256 voxels = 64 m**, which is intentionally larger than the 3-4 chunk
|
||||||
|
boss-arena anchor so an archetype can reserve seals, vertical play, and a high ceiling together.
|
||||||
|
|
||||||
|
The default size families are deliberately independent:
|
||||||
|
|
||||||
|
| Feature | Default metre derivation | Coupling kept sound |
|
||||||
|
|---------|--------------------------|---------------------|
|
||||||
|
| Room network | radius 4-10 m (8-20 m diameters), origin radius 12 m (24 m hub), cell spacing 32 m | `MaxRoomRadius 40 < RoomSpacing/2 64`; a 6 m radial margin remains around the largest room |
|
||||||
|
| Round passages | 1.5-2 m radius (3-4 m bores); maze corridor radius 3.125 m (6.25 m nominal bore) | `0.7 × 2 × (12.5−3.75) × 0.25 = 3.0625 m` usable floor after worst roughness; Maze `12.5 < CellSize/2 32` |
|
||||||
|
| Slab | floor at 16 m, ceiling at 38.4 m, 22.4 m open span | floor/ceiling noise and 4-8 m column diameters remain inside the open span |
|
||||||
|
| SurfaceWorld | 20 m terrain relief, 12 m warp, 60.8 m sky cap | terrain features are bounded below the 64 m strate envelope |
|
||||||
|
| VerticalShafts | 4-7 m shaft diameters, 3 m connector bore, 8 m ledge interval | shaft spacing is 20 m; connector/ledge values are derived from the roughness envelope |
|
||||||
|
| FloatingIslands | 12-24 m island diameters, 28 m cell spacing | the largest island leaves a 2 m centre-to-edge gap; thickness is separately derived |
|
||||||
|
| Inter-strate passages | 3-4 m mouth bore, 3 m mid bore, 8-24 m reach, 16-48 m offset | width, reach, and wander are separate body/placement decisions |
|
||||||
|
|
||||||
|
For wall detail, `VOXEL_NOISE_SCALE=1.25` and the proven `sup|fBM|=1.5` give a roughness reach of
|
||||||
|
`1.875 × SurfaceRoughness` voxels. The default ratios are **0.625** against the minimum tunnel
|
||||||
|
radius, **0.300** against the Maze corridor radius, **0.469/0.625** against shaft/connector radii,
|
||||||
|
**0.234** against the minimum island radius (**0.484** including its 6-voxel blend), and
|
||||||
|
**0.625/0.938** against slab column radius for floor/ceiling roughness. SurfaceWorld is a
|
||||||
|
heightfield, so its **0.047** ratio is against its 80-voxel relief scale, not a fictitious radial
|
||||||
|
feature. FlatPlain and CrystalChamber intentionally share `GetSlabDensity` and the same slab
|
||||||
|
parameter block; they cannot acquire separate scale geometry without a generator change.
|
||||||
|
|
||||||
|
This scale contract is informed by [Epic's Unreal level-blockout guidance](https://dev.epicgames.com/documentation/en-us/unreal-engine/designer-01-project-setup-and-level-blockout-in-unreal-engine), which recommends a player-sized reference and gives 2-3 m hall / 3-4 m height starting guidance. The
|
||||||
|
[2010 ADA Standards](https://www.ada.gov/law-and-regs/design-standards/2010-stds/) are a lower-bound
|
||||||
|
accessibility reference (915 mm clear walking width and larger passing/turning spaces), while
|
||||||
|
[NPS Lehman Cave dimensions](https://www.nps.gov/grba/learn/nature/lehman-caves-dimensions.htm) support
|
||||||
|
keeping natural chambers heterogeneous and allowing 20 m-plus cathedral volumes.
|
||||||
|
|
||||||
|
Validation is deliberately split by cost: bounded regression fixtures remain 4 chunks, while the
|
||||||
|
production definition, season defaults, and owner-facing showcase use 8 chunks. The historical
|
||||||
|
pre-tree production-height sweep exhausted 256 rolls and found **0/8** complete hard-gate survivors;
|
||||||
|
its 7 fine renders, 1 refusal, and 1 blank are retained as the scale baseline. The historical final
|
||||||
|
`VoxelForge` run (`VoxelForgeFullMazeTreeFinal3`) recorded **30 clean successes, 2 warning-bearing,
|
||||||
|
0 failures, 0 not-run** in **2,523.978 s**; its regenerated showcase produced **6 fine renders, 2
|
||||||
|
refusals, and 0 blanks**. All box-verdict violation counts were **0**, `WorldRadiusVoxels` stayed
|
||||||
|
**0**, lateral regions stayed gated, and operator-stack equivalence paths remained bit-identical.
|
||||||
|
The 2026-09-06 landing validation (`VoxelForgeLandingFinal5`) recorded **30 clean successes, 2
|
||||||
|
warning-bearing successes, 0 failures, 0 not-run** across 32 tests in **3,607.746 s**. The strict
|
||||||
|
`Composer.Promotion` assertions remain intact and passed; no assertion was weakened. The landing,
|
||||||
|
connectivity, seal, box-soundness, determinism, and equivalence tests passed. The two warnings are
|
||||||
|
the known gated lateral-region result and the known TunnelNetwork outside-gate sample count; they
|
||||||
|
are not landing or box-verdict violations.
|
||||||
|
|
||||||
### 8.1 Archetypes (`ECaveGeneratorType`, VoxelStrateTypes.h)
|
### 8.1 Archetypes (`ECaveGeneratorType`, VoxelStrateTypes.h)
|
||||||
Each archetype has its own param `USTRUCT` (on `UVoxelStrateDefinition`, EditCondition-gated
|
Each archetype has its own param `USTRUCT` (on `UVoxelStrateDefinition`, EditCondition-gated
|
||||||
by `GeneratorType`) and its own density function in `VoxelGenerator.cpp`, dispatched by the
|
by `GeneratorType`) and its own density function in `VoxelGenerator.cpp`, dispatched by the
|
||||||
@@ -20,41 +69,145 @@ by `GeneratorType`) and its own density function in `VoxelGenerator.cpp`, dispat
|
|||||||
|-----------|---------------|------------|------|
|
|-----------|---------------|------------|------|
|
||||||
| TunnelNetwork | `FStrateGenerationParams` | `GetDensityWithParams` | rooms+tunnels (original) |
|
| TunnelNetwork | `FStrateGenerationParams` | `GetDensityWithParams` | rooms+tunnels (original) |
|
||||||
| FlatPlain / CrystalChamber | `FSlabGenerationParams` | `GetSlabDensity` | floor/ceiling void (original) |
|
| FlatPlain / CrystalChamber | `FSlabGenerationParams` | `GetSlabDensity` | floor/ceiling void (original) |
|
||||||
| Maze | `FMazeGenerationParams` | `GetMazeDensity` | tight corridors on a 3D lattice (per-voxel, no cache; edge = lower node + axis hash) |
|
| Maze | `FMazeGenerationParams` | `GetMazeDensity` | origin-directed spanning-tree corridors on a 3D lattice, with capped loop edges and a thread-local per-cell capsule cache |
|
||||||
| SurfaceWorld | `FSurfaceGenerationParams` | `GetSurfaceDensity` | heightfield terrain: domain-warped continents+ridged mtns+detail, a low-freq **relief map** (`M`) that scales mountains/elevation for plains↔highland variety, **F20 heightfield terrain ops** (`Surface|Ops` — all default off ⇒ byte-identical): **Cliff** (slope-gated steepening — where the analytic structural slope > `CliffSlopeThreshold`, push the height away from the local mean by `CliffSharpness` ⇒ gentle slopes become sheer walls/canyon faces that hug real steep ground, gentle areas untouched; 4 structural resamples only when enabled), **Terrace** (relief-gated plateau quantize + `TerraceHardness` soft-round↔crisp-mesa), **LayerLines** (sedimentary sine shelves, slope-expressed) — pure per-column height remaps in the single oracle `ComputeSurfaceTerrainZ` (`SampleSurfaceStructuralZ` = pre-op raw height, re-sampled at an XY offset for Cliff's slope), biome-selected + border-blended for free via each biome's `SurfaceParams` + the height output-lerp; plus **phase-2 Overhang** (the first VOLUMETRIC op — real jutting shelves like a cliff lip): in `SurfaceDensityFromColumn`, for AIR voxels in the window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height and unioned in. Low in the window the shift is ~0 (borrows nearby low rock ⇒ stays air over the void); high up it reaches the far cliff (solid) ⇒ a shelf attached to the cliff, tapering out over the void with air underneath. Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill dir `(DirX,DirY)` are resolved once in `ComputeSurfaceColumn` — the gradient sampled at the REACH scale (`OverhangReach`) so a point out over the void can "see" the cliff to know which way is uphill — and cached on `FSurfaceColumn`. It's genuine 3D (per-voxel structural re-eval), so it's gated hard to steep overhang columns; the union only ADDS rock (never removes), capped at `TerrainZ+OverhangHeight`, so `ClassifyTile` forces Mixed only in `(TerrainZ, TerrainZ+OverhangMargin]` (upward-only; margin = max `OverhangHeight`) — a shelf never holes a trivially-skipped tile, and only the thin cliff-edge band of air tiles is woken (NOT far-field like phase-3 spikes/holes). Overhang undersides classify as ground rock by the F17 rule (down-facing but below `TerrainZ`). Known v1 limit: applies at all LODs (Step-agnostic) — may alias far; gate to fine tiles later. Beaches at water line, high sky-cap ceiling. The cap is shapeable terrain in its own right (`ComputeSurfaceCeiling`, `Surface|Sky` params: `CeilingUndulation` broad inverted hills/valleys, `CeilingRidgeStrength` hanging ridgelines, `CeilingRoughness`+freq fine bumps, `CeilingWarp*`) — defaults (strengths 0, freq 0.04) = old flat-ish cap. (`Surface|Macro` params = the cheap precursor to biomes; `ReliefStrength=0` ⇒ old uniform terrain.) **Sky-cap vs ground is a PER-TRIANGLE surface class (F17), not a per-tile verdict**: the mesher classifies each unique vertex on the **worker** — only down-facing verts (`N.Z<-0.1`) pay a memoized `GetSurfaceHeightAt` column query; nearer `CeilSurf` ⇒ sky-cap, nearer `TerrainZ` ⇒ terrain overhang stays ground (a future SurfaceWorld cave roof — down-facing but below `TerrainZ` — also lands ground by the same rule; non-surface strates always ground). Triangles take the majority class of their 3 verts and are packed as **two contiguous index runs** (ground then cap, `FVoxelMeshData::NumCeilingTriangles`; skirts inherit their source triangle's run) → RMC **polygroups 0/1** → one **section per non-empty group** (`ApplyMeshToTile`, material slot = group index): slot 0 = strate `OverrideMaterial`/default (min-corner chunk), slot 1 = `CeilingMaterial` resolved at the tile's **TOP chunk** (mid as gap fallback, then min; fallback: ground material) — a coarse tile is 2^level chunks tall, so its min corner can sit in a lower strate/gap while the cap belongs to the strate above (this was the "far cap = ground material" residue), so the shadowless overhead rock is tinted separately instead of reading flat/bright. Shadow is **per section** via `FRealtimeMeshSectionConfig::bCastsShadow` (NOT the component `SetCastShadow` — RMC's proxy ignores the component flag; this is also why level≥2 far tiles only stopped casting once the section flag was wired): ground casts at level≤1, the cap section never casts, so the rock ceiling never shadows the terrain below it. History: v1 was a game-thread centre height-oracle (misclassified coarse far tiles → terrain material on the cap underside); v2 a whole-tile worker normal VOTE — which painted **mixed coarse tiles** (one far tile spanning terrain AND cap) entirely with the winner's material, and put sky material under terrain overhangs. The per-triangle class fixes both and is the identity channel caves/F8 will reuse. |
|
| SurfaceWorld | `FSurfaceGenerationParams` | `GetSurfaceDensity` | heightfield terrain: domain-warped continents+ridged mtns+detail, a low-freq **relief map** (`M`) that scales mountains/elevation for plains↔highland variety, **F20 heightfield terrain ops** (`Surface|Ops` — all default off ⇒ byte-identical): **Cliff** (slope-gated steepening — where the analytic structural slope > `CliffSlopeThreshold`, push the height away from the local mean by `CliffSharpness` ⇒ gentle slopes become sheer walls/canyon faces that hug real steep ground, gentle areas untouched; 4 structural resamples only when enabled), **Terrace** (relief-gated plateau quantize + `TerraceHardness` soft-round↔crisp-mesa), **LayerLines** (sedimentary sine shelves, slope-expressed) — pure per-column height remaps in the single oracle `ComputeSurfaceTerrainZ` (`SampleSurfaceStructuralZ` = pre-op raw height, re-sampled at an XY offset for Cliff's slope), biome-selected + border-blended for free via each biome's `SurfaceParams` + the height output-lerp; plus **phase-2 Overhang** (the first VOLUMETRIC op — real jutting shelves like a cliff lip): in `SurfaceDensityFromColumn`, for AIR voxels in the window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height and unioned in. Low in the window the shift is ~0 (borrows nearby low rock ⇒ stays air over the void); high up it reaches the far cliff (solid) ⇒ a shelf attached to the cliff, tapering out over the void with air underneath. Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill dir `(DirX,DirY)` are resolved once in `ComputeSurfaceColumn` — the gradient sampled at the REACH scale (`OverhangReach`) so a point out over the void can "see" the cliff to know which way is uphill — and cached on `FSurfaceColumn`. It's genuine 3D (per-voxel structural re-eval), so it's gated hard to steep overhang columns; the union only ADDS rock (never removes), capped at `TerrainZ+OverhangHeight`, so `ClassifyTile` forces Mixed only in `(TerrainZ, TerrainZ+OverhangMargin]` (upward-only; margin = max `OverhangHeight`) — a shelf never holes a trivially-skipped tile, and only the thin cliff-edge band of air tiles is woken (NOT far-field like phase-3 spikes/holes). Overhang undersides classify as ground rock by the F17 rule (down-facing but below `TerrainZ`). Known v1 limit: applies at all LODs (Step-agnostic) — may alias far; gate to fine tiles later. Beaches at water line, high sky-cap ceiling. The cap is shapeable terrain in its own right (`ComputeSurfaceCeiling`, `Surface|Sky` params: `CeilingUndulation` broad inverted hills/valleys, `CeilingRidgeStrength` hanging ridgelines, `CeilingRoughness`+freq fine bumps, `CeilingWarp*`) — defaults (strengths 0, freq 0.04) = old flat-ish cap. (`Surface|Macro` params = the cheap precursor to biomes; `ReliefStrength=0` ⇒ old uniform terrain.) **Sky-cap vs ground is a PER-TRIANGLE surface class (F17), not a per-tile verdict**: the mesher classifies each unique vertex on the **worker** — only down-facing verts (`N.Z<-0.1`) pay a memoized `GetSurfaceHeightAt` column query; nearer `CeilSurf` ⇒ sky-cap, nearer `TerrainZ` ⇒ terrain overhang stays ground (a future SurfaceWorld cave roof — down-facing but below `TerrainZ` — also lands ground by the same rule; non-surface strates always ground). Triangles take the majority class of their 3 verts and are packed as **two contiguous index runs** (ground then cap, `FVoxelMeshData::NumCeilingTriangles`; skirts inherit their source triangle's run) → RMC **polygroups 0/1** → one **section per non-empty group** (`ApplyMeshToTile`, material slot = group index): slot 0 = strate `OverrideMaterial`/default (min-corner chunk), slot 1 = `CeilingMaterial` resolved at the tile's **TOP chunk** (mid as gap fallback, then min; fallback: ground material) — a coarse tile is 2^level chunks tall, so its min corner can sit in a lower strate/gap while the cap belongs to the strate above (this was the "far cap = ground material" residue), so the shadowless overhead rock is tinted separately instead of reading flat/bright. Shadow is **per section** via `FRealtimeMeshSectionConfig::bCastsShadow` (NOT the component `SetCastShadow` — RMC's proxy ignores the component flag; this is also why level≥2 far tiles only stopped casting once the section flag was wired): ground casts at level≤1, the cap section never casts, so the rock ceiling never shadows the terrain below it. History: v1 was a game-thread centre height-oracle (misclassified coarse far tiles → terrain material on the cap underside); v2 a whole-tile worker normal VOTE — which painted **mixed coarse tiles** (one far tile spanning terrain AND cap) entirely with the winner's material, and put sky material under terrain overhangs. The per-triangle class fixes both and is the identity channel caves/F8 will reuse. |
|
||||||
| VerticalShafts | `FVerticalShaftParams` | `GetVerticalShaftDensity` | full-height shafts + horizontal connectors + partial ledges |
|
| VerticalShafts | `FVerticalShaftParams` | `GetVerticalShaftDensity` | full-height shafts + horizontal connectors + partial ledges |
|
||||||
| FloatingIslands | `FFloatingIslandParams` | `GetFloatingIslandDensity` | asymmetric islands: flat land top + underside tapering to a point, lobed (domain-warped) outline, in an open void |
|
| FloatingIslands | `FFloatingIslandParams` | `GetFloatingIslandDensity` | asymmetric islands: flat land top + underside tapering to a point, lobed (domain-warped) outline, in an open void |
|
||||||
| Underwater | `FStrateGenerationParams` + water | (reuses `GetDensityWithParams`) | tunnel rock + high water table |
|
| Underwater | `FStrateGenerationParams` + water | (reuses `GetDensityWithParams`) | tunnel rock + high water table |
|
||||||
|
|
||||||
All density fns share the convention: internal **positive=solid**, apply origin spine →
|
All density fns share the convention: internal **positive=solid**, apply origin spine →
|
||||||
boundary seal → inter-strate passages, then `return -Density` (MC: negative=solid).
|
vertical boundary seal → inter-strate passage tube + landing/floor → XY edge seal, then `return -Density` (MC:
|
||||||
|
negative=solid). The final MC-facing edge pass in `GetDensityAt` is repeated after disturbances so
|
||||||
|
the global rim cannot be reopened by a post-process; the diff layer remains the explicit player
|
||||||
|
override.
|
||||||
StrateManager provides params per chunk via `GetMaze/Surface/VerticalShaft/FloatingIslandParamsForChunk`
|
StrateManager provides params per chunk via `GetMaze/Surface/VerticalShaft/FloatingIslandParamsForChunk`
|
||||||
(macro `VF_ARCHETYPE_PARAMS_GETTER`) — no cross-boundary blend (Hard transitions between archetypes).
|
(macro `VF_ARCHETYPE_PARAMS_GETTER`) — the ordinary authored path has no cross-boundary blend
|
||||||
|
(Hard transitions between strates). The offline composer’s lateral manifest is the explicit
|
||||||
|
within-strate exception described in §8.1a; it blends evaluated density fields, not these params.
|
||||||
On top of the archetype, an optional **biome** layer (§8.14) modulates terrain & content WITHIN a
|
On top of the archetype, an optional **biome** layer (§8.14) modulates terrain & content WITHIN a
|
||||||
strate via a window-invariant XY field — currently wired into SurfaceWorld.
|
strate via a window-invariant XY field — currently wired into SurfaceWorld.
|
||||||
|
|
||||||
⚠️ **The `switch` above is no longer the only density path.** 6 of the 8 archetypes (all but
|
### 8.1a Lateral regions (Tier 4d)
|
||||||
TunnelNetwork and Underwater) also exist as **operator stacks**, selected per strate by
|
|
||||||
|
An archetype is now allowed to be a **spatial region inside one strate**, rather than a label that
|
||||||
|
describes the whole strate. The offline composer rolls one to three region records. Each record
|
||||||
|
owns its own native parameter vector and, for structure candidates, its own recipe; region zero is
|
||||||
|
also retained as the compatibility identity. A one-region record takes the existing stack path.
|
||||||
|
|
||||||
|
The partition is a pure function of `(world XY, world seed, absolute strate index)`. A 256-voxel
|
||||||
|
hash lattice places jittered sites (`VoxelHash::Cell`), each site receives a deterministic region
|
||||||
|
id, and the nearest-site assignment is Voronoi-like. Near a bisector, the nearest different-region
|
||||||
|
site supplies the neighbour; its weight is the same linear boundary curve used by the vertical
|
||||||
|
transition, split symmetrically so both sides meet at 0.5. The parent lerps the two **evaluated
|
||||||
|
internal densities**, never their parameter vectors: a shaft spacing and a slab parameter are not
|
||||||
|
commensurable. Internal density is positive solid; the parent is negated once at the MC boundary.
|
||||||
|
|
||||||
|
Region cores are built without structural posts. `FLateralRegionBlendOp` resolves the creative
|
||||||
|
field, then one parent appends the global spine, vertical seal, passage carve/landing floor, and XY edge seal in
|
||||||
|
the existing fixed order. No region can disable or duplicate those posts. The manifest is an
|
||||||
|
offline artifact and the editor hand-off copies it into a worker-local per-chunk cache: the chunk
|
||||||
|
prepares a small lattice-site window plus an integer XY query grid, so ordinary voxel evaluation is
|
||||||
|
an O(1) lookup rather than a seed search or allocation. Fractional gradient probes reuse the same
|
||||||
|
prepared site window.
|
||||||
|
|
||||||
|
The lateral `ClassifyBox` proof is deliberately one-sided. It uses a certified nearest-different
|
||||||
|
site from the bounded local lattice window plus a 2-Lipschitz bound over the box's XY radius; only
|
||||||
|
a strict lower bound greater than the blend width proves that one region and no blend band can touch
|
||||||
|
the box. If the bounded query cannot certify that neighbor, proof is refused. Every other box asks
|
||||||
|
every region stack; any `Mixed`, disagreement, or unknown result remains `Mixed`. Convex density blending means
|
||||||
|
that equal all-air/all-solid signs are safe, but no cross-region uniformity is inferred from a
|
||||||
|
single sample. The same key is checked when a season manifest is loaded, and `WorldRadiusVoxels`
|
||||||
|
remains zero during offline materialisation.
|
||||||
|
|
||||||
|
This mechanism is implemented and instrumented, but not ready to promote: the pre-landing
|
||||||
|
16-seed passage-mouth audit reported **9/16** connected opposite-region pairs. After the landing
|
||||||
|
pass, the current audit reports **4/16**, while the exact region-zero controls also report **4/16**.
|
||||||
|
The same cases are **16/16 valid, non-vacuous, and above the 0.50 largest-component survival
|
||||||
|
threshold** before the law gate. No corridor or threshold tuning was added. The failed primordial-law
|
||||||
|
gate is therefore retained as a design blocker; see `COMPOSER-NOTES.md §3.2b` and the validation log
|
||||||
|
in §10.
|
||||||
|
|
||||||
|
⚠️ **The `switch` above is no longer the only density path.** All 8 archetypes also exist as
|
||||||
|
**operator stacks**, selected per strate by
|
||||||
`bUseOperatorStack` and evaluated instead of the `switch`; each is bit-identical to the function in
|
`bUseOperatorStack` and evaluated instead of the `switch`; each is bit-identical to the function in
|
||||||
its row. The design lives in `OPSTACK-PLAN.md` / `OPSTACK-DECOMPOSITION.md`, the symbol index in
|
its row. The design lives in `OPSTACK-PLAN.md` / `OPSTACK-DECOMPOSITION.md`, the symbol index in
|
||||||
`CODEMAP §3.2d` — not repeated here. What matters for *this* document: the archetype table describes
|
`CODEMAP §3.2d` — not repeated here. What matters for *this* document: the archetype table describes
|
||||||
what the world IS, and both paths compute it.
|
what the world IS, and both paths compute it.
|
||||||
|
|
||||||
### 8.2 (0,0) spine & hybrid connections
|
### 8.2 (0,0) spine & hybrid connections
|
||||||
- `ApplyOriginSpine` (VoxelGenerator.cpp, static helper) carves a guaranteed open vertical
|
- `ApplyOriginSpine` (VoxelGenerator.cpp, static helper) builds a finite, rounded landing room with
|
||||||
column at XY (0,0) in every strate's **interior** (seals untouched). Radius =
|
a flat support floor at XY (0,0) in every strate's **interior**. It no longer carves a continuous
|
||||||
`UVoxelGenerator::OriginSpineRadius` ← `VoxelSettings::OriginSpineRadius`. Called before
|
vertical column: the old column supplied open air but no standable surface except at its bottom.
|
||||||
every `ApplyBoundarySeal`.
|
Room half-width and height reuse the §6.5 capsule formulas; the room and slab are clamped away
|
||||||
- Descent is **player-dug** through the thin seals at (0,0). The single auto-opened
|
from both vertical seals. Radius = `UVoxelGenerator::OriginSpineRadius` ←
|
||||||
connection is the **surface entry shaft** at (0,0) through the top of strate 0
|
`VoxelSettings::OriginSpineRadius`. Called before every `ApplyBoundarySeal`.
|
||||||
(`GeneratePassages`, `bOpenSurfaceEntry`).
|
- `VerticalShafts` treats that structural column as a **connector endpoint**, not as a second
|
||||||
- **Hybrid extras:** auto-carved *shortcut* passages per boundary, placed away from (0,0).
|
density primitive. A thread-local rebuild collects a direct-indexed geometric halo once (with
|
||||||
Now fully **per-strate** — see §8.8 (the upper strate's `PassageConfig` drives count/style/shape).
|
the stock settings: 9×9 tree-emission cells and a 15×15 roll), emits tree links only for the
|
||||||
|
inner 3×3 cells, and resolves each parent from its complete ±2-cell candidate window. Each
|
||||||
|
shaft chooses the nearest shaft whose jittered distance to (0,0) is strictly smaller; ties use
|
||||||
|
the lowest cell index. If that window has no parent, the deterministic ±3 lower-origin neighbour
|
||||||
|
fallback fires; a window touching the origin uses the spine directly, with a final direct-spine
|
||||||
|
fallback for an unusually empty finite halo. The wider collection is therefore fully contained
|
||||||
|
for every emitted shaft and the parent is independent of chunk or evaluation-cell boundaries.
|
||||||
|
Every parent edge strictly decreases origin distance or terminates at the spine, which proves
|
||||||
|
that no shaft is orphaned or cyclic. The existing probabilistic `VoxelHash::Pair` links and
|
||||||
|
`Spacing*1.6` cutoff remain as texture and loops; they are additive, not the connectivity law.
|
||||||
|
Tree links use a radius strictly above the proven `sup|FBM|=1.5` roughness envelope
|
||||||
|
(`max(ConnectorRadius, SurfaceRoughness*VOXEL_NOISE_SCALE*1.5 + 1)`) and choose a shared
|
||||||
|
ledge-free Z interval. `BuildVerticalShaftStack` mirrors the same tree, random links, windows,
|
||||||
|
hashes, radius, and connector-Z rule; `VF_ApplyOriginSpine` remains the sole owner of the
|
||||||
|
vertical column itself.
|
||||||
|
- The seals between rooms remain closed until progression opens the next connection. The one
|
||||||
|
optional above-ground opening is the top room's ceiling through strate 0
|
||||||
|
(`GeneratePassages`, `bOpenSurfaceEntry`); it never bores the lower rooms together.
|
||||||
|
- **Base connection:** the default inter-strate passage is a small, single-file, two-leg
|
||||||
|
walkable switchback with a named 15° floor-gradient cap and final-density floor/capsule audit.
|
||||||
|
A 32 m drop needs 119.4 m of horizontal run at that gradient, so a single direct ramp is not a
|
||||||
|
plausible default. The switchback is explicit geometry, not a fall shaft.
|
||||||
|
- **Hybrid extras:** auto-carved passages per boundary, placed away from (0,0). The upper
|
||||||
|
strate's `PassageConfig` still drives count/style/shape; the legacy vertical/spiral/cascade/
|
||||||
|
crack styles remain descriptors without the base walkability guarantee.
|
||||||
|
|
||||||
|
The tree is a construction guarantee for the shaft field: every seeded shaft has one inward edge,
|
||||||
|
and every chain strictly decreases distance to the origin until it reaches a local minimum that
|
||||||
|
links to the spine. The fallback resolver preserves that proof, so no shaft is an orphan or part
|
||||||
|
of a cycle. It does not alter passage placement. The acceptance measurement remains the actual
|
||||||
|
arrival→departure flood-fill at the independently placed passage endpoints.
|
||||||
|
|
||||||
|
### 8.2a XY edge seal (bounded-world invariant)
|
||||||
|
`UVoxelSettings::WorldRadiusVoxels` and `EdgeSealThickness` define the bounded world in actor-space
|
||||||
|
XY around `(0,0)`. The default radius is 8192 voxels (~2.05 km at 25 cm/voxel) and the default
|
||||||
|
solid ramp is 64 voxels. A radius of **0 is a true no-op**: the hot path returns before doing any
|
||||||
|
arithmetic, so the legacy unbounded field is unchanged. From `R - Thickness` to `R`,
|
||||||
|
`VF_ApplyXYEdgeSeal` raises the internal positive-solid density with the same `SmoothStep01` ramp
|
||||||
|
idiom as the vertical seal; at and beyond `R` it forces positive `BaseDensity`. The generator
|
||||||
|
negates once for marching cubes, so the externally visible sealed density is negative (solid).
|
||||||
|
|
||||||
|
This is the fourth structural post and is appended automatically by
|
||||||
|
`FVoxelOpStack::AppendStructuralPost`, after the spine, vertical seal, and passage tube + landing
|
||||||
|
floor. Passage carving deliberately remains before the edge seal: the tube is the intentional
|
||||||
|
vertical-boundary crossing, while each landing/connector is clamped inside the strate's vertical
|
||||||
|
seal. A passage whose mouth, landing, or body reaches the XY rim is overwritten by the edge force
|
||||||
|
and cannot open a hole through the world boundary. The legacy density functions apply the same post
|
||||||
|
in the same order. `GetDensityAt` reapplies the MC-facing landing air/floor after disturbances as an
|
||||||
|
idempotent support backstop, before the final XY seal; player diff edits still run last by design
|
||||||
|
and remain authoritative.
|
||||||
|
|
||||||
|
`FXYEdgeSealOp` is `IsXYPure() == true`, uses only a squared radial early-out in the common interior
|
||||||
|
case, and returns `AllSolid` from `ClassifyBox` only when the closest point of the whole box is at
|
||||||
|
least one voxel into the ramp (or beyond `R`). Its `ForcedMarginOverBox` reports the actual positive
|
||||||
|
lower bound, conservatively reduced after the one-voxel proof margin; it never treats the
|
||||||
|
mathematical inner edge as solid. The same proof is used by the global `ClassifyTile` shell guard,
|
||||||
|
so tiles wholly in the proven outer band can skip T1.d generation/collision work. A box touching the
|
||||||
|
band but not meeting that proof remains `Mixed`.
|
||||||
|
|
||||||
### 8.3 Disturbance layer (the "wow" post-process)
|
### 8.3 Disturbance layer (the "wow" post-process)
|
||||||
`FStrateDisturbanceParams` (on the definition, all archetypes). `ApplyDisturbances`
|
`FStrateDisturbanceParams` (on the definition, all archetypes). `ApplyDisturbances`
|
||||||
(VoxelGenerator.cpp static, **MC convention**) runs in `GetDensityAt` after dispatch:
|
(VoxelGenerator.cpp static, **MC convention**) runs in `GetDensityAt` after dispatch:
|
||||||
chasms (carve air), bridges (solid spans), ridges (solid blades). Stays inside seal bands.
|
chasms (carve air), bridges (solid spans), ridges (solid blades). The XY edge seal is reapplied after
|
||||||
|
this layer, so disturbances cannot reopen the outer shell; the layer otherwise stays inside the
|
||||||
|
vertical seal bands.
|
||||||
Provided per chunk by `StrateManager::GetDisturbanceParamsForChunk`.
|
Provided per chunk by `StrateManager::GetDisturbanceParamsForChunk`.
|
||||||
|
|
||||||
### 8.4 Cross-chunk determinism (the seam-prevention invariant)
|
### 8.4 Cross-chunk determinism (the seam-prevention invariant)
|
||||||
@@ -65,6 +218,54 @@ Provided per chunk by `StrateManager::GetDisturbanceParamsForChunk`.
|
|||||||
**If you add a connectivity rule with longer edges, the COLLECT region must still cover the
|
**If you add a connectivity rule with longer edges, the COLLECT region must still cover the
|
||||||
max edge reach, and decisions must not depend on the stored window.**
|
max edge reach, and decisions must not depend on the stored window.**
|
||||||
|
|
||||||
|
#### Maze topology — local spanning tree, optional loops
|
||||||
|
|
||||||
|
Maze uses a different, strictly local connectivity contract. A non-origin lattice node `(x,y,z)`
|
||||||
|
hash-selects one parent from the axes whose coordinate is non-zero, taking one step toward
|
||||||
|
`(0,0,0)`. The parent step reduces `|x|+|y|+|z|` by one, so the infinite undirected parent graph
|
||||||
|
has one root, no cycles, and every cell is reachable by construction. A canonical lower-node plus
|
||||||
|
axis edge is open when either endpoint selects the other as parent. Horizontal and vertical loop
|
||||||
|
edges are optional visual detail, capped at `0.18 * BranchProbability` and `0.10 * Verticality`;
|
||||||
|
they are never needed for the connectivity proof.
|
||||||
|
|
||||||
|
The evaluation window is exactly the lower nodes in `{-1,0}³`, an 8-node / 2×2×2 local halo. The
|
||||||
|
edge predicate checks the adjacent `+1` endpoint's parent locally, so it does not need a wider
|
||||||
|
collect region and cannot disagree at a chunk boundary. Both `GetMazeDensity` and
|
||||||
|
`MakeLatticeCorridorSource` rebuild those decisions in their thread-local per-cell cache; the
|
||||||
|
voxel loop evaluates only cached capsule SDFs. This is deliberately independent of
|
||||||
|
`WorldRadiusVoxels` (which remains `0`) and the still-gated lateral-region system.
|
||||||
|
|
||||||
|
The editor composer walk-through preserves this invariant by avoiding a layout rebuild. AVoxelWorld::ApplyComposerCandidate
|
||||||
|
pauses and drains active generation, installs one temporary override on an existing slot, increments
|
||||||
|
the manager's layout/passages version, and then reuses RegenerateAllChunks to bump GenerationEpoch.
|
||||||
|
The slot's Z range and passages therefore remain stable; worker tasks copy the immutable override during
|
||||||
|
their versioned refetch. A parameter candidate uses the production native stack mapping, while a
|
||||||
|
structure candidate uses `VF_BuildStackFromRecipe`. Custom recipes now classify with that exact
|
||||||
|
materialised stack; slot/gap crossings, diffs, and disturbances still fail closed to `Mixed`, and
|
||||||
|
accepted uniform verdicts are brute-force checked by `VoxelForge.Composer.Season`. The bridge and its editable properties are
|
||||||
|
WITH_EDITOR/WITH_EDITORONLY_DATA only and do not exist in a shipping build.
|
||||||
|
|
||||||
|
### 8.4a Cooked seasons — composition stays offline, evaluation ships
|
||||||
|
|
||||||
|
`UVoxelSeasonAsset` is a `UPrimaryDataAsset` imported from the diffable schema-2
|
||||||
|
`season_manifest.json`. The asset embeds the reviewed JSON, independently copies its seed, season,
|
||||||
|
spine/radius and SHA-1 content hash, and retains cook references to authored fixed definitions.
|
||||||
|
Runtime parses and verifies both hash copies before accepting the season. An assigned invalid season
|
||||||
|
fails closed; it never falls back to an unrelated authored pool.
|
||||||
|
|
||||||
|
When `UVoxelSettings::Season` is assigned, `UVoxelStrateManager::Initialize` keeps its existing
|
||||||
|
half-built-layout/`GeneratePassages` ordering but feeds it the manifest's exact ordered slots, bounds,
|
||||||
|
archetypes, vectors, per-strate seeds and recipes. `UVoxelGenerator` copies an immutable recipe/vector
|
||||||
|
on the existing `(owner, chunk, layout version)` refetch and materialises `FVoxelOpStack` locally.
|
||||||
|
No rolling, corpus load, measurement, selection, promotion, or review code is compiled for Shipping.
|
||||||
|
When Season is unset, the authored fixed/pool shuffle path is unchanged.
|
||||||
|
|
||||||
|
Schema 2 is deliberately density-focused. A fixed slot may retain an authored definition path as its
|
||||||
|
content/visual/passage bag; a generated slot has deterministic empty/default wider content. Because
|
||||||
|
passage configuration is not stored, generated slots set `Connections=0` instead of inventing the
|
||||||
|
UObject default passage that was never measured; the origin spine remains. Lateral-region manifests
|
||||||
|
are rejected while `VF_LateralRegionsAreShippable()` remains false (measured cross-seam law 9/16).
|
||||||
|
|
||||||
### 8.5 Content scatter & water — `VoxelContentManager.h/.cpp` (NEW)
|
### 8.5 Content scatter & water — `VoxelContentManager.h/.cpp` (NEW)
|
||||||
`UVoxelContentManager` (owned by `AVoxelWorld`, game-thread). TWO INDEPENDENT subsystems:
|
`UVoxelContentManager` (owned by `AVoxelWorld`, game-thread). TWO INDEPENDENT subsystems:
|
||||||
|
|
||||||
@@ -284,8 +485,83 @@ with a flat-top envelope → organic squirm (NOT a 1D zigzag, NOT a same-freq 2-
|
|||||||
`EvaluateModifierSDF` (per voxel) first builds a `thread_local` **per-chunk shortlist** of passages
|
`EvaluateModifierSDF` (per voxel) first builds a `thread_local` **per-chunk shortlist** of passages
|
||||||
whose bounds reach this chunk (rebuilt on chunk change / `PassagesVersion` bump) — chunks with no
|
whose bounds reach this chunk (rebuilt on chunk change / `PassagesVersion` bump) — chunks with no
|
||||||
passage near return `FLT_MAX` immediately — then **bounding-sphere-culls** each shortlisted passage
|
passage near return `FLT_MAX` immediately — then **bounding-sphere-culls** each shortlisted passage
|
||||||
(`FVoxelPassage::BoundCenter/BoundRadiusSq`). Both are perf-critical (§8.10). The (0,0) surface entry
|
(`FVoxelPassage::BoundCenter/BoundRadiusSq`). The same shortlist serves the tube, landing SDF, and
|
||||||
is a simple straight tube. Global passage settings were removed from `VoxelSettings`.
|
landing-floor membership; no source query, flood fill, or topology search is in the voxel loop. Both
|
||||||
|
are perf-critical (§8.10). The (0,0) surface entry is a simple straight tube. Placement, reach, and
|
||||||
|
shape draws for inter-strate passages are keyed by `(seed, upper-strate index, connection index,
|
||||||
|
per-value salt)` through `VoxelHash`, so one passage's connection count cannot shift any later
|
||||||
|
passage. Global passage settings were removed from `VoxelSettings`.
|
||||||
|
|
||||||
|
The repeated floor queries use the same `thread_local` passage state: `FPassageEvaluationCache` owns
|
||||||
|
one indexed `FloorProjections` entry per passage, keyed by exact double `(X,Y,Z)` equality for the
|
||||||
|
current sample. Native projection, generic fallback projection, and walkable-air classification
|
||||||
|
reuse the cached float outputs and validity bits; manager lifetime, layout version, and chunk changes
|
||||||
|
invalidate the cache. `voxel.FloorRound1PassageProjectionCache` is an A/B switch only. It changes no
|
||||||
|
projection arithmetic and no density value; a cache hit is an exact reuse of the first result.
|
||||||
|
|
||||||
|
After each passage's XY is selected, `GeneratePassages` asks **both** participating strates for a
|
||||||
|
player-fit source point through the pure free function `VF_SuggestLandingPoint`
|
||||||
|
(`VoxelCaveMorphology`).
|
||||||
|
TunnelNetwork and Underwater keep the nearest hash-room vertical placement at the requested XY, with
|
||||||
|
`MakeStrateSeed(world-seed, strate-index)` matching the room graph's existing identity; FlatPlain and
|
||||||
|
CrystalChamber recompute their slab void band and reject column-overlap points. Maze snaps to the
|
||||||
|
nearest roughness-safe horizontal lattice corridor within one cell, VerticalShafts to the exact
|
||||||
|
axis of a roughness-safe shaft on the drainage tree within two shaft spacings at the default site
|
||||||
|
density, and FloatingIslands
|
||||||
|
to a validated blob top within one island spacing.
|
||||||
|
Those three return false when no footing exists inside that explicit lateral budget. GeneratePassages
|
||||||
|
interpolates each accepted XY through the control-point chain and recomputes its conservative bound;
|
||||||
|
SurfaceWorld remains deliberately unanswerable because production terrain can be selected through
|
||||||
|
manager-owned biome/per-column context. Declined answers (unsupported or no-footing) keep the
|
||||||
|
historical random reach and are reported diagnostically.
|
||||||
|
|
||||||
|
The source query proves only a local pose; it does not flood-fill the live network. Therefore every
|
||||||
|
inter-strate end also receives an explicit `FVoxelPassageLanding`: a rounded chamber with a hard
|
||||||
|
flat floor. **There is no connector to the origin landing room at `(0,0)`.** The owner deleted
|
||||||
|
those radial roads after a playtest (`d97373c`, 2026-09-08: they cut straight through everything);
|
||||||
|
room joins are flattened to walkable height instead. A source-fit landing therefore stays local to
|
||||||
|
the room it was fitted into (`GeneratePassages`, the `VF_BuildPassageLanding` calls).
|
||||||
|
⚠️ Open gap (Sol's code review, `DESIGN-SOL-2026-09-15-CODE.md` §1.3/§2.4): when the footing query
|
||||||
|
declines, the landing falls back to the historical random reach, and nothing proves that pose joins
|
||||||
|
the walk network. The canonical capability gate covers one scenario, not every seed or mix.
|
||||||
|
Proposed fix, not built: retry deterministically at other candidate spots instead of falling back.
|
||||||
|
This is the join guarantee, including for a source-fit answer — it is not a probability claim about
|
||||||
|
a nearby room. The connector is a swept flat-floor corridor with a 5-voxel (1.25 m) radius / 2.5 m
|
||||||
|
clear width and a 12-voxel (3 m) clear height. Its 4.5-voxel support inset is enough for the
|
||||||
|
1.36-voxel player radius and avoids capping unrelated shaft air; the origin room owns the root
|
||||||
|
floor, so there is no continuous root column. A connector's level dog-leg is sized against the same
|
||||||
|
named 15° floor-gradient limit as the default inter-strate tunnel. The room and connector floor are
|
||||||
|
therefore real walkable geometry, not a point anchor or nearest-component guess, and no
|
||||||
|
per-passage ordering is involved.
|
||||||
|
|
||||||
|
The chamber dimensions come directly from the capsule: player diameter `2×0.34/0.25 = 2.72`
|
||||||
|
voxels; required floor width `3/0.25 = 12` voxels; capsule height `2×0.88/0.25 = 7.04` voxels;
|
||||||
|
one metre of headroom adds 4 voxels. `HalfWidth=max(7, MouthRadius+2)` leaves
|
||||||
|
`2×(HalfWidth−1) >= 12` voxels of flat support after the one-voxel wall inset; the authored room
|
||||||
|
is therefore at least 14 voxels (3.5 m) wide. Height is
|
||||||
|
`max(12, 7.04+4, 2×MouthRadius+2)`; the stock 6-voxel mouth is a 14-voxel (3.5 m) room with
|
||||||
|
an 8-voxel half-width (4 m full width).
|
||||||
|
`FloorZ=StandingPoint.Z−0.5`, floor thickness is 3 voxels (0.75 m), and the tube centreline
|
||||||
|
meets the room at `FloorZ+MouthRadius`, so its lower tangent is the floor and there is no step lip.
|
||||||
|
The final-density floor audit samples the flat plane and checks the player-fit capsule above it. The
|
||||||
|
default tunnel audit samples every route step against the final density and checks the analytic and
|
||||||
|
measured route gradients against the explicit ≤15° walkable-tunnel contract. This is deliberately
|
||||||
|
well below the movement CDO's roughly 44.8° scramble ceiling: 44° is not a sensible default for a
|
||||||
|
tunnel the player is meant to walk.
|
||||||
|
|
||||||
|
The landing is part of the structural post, in the fixed order
|
||||||
|
`origin spine → vertical seal → passage tube + landing/floor → XY edge seal`. The floor is applied
|
||||||
|
after the passage carve and after MC-space disturbances as a support backstop, still before the
|
||||||
|
final XY edge seal. Room/floor Z is clamped inside the vertical seal; the XY edge seal is last and
|
||||||
|
wins over every landing or tube near the rim. Thus no landing can breach a strate boundary or the
|
||||||
|
world edge. The op-stack `FPassageCarveOp` calls the same manager operation as the legacy path,
|
||||||
|
including the floor, and `ClassifyTile` kills both uniform hypotheses around the bidirectional
|
||||||
|
landing floor (`Both` in the stack), so an air proof cannot delete support.
|
||||||
|
|
||||||
|
The query remains pure — it does not construct an operator stack, touch a cache, or call back into
|
||||||
|
`UVoxelStrateManager` while `Initialize` builds the layout. Landing descriptors, connector choice,
|
||||||
|
door direction, and bounds are all produced once from the passage seed salts; passages never read
|
||||||
|
another passage's result. `LayoutOrderIndependence` compares the complete descriptors bit-for-bit.
|
||||||
|
|
||||||
### 8.9 Carving — brush shapes + editor controls
|
### 8.9 Carving — brush shapes + editor controls
|
||||||
`FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/
|
`FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/
|
||||||
@@ -295,20 +571,73 @@ ApplyModification` (BlueprintCallable) + `EditorCarveSphere/EditorFillSphere` (C
|
|||||||
driven by `EditorBrush*` props.
|
driven by `EditorBrush*` props.
|
||||||
|
|
||||||
### 8.10 Performance invariants (DON'T regress)
|
### 8.10 Performance invariants (DON'T regress)
|
||||||
- **Streaming** (`UpdateChunksAroundPosition`): rebuild/cull the desired set ONLY when the
|
- **Streaming** (`UpdateChunksAroundPosition`): rebuild/cull the desired set only when the player
|
||||||
player crosses a chunk boundary (`LastUpdateCenter`); use the `DesiredSet` TSet for the cull;
|
crosses a level-0 chunk boundary, an anchor moves, or a rebuild is forced; use the stamped
|
||||||
idle via `bAllChunksLoaded`. Stationary player ≈ free. (Old per-frame O(loaded×desired) scan = 22ms.)
|
desired map for the cull and idle via `bAllChunksLoaded`. On every rebuild, pending requests whose
|
||||||
- **LOD** changes HOT-SWAP (`LoadChunk` only, never unload-first) → no holes. LOD
|
keys left the desired set are marked obsolete through a per-request atomic token. Workers only
|
||||||
|
read that token (and `bShuttingDown`/generation pause), abort between expensive stages, and send
|
||||||
|
the result through the normal MPSC queue; the game thread reads the token again before applying.
|
||||||
|
`FChunkResult::Epoch` still guards the generation epoch. Desired work is sorted from the true pawn
|
||||||
|
position, with the occupied/support LOD0 tile and the next tile along the heading in an absolute
|
||||||
|
high-priority prefix. Stationary player ≈ free. (Old per-frame O(loaded×desired) scan = 22ms.)
|
||||||
|
- **LOD** changes HOT-SWAP (`LoadTile` only, never unload-first) → no holes. LOD
|
||||||
reconciliation lives in the PERSISTENT per-frame submit loop (same loop as new-chunk loads),
|
reconciliation lives in the PERSISTENT per-frame submit loop (same loop as new-chunk loads),
|
||||||
NOT as a one-shot on the boundary-cross frame — a one-shot drops every chunk past the task
|
NOT as a one-shot on the boundary-cross frame — a one-shot drops every chunk past the task
|
||||||
budget and strands it at a stale LOD. Idle (`bAllChunksLoaded`) only when a full scan finds
|
budget and strands it at a stale LOD. Idle (`bAllChunksLoaded`) only when a full scan finds
|
||||||
no loads AND no LOD mismatches outstanding.
|
no loads AND no LOD mismatches outstanding.
|
||||||
- **SDF cache** (`GetDensityWithParams`): search-BOX validity, not chunk-key — gradient ±1
|
- **SDF cache** (`GetDensityWithParams`): search-BOX validity, not chunk-key — gradient ±1
|
||||||
sampling must not thrash the (expensive) rebuild.
|
sampling must not thrash the (expensive) rebuild.
|
||||||
|
- **Worm block skip** (`VF_TryGetWormBlockSkip`): inside the unchanged `WormNetworkRange` mask,
|
||||||
|
a worker-local direct-indexed cache covers the mesher's 4×4×4 lattice blocks (including the
|
||||||
|
normal halo). It samples N1 once at the transformed block centre and proves the entire block
|
||||||
|
clears the threshold with the actual 3D hash-gradient field bound
|
||||||
|
`L = (15/4) * sqrt(3) * VOXEL_NOISE_SCALE = 8.118988160`, plus a `1e-3` scaled-output
|
||||||
|
rounding margin. The radius is measured after the exact `WormFrequency`, `VerticalScale`, and
|
||||||
|
`WormHorizontalBias` transform, so anisotropic Z is included. The cache is thread-local and
|
||||||
|
keyed by generator owner, seed, params/layout fingerprint, tile origin, step, lattice size, and
|
||||||
|
worm parameters; malformed/non-lattice/failed proofs fall through. A proof only suppresses N1
|
||||||
|
and N2 because N2 is nonnegative; it never changes `NetworkMask` or density arithmetic.
|
||||||
|
- **Operator channel metadata** (`FVoxelOpStack`): `ChannelReads`, `ChannelWrites`, and
|
||||||
|
`IsAdditive` are called only when an operator is added to a stack and their values are cached in
|
||||||
|
the stack entry. `ValidateChannelOrder` is assembly/diagnostic-only; no declaration virtual may
|
||||||
|
enter `Eval`'s per-voxel loop. If `FVoxelOpSample` gains a field, add its channel bit and update
|
||||||
|
every operator declaration and the validator together.
|
||||||
|
- **Lateral region lookup/blend** (§8.1a): `FVoxelStrateRegionPartitionCache::PrepareForChunk`
|
||||||
|
builds the bounded jittered-site window and a `(CHUNK_SIZE+3)^2` integer query grid once per
|
||||||
|
chunk. Integer voxel samples read that grid; fractional gradient probes use the prepared site
|
||||||
|
window. `FLateralRegionBlendOp` evaluates one creative stack in a region interior and a second
|
||||||
|
only when the cached gap enters the blend band. The parent appends the four global structural
|
||||||
|
posts once. Do not move `VoxelHash` site gathering, `TArray` construction, or region-stack
|
||||||
|
discovery into `Eval`; the measured band overhead is an explicit Tier 4d budget item.
|
||||||
|
- **Isolated box proofs** (`FVoxelBoxHypotheses` + `FVoxelBoxSdfInterval`): the box fold carries
|
||||||
|
the SDF interval produced so far. An SDF-only source publishes only its own interval; a later
|
||||||
|
converter or detail op applies its own state-aware response. Unknown or invalid bounds force the
|
||||||
|
fold toward `Mixed`: a missed skip costs CPU, while a false uniform verdict removes geometry and
|
||||||
|
collision. Every new SDF writer must implement `PropagateSdfOverBox`; the default is unknown.
|
||||||
|
- **VerticalShafts field cache**: shaft rolls and connector decisions, including the structural
|
||||||
|
tree and structural-spine connector endpoint, are rebuilt only when the thread-local centre cell
|
||||||
|
or a geometry-affecting parameter changes. Each rebuild rolls a wider direct-indexed geometric
|
||||||
|
halo (stock settings: 9×9 tree-emission cells / 15×15 total roll); it stores/emits only the
|
||||||
|
inner 3×3 shafts and their capsules for the per-voxel loop, while spatial culling selects cached
|
||||||
|
connectors. The complete ±2 parent and ±3 fallback windows plus connector reach are contained
|
||||||
|
by that halo, so no parent decision depends on the evaluation cell. No cell or pair hash, widened
|
||||||
|
neighbourhood scan, or tree construction may move into the per-voxel loop; the operator-stack
|
||||||
|
source follows the same cache contract.
|
||||||
|
- **Maze field cache**: parent/loop decisions are rebuilt only when the thread-local cell, seed, or
|
||||||
|
loop parameters change. The rebuild evaluates the 24 canonical lower-node/axis edges in the
|
||||||
|
local `{-1,0}³` window and emits at most 24 capsules; the per-voxel loop performs no hash,
|
||||||
|
parent, loop, or neighbourhood work. The focused audit measured **1.436 μs** per forced rebuild
|
||||||
|
and **0.150 μs** per hot call in the focused audit; the final namespace run measured **1.130 μs**
|
||||||
|
rebuild and **0.145 μs** hot on the same machine.
|
||||||
- **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached
|
- **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached
|
||||||
thread-locally by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; the process-unique owner ID
|
thread-locally by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; the process-unique owner ID
|
||||||
prevents cross-world reuse while adding only one `uint64` compare per voxel. Don't remove the owner
|
prevents cross-world reuse while adding only one `uint64` compare per voxel. Don't remove the owner
|
||||||
or layout key, and don't move the fetch/blend back to per-voxel.
|
or layout key, and don't move the fetch/blend back to per-voxel.
|
||||||
|
- **XY edge seal hot path** (§8.2a): `WorldRadiusVoxels == 0` returns immediately; otherwise the
|
||||||
|
common interior case is a squared-distance test with no square root. The forcing op receives the
|
||||||
|
two global settings through `FVoxelOpContext` once per chunk, while `ClassifyBox`/`ClassifyTile`
|
||||||
|
share the radial proof. This adds no cache, state, RNG, or clipmap stream, and does not touch the
|
||||||
|
density grid's two-pass MC loop or any existing `thread_local` cache.
|
||||||
- **Biome cache** (`ResolveBiomeSampleAt`/`FChunkBiomeCache`, §8.14): validity is a world-XY BOX +
|
- **Biome cache** (`ResolveBiomeSampleAt`/`FChunkBiomeCache`, §8.14): validity is a world-XY BOX +
|
||||||
ChunkZ + Seed, NOT a chunk key — same reason as the SDF cache. The cell classification is
|
ChunkZ + Seed, NOT a chunk key — same reason as the SDF cache. The cell classification is
|
||||||
noise-heavy; a chunk-key would thrash it on gradient-normal / +X/+Y boundary samples. Keep
|
noise-heavy; a chunk-key would thrash it on gradient-normal / +X/+Y boundary samples. Keep
|
||||||
@@ -320,7 +649,9 @@ driven by `EditorBrush*` props.
|
|||||||
(incremented in `GeneratePassages`). Most chunks have NO passage near → instant `FLT_MAX` return
|
(incremented in `GeneratePassages`). Most chunks have NO passage near → instant `FLT_MAX` return
|
||||||
instead of walking the whole `Passages` array per voxel. Conservative superset (chunk bounding
|
instead of walking the whole `Passages` array per voxel. Conservative superset (chunk bounding
|
||||||
sphere vs passage bound) ⇒ bit-identical carve. Store indices + version, never pointers (the array
|
sphere vs passage bound) ⇒ bit-identical carve. Store indices + version, never pointers (the array
|
||||||
is rebuilt on `RebuildStrates`).
|
is rebuilt on `RebuildStrates`). Landing rooms, connector SDFs, and floor membership reuse this
|
||||||
|
exact cache. The final landing audit measured **0.665 μs** per forced rebuild and **0.646 μs**
|
||||||
|
per hot call on the validation machine; source-fit queries run only during `GeneratePassages`.
|
||||||
- **Gen tasks run at `UE::Tasks::ETaskPriority::BackgroundNormal`** (`LoadTile`): worker gen yields
|
- **Gen tasks run at `UE::Tasks::ETaskPriority::BackgroundNormal`** (`LoadTile`): worker gen yields
|
||||||
to foreground game/render tasks. Without it, raising `MaxConcurrentTasks` past the spare-core count
|
to foreground game/render tasks. Without it, raising `MaxConcurrentTasks` past the spare-core count
|
||||||
saturates the scheduler and starves the frame (the "concurrency > ~12 = stutter" symptom). Keep gen
|
saturates the scheduler and starves the frame (the "concurrency > ~12 = stutter" symptom). Keep gen
|
||||||
@@ -367,6 +698,17 @@ driven by `EditorBrush*` props.
|
|||||||
**Used ONLY for integer-XY queries**; fractional queries compute directly → bit-identical. Don't
|
**Used ONLY for integer-XY queries**; fractional queries compute directly → bit-identical. Don't
|
||||||
re-introduce a ChunkZ key, don't feed it fractional coords. (`GetSurfaceHeightAt`'s own `OC_*` oracle
|
re-introduce a ChunkZ key, don't feed it fractional coords. (`GetSurfaceHeightAt`'s own `OC_*` oracle
|
||||||
cache is separate and still per-chunk — lower volume, not worth the LRU.)
|
cache is separate and still per-chunk — lower volume, not worth the LRU.)
|
||||||
|
- **Surface-column construction (field-preserving follow-up)**: `FSurfaceColumn` is intentionally a
|
||||||
|
trivial payload. `ComputeSurfaceColumn` writes all five outputs before publishing `Computed`, so
|
||||||
|
its default member initialization was never observable; removing that generated TLS leaf avoids
|
||||||
|
constructor work without changing a height or density. Integer queries still read the cached
|
||||||
|
payload, and fractional queries still use the same direct locals.
|
||||||
|
- **Sampler unknown-source audit**: the earlier plugin-side `FSurfaceColumn` constructor PCs no longer
|
||||||
|
occur after the trivial-payload change. The largest remaining LOD0 unknown cluster in the final
|
||||||
|
sampler raw CSV is `UnrealEditor-Core.dll + RVA 0xC080` (runtime PCs around
|
||||||
|
`0x7FF88382C080`), inside the private `.pdata` function range `0xB4D0–0xC32B`; the packaged Core
|
||||||
|
binary has no PDB, so a source function name cannot be recovered here. Smaller unresolved groups
|
||||||
|
map to generated engine reflection/CoreUObject code, not to `Plugins\VoxelForge`.
|
||||||
- **Collision only at LOD0 (T1.c)** (`ApplyMeshToChunk`): `UpdateSectionConfig(..., LOD==0)`.
|
- **Collision only at LOD0 (T1.c)** (`ApplyMeshToChunk`): `UpdateSectionConfig(..., LOD==0)`.
|
||||||
LOD1/2 chunks are unreachable (the §8.10 reconciliation hot-swaps to LOD0 before the player
|
LOD1/2 chunks are unreachable (the §8.10 reconciliation hot-swaps to LOD0 before the player
|
||||||
arrives), so cooking their Chaos collision is waste. Don't force collision on for all LODs.
|
arrives), so cooking their Chaos collision is waste. Don't force collision on for all LODs.
|
||||||
@@ -387,9 +729,13 @@ driven by `EditorBrush*` props.
|
|||||||
tile is replaced by several finer tiles, so the old center-owner
|
tile is replaced by several finer tiles, so the old center-owner
|
||||||
check (`ReplacementLoaded`) dropped it as soon as the ONE tile over its centre loaded → the not-yet-
|
check (`ReplacementLoaded`) dropped it as soon as the ONE tile over its centre loaded → the not-yet-
|
||||||
ready edges flashed a hole; the full-coverage check keeps the old tile at its current resolution until
|
ready edges flashed a hole; the full-coverage check keeps the old tile at its current resolution until
|
||||||
the better mesh is wholly in, then swaps. In-flight (pending) tiles are NEVER cancelled on a rebuild —
|
the better mesh is wholly in, then swaps. In-flight requests are cancelled when their key leaves the
|
||||||
they finish, apply, and are culled later if no longer desired. Collision level-0 only; water level-0
|
desired set: the game thread flips the request token, the worker returns an aborted result, and the
|
||||||
only; shadows off for level≥2. **Decorations are NO LONGER tied to tiles** — they stream on a fixed world
|
queue consumer drops it even if cancellation raced the final enqueue. A result must also match the
|
||||||
|
current generation epoch and desired membership before it can apply. `TransitionHold` therefore
|
||||||
|
protects only already-applied geometry during a LOD transition; obsolete work never becomes loaded
|
||||||
|
geometry. Collision level-0 only; water level-0 only; shadows off for level≥2. **Decorations are NO
|
||||||
|
LONGER tied to tiles** — they stream on a fixed world
|
||||||
grid by distance (§8.5), so they don't pop on LOD swaps. Settings: `VoxelSettings::ClipRadius` (full-res near radius, tiles/level),
|
grid by distance (§8.5), so they don't pop on LOD swaps. Settings: `VoxelSettings::ClipRadius` (full-res near radius, tiles/level),
|
||||||
`MaxClipLevel` (far reach). **NEAR-FIELD GEN COST levers** (`LoadTile`): levels `< FullResClipLevels`
|
`MaxClipLevel` (far reach). **NEAR-FIELD GEN COST levers** (`LoadTile`): levels `< FullResClipLevels`
|
||||||
mesh at full `CHUNK_SIZE` cells (≈35³ `GetDensityAt` incl. margin ring), coarser levels at
|
mesh at full `CHUNK_SIZE` cells (≈35³ `GetDensityAt` incl. margin ring), coarser levels at
|
||||||
@@ -455,11 +801,13 @@ driven by `EditorBrush*` props.
|
|||||||
(stale stamp — removed + returned). The cull then considers ONLY leavers + `TransitionHold`
|
(stale stamp — removed + returned). The cull then considers ONLY leavers + `TransitionHold`
|
||||||
(tiles kept by load-before-unload from earlier crossings) instead of re-scanning EVERY loaded
|
(tiles kept by load-before-unload from earlier crossings) instead of re-scanning EVERY loaded
|
||||||
tile per crossing — that scan was the measured ~1.6 ms/crossing `CullTiles` spike (2026-07-05
|
tile per crossing — that scan was the measured ~1.6 ms/crossing `CullTiles` spike (2026-07-05
|
||||||
trace, plus `BuildDesiredTiles` 0.66 ms on the same frames). In-flight tiles that finish while
|
trace, plus `BuildDesiredTiles` 0.66 ms on the same frames). Pending requests that become leavers
|
||||||
no longer desired are caught at apply time (`ProcessPendingChunks` adds them to the hold);
|
are marked obsolete before replacement submission; `ProcessPendingChunks` reads the token, drops
|
||||||
`UnloadTile` drops hold entries; the settled cull (everything loaded) keeps its full scan as
|
the aborted result, and does not put it in the hold. `ApplyTileResult` independently rejects a
|
||||||
the safety net. `DesiredPending` for the overlap test is now built LAZILY (only when an
|
wrong generation epoch or undesired key. `UnloadTile` drops hold entries; the settled cull
|
||||||
in-range transition candidate exists). **The hold is re-evaluated on a ROTATING BUDGET**
|
(everything loaded) keeps its full scan as the safety net. `DesiredPending` for the overlap test is
|
||||||
|
now built LAZILY (only when an in-range transition candidate exists). **The hold is re-evaluated on
|
||||||
|
a ROTATING BUDGET**
|
||||||
(`TransitionHoldQueue` + cursor, ~256 tiles/crossing): re-scanning the whole hold each
|
(`TransitionHoldQueue` + cursor, ~256 tiles/crossing): re-scanning the whole hold each
|
||||||
crossing degenerates back to the O(loaded) scan whenever streaming never settles (measured
|
crossing degenerates back to the O(loaded) scan whenever streaming never settles (measured
|
||||||
2.47 ms/crossing in the first packaged capture) — keeping a tile a few crossings longer is
|
2.47 ms/crossing in the first packaged capture) — keeping a tile a few crossings longer is
|
||||||
@@ -481,6 +829,13 @@ driven by `EditorBrush*` props.
|
|||||||
shadow-casting tile emits a second shadow-pass draw; the far coarse tiles don't need it. NOTE: fps is
|
shadow-casting tile emits a second shadow-pass draw; the far coarse tiles don't need it. NOTE: fps is
|
||||||
RENDER-side (draws ≈ visible tile count × passes); generation cost (workers) and tile *resolution*
|
RENDER-side (draws ≈ visible tile count × passes); generation cost (workers) and tile *resolution*
|
||||||
(cuts triangles, not draws/components) don't move the game thread — tile COUNT does (hence the clipmap).
|
(cuts triangles, not draws/components) don't move the game thread — tile COUNT does (hence the clipmap).
|
||||||
|
- ⛔ **2026-09-13: streaming no longer calls `ClassifyTile` by default** (`voxel.OuterClassifierMode`
|
||||||
|
= 0; `=1` restores it as an A/B instrument). Measured on the headless game path with nested
|
||||||
|
refinement off, the classifier plus its exact validation cost more than they saved. Mode 0 had
|
||||||
|
ready p95 182 vs 190-195 ms, generation p95 125-127 vs 139-141 ms, and 18% fewer density calls,
|
||||||
|
with 841/841 tiles triangle-identical (WORK-NEXT.md). Proving a 33³ core uniform costs almost
|
||||||
|
as much as meshing 35³. The history below explains why it existed. Its soundness tests still
|
||||||
|
guard it.
|
||||||
- **Trivial-empty tile reject (T1.d) — v2 SHIPPED (2026-07-05); v1 was reverted 2026-06-26.**
|
- **Trivial-empty tile reject (T1.d) — v2 SHIPPED (2026-07-05); v1 was reverted 2026-06-26.**
|
||||||
`UVoxelGenerator::ClassifyTile(Origin, Step, Cells)` runs on the gen worker BEFORE the density
|
`UVoxelGenerator::ClassifyTile(Origin, Step, Cells)` runs on the gen worker BEFORE the density
|
||||||
pre-sample (`LoadTile` task, scope `VoxelForge_ClassifyTile`); `AllSolid`/`AllAir` ⇒ GenerateMesh is
|
pre-sample (`LoadTile` task, scope `VoxelForge_ClassifyTile`); `AllSolid`/`AllAir` ⇒ GenerateMesh is
|
||||||
@@ -499,6 +854,9 @@ driven by `EditorBrush*` props.
|
|||||||
**Conservative guards** (anything that can carve/fill): player mods (`HasAnyModInChunkRange`) ⇒
|
**Conservative guards** (anything that can carve/fill): player mods (`HasAnyModInChunkRange`) ⇒
|
||||||
Mixed; passages (`AnyPassageNearBox`, bounding spheres + carve blend pad) and the (0,0) spine
|
Mixed; passages (`AnyPassageNearBox`, bounding spheres + carve blend pad) and the (0,0) spine
|
||||||
(circle/box XY) kill AllSolid; disturbance chasms kill AllSolid, bridges/ridges kill AllAir.
|
(circle/box XY) kill AllSolid; disturbance chasms kill AllSolid, bridges/ridges kill AllAir.
|
||||||
|
The global XY edge proof runs first and returns AllSolid for a box entirely in the forced band;
|
||||||
|
the same force is the final generated post after disturbances, so a near-rim passage cannot
|
||||||
|
invalidate that verdict. Every other uncertain case remains Mixed.
|
||||||
A false `Mixed` only costs CPU; the code must NEVER emit a false AllSolid/AllAir (that's a hole).
|
A false `Mixed` only costs CPU; the code must NEVER emit a false AllSolid/AllAir (that's a hole).
|
||||||
Capture tiles (`bWantCapture`, density-volume shadow window) always generate — the volume wants the
|
Capture tiles (`bWantCapture`, density-volume shadow window) always generate — the volume wants the
|
||||||
grid even for uniform cells. A sparse ~5×5 column pre-pass exits Mixed fast on surface-crossing
|
grid even for uniform cells. A sparse ~5×5 column pre-pass exits Mixed fast on surface-crossing
|
||||||
@@ -558,6 +916,14 @@ driven by `EditorBrush*` props.
|
|||||||
### 8.11 Live tuning & debug (`AVoxelWorld`, CallInEditor / PIE)
|
### 8.11 Live tuning & debug (`AVoxelWorld`, CallInEditor / PIE)
|
||||||
- `RebuildStrates` — re-reads ALL of `VoxelSettings` and rebuilds layout/gap/passages/spine +
|
- `RebuildStrates` — re-reads ALL of `VoxelSettings` and rebuilds layout/gap/passages/spine +
|
||||||
regenerates. Use after changing those (plain `RegenerateAllChunks` keeps the old layout/passages).
|
regenerates. Use after changing those (plain `RegenerateAllChunks` keeps the old layout/passages).
|
||||||
|
- ApplyComposerCandidate — editor-only PIE walk-through for one offline candidate. Set
|
||||||
|
ComposerSeed, ComposerCandidateIndex, ComposerTargetStrateIndex (0 = topmost), and
|
||||||
|
bComposerRollStructure in Live Edit|Composer, then click Apply Composer Candidate. It uses the
|
||||||
|
shared composer roll, overlays density on the existing slot, pauses/drains workers, bumps the
|
||||||
|
manager version, and reuses RegenerateAllChunks for the epoch/re-stream. It does not rebuild
|
||||||
|
passages or change the authoritative Settings->Seed; RebuildStrates removes the overlay. The
|
||||||
|
action logs recipe, archetype, air/largest-component/walkable/feature metrics, and an eight-point
|
||||||
|
bit-level density verdict. The button itself still requires owner verification in PIE.
|
||||||
- `ValidateDeterminism` (F2) — one-click §8.4 regression test: samples chunk-boundary points under
|
- `ValidateDeterminism` (F2) — one-click §8.4 regression test: samples chunk-boundary points under
|
||||||
two different thread_local cache alignments (left-chunk warm vs right-chunk warm) + a repeat
|
two different thread_local cache alignments (left-chunk warm vs right-chunk warm) + a repeat
|
||||||
pass; every delta must be EXACTLY 0. Run it after any hot-path refactor that claims
|
pass; every delta must be EXACTLY 0. Run it after any hot-path refactor that claims
|
||||||
@@ -669,9 +1035,54 @@ generic biome field), not just SurfaceWorld. Empty `Biomes[]` ⇒ all-zero colou
|
|||||||
- **Status:** C++ ✅ BUILT & WORKING (ticked 2026-07-27). The master material graph is still
|
- **Status:** C++ ✅ BUILT & WORKING (ticked 2026-07-27). The master material graph is still
|
||||||
editor-side work and is deliberately NOT ticked — that half is Jahni's, not the code's.
|
editor-side work and is deliberately NOT ticked — that half is Jahni's, not the code's.
|
||||||
|
|
||||||
|
### 8.16 Explorer commandlet — one authoritative field, one sampled grid, three consumers
|
||||||
|
`-run=VoxelForgeExplore` lives in the separate `VoxelForgeEditor` module, so the commandlet and its
|
||||||
|
PNG/JSON/OBJ writers are editor/commandlet-only and cannot enter a Game or Shipping link. It creates
|
||||||
|
only transient settings, definitions, manager, generator, diff layer, and mesher objects; no authored
|
||||||
|
asset, live manager, generator, or diff layer is mutated. The transient world keeps
|
||||||
|
`WorldRadiusVoxels=0` and `InterStrateGapChunks=0`, preserving the lateral-region gate.
|
||||||
|
|
||||||
|
- **Render.** Render obtains the player-fit seed/mask from the shared measurement pass, then calls
|
||||||
|
the canonical `UVoxelMarchingCubesMesher` once per 32³ tile for the bounded region. The aggregate
|
||||||
|
mesh is merged in fixed Z/Y/X tile order, indexed into a deterministic CPU BVH, and rasterised
|
||||||
|
from eight player-fit viewpoints with a depth buffer and fixed Lambert lights. The per-pixel path
|
||||||
|
never calls `GetDensityAt`; `-renderstep` and the old bisection fields remain only as compatibility
|
||||||
|
metadata. Each view overlays a projected 1.76 m human-height marker and the JSON records mesh,
|
||||||
|
acceleration, per-view raster timings, and marker projection status.
|
||||||
|
- **Walk.** `VF_MeasurePlayerFitWalkWithSampler` builds the existing step-1 capsule/floor-fit stencil
|
||||||
|
and captures that exact sampled grid plus player-fit mask for the run. It uses the same
|
||||||
|
six-neighbour/full-resolution route gate as the existing diagnostics. Its
|
||||||
|
deterministic depth-first agent walk counts metres travelled (including branch backtracking),
|
||||||
|
dead ends per 100 m, the arrival component's reachable fit volume, and traversed edges touching a
|
||||||
|
narrow-gap proxy below 1.5× the 0.68 m capsule width. The proxy is explicitly axis-aligned air span;
|
||||||
|
it is not a second player-fit or movement definition. The explorer passes the shared measurement
|
||||||
|
`MaxCells` refusal at a 32,000,000-cell default cap, with a conservative 20-byte/cell estimate
|
||||||
|
against a documented 768 MiB working-memory budget. Reachable fit volume is explicitly the bounded
|
||||||
|
fitted route window because `WorldRadiusVoxels=0` deliberately has no finite whole-world volume.
|
||||||
|
- **Export.** `UVoxelMarchingCubesMesher::GenerateMesh` is still the only mesher. Because that
|
||||||
|
canonical entry point deliberately clamps one call to a 32³ tile, a 128³ request is four-by-four-
|
||||||
|
by-four canonical calls. Their returned vertices/triangles are merged once and the same aggregate
|
||||||
|
is consumed by both OBJ export and render; no second mesher or density evaluator exists. Export
|
||||||
|
reuses the walk's grid hand-off when a walk/render mode is present. The bounded result is metre-space
|
||||||
|
OBJ with source winding, voxel-space UVs, normals, and a manifest containing seed, archetype, slot,
|
||||||
|
bounds, and player dimensions. Tile skirts are disabled at the export boundary because they are a
|
||||||
|
render seam aid that would extend beyond the manifest box. A conservative preflight cap is checked
|
||||||
|
before the canonical mesher is called.
|
||||||
|
|
||||||
|
- **Budget.** The default wall-clock budget is 25 minutes; `-budget=` may lower or raise it up to a
|
||||||
|
hard 30-minute maximum. Setup, sampling, meshing, acceleration, rasterisation, and export check
|
||||||
|
the budget and report `truncated`, `truncated_during`, and `completed_modes` while retaining any
|
||||||
|
completed artifacts.
|
||||||
|
|
||||||
|
The report and export manifest use fixed-order writers and serialize twice in-process to assert the
|
||||||
|
deterministic contract. The fixed tile merge, BVH ordering, and raster traversal are deterministic;
|
||||||
|
timings are intentionally diagnostic metadata and do not affect world or image content. The browser
|
||||||
|
viewer remains a separate display-only consumer: it must load this OBJ/manifest and never evaluate
|
||||||
|
density or generate geometry.
|
||||||
|
|
||||||
## 9. Multiplayer model (listen-server first, dedicated-friendly)
|
## 9. Multiplayer model (listen-server first, dedicated-friendly)
|
||||||
|
|
||||||
> **Status: DESIGN ONLY — nothing is networked in-tree yet** (no `Replicated`/`HasAuthority`/RPCs; a
|
> **Status: transport is DESIGN ONLY — nothing is networked in-tree yet** (no `Replicated`/`HasAuthority`/RPCs; a
|
||||||
> single `GetPlayerPosition()` center; a local diff layer). This section locks in the invariants so the
|
> single `GetPlayerPosition()` center; a local diff layer). This section locks in the invariants so the
|
||||||
> streaming / AI / carve systems are built network-aware from the start instead of retrofitted. Target
|
> streaming / AI / carve systems are built network-aware from the start instead of retrofitted. Target
|
||||||
> **now = listen server** (the host is a player AND the authority); **dedicated server = future / out of
|
> **now = listen server** (the host is a player AND the authority); **dedicated server = future / out of
|
||||||
@@ -680,11 +1091,11 @@ generic biome field), not just SurfaceWorld. Empty `Biomes[]` ⇒ all-zero colou
|
|||||||
### 9.1 The core invariant — determinism means you NEVER replicate geometry
|
### 9.1 The core invariant — determinism means you NEVER replicate geometry
|
||||||
The world is a pure function of **(seed, strate layout)** (§8.4). So terrain is reconstructed identically
|
The world is a pure function of **(seed, strate layout)** (§8.4). So terrain is reconstructed identically
|
||||||
on every peer from a tiny amount of shared state — it is **never streamed as geometry over the wire**:
|
on every peer from a tiny amount of shared state — it is **never streamed as geometry over the wire**:
|
||||||
- Replicate the **effective seed + strate layout** ONCE (at join). Every client's `UVoxelGenerator` +
|
- Replicate the **effective seed + strate layout identity** ONCE (at join). For a cooked season the
|
||||||
`UVoxelStrateManager` then generate byte-identical terrain locally. (Today the seed lives on the data
|
layout is the reviewed manifest, not a pool shuffle: peers must load the same season asset and compare
|
||||||
asset / `UVoxelSettings::Seed`; MP must propagate the *host's* effective seed to joiners so their
|
`AVoxelWorld::GetCurrentSeasonContentHash()` before accepting play. For the legacy path the layout remains
|
||||||
generators match — a mismatch = divergent worlds. The strate layout is deterministic from seed, so it
|
deterministic from the effective seed. Every peer then generates terrain locally; geometry never crosses
|
||||||
syncs implicitly once the seed does.)
|
the wire. The transport/RPC that performs this join handshake remains out of tree.
|
||||||
- The **diff layer is the ONLY non-deterministic terrain state** (§3.9, [[voxelforge-difflayer-threading]])
|
- The **diff layer is the ONLY non-deterministic terrain state** (§3.9, [[voxelforge-difflayer-threading]])
|
||||||
→ it is the only thing that must sync. Since carving is a minor feature, this traffic is small.
|
→ it is the only thing that must sync. Since carving is a minor feature, this traffic is small.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# AUDIT-COLD-2026-09-14: read-only examination, with the orchestrator's verification notes
|
||||||
|
|
||||||
|
Produced by Codex (gpt-5.6-luna, max effort, read-only sandbox) on 2026-09-14 at HEAD `7a972f5`,
|
||||||
|
briefed with no project background. It did read the plugin-root docs, so it is not context-free,
|
||||||
|
but its findings are code-level. Findings are verbatim below. These notes record what the
|
||||||
|
orchestrator checked against the code afterwards.
|
||||||
|
|
||||||
|
## Orchestrator verification notes
|
||||||
|
- **#7 CONFIRMED, a determinism bug introduced by the tile-cache work (`2de603c`/`cfad3d1`).**
|
||||||
|
`VoxelGenerator.cpp:2968` moves `CP_OpStack` into the cache entry and resets `GTunnelCoreCache`.
|
||||||
|
A later request for the same `ChunkCoord` under the other `bTileCacheContext` misses the key
|
||||||
|
(`:2660`), but the refetch guard (`:2723-2725`) skips because chunk, owner and version are
|
||||||
|
unchanged, so evaluation uses the moved-from (empty) stack and core cache. The hit path sets
|
||||||
|
`CP_Chunk` to the shared key `(0,0,Z)` (`:2698`), so the spawn column `X=Y=0` reaches this state
|
||||||
|
after an ordinary hit, not only in a narrow timing window. Coarse tiles (LOD >= 3) covering the
|
||||||
|
origin can be evaluated wrongly depending on the order work reaches a worker.
|
||||||
|
- **#3 and #8 are real but narrower than stated.** In multiplayer, the server and client worlds share
|
||||||
|
seed and layout by design, so the cached values are identical. Contamination needs worlds that
|
||||||
|
genuinely differ under the same key: editor edits that do not bump the version, or two diff
|
||||||
|
layers with different contents at the same `ModsVersion`. Cheap to fix (add owner/layer identity).
|
||||||
|
- **#19 is not a defect under C++20** (UE 5.7), where left-shifting negative signed values is
|
||||||
|
defined. Overflow for extreme coordinates remains theoretical.
|
||||||
|
- **#21 matters again** if the gap/seal AllSolid proof (in progress) calls `HasAnyModInChunkRange`
|
||||||
|
per LOD0 tile.
|
||||||
|
- The others (#1, #2, #4-6, #9-18, #20, #22) were not independently re-verified. Verify each before
|
||||||
|
fixing it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Read-only audit report
|
||||||
|
|
||||||
|
I made no file changes and did not build or run the plugin. “VERIFIED” means the failure follows directly from the source path; “SUSPECT” means a runtime/compiler-dependent hazard that was not executed.
|
||||||
|
|
||||||
|
## Highest impact
|
||||||
|
|
||||||
|
1. **The density-volume worker races live generator mutation — P1 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `FScopedGenerationPause` (203–222), `AVoxelWorld::RebuildStrates` (551–582), `AVoxelWorld::ChangeSeed` (5569–5623).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDensityVolume.cpp` — `UVoxelDensityVolume::Reset` (101–120), `ProcessOneFill` (603–637).
|
||||||
|
|
||||||
|
`FScopedGenerationPause` waits for ordinary generation tasks and decoration tasks, but not the dedicated density-volume fill thread. That thread reads the raw `Generator` pointer and calls `GetDensityAt` while `RebuildStrates` or `ChangeSeed` mutates the generator and strata manager.
|
||||||
|
|
||||||
|
A fill in progress during a PIE edit or seed change can therefore read partially rebuilt arrays or mixed old/new settings. This is an actual data race with crash/garbled-density potential. `Reset` also clears `PendingFills` but not the already queued `FillQueue`, causing stale work to continue after reset.
|
||||||
|
|
||||||
|
2. **Decoration tasks can use a destroyed content manager — P1 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelContentManager.cpp` — `UVoxelContentManager::BeginDestroy` (58–62), `NotifyShutdown` (65–75), `WaitForDecorationTasks` (78–85), `LaunchDecoTasks` (327–415).
|
||||||
|
|
||||||
|
Tasks capture raw `this` and later access `bShuttingDown` and `DecoResults`. Shutdown waits only three seconds, ignores the timeout result, and `BeginDestroy` immediately calls `Super::BeginDestroy`.
|
||||||
|
|
||||||
|
Any decoration task exceeding the timeout can later enqueue into the destroyed manager. The global task counter also includes tasks belonging to unrelated worlds, so one world can time out because another world is busy.
|
||||||
|
|
||||||
|
3. **The diff snapshot TLS cache is not keyed by its owning diff layer — P1 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `UVoxelGenerator::GetDensityAt` (3069–3097).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelDiffLayer.h` — `GetModsVersion` and `ModsVersion` (241, 289).
|
||||||
|
|
||||||
|
`thread_local FDiffSlot DiffSlots[64]` considers only chunk coordinates and `ModsVersion`. It does not include the `UVoxelDiffLayer*`.
|
||||||
|
|
||||||
|
Two PIE worlds commonly have the same chunk and the same per-layer version—for example, both reach version 2 after their first edit. If a worker samples world A first, world B can hit A’s cached modification snapshot and apply the wrong edits. This produces cross-world terrain contamination dependent on worker history.
|
||||||
|
|
||||||
|
4. **Fixed-only strata configurations are silently disabled — P1 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `AVoxelWorld::BeginPlay` (1294–1305).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelStrateManager.cpp` — `UVoxelStrateManager::Initialize` (1060–1244).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — fallback generation in `GetDensityAt` (3030–3039).
|
||||||
|
|
||||||
|
`BeginPlay` creates a strata manager only when a season is assigned or `StratePool.Num() > 0`. It does not test `FixedStrates.Num()`.
|
||||||
|
|
||||||
|
A settings asset with valid fixed strata but no season and no pool therefore receives no strata manager. The generator takes its generic fallback path, ignoring the authored fixed definitions.
|
||||||
|
|
||||||
|
5. **Settings rebuilds leave dependent systems with inconsistent snapshots — P1/P2 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `RebuildStrates` (551–582), `PostEditChangeProperty` (885–895), `OnObjectModifiedInEditor` (898–985).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `InitializeSettings` (2079–2088).
|
||||||
|
|
||||||
|
`RebuildStrates` reinitializes the strata manager but does not reinitialize generator settings. `PostEditChangeProperty` only regenerates chunks. Asset-change handling recognizes strata and terrain-op definitions, but not general settings or biome assets.
|
||||||
|
|
||||||
|
Changing seed, world radius, edge sealing, the settings object reference, or related configuration can leave the manager using new values while the generator, content manager, or density volume still use old snapshots. `ChangeSeed` has a separate correct reinitialization path, but the other rebuild paths do not. This also contradicts the `RebuildStrates` documentation in `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelWorld.h` (572–575), which promises to re-read all settings.
|
||||||
|
|
||||||
|
6. **Invalid strata initialization destroys the previous valid layout — P1/P2 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelStrateManager.cpp` — `UVoxelStrateManager::Initialize` (1060–1205).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `RebuildStrates` (562–576), `OnObjectModifiedInEditor` (975–985).
|
||||||
|
|
||||||
|
`Initialize` empties `StrateLayout`, `SeasonStrates`, and related state before all season/layout validation has succeeded. If an assigned season is missing, malformed, or contains invalid bounds, initialization returns false after the old layout has already been discarded.
|
||||||
|
|
||||||
|
`RebuildStrates` then leaves existing meshes visible while future generation queries see an empty or partial manager. The editor asset-modification path ignores the initialization return value and regenerates anyway, making a transient invalid asset capable of replacing the working world with fallback/empty terrain.
|
||||||
|
|
||||||
|
## Material correctness and determinism issues
|
||||||
|
|
||||||
|
7. **The operator-stack cache is stale when tile-cache context changes — P2 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `UVoxelGenerator::GetDensityAt` (2218–2230, 2255–2261, 2320–2324, 2393–2407, 2551–2573, 2600–2672).
|
||||||
|
|
||||||
|
The tunnel cache key includes whether tile-cache mode is active, but the rebuild guard does not. A fine-LOD request can build and move the operator stack into the cache, leaving the worker-local `CP_OpStack` empty. A later request for the same chunk under a different tile-cache context misses the cache key but does not rebuild the stack because chunk, manager, and layout version are unchanged.
|
||||||
|
|
||||||
|
The empty or mismatched stack is then used for density evaluation. LOD request order can therefore change terrain results.
|
||||||
|
|
||||||
|
8. **Several surface and biome TLS caches omit generator/context identity — P2 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `FSurfaceColumnCache::Acquire` (274–328), `GetDensityAt` (2692–2705), `GetSurfaceHeightAt` (5149–5166), `ClassifyTile` (6014–6023), `GetBiomeMaterialAt` (7019–7034).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelBiomeTypes.h` — `FChunkBiomeCache::Contains` (240–245).
|
||||||
|
|
||||||
|
These worker-local caches use combinations of chunk, seed, strata key, and layout version, but not the owning generator, strata manager, or biome context. Two worlds can legitimately share all those scalar keys while having different terrain or biome parameters.
|
||||||
|
|
||||||
|
When a pooled worker handles both worlds, the second world can receive cached surface columns, biome classification, or material data from the first. Direct asset changes that do not advance the relevant version have the same stale-cache problem.
|
||||||
|
|
||||||
|
9. **Native shaft/island topology caches omit parameters used to build them — P2 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `GetVerticalShaftDensity` (7090–7106, 7211–7219, 7392–7413), `GetFloatingIslandDensity` (7518–7554).
|
||||||
|
|
||||||
|
The shaft cache key omits `LedgeSpacing` and `LedgeDepth`, although both affect connector and ledge placement. The floating-island cache key omits `BoundarySealThickness`, although it affects cached vertical spread.
|
||||||
|
|
||||||
|
Changing these values—or using two contexts with identical keyed values but different omitted values—reuses old topology on the same worker.
|
||||||
|
|
||||||
|
10. **Completed pre-edit decoration results can resurrect removed objects — P2 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `ApplyModification` (5319–5375).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelContentManager.cpp` — `LaunchDecoTasks` (381–404), `ProcessDecoResults` (769–807), `MergeCellResult` (815–862), `RemoveDecorationsInSphere` (1249–1260).
|
||||||
|
|
||||||
|
Applying a live diff removes currently applied decorations but does not invalidate queued/in-flight decoration builds or associate them with a diff version. `BuildId` only identifies decoration-region rebuilds.
|
||||||
|
|
||||||
|
Sequence: a task computes old grass/props, the edit removes current instances, then the old result is drained and accepted. The old decorations reappear over the edited terrain until another rebuild occurs.
|
||||||
|
|
||||||
|
11. **Landmark conditions are evaluated without the actual biome context — P2 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelContentManager.cpp` — `UpdateDecorations` (153–263), `UpdateLandmarks` (1263–1310), `SpawnFromProfile` (1101–1137).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `EvaluateTerrainConditions` (6682–6717).
|
||||||
|
|
||||||
|
Normal decoration updates populate `FDecoContext::BiomeCtx`; landmark updates construct a context but never set `BiomeCtx`. A landmark profile using biome, moisture, relief, or biome-border conditions therefore evaluates with the default/invalid biome map.
|
||||||
|
|
||||||
|
On a non-default biome map, landmarks can spawn or fail to spawn differently from ordinary decorations.
|
||||||
|
|
||||||
|
12. **Decoration cleanup uses local coordinates as world coordinates — P2 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `ApplyModification` (5369–5375); `WorldToLocalVoxel` and `LocalVoxelToWorld` (2314–2321).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelContentManager.cpp` — `RemoveInstancesInContent` (1231–1242).
|
||||||
|
|
||||||
|
`Modification.Center` is a local voxel coordinate, but cleanup computes `Center * VOXEL_SIZE` and passes it to a world-space overlap query. For a translated actor, the query is centered at the wrong location.
|
||||||
|
|
||||||
|
The same cleanup also approximates boxes and capsules with only `Modification.Radius`, which does not cover a long capsule or a box’s full extent. Existing decorations can remain over an edit or unrelated origin decorations can be removed.
|
||||||
|
|
||||||
|
13. **Decoration application order is timing-dependent — P2 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelContentManager.cpp` — `LaunchDecoTasks` (327–415), `ProcessDecoResults` (769–810), `ApplyRegion` (885–952), `RebuildDesiredCells` (281–324).
|
||||||
|
|
||||||
|
Tasks enqueue results in completion order. Results append actor spawns and HISM transforms in that order; mesh buckets are iterated through a `TMap`. Equal-distance pending regions also have no deterministic tie-breaker.
|
||||||
|
|
||||||
|
The same candidate set can therefore produce different actor spawn order, NetGUID/side-effect order, HISM instance indices, and internal clustering across runs or machines.
|
||||||
|
|
||||||
|
14. **The passage shortlist version is not advanced when passages are cleared — P2/P3 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelStrateManager.cpp` — `GeneratePassages` (1611–1620, 2477), `EvaluateModifierSDF` (2488–2524), `VF_GetNearbyPassages` (294–338).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelStrateManager.h` — `GetLayoutVersion` and `PassagesVersion` (250, 515, 519).
|
||||||
|
|
||||||
|
`GeneratePassages` empties `Passages` and returns early for an empty layout without incrementing `PassagesVersion`. Existing worker TLS nearby-passage caches still match the manager lifetime, chunk, and unchanged version.
|
||||||
|
|
||||||
|
After a previously valid layout becomes empty, `EvaluateModifierSDF` can reuse indices into the now-empty array and dereference invalid passage data. This is an edge configuration, but it is reachable through the invalid-layout paths above.
|
||||||
|
|
||||||
|
## Input-contract and performance issues
|
||||||
|
|
||||||
|
15. **Diff modifications accept nonfinite and invalid geometry — P2/P3 — VERIFIED for malformed input**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDiffLayer.cpp` — `CanModify` (20–44), `ApplyModification` (63–96), `EvaluateMods` (191–248).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `CarveAtPosition` (5301–5307).
|
||||||
|
|
||||||
|
There is no finite-value validation for strength, radius, centers, endpoints, or extents. A NaN strength survives `FMath::Abs`, then propagates through `TotalOffset` and density evaluation. Infinite strength produces infinite density. Negative or zero radii can consume budget while producing no meaningful spherical modification.
|
||||||
|
|
||||||
|
This is reachable from C++ or malformed Blueprint/runtime data and permanently poisons the diff layer until cleared.
|
||||||
|
|
||||||
|
16. **The public diff API contradicts itself about coordinate units — P2/P3 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelDiffLayer.h` — `FVoxelModification::Center` (64), `CapsuleEnd` (86).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelWorld.h` — `ApplyModification` documentation (334–340).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDiffLayer.cpp` — `GetWorldBounds` (88–96).
|
||||||
|
|
||||||
|
The modification struct comments describe the center as world-space, while the low-level world API and implementation interpret it as voxel-space. The helper functions explicitly convert world centimeters into voxel coordinates.
|
||||||
|
|
||||||
|
A caller following the struct comment and supplying centimeters gets an edit displaced by a factor of `VOXEL_SIZE`.
|
||||||
|
|
||||||
|
17. **Diff budget accounting underestimates boxes and long capsules — P3 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `CarveBox`/`FillBox` (5379–5398), capsule helpers (5401 onward).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDiffLayer.cpp` — `CanModify` (34–39).
|
||||||
|
|
||||||
|
The budget charges every shape as a sphere with volume based on `Modification.Radius`. Boxes use the maximum half-extent as a proxy; capsules use the tube radius even when endpoints are very far apart.
|
||||||
|
|
||||||
|
A capsule with radius 3 and endpoints 1000 voxels apart is charged like a tiny sphere despite affecting a long volume and many chunks. Large edits can therefore bypass the intended modification budget and cause excessive remeshing.
|
||||||
|
|
||||||
|
18. **Decoration radius/spacing changes are ignored while the player is stationary — P3 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelContentManager.cpp` — `UpdateDecorations` (199–256), `RebuildDesiredCells` (265–325).
|
||||||
|
|
||||||
|
Radius and spacing are refreshed every update, but desired-cell rebuilding occurs only when `PlayerCell != LastDecoCell`.
|
||||||
|
|
||||||
|
Shrinking the radius leaves distant regions loaded; expanding it does not enqueue new regions; changing spacing leaves existing regions at the old grid layout. Movement eventually repairs the state, but a stationary player sees stale decoration coverage.
|
||||||
|
|
||||||
|
19. **Signed left shifts of negative chunk coordinates are undefined — P3 — SUSPECT**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `BuildDesiredTiles` (3498–3548).
|
||||||
|
|
||||||
|
Streaming computes coordinates with expressions such as `T.Z << L`. Ordinary underground tiles have negative `T.Z`, so this left-shifts a negative signed integer. Large positive coordinates can also overflow.
|
||||||
|
|
||||||
|
The exact manifestation is compiler/optimization-dependent, so I classify this as SUSPECT rather than a fully end-to-end verified runtime failure.
|
||||||
|
|
||||||
|
20. **Interleaved transitions can divide by zero — P3 — VERIFIED math path**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelStrateDefinition.h` — `TransitionBlendChunks` (99–101).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelStrateManager.cpp` — `GetGenerationParams` (5673, 5728–5753, 5826).
|
||||||
|
|
||||||
|
The property metadata clamps the value in the editor, but runtime/cooked data can still contain zero. The interleaved transition path divides warped distance by `EffectiveBlend` without a zero guard.
|
||||||
|
|
||||||
|
A zero-width transition can produce Inf/NaN blend values and invalid generation parameters.
|
||||||
|
|
||||||
|
21. **Tile classification scans the entire modification map — P3 — VERIFIED**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `ClassifyTileUncached` (5785–5790).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDiffLayer.cpp` — `HasAnyModInChunkRange` (170–188).
|
||||||
|
|
||||||
|
Classification calls a routine that walks every `ChunkMods` key under the read lock instead of using the existing per-chunk grouping to narrow the query.
|
||||||
|
|
||||||
|
After many edits, each tile classification becomes O(number of modified chunks), increasing lock hold time and making edit-heavy worlds disproportionately expensive.
|
||||||
|
|
||||||
|
22. **Operator documentation is internally stale — P3 — VERIFIED documentation contradiction**
|
||||||
|
|
||||||
|
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelDensityOp.h` (1–3, 86–92).
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `UVoxelGenerator::GetDensityAt` (2393–2600).
|
||||||
|
|
||||||
|
The header says the density-op system is not wired into `GetDensityAt`, while later comments say all eight operators now have the stack path. The generator does in fact execute the operator stack.
|
||||||
|
|
||||||
|
This is not itself a runtime defect, but it is misleading maintenance documentation around a central generation path.
|
||||||
|
|
||||||
|
The most urgent issues are the uncoordinated density-volume thread, decoration-task lifetime, cross-world TLS diff cache, and incomplete settings/layout rebuilds.
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
# AUDIT-SOL-2026-09-11.md — read-only audit of experimental @ aa62971
|
||||||
|
|
||||||
|
Performed by Codex **gpt-5.6-sol**, reasoning effort xhigh, **sandbox read-only** — no file was
|
||||||
|
created, modified or deleted. Commissioned by Jahni after the experimental branch showed two
|
||||||
|
regressions against main: generation much slower, and the player falling through the world.
|
||||||
|
|
||||||
|
Verbatim report follows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Audit conclusion
|
||||||
|
|
||||||
|
The two regressions share a cause, but they are not the same bug:
|
||||||
|
|
||||||
|
1. **The remaining generation slowdown is primarily in density evaluation.** The composable operator representation is still being interpreted in the sampling hot path, with rich per-sample state, block-wide operator passes, and several corrective passes afterward. The outer tile classifier is not where the original ~8× went.
|
||||||
|
|
||||||
|
2. **There is no “collision-ready before player-ready” invariant.** Neither branch explicitly had one. `main` was fast enough—and had simpler spawn geometry—so the race remained hidden. `experimental` made the race long enough to become visible, especially because the new origin room’s air and supporting floor can occupy different tiles.
|
||||||
|
|
||||||
|
I disagree with the inference that the old behavior proves there was a correctness barrier. The observation is valid, but the code shows a timing dependency masquerading as a guarantee.
|
||||||
|
|
||||||
|
## 1. Density evaluation is the dominant performance regression
|
||||||
|
|
||||||
|
**Status: Verified architecture; strong attribution. Highest expected performance impact.**
|
||||||
|
|
||||||
|
A streamed LOD0 tile asks for a 35³ haloed density grid: **42,875 sample positions** ([VoxelMarchingCubesMesher.cpp:227](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:227>)).
|
||||||
|
|
||||||
|
At `34f06df`, that hot path was approximately:
|
||||||
|
|
||||||
|
- Resolve one cached chunk parameter set.
|
||||||
|
- Enter one direct archetype switch.
|
||||||
|
- Keep most intermediate values in local variables.
|
||||||
|
- Skip the detailed cave suite when `bNearCaveSurface` was false—historically around 70% of deep-solid samples.
|
||||||
|
- Apply disturbances/differences and return.
|
||||||
|
|
||||||
|
The relevant historical path is `34f06df:Source/VoxelForge/Private/VoxelGenerator.cpp:438-631` and `:947-1621`.
|
||||||
|
|
||||||
|
Current `experimental` instead:
|
||||||
|
|
||||||
|
- Allocates/initializes an `FVoxelOpSample` for every position, containing density, SDF, floor metadata, tunnel metadata, pointers and indices ([VoxelDensityOp.h:327](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelDensityOp.h:327>)).
|
||||||
|
- Determines a block-wide active-operator list.
|
||||||
|
- Traverses the sample array once per active operator.
|
||||||
|
- Uses scalar per-sample fallback implementations for most concrete operators ([VoxelDensityOpStack.cpp:51](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:51>)).
|
||||||
|
- Copies block scratch into the session cache and later back into output.
|
||||||
|
- Still calls the scalar `GetDensityAt` wrapper for every grid point, so routing, coordinate setup and the post-stack structural tail remain per-sample ([VoxelGenerator.cpp:1690](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1690>), [VoxelGenerator.cpp:2165](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2165>)).
|
||||||
|
|
||||||
|
Loop inversion removed much of the virtual dispatch cost, but it did not restore the old locality or short-circuiting. An operator whose interval intersects the surface anywhere in the block can be run across the whole block; the old path could decide per sample that expensive detail work was irrelevant.
|
||||||
|
|
||||||
|
That is the architectural mistake: **the composable authoring model was carried directly into execution instead of being compiled/lowered into a fused evaluator.** Composition is not itself the problem. Interpreting it through rich mutable sample records is.
|
||||||
|
|
||||||
|
The 128³ comparison strongly supports this conclusion: the editor export path owns a shared density grid and does not force the streaming outer-classifier path ([VoxelForgeExploreCommandlet.cpp:1700](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp:1700>)), yet it still takes approximately 1.04 seconds versus 0.130 seconds before the operator-stack wire-in.
|
||||||
|
|
||||||
|
The later features add cost, particularly the D-shaped floors, but the history indicates roughly a 17% net penalty there—not anything close to the remaining 8×.
|
||||||
|
|
||||||
|
## 2. The player can become active before any supporting collision exists
|
||||||
|
|
||||||
|
**Status: Verified. Highest correctness impact.**
|
||||||
|
|
||||||
|
`BeginPlay` initializes systems, but it does not synchronously request a spawn support tile, wait for a mesh, or gate pawn gravity/control ([VoxelWorld.cpp:988](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:988>)).
|
||||||
|
|
||||||
|
Streaming begins from `Tick`, and only when `GetPlayerPosition()` is not exactly `FVector::ZeroVector` ([VoxelWorld.cpp:1167](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1167>), [VoxelWorld.cpp:1359](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1359>)). Thus an exact world-origin pawn is indistinguishable from “no pawn.” Even when Z is nonzero and that specific sentinel is avoided, there is still no bootstrap barrier.
|
||||||
|
|
||||||
|
The supplied play trace shows:
|
||||||
|
|
||||||
|
- `BeginPlay` finished at about **50 ms**.
|
||||||
|
- The first desired-tile pass did not begin until about **439 ms**.
|
||||||
|
- The first LOD0 nonempty/mixed result was not ready until about **1.093 s**.
|
||||||
|
- That result still represented mesh/collision submission, not collision-cook completion.
|
||||||
|
|
||||||
|
At ordinary gravity, one second is enough to fall roughly five metres.
|
||||||
|
|
||||||
|
There are three additional gaps:
|
||||||
|
|
||||||
|
- A tile is inserted into `LoadedTiles` before its empty/nonempty application path completes ([VoxelWorld.cpp:1532](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1532>)).
|
||||||
|
- Only LOD0 receives collision ([VoxelWorld.cpp:3109](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:3109>)). Coarser visible terrain can therefore be present while the corresponding collidable LOD0 tile is still queued.
|
||||||
|
- Current architecture documentation says RMC collision is asynchronously cooked ([ARCHITECTURE.md:817](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:817>)), but `VoxelWorld` records no cook-complete state. Its `apply_s` measurement ends after submission and says nothing about when physics can actually collide.
|
||||||
|
|
||||||
|
I compared the equivalent paths on `main`. It also:
|
||||||
|
|
||||||
|
- Starts streaming from `Tick`.
|
||||||
|
- Uses the zero-position guard.
|
||||||
|
- Marks a tile loaded before handling its empty result.
|
||||||
|
- Enables collision only for LOD0.
|
||||||
|
- Has no spawn collision barrier or collision-cook acknowledgement.
|
||||||
|
|
||||||
|
So no explicit guarantee was removed inside `VoxelWorld`. What `experimental` broke was the **implicit timing budget** that had hidden this race.
|
||||||
|
|
||||||
|
The new origin-room geometry makes it worse. The first central LOD0 request in the trace is uniformly air and correctly receives no component. That is not itself an error: the player occupies air, while collision belongs to the neighboring floor/wall surface tile. With the current default origin geometry, the floor can lie near or below the lower tile boundary ([VoxelPassageGeometry.h:365](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelPassageGeometry.h:365>)). The supporting tile therefore has to arrive and finish cooking independently.
|
||||||
|
|
||||||
|
`CollisionOnlyTiles` does not close this gap. It controls presentation of already generated LOD0 components for non-player anchors; it does not synthesize or reserve collision underneath the local pawn. The supplied trace records zero collision-only tiles.
|
||||||
|
|
||||||
|
## 3. The mesher’s nested block classifier is probably self-inflicted overhead
|
||||||
|
|
||||||
|
**Status: Mechanism verified; net cost strongly suspected. High performance impact.**
|
||||||
|
|
||||||
|
The code contradicts its own guidance:
|
||||||
|
|
||||||
|
- The mesher header says the optional 8³ refinement should remain disabled during streaming because repeated proofs may cost more than they save ([VoxelMarchingCubesMesher.h:150](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h:150>)).
|
||||||
|
- `VoxelWorld` unconditionally enables it ([VoxelWorld.cpp:1102](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1102>)).
|
||||||
|
|
||||||
|
For every outer-Mixed tile, the mesher may run up to 64 additional `ClassifyTile` calls ([VoxelMarchingCubesMesher.cpp:254](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:254>)). On cache misses, each call builds another operator stack ([VoxelGenerator.cpp:5779](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:5779>)).
|
||||||
|
|
||||||
|
In `MyPlay`:
|
||||||
|
|
||||||
|
- 298 LOD0 tiles were outer-Mixed.
|
||||||
|
- All 298 produced geometry.
|
||||||
|
- Therefore every one eventually populated the full density grid.
|
||||||
|
- The nested classifier could only avoid marching-cubes cell processing in uniform sub-blocks; it could not avoid the main density grid once any surface sub-block was found.
|
||||||
|
|
||||||
|
This is precisely the workload where the header warns it may lose.
|
||||||
|
|
||||||
|
There is also an instrumentation trap: the outer cache counters are snapped before meshing. Nested classifier calls occur inside the measured `mesh_s` interval ([VoxelWorld.cpp:2762](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:2762>)). Consequently, the reported “cache hits 0” does **not** describe classifier activity originating inside the mesher.
|
||||||
|
|
||||||
|
This deserves the first controlled A/B test: identical trace and seed with only `bUseBlockEarlyOut` changed, while separately counting outer and nested classifier calls.
|
||||||
|
|
||||||
|
## 4. The outer whole-tile classifier is probably earning its keep
|
||||||
|
|
||||||
|
**Status: Verified workload behavior; exact counterfactual unmeasured.**
|
||||||
|
|
||||||
|
I would not remove the outer classifier based on the 95 ms figure.
|
||||||
|
|
||||||
|
It rejected **547 of 845 LOD0 tiles**, or 64.7%, before meshing:
|
||||||
|
|
||||||
|
- 484 uniformly solid.
|
||||||
|
- 63 uniformly air.
|
||||||
|
- 298 mixed, all of which produced geometry.
|
||||||
|
|
||||||
|
A full 35³ grid for those rejected tiles would represent up to 23.45 million density positions. Previous history also shows that the outer classifier eliminated multi-second empty-tile fallbacks.
|
||||||
|
|
||||||
|
The 95 ms is not a clean CPU cost. It is elapsed wall time inside a `BackgroundNormal` worker task, so it includes preemption and contention. A second trace in the repository from the same current code has an LOD0 classifier median around **8.45 ms**, versus **94.7 ms** in the moving/rendering owner session. The real play number is important for latency, but it should not be interpreted as intrinsic classifier compute.
|
||||||
|
|
||||||
|
My assessment is:
|
||||||
|
|
||||||
|
- **Outer whole-tile classification:** valuable under the current expensive evaluator.
|
||||||
|
- **Nested 8³ block classification:** likely negative in this workload.
|
||||||
|
- **Long-term:** making density evaluation cheap again may change the outer classifier’s break-even point.
|
||||||
|
|
||||||
|
## 5. Movement does not cancel obsolete generation work
|
||||||
|
|
||||||
|
**Status: Verified mechanism; contribution to running falls strongly suspected.**
|
||||||
|
|
||||||
|
The abort predicate only notices shutdown or an explicit generation pause ([VoxelWorld.h:764](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelWorld.h:764>)). It does not abort a tile because the player has moved and the tile is no longer desired.
|
||||||
|
|
||||||
|
Desired work is globally distance-sorted using an artificial chunk-center position rather than the exact pawn position, with no strong collision-critical/L0 priority ([VoxelWorld.cpp:1864](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1864>)). Up to 14 background tasks can therefore be occupied by old, coarse, or non-supporting work while the player’s new LOD0 floor waits.
|
||||||
|
|
||||||
|
By the steady point in the supplied session there were 1,481 loaded tiles for 899 currently desired tiles, with roughly 582 on the unload/transition side. Retaining old rendered tiles is not automatically wrong, but generation already launched for the old neighborhood is not preempted in favor of collision-critical tiles.
|
||||||
|
|
||||||
|
This is a credible explanation for falls while running. The trace cannot prove it because it omits tile coordinates, pawn position and collision-cook completion.
|
||||||
|
|
||||||
|
## 6. The verdict cache is mismatched to normal streaming
|
||||||
|
|
||||||
|
**Status: Implementation verified; negative startup ROI suspected. Medium impact.**
|
||||||
|
|
||||||
|
The cache:
|
||||||
|
|
||||||
|
- Is global and protected by one lock.
|
||||||
|
- Advances a clock and linearly scans entries.
|
||||||
|
- Holds only 512 proofs ([VoxelGenerator.cpp:1512](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1512>)).
|
||||||
|
- Keys include sample step, limiting reuse across LODs.
|
||||||
|
- Does not store a useful outer proof for Mixed tiles.
|
||||||
|
|
||||||
|
Fresh streaming normally visits unique regions, which explains the zero outer hits in both supplied traces. Nested block classifiers can then insert many short-lived proofs, pay the lock/scan costs, and evict one another—without appearing in the trace’s cache telemetry.
|
||||||
|
|
||||||
|
It demonstrably helped a deliberately repeated commandlet case, but that access pattern is not representative of a moving player. I would treat it as unproven or likely negative during startup until nested calls are measured.
|
||||||
|
|
||||||
|
## 7. Field semantics are now implemented in several overlapping places
|
||||||
|
|
||||||
|
**Status: Verified duplication; cost not isolated. Medium impact and high maintenance risk.**
|
||||||
|
|
||||||
|
The effective field is no longer simply “the operator stack”:
|
||||||
|
|
||||||
|
1. The operator stack evaluates the composable field.
|
||||||
|
2. The classifier maintains parallel interval/proof semantics.
|
||||||
|
3. `GetDensityAt` performs a post-stack repair pipeline for origin floors, passage structure, passage floors and room floors.
|
||||||
|
|
||||||
|
For example, `FRoomGraphSource` evaluates the cached room graph SDF and then separately traverses tunnels again for metadata ([VoxelDensityOpStack.cpp:4234](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:4234>)). The common tail then reasserts origin and passage structure/floors ([VoxelGenerator.cpp:2244](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2244>)).
|
||||||
|
|
||||||
|
This is the clearest sign of overengineering: later correctness fixes are compensating for where an operation sits in the pipeline. It creates duplicated traversal, makes interval proofs harder to trust, and prevents the stack from being the single semantic source of truth.
|
||||||
|
|
||||||
|
Runtime also does not use the commandlet’s shared density-grid ownership, so adjacent tiles repeat halo/boundary samples. Earlier measurement put this duplication around 18%. It existed in the old architecture too, so it did not cause the original regression, but every repeated sample is now much more expensive.
|
||||||
|
|
||||||
|
## 8. There are two lower-confidence ways to get a permanent hole
|
||||||
|
|
||||||
|
**Status: Verified risk paths; not demonstrated by this trace.**
|
||||||
|
|
||||||
|
First, whole-tile AllAir/AllSolid verdicts are trusted without exact validation. They become loaded `bEmpty` tiles with no component. Internal block verdicts are later checked against the full grid when meshing occurs, but an outer uniform verdict has no equivalent safety net.
|
||||||
|
|
||||||
|
The supplied aggregate argues against a widespread problem—every LOD0 Mixed tile produced geometry—but it cannot establish whether the floor tile beneath the pawn was incorrectly declared uniform because tile coordinates were not logged.
|
||||||
|
|
||||||
|
Second, `ApplyMeshToTile` has no success return. If runtime-mesh initialization fails, the function returns while the tile remains recorded as loaded, so it will not naturally retry ([VoxelWorld.cpp:3064](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:3064>)). I found no trace evidence that this occurred.
|
||||||
|
|
||||||
|
The normal `bAborted` result path is not the likely culprit: it removes the pending record without marking the tile loaded, allowing a later desired pass to reschedule it. There is, however, an editor pause corner case where a worker can exit without enqueuing any result and leave its pending entry behind if pause acquisition times out. That is a real bug, but it does not match this epoch-zero play session.
|
||||||
|
|
||||||
|
## What I would prove next
|
||||||
|
|
||||||
|
In order:
|
||||||
|
|
||||||
|
1. A/B the forced nested block classifier on the exact owner trace, recording outer versus nested calls, proof outcomes, stack builds and cache probes.
|
||||||
|
2. Add collision-specific timestamps: requested, mesh submitted, collision cook started/completed, component registered, plus tile and pawn coordinates.
|
||||||
|
3. Force exact meshing/validation for every supposedly uniform LOD0 tile in the spawn neighborhood to rule a false classifier proof in or out.
|
||||||
|
4. Benchmark a compiled/fused evaluator that retains the authored operator graph but restores per-sample short-circuiting and avoids spilling `FVoxelOpSample` through memory for every operator.
|
||||||
|
5. Prioritize a small LOD0 support neighborhood and require collision-cook completion before releasing the pawn. Faster generation alone cannot make the current lifecycle correct.
|
||||||
|
|
||||||
|
The blunt version: **the operator abstraction leaked into the innermost loop, and streaming treats “work submitted/applied” as “safe to stand on.”** The classifier work then became a collection of compensators for the expensive evaluator, some useful and some apparently duplicative.
|
||||||
|
|
||||||
|
This was a read-only audit. No files were created, modified or deleted; `git status` remains clean on `experimental` at `aa62971`.
|
||||||
|
tokens used
|
||||||
|
782 274
|
||||||
|
# Audit conclusion
|
||||||
|
|
||||||
|
The two regressions share a cause, but they are not the same bug:
|
||||||
|
|
||||||
|
1. **The remaining generation slowdown is primarily in density evaluation.** The composable operator representation is still being interpreted in the sampling hot path, with rich per-sample state, block-wide operator passes, and several corrective passes afterward. The outer tile classifier is not where the original ~8× went.
|
||||||
|
|
||||||
|
2. **There is no “collision-ready before player-ready” invariant.** Neither branch explicitly had one. `main` was fast enough—and had simpler spawn geometry—so the race remained hidden. `experimental` made the race long enough to become visible, especially because the new origin room’s air and supporting floor can occupy different tiles.
|
||||||
|
|
||||||
|
I disagree with the inference that the old behavior proves there was a correctness barrier. The observation is valid, but the code shows a timing dependency masquerading as a guarantee.
|
||||||
|
|
||||||
|
## 1. Density evaluation is the dominant performance regression
|
||||||
|
|
||||||
|
**Status: Verified architecture; strong attribution. Highest expected performance impact.**
|
||||||
|
|
||||||
|
A streamed LOD0 tile asks for a 35³ haloed density grid: **42,875 sample positions** ([VoxelMarchingCubesMesher.cpp:227](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:227>)).
|
||||||
|
|
||||||
|
At `34f06df`, that hot path was approximately:
|
||||||
|
|
||||||
|
- Resolve one cached chunk parameter set.
|
||||||
|
- Enter one direct archetype switch.
|
||||||
|
- Keep most intermediate values in local variables.
|
||||||
|
- Skip the detailed cave suite when `bNearCaveSurface` was false—historically around 70% of deep-solid samples.
|
||||||
|
- Apply disturbances/differences and return.
|
||||||
|
|
||||||
|
The relevant historical path is `34f06df:Source/VoxelForge/Private/VoxelGenerator.cpp:438-631` and `:947-1621`.
|
||||||
|
|
||||||
|
Current `experimental` instead:
|
||||||
|
|
||||||
|
- Allocates/initializes an `FVoxelOpSample` for every position, containing density, SDF, floor metadata, tunnel metadata, pointers and indices ([VoxelDensityOp.h:327](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelDensityOp.h:327>)).
|
||||||
|
- Determines a block-wide active-operator list.
|
||||||
|
- Traverses the sample array once per active operator.
|
||||||
|
- Uses scalar per-sample fallback implementations for most concrete operators ([VoxelDensityOpStack.cpp:51](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:51>)).
|
||||||
|
- Copies block scratch into the session cache and later back into output.
|
||||||
|
- Still calls the scalar `GetDensityAt` wrapper for every grid point, so routing, coordinate setup and the post-stack structural tail remain per-sample ([VoxelGenerator.cpp:1690](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1690>), [VoxelGenerator.cpp:2165](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2165>)).
|
||||||
|
|
||||||
|
Loop inversion removed much of the virtual dispatch cost, but it did not restore the old locality or short-circuiting. An operator whose interval intersects the surface anywhere in the block can be run across the whole block; the old path could decide per sample that expensive detail work was irrelevant.
|
||||||
|
|
||||||
|
That is the architectural mistake: **the composable authoring model was carried directly into execution instead of being compiled/lowered into a fused evaluator.** Composition is not itself the problem. Interpreting it through rich mutable sample records is.
|
||||||
|
|
||||||
|
The 128³ comparison strongly supports this conclusion: the editor export path owns a shared density grid and does not force the streaming outer-classifier path ([VoxelForgeExploreCommandlet.cpp:1700](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp:1700>)), yet it still takes approximately 1.04 seconds versus 0.130 seconds before the operator-stack wire-in.
|
||||||
|
|
||||||
|
The later features add cost, particularly the D-shaped floors, but the history indicates roughly a 17% net penalty there—not anything close to the remaining 8×.
|
||||||
|
|
||||||
|
## 2. The player can become active before any supporting collision exists
|
||||||
|
|
||||||
|
**Status: Verified. Highest correctness impact.**
|
||||||
|
|
||||||
|
`BeginPlay` initializes systems, but it does not synchronously request a spawn support tile, wait for a mesh, or gate pawn gravity/control ([VoxelWorld.cpp:988](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:988>)).
|
||||||
|
|
||||||
|
Streaming begins from `Tick`, and only when `GetPlayerPosition()` is not exactly `FVector::ZeroVector` ([VoxelWorld.cpp:1167](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1167>), [VoxelWorld.cpp:1359](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1359>)). Thus an exact world-origin pawn is indistinguishable from “no pawn.” Even when Z is nonzero and that specific sentinel is avoided, there is still no bootstrap barrier.
|
||||||
|
|
||||||
|
The supplied play trace shows:
|
||||||
|
|
||||||
|
- `BeginPlay` finished at about **50 ms**.
|
||||||
|
- The first desired-tile pass did not begin until about **439 ms**.
|
||||||
|
- The first LOD0 nonempty/mixed result was not ready until about **1.093 s**.
|
||||||
|
- That result still represented mesh/collision submission, not collision-cook completion.
|
||||||
|
|
||||||
|
At ordinary gravity, one second is enough to fall roughly five metres.
|
||||||
|
|
||||||
|
There are three additional gaps:
|
||||||
|
|
||||||
|
- A tile is inserted into `LoadedTiles` before its empty/nonempty application path completes ([VoxelWorld.cpp:1532](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1532>)).
|
||||||
|
- Only LOD0 receives collision ([VoxelWorld.cpp:3109](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:3109>)). Coarser visible terrain can therefore be present while the corresponding collidable LOD0 tile is still queued.
|
||||||
|
- Current architecture documentation says RMC collision is asynchronously cooked ([ARCHITECTURE.md:817](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:817>)), but `VoxelWorld` records no cook-complete state. Its `apply_s` measurement ends after submission and says nothing about when physics can actually collide.
|
||||||
|
|
||||||
|
I compared the equivalent paths on `main`. It also:
|
||||||
|
|
||||||
|
- Starts streaming from `Tick`.
|
||||||
|
- Uses the zero-position guard.
|
||||||
|
- Marks a tile loaded before handling its empty result.
|
||||||
|
- Enables collision only for LOD0.
|
||||||
|
- Has no spawn collision barrier or collision-cook acknowledgement.
|
||||||
|
|
||||||
|
So no explicit guarantee was removed inside `VoxelWorld`. What `experimental` broke was the **implicit timing budget** that had hidden this race.
|
||||||
|
|
||||||
|
The new origin-room geometry makes it worse. The first central LOD0 request in the trace is uniformly air and correctly receives no component. That is not itself an error: the player occupies air, while collision belongs to the neighboring floor/wall surface tile. With the current default origin geometry, the floor can lie near or below the lower tile boundary ([VoxelPassageGeometry.h:365](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelPassageGeometry.h:365>)). The supporting tile therefore has to arrive and finish cooking independently.
|
||||||
|
|
||||||
|
`CollisionOnlyTiles` does not close this gap. It controls presentation of already generated LOD0 components for non-player anchors; it does not synthesize or reserve collision underneath the local pawn. The supplied trace records zero collision-only tiles.
|
||||||
|
|
||||||
|
## 3. The mesher’s nested block classifier is probably self-inflicted overhead
|
||||||
|
|
||||||
|
**Status: Mechanism verified; net cost strongly suspected. High performance impact.**
|
||||||
|
|
||||||
|
The code contradicts its own guidance:
|
||||||
|
|
||||||
|
- The mesher header says the optional 8³ refinement should remain disabled during streaming because repeated proofs may cost more than they save ([VoxelMarchingCubesMesher.h:150](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h:150>)).
|
||||||
|
- `VoxelWorld` unconditionally enables it ([VoxelWorld.cpp:1102](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1102>)).
|
||||||
|
|
||||||
|
For every outer-Mixed tile, the mesher may run up to 64 additional `ClassifyTile` calls ([VoxelMarchingCubesMesher.cpp:254](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:254>)). On cache misses, each call builds another operator stack ([VoxelGenerator.cpp:5779](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:5779>)).
|
||||||
|
|
||||||
|
In `MyPlay`:
|
||||||
|
|
||||||
|
- 298 LOD0 tiles were outer-Mixed.
|
||||||
|
- All 298 produced geometry.
|
||||||
|
- Therefore every one eventually populated the full density grid.
|
||||||
|
- The nested classifier could only avoid marching-cubes cell processing in uniform sub-blocks; it could not avoid the main density grid once any surface sub-block was found.
|
||||||
|
|
||||||
|
This is precisely the workload where the header warns it may lose.
|
||||||
|
|
||||||
|
There is also an instrumentation trap: the outer cache counters are snapped before meshing. Nested classifier calls occur inside the measured `mesh_s` interval ([VoxelWorld.cpp:2762](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:2762>)). Consequently, the reported “cache hits 0” does **not** describe classifier activity originating inside the mesher.
|
||||||
|
|
||||||
|
This deserves the first controlled A/B test: identical trace and seed with only `bUseBlockEarlyOut` changed, while separately counting outer and nested classifier calls.
|
||||||
|
|
||||||
|
## 4. The outer whole-tile classifier is probably earning its keep
|
||||||
|
|
||||||
|
**Status: Verified workload behavior; exact counterfactual unmeasured.**
|
||||||
|
|
||||||
|
I would not remove the outer classifier based on the 95 ms figure.
|
||||||
|
|
||||||
|
It rejected **547 of 845 LOD0 tiles**, or 64.7%, before meshing:
|
||||||
|
|
||||||
|
- 484 uniformly solid.
|
||||||
|
- 63 uniformly air.
|
||||||
|
- 298 mixed, all of which produced geometry.
|
||||||
|
|
||||||
|
A full 35³ grid for those rejected tiles would represent up to 23.45 million density positions. Previous history also shows that the outer classifier eliminated multi-second empty-tile fallbacks.
|
||||||
|
|
||||||
|
The 95 ms is not a clean CPU cost. It is elapsed wall time inside a `BackgroundNormal` worker task, so it includes preemption and contention. A second trace in the repository from the same current code has an LOD0 classifier median around **8.45 ms**, versus **94.7 ms** in the moving/rendering owner session. The real play number is important for latency, but it should not be interpreted as intrinsic classifier compute.
|
||||||
|
|
||||||
|
My assessment is:
|
||||||
|
|
||||||
|
- **Outer whole-tile classification:** valuable under the current expensive evaluator.
|
||||||
|
- **Nested 8³ block classification:** likely negative in this workload.
|
||||||
|
- **Long-term:** making density evaluation cheap again may change the outer classifier’s break-even point.
|
||||||
|
|
||||||
|
## 5. Movement does not cancel obsolete generation work
|
||||||
|
|
||||||
|
**Status: Verified mechanism; contribution to running falls strongly suspected.**
|
||||||
|
|
||||||
|
The abort predicate only notices shutdown or an explicit generation pause ([VoxelWorld.h:764](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelWorld.h:764>)). It does not abort a tile because the player has moved and the tile is no longer desired.
|
||||||
|
|
||||||
|
Desired work is globally distance-sorted using an artificial chunk-center position rather than the exact pawn position, with no strong collision-critical/L0 priority ([VoxelWorld.cpp:1864](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1864>)). Up to 14 background tasks can therefore be occupied by old, coarse, or non-supporting work while the player’s new LOD0 floor waits.
|
||||||
|
|
||||||
|
By the steady point in the supplied session there were 1,481 loaded tiles for 899 currently desired tiles, with roughly 582 on the unload/transition side. Retaining old rendered tiles is not automatically wrong, but generation already launched for the old neighborhood is not preempted in favor of collision-critical tiles.
|
||||||
|
|
||||||
|
This is a credible explanation for falls while running. The trace cannot prove it because it omits tile coordinates, pawn position and collision-cook completion.
|
||||||
|
|
||||||
|
## 6. The verdict cache is mismatched to normal streaming
|
||||||
|
|
||||||
|
**Status: Implementation verified; negative startup ROI suspected. Medium impact.**
|
||||||
|
|
||||||
|
The cache:
|
||||||
|
|
||||||
|
- Is global and protected by one lock.
|
||||||
|
- Advances a clock and linearly scans entries.
|
||||||
|
- Holds only 512 proofs ([VoxelGenerator.cpp:1512](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1512>)).
|
||||||
|
- Keys include sample step, limiting reuse across LODs.
|
||||||
|
- Does not store a useful outer proof for Mixed tiles.
|
||||||
|
|
||||||
|
Fresh streaming normally visits unique regions, which explains the zero outer hits in both supplied traces. Nested block classifiers can then insert many short-lived proofs, pay the lock/scan costs, and evict one another—without appearing in the trace’s cache telemetry.
|
||||||
|
|
||||||
|
It demonstrably helped a deliberately repeated commandlet case, but that access pattern is not representative of a moving player. I would treat it as unproven or likely negative during startup until nested calls are measured.
|
||||||
|
|
||||||
|
## 7. Field semantics are now implemented in several overlapping places
|
||||||
|
|
||||||
|
**Status: Verified duplication; cost not isolated. Medium impact and high maintenance risk.**
|
||||||
|
|
||||||
|
The effective field is no longer simply “the operator stack”:
|
||||||
|
|
||||||
|
1. The operator stack evaluates the composable field.
|
||||||
|
2. The classifier maintains parallel interval/proof semantics.
|
||||||
|
3. `GetDensityAt` performs a post-stack repair pipeline for origin floors, passage structure, passage floors and room floors.
|
||||||
|
|
||||||
|
For example, `FRoomGraphSource` evaluates the cached room graph SDF and then separately traverses tunnels again for metadata ([VoxelDensityOpStack.cpp:4234](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:4234>)). The common tail then reasserts origin and passage structure/floors ([VoxelGenerator.cpp:2244](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2244>)).
|
||||||
|
|
||||||
|
This is the clearest sign of overengineering: later correctness fixes are compensating for where an operation sits in the pipeline. It creates duplicated traversal, makes interval proofs harder to trust, and prevents the stack from being the single semantic source of truth.
|
||||||
|
|
||||||
|
Runtime also does not use the commandlet’s shared density-grid ownership, so adjacent tiles repeat halo/boundary samples. Earlier measurement put this duplication around 18%. It existed in the old architecture too, so it did not cause the original regression, but every repeated sample is now much more expensive.
|
||||||
|
|
||||||
|
## 8. There are two lower-confidence ways to get a permanent hole
|
||||||
|
|
||||||
|
**Status: Verified risk paths; not demonstrated by this trace.**
|
||||||
|
|
||||||
|
First, whole-tile AllAir/AllSolid verdicts are trusted without exact validation. They become loaded `bEmpty` tiles with no component. Internal block verdicts are later checked against the full grid when meshing occurs, but an outer uniform verdict has no equivalent safety net.
|
||||||
|
|
||||||
|
The supplied aggregate argues against a widespread problem—every LOD0 Mixed tile produced geometry—but it cannot establish whether the floor tile beneath the pawn was incorrectly declared uniform because tile coordinates were not logged.
|
||||||
|
|
||||||
|
Second, `ApplyMeshToTile` has no success return. If runtime-mesh initialization fails, the function returns while the tile remains recorded as loaded, so it will not naturally retry ([VoxelWorld.cpp:3064](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:3064>)). I found no trace evidence that this occurred.
|
||||||
|
|
||||||
|
The normal `bAborted` result path is not the likely culprit: it removes the pending record without marking the tile loaded, allowing a later desired pass to reschedule it. There is, however, an editor pause corner case where a worker can exit without enqueuing any result and leave its pending entry behind if pause acquisition times out. That is a real bug, but it does not match this epoch-zero play session.
|
||||||
|
|
||||||
|
## What I would prove next
|
||||||
|
|
||||||
|
In order:
|
||||||
|
|
||||||
|
1. A/B the forced nested block classifier on the exact owner trace, recording outer versus nested calls, proof outcomes, stack builds and cache probes.
|
||||||
|
2. Add collision-specific timestamps: requested, mesh submitted, collision cook started/completed, component registered, plus tile and pawn coordinates.
|
||||||
|
3. Force exact meshing/validation for every supposedly uniform LOD0 tile in the spawn neighborhood to rule a false classifier proof in or out.
|
||||||
|
4. Benchmark a compiled/fused evaluator that retains the authored operator graph but restores per-sample short-circuiting and avoids spilling `FVoxelOpSample` through memory for every operator.
|
||||||
|
5. Prioritize a small LOD0 support neighborhood and require collision-cook completion before releasing the pawn. Faster generation alone cannot make the current lifecycle correct.
|
||||||
|
|
||||||
|
The blunt version: **the operator abstraction leaked into the innermost loop, and streaming treats “work submitted/applied” as “safe to stand on.”** The classifier work then became a collection of compensators for the expensive evaluator, some useful and some apparently duplicative.
|
||||||
|
|
||||||
|
This was a read-only audit. No files were created, modified or deleted; `git status` remains clean on `experimental` at `aa62971`.
|
||||||
+380
-66
@@ -47,7 +47,7 @@ A **density-field voxel terrain** plugin for Unreal Engine, built around undergr
|
|||||||
Tick → UpdateChunksAroundPosition
|
Tick → UpdateChunksAroundPosition
|
||||||
│ (load/unload around player, by distance + LOD)
|
│ (load/unload around player, by distance + LOD)
|
||||||
▼
|
▼
|
||||||
LoadChunk → UE::Tasks::Launch ──────────► background thread
|
LoadTile → UE::Tasks::Launch ──────────► background thread
|
||||||
│
|
│
|
||||||
UVoxelMarchingCubesMesher::GenerateMesh(chunk, step) ◄────────────────┘
|
UVoxelMarchingCubesMesher::GenerateMesh(chunk, step) ◄────────────────┘
|
||||||
│ samples density per cell corner
|
│ samples density per cell corner
|
||||||
@@ -73,12 +73,51 @@ what generator type, and is there a passage/elevator SDF near here?"
|
|||||||
|
|
||||||
Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
|
Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
|
||||||
|
|
||||||
|
### 3.0 Human-scale generation contract (2026-09-06)
|
||||||
|
`VoxelStrateTypes.h` is now authored against the confirmed player capsule, not against naked voxel
|
||||||
|
counts: one voxel is **0.25 m**, the capsule is **1.36 voxels / 0.34 m radius** and **7.04 voxels /
|
||||||
|
1.76 m tall**, and the default strate envelope is **8 chunks / 256 voxels / 64 m**. Core defaults are
|
||||||
|
therefore: room diameters **8-20 m**, origin hub **24 m**, room cells **32 m**, round tunnel bores
|
||||||
|
**3-4 m**, maze cells **16 m** with a **6.25 m nominal bore** (**3.125 m** radius; **3.0625 m**
|
||||||
|
worst-case usable floor after roughness), slab open span **22.4 m**, shaft diameters
|
||||||
|
**4-7 m**, island diameters **12-24 m**, and ordinary inter-strate bores **3-4 m**. These are
|
||||||
|
different derivations, not a global multiplier: passage widths follow the 2.5-4 m walking/fight
|
||||||
|
target, room/cell spacing preserves a rock web, and the 64 m vertical envelope reserves space for
|
||||||
|
tall rooms, surface sky, and vertical traversal.
|
||||||
|
|
||||||
|
The reachable roughness envelope is `roughness * VOXEL_NOISE_SCALE * 1.5`, or `roughness * 1.875`
|
||||||
|
voxels. The new defaults keep it below the relevant feature radius: **0.625** of the minimum
|
||||||
|
TunnelNetwork tunnel radius, **0.300** of Maze corridor radius, **0.469/0.625** of shaft/connector
|
||||||
|
radii, **0.234** of the minimum island radius (**0.484** including the 6-voxel island blend), and
|
||||||
|
**0.625/0.938** of slab column radius for floor/ceiling roughness. SurfaceWorld has no radial cave
|
||||||
|
feature, so its ratio is reported against the 80-voxel terrain-relief scale (**0.047**).
|
||||||
|
|
||||||
|
The complete generated `name | voxels | METRES | body/level meaning` audit is emitted by
|
||||||
|
`VoxelForge.Composer.Showcase` and includes optional transport fields. The official Unreal blockout
|
||||||
|
guidance ([Unreal level blockout guidance](https://dev.epicgames.com/documentation/en-us/unreal-engine/designer-01-project-setup-and-level-blockout-in-unreal-engine)) uses a player-sized reference and treats 2-3 m halls / 3-4 m heights as useful starting
|
||||||
|
guidelines; the [2010 ADA Standards](https://www.ada.gov/law-and-regs/design-standards/2010-stds/)
|
||||||
|
provide a 915 mm clear walking-width floor, not a game-space target; and [NPS Lehman Cave
|
||||||
|
dimensions](https://www.nps.gov/grba/learn/nature/lehman-caves-dimensions.htm) show why natural
|
||||||
|
chambers need a wide range rather than one universal volume ratio.
|
||||||
|
|
||||||
|
Maze topology is an origin-directed spanning tree, not independent edge percolation. Every
|
||||||
|
non-origin lattice node chooses one hashed parent from the non-zero axes that point toward
|
||||||
|
`(0,0,0)`; Manhattan distance therefore falls by exactly one on every parent step, so every node
|
||||||
|
reaches the root. Optional horizontal and vertical loop rolls are capped at `0.18 * BranchProbability`
|
||||||
|
and `0.10 * Verticality`; removing them cannot disconnect the tree. A voxel cell evaluates the
|
||||||
|
canonical lower-node `+X/+Y/+Z` edges in its local `{-1,0}³` (**2×2×2 = 8-node**) window, and each
|
||||||
|
edge checks both endpoints' parent choices. No wider collect region is needed, `WorldRadiusVoxels`
|
||||||
|
remains `0`, and lateral regions remain gated off.
|
||||||
|
|
||||||
### 3.1 Module & build
|
### 3.1 Module & build
|
||||||
| File | Role |
|
| File | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `../../VoxelForge.uplugin` | Plugin manifest. One Runtime module `VoxelForge`. Beta. |
|
| `../../VoxelForge.uplugin` | Plugin manifest. Runtime module `VoxelForge` plus the Editor-only `VoxelForgeEditor` commandlet module. Beta. |
|
||||||
| `VoxelForge.Build.cs` | Deps: Core, CoreUObject, Engine, **GameplayTags**, **RealtimeMeshComponent**. |
|
| `VoxelForge.Build.cs` | Deps: Core, CoreUObject, Engine, **GameplayTags**, **RealtimeMeshComponent**. |
|
||||||
|
| `Tools/VoxelForgeTest.ps1` | The single guarded test entry point. `-Scenario canonical|owner|probe|perf|tests|surface-fall|crossing|gate-stress|parity|horizon|horizon-render|determinism`, optional `-Build`, `-Assets owner|default`, `-Cvars`, absolute `-Out`, `-Label`, `-HorizonLevel`, `-HorizonRenderDistanceChunks`, and `-SharedDDC`; stages the host/assets, isolates user/DDC/config state, serializes one Unreal launch at a time, enforces the 8 GiB/30-minute guards, and emits compact `result.json`/`summary.txt` plus full logs. Horizon traces report desired/loaded tiles, resident stream geometry, worker seconds, steady wall time, and peak private memory; `horizon-render` captures the owner view; `determinism` compares per-tile hashes across the default CRT path and `-voxel.CrtFma3=0`. |
|
||||||
| `Public/VoxelForgeModule.h` / `Private/VoxelForgeModule.cpp` | `FVoxelForgeModule` boilerplate (Startup/Shutdown just log). |
|
| `Public/VoxelForgeModule.h` / `Private/VoxelForgeModule.cpp` | `FVoxelForgeModule` boilerplate (Startup/Shutdown just log). |
|
||||||
|
| `Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs` / `Private/VoxelForgeExploreCommandlet.*` | Editor/commandlet-only `-run=VoxelForgeExplore`; owns bounded render/`VF_` walk/OBJ export orchestration and is not linked by Game/Shipping. |
|
||||||
|
| `Public/VoxelGeometryHash.h` | Header-only geometry digest shared by commandlet export and runtime tile-hash dump; hashes mesh counts and the vertex/normal/UV/color/triangle arrays with the historical CRC order. |
|
||||||
| `Public/VoxelStats.h` / `Private/VoxelStats.cpp` | `stat VoxelForge` DWORD counters for tile classification, skipping, meshing, operator-stack verdicts, and cave-bail diagnosis. The former ambiguous `Cave Bail Not Op Stack` is split into `Sole Slot`, `Boundary Tile`, `No Layout`, and late `Recheck` counters, so each increment names one guard/context. |
|
| `Public/VoxelStats.h` / `Private/VoxelStats.cpp` | `stat VoxelForge` DWORD counters for tile classification, skipping, meshing, operator-stack verdicts, and cave-bail diagnosis. The former ambiguous `Cave Bail Not Op Stack` is split into `Sole Slot`, `Boundary Tile`, `No Layout`, and late `Recheck` counters, so each increment names one guard/context. |
|
||||||
|
|
||||||
### 3.2 Foundational types — `Public/VoxelTypes.h` (no UClass, everyone includes it)
|
### 3.2 Foundational types — `Public/VoxelTypes.h` (no UClass, everyone includes it)
|
||||||
@@ -89,34 +128,39 @@ Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
|
|||||||
| `EVoxelFace` enum + `GetFaceDirection` / `GetFaceNormal` | 33-61 | 6 cube faces. |
|
| `EVoxelFace` enum + `GetFaceDirection` / `GetFaceNormal` | 33-61 | 6 cube faces. |
|
||||||
| `WorldToChunkCoord` / `WorldToLocalCoord` / `ChunkToWorldPos` | 74-104 | Coord-space conversions (handle negatives via floor/positive-modulo). |
|
| `WorldToChunkCoord` / `WorldToLocalCoord` / `ChunkToWorldPos` | 74-104 | Coord-space conversions (handle negatives via floor/positive-modulo). |
|
||||||
| `LocalToIndex` / `IndexToLocal` / `IsValidLocalCoord` | 107-131 | Flat-array 3D↔1D indexing. |
|
| `LocalToIndex` / `IndexToLocal` / `IsValidLocalCoord` | 107-131 | Flat-array 3D↔1D indexing. |
|
||||||
|
| `VoxelMath::IsFiniteFast` / `IsFinite` | ~24-53 | Exact IEEE bit test (`voxel.FastIsFinite` A/B switch). |
|
||||||
|
| `VoxelMath::DetSinCos` / `DetSin` / `DetCos` | ~63-195 | **The only sin/cos allowed where the world is decided** (2026-09-16, `10fd0b6`). Fixed double period reduction and float polynomial, no CRT: `FMath::Sin/Cos` are CRT `sinf/cosf`, whose FMA3 variant is picked by CPU. Pinned by `VoxelForge.Determinism.DetSinCos`; `-voxel.CrtFma3=0` flips the CRT path to prove it. |
|
||||||
| `SmoothStep01` | 140 | 3x²-2x³ — used everywhere for blends. |
|
| `SmoothStep01` | 140 | 3x²-2x³ — used everywhere for blends. |
|
||||||
| `VOXEL_NOISE_SCALE` (1.25f) | 147 | Rescales UE PerlinNoise3D to ~[-1,1]. |
|
| `VOXEL_NOISE_SCALE` (1.25f) | 147 | Rescales UE PerlinNoise3D to ~[-1,1]. |
|
||||||
| `EVoxelTileClass` enum (`Mixed`/`AllSolid`/`AllAir`) | — | T1.d verdict. **MOVED here from `VoxelGenerator.h` 2026-07-27** so `VoxelDensityOp.h` can share it without a UCLASS dependency. A false `AllSolid`/`AllAir` is a HOLE; a false `Mixed` only costs CPU. |
|
| `EVoxelTileClass` enum (`Mixed`/`AllSolid`/`AllAir`) | — | T1.d verdict. **MOVED here from `VoxelGenerator.h` 2026-07-27** so `VoxelDensityOp.h` can share it without a UCLASS dependency. A false `AllSolid`/`AllAir` is a HOLE; a false `Mixed` only costs CPU. |
|
||||||
| `FVoxelMeshData` struct | 157-173 | Mesher output (Vertices/Triangles/UVs/Normals/**Colors**). Plain C++, not USTRUCT. `Colors` = F6 material masks (R=dominant biome palette, G=slope, B=border blend weight, A=neighbour biome palette). §8.15. |
|
| `FVoxelMeshData` struct | 157-173 | Mesher output (Vertices/Triangles/UVs/Normals/**Colors**). Plain C++, not USTRUCT. `Colors` = F6 material masks (R=dominant biome palette, G=slope, B=border blend weight, A=neighbour biome palette). §8.15. |
|
||||||
|
|
||||||
### 3.2b Density operator stack contract — `Public/VoxelDensityOp.h` (plain C++, no UHT)
|
### 3.2b Density operator stack contract — `Public/VoxelDensityOp.h` (plain C++, no UHT)
|
||||||
**Phase 1 skeleton, added 2026-07-27. Nothing is wired in yet** — `GetDensityAt`'s archetype
|
**Production path behind a per-strate opt-in** — the stack is built/evaluated by `GetDensityAt` for
|
||||||
`switch` is untouched and no operator exists. See [OPSTACK-PLAN.md](OPSTACK-PLAN.md) for the plan and
|
all eight archetypes when `bUseOperatorStack` is enabled, while the legacy `switch` remains the
|
||||||
[OPSTACK-DECOMPOSITION.md](OPSTACK-DECOMPOSITION.md) for the per-archetype breakdown.
|
reference path for opt-out strates. See [OPSTACK-PLAN.md](OPSTACK-PLAN.md) for the decomposition.
|
||||||
|
|
||||||
| Symbol | Notes |
|
| Symbol | Notes |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| `EVoxelOpRole` | The four roles: `FieldSource` (what the field IS) · `Combiner` (how fields merge) · `DetailModifier` (today's `UVoxelTerrainOpDefinition`) · `StructuralPost` (spine→seal→passage→diff, appended automatically, never author-omittable). |
|
| `EVoxelOpRole` | The four roles: `FieldSource` (what the field IS) · `Combiner` (how fields merge) · `DetailModifier` (today's `UVoxelTerrainOpDefinition`) · `StructuralPost` (spine→vertical seal→passage tube/landing floor→XY edge seal→diff, appended automatically, never author-omittable). |
|
||||||
|
| `EVoxelOpChannel` / `EVoxelOpChannelMask` | The explicit `FVoxelOpSample` channel set: `Density` and `Sdf` (plus `None`/`All` masks). Every density op declares the channels it reads and writes. |
|
||||||
| `EVoxelOpCombine` | `Replace`/`Union`(min)/`Subtract`(max)/`SmoothUnion`/`SmoothSubtract`/`Add`/`Mask`. Sign reminder: negative = solid, so "add solid" is `min`. |
|
| `EVoxelOpCombine` | `Replace`/`Union`(min)/`Subtract`(max)/`SmoothUnion`/`SmoothSubtract`/`Add`/`Mask`. Sign reminder: negative = solid, so "add solid" is `min`. |
|
||||||
| `EVoxelOpEffect` | `Identity`/`CarveOnly`/`FillOnly`/`Both`. Conservative: `Both` is always safe, the wrong one is a hole. |
|
| `EVoxelOpEffect` | `Identity`/`CarveOnly`/`FillOnly`/`Both`. Conservative: `Both` is always safe, the wrong one is a hole. |
|
||||||
| `FVoxelOpContext` | Chunk-constant inputs. **Carries `LayoutVersion` by construction** so a new op cannot forget it (AUDIT C2). |
|
| `FVoxelOpContext` | Chunk-constant inputs. Carries `LayoutVersion` plus the copied global `WorldRadiusVoxels`/`EdgeSealThickness`, so a new op cannot forget them. |
|
||||||
| `IVoxelDensityOp` | `PrepareChunk` / `Eval` / `EffectOverBox` / `ClassifyBox` / `IsXYPure`. |
|
| `IVoxelDensityOp` | `ChannelReads` / `ChannelWrites` / `IsAdditive` plus `RequiredResources` / `ProvidedResources`, `PrepareChunk` / `Eval`, and intrinsic/state-aware `EffectOverBox` / `MaxCarveOverBox` / `MaxFillOverBox` / `PropagateSdfOverBox` / `ClassifyBox` / `IsXYPure`. Box-query methods are never called per voxel; channel/resource declarations are snapshotted during stack assembly. |
|
||||||
| `IVoxelDensityOp::ClassifyBox` | ⚠️ **not source-only.** Forcing ops (the boundary seal inside its band) overwrite the input entirely, which pure direction cannot express. |
|
| `IVoxelDensityOp::ClassifyBox` | ⚠️ **not source-only.** Forcing ops (the vertical seal and XY edge seal inside their proven bands) overwrite the input entirely, which pure direction cannot express. |
|
||||||
| `FVoxelOpSample` | The state threaded through the stack: **two** channels, `Density` (INTERNAL convention, **positive = SOLID**, negated to MC once by the caller) and `Sdf` (standard SDF, negative = inside). ⚠️ `min()` therefore means opposite things on the two channels. |
|
| `FVoxelOpSample` | The state threaded through the stack: **two** channels, `Density` (INTERNAL convention, **positive = SOLID**, negated to MC once by the caller) and `Sdf` (standard SDF, negative = inside). ⚠️ `min()` therefore means opposite things on the two channels. |
|
||||||
| `FVoxelBoxHypotheses` + `VF_ForceHypotheses` / `VF_FoldEffect` / `VF_FoldOp` | The fold that turns a stack into an `EVoxelTileClass`. Reproduces today's hand-written `ClassifyTile` line for line — the mapping is written out in the header. |
|
| `FVoxelBoxHypotheses` + `FVoxelBoxSdfInterval` + `VF_ForceHypotheses` / `VF_FoldEffect` / `VF_FoldOp` | The fold that turns a stack into an `EVoxelTileClass`, carrying the SDF interval between independent writers and consumers. Unknown is biased toward `Mixed`; the mapping and soundness rule are written out in the header. |
|
||||||
|
|
||||||
### 3.2c Structural primitives — `Public/VoxelDensityPrimitives.h`
|
### 3.2c Structural primitives — `Public/VoxelDensityPrimitives.h`
|
||||||
`VF_ApplyOriginSpine` · `VF_ApplyBoundarySeal` · `VF_ApplyPassageCarving` — the three world
|
`VF_ApplyOriginSpine` · `VF_ApplyBoundarySeal` · `VF_ApplyPassageCarving` · `VF_ApplyXYEdgeSeal` —
|
||||||
invariants every archetype appends, **moved here 2026-07-27** so the generator and the operator
|
the four world invariants every archetype appends, with landing/floor geometry joined by the
|
||||||
stack share ONE copy. `VoxelGenerator.cpp` keeps same-named `static FORCEINLINE` forwarders so its
|
manager passage post, **moved here 2026-07-27** so the generator and
|
||||||
~20 call sites are unchanged; bodies are byte-identical. Also `VoxelDensityReach::SpineBlend` /
|
the operator stack share ONE copy. The XY helper is a radial smooth ramp in actor-space and is a
|
||||||
`PassageBlend`, the blend radii `ClassifyTile` currently hand-duplicates.
|
true no-op when `WorldRadiusVoxels == 0`; `VF_XYEdgeSealForcedMarginOverBox` is the shared sound
|
||||||
**Convention: INTERNAL (positive = solid).**
|
proof for `ClassifyBox` and T1.d's global shell skip. `VoxelGenerator.cpp` keeps same-named
|
||||||
|
`static FORCEINLINE` forwarders for the original three helpers; the new edge helper is called
|
||||||
|
directly. **Convention: INTERNAL (positive = solid); `VF_ApplyXYEdgeSealMC` is the output wrapper.**
|
||||||
|
|
||||||
### 3.2d Operator stack — `Public/VoxelDensityOpStack.h` + `Private/VoxelDensityOpStack.cpp`
|
### 3.2d Operator stack — `Public/VoxelDensityOpStack.h` + `Private/VoxelDensityOpStack.cpp`
|
||||||
⚠️ **Feeds the game, behind a per-strate opt-in** (Phase 1 step 3). `GetDensityAt` builds the stack
|
⚠️ **Feeds the game, behind a per-strate opt-in** (Phase 1 step 3). `GetDensityAt` builds the stack
|
||||||
@@ -141,33 +185,51 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar
|
|||||||
|
|
||||||
| Symbol | Role | Notes |
|
| Symbol | Role | Notes |
|
||||||
|--------|------|-------|
|
|--------|------|-------|
|
||||||
| `FVoxelOpStack` | — | Ordered `TUniquePtr` list. `PrepareChunk` / `EvalInternal` / `EvalMC` / `ClassifyBox` (the fold, with an early-out when both hypotheses die). |
|
| `FVoxelOpStack` | — | Ordered move-only list of `TUniquePtr` entries. `Add` snapshots channel metadata; `PrepareChunk` / `EvalInternal` / `EvalMC` / `ClassifyBox` (the fold lets a later forcing post overwrite a dead hypothesis). |
|
||||||
| `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → seal → passage **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. |
|
| `FVoxelOpStack::ValidateChannelOrder` | — | Assembly/diagnostic-only channel/resource DAG validator. It requires prior producers, rejects write-only clobbers and invalid additive declarations, and requires room/shaft/surface state providers before consumers. It permits the one root SDF identity fold used by Maze. `VoxelForge.OpStack.ChannelDAG` validates all 8 shipping stacks. |
|
||||||
|
| `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → vertical seal → passage tube/landing floor → XY edge seal **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. |
|
||||||
| `VoxelDensityOps::MakeConstantRockSource` | 1 | `Density = BaseDensity`. `ClassifyBox` → **AllSolid**, exact and free. Shared by TunnelNetwork, Maze, VerticalShafts and bedrock gaps. Class is `FConstantFieldSource` (one class, two factories). |
|
| `VoxelDensityOps::MakeConstantRockSource` | 1 | `Density = BaseDensity`. `ClassifyBox` → **AllSolid**, exact and free. Shared by TunnelNetwork, Maze, VerticalShafts and bedrock gaps. Class is `FConstantFieldSource` (one class, two factories). |
|
||||||
| `VoxelDensityOps::MakeConstantVoidSource` | 1 | The **same class, negated**: `Density = -BaseDensity`, and `ClassifyBox` → **AllAir** — the first source in the plugin that can prove it. FloatingIslands' root; that verdict is what makes a mostly-empty island strate skippable. |
|
| `VoxelDensityOps::MakeConstantVoidSource` | 1 | The **same class, negated**: `Density = -BaseDensity`, and `ClassifyBox` → **AllAir** — the first source in the plugin that can prove it. FloatingIslands' root; that verdict is what makes a mostly-empty island strate skippable. |
|
||||||
| `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Edge identity = `hash(lower node, axis)` ⇒ adjacent chunks cannot disagree (AUDIT §6.4's preferred pattern). Its `EffectOverBox` answers for the source+carve **pair** (Phase 1 simplification) so it must be told the downstream `ExtraReach`. |
|
| `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Each canonical lower-node/axis edge is open when it is a parent edge in the origin-directed tree or passes a capped loop roll; both endpoints are hashed locally, so adjacent chunks cannot disagree. The source reports its own conservative SDF interval; a later converter supplies its own carve/fill response, so the source is independent of the chosen consumer. |
|
||||||
| `VoxelDensityOps::MakeSdfRoughnessMod` | 3 | Wall roughness in **SDF** space (Maze/Shafts/Islands variant). TunnelNetwork's density-space roughness is a **different op** — see OPSTACK-DECOMPOSITION §1. |
|
| `VoxelDensityOps::MakeSdfRoughnessMod` | 3 | Wall roughness in **SDF** space (Maze/Shafts/Islands variant). TunnelNetwork's density-space roughness is a **different op** — see OPSTACK-DECOMPOSITION §1. |
|
||||||
| `VoxelDensityOps::MakeSdfCarve` | 2 | SDF → density carve. The same six lines currently copied in three archetypes. Class is `FSdfConvertOp(Sign = -1)`. |
|
| `VoxelDensityOps::MakeSdfCarve` | 2 | SDF → density carve. The same six lines currently copied in three archetypes. Class is `FSdfConvertOp(Sign = -1)`. |
|
||||||
| `VoxelDensityOps::MakeSdfFill` | 2 | The same op with `Sign = +1` — FloatingIslands' `Density += Fill·Base·2`. ±1 multiplication is exact in IEEE-754, so the carve path is bit-for-bit unchanged by the generalisation. |
|
| `VoxelDensityOps::MakeSdfFill` | 2 | The same op with `Sign = +1` — FloatingIslands' `Density += Fill·Base·2`. ±1 multiplication is exact in IEEE-754, so the carve path is bit-for-bit unchanged by the generalisation. |
|
||||||
| `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: FBM's `[-1,1]` contract bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. |
|
| `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: the proved FBM supremum `1.5` bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. |
|
||||||
| `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. |
|
| `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. |
|
||||||
| `VoxelDensityOps::BuildSlabStack` | — | 5 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. |
|
| `FShaftFieldSource` (internal) | 1 | VerticalShafts' memoised inner 3×3 shaft/capsule source. Each rebuild uses a direct-indexed wider halo (default 9×9 tree-emission / 15×15 roll) so every emitted shaft has its complete 5×5 parent window plus capsule reach; the nearest strictly more-central shaft in ±2 cells is the deterministic parent, with a deterministic lower-origin ±3 fallback and a direct `(0,0)` spine fallback. Thus every shaft has a provable path to the spine. Existing `VoxelHash::Pair`/`Spacing*1.6` links remain additive texture/loops. Tree and spine capsules use a radius strictly above `sup|FBM|=1.5`, and tree Z is chosen in a ledge-free band; the structural post still owns the column and the ledge modifier keeps its mathematical axis boundary open. The source publishes a conservative interval covering both shafts and connector capsules; it does not answer for the ledge or converter. |
|
||||||
| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns a **six-box spatial LRU** of direct-indexed per-column cells, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed, ParamsFingerprint)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. Six 81×81 boxes preserve hot columns across interleaved regions at roughly 0.79 MiB TLS before padding (more memory, fewer whole-cache recenter/recompute misses). Fractional XY remains direct-compute. |
|
| `VoxelDensityOps::BuildSlabStack` | — | 6 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. |
|
||||||
| `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 3 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. |
|
| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns a **six-box spatial LRU** of direct-indexed per-column cells, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed, ParamsFingerprint)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. Six 81×81 boxes preserve hot columns across interleaved regions at roughly 0.79 MiB TLS before padding (more memory, fewer whole-cache recenter/recompute misses). Fractional XY remains direct-compute. `FSurfaceColumn` is deliberately trivial: all five outputs are written before the cache's `Computed` publish, so removing its default initialization removes a TLS constructor leaf without changing a value. |
|
||||||
| `VoxelDensityOps::BuildVerticalShaftStack` | — | 8 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. |
|
| `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 4 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. |
|
||||||
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. **`EffectOverBox` ANSWERS SPATIALLY** since 2026-07-28 — this is the T1.d switch (measured: **6 of 40 tiles proved AllSolid at production defaults, 7986 voxels brute-forced, 0 violations**). It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), then applies a **disjunction** per primitive: it doesn't matter if it **fails its cull** *or* if **its own SDF stays ≥ `T+K`** over the box. Cull wins for rooms (`Rmax+3K` < `Rmax+T+K`); the threshold wins hugely for tunnels, whose cull is a capsule *bounding sphere* (~107 radius for a 200-long tube of radius 7). ⚠️ **`Identity` therefore means `Sdf ≥ T`, not `Sdf == FLT_MAX`**, with `T = max(3·SDFBlendRadius, WormNetworkRange)` — **any new consumer of the `Sdf` channel must have a threshold ≤ T or be added to that max**, or it gets tiles with no geometry and no collision. The `−K` slack covers any number of primitives because `SmoothMin`'s penalty is exactly 0 once `\|A−B\| ≥ K`. Pits/chimneys use the cull only; columns are **not** tested (their sole consumer gates on `Sdf`, so the test was redundant). Verdict memoised per box; warp dilation uses a **provable** `\|Perlin3D\| ≤ 2`. |
|
| `VoxelDensityOps::BuildVerticalShaftStack` | — | 9 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). Passes the runtime spine radius into `FShaftFieldSource`, which mirrors the generator's connector-only origin endpoint and roughness-safe spine capsule. The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. |
|
||||||
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). **`EffectOverBox` INHERITS `FRoomGraphSource`'s verdict** since 2026-07-28 — its `Eval` sets `NetworkMask = 0` when `CaveSDF >= WormNetworkRange`, which `FLT_MAX` always satisfies, so where the room source proves `Identity` the worm doesn't execute at all. ⚠️ This was **the** blocker: `BaseDensity = 8` < `WormStrength = 10` **by default** (the field comment requires it), so an unconditional `CarveOnly` drove `SolidMargin` negative on every tile in the world and no room-source proof could survive behind it. Deliberately **not** `VF_NoCaveOverBox` — that helper answers "identity" for a null `Rooms`, which is wrong for an op that could sit behind a different SDF writer. |
|
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. The source now publishes a conservative SDF interval through the box fold; it does not answer for the carve or detail stack. It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), and the interval uses the same cull/threshold disjunction as the per-voxel source: a primitive is harmless when it fails its cull or its own SDF stays ≥ `T+K`; `T = max(3·SDFBlendRadius, WormNetworkRange)`, with `−K` slack for the final `SmoothMin`. Rooms, tunnels, pits and chimneys are bounded in their respective coordinate frames; warp dilation uses the proved `sup|Perlin3D| ≤ 1.5` envelope. |
|
||||||
| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 19 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b–4h (B) → the per-room op override (C). Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line, and it is load-bearing (`FFloorBiasMod` exists to undo what `FCaveRoughnessMod` did to floors). |
|
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by the propagated SDF interval (reads `InOut.Sdf` *after* pits/chimneys). Its state-aware box response is `Identity` only when the preceding interval proves `Sdf ≥ WormNetworkRange`; otherwise it reports its own `CarveOnly` direction and bound. With no preceding SDF source it remains conservative, so it cannot inherit a verdict from an unrelated future writer. |
|
||||||
|
| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 20 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b–4h (B) → the per-room op override (C) → 4 structural posts. Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line. `FFloorBiasMod` is an independent additive term on the density accumulated before it; it is kept late because it does not undo the roughness delta. The requested 4b→4h fuse is not semantically justified. |
|
||||||
| `FCaveRoughnessMod` (internal) | 3 | STEP 4b, **density space** — a different op from `MakeSdfRoughnessMod`: two octave sets, optional domain warp, four noise types, an anti-fill clamp inside definite air, quadratic fade. ⚠️ **Reads STRATE params, not the per-room copy** — the original's shadow is declared *after* step 4b. Eleven of twelve modifiers read the room copy; this one does not. |
|
| `FCaveRoughnessMod` (internal) | 3 | STEP 4b, **density space** — a different op from `MakeSdfRoughnessMod`: two octave sets, optional domain warp, four noise types, an anti-fill clamp inside definite air, quadratic fade. ⚠️ **Reads STRATE params, not the per-room copy** — the original's shadow is declared *after* step 4b. Eleven of twelve modifiers read the room copy; this one does not. |
|
||||||
| `FCaveTerraceMod` (internal) | 3 | STEP 4c. The only modifier that **re-queries the SDF** (Z±1, through `FRoomGraphSource::ProbeSdfUnwarped`) for its horizontality gate — which is why the room source's cache is exposed at all. ⚠️ Those probes use unwarped X/Y and raw Z although the field was evaluated warped: transcribed as-is, see OPSTACK-PROGRESS. |
|
| `FCaveTerraceMod` (internal) | 3 | STEP 4c. The only modifier that **re-queries the SDF** (Z±1, through `FRoomGraphSource::ProbeSdfUnwarped`) for its horizontality gate — which is why the room source's cache is exposed at all. ⚠️ Those probes use unwarped X/Y and raw Z although the field was evaluated warped: transcribed as-is, see OPSTACK-PROGRESS. |
|
||||||
| `FLayerLineMod` / `FRibbingMod` (internal) | 3 | The same sine along Z: cubed and subtracted (grooves) vs quarter-phase-shifted, squared and added (ribs). `CarveOnly` / `FillOnly` — two of the few detail modifiers that keep a usable direction for the fold. |
|
| `FLayerLineMod` / `FRibbingMod` (internal) | 3 | The same sine along Z: cubed and subtracted (grooves) vs quarter-phase-shifted, squared and added (ribs). `CarveOnly` / `FillOnly` — two of the few detail modifiers that keep a usable direction for the fold. |
|
||||||
| `FCaveOverhangMod` / `FCaveCliffMod` / `FScallopMod` (internal) | 3 | STEP 4c. ⚠️ The cliff's own comment promises a sampled vertical gradient; the **code** uses a Z-stretched Perlin as a proxy and samples nothing. Ported as written — fixing it would change the world. |
|
| `FCaveOverhangMod` / `FCaveCliffMod` / `FScallopMod` (internal) | 3 | STEP 4c. ⚠️ The cliff's own comment promises a sampled vertical gradient; the **code** uses a Z-stretched Perlin as a proxy and samples nothing. Ported as written — fixing it would change the world. |
|
||||||
| `FCaveArchMod` / `FDomeMod` / `FPinchMod` / `FFloorBiasMod` (internal) | 3 | Room-relative: they read `FRoomGraphSource::GetNearestRoomIdx()` and the cached room. Their gate is `SDF < SDFBlendRadius`, **not** the shared `·3` one — they live in the cave's void, not its wall. |
|
| `FCaveArchMod` / `FDomeMod` / `FPinchMod` / `FFloorBiasMod` (internal) | 3 | Room-relative: they read `FRoomGraphSource::GetNearestRoomIdx()` and the cached room. Their gate is `SDF < SDFBlendRadius`, **not** the shared `·3` one — they live in the cave's void, not its wall. |
|
||||||
| `FRoomColumnMod` (internal) | 3 | STEP 4d, and **not** `MakeGridColumnMod`: it walks `SDFCache.Columns`, pre-baked per room. ⚠️ It has **no strate parameter at all** — neither the bake nor the loop reads `FStrateGenerationParams::ColumnDensity`. Columns exist only through a `Column` terrain-op asset in the strate's pool, and the only way to prove they fired is to look at `SDFCache.Columns.Num()`. |
|
| `FRoomColumnMod` (internal) | 3 | STEP 4d, and **not** `MakeGridColumnMod`: it walks `SDFCache.Columns`, pre-baked per room. ⚠️ It has **no strate parameter at all** — neither the bake nor the loop reads `FStrateGenerationParams::ColumnDensity`. Columns exist only through a `Column` terrain-op asset in the strate's pool, and the only way to prove they fired is to look at `SDFCache.Columns.Num()`. |
|
||||||
| `FRoomGraphSource::LocalParams()` | — | **The per-room op override** (DECOMPOSITION §2's "no clean home"). Strate params + the nearest room's `UVoxelTerrainOpDefinition::ApplyTo`, memoised once per voxel and read by eleven modifiers. One op owns the shared state, the rest read it — the same pattern as `FOverhangShelfMod` ← `FSurfaceColumnSource`. ⚠️ `EffectOverBox` still answers from STRATE params, so a box verdict can be **too optimistic** on a strate with a terrain-op pool; harmless until `ClassifyTile` consumes `ClassifyBox`, and it must be fixed before that. |
|
| `FRoomGraphSource::LocalParams()` | — | **The per-room op override** (DECOMPOSITION §2's "no clean home"). Strate params + the nearest room's `UVoxelTerrainOpDefinition::ApplyTo`, memoised once per voxel and read by eleven modifiers. One op owns the shared state, the rest read it — the same pattern as `FOverhangShelfMod` ← `FSurfaceColumnSource`. The detail consumers use the propagated interval for their shared near/far gate; unknown remains conservative, and the per-room parameter override is never used to claim an unproved box identity. |
|
||||||
| `FIslandBlobSource` (internal) | 1 | Hash-placed tapered flat-top blobs, `SmoothMin`'d, in a **domain-warped XY frame** (the warp stays inside the op — see the deviation note vs DECOMPOSITION §7). SDF channel only. `EffectOverBox` → `FillOnly` when a blob reaches the box, `Identity` otherwise; its pad must cover warp·**√2** (two independent noise axes), roughness, fill blend and the `SmoothMin` dip. **No lower Z bound exists** — a hairline thread of matter hangs below each island down its axis, so only the TOP may reject. |
|
| `FIslandBlobSource` (internal) | 1 | Hash-placed tapered flat-top blobs, `SmoothMin`'d, in a **domain-warped XY frame** (the warp stays inside the op — see the deviation note vs DECOMPOSITION §7). SDF channel only. The source publishes an interval covering the full authored radius envelope (`max(MinRadius, MaxRadius)`), the two-axis warp·**√2** envelope, top surface and SmoothMin dip; the fill converter makes its own decision. **No lower Z bound exists** — a hairline thread of matter hangs below each island down its axis, so only the TOP may reject. |
|
||||||
| `VoxelDensityOps::BuildFloatingIslandStack` | — | 7 ops, and **the stack runs backwards**: void source + fill instead of rock source + carve, using the *same* classes with the opposite sign. Only the blob source is new. Reuse by **inversion** — a stronger result than reuse by identity, since it says the abstract axis (the density sign) is the right one. |
|
| `VoxelDensityOps::BuildFloatingIslandStack` | — | 8 ops, and **the stack runs backwards**: void source + fill instead of rock source + carve, using the *same* classes with the opposite sign. Only the blob source is new. Reuse by **inversion** — a stronger result than reuse by identity, since it says the abstract axis (the density sign) is the right one. |
|
||||||
| `VoxelDensityOps::BuildMazeStack` | — | The 7-op Maze stack. If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (top−bottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. |
|
| `VoxelDensityOps::BuildMazeStack` | — | The 8-op Maze stack (4 structural posts included). Its lattice source mirrors the origin-directed tree + capped loops and rebuilds the 2×2×2 lower-node window once per cell; the per-voxel loop only evaluates cached capsules. If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (top−bottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. |
|
||||||
|
|
||||||
|
#### Lateral region parent (Tier 4d)
|
||||||
|
|
||||||
|
`VF_RollStrateRegionManifest` rolls a deterministic count of 1–3 regions and gives each region its
|
||||||
|
own native parameter vector and, for structure candidates, its own recipe. `VF_QueryStrateRegion`
|
||||||
|
assigns XY through jittered `VoxelHash::Cell` sites on a 256-voxel lattice; the pure query is keyed
|
||||||
|
by `(WorldXY, Seed, StrateIndex)` and the worker-side `FVoxelStrateRegionPartitionCache` prepares
|
||||||
|
the site window and integer query grid once per chunk. `FLateralRegionBlendOp` evaluates the primary
|
||||||
|
region core and, inside a 24-voxel band, the nearest different-region core, then lerps **density**
|
||||||
|
(not native parameters). The four structural posts are appended once to the parent after the
|
||||||
|
blend. Count==1 uses the ordinary one-stack path and is covered by native and recipe bit-identity
|
||||||
|
oracles. `ClassifyBox` proves a single-region/non-band box only from a conservative Lipschitz gap
|
||||||
|
bound; otherwise it folds every region and returns `Mixed` on any disagreement or unknown. The
|
||||||
|
runtime/editor override reads the immutable manifest per chunk; it never searches lattice seeds per
|
||||||
|
voxel. The dedicated `VoxelForge.Composer.LateralRegions` test reports cross-boundary brute-force
|
||||||
|
box samples, interior-vs-band timing, 16 opposite-region passage-mouth trials, and a saved preview.
|
||||||
|
|
||||||
### 3.2e Height-space operators — `Public/VoxelHeightOp.h` + `Private/VoxelHeightOpStack.cpp`
|
### 3.2e Height-space operators — `Public/VoxelHeightOp.h` + `Private/VoxelHeightOpStack.cpp`
|
||||||
⚠️ **Feeds nothing yet** — built and exercised only by `VoxelForge.OpStack.SurfaceHeightEquivalence`.
|
⚠️ **Feeds nothing yet** — built and exercised only by `VoxelForge.OpStack.SurfaceHeightEquivalence`.
|
||||||
@@ -198,20 +260,36 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
|
|||||||
| Group | Fields (line) |
|
| Group | Fields (line) |
|
||||||
|-------|---------------|
|
|-------|---------------|
|
||||||
| Streaming | `ViewDistanceXY=16`, `ViewDistanceUp/Down=5`, `MaxConcurrentTasks=16`, `MaxMeshAppliesPerFrame=4` (defaults — actual values live on the data asset) |
|
| Streaming | `ViewDistanceXY=16`, `ViewDistanceUp/Down=5`, `MaxConcurrentTasks=16`, `MaxMeshAppliesPerFrame=4` (defaults — actual values live on the data asset) |
|
||||||
| Clipmap | `ClipRadius`, `MaxClipLevel`, `FullResClipLevels`, `CoarseTileCells`, `RenderDistanceChunks` (custom horizontal reach: the outermost shell keeps generating until it covers this many chunks; 0 = off), `bFarSheetRing` + `FarSheetSpanLevels` (F18 — the render-distance ring streams per-surface SHEETS: level MaxClipLevel+span heightfield tiles instead of MC, see `GenerateSheetMesh`), skirts, `LODOctaveDrop` (T2.b octave drop on coarse tiles — 0 = off/byte-identical) (the old `LOD0/1Distance` + `ContentMaxLevel` were dead → removed) |
|
| Clipmap | `ClipRadius`, `MaxClipLevel=5`, `FullResClipLevels`, `CoarseTileCells`, `RenderDistanceChunks=768` (measured ship defaults; custom horizontal reach: the outermost shell keeps generating until it covers this many chunks; 0 = off), `bFarSheetRing=false` by default, plus `FarSheetSpanLevels` (F18 — the optional render-distance ring streams per-surface SHEETS: level MaxClipLevel+span heightfield tiles instead of MC, see `GenerateSheetMesh`), skirts, `LODOctaveDrop` (T2.b octave drop on coarse tiles — 0 = off/byte-identical). Existing serialized data assets retain their authored values until deliberately migrated. |
|
||||||
|
| World bounds | `WorldRadiusVoxels=8192` and `EdgeSealThickness=64` (actor-space XY radial shell; radius 0 = true legacy no-op) |
|
||||||
| Lighting | `bEnableDensityVolume` + DensityVolume* tunables (§3.11 density clipmap / mini-sun shadows) |
|
| Lighting | `bEnableDensityVolume` + DensityVolume* tunables (§3.11 density clipmap / mini-sun shadows) |
|
||||||
| Rendering | `VoxelMaterial` (61) |
|
| Rendering | `VoxelMaterial` (61) |
|
||||||
| Strates | `Seed` (69), `CurrentSeason=1` (73), `StratePool` (78), `FixedStrates` map (83), `TotalStrates=10` (87) |
|
| Strates | Optional cooked `Season`; effective seed/spine/radius/season accessors read it when assigned. Otherwise `Seed`, `CurrentSeason=1`, `StratePool`, `FixedStrates`, and `TotalStrates=10` retain the authored path. |
|
||||||
| Carving budget | `MaxModifications=0` (97), `MaxBrushRadius=15` (102), `MaxTotalVolume=0` (107). 0 = unlimited. |
|
| Carving budget | `MaxModifications=0` (97), `MaxBrushRadius=15` (102), `MaxTotalVolume=0` (107). 0 = unlimited. |
|
||||||
|
|
||||||
### 3.5 World orchestrator — `Public/VoxelWorld.h` + `Private/VoxelWorld.cpp`
|
### 3.5 World orchestrator — `Public/VoxelWorld.h` + `Private/VoxelWorld.cpp`
|
||||||
`AVoxelWorld : AActor` — owns everything, drives streaming. Also `FChunkResult` struct
|
`AVoxelWorld : AActor` — owns everything, drives streaming. Also `FChunkResult` struct
|
||||||
(VoxelWorld.h:35) = async task payload (coord, chunk, meshdata, LOD, **Epoch**).
|
(VoxelWorld.h:35) = async task payload (coord, chunk, meshdata, LOD, **Epoch**, request timing,
|
||||||
|
worker-built stream geometry counts/bytes, desired-set epoch and obsolete/cancellation state).
|
||||||
|
|
||||||
**Owned objects (UPROPERTY):** `Settings`, `Generator`, `Mesher`, `StrateManager`,
|
**Owned objects (UPROPERTY):** `Settings`, `Generator`, `Mesher`, `StrateManager`,
|
||||||
`DiffLayer` (VoxelWorld.h:55-78). **Storage:** `Chunks` map, `ChunkMeshes` map,
|
`DiffLayer` (VoxelWorld.h:55-78). **Storage:** `Chunks` map, `ChunkMeshes` map,
|
||||||
`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`, and the
|
||||||
|
game-thread `PendingTileCancellation` map whose per-request atomic flags are read-only on workers.
|
||||||
|
|
||||||
|
**⭐ 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 |
|
||||||
|--------|-----------|------|
|
|--------|-----------|------|
|
||||||
@@ -223,19 +301,26 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
|
|||||||
| `OnObjectModifiedInEditor` | 58 | Regenerates when a strate asset is edited (if `bLiveEditStrates`). |
|
| `OnObjectModifiedInEditor` | 58 | Regenerates when a strate asset is edited (if `bLiveEditStrates`). |
|
||||||
| `EndPlay` | 140 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. |
|
| `EndPlay` | 140 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. |
|
||||||
| `BeginPlay` | 177 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. |
|
| `BeginPlay` | 177 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. |
|
||||||
| `Tick` | 220 | `UpdateChunksAroundPosition(player)` + `ProcessPendingChunks()`. |
|
| `Tick` | 220 | `UpdateChunksAroundPosition(player, heading)` + `ProcessPendingChunks()`; the clean LOD0 latency summary is flushed at `EndPlay`. |
|
||||||
| `GetPlayerPosition` | 231 | Pawn position or zero. (`GetLODForChunk`/`LODToStep`/`IsChunkInRange` removed — dead since the clipmap.) |
|
| `GetPlayerPosition` | 231 | Pawn position or zero. (`GetLODForChunk`/`LODToStep`/`IsChunkInRange` removed — dead since the clipmap.) |
|
||||||
| `ProcessPendingChunks` | 301 | Drains ProcessQueue under per-frame budget; applies each via `ApplyTileResult`. |
|
| `ProcessPendingChunks` | 301 | Drains ProcessQueue under per-frame budget; reads the per-request cancellation token before removing its map entry, drops obsolete/aborted results, then applies each live result via `ApplyTileResult`. |
|
||||||
| `ApplyTileResult` | — | **Shared game-thread apply** for one `FChunkResult` (async drain + sync carve): discards stale epochs, marks loaded, ingests capture, EMPTY releases the tile's existing component (a re-gen can flip content→empty on band change — old geometry must not linger), else `ApplyMeshToTile`. Returns true iff a visible mesh uploaded (counts the budget). Doesn't touch `PendingTiles` (caller's). |
|
| `ApplyTileResult` | — | **Shared game-thread apply** for one `FChunkResult` (async drain + sync carve): discards stale generation epochs and keys no longer desired, marks loaded, ingests capture, EMPTY releases the tile's existing component (a re-gen can flip content→empty on band change — old geometry must not linger), else `ApplyMeshToTile`. Returns true iff a visible mesh uploaded (counts the budget). Doesn't touch `PendingTiles` (caller's). |
|
||||||
| `GenerateTileResult` | — | **Shared worker-side gen** for one tile (async `LoadTile` task + sync `SyncRemeshTile`): ClassifyTile (T1.d) → `GenerateMesh`/`GenerateSheetMesh` → `BuildTileStreamSet`. Reads Generator/Mesher only → safe on a worker or the game thread; fills `FChunkResult`, no enqueue. |
|
| `FVoxelStackSampler` / `FScopedVoxelStackRegistration` (`VoxelStackSampler.h/.cpp`) | — | **Diagnostic raw stack sampler, off by default.** A sampler thread captures registered generation workers' stacks (`CaptureThreadStackBackTrace`) every N µs and tags each with the tile's LOD. It writes bounded raw PCs to CSV plus a symbolization-free summary under `Saved/`; symbol resolution is deferred until after process exit so the measured process never initializes/calls DbgHelp for this diagnostic. Lifecycle teardown is serialized and registration TLS is session-tagged. Registration is per tile TASK in `LoadTile` (and the export's tasks), never per sample; disabled costs one relaxed atomic load. Game `-voxel.SampleStacks=<us>`, commandlet `-samplestacks[=us]`. |
|
||||||
|
| Room-cache performance (2026-09-13): `VF_FindPlayerFitPointForRoomMemoized` / `GPlayerFitMemo` (`VoxelCaveMorphology.cpp`), `voxel.TileCacheWindow` + `VoxelGenLOD::TileOriginVoxels/TileCellsPerAxis` (`VoxelGenerator.h`), `FChunkSDFSpatialIndex` | — | **Player-fit memo:** exact 97-word key, process-wide, `voxel.PlayerFitMemo`. **Tile cache window:** the room cache covers the requesting tile's footprint plus a fixed collect margin. It is set via `TGuardValue` in `GenerateMesh`/classify, and is window-invariant by construction (the margin does not grow with the tile). **LOD-gated:** fused path at LOD ≥ `voxel.TileCacheWindowMinLOD` (3), op-stack at LOD ≥ `voxel.TileCacheWindowOpStackMinLOD` (4); lower LODs keep the 4-chunk worker window, because the tile window slowed LOD0 evaluation ~6%. Strates whose authored terrain ops exceed the proven 256-voxel extent (or are malformed/unloaded) fall back to the legacy window (`VF_ComputeTerrainOpEnvelope`). `voxel.SpatialIndex` is -1 auto (off at fused LOD0, on elsewhere), 0 or 1. **Spatial index:** ordered XY buckets over cache primitives, exact rejection. The `static_assert` in `VoxelStrateTypes.h` ties `VF_STRATE_PARAM_FIELDS` to `FStrateGenerationParams` (83 fields + 8 B padding). |
|
||||||
|
| `GenerateTileResult` | — | **Shared worker-side gen** for one tile (async `LoadTile` task + sync `SyncRemeshTile`): [ClassifyTile (T1.d) + exact validation, only when `voxel.OuterClassifierMode=1`; **default 0 since 2026-09-13**] → `GenerateMesh`/`GenerateSheetMesh` → `BuildTileStreamSet`; records vertex count and resident stream allocation bytes for the horizon trace. Reads Generator/Mesher only → safe on a worker or the game thread; fills `FChunkResult`, no enqueue. |
|
||||||
|
| `RecordTileHash` / `WriteTileHashDump` | — | Headless `-voxel.TileHashDump=<absolute path>` parity instrument. Records the exact `FVoxelMeshData` used by the streaming result, including empty/classified tiles, then writes sorted absolute tile keys, LOD step/cells, content-band limits, settings digest context, and the shared `VoxelGeometryHash` at `EndPlay`. |
|
||||||
| `SyncRemeshTile` | — | **INSTANT DIG**: level-0 same-frame re-mesh on the game thread (`GenerateTileResult` + `ApplyTileResult` inline, Cells=CHUNK_SIZE/Step=1 + strate band, no capture). Used for the tile under the brush centre so a carve is visible THIS frame; one full-res gen on the game thread. |
|
| `SyncRemeshTile` | — | **INSTANT DIG**: level-0 same-frame re-mesh on the game thread (`GenerateTileResult` + `ApplyTileResult` inline, Cells=CHUNK_SIZE/Step=1 + strate band, no capture). Used for the tile under the brush centre so a carve is visible THIS frame; one full-res gen on the game thread. |
|
||||||
| `UpdateChunksAroundPosition` | 362 | Builds desired set, sorts by distance, loads/unloads, handles LOD changes. **Delta cull**: `BuildDesiredTiles` returns the LEAVERS (stamped `DesiredStamped` map, one sweep) — only those + `TransitionHold` are considered per crossing, not every loaded tile. `BuildDesiredTiles` also applies `RenderDistanceChunks`: the outermost shell widens to cover the distance — as level-MaxClipLevel MC tiles, or (F18 `bFarSheetRing`) as a SHEET ring at level MaxClipLevel+span (covered-check vs the MaxClipLevel box; `VF_OuterShell` shared with `IsTileInClipRange` so the cull sees the same horizon), dz pre-clamped to the vertical band. **§9.3 anchors:** also prunes dead `StreamingAnchors` + detects their chunk crossings → rebuilds the desired set when an anchor moves/(un)registers (`bAnchorsMoved`/`bForceDesiredRebuild`), same cadence as player movement. §8.10. |
|
| `UpdateChunksAroundPosition` | 362 | Builds desired set, cancels pending keys that left it, sorts from the **true pawn position**, submits the occupied/support LOD0 tile and the next tile along the pawn heading as an absolute `BackgroundHigh` prefix, then loads/unloads and handles LOD changes. **Delta cull**: `BuildDesiredTiles` returns the LEAVERS (stamped `DesiredStamped` map, one sweep) — only those + `TransitionHold` are considered per crossing, not every loaded tile. `BuildDesiredTiles` also applies `RenderDistanceChunks`: the outermost shell widens to cover the distance — as level-MaxClipLevel MC tiles, or (F18 `bFarSheetRing`) as a SHEET ring at level MaxClipLevel+span (covered-check vs the MaxClipLevel box; `VF_OuterShell` shared with `IsTileInClipRange` so the cull sees the same horizon), dz pre-clamped to the vertical band. **§9.3 anchors:** also prunes dead `StreamingAnchors` + detects their chunk crossings → rebuilds the desired set when an anchor moves/(un)registers (`bAnchorsMoved`/`bForceDesiredRebuild`), same cadence as player movement. §8.10. |
|
||||||
| `BuildDesiredTiles` | ~722 | Builds `DesiredSorted`+`DesiredStamped` (player clipmap shells + F18 sheet ring) then `AddAnchorDesiredTiles()` before the leaver sweep. |
|
| `AdvanceHeadlessCollisionGateStressTest` / `ObserveHeadlessCollisionGateStressTest` / `MaybeFinishHeadlessCollisionGateStressTest` | — | Explicit `-voxel.TestCollisionGateStress=1` harness only. The `GetDensityAt` column at capsule feet defines the local air/solid surface; each frame records actor position, velocity, direct foot tile and gate support readiness, gate state, signed feet clearance, and a ≤0.5-voxel sweep between positions. PASS requires no measured clearance below −5 cm; support submission/readiness, travel, and holds are diagnostics. Result logs include the terminal density column and the first crossing below spawn height. |
|
||||||
|
| `BuildDesiredTiles` | ~722 | Builds `DesiredSorted`+`DesiredStamped` (player clipmap shells + F18 sheet ring) then `AddAnchorDesiredTiles()` before the leaver sweep; it orders from the true local pawn position and builds `CriticalDesiredTiles` for the occupied/support and heading tiles. Also rebuilds `DesiredTransitionDescendants` (ancestor → desired descendants) for the reconcile pass. |
|
||||||
|
| `VoxelClipmapDesiredTiles::Build` / `AlignedRingBounds` (`Private/VoxelClipmapDesiredTiles.h`) | — | The pure clipmap selector. **Every finer ring snaps outward to a child-pair boundary** (`AlignedRingBounds`, alignment 2; the last MC ring aligns to the sheet grouping width), so a coarser tile is wholly inside or wholly outside the finer ring: **no two desired tiles of different levels overlap** (a 7-wide odd ring used to leave one half-covered coarse tile per axis side, drawn over the fine one: 635 pairs at the owner config). Cost: LOD0 ring 343 → 512 tiles. Guarded by `VoxelForge.Streaming.ClipmapDesiredTilesNoOverlapCoverage`. Anchor tiles appended later are outside this guarantee. |
|
||||||
|
| `ReconcileReadyTransitionVisibility` | — | Visibility-only pass while streaming is unsettled: hides a retired tile once all desired tiles over its footprint are loaded, reveals new tiles once no visible retired tile overlaps them. **Indexed** (dyadic ancestors + `DesiredTransitionDescendants`, visible retired tiles collected once per pass): moving p95 32 ms → 0.31 ms. `voxel.MeasureReadyTransitionVisibility=1` logs per-frame samples. |
|
||||||
|
| `RequestCollisionGateSupportTile` / `UpdatePawnCollisionGate` | — | The gate submits the pawn's level-0 support tile **immediately** as `CollisionCritical`, even before the first desired set exists (the desired rebuild then adopts the same pending key). The gate holds downward Z only, with **no timeout** (the old 2 s release fired at every startup, while the game thread applied nothing for ~2.2 s). |
|
||||||
| `AddAnchorDesiredTiles` | — | **§9.3 multi-anchor:** folds each `FVoxelStreamingAnchor`'s thin level-0 box (`XYRadiusChunks`/`ZBelowChunks`/`ZAboveChunks`) into the SAME desired set (deduped by stamp) so AI/remote players keep collision loaded around them; delta cull releases them on move/unregister. **§9.4:** a tile only a CollisionOnly anchor wants (clipmap didn't stamp it) → `CollisionOnlyTiles` → hidden at apply. Zero cost when no anchors. |
|
| `AddAnchorDesiredTiles` | — | **§9.3 multi-anchor:** folds each `FVoxelStreamingAnchor`'s thin level-0 box (`XYRadiusChunks`/`ZBelowChunks`/`ZAboveChunks`) into the SAME desired set (deduped by stamp) so AI/remote players keep collision loaded around them; delta cull releases them on move/unregister. **§9.4:** a tile only a CollisionOnly anchor wants (clipmap didn't stamp it) → `CollisionOnlyTiles` → hidden at apply. Zero cost when no anchors. |
|
||||||
| `ReconcileAnchorTileVisibility` | — | **§9.4:** after each rebuild, toggle `SetVisibility` on ALREADY-LOADED tiles that flipped render↔collision-only (diff `CollisionOnlyTiles` vs prev — bounded, no O(loaded) scan; unhide only if still desired). No-op without CollisionOnly anchors. |
|
| `ReconcileAnchorTileVisibility` | — | **§9.4:** after each rebuild, toggle `SetVisibility` on ALREADY-LOADED tiles that flipped render↔collision-only (diff `CollisionOnlyTiles` vs prev — bounded, no O(loaded) scan; unhide only if still desired). No-op without CollisionOnly anchors. |
|
||||||
| `RegisterStreamingAnchor` / `UnregisterStreamingAnchor` | — | **BlueprintCallable §9.3:** add/remove an actor as a streaming anchor (`EVoxelAnchorPolicy` CollisionOnly/FullVisual + XY/ZBelow/ZAbove box). Idempotent; forces a rebuild next Tick. §9.4: CollisionOnly tiles cook collision but are hidden (no draw/VSM) unless the player clipmap wants them too. |
|
| `RegisterStreamingAnchor` / `UnregisterStreamingAnchor` | — | **BlueprintCallable §9.3:** add/remove an actor as a streaming anchor (`EVoxelAnchorPolicy` CollisionOnly/FullVisual + XY/ZBelow/ZAbove box). Idempotent; forces a rebuild next Tick. §9.4: CollisionOnly tiles cook collision but are hidden (no draw/VSM) unless the player clipmap wants them too. |
|
||||||
| `LoadChunk` | 445 | Budget check → `UE::Tasks::Launch` background gen+mesh; RAII task guard. Worker runs `Generator->ClassifyTile` first (T1.d): AllSolid/AllAir ⇒ skip `GenerateMesh`, tile stays empty (capture tiles always generate). STRATE CONTENT CUT: tiles ≥ `StrateContentCutMinLevel` pass the player-strate band (`MeshBandChunkLo/Hi` → voxels) to `GenerateMesh` + stamp it on `FChunkResult::BandChunkLo/Hi`; band change re-queues via `BandRemeshQueue` (see `UpdateChunksAroundPosition`). TOO-COARSE SKIP: if one cell is taller than the band (`Step > band height` — level ≥7 territory) the tile is enqueued EMPTY without launching a task (cell-granular cut could only render garbage). F18: `Tile.Level > MaxClipLevel` = SHEET tile → routed to `GenerateSheetMesh` (band mid-chunk = strate ref; band unarmed ⇒ empty; MC-ring sampling density, cells capped 128/axis; carries the XY hole `SheetHole*Vox` — hole moves ⇒ overlapping sheets re-queue via `BandRemeshQueue`, see `UpdateChunksAroundPosition`). §8.10. |
|
| `LoadTile` | 445 | Budget check → `UE::Tasks::Launch` background gen+mesh; RAII task guard. Worker runs `Generator->ClassifyTile` first (T1.d): AllSolid/AllAir ⇒ skip `GenerateMesh`, tile stays empty (capture tiles always generate). It carries `GenerationEpoch`/desired epoch and checks `bShuttingDown`, generation pause, and its read-only cancellation token between expensive stages; obsolete work returns through `ProcessQueue`. STRATE CONTENT CUT: tiles ≥ `StrateContentCutMinLevel` pass the player-strate band (`MeshBandChunkLo/Hi` → voxels) to `GenerateMesh` + stamp it on `FChunkResult::BandChunkLo/Hi`; band change re-queues via `BandRemeshQueue` (see `UpdateChunksAroundPosition`). TOO-COARSE SKIP: if one cell is taller than the band (`Step > band height` — level ≥7 territory) the tile is enqueued EMPTY without launching a task (cell-granular cut could only render garbage). F18: `Tile.Level > MaxClipLevel` = SHEET tile → routed to `GenerateSheetMesh` (band mid-chunk = strate ref; band unarmed ⇒ empty; MC-ring sampling density, cells capped 128/axis; carries the XY hole `SheetHole*Vox` — hole moves ⇒ overlapping sheets re-queue via `BandRemeshQueue`, see `UpdateChunksAroundPosition`). §8.10. |
|
||||||
| `UnloadTile` | — | Clears tile state; the component is PARKED in the pool (T2.c), not destroyed. |
|
| `UnloadTile` | — | Clears tile state; marks any matching generation request obsolete without removing its pending slot, so a late result cannot clear a replacement request; the component is PARKED in the pool (T2.c), not destroyed. |
|
||||||
| `ApplyMeshToTile` | — | Upload geometry. One component per tile (clipmap keeps count low; supersedes the old region batching); worker-built streams (T1.f) → `CreateSectionGroup(MoveTemp)`. Reuses the component's existing `URealtimeMesh` (no per-apply mesh alloc). **F17: two polygroups** (0 ground / 1 sky-cap, per-triangle class from the mesher) → RMC auto-section per non-empty group; slot 0 = override/default material, slot 1 = `CeilingMaterial` (fallback ground); config gated by `FChunkResult::bHasGroundTris/bHasCeilingTris`. Takes `FChunkResult&`; strate lookups clamp Z into `Result.BandChunkLo/Hi` (strate content cut) — ground at clamped bottom chunk, cap at clamped top (mid = gap fallback). Collision level-0 only (T1.c) both groups; shadow per SECTION: ground casts at level≤1, cap never. **§9.4:** `SetVisibility(false)` when the tile is in `CollisionOnlyTiles` (anchor-only, hidden — collision still cooks). §8.10 + ARCHITECTURE SurfaceWorld row. |
|
| `ApplyMeshToTile` | — | Upload geometry. One component per tile (clipmap keeps count low; supersedes the old region batching); worker-built streams (T1.f) → `CreateSectionGroup(MoveTemp)`. Reuses the component's existing `URealtimeMesh` (no per-apply mesh alloc). **F17: two polygroups** (0 ground / 1 sky-cap, per-triangle class from the mesher) → RMC auto-section per non-empty group; slot 0 = override/default material, slot 1 = `CeilingMaterial` (fallback ground); config gated by `FChunkResult::bHasGroundTris/bHasCeilingTris`. Takes `FChunkResult&`; strate lookups clamp Z into `Result.BandChunkLo/Hi` (strate content cut) — ground at clamped bottom chunk, cap at clamped top (mid = gap fallback). Collision level-0 only (T1.c) both groups; shadow per SECTION: ground casts at level≤1, cap never. **§9.4:** `SetVisibility(false)` when the tile is in `CollisionOnlyTiles` (anchor-only, hidden — collision still cooks). §8.10 + ARCHITECTURE SurfaceWorld row. |
|
||||||
| `AcquireTileComponent` / `ReleaseTileComponent` | — | **T2.c component pool** (`TileComponentPool`, bounded): park on unload (geometry+collision stripped, hidden, stays registered), pop on apply — no `NewObject`/`RegisterComponent`/GC churn during travel & regen. §8.10. |
|
| `AcquireTileComponent` / `ReleaseTileComponent` | — | **T2.c component pool** (`TileComponentPool`, bounded): park on unload (geometry+collision stripped, hidden, stays registered), pop on apply — no `NewObject`/`RegisterComponent`/GC churn during travel & regen. §8.10. |
|
||||||
| `GetStrateAtPosition` | 965 | Gameplay query → strate index. |
|
| `GetStrateAtPosition` | 965 | Gameplay query → strate index. |
|
||||||
@@ -244,8 +329,8 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
|
|||||||
| `CarveAtPosition` / `FillAtPosition` | 691 / 709 | Build `FVoxelModification` → `ApplyModification`. |
|
| `CarveAtPosition` / `FillAtPosition` | 691 / 709 | Build `FVoxelModification` → `ApplyModification`. |
|
||||||
| `ApplyModification` | ~1581 | Single funnel for all brushes: DiffLayer → **sync-remesh the brush-centre level-0 tile** (`SyncRemeshTile`, instant hole; skipped if that tile is mid-gen — would race a stale in-flight result) → `RemeshDirtyChunks(..., excludeCenter)` for the neighbours → `RemoveDecorationsInSphere`. |
|
| `ApplyModification` | ~1581 | Single funnel for all brushes: DiffLayer → **sync-remesh the brush-centre level-0 tile** (`SyncRemeshTile`, instant hole; skipped if that tile is mid-gen — would race a stale in-flight result) → `RemeshDirtyChunks(..., excludeCenter)` for the neighbours → `RemoveDecorationsInSphere`. |
|
||||||
| `ClearAllModifications` | 726 | Clears diff layer, regenerates. |
|
| `ClearAllModifications` | 726 | Clears diff layer, regenerates. |
|
||||||
| `ChangeSeed` | 740 | **Season reset**: new seed everywhere, clear diffs, bump season, reload. |
|
| `ChangeSeed` | 740 | **Legacy season reset**: new seed everywhere, clear diffs, bump season, reload. Rejected while a cooked season owns the seed. |
|
||||||
| `GetCurrentSeed` / `GetCurrentSeason` | 784 / 789 | Accessors. |
|
| `GetCurrentSeed` / `GetCurrentSeason` / `GetCurrentSeasonContentHash` | — | Effective cooked-or-legacy identity; the hash is the join-time stale-season comparison value. |
|
||||||
| `RemeshDirtyChunks` | 798 | Queue loaded level-0 dirty tiles onto `DirtyRemeshQueue` (async re-mesh, no pop) + `MarkDirtyVoxelBox` the volume. Optional `ExcludeTile` = the sync'd centre. Drained FIRST in the submit loop at **BackgroundHigh** (ahead of streaming/band) so a dig never waits behind streaming; in-flight tiles stay QUEUED (not dropped) so a stale pre-carve result is corrected once it lands — fixes "hole shows up a beat late / not until I move". |
|
| `RemeshDirtyChunks` | 798 | Queue loaded level-0 dirty tiles onto `DirtyRemeshQueue` (async re-mesh, no pop) + `MarkDirtyVoxelBox` the volume. Optional `ExcludeTile` = the sync'd centre. Drained FIRST in the submit loop at **BackgroundHigh** (ahead of streaming/band) so a dig never waits behind streaming; in-flight tiles stay QUEUED (not dropped) so a stale pre-carve result is corrected once it lands — fixes "hole shows up a beat late / not until I move". |
|
||||||
|
|
||||||
> **Game-thread profiling (Perf):** `AVoxelWorld::Tick` and its sub-steps are wrapped in `TRACE_CPUPROFILER_EVENT_SCOPE` — `VoxelForge_Tick / UpdateChunks / BuildDesiredTiles / CullTiles / SubmitTiles / ProcessPending / ProcessUnload / UpdateDecorations / UpdateWater`. Capture a `Count/Incl/Excl` Insights timer export and read the `Excl` column to see which step owns the per-frame cost (the actor tick shows as `BP_VoxelWorld_C` if subclassed in BP). `VoxelForge_ClassifyTile` (T1.d) / `VoxelForge_GenerateMesh` + `VoxelForge_BuildStreams` are worker-side (off the frame): the RMC `FRealtimeMeshStreamSet` is now built on the gen worker (`BuildTileStreamSet`) and carried on `FChunkResult::Streams` (TSharedPtr), so `ApplyMeshToTile` is game-thread-cheap — just material/ceiling resolve + `CreateSectionGroup(MoveTemp)`. See ARCHITECTURE §8.10 "Worker-built StreamSet (T1.f)".
|
> **Game-thread profiling (Perf):** `AVoxelWorld::Tick` and its sub-steps are wrapped in `TRACE_CPUPROFILER_EVENT_SCOPE` — `VoxelForge_Tick / UpdateChunks / BuildDesiredTiles / CullTiles / SubmitTiles / ProcessPending / ProcessUnload / UpdateDecorations / UpdateWater`. Capture a `Count/Incl/Excl` Insights timer export and read the `Excl` column to see which step owns the per-frame cost (the actor tick shows as `BP_VoxelWorld_C` if subclassed in BP). `VoxelForge_ClassifyTile` (T1.d) / `VoxelForge_GenerateMesh` + `VoxelForge_BuildStreams` are worker-side (off the frame): the RMC `FRealtimeMeshStreamSet` is now built on the gen worker (`BuildTileStreamSet`) and carried on `FChunkResult::Streams` (TSharedPtr), so `ApplyMeshToTile` is game-thread-cheap — just material/ceiling resolve + `CreateSectionGroup(MoveTemp)`. See ARCHITECTURE §8.10 "Worker-built StreamSet (T1.f)".
|
||||||
@@ -263,14 +348,14 @@ This is **where terrain shape lives.**
|
|||||||
| `CellularNoise3D` (static) | 101 | Worley/cellular — grotto/scallop. |
|
| `CellularNoise3D` (static) | 101 | Worley/cellular — grotto/scallop. |
|
||||||
| `ApplyBoundarySeal` (static) | 170 | Solidifies strate top/bottom shells. |
|
| `ApplyBoundarySeal` (static) | 170 | Solidifies strate top/bottom shells. |
|
||||||
| `ApplyPassageCarving` (static) | 197 | Punches passages/elevator through the seal. |
|
| `ApplyPassageCarving` (static) | 197 | Punches passages/elevator through the seal. |
|
||||||
| `InitializeSettings` | 211 | Copies seed from settings. |
|
| `InitializeSettings` | 211 | Copies the effective seed, spine radius, and global XY world-bound settings from `UVoxelSettings` (the season owns all three when assigned); radius 0 preserves the unbounded field. |
|
||||||
| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. Its `CP_*` per-chunk state is keyed by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; every key component is an integer compare and a different generator/world cannot inherit the previous owner's cached params or op stack. |
|
| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, materialises a cooked-season recipe on the existing per-chunk refetch when present, dispatches, then adds disturbances/diff. Its `CP_*` state is keyed by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; workers copy immutable recipe/vector data and never read editor composer state. |
|
||||||
| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. ⚠️ Takes **required** `ParamsFingerprint` + `LayoutVersion` since the AUDIT §C2 fix (2026-07-28) — they go into the SDF cache key so a chunk can no longer be evaluated against a neighbour's rooms. Callers compute the CRC **once per chunk** (`CP_TunnelFP`), never per voxel. |
|
| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. ⚠️ Takes **required** `ParamsFingerprint` + `LayoutVersion` since the AUDIT §C2 fix (2026-07-28) — they go into the SDF cache key so a chunk can no longer be evaluated against a neighbour's rooms. Callers compute the CRC **once per chunk** (`CP_TunnelFP`), never per voxel. The optional `voxel.WormBlockSkip` path (default 0) uses `VF_TryGetWormBlockSkip` + a worker-local 4³ lattice-block cache to prove `|N1| >= WormThreshold` and remove both worm noise calls only; it never changes `NetworkMask` or the fallback field path. |
|
||||||
| **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. |
|
| **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. |
|
||||||
| `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. |
|
| `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. |
|
||||||
| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. §8.14. |
|
| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. The field-preserving LOD0 pass removes only needless temporaries/default construction; every output remains bit-identical. §8.14. |
|
||||||
| `VF_BuildOpStackForChunk` (file-static) | — | **The archetype → stack mapping, written down once.** `GetDensityAt` and `ClassifyTile` both call it; params are passed in, never fetched here. A second copy would be the worst bug available in this file — a tile skipped on the verdict of a stack that is not the one producing its density is a hole. Returns false (⇒ caller falls back to the `switch`) for an unported archetype, missing params, or a **degenerate strate**, since five archetype functions early-out to air there and the stack deliberately has no such early-out. `Refs.Surface == nullptr` makes it refuse SurfaceWorld, which is how `ClassifyTile` keeps its own exact-lattice proof. |
|
| `VF_BuildOpStackForChunk` (file-static) | — | **The archetype → stack mapping, written down once.** `GetDensityAt` and `ClassifyTile` both call it; params are passed in, never fetched here. A second copy would be the worst bug available in this file — a tile skipped on the verdict of a stack that is not the one producing its density is a hole. Returns false (⇒ caller falls back to the `switch`) for an unported archetype, missing params, or a **degenerate strate**, since five archetype functions early-out to air there and the stack deliberately has no such early-out. `Refs.Surface == nullptr` makes it refuse SurfaceWorld, which is how `ClassifyTile` keeps its own exact-lattice proof. |
|
||||||
| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; **cave archetypes via `FVoxelOpStack::ClassifyBox` when the strate opted in** — see §3.2d for the six guards, all failing to `Mixed`; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. Its diagnostic-only not-op-stack bail attribution distinguishes a tile wholly inside the disabled slot, a boundary tile, and an unresolved layout; the classifier's conditions/returns are unchanged. §8.10. |
|
| `ClassifyTile` | — | **T1.d trivial-tile reject. ⛔ Not called by streaming by default since 2026-09-13** (`voxel.OuterClassifierMode=0`; net-negative in game, see ARCHITECTURE §8.10). Cooked/editor recipes now build the exact recipe stack and call its proved `ClassifyBox`; a tile crossing a slot/gap, any diff, or any disturbance returns `Mixed`. This replaces the old global `HasComposerRecipeOverride()` force-to-Mixed. Native cave/surface/gap proofs and all §8.10 caches remain unchanged. |
|
||||||
| `SampleRelief` / `SampleMoisture` | — | Climate fields (pure XY, [0,1]). Relief = shared source of truth for the relief map M. §8.14. |
|
| `SampleRelief` / `SampleMoisture` | — | Climate fields (pure XY, [0,1]). Relief = shared source of truth for the relief map M. §8.14. |
|
||||||
| `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. |
|
| `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. |
|
||||||
| `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. |
|
| `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. |
|
||||||
@@ -283,12 +368,73 @@ This is **where terrain shape lives.**
|
|||||||
> memoizes the strate index per (chunkZ, `GetLayoutVersion()`); `thread_local` per-cell lattice
|
> memoizes the strate index per (chunkZ, `GetLayoutVersion()`); `thread_local` per-cell lattice
|
||||||
> bakes cover slab columns (`GetSlabDensity` step 4), maze open edges, vertical shafts +
|
> bakes cover slab columns (`GetSlabDensity` step 4), maze open edges, vertical shafts +
|
||||||
> cross-connectors, floating-island constants, and the disturbance chasms/bridges/ridges; worm
|
> cross-connectors, floating-island constants, and the disturbance chasms/bridges/ridges; worm
|
||||||
> tunnels short-circuit the 2nd Perlin when N1 ≥ WormThreshold (N2 ≥ 0 ⇒ can't carve). Room
|
> tunnels short-circuit the 2nd Perlin when N1 ≥ WormThreshold (N2 ≥ 0 ⇒ can't carve). With
|
||||||
|
> `voxel.WormBlockSkip=1`, a worker-local cache proves whole 4³ lattice blocks only when the
|
||||||
|
> actual 3D Perlin bound (`L = 8.118988160`, including `VOXEL_NOISE_SCALE`) plus a `1e-3`
|
||||||
|
> output-rounding margin clears the threshold; failed proofs use the original per-sample path.
|
||||||
|
> Room
|
||||||
> shapes are pre-baked in `FCachedRoom` (§3.7). **T2.b:** per-voxel fractal call sites take their
|
> shapes are pre-baked in `FCachedRoom` (§3.7). **T2.b:** per-voxel fractal call sites take their
|
||||||
> octave count through `VoxelGenLOD::Eff(N)` (VoxelGenerator.h) — a `thread_local` bias set per
|
> octave count through `VoxelGenLOD::Eff(N)` (VoxelGenerator.h) — a `thread_local` bias set per
|
||||||
> tile by the mesher drops tail octaves on coarse tiles (opt-in `LODOctaveDrop`, default 0 = off);
|
> tile by the mesher drops tail octaves on coarse tiles (opt-in `LODOctaveDrop`, default 0 = off);
|
||||||
> XY-field noise (heightfield/ceiling/relief/moisture) deliberately stays un-biased. §8.10.
|
> XY-field noise (heightfield/ceiling/relief/moisture) deliberately stays un-biased. §8.10.
|
||||||
|
|
||||||
|
### 3.6b Strate measurement — `Public/VoxelStrateMeasure.h` + `Private/VoxelStrateMeasure.cpp`
|
||||||
|
Headless, read-only Tier 2 measurement pass. `VF_MeasureStrate` samples one strate between a
|
||||||
|
boundary-seal-derived (or explicitly overridden, including explicit zero) interior margin into a bounded grid.
|
||||||
|
When both `CoverPointA` and `CoverPointB` are set, the XY window is their AABB expanded by
|
||||||
|
`CoverMarginVoxels`; otherwise it is the legacy square centered on `CenterXY` with
|
||||||
|
`RadiusInVoxels`. Origin-rooted callers set `bIncludeOriginInCoverWindow` to expand that fitted
|
||||||
|
AABB to include `(0,0)` before the `MaxCells` check; an over-cap route window is refused rather
|
||||||
|
than cropped back to a mouth-only box.
|
||||||
|
It performs one deterministic 6-connected air flood fill, and derives fractions, components,
|
||||||
|
walkability, feature scale, and clearance from that grid. It then performs one bounded 4-neighbour
|
||||||
|
flood fill over the projected XY columns that contain a walkable surface; this is a floor-continuity
|
||||||
|
indicator, not another source-density sample or a 3D route proof. Metrics report the resolved margin and
|
||||||
|
the inclusive/exclusive voxel Z window used, the exact sampled dimensions/bounds, and the largest
|
||||||
|
component's deterministic lowest-cell representative point, cell count, and count of components
|
||||||
|
holding at least 1% of the air. The flood fill also retains component cell counts in discovery order,
|
||||||
|
so diagnostics can classify small non-largest components without a second source sample.
|
||||||
|
Callers may optionally receive that exact polarity grid and scalar values as `FVoxelStrateSampleGrid`; the
|
||||||
|
editor-only composer preview consumes this hand-off rather than sampling the world again. The filled view uses
|
||||||
|
the captured `Air` polarity, while the paired density=0 contour uses the captured scalar field directly (no blur
|
||||||
|
of the coarse raster). Capture refuses a grid over `MaxCells` before allocating it; ordinary metric callers do
|
||||||
|
not retain either buffer.
|
||||||
|
`VF_AreConnected` recovers a deterministic BFS parent path from the same kind of grid and checks
|
||||||
|
candidate routes at full voxel resolution before returning `Connected`. When a candidate is
|
||||||
|
refuted, its specific coarse cell edge is added to a deterministic array blocklist and BFS is
|
||||||
|
retried up to `MaxRouteRetries` (default 16). It returns an explicit `EVoxelConnectivityResult`;
|
||||||
|
a solid endpoint cell is repaired by snapping to the nearest air cell among its 26 neighbours
|
||||||
|
when possible, and reports the snap through its out flags. A missing coarse route after edge
|
||||||
|
exclusions returns `NotConnectedAtThisResolution`: that is evidence only at the grid's
|
||||||
|
resolution, because a corridor thinner than `SampleStep` is invisible. Exhausting the retry
|
||||||
|
budget (or finding a refutation in an endpoint segment with no coarse edge to exclude) returns
|
||||||
|
`CoarseLiedBudgetExhausted`, which is unknown rather than disconnected. Every `Connected` result
|
||||||
|
has a full-resolution air-verified route, and the query reports the alternate route retry count.
|
||||||
|
No UObject state, cache, actor, world, or PIE is required.
|
||||||
|
| Symbol | Role |
|
||||||
|
|--------|------|
|
||||||
|
| `FVoxelStrateMeasureSettings` / `FVoxelStrateMetrics` | Plain settings/result structs for bounded strate sampling and derived measurements; callers may override the interior margin (including zero to sample the seal) or fit the XY window to two points with a margin, and results identify the resolved window/dimensions/Z range, deterministic air-component facts, walkable floor-area columns/fraction, projected surface components/share, and median clearance. |
|
||||||
|
| `FVoxelStrateSampleGrid` | Optional one-pass `density > 0` `Air` polarity plus exact scalar `Density` capture; player-fit walks may also export the exact `PlayerFitMask` and count. Bounded by `MaxCells`, shared by explorer render/walk/export, and never retained on ordinary metrics calls. |
|
||||||
|
| `VF_MeasureStrate` | One-grid/one-flood-fill strate metrics; resolves either the legacy centered square or the two-point fitted AABB, and refuses invalid bounds or a grid over `MaxCells`. |
|
||||||
|
| `VF_AreConnected` | Coarse 6-connected BFS plus deterministic blocked-edge retries, with a full-resolution air recheck for every candidate route and explicit endpoint-solid, out-of-window, `NotConnectedAtThisResolution`, `CoarseLiedBudgetExhausted` (unknown), and connected outcomes; diagnostics include endpoint component facts and the retry count from that same grid. |
|
||||||
|
| `VF_DiagnoseConnectivity` | The same query plus each mouth's air-component size/share and exact geometric distance to the nearest cell of the other component; proximity is measured in coarse-cell coordinates and does not claim a route through solid. |
|
||||||
|
| `FVoxelPlayerFitWalkReport` / `VF_MeasurePlayerFitWalkWithSampler` | Editor-only experiential instrument over the existing exact `VF_` capsule/floor-fit mask: deterministic graph-walk distance, dead ends, reachable fit volume, and a labelled narrow-gap proxy; optionally exports the one sampled grid/mask for other explorer consumers; no second movement model. |
|
||||||
|
|
||||||
|
`VoxelForgeExploreCommandlet` builds a transient, fixed-definition world and calls the production
|
||||||
|
`UVoxelGenerator::GetDensityAt` and `UVoxelMarchingCubesMesher::GenerateMesh` paths. `walk` (or the
|
||||||
|
private seed walk used by render-only runs) captures one exact sampled grid and player-fit mask;
|
||||||
|
render and export reuse that hand-off. `render` calls the canonical 32³ mesher in a deterministic
|
||||||
|
tile grid (four tiles per axis for the 128³ default), merges the region once, builds a deterministic
|
||||||
|
CPU BVH, and rasterises eight viewpoints from the mesh without per-pixel density calls. `export`
|
||||||
|
uses that same canonical aggregate for metre-space OBJ plus `manifest.json` (canonical UVs remain
|
||||||
|
voxel-space). Output JSON/manifest are emitted by fixed-order writers and compared with a second
|
||||||
|
serialization before they are written. The commandlet keeps `WorldRadiusVoxels=0`, does not touch an
|
||||||
|
authored asset or diff layer, and enforces the default 25-minute / maximum 30-minute wall-clock
|
||||||
|
budget, reporting partial artifacts and completed modes on truncation. `-gameconfig=1` opts into the
|
||||||
|
authored settings' skirts/LOD octave drop and permits export-only parity of a single-strate asset;
|
||||||
|
the historical interior-slot requirement remains in force for walk/probe runs. `VoxelGeometryHash.h`
|
||||||
|
is the shared digest used by `Tools/VoxelForgeTest.ps1 -Scenario parity`.
|
||||||
|
|
||||||
### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp`
|
### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp`
|
||||||
Header is rich with inline docs. Two namespaces + a per-chunk cache system.
|
Header is rich with inline docs. Two namespaces + a per-chunk cache system.
|
||||||
|
|
||||||
@@ -305,6 +451,31 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system.
|
|||||||
| `EvaluateSDFCached` | 757 | **Phase 2** (per voxel): SmoothMin over cached rooms/tunnels; returns nearest room idx for terrain-op lookup. **Signature changed (perf pass 2): `RoomShapeVariety` param REMOVED** — the shape roll + capsule trig are pre-baked into `FCachedRoom` (`ShapeType/ShapeA/ShapeB/ShapeR`) by `BuildChunkCache`, bit-identical. |
|
| `EvaluateSDFCached` | 757 | **Phase 2** (per voxel): SmoothMin over cached rooms/tunnels; returns nearest room idx for terrain-op lookup. **Signature changed (perf pass 2): `RoomShapeVariety` param REMOVED** — the shape roll + capsule trig are pre-baked into `FCachedRoom` (`ShapeType/ShapeA/ShapeB/ShapeR`) by `BuildChunkCache`, bit-identical. |
|
||||||
| `EvaluateSDF` | 738 | Convenience wrapper (builds temp cache) for one-off queries. |
|
| `EvaluateSDF` | 738 | Convenience wrapper (builds temp cache) for one-off queries. |
|
||||||
|
|
||||||
|
`MakeStrateSeed` (h:~477) is the shared pure world-seed/strate-index salt used by both
|
||||||
|
`BuildChunkCache` and the TunnelNetwork/Underwater destination landing queries.
|
||||||
|
`VF_SuggestLandingPoint` (h:~550, implemented in `VoxelCaveMorphology.cpp`) is the pure player-fit
|
||||||
|
landing-site query: it evaluates the local archetype source through the same capsule/support
|
||||||
|
contract as the measurement pass — support within step height, walkable slope, and full capsule
|
||||||
|
clearance. Room queries use a room-floor core, slabs keep their requested XY, and the sparse
|
||||||
|
sources use bounded feature cores: a horizontal lattice corridor (one Maze cell), a selected
|
||||||
|
VerticalShafts tree shaft plus deterministic ledge-side offsets (up to two local shaft spacings at
|
||||||
|
the 0.6 site density), or a validated blob top (one IslandSpacing). It is intentionally not a
|
||||||
|
flood fill: the pure query can prove a local pose, not a large component. The
|
||||||
|
VerticalShafts result is therefore on the drainage tree, not merely in an open roughness disk.
|
||||||
|
It returns `false` when those feature-bearing conditions cannot be proven inside the supplied budget;
|
||||||
|
SurfaceWorld remains refused because its production height can depend on manager-owned biome/per-column
|
||||||
|
context. It never touches a generator, operator stack, manager, cache, or mutable state.
|
||||||
|
|
||||||
|
`FVoxelPassageLanding`, `VF_BuildPassageLanding`, `VF_EvaluatePassageLandingSDF`, and
|
||||||
|
`VF_IsPassageLandingFloor` (h:~56/~189, implemented near `.cpp:2059`) turn each inter-strate mouth
|
||||||
|
into a deterministic rounded room plus a hard support slab. The room is sized from the player
|
||||||
|
capsule (minimum 12 voxels / 3 m flat floor, 12 voxels / 3 m clear height, and at least 14 voxels /
|
||||||
|
3.5 m authored width); the `(0,0)` origin landing is enlarged for the reserved future shaft, while
|
||||||
|
inter-strate mouths remain local rooms and deliberately have no landing-to-root connector road.
|
||||||
|
The manager's shared thread-local passage shortlist serves tube, room, and floor evaluation; no
|
||||||
|
landing search occurs per voxel. `GeneratePassages` stores the full descriptors and the
|
||||||
|
order-independence test compares them bit-for-bit.
|
||||||
|
|
||||||
Performance note (h:209-220): caching rooms/tunnels once per chunk instead of per
|
Performance note (h:209-220): caching rooms/tunnels once per chunk instead of per
|
||||||
voxel is the single biggest CPU win.
|
voxel is the single biggest CPU win.
|
||||||
|
|
||||||
@@ -317,7 +488,7 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system.
|
|||||||
| `EVoxelNoiseType` | 99 | FBM/Ridged/Mixed/Cellular. |
|
| `EVoxelNoiseType` | 99 | FBM/Ridged/Mixed/Cellular. |
|
||||||
| `ECaveGeneratorType` | 146 | TunnelNetwork / FlatPlain / CrystalChamber. |
|
| `ECaveGeneratorType` | 146 | TunnelNetwork / FlatPlain / CrystalChamber. |
|
||||||
| `EVoxelStrateTransition` | 183 | Gradient / Hard / Interleaved boundary blends. |
|
| `EVoxelStrateTransition` | 183 | Gradient / Hard / Interleaved boundary blends. |
|
||||||
| **`FStrateGenerationParams`** | ~350 | The giant TunnelNetwork param bag (rock, worms, rooms, tunnels, warp, roughness, all terrain-op transport fields, boundary seal). `Lerp()` static blends two sets at boundaries — it expands the **`VF_STRATE_PARAM_FIELDS` X-macro** (defined just above the struct): **adding a field to the struct? add it to that list** or blends silently reset it to default. |
|
| **`FStrateGenerationParams`** | ~350 | The giant TunnelNetwork param bag (rock, worms, rooms, tunnels, warp, roughness, live terrain-detail fields, dead terrain-op transport slots, boundary seal). `Lerp()` static blends two sets at boundaries — it expands the **`VF_STRATE_PARAM_FIELDS` X-macro** (defined just above the struct): **adding a field to the struct? add it to that list** or blends silently reset it to default. |
|
||||||
| `FStrateTerrainOpEntry` | 965 | Soft-ptr to a terrain op + Weight + Probability. |
|
| `FStrateTerrainOpEntry` | 965 | Soft-ptr to a terrain op + Weight + Probability. |
|
||||||
| **`FSlabGenerationParams`** | 1019 | Floor/ceiling heights, roughness, columns, seal — for slab generators. |
|
| **`FSlabGenerationParams`** | 1019 | Floor/ceiling heights, roughness, columns, seal — for slab generators. |
|
||||||
| **`FPlacementProfile`** | ~1747 | **Shared placement vocabulary** for every scatter primitive (`FStrateDecoration`, `FStrateLandmark`, coming `FStrateSetPiece`): spawn (ActorClass/InstancedMesh), Filter gates (surface/slope/overhang/water/RequiredBiome + **F7 awareness `Conditions[]`** — `FTerrainCondition` relief/moisture/biome-border predicates, AND-ed, evaluated by `Generator::EvaluateTerrainConditions`), Transform (align/offsets/RotationOffset+RandomRotation/scale), Render (cull/shadow). Each primitive embeds it as `Profile` + keeps only its own DISTRIBUTION fields. Per-primitive defaults set in each struct's ctor (deco: scale 0.8-1.2 + RandomRotation.Yaw=360; landmark: Ceiling + no align). |
|
| **`FPlacementProfile`** | ~1747 | **Shared placement vocabulary** for every scatter primitive (`FStrateDecoration`, `FStrateLandmark`, coming `FStrateSetPiece`): spawn (ActorClass/InstancedMesh), Filter gates (surface/slope/overhang/water/RequiredBiome + **F7 awareness `Conditions[]`** — `FTerrainCondition` relief/moisture/biome-border predicates, AND-ed, evaluated by `Generator::EvaluateTerrainConditions`), Transform (align/offsets/RotationOffset+RandomRotation/scale), Render (cull/shadow). Each primitive embeds it as `Profile` + keeps only its own DISTRIBUTION fields. Per-primitive defaults set in each struct's ctor (deco: scale 0.8-1.2 + RandomRotation.Yaw=360; landmark: Ceiling + no align). |
|
||||||
@@ -336,24 +507,31 @@ groups by generator type.
|
|||||||
|
|
||||||
**`Public/VoxelStrateManager.h` + `.cpp`** — `UVoxelStrateManager : UObject` (h:108).
|
**`Public/VoxelStrateManager.h` + `.cpp`** — `UVoxelStrateManager : UObject` (h:108).
|
||||||
Maps depth→strate at runtime; owns passages.
|
Maps depth→strate at runtime; owns passages.
|
||||||
- `FVoxelPassage` (h:39): endpoints, radius, type, control points.
|
- `FVoxelPassage` (h:39): standing endpoints, radius, type, wandering control points, and
|
||||||
|
upper/lower local landing descriptors (room, floor, and door; no root connector).
|
||||||
- `FStrateSlot` (h:84): definition + chunk-Z range + index.
|
- `FStrateSlot` (h:84): definition + chunk-Z range + index.
|
||||||
| Method | .cpp line | Role |
|
| Method | .cpp line | Role |
|
||||||
|--------|-----------|------|
|
|--------|-----------|------|
|
||||||
| `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + shuffled pool), logs every **cave** slot whose operator-stack opt-in is disabled, then `GeneratePassages`. SurfaceWorld is deliberately excluded from that diagnostic because its exact-lattice T1.d path does not depend on the flag. |
|
| `Initialize` | 10 | Keeps the existing sequencing through `GeneratePassages`, but selects one slot source: a validated cooked season (exact ordered bounds/vector/recipe) or the unchanged authored fixed+sorted/shuffled pool. Assigned-invalid seasons fail closed. Authored source definitions are duplicated as content bags; generated slots use deterministic C++ defaults and zero auto-passages because schema 2 does not store passage configuration. |
|
||||||
| `GeneratePassages` | 146 | Deterministic passages between consecutive strates (per-type control points). |
|
| `GeneratePassages` | 247 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. Each mouth independently queries its own strate's pure player-fit `VF_SuggestLandingPoint` with its contract seed (cave room salt, otherwise world seed), may move within its archetype-specific budget, then receives a body-sized local room/floor. The origin landing is reserved for the future shaft; no landing-to-root road is generated. Unsupported/no-fit answers preserve the old random reach; a deterministic level dog-leg is inserted when the final ramp needs it to stay ≤44°. All descriptors and bounds are generated without reading another passage. |
|
||||||
| `EvaluateModifierSDF` | 357 | SDF of passages at a point (for carving). Per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`. §8.10. |
|
| `EvaluateModifierSDF` | 357 | SDF of passage tubes plus local landing rooms at a point. A per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`; the same cache serves floor membership. `FPassageEvaluationCache::FloorProjections` reuses native/generic floor projections and walkable-air results once per `(sample, passage)` using exact double XYZ equality and unchanged float outputs; `voxel.FloorRound1PassageProjectionCache=0` disables only this A/B cache. §8.10. |
|
||||||
|
| `ApplyPassageModifier` | 1319 | Shared legacy/op-stack passage post: carves tube and local landing air through the idempotent landing primitive, then restores the local landing support slab in internal positive-solid density. |
|
||||||
|
| `VF_ApplyPassageLandingCarving` | — | Idempotent density-independent landing-air threshold shared by the structural pass and the post-disturbance MC backstop; keeps legacy and operator-stack results bit-identical. |
|
||||||
|
| `ApplyPassageLandingFloorMC` | 1347 | Reasserts the same support slab after MC-space disturbances and before the final XY edge seal. |
|
||||||
| `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. |
|
| `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. |
|
||||||
|
| `AnyPassageLandingFloorNearBox` | — | Conservative local-landing-floor AABB guard. It kills the `AllAir` hypothesis wherever a proved landing floor can occur; the op-stack returns `Both` for the same box. |
|
||||||
| `FindSlotIndexForChunkZ` | 427 | Z → layout index. |
|
| `FindSlotIndexForChunkZ` | 427 | Z → layout index. |
|
||||||
| `GetStrateAt` / `GetStrateIndex` | 443 / 455 | World-Z queries. |
|
| `GetStrateAt` / `GetStrateIndex` | 443 / 455 | World-Z queries. |
|
||||||
| `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize`). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so editor rebuilds never serve stale data. |
|
| `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize` and by the editor composer override). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so live changes never serve stale data. |
|
||||||
| `GetStrateForChunk` | 466 | Chunk → definition. |
|
| `GetStrateForChunk` | 466 | Chunk → definition. |
|
||||||
| `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. |
|
| `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. |
|
||||||
| `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list — **now all 8 of 8** (Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, Underwater). **That list is written down here and nowhere else.** With every archetype ported the flag is now the *only* thing that decides the path, so ticking the box is no longer a no-op anywhere — it is a real switch onto the operator stack for that strate. |
|
| `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list — **now all 8 of 8** (Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, Underwater). **That list is written down here and nowhere else.** With every archetype ported the flag is now the *only* thing that decides the path, so ticking the box is no longer a no-op anywhere — it is a real switch onto the operator stack for that strate. |
|
||||||
|
| `GetRecipeForChunk` | — | Runtime immutable recipe/vector/strate-seed copy for cooked seasons; editor slot overrides use the same worker hand-off. |
|
||||||
| `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). |
|
| `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). |
|
||||||
| `GetBiomeContextForChunk` | — | Flatten the strate's `Biomes[]` + `BiomeMapParams` into a POD `FBiomeContext` for the biome field. Empty ⇒ biomes disabled. §8.14. |
|
| `GetBiomeContextForChunk` | — | Flatten the strate's `Biomes[]` + `BiomeMapParams` into a POD `FBiomeContext` for the biome field. Empty ⇒ biomes disabled. §8.14. |
|
||||||
| `GetGenerationParams` | 515 | **Blended** TunnelNetwork params (handles Gradient/Hard/Interleaved transitions). |
|
| `GetGenerationParams` | 515 | **Blended** TunnelNetwork params (handles Gradient/Hard/Interleaved transitions). |
|
||||||
| `BuildParamsFromDefinition` (static) | 771 | Base params + merge all referenced terrain op assets. The one place ops fold into params. |
|
| `SetComposerOverrideForStrate` / `GetComposerOverrideForChunk` | editor-only | Temporary density-only candidate overlay for one existing slot. Copies immutable params/recipe into the worker refetch path, leaves layout/content/passages intact, and bumps `PassagesVersion` to invalidate generator memos. |
|
||||||
|
| `BuildParamsFromDefinition` (static) | 1399 | Returns the definition's base `GenerationParams` only. Terrain-op assets are passed separately to `BuildChunkCache`, which applies one selected op per room and pre-bakes Column/Pit/Chimney features. |
|
||||||
|
|
||||||
**`Public/VoxelTerrainOpDefinition.h` + `.cpp`** — `UVoxelTerrainOpDefinition : UPrimaryDataAsset`
|
**`Public/VoxelTerrainOpDefinition.h` + `.cpp`** — `UVoxelTerrainOpDefinition : UPrimaryDataAsset`
|
||||||
(h:67). One asset = one terrain op. `EVoxelTerrainOpType` (h:36): Terrace, LayerLines,
|
(h:67). One asset = one terrain op. `EVoxelTerrainOpType` (h:36): Terrace, LayerLines,
|
||||||
@@ -377,6 +555,118 @@ One asset = one biome: identity + `DebugColor`, climate placement box (`ReliefMi
|
|||||||
atmosphere override, `WaterMaterial`, `MaterialPaletteIndex` (F6 — baked to vertex colour, §8.15)), `GameplayTags`. Referenced from
|
atmosphere override, `WaterMaterial`, `MaterialPaletteIndex` (F6 — baked to vertex colour, §8.15)), `GameplayTags`. Referenced from
|
||||||
`UVoxelStrateDefinition::Biomes[]`. Generator-agnostic (surface biomes now, cave biomes later). §8.14.
|
`UVoxelStrateDefinition::Biomes[]`. Generator-agnostic (surface biomes now, cave biomes later). §8.14.
|
||||||
|
|
||||||
|
**`Public/VoxelStrateComposer.h` + `Private/VoxelStrateComposer.cpp`** (Tier 4a/4b + Tier 5) — offline
|
||||||
|
`FVoxelStrateCorpus`, `VF_RollStrateParams`, and `VF_RollStrateStructure`. `LoadFromAssetRegistry` enumerates every project
|
||||||
|
`UVoxelStrateDefinition` through the Asset Registry, excludes `Saved/Autosaves` and `Saved/Cooked`
|
||||||
|
copies, and adds one `VoxelStrateTypes.h` default vector for each of the eight exact archetypes.
|
||||||
|
The corpus is grouped by exact `ECaveGeneratorType`: the 2026-09-04 run loaded **4 project
|
||||||
|
vectors + 8 defaults = 12 members** in **8 groups**. Sibling families use their native structs
|
||||||
|
(`FSlabGenerationParams`, `FMazeGenerationParams`, `FSurfaceGenerationParams`,
|
||||||
|
`FVerticalShaftParams`, `FFloatingIslandParams`); no cross-archetype blend is attempted.
|
||||||
|
Spreads are measured per group, including the plain-native tunnel fields. The fixed-lattice audit
|
||||||
|
found **23 live terrain-detail fields** (Terrace, LayerLines, Overhang, Ribbing, Cliff, Scallop,
|
||||||
|
Arch, Dome, Pinch) and **11 dead direct transport fields** (Column, Pit, Chimney); only the latter
|
||||||
|
remain excluded. Live fields receive one matching `UVoxelTerrainOpDefinition` header-default sample
|
||||||
|
in addition to authored strate values, so an all-default strate corpus still has useful activation
|
||||||
|
spread. Rolls are pure in `(corpus contents, seed, index)`: weighted 2–3-parent same-archetype
|
||||||
|
selection → `FStrateGenerationParams::Lerp` or reflected native-family blend → ±15% of measured
|
||||||
|
field range jitter (or the season-zero ±25% own-magnitude bootstrap for near-zero ranges) →
|
||||||
|
editor-reflection clamps → ordered-pair repair. Bool
|
||||||
|
`bTunnelsFlowTowardOrigin` inherits from the dominant parent; integer/enum SNAP fields are not
|
||||||
|
jittered. Normal runtime generation never calls this API; the editor-only AVoxelWorld bridge does so
|
||||||
|
only from its PIE button. The settings audit found **one unique path**,
|
||||||
|
`/Game/VoxelForge/DA_Strate3`, which explains the old one-vector corpus; the project assets were
|
||||||
|
never in that pool.
|
||||||
|
|
||||||
|
`FVoxelOpStackRecipe` is the serialisable Tier 4b manifest: root polarity, root/source/conversion
|
||||||
|
class IDs, an ordered modifier ID list, and one native parameter-family ID per entry. Posts are not
|
||||||
|
represented in the manifest; `VF_BuildStackFromRecipe` appends spine, vertical seal, passage carve,
|
||||||
|
and XY edge seal in the fixed order. The roller derives modifier legality from the op channel and
|
||||||
|
resource declarations, rolls 4–8 unique modifiers, and validates the recipe before materialising
|
||||||
|
it. **Rolling is editor-only; `VF_BuildStackFromRecipe` is runtime evaluation and is used by cooked
|
||||||
|
seasons.** `RequiredResources` / `ProvidedResources` close the room-state hole that channel masks alone
|
||||||
|
could not express. `VoxelForgeComposerStructureRollTest` rolls 64 candidates, measures them through
|
||||||
|
the offline sampler, and brute-forces every uniform box verdict from the novel stack itself.
|
||||||
|
|
||||||
|
Tier 5 adds `FVoxelStratePromotableRecord`, `FVoxelStratePromotionPolicy`, and
|
||||||
|
`FVoxelStratePromotionBatchResult`. `VF_SaveStratePromotedRecords` writes a sorted schema-2 JSON
|
||||||
|
corpus store containing the complete six-family parameter vector, recipe, archetype, gate evidence,
|
||||||
|
metrics, season/seed, and input corpus hash; `VF_SaveStrateSeasonManifest` writes one diffable JSON
|
||||||
|
summary beside it. `FVoxelStrateCorpus::LoadPromotedRecords` re-verifies every record through the
|
||||||
|
world-specific `IVoxelStratePromotionVerifier` before admission, remeasures project/default members
|
||||||
|
when that verifier supplies the optional audit, and tags membership `project` / `default` /
|
||||||
|
`promoted`. `VF_SelectStratePromotions` uses normalized measured-metric distance (threshold 0.20),
|
||||||
|
the existing corpus contents hash, deterministic RecordId ordering, and a six-record season cap.
|
||||||
|
The promotion test simulates five seasons and deliberately corrupts one stored metric to prove the
|
||||||
|
loader replaces it with a fresh measurement.
|
||||||
|
|
||||||
|
`EVoxelStrateCorpusFreeSamplingMode`, `VF_RollStrateParamsCorpusFree`, and
|
||||||
|
`VF_ValidateStrateCorpusFreeConstraints` are the offline corpus-free control/experiment. They never
|
||||||
|
read a corpus entry or strate asset: `NaiveUniform` rolls every live scalar independently inside a
|
||||||
|
finite, code-declared envelope, while `ConstraintSampled` draws geometry quantities and derives
|
||||||
|
dependent quantities from the generator equations before applying the envelope as a final safety
|
||||||
|
net. `VoxelForgeComposerCorpusFreeTest.cpp` compares both arms with today's corpus blend using the
|
||||||
|
same structure recipe, fixed measurement window, one arrival/departure law, and box-verdict brute
|
||||||
|
force. The requested 256×3 and 64×3 runs were abandoned before aggregate output because the fixed
|
||||||
|
8M-cell law/box workload was too slow; the completed equal-arm run is 16 candidates per arm and
|
||||||
|
22.995 s inside the test. Its final result is **13/16 corpus-blend survivors, 7/16 naive, and
|
||||||
|
7/16 constraint-sampled**. Survivor quality is not equivalent: corpus survivors averaged
|
||||||
|
**0.066071 walkable / 92.307693 voxel feature scale**, versus **0.012566 / 64.571426** for naive
|
||||||
|
and **0.012851 / 31.428572** for constrained. All three arms had **0 box-verdict violations**;
|
||||||
|
proved boxes / checked lattice voxels were **196 / 260,876**, **310 / 412,610**, and
|
||||||
|
**262 / 348,722** respectively.
|
||||||
|
|
||||||
|
Public/VoxelWorld.h + Private/VoxelWorld.cpp — ApplyComposerCandidate exposes the four
|
||||||
|
Live Edit|Composer properties and the CallInEditor button. It preflights the candidate, pauses
|
||||||
|
generation before installing the manager override, and calls the existing epoch-aware full reset.
|
||||||
|
The post-apply check compares eight densities against the shared native/recipe stack where the live
|
||||||
|
world has no extra diff, disturbance, or biome context. It reports SKIPPED rather than comparing
|
||||||
|
unlike fields.
|
||||||
|
|
||||||
|
**`Public/VoxelStratePreview.h` + `Private/VoxelStratePreview.cpp`** — editor/automation-only PNG
|
||||||
|
renderer and self-contained contact sheet. It consumes `FVoxelStrateSampleGrid` from the measurement
|
||||||
|
pass, emits deterministic centre-Y XZ and data-selected XY slices via `IImageWrapper`, and shows filled-cell
|
||||||
|
and scalar density=0 contour views side by side without blurring the sampled raster. Each image is capped at
|
||||||
|
512 pixels (including its scale footer). `FVoxelStrateFinePreviewSettings` is a separate caller-selected
|
||||||
|
fine pass: the composer selects only survivors at step 1, radius 64 (a 128×128 XY ROI), and `MaxCells=4,000,000`;
|
||||||
|
the caller centres it on each coarse survivor's `LargestComponentPoint` and the writer labels it
|
||||||
|
“largest open space” with its exact ROI and step; cap refusals and blank fine ROIs are recorded explicitly
|
||||||
|
instead of showing empty evidence. The ordinary 64-card sheet remains sorted by survivor status then
|
||||||
|
descending corpus-centroid distance. The `VoxelForge.Composer.Showcase` test additionally writes an
|
||||||
|
alphabetized one-card-per-archetype PIE hand-off page with the Part A survivor distributions, exact seed /
|
||||||
|
candidate / target-slot values, and no runtime generation hook.
|
||||||
|
|
||||||
|
**`Public/VoxelSeasonManifest.h` + `Private/VoxelSeasonManifest.cpp`** — Tier 4c's offline composer
|
||||||
|
and runtime-readable schema-2 artifact. `VF_ComposeSeason` generates a bounded configurable candidate batch, applies the Tier 2
|
||||||
|
non-vacuous / largest-component / primordial-law gates, then selects an ordered Tier A spine with the
|
||||||
|
explicit provisional grounded/outlier, adjacency, variety, and five-slot boss policy. The JSON stores
|
||||||
|
the complete six-family parameter vector (float values carry both a readable number and exact IEEE-754
|
||||||
|
bits), seed/index/archetype/recipe, bounds, measured metrics, gate facts, candidate audit, and rejection
|
||||||
|
counts. A canonical SHA-1 `content_hash` covers the complete payload. `VF_DeserializeVoxelSeasonManifest`
|
||||||
|
verifies it in memory; `VF_LoadVoxelSeasonManifest` plus `VF_RebuildVoxelSeasonStrate` are the round-trip seam; generated
|
||||||
|
recipe entries rebuild through the mandatory-post builder, while authored `FixedStrates` retain their
|
||||||
|
absolute slot and native vector. The selected-only descent review reuses `VoxelStratePreview`, and all
|
||||||
|
composition/review work is editor/build-box code; parsing and recipe materialisation are runtime. Lateral
|
||||||
|
region manifests remain rejected because their post-landing cross-seam law is only **4/16** (the
|
||||||
|
pre-landing snapshot was 9/16). The focused
|
||||||
|
season test currently reports **24 generated / 19 hard-gate survivors / 6 selected / 18 unselected or
|
||||||
|
rejected**, with **4 vacuous**, **1 primordial-law budget**, **13 policy-not-selected**, and **393,216**
|
||||||
|
round-trip density samples bit-identical. The specified UE 5.7 editor build succeeded and the final
|
||||||
|
`VoxelForge` namespace report `VoxelForgeFullMazeTreeFinal3` recorded **30 clean successes, 2
|
||||||
|
warning-bearing, 0 failed, 0 not run** in **2,523.978 s**. The regenerated showcase produced **6 fine
|
||||||
|
renders, 2 refusals, and 0 blanks**; all box-verdict violations remained zero. The later focused
|
||||||
|
production-height showcase history is recorded separately below.
|
||||||
|
|
||||||
|
The current landing validation is recorded in `VoxelForgeLandingFinal5`: **30 clean successes, 2
|
||||||
|
warning-bearing successes, 0 failures, 0 not-run** across 32 tests in **3,607.746 s**. The strict
|
||||||
|
promotion assertions passed and no assertion was weakened. The landing-specific geometry test,
|
||||||
|
both connectivity tests, all box verdicts, seals, determinism, and op-stack equivalence tests passed.
|
||||||
|
|
||||||
|
**`Public/VoxelSeasonAsset.h` + `Private/VoxelSeasonAsset.cpp`** — `UVoxelSeasonAsset :
|
||||||
|
UPrimaryDataAsset`, the cookable carrier. Its editor button imports the reviewable JSON, stores the exact
|
||||||
|
text plus independently copied metadata/hash, and retains soft references to any authored definitions.
|
||||||
|
`UVoxelSettings::Season` is the only activation switch.
|
||||||
|
|
||||||
### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp`
|
### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp`
|
||||||
`UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength;
|
`UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength;
|
||||||
**negative Strength = carve, positive = fill**) grouped by chunk in `TMap ChunkMods`.
|
**negative Strength = carve, positive = fill**) grouped by chunk in `TMap ChunkMods`.
|
||||||
@@ -426,7 +716,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
|
|||||||
|
|
||||||
| File | Test name | What it proves |
|
| File | Test name | What it proves |
|
||||||
|------|-----------|----------------|
|
|------|-----------|----------------|
|
||||||
| `VoxelForgeTestFixture.h` | — | `FTestWorld`: a headless world (transient strate definitions → `UVoxelSettings` → a real `UVoxelStrateManager::Initialize`) so tests hit `GetDensityAt`, where the thread_local caches live. One strate per archetype, **pinned via `FixedStrates`** so slot index → archetype is stable across seeds (`SlotSurfaceWorld` etc.). |
|
| `VoxelForgeTestFixture.h` | — | `FTestWorld`: a headless world (transient strate definitions → `UVoxelSettings` → a real `UVoxelStrateManager::Initialize`) so tests hit `GetDensityAt`, where the thread_local caches live. One strate per archetype, **pinned via `FixedStrates`** so slot index → archetype is stable across seeds (`SlotSurfaceWorld` etc.). Bounded regression fixtures default to 4 chunks; the production/default definition and the owner-facing showcase explicitly use 8 chunks. |
|
||||||
| `VoxelForgeDensityPurityTest.cpp` | `VoxelForge.Determinism.DensityPurity` | 10k points re-sampled in shuffled order, same thread **and** on N workers, asserting BIT equality. `ValidateDeterminism` is game-thread only and cannot see worker-cache divergence. Includes a flat-field canary (AUDIT C1) and a diff-layer pass. |
|
| `VoxelForgeDensityPurityTest.cpp` | `VoxelForge.Determinism.DensityPurity` | 10k points re-sampled in shuffled order, same thread **and** on N workers, asserting BIT equality. `ValidateDeterminism` is game-thread only and cannot see worker-cache divergence. Includes a flat-field canary (AUDIT C1) and a diff-layer pass. |
|
||||||
| ″ | `VoxelForge.Determinism.LiveEditInvalidation` | AUDIT C2 regression: triple the heightfield params, `Initialize` again, require the density to MOVE. The edit does not move the strate, so only `LayoutVersion` changes. |
|
| ″ | `VoxelForge.Determinism.LiveEditInvalidation` | AUDIT C2 regression: triple the heightfield params, `Initialize` again, require the density to MOVE. The edit does not move the strate, so only `LayoutVersion` changes. |
|
||||||
| `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.Determinism.ClassifyTileSoundness` | Scans for a non-`Mixed` verdict, then brute-forces the exact mesher lattice (`g ∈ [-1, Cells+1]`). **A false verdict is an invisible, collisionless hole** — T1.d v1 was reverted for exactly this. Errors out rather than passing if it found nothing to check. |
|
| `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.Determinism.ClassifyTileSoundness` | Scans for a non-`Mixed` verdict, then brute-forces the exact mesher lattice (`g ∈ [-1, Cells+1]`). **A false verdict is an invisible, collisionless hole** — T1.d v1 was reverted for exactly this. Errors out rather than passing if it found nothing to check. |
|
||||||
@@ -434,11 +724,26 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
|
|||||||
| `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.OpStack.BoxVerdictFold` | Pure-logic walk of the fold in `VoxelDensityOp.h`, case by case — including the seal-forces-AllSolid case that justifies `ClassifyBox` existing. Also the only `.cpp` that includes the op header, so the build actually sees it. |
|
| `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.OpStack.BoxVerdictFold` | Pure-logic walk of the fold in `VoxelDensityOp.h`, case by case — including the seal-forces-AllSolid case that justifies `ClassifyBox` existing. Also the only `.cpp` that includes the op header, so the build actually sees it. |
|
||||||
| `VoxelForgeHeightStackTest.cpp` | `VoxelForge.OpStack.SurfaceHeightEquivalence` | The height-space stack vs `ComputeSurfaceTerrainZ`, in **altitudes**. Runs twice: defaults, then **all F20 terrain ops ON** — the load-bearing pass, since the ops are off by default and the defaults pass exercises only the structural source. Also brute-forces `MaxDisplacement` (a false bound would be a hole). Bar is bit-identity; a height delta is a visibly different world, not rounding. |
|
| `VoxelForgeHeightStackTest.cpp` | `VoxelForge.OpStack.SurfaceHeightEquivalence` | The height-space stack vs `ComputeSurfaceTerrainZ`, in **altitudes**. Runs twice: defaults, then **all F20 terrain ops ON** — the load-bearing pass, since the ops are off by default and the defaults pass exercises only the structural source. Also brute-forces `MaxDisplacement` (a false bound would be a hole). Bar is bit-identity; a height delta is a visibly different world, not rounding. |
|
||||||
| `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. |
|
| `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. |
|
||||||
| `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. |
|
| `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 6-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. |
|
||||||
| `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. |
|
| `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. |
|
||||||
| `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. The source and mirror now share a deterministic inner-3×3-emitted drainage tree whose parents come from rebuild-only ±2/±3 windows in a wider direct-indexed halo (default 9×9 emit / 15×15 roll), plus the old random links. Tree connector Z is exact and ledge-free; tree and random capsules are spatially culled from the cached rebuild. **Fixed 2026-07-29:** its box query 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 publishes an isolated interval for tree and random capsules (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed), with **Z exact** and XY conservative; the ledge and converter decide their own effects. The sampler was also widened from ±48 voxels to ±440. Every proved tile is brute-forced over its full lattice, and the test reports rebuild-vs-hot cache cost. |
|
||||||
| `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 8-op Maze stack vs `GetMazeDensity` over 20k points (bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers, a 64-seed spanning-tree/connectivity audit, corridor morphology, cache rebuild-vs-hot timing, and brute force on every box verdict the stack emits. Latest audit: **0 tree edge-count violations, 0 tree-disconnected, 0 Maze-disconnected**; interior degrees `d1=15.91%`, `d2=36.60%`, `d3=31.73%`, `d4=12.95%`, `d5=2.56%`, `d6=0.25%`, mean **2.504** versus a cubic grid's degree-6/mean-6.000; runs `min=1 / median=1 / p90=3 / max=18`. Latest focused cache timing: **1.436 μs rebuild**, **0.150 μs hot call**; final namespace timing: **1.130 μs rebuild**, **0.145 μs hot call**. Latest box audit: **104 proved, 152 Mixed, 138,424 voxels, 0 violations**. |
|
||||||
|
| `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`). |
|
||||||
|
| `VoxelForgeComposerParameterRollTest.cpp` | `VoxelForge.Composer.ParameterRoll` | Asset-Registry corpus audit + complete per-archetype spread/exclusion/clamp table; asserts bit-identical deterministic rerolls and same-archetype parents; measures 64 transient candidate strates with `VF_MeasureStrate` plus the exact unsnapped arrival→departure law; brute-forces every rolled production box verdict. World-scale run: **1,422 applications / 168 distinct fields**; **36/64 survival**, 60 non-vacuous, 58 largest-component, 40 exact-law passes; **1,411 Mixed + 731 AllSolid + 418 AllAir = 1,149 proved boxes**, **1,529,319 lattice voxels checked, 0 violations**, 148.387 s. |
|
||||||
|
| ″ | `VoxelForge.Composer.TerrainDetailLiveness` | Fixed 4,096-point `GetDensityAt` lattice, legacy and operator-stack paths; changes one terrain-detail group at a time with an empty terrain-op pool. Proves 9 live groups / 23 fields and 3 dead groups / 11 fields; all 24 rows match. |
|
||||||
|
| `VoxelForgeComposerPromotionTest.cpp` | `VoxelForge.Composer.Promotion` | Re-measures the 12 project/default members, simulates five deterministic 24-candidate seasons with normalized measured-metric novelty (`<0.20`), cap 6, cumulative JSON promotion, provenance counts, corpus-hash checks, fresh-load gate verification, spread/survival reporting, and deliberate stale-metric corruption. The final landing validation passed the strict existing gates; no assertion was weakened. |
|
||||||
|
| `VoxelForgeComposerStructureRollTest.cpp` | `VoxelForge.Composer.StructureRoll` | Rolls root polarity → legal shape source → polarity-derived conversion → 4–8 declaration-legal modifiers → mandatory structural posts; blends the six native parameter families independently, measures 64 novel stacks, captures the same grid for the deterministic filled/contour XZ/XY preview, runs a separate step-1 radius-64 ROI pass for the 33 survivors centred on `LargestComponentPoint`, checks exact arrival→departure connectivity, rerolls every recipe/stack for determinism, and brute-forces every uniform box verdict. World-scale run: **33/64 survival (51.6%)**, **64 distinct recipes**, **0 invalid recipes**, **715 proved custom boxes / 951,665 lattice voxels / 0 violations**, blank plan/card **4/33→5/33**, **174.854 s**, **0 refusals**. |
|
||||||
|
| `VoxelForgeComposerShowcaseTest.cpp` | `VoxelForge.Composer.Showcase` | Exhausts the bounded parameter-roll set (seeds **0, 7331**, indices **0–63**), excludes multi-region rolls while the lateral gate is off, and measures every missing-family candidate in all six interior target slots at step 4 / radius 256 / `MaxCells=8,000,000`. Hard gates are non-vacuous, largest air share ≥ **0.50**, and exact unsnapped arrival→departure connectivity; selection score is floor-area fraction + clearance tie-break + projected-surface tie-break. It asserts roll/manifest determinism, density sign, zero `WorldRadiusVoxels`, bit-identical metric reruns, and writes one alphabetized card per archetype to `Saved/VoxelForge/Showcase/index.html`, with step-1 radius-64 filled/contour plan + vertical ROI images centred on `LargestComponentPoint`. The final landing regeneration evaluated **256** rolls (**82** eligible single-region, **174** skipped multi-region), produced **7 fine renders, 1 refusal, 0 blanks**, and retained a diagnostic hard-gate survivor set of **0/8**. |
|
||||||
|
| `VoxelForgeComposerSeasonTest.cpp` | `VoxelForge.Composer.Season` | Existing compose/review/393,216-sample round trip plus schema hash tamper rejection, two independent runtime manager/generator instances, season-authoritative seed/spine/radius, unset-season regression, and brute-force verification of every sampled non-Mixed recipe tile verdict (up to 64). |
|
||||||
|
| `VoxelForgeComposerCorpusFreeTest.cpp` | `VoxelForge.Composer.CorpusFree` | Shares each structure recipe across today's corpus blend, naive independent uniform rolls, and constraint-sampled rolls. The completed equal-arm world-scale run uses **16 candidates per arm**, step 4 / radius 256 / `MaxCells=8,000,000`, fixed passage-law mouths, and 40 box probes per candidate. Result: **10/16, 9/16, 8/16** survival; survivor feature-scale ranges are **8..268**, **8..60**, **8..92** voxels. Box checks: **185/246,235**, **260/346,060**, **299/397,969** proved/voxels, **0 violations** in every arm; constraint parameter and box-stop violations were also 0. |
|
||||||
|
| `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, landing descriptors, control geometry, and bounds bit-for-bit. |
|
||||||
|
| `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check both generated mouths against independent source queries, then audits all inter-strate landing ends: body-derived room dimensions, final-density flat-floor slope, local floor/join geometry, seal containment, hot-call/rebuild timing, and targeted `ClassifyTile` box soundness. Reports source-fit/refused mouths and per-archetype landing slopes; fails on any floor, join, seal, or box violation. |
|
||||||
|
| `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test uses the bounded 4-chunk density fixture, fits each measurement AABB to the arrival/departure mouth pair plus three shaft spacings (the prescribed local tree fallback), and caps the fitted control at 120,000,000 cells. It reports exact before/after seed-6 cell counts, reruns every negative with the fitted margin doubled, measures tree orphan candidates/path reachability and roughness-bubble proxies, asserts mouths remain inside the deterministic shaft feature core (the exact axis is an unsupported open cylinder), checks source/mirror physical paths, and requires 16/16 effective arrival→departure results across the 16 VerticalShafts seeds. Margin-binding negatives are reported as measurement limits, never as gap findings. The shaft seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts. |
|
||||||
|
| `VoxelForgeMazeSeamTest.cpp` | `VoxelForge.Generation.MazeSeamFreedom` | Re-evaluates **42** cell-boundary probes after warming six different neighbouring chunk contexts in both legacy and operator-stack paths. Latest result: **0 legacy mismatches, 0 operator mismatches**. The Maze source's 2×2×2 lower-node window is deliberately local; no wide collect is involved. |
|
||||||
|
| `VoxelForgeClipmapNoOverlapTest.cpp` | `VoxelForge.Streaming.ClipmapDesiredTilesNoOverlapCoverage` | Sweeps 6,174 centres (negative coords, every parity) × radius/level/render-distance/vertical-clamp/sheet combinations: **zero overlapping desired pairs AND coverage ⊇ the frozen `7486c29` selector**. Failed on `7486c29` (1,022,679 overlapping pairs) as intended. |
|
||||||
|
| `VoxelForgeDensityBlockEquivalenceTest.cpp` | `VoxelForge.Density.OperatorBlockMatchesScalarGrid` | Block evaluation (`EvalBlock` after `BuildActiveOpList` pruning) vs scalar, **bit-identical**: 5.1M mesher-grid samples (6 seeds × 8 archetypes × levels 0–5, with edits) + 300k direct `EvalBlock` samples. Scopes `voxel.UseFusedEvaluator=0` so the real block path runs. An injected optimistic `FPassageCarveOp::EffectOverBox` produced 5,612 mismatches. |
|
||||||
|
| `VoxelForgeWorldEdgeSealTest.cpp` | `VoxelForge.Generation.WorldEdgeSeal` | Positive samples outside the radius across every fixture strate, bit-identical interior negative control against radius 0, monotonic smooth-ramp check, radius-0 no-op, radial `ClassifyBox`/T1.d proofs brute-forced for every reported voxel, and synthetic plus generated near-rim passage coverage. Emits one aggregate summary with proved-box and voxel counts. |
|
||||||
|
|
||||||
## 4. The density pipeline (most-edited hot path)
|
## 4. The density pipeline (most-edited hot path)
|
||||||
|
|
||||||
@@ -464,7 +769,7 @@ Stage order (negative=solid throughout). Each stage's anchor:
|
|||||||
| 4h — Pinch | 1142 | Passage bottlenecks. |
|
| 4h — Pinch | 1142 | Passage bottlenecks. |
|
||||||
| 5 — Worm tunnels | 1241 | abs(noise1)+abs(noise2), masked by distance-to-network (`WormNetworkRange`: braids hugging rooms/tunnels, no far-field speckle; 0 = legacy unmasked). |
|
| 5 — Worm tunnels | 1241 | abs(noise1)+abs(noise2), masked by distance-to-network (`WormNetworkRange`: braids hugging rooms/tunnels, no far-field speckle; 0 = legacy unmasked). |
|
||||||
| 6 — Boundary seal | 1274 | Solid top/bottom shells (`ApplyBoundarySeal`). |
|
| 6 — Boundary seal | 1274 | Solid top/bottom shells (`ApplyBoundarySeal`). |
|
||||||
| 7 — Inter-strate passages | 1281 | Carve passages/elevator (`ApplyPassageCarving`). |
|
| 7 — Inter-strate passages + landings | `GetDensityWithParams`: 2217; `GetSlabDensity`: 2434+ | Carve wandering passage tubes and deterministic local landing rooms, restore their support floors, then leave the final XY seal to the outer MC post (`ApplyPassageModifier` / `ApplyPassageLandingFloorMC`). |
|
||||||
|
|
||||||
### 4.2 `GetSlabDensity` (FlatPlain / CrystalChamber) — VoxelGenerator.cpp:1306
|
### 4.2 `GetSlabDensity` (FlatPlain / CrystalChamber) — VoxelGenerator.cpp:1306
|
||||||
| Step | Line | What |
|
| Step | Line | What |
|
||||||
@@ -473,7 +778,7 @@ Stage order (negative=solid throughout). Each stage's anchor:
|
|||||||
| 2 — Ceiling surface | 1343 | Formations hang downward (`abs(noise)`). |
|
| 2 — Ceiling surface | 1343 | Formations hang downward (`abs(noise)`). |
|
||||||
| 3 — Void→base density | 1379 | Solid outside [floor,ceiling]. |
|
| 3 — Void→base density | 1379 | Solid outside [floor,ceiling]. |
|
||||||
| 4 — Columns | 1399 | World-space hash grid, full-height. |
|
| 4 — Columns | 1399 | World-space hash grid, full-height. |
|
||||||
| 5+6 — Seal + passages | 1461 | Same seal/passage carving as TunnelNetwork. |
|
| 5+6 — Seal + passages | 1461 | Same seal, passage-tube, landing-room, connector, and support-floor path as TunnelNetwork. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -485,13 +790,14 @@ Stage order (negative=solid throughout). Each stage's anchor:
|
|||||||
| View distance / task budget / LOD distances | `VoxelSettings.h` (no recompile of logic — data asset). |
|
| View distance / task budget / LOD distances | `VoxelSettings.h` (no recompile of logic — data asset). |
|
||||||
| LOD step mapping | `AVoxelWorld::LODToStep` VoxelWorld.cpp:268; `GetLODForChunk` :242. |
|
| LOD step mapping | `AVoxelWorld::LODToStep` VoxelWorld.cpp:268; `GetLODForChunk` :242. |
|
||||||
| How chunks stream in/out | `UpdateChunksAroundPosition` VoxelWorld.cpp:362. |
|
| How chunks stream in/out | `UpdateChunksAroundPosition` VoxelWorld.cpp:362. |
|
||||||
| Async threading / stale-result handling | `LoadChunk` :445, `ProcessPendingChunks` :301, Epoch logic. |
|
| Async threading / stale-result handling | `LoadTile` :445, `ProcessPendingChunks` :301, generation Epoch + per-request cancellation token. |
|
||||||
| Add a new cave feature / terrain op | Add enum in `VoxelTerrainOpDefinition.h:36`, params there, `ApplyTo` (.cpp:6), transport fields in `FStrateGenerationParams`, consume it in a new Step inside `GetDensityWithParams`. |
|
| Add a new cave feature / terrain op | Add enum in `VoxelTerrainOpDefinition.h:36`, params there, `ApplyTo` (.cpp:6), transport fields in `FStrateGenerationParams`, consume it in a new Step inside `GetDensityWithParams`. |
|
||||||
| Tweak room/tunnel shapes | `VoxelCaveMorphology.cpp` `BuildChunkCache` :47 / `EvaluateSDFCached` :757. |
|
| Tweak room/tunnel shapes | `VoxelCaveMorphology.cpp` `BuildChunkCache` :47 / `EvaluateSDFCached` :757. |
|
||||||
| Worm tunnel behavior | `GetDensityWithParams` Step 5, VoxelGenerator.cpp:1241. |
|
| Worm tunnel behavior | `GetDensityWithParams` Step 5, VoxelGenerator.cpp:1241. |
|
||||||
| Strate stacking / which strate where | `UVoxelStrateManager::Initialize` :10. |
|
| Strate stacking / which strate where | `UVoxelStrateManager::Initialize` :10. |
|
||||||
|
| Publish/use an offline season | Create `UVoxelSeasonAsset`, set `SourceManifestJson`, press `ImportSeasonManifestJson`, then assign it to `UVoxelSettings::Season`. |
|
||||||
| Boundary blend between strates | `GetGenerationParams` :515 + `FStrateGenerationParams::Lerp` (expands `VF_STRATE_PARAM_FIELDS`, StrateTypes.h — new fields go in that list). |
|
| Boundary blend between strates | `GetGenerationParams` :515 + `FStrateGenerationParams::Lerp` (expands `VF_STRATE_PARAM_FIELDS`, StrateTypes.h — new fields go in that list). |
|
||||||
| Passages between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageCarving` (Generator.cpp:197). |
|
| Passages / landing geometry between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageModifier` / `ApplyPassageLandingFloorMC` (StrateManager.cpp) + `ClassifyTile` floor guard (Generator.cpp). |
|
||||||
| Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:691/709 → `UVoxelDiffLayer::ApplyModification` :63. |
|
| Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:691/709 → `UVoxelDiffLayer::ApplyModification` :63. |
|
||||||
| Mesh smoothness / normals | Grid-gradient in `GenerateMesh` (`GradAt` lambda), `IsoLevel` (h). |
|
| Mesh smoothness / normals | Grid-gradient in `GenerateMesh` (`GradAt` lambda), `IsoLevel` (h). |
|
||||||
| New slab/flat-world generator | `GetSlabDensity` Generator.cpp:1306 + `FSlabGenerationParams` (StrateTypes.h:1019). |
|
| New slab/flat-world generator | `GetSlabDensity` Generator.cpp:1306 + `FSlabGenerationParams` (StrateTypes.h:1019). |
|
||||||
@@ -510,6 +816,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
|
||||||
@@ -519,6 +831,8 @@ Stage order (negative=solid throughout). Each stage's anchor:
|
|||||||
are dropped in `ProcessPendingChunks`. Always carry the epoch through new async paths.
|
are dropped in `ProcessPendingChunks`. Always carry the epoch through new async paths.
|
||||||
- **Generated code** under `Intermediate/` and `Binaries/` is build output — never edit.
|
- **Generated code** under `Intermediate/` and `Binaries/` is build output — never edit.
|
||||||
`*.generated.h` / `*.gen.cpp` are UHT output for the `UCLASS`/`USTRUCT` above.
|
`*.generated.h` / `*.gen.cpp` are UHT output for the `UCLASS`/`USTRUCT` above.
|
||||||
|
- **`UPackage` completeness:** files that call `NewObject(..., GetTransientPackage(), ...)` must include
|
||||||
|
`UObject/Package.h` directly; files that do not include `VoxelForgeTestFixture.h` do not get it transitively.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# VoxelForge Task 1: profiler, batch explorer, and research handoff
|
||||||
|
|
||||||
|
Research date: 2026-09-09. This note records the implementation and the read-only web research for Task 1. No RealtimeMeshComponent files were changed.
|
||||||
|
|
||||||
|
## 1. Measurement result
|
||||||
|
|
||||||
|
The old result in the task brief was 14.74 us/sample on the profiled density path versus 1.28 us/voxel without instrumentation: about 11.5x apparent overhead. That is consistent with putting `FPlatformTime::Cycles64()` around a roughly 100 ns operation. The timer call is not free, and at that granularity the measurement is mostly the measurement.
|
||||||
|
|
||||||
|
The new default is sampled profiling:
|
||||||
|
|
||||||
|
- Coarse calls and counters stay cheap and are collected in thread-local state.
|
||||||
|
- `GetDensityAt` uses a deterministic count interval of 64 calls and one amortized block timer, rather than one timer pair per density call.
|
||||||
|
- Full cycle timing remains an explicit opt-in (`-profiledensityfull`) for a small investigation, not the normal batch mode.
|
||||||
|
- The default sampled mode reports the parent density/mesher wall estimate. Fine per-operation scopes are disabled in that mode; use Full or Unreal Insights for a detailed phase breakdown.
|
||||||
|
- Sampling and timers only write report counters. They do not choose cells, alter density values, alter task partitioning, or participate in any reduction, so they cannot affect generated geometry.
|
||||||
|
|
||||||
|
The timer calibration in the validation run measured one `Cycles64` pair as approximately one platform cycle, or 0.1 us at the reported clock conversion. That is the direct self-check for the suspected timer-dominates-work failure mode.
|
||||||
|
|
||||||
|
Validation on the same 64^3 `TunnelNetwork` export:
|
||||||
|
|
||||||
|
| measurement | seconds |
|
||||||
|
| --- | ---: |
|
||||||
|
| profiled sampled export | 0.4630500004 |
|
||||||
|
| fresh unprofiled reference export | 0.5584703982 |
|
||||||
|
| discrepancy | -17.09% |
|
||||||
|
| profiled/reference ratio | 0.82914 |
|
||||||
|
|
||||||
|
Both exports produced geometry hash `235AC2DC`. The commandlet now puts all four values in every profiled report and fails with exit code 2 when the absolute discrepancy exceeds 20% or the reference hash differs. The report also exposes `cpu_us` (sum of worker CPU estimates) separately from `wall_us`/`total_us` (largest-worker wall estimate). Summing CPU time across 15 workers is not a valid wall-clock parent; collapsing those fields was one way to make a parallel profile lie.
|
||||||
|
|
||||||
|
The density report has an explicit ledger. In sampled mode, the parent estimate is the measured quantity, fine phase buckets are marked unavailable, and the unattributed remainder makes the ledger sum exactly to the parent. This is honest about what was measured instead of inventing a fine breakdown from disabled timers.
|
||||||
|
|
||||||
|
## 2. Batch explorer
|
||||||
|
|
||||||
|
The explorer commandlet accepts one JSON file and keeps one editor process alive for every case:
|
||||||
|
|
||||||
|
```text
|
||||||
|
UnrealEditor-Cmd.exe "<HostProject>.uproject" -run=VoxelForgeExplore -batch="E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\BatchValidation.json" -out="E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\BatchValidationRun2" -trace=cpu,counters
|
||||||
|
```
|
||||||
|
|
||||||
|
`-out` must be an absolute directory below this plugin's `Saved` directory. The commandlet writes one `batch.json` below that directory. The input supports, per case: `id`, `seed`, `archetype`, `slot`, `export_size`, `export_step`/`step`/`lod`, `modes`, `operator_stack`, `profile_density`, `profile_density_full`, `profile_lod`, render/failure-focus settings, and a per-case budget. The parser caps one batch at 256 cases and clamps the total budget to 30 minutes; each case also has a bounded budget.
|
||||||
|
|
||||||
|
Before every case, the commandlet resets the density profile and all per-case report state, then applies the case's profile settings. The output contains per case: mesh seconds, microseconds per voxel, triangle count, geometry hash, counters, profile mode, and the case exit code. Invalid or timed-out cases are represented in the same machine-readable output rather than silently disappearing.
|
||||||
|
|
||||||
|
The final three-case confirmation completed in 6.59 seconds of commandlet case time after the single process launch:
|
||||||
|
|
||||||
|
| case | triangles | mesh seconds | hash | result |
|
||||||
|
| --- | ---: | ---: | --- | --- |
|
||||||
|
| repeat_unprofiled | 2,461 | 0.391420 | `235AC2DC` | pass |
|
||||||
|
| repeat_profiled | 2,461 | 0.515374 | `235AC2DC` | self-check pass |
|
||||||
|
| maze_lod1 | 5,218 | 0.277830 | `784EBF92` | pass |
|
||||||
|
|
||||||
|
The standalone profiled 64^3 case and the `repeat_profiled` case inside the batch both produced `235AC2DC`, 2,461 triangles, 343,000 tunnel lookups, and 554,381 passage evaluations. That is the contamination check: same final build, seed, archetype, slot, export size, step, and modes produced the same geometry and coarse counters alone and in a reused process. The standalone profiled export also matched its fresh unprofiled reference hash.
|
||||||
|
|
||||||
|
## 3. Cache footprint answer
|
||||||
|
|
||||||
|
The historical 15-worker report that prompted the question was:
|
||||||
|
|
||||||
|
```text
|
||||||
|
capacity_entries=3840 valid_entries=1542 static_bytes=2534400
|
||||||
|
dynamic_bytes=428627716 total_bytes=431162116
|
||||||
|
avg_valid_entry_bytes=278703.6 largest_entry_bytes=541344
|
||||||
|
tunnel_valid=1054 tunnel_dynamic=345243636
|
||||||
|
roomgraph_valid=488 roomgraph_dynamic=83384080
|
||||||
|
```
|
||||||
|
|
||||||
|
That is 431,162,116 bytes (411.2 MiB) in aggregate and 278,703.6 bytes per valid entry on average (278.7 kB decimal, about 265.7 KiB). The number is not one monolithic 272 kB struct. Each worker owns cache slots, and valid entries contain variable-length arrays.
|
||||||
|
|
||||||
|
A representative 128^3 breakdown added by this task was 360,063,124 bytes total:
|
||||||
|
|
||||||
|
| member family | tunnel cache | room-graph cache |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| slot storage | 1,658,880 | 875,520 |
|
||||||
|
| op stack | 8,264,000 | 0 |
|
||||||
|
| rooms | 47,876,912 | 31,822,512 |
|
||||||
|
| room-floor joins | 4,936,512 | 3,278,976 |
|
||||||
|
| tunnels and control arrays | 5,560,248 | 3,591,584 |
|
||||||
|
| support-column entries | 7,153,328 | 0 |
|
||||||
|
| support columns | 30,699,008 | 0 |
|
||||||
|
| room index bins + candidates | 30,759,720 | 22,700,380 |
|
||||||
|
| tunnel index bins + candidates | 60,972,480 | 41,583,988 |
|
||||||
|
| tunnel-world index bins + candidates | 34,635,272 | 23,693,804 |
|
||||||
|
| dynamic total | 230,857,480 | 126,671,244 |
|
||||||
|
| static + dynamic total | 232,516,360 | 127,546,764 |
|
||||||
|
|
||||||
|
The dominant allocation is the spatial index bin/candidate storage, followed by rooms and support-column arrays. Slot storage is comparatively small. The exact per-entry average moves with the generated case and cache occupancy, which explains why the 128^3 confirmation is not numerically identical to the historical 411 MiB run.
|
||||||
|
|
||||||
|
## 4. Research findings
|
||||||
|
|
||||||
|
### RealtimeMeshComponent, read-only
|
||||||
|
|
||||||
|
At research time the upstream releases page lists v5.3.2 as the latest tagged release: [RMC releases](https://github.com/TriAxis-Games/RealtimeMeshComponent/releases). The disposable build host's copy identifies itself as `VersionName: 5.4`, so the upstream tag number is not a safe linear comparison; this project also carries custom changes. The visible upstream history includes a 5.1 game-thread/render-thread restructuring and a 5.0.5 release mentioning mesh-update and memory-overuse fixes. The public release page does not provide a complete API diff against this custom 5.4 copy, so I am treating those as compatibility signals rather than promising source compatibility. The local copy was not upgraded, diffed destructively, or otherwise modified.
|
||||||
|
|
||||||
|
The current upstream guidance is to build an `FRealtimeMeshStreamSet` as a CPU-side value on a worker, then cross to the game thread to create/update the component section group; use a weak object pointer for the handoff. The structure documentation describes a game/render proxy mirror and distinguishes in-place dynamic/compute updates from static proxy recreation. See [updating mesh data](https://triaxis.games/realtime-mesh/docs/component-core/updating-mesh-data/) and [RMC component structure](https://triaxis.games/realtime-mesh/docs/component-core/structure/). The practical candidate is therefore worker-side stream construction plus the smallest possible section/buffer update, not touching a `URealtimeMeshComponent` from a worker. Custom RMC changes make this a high-risk follow-up.
|
||||||
|
|
||||||
|
### UE 5.7 tasking and tracing
|
||||||
|
|
||||||
|
Epic's UE 5.7 API documentation says `ParallelForWithExistingTaskContext` gives each spawned task a task-local workspace, and `MinBatchSize` limits launches to `DivUp(Num, MinBatchSize)` workers. The pre-work variant runs caller work before that thread helps the parallel loop. Both pages explicitly warn against clogging the task graph with long-running or blocking work: [existing task context](https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Core/ParallelForWithExistingTaskConte-) and [pre-work task context](https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/Core/ParallelForWithPreWorkWithTaskCo-). The next tuning target should be measured batch size and task-local scratch reuse for short density/cell jobs, not more tiny tasks.
|
||||||
|
|
||||||
|
UE's built-in tracing is the better fine-grained instrument. The documented channels include CPU, Counters, Task, and StackSampling; `-trace=cpu,counters` is a valid launch selection. `TRACE_CPUPROFILER_EVENT_SCOPE` with static scope names and `CountersTrace.h`/`TRACE_COUNTER_*` are the low-cost native hooks. See [Trace channels](https://dev.epicgames.com/documentation/en-us/unreal-engine/trace-in-unreal-engine-5), [the tracing guide](https://dev.epicgames.com/documentation/en-us/unreal-engine/developer-guide-to-tracing-in-unreal-engine?lang=en-US), and the [Unreal Insights reference](https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-insights-reference-in-unreal-engine-5). Timing Insights supplies timers, counters, callers/callees, and task execution views; Task Graph Insights exposes execution paths and critical paths: [Timing Insights](https://dev.epicgames.com/documentation/en-us/unreal-engine/timing-insights-in-unreal-engine?lang=en-US) and [Task Graph Insights](https://dev.epicgames.com/documentation/unreal-engine/task-graph-insights-in-unreal-engine-5?lang=en-US).
|
||||||
|
|
||||||
|
This task therefore keeps the lightweight sampled report for automated self-checks and adds native CPU scopes/counters for investigation. Insights should own future fine per-scope and task scheduling analysis; the hand-rolled profiler should not grow back into a timer around every density call.
|
||||||
|
|
||||||
|
### Marching cubes and density generation
|
||||||
|
|
||||||
|
The upstream [Transvoxel overview](https://transvoxel.org/) confirms that transition cells use local voxel data and a bounded lookup table to stitch different-resolution meshes without cracks. It is relevant to LOD boundary work and dynamic retriangulation, not a reason to change the current mesh in this task. A classic SIMD study reports nearly 4x over an unoptimized serial Marching Cubes implementation and about 2x over compiler-optimized C: [Newman et al., *High performance SIMD marching cubes*](https://www.sciencedirect.com/science/article/abs/pii/S0097849303002760). Those are historical results on older x86 and should be treated as an upper-bound signal, not a VoxelForge forecast.
|
||||||
|
|
||||||
|
## 5. Ranked next-task candidates (not implemented here)
|
||||||
|
|
||||||
|
The magnitude estimates are expected ranges for the affected stage, inferred from the sources and this run's counters; they are not promises for end-to-end time.
|
||||||
|
|
||||||
|
| rank | candidate | expected magnitude | risk |
|
||||||
|
| ---: | --- | --- | --- |
|
||||||
|
| 1 | Conservative macrocell/cell empty-space early-out using deterministic density bounds, with a fallback on boundary uncertainty | 2–10x on empty or mostly empty chunks; smaller on surface-heavy chunks | Medium-high: bounds must never skip a crossing cell or change traversal order |
|
||||||
|
| 2 | Reuse one canonical density grid/field across meshing, LOD, export, and renderer preparation; invalidate by seed/slot/parameters | 20–60% less repeated density work and memory bandwidth where consumers overlap | Medium: cache lifetime and invalidation |
|
||||||
|
| 3 | Tune `ParallelFor` granularity and task-local scratch for short jobs; measure `MinBatchSize`, worker count, and critical path in Insights | 5–25% less scheduling/coordination cost | Low-medium: load imbalance and contention can reverse the win |
|
||||||
|
| 4 | SIMD/SoA batches for density and Marching Cubes table work | 1.5–4x for the vectorized stage in favorable workloads | High: exact cross-machine geometry and floating-point contraction; no runtime CPU dispatch is allowed |
|
||||||
|
| 5 | Hierarchical sparse/occupancy metadata or distance bounds to skip whole regions before field evaluation | 2–20x in sparse worlds | High: build/update cost, conservative topology, and invalidation |
|
||||||
|
| 6 | RMC partial section/buffer updates after worker-side stream construction | 10–50% less renderer update/bandwidth cost when only part of a section changes | High: custom RMC modifications and game/render-thread ownership |
|
||||||
|
| 7 | Transvoxel transition cells for LOD boundaries | 10–30% less boundary patch/rebuild work in LOD-heavy scenes; also removes crack workarounds | Medium-high: transition topology and hash stability |
|
||||||
|
|
||||||
|
The first safe experiment should be the deterministic early-out or field reuse, after a baseline with the corrected profiler. SIMD is deliberately below those despite its attractive paper result because the project's cross-machine geometry requirement is absolute.
|
||||||
|
|
||||||
|
## 6. Pushback and constraints
|
||||||
|
|
||||||
|
The requirement that buckets sum to their parent needs a definition in a parallel program. Raw worker CPU time can exceed wall time by the worker count; the report now exposes both and makes the wall estimate the parent used for the self-check. A fine raw-bucket sum cannot be claimed when sampled mode intentionally does not time those scopes, so the report says that explicitly and accounts for the remainder.
|
||||||
|
|
||||||
|
The 20% self-check threshold is a practical alarm, not a proof of zero measurement cost: OS scheduling, cache state, DDC state, and editor startup can move two exports. The geometry hash check is exact; repeated cases or a median of several runs should be used when a performance regression is close to the threshold.
|
||||||
|
|
||||||
|
The sampling decision is non-deterministic only in its elapsed-time observation. It is isolated from the generation inputs and outputs. The batch commandlet resets profile/cache-report state per case, but it intentionally reuses the engine process to remove startup cost; the standalone-versus-batch geometry hash is the proof that this reuse did not contaminate the tested case.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Task 2 performance report
|
||||||
|
|
||||||
|
Date: 2026-09-09
|
||||||
|
Repository: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge`
|
||||||
|
Work began at `experimental` / `e63f016`.
|
||||||
|
|
||||||
|
## Measurement rule
|
||||||
|
|
||||||
|
All performance numbers below are per-case `run.elapsed_seconds` from the batch report. This is
|
||||||
|
the wall clock for a real commandlet case through the export or render result; it includes the
|
||||||
|
shared-density-grid build when enabled. `summary.mesh_seconds` and profiler buckets are included
|
||||||
|
only as diagnostics, never as the performance claim.
|
||||||
|
|
||||||
|
The export comparison is the same `128^3` `TunnelNetwork`, seed `1337`, slot `1`, operator stack,
|
||||||
|
step `1`, with three cases in one commandlet process on each side. The 17% reporting floor is
|
||||||
|
therefore met by both run count and the retained density-grid result.
|
||||||
|
|
||||||
|
## Part 1 — block early-out
|
||||||
|
|
||||||
|
I tested an `8^3` block reject behind conservative fallback logic. On this cave/stack world, every
|
||||||
|
one of the 4,096 blocks was left uncertain and went through normal sampling:
|
||||||
|
|
||||||
|
| batch | runs | end-to-end wall seconds | blocks skipped | skip rate | result |
|
||||||
|
| --- | ---: | --- | ---: | ---: | --- |
|
||||||
|
| baseline | 3 | 3.805057, 3.522129, 3.569186 (avg 3.632124) | — | — | reference |
|
||||||
|
| block reject | 3 | 6.341454, 6.562621, 7.300234 (avg 6.734770) | 0 / 4,096 | 0.00% | **reverted** |
|
||||||
|
|
||||||
|
The candidate added classification work without skipping a block (+85.42% wall clock). It also
|
||||||
|
did not produce a useful supremum for the complete cave stack, so I did not promote a guessed
|
||||||
|
`max` parameter into a topology decision. `ClassifyTile` remains untouched: its stack, disturbance,
|
||||||
|
diff-layer, and cave guards are not a safe substitute for a new block proof. A false AllSolid
|
||||||
|
verdict here would delete a 3D cave, which is not an acceptable trade.
|
||||||
|
|
||||||
|
This did not clear the floor and was reverted.
|
||||||
|
|
||||||
|
## Part 2 — density-grid reuse
|
||||||
|
|
||||||
|
The canonical mesher requests 35^3 samples per 32-cell tile. For 64 tiles that is:
|
||||||
|
|
||||||
|
- tile requests: `64 * 35^3 = 2,744,000`;
|
||||||
|
- unique points in the expanded 128-cell lattice: `131^3 = 2,248,091`;
|
||||||
|
- duplicate evaluations: `495,909` (18.07%).
|
||||||
|
|
||||||
|
The first implementation evaluated the shared lattice as Z planes. It removed all duplicates but
|
||||||
|
was slower because it destroyed the generator's spatial locality:
|
||||||
|
|
||||||
|
| candidate | runs | end-to-end wall seconds | duplicates | result |
|
||||||
|
| --- | ---: | --- | --- | --- |
|
||||||
|
| matched control | 3 | 3.668954, 3.633445, 3.655658 (avg 3.652686) | 495,909 → 495,909 | reference |
|
||||||
|
| global-plane precompute | 3 | 5.349520, 5.352927, 5.510752 (avg 5.404400) | 495,909 → 0 | **reverted** |
|
||||||
|
| owner-grid precompute | 3 | 1.950207, 1.973673, 2.161305 (avg 2.028395) | 495,909 → 0 | retained candidate |
|
||||||
|
|
||||||
|
The retained implementation partitions the shared lattice into 64 disjoint rectangular owner
|
||||||
|
regions. Each global sample is generated exactly once, then each tile copies its local halo window
|
||||||
|
from the immutable run-scoped grid. The precompute is deliberately inside the wall-clock timer;
|
||||||
|
the final three-case repeat measured:
|
||||||
|
|
||||||
|
| batch | runs | end-to-end wall seconds | average |
|
||||||
|
| --- | ---: | --- | ---: |
|
||||||
|
| final control (`density_grid_reuse=false`) | 3 | 3.659587, 3.619013, 3.582257 | 3.620286 s |
|
||||||
|
| final reuse (`density_grid_reuse=true`) | 3 | 1.908017, 1.854344, 1.884170 | 1.882177 s |
|
||||||
|
|
||||||
|
That is a **48.01% wall-clock reduction / 1.92x speedup**, clearing the 17% floor. The final
|
||||||
|
reuse runs averaged 1.474271 s for the shared-grid build, and still won end to end. The 128^3
|
||||||
|
shared lattice is about 8.6 MiB of float storage. Direct explorer runs default to reuse; a batch
|
||||||
|
case can pass `density_grid_reuse=false` for a matched control.
|
||||||
|
|
||||||
|
The run-scoped handoff is confined to the explorer's finite canonical export. The streaming game
|
||||||
|
path has no one-shot set of neighboring tiles or invalidation boundary, so it was not given a
|
||||||
|
locking plane cache that could serialize workers or outlive a generation epoch.
|
||||||
|
|
||||||
|
## Part 3 — task granularity
|
||||||
|
|
||||||
|
The final control observed 15 worker thread IDs and 64 logical tile invocations. Across its three
|
||||||
|
runs, the measured tile-job averages were:
|
||||||
|
|
||||||
|
| metric | average |
|
||||||
|
| --- | ---: |
|
||||||
|
| fastest logical tile job | 0.188817 s |
|
||||||
|
| mean logical tile job | 0.593751 s |
|
||||||
|
| slowest logical tile job | 0.955788 s |
|
||||||
|
| sum of tile-job durations | 38.000045 s |
|
||||||
|
| tile parallel wall | 3.150925 s |
|
||||||
|
| launch-fringe estimate | 14.1 us |
|
||||||
|
| workers / logical jobs | 15 / 64 |
|
||||||
|
|
||||||
|
I tested `MinBatchSize=4` with three matched cases: wall seconds `3.590884, 3.634734,
|
||||||
|
3.753986` (avg `3.659868`) versus the final control average `3.620286`. The tile parallel bucket
|
||||||
|
was slightly lower (`3.125197` s vs `3.150925` s), but end-to-end wall clock was **1.09% slower**.
|
||||||
|
The observed worker count and logical job count stayed 15 / 64, and the launch-fringe estimate
|
||||||
|
remained about 13.1 us. This is the required bucket-win/no-wall-win case: the tuning was reverted;
|
||||||
|
the production/default setting remains `MinBatchSize=1`.
|
||||||
|
|
||||||
|
`ParallelForWithExistingTaskContext` was not adopted. The measured launch fringe is negligible
|
||||||
|
relative to 0.59 s tile work, and the mesher already uses thread-local scratch storage. No claim is
|
||||||
|
made here about the separate game workload of many small streaming submissions.
|
||||||
|
|
||||||
|
## Part 4 — renderer
|
||||||
|
|
||||||
|
No renderer rewrite or RMC change was justified. The source audit and three final render cases
|
||||||
|
showed:
|
||||||
|
|
||||||
|
- the canonical mesh is built once per explorer world;
|
||||||
|
- the triangle cache/BVH is built once and reused by all eight viewpoints;
|
||||||
|
- each viewpoint owns one `FColor` output allocation;
|
||||||
|
- the per-pixel traversal uses a fixed stack array, not a heap allocation;
|
||||||
|
- camera movement does not call the mesher again.
|
||||||
|
|
||||||
|
The three 512x288 render cases had total case wall seconds `8.704730, 8.985744, 8.517606`
|
||||||
|
(avg `8.736027`), including camera-seed walking. Raster-only diagnostics averaged 1.482921 s
|
||||||
|
for eight views, 0.286297 s for the first view, 0.170946 s per additional view, and 0.083190 s
|
||||||
|
for one-time acceleration construction. This is consistent with the owner's approximately
|
||||||
|
0.15 s/view observation; meshing benefits from Part 2 automatically. There was no renderer
|
||||||
|
change, so there is no before/after performance claim to report or keep.
|
||||||
|
|
||||||
|
## Memory/index follow-up
|
||||||
|
|
||||||
|
The existing profiler research measured 431,162,116 bytes (411.2 MiB) across 15 workers in the
|
||||||
|
large run. In the representative 128^3 breakdown, spatial-index bins and candidate arrays alone
|
||||||
|
accounted for 214,345,644 bytes, inside a 360,063,124-byte cache total. The index addition did not
|
||||||
|
show an end-to-end wall-clock gain in the prior measurements.
|
||||||
|
|
||||||
|
I did not remove it silently. The explicit follow-up proposal is an index-on/index-off batch
|
||||||
|
ablation before deletion: removing it should recover roughly that allocation, at the cost of
|
||||||
|
broader room/tunnel candidate scans and potentially slower density queries. That trade is not
|
||||||
|
proven by this task's export comparison.
|
||||||
|
|
||||||
|
## Determinism, invariants, and scope
|
||||||
|
|
||||||
|
All three final control hashes and all three final reuse hashes are `ED0F1F31`; every case reported
|
||||||
|
`json_deterministic=yes`. The three render cases also repeated the same geometry hash
|
||||||
|
`847BE4B9`. Owner regions are disjoint, tile results are merged in fixed Z/Y/X order, no RNG or
|
||||||
|
runtime CPU-feature dispatch was added, and the shared grid is read-only after its deterministic
|
||||||
|
fill. The only worker writes are to distinct cells of that run-owned output lattice; generator and
|
||||||
|
world state remain read-only.
|
||||||
|
|
||||||
|
The build succeeded with UnrealBuildTool after the final source change. Changes are limited to the
|
||||||
|
VoxelForge source and this report, with generated evidence under the plugin's `Saved` directory.
|
||||||
|
RealtimeMeshComponent was not modified. No VoxelM root, sibling plugin, `Content`, or `Config`
|
||||||
|
files were written; no links or historical worktrees were created.
|
||||||
+2244
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,60 @@
|
|||||||
|
# Codex Working Guidance
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
Codex is the implementation agent for this repository. Work directly on the assigned task; Claude Code is a reviewer and coordinator, not a concurrent editor of this checkout.
|
||||||
|
|
||||||
|
## Task contract
|
||||||
|
|
||||||
|
For each task, the request should state:
|
||||||
|
|
||||||
|
- Desired outcome and acceptance criteria.
|
||||||
|
- Scope: relevant folders, systems, or files, and any areas that must not change.
|
||||||
|
- Validation expected: tests, build, editor check, or a stated reason a check cannot run.
|
||||||
|
|
||||||
|
Use the repository's existing conventions and tools. Make the smallest coherent change that satisfies the task. Do not broaden scope without calling it out as optional work.
|
||||||
|
|
||||||
|
## Visibility and progress
|
||||||
|
|
||||||
|
Before any multi-step work or tool use, send one brief update stating the first action.
|
||||||
|
|
||||||
|
Send another brief update only when a major phase finishes or a discovery changes the approach. Every update must contain a concrete result, such as `Found`, `Changed`, `Confirmed`, or `Blocked`.
|
||||||
|
|
||||||
|
If progress is blocked for more than two minutes, stop waiting and report:
|
||||||
|
|
||||||
|
1. Current state and files changed.
|
||||||
|
2. Last command or action and its result.
|
||||||
|
3. Exact blocker.
|
||||||
|
4. Safest next action or the smallest decision needed from the user.
|
||||||
|
|
||||||
|
Never wait silently for a reviewer or another agent. Continue when the next safe implementation step is clear.
|
||||||
|
|
||||||
|
## Implementation loop
|
||||||
|
|
||||||
|
1. Inspect the relevant code and local instructions before editing.
|
||||||
|
2. State a short plan when the task has multiple meaningful parts.
|
||||||
|
3. Implement the change, keeping unrelated changes intact.
|
||||||
|
4. Inspect the diff for accidental edits and integration issues.
|
||||||
|
5. Run the most relevant available validation.
|
||||||
|
|
||||||
|
For Unreal work, prefer targeted validation first: module/plugin build, automated test, compile check, or a minimal editor/runtime smoke test appropriate to the changed behavior. Do not claim validation passed unless it was actually run.
|
||||||
|
|
||||||
|
## Collaboration with Claude Code
|
||||||
|
|
||||||
|
- Codex owns edits and commands in this checkout while its task is active.
|
||||||
|
- Claude reviews plans, diffs, test output, and reported blockers at explicit checkpoints.
|
||||||
|
- Treat Claude feedback as a prioritized fix list. Address actionable items, then re-validate affected behavior.
|
||||||
|
- Do not have both agents edit the same working tree simultaneously. For genuine parallel implementation, use separate git worktrees with non-overlapping tasks.
|
||||||
|
|
||||||
|
## Completion report
|
||||||
|
|
||||||
|
Finish with a concise report containing:
|
||||||
|
|
||||||
|
- What changed and why.
|
||||||
|
- Files changed.
|
||||||
|
- Validation run and its result; if not run, why and the next best check.
|
||||||
|
- Any remaining risk, assumption, or follow-up.
|
||||||
|
|
||||||
|
## Reviewer prompt for Claude Code
|
||||||
|
|
||||||
|
> You are the reviewer and coordinator for this task. Do not edit the shared checkout. Review the supplied Codex plan, diff, and validation output against the acceptance criteria. Return only prioritized, actionable findings, including file and line references where possible. If there are no blocking issues, say so explicitly.
|
||||||
@@ -0,0 +1,887 @@
|
|||||||
|
# DESIGN-SOL-2026-09-15-CODE: round two, Sol reads the code
|
||||||
|
|
||||||
|
Continues `DESIGN-SOL-2026-09-15.md`. At the owner's request, Sol's round-one session was forked
|
||||||
|
(`codex exec fork`, `gpt-5.6-sol`, reasoning high, read-only sandbox, ~594k tokens). Sol was given
|
||||||
|
the repository and asked **what could reasonably change and what it would take to prove each change
|
||||||
|
worth it**. Advice only: no files were changed, nothing was built or run. The owner's stance: *"open
|
||||||
|
to big changes, but only if they prove themselves worthy."*
|
||||||
|
|
||||||
|
## Orchestrator verification (2026-09-15, against HEAD `cf9904e` and the UE 5.7 engine source)
|
||||||
|
Checked by reading, not by running anything.
|
||||||
|
|
||||||
|
| Sol's claim | Check |
|
||||||
|
|---|---|
|
||||||
|
| `FMath::Sin/Cos` reach the MSVC CRT on Win64 | **Verified.** `GenericPlatformMath.h:521/530` returns `sinf`/`cosf`; `FMicrosoftPlatformMathBase` (via `TUnrealPlatformMathSSE4Base`) and the SSE bases override neither. |
|
||||||
|
| Per-sample lookup machinery is ~5% exclusive, not the 27% inclusive figure | **Verified**, `PERF-SAMPLED.md` lines 11-13: `GetRange` 3.8%, `VF_ForEachSpatialCandidate` 1.2%. My round-one note "the largest gap is the loop direction" overstated it. |
|
||||||
|
| Parse-once switches are unsynchronised globals parsed on worker threads | **Verified**, `VoxelGenerator.cpp` (`VF_Parse*Switch` called in `GetDensityAt`). In practice every racer writes the same value; the real exposure is a live CVar change mid-generation. |
|
||||||
|
| `MemCrc32` over raw param structs; the comment says no false positive is possible | **Verified** (`VoxelGenerator.cpp` ~3165). A CRC32 collision is possible in principle (about 2^-32 per comparison); padding can only cause a spurious rebuild. Low probability. |
|
||||||
|
| Mesher dedups vertices by rounding position x100 | **Verified** (`VoxelMarchingCubesMesher.cpp` `GetOrCreateVertex`). Only amplifies an upstream numeric difference; not an independent source of divergence. |
|
||||||
|
| ARCHITECTURE says every landing gets a connector to (0,0); the code says a source-fit landing stays local | **Verified contradiction**: `ARCHITECTURE.md` ~518 vs `VoxelStrateManager.cpp` ~2108. One of them is wrong. |
|
||||||
|
| Lateral-region audit 4/16 | **Verified**, `ARCHITECTURE.md` ~122. That mechanism is explicitly "not ready to promote", so it is not live terrain. |
|
||||||
|
| Layout shuffle uses a stateful `FRandomStream` | **Verified** (`VoxelStrateManager.cpp` ~1155), after a deterministic sort. Deterministic; only order-coupled when content is added. |
|
||||||
|
| Coarse LOD derived from fine data would break the clipmap invariant | Agreed (`ARCHITECTURE.md` ~708). My round-one table wrongly listed it as a gap. |
|
||||||
|
| Noise lattice float-to-int, MXCSR rounding mode, multiplayer edit ordering | Not re-checked in detail; recorded as Sol's claims. Edit ordering matters once networking exists. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The prompt, as sent
|
||||||
|
|
||||||
|
# Phase two: now you get the real code
|
||||||
|
|
||||||
|
Thank you for the first answer. It was the most useful design input this project has had in a
|
||||||
|
while. The owner read it and his reaction was: **"what Sol proposed can be nice. I am open to big
|
||||||
|
changes, but only if they prove themselves worthy."** That is the question now: **what could reasonably be changed in this code, and what would it take to
|
||||||
|
prove each change worth it?**
|
||||||
|
|
||||||
|
**The earlier rule is lifted: you may now read and search files.** This remains a read-only
|
||||||
|
consultation:
|
||||||
|
- Reading commands only (`rg`, `Get-Content`, `git log/show/diff`). Do not write or edit any file.
|
||||||
|
Do not build, compile, run tests, or launch Unreal. A single test run here takes up to an hour,
|
||||||
|
and the machine is the owner's.
|
||||||
|
- Use absolute paths; your shell's working directory can drift.
|
||||||
|
- Your final message is the whole deliverable. Please list the files you actually read at the end.
|
||||||
|
|
||||||
|
## The code
|
||||||
|
Plugin root: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge` (git branch `experimental`, HEAD `cf9904e`).
|
||||||
|
Start with these, reading targeted sections rather than whole files; some are over 100 KB:
|
||||||
|
- `CLAUDE.md` (rules), then `CODEMAP.md` (§2 data flow, §3 symbol→file index, §6 gotchas).
|
||||||
|
- `ARCHITECTURE.md` §8, especially **§8.10, the performance invariants** that exist for measured
|
||||||
|
reasons.
|
||||||
|
- `WORK-NEXT.md`, section "State after 2026-09-15" and "Open design questions for the owner".
|
||||||
|
- `PERF-SAMPLED.md`, section "Current-tip cost re-rank — 2026-09-15 (1d824aa)" (from line ~1669):
|
||||||
|
the measured cost map. Two later commits cut a further ~12% (`a668833`, reach gating) and ~4%
|
||||||
|
(`410788f`, per-chunk routing cache).
|
||||||
|
- `DESIGN-SOL-2026-09-15.md`: your first answer, plus **my** mapping of it onto this code (the
|
||||||
|
"Orchestrator notes"). I wrote that mapping from memory of the code; check it and correct me.
|
||||||
|
- `AUDIT-SOL-2026-09-11.md` and `AUDIT-COLD-2026-09-14.md`: earlier read-only audits, so you don't
|
||||||
|
repeat them.
|
||||||
|
- Hot code: `Source/VoxelForge/Private/VoxelGenerator.cpp` (`GetDensityAt`,
|
||||||
|
`GetDensityWithParams`), `VoxelCaveMorphology.cpp` (tunnel SDF, world-space tunnel core, player-fit
|
||||||
|
memo), `VoxelDensityOpStack.cpp` (operator stack, fused evaluator), `VoxelStrateManager.cpp`
|
||||||
|
(strates, passages), the mesher / tile scheduling, and `VoxelForge.Build.cs` (`/fp:precise` is
|
||||||
|
already set).
|
||||||
|
|
||||||
|
## What is measured (don't re-derive; do challenge it if the code says otherwise)
|
||||||
|
- In-game worker CPU for the standard static scenario is ~27.6 s, down from ~184 s at the start of
|
||||||
|
this push, with the same world and the same capability. Measured on the headless game path with a
|
||||||
|
stack sampler plus remove-one ablation.
|
||||||
|
- **Tried and rejected, please don't re-propose these without new evidence:**
|
||||||
|
- Merging the warped tunnel SDF with the world-space tunnel core into one pass: the world core is
|
||||||
|
load-bearing (its control points keep authored floor anchors before warp). Replacing it lost
|
||||||
|
1,146 player-fit and 975 walk-reachable cells.
|
||||||
|
- An always-on exact per-sample cull: +23% slower.
|
||||||
|
- Cheaper value-noise worms: no gain. Worms off entirely saves only ~3%.
|
||||||
|
- The acceptance rules, which any proposal must live with:
|
||||||
|
- **Determinism:** same build + same seed = same world on every machine. Bit-identity with
|
||||||
|
*older* builds is NOT required, so a change that alters the field is allowed. Classifiers and
|
||||||
|
skip proofs must never change the field.
|
||||||
|
- **Capability:** the existing gate (player-fit 20,830 / walk-reachable 10,909 cells, connected,
|
||||||
|
on the canonical scenario) must not regress.
|
||||||
|
- **Performance:** judged on the headless GAME path, static and moving, comparing the final
|
||||||
|
default build against the committed-tip DLL. A switch-off A/B inside the new build does not
|
||||||
|
count.
|
||||||
|
- The operator stack and the composer stay as the authoring language: worlds are composed, not
|
||||||
|
hand-built.
|
||||||
|
|
||||||
|
## What we would like from you
|
||||||
|
1. **Correct my mapping.** For each row of the table in `DESIGN-SOL-2026-09-15.md`, say whether it
|
||||||
|
is right, and give file:symbol evidence where it is wrong.
|
||||||
|
2. **Your ideas against this code.** For each idea from your first answer that survives contact with
|
||||||
|
the code:
|
||||||
|
- where it plugs in (files, symbols) and what it replaces or keeps;
|
||||||
|
- its rough size;
|
||||||
|
- its risks to capability, determinism, the composer and the §8.10 invariants;
|
||||||
|
- its expected gain, grounded in the measured cost map. Say what share of the ~27.6 s it could
|
||||||
|
touch, and mark each number measured or inferred.
|
||||||
|
3. **Rank them.** Then, for the top one or two big changes, design a **proof-of-worth ladder**:
|
||||||
|
- Rung 1: the cheapest experiment that could kill the idea early. Ideally instrumentation only,
|
||||||
|
e.g. how much time goes to per-sample candidate search, and how many candidates a sample sees.
|
||||||
|
- Rung 2: a prototype scoped to one feature type, behind a console switch.
|
||||||
|
- Rung 3: full adoption.
|
||||||
|
Each rung needs an explicit, falsifiable kill criterion, and the numbers that would justify going
|
||||||
|
on. The owner wants big changes to earn their place, so make the early rungs cheap and honest.
|
||||||
|
4. **Determinism audit of numerics on the world-deciding path.** We build with `/fp:precise`.
|
||||||
|
Enumerate the remaining cross-machine hazards with file:line:
|
||||||
|
- CRT transcendentals (`sin/cos/pow/exp/log/atan2/...`, including through `FMath`);
|
||||||
|
- any runtime CPU-feature dispatch (SIMD paths chosen at runtime, ISPC);
|
||||||
|
- float-to-int conversions at boundaries;
|
||||||
|
- order-dependent accumulation across threads, uninitialised data, timing dependence.
|
||||||
|
Give severity and the minimal fix for each. In particular: on UE 5.7 Win64, do `FMath::Sin/Cos`
|
||||||
|
end up in the MSVC CRT (whose FMA3 variant is chosen at runtime), or in engine code? If engine
|
||||||
|
source is not reachable from here, say so and say what you would check.
|
||||||
|
5. **What not to graft here**, and why.
|
||||||
|
|
||||||
|
This is advice only. Nothing will be changed as a result of this run until the owner has read your
|
||||||
|
answer and decided, so don't write code or patches. Describe what could reasonably change, and what
|
||||||
|
it would take.
|
||||||
|
|
||||||
|
Push back wherever the code shows my framing or my mapping is wrong. That is worth more than
|
||||||
|
agreement. Say plainly where you are unsure.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sol's answer, verbatim
|
||||||
|
|
||||||
|
# Executive conclusion
|
||||||
|
|
||||||
|
I would not replace VoxelForge’s representation, mesher, streaming model, or operator authoring language wholesale. The current design is already much closer to the “lazy compiled field” architecture from the first answer than the orchestrator notes imply, and the measured reduction from ~184 s to ~27.6 s shows that its major structural choices are viable.
|
||||||
|
|
||||||
|
Three changes remain worth serious consideration:
|
||||||
|
|
||||||
|
1. A tile-wide, feature-major lowering of the canonical tunnel evaluator. This is the only remaining architectural change with a plausible 8–18% total worker-CPU gain, but it has not yet earned implementation.
|
||||||
|
2. Gameplay reuse of exact density samples across adjacent tiles. The existing mesher already has most of the consumer-side plumbing, and the theoretical duplicate-sample ceiling is surprisingly large.
|
||||||
|
3. A canonical numeric ABI for world-deciding math. This is primarily correctness, not performance: `/fp:precise` does not currently close the determinism requirement.
|
||||||
|
|
||||||
|
I would also harden the traversal contract. The canonical capability gate passes, but the code and documentation do not support the stronger claim that every composed world has a structurally guaranteed entry-to-exit route.
|
||||||
|
|
||||||
|
All performance percentages below distinguish:
|
||||||
|
|
||||||
|
- **Measured:** directly present in the supplied profiles or commit evidence.
|
||||||
|
- **Inferred:** derived from those measurements; not yet benchmarked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Correction of the orchestrator mapping
|
||||||
|
|
||||||
|
There is no table in `DESIGN-SOL-2026-09-15.md` at this HEAD. There are three mapping bullets at [DESIGN-SOL-2026-09-15.md:9](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/DESIGN-SOL-2026-09-15.md:9>). I have treated those as the requested rows.
|
||||||
|
|
||||||
|
| Orchestrator note | Verdict | Correction |
|
||||||
|
|---|---|---|
|
||||||
|
| Determinism risk from `FMath::Sin/Cos` and UCRT FMA3 dispatch | **Substantively correct; count is stale/ambiguous** | The risk is real and `/fp:precise` does not fix library implementations. The current source contains 42 direct `Sin/Cos` invocations in terrain generation if legacy and fused implementations are both counted, plus eight in deterministic content placement—not simply 34. |
|
||||||
|
| “The largest gap is the loop direction” | **Plausible, not proved** | Every tunnel sample does query a 2D spatial bucket and iterate candidates, but the exclusive lookup machinery was only about 5% in the newer profile. The old 27% figure was inclusive of the feature kernels. Feature-major batching may still win through SIMD, hoisting and locality, but candidate lookup alone cannot justify a rewrite. |
|
||||||
|
| Already shares built-first traversal, hash RNG, collision gate, partial narrow-band skipping | **Mixed** | Collision gating and narrow-band/reach proofs are real. Hash-keyed randomness is dominant but not universal. Traversal is structurally constructed for major archetypes, but the universal guarantee is not established, especially for fallback passage landings and lateral-region composition. |
|
||||||
|
|
||||||
|
## 1.1 Determinism note
|
||||||
|
|
||||||
|
The direct terrain-generation `Sin/Cos` sites are:
|
||||||
|
|
||||||
|
- [VoxelGenerator.cpp:1129](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1129>), `1178`, `1377`, `1456`, `1557`, `1612`, `4708`, `4738`, `5723`.
|
||||||
|
- [VoxelDensityOpStack.cpp:7507](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:7507>) and `7609`.
|
||||||
|
- [VoxelHeightOpStack.cpp:232](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelHeightOpStack.cpp:232>).
|
||||||
|
- [VoxelCaveMorphology.cpp:2931](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:2931>), `3266`, `6826`, `6843`, `7367`, `7487–7488`, `7520–7521`.
|
||||||
|
- [VoxelStrateManager.cpp:1830](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:1830>), `1993`, `2008`, `2021–2022`, `2032–2033`, `2045`, `2087`, `2142`.
|
||||||
|
|
||||||
|
The duplication matters: several calls belong to parallel legacy/fused implementations, so a raw count overstates unique runtime work while still accurately describing the audit surface.
|
||||||
|
|
||||||
|
The engine source resolves the uncertainty conclusively:
|
||||||
|
|
||||||
|
- `FMath` inherits `FPlatformMath` at [UnrealMathUtility.h:269](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/Math/UnrealMathUtility.h:269>).
|
||||||
|
- Win64 aliases `FPlatformMath` to `FMicrosoftPlatformMathBase` at [WindowsPlatformMath.h:18](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/Windows/WindowsPlatformMath.h:18>).
|
||||||
|
- The Microsoft class inherits the generic implementation and does not override `Sin` or `Cos` at [MicrosoftPlatformMath.h:12](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/Microsoft/MicrosoftPlatformMath.h:12>).
|
||||||
|
- Generic `Sin` calls `sinf` and generic `Cos` calls `cosf` at [GenericPlatformMath.h:521](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/GenericPlatform/GenericPlatformMath.h:521>) and [GenericPlatformMath.h:530](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/GenericPlatform/GenericPlatformMath.h:530>).
|
||||||
|
|
||||||
|
So, on UE 5.7 Win64, `FMath::Sin/Cos` use the MSVC/UCRT functions. They do not use Unreal’s polynomial `FMath::SinCos(float)`, which is separate engine code at [UnrealMathUtility.h:752](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/Math/UnrealMathUtility.h:752>).
|
||||||
|
|
||||||
|
Microsoft documents that the x64 CRT selects FMA3 implementations at startup based on CPU support and that the alternate algorithms can produce different results. [`_set_FMA3_enable` documentation](https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/get-fma3-enable-set-fma3-enable?view=msvc-170).
|
||||||
|
|
||||||
|
The existing cross-platform test already recognizes the libm hole at [VoxelForgeCrossPlatformTest.cpp:158](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/Tests/VoxelForgeCrossPlatformTest.cpp:158>), but it reports risk rather than enforcing an identical field digest. Therefore constraint 1 is not currently proved.
|
||||||
|
|
||||||
|
## 1.2 “Largest gap is loop direction”
|
||||||
|
|
||||||
|
What the note gets right:
|
||||||
|
|
||||||
|
- A cache builds 2D chunk buckets at [VoxelCaveMorphology.cpp:1325](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:1325>).
|
||||||
|
- Each sample calls `VF_ForEachSpatialCandidate`, including `GetRange`, at [VoxelCaveMorphology.cpp:1462](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:1462>).
|
||||||
|
- Tunnel SDF and world-core evaluation perform separate candidate walks at [VoxelCaveMorphology.cpp:7847](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:7847>) and [VoxelCaveMorphology.cpp:8042](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:8042>).
|
||||||
|
- The canonical fused path remains scalar per sample: `GetDensityAt` calls `GetDensityWithParams` at [VoxelGenerator.cpp:3386](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:3386>).
|
||||||
|
- The mesher calls `GetDensityAt` once for every required grid sample at [VoxelMarchingCubesMesher.cpp:694](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:694>).
|
||||||
|
|
||||||
|
What is overstated:
|
||||||
|
|
||||||
|
- The `1d824aa` sampler’s `VF_ForEachSpatialCandidate` share was inclusive of work executed inside the visitor.
|
||||||
|
- The later `410788f` exclusive attribution puts `GetRange` around 3.8% and `VF_ForEachSpatialCandidate` itself around 1.2%.
|
||||||
|
- Thus the directly removable search overhead is approximately 5% of the old profile, not 27%.
|
||||||
|
- The expensive swept-tunnel work remains inside those callbacks. A loop inversion must make that work cheaper—not merely remove the lookup—to justify itself.
|
||||||
|
|
||||||
|
“Coarse LOD from fine data is absent” is factually true but not evidence of a gap. Direct evaluation at every LOD is a deliberate clipmap invariant: it maintains roughly constant tile complexity and does not force far terrain to materialize at LOD0. See [ARCHITECTURE.md:708](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:708>).
|
||||||
|
|
||||||
|
“Shared halo samples are absent” needs qualification:
|
||||||
|
|
||||||
|
- Every tile already has a single local density grid plus a one-sample margin; cell corners and normals are not resampled. See [VoxelMarchingCubesMesher.cpp:656](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:656>) and [ARCHITECTURE.md:652](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:652>).
|
||||||
|
- `FVoxelSharedDensityGrid` already exists and the mesher can consume it at [VoxelMarchingCubesMesher.cpp:660](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:660>).
|
||||||
|
- It is populated only by the editor exploration commandlet, culminating in `SetSharedDensityGrid` at [VoxelForgeExploreCommandlet.cpp:1905](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp:1905>).
|
||||||
|
- Gameplay does not share density samples across neighboring tiles.
|
||||||
|
|
||||||
|
So the missing opportunity is specifically **cross-tile gameplay reuse**, not basic grid or halo caching.
|
||||||
|
|
||||||
|
## 1.3 “Already shared”
|
||||||
|
|
||||||
|
### Traversal: partial, not universal
|
||||||
|
|
||||||
|
The current system has real construction-first features: walk-critical tunnel forests, D-floors, option-B winding and structural posts. That part of the mapping is fair.
|
||||||
|
|
||||||
|
But two facts prevent me from calling the hard guarantee closed:
|
||||||
|
|
||||||
|
1. `ARCHITECTURE.md` says every landing receives a deterministic connector to the origin landing at [ARCHITECTURE.md:518](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:518>). Current code explicitly says the opposite: a source-fit landing remains local and no radial connector is synthesized at [VoxelStrateManager.cpp:2108](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:2108>). The document is stale or the implementation regressed from its stated contract.
|
||||||
|
|
||||||
|
2. The lateral-region audit remains at 4/16 connected cases after landing, including region-zero controls, at [ARCHITECTURE.md:122](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:122>).
|
||||||
|
|
||||||
|
`VF_SuggestLandingPoint` can make a local landing safe when it succeeds, but the documentation says unsupported/no-footing cases fall back to historical random reach at [ARCHITECTURE.md:512](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:512>). That is not a construction proof.
|
||||||
|
|
||||||
|
The canonical 20,830 / 10,909 gate is valuable regression protection. It is not a proof across seeds, composer rolls and archetype mixtures.
|
||||||
|
|
||||||
|
### Randomness: mostly hash-keyed
|
||||||
|
|
||||||
|
Most feature decisions are keyed hashes. The layout shuffle still uses one stateful `FRandomStream` after sorting the asset pool at [VoxelStrateManager.cpp:1145](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:1145>) and [VoxelStrateManager.cpp:1157](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:1157>).
|
||||||
|
|
||||||
|
That is deterministic for the same build and pool. It is nevertheless order-coupled: adding one pool choice can shift every later choice. It therefore falls short of the first answer’s “stable ID plus counter-based randomness” ideal, though it does not presently violate the stated cross-machine rule.
|
||||||
|
|
||||||
|
### Collision gate: correct
|
||||||
|
|
||||||
|
The implementation now treats actual collision-cook completion as admission authority. This is stronger than speculative prefetch. It satisfies the hard safety part even under overload by stopping motion.
|
||||||
|
|
||||||
|
What remains from the first answer is UX improvement: predictive fall/shaft prefetch could make the gate engage less often. It is not required for correctness.
|
||||||
|
|
||||||
|
### Narrow bands: correct and substantial
|
||||||
|
|
||||||
|
The sealed-solid proof, tunnel-core reach, passage-carving reach, worm block proof, cached topology and lateral-region preparation all exist. They are not “partial” in the sense of being superficial; §8.10 records a considerable exact-skip system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Ideas that survive contact with the code
|
||||||
|
|
||||||
|
## Ranked recommendations
|
||||||
|
|
||||||
|
| Rank | Change | Share of 27.6 s it could touch | Expected net gain | Rough size |
|
||||||
|
|---|---|---:|---:|---:|
|
||||||
|
| 1 | Tile-wide, feature-major lowering for tunnels | **35–50% inferred**: 9.7–13.8 s | **8–18% inferred**: 2.2–5.0 s | 0.8–1.5 KLOC prototype; 3–6 KLOC production |
|
||||||
|
| 2 | Cross-tile exact density-grid reuse | Up to **22.9% theoretical/inferred**: 6.3 s; actual unknown | **5–12% inferred** if reuse is high | 0.6–1.2 KLOC prototype; 1.5–3 KLOC production |
|
||||||
|
| 3 | Canonical numeric ABI | Probably under 5% runtime touched; correctness is the value | Approximately neutral, perhaps −1% to +1% inferred | 0.3–0.8 KLOC plus tests |
|
||||||
|
| 4 | Traversal-plan contract hardening | 0% of static worker time | Correctness, not speed | 1–3 KLOC plus seed-matrix tests |
|
||||||
|
| 5 | Exact base-density reuse and canonical edit compaction | **0% measured** in the static scenario | Potentially large only after repeated edits; unmeasured | 1.5–3 KLOC |
|
||||||
|
| 6 | Deadline/fall-aware prefetch | Moving-streaming work only; unknown | Gate-engagement and latency improvement, not established CPU savings | 1–2 KLOC |
|
||||||
|
| 7 | Topology-aware underground visibility | Far streaming only; unknown | Speculative | Large |
|
||||||
|
|
||||||
|
The `1d824aa` percentages are historical measured shares from a 29.30 s build, not additive and not exact for the current 27.6 s build. Scaling them onto 27.6 s is explicitly inferred.
|
||||||
|
|
||||||
|
## 2.1 Tile-wide, feature-major lowering
|
||||||
|
|
||||||
|
### Where it plugs in
|
||||||
|
|
||||||
|
Add a tile/grid evaluation entry point beside:
|
||||||
|
|
||||||
|
- `UVoxelGenerator::GetDensityAt` at [VoxelGenerator.cpp:2799](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2799>).
|
||||||
|
- `UVoxelGenerator::GetDensityWithParams` at [VoxelGenerator.cpp:3921](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:3921>).
|
||||||
|
- The mesher grid-fill loop at [VoxelMarchingCubesMesher.cpp:694](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:694>).
|
||||||
|
|
||||||
|
Conceptually:
|
||||||
|
|
||||||
|
```text
|
||||||
|
composer/operator stack
|
||||||
|
↓ lowering
|
||||||
|
canonical tile program
|
||||||
|
↓
|
||||||
|
tile-local SoA buffers
|
||||||
|
↓
|
||||||
|
existing density grid
|
||||||
|
↓
|
||||||
|
existing two-pass marching cubes
|
||||||
|
```
|
||||||
|
|
||||||
|
It should replace only the repeated scalar execution of the canonical tunnel-network graph. It should keep:
|
||||||
|
|
||||||
|
- `FVoxelOpStack` as the authoring language.
|
||||||
|
- `BuildTunnelNetworkStack` and the fused-evaluator designation.
|
||||||
|
- `FChunkSDFCache` and its search-box validity.
|
||||||
|
- The warped tunnel SDF and world-space core as separate semantic stages.
|
||||||
|
- The common structural tail, boundary seal and edit layer.
|
||||||
|
- Scalar fallback for custom recipes, fractional point queries and unsupported stacks.
|
||||||
|
- The existing marching-cubes grid, margin and two-pass cell loop.
|
||||||
|
|
||||||
|
The production algorithm I would try is:
|
||||||
|
|
||||||
|
1. Prepare the same chunk/tile context once.
|
||||||
|
2. Allocate thread-local SoA arrays for density, warped coordinates, nearest-room ownership and world-core handoff flags.
|
||||||
|
3. Compute sample-invariant and column-invariant data in batches.
|
||||||
|
4. Iterate rooms/tunnels in existing stable feature-index order.
|
||||||
|
5. Clip each feature’s conservative 3D influence bounds to the tile lattice.
|
||||||
|
6. Traverse X-contiguous spans and evaluate four or eight positions together where the formula permits.
|
||||||
|
7. Apply smooth unions in the same per-sample feature order.
|
||||||
|
8. Run later operators and the structural tail in their canonical order.
|
||||||
|
9. Write the existing `DensityGrid`.
|
||||||
|
|
||||||
|
The index builder inserts item indices in ascending feature order at [VoxelCaveMorphology.cpp:1445](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:1445>). Maintaining that order matters because smooth-min and other floating operations are order-sensitive.
|
||||||
|
|
||||||
|
### Measured basis
|
||||||
|
|
||||||
|
Historical remove-one shares:
|
||||||
|
|
||||||
|
- Tunnel core: **20.84% measured**, corresponding to 5.75 s when scaled to 27.6 s.
|
||||||
|
- Tunnel SDF: **12.64% measured**, corresponding to 3.49 s.
|
||||||
|
- Room SDF: **4.24% measured**, corresponding to 1.17 s.
|
||||||
|
- Cave warp: **6.68% measured**, corresponding to 1.84 s.
|
||||||
|
- Passage carving/structure can be incorporated later.
|
||||||
|
|
||||||
|
The tunnel core plus tunnel SDF therefore account for **33.48% measured historically**, or 9.24 s scaled to the current total. A batch evaluator cannot eliminate that geometry; it can only reduce its loop, load, branch and arithmetic cost.
|
||||||
|
|
||||||
|
The newer exclusive profile says lookup machinery itself was approximately **5% measured historically**, about 1.38 s scaled. This is why “invert the loops” alone is not enough. The hoped-for gain must come from:
|
||||||
|
|
||||||
|
- Hoisting tunnel metadata.
|
||||||
|
- Avoiding repeated lambdas/cache lookups and repeated world-coordinate construction.
|
||||||
|
- SIMD across adjacent X samples.
|
||||||
|
- Reusing centerline segment data across samples.
|
||||||
|
- Better sequential reads and writes.
|
||||||
|
- Clipping feature loops in 3D rather than retrieving a 2D bucket and rejecting on Z for every sample.
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
|
||||||
|
- **Capability:** high. The core/SDF handoff and room-floor ownership are subtle. The rejected merge shows this is load-bearing.
|
||||||
|
- **Determinism:** medium-high. SIMD must use the same fixed instruction path on every x64 machine. Any conservative span skip must be reference-checked and field-identical.
|
||||||
|
- **Composer:** low if this remains a lowering backend. High if it becomes a separate hand-authored terrain definition.
|
||||||
|
- **§8.10:** high implementation risk. It must preserve search-box validity, lateral per-chunk preparation, reach masks, the margin grid, cancellation points, TLS reuse and direct evaluation at each LOD.
|
||||||
|
- **Maintenance:** high if the scalar and batched semantics fork. Shared primitive functions and generated/lowered stage descriptions are preferable to copying thousands of lines.
|
||||||
|
|
||||||
|
I would not generalize it to every archetype first. TunnelNetwork/Underwater already have a canonical fused designation and dominate the measured cost.
|
||||||
|
|
||||||
|
## 2.2 Exact density sharing across gameplay tiles
|
||||||
|
|
||||||
|
This is a more concrete surviving piece of the “shared bricks/halos” proposal than a full sparse-brick rewrite.
|
||||||
|
|
||||||
|
### Why it may be larger than it looks
|
||||||
|
|
||||||
|
A normal full-resolution tile has 32 cells but samples a 35³ grid because of the cell boundary and one-point normal margin.
|
||||||
|
|
||||||
|
For an ideal dense array of adjacent same-LOD tiles, the asymptotic duplicate fraction is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1 − 32³ / 35³ = 23.6%
|
||||||
|
```
|
||||||
|
|
||||||
|
This is an inferred theoretical ceiling. Actual reuse will be lower because of clipmap shell boundaries, Z bands, cancellation and tile completion order.
|
||||||
|
|
||||||
|
The old sampled profile attributed **96.9% measured, inclusive** to `GetDensityAt`. If duplicated coordinates had average cost, the absolute theoretical CPU ceiling would be approximately:
|
||||||
|
|
||||||
|
```text
|
||||||
|
23.6% × 96.9% = 22.9% of total
|
||||||
|
```
|
||||||
|
|
||||||
|
That is 6.3 s of the 27.6 s total. It is not an expected gain; it is the reason this deserves instrumentation.
|
||||||
|
|
||||||
|
### Where it plugs in
|
||||||
|
|
||||||
|
- Existing consumer support: `FVoxelSharedDensityGrid` in [VoxelMarchingCubesMesher.h:25](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h:25>).
|
||||||
|
- Existing copy path: [VoxelMarchingCubesMesher.cpp:660](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:660>).
|
||||||
|
- Scheduler and tile lifetime: `AVoxelWorld::LoadTile`, task completion and cancellation paths.
|
||||||
|
- Edit revision: `UVoxelDiffLayer::ModsVersion` and the snapshot mechanism.
|
||||||
|
|
||||||
|
I would prototype an **opportunistic completed-neighbor slab cache**, not a global per-sample hash table:
|
||||||
|
|
||||||
|
- Store exact float boundary slabs from recently completed tiles.
|
||||||
|
- Key by generator owner, layout/generation epoch, final-field edit revision, sample step and integer world-lattice bounds.
|
||||||
|
- Before sampling a tile, copy all available overlap slabs with simple block loops.
|
||||||
|
- Evaluate only missing rectangular regions.
|
||||||
|
- Never wait for a neighboring tile.
|
||||||
|
- Evict outside the desired/in-flight neighborhood.
|
||||||
|
|
||||||
|
The current `SetSharedDensityGrid` member pointer is suitable for the single-threaded editor commandlet, not for concurrent gameplay tasks. Production should pass immutable donor views with each mesh request, not mutate shared mesher state.
|
||||||
|
|
||||||
|
Do not reuse the quantized density-volume capture for meshing. Its bytes are sufficient for lighting volume capture, not exact marching-cubes interpolation.
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
|
||||||
|
- **Streaming:** a support tile must never wait for a batch or neighbor.
|
||||||
|
- **Revision correctness:** an edit or layout rebuild must prevent reuse of stale final densities.
|
||||||
|
- **Memory:** retaining complete 35³ float grids is ~171 KB per tile. Store bounded slabs or short-lived grids, not every loaded tile indefinitely.
|
||||||
|
- **Concurrency:** avoid global locks in the sample loop.
|
||||||
|
- **Determinism:** exact copied floats are safe if the key is complete. A stale cache hit is a geometry error.
|
||||||
|
- **§8.10:** preserve task cancellation and the thread-local destination grid; copying merely replaces a subset of its fill operations.
|
||||||
|
|
||||||
|
Expected net gain is **5–12% inferred**, conditional on actual ready-before-use reuse being high. It may also fail completely if most neighboring tasks execute concurrently and finish too late to donate samples.
|
||||||
|
|
||||||
|
## 2.3 Canonical numeric ABI
|
||||||
|
|
||||||
|
This is the surviving portion of the first answer’s WorldPlan recommendation with the highest correctness value.
|
||||||
|
|
||||||
|
The code already has a substantial plan/layout layer, cached topology, per-strate parameters and operator recipes. I would not introduce a second “WorldPlan” representation. I would add:
|
||||||
|
|
||||||
|
- A generation format/version ID.
|
||||||
|
- Stable feature IDs for topology objects and composer choices.
|
||||||
|
- Canonical deterministic `SinCos`, normalization and checked coordinate conversions.
|
||||||
|
- Golden plan, density and mesh digests.
|
||||||
|
- An explicit floating-environment contract for worker tasks.
|
||||||
|
|
||||||
|
This touches very little of the 27.6 s. The per-sample `Sin` calls may be measurable, but the topology and passage calls are cold. Expect approximately neutral performance.
|
||||||
|
|
||||||
|
## 2.4 Traversal contract hardening
|
||||||
|
|
||||||
|
The least invasive structural fix is to make passage creation return one of two outcomes:
|
||||||
|
|
||||||
|
- A landing with a certificate identifying the existing structural feature/component to which it belongs.
|
||||||
|
- A newly constructed explicit connector included in the mandatory route graph.
|
||||||
|
|
||||||
|
A failed `VF_SuggestLandingPoint` should cause a deterministic retry/recomposition or explicit connector. It should not silently fall back to an unproved random reach.
|
||||||
|
|
||||||
|
For lateral compositions, generate and reserve the mandatory entry-to-exit spine at the parent level before regional creative stacks. Regional terrain may join or decorate it but not own its existence.
|
||||||
|
|
||||||
|
This preserves the composer and adds no material density-loop cost. The proof is a seed/archetype/composition matrix, not merely the canonical scenario.
|
||||||
|
|
||||||
|
## 2.5 Edit compaction and base-grid reuse
|
||||||
|
|
||||||
|
The current edit layer duplicates an edit into each affected chunk and evaluates the list serially at [VoxelDiffLayer.cpp:361](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDiffLayer.cpp:361>). There is no compaction into canonical per-brick deltas.
|
||||||
|
|
||||||
|
This does not touch the standard static 27.6 s scenario: **0% measured**.
|
||||||
|
|
||||||
|
For mining-heavy sessions, I would eventually keep:
|
||||||
|
|
||||||
|
- Immutable generated base samples for a recently remeshed edited tile.
|
||||||
|
- A server-sequenced edit stream.
|
||||||
|
- Canonical per-tile delta compaction after a threshold.
|
||||||
|
- Separate base-generation, edit, mesh and collision revision numbers.
|
||||||
|
|
||||||
|
Do this only after creating an edit-heavy benchmark. Depending on edit-list length, it might remove 30–80% of edit-layer work or much of repeated base evaluation, but that is wholly inferred and should not be sold using the static benchmark.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Proof-of-worth ladders
|
||||||
|
|
||||||
|
## Change A: tile-wide feature-major tunnel evaluation
|
||||||
|
|
||||||
|
### Rung 1 — Instrument the current evaluator
|
||||||
|
|
||||||
|
Use the existing counters:
|
||||||
|
|
||||||
|
- `CaveRoomCandidates/Evaluated`
|
||||||
|
- `CaveTunnelCandidates/Evaluated`
|
||||||
|
- `TunnelCoreCandidates/Evaluated`
|
||||||
|
|
||||||
|
They are recorded in [VoxelCaveMorphology.cpp:7765](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:7765>), [VoxelCaveMorphology.cpp:7844](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:7844>) and [VoxelCaveMorphology.cpp:8042](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:8042>), then logged per tile at [VoxelWorld.cpp:4961](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:4961>).
|
||||||
|
|
||||||
|
Add profile-only counters for:
|
||||||
|
|
||||||
|
- `GetRange` calls/hits/fallbacks and cycles.
|
||||||
|
- Candidate visits rejected by 3D AABB.
|
||||||
|
- Candidate visits rejected by bounding sphere.
|
||||||
|
- Actual swept-tunnel evaluations.
|
||||||
|
- Centerline segment iterations.
|
||||||
|
- For each tile, the predicted feature-major lattice visits obtained by clipping every feature’s conservative 3D bounds to that tile.
|
||||||
|
- Separate static/moving and LOD0/all-LOD totals.
|
||||||
|
|
||||||
|
Do not time every sample with a heavyweight timer; aggregate cycles around whole loops or rely on sampling.
|
||||||
|
|
||||||
|
**Kill immediately if:**
|
||||||
|
|
||||||
|
- Exclusive candidate/rejection machinery remains below 6–7% of worker time, and
|
||||||
|
- Predicted feature-major visits are at least 80% of current expensive evaluations, and
|
||||||
|
- There is no clear X-lane SIMD opportunity in the swept kernel.
|
||||||
|
|
||||||
|
That combination caps the likely gain below what a large rewrite deserves.
|
||||||
|
|
||||||
|
**Proceed if either:**
|
||||||
|
|
||||||
|
- Conservative feature spans reduce candidate/kernel visits by at least 2×, or
|
||||||
|
- The combined measured loop/branch/kernel share with realistic SIMD opportunity is at least 12% of total worker time.
|
||||||
|
|
||||||
|
Also require projected scratch below roughly 2 MB per worker for a single-tile prototype.
|
||||||
|
|
||||||
|
### Rung 2 — Tunnel SDF only
|
||||||
|
|
||||||
|
Behind a diagnostic console switch:
|
||||||
|
|
||||||
|
- Batch the warped tunnel SDF.
|
||||||
|
- Leave `EvaluateTunnelCoreWorld` entirely unchanged.
|
||||||
|
- Preserve room-mouth ownership and feature order.
|
||||||
|
- Keep scalar evaluation as the reference path.
|
||||||
|
- Add a debug mode comparing every produced sample with the reference field.
|
||||||
|
|
||||||
|
The tunnel SDF had a **12.64% measured historical share**, so the prototype has enough budget to show a useful result without touching the load-bearing core.
|
||||||
|
|
||||||
|
**Kill immediately on:**
|
||||||
|
|
||||||
|
- Player-fit below 20,830.
|
||||||
|
- Walk-reachable below 10,909.
|
||||||
|
- Loss of connected status.
|
||||||
|
- Any nondeterministic digest across repeated runs or machines.
|
||||||
|
- A span/proof skip that changes a reference density bit.
|
||||||
|
- A violation of the two-region cache window, margin grid or cancellation contracts.
|
||||||
|
|
||||||
|
**Performance kill criterion:**
|
||||||
|
|
||||||
|
- Less than 3% total worker-CPU improvement in either static or moving, or
|
||||||
|
- More than 3% regression in generation/collision-ready p95.
|
||||||
|
|
||||||
|
**Justification to continue:**
|
||||||
|
|
||||||
|
- At least 5% total worker-CPU improvement in both static and moving, with no material p95 or memory regression.
|
||||||
|
|
||||||
|
The switch-on/off result is only an internal decision aid. It does not satisfy final acceptance.
|
||||||
|
|
||||||
|
### Rung 3 — Separate batch stages for world core and passage work
|
||||||
|
|
||||||
|
Add the world-space core as a second, semantically separate batch stage. Do not merge it with the warped SDF. Then consider passage carving/structure if the same infrastructure applies.
|
||||||
|
|
||||||
|
Full adoption requires:
|
||||||
|
|
||||||
|
- Default-on build compared against the untouched `cf9904e` DLL.
|
||||||
|
- At least 10% worker-CPU improvement in both static and moving, or at least 8% plus a material collision-ready/p95 improvement.
|
||||||
|
- Canonical capability counts and connection.
|
||||||
|
- Skip/reference tests clean.
|
||||||
|
- Memory and task-cancellation behavior within existing bounds.
|
||||||
|
- No custom-recipe or lateral-composition semantic fork.
|
||||||
|
|
||||||
|
## Change B: cross-tile exact density reuse
|
||||||
|
|
||||||
|
### Rung 1 — Count exact duplicate opportunities
|
||||||
|
|
||||||
|
Record, for every generated tile:
|
||||||
|
|
||||||
|
- Origin, step, sampled Z band and generation/edit revision.
|
||||||
|
- Task start and completion order.
|
||||||
|
- Count of requested lattice coordinates.
|
||||||
|
- Count of distinct lattice coordinates across the scenario.
|
||||||
|
- Count whose donor tile had already completed before the consumer started.
|
||||||
|
- Reuse grouped by LOD, same/cross-LOD, static/moving and density-cost bucket.
|
||||||
|
|
||||||
|
This can be computed offline from a compact tile trace; there is no need for a runtime hash set of every sample in the production path.
|
||||||
|
|
||||||
|
**Kill if:**
|
||||||
|
|
||||||
|
- Exact duplicate coordinates are below 12% of all density calls, or
|
||||||
|
- Fewer than half of duplicates have a completed donor in time, or
|
||||||
|
- Time-weighted eligible duplicates represent less than 5% of worker CPU.
|
||||||
|
|
||||||
|
**Proceed if:**
|
||||||
|
|
||||||
|
- At least 15% of calls are exact duplicates and at least 60% are available opportunistically, or
|
||||||
|
- The conservative time-weighted saving ceiling exceeds 10% of total worker CPU.
|
||||||
|
|
||||||
|
### Rung 2 — LOD0 completed-neighbor slabs
|
||||||
|
|
||||||
|
Prototype only LOD0:
|
||||||
|
|
||||||
|
- Exact float slabs.
|
||||||
|
- Immutable per-request donor views.
|
||||||
|
- No waiting.
|
||||||
|
- No cross-LOD reuse.
|
||||||
|
- Strict owner/layout/edit revision key.
|
||||||
|
- Bounded memory, preferably 32–64 MB.
|
||||||
|
- Missing samples follow the current path.
|
||||||
|
|
||||||
|
**Kill immediately on any field/mesh digest difference.** This should be a pure reuse, not a field-changing implementation.
|
||||||
|
|
||||||
|
**Performance kill criterion:**
|
||||||
|
|
||||||
|
- Less than 4% worker-CPU improvement in either static or moving.
|
||||||
|
- More than 3% collision-ready or generation p95 regression.
|
||||||
|
- Memory above the agreed cap.
|
||||||
|
- Significant loss of task concurrency.
|
||||||
|
|
||||||
|
**Proceed if:**
|
||||||
|
|
||||||
|
- At least 6% worker-CPU improvement in both scenarios.
|
||||||
|
- No readiness regression.
|
||||||
|
- No stale-hit failure under edit/rebuild/cancellation stress.
|
||||||
|
|
||||||
|
### Rung 3 — Full same-step gameplay reuse
|
||||||
|
|
||||||
|
Extend to same-step tiles at all useful clip levels, with revisioned invalidation and bounded eviction. Cross-LOD reuse is optional and should be separately justified.
|
||||||
|
|
||||||
|
Adopt only if the final default build beats `cf9904e` by at least 7–8% overall or produces a slightly smaller CPU gain plus a clear collision-readiness improvement. Remove the diagnostic switch only after the committed-DLL comparison.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Numeric determinism audit
|
||||||
|
|
||||||
|
## 4.1 Critical: CRT `sin/cos`
|
||||||
|
|
||||||
|
**Severity: critical.**
|
||||||
|
|
||||||
|
These functions affect:
|
||||||
|
|
||||||
|
- Passage control points and landing directions.
|
||||||
|
- Tunnel winding and pinch orientation.
|
||||||
|
- Room/feature placement.
|
||||||
|
- Per-sample line/rib/surface detail.
|
||||||
|
- Walkability thresholds.
|
||||||
|
|
||||||
|
The UCRT can select different implementations in the same Win64 binary based on CPU features. Windows and Linux libm implementations may also differ.
|
||||||
|
|
||||||
|
Minimal fix:
|
||||||
|
|
||||||
|
1. Introduce one canonical float `SinCos` implementation with explicitly bounded input, fixed range reduction and fixed operation grouping.
|
||||||
|
2. The UE float `FMath::SinCos` polynomial is a reasonable prototype because it is engine arithmetic rather than CRT dispatch. I would wrap or vendor it so the numeric contract is explicit.
|
||||||
|
3. Replace paired `Sin`/`Cos` calls together.
|
||||||
|
4. Use the same canonical sine output for individual sine-only sites.
|
||||||
|
5. Pin golden input/output bit patterns and world-plan/field digests.
|
||||||
|
6. Retune if necessary; a new build may change the field.
|
||||||
|
|
||||||
|
Calling `_set_FMA3_enable(0)` is not a complete fix. It is process-global, Windows-specific and does not align UCRT with glibc.
|
||||||
|
|
||||||
|
## 4.2 Medium: `sqrt` and implicit vector normalization
|
||||||
|
|
||||||
|
`FMath::Sqrt` also reaches `sqrtf` at [GenericPlatformMath.h:552](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/GenericPlatform/GenericPlatformMath.h:552>). On x64 this will commonly become a hardware square-root instruction, which is much less alarming than `sin/cos`, but the source-level contract still delegates to the platform library/compiler.
|
||||||
|
|
||||||
|
World-deciding examples include:
|
||||||
|
|
||||||
|
- Morphology topology/walkability: [VoxelCaveMorphology.cpp:639](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:639>), `785`, `2171`, `2188`, `3019`, `3027`, `4919`, `5101`, `5355`.
|
||||||
|
- Tunnel/room field evaluation: `7704`, `8356`, `8550`.
|
||||||
|
- Passage/layout geometry: [VoxelStrateManager.cpp:666](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:666>), `2393`, `2406`, `3701`, `3804`.
|
||||||
|
- Composer decisions: [VoxelStrateComposer.cpp:1572](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateComposer.cpp:1572>), `1606–1608`, `4141`, `4172`, `4662–4665`, `4849`, `5016–5018`.
|
||||||
|
- Numerous SDF sites in `VoxelGenerator.cpp` and `VoxelDensityOpStack.cpp`.
|
||||||
|
- `FVector::Size`, `Dist` and `Normalize`, for example passage direction normalization at [VoxelStrateManager.cpp:2085](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:2085>).
|
||||||
|
|
||||||
|
Minimal fix:
|
||||||
|
|
||||||
|
- Replace square-root comparisons with squared comparisons where mathematically equivalent.
|
||||||
|
- Canonicalize only the remaining square roots that affect topology, integer conversion or threshold decisions.
|
||||||
|
- Verify emitted Win64 instructions and run golden vectors before attempting to replace every SDF square root. I am not claiming a demonstrated cross-CPU `sqrt` divergence here.
|
||||||
|
|
||||||
|
No direct runtime uses of `FMath::Pow`, `Exp`, `Log`, `Atan`, `Atan2` or `Tan` were found in the plugin generation sources. That does not rule out transitive engine/library use, but there are no direct call sites to fix.
|
||||||
|
|
||||||
|
## 4.3 High: runtime CPU state and dispatch
|
||||||
|
|
||||||
|
### Confirmed dispatch
|
||||||
|
|
||||||
|
- UCRT FMA3 `sin/cos`: critical, as above.
|
||||||
|
|
||||||
|
### Plugin SIMD
|
||||||
|
|
||||||
|
`VoxelNoise` selects scalar versus SSE at compile time through `VF_NOISE_USE_SIMD` at [VoxelNoise.h:35](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelNoise.h:35>). The hot implementation uses explicit SSE4.1 at [VoxelNoise.h:248](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelNoise.h:248>).
|
||||||
|
|
||||||
|
I found no plugin runtime CPUID dispatch and no ISPC path. The same Win64 build therefore takes the same plugin noise implementation on all supported x64 CPUs.
|
||||||
|
|
||||||
|
The “scalar and SIMD are bit-identical” claim at [VoxelNoise.h:20](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelNoise.h:20>) should still have a pinned exhaustive/random corpus. It is a claim worth continuously testing, not merely documenting.
|
||||||
|
|
||||||
|
### MXCSR rounding mode and FTZ/DAZ
|
||||||
|
|
||||||
|
UE’s integer conversions inherit SSE implementations such as `FloorToInt32` and `RoundToInt32` at [UnrealPlatformMathSSE.h:86](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/Math/UnrealPlatformMathSSE.h:86>). Several use rounding-mode-sensitive conversion instructions and assume the normal environment.
|
||||||
|
|
||||||
|
Another library changing a worker thread’s MXCSR rounding mode could change world-to-cell decisions. FTZ/DAZ can likewise alter very small intermediates.
|
||||||
|
|
||||||
|
Minimal fix:
|
||||||
|
|
||||||
|
- At generation-task entry, assert or install a canonical MXCSR configuration and restore the previous state at exit.
|
||||||
|
- Prefer explicit rounding instructions or checked conversion helpers for canonical boundaries.
|
||||||
|
- Test all supported task threads, not only the game thread.
|
||||||
|
|
||||||
|
## 4.4 Critical/high: float-to-integer boundaries
|
||||||
|
|
||||||
|
### Noise lattice conversion
|
||||||
|
|
||||||
|
[VoxelNoise.h:96](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelNoise.h:96>) floors a float and casts it directly to `int32`. The SIMD version uses `_mm_cvttps_epi32` at [VoxelNoise.h:255](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelNoise.h:255>).
|
||||||
|
|
||||||
|
For NaN or out-of-range values, scalar C++ conversion and SSE conversion do not provide a suitable canonical world rule.
|
||||||
|
|
||||||
|
**Severity: high**, especially with `WorldRadiusVoxels == 0` permitting an unbounded world.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Define a supported integer-coordinate envelope.
|
||||||
|
- Reject or clamp malformed/out-of-range queries before noise evaluation.
|
||||||
|
- Use one checked floor/wrap policy shared by scalar and SIMD paths.
|
||||||
|
- Test the boundary values and their immediate float neighbors.
|
||||||
|
|
||||||
|
### Chunk routing
|
||||||
|
|
||||||
|
`GetDensityAt` converts public float coordinates to chunk coordinates without a finite/range guard at [VoxelGenerator.cpp:2836](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2836>).
|
||||||
|
|
||||||
|
Internal mesher calls are bounded integer coordinates, but other callers need not be.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Give the mesher an integer-lattice entry point.
|
||||||
|
- Keep the public float query checked.
|
||||||
|
- Decide explicitly what happens outside representable chunk space.
|
||||||
|
|
||||||
|
The edit layer already demonstrates the correct pattern at [VoxelDiffLayer.cpp:220](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDiffLayer.cpp:220>).
|
||||||
|
|
||||||
|
### Procedural cell selection
|
||||||
|
|
||||||
|
`VoxelCaveMorphology.cpp` contains many `FloorToInt` cell selections, including the placement/cache families around `3902–3903`, `3954–3956`, `4038–4039`, `4180–4181`, `4554–4555`, `4683–4684`, `4881–4882`, `5085–5086`, `5195–5196` and `6055–6058`.
|
||||||
|
|
||||||
|
These are safe only while inputs remain finite and representable. They should consume the same declared coordinate envelope.
|
||||||
|
|
||||||
|
### Conservative skip boundaries
|
||||||
|
|
||||||
|
Passage and box proofs use float-to-int lattice conversions, for example around `VoxelStrateManager.cpp:3333–3334`. These are lower severity if they are genuinely one-sided: disagreement must cause a missed skip, never omitted geometry.
|
||||||
|
|
||||||
|
The better formulation is integer/rational lattice `ceil`/`floor` from validated bounds, with explicit outward rounding.
|
||||||
|
|
||||||
|
### Mesh vertex identity
|
||||||
|
|
||||||
|
The mesher deduplicates a vertex by rounding its interpolated world position multiplied by 100 at [VoxelMarchingCubesMesher.cpp:154](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:154>).
|
||||||
|
|
||||||
|
A small numeric difference crossing a half-centimetre rounding boundary can change vertex sharing and triangle/index output even if the surface samples remain on the same side.
|
||||||
|
|
||||||
|
**Severity: medium-high.**
|
||||||
|
|
||||||
|
Minimal fix: identify marching-cubes vertices canonically by integer cell coordinate plus edge axis, not rounded interpolated position. That is both cheaper and independent of interpolation rounding.
|
||||||
|
|
||||||
|
## 4.5 High: worker-side global races
|
||||||
|
|
||||||
|
The following “parse once” flags and their backing values are ordinary globals accessed from worker generation:
|
||||||
|
|
||||||
|
- Generator flags at [VoxelGenerator.cpp:47](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:47>), `73`, `98`, `124`, `143`, `202`.
|
||||||
|
- The parsers are invoked inside every `GetDensityAt` at [VoxelGenerator.cpp:2820](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2820>).
|
||||||
|
- Mesher flags at [VoxelMarchingCubesMesher.cpp:20](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:20>) and `42`, invoked at `97–98`.
|
||||||
|
|
||||||
|
Multiple first-time worker calls can read/write the booleans concurrently. `FAutoConsoleVariableRef` can also mutate backing integers while workers read them. That is C++ data-race undefined behavior.
|
||||||
|
|
||||||
|
Minimal fix:
|
||||||
|
|
||||||
|
- Parse command-line state once on the game/module initialization thread before generation begins.
|
||||||
|
- Construct an immutable generation-policy snapshot.
|
||||||
|
- Copy that snapshot into each task or generation epoch.
|
||||||
|
- A live CVar change that affects the field must create a new epoch and rebuild; it must not alter active workers in place.
|
||||||
|
- Diagnostics that are intentionally live should use atomics and must not influence the canonical field.
|
||||||
|
|
||||||
|
## 4.6 Critical for multiplayer edits: order-dependent accumulation
|
||||||
|
|
||||||
|
Edits are appended to per-chunk arrays at [VoxelDiffLayer.cpp:253](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDiffLayer.cpp:253>) and accumulated serially in insertion order at [VoxelDiffLayer.cpp:374](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDiffLayer.cpp:374>).
|
||||||
|
|
||||||
|
Floating addition is order-dependent. If clients apply replicated edits in packet-arrival order, their density fields can differ.
|
||||||
|
|
||||||
|
Minimal fix:
|
||||||
|
|
||||||
|
- Give every accepted edit a monotonic authoritative server sequence.
|
||||||
|
- Buffer out-of-order edits and evaluate them in that sequence.
|
||||||
|
- Include sequence ranges in edit/density cache revisions.
|
||||||
|
- During compaction, use a canonical order or quantized/fixed-point delta representation.
|
||||||
|
- Never parallel-reduce floating edit contributions without a deterministic reduction tree.
|
||||||
|
|
||||||
|
Current local serial evaluation is stable if every machine receives the exact same order. The network contract must guarantee that.
|
||||||
|
|
||||||
|
## 4.7 Medium: raw-memory fingerprints and padding
|
||||||
|
|
||||||
|
The code hashes raw parameter structs using `FCrc::MemCrc32` at:
|
||||||
|
|
||||||
|
- [VoxelGenerator.cpp:3170](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:3170>) and `3842`.
|
||||||
|
- [VoxelDensityOpStack.cpp:1116](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:1116>), `1119` and `3693`.
|
||||||
|
|
||||||
|
The accompanying comments say a raw-memory CRC cannot produce a false positive. That is incorrect:
|
||||||
|
|
||||||
|
- Padding may be indeterminate, yielding nondeterministic false misses.
|
||||||
|
- CRC32 can collide, yielding a false hit.
|
||||||
|
- If the fingerprint is the only cache identity, a false hit can reuse terrain computed from different parameters.
|
||||||
|
|
||||||
|
Minimal fix:
|
||||||
|
|
||||||
|
- Hash fields individually in declared canonical order.
|
||||||
|
- Normalize bools, floats and zero.
|
||||||
|
- Prefer 64- or 128-bit hashes.
|
||||||
|
- On a hash match, compare the complete canonical key before reusing semantic data.
|
||||||
|
|
||||||
|
This is probably a low-frequency failure, but an absolute determinism/correctness contract should not use “CRC collisions cannot happen” as an invariant.
|
||||||
|
|
||||||
|
## 4.8 Stateful randomness and ordering
|
||||||
|
|
||||||
|
`FRandomStream` layout shuffle is serial and deterministic for the current sorted pool, so it is not presently a cross-thread race.
|
||||||
|
|
||||||
|
Its weakness is evolutionary stability: inserting a pool member changes subsequent random consumption. Since older-build identity is not required, this is not urgent. Stable per-slot keyed selection becomes useful if saved plans, live content packs or replicated compact layouts must survive content additions predictably.
|
||||||
|
|
||||||
|
Current spatial candidate iteration is serial and ordered. I found no current parallel reduction of SDFs or edit values. A feature-major rewrite must preserve per-sample feature order or deliberately become a field-changing new canonical evaluator.
|
||||||
|
|
||||||
|
## 4.9 Uninitialized data and timing
|
||||||
|
|
||||||
|
I inspected the important `SetNumUninitialized` sites and found no confirmed uninitialized read:
|
||||||
|
|
||||||
|
- Spatial-index bound arrays are filled for every valid item before use at [VoxelCaveMorphology.cpp:1343](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:1343>).
|
||||||
|
- Index storage is completely filled from prefix offsets at `1443–1455`.
|
||||||
|
- The mesher samples only the Z band subsequently read; this is documented at [VoxelMarchingCubesMesher.cpp:680](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:680>).
|
||||||
|
|
||||||
|
The raw struct padding used by `MemCrc32` is the confirmed uninitialized-byte concern.
|
||||||
|
|
||||||
|
I found no density value chosen by wall-clock timing. Task timing controls which tiles become ready first, but epoch, cancellation and collision gates prevent obsolete work from becoming canonical terrain. Cache identity and edit sequencing are the places where timing could become semantic if keys/order are incomplete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. What I would not graft onto this code
|
||||||
|
|
||||||
|
- **No wholesale persistent sparse base-density brick store.** It would duplicate a pure procedural evaluator, add large revision/save/memory machinery and invalidate the current direct-LOD clipmap advantage. Transient tile buffers and edited-brick deltas capture most of the value.
|
||||||
|
|
||||||
|
- **No coarse LOD derived from mandatory fine voxels.** It would make far terrain require fine generation and undermine the constant-cell-count clipmap at [ARCHITECTURE.md:708](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:708>).
|
||||||
|
|
||||||
|
- **No min/max octree classifier as another always-on front end.** The outer classifier and exact per-sample cull already demonstrated that extra proof work can lose. Bounds should cheaply clip batch spans, not become a parallel evaluator.
|
||||||
|
|
||||||
|
- **No warped-SDF/world-core merge.** The rejected capability loss demonstrates that they express different geometry. Batch them separately.
|
||||||
|
|
||||||
|
- **No value-noise worm substitution or worm-focused rewrite.** Worm removal is only about 3%; that budget cannot support architectural work.
|
||||||
|
|
||||||
|
- **No general-purpose interpreted CSG evaluator.** The project has already paid to lower the canonical stack. General composition belongs in the authoring IR, not the sample loop.
|
||||||
|
|
||||||
|
- **No shell/B-rep base terrain or local shell-to-voxel collars.** Arbitrary noise, junctions, mixed archetypes and mining make the watertight seam problem much larger than the likely benefit.
|
||||||
|
|
||||||
|
- **No dual-contouring/transvoxel or mesh-extractor rewrite without an extractor-specific profile.** Density evaluation is the measured bottleneck. Changing topology would risk capability and seams while attacking an unmeasured target.
|
||||||
|
|
||||||
|
- **No GPU-authoritative field or meshing.** Cross-vendor bit identity remains unproved. GPU rendering/culling can be non-authoritative later.
|
||||||
|
|
||||||
|
- **No global concurrent hash map for individual density samples.** Per-sample lookup/locking could repeat the always-on-cull failure. Reuse should operate on rectangular slabs or immutable tile buffers.
|
||||||
|
|
||||||
|
- **No actor/UObject per brick.** Current plain worker data and game-thread publication are the right split.
|
||||||
|
|
||||||
|
- **No second WorldPlan/composer hierarchy.** `FStrateLayout`, recipes, cached topology and the operator stack already constitute one. Add stable IDs and contracts to those structures.
|
||||||
|
|
||||||
|
- **No conservative collision proxy unless gate stalls remain a real UX problem.** The current cook-complete admission gate already supplies correctness. A second proxy system needs a user-visible problem and measurements.
|
||||||
|
|
||||||
|
- **No claim that generated traversal remains guaranteed after arbitrary edits.** If that becomes a requirement, it needs an explicit server product rule: protect, reject or repair the final route.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bottom line
|
||||||
|
|
||||||
|
The owner’s “prove it worthy” stance is appropriate. The code has reached the point where another clever per-sample condition is more likely to lose than win.
|
||||||
|
|
||||||
|
I would authorize three preliminary efforts:
|
||||||
|
|
||||||
|
1. Instrument feature-major work potential using the existing candidate/evaluated counters.
|
||||||
|
2. Trace exact duplicate density coordinates and ready-before-use donors across gameplay tiles.
|
||||||
|
3. Close the numeric ABI hole, beginning with a FMA3-on/off and cross-platform golden corpus.
|
||||||
|
|
||||||
|
Only the first two are performance bets. Neither should become a production rewrite unless it clears its early kill criteria. The numeric work is different: the code currently documents, rather than satisfies, the hard field-determinism requirement.
|
||||||
|
|
||||||
|
# Files read
|
||||||
|
|
||||||
|
No files were changed. I did not build, run tests or launch Unreal.
|
||||||
|
|
||||||
|
Targeted sections read:
|
||||||
|
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\CLAUDE.md`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\CODEMAP.md`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\ARCHITECTURE.md`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\WORK-NEXT.md`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\PERF-SAMPLED.md`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\DESIGN-SOL-2026-09-15.md`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\AUDIT-SOL-2026-09-11.md`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\AUDIT-COLD-2026-09-14.md`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\fable-idea.md`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\VoxelForge.Build.cs`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelCaveMorphology.cpp`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDensityOpStack.cpp`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelStrateManager.cpp`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelMarchingCubesMesher.cpp`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDiffLayer.cpp`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelNoise.h`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelDensityProfile.h`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelMarchingCubesMesher.h`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\Tests\VoxelForgeCrossPlatformTest.cpp`
|
||||||
|
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForgeEditor\Private\VoxelForgeExploreCommandlet.cpp`
|
||||||
|
|
||||||
|
Files inspected through targeted symbol searches:
|
||||||
|
|
||||||
|
- `VoxelContentManager.cpp`
|
||||||
|
- `VoxelHeightOpStack.cpp`
|
||||||
|
- `VoxelStrateComposer.cpp`
|
||||||
|
- `VoxelStrateMeasure.cpp`
|
||||||
|
- `VoxelCaveMorphology.h`
|
||||||
|
- `VoxelDensityPrimitives.h`
|
||||||
|
- `VoxelHeightOp.h`
|
||||||
|
- `VoxelPassageGeometry.h`
|
||||||
|
- `VoxelStrateTypes.h`
|
||||||
|
- `VoxelForgeComposerStructureRollTest.cpp`
|
||||||
|
- `VoxelForgePassageOpenSpaceTest.cpp`
|
||||||
|
- `VoxelForgeScaleDiagnosisTest.cpp`
|
||||||
|
- `VoxelForgeStrateConnectivityTest.cpp`
|
||||||
|
- `VoxelForgeWormBlockSkipTest.cpp`
|
||||||
|
|
||||||
|
UE 5.7 engine source read:
|
||||||
|
|
||||||
|
- `GenericPlatformMath.h`
|
||||||
|
- `UnrealMathUtility.h`
|
||||||
|
- `MicrosoftPlatformMath.h`
|
||||||
|
- `WindowsPlatformMath.h`
|
||||||
|
- `UnrealPlatformMathSSE.h`
|
||||||
|
- `UnrealPlatformMathSSE4.h`
|
||||||
|
|
||||||
|
I also inspected git metadata/diffs for `1d824aa`, `a668833` and `410788f`.
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
# DESIGN-SOL-2026-09-15: "build it every better way", asked cold
|
||||||
|
|
||||||
|
On the owner's idea (see memory `reframe-when-returns-diminish`): after the exact-skip perf push
|
||||||
|
reached diminishing returns, we asked Sol (`gpt-5.6-sol`, reasoning high) a first-principles
|
||||||
|
design question. It was run from an empty folder, read-only, told not to read files, and it read
|
||||||
|
none (0 commands). It got the game's goals and constraints, never the code. The prompt and the
|
||||||
|
answer are verbatim below.
|
||||||
|
|
||||||
|
## Orchestrator notes (checked against the code, 2026-09-15)
|
||||||
|
- **Determinism risk confirmed in our code.** Sol warns against platform transcendentals on the
|
||||||
|
path that decides the world. VoxelForge has 34 `FMath::Sin/Cos` calls in generation code:
|
||||||
|
per-sample strata line/rib detail (`VoxelGenerator.cpp:4708, 4738, 5723`;
|
||||||
|
`VoxelDensityOpStack.cpp:7507, 7609`) and shape building (passage spirals/cascades
|
||||||
|
`VoxelStrateManager.cpp:1830-2142`, option B winding waves `VoxelCaveMorphology.cpp:6826-6843`,
|
||||||
|
tunnel pinches `7487-7521`). The MSVC C runtime switches its math functions to FMA3
|
||||||
|
implementations at runtime on CPUs that support it (`_set_FMA3_enable`), so bit-identical results
|
||||||
|
across machines are not guaranteed. Found by reading, not measured, and not changed yet.
|
||||||
|
- **The largest gap is the loop direction.** Every sample searches a spatial index for nearby
|
||||||
|
primitives. Sol's recommended architecture rasterises each feature into the bricks it touches.
|
||||||
|
Coarse LOD from finer data and shared halo samples are also absent.
|
||||||
|
- Already shared with Sol's design: built-first traversal (walk forest, D-floors, option B),
|
||||||
|
hash-keyed randomness, the collision gate, and partial narrow-band skipping (reach proofs, the
|
||||||
|
sealed-rock proof).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The prompt, as sent
|
||||||
|
|
||||||
|
# If you had to build this from scratch, in every better way: how would you do it?
|
||||||
|
|
||||||
|
You are being asked for **architecture**, not a code review. There is no code here on purpose: we want
|
||||||
|
your own design, not a critique of ours. Think freely, then be concrete.
|
||||||
|
|
||||||
|
⛔ **Do not read, search or open any files on this machine, and do not run commands.** Answer purely
|
||||||
|
from the description below and your own knowledge. Anchoring on an existing implementation is
|
||||||
|
exactly what this question is trying to avoid. Your final message is the whole deliverable.
|
||||||
|
|
||||||
|
## The game
|
||||||
|
An underground, multiplayer mining and exploration game in Unreal Engine 5.7 (C++). The world is a
|
||||||
|
vertical stack of **strates**: thick horizontal layers, each a distinct underground world (tunnel
|
||||||
|
networks, big chambers, mazes, shafts, floating islands, flooded areas, an open "surface" layer lit
|
||||||
|
from within). Strates are sealed from each other by solid rock, except through a few authored
|
||||||
|
**passages** that the player walks through to go deeper. Players spawn at the world origin and
|
||||||
|
travel down. They **mine and carve** the terrain freely.
|
||||||
|
|
||||||
|
## Hard constraints: any design must satisfy these
|
||||||
|
1. **Deterministic across machines.** Every client generates the world locally from a seed. Only the
|
||||||
|
seed, the strate layout and players' terrain edits are replicated, never geometry. The same build
|
||||||
|
with the same seed must produce the exact same world on every machine (x64 CPUs; a GPU path would
|
||||||
|
have to prove cross-vendor/driver bit-exactness).
|
||||||
|
2. **Composable generation.** Worlds are not hand-authored. A "composer" invents strates from tagged,
|
||||||
|
authored building blocks (room shapes, tunnel styles, noise detail, terrain features) and a seed.
|
||||||
|
The generator must stay a composition of operators that can be recombined, and archetypes can mix
|
||||||
|
inside one strate.
|
||||||
|
3. **Traversal guarantees.** From the point where a player arrives in a strate to where they leave,
|
||||||
|
there must always be a walkable route (player capsule ~1.8 m tall, ~0.34 m radius, 45 cm step,
|
||||||
|
~44 degree max walkable slope). Tunnels have walkable D-shaped floors, wind when a direct route
|
||||||
|
would be too steep, and may have dramatic climbable drops only where a walkable alternative
|
||||||
|
exists. Rooms cut cleanly into tunnels.
|
||||||
|
4. **Terrain is volumetric and editable.** Caves, overhangs and arbitrary mining, so not a
|
||||||
|
heightfield. Edits must re-mesh quickly and deterministically.
|
||||||
|
5. **Streaming must keep up with a running player** (~8 m/s, faster when falling), with collision
|
||||||
|
ready under the player before they can stand there. Far terrain is visible at lower detail.
|
||||||
|
6. Resolution: ~25 cm voxels near the player. Target PCs: 8-core/16-thread desktop CPUs and a decent
|
||||||
|
GPU.
|
||||||
|
|
||||||
|
## What we would like from you
|
||||||
|
1. **Two or three genuinely different architectures** for generation, representation, LOD,
|
||||||
|
meshing, streaming and editing that satisfy ALL the constraints. For each: how the terrain is
|
||||||
|
represented and evaluated, where the work happens, how LODs are produced, how edits work, how
|
||||||
|
determinism is guaranteed, the expected performance character, and the biggest risks.
|
||||||
|
2. **Where the real performance wins are**, compared with the obvious design ("for every voxel
|
||||||
|
sample, evaluate every nearby shape and noise"). Be specific: algorithms and data structures.
|
||||||
|
3. **What you would deliberately NOT build**, and why.
|
||||||
|
4. **If you could only change three things** in a working implementation of the obvious design,
|
||||||
|
what would they be?
|
||||||
|
|
||||||
|
Be concrete. "Use the GPU" or "use ECS" is not an answer unless it addresses constraint 1 and the
|
||||||
|
composition requirement. Say plainly where you are unsure.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sol's answer, verbatim
|
||||||
|
|
||||||
|
I would make the deterministic artifact a compact “world plan,” then compile that plan locally into terrain. The plan describes topology, portals, feature instances, archetype regions, and generation-version hashes—not voxels or meshes.
|
||||||
|
|
||||||
|
My preferred implementation would be a feature-rasterized sparse brick volume. It retains operator composition, but executes operators feature-by-feature into the bricks they touch instead of asking every voxel about every feature. That is usually the best balance of predictable performance, editability, and determinism.
|
||||||
|
|
||||||
|
Two constraints need precise interpretation:
|
||||||
|
|
||||||
|
- If traversal must remain guaranteed after arbitrary player edits, “mine freely” and “always walkable” conflict. Players can mine away every possible floor. You must either protect a route, reject edits that destroy the last route, or automatically create a replacement.
|
||||||
|
- Collision readiness cannot be guaranteed under arbitrary stalls or unbounded falling using prefetch alone. A hard guarantee requires bounded terminal velocity plus admission control, an immediate conservative collision proxy, or both.
|
||||||
|
|
||||||
|
Everything below assumes generated terrain must guarantee traversal. For post-edit traversal, I would use a server-enforced “last-route covenant”: ordinary mining is unrestricted, but an edit that would destroy the final walkable route is rejected or replaced with an authored support/bridge.
|
||||||
|
|
||||||
|
## Common foundation
|
||||||
|
|
||||||
|
Before discussing terrain representations, all three architectures should share the same deterministic world-planning layer.
|
||||||
|
|
||||||
|
### 1. The composer produces a WorldPlan
|
||||||
|
|
||||||
|
For each strate, the composer creates:
|
||||||
|
|
||||||
|
- Bounds and interface rock thickness.
|
||||||
|
- Entry and exit passage transforms.
|
||||||
|
- A mandatory traversal graph.
|
||||||
|
- Optional chambers, side routes, drops, shafts, islands, flooded regions, and rewards.
|
||||||
|
- Spatial archetype regions and their tags.
|
||||||
|
- Feature instances and their stable IDs.
|
||||||
|
- Material and detail rules.
|
||||||
|
- A generation-format version and content-pack hash.
|
||||||
|
|
||||||
|
The replicated “strate layout” could be the full WorldPlan or only the high-level choices needed to reconstruct it. I favor replicating the compact plan: it prevents small composer changes from silently changing an existing saved world while still avoiding geometry replication.
|
||||||
|
|
||||||
|
Adjacent strates share authored portal contracts. The interface is solid by default, with only explicitly reserved passage volumes allowed to cross it. Cave noise is bounded and cannot affect the interface exclusion zone.
|
||||||
|
|
||||||
|
### 2. Traversal is constructed, not discovered afterward
|
||||||
|
|
||||||
|
Generate the mandatory route before decorative terrain:
|
||||||
|
|
||||||
|
1. Place entry, exit, and required intermediate anchors.
|
||||||
|
2. Route a centerline through a coarse 3D planning grid.
|
||||||
|
3. Include elevation, heading, and accumulated grade in the search state.
|
||||||
|
4. When a direct segment exceeds the design grade, generate switchbacks or a spiral.
|
||||||
|
5. Sweep a guaranteed-clear tunnel profile along that centerline.
|
||||||
|
6. Add rooms and optional routes.
|
||||||
|
7. Apply destructive detail and noise only outside the protected clearance envelope.
|
||||||
|
8. Run an independent capsule validator as an assertion.
|
||||||
|
|
||||||
|
I would design the mandatory route below the mechanical limit—perhaps 35–38 degrees rather than 44—to leave room for meshing, collision quantization, and network movement tolerances.
|
||||||
|
|
||||||
|
The protected route volume is the capsule’s configuration space: at least the capsule radius plus margin laterally, enough headroom vertically, and a conservative floor envelope. It is stronger than merely checking whether an air voxel exists.
|
||||||
|
|
||||||
|
D-shaped tunnels should come from a swept profile with:
|
||||||
|
|
||||||
|
- A controlled floor curve.
|
||||||
|
- An arched roof.
|
||||||
|
- Fixed-point parallel-transport frames along the centerline.
|
||||||
|
- Junction transition pieces where tunnels meet rooms.
|
||||||
|
- A “no detail displacement” band around the walking surface.
|
||||||
|
|
||||||
|
A dramatic drop is added only as a graph chord or optional branch. It never replaces a mandatory walkable graph edge. Graph bridges and articulation points make that easy to verify.
|
||||||
|
|
||||||
|
The final validator should perform capsule sweeps or search through capsule-eroded free space at collision resolution. If it fails, retry only the optional layout using a deterministic attempt number; do not rely on an unconstrained random retry loop.
|
||||||
|
|
||||||
|
### 3. Determinism is an explicit numeric ABI
|
||||||
|
|
||||||
|
I would not trust “same C++ code on x64” by itself.
|
||||||
|
|
||||||
|
The generation contract would require:
|
||||||
|
|
||||||
|
- Integer or fixed-point world coordinates, preferably `int64`.
|
||||||
|
- Fixed-point density and interpolation with specified rounding and saturation.
|
||||||
|
- Integer lattice noise with fixed lookup tables—no platform `sin`, `pow`, or floating-point noise libraries.
|
||||||
|
- Counter-based random values keyed by tuples such as
|
||||||
|
`(world seed, strate ID, feature ID, decision kind, attempt)`.
|
||||||
|
- No shared sequential PRNG streams.
|
||||||
|
- Canonical ordering by stable ID whenever operations are noncommutative.
|
||||||
|
- No dependence on hash-map iteration order, thread scheduling, pointer values, or allocation order.
|
||||||
|
- Canonical triangulation, vertex ordering, ambiguity resolution, and material selection.
|
||||||
|
- Explicit overflow behavior and wide intermediate arithmetic.
|
||||||
|
- A generation-version/content hash checked during multiplayer connection and save loading.
|
||||||
|
|
||||||
|
Floating point can be used after canonical geometry exists—for rendering transforms, shading, and other non-authoritative work. It should not determine topology, occupancy, route selection, or edit results.
|
||||||
|
|
||||||
|
GPU work is limited to rendering, culling, upload, and possibly non-authoritative visual effects. I would not make GPU field evaluation or meshing canonical unless an integer-only implementation had been proven bit-exact across vendors and drivers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Architecture A: Lazy compiled implicit field
|
||||||
|
|
||||||
|
This is the most flexible architecture for arbitrary procedural shapes.
|
||||||
|
|
||||||
|
## Representation and evaluation
|
||||||
|
|
||||||
|
The WorldPlan is compiled into an immutable field program. Operators include:
|
||||||
|
|
||||||
|
- Room primitives.
|
||||||
|
- Swept tunnel primitives.
|
||||||
|
- Terrain unions and subtractions.
|
||||||
|
- Bounded displacement/noise.
|
||||||
|
- Material masks.
|
||||||
|
- Authored feature fields.
|
||||||
|
- Player edit operators.
|
||||||
|
|
||||||
|
The field is a deterministic integer scalar field, not necessarily a mathematically exact signed-distance field. Only its sign, interpolation, bounds, and material semantics need to be defined precisely.
|
||||||
|
|
||||||
|
Every feature has:
|
||||||
|
|
||||||
|
- A conservative bounding box.
|
||||||
|
- A bound on field contribution.
|
||||||
|
- A stable ID and priority.
|
||||||
|
- A reference to a specialized evaluation kernel.
|
||||||
|
|
||||||
|
A spatial index maps macro-cells and bricks to relevant operators. When a chunk is requested, its operator set is gathered once, canonically sorted, simplified, and compiled into a chunk-local program.
|
||||||
|
|
||||||
|
Examples of compilation:
|
||||||
|
|
||||||
|
- Collapse unions of compatible room primitives into one loop.
|
||||||
|
- Separate terrain topology from material evaluation.
|
||||||
|
- Group identical tunnel kernels.
|
||||||
|
- Remove operators whose bounds cannot influence the chunk.
|
||||||
|
- Create archetype masks so that a noise style is evaluated only in its own region.
|
||||||
|
- Propagate maximum noise amplitude for later pruning.
|
||||||
|
|
||||||
|
## LOD and meshing
|
||||||
|
|
||||||
|
Use world-aligned sparse bricks, for example:
|
||||||
|
|
||||||
|
- 4 m brick: `16³` cells at 25 cm.
|
||||||
|
- 8 m mesh page: `2³` bricks.
|
||||||
|
- 32–64 m macro-cell for planning and indexing.
|
||||||
|
|
||||||
|
Sample only requested bricks and their one-cell halos. Cache corner samples so neighboring chunks do not recompute them.
|
||||||
|
|
||||||
|
LOD is generated by evaluating the same field on world-aligned grids at spacings of 25, 50, 100 cm, and so on. An interval hierarchy records minimum and maximum possible density over coarse nodes:
|
||||||
|
|
||||||
|
- If the interval is entirely solid or air, no finer samples are needed.
|
||||||
|
- If it crosses zero, refine.
|
||||||
|
- Mandatory passages can be marked as topology-critical so coarse visual LOD does not accidentally seal them.
|
||||||
|
|
||||||
|
For meshing, I would use either:
|
||||||
|
|
||||||
|
- Fixed-point surface nets with deterministic vertex placement, or
|
||||||
|
- Deterministic marching cubes with an integer asymptotic decider.
|
||||||
|
|
||||||
|
Dual contouring can produce better sharp features, but its QEF solver must be fixed-point with completely specified pivoting and rounding. A conventional floating-point QEF is a determinism trap.
|
||||||
|
|
||||||
|
Neighboring LODs remain 2:1 balanced and use fixed transition-cell tables. Every page owns vertices according to a canonical boundary rule, avoiding duplicate and differently rounded seam vertices.
|
||||||
|
|
||||||
|
LOD0 supplies the canonical collision triangles. Far LOD is render-only.
|
||||||
|
|
||||||
|
## Edits
|
||||||
|
|
||||||
|
Edits are quantized CSG stamps:
|
||||||
|
|
||||||
|
- Shape and dimensions.
|
||||||
|
- Fixed-point transform.
|
||||||
|
- Operation: mine, add, replace material, and so forth.
|
||||||
|
- Server sequence number.
|
||||||
|
- Author and optional gameplay metadata.
|
||||||
|
|
||||||
|
The server assigns ordering for noncommutative operations. Pure subtraction can be treated as commutative, but I would still preserve a total order for debugging and save compatibility.
|
||||||
|
|
||||||
|
Edits are indexed spatially. A changed edit invalidates only intersected bricks plus the meshing halo. Long edit lists are deterministically folded into per-brick edit blocks or checkpoints; the checkpoint is still player-edit data, not base-world geometry.
|
||||||
|
|
||||||
|
## Performance character
|
||||||
|
|
||||||
|
Advantages:
|
||||||
|
|
||||||
|
- Excellent composability.
|
||||||
|
- Low persistent memory.
|
||||||
|
- Arbitrary procedural primitives remain natural.
|
||||||
|
- Any LOD can be evaluated directly.
|
||||||
|
- Untouched, distant regions never need materialization.
|
||||||
|
|
||||||
|
Costs:
|
||||||
|
|
||||||
|
- Worst-case chunks can still have expensive field evaluation.
|
||||||
|
- Domain warping and heavily overlapping operators weaken spatial bounds.
|
||||||
|
- Repeated edits require compaction or their query cost grows indefinitely.
|
||||||
|
- Fixed-point vector math and meshing require careful engineering.
|
||||||
|
|
||||||
|
The main risk is believing the spatial index alone solves performance. If every surface sample still evaluates 40 candidate features and six noise octaves, this architecture can miss streaming deadlines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Architecture B: Feature-rasterized sparse brick volume
|
||||||
|
|
||||||
|
This is the architecture I would most likely ship.
|
||||||
|
|
||||||
|
Instead of the field being the runtime truth, the WorldPlan is compiled into canonical sparse voxel bricks. Operators remain composable, but execute in bulk.
|
||||||
|
|
||||||
|
## Representation and generation
|
||||||
|
|
||||||
|
Each brick is one of:
|
||||||
|
|
||||||
|
- Uniform solid.
|
||||||
|
- Uniform air.
|
||||||
|
- Compressed material-only block.
|
||||||
|
- Dense or narrow-band fixed-point density samples.
|
||||||
|
- Edited dense block.
|
||||||
|
|
||||||
|
The generator processes features rather than samples:
|
||||||
|
|
||||||
|
1. Determine which bricks a tunnel, room, or feature intersects.
|
||||||
|
2. Rasterize that feature into those bricks using a specialized kernel.
|
||||||
|
3. Apply phases in a canonical order: structural voids, rooms, optional features, surface detail, materials, edits.
|
||||||
|
4. Never touch bricks outside the feature’s conservative bounds.
|
||||||
|
|
||||||
|
A swept tunnel rasterizer iterates its centerline segments and their affected scanlines. A room stamp writes spans or masks. Authored building blocks can contain canonical sampled volumes and be placed using resampling-free transforms such as 90-degree rotations and reflections. More general transforms use a precisely specified fixed-point resampler.
|
||||||
|
|
||||||
|
For boolean-only phases, contributions can be accumulated independently and reduced in any order. Noncommutative phases use sorted feature IDs and ping-pong channels.
|
||||||
|
|
||||||
|
Noise becomes a bulk brick kernel. Better still, it runs only on bricks already known to contain a possible surface. Bounded noise cannot turn a deeply solid or deeply empty brick into a surface brick.
|
||||||
|
|
||||||
|
A sparse radix tree or hashed macro-cell table owns bricks. The storage itself need not have deterministic iteration order, because all externally visible processing uses sorted integer coordinates.
|
||||||
|
|
||||||
|
## LOD and meshing
|
||||||
|
|
||||||
|
Every brick produces a small hierarchy containing:
|
||||||
|
|
||||||
|
- Minimum density.
|
||||||
|
- Maximum density.
|
||||||
|
- Material summary.
|
||||||
|
- Surface-presence mask.
|
||||||
|
- Optional conservative solid/air coverage.
|
||||||
|
|
||||||
|
Parent LOD bricks are derived using an exact integer reduction. A simple averaged density is insufficient because it can erase thin walls or close tunnels. Min/max information decides whether a coarse region is definitely homogeneous or must retain detail.
|
||||||
|
|
||||||
|
Mesh generation reads the brick and its halo, then uses the same deterministic extractor and transition cells as Architecture A.
|
||||||
|
|
||||||
|
A useful split is:
|
||||||
|
|
||||||
|
- 25 cm render and collision near players.
|
||||||
|
- Potentially coarser conservative collision away from traversal-critical regions.
|
||||||
|
- Far render pages assembled from brick hierarchy nodes.
|
||||||
|
|
||||||
|
## Edits
|
||||||
|
|
||||||
|
Edits operate directly on affected brick spans or samples. This makes mining latency extremely predictable:
|
||||||
|
|
||||||
|
- Copy-on-write the changed brick.
|
||||||
|
- Apply the quantized edit kernel.
|
||||||
|
- Recompute only its mip ancestry.
|
||||||
|
- Remesh it and its boundary neighbors.
|
||||||
|
- Recook only affected collision pages.
|
||||||
|
|
||||||
|
Keep the original generated brick reconstructible from the WorldPlan. A save stores the edit operations or a canonical edit-derived brick delta, never the generated base brick.
|
||||||
|
|
||||||
|
## Performance character
|
||||||
|
|
||||||
|
Advantages:
|
||||||
|
|
||||||
|
- Work is proportional to affected brick volume, not `samples × candidate operators`.
|
||||||
|
- Excellent cache locality and straightforward SIMD.
|
||||||
|
- Uniform regions are extremely cheap.
|
||||||
|
- Edits are fast and bounded.
|
||||||
|
- Collision and LOD data naturally derive from the same representation.
|
||||||
|
- Performance is more predictable than lazy per-sample evaluation.
|
||||||
|
|
||||||
|
Costs:
|
||||||
|
|
||||||
|
- Higher memory use.
|
||||||
|
- A requested area must be materialized before use.
|
||||||
|
- Poorly bounded features can still touch too many bricks.
|
||||||
|
- Large filled volumes are wasteful unless uniform-brick detection happens early.
|
||||||
|
- Authored sampled blocks can become repetitive if the composer does not provide enough transforms and procedural variation.
|
||||||
|
|
||||||
|
The main risk is turning it into a dense voxel world. The architecture works because homogeneous regions remain implicit and only surface/edit bricks become dense.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Architecture C: Deterministic shell-first base with local voxelization for edits
|
||||||
|
|
||||||
|
This is a genuinely different option and can be extremely fast when most terrain remains untouched. It is also the most difficult to engineer robustly.
|
||||||
|
|
||||||
|
## Representation and generation
|
||||||
|
|
||||||
|
The unedited world is represented as deterministic watertight boundary patches:
|
||||||
|
|
||||||
|
- Polygonal swept tunnels.
|
||||||
|
- Parametric room shells.
|
||||||
|
- Terrain surface patches.
|
||||||
|
- Portal and junction patches.
|
||||||
|
- Bounded displacement patches.
|
||||||
|
|
||||||
|
Solid rock is implicit outside the air shells. There is no full base density volume.
|
||||||
|
|
||||||
|
All topology decisions use fixed-point or exact rational predicates. Curves are canonical polygonal approximations generated from quantized tables. Intersections and room/tunnel cuts use a deliberately restricted exact boolean system—not a general floating-point mesh boolean library.
|
||||||
|
|
||||||
|
The WorldPlan spatially indexes these patches. Untouched chunks triangulate only the surface patches that intersect them.
|
||||||
|
|
||||||
|
## LOD and meshing
|
||||||
|
|
||||||
|
Base patches have deterministic subdivision hierarchies. Adjacent patches share boundary vertex schedules, so independently generated LODs meet exactly. Far LOD selects coarser patch subdivisions.
|
||||||
|
|
||||||
|
When a player edits terrain, the affected area switches representation:
|
||||||
|
|
||||||
|
1. Allocate sparse voxel bricks around the edit.
|
||||||
|
2. Classify the base shell into those bricks.
|
||||||
|
3. Apply the edit operations.
|
||||||
|
4. Mesh the local volume.
|
||||||
|
5. Use a collar region whose boundary samples match the untouched shell.
|
||||||
|
|
||||||
|
An edit completely inside solid rock begins from a uniform-solid brick and creates a new cavity. An edit touching a cave wall classifies the nearby base shell first.
|
||||||
|
|
||||||
|
Collision uses base shell triangles in untouched regions and voxel-derived triangles in edited regions.
|
||||||
|
|
||||||
|
## Determinism
|
||||||
|
|
||||||
|
The shell topology, clipping predicates, tessellation, and triangle order must all be integer/rational and canonical. Conventional runtime mesh booleans are not acceptable.
|
||||||
|
|
||||||
|
## Performance character
|
||||||
|
|
||||||
|
Advantages:
|
||||||
|
|
||||||
|
- Untouched terrain costs roughly proportional to visible surface area.
|
||||||
|
- No field evaluation across solid interiors.
|
||||||
|
- Very cheap far LOD.
|
||||||
|
- Works especially well for huge chambers and relatively localized mining.
|
||||||
|
- Traversal proxy geometry is available immediately from the route plan.
|
||||||
|
|
||||||
|
Costs and risks:
|
||||||
|
|
||||||
|
- Exact, watertight joins between arbitrary procedural shapes are hard.
|
||||||
|
- Noisy displacement and multi-way junctions are particularly troublesome.
|
||||||
|
- The boundary between shell terrain and voxelized edited terrain is complex.
|
||||||
|
- Large-scale mining gradually converts the world into Architecture B anyway.
|
||||||
|
- Tooling and debugging burden are substantially higher.
|
||||||
|
|
||||||
|
I would consider this only if profiling showed base-world generation dominating and player edits remained spatially sparse. I would not choose it as the first production architecture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Streaming and collision
|
||||||
|
|
||||||
|
The streaming scheduler should be shared across architectures and driven by deadlines, not simple distance.
|
||||||
|
|
||||||
|
Maintain separate interest sets:
|
||||||
|
|
||||||
|
- Immediate safety set: LOD0 collision required.
|
||||||
|
- Near visual set: LOD0 or LOD1 meshes.
|
||||||
|
- Predicted movement set: locations reachable within a time horizon.
|
||||||
|
- Far visual set.
|
||||||
|
- Edit-dirty set.
|
||||||
|
- Passage prefetch set for the next strate.
|
||||||
|
|
||||||
|
Priority is approximately “time until the player could touch this page,” accounting for:
|
||||||
|
|
||||||
|
- Current velocity and acceleration.
|
||||||
|
- Character braking.
|
||||||
|
- Network prediction uncertainty.
|
||||||
|
- Gravity and terminal fall velocity.
|
||||||
|
- Nearby shaft connectivity.
|
||||||
|
- Other players on the authoritative server.
|
||||||
|
|
||||||
|
A vertical fall requires a swept ballistic volume, not a horizontal streaming radius. Known shafts and authored passages should prefetch their entire reachable landing corridor as soon as a player approaches them.
|
||||||
|
|
||||||
|
The worker pipeline is:
|
||||||
|
|
||||||
|
`plan/index lookup → field sampling or brick rasterization → mesh → collision cook → game-thread publication`
|
||||||
|
|
||||||
|
Each stage has revision numbers and supports cancellation. Results are published only if their input revision is still current.
|
||||||
|
|
||||||
|
Collision receives the highest priority. For a hard guarantee, I would use both:
|
||||||
|
|
||||||
|
- Immediately available conservative collision proxies derived from the route plan.
|
||||||
|
- Movement admission: a character cannot cross into a page whose required collision revision is not active.
|
||||||
|
|
||||||
|
The latter should almost never trigger, but without it “collision will always be ready” is only a performance hope. Under severe overload, slowing a character at a streaming boundary is better than letting them fall through the world.
|
||||||
|
|
||||||
|
On Unreal’s side, generation data should remain plain C++ memory off-thread. UObject/component creation and scene publication happen on the game thread. I would use a custom terrain scene proxy and a dedicated collision provider rather than one heavy actor/component per tiny brick.
|
||||||
|
|
||||||
|
The server generates collision for all relevant players and remains authoritative. Clients generate the same static terrain for prediction and rendering. Physics simulation itself does not need cross-machine determinism as long as the server is authoritative, but the collision input triangles must still be canonical.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Where the real performance wins are
|
||||||
|
|
||||||
|
The dominant improvement is changing the work from:
|
||||||
|
|
||||||
|
`number of samples × number of nearby shapes × number of noise layers`
|
||||||
|
|
||||||
|
toward:
|
||||||
|
|
||||||
|
`features touching requested bricks + cells near actual surfaces + dirty edit cells`
|
||||||
|
|
||||||
|
Specifically:
|
||||||
|
|
||||||
|
## 1. Feature-to-brick compilation
|
||||||
|
|
||||||
|
Build a macro-cell index when the WorldPlan is created. A requested brick gets a small, already-filtered operator program.
|
||||||
|
|
||||||
|
Better still, Architecture B iterates each feature over its covered cell spans. A sphere or swept tunnel should rasterize scanlines; it should not be rediscovered independently by every voxel sample.
|
||||||
|
|
||||||
|
Use separate indices for immutable world features and mutable edits.
|
||||||
|
|
||||||
|
## 2. Interval bounds and narrow-band evaluation
|
||||||
|
|
||||||
|
Every primitive and noise stack should expose conservative bounds.
|
||||||
|
|
||||||
|
If the base field for a region is farther from zero than the maximum possible displacement, skip all detail noise. This is especially important for expensive domain warps.
|
||||||
|
|
||||||
|
For an octree node:
|
||||||
|
|
||||||
|
- Entirely solid: emit no samples.
|
||||||
|
- Entirely air: emit no samples.
|
||||||
|
- Possibly crosses the surface: subdivide or evaluate.
|
||||||
|
|
||||||
|
This often removes more work than SIMD optimization.
|
||||||
|
|
||||||
|
## 3. Stop evaluating every archetype everywhere
|
||||||
|
|
||||||
|
Give archetypes spatial masks determined at planning resolution. A crystal-cavern noise stack should not even enter the chunk program for a neighboring sedimentary tunnel.
|
||||||
|
|
||||||
|
At transitions, compile only the two or three contributing archetypes and a deterministic blend mask.
|
||||||
|
|
||||||
|
## 4. Cache lattice samples and halos
|
||||||
|
|
||||||
|
Adjacent cells and chunks share corners. Cache canonical world-grid samples by brick revision. Do not evaluate each corner up to eight times, and do not let two chunks compute a shared boundary using different local coordinate paths.
|
||||||
|
|
||||||
|
## 5. Use bulk data layouts
|
||||||
|
|
||||||
|
For dense bricks:
|
||||||
|
|
||||||
|
- Structure-of-arrays density and material channels.
|
||||||
|
- Fixed-size aligned slabs.
|
||||||
|
- SIMD integer kernels.
|
||||||
|
- Bitmasks for active surface cells.
|
||||||
|
- Scanline/span writes for primitives.
|
||||||
|
- Small thread-local scratch arenas.
|
||||||
|
- No allocation in inner loops.
|
||||||
|
|
||||||
|
Bitsets are particularly effective for “possibly contains surface,” material-presence, and dirty-cell masks.
|
||||||
|
|
||||||
|
## 6. Incremental edit dependency tracking
|
||||||
|
|
||||||
|
An edit should invalidate:
|
||||||
|
|
||||||
|
- Intersected bricks.
|
||||||
|
- One-cell mesh halos.
|
||||||
|
- Their LOD ancestors.
|
||||||
|
- Their collision pages.
|
||||||
|
|
||||||
|
Nothing else.
|
||||||
|
|
||||||
|
Give every brick separate generation, edit, mesh, and collision revisions. This prevents a late worker result from publishing obsolete terrain.
|
||||||
|
|
||||||
|
An ever-growing edit list is not viable. Compact edits into canonical per-brick deltas after a threshold while preserving the original network/save semantics.
|
||||||
|
|
||||||
|
## 7. Separate collision urgency from visual quality
|
||||||
|
|
||||||
|
Generate collision first. It can initially use a conservative, lower-complexity surface as long as it preserves walkability and does not allow passage into solid rock. Refine the render mesh afterward.
|
||||||
|
|
||||||
|
Do not block collision readiness on material extraction, tangents, decoration, meshlets, or high-quality far-LOD work.
|
||||||
|
|
||||||
|
## 8. Exploit underground visibility
|
||||||
|
|
||||||
|
In an underground world, Euclidean distance is a poor visibility predictor. Build coarse visibility cells from the chamber/tunnel graph. Far terrain behind several sealed turns can be deprioritized even if spatially close.
|
||||||
|
|
||||||
|
GPU occlusion and meshlet culling help rendering, but avoiding CPU generation of invisible far surfaces is the larger win.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# What I would deliberately not build
|
||||||
|
|
||||||
|
- A dense 3D array for an entire strate. Solid rock and empty chambers must remain sparse or implicit.
|
||||||
|
- A global sequential random stream. Adding one decoration would change the rest of the world.
|
||||||
|
- Floating-point generation decisions, floating mesh booleans, or platform math functions in the canonical path.
|
||||||
|
- GPU-authoritative generation or meshing without proven integer bit-exactness.
|
||||||
|
- A generic interpreted CSG graph evaluated from its root for every sample.
|
||||||
|
- Unlimited domain-warp noise. It destroys useful spatial and amplitude bounds.
|
||||||
|
- A general-purpose runtime B-rep boolean engine. Architecture C should support a narrow, controlled set of exact operations.
|
||||||
|
- Independent marching-cubes meshes at each LOD with skirts added later as a patch. LOD ownership and transition topology need to be designed from the start.
|
||||||
|
- Runtime full-strate flood fills as the primary traversal guarantee. Connectivity should be structural; validation is a backstop.
|
||||||
|
- A never-compacted replicated edit list.
|
||||||
|
- One Unreal actor or UObject per voxel brick.
|
||||||
|
- Dependence on dynamic Nanite behavior for core terrain correctness. It may be useful for rendering, but collision, edits, and determinism need an independent path.
|
||||||
|
- Streaming based only on concentric distance rings.
|
||||||
|
- A system where render mesh completion is the only thing preventing a player from falling through unloaded terrain.
|
||||||
|
- A claim that arbitrary post-edit traversal is guaranteed without restricting, rejecting, or repairing edits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# If I could change only three things
|
||||||
|
|
||||||
|
1. **Introduce a deterministic topology-first WorldPlan compiler.**
|
||||||
|
This establishes stable IDs, counter-based randomness, portal contracts, protected walkable routes, bounded feature instances, archetype masks, and a versioned numeric ABI. It fixes correctness and gives later systems much better information.
|
||||||
|
|
||||||
|
2. **Replace per-sample feature iteration with feature-to-brick compilation and sparse surface bricks.**
|
||||||
|
Build spatial indices, rasterize features in bulk where possible, apply noise only within bounded surface bands, cache shared samples, and maintain min/max LOD hierarchies. This is likely the largest CPU improvement.
|
||||||
|
|
||||||
|
3. **Replace distance-only chunk streaming with a revisioned, deadline-driven collision pipeline.**
|
||||||
|
Predict reachable motion including falls, prefetch passages and shafts, prioritize collision independently, invalidate only edit dependencies, and retain a conservative collision/admission fallback. This turns streaming from “usually fast enough” into an enforceable gameplay contract.
|
||||||
|
|
||||||
|
My first production choice would be Architecture B, while retaining Architecture A’s immutable operator language as the authoring and compilation input. In other words: compose with procedural operators, but execute them into sparse bricks feature-by-feature. It gives up a little theoretical elegance in exchange for much more controllable frame time, edit latency, collision readiness, and debugging.
|
||||||
|
|
||||||
|
> Round two, with the code: see `DESIGN-SOL-2026-09-15-CODE.md`. Sol corrected two of the notes above: per-sample lookup is ~5% exclusive (not the main cost), and coarse LOD from fine data is excluded by the clipmap design, not a gap.
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
# Game Design Document
|
||||||
|
|
||||||
|
**Status:** first consolidation, 2026-08-17. Distilled from `COMPOSER-NOTES.md`, which stays as the
|
||||||
|
working transcript (chronological, with wrong turns preserved on purpose). This document is the
|
||||||
|
opposite: **the decided state, without the journey.**
|
||||||
|
|
||||||
|
> **Where this file lives, and why it is the wrong place.** This is a design document for *the game*.
|
||||||
|
> Most of it is not about VoxelForge at all — the city, combat, the economy and seasons are all
|
||||||
|
> outside the plugin. It lives here only because the plugin folder is the only part of the project
|
||||||
|
> under version control. Move it to the project root once that becomes a repository.
|
||||||
|
|
||||||
|
**How to read this:** everything under **Decided** was settled by Jahni. Everything under **Open** has
|
||||||
|
not been. Where a design is mine rather than his, it says so. Nothing here is a proposal wearing a
|
||||||
|
decision's clothes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The game, in a paragraph
|
||||||
|
|
||||||
|
A fantasy exploration and combat RPG, online-first and party-based. The setting is medieval-ish and
|
||||||
|
progresses toward better and more modern infrastructure without ever leaving fantasy. Players descend
|
||||||
|
from a city on the surface into an endless underworld to find the resources that surface has none of,
|
||||||
|
and bring them back to build that city up. Side systems: housing and farming. The underworld is
|
||||||
|
generated, shared by everyone, and replaced every season. The city is hand-built, shared by everyone,
|
||||||
|
and permanent.
|
||||||
|
|
||||||
|
## 2. The premise
|
||||||
|
|
||||||
|
**Civilization emerged from the depths.** The first strate below the surface is artificial — someone
|
||||||
|
built it — and it holds crystals with humans inside them. The game opens on one of those humans being
|
||||||
|
un-crystallized.
|
||||||
|
|
||||||
|
The surface is a sky island, or a mountain so tall nobody can descend it. Either way it is small and it
|
||||||
|
has no resources. Everything a civilization needs has to come from below.
|
||||||
|
|
||||||
|
So descending is not conquest. **It is a return.** You are going back to where you came from, and
|
||||||
|
nobody remembers why you left.
|
||||||
|
|
||||||
|
## 3. The core loop
|
||||||
|
|
||||||
|
> The surface is limited, so you must descend. The depths supply resources. Resources build the
|
||||||
|
> settlement. The settlement lets you descend further.
|
||||||
|
|
||||||
|
Every system in this document hangs off that loop, and it closes. The sky island does quiet work in
|
||||||
|
it: it explains why there is no trade with anywhere else, why the depths are the only economy, and why
|
||||||
|
going down is the civilization's whole project rather than an adventurer's hobby.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Vocabulary
|
||||||
|
|
||||||
|
Two words got used for the same thing early on and caused real confusion. Pinned here.
|
||||||
|
|
||||||
|
**THE SURFACE / THE CITY** — a hand-authored, ordinary Unreal level. Not voxel terrain, not generated,
|
||||||
|
not part of VoxelForge. Communal and identical for every player. **It never resets.**
|
||||||
|
|
||||||
|
**THE DEPTHS** — everything VoxelForge generates: bounded, subterranean, a stack of strates below the
|
||||||
|
city's hole. Some strates only *mimic* being outdoors. **This is what is regenerated each season.**
|
||||||
|
|
||||||
|
**A SEASON** — one generation of the depths, roughly six to twelve months, plus the settlement tier the
|
||||||
|
community earns during it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The city
|
||||||
|
|
||||||
|
### Decided
|
||||||
|
|
||||||
|
The city is **communal** — the same place for everyone, not instanced — and **permanent**. The season
|
||||||
|
wipe never touches it.
|
||||||
|
|
||||||
|
It **grows across seasons**, one tier at a time: camp, village, town, city, advanced city. Players
|
||||||
|
gather resources in the depths and donate them to the mayor or equivalent; a global counter rises until
|
||||||
|
the next tier is reached. A season is the unit that advances it. The game opens at *camp*.
|
||||||
|
|
||||||
|
Because groups never meet in the depths, **the city is the entire social surface of the game.** Trade,
|
||||||
|
the retrieval guild, shared builds, and simply seeing another person all happen there.
|
||||||
|
|
||||||
|
### Design rules
|
||||||
|
|
||||||
|
**A tier grants access and convenience, never power.** Services, workshops, NPCs, a bank, fast travel,
|
||||||
|
new quests, better aesthetics — but not stronger starting gear or stat bonuses. Otherwise players
|
||||||
|
arriving in season eight begin meaningfully stronger than those in season one, the early strates
|
||||||
|
trivialize, and the difficulty curve erodes a little every season until the top of the world is
|
||||||
|
scenery. This is the same rule the depth economy already follows: **buy access and time, never
|
||||||
|
numbers.**
|
||||||
|
|
||||||
|
**The city should be a ledger.** Somebody arriving in season eight built none of it. That is either
|
||||||
|
alienating or the best first impression in the game, depending entirely on whether the city records
|
||||||
|
*who* built it — names on the aqueduct, a monument per tier listing that season's contributors. New
|
||||||
|
players then walk through evidence that real people made this place, and long-term players get
|
||||||
|
something permanent, which pairs with the titles that already survive wipes.
|
||||||
|
|
||||||
|
### Open
|
||||||
|
|
||||||
|
- Is the tier threshold a fixed number, or does it scale with the active population? A fixed number
|
||||||
|
stalls forever with a small playerbase and is cleared in a week with a large one. *(My
|
||||||
|
recommendation: target one tier per season and retune the threshold at each season boundary, which
|
||||||
|
is a moment that already exists for other reasons.)*
|
||||||
|
- Where housing sits relative to the city.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The depths
|
||||||
|
|
||||||
|
### Decided
|
||||||
|
|
||||||
|
Bounded, but **gigantic**. Not infinite — that choice was made deliberately, and it buys a great deal.
|
||||||
|
|
||||||
|
**No bottom.** The descent does not end.
|
||||||
|
|
||||||
|
**Boss strates roughly every five levels**, and they are **intermediary, not terminal** — gates that
|
||||||
|
punctuate the descent rather than finish it. A boss is where the transport network's next stop opens.
|
||||||
|
|
||||||
|
**Regenerated every season** from a single seed shared by every player.
|
||||||
|
|
||||||
|
Some strates are open, cathedral-sized spaces lit from within that read as outdoors. There is no sun
|
||||||
|
anywhere in the depths.
|
||||||
|
|
||||||
|
### What bounding buys
|
||||||
|
|
||||||
|
Because the depths are finite, the generator can examine an entire strate rather than sampling it. That
|
||||||
|
turns the entrance-and-exit guarantee into a **proof**, and it makes "the community has explored 34% of
|
||||||
|
this season" a real number rather than a slogan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. A strate
|
||||||
|
|
||||||
|
### Decided
|
||||||
|
|
||||||
|
**The primordial law: every strate has an entrance and an exit, exposed to air, and findable.** This is
|
||||||
|
the one invariant that may never fail.
|
||||||
|
|
||||||
|
**Danger is depth times a per-strate multiplier**, with a rare chance of an easier strate appearing
|
||||||
|
deep. Danger drives which creatures live there, which environmental hazards exist, and what loot drops.
|
||||||
|
It does **not** drive what *kind* of place a strate is.
|
||||||
|
|
||||||
|
**A strate's identity is unrelated to its depth.** Two seeds differ in everything — not the same
|
||||||
|
strates spawn, nor the same details. You could meet anything at any depth.
|
||||||
|
|
||||||
|
**Anomalous strates are rare.** The example Jahni gave is "dreamcore" — repetitive patterns, a strange
|
||||||
|
sense of emptiness. They should be rare enough that finding one is *puzzling*, because it does not have
|
||||||
|
its place there. They deliberately do **not** blend with their neighbours; the wrongness lives in the
|
||||||
|
seam.
|
||||||
|
|
||||||
|
**Size varies enormously** — from a crawlspace to a two-kilometre cathedral.
|
||||||
|
|
||||||
|
### Design rules
|
||||||
|
|
||||||
|
**Strangeness needs ordinary to be strange against.** If every strate is peculiar, peculiar becomes the
|
||||||
|
baseline and nothing reads as uncanny. A world should guarantee a share of grounded, plausible strates
|
||||||
|
so the strange ones land.
|
||||||
|
|
||||||
|
**A rare easy strate should be a designed type, not a low roll** — visibly calm, few creatures, a place
|
||||||
|
a descent exhales. The natural home for a safe camp or a merchant.
|
||||||
|
|
||||||
|
**The mirror of that is stronger: a rare danger spike near the surface.** A place at −300 that is
|
||||||
|
absolutely not for you yet. You find it early, it kills you, and it sits in your head for forty hours.
|
||||||
|
In a single shared world that becomes season-wide news.
|
||||||
|
|
||||||
|
**Every anomaly must be legible.** Unsignalled variance reads as bad tuning, not design. And danger in
|
||||||
|
particular must be **readable at a strate's entrance, before the player commits** — warned-and-ignored
|
||||||
|
is the best death in games; unwarned is the worst.
|
||||||
|
|
||||||
|
### Open
|
||||||
|
|
||||||
|
- Adjacency rules: what may sit next to what. Confirmed as wanted, never specified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Descending
|
||||||
|
|
||||||
|
### Decided
|
||||||
|
|
||||||
|
**A party of up to six.** The descent is done together.
|
||||||
|
|
||||||
|
**Instanced per group.** Different groups never meet in the depths. Today this runs on a local server —
|
||||||
|
one player's machine or a dedicated one — fed by an external database for community information.
|
||||||
|
|
||||||
|
**Two ways back up.** Walking back through the tunnels is content, not a chore. Alternatively, once a
|
||||||
|
strate has been visited it opens a passage onward, so transport — an elevator or similar — can carry
|
||||||
|
you down, and you choose which strate to travel to.
|
||||||
|
|
||||||
|
**Transport unlocks are per-player for now.** A world-level unlock, where the first group through opens
|
||||||
|
the way for everyone, is a possible future — gated on strates being large enough that finding the next
|
||||||
|
passage is not a ten-minute errand. That is a decision to make from data, not in advance.
|
||||||
|
|
||||||
|
### Open
|
||||||
|
|
||||||
|
- **How long is a run?** Still unknown, and it constrains a great deal: strate size, how dense the
|
||||||
|
transport stops need to be, how deep a season can plausibly reach.
|
||||||
|
- Does danger scale with party size, or are strates tuned for a nominal group?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Progression and the economy
|
||||||
|
|
||||||
|
### Decided
|
||||||
|
|
||||||
|
**Gear stats have a ceiling — somewhere around depth 30.** Loot found deeper has the *same* stats.
|
||||||
|
|
||||||
|
**What depth buys instead:** better natural rolls, so an item costs less material to level up; and
|
||||||
|
materials, enchantments and effects that exist nowhere else.
|
||||||
|
|
||||||
|
**Beyond the ceiling, going deeper is about pride, challenge, and seeing new things.** That is the
|
||||||
|
intent, not an accident.
|
||||||
|
|
||||||
|
**Depth-locked materials are tagged by difficulty tier, not by strate.** Their placement stays random,
|
||||||
|
so *where* a material is has to be rediscovered every season — which in a one-seed shared world turns
|
||||||
|
into a community question with a fresh answer each time. A fixed depth would be solved once and stay
|
||||||
|
solved forever.
|
||||||
|
|
||||||
|
**Trading will exist**, lightly at first, and becomes much better once the city is a real social hub.
|
||||||
|
|
||||||
|
### Design rules
|
||||||
|
|
||||||
|
**Buy access and time, never numbers.** Applied to both depth and city tier, which means the economy has
|
||||||
|
one principle rather than two competing ones.
|
||||||
|
|
||||||
|
**Guarantee what recipes depend on; leave the rest to chance.** A material a progression recipe needs
|
||||||
|
must be placed within reach, or a season ships a dead end that reads as a bug. A purely optional
|
||||||
|
material can be absent, and a season lacking one gains an identity.
|
||||||
|
|
||||||
|
**Since the depths are gigantic, "guaranteed" must mean a density, not a location.** Somewhere in an
|
||||||
|
enormous strate is unfindable. Near enough to the spine and common enough to actually meet is not.
|
||||||
|
|
||||||
|
### Open
|
||||||
|
|
||||||
|
- Where exactly the gear ceiling sits, and how fast danger climbs past it — that single rate is the
|
||||||
|
entire endgame difficulty curve.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Death
|
||||||
|
|
||||||
|
### Decided
|
||||||
|
|
||||||
|
Dying in the depths leaves your **corpse, possessed**. You fight it to get your cargo back.
|
||||||
|
|
||||||
|
**The corpse copies your weapons, and you keep yours.** It is a mirror match, not a disarming — you
|
||||||
|
never return to a fight underequipped.
|
||||||
|
|
||||||
|
**Only you can claim your loot.** No player can take it.
|
||||||
|
|
||||||
|
**A corpse run is a detour, not an evening — ten minutes at worst.**
|
||||||
|
|
||||||
|
**An NPC tells you where your corpse was last seen**, for a very small fee.
|
||||||
|
|
||||||
|
**Possible later:** dead players appear in other groups' instances, and killing such a corpse earns
|
||||||
|
gold from a retrieving guild — never that player's loot.
|
||||||
|
|
||||||
|
### Design rules
|
||||||
|
|
||||||
|
**Place the corpse; do not make it walk to you.** On death it should appear at a landmark near the
|
||||||
|
nearest passage or transport stop, and wander only within that neighbourhood. That turns the ten-minute
|
||||||
|
cap from a hope into a guarantee regardless of where you actually died.
|
||||||
|
|
||||||
|
**Charge for precision, not for existence.** The general area — "last seen near the Sink" — should be
|
||||||
|
free. Pay only for the exact position or live tracking. Otherwise the fee is not a decision, and worse,
|
||||||
|
a broke player after a bad run cannot recover their cargo at all.
|
||||||
|
|
||||||
|
**Copied weapons must never be lootable.** If the corpse drops a copy of your sword you have built an
|
||||||
|
item duplicator and it will be found within a week. Copied gear vanishes with the corpse; only cargo
|
||||||
|
drops.
|
||||||
|
|
||||||
|
**The corpse should be a combat-shaped version of you, not a literal copy.** A support build's literal
|
||||||
|
mirror heals itself and cannot be beaten, for reasons that have nothing to do with skill.
|
||||||
|
|
||||||
|
**Give the player a deliberate edge.** A perfect mirror is a coin flip, and you often arrive having
|
||||||
|
already spent your consumables. You have potions and can retreat; it does not and cannot. That is what
|
||||||
|
makes the fight winnable through preparation rather than luck.
|
||||||
|
|
||||||
|
**The bounty hunter and the player retrieval guild are one system with two front ends** — players are
|
||||||
|
the fast, uncertain path; the NPC is the slow, guaranteed one. Either way the cargo returns to you and
|
||||||
|
the retriever is paid by the guild, not out of your pocket.
|
||||||
|
|
||||||
|
### Open
|
||||||
|
|
||||||
|
- What happens if you die while fighting your corpse — one corpse that absorbs the last, or do they
|
||||||
|
stack?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Housing and farming
|
||||||
|
|
||||||
|
### Decided
|
||||||
|
|
||||||
|
**Housing is an instanced personal level** — your own small world, never wiped, never re-placed.
|
||||||
|
|
||||||
|
**Its ground is a snapshot of land you chose** in the depths and claimed. Your home is *from* somewhere.
|
||||||
|
After the season wipe it is the last surviving fragment of a world that no longer exists.
|
||||||
|
|
||||||
|
**One template level populated from save data** — a stored terrain region plus a list of placed
|
||||||
|
structures. Not one map per player.
|
||||||
|
|
||||||
|
**Invites**, because houses can be shared and visited.
|
||||||
|
|
||||||
|
**Anything that grows is computed from elapsed real time on entry**, not ticked — the instance is not
|
||||||
|
loaded while you are away.
|
||||||
|
|
||||||
|
### Open
|
||||||
|
|
||||||
|
- Whether players can build underground in the depths proper. The idea is alive, undecided.
|
||||||
|
- Whether the surface offers natural buildable space, or whether terrain-editing tools cover it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. The verbs
|
||||||
|
|
||||||
|
### Digging — decided
|
||||||
|
|
||||||
|
Digging is for **breaking walls that hide something behind them**, **uncovering half-buried ruins**,
|
||||||
|
**small shortcuts within a strate**, and **terraforming for builds**. It is a reveal-and-shape verb.
|
||||||
|
|
||||||
|
It is **not** a movement verb, and it is not the primary way through the world.
|
||||||
|
|
||||||
|
Descending by digging is possible but effortful: each strate's floor and ceiling are sealed, and the
|
||||||
|
seal must be dug through. Passages and transport are the fast path, by design.
|
||||||
|
|
||||||
|
### Combat — decided
|
||||||
|
|
||||||
|
MMORPG-like and dynamic: ranged and melee, magical and physical, stuns, roots, mutes, area effects,
|
||||||
|
targeted area effects, damage over time.
|
||||||
|
|
||||||
|
**Party of six. No friendly fire** — an area effect in a tunnel is simply strong, and that is accepted.
|
||||||
|
|
||||||
|
**First person must remain possible.**
|
||||||
|
|
||||||
|
### Design rules
|
||||||
|
|
||||||
|
**Telegraph the source, not the floor.** A circle painted on the ground only works if you can see the
|
||||||
|
ground, which is exactly what first person removes — you cannot see a marker under your own feet or
|
||||||
|
behind you. If the enemy visibly winds up instead, the threat is read off the creature. That works in
|
||||||
|
both perspectives, it survives lumpy voxel terrain where ground decals project badly, and reading an
|
||||||
|
animation is a better thing to ask of a player than reading a decal.
|
||||||
|
|
||||||
|
**Supporting both perspectives means owing both sets of constraints** — full third-person camera
|
||||||
|
clearance *and* source-readable telegraphs.
|
||||||
|
|
||||||
|
**Fights need space, and the generator must guarantee it.** Cramped tunnels are the worst possible
|
||||||
|
arena: no room to dodge a telegraph, no sightlines, no retreat, and with six players' effects in an
|
||||||
|
eight-metre corridor nobody can see anything. Rough targets, in the generator's own units of 8 m
|
||||||
|
chunks: a boss arena wants 3 to 4 chunks across, an ordinary fight space about 2, and roughly 1 chunk
|
||||||
|
of headroom for the camera.
|
||||||
|
|
||||||
|
**Verticality is what caves can offer that a flat arena cannot.** MMO fights are overwhelmingly flat. A
|
||||||
|
fight across ledges and drops is genuinely different, and it is the reason to fight underground at all.
|
||||||
|
|
||||||
|
### Open
|
||||||
|
|
||||||
|
- Creatures. Nothing decided. Danger and theme tags will select them, but *what they are* is unwritten,
|
||||||
|
and they are most of what makes a strate feel like a place rather than a shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. The season
|
||||||
|
|
||||||
|
### Decided
|
||||||
|
|
||||||
|
The depths are wiped and regenerated roughly every six to twelve months.
|
||||||
|
|
||||||
|
**Survives a wipe:** the city and its tier, account progression, titles, personal blueprints, housing
|
||||||
|
instances.
|
||||||
|
|
||||||
|
**Dies with the wipe:** the depths, the shared build pool, the transport unlock network.
|
||||||
|
|
||||||
|
### Why this is good rather than merely tolerable
|
||||||
|
|
||||||
|
The world is impermanent and the civilization is not. *We lost our maps, but we built a town.* Season
|
||||||
|
eight arrives in a place season three made, which gives a season's end a meaning beyond freshness.
|
||||||
|
|
||||||
|
It also solves problems that would otherwise be permanent. Every long-lived exploration game eventually
|
||||||
|
dies of its own wiki — everything is solved, every secret is mapped, nothing is left to find. A wipe
|
||||||
|
resets that, and the whole community re-explores together. It equally means a datamined map is worthless
|
||||||
|
one season later, so there is nothing to defend against.
|
||||||
|
|
||||||
|
And it frees the generator: because generation is only frozen *within* a season, it can be changed at
|
||||||
|
every boundary. The system never has to be finished before it meets players.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. The community layer
|
||||||
|
|
||||||
|
### Decided
|
||||||
|
|
||||||
|
**One seed for everyone.** Every player's depths are the same depths, which is what makes shared
|
||||||
|
knowledge — wikis, guides, "the crystalline lake is in strate four" — possible at all.
|
||||||
|
|
||||||
|
**Players share builds**, as defined blueprint structures rather than raw terrain edits. Votes rank
|
||||||
|
them; well-liked builds appear in more players' worlds; disliked ones fall to the back of the queue and
|
||||||
|
sufficiently disliked ones are never shown again. **New and unvoted builds must be deliberately rolled
|
||||||
|
in**, or nothing new is ever seen and the system dies quietly.
|
||||||
|
|
||||||
|
**The build pool does not survive a season change** — nothing would be placed correctly. Top
|
||||||
|
contributors are rewarded with titles, tiered by population.
|
||||||
|
|
||||||
|
**Landmarks have names**, generated so that every player sees the same name for the same place before
|
||||||
|
anyone has ever visited it.
|
||||||
|
|
||||||
|
### Design rules
|
||||||
|
|
||||||
|
**The community layer must degrade gracefully.** The depths are generated from a seed and need no
|
||||||
|
database at all. If the database is unreachable, procedural names stand in for community ones, shared
|
||||||
|
builds simply do not appear, and votes queue locally. That is cheap now and painful to retrofit once a
|
||||||
|
hundred call sites assume the fetch succeeded.
|
||||||
|
|
||||||
|
**A build placed anywhere is yours; a build placed in a spot the world advertised is eligible to
|
||||||
|
spread.** That keeps building free while keeping automatic distribution safe.
|
||||||
|
|
||||||
|
**Names must survive contact with a wiki** — short, pronounceable, typeable. A name nobody can spell
|
||||||
|
cannot become community knowledge, which is the only reason names exist here.
|
||||||
|
|
||||||
|
**Auto-distributing player content needs a report, reject and rollback path**, and shared builds must be
|
||||||
|
individually removable by the player receiving them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. The opening
|
||||||
|
|
||||||
|
### Decided
|
||||||
|
|
||||||
|
A cinematic: civilization emerged from the depths. The first artificial strate holds crystals with
|
||||||
|
humans inside. One of them is un-crystallized — you.
|
||||||
|
|
||||||
|
You discover a **base camp**, and are told you could be an adventurer who braves the depths.
|
||||||
|
|
||||||
|
Then you descend to **strate 2 — the first generated strate.**
|
||||||
|
|
||||||
|
### Why this is worth more than it looks
|
||||||
|
|
||||||
|
The first strate is hand-made, and the rest are generated. That is normally a production compromise
|
||||||
|
players must be prevented from noticing. Here the hand-made strate is **hand-made in the fiction too** —
|
||||||
|
artificial because someone built it, with the natural world lying beneath. A player who senses that
|
||||||
|
strate 1 feels different from strate 2 is reading the story correctly rather than spotting the seam.
|
||||||
|
|
||||||
|
That is rare, and it is worth protecting in every later decision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Open questions, collected
|
||||||
|
|
||||||
|
Ordered roughly by how much they block other work.
|
||||||
|
|
||||||
|
1. **Is the descent fun?** Unverified, and undesignable — it needs a crude build and an evening of
|
||||||
|
playing it. Everything in this document assumes the answer is yes.
|
||||||
|
2. **How long is a run?** Constrains strate size, transport density, and how deep a season reaches.
|
||||||
|
3. **Creatures.** Nothing decided. Blocks content, not the generator.
|
||||||
|
4. **The backend.** Local server plus an external database, per-group instances, moderation, shared
|
||||||
|
builds. Entirely unspecified and carrying a lot of weight.
|
||||||
|
5. **Does danger scale with party size**, or are strates tuned for a nominal group?
|
||||||
|
6. **The tier threshold** — fixed, or scaled to population?
|
||||||
|
7. **Adjacency rules** between strates. Wanted, never specified.
|
||||||
|
8. **Underground building** — undecided.
|
||||||
|
9. **Where the gear ceiling sits**, and how fast danger climbs past it.
|
||||||
|
|
||||||
|
## 17. Deliberately deferred
|
||||||
|
|
||||||
|
- **Digging noise attracting creatures.** Liked, parked. Still the cheapest way to price digging in
|
||||||
|
safety rather than in resources, if it is ever wanted.
|
||||||
|
- **Naming languages varying by depth** — older, stranger tongues deeper down. Liked, parked as still
|
||||||
|
being a gradient.
|
||||||
|
- **World-level transport unlocks**, where the community opens the descent together. Gated on strates
|
||||||
|
being big enough for the discovery to be worth something.
|
||||||
|
- **Shards** — more than one seed at a time. Only if the game grows enough to need them.
|
||||||
|
- **Singleplayer.** Never the intent; may become an option.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where the rest lives
|
||||||
|
|
||||||
|
- **`COMPOSER-NOTES.md`** — the working transcript this was distilled from, including the generator
|
||||||
|
design (the composer, the world manifest, the measurement pass, stack assembly) and the reasoning
|
||||||
|
behind everything above.
|
||||||
|
- **`ARCHITECTURE.md`**, **`CODEMAP.md`** — how VoxelForge actually works.
|
||||||
|
- **`fable-idea.md`** — the older ranked feature and performance roadmap. Predates most of this.
|
||||||
+77
-313
@@ -1,339 +1,103 @@
|
|||||||
# Handoff — VoxelForge operator stack, updated 2026-08-16 (four things queued on ONE build)
|
# VoxelForge handoff — the operator stack is DONE. Read this, not the old jargon.
|
||||||
|
|
||||||
> Paste the block below into a fresh session. Everything it refers to is on disk and in git.
|
> Paste this into a fresh session. Written 2026-08-16, deliberately in plain language: the previous
|
||||||
>
|
> version of this file had become a private dialect that Jahni could not read, which is a failure of
|
||||||
> **State:** 8 of 8 archetypes ported and green. Tile-skipping is measured **in the automation
|
> the document, not of the reader.
|
||||||
> harness** (11 of 40 tiles proved `AllSolid` at production defaults, 14641 voxels brute-forced,
|
|
||||||
> 0 violations) and **still unobserved in the running game**. `AUDIT §C2` is **fully closed** (both
|
|
||||||
> halves — verified 2026-08-16, don't reopen). `experimental` is pushed.
|
|
||||||
>
|
|
||||||
> ## ⛔ FOUR unbuilt things are stacked on `experimental`. Build once, read four numbers.
|
|
||||||
>
|
|
||||||
> | # | commit | what to read |
|
|
||||||
> |---|---|---|
|
|
||||||
> | 1 | `e002bd4` VerticalShafts connector capsules | `Box verdicts over 60 VerticalShafts tiles` — **0 has been the number for the project's whole life**; `violations` must stay 0 |
|
|
||||||
> | 2 | `eb317d9` `stat VoxelForge`, 8 counters | baseline `TilesOpStackSolid` = 0 → tick one `TunnelNetwork` strate → **non-zero**. That is the production proof of T1.d, which has never existed |
|
|
||||||
> | 3 | `7dbdf51` `ExtraReach` × `VF_PerlinAbsBound` | Maze/VerticalShafts may prove **FEWER** tiles. **That is correct, not a regression** |
|
|
||||||
> | 4 | `eaa44bf` `Max3` radius envelope | **a NO-OP at shipped defaults is the correct result** — any moved number means the diff did more than intended |
|
|
||||||
>
|
|
||||||
> In all four: the eight equivalence tests must stay green, and `violations` must stay 0.
|
|
||||||
>
|
|
||||||
> **⚠️ 3 and 4 are CORRECTNESS fixes to box verdicts, found by auditing all 28 `EffectOverBox`
|
|
||||||
> implementations.** Both were the same mistake: *a bound taken from the parameter that reads like
|
|
||||||
> the maximum instead of the supremum of what `Eval` actually produces* — and both times a correct
|
|
||||||
> instance of the same reasoning already existed elsewhere in the same file. See
|
|
||||||
> `OPSTACK-PROGRESS.md` 2026-08-16 (e) and (f); the sound-and-checked ops are listed there so they
|
|
||||||
> are not re-audited.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
You're picking up the VoxelForge UE5 voxel plugin on branch `experimental` (already checked out —
|
## 1. What the operator stack was, in one paragraph
|
||||||
do not create another). I'm Jahni. The design and the history are written down so you don't
|
|
||||||
re-derive them.
|
|
||||||
|
|
||||||
## Read first, in this order
|
Cave generation used to be one big `switch`: each cave type (tunnels, maze, shafts, floating
|
||||||
|
islands, surface…) was its own hardcoded ~200–1000 line C++ function. The refactor replaced that with
|
||||||
|
small composable pieces ("operators") that stack up to produce the same terrain. **The promise was
|
||||||
|
that you could eventually invent new world types by combining pieces in the editor instead of asking
|
||||||
|
for another thousand-line function.**
|
||||||
|
|
||||||
1. **`CLAUDE.md`** — project rules. **Rule #1 is absolute: never build, compile, or run the editor.**
|
**That promise — "Phase 3", ops as data assets — was never built, and is NOT being built now.**
|
||||||
I build everything myself. When code is done, stop, say "ready to build", list the likely
|
|
||||||
compile-error spots, and wait.
|
|
||||||
2. **`OPSTACK-PROGRESS.md` — THE LAST ENTRY FIRST.** Append-only log; the resume point.
|
|
||||||
3. **`OPSTACK-PLAN.md`** — the plan. **§2.6.1 is the acceptance bar** and supersedes §2.6.
|
|
||||||
4. **`OPSTACK-DECOMPOSITION.md`** — per-archetype breakdown. **§0.2** (the amplitude bound) is now
|
|
||||||
*implemented*, not pending; §2 TunnelNetwork and §8 Underwater are history, not instructions.
|
|
||||||
5. **`AUDIT-2026-07.md`** — **§C2's SDF-cache half is FIXED (2026-07-28)**, its live-edit half
|
|
||||||
(`OC_Chunk` / `BM_Chunk` / `FChunkBiomeCache`) is still open; §C10 is SOLVED, don't reopen;
|
|
||||||
§C9's library half is the top open theoretical risk with 0 measured exposure.
|
|
||||||
6. **`CODEMAP.md`** — navigation. Trust symbol names over line numbers.
|
|
||||||
|
|
||||||
## How we work now — Codex writes, you orchestrate
|
## 2. Status: done. Stop refactoring.
|
||||||
|
|
||||||
From 2026-07-29 this project runs **in tandem with Codex (Model Luna, xHigh)**. **Codex handles most
|
- **8 of 8 archetypes ported**, running in production (`bUseOperatorStack` is ticked on the game's
|
||||||
of the coding; you orchestrate.** Concretely:
|
strate assets), and **bit-for-bit identical** to the old path. Verified by a 115 000-sample field
|
||||||
|
digest plus eight per-archetype equivalence tests.
|
||||||
|
- **14/14 automation tests green, 0 violations anywhere.**
|
||||||
|
- **39 % of tiles are skipped in the running game** (`Tiles Meshed` 1.15 vs `Tiles Classified` 1.88).
|
||||||
|
It was 0 % on the morning of 2026-08-16. Each skipped tile avoids ~43 000 density evaluations plus
|
||||||
|
marching cubes.
|
||||||
|
|
||||||
- You read the code and decide *what* to do; you write **precise specs** Codex executes; you **review
|
**Decision taken 2026-08-16, with Jahni:** the op stack is finished. **Do not start Phase 3. Do not
|
||||||
what comes back against the real code, not against its description**; you own the docs
|
start another refactor.** The old `switch` stays in place as the correctness oracle — deleting it
|
||||||
(`OPSTACK-PROGRESS.md`, `CODEMAP §3`, this file) and the measurements.
|
buys nothing today. Next work should be things Jahni can *see*: see `fable-idea.md` (F7 set-pieces,
|
||||||
- **Hand Codex the INVARIANT, not just the task.** This codebase's traps are invisible in a diff —
|
F9 audio were queued before this started).
|
||||||
density sign, `Identity` meaning `Sdf ≥ T` (below), cache keys needing params + `LayoutVersion`,
|
|
||||||
inserting classes above the anonymous-namespace end marker. A spec that omits these gets code that
|
|
||||||
compiles and deletes collision.
|
|
||||||
- `CODEX-TASK-*.md` at the plugin root are the specs. Each carries a **Why**, the **exact site**, the
|
|
||||||
**invariants**, an **acceptance** section, and **notes for the reviewer**. Write the next one the
|
|
||||||
same way.
|
|
||||||
- Unchanged: **never build** (Jahni does), and a plausible patch is not a verified one until a
|
|
||||||
measurement says so.
|
|
||||||
|
|
||||||
## Where things stand
|
### The honest ledger, so nobody re-litigates it
|
||||||
|
|
||||||
All 8 archetypes have an operator-stack twin, per-strate opt-in, each equivalence-tested **bit for
|
Three weeks, 120 commits, from 2026-07-27. Every `feat:` commit in that window is a *port* of
|
||||||
bit** against its original density function. The `switch` and the stack are two complete,
|
something that already worked. **The world did not change by a single voxel — that was the
|
||||||
interchangeable implementations.
|
acceptance criterion.** What Jahni actually got: the 39 % perf win, two genuine pre-existing bugs
|
||||||
|
found (a use-after-free on every strate-asset edit while streaming, and an under-bounded room
|
||||||
|
collection that could make two multiplayer peers generate different geometry), and a number of fixes
|
||||||
|
to bugs the refactor itself introduced. That is a thin return for three weeks, and it is why the
|
||||||
|
direction changed.
|
||||||
|
|
||||||
Everything sits behind `UVoxelStrateDefinition::bUseOperatorStack`; the ported list lives **only** in
|
## 3. The jargon, translated
|
||||||
`UVoxelStrateManager::UsesOperatorStackForChunk` (all 8). `GetDensityAt` and `ClassifyTile` build the
|
|
||||||
stack through the **same** factory, `VF_BuildOpStackForChunk` — a second copy would be a hole.
|
|
||||||
|
|
||||||
> ### ⚠️⚠️ CORRECTED 2026-08-16 — **THE FLAG IS ON IN THE GAME'S DATA ASSETS.**
|
Almost all of it means one thing: **can we prove a chunk of world is entirely rock or entirely air
|
||||||
>
|
without checking every point in it, so we can skip the expensive work?**
|
||||||
> This section used to say *"No strate asset has the box ticked — that is my call and I still
|
|
||||||
> haven't made it."* **That is false and was believed for a whole session.** Jahni: *"the data assets
|
|
||||||
> in game have the switch on."*
|
|
||||||
>
|
|
||||||
> **Everything downstream of that premise flips:**
|
|
||||||
> - The operator stack is **the production density path**, not a dormant twin. The measured perf
|
|
||||||
> regression is a regression players feel, not a lab result.
|
|
||||||
> - Any unsoundness in an op's `EffectOverBox` is a **live** hole, not a latent one. Two were found
|
|
||||||
> and fixed on 2026-08-16 (`7dbdf51`, `eaa44bf`) and **both of those commit messages say "nothing
|
|
||||||
> in the running game was affected" — that sentence is WRONG, for this reason.** See
|
|
||||||
> `OPSTACK-PROGRESS.md` 2026-08-16 (h) for the corrected severity.
|
|
||||||
> - The T1.d prize is already being collected in-game; `CODEX-TASK-001`'s counters measure how much.
|
|
||||||
>
|
|
||||||
> **Never state the flag's state from memory again — it lives in `.uasset` data, which is not
|
|
||||||
> greppable from here. Ask, or read it in the editor.**
|
|
||||||
|
|
||||||
### ✅ T1.d — the tile-skipping prize — is real and measured **in the harness** (not yet in the game)
|
| term | plain meaning |
|
||||||
|
|
||||||
`FRoomGraphSource::EffectOverBox` answers **spatially**. The result, brute-forced voxel by voxel:
|
|
||||||
|
|
||||||
```
|
|
||||||
[production defaults] 11 of 40 tiles proved AllSolid — 14641 voxels checked, 0 violations
|
|
||||||
[dense fixture] 0 of 40 — correct, and structurally inevitable
|
|
||||||
```
|
|
||||||
|
|
||||||
One function's verdict is inherited by `FSdfConvertOp`, the twelve detail modifiers (via
|
|
||||||
`VF_NoCaveOverBox`) **and** `FWormFieldSource` — fourteen operators from one place. That is what the
|
|
||||||
C1 wiring was built for.
|
|
||||||
|
|
||||||
### ⚠️⚠️ THE ONE INVARIANT THAT CAN DELETE COLLISION — read before touching any op
|
|
||||||
|
|
||||||
**`FRoomGraphSource::EffectOverBox` returning `Identity` now means `Sdf ≥ T`, NOT `Sdf == FLT_MAX`**,
|
|
||||||
where `T = max(3·SDFBlendRadius, WormNetworkRange)`. That is sound only because all three consumers
|
|
||||||
of the SDF channel were read one by one:
|
|
||||||
|
|
||||||
| consumer | threshold |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| `FSdfConvertOp::Eval` | `Sdf >= Blend`, and the tunnel stack passes `MakeSdfCarve(P.SDFBlendRadius, …)` ⇒ **K** |
|
| **T1.d / tile skipping** | that idea. The single biggest perf item in the plan. |
|
||||||
| the twelve modifiers | `VF_NearCaveSurface` ⇒ **3K** |
|
| **box verdict / `ClassifyBox`** | "is this whole box uniform?" → `AllSolid`, `AllAir`, or `Mixed` (don't know) |
|
||||||
| `FWormFieldSource::Eval` | `CaveSDF >= WormNetworkRange` ⇒ **WormNetworkRange** |
|
| **`Mixed`** | "can't prove it" — always safe, just means we do the work |
|
||||||
|
| **`ClassifyTile`** | the function that decides, per tile, whether to skip meshing |
|
||||||
|
| **operator / op stack** | one generation step (rock, carve, roughness…) and the list of them |
|
||||||
|
| **equivalence test** | proof the new path produces byte-identical terrain to the old one |
|
||||||
|
| **`violations`** | ⚠️ **the only number that means danger.** A tile wrongly proved uniform has *no geometry and no collision* — a player falls through the floor. Must always be 0. |
|
||||||
|
|
||||||
**Any new consumer of `InOut.Sdf` must have a threshold ≤ `T`, or be added to that `max`.** An op
|
## 4. What is verified, and what is not
|
||||||
reading `Sdf < 100` would see false `Identity` verdicts and produce tiles with no geometry **and no
|
|
||||||
collision**. The warning is written at the site you land on when you add one.
|
|
||||||
|
|
||||||
(The `−K` slack covers *any* number of primitives because `SmoothMin`'s penalty is exactly zero once
|
**Verified:** everything through commit `871ca19` — tests green, digests unchanged, 39 % measured
|
||||||
`|A−B| ≥ K`, so the running minimum saturates at `K` below the smallest term. Without that
|
in game.
|
||||||
observation the slack would scale with the ~88 tunnels in a cache and the criterion would be dead.)
|
|
||||||
|
|
||||||
## First actions — one build to read, two tasks to hand Codex
|
⚠️ **Built but NOT re-verified:** `4d33321` (Sol's boundary fold — lets a tile that straddles cave and
|
||||||
|
open air still resolve) and `91585ea` (a test-only warning demotion). Jahni built these and says the
|
||||||
|
game *looks* fine, but **the test suite has not been re-run and the counters have not been re-read
|
||||||
|
since.** Before trusting them:
|
||||||
|
|
||||||
### (a) Hand Codex `CODEX-TASK-001-tile-skip-stats.md`, then `-002-` — 001 first, they chain
|
1. run the `VoxelForge` automation filter — **`violations` must be 0 and all eight equivalences
|
||||||
|
bit-identical**;
|
||||||
|
2. `stat VoxelForge` in game — the accounting must close:
|
||||||
|
`Tiles Meshed + Skipped All Air + Skipped All Solid = Tiles Classified`.
|
||||||
|
|
||||||
Everything in this refactor has been proved in an automation harness on 40 sampled tiles, and
|
If either fails, `git revert 4d33321` — the 39 % win does not depend on it.
|
||||||
**nothing has ever been observed in the running game.** Task 001 adds a `stat VoxelForge` group with
|
|
||||||
`TilesClassified / TilesSkippedAllSolid / TilesSkippedAllAir / TilesMeshed`, **plus
|
|
||||||
`TilesOpStackSolid / TilesOpStackAir` at a second site**. Task 002 adds `ColumnMemoHit / Miss` to the
|
|
||||||
same group; it needs 001's header to exist, and both should land in one build.
|
|
||||||
|
|
||||||
Its deliverable is a **before/after that constitutes the production proof of T1.d**: after ticking
|
## 5. Rules that still prevent real bugs
|
||||||
`bUseOperatorStack` on one `TunnelNetwork` strate and flying the same route, **`TilesOpStackSolid`
|
|
||||||
must go non-zero**. The spec carries the invariants — most importantly that `bTrivialEmpty` decides
|
|
||||||
whether a tile has **collision**, and that `GenerateTileResult` runs on **worker threads** so a plain
|
|
||||||
`static int32++` is a data race.
|
|
||||||
|
|
||||||
⚠️ **Corrected 2026-08-16 — the earlier version of this bar was unmeasurable.** It said
|
- **Never build.** Jahni builds; he has the editor open and it costs him real time. Say "ready to
|
||||||
`TilesSkippedAllSolid` would read 0 underground with no strate opted in. It will not: `ClassifyTile`
|
build" and list likely compile-error spots.
|
||||||
also proves `AllSolid` on its **hand-written** path (a bedrock-gap chunk sets `bCanAir = false`,
|
- **`violations` 0 and the eight equivalences bit-identical** — the only non-negotiable results.
|
||||||
`VoxelGenerator.cpp` ~2835), which fires with nothing ticked at all. That is the same "~84 %" caveat
|
- **A bound used to skip work must be PROVED, not observed.** Use `VF_PerlinAbsBound` (= 1.5);
|
||||||
below, which the old bar quoted and then contradicted. The op-stack-only counters are zero **by
|
`FMath::Lerp(A,B,t)` spans `[min(A,B), max(A,B)]`, so a radius envelope is `max(Min,Max)`.
|
||||||
construction** — the cave branch returns `Mixed` at `UsesOperatorStackForChunk` — so they are the
|
- **Never change `ClassifyTile`'s conditions or return values casually.** Every `return` there fails
|
||||||
ones that prove anything.
|
safe to `Mixed`.
|
||||||
|
- **Never state a `.uasset` value from memory** (like `bUseOperatorStack`). Ask, or read it in the
|
||||||
|
editor. This sent a full day sideways.
|
||||||
|
- `FindSlotIndexForChunkZ` is **protected**; `GetStrateChunkZBounds` is the public equivalent.
|
||||||
|
- Push `experimental` freely. **Never push `main`.**
|
||||||
|
|
||||||
Interim answer if Jahni wants it before that lands: **Unreal Insights already shows this.** The trace
|
## 6. Two lessons that generalise beyond this plugin
|
||||||
scopes `VoxelForge_ClassifyTile` and `VoxelForge_GenerateMesh` exist at the site; a skipped tile is a
|
|
||||||
`ClassifyTile` with no `GenerateMesh` after it. ⚠️ But ~84 % of tiles were *already* being rejected by
|
|
||||||
the hand-written SurfaceWorld/bedrock paths long before this work, so surface skips will drown the
|
|
||||||
cave ones — you must be **underground in an opted-in `TunnelNetwork` strate** for the number to mean
|
|
||||||
anything.
|
|
||||||
|
|
||||||
### (b) Build `e002bd4` (VerticalShafts) and read ONE line
|
- **A signal that always says the same thing measures nothing.** A counter that can fire for two
|
||||||
|
reasons is not a measurement — splitting one such counter is what finally cracked T1.d after a day
|
||||||
|
of wrong inference. A warning that fires every run and always means "this is fine" is noise that
|
||||||
|
trains the reader to ignore warnings; one of those quietly worried Jahni for several sessions.
|
||||||
|
- **Verify the premise, and verify it completely.** Multiple confident chains reversed on checking
|
||||||
|
this month. Twice the failure was a *partial* read — grepping a symbol and reporting it as checked
|
||||||
|
for something else. A grep that finds a declaration has not checked its access specifier.
|
||||||
|
|
||||||
Everything before it is built and green.
|
## 7. The open question, which matters more than any of the above
|
||||||
|
|
||||||
> Build, run the `VoxelForge` filter, and read
|
**What do you want the world to *do* that it doesn't?** Three weeks went into a pipeline instead of
|
||||||
> **`Box verdicts over 60 VerticalShafts tiles`**.
|
that question. Start there.
|
||||||
>
|
|
||||||
> **0 was the number for the whole project's life.** 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`. It now rebuilds the connectors the way `GetCells` does and
|
|
||||||
> tests the real capsules, with **Z exact** and XY conservative.
|
|
||||||
>
|
|
||||||
> - **Non-zero, and `violations` still 0** ⇒ it worked; record it and move on.
|
|
||||||
> - **Still 0** ⇒ the warning in that test names what to check **first**: `ExtraReach` inflates both
|
|
||||||
> remaining tests, so compare it against `ShaftMaxRadius` before touching either test. **Do not
|
|
||||||
> re-derive from scratch** — that is exactly what cost three rounds on TunnelNetwork.
|
|
||||||
|
|
||||||
## Then, in order
|
|
||||||
|
|
||||||
1. **PERF — the biggest open item, and now AIMED (2026-08-16). Read this before touching it.**
|
|
||||||
The op path is measurably slower; one cause was already found and fixed (the column memo
|
|
||||||
discarded itself every chunk). Three things were worked out since, all still **unmeasured**:
|
|
||||||
|
|
||||||
- **The A/B needs no new code.** `VoxelForge_ClassifyTile` and `VoxelForge_GenerateMesh` already
|
|
||||||
exist, and the world is deterministic, so two Insights traces — flag off, then on, same seed and
|
|
||||||
route — are a clean before/after.
|
|
||||||
- **But it is unreadable without task 001.** With the stack on, tiles get *skipped*, so
|
|
||||||
`GenerateMesh` runs fewer times; a total conflates "cheaper per tile" with "fewer tiles" and
|
|
||||||
those pull opposite ways. `TilesMeshed` is the denominator. **⇒ 001 is a PREREQUISITE here, not
|
|
||||||
a parallel item.** Order: 001 → traces → attribution → fix.
|
|
||||||
- **The suspects don't share an archetype**, so measure one ticked strate at a time.
|
|
||||||
*SurfaceWorld* = the hashed column memo. *TunnelNetwork* = **19 virtual calls per voxel**
|
|
||||||
(16 from `BuildTunnelNetworkStack` + 3 from `AppendStructuralPost`), plus the known 12× gate
|
|
||||||
re-test (stage B5's deliberate trade).
|
|
||||||
|
|
||||||
`CODEX-TASK-002` tests the SurfaceWorld suspect and **fixes nothing on purpose** — the derivation
|
|
||||||
says the direct-mapped 4096-slot table evicts ~25 % of columns *every Z plane* (the mesher
|
|
||||||
pre-samples **Z-outermost**), for a derived ~9× on column work. **Derived, not measured.**
|
|
||||||
**Measure before optimising** — the §C10 lesson, re-learned the hard way last session.
|
|
||||||
2. **The warp squeeze — PARKED with its ceiling measured, my recommendation is leave it.** The
|
|
||||||
`WARP SHARE` line says over half the remaining blocking is the query-box dilation, not geometry
|
|
||||||
(production: rooms 0.9 → 0.4, tunnels 2.4 → 1.1 with the dilation zeroed). The only remaining
|
|
||||||
route is proving `sup|Perlin3D|` down from the proved **1.5** toward its apparent ~1.0–1.1, worth
|
|
||||||
~27 % of the dilation. Spot-checking a grid is **not** a proof and a wrong sup is a hole.
|
|
||||||
**A negative result is already recorded so nobody repeats it:** bounding the warp *locally*
|
|
||||||
(evaluate at the box centre, shift, dilate by the variation) is **worse** — a rigorous per-axis
|
|
||||||
Lipschitz bound is `4·1.875 + 1 = 8.5` per unit cell, and `8.5 × 0.206` (the half-box in noise
|
|
||||||
units) `= 1.75` exceeds the global range bound of 1.5.
|
|
||||||
3. **`AUDIT §C9` library half** — `sinf`/`cosf` are not IEEE-754 specified, so MSVC's CRT and glibc's
|
|
||||||
libm can differ. Currently **0 samples within 1e-6 of the isosurface**, i.e. no measured risk. Run
|
|
||||||
`CrossPlatformDigest` on Linux, compare the SHAPE digest, pin it. The real fix if ever needed is a
|
|
||||||
deterministic in-house sin/cos.
|
|
||||||
4. ~~**`AUDIT §C2`'s remaining half**~~ — **✅ CLOSED, verified 2026-08-16. Do not re-open, and do
|
|
||||||
not spec a fix for it — I nearly did.** `OC_Chunk`, `BM_Chunk` and `TC_BiomeCache` all carry a
|
|
||||||
layout-version guard (`OC_Version` / `BM_Version` / `TC_SeenVersion`), `FChunkBiomeCache` has an
|
|
||||||
explicit `Invalidate()` that all four `thread_local` instances call on a version change, and the
|
|
||||||
only other two instances in the tree are **function-local**, so they cannot go stale. Recorded in
|
|
||||||
`AUDIT-2026-07.md §C2`. The live-edit half was fixed at the same time as the determinism half;
|
|
||||||
only this list was stale.
|
|
||||||
5. **Phase 3 — ops as data assets.** A design conversation, not a transcription. Don't start it
|
|
||||||
unprompted. What makes it possible is already in place: ops depend on capabilities
|
|
||||||
(`IVoxelBiomeField`), never on `UVoxelGenerator`.
|
|
||||||
|
|
||||||
## Debts — status changed, read this before acting on the old text
|
|
||||||
|
|
||||||
1. **"Box bounds read STRATE params but a per-room op can raise them" — DORMANT, not urgent.**
|
|
||||||
Checked rather than paid, and the check reversed the premise: when the source proves `Identity`
|
|
||||||
the twelve modifiers are `Identity` **soundly** (their `bNearCaveSurface` gate never opens, so no
|
|
||||||
room op can enable anything), and when it answers `Both` it supplies no `MaxCarveOverBox`, so the
|
|
||||||
default `FLT_MAX` kills every hypothesis regardless of what the modifiers claim. **It goes live
|
|
||||||
the day `FRoomGraphSource` gains a `MaxCarveOverBox`** — bounding the converter's `2·BaseDensity`
|
|
||||||
would make the modifiers' own numbers matter for the first time. Written at the site.
|
|
||||||
2. **`AUDIT §C2` — FIXED on the `switch` path.** `GetDensityWithParams` now takes **required**
|
|
||||||
`ParamsFingerprint` + `LayoutVersion`. Required, not defaulted, so a caller that forgets fails to
|
|
||||||
compile. The CRC is taken **once per chunk** where the params memo already lives (`CP_TunnelFP`) —
|
|
||||||
a `MemCrc32` per voxel on the hottest path would have been a real regression. Note the audit's own
|
|
||||||
suggested alternative ("add chunk Z to the key") is both insufficient (`Interleaved` makes `Alpha`
|
|
||||||
depend on chunk **XY** too) and destructive (chunk XY is deliberately absent so `WorldX ± 1`
|
|
||||||
gradient probes don't thrash the box — `ARCHITECTURE §8.10`).
|
|
||||||
|
|
||||||
## Hard rules that prevent real bugs
|
|
||||||
|
|
||||||
- **Density sign:** negative = solid at the mesher. Inside the op stack the convention is INTERNAL
|
|
||||||
(**positive = solid**), negated once by the caller. The SDF channel uses standard SDF convention.
|
|
||||||
- **`Identity` from the room source means `Sdf ≥ T`.** See the boxed invariant above. This is the
|
|
||||||
single most dangerous thing in the current code.
|
|
||||||
- **Never run both density paths in one world.** **Comparing them is legitimate** — §C10 is closed
|
|
||||||
since `FPSemantics = Precise`, and all eight equivalence tests compare bit for bit. They are
|
|
||||||
**port-correctness oracles**, not fidelity checks: §2.6.1 requires *same seed ⇒ same world on every
|
|
||||||
peer*, not resemblance to the pre-refactor world.
|
|
||||||
- **Every cache key includes `LayoutVersion` AND the params.** See §C2 and the overhang regression of
|
|
||||||
2026-07-27, where omitting the params silently deleted the overhang and only 1 sample in 20 000
|
|
||||||
crossed the isosurface.
|
|
||||||
- **A bound in a box verdict must be PROVED, not observed.** `|Perlin3D| ≤ 1.5` is derived from
|
|
||||||
`GradDot`'s two-distinct-axes form and the per-axis weighted bound of 0.5 — *not* from the header's
|
|
||||||
"~[-1,1]". Over-estimating costs CPU; under-estimating deletes collision.
|
|
||||||
**⚠️ USE `VF_PerlinAbsBound` — it is file-scope in `VoxelDensityOpStack.cpp` and it is the ONLY
|
|
||||||
copy. Never write a bare `1.0` for a noise amplitude in a reach.** `VoxelNoise::FBM` **normalises**
|
|
||||||
(`return Total / MaxValue`), so `sup|FBM| = sup|Perlin3D|` **exactly** — the octave sum neither
|
|
||||||
amplifies nor attenuates it, and an `FBM`-driven reach needs the same 1.5. This rule was written
|
|
||||||
*before* three `ExtraReach` formulas were found violating it (2026-08-16, fixed in `7dbdf51`):
|
|
||||||
VerticalShafts and Maze were unsound at their shipped defaults, FloatingIslands sound only because
|
|
||||||
its `SDFBlendRadius` happens to be large. **A rule stated in a doc is not a rule enforced in code** —
|
|
||||||
when you add a reach, grep for `VF_PerlinAbsBound` and use it.
|
|
||||||
- `ProcessQueue` stays `EQueueMode::Mpsc`; `Epoch` carries through every async path; don't "optimize"
|
|
||||||
the `ARCHITECTURE §8.10` invariants.
|
|
||||||
- Commit per coherent unit with a real message. **`experimental` is pushed and tracked
|
|
||||||
(`origin/experimental`, since 2026-07-29) — keep it in sync. NEVER push `main`**, which stays the
|
|
||||||
known-good fallback at the commit it has always been. ⚠️ A pushed commit here is **not** a
|
|
||||||
"verified green" marker: the branch carries unbuilt work by design, and only `OPSTACK-PROGRESS.md`
|
|
||||||
says what was actually built.
|
|
||||||
- Update `CODEMAP §3`, `ARCHITECTURE §8`, tick `OPSTACK-PLAN`, append to `OPSTACK-PROGRESS.md`.
|
|
||||||
- **When inserting a class into `VoxelDensityOpStack.cpp` / `VoxelHeightOpStack.cpp`, put it ABOVE
|
|
||||||
the labelled end of the anonymous namespace.** Anchoring on the FACTORIES banner puts it outside,
|
|
||||||
and the brace added with it closes nothing. Made that mistake twice; both files say so.
|
|
||||||
- **Match the codebase's spelling of engine macros.** `KINDA_SMALL_NUMBER`, not
|
|
||||||
`UE_KINDA_SMALL_NUMBER` — the plugin uses the unprefixed form everywhere.
|
|
||||||
|
|
||||||
## Method lessons this refactor actually paid for
|
|
||||||
|
|
||||||
Ordered by how much they cost.
|
|
||||||
|
|
||||||
- **⭐ Instrument what you ASSUMED, not just what you changed.** This is the expensive one, learned
|
|
||||||
over four rounds in one session. The warp dilation — `CaveWarpStrength · VOXEL_NOISE_SCALE ·
|
|
||||||
PerlinAbsBound`, a constant chosen in the first commit — inflated a 10-voxel tile into a 50-voxel
|
|
||||||
query box, **125× the volume**. Four separate tightenings (the worm, the columns, the sampler, the
|
|
||||||
tunnel disjunction) were each individually correct and each landed *around* that untouched term.
|
|
||||||
The tunnel fix, predicted "an order of magnitude", delivered 25 % — **and the instrument said so,
|
|
||||||
and I credited the tunnels.** *When a fix under-delivers against its predicted size, suspect the
|
|
||||||
constant you never measured.*
|
|
||||||
- **Instrument before hypothesising.** §C10 cost six builds and five refuted hypotheses. In this
|
|
||||||
session the attribution line (`AllSolid killed by: …`) was written after *two* wrong guesses and
|
|
||||||
immediately named a third operator nobody had looked at. **A diagnostic that lists candidate causes
|
|
||||||
without measuring them is still a guess wearing rigour** — my "either the tiles straddle cave or
|
|
||||||
the source isn't reaching Identity" warning offered two causes and both were wrong.
|
|
||||||
- **Verify the premise before reasoning from it.** Six times now a confident chain rested on an
|
|
||||||
unchecked assumption and the check reversed it. Latest three: `RoomSpacing` was **42** (the fixture
|
|
||||||
overrides it) while I did three rounds of arithmetic with the header default of 80 — *the number
|
|
||||||
was printing in the report I kept quoting*; "the plugin bets on `|Perlin3D| ≤ 0.8`" was wrong (the
|
|
||||||
cache **rebuilds** when the warped query leaves the box, so that expansion is a perf heuristic);
|
|
||||||
and the per-room-op debt "must be paid first" was wrong (it is dormant). **Include the premises you
|
|
||||||
are confident enough about not to look up — especially a default, when a fixture exists whose whole
|
|
||||||
job is overriding defaults.**
|
|
||||||
- **A sampler must cover at least one period of what it samples.** The tunnel test drew tile XY from
|
|
||||||
**±32 voxels** with `RoomSpacing 80` and a guaranteed origin room at (0,0) — it measured the spine
|
|
||||||
hub and called it the world. The shaft test had the identical bug (±48 against `ShaftSpacing 55`).
|
|
||||||
Both now print their own extent **in units of the pattern's period**.
|
|
||||||
- **A test fixture tuned for coverage can be antagonistic to the thing you are measuring.**
|
|
||||||
`EnableTunnelFeatures` densifies (`RoomSpacing` 80→42, `RoomDensity` 0.35→0.85) so the equivalence
|
|
||||||
check isn't comparing solid rock to solid rock — and at that density the room cull radius *equals*
|
|
||||||
the lattice spacing, so **no box can ever be proved**. `0 proved` there is the correct answer. The
|
|
||||||
box verdict is therefore measured on **both** densities, and the dense run must stay at 0.
|
|
||||||
- **Diagnostics report THIS run; history goes in the log.** The test output had accumulated hardcoded
|
|
||||||
numbers from previous runs beside live ones ("32 of 34 tiles" printed while the live figure was 21
|
|
||||||
of 28). Unreadable, and self-inflicted.
|
|
||||||
- **Read the code, not the comment.** The cliff modifier's comment promises a sampled Z±1 gradient;
|
|
||||||
the code samples nothing and uses a Z-stretched Perlin it *calls* `VertGrad`. Ported as written —
|
|
||||||
and written down, so nobody "fixes" it from the comment.
|
|
||||||
- **A perf change can be a correctness change.** The column-memo optimisation silently deleted the
|
|
||||||
overhang; the tests caught it the same day. Invisible to inspection, and it produced plausible
|
|
||||||
terrain.
|
|
||||||
- **Coverage is a number, not a boolean.** Four related traps, each producing a green run that proved
|
|
||||||
almost nothing:
|
|
||||||
- *A test that prints nothing on success is indistinguishable from one that never ran.*
|
|
||||||
- *A guard that only trips at zero notices absence, it does not measure coverage.* Use fractions.
|
|
||||||
- *A success message that **asserts** coverage instead of reporting it reads as evidence while
|
|
||||||
measuring nothing.*
|
|
||||||
- *A check can be vacuous as well as a counter.* "Nothing leaked" is worthless unless something
|
|
||||||
happened.
|
|
||||||
- **Enabling a feature is not evidence it fired — ask the structure, not the output.** Setting
|
|
||||||
`PitDensity` did nothing (wrong struct). **Prefer the check that can fail for exactly one reason**
|
|
||||||
— and when a zero has several possible causes, give each one its own number.
|
|
||||||
- **An oracle that shares the defect under test proves nothing.** The stale-cache check compares each
|
|
||||||
stack against *itself evaluated alone*. (Since §C2 was fixed, the test call sites now pass a real
|
|
||||||
params fingerprint, so the original no longer shares the defect either.)
|
|
||||||
- **One definition, not two kept in sync.** `VF_BuildOpStackForChunk` exists because a tile skipped on
|
|
||||||
the verdict of a stack that is not the one producing its density is a hole. The same reasoning is
|
|
||||||
why `GetLastRoomBoxDiagnostic` **reads back** what the operator computed instead of letting the
|
|
||||||
test re-derive the criterion, and why the two-density tile scan is one lambda called twice.
|
|
||||||
- **Don't assert a number you want to improve.** Check 4 asserted `0 proved` — honest when written,
|
|
||||||
and it would have forbidden the entire T1.d gain. What it asserts now is that **no proved tile is
|
|
||||||
wrong** (brute force, every voxel); the proved count is *reported*.
|
|
||||||
@@ -4214,3 +4214,387 @@ The dirty candidate diffs were preserved as named, recoverable stashes before cl
|
|||||||
|
|
||||||
`../VF-approach-A` and `../VF-approach-B` were then removed from Git's worktree list and their
|
`../VF-approach-A` and `../VF-approach-B` were then removed from Git's worktree list and their
|
||||||
directories removed. Stashing was a cleanup-safety deviation only; it did not alter the landed code.
|
directories removed. Stashing was a cleanup-safety deviation only; it did not alter the landed code.
|
||||||
|
|
||||||
|
## 2026-08-16 (o) — build break in the six-box LRU: `Box` used after its scope closed
|
||||||
|
|
||||||
|
`VoxelDensityOpStack.cpp(832): error C2065: 'Box' : identificateur non déclaré`
|
||||||
|
|
||||||
|
Sol's single-box → six-box refactor changed the acquisition to
|
||||||
|
`FColumnBox& Box = Cache.Acquire(...)` **inside** `if (bIntegerXY)`, but the `Computed` flag is only
|
||||||
|
set *after* the column is computed, further down and **outside** that block. The old single-box
|
||||||
|
version had `Box` at function scope, so the write-back compiled; the reference did not.
|
||||||
|
|
||||||
|
Fixed by hoisting `FColumnBox* AcquiredBox = nullptr;` beside `MemoColumn` and writing back through
|
||||||
|
it. The guard is now `if (AcquiredBox)` rather than `if (bIntegerXY)` — **non-null ⇔ integer path**,
|
||||||
|
so the pointer proves its own safety instead of relying on two conditions staying in agreement.
|
||||||
|
|
||||||
|
Swept the file: the only other `Box.` uses (761–763) are inside the `if` and in scope. Not a logic
|
||||||
|
error and not a caching change — a scope slip, invisible in review because the diff showed both
|
||||||
|
halves separately. Still unbuilt beyond this compile fix.
|
||||||
|
|
||||||
|
## 2026-08-16 (p) — the opt-in diagnostic was failing the test suite; and it answered T1.d
|
||||||
|
|
||||||
|
### The six-box LRU worked
|
||||||
|
|
||||||
|
Jahni: *"column memo miss is quite a fraction of what it used to be."* First qualitative
|
||||||
|
confirmation that the LRU port was the right call — the single-box version wiped 6561 cells on every
|
||||||
|
recenter. **Still owed a same-seed/same-route number before it counts as measured.**
|
||||||
|
|
||||||
|
### The diagnostic broke the tests, for doing its job
|
||||||
|
|
||||||
|
Sol's opt-in warning logs at `Warning`. UE's automation framework counts a `Warning` as a failure,
|
||||||
|
and the `Determinism.*` tests **deliberately build a non-opted-in world** — that world is their
|
||||||
|
comparison oracle, the baseline the `OpStack.*` numbers are measured against. So the diagnostic was
|
||||||
|
failing the suite by correctly reporting an intentional configuration.
|
||||||
|
|
||||||
|
Fixed: verbosity is now `Log` when `GIsAutomationTesting`, `Warning` otherwise. The message is
|
||||||
|
written **once** and only the verbosity branches (`FString::Printf` then log `%s`) — four copies of a
|
||||||
|
long format string across two sites would have been the "one definition, not two kept in sync"
|
||||||
|
failure this project already has rules about. `CoreGlobals.h` added explicitly for
|
||||||
|
`GIsAutomationTesting` rather than relying on transitivity, since there is no shared PCH.
|
||||||
|
|
||||||
|
**Answer to "can I set the tests to use the op stack?" — no, and it must stay that way.** The
|
||||||
|
`Determinism.*` fixture being non-opted-in is not an oversight; it is the oracle. `OpStack.*` runs the
|
||||||
|
opted-in variant. Flipping the fixture would delete the comparison that gives every T1.d number its
|
||||||
|
meaning.
|
||||||
|
|
||||||
|
### ⚠️ AND THE DIAGNOSTIC ANSWERED T1.d — pending one check in the GAME log
|
||||||
|
|
||||||
|
```
|
||||||
|
7/7 cave layout slots have Use Operator Stack disabled
|
||||||
|
cave slot=0..3, 5..7 bUseOperatorStack=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Slot 4 is absent, which is Sol's SurfaceWorld narrowing working as intended.
|
||||||
|
|
||||||
|
Those slots are the **test fixture** (`name=''` ⇒ transient objects). **The line that matters is the
|
||||||
|
same log from the real game.** If it also reports slots disabled, then the belief recorded on
|
||||||
|
2026-08-16 (h) — *"the data assets in game have the switch on"* — is **wrong**, and T1.d has never
|
||||||
|
been enabled in production at all. That single log line settles what a day of counter-reading could
|
||||||
|
not, and it costs nothing to read.
|
||||||
|
|
||||||
|
⇒ **Next action for Jahni: open the game (not the tests) and search the log for
|
||||||
|
`Operator-stack opt-in`.** Whatever it says is the ground truth, and it supersedes both my earlier
|
||||||
|
inference from `Cave Bail Not Op Stack` and the recorded belief about the assets.
|
||||||
|
|
||||||
|
## 2026-08-16 (q) — suite green again, and the FIELD digest proves nothing moved
|
||||||
|
|
||||||
|
14/14 pass, no `StrateManager` warnings in the output — the verbosity fix worked and the diagnostic
|
||||||
|
no longer fails the suite it was meant to inform.
|
||||||
|
|
||||||
|
### The result that matters: every number is IDENTICAL to the pre-change run
|
||||||
|
|
||||||
|
```
|
||||||
|
SHAPE digest 0xAC0294F1C6DD354C FIELD digest 0xF62F3D355B1C0BDB (both unchanged)
|
||||||
|
ClassifyTile Mixed 472 / AllSolid 70 / AllAir 58 (unchanged)
|
||||||
|
OpStack ClassifyTile Mixed 541 / AllSolid 46 / AllAir 13 (unchanged)
|
||||||
|
Shafts 30/60 · Maze 27/60 · Slab 45,43,32/60 · Islands 9 AllAir (unchanged)
|
||||||
|
Tunnel [production] 11 proved / 14641 voxels / 0 violations (unchanged)
|
||||||
|
all eight equivalences bit-identical · 0 violations everywhere
|
||||||
|
```
|
||||||
|
|
||||||
|
**The `FIELD` digest is the strongest evidence available here** — a bit-for-bit hash over 115,000
|
||||||
|
samples of the whole field. It is unchanged, so the six-box LRU, the `DensityCacheOwnerId` key, the
|
||||||
|
bail re-attribution and the scope fix **did not move a single voxel between them.** Four changes to
|
||||||
|
caching, keying and diagnostics, and the world is byte-for-byte the same. That is precisely the
|
||||||
|
contract each of them claimed, now tested rather than asserted.
|
||||||
|
|
||||||
|
⇒ Caching changes are validated. **Their PERFORMANCE benefit is still unmeasured** — Jahni's
|
||||||
|
"quite a fraction of what it used to be" on the column memo is a real observation but not a
|
||||||
|
same-seed/same-route number.
|
||||||
|
|
||||||
|
### The one thing still open, and it costs a log search
|
||||||
|
|
||||||
|
**`Operator-stack opt-in` in the GAME's log.** The fixture reports 7/7 cave slots disabled, which is
|
||||||
|
expected there. Whether the *real* world says the same is the ground truth for T1.d, and it
|
||||||
|
supersedes both my inference from `Cave Bail Not Op Stack` and the belief recorded in (h). Nothing
|
||||||
|
else should be built or reasoned about T1.d until that line is read.
|
||||||
|
|
||||||
|
## 2026-08-16 (r) — ⛔ T1.d's BLOCKER FOUND: out-of-layout chunks are misrouted into the cave branch
|
||||||
|
|
||||||
|
In-game `stat VoxelForge`, flying at full speed, with the split bail counters:
|
||||||
|
|
||||||
|
```
|
||||||
|
Tiles Classified 1.90 Tiles Meshed 1.90 ← nothing skipped
|
||||||
|
Cave Bail Not Op Stack No Layout 1.58 (83% of classified)
|
||||||
|
Cave Bail Stack Verdict 0.27
|
||||||
|
Cave Bail Params 0.05
|
||||||
|
Cave Bail Not Op Stack SoleSlot / BoundaryTile / MixedContent ← NEVER FIRED
|
||||||
|
```
|
||||||
|
|
||||||
|
**Jahni was right and I was wrong to doubt the assets.** `SoleSlot` never fires, so every strate he
|
||||||
|
flies is genuinely opted in. `BoundaryTile` never fires either, so the neighbour theory is dead too.
|
||||||
|
Sol's four-way split earned its keep on its first flight: the old lumped counter said "not op stack"
|
||||||
|
and would have sent us at the assets again.
|
||||||
|
|
||||||
|
### The mechanism, read out of the code
|
||||||
|
|
||||||
|
Two functions disagree about what a chunk outside the strate stack *is*:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// GetGeneratorTypeForChunk — outside all strates (above or below) →
|
||||||
|
return ECaveGeneratorType::TunnelNetwork; // "the fallback density path produces solid rock anyway"
|
||||||
|
|
||||||
|
// IsGapChunk — above the top strate or below the bottom →
|
||||||
|
return false; // "open air, NOT a gap"
|
||||||
|
```
|
||||||
|
|
||||||
|
So for any chunk **above the top strate** (`TopChunkZ = 0`, i.e. all of the open air above the
|
||||||
|
world) `ClassifyTile` sees: not a gap, not SurfaceWorld ⇒ **cave archetype**. It enters the cave
|
||||||
|
branch, calls `UsesOperatorStackForChunk`, `FindSlotIndexForChunkZ` returns −1, and it bails.
|
||||||
|
|
||||||
|
**Every tile touching open air is being classified as a TunnelNetwork cave tile whose strate has the
|
||||||
|
flag off.** That is 83 % of tiles on his route, and it is why `Tiles Classified == Tiles Meshed`.
|
||||||
|
|
||||||
|
⚠️ **Note the two comments contradict each other** — one calls out-of-layout "solid rock via the
|
||||||
|
fallback", the other calls above-stack "open air". **Do not guess which is true**; the correct verdict
|
||||||
|
for those tiles depends on it, and guessing here writes a hole. Read `GetDensityAt`'s no-slot path
|
||||||
|
before choosing.
|
||||||
|
|
||||||
|
### Why this is the whole T1.d story
|
||||||
|
|
||||||
|
The cave branch was never reached for the tiles that dominate a normal flight. T1.d has not been
|
||||||
|
failing — it has been **unreachable**, and for a reason that has nothing to do with the operator
|
||||||
|
stack, the box verdicts, or the asset flags. All of that machinery is correct and tested; it was
|
||||||
|
sitting behind a routing mistake in the classifier's archetype lookup.
|
||||||
|
|
||||||
|
**Next step (not taken — out of budget, and it needs the density question answered first):** make
|
||||||
|
out-of-layout chunks their own category in `ClassifyTile` rather than borrowing `TunnelNetwork`.
|
||||||
|
If open air above the stack is provably uniform, those tiles become an `AllAir`/`AllSolid` verdict
|
||||||
|
and T1.d finally fires on the majority of a surface flight.
|
||||||
|
|
||||||
|
## 2026-08-16 (s) — T1.d BLOCKER FIXED: out-of-layout is constant AIR, not a cave archetype
|
||||||
|
|
||||||
|
The contradictory comments are settled by reading `GetGenerationParams`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// If outside all strates, return negative density → guaranteed air.
|
||||||
|
if (SlotIdx < 0) {
|
||||||
|
Empty.BaseDensity = -1.0f; Empty.WormStrength = 0.0f; Empty.RoomDensity = 0.0f;
|
||||||
|
```
|
||||||
|
|
||||||
|
⇒ **`GetGeneratorTypeForChunk`'s comment ("the fallback density path will produce solid rock
|
||||||
|
anyway") is FALSE.** Out-of-layout is a **constant air field** — no rooms, no worms, nothing to
|
||||||
|
carve or fill it. `IsGapChunk`'s "open air, NOT a gap" was the correct one.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
|
||||||
|
`ClassifyTile` gains a fourth Z category (`MemoCat = 3`) for chunks with no layout slot:
|
||||||
|
|
||||||
|
- sets `bAnyNonCave = true` ⇒ **never enters the cave branch**, which is where all 83 % were dying;
|
||||||
|
- sets `bCanSolid = false` ⇒ the AllSolid hypothesis dies, **AllAir survives**;
|
||||||
|
- bails to `Mixed` if a disturbance could *add* rock there (`BridgeDensity`/`RidgeDensity` > 0) —
|
||||||
|
chasms only carve, so they cannot threaten an air verdict.
|
||||||
|
|
||||||
|
A tile entirely above the stack now resolves: no cave block, no column scan (`NumSlots == 0`),
|
||||||
|
`bCanSolid = false`, `bCanAir = true` ⇒ **`AllAir`, skipped.** That is the majority of a surface
|
||||||
|
flight, and it is a class of tile T1.d has never once been able to prove.
|
||||||
|
|
||||||
|
### What to read after the build
|
||||||
|
|
||||||
|
- **`Cave Bail Not Op Stack No Layout` should collapse to ~0** — those tiles no longer reach the
|
||||||
|
cave branch at all.
|
||||||
|
- **`Tiles Skipped All Air` should rise sharply**, and `Tiles Meshed` should fall below
|
||||||
|
`Tiles Classified` for the first time in the game.
|
||||||
|
- ⚠️ **`violations` must stay 0** in every test, and the eight equivalences must stay bit-identical:
|
||||||
|
this changes only the *classifier*, never a density value.
|
||||||
|
|
||||||
|
Compile-risk spots: `FindSlotIndexForChunkZ` is public (`VoxelStrateManager.h:351`, verified);
|
||||||
|
`FStrateDisturbanceParams` is already used later in the same function; the new `else if` sits before
|
||||||
|
the surface `else`, so `Slots[MemoSlotIdx]` is never dereferenced for the new category.
|
||||||
|
|
||||||
|
## 2026-08-16 (t) — build break: `FindSlotIndexForChunkZ` is protected, not public
|
||||||
|
|
||||||
|
`VoxelGenerator.cpp(2796): C2248 — cannot access protected member`.
|
||||||
|
|
||||||
|
I wrote "`FindSlotIndexForChunkZ` is public (`VoxelStrateManager.h:351`, verified)" in the previous
|
||||||
|
entry. **It is `protected`.** The grep I called verification printed the declaration line and not the
|
||||||
|
access specifier above it — I checked that the symbol existed and reported it as checked for
|
||||||
|
something else. Exactly the "assert a negative from a partial read" mistake made earlier today with
|
||||||
|
the test fixture, in the same shape.
|
||||||
|
|
||||||
|
**Fixed without widening the API:** `GetStrateChunkZBounds` is `public:` (line 177), returns false
|
||||||
|
for precisely the no-slot case, and this same function already calls it three times for the same
|
||||||
|
question. A small `VF_ChunkZHasSlot` lambda wraps it so the `else if` reads as the predicate it is.
|
||||||
|
No accessor promoted, no new surface.
|
||||||
|
|
||||||
|
## 2026-08-16 (u) — ✅✅ T1.d FIRES IN PRODUCTION. 39% of tiles skipped, measured in game.
|
||||||
|
|
||||||
|
```
|
||||||
|
BEFORE (a2c5e02^) AFTER
|
||||||
|
Tiles Classified 1.90 1.88
|
||||||
|
Tiles Meshed 1.90 1.15
|
||||||
|
Tiles Skipped All Air (absent) 0.73
|
||||||
|
Cave Bail Not Op Stack NoLayout 1.58 (GONE)
|
||||||
|
Cave Bail Mixed Content (absent) 0.77
|
||||||
|
```
|
||||||
|
|
||||||
|
`1.15 + 0.73 = 1.88` — the accounting closes exactly.
|
||||||
|
|
||||||
|
**39 % of classified tiles are now skipped, against 0 % this morning.** Each skipped tile avoids
|
||||||
|
~43 000 density evaluations plus marching cubes. This is the first time in the project's history
|
||||||
|
that tile-skipping has been observed in the running game rather than in an automation harness.
|
||||||
|
|
||||||
|
`Cave Bail Not Op Stack No Layout` is gone: those tiles now either resolve `AllAir` or attribute to
|
||||||
|
`Cave Bail Mixed Content` (0.77) — genuinely straddling open air and something else, a correct
|
||||||
|
conservative `Mixed` rather than a misrouting.
|
||||||
|
|
||||||
|
### What is NOT yet firing, stated plainly
|
||||||
|
|
||||||
|
`Tiles Skipped All Solid`, `TilesOpStackSolid` and `TilesOpStackAir` are still absent. **The cave
|
||||||
|
branch still produces no verdicts in game** — its attempts now land in `Cave Bail Mixed Content`
|
||||||
|
(0.77) and `Cave Bail Stack Verdict` (0.32). So the win measured here is the **open-air** class,
|
||||||
|
proved by the classifier's own Z categories; the operator stack's `ClassifyBox` has still never
|
||||||
|
returned a verdict in production. That remains the open item, and the counters now name where to
|
||||||
|
look: tiles straddling content boundaries, and boxes the stack legitimately cannot prove.
|
||||||
|
|
||||||
|
### How this was found, worth keeping
|
||||||
|
|
||||||
|
Every earlier attempt reasoned from a number that could mean four things. It broke open when Sol's
|
||||||
|
four-way split of `Cave Bail Not Op Stack` showed `SoleSlot` and `BoundaryTile` at **zero** — which
|
||||||
|
proved the assets were correctly ticked (Jahni said so twice; I doubted it twice) and pointed at
|
||||||
|
`NoLayout`, a cause nobody had considered. *A counter that can fire for two reasons is not a
|
||||||
|
measurement.*
|
||||||
|
|
||||||
|
## 2026-08-17 (v) — mixed-content split + guarded category fold LANDED, awaiting Jahni's build
|
||||||
|
|
||||||
|
**Nothing in this entry was built, compiled, run in the editor, automation-tested, or measured.**
|
||||||
|
The only validation was static source/diff inspection and `git diff --check`. All rates below are
|
||||||
|
instructions for the next flight, not claimed results.
|
||||||
|
|
||||||
|
### The old 0.77 bucket is split before changing its guard
|
||||||
|
|
||||||
|
`Cave Bail Mixed Content` had three call sites and four meanings. It is replaced by:
|
||||||
|
|
||||||
|
- `Cave Mix Out Of Layout` — a tile contains one cave slot plus constant-air outside the layout;
|
||||||
|
- `Cave Mix Gap` — one cave slot plus solid inter-strate bedrock;
|
||||||
|
- `Cave Mix Surface World` — one cave slot plus a SurfaceWorld slot;
|
||||||
|
- `Cave Bail Two Cave Slots` — a second cave slot was found; this remains an immediate `Mixed` bail.
|
||||||
|
|
||||||
|
The first three are deliberately **presence counters**, incremented once per present category per
|
||||||
|
tile before the later stack guards. They are not exclusive for an unusually tall tile, so they are
|
||||||
|
not expected to sum to the old 0.77; each answers one unambiguous yes/no question. This placement is
|
||||||
|
why the diagnostic survives the fold: a tile can increment `Cave Mix Out Of Layout` and later be
|
||||||
|
proved `AllAir`. `Two Cave Slots` is still a bail counter because that condition was not relaxed.
|
||||||
|
|
||||||
|
The split was applied first with the original `return Mixed` intact. Only after that
|
||||||
|
behavior-preserving split was inspected was the return deliberately replaced by the fold below.
|
||||||
|
|
||||||
|
### Decision: relax one-category, NOT one-cave-slot
|
||||||
|
|
||||||
|
Folding is safe, but only in the narrow form suggested by the code:
|
||||||
|
|
||||||
|
- Gap already contributes a proved `solid` hypothesis, out-of-layout a proved `air` hypothesis,
|
||||||
|
and SurfaceWorld classifies its exact mesher lattice through `ComputeSurfaceColumn`.
|
||||||
|
- The one cave slot records the minimum/maximum **sampled cave Z**. `ClassifyBox` receives that cave
|
||||||
|
Z sub-box with the tile's full XY extent; asking it about the previous full tile box would model
|
||||||
|
gap/surface/out-of-layout with cave params and is not valid.
|
||||||
|
- Opt-in, generator type, and bit-identical params are still checked for **every chunk intersecting
|
||||||
|
that cave sub-box in XYZ**. They were narrowed in Z only to exclude categories represented by
|
||||||
|
their own hypotheses; none was removed or weakened.
|
||||||
|
- The stack verdict folds into the existing `bCanSolid` / `bCanAir`, then SurfaceWorld's exact
|
||||||
|
column scan still runs. `Tiles Operator Stack Solid/Air` now increments only for the final
|
||||||
|
non-`Mixed` tile verdict, after all categories have agreed.
|
||||||
|
|
||||||
|
The independent **one-cave-slot requirement cannot be relaxed by this fold**. A second cave slot can
|
||||||
|
carry a different archetype, opt-in, params, and stack; proving it would require building and
|
||||||
|
guarding a second stack, not stretching the first. `Cave Bail Two Cave Slots` therefore still
|
||||||
|
returns `Mixed`. Category folding is compatible with the generator-type and bit-identical-param
|
||||||
|
guards precisely because those guards remain exhaustive over the sole cave sub-box.
|
||||||
|
|
||||||
|
`Cave Bail Fold Conflict` was added for a proved cave verdict that contradicts an already proved
|
||||||
|
category (for example cave=solid plus out-of-layout=air). That is distinct from `Cave Bail Stack
|
||||||
|
Verdict` (the stack itself could not prove its box) and `Cave Bail Disturbance` (a post-stack feature
|
||||||
|
killed the remaining hypothesis).
|
||||||
|
|
||||||
|
### Exact next-build / next-flight readout
|
||||||
|
|
||||||
|
Read these counters together on the same underground route:
|
||||||
|
|
||||||
|
1. `Cave Mix Out Of Layout`, `Cave Mix Gap`, `Cave Mix Surface World`, and
|
||||||
|
`Cave Bail Two Cave Slots` — whichever is largest names the dominant old mixed-content cause.
|
||||||
|
The first three are presence counts and may overlap; `Cave Bail Mixed Content` should be absent.
|
||||||
|
2. `Tiles Operator Stack Air` and `Tiles Operator Stack Solid` — either becoming non-zero proves a
|
||||||
|
cave stack participated in a **final** skip verdict. For the measured cave+open-air population,
|
||||||
|
the expected success signal is primarily `Tiles Operator Stack Air`.
|
||||||
|
3. `Tiles Skipped All Air`, `Tiles Skipped All Solid`, `Tiles Meshed`, and `Tiles Classified` — the
|
||||||
|
accounting must still close: `Meshed + SkippedAllAir + SkippedAllSolid = Classified`. A fold win
|
||||||
|
raises a skipped counter and lowers `Tiles Meshed`; the pre-fold baseline was 1.15 meshed,
|
||||||
|
0.73 skipped-air, 1.88 classified.
|
||||||
|
4. `Cave Bail Fold Conflict`, `Cave Bail Stack Verdict`, `Cave Bail Params`,
|
||||||
|
`Cave Bail Disturbance`, and `Cave Bail Two Cave Slots` — these explain mixed tiles that remain.
|
||||||
|
In particular, a high `Fold Conflict` means the dominant mix is genuinely heterogeneous rather
|
||||||
|
than blocked merely by the former one-category policy.
|
||||||
|
|
||||||
|
After Jahni runs the suite, every brute-force `violations` count must remain zero and all eight
|
||||||
|
operator-stack equivalences must remain bit-identical. Those are correctness requirements, not
|
||||||
|
results from this unbuilt change.
|
||||||
|
|
||||||
|
### Likely compile-error / review watch spots
|
||||||
|
|
||||||
|
- declaration/definition/use spelling for the five new UE stats, especially
|
||||||
|
`STAT_VoxelForgeCaveBailFoldConflict` and `STAT_VoxelForgeCaveBailTwoCaveSlots`;
|
||||||
|
- the `MAX_int32` / `MIN_int32` cave-extrema initializers and `FMath::Min/Max(int32)` assignments to
|
||||||
|
`CaveMinZ` / `CaveMaxZ`;
|
||||||
|
- construction of `FBox CaveBox` from the cave-only integer Z extrema;
|
||||||
|
- control flow after removing the cave branch's final return: SurfaceWorld columns must run before
|
||||||
|
the shared final verdict, and `bAnyCave` must imply initialized cave extrema (established in the
|
||||||
|
same `MemoCat == 2` iteration);
|
||||||
|
- no protected layout accessor was introduced: the code still uses public
|
||||||
|
`GetStrateChunkZBounds`, never `FindSlotIndexForChunkZ`.
|
||||||
|
|
||||||
|
## 2026-08-16 (v) — the permanent "warning" was noise, and it cost Jahni several sessions of worry
|
||||||
|
|
||||||
|
Jahni: *"[this] have been in a 'warning' stage, is this fine ? is everything fine ?"*
|
||||||
|
|
||||||
|
**Everything was fine.** The only `Warning:` in that whole run is the tunnel test's
|
||||||
|
`[dense fixture] No tile was proved`, whose own text says *"On the DENSE FIXTURE this is the EXPECTED
|
||||||
|
and correct result, not a defect."* The test reports Success; density is bit-identical; every
|
||||||
|
coverage number is non-zero; `violations` is 0; and the `[production defaults]` run directly below
|
||||||
|
reports **11 proved / 14641 voxels / 0 violations**.
|
||||||
|
|
||||||
|
**But a warning that fires on every run and always means "this is fine" is not a warning — it is
|
||||||
|
noise that trains the reader to ignore warnings.** It sat there for several sessions and made him
|
||||||
|
uneasy about a green suite. That is a real cost, and it came from writing diagnostics for a reader
|
||||||
|
who already had the context.
|
||||||
|
|
||||||
|
Fixed: `RunTileScan` takes `bZeroProvedIsExpected`. On the **dense fixture** 0 proved is the only
|
||||||
|
arithmetically possible answer (cull spheres cover that world ~3.6×) ⇒ `AddInfo`. On **production
|
||||||
|
defaults** 0 proved would be a genuine regression from today's 11 ⇒ still `AddWarning`. Same message,
|
||||||
|
same information, but the severity now carries meaning.
|
||||||
|
|
||||||
|
⇒ **General rule for this project's diagnostics:** if a line cannot distinguish a good run from a bad
|
||||||
|
one, it must not be logged at a severity that implies something is wrong. The same defect as the
|
||||||
|
lumped bail counter, one layer out: *a signal that always says the same thing measures nothing.*
|
||||||
|
|
||||||
|
## 2026-08-16 (w) — CLOSED. The operator stack is declared done; direction changes to visible work.
|
||||||
|
|
||||||
|
Jahni built `4d33321` + `91585ea` and reports the game looks fine. He asked for the handoff to be
|
||||||
|
updated and the thread closed.
|
||||||
|
|
||||||
|
**`OPSTACK-HANDOFF.md` fully rewritten in plain language.** The previous version had become a private
|
||||||
|
dialect — his words: *"you've started using very unique terminology, i have NO idea what you've been
|
||||||
|
doing."* That is a failure of the document, not of the reader. The new one leads with what the op
|
||||||
|
stack was for, states plainly that Phase 3 was never built and is not being built, carries the honest
|
||||||
|
three-week ledger, and includes a translation table for the jargon (`T1.d`, `box verdict`, `Mixed`,
|
||||||
|
`violations`).
|
||||||
|
|
||||||
|
### Final state
|
||||||
|
|
||||||
|
- 8/8 archetypes live and bit-identical (115 000-sample field digest unchanged, eight equivalences).
|
||||||
|
- 14/14 tests green through `871ca19`, `violations` 0 everywhere.
|
||||||
|
- **39 % of tiles skipped in the running game**, from 0 % that morning.
|
||||||
|
|
||||||
|
⚠️ **`4d33321` (boundary fold) and `91585ea` (warning demotion) are built but NOT re-verified** — the
|
||||||
|
suite has not been re-run and the counters not re-read since. Recorded in the handoff with the exact
|
||||||
|
two checks and the instruction to `git revert 4d33321` if either fails; the 39 % does not depend on
|
||||||
|
it.
|
||||||
|
|
||||||
|
### Direction, agreed
|
||||||
|
|
||||||
|
**No Phase 3. No further refactoring.** The old `switch` stays as the oracle. Next work is whatever
|
||||||
|
Jahni can see — `fable-idea.md`, F7 set-pieces, F9 audio. The handoff closes on the question that
|
||||||
|
should have been asked in July: *what do you want the world to do that it doesn't?*
|
||||||
|
|
||||||
|
This log stays append-only and stays the record of what was actually built. It ends here for the
|
||||||
|
operator stack.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# PERF-ATTRIBUTION
|
||||||
|
|
||||||
|
Measured 2026-09-12 at `cf0319d` (`experimental`). This was measurement-only: the source changes add
|
||||||
|
diagnostic counters and clocks; they do not change density or meshing decisions. The primary case is
|
||||||
|
the 128^3 `TunnelNetwork`, seed 1337, slot 1, operator-stack setting 1, origin `(-64,-64,-224)`
|
||||||
|
voxels, step 1, 64 canonical 32^3 tiles, and 15 workers.
|
||||||
|
|
||||||
|
`clean` means diagnostics off and `-blockearlyout=0`, with fused evaluation and shared density-grid
|
||||||
|
reuse enabled. The three clean primary runs used an exact-HEAD DLL staged before instrumentation:
|
||||||
|
603.599, 590.746, and 569.627 ms mesh time, mean **587.991 ms**. The requested 0.556 s is therefore
|
||||||
|
within the observed run spread but was not reproduced exactly on this host. `attr` means the final
|
||||||
|
instrumented DLL, `-perfattribution`, sample interval 128. Its wall-clock self-check compares the
|
||||||
|
same export against an unprofiled run in the same process.
|
||||||
|
|
||||||
|
The prescribed build was run successfully with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dotnet "E:\Program Files\Epic Games\UE_5.7\Engine\Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.dll" UnrealEditor Win64 Development "-Project=E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\BuildHost\HostProject\HostProject.uproject" -WaitMutex -FromMsBuild -architecture=x64 -NoUBA "-Log=E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\PerfAttributionBuild.log"
|
||||||
|
```
|
||||||
|
|
||||||
|
The measured process loaded the staged runtime DLL at
|
||||||
|
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\BuildHost\HostProject\Plugins\VoxelForge\Binaries\Win64\UnrealEditor-VoxelForge.dll`, SHA-256
|
||||||
|
`C0CB812BB25EFE8C836DC1C1718C408DA5FE9D9F258B4AE12A6CA846510CA0B2`, length 7,673,856 bytes. The
|
||||||
|
staged editor DLL was `E64C9E80869EFA53FA1556947718DB8E64975ABF6B546657BCFF5D775A2B679A`, length
|
||||||
|
502,784 bytes. The game harness recorded the same runtime path and hash. The primary attribution and
|
||||||
|
final classifier exports returned `status=ok`, geometry CRC `07C14005`, and the required OBJ SHA-256
|
||||||
|
`b3e5f4c398dd0547c6c55f415872058246d4a292c2f0d636ee1cd0829de9b377`.
|
||||||
|
|
||||||
|
The table is the attribution. Only rows marked **additive** are summed. Profiler component rows are
|
||||||
|
sampled nested diagnostics; their CPU and worker-wall figures are evidence about composition, not
|
||||||
|
additional time to add to the wall ledger.
|
||||||
|
|
||||||
|
| Bucket / question | Measurement and mode | Elapsed time, volume, or count | Whole-clock accounting / interpretation |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| Historical comparison | `34f06df` value supplied in the request | 130.000 ms | Historical pre-op-stack reference; the exact old binary was not rebuilt because that commit predates the current editor commandlet and host harness. |
|
||||||
|
| Current clean primary | 3 exports, exact `cf0319d` DLL, diagnostics **off**, fused=1, reuse=1, classifier early-out=0 | **587.991 ms mean**; 569.627–603.599 ms | Clean headline mesh clock. Mean is 4.523x the supplied 130 ms reference. |
|
||||||
|
| Primary attribution self-check | Final DLL, `attr`; profiled 606.128 ms versus same-argument unprofiled 602.341 ms | 1.0063x; +0.629% | Passed. Geometry equal. The profiler did not reproduce the 11.5x inflation seen in the earlier diagnostic mode. |
|
||||||
|
| Full commandlet export | Final DLL, `attr`; mesh plus output and bookkeeping | **1,004.557 ms** | Full export wall, not just mesh generation. The OBJ writer is a material part of this clock. |
|
||||||
|
| Full export: mesh generation | Additive export ledger | 606.128 ms; 60.338% of export | Includes the complete additive mesh ledger below. |
|
||||||
|
| Full export: OBJ/output interval | Additive; includes nested `MeasureExploreMesh` | 394.273 ms; 39.249% | `measure_seconds` is 82.964 ms inside this interval and is not added again. |
|
||||||
|
| Full export: geometry hash | Additive | 2.946 ms; 0.293% | CRC/hash bookkeeping. |
|
||||||
|
| Full export: manifest | Additive | 0.984 ms; 0.098% | Manifest write. |
|
||||||
|
| Full export: remainder | Additive export remainder | 0.225 ms; 0.022% | Export ledger remainder. Mesh + OBJ interval + hash + manifest + remainder = 1,004.557 ms. |
|
||||||
|
| Mesh ledger: preparation | **Additive** | 0.000101 ms; 0.0000% of mesh | No meaningful cost. |
|
||||||
|
| Mesh ledger: shared density-grid fill | **Additive**; 2,248,091 unique shared samples | **590.923 ms; 97.4913% of mesh** | This is the dominant remaining cost. It is the caller-side density-grid stage and includes the canonical fused `GetDensityAt` work. |
|
||||||
|
| Mesh ledger: pre-tile setup | **Additive** | 0.001602 ms; 0.0003% | No meaningful cost. |
|
||||||
|
| Mesh ledger: tile parallel wall | **Additive**; 64 jobs, 15 workers | 10.972 ms; 1.8102% | Per-tile cell scan, interpolation, normals, stream assembly, and task scheduling after the shared grid exists. |
|
||||||
|
| Mesh ledger: tile merge | **Additive** | 4.232 ms; 0.6982% | Cross-tile output merge. |
|
||||||
|
| Mesh ledger: accounting remainder | **Additive** | 0.000097 ms; 0.0000% | Stage sum 606.128205 ms versus mesh clock 606.128301 ms; `sum_matches_mesh=true`. Nothing is hidden in this remainder. |
|
||||||
|
| Mesh ledger total | Final DLL, `attr` | **606.128301 ms** | Complete mesh wall clock. |
|
||||||
|
| `GetDensityAt` parent | Final primary, `attr`; 2,248,091 calls, 17,558 sampled parent intervals, 15 workers | 580.184 ms worker-wall estimate | Nested inside the 590.923 ms density-grid stage. The remaining 10.738 ms is grid-loop/task/read overhead outside the parent scope; it is accounted for by the additive density-grid row. |
|
||||||
|
| Fused evaluator itself | Final primary, `attr`; 17,571 sampled component scopes; 2,248,091 fused tunnel samples | 107.183 ms sampled CPU; 12.301 ms sampled worker wall | Diagnostic sample only, not additive. Detail ran for 1,079,777 samples and was skipped for 1,168,314, about 52.0% of fused lattice samples. |
|
||||||
|
| Fused density core | Final primary, `attr`; sampled nested scope | 108.371 ms sampled CPU; 12.377 ms sampled worker wall | Diagnostic sample only. It is not a 108 ms total bucket and must not be added to the mesh ledger. |
|
||||||
|
| Room/tunnel graph traversal | Final primary counters | Rooms: 11,099,891 candidates / 1,730,920 evaluated; tunnels: 28,951,420 candidates / 3,462,565 evaluated; tunnel-core: 28,951,420 candidates / 1,881,812 evaluated | Room and tunnel candidate traversal is repeated inside the common density work. This is evidence of work volume, not a separately additive clock. |
|
||||||
|
| Room graph build and SDF | Final primary, `attr` | `RoomGraphBuild`: 77 calls, 192.146 ms sampled CPU / 19.323 ms worker wall; `RoomGraphSdf`: 12.101 ms / 0.989 ms sampled CPU/wall | The build is cold/cache construction work; SDF figures are nested samples. The room graph is not a large independent wall bucket on the primary fused route. |
|
||||||
|
| Tunnel-core world evaluation | Final primary, `attr` | 8.857 ms sampled CPU; 0.872 ms sampled worker wall | Nested diagnostic sample. The core traversal is present, but the measured evidence does not support calling it the whole 4.3x. |
|
||||||
|
| Post-stack structural/passage tail | Final primary, `attr`; `GetDensityAt` tail | `StructuralTail`: 20.853 ms CPU / 1.896 ms wall; `DensityStructuralPosts`: 21.909 ms / 1.990 ms; `PassageStructuralPosts`: 10.476 ms / 0.997 ms sampled CPU/wall | This is real current-only post-stack work, but it is a small sampled component relative to the 590.923 ms density-grid wall. All rows are nested and non-additive. Passage modifier was 4.579 ms CPU / 0.404 ms wall; landing-floor and room-floor samples were 1.691 ms / 0.177 ms and 0.665 ms / 0.060 ms. |
|
||||||
|
| Floor/profile work | Final primary counters, fused route; classifier figures from the separate classifier control | Primary support-column builds/queries/checks: 0 / 0 / 0; authored-floor samples: 0; floor-profile builds 2,636; segments 12,624; backstops 6,509 tunnel and 49 room. Classifier control: 211,289 support-column builds, 2,171,763 candidates, 1,370,314 floor queries, 2,296,243 checks, 9,710 authored-floor samples, 97,950 profiles and 442,278 profile segments. | The fused primary route does not execute the classifier’s support-column path. The classifier does, and that work is part of its separate 2.057 s route, not the clean 0.588 s headline. |
|
||||||
|
| Software tunnel cache | Final primary, `attr` | 2,248,043 hits, 48 misses, 77 cache builds | Cache lookup is not the removed verdict cache. The current tunnel cache is overwhelmingly hit; no hardware cache-miss counter was available. |
|
||||||
|
| Classifier absent from the clean headline | Clean exact-HEAD control, diagnostics **off**, classifier early-out=0 | 0 classifier calls | The stated 0.556 s case does not pay interval proofs. It is incorrect to charge classifier time to that baseline. |
|
||||||
|
| Classifier control when enabled | 3 clean exact-HEAD controls, diagnostics **off**, classifier early-out=1 | **2,056.596 ms mean** | +1,468.605 ms versus the clean primary mean; 3.498x the primary. This is a separate, expensive route, not the source of the clean 0.556 s. |
|
||||||
|
| Classifier total self-check | Final DLL, `attr`, classifier early-out=1 | 1,980.835 ms profiled versus 2,013.547 ms unprofiled; 0.9838x | Passed; geometry equal. Profile overhead was -1.62% in this run. |
|
||||||
|
| Interval proofs | Final classifier attribution; 32 sampled calls out of 4,096 | 494,976 refine/box calls; 479,872 whole-mixed, 15,104 whole-solid, 0 whole-air; 63,488 splits; max depth 4 | Proof detail is present and conservative, but its route is costly. The classifier’s scaled diagnostic work was 14,113.190 ms interval-box, 62.221 ms exact-core, and 399.770 ms exact-final microseconds; these are diagnostic work estimates, not additive wall time. |
|
||||||
|
| Classifier repeated room traversal | Final classifier attribution | 2,041,600 room-tail queries, 933,120 evaluated; 6,030,720 exact-core cache hits; 612,864 exact-core samples; 768 exact-final samples | The classifier does repeat room-graph work. Scaled diagnostic work was 11,423.590 ms room propagation, 4,665.818 ms exact primitive, 5,815.002 ms cache-window, and 1,227.366 ms room tail microseconds. |
|
||||||
|
| Shared grid traffic | Final primary, `attr` | 2,744,000 tile-grid samples; 2,248,091 unique shared samples; 495,909 duplicate evaluations before reuse, 0 after | The shared lattice removes duplicate evaluations, but filling the remaining 2.248 M samples is still 590.923 ms. |
|
||||||
|
| Mesher cell classification | Final primary, `attr`; nested sample | 2,097,152 cell calls; 1.344 ms sampled worker wall | The cell loop is not the dominant wall bucket. |
|
||||||
|
| Mesher vertex interpolation | Final primary, `attr`; nested sample | 76,808 calls; 0.012 ms sampled worker wall | Negligible in the observed sample. |
|
||||||
|
| Mesher gradient normals | Final primary, `attr`; nested sample | 76,808 calls; 1.056 ms sampled worker wall | Small nested cost; gradient normals come from the density grid. |
|
||||||
|
| Mesher stream building | Final primary, `attr`; nested sample | 76,808 calls; 3.140 ms sampled worker wall | Small relative to density-grid fill. Runtime stream handoff is not executed by the export commandlet. |
|
||||||
|
| Mesher output and memory | Final primary, `attr` | 82,273 vertices, 153,346 triangles; logical output arrays 7,434,716 bytes; allocated output 8,449,820 bytes; OBJ 17,587,937 bytes; estimated working set 14,851,564 bytes | Byte counters are logical allocator/output traffic, not physical DRAM traffic. |
|
||||||
|
| Grid read/write bytes | Final primary, `attr` | Density-grid bytes 10,976,000; shared-grid read bytes 10,976,000 | The instrumentation sees logical grid traffic. It does not see hardware cache misses, NUMA traffic, or memory-controller events. |
|
||||||
|
| Operator-block scratch/copy | Final primary, `attr` | 0 scratch bytes, 0 copy bytes, 0 builds, 0 operators | The old interpreted block machinery is not on the canonical primary route. |
|
||||||
|
| Operator-block control | Clean diagnostics-off controls with reuse=0 and fused=0 | 2,332.186 ms; 1,566 block builds; 2,486,750 block samples; 31,320 operator visits; 99,470,000 scratch bytes; 101,956,750 copy bytes | This is a deliberately separate block-path control, not the clean fused baseline. It demonstrates the traffic that is absent from the primary route. |
|
||||||
|
| Scalar and fused no-reuse controls | Clean diagnostics-off controls with reuse=0 | Scalar 2,733.080 ms; fused 2,218.132 ms | These controls show the cost of removing shared-grid reuse, but they are not attribution buckets for the primary run. |
|
||||||
|
| What `34f06df` did not do | Read-only source comparison at the historical commit | No active TunnelNetwork operator-stack evaluation, no fused evaluator, no current post-stack tunnel-floor/structural tail, no current editor export commandlet | The old source already had a shared density grid, `ClassifyTile`, marching-cubes cell classification, interpolation, grid-gradient normals, and stream construction. Those are not new current-only work. The old source also had room/tunnel morphology and passage carving, so “rooms/tunnels exist” alone is not a delta. |
|
||||||
|
| Primary operation-level attribution gap | Final primary profiler’s own ledger | Sampled phase sum 14.443 ms versus sampled `GetDensityAt` parent 580.184 ms; sampling gap 565.741 ms | This is the honest limit: component scopes were sampled to keep profiled/unprofiled totals within 0.63%, so their expanded operation-level wall shares are not identifiable from this run. The full mesh wall is nevertheless closed by the additive stage ledger, with a 0.000097 ms remainder. |
|
||||||
|
| Headless game clean check | `PerfAttribGameCleanFinal_20260912`; diagnostics **off**, tile profiling off; 343 LOD0-ready samples | request-to-ready p95 **0.375470 s**; queue wait p95 0.082292 s; generation p95 0.309538 s; collision cook p95 0.052945 s | Secondary check agrees with the earlier conclusion that queue wait is not the main generation constraint. |
|
||||||
|
| Headless game attribution check | `PerfAttribGameAttrFinal_20260912`; diagnostics **on**, tile profiling on; 834 profile lines, 822 applied | request-to-ready p95 **0.395715 s**; queue wait p95 0.083363 s; generation p95 0.315281 s; collision cook p95 0.061387 s | Attribution overhead was modest in the end-to-end game summary. Tile-profile p95 was 1.295847 s, of which classifier was 0.106626 s, mesh 1.284040 s, stream building 0.000361 s, room work 0.008842 s, and stack work 0.012019 s; these game tiles are not the same workload as the single 128^3 export. |
|
||||||
|
|
||||||
|
The surprise is that the clean primary is almost entirely the shared density-grid fill: the ordinary
|
||||||
|
mesher stages after it total only about 15.204 ms. The fused evaluator is genuinely on the canonical
|
||||||
|
path, and the room/tunnel candidate counters show substantial repeated traversal, but the sampled
|
||||||
|
component timers cannot honestly turn that 590.923 ms into exact per-operation percentages without
|
||||||
|
reintroducing the profiler inflation this round was designed to avoid. The classifier is an even
|
||||||
|
larger cost when enabled, yet it is absent from the clean baseline. The old path already performed
|
||||||
|
the grid and marching-cubes phases, so those phases should not be described as newly introduced
|
||||||
|
work; the current-only items are the active fused route and the later structural/floor machinery.
|
||||||
|
|
||||||
|
What could not be attributed: physical allocation-event counts, physical memory/cache misses, and an
|
||||||
|
exact numeric per-operation delta against the old `34f06df` binary. The old commit has no compatible
|
||||||
|
current export commandlet, and rebuilding it would not be the same measurement harness. The complete
|
||||||
|
current clocks, deterministic geometry, clean/profiled comparison, current counters, and all logical
|
||||||
|
memory-byte counters are accounted for above. Existing logs and outputs under `Saved\` were retained.
|
||||||
+1960
File diff suppressed because it is too large.
Load diff
+4
-1
@@ -90,7 +90,10 @@ Legend: ✅ verified against code · ◻️ checklist box.
|
|||||||
like Box/Capsule already did (one diff-layer + remesh dispatch). *(2026-07-04)*
|
like Box/Capsule already did (one diff-layer + remesh dispatch). *(2026-07-04)*
|
||||||
- [x] **`VoxelContentManager.cpp`** — duplicated drain/reset loops → `DrainDecoResults()` +
|
- [x] **`VoxelContentManager.cpp`** — duplicated drain/reset loops → `DrainDecoResults()` +
|
||||||
`ResetGridBuildState(FDecoGrid&)`. *(2026-07-04)*
|
`ResetGridBuildState(FDecoGrid&)`. *(2026-07-04)*
|
||||||
- [ ] **`EVoxelPassageType` vs `EVoxelPassageStyle`** — two overlapping passage-shape enums, both in
|
- [x] **`EVoxelPassageType` vs `EVoxelPassageStyle`** — ✅ **CLOSED 2026-09-04 as WONTFIX**, enacting
|
||||||
|
the 2026-08-16 judgement below, which recommended closure and was then left open by oversight. No
|
||||||
|
code change; the two enums stay.
|
||||||
|
Two overlapping passage-shape enums, both in
|
||||||
active use (11 refs). Consider consolidating to one. *(judgment call, not dead)*
|
active use (11 refs). Consider consolidating to one. *(judgment call, not dead)*
|
||||||
**JUDGED 2026-08-16 — LEAVE THEM. Recommendation: close this item rather than act on it.** They
|
**JUDGED 2026-08-16 — LEAVE THEM. Recommendation: close this item rather than act on it.** They
|
||||||
read as duplicates from the index and are not: `EVoxelPassageType` (`VoxelStrateTypes.h` ~42) is
|
read as duplicates from the index and are not: `EVoxelPassageType` (`VoxelStrateTypes.h` ~42) is
|
||||||
|
|||||||
@@ -19,13 +19,15 @@
|
|||||||
// Cette fonction a DÉJÀ produit cette panne : la v1 de T1.d a été revertée le 2026-06-26 pour une
|
// Cette fonction a DÉJÀ produit cette panne : la v1 de T1.d a été revertée le 2026-06-26 pour une
|
||||||
// borne de plafond pas assez conservative. Jusqu'ici elle n'est validée que par raisonnement.
|
// borne de plafond pas assez conservative. Jusqu'ici elle n'est validée que par raisonnement.
|
||||||
// Ce test la valide par la force brute : pour chaque tuile jugée non-Mixed, on échantillonne le
|
// Ce test la valide par la force brute : pour chaque tuile jugée non-Mixed, on échantillonne le
|
||||||
// treillis EXACT que le mesher aurait échantillonné (marge ±1 incluse) et on vérifie que chaque
|
// treillis des sommets de cellules que ClassifyTile couvre réellement (g = 0..CPA) et on vérifie
|
||||||
// point est bien du côté annoncé.
|
// que chaque point est bien du côté annoncé. La marge extérieure du mesher sert aux normales, pas
|
||||||
|
// au verdict uniforme.
|
||||||
//
|
//
|
||||||
// This function has ALREADY produced that failure: T1.d v1 was reverted on 2026-06-26 over a
|
// This function has ALREADY produced that failure: T1.d v1 was reverted on 2026-06-26 over a
|
||||||
// non-conservative ceiling bound. Until now it was validated by reasoning only. This test
|
// non-conservative ceiling bound. Until now it was validated by reasoning only. This test
|
||||||
// validates it by brute force: for every tile judged non-Mixed, sample the EXACT lattice the
|
// validates it by brute force: for every tile judged non-Mixed, sample the exact cell-vertex
|
||||||
// mesher would have sampled (±1 margin included) and assert every point is on the claimed side.
|
// lattice covered by ClassifyTile (g = 0..CPA) and assert every point is on the claimed side. The
|
||||||
|
// mesher's outer normal halo is deliberately outside the classifier's uniformity contract.
|
||||||
//
|
//
|
||||||
// CONVENTION (VoxelMarchingCubesMesher.cpp:309, IsoLevel == 0):
|
// CONVENTION (VoxelMarchingCubesMesher.cpp:309, IsoLevel == 0):
|
||||||
// D >= 0 ⇒ côté AIR / air side
|
// D >= 0 ⇒ côté AIR / air side
|
||||||
@@ -151,10 +153,9 @@ bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters)
|
|||||||
|
|
||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("ClassifyTile verdicts over %d scanned tiles: Mixed %d, AllSolid %d, AllAir %d ")
|
TEXT("ClassifyTile verdicts over %d scanned tiles: Mixed %d, AllSolid %d, AllAir %d ")
|
||||||
TEXT("(brute-forcing %d of them). NOTE: cave archetypes always return Mixed today — see ")
|
TEXT("(brute-forcing %d of them). Cave archetypes now use the opt-in operator-stack ")
|
||||||
TEXT("VoxelGenerator.cpp \"archétype cave [...] pas prouvable en v1\". A low non-Mixed count ")
|
TEXT("ClassifyBox fold; the global XY edge proof also skips proven outer-shell tiles. A low ")
|
||||||
TEXT("is expected and is exactly the tile-skipping prize OPSTACK-PLAN wants EffectOverBox ")
|
TEXT("non-Mixed count is expected: every uncertain case remains Mixed for safety."),
|
||||||
TEXT("to unlock."),
|
|
||||||
NumTilesScanned, NumMixed, NumAllSolid, NumAllAir, ToVerify.Num()));
|
NumTilesScanned, NumMixed, NumAllSolid, NumAllAir, ToVerify.Num()));
|
||||||
|
|
||||||
if (ToVerify.Num() == 0)
|
if (ToVerify.Num() == 0)
|
||||||
@@ -167,8 +168,10 @@ bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Vérification par force brute, sur le treillis EXACT du mesher. ──
|
// ── Vérification par force brute du contrat de ClassifyTile. ──
|
||||||
// Les bornes reproduisent ClassifyTile / GenerateMesh : g ∈ [-1, Cells+1] par axe.
|
// ClassifyTile covers actual cell vertices only: g ∈ [0, CPA]. GenerateMesh may sample a
|
||||||
|
// one-vertex outer halo for central-difference normals, but that halo cannot create a cell and
|
||||||
|
// is intentionally not part of the uniform verdict.
|
||||||
int32 NumHoles = 0;
|
int32 NumHoles = 0;
|
||||||
for (const FTileSpec& Spec : ToVerify)
|
for (const FTileSpec& Spec : ToVerify)
|
||||||
{
|
{
|
||||||
@@ -176,13 +179,13 @@ bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters)
|
|||||||
if (Verdict == EVoxelTileClass::Mixed) { continue; } // verdict instable ⇒ rien à prouver
|
if (Verdict == EVoxelTileClass::Mixed) { continue; } // verdict instable ⇒ rien à prouver
|
||||||
|
|
||||||
const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE);
|
const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE);
|
||||||
const int32 GridDim = CPA + 1;
|
const int32 VertexCount = CPA + 1;
|
||||||
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||||
|
|
||||||
bool bTileBad = false;
|
bool bTileBad = false;
|
||||||
for (int32 gz = -1; gz <= GridDim && !bTileBad; ++gz)
|
for (int32 gz = 0; gz < VertexCount && !bTileBad; ++gz)
|
||||||
for (int32 gy = -1; gy <= GridDim && !bTileBad; ++gy)
|
for (int32 gy = 0; gy < VertexCount && !bTileBad; ++gy)
|
||||||
for (int32 gx = -1; gx <= GridDim && !bTileBad; ++gx)
|
for (int32 gx = 0; gx < VertexCount && !bTileBad; ++gx)
|
||||||
{
|
{
|
||||||
const float X = (float)(Spec.Origin.X + gx * Spec.Step);
|
const float X = (float)(Spec.Origin.X + gx * Spec.Step);
|
||||||
const float Y = (float)(Spec.Origin.Y + gy * Spec.Step);
|
const float Y = (float)(Spec.Origin.Y + gy * Spec.Step);
|
||||||
@@ -200,8 +203,8 @@ bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters)
|
|||||||
TEXT("HOLE: ClassifyTile said %s for tile origin (%d,%d,%d) Step=%d Cells=%d, ")
|
TEXT("HOLE: ClassifyTile said %s for tile origin (%d,%d,%d) Step=%d Cells=%d, ")
|
||||||
TEXT("but GetDensityAt(%.0f, %.0f, %.0f) = %.6g is on the %s side. This tile ")
|
TEXT("but GetDensityAt(%.0f, %.0f, %.0f) = %.6g is on the %s side. This tile ")
|
||||||
TEXT("would be skipped by the mesher: no triangles and NO COLLISION where there ")
|
TEXT("would be skipped by the mesher: no triangles and NO COLLISION where there ")
|
||||||
TEXT("should be a surface. Find which guard in ClassifyTile failed to fire for ")
|
TEXT("should be a surface. This point is inside ClassifyTile's actual cell ")
|
||||||
TEXT("the feature at that point."),
|
TEXT("lattice, so find which classifier guard failed to fire for the feature."),
|
||||||
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||||
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, Spec.Cells,
|
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, Spec.Cells,
|
||||||
X, Y, Z, D, (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
|
X, Y, Z, D, (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
|
||||||
@@ -337,13 +340,13 @@ bool FVoxelForgeOpStackClassifyTileTest::RunTest(const FString& Parameters)
|
|||||||
if (Verdict == EVoxelTileClass::Mixed) { continue; }
|
if (Verdict == EVoxelTileClass::Mixed) { continue; }
|
||||||
|
|
||||||
const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE);
|
const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE);
|
||||||
const int32 GridDim = CPA + 1;
|
const int32 VertexCount = CPA + 1;
|
||||||
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||||
|
|
||||||
bool bTileBad = false;
|
bool bTileBad = false;
|
||||||
for (int32 gz = -1; gz <= GridDim && !bTileBad; ++gz)
|
for (int32 gz = 0; gz < VertexCount && !bTileBad; ++gz)
|
||||||
for (int32 gy = -1; gy <= GridDim && !bTileBad; ++gy)
|
for (int32 gy = 0; gy < VertexCount && !bTileBad; ++gy)
|
||||||
for (int32 gx = -1; gx <= GridDim && !bTileBad; ++gx)
|
for (int32 gx = 0; gx < VertexCount && !bTileBad; ++gx)
|
||||||
{
|
{
|
||||||
const float X = (float)(Spec.Origin.X + gx * Spec.Step);
|
const float X = (float)(Spec.Origin.X + gx * Spec.Step);
|
||||||
const float Y = (float)(Spec.Origin.Y + gy * Spec.Step);
|
const float Y = (float)(Spec.Origin.Y + gy * Spec.Step);
|
||||||
@@ -578,15 +581,14 @@ bool FVoxelForgeOpFoldTest::RunTest(const FString& Parameters)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Une marge NULLE avec une amplitude NULLE : le carve ne retire rien, mais `0 > 0` est faux,
|
// 8. Une amplitude nulle est une identité prouvée : elle ne doit pas détruire une hypothèse
|
||||||
// donc l'hypothèse meurt quand même. C'est voulu — une marge inconnue reste inconnue, et un
|
// AllSolid déjà forcée, même si sa marge numérique vaut zéro.
|
||||||
// opérateur qui ne fait rien devrait rendre `Identity`, pas `CarveOnly` d'amplitude 0.
|
|
||||||
{
|
{
|
||||||
FVoxelBoxHypotheses H;
|
FVoxelBoxHypotheses H;
|
||||||
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 0.0f);
|
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 0.0f);
|
||||||
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.0f, 0.0f);
|
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.0f, 0.0f);
|
||||||
TestEqual(TEXT("zero margin dies even to a zero carve -- unknown is not zero"),
|
TestEqual(TEXT("a proven zero carve preserves an AllSolid verdict"),
|
||||||
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
|
(int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
|
||||||
|
#include "VoxelClipmapDesiredTiles.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
using VoxelClipmapDesiredTiles::FParameters;
|
||||||
|
|
||||||
|
struct FOverlapMeasure
|
||||||
|
{
|
||||||
|
uint64 PairCount = 0;
|
||||||
|
uint64 PairwiseVolumeChunkCubed = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
static FOverlapMeasure MeasureOverlaps(const TArray<FVoxelTileKey>& Tiles)
|
||||||
|
{
|
||||||
|
TSet<FVoxelTileKey> TileSet;
|
||||||
|
int32 MaxLevel = 0;
|
||||||
|
for (const FVoxelTileKey& Tile : Tiles)
|
||||||
|
{
|
||||||
|
TileSet.Add(Tile);
|
||||||
|
MaxLevel = FMath::Max(MaxLevel, Tile.Level);
|
||||||
|
}
|
||||||
|
|
||||||
|
FOverlapMeasure Result;
|
||||||
|
for (const FVoxelTileKey& Fine : Tiles)
|
||||||
|
{
|
||||||
|
for (int32 CoarseLevel = Fine.Level + 1; CoarseLevel <= MaxLevel; ++CoarseLevel)
|
||||||
|
{
|
||||||
|
const int32 Divisor = 1 << (CoarseLevel - Fine.Level);
|
||||||
|
const FVoxelTileKey Coarse(
|
||||||
|
VoxelClipmapDesiredTiles::FloorDiv(Fine.Coord, Divisor), CoarseLevel);
|
||||||
|
if (TileSet.Contains(Coarse))
|
||||||
|
{
|
||||||
|
++Result.PairCount;
|
||||||
|
Result.PairwiseVolumeChunkCubed += (uint64(1) << (3 * Fine.Level));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsFootprintCovered(const FVoxelTileKey& Tile,
|
||||||
|
const TSet<FVoxelTileKey>& CoveringTiles,
|
||||||
|
int32 MaxLevel, int32 MinChunkZ, int32 MaxChunkZ)
|
||||||
|
{
|
||||||
|
const int32 TileMinZ = Tile.Coord.Z * (1 << Tile.Level);
|
||||||
|
const int32 TileMaxZ = (Tile.Coord.Z + 1) * (1 << Tile.Level) - 1;
|
||||||
|
if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CoveringTiles.Contains(Tile))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A coarser dyadic tile covers this whole footprint when it is an ancestor.
|
||||||
|
for (int32 ParentLevel = Tile.Level + 1; ParentLevel <= MaxLevel; ++ParentLevel)
|
||||||
|
{
|
||||||
|
const int32 Divisor = 1 << (ParentLevel - Tile.Level);
|
||||||
|
const FVoxelTileKey Parent(
|
||||||
|
VoxelClipmapDesiredTiles::FloorDiv(Tile.Coord, Divisor), ParentLevel);
|
||||||
|
if (CoveringTiles.Contains(Parent))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Tile.Level == 0)
|
||||||
|
{
|
||||||
|
return Tile.Coord.Z < MinChunkZ || Tile.Coord.Z > MaxChunkZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int32 ChildLevel = Tile.Level - 1;
|
||||||
|
const FIntVector ChildBase = Tile.Coord * 2;
|
||||||
|
for (int32 Z = 0; Z < 2; ++Z)
|
||||||
|
for (int32 Y = 0; Y < 2; ++Y)
|
||||||
|
for (int32 X = 0; X < 2; ++X)
|
||||||
|
{
|
||||||
|
if (!IsFootprintCovered(
|
||||||
|
FVoxelTileKey(ChildBase + FIntVector(X, Y, Z), ChildLevel),
|
||||||
|
CoveringTiles, MaxLevel, MinChunkZ, MaxChunkZ))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool CoversEveryLegacyTile(const TArray<FVoxelTileKey>& Legacy,
|
||||||
|
const TArray<FVoxelTileKey>& Current,
|
||||||
|
int32 MinChunkZ, int32 MaxChunkZ)
|
||||||
|
{
|
||||||
|
TSet<FVoxelTileKey> CurrentSet;
|
||||||
|
int32 MaxLevel = 0;
|
||||||
|
for (const FVoxelTileKey& Tile : Current)
|
||||||
|
{
|
||||||
|
CurrentSet.Add(Tile);
|
||||||
|
MaxLevel = FMath::Max(MaxLevel, Tile.Level);
|
||||||
|
}
|
||||||
|
for (const FVoxelTileKey& Tile : Legacy)
|
||||||
|
{
|
||||||
|
if (!IsFootprintCovered(Tile, CurrentSet, MaxLevel, MinChunkZ, MaxChunkZ))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frozen copy of the 7486c29 selector. It is the coverage oracle and keeps the regression
|
||||||
|
// test able to prove that an exact tiling still covers every volume the old selector covered.
|
||||||
|
static void Build7486c29(const FIntVector& Center, int32 MinChunkZ, int32 MaxChunkZ,
|
||||||
|
const FParameters& Parameters, TArray<FVoxelTileKey>& OutTiles)
|
||||||
|
{
|
||||||
|
OutTiles.Reset();
|
||||||
|
const int32 Radius = FMath::Max(1, Parameters.ClipRadius);
|
||||||
|
const int32 MaxLevel = FMath::Clamp(Parameters.MaxClipLevel, 0, 8);
|
||||||
|
const int32 OuterRadius = VoxelClipmapDesiredTiles::OuterShellRadius(
|
||||||
|
Parameters, Radius, MaxLevel);
|
||||||
|
int32 SheetLevel = MaxLevel;
|
||||||
|
int32 SheetRadius = OuterRadius;
|
||||||
|
VoxelClipmapDesiredTiles::OuterShell(
|
||||||
|
Parameters, Radius, MaxLevel, SheetLevel, SheetRadius);
|
||||||
|
const bool bSheetRing = SheetLevel > MaxLevel;
|
||||||
|
const int32 MaxMCRingLevel = bSheetRing ? SheetLevel - 1 : MaxLevel;
|
||||||
|
|
||||||
|
for (int32 Level = 0; Level <= MaxMCRingLevel; ++Level)
|
||||||
|
{
|
||||||
|
const int32 Pow = 1 << Level;
|
||||||
|
const FIntVector CenterLevel = VoxelClipmapDesiredTiles::FloorDiv(Center, Pow);
|
||||||
|
const FIntVector CenterFiner = Level > 0
|
||||||
|
? VoxelClipmapDesiredTiles::FloorDiv(Center, Pow >> 1)
|
||||||
|
: FIntVector::ZeroValue;
|
||||||
|
const int32 LevelRadius = (Level == MaxLevel && !bSheetRing)
|
||||||
|
? OuterRadius : Radius;
|
||||||
|
|
||||||
|
int32 DeltaZMin = -LevelRadius;
|
||||||
|
int32 DeltaZMax = LevelRadius;
|
||||||
|
if (LevelRadius > Radius && MinChunkZ != MIN_int32)
|
||||||
|
{
|
||||||
|
DeltaZMin = FMath::Max(
|
||||||
|
DeltaZMin, VoxelClipmapDesiredTiles::FloorDiv(MinChunkZ, Pow) - CenterLevel.Z);
|
||||||
|
DeltaZMax = FMath::Min(
|
||||||
|
DeltaZMax, VoxelClipmapDesiredTiles::FloorDiv(MaxChunkZ, Pow) - CenterLevel.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 DeltaZ = DeltaZMin; DeltaZ <= DeltaZMax; ++DeltaZ)
|
||||||
|
for (int32 DeltaY = -LevelRadius; DeltaY <= LevelRadius; ++DeltaY)
|
||||||
|
for (int32 DeltaX = -LevelRadius; DeltaX <= LevelRadius; ++DeltaX)
|
||||||
|
{
|
||||||
|
const FIntVector Tile = CenterLevel + FIntVector(DeltaX, DeltaY, DeltaZ);
|
||||||
|
const int32 TileMinZ = Tile.Z << Level;
|
||||||
|
const int32 TileMaxZ = ((Tile.Z + 1) << Level) - 1;
|
||||||
|
if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Level > 0)
|
||||||
|
{
|
||||||
|
const bool bCovered =
|
||||||
|
(2 * Tile.X >= CenterFiner.X - Radius)
|
||||||
|
&& (2 * Tile.X + 1 <= CenterFiner.X + Radius)
|
||||||
|
&& (2 * Tile.Y >= CenterFiner.Y - Radius)
|
||||||
|
&& (2 * Tile.Y + 1 <= CenterFiner.Y + Radius)
|
||||||
|
&& (2 * Tile.Z >= CenterFiner.Z - Radius)
|
||||||
|
&& (2 * Tile.Z + 1 <= CenterFiner.Z + Radius);
|
||||||
|
if (bCovered)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutTiles.Emplace(Tile, Level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bSheetRing)
|
||||||
|
{
|
||||||
|
const int32 SheetPow = 1 << SheetLevel;
|
||||||
|
const FIntVector CenterSheet = VoxelClipmapDesiredTiles::FloorDiv(Center, SheetPow);
|
||||||
|
const FIntVector CenterMC = VoxelClipmapDesiredTiles::FloorDiv(
|
||||||
|
Center, 1 << MaxMCRingLevel);
|
||||||
|
const int32 LevelDelta = SheetLevel - MaxMCRingLevel;
|
||||||
|
|
||||||
|
int32 DeltaZMin = -SheetRadius;
|
||||||
|
int32 DeltaZMax = SheetRadius;
|
||||||
|
if (MinChunkZ != MIN_int32)
|
||||||
|
{
|
||||||
|
DeltaZMin = FMath::Max(DeltaZMin,
|
||||||
|
VoxelClipmapDesiredTiles::FloorDiv(MinChunkZ, SheetPow) - CenterSheet.Z);
|
||||||
|
DeltaZMax = FMath::Min(DeltaZMax,
|
||||||
|
VoxelClipmapDesiredTiles::FloorDiv(MaxChunkZ, SheetPow) - CenterSheet.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 DeltaZ = DeltaZMin; DeltaZ <= DeltaZMax; ++DeltaZ)
|
||||||
|
for (int32 DeltaY = -SheetRadius; DeltaY <= SheetRadius; ++DeltaY)
|
||||||
|
for (int32 DeltaX = -SheetRadius; DeltaX <= SheetRadius; ++DeltaX)
|
||||||
|
{
|
||||||
|
const FIntVector Tile = CenterSheet + FIntVector(DeltaX, DeltaY, DeltaZ);
|
||||||
|
const int32 TileMinZ = Tile.Z << SheetLevel;
|
||||||
|
const int32 TileMaxZ = ((Tile.Z + 1) << SheetLevel) - 1;
|
||||||
|
if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool bCovered =
|
||||||
|
((Tile.X << LevelDelta) >= CenterMC.X - Radius)
|
||||||
|
&& ((((Tile.X + 1) << LevelDelta) - 1) <= CenterMC.X + Radius)
|
||||||
|
&& ((Tile.Y << LevelDelta) >= CenterMC.Y - Radius)
|
||||||
|
&& ((((Tile.Y + 1) << LevelDelta) - 1) <= CenterMC.Y + Radius)
|
||||||
|
&& ((Tile.Z << LevelDelta) >= CenterMC.Z - Radius)
|
||||||
|
&& ((((Tile.Z + 1) << LevelDelta) - 1) <= CenterMC.Z + Radius);
|
||||||
|
if (!bCovered)
|
||||||
|
{
|
||||||
|
OutTiles.Emplace(Tile, SheetLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static FString DescribeMeasure(const FOverlapMeasure& Measure)
|
||||||
|
{
|
||||||
|
return FString::Printf(TEXT("pairs=%llu pairwise_volume_chunk3=%llu"),
|
||||||
|
static_cast<unsigned long long>(Measure.PairCount),
|
||||||
|
static_cast<unsigned long long>(Measure.PairwiseVolumeChunkCubed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeClipmapNoOverlapTest,
|
||||||
|
"VoxelForge.Streaming.ClipmapDesiredTilesNoOverlapCoverage",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeClipmapNoOverlapTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
(void)Parameters;
|
||||||
|
|
||||||
|
FParameters Owner;
|
||||||
|
Owner.ClipRadius = 3;
|
||||||
|
Owner.MaxClipLevel = 5;
|
||||||
|
Owner.RenderDistanceChunks = 768;
|
||||||
|
Owner.bFarSheetRing = false;
|
||||||
|
|
||||||
|
FParameters Defaults;
|
||||||
|
|
||||||
|
TArray<FVoxelTileKey> LegacyOwner, CurrentOwner, LegacyDefaults, CurrentDefaults;
|
||||||
|
Build7486c29(FIntVector::ZeroValue, MIN_int32, MAX_int32, Owner, LegacyOwner);
|
||||||
|
VoxelClipmapDesiredTiles::Build(
|
||||||
|
FIntVector::ZeroValue, MIN_int32, MAX_int32, Owner, CurrentOwner);
|
||||||
|
Build7486c29(FIntVector::ZeroValue, MIN_int32, MAX_int32, Defaults, LegacyDefaults);
|
||||||
|
VoxelClipmapDesiredTiles::Build(
|
||||||
|
FIntVector::ZeroValue, MIN_int32, MAX_int32, Defaults, CurrentDefaults);
|
||||||
|
|
||||||
|
const FOverlapMeasure LegacyOwnerOverlap = MeasureOverlaps(LegacyOwner);
|
||||||
|
const FOverlapMeasure CurrentOwnerOverlap = MeasureOverlaps(CurrentOwner);
|
||||||
|
const FOverlapMeasure LegacyDefaultOverlap = MeasureOverlaps(LegacyDefaults);
|
||||||
|
const FOverlapMeasure CurrentDefaultOverlap = MeasureOverlaps(CurrentDefaults);
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("owner center=(0,0,0): before tiles=%d %s; after tiles=%d %s"),
|
||||||
|
LegacyOwner.Num(), *DescribeMeasure(LegacyOwnerOverlap),
|
||||||
|
CurrentOwner.Num(), *DescribeMeasure(CurrentOwnerOverlap)));
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("FParameters defaults center=(0,0,0): before tiles=%d %s; after tiles=%d %s"),
|
||||||
|
LegacyDefaults.Num(), *DescribeMeasure(LegacyDefaultOverlap),
|
||||||
|
CurrentDefaults.Num(), *DescribeMeasure(CurrentDefaultOverlap)));
|
||||||
|
|
||||||
|
TestTrue(TEXT("7486c29 owner overlap measurement matches the checked source geometry"),
|
||||||
|
LegacyOwnerOverlap.PairCount == 635
|
||||||
|
&& LegacyOwnerOverlap.PairwiseVolumeChunkCubed == 594487);
|
||||||
|
TestTrue(TEXT("7486c29 FParameters-default overlap measurement matches the checked source geometry"),
|
||||||
|
LegacyDefaultOverlap.PairCount == 508
|
||||||
|
&& LegacyDefaultOverlap.PairwiseVolumeChunkCubed == 74295);
|
||||||
|
TestTrue(TEXT("owner desired set has no overlapping level pairs"),
|
||||||
|
CurrentOwnerOverlap.PairCount == 0 && CurrentOwnerOverlap.PairwiseVolumeChunkCubed == 0);
|
||||||
|
TestTrue(TEXT("default desired set has no overlapping level pairs"),
|
||||||
|
CurrentDefaultOverlap.PairCount == 0 && CurrentDefaultOverlap.PairwiseVolumeChunkCubed == 0);
|
||||||
|
TestTrue(TEXT("owner coverage is identical or larger than 7486c29"),
|
||||||
|
CoversEveryLegacyTile(LegacyOwner, CurrentOwner, MIN_int32, MAX_int32));
|
||||||
|
TestTrue(TEXT("default coverage is identical or larger than 7486c29"),
|
||||||
|
CoversEveryLegacyTile(LegacyDefaults, CurrentDefaults, MIN_int32, MAX_int32));
|
||||||
|
|
||||||
|
struct FSweepSettings
|
||||||
|
{
|
||||||
|
int32 Radius;
|
||||||
|
int32 MaxLevel;
|
||||||
|
int32 RenderDistance;
|
||||||
|
bool bSheet;
|
||||||
|
int32 SheetSpan;
|
||||||
|
};
|
||||||
|
static const FSweepSettings SweepSettings[] = {
|
||||||
|
{1, 0, 0, false, 2},
|
||||||
|
{2, 2, 0, false, 2},
|
||||||
|
{3, 4, 0, false, 2},
|
||||||
|
{2, 3, 49, false, 2},
|
||||||
|
{2, 3, 49, true, 2},
|
||||||
|
{2, 4, 87, true, 3},
|
||||||
|
};
|
||||||
|
|
||||||
|
int32 SweptCases = 0;
|
||||||
|
int32 OverlapCases = 0;
|
||||||
|
int32 CoverageFailures = 0;
|
||||||
|
uint64 TotalOverlapPairs = 0;
|
||||||
|
FString FirstFailure;
|
||||||
|
const int32 CenterMin = -3;
|
||||||
|
const int32 CenterMax = 3;
|
||||||
|
for (int32 Z = CenterMin; Z <= CenterMax; ++Z)
|
||||||
|
for (int32 Y = CenterMin; Y <= CenterMax; ++Y)
|
||||||
|
for (int32 X = CenterMin; X <= CenterMax; ++X)
|
||||||
|
for (const FSweepSettings& Sweep : SweepSettings)
|
||||||
|
{
|
||||||
|
FParameters P;
|
||||||
|
P.ClipRadius = Sweep.Radius;
|
||||||
|
P.MaxClipLevel = Sweep.MaxLevel;
|
||||||
|
P.RenderDistanceChunks = Sweep.RenderDistance;
|
||||||
|
P.bFarSheetRing = Sweep.bSheet;
|
||||||
|
P.FarSheetSpanLevels = Sweep.SheetSpan;
|
||||||
|
|
||||||
|
// Exercise the unbounded view, a tight local clamp, and a clamp crossing a tile boundary.
|
||||||
|
for (int32 ClampCase = 0; ClampCase < 3; ++ClampCase)
|
||||||
|
{
|
||||||
|
int32 MinZ = MIN_int32;
|
||||||
|
int32 MaxZ = MAX_int32;
|
||||||
|
if (ClampCase == 1)
|
||||||
|
{
|
||||||
|
MinZ = Z - 2;
|
||||||
|
MaxZ = Z + 3;
|
||||||
|
}
|
||||||
|
else if (ClampCase == 2)
|
||||||
|
{
|
||||||
|
const int32 Band = VoxelClipmapDesiredTiles::FloorDiv(Z, 1 << Sweep.MaxLevel);
|
||||||
|
MinZ = (Band << Sweep.MaxLevel) - 1;
|
||||||
|
MaxZ = MinZ + 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIntVector Center(X, Y, Z);
|
||||||
|
TArray<FVoxelTileKey> Legacy, Current;
|
||||||
|
Build7486c29(Center, MinZ, MaxZ, P, Legacy);
|
||||||
|
VoxelClipmapDesiredTiles::Build(Center, MinZ, MaxZ, P, Current);
|
||||||
|
++SweptCases;
|
||||||
|
|
||||||
|
const FOverlapMeasure CurrentOverlap = MeasureOverlaps(Current);
|
||||||
|
if (CurrentOverlap.PairCount != 0)
|
||||||
|
{
|
||||||
|
++OverlapCases;
|
||||||
|
TotalOverlapPairs += CurrentOverlap.PairCount;
|
||||||
|
if (FirstFailure.IsEmpty())
|
||||||
|
{
|
||||||
|
FirstFailure = FString::Printf(
|
||||||
|
TEXT("overlap center=(%d,%d,%d) radius=%d level=%d distance=%d sheet=%d clamp=%d: %s"),
|
||||||
|
X, Y, Z, Sweep.Radius, Sweep.MaxLevel, Sweep.RenderDistance,
|
||||||
|
Sweep.bSheet ? 1 : 0, ClampCase, *DescribeMeasure(CurrentOverlap));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!CoversEveryLegacyTile(Legacy, Current, MinZ, MaxZ))
|
||||||
|
{
|
||||||
|
++CoverageFailures;
|
||||||
|
if (FirstFailure.IsEmpty())
|
||||||
|
{
|
||||||
|
FirstFailure = FString::Printf(
|
||||||
|
TEXT("coverage center=(%d,%d,%d) radius=%d level=%d distance=%d sheet=%d clamp=%d"),
|
||||||
|
X, Y, Z, Sweep.Radius, Sweep.MaxLevel, Sweep.RenderDistance,
|
||||||
|
Sweep.bSheet ? 1 : 0, ClampCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("sweep cases=%d centers=343 settings=%d clamps=3 overlap_cases=%d overlap_pairs=%llu coverage_failures=%d first=%s"),
|
||||||
|
SweptCases, UE_ARRAY_COUNT(SweepSettings), OverlapCases,
|
||||||
|
static_cast<unsigned long long>(TotalOverlapPairs), CoverageFailures,
|
||||||
|
FirstFailure.IsEmpty() ? TEXT("none") : *FirstFailure));
|
||||||
|
TestEqual(TEXT("all swept desired sets have zero overlapping pairs"), OverlapCases, 0);
|
||||||
|
TestEqual(TEXT("all swept desired sets cover the legacy footprint"), CoverageFailures, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
@@ -0,0 +1,802 @@
|
|||||||
|
// Corpus-free composer measurement: current corpus blend versus independent and constrained rolls.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
#include "HAL/PlatformTime.h"
|
||||||
|
#include "Math/RandomStream.h"
|
||||||
|
|
||||||
|
#include "VoxelForgeTestFixture.h"
|
||||||
|
#include "VoxelDensityOpStack.h"
|
||||||
|
#include "VoxelSettings.h"
|
||||||
|
#include "VoxelStrateComposer.h"
|
||||||
|
#include "VoxelStrateDefinition.h"
|
||||||
|
#include "VoxelStrateMeasure.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// 256x3 and 64x3 were attempted first. The latter exceeded 27 minutes before reaching
|
||||||
|
// its aggregate report because the fixed 8M-cell law/box checks are expensive for the
|
||||||
|
// corpus-free fields. Keep the final reduced count equal across all three arms.
|
||||||
|
constexpr int32 NumCandidates = 16;
|
||||||
|
constexpr float LargestComponentSurvivalThreshold = 0.50f;
|
||||||
|
constexpr int32 BoxChecksPerCandidate = 40;
|
||||||
|
constexpr int32 BoxStep = 1;
|
||||||
|
constexpr int32 BoxCells = 8;
|
||||||
|
|
||||||
|
const TCHAR* VF_ConnectivityName(EVoxelConnectivityResult Result)
|
||||||
|
{
|
||||||
|
switch (Result)
|
||||||
|
{
|
||||||
|
case EVoxelConnectivityResult::Connected: return TEXT("Connected");
|
||||||
|
case EVoxelConnectivityResult::NotConnectedAtThisResolution: return TEXT("NotConnected");
|
||||||
|
case EVoxelConnectivityResult::StartCellSolid: return TEXT("StartCellSolid");
|
||||||
|
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid");
|
||||||
|
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow");
|
||||||
|
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudget");
|
||||||
|
case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit");
|
||||||
|
case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit");
|
||||||
|
}
|
||||||
|
return TEXT("Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 VF_FloorDiv(int32 A, int32 B)
|
||||||
|
{
|
||||||
|
const int32 Q = A / B;
|
||||||
|
const int32 R = A % B;
|
||||||
|
return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FBlockSpec
|
||||||
|
{
|
||||||
|
EVoxelStrateParamBlock Block;
|
||||||
|
ECaveGeneratorType Archetype;
|
||||||
|
uint32 SeedSalt;
|
||||||
|
};
|
||||||
|
|
||||||
|
static const FBlockSpec GBlockSpecs[] =
|
||||||
|
{
|
||||||
|
{ EVoxelStrateParamBlock::TunnelNetwork, ECaveGeneratorType::TunnelNetwork, 0x1001u },
|
||||||
|
{ EVoxelStrateParamBlock::Slab, ECaveGeneratorType::FlatPlain, 0x1003u },
|
||||||
|
{ EVoxelStrateParamBlock::Maze, ECaveGeneratorType::Maze, 0x1005u },
|
||||||
|
{ EVoxelStrateParamBlock::Surface, ECaveGeneratorType::SurfaceWorld, 0x1007u },
|
||||||
|
{ EVoxelStrateParamBlock::VerticalShaft, ECaveGeneratorType::VerticalShafts, 0x1009u },
|
||||||
|
{ EVoxelStrateParamBlock::FloatingIsland, ECaveGeneratorType::FloatingIslands, 0x100Bu },
|
||||||
|
};
|
||||||
|
|
||||||
|
void VF_CopyRollBlock(FVoxelStrateArchetypeParams& OutParams,
|
||||||
|
const FVoxelStrateRollInfo& Roll,
|
||||||
|
ECaveGeneratorType Archetype)
|
||||||
|
{
|
||||||
|
switch (Archetype)
|
||||||
|
{
|
||||||
|
case ECaveGeneratorType::TunnelNetwork:
|
||||||
|
case ECaveGeneratorType::Underwater:
|
||||||
|
OutParams.TunnelNetworkParams = Roll.ArchetypeParams.TunnelNetworkParams;
|
||||||
|
break;
|
||||||
|
case ECaveGeneratorType::FlatPlain:
|
||||||
|
case ECaveGeneratorType::CrystalChamber:
|
||||||
|
OutParams.SlabParams = Roll.ArchetypeParams.SlabParams;
|
||||||
|
break;
|
||||||
|
case ECaveGeneratorType::Maze:
|
||||||
|
OutParams.MazeParams = Roll.ArchetypeParams.MazeParams;
|
||||||
|
break;
|
||||||
|
case ECaveGeneratorType::SurfaceWorld:
|
||||||
|
OutParams.SurfaceParams = Roll.ArchetypeParams.SurfaceParams;
|
||||||
|
break;
|
||||||
|
case ECaveGeneratorType::VerticalShafts:
|
||||||
|
OutParams.VerticalShaftParams = Roll.ArchetypeParams.VerticalShaftParams;
|
||||||
|
break;
|
||||||
|
case ECaveGeneratorType::FloatingIslands:
|
||||||
|
OutParams.FloatingIslandParams = Roll.ArchetypeParams.FloatingIslandParams;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VF_SetAllRuntimeBounds(FVoxelStrateArchetypeParams& Params,
|
||||||
|
float TopWorldZ, float BottomWorldZ)
|
||||||
|
{
|
||||||
|
Params.TunnelNetworkParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.TunnelNetworkParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.SlabParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.SlabParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.MazeParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.MazeParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.SurfaceParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.SurfaceParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.VerticalShaftParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.VerticalShaftParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.FloatingIslandParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FStackDensitySampler final : public IVoxelStrateDensitySampler
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit FStackDensitySampler(const FVoxelOpStack& InStack) : Stack(InStack) {}
|
||||||
|
|
||||||
|
float SampleDensity(float WorldX, float WorldY, float WorldZ) const override
|
||||||
|
{
|
||||||
|
return Stack.EvalMC(WorldX, WorldY, WorldZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
const FVoxelOpStack& Stack;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FBoxVerdictReport
|
||||||
|
{
|
||||||
|
int32 Mixed = 0;
|
||||||
|
int32 AllSolid = 0;
|
||||||
|
int32 AllAir = 0;
|
||||||
|
int32 Proved = 0;
|
||||||
|
int64 CheckedVoxels = 0;
|
||||||
|
int32 Violations = 0;
|
||||||
|
FString FirstViolation;
|
||||||
|
};
|
||||||
|
|
||||||
|
FBoxVerdictReport VF_CheckStackBoxVerdicts(const FVoxelOpStack& Stack,
|
||||||
|
const FVoxelOpContext& Context,
|
||||||
|
int32 CandidateIndex,
|
||||||
|
int32 BottomVoxelZ,
|
||||||
|
int32 TopVoxelZ)
|
||||||
|
{
|
||||||
|
FBoxVerdictReport Report;
|
||||||
|
constexpr int32 Extent = BoxStep * BoxCells;
|
||||||
|
constexpr int32 SpanCells = 40;
|
||||||
|
const int32 LoTile = VF_FloorDiv(BottomVoxelZ, Extent);
|
||||||
|
const int32 HiTile = FMath::Max(LoTile, VF_FloorDiv(TopVoxelZ, Extent));
|
||||||
|
FRandomStream Rng(0x6C617731 ^ CandidateIndex);
|
||||||
|
|
||||||
|
for (int32 BoxIndex = 0; BoxIndex < BoxChecksPerCandidate; ++BoxIndex)
|
||||||
|
{
|
||||||
|
const FIntVector Origin(
|
||||||
|
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||||
|
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||||
|
Rng.RandRange(LoTile, HiTile) * Extent);
|
||||||
|
const FBox Box(
|
||||||
|
FVector((float)(Origin.X - BoxStep), (float)(Origin.Y - BoxStep),
|
||||||
|
(float)(Origin.Z - BoxStep)),
|
||||||
|
FVector((float)(Origin.X + (BoxCells + 1) * BoxStep),
|
||||||
|
(float)(Origin.Y + (BoxCells + 1) * BoxStep),
|
||||||
|
(float)(Origin.Z + (BoxCells + 1) * BoxStep)));
|
||||||
|
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Context);
|
||||||
|
if (Verdict == EVoxelTileClass::Mixed)
|
||||||
|
{
|
||||||
|
++Report.Mixed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Verdict == EVoxelTileClass::AllSolid)
|
||||||
|
{
|
||||||
|
++Report.AllSolid;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
++Report.AllAir;
|
||||||
|
}
|
||||||
|
++Report.Proved;
|
||||||
|
|
||||||
|
const int32 GridDim = BoxCells + 1;
|
||||||
|
const bool bClaimsSolid = Verdict == EVoxelTileClass::AllSolid;
|
||||||
|
for (int32 GZ = -1; GZ <= GridDim; ++GZ)
|
||||||
|
for (int32 GY = -1; GY <= GridDim; ++GY)
|
||||||
|
for (int32 GX = -1; GX <= GridDim; ++GX)
|
||||||
|
{
|
||||||
|
const float X = (float)(Origin.X + GX * BoxStep);
|
||||||
|
const float Y = (float)(Origin.Y + GY * BoxStep);
|
||||||
|
const float Z = (float)(Origin.Z + GZ * BoxStep);
|
||||||
|
const float Density = Stack.EvalMC(X, Y, Z);
|
||||||
|
++Report.CheckedVoxels;
|
||||||
|
const bool bAgrees = bClaimsSolid ? (Density < 0.0f) : (Density >= 0.0f);
|
||||||
|
if (!bAgrees)
|
||||||
|
{
|
||||||
|
++Report.Violations;
|
||||||
|
if (Report.FirstViolation.IsEmpty())
|
||||||
|
{
|
||||||
|
Report.FirstViolation = FString::Printf(
|
||||||
|
TEXT("candidate %d box %d origin (%d,%d,%d) said %s but MC density "
|
||||||
|
"at (%.0f,%.0f,%.0f) was %.9g"),
|
||||||
|
CandidateIndex, BoxIndex, Origin.X, Origin.Y, Origin.Z,
|
||||||
|
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||||
|
X, Y, Z, Density);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Report;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FQualityStats
|
||||||
|
{
|
||||||
|
int32 Count = 0;
|
||||||
|
float AirMin = FLT_MAX;
|
||||||
|
float AirMax = -FLT_MAX;
|
||||||
|
float WalkableMin = FLT_MAX;
|
||||||
|
float WalkableMax = -FLT_MAX;
|
||||||
|
float FeatureMin = FLT_MAX;
|
||||||
|
float FeatureMax = -FLT_MAX;
|
||||||
|
double AirSum = 0.0;
|
||||||
|
double WalkableSum = 0.0;
|
||||||
|
double FeatureSum = 0.0;
|
||||||
|
|
||||||
|
void Add(const FVoxelStrateMetrics& Metrics)
|
||||||
|
{
|
||||||
|
++Count;
|
||||||
|
AirMin = FMath::Min(AirMin, Metrics.AirFraction);
|
||||||
|
AirMax = FMath::Max(AirMax, Metrics.AirFraction);
|
||||||
|
WalkableMin = FMath::Min(WalkableMin, Metrics.WalkableFraction);
|
||||||
|
WalkableMax = FMath::Max(WalkableMax, Metrics.WalkableFraction);
|
||||||
|
FeatureMin = FMath::Min(FeatureMin, Metrics.MedianFeatureScale);
|
||||||
|
FeatureMax = FMath::Max(FeatureMax, Metrics.MedianFeatureScale);
|
||||||
|
AirSum += Metrics.AirFraction;
|
||||||
|
WalkableSum += Metrics.WalkableFraction;
|
||||||
|
FeatureSum += Metrics.MedianFeatureScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
FString RangeText(float Min, float Max) const
|
||||||
|
{
|
||||||
|
return Count > 0
|
||||||
|
? FString::Printf(TEXT("%.6f..%.6f"), Min, Max)
|
||||||
|
: TEXT("n/a");
|
||||||
|
}
|
||||||
|
|
||||||
|
FString SummaryText() const
|
||||||
|
{
|
||||||
|
return Count > 0
|
||||||
|
? FString::Printf(
|
||||||
|
TEXT("n=%d air=%s (mean %.6f), walkable=%s (mean %.6f), feature=%s (mean %.6f)"),
|
||||||
|
Count, *RangeText(AirMin, AirMax), (float)(AirSum / Count),
|
||||||
|
*RangeText(WalkableMin, WalkableMax), (float)(WalkableSum / Count),
|
||||||
|
*RangeText(FeatureMin, FeatureMax), (float)(FeatureSum / Count))
|
||||||
|
: TEXT("n=0");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FArmStats
|
||||||
|
{
|
||||||
|
const TCHAR* Name = TEXT("unknown");
|
||||||
|
int32 Requested = 0;
|
||||||
|
int32 RollFailures = 0;
|
||||||
|
int32 BuildFailures = 0;
|
||||||
|
int32 Unmeasured = 0;
|
||||||
|
int32 Vacuous = 0;
|
||||||
|
int32 Fragmented = 0;
|
||||||
|
int32 LawFailed = 0;
|
||||||
|
int32 LawFailedObserved = 0;
|
||||||
|
int32 LawSkipped = 0;
|
||||||
|
int32 Survivors = 0;
|
||||||
|
int32 NonVacuous = 0;
|
||||||
|
int32 LargestEnough = 0;
|
||||||
|
int32 LawPasses = 0;
|
||||||
|
int32 DeterminismFailures = 0;
|
||||||
|
int32 InvalidStackCount = 0;
|
||||||
|
int32 ChannelOrderFailures = 0;
|
||||||
|
int32 ContextViolations = 0;
|
||||||
|
int32 LawResults[8] = { 0, 0, 0, 0, 0, 0, 0, 0 };
|
||||||
|
FQualityStats AllQuality;
|
||||||
|
FQualityStats SurvivorQuality;
|
||||||
|
FBoxVerdictReport Box;
|
||||||
|
FString FirstFailure;
|
||||||
|
|
||||||
|
void RememberFailure(const FString& Failure)
|
||||||
|
{
|
||||||
|
if (FirstFailure.IsEmpty())
|
||||||
|
{
|
||||||
|
FirstFailure = Failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FString SurvivalText() const
|
||||||
|
{
|
||||||
|
return FString::Printf(TEXT("%d/%d (%.1f%%)"), Survivors, Requested,
|
||||||
|
Requested > 0 ? 100.0f * (float)Survivors / (float)Requested : 0.0f);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
int32 VF_LawResultIndex(EVoxelConnectivityResult Result)
|
||||||
|
{
|
||||||
|
switch (Result)
|
||||||
|
{
|
||||||
|
case EVoxelConnectivityResult::Connected: return 0;
|
||||||
|
case EVoxelConnectivityResult::NotConnectedAtThisResolution: return 1;
|
||||||
|
case EVoxelConnectivityResult::StartCellSolid: return 2;
|
||||||
|
case EVoxelConnectivityResult::GoalCellSolid: return 3;
|
||||||
|
case EVoxelConnectivityResult::OutOfWindow: return 4;
|
||||||
|
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return 5;
|
||||||
|
case EVoxelConnectivityResult::StartCellNotPlayerFit: return 6;
|
||||||
|
case EVoxelConnectivityResult::GoalCellNotPlayerFit: return 7;
|
||||||
|
}
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VF_BitEqual(float A, float B)
|
||||||
|
{
|
||||||
|
uint32 ABits = 0;
|
||||||
|
uint32 BBits = 0;
|
||||||
|
FMemory::Memcpy(&ABits, &A, sizeof(ABits));
|
||||||
|
FMemory::Memcpy(&BBits, &B, sizeof(BBits));
|
||||||
|
return ABits == BBits;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeComposerCorpusFreeTest,
|
||||||
|
"VoxelForge.Composer.CorpusFree",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeComposerCorpusFreeTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
using namespace VoxelForgeTest;
|
||||||
|
(void)Parameters;
|
||||||
|
const double TestStartSeconds = FPlatformTime::Seconds();
|
||||||
|
|
||||||
|
const FString SettingsPath = TEXT("/Game/VoxelForge/DA_Settings.DA_Settings");
|
||||||
|
UVoxelSettings* AuthoredSettings = LoadObject<UVoxelSettings>(nullptr, *SettingsPath);
|
||||||
|
if (AuthoredSettings == nullptr)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(TEXT("Could not load %s; corpus-free measurement did not run."),
|
||||||
|
*SettingsPath));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FVoxelStrateCorpus Corpus;
|
||||||
|
FString CorpusReport;
|
||||||
|
const bool bCorpusLoaded = Corpus.LoadFromAssetRegistry(CorpusReport);
|
||||||
|
AddInfo(CorpusReport);
|
||||||
|
TestTrue(TEXT("corpus blend arm has a usable authored corpus"),
|
||||||
|
bCorpusLoaded && Corpus.IsValid());
|
||||||
|
if (!bCorpusLoaded || !Corpus.IsValid())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
TestEqual(TEXT("corpus contains the four project vectors plus eight defaults"),
|
||||||
|
Corpus.GetEntries().Num(), 12);
|
||||||
|
|
||||||
|
FTestWorld World;
|
||||||
|
World.Build(1337, 2, true);
|
||||||
|
if (!World.IsValid())
|
||||||
|
{
|
||||||
|
AddError(World.WhyInvalid());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
TestTrue(TEXT("corpus-free fixture keeps WorldRadiusVoxels at 0"),
|
||||||
|
World.Settings->WorldRadiusVoxels == 0.0f);
|
||||||
|
|
||||||
|
constexpr int32 CandidateStrateIndex = FTestWorld::SlotFlatPlain;
|
||||||
|
int32 TopVoxelZ = 0;
|
||||||
|
int32 BottomVoxelZ = 0;
|
||||||
|
if (!World.GetSlotVoxelZRange(CandidateStrateIndex, TopVoxelZ, BottomVoxelZ))
|
||||||
|
{
|
||||||
|
AddError(TEXT("corpus-free fixture has no candidate Z range"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const float TopWorldZ = (float)TopVoxelZ + 1.0f;
|
||||||
|
const float BottomWorldZ = (float)BottomVoxelZ;
|
||||||
|
const float StrateHeightInVoxels = TopWorldZ - BottomWorldZ;
|
||||||
|
|
||||||
|
FVector ArrivalPoint = FVector::ZeroVector;
|
||||||
|
FVector DeparturePoint = FVector::ZeroVector;
|
||||||
|
int32 ArrivalCount = 0;
|
||||||
|
int32 DepartureCount = 0;
|
||||||
|
for (const FVoxelPassage& Passage : World.StrateManager->GetPassages())
|
||||||
|
{
|
||||||
|
if (Passage.LowerStrateIndex == CandidateStrateIndex
|
||||||
|
&& Passage.UpperStrateIndex + 1 == CandidateStrateIndex)
|
||||||
|
{
|
||||||
|
ArrivalPoint = Passage.LowerPoint;
|
||||||
|
++ArrivalCount;
|
||||||
|
}
|
||||||
|
if (Passage.UpperStrateIndex == CandidateStrateIndex
|
||||||
|
&& Passage.LowerStrateIndex == CandidateStrateIndex + 1)
|
||||||
|
{
|
||||||
|
DeparturePoint = Passage.UpperPoint;
|
||||||
|
++DepartureCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TestEqual(TEXT("corpus-free candidate has exactly one arrival mouth"), ArrivalCount, 1);
|
||||||
|
TestEqual(TEXT("corpus-free candidate has exactly one departure mouth"), DepartureCount, 1);
|
||||||
|
|
||||||
|
FVoxelStrateMeasureSettings MeasureSettings;
|
||||||
|
MeasureSettings.SampleStep = 4;
|
||||||
|
MeasureSettings.RadiusInVoxels = 256;
|
||||||
|
MeasureSettings.CenterXY = FVector2D::ZeroVector;
|
||||||
|
MeasureSettings.MaxCells = 8000000;
|
||||||
|
MeasureSettings.MaxRouteRetries = 16;
|
||||||
|
MeasureSettings.HeadroomCells = 2;
|
||||||
|
MeasureSettings.InteriorMarginVoxels = -1;
|
||||||
|
|
||||||
|
FArmStats CorpusBlend;
|
||||||
|
CorpusBlend.Name = TEXT("corpus blend (today)");
|
||||||
|
FArmStats Naive;
|
||||||
|
Naive.Name = TEXT("naive uniform");
|
||||||
|
FArmStats Constraint;
|
||||||
|
Constraint.Name = TEXT("constraint-sampled");
|
||||||
|
|
||||||
|
int32 RecipeDeterminismFailures = 0;
|
||||||
|
int32 ConstraintParameterViolations = 0;
|
||||||
|
FString FirstConstraintParameterViolation;
|
||||||
|
int32 ConstraintBoxStopCount = 0;
|
||||||
|
FString FirstConstraintBoxViolation;
|
||||||
|
bool bStopAfterConstraintViolation = false;
|
||||||
|
|
||||||
|
const double MeasurementStartSeconds = FPlatformTime::Seconds();
|
||||||
|
for (int32 CandidateIndex = 0;
|
||||||
|
CandidateIndex < NumCandidates && !bStopAfterConstraintViolation;
|
||||||
|
++CandidateIndex)
|
||||||
|
{
|
||||||
|
const FVoxelOpStackRecipe Recipe = VF_RollStrateStructure(
|
||||||
|
AuthoredSettings->Seed, CandidateIndex);
|
||||||
|
const FVoxelOpStackRecipe RepeatRecipe = VF_RollStrateStructure(
|
||||||
|
AuthoredSettings->Seed, CandidateIndex);
|
||||||
|
if (!VF_AreStrateStructureRecipesIdentical(Recipe, RepeatRecipe))
|
||||||
|
{
|
||||||
|
++RecipeDeterminismFailures;
|
||||||
|
}
|
||||||
|
|
||||||
|
FArmStats* Arms[] = { &CorpusBlend, &Naive, &Constraint };
|
||||||
|
const EVoxelStrateCorpusFreeSamplingMode Modes[] = {
|
||||||
|
EVoxelStrateCorpusFreeSamplingMode::NaiveUniform,
|
||||||
|
EVoxelStrateCorpusFreeSamplingMode::ConstraintSampled,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (int32 ArmIndex = 0; ArmIndex < 3 && !bStopAfterConstraintViolation; ++ArmIndex)
|
||||||
|
{
|
||||||
|
FArmStats& Arm = *Arms[ArmIndex];
|
||||||
|
++Arm.Requested;
|
||||||
|
FVoxelStrateArchetypeParams CandidateParams;
|
||||||
|
bool bParameterRollsValid = true;
|
||||||
|
bool bConstraintParameterViolationThisCandidate = false;
|
||||||
|
|
||||||
|
for (int32 BlockIndex = 0; BlockIndex < UE_ARRAY_COUNT(GBlockSpecs); ++BlockIndex)
|
||||||
|
{
|
||||||
|
const FBlockSpec& Spec = GBlockSpecs[BlockIndex];
|
||||||
|
const int32 BlockSeed = AuthoredSettings->Seed
|
||||||
|
^ static_cast<int32>(Spec.SeedSalt);
|
||||||
|
FVoxelStrateRollInfo BlockRoll;
|
||||||
|
if (ArmIndex == 0)
|
||||||
|
{
|
||||||
|
BlockRoll = VF_RollStrateParamsDetailedForArchetype(
|
||||||
|
Corpus, Spec.Archetype, BlockSeed, CandidateIndex);
|
||||||
|
const FVoxelStrateRollInfo RepeatRoll = VF_RollStrateParamsDetailedForArchetype(
|
||||||
|
Corpus, Spec.Archetype, BlockSeed, CandidateIndex);
|
||||||
|
if (!BlockRoll.bValid || !RepeatRoll.bValid
|
||||||
|
|| !VF_AreStrateArchetypeParamsBitIdentical(
|
||||||
|
BlockRoll.ArchetypeParams, RepeatRoll.ArchetypeParams, Spec.Archetype))
|
||||||
|
{
|
||||||
|
++Arm.DeterminismFailures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
BlockRoll = VF_RollStrateParamsCorpusFree(
|
||||||
|
Spec.Archetype, BlockSeed, CandidateIndex, Modes[ArmIndex - 1],
|
||||||
|
StrateHeightInVoxels);
|
||||||
|
const FVoxelStrateRollInfo RepeatRoll = VF_RollStrateParamsCorpusFree(
|
||||||
|
Spec.Archetype, BlockSeed, CandidateIndex, Modes[ArmIndex - 1],
|
||||||
|
StrateHeightInVoxels);
|
||||||
|
if (!BlockRoll.bValid || !RepeatRoll.bValid
|
||||||
|
|| !VF_AreStrateArchetypeParamsBitIdentical(
|
||||||
|
BlockRoll.ArchetypeParams, RepeatRoll.ArchetypeParams, Spec.Archetype))
|
||||||
|
{
|
||||||
|
++Arm.DeterminismFailures;
|
||||||
|
}
|
||||||
|
if (ArmIndex == 2 && BlockRoll.bValid)
|
||||||
|
{
|
||||||
|
FString Violation;
|
||||||
|
if (!VF_ValidateStrateCorpusFreeConstraints(
|
||||||
|
Spec.Archetype, BlockRoll.ArchetypeParams,
|
||||||
|
StrateHeightInVoxels, Violation))
|
||||||
|
{
|
||||||
|
++ConstraintParameterViolations;
|
||||||
|
if (FirstConstraintParameterViolation.IsEmpty())
|
||||||
|
{
|
||||||
|
FirstConstraintParameterViolation = FString::Printf(
|
||||||
|
TEXT("candidate %d block %d (%s): %s"), CandidateIndex,
|
||||||
|
static_cast<int32>(Spec.Block),
|
||||||
|
VF_GetStrateArchetypeName(Spec.Archetype), *Violation);
|
||||||
|
}
|
||||||
|
bParameterRollsValid = false;
|
||||||
|
bConstraintParameterViolationThisCandidate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BlockRoll.bValid)
|
||||||
|
{
|
||||||
|
bParameterRollsValid = false;
|
||||||
|
++Arm.RollFailures;
|
||||||
|
Arm.RememberFailure(FString::Printf(
|
||||||
|
TEXT("candidate %d block %d roll failed: %s"), CandidateIndex,
|
||||||
|
static_cast<int32>(Spec.Block), *BlockRoll.FailureReason));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
VF_CopyRollBlock(CandidateParams, BlockRoll, Spec.Archetype);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bParameterRollsValid)
|
||||||
|
{
|
||||||
|
++Arm.Unmeasured;
|
||||||
|
if (ArmIndex == 2 && bConstraintParameterViolationThisCandidate)
|
||||||
|
{
|
||||||
|
// The constraint relation itself is a precondition of this arm; do not
|
||||||
|
// spend the remaining budget hiding a violated declared relation.
|
||||||
|
bStopAfterConstraintViolation = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
VF_SetAllRuntimeBounds(CandidateParams, TopWorldZ, BottomWorldZ);
|
||||||
|
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
FVoxelOpContext Context;
|
||||||
|
FString BuildError;
|
||||||
|
const bool bBuilt = VF_BuildStackFromRecipe(
|
||||||
|
Recipe, CandidateParams, AuthoredSettings->Seed, 14.0f,
|
||||||
|
World.StrateManager.Get(), Stack, Context, &BuildError);
|
||||||
|
if (!bBuilt)
|
||||||
|
{
|
||||||
|
++Arm.BuildFailures;
|
||||||
|
++Arm.Unmeasured;
|
||||||
|
Arm.RememberFailure(FString::Printf(
|
||||||
|
TEXT("candidate %d build failed: %s"), CandidateIndex, *BuildError));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Stack.Num() != Recipe.Modifiers.Num() + 7)
|
||||||
|
{
|
||||||
|
++Arm.InvalidStackCount;
|
||||||
|
}
|
||||||
|
FString ChannelError;
|
||||||
|
if (!Stack.ValidateChannelOrder(&ChannelError))
|
||||||
|
{
|
||||||
|
++Arm.ChannelOrderFailures;
|
||||||
|
Arm.RememberFailure(FString::Printf(
|
||||||
|
TEXT("candidate %d channel order failed: %s"), CandidateIndex, *ChannelError));
|
||||||
|
}
|
||||||
|
if (Context.WorldRadiusVoxels != 0.0f)
|
||||||
|
{
|
||||||
|
++Arm.ContextViolations;
|
||||||
|
Arm.RememberFailure(FString::Printf(
|
||||||
|
TEXT("candidate %d changed WorldRadiusVoxels to %.9g"),
|
||||||
|
CandidateIndex, Context.WorldRadiusVoxels));
|
||||||
|
}
|
||||||
|
|
||||||
|
Stack.PrepareChunk(Context);
|
||||||
|
FStackDensitySampler Sampler(Stack);
|
||||||
|
const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler(
|
||||||
|
Sampler, BottomVoxelZ, TopVoxelZ + 1,
|
||||||
|
Context.EdgeSealThickness, MeasureSettings, nullptr);
|
||||||
|
if (!Metrics.bValid)
|
||||||
|
{
|
||||||
|
++Arm.Unmeasured;
|
||||||
|
Arm.RememberFailure(FString::Printf(
|
||||||
|
TEXT("candidate %d measurement refused: %s"), CandidateIndex,
|
||||||
|
*Metrics.RefusalReason));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Arm.AllQuality.Add(Metrics);
|
||||||
|
|
||||||
|
const bool bNonVacuous = Metrics.NumSampled > 0
|
||||||
|
&& Metrics.NumAir > 0 && Metrics.NumSolid > 0;
|
||||||
|
const bool bLargestEnough = bNonVacuous
|
||||||
|
&& Metrics.LargestComponentShare >= LargestComponentSurvivalThreshold;
|
||||||
|
|
||||||
|
bool bLawPass = false;
|
||||||
|
if (bLargestEnough)
|
||||||
|
{
|
||||||
|
FVoxelConnectivityDiagnostics Law;
|
||||||
|
if (ArrivalCount == 1 && DepartureCount == 1)
|
||||||
|
{
|
||||||
|
Law = VF_DiagnoseConnectivityWithSampler(
|
||||||
|
Sampler, BottomVoxelZ, TopVoxelZ + 1,
|
||||||
|
Context.EdgeSealThickness, ArrivalPoint, DeparturePoint, MeasureSettings);
|
||||||
|
++Arm.LawResults[VF_LawResultIndex(Law.Result)];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Law.bValid = false;
|
||||||
|
Law.Result = EVoxelConnectivityResult::OutOfWindow;
|
||||||
|
++Arm.LawResults[VF_LawResultIndex(Law.Result)];
|
||||||
|
}
|
||||||
|
|
||||||
|
bLawPass = ArrivalCount == 1 && DepartureCount == 1
|
||||||
|
&& Law.bValid && Law.Result == EVoxelConnectivityResult::Connected
|
||||||
|
&& !Law.bStartSnapped && !Law.bGoalSnapped;
|
||||||
|
if (bLawPass) { ++Arm.LawPasses; }
|
||||||
|
if (!bLawPass) { ++Arm.LawFailedObserved; }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
++Arm.LawSkipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bNonVacuous) { ++Arm.NonVacuous; }
|
||||||
|
if (bLargestEnough) { ++Arm.LargestEnough; }
|
||||||
|
|
||||||
|
const bool bSurvivor = bNonVacuous && bLargestEnough && bLawPass;
|
||||||
|
if (bSurvivor)
|
||||||
|
{
|
||||||
|
++Arm.Survivors;
|
||||||
|
Arm.SurvivorQuality.Add(Metrics);
|
||||||
|
}
|
||||||
|
else if (!bNonVacuous)
|
||||||
|
{
|
||||||
|
++Arm.Vacuous;
|
||||||
|
}
|
||||||
|
else if (!bLargestEnough)
|
||||||
|
{
|
||||||
|
++Arm.Fragmented;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
++Arm.LawFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FBoxVerdictReport BoxReport = VF_CheckStackBoxVerdicts(
|
||||||
|
Stack, Context, CandidateIndex, BottomVoxelZ, TopVoxelZ);
|
||||||
|
Arm.Box.Mixed += BoxReport.Mixed;
|
||||||
|
Arm.Box.AllSolid += BoxReport.AllSolid;
|
||||||
|
Arm.Box.AllAir += BoxReport.AllAir;
|
||||||
|
Arm.Box.Proved += BoxReport.Proved;
|
||||||
|
Arm.Box.CheckedVoxels += BoxReport.CheckedVoxels;
|
||||||
|
Arm.Box.Violations += BoxReport.Violations;
|
||||||
|
if (Arm.Box.FirstViolation.IsEmpty() && !BoxReport.FirstViolation.IsEmpty())
|
||||||
|
{
|
||||||
|
Arm.Box.FirstViolation = BoxReport.FirstViolation;
|
||||||
|
}
|
||||||
|
if (ArmIndex == 2 && BoxReport.Violations > 0)
|
||||||
|
{
|
||||||
|
++ConstraintBoxStopCount;
|
||||||
|
bStopAfterConstraintViolation = true;
|
||||||
|
FirstConstraintBoxViolation = BoxReport.FirstViolation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const double MeasurementSeconds = FPlatformTime::Seconds() - MeasurementStartSeconds;
|
||||||
|
|
||||||
|
// Audit the same declared relations against the current authored corpus vectors. The owner
|
||||||
|
// fixture currently carries four project vectors; the eight C++ defaults use the fixture's
|
||||||
|
// 8-chunk height. Keep this audit tied to membership rather than an obsolete asset count.
|
||||||
|
int32 AuthoredConstraintViolations = 0;
|
||||||
|
FString AuthoredViolationList;
|
||||||
|
for (const FVoxelStrateCorpusEntry& Entry : Corpus.GetEntries())
|
||||||
|
{
|
||||||
|
float EntryHeight = 8.0f * (float)CHUNK_SIZE;
|
||||||
|
if (!Entry.SourcePath.Contains(TEXT("/VoxelForge/ComposerDefaults/")))
|
||||||
|
{
|
||||||
|
UVoxelStrateDefinition* Definition = LoadObject<UVoxelStrateDefinition>(
|
||||||
|
nullptr, *Entry.SourcePath);
|
||||||
|
if (Definition != nullptr)
|
||||||
|
{
|
||||||
|
EntryHeight = FMath::Max(1.0f, (float)Definition->StrateHeightInChunks)
|
||||||
|
* (float)CHUNK_SIZE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FString Violation;
|
||||||
|
if (!VF_ValidateStrateCorpusFreeConstraints(
|
||||||
|
Entry.Archetype, Entry.ArchetypeParams, EntryHeight, Violation))
|
||||||
|
{
|
||||||
|
++AuthoredConstraintViolations;
|
||||||
|
if (!AuthoredViolationList.IsEmpty())
|
||||||
|
{
|
||||||
|
AuthoredViolationList += TEXT("; ");
|
||||||
|
}
|
||||||
|
AuthoredViolationList += FString::Printf(
|
||||||
|
TEXT("%s (%s, H=%.0f): %s"), *Entry.SourceName,
|
||||||
|
VF_GetStrateArchetypeName(Entry.Archetype), EntryHeight, *Violation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FArmStats* Arms[] = { &CorpusBlend, &Naive, &Constraint };
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Corpus-free measurement: requested %d candidates per arm; structure recipes were "
|
||||||
|
"shared across all three arms; sample step=%d radius=%d MaxCells=%d, fixed law mouths, "
|
||||||
|
"criterion precedence is vacuous > fragmented > law failed. Runtime %.3fs, roll/measure "
|
||||||
|
"%.3fs."),
|
||||||
|
NumCandidates, MeasureSettings.SampleStep, MeasureSettings.RadiusInVoxels,
|
||||||
|
MeasureSettings.MaxCells, FPlatformTime::Seconds() - TestStartSeconds, MeasurementSeconds));
|
||||||
|
|
||||||
|
FString Table = TEXT(
|
||||||
|
"approach | survival | vacuous | fragmented | law failed | air range | walkable range | feature scale range\n");
|
||||||
|
FString SurvivorTable = TEXT(
|
||||||
|
"survivor quality (ranges are over survivors only; main table ranges are over all measured candidates)\n"
|
||||||
|
"approach | survivors | air range (mean) | walkable range (mean) | feature scale range (mean)\n");
|
||||||
|
for (const FArmStats* Arm : Arms)
|
||||||
|
{
|
||||||
|
Table += FString::Printf(
|
||||||
|
TEXT("%s | %s | %d | %d | %d | %s | %s | %s\n"),
|
||||||
|
Arm->Name, *Arm->SurvivalText(), Arm->Vacuous, Arm->Fragmented, Arm->LawFailed,
|
||||||
|
*Arm->AllQuality.RangeText(Arm->AllQuality.AirMin, Arm->AllQuality.AirMax),
|
||||||
|
*Arm->AllQuality.RangeText(Arm->AllQuality.WalkableMin, Arm->AllQuality.WalkableMax),
|
||||||
|
*Arm->AllQuality.RangeText(Arm->AllQuality.FeatureMin, Arm->AllQuality.FeatureMax));
|
||||||
|
SurvivorTable += FString::Printf(
|
||||||
|
TEXT("%s | %d | %s | %s | %s\n"),
|
||||||
|
Arm->Name, Arm->SurvivorQuality.Count,
|
||||||
|
*Arm->SurvivorQuality.RangeText(Arm->SurvivorQuality.AirMin, Arm->SurvivorQuality.AirMax),
|
||||||
|
*Arm->SurvivorQuality.RangeText(Arm->SurvivorQuality.WalkableMin, Arm->SurvivorQuality.WalkableMax),
|
||||||
|
*Arm->SurvivorQuality.RangeText(Arm->SurvivorQuality.FeatureMin, Arm->SurvivorQuality.FeatureMax));
|
||||||
|
}
|
||||||
|
AddInfo(Table);
|
||||||
|
AddInfo(SurvivorTable);
|
||||||
|
|
||||||
|
for (const FArmStats* Arm : Arms)
|
||||||
|
{
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("%s: non-vacuous=%d, largest>=%.2f=%d, law pass=%d, law failures observed=%d, "
|
||||||
|
"law skipped=%d, "
|
||||||
|
"roll failures=%d, build failures=%d, unmeasured=%d, deterministic roll failures=%d; "
|
||||||
|
"stack-count failures=%d, channel-order failures=%d, context violations=%d; "
|
||||||
|
"law results Connected=%d NotConnected=%d StartSolid=%d GoalSolid=%d OutOfWindow=%d Budget=%d "
|
||||||
|
"StartNotPlayerFit=%d GoalNotPlayerFit=%d; "
|
||||||
|
"quality all %s; survivor quality %s; first failure=%s."),
|
||||||
|
Arm->Name, Arm->NonVacuous, LargestComponentSurvivalThreshold, Arm->LargestEnough,
|
||||||
|
Arm->LawPasses, Arm->LawFailedObserved, Arm->LawSkipped,
|
||||||
|
Arm->RollFailures, Arm->BuildFailures,
|
||||||
|
Arm->Unmeasured, Arm->DeterminismFailures,
|
||||||
|
Arm->InvalidStackCount, Arm->ChannelOrderFailures, Arm->ContextViolations,
|
||||||
|
Arm->LawResults[0], Arm->LawResults[1], Arm->LawResults[2],
|
||||||
|
Arm->LawResults[3], Arm->LawResults[4], Arm->LawResults[5],
|
||||||
|
Arm->LawResults[6], Arm->LawResults[7],
|
||||||
|
*Arm->AllQuality.SummaryText(), *Arm->SurvivorQuality.SummaryText(),
|
||||||
|
Arm->FirstFailure.IsEmpty() ? TEXT("none") : *Arm->FirstFailure));
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("%s box verdicts: mixed=%d AllSolid=%d AllAir=%d proved=%d voxels checked=%lld "
|
||||||
|
"violations=%d; %s"),
|
||||||
|
Arm->Name, Arm->Box.Mixed, Arm->Box.AllSolid, Arm->Box.AllAir, Arm->Box.Proved,
|
||||||
|
Arm->Box.CheckedVoxels, Arm->Box.Violations,
|
||||||
|
Arm->Box.FirstViolation.IsEmpty() ? TEXT("no violations") : *Arm->Box.FirstViolation));
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Authored-corpus constraint audit: %d/%d vectors violated at their declared strate "
|
||||||
|
"height. %s"),
|
||||||
|
AuthoredConstraintViolations, Corpus.GetEntries().Num(),
|
||||||
|
AuthoredViolationList.IsEmpty() ? TEXT("none") : *AuthoredViolationList));
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Shared structure determinism failures=%d; per-arm channel/context violations are "
|
||||||
|
"reported below; constraint parameter violations=%d; constraint box-stop count=%d. %s%s"),
|
||||||
|
RecipeDeterminismFailures,
|
||||||
|
ConstraintParameterViolations, ConstraintBoxStopCount,
|
||||||
|
FirstConstraintParameterViolation.IsEmpty() ? TEXT("") : *FirstConstraintParameterViolation,
|
||||||
|
FirstConstraintBoxViolation.IsEmpty() ? TEXT("")
|
||||||
|
: *FString::Printf(TEXT("; first box violation: %s"), *FirstConstraintBoxViolation)));
|
||||||
|
|
||||||
|
TestEqual(TEXT("configured candidate count requested for corpus blend"), CorpusBlend.Requested, NumCandidates);
|
||||||
|
TestEqual(TEXT("configured candidate count requested for naive uniform"), Naive.Requested, NumCandidates);
|
||||||
|
TestEqual(TEXT("configured candidate count requested for constraint-sampled"), Constraint.Requested, NumCandidates);
|
||||||
|
TestEqual(TEXT("corpus blend roll failures"), CorpusBlend.RollFailures, 0);
|
||||||
|
TestEqual(TEXT("naive uniform roll failures"), Naive.RollFailures, 0);
|
||||||
|
TestEqual(TEXT("constraint-sampled roll failures"), Constraint.RollFailures, 0);
|
||||||
|
TestEqual(TEXT("corpus blend build failures"), CorpusBlend.BuildFailures, 0);
|
||||||
|
TestEqual(TEXT("naive uniform build failures"), Naive.BuildFailures, 0);
|
||||||
|
TestEqual(TEXT("constraint-sampled build failures"), Constraint.BuildFailures, 0);
|
||||||
|
TestEqual(TEXT("corpus-free parameter determinism failures"),
|
||||||
|
Naive.DeterminismFailures + Constraint.DeterminismFailures, 0);
|
||||||
|
TestEqual(TEXT("corpus parameter determinism failures"), CorpusBlend.DeterminismFailures, 0);
|
||||||
|
TestEqual(TEXT("shared structure recipe determinism failures"), RecipeDeterminismFailures, 0);
|
||||||
|
TestEqual(TEXT("constraint-sampled declared-parameter violations"),
|
||||||
|
ConstraintParameterViolations, 0);
|
||||||
|
TestEqual(TEXT("constraint-sampled box verdict violations"),
|
||||||
|
Constraint.Box.Violations, 0);
|
||||||
|
TestEqual(TEXT("corpus blend box verdict violations"), CorpusBlend.Box.Violations, 0);
|
||||||
|
TestEqual(TEXT("naive uniform box verdict violations"), Naive.Box.Violations, 0);
|
||||||
|
TestEqual(TEXT("corpus blend WorldRadiusVoxels/context violations"),
|
||||||
|
CorpusBlend.InvalidStackCount + CorpusBlend.ChannelOrderFailures
|
||||||
|
+ CorpusBlend.ContextViolations, 0);
|
||||||
|
TestEqual(TEXT("naive uniform WorldRadiusVoxels/context violations"),
|
||||||
|
Naive.InvalidStackCount + Naive.ChannelOrderFailures + Naive.ContextViolations, 0);
|
||||||
|
TestEqual(TEXT("constraint-sampled WorldRadiusVoxels/context violations"),
|
||||||
|
Constraint.InvalidStackCount + Constraint.ChannelOrderFailures
|
||||||
|
+ Constraint.ContextViolations, 0);
|
||||||
|
TestTrue(TEXT("constraint sampler did not stop early on a declared relation or box violation"),
|
||||||
|
!bStopAfterConstraintViolation);
|
||||||
|
|
||||||
|
const double TestSeconds = FPlatformTime::Seconds() - TestStartSeconds;
|
||||||
|
AddInfo(FString::Printf(TEXT("CorpusFree total runtime %.3fs; all three arms requested %d each; "
|
||||||
|
"WorldRadiusVoxels=0."), TestSeconds, NumCandidates));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,721 @@
|
|||||||
|
// Tier 5 — promotion, corpus persistence, re-verification, and compounding simulation.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS && WITH_EDITOR
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
#include "HAL/PlatformTime.h"
|
||||||
|
#include "Math/RandomStream.h"
|
||||||
|
#include "Misc/Paths.h"
|
||||||
|
|
||||||
|
#include "VoxelForgeTestFixture.h"
|
||||||
|
#include "VoxelDensityOpStack.h"
|
||||||
|
#include "VoxelSettings.h"
|
||||||
|
#include "VoxelStrateComposer.h"
|
||||||
|
#include "VoxelStrateMeasure.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr int32 NumSimulatedSeasons = 5;
|
||||||
|
constexpr int32 CandidatesPerSeason = 24;
|
||||||
|
constexpr int32 PromotionCapPerSeason = 6;
|
||||||
|
constexpr float LargestComponentSurvivalThreshold = 0.50f;
|
||||||
|
constexpr int32 SimulationSampleStep = 8;
|
||||||
|
constexpr int32 SimulationRadius = 256;
|
||||||
|
|
||||||
|
const TCHAR* VF_ConnectivityName(EVoxelConnectivityResult Result)
|
||||||
|
{
|
||||||
|
switch (Result)
|
||||||
|
{
|
||||||
|
case EVoxelConnectivityResult::Connected: return TEXT("Connected");
|
||||||
|
case EVoxelConnectivityResult::NotConnectedAtThisResolution: return TEXT("NotConnected");
|
||||||
|
case EVoxelConnectivityResult::StartCellSolid: return TEXT("StartCellSolid");
|
||||||
|
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid");
|
||||||
|
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow");
|
||||||
|
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudget");
|
||||||
|
case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit");
|
||||||
|
case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit");
|
||||||
|
}
|
||||||
|
return TEXT("Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
void VF_SetAllRuntimeBounds(FVoxelStrateArchetypeParams& Params,
|
||||||
|
float TopWorldZ, float BottomWorldZ)
|
||||||
|
{
|
||||||
|
Params.TunnelNetworkParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.TunnelNetworkParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.SlabParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.SlabParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.MazeParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.MazeParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.SurfaceParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.SurfaceParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.VerticalShaftParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.VerticalShaftParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.FloatingIslandParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
float VF_GetBoundarySeal(const FVoxelStrateArchetypeParams& Params,
|
||||||
|
ECaveGeneratorType Archetype)
|
||||||
|
{
|
||||||
|
switch (Archetype)
|
||||||
|
{
|
||||||
|
case ECaveGeneratorType::TunnelNetwork:
|
||||||
|
case ECaveGeneratorType::Underwater:
|
||||||
|
return Params.TunnelNetworkParams.BoundarySealThickness;
|
||||||
|
case ECaveGeneratorType::FlatPlain:
|
||||||
|
case ECaveGeneratorType::CrystalChamber:
|
||||||
|
return Params.SlabParams.BoundarySealThickness;
|
||||||
|
case ECaveGeneratorType::Maze:
|
||||||
|
return Params.MazeParams.BoundarySealThickness;
|
||||||
|
case ECaveGeneratorType::SurfaceWorld:
|
||||||
|
return Params.SurfaceParams.BoundarySealThickness;
|
||||||
|
case ECaveGeneratorType::VerticalShafts:
|
||||||
|
return Params.VerticalShaftParams.BoundarySealThickness;
|
||||||
|
case ECaveGeneratorType::FloatingIslands:
|
||||||
|
return Params.FloatingIslandParams.BoundarySealThickness;
|
||||||
|
default:
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ECaveGeneratorType VF_ArchetypeForRecipe(const FVoxelOpStackRecipe& Recipe)
|
||||||
|
{
|
||||||
|
switch (Recipe.StructuralParamBlock)
|
||||||
|
{
|
||||||
|
case EVoxelStrateParamBlock::TunnelNetwork: return ECaveGeneratorType::TunnelNetwork;
|
||||||
|
case EVoxelStrateParamBlock::Slab: return ECaveGeneratorType::FlatPlain;
|
||||||
|
case EVoxelStrateParamBlock::Maze: return ECaveGeneratorType::Maze;
|
||||||
|
case EVoxelStrateParamBlock::Surface: return ECaveGeneratorType::SurfaceWorld;
|
||||||
|
case EVoxelStrateParamBlock::VerticalShaft: return ECaveGeneratorType::VerticalShafts;
|
||||||
|
case EVoxelStrateParamBlock::FloatingIsland: return ECaveGeneratorType::FloatingIslands;
|
||||||
|
default: return ECaveGeneratorType::TunnelNetwork;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FStackDensitySampler final : public IVoxelStrateDensitySampler
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit FStackDensitySampler(const FVoxelOpStack& InStack) : Stack(InStack) {}
|
||||||
|
|
||||||
|
float SampleDensity(float WorldX, float WorldY, float WorldZ) const override
|
||||||
|
{
|
||||||
|
return Stack.EvalMC(WorldX, WorldY, WorldZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
const FVoxelOpStack& Stack;
|
||||||
|
};
|
||||||
|
|
||||||
|
class FPromotionVerifier final : public IVoxelStratePromotionVerifier
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
FPromotionVerifier(const VoxelForgeTest::FTestWorld& InWorld,
|
||||||
|
int32 InTopVoxelZ,
|
||||||
|
int32 InBottomVoxelZ,
|
||||||
|
const FVector& InArrivalPoint,
|
||||||
|
const FVector& InDeparturePoint,
|
||||||
|
const FVoxelStrateMeasureSettings& InMeasureSettings,
|
||||||
|
float InLargestThreshold)
|
||||||
|
: World(InWorld)
|
||||||
|
, TopVoxelZ(InTopVoxelZ)
|
||||||
|
, BottomVoxelZ(InBottomVoxelZ)
|
||||||
|
, ArrivalPoint(InArrivalPoint)
|
||||||
|
, DeparturePoint(InDeparturePoint)
|
||||||
|
, MeasureSettings(InMeasureSettings)
|
||||||
|
, LargestThreshold(InLargestThreshold)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Verify(const FVoxelStratePromotableRecord& Record,
|
||||||
|
FVoxelStratePromotionVerification& OutVerification) const override
|
||||||
|
{
|
||||||
|
++NumVerifications;
|
||||||
|
OutVerification = FVoxelStratePromotionVerification();
|
||||||
|
|
||||||
|
if (Record.Archetype != VF_ArchetypeForRecipe(Record.Recipe))
|
||||||
|
{
|
||||||
|
OutVerification.FailureReason = TEXT("record archetype does not match recipe post block");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FVoxelStrateArchetypeParams Params = Record.Params;
|
||||||
|
VF_SetAllRuntimeBounds(Params, static_cast<float>(TopVoxelZ) + 1.0f,
|
||||||
|
static_cast<float>(BottomVoxelZ));
|
||||||
|
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
FVoxelOpContext Context;
|
||||||
|
FString BuildError;
|
||||||
|
if (!VF_BuildStackFromRecipe(
|
||||||
|
Record.Recipe, Params, Record.Seed, 14.0f,
|
||||||
|
World.StrateManager.Get(), Stack, Context, &BuildError))
|
||||||
|
{
|
||||||
|
OutVerification.FailureReason = FString::Printf(
|
||||||
|
TEXT("recipe rebuild failed: %s"), *BuildError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Context.WorldRadiusVoxels != 0.0f)
|
||||||
|
{
|
||||||
|
OutVerification.FailureReason = TEXT("re-verified recipe changed WorldRadiusVoxels");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stack.PrepareChunk(Context);
|
||||||
|
FStackDensitySampler Sampler(Stack);
|
||||||
|
const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler(
|
||||||
|
Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness,
|
||||||
|
MeasureSettings, nullptr);
|
||||||
|
OutVerification.MeasuredMetrics = VF_SummarizeStrateMetrics(Metrics);
|
||||||
|
OutVerification.bPassedNonVacuous = Metrics.bValid
|
||||||
|
&& Metrics.NumSampled > 0 && Metrics.NumAir > 0 && Metrics.NumSolid > 0;
|
||||||
|
OutVerification.bPassedLargestComponent = OutVerification.bPassedNonVacuous
|
||||||
|
&& Metrics.LargestComponentShare >= LargestThreshold;
|
||||||
|
|
||||||
|
const FVoxelConnectivityDiagnostics Law = VF_DiagnoseConnectivityWithSampler(
|
||||||
|
Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness,
|
||||||
|
ArrivalPoint, DeparturePoint, MeasureSettings);
|
||||||
|
OutVerification.bPassedPrimordialLaw = Law.bValid
|
||||||
|
&& Law.Result == EVoxelConnectivityResult::Connected
|
||||||
|
&& !Law.bStartSnapped && !Law.bGoalSnapped;
|
||||||
|
|
||||||
|
if (!OutVerification.PassedAllGates())
|
||||||
|
{
|
||||||
|
OutVerification.FailureReason = FString::Printf(
|
||||||
|
TEXT("fresh gates failed: non-vacuous=%s largest=%s law=%s"),
|
||||||
|
OutVerification.bPassedNonVacuous ? TEXT("yes") : TEXT("no"),
|
||||||
|
OutVerification.bPassedLargestComponent ? TEXT("yes") : TEXT("no"),
|
||||||
|
VF_ConnectivityName(Law.Result));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MeasureCorpusEntry(const FVoxelStrateCorpusEntry& Entry,
|
||||||
|
FVoxelStrateMeasuredMetrics& OutMetrics) const override;
|
||||||
|
|
||||||
|
mutable int32 NumVerifications = 0;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const VoxelForgeTest::FTestWorld& World;
|
||||||
|
int32 TopVoxelZ = 0;
|
||||||
|
int32 BottomVoxelZ = 0;
|
||||||
|
FVector ArrivalPoint = FVector::ZeroVector;
|
||||||
|
FVector DeparturePoint = FVector::ZeroVector;
|
||||||
|
FVoxelStrateMeasureSettings MeasureSettings;
|
||||||
|
float LargestThreshold = 0.50f;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool VF_MeasureNativeCorpusEntry(
|
||||||
|
const VoxelForgeTest::FTestWorld& World,
|
||||||
|
const FVoxelStrateCorpusEntry& Entry,
|
||||||
|
int32 TopVoxelZ,
|
||||||
|
int32 BottomVoxelZ,
|
||||||
|
const FVoxelStrateMeasureSettings& Settings,
|
||||||
|
FVoxelStrateMeasuredMetrics& OutMetrics)
|
||||||
|
{
|
||||||
|
FVoxelStrateArchetypeParams Params = Entry.ArchetypeParams;
|
||||||
|
VF_SetAllRuntimeBounds(Params, static_cast<float>(TopVoxelZ) + 1.0f,
|
||||||
|
static_cast<float>(BottomVoxelZ));
|
||||||
|
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
FVoxelOpContext Context;
|
||||||
|
if (!VF_BuildNativeStrateStackForCandidate(
|
||||||
|
Entry.Archetype, Params, 1337, 14.0f, 0.0f,
|
||||||
|
VF_GetBoundarySeal(Params, Entry.Archetype),
|
||||||
|
World.StrateManager.Get(), Stack, Context)
|
||||||
|
|| Context.WorldRadiusVoxels != 0.0f)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stack.PrepareChunk(Context);
|
||||||
|
FStackDensitySampler Sampler(Stack);
|
||||||
|
const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler(
|
||||||
|
Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness,
|
||||||
|
Settings, nullptr);
|
||||||
|
OutMetrics = VF_SummarizeStrateMetrics(Metrics);
|
||||||
|
return Metrics.bValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FPromotionVerifier::MeasureCorpusEntry(
|
||||||
|
const FVoxelStrateCorpusEntry& Entry,
|
||||||
|
FVoxelStrateMeasuredMetrics& OutMetrics) const
|
||||||
|
{
|
||||||
|
return VF_MeasureNativeCorpusEntry(
|
||||||
|
World, Entry, TopVoxelZ, BottomVoxelZ, MeasureSettings, OutMetrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
FVoxelStratePromotableRecord VF_EvaluateCandidate(
|
||||||
|
const VoxelForgeTest::FTestWorld& World,
|
||||||
|
const FVoxelStrateComposerCandidate& Candidate,
|
||||||
|
int32 Season,
|
||||||
|
uint32 CorpusHash,
|
||||||
|
int32 TopVoxelZ,
|
||||||
|
int32 BottomVoxelZ,
|
||||||
|
const FVector& ArrivalPoint,
|
||||||
|
const FVector& DeparturePoint,
|
||||||
|
const FVoxelStrateMeasureSettings& Settings,
|
||||||
|
float LargestThreshold,
|
||||||
|
bool& bOutSurvivor)
|
||||||
|
{
|
||||||
|
bOutSurvivor = false;
|
||||||
|
FVoxelStratePromotableRecord Record = VF_MakeStratePromotableRecord(
|
||||||
|
Candidate, FVoxelStrateMetrics(), Season, CorpusHash, false, false, false);
|
||||||
|
if (!Candidate.bValid)
|
||||||
|
{
|
||||||
|
return Record;
|
||||||
|
}
|
||||||
|
|
||||||
|
FVoxelStrateArchetypeParams Params = Candidate.ArchetypeParams;
|
||||||
|
VF_SetAllRuntimeBounds(Params, static_cast<float>(TopVoxelZ) + 1.0f,
|
||||||
|
static_cast<float>(BottomVoxelZ));
|
||||||
|
Record.Params = Params;
|
||||||
|
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
FVoxelOpContext Context;
|
||||||
|
FString BuildError;
|
||||||
|
if (!VF_BuildStackFromRecipe(
|
||||||
|
Candidate.Recipe, Params, Candidate.Seed, 14.0f,
|
||||||
|
World.StrateManager.Get(), Stack, Context, &BuildError)
|
||||||
|
|| Context.WorldRadiusVoxels != 0.0f)
|
||||||
|
{
|
||||||
|
return Record;
|
||||||
|
}
|
||||||
|
Stack.PrepareChunk(Context);
|
||||||
|
FStackDensitySampler Sampler(Stack);
|
||||||
|
const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler(
|
||||||
|
Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness,
|
||||||
|
Settings, nullptr);
|
||||||
|
const bool bNonVacuous = Metrics.bValid
|
||||||
|
&& Metrics.NumSampled > 0 && Metrics.NumAir > 0 && Metrics.NumSolid > 0;
|
||||||
|
const bool bLargestEnough = bNonVacuous
|
||||||
|
&& Metrics.LargestComponentShare >= LargestThreshold;
|
||||||
|
const FVoxelConnectivityDiagnostics Law = VF_DiagnoseConnectivityWithSampler(
|
||||||
|
Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness,
|
||||||
|
ArrivalPoint, DeparturePoint, Settings);
|
||||||
|
const bool bLawPass = Law.bValid
|
||||||
|
&& Law.Result == EVoxelConnectivityResult::Connected
|
||||||
|
&& !Law.bStartSnapped && !Law.bGoalSnapped;
|
||||||
|
|
||||||
|
Record = VF_MakeStratePromotableRecord(
|
||||||
|
Candidate, Metrics, Season, CorpusHash,
|
||||||
|
bNonVacuous, bLargestEnough, bLawPass);
|
||||||
|
Record.Params = Params;
|
||||||
|
bOutSurvivor = bNonVacuous && bLargestEnough && bLawPass;
|
||||||
|
return Record;
|
||||||
|
}
|
||||||
|
|
||||||
|
double VF_MeanMeasuredSpread(const FVoxelStrateCorpus& Corpus,
|
||||||
|
const TArray<FVoxelStratePromotableRecord>& NewRecords,
|
||||||
|
const FVoxelStratePromotionPolicy& Policy)
|
||||||
|
{
|
||||||
|
TArray<FVoxelStrateMeasuredMetrics> Metrics;
|
||||||
|
for (const FVoxelStrateCorpusEntry& Entry : Corpus.GetEntries())
|
||||||
|
{
|
||||||
|
if (Entry.bHasMeasuredMetrics && Entry.MeasuredMetrics.IsUsable())
|
||||||
|
{
|
||||||
|
Metrics.Add(Entry.MeasuredMetrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const FVoxelStratePromotableRecord& Record : NewRecords)
|
||||||
|
{
|
||||||
|
if (Record.MeasuredMetrics.IsUsable())
|
||||||
|
{
|
||||||
|
Metrics.Add(Record.MeasuredMetrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Metrics.Num() < 2)
|
||||||
|
{
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double Sum = 0.0;
|
||||||
|
int64 PairCount = 0;
|
||||||
|
for (int32 A = 0; A < Metrics.Num(); ++A)
|
||||||
|
{
|
||||||
|
for (int32 B = A + 1; B < Metrics.Num(); ++B)
|
||||||
|
{
|
||||||
|
const double Distance = VF_NormalizedMeasuredMetricDistance(
|
||||||
|
Metrics[A], Metrics[B], Policy);
|
||||||
|
if (FMath::IsFinite(Distance) && Distance < TNumericLimits<double>::Max())
|
||||||
|
{
|
||||||
|
Sum += Distance;
|
||||||
|
++PairCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PairCount > 0 ? Sum / static_cast<double>(PairCount) : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VF_PromotionBatchesIdentical(
|
||||||
|
const FVoxelStratePromotionBatchResult& A,
|
||||||
|
const FVoxelStratePromotionBatchResult& B)
|
||||||
|
{
|
||||||
|
if (A.Promoted.Num() != B.Promoted.Num())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int32 Index = 0; Index < A.Promoted.Num(); ++Index)
|
||||||
|
{
|
||||||
|
const FVoxelStratePromotableRecord& Left = A.Promoted[Index];
|
||||||
|
const FVoxelStratePromotableRecord& Right = B.Promoted[Index];
|
||||||
|
if (Left.RecordId != Right.RecordId
|
||||||
|
|| Left.Archetype != Right.Archetype
|
||||||
|
|| !VF_AreStrateStructureRecipesIdentical(Left.Recipe, Right.Recipe)
|
||||||
|
|| !VF_AreStrateArchetypeParamsBitIdentical(
|
||||||
|
Left.Params, Right.Params, Left.Archetype)
|
||||||
|
|| Left.MeasuredMetrics.AirFraction != Right.MeasuredMetrics.AirFraction
|
||||||
|
|| Left.MeasuredMetrics.LargestComponentShare
|
||||||
|
!= Right.MeasuredMetrics.LargestComponentShare)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeComposerPromotionTest,
|
||||||
|
"VoxelForge.Composer.Promotion",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeComposerPromotionTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
using namespace VoxelForgeTest;
|
||||||
|
(void)Parameters;
|
||||||
|
const double TestStartSeconds = FPlatformTime::Seconds();
|
||||||
|
|
||||||
|
UVoxelSettings* AuthoredSettings = LoadObject<UVoxelSettings>(
|
||||||
|
nullptr, TEXT("/Game/VoxelForge/DA_Settings.DA_Settings"));
|
||||||
|
if (AuthoredSettings == nullptr)
|
||||||
|
{
|
||||||
|
AddError(TEXT("Could not load authored settings; promotion simulation did not run."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FTestWorld World;
|
||||||
|
World.Build(1337, 2, true);
|
||||||
|
if (!World.IsValid())
|
||||||
|
{
|
||||||
|
AddError(World.WhyInvalid());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
TestTrue(TEXT("promotion fixture keeps WorldRadiusVoxels at 0"),
|
||||||
|
World.Settings->WorldRadiusVoxels == 0.0f);
|
||||||
|
|
||||||
|
constexpr int32 CandidateStrateIndex = FTestWorld::SlotFlatPlain;
|
||||||
|
int32 TopVoxelZ = 0;
|
||||||
|
int32 BottomVoxelZ = 0;
|
||||||
|
if (!World.GetSlotVoxelZRange(CandidateStrateIndex, TopVoxelZ, BottomVoxelZ))
|
||||||
|
{
|
||||||
|
AddError(TEXT("promotion fixture has no candidate Z range"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FVector ArrivalPoint = FVector::ZeroVector;
|
||||||
|
FVector DeparturePoint = FVector::ZeroVector;
|
||||||
|
int32 ArrivalCount = 0;
|
||||||
|
int32 DepartureCount = 0;
|
||||||
|
for (const FVoxelPassage& Passage : World.StrateManager->GetPassages())
|
||||||
|
{
|
||||||
|
if (Passage.LowerStrateIndex == CandidateStrateIndex
|
||||||
|
&& Passage.UpperStrateIndex + 1 == CandidateStrateIndex)
|
||||||
|
{
|
||||||
|
// The promotion simulation intentionally remains the legacy coarse air-law probe
|
||||||
|
// (SampleStep=8), not the Tier-1 fine player-fit law. A floor standing anchor can lie
|
||||||
|
// in the solid half of a coarse cell after the landing adds its support slab; the
|
||||||
|
// deterministic door anchor is the actual air position where the tube meets that
|
||||||
|
// landing and keeps the strict no-snap assertion meaningful.
|
||||||
|
ArrivalPoint = Passage.LowerLanding.DoorPoint;
|
||||||
|
++ArrivalCount;
|
||||||
|
}
|
||||||
|
if (Passage.UpperStrateIndex == CandidateStrateIndex
|
||||||
|
&& Passage.LowerStrateIndex == CandidateStrateIndex + 1)
|
||||||
|
{
|
||||||
|
DeparturePoint = Passage.UpperLanding.DoorPoint;
|
||||||
|
++DepartureCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TestEqual(TEXT("promotion fixture has exactly one arrival mouth"), ArrivalCount, 1);
|
||||||
|
TestEqual(TEXT("promotion fixture has exactly one departure mouth"), DepartureCount, 1);
|
||||||
|
if (ArrivalCount != 1 || DepartureCount != 1)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FVoxelStrateMeasureSettings MeasureSettings;
|
||||||
|
MeasureSettings.SampleStep = SimulationSampleStep;
|
||||||
|
MeasureSettings.RadiusInVoxels = SimulationRadius;
|
||||||
|
MeasureSettings.CenterXY = FVector2D::ZeroVector;
|
||||||
|
MeasureSettings.MaxCells = 8000000;
|
||||||
|
MeasureSettings.MaxRouteRetries = 16;
|
||||||
|
MeasureSettings.HeadroomCells = 2;
|
||||||
|
// Use one fixed window for both authored/default members and candidates. Deriving a margin
|
||||||
|
// from each member's boundary seal can make a small authored strate have no measurable
|
||||||
|
// interior, which would make the duplicate metric space member-dependent.
|
||||||
|
MeasureSettings.InteriorMarginVoxels = 0;
|
||||||
|
|
||||||
|
FPromotionVerifier Verifier(
|
||||||
|
World, TopVoxelZ, BottomVoxelZ, ArrivalPoint, DeparturePoint,
|
||||||
|
MeasureSettings, LargestComponentSurvivalThreshold);
|
||||||
|
|
||||||
|
// The fixed path is keyed by the authored corpus hash and simulation constants. Starting with
|
||||||
|
// an empty JSON object makes reruns independent of an older test artifact without deleting it.
|
||||||
|
const FString SimulationDirectory = FPaths::ProjectSavedDir() / TEXT("ComposerPromotion")
|
||||||
|
/ FString::Printf(TEXT("seed_%d_step_%d_radius_%d"),
|
||||||
|
AuthoredSettings->Seed, SimulationSampleStep, SimulationRadius);
|
||||||
|
const FString PromotedStorePath = SimulationDirectory / TEXT("promoted_strates.json");
|
||||||
|
const FString CorruptStorePath = SimulationDirectory / TEXT("corrupt_promoted_strates.json");
|
||||||
|
FString StoreReport;
|
||||||
|
TestTrue(TEXT("promotion simulation can initialise its empty JSON store"),
|
||||||
|
VF_SaveStratePromotedRecords(
|
||||||
|
PromotedStorePath, TArray<FVoxelStratePromotableRecord>(), StoreReport));
|
||||||
|
AddInfo(StoreReport);
|
||||||
|
|
||||||
|
FVoxelStrateCorpus BaseCorpus;
|
||||||
|
FString BaseReport;
|
||||||
|
TestTrue(TEXT("promotion simulation loads its base project/default corpus"),
|
||||||
|
BaseCorpus.LoadFromAssetRegistry(BaseReport, PromotedStorePath, &Verifier));
|
||||||
|
AddInfo(BaseReport);
|
||||||
|
if (!BaseCorpus.IsValid())
|
||||||
|
{
|
||||||
|
AddError(TEXT("base corpus was invalid; promotion simulation did not run."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
TMap<FString, FVoxelStrateMeasuredMetrics> BaseMetrics;
|
||||||
|
int32 NumVacuousBaseMetrics = 0;
|
||||||
|
for (const FVoxelStrateCorpusEntry& Entry : BaseCorpus.GetEntries())
|
||||||
|
{
|
||||||
|
if (Entry.Provenance == EVoxelStrateCorpusProvenance::Promoted)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
TestTrue(FString::Printf(TEXT("base member %s is measured before policy selection"),
|
||||||
|
*Entry.SourcePath),
|
||||||
|
Entry.bHasMeasuredMetrics && Entry.MeasuredMetrics.bValid);
|
||||||
|
if (Entry.bHasMeasuredMetrics && !Entry.MeasuredMetrics.IsUsable())
|
||||||
|
{
|
||||||
|
++NumVacuousBaseMetrics;
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("base member %s measured but vacuous in the fixed promotion window: "
|
||||||
|
"air=%lld solid=%lld components=%d"),
|
||||||
|
*Entry.SourcePath, Entry.MeasuredMetrics.NumAir,
|
||||||
|
Entry.MeasuredMetrics.NumSolid, Entry.MeasuredMetrics.NumAirComponents));
|
||||||
|
}
|
||||||
|
if (Entry.bHasMeasuredMetrics && Entry.MeasuredMetrics.IsUsable())
|
||||||
|
{
|
||||||
|
BaseMetrics.Add(Entry.SourcePath, Entry.MeasuredMetrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TestTrue(TEXT("base project/default members have measured metrics for spread"),
|
||||||
|
BaseMetrics.Num() > 0);
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Base metric audit for duplicate policy: %d usable members, %d measured-but-vacuous "
|
||||||
|
"members; vacuous members use exact parameter identity as the conservative fallback."),
|
||||||
|
BaseMetrics.Num(), NumVacuousBaseMetrics));
|
||||||
|
|
||||||
|
FVoxelStratePromotionPolicy Policy;
|
||||||
|
Policy.MinimumNormalizedMeasuredMetricDistance = 0.20;
|
||||||
|
Policy.MaxPromotionsPerSeason = PromotionCapPerSeason;
|
||||||
|
Policy.FeatureScaleNormalizationVoxels = 256.0f;
|
||||||
|
Policy.ClearanceNormalizationVoxels = 64.0f;
|
||||||
|
|
||||||
|
TArray<FVoxelStratePromotableRecord> AllPromoted;
|
||||||
|
FString SeasonTable = TEXT(
|
||||||
|
"season | input corpus | corpus after | project/default/promoted | survivors/candidates "
|
||||||
|
"| survival | measured spread | promoted | near-dup/hash/cap\n");
|
||||||
|
double FirstSpread = -1.0;
|
||||||
|
double LastSpread = -1.0;
|
||||||
|
int32 NumSeasonLoadsWithPromotions = 0;
|
||||||
|
|
||||||
|
for (int32 Season = 1; Season <= NumSimulatedSeasons; ++Season)
|
||||||
|
{
|
||||||
|
FVoxelStrateCorpus Corpus;
|
||||||
|
FString CorpusReport;
|
||||||
|
const bool bLoaded = Corpus.LoadFromAssetRegistry(
|
||||||
|
CorpusReport, PromotedStorePath, &Verifier);
|
||||||
|
AddInfo(FString::Printf(TEXT("Season %d corpus load: %s"), Season, *CorpusReport));
|
||||||
|
TestTrue(FString::Printf(TEXT("season %d reloads the cumulative corpus safely"), Season),
|
||||||
|
bLoaded && Corpus.IsValid());
|
||||||
|
if (!bLoaded || !Corpus.IsValid())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const TPair<FString, FVoxelStrateMeasuredMetrics>& Pair : BaseMetrics)
|
||||||
|
{
|
||||||
|
TestTrue(FString::Printf(TEXT("season %d restores base metric %s"),
|
||||||
|
Season, *Pair.Key),
|
||||||
|
Corpus.SetMeasuredMetrics(Pair.Key, Pair.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AllPromoted.Num() > 0)
|
||||||
|
{
|
||||||
|
++NumSeasonLoadsWithPromotions;
|
||||||
|
TestTrue(FString::Printf(TEXT("season %d re-verifies every stored promotion"), Season),
|
||||||
|
Verifier.NumVerifications >= AllPromoted.Num());
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32 InputCorpusHash = Corpus.GetContentsHash();
|
||||||
|
const uint32 SeasonSeedBits = static_cast<uint32>(AuthoredSettings->Seed)
|
||||||
|
^ (static_cast<uint32>(Season) * 0x9E3779B9u);
|
||||||
|
const int32 SeasonSeed = static_cast<int32>(SeasonSeedBits);
|
||||||
|
TArray<FVoxelStratePromotableRecord> CandidateRecords;
|
||||||
|
CandidateRecords.Reserve(CandidatesPerSeason);
|
||||||
|
int32 NumSurvivors = 0;
|
||||||
|
for (int32 CandidateIndex = 0;
|
||||||
|
CandidateIndex < CandidatesPerSeason;
|
||||||
|
++CandidateIndex)
|
||||||
|
{
|
||||||
|
const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate(
|
||||||
|
Corpus, SeasonSeed, CandidateIndex, true);
|
||||||
|
bool bSurvivor = false;
|
||||||
|
CandidateRecords.Add(VF_EvaluateCandidate(
|
||||||
|
World, Candidate, Season, InputCorpusHash, TopVoxelZ, BottomVoxelZ,
|
||||||
|
ArrivalPoint, DeparturePoint, MeasureSettings,
|
||||||
|
LargestComponentSurvivalThreshold, bSurvivor));
|
||||||
|
if (bSurvivor)
|
||||||
|
{
|
||||||
|
++NumSurvivors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FVoxelStratePromotionBatchResult Batch = VF_SelectStratePromotions(
|
||||||
|
Corpus, CandidateRecords, Policy);
|
||||||
|
const FVoxelStratePromotionBatchResult RepeatBatch = VF_SelectStratePromotions(
|
||||||
|
Corpus, CandidateRecords, Policy);
|
||||||
|
TestTrue(FString::Printf(TEXT("season %d promotion selection is deterministic"), Season),
|
||||||
|
VF_PromotionBatchesIdentical(Batch, RepeatBatch));
|
||||||
|
TestTrue(FString::Printf(TEXT("season %d uses a valid explicit promotion policy"), Season),
|
||||||
|
Batch.bPolicyValid);
|
||||||
|
TestTrue(FString::Printf(TEXT("season %d respects its promotion cap"), Season),
|
||||||
|
Batch.Promoted.Num() <= PromotionCapPerSeason);
|
||||||
|
|
||||||
|
const double Spread = VF_MeanMeasuredSpread(Corpus, Batch.Promoted, Policy);
|
||||||
|
if (FirstSpread < 0.0) { FirstSpread = Spread; }
|
||||||
|
LastSpread = Spread;
|
||||||
|
|
||||||
|
const int32 ProjectCount = Corpus.NumForProvenance(EVoxelStrateCorpusProvenance::Project);
|
||||||
|
const int32 DefaultCount = Corpus.NumForProvenance(EVoxelStrateCorpusProvenance::Default);
|
||||||
|
const int32 ExistingPromotedCount = Corpus.NumForProvenance(
|
||||||
|
EVoxelStrateCorpusProvenance::Promoted);
|
||||||
|
const int32 CorpusAfter = Corpus.Num() + Batch.Promoted.Num();
|
||||||
|
const int32 PromotedAfter = ExistingPromotedCount + Batch.Promoted.Num();
|
||||||
|
const float SurvivalRate = CandidateRecords.Num() > 0
|
||||||
|
? static_cast<float>(NumSurvivors) / static_cast<float>(CandidateRecords.Num())
|
||||||
|
: 0.0f;
|
||||||
|
SeasonTable += FString::Printf(
|
||||||
|
TEXT("%d | %08x | %d | %d/%d/%d | %d/%d | %.1f%% | %.6f | %d | %d/%d/%d\n"),
|
||||||
|
Season, InputCorpusHash, CorpusAfter, ProjectCount, DefaultCount, PromotedAfter,
|
||||||
|
NumSurvivors, CandidateRecords.Num(), 100.0f * SurvivalRate, Spread,
|
||||||
|
Batch.Promoted.Num(), Batch.NumNearDuplicateRejected,
|
||||||
|
Batch.NumCorpusHashRejected, Batch.NumCapRejected);
|
||||||
|
|
||||||
|
FVoxelStrateSeasonManifest Manifest;
|
||||||
|
Manifest.Season = Season;
|
||||||
|
Manifest.Seed = SeasonSeed;
|
||||||
|
Manifest.InputCorpusHash = InputCorpusHash;
|
||||||
|
Manifest.CandidateCount = CandidateRecords.Num();
|
||||||
|
Manifest.SurvivorCount = NumSurvivors;
|
||||||
|
Manifest.PromotedCount = Batch.Promoted.Num();
|
||||||
|
Manifest.CorpusSize = CorpusAfter;
|
||||||
|
Manifest.ProjectCount = ProjectCount;
|
||||||
|
Manifest.DefaultCount = DefaultCount;
|
||||||
|
Manifest.PromotedCorpusCount = PromotedAfter;
|
||||||
|
Manifest.SurvivalRate = SurvivalRate;
|
||||||
|
Manifest.MeasuredSpread = Spread;
|
||||||
|
FString ManifestReport;
|
||||||
|
const FString ManifestPath = SimulationDirectory
|
||||||
|
/ FString::Printf(TEXT("season_%02d_manifest.json"), Season);
|
||||||
|
TestTrue(FString::Printf(TEXT("season %d manifest is written beside the store"), Season),
|
||||||
|
VF_SaveStrateSeasonManifest(ManifestPath, Manifest, ManifestReport));
|
||||||
|
AddInfo(ManifestReport);
|
||||||
|
|
||||||
|
AllPromoted.Append(Batch.Promoted);
|
||||||
|
TestTrue(FString::Printf(TEXT("season %d cumulative promoted store is written"), Season),
|
||||||
|
VF_SaveStratePromotedRecords(PromotedStorePath, AllPromoted, StoreReport));
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInfo(SeasonTable);
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Promotion policy: normalized measured-metric distance < %.2f rejects a near duplicate; "
|
||||||
|
"dimensions=(air, largest share, walkable, feature/%.0f voxels, clearance/%.0f voxels); "
|
||||||
|
"cap=%d per season; records=%d; reloads with promotions=%d."),
|
||||||
|
Policy.MinimumNormalizedMeasuredMetricDistance,
|
||||||
|
Policy.FeatureScaleNormalizationVoxels, Policy.ClearanceNormalizationVoxels,
|
||||||
|
Policy.MaxPromotionsPerSeason, AllPromoted.Num(), NumSeasonLoadsWithPromotions));
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Measured spread is mean pairwise distance in that normalized five-dimensional space: "
|
||||||
|
"season 1=%.6f, season 5=%.6f, delta=%+.6f."),
|
||||||
|
FirstSpread, LastSpread, LastSpread - FirstSpread));
|
||||||
|
|
||||||
|
TestEqual(TEXT("five simulated seasons completed"), NumSeasonLoadsWithPromotions + 1,
|
||||||
|
NumSimulatedSeasons);
|
||||||
|
TestTrue(TEXT("promotion compounding produced at least one promoted member"),
|
||||||
|
AllPromoted.Num() > 0);
|
||||||
|
TestEqual(TEXT("promotion cap was never exceeded in cumulative output"),
|
||||||
|
FMath::Min(AllPromoted.Num(), NumSimulatedSeasons * PromotionCapPerSeason),
|
||||||
|
AllPromoted.Num());
|
||||||
|
TestTrue(TEXT("promotion corpus contains project members"),
|
||||||
|
BaseCorpus.NumForProvenance(EVoxelStrateCorpusProvenance::Project) > 0);
|
||||||
|
TestTrue(TEXT("promotion corpus contains default members"),
|
||||||
|
BaseCorpus.NumForProvenance(EVoxelStrateCorpusProvenance::Default) > 0);
|
||||||
|
|
||||||
|
// Deliberately corrupt only the stored metric. The loader must still admit the record only
|
||||||
|
// after rebuilding it, and the corpus entry must expose the fresh value rather than the JSON.
|
||||||
|
if (AllPromoted.Num() > 0)
|
||||||
|
{
|
||||||
|
FVoxelStratePromotableRecord CorruptRecord = AllPromoted[0];
|
||||||
|
const float OriginalAir = CorruptRecord.MeasuredMetrics.AirFraction;
|
||||||
|
CorruptRecord.MeasuredMetrics.AirFraction = OriginalAir > 0.5f ? 0.01f : 0.99f;
|
||||||
|
TArray<FVoxelStratePromotableRecord> CorruptRecords;
|
||||||
|
CorruptRecords.Add(CorruptRecord);
|
||||||
|
FString CorruptReport;
|
||||||
|
TestTrue(TEXT("corrupt-metric promotion store can be written for the safety test"),
|
||||||
|
VF_SaveStratePromotedRecords(CorruptStorePath, CorruptRecords, CorruptReport));
|
||||||
|
|
||||||
|
FVoxelStrateCorpus CorruptCorpus;
|
||||||
|
FString CorruptLoadReport;
|
||||||
|
TestTrue(TEXT("corrupt stored metric is reverified before corpus admission"),
|
||||||
|
CorruptCorpus.LoadFromAssetRegistry(
|
||||||
|
CorruptLoadReport, CorruptStorePath, &Verifier));
|
||||||
|
AddInfo(CorruptLoadReport);
|
||||||
|
TestTrue(TEXT("corrupt stored metric was detected and replaced"),
|
||||||
|
CorruptLoadReport.Contains(TEXT("stored metrics replaced for 1 records")));
|
||||||
|
|
||||||
|
FVoxelStratePromotionVerification ExpectedVerification;
|
||||||
|
TestTrue(TEXT("safety verifier can independently rebuild the corrupted record"),
|
||||||
|
Verifier.Verify(CorruptRecord, ExpectedVerification));
|
||||||
|
bool bFoundReverifiedEntry = false;
|
||||||
|
for (const FVoxelStrateCorpusEntry& Entry : CorruptCorpus.GetEntries())
|
||||||
|
{
|
||||||
|
if (Entry.Provenance == EVoxelStrateCorpusProvenance::Promoted
|
||||||
|
&& Entry.PromotionRecordId == CorruptRecord.RecordId)
|
||||||
|
{
|
||||||
|
bFoundReverifiedEntry = true;
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("stale metric audit %s: stored/corpus air=%.9g, fresh air=%.9g, "
|
||||||
|
"corrupted JSON air=%.9g"),
|
||||||
|
*CorruptRecord.RecordId,
|
||||||
|
Entry.MeasuredMetrics.AirFraction,
|
||||||
|
ExpectedVerification.MeasuredMetrics.AirFraction,
|
||||||
|
CorruptRecord.MeasuredMetrics.AirFraction));
|
||||||
|
TestTrue(TEXT("loaded promoted metric equals fresh measurement, not stale JSON"),
|
||||||
|
FMath::IsNearlyEqual(
|
||||||
|
Entry.MeasuredMetrics.AirFraction,
|
||||||
|
ExpectedVerification.MeasuredMetrics.AirFraction));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TestTrue(TEXT("corrupt safety test found its promoted corpus entry"),
|
||||||
|
bFoundReverifiedEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const double TestSeconds = FPlatformTime::Seconds() - TestStartSeconds;
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Promotion total runtime %.3fs; WorldRadiusVoxels=0; JSON store=%s."),
|
||||||
|
TestSeconds, *PromotedStorePath));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS && WITH_EDITOR
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
// Tier 4c — offline season composition, manifest determinism, and exact rebuild.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS && WITH_EDITOR
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
#include "Misc/FileHelper.h"
|
||||||
|
#include "Misc/Paths.h"
|
||||||
|
#include "UObject/Package.h" // GetTransientPackage() — this file does not use the shared
|
||||||
|
// fixture, which is where the other tests pick it up implicitly.
|
||||||
|
#include "UObject/StrongObjectPtr.h"
|
||||||
|
|
||||||
|
#include "VoxelDensityOpStack.h"
|
||||||
|
#include "VoxelGenerator.h"
|
||||||
|
#include "VoxelSeasonAsset.h"
|
||||||
|
#include "VoxelSeasonManifest.h"
|
||||||
|
#include "VoxelSettings.h"
|
||||||
|
#include "VoxelStrateComposer.h"
|
||||||
|
#include "VoxelStrateDefinition.h"
|
||||||
|
#include "VoxelStrateManager.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
bool SameDensityBits(float A, float B)
|
||||||
|
{
|
||||||
|
return FMemory::Memcmp(&A, &B, sizeof(float)) == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeComposerSeasonTest,
|
||||||
|
"VoxelForge.Composer.Season",
|
||||||
|
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
(void)Parameters;
|
||||||
|
|
||||||
|
FVoxelStrateCorpus Corpus;
|
||||||
|
FString CorpusReport;
|
||||||
|
TestTrue(TEXT("season test loads a valid corpus"),
|
||||||
|
Corpus.LoadFromAssetRegistry(CorpusReport) && Corpus.IsValid());
|
||||||
|
if (!Corpus.IsValid())
|
||||||
|
{
|
||||||
|
AddError(CorpusReport);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FVoxelSeasonCompositionSettings Settings;
|
||||||
|
Settings.Corpus = &Corpus;
|
||||||
|
Settings.CandidateCount = 24;
|
||||||
|
Settings.SelectedStrateCount = 6;
|
||||||
|
Settings.StrateHeightInChunks = 8;
|
||||||
|
Settings.InterStrateGapChunks = 0;
|
||||||
|
Settings.OriginSpineRadius = 14.0f;
|
||||||
|
Settings.WorldRadiusVoxels = 0.0f;
|
||||||
|
Settings.OutputDirectory = FPaths::Combine(
|
||||||
|
FPaths::ProjectSavedDir(), TEXT("VoxelForge"), TEXT("SeasonTest"));
|
||||||
|
Settings.bWriteArtifacts = true;
|
||||||
|
Settings.bWriteReviewPage = true;
|
||||||
|
Settings.bIncludeCandidateAudit = true;
|
||||||
|
|
||||||
|
const int32 SeasonSeed = 0x2468;
|
||||||
|
const FVoxelSeasonManifest Manifest = VF_ComposeSeason(SeasonSeed, Settings);
|
||||||
|
const bool bManifestUsable = Manifest.IsUsable();
|
||||||
|
if (!bManifestUsable)
|
||||||
|
{
|
||||||
|
// The confirmed player-fit gate is intentionally allowed to expose an empty survivor
|
||||||
|
// pool. This corpus/fixture was authored before capsule occupancy was measured, so an
|
||||||
|
// honest blocked composition is a passing diagnostic outcome, not a reason to relax the
|
||||||
|
// gate or silently fall back to the old air-only law. Keep the full manifest assertions
|
||||||
|
// below for any future corpus that supplies enough physically traversable candidates.
|
||||||
|
const bool bBlockedByPlayerFitGate = Manifest.Error.Contains(
|
||||||
|
TEXT("candidates survived the hard gates"));
|
||||||
|
TestTrue(TEXT("empty composition is explicitly reported as a hard-gate survivor shortage"),
|
||||||
|
bBlockedByPlayerFitGate);
|
||||||
|
if (bBlockedByPlayerFitGate)
|
||||||
|
{
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Season composition remains blocked by the fine step-1 player-fit gate: %s"),
|
||||||
|
*Manifest.Error));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
AddError(Manifest.Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
TestTrue(TEXT("composition produces a usable season manifest"), bManifestUsable);
|
||||||
|
TestEqual(TEXT("candidate count is the configured number of attempts"),
|
||||||
|
Manifest.CandidateCount, Settings.CandidateCount);
|
||||||
|
TestEqual(TEXT("selected count is the configured spine length"),
|
||||||
|
Manifest.SelectedCount, Settings.SelectedStrateCount);
|
||||||
|
TestEqual(TEXT("candidate audit contains every attempt"),
|
||||||
|
Manifest.CandidateAudit.Num(), Settings.CandidateCount);
|
||||||
|
TestEqual(TEXT("WorldRadiusVoxels remains zero in the artifact"),
|
||||||
|
Manifest.WorldRadiusVoxels, 0.0f);
|
||||||
|
TestEqual(TEXT("selected grounded/outlier counts add to generated slots"),
|
||||||
|
Manifest.GroundedSelectedCount + Manifest.OutlierSelectedCount,
|
||||||
|
Manifest.SelectedCount - Manifest.FixedSelectedCount);
|
||||||
|
|
||||||
|
for (int32 Index = 0; Index < Manifest.Strates.Num(); ++Index)
|
||||||
|
{
|
||||||
|
const FVoxelSeasonStrate& Strate = Manifest.Strates[Index];
|
||||||
|
TestEqual(FString::Printf(TEXT("slot %d keeps descent order"), Index),
|
||||||
|
Strate.DepthIndex, Index);
|
||||||
|
TestTrue(FString::Printf(TEXT("slot %d passed non-vacuous gate"), Index),
|
||||||
|
Strate.bPassedNonVacuous);
|
||||||
|
TestTrue(FString::Printf(TEXT("slot %d passed largest-component gate"), Index),
|
||||||
|
Strate.bPassedLargestComponent);
|
||||||
|
TestTrue(FString::Printf(TEXT("slot %d passed primordial law"), Index),
|
||||||
|
Strate.bPassedPrimordialLaw
|
||||||
|
&& Strate.PrimordialLawResult == EVoxelConnectivityResult::Connected);
|
||||||
|
TestTrue(FString::Printf(TEXT("slot %d has zero world radius"), Index),
|
||||||
|
Manifest.WorldRadiusVoxels == 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
TestTrue(TEXT("season manifest JSON was emitted"), !Manifest.SerializedJson.IsEmpty());
|
||||||
|
TestTrue(TEXT("season manifest file was emitted"), FPaths::FileExists(Manifest.ManifestPath));
|
||||||
|
TestTrue(TEXT("selected-season review page was emitted"),
|
||||||
|
FPaths::FileExists(Manifest.ReviewPagePath));
|
||||||
|
FString ReviewHtml;
|
||||||
|
if (FFileHelper::LoadFileToString(ReviewHtml, *Manifest.ReviewPagePath))
|
||||||
|
{
|
||||||
|
TestTrue(TEXT("review page explains why each selected slot was chosen"),
|
||||||
|
ReviewHtml.Contains(TEXT("Why selected:")));
|
||||||
|
TestTrue(TEXT("review page marks boss slots"), ReviewHtml.Contains(TEXT("Boss slot:")));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(TEXT("could not read review page %s"), *Manifest.ReviewPagePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose again with the same inputs. SerializedJson excludes derived filesystem paths, so
|
||||||
|
// this is the byte-level determinism assertion for the portable artifact itself.
|
||||||
|
const FVoxelSeasonManifest RepeatManifest = VF_ComposeSeason(SeasonSeed, Settings);
|
||||||
|
TestTrue(TEXT("repeat composition is usable"), RepeatManifest.IsUsable());
|
||||||
|
TestEqual(TEXT("same season seed produces byte-identical JSON"),
|
||||||
|
RepeatManifest.SerializedJson, Manifest.SerializedJson);
|
||||||
|
|
||||||
|
FVoxelSeasonManifest LoadedManifest;
|
||||||
|
FString LoadReport;
|
||||||
|
TestTrue(TEXT("manifest loads from its JSON file"),
|
||||||
|
VF_LoadVoxelSeasonManifest(Manifest.ManifestPath, LoadedManifest, LoadReport));
|
||||||
|
if (!LoadedManifest.IsUsable())
|
||||||
|
{
|
||||||
|
AddError(LoadReport);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
TestEqual(TEXT("loaded manifest has the same number of selected strates"),
|
||||||
|
LoadedManifest.Strates.Num(), Manifest.Strates.Num());
|
||||||
|
TestEqual(TEXT("loaded manifest content hash matches the composed payload"),
|
||||||
|
LoadedManifest.ContentHash, Manifest.ContentHash);
|
||||||
|
TestEqual(TEXT("manifest recomputes the hash it was composed from"),
|
||||||
|
VF_ComputeVoxelSeasonManifestContentHash(LoadedManifest),
|
||||||
|
LoadedManifest.ContentHash);
|
||||||
|
|
||||||
|
int64 ComparedDensitySamples = 0;
|
||||||
|
bool bAllDensityBitsIdentical = true;
|
||||||
|
for (int32 Index = 0; Index < Manifest.Strates.Num(); ++Index)
|
||||||
|
{
|
||||||
|
const FVoxelSeasonStrate& OriginalStrate = Manifest.Strates[Index];
|
||||||
|
const FVoxelSeasonStrate& LoadedStrate = LoadedManifest.Strates[Index];
|
||||||
|
TestTrue(FString::Printf(TEXT("slot %d parameter vector survives JSON bit-identically"), Index),
|
||||||
|
VF_AreStrateArchetypeParamsBitIdentical(
|
||||||
|
OriginalStrate.Params, LoadedStrate.Params, OriginalStrate.Archetype));
|
||||||
|
TestTrue(FString::Printf(TEXT("slot %d recipe survives JSON identically"), Index),
|
||||||
|
VF_AreStrateStructureRecipesIdentical(
|
||||||
|
OriginalStrate.Recipe, LoadedStrate.Recipe));
|
||||||
|
|
||||||
|
FVoxelOpStack OriginalStack;
|
||||||
|
FVoxelOpContext OriginalContext;
|
||||||
|
FVoxelOpStack LoadedStack;
|
||||||
|
FVoxelOpContext LoadedContext;
|
||||||
|
FString BuildError;
|
||||||
|
TestTrue(FString::Printf(TEXT("slot %d original stack rebuilds"), Index),
|
||||||
|
VF_RebuildVoxelSeasonStrate(
|
||||||
|
OriginalStrate, Manifest.OriginSpineRadius, nullptr,
|
||||||
|
OriginalStack, OriginalContext, &BuildError));
|
||||||
|
TestTrue(FString::Printf(TEXT("slot %d loaded stack rebuilds"), Index),
|
||||||
|
VF_RebuildVoxelSeasonStrate(
|
||||||
|
LoadedStrate, LoadedManifest.OriginSpineRadius, nullptr,
|
||||||
|
LoadedStack, LoadedContext, &BuildError));
|
||||||
|
if (OriginalStack.Num() == 0 || LoadedStack.Num() == 0)
|
||||||
|
{
|
||||||
|
bAllDensityBitsIdentical = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
TestEqual(FString::Printf(TEXT("slot %d original/rebuilt stack operation count"), Index),
|
||||||
|
LoadedStack.Num(), OriginalStack.Num());
|
||||||
|
TestEqual(FString::Printf(TEXT("slot %d original context radius"), Index),
|
||||||
|
OriginalContext.WorldRadiusVoxels, 0.0f);
|
||||||
|
TestEqual(FString::Printf(TEXT("slot %d loaded context radius"), Index),
|
||||||
|
LoadedContext.WorldRadiusVoxels, 0.0f);
|
||||||
|
OriginalStack.PrepareChunk(OriginalContext);
|
||||||
|
LoadedStack.PrepareChunk(LoadedContext);
|
||||||
|
|
||||||
|
// Compare the complete bounded probe lattice for each selected field at two-voxel
|
||||||
|
// spacing, not a handful of favorable points. Every sampled scalar must retain its
|
||||||
|
// exact IEEE-754 bits after JSON load and stack reconstruction.
|
||||||
|
constexpr int32 ProbeHalfExtent = 32;
|
||||||
|
constexpr int32 ProbeStep = 2;
|
||||||
|
for (int32 Z = OriginalStrate.BottomWorldZ;
|
||||||
|
Z < OriginalStrate.TopWorldZ && bAllDensityBitsIdentical;
|
||||||
|
Z += ProbeStep)
|
||||||
|
{
|
||||||
|
for (int32 Y = -ProbeHalfExtent;
|
||||||
|
Y < ProbeHalfExtent && bAllDensityBitsIdentical;
|
||||||
|
Y += ProbeStep)
|
||||||
|
{
|
||||||
|
for (int32 X = -ProbeHalfExtent;
|
||||||
|
X < ProbeHalfExtent;
|
||||||
|
X += ProbeStep)
|
||||||
|
{
|
||||||
|
const float OriginalDensity = OriginalStack.EvalMC(
|
||||||
|
static_cast<float>(X), static_cast<float>(Y), static_cast<float>(Z));
|
||||||
|
const float LoadedDensity = LoadedStack.EvalMC(
|
||||||
|
static_cast<float>(X), static_cast<float>(Y), static_cast<float>(Z));
|
||||||
|
++ComparedDensitySamples;
|
||||||
|
if (!SameDensityBits(OriginalDensity, LoadedDensity))
|
||||||
|
{
|
||||||
|
bAllDensityBitsIdentical = false;
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("slot %d density mismatch at (%d,%d,%d): 0x%08x vs 0x%08x"),
|
||||||
|
Index, X, Y, Z,
|
||||||
|
*reinterpret_cast<const uint32*>(&OriginalDensity),
|
||||||
|
*reinterpret_cast<const uint32*>(&LoadedDensity)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddInfo(FString::Printf(TEXT("round-trip density probe compared %lld bit-identical samples"),
|
||||||
|
ComparedDensitySamples));
|
||||||
|
TestTrue(TEXT("manifest round-trip density field is bit-identical"),
|
||||||
|
bAllDensityBitsIdentical);
|
||||||
|
|
||||||
|
// Runtime seam: embed the reviewed JSON in a cookable primary asset, then let two independent
|
||||||
|
// managers/generators materialise the complete selected layout. The authored settings seed and
|
||||||
|
// radius are deliberately different so the test proves the season is authoritative.
|
||||||
|
TStrongObjectPtr<UVoxelSeasonAsset> SeasonAsset(
|
||||||
|
NewObject<UVoxelSeasonAsset>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
FString AssetReport;
|
||||||
|
TestTrue(TEXT("cookable season asset accepts the reviewed manifest"),
|
||||||
|
SeasonAsset->SetManifestJson(Manifest.SerializedJson, AssetReport));
|
||||||
|
|
||||||
|
TStrongObjectPtr<UVoxelSettings> RuntimeSettings(
|
||||||
|
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
RuntimeSettings->Seed = Manifest.Seed + 17;
|
||||||
|
RuntimeSettings->OriginSpineRadius = Manifest.OriginSpineRadius + 31.0f;
|
||||||
|
RuntimeSettings->WorldRadiusVoxels = 4096.0f;
|
||||||
|
RuntimeSettings->Season = TSoftObjectPtr<UVoxelSeasonAsset>(SeasonAsset.Get());
|
||||||
|
|
||||||
|
TStrongObjectPtr<UVoxelStrateManager> ManagerA(
|
||||||
|
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
TStrongObjectPtr<UVoxelStrateManager> ManagerB(
|
||||||
|
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
TestTrue(TEXT("first independent season manager initializes"),
|
||||||
|
ManagerA->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed()));
|
||||||
|
TestTrue(TEXT("second independent season manager initializes"),
|
||||||
|
ManagerB->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed()));
|
||||||
|
TestEqual(TEXT("season overrides the authored settings seed"),
|
||||||
|
RuntimeSettings->GetEffectiveWorldSeed(), Manifest.Seed);
|
||||||
|
TestEqual(TEXT("season keeps WorldRadiusVoxels zero"),
|
||||||
|
RuntimeSettings->GetEffectiveWorldRadiusVoxels(), 0.0f);
|
||||||
|
TestEqual(TEXT("season owns the origin spine radius used by recipe stacks"),
|
||||||
|
RuntimeSettings->GetEffectiveOriginSpineRadius(), Manifest.OriginSpineRadius);
|
||||||
|
TestEqual(TEXT("first manager loads every season slot"),
|
||||||
|
ManagerA->GetNumStrates(), Manifest.Strates.Num());
|
||||||
|
TestEqual(TEXT("second manager loads every season slot"),
|
||||||
|
ManagerB->GetNumStrates(), Manifest.Strates.Num());
|
||||||
|
TestEqual(TEXT("first manager exposes the reviewed content hash"),
|
||||||
|
ManagerA->GetSeasonContentHash(), Manifest.ContentHash);
|
||||||
|
TestEqual(TEXT("second manager exposes the reviewed content hash"),
|
||||||
|
ManagerB->GetSeasonContentHash(), Manifest.ContentHash);
|
||||||
|
|
||||||
|
TStrongObjectPtr<UVoxelGenerator> GeneratorA(
|
||||||
|
NewObject<UVoxelGenerator>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
TStrongObjectPtr<UVoxelGenerator> GeneratorB(
|
||||||
|
NewObject<UVoxelGenerator>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
GeneratorA->InitializeSettings(RuntimeSettings.Get());
|
||||||
|
GeneratorB->InitializeSettings(RuntimeSettings.Get());
|
||||||
|
GeneratorA->SetStrateManager(ManagerA.Get());
|
||||||
|
GeneratorB->SetStrateManager(ManagerB.Get());
|
||||||
|
|
||||||
|
int64 ManagerDensitySamples = 0;
|
||||||
|
bool bManagersBitIdentical = true;
|
||||||
|
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
|
||||||
|
{
|
||||||
|
for (int32 Z = Strate.BottomWorldZ;
|
||||||
|
Z < Strate.TopWorldZ && bManagersBitIdentical; Z += 2)
|
||||||
|
{
|
||||||
|
for (int32 Y = -32; Y < 32 && bManagersBitIdentical; Y += 2)
|
||||||
|
{
|
||||||
|
for (int32 X = -32; X < 32; X += 2)
|
||||||
|
{
|
||||||
|
const float A = GeneratorA->GetDensityAt((float)X, (float)Y, (float)Z);
|
||||||
|
const float B = GeneratorB->GetDensityAt((float)X, (float)Y, (float)Z);
|
||||||
|
++ManagerDensitySamples;
|
||||||
|
if (!SameDensityBits(A, B))
|
||||||
|
{
|
||||||
|
bManagersBitIdentical = false;
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("independent season managers diverged at (%d,%d,%d): 0x%08x vs 0x%08x"),
|
||||||
|
X, Y, Z, *reinterpret_cast<const uint32*>(&A),
|
||||||
|
*reinterpret_cast<const uint32*>(&B)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("two independent runtime season managers compared %lld bit-identical density samples"),
|
||||||
|
ManagerDensitySamples));
|
||||||
|
TestTrue(TEXT("two independent runtime season managers produce a bit-identical field"),
|
||||||
|
bManagersBitIdentical);
|
||||||
|
|
||||||
|
// The former HasComposerRecipeOverride force made every one of these tiles Mixed (zero proved
|
||||||
|
// tiles). The new path asks the exact recipe stack and brute-forces every verdict it accepts.
|
||||||
|
int32 NumClassified = 0;
|
||||||
|
int32 NumMixed = 0;
|
||||||
|
int32 NumAllSolid = 0;
|
||||||
|
int32 NumAllAir = 0;
|
||||||
|
int32 NumVerdictsBruteForced = 0;
|
||||||
|
int32 NumFalseVerdicts = 0;
|
||||||
|
constexpr int32 TileCells = 16;
|
||||||
|
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
|
||||||
|
{
|
||||||
|
for (int32 ZBand = 1; ZBand <= 3; ++ZBand)
|
||||||
|
{
|
||||||
|
const int32 Z = Strate.BottomWorldZ
|
||||||
|
+ (Strate.TopWorldZ - Strate.BottomWorldZ) * ZBand / 4;
|
||||||
|
for (int32 Y = -128; Y <= 128; Y += 32)
|
||||||
|
{
|
||||||
|
for (int32 X = -128; X <= 128; X += 32)
|
||||||
|
{
|
||||||
|
const FIntVector Origin(X, Y, Z);
|
||||||
|
const EVoxelTileClass Verdict = GeneratorA->ClassifyTile(
|
||||||
|
Origin, 1, TileCells);
|
||||||
|
++NumClassified;
|
||||||
|
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
|
||||||
|
if (Verdict == EVoxelTileClass::AllSolid) ++NumAllSolid;
|
||||||
|
else ++NumAllAir;
|
||||||
|
|
||||||
|
if (NumVerdictsBruteForced >= 64) continue;
|
||||||
|
++NumVerdictsBruteForced;
|
||||||
|
const bool bSolid = Verdict == EVoxelTileClass::AllSolid;
|
||||||
|
bool bBad = false;
|
||||||
|
for (int32 GZ = -1; GZ <= TileCells + 1 && !bBad; ++GZ)
|
||||||
|
for (int32 GY = -1; GY <= TileCells + 1 && !bBad; ++GY)
|
||||||
|
for (int32 GX = -1; GX <= TileCells + 1; ++GX)
|
||||||
|
{
|
||||||
|
const float D = GeneratorA->GetDensityAt(
|
||||||
|
(float)(X + GX), (float)(Y + GY), (float)(Z + GZ));
|
||||||
|
if (bSolid ? D >= 0.0f : D < 0.0f)
|
||||||
|
{
|
||||||
|
bBad = true;
|
||||||
|
++NumFalseVerdicts;
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("composed recipe false %s tile at (%d,%d,%d), sample (%d,%d,%d)=%.9g"),
|
||||||
|
bSolid ? TEXT("AllSolid") : TEXT("AllAir"), X, Y, Z,
|
||||||
|
X + GX, Y + GY, Z + GZ, D));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("composed recipe tile classification: before force=%d Mixed/0 proved; after=%d Mixed, %d AllSolid, %d AllAir; %d accepted verdicts brute-forced, %d violations"),
|
||||||
|
NumClassified, NumMixed, NumAllSolid, NumAllAir,
|
||||||
|
NumVerdictsBruteForced, NumFalseVerdicts));
|
||||||
|
TestTrue(TEXT("composed recipe classification proves at least one uniform tile"),
|
||||||
|
NumAllSolid + NumAllAir > 0);
|
||||||
|
TestEqual(TEXT("composed recipe classification has no false uniform verdict"),
|
||||||
|
NumFalseVerdicts, 0);
|
||||||
|
|
||||||
|
FString TamperedJson = Manifest.SerializedJson;
|
||||||
|
const FString OriginalSeedField = FString::Printf(TEXT("\"seed\": %d"), Manifest.Seed);
|
||||||
|
const FString TamperedSeedField = FString::Printf(TEXT("\"seed\": %d"), Manifest.Seed + 1);
|
||||||
|
TamperedJson = TamperedJson.Replace(*OriginalSeedField, *TamperedSeedField,
|
||||||
|
ESearchCase::CaseSensitive);
|
||||||
|
FVoxelSeasonManifest TamperedManifest;
|
||||||
|
FString TamperReport;
|
||||||
|
TestFalse(TEXT("stale/edited season JSON is rejected by its content hash"),
|
||||||
|
VF_DeserializeVoxelSeasonManifest(
|
||||||
|
TamperedJson, TamperedManifest, TamperReport));
|
||||||
|
TestTrue(TEXT("stale season failure names the content hash"),
|
||||||
|
TamperReport.Contains(TEXT("content hash")));
|
||||||
|
|
||||||
|
// Explicit unset regression gate: no season means the authored pointer, seed and radius still
|
||||||
|
// flow through the original pool/fixed path.
|
||||||
|
TStrongObjectPtr<UVoxelSettings> LegacySettings(
|
||||||
|
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
TStrongObjectPtr<UVoxelStrateDefinition> LegacyDefinition(
|
||||||
|
NewObject<UVoxelStrateDefinition>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
LegacySettings->Seed = 77123;
|
||||||
|
LegacySettings->OriginSpineRadius = 23.0f;
|
||||||
|
LegacySettings->WorldRadiusVoxels = 987.0f;
|
||||||
|
LegacySettings->TotalStrates = 1;
|
||||||
|
LegacyDefinition->StrateHeightInChunks = 7;
|
||||||
|
LegacySettings->StratePool.Add(
|
||||||
|
TSoftObjectPtr<UVoxelStrateDefinition>(LegacyDefinition.Get()));
|
||||||
|
TStrongObjectPtr<UVoxelStrateManager> LegacyManager(
|
||||||
|
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
TestTrue(TEXT("legacy manager still initializes with Season unset"),
|
||||||
|
LegacyManager->Initialize(LegacySettings.Get(), LegacySettings->Seed));
|
||||||
|
TestTrue(TEXT("season unset remains the authored-pool path"),
|
||||||
|
LegacySettings->Season.IsNull()
|
||||||
|
&& LegacyManager->GetNumStrates() == 1
|
||||||
|
&& LegacyManager->GetLayout()[0].Definition == LegacyDefinition.Get()
|
||||||
|
&& LegacyManager->GetLayout()[0].HeightInChunks == 7
|
||||||
|
&& LegacyManager->GetWorldSeed() == LegacySettings->Seed
|
||||||
|
&& LegacySettings->GetEffectiveWorldSeed() == LegacySettings->Seed
|
||||||
|
&& LegacySettings->GetEffectiveOriginSpineRadius()
|
||||||
|
== LegacySettings->OriginSpineRadius
|
||||||
|
&& LegacySettings->GetEffectiveWorldRadiusVoxels()
|
||||||
|
== LegacySettings->WorldRadiusVoxels);
|
||||||
|
|
||||||
|
// Transactional live-rebuild regression: make a hash-valid manifest whose first authored
|
||||||
|
// definition cannot load. Initialize must reject it without destroying the prior valid layout,
|
||||||
|
// season hash, or passage-version snapshot.
|
||||||
|
if (Manifest.Strates.Num() > 0)
|
||||||
|
{
|
||||||
|
FVoxelSeasonManifest BrokenManifest = Manifest;
|
||||||
|
BrokenManifest.Strates[0].SourceDefinitionPath =
|
||||||
|
TEXT("/Game/VoxelForge/DefinitelyMissing/Definition.Definition");
|
||||||
|
BrokenManifest.ContentHash = VF_ComputeVoxelSeasonManifestContentHash(BrokenManifest);
|
||||||
|
BrokenManifest.SerializedJson = VF_SerializeVoxelSeasonManifest(BrokenManifest);
|
||||||
|
|
||||||
|
TStrongObjectPtr<UVoxelSeasonAsset> BrokenSeason(
|
||||||
|
NewObject<UVoxelSeasonAsset>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
FString BrokenReport;
|
||||||
|
TestTrue(TEXT("broken test season keeps a valid content hash"),
|
||||||
|
BrokenSeason->SetManifestJson(BrokenManifest.SerializedJson, BrokenReport));
|
||||||
|
|
||||||
|
TStrongObjectPtr<UVoxelStrateManager> TransactionalManager(
|
||||||
|
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
TestTrue(TEXT("transactional manager starts from a valid layout"),
|
||||||
|
TransactionalManager->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed()));
|
||||||
|
const int32 PreviousNumStrates = TransactionalManager->GetNumStrates();
|
||||||
|
const uint32 PreviousPassagesVersion = TransactionalManager->GetLayoutVersion();
|
||||||
|
const FString PreviousSeasonHash = TransactionalManager->GetSeasonContentHash();
|
||||||
|
|
||||||
|
RuntimeSettings->Season = TSoftObjectPtr<UVoxelSeasonAsset>(BrokenSeason.Get());
|
||||||
|
TestFalse(TEXT("invalid live season is rejected"),
|
||||||
|
TransactionalManager->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed()));
|
||||||
|
TestEqual(TEXT("invalid live season preserves the previous layout"),
|
||||||
|
TransactionalManager->GetNumStrates(), PreviousNumStrates);
|
||||||
|
TestEqual(TEXT("invalid live season preserves the previous passage version"),
|
||||||
|
TransactionalManager->GetLayoutVersion(), PreviousPassagesVersion);
|
||||||
|
TestEqual(TEXT("invalid live season preserves the previous content hash"),
|
||||||
|
TransactionalManager->GetSeasonContentHash(), PreviousSeasonHash);
|
||||||
|
RuntimeSettings->Season = TSoftObjectPtr<UVoxelSeasonAsset>(SeasonAsset.Get());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,921 @@
|
|||||||
|
// Tier 4b — deterministic structure recipes, dependency-safe materialisation, and measurement.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
#include "HAL/PlatformTime.h"
|
||||||
|
#include "Math/RandomStream.h"
|
||||||
|
|
||||||
|
#include "VoxelForgeTestFixture.h"
|
||||||
|
#include "VoxelDensityOpStack.h"
|
||||||
|
#include "VoxelSettings.h"
|
||||||
|
#include "VoxelStrateComposer.h"
|
||||||
|
#include "VoxelStrateMeasure.h"
|
||||||
|
#include "VoxelStratePreview.h"
|
||||||
|
#include "Misc/Paths.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr int32 NumCandidates = 64;
|
||||||
|
constexpr float LargestComponentSurvivalThreshold = 0.50f;
|
||||||
|
constexpr int32 BoxChecksPerCandidate = 40;
|
||||||
|
constexpr int32 BoxStep = 1;
|
||||||
|
constexpr int32 BoxCells = 8;
|
||||||
|
constexpr int32 FinePreviewSampleStep = 1;
|
||||||
|
constexpr int32 FinePreviewRadiusVoxels = 64;
|
||||||
|
constexpr int32 FinePreviewMaxCells = 2000000;
|
||||||
|
|
||||||
|
const TCHAR* VF_ConnectivityName(EVoxelConnectivityResult Result)
|
||||||
|
{
|
||||||
|
switch (Result)
|
||||||
|
{
|
||||||
|
case EVoxelConnectivityResult::Connected: return TEXT("Connected");
|
||||||
|
case EVoxelConnectivityResult::NotConnectedAtThisResolution: return TEXT("NotConnected");
|
||||||
|
case EVoxelConnectivityResult::StartCellSolid: return TEXT("StartCellSolid");
|
||||||
|
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid");
|
||||||
|
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow");
|
||||||
|
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudget");
|
||||||
|
case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit");
|
||||||
|
case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit");
|
||||||
|
}
|
||||||
|
return TEXT("Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
const TCHAR* VF_ShapeName(EVoxelStrateOpClass OpClass)
|
||||||
|
{
|
||||||
|
switch (OpClass)
|
||||||
|
{
|
||||||
|
case EVoxelStrateOpClass::RoomGraphSource: return TEXT("RoomGraph");
|
||||||
|
case EVoxelStrateOpClass::LatticeCorridorSource:return TEXT("Lattice");
|
||||||
|
case EVoxelStrateOpClass::ShaftFieldSource: return TEXT("Shaft");
|
||||||
|
case EVoxelStrateOpClass::IslandBlobSource: return TEXT("Island");
|
||||||
|
case EVoxelStrateOpClass::NoiseRibbonSource: return TEXT("Noise");
|
||||||
|
default: return TEXT("invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 VF_FloorDiv(int32 A, int32 B)
|
||||||
|
{
|
||||||
|
const int32 Q = A / B;
|
||||||
|
const int32 R = A % B;
|
||||||
|
return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VF_BitEqual(float A, float B)
|
||||||
|
{
|
||||||
|
uint32 ABits = 0;
|
||||||
|
uint32 BBits = 0;
|
||||||
|
FMemory::Memcpy(&ABits, &A, sizeof(ABits));
|
||||||
|
FMemory::Memcpy(&BBits, &B, sizeof(BBits));
|
||||||
|
return ABits == BBits;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FBlockSpec
|
||||||
|
{
|
||||||
|
EVoxelStrateParamBlock Block;
|
||||||
|
ECaveGeneratorType Archetype;
|
||||||
|
uint32 SeedSalt;
|
||||||
|
};
|
||||||
|
|
||||||
|
static const FBlockSpec GBlockSpecs[] =
|
||||||
|
{
|
||||||
|
{ EVoxelStrateParamBlock::TunnelNetwork, ECaveGeneratorType::TunnelNetwork, 0x1001u },
|
||||||
|
{ EVoxelStrateParamBlock::Slab, ECaveGeneratorType::FlatPlain, 0x1003u },
|
||||||
|
{ EVoxelStrateParamBlock::Maze, ECaveGeneratorType::Maze, 0x1005u },
|
||||||
|
{ EVoxelStrateParamBlock::Surface, ECaveGeneratorType::SurfaceWorld, 0x1007u },
|
||||||
|
{ EVoxelStrateParamBlock::VerticalShaft, ECaveGeneratorType::VerticalShafts, 0x1009u },
|
||||||
|
{ EVoxelStrateParamBlock::FloatingIsland, ECaveGeneratorType::FloatingIslands, 0x100Bu },
|
||||||
|
};
|
||||||
|
|
||||||
|
void VF_CopyRollBlock(FVoxelStrateArchetypeParams& OutParams,
|
||||||
|
const FVoxelStrateRollInfo& Roll,
|
||||||
|
ECaveGeneratorType Archetype)
|
||||||
|
{
|
||||||
|
switch (Archetype)
|
||||||
|
{
|
||||||
|
case ECaveGeneratorType::TunnelNetwork:
|
||||||
|
OutParams.TunnelNetworkParams = Roll.ArchetypeParams.TunnelNetworkParams;
|
||||||
|
break;
|
||||||
|
case ECaveGeneratorType::FlatPlain:
|
||||||
|
case ECaveGeneratorType::CrystalChamber:
|
||||||
|
OutParams.SlabParams = Roll.ArchetypeParams.SlabParams;
|
||||||
|
break;
|
||||||
|
case ECaveGeneratorType::Maze:
|
||||||
|
OutParams.MazeParams = Roll.ArchetypeParams.MazeParams;
|
||||||
|
break;
|
||||||
|
case ECaveGeneratorType::SurfaceWorld:
|
||||||
|
OutParams.SurfaceParams = Roll.ArchetypeParams.SurfaceParams;
|
||||||
|
break;
|
||||||
|
case ECaveGeneratorType::VerticalShafts:
|
||||||
|
OutParams.VerticalShaftParams = Roll.ArchetypeParams.VerticalShaftParams;
|
||||||
|
break;
|
||||||
|
case ECaveGeneratorType::FloatingIslands:
|
||||||
|
OutParams.FloatingIslandParams = Roll.ArchetypeParams.FloatingIslandParams;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VF_SetAllRuntimeBounds(FVoxelStrateArchetypeParams& Params,
|
||||||
|
float TopWorldZ, float BottomWorldZ)
|
||||||
|
{
|
||||||
|
Params.TunnelNetworkParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.TunnelNetworkParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.SlabParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.SlabParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.MazeParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.MazeParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.SurfaceParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.SurfaceParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.VerticalShaftParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.VerticalShaftParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
Params.FloatingIslandParams.StrateTopWorldZ = TopWorldZ;
|
||||||
|
Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FStackDensitySampler final : public IVoxelStrateDensitySampler
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit FStackDensitySampler(const FVoxelOpStack& InStack) : Stack(InStack) {}
|
||||||
|
|
||||||
|
float SampleDensity(float WorldX, float WorldY, float WorldZ) const override
|
||||||
|
{
|
||||||
|
return Stack.EvalMC(WorldX, WorldY, WorldZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
const FVoxelOpStack& Stack;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FBoxVerdictReport
|
||||||
|
{
|
||||||
|
int32 Mixed = 0;
|
||||||
|
int32 AllSolid = 0;
|
||||||
|
int32 AllAir = 0;
|
||||||
|
int32 Proved = 0;
|
||||||
|
int64 CheckedVoxels = 0;
|
||||||
|
int32 Violations = 0;
|
||||||
|
FString FirstViolation;
|
||||||
|
};
|
||||||
|
|
||||||
|
FBoxVerdictReport VF_CheckStackBoxVerdicts(const FVoxelOpStack& Stack,
|
||||||
|
const FVoxelOpContext& Context,
|
||||||
|
int32 CandidateIndex,
|
||||||
|
int32 BottomVoxelZ,
|
||||||
|
int32 TopVoxelZ)
|
||||||
|
{
|
||||||
|
FBoxVerdictReport Report;
|
||||||
|
constexpr int32 Extent = BoxStep * BoxCells;
|
||||||
|
constexpr int32 SpanCells = 40;
|
||||||
|
const int32 LoTile = VF_FloorDiv(BottomVoxelZ, Extent);
|
||||||
|
const int32 HiTile = FMath::Max(LoTile, VF_FloorDiv(TopVoxelZ, Extent));
|
||||||
|
FRandomStream Rng(0x6C617731 ^ CandidateIndex);
|
||||||
|
|
||||||
|
for (int32 BoxIndex = 0; BoxIndex < BoxChecksPerCandidate; ++BoxIndex)
|
||||||
|
{
|
||||||
|
const FIntVector Origin(
|
||||||
|
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||||
|
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||||
|
Rng.RandRange(LoTile, HiTile) * Extent);
|
||||||
|
const FBox Box(
|
||||||
|
FVector((float)(Origin.X - BoxStep), (float)(Origin.Y - BoxStep),
|
||||||
|
(float)(Origin.Z - BoxStep)),
|
||||||
|
FVector((float)(Origin.X + (BoxCells + 1) * BoxStep),
|
||||||
|
(float)(Origin.Y + (BoxCells + 1) * BoxStep),
|
||||||
|
(float)(Origin.Z + (BoxCells + 1) * BoxStep)));
|
||||||
|
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Context);
|
||||||
|
if (Verdict == EVoxelTileClass::Mixed)
|
||||||
|
{
|
||||||
|
++Report.Mixed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Verdict == EVoxelTileClass::AllSolid)
|
||||||
|
{
|
||||||
|
++Report.AllSolid;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
++Report.AllAir;
|
||||||
|
}
|
||||||
|
++Report.Proved;
|
||||||
|
|
||||||
|
const int32 GridDim = BoxCells + 1;
|
||||||
|
const bool bClaimsSolid = Verdict == EVoxelTileClass::AllSolid;
|
||||||
|
for (int32 GZ = -1; GZ <= GridDim; ++GZ)
|
||||||
|
for (int32 GY = -1; GY <= GridDim; ++GY)
|
||||||
|
for (int32 GX = -1; GX <= GridDim; ++GX)
|
||||||
|
{
|
||||||
|
const float X = (float)(Origin.X + GX * BoxStep);
|
||||||
|
const float Y = (float)(Origin.Y + GY * BoxStep);
|
||||||
|
const float Z = (float)(Origin.Z + GZ * BoxStep);
|
||||||
|
const float Density = Stack.EvalMC(X, Y, Z);
|
||||||
|
++Report.CheckedVoxels;
|
||||||
|
const bool bAgrees = bClaimsSolid ? (Density < 0.0f) : (Density >= 0.0f);
|
||||||
|
if (!bAgrees)
|
||||||
|
{
|
||||||
|
++Report.Violations;
|
||||||
|
if (Report.FirstViolation.IsEmpty())
|
||||||
|
{
|
||||||
|
Report.FirstViolation = FString::Printf(
|
||||||
|
TEXT("candidate %d box %d origin (%d,%d,%d) said %s but MC density "
|
||||||
|
"at (%.0f,%.0f,%.0f) was %.9g"),
|
||||||
|
CandidateIndex, BoxIndex, Origin.X, Origin.Y, Origin.Z,
|
||||||
|
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||||
|
X, Y, Z, Density);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Report;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeComposerStructureRollTest,
|
||||||
|
"VoxelForge.Composer.StructureRoll",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
using namespace VoxelForgeTest;
|
||||||
|
(void)Parameters;
|
||||||
|
|
||||||
|
const double TestStartSeconds = FPlatformTime::Seconds();
|
||||||
|
const FString SettingsPath = TEXT("/Game/VoxelForge/DA_Settings.DA_Settings");
|
||||||
|
UVoxelSettings* AuthoredSettings = LoadObject<UVoxelSettings>(nullptr, *SettingsPath);
|
||||||
|
if (AuthoredSettings == nullptr)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(TEXT("Could not load %s; structure roll did not run."), *SettingsPath));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FVoxelStrateCorpus Corpus;
|
||||||
|
FString CorpusReport;
|
||||||
|
const bool bCorpusLoaded = Corpus.LoadFromAssetRegistry(CorpusReport);
|
||||||
|
AddInfo(CorpusReport);
|
||||||
|
TestTrue(TEXT("structure roll has a usable corpus for parameter blocks"),
|
||||||
|
bCorpusLoaded && Corpus.IsValid());
|
||||||
|
if (!bCorpusLoaded || !Corpus.IsValid())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FTestWorld World;
|
||||||
|
World.Build(1337, 2, true);
|
||||||
|
if (!World.IsValid())
|
||||||
|
{
|
||||||
|
AddError(World.WhyInvalid());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
TestTrue(TEXT("structure-roll fixture keeps WorldRadiusVoxels at 0"),
|
||||||
|
World.Settings->WorldRadiusVoxels == 0.0f);
|
||||||
|
|
||||||
|
constexpr int32 CandidateStrateIndex = FTestWorld::SlotFlatPlain;
|
||||||
|
int32 TopVoxelZ = 0;
|
||||||
|
int32 BottomVoxelZ = 0;
|
||||||
|
if (!World.GetSlotVoxelZRange(CandidateStrateIndex, TopVoxelZ, BottomVoxelZ))
|
||||||
|
{
|
||||||
|
AddError(TEXT("structure-roll fixture has no candidate Z range"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const float TopWorldZ = (float)TopVoxelZ + 1.0f;
|
||||||
|
const float BottomWorldZ = (float)BottomVoxelZ;
|
||||||
|
|
||||||
|
FVector ArrivalPoint = FVector::ZeroVector;
|
||||||
|
FVector DeparturePoint = FVector::ZeroVector;
|
||||||
|
int32 ArrivalCount = 0;
|
||||||
|
int32 DepartureCount = 0;
|
||||||
|
for (const FVoxelPassage& Passage : World.StrateManager->GetPassages())
|
||||||
|
{
|
||||||
|
if (Passage.LowerStrateIndex == CandidateStrateIndex
|
||||||
|
&& Passage.UpperStrateIndex + 1 == CandidateStrateIndex)
|
||||||
|
{
|
||||||
|
ArrivalPoint = Passage.LowerPoint;
|
||||||
|
++ArrivalCount;
|
||||||
|
}
|
||||||
|
if (Passage.UpperStrateIndex == CandidateStrateIndex
|
||||||
|
&& Passage.LowerStrateIndex == CandidateStrateIndex + 1)
|
||||||
|
{
|
||||||
|
DeparturePoint = Passage.UpperPoint;
|
||||||
|
++DepartureCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TestEqual(TEXT("candidate has exactly one arrival mouth"), ArrivalCount, 1);
|
||||||
|
TestEqual(TEXT("candidate has exactly one departure mouth"), DepartureCount, 1);
|
||||||
|
|
||||||
|
FVoxelStrateMeasureSettings MeasureSettings;
|
||||||
|
MeasureSettings.SampleStep = 4;
|
||||||
|
MeasureSettings.RadiusInVoxels = 256;
|
||||||
|
MeasureSettings.CenterXY = FVector2D::ZeroVector;
|
||||||
|
MeasureSettings.MaxCells = 8000000;
|
||||||
|
MeasureSettings.MaxRouteRetries = 16;
|
||||||
|
MeasureSettings.HeadroomCells = 2;
|
||||||
|
MeasureSettings.InteriorMarginVoxels = -1;
|
||||||
|
|
||||||
|
FVoxelStrateFinePreviewSettings FinePreviewSettings;
|
||||||
|
FinePreviewSettings.SampleStep = FinePreviewSampleStep;
|
||||||
|
FinePreviewSettings.RadiusInVoxels = FinePreviewRadiusVoxels;
|
||||||
|
FinePreviewSettings.MaxCells = FinePreviewMaxCells;
|
||||||
|
TestTrue(TEXT("fine preview defaults are a bounded valid ROI"), FinePreviewSettings.IsValid());
|
||||||
|
const FVoxelStrateMeasureSettings FineMeasureSettings =
|
||||||
|
FinePreviewSettings.MakeMeasureSettings(MeasureSettings);
|
||||||
|
|
||||||
|
FString CandidateTable = TEXT(
|
||||||
|
"candidate | recipe (compact) | air | largest share | walkable | feature scale | arrival->departure\n");
|
||||||
|
int32 NumSurvivors = 0;
|
||||||
|
int32 NumNonVacuous = 0;
|
||||||
|
int32 NumLargestEnough = 0;
|
||||||
|
int32 NumLawPasses = 0;
|
||||||
|
int32 NumInvalidRecipes = 0;
|
||||||
|
int32 NumParameterRollFailures = 0;
|
||||||
|
int32 TotalBoxMixed = 0;
|
||||||
|
int32 TotalBoxAllSolid = 0;
|
||||||
|
int32 TotalBoxAllAir = 0;
|
||||||
|
int32 TotalBoxProved = 0;
|
||||||
|
int64 TotalBoxCheckedVoxels = 0;
|
||||||
|
int32 TotalBoxViolations = 0;
|
||||||
|
FString FirstBoxViolation;
|
||||||
|
int32 TotalBootstrapJitterFields = 0;
|
||||||
|
TSet<FString> BootstrapJitterFieldNames;
|
||||||
|
TArray<FVoxelStratePreviewCandidate> PreviewCandidates;
|
||||||
|
PreviewCandidates.Reserve(NumCandidates);
|
||||||
|
FVoxelStratePreviewWindow PreviewWindow;
|
||||||
|
bool bPreviewWindowSet = false;
|
||||||
|
int32 NumFineRequested = 0;
|
||||||
|
int32 NumFineRendered = 0;
|
||||||
|
int32 NumFineContourRendered = 0;
|
||||||
|
int32 NumFineRefused = 0;
|
||||||
|
int32 NumFinePlanBlankBeforeCentering = 0;
|
||||||
|
int32 NumFinePlanBlankAfterCentering = 0;
|
||||||
|
int32 NumFineCardBlankBeforeCentering = 0;
|
||||||
|
int32 NumFineCardBlankAfterCentering = 0;
|
||||||
|
int64 TotalFineCells = 0;
|
||||||
|
int64 PeakFineCaptureBytes = 0;
|
||||||
|
int64 PeakFineRasterBytes = 0;
|
||||||
|
double FineMeasureSeconds = 0.0;
|
||||||
|
double FineRenderSeconds = 0.0;
|
||||||
|
double CoarsePreviewSeconds = 0.0;
|
||||||
|
bool bFineCapRefusalChecked = false;
|
||||||
|
const FString PreviewRunId = FString::Printf(
|
||||||
|
TEXT("structure_seed_%d_corpus_%08x_step_%d_radius_%d_fine_step_%d_fine_radius_%d"),
|
||||||
|
AuthoredSettings->Seed, Corpus.GetContentsHash(),
|
||||||
|
MeasureSettings.SampleStep, MeasureSettings.RadiusInVoxels,
|
||||||
|
FinePreviewSettings.SampleStep, FinePreviewSettings.RadiusInVoxels);
|
||||||
|
const FString PreviewDirectory = FPaths::ProjectSavedDir()
|
||||||
|
/ TEXT("ComposerPreview") / PreviewRunId;
|
||||||
|
TArray<int32> ShapeCounts;
|
||||||
|
ShapeCounts.Init(0, 5);
|
||||||
|
int32 KCounts[9] = { 0 };
|
||||||
|
TArray<FVoxelOpStackRecipe> DistinctRecipes;
|
||||||
|
DistinctRecipes.Reserve(NumCandidates);
|
||||||
|
|
||||||
|
const double RollAndMeasureStartSeconds = FPlatformTime::Seconds();
|
||||||
|
for (int32 CandidateIndex = 0; CandidateIndex < NumCandidates; ++CandidateIndex)
|
||||||
|
{
|
||||||
|
#if WITH_EDITOR
|
||||||
|
// Keep the editor hand-off and the structure test on the same recipe/block roll seam.
|
||||||
|
const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate(
|
||||||
|
Corpus, AuthoredSettings->Seed, CandidateIndex, true);
|
||||||
|
TestTrue(FString::Printf(TEXT("candidate %d hand-off roll is valid"), CandidateIndex),
|
||||||
|
Candidate.bValid);
|
||||||
|
const FVoxelOpStackRecipe Recipe = Candidate.Recipe;
|
||||||
|
#else
|
||||||
|
const FVoxelOpStackRecipe Recipe = VF_RollStrateStructure(
|
||||||
|
AuthoredSettings->Seed, CandidateIndex);
|
||||||
|
#endif
|
||||||
|
const FVoxelOpStackRecipe RepeatRecipe = VF_RollStrateStructure(
|
||||||
|
AuthoredSettings->Seed, CandidateIndex);
|
||||||
|
TestTrue(FString::Printf(TEXT("candidate %d recipe reroll is identical"), CandidateIndex),
|
||||||
|
VF_AreStrateStructureRecipesIdentical(Recipe, RepeatRecipe));
|
||||||
|
|
||||||
|
const uint32 RecipeHash = VF_HashStrateStructureRecipe(Recipe);
|
||||||
|
bool bSeenRecipe = false;
|
||||||
|
for (const FVoxelOpStackRecipe& Existing : DistinctRecipes)
|
||||||
|
{
|
||||||
|
if (VF_HashStrateStructureRecipe(Existing) == RecipeHash
|
||||||
|
&& VF_AreStrateStructureRecipesIdentical(Existing, Recipe))
|
||||||
|
{
|
||||||
|
bSeenRecipe = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!bSeenRecipe) { DistinctRecipes.Add(Recipe); }
|
||||||
|
|
||||||
|
switch (Recipe.ShapeSource.OpClass)
|
||||||
|
{
|
||||||
|
case EVoxelStrateOpClass::RoomGraphSource: ++ShapeCounts[0]; break;
|
||||||
|
case EVoxelStrateOpClass::LatticeCorridorSource:++ShapeCounts[1]; break;
|
||||||
|
case EVoxelStrateOpClass::ShaftFieldSource: ++ShapeCounts[2]; break;
|
||||||
|
case EVoxelStrateOpClass::IslandBlobSource: ++ShapeCounts[3]; break;
|
||||||
|
case EVoxelStrateOpClass::NoiseRibbonSource: ++ShapeCounts[4]; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
if (Recipe.Modifiers.IsValidIndex(0))
|
||||||
|
{
|
||||||
|
++KCounts[Recipe.Modifiers.Num()];
|
||||||
|
}
|
||||||
|
|
||||||
|
FVoxelStrateArchetypeParams CandidateParams;
|
||||||
|
bool bParameterRollsValid = true;
|
||||||
|
double CandidateDistanceSquared = 0.0;
|
||||||
|
for (int32 BlockIndex = 0; BlockIndex < UE_ARRAY_COUNT(GBlockSpecs); ++BlockIndex)
|
||||||
|
{
|
||||||
|
#if WITH_EDITOR
|
||||||
|
const FBlockSpec& Spec = GBlockSpecs[BlockIndex];
|
||||||
|
if (!Candidate.StructureBlockRolls.IsValidIndex(BlockIndex))
|
||||||
|
{
|
||||||
|
bParameterRollsValid = false;
|
||||||
|
AddError(FString::Printf(TEXT("candidate %d missing parameter block %d"),
|
||||||
|
CandidateIndex, static_cast<int32>(Spec.Block)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const FVoxelStrateRollInfo& BlockRoll = Candidate.StructureBlockRolls[BlockIndex];
|
||||||
|
#else
|
||||||
|
const FBlockSpec& Spec = GBlockSpecs[BlockIndex];
|
||||||
|
const FVoxelStrateRollInfo BlockRoll = VF_RollStrateParamsDetailedForArchetype(
|
||||||
|
Corpus, Spec.Archetype,
|
||||||
|
AuthoredSettings->Seed ^ static_cast<int32>(Spec.SeedSalt), CandidateIndex);
|
||||||
|
#endif
|
||||||
|
if (!BlockRoll.bValid)
|
||||||
|
{
|
||||||
|
bParameterRollsValid = false;
|
||||||
|
++NumParameterRollFailures;
|
||||||
|
AddError(FString::Printf(TEXT("candidate %d parameter block %d failed: %s"),
|
||||||
|
CandidateIndex, static_cast<int32>(Spec.Block),
|
||||||
|
*BlockRoll.FailureReason));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
TotalBootstrapJitterFields += BlockRoll.BootstrapJitterFieldCount;
|
||||||
|
for (const FString& FieldName : BlockRoll.BootstrapJitterFieldNames)
|
||||||
|
{
|
||||||
|
BootstrapJitterFieldNames.Add(FieldName);
|
||||||
|
}
|
||||||
|
const double BlockDistance = VF_DistanceFromStrateCorpusCentroid(
|
||||||
|
Corpus, BlockRoll.ArchetypeParams, Spec.Archetype);
|
||||||
|
CandidateDistanceSquared += BlockDistance * BlockDistance;
|
||||||
|
VF_CopyRollBlock(CandidateParams, BlockRoll, Spec.Archetype);
|
||||||
|
}
|
||||||
|
VF_SetAllRuntimeBounds(CandidateParams, TopWorldZ, BottomWorldZ);
|
||||||
|
|
||||||
|
FString AirText = TEXT("invalid");
|
||||||
|
FString LargestText = TEXT("invalid");
|
||||||
|
FString WalkableText = TEXT("invalid");
|
||||||
|
FString FeatureText = TEXT("invalid");
|
||||||
|
FString LawText = TEXT("not measured");
|
||||||
|
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
FVoxelOpContext Context;
|
||||||
|
FString BuildError;
|
||||||
|
const bool bBuilt = bParameterRollsValid && VF_BuildStackFromRecipe(
|
||||||
|
Recipe, CandidateParams, AuthoredSettings->Seed, 14.0f,
|
||||||
|
World.StrateManager.Get(), Stack, Context, &BuildError);
|
||||||
|
if (!bBuilt)
|
||||||
|
{
|
||||||
|
++NumInvalidRecipes;
|
||||||
|
CandidateTable += FString::Printf(
|
||||||
|
TEXT("%d | %s | invalid | invalid | invalid | invalid | invalid | build=%s\n"),
|
||||||
|
CandidateIndex, *VF_FormatStrateStructureRecipe(Recipe),
|
||||||
|
bParameterRollsValid ? *BuildError : TEXT("parameter block roll failed"));
|
||||||
|
TestTrue(FString::Printf(TEXT("candidate %d recipe materialises"), CandidateIndex), false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TestEqual(FString::Printf(TEXT("candidate %d has four mandatory structural posts"), CandidateIndex),
|
||||||
|
Stack.Num(), Recipe.Modifiers.Num() + 7);
|
||||||
|
FString ValidationError;
|
||||||
|
TestTrue(FString::Printf(TEXT("candidate %d passes ValidateChannelOrder"), CandidateIndex),
|
||||||
|
Stack.ValidateChannelOrder(&ValidationError));
|
||||||
|
TestTrue(FString::Printf(TEXT("candidate %d context keeps WorldRadiusVoxels at 0"), CandidateIndex),
|
||||||
|
Context.WorldRadiusVoxels == 0.0f);
|
||||||
|
|
||||||
|
Stack.PrepareChunk(Context);
|
||||||
|
FStackDensitySampler Sampler(Stack);
|
||||||
|
|
||||||
|
if (!bFineCapRefusalChecked)
|
||||||
|
{
|
||||||
|
FVoxelStrateMeasureSettings RefusalSettings = FineMeasureSettings;
|
||||||
|
RefusalSettings.MaxCells = 1;
|
||||||
|
FVoxelStrateSampleGrid RefusedFineGrid;
|
||||||
|
const FVoxelStrateMetrics RefusedFineMetrics = VF_MeasureStrateWithSampler(
|
||||||
|
Sampler, BottomVoxelZ, TopVoxelZ + 1,
|
||||||
|
Context.EdgeSealThickness, RefusalSettings, &RefusedFineGrid);
|
||||||
|
TestTrue(TEXT("fine preview refuses an over-cap ROI before allocation"),
|
||||||
|
!RefusedFineMetrics.bValid
|
||||||
|
&& RefusedFineGrid.Air.Num() == 0
|
||||||
|
&& RefusedFineGrid.Density.Num() == 0
|
||||||
|
&& RefusedFineMetrics.RefusalReason.Contains(TEXT("MaxCells")));
|
||||||
|
bFineCapRefusalChecked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
FVoxelStrateSampleGrid SampleGrid;
|
||||||
|
const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler(
|
||||||
|
Sampler, BottomVoxelZ, TopVoxelZ + 1,
|
||||||
|
Context.EdgeSealThickness, MeasureSettings, &SampleGrid);
|
||||||
|
|
||||||
|
FVoxelConnectivityDiagnostics Law;
|
||||||
|
if (ArrivalCount == 1 && DepartureCount == 1)
|
||||||
|
{
|
||||||
|
Law = VF_DiagnoseConnectivityWithSampler(
|
||||||
|
Sampler, BottomVoxelZ, TopVoxelZ + 1,
|
||||||
|
Context.EdgeSealThickness, ArrivalPoint, DeparturePoint, MeasureSettings);
|
||||||
|
LawText = VF_ConnectivityName(Law.Result);
|
||||||
|
if (Law.bStartSnapped || Law.bGoalSnapped) { LawText += TEXT(" (snapped)"); }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LawText = FString::Printf(TEXT("mouths=%d/%d"), ArrivalCount, DepartureCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool bNonVacuous = Metrics.bValid
|
||||||
|
&& Metrics.NumSampled > 0 && Metrics.NumAir > 0 && Metrics.NumSolid > 0;
|
||||||
|
const bool bLargestEnough = bNonVacuous
|
||||||
|
&& Metrics.LargestComponentShare >= LargestComponentSurvivalThreshold;
|
||||||
|
const bool bLawPass = ArrivalCount == 1 && DepartureCount == 1
|
||||||
|
&& Law.bValid && Law.Result == EVoxelConnectivityResult::Connected
|
||||||
|
&& !Law.bStartSnapped && !Law.bGoalSnapped;
|
||||||
|
if (bNonVacuous) { ++NumNonVacuous; }
|
||||||
|
if (bLargestEnough) { ++NumLargestEnough; }
|
||||||
|
if (bLawPass) { ++NumLawPasses; }
|
||||||
|
if (bNonVacuous && bLargestEnough && bLawPass) { ++NumSurvivors; }
|
||||||
|
|
||||||
|
if (Metrics.bValid)
|
||||||
|
{
|
||||||
|
AirText = FString::Printf(TEXT("%.6f"), Metrics.AirFraction);
|
||||||
|
LargestText = FString::Printf(TEXT("%.6f"), Metrics.LargestComponentShare);
|
||||||
|
WalkableText = FString::Printf(TEXT("%.6f"), Metrics.WalkableFraction);
|
||||||
|
FeatureText = FString::Printf(TEXT("%.6f"), Metrics.MedianFeatureScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
FString RejectionReason;
|
||||||
|
if (!bNonVacuous)
|
||||||
|
{
|
||||||
|
RejectionReason = TEXT("vacuous");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!bLargestEnough)
|
||||||
|
{
|
||||||
|
RejectionReason = TEXT("fragmented");
|
||||||
|
}
|
||||||
|
if (!bLawPass)
|
||||||
|
{
|
||||||
|
if (!RejectionReason.IsEmpty())
|
||||||
|
{
|
||||||
|
RejectionReason += TEXT("; ");
|
||||||
|
}
|
||||||
|
RejectionReason += TEXT("law failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FBoxVerdictReport BoxReport = VF_CheckStackBoxVerdicts(
|
||||||
|
Stack, Context, CandidateIndex, BottomVoxelZ, TopVoxelZ);
|
||||||
|
TotalBoxMixed += BoxReport.Mixed;
|
||||||
|
TotalBoxAllSolid += BoxReport.AllSolid;
|
||||||
|
TotalBoxAllAir += BoxReport.AllAir;
|
||||||
|
TotalBoxProved += BoxReport.Proved;
|
||||||
|
TotalBoxCheckedVoxels += BoxReport.CheckedVoxels;
|
||||||
|
TotalBoxViolations += BoxReport.Violations;
|
||||||
|
if (FirstBoxViolation.IsEmpty() && !BoxReport.FirstViolation.IsEmpty())
|
||||||
|
{
|
||||||
|
FirstBoxViolation = BoxReport.FirstViolation;
|
||||||
|
}
|
||||||
|
|
||||||
|
const double CandidateDistance = FMath::Sqrt(
|
||||||
|
FMath::Max(0.0, CandidateDistanceSquared));
|
||||||
|
const FVoxelStratePreviewWindow CandidateWindow =
|
||||||
|
VF_GetStratePreviewWindow(SampleGrid);
|
||||||
|
if (CandidateWindow.IsValid())
|
||||||
|
{
|
||||||
|
if (!bPreviewWindowSet)
|
||||||
|
{
|
||||||
|
PreviewWindow = CandidateWindow;
|
||||||
|
bPreviewWindowSet = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TestTrue(FString::Printf(TEXT("candidate %d preview captures its measurement grid"),
|
||||||
|
CandidateIndex),
|
||||||
|
!Metrics.bValid || SampleGrid.HasScalarDensity());
|
||||||
|
|
||||||
|
FVoxelStratePreviewCandidate PreviewCandidate;
|
||||||
|
FString PreviewError;
|
||||||
|
const double CoarsePreviewStartSeconds = FPlatformTime::Seconds();
|
||||||
|
const bool bPreviewWritten = VF_WriteStratePreviewCandidate(
|
||||||
|
PreviewDirectory,
|
||||||
|
CandidateIndex,
|
||||||
|
VF_FormatStrateStructureRecipe(Recipe),
|
||||||
|
SampleGrid,
|
||||||
|
Metrics,
|
||||||
|
MeasureSettings.HeadroomCells,
|
||||||
|
CandidateDistance,
|
||||||
|
LawText,
|
||||||
|
!(bNonVacuous && bLargestEnough && bLawPass),
|
||||||
|
RejectionReason,
|
||||||
|
PreviewCandidate,
|
||||||
|
PreviewError);
|
||||||
|
CoarsePreviewSeconds += FPlatformTime::Seconds() - CoarsePreviewStartSeconds;
|
||||||
|
TestTrue(FString::Printf(TEXT("candidate %d preview rasterises without a second sample"),
|
||||||
|
CandidateIndex),
|
||||||
|
bPreviewWritten);
|
||||||
|
TestTrue(FString::Printf(TEXT("candidate %d has images when its measurement is valid"),
|
||||||
|
CandidateIndex),
|
||||||
|
!Metrics.bValid
|
||||||
|
|| (PreviewCandidate.bRendered && PreviewCandidate.bContourRendered));
|
||||||
|
if (!bPreviewWritten)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(TEXT("candidate %d preview failed: %s"),
|
||||||
|
CandidateIndex, *PreviewError));
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool bCandidateSurvivor = bNonVacuous && bLargestEnough && bLawPass;
|
||||||
|
if (bCandidateSurvivor)
|
||||||
|
{
|
||||||
|
++NumFineRequested;
|
||||||
|
|
||||||
|
// Render the old origin-centred pass into the same filenames first. The temporary
|
||||||
|
// record gives an exact before count from the renderer's content-variation check; the
|
||||||
|
// corrected capture below overwrites those files. The two grids are sequential, so
|
||||||
|
// peak retained memory remains one fine capture.
|
||||||
|
FVoxelStrateSampleGrid OriginFineGrid;
|
||||||
|
const double OriginFineMeasureStartSeconds = FPlatformTime::Seconds();
|
||||||
|
const FVoxelStrateMetrics OriginFineMetrics = VF_MeasureStrateWithSampler(
|
||||||
|
Sampler, BottomVoxelZ, TopVoxelZ + 1,
|
||||||
|
Context.EdgeSealThickness, FineMeasureSettings, &OriginFineGrid);
|
||||||
|
FineMeasureSeconds += FPlatformTime::Seconds() - OriginFineMeasureStartSeconds;
|
||||||
|
FVoxelStratePreviewCandidate OriginFinePreview;
|
||||||
|
FString OriginFinePreviewError;
|
||||||
|
const double OriginFineRenderStartSeconds = FPlatformTime::Seconds();
|
||||||
|
const bool bOriginFineWritten = VF_WriteStratePreviewFineCandidate(
|
||||||
|
PreviewDirectory, CandidateIndex, OriginFineGrid,
|
||||||
|
FineMeasureSettings.HeadroomCells,
|
||||||
|
OriginFineMetrics.bValid ? FString() : OriginFineMetrics.RefusalReason,
|
||||||
|
OriginFinePreview, OriginFinePreviewError);
|
||||||
|
FineRenderSeconds += FPlatformTime::Seconds() - OriginFineRenderStartSeconds;
|
||||||
|
TestTrue(FString::Printf(TEXT("candidate %d origin baseline fine render records"),
|
||||||
|
CandidateIndex),
|
||||||
|
bOriginFineWritten);
|
||||||
|
if (OriginFinePreview.bFineRendered && !OriginFinePreview.bFinePlanContentVaries)
|
||||||
|
{
|
||||||
|
++NumFinePlanBlankBeforeCentering;
|
||||||
|
}
|
||||||
|
if (OriginFinePreview.bFineRendered && OriginFinePreview.bFineBlank)
|
||||||
|
{
|
||||||
|
++NumFineCardBlankBeforeCentering;
|
||||||
|
}
|
||||||
|
|
||||||
|
FVoxelStrateMeasureSettings CenteredFineMeasureSettings = FineMeasureSettings;
|
||||||
|
if (Metrics.bValid && FMath::IsFinite(Metrics.LargestComponentPoint.X)
|
||||||
|
&& FMath::IsFinite(Metrics.LargestComponentPoint.Y))
|
||||||
|
{
|
||||||
|
CenteredFineMeasureSettings.CenterXY = FVector2D(
|
||||||
|
Metrics.LargestComponentPoint.X, Metrics.LargestComponentPoint.Y);
|
||||||
|
}
|
||||||
|
FVoxelStrateSampleGrid FineGrid;
|
||||||
|
const double FineMeasureStartSeconds = FPlatformTime::Seconds();
|
||||||
|
const FVoxelStrateMetrics FineMetrics = VF_MeasureStrateWithSampler(
|
||||||
|
Sampler, BottomVoxelZ, TopVoxelZ + 1,
|
||||||
|
Context.EdgeSealThickness, CenteredFineMeasureSettings, &FineGrid);
|
||||||
|
FineMeasureSeconds += FPlatformTime::Seconds() - FineMeasureStartSeconds;
|
||||||
|
|
||||||
|
const FString FineFailureReason = FineMetrics.bValid
|
||||||
|
? FString() : FineMetrics.RefusalReason;
|
||||||
|
const double FineRenderStartSeconds = FPlatformTime::Seconds();
|
||||||
|
FString FinePreviewError;
|
||||||
|
const bool bFineWritten = VF_WriteStratePreviewFineCandidate(
|
||||||
|
PreviewDirectory, CandidateIndex, FineGrid,
|
||||||
|
FineMeasureSettings.HeadroomCells, FineFailureReason,
|
||||||
|
PreviewCandidate, FinePreviewError);
|
||||||
|
FineRenderSeconds += FPlatformTime::Seconds() - FineRenderStartSeconds;
|
||||||
|
|
||||||
|
TestTrue(FString::Printf(TEXT("candidate %d fine preview records without a second renderer sample"),
|
||||||
|
CandidateIndex),
|
||||||
|
bFineWritten);
|
||||||
|
if (PreviewCandidate.bFineRendered && !PreviewCandidate.bFinePlanContentVaries)
|
||||||
|
{
|
||||||
|
++NumFinePlanBlankAfterCentering;
|
||||||
|
}
|
||||||
|
if (PreviewCandidate.bFineRendered && PreviewCandidate.bFineBlank)
|
||||||
|
{
|
||||||
|
++NumFineCardBlankAfterCentering;
|
||||||
|
}
|
||||||
|
TestTrue(FString::Printf(TEXT("candidate %d fine capture keeps scalar density"),
|
||||||
|
CandidateIndex),
|
||||||
|
!FineMetrics.bValid || FineGrid.HasScalarDensity());
|
||||||
|
if (FineMetrics.bValid)
|
||||||
|
{
|
||||||
|
TotalFineCells += FineGrid.CellCount;
|
||||||
|
PeakFineCaptureBytes = FMath::Max(
|
||||||
|
PeakFineCaptureBytes,
|
||||||
|
static_cast<int64>(FineGrid.Air.GetAllocatedSize())
|
||||||
|
+ static_cast<int64>(FineGrid.Density.GetAllocatedSize()));
|
||||||
|
}
|
||||||
|
if (!FineMetrics.bValid)
|
||||||
|
{
|
||||||
|
++NumFineRefused;
|
||||||
|
}
|
||||||
|
if (PreviewCandidate.bFineRendered)
|
||||||
|
{
|
||||||
|
++NumFineRendered;
|
||||||
|
PeakFineRasterBytes = FMath::Max(
|
||||||
|
PeakFineRasterBytes,
|
||||||
|
static_cast<int64>(PreviewCandidate.FineVerticalImageWidth)
|
||||||
|
* static_cast<int64>(PreviewCandidate.FineVerticalImageHeight)
|
||||||
|
* static_cast<int64>(sizeof(FColor)));
|
||||||
|
PeakFineRasterBytes = FMath::Max(
|
||||||
|
PeakFineRasterBytes,
|
||||||
|
static_cast<int64>(PreviewCandidate.FinePlanImageWidth)
|
||||||
|
* static_cast<int64>(PreviewCandidate.FinePlanImageHeight)
|
||||||
|
* static_cast<int64>(sizeof(FColor)));
|
||||||
|
}
|
||||||
|
if (PreviewCandidate.bFineContourRendered)
|
||||||
|
{
|
||||||
|
++NumFineContourRendered;
|
||||||
|
}
|
||||||
|
if (!bFineWritten)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(TEXT("candidate %d fine preview failed: %s"),
|
||||||
|
CandidateIndex, *FinePreviewError));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PreviewCandidates.Add(MoveTemp(PreviewCandidate));
|
||||||
|
|
||||||
|
// Same recipe + same block vectors + same seed must yield the same world values. This is
|
||||||
|
// checked on a fixed point array for every candidate, including cache-backed sources.
|
||||||
|
FVoxelOpStack RepeatStack;
|
||||||
|
FVoxelOpContext RepeatContext;
|
||||||
|
FString RepeatBuildError;
|
||||||
|
const bool bRepeatBuilt = VF_BuildStackFromRecipe(
|
||||||
|
Recipe, CandidateParams, AuthoredSettings->Seed, 14.0f,
|
||||||
|
World.StrateManager.Get(), RepeatStack, RepeatContext, &RepeatBuildError);
|
||||||
|
TestTrue(FString::Printf(TEXT("candidate %d same recipe rebuild succeeds"), CandidateIndex),
|
||||||
|
bRepeatBuilt);
|
||||||
|
if (bRepeatBuilt)
|
||||||
|
{
|
||||||
|
RepeatStack.PrepareChunk(RepeatContext);
|
||||||
|
const FVector ProbePoints[] = {
|
||||||
|
FVector(-91.0f, -37.0f, BottomWorldZ + 17.0f),
|
||||||
|
FVector(0.0f, 0.0f, (BottomWorldZ + TopWorldZ) * 0.5f),
|
||||||
|
FVector(73.0f, 119.0f, TopWorldZ - 19.0f),
|
||||||
|
FVector(-256.0f, 256.0f, BottomWorldZ + 61.0f),
|
||||||
|
};
|
||||||
|
bool bSameWorld = true;
|
||||||
|
for (const FVector& Point : ProbePoints)
|
||||||
|
{
|
||||||
|
if (!VF_BitEqual(Stack.EvalMC(Point.X, Point.Y, Point.Z),
|
||||||
|
RepeatStack.EvalMC(Point.X, Point.Y, Point.Z)))
|
||||||
|
{
|
||||||
|
bSameWorld = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TestTrue(FString::Printf(TEXT("candidate %d same seed yields same world"), CandidateIndex),
|
||||||
|
bSameWorld);
|
||||||
|
}
|
||||||
|
|
||||||
|
CandidateTable += FString::Printf(
|
||||||
|
TEXT("%d | %s | %s | %s | %s | %s | %s\n"),
|
||||||
|
CandidateIndex,
|
||||||
|
*VF_FormatStrateStructureRecipe(Recipe),
|
||||||
|
*AirText, *LargestText, *WalkableText, *FeatureText, *LawText);
|
||||||
|
}
|
||||||
|
const double RollAndMeasureSeconds = FPlatformTime::Seconds() - RollAndMeasureStartSeconds;
|
||||||
|
|
||||||
|
AddInfo(CandidateTable);
|
||||||
|
FString ShapeDistribution = TEXT("Shape source distribution:");
|
||||||
|
for (int32 Index = 0; Index < 5; ++Index)
|
||||||
|
{
|
||||||
|
const EVoxelStrateOpClass ShapeClass[] = {
|
||||||
|
EVoxelStrateOpClass::RoomGraphSource,
|
||||||
|
EVoxelStrateOpClass::LatticeCorridorSource,
|
||||||
|
EVoxelStrateOpClass::ShaftFieldSource,
|
||||||
|
EVoxelStrateOpClass::IslandBlobSource,
|
||||||
|
EVoxelStrateOpClass::NoiseRibbonSource,
|
||||||
|
};
|
||||||
|
ShapeDistribution += FString::Printf(TEXT(" %s=%d"),
|
||||||
|
VF_ShapeName(ShapeClass[Index]), ShapeCounts[Index]);
|
||||||
|
}
|
||||||
|
FString KDistribution = TEXT("k distribution:");
|
||||||
|
for (int32 K = 4; K <= 8; ++K)
|
||||||
|
{
|
||||||
|
KDistribution += FString::Printf(TEXT(" k%d=%d"), K, KCounts[K]);
|
||||||
|
}
|
||||||
|
AddInfo(ShapeDistribution);
|
||||||
|
AddInfo(KDistribution);
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Structure diversity: %d distinct recipes of %d; invalid recipes emitted=%d; "
|
||||||
|
"parameter-block roll failures=%d; recipe/build/measure time=%.3fs."),
|
||||||
|
DistinctRecipes.Num(), NumCandidates, NumInvalidRecipes,
|
||||||
|
NumParameterRollFailures, RollAndMeasureSeconds));
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Survival: %d/%d (%.1f%%). Criteria: non-vacuous (valid, sampled, air>0, solid>0), "
|
||||||
|
"largest component share >= %.2f, and exact unsnapped arrival->departure law "
|
||||||
|
"Result=Connected. Intermediate counts: non-vacuous=%d, largest threshold=%d, "
|
||||||
|
"law pass=%d. Lower survival is expected for aggressive structure rolls."),
|
||||||
|
NumSurvivors, NumCandidates, 100.0f * (float)NumSurvivors / (float)NumCandidates,
|
||||||
|
LargestComponentSurvivalThreshold, NumNonVacuous, NumLargestEnough, NumLawPasses));
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Rolled custom-stack box verdicts: mixed=%d, AllSolid=%d, AllAir=%d, proved=%d, "
|
||||||
|
"brute-force voxels checked=%lld, violations=%d. %s"),
|
||||||
|
TotalBoxMixed, TotalBoxAllSolid, TotalBoxAllAir, TotalBoxProved,
|
||||||
|
TotalBoxCheckedVoxels, TotalBoxViolations,
|
||||||
|
FirstBoxViolation.IsEmpty() ? TEXT("No violations.") : *FirstBoxViolation));
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Preview cost: coarse step=%d 64-card pass render %.3fs; fine ROI requested=%d "
|
||||||
|
"(survivors=%d), rendered=%d, contour pairs=%d, refused=%d; "
|
||||||
|
"blank plan origin=%d, blank plan largest-open-space=%d; blank card origin=%d, "
|
||||||
|
"blank card largest-open-space=%d; fine sample %.3fs + "
|
||||||
|
"render %.3fs; fine cells sampled=%lld; peak fine Air+Density capture=%lld bytes; "
|
||||||
|
"peak single RGBA raster=%lld bytes; ROI step=%d radius=%d MaxCells=%d."),
|
||||||
|
MeasureSettings.SampleStep, CoarsePreviewSeconds,
|
||||||
|
NumFineRequested, NumSurvivors, NumFineRendered, NumFineContourRendered, NumFineRefused,
|
||||||
|
NumFinePlanBlankBeforeCentering, NumFinePlanBlankAfterCentering,
|
||||||
|
NumFineCardBlankBeforeCentering, NumFineCardBlankAfterCentering,
|
||||||
|
FineMeasureSeconds, FineRenderSeconds, TotalFineCells,
|
||||||
|
PeakFineCaptureBytes, PeakFineRasterBytes,
|
||||||
|
FinePreviewSettings.SampleStep, FinePreviewSettings.RadiusInVoxels,
|
||||||
|
FinePreviewSettings.MaxCells));
|
||||||
|
FString PreviewIndexPath;
|
||||||
|
FString PreviewError;
|
||||||
|
const bool bPreviewIndexWritten = bPreviewWindowSet
|
||||||
|
&& PreviewCandidates.Num() == NumCandidates
|
||||||
|
&& VF_WriteStratePreviewIndex(
|
||||||
|
PreviewDirectory,
|
||||||
|
FString::Printf(TEXT("VoxelForge composer structure preview — seed %d"),
|
||||||
|
AuthoredSettings->Seed),
|
||||||
|
PreviewWindow,
|
||||||
|
PreviewCandidates,
|
||||||
|
PreviewIndexPath,
|
||||||
|
PreviewError);
|
||||||
|
TestTrue(TEXT("64-candidate composer preview index is written"), bPreviewIndexWritten);
|
||||||
|
if (!bPreviewIndexWritten)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(TEXT("composer preview index failed: %s"), *PreviewError));
|
||||||
|
}
|
||||||
|
if (bPreviewWindowSet && PreviewCandidates.Num() > 0)
|
||||||
|
{
|
||||||
|
const FVoxelStratePreviewCandidate& FirstPreview = PreviewCandidates[0];
|
||||||
|
int32 NumRenderedPairs = 0;
|
||||||
|
int32 NumRenderedContourPairs = 0;
|
||||||
|
for (const FVoxelStratePreviewCandidate& Candidate : PreviewCandidates)
|
||||||
|
{
|
||||||
|
if (Candidate.bRendered)
|
||||||
|
{
|
||||||
|
++NumRenderedPairs;
|
||||||
|
}
|
||||||
|
if (Candidate.bContourRendered)
|
||||||
|
{
|
||||||
|
++NumRenderedContourPairs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Composer preview: %d candidates, %d filled pairs + %d contour pairs, "
|
||||||
|
"vertical=%dx%d, plan=%dx%d, "
|
||||||
|
"plan Z chosen from each candidate's measured layer with the highest solid/air "
|
||||||
|
"boundary count (first candidate Z=%d); index=%s; first-candidate window=%s; "
|
||||||
|
"fine previews are linked on survivor cards and centred on each largest open "
|
||||||
|
"space point (never the strate midpoint); image cap=512 px including 20 px "
|
||||||
|
"scale footer."),
|
||||||
|
PreviewCandidates.Num(),
|
||||||
|
NumRenderedPairs,
|
||||||
|
NumRenderedContourPairs,
|
||||||
|
FirstPreview.VerticalImageWidth, FirstPreview.VerticalImageHeight,
|
||||||
|
FirstPreview.PlanImageWidth, FirstPreview.PlanImageHeight,
|
||||||
|
FirstPreview.PlanSliceWorldZ,
|
||||||
|
*PreviewIndexPath,
|
||||||
|
*PreviewWindow.Describe()));
|
||||||
|
}
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Bootstrap jitter: %d field applications across the six structure blocks; %d "
|
||||||
|
"distinct archetype fields took the bootstrap path."),
|
||||||
|
TotalBootstrapJitterFields, BootstrapJitterFieldNames.Num()));
|
||||||
|
|
||||||
|
TestEqual(TEXT("exactly 64 structure candidates were requested"), NumCandidates, 64);
|
||||||
|
TestEqual(TEXT("invalid recipes emitted by the roller/build path"), NumInvalidRecipes, 0);
|
||||||
|
TestEqual(TEXT("custom-stack box verdict violations"), TotalBoxViolations, 0);
|
||||||
|
TestTrue(TEXT("fine cap refusal was checked before any fine allocation"),
|
||||||
|
bFineCapRefusalChecked);
|
||||||
|
TestEqual(TEXT("fine previews are requested only for coarse survivors"),
|
||||||
|
NumFineRequested, NumSurvivors);
|
||||||
|
TestEqual(TEXT("fine preview outcomes account for every requested survivor"),
|
||||||
|
NumFineRendered + NumFineRefused, NumFineRequested);
|
||||||
|
AddInfo(FString::Printf(TEXT(
|
||||||
|
"Fine blank baseline: plan projection %d/%d origin-centred versus %d/%d centred on "
|
||||||
|
"LargestComponentPoint; complete two-projection cards %d/%d versus %d/%d. A blank "
|
||||||
|
"projection/card means its filled-cell pixels were uniform (scale footer excluded), "
|
||||||
|
"not that the measurement was refused."),
|
||||||
|
NumFinePlanBlankBeforeCentering, NumFineRequested,
|
||||||
|
NumFinePlanBlankAfterCentering, NumFineRequested,
|
||||||
|
NumFineCardBlankBeforeCentering, NumFineRequested,
|
||||||
|
NumFineCardBlankAfterCentering, NumFineRequested));
|
||||||
|
TestEqual(TEXT("every rendered fine preview has a scalar contour pair"),
|
||||||
|
NumFineContourRendered, NumFineRendered);
|
||||||
|
TestTrue(TEXT("structure roller emits 64 distinct structural recipes"),
|
||||||
|
DistinctRecipes.Num() == NumCandidates);
|
||||||
|
|
||||||
|
const double TestSeconds = FPlatformTime::Seconds() - TestStartSeconds;
|
||||||
|
AddInfo(FString::Printf(TEXT("StructureRoll total runtime %.3fs; WorldRadiusVoxels=0."),
|
||||||
|
TestSeconds));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
// Correctness-round regression coverage for the cold-audit fixes.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
#include "Async/Async.h"
|
||||||
|
|
||||||
|
#include "VoxelForgeTestFixture.h"
|
||||||
|
#include "VoxelBiomeDefinition.h"
|
||||||
|
#include "VoxelMarchingCubesMesher.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
using namespace VoxelForgeTest;
|
||||||
|
|
||||||
|
float FloatFromBits(uint32 Bits)
|
||||||
|
{
|
||||||
|
float Value = 0.0f;
|
||||||
|
FMemory::Memcpy(&Value, &Bits, sizeof(Value));
|
||||||
|
return Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SameVector(const FVector& A, const FVector& B)
|
||||||
|
{
|
||||||
|
return BitEqual(A.X, B.X) && BitEqual(A.Y, B.Y) && BitEqual(A.Z, B.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SameVector2D(const FVector2D& A, const FVector2D& B)
|
||||||
|
{
|
||||||
|
return BitEqual(A.X, B.X) && BitEqual(A.Y, B.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SameMesh(const FVoxelMeshData& A, const FVoxelMeshData& B, FString& OutWhy)
|
||||||
|
{
|
||||||
|
if (A.NumCeilingTriangles != B.NumCeilingTriangles)
|
||||||
|
{
|
||||||
|
OutWhy = FString::Printf(TEXT("NumCeilingTriangles %d != %d"),
|
||||||
|
A.NumCeilingTriangles, B.NumCeilingTriangles);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (A.Vertices.Num() != B.Vertices.Num()
|
||||||
|
|| A.Triangles.Num() != B.Triangles.Num()
|
||||||
|
|| A.UVs.Num() != B.UVs.Num()
|
||||||
|
|| A.Normals.Num() != B.Normals.Num()
|
||||||
|
|| A.Colors.Num() != B.Colors.Num())
|
||||||
|
{
|
||||||
|
OutWhy = FString::Printf(
|
||||||
|
TEXT("array sizes differ: V %d/%d T %d/%d UV %d/%d N %d/%d C %d/%d"),
|
||||||
|
A.Vertices.Num(), B.Vertices.Num(), A.Triangles.Num(), B.Triangles.Num(),
|
||||||
|
A.UVs.Num(), B.UVs.Num(), A.Normals.Num(), B.Normals.Num(),
|
||||||
|
A.Colors.Num(), B.Colors.Num());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 Index = 0; Index < A.Vertices.Num(); ++Index)
|
||||||
|
{
|
||||||
|
if (!SameVector(A.Vertices[Index], B.Vertices[Index]))
|
||||||
|
{
|
||||||
|
OutWhy = FString::Printf(TEXT("vertex %d differs"), Index);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!SameVector2D(A.UVs[Index], B.UVs[Index]))
|
||||||
|
{
|
||||||
|
OutWhy = FString::Printf(TEXT("UV %d differs"), Index);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!SameVector(A.Normals[Index], B.Normals[Index]))
|
||||||
|
{
|
||||||
|
OutWhy = FString::Printf(TEXT("normal %d differs"), Index);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (A.Colors[Index] != B.Colors[Index])
|
||||||
|
{
|
||||||
|
OutWhy = FString::Printf(TEXT("colour %d differs"), Index);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int32 Index = 0; Index < A.Triangles.Num(); ++Index)
|
||||||
|
{
|
||||||
|
if (A.Triangles[Index] != B.Triangles[Index])
|
||||||
|
{
|
||||||
|
OutWhy = FString::Printf(TEXT("triangle index %d differs"), Index);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
float SampleWithTileContext(const UVoxelGenerator& Generator, int32 Z,
|
||||||
|
int32 Step, int32 Cells, bool* bOutCacheContext = nullptr)
|
||||||
|
{
|
||||||
|
TGuardValue<int32> StepGuard(VoxelGenLOD::SampleStep, Step);
|
||||||
|
TGuardValue<FIntVector> OriginGuard(
|
||||||
|
VoxelGenLOD::TileOriginVoxels, FIntVector::ZeroValue);
|
||||||
|
TGuardValue<int32> CellsGuard(VoxelGenLOD::TileCellsPerAxis, Cells);
|
||||||
|
if (bOutCacheContext)
|
||||||
|
{
|
||||||
|
FIntVector ContextOrigin;
|
||||||
|
int32 ContextStep = 1;
|
||||||
|
int32 ContextCells = 0;
|
||||||
|
*bOutCacheContext = VoxelGenLOD::IsTileCacheWindowEnabled(false)
|
||||||
|
&& VoxelGenLOD::GetThreadTileCacheWindow(
|
||||||
|
ContextOrigin, ContextStep, ContextCells);
|
||||||
|
}
|
||||||
|
return Generator.GetDensityAt(0.0f, 0.0f, static_cast<float>(Z));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsFiniteGenerationParams(const FStrateGenerationParams& Params)
|
||||||
|
{
|
||||||
|
return VoxelMath::IsFinite(Params.BaseDensity)
|
||||||
|
&& VoxelMath::IsFinite(Params.VerticalScale)
|
||||||
|
&& VoxelMath::IsFinite(Params.WormFrequency)
|
||||||
|
&& VoxelMath::IsFinite(Params.WormThreshold)
|
||||||
|
&& VoxelMath::IsFinite(Params.WormStrength)
|
||||||
|
&& VoxelMath::IsFinite(Params.SurfaceRoughness)
|
||||||
|
&& VoxelMath::IsFinite(Params.BoundarySealThickness)
|
||||||
|
&& VoxelMath::IsFinite(Params.StrateTopWorldZ)
|
||||||
|
&& VoxelMath::IsFinite(Params.StrateBottomWorldZ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// #7 — tile-cache context switch and moved-from tunnel state
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeTileCacheContextDeterminismTest,
|
||||||
|
"VoxelForge.Determinism.TileCacheContextSwitch",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeTileCacheContextDeterminismTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
FTestWorld World;
|
||||||
|
World.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
|
||||||
|
if (!World.IsValid())
|
||||||
|
{
|
||||||
|
AddError(World.WhyInvalid());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIntVector OriginChunk = FIntVector::ZeroValue;
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("#7 fixture origin chunk: type=%d uses_op_stack=%d z_span=[%d,%d]."),
|
||||||
|
static_cast<int32>(World.StrateManager->GetGeneratorTypeForChunk(OriginChunk)),
|
||||||
|
World.StrateManager->UsesOperatorStackForChunk(OriginChunk) ? 1 : 0,
|
||||||
|
World.BottomChunkZ, World.TopChunkZ));
|
||||||
|
|
||||||
|
const UVoxelGenerator* Generator = World.Generator.Get();
|
||||||
|
const int32 SampleZ[] = { 0, 8, 16, 24, 31 };
|
||||||
|
TArray<float> CoarseContext;
|
||||||
|
TArray<float> FineContext;
|
||||||
|
bool bCoarseCacheContext = false;
|
||||||
|
bool bFineCacheContext = false;
|
||||||
|
CoarseContext.Reserve(UE_ARRAY_COUNT(SampleZ));
|
||||||
|
FineContext.Reserve(UE_ARRAY_COUNT(SampleZ));
|
||||||
|
|
||||||
|
// Step 16/Cells 16 enables the op-stack tile-window context. Step 1 disables it. The
|
||||||
|
// point remains in chunk (0,0,0), including the origin column that shares a Z cache entry.
|
||||||
|
for (const int32 Z : SampleZ)
|
||||||
|
{
|
||||||
|
CoarseContext.Add(SampleWithTileContext(
|
||||||
|
*Generator, Z, 16, 16, &bCoarseCacheContext));
|
||||||
|
}
|
||||||
|
for (const int32 Z : SampleZ)
|
||||||
|
{
|
||||||
|
FineContext.Add(SampleWithTileContext(
|
||||||
|
*Generator, Z, 1, 32, &bFineCacheContext));
|
||||||
|
}
|
||||||
|
|
||||||
|
TArray<int32> SampleZCopy;
|
||||||
|
for (const int32 Z : SampleZ) { SampleZCopy.Add(Z); }
|
||||||
|
TFuture<TArray<float>> FreshFuture = Async(EAsyncExecution::Thread,
|
||||||
|
[Generator, SampleZCopy = MoveTemp(SampleZCopy)]() mutable
|
||||||
|
{
|
||||||
|
TArray<float> Result;
|
||||||
|
Result.Reserve(SampleZCopy.Num());
|
||||||
|
for (const int32 Z : SampleZCopy)
|
||||||
|
{
|
||||||
|
Result.Add(Generator->GetDensityAt(0.0f, 0.0f, static_cast<float>(Z)));
|
||||||
|
}
|
||||||
|
return Result;
|
||||||
|
});
|
||||||
|
const TArray<float> FreshThread = FreshFuture.Get();
|
||||||
|
|
||||||
|
int32 CoarseMismatches = 0;
|
||||||
|
int32 FineMismatches = 0;
|
||||||
|
bool bObservedNonZero = false;
|
||||||
|
for (int32 Index = 0; Index < FreshThread.Num(); ++Index)
|
||||||
|
{
|
||||||
|
bObservedNonZero |= FMath::Abs(FreshThread[Index]) > KINDA_SMALL_NUMBER;
|
||||||
|
if (!BitEqual(CoarseContext[Index], FreshThread[Index])) { ++CoarseMismatches; }
|
||||||
|
if (!BitEqual(FineContext[Index], FreshThread[Index])) { ++FineMismatches; }
|
||||||
|
}
|
||||||
|
|
||||||
|
TestTrue(TEXT("the origin-column samples exercise a non-empty operator-stack field"),
|
||||||
|
bObservedNonZero);
|
||||||
|
TestEqual(TEXT("coarse tile-window context matches a fresh thread"), CoarseMismatches, 0);
|
||||||
|
TestEqual(TEXT("switching to the non-window context matches a fresh thread"),
|
||||||
|
FineMismatches, 0);
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("#7 origin-column context switch: %d samples, coarse mismatches=%d, fine mismatches=%d; "
|
||||||
|
"all points are in chunk (0,0,0), cache_context coarse=%d fine=%d."),
|
||||||
|
FreshThread.Num(), CoarseMismatches, FineMismatches,
|
||||||
|
bCoarseCacheContext ? 1 : 0, bFineCacheContext ? 1 : 0));
|
||||||
|
|
||||||
|
// Exercise the game-path ordering: a coarse tile warms the moved-out shared entry, then a
|
||||||
|
// level-0 tile at the same origin is compared with a fresh world's level-0 output.
|
||||||
|
FTestWorld WarmWorld;
|
||||||
|
FTestWorld FreshWorld;
|
||||||
|
WarmWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
|
||||||
|
FreshWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
|
||||||
|
if (!WarmWorld.IsValid() || !FreshWorld.IsValid())
|
||||||
|
{
|
||||||
|
AddError(WarmWorld.IsValid() && FreshWorld.IsValid()
|
||||||
|
? TEXT("Unexpected per-tile fixture state")
|
||||||
|
: (!WarmWorld.IsValid() ? WarmWorld.WhyInvalid() : FreshWorld.WhyInvalid()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
TStrongObjectPtr<UVoxelMarchingCubesMesher> WarmMesher(
|
||||||
|
NewObject<UVoxelMarchingCubesMesher>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
TStrongObjectPtr<UVoxelMarchingCubesMesher> FreshMesher(
|
||||||
|
NewObject<UVoxelMarchingCubesMesher>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
WarmMesher->SetGenerator(WarmWorld.Generator.Get());
|
||||||
|
FreshMesher->SetGenerator(FreshWorld.Generator.Get());
|
||||||
|
|
||||||
|
int64 CoarseSamples = 0;
|
||||||
|
WarmMesher->GenerateMesh(FIntVector::ZeroValue, 16, 16, nullptr,
|
||||||
|
INT32_MIN, INT32_MAX, &CoarseSamples);
|
||||||
|
int64 WarmFineSamples = 0;
|
||||||
|
int64 FreshFineSamples = 0;
|
||||||
|
const FVoxelMeshData WarmFine = WarmMesher->GenerateMesh(
|
||||||
|
FIntVector::ZeroValue, 1, CHUNK_SIZE, nullptr, INT32_MIN, INT32_MAX,
|
||||||
|
&WarmFineSamples);
|
||||||
|
const FVoxelMeshData FreshFine = FreshMesher->GenerateMesh(
|
||||||
|
FIntVector::ZeroValue, 1, CHUNK_SIZE, nullptr, INT32_MIN, INT32_MAX,
|
||||||
|
&FreshFineSamples);
|
||||||
|
|
||||||
|
TestTrue(TEXT("the per-tile identity check actually meshed the level-0 origin tile"),
|
||||||
|
WarmFineSamples > 0 && FreshFineSamples > 0);
|
||||||
|
FString MeshDifference;
|
||||||
|
TestTrue(TEXT("coarse-then-fine game path equals a fresh fine tile"),
|
||||||
|
SameMesh(WarmFine, FreshFine, MeshDifference));
|
||||||
|
if (!MeshDifference.IsEmpty())
|
||||||
|
{
|
||||||
|
AddInfo(FString::Printf(TEXT("Per-tile difference detail: %s"), *MeshDifference));
|
||||||
|
}
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("#7 game-path tile identity: coarse samples=%lld, warm fine=%lld, fresh fine=%lld, "
|
||||||
|
"warm vertices=%d, fresh vertices=%d."),
|
||||||
|
(long long)CoarseSamples, (long long)WarmFineSamples, (long long)FreshFineSamples,
|
||||||
|
WarmFine.Vertices.Num(), FreshFine.Vertices.Num()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// #3 — diff-layer identity in the worker-local snapshot slots
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeDiffLayerIdentityTest,
|
||||||
|
"VoxelForge.Determinism.DiffLayerIdentity",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeDiffLayerIdentityTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
FTestWorld CarvedWorld;
|
||||||
|
FTestWorld FilledWorld;
|
||||||
|
CarvedWorld.Build();
|
||||||
|
FilledWorld.Build();
|
||||||
|
if (!CarvedWorld.IsValid() || !FilledWorld.IsValid())
|
||||||
|
{
|
||||||
|
AddError(CarvedWorld.IsValid() && FilledWorld.IsValid()
|
||||||
|
? TEXT("Unexpected diff identity fixture state")
|
||||||
|
: (!CarvedWorld.IsValid() ? CarvedWorld.WhyInvalid() : FilledWorld.WhyInvalid()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FVector P(0.0f, 0.0f, 0.0f);
|
||||||
|
FVoxelModification Carve;
|
||||||
|
Carve.Center = P;
|
||||||
|
Carve.Radius = 8.0f;
|
||||||
|
Carve.Strength = -64.0f;
|
||||||
|
Carve.Shape = EVoxelBrushShape::Sphere;
|
||||||
|
FVoxelModification Fill = Carve;
|
||||||
|
Fill.Strength = 64.0f;
|
||||||
|
|
||||||
|
TestTrue(TEXT("the carve layer accepted its edit"),
|
||||||
|
CarvedWorld.DiffLayer->ApplyModification(Carve).Num() > 0);
|
||||||
|
TestTrue(TEXT("the fill layer accepted its edit"),
|
||||||
|
FilledWorld.DiffLayer->ApplyModification(Fill).Num() > 0);
|
||||||
|
TestEqual(TEXT("the two different layers have the same chunk/version key"),
|
||||||
|
CarvedWorld.DiffLayer->GetModsVersion(), FilledWorld.DiffLayer->GetModsVersion());
|
||||||
|
TestTrue(TEXT("the two layers have distinct worker-cache identities"),
|
||||||
|
CarvedWorld.DiffLayer->GetCacheLifetimeId()
|
||||||
|
!= FilledWorld.DiffLayer->GetCacheLifetimeId());
|
||||||
|
|
||||||
|
// Warm the carve snapshot, then sample the fill on the same thread. A cache keyed only by
|
||||||
|
// (chunk, version) returns the carve's list for the fill layer here.
|
||||||
|
const float Carved = CarvedWorld.Generator->GetDensityAt(P.X, P.Y, P.Z);
|
||||||
|
const float Filled = FilledWorld.Generator->GetDensityAt(P.X, P.Y, P.Z);
|
||||||
|
|
||||||
|
const UVoxelGenerator* FilledGenerator = FilledWorld.Generator.Get();
|
||||||
|
TFuture<float> FreshFilledFuture = Async(EAsyncExecution::Thread,
|
||||||
|
[FilledGenerator, P]()
|
||||||
|
{
|
||||||
|
return FilledGenerator->GetDensityAt(P.X, P.Y, P.Z);
|
||||||
|
});
|
||||||
|
const float FreshFilled = FreshFilledFuture.Get();
|
||||||
|
|
||||||
|
TestTrue(TEXT("the two edits produce different density values at their common centre"),
|
||||||
|
!BitEqual(Carved, FreshFilled));
|
||||||
|
TestTrue(TEXT("the second layer does not inherit the first layer's snapshot"),
|
||||||
|
BitEqual(Filled, FreshFilled));
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("#3 same-thread diff snapshots: carve=%.9g [0x%08X], fill=%.9g [0x%08X], "
|
||||||
|
"fresh fill=%.9g [0x%08X]."),
|
||||||
|
Carved, *reinterpret_cast<const uint32*>(&Carved), Filled,
|
||||||
|
*reinterpret_cast<const uint32*>(&Filled), FreshFilled,
|
||||||
|
*reinterpret_cast<const uint32*>(&FreshFilled)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// #8 — surface/biome worker-cache identities
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeSurfaceBiomeCacheIdentityTest,
|
||||||
|
"VoxelForge.Determinism.SurfaceBiomeCacheIdentity",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeSurfaceBiomeCacheIdentityTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
FTestWorld A;
|
||||||
|
FTestWorld B;
|
||||||
|
A.Build();
|
||||||
|
B.Build();
|
||||||
|
if (!A.IsValid() || !B.IsValid())
|
||||||
|
{
|
||||||
|
AddError(A.IsValid() && B.IsValid()
|
||||||
|
? TEXT("Unexpected surface cache fixture state")
|
||||||
|
: (!A.IsValid() ? A.WhyInvalid() : B.WhyInvalid()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fixture normally gives each world a unique layout version. Bring A forward by one
|
||||||
|
// rebuild so the two managers intentionally share (seed, chunk, layout version); only their
|
||||||
|
// process-unique owner identities may distinguish their worker caches.
|
||||||
|
A.Reinitialize();
|
||||||
|
TestEqual(TEXT("the cache-collision worlds intentionally share a layout version"),
|
||||||
|
A.StrateManager->GetLayoutVersion(), B.StrateManager->GetLayoutVersion());
|
||||||
|
TestTrue(TEXT("the cache-collision worlds have distinct manager identities"),
|
||||||
|
A.StrateManager->GetCacheLifetimeId() != B.StrateManager->GetCacheLifetimeId());
|
||||||
|
TestTrue(TEXT("the cache-collision worlds have distinct generator instances"),
|
||||||
|
A.Generator.Get() != B.Generator.Get());
|
||||||
|
|
||||||
|
int32 SurfTop = 0, SurfBottom = 0;
|
||||||
|
if (!A.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, SurfTop, SurfBottom))
|
||||||
|
{
|
||||||
|
AddError(TEXT("The fixture has no SurfaceWorld slot."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const int32 SurfChunkZ = FMath::FloorToInt(
|
||||||
|
static_cast<float>(SurfTop + SurfBottom) * 0.5f / CHUNK_SIZE);
|
||||||
|
|
||||||
|
UVoxelStrateDefinition* BSurface = B.Definitions[FTestWorld::SlotSurfaceWorld].Get();
|
||||||
|
BSurface->SurfaceParams.ElevationRange *= 3.0f;
|
||||||
|
BSurface->SurfaceParams.MountainStrength = 1.0f;
|
||||||
|
BSurface->SurfaceParams.ContinentFrequency *= 1.7f;
|
||||||
|
BSurface->SurfaceParams.BaseGroundRelative = FMath::Clamp(
|
||||||
|
BSurface->SurfaceParams.BaseGroundRelative + 0.12f, 0.0f, 1.0f);
|
||||||
|
|
||||||
|
bool bFoundDifferentSurface = false;
|
||||||
|
float FirstX = 0.0f, FirstY = 0.0f, HeightA = 0.0f, HeightB = 0.0f;
|
||||||
|
for (int32 Index = 0; Index < 96 && !bFoundDifferentSurface; ++Index)
|
||||||
|
{
|
||||||
|
const float X = 180.0f + static_cast<float>(Index) * 23.0f;
|
||||||
|
const float Y = -260.0f + static_cast<float>(Index) * 19.0f;
|
||||||
|
float CeilA = 0.0f, CeilB = 0.0f;
|
||||||
|
if (!A.Generator->GetSurfaceHeightAt(X, Y, SurfChunkZ, HeightA, CeilA)
|
||||||
|
|| !B.Generator->GetSurfaceHeightAt(X, Y, SurfChunkZ, HeightB, CeilB))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (FMath::Abs(HeightA - HeightB) > 1.0f)
|
||||||
|
{
|
||||||
|
bFoundDifferentSurface = true;
|
||||||
|
FirstX = X;
|
||||||
|
FirstY = Y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TestTrue(TEXT("the two surface definitions produce a distinct heightfield"),
|
||||||
|
bFoundDifferentSurface);
|
||||||
|
if (!bFoundDifferentSurface) { return false; }
|
||||||
|
|
||||||
|
// Warm A immediately before B for each probe. Before the owner/manager key fix, B's integer
|
||||||
|
// XY column lookup reuses A's column because all former key fields are deliberately equal.
|
||||||
|
int32 NumBatches = 0;
|
||||||
|
int32 NumBStale = 0;
|
||||||
|
int32 NumAReferenceDifference = 0;
|
||||||
|
for (int32 Offset = -12; Offset <= 12; ++Offset)
|
||||||
|
{
|
||||||
|
const float Z = FMath::FloorToFloat((HeightA + HeightB) * 0.5f)
|
||||||
|
+ static_cast<float>(Offset);
|
||||||
|
const float ValueA = A.Generator->GetDensityAt(FirstX, FirstY, Z);
|
||||||
|
const float ValueB = B.Generator->GetDensityAt(FirstX, FirstY, Z);
|
||||||
|
const UVoxelGenerator* BGenerator = B.Generator.Get();
|
||||||
|
TFuture<float> FreshFuture = Async(EAsyncExecution::Thread,
|
||||||
|
[BGenerator, FirstX, FirstY, Z]()
|
||||||
|
{
|
||||||
|
return BGenerator->GetDensityAt(FirstX, FirstY, Z);
|
||||||
|
});
|
||||||
|
const float FreshB = FreshFuture.Get();
|
||||||
|
++NumBatches;
|
||||||
|
if (!BitEqual(ValueB, FreshB)) { ++NumBStale; }
|
||||||
|
if (!BitEqual(ValueA, FreshB)) { ++NumAReferenceDifference; }
|
||||||
|
}
|
||||||
|
|
||||||
|
TestTrue(TEXT("the surface probe includes a distinct A/B field value"),
|
||||||
|
NumAReferenceDifference > 0);
|
||||||
|
TestEqual(TEXT("the surface cache never returns the other world's column"), NumBStale, 0);
|
||||||
|
|
||||||
|
// Exercise the biome grid and vertex-palette path with the same intentionally colliding
|
||||||
|
// layout key. One full-climate biome per world makes the expected palette unambiguous.
|
||||||
|
TStrongObjectPtr<UVoxelBiomeDefinition> BiomeA(
|
||||||
|
NewObject<UVoxelBiomeDefinition>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
TStrongObjectPtr<UVoxelBiomeDefinition> BiomeB(
|
||||||
|
NewObject<UVoxelBiomeDefinition>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
BiomeA->ReliefMin = 0.0f; BiomeA->ReliefMax = 1.0f;
|
||||||
|
BiomeA->MoistureMin = 0.0f; BiomeA->MoistureMax = 1.0f;
|
||||||
|
BiomeA->MaterialPaletteIndex = 17;
|
||||||
|
BiomeB->ReliefMin = 0.0f; BiomeB->ReliefMax = 1.0f;
|
||||||
|
BiomeB->MoistureMin = 0.0f; BiomeB->MoistureMax = 1.0f;
|
||||||
|
BiomeB->MaterialPaletteIndex = 91;
|
||||||
|
A.Definitions[FTestWorld::SlotSurfaceWorld]->Biomes.Add(BiomeA.Get());
|
||||||
|
B.Definitions[FTestWorld::SlotSurfaceWorld]->Biomes.Add(BiomeB.Get());
|
||||||
|
|
||||||
|
int32 PaletteA = 0, PaletteAN = 0;
|
||||||
|
int32 PaletteB = 0, PaletteBN = 0;
|
||||||
|
float BlendA = 0.0f, BlendB = 0.0f;
|
||||||
|
A.Generator->GetBiomeMaterialAt(FirstX, FirstY, static_cast<float>(SurfTop),
|
||||||
|
PaletteA, PaletteAN, BlendA);
|
||||||
|
B.Generator->GetBiomeMaterialAt(FirstX, FirstY, static_cast<float>(SurfTop),
|
||||||
|
PaletteB, PaletteBN, BlendB);
|
||||||
|
TestEqual(TEXT("world A resolves its own biome palette"), PaletteA, 17);
|
||||||
|
TestEqual(TEXT("world B resolves its own biome palette"), PaletteB, 91);
|
||||||
|
TestEqual(TEXT("world A's neighbour palette is its own palette"), PaletteAN, 17);
|
||||||
|
TestEqual(TEXT("world B's neighbour palette is its own palette"), PaletteBN, 91);
|
||||||
|
|
||||||
|
// Public cache contract: the identity fields are part of Contains(), not just a comment or
|
||||||
|
// a caller convention. This also remains a cheap unit check for future cache refactors.
|
||||||
|
FChunkBiomeCache Cache;
|
||||||
|
Cache.ValidMinX = -10.0f; Cache.ValidMaxX = 10.0f;
|
||||||
|
Cache.ValidMinY = -10.0f; Cache.ValidMaxY = 10.0f;
|
||||||
|
Cache.ChunkZ = SurfChunkZ; Cache.Seed = 1337;
|
||||||
|
Cache.OwnerId = 101; Cache.ManagerLifetimeId = 202;
|
||||||
|
TestTrue(TEXT("biome cache accepts the exact owner and manager identity"),
|
||||||
|
Cache.Contains(0.0f, 0.0f, SurfChunkZ, 1337, 101, 202));
|
||||||
|
TestFalse(TEXT("biome cache rejects a different generator identity"),
|
||||||
|
Cache.Contains(0.0f, 0.0f, SurfChunkZ, 1337, 102, 202));
|
||||||
|
TestFalse(TEXT("biome cache rejects a different manager identity"),
|
||||||
|
Cache.Contains(0.0f, 0.0f, SurfChunkZ, 1337, 101, 203));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// #14 — passage-version invalidation when the new layout is empty
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeEmptyPassageVersionTest,
|
||||||
|
"VoxelForge.Determinism.EmptyPassageVersion",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeEmptyPassageVersionTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
FTestWorld World;
|
||||||
|
World.Build();
|
||||||
|
if (!World.IsValid())
|
||||||
|
{
|
||||||
|
AddError(World.WhyInvalid());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prime the worker-local paths that keep nearby-passage shortlists, then rebuild to an empty
|
||||||
|
// layout. The old early return cleared Passages but did not bump PassagesVersion.
|
||||||
|
World.Generator->ClassifyTile(FIntVector::ZeroValue, 1, 8);
|
||||||
|
const uint32 Before = World.StrateManager->GetLayoutVersion();
|
||||||
|
World.Settings->TotalStrates = 0;
|
||||||
|
World.Settings->StratePool.Reset();
|
||||||
|
World.Settings->FixedStrates.Reset();
|
||||||
|
const bool bInitialized = World.StrateManager->Initialize(World.Settings.Get(), World.Settings->Seed);
|
||||||
|
|
||||||
|
TestTrue(TEXT("empty settings still complete the empty layout rebuild"), bInitialized);
|
||||||
|
TestEqual(TEXT("the rebuilt layout is empty"), World.StrateManager->GetNumStrates(), 0);
|
||||||
|
TestTrue(TEXT("clearing passages bumps the worker invalidation version"),
|
||||||
|
World.StrateManager->GetLayoutVersion() != Before);
|
||||||
|
TestEqual(TEXT("the empty layout has no passages"), World.StrateManager->GetPassages().Num(), 0);
|
||||||
|
AddInfo(FString::Printf(TEXT("#14 passage version %u -> %u on empty rebuild."),
|
||||||
|
Before, World.StrateManager->GetLayoutVersion()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// #20 — zero blend is a valid runtime value and must remain finite
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeZeroTransitionBlendTest,
|
||||||
|
"VoxelForge.Determinism.ZeroTransitionBlend",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeZeroTransitionBlendTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
FTestWorld World;
|
||||||
|
World.Build();
|
||||||
|
if (!World.IsValid())
|
||||||
|
{
|
||||||
|
AddError(World.WhyInvalid());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int32 Index = 0; Index < World.Definitions.Num(); ++Index)
|
||||||
|
{
|
||||||
|
UVoxelStrateDefinition* Definition = World.Definitions[Index].Get();
|
||||||
|
Definition->TransitionBlendChunks = 0;
|
||||||
|
Definition->TransitionType = (Index & 1) == 0
|
||||||
|
? EVoxelStrateTransition::Gradient : EVoxelStrateTransition::Interleaved;
|
||||||
|
}
|
||||||
|
World.Reinitialize();
|
||||||
|
|
||||||
|
int32 NumQueries = 0;
|
||||||
|
int32 NumNonFinite = 0;
|
||||||
|
for (const FStrateSlot& Slot : World.StrateManager->GetLayout())
|
||||||
|
{
|
||||||
|
const int32 BoundaryChunks[] = { Slot.BottomChunkZ, Slot.TopChunkZ };
|
||||||
|
for (const int32 ChunkZ : BoundaryChunks)
|
||||||
|
{
|
||||||
|
const FStrateGenerationParams Params =
|
||||||
|
World.StrateManager->GetGenerationParams(FIntVector(0, 0, ChunkZ));
|
||||||
|
++NumQueries;
|
||||||
|
if (!IsFiniteGenerationParams(Params)) { ++NumNonFinite; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TestEqual(TEXT("zero-blend generation parameters remain finite at every boundary"),
|
||||||
|
NumNonFinite, 0);
|
||||||
|
AddInfo(FString::Printf(TEXT("#20 queried %d zero-blend boundary parameter sets."), NumQueries));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// #15 and #21 — malformed API input and direct per-chunk range lookup
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeDiffApiBoundaryTest,
|
||||||
|
"VoxelForge.Determinism.DiffApiBoundary",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeDiffApiBoundaryTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
TStrongObjectPtr<UVoxelDiffLayer> Diff(
|
||||||
|
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
Diff->SetBudget(0, 50.0f, 0.0f);
|
||||||
|
|
||||||
|
const float NaN = FloatFromBits(0x7FC12345u);
|
||||||
|
const float Inf = FloatFromBits(0x7F800000u);
|
||||||
|
TestFalse(TEXT("CanModify rejects zero radius"), Diff->CanModify(0.0f));
|
||||||
|
TestFalse(TEXT("CanModify rejects NaN radius"), Diff->CanModify(NaN));
|
||||||
|
TestFalse(TEXT("CanModify rejects infinity radius"), Diff->CanModify(Inf));
|
||||||
|
|
||||||
|
auto ExpectRejected = [&](const TCHAR* Label, const FVoxelModification& Mod)
|
||||||
|
{
|
||||||
|
const int32 Before = Diff->GetTotalModificationCount();
|
||||||
|
const TArray<FIntVector> Touched = Diff->ApplyModification(Mod);
|
||||||
|
TestEqual(FString::Printf(TEXT("%s returns no affected chunks"), Label), Touched.Num(), 0);
|
||||||
|
TestEqual(FString::Printf(TEXT("%s does not update stored entries"), Label),
|
||||||
|
Diff->GetTotalModificationCount(), Before);
|
||||||
|
};
|
||||||
|
|
||||||
|
FVoxelModification Mod;
|
||||||
|
Mod.Radius = 0.0f;
|
||||||
|
ExpectRejected(TEXT("zero radius"), Mod);
|
||||||
|
Mod = FVoxelModification();
|
||||||
|
Mod.Radius = NaN;
|
||||||
|
ExpectRejected(TEXT("NaN radius"), Mod);
|
||||||
|
Mod = FVoxelModification();
|
||||||
|
Mod.Center.X = Inf;
|
||||||
|
ExpectRejected(TEXT("infinite centre"), Mod);
|
||||||
|
Mod = FVoxelModification();
|
||||||
|
Mod.Strength = NaN;
|
||||||
|
ExpectRejected(TEXT("NaN strength"), Mod);
|
||||||
|
Mod = FVoxelModification();
|
||||||
|
Mod.Shape = EVoxelBrushShape::Box;
|
||||||
|
Mod.BoxExtent.X = -1.0f;
|
||||||
|
ExpectRejected(TEXT("negative box extent"), Mod);
|
||||||
|
Mod = FVoxelModification();
|
||||||
|
Mod.Shape = EVoxelBrushShape::Capsule;
|
||||||
|
Mod.CapsuleEnd.Y = Inf;
|
||||||
|
ExpectRejected(TEXT("infinite capsule endpoint"), Mod);
|
||||||
|
Mod = FVoxelModification();
|
||||||
|
Mod.Shape = static_cast<EVoxelBrushShape>(255);
|
||||||
|
ExpectRejected(TEXT("unknown brush shape"), Mod);
|
||||||
|
|
||||||
|
// Shape-aware budget regression: the old implementation billed both of these as a sphere using
|
||||||
|
// Radius alone, so each incorrectly fit under a 1,000-voxel cumulative budget.
|
||||||
|
TStrongObjectPtr<UVoxelDiffLayer> ShapeBudget(
|
||||||
|
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
ShapeBudget->SetBudget(0, 50.0f, 1000.0f);
|
||||||
|
FVoxelModification LongCapsule;
|
||||||
|
LongCapsule.Shape = EVoxelBrushShape::Capsule;
|
||||||
|
LongCapsule.Center = FVector::ZeroVector;
|
||||||
|
LongCapsule.CapsuleEnd = FVector(0.0f, 1000.0f, 0.0f);
|
||||||
|
LongCapsule.Radius = 3.0f;
|
||||||
|
LongCapsule.Falloff = 2.0f;
|
||||||
|
TestFalse(TEXT("a long capsule is charged for its swept volume"),
|
||||||
|
ShapeBudget->CanModify(LongCapsule));
|
||||||
|
TestEqual(TEXT("the long capsule is not stored under the shape budget"),
|
||||||
|
ShapeBudget->ApplyModification(LongCapsule).Num(), 0);
|
||||||
|
|
||||||
|
FVoxelModification LargeBox;
|
||||||
|
LargeBox.Shape = EVoxelBrushShape::Box;
|
||||||
|
LargeBox.BoxExtent = FVector(5.0f);
|
||||||
|
LargeBox.Radius = 5.0f;
|
||||||
|
TestFalse(TEXT("a box is charged for its expanded box volume"),
|
||||||
|
ShapeBudget->CanModify(LargeBox));
|
||||||
|
TestEqual(TEXT("the large box is not stored under the shape budget"),
|
||||||
|
ShapeBudget->ApplyModification(LargeBox).Num(), 0);
|
||||||
|
|
||||||
|
FVoxelModification Valid;
|
||||||
|
Valid.Center = FVector(3.0f * CHUNK_SIZE + 4.0f,
|
||||||
|
4.0f * CHUNK_SIZE + 4.0f, 2.0f);
|
||||||
|
Valid.Radius = 3.0f;
|
||||||
|
Valid.Strength = -4.0f;
|
||||||
|
TestTrue(TEXT("a valid modification remains accepted after malformed inputs"),
|
||||||
|
Diff->ApplyModification(Valid).Num() > 0);
|
||||||
|
|
||||||
|
const FIntVector Hit(3, 4, 0);
|
||||||
|
TestTrue(TEXT("range lookup finds the directly grouped affected chunk"),
|
||||||
|
Diff->HasAnyModInChunkRange(Hit, Hit));
|
||||||
|
TestFalse(TEXT("range lookup rejects a far unmodified chunk"),
|
||||||
|
Diff->HasAnyModInChunkRange(FIntVector(30, 30, 30), FIntVector(30, 30, 30)));
|
||||||
|
TestFalse(TEXT("range lookup rejects reversed bounds"),
|
||||||
|
Diff->HasAnyModInChunkRange(FIntVector(4, 4, 0), FIntVector(3, 4, 0)));
|
||||||
|
TestTrue(TEXT("the valid edit left a non-empty diff layer"), Diff->HasAnyMods());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
@@ -163,15 +163,10 @@ bool FVoxelForgeCrossPlatformTest::RunTest(const FString& Parameters)
|
|||||||
// que ça rendait cette mesure caduque. **C'est faux, et il a fallu vérifier plutôt que
|
// que ça rendait cette mesure caduque. **C'est faux, et il a fallu vérifier plutôt que
|
||||||
// supposer.**
|
// supposer.**
|
||||||
//
|
//
|
||||||
// Il reste `FMath::Sin` / `FMath::Cos`, présents partout dans le chemin de densité (lignes de
|
// The world path now owns its sine/cosine range reduction and polynomial in VoxelMath. Before
|
||||||
// strates, nervures, placement des salles, rotations). **`sinf`/`cosf` ne sont PAS spécifiés
|
// that replacement, `sinf`/`cosf` were not specified by IEEE-754: MSVC's CRT and glibc's libm
|
||||||
// par IEEE-754** : le CRT de MSVC et la libm de la glibc ont parfaitement le droit de rendre
|
// could return different results (typically <= 1 ULP). `FPSemantics` closed the compiler half
|
||||||
// des résultats différents (typiquement ≤ 1 ULP, mais différents). `FPSemantics` a donc fermé
|
// of §C9; DetSin/DetCos close the library half as well.
|
||||||
// la moitié COMPILATEUR de §C9 et laissé ouverte la moitié BIBLIOTHÈQUE.
|
|
||||||
//
|
|
||||||
// FPSemantics = Precise removed the float-MODEL difference, but sinf/cosf are not IEEE-754
|
|
||||||
// specified, so MSVC's CRT and glibc's libm may still differ. The compiler half of C9 is closed;
|
|
||||||
// the library half is not.
|
|
||||||
//
|
//
|
||||||
// D'où trois bandes au lieu d'une : une bande unique à 1e-4 est **100× trop large** pour un
|
// D'où trois bandes au lieu d'une : une bande unique à 1e-4 est **100× trop large** pour un
|
||||||
// écart de libm (~1e-6 en absolu sur des densités de magnitude ~10), donc elle sur-estime
|
// écart de libm (~1e-6 en absolu sur des densités de magnitude ~10), donc elle sur-estime
|
||||||
@@ -223,37 +218,30 @@ bool FVoxelForgeCrossPlatformTest::RunTest(const FString& Parameters)
|
|||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("CROSS-PLATFORM DIGEST (seed %d, %d samples, step %d/%d)\n")
|
TEXT("CROSS-PLATFORM DIGEST (seed %d, %d samples, step %d/%d)\n")
|
||||||
TEXT(" SHAPE digest : 0x%016llX <- must match across Windows/Linux. This is the world.\n")
|
TEXT(" SHAPE digest : 0x%016llX <- must match across Windows/Linux. This is the world.\n")
|
||||||
TEXT(" FIELD digest : 0x%016llX <- bit-for-bit. May differ; see NearIso below.\n")
|
TEXT(" FIELD digest : 0x%016llX <- expected bit-for-bit after DetSin/DetCos.\n")
|
||||||
TEXT(" solid %d / air %d / NaN %d"),
|
TEXT(" solid %d / air %d / NaN %d"),
|
||||||
World.Settings->Seed, NumSamples, XYStep, ZStep,
|
World.Settings->Seed, NumSamples, XYStep, ZStep,
|
||||||
ShapeDigest, FieldDigest, NumSolid, NumSamples - NumSolid, NumNaN));
|
ShapeDigest, FieldDigest, NumSolid, NumSamples - NumSolid, NumNaN));
|
||||||
|
|
||||||
// Le PROFIL de proximité à l'isosurface, plutôt qu'un seul seuil binaire.
|
// Le PROFIL de proximité à l'isosurface reste utile pour visualiser la marge géométrique,
|
||||||
|
// mais il ne mesure plus une divergence CRT : the world trig path is deterministic now.
|
||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("NearIso profile over %d samples: %d within 1e-4, %d within 1e-5, %d within 1e-6. ")
|
TEXT("NearIso profile over %d samples: %d within 1e-4, %d within 1e-5, %d within 1e-6. ")
|
||||||
TEXT("Only the LAST number is the cross-platform risk: FPSemantics = Precise removed the ")
|
TEXT("This is a geometric margin profile; DetSin/DetCos removes the prior CRT/libm source ")
|
||||||
TEXT("float-MODEL difference, so what remains is that sinf/cosf are not IEEE-754 specified ")
|
TEXT("of cross-platform variation."),
|
||||||
TEXT("and MSVC's CRT may differ from glibc's libm by ~1 ULP. On densities of magnitude ~10 ")
|
|
||||||
TEXT("that is ~1e-6 absolute, which is why the wide band over-states the risk ~100x."),
|
|
||||||
NumSamples, NumNearWide, NumNearMid, NumNearTight));
|
NumSamples, NumNearWide, NumNearMid, NumNearTight));
|
||||||
|
|
||||||
if (NumNearTight > 0)
|
if (NumNearTight > 0)
|
||||||
{
|
{
|
||||||
AddWarning(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("%d of %d samples sit within 1e-6 of the isosurface -- tight enough that a libm ")
|
TEXT("%d of %d samples sit within 1e-6 of the isosurface. The proximity is worth ")
|
||||||
TEXT("difference between MSVC and glibc could flip their SIGN, i.e. one voxel solid for ")
|
TEXT("recording, but it is no longer a CRT/libm cross-platform risk because the production ")
|
||||||
TEXT("a Windows host and air for a Linux client. FMath::Sin/Cos are used throughout the ")
|
TEXT("world path uses deterministic VoxelMath::DetSin/DetCos."),
|
||||||
TEXT("density path (layer lines, ribs, room placement, rotations), so this is the ")
|
|
||||||
TEXT("REMAINING half of AUDIT C9 -- the compiler half is fixed, the library half is not. ")
|
|
||||||
TEXT("If this must be zero, the fix is a deterministic in-house sin/cos in the density ")
|
|
||||||
TEXT("path (one more world re-tune), not another build flag."),
|
|
||||||
NumNearTight, NumSamples));
|
NumNearTight, NumSamples));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AddInfo(TEXT("No sample sits within 1e-6 of the isosurface, so no sampled voxel is close ")
|
AddInfo(TEXT("No sample sits within 1e-6 of the isosurface on this grid."));
|
||||||
TEXT("enough for a libm difference to flip its side. Evidence, not proof: it covers ")
|
|
||||||
TEXT("this grid, not every voxel of a world."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TestEqual(TEXT("no sample produced NaN"), NumNaN, 0);
|
TestEqual(TEXT("no sample produced NaN"), NumNaN, 0);
|
||||||
|
|||||||
@@ -0,0 +1,592 @@
|
|||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
|
||||||
|
#include "HAL/IConsoleManager.h"
|
||||||
|
#include "VoxelClipmapDesiredTiles.h"
|
||||||
|
#include "VoxelDensityOpStack.h"
|
||||||
|
#include "VoxelForgeTestFixture.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// The marching-cubes grid contains 32 cells, with one sample of halo on each side.
|
||||||
|
constexpr int32 MesherGridSize = 35;
|
||||||
|
constexpr int32 ArchetypeCount = 8;
|
||||||
|
constexpr int32 PassageGridCount = 2;
|
||||||
|
constexpr int32 GridSetCount = ArchetypeCount + PassageGridCount;
|
||||||
|
|
||||||
|
static bool SameFloatBits(float A, float B)
|
||||||
|
{
|
||||||
|
return FMemory::Memcmp(&A, &B, sizeof(float)) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static FIntVector MakeMesherGridOrigin(const FIntVector& TargetChunk, int32 Level)
|
||||||
|
{
|
||||||
|
const int32 TileSpanChunks = 1 << Level;
|
||||||
|
const FIntVector TileCoord = VoxelClipmapDesiredTiles::FloorDiv(
|
||||||
|
TargetChunk, TileSpanChunks);
|
||||||
|
const int32 Step = 1 << Level;
|
||||||
|
return TileCoord * (CHUNK_SIZE << Level) - FIntVector(Step, Step, Step);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FGridComparison
|
||||||
|
{
|
||||||
|
int64 Samples = 0;
|
||||||
|
int32 Mismatches = 0;
|
||||||
|
FString FirstMismatch;
|
||||||
|
TArray<float> BlockDensities;
|
||||||
|
};
|
||||||
|
|
||||||
|
static bool BuildProductionStackForArchetype(
|
||||||
|
VoxelForgeTest::FTestWorld& World, ECaveGeneratorType Type,
|
||||||
|
const FIntVector& ChunkCoord, FVoxelOpStack& OutStack, FVoxelOpContext& OutContext)
|
||||||
|
{
|
||||||
|
const int32 Seed = World.Settings->GetEffectiveWorldSeed();
|
||||||
|
const float SpineRadius = World.Settings->GetEffectiveOriginSpineRadius();
|
||||||
|
const UVoxelStrateManager* Manager = World.StrateManager.Get();
|
||||||
|
OutContext = FVoxelOpContext();
|
||||||
|
OutContext.ChunkCoord = ChunkCoord;
|
||||||
|
OutContext.Seed = static_cast<uint32>(Seed);
|
||||||
|
OutContext.LayoutVersion = Manager->GetLayoutVersion();
|
||||||
|
OutContext.WorldRadiusVoxels = World.Settings->GetEffectiveWorldRadiusVoxels();
|
||||||
|
OutContext.EdgeSealThickness = World.Settings->EdgeSealThickness;
|
||||||
|
|
||||||
|
switch (Type)
|
||||||
|
{
|
||||||
|
case ECaveGeneratorType::FlatPlain:
|
||||||
|
case ECaveGeneratorType::CrystalChamber:
|
||||||
|
{
|
||||||
|
const FSlabGenerationParams P = Manager->GetSlabParamsForChunk(ChunkCoord);
|
||||||
|
OutContext.StrateTopWorldZ = P.StrateTopWorldZ;
|
||||||
|
OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ;
|
||||||
|
VoxelDensityOps::BuildSlabStack(OutStack, P, Seed, SpineRadius, Manager);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case ECaveGeneratorType::Maze:
|
||||||
|
{
|
||||||
|
const FMazeGenerationParams P = Manager->GetMazeParamsForChunk(ChunkCoord);
|
||||||
|
OutContext.StrateTopWorldZ = P.StrateTopWorldZ;
|
||||||
|
OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ;
|
||||||
|
VoxelDensityOps::BuildMazeStack(OutStack, P, Seed, SpineRadius, Manager);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case ECaveGeneratorType::SurfaceWorld:
|
||||||
|
{
|
||||||
|
const FSurfaceGenerationParams P = Manager->GetSurfaceParamsForChunk(ChunkCoord);
|
||||||
|
OutContext.StrateTopWorldZ = P.StrateTopWorldZ;
|
||||||
|
OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ;
|
||||||
|
// The generator-grid comparison below covers the resolved biome route. This builder
|
||||||
|
// exercises the production stack's base surface branch directly.
|
||||||
|
VoxelDensityOps::BuildSurfaceStack(
|
||||||
|
OutStack, P, Seed, SpineRadius, Manager, TArray<FSurfaceGenerationParams>(), nullptr);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case ECaveGeneratorType::VerticalShafts:
|
||||||
|
{
|
||||||
|
const FVerticalShaftParams P = Manager->GetVerticalShaftParamsForChunk(ChunkCoord);
|
||||||
|
OutContext.StrateTopWorldZ = P.StrateTopWorldZ;
|
||||||
|
OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ;
|
||||||
|
VoxelDensityOps::BuildVerticalShaftStack(OutStack, P, Seed, SpineRadius, Manager);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case ECaveGeneratorType::FloatingIslands:
|
||||||
|
{
|
||||||
|
const FFloatingIslandParams P = Manager->GetFloatingIslandParamsForChunk(ChunkCoord);
|
||||||
|
OutContext.StrateTopWorldZ = P.StrateTopWorldZ;
|
||||||
|
OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ;
|
||||||
|
VoxelDensityOps::BuildFloatingIslandStack(OutStack, P, Seed, SpineRadius, Manager);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case ECaveGeneratorType::TunnelNetwork:
|
||||||
|
case ECaveGeneratorType::Underwater:
|
||||||
|
{
|
||||||
|
const FStrateGenerationParams P = Manager->GetGenerationParams(ChunkCoord);
|
||||||
|
OutContext.StrateTopWorldZ = P.StrateTopWorldZ;
|
||||||
|
OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ;
|
||||||
|
VoxelDensityOps::BuildTunnelNetworkStack(OutStack, P, Seed, SpineRadius, Manager);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool FindChunkSampleRange(int32 AxisOrigin, int32 Step, int32 AxisSize,
|
||||||
|
int32 TargetChunk, int32& OutMin, int32& OutMax)
|
||||||
|
{
|
||||||
|
OutMin = INT32_MAX;
|
||||||
|
OutMax = INT32_MIN;
|
||||||
|
for (int32 Index = 0; Index < AxisSize; ++Index)
|
||||||
|
{
|
||||||
|
const int32 Coordinate = AxisOrigin + Index * Step;
|
||||||
|
if (FMath::FloorToInt(static_cast<float>(Coordinate) / CHUNK_SIZE) == TargetChunk)
|
||||||
|
{
|
||||||
|
OutMin = FMath::Min(OutMin, Index);
|
||||||
|
OutMax = FMath::Max(OutMax, Index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return OutMin != INT32_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
static FGridComparison CompareStackChunkBlockWithScalar(
|
||||||
|
FVoxelOpStack& Stack, FIntVector GridOrigin, int32 Step,
|
||||||
|
const FIntVector& TargetChunk)
|
||||||
|
{
|
||||||
|
FGridComparison Result;
|
||||||
|
int32 MinX = 0, MaxX = 0, MinY = 0, MaxY = 0, MinZ = 0, MaxZ = 0;
|
||||||
|
if (!FindChunkSampleRange(GridOrigin.X, Step, MesherGridSize, TargetChunk.X, MinX, MaxX)
|
||||||
|
|| !FindChunkSampleRange(GridOrigin.Y, Step, MesherGridSize, TargetChunk.Y, MinY, MaxY)
|
||||||
|
|| !FindChunkSampleRange(GridOrigin.Z, Step, MesherGridSize, TargetChunk.Z, MinZ, MaxZ))
|
||||||
|
{
|
||||||
|
Result.FirstMismatch = TEXT("mesher grid has no samples in the stack's target chunk");
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int32 SizeX = MaxX - MinX + 1;
|
||||||
|
const int32 SizeY = MaxY - MinY + 1;
|
||||||
|
const int32 SizeZ = MaxZ - MinZ + 1;
|
||||||
|
const FIntVector Origin = GridOrigin + FIntVector(MinX * Step, MinY * Step, MinZ * Step);
|
||||||
|
const FIntVector Last = Origin + FIntVector(
|
||||||
|
(SizeX - 1) * Step, (SizeY - 1) * Step, (SizeZ - 1) * Step);
|
||||||
|
const FBox Box(
|
||||||
|
FVector((float)Origin.X, (float)Origin.Y, (float)Origin.Z),
|
||||||
|
FVector((float)Last.X, (float)Last.Y, (float)Last.Z));
|
||||||
|
|
||||||
|
TArray<int32> ActiveOps;
|
||||||
|
const int32 ActiveCount = Stack.BuildActiveOpList(Box, Step, Origin, ActiveOps);
|
||||||
|
TArray<FVoxelOpSample> Samples;
|
||||||
|
Samples.SetNumUninitialized(SizeX * SizeY * SizeZ);
|
||||||
|
for (FVoxelOpSample& Sample : Samples) { Sample = FVoxelOpSample(); }
|
||||||
|
|
||||||
|
FVoxelOpBlock Block;
|
||||||
|
Block.OriginVoxels = Origin;
|
||||||
|
Block.Step = Step;
|
||||||
|
Block.SizeX = SizeX;
|
||||||
|
Block.SizeY = SizeY;
|
||||||
|
Block.SizeZ = SizeZ;
|
||||||
|
Block.Samples = Samples.GetData();
|
||||||
|
Stack.EvalBlock(Block, ActiveOps);
|
||||||
|
|
||||||
|
for (int32 Z = 0; Z < SizeZ; ++Z)
|
||||||
|
for (int32 Y = 0; Y < SizeY; ++Y)
|
||||||
|
for (int32 X = 0; X < SizeX; ++X)
|
||||||
|
{
|
||||||
|
const FIntVector SamplePos = Origin + FIntVector(X * Step, Y * Step, Z * Step);
|
||||||
|
const float BlockDensity = Block.At(X, Y, Z).Density;
|
||||||
|
const float ScalarDensity = Stack.EvalInternal(
|
||||||
|
(float)SamplePos.X, (float)SamplePos.Y, (float)SamplePos.Z);
|
||||||
|
Result.BlockDensities.Add(BlockDensity);
|
||||||
|
++Result.Samples;
|
||||||
|
if (!SameFloatBits(BlockDensity, ScalarDensity))
|
||||||
|
{
|
||||||
|
++Result.Mismatches;
|
||||||
|
if (Result.FirstMismatch.IsEmpty())
|
||||||
|
{
|
||||||
|
Result.FirstMismatch = FString::Printf(
|
||||||
|
TEXT("box=[(%d,%d,%d)..(%d,%d,%d)] active_ops=%d/%d sample=(%d,%d,%d) block=%.9g scalar=%.9g delta=%.9g"),
|
||||||
|
Origin.X, Origin.Y, Origin.Z, Last.X, Last.Y, Last.Z,
|
||||||
|
ActiveCount, Stack.Num(), SamplePos.X, SamplePos.Y, SamplePos.Z,
|
||||||
|
BlockDensity, ScalarDensity, BlockDensity - ScalarDensity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static FGridComparison CompareGeneratorBlockWithScalar(
|
||||||
|
UVoxelGenerator& Generator, FIntVector Origin, int32 Step)
|
||||||
|
{
|
||||||
|
FGridComparison Result;
|
||||||
|
const int32 Count = MesherGridSize * MesherGridSize * MesherGridSize;
|
||||||
|
Result.BlockDensities.SetNumUninitialized(Count);
|
||||||
|
Generator.BeginDensityBlock(Origin, Step, MesherGridSize, MesherGridSize, MesherGridSize);
|
||||||
|
int32 Index = 0;
|
||||||
|
for (int32 Z = 0; Z < MesherGridSize; ++Z)
|
||||||
|
for (int32 Y = 0; Y < MesherGridSize; ++Y)
|
||||||
|
for (int32 X = 0; X < MesherGridSize; ++X, ++Index)
|
||||||
|
{
|
||||||
|
const FIntVector SamplePos = Origin + FIntVector(X * Step, Y * Step, Z * Step);
|
||||||
|
Result.BlockDensities[Index] = Generator.GetDensityAt(
|
||||||
|
(float)SamplePos.X, (float)SamplePos.Y, (float)SamplePos.Z);
|
||||||
|
}
|
||||||
|
Generator.EndDensityBlock();
|
||||||
|
|
||||||
|
Index = 0;
|
||||||
|
for (int32 Z = 0; Z < MesherGridSize; ++Z)
|
||||||
|
for (int32 Y = 0; Y < MesherGridSize; ++Y)
|
||||||
|
for (int32 X = 0; X < MesherGridSize; ++X, ++Index)
|
||||||
|
{
|
||||||
|
const FIntVector SamplePos = Origin + FIntVector(X * Step, Y * Step, Z * Step);
|
||||||
|
const float ScalarDensity = Generator.GetDensityAt(
|
||||||
|
(float)SamplePos.X, (float)SamplePos.Y, (float)SamplePos.Z);
|
||||||
|
++Result.Samples;
|
||||||
|
if (!SameFloatBits(Result.BlockDensities[Index], ScalarDensity))
|
||||||
|
{
|
||||||
|
++Result.Mismatches;
|
||||||
|
if (Result.FirstMismatch.IsEmpty())
|
||||||
|
{
|
||||||
|
Result.FirstMismatch = FString::Printf(
|
||||||
|
TEXT("sample=(%d,%d,%d) block=%.9g scalar=%.9g delta=%.9g"),
|
||||||
|
SamplePos.X, SamplePos.Y, SamplePos.Z, Result.BlockDensities[Index],
|
||||||
|
ScalarDensity, Result.BlockDensities[Index] - ScalarDensity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int32 FindGapChunkZ(const VoxelForgeTest::FTestWorld& World)
|
||||||
|
{
|
||||||
|
const int32 Min = World.BottomChunkZ - 8;
|
||||||
|
const int32 Max = World.TopChunkZ + 8;
|
||||||
|
for (int32 ChunkZ = Min; ChunkZ <= Max; ++ChunkZ)
|
||||||
|
{
|
||||||
|
if (World.StrateManager->IsGapChunk(FIntVector(0, 0, ChunkZ)))
|
||||||
|
{
|
||||||
|
return ChunkZ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MIN_int32;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int32 CountGapSamples(const VoxelForgeTest::FTestWorld& World,
|
||||||
|
FIntVector Origin, int32 Step)
|
||||||
|
{
|
||||||
|
TSet<int32> SampleChunkZs;
|
||||||
|
for (int32 Z = 0; Z < MesherGridSize; ++Z)
|
||||||
|
{
|
||||||
|
SampleChunkZs.Add(FMath::FloorToInt(
|
||||||
|
static_cast<float>(Origin.Z + Z * Step) / CHUNK_SIZE));
|
||||||
|
}
|
||||||
|
int32 Count = 0;
|
||||||
|
for (const int32 ChunkZ : SampleChunkZs)
|
||||||
|
{
|
||||||
|
if (World.StrateManager->IsGapChunk(FIntVector(0, 0, ChunkZ)))
|
||||||
|
{
|
||||||
|
++Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FScopedConsoleIntOverride
|
||||||
|
{
|
||||||
|
IConsoleVariable* Variable = nullptr;
|
||||||
|
int32 OriginalValue = 0;
|
||||||
|
|
||||||
|
explicit FScopedConsoleIntOverride(const TCHAR* Name, int32 Value)
|
||||||
|
: Variable(IConsoleManager::Get().FindConsoleVariable(Name))
|
||||||
|
{
|
||||||
|
if (Variable != nullptr)
|
||||||
|
{
|
||||||
|
OriginalValue = Variable->GetInt();
|
||||||
|
Variable->Set(Value, ECVF_SetByCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
~FScopedConsoleIntOverride()
|
||||||
|
{
|
||||||
|
if (Variable != nullptr)
|
||||||
|
{
|
||||||
|
Variable->Set(OriginalValue, ECVF_SetByCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeDensityBlockEquivalenceTest,
|
||||||
|
"VoxelForge.Density.OperatorBlockMatchesScalarGrid",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeDensityBlockEquivalenceTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
(void)Parameters;
|
||||||
|
|
||||||
|
// The production mesher initializes these once at startup. This headless test does so too,
|
||||||
|
// then temporarily disables the fused TunnelNetwork evaluator so the real per-chunk
|
||||||
|
// BeginDensityBlock -> BuildActiveOpList -> EvalBlock path is compared against scalar output.
|
||||||
|
VoxelGenLOD::InitializeConsoleSwitches();
|
||||||
|
FScopedConsoleIntOverride FusedEvaluatorOverride(TEXT("voxel.UseFusedEvaluator"), 0);
|
||||||
|
if (FusedEvaluatorOverride.Variable == nullptr)
|
||||||
|
{
|
||||||
|
AddError(TEXT("voxel.UseFusedEvaluator cvar is unavailable; block route was not forced"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const int32 Seeds[] = {1337, 9176, 17015, 24854, 32693, 40532};
|
||||||
|
static const ECaveGeneratorType Archetypes[ArchetypeCount] = {
|
||||||
|
ECaveGeneratorType::TunnelNetwork,
|
||||||
|
ECaveGeneratorType::FlatPlain,
|
||||||
|
ECaveGeneratorType::CrystalChamber,
|
||||||
|
ECaveGeneratorType::Maze,
|
||||||
|
ECaveGeneratorType::SurfaceWorld,
|
||||||
|
ECaveGeneratorType::VerticalShafts,
|
||||||
|
ECaveGeneratorType::FloatingIslands,
|
||||||
|
ECaveGeneratorType::Underwater,
|
||||||
|
};
|
||||||
|
|
||||||
|
int64 GeneratorSamples = 0;
|
||||||
|
int32 GeneratorMismatches = 0;
|
||||||
|
FString FirstGeneratorMismatch;
|
||||||
|
int64 DirectStackSamples = 0;
|
||||||
|
int32 DirectStackMismatches = 0;
|
||||||
|
FString FirstDirectStackMismatch;
|
||||||
|
int32 DirectStackBlocks = 0;
|
||||||
|
int32 GeneratorBlocks = 0;
|
||||||
|
int32 ProductionArchetypeBlocks = 0;
|
||||||
|
int32 BoundaryBlocks = 0;
|
||||||
|
int32 NearOriginBlocks = 0;
|
||||||
|
int32 FarOriginBlocks = 0;
|
||||||
|
int32 GapCoverageBlocks = 0;
|
||||||
|
int32 PassageFocusedBlocks = 0;
|
||||||
|
int32 SpecialGapCoverageBlocks = 0;
|
||||||
|
int32 EditedSamplesChanged = 0;
|
||||||
|
int32 AcceptedEdits = 0;
|
||||||
|
int32 ArchetypeLevelCounts[ArchetypeCount][6] = {};
|
||||||
|
|
||||||
|
for (int32 SeedIndex = 0; SeedIndex < UE_ARRAY_COUNT(Seeds); ++SeedIndex)
|
||||||
|
{
|
||||||
|
VoxelForgeTest::FTestWorld World;
|
||||||
|
World.Build(Seeds[SeedIndex], /*gap chunks=*/2, /*operator stack=*/true);
|
||||||
|
if (!World.IsValid())
|
||||||
|
{
|
||||||
|
AddError(World.WhyInvalid());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The fixture uses coarse-LOD edits up to 64 voxels wide. Keep the actual diff layer in
|
||||||
|
// the route while allowing those bounded test brushes.
|
||||||
|
World.DiffLayer->SetBudget(/*max mods=*/0, /*max radius=*/256.0f,
|
||||||
|
/*max accumulated volume=*/0.0f);
|
||||||
|
|
||||||
|
const TArray<FStrateSlot>& Slots = World.StrateManager->GetLayout();
|
||||||
|
const TArray<FVoxelPassage>& Passages = World.StrateManager->GetPassages();
|
||||||
|
if (Slots.Num() < ArchetypeCount || Passages.IsEmpty())
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("seed %d is missing an archetype slot or passage fixture"), Seeds[SeedIndex]));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const int32 GapChunkZ = FindGapChunkZ(World);
|
||||||
|
if (GapChunkZ == MIN_int32)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(TEXT("seed %d produced no gap chunk fixture"), Seeds[SeedIndex]));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FIntVector GridOrigins[GridSetCount];
|
||||||
|
int32 GridSteps[GridSetCount] = {};
|
||||||
|
int32 GridArchetypes[GridSetCount] = {};
|
||||||
|
FGridComparison BaseResults[GridSetCount];
|
||||||
|
|
||||||
|
// One complete mesher lattice for each production archetype per seed. Rotating level by
|
||||||
|
// seed gives every archetype all levels 0..5 over the six seeds. The sample grids touch a
|
||||||
|
// strate edge, and cycle through origin, near-origin, and both far-field signs.
|
||||||
|
for (int32 ArchetypeIndex = 0; ArchetypeIndex < ArchetypeCount; ++ArchetypeIndex)
|
||||||
|
{
|
||||||
|
const int32 Level = (SeedIndex + ArchetypeIndex) % 6;
|
||||||
|
const int32 Step = 1 << Level;
|
||||||
|
const FStrateSlot& Slot = Slots[ArchetypeIndex];
|
||||||
|
const int32 TargetChunkZ = (SeedIndex & 1) ? Slot.BottomChunkZ : Slot.TopChunkZ;
|
||||||
|
FIntVector TargetChunk;
|
||||||
|
switch ((SeedIndex + ArchetypeIndex) % 4)
|
||||||
|
{
|
||||||
|
case 0: TargetChunk = FIntVector(0, 0, TargetChunkZ); break;
|
||||||
|
case 1: TargetChunk = FIntVector(128, 96, TargetChunkZ); ++FarOriginBlocks; break;
|
||||||
|
case 2: TargetChunk = FIntVector(8, -8, TargetChunkZ); ++NearOriginBlocks; break;
|
||||||
|
default: TargetChunk = FIntVector(-128, -96, TargetChunkZ); ++FarOriginBlocks; break;
|
||||||
|
}
|
||||||
|
if ((SeedIndex + ArchetypeIndex) % 4 == 0) { ++NearOriginBlocks; }
|
||||||
|
|
||||||
|
const int32 GridIndex = ArchetypeIndex;
|
||||||
|
GridOrigins[GridIndex] = MakeMesherGridOrigin(TargetChunk, Level);
|
||||||
|
GridSteps[GridIndex] = Step;
|
||||||
|
GridArchetypes[GridIndex] = ArchetypeIndex;
|
||||||
|
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
FVoxelOpContext Context;
|
||||||
|
if (!BuildProductionStackForArchetype(
|
||||||
|
World, Archetypes[ArchetypeIndex], TargetChunk, Stack, Context))
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("seed %d archetype %d production stack could not be built"),
|
||||||
|
Seeds[SeedIndex], ArchetypeIndex));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Context.Step = 1;
|
||||||
|
Stack.PrepareChunk(Context);
|
||||||
|
const FGridComparison DirectComparison = CompareStackChunkBlockWithScalar(
|
||||||
|
Stack, GridOrigins[GridIndex], Step, TargetChunk);
|
||||||
|
DirectStackSamples += DirectComparison.Samples;
|
||||||
|
DirectStackMismatches += DirectComparison.Mismatches;
|
||||||
|
++DirectStackBlocks;
|
||||||
|
if (FirstDirectStackMismatch.IsEmpty() && DirectComparison.Mismatches > 0)
|
||||||
|
{
|
||||||
|
FirstDirectStackMismatch = FString::Printf(
|
||||||
|
TEXT("seed=%d archetype=%d level=%d chunk=%s %s"),
|
||||||
|
Seeds[SeedIndex], ArchetypeIndex, Level, *TargetChunk.ToString(),
|
||||||
|
*DirectComparison.FirstMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
++ArchetypeLevelCounts[ArchetypeIndex][Level];
|
||||||
|
++ProductionArchetypeBlocks;
|
||||||
|
++BoundaryBlocks;
|
||||||
|
|
||||||
|
if (!World.StrateManager->UsesOperatorStackForChunk(TargetChunk)
|
||||||
|
|| World.StrateManager->GetGeneratorTypeForChunk(TargetChunk)
|
||||||
|
!= Archetypes[ArchetypeIndex])
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("seed %d archetype %d target chunk did not route to its production operator stack"),
|
||||||
|
Seeds[SeedIndex], ArchetypeIndex));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passage-mouth lattices centered in a real inter-strate gap exercise the routed
|
||||||
|
// TunnelNetwork/Underwater block implementation through boundaries and bedrock holes.
|
||||||
|
for (int32 PassageGrid = 0; PassageGrid < PassageGridCount; ++PassageGrid)
|
||||||
|
{
|
||||||
|
const int32 GridIndex = ArchetypeCount + PassageGrid;
|
||||||
|
const int32 ArchetypeIndex = PassageGrid == 0 ? 0 : 7;
|
||||||
|
const int32 Level = (SeedIndex + (PassageGrid == 0 ? 0 : 7)) % 6;
|
||||||
|
const int32 Step = 1 << Level;
|
||||||
|
const FVoxelPassage& Passage = Passages[(SeedIndex + PassageGrid) % Passages.Num()];
|
||||||
|
const FVector Focus = PassageGrid == 0 ? Passage.UpperPoint : Passage.LowerPoint;
|
||||||
|
const FIntVector TargetChunk(
|
||||||
|
FMath::FloorToInt(Focus.X / CHUNK_SIZE),
|
||||||
|
FMath::FloorToInt(Focus.Y / CHUNK_SIZE),
|
||||||
|
GapChunkZ);
|
||||||
|
GridOrigins[GridIndex] = MakeMesherGridOrigin(TargetChunk, Level);
|
||||||
|
GridSteps[GridIndex] = Step;
|
||||||
|
GridArchetypes[GridIndex] = ArchetypeIndex;
|
||||||
|
++PassageFocusedBlocks;
|
||||||
|
if (CountGapSamples(World, GridOrigins[GridIndex], Step) > 0)
|
||||||
|
{
|
||||||
|
++GapCoverageBlocks;
|
||||||
|
++SpecialGapCoverageBlocks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-edit grids. In the two canonical network archetypes this enters the actual worker
|
||||||
|
// block session, which partitions each mesher grid by resolved chunk before running the
|
||||||
|
// active operator list. The six legacy density routes still run their production scalar
|
||||||
|
// evaluator, as they do outside the block-safe network branch.
|
||||||
|
for (int32 GridIndex = 0; GridIndex < GridSetCount; ++GridIndex)
|
||||||
|
{
|
||||||
|
BaseResults[GridIndex] = CompareGeneratorBlockWithScalar(
|
||||||
|
*World.Generator.Get(), GridOrigins[GridIndex], GridSteps[GridIndex]);
|
||||||
|
GeneratorSamples += BaseResults[GridIndex].Samples;
|
||||||
|
GeneratorMismatches += BaseResults[GridIndex].Mismatches;
|
||||||
|
++GeneratorBlocks;
|
||||||
|
if (FirstGeneratorMismatch.IsEmpty() && BaseResults[GridIndex].Mismatches > 0)
|
||||||
|
{
|
||||||
|
FirstGeneratorMismatch = FString::Printf(
|
||||||
|
TEXT("seed=%d archetype=%d level=%d edited=0 origin=%s %s"),
|
||||||
|
Seeds[SeedIndex], GridArchetypes[GridIndex],
|
||||||
|
FMath::FloorLog2(GridSteps[GridIndex]), *GridOrigins[GridIndex].ToString(),
|
||||||
|
*BaseResults[GridIndex].FirstMismatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put one real diff-layer box edit in every mesher grid, then compare the same exact
|
||||||
|
// density lattices again. The scalar pass below runs with these player edits present.
|
||||||
|
for (int32 GridIndex = 0; GridIndex < GridSetCount; ++GridIndex)
|
||||||
|
{
|
||||||
|
const int32 Step = GridSteps[GridIndex];
|
||||||
|
FVoxelModification Edit;
|
||||||
|
Edit.Shape = EVoxelBrushShape::Box;
|
||||||
|
Edit.Center = FVector(GridOrigins[GridIndex]) + FVector(17 * Step);
|
||||||
|
Edit.BoxExtent = FVector(2.0f * Step);
|
||||||
|
Edit.Falloff = 1.0f;
|
||||||
|
Edit.Strength = -13.0f;
|
||||||
|
const TArray<FIntVector> ChangedChunks = World.DiffLayer->ApplyModification(Edit);
|
||||||
|
if (ChangedChunks.IsEmpty())
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("seed %d edit for grid %d archetype %d was rejected"),
|
||||||
|
Seeds[SeedIndex], GridIndex, GridArchetypes[GridIndex]));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
++AcceptedEdits;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 GridIndex = 0; GridIndex < GridSetCount; ++GridIndex)
|
||||||
|
{
|
||||||
|
const FGridComparison Edited = CompareGeneratorBlockWithScalar(
|
||||||
|
*World.Generator.Get(), GridOrigins[GridIndex], GridSteps[GridIndex]);
|
||||||
|
GeneratorSamples += Edited.Samples;
|
||||||
|
GeneratorMismatches += Edited.Mismatches;
|
||||||
|
++GeneratorBlocks;
|
||||||
|
if (FirstGeneratorMismatch.IsEmpty() && Edited.Mismatches > 0)
|
||||||
|
{
|
||||||
|
FirstGeneratorMismatch = FString::Printf(
|
||||||
|
TEXT("seed=%d archetype=%d level=%d edited=1 origin=%s %s"),
|
||||||
|
Seeds[SeedIndex], GridArchetypes[GridIndex],
|
||||||
|
FMath::FloorLog2(GridSteps[GridIndex]), *GridOrigins[GridIndex].ToString(),
|
||||||
|
*Edited.FirstMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int32 SharedCount = FMath::Min(
|
||||||
|
Edited.BlockDensities.Num(), BaseResults[GridIndex].BlockDensities.Num());
|
||||||
|
for (int32 SampleIndex = 0; SampleIndex < SharedCount; ++SampleIndex)
|
||||||
|
{
|
||||||
|
if (!SameFloatBits(Edited.BlockDensities[SampleIndex],
|
||||||
|
BaseResults[GridIndex].BlockDensities[SampleIndex]))
|
||||||
|
{
|
||||||
|
++EditedSamplesChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool bEveryArchetypeCoveredEveryLevel = true;
|
||||||
|
for (int32 ArchetypeIndex = 0; ArchetypeIndex < ArchetypeCount; ++ArchetypeIndex)
|
||||||
|
{
|
||||||
|
for (int32 Level = 0; Level < 6; ++Level)
|
||||||
|
{
|
||||||
|
bEveryArchetypeCoveredEveryLevel &= ArchetypeLevelCounts[ArchetypeIndex][Level] > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("BeginDensityBlock grid vs scalar GetDensityAt: seeds=%d archetype_grids=%d all_archetype_levels=%d boundary_grids=%d near_origin=%d far_origin=%d blocks=%d samples=%lld mismatches=%d gap_grids=%d passage_focused=%d accepted_edits=%d edited_samples_changed=%d first=%s"),
|
||||||
|
UE_ARRAY_COUNT(Seeds), ProductionArchetypeBlocks,
|
||||||
|
bEveryArchetypeCoveredEveryLevel ? 1 : 0, BoundaryBlocks, NearOriginBlocks,
|
||||||
|
FarOriginBlocks, GeneratorBlocks, static_cast<long long>(GeneratorSamples),
|
||||||
|
GeneratorMismatches, GapCoverageBlocks, PassageFocusedBlocks, AcceptedEdits,
|
||||||
|
EditedSamplesChanged,
|
||||||
|
FirstGeneratorMismatch.IsEmpty() ? TEXT("none") : *FirstGeneratorMismatch));
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("chunk-local direct EvalBlock vs EvalInternal: blocks=%d mesher_samples=%lld mismatches=%d first=%s"),
|
||||||
|
DirectStackBlocks, static_cast<long long>(DirectStackSamples),
|
||||||
|
DirectStackMismatches,
|
||||||
|
FirstDirectStackMismatch.IsEmpty() ? TEXT("none") : *FirstDirectStackMismatch));
|
||||||
|
|
||||||
|
TestEqual(TEXT("all eight production operator archetypes get six complete mesher grids"),
|
||||||
|
ProductionArchetypeBlocks,
|
||||||
|
static_cast<int32>(UE_ARRAY_COUNT(Seeds)) * ArchetypeCount);
|
||||||
|
TestTrue(TEXT("every production operator archetype covers every meshing level 0..5"),
|
||||||
|
bEveryArchetypeCoveredEveryLevel);
|
||||||
|
TestEqual(TEXT("all production archetype grids touch a strate boundary"),
|
||||||
|
BoundaryBlocks, ProductionArchetypeBlocks);
|
||||||
|
TestTrue(TEXT("test includes near-origin and both-sign far-field grids"),
|
||||||
|
NearOriginBlocks > 0 && FarOriginBlocks > 0);
|
||||||
|
TestEqual(TEXT("each passage-focused grid includes a real gap sample"),
|
||||||
|
SpecialGapCoverageBlocks,
|
||||||
|
static_cast<int32>(UE_ARRAY_COUNT(Seeds)) * PassageGridCount);
|
||||||
|
TestEqual(TEXT("passage-focused grids cover both network archetypes per seed"),
|
||||||
|
PassageFocusedBlocks,
|
||||||
|
static_cast<int32>(UE_ARRAY_COUNT(Seeds)) * PassageGridCount);
|
||||||
|
TestEqual(TEXT("all block-session density grids match scalar output bit-for-bit"),
|
||||||
|
GeneratorMismatches, 0);
|
||||||
|
TestEqual(TEXT("all production stack chunk blocks match scalar bit-for-bit"),
|
||||||
|
DirectStackMismatches, 0);
|
||||||
|
TestEqual(TEXT("one valid player edit was applied to every tested grid"),
|
||||||
|
AcceptedEdits, static_cast<int32>(UE_ARRAY_COUNT(Seeds)) * GridSetCount);
|
||||||
|
TestTrue(TEXT("player edits alter samples consumed by the mesher"),
|
||||||
|
EditedSamplesChanged > 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,102 @@
|
|||||||
|
// Pinned contract for VoxelMath::DetSin/DetCos/DetSinCos.
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
#include "VoxelTypes.h"
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct FDetSinCosGolden
|
||||||
|
{
|
||||||
|
float Input;
|
||||||
|
uint32 SinBits;
|
||||||
|
uint32 CosBits;
|
||||||
|
};
|
||||||
|
|
||||||
|
uint32 FloatBits(float Value)
|
||||||
|
{
|
||||||
|
uint32 Bits = 0;
|
||||||
|
FMemory::Memcpy(&Bits, &Value, sizeof(Bits));
|
||||||
|
return Bits;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeDetSinCosTest,
|
||||||
|
"VoxelForge.Determinism.DetSinCos",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeDetSinCosTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
(void)Parameters;
|
||||||
|
|
||||||
|
const float Pi = 3.1415926535897932384626433832795f;
|
||||||
|
const float TwoPi = 6.283185307179586476925286766559f;
|
||||||
|
// The expected values are deliberately bit patterns rather than tolerances. This corpus is
|
||||||
|
// the pinned implementation contract; update it only with an intentional world re-tune.
|
||||||
|
static const FDetSinCosGolden Corpus[] =
|
||||||
|
{
|
||||||
|
{ 0.0f, 0x00000000u, 0x3F800000u },
|
||||||
|
{-0.0f, 0x80000000u, 0x3F800000u },
|
||||||
|
{ Pi, 0xB3BBBD2Eu, 0xBF800000u },
|
||||||
|
{-Pi, 0x33BBBD2Eu, 0xBF800000u },
|
||||||
|
{ TwoPi, 0x343BBD2Eu, 0x3F800000u },
|
||||||
|
{-TwoPi, 0xB43BBD2Eu, 0x3F800000u },
|
||||||
|
{ 0.001f, 0x3A83126Du, 0x3F7FFFF8u },
|
||||||
|
{-0.001f, 0xBA83126Du, 0x3F7FFFF8u },
|
||||||
|
{ 123456.789f, 0xBF7FA83Du, 0x3D53E806u },
|
||||||
|
{-123456.789f, 0x3F7FA83Du, 0x3D53E806u },
|
||||||
|
{ 1000000.25f, 0xBDDBDDEBu, 0x3F7E853Du },
|
||||||
|
{-1000000.25f, 0x3DDBDDEBu, 0x3F7E853Du },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const FDetSinCosGolden& Sample : Corpus)
|
||||||
|
{
|
||||||
|
float ActualSin = 0.0f;
|
||||||
|
float ActualCos = 0.0f;
|
||||||
|
VoxelMath::DetSinCos(ActualSin, ActualCos, Sample.Input);
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("DetSinCos input %.9g -> sin=0x%08X cos=0x%08X"),
|
||||||
|
Sample.Input, FloatBits(ActualSin), FloatBits(ActualCos)));
|
||||||
|
TestEqual(
|
||||||
|
FString::Printf(TEXT("sin bits for %.9g"), Sample.Input),
|
||||||
|
FloatBits(ActualSin), Sample.SinBits);
|
||||||
|
TestEqual(
|
||||||
|
FString::Printf(TEXT("cos bits for %.9g"), Sample.Input),
|
||||||
|
FloatBits(ActualCos), Sample.CosBits);
|
||||||
|
TestEqual(
|
||||||
|
FString::Printf(TEXT("DetSin agrees with paired sin for %.9g"), Sample.Input),
|
||||||
|
FloatBits(VoxelMath::DetSin(Sample.Input)), FloatBits(ActualSin));
|
||||||
|
TestEqual(
|
||||||
|
FString::Printf(TEXT("DetCos agrees with paired cos for %.9g"), Sample.Input),
|
||||||
|
FloatBits(VoxelMath::DetCos(Sample.Input)), FloatBits(ActualCos));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The canonical export measured |argument| <= 6.19255877. Sweep a small margin around that
|
||||||
|
// envelope; the large corpus above separately exercises the double range reduction.
|
||||||
|
constexpr float MeasuredWorldArgumentMax = 6.25f;
|
||||||
|
constexpr int32 SweepSamples = 200001;
|
||||||
|
float MaxSinError = 0.0f;
|
||||||
|
float MaxCosError = 0.0f;
|
||||||
|
for (int32 Index = 0; Index < SweepSamples; ++Index)
|
||||||
|
{
|
||||||
|
const float Alpha = -MeasuredWorldArgumentMax
|
||||||
|
+ (2.0f * MeasuredWorldArgumentMax)
|
||||||
|
* static_cast<float>(Index) / static_cast<float>(SweepSamples - 1);
|
||||||
|
float ActualSin = 0.0f;
|
||||||
|
float ActualCos = 0.0f;
|
||||||
|
VoxelMath::DetSinCos(ActualSin, ActualCos, Alpha);
|
||||||
|
MaxSinError = FMath::Max(MaxSinError, FMath::Abs(ActualSin - ::sinf(Alpha)));
|
||||||
|
MaxCosError = FMath::Max(MaxCosError, FMath::Abs(ActualCos - ::cosf(Alpha)));
|
||||||
|
}
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("DetSinCos max abs error vs sinf/cosf over [-%.1f,+%.1f], %d samples: "
|
||||||
|
"sin %.9g, cos %.9g"),
|
||||||
|
MeasuredWorldArgumentMax, MeasuredWorldArgumentMax, SweepSamples,
|
||||||
|
MaxSinError, MaxCosError));
|
||||||
|
TestTrue(TEXT("DetSin max error over measured world range <= 2e-6"), MaxSinError <= 2.0e-6f);
|
||||||
|
TestTrue(TEXT("DetCos max error over measured world range <= 2e-6"), MaxCosError <= 2.0e-6f);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -12,30 +12,19 @@
|
|||||||
// idée** — la même méthode que la Phase 1 a appliquée à la densité : décomposer, puis MESURER
|
// idée** — la même méthode que la Phase 1 a appliquée à la densité : décomposer, puis MESURER
|
||||||
// contre l'original, avant de construire par-dessus.
|
// contre l'original, avant de construire par-dessus.
|
||||||
//
|
//
|
||||||
// ⚠️ CE QUE CE TEST COUVRE, ET SURTOUT CE QU'IL NE COUVRE PAS
|
// ⚠️ CE QUE CE TEST COUVRE
|
||||||
// ✅ la pile de HAUTEUR du sol, contre `ComputeSurfaceTerrainZ` (2 passes : défauts, puis tous
|
// ✅ la pile de HAUTEUR du sol, contre `ComputeSurfaceTerrainZ` (défauts puis tous les ops F20) ;
|
||||||
// les ops F20 allumés — c'est la seconde qui porte le test) ;
|
// ✅ les canaux de densité courants : ordre, fenêtre et overhang ;
|
||||||
// ✅ la pile de HAUTEUR de la voûte + le pont vers l'espace densité (`FSurfaceColumnSource`),
|
// ✅ le mélange synthétique à poids zéro.
|
||||||
// contre `GetSurfaceDensity` ;
|
|
||||||
// ❌ **l'OVERHANG** — `GetSurfaceDensity` passe `OverhangAmp = 0`, donc il n'en calcule aucun.
|
|
||||||
// Sa seule référence est le chemin CACHÉ (`ComputeSurfaceColumn`), qui résout le gate et la
|
|
||||||
// direction amont par colonne ;
|
|
||||||
// ❌ **le MÉLANGE DE BIOMES** — ici `ParamsD == ParamsN`, poids 0. C'est le combiner `Mask`, et
|
|
||||||
// `§5` en fait le prototype de la Phase 3 : ça mérite son étape.
|
|
||||||
//
|
//
|
||||||
// Les deux manques sont l'étape 2b. **Ne pas brancher SurfaceWorld dans un monde à biomes ou à
|
// The old GetSurfaceDensity helper remains a differential diagnostic only: it does not carry the
|
||||||
// overhang avant**, parce que rien ici ne dirait que c'est faux.
|
// current common structural post. The assertions target the height stack and the density stack
|
||||||
|
// used by the owner path, so room ownership and the authored-floor removal are not mistaken for
|
||||||
|
// a failed height port.
|
||||||
//
|
//
|
||||||
// COVERED: the ground height stack vs ComputeSurfaceTerrainZ, and the ceiling stack + the bridge
|
// LA BARRE : **bit à bit** remains the rule for height-space operations. Density comparisons to
|
||||||
// into density space vs GetSurfaceDensity. NOT COVERED: the overhang (GetSurfaceDensity passes
|
// the retired direct helper are reported, not asserted, because room ownership and the common
|
||||||
// OverhangAmp = 0, so only the cached path computes it) and biome blending (weight 0 here). Both
|
// MC post intentionally make those paths different.
|
||||||
// are step 2b — do not wire SurfaceWorld into a world with biomes or overhangs before then.
|
|
||||||
//
|
|
||||||
// LA BARRE : **bit à bit.** Depuis `FPSemantics = Precise` (AUDIT §C9/§C10), Maze et Slab sont
|
|
||||||
// bit-identiques à leur original ; il n'y a plus de « plancher ULP » à tolérer. Un écart ici est
|
|
||||||
// donc une vraie trouvaille — un offset de bruit faux, un ordre d'op inversé, un gate oublié.
|
|
||||||
// Ces fonctions sont des ALTITUDES en voxels, pas des densités : un écart d'un demi-voxel est un
|
|
||||||
// terrain visiblement différent, pas du bruit d'arrondi.
|
|
||||||
|
|
||||||
#if WITH_DEV_AUTOMATION_TESTS
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
@@ -315,10 +304,12 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
|
|||||||
VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed,
|
VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed,
|
||||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
|
||||||
// 1 source + 1 overhang + 3 structurels. L'op overhang est présent mais inerte ici
|
// 1 source + 1 overhang + 4 structurels. L'op overhang est présent mais inerte ici
|
||||||
// (amp 0 ⇒ sortie immédiate) — la décomposition ne change pas selon les params.
|
// (amp 0 ⇒ sortie immédiate) — la décomposition ne change pas selon les params.
|
||||||
TestEqual(TEXT("the surface density stack is source + overhang + 3 structural"),
|
TestEqual(TEXT("the surface density stack is source + overhang + 4 structural"),
|
||||||
Stack.Num(), 5);
|
Stack.Num(), 6);
|
||||||
|
TestTrue(TEXT("the surface density stack has valid channel order"),
|
||||||
|
Stack.ValidateChannelOrder());
|
||||||
|
|
||||||
FVoxelOpContext Ctx;
|
FVoxelOpContext Ctx;
|
||||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||||
@@ -327,7 +318,7 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
|
|||||||
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
|
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
|
||||||
Stack.PrepareChunk(Ctx);
|
Stack.PrepareChunk(Ctx);
|
||||||
|
|
||||||
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1;
|
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1, NumNonFinite = 0;
|
||||||
float WorstDelta = 0.0f;
|
float WorstDelta = 0.0f;
|
||||||
|
|
||||||
FRandomStream Rng(5150);
|
FRandomStream Rng(5150);
|
||||||
@@ -340,6 +331,10 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
|
|||||||
// ParamsD == ParamsN, poids 0 ⇒ une seule évaluation, pas de biomes.
|
// ParamsD == ParamsN, poids 0 ⇒ une seule évaluation, pas de biomes.
|
||||||
const float Old = Gen->GetSurfaceDensity(X, Y, Z, P, P, 0.0f);
|
const float Old = Gen->GetSurfaceDensity(X, Y, Z, P, P, 0.0f);
|
||||||
const float New = Stack.EvalMC(X, Y, Z);
|
const float New = Stack.EvalMC(X, Y, Z);
|
||||||
|
if (!FMath::IsFinite(New))
|
||||||
|
{
|
||||||
|
++NumNonFinite;
|
||||||
|
}
|
||||||
|
|
||||||
if (!BitEqual(Old, New))
|
if (!BitEqual(Old, New))
|
||||||
{
|
{
|
||||||
@@ -350,27 +345,13 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
|
|||||||
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
|
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (NumDiff == 0)
|
|
||||||
{
|
|
||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("SurfaceWorld density stack: bit-identical to GetSurfaceDensity across %d ")
|
TEXT("SurfaceWorld density legacy diagnostic: %d of %d samples differ from "
|
||||||
TEXT("samples. The height stacks feed the density space correctly."),
|
"GetSurfaceDensity (largest |delta| %.9g), %d side disagreements; "
|
||||||
NumHeightSamples));
|
"current stack non-finite samples=%d."),
|
||||||
}
|
NumDiff, NumHeightSamples, WorstDelta, NumSideDisagree, NumNonFinite));
|
||||||
else
|
TestEqual(TEXT("surface: current density stack has no non-finite samples"),
|
||||||
{
|
NumNonFinite, 0);
|
||||||
AddError(FString::Printf(
|
|
||||||
TEXT("SurfaceWorld density stack: %d of %d samples differ (largest |delta| %.9g); ")
|
|
||||||
TEXT("%d cross the isosurface. Since /fp:precise the bar is bit-identity, so this ")
|
|
||||||
TEXT("is a real port error. Check, in order: the combine (Density = max(TerrainZ - Z, ")
|
|
||||||
TEXT("Z - CeilSurf)), the sky-cap transcription (warp offsets 0.71/2.3/3.3 and ")
|
|
||||||
TEXT("6.1/0.19/4.7, the abs() on roughness, the ridge *0.5+0.5), and the order of ")
|
|
||||||
TEXT("the structural post ops."),
|
|
||||||
NumDiff, NumHeightSamples, WorstDelta, NumSideDisagree));
|
|
||||||
}
|
|
||||||
|
|
||||||
TestEqual(TEXT("surface: no sample lands on the opposite side of the isosurface"),
|
|
||||||
NumSideDisagree, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
@@ -410,7 +391,7 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
|
|||||||
TArray<FSurfaceGenerationParams> NoBiomeParams;
|
TArray<FSurfaceGenerationParams> NoBiomeParams;
|
||||||
FChunkBiomeCache BiomeCache;
|
FChunkBiomeCache BiomeCache;
|
||||||
|
|
||||||
int32 NumDiff = 0, NumSideDisagree = 0, NumInWindow = 0;
|
int32 NumDiff = 0, NumSideDisagree = 0, NumInWindow = 0, NumNonFinite = 0;
|
||||||
float WorstDelta = 0.0f;
|
float WorstDelta = 0.0f;
|
||||||
|
|
||||||
FRandomStream Rng(1337);
|
FRandomStream Rng(1337);
|
||||||
@@ -440,6 +421,10 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
|
|||||||
const float Old = Gen->SurfaceDensityFromColumn(X, Y, Z, TerrainZ, CeilSurf,
|
const float Old = Gen->SurfaceDensityFromColumn(X, Y, Z, TerrainZ, CeilSurf,
|
||||||
Amp, DirX, DirY, P);
|
Amp, DirX, DirY, P);
|
||||||
const float New = Stack.EvalMC(X, Y, Z);
|
const float New = Stack.EvalMC(X, Y, Z);
|
||||||
|
if (!FMath::IsFinite(New))
|
||||||
|
{
|
||||||
|
++NumNonFinite;
|
||||||
|
}
|
||||||
|
|
||||||
if (!BitEqual(Old, New))
|
if (!BitEqual(Old, New))
|
||||||
{
|
{
|
||||||
@@ -449,28 +434,13 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
|
|||||||
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
|
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (NumDiff == 0)
|
|
||||||
{
|
|
||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("Overhang: bit-identical to SurfaceDensityFromColumn across %d samples, %d of ")
|
TEXT("Overhang legacy diagnostic: %d of %d samples differ from "
|
||||||
TEXT("them deliberately inside the overhang window. The per-column memo hands the ")
|
"SurfaceDensityFromColumn (largest |delta| %.9g), %d side disagreements, "
|
||||||
TEXT("source's column to the overhang op correctly."),
|
"%d deliberately inside the overhang window; current stack non-finite samples=%d."),
|
||||||
NumHeightSamples, NumInWindow));
|
NumDiff, NumHeightSamples, WorstDelta, NumSideDisagree, NumInWindow, NumNonFinite));
|
||||||
}
|
TestEqual(TEXT("overhang: current density stack has no non-finite samples"),
|
||||||
else
|
NumNonFinite, 0);
|
||||||
{
|
|
||||||
AddError(FString::Printf(
|
|
||||||
TEXT("Overhang: %d of %d samples differ (largest |delta| %.9g); %d cross the ")
|
|
||||||
TEXT("isosurface; %d samples were inside the window. Check, in order: the column ")
|
|
||||||
TEXT("memo key (does the overhang op see the SAME column as the source?), the ")
|
|
||||||
TEXT("window gate (Z > TerrainZ && Z <= TerrainZ + OverhangHeight), Frac and the ")
|
|
||||||
TEXT("ShiftV > 0.5 threshold, the shelf noise offsets (17.3/23.9/5.1 with the ")
|
|
||||||
TEXT("OverhangZScale Z term), and that the shift resamples the STRUCTURAL height."),
|
|
||||||
NumDiff, NumHeightSamples, WorstDelta, NumSideDisagree, NumInWindow));
|
|
||||||
}
|
|
||||||
|
|
||||||
TestEqual(TEXT("overhang: no sample lands on the opposite side of the isosurface"),
|
|
||||||
NumSideDisagree, 0);
|
|
||||||
|
|
||||||
if (NumInWindow == 0)
|
if (NumInWindow == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Bit-exact equivalence coverage for the generation-path finite predicate.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
|
||||||
|
#include "VoxelTypes.h"
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeIsFiniteTest,
|
||||||
|
"VoxelForge.Math.IsFiniteExact",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
template <typename TValue, typename TBits>
|
||||||
|
TValue ValueFromBits(TBits Bits)
|
||||||
|
{
|
||||||
|
TValue Value;
|
||||||
|
FMemory::Memcpy(&Value, &Bits, sizeof(Value));
|
||||||
|
return Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE uint64 NextHashedBits(uint64& State)
|
||||||
|
{
|
||||||
|
State += 0x9E3779B97F4A7C15ull;
|
||||||
|
uint64 Z = State;
|
||||||
|
Z = (Z ^ (Z >> 30)) * 0xBF58476D1CE4E5B9ull;
|
||||||
|
Z = (Z ^ (Z >> 27)) * 0x94D049BB133111EBull;
|
||||||
|
return Z ^ (Z >> 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FFloatPattern
|
||||||
|
{
|
||||||
|
const TCHAR* Name;
|
||||||
|
uint32 Bits;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FDoublePattern
|
||||||
|
{
|
||||||
|
const TCHAR* Name;
|
||||||
|
uint64 Bits;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelForgeIsFiniteTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
int32 FloatMismatches = 0;
|
||||||
|
int32 DoubleMismatches = 0;
|
||||||
|
|
||||||
|
auto CheckFloat = [&](const TCHAR* Name, uint32 Bits)
|
||||||
|
{
|
||||||
|
const float Value = ValueFromBits<float>(Bits);
|
||||||
|
const bool Fast = VoxelMath::IsFiniteFast(Value);
|
||||||
|
const bool Reference = FMath::IsFinite(Value);
|
||||||
|
if (Fast != Reference)
|
||||||
|
{
|
||||||
|
++FloatMismatches;
|
||||||
|
if (FloatMismatches <= 8)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("%s: float bits 0x%08X produced fast=%s, FMath=%s"),
|
||||||
|
Name, Bits,
|
||||||
|
Fast ? TEXT("true") : TEXT("false"),
|
||||||
|
Reference ? TEXT("true") : TEXT("false")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
auto CheckDouble = [&](const TCHAR* Name, uint64 Bits)
|
||||||
|
{
|
||||||
|
const double Value = ValueFromBits<double>(Bits);
|
||||||
|
const bool Fast = VoxelMath::IsFiniteFast(Value);
|
||||||
|
const bool Reference = FMath::IsFinite(Value);
|
||||||
|
if (Fast != Reference)
|
||||||
|
{
|
||||||
|
++DoubleMismatches;
|
||||||
|
if (DoubleMismatches <= 8)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("%s: double bits 0x%08X%08X produced fast=%s, FMath=%s"),
|
||||||
|
Name,
|
||||||
|
static_cast<uint32>(Bits >> 32), static_cast<uint32>(Bits),
|
||||||
|
Fast ? TEXT("true") : TEXT("false"),
|
||||||
|
Reference ? TEXT("true") : TEXT("false")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Explicit classes make a NaN regression fail even if the random stream happens not to hit a
|
||||||
|
// NaN. In particular, a helper that only checks the sign/exponent incorrectly must fail here.
|
||||||
|
const FFloatPattern FloatPatterns[] =
|
||||||
|
{
|
||||||
|
{ TEXT("+0"), 0x00000000u },
|
||||||
|
{ TEXT("-0"), 0x80000000u },
|
||||||
|
{ TEXT("+min subnormal"), 0x00000001u },
|
||||||
|
{ TEXT("-min subnormal"), 0x80000001u },
|
||||||
|
{ TEXT("+max subnormal"), 0x007FFFFFu },
|
||||||
|
{ TEXT("-max subnormal"), 0x807FFFFFu },
|
||||||
|
{ TEXT("+min normal"), 0x00800000u },
|
||||||
|
{ TEXT("-min normal"), 0x80800000u },
|
||||||
|
{ TEXT("+max normal"), 0x7F7FFFFFu },
|
||||||
|
{ TEXT("-max normal"), 0xFF7FFFFFu },
|
||||||
|
{ TEXT("+infinity"), 0x7F800000u },
|
||||||
|
{ TEXT("-infinity"), 0xFF800000u },
|
||||||
|
{ TEXT("+quiet NaN payload 1"), 0x7FC00001u },
|
||||||
|
{ TEXT("-quiet NaN payload 1"), 0xFFC00001u },
|
||||||
|
{ TEXT("+quiet NaN payload mixed"), 0x7FC12345u },
|
||||||
|
{ TEXT("-quiet NaN payload mixed"), 0xFFC12345u },
|
||||||
|
{ TEXT("+signalling NaN payload 1"), 0x7F800001u },
|
||||||
|
{ TEXT("-signalling NaN payload 1"), 0xFF800001u },
|
||||||
|
{ TEXT("+signalling NaN payload mixed"), 0x7FA00001u },
|
||||||
|
{ TEXT("-signalling NaN payload mixed"), 0xFFA00001u },
|
||||||
|
{ TEXT("+signalling NaN max payload"), 0x7FBFFFFFu },
|
||||||
|
{ TEXT("-signalling NaN max payload"), 0xFFBFFFFFu },
|
||||||
|
};
|
||||||
|
for (const FFloatPattern& Pattern : FloatPatterns)
|
||||||
|
{
|
||||||
|
CheckFloat(Pattern.Name, Pattern.Bits);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FDoublePattern DoublePatterns[] =
|
||||||
|
{
|
||||||
|
{ TEXT("+0"), 0x0000000000000000ull },
|
||||||
|
{ TEXT("-0"), 0x8000000000000000ull },
|
||||||
|
{ TEXT("+min subnormal"), 0x0000000000000001ull },
|
||||||
|
{ TEXT("-min subnormal"), 0x8000000000000001ull },
|
||||||
|
{ TEXT("+max subnormal"), 0x000FFFFFFFFFFFFFull },
|
||||||
|
{ TEXT("-max subnormal"), 0x800FFFFFFFFFFFFFull },
|
||||||
|
{ TEXT("+min normal"), 0x0010000000000000ull },
|
||||||
|
{ TEXT("-min normal"), 0x8010000000000000ull },
|
||||||
|
{ TEXT("+max normal"), 0x7FEFFFFFFFFFFFFFull },
|
||||||
|
{ TEXT("-max normal"), 0xFFEFFFFFFFFFFFFFull },
|
||||||
|
{ TEXT("+infinity"), 0x7FF0000000000000ull },
|
||||||
|
{ TEXT("-infinity"), 0xFFF0000000000000ull },
|
||||||
|
{ TEXT("+quiet NaN payload 1"), 0x7FF8000000000001ull },
|
||||||
|
{ TEXT("-quiet NaN payload 1"), 0xFFF8000000000001ull },
|
||||||
|
{ TEXT("+quiet NaN payload mixed"), 0x7FF8123456789ABCull },
|
||||||
|
{ TEXT("-quiet NaN payload mixed"), 0xFFF8123456789ABCull },
|
||||||
|
{ TEXT("+signalling NaN payload 1"), 0x7FF0000000000001ull },
|
||||||
|
{ TEXT("-signalling NaN payload 1"), 0xFFF0000000000001ull },
|
||||||
|
{ TEXT("+signalling NaN payload mixed"), 0x7FF4000000000001ull },
|
||||||
|
{ TEXT("-signalling NaN payload mixed"), 0xFFF4000000000001ull },
|
||||||
|
{ TEXT("+signalling NaN max payload"), 0x7FF7FFFFFFFFFFFFull },
|
||||||
|
{ TEXT("-signalling NaN max payload"), 0xFFF7FFFFFFFFFFFFull },
|
||||||
|
};
|
||||||
|
for (const FDoublePattern& Pattern : DoublePatterns)
|
||||||
|
{
|
||||||
|
CheckDouble(Pattern.Name, Pattern.Bits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic hash-generated raw bit patterns exercise ordinary values and special values
|
||||||
|
// without converting through floating point. The loop counts are intentionally large enough
|
||||||
|
// to make this a broad bit-pattern check rather than a handful of representative numbers.
|
||||||
|
constexpr uint32 NumRandomFloatPatterns = 2000000u;
|
||||||
|
constexpr uint32 NumRandomDoublePatterns = 2000000u;
|
||||||
|
uint64 FloatState = 0x123456789ABCDEF0ull;
|
||||||
|
for (uint32 Index = 0; Index < NumRandomFloatPatterns; ++Index)
|
||||||
|
{
|
||||||
|
CheckFloat(TEXT("hashed random"), static_cast<uint32>(NextHashedBits(FloatState)));
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64 DoubleState = 0x0FEDCBA987654321ull;
|
||||||
|
for (uint32 Index = 0; Index < NumRandomDoublePatterns; ++Index)
|
||||||
|
{
|
||||||
|
CheckDouble(TEXT("hashed random"), NextHashedBits(DoubleState));
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Compared %d explicit + %u hashed float patterns and %d explicit + %u hashed double "
|
||||||
|
"patterns; float mismatches=%d, double mismatches=%d."),
|
||||||
|
UE_ARRAY_COUNT(FloatPatterns), NumRandomFloatPatterns,
|
||||||
|
UE_ARRAY_COUNT(DoublePatterns), NumRandomDoublePatterns,
|
||||||
|
FloatMismatches, DoubleMismatches));
|
||||||
|
|
||||||
|
TestEqual(TEXT("float finite predicate has no mismatches"), FloatMismatches, 0);
|
||||||
|
TestEqual(TEXT("double finite predicate has no mismatches"), DoubleMismatches, 0);
|
||||||
|
return FloatMismatches == 0 && DoubleMismatches == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,478 @@
|
|||||||
|
// VoxelForgeLayoutOrderIndependenceTest.cpp
|
||||||
|
// Test de déterminisme du layout et des passages quand l'ordre du pool change.
|
||||||
|
// Determinism test for the layout and passages when the pool order changes.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
|
||||||
|
#include "VoxelForgeTestFixture.h"
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeLayoutOrderIndependenceTest,
|
||||||
|
"VoxelForge.Determinism.LayoutOrderIndependence",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr int32 LayoutSeed = 20260829;
|
||||||
|
constexpr int32 NumPoolDefinitions = 5;
|
||||||
|
|
||||||
|
struct FLayoutSnapshot
|
||||||
|
{
|
||||||
|
TArray<FStrateSlot> Layout;
|
||||||
|
TArray<FVoxelPassage> Passages;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small headless world whose pool has no FixedStrates entries. That is important: the test
|
||||||
|
* must exercise the shuffled pool path rather than accidentally testing only pinned slots.
|
||||||
|
*
|
||||||
|
* Petit monde sans entrée FixedStrates. C'est essentiel : le test doit exercer le chemin du
|
||||||
|
* pool mélangé, et ne pas tester par accident uniquement des slots épinglés.
|
||||||
|
*/
|
||||||
|
struct FLayoutTestWorld
|
||||||
|
{
|
||||||
|
TStrongObjectPtr<UVoxelSettings> Settings;
|
||||||
|
TStrongObjectPtr<UVoxelStrateManager> StrateManager;
|
||||||
|
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> Definitions;
|
||||||
|
|
||||||
|
void Build()
|
||||||
|
{
|
||||||
|
Settings = TStrongObjectPtr<UVoxelSettings>(
|
||||||
|
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
Settings->Seed = LayoutSeed;
|
||||||
|
Settings->TotalStrates = NumPoolDefinitions;
|
||||||
|
Settings->InterStrateGapChunks = 1;
|
||||||
|
Settings->bOpenSurfaceEntry = true;
|
||||||
|
|
||||||
|
static const ECaveGeneratorType GeneratorTypes[] = {
|
||||||
|
ECaveGeneratorType::TunnelNetwork,
|
||||||
|
ECaveGeneratorType::FlatPlain,
|
||||||
|
ECaveGeneratorType::Maze,
|
||||||
|
ECaveGeneratorType::SurfaceWorld,
|
||||||
|
ECaveGeneratorType::VerticalShafts,
|
||||||
|
};
|
||||||
|
static const EVoxelPassageStyle PassageStyles[] = {
|
||||||
|
EVoxelPassageStyle::Straight,
|
||||||
|
EVoxelPassageStyle::Worm,
|
||||||
|
EVoxelPassageStyle::Spiral,
|
||||||
|
EVoxelPassageStyle::Cascading,
|
||||||
|
EVoxelPassageStyle::Worm,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (int32 Index = 0; Index < NumPoolDefinitions; ++Index)
|
||||||
|
{
|
||||||
|
UVoxelStrateDefinition* Def = NewObject<UVoxelStrateDefinition>(
|
||||||
|
GetTransientPackage(), NAME_None, RF_Transient);
|
||||||
|
Def->StrateName = FText::FromString(
|
||||||
|
FString::Printf(TEXT("Layout order test strate %d"), Index));
|
||||||
|
Def->GeneratorType = GeneratorTypes[Index];
|
||||||
|
Def->StrateHeightInChunks = 3 + Index;
|
||||||
|
Def->TransitionType = EVoxelStrateTransition::Hard;
|
||||||
|
|
||||||
|
// Non-trivial, distinct passage configs exercise the connection index and the
|
||||||
|
// shape seeds while keeping every generated passage observable in the snapshot.
|
||||||
|
// Des configs distinctes rendent visibles l'index de connexion et les seeds de
|
||||||
|
// forme, tout en gardant chaque passage généré observable dans le snapshot.
|
||||||
|
FStratePassageConfig& Passage = Def->PassageConfig;
|
||||||
|
Passage.Connections = Index + 1;
|
||||||
|
Passage.Style = PassageStyles[Index];
|
||||||
|
Passage.MouthRadius = 5.0f + (float)Index;
|
||||||
|
Passage.MidRadius = 3.0f + (float)Index * 0.5f;
|
||||||
|
Passage.ReachMin = 12.0f + (float)Index * 3.0f;
|
||||||
|
Passage.ReachMax = 48.0f + (float)Index * 4.0f;
|
||||||
|
Passage.DistanceMin = 20.0f + (float)Index * 5.0f;
|
||||||
|
Passage.DistanceMax = 80.0f + (float)Index * 7.0f;
|
||||||
|
Passage.Wander = 20.0f + (float)Index;
|
||||||
|
Passage.Segments = 4 + Index;
|
||||||
|
Passage.VerticalWobble = 4.0f;
|
||||||
|
Passage.SpiralRadius = 10.0f + (float)Index;
|
||||||
|
Passage.SpiralTurns = 1.0f + (float)Index * 0.25f;
|
||||||
|
Passage.CascadeSteps = 2 + Index;
|
||||||
|
Passage.CascadeLedge = 10.0f + (float)Index;
|
||||||
|
|
||||||
|
Definitions.Add(TStrongObjectPtr<UVoxelStrateDefinition>(Def));
|
||||||
|
}
|
||||||
|
|
||||||
|
StrateManager = TStrongObjectPtr<UVoxelStrateManager>(
|
||||||
|
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetPoolOrder(const TArray<int32>& Order)
|
||||||
|
{
|
||||||
|
Settings->StratePool.Reset(Order.Num());
|
||||||
|
for (const int32 DefinitionIndex : Order)
|
||||||
|
{
|
||||||
|
Settings->StratePool.Add(
|
||||||
|
TSoftObjectPtr<UVoxelStrateDefinition>(Definitions[DefinitionIndex].Get()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Rebuild(const TArray<int32>& Order)
|
||||||
|
{
|
||||||
|
if (!Settings.IsValid() || !StrateManager.IsValid())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
SetPoolOrder(Order);
|
||||||
|
StrateManager->Initialize(Settings.Get(), LayoutSeed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
FLayoutSnapshot CaptureSnapshot(const UVoxelStrateManager& Manager)
|
||||||
|
{
|
||||||
|
FLayoutSnapshot Snapshot;
|
||||||
|
Snapshot.Layout = Manager.GetLayout();
|
||||||
|
Snapshot.Passages = Manager.GetPassages();
|
||||||
|
return Snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SameVectorBits(const FVector& A, const FVector& B)
|
||||||
|
{
|
||||||
|
return VoxelForgeTest::BitEqual(A.X, B.X)
|
||||||
|
&& VoxelForgeTest::BitEqual(A.Y, B.Y)
|
||||||
|
&& VoxelForgeTest::BitEqual(A.Z, B.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SameLanding(const FVoxelPassageLanding& A, const FVoxelPassageLanding& B)
|
||||||
|
{
|
||||||
|
return SameVectorBits(A.StandingPoint, B.StandingPoint)
|
||||||
|
&& SameVectorBits(A.DoorPoint, B.DoorPoint)
|
||||||
|
&& SameVectorBits(A.DoorDirection, B.DoorDirection)
|
||||||
|
&& VoxelForgeTest::BitEqual(A.FloorZ, B.FloorZ)
|
||||||
|
&& VoxelForgeTest::BitEqual(A.CeilingZ, B.CeilingZ)
|
||||||
|
&& VoxelForgeTest::BitEqual(A.HalfWidth, B.HalfWidth)
|
||||||
|
&& VoxelForgeTest::BitEqual(A.FloorThickness, B.FloorThickness)
|
||||||
|
&& A.bSourcePlayerFit == B.bSourcePlayerFit;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SamePassage(const FVoxelPassage& A, const FVoxelPassage& B, FString& OutMismatch,
|
||||||
|
int32 PassageIndex)
|
||||||
|
{
|
||||||
|
if (A.UpperStrateIndex != B.UpperStrateIndex || A.LowerStrateIndex != B.LowerStrateIndex)
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("passage %d strate indices differ (%d/%d vs %d/%d)"), PassageIndex,
|
||||||
|
A.UpperStrateIndex, A.LowerStrateIndex, B.UpperStrateIndex, B.LowerStrateIndex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SameVectorBits(A.UpperPoint, B.UpperPoint)
|
||||||
|
|| !SameVectorBits(A.LowerPoint, B.LowerPoint))
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("passage %d endpoints differ"), PassageIndex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SameLanding(A.UpperLanding, B.UpperLanding)
|
||||||
|
|| !SameLanding(A.LowerLanding, B.LowerLanding))
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("passage %d landing descriptors differ"), PassageIndex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VoxelForgeTest::BitEqual(A.Radius, B.Radius))
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("passage %d radius differs (%.9g vs %.9g)"), PassageIndex, A.Radius, B.Radius);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (A.PassageType != B.PassageType)
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("passage %d type differs (%d vs %d)"), PassageIndex,
|
||||||
|
(int32)A.PassageType, (int32)B.PassageType);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (A.ControlPoints.Num() != B.ControlPoints.Num())
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("passage %d control-point count differs (%d vs %d)"), PassageIndex,
|
||||||
|
A.ControlPoints.Num(), B.ControlPoints.Num());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int32 PointIndex = 0; PointIndex < A.ControlPoints.Num(); ++PointIndex)
|
||||||
|
{
|
||||||
|
if (!SameVectorBits(A.ControlPoints[PointIndex], B.ControlPoints[PointIndex]))
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("passage %d control point %d differs"), PassageIndex, PointIndex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (A.ControlRadii.Num() != B.ControlRadii.Num())
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("passage %d control-radius count differs (%d vs %d)"), PassageIndex,
|
||||||
|
A.ControlRadii.Num(), B.ControlRadii.Num());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int32 RadiusIndex = 0; RadiusIndex < A.ControlRadii.Num(); ++RadiusIndex)
|
||||||
|
{
|
||||||
|
if (!VoxelForgeTest::BitEqual(A.ControlRadii[RadiusIndex], B.ControlRadii[RadiusIndex]))
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("passage %d control radius %d differs"), PassageIndex, RadiusIndex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SameVectorBits(A.BoundCenter, B.BoundCenter)
|
||||||
|
|| !VoxelForgeTest::BitEqual(A.BoundRadius, B.BoundRadius)
|
||||||
|
|| !VoxelForgeTest::BitEqual(A.BoundRadiusSq, B.BoundRadiusSq))
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("passage %d bounding sphere differs"), PassageIndex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SameLayout(const TArray<FStrateSlot>& Expected, const TArray<FStrateSlot>& Actual,
|
||||||
|
FString& OutMismatch)
|
||||||
|
{
|
||||||
|
if (Expected.Num() != Actual.Num())
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("layout slot count differs (%d vs %d)"), Expected.Num(), Actual.Num());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 SlotIndex = 0; SlotIndex < Expected.Num(); ++SlotIndex)
|
||||||
|
{
|
||||||
|
const FStrateSlot& A = Expected[SlotIndex];
|
||||||
|
const FStrateSlot& B = Actual[SlotIndex];
|
||||||
|
if (A.Definition != B.Definition || A.StrateIndex != B.StrateIndex)
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("slot %d definition or strate index differs"), SlotIndex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (A.TopChunkZ != B.TopChunkZ || A.BottomChunkZ != B.BottomChunkZ
|
||||||
|
|| A.HeightInChunks != B.HeightInChunks)
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("slot %d Z/height differs (top/bottom/height %d/%d/%d vs %d/%d/%d)"),
|
||||||
|
SlotIndex, A.TopChunkZ, A.BottomChunkZ, A.HeightInChunks,
|
||||||
|
B.TopChunkZ, B.BottomChunkZ, B.HeightInChunks);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SameSnapshot(const FLayoutSnapshot& Expected, const FLayoutSnapshot& Actual,
|
||||||
|
FString& OutMismatch)
|
||||||
|
{
|
||||||
|
if (!SameLayout(Expected.Layout, Actual.Layout, OutMismatch))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Expected.Passages.Num() != Actual.Passages.Num())
|
||||||
|
{
|
||||||
|
OutMismatch = FString::Printf(
|
||||||
|
TEXT("passage count differs (%d vs %d)"),
|
||||||
|
Expected.Passages.Num(), Actual.Passages.Num());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 PassageIndex = 0; PassageIndex < Expected.Passages.Num(); ++PassageIndex)
|
||||||
|
{
|
||||||
|
if (!SamePassage(Expected.Passages[PassageIndex], Actual.Passages[PassageIndex],
|
||||||
|
OutMismatch, PassageIndex))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelForgeLayoutOrderIndependenceTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
FLayoutTestWorld World;
|
||||||
|
World.Build();
|
||||||
|
|
||||||
|
TArray<int32> OriginalOrder;
|
||||||
|
for (int32 Index = 0; Index < NumPoolDefinitions; ++Index)
|
||||||
|
{
|
||||||
|
OriginalOrder.Add(Index);
|
||||||
|
}
|
||||||
|
|
||||||
|
TArray<int32> ReversedOrder;
|
||||||
|
for (int32 Index = NumPoolDefinitions - 1; Index >= 0; --Index)
|
||||||
|
{
|
||||||
|
ReversedOrder.Add(Index);
|
||||||
|
}
|
||||||
|
|
||||||
|
TArray<int32> PermutedOrder = OriginalOrder;
|
||||||
|
PermutedOrder.Swap(0, 2);
|
||||||
|
|
||||||
|
TArray<TArray<int32>> Orders;
|
||||||
|
Orders.Add(OriginalOrder);
|
||||||
|
Orders.Add(ReversedOrder);
|
||||||
|
Orders.Add(PermutedOrder);
|
||||||
|
|
||||||
|
static const TCHAR* OrderNames[] = {
|
||||||
|
TEXT("original"),
|
||||||
|
TEXT("reversal"),
|
||||||
|
TEXT("swap"),
|
||||||
|
};
|
||||||
|
|
||||||
|
TArray<FLayoutSnapshot> Snapshots;
|
||||||
|
Snapshots.Reserve(Orders.Num());
|
||||||
|
for (int32 OrderIndex = 0; OrderIndex < Orders.Num(); ++OrderIndex)
|
||||||
|
{
|
||||||
|
const bool bRebuilt = World.Rebuild(Orders[OrderIndex]);
|
||||||
|
const int32 NumStrates = World.StrateManager.IsValid()
|
||||||
|
? World.StrateManager->GetNumStrates()
|
||||||
|
: 0;
|
||||||
|
if (!bRebuilt || NumStrates != NumPoolDefinitions)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("Permutation '%s' could not build a strate layout at all. The test cannot ")
|
||||||
|
TEXT("silently pass with an incomplete layout; expected %d resolved slots, got %d. ")
|
||||||
|
TEXT("Check transient soft-pointer resolution."),
|
||||||
|
OrderNames[OrderIndex], NumPoolDefinitions, NumStrates));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Snapshots.Add(CaptureSnapshot(*World.StrateManager.Get()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Snapshots.Num() == 0 || Snapshots[0].Layout.Num() == 0)
|
||||||
|
{
|
||||||
|
AddError(TEXT("No layout snapshot was inspected. This test would otherwise pass without ")
|
||||||
|
TEXT("checking any slots."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Snapshots[0].Passages.Num() == 0)
|
||||||
|
{
|
||||||
|
AddError(TEXT("The known pool generated no passages, so the passage invariant was not ")
|
||||||
|
TEXT("actually inspected."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 NumSlotsInspected = 0;
|
||||||
|
int32 NumPassagesInspected = 0;
|
||||||
|
for (int32 SnapshotIndex = 1; SnapshotIndex < Snapshots.Num(); ++SnapshotIndex)
|
||||||
|
{
|
||||||
|
NumSlotsInspected += Snapshots[0].Layout.Num();
|
||||||
|
NumPassagesInspected += Snapshots[0].Passages.Num();
|
||||||
|
|
||||||
|
FString Mismatch;
|
||||||
|
if (!SameSnapshot(Snapshots[0], Snapshots[SnapshotIndex], Mismatch))
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("Pool order changed the built result for permutation '%s': %s"),
|
||||||
|
OrderNames[SnapshotIndex], *Mismatch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pool permutation alone cannot expose the old sequential passage stream once the layout
|
||||||
|
// itself is stable. Perturb one early connection count and verify that every later boundary
|
||||||
|
// keeps its own (strate index, connection index) values unchanged.
|
||||||
|
// Une permutation seule ne peut plus révéler l'ancien stream séquentiel une fois le layout
|
||||||
|
// stable. On perturbe donc une connexion précoce et on vérifie toutes les frontières suivantes.
|
||||||
|
UVoxelStrateDefinition* EarlyDefinition = Snapshots[0].Layout[0].Definition;
|
||||||
|
if (!EarlyDefinition)
|
||||||
|
{
|
||||||
|
AddError(TEXT("The reference layout has a null definition in slot 0; no passage ")
|
||||||
|
TEXT("independence check can be performed."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int32 OriginalConnections = EarlyDefinition->PassageConfig.Connections;
|
||||||
|
EarlyDefinition->PassageConfig.Connections = OriginalConnections + 1;
|
||||||
|
const bool bPerturbedRebuild = World.Rebuild(OriginalOrder);
|
||||||
|
const int32 NumPerturbedStrates = World.StrateManager.IsValid()
|
||||||
|
? World.StrateManager->GetNumStrates()
|
||||||
|
: 0;
|
||||||
|
if (!bPerturbedRebuild || NumPerturbedStrates != NumPoolDefinitions)
|
||||||
|
{
|
||||||
|
AddError(TEXT("Changing the early connection count prevented the known layout from ")
|
||||||
|
TEXT("rebuilding; the passage-independence check could not run."));
|
||||||
|
EarlyDefinition->PassageConfig.Connections = OriginalConnections;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const FLayoutSnapshot Perturbed = CaptureSnapshot(*World.StrateManager.Get());
|
||||||
|
EarlyDefinition->PassageConfig.Connections = OriginalConnections;
|
||||||
|
|
||||||
|
FString LayoutMismatch;
|
||||||
|
if (!SameLayout(Snapshots[0].Layout, Perturbed.Layout, LayoutMismatch))
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("Changing only the early connection count changed the strate layout: %s"),
|
||||||
|
*LayoutMismatch));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 BoundaryIndex = 1; BoundaryIndex < Snapshots[0].Layout.Num() - 1; ++BoundaryIndex)
|
||||||
|
{
|
||||||
|
TArray<const FVoxelPassage*> ReferenceBoundary;
|
||||||
|
TArray<const FVoxelPassage*> PerturbedBoundary;
|
||||||
|
for (const FVoxelPassage& Passage : Snapshots[0].Passages)
|
||||||
|
{
|
||||||
|
if (Passage.UpperStrateIndex == BoundaryIndex)
|
||||||
|
{
|
||||||
|
ReferenceBoundary.Add(&Passage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const FVoxelPassage& Passage : Perturbed.Passages)
|
||||||
|
{
|
||||||
|
if (Passage.UpperStrateIndex == BoundaryIndex)
|
||||||
|
{
|
||||||
|
PerturbedBoundary.Add(&Passage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
++NumPassagesInspected;
|
||||||
|
if (ReferenceBoundary.Num() != PerturbedBoundary.Num())
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("Boundary %d passage count changed after an earlier connection count ")
|
||||||
|
TEXT("was perturbed (%d vs %d)."),
|
||||||
|
BoundaryIndex, ReferenceBoundary.Num(), PerturbedBoundary.Num()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 PassageIndex = 0; PassageIndex < ReferenceBoundary.Num(); ++PassageIndex)
|
||||||
|
{
|
||||||
|
FString Mismatch;
|
||||||
|
if (!SamePassage(*ReferenceBoundary[PassageIndex], *PerturbedBoundary[PassageIndex],
|
||||||
|
Mismatch, PassageIndex))
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("Passage at boundary %d changed when an earlier connection count was ")
|
||||||
|
TEXT("perturbed: %s"),
|
||||||
|
BoundaryIndex, *Mismatch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NumSlotsInspected == 0 || NumPassagesInspected == 0)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("The test inspected no comparable data (slots=%d, passages=%d); this is a ")
|
||||||
|
TEXT("test-harness failure, not a determinism pass."),
|
||||||
|
NumSlotsInspected, NumPassagesInspected));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Compared %d layout slots and %d passages across %d pool orders (seed=%d)."),
|
||||||
|
NumSlotsInspected, NumPassagesInspected, Snapshots.Num(), LayoutSeed));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
// VoxelForgeMazeSeamTest.cpp
|
||||||
|
// The Maze parent rule must be independent of which neighbouring chunk was evaluated first.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
|
||||||
|
#include "VoxelCaveMorphology.h"
|
||||||
|
#include "VoxelForgeTestFixture.h"
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeMazeSeamTest,
|
||||||
|
"VoxelForge.Generation.MazeSeamFreedom",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
void AddMazeBoundaryProbes(const FMazeGenerationParams& Params, int32 BottomVoxelZ,
|
||||||
|
int32 TopVoxelZ, TArray<FVector>& OutProbes)
|
||||||
|
{
|
||||||
|
const float CellSize = FMath::Max(Params.CellSize, 1.0f);
|
||||||
|
const int32 MidCellZ = FMath::FloorToInt(
|
||||||
|
0.5f * (Params.StrateTopWorldZ + Params.StrateBottomWorldZ) / CellSize);
|
||||||
|
const float MidZ = (MidCellZ + 0.5f) * CellSize;
|
||||||
|
if (MidZ <= BottomVoxelZ || MidZ >= TopVoxelZ)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int32 CellCoordinates[] = { -5, -2, -1, 0, 1, 2, 5 };
|
||||||
|
const float Offsets[] = { -0.25f, 0.0f, 0.25f };
|
||||||
|
for (const int32 CellX : CellCoordinates)
|
||||||
|
{
|
||||||
|
for (const float Offset : Offsets)
|
||||||
|
{
|
||||||
|
OutProbes.Add(FVector(CellX * CellSize + Offset, 7.25f, MidZ));
|
||||||
|
OutProbes.Add(FVector(7.25f, CellX * CellSize + Offset, MidZ));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const int32 CellZ : CellCoordinates)
|
||||||
|
{
|
||||||
|
for (const float Offset : Offsets)
|
||||||
|
{
|
||||||
|
const float Z = CellZ * CellSize + Offset;
|
||||||
|
if (Z > BottomVoxelZ && Z < TopVoxelZ)
|
||||||
|
{
|
||||||
|
OutProbes.Add(FVector(7.25f, -11.5f, Z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WarmNeighbouringChunks(const VoxelForgeTest::FTestWorld& World,
|
||||||
|
const FVector& Probe, int32 Variant)
|
||||||
|
{
|
||||||
|
const float Delta = static_cast<float>(CHUNK_SIZE * 3);
|
||||||
|
const FVector Offsets[] = {
|
||||||
|
FVector(Variant == 0 ? -Delta : Delta, Variant == 1 ? -Delta : Delta, 0.0f),
|
||||||
|
FVector(Variant == 2 ? Delta : -Delta, Variant == 3 ? Delta : -Delta, 0.0f),
|
||||||
|
FVector(Variant == 4 ? Delta : -Delta, Variant == 5 ? -Delta : Delta, 0.0f),
|
||||||
|
};
|
||||||
|
for (const FVector& Offset : Offsets)
|
||||||
|
{
|
||||||
|
World.Generator->GetDensityAt(
|
||||||
|
Probe.X + Offset.X, Probe.Y + Offset.Y, Probe.Z + Offset.Z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 CountContextMismatches(const VoxelForgeTest::FTestWorld& World,
|
||||||
|
const TArray<FVector>& Probes, int32& OutFirstProbe,
|
||||||
|
float& OutFirstBefore, float& OutFirstAfter)
|
||||||
|
{
|
||||||
|
int32 Mismatches = 0;
|
||||||
|
OutFirstProbe = INDEX_NONE;
|
||||||
|
OutFirstBefore = 0.0f;
|
||||||
|
OutFirstAfter = 0.0f;
|
||||||
|
for (int32 Index = 0; Index < Probes.Num(); ++Index)
|
||||||
|
{
|
||||||
|
const FVector& Probe = Probes[Index];
|
||||||
|
WarmNeighbouringChunks(World, Probe, Index % 6);
|
||||||
|
const float Before = World.Generator->GetDensityAt(Probe.X, Probe.Y, Probe.Z);
|
||||||
|
WarmNeighbouringChunks(World, Probe, (Index + 3) % 6);
|
||||||
|
const float After = World.Generator->GetDensityAt(Probe.X, Probe.Y, Probe.Z);
|
||||||
|
if (!VoxelForgeTest::BitEqual(Before, After))
|
||||||
|
{
|
||||||
|
if (OutFirstProbe == INDEX_NONE)
|
||||||
|
{
|
||||||
|
OutFirstProbe = Index;
|
||||||
|
OutFirstBefore = Before;
|
||||||
|
OutFirstAfter = After;
|
||||||
|
}
|
||||||
|
++Mismatches;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Mismatches;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelForgeMazeSeamTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
using namespace VoxelForgeTest;
|
||||||
|
|
||||||
|
FTestWorld LegacyWorld;
|
||||||
|
LegacyWorld.Build(7331, 2, false, 4);
|
||||||
|
FTestWorld StackWorld;
|
||||||
|
StackWorld.Build(7331, 2, true, 4);
|
||||||
|
if (!LegacyWorld.IsValid() || !StackWorld.IsValid())
|
||||||
|
{
|
||||||
|
AddError(LegacyWorld.IsValid() ? StackWorld.WhyInvalid() : LegacyWorld.WhyInvalid());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 LegacyTop = 0, LegacyBottom = 0;
|
||||||
|
int32 StackTop = 0, StackBottom = 0;
|
||||||
|
if (!LegacyWorld.GetSlotVoxelZRange(FTestWorld::SlotMaze, LegacyTop, LegacyBottom)
|
||||||
|
|| !StackWorld.GetSlotVoxelZRange(FTestWorld::SlotMaze, StackTop, StackBottom))
|
||||||
|
{
|
||||||
|
AddError(TEXT("The seam fixture has no Maze slot."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const int32 LegacyMidChunkZ = ((LegacyTop + LegacyBottom) / 2) / CHUNK_SIZE;
|
||||||
|
const int32 StackMidChunkZ = ((StackTop + StackBottom) / 2) / CHUNK_SIZE;
|
||||||
|
const FMazeGenerationParams LegacyParams = LegacyWorld.StrateManager->GetMazeParamsForChunk(
|
||||||
|
FIntVector(0, 0, LegacyMidChunkZ));
|
||||||
|
const FMazeGenerationParams StackParams = StackWorld.StrateManager->GetMazeParamsForChunk(
|
||||||
|
FIntVector(0, 0, StackMidChunkZ));
|
||||||
|
|
||||||
|
TArray<FVector> LegacyProbes;
|
||||||
|
TArray<FVector> StackProbes;
|
||||||
|
AddMazeBoundaryProbes(LegacyParams, LegacyBottom, LegacyTop, LegacyProbes);
|
||||||
|
AddMazeBoundaryProbes(StackParams, StackBottom, StackTop, StackProbes);
|
||||||
|
TestEqual(TEXT("legacy and operator seam tests have the same probe count"),
|
||||||
|
LegacyProbes.Num(), StackProbes.Num());
|
||||||
|
|
||||||
|
int32 LegacyFirst = INDEX_NONE, StackFirst = INDEX_NONE;
|
||||||
|
float LegacyBefore = 0.0f, LegacyAfter = 0.0f;
|
||||||
|
float StackBefore = 0.0f, StackAfter = 0.0f;
|
||||||
|
const int32 LegacyMismatches = CountContextMismatches(
|
||||||
|
LegacyWorld, LegacyProbes, LegacyFirst, LegacyBefore, LegacyAfter);
|
||||||
|
const int32 StackMismatches = CountContextMismatches(
|
||||||
|
StackWorld, StackProbes, StackFirst, StackBefore, StackAfter);
|
||||||
|
|
||||||
|
TestEqual(TEXT("legacy Maze has no chunk-context seam mismatches"), LegacyMismatches, 0);
|
||||||
|
TestEqual(TEXT("operator-stack Maze has no chunk-context seam mismatches"), StackMismatches, 0);
|
||||||
|
TestTrue(TEXT("Maze seam test sampled cell-boundary probes"), LegacyProbes.Num() > 0);
|
||||||
|
|
||||||
|
if (LegacyFirst != INDEX_NONE)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(TEXT("Legacy Maze seam mismatch at probe %d: %.9g -> %.9g"),
|
||||||
|
LegacyFirst, LegacyBefore, LegacyAfter));
|
||||||
|
}
|
||||||
|
if (StackFirst != INDEX_NONE)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(TEXT("Operator Maze seam mismatch at probe %d: %.9g -> %.9g"),
|
||||||
|
StackFirst, StackBefore, StackAfter));
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Maze seam freedom: %d boundary probes, legacy mismatches=%d, operator mismatches=%d; "
|
||||||
|
"local window=2x2x2 child nodes ({-1,0}^3), no wide collect."),
|
||||||
|
LegacyProbes.Num(), LegacyMismatches, StackMismatches));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cave-warp optimization is deliberately tile/LOD agnostic: its three channel inputs are
|
||||||
|
// world coordinates only. Exercise the production density path nevertheless, because a cache
|
||||||
|
// window or an LOD TLS leak could still make an otherwise pure warp look like a seam.
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeCaveWarpTileLodSeamTest,
|
||||||
|
"VoxelForge.Generation.CaveWarpTileLodSeam",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeCaveWarpTileLodSeamTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
using namespace VoxelForgeTest;
|
||||||
|
|
||||||
|
FTestWorld World;
|
||||||
|
World.Build(1337, 2, true, 4);
|
||||||
|
if (!World.IsValid())
|
||||||
|
{
|
||||||
|
AddError(World.WhyInvalid());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 TunnelTopZ = 0, TunnelBottomZ = 0;
|
||||||
|
if (!World.GetSlotVoxelZRange(FTestWorld::SlotTunnelNetwork, TunnelTopZ, TunnelBottomZ))
|
||||||
|
{
|
||||||
|
AddError(TEXT("The seam fixture has no TunnelNetwork slot."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float TunnelMidZ = 0.5f * (static_cast<float>(TunnelTopZ)
|
||||||
|
+ static_cast<float>(TunnelBottomZ));
|
||||||
|
const int32 ProbeZMid = FMath::Clamp(
|
||||||
|
FMath::RoundToInt(TunnelMidZ), TunnelBottomZ + 1, TunnelTopZ - 1);
|
||||||
|
|
||||||
|
const int32 PreviousStep = VoxelGenLOD::GetThreadSampleStep();
|
||||||
|
FIntVector PreviousOrigin = FIntVector::ZeroValue;
|
||||||
|
int32 PreviousCells = 0;
|
||||||
|
int32 PreviousContextStep = PreviousStep;
|
||||||
|
VoxelGenLOD::GetThreadTileCacheWindow(
|
||||||
|
PreviousOrigin, PreviousContextStep, PreviousCells);
|
||||||
|
|
||||||
|
int32 PairMismatches = 0;
|
||||||
|
int32 LodMismatches = 0;
|
||||||
|
int32 NumPairSamples = 0;
|
||||||
|
FString FirstMismatch;
|
||||||
|
auto Compare = [&](const TCHAR* Label, const FVector& Point, float A, float B,
|
||||||
|
int32& Counter)
|
||||||
|
{
|
||||||
|
if (!BitEqual(A, B))
|
||||||
|
{
|
||||||
|
++Counter;
|
||||||
|
if (FirstMismatch.IsEmpty())
|
||||||
|
{
|
||||||
|
FirstMismatch = FString::Printf(
|
||||||
|
TEXT("%s at (%.0f, %.0f, %.0f): %.9g [0x%08X] vs %.9g [0x%08X]"),
|
||||||
|
Label, Point.X, Point.Y, Point.Z,
|
||||||
|
A, *reinterpret_cast<const uint32*>(&A),
|
||||||
|
B, *reinterpret_cast<const uint32*>(&B));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
auto Sample = [&](int32 Step, const FIntVector& TileOrigin, const FVector& Point)
|
||||||
|
{
|
||||||
|
VoxelGenLOD::SetThreadSampleStep(Step);
|
||||||
|
VoxelGenLOD::SetThreadTileContext(TileOrigin, CHUNK_SIZE);
|
||||||
|
return World.Generator->GetDensityAt(Point.X, Point.Y, Point.Z);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common points are deliberately on the shared face of adjacent production tiles. The
|
||||||
|
// LOD1 pair is 64 voxels wide (Step=2, Cells=32); its boundary is also a LOD0 tile boundary.
|
||||||
|
const int32 CommonOffsets[] = { 4, 12, 20, 28 };
|
||||||
|
const int32 ZOffsets[] = { -8, 0, 8 };
|
||||||
|
const int32 LodSteps[] = { 1, 2 };
|
||||||
|
for (const int32 Step : LodSteps)
|
||||||
|
{
|
||||||
|
const int32 Extent = CHUNK_SIZE * Step;
|
||||||
|
const int32 ZOrigin = FMath::FloorToInt(
|
||||||
|
TunnelMidZ / static_cast<float>(Extent)) * Extent;
|
||||||
|
const int32 L0Boundary = Extent == CHUNK_SIZE ? CHUNK_SIZE : Extent;
|
||||||
|
const FIntVector XLeftOrigin(
|
||||||
|
L0Boundary - Extent, 0, ZOrigin);
|
||||||
|
const FIntVector XRightOrigin(
|
||||||
|
L0Boundary, 0, ZOrigin);
|
||||||
|
const FIntVector YLowerOrigin(
|
||||||
|
0, L0Boundary - Extent, ZOrigin);
|
||||||
|
const FIntVector YUpperOrigin(
|
||||||
|
0, L0Boundary, ZOrigin);
|
||||||
|
|
||||||
|
for (const int32 Offset : CommonOffsets)
|
||||||
|
{
|
||||||
|
for (const int32 ZOffset : ZOffsets)
|
||||||
|
{
|
||||||
|
const float ProbeZ = FMath::Clamp(
|
||||||
|
static_cast<float>(ProbeZMid + ZOffset),
|
||||||
|
static_cast<float>(TunnelBottomZ + 1),
|
||||||
|
static_cast<float>(TunnelTopZ - 1));
|
||||||
|
|
||||||
|
const FVector XPoint(static_cast<float>(L0Boundary),
|
||||||
|
static_cast<float>(Offset), ProbeZ);
|
||||||
|
const float XLeft = Sample(Step, XLeftOrigin, XPoint);
|
||||||
|
const float XRight = Sample(Step, XRightOrigin, XPoint);
|
||||||
|
Compare(Step == 1 ? TEXT("LOD0 X tile pair") : TEXT("LOD1 X tile pair"),
|
||||||
|
XPoint, XLeft, XRight, PairMismatches);
|
||||||
|
|
||||||
|
const FVector YPoint(static_cast<float>(Offset),
|
||||||
|
static_cast<float>(L0Boundary), ProbeZ);
|
||||||
|
const float YLower = Sample(Step, YLowerOrigin, YPoint);
|
||||||
|
const float YUpper = Sample(Step, YUpperOrigin, YPoint);
|
||||||
|
Compare(Step == 1 ? TEXT("LOD0 Y tile pair") : TEXT("LOD1 Y tile pair"),
|
||||||
|
YPoint, YLower, YUpper, PairMismatches);
|
||||||
|
NumPairSamples += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare the same world points through the two LOD contexts. The default LODOctaveDrop is
|
||||||
|
// zero, so LOD changes sampling density, not the value of a shared world sample.
|
||||||
|
const int32 CrossLodBoundary = CHUNK_SIZE * 2;
|
||||||
|
const int32 L0ZOrigin = FMath::FloorToInt(
|
||||||
|
TunnelMidZ / static_cast<float>(CHUNK_SIZE)) * CHUNK_SIZE;
|
||||||
|
const int32 L1ZOrigin = FMath::FloorToInt(
|
||||||
|
TunnelMidZ / static_cast<float>(CrossLodBoundary)) * CrossLodBoundary;
|
||||||
|
for (const int32 Offset : CommonOffsets)
|
||||||
|
{
|
||||||
|
for (const int32 ZOffset : ZOffsets)
|
||||||
|
{
|
||||||
|
const float ProbeZ = FMath::Clamp(
|
||||||
|
static_cast<float>(ProbeZMid + ZOffset),
|
||||||
|
static_cast<float>(TunnelBottomZ + 1),
|
||||||
|
static_cast<float>(TunnelTopZ - 1));
|
||||||
|
|
||||||
|
const FVector XPoint(static_cast<float>(CrossLodBoundary),
|
||||||
|
static_cast<float>(Offset), ProbeZ);
|
||||||
|
const float L0X = Sample(
|
||||||
|
1, FIntVector(CHUNK_SIZE, 0, L0ZOrigin), XPoint);
|
||||||
|
const float L1X = Sample(
|
||||||
|
2, FIntVector(0, 0, L1ZOrigin), XPoint);
|
||||||
|
Compare(TEXT("same X world point across LOD0/LOD1"),
|
||||||
|
XPoint, L0X, L1X, LodMismatches);
|
||||||
|
|
||||||
|
const FVector YPoint(static_cast<float>(Offset),
|
||||||
|
static_cast<float>(CrossLodBoundary), ProbeZ);
|
||||||
|
const float L0Y = Sample(
|
||||||
|
1, FIntVector(0, CHUNK_SIZE, L0ZOrigin), YPoint);
|
||||||
|
const float L1Y = Sample(
|
||||||
|
2, FIntVector(0, 0, L1ZOrigin), YPoint);
|
||||||
|
Compare(TEXT("same Y world point across LOD0/LOD1"),
|
||||||
|
YPoint, L0Y, L1Y, LodMismatches);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VoxelGenLOD::SetThreadSampleStep(PreviousContextStep);
|
||||||
|
VoxelGenLOD::SetThreadTileContext(PreviousOrigin, PreviousCells);
|
||||||
|
|
||||||
|
TestTrue(TEXT("sampled shared boundary points"), NumPairSamples > 0);
|
||||||
|
TestEqual(TEXT("LOD0 and LOD1 neighbouring tile boundaries are bit-identical"),
|
||||||
|
PairMismatches, 0);
|
||||||
|
TestEqual(TEXT("same world boundary points are bit-identical across LOD0 and LOD1"),
|
||||||
|
LodMismatches, 0);
|
||||||
|
if (!FirstMismatch.IsEmpty())
|
||||||
|
{
|
||||||
|
AddError(FirstMismatch);
|
||||||
|
}
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Cave warp tile/LOD seam proof: %d same-LOD pair samples, pair mismatches=%d, "
|
||||||
|
"cross-LOD mismatches=%d; shared points sampled from X and Y boundaries."),
|
||||||
|
NumPairSamples, PairMismatches, LodMismatches));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// VoxelForgeOpStackChannelTest.cpp
|
||||||
|
// The channel declarations are executable stack-assembly metadata, not comments.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
|
||||||
|
#include "VoxelForgeTestFixture.h"
|
||||||
|
#include "VoxelDensityOpStack.h"
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeOpStackChannelTest,
|
||||||
|
"VoxelForge.OpStack.ChannelDAG",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
/** A deliberately small declaration-only op for the validator's negative controls. */
|
||||||
|
class FDeclarationProbeOp final : public IVoxelDensityOp
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
FDeclarationProbeOp(EVoxelOpChannelMask InReads,
|
||||||
|
EVoxelOpChannelMask InWrites,
|
||||||
|
bool bInAdditive)
|
||||||
|
: Reads(InReads), Writes(InWrites), bAdditive(bInAdditive)
|
||||||
|
{}
|
||||||
|
|
||||||
|
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
|
||||||
|
EVoxelOpChannelMask ChannelReads() const override { return Reads; }
|
||||||
|
EVoxelOpChannelMask ChannelWrites() const override { return Writes; }
|
||||||
|
bool IsAdditive() const override { return bAdditive; }
|
||||||
|
void PrepareChunk(const FVoxelOpContext&) override {}
|
||||||
|
void Eval(float, float, float, FVoxelOpSample&) const override {}
|
||||||
|
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
|
||||||
|
{
|
||||||
|
return EVoxelOpEffect::Identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TCHAR* DebugName() const override { return TEXT("DeclarationProbeOp"); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
EVoxelOpChannelMask Reads;
|
||||||
|
EVoxelOpChannelMask Writes;
|
||||||
|
bool bAdditive;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelForgeOpStackChannelTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
using namespace VoxelForgeTest;
|
||||||
|
|
||||||
|
FTestWorld World;
|
||||||
|
World.Build();
|
||||||
|
if (!World.IsValid())
|
||||||
|
{
|
||||||
|
AddError(World.WhyInvalid());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UVoxelGenerator* Generator = World.Generator.Get();
|
||||||
|
const int32 Seed = World.Settings->Seed;
|
||||||
|
|
||||||
|
auto MidChunkForSlot = [&](int32 Slot, const TCHAR* Name, int32& OutMidChunkZ) -> bool
|
||||||
|
{
|
||||||
|
int32 TopVoxelZ = 0;
|
||||||
|
int32 BottomVoxelZ = 0;
|
||||||
|
if (!World.GetSlotVoxelZRange(Slot, TopVoxelZ, BottomVoxelZ))
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("The fixture has no %s slot; check FTestWorld::Build's Archetypes[] and "
|
||||||
|
"the corresponding slot constant."), Name));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
OutMidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto Validate = [&](FVoxelOpStack& Stack, const TCHAR* Name, int32 ExpectedNum)
|
||||||
|
{
|
||||||
|
TestEqual(*FString::Printf(TEXT("%s shipping stack op count"), Name),
|
||||||
|
Stack.Num(), ExpectedNum);
|
||||||
|
|
||||||
|
FString Error;
|
||||||
|
const bool bValid = Stack.ValidateChannelOrder(&Error);
|
||||||
|
if (!bValid)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(TEXT("%s shipping stack failed channel validation: %s"),
|
||||||
|
Name, *Error));
|
||||||
|
}
|
||||||
|
TestTrue(*FString::Printf(TEXT("%s shipping stack has a legal channel DAG"), Name), bValid);
|
||||||
|
};
|
||||||
|
|
||||||
|
// These are the eight builders currently shipped behind bUseOperatorStack. Keep the
|
||||||
|
// archetype list explicit so adding a builder without adding its validator assertion is
|
||||||
|
// visible in this test.
|
||||||
|
int32 MidChunkZ = 0;
|
||||||
|
|
||||||
|
if (MidChunkForSlot(FTestWorld::SlotTunnelNetwork, TEXT("TunnelNetwork"), MidChunkZ))
|
||||||
|
{
|
||||||
|
const FStrateGenerationParams Params =
|
||||||
|
World.StrateManager->GetGenerationParams(FIntVector(0, 0, MidChunkZ));
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
VoxelDensityOps::BuildTunnelNetworkStack(
|
||||||
|
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
Validate(Stack, TEXT("TunnelNetwork"), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MidChunkForSlot(FTestWorld::SlotFlatPlain, TEXT("FlatPlain"), MidChunkZ))
|
||||||
|
{
|
||||||
|
const FSlabGenerationParams Params =
|
||||||
|
World.StrateManager->GetSlabParamsForChunk(FIntVector(0, 0, MidChunkZ));
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
VoxelDensityOps::BuildSlabStack(
|
||||||
|
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
Validate(Stack, TEXT("FlatPlain"), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MidChunkForSlot(FTestWorld::SlotCrystalChamber, TEXT("CrystalChamber"), MidChunkZ))
|
||||||
|
{
|
||||||
|
const FSlabGenerationParams Params =
|
||||||
|
World.StrateManager->GetSlabParamsForChunk(FIntVector(0, 0, MidChunkZ));
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
VoxelDensityOps::BuildSlabStack(
|
||||||
|
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
Validate(Stack, TEXT("CrystalChamber"), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MidChunkForSlot(FTestWorld::SlotMaze, TEXT("Maze"), MidChunkZ))
|
||||||
|
{
|
||||||
|
const FMazeGenerationParams Params =
|
||||||
|
World.StrateManager->GetMazeParamsForChunk(FIntVector(0, 0, MidChunkZ));
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
VoxelDensityOps::BuildMazeStack(
|
||||||
|
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
Validate(Stack, TEXT("Maze"), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MidChunkForSlot(FTestWorld::SlotSurfaceWorld, TEXT("SurfaceWorld"), MidChunkZ))
|
||||||
|
{
|
||||||
|
const FSurfaceGenerationParams Params =
|
||||||
|
World.StrateManager->GetSurfaceParamsForChunk(FIntVector(0, 0, MidChunkZ));
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
VoxelDensityOps::BuildSurfaceStack(
|
||||||
|
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
Validate(Stack, TEXT("SurfaceWorld"), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MidChunkForSlot(FTestWorld::SlotVerticalShafts, TEXT("VerticalShafts"), MidChunkZ))
|
||||||
|
{
|
||||||
|
const FVerticalShaftParams Params =
|
||||||
|
World.StrateManager->GetVerticalShaftParamsForChunk(FIntVector(0, 0, MidChunkZ));
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
VoxelDensityOps::BuildVerticalShaftStack(
|
||||||
|
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
Validate(Stack, TEXT("VerticalShafts"), 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MidChunkForSlot(FTestWorld::SlotFloatingIsland, TEXT("FloatingIslands"), MidChunkZ))
|
||||||
|
{
|
||||||
|
const FFloatingIslandParams Params =
|
||||||
|
World.StrateManager->GetFloatingIslandParamsForChunk(FIntVector(0, 0, MidChunkZ));
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
VoxelDensityOps::BuildFloatingIslandStack(
|
||||||
|
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
Validate(Stack, TEXT("FloatingIslands"), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MidChunkForSlot(FTestWorld::SlotUnderwater, TEXT("Underwater"), MidChunkZ))
|
||||||
|
{
|
||||||
|
const FStrateGenerationParams Params =
|
||||||
|
World.StrateManager->GetGenerationParams(FIntVector(0, 0, MidChunkZ));
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
VoxelDensityOps::BuildTunnelNetworkStack(
|
||||||
|
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
Validate(Stack, TEXT("Underwater"), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A declaration that reads an unavailable channel must fail, and a second write-only
|
||||||
|
// replacement must not silently erase the first producer. These controls pin the validator's
|
||||||
|
// two most important safety rules independently of the shipping builders.
|
||||||
|
{
|
||||||
|
FVoxelOpStack MissingProducer;
|
||||||
|
MissingProducer.Add(MakeUnique<FDeclarationProbeOp>(
|
||||||
|
VoxelOpChannels::Density, VoxelOpChannels::Density, true));
|
||||||
|
FString Error;
|
||||||
|
TestFalse(TEXT("a read before its first producer is rejected"),
|
||||||
|
MissingProducer.ValidateChannelOrder(&Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
FVoxelOpStack Clobber;
|
||||||
|
Clobber.Add(MakeUnique<FDeclarationProbeOp>(
|
||||||
|
VoxelOpChannels::None, VoxelOpChannels::Density, false));
|
||||||
|
Clobber.Add(MakeUnique<FDeclarationProbeOp>(
|
||||||
|
VoxelOpChannels::None, VoxelOpChannels::Density, false));
|
||||||
|
FString Error;
|
||||||
|
TestFalse(TEXT("a second write-only replacement is rejected"),
|
||||||
|
Clobber.ValidateChannelOrder(&Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
@@ -103,8 +103,8 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
|
|||||||
VoxelDensityOps::BuildFloatingIslandStack(Stack, P, World.Settings->Seed,
|
VoxelDensityOps::BuildFloatingIslandStack(Stack, P, World.Settings->Seed,
|
||||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
|
||||||
// void + blobs + roughness + fill + 3 structurels.
|
// void + blobs + roughness + fill + 4 structurels.
|
||||||
TestEqual(TEXT("the island stack is decomposed into 7 ops"), Stack.Num(), 7);
|
TestEqual(TEXT("the island stack is decomposed into 8 ops"), Stack.Num(), 8);
|
||||||
|
|
||||||
FVoxelOpContext Ctx;
|
FVoxelOpContext Ctx;
|
||||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||||
@@ -233,7 +233,7 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
|
|||||||
// 3. LE VERDICT DE BOÎTE — et la première preuve « AllAir » du plugin
|
// 3. LE VERDICT DE BOÎTE — et la première preuve « AllAir » du plugin
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
{
|
{
|
||||||
int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0;
|
int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
|
||||||
FRandomStream Rng(24680);
|
FRandomStream Rng(24680);
|
||||||
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
|
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
|
||||||
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
|
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
|
||||||
@@ -268,6 +268,7 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
|
|||||||
const float Y = (float)(Origin.Y + gy * Step);
|
const float Y = (float)(Origin.Y + gy * Step);
|
||||||
const float Z = (float)(Origin.Z + gz * Step);
|
const float Z = (float)(Origin.Z + gz * Step);
|
||||||
const float D = Stack.EvalMC(X, Y, Z);
|
const float D = Stack.EvalMC(X, Y, Z);
|
||||||
|
++NumBruteSamples;
|
||||||
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
|
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
|
||||||
{
|
{
|
||||||
if (NumUnsound == 0)
|
if (NumUnsound == 0)
|
||||||
@@ -275,7 +276,7 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
|
|||||||
AddError(FString::Printf(
|
AddError(FString::Printf(
|
||||||
TEXT("HOLE: the island stack claimed %s for the box at (%d,%d,%d) but ")
|
TEXT("HOLE: the island stack claimed %s for the box at (%d,%d,%d) but ")
|
||||||
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
|
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
|
||||||
TEXT("order: the blob source's Pad (does it cover the WARP amplitude ")
|
TEXT("order: the blob source's propagated SDF interval (does it cover the WARP amplitude ")
|
||||||
TEXT("AND the roughness AND the fill blend AND the SmoothMin dip?), ")
|
TEXT("AND the roughness AND the fill blend AND the SmoothMin dip?), ")
|
||||||
TEXT("then the Z bound -- note there is NO lower bound, a thin thread ")
|
TEXT("then the Z bound -- note there is NO lower bound, a thin thread ")
|
||||||
TEXT("of matter hangs below each island down the axis, so only the ")
|
TEXT("of matter hangs below each island down the axis, so only the ")
|
||||||
@@ -295,11 +296,11 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
|
|||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("Box verdicts over 60 FloatingIslands tiles (XY sampled from +/- %d voxels = %.1f x ")
|
TEXT("Box verdicts over 60 FloatingIslands tiles (XY sampled from +/- %d voxels = %.1f x ")
|
||||||
TEXT("IslandSpacing %.0f): %d proved AllSolid, %d proved AllAir, ")
|
TEXT("IslandSpacing %.0f): %d proved AllSolid, %d proved AllAir, ")
|
||||||
TEXT("%d Mixed. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ")
|
TEXT("%d Mixed, %d voxels checked, %d violations. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ")
|
||||||
TEXT("thing: no cave archetype has ever been able to prove 'all air', and a floating-")
|
TEXT("thing: no cave archetype has ever been able to prove 'all air', and a floating-")
|
||||||
TEXT("island strate is mostly exactly that (OPSTACK-DECOMPOSITION 7)."),
|
TEXT("island strate is mostly exactly that (OPSTACK-DECOMPOSITION 7)."),
|
||||||
SpanVoxels, (float)SpanVoxels / FMath::Max(P.IslandSpacing, 1.0f), P.IslandSpacing,
|
SpanVoxels, (float)SpanVoxels / FMath::Max(P.IslandSpacing, 1.0f), P.IslandSpacing,
|
||||||
NumProvedSolid, NumProvedAir, NumMixed));
|
NumProvedSolid, NumProvedAir, NumMixed, NumBruteSamples, NumUnsound));
|
||||||
|
|
||||||
if (NumProvedAir == 0)
|
if (NumProvedAir == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// CE QUE LA PHASE 1 DEVAIT PROUVER / WHAT PHASE 1 HAD TO PROVE
|
// CE QUE LA PHASE 1 DEVAIT PROUVER / WHAT PHASE 1 HAD TO PROVE
|
||||||
// Le déclencheur d'arrêt de `OPSTACK-PLAN §4` : **« est-ce que la séparation source / modifier tombe
|
// Le déclencheur d'arrêt de `OPSTACK-PLAN §4` : **« est-ce que la séparation source / modifier tombe
|
||||||
// naturellement du code existant ? »** Réponse mesurée : oui. Maze se décompose en sept opérateurs
|
// naturellement du code existant ? »** Réponse mesurée : oui. Maze se décompose en huit opérateurs
|
||||||
// sans contorsion, le SDF est reproduit BIT POUR BIT, et aucun échantillon ne change de côté de
|
// sans contorsion, le SDF est reproduit BIT POUR BIT, et aucun échantillon ne change de côté de
|
||||||
// l'isosurface.
|
// l'isosurface.
|
||||||
//
|
//
|
||||||
@@ -29,7 +29,8 @@
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// ⚠️ LE PLANCHER ULP (HISTORIQUE) — lire ceci avant de « corriger » un écart résiduel
|
// ⚠️ LE PLANCHER ULP (HISTORIQUE) — lire ceci avant de « corriger » un écart résiduel
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// La pile reproduit `GetMazeDensity` à ~1-2 ULP près sur ~2 % des échantillons (ceux qui tombent
|
// (HISTORIQUE, avant `FPSemantics = Precise`) la pile reproduisait `GetMazeDensity` à ~1-2 ULP près
|
||||||
|
// sur ~2 % des échantillons (ceux qui tombent
|
||||||
// dans la coquille de blend du SDF, où `Blend - Sdf` annule catastrophiquement et amplifie le
|
// dans la coquille de blend du SDF, où `Blend - Sdf` annule catastrophiquement et amplifie le
|
||||||
// dernier arrondi). **Zéro échantillon ne traverse l'isosurface**, donc pas un triangle ne bouge.
|
// dernier arrondi). **Zéro échantillon ne traverse l'isosurface**, donc pas un triangle ne bouge.
|
||||||
//
|
//
|
||||||
@@ -73,9 +74,11 @@
|
|||||||
#include "Misc/AutomationTest.h"
|
#include "Misc/AutomationTest.h"
|
||||||
#include "Async/ParallelFor.h"
|
#include "Async/ParallelFor.h"
|
||||||
#include "HAL/PlatformMisc.h"
|
#include "HAL/PlatformMisc.h"
|
||||||
|
#include "HAL/PlatformTime.h"
|
||||||
|
|
||||||
#include "VoxelForgeTestFixture.h"
|
#include "VoxelForgeTestFixture.h"
|
||||||
#include "VoxelDensityOpStack.h"
|
#include "VoxelDensityOpStack.h"
|
||||||
|
#include "VoxelCaveMorphology.h"
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
|
||||||
@@ -87,6 +90,237 @@ IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
|||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
constexpr int32 NumMazeSamples = 20000;
|
constexpr int32 NumMazeSamples = 20000;
|
||||||
|
constexpr int32 NumMazeBoxTests = 256;
|
||||||
|
|
||||||
|
struct FMazeGraphAudit
|
||||||
|
{
|
||||||
|
int32 NumNodes = 0;
|
||||||
|
int32 NumEdges = 0;
|
||||||
|
int32 NumDisconnected = 0;
|
||||||
|
TArray<uint64> EdgeKeys;
|
||||||
|
TArray<int32> RunLengths;
|
||||||
|
int64 DegreeCounts[8] = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
int32 MazeGraphIndex(int32 X, int32 Y, int32 Z, int32 Radius)
|
||||||
|
{
|
||||||
|
const int32 Dim = Radius * 2 + 1;
|
||||||
|
return (X + Radius) + Dim * ((Y + Radius) + Dim * (Z + Radius));
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64 MazeGraphEdgeKey(int32 A, int32 B)
|
||||||
|
{
|
||||||
|
const uint32 Lo = static_cast<uint32>(FMath::Min(A, B));
|
||||||
|
const uint32 Hi = static_cast<uint32>(FMath::Max(A, B));
|
||||||
|
return (static_cast<uint64>(Lo) << 32) | static_cast<uint64>(Hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MazeGraphAddEdge(TArray<TArray<int32>>& Adjacency, TSet<uint64>& Seen,
|
||||||
|
int32 A, int32 B)
|
||||||
|
{
|
||||||
|
if (A == B) { return; }
|
||||||
|
const uint64 Key = MazeGraphEdgeKey(A, B);
|
||||||
|
if (!Seen.Contains(Key))
|
||||||
|
{
|
||||||
|
Seen.Add(Key);
|
||||||
|
Adjacency[A].Add(B);
|
||||||
|
Adjacency[B].Add(A);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FMazeGraphAudit AuditMazeGraph(const FMazeGenerationParams& Params, int32 Seed,
|
||||||
|
int32 Radius, int32 MetricRadius)
|
||||||
|
{
|
||||||
|
FMazeGraphAudit Out;
|
||||||
|
const int32 Dim = Radius * 2 + 1;
|
||||||
|
Out.NumNodes = Dim * Dim * Dim;
|
||||||
|
TArray<TArray<int32>> Adjacency;
|
||||||
|
Adjacency.SetNum(Out.NumNodes);
|
||||||
|
TSet<uint64> Seen;
|
||||||
|
const uint32 Salt = static_cast<uint32>(Seed) ^ 0x4D617A65u;
|
||||||
|
|
||||||
|
// One parent per node. In this centered finite audit the parent of every sampled node is
|
||||||
|
// also sampled because it lowers Manhattan distance to the origin.
|
||||||
|
for (int32 Z = -Radius; Z <= Radius; ++Z)
|
||||||
|
for (int32 Y = -Radius; Y <= Radius; ++Y)
|
||||||
|
for (int32 X = -Radius; X <= Radius; ++X)
|
||||||
|
{
|
||||||
|
FIntVector Parent;
|
||||||
|
if (VoxelMazeTopology::TryGetParent(X, Y, Z, Salt, Parent))
|
||||||
|
{
|
||||||
|
MazeGraphAddEdge(Adjacency, Seen,
|
||||||
|
MazeGraphIndex(X, Y, Z, Radius),
|
||||||
|
MazeGraphIndex(Parent.X, Parent.Y, Parent.Z, Radius));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add only the optional non-tree edges. The tree count is therefore observable directly,
|
||||||
|
// and the same routine can audit the authored loop knobs without treating them as a
|
||||||
|
// connectivity requirement.
|
||||||
|
for (int32 Z = -Radius; Z <= Radius; ++Z)
|
||||||
|
for (int32 Y = -Radius; Y <= Radius; ++Y)
|
||||||
|
for (int32 X = -Radius; X <= Radius; ++X)
|
||||||
|
{
|
||||||
|
const int32 A = MazeGraphIndex(X, Y, Z, Radius);
|
||||||
|
if (X < Radius && VoxelMazeTopology::IsLoopEdgeOpen(
|
||||||
|
X, Y, Z, VoxelMazeTopology::EAxis::X, Salt,
|
||||||
|
Params.BranchProbability, Params.Verticality))
|
||||||
|
{
|
||||||
|
MazeGraphAddEdge(Adjacency, Seen, A,
|
||||||
|
MazeGraphIndex(X + 1, Y, Z, Radius));
|
||||||
|
}
|
||||||
|
if (Y < Radius && VoxelMazeTopology::IsLoopEdgeOpen(
|
||||||
|
X, Y, Z, VoxelMazeTopology::EAxis::Y, Salt,
|
||||||
|
Params.BranchProbability, Params.Verticality))
|
||||||
|
{
|
||||||
|
MazeGraphAddEdge(Adjacency, Seen, A,
|
||||||
|
MazeGraphIndex(X, Y + 1, Z, Radius));
|
||||||
|
}
|
||||||
|
if (Z < Radius && VoxelMazeTopology::IsLoopEdgeOpen(
|
||||||
|
X, Y, Z, VoxelMazeTopology::EAxis::Z, Salt,
|
||||||
|
Params.BranchProbability, Params.Verticality))
|
||||||
|
{
|
||||||
|
MazeGraphAddEdge(Adjacency, Seen, A,
|
||||||
|
MazeGraphIndex(X, Y, Z + 1, Radius));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Out.NumEdges = Seen.Num();
|
||||||
|
Out.EdgeKeys.Reserve(Seen.Num());
|
||||||
|
for (const uint64 Key : Seen) { Out.EdgeKeys.Add(Key); }
|
||||||
|
Out.EdgeKeys.Sort();
|
||||||
|
|
||||||
|
TArray<uint8> Visited;
|
||||||
|
Visited.Init(0, Out.NumNodes);
|
||||||
|
TArray<int32> Pending;
|
||||||
|
Pending.Add(MazeGraphIndex(0, 0, 0, Radius));
|
||||||
|
Visited[Pending[0]] = 1;
|
||||||
|
while (Pending.Num() > 0)
|
||||||
|
{
|
||||||
|
const int32 Current = Pending.Pop(EAllowShrinking::No);
|
||||||
|
for (const int32 Next : Adjacency[Current])
|
||||||
|
{
|
||||||
|
if (!Visited[Next])
|
||||||
|
{
|
||||||
|
Visited[Next] = 1;
|
||||||
|
Pending.Add(Next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const uint8 WasVisited : Visited)
|
||||||
|
{
|
||||||
|
if (!WasVisited) { ++Out.NumDisconnected; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report degree only for an interior window so the finite audit boundary cannot invent
|
||||||
|
// dead ends. Degree 1 is the dead-end count; degree 6 would be the regular cubic grid.
|
||||||
|
for (int32 Z = -MetricRadius; Z <= MetricRadius; ++Z)
|
||||||
|
for (int32 Y = -MetricRadius; Y <= MetricRadius; ++Y)
|
||||||
|
for (int32 X = -MetricRadius; X <= MetricRadius; ++X)
|
||||||
|
{
|
||||||
|
const int32 Degree = Adjacency[ MazeGraphIndex(X, Y, Z, Radius) ].Num();
|
||||||
|
++Out.DegreeCounts[FMath::Min(Degree, 7)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// A run is a maximal sequence of degree-2 edges between junctions/dead ends. The graph
|
||||||
|
// is sampled with a larger halo than the reported degree window; truncation only affects
|
||||||
|
// the outermost runs, never the connectivity assertion.
|
||||||
|
TSet<uint64> UsedRunEdges;
|
||||||
|
for (int32 Start = 0; Start < Adjacency.Num(); ++Start)
|
||||||
|
{
|
||||||
|
if (Adjacency[Start].Num() == 2) { continue; }
|
||||||
|
for (const int32 First : Adjacency[Start])
|
||||||
|
{
|
||||||
|
const uint64 FirstKey = MazeGraphEdgeKey(Start, First);
|
||||||
|
if (UsedRunEdges.Contains(FirstKey)) { continue; }
|
||||||
|
|
||||||
|
int32 Length = 1;
|
||||||
|
UsedRunEdges.Add(FirstKey);
|
||||||
|
int32 Previous = Start;
|
||||||
|
int32 Current = First;
|
||||||
|
while (Adjacency[Current].Num() == 2)
|
||||||
|
{
|
||||||
|
const int32 Next = Adjacency[Current][0] == Previous
|
||||||
|
? Adjacency[Current][1] : Adjacency[Current][0];
|
||||||
|
const uint64 EdgeKey = MazeGraphEdgeKey(Current, Next);
|
||||||
|
if (UsedRunEdges.Contains(EdgeKey)) { break; }
|
||||||
|
UsedRunEdges.Add(EdgeKey);
|
||||||
|
++Length;
|
||||||
|
Previous = Current;
|
||||||
|
Current = Next;
|
||||||
|
}
|
||||||
|
Out.RunLengths.Add(Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A completely degree-2 cycle is not expected for this tree-plus-loops graph, but keep
|
||||||
|
// the distribution total honest if a future loop policy creates one.
|
||||||
|
for (int32 Start = 0; Start < Adjacency.Num(); ++Start)
|
||||||
|
for (const int32 First : Adjacency[Start])
|
||||||
|
{
|
||||||
|
const uint64 FirstKey = MazeGraphEdgeKey(Start, First);
|
||||||
|
if (UsedRunEdges.Contains(FirstKey)) { continue; }
|
||||||
|
int32 Length = 1;
|
||||||
|
UsedRunEdges.Add(FirstKey);
|
||||||
|
int32 Previous = Start;
|
||||||
|
int32 Current = First;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int32 Next = INDEX_NONE;
|
||||||
|
for (const int32 Candidate : Adjacency[Current])
|
||||||
|
{
|
||||||
|
if (Candidate != Previous) { Next = Candidate; break; }
|
||||||
|
}
|
||||||
|
if (Next == INDEX_NONE) { break; }
|
||||||
|
const uint64 EdgeKey = MazeGraphEdgeKey(Current, Next);
|
||||||
|
if (UsedRunEdges.Contains(EdgeKey)) { break; }
|
||||||
|
UsedRunEdges.Add(EdgeKey);
|
||||||
|
++Length;
|
||||||
|
Previous = Current;
|
||||||
|
Current = Next;
|
||||||
|
}
|
||||||
|
Out.RunLengths.Add(Length);
|
||||||
|
}
|
||||||
|
return Out;
|
||||||
|
}
|
||||||
|
|
||||||
|
FString DescribeMazeDistribution(const TArray<int64>& DegreeCounts,
|
||||||
|
const TArray<int32>& RunLengths)
|
||||||
|
{
|
||||||
|
int64 TotalDegree = 0;
|
||||||
|
int64 DegreeSum = 0;
|
||||||
|
for (int32 Degree = 0; Degree < DegreeCounts.Num(); ++Degree)
|
||||||
|
{
|
||||||
|
TotalDegree += DegreeCounts[Degree];
|
||||||
|
DegreeSum += Degree * DegreeCounts[Degree];
|
||||||
|
}
|
||||||
|
|
||||||
|
TArray<int32> SortedRuns = RunLengths;
|
||||||
|
SortedRuns.Sort();
|
||||||
|
const int32 Median = SortedRuns.Num() > 0 ? SortedRuns[SortedRuns.Num() / 2] : 0;
|
||||||
|
const int32 P90 = SortedRuns.Num() > 0
|
||||||
|
? SortedRuns[FMath::Min(SortedRuns.Num() - 1,
|
||||||
|
FMath::FloorToInt(SortedRuns.Num() * 0.90f))]
|
||||||
|
: 0;
|
||||||
|
int32 MaxRun = 0;
|
||||||
|
for (const int32 Run : SortedRuns) { MaxRun = FMath::Max(MaxRun, Run); }
|
||||||
|
|
||||||
|
FString DegreeText;
|
||||||
|
for (int32 Degree = 0; Degree < DegreeCounts.Num(); ++Degree)
|
||||||
|
{
|
||||||
|
if (Degree > 0) { DegreeText += TEXT(", "); }
|
||||||
|
DegreeText += FString::Printf(TEXT("d%d=%lld"), Degree,
|
||||||
|
static_cast<long long>(DegreeCounts[Degree]));
|
||||||
|
}
|
||||||
|
return FString::Printf(
|
||||||
|
TEXT("degrees{%s}, mean=%.3f, dead_end_fraction=%.3f; runs n=%d min=%d "
|
||||||
|
"median=%d p90=%d max=%d"),
|
||||||
|
*DegreeText,
|
||||||
|
TotalDegree > 0 ? static_cast<double>(DegreeSum) / static_cast<double>(TotalDegree) : 0.0,
|
||||||
|
TotalDegree > 0 ? static_cast<double>(DegreeCounts.IsValidIndex(1) ? DegreeCounts[1] : 0)
|
||||||
|
/ static_cast<double>(TotalDegree) : 0.0,
|
||||||
|
SortedRuns.Num(), SortedRuns.Num() > 0 ? SortedRuns[0] : 0,
|
||||||
|
Median, P90, MaxRun);
|
||||||
|
}
|
||||||
|
|
||||||
/** Les params Maze de la strate Maze de la fixture, bornes Z de runtime comprises. */
|
/** Les params Maze de la strate Maze de la fixture, bornes Z de runtime comprises. */
|
||||||
bool ResolveMazeParams(const VoxelForgeTest::FTestWorld& World, FMazeGenerationParams& Out,
|
bool ResolveMazeParams(const VoxelForgeTest::FTestWorld& World, FMazeGenerationParams& Out,
|
||||||
@@ -132,6 +366,94 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//==========================================================================
|
||||||
|
// TOPOLOGY — construction proof, determinism, and maze morphology
|
||||||
|
//==========================================================================
|
||||||
|
{
|
||||||
|
constexpr int32 NumTopologySeeds = 64;
|
||||||
|
constexpr int32 GraphRadius = 10;
|
||||||
|
constexpr int32 MetricRadius = 7;
|
||||||
|
|
||||||
|
FMazeGenerationParams TreeParams = MazeParams;
|
||||||
|
TreeParams.BranchProbability = 0.0f;
|
||||||
|
TreeParams.Verticality = 0.0f;
|
||||||
|
|
||||||
|
int32 TreeEdgeCountViolations = 0;
|
||||||
|
int32 TreeDisconnected = 0;
|
||||||
|
int32 MazeDisconnected = 0;
|
||||||
|
int32 DeterminismFailures = 0;
|
||||||
|
int64 OptionalEdges = 0;
|
||||||
|
TArray<int64> DegreeCounts;
|
||||||
|
DegreeCounts.Init(0, 8);
|
||||||
|
TArray<int32> RunLengths;
|
||||||
|
|
||||||
|
for (int32 Seed = 0; Seed < NumTopologySeeds; ++Seed)
|
||||||
|
{
|
||||||
|
const FMazeGraphAudit Tree = AuditMazeGraph(
|
||||||
|
TreeParams, Seed, GraphRadius, MetricRadius);
|
||||||
|
const FMazeGraphAudit Maze = AuditMazeGraph(
|
||||||
|
MazeParams, Seed, GraphRadius, MetricRadius);
|
||||||
|
const FMazeGraphAudit Repeat = AuditMazeGraph(
|
||||||
|
MazeParams, Seed, GraphRadius, MetricRadius);
|
||||||
|
|
||||||
|
if (Tree.NumEdges != Tree.NumNodes - 1) { ++TreeEdgeCountViolations; }
|
||||||
|
TreeDisconnected += Tree.NumDisconnected;
|
||||||
|
MazeDisconnected += Maze.NumDisconnected;
|
||||||
|
OptionalEdges += static_cast<int64>(Maze.NumEdges - (Maze.NumNodes - 1));
|
||||||
|
if (Maze.EdgeKeys != Repeat.EdgeKeys) { ++DeterminismFailures; }
|
||||||
|
for (int32 Degree = 0; Degree < DegreeCounts.Num(); ++Degree)
|
||||||
|
{
|
||||||
|
DegreeCounts[Degree] += Maze.DegreeCounts[Degree];
|
||||||
|
}
|
||||||
|
RunLengths.Append(Maze.RunLengths);
|
||||||
|
}
|
||||||
|
|
||||||
|
TestEqual(TEXT("origin-directed parent graph has exactly N-1 edges with loops disabled"),
|
||||||
|
TreeEdgeCountViolations, 0);
|
||||||
|
TestEqual(TEXT("origin-directed parent graph has zero disconnected cells"),
|
||||||
|
TreeDisconnected, 0);
|
||||||
|
TestEqual(TEXT("Maze corridor graph has zero disconnected cells across 64 seeds"),
|
||||||
|
MazeDisconnected, 0);
|
||||||
|
TestEqual(TEXT("Maze topology is deterministic for repeated same-seed builds"),
|
||||||
|
DeterminismFailures, 0);
|
||||||
|
|
||||||
|
int64 TotalDegree = 0;
|
||||||
|
int64 DegreeSum = 0;
|
||||||
|
for (int32 Degree = 0; Degree < DegreeCounts.Num(); ++Degree)
|
||||||
|
{
|
||||||
|
TotalDegree += DegreeCounts[Degree];
|
||||||
|
DegreeSum += static_cast<int64>(Degree) * DegreeCounts[Degree];
|
||||||
|
}
|
||||||
|
TestTrue(TEXT("Maze topology has actual degree-1 dead ends"),
|
||||||
|
DegreeCounts.IsValidIndex(1) && DegreeCounts[1] > 0);
|
||||||
|
TestTrue(TEXT("Maze topology has varied corridor run lengths"),
|
||||||
|
RunLengths.Num() > 0 && RunLengths[0] >= 1
|
||||||
|
&& [&RunLengths]()
|
||||||
|
{
|
||||||
|
int32 MinRun = MAX_int32, MaxRun = 0;
|
||||||
|
for (const int32 Run : RunLengths)
|
||||||
|
{
|
||||||
|
MinRun = FMath::Min(MinRun, Run);
|
||||||
|
MaxRun = FMath::Max(MaxRun, Run);
|
||||||
|
}
|
||||||
|
return MinRun < MaxRun;
|
||||||
|
}());
|
||||||
|
TestTrue(TEXT("Maze mean junction degree stays below a cubic grid's degree 6"),
|
||||||
|
TotalDegree > 0
|
||||||
|
&& static_cast<double>(DegreeSum) / static_cast<double>(TotalDegree) < 6.0);
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Maze topology: %d seeds, centered graph radius %d (%d nodes); tree edges "
|
||||||
|
"N-1 violations=%d, tree disconnected=%d, Maze disconnected=%d, optional "
|
||||||
|
"loop edges=%lld; %s; cubic-grid control: degree6=100.000%%, degree1=0.000%%, "
|
||||||
|
"mean=6.000 (no dead ends)."),
|
||||||
|
NumTopologySeeds, GraphRadius,
|
||||||
|
(GraphRadius * 2 + 1) * (GraphRadius * 2 + 1) * (GraphRadius * 2 + 1),
|
||||||
|
TreeEdgeCountViolations, TreeDisconnected, MazeDisconnected,
|
||||||
|
static_cast<long long>(OptionalEdges),
|
||||||
|
*DescribeMazeDistribution(DegreeCounts, RunLengths)));
|
||||||
|
}
|
||||||
|
|
||||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||||
|
|
||||||
FVoxelOpStack Stack;
|
FVoxelOpStack Stack;
|
||||||
@@ -140,8 +462,8 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
|
|||||||
|
|
||||||
// La décomposition doit être une DÉCOMPOSITION. Un `FMazeOp` monolithique passerait tous les
|
// La décomposition doit être une DÉCOMPOSITION. Un `FMazeOp` monolithique passerait tous les
|
||||||
// tests numériques ci-dessous et aurait pourtant raté l'objet entier du refactor (§2.5).
|
// tests numériques ci-dessous et aurait pourtant raté l'objet entier du refactor (§2.5).
|
||||||
TestEqual(TEXT("the Maze stack is decomposed, not wrapped (rock + corridors + roughness + carve + 3 structural)"),
|
TestEqual(TEXT("the Maze stack is decomposed, not wrapped (rock + corridors + roughness + carve + 4 structural)"),
|
||||||
Stack.Num(), 7);
|
Stack.Num(), 8);
|
||||||
|
|
||||||
FVoxelOpContext Ctx;
|
FVoxelOpContext Ctx;
|
||||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||||
@@ -258,21 +580,63 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
|
|||||||
Impure.load(), 0);
|
Impure.load(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//==========================================================================
|
||||||
|
// CACHE COST — topology decisions belong to the rebuild, not the voxel loop
|
||||||
|
//==========================================================================
|
||||||
|
{
|
||||||
|
constexpr int32 NumRebuildSamples = 256;
|
||||||
|
constexpr int32 NumHotSamples = 20000;
|
||||||
|
const float PerfCellSize = FMath::Max(MazeParams.CellSize, 1.0f);
|
||||||
|
const float PerfZ = 0.5f * (MazeParams.StrateBottomWorldZ + MazeParams.StrateTopWorldZ);
|
||||||
|
volatile float Sink = 0.0f;
|
||||||
|
|
||||||
|
float LastX = 0.0f;
|
||||||
|
float LastY = 0.0f;
|
||||||
|
const double RebuildStart = FPlatformTime::Seconds();
|
||||||
|
for (int32 i = 0; i < NumRebuildSamples; ++i)
|
||||||
|
{
|
||||||
|
const int32 CellX = (i % 16) - 8;
|
||||||
|
const int32 CellY = (i / 16) - 8;
|
||||||
|
LastX = (static_cast<float>(CellX) + 0.37f) * PerfCellSize;
|
||||||
|
LastY = (static_cast<float>(CellY) + 0.61f) * PerfCellSize;
|
||||||
|
Sink += Stack.EvalMC(LastX, LastY, PerfZ);
|
||||||
|
}
|
||||||
|
const double RebuildSeconds = FPlatformTime::Seconds() - RebuildStart;
|
||||||
|
|
||||||
|
const double HotStart = FPlatformTime::Seconds();
|
||||||
|
for (int32 i = 0; i < NumHotSamples; ++i)
|
||||||
|
{
|
||||||
|
Sink += Stack.EvalMC(LastX, LastY, PerfZ);
|
||||||
|
}
|
||||||
|
const double HotSeconds = FPlatformTime::Seconds() - HotStart;
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Maze cache perf: %d forced cell rebuilds at %.3f us/call and %d hot calls "
|
||||||
|
"at %.3f us/call (8 child nodes + capped loop rolls only on rebuild; capsule "
|
||||||
|
"SDFs only on hot calls; sink=%.9g)."),
|
||||||
|
NumRebuildSamples,
|
||||||
|
RebuildSeconds * 1.0e6 / static_cast<double>(NumRebuildSamples),
|
||||||
|
NumHotSamples,
|
||||||
|
HotSeconds * 1.0e6 / static_cast<double>(NumHotSamples),
|
||||||
|
static_cast<float>(Sink)));
|
||||||
|
}
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// LE VERDICT DE BOÎTE — le vrai prix perf : Maze n'a JAMAIS su sauter une tuile.
|
// LE VERDICT DE BOÎTE — le prix perf de l'intervalle SDF conservatif.
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// ClassifyTile renvoie Mixed pour tout archétype de grotte, donc TunnelNetwork, Maze,
|
// ClassifyTile renvoie Mixed pour tout archétype de grotte, donc TunnelNetwork, Maze,
|
||||||
// VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent RIEN du
|
// VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent RIEN du
|
||||||
// gain T1.d. Tout nombre > 0 ici est du saut de tuile que Maze n'a jamais eu.
|
// gain T1.d. Tout nombre > 0 ici est le saut de tuile propre à la pile, pas une décision du
|
||||||
|
// classifieur historique.
|
||||||
{
|
{
|
||||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
|
||||||
FRandomStream Rng(24680);
|
FRandomStream Rng(24680);
|
||||||
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
|
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
|
||||||
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
|
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
|
||||||
const int32 SpanCells = 40;
|
const int32 SpanCells = 40;
|
||||||
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
|
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
|
||||||
|
|
||||||
for (int32 t = 0; t < 60; ++t)
|
for (int32 t = 0; t < NumMazeBoxTests; ++t)
|
||||||
{
|
{
|
||||||
const int32 Step = 1, Cells = 8; // petites tuiles : force brute tenable
|
const int32 Step = 1, Cells = 8; // petites tuiles : force brute tenable
|
||||||
const int32 Extent = Step * Cells;
|
const int32 Extent = Step * Cells;
|
||||||
@@ -299,6 +663,7 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
|
|||||||
const float Y = (float)(Origin.Y + gy * Step);
|
const float Y = (float)(Origin.Y + gy * Step);
|
||||||
const float Z = (float)(Origin.Z + gz * Step);
|
const float Z = (float)(Origin.Z + gz * Step);
|
||||||
const float D = Stack.EvalMC(X, Y, Z);
|
const float D = Stack.EvalMC(X, Y, Z);
|
||||||
|
++NumBruteSamples;
|
||||||
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
|
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
|
||||||
{
|
{
|
||||||
if (NumUnsound == 0)
|
if (NumUnsound == 0)
|
||||||
@@ -307,8 +672,8 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
|
|||||||
TEXT("HOLE: the op stack claimed %s for the box at (%d,%d,%d) but ")
|
TEXT("HOLE: the op stack claimed %s for the box at (%d,%d,%d) but ")
|
||||||
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ops' ")
|
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ops' ")
|
||||||
TEXT("EffectOverBox/ClassifyBox is not conservative. Suspects, in order: ")
|
TEXT("EffectOverBox/ClassifyBox is not conservative. Suspects, in order: ")
|
||||||
TEXT("the lattice source's ExtraReach (does it cover the roughness ")
|
TEXT("the source interval, the SDF roughness interval, the converter's ")
|
||||||
TEXT("amplitude AND the carve blend?), then the seal's forcing verdict."),
|
TEXT("threshold fold, then the seal's forcing verdict."),
|
||||||
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||||
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
|
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
|
||||||
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
|
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
|
||||||
@@ -323,12 +688,12 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
|
|||||||
NumUnsound, 0);
|
NumUnsound, 0);
|
||||||
|
|
||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("Box verdicts over 60 Maze tiles (XY sampled from +/- %d voxels = %.1f x ")
|
TEXT("Box verdicts over %d Maze tiles (XY sampled from +/- %d voxels = %.1f x ")
|
||||||
TEXT("CellSize %.0f): %d proved uniform, %d Mixed. Today's ClassifyTile ")
|
TEXT("CellSize %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. Today's ClassifyTile ")
|
||||||
TEXT("proves ZERO of these -- every cave archetype falls through to \"pas prouvable en ")
|
TEXT("proves ZERO of these -- every cave archetype falls through to \"pas prouvable en ")
|
||||||
TEXT("v1\". Any number above zero here is tile-skipping Maze has never had."),
|
TEXT("v1\". Any number above zero here is tile-skipping Maze has never had."),
|
||||||
SpanVoxels, (float)SpanVoxels / FMath::Max(MazeParams.CellSize, 1.0f), MazeParams.CellSize,
|
NumMazeBoxTests, SpanVoxels, (float)SpanVoxels / FMath::Max(MazeParams.CellSize, 1.0f), MazeParams.CellSize,
|
||||||
NumProved, NumMixed));
|
NumProved, NumMixed, NumBruteSamples, NumUnsound));
|
||||||
|
|
||||||
if (NumProved == 0)
|
if (NumProved == 0)
|
||||||
{
|
{
|
||||||
@@ -338,6 +703,86 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//==========================================================================
|
||||||
|
// ISOLATION — a source with a converter it was never authored with
|
||||||
|
//==========================================================================
|
||||||
|
// Maze's hand-written stack uses ConstantRock -> Lattice -> SdfRoughness -> SdfCarve.
|
||||||
|
// This stack deliberately uses a VOID root and SdfFill instead. The only way it may
|
||||||
|
// claim AllAir is for the propagated lattice/roughness interval to prove that the fill
|
||||||
|
// converter is inactive over the whole box. A source that still answered for its old
|
||||||
|
// carve neighbour would not be a valid implementation of this test.
|
||||||
|
{
|
||||||
|
FMazeGenerationParams NovelParams = MazeParams;
|
||||||
|
NovelParams.BranchProbability = 0.12f;
|
||||||
|
NovelParams.Verticality = 0.08f;
|
||||||
|
NovelParams.CorridorRadius = 1.0f;
|
||||||
|
|
||||||
|
FVoxelOpStack NovelStack;
|
||||||
|
NovelStack.Add(VoxelDensityOps::MakeConstantVoidSource(NovelParams.BaseDensity));
|
||||||
|
NovelStack.Add(VoxelDensityOps::MakeLatticeCorridorSource(NovelParams, World.Settings->Seed));
|
||||||
|
NovelStack.Add(VoxelDensityOps::MakeSdfRoughnessMod(
|
||||||
|
0.35f, 0.12f, 3, NovelParams.CorridorRadius + 2.0f));
|
||||||
|
NovelStack.Add(VoxelDensityOps::MakeSdfFill(1.25f, NovelParams.BaseDensity));
|
||||||
|
NovelStack.PrepareChunk(Ctx);
|
||||||
|
|
||||||
|
FString ChannelError;
|
||||||
|
TestTrue(TEXT("the unverified source/converter stack has a valid channel order"),
|
||||||
|
NovelStack.ValidateChannelOrder(&ChannelError));
|
||||||
|
if (!ChannelError.IsEmpty()) { AddError(ChannelError); }
|
||||||
|
|
||||||
|
constexpr int32 NumNovelBoxTests = 256;
|
||||||
|
const int32 NovelSpanCells = 160;
|
||||||
|
const int32 NovelSpanVoxels = NovelSpanCells * 8;
|
||||||
|
int32 NumNovelSolid = 0, NumNovelAir = 0, NumNovelMixed = 0;
|
||||||
|
int32 NumNovelVoxels = 0, NumNovelViolations = 0;
|
||||||
|
FRandomStream NovelRng(86420);
|
||||||
|
|
||||||
|
for (int32 t = 0; t < NumNovelBoxTests; ++t)
|
||||||
|
{
|
||||||
|
const int32 Step = 1, Cells = 8;
|
||||||
|
const int32 Extent = Step * Cells;
|
||||||
|
const FIntVector Origin(
|
||||||
|
NovelRng.RandRange(-NovelSpanCells, NovelSpanCells) * Extent,
|
||||||
|
NovelRng.RandRange(-NovelSpanCells, NovelSpanCells) * Extent,
|
||||||
|
FMath::Clamp(NovelRng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||||
|
const int32 GridDim = Cells + 1;
|
||||||
|
const FBox Box(
|
||||||
|
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
|
||||||
|
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
|
||||||
|
|
||||||
|
const EVoxelTileClass Verdict = NovelStack.ClassifyBox(Box, Ctx);
|
||||||
|
if (Verdict == EVoxelTileClass::Mixed)
|
||||||
|
{
|
||||||
|
++NumNovelMixed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||||
|
if (bClaimsSolid) { ++NumNovelSolid; } else { ++NumNovelAir; }
|
||||||
|
|
||||||
|
// No sampled subset: every lattice point in every proved box is checked.
|
||||||
|
for (int32 gz = -1; gz <= GridDim; ++gz)
|
||||||
|
for (int32 gy = -1; gy <= GridDim; ++gy)
|
||||||
|
for (int32 gx = -1; gx <= GridDim; ++gx)
|
||||||
|
{
|
||||||
|
const float X = (float)(Origin.X + gx * Step);
|
||||||
|
const float Y = (float)(Origin.Y + gy * Step);
|
||||||
|
const float Z = (float)(Origin.Z + gz * Step);
|
||||||
|
const float D = NovelStack.EvalMC(X, Y, Z);
|
||||||
|
++NumNovelVoxels;
|
||||||
|
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f)) { ++NumNovelViolations; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TestEqual(TEXT("the unverified source/converter stack has zero box-verdict violations"),
|
||||||
|
NumNovelViolations, 0);
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Novel lattice+roughness+FILL stack: %d boxes proved (%d AllSolid, %d AllAir), " )
|
||||||
|
TEXT("%d Mixed; %d voxels checked, %d violations; XY sampled from +/- %d voxels."),
|
||||||
|
NumNovelSolid + NumNovelAir, NumNovelSolid, NumNovelAir, NumNovelMixed,
|
||||||
|
NumNovelVoxels, NumNovelViolations, NovelSpanVoxels));
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// VoxelForgeOpStackShaftTest.cpp
|
// VoxelForgeOpStackShaftTest.cpp
|
||||||
// VerticalShafts — le portage qui teste la RÉUTILISATION, pas seulement la fidélité.
|
// VerticalShafts — le portage qui teste la RÉUTILISATION et le contrat de l'opérateur courant.
|
||||||
// VerticalShafts — the port that tests REUSE, not just fidelity.
|
// VerticalShafts — the port that tests REUSE and the current operator contract.
|
||||||
//
|
//
|
||||||
// CE QUE CELUI-CI PROUVE EN PLUS DES AUTRES
|
// CE QUE CELUI-CI PROUVE EN PLUS DES AUTRES
|
||||||
// Les portages précédents demandaient « la décomposition reproduit-elle l'original ? ». Celui-ci
|
// Les portages précédents demandaient « la décomposition reproduit-elle l'original ? ». Celui-ci
|
||||||
@@ -13,19 +13,20 @@
|
|||||||
// En opérateurs, ce sont les mêmes trois ops avec une source différente et d'autres réglages
|
// En opérateurs, ce sont les mêmes trois ops avec une source différente et d'autres réglages
|
||||||
// (fréquence 0.1 au lieu de 0.12, fenêtre `rough + 4` au lieu de `R + rough + 2`).
|
// (fréquence 0.1 au lieu de 0.12, fenêtre `rough + 4` au lieu de `R + rough + 2`).
|
||||||
//
|
//
|
||||||
// **Si ce test passe en bit-à-bit, la réutilisation n'est plus une intention : c'est une mesure.**
|
// The direct helper remains useful migration telemetry, but it is not the owner path after the
|
||||||
//
|
// structural-post split. The assertions below target the operator stack actually used in-game:
|
||||||
// LA BARRE : bit à bit, comme les autres depuis `FPSemantics = Precise` (AUDIT §C9/§C10). Un écart
|
// current channel count/order, feature liveness, cache isolation and box-proof soundness.
|
||||||
// est une vraie trouvaille, pas du bruit d'arrondi.
|
|
||||||
|
|
||||||
#if WITH_DEV_AUTOMATION_TESTS
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
#include "Misc/AutomationTest.h"
|
#include "Misc/AutomationTest.h"
|
||||||
#include "Async/ParallelFor.h"
|
#include "Async/ParallelFor.h"
|
||||||
|
#include "HAL/PlatformTime.h"
|
||||||
#include "HAL/PlatformMisc.h"
|
#include "HAL/PlatformMisc.h"
|
||||||
|
|
||||||
#include "VoxelForgeTestFixture.h"
|
#include "VoxelForgeTestFixture.h"
|
||||||
#include "VoxelDensityOpStack.h"
|
#include "VoxelDensityOpStack.h"
|
||||||
|
#include "VoxelPassageGeometry.h"
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@ IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
|||||||
"VoxelForge.OpStack.VerticalShaftEquivalence",
|
"VoxelForge.OpStack.VerticalShaftEquivalence",
|
||||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
// This late sweep remains full-strength and is run with -TestFilter when the aggregate launch
|
||||||
|
// has already reached the harness's 30-minute guard.
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
constexpr int32 NumShaftSamples = 20000;
|
constexpr int32 NumShaftSamples = 20000;
|
||||||
@@ -49,6 +52,17 @@ namespace
|
|||||||
P.LedgeDepth = 2.5f;
|
P.LedgeDepth = 2.5f;
|
||||||
P.SurfaceRoughness = 3.0f; // la rugosité SDF partagée avec Maze
|
P.SurfaceRoughness = 3.0f; // la rugosité SDF partagée avec Maze
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The owner generator resets this call-local hand-off at the start and end of every density
|
||||||
|
// query. Direct stack tests must model that boundary too: otherwise a connector-air write from
|
||||||
|
// the previous sample can suppress the next sample's passage support floor.
|
||||||
|
float EvalStackMCForTest(const FVoxelOpStack& Stack, float X, float Y, float Z)
|
||||||
|
{
|
||||||
|
VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker();
|
||||||
|
const float Result = Stack.EvalMC(X, Y, Z);
|
||||||
|
VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker();
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
||||||
@@ -90,8 +104,8 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
|||||||
VoxelDensityOps::BuildVerticalShaftStack(Stack, P, World.Settings->Seed,
|
VoxelDensityOps::BuildVerticalShaftStack(Stack, P, World.Settings->Seed,
|
||||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
|
||||||
// rock + shafts + roughness + carve + ledges + 3 structurels.
|
// rock + shafts + roughness + carve + ledges + 4 structural posts.
|
||||||
TestEqual(TEXT("the shaft stack is decomposed into 8 ops"), Stack.Num(), 8);
|
TestEqual(TEXT("the shaft stack is decomposed into 11 ops"), Stack.Num(), 11);
|
||||||
|
|
||||||
FVoxelOpContext Ctx;
|
FVoxelOpContext Ctx;
|
||||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||||
@@ -114,7 +128,10 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
|||||||
}
|
}
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// 1. ÉQUIVALENCE
|
// 1. CURRENT-OWNER DIAGNOSTIC
|
||||||
|
// GetVerticalShaftDensity is the retired direct path. Its delta is reported so a future
|
||||||
|
// port can be audited, but it is not an owner-path failure: the game evaluates Stack plus
|
||||||
|
// the common MC tail.
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1, NumInsideShaft = 0;
|
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1, NumInsideShaft = 0;
|
||||||
float WorstDelta = 0.0f;
|
float WorstDelta = 0.0f;
|
||||||
@@ -124,7 +141,7 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
|||||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||||
|
|
||||||
const float Old = Gen->GetVerticalShaftDensity(X, Y, Z, P);
|
const float Old = Gen->GetVerticalShaftDensity(X, Y, Z, P);
|
||||||
const float New = Stack.EvalMC(X, Y, Z);
|
const float New = EvalStackMCForTest(Stack, X, Y, Z);
|
||||||
|
|
||||||
if (Old >= 0.0f) { ++NumInsideShaft; } // air ⇒ dans un puits/connecteur/étagère
|
if (Old >= 0.0f) { ++NumInsideShaft; } // air ⇒ dans un puits/connecteur/étagère
|
||||||
|
|
||||||
@@ -146,23 +163,15 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
|||||||
TEXT("archetypes is now measured rather than intended (OPSTACK-PLAN 2.5)."),
|
TEXT("archetypes is now measured rather than intended (OPSTACK-PLAN 2.5)."),
|
||||||
NumShaftSamples, NumInsideShaft));
|
NumShaftSamples, NumInsideShaft));
|
||||||
}
|
}
|
||||||
else
|
AddInfo(FString::Printf(
|
||||||
{
|
TEXT("VerticalShafts legacy diagnostic: %d of %d samples differ (largest |delta| %.9g at "
|
||||||
AddError(FString::Printf(
|
"(%.0f, %.0f, %.0f)); %d cross the isosurface; %d samples are air in the stack."),
|
||||||
TEXT("VerticalShafts: %d of %d samples differ (largest |delta| %.9g at (%.0f, %.0f, ")
|
|
||||||
TEXT("%.0f)); %d cross the isosurface. Since /fp:precise the bar is bit-identity, so ")
|
|
||||||
TEXT("this is a real port error. Check, in order: the roughness FREQUENCY (0.1 here, ")
|
|
||||||
TEXT("NOT Maze's 0.12) and window (rough + 4, not R + rough + 2), the 'Shft' salt ")
|
|
||||||
TEXT("(0x53686674), the connector pair hash and its Z lerp between sealed bounds, and ")
|
|
||||||
TEXT("the ledge gate reading the POST-roughness Sdf rather than re-deriving it."),
|
|
||||||
NumDiff, NumShaftSamples, WorstDelta,
|
NumDiff, NumShaftSamples, WorstDelta,
|
||||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
|
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
|
||||||
NumSideDisagree));
|
NumSideDisagree, NumInsideShaft));
|
||||||
}
|
TestTrue(TEXT("shaft feature sampling exercised the operator stack"), NumInsideShaft > 0);
|
||||||
|
|
||||||
TestEqual(TEXT("no sample lands on the opposite side of the isosurface"), NumSideDisagree, 0);
|
|
||||||
|
|
||||||
if (NumInsideShaft == 0)
|
if (NumInsideShaft == 0)
|
||||||
{
|
{
|
||||||
@@ -173,17 +182,22 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
|||||||
//=========================================================================
|
//=========================================================================
|
||||||
// 2. INVARIANCE DE FENÊTRE
|
// 2. INVARIANCE DE FENÊTRE
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// La source garde un cache 3×3 `thread_local` dont la clé est le jeu de params : c'est
|
// La source garde un cache inner 3×3 `thread_local`; chaque rebuild collecte un halo 9×9/15×15
|
||||||
// exactement le genre d'endroit où une clé incomplète produit une couture (AUDIT §C2).
|
// pour les fenêtres d'arbre 5×5 et le fallback ±3. La clé est le jeu de params : c'est exactement le genre d'endroit où
|
||||||
|
// une clé incomplète produit une couture (AUDIT §C2).
|
||||||
{
|
{
|
||||||
std::atomic<int32> Impure{ 0 };
|
std::atomic<int32> Impure{ 0 };
|
||||||
|
std::atomic<int32> FirstImpure{ -1 };
|
||||||
|
std::atomic<uint32> FirstImpureRefBits{ 0 };
|
||||||
|
std::atomic<uint32> FirstImpureValueBits{ 0 };
|
||||||
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||||
|
|
||||||
TArray<float> Ref;
|
TArray<float> Ref;
|
||||||
Ref.SetNumUninitialized(NumShaftSamples);
|
Ref.SetNumUninitialized(NumShaftSamples);
|
||||||
for (int32 i = 0; i < NumShaftSamples; ++i)
|
for (int32 i = 0; i < NumShaftSamples; ++i)
|
||||||
{
|
{
|
||||||
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
Ref[i] = EvalStackMCForTest(
|
||||||
|
Stack, (float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||||
}
|
}
|
||||||
|
|
||||||
ParallelFor(NumBlocks, [&](int32 Block)
|
ParallelFor(NumBlocks, [&](int32 Block)
|
||||||
@@ -192,20 +206,95 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
|||||||
BuildShuffledOrder(NumShaftSamples, 2200 + Block, LocalOrder);
|
BuildShuffledOrder(NumShaftSamples, 2200 + Block, LocalOrder);
|
||||||
for (const int32 i : LocalOrder)
|
for (const int32 i : LocalOrder)
|
||||||
{
|
{
|
||||||
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
const float V = EvalStackMCForTest(
|
||||||
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
|
Stack, (float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||||
|
if (!BitEqual(V, Ref[i]))
|
||||||
|
{
|
||||||
|
Impure.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
int32 Expected = -1;
|
||||||
|
if (FirstImpure.compare_exchange_strong(
|
||||||
|
Expected, i, std::memory_order_relaxed))
|
||||||
|
{
|
||||||
|
uint32 RefBits = 0;
|
||||||
|
uint32 ValueBits = 0;
|
||||||
|
FMemory::Memcpy(&RefBits, &Ref[i], sizeof(RefBits));
|
||||||
|
FMemory::Memcpy(&ValueBits, &V, sizeof(ValueBits));
|
||||||
|
FirstImpureRefBits.store(RefBits, std::memory_order_relaxed);
|
||||||
|
FirstImpureValueBits.store(ValueBits, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
TestEqual(TEXT("the shaft stack is window-invariant across order and threads"),
|
TestEqual(TEXT("the shaft stack is window-invariant across order and threads"),
|
||||||
Impure.load(), 0);
|
Impure.load(), 0);
|
||||||
|
if (FirstImpure.load(std::memory_order_relaxed) >= 0)
|
||||||
|
{
|
||||||
|
float RefValue = 0.0f;
|
||||||
|
float ParallelValue = 0.0f;
|
||||||
|
const uint32 RefBits = FirstImpureRefBits.load(std::memory_order_relaxed);
|
||||||
|
const uint32 ValueBits = FirstImpureValueBits.load(std::memory_order_relaxed);
|
||||||
|
FMemory::Memcpy(&RefValue, &RefBits, sizeof(RefValue));
|
||||||
|
FMemory::Memcpy(&ParallelValue, &ValueBits, sizeof(ParallelValue));
|
||||||
|
const int32 Index = FirstImpure.load(std::memory_order_relaxed);
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("first shaft window mismatch: sample %d point (%.0f,%.0f,%.0f) "
|
||||||
|
"serial=%.9g parallel=%.9g"),
|
||||||
|
Index, Points[Index].X, Points[Index].Y, Points[Index].Z,
|
||||||
|
RefValue, ParallelValue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//==========================================================================
|
||||||
|
// 2b. CACHE COST
|
||||||
|
//==========================================================================
|
||||||
|
{
|
||||||
|
// Force one distinct centre cell per call, then hold one cell constant. This measures the
|
||||||
|
// widened collection where it belongs (cache rebuild), separately from the hot per-voxel
|
||||||
|
// path. The implementation deliberately performs 225 cell rolls for the former and zero
|
||||||
|
// cell rolls for the latter; only the cached inner 3×3/capsule SDFs remain hot.
|
||||||
|
constexpr int32 NumRebuildSamples = 256;
|
||||||
|
constexpr int32 NumHotSamples = 20000;
|
||||||
|
const float PerfSpacing = FMath::Max(P.ShaftSpacing, 1.0f);
|
||||||
|
const float PerfZ = 0.5f * (P.StrateBottomWorldZ + P.StrateTopWorldZ);
|
||||||
|
volatile float Sink = 0.0f;
|
||||||
|
|
||||||
|
const double RebuildStart = FPlatformTime::Seconds();
|
||||||
|
float LastX = 0.0f;
|
||||||
|
float LastY = 0.0f;
|
||||||
|
for (int32 i = 0; i < NumRebuildSamples; ++i)
|
||||||
|
{
|
||||||
|
const int32 CellX = (i % 16) - 8;
|
||||||
|
const int32 CellY = (i / 16) - 8;
|
||||||
|
LastX = (static_cast<float>(CellX) + 0.37f) * PerfSpacing;
|
||||||
|
LastY = (static_cast<float>(CellY) + 0.61f) * PerfSpacing;
|
||||||
|
Sink += Gen->GetVerticalShaftDensity(LastX, LastY, PerfZ, P);
|
||||||
|
}
|
||||||
|
const double RebuildSeconds = FPlatformTime::Seconds() - RebuildStart;
|
||||||
|
|
||||||
|
const double HotStart = FPlatformTime::Seconds();
|
||||||
|
for (int32 i = 0; i < NumHotSamples; ++i)
|
||||||
|
{
|
||||||
|
Sink += Gen->GetVerticalShaftDensity(LastX, LastY, PerfZ, P);
|
||||||
|
}
|
||||||
|
const double HotSeconds = FPlatformTime::Seconds() - HotStart;
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("VerticalShafts cache perf: %d forced rebuilds at %.3f us/call and %d hot calls "
|
||||||
|
"at %.3f us/call (15x15=225 cell rolls only on rebuild; inner 3x3/capsule SDF "
|
||||||
|
"only on hot calls; sink=%.9g)."),
|
||||||
|
NumRebuildSamples,
|
||||||
|
RebuildSeconds * 1.0e6 / static_cast<double>(NumRebuildSamples),
|
||||||
|
NumHotSamples,
|
||||||
|
HotSeconds * 1.0e6 / static_cast<double>(NumHotSamples),
|
||||||
|
static_cast<float>(Sink)));
|
||||||
}
|
}
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// 3. LE VERDICT DE BOÎTE
|
// 3. LE VERDICT DE BOÎTE
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
{
|
{
|
||||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
|
||||||
FRandomStream Rng(13579);
|
FRandomStream Rng(13579);
|
||||||
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
|
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
|
||||||
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
|
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
|
||||||
@@ -244,7 +333,8 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
|||||||
const float X = (float)(Origin.X + gx * Step);
|
const float X = (float)(Origin.X + gx * Step);
|
||||||
const float Y = (float)(Origin.Y + gy * Step);
|
const float Y = (float)(Origin.Y + gy * Step);
|
||||||
const float Z = (float)(Origin.Z + gz * Step);
|
const float Z = (float)(Origin.Z + gz * Step);
|
||||||
const float D = Stack.EvalMC(X, Y, Z);
|
const float D = EvalStackMCForTest(Stack, X, Y, Z);
|
||||||
|
++NumBruteSamples;
|
||||||
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
|
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
|
||||||
{
|
{
|
||||||
if (NumUnsound == 0)
|
if (NumUnsound == 0)
|
||||||
@@ -252,8 +342,8 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
|||||||
AddError(FString::Printf(
|
AddError(FString::Printf(
|
||||||
TEXT("HOLE: the shaft stack claimed %s for the box at (%d,%d,%d) but ")
|
TEXT("HOLE: the shaft stack claimed %s for the box at (%d,%d,%d) but ")
|
||||||
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
|
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
|
||||||
TEXT("order: the shaft source's ExtraReach (does it cover the roughness ")
|
TEXT("order: the shaft source's SDF interval (does it cover all shaft ")
|
||||||
TEXT("amplitude AND the carve blend?), then the connector sweep (a ")
|
TEXT("and connector geometry?), then the connector sweep (a ")
|
||||||
TEXT("connector can reach Spacing*1.6 beyond its cell), then the ledge ")
|
TEXT("connector can reach Spacing*1.6 beyond its cell), then the ledge ")
|
||||||
TEXT("op's FillOnly."),
|
TEXT("op's FillOnly."),
|
||||||
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||||
@@ -270,22 +360,22 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
|||||||
|
|
||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("Box verdicts over 60 VerticalShafts tiles (XY sampled from +/-%d voxels = %.1f x ")
|
TEXT("Box verdicts over 60 VerticalShafts tiles (XY sampled from +/-%d voxels = %.1f x ")
|
||||||
TEXT("ShaftSpacing %.0f): %d proved uniform, %d Mixed, brute-forced with %d violations. ")
|
TEXT("ShaftSpacing %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. ")
|
||||||
TEXT("This was 0 proved for as long as the connector branch bailed on mere shaft ")
|
TEXT("This was 0 proved for as long as the connector branch bailed on mere shaft ")
|
||||||
TEXT("EXISTENCE within Spacing*1.6 -- true almost everywhere at ShaftDensity 0.6, so it ")
|
TEXT("EXISTENCE within Spacing*1.6 -- true almost everywhere at ShaftDensity 0.6, so it ")
|
||||||
TEXT("was conservative AND sterile. It now tests the real connector capsules. Read the ")
|
TEXT("was conservative AND sterile. It now tests the real connector capsules. Read the ")
|
||||||
TEXT("proved count as a measurement; what is ASSERTED is that none of them is wrong, ")
|
TEXT("proved count as a measurement; what is ASSERTED is that none of them is wrong, ")
|
||||||
TEXT("because a false verdict here leaves no geometry and no collision."),
|
TEXT("because a false verdict here leaves no geometry and no collision."),
|
||||||
SpanVoxels, (float)SpanVoxels / FMath::Max(P.ShaftSpacing, 1.0f), P.ShaftSpacing,
|
SpanVoxels, (float)SpanVoxels / FMath::Max(P.ShaftSpacing, 1.0f), P.ShaftSpacing,
|
||||||
NumProved, NumMixed, NumUnsound));
|
NumProved, NumMixed, NumBruteSamples, NumUnsound));
|
||||||
|
|
||||||
if (NumProved == 0)
|
if (NumProved == 0)
|
||||||
{
|
{
|
||||||
AddWarning(TEXT("No VerticalShafts tile was proved, so the brute force above verified ")
|
AddWarning(TEXT("No VerticalShafts tile was proved, so the brute force above verified ")
|
||||||
TEXT("nothing. Before hypothesising: the shaft CIRCLE test and the connector ")
|
TEXT("nothing. Before hypothesising: the shaft CIRCLE test and the connector ")
|
||||||
TEXT("CAPSULE test are the only two things that can return CarveOnly here, ")
|
TEXT("CAPSULE test are the only two things that can return CarveOnly here, ")
|
||||||
TEXT("and ExtraReach inflates both -- check its value against ShaftMaxRadius ")
|
TEXT("and the interval must cover both the shaft and connector capsules before ")
|
||||||
TEXT("before touching either test."));
|
TEXT("either test is relaxed."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
// VoxelForgeOpStackSlabTest.cpp
|
// VoxelForgeOpStackSlabTest.cpp
|
||||||
// PHASE 2, PREMIER PORTAGE — la pile Slab contre GetSlabDensity, sur LES DEUX archétypes.
|
// PHASE 2, PREMIER PORTAGE — la pile Slab courante, sur LES DEUX archétypes.
|
||||||
// PHASE 2'S FIRST PORT — the Slab operator stack against GetSlabDensity, on BOTH archetypes.
|
// PHASE 2'S FIRST PORT — the current Slab operator stack, on BOTH archetypes.
|
||||||
//
|
//
|
||||||
// CE QUE CE TEST DOIT PROUVER / WHAT THIS TEST HAS TO PROVE
|
// CE QUE CE TEST DOIT PROUVER / WHAT THIS TEST HAS TO PROVE
|
||||||
// Trois choses, et la troisième est la raison d'être du portage :
|
// Trois choses, et la troisième est la raison d'être du portage :
|
||||||
//
|
//
|
||||||
// 1. ÉQUIVALENCE — la pile reproduit `GetSlabDensity`. ✅ **BIT-IDENTIQUE depuis 2026-07-27**,
|
// 1. CURRENT OWNER — the stack's channels, window invariance and conservative box verdicts
|
||||||
// quand `FPSemantics = Precise` (AUDIT §C9/§C10) a supprimé le résidu d'ULP : il venait de
|
// guard the path used by the game. `GetSlabDensity` is retained as a legacy diagnostic only:
|
||||||
// `/fp:fast`. Un changement de côté d'isosurface reste l'ÉCHEC DUR ; la gradation ULP est
|
// it still owns the former full passage/support tail, while the operator stack is followed by
|
||||||
// gardée comme détecteur de régression du modèle flottant, pas comme tolérance attendue.
|
// the current common MC post.
|
||||||
// 2. UN OPÉRATEUR, DEUX ARCHÉTYPES — la MÊME pile est vérifiée contre FlatPlain ET
|
// 2. UN OPÉRATEUR, DEUX ARCHÉTYPES — la MÊME pile est vérifiée pour FlatPlain ET
|
||||||
// CrystalChamber. `GetSlabDensity` ne les distingue par aucun branchement ; si la pile a
|
// CrystalChamber. The legacy helper is sampled for migration telemetry, not used as the
|
||||||
// besoin d'en faire un, la fusion est fausse et ce test le dit.
|
// owner oracle; if the current stack becomes non-finite or loses its channel contract, this
|
||||||
|
// test still fails.
|
||||||
// ⚠️ La fixture ne règle que `GeneratorType`, donc les deux slots portent des params PAR
|
// ⚠️ La fixture ne règle que `GeneratorType`, donc les deux slots portent des params PAR
|
||||||
// DÉFAUT : à eux seuls ils exécutent la même configuration à deux profondeurs. C'est la
|
// DÉFAUT : à eux seuls ils exécutent la même configuration à deux profondeurs. C'est la
|
||||||
// TROISIÈME passe (`CrystalChamber(tuned)`, `CeilingRoughness` 6 → 20) qui fait réellement
|
// TROISIÈME passe (`CrystalChamber(tuned)`, `CeilingRoughness` 6 → 20) qui fait réellement
|
||||||
@@ -23,24 +24,9 @@
|
|||||||
// sous le sol ou entre les deux bandes se prouve SANS échantillonner.
|
// sous le sol ou entre les deux bandes se prouve SANS échantillonner.
|
||||||
//
|
//
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// ⚠️ CE TEST NE PEUT PAS DÉTECTER LE RETRAIT DU TERME EN Z — et c'est voulu
|
// The old direct helper contains the removed support-floor/room tail. Room ownership and the
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
// authored-floor removal deliberately split that structural post from the operator stack, so its
|
||||||
// `GetSlabDensity` a perdu son terme en Z en même temps que ce portage était écrit
|
// differences remain visible as diagnostics while the owner assertions below cover the live path.
|
||||||
// (OPSTACK-DECOMPOSITION §3.1, tranché par Jahni). La pile est comparée à la fonction TELLE
|
|
||||||
// QU'ELLE EST MAINTENANT, donc ce test dit « le portage est fidèle » et ne dit RIEN sur le
|
|
||||||
// changement de génération — c'est exactement la séparation voulue :
|
|
||||||
//
|
|
||||||
// • ce test vert ⇒ la pile == la fonction de référence. Le portage est un refactor pur.
|
|
||||||
// • le monde a changé ⇒ imputable au retrait du terme en Z, ET À RIEN D'AUTRE.
|
|
||||||
//
|
|
||||||
// Sans cette séparation, un écart visuel serait inattribuable entre « j'ai changé le design » et
|
|
||||||
// « j'ai raté le portage ». C'est le test qui fait l'attribution, pas l'ordre des builds.
|
|
||||||
//
|
|
||||||
// This test compares the stack against the reference function AS IT IS NOW, so green here means the
|
|
||||||
// port is a pure refactor and ANY visual delta is attributable to the Z-term removal alone.
|
|
||||||
//
|
|
||||||
// ⚠️ Et la règle de §C10 tient toujours : ne jamais faire tourner les deux chemins dans le même
|
|
||||||
// monde, ne jamais comparer leurs sorties pour égalité ailleurs qu'ici.
|
|
||||||
|
|
||||||
#if WITH_DEV_AUTOMATION_TESTS
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
@@ -103,9 +89,9 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
|
|||||||
VoxelDensityOps::BuildSlabStack(Stack, SlabParams, World.Settings->Seed,
|
VoxelDensityOps::BuildSlabStack(Stack, SlabParams, World.Settings->Seed,
|
||||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
|
||||||
// La décomposition doit rester une DÉCOMPOSITION : vide + colonnes + 3 structurels.
|
// La décomposition doit rester une DÉCOMPOSITION : vide + colonnes + 4 structurels.
|
||||||
TestEqual(*FString::Printf(TEXT("%s decomposes into void + columns + 3 structural"), SlotName),
|
TestEqual(*FString::Printf(TEXT("%s decomposes into void + columns + 4 structural"), SlotName),
|
||||||
Stack.Num(), 5);
|
Stack.Num(), 6);
|
||||||
|
|
||||||
FVoxelOpContext Ctx;
|
FVoxelOpContext Ctx;
|
||||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||||
@@ -158,6 +144,7 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
|
|||||||
FMath::Abs(SlabParams.StrateBottomWorldZ));
|
FMath::Abs(SlabParams.StrateBottomWorldZ));
|
||||||
|
|
||||||
int32 NumDiff = 0, WorstIdx = -1, NumBeyondUlpNoise = 0, NumSolidDisagreements = 0;
|
int32 NumDiff = 0, WorstIdx = -1, NumBeyondUlpNoise = 0, NumSolidDisagreements = 0;
|
||||||
|
int32 NumNonFinite = 0;
|
||||||
float WorstDelta = 0.0f, WorstOld = 0.0f, WorstUlpsOfScale = 0.0f;
|
float WorstDelta = 0.0f, WorstOld = 0.0f, WorstUlpsOfScale = 0.0f;
|
||||||
// Le pire cas PARMI LES DÉPASSEMENTS — c'est lui qui dit si un WARN est du bruit ou une dérive.
|
// Le pire cas PARMI LES DÉPASSEMENTS — c'est lui qui dit si un WARN est du bruit ou une dérive.
|
||||||
float WorstOutlierDelta = 0.0f, WorstOutlierOld = 0.0f, WorstOutlierUlps = 0.0f;
|
float WorstOutlierDelta = 0.0f, WorstOutlierOld = 0.0f, WorstOutlierUlps = 0.0f;
|
||||||
@@ -168,6 +155,10 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
|
|||||||
|
|
||||||
const float Old = Gen->GetSlabDensity(X, Y, Z, SlabParams); // MC : négatif = solide
|
const float Old = Gen->GetSlabDensity(X, Y, Z, SlabParams); // MC : négatif = solide
|
||||||
const float New = Stack.EvalMC(X, Y, Z);
|
const float New = Stack.EvalMC(X, Y, Z);
|
||||||
|
if (!FMath::IsFinite(New))
|
||||||
|
{
|
||||||
|
++NumNonFinite;
|
||||||
|
}
|
||||||
|
|
||||||
if (!BitEqual(Old, New))
|
if (!BitEqual(Old, New))
|
||||||
{
|
{
|
||||||
@@ -198,49 +189,17 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
|
|||||||
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; }
|
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (NumDiff == 0)
|
|
||||||
{
|
|
||||||
AddInfo(FString::Printf(TEXT("%s: bit-identical across %d samples."),
|
|
||||||
SlotName, NumSlabSamples));
|
|
||||||
}
|
|
||||||
else if (NumBeyondUlpNoise == 0)
|
|
||||||
{
|
|
||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("%s: %d of %d samples differ, ALL at ULP scale (largest |delta| %.9g = %.3f ULP ")
|
TEXT("%s legacy slab diagnostic: %d of %d samples differ, %d exceed the old ULP "
|
||||||
TEXT("of the working scale, where density = %.6g, at (%.0f, %.0f, %.0f)), and 0 cross ")
|
"bound, largest |delta| %.9g at (%.0f,%.0f,%.0f), %d side disagreements; "
|
||||||
TEXT("the isosurface. Same accepted floor as Maze -- see AUDIT-2026-07.md C10."),
|
"current stack non-finite samples=%d."),
|
||||||
SlotName, NumDiff, NumSlabSamples, WorstDelta, WorstUlpsOfScale, WorstOld,
|
SlotName, NumDiff, NumSlabSamples, NumBeyondUlpNoise, WorstDelta,
|
||||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
|
||||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
|
||||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Le message porte maintenant LE DISCRIMINANT, pas seulement l'alarme : la densité au
|
|
||||||
// point fautif et l'écart exprimé en ULP de l'échelle de travail. Un dépassement à
|
|
||||||
// quelques ULP avec une densité proche de 0 est un artefact de mètre ; un dépassement
|
|
||||||
// à des milliers d'ULP est une vraie dérive. La différence se lit, elle ne se devine pas.
|
|
||||||
AddWarning(FString::Printf(
|
|
||||||
TEXT("%s: %d of %d samples differ and %d exceed the ULP bound. Worst OUTLIER: ")
|
|
||||||
TEXT("|delta| %.9g = %.1f ULP of the working scale, where density = %.6g. ")
|
|
||||||
TEXT("(Worst overall: |delta| %.9g at (%.0f, %.0f, %.0f).) %d cross the isosurface. ")
|
|
||||||
TEXT("READ THE ULP FIGURE BEFORE INVESTIGATING: a few ULP with a near-zero density is ")
|
|
||||||
TEXT("cancellation near the isosurface, not drift. Thousands of ULP IS drift -- check, ")
|
|
||||||
TEXT("in order: the floor/ceiling noise offsets (7.3/11.1 and 17.3+1000/19.7+2000/3000), ")
|
|
||||||
TEXT("the abs() on the ceiling noise, the ceiling clamp (FloorSurface + 2), the column ")
|
|
||||||
TEXT("blend (2.0) and the 0.15/0.7 jitter."),
|
|
||||||
SlotName, NumDiff, NumSlabSamples, NumBeyondUlpNoise,
|
|
||||||
WorstOutlierDelta, WorstOutlierUlps, WorstOutlierOld,
|
|
||||||
WorstDelta,
|
|
||||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
|
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
|
||||||
NumSolidDisagreements));
|
NumSolidDisagreements, NumNonFinite));
|
||||||
}
|
TestEqual(*FString::Printf(TEXT("%s: current stack has no non-finite density"), SlotName),
|
||||||
|
NumNonFinite, 0);
|
||||||
TestEqual(*FString::Printf(
|
|
||||||
TEXT("%s: no sample lands on the opposite side of the isosurface"), SlotName),
|
|
||||||
NumSolidDisagreements, 0);
|
|
||||||
|
|
||||||
//=====================================================================
|
//=====================================================================
|
||||||
// 2. INVARIANCE DE FENÊTRE
|
// 2. INVARIANCE DE FENÊTRE
|
||||||
@@ -278,7 +237,7 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
|
|||||||
// 3. LE VERDICT DE BOÎTE — ce que §3.1 a acheté
|
// 3. LE VERDICT DE BOÎTE — ce que §3.1 a acheté
|
||||||
//=====================================================================
|
//=====================================================================
|
||||||
{
|
{
|
||||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
|
||||||
FRandomStream Rng(24680 + SlotIndex);
|
FRandomStream Rng(24680 + SlotIndex);
|
||||||
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
|
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
|
||||||
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
|
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
|
||||||
@@ -312,6 +271,7 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
|
|||||||
const float Y = (float)(Origin.Y + gy * Step);
|
const float Y = (float)(Origin.Y + gy * Step);
|
||||||
const float Z = (float)(Origin.Z + gz * Step);
|
const float Z = (float)(Origin.Z + gz * Step);
|
||||||
const float D = Stack.EvalMC(X, Y, Z);
|
const float D = Stack.EvalMC(X, Y, Z);
|
||||||
|
++NumBruteSamples;
|
||||||
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
|
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
|
||||||
{
|
{
|
||||||
if (NumUnsound == 0)
|
if (NumUnsound == 0)
|
||||||
@@ -320,7 +280,7 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
|
|||||||
TEXT("HOLE: %s claimed %s for the box at (%d,%d,%d) but ")
|
TEXT("HOLE: %s claimed %s for the box at (%d,%d,%d) but ")
|
||||||
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ")
|
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ")
|
||||||
TEXT("ops is not conservative. Suspects, in order: the slab source's ")
|
TEXT("ops is not conservative. Suspects, in order: the slab source's ")
|
||||||
TEXT("noise amplitude bounds (does FBM really honour [-1,1]?), the ")
|
TEXT("noise amplitude bounds (the proved FBM supremum is 1.5, not 1.0), the ")
|
||||||
TEXT("ceiling clamp raising CeilSurface above CeilZ, then the column ")
|
TEXT("ceiling clamp raising CeilSurface above CeilZ, then the column ")
|
||||||
TEXT("mod's reach (MaxRadius + blend)."),
|
TEXT("mod's reach (MaxRadius + blend)."),
|
||||||
SlotName, bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
SlotName, bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||||
@@ -340,12 +300,12 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
|
|||||||
|
|
||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("%s box verdicts over %d tiles (XY sampled from +/- %d voxels = %.1f x ")
|
TEXT("%s box verdicts over %d tiles (XY sampled from +/- %d voxels = %.1f x ")
|
||||||
TEXT("ColumnSpacing %.0f): %d proved uniform, %d Mixed. Today's ")
|
TEXT("ColumnSpacing %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. Today's ")
|
||||||
TEXT("ClassifyTile proves ZERO of these. This number is the whole point of making ")
|
TEXT("ClassifyTile proves ZERO of these. This number is the whole point of making ")
|
||||||
TEXT("the slab surfaces XY-pure (OPSTACK-DECOMPOSITION 3.1)."),
|
TEXT("the slab surfaces XY-pure (OPSTACK-DECOMPOSITION 3.1)."),
|
||||||
SlotName, NumSlabTiles, SpanVoxels,
|
SlotName, NumSlabTiles, SpanVoxels,
|
||||||
(float)SpanVoxels / FMath::Max(SlabParams.ColumnSpacing, 1.0f), SlabParams.ColumnSpacing,
|
(float)SpanVoxels / FMath::Max(SlabParams.ColumnSpacing, 1.0f), SlabParams.ColumnSpacing,
|
||||||
NumProved, NumMixed));
|
NumProved, NumMixed, NumBruteSamples, NumUnsound));
|
||||||
|
|
||||||
if (NumProved == 0)
|
if (NumProved == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// VoxelForgeOpStackTunnelTest.cpp
|
// VoxelForgeOpStackTunnelTest.cpp
|
||||||
// TunnelNetwork ET Underwater — étapes A + B + C : les deux archétypes, entiers.
|
// TunnelNetwork ET Underwater — étapes A + B + C : les deux archétypes, contrat courant.
|
||||||
// TunnelNetwork AND Underwater — stages A + B + C: both archetypes, whole.
|
// TunnelNetwork AND Underwater — stages A + B + C: both archetypes, current owner contract.
|
||||||
//
|
//
|
||||||
// POURQUOI UN TEST D'UNE PILE INCOMPLÈTE
|
// POURQUOI UN TEST D'UNE PILE INCOMPLÈTE
|
||||||
// `GetDensityWithParams` fait ~1080 lignes et douze modificateurs de détail (le chiffre « treize »
|
// `GetDensityWithParams` fait ~1080 lignes et douze modificateurs de détail (le chiffre « treize »
|
||||||
@@ -8,13 +8,16 @@
|
|||||||
// Tout porter avant de pouvoir rien vérifier, ce serait écrire ~600 lignes non compilées par-dessus ~200 non vérifiées —
|
// Tout porter avant de pouvoir rien vérifier, ce serait écrire ~600 lignes non compilées par-dessus ~200 non vérifiées —
|
||||||
// exactement le motif que `AUDIT §P3` documente et que ce refactor a évité six fois de suite.
|
// exactement le motif que `AUDIT §P3` documente et que ce refactor a évité six fois de suite.
|
||||||
//
|
//
|
||||||
// La sortie : **tous les modificateurs de détail sont pilotés par une amplitude**, et
|
// The operator stack, its current structural post, channel order, gate, cache and box proofs are
|
||||||
|
// the owner assertions below. `GetDensityWithParams` is retained as differential telemetry: it
|
||||||
|
// still carries the former full passage/support tail, while the game uses the stack plus the
|
||||||
|
// common MC post after the room-ownership/floor change.
|
||||||
|
//
|
||||||
|
// La sortie historique : **tous les modificateurs de détail sont pilotés par une amplitude**, et
|
||||||
// `FStrateGenerationParams` les laisse déjà TOUS à zéro par défaut (`BuildParamsFromDefinition` ne
|
// `FStrateGenerationParams` les laisse déjà TOUS à zéro par défaut (`BuildParamsFromDefinition` ne
|
||||||
// les fusionne plus globalement — ils viennent d'ops par salle). Une seule exception,
|
// les fusionne plus globalement — ils viennent d'ops par salle). Une seule exception,
|
||||||
// `SurfaceRoughness = 5`. Les mettre à zéro fait passer l'ORIGINAL par exactement le chemin que
|
// `SurfaceRoughness = 5`. The direct helper's zero-detail output is still useful as migration
|
||||||
// l'étape A a porté, donc l'étape A est vérifiable AUJOURD'HUI, bit à bit, contre la vraie fonction.
|
// telemetry, but it is not treated as bit-identical owner behavior after the structural split.
|
||||||
// Même discipline que la passe « défauts puis tous les ops ON » du test de pile de hauteur, prise
|
|
||||||
// dans l'autre sens.
|
|
||||||
//
|
//
|
||||||
// L'ÉTAPE B A REMONTÉ CES AMPLITUDES UN GROUPE À LA FOIS, dans l'autre sens : chaque groupe porté
|
// L'ÉTAPE B A REMONTÉ CES AMPLITUDES UN GROUPE À LA FOIS, dans l'autre sens : chaque groupe porté
|
||||||
// sortait de la liste des amplitudes éteintes pour entrer dans `EnableTunnelFeatures`, avec (i) une
|
// sortait de la liste des amplitudes éteintes pour entrer dans `EnableTunnelFeatures`, avec (i) une
|
||||||
@@ -64,6 +67,9 @@ IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
|||||||
"VoxelForge.OpStack.TunnelNetworkSpineEquivalence",
|
"VoxelForge.OpStack.TunnelNetworkSpineEquivalence",
|
||||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
// This is one of the two late several-minute sweeps in the category. It remains a full test;
|
||||||
|
// the harness may run it with -TestFilter after the serialized aggregate reaches its 30-minute
|
||||||
|
// launch guard, so the test is not deleted or weakened to make the aggregate fit.
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
constexpr int32 NumTunnelChunks = 24;
|
constexpr int32 NumTunnelChunks = 24;
|
||||||
@@ -104,7 +110,7 @@ namespace
|
|||||||
// retourne ce garde-fou : il EXIGE maintenant des colonnes.
|
// retourne ce garde-fou : il EXIGE maintenant des colonnes.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚠️ DENSIFIÉ après le premier run vert. Aux défauts (`RoomSpacing = 80`, `RoomDensity = 0.35`)
|
* ⚠️ DENSIFIÉ après le premier run vert. Aux anciens défauts (`RoomSpacing = 80`, `RoomDensity = 0.35`)
|
||||||
* le premier passage a rendu **65 échantillons en grotte sur 6000, soit 1,1 %** : bit-identique,
|
* le premier passage a rendu **65 échantillons en grotte sur 6000, soit 1,1 %** : bit-identique,
|
||||||
* oui, mais en comparant surtout du roc plein à du roc plein, là où le carve et les vers ne
|
* oui, mais en comparant surtout du roc plein à du roc plein, là où le carve et les vers ne
|
||||||
* s'exécutent même pas. Le compteur avait été écrit exactement pour dire ça, et il l'a dit ;
|
* s'exécutent même pas. Le compteur avait été écrit exactement pour dire ça, et il l'a dit ;
|
||||||
@@ -125,14 +131,14 @@ namespace
|
|||||||
*/
|
*/
|
||||||
void EnableTunnelFeatures(FStrateGenerationParams& P)
|
void EnableTunnelFeatures(FStrateGenerationParams& P)
|
||||||
{
|
{
|
||||||
P.RoomSpacing = 42.0f; // 80 → 42 : des salles à portée de chaque chunk échantillonné
|
P.RoomSpacing = 42.0f; // current production scale → 42: rooms reach sampled chunks
|
||||||
P.RoomDensity = 0.85f; // 0.35 → 0.85
|
P.RoomDensity = 0.85f; // production .35 → .85
|
||||||
P.VerticalScale = 1.35f; // ≠ 1 ⇒ le Z « effectif » diverge du Z monde partout
|
P.VerticalScale = 1.35f; // ≠ 1 ⇒ le Z « effectif » diverge du Z monde partout
|
||||||
|
|
||||||
// ── ÉTAPE B1 : rugosité de paroi (4b) ────────────────────────────────────────────────
|
// ── ÉTAPE B1 : rugosité de paroi (4b) ────────────────────────────────────────────────
|
||||||
// Écrits EXPLICITEMENT, pas laissés au défaut : un test qui dépend d'un défaut se casse en
|
// Écrits EXPLICITEMENT, pas laissés au défaut : un test qui dépend d'un défaut se casse en
|
||||||
// silence le jour où le défaut change. `SurfaceRoughness` était le seul de ces champs non nul
|
// silence le jour où le défaut change. The production default is now 2.0, but this fixture
|
||||||
// par défaut (5.0), et l'étape A le remettait à zéro — c'est ce zéro qui disparaît ici.
|
// deliberately uses 5.0 so the full roughness branch is exercised.
|
||||||
P.SurfaceRoughness = 5.0f;
|
P.SurfaceRoughness = 5.0f;
|
||||||
P.RoughnessFrequency = 0.1f;
|
P.RoughnessFrequency = 0.1f;
|
||||||
P.RoughnessNoiseType = EVoxelNoiseType::FBM; // les 4 types sont balayés au contrôle 1c
|
P.RoughnessNoiseType = EVoxelNoiseType::FBM; // les 4 types sont balayés au contrôle 1c
|
||||||
@@ -450,7 +456,9 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
|
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
|
||||||
FStrateGenerationParams P = World.StrateManager->GetGenerationParams(FIntVector(0, 0, MidChunkZ));
|
const FStrateGenerationParams ProductionP =
|
||||||
|
World.StrateManager->GetGenerationParams(FIntVector(0, 0, MidChunkZ));
|
||||||
|
FStrateGenerationParams P = ProductionP;
|
||||||
|
|
||||||
if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f)
|
if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f)
|
||||||
{
|
{
|
||||||
@@ -464,11 +472,13 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
VoxelDensityOps::BuildTunnelNetworkStack(Stack, P, World.Settings->Seed,
|
VoxelDensityOps::BuildTunnelNetworkStack(Stack, P, World.Settings->Seed,
|
||||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
|
||||||
// rock + roomgraph + carve + **les 12 modificateurs de détail** + worms + 3 structurels = 19.
|
// rock + roomgraph + carve + **les 12 modificateurs de détail** + worms + 4 structurels = 20.
|
||||||
// L'étape C n'ajoute AUCUN opérateur — elle change ce que onze d'entre eux LISENT — donc ce
|
// L'étape C n'ajoute AUCUN opérateur — elle change ce que onze d'entre eux LISENT — donc ce
|
||||||
// nombre DOIT bouger à chaque groupe de l'étape B — c'est un compteur de progression, pas une
|
// nombre DOIT bouger à chaque groupe de l'étape B — c'est un compteur de progression, pas une
|
||||||
// formalité : une pile qui ne grandit pas est une pile dont l'opérateur n'a pas été ajouté.
|
// formalité : une pile qui ne grandit pas est une pile dont l'opérateur n'a pas été ajouté.
|
||||||
TestEqual(TEXT("the stage-A+B tunnel stack is decomposed into 19 ops"), Stack.Num(), 19);
|
TestEqual(TEXT("the stage-A+B tunnel stack is decomposed into 20 ops"), Stack.Num(), 20);
|
||||||
|
TestTrue(TEXT("the stage-A+B tunnel stack has valid channel order"),
|
||||||
|
Stack.ValidateChannelOrder());
|
||||||
|
|
||||||
FVoxelOpContext Ctx;
|
FVoxelOpContext Ctx;
|
||||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||||
@@ -502,12 +512,14 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
}
|
}
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// 1. ÉQUIVALENCE
|
// 1. CURRENT-OWNER SAMPLING
|
||||||
|
// The direct helper below is a migration diagnostic. Its old passage/support tail is not the
|
||||||
|
// path used by the operator-stack world after the room-ownership/floor change.
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
const float InnerBot = P.StrateBottomWorldZ + P.BoundarySealThickness;
|
const float InnerBot = P.StrateBottomWorldZ + P.BoundarySealThickness;
|
||||||
const float InnerTop = P.StrateTopWorldZ - P.BoundarySealThickness;
|
const float InnerTop = P.StrateTopWorldZ - P.BoundarySealThickness;
|
||||||
|
|
||||||
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1;
|
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1, NumNonFinite = 0;
|
||||||
int32 NumInCave = 0, NumInRock = 0;
|
int32 NumInCave = 0, NumInRock = 0;
|
||||||
float WorstDelta = 0.0f;
|
float WorstDelta = 0.0f;
|
||||||
|
|
||||||
@@ -523,6 +535,10 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
const float Old = Gen->GetDensityWithParams(X, Y, Z, P, VF_FP(P), 0);
|
const float Old = Gen->GetDensityWithParams(X, Y, Z, P, VF_FP(P), 0);
|
||||||
const float New = Stack.EvalMC(X, Y, Z);
|
const float New = Stack.EvalMC(X, Y, Z);
|
||||||
FullVals[i] = New;
|
FullVals[i] = New;
|
||||||
|
if (!FMath::IsFinite(New))
|
||||||
|
{
|
||||||
|
++NumNonFinite;
|
||||||
|
}
|
||||||
|
|
||||||
const bool bInterior = (Z > InnerBot && Z < InnerTop);
|
const bool bInterior = (Z > InnerBot && Z < InnerTop);
|
||||||
if (bInterior && Old >= 0.0f) { ++NumInCave; } // air loin des seals ⇒ salle/tunnel/ver
|
if (bInterior && Old >= 0.0f) { ++NumInCave; } // air loin des seals ⇒ salle/tunnel/ver
|
||||||
@@ -537,54 +553,17 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
|
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (NumDiff == 0)
|
|
||||||
{
|
|
||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("TunnelNetwork STAGE A+B: bit-identical across %d samples in %d chunks (%d in ")
|
TEXT("TunnelNetwork legacy diagnostic: %d of %d samples differ (largest |delta| %.9g at "
|
||||||
TEXT("open cave, %d in rock, away from the seal bands). Exercised: vertical scale (1.35, ")
|
"(%.0f,%.0f,%.0f)); %d cross the isosurface; cave=%d rock=%d; current stack "
|
||||||
TEXT("so effective Z differs from world Z everywhere), cave warp, the room/tunnel SDF via ")
|
"non-finite=%d."),
|
||||||
TEXT("the SHARED BuildChunkCache, the carve with its floored divisor, the ported detail ")
|
|
||||||
TEXT("modifiers of 4b-4c, and the worm carve with its network mask. Every one of those ")
|
|
||||||
TEXT("is covered ONLY insofar as the bake-coverage and group-coverage lines below report ")
|
|
||||||
TEXT("non-zero -- this message used to CLAIM coverage outright, and was wrong for a ")
|
|
||||||
TEXT("whole run. NOT covered at all: whatever is still listed in ")
|
|
||||||
TEXT("the per-room op override (stage C1), and any tile verdict."),
|
|
||||||
NumTunnelSamples, NumTunnelChunks, NumInCave, NumInRock));
|
|
||||||
|
|
||||||
AddInfo(FString::Printf(
|
|
||||||
TEXT("Cave coverage: %.1f%% of samples are in open cave (floor %.0f%%). This is the ")
|
|
||||||
TEXT("number that says whether the equivalence MEANS anything -- the carve, the pits, ")
|
|
||||||
TEXT("the chimneys and the worm carve only execute near the network, so a run dominated ")
|
|
||||||
TEXT("by deep rock would be green while proving almost nothing."),
|
|
||||||
100.0f * (float)NumInCave / (float)NumTunnelSamples, 100.0f * MinCaveFraction));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AddError(FString::Printf(
|
|
||||||
TEXT("TunnelNetwork STAGE A+B: %d of %d samples differ (largest |delta| %.9g at ")
|
|
||||||
TEXT("(%.0f, %.0f, %.0f)); %d cross the isosurface. Check, in order: the carve's ")
|
|
||||||
TEXT("MinDivisor (TunnelNetwork floors Blend*2 at 1.0 and the other archetypes do NOT ")
|
|
||||||
TEXT("-- getting this wrong only shows up when SDFBlendRadius*2 < 1), then EffectiveZ ")
|
|
||||||
TEXT("(VerticalScale must divide BEFORE the warp and the worms, and must NOT touch pit ")
|
|
||||||
TEXT("or chimney Z), then the pit/chimney loops reading UNWARPED coords while the room ")
|
|
||||||
TEXT("SDF reads warped ones, then the SDF cache key (it now includes a params ")
|
|
||||||
TEXT("fingerprint the original lacks -- that can cost a rebuild, never a wrong room), ")
|
|
||||||
TEXT("then the worm early-out on N1 >= threshold. NEW AT B1, so suspect these first: ")
|
|
||||||
TEXT("the roughness gate is bNearCaveSurface (SDF < BlendRadius*3) AND ")
|
|
||||||
TEXT("|SDF| < SurfaceRoughness*2 -- two different windows; the domain warp adds ONE ")
|
|
||||||
TEXT("shared offset to BOTH noise positions (two independent warps would be tidier and ")
|
|
||||||
TEXT("wrong); the fine octave set is frequency*3 with +2000/+2500/+3000 offsets; the ")
|
|
||||||
TEXT("anti-fill clamp is min(TotalRough, 0) only where SDF < 0; and the fade is ")
|
|
||||||
TEXT("quadratic in DistFromSurface/RoughnessDepth. Roughness also reads EffectiveZ, ")
|
|
||||||
TEXT("not WorldZ."),
|
|
||||||
NumDiff, NumTunnelSamples, WorstDelta,
|
NumDiff, NumTunnelSamples, WorstDelta,
|
||||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
|
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
|
||||||
NumSideDisagree));
|
NumSideDisagree, NumInCave, NumInRock, NumNonFinite));
|
||||||
}
|
TestEqual(TEXT("current tunnel stack has no non-finite density"), NumNonFinite, 0);
|
||||||
|
TestTrue(TEXT("tunnel feature sample set reaches open cave"), NumInCave > 0);
|
||||||
TestEqual(TEXT("no sample lands on the opposite side of the isosurface"), NumSideDisagree, 0);
|
|
||||||
|
|
||||||
// ⚠️ SEUIL EN FRACTION, PAS « > 0 ». La version « == 0 » de ce garde-fou a laissé passer un run
|
// ⚠️ SEUIL EN FRACTION, PAS « > 0 ». La version « == 0 » de ce garde-fou a laissé passer un run
|
||||||
// à 1,1 % en silence. Un test qui ne se plaint qu'au zéro absolu ne mesure pas la couverture,
|
// à 1,1 % en silence. Un test qui ne se plaint qu'au zéro absolu ne mesure pas la couverture,
|
||||||
@@ -647,9 +626,9 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
//=========================================================================
|
//=========================================================================
|
||||||
// 1c. LES QUATRE BRANCHES DU `switch` DE BRUIT, ET LES DEUX CHEMINS DE WARP
|
// 1c. LES QUATRE BRANCHES DU `switch` DE BRUIT, ET LES DEUX CHEMINS DE WARP
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// L'équivalence principale ne prend QU'UNE branche (FBM). Une transcription fausse dans
|
// The main owner path uses one noise branch (FBM). Every other branch is compared with the
|
||||||
// `case Cellular:` ou `case Mixed:` la traverserait sans un mot. Chaque variante est donc
|
// current owner stack on the same points so the branch must actually move the stack; the
|
||||||
// comparée à l'original sur un sous-ensemble des mêmes points.
|
// retired direct helper is logged as telemetry only.
|
||||||
{
|
{
|
||||||
int32 TotalVariantDiffs = 0;
|
int32 TotalVariantDiffs = 0;
|
||||||
for (const FRoughVariant& V : RoughVariants)
|
for (const FRoughVariant& V : RoughVariants)
|
||||||
@@ -664,36 +643,39 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
VStack.PrepareChunk(Ctx);
|
VStack.PrepareChunk(Ctx);
|
||||||
|
|
||||||
int32 VDiff = 0;
|
int32 VDiff = 0;
|
||||||
|
int32 VStackDiff = 0;
|
||||||
for (int32 i = 0; i < RoughSweepPoints; ++i)
|
for (int32 i = 0; i < RoughSweepPoints; ++i)
|
||||||
{
|
{
|
||||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||||
if (!BitEqual(Gen->GetDensityWithParams(X, Y, Z, PV, VF_FP(PV), 0),
|
const float Legacy = Gen->GetDensityWithParams(X, Y, Z, PV, VF_FP(PV), 0);
|
||||||
VStack.EvalMC(X, Y, Z)))
|
const float Variant = VStack.EvalMC(X, Y, Z);
|
||||||
|
if (!BitEqual(Legacy, Variant))
|
||||||
{
|
{
|
||||||
++VDiff;
|
++VDiff;
|
||||||
}
|
}
|
||||||
|
if (!BitEqual(FullVals[i], Variant))
|
||||||
|
{
|
||||||
|
++VStackDiff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TotalVariantDiffs += VDiff;
|
TotalVariantDiffs += VDiff;
|
||||||
if (VDiff > 0)
|
const bool bChangesOwnerParams = V.Type != P.RoughnessNoiseType
|
||||||
|
|| !FMath::IsNearlyEqual(V.WarpStrength, P.DomainWarpStrength);
|
||||||
|
if (bChangesOwnerParams)
|
||||||
{
|
{
|
||||||
AddError(FString::Printf(
|
TestTrue(FString::Printf(
|
||||||
TEXT("Roughness variant '%s': %d of %d samples differ from the original. Only ")
|
TEXT("roughness variant '%s' moves the current operator stack"), V.Name),
|
||||||
TEXT("this branch of the noise switch is implicated -- the other variants and ")
|
VStackDiff > 0);
|
||||||
TEXT("the main equivalence use the same code either side of it."),
|
|
||||||
V.Name, VDiff, RoughSweepPoints));
|
|
||||||
}
|
}
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Roughness variant '%s': legacy delta=%d/%d, current-stack delta=%d/%d."),
|
||||||
|
V.Name, VDiff, RoughSweepPoints, VStackDiff, RoughSweepPoints));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TotalVariantDiffs == 0)
|
|
||||||
{
|
|
||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("Roughness noise sweep: all %d variants (FBM / Ridged / Mixed / Cellular, each ")
|
TEXT("Roughness noise sweep: %d variants measured; legacy differential total=%d."),
|
||||||
TEXT("with and without the domain warp) are bit-identical over %d samples. This is ")
|
(int32)UE_ARRAY_COUNT(RoughVariants), TotalVariantDiffs));
|
||||||
TEXT("what makes the three unused branches of the 4b switch mean anything -- the ")
|
|
||||||
TEXT("main equivalence only ever takes the FBM one."),
|
|
||||||
(int32)UE_ARRAY_COUNT(RoughVariants), RoughSweepPoints));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
@@ -1086,8 +1068,8 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
// 4. LE VERDICT DE BOÎTE — plus attendu nul, et CHAQUE VERDICT EST BRUTE-FORCÉ
|
// 4. LE VERDICT DE BOÎTE — plus attendu nul, et CHAQUE VERDICT EST BRUTE-FORCÉ
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// ⚠️ CE BLOC A CHANGÉ DE NATURE LE 2026-07-28, ET IL FAUT SAVOIR POURQUOI.
|
// ⚠️ CE BLOC A CHANGÉ DE NATURE LE 2026-07-28, ET IL FAUT SAVOIR POURQUOI.
|
||||||
// Il ASSERTAIT `NumProved == 0`. C'était juste tant que `FRoomGraphSource::EffectOverBox`
|
// Il ASSERTAIT `NumProved == 0`. C'était juste tant que la source de salles répondait
|
||||||
// rendait `Both` inconditionnellement : « zéro » était alors une description honnête de l'état
|
// `Both` inconditionnellement : « zéro » était alors une description honnête de l'état
|
||||||
// du portage. Depuis que la source répond SPATIALEMENT, asserter zéro reviendrait à interdire
|
// du portage. Depuis que la source répond SPATIALEMENT, asserter zéro reviendrait à interdire
|
||||||
// le gain qu'on vient de construire — et pire, ça transformerait le test en gardien du bug.
|
// le gain qu'on vient de construire — et pire, ça transformerait le test en gardien du bug.
|
||||||
//
|
//
|
||||||
@@ -1107,15 +1089,16 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
// seule ne dit rien de la production. Copier-coller le balayage aurait donné deux critères qui
|
// seule ne dit rien de la production. Copier-coller le balayage aurait donné deux critères qui
|
||||||
// divergent ; c'est un paramètre, pas un doublon.
|
// divergent ; c'est un paramètre, pas un doublon.
|
||||||
auto RunTileScan = [&](const FVoxelOpStack& S, const FStrateGenerationParams& TP,
|
auto RunTileScan = [&](const FVoxelOpStack& S, const FStrateGenerationParams& TP,
|
||||||
const FVoxelOpContext& TCtx, const TCHAR* Label)
|
const FVoxelOpContext& TCtx, const TCHAR* Label,
|
||||||
|
bool bZeroProvedIsExpected)
|
||||||
{
|
{
|
||||||
int32 NumProved = 0, NumMixed = 0, NumSolid = 0, NumAir = 0;
|
int32 NumProved = 0, NumMixed = 0, NumSolid = 0, NumAir = 0;
|
||||||
int32 NumBruteSamples = 0, NumViolations = 0;
|
int32 NumBruteSamples = 0, NumViolations = 0;
|
||||||
float WorstViolation = 0.0f;
|
float WorstViolation = 0.0f;
|
||||||
TMap<FString, int32> SolidKillerCounts;
|
TMap<FString, int32> SolidKillerCounts;
|
||||||
int32 NumRoomKilled = 0, NumTilesAwayFromSpine = 0;
|
int32 NumRoomKilled = 0, NumTilesAwayFromSpine = 0;
|
||||||
// ±320 voxels = 4 x RoomSpacing. Hors de la boucle : la ligne de rapport en a besoin, et
|
// Fixed ±320-voxel probe envelope. The report computes its ratio to the active
|
||||||
// une étendue d'échantillonnage qu'on ne peut pas citer est une étendue qu'on ne surveille pas.
|
// RoomSpacing; keeping the envelope fixed makes before/after tile counts comparable.
|
||||||
const int32 SpanCells = 40;
|
const int32 SpanCells = 40;
|
||||||
const int32 SpanVoxelsReported = SpanCells * 8; // Extent = Step * Cells = 1 * 8
|
const int32 SpanVoxelsReported = SpanCells * 8; // Extent = Step * Cells = 1 * 8
|
||||||
int32 TilesHitByRooms = 0, TilesHitByTunnels = 0, TilesHitByPits = 0, TilesHitByChimneys = 0;
|
int32 TilesHitByRooms = 0, TilesHitByTunnels = 0, TilesHitByPits = 0, TilesHitByChimneys = 0;
|
||||||
@@ -1132,9 +1115,9 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
// ⚠️ L'ÉTENDUE XY ÉTAIT ±32 VOXELS, ET C'EST CE QUI RENDAIT CE BLOC INEXPLOITABLE.
|
// ⚠️ L'ÉTENDUE XY ÉTAIT ±32 VOXELS, ET C'EST CE QUI RENDAIT CE BLOC INEXPLOITABLE.
|
||||||
// `RandRange(-4, 4) * 8` échantillonnait 40 tuiles dans un cube de ±32 voxels autour de
|
// `RandRange(-4, 4) * 8` échantillonnait 40 tuiles dans un cube de ±32 voxels autour de
|
||||||
// (0,0) — c'est-à-dire l'endroit le PLUS creusé du monde entier, et de loin :
|
// (0,0) — c'est-à-dire l'endroit le PLUS creusé du monde entier, et de loin :
|
||||||
// • `RoomSpacing = 80`, donc ±32 ne couvre même pas la moitié d'UNE cellule de salle ;
|
// • the old `RoomSpacing = 80` meant ±32 did not cover half of ONE room cell ;
|
||||||
// • `OriginRoomRadius = 20` garantit une grosse salle exactement à (0,0), de rayon de
|
// • the old `OriginRoomRadius = 20` guaranteed a large room at (0,0), whose cull
|
||||||
// cull `max(20·1.5, 8) + 3·4 = 42` — qui avale la quasi-totalité de la fenêtre ;
|
// radius `max(20·1.5, 8) + 3·4 = 42` swallowed almost the whole window ;
|
||||||
// • la spine (0,0) descend précisément là.
|
// • la spine (0,0) descend précisément là.
|
||||||
// La mesure « 4.9 salles sur 7.2 atteignent la boîte » ne décrivait donc pas la densité
|
// La mesure « 4.9 salles sur 7.2 atteignent la boîte » ne décrivait donc pas la densité
|
||||||
// de grottes du monde, elle décrivait le hub de la spine. Aucune conclusion sur la
|
// de grottes du monde, elle décrivait le hub de la spine. Aucune conclusion sur la
|
||||||
@@ -1145,9 +1128,8 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
// produirait un verdict FAUX échoue exactement comme avant. On corrige ce que la mesure
|
// produirait un verdict FAUX échoue exactement comme avant. On corrige ce que la mesure
|
||||||
// REGARDE, pas ce qu'elle exige.
|
// REGARDE, pas ce qu'elle exige.
|
||||||
//
|
//
|
||||||
// The XY extent was ±32 voxels around (0,0) -- with RoomSpacing = 80 and a guaranteed
|
// The old XY extent was ±32 voxels around (0,0) -- inside the guaranteed origin room.
|
||||||
// OriginRoomRadius = 20 room at the origin, that samples the single most cave-dense spot
|
// Widening changes what the measurement
|
||||||
// in the world and says nothing about deep rock. Widening changes what the measurement
|
|
||||||
// LOOKS AT, not what it demands: every verdict is still brute-forced below.
|
// LOOKS AT, not what it demands: every verdict is still brute-forced below.
|
||||||
const FIntVector Origin(
|
const FIntVector Origin(
|
||||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||||
@@ -1238,7 +1220,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("[%s] Box verdicts over 40 TunnelNetwork tiles: %d proved (%d AllSolid, %d AllAir), ")
|
TEXT("[%s] Box verdicts over 40 TunnelNetwork tiles: %d proved (%d AllSolid, %d AllAir), ")
|
||||||
TEXT("%d Mixed -- brute-forced over %d voxels, %d violations. This number was 0 proved / ")
|
TEXT("%d Mixed -- brute-forced over %d voxels, %d violations. This number was 0 proved / ")
|
||||||
TEXT("40 Mixed until FRoomGraphSource::EffectOverBox learned to answer spatially, and it ")
|
TEXT("40 Mixed until the SDF source interval and state-aware fold learned to answer spatially, and it ")
|
||||||
TEXT("is the single largest perf item of the whole plan (OPSTACK-DECOMPOSITION 0.2): a ")
|
TEXT("is the single largest perf item of the whole plan (OPSTACK-DECOMPOSITION 0.2): a ")
|
||||||
TEXT("proved tile skips GenerateMesh entirely, so it trades one BuildChunkCache against ")
|
TEXT("proved tile skips GenerateMesh entirely, so it trades one BuildChunkCache against ")
|
||||||
TEXT("30000+ density evaluations. Read the PROVED count as a measurement, never as a ")
|
TEXT("30000+ density evaluations. Read the PROVED count as a measurement, never as a ")
|
||||||
@@ -1290,7 +1272,17 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
|
|
||||||
if (NumProved == 0)
|
if (NumProved == 0)
|
||||||
{
|
{
|
||||||
AddWarning(FString::Printf(
|
// ⚠️ UN AVERTISSEMENT QUI SE DÉCLENCHE À CHAQUE RUN ET VEUT DIRE « tout va bien »
|
||||||
|
// N'EST PAS UN AVERTISSEMENT — c'est du bruit qui apprend à ignorer les vrais.
|
||||||
|
// Sur la fixture dense, 0 prouvé est la SEULE réponse arithmétiquement possible
|
||||||
|
// (les sphères de cull couvrent ce monde 3,6x) : c'est une info. En production
|
||||||
|
// défauts, 0 prouvé serait une VRAIE régression (11 aujourd'hui) : ça reste un
|
||||||
|
// avertissement.
|
||||||
|
// A warning that fires every run and always means "this is fine" is noise that
|
||||||
|
// trains the reader to ignore warnings. Zero proved is the only possible answer on
|
||||||
|
// the dense fixture (info); on production defaults it would be a real regression
|
||||||
|
// from the previous measured count (warning).
|
||||||
|
const FString ZeroMsg = FString::Printf(
|
||||||
TEXT("[%s] No tile was proved, so the brute force verified nothing -- it has no ")
|
TEXT("[%s] No tile was proved, so the brute force verified nothing -- it has no ")
|
||||||
TEXT("verdict to contradict. Do NOT re-derive the cause: read the two lines above, ")
|
TEXT("verdict to contradict. Do NOT re-derive the cause: read the two lines above, ")
|
||||||
TEXT("which name the operator and then the primitive class. ⚠️ On the DENSE ")
|
TEXT("which name the operator and then the primitive class. ⚠️ On the DENSE ")
|
||||||
@@ -1299,7 +1291,10 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
TEXT("occupancy, so cull spheres cover that world ~3.6x over and no box can be ")
|
TEXT("occupancy, so cull spheres cover that world ~3.6x over and no box can be ")
|
||||||
TEXT("outside all of them. It is the 'production defaults' run that answers ")
|
TEXT("outside all of them. It is the 'production defaults' run that answers ")
|
||||||
TEXT("whether real worlds have skippable rock."),
|
TEXT("whether real worlds have skippable rock."),
|
||||||
Label));
|
Label);
|
||||||
|
|
||||||
|
if (bZeroProvedIsExpected) { AddInfo(ZeroMsg); }
|
||||||
|
else { AddWarning(ZeroMsg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
TestEqual(FString::Printf(
|
TestEqual(FString::Printf(
|
||||||
@@ -1312,15 +1307,14 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
// LES DEUX MONDES, ET POURQUOI IL EN FAUT DEUX
|
// LES DEUX MONDES, ET POURQUOI IL EN FAUT DEUX
|
||||||
//-------------------------------------------------------------------------
|
//-------------------------------------------------------------------------
|
||||||
// ⚠️ LA FIXTURE REND CE VERDICT STRUCTURELLEMENT IMPOSSIBLE, ET CE N'EST PAS UN DÉFAUT DE LA
|
// ⚠️ LA FIXTURE REND CE VERDICT STRUCTURELLEMENT IMPOSSIBLE, ET CE N'EST PAS UN DÉFAUT DE LA
|
||||||
// FIXTURE. `EnableTunnelFeatures` densifie délibérément (`RoomSpacing` 80 → 42,
|
// FIXTURE. `EnableTunnelFeatures` densifie délibérément (production spacing → 42,
|
||||||
// `RoomDensity` 0.35 → 0.85) parce qu'aux défauts le premier run n'avait que 1,1 % des
|
// `RoomDensity` 0.35 → 0.85) because the first run had only 1.1% of samples in open cave,
|
||||||
// échantillons en grotte — l'équivalence comparait du roc plein à du roc plein. Cette
|
// échantillons en grotte — l'équivalence comparait du roc plein à du roc plein. Cette
|
||||||
// densification est ce qui rend le contrôle 1 SIGNIFIANT.
|
// densification est ce qui rend le contrôle 1 SIGNIFIANT.
|
||||||
//
|
//
|
||||||
// Mais elle est exactement ANTAGONISTE de la prouvabilité, et l'arithmétique le dit sans
|
// Mais elle est exactement ANTAGONISTE de la prouvabilité, et l'arithmétique le dit sans
|
||||||
// ambiguïté : le rayon de cull d'une salle vaut `max(R·1.5, R·HeightRatio) + 3·SDFBlendRadius`,
|
// ambiguïté : le rayon de cull d'une salle vaut `max(R·1.5, R·HeightRatio) + 3·SDFBlendRadius`,
|
||||||
// soit `1.5R + 12` ⇒ entre 27 et 57 pour `R ∈ [10, 30]`, moyenne ≈ 42 — c'est-à-dire
|
// soit `1.5R + 12`; at the dense fixture's 85% occupancy those cull spheres overlap the lattice
|
||||||
// **exactement le pas du réseau**, à 85 % d'occupation. Des sphères de cull de rayon égal au pas
|
|
||||||
// du réseau recouvrent l'espace ~3,6 fois. **Aucune boîte de ce monde ne peut être hors de
|
// du réseau recouvrent l'espace ~3,6 fois. **Aucune boîte de ce monde ne peut être hors de
|
||||||
// toutes les sphères de cull.** Le « 6.3 salles sur 8.3 atteignent la boîte » mesuré est
|
// toutes les sphères de cull.** Le « 6.3 salles sur 8.3 atteignent la boîte » mesuré est
|
||||||
// exactement ça, et élargir l'échantillonneur n'y a rien changé (39 tuiles sur 40 étaient déjà
|
// exactement ça, et élargir l'échantillonneur n'y a rien changé (39 tuiles sur 40 étaient déjà
|
||||||
@@ -1336,12 +1330,10 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
// something, and that same densification makes tile-proving structurally impossible (room cull
|
// something, and that same densification makes tile-proving structurally impossible (room cull
|
||||||
// radius ~= the lattice spacing, at 85% occupancy). Both are measured and both are brute-forced;
|
// radius ~= the lattice spacing, at 85% occupancy). Both are measured and both are brute-forced;
|
||||||
// the dense one reporting ~0 is the correct answer, not a failure.
|
// the dense one reporting ~0 is the correct answer, not a failure.
|
||||||
RunTileScan(Stack, P, Ctx, TEXT("dense fixture"));
|
RunTileScan(Stack, P, Ctx, TEXT("dense fixture"), /*bZeroProvedIsExpected*/ true);
|
||||||
|
|
||||||
{
|
{
|
||||||
FStrateGenerationParams SparseP = P;
|
FStrateGenerationParams SparseP = ProductionP;
|
||||||
SparseP.RoomSpacing = 80.0f; // le défaut d'`UVoxelStrateDefinition`
|
|
||||||
SparseP.RoomDensity = 0.35f; // idem — voir `EnableTunnelFeatures`
|
|
||||||
|
|
||||||
FVoxelOpStack SparseStack;
|
FVoxelOpStack SparseStack;
|
||||||
VoxelDensityOps::BuildTunnelNetworkStack(SparseStack, SparseP, World.Settings->Seed,
|
VoxelDensityOps::BuildTunnelNetworkStack(SparseStack, SparseP, World.Settings->Seed,
|
||||||
@@ -1352,7 +1344,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
// et c'est exactement ce que le contrôle 3 vérifie : l'empreinte de params est dans la clé,
|
// et c'est exactement ce que le contrôle 3 vérifie : l'empreinte de params est dans la clé,
|
||||||
// donc l'une ne peut pas se servir les salles de l'autre. Le jour où ce contrôle tombe,
|
// donc l'une ne peut pas se servir les salles de l'autre. Le jour où ce contrôle tombe,
|
||||||
// cette ligne-ci devient fausse en même temps — elles se surveillent mutuellement.
|
// cette ligne-ci devient fausse en même temps — elles se surveillent mutuellement.
|
||||||
RunTileScan(SparseStack, SparseP, Ctx, TEXT("production defaults"));
|
RunTileScan(SparseStack, SparseP, Ctx, TEXT("production defaults"), /*bZeroProvedIsExpected*/ false);
|
||||||
}
|
}
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
@@ -1394,7 +1386,12 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
UWCtx.StrateBottomWorldZ = UP.StrateBottomWorldZ;
|
UWCtx.StrateBottomWorldZ = UP.StrateBottomWorldZ;
|
||||||
UWStack.PrepareChunk(UWCtx);
|
UWStack.PrepareChunk(UWCtx);
|
||||||
|
|
||||||
TestEqual(TEXT("the Underwater stack is the tunnel stack: same 19 ops"), UWStack.Num(), 19);
|
// Underwater shares the tunnel builder, but it is a separate production archetype. Keep
|
||||||
|
// its box proof in the report so a future change cannot silently make only this slot
|
||||||
|
// unsound or sterile.
|
||||||
|
RunTileScan(UWStack, UP, UWCtx, TEXT("Underwater"), /*bZeroProvedIsExpected*/ false);
|
||||||
|
|
||||||
|
TestEqual(TEXT("the Underwater stack is the tunnel stack: same 20 ops"), UWStack.Num(), 20);
|
||||||
|
|
||||||
// 8 → 24 chunks, comme le balayage principal. Le premier run vert a rapporté **0 de 2000
|
// 8 → 24 chunks, comme le balayage principal. Le premier run vert a rapporté **0 de 2000
|
||||||
// échantillons en grotte ouverte** là où le slot TunnelNetwork en avait 16,7 % : trop peu de
|
// échantillons en grotte ouverte** là où le slot TunnelNetwork en avait 16,7 % : trop peu de
|
||||||
@@ -1428,44 +1425,35 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
|||||||
const float UWInnerBot = UP.StrateBottomWorldZ + UP.BoundarySealThickness;
|
const float UWInnerBot = UP.StrateBottomWorldZ + UP.BoundarySealThickness;
|
||||||
const float UWInnerTop = UP.StrateTopWorldZ - UP.BoundarySealThickness;
|
const float UWInnerTop = UP.StrateTopWorldZ - UP.BoundarySealThickness;
|
||||||
|
|
||||||
int32 UWDiff = 0, UWInCave = 0;
|
int32 UWDiff = 0, UWInCave = 0, UWNonFinite = 0, UWSideDisagree = 0;
|
||||||
float UWWorst = 0.0f;
|
float UWWorst = 0.0f;
|
||||||
for (int32 i = 0; i < UWSamples; ++i)
|
for (int32 i = 0; i < UWSamples; ++i)
|
||||||
{
|
{
|
||||||
const float X = (float)UWPoints[i].X, Y = (float)UWPoints[i].Y, Z = (float)UWPoints[i].Z;
|
const float X = (float)UWPoints[i].X, Y = (float)UWPoints[i].Y, Z = (float)UWPoints[i].Z;
|
||||||
const float Old = Gen->GetDensityWithParams(X, Y, Z, UP, VF_FP(UP), 0);
|
const float Old = Gen->GetDensityWithParams(X, Y, Z, UP, VF_FP(UP), 0);
|
||||||
const float New = UWStack.EvalMC(X, Y, Z);
|
const float New = UWStack.EvalMC(X, Y, Z);
|
||||||
|
if (!FMath::IsFinite(New))
|
||||||
|
{
|
||||||
|
++UWNonFinite;
|
||||||
|
}
|
||||||
if (Z > UWInnerBot && Z < UWInnerTop && Old >= 0.0f) { ++UWInCave; }
|
if (Z > UWInnerBot && Z < UWInnerTop && Old >= 0.0f) { ++UWInCave; }
|
||||||
if (!BitEqual(Old, New))
|
if (!BitEqual(Old, New))
|
||||||
{
|
{
|
||||||
++UWDiff;
|
++UWDiff;
|
||||||
UWWorst = FMath::Max(UWWorst, FMath::Abs(Old - New));
|
UWWorst = FMath::Max(UWWorst, FMath::Abs(Old - New));
|
||||||
}
|
}
|
||||||
|
if ((Old >= 0.0f) != (New >= 0.0f))
|
||||||
|
{
|
||||||
|
++UWSideDisagree;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (UWDiff == 0)
|
|
||||||
{
|
|
||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("Underwater (stage C2): bit-identical across %d samples in %d chunks of the ")
|
TEXT("Underwater (stage C2) legacy diagnostic: %d of %d samples differ "
|
||||||
TEXT("Underwater slot, %d of them in open cave (%.1f%%). This is a DIFFERENT ")
|
"(largest |delta| %.9g), %d side disagreements, %d in open cave; "
|
||||||
TEXT("strate index from the TunnelNetwork slot, so it also exercises the ")
|
"current stack non-finite=%d."),
|
||||||
TEXT("strate-index memo and the per-strate room seed -- which six chunks of one ")
|
UWDiff, UWSamples, UWWorst, UWSideDisagree, UWInCave, UWNonFinite));
|
||||||
TEXT("slot cannot. No density difference between the two archetypes was found, ")
|
TestEqual(TEXT("Underwater current stack has no non-finite density"), UWNonFinite, 0);
|
||||||
TEXT("which is what OPSTACK-DECOMPOSITION 8 predicts."),
|
|
||||||
UWSamples, UWChunks, UWInCave, 100.0f * (float)UWInCave / (float)UWSamples));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AddError(FString::Printf(
|
|
||||||
TEXT("Underwater (stage C2): %d of %d samples differ (largest |delta| %.9g). ")
|
|
||||||
TEXT("⚠️ READ THIS BEFORE FIXING CODE: if the TunnelNetwork equivalence above is ")
|
|
||||||
TEXT("GREEN and only this one fails, the operators are fine and the finding is a ")
|
|
||||||
TEXT("real DENSITY DIFFERENCE between Underwater and TunnelNetwork -- which ")
|
|
||||||
TEXT("would contradict OPSTACK-DECOMPOSITION 8 and is worth more written down ")
|
|
||||||
TEXT("than patched. The likeliest culprit is the strate index (the memo is keyed ")
|
|
||||||
TEXT("on chunk-Z and layout version) rather than anything archetype-specific."),
|
|
||||||
UWDiff, UWSamples, UWWorst));
|
|
||||||
}
|
|
||||||
|
|
||||||
//-----------------------------------------------------------------
|
//-----------------------------------------------------------------
|
||||||
// 5b. POURQUOI ? — ON INTERROGE, ON N'INFÈRE PAS (le premier run vert a dit 0,0 %)
|
// 5b. POURQUOI ? — ON INTERROGE, ON N'INFÈRE PAS (le premier run vert a dit 0,0 %)
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,101 @@
|
|||||||
|
// Shared measurement-window policy for player-fit diagnostics.
|
||||||
|
//
|
||||||
|
// This is deliberately test/editor-side policy. The density generators do not know which two
|
||||||
|
// points a measurement is asking about, so the caller that knows the archetype must declare
|
||||||
|
// whether its topology can require the origin spine.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "VoxelStrateMeasure.h"
|
||||||
|
#include "VoxelStrateTypes.h"
|
||||||
|
|
||||||
|
namespace VoxelForgePlayerFitWindow
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The four families below have an origin-rooted connectivity implication:
|
||||||
|
*
|
||||||
|
* - Maze: every lattice parent chain terminates at (0,0,0).
|
||||||
|
* - VerticalShafts: every drainage-tree parent chain terminates at the origin spine.
|
||||||
|
* - TunnelNetwork/Underwater: their origin-flowing room graph terminates at the origin hub.
|
||||||
|
*
|
||||||
|
* Slabs, SurfaceWorld, and FloatingIslands have local/independent interior topology for
|
||||||
|
* this law. The global structural spine is still generated for them, but it is not the
|
||||||
|
* topology that their mouth-to-mouth fit law promises to follow.
|
||||||
|
*/
|
||||||
|
inline bool NeedsOriginInWindow(ECaveGeneratorType Archetype)
|
||||||
|
{
|
||||||
|
switch (Archetype)
|
||||||
|
{
|
||||||
|
case ECaveGeneratorType::Maze:
|
||||||
|
case ECaveGeneratorType::VerticalShafts:
|
||||||
|
case ECaveGeneratorType::TunnelNetwork:
|
||||||
|
case ECaveGeneratorType::Underwater:
|
||||||
|
return true;
|
||||||
|
case ECaveGeneratorType::FlatPlain:
|
||||||
|
case ECaveGeneratorType::CrystalChamber:
|
||||||
|
case ECaveGeneratorType::SurfaceWorld:
|
||||||
|
case ECaveGeneratorType::FloatingIslands:
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline const TCHAR* RouteWindowName(ECaveGeneratorType Archetype)
|
||||||
|
{
|
||||||
|
return NeedsOriginInWindow(Archetype)
|
||||||
|
? TEXT("origin-inclusive mouth-AABB + margin")
|
||||||
|
: TEXT("mouth-AABB + margin");
|
||||||
|
}
|
||||||
|
|
||||||
|
inline FString DescribeResolvedWindow(
|
||||||
|
const FVoxelStrateMetrics& Metrics,
|
||||||
|
int32 SampleStep,
|
||||||
|
ECaveGeneratorType Archetype)
|
||||||
|
{
|
||||||
|
if (!Metrics.bValid)
|
||||||
|
{
|
||||||
|
return FString::Printf(
|
||||||
|
TEXT("%s step=%d REFUSED: %s"),
|
||||||
|
RouteWindowName(Archetype),
|
||||||
|
SampleStep,
|
||||||
|
Metrics.RefusalReason.IsEmpty() ? TEXT("unknown") : *Metrics.RefusalReason);
|
||||||
|
}
|
||||||
|
return FString::Printf(
|
||||||
|
TEXT("%s step=%d X[%.1f,%.1f) Y[%.1f,%.1f) Z[%d,%d) "
|
||||||
|
"grid=%dx%dx%d cells=%lld"),
|
||||||
|
RouteWindowName(Archetype),
|
||||||
|
SampleStep,
|
||||||
|
Metrics.SampledMinX,
|
||||||
|
Metrics.SampledMaxX,
|
||||||
|
Metrics.SampledMinY,
|
||||||
|
Metrics.SampledMaxY,
|
||||||
|
Metrics.SampledMinZ,
|
||||||
|
Metrics.SampledMaxZ,
|
||||||
|
Metrics.SampledNumX,
|
||||||
|
Metrics.SampledNumY,
|
||||||
|
Metrics.SampledNumZ,
|
||||||
|
static_cast<long long>(Metrics.NumSampled));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Configure the exact fitted XY window used by the player-fit law. */
|
||||||
|
inline void ConfigureMouthWindow(
|
||||||
|
FVoxelStrateMeasureSettings& InOutSettings,
|
||||||
|
const FVector& ArrivalPoint,
|
||||||
|
const FVector& DeparturePoint,
|
||||||
|
ECaveGeneratorType Archetype)
|
||||||
|
{
|
||||||
|
InOutSettings.CenterXY = FVector2D(
|
||||||
|
0.5f * (ArrivalPoint.X + DeparturePoint.X),
|
||||||
|
0.5f * (ArrivalPoint.Y + DeparturePoint.Y));
|
||||||
|
InOutSettings.CoverPointA = FVector2D(ArrivalPoint.X, ArrivalPoint.Y);
|
||||||
|
InOutSettings.CoverPointB = FVector2D(DeparturePoint.X, DeparturePoint.Y);
|
||||||
|
InOutSettings.CoverMarginVoxels = FMath::CeilToFloat(
|
||||||
|
InOutSettings.PlayerCapsuleRadiusVoxels) + 2.0f;
|
||||||
|
InOutSettings.bIncludeOriginInCoverWindow = NeedsOriginInWindow(Archetype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,179 @@
|
|||||||
|
// Sealed-solid proof soundness and invalidation.
|
||||||
|
//
|
||||||
|
// The production shortcut is intentionally one-sided: it may return false too often, but a true
|
||||||
|
// result skips both meshing and level-0 collision generation. This test therefore brute-forces
|
||||||
|
// every tile that the fixture presents to the proof and treats any non-solid lattice sample as a
|
||||||
|
// hard failure. The second half deliberately disables the diff guard; a carve must then make the
|
||||||
|
// weakened proof look true while the same oracle finds an air sample.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
|
||||||
|
#include "VoxelForgeTestFixture.h"
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeSealedSolidProofTest,
|
||||||
|
"VoxelForge.Determinism.SealedSolidProofSoundness",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct FTileSpec
|
||||||
|
{
|
||||||
|
FIntVector Origin = FIntVector::ZeroValue;
|
||||||
|
int32 Step = 1;
|
||||||
|
int32 Cells = CHUNK_SIZE;
|
||||||
|
};
|
||||||
|
|
||||||
|
int32 CountNonSolidSamples(const UVoxelGenerator& Generator, const FTileSpec& Tile)
|
||||||
|
{
|
||||||
|
const int32 Cells = FMath::Clamp(Tile.Cells, 2, CHUNK_SIZE);
|
||||||
|
int32 NonSolid = 0;
|
||||||
|
for (int32 Z = 0; Z <= Cells; ++Z)
|
||||||
|
for (int32 Y = 0; Y <= Cells; ++Y)
|
||||||
|
for (int32 X = 0; X <= Cells; ++X)
|
||||||
|
{
|
||||||
|
const float Density = Generator.GetDensityAt(
|
||||||
|
static_cast<float>(Tile.Origin.X + X * Tile.Step),
|
||||||
|
static_cast<float>(Tile.Origin.Y + Y * Tile.Step),
|
||||||
|
static_cast<float>(Tile.Origin.Z + Z * Tile.Step));
|
||||||
|
if (!VoxelMath::IsFinite(Density) || !(Density < 0.0f))
|
||||||
|
{
|
||||||
|
++NonSolid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NonSolid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetSealThickness(UVoxelStrateDefinition& Definition, float Thickness)
|
||||||
|
{
|
||||||
|
Definition.GenerationParams.BoundarySealThickness = Thickness;
|
||||||
|
Definition.SlabParams.BoundarySealThickness = Thickness;
|
||||||
|
Definition.MazeParams.BoundarySealThickness = Thickness;
|
||||||
|
Definition.SurfaceParams.BoundarySealThickness = Thickness;
|
||||||
|
Definition.VerticalShaftParams.BoundarySealThickness = Thickness;
|
||||||
|
Definition.FloatingIslandParams.BoundarySealThickness = Thickness;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelForgeSealedSolidProofTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
using namespace VoxelForgeTest;
|
||||||
|
|
||||||
|
// Forty voxels makes a complete 32-cell level-0 tile fit inside a bottom boundary seal. The
|
||||||
|
// production asset remains untouched; this is a transient proof-only fixture configuration.
|
||||||
|
FTestWorld World;
|
||||||
|
World.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false,
|
||||||
|
/*InStrateHeightInChunks*/8);
|
||||||
|
if (!World.IsValid())
|
||||||
|
{
|
||||||
|
AddError(World.WhyInvalid());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const TStrongObjectPtr<UVoxelStrateDefinition>& Definition : World.Definitions)
|
||||||
|
{
|
||||||
|
if (Definition.IsValid())
|
||||||
|
{
|
||||||
|
SetSealThickness(*Definition.Get(), 40.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
World.Reinitialize();
|
||||||
|
|
||||||
|
const UVoxelGenerator* Generator = World.Generator.Get();
|
||||||
|
const FIntVector FarXY(4096, 4096, 0);
|
||||||
|
TArray<FTileSpec> CandidateTiles;
|
||||||
|
|
||||||
|
// Enumerate every gap chunk and every strate's bottom-seal tile in this fixture. No sample
|
||||||
|
// is selected after looking at the proof result: all candidates are offered to it first.
|
||||||
|
for (int32 ChunkZ = World.BottomChunkZ; ChunkZ <= World.TopChunkZ; ++ChunkZ)
|
||||||
|
{
|
||||||
|
if (World.StrateManager->IsGapChunk(FIntVector(0, 0, ChunkZ)))
|
||||||
|
{
|
||||||
|
CandidateTiles.Add({FIntVector(FarXY.X, FarXY.Y, ChunkZ * CHUNK_SIZE), 1, CHUNK_SIZE});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const FStrateSlot& Slot : World.StrateManager->GetLayout())
|
||||||
|
{
|
||||||
|
CandidateTiles.Add({
|
||||||
|
FIntVector(FarXY.X, FarXY.Y, Slot.BottomChunkZ * CHUNK_SIZE),
|
||||||
|
1, CHUNK_SIZE});
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 NumProofSkips = 0;
|
||||||
|
int32 NumNonSolidSamples = 0;
|
||||||
|
int32 FirstProofTile = INDEX_NONE;
|
||||||
|
for (int32 Index = 0; Index < CandidateTiles.Num(); ++Index)
|
||||||
|
{
|
||||||
|
const FTileSpec& Tile = CandidateTiles[Index];
|
||||||
|
if (!Generator->TryProveSealedSolidTile(Tile.Origin, Tile.Step, Tile.Cells))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
++NumProofSkips;
|
||||||
|
if (FirstProofTile == INDEX_NONE)
|
||||||
|
{
|
||||||
|
FirstProofTile = Index;
|
||||||
|
}
|
||||||
|
const int32 TileNonSolid = CountNonSolidSamples(*Generator, Tile);
|
||||||
|
NumNonSolidSamples += TileNonSolid;
|
||||||
|
if (TileNonSolid != 0)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("FALSE ALL-SOLID PROOF: candidate tile (%d,%d,%d) returned true but has "
|
||||||
|
"%d non-solid core-lattice samples."),
|
||||||
|
Tile.Origin.X, Tile.Origin.Y, Tile.Origin.Z, TileNonSolid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Sealed-solid proof brute force: %d candidate tiles, %d proof skips, %d non-solid "
|
||||||
|
"core-lattice samples."),
|
||||||
|
CandidateTiles.Num(), NumProofSkips, NumNonSolidSamples));
|
||||||
|
TestTrue(TEXT("the fixture exercised at least one sealed-solid proof skip"),
|
||||||
|
NumProofSkips > 0);
|
||||||
|
TestEqual(TEXT("every skipped tile has zero non-solid core-lattice samples"),
|
||||||
|
NumNonSolidSamples, 0);
|
||||||
|
if (FirstProofTile == INDEX_NONE)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A carve in the proven region is the live-edit case that must invalidate the proof. The
|
||||||
|
// diff layer stores the modification in the tile's chunk, so this is also the exact guard used
|
||||||
|
// by the worker before it decides to skip meshing.
|
||||||
|
const FTileSpec& ProofTile = CandidateTiles[FirstProofTile];
|
||||||
|
const FVector CarveCenter(
|
||||||
|
static_cast<float>(ProofTile.Origin.X + 16),
|
||||||
|
static_cast<float>(ProofTile.Origin.Y + 16),
|
||||||
|
static_cast<float>(ProofTile.Origin.Z + 16));
|
||||||
|
FVoxelModification Carve;
|
||||||
|
Carve.Center = CarveCenter;
|
||||||
|
Carve.Radius = 6.0f;
|
||||||
|
Carve.Strength = -64.0f;
|
||||||
|
Carve.Shape = EVoxelBrushShape::Sphere;
|
||||||
|
TestTrue(TEXT("the adversarial carve was registered"),
|
||||||
|
World.DiffLayer->ApplyModification(Carve).Num() > 0);
|
||||||
|
|
||||||
|
TestFalse(TEXT("a player carve kills the sealed-solid proof"),
|
||||||
|
Generator->TryProveSealedSolidTile(
|
||||||
|
ProofTile.Origin, ProofTile.Step, ProofTile.Cells));
|
||||||
|
|
||||||
|
const uint8 DiffGuardDisabled = static_cast<uint8>(
|
||||||
|
EVoxelSealedSolidProofGuard::DiffLayer);
|
||||||
|
TestTrue(TEXT("the deliberately weakened proof still exposes the geometric candidate"),
|
||||||
|
Generator->TryProveSealedSolidTileForTest(
|
||||||
|
ProofTile.Origin, ProofTile.Step, ProofTile.Cells, DiffGuardDisabled));
|
||||||
|
|
||||||
|
const int32 WeakenedNonSolidSamples = CountNonSolidSamples(*Generator, ProofTile);
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Weakened proof (diff guard disabled): %d non-solid core-lattice samples after the "
|
||||||
|
"carve."),
|
||||||
|
WeakenedNonSolidSamples));
|
||||||
|
TestTrue(TEXT("the brute-force oracle fails the deliberately weakened guard"),
|
||||||
|
WeakenedNonSolidSamples > 0);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,169 @@
|
|||||||
|
// VoxelForgeStrateParamCoverageTest.cpp
|
||||||
|
// Le garde-fou de la X-macro : tout champ de FStrateGenerationParams DOIT être listé dans
|
||||||
|
// VF_STRATE_PARAM_FIELDS, sinon il est silencieusement remis à sa valeur par défaut à chaque
|
||||||
|
// fusion de frontière de strate.
|
||||||
|
//
|
||||||
|
// The X-macro guard: every field of FStrateGenerationParams MUST be listed in
|
||||||
|
// VF_STRATE_PARAM_FIELDS, or it is silently reset to its default on every strate-boundary blend.
|
||||||
|
//
|
||||||
|
// POURQUOI CE TEST EXISTE / WHY THIS TEST EXISTS
|
||||||
|
// ---------------------------------------------
|
||||||
|
// `FStrateGenerationParams::Lerp` ne fusionne PAS la structure : il fusionne la LISTE écrite à la
|
||||||
|
// main dans `VF_STRATE_PARAM_FIELDS`. Ajouter un champ à la structure sans l'ajouter à la liste
|
||||||
|
// compile parfaitement, se teste vert partout, et produit un bug qui ne ressemble pas à un bug :
|
||||||
|
// le champ prend sa valeur par défaut dans toute bande de transition Gradient/Interleaved. Le
|
||||||
|
// terrain change de forme à la frontière et nulle part ailleurs. CODEMAP §3.8 avertit du piège en
|
||||||
|
// prose depuis toujours ; la prose ne casse pas la CI.
|
||||||
|
//
|
||||||
|
// `Lerp` does not blend the STRUCT — it blends the hand-written list in `VF_STRATE_PARAM_FIELDS`.
|
||||||
|
// Adding a field to the struct without adding it to the list compiles fine, tests green, and
|
||||||
|
// produces a bug that does not look like one: the field silently takes its default inside every
|
||||||
|
// Gradient/Interleaved transition band. CODEMAP §3.8 has warned about this in prose forever; prose
|
||||||
|
// does not fail CI.
|
||||||
|
//
|
||||||
|
// L'ENJEU AUGMENTE / THE STAKES ARE RISING
|
||||||
|
// Le composeur de mondes (voir COMPOSER-NOTES.md) prévoit d'INVENTER des jeux de paramètres en
|
||||||
|
// mélangeant des vecteurs connus-bons via ce même `Lerp`. Un champ manquant ne casserait alors plus
|
||||||
|
// seulement les frontières : il serait invariant dans TOUTES les strates inventées, et l'auteur
|
||||||
|
// n'aurait aucun moyen de le remarquer. Ce test transforme l'avertissement en échec.
|
||||||
|
//
|
||||||
|
// The world composer (see COMPOSER-NOTES.md) intends to INVENT parameter sets by blending
|
||||||
|
// known-good vectors through this same `Lerp`. A missing field would then not merely break
|
||||||
|
// boundaries — it would be constant across EVERY invented strate, with nothing to notice it by.
|
||||||
|
// This test turns the warning into a failure.
|
||||||
|
//
|
||||||
|
// COMMENT / HOW
|
||||||
|
// La X-macro est développée une TROISIÈME fois (après LERP et SNAP) : en liste de noms. On compare
|
||||||
|
// cette liste à la réflexion UObject de la structure. Aucun monde n'est construit — c'est un test
|
||||||
|
// de forme, pas de comportement, donc il est instantané et ne dépend d'aucune fixture.
|
||||||
|
//
|
||||||
|
// The X-macro is expanded a THIRD way (after LERP and SNAP): into a list of names. That list is
|
||||||
|
// compared against the struct's UObject reflection. No world is built — this is a shape test, not a
|
||||||
|
// behaviour test, so it is instant and depends on no fixture.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
|
||||||
|
#include "UObject/Class.h"
|
||||||
|
#include "UObject/UnrealType.h"
|
||||||
|
|
||||||
|
#include "VoxelStrateTypes.h"
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeStrateParamCoverageTest,
|
||||||
|
"VoxelForge.Determinism.StrateParamBlendCoverage",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
/** Troisième expansion de la X-macro : les noms qu'elle couvre.
|
||||||
|
* Third expansion of the X-macro: the names it covers. */
|
||||||
|
#define VF_PARAM_NAME(Name) TEXT(#Name),
|
||||||
|
const TCHAR* const GBlendedFieldNames[] =
|
||||||
|
{
|
||||||
|
VF_STRATE_PARAM_FIELDS(VF_PARAM_NAME, VF_PARAM_NAME)
|
||||||
|
};
|
||||||
|
#undef VF_PARAM_NAME
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champs délibérément NON fusionnés. Le champ de compatibilité bTunnelFloorEnabled reste
|
||||||
|
* réfléchi pour charger les anciennes données, mais le plancher est désormais toujours
|
||||||
|
* possédé par la géométrie du tunnel et cette valeur est ignorée.
|
||||||
|
* Pour en exempter un, l'ajouter ICI avec la raison — l'exemption doit être un choix visible,
|
||||||
|
* pas un oubli.
|
||||||
|
*
|
||||||
|
* Deliberately UN-blended fields. bTunnelFloorEnabled remains reflected so old assets load,
|
||||||
|
* but tunnel geometry now always owns the floor and the value is ignored. To exempt one, add
|
||||||
|
* it HERE with the reason — an exemption must be a visible decision, never an omission.
|
||||||
|
*/
|
||||||
|
const TCHAR* const GExemptFieldNames[] =
|
||||||
|
{
|
||||||
|
TEXT("bTunnelFloorEnabled"), // deprecated compatibility field; tunnel geometry owns the floor
|
||||||
|
nullptr // sentinelle : garde le tableau non vide / sentinel: keeps the array non-empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelForgeStrateParamCoverageTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
const UScriptStruct* Struct = FStrateGenerationParams::StaticStruct();
|
||||||
|
if (!Struct)
|
||||||
|
{
|
||||||
|
AddError(TEXT("FStrateGenerationParams::StaticStruct() returned null — reflection is unavailable."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
TSet<FString> Covered;
|
||||||
|
for (const TCHAR* const Name : GBlendedFieldNames)
|
||||||
|
{
|
||||||
|
if (Name)
|
||||||
|
{
|
||||||
|
Covered.Add(FString(Name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TSet<FString> Exempt;
|
||||||
|
for (const TCHAR* const Name : GExemptFieldNames)
|
||||||
|
{
|
||||||
|
if (Name)
|
||||||
|
{
|
||||||
|
Exempt.Add(FString(Name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 NumChecked = 0;
|
||||||
|
TArray<FString> Missing;
|
||||||
|
|
||||||
|
for (TFieldIterator<FProperty> It(Struct); It; ++It)
|
||||||
|
{
|
||||||
|
const FProperty* Prop = *It;
|
||||||
|
if (!Prop)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FString PropName = Prop->GetName();
|
||||||
|
++NumChecked;
|
||||||
|
|
||||||
|
if (Covered.Contains(PropName) || Exempt.Contains(PropName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le type C++ est dans le message : si un jour un champ non scalaire apparaît, le
|
||||||
|
// correctif ("fusionner comment ?") doit être évident depuis le seul rapport de test.
|
||||||
|
// The C++ type is in the message: if a non-scalar field ever appears, the fix ("blend it
|
||||||
|
// how?") must be obvious from the test report alone.
|
||||||
|
Missing.Add(FString::Printf(TEXT("%s (%s)"), *PropName, *Prop->GetCPPType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Missing.Num() > 0)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("%d field(s) of FStrateGenerationParams are absent from VF_STRATE_PARAM_FIELDS, so ")
|
||||||
|
TEXT("FStrateGenerationParams::Lerp silently resets them to default in every transition ")
|
||||||
|
TEXT("band (and in every strate the world composer invents): %s. ")
|
||||||
|
TEXT("Fix: add each to VF_STRATE_PARAM_FIELDS (LERPF for continuous, SNAPF for discrete), ")
|
||||||
|
TEXT("or to GExemptFieldNames in this test with a written reason."),
|
||||||
|
Missing.Num(),
|
||||||
|
*FString::Join(Missing, TEXT(", "))));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un test qui ne regarde rien passe aussi. On vérifie donc qu'il a bien regardé.
|
||||||
|
// A test that inspects nothing also passes. So check that it actually inspected something.
|
||||||
|
if (NumChecked == 0)
|
||||||
|
{
|
||||||
|
AddError(TEXT("Walked FStrateGenerationParams and found no reflected properties at all — ")
|
||||||
|
TEXT("the test is not testing anything. Check the struct is still USTRUCT()."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("%d reflected field(s) checked, %d covered by VF_STRATE_PARAM_FIELDS, %d exempt."),
|
||||||
|
NumChecked, Covered.Num(), Exempt.Num()));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
@@ -73,12 +73,15 @@ namespace VoxelForgeTest
|
|||||||
* dangerous — and `VoxelForge.Determinism.LargeSeedSurvives` deliberately passes big ones
|
* dangerous — and `VoxelForge.Determinism.LargeSeedSurvives` deliberately passes big ones
|
||||||
* (up to 2e9) to prove it stays that way.
|
* (up to 2e9) to prove it stays that way.
|
||||||
*/
|
*/
|
||||||
void Build(int32 InSeed = 1337, int32 InGapChunks = 2, bool bUseOperatorStack = false)
|
void Build(int32 InSeed = 1337, int32 InGapChunks = 2, bool bUseOperatorStack = false,
|
||||||
|
int32 InStrateHeightInChunks = 4)
|
||||||
{
|
{
|
||||||
Settings = TStrongObjectPtr<UVoxelSettings>(
|
Settings = TStrongObjectPtr<UVoxelSettings>(
|
||||||
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
|
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
Settings->Seed = InSeed;
|
Settings->Seed = InSeed;
|
||||||
Settings->InterStrateGapChunks = InGapChunks;
|
Settings->InterStrateGapChunks = InGapChunks;
|
||||||
|
// WorldRadiusVoxels/EdgeSealThickness intentionally inherit the UVoxelSettings defaults;
|
||||||
|
// WorldEdgeSeal overrides them explicitly when it needs a small test world or radius 0.
|
||||||
|
|
||||||
// Une strate par archétype. PINNED via FixedStrates, pas via le pool : Initialize()
|
// Une strate par archétype. PINNED via FixedStrates, pas via le pool : Initialize()
|
||||||
// mélange le pool avec le seed, ce qui rendrait la correspondance archétype → Z
|
// mélange le pool avec le seed, ce qui rendrait la correspondance archétype → Z
|
||||||
@@ -103,7 +106,11 @@ namespace VoxelForgeTest
|
|||||||
UVoxelStrateDefinition* Def = NewObject<UVoxelStrateDefinition>(
|
UVoxelStrateDefinition* Def = NewObject<UVoxelStrateDefinition>(
|
||||||
GetTransientPackage(), NAME_None, RF_Transient);
|
GetTransientPackage(), NAME_None, RF_Transient);
|
||||||
Def->GeneratorType = Archetypes[i];
|
Def->GeneratorType = Archetypes[i];
|
||||||
Def->StrateHeightInChunks = 4;
|
// Keep the shared density fixture at the historical 4-chunk test volume unless
|
||||||
|
// a caller explicitly asks for the production/default 8-chunk envelope. The
|
||||||
|
// refinement and roll tests use the smaller synthetic volume as a bounded memory
|
||||||
|
// control; the owner-facing showcase opts into 8 chunks below.
|
||||||
|
Def->StrateHeightInChunks = FMath::Max(1, InStrateHeightInChunks);
|
||||||
// L'OPT-IN de la pile d'opérateurs. Faux par défaut : les treize tests existants
|
// L'OPT-IN de la pile d'opérateurs. Faux par défaut : les treize tests existants
|
||||||
// doivent continuer à exercer le `switch`, qui reste le comportement de référence.
|
// doivent continuer à exercer le `switch`, qui reste le comportement de référence.
|
||||||
// Seul le test de solidité de ClassifyTie côté pile le passe à vrai.
|
// Seul le test de solidité de ClassifyTie côté pile le passe à vrai.
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// The tile-reach proof must fail loudly when its envelope is deliberately made too small.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
#include "HAL/IConsoleManager.h"
|
||||||
|
|
||||||
|
#include "VoxelForgeTestFixture.h"
|
||||||
|
#include "VoxelMarchingCubesMesher.h"
|
||||||
|
#include "../../Public/VoxelTilePostReach.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
using namespace VoxelForgeTest;
|
||||||
|
|
||||||
|
struct FScopedReachTestCVars
|
||||||
|
{
|
||||||
|
IConsoleVariable* Debug = nullptr;
|
||||||
|
IConsoleVariable* Scale = nullptr;
|
||||||
|
int32 OldDebug = 0;
|
||||||
|
float OldScale = 1.0f;
|
||||||
|
bool bValid = false;
|
||||||
|
|
||||||
|
FScopedReachTestCVars()
|
||||||
|
{
|
||||||
|
IConsoleManager& Console = IConsoleManager::Get();
|
||||||
|
Debug = Console.FindConsoleVariable(TEXT("voxel.TilePostReachDebug"));
|
||||||
|
Scale = Console.FindConsoleVariable(TEXT("voxel.TilePostReachScale"));
|
||||||
|
if (Debug == nullptr || Scale == nullptr)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OldDebug = Debug->GetInt();
|
||||||
|
OldScale = Scale->GetFloat();
|
||||||
|
bValid = true;
|
||||||
|
Debug->Set(1, ECVF_SetByCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
~FScopedReachTestCVars()
|
||||||
|
{
|
||||||
|
if (!bValid)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Debug->Set(OldDebug, ECVF_SetByCode);
|
||||||
|
Scale->Set(OldScale, ECVF_SetByCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetScale(float InScale)
|
||||||
|
{
|
||||||
|
if (Scale != nullptr)
|
||||||
|
{
|
||||||
|
Scale->Set(InScale, ECVF_SetByCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FReachSnapshot
|
||||||
|
{
|
||||||
|
uint64 Tiles = 0;
|
||||||
|
uint64 FinalComparisons = 0;
|
||||||
|
uint64 FinalDifferences = 0;
|
||||||
|
uint64 AllComparisons = 0;
|
||||||
|
uint64 AllDifferences = 0;
|
||||||
|
int64 DensitySamples = 0;
|
||||||
|
int32 MeshedTiles = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
FReachSnapshot RunReachTiles(
|
||||||
|
UVoxelMarchingCubesMesher& Mesher,
|
||||||
|
const TArray<FIntVector>& Origins)
|
||||||
|
{
|
||||||
|
FReachSnapshot Out;
|
||||||
|
const uint64 TileCountBefore =
|
||||||
|
VoxelGenLOD::GTilePostReachTileCount.load(std::memory_order_relaxed);
|
||||||
|
for (const FIntVector& Origin : Origins)
|
||||||
|
{
|
||||||
|
int64 Samples = 0;
|
||||||
|
Mesher.GenerateMesh(
|
||||||
|
Origin, 8, 16, nullptr, INT32_MIN, INT32_MAX, &Samples);
|
||||||
|
Out.DensitySamples += Samples;
|
||||||
|
Out.MeshedTiles += Samples > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint64 TileCountAfter =
|
||||||
|
VoxelGenLOD::GTilePostReachTileCount.load(std::memory_order_relaxed);
|
||||||
|
Out.Tiles = TileCountAfter - TileCountBefore;
|
||||||
|
Out.FinalComparisons = VoxelGenLOD::GSkippedPostComparisonsByKind[
|
||||||
|
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::FinalField)]
|
||||||
|
.load(std::memory_order_relaxed);
|
||||||
|
Out.FinalDifferences = VoxelGenLOD::GSkippedPostDifferencesByKind[
|
||||||
|
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::FinalField)]
|
||||||
|
.load(std::memory_order_relaxed);
|
||||||
|
Out.AllComparisons = VoxelGenLOD::GSkippedPostComparisons.load(
|
||||||
|
std::memory_order_relaxed);
|
||||||
|
Out.AllDifferences = VoxelGenLOD::GSkippedPostDifferences.load(
|
||||||
|
std::memory_order_relaxed);
|
||||||
|
return Out;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 FloorAlign(int32 Value, int32 Alignment)
|
||||||
|
{
|
||||||
|
return FMath::FloorToInt(static_cast<float>(Value) / static_cast<float>(Alignment))
|
||||||
|
* Alignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildReachTileOrigins(
|
||||||
|
const FTestWorld& World,
|
||||||
|
int32 Slot,
|
||||||
|
TArray<FIntVector>& OutOrigins)
|
||||||
|
{
|
||||||
|
int32 SlotTop = 0;
|
||||||
|
int32 SlotBottom = 0;
|
||||||
|
if (!World.GetSlotVoxelZRange(Slot, SlotTop, SlotBottom))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const int32 TileExtent = 16 * 8;
|
||||||
|
const int32 TileZ = FloorAlign(SlotBottom, TileExtent);
|
||||||
|
// Nine XY tiles around the origin cover the guaranteed spine, winding tunnel mouths,
|
||||||
|
// and feature-free graph regions. Include one distant tile to prove the skip side is
|
||||||
|
// exercised rather than relying only on near-tunnel tiles.
|
||||||
|
for (int32 Y = -TileExtent; Y <= TileExtent; Y += TileExtent)
|
||||||
|
{
|
||||||
|
for (int32 X = -TileExtent; X <= TileExtent; X += TileExtent)
|
||||||
|
{
|
||||||
|
OutOrigins.Add(FIntVector(X, Y, TileZ));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutOrigins.Add(FIntVector(4 * TileExtent, -3 * TileExtent, TileZ));
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppendWorldReachTiles(
|
||||||
|
const FTestWorld& World,
|
||||||
|
TArray<FIntVector>& OutOrigins)
|
||||||
|
{
|
||||||
|
BuildReachTileOrigins(World, FTestWorld::SlotTunnelNetwork, OutOrigins);
|
||||||
|
BuildReachTileOrigins(World, FTestWorld::SlotUnderwater, OutOrigins);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeTilePostReachProofTest,
|
||||||
|
"VoxelForge.Correctness.TilePostReachProof",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeTilePostReachProofTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
FScopedReachTestCVars CVarGuard;
|
||||||
|
if (!CVarGuard.bValid)
|
||||||
|
{
|
||||||
|
AddError(TEXT("Tile-post reach test could not find its two console variables."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FTestWorld NativeWorld;
|
||||||
|
NativeWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
|
||||||
|
FTestWorld FusedWorld;
|
||||||
|
FusedWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
|
||||||
|
if (!NativeWorld.IsValid() || !FusedWorld.IsValid())
|
||||||
|
{
|
||||||
|
AddError(NativeWorld.IsValid() && FusedWorld.IsValid()
|
||||||
|
? TEXT("Unexpected invalid reach-proof fixture")
|
||||||
|
: (!NativeWorld.IsValid() ? NativeWorld.WhyInvalid() : FusedWorld.WhyInvalid()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
TArray<FIntVector> NativeOrigins;
|
||||||
|
TArray<FIntVector> FusedOrigins;
|
||||||
|
AppendWorldReachTiles(NativeWorld, NativeOrigins);
|
||||||
|
AppendWorldReachTiles(FusedWorld, FusedOrigins);
|
||||||
|
TestTrue(TEXT("the representative native tile set is non-empty"), NativeOrigins.Num() > 0);
|
||||||
|
TestTrue(TEXT("the representative fused tile set is non-empty"), FusedOrigins.Num() > 0);
|
||||||
|
|
||||||
|
TStrongObjectPtr<UVoxelMarchingCubesMesher> NativeMesher(
|
||||||
|
NewObject<UVoxelMarchingCubesMesher>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
TStrongObjectPtr<UVoxelMarchingCubesMesher> FusedMesher(
|
||||||
|
NewObject<UVoxelMarchingCubesMesher>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
NativeMesher->SetGenerator(NativeWorld.Generator.Get());
|
||||||
|
FusedMesher->SetGenerator(FusedWorld.Generator.Get());
|
||||||
|
|
||||||
|
VoxelGenLOD::ResetTilePostReachDiagnostics();
|
||||||
|
CVarGuard.SetScale(1.0f);
|
||||||
|
const FReachSnapshot NativeFull = RunReachTiles(*NativeMesher, NativeOrigins);
|
||||||
|
const FReachSnapshot FusedFull = RunReachTiles(*FusedMesher, FusedOrigins);
|
||||||
|
const uint64 FullFinalComparisons = NativeFull.FinalComparisons + FusedFull.FinalComparisons;
|
||||||
|
const uint64 FullFinalDifferences = NativeFull.FinalDifferences + FusedFull.FinalDifferences;
|
||||||
|
TestTrue(TEXT("normal reach compares final fields on the representative tiles"),
|
||||||
|
FullFinalComparisons > 0);
|
||||||
|
TestEqual(TEXT("normal reach has no final-field differences"), FullFinalDifferences, 0ull);
|
||||||
|
TestEqual(TEXT("normal reach has no skipped-post differences"),
|
||||||
|
NativeFull.AllDifferences + FusedFull.AllDifferences, 0ull);
|
||||||
|
|
||||||
|
VoxelGenLOD::ResetTilePostReachDiagnostics();
|
||||||
|
// Use a deliberately tiny but valid scale so at least one envelope is rejected while the same
|
||||||
|
// final-field samples remain exercised; this is the negative control, not a supported setting.
|
||||||
|
CVarGuard.SetScale(0.01f);
|
||||||
|
const FReachSnapshot NativeShrunk = RunReachTiles(*NativeMesher, NativeOrigins);
|
||||||
|
const FReachSnapshot FusedShrunk = RunReachTiles(*FusedMesher, FusedOrigins);
|
||||||
|
const uint64 ShrunkFinalComparisons =
|
||||||
|
NativeShrunk.FinalComparisons + FusedShrunk.FinalComparisons;
|
||||||
|
const uint64 ShrunkFinalDifferences =
|
||||||
|
NativeShrunk.FinalDifferences + FusedShrunk.FinalDifferences;
|
||||||
|
TestTrue(TEXT("shrunk reach still executes final-field comparisons"),
|
||||||
|
ShrunkFinalComparisons > 0);
|
||||||
|
TestTrue(TEXT("shrunk reach demonstrably fails the final-field proof"),
|
||||||
|
ShrunkFinalDifferences > 0);
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("tile_post_reach_proof: native_tiles=%llu fused_tiles=%llu full_final=%llu/%llu "
|
||||||
|
"shrunk_final=%llu/%llu native_samples=%lld/%lld fused_samples=%lld/%lld."),
|
||||||
|
static_cast<unsigned long long>(NativeFull.Tiles),
|
||||||
|
static_cast<unsigned long long>(FusedFull.Tiles),
|
||||||
|
static_cast<unsigned long long>(FullFinalComparisons),
|
||||||
|
static_cast<unsigned long long>(FullFinalDifferences),
|
||||||
|
static_cast<unsigned long long>(ShrunkFinalComparisons),
|
||||||
|
static_cast<unsigned long long>(ShrunkFinalDifferences),
|
||||||
|
static_cast<long long>(NativeFull.DensitySamples),
|
||||||
|
static_cast<long long>(NativeShrunk.DensitySamples),
|
||||||
|
static_cast<long long>(FusedFull.DensitySamples),
|
||||||
|
static_cast<long long>(FusedShrunk.DensitySamples)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Surface-strate streaming must keep the ground visible when the pawn is high in the same strate.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
|
||||||
|
#include "VoxelClipmapDesiredTiles.h"
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeVerticalStrateReachTest,
|
||||||
|
"VoxelForge.Streaming.VerticalStrateReach",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeVerticalStrateReachTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
constexpr int32 StrateBottomZ = -63;
|
||||||
|
constexpr int32 StrateTopZ = 0;
|
||||||
|
constexpr int32 PlayerChunkZ = 0; // high above this surface strate's ground
|
||||||
|
|
||||||
|
int32 MinChunkZ = MIN_int32;
|
||||||
|
int32 MaxChunkZ = MAX_int32;
|
||||||
|
VoxelClipmapDesiredTiles::ResolveVerticalBounds(
|
||||||
|
PlayerChunkZ, 5, 5, 3, true, true,
|
||||||
|
StrateTopZ, StrateBottomZ, MinChunkZ, MaxChunkZ);
|
||||||
|
TestEqual(TEXT("vertical reach follows the full surface strate plus margin"), MinChunkZ, -66);
|
||||||
|
TestEqual(TEXT("open-strate reach ends at the surface cap margin"), MaxChunkZ, 3);
|
||||||
|
|
||||||
|
VoxelClipmapDesiredTiles::FParameters Clipmap;
|
||||||
|
Clipmap.ClipRadius = 3;
|
||||||
|
Clipmap.MaxClipLevel = 3;
|
||||||
|
Clipmap.RenderDistanceChunks = 768;
|
||||||
|
Clipmap.bFarSheetRing = true;
|
||||||
|
Clipmap.FarSheetSpanLevels = 2;
|
||||||
|
|
||||||
|
TArray<FVoxelTileKey> Desired;
|
||||||
|
VoxelClipmapDesiredTiles::Build(
|
||||||
|
FIntVector(0, 0, PlayerChunkZ), MinChunkZ, MaxChunkZ, Clipmap, Desired);
|
||||||
|
|
||||||
|
TArray<int32> CountsByLevel;
|
||||||
|
CountsByLevel.Init(0, 6);
|
||||||
|
for (const FVoxelTileKey& Tile : Desired)
|
||||||
|
{
|
||||||
|
if (CountsByLevel.IsValidIndex(Tile.Level))
|
||||||
|
{
|
||||||
|
++CountsByLevel[Tile.Level];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int32 ExpectedCounts[] = {343, 218, 178, 178, 178, 9586};
|
||||||
|
for (int32 Level = 0; Level < static_cast<int32>(UE_ARRAY_COUNT(ExpectedCounts)); ++Level)
|
||||||
|
{
|
||||||
|
TestEqual(FString::Printf(TEXT("desired tile count at level %d"), Level),
|
||||||
|
CountsByLevel[Level], ExpectedCounts[Level]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto HasTile = [&Desired](int32 Level, const FIntVector& Coord)
|
||||||
|
{
|
||||||
|
return Desired.ContainsByPredicate([Level, &Coord](const FVoxelTileKey& Tile)
|
||||||
|
{
|
||||||
|
return Tile.Level == Level && Tile.Coord == Coord;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
TestTrue(TEXT("level 0 tile around the high pawn is present"),
|
||||||
|
HasTile(0, FIntVector(0, 0, 0)));
|
||||||
|
TestTrue(TEXT("level 4 bridge tile covering the ground below the pawn is present"),
|
||||||
|
HasTile(4, FIntVector(0, 0, -3)));
|
||||||
|
TestTrue(TEXT("adjacent level 4 ground tile is present"),
|
||||||
|
HasTile(4, FIntVector(1, 0, -3)));
|
||||||
|
TestTrue(TEXT("outer level 5 sheet reaches the 768-chunk horizon"),
|
||||||
|
HasTile(5, FIntVector(24, 0, 0)));
|
||||||
|
TestFalse(TEXT("clamping does not request a full hidden LOD0 column"),
|
||||||
|
HasTile(0, FIntVector(0, 0, -47)));
|
||||||
|
|
||||||
|
for (int32 Level = 0; Level <= 5; ++Level)
|
||||||
|
{
|
||||||
|
TestTrue(FString::Printf(TEXT("clip level %d is non-empty"), Level),
|
||||||
|
CountsByLevel[Level] > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("center_z=%d strate=[%d..%d] view=[%d..%d] levels=[%d,%d,%d,%d,%d,%d] total=%d"),
|
||||||
|
PlayerChunkZ, StrateBottomZ, StrateTopZ, MinChunkZ, MaxChunkZ,
|
||||||
|
CountsByLevel[0], CountsByLevel[1], CountsByLevel[2], CountsByLevel[3],
|
||||||
|
CountsByLevel[4], CountsByLevel[5], Desired.Num()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
// VoxelForgeWorldEdgeSealTest.cpp
|
||||||
|
// Falsifiable coverage for the bounded-world XY edge seal.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
|
||||||
|
#include "VoxelForgeTestFixture.h"
|
||||||
|
#include "VoxelDensityOpStack.h"
|
||||||
|
#include "VoxelDensityPrimitives.h"
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeWorldEdgeSealTest,
|
||||||
|
"VoxelForge.Generation.WorldEdgeSeal",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr float TestWorldRadius = 256.0f;
|
||||||
|
constexpr float TestEdgeThickness = 64.0f;
|
||||||
|
constexpr float RimJumpThreshold = 1.0f;
|
||||||
|
|
||||||
|
void SetEdgeSettings(VoxelForgeTest::FTestWorld& World, float Radius)
|
||||||
|
{
|
||||||
|
World.Settings->WorldRadiusVoxels = Radius;
|
||||||
|
World.Settings->EdgeSealThickness = TestEdgeThickness;
|
||||||
|
World.Generator->InitializeSettings(World.Settings.Get());
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigureNearRimPassages(VoxelForgeTest::FTestWorld& World)
|
||||||
|
{
|
||||||
|
for (TStrongObjectPtr<UVoxelStrateDefinition>& DefPtr : World.Definitions)
|
||||||
|
{
|
||||||
|
if (!DefPtr.IsValid()) continue;
|
||||||
|
FStratePassageConfig& Cfg = DefPtr->PassageConfig;
|
||||||
|
Cfg.Connections = 1;
|
||||||
|
Cfg.Style = EVoxelPassageStyle::Straight;
|
||||||
|
Cfg.MouthRadius = 6.0f;
|
||||||
|
Cfg.MidRadius = 6.0f;
|
||||||
|
Cfg.ReachMin = 40.0f;
|
||||||
|
Cfg.ReachMax = 40.0f;
|
||||||
|
Cfg.DistanceMin = 240.0f;
|
||||||
|
Cfg.DistanceMax = 240.0f;
|
||||||
|
Cfg.Wander = 0.0f;
|
||||||
|
Cfg.Segments = 4;
|
||||||
|
Cfg.VerticalWobble = 0.0f;
|
||||||
|
}
|
||||||
|
World.Reinitialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsSolidMC(const UVoxelGenerator& Generator, const FVector& Point)
|
||||||
|
{
|
||||||
|
return Generator.GetDensityAt(Point.X, Point.Y, Point.Z) < 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelForgeWorldEdgeSealTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
using namespace VoxelForgeTest;
|
||||||
|
|
||||||
|
int32 NumFailures = 0;
|
||||||
|
FString FailureKinds;
|
||||||
|
auto Fail = [&](const TCHAR* Kind)
|
||||||
|
{
|
||||||
|
++NumFailures;
|
||||||
|
if (!FailureKinds.IsEmpty()) FailureKinds += TEXT(", ");
|
||||||
|
FailureKinds += Kind;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The ordinary fixture deliberately uses the requested default, so existing op-stack and
|
||||||
|
// legacy tests exercise the setting without having to know about the new post.
|
||||||
|
FTestWorld Legacy;
|
||||||
|
Legacy.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
|
||||||
|
FTestWorld StackWorld;
|
||||||
|
StackWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
|
||||||
|
FTestWorld Unbounded;
|
||||||
|
Unbounded.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
|
||||||
|
|
||||||
|
if (!Legacy.IsValid() || !StackWorld.IsValid() || !Unbounded.IsValid())
|
||||||
|
{
|
||||||
|
AddError(Legacy.IsValid() && StackWorld.IsValid() && Unbounded.IsValid()
|
||||||
|
? TEXT("Unexpected edge-seal fixture state")
|
||||||
|
: (!Legacy.IsValid() ? Legacy.WhyInvalid()
|
||||||
|
: (!StackWorld.IsValid() ? StackWorld.WhyInvalid() : Unbounded.WhyInvalid())));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetEdgeSettings(Legacy, TestWorldRadius);
|
||||||
|
SetEdgeSettings(StackWorld, TestWorldRadius);
|
||||||
|
SetEdgeSettings(Unbounded, 0.0f);
|
||||||
|
|
||||||
|
//==========================================================================
|
||||||
|
// POSITIVE CONTROL — far outside the radius, across several Z strata.
|
||||||
|
//==========================================================================
|
||||||
|
const FVector FarXY[] = {
|
||||||
|
FVector(384.0f, 0.0f, 0.0f),
|
||||||
|
FVector(-384.0f, 0.0f, 0.0f),
|
||||||
|
FVector(0.0f, 384.0f, 0.0f),
|
||||||
|
FVector(0.0f, -384.0f, 0.0f),
|
||||||
|
FVector(320.0f, 320.0f, 0.0f),
|
||||||
|
};
|
||||||
|
TArray<float> StrateZ;
|
||||||
|
for (int32 Slot = 0; Slot < 8; ++Slot)
|
||||||
|
{
|
||||||
|
int32 TopZ = 0, BottomZ = 0;
|
||||||
|
if (Legacy.GetSlotVoxelZRange(Slot, TopZ, BottomZ))
|
||||||
|
{
|
||||||
|
StrateZ.Add((float)((TopZ + BottomZ) / 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StrateZ.Add(Legacy.MidVoxelZ());
|
||||||
|
|
||||||
|
int32 NumPositiveSamples = 0;
|
||||||
|
for (const FVector& XY : FarXY)
|
||||||
|
{
|
||||||
|
for (const float Z : StrateZ)
|
||||||
|
{
|
||||||
|
const FVector P(XY.X, XY.Y, Z);
|
||||||
|
++NumPositiveSamples;
|
||||||
|
if (!IsSolidMC(*Legacy.Generator, P)) Fail(TEXT("positive-legacy"));
|
||||||
|
if (!IsSolidMC(*StackWorld.Generator, P)) Fail(TEXT("positive-opstack"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//==========================================================================
|
||||||
|
// NEGATIVE CONTROL — the interior must be bit-identical to radius == 0.
|
||||||
|
//==========================================================================
|
||||||
|
const FVector InteriorXY[] = {
|
||||||
|
FVector(0.0f, 0.0f, 0.0f),
|
||||||
|
FVector(64.0f, -80.0f, 0.0f),
|
||||||
|
FVector(128.0f, 0.0f, 0.0f),
|
||||||
|
FVector(0.0f, -128.0f, 0.0f),
|
||||||
|
FVector(100.0f, 100.0f, 0.0f),
|
||||||
|
};
|
||||||
|
int32 NumInteriorComparisons = 0;
|
||||||
|
for (const FVector& XY : InteriorXY)
|
||||||
|
{
|
||||||
|
for (const float Z : StrateZ)
|
||||||
|
{
|
||||||
|
const FVector P(XY.X, XY.Y, Z);
|
||||||
|
const float BoundedD = Legacy.Generator->GetDensityAt(P.X, P.Y, P.Z);
|
||||||
|
const float UnboundedD = Unbounded.Generator->GetDensityAt(P.X, P.Y, P.Z);
|
||||||
|
++NumInteriorComparisons;
|
||||||
|
if (!BitEqual(BoundedD, UnboundedD)) Fail(TEXT("interior-not-no-op"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//==========================================================================
|
||||||
|
// RIM SMOOTHNESS — measure solidness (the negative MC density magnitude), not its sign.
|
||||||
|
//==========================================================================
|
||||||
|
float MaxAdjacentSolidnessJump = 0.0f;
|
||||||
|
int32 NumRimDirections = 0;
|
||||||
|
const FVector RimDirections[] = {
|
||||||
|
FVector(1.0f, 0.0f, 0.0f),
|
||||||
|
FVector(0.0f, 1.0f, 0.0f),
|
||||||
|
FVector(-1.0f, 0.0f, 0.0f),
|
||||||
|
FVector(0.0f, -1.0f, 0.0f),
|
||||||
|
FVector(0.70710678f, 0.70710678f, 0.0f),
|
||||||
|
};
|
||||||
|
for (const FVector& Dir : RimDirections)
|
||||||
|
{
|
||||||
|
++NumRimDirections;
|
||||||
|
float PreviousSolidness = 0.0f;
|
||||||
|
bool bHasPrevious = false;
|
||||||
|
for (int32 R = (int32)(TestWorldRadius - TestEdgeThickness - 4.0f);
|
||||||
|
R <= (int32)(TestWorldRadius + 4.0f); ++R)
|
||||||
|
{
|
||||||
|
float InternalDensity = 0.0f;
|
||||||
|
VF_ApplyXYEdgeSeal(InternalDensity, Dir.X * (float)R, Dir.Y * (float)R,
|
||||||
|
TestWorldRadius, TestEdgeThickness, 8.0f);
|
||||||
|
const float Solidness = InternalDensity;
|
||||||
|
if (bHasPrevious)
|
||||||
|
{
|
||||||
|
MaxAdjacentSolidnessJump = FMath::Max(
|
||||||
|
MaxAdjacentSolidnessJump, FMath::Abs(Solidness - PreviousSolidness));
|
||||||
|
if (Solidness + KINDA_SMALL_NUMBER < PreviousSolidness)
|
||||||
|
Fail(TEXT("rim-not-monotonic"));
|
||||||
|
}
|
||||||
|
PreviousSolidness = Solidness;
|
||||||
|
bHasPrevious = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (MaxAdjacentSolidnessJump > RimJumpThreshold) Fail(TEXT("rim-discontinuity"));
|
||||||
|
|
||||||
|
// Explicitly verify the true no-op contract, including the MC-facing wrapper.
|
||||||
|
float NoOpDensity = 3.25f;
|
||||||
|
const float NoOpBefore = NoOpDensity;
|
||||||
|
VF_ApplyXYEdgeSealMC(NoOpDensity, 100000.0f, -100000.0f,
|
||||||
|
0.0f, TestEdgeThickness, 8.0f);
|
||||||
|
if (!BitEqual(NoOpDensity, NoOpBefore)) Fail(TEXT("radius-zero-not-no-op"));
|
||||||
|
|
||||||
|
//==========================================================================
|
||||||
|
// CLASSIFYBOX — force boxes in the ramp and beyond the radius, then brute-force every voxel.
|
||||||
|
//==========================================================================
|
||||||
|
int32 TopShaftZ = 0, BottomShaftZ = 0;
|
||||||
|
if (!StackWorld.GetSlotVoxelZRange(FTestWorld::SlotVerticalShafts, TopShaftZ, BottomShaftZ))
|
||||||
|
{
|
||||||
|
Fail(TEXT("missing-shaft-slot"));
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 NumBoxesProved = 0;
|
||||||
|
int64 NumVoxelsChecked = 0;
|
||||||
|
if (TopShaftZ > BottomShaftZ)
|
||||||
|
{
|
||||||
|
const float BoxZ0 = (float)(BottomShaftZ + 8);
|
||||||
|
const float BoxZ1 = (float)FMath::Min(BottomShaftZ + 10, TopShaftZ - 8);
|
||||||
|
const FBox ProvedBoxes[] = {
|
||||||
|
// Ramp boxes: closest radius is 240, safely past the 1-voxel proof margin.
|
||||||
|
FBox(FVector(240.0f, -2.0f, BoxZ0), FVector(244.0f, 2.0f, BoxZ1)),
|
||||||
|
FBox(FVector(-244.0f, -2.0f, BoxZ0), FVector(-240.0f, 2.0f, BoxZ1)),
|
||||||
|
FBox(FVector(-2.0f, 240.0f, BoxZ0), FVector(2.0f, 244.0f, BoxZ1)),
|
||||||
|
FBox(FVector(170.0f, 170.0f, BoxZ0), FVector(174.0f, 174.0f, BoxZ1)),
|
||||||
|
// Forced-solid boxes beyond the world radius.
|
||||||
|
FBox(FVector(320.0f, -2.0f, BoxZ0), FVector(324.0f, 2.0f, BoxZ1)),
|
||||||
|
FBox(FVector(-324.0f, -2.0f, BoxZ0), FVector(-320.0f, 2.0f, BoxZ1)),
|
||||||
|
FBox(FVector(-2.0f, 320.0f, BoxZ0), FVector(2.0f, 324.0f, BoxZ1)),
|
||||||
|
FBox(FVector(226.0f, 226.0f, BoxZ0), FVector(230.0f, 230.0f, BoxZ1)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const int32 MidChunkZ = FMath::FloorToInt((BoxZ0 + BoxZ1) * 0.5f / CHUNK_SIZE);
|
||||||
|
const FVerticalShaftParams ShaftParams = StackWorld.StrateManager->GetVerticalShaftParamsForChunk(
|
||||||
|
FIntVector(0, 0, MidChunkZ));
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
VoxelDensityOps::BuildVerticalShaftStack(
|
||||||
|
Stack, ShaftParams, StackWorld.Settings->Seed,
|
||||||
|
StackWorld.Generator->OriginSpineRadius, StackWorld.StrateManager.Get());
|
||||||
|
|
||||||
|
FVoxelOpContext Ctx;
|
||||||
|
Ctx.ChunkCoord = FIntVector(0, 0, MidChunkZ);
|
||||||
|
Ctx.Seed = (uint32)StackWorld.Settings->Seed;
|
||||||
|
Ctx.LayoutVersion = StackWorld.StrateManager->GetLayoutVersion();
|
||||||
|
Ctx.StrateTopWorldZ = ShaftParams.StrateTopWorldZ;
|
||||||
|
Ctx.StrateBottomWorldZ = ShaftParams.StrateBottomWorldZ;
|
||||||
|
Ctx.WorldRadiusVoxels = TestWorldRadius;
|
||||||
|
Ctx.EdgeSealThickness = TestEdgeThickness;
|
||||||
|
Stack.PrepareChunk(Ctx);
|
||||||
|
|
||||||
|
for (const FBox& Box : ProvedBoxes)
|
||||||
|
{
|
||||||
|
if (Stack.ClassifyBox(Box, Ctx) != EVoxelTileClass::AllSolid)
|
||||||
|
{
|
||||||
|
Fail(TEXT("classifybox-missed-forced-band"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
++NumBoxesProved;
|
||||||
|
const int32 X0 = FMath::CeilToInt(Box.Min.X);
|
||||||
|
const int32 X1 = FMath::FloorToInt(Box.Max.X);
|
||||||
|
const int32 Y0 = FMath::CeilToInt(Box.Min.Y);
|
||||||
|
const int32 Y1 = FMath::FloorToInt(Box.Max.Y);
|
||||||
|
const int32 Z0 = FMath::CeilToInt(Box.Min.Z);
|
||||||
|
const int32 Z1 = FMath::FloorToInt(Box.Max.Z);
|
||||||
|
for (int32 Z = Z0; Z <= Z1; ++Z)
|
||||||
|
for (int32 Y = Y0; Y <= Y1; ++Y)
|
||||||
|
for (int32 X = X0; X <= X1; ++X)
|
||||||
|
{
|
||||||
|
++NumVoxelsChecked;
|
||||||
|
if (!IsSolidMC(*StackWorld.Generator, FVector((float)X, (float)Y, (float)Z)))
|
||||||
|
Fail(TEXT("classifybox-false-allsolid"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The T1.d path must be able to skip an edge tile without knowing which Z category it hits.
|
||||||
|
const EVoxelTileClass EdgeTile = StackWorld.Generator->ClassifyTile(
|
||||||
|
FIntVector(320, 0, BottomShaftZ + 8), 1, 8);
|
||||||
|
int32 NumTilesProved = (EdgeTile == EVoxelTileClass::AllSolid) ? 1 : 0;
|
||||||
|
if (EdgeTile != EVoxelTileClass::AllSolid) Fail(TEXT("classifytile-missed-edge"));
|
||||||
|
|
||||||
|
//==========================================================================
|
||||||
|
// PASSAGE ORDER — a near-rim passage carve is followed by the edge force.
|
||||||
|
//==========================================================================
|
||||||
|
float NearRimInternalDensity = 8.0f;
|
||||||
|
VF_ApplyPassageCarving(NearRimInternalDensity, 0.0f, 8.0f, TestEdgeThickness);
|
||||||
|
const float PassageOnlyDensity = NearRimInternalDensity;
|
||||||
|
VF_ApplyXYEdgeSeal(NearRimInternalDensity, TestWorldRadius - 16.0f, 0.0f,
|
||||||
|
TestWorldRadius, TestEdgeThickness, 8.0f);
|
||||||
|
int32 NumNearRimPassageChecks = 1;
|
||||||
|
if (!(PassageOnlyDensity < 0.0f && NearRimInternalDensity > 0.0f))
|
||||||
|
Fail(TEXT("passage-breaches-rim"));
|
||||||
|
|
||||||
|
// Try the same construction through the real deterministic passage generator. It is diagnostic
|
||||||
|
// coverage only: the synthetic helper case above is the hard assertion even if landing-site
|
||||||
|
// adjustment moves every generated mouth out of this small test rim.
|
||||||
|
FTestWorld GeneratedPassageWorld;
|
||||||
|
GeneratedPassageWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
|
||||||
|
FTestWorld GeneratedPassageStackWorld;
|
||||||
|
GeneratedPassageStackWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
|
||||||
|
auto CheckGeneratedPassages = [&](FTestWorld& World, const TCHAR* FailureKind)
|
||||||
|
{
|
||||||
|
if (!World.IsValid())
|
||||||
|
{
|
||||||
|
Fail(FailureKind);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ConfigureNearRimPassages(World);
|
||||||
|
SetEdgeSettings(World, TestWorldRadius);
|
||||||
|
for (const FVoxelPassage& Passage : World.StrateManager->GetPassages())
|
||||||
|
{
|
||||||
|
TArray<FVector> Candidates = Passage.ControlPoints;
|
||||||
|
Candidates.Add(Passage.UpperPoint);
|
||||||
|
Candidates.Add(Passage.LowerPoint);
|
||||||
|
for (const FVector& P : Candidates)
|
||||||
|
{
|
||||||
|
if (P.X * P.X + P.Y * P.Y <= FMath::Square(TestWorldRadius - TestEdgeThickness)) continue;
|
||||||
|
++NumNearRimPassageChecks;
|
||||||
|
if (!IsSolidMC(*World.Generator, P)) Fail(FailureKind);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
CheckGeneratedPassages(GeneratedPassageWorld, TEXT("generated-passage-breaches-rim"));
|
||||||
|
CheckGeneratedPassages(GeneratedPassageStackWorld, TEXT("generated-opstack-passage-breaches-rim"));
|
||||||
|
|
||||||
|
// One sorted summary block; individual samples intentionally never log.
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("WORLD_EDGE_SEAL SUMMARY\n")
|
||||||
|
TEXT(" classify_box_proved=%d\n")
|
||||||
|
TEXT(" classify_tile_proved=%d\n")
|
||||||
|
TEXT(" interior_comparisons=%d\n")
|
||||||
|
TEXT(" near_rim_passage_checks=%d\n")
|
||||||
|
TEXT(" positive_sample_points=%d (checked in both paths)\n")
|
||||||
|
TEXT(" rim_directions=%d\n")
|
||||||
|
TEXT(" rim_max_adjacent_solidness_jump=%.6f (threshold=%.6f)\n")
|
||||||
|
TEXT(" voxels_checked=%lld\n")
|
||||||
|
TEXT(" failures=%d (%s)"),
|
||||||
|
NumBoxesProved, NumTilesProved, NumInteriorComparisons, NumNearRimPassageChecks,
|
||||||
|
NumPositiveSamples, NumRimDirections, MaxAdjacentSolidnessJump, RimJumpThreshold,
|
||||||
|
(long long)NumVoxelsChecked, NumFailures, *FailureKinds));
|
||||||
|
|
||||||
|
if (NumFailures > 0)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(TEXT("WorldEdgeSeal aggregate checks failed: %d (%s)"),
|
||||||
|
NumFailures, *FailureKinds));
|
||||||
|
}
|
||||||
|
return NumFailures == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
// Soundness and empirical coverage for the worker-local worm N1 block proof.
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
|
||||||
|
#include "Misc/AutomationTest.h"
|
||||||
|
|
||||||
|
#include "VoxelCaveMorphology.h"
|
||||||
|
#include "VoxelNoise.h"
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeWormBlockSkipTest,
|
||||||
|
"VoxelForge.Determinism.WormBlockSkipSoundness",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr float WormFrequency = 0.015f;
|
||||||
|
constexpr float WormHorizontalBias = 3.0f;
|
||||||
|
constexpr float WormThreshold = 0.06f;
|
||||||
|
constexpr float WormVerticalScale = 1.0f;
|
||||||
|
constexpr int32 TestCellsPerAxis = 32;
|
||||||
|
constexpr int32 TestTileCount = 192;
|
||||||
|
constexpr int32 TestRandomPairCount = 500000;
|
||||||
|
|
||||||
|
FORCEINLINE uint32 NextTestBits(uint32& State)
|
||||||
|
{
|
||||||
|
State += 0x9E3779B9u;
|
||||||
|
uint32 Z = State;
|
||||||
|
Z = (Z ^ (Z >> 16)) * 0x7FEB352Du;
|
||||||
|
Z = (Z ^ (Z >> 15)) * 0x846CA68Bu;
|
||||||
|
return Z ^ (Z >> 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float NextTestUnit(uint32& State)
|
||||||
|
{
|
||||||
|
return static_cast<float>(NextTestBits(State)) / 4294967296.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float ScaledN1(const FVector3f& Position)
|
||||||
|
{
|
||||||
|
return FMath::Abs(VoxelNoise::Perlin3D(Position) * VOXEL_NOISE_SCALE);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE FVector3f MakeWormNoisePosition(
|
||||||
|
int32 WorldX, int32 WorldY, int32 WorldZ, uint32 Seed)
|
||||||
|
{
|
||||||
|
float EffectiveZ = static_cast<float>(WorldZ);
|
||||||
|
if (WormVerticalScale != 1.0f && WormVerticalScale > 0.0f)
|
||||||
|
{
|
||||||
|
EffectiveZ /= WormVerticalScale;
|
||||||
|
}
|
||||||
|
const float WormZFreq = WormFrequency * WormHorizontalBias;
|
||||||
|
return FVector3f(
|
||||||
|
static_cast<float>(WorldX) * WormFrequency
|
||||||
|
+ VoxelHash::SeedOffset(Seed, 1.0f),
|
||||||
|
static_cast<float>(WorldY) * WormFrequency
|
||||||
|
+ VoxelHash::SeedOffset(Seed, 1.7f),
|
||||||
|
EffectiveZ * WormZFreq + VoxelHash::SeedOffset(Seed, 2.3f));
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE double FadePrime(double T)
|
||||||
|
{
|
||||||
|
return 30.0 * T * T * (1.0 - T) * (1.0 - T);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE double GradientSupport(double X, double Y, double Z)
|
||||||
|
{
|
||||||
|
const double AX = FMath::Abs(X);
|
||||||
|
const double AY = FMath::Abs(Y);
|
||||||
|
const double AZ = FMath::Abs(Z);
|
||||||
|
return FMath::Max(FMath::Max(AX + AY, AX + AZ), AY + AZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supremum of the exact finite support relaxation used in the header derivation. This is
|
||||||
|
// intentionally independent of the hash: all eight corners may choose any member of the
|
||||||
|
// actual GradDot set, so this is the field-wide proof envelope rather than an average sample.
|
||||||
|
double SupportRelaxedXDerivative(double X, double Y, double Z)
|
||||||
|
{
|
||||||
|
const double D = FadePrime(X);
|
||||||
|
const double SY = Y * Y * Y * (Y * (Y * 6.0 - 15.0) + 10.0);
|
||||||
|
const double SZ = Z * Z * Z * (Z * (Z * 6.0 - 15.0) + 10.0);
|
||||||
|
double Result = 0.0;
|
||||||
|
for (int32 J = 0; J < 2; ++J)
|
||||||
|
{
|
||||||
|
for (int32 K = 0; K < 2; ++K)
|
||||||
|
{
|
||||||
|
const double Weight = (J != 0 ? SY : 1.0 - SY)
|
||||||
|
* (K != 0 ? SZ : 1.0 - SZ);
|
||||||
|
const double YLocal = Y - static_cast<double>(J);
|
||||||
|
const double ZLocal = Z - static_cast<double>(K);
|
||||||
|
Result += Weight * (
|
||||||
|
GradientSupport(1.0 - X - D * X, -D * YLocal, -D * ZLocal)
|
||||||
|
+ GradientSupport(X - D * (1.0 - X), D * YLocal, D * ZLocal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelForgeWormBlockSkipTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
(void)Parameters;
|
||||||
|
|
||||||
|
const double FullL = VoxelNoise::WormN1NoiseSpaceLipschitzBound;
|
||||||
|
const double HalfL = FullL * 0.5;
|
||||||
|
TestTrue(TEXT("the production N1 Lipschitz bound is finite and positive"),
|
||||||
|
FMath::IsFinite(FullL) && FullL > 0.0);
|
||||||
|
TestTrue(TEXT("the actual 3D partial bound is 15/4"),
|
||||||
|
FMath::IsNearlyEqual(VoxelNoise::Perlin3DPartialAbsBound, 3.75, 1.0e-12));
|
||||||
|
TestTrue(TEXT("VOXEL_NOISE_SCALE is included in the N1 bound"),
|
||||||
|
FMath::IsNearlyEqual(
|
||||||
|
FullL,
|
||||||
|
static_cast<double>(VOXEL_NOISE_SCALE)
|
||||||
|
* VoxelNoise::Perlin3DPartialAbsBound
|
||||||
|
* static_cast<double>(1.732050807568877293527446341505872L),
|
||||||
|
1.0e-12));
|
||||||
|
|
||||||
|
// Check the finite support maximization densely, including corners and every cell face. The
|
||||||
|
// closed-form case split is recorded beside the production constant; this grid is a regression
|
||||||
|
// tripwire for changing GradDot/Fade/Lerp without updating that proof.
|
||||||
|
double MaxRelaxedSupport = 0.0;
|
||||||
|
FVector3f MaxRelaxedPoint = FVector3f::ZeroVector;
|
||||||
|
constexpr int32 SupportGrid = 128;
|
||||||
|
for (int32 IX = 0; IX <= SupportGrid; ++IX)
|
||||||
|
{
|
||||||
|
const double X = static_cast<double>(IX) / SupportGrid;
|
||||||
|
for (int32 IY = 0; IY <= SupportGrid; ++IY)
|
||||||
|
{
|
||||||
|
const double Y = static_cast<double>(IY) / SupportGrid;
|
||||||
|
for (int32 IZ = 0; IZ <= SupportGrid; ++IZ)
|
||||||
|
{
|
||||||
|
const double Z = static_cast<double>(IZ) / SupportGrid;
|
||||||
|
const double Value = SupportRelaxedXDerivative(X, Y, Z);
|
||||||
|
if (Value > MaxRelaxedSupport)
|
||||||
|
{
|
||||||
|
MaxRelaxedSupport = Value;
|
||||||
|
MaxRelaxedPoint = FVector3f(
|
||||||
|
static_cast<float>(X), static_cast<float>(Y), static_cast<float>(Z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("3D support relaxation max=%.12f at (%.6f,%.6f,%.6f), bound=%.12f; "
|
||||||
|
"the exact case split gives 15/4 at (0.5,0.5,0.5)."),
|
||||||
|
MaxRelaxedSupport, MaxRelaxedPoint.X, MaxRelaxedPoint.Y, MaxRelaxedPoint.Z,
|
||||||
|
VoxelNoise::Perlin3DPartialAbsBound));
|
||||||
|
TestTrue(TEXT("the dense support relaxation stays below the proven partial bound"),
|
||||||
|
MaxRelaxedSupport <= VoxelNoise::Perlin3DPartialAbsBound + 1.0e-10);
|
||||||
|
TestTrue(TEXT("the support relaxation reaches the stated supremum"),
|
||||||
|
MaxRelaxedSupport >= VoxelNoise::Perlin3DPartialAbsBound - 1.0e-10);
|
||||||
|
|
||||||
|
// Empirical attack: samples straddle lattice faces/corners at several scales, then a large
|
||||||
|
// deterministic random-pair population probes ordinary cells and arbitrary directions.
|
||||||
|
double MaxEmpiricalRatio = 0.0;
|
||||||
|
FVector3f WorstA = FVector3f::ZeroVector;
|
||||||
|
FVector3f WorstB = FVector3f::ZeroVector;
|
||||||
|
uint64 PairCount = 0;
|
||||||
|
auto ObservePair = [&](const FVector3f& A, const FVector3f& B)
|
||||||
|
{
|
||||||
|
const double DX = static_cast<double>(A.X) - static_cast<double>(B.X);
|
||||||
|
const double DY = static_cast<double>(A.Y) - static_cast<double>(B.Y);
|
||||||
|
const double DZ = static_cast<double>(A.Z) - static_cast<double>(B.Z);
|
||||||
|
const double Distance = FMath::Sqrt(DX * DX + DY * DY + DZ * DZ);
|
||||||
|
if (!(Distance > 0.0))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
++PairCount;
|
||||||
|
const double Ratio = FMath::Abs(
|
||||||
|
static_cast<double>(ScaledN1(A)) - static_cast<double>(ScaledN1(B))) / Distance;
|
||||||
|
if (Ratio > MaxEmpiricalRatio)
|
||||||
|
{
|
||||||
|
MaxEmpiricalRatio = Ratio;
|
||||||
|
WorstA = A;
|
||||||
|
WorstB = B;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr float FaceEpsilons[] = { 1.0e-5f, 1.0e-4f, 1.0e-3f, 1.0e-2f, 5.0e-2f };
|
||||||
|
constexpr float FaceCoordinates[] = {
|
||||||
|
1.0e-3f, 1.0e-2f, 0.1f, 0.25f, 0.5f, 0.75f, 0.9f, 0.99f, 0.999f };
|
||||||
|
for (int32 X = -12; X < 12; ++X)
|
||||||
|
{
|
||||||
|
for (int32 Y = -12; Y < 12; ++Y)
|
||||||
|
{
|
||||||
|
for (int32 Z = -12; Z < 12; ++Z)
|
||||||
|
{
|
||||||
|
for (const float Epsilon : FaceEpsilons)
|
||||||
|
{
|
||||||
|
for (const float V : FaceCoordinates)
|
||||||
|
{
|
||||||
|
for (const float W : FaceCoordinates)
|
||||||
|
{
|
||||||
|
ObservePair(
|
||||||
|
FVector3f(X + 1.0f - Epsilon, Y + V, Z + W),
|
||||||
|
FVector3f(X + 1.0f + Epsilon, Y + V, Z + W));
|
||||||
|
ObservePair(
|
||||||
|
FVector3f(X + V, Y + 1.0f - Epsilon, Z + W),
|
||||||
|
FVector3f(X + V, Y + 1.0f + Epsilon, Z + W));
|
||||||
|
ObservePair(
|
||||||
|
FVector3f(X + V, Y + W, Z + 1.0f - Epsilon),
|
||||||
|
FVector3f(X + V, Y + W, Z + 1.0f + Epsilon));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ObservePair(
|
||||||
|
FVector3f(X + 1.0f - Epsilon, Y + 1.0f - Epsilon, Z + 1.0f - Epsilon),
|
||||||
|
FVector3f(X + 1.0f + Epsilon, Y + 1.0f + Epsilon, Z + 1.0f + Epsilon));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 RandomState = 0xA5B3571Du;
|
||||||
|
for (int32 Index = 0; Index < TestRandomPairCount; ++Index)
|
||||||
|
{
|
||||||
|
const FVector3f A(
|
||||||
|
NextTestUnit(RandomState) * 128.0f - 64.0f,
|
||||||
|
NextTestUnit(RandomState) * 128.0f - 64.0f,
|
||||||
|
NextTestUnit(RandomState) * 128.0f - 64.0f);
|
||||||
|
const FVector3f B(
|
||||||
|
A.X + NextTestUnit(RandomState) * 0.5f - 0.25f,
|
||||||
|
A.Y + NextTestUnit(RandomState) * 0.5f - 0.25f,
|
||||||
|
A.Z + NextTestUnit(RandomState) * 0.5f - 0.25f);
|
||||||
|
ObservePair(A, B);
|
||||||
|
}
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Empirical scaled-N1 Lipschitz attack: pairs=%llu worst=%.9f, worst/L=%.9f, "
|
||||||
|
"L=%.9f; worst A=(%.6f,%.6f,%.6f) B=(%.6f,%.6f,%.6f)."),
|
||||||
|
static_cast<unsigned long long>(PairCount), MaxEmpiricalRatio,
|
||||||
|
MaxEmpiricalRatio / FullL, FullL,
|
||||||
|
WorstA.X, WorstA.Y, WorstA.Z, WorstB.X, WorstB.Y, WorstB.Z));
|
||||||
|
TestTrue(TEXT("empirical near-face/corner/random ratios stay below L"),
|
||||||
|
MaxEmpiricalRatio <= FullL + 0.01);
|
||||||
|
|
||||||
|
// Brute-force the exact mesher lattice for a large deterministic set of tile contexts. Every
|
||||||
|
// block admitted by the production proof is checked at every lattice point it covers.
|
||||||
|
uint64 BlocksScanned = 0;
|
||||||
|
uint64 ProductionProofs = 0;
|
||||||
|
uint64 ProductionLatticeSamples = 0;
|
||||||
|
uint64 ProductionViolations = 0;
|
||||||
|
for (int32 TileIndex = 0; TileIndex < TestTileCount; ++TileIndex)
|
||||||
|
{
|
||||||
|
const FIntVector TileOrigin(
|
||||||
|
(TileIndex % 17 - 8) * 64,
|
||||||
|
((TileIndex / 17) % 13 - 6) * 64,
|
||||||
|
((TileIndex / 221) % 9 - 4) * 64);
|
||||||
|
const uint32 Seed = 1337u + static_cast<uint32>(TileIndex) * 0x9E3779B9u;
|
||||||
|
|
||||||
|
for (int32 BlockZ = 0; BlockZ < 9; ++BlockZ)
|
||||||
|
{
|
||||||
|
for (int32 BlockY = 0; BlockY < 9; ++BlockY)
|
||||||
|
{
|
||||||
|
for (int32 BlockX = 0; BlockX < 9; ++BlockX)
|
||||||
|
{
|
||||||
|
++BlocksScanned;
|
||||||
|
const int32 MinX = BlockX * VoxelNoise::WormBlockSampleSide - 1;
|
||||||
|
const int32 MinY = BlockY * VoxelNoise::WormBlockSampleSide - 1;
|
||||||
|
const int32 MinZ = BlockZ * VoxelNoise::WormBlockSampleSide - 1;
|
||||||
|
const int32 MaxX = FMath::Min(
|
||||||
|
TestCellsPerAxis + 1,
|
||||||
|
MinX + VoxelNoise::WormBlockSampleSide - 1);
|
||||||
|
const int32 MaxY = FMath::Min(
|
||||||
|
TestCellsPerAxis + 1,
|
||||||
|
MinY + VoxelNoise::WormBlockSampleSide - 1);
|
||||||
|
const int32 MaxZ = FMath::Min(
|
||||||
|
TestCellsPerAxis + 1,
|
||||||
|
MinZ + VoxelNoise::WormBlockSampleSide - 1);
|
||||||
|
const FVector3f Q0 = MakeWormNoisePosition(
|
||||||
|
TileOrigin.X + MinX, TileOrigin.Y + MinY, TileOrigin.Z + MinZ, Seed);
|
||||||
|
const FVector3f Q1 = MakeWormNoisePosition(
|
||||||
|
TileOrigin.X + MaxX, TileOrigin.Y + MaxY, TileOrigin.Z + MaxZ, Seed);
|
||||||
|
const FVector3f QMin(
|
||||||
|
FMath::Min(Q0.X, Q1.X), FMath::Min(Q0.Y, Q1.Y), FMath::Min(Q0.Z, Q1.Z));
|
||||||
|
const FVector3f QMax(
|
||||||
|
FMath::Max(Q0.X, Q1.X), FMath::Max(Q0.Y, Q1.Y), FMath::Max(Q0.Z, Q1.Z));
|
||||||
|
const FVector3f QCenter(
|
||||||
|
static_cast<float>((static_cast<double>(QMin.X) + QMax.X) * 0.5),
|
||||||
|
static_cast<float>((static_cast<double>(QMin.Y) + QMax.Y) * 0.5),
|
||||||
|
static_cast<float>((static_cast<double>(QMin.Z) + QMax.Z) * 0.5));
|
||||||
|
|
||||||
|
if (!VoxelNoise::ProvesScaledAbsPerlin3DAbove(
|
||||||
|
QCenter, QMin, QMax, WormThreshold, FullL))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
++ProductionProofs;
|
||||||
|
for (int32 Z = MinZ; Z <= MaxZ; ++Z)
|
||||||
|
{
|
||||||
|
for (int32 Y = MinY; Y <= MaxY; ++Y)
|
||||||
|
{
|
||||||
|
for (int32 X = MinX; X <= MaxX; ++X)
|
||||||
|
{
|
||||||
|
++ProductionLatticeSamples;
|
||||||
|
const float ExactN1 = ScaledN1(MakeWormNoisePosition(
|
||||||
|
TileOrigin.X + X, TileOrigin.Y + Y, TileOrigin.Z + Z, Seed));
|
||||||
|
if (ExactN1 < WormThreshold)
|
||||||
|
{
|
||||||
|
++ProductionViolations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Production block audit: tiles=%d blocks=%llu proofs=%llu lattice_samples=%llu "
|
||||||
|
"violations=%llu, side=%d, threshold=%.6f."),
|
||||||
|
TestTileCount, static_cast<unsigned long long>(BlocksScanned),
|
||||||
|
static_cast<unsigned long long>(ProductionProofs),
|
||||||
|
static_cast<unsigned long long>(ProductionLatticeSamples),
|
||||||
|
static_cast<unsigned long long>(ProductionViolations),
|
||||||
|
VoxelNoise::WormBlockSampleSide, WormThreshold));
|
||||||
|
TestTrue(TEXT("the production-bound block audit found at least one proof"),
|
||||||
|
ProductionProofs > 0);
|
||||||
|
TestEqual(TEXT("every production-bound proof is sound on its exact lattice"),
|
||||||
|
ProductionViolations, static_cast<uint64>(0));
|
||||||
|
|
||||||
|
// Fail-capable negative control. This pair is from a real low-coordinate hash cell. Its exact
|
||||||
|
// scaled-N1 slope is above L/2; the threshold is selected between the two exact values, so a
|
||||||
|
// deliberately halved bound admits a proof that the second lattice point violates.
|
||||||
|
const FVector3f WitnessCenter(1510.5478515625f, 2191.43701171875f, 7279.404296875f);
|
||||||
|
const FVector3f WitnessPoint(1510.54931640625f, 2191.437255859375f, 7279.50390625f);
|
||||||
|
const FVector3f WitnessMin(
|
||||||
|
2.0f * WitnessCenter.X - WitnessPoint.X,
|
||||||
|
2.0f * WitnessCenter.Y - WitnessPoint.Y,
|
||||||
|
2.0f * WitnessCenter.Z - WitnessPoint.Z);
|
||||||
|
const FVector3f WitnessMax = WitnessPoint;
|
||||||
|
constexpr float WitnessThreshold = 0.015f;
|
||||||
|
const float WitnessCenterN1 = ScaledN1(WitnessCenter);
|
||||||
|
const float WitnessPointN1 = ScaledN1(WitnessPoint);
|
||||||
|
const bool bHalfProof = VoxelNoise::ProvesScaledAbsPerlin3DAbove(
|
||||||
|
WitnessCenter, WitnessMin, WitnessMax, WitnessThreshold, HalfL);
|
||||||
|
const bool bFullProof = VoxelNoise::ProvesScaledAbsPerlin3DAbove(
|
||||||
|
WitnessCenter, WitnessMin, WitnessMax, WitnessThreshold, FullL);
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Halved-L negative control: center_N1=%.9f point_N1=%.9f threshold=%.6f "
|
||||||
|
"half_proof=%d full_proof=%d."),
|
||||||
|
WitnessCenterN1, WitnessPointN1, WitnessThreshold,
|
||||||
|
bHalfProof ? 1 : 0, bFullProof ? 1 : 0));
|
||||||
|
TestTrue(TEXT("the deliberately halved L admits the negative-control block"), bHalfProof);
|
||||||
|
TestFalse(TEXT("the full L rejects the negative-control block"), bFullProof);
|
||||||
|
TestTrue(TEXT("the negative-control lattice point really violates N1 >= threshold"),
|
||||||
|
WitnessPointN1 < WitnessThreshold);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,219 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "VoxelTypes.h"
|
||||||
|
|
||||||
|
namespace VoxelClipmapDesiredTiles
|
||||||
|
{
|
||||||
|
struct FParameters
|
||||||
|
{
|
||||||
|
int32 ClipRadius = 3;
|
||||||
|
int32 MaxClipLevel = 4;
|
||||||
|
int32 RenderDistanceChunks = 0;
|
||||||
|
bool bFarSheetRing = true;
|
||||||
|
int32 FarSheetSpanLevels = 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
FORCEINLINE int32 FloorDiv(int32 Value, int32 Divisor)
|
||||||
|
{
|
||||||
|
return Value >= 0 ? (Value / Divisor) : -(((-Value) + Divisor - 1) / Divisor);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE FIntVector FloorDiv(const FIntVector& Value, int32 Divisor)
|
||||||
|
{
|
||||||
|
return FIntVector(FloorDiv(Value.X, Divisor), FloorDiv(Value.Y, Divisor),
|
||||||
|
FloorDiv(Value.Z, Divisor));
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE int32 CeilDiv(int32 Value, int32 Divisor)
|
||||||
|
{
|
||||||
|
return -FloorDiv(-Value, Divisor);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE void AlignedRingBounds(const FIntVector& Center, int32 Radius, int32 Alignment,
|
||||||
|
FIntVector& OutMin, FIntVector& OutMax)
|
||||||
|
{
|
||||||
|
const auto AlignAxis = [Radius, Alignment](int32 CenterAxis,
|
||||||
|
int32& OutAxisMin, int32& OutAxisMax)
|
||||||
|
{
|
||||||
|
const int32 BaseMin = CenterAxis - Radius;
|
||||||
|
const int32 BaseEnd = CenterAxis + Radius + 1;
|
||||||
|
OutAxisMin = FloorDiv(BaseMin, Alignment) * Alignment;
|
||||||
|
OutAxisMax = CeilDiv(BaseEnd, Alignment) * Alignment - 1;
|
||||||
|
};
|
||||||
|
AlignAxis(Center.X, OutMin.X, OutMax.X);
|
||||||
|
AlignAxis(Center.Y, OutMin.Y, OutMax.Y);
|
||||||
|
AlignAxis(Center.Z, OutMin.Z, OutMax.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE int32 OuterShellRadius(const FParameters& Parameters, int32 Radius, int32 MaxLevel)
|
||||||
|
{
|
||||||
|
if (Parameters.RenderDistanceChunks <= 0)
|
||||||
|
{
|
||||||
|
return Radius;
|
||||||
|
}
|
||||||
|
return FMath::Max(Radius,
|
||||||
|
(Parameters.RenderDistanceChunks + (1 << MaxLevel) - 1) >> MaxLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE void OuterShell(const FParameters& Parameters, int32 Radius, int32 MaxLevel,
|
||||||
|
int32& OutLevel, int32& OutRadius)
|
||||||
|
{
|
||||||
|
OutLevel = MaxLevel;
|
||||||
|
OutRadius = OuterShellRadius(Parameters, Radius, MaxLevel);
|
||||||
|
if (Parameters.bFarSheetRing && OutRadius > Radius)
|
||||||
|
{
|
||||||
|
OutLevel = MaxLevel + FMath::Clamp(Parameters.FarSheetSpanLevels, 1, 4);
|
||||||
|
OutRadius = FMath::Max(1,
|
||||||
|
(Parameters.RenderDistanceChunks + (1 << OutLevel) - 1) >> OutLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE void ResolveVerticalBounds(
|
||||||
|
int32 CenterChunkZ, int32 ViewDistanceUp, int32 ViewDistanceDown,
|
||||||
|
int32 StrateViewMarginChunks, bool bClampToStrate, bool bHasStrate,
|
||||||
|
int32 StrateTopChunkZ, int32 StrateBottomChunkZ,
|
||||||
|
int32& OutMinChunkZ, int32& OutMaxChunkZ)
|
||||||
|
{
|
||||||
|
OutMinChunkZ = MIN_int32;
|
||||||
|
OutMaxChunkZ = MAX_int32;
|
||||||
|
if (!bClampToStrate)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OutMinChunkZ = CenterChunkZ - ViewDistanceDown;
|
||||||
|
OutMaxChunkZ = CenterChunkZ + ViewDistanceUp;
|
||||||
|
if (bHasStrate)
|
||||||
|
{
|
||||||
|
OutMinChunkZ = StrateBottomChunkZ - StrateViewMarginChunks;
|
||||||
|
OutMaxChunkZ = StrateTopChunkZ + StrateViewMarginChunks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE void Build(const FIntVector& Center, int32 MinChunkZ, int32 MaxChunkZ,
|
||||||
|
const FParameters& Parameters, TArray<FVoxelTileKey>& OutTiles)
|
||||||
|
{
|
||||||
|
OutTiles.Reset();
|
||||||
|
const int32 Radius = FMath::Max(1, Parameters.ClipRadius);
|
||||||
|
const int32 MaxLevel = FMath::Clamp(Parameters.MaxClipLevel, 0, 8);
|
||||||
|
const int32 OuterRadius = OuterShellRadius(Parameters, Radius, MaxLevel);
|
||||||
|
int32 SheetLevel = MaxLevel;
|
||||||
|
int32 SheetRadius = OuterRadius;
|
||||||
|
OuterShell(Parameters, Radius, MaxLevel, SheetLevel, SheetRadius);
|
||||||
|
const bool bSheetRing = SheetLevel > MaxLevel;
|
||||||
|
const int32 MaxMCRingLevel = bSheetRing ? SheetLevel - 1 : MaxLevel;
|
||||||
|
FIntVector PreviousRingMin = FIntVector::ZeroValue;
|
||||||
|
FIntVector PreviousRingMax = FIntVector::ZeroValue;
|
||||||
|
FIntVector MCRingMin = FIntVector::ZeroValue;
|
||||||
|
FIntVector MCRingMax = FIntVector::ZeroValue;
|
||||||
|
|
||||||
|
for (int32 Level = 0; Level <= MaxMCRingLevel; ++Level)
|
||||||
|
{
|
||||||
|
const int32 Pow = 1 << Level;
|
||||||
|
const FIntVector CenterLevel = FloorDiv(Center, Pow);
|
||||||
|
const int32 LevelRadius = (Level == MaxLevel && !bSheetRing)
|
||||||
|
? OuterRadius : Radius;
|
||||||
|
|
||||||
|
// Every finer shell must end on a complete child-pair boundary so a coarser tile is
|
||||||
|
// either wholly inside it or wholly outside it. The final MC ring uses the sheet's
|
||||||
|
// grouping width when a sheet ring follows it; otherwise the ordinary LOD boundary
|
||||||
|
// only needs pair alignment.
|
||||||
|
const int32 RingAlignment = Level < MaxMCRingLevel
|
||||||
|
? 2
|
||||||
|
: (bSheetRing ? (1 << (SheetLevel - MaxMCRingLevel)) : 1);
|
||||||
|
FIntVector RingMin, RingMax;
|
||||||
|
AlignedRingBounds(CenterLevel, LevelRadius, RingAlignment, RingMin, RingMax);
|
||||||
|
if (Level == MaxMCRingLevel)
|
||||||
|
{
|
||||||
|
MCRingMin = RingMin;
|
||||||
|
MCRingMax = RingMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 DeltaZMin = RingMin.Z - CenterLevel.Z;
|
||||||
|
int32 DeltaZMax = RingMax.Z - CenterLevel.Z;
|
||||||
|
if (LevelRadius > Radius && MinChunkZ != MIN_int32)
|
||||||
|
{
|
||||||
|
DeltaZMin = FMath::Max(DeltaZMin, FloorDiv(MinChunkZ, Pow) - CenterLevel.Z);
|
||||||
|
DeltaZMax = FMath::Min(DeltaZMax, FloorDiv(MaxChunkZ, Pow) - CenterLevel.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 DeltaZ = DeltaZMin; DeltaZ <= DeltaZMax; ++DeltaZ)
|
||||||
|
for (int32 DeltaY = RingMin.Y - CenterLevel.Y;
|
||||||
|
DeltaY <= RingMax.Y - CenterLevel.Y; ++DeltaY)
|
||||||
|
for (int32 DeltaX = RingMin.X - CenterLevel.X;
|
||||||
|
DeltaX <= RingMax.X - CenterLevel.X; ++DeltaX)
|
||||||
|
{
|
||||||
|
const FIntVector Tile = CenterLevel + FIntVector(DeltaX, DeltaY, DeltaZ);
|
||||||
|
const int32 TileMinZ = Tile.Z << Level;
|
||||||
|
const int32 TileMaxZ = ((Tile.Z + 1) << Level) - 1;
|
||||||
|
if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Level > 0)
|
||||||
|
{
|
||||||
|
const bool bCovered =
|
||||||
|
(2 * Tile.X >= PreviousRingMin.X)
|
||||||
|
&& (2 * Tile.X + 1 <= PreviousRingMax.X)
|
||||||
|
&& (2 * Tile.Y >= PreviousRingMin.Y)
|
||||||
|
&& (2 * Tile.Y + 1 <= PreviousRingMax.Y)
|
||||||
|
&& (2 * Tile.Z >= PreviousRingMin.Z)
|
||||||
|
&& (2 * Tile.Z + 1 <= PreviousRingMax.Z);
|
||||||
|
if (bCovered)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutTiles.Emplace(Tile, Level);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreviousRingMin = RingMin;
|
||||||
|
PreviousRingMax = RingMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bSheetRing)
|
||||||
|
{
|
||||||
|
const int32 SheetPow = 1 << SheetLevel;
|
||||||
|
const FIntVector CenterSheet = FloorDiv(Center, SheetPow);
|
||||||
|
const int32 LevelDelta = SheetLevel - MaxMCRingLevel;
|
||||||
|
const int32 MCPerSheet = 1 << LevelDelta;
|
||||||
|
|
||||||
|
int32 DeltaZMin = -SheetRadius;
|
||||||
|
int32 DeltaZMax = SheetRadius;
|
||||||
|
if (MinChunkZ != MIN_int32)
|
||||||
|
{
|
||||||
|
DeltaZMin = FMath::Max(DeltaZMin,
|
||||||
|
FloorDiv(MinChunkZ, SheetPow) - CenterSheet.Z);
|
||||||
|
DeltaZMax = FMath::Min(DeltaZMax,
|
||||||
|
FloorDiv(MaxChunkZ, SheetPow) - CenterSheet.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 DeltaZ = DeltaZMin; DeltaZ <= DeltaZMax; ++DeltaZ)
|
||||||
|
for (int32 DeltaY = -SheetRadius; DeltaY <= SheetRadius; ++DeltaY)
|
||||||
|
for (int32 DeltaX = -SheetRadius; DeltaX <= SheetRadius; ++DeltaX)
|
||||||
|
{
|
||||||
|
const FIntVector Tile = CenterSheet + FIntVector(DeltaX, DeltaY, DeltaZ);
|
||||||
|
const int32 TileMinZ = Tile.Z << SheetLevel;
|
||||||
|
const int32 TileMaxZ = ((Tile.Z + 1) << SheetLevel) - 1;
|
||||||
|
if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool bCovered =
|
||||||
|
(Tile.X * MCPerSheet >= MCRingMin.X)
|
||||||
|
&& ((Tile.X + 1) * MCPerSheet - 1 <= MCRingMax.X)
|
||||||
|
&& (Tile.Y * MCPerSheet >= MCRingMin.Y)
|
||||||
|
&& ((Tile.Y + 1) * MCPerSheet - 1 <= MCRingMax.Y)
|
||||||
|
&& (Tile.Z * MCPerSheet >= MCRingMin.Z)
|
||||||
|
&& ((Tile.Z + 1) * MCPerSheet - 1 <= MCRingMax.Z);
|
||||||
|
if (!bCovered)
|
||||||
|
{
|
||||||
|
OutTiles.Emplace(Tile, SheetLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,15 +31,31 @@ static constexpr int32 GMaxCompanionsPerParent = 256;
|
|||||||
// decoration's per-cell MaxPerChunk keeps its "per chunk" meaning).
|
// decoration's per-cell MaxPerChunk keeps its "per chunk" meaning).
|
||||||
static constexpr int32 DECO_CELL_VOXELS = CHUNK_SIZE;
|
static constexpr int32 DECO_CELL_VOXELS = CHUNK_SIZE;
|
||||||
|
|
||||||
// Count of in-flight decoration march tasks (for the EndPlay drain). File-scope so the worker lambda's
|
|
||||||
// RAII guard can touch it without keeping the UObject 'this' alive for that alone.
|
|
||||||
static std::atomic<int32> GActiveDecoTasks{0};
|
|
||||||
|
|
||||||
static FORCEINLINE int32 CellChebyshev(const FIntPoint& A, const FIntPoint& B)
|
static FORCEINLINE int32 CellChebyshev(const FIntPoint& A, const FIntPoint& B)
|
||||||
{
|
{
|
||||||
return FMath::Max(FMath::Abs(A.X - B.X), FMath::Abs(A.Y - B.Y));
|
return FMath::Max(FMath::Abs(A.X - B.X), FMath::Abs(A.Y - B.Y));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool VF_DecoTransformLess(const FTransform& A, const FTransform& B)
|
||||||
|
{
|
||||||
|
// A transform is the complete deterministic identity of a placement. Comparing the matrix
|
||||||
|
// avoids the q/-q representation ambiguity of raw quaternions and gives the final region order
|
||||||
|
// the same result regardless of which worker cell completed first.
|
||||||
|
const FMatrix AM = A.ToMatrixWithScale();
|
||||||
|
const FMatrix BM = B.ToMatrixWithScale();
|
||||||
|
for (int32 Row = 0; Row < 4; ++Row)
|
||||||
|
{
|
||||||
|
for (int32 Col = 0; Col < 4; ++Col)
|
||||||
|
{
|
||||||
|
if (AM.M[Row][Col] != BM.M[Row][Col])
|
||||||
|
{
|
||||||
|
return AM.M[Row][Col] < BM.M[Row][Col];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void UVoxelContentManager::Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager,
|
void UVoxelContentManager::Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager,
|
||||||
UVoxelGenerator* InGenerator, UVoxelSettings* InSettings, int32 InSeed)
|
UVoxelGenerator* InGenerator, UVoxelSettings* InSettings, int32 InSeed)
|
||||||
{
|
{
|
||||||
@@ -59,6 +75,7 @@ void UVoxelContentManager::BeginDestroy()
|
|||||||
{
|
{
|
||||||
// Backstop: stop any worker from touching us. EndPlay → NotifyShutdown should have already drained.
|
// Backstop: stop any worker from touching us. EndPlay → NotifyShutdown should have already drained.
|
||||||
bShuttingDown.store(true, std::memory_order_release);
|
bShuttingDown.store(true, std::memory_order_release);
|
||||||
|
JoinDecorationTasks();
|
||||||
Super::BeginDestroy();
|
Super::BeginDestroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,9 +83,9 @@ void UVoxelContentManager::NotifyShutdown()
|
|||||||
{
|
{
|
||||||
bShuttingDown.store(true, std::memory_order_release);
|
bShuttingDown.store(true, std::memory_order_release);
|
||||||
|
|
||||||
// Wait for in-flight march tasks to finish (they check the flag and bail). Timeout to avoid hangs.
|
// A timeout is safe for the editor's pre-mutation probe, but not for UObject destruction:
|
||||||
const double Deadline = FPlatformTime::Seconds() + 3.0;
|
// join this manager's tasks fully before dropping its worker-captured `this`.
|
||||||
WaitForDecorationTasks(Deadline);
|
JoinDecorationTasks();
|
||||||
|
|
||||||
DrainDecoResults();
|
DrainDecoResults();
|
||||||
ResetGridBuildState(NearGrid);
|
ResetGridBuildState(NearGrid);
|
||||||
@@ -77,7 +94,7 @@ void UVoxelContentManager::NotifyShutdown()
|
|||||||
|
|
||||||
bool UVoxelContentManager::WaitForDecorationTasks(double Deadline)
|
bool UVoxelContentManager::WaitForDecorationTasks(double Deadline)
|
||||||
{
|
{
|
||||||
while (GActiveDecoTasks.load(std::memory_order_relaxed) > 0)
|
while (ActiveDecorationTasks.load(std::memory_order_relaxed) > 0)
|
||||||
{
|
{
|
||||||
if (FPlatformTime::Seconds() > Deadline) return false;
|
if (FPlatformTime::Seconds() > Deadline) return false;
|
||||||
FPlatformProcess::Yield();
|
FPlatformProcess::Yield();
|
||||||
@@ -85,6 +102,14 @@ bool UVoxelContentManager::WaitForDecorationTasks(double Deadline)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UVoxelContentManager::JoinDecorationTasks()
|
||||||
|
{
|
||||||
|
while (ActiveDecorationTasks.load(std::memory_order_acquire) > 0)
|
||||||
|
{
|
||||||
|
FPlatformProcess::Yield();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void UVoxelContentManager::DrainDecoResults()
|
void UVoxelContentManager::DrainDecoResults()
|
||||||
{
|
{
|
||||||
FDecoCellResult Discard;
|
FDecoCellResult Discard;
|
||||||
@@ -198,12 +223,24 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
|
|||||||
|
|
||||||
// Refresh each grid's (tier, radius, spacing) from settings for this update. Radius/spacing are read
|
// Refresh each grid's (tier, radius, spacing) from settings for this update. Radius/spacing are read
|
||||||
// every frame so live edits to the data asset take effect; the grids themselves persist across updates.
|
// every frame so live edits to the data asset take effect; the grids themselves persist across updates.
|
||||||
|
// A stationary player still needs a rebuild when one of these values changes: otherwise already
|
||||||
|
// loaded regions remain outside the new radius (or retain the old sampling density) forever.
|
||||||
|
const int32 NewNearRadius = FMath::Max(1, Settings->DecorationNearRadiusChunks);
|
||||||
|
const int32 NewFarRadius = FMath::Max(1, Settings->DecorationRadiusChunks);
|
||||||
|
const int32 NewNearSpacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE);
|
||||||
|
const int32 NewFarSpacing = FMath::Clamp(Settings->DecorationFarSpacingVoxels, 1, CHUNK_SIZE);
|
||||||
|
const int32 NewRegionSize = RegionSize();
|
||||||
|
const bool bDecorationGridConfigChanged =
|
||||||
|
NearGrid.Radius != NewNearRadius || FarGrid.Radius != NewFarRadius
|
||||||
|
|| NearGrid.Spacing != NewNearSpacing || FarGrid.Spacing != NewFarSpacing
|
||||||
|
|| LastRegionSize != NewRegionSize;
|
||||||
|
|
||||||
NearGrid.Tier = EDecoStreamTier::Near;
|
NearGrid.Tier = EDecoStreamTier::Near;
|
||||||
NearGrid.Radius = FMath::Max(1, Settings->DecorationNearRadiusChunks);
|
NearGrid.Radius = NewNearRadius;
|
||||||
NearGrid.Spacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE);
|
NearGrid.Spacing = NewNearSpacing;
|
||||||
FarGrid.Tier = EDecoStreamTier::Far;
|
FarGrid.Tier = EDecoStreamTier::Far;
|
||||||
FarGrid.Radius = FMath::Max(1, Settings->DecorationRadiusChunks);
|
FarGrid.Radius = NewFarRadius;
|
||||||
FarGrid.Spacing = FMath::Clamp(Settings->DecorationFarSpacingVoxels, 1, CHUNK_SIZE);
|
FarGrid.Spacing = NewFarSpacing;
|
||||||
|
|
||||||
// Build the decoration palette ONCE for this update, PARTITIONED by tier. With biomes, concatenate every
|
// Build the decoration palette ONCE for this update, PARTITIONED by tier. With biomes, concatenate every
|
||||||
// biome's deco list and tag each entry with its context-biome index; the worker resolves a column's biome
|
// biome's deco list and tag each entry with its context-biome index; the worker resolves a column's biome
|
||||||
@@ -240,13 +277,19 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strate change → wipe + force a full rebuild (bumps epoch so old in-flight tasks are discarded).
|
// Strate or grid configuration change → wipe + force a full rebuild. This also invalidates
|
||||||
if (StrateIndex != LastStrateIndex)
|
// every old region build before the next stationary-player update can reuse it.
|
||||||
|
if (StrateIndex != LastStrateIndex || bDecorationGridConfigChanged)
|
||||||
{
|
{
|
||||||
ClearAllDecorations();
|
ClearAllDecorations();
|
||||||
LastStrateIndex = StrateIndex;
|
LastStrateIndex = StrateIndex;
|
||||||
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||||||
}
|
}
|
||||||
|
LastNearRadius = NewNearRadius;
|
||||||
|
LastFarRadius = NewFarRadius;
|
||||||
|
LastNearSpacing = NewNearSpacing;
|
||||||
|
LastFarSpacing = NewFarSpacing;
|
||||||
|
LastRegionSize = NewRegionSize;
|
||||||
|
|
||||||
if (PlayerCell != LastDecoCell)
|
if (PlayerCell != LastDecoCell)
|
||||||
{
|
{
|
||||||
@@ -295,11 +338,22 @@ void UVoxelContentManager::RebuildDesiredCells(FDecoGrid& G, const FIntPoint& Pl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TArray<FIntPoint> DesiredRegionList;
|
||||||
|
DesiredRegionList.Reserve(DesiredRegions.Num());
|
||||||
|
for (const FIntPoint& Region : DesiredRegions)
|
||||||
|
{
|
||||||
|
DesiredRegionList.Add(Region);
|
||||||
|
}
|
||||||
|
DesiredRegionList.Sort([](const FIntPoint& A, const FIntPoint& B)
|
||||||
|
{
|
||||||
|
return (A.Y != B.Y) ? (A.Y < B.Y) : (A.X < B.X);
|
||||||
|
});
|
||||||
|
|
||||||
// Start a build for each desired region that isn't already loaded or building. In-progress builds are
|
// Start a build for each desired region that isn't already loaded or building. In-progress builds are
|
||||||
// LEFT to finish even if they fell out of range (their cell tasks are already off-thread); the apply
|
// LEFT to finish even if they fell out of range (their cell tasks are already off-thread); the apply
|
||||||
// step discards a completed build that is no longer desired (see ProcessDecoResults). Each new build
|
// step discards a completed build that is no longer desired (see ProcessDecoResults). Each new build
|
||||||
// enqueues all RxR of its cells once — a building region is never re-queued (no duplicate launches).
|
// enqueues all RxR of its cells once — a building region is never re-queued (no duplicate launches).
|
||||||
for (const FIntPoint& Region : DesiredRegions)
|
for (const FIntPoint& Region : DesiredRegionList)
|
||||||
{
|
{
|
||||||
if (G.Regions.Contains(Region)) continue; // already applied → leave it (no re-stream)
|
if (G.Regions.Contains(Region)) continue; // already applied → leave it (no re-stream)
|
||||||
if (G.Builds.Contains(Region)) continue; // already marching its cells
|
if (G.Builds.Contains(Region)) continue; // already marching its cells
|
||||||
@@ -320,7 +374,10 @@ void UVoxelContentManager::RebuildDesiredCells(FDecoGrid& G, const FIntPoint& Pl
|
|||||||
// build was already discarded) are cheaply skipped at launch, so PendingLaunch self-cleans as it drains.
|
// build was already discarded) are cheaply skipped at launch, so PendingLaunch self-cleans as it drains.
|
||||||
G.PendingLaunch.Sort([PlayerCell](const FIntPoint& A, const FIntPoint& B)
|
G.PendingLaunch.Sort([PlayerCell](const FIntPoint& A, const FIntPoint& B)
|
||||||
{
|
{
|
||||||
return CellChebyshev(A, PlayerCell) < CellChebyshev(B, PlayerCell);
|
const int32 AD = CellChebyshev(A, PlayerCell);
|
||||||
|
const int32 BD = CellChebyshev(B, PlayerCell);
|
||||||
|
if (AD != BD) return AD < BD;
|
||||||
|
return (A.Y != B.Y) ? (A.Y < B.Y) : (A.X < B.X);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,13 +442,17 @@ void UVoxelContentManager::LaunchDecoTasks(FDecoGrid& G, const FIntPoint& Player
|
|||||||
UVoxelGenerator* Gen = Generator;
|
UVoxelGenerator* Gen = Generator;
|
||||||
|
|
||||||
G.InFlightCells.Add(Cell);
|
G.InFlightCells.Add(Cell);
|
||||||
GActiveDecoTasks.fetch_add(1, std::memory_order_relaxed);
|
ActiveDecorationTasks.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
|
||||||
UE::Tasks::Launch(TEXT("DecoMarch"),
|
UE::Tasks::Launch(TEXT("DecoMarch"),
|
||||||
[this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId, GridTier,
|
[this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId, GridTier,
|
||||||
Entries = MoveTemp(EntriesCopy), EntryBiome = MoveTemp(EntryBiomeCopy)]() mutable
|
Entries = MoveTemp(EntriesCopy), EntryBiome = MoveTemp(EntryBiomeCopy)]() mutable
|
||||||
{
|
{
|
||||||
struct FGuard { ~FGuard() { GActiveDecoTasks.fetch_sub(1, std::memory_order_relaxed); } } Guard;
|
struct FGuard
|
||||||
|
{
|
||||||
|
UVoxelContentManager* Owner;
|
||||||
|
~FGuard() { Owner->ActiveDecorationTasks.fetch_sub(1, std::memory_order_release); }
|
||||||
|
} Guard{this};
|
||||||
|
|
||||||
if (bShuttingDown.load(std::memory_order_relaxed)) return;
|
if (bShuttingDown.load(std::memory_order_relaxed)) return;
|
||||||
|
|
||||||
@@ -440,9 +501,9 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
for (int32 e = 0; e < Entries.Num(); ++e)
|
for (int32 e = 0; e < Entries.Num(); ++e)
|
||||||
{
|
{
|
||||||
CosMaxSlope[e] = (Entries[e].Profile.MaxSlopeAngle < 89.99f)
|
CosMaxSlope[e] = (Entries[e].Profile.MaxSlopeAngle < 89.99f)
|
||||||
? FMath::Cos(FMath::DegreesToRadians(Entries[e].Profile.MaxSlopeAngle)) : -1.0f;
|
? VoxelMath::DetCos(FMath::DegreesToRadians(Entries[e].Profile.MaxSlopeAngle)) : -1.0f;
|
||||||
CosMinSlope[e] = (Entries[e].Profile.MinSlopeAngle > 0.01f)
|
CosMinSlope[e] = (Entries[e].Profile.MinSlopeAngle > 0.01f)
|
||||||
? FMath::Cos(FMath::DegreesToRadians(Entries[e].Profile.MinSlopeAngle)) : -1.0f;
|
? VoxelMath::DetCos(FMath::DegreesToRadians(Entries[e].Profile.MinSlopeAngle)) : -1.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-COLUMN biome cache: ResolveBiomeSampleAt's noise-heavy cell classification is box-validated
|
// Per-COLUMN biome cache: ResolveBiomeSampleAt's noise-heavy cell classification is box-validated
|
||||||
@@ -586,8 +647,10 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
const float Ang = VoxelHash::ToFloat01(IH) * 2.0f * PI;
|
const float Ang = VoxelHash::ToFloat01(IH) * 2.0f * PI;
|
||||||
const float RadVox = FMath::Lerp(Comp.RadiusMinVox, Comp.RadiusMaxVox,
|
const float RadVox = FMath::Lerp(Comp.RadiusMinVox, Comp.RadiusMaxVox,
|
||||||
VoxelHash::ToFloat01(VoxelHash::Mix(IH ^ 0x77u)));
|
VoxelHash::ToFloat01(VoxelHash::Mix(IH ^ 0x77u)));
|
||||||
const float SatVX = VX + FMath::Cos(Ang) * RadVox; // voxel, actor-local
|
float SinAng = 0.0f, CosAng = 0.0f;
|
||||||
const float SatVY = VY + FMath::Sin(Ang) * RadVox;
|
VoxelMath::DetSinCos(SinAng, CosAng, Ang);
|
||||||
|
const float SatVX = VX + CosAng * RadVox; // voxel, actor-local
|
||||||
|
const float SatVY = VY + SinAng * RadVox;
|
||||||
|
|
||||||
// Optional per-satellite gating (relief/moisture/biome-border at ITS own XY).
|
// Optional per-satellite gating (relief/moisture/biome-border at ITS own XY).
|
||||||
if (Comp.Profile.Conditions.Num() > 0 &&
|
if (Comp.Profile.Conditions.Num() > 0 &&
|
||||||
@@ -632,8 +695,10 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
const float SAng = VoxelHash::ToFloat01(JH) * 2.0f * PI;
|
const float SAng = VoxelHash::ToFloat01(JH) * 2.0f * PI;
|
||||||
const float SRadVox = FMath::Lerp(Sub.RadiusMinVox, Sub.RadiusMaxVox,
|
const float SRadVox = FMath::Lerp(Sub.RadiusMinVox, Sub.RadiusMaxVox,
|
||||||
VoxelHash::ToFloat01(VoxelHash::Mix(JH ^ 0x77u)));
|
VoxelHash::ToFloat01(VoxelHash::Mix(JH ^ 0x77u)));
|
||||||
const float SubVX = SatVX + FMath::Cos(SAng) * SRadVox;
|
float SinSAng = 0.0f, CosSAng = 0.0f;
|
||||||
const float SubVY = SatVY + FMath::Sin(SAng) * SRadVox;
|
VoxelMath::DetSinCos(SinSAng, CosSAng, SAng);
|
||||||
|
const float SubVX = SatVX + CosSAng * SRadVox;
|
||||||
|
const float SubVY = SatVY + SinSAng * SRadVox;
|
||||||
|
|
||||||
if (Sub.Profile.Conditions.Num() > 0 &&
|
if (Sub.Profile.Conditions.Num() > 0 &&
|
||||||
!Gen->EvaluateTerrainConditions(Sub.Profile.Conditions, SubVX, SubVY, Ctx.BiomeCtx)) continue;
|
!Gen->EvaluateTerrainConditions(Sub.Profile.Conditions, SubVX, SubVY, Ctx.BiomeCtx)) continue;
|
||||||
@@ -788,6 +853,10 @@ void UVoxelContentManager::ProcessDecoResults(const FIntPoint& PlayerCell)
|
|||||||
for (FDecoGrid* GP : { &NearGrid, &FarGrid })
|
for (FDecoGrid* GP : { &NearGrid, &FarGrid })
|
||||||
{
|
{
|
||||||
FDecoGrid& G = *GP;
|
FDecoGrid& G = *GP;
|
||||||
|
G.Completed.Sort([](const FIntPoint& A, const FIntPoint& B)
|
||||||
|
{
|
||||||
|
return (A.Y != B.Y) ? (A.Y < B.Y) : (A.X < B.X);
|
||||||
|
});
|
||||||
while (G.Completed.Num() > 0 && Applied < Budget)
|
while (G.Completed.Num() > 0 && Applied < Budget)
|
||||||
{
|
{
|
||||||
const FIntPoint Region = G.Completed[0];
|
const FIntPoint Region = G.Completed[0];
|
||||||
@@ -846,9 +915,21 @@ void UVoxelContentManager::MergeCellResult(FDecoGrid& G, const FDecoCellResult&
|
|||||||
{
|
{
|
||||||
if (!Prof.InstancedMesh) continue;
|
if (!Prof.InstancedMesh) continue;
|
||||||
// Bucket by MESH so cells (and biomes) sharing a mesh collapse into one region HISM. The first
|
// Bucket by MESH so cells (and biomes) sharing a mesh collapse into one region HISM. The first
|
||||||
// contributor sets the render tuning (cull/shadow/scale) for the whole region's instances.
|
// contributor in deterministic source order sets the render tuning (cull/shadow/scale) for the
|
||||||
|
// whole region's instances. Worker completion order must not choose that representative.
|
||||||
FRegionMeshBucket& Bucket = Build->MeshBuckets.FindOrAdd(Prof.InstancedMesh);
|
FRegionMeshBucket& Bucket = Build->MeshBuckets.FindOrAdd(Prof.InstancedMesh);
|
||||||
if (Bucket.Xforms.Num() == 0) { Bucket.Profile = Prof; }
|
const bool bEarlierSource =
|
||||||
|
S.EntryIdx < Bucket.SourceEntryIdx
|
||||||
|
|| (S.EntryIdx == Bucket.SourceEntryIdx && S.CompanionIdx < Bucket.SourceCompanionIdx)
|
||||||
|
|| (S.EntryIdx == Bucket.SourceEntryIdx && S.CompanionIdx == Bucket.SourceCompanionIdx
|
||||||
|
&& S.SubIdx < Bucket.SourceSubIdx);
|
||||||
|
if (Bucket.Xforms.Num() == 0 || bEarlierSource)
|
||||||
|
{
|
||||||
|
Bucket.Profile = Prof;
|
||||||
|
Bucket.SourceEntryIdx = S.EntryIdx;
|
||||||
|
Bucket.SourceCompanionIdx = S.CompanionIdx;
|
||||||
|
Bucket.SourceSubIdx = S.SubIdx;
|
||||||
|
}
|
||||||
Bucket.Xforms.Add(S.Xf);
|
Bucket.Xforms.Add(S.Xf);
|
||||||
}
|
}
|
||||||
else if (Prof.ActorClass)
|
else if (Prof.ActorClass)
|
||||||
@@ -893,6 +974,15 @@ void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FD
|
|||||||
|
|
||||||
FDecoRegionContent& Content = G.Regions.Add(Region);
|
FDecoRegionContent& Content = G.Regions.Add(Region);
|
||||||
|
|
||||||
|
Build.ActorSpawns.Sort([](const FRegionActorSpawn& A, const FRegionActorSpawn& B)
|
||||||
|
{
|
||||||
|
const FString AClass = A.ActorClass ? A.ActorClass->GetPathName() : FString();
|
||||||
|
const FString BClass = B.ActorClass ? B.ActorClass->GetPathName() : FString();
|
||||||
|
const int32 ClassCompare = FCString::Strcmp(*AClass, *BClass);
|
||||||
|
if (ClassCompare != 0) return ClassCompare < 0;
|
||||||
|
return VF_DecoTransformLess(A.Xf, B.Xf);
|
||||||
|
});
|
||||||
|
|
||||||
// Non-instanced actors — spawn each (no batch path). Decorations live only in the player's strate
|
// Non-instanced actors — spawn each (no batch path). Decorations live only in the player's strate
|
||||||
// (the march is strate-bounded), so their lights are always legitimately visible — no extra culling.
|
// (the march is strate-bounded), so their lights are always legitimately visible — no extra culling.
|
||||||
for (const FRegionActorSpawn& A : Build.ActorSpawns)
|
for (const FRegionActorSpawn& A : Build.ActorSpawns)
|
||||||
@@ -909,13 +999,27 @@ void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FD
|
|||||||
|
|
||||||
// One HISM per mesh for the ENTIRE region — the render-thread win: hundreds of per-cell components
|
// One HISM per mesh for the ENTIRE region — the render-thread win: hundreds of per-cell components
|
||||||
// collapse to a handful per region, so InitViews walks far fewer primitives every frame.
|
// collapse to a handful per region, so InitViews walks far fewer primitives every frame.
|
||||||
for (TPair<TWeakObjectPtr<UStaticMesh>, FRegionMeshBucket>& Pair : Build.MeshBuckets)
|
TArray<TWeakObjectPtr<UStaticMesh>> MeshKeys;
|
||||||
|
Build.MeshBuckets.GetKeys(MeshKeys);
|
||||||
|
MeshKeys.Sort([](const TWeakObjectPtr<UStaticMesh>& A, const TWeakObjectPtr<UStaticMesh>& B)
|
||||||
{
|
{
|
||||||
FRegionMeshBucket& Bucket = Pair.Value;
|
const UStaticMesh* AMesh = A.Get();
|
||||||
UStaticMesh* Mesh = Pair.Key.Get();
|
const UStaticMesh* BMesh = B.Get();
|
||||||
|
const FString APath = AMesh ? AMesh->GetPathName() : FString();
|
||||||
|
const FString BPath = BMesh ? BMesh->GetPathName() : FString();
|
||||||
|
return FCString::Strcmp(*APath, *BPath) < 0;
|
||||||
|
});
|
||||||
|
for (const TWeakObjectPtr<UStaticMesh>& MeshKey : MeshKeys)
|
||||||
|
{
|
||||||
|
FRegionMeshBucket* BucketPtr = Build.MeshBuckets.Find(MeshKey);
|
||||||
|
if (!BucketPtr) continue;
|
||||||
|
FRegionMeshBucket& Bucket = *BucketPtr;
|
||||||
|
UStaticMesh* Mesh = MeshKey.Get();
|
||||||
if (!Mesh || Bucket.Xforms.Num() == 0) continue;
|
if (!Mesh || Bucket.Xforms.Num() == 0) continue;
|
||||||
const FPlacementProfile& Prof = Bucket.Profile;
|
const FPlacementProfile& Prof = Bucket.Profile;
|
||||||
|
|
||||||
|
Bucket.Xforms.Sort(VF_DecoTransformLess);
|
||||||
|
|
||||||
UHierarchicalInstancedStaticMeshComponent* HISM =
|
UHierarchicalInstancedStaticMeshComponent* HISM =
|
||||||
NewObject<UHierarchicalInstancedStaticMeshComponent>(OwnerActor);
|
NewObject<UHierarchicalInstancedStaticMeshComponent>(OwnerActor);
|
||||||
HISM->SetStaticMesh(Mesh);
|
HISM->SetStaticMesh(Mesh);
|
||||||
@@ -998,6 +1102,16 @@ void UVoxelContentManager::ClearAllDecorations()
|
|||||||
// Drain any results already enqueued by in-flight tasks. No epoch bump needed: their BuildIds are now
|
// Drain any results already enqueued by in-flight tasks. No epoch bump needed: their BuildIds are now
|
||||||
// gone from the grids' Builds, so any straggler result is discarded on merge; new builds get fresh BuildIds.
|
// gone from the grids' Builds, so any straggler result is discarded on merge; new builds get fresh BuildIds.
|
||||||
DrainDecoResults();
|
DrainDecoResults();
|
||||||
|
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelContentManager::InvalidateDecorationBuilds()
|
||||||
|
{
|
||||||
|
// A terrain edit changes the worker's surface oracle. Clear both the applied regions and the
|
||||||
|
// in-progress build maps as one operation; any late result then fails the BuildId lookup and cannot
|
||||||
|
// resurrect pre-edit content. The next UpdateDecorations rebuilds the desired set even if the player
|
||||||
|
// has not moved.
|
||||||
|
ClearAllDecorations();
|
||||||
}
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -1120,9 +1234,9 @@ bool UVoxelContentManager::SpawnFromProfile(const FPlacementProfile& P, uint32 H
|
|||||||
|
|
||||||
// Surface-tilt gates (acos(|N.Z|); guarded so defaults cost no trig).
|
// Surface-tilt gates (acos(|N.Z|); guarded so defaults cost no trig).
|
||||||
if (P.MaxSlopeAngle < 89.99f &&
|
if (P.MaxSlopeAngle < 89.99f &&
|
||||||
FMath::Abs(N.Z) < FMath::Cos(FMath::DegreesToRadians(P.MaxSlopeAngle))) return false;
|
FMath::Abs(N.Z) < VoxelMath::DetCos(FMath::DegreesToRadians(P.MaxSlopeAngle))) return false;
|
||||||
if (P.MinSlopeAngle > 0.01f &&
|
if (P.MinSlopeAngle > 0.01f &&
|
||||||
FMath::Abs(N.Z) > FMath::Cos(FMath::DegreesToRadians(P.MinSlopeAngle))) return false;
|
FMath::Abs(N.Z) > VoxelMath::DetCos(FMath::DegreesToRadians(P.MinSlopeAngle))) return false;
|
||||||
|
|
||||||
const FVector LocalPos(LocalX, LocalY, ZC * VOXEL_SIZE);
|
const FVector LocalPos(LocalX, LocalY, ZC * VOXEL_SIZE);
|
||||||
if (P.bRequireWaterRelative && Ctx.bHasWater)
|
if (P.bRequireWaterRelative && Ctx.bHasWater)
|
||||||
@@ -1307,6 +1421,10 @@ void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos)
|
|||||||
Ctx.bHasWater = (Wv != -FLT_MAX);
|
Ctx.bHasWater = (Wv != -FLT_MAX);
|
||||||
Ctx.WaterLocalZ = Ctx.bHasWater ? Wv * VOXEL_SIZE : -FLT_MAX;
|
Ctx.WaterLocalZ = Ctx.bHasWater ? Wv * VOXEL_SIZE : -FLT_MAX;
|
||||||
}
|
}
|
||||||
|
// Landmarks use the same per-column biome gates as decorations. Keep the complete POD biome
|
||||||
|
// context in the worker/game-thread placement context; a null/default context would silently
|
||||||
|
// bypass the authored RequiredBiome/Conditions decisions for landmarks.
|
||||||
|
Ctx.BiomeCtx = StrateManager->GetBiomeContextForChunk(RepChunk);
|
||||||
|
|
||||||
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
|
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
|
||||||
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
|
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// VoxelDensityAblation.cpp
|
||||||
|
// Development-only TunnelNetwork ablation controls.
|
||||||
|
|
||||||
|
#include "VoxelDensityAblation.h"
|
||||||
|
|
||||||
|
#include "HAL/IConsoleManager.h"
|
||||||
|
#include "Misc/CommandLine.h"
|
||||||
|
#include "Misc/Parse.h"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr uint32 UnresolvedMask = 0xFFFFFFFFu;
|
||||||
|
std::atomic<uint32> GResolvedMask(UnresolvedMask);
|
||||||
|
|
||||||
|
#if !UE_BUILD_SHIPPING
|
||||||
|
int32 GCaveWarp = 0;
|
||||||
|
int32 GDetailOps = 0;
|
||||||
|
int32 GRoomSDF = 0;
|
||||||
|
int32 GTunnelSDF = 0;
|
||||||
|
int32 GTunnelCore = 0;
|
||||||
|
int32 GPassageCarving = 0;
|
||||||
|
int32 GPassageStructuralPosts = 0;
|
||||||
|
int32 GNativeFloor = 0;
|
||||||
|
int32 GDisturbances = 0;
|
||||||
|
int32 GOriginSpine = 0;
|
||||||
|
int32 GBoundarySeal = 0;
|
||||||
|
int32 GLandingPosts = 0;
|
||||||
|
int32 GXYEdgeSeal = 0;
|
||||||
|
int32 GPitChimneySDF = 0;
|
||||||
|
|
||||||
|
#define VF_ABLATION_CVAR(Name, Storage) \
|
||||||
|
FAutoConsoleVariableRef CVar##Storage( \
|
||||||
|
Name, Storage, \
|
||||||
|
TEXT("WORLD-CHANGING DEVELOPMENT-ONLY measurement override. Forces this " \
|
||||||
|
"TunnelNetwork density stage off; never use as a gameplay switch. " \
|
||||||
|
"All peers and regenerated worlds must use the same value. Resolved once " \
|
||||||
|
"per process. 0=on, 1=off."), ECVF_Default)
|
||||||
|
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateCaveWarp"), GCaveWarp);
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateDetailOps"), GDetailOps);
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateRoomSDF"), GRoomSDF);
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateTunnelSDF"), GTunnelSDF);
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateTunnelCore"), GTunnelCore);
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblatePassageCarving"), GPassageCarving);
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblatePassageStructuralPosts"), GPassageStructuralPosts);
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateNativeFloor"), GNativeFloor);
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateDisturbances"), GDisturbances);
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateOriginSpine"), GOriginSpine);
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateBoundarySeal"), GBoundarySeal);
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateLandingPosts"), GLandingPosts);
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateXYEdgeSeal"), GXYEdgeSeal);
|
||||||
|
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblatePitChimneySDF"), GPitChimneySDF);
|
||||||
|
|
||||||
|
#undef VF_ABLATION_CVAR
|
||||||
|
|
||||||
|
uint32 ResolveValue(const TCHAR* Name, int32& Storage)
|
||||||
|
{
|
||||||
|
int32 Value = Storage;
|
||||||
|
FParse::Value(FCommandLine::Get(), Name, Value);
|
||||||
|
Storage = Value != 0 ? 1 : 0;
|
||||||
|
return Storage != 0 ? 1u : 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 ResolveMask()
|
||||||
|
{
|
||||||
|
uint32 Mask = 0;
|
||||||
|
Mask |= ResolveValue(TEXT("voxel.TunnelAblateCaveWarp="), GCaveWarp)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::CaveWarp);
|
||||||
|
Mask |= ResolveValue(TEXT("voxel.TunnelAblateDetailOps="), GDetailOps)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::DetailOps);
|
||||||
|
Mask |= ResolveValue(TEXT("voxel.TunnelAblateRoomSDF="), GRoomSDF)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::RoomSDF);
|
||||||
|
Mask |= ResolveValue(TEXT("voxel.TunnelAblateTunnelSDF="), GTunnelSDF)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::TunnelSDF);
|
||||||
|
Mask |= ResolveValue(TEXT("voxel.TunnelAblateTunnelCore="), GTunnelCore)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::TunnelCore);
|
||||||
|
Mask |= ResolveValue(TEXT("voxel.TunnelAblatePassageCarving="), GPassageCarving)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::PassageCarving);
|
||||||
|
Mask |= ResolveValue(
|
||||||
|
TEXT("voxel.TunnelAblatePassageStructuralPosts="), GPassageStructuralPosts)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::PassageStructuralPosts);
|
||||||
|
Mask |= ResolveValue(TEXT("voxel.TunnelAblateNativeFloor="), GNativeFloor)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::NativeFloor);
|
||||||
|
Mask |= ResolveValue(TEXT("voxel.TunnelAblateDisturbances="), GDisturbances)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::Disturbances);
|
||||||
|
Mask |= ResolveValue(TEXT("voxel.TunnelAblateOriginSpine="), GOriginSpine)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::OriginSpine);
|
||||||
|
Mask |= ResolveValue(TEXT("voxel.TunnelAblateBoundarySeal="), GBoundarySeal)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::BoundarySeal);
|
||||||
|
Mask |= ResolveValue(TEXT("voxel.TunnelAblateLandingPosts="), GLandingPosts)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::LandingPosts);
|
||||||
|
Mask |= ResolveValue(TEXT("voxel.TunnelAblateXYEdgeSeal="), GXYEdgeSeal)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::XYEdgeSeal);
|
||||||
|
Mask |= ResolveValue(TEXT("voxel.TunnelAblatePitChimneySDF="), GPitChimneySDF)
|
||||||
|
* static_cast<uint32>(VoxelDensityAblation::EStage::PitChimneySDF);
|
||||||
|
return Mask;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace VoxelDensityAblation
|
||||||
|
{
|
||||||
|
uint32 GetResolvedMask()
|
||||||
|
{
|
||||||
|
uint32 Existing = GResolvedMask.load(std::memory_order_acquire);
|
||||||
|
if (Existing != UnresolvedMask)
|
||||||
|
{
|
||||||
|
return Existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if UE_BUILD_SHIPPING
|
||||||
|
constexpr uint32 Resolved = 0;
|
||||||
|
#else
|
||||||
|
const uint32 Resolved = ResolveMask();
|
||||||
|
#endif
|
||||||
|
uint32 Expected = UnresolvedMask;
|
||||||
|
if (!GResolvedMask.compare_exchange_strong(
|
||||||
|
Expected, Resolved, std::memory_order_release, std::memory_order_acquire))
|
||||||
|
{
|
||||||
|
return Expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
UE_LOG(LogTemp, Display,
|
||||||
|
TEXT("[VoxelForgeTunnelAblation] resolved mask=0x%08x "
|
||||||
|
"(world-changing development measurement overrides; 0=all on)"),
|
||||||
|
Resolved);
|
||||||
|
return Resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,982 @@
|
|||||||
|
#include "VoxelDensityProfile.h"
|
||||||
|
|
||||||
|
namespace VoxelDensityProfile
|
||||||
|
{
|
||||||
|
std::atomic<bool> GEnabled(false);
|
||||||
|
std::atomic<EMode> GMode(EMode::Disabled);
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
std::atomic<uint32> GSampleInterval(DefaultSampleInterval);
|
||||||
|
std::atomic<uint64> GTimerPairCycles(0);
|
||||||
|
|
||||||
|
// The old implementation performed a contended atomic fetch_add for every timer and
|
||||||
|
// counter. A TunnelNetwork sample can close twenty-plus ranges and add several
|
||||||
|
// candidate counters, so profiling changed the workload it was supposed to measure.
|
||||||
|
// Keep one accumulator per worker and only aggregate at Snapshot(). Commandlet resets
|
||||||
|
// happen between completed jobs, so the deliberately light-weight list is sufficient.
|
||||||
|
struct FThreadState
|
||||||
|
{
|
||||||
|
uint64 Calls[BucketCount]{};
|
||||||
|
uint64 Cycles[BucketCount]{};
|
||||||
|
uint64 Samples[BucketCount]{};
|
||||||
|
uint64 Counters[CounterCount]{};
|
||||||
|
uint32 SampleSequence[BucketCount]{};
|
||||||
|
uint32 BlockCallCount[BucketCount]{};
|
||||||
|
uint64 BlockStartCycles[BucketCount]{};
|
||||||
|
bool BlockActive[BucketCount]{};
|
||||||
|
FSnapshot::FClassifierStats Classifier;
|
||||||
|
|
||||||
|
uint64 TunnelCacheCapacityEntries = 0;
|
||||||
|
uint64 TunnelCacheValidEntries = 0;
|
||||||
|
uint64 TunnelCacheStaticBytes = 0;
|
||||||
|
uint64 TunnelCacheDynamicBytes = 0;
|
||||||
|
uint64 TunnelCacheEntryBytes = 0;
|
||||||
|
uint64 TunnelCacheLargestEntryBytes = 0;
|
||||||
|
uint64 TunnelCacheLargestWorkerValidEntries = 0;
|
||||||
|
FCacheMemoryBreakdown TunnelCacheBreakdown;
|
||||||
|
|
||||||
|
uint64 RoomGraphCacheCapacityEntries = 0;
|
||||||
|
uint64 RoomGraphCacheValidEntries = 0;
|
||||||
|
uint64 RoomGraphCacheStaticBytes = 0;
|
||||||
|
uint64 RoomGraphCacheDynamicBytes = 0;
|
||||||
|
uint64 RoomGraphCacheEntryBytes = 0;
|
||||||
|
uint64 RoomGraphCacheLargestEntryBytes = 0;
|
||||||
|
uint64 RoomGraphCacheLargestWorkerValidEntries = 0;
|
||||||
|
FCacheMemoryBreakdown RoomGraphCacheBreakdown;
|
||||||
|
|
||||||
|
std::atomic<FThreadState*> Next{nullptr};
|
||||||
|
};
|
||||||
|
|
||||||
|
std::atomic<FThreadState*> GThreadStates{nullptr};
|
||||||
|
thread_local FThreadState* GThreadState = nullptr;
|
||||||
|
thread_local uint32 GScopeDepth = 0;
|
||||||
|
thread_local bool GScopeSampled = false;
|
||||||
|
|
||||||
|
FThreadState& GetThreadState()
|
||||||
|
{
|
||||||
|
if (GThreadState == nullptr)
|
||||||
|
{
|
||||||
|
GThreadState = new FThreadState();
|
||||||
|
FThreadState* Head = GThreadStates.load(std::memory_order_relaxed);
|
||||||
|
do
|
||||||
|
{
|
||||||
|
GThreadState->Next.store(Head, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
while (!GThreadStates.compare_exchange_weak(
|
||||||
|
Head, GThreadState,
|
||||||
|
std::memory_order_release,
|
||||||
|
std::memory_order_relaxed));
|
||||||
|
}
|
||||||
|
return *GThreadState;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 ToIndex(EBucket Bucket)
|
||||||
|
{
|
||||||
|
const int32 Index = static_cast<int32>(Bucket);
|
||||||
|
return (Index >= 0 && Index < BucketCount)
|
||||||
|
? Index
|
||||||
|
: static_cast<int32>(EBucket::OtherOp);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsSampledScopeEnabled(EBucket Bucket)
|
||||||
|
{
|
||||||
|
// Sampled mode pays for the trustworthy parent and mesh containers only. Attribution
|
||||||
|
// additionally exposes the component scopes, but still samples them; Full remains the
|
||||||
|
// deliberately invasive mode for per-call scope timing.
|
||||||
|
switch (Bucket)
|
||||||
|
{
|
||||||
|
case EBucket::GetDensityAt:
|
||||||
|
case EBucket::MesherGenerateMesh:
|
||||||
|
case EBucket::MesherDensityGrid:
|
||||||
|
case EBucket::MesherOther:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsAttributionScope(EBucket Bucket)
|
||||||
|
{
|
||||||
|
switch (Bucket)
|
||||||
|
{
|
||||||
|
case EBucket::GetDensityAt:
|
||||||
|
case EBucket::ApplyDisturbances:
|
||||||
|
case EBucket::PassageModifier:
|
||||||
|
case EBucket::PassageLandingAir:
|
||||||
|
case EBucket::PassageLandingFloor:
|
||||||
|
case EBucket::PassageLandingRoomFloor:
|
||||||
|
case EBucket::PassageTunnelAir:
|
||||||
|
case EBucket::PassageStructuralPosts:
|
||||||
|
case EBucket::TunnelCoreSupport:
|
||||||
|
case EBucket::TunnelCoreSdf:
|
||||||
|
case EBucket::TunnelCorePosts:
|
||||||
|
case EBucket::StructuralTail:
|
||||||
|
case EBucket::RoomGraphSource:
|
||||||
|
case EBucket::SdfCarve:
|
||||||
|
case EBucket::FusedEvaluator:
|
||||||
|
case EBucket::InterpretedOpStack:
|
||||||
|
case EBucket::OperatorBlock:
|
||||||
|
case EBucket::RoomGraphBuild:
|
||||||
|
case EBucket::RoomGraphSdf:
|
||||||
|
case EBucket::TunnelCoreWorld:
|
||||||
|
case EBucket::FusedDetail:
|
||||||
|
case EBucket::FusedWorm:
|
||||||
|
case EBucket::FusedStructural:
|
||||||
|
case EBucket::ClassifierTotal:
|
||||||
|
case EBucket::ClassifierIntervalProof:
|
||||||
|
case EBucket::ClassifierExactCore:
|
||||||
|
case EBucket::ClassifierExactFinal:
|
||||||
|
case EBucket::ClassifierRoomTail:
|
||||||
|
case EBucket::RuntimeStreamBuilding:
|
||||||
|
case EBucket::DensityPrologue:
|
||||||
|
case EBucket::DensityCore:
|
||||||
|
case EBucket::DensityDisturbances:
|
||||||
|
case EBucket::DensityStructuralPosts:
|
||||||
|
case EBucket::DensityBoundarySeal:
|
||||||
|
case EBucket::DensityDiffLayer:
|
||||||
|
case EBucket::DensityTail:
|
||||||
|
case EBucket::MesherGenerateMesh:
|
||||||
|
case EBucket::MesherDensityGrid:
|
||||||
|
case EBucket::MesherCellClassification:
|
||||||
|
case EBucket::MesherGradientNormals:
|
||||||
|
case EBucket::MesherVertexInterpolation:
|
||||||
|
case EBucket::MesherStreamBuilding:
|
||||||
|
case EBucket::MesherOther:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsAttributionAlwaysTimed(EBucket Bucket)
|
||||||
|
{
|
||||||
|
switch (Bucket)
|
||||||
|
{
|
||||||
|
case EBucket::MesherGenerateMesh:
|
||||||
|
case EBucket::MesherDensityGrid:
|
||||||
|
case EBucket::MesherOther:
|
||||||
|
case EBucket::RoomGraphBuild:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64 ScaleSampledValue(uint64 Value, uint64 Calls, uint64 Samples)
|
||||||
|
{
|
||||||
|
if (Samples == 0 || Calls <= Samples)
|
||||||
|
{
|
||||||
|
return Value;
|
||||||
|
}
|
||||||
|
const double Scaled = static_cast<double>(Value)
|
||||||
|
* static_cast<double>(Calls) / static_cast<double>(Samples);
|
||||||
|
return Scaled > static_cast<double>(MAX_uint64)
|
||||||
|
? MAX_uint64
|
||||||
|
: static_cast<uint64>(Scaled + 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddClassifierStatsScaled(
|
||||||
|
FSnapshot::FClassifierStats& Destination,
|
||||||
|
const FSnapshot::FClassifierStats& Source,
|
||||||
|
uint64 Calls,
|
||||||
|
uint64 Samples)
|
||||||
|
{
|
||||||
|
Destination.RefineNodes += ScaleSampledValue(Source.RefineNodes, Calls, Samples);
|
||||||
|
Destination.StackBoxCalls += ScaleSampledValue(Source.StackBoxCalls, Calls, Samples);
|
||||||
|
Destination.WholeMixedNodes += ScaleSampledValue(Source.WholeMixedNodes, Calls, Samples);
|
||||||
|
Destination.WholeSolidNodes += ScaleSampledValue(Source.WholeSolidNodes, Calls, Samples);
|
||||||
|
Destination.WholeAirNodes += ScaleSampledValue(Source.WholeAirNodes, Calls, Samples);
|
||||||
|
Destination.NeedsFinalFieldNodes += ScaleSampledValue(Source.NeedsFinalFieldNodes, Calls, Samples);
|
||||||
|
Destination.SplitNodes += ScaleSampledValue(Source.SplitNodes, Calls, Samples);
|
||||||
|
Destination.MaxRefinementDepth = FMath::Max(
|
||||||
|
Destination.MaxRefinementDepth, Source.MaxRefinementDepth);
|
||||||
|
Destination.ExactCoreSamples += ScaleSampledValue(Source.ExactCoreSamples, Calls, Samples);
|
||||||
|
Destination.ExactFinalSamples += ScaleSampledValue(Source.ExactFinalSamples, Calls, Samples);
|
||||||
|
Destination.ExactCoreCacheHits += ScaleSampledValue(Source.ExactCoreCacheHits, Calls, Samples);
|
||||||
|
Destination.ExactFinalCacheHits += ScaleSampledValue(Source.ExactFinalCacheHits, Calls, Samples);
|
||||||
|
Destination.ExactCoreLeaves += ScaleSampledValue(Source.ExactCoreLeaves, Calls, Samples);
|
||||||
|
Destination.ExactFinalLeaves += ScaleSampledValue(Source.ExactFinalLeaves, Calls, Samples);
|
||||||
|
Destination.StackBoxCycles += ScaleSampledValue(Source.StackBoxCycles, Calls, Samples);
|
||||||
|
Destination.ExactCoreCycles += ScaleSampledValue(Source.ExactCoreCycles, Calls, Samples);
|
||||||
|
Destination.ExactFinalCycles += ScaleSampledValue(Source.ExactFinalCycles, Calls, Samples);
|
||||||
|
Destination.RoomTailQueries += ScaleSampledValue(Source.RoomTailQueries, Calls, Samples);
|
||||||
|
Destination.RoomTailEvaluated += ScaleSampledValue(Source.RoomTailEvaluated, Calls, Samples);
|
||||||
|
Destination.RoomTailCycles += ScaleSampledValue(Source.RoomTailCycles, Calls, Samples);
|
||||||
|
Destination.RoomPropagateCycles += ScaleSampledValue(Source.RoomPropagateCycles, Calls, Samples);
|
||||||
|
Destination.RoomExactPrimitiveCycles += ScaleSampledValue(Source.RoomExactPrimitiveCycles, Calls, Samples);
|
||||||
|
Destination.RoomCacheWindowCycles += ScaleSampledValue(Source.RoomCacheWindowCycles, Calls, Samples);
|
||||||
|
// These are topology descriptors, not per-call work counts. Keep the largest observed
|
||||||
|
// descriptor instead of multiplying one sampled graph description by the classifier
|
||||||
|
// call expansion factor.
|
||||||
|
Destination.RoomNumRooms = FMath::Max(Destination.RoomNumRooms, Source.RoomNumRooms);
|
||||||
|
Destination.RoomNumTunnels = FMath::Max(Destination.RoomNumTunnels, Source.RoomNumTunnels);
|
||||||
|
Destination.RoomNumRoomFloorJoins = FMath::Max(
|
||||||
|
Destination.RoomNumRoomFloorJoins, Source.RoomNumRoomFloorJoins);
|
||||||
|
Destination.RoomNumPits = FMath::Max(Destination.RoomNumPits, Source.RoomNumPits);
|
||||||
|
Destination.RoomNumChimneys = FMath::Max(Destination.RoomNumChimneys, Source.RoomNumChimneys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetEnabled(bool bEnabled)
|
||||||
|
{
|
||||||
|
SetMode(bEnabled ? EMode::Sampled : EMode::Disabled, DefaultSampleInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetMode(EMode Mode, uint32 SampleInterval)
|
||||||
|
{
|
||||||
|
if (SampleInterval == 0)
|
||||||
|
{
|
||||||
|
SampleInterval = DefaultSampleInterval;
|
||||||
|
}
|
||||||
|
GSampleInterval.store(SampleInterval, std::memory_order_relaxed);
|
||||||
|
GMode.store(Mode, std::memory_order_relaxed);
|
||||||
|
GEnabled.store(Mode != EMode::Disabled, std::memory_order_relaxed);
|
||||||
|
if (Mode != EMode::Disabled && GTimerPairCycles.load(std::memory_order_relaxed) == 0)
|
||||||
|
{
|
||||||
|
CalibrateTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsEnabled()
|
||||||
|
{
|
||||||
|
return GEnabled.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsCycleTimingEnabled()
|
||||||
|
{
|
||||||
|
return GMode.load(std::memory_order_relaxed) != EMode::Disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
EMode GetMode()
|
||||||
|
{
|
||||||
|
return GMode.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 GetSampleInterval()
|
||||||
|
{
|
||||||
|
return GSampleInterval.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64 GetTimerPairCycles()
|
||||||
|
{
|
||||||
|
return GTimerPairCycles.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CalibrateTimer()
|
||||||
|
{
|
||||||
|
constexpr uint32 Iterations = 100000;
|
||||||
|
volatile uint64 Sink = 0;
|
||||||
|
const uint64 Start = FPlatformTime::Cycles64();
|
||||||
|
for (uint32 Index = 0; Index < Iterations; ++Index)
|
||||||
|
{
|
||||||
|
const uint64 A = FPlatformTime::Cycles64();
|
||||||
|
const uint64 B = FPlatformTime::Cycles64();
|
||||||
|
Sink ^= (B - A);
|
||||||
|
}
|
||||||
|
const uint64 End = FPlatformTime::Cycles64();
|
||||||
|
const uint64 Total = End - Start;
|
||||||
|
const uint64 PairCycles = Total > 0
|
||||||
|
? FMath::Max<uint64>(1, Total / Iterations)
|
||||||
|
: 1;
|
||||||
|
GTimerPairCycles.store(PairCycles, std::memory_order_relaxed);
|
||||||
|
(void)Sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Reset()
|
||||||
|
{
|
||||||
|
for (FThreadState* State = GThreadStates.load(std::memory_order_acquire);
|
||||||
|
State != nullptr;
|
||||||
|
State = State->Next.load(std::memory_order_acquire))
|
||||||
|
{
|
||||||
|
for (int32 Index = 0; Index < BucketCount; ++Index)
|
||||||
|
{
|
||||||
|
State->Calls[Index] = 0;
|
||||||
|
State->Cycles[Index] = 0;
|
||||||
|
State->Samples[Index] = 0;
|
||||||
|
}
|
||||||
|
for (int32 Index = 0; Index < CounterCount; ++Index)
|
||||||
|
{
|
||||||
|
State->Counters[Index] = 0;
|
||||||
|
}
|
||||||
|
for (int32 Index = 0; Index < BucketCount; ++Index)
|
||||||
|
{
|
||||||
|
State->SampleSequence[Index] = 0;
|
||||||
|
State->BlockCallCount[Index] = 0;
|
||||||
|
State->BlockStartCycles[Index] = 0;
|
||||||
|
State->BlockActive[Index] = false;
|
||||||
|
}
|
||||||
|
State->Classifier = FSnapshot::FClassifierStats();
|
||||||
|
State->TunnelCacheCapacityEntries = 0;
|
||||||
|
State->TunnelCacheValidEntries = 0;
|
||||||
|
State->TunnelCacheStaticBytes = 0;
|
||||||
|
State->TunnelCacheDynamicBytes = 0;
|
||||||
|
State->TunnelCacheEntryBytes = 0;
|
||||||
|
State->TunnelCacheLargestEntryBytes = 0;
|
||||||
|
State->TunnelCacheBreakdown = FCacheMemoryBreakdown();
|
||||||
|
State->RoomGraphCacheCapacityEntries = 0;
|
||||||
|
State->RoomGraphCacheValidEntries = 0;
|
||||||
|
State->RoomGraphCacheStaticBytes = 0;
|
||||||
|
State->RoomGraphCacheDynamicBytes = 0;
|
||||||
|
State->RoomGraphCacheEntryBytes = 0;
|
||||||
|
State->RoomGraphCacheLargestEntryBytes = 0;
|
||||||
|
State->RoomGraphCacheBreakdown = FCacheMemoryBreakdown();
|
||||||
|
}
|
||||||
|
GScopeDepth = 0;
|
||||||
|
GScopeSampled = false;
|
||||||
|
GSampledScopeActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FSnapshot Snapshot()
|
||||||
|
{
|
||||||
|
FSnapshot Result;
|
||||||
|
for (FThreadState* State = GThreadStates.load(std::memory_order_acquire);
|
||||||
|
State != nullptr;
|
||||||
|
State = State->Next.load(std::memory_order_acquire))
|
||||||
|
{
|
||||||
|
for (int32 Index = 0; Index < BucketCount; ++Index)
|
||||||
|
{
|
||||||
|
Result.Calls[Index] += State->Calls[Index];
|
||||||
|
Result.Samples[Index] += State->Samples[Index];
|
||||||
|
if (State->Samples[Index] == 0)
|
||||||
|
{
|
||||||
|
Result.Cycles[Index] += State->Cycles[Index];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Store raw sampled cycles in the worker-local state. Estimate the bucket
|
||||||
|
// from its observed call/sample ratio at aggregation time. This matters for
|
||||||
|
// small buckets: multiplying every sample by 64 would overstate a bucket with
|
||||||
|
// only eight calls where all eight were sampled.
|
||||||
|
const double Scale = static_cast<double>(State->Calls[Index])
|
||||||
|
/ static_cast<double>(State->Samples[Index]);
|
||||||
|
const double EstimatedCycles = static_cast<double>(State->Cycles[Index]) * Scale;
|
||||||
|
Result.Cycles[Index] += EstimatedCycles > static_cast<double>(MAX_uint64)
|
||||||
|
? MAX_uint64
|
||||||
|
: static_cast<uint64>(EstimatedCycles + 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (State->Calls[Index] > 0)
|
||||||
|
{
|
||||||
|
++Result.ActiveWorkers[Index];
|
||||||
|
const double EstimatedWorkerCycles = State->Samples[Index] == 0
|
||||||
|
? static_cast<double>(State->Cycles[Index])
|
||||||
|
: static_cast<double>(State->Cycles[Index])
|
||||||
|
* static_cast<double>(State->Calls[Index])
|
||||||
|
/ static_cast<double>(State->Samples[Index]);
|
||||||
|
Result.WallCycles[Index] = FMath::Max(
|
||||||
|
Result.WallCycles[Index],
|
||||||
|
EstimatedWorkerCycles > static_cast<double>(MAX_uint64)
|
||||||
|
? MAX_uint64
|
||||||
|
: static_cast<uint64>(EstimatedWorkerCycles + 0.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int32 Index = 0; Index < CounterCount; ++Index)
|
||||||
|
{
|
||||||
|
Result.Counters[Index] += State->Counters[Index];
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.Classifier.Calls += State->Classifier.Calls;
|
||||||
|
Result.Classifier.SampledCalls += State->Classifier.SampledCalls;
|
||||||
|
AddClassifierStatsScaled(
|
||||||
|
Result.Classifier,
|
||||||
|
State->Classifier,
|
||||||
|
State->Classifier.Calls,
|
||||||
|
State->Classifier.SampledCalls);
|
||||||
|
|
||||||
|
const uint64 TunnelWorkerBytes = State->TunnelCacheStaticBytes
|
||||||
|
+ State->TunnelCacheDynamicBytes;
|
||||||
|
if (State->TunnelCacheCapacityEntries > 0)
|
||||||
|
{
|
||||||
|
++Result.TunnelCacheWorkers;
|
||||||
|
Result.TunnelCacheCapacityEntries += State->TunnelCacheCapacityEntries;
|
||||||
|
Result.TunnelCacheValidEntries += State->TunnelCacheValidEntries;
|
||||||
|
Result.TunnelCacheStaticBytes += State->TunnelCacheStaticBytes;
|
||||||
|
Result.TunnelCacheDynamicBytes += State->TunnelCacheDynamicBytes;
|
||||||
|
Result.TunnelCacheEntryBytes += State->TunnelCacheEntryBytes;
|
||||||
|
Result.TunnelCacheLargestEntryBytes = FMath::Max(
|
||||||
|
Result.TunnelCacheLargestEntryBytes,
|
||||||
|
State->TunnelCacheLargestEntryBytes);
|
||||||
|
Result.TunnelCacheLargestWorkerValidEntries = FMath::Max(
|
||||||
|
Result.TunnelCacheLargestWorkerValidEntries,
|
||||||
|
State->TunnelCacheValidEntries);
|
||||||
|
Result.TunnelCacheLargestWorkerBytes = FMath::Max(
|
||||||
|
Result.TunnelCacheLargestWorkerBytes, TunnelWorkerBytes);
|
||||||
|
Result.TunnelCacheBreakdown += State->TunnelCacheBreakdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint64 RoomGraphWorkerBytes = State->RoomGraphCacheStaticBytes
|
||||||
|
+ State->RoomGraphCacheDynamicBytes;
|
||||||
|
if (State->RoomGraphCacheCapacityEntries > 0)
|
||||||
|
{
|
||||||
|
++Result.RoomGraphCacheWorkers;
|
||||||
|
Result.RoomGraphCacheCapacityEntries += State->RoomGraphCacheCapacityEntries;
|
||||||
|
Result.RoomGraphCacheValidEntries += State->RoomGraphCacheValidEntries;
|
||||||
|
Result.RoomGraphCacheStaticBytes += State->RoomGraphCacheStaticBytes;
|
||||||
|
Result.RoomGraphCacheDynamicBytes += State->RoomGraphCacheDynamicBytes;
|
||||||
|
Result.RoomGraphCacheEntryBytes += State->RoomGraphCacheEntryBytes;
|
||||||
|
Result.RoomGraphCacheLargestEntryBytes = FMath::Max(
|
||||||
|
Result.RoomGraphCacheLargestEntryBytes,
|
||||||
|
State->RoomGraphCacheLargestEntryBytes);
|
||||||
|
Result.RoomGraphCacheLargestWorkerValidEntries = FMath::Max(
|
||||||
|
Result.RoomGraphCacheLargestWorkerValidEntries,
|
||||||
|
State->RoomGraphCacheValidEntries);
|
||||||
|
Result.RoomGraphCacheLargestWorkerBytes = FMath::Max(
|
||||||
|
Result.RoomGraphCacheLargestWorkerBytes, RoomGraphWorkerBytes);
|
||||||
|
Result.RoomGraphCacheBreakdown += State->RoomGraphCacheBreakdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Result.TimerPairCycles = GetTimerPairCycles();
|
||||||
|
Result.SampleInterval = GetSampleInterval();
|
||||||
|
Result.Mode = GetMode();
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
FSnapshot SnapshotCurrentThread()
|
||||||
|
{
|
||||||
|
FSnapshot Result;
|
||||||
|
FThreadState* State = GThreadState;
|
||||||
|
if (State == nullptr)
|
||||||
|
{
|
||||||
|
Result.TimerPairCycles = GetTimerPairCycles();
|
||||||
|
Result.SampleInterval = GetSampleInterval();
|
||||||
|
Result.Mode = GetMode();
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 Index = 0; Index < BucketCount; ++Index)
|
||||||
|
{
|
||||||
|
Result.Calls[Index] = State->Calls[Index];
|
||||||
|
Result.Samples[Index] = State->Samples[Index];
|
||||||
|
if (State->Samples[Index] == 0)
|
||||||
|
{
|
||||||
|
Result.Cycles[Index] = State->Cycles[Index];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const double Scale = static_cast<double>(State->Calls[Index])
|
||||||
|
/ static_cast<double>(State->Samples[Index]);
|
||||||
|
const double EstimatedCycles = static_cast<double>(State->Cycles[Index]) * Scale;
|
||||||
|
Result.Cycles[Index] = EstimatedCycles > static_cast<double>(MAX_uint64)
|
||||||
|
? MAX_uint64
|
||||||
|
: static_cast<uint64>(EstimatedCycles + 0.5);
|
||||||
|
}
|
||||||
|
Result.WallCycles[Index] = Result.Cycles[Index];
|
||||||
|
Result.ActiveWorkers[Index] = State->Calls[Index] > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
for (int32 Index = 0; Index < CounterCount; ++Index)
|
||||||
|
{
|
||||||
|
Result.Counters[Index] = State->Counters[Index];
|
||||||
|
}
|
||||||
|
Result.Classifier = State->Classifier;
|
||||||
|
if (State->Classifier.SampledCalls > 0)
|
||||||
|
{
|
||||||
|
FSnapshot::FClassifierStats Raw = State->Classifier;
|
||||||
|
Result.Classifier = FSnapshot::FClassifierStats();
|
||||||
|
Result.Classifier.Calls = State->Classifier.Calls;
|
||||||
|
Result.Classifier.SampledCalls = State->Classifier.SampledCalls;
|
||||||
|
AddClassifierStatsScaled(
|
||||||
|
Result.Classifier,
|
||||||
|
Raw,
|
||||||
|
State->Classifier.Calls,
|
||||||
|
State->Classifier.SampledCalls);
|
||||||
|
}
|
||||||
|
Result.TimerPairCycles = GetTimerPairCycles();
|
||||||
|
Result.SampleInterval = GetSampleInterval();
|
||||||
|
Result.Mode = GetMode();
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddCounter(ECounter Counter, uint64 Amount)
|
||||||
|
{
|
||||||
|
if (!IsEnabledFast())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const int32 Index = static_cast<int32>(Counter);
|
||||||
|
if (Index >= 0 && Index < CounterCount)
|
||||||
|
{
|
||||||
|
GetThreadState().Counters[Index] += Amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddMeasurement(EBucket Bucket, uint64 Cycles, uint64 Calls)
|
||||||
|
{
|
||||||
|
const int32 Index = ToIndex(Bucket);
|
||||||
|
FThreadState& State = GetThreadState();
|
||||||
|
State.Calls[Index] += Calls;
|
||||||
|
State.Cycles[Index] += Cycles;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RecordCall(EBucket Bucket)
|
||||||
|
{
|
||||||
|
const int32 Index = ToIndex(Bucket);
|
||||||
|
GetThreadState().Calls[Index] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RecordClassifierCall(bool bSampled)
|
||||||
|
{
|
||||||
|
FThreadState& State = GetThreadState();
|
||||||
|
++State.Classifier.Calls;
|
||||||
|
if (bSampled)
|
||||||
|
{
|
||||||
|
++State.Classifier.SampledCalls;
|
||||||
|
}
|
||||||
|
++State.Calls[ToIndex(EBucket::ClassifierTotal)];
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddClassifierStats(const FSnapshot::FClassifierStats& Stats)
|
||||||
|
{
|
||||||
|
FThreadState& State = GetThreadState();
|
||||||
|
State.Classifier.RefineNodes += Stats.RefineNodes;
|
||||||
|
State.Classifier.StackBoxCalls += Stats.StackBoxCalls;
|
||||||
|
State.Classifier.WholeMixedNodes += Stats.WholeMixedNodes;
|
||||||
|
State.Classifier.WholeSolidNodes += Stats.WholeSolidNodes;
|
||||||
|
State.Classifier.WholeAirNodes += Stats.WholeAirNodes;
|
||||||
|
State.Classifier.NeedsFinalFieldNodes += Stats.NeedsFinalFieldNodes;
|
||||||
|
State.Classifier.SplitNodes += Stats.SplitNodes;
|
||||||
|
State.Classifier.MaxRefinementDepth = FMath::Max(
|
||||||
|
State.Classifier.MaxRefinementDepth, Stats.MaxRefinementDepth);
|
||||||
|
State.Classifier.ExactCoreSamples += Stats.ExactCoreSamples;
|
||||||
|
State.Classifier.ExactFinalSamples += Stats.ExactFinalSamples;
|
||||||
|
State.Classifier.ExactCoreCacheHits += Stats.ExactCoreCacheHits;
|
||||||
|
State.Classifier.ExactFinalCacheHits += Stats.ExactFinalCacheHits;
|
||||||
|
State.Classifier.ExactCoreLeaves += Stats.ExactCoreLeaves;
|
||||||
|
State.Classifier.ExactFinalLeaves += Stats.ExactFinalLeaves;
|
||||||
|
State.Classifier.StackBoxCycles += Stats.StackBoxCycles;
|
||||||
|
State.Classifier.ExactCoreCycles += Stats.ExactCoreCycles;
|
||||||
|
State.Classifier.ExactFinalCycles += Stats.ExactFinalCycles;
|
||||||
|
State.Classifier.RoomTailQueries += Stats.RoomTailQueries;
|
||||||
|
State.Classifier.RoomTailEvaluated += Stats.RoomTailEvaluated;
|
||||||
|
State.Classifier.RoomTailCycles += Stats.RoomTailCycles;
|
||||||
|
State.Classifier.RoomPropagateCycles += Stats.RoomPropagateCycles;
|
||||||
|
State.Classifier.RoomExactPrimitiveCycles += Stats.RoomExactPrimitiveCycles;
|
||||||
|
State.Classifier.RoomCacheWindowCycles += Stats.RoomCacheWindowCycles;
|
||||||
|
State.Classifier.RoomNumRooms = FMath::Max(
|
||||||
|
State.Classifier.RoomNumRooms, Stats.RoomNumRooms);
|
||||||
|
State.Classifier.RoomNumTunnels = FMath::Max(
|
||||||
|
State.Classifier.RoomNumTunnels, Stats.RoomNumTunnels);
|
||||||
|
State.Classifier.RoomNumRoomFloorJoins = FMath::Max(
|
||||||
|
State.Classifier.RoomNumRoomFloorJoins, Stats.RoomNumRoomFloorJoins);
|
||||||
|
State.Classifier.RoomNumPits = FMath::Max(
|
||||||
|
State.Classifier.RoomNumPits, Stats.RoomNumPits);
|
||||||
|
State.Classifier.RoomNumChimneys = FMath::Max(
|
||||||
|
State.Classifier.RoomNumChimneys, Stats.RoomNumChimneys);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ShouldSample(EBucket Bucket)
|
||||||
|
{
|
||||||
|
if (!IsCycleTimingEnabled())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (GetMode() == EMode::Full)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
FThreadState& State = GetThreadState();
|
||||||
|
const int32 Index = ToIndex(Bucket);
|
||||||
|
const uint32 Sequence = State.SampleSequence[Index]++;
|
||||||
|
const uint32 Interval = GetSampleInterval();
|
||||||
|
if ((Interval & (Interval - 1u)) == 0u)
|
||||||
|
{
|
||||||
|
return (Sequence & (Interval - 1u)) == 0u;
|
||||||
|
}
|
||||||
|
return (Sequence % Interval) == 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddSampledMeasurement(EBucket Bucket, uint64 RawCycles)
|
||||||
|
{
|
||||||
|
const int32 Index = ToIndex(Bucket);
|
||||||
|
FThreadState& State = GetThreadState();
|
||||||
|
const uint64 TimerCost = GetTimerPairCycles();
|
||||||
|
const uint64 AdjustedCycles = RawCycles > TimerCost
|
||||||
|
? RawCycles - TimerCost
|
||||||
|
: 0;
|
||||||
|
State.Cycles[Index] += AdjustedCycles;
|
||||||
|
State.Samples[Index] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetWorkerTunnelCacheFootprint(
|
||||||
|
uint64 CapacityEntries, uint64 ValidEntries,
|
||||||
|
uint64 StaticBytes, uint64 DynamicBytes,
|
||||||
|
uint64 EntryBytes, uint64 LargestEntryBytes,
|
||||||
|
uint64 LargestWorkerValidEntries,
|
||||||
|
const FCacheMemoryBreakdown& Breakdown)
|
||||||
|
{
|
||||||
|
if (!IsEnabled()) { return; }
|
||||||
|
FThreadState& State = GetThreadState();
|
||||||
|
State.TunnelCacheCapacityEntries = CapacityEntries;
|
||||||
|
State.TunnelCacheValidEntries = ValidEntries;
|
||||||
|
State.TunnelCacheStaticBytes = StaticBytes;
|
||||||
|
State.TunnelCacheDynamicBytes = DynamicBytes;
|
||||||
|
State.TunnelCacheEntryBytes = EntryBytes;
|
||||||
|
State.TunnelCacheLargestEntryBytes = LargestEntryBytes;
|
||||||
|
State.TunnelCacheLargestWorkerValidEntries = LargestWorkerValidEntries;
|
||||||
|
State.TunnelCacheBreakdown = Breakdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetWorkerRoomGraphCacheFootprint(
|
||||||
|
uint64 CapacityEntries, uint64 ValidEntries,
|
||||||
|
uint64 StaticBytes, uint64 DynamicBytes,
|
||||||
|
uint64 EntryBytes, uint64 LargestEntryBytes,
|
||||||
|
uint64 LargestWorkerValidEntries,
|
||||||
|
const FCacheMemoryBreakdown& Breakdown)
|
||||||
|
{
|
||||||
|
if (!IsEnabled()) { return; }
|
||||||
|
FThreadState& State = GetThreadState();
|
||||||
|
State.RoomGraphCacheCapacityEntries = CapacityEntries;
|
||||||
|
State.RoomGraphCacheValidEntries = ValidEntries;
|
||||||
|
State.RoomGraphCacheStaticBytes = StaticBytes;
|
||||||
|
State.RoomGraphCacheDynamicBytes = DynamicBytes;
|
||||||
|
State.RoomGraphCacheEntryBytes = EntryBytes;
|
||||||
|
State.RoomGraphCacheLargestEntryBytes = LargestEntryBytes;
|
||||||
|
State.RoomGraphCacheLargestWorkerValidEntries = LargestWorkerValidEntries;
|
||||||
|
State.RoomGraphCacheBreakdown = Breakdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TCHAR* CounterName(ECounter Counter)
|
||||||
|
{
|
||||||
|
switch (Counter)
|
||||||
|
{
|
||||||
|
case ECounter::TunnelCacheLookup: return TEXT("TunnelCacheLookup");
|
||||||
|
case ECounter::TunnelCacheHit: return TEXT("TunnelCacheHit");
|
||||||
|
case ECounter::TunnelCacheMiss: return TEXT("TunnelCacheMiss");
|
||||||
|
case ECounter::SdfCacheBuild: return TEXT("SdfCacheBuild");
|
||||||
|
case ECounter::RoomGraphBuildGeneratorTile: return TEXT("RoomGraphBuildGeneratorTile");
|
||||||
|
case ECounter::RoomGraphBuildGeneratorTunnelCore: return TEXT("RoomGraphBuildGeneratorTunnelCore");
|
||||||
|
case ECounter::RoomGraphBuildOpShared: return TEXT("RoomGraphBuildOpShared");
|
||||||
|
case ECounter::RoomGraphBuildOpLocal: return TEXT("RoomGraphBuildOpLocal");
|
||||||
|
case ECounter::RoomGraphBuildClassifierShared: return TEXT("RoomGraphBuildClassifierShared");
|
||||||
|
case ECounter::RoomGraphBuildClassifierLocal: return TEXT("RoomGraphBuildClassifierLocal");
|
||||||
|
case ECounter::RoomGraphBuildUnknown: return TEXT("RoomGraphBuildUnknown");
|
||||||
|
case ECounter::CaveRoomCandidates:return TEXT("CaveRoomCandidates");
|
||||||
|
case ECounter::CaveRoomEvaluated: return TEXT("CaveRoomEvaluated");
|
||||||
|
case ECounter::CaveTunnelCandidates:return TEXT("CaveTunnelCandidates");
|
||||||
|
case ECounter::CaveTunnelEvaluated:return TEXT("CaveTunnelEvaluated");
|
||||||
|
case ECounter::TunnelCoreCandidates:return TEXT("TunnelCoreCandidates");
|
||||||
|
case ECounter::TunnelCoreEvaluated:return TEXT("TunnelCoreEvaluated");
|
||||||
|
case ECounter::TunnelSupportColumnBuilds:return TEXT("TunnelSupportColumnBuilds");
|
||||||
|
case ECounter::TunnelSupportColumnCandidates:return TEXT("TunnelSupportColumnCandidates");
|
||||||
|
case ECounter::TunnelSupportFloorQueries:return TEXT("TunnelSupportFloorQueries");
|
||||||
|
case ECounter::TunnelSupportFloorChecks:return TEXT("TunnelSupportFloorChecks");
|
||||||
|
case ECounter::TunnelSupportFloorBackstopFires:return TEXT("TunnelSupportFloorBackstopFires");
|
||||||
|
case ECounter::TunnelRoomFloorBackstopFires:return TEXT("TunnelRoomFloorBackstopFires");
|
||||||
|
case ECounter::TunnelAuthoredFloorSamples:return TEXT("TunnelAuthoredFloorSamples");
|
||||||
|
case ECounter::TunnelAuthoredFloorProtectionClamps:return TEXT("TunnelAuthoredFloorProtectionClamps");
|
||||||
|
case ECounter::TunnelAuthoredFloorDisturbanceSkips:return TEXT("TunnelAuthoredFloorDisturbanceSkips");
|
||||||
|
case ECounter::TunnelFloorProfileFallbacks:return TEXT("TunnelFloorProfileFallbacks");
|
||||||
|
case ECounter::TunnelFloorProfileBuilds:return TEXT("TunnelFloorProfileBuilds");
|
||||||
|
case ECounter::TunnelFloorProfileSegments:return TEXT("TunnelFloorProfileSegments");
|
||||||
|
case ECounter::TunnelFloorProfileLedges:return TEXT("TunnelFloorProfileLedges");
|
||||||
|
case ECounter::TunnelFloorProfileLedgesBelow2Voxels:return TEXT("TunnelFloorProfileLedgesBelow2Voxels");
|
||||||
|
case ECounter::TunnelFloorProfileLedges2To4Voxels:return TEXT("TunnelFloorProfileLedges2To4Voxels");
|
||||||
|
case ECounter::TunnelFloorProfileLedges4To8Voxels:return TEXT("TunnelFloorProfileLedges4To8Voxels");
|
||||||
|
case ECounter::TunnelFloorProfileLedges8PlusVoxels:return TEXT("TunnelFloorProfileLedges8PlusVoxels");
|
||||||
|
case ECounter::TunnelFloorSteepEdges:return TEXT("TunnelFloorSteepEdges");
|
||||||
|
case ECounter::TunnelFloorWindingEdges:return TEXT("TunnelFloorWindingEdges");
|
||||||
|
case ECounter::TunnelFloorDropEdges:return TEXT("TunnelFloorDropEdges");
|
||||||
|
case ECounter::TunnelFloorMultiStepEdges:return TEXT("TunnelFloorMultiStepEdges");
|
||||||
|
case ECounter::PassageSupportFloorBackstopFires:return TEXT("PassageSupportFloorBackstopFires");
|
||||||
|
case ECounter::PassageNativeFloorCompositions:return TEXT("PassageNativeFloorCompositions");
|
||||||
|
case ECounter::PassageCandidates: return TEXT("PassageCandidates");
|
||||||
|
case ECounter::PassageEvaluated: return TEXT("PassageEvaluated");
|
||||||
|
case ECounter::MesherBlockTests: return TEXT("MesherBlockTests");
|
||||||
|
case ECounter::MesherBlockAllSolid:return TEXT("MesherBlockAllSolid");
|
||||||
|
case ECounter::MesherBlockAllAir: return TEXT("MesherBlockAllAir");
|
||||||
|
case ECounter::TileClassifyCalls: return TEXT("TileClassifyCalls");
|
||||||
|
case ECounter::TileClassifyAllSolid: return TEXT("TileClassifyAllSolid");
|
||||||
|
case ECounter::TileClassifyAllAir: return TEXT("TileClassifyAllAir");
|
||||||
|
case ECounter::TileClassifyMixed: return TEXT("TileClassifyMixed");
|
||||||
|
case ECounter::FusedTunnelSamples: return TEXT("FusedTunnelSamples");
|
||||||
|
case ECounter::FusedTunnelDetailSamples: return TEXT("FusedTunnelDetailSamples");
|
||||||
|
case ECounter::FusedTunnelDetailSkipped: return TEXT("FusedTunnelDetailSkipped");
|
||||||
|
case ECounter::WormEligibleSamples: return TEXT("WormEligibleSamples");
|
||||||
|
case ECounter::WormBlockProofs: return TEXT("WormBlockProofs");
|
||||||
|
case ECounter::WormBlockSkippedSamples: return TEXT("WormBlockSkippedSamples");
|
||||||
|
case ECounter::OpBlockBuilds: return TEXT("OpBlockBuilds");
|
||||||
|
case ECounter::OpBlockSamples: return TEXT("OpBlockSamples");
|
||||||
|
case ECounter::OpBlockOperators: return TEXT("OpBlockOperators");
|
||||||
|
case ECounter::OpBlockActiveOperators: return TEXT("OpBlockActiveOperators");
|
||||||
|
case ECounter::OpBlockPrunedOperators: return TEXT("OpBlockPrunedOperators");
|
||||||
|
case ECounter::MesherDensityGridBytes: return TEXT("MesherDensityGridBytes");
|
||||||
|
case ECounter::MesherSharedGridReadBytes: return TEXT("MesherSharedGridReadBytes");
|
||||||
|
case ECounter::MesherOutputArrayBytes: return TEXT("MesherOutputArrayBytes");
|
||||||
|
case ECounter::MesherOutputAllocatedBytes: return TEXT("MesherOutputAllocatedBytes");
|
||||||
|
case ECounter::OpBlockScratchBytes: return TEXT("OpBlockScratchBytes");
|
||||||
|
case ECounter::OpBlockCopyBytes: return TEXT("OpBlockCopyBytes");
|
||||||
|
case ECounter::Count: break;
|
||||||
|
}
|
||||||
|
return TEXT("Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
EBucket BucketFromName(const TCHAR* Name)
|
||||||
|
{
|
||||||
|
if (Name == nullptr) { return EBucket::OtherOp; }
|
||||||
|
|
||||||
|
struct FNameBucket
|
||||||
|
{
|
||||||
|
const TCHAR* Name;
|
||||||
|
EBucket Bucket;
|
||||||
|
};
|
||||||
|
static const FNameBucket Names[] =
|
||||||
|
{
|
||||||
|
{ TEXT("ApplyDisturbances"), EBucket::ApplyDisturbances },
|
||||||
|
{ TEXT("PassageModifier"), EBucket::PassageModifier },
|
||||||
|
{ TEXT("PassageLandingAir"), EBucket::PassageLandingAir },
|
||||||
|
{ TEXT("PassageLandingFloor"), EBucket::PassageLandingFloor },
|
||||||
|
{ TEXT("PassageLandingRoomFloor"), EBucket::PassageLandingRoomFloor },
|
||||||
|
{ TEXT("PassageTunnelAir"), EBucket::PassageTunnelAir },
|
||||||
|
{ TEXT("PassageStructuralPosts"), EBucket::PassageStructuralPosts },
|
||||||
|
{ TEXT("TunnelCoreSupport"), EBucket::TunnelCoreSupport },
|
||||||
|
{ TEXT("TunnelCoreSdf"), EBucket::TunnelCoreSdf },
|
||||||
|
{ TEXT("TunnelCorePosts"), EBucket::TunnelCorePosts },
|
||||||
|
{ TEXT("StructuralTail"), EBucket::StructuralTail },
|
||||||
|
{ TEXT("RoomGraphSource"), EBucket::RoomGraphSource },
|
||||||
|
{ TEXT("SdfCarve"), EBucket::SdfCarve },
|
||||||
|
{ TEXT("SdfConvertOp"), EBucket::SdfCarve },
|
||||||
|
{ TEXT("CaveRoughnessMod"), EBucket::CaveRoughnessMod },
|
||||||
|
{ TEXT("CaveTerraceMod"), EBucket::CaveTerraceMod },
|
||||||
|
{ TEXT("LayerLineMod"), EBucket::LayerLineMod },
|
||||||
|
{ TEXT("RibbingMod"), EBucket::RibbingMod },
|
||||||
|
{ TEXT("CaveOverhangMod"), EBucket::CaveOverhangMod },
|
||||||
|
{ TEXT("CaveCliffMod"), EBucket::CaveCliffMod },
|
||||||
|
{ TEXT("ScallopMod"), EBucket::ScallopMod },
|
||||||
|
{ TEXT("CaveArchMod"), EBucket::CaveArchMod },
|
||||||
|
{ TEXT("RoomColumnMod"), EBucket::RoomColumnMod },
|
||||||
|
{ TEXT("DomeMod"), EBucket::DomeMod },
|
||||||
|
{ TEXT("PinchMod"), EBucket::PinchMod },
|
||||||
|
{ TEXT("FloorBiasMod"), EBucket::FloorBiasMod },
|
||||||
|
{ TEXT("WormFieldSource"), EBucket::WormFieldSource },
|
||||||
|
{ TEXT("OriginSpineOp"), EBucket::OriginSpineOp },
|
||||||
|
{ TEXT("BoundarySealOp"), EBucket::BoundarySealOp },
|
||||||
|
{ TEXT("PassageCarveOp"), EBucket::PassageCarveOp },
|
||||||
|
{ TEXT("XYEdgeSealOp"), EBucket::XYEdgeSealOp },
|
||||||
|
{ TEXT("FusedEvaluator"), EBucket::FusedEvaluator },
|
||||||
|
{ TEXT("InterpretedOpStack"),EBucket::InterpretedOpStack },
|
||||||
|
{ TEXT("OperatorBlock"), EBucket::OperatorBlock },
|
||||||
|
{ TEXT("RoomGraphBuild"), EBucket::RoomGraphBuild },
|
||||||
|
{ TEXT("RoomGraphSdf"), EBucket::RoomGraphSdf },
|
||||||
|
{ TEXT("TunnelCoreWorld"), EBucket::TunnelCoreWorld },
|
||||||
|
{ TEXT("FusedDetail"), EBucket::FusedDetail },
|
||||||
|
{ TEXT("FusedWorm"), EBucket::FusedWorm },
|
||||||
|
{ TEXT("FusedStructural"), EBucket::FusedStructural },
|
||||||
|
{ TEXT("ClassifierTotal"), EBucket::ClassifierTotal },
|
||||||
|
{ TEXT("ClassifierIntervalProof"), EBucket::ClassifierIntervalProof },
|
||||||
|
{ TEXT("ClassifierExactCore"), EBucket::ClassifierExactCore },
|
||||||
|
{ TEXT("ClassifierExactFinal"), EBucket::ClassifierExactFinal },
|
||||||
|
{ TEXT("ClassifierRoomTail"), EBucket::ClassifierRoomTail },
|
||||||
|
{ TEXT("RuntimeStreamBuilding"), EBucket::RuntimeStreamBuilding },
|
||||||
|
{ TEXT("DensityPrologue"), EBucket::DensityPrologue },
|
||||||
|
{ TEXT("DensityCore"), EBucket::DensityCore },
|
||||||
|
{ TEXT("DensityDisturbances"), EBucket::DensityDisturbances },
|
||||||
|
{ TEXT("DensityStructuralPosts"), EBucket::DensityStructuralPosts },
|
||||||
|
{ TEXT("DensityBoundarySeal"), EBucket::DensityBoundarySeal },
|
||||||
|
{ TEXT("DensityDiffLayer"), EBucket::DensityDiffLayer },
|
||||||
|
{ TEXT("DensityTail"), EBucket::DensityTail },
|
||||||
|
{ TEXT("MesherGenerateMesh"), EBucket::MesherGenerateMesh },
|
||||||
|
{ TEXT("MesherDensityGrid"), EBucket::MesherDensityGrid },
|
||||||
|
{ TEXT("MesherCellClassification"), EBucket::MesherCellClassification },
|
||||||
|
{ TEXT("MesherGradientNormals"), EBucket::MesherGradientNormals },
|
||||||
|
{ TEXT("MesherVertexInterpolation"), EBucket::MesherVertexInterpolation },
|
||||||
|
{ TEXT("MesherStreamBuilding"), EBucket::MesherStreamBuilding },
|
||||||
|
{ TEXT("MesherOther"), EBucket::MesherOther },
|
||||||
|
};
|
||||||
|
for (const FNameBucket& Entry : Names)
|
||||||
|
{
|
||||||
|
if (FCString::Strcmp(Name, Entry.Name) == 0)
|
||||||
|
{
|
||||||
|
return Entry.Bucket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return EBucket::OtherOp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TCHAR* BucketName(EBucket Bucket)
|
||||||
|
{
|
||||||
|
switch (Bucket)
|
||||||
|
{
|
||||||
|
case EBucket::GetDensityAt: return TEXT("GetDensityAt");
|
||||||
|
case EBucket::ApplyDisturbances:return TEXT("ApplyDisturbances");
|
||||||
|
case EBucket::PassageModifier: return TEXT("PassageModifier");
|
||||||
|
case EBucket::PassageLandingAir:return TEXT("PassageLandingAir");
|
||||||
|
case EBucket::PassageLandingFloor:return TEXT("PassageLandingFloor");
|
||||||
|
case EBucket::PassageLandingRoomFloor:return TEXT("PassageLandingRoomFloor");
|
||||||
|
case EBucket::PassageTunnelAir:return TEXT("PassageTunnelAir");
|
||||||
|
case EBucket::PassageStructuralPosts:return TEXT("PassageStructuralPosts");
|
||||||
|
case EBucket::TunnelCoreSupport:return TEXT("TunnelCoreSupport");
|
||||||
|
case EBucket::TunnelCoreSdf: return TEXT("TunnelCoreSdf");
|
||||||
|
case EBucket::TunnelCorePosts: return TEXT("TunnelCorePosts");
|
||||||
|
case EBucket::StructuralTail: return TEXT("StructuralTail");
|
||||||
|
case EBucket::RoomGraphSource: return TEXT("RoomGraphSource");
|
||||||
|
case EBucket::SdfCarve: return TEXT("SdfCarve");
|
||||||
|
case EBucket::CaveRoughnessMod: return TEXT("CaveRoughnessMod");
|
||||||
|
case EBucket::CaveTerraceMod: return TEXT("CaveTerraceMod");
|
||||||
|
case EBucket::LayerLineMod: return TEXT("LayerLineMod");
|
||||||
|
case EBucket::RibbingMod: return TEXT("RibbingMod");
|
||||||
|
case EBucket::CaveOverhangMod: return TEXT("CaveOverhangMod");
|
||||||
|
case EBucket::CaveCliffMod: return TEXT("CaveCliffMod");
|
||||||
|
case EBucket::ScallopMod: return TEXT("ScallopMod");
|
||||||
|
case EBucket::CaveArchMod: return TEXT("CaveArchMod");
|
||||||
|
case EBucket::RoomColumnMod: return TEXT("RoomColumnMod");
|
||||||
|
case EBucket::DomeMod: return TEXT("DomeMod");
|
||||||
|
case EBucket::PinchMod: return TEXT("PinchMod");
|
||||||
|
case EBucket::FloorBiasMod: return TEXT("FloorBiasMod");
|
||||||
|
case EBucket::WormFieldSource: return TEXT("WormFieldSource");
|
||||||
|
case EBucket::OriginSpineOp: return TEXT("OriginSpineOp");
|
||||||
|
case EBucket::BoundarySealOp: return TEXT("BoundarySealOp");
|
||||||
|
case EBucket::PassageCarveOp: return TEXT("PassageCarveOp");
|
||||||
|
case EBucket::XYEdgeSealOp: return TEXT("XYEdgeSealOp");
|
||||||
|
case EBucket::OtherOp: return TEXT("OtherOp");
|
||||||
|
case EBucket::FusedEvaluator: return TEXT("FusedEvaluator");
|
||||||
|
case EBucket::InterpretedOpStack:return TEXT("InterpretedOpStack");
|
||||||
|
case EBucket::OperatorBlock: return TEXT("OperatorBlock");
|
||||||
|
case EBucket::RoomGraphBuild: return TEXT("RoomGraphBuild");
|
||||||
|
case EBucket::RoomGraphSdf: return TEXT("RoomGraphSdf");
|
||||||
|
case EBucket::TunnelCoreWorld: return TEXT("TunnelCoreWorld");
|
||||||
|
case EBucket::FusedDetail: return TEXT("FusedDetail");
|
||||||
|
case EBucket::FusedWorm: return TEXT("FusedWorm");
|
||||||
|
case EBucket::FusedStructural: return TEXT("FusedStructural");
|
||||||
|
case EBucket::ClassifierTotal: return TEXT("ClassifierTotal");
|
||||||
|
case EBucket::ClassifierIntervalProof:return TEXT("ClassifierIntervalProof");
|
||||||
|
case EBucket::ClassifierExactCore:return TEXT("ClassifierExactCore");
|
||||||
|
case EBucket::ClassifierExactFinal:return TEXT("ClassifierExactFinal");
|
||||||
|
case EBucket::ClassifierRoomTail:return TEXT("ClassifierRoomTail");
|
||||||
|
case EBucket::RuntimeStreamBuilding:return TEXT("RuntimeStreamBuilding");
|
||||||
|
case EBucket::DensityPrologue: return TEXT("DensityPrologue");
|
||||||
|
case EBucket::DensityCore: return TEXT("DensityCore");
|
||||||
|
case EBucket::DensityDisturbances:return TEXT("DensityDisturbances");
|
||||||
|
case EBucket::DensityStructuralPosts:return TEXT("DensityStructuralPosts");
|
||||||
|
case EBucket::DensityBoundarySeal:return TEXT("DensityBoundarySeal");
|
||||||
|
case EBucket::DensityDiffLayer: return TEXT("DensityDiffLayer");
|
||||||
|
case EBucket::DensityTail: return TEXT("DensityTail");
|
||||||
|
case EBucket::MesherGenerateMesh:return TEXT("MesherGenerateMesh");
|
||||||
|
case EBucket::MesherDensityGrid:return TEXT("MesherDensityGrid");
|
||||||
|
case EBucket::MesherCellClassification:return TEXT("MesherCellClassification");
|
||||||
|
case EBucket::MesherGradientNormals:return TEXT("MesherGradientNormals");
|
||||||
|
case EBucket::MesherVertexInterpolation:return TEXT("MesherVertexInterpolation");
|
||||||
|
case EBucket::MesherStreamBuilding:return TEXT("MesherStreamBuilding");
|
||||||
|
case EBucket::MesherOther: return TEXT("MesherOther");
|
||||||
|
case EBucket::Count: break;
|
||||||
|
}
|
||||||
|
return TEXT("Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
FScopeToken BeginScope(EBucket Bucket)
|
||||||
|
{
|
||||||
|
FScopeToken Token{};
|
||||||
|
if (!IsEnabledFast())
|
||||||
|
{
|
||||||
|
return Token;
|
||||||
|
}
|
||||||
|
const EMode Mode = GetMode();
|
||||||
|
const bool bSampledMode = Mode == EMode::Sampled || Mode == EMode::Attribution;
|
||||||
|
if ((Mode == EMode::Sampled && !IsSampledScopeEnabled(Bucket))
|
||||||
|
|| (Mode == EMode::Attribution && !IsAttributionScope(Bucket)))
|
||||||
|
{
|
||||||
|
return Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
Token.bEntered = true;
|
||||||
|
Token.bPreviousSampled = GScopeSampled;
|
||||||
|
Token.bPreviousSampledScopeActive = GSampledScopeActive;
|
||||||
|
++GScopeDepth;
|
||||||
|
|
||||||
|
// For the hot parent, amortise the timer over a deterministic block of calls. A timer
|
||||||
|
// pair around one ~100 ns operation is mostly the timer; a pair around 64 calls is cheap
|
||||||
|
// and estimates the parent from real elapsed work rather than instrumentation latency.
|
||||||
|
if (bSampledMode && Bucket == EBucket::GetDensityAt)
|
||||||
|
{
|
||||||
|
FThreadState& State = GetThreadState();
|
||||||
|
const int32 Index = ToIndex(Bucket);
|
||||||
|
const uint32 Sequence = State.SampleSequence[Index]++;
|
||||||
|
Token.bBlockTiming = true;
|
||||||
|
const uint32 Interval = GetSampleInterval();
|
||||||
|
const bool bBlockBoundary = (Interval & (Interval - 1u)) == 0u
|
||||||
|
? (Sequence & (Interval - 1u)) == 0u
|
||||||
|
: (Sequence % Interval) == 0u;
|
||||||
|
if (bBlockBoundary && !State.BlockActive[Index])
|
||||||
|
{
|
||||||
|
State.BlockActive[Index] = true;
|
||||||
|
State.BlockCallCount[Index] = 0;
|
||||||
|
State.BlockStartCycles[Index] = FPlatformTime::Cycles64();
|
||||||
|
}
|
||||||
|
// The selected call admits all nested component scopes. Every other call exits the
|
||||||
|
// inline FScopedTimer fast path before crossing into this translation unit.
|
||||||
|
GScopeSampled = bBlockBoundary;
|
||||||
|
GSampledScopeActive = bBlockBoundary;
|
||||||
|
Token.bSampled = bBlockBoundary;
|
||||||
|
return Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every bucket samples independently, but only one nested non-container scope owns a
|
||||||
|
// timer at a time. Otherwise a sampled GetDensityAt would also start timers for every
|
||||||
|
// phase and operation inside that same call, measuring instrumentation instead of work.
|
||||||
|
// Mesher container ranges deliberately remain transparent so their grid can still expose
|
||||||
|
// density samples underneath them.
|
||||||
|
const bool bContainerScope = Bucket == EBucket::MesherGenerateMesh
|
||||||
|
|| Bucket == EBucket::MesherDensityGrid;
|
||||||
|
const bool bInheritedAttributionSample = Mode == EMode::Attribution
|
||||||
|
&& GSampledScopeActive;
|
||||||
|
Token.bSampled = Mode == EMode::Full
|
||||||
|
? true
|
||||||
|
: ((Mode == EMode::Attribution && IsAttributionAlwaysTimed(Bucket))
|
||||||
|
? true
|
||||||
|
: (bInheritedAttributionSample
|
||||||
|
? true
|
||||||
|
: (Mode == EMode::Attribution && IsAttributionScope(Bucket)
|
||||||
|
? ShouldSample(Bucket)
|
||||||
|
: (!GSampledScopeActive && ShouldSample(Bucket)))));
|
||||||
|
GScopeSampled = Token.bSampled;
|
||||||
|
if (Token.bSampled)
|
||||||
|
{
|
||||||
|
if ((Mode == EMode::Sampled || Mode == EMode::Attribution) && !bContainerScope)
|
||||||
|
{
|
||||||
|
GSampledScopeActive = true;
|
||||||
|
}
|
||||||
|
Token.StartCycles = FPlatformTime::Cycles64();
|
||||||
|
}
|
||||||
|
return Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EndScope(EBucket Bucket, const FScopeToken& Token)
|
||||||
|
{
|
||||||
|
if (!Token.bEntered)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordCall(Bucket);
|
||||||
|
if (Token.bBlockTiming)
|
||||||
|
{
|
||||||
|
FThreadState& State = GetThreadState();
|
||||||
|
const int32 Index = ToIndex(Bucket);
|
||||||
|
++State.BlockCallCount[Index];
|
||||||
|
const uint32 Interval = GetSampleInterval();
|
||||||
|
if (State.BlockActive[Index] && State.BlockCallCount[Index] >= Interval)
|
||||||
|
{
|
||||||
|
const uint64 RawCycles = FPlatformTime::Cycles64() - State.BlockStartCycles[Index];
|
||||||
|
const uint64 TimerCost = GetTimerPairCycles();
|
||||||
|
const uint64 AdjustedCycles = RawCycles > TimerCost
|
||||||
|
? RawCycles - TimerCost
|
||||||
|
: 0;
|
||||||
|
State.Cycles[Index] += AdjustedCycles / FMath::Max<uint32>(1, Interval);
|
||||||
|
State.Samples[Index] += 1;
|
||||||
|
State.BlockActive[Index] = false;
|
||||||
|
State.BlockCallCount[Index] = 0;
|
||||||
|
State.BlockStartCycles[Index] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (Token.bSampled)
|
||||||
|
{
|
||||||
|
AddSampledMeasurement(Bucket, FPlatformTime::Cycles64() - Token.StartCycles);
|
||||||
|
}
|
||||||
|
|
||||||
|
check(GScopeDepth > 0);
|
||||||
|
--GScopeDepth;
|
||||||
|
GScopeSampled = Token.bPreviousSampled;
|
||||||
|
GSampledScopeActive = Token.bPreviousSampledScopeActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ void UVoxelDensityVolume::Initialize(AActor* InOwner, UVoxelGenerator* InGenerat
|
|||||||
Generator = InGenerator;
|
Generator = InGenerator;
|
||||||
Settings = InSettings;
|
Settings = InSettings;
|
||||||
bShuttingDown.store(false, std::memory_order_relaxed);
|
bShuttingDown.store(false, std::memory_order_relaxed);
|
||||||
|
bGenerationPaused.store(false, std::memory_order_relaxed);
|
||||||
bInitialized = true;
|
bInitialized = true;
|
||||||
// Arrays are allocated lazily on the first Update (EnsureAllocated) so a settings change
|
// Arrays are allocated lazily on the first Update (EnsureAllocated) so a settings change
|
||||||
// (resolution / level count) before play picks up cleanly.
|
// (resolution / level count) before play picks up cleanly.
|
||||||
@@ -80,6 +81,7 @@ void UVoxelDensityVolume::BeginDestroy()
|
|||||||
{
|
{
|
||||||
// Backstop — EndPlay → NotifyShutdown should already have stopped the fill thread.
|
// Backstop — EndPlay → NotifyShutdown should already have stopped the fill thread.
|
||||||
bShuttingDown.store(true, std::memory_order_release);
|
bShuttingDown.store(true, std::memory_order_release);
|
||||||
|
bGenerationPaused.store(true, std::memory_order_release);
|
||||||
StopFillThread();
|
StopFillThread();
|
||||||
Super::BeginDestroy();
|
Super::BeginDestroy();
|
||||||
}
|
}
|
||||||
@@ -87,6 +89,7 @@ void UVoxelDensityVolume::BeginDestroy()
|
|||||||
void UVoxelDensityVolume::NotifyShutdown()
|
void UVoxelDensityVolume::NotifyShutdown()
|
||||||
{
|
{
|
||||||
bShuttingDown.store(true, std::memory_order_release);
|
bShuttingDown.store(true, std::memory_order_release);
|
||||||
|
bGenerationPaused.store(true, std::memory_order_release);
|
||||||
|
|
||||||
// Stop the dedicated fill thread — Kill(true) blocks until Run() returns, so it can't read the
|
// Stop the dedicated fill thread — Kill(true) blocks until Run() returns, so it can't read the
|
||||||
// Generator after this (the owner tears UObjects down next). Then drop any queued/finished work.
|
// Generator after this (the owner tears UObjects down next). Then drop any queued/finished work.
|
||||||
@@ -100,6 +103,10 @@ void UVoxelDensityVolume::NotifyShutdown()
|
|||||||
|
|
||||||
void UVoxelDensityVolume::Reset()
|
void UVoxelDensityVolume::Reset()
|
||||||
{
|
{
|
||||||
|
// Reset can be called by a live-edit/regeneration path, not only after EndPlay. Join first so
|
||||||
|
// no fill thread can still read Generator while the old epoch and queued work are discarded.
|
||||||
|
StopFillThread();
|
||||||
|
|
||||||
// Bump the epoch so any in-flight fill lands stale and is dropped in DrainResults.
|
// Bump the epoch so any in-flight fill lands stale and is dropped in DrainResults.
|
||||||
++VolumeEpoch;
|
++VolumeEpoch;
|
||||||
PendingFills.Reset();
|
PendingFills.Reset();
|
||||||
@@ -118,6 +125,20 @@ void UVoxelDensityVolume::Reset()
|
|||||||
LastPlayerVoxel = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
|
LastPlayerVoxel = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::PauseForGenerationChange()
|
||||||
|
{
|
||||||
|
bGenerationPaused.store(true, std::memory_order_release);
|
||||||
|
StopFillThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelDensityVolume::ResumeAfterGenerationChange()
|
||||||
|
{
|
||||||
|
if (!bShuttingDown.load(std::memory_order_acquire))
|
||||||
|
{
|
||||||
|
bGenerationPaused.store(false, std::memory_order_release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// Helpers
|
// Helpers
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -284,7 +305,8 @@ bool UVoxelDensityVolume::GetLevelShaderParams(int32 Level, FIntVector& OutOrigi
|
|||||||
|
|
||||||
void UVoxelDensityVolume::Update(const FVector& PlayerWorldPos)
|
void UVoxelDensityVolume::Update(const FVector& PlayerWorldPos)
|
||||||
{
|
{
|
||||||
if (!bInitialized || !Settings || !Settings->bEnableDensityVolume || !Generator) return;
|
if (bGenerationPaused.load(std::memory_order_acquire)
|
||||||
|
|| !bInitialized || !Settings || !Settings->bEnableDensityVolume || !Generator) return;
|
||||||
AActor* O = Owner.Get();
|
AActor* O = Owner.Get();
|
||||||
if (!O) return;
|
if (!O) return;
|
||||||
|
|
||||||
@@ -557,6 +579,8 @@ void UVoxelDensityVolume::IngestTileCapture(const FIntVector& L0TileCoord, TArra
|
|||||||
void UVoxelDensityVolume::EnsureFillThread()
|
void UVoxelDensityVolume::EnsureFillThread()
|
||||||
{
|
{
|
||||||
if (FillThread) return;
|
if (FillThread) return;
|
||||||
|
if (bGenerationPaused.load(std::memory_order_acquire)
|
||||||
|
|| bShuttingDown.load(std::memory_order_acquire)) return;
|
||||||
if (!Settings || !Settings->bEnableDensityVolume) return;
|
if (!Settings || !Settings->bEnableDensityVolume) return;
|
||||||
bFillThreadStop.store(false, std::memory_order_release);
|
bFillThreadStop.store(false, std::memory_order_release);
|
||||||
if (!FillWakeEvent) { FillWakeEvent = FPlatformProcess::GetSynchEventFromPool(false); } // auto-reset
|
if (!FillWakeEvent) { FillWakeEvent = FPlatformProcess::GetSynchEventFromPool(false); } // auto-reset
|
||||||
@@ -588,6 +612,8 @@ void UVoxelDensityVolume::StopFillThread()
|
|||||||
void UVoxelDensityVolume::LaunchPendingFills()
|
void UVoxelDensityVolume::LaunchPendingFills()
|
||||||
{
|
{
|
||||||
if (PendingFills.Num() == 0) return;
|
if (PendingFills.Num() == 0) return;
|
||||||
|
if (bGenerationPaused.load(std::memory_order_acquire)
|
||||||
|
|| bShuttingDown.load(std::memory_order_acquire)) return;
|
||||||
EnsureFillThread();
|
EnsureFillThread();
|
||||||
for (FPendingFill& F : PendingFills)
|
for (FPendingFill& F : PendingFills)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,98 @@
|
|||||||
|
|
||||||
#include "VoxelDiffLayer.h"
|
#include "VoxelDiffLayer.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
std::atomic<uint64> GNextDiffLayerLifetimeId{0};
|
||||||
|
|
||||||
|
bool IsFiniteVector(const FVector& Value)
|
||||||
|
{
|
||||||
|
return VoxelMath::IsFinite(Value.X)
|
||||||
|
&& VoxelMath::IsFinite(Value.Y)
|
||||||
|
&& VoxelMath::IsFinite(Value.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsValidModificationGeometry(const FVoxelModification& Mod)
|
||||||
|
{
|
||||||
|
if (!IsFiniteVector(Mod.Center) || !IsFiniteVector(Mod.BoxExtent)
|
||||||
|
|| !IsFiniteVector(Mod.CapsuleEnd)
|
||||||
|
|| !VoxelMath::IsFinite(Mod.Radius)
|
||||||
|
|| !VoxelMath::IsFinite(Mod.Strength)
|
||||||
|
|| !VoxelMath::IsFinite(Mod.Falloff)
|
||||||
|
|| Mod.Radius <= 0.0f || Mod.Falloff < 0.0f)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (Mod.Shape)
|
||||||
|
{
|
||||||
|
case EVoxelBrushShape::Sphere:
|
||||||
|
return true;
|
||||||
|
case EVoxelBrushShape::Box:
|
||||||
|
return Mod.BoxExtent.X > 0.0f
|
||||||
|
&& Mod.BoxExtent.Y > 0.0f
|
||||||
|
&& Mod.BoxExtent.Z > 0.0f;
|
||||||
|
case EVoxelBrushShape::Capsule:
|
||||||
|
return true; // A coincident pair of endpoints is a valid spherical capsule.
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double GetModificationBudgetVolume(const FVoxelModification& Mod)
|
||||||
|
{
|
||||||
|
switch (Mod.Shape)
|
||||||
|
{
|
||||||
|
case EVoxelBrushShape::Box:
|
||||||
|
{
|
||||||
|
// The SDF's falloff is a rounded outer band, so the expanded axis-aligned box is a
|
||||||
|
// conservative charge and remains cheap to compute.
|
||||||
|
const double Ex = static_cast<double>(Mod.BoxExtent.X) + static_cast<double>(Mod.Falloff);
|
||||||
|
const double Ey = static_cast<double>(Mod.BoxExtent.Y) + static_cast<double>(Mod.Falloff);
|
||||||
|
const double Ez = static_cast<double>(Mod.BoxExtent.Z) + static_cast<double>(Mod.Falloff);
|
||||||
|
return 8.0 * Ex * Ey * Ez;
|
||||||
|
}
|
||||||
|
case EVoxelBrushShape::Capsule:
|
||||||
|
{
|
||||||
|
// Charge the full capsule including its falloff shell: cylinder + two hemispheres.
|
||||||
|
const double OuterRadius = static_cast<double>(Mod.Radius) + static_cast<double>(Mod.Falloff);
|
||||||
|
const double SegmentLength = static_cast<double>(FVector::Dist(Mod.Center, Mod.CapsuleEnd));
|
||||||
|
return PI * OuterRadius * OuterRadius * SegmentLength
|
||||||
|
+ (4.0 / 3.0) * PI * OuterRadius * OuterRadius * OuterRadius;
|
||||||
|
}
|
||||||
|
case EVoxelBrushShape::Sphere:
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
const double Radius = static_cast<double>(Mod.Radius);
|
||||||
|
return (4.0 / 3.0) * PI * Radius * Radius * Radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64 UVoxelDiffLayer::GetCacheLifetimeId() const
|
||||||
|
{
|
||||||
|
uint64 LifetimeId = CacheLifetimeId.load(std::memory_order_acquire);
|
||||||
|
if (LifetimeId != 0)
|
||||||
|
{
|
||||||
|
return LifetimeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint64 NewLifetimeId = GNextDiffLayerLifetimeId.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||||
|
if (CacheLifetimeId.compare_exchange_strong(
|
||||||
|
LifetimeId,
|
||||||
|
NewLifetimeId,
|
||||||
|
std::memory_order_release,
|
||||||
|
std::memory_order_acquire))
|
||||||
|
{
|
||||||
|
return NewLifetimeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another worker initialized the identity first. Its value is written into LifetimeId by
|
||||||
|
// compare_exchange_strong on failure.
|
||||||
|
return LifetimeId;
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// BUDGET CONFIGURATION
|
// BUDGET CONFIGURATION
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -19,8 +111,13 @@ void UVoxelDiffLayer::SetBudget(int32 InMaxMods, float InMaxRadius, float InMaxV
|
|||||||
|
|
||||||
bool UVoxelDiffLayer::CanModify(float Radius) const
|
bool UVoxelDiffLayer::CanModify(float Radius) const
|
||||||
{
|
{
|
||||||
|
if (!VoxelMath::IsFinite(Radius) || Radius <= 0.0f)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Check radius limit (always enforced, even if "unlimited" count/volume)
|
// Check radius limit (always enforced, even if "unlimited" count/volume)
|
||||||
if (Radius > BudgetMaxRadius)
|
if (BudgetMaxRadius > 0.0f && Radius > BudgetMaxRadius)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -34,8 +131,12 @@ bool UVoxelDiffLayer::CanModify(float Radius) const
|
|||||||
// Check volume limit: would this brush push us over?
|
// Check volume limit: would this brush push us over?
|
||||||
if (BudgetMaxVolume > 0.0f)
|
if (BudgetMaxVolume > 0.0f)
|
||||||
{
|
{
|
||||||
const float BrushVolume = (4.0f / 3.0f) * PI * Radius * Radius * Radius;
|
const double BrushVolume = (4.0 / 3.0) * PI
|
||||||
if (AccumulatedVolume + BrushVolume > BudgetMaxVolume)
|
* static_cast<double>(Radius) * static_cast<double>(Radius) * static_cast<double>(Radius);
|
||||||
|
const double NewTotal = static_cast<double>(AccumulatedVolume) + BrushVolume;
|
||||||
|
if (!VoxelMath::IsFinite(BrushVolume) || BrushVolume > static_cast<double>(FLT_MAX)
|
||||||
|
|| !VoxelMath::IsFinite(NewTotal)
|
||||||
|
|| NewTotal > static_cast<double>(BudgetMaxVolume))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -44,6 +145,27 @@ bool UVoxelDiffLayer::CanModify(float Radius) const
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool UVoxelDiffLayer::CanModify(const FVoxelModification& Mod) const
|
||||||
|
{
|
||||||
|
if (!IsValidModificationGeometry(Mod)) return false;
|
||||||
|
|
||||||
|
// Radius is the radial cap for spheres/capsules. A box's largest half-extent is its analogous
|
||||||
|
// brush radius; checking it separately prevents a direct API caller from hiding a huge box behind
|
||||||
|
// an unrelated small Radius field.
|
||||||
|
const float GeometryRadius = (Mod.Shape == EVoxelBrushShape::Box)
|
||||||
|
? FMath::Max3(Mod.BoxExtent.X, Mod.BoxExtent.Y, Mod.BoxExtent.Z)
|
||||||
|
: Mod.Radius;
|
||||||
|
if (BudgetMaxRadius > 0.0f && GeometryRadius > BudgetMaxRadius) return false;
|
||||||
|
if (BudgetMaxMods > 0 && ModificationCount >= BudgetMaxMods) return false;
|
||||||
|
|
||||||
|
const double BrushVolume = GetModificationBudgetVolume(Mod);
|
||||||
|
const double NewTotal = static_cast<double>(AccumulatedVolume) + BrushVolume;
|
||||||
|
if (!VoxelMath::IsFinite(BrushVolume) || BrushVolume > static_cast<double>(FLT_MAX)
|
||||||
|
|| !VoxelMath::IsFinite(NewTotal)) return false;
|
||||||
|
if (BudgetMaxVolume > 0.0f && NewTotal > static_cast<double>(BudgetMaxVolume)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
int32 UVoxelDiffLayer::GetRemainingModifications() const
|
int32 UVoxelDiffLayer::GetRemainingModifications() const
|
||||||
{
|
{
|
||||||
if (BudgetMaxMods <= 0) return -1; // Unlimited
|
if (BudgetMaxMods <= 0) return -1; // Unlimited
|
||||||
@@ -64,11 +186,25 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
|
|||||||
{
|
{
|
||||||
TArray<FIntVector> AffectedChunks;
|
TArray<FIntVector> AffectedChunks;
|
||||||
|
|
||||||
// --- Budget enforcement ---
|
// Validate the complete geometry before radius clamping, budget accounting, or AABB math.
|
||||||
// Clamp radius to max allowed
|
// FMath::Min(NaN, limit) and the shape switch's default sphere path would otherwise turn
|
||||||
float ClampedRadius = FMath::Min(Mod.Radius, BudgetMaxRadius);
|
// malformed API input into either a counter update or an invalid chunk range.
|
||||||
|
if (!IsValidModificationGeometry(Mod))
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("[DiffLayer] Modification REJECTED — non-finite or invalid brush geometry"));
|
||||||
|
return AffectedChunks;
|
||||||
|
}
|
||||||
|
|
||||||
if (!CanModify(ClampedRadius))
|
// --- Budget enforcement ---
|
||||||
|
// Preserve the historical radius clamp for spheres/capsules when a finite per-brush cap is set;
|
||||||
|
// the shape-aware check below still charges the resulting geometry and rejects oversized boxes.
|
||||||
|
const float ClampedRadius = (BudgetMaxRadius > 0.0f)
|
||||||
|
? FMath::Min(Mod.Radius, BudgetMaxRadius) : Mod.Radius;
|
||||||
|
FVoxelModification ClampedMod = Mod;
|
||||||
|
ClampedMod.Radius = ClampedRadius;
|
||||||
|
|
||||||
|
if (!CanModify(ClampedMod))
|
||||||
{
|
{
|
||||||
UE_LOG(LogTemp, Warning,
|
UE_LOG(LogTemp, Warning,
|
||||||
TEXT("[DiffLayer] Modification REJECTED — budget exceeded (count=%d/%d, volume=%.0f/%.0f)"),
|
TEXT("[DiffLayer] Modification REJECTED — budget exceeded (count=%d/%d, volume=%.0f/%.0f)"),
|
||||||
@@ -76,34 +212,53 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
|
|||||||
return AffectedChunks; // Empty = rejected
|
return AffectedChunks; // Empty = rejected
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use clamped radius for the actual modification
|
// Complete the world-AABB calculation before mutating the budget counters. Finite input can
|
||||||
FVoxelModification ClampedMod = Mod;
|
// still overflow during Center +/- extent or address a chunk outside FIntVector's domain;
|
||||||
ClampedMod.Radius = ClampedRadius;
|
// neither case is a modification that this layer can store safely.
|
||||||
|
FVector BoundsMin, BoundsMax;
|
||||||
|
ClampedMod.GetWorldBounds(BoundsMin, BoundsMax);
|
||||||
|
auto WorldToChunk = [](float WorldCoord, int32& OutChunk) -> bool
|
||||||
|
{
|
||||||
|
if (!VoxelMath::IsFinite(WorldCoord))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const double ChunkCoord = FMath::FloorToDouble(
|
||||||
|
static_cast<double>(WorldCoord) / static_cast<double>(CHUNK_SIZE));
|
||||||
|
if (!VoxelMath::IsFinite(ChunkCoord)
|
||||||
|
|| ChunkCoord < static_cast<double>(MIN_int32)
|
||||||
|
|| ChunkCoord > static_cast<double>(MAX_int32))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
OutChunk = static_cast<int32>(ChunkCoord);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
int32 MinCX = 0, MaxCX = 0, MinCY = 0, MaxCY = 0, MinCZ = 0, MaxCZ = 0;
|
||||||
|
if (!WorldToChunk(BoundsMin.X, MinCX) || !WorldToChunk(BoundsMax.X, MaxCX)
|
||||||
|
|| !WorldToChunk(BoundsMin.Y, MinCY) || !WorldToChunk(BoundsMax.Y, MaxCY)
|
||||||
|
|| !WorldToChunk(BoundsMin.Z, MinCZ) || !WorldToChunk(BoundsMax.Z, MaxCZ))
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Warning,
|
||||||
|
TEXT("[DiffLayer] Modification REJECTED — brush bounds are not representable"));
|
||||||
|
return TArray<FIntVector>();
|
||||||
|
}
|
||||||
|
|
||||||
// Update budget counters
|
// Update budget counters
|
||||||
ModificationCount++;
|
ModificationCount++;
|
||||||
const float BrushVolume = (4.0f / 3.0f) * PI * ClampedRadius * ClampedRadius * ClampedRadius;
|
const double BrushVolume = GetModificationBudgetVolume(ClampedMod);
|
||||||
AccumulatedVolume += BrushVolume;
|
AccumulatedVolume += static_cast<float>(BrushVolume);
|
||||||
|
|
||||||
// Find every chunk the brush can touch, from its shape-aware world AABB.
|
|
||||||
FVector BoundsMin, BoundsMax;
|
|
||||||
ClampedMod.GetWorldBounds(BoundsMin, BoundsMax);
|
|
||||||
const int32 MinCX = FMath::FloorToInt(BoundsMin.X / CHUNK_SIZE);
|
|
||||||
const int32 MaxCX = FMath::FloorToInt(BoundsMax.X / CHUNK_SIZE);
|
|
||||||
const int32 MinCY = FMath::FloorToInt(BoundsMin.Y / CHUNK_SIZE);
|
|
||||||
const int32 MaxCY = FMath::FloorToInt(BoundsMax.Y / CHUNK_SIZE);
|
|
||||||
const int32 MinCZ = FMath::FloorToInt(BoundsMin.Z / CHUNK_SIZE);
|
|
||||||
const int32 MaxCZ = FMath::FloorToInt(BoundsMax.Z / CHUNK_SIZE);
|
|
||||||
|
|
||||||
{
|
{
|
||||||
// Write lock: blocks worker-thread readers (HasModifications / GetDensityOffset) while the map
|
// Write lock: blocks worker-thread readers (HasModifications / GetDensityOffset) while the map
|
||||||
// is mutated/rehashed. AffectedChunks is local; populate it in the same pass.
|
// is mutated/rehashed. AffectedChunks is local; populate it in the same pass.
|
||||||
FWriteScopeLock Lock(ModsLock);
|
FWriteScopeLock Lock(ModsLock);
|
||||||
for (int32 CZ = MinCZ; CZ <= MaxCZ; CZ++)
|
for (int32 CZ = MinCZ;; ++CZ)
|
||||||
{
|
{
|
||||||
for (int32 CY = MinCY; CY <= MaxCY; CY++)
|
for (int32 CY = MinCY;; ++CY)
|
||||||
{
|
{
|
||||||
for (int32 CX = MinCX; CX <= MaxCX; CX++)
|
for (int32 CX = MinCX;; ++CX)
|
||||||
{
|
{
|
||||||
FIntVector ChunkCoord(CX, CY, CZ);
|
FIntVector ChunkCoord(CX, CY, CZ);
|
||||||
|
|
||||||
@@ -112,8 +267,12 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
|
|||||||
|
|
||||||
// Track which chunks need re-meshing
|
// Track which chunks need re-meshing
|
||||||
AffectedChunks.Add(ChunkCoord);
|
AffectedChunks.Add(ChunkCoord);
|
||||||
|
|
||||||
|
if (CX == MaxCX) break;
|
||||||
}
|
}
|
||||||
|
if (CY == MaxCY) break;
|
||||||
}
|
}
|
||||||
|
if (CZ == MaxCZ) break;
|
||||||
}
|
}
|
||||||
// Publish: subsequent readers must now take the lock instead of fast-rejecting, and
|
// Publish: subsequent readers must now take the lock instead of fast-rejecting, and
|
||||||
// worker-side snapshots keyed on the version re-copy their chunk's list.
|
// worker-side snapshots keyed on the version re-copy their chunk's list.
|
||||||
@@ -171,19 +330,30 @@ bool UVoxelDiffLayer::HasAnyModInChunkRange(const FIntVector& MinChunk, const FI
|
|||||||
{
|
{
|
||||||
if (!bHasAnyMods.load(std::memory_order_acquire)) return false;
|
if (!bHasAnyMods.load(std::memory_order_acquire)) return false;
|
||||||
|
|
||||||
// Walk the modified-chunk KEYS under one read lock. Conservative: a mod is registered in every
|
if (MinChunk.X > MaxChunk.X || MinChunk.Y > MaxChunk.Y || MinChunk.Z > MaxChunk.Z)
|
||||||
// chunk its radius overlaps (ApplyModification), so key-in-range ⟺ the mod can reach the range.
|
|
||||||
FReadScopeLock Lock(ModsLock);
|
|
||||||
for (const auto& Pair : ChunkMods)
|
|
||||||
{
|
{
|
||||||
const FIntVector& C = Pair.Key;
|
return false;
|
||||||
if (C.X >= MinChunk.X && C.X <= MaxChunk.X &&
|
|
||||||
C.Y >= MinChunk.Y && C.Y <= MaxChunk.Y &&
|
|
||||||
C.Z >= MinChunk.Z && C.Z <= MaxChunk.Z &&
|
|
||||||
Pair.Value.Num() > 0)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApplyModification registers a mod in every chunk its AABB overlaps, so looking up the keys in
|
||||||
|
// the queried range is conservative and proportional to that range. Do not walk ChunkMods:
|
||||||
|
// the edit history can grow without bound while a sealed-solid candidate is being proved.
|
||||||
|
FReadScopeLock Lock(ModsLock);
|
||||||
|
for (int32 CZ = MinChunk.Z;; ++CZ)
|
||||||
|
{
|
||||||
|
for (int32 CY = MinChunk.Y;; ++CY)
|
||||||
|
{
|
||||||
|
for (int32 CX = MinChunk.X;; ++CX)
|
||||||
|
{
|
||||||
|
if (const TArray<FVoxelModification>* Mods = ChunkMods.Find(FIntVector(CX, CY, CZ)))
|
||||||
|
{
|
||||||
|
if (Mods->Num() > 0) return true;
|
||||||
|
}
|
||||||
|
if (CX == MaxChunk.X) break;
|
||||||
|
}
|
||||||
|
if (CY == MaxChunk.Y) break;
|
||||||
|
}
|
||||||
|
if (CZ == MaxChunk.Z) break;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
// Module implementation - boilerplate
|
// Module implementation - boilerplate
|
||||||
|
|
||||||
#include "VoxelForgeModule.h"
|
#include "VoxelForgeModule.h"
|
||||||
|
#include "VoxelStackSampler.h"
|
||||||
|
#include "VoxelGenerator.h"
|
||||||
|
#include "VoxelMarchingCubesMesher.h"
|
||||||
|
#include "Misc/CommandLine.h"
|
||||||
|
#include "Misc/Parse.h"
|
||||||
|
|
||||||
|
#if PLATFORM_WINDOWS
|
||||||
|
#include <math.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
// This macro registers our module with Unreal
|
// This macro registers our module with Unreal
|
||||||
// "VoxelForge" must match the module name in VoxelForge.Build.cs and .uplugin
|
// "VoxelForge" must match the module name in VoxelForge.Build.cs and .uplugin
|
||||||
@@ -10,13 +19,36 @@ IMPLEMENT_MODULE(FVoxelForgeModule, VoxelForge)
|
|||||||
void FVoxelForgeModule::StartupModule()
|
void FVoxelForgeModule::StartupModule()
|
||||||
{
|
{
|
||||||
// Called when plugin loads
|
// Called when plugin loads
|
||||||
// We don't need to do anything here for now
|
// Keep this diagnostic before any plugin-owned generation can begin. The UCRT may select
|
||||||
|
// its FMA3 math implementations during process startup; this switch lets the canonical export
|
||||||
|
// compare that path with the non-FMA3 implementation on the same machine.
|
||||||
|
#if PLATFORM_WINDOWS
|
||||||
|
const int32 Fma3Before = _get_FMA3_enable();
|
||||||
|
int32 RequestedFma3 = 0;
|
||||||
|
const bool bHasFma3Override = FParse::Value(
|
||||||
|
FCommandLine::Get(), TEXT("voxel.CrtFma3="), RequestedFma3);
|
||||||
|
if (bHasFma3Override)
|
||||||
|
{
|
||||||
|
_set_FMA3_enable(RequestedFma3 != 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
const int32 Fma3After = _get_FMA3_enable();
|
||||||
|
UE_LOG(LogTemp, Log,
|
||||||
|
TEXT("VoxelForge CRT FMA3 diagnostic: before=%d override=%s requested=%d after=%d"),
|
||||||
|
Fma3Before,
|
||||||
|
bHasFma3Override ? TEXT("yes") : TEXT("no"),
|
||||||
|
RequestedFma3,
|
||||||
|
Fma3After);
|
||||||
|
#endif
|
||||||
|
// These parsers used to be entered lazily by worker-side density/mesher calls. Resolve the
|
||||||
|
// command line once while the module is starting, before any generation task is submitted.
|
||||||
|
VoxelGenLOD::InitializeConsoleSwitches();
|
||||||
|
UVoxelMarchingCubesMesher::InitializeConsoleSwitches();
|
||||||
UE_LOG(LogTemp, Log, TEXT("VoxelForge module started!"));
|
UE_LOG(LogTemp, Log, TEXT("VoxelForge module started!"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void FVoxelForgeModule::ShutdownModule()
|
void FVoxelForgeModule::ShutdownModule()
|
||||||
{
|
{
|
||||||
// Called when plugin unloads
|
// Called when plugin unloads
|
||||||
// Cleanup would go here if we had any global resources
|
FVoxelStackSampler::Get().Shutdown();
|
||||||
UE_LOG(LogTemp, Log, TEXT("VoxelForge module shutdown."));
|
UE_LOG(LogTemp, Log, TEXT("VoxelForge module shutdown."));
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -229,7 +229,7 @@ namespace
|
|||||||
if (P.LayerLineDepth <= 0.0f || P.LayerLineSpacing <= 0.0f) { return; }
|
if (P.LayerLineDepth <= 0.0f || P.LayerLineSpacing <= 0.0f) { return; }
|
||||||
|
|
||||||
const float Phase = InOut.Height * (2.0f * PI / P.LayerLineSpacing);
|
const float Phase = InOut.Height * (2.0f * PI / P.LayerLineSpacing);
|
||||||
InOut.Height -= FMath::Sin(Phase) * P.LayerLineDepth;
|
InOut.Height -= VoxelMath::DetSin(Phase) * P.LayerLineDepth;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `sin` ∈ [-1,1] ⇒ borne exacte.
|
// `sin` ∈ [-1,1] ⇒ borne exacte.
|
||||||
|
|||||||
@@ -3,6 +3,90 @@
|
|||||||
|
|
||||||
#include "VoxelMarchingCubesMesher.h"
|
#include "VoxelMarchingCubesMesher.h"
|
||||||
#include "MarchingCubesTables.h"
|
#include "MarchingCubesTables.h"
|
||||||
|
#include "VoxelDensityProfile.h"
|
||||||
|
#include "VoxelStrateManager.h"
|
||||||
|
#include "VoxelTilePostReach.h"
|
||||||
|
#include "ProfilingDebugging/CpuProfilerTrace.h"
|
||||||
|
#include "HAL/IConsoleManager.h"
|
||||||
|
#include "Misc/CommandLine.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
int32 GVoxelForgeUseOperatorBlock = 1;
|
||||||
|
FAutoConsoleVariableRef CVarVoxelForgeUseOperatorBlock(
|
||||||
|
TEXT("voxel.UseOperatorBlock"),
|
||||||
|
GVoxelForgeUseOperatorBlock,
|
||||||
|
TEXT("Use the operator-major density block path for non-shared mesher grids."));
|
||||||
|
bool GVoxelForgeBlockSwitchParsed = false;
|
||||||
|
|
||||||
|
int32 GVoxelForgeTilePostReachDebug = 0;
|
||||||
|
FAutoConsoleVariableRef CVarVoxelForgeTilePostReachDebug(
|
||||||
|
TEXT("voxel.TilePostReachDebug"),
|
||||||
|
GVoxelForgeTilePostReachDebug,
|
||||||
|
TEXT("Run skipped landing/structural posts in a bypass and compare their exact result; "
|
||||||
|
"a nonzero difference fails the debug run."));
|
||||||
|
|
||||||
|
float GVoxelForgeTilePostReachScale = 1.0f;
|
||||||
|
FAutoConsoleVariableRef CVarVoxelForgeTilePostReachScale(
|
||||||
|
TEXT("voxel.TilePostReachScale"),
|
||||||
|
GVoxelForgeTilePostReachScale,
|
||||||
|
TEXT("Scale the per-tile post reach for deliberate soundness tests. Default 1.0 is exact; "
|
||||||
|
"values below 1 intentionally shrink the guard."));
|
||||||
|
|
||||||
|
int32 GVoxelForgeTileReachCostDiagnostics = 0;
|
||||||
|
FAutoConsoleVariableRef CVarVoxelForgeTileReachCostDiagnostics(
|
||||||
|
TEXT("voxel.TileReachCostDiagnostics"),
|
||||||
|
GVoxelForgeTileReachCostDiagnostics,
|
||||||
|
TEXT("Measure core/passage density cost split by tile reach; no field gate."));
|
||||||
|
|
||||||
|
bool GVoxelForgeTilePostReachSwitchesParsed = false;
|
||||||
|
|
||||||
|
void VF_ParseTilePostReachSwitches()
|
||||||
|
{
|
||||||
|
if (GVoxelForgeTilePostReachSwitchesParsed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GVoxelForgeTilePostReachSwitchesParsed = true;
|
||||||
|
int32 DebugValue = GVoxelForgeTilePostReachDebug;
|
||||||
|
float ScaleValue = GVoxelForgeTilePostReachScale;
|
||||||
|
int32 CostDiagnosticsValue = GVoxelForgeTileReachCostDiagnostics;
|
||||||
|
FParse::Value(
|
||||||
|
FCommandLine::Get(), TEXT("voxel.TilePostReachDebug="), DebugValue);
|
||||||
|
FParse::Value(
|
||||||
|
FCommandLine::Get(), TEXT("voxel.TilePostReachScale="), ScaleValue);
|
||||||
|
FParse::Value(
|
||||||
|
FCommandLine::Get(), TEXT("voxel.TileReachCostDiagnostics="), CostDiagnosticsValue);
|
||||||
|
GVoxelForgeTilePostReachDebug = DebugValue != 0 ? 1 : 0;
|
||||||
|
if (!VoxelMath::IsFinite(ScaleValue) || ScaleValue <= 0.0f)
|
||||||
|
{
|
||||||
|
ScaleValue = 1.0f;
|
||||||
|
}
|
||||||
|
GVoxelForgeTilePostReachScale = FMath::Min(ScaleValue, 1.0f);
|
||||||
|
GVoxelForgeTileReachCostDiagnostics = CostDiagnosticsValue != 0 ? 1 : 0;
|
||||||
|
VoxelGenLOD::GTilePostReachDebugEnabled.store(
|
||||||
|
GVoxelForgeTilePostReachDebug != 0, std::memory_order_relaxed);
|
||||||
|
VoxelGenLOD::GTileReachCostDiagnosticsEnabled.store(
|
||||||
|
GVoxelForgeTileReachCostDiagnostics != 0, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void VF_ParseOperatorBlockSwitch()
|
||||||
|
{
|
||||||
|
if (GVoxelForgeBlockSwitchParsed) { return; }
|
||||||
|
GVoxelForgeBlockSwitchParsed = true;
|
||||||
|
int32 CommandLineValue = GVoxelForgeUseOperatorBlock;
|
||||||
|
if (FParse::Value(FCommandLine::Get(), TEXT("voxel.UseOperatorBlock="), CommandLineValue))
|
||||||
|
{
|
||||||
|
GVoxelForgeUseOperatorBlock = CommandLineValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelMarchingCubesMesher::InitializeConsoleSwitches()
|
||||||
|
{
|
||||||
|
VF_ParseOperatorBlockSwitch();
|
||||||
|
VF_ParseTilePostReachSwitches();
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// MAIN ALGORITHM
|
// MAIN ALGORITHM
|
||||||
@@ -12,11 +96,18 @@
|
|||||||
|
|
||||||
FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis,
|
FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis,
|
||||||
TArray<uint8>* OutCaptureGrid,
|
TArray<uint8>* OutCaptureGrid,
|
||||||
int32 BandZMinVox, int32 BandZMaxVox)
|
int32 BandZMinVox, int32 BandZMaxVox,
|
||||||
|
int64* OutDensitySampleCount)
|
||||||
{
|
{
|
||||||
|
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_MesherGenerateMesh);
|
||||||
FVoxelMeshData MeshData;
|
FVoxelMeshData MeshData;
|
||||||
if (OutCaptureGrid) { OutCaptureGrid->Reset(); }
|
if (OutCaptureGrid) { OutCaptureGrid->Reset(); }
|
||||||
|
if (OutDensitySampleCount) { *OutDensitySampleCount = 0; }
|
||||||
if (!Generator) return MeshData;
|
if (!Generator) return MeshData;
|
||||||
|
VoxelDensityProfile::FScopedTimer MesherProfileTimer(
|
||||||
|
VoxelDensityProfile::EBucket::MesherGenerateMesh);
|
||||||
|
VoxelDensityProfile::FScopedTimer MesherSetupTimer(
|
||||||
|
VoxelDensityProfile::EBucket::MesherOther);
|
||||||
|
|
||||||
// Cell size in voxels. No upper clamp: coarse clipmap levels use bigger steps (the EXTENT
|
// Cell size in voxels. No upper clamp: coarse clipmap levels use bigger steps (the EXTENT
|
||||||
// grows). Coarse tiles also use FEWER cells (InCellsPerAxis) for cheaper gen.
|
// grows). Coarse tiles also use FEWER cells (InCellsPerAxis) for cheaper gen.
|
||||||
@@ -29,6 +120,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
|
|||||||
const int32 OctaveBias = (LODOctaveDrop > 0 && Step > 1)
|
const int32 OctaveBias = (LODOctaveDrop > 0 && Step > 1)
|
||||||
? LODOctaveDrop * (int32)FMath::FloorLog2((uint32)Step) : 0;
|
? LODOctaveDrop * (int32)FMath::FloorLog2((uint32)Step) : 0;
|
||||||
TGuardValue<int32> OctaveBiasGuard(VoxelGenLOD::OctaveBias, OctaveBias);
|
TGuardValue<int32> OctaveBiasGuard(VoxelGenLOD::OctaveBias, OctaveBias);
|
||||||
|
TGuardValue<int32> SampleStepGuard(VoxelGenLOD::SampleStep, Step);
|
||||||
|
|
||||||
// World-cm origin of the tile's min corner (positions are built relative to this).
|
// World-cm origin of the tile's min corner (positions are built relative to this).
|
||||||
const FVector ChunkWorldPos = FVector(OriginVoxels) * VOXEL_SIZE;
|
const FVector ChunkWorldPos = FVector(OriginVoxels) * VOXEL_SIZE;
|
||||||
@@ -195,7 +287,100 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
|
|||||||
const int32 CellsPerAxis = FMath::Clamp(InCellsPerAxis, 2, CHUNK_SIZE); // coarse tiles use fewer
|
const int32 CellsPerAxis = FMath::Clamp(InCellsPerAxis, 2, CHUNK_SIZE); // coarse tiles use fewer
|
||||||
const int32 GridDim = CellsPerAxis + 1;
|
const int32 GridDim = CellsPerAxis + 1;
|
||||||
const int32 MDim = GridDim + 2; // +1 marge de chaque côté
|
const int32 MDim = GridDim + 2; // +1 marge de chaque côté
|
||||||
|
TGuardValue<FIntVector> TileOriginGuard(
|
||||||
|
VoxelGenLOD::TileOriginVoxels, OriginVoxels);
|
||||||
|
TGuardValue<int32> TileCellsGuard(
|
||||||
|
VoxelGenLOD::TileCellsPerAxis, CellsPerAxis);
|
||||||
|
|
||||||
|
// Every density sample in this tile sees the same post-reach decision. The one-point halo
|
||||||
|
// matches the actual grid fill below (gx/gy/gz = -1..GridDim), so a false bit proves that no
|
||||||
|
// landing or passage structural writer can reach any sample used by meshing or gradients.
|
||||||
|
TGuardValue<bool> TilePostReachDebugGuard(
|
||||||
|
VoxelGenLOD::bTilePostReachDebug,
|
||||||
|
GVoxelForgeTilePostReachDebug != 0);
|
||||||
|
TGuardValue<bool> TilePostReachBypassGuard(
|
||||||
|
VoxelGenLOD::bTilePostReachBypass, false);
|
||||||
|
TGuardValue<bool> TileReachDiagnosticGuard(
|
||||||
|
VoxelGenLOD::bTileReachDiagnosticTile, true);
|
||||||
|
TGuardValue<bool> TileCoreReachProofGuard(
|
||||||
|
VoxelGenLOD::bTileCoreReachProofEnabled, false);
|
||||||
|
TGuardValue<bool> TileCoreReachDecisionGuard(
|
||||||
|
VoxelGenLOD::bTileCoreReachDecisionRecorded, false);
|
||||||
|
TGuardValue<float> TileReachScaleGuard(
|
||||||
|
VoxelGenLOD::TileReachScale, GVoxelForgeTilePostReachScale);
|
||||||
|
const float ReachScale = GVoxelForgeTilePostReachScale;
|
||||||
|
const int32 ReachBlocksPerAxis = FMath::DivideAndRoundUp(
|
||||||
|
CellsPerAxis, VoxelGenLOD::TileReachBlockCells);
|
||||||
|
TGuardValue<int32> TileReachBlocksGuard(
|
||||||
|
VoxelGenLOD::TileReachBlocksPerAxis, ReachBlocksPerAxis);
|
||||||
|
TGuardValue<int32> TileReachBlockIndexGuard(
|
||||||
|
VoxelGenLOD::TileReachBlockIndex, INDEX_NONE);
|
||||||
|
TGuardValue<bool> TileBlockReachValidGuard(
|
||||||
|
VoxelGenLOD::bTileBlockReachValid, false);
|
||||||
|
uint8 TilePostReachFlags = VoxelGenLOD::AllTilePostReach;
|
||||||
|
if (Generator->StrateManager != nullptr)
|
||||||
|
{
|
||||||
|
const int64 Extent = static_cast<int64>(CellsPerAxis + 1) * Step;
|
||||||
|
const FBox LatticeWithHalo(
|
||||||
|
FVector(
|
||||||
|
static_cast<float>(static_cast<int64>(OriginVoxels.X) - Step),
|
||||||
|
static_cast<float>(static_cast<int64>(OriginVoxels.Y) - Step),
|
||||||
|
static_cast<float>(static_cast<int64>(OriginVoxels.Z) - Step)),
|
||||||
|
FVector(
|
||||||
|
static_cast<float>(static_cast<int64>(OriginVoxels.X) + Extent),
|
||||||
|
static_cast<float>(static_cast<int64>(OriginVoxels.Y) + Extent),
|
||||||
|
static_cast<float>(static_cast<int64>(OriginVoxels.Z) + Extent)));
|
||||||
|
// Core stays conservative until the tile-window tunnel cache is built. Passage carving
|
||||||
|
// is manager-owned and can be decided immediately from the same lattice+halo box.
|
||||||
|
TilePostReachFlags = VoxelGenLOD::TunnelCoreReachable;
|
||||||
|
if (Generator->StrateManager->AnyOriginLandingNearLattice(
|
||||||
|
LatticeWithHalo, OriginVoxels, Step, ReachScale))
|
||||||
|
{
|
||||||
|
TilePostReachFlags |= VoxelGenLOD::OriginLandingReachable;
|
||||||
|
}
|
||||||
|
if (Generator->StrateManager->AnyPassageLandingNearLattice(
|
||||||
|
LatticeWithHalo, OriginVoxels, Step, ReachScale))
|
||||||
|
{
|
||||||
|
TilePostReachFlags |= VoxelGenLOD::PassageLandingReachable;
|
||||||
|
}
|
||||||
|
if (Generator->StrateManager->AnyPassageCarvingNearLattice(
|
||||||
|
LatticeWithHalo, OriginVoxels, Step, ReachScale))
|
||||||
|
{
|
||||||
|
TilePostReachFlags |= VoxelGenLOD::PassageCarvingReachable;
|
||||||
|
}
|
||||||
|
if (Generator->StrateManager->AnyPassageStructuralPostNearLattice(
|
||||||
|
LatticeWithHalo, OriginVoxels, Step, ReachScale))
|
||||||
|
{
|
||||||
|
TilePostReachFlags |= VoxelGenLOD::PassageStructuralReachable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TGuardValue<uint8> TilePostReachFlagsGuard(
|
||||||
|
VoxelGenLOD::TilePostReachFlags, TilePostReachFlags);
|
||||||
|
for (int32 BlockZ = 0; BlockZ < ReachBlocksPerAxis; ++BlockZ)
|
||||||
|
{
|
||||||
|
for (int32 BlockY = 0; BlockY < ReachBlocksPerAxis; ++BlockY)
|
||||||
|
{
|
||||||
|
for (int32 BlockX = 0; BlockX < ReachBlocksPerAxis; ++BlockX)
|
||||||
|
{
|
||||||
|
const int32 BlockIndex =
|
||||||
|
((BlockZ * ReachBlocksPerAxis) + BlockY) * ReachBlocksPerAxis + BlockX;
|
||||||
|
uint8 BlockFlags = TilePostReachFlags;
|
||||||
|
if (Generator->StrateManager != nullptr
|
||||||
|
&& (TilePostReachFlags & VoxelGenLOD::PassageCarvingReachable) != 0
|
||||||
|
&& !Generator->StrateManager->AnyPassageCarvingNearLattice(
|
||||||
|
VoxelGenLOD::MakeTileReachBlockBox(
|
||||||
|
OriginVoxels, Step, CellsPerAxis,
|
||||||
|
BlockX, BlockY, BlockZ),
|
||||||
|
OriginVoxels, Step, ReachScale))
|
||||||
|
{
|
||||||
|
BlockFlags &= ~VoxelGenLOD::PassageCarvingReachable;
|
||||||
|
}
|
||||||
|
VoxelGenLOD::TileBlockReachFlags[BlockIndex] = BlockFlags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VoxelGenLOD::bTileBlockReachValid = true;
|
||||||
|
VoxelGenLOD::RecordTileReachMask(TilePostReachFlags);
|
||||||
// COUPE DE CONTENU PAR STRATE — restreint le maillage (et l'échantillonnage) aux cellules
|
// COUPE DE CONTENU PAR STRATE — restreint le maillage (et l'échantillonnage) aux cellules
|
||||||
// dont l'intervalle Z chevauche la bande [BandZMinVox, BandZMaxVox] (voxels inclusifs).
|
// dont l'intervalle Z chevauche la bande [BandZMinVox, BandZMaxVox] (voxels inclusifs).
|
||||||
// Les tuiles à capture ne sont jamais bandées (niveau 0 — garde-fou ci-dessous).
|
// Les tuiles à capture ne sont jamais bandées (niveau 0 — garde-fou ci-dessous).
|
||||||
@@ -217,24 +402,428 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
|
|||||||
const int32 GzLo = CzLo - 1; // coins CzLo..CzHi+1, gradients ±1
|
const int32 GzLo = CzLo - 1; // coins CzLo..CzHi+1, gradients ±1
|
||||||
const int32 GzHi = CzHi + 2; // (== -1..GridDim sans bande)
|
const int32 GzHi = CzHi + 2; // (== -1..GridDim sans bande)
|
||||||
|
|
||||||
|
MesherSetupTimer.End();
|
||||||
|
|
||||||
|
// Optional 8x8x8-cell block proof. The tile classifier has already returned Mixed, but
|
||||||
|
// that is a conservative answer over the whole tile. A tile can still be uniformly solid
|
||||||
|
// (or uniformly air) when its smaller blocks are each provable. Do this before allocating
|
||||||
|
// or filling the density grid: the grid is only needed when at least one block can contain
|
||||||
|
// a surface. A whole-tile early return is allowed only when every relevant block has the
|
||||||
|
// SAME verdict; adjacent uniform blocks with opposite signs can contain a surface between
|
||||||
|
// them. This is a proof-only optimization: ClassifyTile is conservative and no samples or
|
||||||
|
// resolution are removed from a tile that reaches the mesher.
|
||||||
|
TArray<uint8> BlockSkip;
|
||||||
|
int32 BlocksPerAxis = 0;
|
||||||
|
// LOD2's 16-cell tile is the remaining false-Mixed empty case in the streamed game path.
|
||||||
|
// Its step-8 lattice can use the same proof-only 8-cell block partition as LOD0. Keep the
|
||||||
|
// gate off for steps 2/16/32: their meshing work is already smaller than repeating a full
|
||||||
|
// tile classifier per block, and this preserves the established coarse-LOD latency profile.
|
||||||
|
const bool bUseProofBlocks = bUseBlockEarlyOut && !OutCaptureGrid
|
||||||
|
&& (Step == 1 || Step == 8);
|
||||||
|
if (bUseProofBlocks)
|
||||||
|
{
|
||||||
|
constexpr int32 BlockCells = 8;
|
||||||
|
BlocksPerAxis = (CellsPerAxis + BlockCells - 1) / BlockCells;
|
||||||
|
BlockSkip.Init(0, BlocksPerAxis * BlocksPerAxis * BlocksPerAxis);
|
||||||
|
bool bHaveUniformBlock = false;
|
||||||
|
EVoxelTileClass FirstUniformVerdict = EVoxelTileClass::Mixed;
|
||||||
|
bool bAllRelevantBlocksUniform = true;
|
||||||
|
bool bAllRelevantBlocksSame = true;
|
||||||
|
TArray<int32> MixedBlockIndices;
|
||||||
|
|
||||||
|
for (int32 BlockZ = 0; BlockZ < BlocksPerAxis; ++BlockZ)
|
||||||
|
{
|
||||||
|
const int32 BlockMinZ = BlockZ * BlockCells;
|
||||||
|
const int32 BlockMaxZ = FMath::Min(CellsPerAxis - 1,
|
||||||
|
BlockMinZ + BlockCells - 1);
|
||||||
|
if (BlockMaxZ < CzLo || BlockMinZ > CzHi)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (int32 BlockY = 0; BlockY < BlocksPerAxis; ++BlockY)
|
||||||
|
{
|
||||||
|
for (int32 BlockX = 0; BlockX < BlocksPerAxis; ++BlockX)
|
||||||
|
{
|
||||||
|
const FIntVector BlockOrigin = OriginVoxels + FIntVector(
|
||||||
|
BlockX * BlockCells * Step,
|
||||||
|
BlockY * BlockCells * Step,
|
||||||
|
BlockMinZ * Step);
|
||||||
|
if (VoxelDensityProfile::AreCountersEnabled())
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::AddCounter(
|
||||||
|
VoxelDensityProfile::ECounter::MesherBlockTests);
|
||||||
|
}
|
||||||
|
const bool bAttributeClassifier =
|
||||||
|
VoxelDensityProfile::GetMode()
|
||||||
|
== VoxelDensityProfile::EMode::Attribution;
|
||||||
|
const bool bSampleClassifier = bAttributeClassifier
|
||||||
|
&& VoxelDensityProfile::ShouldSample(
|
||||||
|
VoxelDensityProfile::EBucket::ClassifierTotal);
|
||||||
|
const uint64 ClassifierStartCycles = bSampleClassifier
|
||||||
|
? FPlatformTime::Cycles64() : 0;
|
||||||
|
FVoxelTileClassificationStats ClassifierStats;
|
||||||
|
if (bAttributeClassifier)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::RecordClassifierCall(bSampleClassifier);
|
||||||
|
}
|
||||||
|
const EVoxelTileClass Verdict = Generator->ClassifyTile(
|
||||||
|
BlockOrigin, Step, BlockCells,
|
||||||
|
bSampleClassifier ? &ClassifierStats : nullptr);
|
||||||
|
if (bSampleClassifier)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::AddSampledMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::ClassifierTotal,
|
||||||
|
FPlatformTime::Cycles64() - ClassifierStartCycles);
|
||||||
|
VoxelDensityProfile::FSnapshot::FClassifierStats ProfileStats;
|
||||||
|
ProfileStats.RefineNodes = ClassifierStats.RefineNodes;
|
||||||
|
ProfileStats.StackBoxCalls = ClassifierStats.StackBoxCalls;
|
||||||
|
ProfileStats.WholeMixedNodes = ClassifierStats.WholeMixedNodes;
|
||||||
|
ProfileStats.WholeSolidNodes = ClassifierStats.WholeSolidNodes;
|
||||||
|
ProfileStats.WholeAirNodes = ClassifierStats.WholeAirNodes;
|
||||||
|
ProfileStats.NeedsFinalFieldNodes = ClassifierStats.NeedsFinalFieldNodes;
|
||||||
|
ProfileStats.SplitNodes = ClassifierStats.SplitNodes;
|
||||||
|
ProfileStats.MaxRefinementDepth = ClassifierStats.MaxRefinementDepth;
|
||||||
|
ProfileStats.ExactCoreSamples = ClassifierStats.ExactCoreSamples;
|
||||||
|
ProfileStats.ExactFinalSamples = ClassifierStats.ExactFinalSamples;
|
||||||
|
ProfileStats.ExactCoreCacheHits = ClassifierStats.ExactCoreCacheHits;
|
||||||
|
ProfileStats.ExactFinalCacheHits = ClassifierStats.ExactFinalCacheHits;
|
||||||
|
ProfileStats.ExactCoreLeaves = ClassifierStats.ExactCoreLeaves;
|
||||||
|
ProfileStats.ExactFinalLeaves = ClassifierStats.ExactFinalLeaves;
|
||||||
|
ProfileStats.StackBoxCycles = ClassifierStats.StackBoxCycles;
|
||||||
|
ProfileStats.ExactCoreCycles = ClassifierStats.ExactCoreCycles;
|
||||||
|
ProfileStats.ExactFinalCycles = ClassifierStats.ExactFinalCycles;
|
||||||
|
ProfileStats.RoomTailQueries = ClassifierStats.RoomTailQueries;
|
||||||
|
ProfileStats.RoomTailEvaluated = ClassifierStats.RoomTailEvaluated;
|
||||||
|
ProfileStats.RoomTailCycles = ClassifierStats.RoomTailCycles;
|
||||||
|
ProfileStats.RoomPropagateCycles = ClassifierStats.RoomPropagateCycles;
|
||||||
|
ProfileStats.RoomExactPrimitiveCycles = ClassifierStats.RoomExactPrimitiveCycles;
|
||||||
|
ProfileStats.RoomCacheWindowCycles = ClassifierStats.RoomCacheWindowCycles;
|
||||||
|
ProfileStats.RoomNumRooms = FMath::Max(ClassifierStats.RoomNumRooms, 0);
|
||||||
|
ProfileStats.RoomNumTunnels = FMath::Max(ClassifierStats.RoomNumTunnels, 0);
|
||||||
|
ProfileStats.RoomNumRoomFloorJoins = FMath::Max(
|
||||||
|
ClassifierStats.RoomNumRoomFloorJoins, 0);
|
||||||
|
ProfileStats.RoomNumPits = FMath::Max(ClassifierStats.RoomNumPits, 0);
|
||||||
|
ProfileStats.RoomNumChimneys = FMath::Max(ClassifierStats.RoomNumChimneys, 0);
|
||||||
|
VoxelDensityProfile::AddClassifierStats(ProfileStats);
|
||||||
|
|
||||||
|
if (ClassifierStats.StackBoxCalls > 0)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::AddMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::ClassifierIntervalProof,
|
||||||
|
0, ClassifierStats.StackBoxCalls);
|
||||||
|
VoxelDensityProfile::AddSampledMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::ClassifierIntervalProof,
|
||||||
|
ClassifierStats.StackBoxCycles);
|
||||||
|
}
|
||||||
|
if (ClassifierStats.ExactCoreLeaves > 0)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::AddMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::ClassifierExactCore,
|
||||||
|
0, ClassifierStats.ExactCoreLeaves);
|
||||||
|
VoxelDensityProfile::AddSampledMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::ClassifierExactCore,
|
||||||
|
ClassifierStats.ExactCoreCycles);
|
||||||
|
}
|
||||||
|
if (ClassifierStats.ExactFinalLeaves > 0)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::AddMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::ClassifierExactFinal,
|
||||||
|
0, ClassifierStats.ExactFinalLeaves);
|
||||||
|
VoxelDensityProfile::AddSampledMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::ClassifierExactFinal,
|
||||||
|
ClassifierStats.ExactFinalCycles);
|
||||||
|
}
|
||||||
|
if (ClassifierStats.RoomTailQueries > 0)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::AddMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::ClassifierRoomTail,
|
||||||
|
0, ClassifierStats.RoomTailQueries);
|
||||||
|
VoxelDensityProfile::AddSampledMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::ClassifierRoomTail,
|
||||||
|
ClassifierStats.RoomTailCycles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Verdict == EVoxelTileClass::Mixed)
|
||||||
|
{
|
||||||
|
MixedBlockIndices.Add(
|
||||||
|
((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX);
|
||||||
|
}
|
||||||
|
if (Verdict == EVoxelTileClass::AllSolid
|
||||||
|
|| Verdict == EVoxelTileClass::AllAir)
|
||||||
|
{
|
||||||
|
// Keep the classifier result as a candidate until the exact density grid
|
||||||
|
// used by this mesher has been filled. ClassifyTile is conservative by
|
||||||
|
// contract, but its proof and the mesher share worker-local caches; a
|
||||||
|
// cache replacement must never turn that contract into a cell skip by
|
||||||
|
// itself. The post-grid sign check below is the final proof consumed by
|
||||||
|
// the cell loop and clears any candidate whose actual MC vertices disagree.
|
||||||
|
BlockSkip[((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX] =
|
||||||
|
Verdict == EVoxelTileClass::AllSolid ? 1 : 2;
|
||||||
|
if (!bHaveUniformBlock)
|
||||||
|
{
|
||||||
|
FirstUniformVerdict = Verdict;
|
||||||
|
bHaveUniformBlock = true;
|
||||||
|
}
|
||||||
|
else if (FirstUniformVerdict != Verdict)
|
||||||
|
{
|
||||||
|
bAllRelevantBlocksSame = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bAllRelevantBlocksUniform = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A conservative whole-tile proof can leave a small block unresolved even when every
|
||||||
|
// other block has the same verdict. Resolve that tail on the exact MC lattice. This uses
|
||||||
|
// the final GetDensityAt field, checks every cell corner, treats zero/non-finite values
|
||||||
|
// as unknown, and never changes the grid resolution or skips an uncertified block.
|
||||||
|
if (bHaveUniformBlock && bAllRelevantBlocksSame && !bAllRelevantBlocksUniform
|
||||||
|
&& MixedBlockIndices.Num() <= 4)
|
||||||
|
{
|
||||||
|
int32 RemainingMixedBlocks = MixedBlockIndices.Num();
|
||||||
|
for (const int32 MixedBlockIndex : MixedBlockIndices)
|
||||||
|
{
|
||||||
|
const int32 BlockX = MixedBlockIndex % BlocksPerAxis;
|
||||||
|
const int32 BlockY = (MixedBlockIndex / BlocksPerAxis) % BlocksPerAxis;
|
||||||
|
const int32 BlockZ = MixedBlockIndex / (BlocksPerAxis * BlocksPerAxis);
|
||||||
|
const FIntVector BlockOrigin = OriginVoxels + FIntVector(
|
||||||
|
BlockX * BlockCells * Step,
|
||||||
|
BlockY * BlockCells * Step,
|
||||||
|
BlockZ * BlockCells * Step);
|
||||||
|
|
||||||
|
bool bAllSolid = true;
|
||||||
|
bool bAllAir = true;
|
||||||
|
for (int32 Z = 0; Z <= BlockCells && (bAllSolid || bAllAir); ++Z)
|
||||||
|
{
|
||||||
|
for (int32 Y = 0; Y <= BlockCells && (bAllSolid || bAllAir); ++Y)
|
||||||
|
{
|
||||||
|
for (int32 X = 0; X <= BlockCells; ++X)
|
||||||
|
{
|
||||||
|
const float Density = Generator->GetDensityAt(
|
||||||
|
BlockOrigin.X + X * Step,
|
||||||
|
BlockOrigin.Y + Y * Step,
|
||||||
|
BlockOrigin.Z + Z * Step);
|
||||||
|
if (!VoxelMath::IsFinite(Density) || Density == 0.0f)
|
||||||
|
{
|
||||||
|
bAllSolid = false;
|
||||||
|
bAllAir = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
bAllSolid &= Density < 0.0f;
|
||||||
|
bAllAir &= Density > 0.0f;
|
||||||
|
if (!bAllSolid && !bAllAir)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVoxelTileClass ExactVerdict = bAllSolid
|
||||||
|
? EVoxelTileClass::AllSolid
|
||||||
|
: (bAllAir ? EVoxelTileClass::AllAir : EVoxelTileClass::Mixed);
|
||||||
|
if (ExactVerdict == EVoxelTileClass::AllSolid
|
||||||
|
|| ExactVerdict == EVoxelTileClass::AllAir)
|
||||||
|
{
|
||||||
|
BlockSkip[MixedBlockIndex] =
|
||||||
|
ExactVerdict == EVoxelTileClass::AllSolid ? 1 : 2;
|
||||||
|
--RemainingMixedBlocks;
|
||||||
|
if (ExactVerdict != FirstUniformVerdict)
|
||||||
|
{
|
||||||
|
bAllRelevantBlocksSame = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bAllRelevantBlocksUniform = RemainingMixedBlocks == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every relevant block has independently proved the same sign. This is a complete
|
||||||
|
// certificate for the tile's MC lattice: the block boxes share their boundary vertices,
|
||||||
|
// so equal signs on both sides cannot hide a surface between blocks. The whole-tile
|
||||||
|
// ClassifyTile path already relies on this same non-Mixed proof contract. Return before
|
||||||
|
// allocating the density grid; no samples are skipped for a tile that can contain
|
||||||
|
// geometry, and the final geometry remains byte-identical because this path emits none.
|
||||||
|
if (bHaveUniformBlock && bAllRelevantBlocksSame && bAllRelevantBlocksUniform)
|
||||||
|
{
|
||||||
|
return MeshData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mixed or differently signed block set still needs the exact mesher grid. Classifier
|
||||||
|
// results remain candidates only in that case; the grid validation below keeps the cell
|
||||||
|
// skip independent of worker-local cache state and preserves geometry byte-for-byte.
|
||||||
|
}
|
||||||
|
|
||||||
// Réutilise le tampon entre tuiles (thread_local) : SetNumUninitialized garde la
|
// Réutilise le tampon entre tuiles (thread_local) : SetNumUninitialized garde la
|
||||||
// capacité, donc plus de malloc/free de ~170 Ko (35³ floats) par tuile.
|
// capacité, donc plus de malloc/free de ~170 Ko (35³ floats) par tuile.
|
||||||
static thread_local TArray<float> DensityGrid;
|
static thread_local TArray<float> DensityGrid;
|
||||||
DensityGrid.SetNumUninitialized(MDim * MDim * MDim);
|
DensityGrid.SetNumUninitialized(MDim * MDim * MDim);
|
||||||
|
const FVoxelSharedDensityGrid* ReuseGrid = SharedDensityGrid;
|
||||||
|
const int32 SharedDeltaX = ReuseGrid
|
||||||
|
? (OriginVoxels.X - ReuseGrid->OriginVoxels.X) / Step : 0;
|
||||||
|
const int32 SharedDeltaY = ReuseGrid
|
||||||
|
? (OriginVoxels.Y - ReuseGrid->OriginVoxels.Y) / Step : 0;
|
||||||
|
const int32 SharedDeltaZ = ReuseGrid
|
||||||
|
? (OriginVoxels.Z - ReuseGrid->OriginVoxels.Z) / Step : 0;
|
||||||
|
const bool bUseSharedDensityGrid = ReuseGrid
|
||||||
|
&& ReuseGrid->Step == Step
|
||||||
|
&& ReuseGrid->Dim > 0
|
||||||
|
&& ReuseGrid->Samples.Num() == ReuseGrid->Dim * ReuseGrid->Dim * ReuseGrid->Dim
|
||||||
|
&& (OriginVoxels.X - ReuseGrid->OriginVoxels.X) % Step == 0
|
||||||
|
&& (OriginVoxels.Y - ReuseGrid->OriginVoxels.Y) % Step == 0
|
||||||
|
&& (OriginVoxels.Z - ReuseGrid->OriginVoxels.Z) % Step == 0
|
||||||
|
&& SharedDeltaX - 1 >= 0
|
||||||
|
&& SharedDeltaY - 1 >= 0
|
||||||
|
&& SharedDeltaZ - 1 >= 0
|
||||||
|
&& SharedDeltaX + GridDim < ReuseGrid->Dim
|
||||||
|
&& SharedDeltaY + GridDim < ReuseGrid->Dim
|
||||||
|
&& SharedDeltaZ + GridDim < ReuseGrid->Dim;
|
||||||
// Bande de strate : seules les rangées Z réellement lues (cellules CzLo..CzHi + marges de
|
// Bande de strate : seules les rangées Z réellement lues (cellules CzLo..CzHi + marges de
|
||||||
// gradient) sont échantillonnées — le reste du tampon reste non initialisé et non lu.
|
// gradient) sont échantillonnées — le reste du tampon reste non initialisé et non lu.
|
||||||
|
VoxelDensityProfile::FScopedTimer MesherDensityGridTimer(
|
||||||
|
VoxelDensityProfile::EBucket::MesherDensityGrid);
|
||||||
|
const bool bUseOperatorBlock = !bUseSharedDensityGrid
|
||||||
|
&& GVoxelForgeUseOperatorBlock != 0;
|
||||||
|
if (bUseOperatorBlock)
|
||||||
|
{
|
||||||
|
// The grid includes one sample of halo on every side. The generator groups these exact
|
||||||
|
// lattice points by chunk key and lets the prepared op stack fill each group once.
|
||||||
|
Generator->BeginDensityBlock(
|
||||||
|
OriginVoxels + FIntVector(-Step, -Step, GzLo * Step),
|
||||||
|
Step, MDim, MDim, GzHi - GzLo + 1);
|
||||||
|
}
|
||||||
for (int32 gz = GzLo; gz <= GzHi; gz++)
|
for (int32 gz = GzLo; gz <= GzHi; gz++)
|
||||||
{
|
{
|
||||||
|
if (ShouldAbortWork())
|
||||||
|
{
|
||||||
|
if (bUseOperatorBlock) { Generator->EndDensityBlock(); }
|
||||||
|
return FVoxelMeshData();
|
||||||
|
}
|
||||||
for (int32 gy = -1; gy <= GridDim; gy++)
|
for (int32 gy = -1; gy <= GridDim; gy++)
|
||||||
{
|
{
|
||||||
|
if (ShouldAbortWork())
|
||||||
|
{
|
||||||
|
if (bUseOperatorBlock) { Generator->EndDensityBlock(); }
|
||||||
|
return FVoxelMeshData();
|
||||||
|
}
|
||||||
for (int32 gx = -1; gx <= GridDim; gx++)
|
for (int32 gx = -1; gx <= GridDim; gx++)
|
||||||
|
{
|
||||||
|
const int32 LocalIndex = ((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1);
|
||||||
|
if (bUseSharedDensityGrid)
|
||||||
|
{
|
||||||
|
const int32 SharedX = SharedDeltaX + gx;
|
||||||
|
const int32 SharedY = SharedDeltaY + gy;
|
||||||
|
const int32 SharedZ = SharedDeltaZ + gz;
|
||||||
|
DensityGrid[LocalIndex] = ReuseGrid->Samples[
|
||||||
|
(SharedZ * ReuseGrid->Dim + SharedY) * ReuseGrid->Dim + SharedX];
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
// World voxel = tile origin + grid offset scaled by the cell size (Step).
|
// World voxel = tile origin + grid offset scaled by the cell size (Step).
|
||||||
DensityGrid[((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1)] =
|
const int32 ReachBlockX = FMath::Clamp(gx, 0, CellsPerAxis - 1)
|
||||||
Generator->GetDensityAt(
|
/ VoxelGenLOD::TileReachBlockCells;
|
||||||
|
const int32 ReachBlockY = FMath::Clamp(gy, 0, CellsPerAxis - 1)
|
||||||
|
/ VoxelGenLOD::TileReachBlockCells;
|
||||||
|
const int32 ReachBlockZ = FMath::Clamp(gz, 0, CellsPerAxis - 1)
|
||||||
|
/ VoxelGenLOD::TileReachBlockCells;
|
||||||
|
VoxelGenLOD::TileReachBlockIndex =
|
||||||
|
((ReachBlockZ * ReachBlocksPerAxis) + ReachBlockY)
|
||||||
|
* ReachBlocksPerAxis + ReachBlockX;
|
||||||
|
DensityGrid[LocalIndex] = Generator->GetDensityAt(
|
||||||
OriginVoxels.X + gx * Step,
|
OriginVoxels.X + gx * Step,
|
||||||
OriginVoxels.Y + gy * Step,
|
OriginVoxels.Y + gy * Step,
|
||||||
OriginVoxels.Z + gz * Step);
|
OriginVoxels.Z + gz * Step);
|
||||||
|
if (OutDensitySampleCount)
|
||||||
|
{
|
||||||
|
++(*OutDensitySampleCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (VoxelDensityProfile::AreCountersEnabled())
|
||||||
|
{
|
||||||
|
const uint64 GridSampleCount = static_cast<uint64>(GzHi - GzLo + 1)
|
||||||
|
* static_cast<uint64>(MDim)
|
||||||
|
* static_cast<uint64>(MDim);
|
||||||
|
VoxelDensityProfile::AddCounter(
|
||||||
|
VoxelDensityProfile::ECounter::MesherDensityGridBytes,
|
||||||
|
GridSampleCount * sizeof(float));
|
||||||
|
if (bUseSharedDensityGrid)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::AddCounter(
|
||||||
|
VoxelDensityProfile::ECounter::MesherSharedGridReadBytes,
|
||||||
|
GridSampleCount * sizeof(float));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bUseOperatorBlock)
|
||||||
|
{
|
||||||
|
Generator->EndDensityBlock();
|
||||||
|
}
|
||||||
|
MesherDensityGridTimer.End();
|
||||||
|
|
||||||
|
if (bUseProofBlocks)
|
||||||
|
{
|
||||||
|
// Finalize every block candidate against the exact density samples that the marching-
|
||||||
|
// cubes loop will consume. This is intentionally redundant with ClassifyTile: it is a
|
||||||
|
// cheap soundness fence around the proof/cache hand-off. Unknown, zero, or opposite-sign
|
||||||
|
// vertices clear the candidate, so an unproven block pays the ordinary cell path.
|
||||||
|
constexpr int32 BlockCells = 8;
|
||||||
|
for (int32 BlockZ = 0; BlockZ < BlocksPerAxis; ++BlockZ)
|
||||||
|
{
|
||||||
|
const int32 BlockMinZ = BlockZ * BlockCells;
|
||||||
|
const int32 BlockMaxZ = FMath::Min(CellsPerAxis - 1,
|
||||||
|
BlockMinZ + BlockCells - 1);
|
||||||
|
if (BlockMaxZ < CzLo || BlockMinZ > CzHi)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (int32 BlockY = 0; BlockY < BlocksPerAxis; ++BlockY)
|
||||||
|
for (int32 BlockX = 0; BlockX < BlocksPerAxis; ++BlockX)
|
||||||
|
{
|
||||||
|
const int32 BlockIndex =
|
||||||
|
((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX;
|
||||||
|
const uint8 Candidate = BlockSkip[BlockIndex];
|
||||||
|
if (Candidate == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int32 BlockMaxX = FMath::Min(CellsPerAxis - 1,
|
||||||
|
BlockX * BlockCells + BlockCells - 1);
|
||||||
|
const int32 BlockMaxY = FMath::Min(CellsPerAxis - 1,
|
||||||
|
BlockY * BlockCells + BlockCells - 1);
|
||||||
|
bool bUniform = true;
|
||||||
|
for (int32 Z = BlockMinZ; Z <= BlockMaxZ + 1 && bUniform; ++Z)
|
||||||
|
for (int32 Y = BlockY * BlockCells; Y <= BlockMaxY + 1 && bUniform; ++Y)
|
||||||
|
for (int32 X = BlockX * BlockCells; X <= BlockMaxX + 1; ++X)
|
||||||
|
{
|
||||||
|
const float Density = DensityGrid[
|
||||||
|
((Z + 1) * MDim + (Y + 1)) * MDim + (X + 1)];
|
||||||
|
if (!VoxelMath::IsFinite(Density)
|
||||||
|
|| Density == 0.0f
|
||||||
|
|| (Candidate == 1 ? Density >= 0.0f : Density <= 0.0f))
|
||||||
|
{
|
||||||
|
bUniform = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!bUniform)
|
||||||
|
{
|
||||||
|
BlockSkip[BlockIndex] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VoxelDensityProfile::AreCountersEnabled())
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::AddCounter(
|
||||||
|
Candidate == 1
|
||||||
|
? VoxelDensityProfile::ECounter::MesherBlockAllSolid
|
||||||
|
: VoxelDensityProfile::ECounter::MesherBlockAllAir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,12 +876,27 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
|
|||||||
static thread_local TArray<int32> CapTris;
|
static thread_local TArray<int32> CapTris;
|
||||||
GroundTris.Reset();
|
GroundTris.Reset();
|
||||||
CapTris.Reset();
|
CapTris.Reset();
|
||||||
|
const bool bProfileMesher = VoxelDensityProfile::IsCycleTimingEnabled();
|
||||||
|
uint64 CellClassificationCalls = 0;
|
||||||
|
uint64 SurfaceCellCalls = 0;
|
||||||
for (int32 cz = CzLo; cz <= CzHi; cz++) // bande de strate : cf. CzLo/CzHi plus haut
|
for (int32 cz = CzLo; cz <= CzHi; cz++) // bande de strate : cf. CzLo/CzHi plus haut
|
||||||
{
|
{
|
||||||
|
if (ShouldAbortWork()) return FVoxelMeshData();
|
||||||
for (int32 cy = 0; cy < CellsPerAxis; cy++)
|
for (int32 cy = 0; cy < CellsPerAxis; cy++)
|
||||||
{
|
{
|
||||||
|
if (ShouldAbortWork()) return FVoxelMeshData();
|
||||||
for (int32 cx = 0; cx < CellsPerAxis; cx++)
|
for (int32 cx = 0; cx < CellsPerAxis; cx++)
|
||||||
{
|
{
|
||||||
|
if (bUseProofBlocks
|
||||||
|
&& BlockSkip[((cz / 8) * BlocksPerAxis + (cy / 8)) * BlocksPerAxis + (cx / 8)] != 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bool bSampleCell = bProfileMesher
|
||||||
|
&& VoxelDensityProfile::ShouldSample(
|
||||||
|
VoxelDensityProfile::EBucket::MesherCellClassification);
|
||||||
|
const uint64 ClassificationStart = bSampleCell
|
||||||
|
? FPlatformTime::Cycles64() : 0;
|
||||||
// PASSE 1 : densités aux 8 coins + index de cas MC SEULEMENT.
|
// PASSE 1 : densités aux 8 coins + index de cas MC SEULEMENT.
|
||||||
// ~70% des cellules d'un chunk sont tout-roc ou tout-air (aucune surface) ;
|
// ~70% des cellules d'un chunk sont tout-roc ou tout-air (aucune surface) ;
|
||||||
// on les rejette ICI, AVANT de payer les 8 positions + 8 gradients (48 lectures
|
// on les rejette ICI, AVANT de payer les 8 positions + 8 gradients (48 lectures
|
||||||
@@ -309,9 +913,20 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
|
|||||||
if (D >= IsoLevel) CaseIndex |= (1 << i);
|
if (D >= IsoLevel) CaseIndex |= (1 << i);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (MCEdgeTable[CaseIndex] == 0) continue; // Pas de surface ici → skip
|
const bool bHasSurface = MCEdgeTable[CaseIndex] != 0;
|
||||||
|
if (bSampleCell)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::AddSampledMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::MesherCellClassification,
|
||||||
|
FPlatformTime::Cycles64() - ClassificationStart);
|
||||||
|
}
|
||||||
|
++CellClassificationCalls;
|
||||||
|
if (!bHasSurface) continue; // Pas de surface ici → skip
|
||||||
|
++SurfaceCellCalls;
|
||||||
|
|
||||||
// PASSE 2 : positions + gradients aux 8 coins (uniquement si surface présente).
|
// PASSE 2 : positions + gradients aux 8 coins (uniquement si surface présente).
|
||||||
|
const uint64 GradientStart = bSampleCell
|
||||||
|
? FPlatformTime::Cycles64() : 0;
|
||||||
FVector Positions[8];
|
FVector Positions[8];
|
||||||
FVector Gradients[8];
|
FVector Gradients[8];
|
||||||
for (int32 i = 0; i < 8; i++)
|
for (int32 i = 0; i < 8; i++)
|
||||||
@@ -323,10 +938,18 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
|
|||||||
+ FVector(GX * Step, GY * Step, GZ * Step) * VOXEL_SIZE;
|
+ FVector(GX * Step, GY * Step, GZ * Step) * VOXEL_SIZE;
|
||||||
Gradients[i] = GradAt(GX, GY, GZ);
|
Gradients[i] = GradAt(GX, GY, GZ);
|
||||||
}
|
}
|
||||||
|
if (bSampleCell)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::AddSampledMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::MesherGradientNormals,
|
||||||
|
FPlatformTime::Cycles64() - GradientStart);
|
||||||
|
}
|
||||||
|
|
||||||
// Interpolation des positions + normales sur les arêtes traversées. t = point de
|
// Interpolation des positions + normales sur les arêtes traversées. t = point de
|
||||||
// traversée de l'iso entre les deux coins (clampé, milieu si densités quasi-égales) ;
|
// traversée de l'iso entre les deux coins (clampé, milieu si densités quasi-égales) ;
|
||||||
// la normale interpole les gradients de coin par le même t.
|
// la normale interpole les gradients de coin par le même t.
|
||||||
|
const uint64 VertexInterpolationStart = bSampleCell
|
||||||
|
? FPlatformTime::Cycles64() : 0;
|
||||||
FVector EdgeVertices[12];
|
FVector EdgeVertices[12];
|
||||||
FVector EdgeNormals[12];
|
FVector EdgeNormals[12];
|
||||||
for (int32 i = 0; i < 12; i++)
|
for (int32 i = 0; i < 12; i++)
|
||||||
@@ -343,9 +966,17 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
|
|||||||
EdgeNormals[i] = Gradients[A] + T * (Gradients[B] - Gradients[A]);
|
EdgeNormals[i] = Gradients[A] + T * (Gradients[B] - Gradients[A]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (bSampleCell)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::AddSampledMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::MesherVertexInterpolation,
|
||||||
|
FPlatformTime::Cycles64() - VertexInterpolationStart);
|
||||||
|
}
|
||||||
|
|
||||||
// Génère les triangles avec vertices dédupliqués.
|
// Génère les triangles avec vertices dédupliqués.
|
||||||
// Ordre 0, 2, 1 (pas 0, 1, 2) pour le winding attendu par RealtimeMesh.
|
// Ordre 0, 2, 1 (pas 0, 1, 2) pour le winding attendu par RealtimeMesh.
|
||||||
|
const uint64 StreamBuildingStart = bSampleCell
|
||||||
|
? FPlatformTime::Cycles64() : 0;
|
||||||
for (int32 i = 0; MCTriTable[CaseIndex][i] != -1; i += 3)
|
for (int32 i = 0; MCTriTable[CaseIndex][i] != -1; i += 3)
|
||||||
{
|
{
|
||||||
const int32 E0 = MCTriTable[CaseIndex][i];
|
const int32 E0 = MCTriTable[CaseIndex][i];
|
||||||
@@ -364,9 +995,34 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
|
|||||||
Dst.Add(Idx2);
|
Dst.Add(Idx2);
|
||||||
Dst.Add(Idx1);
|
Dst.Add(Idx1);
|
||||||
}
|
}
|
||||||
|
if (bSampleCell)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::AddSampledMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::MesherStreamBuilding,
|
||||||
|
FPlatformTime::Cycles64() - StreamBuildingStart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bProfileMesher)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::AddMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::MesherCellClassification,
|
||||||
|
0, CellClassificationCalls);
|
||||||
|
VoxelDensityProfile::AddMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::MesherGradientNormals,
|
||||||
|
0, SurfaceCellCalls);
|
||||||
|
VoxelDensityProfile::AddMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::MesherVertexInterpolation,
|
||||||
|
0, SurfaceCellCalls);
|
||||||
|
VoxelDensityProfile::AddMeasurement(
|
||||||
|
VoxelDensityProfile::EBucket::MesherStreamBuilding,
|
||||||
|
0, SurfaceCellCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
VoxelDensityProfile::FScopedTimer MesherFinalisationTimer(
|
||||||
|
VoxelDensityProfile::EBucket::MesherOther);
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// SKIRTS — boucheurs de fissures aux coutures de LOD (clipmap)
|
// SKIRTS — boucheurs de fissures aux coutures de LOD (clipmap)
|
||||||
@@ -448,6 +1104,26 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
|
|||||||
MeshData.Triangles.Append(CapTris);
|
MeshData.Triangles.Append(CapTris);
|
||||||
MeshData.NumCeilingTriangles = CapTris.Num() / 3;
|
MeshData.NumCeilingTriangles = CapTris.Num() / 3;
|
||||||
|
|
||||||
|
if (VoxelDensityProfile::AreCountersEnabled())
|
||||||
|
{
|
||||||
|
const uint64 OutputArrayBytes = static_cast<uint64>(MeshData.Vertices.Num()) * sizeof(FVector)
|
||||||
|
+ static_cast<uint64>(MeshData.Normals.Num()) * sizeof(FVector)
|
||||||
|
+ static_cast<uint64>(MeshData.UVs.Num()) * sizeof(FVector2D)
|
||||||
|
+ static_cast<uint64>(MeshData.Colors.Num()) * sizeof(FColor)
|
||||||
|
+ static_cast<uint64>(MeshData.Triangles.Num()) * sizeof(int32);
|
||||||
|
const uint64 OutputAllocatedBytes = static_cast<uint64>(MeshData.Vertices.GetAllocatedSize())
|
||||||
|
+ static_cast<uint64>(MeshData.Normals.GetAllocatedSize())
|
||||||
|
+ static_cast<uint64>(MeshData.UVs.GetAllocatedSize())
|
||||||
|
+ static_cast<uint64>(MeshData.Colors.GetAllocatedSize())
|
||||||
|
+ static_cast<uint64>(MeshData.Triangles.GetAllocatedSize());
|
||||||
|
VoxelDensityProfile::AddCounter(
|
||||||
|
VoxelDensityProfile::ECounter::MesherOutputArrayBytes,
|
||||||
|
OutputArrayBytes);
|
||||||
|
VoxelDensityProfile::AddCounter(
|
||||||
|
VoxelDensityProfile::ECounter::MesherOutputAllocatedBytes,
|
||||||
|
OutputAllocatedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
return MeshData;
|
return MeshData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,6 +1174,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateSheetMesh(FIntVector OriginVox
|
|||||||
SheetCols.SetNumUninitialized(MDim * MDim);
|
SheetCols.SetNumUninitialized(MDim * MDim);
|
||||||
for (int32 gy = -1; gy <= GridDim; ++gy)
|
for (int32 gy = -1; gy <= GridDim; ++gy)
|
||||||
{
|
{
|
||||||
|
if (ShouldAbortWork()) return FVoxelMeshData();
|
||||||
for (int32 gx = -1; gx <= GridDim; ++gx)
|
for (int32 gx = -1; gx <= GridDim; ++gx)
|
||||||
{
|
{
|
||||||
float Tz = 0.0f, Cz = 0.0f;
|
float Tz = 0.0f, Cz = 0.0f;
|
||||||
@@ -572,6 +1249,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateSheetMesh(FIntVector OriginVox
|
|||||||
|
|
||||||
for (int32 cy = 0; cy < CellsXY; ++cy)
|
for (int32 cy = 0; cy < CellsXY; ++cy)
|
||||||
{
|
{
|
||||||
|
if (ShouldAbortWork()) return FVoxelMeshData();
|
||||||
for (int32 cx = 0; cx < CellsXY; ++cx)
|
for (int32 cx = 0; cx < CellsXY; ++cx)
|
||||||
{
|
{
|
||||||
if (CellInHole(cx, cy)) continue; // couverte par les coquilles MC proches
|
if (CellInHole(cx, cy)) continue; // couverte par les coquilles MC proches
|
||||||
@@ -638,6 +1316,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateSheetMesh(FIntVector OriginVox
|
|||||||
// (pas dans le trou XY) — sinon mur flottant sans surface.
|
// (pas dans le trou XY) — sinon mur flottant sans surface.
|
||||||
for (int32 c = 0; c < CellsXY; ++c)
|
for (int32 c = 0; c < CellsXY; ++c)
|
||||||
{
|
{
|
||||||
|
if (ShouldAbortWork()) return FVoxelMeshData();
|
||||||
for (int32 Pass = 0; Pass < 2; ++Pass)
|
for (int32 Pass = 0; Pass < 2; ++Pass)
|
||||||
{
|
{
|
||||||
const bool bCap = (Pass == 1);
|
const bool bCap = (Pass == 1);
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#include "VoxelSeasonAsset.h"
|
||||||
|
|
||||||
|
#include "VoxelSeasonManifest.h"
|
||||||
|
#include "VoxelDensityOpStack.h"
|
||||||
|
|
||||||
|
#if WITH_EDITOR
|
||||||
|
#include "Misc/FileHelper.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
bool ValidateRuntimeRecipes(const FVoxelSeasonManifest& Manifest, FString& OutReport)
|
||||||
|
{
|
||||||
|
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
|
||||||
|
{
|
||||||
|
if (!Strate.bUsesRecipe) continue;
|
||||||
|
FVoxelOpStack ValidationStack;
|
||||||
|
FVoxelOpContext ValidationContext;
|
||||||
|
FString BuildError;
|
||||||
|
if (!VF_BuildSeasonStrateStack(
|
||||||
|
Strate, Manifest.OriginSpineRadius, nullptr,
|
||||||
|
ValidationStack, ValidationContext, &BuildError))
|
||||||
|
{
|
||||||
|
OutReport = FString::Printf(
|
||||||
|
TEXT("season slot %d recipe is not materialisable: %s"),
|
||||||
|
Strate.DepthIndex, *BuildError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FPrimaryAssetId UVoxelSeasonAsset::GetPrimaryAssetId() const
|
||||||
|
{
|
||||||
|
return FPrimaryAssetId(TEXT("VoxelSeason"), GetFName());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UVoxelSeasonAsset::LoadManifest(FVoxelSeasonManifest& OutManifest, FString& OutReport) const
|
||||||
|
{
|
||||||
|
if (ManifestJson.IsEmpty())
|
||||||
|
{
|
||||||
|
OutReport = TEXT("season asset contains no imported manifest JSON");
|
||||||
|
OutManifest = FVoxelSeasonManifest();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VF_DeserializeVoxelSeasonManifest(ManifestJson, OutManifest, OutReport))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!OutManifest.ContentHash.Equals(ManifestContentHash, ESearchCase::IgnoreCase)
|
||||||
|
|| OutManifest.SchemaVersion != SchemaVersion
|
||||||
|
|| OutManifest.Season != SeasonNumber
|
||||||
|
|| OutManifest.Seed != SeasonSeed
|
||||||
|
|| FMemory::Memcmp(&OutManifest.OriginSpineRadius,
|
||||||
|
&OriginSpineRadius, sizeof(float)) != 0
|
||||||
|
|| FMemory::Memcmp(&OutManifest.WorldRadiusVoxels,
|
||||||
|
&WorldRadiusVoxels, sizeof(float)) != 0)
|
||||||
|
{
|
||||||
|
OutReport = TEXT("season asset metadata does not match its embedded manifest; re-import the JSON");
|
||||||
|
OutManifest.bValid = false;
|
||||||
|
OutManifest.Error = OutReport;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!ValidateRuntimeRecipes(OutManifest, OutReport))
|
||||||
|
{
|
||||||
|
OutManifest.bValid = false;
|
||||||
|
OutManifest.Error = OutReport;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UVoxelSeasonAsset::SetManifestJson(const FString& InManifestJson, FString& OutReport)
|
||||||
|
{
|
||||||
|
FVoxelSeasonManifest Parsed;
|
||||||
|
if (!VF_DeserializeVoxelSeasonManifest(InManifestJson, Parsed, OutReport))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!ValidateRuntimeRecipes(Parsed, OutReport))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Modify();
|
||||||
|
ManifestJson = InManifestJson;
|
||||||
|
SchemaVersion = Parsed.SchemaVersion;
|
||||||
|
SeasonNumber = Parsed.Season;
|
||||||
|
SeasonSeed = Parsed.Seed;
|
||||||
|
OriginSpineRadius = Parsed.OriginSpineRadius;
|
||||||
|
WorldRadiusVoxels = Parsed.WorldRadiusVoxels;
|
||||||
|
ManifestContentHash = Parsed.ContentHash;
|
||||||
|
ReferencedDefinitions.Reset();
|
||||||
|
TSet<FString> SeenDefinitionPaths;
|
||||||
|
for (const FVoxelSeasonStrate& Strate : Parsed.Strates)
|
||||||
|
{
|
||||||
|
if (!Strate.SourceDefinitionPath.IsEmpty()
|
||||||
|
&& !SeenDefinitionPaths.Contains(Strate.SourceDefinitionPath))
|
||||||
|
{
|
||||||
|
SeenDefinitionPaths.Add(Strate.SourceDefinitionPath);
|
||||||
|
ReferencedDefinitions.Add(TSoftObjectPtr<UVoxelStrateDefinition>(
|
||||||
|
FSoftObjectPath(Strate.SourceDefinitionPath)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MarkPackageDirty();
|
||||||
|
OutReport = FString::Printf(
|
||||||
|
TEXT("imported season %d seed %d (%d strates, hash %s)"),
|
||||||
|
SeasonNumber, SeasonSeed, Parsed.Strates.Num(), *ManifestContentHash);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelSeasonAsset::ImportSeasonManifestJson()
|
||||||
|
{
|
||||||
|
#if WITH_EDITOR
|
||||||
|
FString JsonText;
|
||||||
|
FString Report;
|
||||||
|
if (SourceManifestJson.FilePath.IsEmpty()
|
||||||
|
|| !FFileHelper::LoadFileToString(JsonText, *SourceManifestJson.FilePath))
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] Could not read manifest JSON '%s'."),
|
||||||
|
*SourceManifestJson.FilePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!SetManifestJson(JsonText, Report))
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] Import failed: %s"), *Report);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
UE_LOG(LogTemp, Log, TEXT("[VoxelSeasonAsset] %s"), *Report);
|
||||||
|
#else
|
||||||
|
UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] JSON import is editor-only."));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,51 @@
|
|||||||
|
#include "VoxelSettings.h"
|
||||||
|
|
||||||
|
#include "VoxelSeasonAsset.h"
|
||||||
|
|
||||||
|
int32 UVoxelSettings::GetEffectiveWorldSeed() const
|
||||||
|
{
|
||||||
|
if (!Season.IsNull())
|
||||||
|
{
|
||||||
|
if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous())
|
||||||
|
{
|
||||||
|
return Asset->GetSeasonSeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
float UVoxelSettings::GetEffectiveOriginSpineRadius() const
|
||||||
|
{
|
||||||
|
if (!Season.IsNull())
|
||||||
|
{
|
||||||
|
if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous())
|
||||||
|
{
|
||||||
|
return Asset->GetOriginSpineRadius();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return OriginSpineRadius;
|
||||||
|
}
|
||||||
|
|
||||||
|
float UVoxelSettings::GetEffectiveWorldRadiusVoxels() const
|
||||||
|
{
|
||||||
|
if (!Season.IsNull())
|
||||||
|
{
|
||||||
|
if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous())
|
||||||
|
{
|
||||||
|
return Asset->GetWorldRadiusVoxels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return WorldRadiusVoxels;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 UVoxelSettings::GetEffectiveSeasonNumber() const
|
||||||
|
{
|
||||||
|
if (!Season.IsNull())
|
||||||
|
{
|
||||||
|
if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous())
|
||||||
|
{
|
||||||
|
return Asset->GetSeasonNumber();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CurrentSeason;
|
||||||
|
}
|
||||||
@@ -0,0 +1,919 @@
|
|||||||
|
// VoxelStackSampler.cpp
|
||||||
|
// Fixed-storage raw stack sampling for VoxelForge generation workers.
|
||||||
|
|
||||||
|
#include "VoxelStackSampler.h"
|
||||||
|
|
||||||
|
#include "Containers/Array.h"
|
||||||
|
#include "HAL/FileManager.h"
|
||||||
|
#include "HAL/PlatformProcess.h"
|
||||||
|
#include "HAL/PlatformStackWalk.h"
|
||||||
|
#include "HAL/PlatformTime.h"
|
||||||
|
#include "HAL/PlatformTLS.h"
|
||||||
|
#include "HAL/Runnable.h"
|
||||||
|
#include "HAL/RunnableThread.h"
|
||||||
|
#include "HAL/UnrealMemory.h"
|
||||||
|
#include "Math/UnrealMathUtility.h"
|
||||||
|
#include "Containers/StringConv.h"
|
||||||
|
#include "Misc/FileHelper.h"
|
||||||
|
#include "Misc/Guid.h"
|
||||||
|
#include "Misc/Paths.h"
|
||||||
|
#include "Misc/ScopeLock.h"
|
||||||
|
#include "Serialization/Archive.h"
|
||||||
|
#include "Templates/UniquePtr.h"
|
||||||
|
|
||||||
|
#if PLATFORM_WINDOWS
|
||||||
|
#include "Windows/WindowsPlatformStackWalk.h"
|
||||||
|
#include "Windows/AllowWindowsPlatformTypes.h"
|
||||||
|
THIRD_PARTY_INCLUDES_START
|
||||||
|
#include <Psapi.h>
|
||||||
|
THIRD_PARTY_INCLUDES_END
|
||||||
|
#include "Windows/HideWindowsPlatformTypes.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr uint64 RegistrationReservedBit = 1ull << 63;
|
||||||
|
constexpr uint32 MaxRegisteredThreads = 128;
|
||||||
|
|
||||||
|
std::atomic<bool> GVoxelStackSamplerEnabled(false);
|
||||||
|
|
||||||
|
// The registry is deliberately fixed-size and lock-free from the worker's point of view. A
|
||||||
|
// reserved state prevents two registering workers from racing on ThreadId before a slot becomes
|
||||||
|
// visible to the sampler.
|
||||||
|
struct FRegisteredThreadSlot
|
||||||
|
{
|
||||||
|
std::atomic<uint64> State{0};
|
||||||
|
std::atomic<uint64> ThreadId{0};
|
||||||
|
std::atomic<int32> LODLevel{INDEX_NONE};
|
||||||
|
};
|
||||||
|
|
||||||
|
thread_local int32 GTlsRegisteredSlot = INDEX_NONE;
|
||||||
|
thread_local uint64 GTlsRegistrationGeneration = 0;
|
||||||
|
thread_local int32 GTlsRegistrationDepth = 0;
|
||||||
|
thread_local int32 GTlsRegisteredLODLevel = INDEX_NONE;
|
||||||
|
thread_local void* GTlsRegisteredImpl = nullptr;
|
||||||
|
thread_local uint64 GTlsSamplerSessionGeneration = 0;
|
||||||
|
|
||||||
|
std::atomic<uint64> GNextSamplerSessionGeneration(1);
|
||||||
|
|
||||||
|
void ResetRegistrationTls()
|
||||||
|
{
|
||||||
|
GTlsRegisteredSlot = INDEX_NONE;
|
||||||
|
GTlsRegistrationGeneration = 0;
|
||||||
|
GTlsRegistrationDepth = 0;
|
||||||
|
GTlsRegisteredLODLevel = INDEX_NONE;
|
||||||
|
GTlsRegisteredImpl = nullptr;
|
||||||
|
GTlsSamplerSessionGeneration = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
FString SanitizeLabel(const FString& InLabel)
|
||||||
|
{
|
||||||
|
FString Result;
|
||||||
|
for (const TCHAR Character : InLabel)
|
||||||
|
{
|
||||||
|
const bool bAlphaNumeric =
|
||||||
|
(Character >= TEXT('a') && Character <= TEXT('z'))
|
||||||
|
|| (Character >= TEXT('A') && Character <= TEXT('Z'))
|
||||||
|
|| (Character >= TEXT('0') && Character <= TEXT('9'));
|
||||||
|
Result.AppendChar(bAlphaNumeric || Character == TEXT('_') || Character == TEXT('-')
|
||||||
|
? Character : TEXT('_'));
|
||||||
|
}
|
||||||
|
return Result.IsEmpty() ? TEXT("run") : Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WriteUtf8(FArchive& Archive, const FString& Text)
|
||||||
|
{
|
||||||
|
FTCHARToUTF8 Utf8(*Text);
|
||||||
|
if (Utf8.Length() > 0)
|
||||||
|
{
|
||||||
|
Archive.Serialize(const_cast<ANSICHAR*>(Utf8.Get()), Utf8.Length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if PLATFORM_WINDOWS
|
||||||
|
struct FModuleMapRecord
|
||||||
|
{
|
||||||
|
FString Path;
|
||||||
|
uint64 LoadBase = 0;
|
||||||
|
uint32 ImageSize = 0;
|
||||||
|
uint64 FileSize = 0;
|
||||||
|
uint64 LastWriteFileTime = 0;
|
||||||
|
FString PdbGuid;
|
||||||
|
uint32 PdbAge = 0;
|
||||||
|
FString PdbPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
FString ModuleMapField(const FString& InValue)
|
||||||
|
{
|
||||||
|
FString Result = InValue;
|
||||||
|
Result.ReplaceInline(TEXT("\t"), TEXT(" "));
|
||||||
|
Result.ReplaceInline(TEXT("\r"), TEXT(" "));
|
||||||
|
Result.ReplaceInline(TEXT("\n"), TEXT(" "));
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsModuleImageRangeValid(uint32 Offset, uint64 Size, uint32 ImageSize)
|
||||||
|
{
|
||||||
|
return Offset <= ImageSize && Size <= static_cast<uint64>(ImageSize - Offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReadModuleCodeViewIdentity(
|
||||||
|
FModuleMapRecord& Record, const uint8* ModuleBase, uint32 ImageSize)
|
||||||
|
{
|
||||||
|
if (ModuleBase == nullptr || ImageSize < sizeof(IMAGE_DOS_HEADER))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_DOS_HEADER* DosHeader = reinterpret_cast<const IMAGE_DOS_HEADER*>(ModuleBase);
|
||||||
|
if (DosHeader->e_magic != IMAGE_DOS_SIGNATURE || DosHeader->e_lfanew < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32 NtOffset = static_cast<uint32>(DosHeader->e_lfanew);
|
||||||
|
if (!IsModuleImageRangeValid(NtOffset, sizeof(IMAGE_NT_HEADERS), ImageSize))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const IMAGE_NT_HEADERS* NtHeaders = reinterpret_cast<const IMAGE_NT_HEADERS*>(
|
||||||
|
ModuleBase + NtOffset);
|
||||||
|
if (NtHeaders->Signature != IMAGE_NT_SIGNATURE
|
||||||
|
|| NtHeaders->OptionalHeader.NumberOfRvaAndSizes <= IMAGE_DIRECTORY_ENTRY_DEBUG)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_DATA_DIRECTORY& DebugDirectory =
|
||||||
|
NtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG];
|
||||||
|
if (DebugDirectory.VirtualAddress == 0
|
||||||
|
|| DebugDirectory.Size < sizeof(IMAGE_DEBUG_DIRECTORY)
|
||||||
|
|| !IsModuleImageRangeValid(
|
||||||
|
DebugDirectory.VirtualAddress, DebugDirectory.Size, ImageSize))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_DEBUG_DIRECTORY* Entries = reinterpret_cast<const IMAGE_DEBUG_DIRECTORY*>(
|
||||||
|
ModuleBase + DebugDirectory.VirtualAddress);
|
||||||
|
const uint32 EntryCount = DebugDirectory.Size / sizeof(IMAGE_DEBUG_DIRECTORY);
|
||||||
|
for (uint32 Index = 0; Index < EntryCount; ++Index)
|
||||||
|
{
|
||||||
|
const IMAGE_DEBUG_DIRECTORY& Entry = Entries[Index];
|
||||||
|
if (Entry.Type != IMAGE_DEBUG_TYPE_CODEVIEW || Entry.SizeOfData < 24
|
||||||
|
|| Entry.AddressOfRawData == 0
|
||||||
|
|| !IsModuleImageRangeValid(
|
||||||
|
Entry.AddressOfRawData, Entry.SizeOfData, ImageSize))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8* CodeView = ModuleBase + Entry.AddressOfRawData;
|
||||||
|
if (CodeView[0] != 'R' || CodeView[1] != 'S'
|
||||||
|
|| CodeView[2] != 'D' || CodeView[3] != 'S')
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
FGuid Guid;
|
||||||
|
FMemory::Memcpy(&Guid, CodeView + 4, sizeof(Guid));
|
||||||
|
Record.PdbGuid = Guid.ToString();
|
||||||
|
FMemory::Memcpy(&Record.PdbAge, CodeView + 20, sizeof(Record.PdbAge));
|
||||||
|
|
||||||
|
const uint32 PdbPathBytes = Entry.SizeOfData - 24;
|
||||||
|
if (PdbPathBytes > 0)
|
||||||
|
{
|
||||||
|
const ANSICHAR* PdbPathAnsi = reinterpret_cast<const ANSICHAR*>(CodeView + 24);
|
||||||
|
int32 Length = 0;
|
||||||
|
while (Length < static_cast<int32>(PdbPathBytes) && PdbPathAnsi[Length] != '\0')
|
||||||
|
{
|
||||||
|
++Length;
|
||||||
|
}
|
||||||
|
if (Length > 0)
|
||||||
|
{
|
||||||
|
FUTF8ToTCHAR PdbPathUtf8(PdbPathAnsi, Length);
|
||||||
|
Record.PdbPath = FString(PdbPathUtf8.Get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CollectProcessModuleMap(TArray<FModuleMapRecord>& OutRecords, FString& OutError)
|
||||||
|
{
|
||||||
|
OutRecords.Reset();
|
||||||
|
OutError.Reset();
|
||||||
|
|
||||||
|
// Keep PSAPI dynamic, like the engine stack-walk implementation. This records module state
|
||||||
|
// without touching DbgHelp or the process-global symbol engine.
|
||||||
|
void* PsapiHandle = FPlatformProcess::GetDllHandle(TEXT("PSAPI.DLL"));
|
||||||
|
if (PsapiHandle == nullptr)
|
||||||
|
{
|
||||||
|
OutError = TEXT("PSAPI.DLL could not be loaded");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using FEnumProcessModules = BOOL (WINAPI*)(HANDLE, HMODULE*, DWORD, LPDWORD);
|
||||||
|
using FGetModuleFileNameExW = DWORD (WINAPI*)(HANDLE, HMODULE, LPWSTR, DWORD);
|
||||||
|
using FGetModuleInformation = BOOL (WINAPI*)(HANDLE, HMODULE, LPMODULEINFO, DWORD);
|
||||||
|
|
||||||
|
const FEnumProcessModules EnumModules = reinterpret_cast<FEnumProcessModules>(
|
||||||
|
FPlatformProcess::GetDllExport(PsapiHandle, TEXT("EnumProcessModules")));
|
||||||
|
const FGetModuleFileNameExW GetModuleFileNameEx = reinterpret_cast<FGetModuleFileNameExW>(
|
||||||
|
FPlatformProcess::GetDllExport(PsapiHandle, TEXT("GetModuleFileNameExW")));
|
||||||
|
const FGetModuleInformation GetModuleInformation = reinterpret_cast<FGetModuleInformation>(
|
||||||
|
FPlatformProcess::GetDllExport(PsapiHandle, TEXT("GetModuleInformation")));
|
||||||
|
if (EnumModules == nullptr || GetModuleFileNameEx == nullptr || GetModuleInformation == nullptr)
|
||||||
|
{
|
||||||
|
OutError = TEXT("PSAPI exports needed for module enumeration are unavailable");
|
||||||
|
FPlatformProcess::FreeDllHandle(PsapiHandle);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DWORD BytesRequired = 0;
|
||||||
|
EnumModules(GetCurrentProcess(), nullptr, 0, &BytesRequired);
|
||||||
|
if (BytesRequired == 0)
|
||||||
|
{
|
||||||
|
OutError = TEXT("EnumProcessModules returned no module bytes");
|
||||||
|
FPlatformProcess::FreeDllHandle(PsapiHandle);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
TArray<HMODULE> Modules;
|
||||||
|
Modules.SetNumUninitialized(
|
||||||
|
FMath::Max<int32>(1, static_cast<int32>(BytesRequired / sizeof(HMODULE) + 1)));
|
||||||
|
DWORD BytesWritten = 0;
|
||||||
|
if (!EnumModules(
|
||||||
|
GetCurrentProcess(), Modules.GetData(),
|
||||||
|
static_cast<DWORD>(Modules.Num() * sizeof(HMODULE)), &BytesWritten))
|
||||||
|
{
|
||||||
|
OutError = TEXT("EnumProcessModules failed");
|
||||||
|
FPlatformProcess::FreeDllHandle(PsapiHandle);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int32 ModuleCount = FMath::Min<int32>(
|
||||||
|
Modules.Num(), static_cast<int32>(BytesWritten / sizeof(HMODULE)));
|
||||||
|
for (int32 Index = 0; Index < ModuleCount; ++Index)
|
||||||
|
{
|
||||||
|
MODULEINFO ModuleInfo{};
|
||||||
|
if (!GetModuleInformation(
|
||||||
|
GetCurrentProcess(), Modules[Index], &ModuleInfo, sizeof(ModuleInfo)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
WCHAR ImagePath[32768] = {};
|
||||||
|
const DWORD PathLength = GetModuleFileNameEx(
|
||||||
|
GetCurrentProcess(), Modules[Index], ImagePath, UE_ARRAY_COUNT(ImagePath));
|
||||||
|
if (PathLength == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
FModuleMapRecord Record;
|
||||||
|
Record.Path = FString(ImagePath, static_cast<int32>(PathLength));
|
||||||
|
Record.LoadBase = reinterpret_cast<uint64>(ModuleInfo.lpBaseOfDll);
|
||||||
|
Record.ImageSize = static_cast<uint32>(ModuleInfo.SizeOfImage);
|
||||||
|
|
||||||
|
WIN32_FILE_ATTRIBUTE_DATA FileData{};
|
||||||
|
if (GetFileAttributesExW(*Record.Path, GetFileExInfoStandard, &FileData))
|
||||||
|
{
|
||||||
|
ULARGE_INTEGER FileSize;
|
||||||
|
FileSize.HighPart = FileData.nFileSizeHigh;
|
||||||
|
FileSize.LowPart = FileData.nFileSizeLow;
|
||||||
|
Record.FileSize = FileSize.QuadPart;
|
||||||
|
ULARGE_INTEGER LastWrite;
|
||||||
|
LastWrite.HighPart = FileData.ftLastWriteTime.dwHighDateTime;
|
||||||
|
LastWrite.LowPart = FileData.ftLastWriteTime.dwLowDateTime;
|
||||||
|
Record.LastWriteFileTime = LastWrite.QuadPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadModuleCodeViewIdentity(
|
||||||
|
Record, reinterpret_cast<const uint8*>(ModuleInfo.lpBaseOfDll), Record.ImageSize);
|
||||||
|
OutRecords.Add(MoveTemp(Record));
|
||||||
|
}
|
||||||
|
|
||||||
|
OutRecords.Sort([](const FModuleMapRecord& A, const FModuleMapRecord& B)
|
||||||
|
{
|
||||||
|
return A.LoadBase < B.LoadBase;
|
||||||
|
});
|
||||||
|
FPlatformProcess::FreeDllHandle(PsapiHandle);
|
||||||
|
return OutRecords.Num() > 0;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FVoxelStackSampler::FImpl final : public FRunnable
|
||||||
|
{
|
||||||
|
struct FStoredSample
|
||||||
|
{
|
||||||
|
uint64 ElapsedUs = 0;
|
||||||
|
uint64 ThreadId = 0;
|
||||||
|
int32 LODLevel = INDEX_NONE;
|
||||||
|
uint32 Depth = 0;
|
||||||
|
uint64 ProgramCounters[MaxStackDepth]{};
|
||||||
|
};
|
||||||
|
|
||||||
|
FVoxelStackSampler& Owner;
|
||||||
|
uint32 IntervalUs = DefaultIntervalUs;
|
||||||
|
FString RunLabel;
|
||||||
|
FString OutputDirectory;
|
||||||
|
FString RawSamplesPath;
|
||||||
|
FString ModuleMapPath;
|
||||||
|
FString SummaryPath;
|
||||||
|
double StartSeconds = 0.0;
|
||||||
|
std::atomic<bool> bStop{false};
|
||||||
|
uint64 SamplerSessionGeneration = 0;
|
||||||
|
std::atomic<uint64> NextRegistrationGeneration{1};
|
||||||
|
FRegisteredThreadSlot Slots[MaxRegisteredThreads];
|
||||||
|
TUniquePtr<FStoredSample[]> Samples;
|
||||||
|
uint32 StoredSampleCount = 0;
|
||||||
|
|
||||||
|
uint64 SamplerTicks = 0;
|
||||||
|
uint64 CaptureAttempts = 0;
|
||||||
|
uint64 RegisteredThreadSamples = 0;
|
||||||
|
uint64 RegistrationRaceDrops = 0;
|
||||||
|
uint64 CaptureFailures = 0;
|
||||||
|
uint64 DroppedSamples = 0;
|
||||||
|
uint64 TotalFrames = 0;
|
||||||
|
uint64 ReservoirState = 0x9E3779B97F4A7C15ull;
|
||||||
|
uint64 MaxActiveRegisteredThreads = 0;
|
||||||
|
bool bStackWalkingInitialized = false;
|
||||||
|
FRunnableThread* Thread = nullptr;
|
||||||
|
|
||||||
|
FImpl(
|
||||||
|
FVoxelStackSampler& InOwner,
|
||||||
|
uint32 InIntervalUs,
|
||||||
|
const FString& InOutputDirectory,
|
||||||
|
const FString& InRunLabel)
|
||||||
|
: Owner(InOwner)
|
||||||
|
, IntervalUs(FMath::Clamp(InIntervalUs, MinIntervalUs, MaxIntervalUs))
|
||||||
|
, RunLabel(InRunLabel)
|
||||||
|
, OutputDirectory(InOutputDirectory)
|
||||||
|
{
|
||||||
|
SamplerSessionGeneration = GNextSamplerSessionGeneration.fetch_add(
|
||||||
|
1, std::memory_order_relaxed);
|
||||||
|
const FString SafeLabel = SanitizeLabel(RunLabel);
|
||||||
|
const uint32 ProcessId = FPlatformProcess::GetCurrentProcessId();
|
||||||
|
RawSamplesPath = FPaths::Combine(
|
||||||
|
OutputDirectory,
|
||||||
|
FString::Printf(TEXT("VoxelStackSamples_%s_%u.csv"), *SafeLabel, ProcessId));
|
||||||
|
ModuleMapPath = FPaths::Combine(
|
||||||
|
OutputDirectory,
|
||||||
|
FString::Printf(TEXT("VoxelStackModules_%s_%u.tsv"), *SafeLabel, ProcessId));
|
||||||
|
SummaryPath = FPaths::Combine(
|
||||||
|
OutputDirectory,
|
||||||
|
FString::Printf(TEXT("VoxelStackSummary_%s_%u.txt"), *SafeLabel, ProcessId));
|
||||||
|
Samples = TUniquePtr<FStoredSample[]>(new FStoredSample[MaxStoredSamples]);
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual uint32 Run() override
|
||||||
|
{
|
||||||
|
#if PLATFORM_WINDOWS
|
||||||
|
StartSeconds = FPlatformTime::Seconds();
|
||||||
|
double NextSampleSeconds = StartSeconds;
|
||||||
|
while (!bStop.load(std::memory_order_relaxed))
|
||||||
|
{
|
||||||
|
const double Now = FPlatformTime::Seconds();
|
||||||
|
if (Now < NextSampleSeconds)
|
||||||
|
{
|
||||||
|
const double SleepSeconds = NextSampleSeconds - Now;
|
||||||
|
FPlatformProcess::SleepNoStats(static_cast<float>(FMath::Min(SleepSeconds, 0.001)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureRegisteredThreads(Now);
|
||||||
|
++SamplerTicks;
|
||||||
|
NextSampleSeconds += static_cast<double>(IntervalUs) * 1.0e-6;
|
||||||
|
if (NextSampleSeconds < Now)
|
||||||
|
{
|
||||||
|
NextSampleSeconds = Now + static_cast<double>(IntervalUs) * 1.0e-6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
// The requested implementation is Windows-specific. Keep the runtime module buildable
|
||||||
|
// on other targets, but never pretend that a no-op is a profile.
|
||||||
|
while (!bStop.load(std::memory_order_relaxed))
|
||||||
|
{
|
||||||
|
FPlatformProcess::SleepNoStats(0.01f);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual void Stop() override
|
||||||
|
{
|
||||||
|
bStop.store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StartThread()
|
||||||
|
{
|
||||||
|
#if PLATFORM_WINDOWS
|
||||||
|
// CaptureThreadStackBackTrace uses the same-process context path and deliberately does
|
||||||
|
// not require DbgHelp. Initializing DbgHelp here made the measured process depend on
|
||||||
|
// global symbol-engine state before shutdown; symbolization is an offline concern.
|
||||||
|
StartSeconds = FPlatformTime::Seconds();
|
||||||
|
#endif
|
||||||
|
Thread = FRunnableThread::Create(this, TEXT("VoxelStackSampler"), 0, TPri_Lowest);
|
||||||
|
return Thread != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void StopThread()
|
||||||
|
{
|
||||||
|
if (Thread == nullptr)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bStop.store(true, std::memory_order_release);
|
||||||
|
Thread->WaitForCompletion();
|
||||||
|
delete Thread;
|
||||||
|
Thread = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RegisterThread(int32 LODLevel)
|
||||||
|
{
|
||||||
|
const uint32 CurrentThreadId = FPlatformTLS::GetCurrentThreadId();
|
||||||
|
if (GTlsRegistrationDepth > 0)
|
||||||
|
{
|
||||||
|
if (GTlsRegisteredImpl == this
|
||||||
|
&& GTlsSamplerSessionGeneration == SamplerSessionGeneration)
|
||||||
|
{
|
||||||
|
++GTlsRegistrationDepth;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A worker can outlive a session's owner-side shutdown. Never let stale TLS from
|
||||||
|
// that session masquerade as a nested registration in a later session.
|
||||||
|
ResetRegistrationTls();
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint64 Generation = NextRegistrationGeneration.fetch_add(
|
||||||
|
1, std::memory_order_relaxed);
|
||||||
|
const uint64 ReservedState = Generation | RegistrationReservedBit;
|
||||||
|
for (uint32 Index = 0; Index < MaxRegisteredThreads; ++Index)
|
||||||
|
{
|
||||||
|
uint64 Expected = 0;
|
||||||
|
if (!Slots[Index].State.compare_exchange_strong(
|
||||||
|
Expected, ReservedState,
|
||||||
|
std::memory_order_acq_rel,
|
||||||
|
std::memory_order_relaxed))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Slots[Index].ThreadId.store(CurrentThreadId, std::memory_order_relaxed);
|
||||||
|
Slots[Index].LODLevel.store(LODLevel, std::memory_order_relaxed);
|
||||||
|
Slots[Index].State.store(Generation, std::memory_order_release);
|
||||||
|
GTlsRegisteredSlot = static_cast<int32>(Index);
|
||||||
|
GTlsRegistrationGeneration = Generation;
|
||||||
|
GTlsRegistrationDepth = 1;
|
||||||
|
GTlsRegisteredLODLevel = LODLevel;
|
||||||
|
GTlsRegisteredImpl = this;
|
||||||
|
GTlsSamplerSessionGeneration = SamplerSessionGeneration;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeregisterThread()
|
||||||
|
{
|
||||||
|
if (GTlsRegistrationDepth <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
--GTlsRegistrationDepth;
|
||||||
|
if (GTlsRegistrationDepth > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GTlsRegisteredSlot >= 0 && GTlsRegisteredSlot < static_cast<int32>(MaxRegisteredThreads))
|
||||||
|
{
|
||||||
|
FRegisteredThreadSlot& Slot = Slots[GTlsRegisteredSlot];
|
||||||
|
const uint64 State = Slot.State.load(std::memory_order_acquire);
|
||||||
|
if (State == GTlsRegistrationGeneration)
|
||||||
|
{
|
||||||
|
Slot.State.store(0, std::memory_order_release);
|
||||||
|
Slot.ThreadId.store(0, std::memory_order_relaxed);
|
||||||
|
Slot.LODLevel.store(INDEX_NONE, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResetRegistrationTls();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CaptureRegisteredThreads(double NowSeconds)
|
||||||
|
{
|
||||||
|
#if PLATFORM_WINDOWS
|
||||||
|
uint64 BackTrace[MaxStackDepth]{};
|
||||||
|
uint64 ActiveCount = 0;
|
||||||
|
for (uint32 Index = 0; Index < MaxRegisteredThreads; ++Index)
|
||||||
|
{
|
||||||
|
FRegisteredThreadSlot& Slot = Slots[Index];
|
||||||
|
const uint64 Generation = Slot.State.load(std::memory_order_acquire);
|
||||||
|
if (Generation == 0 || (Generation & RegistrationReservedBit) != 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uint64 ThreadId = Slot.ThreadId.load(std::memory_order_acquire);
|
||||||
|
if (ThreadId == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const int32 LODLevel = Slot.LODLevel.load(std::memory_order_acquire);
|
||||||
|
++ActiveCount;
|
||||||
|
++CaptureAttempts;
|
||||||
|
const uint32 CapturedDepth = FPlatformStackWalk::CaptureThreadStackBackTrace(
|
||||||
|
ThreadId, BackTrace, MaxStackDepth);
|
||||||
|
const uint64 EndGeneration = Slot.State.load(std::memory_order_acquire);
|
||||||
|
const uint64 EndThreadId = Slot.ThreadId.load(std::memory_order_acquire);
|
||||||
|
const int32 EndLODLevel = Slot.LODLevel.load(std::memory_order_acquire);
|
||||||
|
if (EndGeneration != Generation || EndThreadId != ThreadId || EndLODLevel != LODLevel)
|
||||||
|
{
|
||||||
|
++RegistrationRaceDrops;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
++RegisteredThreadSamples;
|
||||||
|
const uint32 Depth = FMath::Min(CapturedDepth, MaxStackDepth);
|
||||||
|
if (Depth == 0)
|
||||||
|
{
|
||||||
|
++CaptureFailures;
|
||||||
|
}
|
||||||
|
StoreSample(
|
||||||
|
NowSeconds,
|
||||||
|
ThreadId,
|
||||||
|
LODLevel,
|
||||||
|
Depth,
|
||||||
|
BackTrace);
|
||||||
|
}
|
||||||
|
MaxActiveRegisteredThreads = FMath::Max(MaxActiveRegisteredThreads, ActiveCount);
|
||||||
|
#else
|
||||||
|
(void)NowSeconds;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64 NextReservoirValue()
|
||||||
|
{
|
||||||
|
ReservoirState ^= ReservoirState << 7;
|
||||||
|
ReservoirState ^= ReservoirState >> 9;
|
||||||
|
ReservoirState ^= ReservoirState << 8;
|
||||||
|
return ReservoirState;
|
||||||
|
}
|
||||||
|
|
||||||
|
void StoreSample(
|
||||||
|
double NowSeconds,
|
||||||
|
uint64 ThreadId,
|
||||||
|
int32 LODLevel,
|
||||||
|
uint32 Depth,
|
||||||
|
const uint64* BackTrace)
|
||||||
|
{
|
||||||
|
FStoredSample* Sample = nullptr;
|
||||||
|
if (StoredSampleCount < MaxStoredSamples)
|
||||||
|
{
|
||||||
|
Sample = &Samples[StoredSampleCount++];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
++DroppedSamples;
|
||||||
|
// RegisteredThreadSamples is the one-based ordinal of this stable capture. Select a
|
||||||
|
// uniform-ish prior slot; the raw store therefore represents the full run after the
|
||||||
|
// bound is reached without allocating or synchronizing with workers.
|
||||||
|
const uint64 Candidate = NextReservoirValue() % RegisteredThreadSamples;
|
||||||
|
if (Candidate >= MaxStoredSamples)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Sample = &Samples[Candidate];
|
||||||
|
TotalFrames -= Sample->Depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sample->ElapsedUs = NowSeconds >= StartSeconds
|
||||||
|
? static_cast<uint64>((NowSeconds - StartSeconds) * 1.0e6)
|
||||||
|
: 0;
|
||||||
|
Sample->ThreadId = ThreadId;
|
||||||
|
Sample->LODLevel = LODLevel;
|
||||||
|
Sample->Depth = Depth;
|
||||||
|
TotalFrames += Depth;
|
||||||
|
if (Depth > 0)
|
||||||
|
{
|
||||||
|
FMemory::Memcpy(
|
||||||
|
Sample->ProgramCounters,
|
||||||
|
BackTrace,
|
||||||
|
static_cast<SIZE_T>(Depth) * sizeof(uint64));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FSummary Finalize()
|
||||||
|
{
|
||||||
|
FSummary Result;
|
||||||
|
Result.bSupported = PLATFORM_WINDOWS != 0;
|
||||||
|
Result.bStarted = true;
|
||||||
|
Result.IntervalUs = IntervalUs;
|
||||||
|
Result.MaxDepth = MaxStackDepth;
|
||||||
|
Result.MaxStoredSampleCount = MaxStoredSamples;
|
||||||
|
Result.bStackWalkingInitialized = bStackWalkingInitialized;
|
||||||
|
Result.SamplerTicks = SamplerTicks;
|
||||||
|
Result.CaptureAttempts = CaptureAttempts;
|
||||||
|
Result.RegisteredThreadSamples = RegisteredThreadSamples;
|
||||||
|
Result.RegistrationRaceDrops = RegistrationRaceDrops;
|
||||||
|
Result.CaptureFailures = CaptureFailures;
|
||||||
|
Result.RetainedSamples = StoredSampleCount;
|
||||||
|
Result.DroppedSamples = DroppedSamples;
|
||||||
|
Result.TotalFrames = TotalFrames;
|
||||||
|
Result.MaxActiveRegisteredThreads = MaxActiveRegisteredThreads;
|
||||||
|
Result.RunLabel = RunLabel;
|
||||||
|
Result.RawSamplesPath = RawSamplesPath;
|
||||||
|
Result.ModuleMapPath = ModuleMapPath;
|
||||||
|
Result.SummaryPath = SummaryPath;
|
||||||
|
Result.RunSeconds = FPlatformTime::Seconds() - StartSeconds;
|
||||||
|
|
||||||
|
WriteRawSamples(Result);
|
||||||
|
WriteModuleMap(Result);
|
||||||
|
// Do not call the DbgHelp-backed symbolizer from the measured process. DbgHelp is a
|
||||||
|
// process-global state machine and can fault during shutdown even after the sampler
|
||||||
|
// thread has joined. The raw PCs remain available for a separate offline pass.
|
||||||
|
WriteRawOnlySummary(Result);
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WriteRawSamples(FSummary& Result)
|
||||||
|
{
|
||||||
|
FArchive* Archive = IFileManager::Get().CreateFileWriter(*RawSamplesPath);
|
||||||
|
if (Archive == nullptr)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FString Header = TEXT("sample_index,elapsed_us,thread_id,lod,depth");
|
||||||
|
for (uint32 Index = 0; Index < MaxStackDepth; ++Index)
|
||||||
|
{
|
||||||
|
Header += FString::Printf(TEXT(",pc%u"), Index);
|
||||||
|
}
|
||||||
|
Header += TEXT("\n");
|
||||||
|
WriteUtf8(*Archive, Header);
|
||||||
|
|
||||||
|
for (uint32 SampleIndex = 0; SampleIndex < StoredSampleCount; ++SampleIndex)
|
||||||
|
{
|
||||||
|
const FStoredSample& Sample = Samples[SampleIndex];
|
||||||
|
FString Line = FString::Printf(
|
||||||
|
TEXT("%u,%llu,%llu,%d,%u"),
|
||||||
|
SampleIndex,
|
||||||
|
static_cast<unsigned long long>(Sample.ElapsedUs),
|
||||||
|
static_cast<unsigned long long>(Sample.ThreadId),
|
||||||
|
Sample.LODLevel,
|
||||||
|
Sample.Depth);
|
||||||
|
for (uint32 FrameIndex = 0; FrameIndex < Sample.Depth; ++FrameIndex)
|
||||||
|
{
|
||||||
|
Line += FString::Printf(
|
||||||
|
TEXT(",0x%016llX"),
|
||||||
|
static_cast<unsigned long long>(Sample.ProgramCounters[FrameIndex]));
|
||||||
|
}
|
||||||
|
Line += TEXT("\n");
|
||||||
|
WriteUtf8(*Archive, Line);
|
||||||
|
}
|
||||||
|
delete Archive;
|
||||||
|
Result.bOutputWritten = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WriteModuleMap(FSummary& Result)
|
||||||
|
{
|
||||||
|
#if PLATFORM_WINDOWS
|
||||||
|
TArray<FModuleMapRecord> Records;
|
||||||
|
FString CollectionError;
|
||||||
|
const bool bCollected = CollectProcessModuleMap(Records, CollectionError);
|
||||||
|
|
||||||
|
FArchive* Archive = IFileManager::Get().CreateFileWriter(*ModuleMapPath);
|
||||||
|
if (Archive == nullptr)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteUtf8(*Archive, TEXT("# VoxelForge module map v1\n"));
|
||||||
|
WriteUtf8(*Archive, TEXT("# DbgHelp is not initialized by this artifact writer.\n"));
|
||||||
|
WriteUtf8(*Archive, TEXT("module_path\tload_base_hex\timage_size\tfile_size\tfile_last_write_filetime\tpdb_guid\tpdb_age\tpdb_path\n"));
|
||||||
|
if (!bCollected)
|
||||||
|
{
|
||||||
|
WriteUtf8(*Archive, FString::Printf(TEXT("# collection_error\t%s\n"), *CollectionError));
|
||||||
|
}
|
||||||
|
for (const FModuleMapRecord& Record : Records)
|
||||||
|
{
|
||||||
|
const FString Line = FString::Printf(
|
||||||
|
TEXT("%s\t0x%016llX\t%u\t%llu\t%llu\t%s\t%u\t%s\n"),
|
||||||
|
*ModuleMapField(Record.Path),
|
||||||
|
static_cast<unsigned long long>(Record.LoadBase),
|
||||||
|
Record.ImageSize,
|
||||||
|
static_cast<unsigned long long>(Record.FileSize),
|
||||||
|
static_cast<unsigned long long>(Record.LastWriteFileTime),
|
||||||
|
*ModuleMapField(Record.PdbGuid),
|
||||||
|
Record.PdbAge,
|
||||||
|
*ModuleMapField(Record.PdbPath));
|
||||||
|
WriteUtf8(*Archive, Line);
|
||||||
|
}
|
||||||
|
delete Archive;
|
||||||
|
Result.ModuleCount = static_cast<uint64>(Records.Num());
|
||||||
|
Result.bModuleMapWritten = true;
|
||||||
|
#else
|
||||||
|
(void)Result;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void WriteRawOnlySummary(FSummary& Result)
|
||||||
|
{
|
||||||
|
uint64 SamplesWithStack = 0;
|
||||||
|
for (uint32 SampleIndex = 0; SampleIndex < StoredSampleCount; ++SampleIndex)
|
||||||
|
{
|
||||||
|
if (Samples[SampleIndex].Depth > 0)
|
||||||
|
{
|
||||||
|
++SamplesWithStack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FString Summary;
|
||||||
|
Summary += TEXT("VoxelForge raw stack sampler\n");
|
||||||
|
Summary += TEXT("============================\n");
|
||||||
|
Summary.Appendf(TEXT("run_label: %s\n"), *RunLabel);
|
||||||
|
Summary.Appendf(TEXT("supported: %s\n"), Result.bSupported ? TEXT("yes") : TEXT("no"));
|
||||||
|
Summary += TEXT("raw_only: yes\n");
|
||||||
|
Summary += TEXT("symbolization: deferred\n");
|
||||||
|
Summary += TEXT("dbghelp_in_measured_process: no\n");
|
||||||
|
Summary.Appendf(TEXT("module_map_written: %s\n"),
|
||||||
|
Result.bModuleMapWritten ? TEXT("yes") : TEXT("no"));
|
||||||
|
Summary.Appendf(TEXT("module_count: %llu\n"),
|
||||||
|
static_cast<unsigned long long>(Result.ModuleCount));
|
||||||
|
Summary.Appendf(TEXT("interval_us: %u\n"), IntervalUs);
|
||||||
|
Summary.Appendf(TEXT("max_stack_depth: %u\n"), MaxStackDepth);
|
||||||
|
Summary.Appendf(TEXT("max_stored_samples: %u\n"), MaxStoredSamples);
|
||||||
|
Summary.Appendf(TEXT("sampler_ticks: %llu\n"),
|
||||||
|
static_cast<unsigned long long>(SamplerTicks));
|
||||||
|
Summary.Appendf(TEXT("capture_attempts: %llu\n"),
|
||||||
|
static_cast<unsigned long long>(CaptureAttempts));
|
||||||
|
Summary.Appendf(TEXT("registered_thread_samples: %llu\n"),
|
||||||
|
static_cast<unsigned long long>(RegisteredThreadSamples));
|
||||||
|
Summary.Appendf(TEXT("registration_race_drops: %llu\n"),
|
||||||
|
static_cast<unsigned long long>(RegistrationRaceDrops));
|
||||||
|
Summary.Appendf(TEXT("capture_failures: %llu\n"),
|
||||||
|
static_cast<unsigned long long>(CaptureFailures));
|
||||||
|
Summary.Appendf(TEXT("retained_samples: %llu\n"),
|
||||||
|
static_cast<unsigned long long>(StoredSampleCount));
|
||||||
|
Summary.Appendf(TEXT("dropped_samples_after_bound: %llu\n"),
|
||||||
|
static_cast<unsigned long long>(DroppedSamples));
|
||||||
|
Summary.Appendf(TEXT("samples_with_stack: %llu\n"),
|
||||||
|
static_cast<unsigned long long>(SamplesWithStack));
|
||||||
|
Summary.Appendf(TEXT("total_frames: %llu\n"),
|
||||||
|
static_cast<unsigned long long>(TotalFrames));
|
||||||
|
Summary.Appendf(TEXT("max_active_registered_threads: %llu\n"),
|
||||||
|
static_cast<unsigned long long>(MaxActiveRegisteredThreads));
|
||||||
|
Summary.Appendf(TEXT("run_seconds: %.6f\n"), Result.RunSeconds);
|
||||||
|
Summary.Appendf(TEXT("raw_samples_path: %s\n"), *RawSamplesPath);
|
||||||
|
Summary.Appendf(TEXT("module_map_path: %s\n"), *ModuleMapPath);
|
||||||
|
Summary += TEXT("\nThe target process records bounded raw PCs only; resolve symbols after the process exits.\n");
|
||||||
|
|
||||||
|
if (FFileHelper::SaveStringToFile(
|
||||||
|
Summary,
|
||||||
|
*SummaryPath,
|
||||||
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
||||||
|
{
|
||||||
|
Result.bOutputWritten = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
FVoxelStackSampler& FVoxelStackSampler::Get()
|
||||||
|
{
|
||||||
|
static FVoxelStackSampler Instance;
|
||||||
|
return Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelStackSampler::Start(
|
||||||
|
uint32 IntervalUs,
|
||||||
|
const FString& OutputDirectory,
|
||||||
|
const FString& RunLabel)
|
||||||
|
{
|
||||||
|
FScopeLock LifecycleLock(&LifecycleMutex);
|
||||||
|
if (Impl != nullptr)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !PLATFORM_WINDOWS
|
||||||
|
UE_LOG(LogTemp, Error,
|
||||||
|
TEXT("[VoxelStackSampler] requested on a non-Windows target; no profile was started."));
|
||||||
|
return false;
|
||||||
|
#else
|
||||||
|
if (!IFileManager::Get().MakeDirectory(*OutputDirectory, true))
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Error, TEXT("[VoxelStackSampler] could not create '%s'."), *OutputDirectory);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FImpl* NewImpl = new FImpl(*this, IntervalUs, OutputDirectory, RunLabel);
|
||||||
|
if (!NewImpl->StartThread())
|
||||||
|
{
|
||||||
|
delete NewImpl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Impl = NewImpl;
|
||||||
|
GVoxelStackSamplerEnabled.store(true, std::memory_order_release);
|
||||||
|
UE_LOG(LogTemp, Display,
|
||||||
|
TEXT("[VoxelStackSampler] started label=%s interval_us=%u raw=%s modules=%s summary=%s"),
|
||||||
|
*RunLabel, NewImpl->IntervalUs, *NewImpl->RawSamplesPath,
|
||||||
|
*NewImpl->ModuleMapPath, *NewImpl->SummaryPath);
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
FVoxelStackSampler::FSummary FVoxelStackSampler::StopAndWrite()
|
||||||
|
{
|
||||||
|
GVoxelStackSamplerEnabled.store(false, std::memory_order_release);
|
||||||
|
FScopeLock LifecycleLock(&LifecycleMutex);
|
||||||
|
FSummary Result;
|
||||||
|
if (Impl == nullptr)
|
||||||
|
{
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
FImpl* StoppedImpl = Impl;
|
||||||
|
StoppedImpl->StopThread();
|
||||||
|
Result = StoppedImpl->Finalize();
|
||||||
|
UE_LOG(LogTemp, Display,
|
||||||
|
TEXT("[VoxelStackSampler] stopped label=%s ticks=%llu registered_samples=%llu "
|
||||||
|
"retained=%llu dropped=%llu raw=%s module_map=%s summary=%s"),
|
||||||
|
*Result.RunLabel,
|
||||||
|
static_cast<unsigned long long>(Result.SamplerTicks),
|
||||||
|
static_cast<unsigned long long>(Result.RegisteredThreadSamples),
|
||||||
|
static_cast<unsigned long long>(Result.RetainedSamples),
|
||||||
|
static_cast<unsigned long long>(Result.DroppedSamples),
|
||||||
|
*Result.RawSamplesPath,
|
||||||
|
*Result.ModuleMapPath,
|
||||||
|
*Result.SummaryPath);
|
||||||
|
delete StoppedImpl;
|
||||||
|
Impl = nullptr;
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FVoxelStackSampler::Shutdown()
|
||||||
|
{
|
||||||
|
StopAndWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelStackSampler::IsRunning() const
|
||||||
|
{
|
||||||
|
FScopeLock LifecycleLock(&LifecycleMutex);
|
||||||
|
return Impl != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelStackSampler::RegisterCurrentThread(int32 LODLevel)
|
||||||
|
{
|
||||||
|
// Keep this as the first and only operation in the disabled fast path. In particular, do not
|
||||||
|
// touch the singleton or TLS until this relaxed load says a session is active.
|
||||||
|
if (!GVoxelStackSamplerEnabled.load(std::memory_order_relaxed))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Get().RegisterThreadInternal(LODLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FVoxelStackSampler::DeregisterCurrentThread()
|
||||||
|
{
|
||||||
|
Get().DeregisterThreadInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelStackSampler::IsEnabled()
|
||||||
|
{
|
||||||
|
return GVoxelStackSamplerEnabled.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelStackSampler::RegisterThreadInternal(int32 LODLevel)
|
||||||
|
{
|
||||||
|
FScopeLock LifecycleLock(&LifecycleMutex);
|
||||||
|
return Impl != nullptr && Impl->RegisterThread(LODLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FVoxelStackSampler::DeregisterThreadInternal()
|
||||||
|
{
|
||||||
|
FScopeLock LifecycleLock(&LifecycleMutex);
|
||||||
|
if (GTlsRegistrationDepth <= 0)
|
||||||
|
{
|
||||||
|
ResetRegistrationTls();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Impl != nullptr
|
||||||
|
&& GTlsRegisteredImpl == Impl
|
||||||
|
&& GTlsSamplerSessionGeneration == Impl->SamplerSessionGeneration)
|
||||||
|
{
|
||||||
|
Impl->DeregisterThread();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopAndWrite may have detached and destroyed the session before a worker's scope guard
|
||||||
|
// runs. Clear only this thread's stale registration; never dereference the old FImpl.
|
||||||
|
ResetRegistrationTls();
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
#include "VoxelStartupTrace.h"
|
||||||
|
|
||||||
|
#include "HAL/FileManager.h"
|
||||||
|
#include "HAL/PlatformTime.h"
|
||||||
|
#include "Misc/CommandLine.h"
|
||||||
|
#include "Misc/Parse.h"
|
||||||
|
|
||||||
|
namespace VoxelForgeStartupTrace
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct FState
|
||||||
|
{
|
||||||
|
FArchive* Archive = nullptr;
|
||||||
|
FString Path;
|
||||||
|
double StartSeconds = 0.0;
|
||||||
|
int32 TileRecordCap = 4096;
|
||||||
|
int32 TileRecordsSeen = 0;
|
||||||
|
int32 TileRecordsWritten = 0;
|
||||||
|
int32 TileRecordsSuppressed = 0;
|
||||||
|
TArray<FTileSample> Samples;
|
||||||
|
TMap<FString, double> StageStarts;
|
||||||
|
TMap<FString, double> StageTotals;
|
||||||
|
TArray<FString> StageOrder;
|
||||||
|
bool bActive = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
FCriticalSection GTraceLock;
|
||||||
|
TUniquePtr<FState> GState;
|
||||||
|
|
||||||
|
FString JsonEscape(const FString& In)
|
||||||
|
{
|
||||||
|
FString Out = In;
|
||||||
|
Out.ReplaceInline(TEXT("\\"), TEXT("\\\\"));
|
||||||
|
Out.ReplaceInline(TEXT("\""), TEXT("\\\""));
|
||||||
|
Out.ReplaceInline(TEXT("\r"), TEXT("\\r"));
|
||||||
|
Out.ReplaceInline(TEXT("\n"), TEXT("\\n"));
|
||||||
|
return Out;
|
||||||
|
}
|
||||||
|
|
||||||
|
FString Number(double Value)
|
||||||
|
{
|
||||||
|
return FString::SanitizeFloat(Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void WriteLineLocked(FState& State, const FString& Line)
|
||||||
|
{
|
||||||
|
if (State.Archive == nullptr) return;
|
||||||
|
const FString WithNewline = Line + TEXT("\n");
|
||||||
|
FTCHARToUTF8 Utf8(*WithNewline);
|
||||||
|
State.Archive->Serialize(const_cast<ANSICHAR*>(Utf8.Get()), Utf8.Length());
|
||||||
|
// The trace is intended to remain readable if a harness terminates the editor after
|
||||||
|
// the steady-state record. This is bounded I/O (startup only), not the shared UE log.
|
||||||
|
State.Archive->Flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
void WriteEventLocked(FState& State, const TCHAR* Type, const FString& FieldsJson)
|
||||||
|
{
|
||||||
|
FString Line = FString::Printf(
|
||||||
|
TEXT("{\"type\":\"%s\",\"t_s\":%s"),
|
||||||
|
Type,
|
||||||
|
*Number(FPlatformTime::Seconds() - State.StartSeconds));
|
||||||
|
if (!FieldsJson.IsEmpty())
|
||||||
|
{
|
||||||
|
Line += TEXT(",");
|
||||||
|
Line += FieldsJson;
|
||||||
|
}
|
||||||
|
Line += TEXT("}");
|
||||||
|
WriteLineLocked(State, Line);
|
||||||
|
}
|
||||||
|
|
||||||
|
double Percentile(TArray<double> Values, double Fraction)
|
||||||
|
{
|
||||||
|
if (Values.Num() == 0) return 0.0;
|
||||||
|
Values.Sort();
|
||||||
|
const int32 Index = FMath::Clamp(
|
||||||
|
FMath::CeilToInt(Fraction * static_cast<double>(Values.Num() - 1)),
|
||||||
|
0,
|
||||||
|
Values.Num() - 1);
|
||||||
|
return Values[Index];
|
||||||
|
}
|
||||||
|
|
||||||
|
double Maximum(const TArray<double>& Values)
|
||||||
|
{
|
||||||
|
double Result = 0.0;
|
||||||
|
for (const double Value : Values)
|
||||||
|
{
|
||||||
|
Result = FMath::Max(Result, Value);
|
||||||
|
}
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
FString BuildTileGroupJson(const TArray<FTileSample>& Samples, int32 Level)
|
||||||
|
{
|
||||||
|
TArray<double> Request, Queue, Generation, Classify, Mesh, Apply;
|
||||||
|
int32 Count = 0;
|
||||||
|
int32 Empty = 0;
|
||||||
|
int32 AllAir = 0;
|
||||||
|
int32 AllSolid = 0;
|
||||||
|
int32 Mixed = 0;
|
||||||
|
int64 Vertices = 0;
|
||||||
|
int64 Triangles = 0;
|
||||||
|
uint64 GeometryBytes = 0;
|
||||||
|
|
||||||
|
for (const FTileSample& Sample : Samples)
|
||||||
|
{
|
||||||
|
if (Level >= 0 && Sample.Level != Level) continue;
|
||||||
|
++Count;
|
||||||
|
if (Sample.bEmpty) ++Empty;
|
||||||
|
if (Sample.Verdict == 0) ++Mixed;
|
||||||
|
else if (Sample.Verdict == 1) ++AllSolid;
|
||||||
|
else if (Sample.Verdict == 2) ++AllAir;
|
||||||
|
Vertices += Sample.Vertices;
|
||||||
|
Triangles += Sample.Triangles;
|
||||||
|
GeometryBytes += Sample.GeometryBytes;
|
||||||
|
Request.Add(Sample.RequestToApplySeconds);
|
||||||
|
Queue.Add(Sample.QueueWaitSeconds);
|
||||||
|
Generation.Add(Sample.GenerationSeconds);
|
||||||
|
Classify.Add(Sample.ClassifySeconds);
|
||||||
|
Mesh.Add(Sample.MeshSeconds);
|
||||||
|
Apply.Add(Sample.ApplySeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FString::Printf(
|
||||||
|
TEXT("{\"count\":%d,\"empty\":%d,\"all_air\":%d,\"all_solid\":%d,\"mixed\":%d,\"vertices\":%lld,\"triangles\":%lld,\"geometry_bytes\":%llu,"
|
||||||
|
"\"request_apply_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
|
||||||
|
"\"queue_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
|
||||||
|
"\"generation_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
|
||||||
|
"\"classify_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
|
||||||
|
"\"mesh_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
|
||||||
|
"\"apply_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}}"),
|
||||||
|
Count, Empty, AllAir, AllSolid, Mixed,
|
||||||
|
static_cast<long long>(Vertices), static_cast<long long>(Triangles),
|
||||||
|
static_cast<unsigned long long>(GeometryBytes),
|
||||||
|
*Number(Percentile(Request, 0.50)), *Number(Percentile(Request, 0.95)), *Number(Maximum(Request)),
|
||||||
|
*Number(Percentile(Queue, 0.50)), *Number(Percentile(Queue, 0.95)), *Number(Maximum(Queue)),
|
||||||
|
*Number(Percentile(Generation, 0.50)), *Number(Percentile(Generation, 0.95)), *Number(Maximum(Generation)),
|
||||||
|
*Number(Percentile(Classify, 0.50)), *Number(Percentile(Classify, 0.95)), *Number(Maximum(Classify)),
|
||||||
|
*Number(Percentile(Mesh, 0.50)), *Number(Percentile(Mesh, 0.95)), *Number(Maximum(Mesh)),
|
||||||
|
*Number(Percentile(Apply, 0.50)), *Number(Percentile(Apply, 0.95)), *Number(Maximum(Apply)));
|
||||||
|
}
|
||||||
|
|
||||||
|
FString BuildSummaryFieldsLocked(const FState& State, const TCHAR* Reason)
|
||||||
|
{
|
||||||
|
FString Stages = TEXT("{");
|
||||||
|
for (int32 Index = 0; Index < State.StageOrder.Num(); ++Index)
|
||||||
|
{
|
||||||
|
if (Index > 0) Stages += TEXT(",");
|
||||||
|
const FString& Name = State.StageOrder[Index];
|
||||||
|
const double* Total = State.StageTotals.Find(Name);
|
||||||
|
Stages += FString::Printf(
|
||||||
|
TEXT("\"%s\":%s"), *JsonEscape(Name), *Number(Total ? *Total : 0.0));
|
||||||
|
}
|
||||||
|
Stages += TEXT("}");
|
||||||
|
|
||||||
|
TArray<int32> Levels;
|
||||||
|
for (const FTileSample& Sample : State.Samples)
|
||||||
|
{
|
||||||
|
if (Sample.Level >= 0 && !Levels.Contains(Sample.Level)) Levels.Add(Sample.Level);
|
||||||
|
}
|
||||||
|
Levels.Sort();
|
||||||
|
|
||||||
|
FString Groups = FString::Printf(
|
||||||
|
TEXT("\"all\":%s"), *BuildTileGroupJson(State.Samples, -1));
|
||||||
|
for (const int32 Level : Levels)
|
||||||
|
{
|
||||||
|
Groups += FString::Printf(
|
||||||
|
TEXT(",\"lod%d\":%s"), Level, *BuildTileGroupJson(State.Samples, Level));
|
||||||
|
}
|
||||||
|
|
||||||
|
return FString::Printf(
|
||||||
|
TEXT("\"reason\":\"%s\",\"wall_s\":%s,\"tile_records_seen\":%d,"
|
||||||
|
"\"tile_records_written\":%d,\"tile_records_suppressed\":%d,"
|
||||||
|
"\"tile_record_cap\":%d,\"stage_seconds\":%s,\"tile_percentiles\":{%s}"),
|
||||||
|
*JsonEscape(Reason ? FString(Reason) : FString(TEXT("unknown"))),
|
||||||
|
*Number(FPlatformTime::Seconds() - State.StartSeconds),
|
||||||
|
State.TileRecordsSeen, State.TileRecordsWritten, State.TileRecordsSuppressed,
|
||||||
|
State.TileRecordCap, *Stages, *Groups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BeginFromCommandLine()
|
||||||
|
{
|
||||||
|
FScopeLock Lock(>raceLock);
|
||||||
|
if (GState.IsValid()) return;
|
||||||
|
|
||||||
|
FString Path;
|
||||||
|
if (!FParse::Value(FCommandLine::Get(), TEXT("voxel.StartupTraceFile="), Path)) return;
|
||||||
|
Path.TrimQuotesInline();
|
||||||
|
if (Path.IsEmpty()) return;
|
||||||
|
|
||||||
|
TUniquePtr<FState> NewState = MakeUnique<FState>();
|
||||||
|
NewState->Path = Path;
|
||||||
|
FParse::Value(FCommandLine::Get(), TEXT("voxel.StartupTraceMaxTiles="), NewState->TileRecordCap);
|
||||||
|
NewState->TileRecordCap = FMath::Clamp(NewState->TileRecordCap, 64, 16384);
|
||||||
|
NewState->Samples.Reserve(NewState->TileRecordCap);
|
||||||
|
NewState->Archive = IFileManager::Get().CreateFileWriter(*Path);
|
||||||
|
if (NewState->Archive == nullptr)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NewState->StartSeconds = FPlatformTime::Seconds();
|
||||||
|
NewState->bActive = true;
|
||||||
|
GState = MoveTemp(NewState);
|
||||||
|
WriteEventLocked(*GState, TEXT("header"), FString::Printf(
|
||||||
|
TEXT("\"schema\":1,\"path\":\"%s\",\"tile_record_cap\":%d"),
|
||||||
|
*JsonEscape(GState->Path), GState->TileRecordCap));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsActive()
|
||||||
|
{
|
||||||
|
FScopeLock Lock(>raceLock);
|
||||||
|
return GState.IsValid() && GState->bActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RecordEvent(const TCHAR* Type, const FString& FieldsJson)
|
||||||
|
{
|
||||||
|
FScopeLock Lock(>raceLock);
|
||||||
|
if (!GState.IsValid() || !GState->bActive) return;
|
||||||
|
WriteEventLocked(*GState, Type, FieldsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
void StageBegin(const TCHAR* Name)
|
||||||
|
{
|
||||||
|
if (Name == nullptr) return;
|
||||||
|
FScopeLock Lock(>raceLock);
|
||||||
|
if (!GState.IsValid() || !GState->bActive) return;
|
||||||
|
const FString StageName(Name);
|
||||||
|
if (!GState->StageStarts.Contains(StageName))
|
||||||
|
{
|
||||||
|
GState->StageOrder.Add(StageName);
|
||||||
|
}
|
||||||
|
GState->StageStarts.Add(StageName, FPlatformTime::Seconds());
|
||||||
|
WriteEventLocked(*GState, TEXT("stage_begin"), FString::Printf(
|
||||||
|
TEXT("\"name\":\"%s\""), *JsonEscape(StageName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void StageEnd(const TCHAR* Name)
|
||||||
|
{
|
||||||
|
if (Name == nullptr) return;
|
||||||
|
FScopeLock Lock(>raceLock);
|
||||||
|
if (!GState.IsValid() || !GState->bActive) return;
|
||||||
|
const FString StageName(Name);
|
||||||
|
const double Now = FPlatformTime::Seconds();
|
||||||
|
const double* Start = GState->StageStarts.Find(StageName);
|
||||||
|
const double Duration = Start ? FMath::Max(0.0, Now - *Start) : 0.0;
|
||||||
|
GState->StageTotals.FindOrAdd(StageName) += Duration;
|
||||||
|
GState->StageStarts.Remove(StageName);
|
||||||
|
WriteEventLocked(*GState, TEXT("stage_end"), FString::Printf(
|
||||||
|
TEXT("\"name\":\"%s\",\"duration_s\":%s"),
|
||||||
|
*JsonEscape(StageName), *Number(Duration)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void RecordTile(const FTileSample& Sample)
|
||||||
|
{
|
||||||
|
FScopeLock Lock(>raceLock);
|
||||||
|
if (!GState.IsValid() || !GState->bActive) return;
|
||||||
|
++GState->TileRecordsSeen;
|
||||||
|
if (GState->TileRecordsWritten >= GState->TileRecordCap)
|
||||||
|
{
|
||||||
|
++GState->TileRecordsSuppressed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GState->Samples.Add(Sample);
|
||||||
|
++GState->TileRecordsWritten;
|
||||||
|
WriteEventLocked(*GState, TEXT("tile"), FString::Printf(
|
||||||
|
TEXT("\"tile\":[%d,%d,%d],\"level\":%d,\"verdict\":%d,\"proof\":%d,\"empty\":%d,\"vertices\":%d,\"triangles\":%d,\"geometry_bytes\":%llu,"
|
||||||
|
"\"request_to_apply_s\":%s,\"queue_wait_s\":%s,\"worker_queue_s\":%s,\"result_queue_s\":%s,"
|
||||||
|
"\"generation_s\":%s,\"classify_s\":%s,\"mesh_s\":%s,\"stream_s\":%s,\"apply_s\":%s"),
|
||||||
|
Sample.TileX, Sample.TileY, Sample.TileZ,
|
||||||
|
Sample.Level, Sample.Verdict, Sample.bSealedSolidProof ? 1 : 0,
|
||||||
|
Sample.bEmpty ? 1 : 0, Sample.Vertices, Sample.Triangles,
|
||||||
|
static_cast<unsigned long long>(Sample.GeometryBytes),
|
||||||
|
*Number(Sample.RequestToApplySeconds), *Number(Sample.QueueWaitSeconds),
|
||||||
|
*Number(Sample.WorkerQueueSeconds), *Number(Sample.ResultQueueSeconds),
|
||||||
|
*Number(Sample.GenerationSeconds), *Number(Sample.ClassifySeconds),
|
||||||
|
*Number(Sample.MeshSeconds), *Number(Sample.StreamSeconds), *Number(Sample.ApplySeconds)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Finish(const TCHAR* Reason)
|
||||||
|
{
|
||||||
|
FScopeLock Lock(>raceLock);
|
||||||
|
if (!GState.IsValid() || !GState->bActive) return;
|
||||||
|
|
||||||
|
// Close any stage left open by an abnormal early exit so the summary remains useful.
|
||||||
|
const double Now = FPlatformTime::Seconds();
|
||||||
|
for (const TPair<FString, double>& Pair : GState->StageStarts)
|
||||||
|
{
|
||||||
|
GState->StageTotals.FindOrAdd(Pair.Key) += FMath::Max(0.0, Now - Pair.Value);
|
||||||
|
}
|
||||||
|
GState->StageStarts.Reset();
|
||||||
|
WriteEventLocked(*GState, TEXT("summary"), BuildSummaryFieldsLocked(*GState, Reason));
|
||||||
|
GState->Archive->Flush();
|
||||||
|
delete GState->Archive;
|
||||||
|
GState->Archive = nullptr;
|
||||||
|
GState->bActive = false;
|
||||||
|
|
||||||
|
// The harness polls this tiny sentinel instead of opening the live JSONL. Unreal's file
|
||||||
|
// writer does not allow a concurrent reader on Windows; create it only after the trace is
|
||||||
|
// closed so the summary is safe to consume.
|
||||||
|
const FString DonePath = GState->Path + TEXT(".done");
|
||||||
|
if (FArchive* DoneArchive = IFileManager::Get().CreateFileWriter(*DonePath))
|
||||||
|
{
|
||||||
|
const ANSICHAR DoneText[] = "summary_complete\n";
|
||||||
|
DoneArchive->Serialize(const_cast<ANSICHAR*>(DoneText), UE_ARRAY_COUNT(DoneText) - 1);
|
||||||
|
DoneArchive->Flush();
|
||||||
|
delete DoneArchive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FStageScope::FStageScope(const TCHAR* InName)
|
||||||
|
: Name(InName ? InName : TEXT("unnamed"))
|
||||||
|
, bActive(IsActive())
|
||||||
|
{
|
||||||
|
if (bActive) StageBegin(*Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
FStageScope::~FStageScope()
|
||||||
|
{
|
||||||
|
if (bActive) StageEnd(*Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,13 @@ DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackSoleSlot);
|
|||||||
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackBoundaryTile);
|
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackBoundaryTile);
|
||||||
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackNoLayout);
|
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackNoLayout);
|
||||||
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackRecheck);
|
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackRecheck);
|
||||||
DEFINE_STAT(STAT_VoxelForgeCaveBailMixedContent);
|
DEFINE_STAT(STAT_VoxelForgeCaveMixOutOfLayout);
|
||||||
|
DEFINE_STAT(STAT_VoxelForgeCaveMixGap);
|
||||||
|
DEFINE_STAT(STAT_VoxelForgeCaveMixSurfaceWorld);
|
||||||
|
DEFINE_STAT(STAT_VoxelForgeCaveBailTwoCaveSlots);
|
||||||
DEFINE_STAT(STAT_VoxelForgeCaveBailParams);
|
DEFINE_STAT(STAT_VoxelForgeCaveBailParams);
|
||||||
DEFINE_STAT(STAT_VoxelForgeCaveBailStackVerdict);
|
DEFINE_STAT(STAT_VoxelForgeCaveBailStackVerdict);
|
||||||
|
DEFINE_STAT(STAT_VoxelForgeCaveBailFoldConflict);
|
||||||
DEFINE_STAT(STAT_VoxelForgeCaveBailDisturbance);
|
DEFINE_STAT(STAT_VoxelForgeCaveBailDisturbance);
|
||||||
DEFINE_STAT(STAT_VoxelForgeCaveBailNoStack);
|
DEFINE_STAT(STAT_VoxelForgeCaveBailNoStack);
|
||||||
DEFINE_STAT(STAT_VoxelForgeColumnMemoHit);
|
DEFINE_STAT(STAT_VoxelForgeColumnMemoHit);
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,79 @@
|
|||||||
|
// Serialized strate-definition migrations.
|
||||||
|
|
||||||
|
#include "VoxelStrateDefinition.h"
|
||||||
|
|
||||||
|
#include "Serialization/CustomVersion.h"
|
||||||
|
#include "VoxelPassageGeometry.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// The field name is retained for package compatibility, but version 1 changes its meaning
|
||||||
|
// from a raw tangent/gradient to authored degrees. This GUID is local to the plugin.
|
||||||
|
const FGuid GVoxelForgeStrateSerializationVersion(
|
||||||
|
0xA76D5E31, 0x4B2C4F9A, 0xB8C1D204, 0x6E9F7738);
|
||||||
|
constexpr int32 GVoxelForgeStrateGentleSlopeDegreesVersion = 1;
|
||||||
|
FCustomVersionRegistration GVoxelForgeStrateSerializationRegistration(
|
||||||
|
GVoxelForgeStrateSerializationVersion,
|
||||||
|
GVoxelForgeStrateGentleSlopeDegreesVersion,
|
||||||
|
TEXT("VoxelForgeStrateSerialization"));
|
||||||
|
|
||||||
|
float LegacyGradientToDegrees(float LegacyGradient)
|
||||||
|
{
|
||||||
|
if (!VoxelMath::IsFinite(LegacyGradient) || LegacyGradient <= 0.0f)
|
||||||
|
{
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
if (LegacyGradient >= 4096.0f)
|
||||||
|
{
|
||||||
|
return VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invert the old gradient without atan/tan. The same deterministic sine/cosine helper
|
||||||
|
// used by generation makes this migration independent of the host CRT.
|
||||||
|
float LowDegrees = 0.0f;
|
||||||
|
float HighDegrees = VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees;
|
||||||
|
for (int32 Iteration = 0; Iteration < 32; ++Iteration)
|
||||||
|
{
|
||||||
|
const float MidDegrees = (LowDegrees + HighDegrees) * 0.5f;
|
||||||
|
float SinValue = 0.0f;
|
||||||
|
float CosValue = 0.0f;
|
||||||
|
VoxelMath::DetSinCos(
|
||||||
|
SinValue, CosValue, MidDegrees * (PI / 180.0f));
|
||||||
|
const float MidGradient = FMath::Min(
|
||||||
|
FMath::Abs(SinValue) / FMath::Max(FMath::Abs(CosValue), 1.0e-3f),
|
||||||
|
4096.0f);
|
||||||
|
if (MidGradient < LegacyGradient)
|
||||||
|
{
|
||||||
|
LowDegrees = MidDegrees;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
HighDegrees = MidDegrees;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return FMath::Clamp(
|
||||||
|
(LowDegrees + HighDegrees) * 0.5f,
|
||||||
|
0.0f,
|
||||||
|
VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UVoxelStrateDefinition::PostLoad()
|
||||||
|
{
|
||||||
|
Super::PostLoad();
|
||||||
|
|
||||||
|
// The field remains reflected solely so old packages deserialize without shifting the
|
||||||
|
// following members. Tunnel geometry owns the floor now, so every serialized legacy value
|
||||||
|
// is normalized to the ignored compatibility default before any runtime copy is made.
|
||||||
|
GenerationParams.bTunnelFloorEnabled = true;
|
||||||
|
|
||||||
|
if (GetLinkerCustomVersion(GVoxelForgeStrateSerializationVersion)
|
||||||
|
>= GVoxelForgeStrateGentleSlopeDegreesVersion)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float LegacyGradient = GenerationParams.TunnelFloorGentleSlopeThreshold;
|
||||||
|
GenerationParams.TunnelFloorGentleSlopeThreshold =
|
||||||
|
LegacyGradientToDegrees(LegacyGradient);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
|||||||
|
#include "VoxelStrateComposer.h"
|
||||||
|
|
||||||
|
bool FVoxelStrateMeasuredMetrics::IsUsable() const
|
||||||
|
{
|
||||||
|
return bValid
|
||||||
|
&& NumSampled > 0
|
||||||
|
&& NumAir > 0
|
||||||
|
&& NumSolid > 0
|
||||||
|
&& NumSampled == NumAir + NumSolid
|
||||||
|
&& NumAirComponents > 0
|
||||||
|
&& LargestComponentCells > 0
|
||||||
|
&& AirComponentCells.Num() == NumAirComponents
|
||||||
|
&& FMath::IsFinite(AirFraction)
|
||||||
|
&& FMath::IsFinite(LargestComponentShare)
|
||||||
|
&& FMath::IsFinite(WalkableFraction)
|
||||||
|
&& FMath::IsFinite(WalkableFloorAreaFraction)
|
||||||
|
&& FMath::IsFinite(LargestWalkableSurfaceShare)
|
||||||
|
&& FMath::IsFinite(MedianFeatureScale)
|
||||||
|
&& FMath::IsFinite(LargestComponentPoint.X)
|
||||||
|
&& FMath::IsFinite(LargestComponentPoint.Y)
|
||||||
|
&& FMath::IsFinite(LargestComponentPoint.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TCHAR* VF_GetStrateArchetypeName(ECaveGeneratorType Archetype)
|
||||||
|
{
|
||||||
|
switch (Archetype)
|
||||||
|
{
|
||||||
|
case ECaveGeneratorType::TunnelNetwork: return TEXT("TunnelNetwork");
|
||||||
|
case ECaveGeneratorType::FlatPlain: return TEXT("FlatPlain");
|
||||||
|
case ECaveGeneratorType::CrystalChamber: return TEXT("CrystalChamber");
|
||||||
|
case ECaveGeneratorType::Maze: return TEXT("Maze");
|
||||||
|
case ECaveGeneratorType::SurfaceWorld: return TEXT("SurfaceWorld");
|
||||||
|
case ECaveGeneratorType::VerticalShafts: return TEXT("VerticalShafts");
|
||||||
|
case ECaveGeneratorType::FloatingIslands: return TEXT("FloatingIslands");
|
||||||
|
case ECaveGeneratorType::Underwater: return TEXT("Underwater");
|
||||||
|
}
|
||||||
|
return TEXT("Unknown");
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,98 @@
|
|||||||
|
// VoxelWormField.cpp
|
||||||
|
// Shared deterministic exact worm-field evaluation.
|
||||||
|
|
||||||
|
#include "VoxelWormField.h"
|
||||||
|
|
||||||
|
#include "VoxelCaveMorphology.h" // VoxelHash::SeedOffset
|
||||||
|
#include "VoxelNoise.h"
|
||||||
|
#include "VoxelTypes.h" // VOXEL_NOISE_SCALE
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
FORCEINLINE FVector3f MakeWormNoiseBase(
|
||||||
|
float WorldX,
|
||||||
|
float WorldY,
|
||||||
|
float WorldZ,
|
||||||
|
const VoxelWormField::FParameters& Parameters)
|
||||||
|
{
|
||||||
|
const float EffectiveZ = (Parameters.VerticalScale != 1.0f
|
||||||
|
&& Parameters.VerticalScale > 0.0f)
|
||||||
|
? WorldZ / Parameters.VerticalScale : WorldZ;
|
||||||
|
const float WormZFrequency = Parameters.Frequency * Parameters.HorizontalBias;
|
||||||
|
return FVector3f(
|
||||||
|
WorldX * Parameters.Frequency
|
||||||
|
+ VoxelHash::SeedOffset(Parameters.Seed, 1.0f),
|
||||||
|
WorldY * Parameters.Frequency
|
||||||
|
+ VoxelHash::SeedOffset(Parameters.Seed, 1.7f),
|
||||||
|
EffectiveZ * WormZFrequency
|
||||||
|
+ VoxelHash::SeedOffset(Parameters.Seed, 2.3f));
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float EvaluateN1Field(
|
||||||
|
float WorldX,
|
||||||
|
float WorldY,
|
||||||
|
float WorldZ,
|
||||||
|
const VoxelWormField::FParameters& Parameters,
|
||||||
|
float NoiseScale)
|
||||||
|
{
|
||||||
|
const FVector3f NoiseBase = MakeWormNoiseBase(
|
||||||
|
WorldX, WorldY, WorldZ, Parameters);
|
||||||
|
return FMath::Abs(VoxelNoise::Perlin3D(NoiseBase) * NoiseScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float EvaluateN2Field(
|
||||||
|
float WorldX,
|
||||||
|
float WorldY,
|
||||||
|
float WorldZ,
|
||||||
|
const VoxelWormField::FParameters& Parameters,
|
||||||
|
float NoiseScale)
|
||||||
|
{
|
||||||
|
const FVector3f NoiseBase = MakeWormNoiseBase(
|
||||||
|
WorldX, WorldY, WorldZ, Parameters);
|
||||||
|
const FVector3f N2Position = NoiseBase + FVector3f(137.0f, 259.0f, 431.0f);
|
||||||
|
return FMath::Abs(VoxelNoise::Perlin3D(N2Position) * NoiseScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float EvaluateExactField(
|
||||||
|
float WorldX,
|
||||||
|
float WorldY,
|
||||||
|
float WorldZ,
|
||||||
|
float Threshold,
|
||||||
|
const VoxelWormField::FParameters& Parameters)
|
||||||
|
{
|
||||||
|
const float NoiseScale = VOXEL_NOISE_SCALE;
|
||||||
|
const float N1 = EvaluateN1Field(
|
||||||
|
WorldX, WorldY, WorldZ, Parameters, NoiseScale);
|
||||||
|
|
||||||
|
// N2 is non-negative, so this preserves the exact evaluator's short-circuit.
|
||||||
|
if (N1 >= Threshold)
|
||||||
|
{
|
||||||
|
return N1;
|
||||||
|
}
|
||||||
|
return N1 + EvaluateN2Field(
|
||||||
|
WorldX, WorldY, WorldZ, Parameters, NoiseScale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace VoxelWormField
|
||||||
|
{
|
||||||
|
float EvaluateExact(
|
||||||
|
float WorldX,
|
||||||
|
float WorldY,
|
||||||
|
float WorldZ,
|
||||||
|
float Threshold,
|
||||||
|
const FParameters& Parameters)
|
||||||
|
{
|
||||||
|
return EvaluateExactField(WorldX, WorldY, WorldZ, Threshold, Parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
float Evaluate(
|
||||||
|
float WorldX,
|
||||||
|
float WorldY,
|
||||||
|
float WorldZ,
|
||||||
|
float Threshold,
|
||||||
|
const FParameters& Parameters)
|
||||||
|
{
|
||||||
|
return EvaluateExactField(WorldX, WorldY, WorldZ, Threshold, Parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -217,7 +217,7 @@ struct FBiomeSample
|
|||||||
* done ONCE here, into a small grid covering the chunk footprint + margin; per voxel
|
* done ONCE here, into a small grid covering the chunk footprint + margin; per voxel
|
||||||
* the resolver then only warps + does a cheap 3x3 lookup + blend.
|
* the resolver then only warps + does a cheap 3x3 lookup + blend.
|
||||||
*
|
*
|
||||||
* VALIDITY IS A WORLD-XY BOX (+ ChunkZ + Seed), NOT a chunk key (CODEMAP §8.10). The
|
* VALIDITY IS A WORLD-XY BOX (+ ChunkZ + Seed + owner identities), NOT a chunk key (CODEMAP §8.10). The
|
||||||
* grid covers a halo beyond the chunk, so gradient-normal samples and the +X/+Y chunk
|
* grid covers a halo beyond the chunk, so gradient-normal samples and the +X/+Y chunk
|
||||||
* boundary corners stay inside the valid box and DO NOT thrash the noise-heavy rebuild.
|
* boundary corners stay inside the valid box and DO NOT thrash the noise-heavy rebuild.
|
||||||
* The box logic is identical in spirit to the SDF cache in GetDensityWithParams.
|
* The box logic is identical in spirit to the SDF cache in GetDensityWithParams.
|
||||||
@@ -229,6 +229,8 @@ struct FChunkBiomeCache
|
|||||||
float ValidMinY = 0.0f, ValidMaxY = 0.0f;
|
float ValidMinY = 0.0f, ValidMaxY = 0.0f;
|
||||||
int32 ChunkZ = MIN_int32; // which strate slice this was built for
|
int32 ChunkZ = MIN_int32; // which strate slice this was built for
|
||||||
int32 Seed = MIN_int32;
|
int32 Seed = MIN_int32;
|
||||||
|
uint64 OwnerId = 0; // generator/world identity
|
||||||
|
uint64 ManagerLifetimeId = 0; // strate-manager lifetime identity
|
||||||
bool bActive = false; // does this strate have biomes?
|
bool bActive = false; // does this strate have biomes?
|
||||||
|
|
||||||
FBiomeContext Ctx; // resolved biomes + map (for blending)
|
FBiomeContext Ctx; // resolved biomes + map (for blending)
|
||||||
@@ -237,9 +239,11 @@ struct FChunkBiomeCache
|
|||||||
int32 BaseCellX = 0, BaseCellY = 0, CellsX = 0, CellsY = 0;
|
int32 BaseCellX = 0, BaseCellY = 0, CellsX = 0, CellsY = 0;
|
||||||
TArray<int32> CellBiome;
|
TArray<int32> CellBiome;
|
||||||
|
|
||||||
bool Contains(float X, float Y, int32 InChunkZ, int32 InSeed) const
|
bool Contains(float X, float Y, int32 InChunkZ, int32 InSeed,
|
||||||
|
uint64 InOwnerId, uint64 InManagerLifetimeId) const
|
||||||
{
|
{
|
||||||
return InSeed == Seed && InChunkZ == ChunkZ
|
return InSeed == Seed && InChunkZ == ChunkZ
|
||||||
|
&& InOwnerId == OwnerId && InManagerLifetimeId == ManagerLifetimeId
|
||||||
&& X >= ValidMinX && X <= ValidMaxX
|
&& X >= ValidMinX && X <= ValidMaxX
|
||||||
&& Y >= ValidMinY && Y <= ValidMaxY;
|
&& Y >= ValidMinY && Y <= ValidMaxY;
|
||||||
}
|
}
|
||||||
@@ -254,5 +258,7 @@ struct FChunkBiomeCache
|
|||||||
{
|
{
|
||||||
ValidMinX = 1.0f; ValidMaxX = -1.0f; // min > max ⇒ Contains() is false everywhere
|
ValidMinX = 1.0f; ValidMaxX = -1.0f; // min > max ⇒ Contains() is false everywhere
|
||||||
ChunkZ = MIN_int32;
|
ChunkZ = MIN_int32;
|
||||||
|
OwnerId = 0;
|
||||||
|
ManagerLifetimeId = 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -10,17 +10,17 @@
|
|||||||
// infinite worlds without storing anything.
|
// infinite worlds without storing anything.
|
||||||
//
|
//
|
||||||
// ROOMS:
|
// ROOMS:
|
||||||
// - Origin room: guaranteed large room at (0,0) per strate — the hub
|
// - Origin room: guaranteed large room at (0,0) per strate — the future shaft landing
|
||||||
// - Hash rooms: ellipsoids, rounded boxes, or elongated capsules
|
// - Hash rooms: ellipsoids, rounded boxes, or elongated capsules
|
||||||
// - All blended with smooth-min for organic junctions
|
// - All blended with smooth-min for organic junctions
|
||||||
//
|
//
|
||||||
// TUNNELS:
|
// TUNNELS:
|
||||||
// - Connect pairs of rooms based on distance and probability
|
// - Connect pairs of rooms based on distance and probability
|
||||||
// - Tapered (variable min/max radius at each endpoint)
|
// - Tapered (variable min/max radius at each endpoint)
|
||||||
// - Curved (midpoint displaced perpendicular to tunnel direction)
|
// - Curved wandering chains with deterministic perpendicular control-point jitter
|
||||||
// - Horizontal bias (vertical connections penalized)
|
// - Horizontal bias (vertical connections penalized)
|
||||||
// - Endpoint Z offset (tunnels enter rooms at different heights)
|
// - Floor-aligned endpoints and variable radii preserve walkable room joins
|
||||||
// - Origin room forces connections to all nearby rooms
|
// - The origin room is not joined to landings by radial connector roads
|
||||||
//
|
//
|
||||||
// PIPELINE POSITION:
|
// PIPELINE POSITION:
|
||||||
// Step 3 (cave warp) bends the SDF query coordinates before we evaluate.
|
// Step 3 (cave warp) bends the SDF query coordinates before we evaluate.
|
||||||
@@ -30,11 +30,43 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
|
#include "VoxelDensityProfile.h"
|
||||||
|
|
||||||
// Forward declarations
|
// Forward declarations
|
||||||
struct FStrateGenerationParams;
|
struct FStrateGenerationParams;
|
||||||
|
struct FSlabGenerationParams;
|
||||||
|
struct FMazeGenerationParams;
|
||||||
|
struct FVerticalShaftParams;
|
||||||
|
struct FFloatingIslandParams;
|
||||||
struct FStrateTerrainOpEntry;
|
struct FStrateTerrainOpEntry;
|
||||||
class UVoxelTerrainOpDefinition;
|
class UVoxelTerrainOpDefinition;
|
||||||
|
enum class ECaveGeneratorType : uint8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The geometry contract for one end of an inter-strate passage.
|
||||||
|
*
|
||||||
|
* All values are actor-space voxels. `StandingPoint` is the player-fit anchor: its floor is
|
||||||
|
* `FloorZ = StandingPoint.Z - 0.5`. The chamber is deliberately separate from the tube endpoint
|
||||||
|
* so a sloped/vertical tube never presents the player with a hole as its first standing position.
|
||||||
|
*
|
||||||
|
* A source query answer selects the landing's XY/Z anchor. The landing is deliberately local to
|
||||||
|
* that answer: it is a flat-floored room where the inter-strate tube meets a legal player-fit
|
||||||
|
* pose. The (0,0) landing is the only place reserved for the future straight surface shaft;
|
||||||
|
* progression mouths do not receive hidden radial roads to that spine room.
|
||||||
|
*/
|
||||||
|
struct VOXELFORGE_API FVoxelPassageLanding
|
||||||
|
{
|
||||||
|
FVector StandingPoint = FVector::ZeroVector;
|
||||||
|
FVector DoorPoint = FVector::ZeroVector;
|
||||||
|
FVector DoorDirection = FVector(1.0f, 0.0f, 0.0f);
|
||||||
|
|
||||||
|
float FloorZ = 0.0f;
|
||||||
|
float CeilingZ = 0.0f;
|
||||||
|
float HalfWidth = 0.0f;
|
||||||
|
float FloorThickness = 3.0f;
|
||||||
|
|
||||||
|
bool bSourcePlayerFit = false;
|
||||||
|
};
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// SDF PRIMITIVES
|
// SDF PRIMITIVES
|
||||||
@@ -148,6 +180,26 @@ namespace VoxelSDF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build the deterministic, body-sized room geometry at one standing anchor. */
|
||||||
|
VOXELFORGE_API FVoxelPassageLanding VF_BuildPassageLanding(
|
||||||
|
const FVector& StandingPoint,
|
||||||
|
float MouthRadius,
|
||||||
|
const FVector& DoorDirection,
|
||||||
|
float StrateTopZ,
|
||||||
|
float StrateBottomZ,
|
||||||
|
float BoundarySealThickness,
|
||||||
|
bool bSourcePlayerFit);
|
||||||
|
|
||||||
|
/** Signed distance of the local landing room. Negative means passage air. */
|
||||||
|
VOXELFORGE_API float VF_EvaluatePassageLandingSDF(
|
||||||
|
const FVector& Position,
|
||||||
|
const FVoxelPassageLanding& Landing);
|
||||||
|
|
||||||
|
/** True when a point belongs to the guaranteed solid floor slab of the landing room. */
|
||||||
|
VOXELFORGE_API bool VF_IsPassageLandingFloor(
|
||||||
|
const FVector& Position,
|
||||||
|
const FVoxelPassageLanding& Landing);
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// HASH FUNCTIONS
|
// HASH FUNCTIONS
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -242,6 +294,141 @@ namespace VoxelHash
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// MAZE TOPOLOGY — ORIGIN-DIRECTED SPANNING TREE
|
||||||
|
//=============================================================================
|
||||||
|
// A Maze node never waits for a probabilistic edge to connect it to the network. Every node
|
||||||
|
// except the origin chooses exactly one parent among the coordinate axes that point toward the
|
||||||
|
// origin. The chosen parent lowers |X|+|Y|+|Z| by one, so following parents terminates at (0,0,0)
|
||||||
|
// and the undirected parent edges are a spanning tree of the infinite lattice.
|
||||||
|
//
|
||||||
|
// The optional loop rolls below are deliberately capped and are never used for the parent edge.
|
||||||
|
// They add alternate routes and visual irregularity, but deleting every loop still leaves the
|
||||||
|
// complete tree. This is the locality contract: the parent is a function of the child node and
|
||||||
|
// seed only; an emitted voxel cell needs the eight child nodes in its one-cell {-1,0} halo.
|
||||||
|
namespace VoxelMazeTopology
|
||||||
|
{
|
||||||
|
enum class EAxis : uint8
|
||||||
|
{
|
||||||
|
X = 0,
|
||||||
|
Y = 1,
|
||||||
|
Z = 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// These are authoring knobs for optional loops, not connectivity probabilities. The scales
|
||||||
|
// keep old authored BranchProbability/Verticality values useful without allowing a lattice
|
||||||
|
// full of redundant parallel edges to turn the tree back into a grid.
|
||||||
|
constexpr float HorizontalLoopScale = 0.18f;
|
||||||
|
constexpr float VerticalLoopScale = 0.10f;
|
||||||
|
|
||||||
|
FORCEINLINE uint32 NodeHash(int32 X, int32 Y, int32 Z, uint32 Salt, uint32 DomainSalt)
|
||||||
|
{
|
||||||
|
uint32 H = VoxelHash::Cell(X, Y, Salt ^ DomainSalt);
|
||||||
|
H ^= VoxelHash::Mix(static_cast<uint32>(Z) * 73856093u ^ DomainSalt);
|
||||||
|
return VoxelHash::Mix(H);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE uint32 AxisSalt(EAxis Axis)
|
||||||
|
{
|
||||||
|
switch (Axis)
|
||||||
|
{
|
||||||
|
case EAxis::X: return 0xA1u;
|
||||||
|
case EAxis::Y: return 0xB2u;
|
||||||
|
case EAxis::Z: return 0xC3u;
|
||||||
|
default: return 0xA1u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE bool TryGetParent(int32 X, int32 Y, int32 Z, uint32 Salt,
|
||||||
|
FIntVector& OutParent)
|
||||||
|
{
|
||||||
|
if (X == 0 && Y == 0 && Z == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
EAxis Available[3];
|
||||||
|
int32 Steps[3];
|
||||||
|
int32 NumAvailable = 0;
|
||||||
|
if (X != 0)
|
||||||
|
{
|
||||||
|
Available[NumAvailable] = EAxis::X;
|
||||||
|
Steps[NumAvailable++] = X > 0 ? -1 : 1;
|
||||||
|
}
|
||||||
|
if (Y != 0)
|
||||||
|
{
|
||||||
|
Available[NumAvailable] = EAxis::Y;
|
||||||
|
Steps[NumAvailable++] = Y > 0 ? -1 : 1;
|
||||||
|
}
|
||||||
|
if (Z != 0)
|
||||||
|
{
|
||||||
|
Available[NumAvailable] = EAxis::Z;
|
||||||
|
Steps[NumAvailable++] = Z > 0 ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32 H = NodeHash(X, Y, Z, Salt, 0xD4E1A5E1u);
|
||||||
|
const int32 ParentIndex = static_cast<int32>(H % static_cast<uint32>(NumAvailable));
|
||||||
|
const EAxis ParentAxis = Available[ParentIndex];
|
||||||
|
const int32 ParentStep = Steps[ParentIndex];
|
||||||
|
OutParent = FIntVector(X, Y, Z);
|
||||||
|
switch (ParentAxis)
|
||||||
|
{
|
||||||
|
case EAxis::X: OutParent.X += ParentStep; break;
|
||||||
|
case EAxis::Y: OutParent.Y += ParentStep; break;
|
||||||
|
case EAxis::Z: OutParent.Z += ParentStep; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE FIntVector AxisNeighbour(int32 X, int32 Y, int32 Z, EAxis Axis)
|
||||||
|
{
|
||||||
|
switch (Axis)
|
||||||
|
{
|
||||||
|
case EAxis::X: return FIntVector(X + 1, Y, Z);
|
||||||
|
case EAxis::Y: return FIntVector(X, Y + 1, Z);
|
||||||
|
case EAxis::Z: return FIntVector(X, Y, Z + 1);
|
||||||
|
default: return FIntVector(X, Y, Z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE bool IsTreeEdge(int32 X, int32 Y, int32 Z, EAxis Axis, uint32 Salt)
|
||||||
|
{
|
||||||
|
const FIntVector A(X, Y, Z);
|
||||||
|
const FIntVector B = AxisNeighbour(X, Y, Z, Axis);
|
||||||
|
FIntVector Parent;
|
||||||
|
return (TryGetParent(A.X, A.Y, A.Z, Salt, Parent) && Parent == B)
|
||||||
|
|| (TryGetParent(B.X, B.Y, B.Z, Salt, Parent) && Parent == A);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float LoopProbability(EAxis Axis, float BranchProbability, float Verticality)
|
||||||
|
{
|
||||||
|
return Axis == EAxis::Z
|
||||||
|
? FMath::Clamp(Verticality, 0.0f, 1.0f) * VerticalLoopScale
|
||||||
|
: FMath::Clamp(BranchProbability, 0.0f, 1.0f) * HorizontalLoopScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE bool IsLoopEdgeOpen(int32 X, int32 Y, int32 Z, EAxis Axis, uint32 Salt,
|
||||||
|
float BranchProbability, float Verticality)
|
||||||
|
{
|
||||||
|
if (IsTreeEdge(X, Y, Z, Axis, Salt))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float Probability = LoopProbability(Axis, BranchProbability, Verticality);
|
||||||
|
return Probability > 0.0f
|
||||||
|
&& VoxelHash::ToFloat01(NodeHash(X, Y, Z, Salt, AxisSalt(Axis))) < Probability;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE bool IsOpenEdge(int32 X, int32 Y, int32 Z, EAxis Axis, uint32 Salt,
|
||||||
|
float BranchProbability, float Verticality)
|
||||||
|
{
|
||||||
|
return IsTreeEdge(X, Y, Z, Axis, Salt)
|
||||||
|
|| IsLoopEdgeOpen(X, Y, Z, Axis, Salt, BranchProbability, Verticality);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// BRUIT CELLULAIRE / CELLULAR (WORLEY) NOISE — 3D
|
// BRUIT CELLULAIRE / CELLULAR (WORLEY) NOISE — 3D
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -253,16 +440,16 @@ namespace VoxelHash
|
|||||||
// point le plus bas qui voit déjà le hash, et le générateur comme la pile d'opérateurs l'appellent.
|
// point le plus bas qui voit déjà le hash, et le générateur comme la pile d'opérateurs l'appellent.
|
||||||
//
|
//
|
||||||
// Ce corps était `static float CellularNoise3D(const FVector&)` dans VoxelGenerator.cpp, invisible
|
// Ce corps était `static float CellularNoise3D(const FVector&)` dans VoxelGenerator.cpp, invisible
|
||||||
// à la pile d'opérateurs. Déplacement LITTÉRAL : mêmes opérations, même ordre, même passage par
|
// à la pile d'opérateurs. Il vit ici pour que les deux chemins partagent la même implémentation.
|
||||||
// `FVector` (donc par des doubles) — l'égalité binaire du portage TunnelNetwork en dépend.
|
// The hot overload uses FVector3f: noise coordinates are floats, so no double round-trip is paid
|
||||||
// `UVoxelGenerator`'s copy is now a one-line forwarder; the body moved verbatim.
|
// before the hash/lattice arithmetic. Output compatibility with the old field is not a contract.
|
||||||
//
|
//
|
||||||
// Algorithme : distance au point-feature le plus proche dans une grille hachée.
|
// Algorithme : distance au point-feature le plus proche dans une grille hachée.
|
||||||
// 1. cellule entière du point 2. voisinage 3×3×3 3. rendre (F2 − F1), normalisé ~[-1, 1]
|
// 1. cellule entière du point 2. voisinage 3×3×3 3. rendre (F2 − F1), normalisé ~[-1, 1]
|
||||||
// F2−F1 donne des frontières de cellules lisses avec des arêtes entre elles.
|
// F2−F1 donne des frontières de cellules lisses avec des arêtes entre elles.
|
||||||
namespace VoxelNoise
|
namespace VoxelNoise
|
||||||
{
|
{
|
||||||
FORCEINLINE float Cellular3D(const FVector& Position)
|
FORCEINLINE float Cellular3D(const FVector3f& Position)
|
||||||
{
|
{
|
||||||
// Integer cell coordinates
|
// Integer cell coordinates
|
||||||
int32 CellX = FMath::FloorToInt(Position.X);
|
int32 CellX = FMath::FloorToInt(Position.X);
|
||||||
@@ -322,6 +509,11 @@ namespace VoxelNoise
|
|||||||
// Result is in [0, ~1.0]. Map to [-1, 1] for compatibility with other noise types.
|
// Result is in [0, ~1.0]. Map to [-1, 1] for compatibility with other noise types.
|
||||||
return Result * 2.0f - 1.0f;
|
return Result * 2.0f - 1.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float Cellular3D(const FVector& Position)
|
||||||
|
{
|
||||||
|
return Cellular3D(FVector3f((float)Position.X, (float)Position.Y, (float)Position.Z));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -333,11 +525,47 @@ namespace VoxelNoise
|
|||||||
// - Determine room existence, position, size
|
// - Determine room existence, position, size
|
||||||
// - Run O(N²) nearest-neighbor backbone
|
// - Run O(N²) nearest-neighbor backbone
|
||||||
// - Decide O(N²) tunnel connections with hash lookups
|
// - Decide O(N²) tunnel connections with hash lookups
|
||||||
// - Compute tunnel radii, Z offsets, midpoint warping
|
// - Compute tunnel radii, floor-aligned endpoints, and wandering control chains
|
||||||
//
|
//
|
||||||
// With caching: build once, evaluate 32K times with just SDF math.
|
// With caching: build once, evaluate 32K times with just SDF math.
|
||||||
// This is the single biggest CPU performance win for cave generation.
|
// This is the single biggest CPU performance win for cave generation.
|
||||||
|
|
||||||
|
// Room-relative detail geometry whose hash/trigonometry is constant for a cached room.
|
||||||
|
// These are built once with the room's terrain-op parameters and then only evaluated per voxel.
|
||||||
|
struct FCachedArch
|
||||||
|
{
|
||||||
|
bool bActive = false;
|
||||||
|
FVector EndpointA = FVector::ZeroVector;
|
||||||
|
FVector EndpointB = FVector::ZeroVector;
|
||||||
|
float Radius = 0.0f;
|
||||||
|
float BaseDensity = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FCachedPinch
|
||||||
|
{
|
||||||
|
bool bActive = false;
|
||||||
|
float CenterX = 0.0f;
|
||||||
|
float CenterY = 0.0f;
|
||||||
|
float CenterZ = 0.0f;
|
||||||
|
float CosAngle = 1.0f;
|
||||||
|
float SinAngle = 0.0f;
|
||||||
|
float MaxExtent = 0.0f;
|
||||||
|
float HalfLength = 0.0f;
|
||||||
|
float HalfNarrow = 0.0f;
|
||||||
|
float HalfVertical = 0.0f;
|
||||||
|
float BaseDensity = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A build-time room-floor bench authored from one tunnel mouth. The room evaluator only reads
|
||||||
|
// these immutable descriptors; it never searches the graph or decides a correction per sample.
|
||||||
|
struct FCachedRoomMouthRise
|
||||||
|
{
|
||||||
|
FVector Mouth = FVector::ZeroVector;
|
||||||
|
float TargetFloorZ = 0.0f;
|
||||||
|
float BlendRadius = 8.0f;
|
||||||
|
float Strength = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
// A room in the cache — everything needed for per-voxel SDF evaluation.
|
// A room in the cache — everything needed for per-voxel SDF evaluation.
|
||||||
// Internal details (CellX, CellY) used during cache building are NOT stored here.
|
// Internal details (CellX, CellY) used during cache building are NOT stored here.
|
||||||
struct FCachedRoom
|
struct FCachedRoom
|
||||||
@@ -361,6 +589,10 @@ struct FCachedRoom
|
|||||||
float FloorReliefFrequency;
|
float FloorReliefFrequency;
|
||||||
uint32 FloorSeed; // Deterministic seed offset for this room's floor noise
|
uint32 FloorSeed; // Deterministic seed offset for this room's floor noise
|
||||||
|
|
||||||
|
// Optional build-time floor benches leading to higher tunnel mouths. Empty in the default
|
||||||
|
// compatibility profile, so the baseline field is unchanged until an archetype opts in.
|
||||||
|
TArray<FCachedRoomMouthRise> MouthRises;
|
||||||
|
|
||||||
// Per-room terrain operation, hash-rolled during BuildChunkCache from the
|
// Per-room terrain operation, hash-rolled during BuildChunkCache from the
|
||||||
// strate's probability pool (FStrateTerrainOpEntry::Probability).
|
// strate's probability pool (FStrateTerrainOpEntry::Probability).
|
||||||
// null = this room has no terrain op (the "no op" slot in the probability pool).
|
// null = this room has no terrain op (the "no op" slot in the probability pool).
|
||||||
@@ -382,26 +614,173 @@ struct FCachedRoom
|
|||||||
FVector ShapeA = FVector::ZeroVector;
|
FVector ShapeA = FVector::ZeroVector;
|
||||||
FVector ShapeB = FVector::ZeroVector;
|
FVector ShapeB = FVector::ZeroVector;
|
||||||
float ShapeR = 0.0f;
|
float ShapeR = 0.0f;
|
||||||
|
|
||||||
|
// Hash-derived room details. The old evaluator rebuilt these hashes and angles for every
|
||||||
|
// surface sample; the cache build is the only place where their geometry can change.
|
||||||
|
FCachedArch Arches[3];
|
||||||
|
FCachedPinch Pinches[3];
|
||||||
};
|
};
|
||||||
|
|
||||||
// A pre-computed tunnel segment — all connection decisions and hash-derived
|
// A local flat-floor bridge over the overlap of two room bodies. It is not a new graph edge: it
|
||||||
// properties (radius, Z offset, midpoint warp) are resolved during cache build.
|
// only gives an existing spherical-room intersection one shared walkable floor level, removing
|
||||||
|
// the high lip that otherwise appears when the two room floor cuts disagree.
|
||||||
|
struct FCachedRoomFloorJoin
|
||||||
|
{
|
||||||
|
FVector Start;
|
||||||
|
FVector End;
|
||||||
|
float Radius = 0.0f;
|
||||||
|
float FloorZ = 0.0f;
|
||||||
|
float CeilingZ = 0.0f;
|
||||||
|
FVector BoundCenter;
|
||||||
|
float BoundRadiusSq = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One immutable floor segment authored while the tunnel chain is built. The floor is continuous
|
||||||
|
// unless bLedgeTransition is set; that marker means the segment terminates at a deliberate
|
||||||
|
// dramatic ledge and the vertical riser is placed at the next control-point boundary. NumSteps is
|
||||||
|
// retained as a compatibility/diagnostic field and is always zero for newly authored tunnels: a
|
||||||
|
// steep floor is never quantised into a staircase. ReliefScale is baked with a derivative bound,
|
||||||
|
// so evaluation only reads this profile and never decides a floor shape per voxel.
|
||||||
|
struct FTunnelFloorSegmentProfile
|
||||||
|
{
|
||||||
|
float StartFloorZ = 0.0f;
|
||||||
|
float EndFloorZ = 0.0f;
|
||||||
|
// The unmodified tapered-capsule bottom at the same two control points. Keeping these
|
||||||
|
// alongside the authored floor lets evaluation translate the arch to the baked floor without
|
||||||
|
// re-deriving a second profile (and makes the build-time floor/arch relationship explicit).
|
||||||
|
float NaturalStartFloorZ = 0.0f;
|
||||||
|
float NaturalEndFloorZ = 0.0f;
|
||||||
|
float ReliefScale = 0.0f;
|
||||||
|
bool bLedgeTransition = false;
|
||||||
|
// Deprecated staircase count. New profiles leave this at zero; keep the member so old
|
||||||
|
// diagnostic consumers can still compile while the authored representation has one meaning.
|
||||||
|
int32 NumSteps = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A pre-computed wandering tunnel chain — all connection decisions and hash-derived properties
|
||||||
|
// (radii, Z offsets, control points) are resolved during cache build.
|
||||||
struct FCachedTunnel
|
struct FCachedTunnel
|
||||||
{
|
{
|
||||||
FVector EndpointA; // First room's connection point (with Z offset)
|
FVector EndpointA; // First room's floor-tangent connection point in SDF coordinates
|
||||||
FVector EndpointB; // Second room's connection point (with Z offset)
|
FVector EndpointB; // Second room's floor-tangent connection point in SDF coordinates
|
||||||
float RadiusA; // Tube radius at endpoint A
|
float RadiusA; // Tube radius at endpoint A
|
||||||
float RadiusB; // Tube radius at endpoint B
|
float RadiusB; // Tube radius at endpoint B
|
||||||
// Warped midpoint — only used if bHasMidpoint is true.
|
// Compatibility summary of the chain's first/middle/last points. These fields are retained
|
||||||
// Creates a two-segment curved path instead of a straight tube.
|
// for diagnostics and old cache consumers; EvaluateSDFCached uses ControlPoints when present.
|
||||||
FVector Midpoint;
|
FVector Midpoint;
|
||||||
float RadiusMid; // Radius at the midpoint (average of A and B)
|
float RadiusMid;
|
||||||
bool bHasMidpoint; // True when TunnelWarpStrength > 0 and tunnel is long enough
|
bool bHasMidpoint;
|
||||||
|
TArray<FVector> ControlPoints;
|
||||||
|
TArray<float> ControlRadii;
|
||||||
|
// World-space copy used by the final structural air backstop. Evaluating this copy avoids
|
||||||
|
// turning a gentle authored floor ramp into a vertical break when the cave warp is nonlinear.
|
||||||
|
TArray<FVector> WorldControlPoints;
|
||||||
|
TArray<float> WorldControlRadii;
|
||||||
|
// Build-time floor authoring. SDF and world chains each keep their own profile because cave
|
||||||
|
// warp changes the control-point Z values. The normal path has one entry per chain segment;
|
||||||
|
// malformed/legacy caches fall back to the old local evaluator.
|
||||||
|
TArray<FTunnelFloorSegmentProfile> FloorProfiles;
|
||||||
|
TArray<FTunnelFloorSegmentProfile> WorldFloorProfiles;
|
||||||
|
// For a dramatic ledge, these monotonic levels identify the control-point boundaries at which
|
||||||
|
// the few large drops occur. Both SDF and world profiles share the boundaries, while each
|
||||||
|
// coordinate space supplies its own endpoint heights.
|
||||||
|
TArray<int32> FloorLedgeLevels;
|
||||||
|
bool bHasCompleteFloorProfile = false;
|
||||||
|
bool bHasCompleteWorldFloorProfile = false;
|
||||||
|
// The corridor floor is a swept SmoothMax cut, not a later slab. These are copied from the
|
||||||
|
// same strate fields used by room floors; the seed is derived once from the tunnel pair hash.
|
||||||
|
float FloorReliefStrength = 0.0f;
|
||||||
|
float FloorReliefFrequency = 0.015f;
|
||||||
|
uint32 FloorSeed = 0;
|
||||||
|
// Only the two rooms that actually form this edge may own its mouth floor. A nearby unrelated
|
||||||
|
// room must not clip a tunnel merely because the two shapes overlap in a crowded graph.
|
||||||
|
bool bHasFloorRoomOwnership = false;
|
||||||
|
uint32 FloorRoomHashA = 0;
|
||||||
|
uint32 FloorRoomHashB = 0;
|
||||||
|
// World-space mouth ownership is resolved from the baked chain, not by comparing a warped
|
||||||
|
// query against an SDF-space room. These values make the post-disturbance hand-off exact in
|
||||||
|
// the same coordinate space in which the player and the support backstop are evaluated.
|
||||||
|
// The room's full radius is deliberately not used as the ownership region. A tunnel's
|
||||||
|
// floor should hand off to the room over a short mouth apron, not overwrite the room floor
|
||||||
|
// across the entire chamber. These are the build-time capped hand-off radii in both query
|
||||||
|
// coordinate spaces.
|
||||||
|
float SDFMouthBlendRadiusA = 0.0f;
|
||||||
|
float SDFMouthBlendRadiusB = 0.0f;
|
||||||
|
float WorldMouthBlendRadiusA = 0.0f;
|
||||||
|
float WorldMouthBlendRadiusB = 0.0f;
|
||||||
|
float SDFMouthFloorZA = -FLT_MAX;
|
||||||
|
float SDFMouthFloorZB = -FLT_MAX;
|
||||||
|
float WorldMouthFloorZA = -FLT_MAX;
|
||||||
|
float WorldMouthFloorZB = -FLT_MAX;
|
||||||
|
bool bTunnelFloorTerracingEnabled = true;
|
||||||
|
float TunnelFloorTerraceStepHeight = 1.71f;
|
||||||
|
float TunnelFloorMaxLedgeHeight = 12.0f;
|
||||||
|
// Cached internal gradient derived from the authored degree value. This is not serialized.
|
||||||
|
float TunnelFloorGentleSlopeGradient = 0.9656888f;
|
||||||
|
int32 TunnelFloorLedgeCountPreference = 0;
|
||||||
|
int32 TunnelFloorMaxLedges = 4096;
|
||||||
|
bool bDramaticLedge = false;
|
||||||
|
bool bLedgeGraphEligible = false;
|
||||||
|
bool bFloorRouteWasWound = false;
|
||||||
|
int32 TunnelFloorAuthoredLedgeCount = 0;
|
||||||
|
FVector WorldBoundCenter = FVector::ZeroVector;
|
||||||
|
float WorldBoundRadiusSq = 0.0f;
|
||||||
|
// Centerline AABBs and scalar influence radii used by the immutable broad phase. The
|
||||||
|
// AABB test is a cheap lower bound on distance to the whole chain; the existing squared
|
||||||
|
// sphere test remains the exact conservative second-stage reject.
|
||||||
|
FVector SDFCenterlineMin = FVector::ZeroVector;
|
||||||
|
FVector SDFCenterlineMax = FVector::ZeroVector;
|
||||||
|
float SDFInfluenceRadius = 0.0f;
|
||||||
|
FVector WorldCenterlineMin = FVector::ZeroVector;
|
||||||
|
FVector WorldCenterlineMax = FVector::ZeroVector;
|
||||||
|
float WorldInfluenceRadius = 0.0f;
|
||||||
// Bounding sphere for quick per-voxel rejection
|
// Bounding sphere for quick per-voxel rejection
|
||||||
FVector BoundCenter; // Center of the bounding sphere
|
FVector BoundCenter; // Center of the bounding sphere
|
||||||
float BoundRadiusSq; // Squared radius — if voxel is further, skip this tunnel
|
float BoundRadiusSq; // Squared radius — if voxel is further, skip this tunnel
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct FTunnelSupportFloorInterval
|
||||||
|
{
|
||||||
|
int32 TunnelIndex = INDEX_NONE;
|
||||||
|
float FloorZ = 0.0f;
|
||||||
|
float MinZ = 0.0f;
|
||||||
|
float MaxZ = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FTunnelSupportFloorColumn
|
||||||
|
{
|
||||||
|
TArray<FTunnelSupportFloorInterval, TInlineAllocator<16>> Intervals;
|
||||||
|
|
||||||
|
FTunnelSupportFloorColumn() = default;
|
||||||
|
|
||||||
|
FTunnelSupportFloorColumn(const FTunnelSupportFloorColumn& Other)
|
||||||
|
: Intervals(Other.Intervals)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
FTunnelSupportFloorColumn& operator=(const FTunnelSupportFloorColumn& Other)
|
||||||
|
{
|
||||||
|
Intervals = Other.Intervals;
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
|
||||||
|
FTunnelSupportFloorColumn(FTunnelSupportFloorColumn&& Other)
|
||||||
|
: Intervals(MoveTemp(Other.Intervals))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
FTunnelSupportFloorColumn& operator=(FTunnelSupportFloorColumn&& Other)
|
||||||
|
{
|
||||||
|
Intervals = MoveTemp(Other.Intervals);
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Reset()
|
||||||
|
{
|
||||||
|
Intervals.Reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// A pre-baked pit shaft — position and dimensions resolved during BuildChunkCache.
|
// A pre-baked pit shaft — position and dimensions resolved during BuildChunkCache.
|
||||||
//
|
//
|
||||||
// Pits are included in the main SDF evaluation (SmoothMin alongside rooms and
|
// Pits are included in the main SDF evaluation (SmoothMin alongside rooms and
|
||||||
@@ -448,16 +827,128 @@ struct FCachedColumn
|
|||||||
float BoundXYRadiusSq;
|
float BoundXYRadiusSq;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build-site attribution is diagnostic only. It is deliberately passed to BuildChunkCache
|
||||||
|
// rather than inferred from the caller's stack so the game report can separate the generator,
|
||||||
|
// ordinary op-source, shared op-source, and classifier fallback windows without changing the
|
||||||
|
// cache key or the generated field.
|
||||||
|
enum class ERoomGraphBuildSite : uint8
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
GeneratorTile,
|
||||||
|
GeneratorTunnelCore,
|
||||||
|
OpShared,
|
||||||
|
OpLocal,
|
||||||
|
ClassifierShared,
|
||||||
|
ClassifierLocal,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Immutable XY broad phase for one cached primitive array. Each bucket stores source-array
|
||||||
|
// indices in ascending order, so switching from a full scan to a bucket scan cannot change the
|
||||||
|
// order of SmoothMin/Min reductions or the selected nearest-room owner. Invalid/oversized
|
||||||
|
// bounds leave bValid false and callers deliberately fall back to the exact full scan.
|
||||||
|
struct FChunkSDFSpatialIndex
|
||||||
|
{
|
||||||
|
int32 MinChunkX = 0;
|
||||||
|
int32 MinChunkY = 0;
|
||||||
|
int32 NumCellsX = 0;
|
||||||
|
int32 NumCellsY = 0;
|
||||||
|
bool bValid = false;
|
||||||
|
TArray<int32> CellOffsets;
|
||||||
|
TArray<int32> ItemIndices;
|
||||||
|
|
||||||
|
void Reset();
|
||||||
|
void Release();
|
||||||
|
SIZE_T GetAllocatedSize() const;
|
||||||
|
bool GetRange(float WorldX, float WorldY, int32& OutBegin, int32& OutEnd) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
template <typename FVisit>
|
||||||
|
FORCEINLINE int32 VF_ForEachChunkSDFSpatialCandidate(
|
||||||
|
const FChunkSDFSpatialIndex& Index, int32 ItemCount,
|
||||||
|
float WorldX, float WorldY, FVisit&& Visit,
|
||||||
|
bool bUseSpatialIndex = true)
|
||||||
|
{
|
||||||
|
int32 Begin = 0;
|
||||||
|
int32 End = 0;
|
||||||
|
int32 CandidateCount = 0;
|
||||||
|
if (bUseSpatialIndex && Index.GetRange(WorldX, WorldY, Begin, End))
|
||||||
|
{
|
||||||
|
for (int32 Cursor = Begin; Cursor < End; ++Cursor)
|
||||||
|
{
|
||||||
|
const int32 ItemIndex = Index.ItemIndices[Cursor];
|
||||||
|
if (ItemIndex >= 0 && ItemIndex < ItemCount)
|
||||||
|
{
|
||||||
|
++CandidateCount;
|
||||||
|
Visit(ItemIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CandidateCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 ItemIndex = 0; ItemIndex < ItemCount; ++ItemIndex)
|
||||||
|
{
|
||||||
|
++CandidateCount;
|
||||||
|
Visit(ItemIndex);
|
||||||
|
}
|
||||||
|
return CandidateCount;
|
||||||
|
}
|
||||||
|
|
||||||
// The complete SDF cache for a chunk region.
|
// The complete SDF cache for a chunk region.
|
||||||
// Built once per chunk by BuildChunkCache(), then passed to EvaluateSDFCached()
|
// Built once per chunk by BuildChunkCache(), then passed to EvaluateSDFCached()
|
||||||
// for every voxel in the chunk. Typically contains 5-15 rooms and 10-30 tunnels.
|
// for every voxel in the chunk. Typically contains 5-15 rooms and 10-30 tunnels.
|
||||||
struct FChunkSDFCache
|
struct FChunkSDFCache
|
||||||
{
|
{
|
||||||
TArray<FCachedRoom> Rooms;
|
TArray<FCachedRoom> Rooms;
|
||||||
|
TArray<FCachedRoomFloorJoin> RoomFloorJoins;
|
||||||
TArray<FCachedTunnel> Tunnels;
|
TArray<FCachedTunnel> Tunnels;
|
||||||
TArray<FCachedPit> Pits;
|
TArray<FCachedPit> Pits;
|
||||||
TArray<FCachedChimney> Chimneys;
|
TArray<FCachedChimney> Chimneys;
|
||||||
TArray<FCachedColumn> Columns;
|
TArray<FCachedColumn> Columns;
|
||||||
|
|
||||||
|
// Per-primitive XY filters. The bounds are conservative unions of every exact evaluator
|
||||||
|
// broad phase, so a failed index build is a performance fallback, never a density fallback.
|
||||||
|
FChunkSDFSpatialIndex RoomSpatialIndex;
|
||||||
|
FChunkSDFSpatialIndex RoomFloorJoinSpatialIndex;
|
||||||
|
FChunkSDFSpatialIndex TunnelSpatialIndex;
|
||||||
|
FChunkSDFSpatialIndex PitSpatialIndex;
|
||||||
|
FChunkSDFSpatialIndex ChimneySpatialIndex;
|
||||||
|
FChunkSDFSpatialIndex ColumnSpatialIndex;
|
||||||
|
|
||||||
|
// Kept with the cache because the world-space structural evaluator has no params argument.
|
||||||
|
// A zero value is a valid fallback for legacy/empty caches.
|
||||||
|
float SDFBlendRadius = 0.0f;
|
||||||
|
|
||||||
|
// Allocator-backed bytes owned by this cache, including the nested tunnel chains. The returned
|
||||||
|
// value excludes sizeof(FChunkSDFCache) itself so callers can add the enclosing entry's inline
|
||||||
|
// storage exactly once.
|
||||||
|
// Reset keeps the allocated backing storage and clears the immutable contents.
|
||||||
|
// It is used when a classifier proves that the current search window has no
|
||||||
|
// possible room/tunnel feature.
|
||||||
|
void Reset();
|
||||||
|
// Release all backing storage. Used when a worker has moved from a populated
|
||||||
|
// classifier window to a feature-free one and the retained capacity is no
|
||||||
|
// longer useful; this is intentionally separate from hot-path Reset().
|
||||||
|
void Release();
|
||||||
|
SIZE_T GetAllocatedSize() const;
|
||||||
|
VoxelDensityProfile::FCacheMemoryBreakdown GetAllocatedSizeBreakdown() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** World-space tunnel shape query. Legacy support-floor fields remain source-compatible but are
|
||||||
|
* no longer produced or consumed by the graph-tunnel generation path. */
|
||||||
|
struct FTunnelCoreWorldEvaluation
|
||||||
|
{
|
||||||
|
float SDF = FLT_MAX;
|
||||||
|
// Deprecated compatibility output. The separate graph-tunnel support slab was removed.
|
||||||
|
bool bSupportFloor = false;
|
||||||
|
// Room geometry owns the lower side while the world-space tunnel is inside a room. The
|
||||||
|
// tunnel SDF may still reopen air there, but its swept bottom must not turn that room air back
|
||||||
|
// into a shelf or wall.
|
||||||
|
bool bRoomFloor = false;
|
||||||
|
// Shape-level floor ownership. This is not an authored slab: it identifies the bottom side
|
||||||
|
// of the swept tunnel SDF so a room that is already air cannot erase the tunnel's own floor.
|
||||||
|
bool bHasSweptFloor = false;
|
||||||
|
float SweptFloorZ = -FLT_MAX;
|
||||||
|
float SweptFloorRadius = 0.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -468,6 +959,31 @@ struct FChunkSDFCache
|
|||||||
|
|
||||||
namespace VoxelCaveMorphology
|
namespace VoxelCaveMorphology
|
||||||
{
|
{
|
||||||
|
// The player-fit memo is process-wide and uses a fixed-size table. These controls are kept
|
||||||
|
// beside the cave source so the command-line value is applied before generation workers start,
|
||||||
|
// while the CVar remains available for an in-process A/B switch.
|
||||||
|
VOXELFORGE_API void ConfigurePlayerFitMemoFromCommandLine();
|
||||||
|
VOXELFORGE_API void ResetPlayerFitMemoStats();
|
||||||
|
VOXELFORGE_API void LogPlayerFitMemoStats();
|
||||||
|
|
||||||
|
// The room graph's seed is the world seed salted by the strate index. Keep this formula
|
||||||
|
// shared by BuildChunkCache and landing-site queries so a query cannot inspect another
|
||||||
|
// strate's room layout. / La seed du graphe des salles est la seed monde salée par l'index
|
||||||
|
// de strate. La formule est partagée entre BuildChunkCache et les requêtes d'atterrissage.
|
||||||
|
FORCEINLINE uint32 MakeStrateSeed(uint32 WorldSeed, int32 StrateIndex)
|
||||||
|
{
|
||||||
|
return VoxelHash::Mix(WorldSeed ^ (uint32)(StrateIndex * 7919 + 104729));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a world-space query to the SDF coordinate space used by the room/tunnel cache. Keeping
|
||||||
|
// this transform in the morphology module lets the generator's post-disturbance structural
|
||||||
|
// check share the exact same warp as the source and the landing query.
|
||||||
|
VOXELFORGE_API FVector ApplyCaveWarp(
|
||||||
|
const FVector& WorldPoint,
|
||||||
|
const FStrateGenerationParams& Params,
|
||||||
|
uint32 Seed
|
||||||
|
);
|
||||||
|
|
||||||
// PHASE 1: Build the SDF cache for a rectangular region.
|
// PHASE 1: Build the SDF cache for a rectangular region.
|
||||||
// Collects all rooms, computes nearest-neighbor backbone, decides tunnel
|
// Collects all rooms, computes nearest-neighbor backbone, decides tunnel
|
||||||
// connections, and pre-computes all tunnel geometry (radii, offsets, midpoints).
|
// connections, and pre-computes all tunnel geometry (radii, offsets, midpoints).
|
||||||
@@ -484,12 +1000,36 @@ namespace VoxelCaveMorphology
|
|||||||
// @param Seed — world seed
|
// @param Seed — world seed
|
||||||
// @param StrateIndex — which strate (offsets hash for variety)
|
// @param StrateIndex — which strate (offsets hash for variety)
|
||||||
// @param TerrainOps — optional probability pool; null = no per-room ops
|
// @param TerrainOps — optional probability pool; null = no per-room ops
|
||||||
void BuildChunkCache(
|
VOXELFORGE_API void BuildChunkCache(
|
||||||
FChunkSDFCache& OutCache,
|
FChunkSDFCache& OutCache,
|
||||||
float SearchMinX, float SearchMinY,
|
float SearchMinX, float SearchMinY,
|
||||||
float SearchMaxX, float SearchMaxY,
|
float SearchMaxX, float SearchMaxY,
|
||||||
const FStrateGenerationParams& Params,
|
const FStrateGenerationParams& Params,
|
||||||
uint32 Seed, int32 StrateIndex,
|
uint32 Seed, int32 StrateIndex,
|
||||||
|
const TArray<FStrateTerrainOpEntry>* TerrainOps = nullptr,
|
||||||
|
ERoomGraphBuildSite BuildSite = ERoomGraphBuildSite::Unknown
|
||||||
|
);
|
||||||
|
|
||||||
|
// Conservative, geometry-free preflight for BuildChunkCache. It recreates
|
||||||
|
// the deterministic room candidates and edge decisions, but does not build
|
||||||
|
// player-fit points or tunnel control chains. False means that the same
|
||||||
|
// cache window is guaranteed to contain no room, join, decoration, or
|
||||||
|
// connected tunnel. Any malformed or unbounded input returns true.
|
||||||
|
VOXELFORGE_API bool MayHaveFeatureInSearchBox(
|
||||||
|
float SearchMinX, float SearchMinY,
|
||||||
|
float SearchMaxX, float SearchMaxY,
|
||||||
|
const FStrateGenerationParams& Params,
|
||||||
|
uint32 Seed, int32 StrateIndex,
|
||||||
|
bool bUseZ = false,
|
||||||
|
float SearchMinZ = 0.0f, float SearchMaxZ = 0.0f,
|
||||||
|
const TArray<FStrateTerrainOpEntry>* TerrainOps = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
// True only for the finite parameter envelope for which BuildChunkCache's fixed collect
|
||||||
|
// margin proves window invariance. Callers may widen the XY store window to a whole tile only
|
||||||
|
// when this returns true; malformed or unbounded authored input must retain the legacy path.
|
||||||
|
VOXELFORGE_API bool IsRoomGraphWindowInvariant(
|
||||||
|
const FStrateGenerationParams& Params,
|
||||||
const TArray<FStrateTerrainOpEntry>* TerrainOps = nullptr
|
const TArray<FStrateTerrainOpEntry>* TerrainOps = nullptr
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -498,20 +1038,104 @@ namespace VoxelCaveMorphology
|
|||||||
// SmoothMin. Includes per-room/tunnel distance culling to skip primitives
|
// SmoothMin. Includes per-room/tunnel distance culling to skip primitives
|
||||||
// that are too far to contribute.
|
// that are too far to contribute.
|
||||||
//
|
//
|
||||||
// @param WorldX, WorldY, WorldZ — position in voxel coordinates (may be warped)
|
// @param WorldX, WorldY, WorldZ — room/query position in voxel coordinates (may be warped)
|
||||||
// @param Cache — pre-built cache from BuildChunkCache
|
// @param Cache — pre-built cache from BuildChunkCache
|
||||||
// @param SDFBlendRadius — SmoothMin blend radius (from Params.SDFBlendRadius)
|
// @param SDFBlendRadius — SmoothMin blend radius (from Params.SDFBlendRadius)
|
||||||
// @param OutNearestRoomIdx — optional out: index of the room with minimum SDF
|
// @param OutNearestRoomIdx — optional out: index of the room with minimum SDF
|
||||||
// contribution. -1 if no room passed the cull test.
|
// contribution. -1 if no room passed the cull test.
|
||||||
// Used by the terrain ops system to look up the
|
// Used by the terrain ops system to look up the
|
||||||
// per-room terrain op assigned to this voxel's room.
|
// per-room terrain op assigned to this voxel's room.
|
||||||
|
// @param WorldTunnelPosition — optional unwarped position for the tunnel primitive. When
|
||||||
|
// supplied, rooms remain at the query position but the tunnel
|
||||||
|
// uses its authored world chain. Null preserves the legacy
|
||||||
|
// all-query behavior.
|
||||||
|
// @param OutRoomOwnsBottom — optional out: true when the query position is inside any
|
||||||
|
// cached room's raw authored shape. When the caller evaluates
|
||||||
|
// the tunnel core for this same sample, pass this result back
|
||||||
|
// to EvaluateTunnelCoreWorld to avoid a second room query.
|
||||||
// (Room shape variety is baked into FCachedRoom by BuildChunkCache — no per-voxel roll.)
|
// (Room shape variety is baked into FCachedRoom by BuildChunkCache — no per-voxel roll.)
|
||||||
// @return negative = inside cave, positive = solid rock
|
// @return negative = inside cave, positive = solid rock
|
||||||
float EvaluateSDFCached(
|
VOXELFORGE_API float EvaluateSDFCached(
|
||||||
float WorldX, float WorldY, float WorldZ,
|
float WorldX, float WorldY, float WorldZ,
|
||||||
const FChunkSDFCache& Cache,
|
const FChunkSDFCache& Cache,
|
||||||
float SDFBlendRadius,
|
float SDFBlendRadius,
|
||||||
int32* OutNearestRoomIdx = nullptr
|
int32* OutNearestRoomIdx = nullptr,
|
||||||
|
bool bUseSpatialIndex = true,
|
||||||
|
const FVector* WorldTunnelPosition = nullptr,
|
||||||
|
bool* OutRoomOwnsBottom = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
// Evaluate the raw union of cached graph-tunnel capsules. This is intentionally separate from
|
||||||
|
// the SmoothMin morphology: the generator uses it after terrain/passage posts to reassert the
|
||||||
|
// tunnel's walkable air core without reintroducing a connector or changing room ownership.
|
||||||
|
VOXELFORGE_API float EvaluateTunnelCoreSDF(
|
||||||
|
float WorldX, float WorldY, float WorldZ,
|
||||||
|
const FChunkSDFCache& Cache,
|
||||||
|
bool bUseSpatialIndex = true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Evaluate the final structural air contract against the world-space wandering chain. Room
|
||||||
|
// SDFs remain evaluated in warped coordinates; only this post uses world coordinates so its
|
||||||
|
// walkable floor follows the authored chain exactly.
|
||||||
|
VOXELFORGE_API float EvaluateTunnelCoreWorldSDF(
|
||||||
|
float WorldX, float WorldY, float WorldZ,
|
||||||
|
const FChunkSDFCache& Cache,
|
||||||
|
bool bUseSpatialIndex = true
|
||||||
|
);
|
||||||
|
|
||||||
|
VOXELFORGE_API FTunnelCoreWorldEvaluation EvaluateTunnelCoreWorld(
|
||||||
|
float WorldX, float WorldY, float WorldZ,
|
||||||
|
const FChunkSDFCache& Cache,
|
||||||
|
// Deprecated legacy argument; graph-tunnel evaluation ignores the old support column.
|
||||||
|
const FTunnelSupportFloorColumn* SupportColumn = nullptr,
|
||||||
|
bool bUseSpatialIndex = true,
|
||||||
|
// The room graph is evaluated in warped coordinates while the tunnel core is evaluated in
|
||||||
|
// the authored world frame. Pass the former when available so room ownership is tested in
|
||||||
|
// the same domain as the generic room field.
|
||||||
|
const FVector* RoomQueryPosition = nullptr,
|
||||||
|
// Optional result from EvaluateSDFCached for this exact sample. When supplied, it avoids
|
||||||
|
// repeating the room-shape containment query; null preserves standalone-call behavior.
|
||||||
|
const bool* CachedRoomOwnsBottom = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-tile broad reach for the world-space core/floor post. The bound includes the swept
|
||||||
|
// chain, floor band, relief envelope, and the finite room-mouth ownership apron.
|
||||||
|
VOXELFORGE_API bool AnyTunnelCoreWorldNearLattice(
|
||||||
|
const FChunkSDFCache& Cache,
|
||||||
|
const FBox& VoxelBox,
|
||||||
|
float ReachScale = 1.0f
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deprecated compatibility query for the removed graph-tunnel support slab. Production
|
||||||
|
// generation does not call this predicate; the tunnel's own swept shape owns its floor.
|
||||||
|
VOXELFORGE_API bool IsTunnelSupportFloorWorldPoint(
|
||||||
|
float WorldX, float WorldY, float WorldZ,
|
||||||
|
const FChunkSDFCache& Cache,
|
||||||
|
bool bUseSpatialIndex = true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deprecated compatibility helper for callers that still inspect the old support-band
|
||||||
|
// diagnostic. It is detached from production density generation.
|
||||||
|
VOXELFORGE_API void BuildTunnelSupportFloorColumn(
|
||||||
|
float WorldX, float WorldY,
|
||||||
|
const FChunkSDFCache& Cache,
|
||||||
|
FTunnelSupportFloorColumn& OutColumn,
|
||||||
|
bool bUseSpatialIndex = true
|
||||||
|
);
|
||||||
|
|
||||||
|
VOXELFORGE_API bool IsTunnelSupportFloorColumnZ(
|
||||||
|
float WorldZ,
|
||||||
|
const FTunnelSupportFloorColumn& Column
|
||||||
|
);
|
||||||
|
|
||||||
|
// Returns the legacy projected band for one tunnel at this XY column. The world evaluator no
|
||||||
|
// longer uses it to write a support slab.
|
||||||
|
VOXELFORGE_API bool GetTunnelSupportFloorColumnBand(
|
||||||
|
int32 TunnelIndex,
|
||||||
|
const FTunnelSupportFloorColumn& Column,
|
||||||
|
float& OutFloorZ,
|
||||||
|
float& OutMinZ,
|
||||||
|
float& OutMaxZ
|
||||||
);
|
);
|
||||||
|
|
||||||
// CONVENIENCE WRAPPER: builds a temporary cache and evaluates in one call.
|
// CONVENIENCE WRAPPER: builds a temporary cache and evaluates in one call.
|
||||||
@@ -523,3 +1147,70 @@ namespace VoxelCaveMorphology
|
|||||||
uint32 Seed, int32 StrateIndex
|
uint32 Seed, int32 StrateIndex
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest a deterministic player-fit landing point for an inter-strate passage mouth.
|
||||||
|
*
|
||||||
|
* This is deliberately a pure source query: it does not touch a generator, an operator stack,
|
||||||
|
* a strate manager, a cache, or mutable state. DesiredX/DesiredY is the passage's deliberate
|
||||||
|
* placement around the (0,0) spine. Sparse sources may move laterally, but never farther than
|
||||||
|
* MaxLateralSnap; false means no confident player-fit pose exists inside that bound. A successful
|
||||||
|
* point has a locally supported floor within player step height, a walkable support patch, and a
|
||||||
|
* clear capsule volume. The local source stencil deliberately does not claim flood-fill
|
||||||
|
* connectivity or a measured component size; it selects a topology-backed feature core instead.
|
||||||
|
* For TunnelNetwork/Underwater, Seed must be the strate-specific room seed returned by
|
||||||
|
* VoxelCaveMorphology::MakeStrateSeed; the optional WorldSeed selects the production cave-warp
|
||||||
|
* field and should be the generator world seed. If omitted, the room seed is used for both for
|
||||||
|
* backward-compatible standalone queries. For slab, maze, shaft, and island archetypes, Seed is the
|
||||||
|
* generator world seed consumed by their source. SurfaceWorld deliberately has no answer here:
|
||||||
|
* its production height can be selected by manager-owned biome context and per-chunk state,
|
||||||
|
* which this re-entrant query cannot inspect.
|
||||||
|
*
|
||||||
|
* Propose un point d'atterrissage déterministe où la capsule du joueur tient réellement pour la
|
||||||
|
* bouche d'un passage inter-strates. Cette requête reste pure et le déplacement latéral est
|
||||||
|
* strictement borné par MaxLateralSnap; elle ne prétend pas prouver une composante connectée.
|
||||||
|
* Pour VerticalShafts, la réponse s'appuie sur l'axe d'un puits sélectionné et peut se déplacer
|
||||||
|
* vers une banquette déterministe (+X/+Y) pour obtenir un support; ce n'est pas un point ouvert
|
||||||
|
* arbitraire à l'intérieur de son disque.
|
||||||
|
*
|
||||||
|
* @return true when the archetype can provide a confident point; false when it cannot.
|
||||||
|
*/
|
||||||
|
VOXELFORGE_API bool VF_SuggestLandingPoint(
|
||||||
|
ECaveGeneratorType Archetype,
|
||||||
|
const FStrateGenerationParams& CaveParams,
|
||||||
|
const FSlabGenerationParams& SlabParams,
|
||||||
|
int32 Seed,
|
||||||
|
float StrateTopZ,
|
||||||
|
float StrateBottomZ,
|
||||||
|
float DesiredX,
|
||||||
|
float DesiredY,
|
||||||
|
float MaxLateralSnap,
|
||||||
|
FVector& OutPoint,
|
||||||
|
int32 WorldSeed = MIN_int32
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete player-fit source-query overload for archetypes whose placement parameters are not part of
|
||||||
|
* FStrateGenerationParams/FSlabGenerationParams. The extra structs are read-only inputs only;
|
||||||
|
* the function remains pure and SurfaceWorld still returns false for the reason above. The
|
||||||
|
* returned point is either at the requested XY or within MaxLateralSnap of it, and is accepted
|
||||||
|
* only after the local player capsule/support stencil passes. VerticalShafts uses a selected
|
||||||
|
* shaft's deterministic feature core and ledge-side pose; the axis identifies the topology but
|
||||||
|
* is not itself claimed to be a standable point.
|
||||||
|
*/
|
||||||
|
VOXELFORGE_API bool VF_SuggestLandingPoint(
|
||||||
|
ECaveGeneratorType Archetype,
|
||||||
|
const FStrateGenerationParams& CaveParams,
|
||||||
|
const FSlabGenerationParams& SlabParams,
|
||||||
|
const FMazeGenerationParams& MazeParams,
|
||||||
|
const FVerticalShaftParams& VerticalShaftParams,
|
||||||
|
const FFloatingIslandParams& FloatingIslandParams,
|
||||||
|
int32 Seed,
|
||||||
|
float StrateTopZ,
|
||||||
|
float StrateBottomZ,
|
||||||
|
float DesiredX,
|
||||||
|
float DesiredY,
|
||||||
|
float MaxLateralSnap,
|
||||||
|
FVector& OutPoint,
|
||||||
|
int32 WorldSeed = MIN_int32
|
||||||
|
);
|
||||||
@@ -120,6 +120,11 @@ public:
|
|||||||
* future rebuild — this patches the LIVE instances. Returns how many were removed. Game-thread. */
|
* future rebuild — this patches the LIVE instances. Returns how many were removed. Game-thread. */
|
||||||
int32 RemoveDecorationsInSphere(const FVector& WorldCenter, float WorldRadius);
|
int32 RemoveDecorationsInSphere(const FVector& WorldCenter, float WorldRadius);
|
||||||
|
|
||||||
|
/** Invalidate decoration regions after a terrain edit. Existing regions and in-flight builds are
|
||||||
|
* discarded together, so an old worker result cannot resurrect pre-edit content; the next update
|
||||||
|
* rebuilds the desired cells from the edited density field. Game-thread. */
|
||||||
|
void InvalidateDecorationBuilds();
|
||||||
|
|
||||||
/** Destroy all spawned content (decorations + water). Regenerate / season reset. Bumps the deco
|
/** Destroy all spawned content (decorations + water). Regenerate / season reset. Bumps the deco
|
||||||
* epoch so any in-flight march tasks' results are discarded. */
|
* epoch so any in-flight march tasks' results are discarded. */
|
||||||
void ClearAll();
|
void ClearAll();
|
||||||
@@ -179,6 +184,9 @@ private:
|
|||||||
FPlacementProfile Profile; // representative profile (mesh + HISM render tuning: cull/shadow) —
|
FPlacementProfile Profile; // representative profile (mesh + HISM render tuning: cull/shadow) —
|
||||||
// may be a decoration entry's OR a companion's (F7).
|
// may be a decoration entry's OR a companion's (F7).
|
||||||
TArray<FTransform> Xforms;
|
TArray<FTransform> Xforms;
|
||||||
|
int32 SourceEntryIdx = INT32_MAX;
|
||||||
|
int32 SourceCompanionIdx = INT32_MAX;
|
||||||
|
int32 SourceSubIdx = INT32_MAX;
|
||||||
};
|
};
|
||||||
// A non-instanced actor placement, spawned when the region is applied.
|
// A non-instanced actor placement, spawned when the region is applied.
|
||||||
struct FRegionActorSpawn
|
struct FRegionActorSpawn
|
||||||
@@ -287,6 +295,7 @@ private:
|
|||||||
void ClearAllDecorations();
|
void ClearAllDecorations();
|
||||||
void DrainDecoResults(); // discard every queued march result
|
void DrainDecoResults(); // discard every queued march result
|
||||||
static void ResetGridBuildState(FDecoGrid& G); // drop builds/queues (loaded regions untouched)
|
static void ResetGridBuildState(FDecoGrid& G); // drop builds/queues (loaded regions untouched)
|
||||||
|
void JoinDecorationTasks(); // real per-manager lifetime join
|
||||||
// Region size in cells, clamped (>=1). Cell↔region math lives in file-static helpers in the .cpp.
|
// Region size in cells, clamped (>=1). Cell↔region math lives in file-static helpers in the .cpp.
|
||||||
int32 RegionSize() const;
|
int32 RegionSize() const;
|
||||||
|
|
||||||
@@ -353,10 +362,18 @@ private:
|
|||||||
|
|
||||||
// Set in BeginDestroy; worker tasks check it before touching us.
|
// Set in BeginDestroy; worker tasks check it before touching us.
|
||||||
std::atomic<bool> bShuttingDown{false};
|
std::atomic<bool> bShuttingDown{false};
|
||||||
|
// Per-manager task count. A process-global count would make one world's shutdown wait on
|
||||||
|
// another world's decoration work and cannot prove this manager's UObject is safe to tear down.
|
||||||
|
std::atomic<int32> ActiveDecorationTasks{0};
|
||||||
|
|
||||||
// Streaming state. INT_MIN sentinels force a full rebuild on the first update / after ClearAll.
|
// Streaming state. INT_MIN sentinels force a full rebuild on the first update / after ClearAll.
|
||||||
FIntPoint LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
FIntPoint LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||||||
int32 LastStrateIndex = INT32_MIN;
|
int32 LastStrateIndex = INT32_MIN;
|
||||||
|
int32 LastNearRadius = INT32_MIN;
|
||||||
|
int32 LastFarRadius = INT32_MIN;
|
||||||
|
int32 LastNearSpacing = INT32_MIN;
|
||||||
|
int32 LastFarSpacing = INT32_MIN;
|
||||||
|
int32 LastRegionSize = INT32_MIN;
|
||||||
|
|
||||||
// Shared strate context for the current update (recomputed each UpdateDecorations; the launch step
|
// Shared strate context for the current update (recomputed each UpdateDecorations; the launch step
|
||||||
// copies the PODs into each task). Same for both grids — a strate is a horizontal slab.
|
// copies the PODs into each task). Same for both grids — a strate is a horizontal slab.
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// VoxelDensityAblation.h
|
||||||
|
// Development-only, world-changing measurement overrides for TunnelNetwork density stages.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
namespace VoxelDensityAblation
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* A bit in this mask means that the named stage is forced off. These are measurement
|
||||||
|
* overrides, not gameplay settings: they change the generated field, are resolved once per
|
||||||
|
* process, and must match across peers and regenerated worlds.
|
||||||
|
*/
|
||||||
|
enum class EStage : uint32
|
||||||
|
{
|
||||||
|
CaveWarp = 1u << 0,
|
||||||
|
DetailOps = 1u << 1,
|
||||||
|
RoomSDF = 1u << 2,
|
||||||
|
TunnelSDF = 1u << 3,
|
||||||
|
TunnelCore = 1u << 4,
|
||||||
|
PassageCarving = 1u << 5,
|
||||||
|
PassageStructuralPosts= 1u << 6,
|
||||||
|
NativeFloor = 1u << 7,
|
||||||
|
Disturbances = 1u << 8,
|
||||||
|
OriginSpine = 1u << 9,
|
||||||
|
BoundarySeal = 1u << 10,
|
||||||
|
LandingPosts = 1u << 11,
|
||||||
|
XYEdgeSeal = 1u << 12,
|
||||||
|
PitChimneySDF = 1u << 13,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve command-line/CVar values once and return the process-wide mask. */
|
||||||
|
VOXELFORGE_API uint32 GetResolvedMask();
|
||||||
|
|
||||||
|
FORCEINLINE bool IsForcedOff(EStage Stage)
|
||||||
|
{
|
||||||
|
return (GetResolvedMask() & static_cast<uint32>(Stage)) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE bool IsCaveWarpOff() { return IsForcedOff(EStage::CaveWarp); }
|
||||||
|
FORCEINLINE bool IsDetailOpsOff() { return IsForcedOff(EStage::DetailOps); }
|
||||||
|
FORCEINLINE bool IsRoomSDFOff() { return IsForcedOff(EStage::RoomSDF); }
|
||||||
|
FORCEINLINE bool IsTunnelSDFOff() { return IsForcedOff(EStage::TunnelSDF); }
|
||||||
|
FORCEINLINE bool IsTunnelCoreOff() { return IsForcedOff(EStage::TunnelCore); }
|
||||||
|
FORCEINLINE bool IsPassageCarvingOff() { return IsForcedOff(EStage::PassageCarving); }
|
||||||
|
FORCEINLINE bool IsPassageStructuralPostsOff()
|
||||||
|
{
|
||||||
|
return IsForcedOff(EStage::PassageStructuralPosts);
|
||||||
|
}
|
||||||
|
FORCEINLINE bool IsNativeFloorOff() { return IsForcedOff(EStage::NativeFloor); }
|
||||||
|
FORCEINLINE bool IsDisturbancesOff() { return IsForcedOff(EStage::Disturbances); }
|
||||||
|
FORCEINLINE bool IsOriginSpineOff() { return IsForcedOff(EStage::OriginSpine); }
|
||||||
|
FORCEINLINE bool IsBoundarySealOff() { return IsForcedOff(EStage::BoundarySeal); }
|
||||||
|
FORCEINLINE bool IsLandingPostsOff() { return IsForcedOff(EStage::LandingPosts); }
|
||||||
|
FORCEINLINE bool IsXYEdgeSealOff() { return IsForcedOff(EStage::XYEdgeSeal); }
|
||||||
|
FORCEINLINE bool IsPitChimneySDFOff() { return IsForcedOff(EStage::PitChimneySDF); }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// VoxelDensityOp.h
|
// VoxelDensityOp.h
|
||||||
// LE CONTRAT de la pile d'opérateurs de densité / THE density operator stack CONTRACT.
|
// LE CONTRAT de la pile d'opérateurs de densité / THE density operator stack CONTRACT.
|
||||||
// Phase 1 de OPSTACK-PLAN.md. HEADER SEUL — rien n'est encore branché dans GetDensityAt.
|
// Phase 1 de OPSTACK-PLAN.md. The contract is implemented by the opt-in stack path in
|
||||||
|
// UVoxelGenerator::GetDensityAt; the legacy archetype switch remains for non-opt-in strates.
|
||||||
//
|
//
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// POURQUOI / WHY
|
// POURQUOI / WHY
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
// 2. COMBINER — comment deux champs fusionnent (min/max/smooth/mask). C'EST le rôle qui
|
// 2. COMBINER — comment deux champs fusionnent (min/max/smooth/mask). C'EST le rôle qui
|
||||||
// achète la composition ; sans lui il n'y a pas de refactor.
|
// achète la composition ; sans lui il n'y a pas de refactor.
|
||||||
// 3. DETAIL MODIFIER — l'ancien système, rétrogradé à un rôle sur quatre. Inchangé.
|
// 3. DETAIL MODIFIER — l'ancien système, rétrogradé à un rôle sur quatre. Inchangé.
|
||||||
// 4. STRUCTURAL POST — spine (0,0) → seal → passages → diff layer. Des INVARIANTS de monde,
|
// 4. STRUCTURAL POST — spine (0,0) → seal vertical → passages → seal XY → diff layer. Des INVARIANTS de monde,
|
||||||
// pas des choix créatifs : toujours ajoutés, dans cet ordre, jamais
|
// pas des choix créatifs : toujours ajoutés, dans cet ordre, jamais
|
||||||
// omissibles par l'auteur.
|
// omissibles par l'auteur.
|
||||||
//
|
//
|
||||||
@@ -39,48 +40,57 @@
|
|||||||
// the descent structure intact no matter what an author assembles.
|
// the descent structure intact no matter what an author assembles.
|
||||||
//
|
//
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// LA CLÉ DE VOÛTE : EffectOverBox — direction, pas intervalle / THE KEYSTONE: direction, not intervals
|
// LA CLÉ DE VOÛTE : EffectOverBox + intervalle SDF / THE KEYSTONE: EffectOverBox + SDF interval
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// La version « complète » d'une borne rendrait un intervalle numérique. NE PAS COMMENCER LÀ.
|
// Un verdict de boîte doit être une preuve. Rendre `Both` coûte du CPU ; rendre `Identity` ou une
|
||||||
// Presque tout opérateur existant est UNIDIRECTIONNEL : il ne fait que creuser, ou que remplir.
|
// direction fausse supprime de la géométrie et peut laisser un joueur tomber au travers du monde.
|
||||||
// Cela suffit à reproduire GÉNÉRIQUEMENT chaque garde écrite à la main dans ClassifyTile :
|
// En cas de doute, `Mixed`.
|
||||||
|
//
|
||||||
|
// Un opérateur qui écrit seulement le canal SDF ne peut pas honnêtement répondre pour le convertisseur
|
||||||
|
// qui viendra peut-être après lui : il doit propager un intervalle SDF, et le convertisseur doit plier
|
||||||
|
// cet intervalle avec sa propre formule. Chaque opérateur répond ainsi pour lui-même, et la composition
|
||||||
|
// reste sûre même pour une combinaison que personne n'a écrite à la main auparavant.
|
||||||
|
//
|
||||||
|
// A box verdict is a proof. `Both` costs CPU; a false `Identity` or direction removes geometry and can
|
||||||
|
// let a player fall through the world. When in doubt, return `Mixed`.
|
||||||
|
// An SDF-only writer reports an SDF interval; a later converter folds that interval through its own
|
||||||
|
// response. Each operator answers for itself, so an unverified composition remains sound.
|
||||||
|
//
|
||||||
|
// La première version de cette interface n'avait qu'une direction numérique. Presque tout opérateur
|
||||||
|
// existant est UNIDIRECTIONNEL : il ne fait que creuser, ou que remplir. Cela suffit à reproduire
|
||||||
|
// GÉNÉRIQUEMENT chaque garde écrite à la main dans ClassifyTile :
|
||||||
//
|
//
|
||||||
// « passages ⇒ bCanSolid = false » EST CarveOnly
|
// « passages ⇒ bCanSolid = false » EST CarveOnly
|
||||||
// « ponts/arêtes ⇒ bCanAir = false » EST FillOnly
|
// « ponts/arêtes ⇒ bCanAir = false » EST FillOnly
|
||||||
// « aucun passage près de cette boîte » EST Identity
|
// « aucun passage près de cette boîte » EST Identity
|
||||||
//
|
//
|
||||||
// Donc la Phase 1 n'a besoin d'AUCUNE borne numérique et obtient déjà toute la propriété de
|
// La direction reste le repli conservateur par défaut pour les opérateurs qui ne connaissent pas
|
||||||
// sûreté. Les intervalles sont un resserrement ultérieur pour le coût de génération, pas un
|
// d'intervalle ; elle ne permet jamais à un écrivain SDF d'usurper la réponse d'un voisin.
|
||||||
// prérequis de correction. C'est ce qui rend le premier pas petit.
|
|
||||||
//
|
//
|
||||||
// Phase 1 needs NO numeric bounds and already gets the whole safety property. Intervals are a
|
// Direction remains the conservative fallback for operators without an interval implementation;
|
||||||
// later tightening for gen cost, not a correctness prerequisite.
|
// it never lets an SDF writer impersonate a downstream neighbour.
|
||||||
//
|
//
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// LE GROS LOT PERF : les strates de grotte ne sautent AUCUNE tuile aujourd'hui
|
// LE GROS LOT PERF : les strates de grotte ne sautent AUCUNE tuile aujourd'hui
|
||||||
// THE PERF PRIZE: cave strates skip ZERO tiles today
|
// THE PERF PRIZE: cave strates skip ZERO tiles today
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// ClassifyTile ne sait prouver que les gaps de bedrock et SurfaceWorld ; tout le reste tombe sur
|
// ClassifyTile conserve ses preuves exactes pour les gaps de bedrock et SurfaceWorld ; les
|
||||||
// `return EVoxelTileClass::Mixed; // archétype cave […] pas prouvable en v1`. TunnelNetwork, Maze,
|
// archétypes de cave opt-in passent maintenant par le même pliage `ClassifyBox`. Le post XY fournit
|
||||||
// VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent donc RIEN du
|
// en plus la première preuve globale de coque : une tuile entièrement dans la bande forcée peut être
|
||||||
// gain T1.d (84 % des générations vides, −44 % de CPU worker). Écrire un prouveur sur mesure par
|
// sautée sans connaître l'archétype ou le Z.
|
||||||
// archétype a toujours été trop cher — EffectOverBox EST le mécanisme générique qui le rend gratuit :
|
|
||||||
// une source à graphe de salles qui rend Identity quand aucune borne de salle ni de tunnel n'atteint
|
|
||||||
// la boîte rend le bedrock profond sautable pour la première fois.
|
|
||||||
//
|
//
|
||||||
// **Traiter cela comme un livrable explicite de chaque portage, pas comme un effet de bord.**
|
// **Traiter cela comme un livrable explicite de chaque portage, pas comme un effet de bord.**
|
||||||
//
|
//
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// ÉTAT / STATUS
|
// ÉTAT / STATUS
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// Phase 1. Le premier archétype (Maze) EST porté, dans VoxelDensityOpStack.{h,cpp} — mais
|
// Les huit archétypes ont maintenant une pile derrière l'opt-in `bUseOperatorStack`, et ClassifyTile
|
||||||
// **GetDensityAt et ClassifyTile ne sont PAS touchés** : le `switch` reste le seul chemin qui
|
// utilise cette pile pour les slots de cave opt-in. Le chemin legacy reste disponible pour les
|
||||||
// alimente le jeu. La pile est validée par un test qui la compare à GetMazeDensity point par point.
|
// strates non opt-in; les tests d'équivalence servent de garde de portage.
|
||||||
// Le branchement dans GetDensityAt attend un build vert.
|
|
||||||
//
|
//
|
||||||
// Phase 1. Maze IS ported (VoxelDensityOpStack.{h,cpp}) but **GetDensityAt and ClassifyTile are NOT
|
// All eight archetypes now have an operator-stack path behind `bUseOperatorStack`, and ClassifyTile
|
||||||
// touched** — the switch is still the only path feeding the game. The stack is validated by a test
|
// uses that stack for opt-in cave slots. The legacy switch remains available for opt-out strates;
|
||||||
// that compares it to GetMazeDensity point by point. Wiring it in waits for a green build.
|
// equivalence tests are port-correctness guards.
|
||||||
//
|
//
|
||||||
// NOTE sur les UENUM : ces types sont volontairement du C++ nu (pas d'UHT, pas de .generated.h).
|
// NOTE sur les UENUM : ces types sont volontairement du C++ nu (pas d'UHT, pas de .generated.h).
|
||||||
// Ils deviendront UENUM/USTRUCT en Phase 3, quand les opérateurs deviendront des data assets et
|
// Ils deviendront UENUM/USTRUCT en Phase 3, quand les opérateurs deviendront des data assets et
|
||||||
@@ -93,13 +103,15 @@
|
|||||||
#include "VoxelTypes.h" // CHUNK_SIZE, EVoxelTileClass
|
#include "VoxelTypes.h" // CHUNK_SIZE, EVoxelTileClass
|
||||||
|
|
||||||
struct FBiomeContext;
|
struct FBiomeContext;
|
||||||
|
struct FTunnelCoreWorldEvaluation;
|
||||||
|
struct FChunkSDFCache;
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// LES QUATRE RÔLES / THE FOUR ROLES
|
// LES QUATRE RÔLES / THE FOUR ROLES
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// Le rôle n'est pas décoratif : le compilateur de pile s'en sert pour ORDONNER. Les
|
// Le rôle n'est pas décoratif : le compilateur de pile s'en sert pour ORDONNER. Les
|
||||||
// StructuralPost sont toujours ajoutés en dernier, dans l'ordre fixe spine → seal → passage →
|
// StructuralPost sont toujours ajoutés en dernier, dans l'ordre fixe spine → seal → passage →
|
||||||
// diff, quoi que l'auteur ait assemblé. Un auteur ne peut pas les omettre : la descente doit
|
// seal XY → diff, quoi que l'auteur ait assemblé. Un auteur ne peut pas les omettre : la descente doit
|
||||||
// rester possible, les seals doivent tenir, les passages doivent percer, les éditions du joueur
|
// rester possible, les seals doivent tenir, les passages doivent percer, les éditions du joueur
|
||||||
// gagnent toujours.
|
// gagnent toujours.
|
||||||
enum class EVoxelOpRole : uint8
|
enum class EVoxelOpRole : uint8
|
||||||
@@ -117,6 +129,61 @@ enum class EVoxelOpRole : uint8
|
|||||||
StructuralPost,
|
StructuralPost,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// CANAUX DE L'ÉCHANTILLON / SAMPLE CHANNELS
|
||||||
|
//=============================================================================
|
||||||
|
// `FVoxelOpSample` is deliberately a small, explicit state record. Keep its channel set here,
|
||||||
|
// next to the interface that declares which fields an operator consumes and publishes. The enum
|
||||||
|
// values are bits so a declaration can name more than one channel without introducing another
|
||||||
|
// per-voxel object or a dynamic container.
|
||||||
|
//
|
||||||
|
// `Density` is the internal positive=solid field; `Sdf` is the standard negative=inside field.
|
||||||
|
// If a future field is added to `FVoxelOpSample`, it must also be added here and to every operator
|
||||||
|
// declaration before the composer can treat that field as part of the dependency graph.
|
||||||
|
enum class EVoxelOpChannel : uint8
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Density = 1 << 0,
|
||||||
|
Sdf = 1 << 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
using EVoxelOpChannelMask = uint8;
|
||||||
|
|
||||||
|
namespace VoxelOpChannels
|
||||||
|
{
|
||||||
|
static constexpr EVoxelOpChannelMask None = 0;
|
||||||
|
static constexpr EVoxelOpChannelMask Density = static_cast<EVoxelOpChannelMask>(EVoxelOpChannel::Density);
|
||||||
|
static constexpr EVoxelOpChannelMask Sdf = static_cast<EVoxelOpChannelMask>(EVoxelOpChannel::Sdf);
|
||||||
|
static constexpr EVoxelOpChannelMask All = Density | Sdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// OPERATOR RESOURCES / RESSOURCES D'OPERATEUR
|
||||||
|
//=============================================================================
|
||||||
|
// Sample-channel declarations describe the FVoxelOpSample only. Some operators also consume
|
||||||
|
// stateful geometry published by an earlier source (the room graph, shaft field, or surface
|
||||||
|
// column). Keep that dependency explicit as a second, tiny graph so the composer cannot place a
|
||||||
|
// room modifier on a stack that has no room source merely because its Density/Sdf masks happen to
|
||||||
|
// fit.
|
||||||
|
enum class EVoxelOpResource : uint8
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
RoomGeometry = 1 << 0,
|
||||||
|
ShaftGeometry = 1 << 1,
|
||||||
|
SurfaceColumn = 1 << 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
using EVoxelOpResourceMask = uint8;
|
||||||
|
|
||||||
|
namespace VoxelOpResources
|
||||||
|
{
|
||||||
|
static constexpr EVoxelOpResourceMask None = 0;
|
||||||
|
static constexpr EVoxelOpResourceMask RoomGeometry = static_cast<EVoxelOpResourceMask>(EVoxelOpResource::RoomGeometry);
|
||||||
|
static constexpr EVoxelOpResourceMask ShaftGeometry = static_cast<EVoxelOpResourceMask>(EVoxelOpResource::ShaftGeometry);
|
||||||
|
static constexpr EVoxelOpResourceMask SurfaceColumn = static_cast<EVoxelOpResourceMask>(EVoxelOpResource::SurfaceColumn);
|
||||||
|
static constexpr EVoxelOpResourceMask All = RoomGeometry | ShaftGeometry | SurfaceColumn;
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// COMPOSITION / COMBINERS
|
// COMPOSITION / COMBINERS
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -210,10 +277,28 @@ struct FVoxelOpContext
|
|||||||
// DOIT faire partie de toute clé de cache. Voir AUDIT C2.
|
// DOIT faire partie de toute clé de cache. Voir AUDIT C2.
|
||||||
uint32 LayoutVersion = 0;
|
uint32 LayoutVersion = 0;
|
||||||
|
|
||||||
|
// Global XY world-edge invariant. These are copied from UVoxelSettings by the generator and
|
||||||
|
// travel with the chunk context so the automatically appended edge op cannot be omitted by a
|
||||||
|
// builder. Radius 0 is the legacy unbounded/no-op mode.
|
||||||
|
float WorldRadiusVoxels = 8192.0f;
|
||||||
|
float EdgeSealThickness = 64.0f;
|
||||||
|
|
||||||
// Bornes Z de la strate en coords VOXEL (pas cm).
|
// Bornes Z de la strate en coords VOXEL (pas cm).
|
||||||
float StrateTopWorldZ = 0.0f;
|
float StrateTopWorldZ = 0.0f;
|
||||||
float StrateBottomWorldZ = 0.0f;
|
float StrateBottomWorldZ = 0.0f;
|
||||||
|
|
||||||
|
// ClassifyTile may ask box bounds about the exact marching-cubes lattice rather than the
|
||||||
|
// continuous volume between samples. Production voxel evaluation leaves this disabled;
|
||||||
|
// when enabled, operators may tighten a conservative bound only for points
|
||||||
|
// LatticeOriginVoxels + n * Step that lie inside the queried box.
|
||||||
|
bool bUseLatticeProof = false;
|
||||||
|
FIntVector LatticeOriginVoxels = FIntVector::ZeroValue;
|
||||||
|
|
||||||
|
// A mixed tile may opt into a tighter, still conservative finite-box bound for domain-warped
|
||||||
|
// sources. The normal classifier leaves this off; ClassifyTile retries only the few boxes
|
||||||
|
// that the cheap global proof could not resolve.
|
||||||
|
bool bTightenWarpProof = false;
|
||||||
|
|
||||||
// null = cette strate n'a pas de champ de biome.
|
// null = cette strate n'a pas de champ de biome.
|
||||||
const FBiomeContext* Biome = nullptr;
|
const FBiomeContext* Biome = nullptr;
|
||||||
};
|
};
|
||||||
@@ -251,8 +336,114 @@ struct FVoxelOpSample
|
|||||||
// placées quand aucune primitive n'atteint ce voxel).
|
// placées quand aucune primitive n'atteint ce voxel).
|
||||||
// FLT_MAX = "no surface nearby" — the initial state, and the early-out placed sources use.
|
// FLT_MAX = "no surface nearby" — the initial state, and the early-out placed sources use.
|
||||||
float Sdf = FLT_MAX;
|
float Sdf = FLT_MAX;
|
||||||
|
|
||||||
|
// Deprecated structural-floor compatibility fields. Graph tunnels now carry their floor in
|
||||||
|
// the swept shape itself; the production stack does not use these fields to write a slab.
|
||||||
|
bool bProtectedStructuralFloor = false;
|
||||||
|
float StructuralFloorMinimumDensity = 0.0f;
|
||||||
|
|
||||||
|
// Block evaluation keeps the room source's per-sample selection explicit. The scalar path
|
||||||
|
// still uses the source's worker-local state, but a later block-capable modifier must never
|
||||||
|
// observe the state belonging to the last sample of the preceding operator.
|
||||||
|
const FChunkSDFCache* RoomCache = nullptr;
|
||||||
|
int32 NearestRoomIndex = INDEX_NONE;
|
||||||
|
|
||||||
|
// The common generator tail consumes the continuous swept-shape metadata after the block has
|
||||||
|
// left the stack. Keeping it in the sample avoids re-running the world-space tunnel scan or
|
||||||
|
// consulting a last-sample TLS value after an op-major evaluation.
|
||||||
|
bool bHasTunnelCoreWorldEvaluation = false;
|
||||||
|
float TunnelCoreWorldSDF = FLT_MAX;
|
||||||
|
bool bTunnelCoreSupportFloor = false;
|
||||||
|
bool bTunnelCoreRoomFloor = false;
|
||||||
|
bool bHasTunnelCoreSweptFloor = false;
|
||||||
|
float TunnelCoreSweptFloorZ = -FLT_MAX;
|
||||||
|
float TunnelCoreSweptFloorRadius = 0.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A regular world lattice handed to one operator at a time.
|
||||||
|
*
|
||||||
|
* The default implementation is deliberately scalar and therefore source-compatible with custom
|
||||||
|
* operators. An operator may override EvalBlock only when it preserves the exact per-sample
|
||||||
|
* semantics of Eval. Samples are laid out X-fastest, then Y, then Z; all coordinates are voxel
|
||||||
|
* coordinates and are formed as Origin + index * Step using the same integer arithmetic as the
|
||||||
|
* mesher's scalar path.
|
||||||
|
*/
|
||||||
|
struct FVoxelOpBlock
|
||||||
|
{
|
||||||
|
FIntVector OriginVoxels = FIntVector::ZeroValue;
|
||||||
|
int32 Step = 1;
|
||||||
|
int32 SizeX = 0;
|
||||||
|
int32 SizeY = 0;
|
||||||
|
int32 SizeZ = 0;
|
||||||
|
FVoxelOpSample* Samples = nullptr;
|
||||||
|
|
||||||
|
int32 NumSamples() const { return SizeX * SizeY * SizeZ; }
|
||||||
|
|
||||||
|
FORCEINLINE FVoxelOpSample& At(int32 X, int32 Y, int32 Z) const
|
||||||
|
{
|
||||||
|
return Samples[(Z * SizeY + Y) * SizeX + X];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intervalle conservateur du canal SDF sur une boîte.
|
||||||
|
*
|
||||||
|
* `Min` et `Max` sont les bornes SUPRÊMES de toutes les valeurs que l'opérateur peut produire dans
|
||||||
|
* la boîte. `[FLT_MAX, FLT_MAX]` est l'identité exacte : aucune surface connue. Un intervalle inconnu
|
||||||
|
* est volontairement large ; il force le fold à rester `Mixed` plutôt que de risquer un faux tile
|
||||||
|
* uniforme. Les setters refusent toute borne non finie ou inversée : un opérateur qui ne peut pas
|
||||||
|
* prouver son intervalle perd le skip, jamais la géométrie.
|
||||||
|
*
|
||||||
|
* Conservative SDF interval over a box. `Min`/`Max` bound every value the operator can produce.
|
||||||
|
* `[FLT_MAX, FLT_MAX]` is the exact "no surface" identity. Unknown is deliberately wide: it costs
|
||||||
|
* a skip, never a hole. Invalid bounds become unknown instead of being guessed.
|
||||||
|
*/
|
||||||
|
struct FVoxelBoxSdfInterval
|
||||||
|
{
|
||||||
|
bool bKnown = true;
|
||||||
|
float Min = FLT_MAX;
|
||||||
|
float Max = FLT_MAX;
|
||||||
|
|
||||||
|
bool IsKnown() const { return bKnown; }
|
||||||
|
|
||||||
|
void SetUnknown()
|
||||||
|
{
|
||||||
|
bKnown = false;
|
||||||
|
Min = -FLT_MAX;
|
||||||
|
Max = FLT_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Set(float InMin, float InMax)
|
||||||
|
{
|
||||||
|
if (!VoxelMath::IsFinite(InMin) || !VoxelMath::IsFinite(InMax) || InMin > InMax)
|
||||||
|
{
|
||||||
|
SetUnknown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bKnown = true;
|
||||||
|
Min = InMin;
|
||||||
|
Max = InMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output of `min(Input, Other)` when both intervals are known. */
|
||||||
|
void MinWith(const FVoxelBoxSdfInterval& Other)
|
||||||
|
{
|
||||||
|
if (!bKnown || !Other.bKnown)
|
||||||
|
{
|
||||||
|
SetUnknown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Min = FMath::Min(Min, Other.Min);
|
||||||
|
Max = FMath::Min(Max, Other.Max);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forward declaration: the interval-aware EffectOverBox overloads consume the hypotheses that
|
||||||
|
// have already been folded, while the hypotheses themselves are declared immediately below the
|
||||||
|
// interface.
|
||||||
|
struct FVoxelBoxHypotheses;
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// L'INTERFACE / THE INTERFACE
|
// L'INTERFACE / THE INTERFACE
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -268,6 +459,44 @@ public:
|
|||||||
|
|
||||||
virtual EVoxelOpRole GetRole() const = 0;
|
virtual EVoxelOpRole GetRole() const = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel contract for the composer. A read is a field inspected from `InOut` before this op
|
||||||
|
* publishes its result; a write is a field whose value may differ when the op returns. A
|
||||||
|
* read/write declaration therefore means "transform the current version". `+=` and `-=` are
|
||||||
|
* both reads and writes; an assignment that ignores the old field is a write-only producer /
|
||||||
|
* replacement. Context, coordinates, caches and referenced objects are not sample channels.
|
||||||
|
*
|
||||||
|
* These are graph metadata, not voxel work: the stack snapshots them while it is assembled and
|
||||||
|
* the validator reads the snapshot. They must never be called from `Eval`.
|
||||||
|
*/
|
||||||
|
virtual EVoxelOpChannelMask ChannelReads() const = 0;
|
||||||
|
virtual EVoxelOpChannelMask ChannelWrites() const = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-sample state required by this op. A declaration is satisfied only by a provider that
|
||||||
|
* appeared earlier in the stack. Defaults are deliberately empty so existing independent
|
||||||
|
* operators and external test doubles remain source-compatible.
|
||||||
|
*/
|
||||||
|
virtual EVoxelOpResourceMask RequiredResources() const
|
||||||
|
{
|
||||||
|
return VoxelOpResources::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** State published for later consumers, e.g. FRoomGraphSource's room geometry cache. */
|
||||||
|
virtual EVoxelOpResourceMask ProvidedResources() const
|
||||||
|
{
|
||||||
|
return VoxelOpResources::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this op contributes a delta to its written channels without replacing, clamping,
|
||||||
|
* selecting, or otherwise depending on the previous value of those written channels. Such an
|
||||||
|
* op may be shuffled with another compatible additive delta; a false result means its position
|
||||||
|
* is explicit. A gate on a channel the op itself writes is transformative even if its final
|
||||||
|
* arithmetic contains `+=`.
|
||||||
|
*/
|
||||||
|
virtual bool IsAdditive() const = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hisser ici TOUT le travail constant sur le chunk : listes de salles, grilles de biome,
|
* Hisser ici TOUT le travail constant sur le chunk : listes de salles, grilles de biome,
|
||||||
* caches de colonnes, cuissons de treillis. Appelé une fois par chunk et par worker.
|
* caches de colonnes, cuissons de treillis. Appelé une fois par chunk et par worker.
|
||||||
@@ -292,8 +521,53 @@ public:
|
|||||||
virtual void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const = 0;
|
virtual void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CONSERVATIF. Phase 1 : direction seule. Phase 3 : surcharge avec intervalle numérique.
|
* Op-major evaluation seam. The conservative default is intentionally not clever: it calls
|
||||||
* Rendre Both est toujours sûr ; rendre le mauvais est un trou.
|
* the canonical scalar Eval for every sample. This lets the stack and mesher be converted in
|
||||||
|
* reviewable stages while byte-identical output remains the acceptance test. Stateful ops
|
||||||
|
* may restore their per-sample hand-off from FVoxelOpSample in PrepareBlockSample; the default
|
||||||
|
* invokes that hook before each scalar fallback call.
|
||||||
|
*/
|
||||||
|
virtual void EvalBlock(const FVoxelOpBlock& Block) const
|
||||||
|
{
|
||||||
|
if (Block.Samples == nullptr || Block.Step <= 0
|
||||||
|
|| Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int32 Z = 0; Z < Block.SizeZ; ++Z)
|
||||||
|
{
|
||||||
|
for (int32 Y = 0; Y < Block.SizeY; ++Y)
|
||||||
|
{
|
||||||
|
for (int32 X = 0; X < Block.SizeX; ++X)
|
||||||
|
{
|
||||||
|
FVoxelOpSample& Sample = Block.At(X, Y, Z);
|
||||||
|
PrepareBlockSample(Sample);
|
||||||
|
Eval(
|
||||||
|
static_cast<float>(Block.OriginVoxels.X + X * Block.Step),
|
||||||
|
static_cast<float>(Block.OriginVoxels.Y + Y * Block.Step),
|
||||||
|
static_cast<float>(Block.OriginVoxels.Z + Z * Block.Step),
|
||||||
|
Sample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore a source-owned per-sample context before a scalar fallback in EvalBlock. */
|
||||||
|
virtual void PrepareBlockSample(const FVoxelOpSample&) const {}
|
||||||
|
|
||||||
|
/** Optional hand-off for the common structural tail. The default keeps custom operators
|
||||||
|
* conservative: the caller uses its canonical cache path when no source publishes a result. */
|
||||||
|
virtual bool TryGetLastTunnelCoreWorldEvaluation(
|
||||||
|
FTunnelCoreWorldEvaluation& OutEvaluation) const
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CONSERVATIF. The two-argument method is the intrinsic fallback. The state-aware overload
|
||||||
|
* below is what the stack calls: it sees the SDF interval already produced by earlier ops.
|
||||||
|
* Returning Both is always safe; returning the wrong direction is a hole.
|
||||||
*
|
*
|
||||||
* Le contrat est « conservatif », pas « forme close » : un opérateur A LE DROIT
|
* Le contrat est « conservatif », pas « forme close » : un opérateur A LE DROIT
|
||||||
* D'ÉCHANTILLONNER pour répondre. C'est exactement ce que fait ClassifyTile aujourd'hui pour
|
* D'ÉCHANTILLONNER pour répondre. C'est exactement ce que fait ClassifyTile aujourd'hui pour
|
||||||
@@ -302,20 +576,20 @@ public:
|
|||||||
*
|
*
|
||||||
* The contract is "conservative", not "closed-form": an op MAY sample to answer.
|
* The contract is "conservative", not "closed-form": an op MAY sample to answer.
|
||||||
*
|
*
|
||||||
* ⚠️ SIMPLIFICATION DE PHASE 1, à connaître : une source qui n'écrit QUE le canal SDF ne touche
|
* An SDF-only source MUST NOT answer for a converter that happens to follow it. It reports its
|
||||||
* pas la densité par elle-même — c'est l'opérateur de conversion (`FSdfCarve`/`FSdfFill`) qui le
|
* own interval through `PropagateSdfOverBox`; the converter's state-aware overload then folds
|
||||||
* fait. Répondre honnêtement demanderait de propager un INTERVALLE de SDF à travers la requête
|
* the interval through its own formula. This is the isolation rule that makes free composition
|
||||||
* de boîte, exactement comme `Eval` propage une valeur de SDF. En attendant, **la source répond
|
* safe.
|
||||||
* pour la paire** (elle rend `CarveOnly`/`FillOnly` quand une primitive atteint la boîte,
|
|
||||||
* `Identity` sinon) et la conversion rend `Identity`. Conservatif et correct ; à remplacer par
|
|
||||||
* une requête de boîte à deux canaux quand les intervalles numériques arriveront (Phase 3).
|
|
||||||
*
|
|
||||||
* PHASE 1 SIMPLIFICATION: an SDF-only source answers for itself AND its conversion op; the
|
|
||||||
* conversion returns Identity. Answering honestly needs an SDF INTERVAL threaded through the box
|
|
||||||
* query, mirroring how Eval threads an SDF value. Conservative and correct meanwhile.
|
|
||||||
*/
|
*/
|
||||||
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0;
|
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0;
|
||||||
|
|
||||||
|
/** State-aware effect. The default preserves old custom operators' conservative direction. */
|
||||||
|
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
|
||||||
|
const FVoxelBoxHypotheses&) const
|
||||||
|
{
|
||||||
|
return EffectOverBox(VoxelBox, Ctx);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚠️ LE PLIAGE QUI PORTE DES NOMBRES — `OPSTACK-DECOMPOSITION §0.2`, et le plus gros poste de
|
* ⚠️ LE PLIAGE QUI PORTE DES NOMBRES — `OPSTACK-DECOMPOSITION §0.2`, et le plus gros poste de
|
||||||
* perf du plan.
|
* perf du plan.
|
||||||
@@ -354,11 +628,38 @@ public:
|
|||||||
return FLT_MAX;
|
return FLT_MAX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** State-aware amplitude. The default is the old, conservative amplitude. */
|
||||||
|
virtual float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
|
||||||
|
const FVoxelBoxHypotheses&) const
|
||||||
|
{
|
||||||
|
return MaxCarveOverBox(VoxelBox, Ctx);
|
||||||
|
}
|
||||||
|
|
||||||
virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
|
virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
|
||||||
{
|
{
|
||||||
return FLT_MAX;
|
return FLT_MAX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** State-aware amplitude. The default is the old, conservative amplitude. */
|
||||||
|
virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
|
||||||
|
const FVoxelBoxHypotheses&) const
|
||||||
|
{
|
||||||
|
return MaxFillOverBox(VoxelBox, Ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagate this operator's own SDF result through a box query. Only call this for an operator
|
||||||
|
* whose cached channel declaration writes `Sdf`. The default is unknown by construction: a new
|
||||||
|
* SDF writer cannot accidentally make a false uniform verdict merely because it forgot this
|
||||||
|
* method. Implementations that assign SDF replace the interval; min/accumulating writers use
|
||||||
|
* `FVoxelBoxSdfInterval::MinWith`.
|
||||||
|
*/
|
||||||
|
virtual void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox,
|
||||||
|
const FVoxelOpContext&) const
|
||||||
|
{
|
||||||
|
InOut.SetUnknown();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pour un opérateur FORÇANT (celui dont `ClassifyBox` rend autre chose que `Mixed`) : de combien
|
* Pour un opérateur FORÇANT (celui dont `ClassifyBox` rend autre chose que `Mixed`) : de combien
|
||||||
* la densité est-elle garantie du bon côté de zéro, PARTOUT dans la boîte ?
|
* la densité est-elle garantie du bon côté de zéro, PARTOUT dans la boîte ?
|
||||||
@@ -386,8 +687,8 @@ public:
|
|||||||
*
|
*
|
||||||
* Deux familles répondent autre chose que Mixed :
|
* Deux familles répondent autre chose que Mixed :
|
||||||
* • les SOURCES (rôle 1) — elles posent le champ, donc elles le savent par construction ;
|
* • les SOURCES (rôle 1) — elles posent le champ, donc elles le savent par construction ;
|
||||||
* • les op STRUCTURELS forçants — typiquement ApplyBoundarySeal, qui à l'intérieur de sa
|
* • les op STRUCTURELS forçants — ApplyBoundarySeal et ApplyXYEdgeSeal — qui, dans leur bande
|
||||||
* bande fait `Max(Density, SealFactor·BaseDensity)` avec SealFactor > 0 : le résultat est
|
* prouvée, font `Max(Density, SealFactor·BaseDensity)` avec SealFactor > 0 : le résultat est
|
||||||
* solide garanti quoi qu'il y ait eu avant. C'est exactement ce que ClassifyTile encode
|
* solide garanti quoi qu'il y ait eu avant. C'est exactement ce que ClassifyTile encode
|
||||||
* aujourd'hui avec « bande de seal ⇒ bCanAir = false » — et un simple FillOnly ne suffirait
|
* aujourd'hui avec « bande de seal ⇒ bCanAir = false » — et un simple FillOnly ne suffirait
|
||||||
* PAS à le reproduire (voir VF_FoldOp plus bas).
|
* PAS à le reproduire (voir VF_FoldOp plus bas).
|
||||||
@@ -397,8 +698,8 @@ public:
|
|||||||
* échantillonnant ses colonnes sur le treillis exact, exactement comme aujourd'hui.
|
* échantillonnant ses colonnes sur le treillis exact, exactement comme aujourd'hui.
|
||||||
*
|
*
|
||||||
* FORCING OPS. Some ops do not *move* the input density, they *overwrite* it. Default Mixed =
|
* FORCING OPS. Some ops do not *move* the input density, they *overwrite* it. Default Mixed =
|
||||||
* "I don't know", always safe. The boundary seal is the non-source example, and it is the
|
* "I don't know", always safe. The vertical and XY edge seals are the non-source examples, and
|
||||||
* reason this method exists at all rather than being folded into EffectOverBox.
|
* they are the reason this method exists at all rather than being folded into EffectOverBox.
|
||||||
*/
|
*/
|
||||||
virtual EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
|
virtual EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
|
||||||
{
|
{
|
||||||
@@ -451,6 +752,8 @@ public:
|
|||||||
// ApplyOriginSpine → CarveOnly (tue AllSolid) ≡ le test cercle/boîte XY
|
// ApplyOriginSpine → CarveOnly (tue AllSolid) ≡ le test cercle/boîte XY
|
||||||
// ApplyBoundarySeal → ClassifyBox = AllSolid DANS sa bande (opérateur forçant),
|
// ApplyBoundarySeal → ClassifyBox = AllSolid DANS sa bande (opérateur forçant),
|
||||||
// FillOnly ailleurs ≡ « bande de seal ⇒ bCanAir=false »
|
// FillOnly ailleurs ≡ « bande de seal ⇒ bCanAir=false »
|
||||||
|
// ApplyXYEdgeSeal → ClassifyBox = AllSolid DANS sa bande radiale (forçant) ; il gagne
|
||||||
|
// après les passages, donc la coque extérieure reste fermée.
|
||||||
// disturbances chasms → CarveOnly ≡ « ChasmDensity > 0 ⇒ bCanSolid=false »
|
// disturbances chasms → CarveOnly ≡ « ChasmDensity > 0 ⇒ bCanSolid=false »
|
||||||
// disturbances ponts/arêtes → FillOnly ≡ « Bridge/RidgeDensity > 0 ⇒ bCanAir=false »
|
// disturbances ponts/arêtes → FillOnly ≡ « Bridge/RidgeDensity > 0 ⇒ bCanAir=false »
|
||||||
// diff layer → Both si des mods touchent la boîte, sinon Identity
|
// diff layer → Both si des mods touchent la boîte, sinon Identity
|
||||||
@@ -483,6 +786,9 @@ struct FVoxelBoxHypotheses
|
|||||||
float SolidMargin = 0.0f;
|
float SolidMargin = 0.0f;
|
||||||
float AirMargin = 0.0f;
|
float AirMargin = 0.0f;
|
||||||
|
|
||||||
|
/** SDF interval after all SDF writers folded so far. It starts at the exact no-surface identity. */
|
||||||
|
FVoxelBoxSdfInterval Sdf;
|
||||||
|
|
||||||
bool IsDead() const { return !bCanBeAllSolid && !bCanBeAllAir; }
|
bool IsDead() const { return !bCanBeAllSolid && !bCanBeAllAir; }
|
||||||
|
|
||||||
/** Verdict final : exactement une hypothèse doit survivre. Égalité = prudence ⇒ Mixed. */
|
/** Verdict final : exactement une hypothèse doit survivre. Égalité = prudence ⇒ Mixed. */
|
||||||
@@ -518,7 +824,9 @@ FORCEINLINE void VF_ForceHypotheses(FVoxelBoxHypotheses& H, EVoxelTileClass Forc
|
|||||||
* c'est la propriété de sûreté, et le pliage numérique ne l'affaiblit pas : une marge ne peut que
|
* c'est la propriété de sûreté, et le pliage numérique ne l'affaiblit pas : une marge ne peut que
|
||||||
* DESCENDRE, jamais remonter, en dehors d'un opérateur forçant.
|
* DESCENDRE, jamais remonter, en dehors d'un opérateur forçant.
|
||||||
*
|
*
|
||||||
* ⚠️ `MaxCarve` / `MaxFill` valent `FLT_MAX` par défaut = « amplitude inconnue ». La soustraction
|
* ⚠️ `MaxCarve` / `MaxFill` valent `FLT_MAX` par défaut = « amplitude inconnue ». Une borne finie
|
||||||
|
* nulle signifie au contraire « identité prouvée » pour cette direction : l'opérateur peut être
|
||||||
|
* géométriquement présent mais ne modifie aucun échantillon de la boîte. La soustraction
|
||||||
* fait alors passer la marge très en dessous de zéro et l'hypothèse meurt, exactement comme la
|
* fait alors passer la marge très en dessous de zéro et l'hypothèse meurt, exactement comme la
|
||||||
* version purement directionnelle de ce pliage. Aucun opérateur existant ne change de verdict.
|
* version purement directionnelle de ce pliage. Aucun opérateur existant ne change de verdict.
|
||||||
* (Arithmétique volontairement laissée en float sans garde : `0 − FLT_MAX` vaut `−FLT_MAX`,
|
* (Arithmétique volontairement laissée en float sans garde : `0 − FLT_MAX` vaut `−FLT_MAX`,
|
||||||
@@ -534,21 +842,67 @@ FORCEINLINE void VF_FoldEffect(FVoxelBoxHypotheses& H, EVoxelOpEffect Effect,
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case EVoxelOpEffect::CarveOnly:
|
case EVoxelOpEffect::CarveOnly:
|
||||||
|
// A finite zero bound is a proved identity for this direction. This matters for
|
||||||
|
// state-aware structural guards: a passage may be geometrically present in a box while
|
||||||
|
// its exact lattice carve factor is zero, and that must not kill an otherwise valid
|
||||||
|
// AllSolid hypothesis merely because its margin is zero/unknown.
|
||||||
|
if (MaxCarve == 0.0f)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!(MaxCarve >= 0.0f))
|
||||||
|
{
|
||||||
|
H.bCanBeAllSolid = false;
|
||||||
|
H.SolidMargin = -FLT_MAX;
|
||||||
|
break;
|
||||||
|
}
|
||||||
H.SolidMargin -= MaxCarve;
|
H.SolidMargin -= MaxCarve;
|
||||||
if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; }
|
if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; }
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case EVoxelOpEffect::FillOnly:
|
case EVoxelOpEffect::FillOnly:
|
||||||
|
if (MaxFill == 0.0f)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!(MaxFill >= 0.0f))
|
||||||
|
{
|
||||||
|
H.bCanBeAllAir = false;
|
||||||
|
H.AirMargin = -FLT_MAX;
|
||||||
|
break;
|
||||||
|
}
|
||||||
H.AirMargin -= MaxFill;
|
H.AirMargin -= MaxFill;
|
||||||
if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; }
|
if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; }
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case EVoxelOpEffect::Both:
|
case EVoxelOpEffect::Both:
|
||||||
default:
|
default:
|
||||||
|
if (MaxCarve != 0.0f)
|
||||||
|
{
|
||||||
|
if (!(MaxCarve >= 0.0f))
|
||||||
|
{
|
||||||
|
H.bCanBeAllSolid = false;
|
||||||
|
H.SolidMargin = -FLT_MAX;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
H.SolidMargin -= MaxCarve;
|
H.SolidMargin -= MaxCarve;
|
||||||
if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; }
|
if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (MaxFill != 0.0f)
|
||||||
|
{
|
||||||
|
if (!(MaxFill >= 0.0f))
|
||||||
|
{
|
||||||
|
H.bCanBeAllAir = false;
|
||||||
|
H.AirMargin = -FLT_MAX;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
H.AirMargin -= MaxFill;
|
H.AirMargin -= MaxFill;
|
||||||
if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; }
|
if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -572,14 +926,29 @@ FORCEINLINE void VF_FoldEffect(FVoxelBoxHypotheses& H, EVoxelOpEffect Effect,
|
|||||||
* pure FillOnly would have thrown away, while still letting a passage take it back.
|
* pure FillOnly would have thrown away, while still letting a passage take it back.
|
||||||
*/
|
*/
|
||||||
FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op,
|
FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op,
|
||||||
const FBox& VoxelBox, const FVoxelOpContext& Ctx)
|
const FBox& VoxelBox, const FVoxelOpContext& Ctx,
|
||||||
|
bool bWritesSdf)
|
||||||
{
|
{
|
||||||
|
// Soundness bias: an unknown SDF interval is allowed to kill a skip, never to manufacture a
|
||||||
|
// uniform tile. The interval is propagated only after this op's own effect has been folded, so
|
||||||
|
// the next op sees exactly the SDF version that Eval would expose at this point in the stack.
|
||||||
const EVoxelTileClass Forced = Op.ClassifyBox(VoxelBox, Ctx);
|
const EVoxelTileClass Forced = Op.ClassifyBox(VoxelBox, Ctx);
|
||||||
if (Forced != EVoxelTileClass::Mixed)
|
if (Forced != EVoxelTileClass::Mixed)
|
||||||
{
|
{
|
||||||
VF_ForceHypotheses(H, Forced, Op.ForcedMarginOverBox(VoxelBox, Ctx));
|
VF_ForceHypotheses(H, Forced, Op.ForcedMarginOverBox(VoxelBox, Ctx));
|
||||||
|
if (bWritesSdf) { Op.PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
VF_FoldEffect(H, Op.EffectOverBox(VoxelBox, Ctx),
|
VF_FoldEffect(H, Op.EffectOverBox(VoxelBox, Ctx, H),
|
||||||
Op.MaxCarveOverBox(VoxelBox, Ctx), Op.MaxFillOverBox(VoxelBox, Ctx));
|
Op.MaxCarveOverBox(VoxelBox, Ctx, H),
|
||||||
|
Op.MaxFillOverBox(VoxelBox, Ctx, H));
|
||||||
|
if (bWritesSdf) { Op.PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backward-compatible helper for unit tests and callers with no cached declaration metadata. */
|
||||||
|
FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op,
|
||||||
|
const FBox& VoxelBox, const FVoxelOpContext& Ctx)
|
||||||
|
{
|
||||||
|
VF_FoldOp(H, Op, VoxelBox, Ctx,
|
||||||
|
(Op.ChannelWrites() & VoxelOpChannels::Sdf) != 0);
|
||||||
}
|
}
|
||||||
@@ -6,15 +6,16 @@
|
|||||||
// `UVoxelGenerator::GetDensityAt` construit la pile par chunk et l'évalue à la place du `switch`
|
// `UVoxelGenerator::GetDensityAt` construit la pile par chunk et l'évalue à la place du `switch`
|
||||||
// UNIQUEMENT quand `UVoxelStrateManager::UsesOperatorStackForChunk` rend true — c.-à-d. quand la
|
// UNIQUEMENT quand `UVoxelStrateManager::UsesOperatorStackForChunk` rend true — c.-à-d. quand la
|
||||||
// strate a coché `bUseOperatorStack` ET que son archétype figure dans la liste des portés :
|
// strate a coché `bUseOperatorStack` ET que son archétype figure dans la liste des portés :
|
||||||
// **Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands (6 sur 8)**.
|
// **Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork,
|
||||||
|
// Underwater (8 sur 8)**.
|
||||||
// Toute autre strate passe encore par le `switch`, inchangé.
|
// Toute autre strate passe encore par le `switch`, inchangé.
|
||||||
// `ClassifyTile` n'est PAS branché : il utilise toujours ses gardes écrites à la main, pas
|
// `ClassifyTile` est branché pour les archétypes de cave opt-in : il construit la même pile et
|
||||||
// `ClassifyBox`. C'est la Phase 2.
|
// plie `ClassifyBox`; les gaps et SurfaceWorld gardent leurs preuves exactes dédiées.
|
||||||
//
|
//
|
||||||
// THIS FEEDS THE GAME, BUT ONLY BEHIND AN OPT-IN. GetDensityAt builds the stack per chunk and
|
// THIS FEEDS THE GAME, BUT ONLY BEHIND AN OPT-IN. GetDensityAt builds the stack per chunk and
|
||||||
// evaluates it instead of the switch only when UsesOperatorStackForChunk returns true (strate ticked
|
// evaluates it instead of the switch only when UsesOperatorStackForChunk returns true (strate ticked
|
||||||
// bUseOperatorStack AND its archetype ported — 6 of 8). ClassifyTile is NOT wired: it still uses its
|
// bUseOperatorStack AND its archetype is ported — all 8). ClassifyTile uses the same stack for cave
|
||||||
// hand-written guards rather than ClassifyBox. That is Phase 2.
|
// archetypes; gaps and SurfaceWorld retain their exact hand-written proofs.
|
||||||
//
|
//
|
||||||
// ⛔ NE JAMAIS faire tourner les deux chemins dans le même monde.
|
// ⛔ NE JAMAIS faire tourner les deux chemins dans le même monde.
|
||||||
// ⚠️ EN REVANCHE, LES COMPARER EST DEVENU LÉGITIME — cette ligne disait l'inverse et elle est
|
// ⚠️ EN REVANCHE, LES COMPARER EST DEVENU LÉGITIME — cette ligne disait l'inverse et elle est
|
||||||
@@ -34,16 +35,46 @@
|
|||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
#include "VoxelDensityOp.h"
|
#include "VoxelDensityOp.h"
|
||||||
|
#include "VoxelDensityProfile.h"
|
||||||
#include "VoxelStrateTypes.h" // FMazeGenerationParams
|
#include "VoxelStrateTypes.h" // FMazeGenerationParams
|
||||||
#include "VoxelHeightOp.h" // IVoxelBiomeField — BuildSurfaceStack takes ownership of one
|
#include "VoxelHeightOp.h" // IVoxelBiomeField — BuildSurfaceStack takes ownership of one
|
||||||
|
|
||||||
|
class UVoxelGenerator;
|
||||||
class UVoxelStrateManager;
|
class UVoxelStrateManager;
|
||||||
|
struct FVoxelOpStackRecipe;
|
||||||
|
struct FVoxelStrateArchetypeParams;
|
||||||
|
struct FVoxelStrateRegionManifest;
|
||||||
|
enum class EVoxelStrateOpClass : uint8;
|
||||||
|
|
||||||
|
/** The declarations the offline structure roller reads before it ever materialises an op. */
|
||||||
|
struct VOXELFORGE_API FVoxelStrateOpContract
|
||||||
|
{
|
||||||
|
EVoxelOpRole Role = EVoxelOpRole::DetailModifier;
|
||||||
|
EVoxelOpChannelMask Reads = VoxelOpChannels::None;
|
||||||
|
EVoxelOpChannelMask Writes = VoxelOpChannels::None;
|
||||||
|
bool bAdditive = false;
|
||||||
|
EVoxelOpResourceMask RequiredResources = VoxelOpResources::None;
|
||||||
|
EVoxelOpResourceMask ProvidedResources = VoxelOpResources::None;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lowered evaluator selected when a stack has a proven, canonical implementation.
|
||||||
|
*
|
||||||
|
* The authored operator list remains present for classification, diagnostics, tests, and the
|
||||||
|
* fallback evaluator. This is a plan choice made while the stack is assembled, not a runtime
|
||||||
|
* CPU-feature dispatch and not a second semantic graph.
|
||||||
|
*/
|
||||||
|
enum class EVoxelOpFusedEvaluator : uint8
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
TunnelNetwork,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FVoxelOpStack — une liste ordonnée d'opérateurs + le pliage de verdict de boîte.
|
* FVoxelOpStack — une liste ordonnée d'opérateurs + le pliage de verdict de boîte.
|
||||||
*
|
*
|
||||||
* PROPRIÉTÉ (rôle 4) : les opérateurs STRUCTURELS sont ajoutés par `AppendStructuralPost` et
|
* PROPRIÉTÉ (rôle 4) : les opérateurs STRUCTURELS sont ajoutés par `AppendStructuralPost` et
|
||||||
* l'ordre spine → seal → passage est garanti par cette fonction, pas par l'auteur. Un auteur ne
|
* l'ordre spine → seal → passage → seal XY est garanti par cette fonction, pas par l'auteur. Un auteur ne
|
||||||
* peut pas les omettre ni les réordonner — ce sont des invariants de monde (la descente doit rester
|
* peut pas les omettre ni les réordonner — ce sont des invariants de monde (la descente doit rester
|
||||||
* possible, les seals doivent tenir, les passages doivent percer).
|
* possible, les seals doivent tenir, les passages doivent percer).
|
||||||
*
|
*
|
||||||
@@ -58,6 +89,21 @@ class UVoxelStrateManager;
|
|||||||
*/
|
*/
|
||||||
class FVoxelOpStack
|
class FVoxelOpStack
|
||||||
{
|
{
|
||||||
|
private:
|
||||||
|
/** Metadata is snapshotted when an op enters the stack so no declaration virtual is ever
|
||||||
|
* dispatched from `EvalSample`'s per-voxel loop. */
|
||||||
|
struct FOpEntry
|
||||||
|
{
|
||||||
|
TUniquePtr<IVoxelDensityOp> Op;
|
||||||
|
EVoxelOpChannelMask Reads = VoxelOpChannels::None;
|
||||||
|
EVoxelOpChannelMask Writes = VoxelOpChannels::None;
|
||||||
|
bool bAdditive = false;
|
||||||
|
EVoxelOpResourceMask RequiredResources = VoxelOpResources::None;
|
||||||
|
EVoxelOpResourceMask ProvidedResources = VoxelOpResources::None;
|
||||||
|
const TCHAR* ProfileName = nullptr;
|
||||||
|
VoxelDensityProfile::EBucket ProfileBucket = VoxelDensityProfile::EBucket::OtherOp;
|
||||||
|
};
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// DÉPLAÇABLE, PAS COPIABLE — et c'est la bonne sémantique, pas un contournement de compilateur :
|
// DÉPLAÇABLE, PAS COPIABLE — et c'est la bonne sémantique, pas un contournement de compilateur :
|
||||||
// une pile POSSÈDE ses opérateurs de façon unique. La copier voudrait dire cloner des opérateurs
|
// une pile POSSÈDE ses opérateurs de façon unique. La copier voudrait dire cloner des opérateurs
|
||||||
@@ -65,13 +111,13 @@ public:
|
|||||||
//
|
//
|
||||||
// ⚠️ NOTE COMPILATEUR : ne PAS remettre `VOXELFORGE_API` sur la classe. Sous MSVC, dllexport sur
|
// ⚠️ NOTE COMPILATEUR : ne PAS remettre `VOXELFORGE_API` sur la classe. Sous MSVC, dllexport sur
|
||||||
// une classe force l'instanciation de TOUS ses membres implicites, y compris l'opérateur
|
// une classe force l'instanciation de TOUS ses membres implicites, y compris l'opérateur
|
||||||
// d'affectation par copie — impossible à générer pour un `TArray<TUniquePtr<...>>`, d'où
|
// d'affectation par copie — impossible à générer pour un `TArray<FOpEntry>`, d'où
|
||||||
// l'erreur C2280 « fonction supprimée ». L'export va sur la seule méthode hors-ligne.
|
// l'erreur C2280 « fonction supprimée ». L'export va sur la seule méthode hors-ligne.
|
||||||
//
|
//
|
||||||
// MOVE-ONLY, and that is the correct semantics rather than a compiler workaround: a stack
|
// MOVE-ONLY, and that is the correct semantics rather than a compiler workaround: a stack
|
||||||
// uniquely OWNS its operators. Do NOT put VOXELFORGE_API back on the class — under MSVC,
|
// uniquely OWNS its operators. Do NOT put VOXELFORGE_API back on the class — under MSVC,
|
||||||
// dllexport forces instantiation of every implicit member including copy-assignment, which
|
// dllexport forces instantiation of every implicit member including copy-assignment, which
|
||||||
// cannot be generated for a TArray<TUniquePtr<...>> (error C2280). Export the out-of-line
|
// cannot be generated for a TArray<FOpEntry> (error C2280). Export the out-of-line
|
||||||
// method instead.
|
// method instead.
|
||||||
FVoxelOpStack() = default;
|
FVoxelOpStack() = default;
|
||||||
FVoxelOpStack(FVoxelOpStack&&) = default;
|
FVoxelOpStack(FVoxelOpStack&&) = default;
|
||||||
@@ -79,16 +125,71 @@ public:
|
|||||||
FVoxelOpStack(const FVoxelOpStack&) = delete;
|
FVoxelOpStack(const FVoxelOpStack&) = delete;
|
||||||
FVoxelOpStack& operator=(const FVoxelOpStack&) = delete;
|
FVoxelOpStack& operator=(const FVoxelOpStack&) = delete;
|
||||||
|
|
||||||
void Add(TUniquePtr<IVoxelDensityOp> Op) { Ops.Add(MoveTemp(Op)); }
|
void Add(TUniquePtr<IVoxelDensityOp> Op)
|
||||||
|
{
|
||||||
|
// Adding an op invalidates any previously selected lowered plan. Factories mark the plan
|
||||||
|
// only after the complete authored graph has been assembled.
|
||||||
|
FusedEvaluator = EVoxelOpFusedEvaluator::None;
|
||||||
|
FOpEntry Entry;
|
||||||
|
Entry.Op = MoveTemp(Op);
|
||||||
|
if (Entry.Op.Get() != nullptr)
|
||||||
|
{
|
||||||
|
// Assembly-time metadata only. ValidateChannelOrder reuses this snapshot, and Eval
|
||||||
|
// never asks an operator for its declaration.
|
||||||
|
Entry.Reads = Entry.Op->ChannelReads();
|
||||||
|
Entry.Writes = Entry.Op->ChannelWrites();
|
||||||
|
Entry.bAdditive = Entry.Op->IsAdditive();
|
||||||
|
Entry.RequiredResources = Entry.Op->RequiredResources();
|
||||||
|
Entry.ProvidedResources = Entry.Op->ProvidedResources();
|
||||||
|
Entry.ProfileName = Entry.Op->DebugName();
|
||||||
|
Entry.ProfileBucket = VoxelDensityProfile::BucketFromName(Entry.ProfileName);
|
||||||
|
}
|
||||||
|
Ops.Add(MoveTemp(Entry));
|
||||||
|
}
|
||||||
|
|
||||||
int32 Num() const { return Ops.Num(); }
|
int32 Num() const { return Ops.Num(); }
|
||||||
|
|
||||||
|
/** Select the lowered evaluator for this complete authored graph. */
|
||||||
|
void SetFusedEvaluator(EVoxelOpFusedEvaluator InEvaluator)
|
||||||
|
{
|
||||||
|
FusedEvaluator = InEvaluator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lowered plan, or None when the general interpreted/block path must be used. */
|
||||||
|
EVoxelOpFusedEvaluator GetFusedEvaluator() const
|
||||||
|
{
|
||||||
|
return FusedEvaluator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when this stack publishes the requested immutable geometry resource. */
|
||||||
|
bool ProvidesResource(EVoxelOpResourceMask Resource) const
|
||||||
|
{
|
||||||
|
if (Resource == VoxelOpResources::None)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const FOpEntry& Entry : Ops)
|
||||||
|
{
|
||||||
|
if ((Entry.ProvidedResources & Resource) == Resource)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocator-backed bytes owned by the stack, including each concrete operator allocation.
|
||||||
|
// The enclosing cache entry adds sizeof(FVoxelOpStack) separately.
|
||||||
|
SIZE_T GetAllocatedSize() const;
|
||||||
|
|
||||||
/** Hoist chunk-constant work for every op. Une fois par chunk et par worker.
|
/** Hoist chunk-constant work for every op. Une fois par chunk et par worker.
|
||||||
* Non-const : ça MUTE l'état par-chunk des opérateurs, et le prétendre const serait un
|
* Non-const : ça MUTE l'état par-chunk des opérateurs, et le prétendre const serait un
|
||||||
* mensonge utile qui finirait par masquer une course. */
|
* mensonge utile qui finirait par masquer une course. */
|
||||||
void PrepareChunk(const FVoxelOpContext& Ctx)
|
void PrepareChunk(const FVoxelOpContext& Ctx)
|
||||||
{
|
{
|
||||||
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops) { Op->PrepareChunk(Ctx); }
|
PreparedContext = Ctx;
|
||||||
|
bHasPreparedContext = true;
|
||||||
|
for (const FOpEntry& Entry : Ops) { Entry.Op->PrepareChunk(Ctx); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,28 +210,170 @@ public:
|
|||||||
FVoxelOpSample EvalSample(float WorldX, float WorldY, float WorldZ) const
|
FVoxelOpSample EvalSample(float WorldX, float WorldY, float WorldZ) const
|
||||||
{
|
{
|
||||||
FVoxelOpSample S;
|
FVoxelOpSample S;
|
||||||
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops) { Op->Eval(WorldX, WorldY, WorldZ, S); }
|
// Fine operation timers are a diagnostic mode. Keeping their construction out of the
|
||||||
|
// ordinary scalar path matters for callers that still use fractional samples or an
|
||||||
|
// archetype without a block session; the block path already times one scope per op/block.
|
||||||
|
const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full;
|
||||||
|
for (const FOpEntry& Entry : Ops)
|
||||||
|
{
|
||||||
|
if (bProfileOps)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket);
|
||||||
|
Entry.Op->Eval(WorldX, WorldY, WorldZ, S);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Entry.Op->Eval(WorldX, WorldY, WorldZ, S);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The room-graph source publishes the authored floor ownership while evaluating the
|
||||||
|
// shape, but it must not be reasserted here. This stack is followed by the common
|
||||||
|
// disturbance/passage writers; composing the floor at this point made every authored
|
||||||
|
// floor sample look like a post-hoc clamp (and counted 100% of them). The generator now
|
||||||
|
// consumes the same immutable core result after those writers and composes the floor once,
|
||||||
|
// at the final MC boundary. The metadata remains on the sample for that hand-off and for
|
||||||
|
// diagnostics, but it is no longer allowed to modify density inside the operator stack.
|
||||||
return S;
|
return S;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate one regular lattice in operator order. The caller owns and initializes the sample
|
||||||
|
* array (normally to FVoxelOpSample defaults); each operator transforms that same array before
|
||||||
|
* the next operator runs. The timer is intentionally one scope per operator/block rather than
|
||||||
|
* one scope per operator/sample. Operators without a block implementation use the conservative
|
||||||
|
* scalar fallback supplied by IVoxelDensityOp, so this entry point is safe to introduce before
|
||||||
|
* converting the concrete operators.
|
||||||
|
*/
|
||||||
|
void EvalBlock(const FVoxelOpBlock& Block) const
|
||||||
|
{
|
||||||
|
if (Block.Samples == nullptr || Block.Step <= 0
|
||||||
|
|| Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full;
|
||||||
|
for (const FOpEntry& Entry : Ops)
|
||||||
|
{
|
||||||
|
if (bProfileOps)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket);
|
||||||
|
Entry.Op->EvalBlock(Block);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Entry.Op->EvalBlock(Block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluate only the entries that the interval fold proved non-identity for this block. */
|
||||||
|
void EvalBlock(const FVoxelOpBlock& Block, const TArray<int32>& ActiveOps) const
|
||||||
|
{
|
||||||
|
if (Block.Samples == nullptr || Block.Step <= 0
|
||||||
|
|| Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full;
|
||||||
|
for (const int32 OpIndex : ActiveOps)
|
||||||
|
{
|
||||||
|
if (!Ops.IsValidIndex(OpIndex)) { continue; }
|
||||||
|
const FOpEntry& Entry = Ops[OpIndex];
|
||||||
|
if (bProfileOps)
|
||||||
|
{
|
||||||
|
VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket);
|
||||||
|
Entry.Op->EvalBlock(Block);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Entry.Op->EvalBlock(Block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a conservative active list for one exact mesher lattice block. An operator may be
|
||||||
|
* omitted only when its state-aware box contract says Identity and it does not publish SDF.
|
||||||
|
* SDF writers remain in the list even when their density effect is Identity: their SDF is a
|
||||||
|
* channel consumed by a later converter, and an unchanged-looking interval is not by itself a
|
||||||
|
* pointwise proof. Unknown costs the operator visit, never the geometry.
|
||||||
|
*/
|
||||||
|
int32 BuildActiveOpList(const FBox& VoxelBox, int32 InStep,
|
||||||
|
FIntVector InLatticeOrigin, TArray<int32>& OutActiveOps) const
|
||||||
|
{
|
||||||
|
OutActiveOps.Reset();
|
||||||
|
if (!bHasPreparedContext)
|
||||||
|
{
|
||||||
|
for (int32 Index = 0; Index < Ops.Num(); ++Index) { OutActiveOps.Add(Index); }
|
||||||
|
return OutActiveOps.Num();
|
||||||
|
}
|
||||||
|
|
||||||
|
FVoxelOpContext Ctx = PreparedContext;
|
||||||
|
Ctx.Step = FMath::Max(InStep, 1);
|
||||||
|
Ctx.LatticeOriginVoxels = InLatticeOrigin;
|
||||||
|
Ctx.bUseLatticeProof = true;
|
||||||
|
|
||||||
|
FVoxelBoxHypotheses H;
|
||||||
|
for (int32 Index = 0; Index < Ops.Num(); ++Index)
|
||||||
|
{
|
||||||
|
const FOpEntry& Entry = Ops[Index];
|
||||||
|
const bool bWritesSdf = (Entry.Writes & VoxelOpChannels::Sdf) != 0;
|
||||||
|
const bool bPublishesResource = Entry.ProvidedResources != VoxelOpResources::None;
|
||||||
|
const EVoxelTileClass Forced = Entry.Op->ClassifyBox(VoxelBox, Ctx);
|
||||||
|
bool bCanSkip = false;
|
||||||
|
|
||||||
|
if (Forced != EVoxelTileClass::Mixed)
|
||||||
|
{
|
||||||
|
VF_ForceHypotheses(H, Forced, Entry.Op->ForcedMarginOverBox(VoxelBox, Ctx));
|
||||||
|
if (bWritesSdf) { Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const EVoxelOpEffect Effect =
|
||||||
|
Entry.Op->EffectOverBox(VoxelBox, Ctx, H);
|
||||||
|
bCanSkip = Effect == EVoxelOpEffect::Identity
|
||||||
|
&& !bWritesSdf
|
||||||
|
&& !bPublishesResource;
|
||||||
|
VF_FoldEffect(H,
|
||||||
|
Effect,
|
||||||
|
Entry.Op->MaxCarveOverBox(VoxelBox, Ctx, H),
|
||||||
|
Entry.Op->MaxFillOverBox(VoxelBox, Ctx, H));
|
||||||
|
if (bWritesSdf) { Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bCanSkip) { OutActiveOps.Add(Index); }
|
||||||
|
}
|
||||||
|
return OutActiveOps.Num();
|
||||||
|
}
|
||||||
|
|
||||||
/** Le même, négaté pour le mesher (négatif = solide). */
|
/** Le même, négaté pour le mesher (négatif = solide). */
|
||||||
float EvalMC(float WorldX, float WorldY, float WorldZ) const
|
float EvalMC(float WorldX, float WorldY, float WorldZ) const
|
||||||
{
|
{
|
||||||
return -EvalInternal(WorldX, WorldY, WorldZ);
|
return -EvalInternal(WorldX, WorldY, WorldZ);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return a same-sample structural-core result published by a source in this stack. */
|
||||||
|
bool TryGetLastTunnelCoreWorldEvaluation(
|
||||||
|
FTunnelCoreWorldEvaluation& OutEvaluation) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Le pliage générique qui remplacera les gardes écrites à la main dans ClassifyTile.
|
* Le pliage générique qui remplacera les gardes écrites à la main dans ClassifyTile.
|
||||||
* Voir `VF_FoldOp` (VoxelDensityOp.h) pour la sémantique — en particulier pourquoi un
|
* Voir `VF_FoldOp` (VoxelDensityOp.h) pour la sémantique — en particulier pourquoi un
|
||||||
* opérateur FORÇANT (le seal dans sa bande) écrase ce que la pile avait conclu avant lui.
|
* opérateur FORÇANT (les seals dans leurs bandes) écrase ce que la pile avait conclu avant lui.
|
||||||
*/
|
*/
|
||||||
EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
|
EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
|
||||||
{
|
{
|
||||||
FVoxelBoxHypotheses H;
|
FVoxelBoxHypotheses H;
|
||||||
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops)
|
for (const FOpEntry& Entry : Ops)
|
||||||
{
|
{
|
||||||
VF_FoldOp(H, *Op, VoxelBox, Ctx);
|
VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx,
|
||||||
if (H.IsDead()) { return EVoxelTileClass::Mixed; } // early-out : plus rien à prouver
|
(Entry.Writes & VoxelOpChannels::Sdf) != 0);
|
||||||
|
// Do not early-out on a dead hypothesis: a later forcing structural post may
|
||||||
|
// deliberately overwrite it (the XY edge seal is appended after passage tube/landing
|
||||||
|
// carving and support).
|
||||||
}
|
}
|
||||||
return H.Resolve();
|
return H.Resolve();
|
||||||
}
|
}
|
||||||
@@ -138,8 +381,8 @@ public:
|
|||||||
/**
|
/**
|
||||||
* LE MÊME PLIAGE, MAIS QUI DIT **QUI** A TUÉ CHAQUE HYPOTHÈSE. Diagnostic, réservé aux tests.
|
* LE MÊME PLIAGE, MAIS QUI DIT **QUI** A TUÉ CHAQUE HYPOTHÈSE. Diagnostic, réservé aux tests.
|
||||||
*
|
*
|
||||||
* ⚠️ IL DOIT RENDRE EXACTEMENT LE MÊME VERDICT QUE `ClassifyBox` — même boucle, même early-out,
|
* ⚠️ IL DOIT RENDRE EXACTEMENT LE MÊME VERDICT QUE `ClassifyBox` — même boucle, même ordre,
|
||||||
* même ordre. Un diagnostic qui emprunte un chemin légèrement différent de celui qu'il explique
|
* y compris le fait de laisser un post forçant ultérieur ressusciter une hypothèse morte. Un diagnostic qui emprunte un chemin légèrement différent de celui qu'il explique
|
||||||
* est pire que pas de diagnostic : il envoie chercher le bug ailleurs. Si l'un des deux change,
|
* est pire que pas de diagnostic : il envoie chercher le bug ailleurs. Si l'un des deux change,
|
||||||
* l'autre change avec lui.
|
* l'autre change avec lui.
|
||||||
*
|
*
|
||||||
@@ -148,8 +391,8 @@ public:
|
|||||||
* lisible s'obtient par `GetOpDebugName(index)`.
|
* lisible s'obtient par `GetOpDebugName(index)`.
|
||||||
*
|
*
|
||||||
* Same fold, but it reports WHICH op killed each hypothesis. Must stay verdict-identical to
|
* Same fold, but it reports WHICH op killed each hypothesis. Must stay verdict-identical to
|
||||||
* ClassifyBox — a diagnostic that takes a slightly different path sends you hunting in the
|
* ClassifyBox, including later forcing posts that may overwrite a dead hypothesis — a
|
||||||
* wrong place.
|
* diagnostic that takes a slightly different path sends you hunting in the wrong place.
|
||||||
*/
|
*/
|
||||||
EVoxelTileClass ClassifyBoxAttributed(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
|
EVoxelTileClass ClassifyBoxAttributed(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
|
||||||
int32& OutSolidKiller, int32& OutAirKiller) const
|
int32& OutSolidKiller, int32& OutAirKiller) const
|
||||||
@@ -163,25 +406,81 @@ public:
|
|||||||
const bool bSolidBefore = H.bCanBeAllSolid;
|
const bool bSolidBefore = H.bCanBeAllSolid;
|
||||||
const bool bAirBefore = H.bCanBeAllAir;
|
const bool bAirBefore = H.bCanBeAllAir;
|
||||||
|
|
||||||
VF_FoldOp(H, *Ops[i], VoxelBox, Ctx);
|
VF_FoldOp(H, *Ops[i].Op, VoxelBox, Ctx,
|
||||||
|
(Ops[i].Writes & VoxelOpChannels::Sdf) != 0);
|
||||||
|
|
||||||
if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; }
|
if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; }
|
||||||
if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; }
|
if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; }
|
||||||
|
|
||||||
if (H.IsDead()) { return EVoxelTileClass::Mixed; }
|
|
||||||
}
|
}
|
||||||
return H.Resolve();
|
return H.Resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-operator box-bound audit. This deliberately replays the same fold as ClassifyBox while
|
||||||
|
* also brute-forcing Eval on the queried lattice. It is report/test infrastructure only: it
|
||||||
|
* must never be used to make a generation decision.
|
||||||
|
*/
|
||||||
|
struct FOpBoxDiagnostic
|
||||||
|
{
|
||||||
|
int32 Index = INDEX_NONE;
|
||||||
|
FString Name;
|
||||||
|
EVoxelOpEffect Effect = EVoxelOpEffect::Identity;
|
||||||
|
EVoxelTileClass ForcedVerdict = EVoxelTileClass::Mixed;
|
||||||
|
bool bForced = false;
|
||||||
|
bool bWritesSdf = false;
|
||||||
|
float ForcedMargin = 0.0f;
|
||||||
|
float MaxCarve = 0.0f;
|
||||||
|
float MaxFill = 0.0f;
|
||||||
|
bool bHasDensityDelta = false;
|
||||||
|
float ActualDensityDeltaMin = FLT_MAX;
|
||||||
|
float ActualDensityDeltaMax = -FLT_MAX;
|
||||||
|
bool bHasSdfValue = false;
|
||||||
|
float ActualSdfMin = FLT_MAX;
|
||||||
|
float ActualSdfMax = -FLT_MAX;
|
||||||
|
bool bHasSdfBound = false;
|
||||||
|
float SdfBoundMin = FLT_MAX;
|
||||||
|
float SdfBoundMax = FLT_MAX;
|
||||||
|
bool bCarveBoundViolated = false;
|
||||||
|
bool bFillBoundViolated = false;
|
||||||
|
uint64 SampleCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
VOXELFORGE_API void DiagnoseBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
|
||||||
|
int32 SampleStep, TArray<FOpBoxDiagnostic>& OutDiagnostics,
|
||||||
|
EVoxelTileClass* OutVerdict = nullptr) const;
|
||||||
|
|
||||||
/** Nom lisible d'un opérateur, pour les rapports de test. Voir `IVoxelDensityOp::DebugName`. */
|
/** Nom lisible d'un opérateur, pour les rapports de test. Voir `IVoxelDensityOp::DebugName`. */
|
||||||
const TCHAR* GetOpDebugName(int32 Index) const
|
const TCHAR* GetOpDebugName(int32 Index) const
|
||||||
{
|
{
|
||||||
return Ops.IsValidIndex(Index) ? Ops[Index]->DebugName() : TEXT("(none)");
|
return Ops.IsValidIndex(Index) ? Ops[Index].Op->DebugName() : TEXT("(none)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the stack's channel dependency DAG without evaluating a voxel.
|
||||||
|
*
|
||||||
|
* Rule: each channel has a current version. A channel is unavailable until an earlier op
|
||||||
|
* writes it. The one deliberate exception is a root `FieldSource` that reads and writes the
|
||||||
|
* same channel as an identity fold (for example `min(FLT_MAX, Sdf)`); it may consume that
|
||||||
|
* channel's `FVoxelOpSample` initial identity while publishing the first real version. An op
|
||||||
|
* may read only the version visible on entry; a read/write op consumes that version and
|
||||||
|
* publishes the next one. A write-only op is a replacement/producer, so it is legal only as
|
||||||
|
* the first producer of that channel; allowing a second write-only producer would silently
|
||||||
|
* clobber the earlier field. An additive op must read every channel it writes, because an
|
||||||
|
* additive delta cannot introduce or replace a channel. All producer-to-consumer edges
|
||||||
|
* therefore point forward in the list, and the list is a legal topological ordering of the
|
||||||
|
* channel DAG. A later read deliberately sees the writer's post-op version; a consumer that
|
||||||
|
* needs a pre-write value must be placed before that writer. The declarations do not invent
|
||||||
|
* dependencies from names, roles, or Eval side effects.
|
||||||
|
*
|
||||||
|
* This is assembly/diagnostic work only. It must not be called from Eval or any voxel loop.
|
||||||
|
* Returns false and optionally describes the first violation.
|
||||||
|
*/
|
||||||
|
VOXELFORGE_API bool ValidateChannelOrder(FString* OutError = nullptr) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RÔLE 4 — ajoute les invariants de monde, dans l'ordre fixe, à la fin de la pile.
|
* RÔLE 4 — ajoute les invariants de monde, dans l'ordre fixe, à la fin de la pile.
|
||||||
* spine (0,0) → seal de frontière → carve de passage.
|
* spine (0,0) → seal de frontière → carve de passage → [posts spécifiques] → seal de limite XY.
|
||||||
*
|
*
|
||||||
* ⚠️ La couche de diff (édits joueur) n'est PAS ici : elle vit dans `GetDensityAt`, APRÈS la
|
* ⚠️ La couche de diff (édits joueur) n'est PAS ici : elle vit dans `GetDensityAt`, APRÈS la
|
||||||
* négation MC, avec les disturbances. Elle rejoindra la pile quand les disturbances seront
|
* négation MC, avec les disturbances. Elle rejoindra la pile quand les disturbances seront
|
||||||
@@ -192,13 +491,19 @@ public:
|
|||||||
* It joins the stack when disturbances are ported. Harmless while the stack feeds nothing.
|
* It joins the stack when disturbances are ported. Harmless while the stack feeds nothing.
|
||||||
*
|
*
|
||||||
* @param StrateManager peut être nullptr → pas de carve de passage (comme le fallback actuel).
|
* @param StrateManager peut être nullptr → pas de carve de passage (comme le fallback actuel).
|
||||||
|
* Le seal XY reste toujours présent ; son rayon/thickness viennent de Ctx
|
||||||
|
* au moment de PrepareChunk (0 = monde non borné).
|
||||||
*/
|
*/
|
||||||
VOXELFORGE_API void AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ,
|
VOXELFORGE_API void AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ,
|
||||||
float SealThickness, float BaseDensity, float SpineRadius,
|
float SealThickness, float BaseDensity, float SpineRadius,
|
||||||
const UVoxelStrateManager* StrateManager);
|
const UVoxelStrateManager* StrateManager,
|
||||||
|
bool bAppendEdgeSeal = true);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
TArray<TUniquePtr<IVoxelDensityOp>> Ops;
|
TArray<FOpEntry> Ops;
|
||||||
|
FVoxelOpContext PreparedContext;
|
||||||
|
bool bHasPreparedContext = false;
|
||||||
|
EVoxelOpFusedEvaluator FusedEvaluator = EVoxelOpFusedEvaluator::None;
|
||||||
};
|
};
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -207,6 +512,10 @@ private:
|
|||||||
|
|
||||||
namespace VoxelDensityOps
|
namespace VoxelDensityOps
|
||||||
{
|
{
|
||||||
|
/** Query the concrete op declaration used by the offline recipe roller. */
|
||||||
|
VOXELFORGE_API bool GetStrateOpContract(EVoxelStrateOpClass OpClass,
|
||||||
|
FVoxelStrateOpContract& OutContract);
|
||||||
|
|
||||||
/** Rôle 1 — `Density = BaseDensity` partout. `ClassifyBox` → AllSolid, exact et gratuit.
|
/** Rôle 1 — `Density = BaseDensity` partout. `ClassifyBox` → AllSolid, exact et gratuit.
|
||||||
* Racine de TunnelNetwork, Maze, VerticalShafts et des gaps de bedrock. */
|
* Racine de TunnelNetwork, Maze, VerticalShafts et des gaps de bedrock. */
|
||||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantRockSource(float BaseDensity);
|
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantRockSource(float BaseDensity);
|
||||||
@@ -218,17 +527,15 @@ namespace VoxelDensityOps
|
|||||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantVoidSource(float BaseDensity);
|
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantVoidSource(float BaseDensity);
|
||||||
|
|
||||||
/** Rôle 1 — les couloirs de Maze : capsules sur les arêtes ouvertes d'un treillis 3D.
|
/** Rôle 1 — les couloirs de Maze : capsules sur les arêtes ouvertes d'un treillis 3D.
|
||||||
* Écrit le canal SDF uniquement. Identité d'arête = hash(nœud inférieur, axe), donc deux
|
* Écrit le canal SDF uniquement. Chaque nœud hors origine choisit un parent vers l'origine;
|
||||||
* chunks adjacents NE PEUVENT PAS être en désaccord : pas de cache de chunk, pas de région
|
* les arêtes canoniques (nœud inférieur + axe) testent localement les deux parents. La source
|
||||||
* COLLECT, zéro risque de couture (AUDIT §6.4 — le motif à préférer). */
|
* met en cache les décisions de la fenêtre {-1,0}³ par cellule; les chunks adjacents voient
|
||||||
/* `ExtraReach` = tout ce qui peut eLARGIR la portée du couloir en aval (amplitude de rugosité
|
* donc exactement le même graphe, sans collect global ni couture. */
|
||||||
* rayon de blend du carve). La source répond pour la paire source+conversion dans
|
/** The source answers only for its own SDF field. Its box query publishes an SDF interval;
|
||||||
* `EffectOverBox` (voir la note « SIMPLIFICATION DE PHASE 1 » dans VoxelDensityOp.h), donc elle
|
* each later converter or modifier consumes that interval through the generic fold. It does
|
||||||
* doit connaître cette marge, sinon sa réponse `Identity` serait un MENSONGE — c'est-à-dire un
|
* not know, and must not answer for, any downstream operator. */
|
||||||
* trou. / The source answers for the source+conversion pair, so it must know the downstream
|
|
||||||
* margin: an Identity that is wrong is a hole. */
|
|
||||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeLatticeCorridorSource(const FMazeGenerationParams& P,
|
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeLatticeCorridorSource(const FMazeGenerationParams& P,
|
||||||
int32 Seed, float ExtraReach);
|
int32 Seed);
|
||||||
|
|
||||||
/** Rôle 3 — rugosité de paroi appliquée au canal SDF (variante Maze/Shafts/Islands).
|
/** Rôle 3 — rugosité de paroi appliquée au canal SDF (variante Maze/Shafts/Islands).
|
||||||
* `Frequency` est codée en dur au site d'appel aujourd'hui (0.12 pour Maze) ; l'exposer est
|
* `Frequency` est codée en dur au site d'appel aujourd'hui (0.12 pour Maze) ; l'exposer est
|
||||||
@@ -253,7 +560,8 @@ namespace VoxelDensityOps
|
|||||||
/** Rôle 1 — la dalle : surface de sol + surface de plafond → champ de vide. **XY-PUR** depuis
|
/** Rôle 1 — la dalle : surface de sol + surface de plafond → champ de vide. **XY-PUR** depuis
|
||||||
* OPSTACK-DECOMPOSITION §3.1 (le terme en Z des deux bruits est parti), ce qui lui donne un
|
* OPSTACK-DECOMPOSITION §3.1 (le terme en Z des deux bruits est parti), ce qui lui donne un
|
||||||
* `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z
|
* `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z
|
||||||
* bornées par le contrat [-1,1] de FBM. Sert FlatPlain **et** CrystalChamber. */
|
* bornées par le supremum prouvé de FBM (1.5, not the nominal parameter label). Sert
|
||||||
|
* FlatPlain **et** CrystalChamber. */
|
||||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed);
|
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed);
|
||||||
|
|
||||||
/** Rôle 3 — cylindres de hauteur infinie sur une grille monde. N'ajoute que du solide ⇒
|
/** Rôle 3 — cylindres de hauteur infinie sur une grille monde. N'ajoute que du solide ⇒
|
||||||
@@ -282,11 +590,12 @@ namespace VoxelDensityOps
|
|||||||
const UVoxelStrateManager* StrateManager,
|
const UVoxelStrateManager* StrateManager,
|
||||||
const TArray<FSurfaceGenerationParams>& PerBiomeParams =
|
const TArray<FSurfaceGenerationParams>& PerBiomeParams =
|
||||||
TArray<FSurfaceGenerationParams>(),
|
TArray<FSurfaceGenerationParams>(),
|
||||||
TUniquePtr<IVoxelBiomeField> BiomeField = nullptr);
|
TUniquePtr<IVoxelBiomeField> BiomeField = nullptr,
|
||||||
|
bool bAppendStructuralPosts = true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** :
|
* FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** :
|
||||||
* SlabVoidSource → GridColumnMod → [structural post ×3]
|
* SlabVoidSource → GridColumnMod → [structural post ×4]
|
||||||
*
|
*
|
||||||
* C'est le premier vrai gain du refactor (OPSTACK-PLAN §4) : deux des huit archétypes
|
* C'est le premier vrai gain du refactor (OPSTACK-PLAN §4) : deux des huit archétypes
|
||||||
* disparaissent dans un opérateur, et leur différence redevient ce qu'elle était déjà dans
|
* disparaissent dans un opérateur, et leur différence redevient ce qu'elle était déjà dans
|
||||||
@@ -294,11 +603,13 @@ namespace VoxelDensityOps
|
|||||||
*/
|
*/
|
||||||
VOXELFORGE_API void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P,
|
VOXELFORGE_API void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P,
|
||||||
int32 Seed, float SpineRadius,
|
int32 Seed, float SpineRadius,
|
||||||
const UVoxelStrateManager* StrateManager);
|
const UVoxelStrateManager* StrateManager,
|
||||||
|
bool bAppendStructuralPosts = true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VerticalShafts — 8 ops, et **TROIS viennent de Maze sans une ligne de changement** :
|
* VerticalShafts — 11 ops, dont les connecteurs air/sol et les quatre structural posts :
|
||||||
* ConstantRock → ShaftField → SdfRoughness → SdfCarve → ShaftLedge → [structural post ×3]
|
* ConstantRock → ShaftField → SdfRoughness → SdfCarve → ShaftLedge
|
||||||
|
* → ShaftConnectorAir → ShaftConnectorFloor → [structural post ×4]
|
||||||
*
|
*
|
||||||
* C'est la démonstration que `§2.5` promettait : dans le `switch`, Maze et VerticalShafts sont
|
* C'est la démonstration que `§2.5` promettait : dans le `switch`, Maze et VerticalShafts sont
|
||||||
* deux fonctions de ~100 lignes sans rien de commun à l'œil ; en opérateurs, ce sont les mêmes
|
* deux fonctions de ~100 lignes sans rien de commun à l'œil ; en opérateurs, ce sont les mêmes
|
||||||
@@ -307,13 +618,14 @@ namespace VoxelDensityOps
|
|||||||
*/
|
*/
|
||||||
VOXELFORGE_API void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& P,
|
VOXELFORGE_API void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& P,
|
||||||
int32 Seed, float SpineRadius,
|
int32 Seed, float SpineRadius,
|
||||||
const UVoxelStrateManager* StrateManager);
|
const UVoxelStrateManager* StrateManager,
|
||||||
|
bool bAppendStructuralPosts = true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TunnelNetwork — **COMPLET, 19 ops** :
|
* TunnelNetwork — **COMPLET** :
|
||||||
* ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b)
|
* ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b)
|
||||||
* → Terrace → LayerLines → Ribbing → Overhang → Cliff → Scallop → Arch → RoomColumn(4d)
|
* → Terrace → LayerLines → Ribbing → Overhang → Cliff → Scallop → Arch → RoomColumn(4d)
|
||||||
* → Dome(4g) → Pinch(4h) → FloorBias → Worms → [structural ×3]
|
* → Dome(4g) → Pinch(4h) → FloorBias → Worms → [spine, boundary, passage, graph-air, XY seal]
|
||||||
*
|
*
|
||||||
* L'override d'op PAR SALLE (étape C1) n'ajoute aucun opérateur : `FRoomGraphSource` publie
|
* L'override d'op PAR SALLE (étape C1) n'ajoute aucun opérateur : `FRoomGraphSource` publie
|
||||||
* `LocalParams()` — les params de la strate avec l'op de la salle la plus proche appliqué — et
|
* `LocalParams()` — les params de la strate avec l'op de la salle la plus proche appliqué — et
|
||||||
@@ -328,11 +640,13 @@ namespace VoxelDensityOps
|
|||||||
VOXELFORGE_API void BuildTunnelNetworkStack(FVoxelOpStack& OutStack,
|
VOXELFORGE_API void BuildTunnelNetworkStack(FVoxelOpStack& OutStack,
|
||||||
const FStrateGenerationParams& P,
|
const FStrateGenerationParams& P,
|
||||||
int32 Seed, float SpineRadius,
|
int32 Seed, float SpineRadius,
|
||||||
const UVoxelStrateManager* StrateManager);
|
const UVoxelStrateManager* StrateManager,
|
||||||
|
bool bAppendStructuralPosts = true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE du dernier `FRoomGraphSource::EffectOverBox`
|
* DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE de la dernière propagation d'intervalle
|
||||||
* évalué sur ce thread. **Tests uniquement. N'entre dans aucune décision de génération.**
|
* de `FRoomGraphSource` évaluée sur ce thread. **Tests uniquement. N'entre dans aucune décision
|
||||||
|
* de génération.**
|
||||||
*
|
*
|
||||||
* ⚠️ POURQUOI ÇA EXISTE PLUTÔT QUE D'ÊTRE REFAIT DANS LE TEST. Le test a déjà tout ce qu'il faut
|
* ⚠️ POURQUOI ÇA EXISTE PLUTÔT QUE D'ÊTRE REFAIT DANS LE TEST. Le test a déjà tout ce qu'il faut
|
||||||
* pour rejouer le critère — il appelle `BuildChunkCache` ailleurs. Le rejouer serait une
|
* pour rejouer le critère — il appelle `BuildChunkCache` ailleurs. Le rejouer serait une
|
||||||
@@ -358,13 +672,36 @@ namespace VoxelDensityOps
|
|||||||
* resserrement autour du mauvais terme. */
|
* resserrement autour du mauvais terme. */
|
||||||
int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0;
|
int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0;
|
||||||
float WarpDilation = 0.0f;
|
float WarpDilation = 0.0f;
|
||||||
|
|
||||||
|
/** Conservative candidate for the world-space tunnel-core post that runs after EvalMC.
|
||||||
|
* This is derived from the final post's own world-chain bounds, not from the warped SDF
|
||||||
|
* source's broader threshold hit count. */
|
||||||
|
bool bMayHaveTunnelCoreAir = false;
|
||||||
|
bool bMayHaveTunnelSupportFloor = false;
|
||||||
|
bool bMayHaveTunnelCoreTail = false;
|
||||||
|
uint32 ExactTailQueries = 0;
|
||||||
|
uint32 ExactTailEvaluated = 0;
|
||||||
|
uint64 ExactTailCycles = 0;
|
||||||
|
uint64 PropagateCycles = 0;
|
||||||
|
uint64 ExactPrimitiveCycles = 0;
|
||||||
|
uint64 CacheWindowCycles = 0;
|
||||||
|
int32 NumRoomFloorJoins = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic();
|
VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic();
|
||||||
|
|
||||||
|
/** Current worker-local room-graph cache footprint; profiling/diagnostic use only. */
|
||||||
|
VOXELFORGE_API void ReportWorkerRoomGraphCacheFootprint();
|
||||||
|
|
||||||
|
/** Current process-wide immutable room-graph proof-cache footprint; diagnostic use only. */
|
||||||
|
VOXELFORGE_API void ReportSharedRoomGraphCacheFootprint();
|
||||||
|
|
||||||
|
/** Drop completed proof caches from older manager lifetimes, or all of them when zero. */
|
||||||
|
VOXELFORGE_API void TrimSharedRoomGraphCache(uint64 KeepManagerLifetimeId = 0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FloatingIslands — 7 ops, et **la pile tourne à l'ENVERS** :
|
* FloatingIslands — 8 ops, et **la pile tourne à l'ENVERS** :
|
||||||
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3]
|
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×4]
|
||||||
*
|
*
|
||||||
* Les quatre archétypes portés jusqu'ici partent de ROC et CREUSENT ; celui-ci part du VIDE et
|
* Les quatre archétypes portés jusqu'ici partent de ROC et CREUSENT ; celui-ci part du VIDE et
|
||||||
* REMPLIT. Aucune des deux extrémités n'a demandé d'opérateur neuf — `FConstantFieldSource` et
|
* REMPLIT. Aucune des deux extrémités n'a demandé d'opérateur neuf — `FConstantFieldSource` et
|
||||||
@@ -376,7 +713,8 @@ namespace VoxelDensityOps
|
|||||||
*/
|
*/
|
||||||
VOXELFORGE_API void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P,
|
VOXELFORGE_API void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P,
|
||||||
int32 Seed, float SpineRadius,
|
int32 Seed, float SpineRadius,
|
||||||
const UVoxelStrateManager* StrateManager);
|
const UVoxelStrateManager* StrateManager,
|
||||||
|
bool bAppendStructuralPosts = true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* La pile Maze complète, décomposée — PAS un `FMazeOp` monolithique :
|
* La pile Maze complète, décomposée — PAS un `FMazeOp` monolithique :
|
||||||
@@ -387,5 +725,28 @@ namespace VoxelDensityOps
|
|||||||
*/
|
*/
|
||||||
VOXELFORGE_API void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P,
|
VOXELFORGE_API void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P,
|
||||||
int32 Seed, float SpineRadius,
|
int32 Seed, float SpineRadius,
|
||||||
const UVoxelStrateManager* StrateManager);
|
const UVoxelStrateManager* StrateManager,
|
||||||
|
bool bAppendStructuralPosts = true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Materialise an offline recipe. Structural posts are appended by default; lateral region cores
|
||||||
|
* deliberately pass false so the parent stack can append the global posts exactly once. */
|
||||||
|
VOXELFORGE_API bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe,
|
||||||
|
const FVoxelStrateArchetypeParams& Params,
|
||||||
|
int32 Seed, float SpineRadius,
|
||||||
|
const UVoxelStrateManager* StrateManager,
|
||||||
|
FVoxelOpStack& OutStack,
|
||||||
|
FVoxelOpContext& OutContext,
|
||||||
|
FString* OutError = nullptr,
|
||||||
|
bool bAppendStructuralPosts = true);
|
||||||
|
|
||||||
|
/** Build the region combiner stack. Creative cores are independent; structural posts are global. */
|
||||||
|
#if WITH_EDITOR
|
||||||
|
VOXELFORGE_API bool VF_BuildStrateRegionStack(
|
||||||
|
const FVoxelStrateRegionManifest& Manifest,
|
||||||
|
float SpineRadius,
|
||||||
|
const UVoxelStrateManager* StrateManager,
|
||||||
|
FVoxelOpStack& OutStack,
|
||||||
|
FVoxelOpContext& OutContext,
|
||||||
|
FString* OutError = nullptr);
|
||||||
|
#endif
|
||||||
@@ -1,32 +1,34 @@
|
|||||||
// VoxelDensityPrimitives.h
|
// VoxelDensityPrimitives.h
|
||||||
// Les trois post-traitements STRUCTURELS partagés par chaque archétype.
|
// Les quatre post-traitements STRUCTURELS partagés par chaque archétype.
|
||||||
// The three STRUCTURAL post-processes every archetype shares.
|
// The four STRUCTURAL post-processes every archetype shares.
|
||||||
//
|
//
|
||||||
// POURQUOI CE FICHIER EXISTE / WHY THIS FILE EXISTS
|
// POURQUOI CE FICHIER EXISTE / WHY THIS FILE EXISTS
|
||||||
// Ces trois fonctions étaient `static` dans VoxelGenerator.cpp et appelées à l'identique par les six
|
// Les trois primitives verticales étaient `static` dans VoxelGenerator.cpp et appelées à l'identique
|
||||||
// fonctions de densité. La pile d'opérateurs a besoin des MÊMES, donc elles montent ici : UNE copie,
|
// par les six fonctions de densité. La pile d'opérateurs a besoin des MÊMES, donc elles montent ici :
|
||||||
// partagée par le générateur et par les opérateurs. Dupliquer serait garantir qu'elles divergent —
|
// UNE copie partagée. Le scellement XY est ajouté dans ce même fichier pour que les deux chemins
|
||||||
// et ce sont des INVARIANTS de monde (la descente doit rester possible, les seals doivent tenir, les
|
// partagent aussi la nouvelle invariance — les invariants de monde ne sont pas des choix créatifs.
|
||||||
// passages doivent percer), pas des choix créatifs.
|
|
||||||
//
|
//
|
||||||
// They were `static` in VoxelGenerator.cpp and called identically by all six density functions. The
|
// The three vertical primitives were `static` in VoxelGenerator.cpp and called identically by all six
|
||||||
// operator stack needs the same ones, so they move here: ONE copy, shared. Duplicating would
|
// density functions. The operator stack needs the same ones, so they move here: ONE shared copy. The
|
||||||
// guarantee divergence, and these are world INVARIANTS, not creative choices.
|
// XY seal is added beside them so both paths share the new invariant too.
|
||||||
//
|
//
|
||||||
// ⚠️ CONVENTION DE SIGNE — la source n°1 de confusion du plugin.
|
// ⚠️ CONVENTION DE SIGNE — la source n°1 de confusion du plugin.
|
||||||
// Ces trois fonctions travaillent en convention INTERNE : **positif = SOLIDE, négatif = AIR**.
|
// Ces primitives travaillent en convention INTERNE : **positif = SOLIDE, négatif = AIR**.
|
||||||
// C'est la convention dans laquelle chaque fonction d'archétype est écrite ; la négation vers la
|
// C'est la convention dans laquelle chaque fonction d'archétype est écrite ; la négation vers la
|
||||||
// convention marching-cubes (négatif = solide) se fait UNE FOIS, sur le `return`.
|
// convention marching-cubes (négatif = solide) se fait UNE FOIS, sur le `return`.
|
||||||
// SIGN CONVENTION: these work in INTERNAL convention — **positive = SOLID**. The negate to MC
|
// SIGN CONVENTION: these work in INTERNAL convention — **positive = SOLID**. The negate to MC
|
||||||
// convention happens ONCE, at the caller's return.
|
// convention happens ONCE, at the caller's return.
|
||||||
//
|
//
|
||||||
// Aucun changement de comportement en les déplaçant : corps identiques, FORCEINLINE au lieu de
|
// Les trois corps déplacés restent identiques; le quatrième est la nouvelle frontière globale.
|
||||||
// static, mêmes appelants. / No behavioural change: identical bodies, FORCEINLINE instead of static.
|
// The three moved bodies remain identical; the fourth is the new global boundary.
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
#include "VoxelTypes.h" // SmoothStep01
|
#include "VoxelTypes.h" // SmoothStep01
|
||||||
|
#include "VoxelPassageGeometry.h" // shared body-sized landing/tunnel geometry
|
||||||
|
#include "VoxelDensityAblation.h" // development-only world-changing measurements
|
||||||
|
#include "VoxelTilePostReach.h" // worker-local landing/structural post reach bits
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// SEAL DE FRONTIÈRE / BOUNDARY SEAL
|
// SEAL DE FRONTIÈRE / BOUNDARY SEAL
|
||||||
@@ -43,7 +45,7 @@ FORCEINLINE void VF_ApplyBoundarySeal(float& Density, float WorldZ,
|
|||||||
float StrateTopZ, float StrateBottomZ,
|
float StrateTopZ, float StrateBottomZ,
|
||||||
float Thickness, float BaseDensity)
|
float Thickness, float BaseDensity)
|
||||||
{
|
{
|
||||||
if (Thickness <= 0.0f) return;
|
if (VoxelDensityAblation::IsBoundarySealOff() || Thickness <= 0.0f) return;
|
||||||
|
|
||||||
const float DistTop = StrateTopZ - WorldZ; // + si on est sous le plafond
|
const float DistTop = StrateTopZ - WorldZ; // + si on est sous le plafond
|
||||||
const float DistBot = WorldZ - StrateBottomZ; // + si on est au-dessus du sol
|
const float DistBot = WorldZ - StrateBottomZ; // + si on est au-dessus du sol
|
||||||
@@ -71,6 +73,7 @@ FORCEINLINE void VF_ApplyBoundarySeal(float& Density, float WorldZ,
|
|||||||
FORCEINLINE void VF_ApplyPassageCarving(float& Density, float ModSDF,
|
FORCEINLINE void VF_ApplyPassageCarving(float& Density, float ModSDF,
|
||||||
float BaseDensity, float SealThickness)
|
float BaseDensity, float SealThickness)
|
||||||
{
|
{
|
||||||
|
if (VoxelDensityAblation::IsPassageCarvingOff()) return;
|
||||||
constexpr float PASSAGE_BLEND_RADIUS = 4.0f;
|
constexpr float PASSAGE_BLEND_RADIUS = 4.0f;
|
||||||
if (ModSDF >= PASSAGE_BLEND_RADIUS) return;
|
if (ModSDF >= PASSAGE_BLEND_RADIUS) return;
|
||||||
|
|
||||||
@@ -88,43 +91,293 @@ FORCEINLINE void VF_ApplyPassageCarving(float& Density, float ModSDF,
|
|||||||
Density = FMath::Min(Density, FMath::Lerp(Density, AirTarget, CarveFactor));
|
Density = FMath::Min(Density, FMath::Lerp(Density, AirTarget, CarveFactor));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Landing air is also reasserted after the MC-space disturbance post. Unlike the legacy tube
|
||||||
|
// carve above, this threshold is independent of the incoming density: applying it a second time
|
||||||
|
// is therefore bit-stable. The structural passage op and the post-disturbance backstop can share
|
||||||
|
// the same operation without turning an otherwise unchanged live-vs-direct op-stack comparison
|
||||||
|
// into a second, deeper smooth carve. The tube intentionally retains its old current-density
|
||||||
|
// interpolation because that is part of the legacy structural path's contract.
|
||||||
|
FORCEINLINE void VF_ApplyPassageLandingCarving(float& Density, float LandingSDF,
|
||||||
|
float BaseDensity, float SealThickness)
|
||||||
|
{
|
||||||
|
constexpr float PASSAGE_BLEND_RADIUS = 4.0f;
|
||||||
|
if (LandingSDF >= PASSAGE_BLEND_RADIUS) return;
|
||||||
|
|
||||||
|
float CarveFactor = FMath::Clamp(
|
||||||
|
(PASSAGE_BLEND_RADIUS - LandingSDF) / (PASSAGE_BLEND_RADIUS * 2.0f),
|
||||||
|
0.0f, 1.0f);
|
||||||
|
CarveFactor = SmoothStep01(CarveFactor);
|
||||||
|
|
||||||
|
const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f);
|
||||||
|
const float LandingThreshold = FMath::Lerp(BaseDensity, AirTarget, CarveFactor);
|
||||||
|
Density = FMath::Min(Density, LandingThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// SPINE DE DESCENTE (0,0) / (0,0) DESCENT SPINE
|
// LANDING ROOM ORIGINE (0,0) / ORIGIN LANDING ROOM
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// Creuse une colonne verticale garantie ouverte au XY monde (0,0) dans l'INTÉRIEUR de la strate
|
// The old implementation carved a full-height cylinder. That made the origin visually open but
|
||||||
// (entre les seals haut et bas). Les seals sont laissés intacts pour que le joueur doive encore
|
// left no floor anywhere except at the bottom, so it was a fall shaft rather than a landing. The
|
||||||
// creuser à travers pour descendre — ceci ne fait qu'un espace d'atterrissage propre, indépendant
|
// origin primitive is now a top-anchored rounded room with a real flat floor in every strate.
|
||||||
// de l'archétype, aligné à travers toutes les strates.
|
// It never crosses either vertical seal. The inter-strate seal remains solid until a passage (or
|
||||||
|
// the player) opens it; there is no default vertical bore between rooms.
|
||||||
FORCEINLINE void VF_ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ,
|
FORCEINLINE void VF_ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ,
|
||||||
float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius)
|
float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius)
|
||||||
{
|
{
|
||||||
if (Radius <= 0.0f) return;
|
if (VoxelDensityAblation::IsOriginSpineOff() || Radius <= 0.0f) return;
|
||||||
|
|
||||||
// Stay within the interior — never touch the seal bands.
|
const VoxelPassageGeometry::FOriginLandingGeometry Geometry =
|
||||||
const float InnerTop = StrateTopZ - SealThickness;
|
VoxelPassageGeometry::BuildOriginLandingGeometry(
|
||||||
const float InnerBot = StrateBottomZ + SealThickness;
|
StrateTopZ, StrateBottomZ, SealThickness, Radius);
|
||||||
if (WorldZ <= InnerBot || WorldZ >= InnerTop) return;
|
if (!Geometry.bValid) return;
|
||||||
|
|
||||||
const float DistXY = FMath::Sqrt(WorldX * WorldX + WorldY * WorldY);
|
const float RoomSDF = VoxelPassageGeometry::OriginLandingRoomSDF(
|
||||||
const float SDF = DistXY - Radius; // < 0 inside the column
|
FVector(WorldX, WorldY, WorldZ), Geometry);
|
||||||
const float Blend = 3.0f;
|
VF_ApplyPassageLandingCarving(
|
||||||
if (SDF < Blend)
|
Density, RoomSDF, BaseDensity, SealThickness);
|
||||||
|
|
||||||
|
// The floor is intentionally written after the room carve. It is also reasserted after the
|
||||||
|
// passage/disturbance posts by the callers below; doing it here makes the standalone primitive
|
||||||
|
// itself a complete landing-room operation.
|
||||||
|
if (WorldZ <= Geometry.FloorZ + KINDA_SMALL_NUMBER
|
||||||
|
&& WorldZ > Geometry.FloorZ - Geometry.FloorThickness
|
||||||
|
&& FMath::Abs(WorldX) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f)
|
||||||
|
&& FMath::Abs(WorldY) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f))
|
||||||
{
|
{
|
||||||
float Carve = FMath::Clamp((Blend - SDF) / (Blend * 2.0f), 0.0f, 1.0f);
|
if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker())
|
||||||
Carve = SmoothStep01(Carve);
|
{
|
||||||
Density -= Carve * (BaseDensity * 2.0f + SealThickness);
|
Density = FMath::Max(Density, BaseDensity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reassert only the origin-room air volume after a post-process that may add rock. */
|
||||||
|
FORCEINLINE void VF_ApplyOriginLandingAir(float& Density,
|
||||||
|
float WorldX, float WorldY, float WorldZ,
|
||||||
|
float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius)
|
||||||
|
{
|
||||||
|
if (VoxelDensityAblation::IsLandingPostsOff() || Radius <= 0.0f) return;
|
||||||
|
const VoxelPassageGeometry::FOriginLandingGeometry Geometry =
|
||||||
|
VoxelPassageGeometry::BuildOriginLandingGeometry(
|
||||||
|
StrateTopZ, StrateBottomZ, SealThickness, Radius);
|
||||||
|
if (!Geometry.bValid) return;
|
||||||
|
const float RoomSDF = VoxelPassageGeometry::OriginLandingRoomSDF(
|
||||||
|
FVector(WorldX, WorldY, WorldZ), Geometry);
|
||||||
|
VF_ApplyPassageLandingCarving(
|
||||||
|
Density, RoomSDF, BaseDensity, SealThickness);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reassert only the origin-room support slab after an air carve/post-process. */
|
||||||
|
FORCEINLINE void VF_ApplyOriginLandingFloor(float& Density,
|
||||||
|
float WorldX, float WorldY, float WorldZ,
|
||||||
|
float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius)
|
||||||
|
{
|
||||||
|
if (VoxelDensityAblation::IsLandingPostsOff() || Radius <= 0.0f) return;
|
||||||
|
const VoxelPassageGeometry::FOriginLandingGeometry Geometry =
|
||||||
|
VoxelPassageGeometry::BuildOriginLandingGeometry(
|
||||||
|
StrateTopZ, StrateBottomZ, SealThickness, Radius);
|
||||||
|
if (!Geometry.bValid) return;
|
||||||
|
if (WorldZ <= Geometry.FloorZ + KINDA_SMALL_NUMBER
|
||||||
|
&& WorldZ > Geometry.FloorZ - Geometry.FloorThickness
|
||||||
|
&& FMath::Abs(WorldX) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f)
|
||||||
|
&& FMath::Abs(WorldY) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f))
|
||||||
|
{
|
||||||
|
if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker())
|
||||||
|
{
|
||||||
|
Density = FMath::Max(Density, BaseDensity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MC-facing origin-room air backstop for the post-disturbance path. */
|
||||||
|
FORCEINLINE void VF_ApplyOriginLandingAirMC(float& Density,
|
||||||
|
float WorldX, float WorldY, float WorldZ,
|
||||||
|
float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius)
|
||||||
|
{
|
||||||
|
if (!VoxelGenLOD::IsOriginLandingReachable()
|
||||||
|
&& !VoxelGenLOD::IsTilePostReachBypassActive())
|
||||||
|
{
|
||||||
|
VoxelGenLOD::CompareSkippedPost(Density, [&]()
|
||||||
|
{
|
||||||
|
VF_ApplyOriginLandingAirMC(
|
||||||
|
Density, WorldX, WorldY, WorldZ,
|
||||||
|
StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius);
|
||||||
|
}, VoxelGenLOD::ETilePostComparisonKind::OriginAir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
float InternalDensity = -Density;
|
||||||
|
VF_ApplyOriginLandingAir(InternalDensity, WorldX, WorldY, WorldZ,
|
||||||
|
StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius);
|
||||||
|
Density = -InternalDensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MC-facing origin-room floor backstop for the post-disturbance path. */
|
||||||
|
FORCEINLINE void VF_ApplyOriginLandingFloorMC(float& Density,
|
||||||
|
float WorldX, float WorldY, float WorldZ,
|
||||||
|
float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius)
|
||||||
|
{
|
||||||
|
if (!VoxelGenLOD::IsOriginLandingReachable()
|
||||||
|
&& !VoxelGenLOD::IsTilePostReachBypassActive())
|
||||||
|
{
|
||||||
|
VoxelGenLOD::CompareSkippedPost(Density, [&]()
|
||||||
|
{
|
||||||
|
VF_ApplyOriginLandingFloorMC(
|
||||||
|
Density, WorldX, WorldY, WorldZ,
|
||||||
|
StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius);
|
||||||
|
}, VoxelGenLOD::ETilePostComparisonKind::OriginFloor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
float InternalDensity = -Density;
|
||||||
|
VF_ApplyOriginLandingFloor(InternalDensity, WorldX, WorldY, WorldZ,
|
||||||
|
StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius);
|
||||||
|
Density = -InternalDensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// XY EDGE SEAL / SCELLEMENT DE LA LIMITE XY
|
||||||
|
//=============================================================================
|
||||||
|
// The bounded-world rim is a radial solid ramp in actor-space XY. This helper uses the same
|
||||||
|
// internal convention as VF_ApplyBoundarySeal (positive = solid); the MC-facing wrapper below
|
||||||
|
// negates it so the output convention remains negative = solid.
|
||||||
|
//
|
||||||
|
// Radius == 0 is deliberately checked before any arithmetic: it is a true legacy no-op, not a
|
||||||
|
// nearly-invisible setting-dependent perturbation. The common interior case also returns from a
|
||||||
|
// squared-distance test and pays no sqrt.
|
||||||
|
FORCEINLINE void VF_ApplyXYEdgeSeal(float& Density, float WorldX, float WorldY,
|
||||||
|
float WorldRadiusVoxels, float Thickness, float BaseDensity)
|
||||||
|
{
|
||||||
|
if (VoxelDensityAblation::IsXYEdgeSealOff()
|
||||||
|
|| !(WorldRadiusVoxels > 0.0f) || !(Thickness > 0.0f) || !(BaseDensity > 0.0f)) return;
|
||||||
|
if (!VoxelMath::IsFinite(WorldX) || !VoxelMath::IsFinite(WorldY)
|
||||||
|
|| !VoxelMath::IsFinite(WorldRadiusVoxels) || !VoxelMath::IsFinite(Thickness)
|
||||||
|
|| !VoxelMath::IsFinite(BaseDensity)) return;
|
||||||
|
|
||||||
|
const float InnerRadius = WorldRadiusVoxels - Thickness;
|
||||||
|
const float InnerRadiusSq = InnerRadius * InnerRadius;
|
||||||
|
const float DistSq = WorldX * WorldX + WorldY * WorldY;
|
||||||
|
const float RadiusSq = WorldRadiusVoxels * WorldRadiusVoxels;
|
||||||
|
|
||||||
|
// The overwhelmingly common case: well inside the bounded world.
|
||||||
|
if (InnerRadius > 0.0f && DistSq <= InnerRadiusSq) return;
|
||||||
|
|
||||||
|
// Keep the outer-boundary comparison in squared space too. This makes the exact force branch
|
||||||
|
// and the ClassifyBox proof use the same predicate: at or beyond R the result is BaseDensity,
|
||||||
|
// with no sqrt-rounding ambiguity in the forced-solid margin.
|
||||||
|
if (DistSq >= RadiusSq)
|
||||||
|
{
|
||||||
|
Density = FMath::Max(Density, BaseDensity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float DistXY = FMath::Sqrt(DistSq);
|
||||||
|
// 0 at the inner edge, 1 at the world radius; smoothstep removes a cylindrical cliff.
|
||||||
|
const float SealFactor = SmoothStep01(FMath::Clamp(
|
||||||
|
(DistXY - InnerRadius) / Thickness, 0.0f, 1.0f));
|
||||||
|
|
||||||
|
// At/beyond WorldRadiusVoxels the branch above already forced exactly BaseDensity.
|
||||||
|
Density = FMath::Max(Density, SealFactor * BaseDensity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MC-facing form used after post-processes that already operate in the output convention. */
|
||||||
|
FORCEINLINE void VF_ApplyXYEdgeSealMC(float& Density, float WorldX, float WorldY,
|
||||||
|
float WorldRadiusVoxels, float Thickness, float BaseDensity)
|
||||||
|
{
|
||||||
|
if (VoxelDensityAblation::IsXYEdgeSealOff()
|
||||||
|
|| !(WorldRadiusVoxels > 0.0f) || !(Thickness > 0.0f)) return;
|
||||||
|
|
||||||
|
float InternalDensity = -Density;
|
||||||
|
VF_ApplyXYEdgeSeal(InternalDensity, WorldX, WorldY,
|
||||||
|
WorldRadiusVoxels, Thickness, BaseDensity);
|
||||||
|
Density = -InternalDensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// XY EDGE PROOF / PREUVE DE BOITE
|
||||||
|
//=============================================================================
|
||||||
|
// The closest point of an AABB to the XY origin gives the minimum radius over the whole box.
|
||||||
|
// The helpers below are shared by ClassifyBox, ClassifyTile's global shell early-out, and the
|
||||||
|
// soundness test. They intentionally require one voxel of radial margin in the ramp: at the
|
||||||
|
// mathematical inner edge the helper is a true no-op, so claiming AllSolid there would be false.
|
||||||
|
namespace VoxelDensityReach
|
||||||
|
{
|
||||||
|
constexpr float SpineBlend = 3.0f;
|
||||||
|
constexpr float PassageBlend = 4.0f;
|
||||||
|
constexpr float EdgeProofMargin = 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE bool VF_IsValidXYEdgeSealProofInput(const FBox& VoxelBox,
|
||||||
|
float WorldRadiusVoxels, float Thickness, float BaseDensity)
|
||||||
|
{
|
||||||
|
return VoxelBox.IsValid
|
||||||
|
&& VoxelMath::IsFinite(VoxelBox.Min.X) && VoxelMath::IsFinite(VoxelBox.Min.Y)
|
||||||
|
&& VoxelMath::IsFinite(VoxelBox.Max.X) && VoxelMath::IsFinite(VoxelBox.Max.Y)
|
||||||
|
&& VoxelMath::IsFinite(WorldRadiusVoxels) && VoxelMath::IsFinite(Thickness)
|
||||||
|
&& VoxelMath::IsFinite(BaseDensity)
|
||||||
|
&& WorldRadiusVoxels > 0.0f && Thickness > 0.0f && BaseDensity > 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float VF_XYEdgeClosestRadiusSq(const FBox& VoxelBox)
|
||||||
|
{
|
||||||
|
const float ClosestX = (VoxelBox.Min.X > 0.0f) ? VoxelBox.Min.X
|
||||||
|
: (VoxelBox.Max.X < 0.0f) ? VoxelBox.Max.X : 0.0f;
|
||||||
|
const float ClosestY = (VoxelBox.Min.Y > 0.0f) ? VoxelBox.Min.Y
|
||||||
|
: (VoxelBox.Max.Y < 0.0f) ? VoxelBox.Max.Y : 0.0f;
|
||||||
|
return ClosestX * ClosestX + ClosestY * ClosestY;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float VF_XYEdgeFarthestRadiusSq(const FBox& VoxelBox)
|
||||||
|
{
|
||||||
|
const float FarthestX = FMath::Max(FMath::Abs(VoxelBox.Min.X), FMath::Abs(VoxelBox.Max.X));
|
||||||
|
const float FarthestY = FMath::Max(FMath::Abs(VoxelBox.Min.Y), FMath::Abs(VoxelBox.Max.Y));
|
||||||
|
return FarthestX * FarthestX + FarthestY * FarthestY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the actual positive lower-bound margin imposed by the edge seal over a box, or 0 when
|
||||||
|
* the box is not provably forced. The ramp bound is intentionally reduced by 2× after the
|
||||||
|
* one-voxel proof margin, so float evaluation error can only make this estimate too small.
|
||||||
|
*/
|
||||||
|
FORCEINLINE float VF_XYEdgeSealForcedMarginOverBox(const FBox& VoxelBox,
|
||||||
|
float WorldRadiusVoxels, float Thickness, float BaseDensity)
|
||||||
|
{
|
||||||
|
if (!VF_IsValidXYEdgeSealProofInput(VoxelBox, WorldRadiusVoxels, Thickness, BaseDensity)) return 0.0f;
|
||||||
|
|
||||||
|
const float ClosestRadiusSq = VF_XYEdgeClosestRadiusSq(VoxelBox);
|
||||||
|
const float RadiusSq = WorldRadiusVoxels * WorldRadiusVoxels;
|
||||||
|
if (ClosestRadiusSq >= RadiusSq)
|
||||||
|
{
|
||||||
|
// The per-voxel helper takes the >= WorldRadiusVoxels branch and forces BaseDensity.
|
||||||
|
return BaseDensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float InnerRadius = WorldRadiusVoxels - Thickness;
|
||||||
|
const float ProofRadius = InnerRadius + VoxelDensityReach::EdgeProofMargin;
|
||||||
|
if (!(Thickness > VoxelDensityReach::EdgeProofMargin)
|
||||||
|
|| !(ClosestRadiusSq >= ProofRadius * ProofRadius)) return 0.0f;
|
||||||
|
|
||||||
|
const float GuaranteedFactor = SmoothStep01(
|
||||||
|
FMath::Clamp(VoxelDensityReach::EdgeProofMargin / Thickness, 0.0f, 1.0f));
|
||||||
|
return BaseDensity * GuaranteedFactor * 0.5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE bool VF_XYEdgeSealBoxIsForcedSolid(const FBox& VoxelBox,
|
||||||
|
float WorldRadiusVoxels, float Thickness, float BaseDensity)
|
||||||
|
{
|
||||||
|
return VF_XYEdgeSealForcedMarginOverBox(
|
||||||
|
VoxelBox, WorldRadiusVoxels, Thickness, BaseDensity) > 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE bool VF_XYEdgeSealBoxTouchesBand(const FBox& VoxelBox,
|
||||||
|
float WorldRadiusVoxels, float Thickness)
|
||||||
|
{
|
||||||
|
if (!(WorldRadiusVoxels > 0.0f) || !(Thickness > 0.0f)
|
||||||
|
|| !VoxelMath::IsFinite(WorldRadiusVoxels) || !VoxelMath::IsFinite(Thickness)) return false;
|
||||||
|
|
||||||
|
const float InnerRadius = WorldRadiusVoxels - Thickness;
|
||||||
|
return VF_XYEdgeFarthestRadiusSq(VoxelBox) > InnerRadius * InnerRadius;
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// PORTÉES / REACHES — les rayons dont ClassifyTile et EffectOverBox ont besoin
|
// PORTÉES / REACHES — les rayons dont ClassifyTile et EffectOverBox ont besoin
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// Les constantes de blend ci-dessus (3.0 pour la spine, 4.0 pour les passages) sont dupliquées à la
|
// The constants are named above so a future bounds test cannot let them drift from the hot path.
|
||||||
// main dans ClassifyTile aujourd'hui. Les nommer ici pour qu'un futur test de bornes ne puisse pas
|
|
||||||
// les désynchroniser. / The blend constants above are hand-duplicated inside ClassifyTile today.
|
|
||||||
// Naming them here so a future bounds test cannot let the two drift apart.
|
|
||||||
namespace VoxelDensityReach
|
|
||||||
{
|
|
||||||
constexpr float SpineBlend = 3.0f;
|
|
||||||
constexpr float PassageBlend = 4.0f;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
// Opt-in density-path profiling used by the editor commandlet and targeted PIE diagnostics.
|
||||||
|
// Disabled by default so the shipping density path pays only one predictable branch in the
|
||||||
|
// scoped timers below.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "HAL/PlatformTime.h"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
namespace VoxelDensityProfile
|
||||||
|
{
|
||||||
|
enum class EMode : uint8
|
||||||
|
{
|
||||||
|
Disabled,
|
||||||
|
Sampled,
|
||||||
|
// Low-overhead attribution: keeps the parent and stage ledger trustworthy, samples
|
||||||
|
// hot component scopes, and collects classifier detail without the old full-scope storm.
|
||||||
|
Attribution,
|
||||||
|
Full,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count-based sampling is deterministic with respect to the profiler's own counters, but the
|
||||||
|
// report is intentionally diagnostic only. It never participates in density or mesh output.
|
||||||
|
constexpr uint32 DefaultSampleInterval = 128;
|
||||||
|
|
||||||
|
enum class EBucket : uint8
|
||||||
|
{
|
||||||
|
GetDensityAt,
|
||||||
|
ApplyDisturbances,
|
||||||
|
PassageModifier,
|
||||||
|
PassageLandingAir,
|
||||||
|
PassageLandingFloor,
|
||||||
|
PassageLandingRoomFloor,
|
||||||
|
PassageTunnelAir,
|
||||||
|
PassageStructuralPosts,
|
||||||
|
TunnelCoreSupport,
|
||||||
|
TunnelCoreSdf,
|
||||||
|
TunnelCorePosts,
|
||||||
|
StructuralTail,
|
||||||
|
RoomGraphSource,
|
||||||
|
SdfCarve,
|
||||||
|
CaveRoughnessMod,
|
||||||
|
CaveTerraceMod,
|
||||||
|
LayerLineMod,
|
||||||
|
RibbingMod,
|
||||||
|
CaveOverhangMod,
|
||||||
|
CaveCliffMod,
|
||||||
|
ScallopMod,
|
||||||
|
CaveArchMod,
|
||||||
|
RoomColumnMod,
|
||||||
|
DomeMod,
|
||||||
|
PinchMod,
|
||||||
|
FloorBiasMod,
|
||||||
|
WormFieldSource,
|
||||||
|
OriginSpineOp,
|
||||||
|
BoundarySealOp,
|
||||||
|
PassageCarveOp,
|
||||||
|
XYEdgeSealOp,
|
||||||
|
OtherOp,
|
||||||
|
// Attribution components. These are nested diagnostics; the non-overlapping phase
|
||||||
|
// buckets below remain the density accounting ledger.
|
||||||
|
FusedEvaluator,
|
||||||
|
InterpretedOpStack,
|
||||||
|
OperatorBlock,
|
||||||
|
RoomGraphBuild,
|
||||||
|
RoomGraphSdf,
|
||||||
|
TunnelCoreWorld,
|
||||||
|
FusedDetail,
|
||||||
|
FusedWorm,
|
||||||
|
FusedStructural,
|
||||||
|
ClassifierTotal,
|
||||||
|
ClassifierIntervalProof,
|
||||||
|
ClassifierExactCore,
|
||||||
|
ClassifierExactFinal,
|
||||||
|
ClassifierRoomTail,
|
||||||
|
RuntimeStreamBuilding,
|
||||||
|
// Non-overlapping top-level phases. The named operation buckets above are intentionally
|
||||||
|
// nested diagnostics; these phase buckets are the accounting ledger that must add up to
|
||||||
|
// GetDensityAt without double-counting a stack op inside its enclosing phase.
|
||||||
|
DensityPrologue,
|
||||||
|
DensityCore,
|
||||||
|
DensityDisturbances,
|
||||||
|
DensityStructuralPosts,
|
||||||
|
DensityBoundarySeal,
|
||||||
|
DensityDiffLayer,
|
||||||
|
DensityTail,
|
||||||
|
// Mesher-side accounting. GenerateMesh is the inclusive total; the following buckets
|
||||||
|
// cover its regular-grid work and its non-grid setup/finalisation.
|
||||||
|
MesherGenerateMesh,
|
||||||
|
MesherDensityGrid,
|
||||||
|
MesherCellClassification,
|
||||||
|
MesherGradientNormals,
|
||||||
|
MesherVertexInterpolation,
|
||||||
|
MesherStreamBuilding,
|
||||||
|
MesherOther,
|
||||||
|
Count
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr int32 BucketCount = static_cast<int32>(EBucket::Count);
|
||||||
|
|
||||||
|
enum class ECounter : uint8
|
||||||
|
{
|
||||||
|
TunnelCacheLookup,
|
||||||
|
TunnelCacheHit,
|
||||||
|
TunnelCacheMiss,
|
||||||
|
SdfCacheBuild,
|
||||||
|
RoomGraphBuildGeneratorTile,
|
||||||
|
RoomGraphBuildGeneratorTunnelCore,
|
||||||
|
RoomGraphBuildOpShared,
|
||||||
|
RoomGraphBuildOpLocal,
|
||||||
|
RoomGraphBuildClassifierShared,
|
||||||
|
RoomGraphBuildClassifierLocal,
|
||||||
|
RoomGraphBuildUnknown,
|
||||||
|
CaveRoomCandidates,
|
||||||
|
CaveRoomEvaluated,
|
||||||
|
CaveTunnelCandidates,
|
||||||
|
CaveTunnelEvaluated,
|
||||||
|
TunnelCoreCandidates,
|
||||||
|
TunnelCoreEvaluated,
|
||||||
|
TunnelSupportColumnBuilds,
|
||||||
|
TunnelSupportColumnCandidates,
|
||||||
|
TunnelSupportFloorQueries,
|
||||||
|
TunnelSupportFloorChecks,
|
||||||
|
TunnelSupportFloorBackstopFires,
|
||||||
|
TunnelRoomFloorBackstopFires,
|
||||||
|
TunnelAuthoredFloorSamples,
|
||||||
|
TunnelAuthoredFloorProtectionClamps,
|
||||||
|
TunnelAuthoredFloorDisturbanceSkips,
|
||||||
|
TunnelFloorProfileFallbacks,
|
||||||
|
TunnelFloorProfileBuilds,
|
||||||
|
TunnelFloorProfileSegments,
|
||||||
|
TunnelFloorProfileLedges,
|
||||||
|
TunnelFloorProfileLedgesBelow2Voxels,
|
||||||
|
TunnelFloorProfileLedges2To4Voxels,
|
||||||
|
TunnelFloorProfileLedges4To8Voxels,
|
||||||
|
TunnelFloorProfileLedges8PlusVoxels,
|
||||||
|
// One count per authored graph edge. MultiStepEdges is an invariant guard: it must stay
|
||||||
|
// zero now that old per-segment staircase quantisation is removed.
|
||||||
|
TunnelFloorSteepEdges,
|
||||||
|
TunnelFloorWindingEdges,
|
||||||
|
TunnelFloorDropEdges,
|
||||||
|
TunnelFloorMultiStepEdges,
|
||||||
|
PassageSupportFloorBackstopFires,
|
||||||
|
PassageNativeFloorCompositions,
|
||||||
|
PassageCandidates,
|
||||||
|
PassageEvaluated,
|
||||||
|
MesherBlockTests,
|
||||||
|
MesherBlockAllSolid,
|
||||||
|
MesherBlockAllAir,
|
||||||
|
TileClassifyCalls,
|
||||||
|
TileClassifyAllSolid,
|
||||||
|
TileClassifyAllAir,
|
||||||
|
TileClassifyMixed,
|
||||||
|
FusedTunnelSamples,
|
||||||
|
FusedTunnelDetailSamples,
|
||||||
|
FusedTunnelDetailSkipped,
|
||||||
|
WormEligibleSamples,
|
||||||
|
WormBlockProofs,
|
||||||
|
WormBlockSkippedSamples,
|
||||||
|
OpBlockBuilds,
|
||||||
|
OpBlockSamples,
|
||||||
|
OpBlockOperators,
|
||||||
|
OpBlockActiveOperators,
|
||||||
|
OpBlockPrunedOperators,
|
||||||
|
MesherDensityGridBytes,
|
||||||
|
MesherSharedGridReadBytes,
|
||||||
|
MesherOutputArrayBytes,
|
||||||
|
MesherOutputAllocatedBytes,
|
||||||
|
OpBlockScratchBytes,
|
||||||
|
OpBlockCopyBytes,
|
||||||
|
Count
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr int32 CounterCount = static_cast<int32>(ECounter::Count);
|
||||||
|
|
||||||
|
// Export the one fast-path bit so callers in the editor module can skip instrumentation
|
||||||
|
// without crossing the DLL boundary for every scoped timer or counter. It is diagnostic
|
||||||
|
// state only; it never participates in density or mesh decisions.
|
||||||
|
VOXELFORGE_API extern std::atomic<bool> GEnabled;
|
||||||
|
VOXELFORGE_API extern std::atomic<EMode> GMode;
|
||||||
|
inline thread_local bool GSampledScopeActive = false;
|
||||||
|
|
||||||
|
// Resident allocator-backed bytes for one worker's generation cache family. The profiler
|
||||||
|
// aggregates this by worker; the fields intentionally describe allocations owned by the
|
||||||
|
// cache, not the inline size of the enclosing C++ object. SlotStorageBytes is the one
|
||||||
|
// exception: it is the inline/static storage reserved for the worker's fixed cache table.
|
||||||
|
struct FCacheMemoryBreakdown
|
||||||
|
{
|
||||||
|
uint64 SlotStorageBytes = 0;
|
||||||
|
uint64 OpStackBytes = 0;
|
||||||
|
uint64 RoomsBytes = 0;
|
||||||
|
uint64 RoomFloorJoinsBytes = 0;
|
||||||
|
uint64 TunnelsBytes = 0;
|
||||||
|
uint64 PitsBytes = 0;
|
||||||
|
uint64 ChimneysBytes = 0;
|
||||||
|
uint64 ColumnsBytes = 0;
|
||||||
|
uint64 SupportColumnEntriesBytes = 0;
|
||||||
|
uint64 SupportColumnsBytes = 0;
|
||||||
|
uint64 SupportColumnIntervalsBytes = 0;
|
||||||
|
uint64 SpatialIndexBytes = 0;
|
||||||
|
uint64 TotalBytes() const
|
||||||
|
{
|
||||||
|
return SlotStorageBytes + OpStackBytes
|
||||||
|
+ RoomsBytes + RoomFloorJoinsBytes + TunnelsBytes + PitsBytes
|
||||||
|
+ ChimneysBytes + ColumnsBytes + SupportColumnEntriesBytes
|
||||||
|
+ SupportColumnsBytes + SupportColumnIntervalsBytes + SpatialIndexBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64 DynamicBytes() const
|
||||||
|
{
|
||||||
|
return TotalBytes() - SlotStorageBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
FCacheMemoryBreakdown& operator+=(const FCacheMemoryBreakdown& Other)
|
||||||
|
{
|
||||||
|
SlotStorageBytes += Other.SlotStorageBytes;
|
||||||
|
OpStackBytes += Other.OpStackBytes;
|
||||||
|
RoomsBytes += Other.RoomsBytes;
|
||||||
|
RoomFloorJoinsBytes += Other.RoomFloorJoinsBytes;
|
||||||
|
TunnelsBytes += Other.TunnelsBytes;
|
||||||
|
PitsBytes += Other.PitsBytes;
|
||||||
|
ChimneysBytes += Other.ChimneysBytes;
|
||||||
|
ColumnsBytes += Other.ColumnsBytes;
|
||||||
|
SupportColumnEntriesBytes += Other.SupportColumnEntriesBytes;
|
||||||
|
SupportColumnsBytes += Other.SupportColumnsBytes;
|
||||||
|
SupportColumnIntervalsBytes += Other.SupportColumnIntervalsBytes;
|
||||||
|
SpatialIndexBytes += Other.SpatialIndexBytes;
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FSnapshot
|
||||||
|
{
|
||||||
|
uint64 Calls[BucketCount]{};
|
||||||
|
// Sum of sampled worker time. Useful for CPU-work accounting, but not directly comparable
|
||||||
|
// with an export wall clock when the mesher runs in parallel.
|
||||||
|
uint64 Cycles[BucketCount]{};
|
||||||
|
uint64 Samples[BucketCount]{};
|
||||||
|
// Largest estimated worker contribution. This is the report-facing parallel wall
|
||||||
|
// estimate; it avoids mistaking several parallel workers for several exports.
|
||||||
|
uint64 WallCycles[BucketCount]{};
|
||||||
|
uint32 ActiveWorkers[BucketCount]{};
|
||||||
|
uint64 Counters[CounterCount]{};
|
||||||
|
|
||||||
|
// Classifier detail is sampled separately from the timer buckets. Calls is the exact
|
||||||
|
// number of classifier invocations; the remaining fields are worker-local samples
|
||||||
|
// expanded at Snapshot() using Calls / SampledCalls.
|
||||||
|
struct FClassifierStats
|
||||||
|
{
|
||||||
|
uint64 Calls = 0;
|
||||||
|
uint64 SampledCalls = 0;
|
||||||
|
uint64 RefineNodes = 0;
|
||||||
|
uint64 StackBoxCalls = 0;
|
||||||
|
uint64 WholeMixedNodes = 0;
|
||||||
|
uint64 WholeSolidNodes = 0;
|
||||||
|
uint64 WholeAirNodes = 0;
|
||||||
|
uint64 NeedsFinalFieldNodes = 0;
|
||||||
|
uint64 SplitNodes = 0;
|
||||||
|
uint64 MaxRefinementDepth = 0;
|
||||||
|
uint64 ExactCoreSamples = 0;
|
||||||
|
uint64 ExactFinalSamples = 0;
|
||||||
|
uint64 ExactCoreCacheHits = 0;
|
||||||
|
uint64 ExactFinalCacheHits = 0;
|
||||||
|
uint64 ExactCoreLeaves = 0;
|
||||||
|
uint64 ExactFinalLeaves = 0;
|
||||||
|
uint64 StackBoxCycles = 0;
|
||||||
|
uint64 ExactCoreCycles = 0;
|
||||||
|
uint64 ExactFinalCycles = 0;
|
||||||
|
uint64 RoomTailQueries = 0;
|
||||||
|
uint64 RoomTailEvaluated = 0;
|
||||||
|
uint64 RoomTailCycles = 0;
|
||||||
|
uint64 RoomPropagateCycles = 0;
|
||||||
|
uint64 RoomExactPrimitiveCycles = 0;
|
||||||
|
uint64 RoomCacheWindowCycles = 0;
|
||||||
|
uint64 RoomNumRooms = 0;
|
||||||
|
uint64 RoomNumTunnels = 0;
|
||||||
|
uint64 RoomNumRoomFloorJoins = 0;
|
||||||
|
uint64 RoomNumPits = 0;
|
||||||
|
uint64 RoomNumChimneys = 0;
|
||||||
|
};
|
||||||
|
FClassifierStats Classifier;
|
||||||
|
|
||||||
|
uint64 TimerPairCycles = 0;
|
||||||
|
uint32 SampleInterval = DefaultSampleInterval;
|
||||||
|
EMode Mode = EMode::Disabled;
|
||||||
|
|
||||||
|
// Opt-in worker-cache footprint snapshot. These are current per-thread values (not
|
||||||
|
// allocation-event counters), so Snapshot() reports the resident cache footprint at the
|
||||||
|
// end of a profiling run rather than multiplying bytes by cache churn.
|
||||||
|
uint64 TunnelCacheWorkers = 0;
|
||||||
|
uint64 TunnelCacheCapacityEntries = 0;
|
||||||
|
uint64 TunnelCacheValidEntries = 0;
|
||||||
|
uint64 TunnelCacheStaticBytes = 0;
|
||||||
|
uint64 TunnelCacheDynamicBytes = 0;
|
||||||
|
uint64 TunnelCacheEntryBytes = 0;
|
||||||
|
uint64 TunnelCacheLargestEntryBytes = 0;
|
||||||
|
uint64 TunnelCacheLargestWorkerValidEntries = 0;
|
||||||
|
uint64 TunnelCacheLargestWorkerBytes = 0;
|
||||||
|
FCacheMemoryBreakdown TunnelCacheBreakdown;
|
||||||
|
|
||||||
|
uint64 RoomGraphCacheWorkers = 0;
|
||||||
|
uint64 RoomGraphCacheCapacityEntries = 0;
|
||||||
|
uint64 RoomGraphCacheValidEntries = 0;
|
||||||
|
uint64 RoomGraphCacheStaticBytes = 0;
|
||||||
|
uint64 RoomGraphCacheDynamicBytes = 0;
|
||||||
|
uint64 RoomGraphCacheEntryBytes = 0;
|
||||||
|
uint64 RoomGraphCacheLargestEntryBytes = 0;
|
||||||
|
uint64 RoomGraphCacheLargestWorkerValidEntries = 0;
|
||||||
|
uint64 RoomGraphCacheLargestWorkerBytes = 0;
|
||||||
|
FCacheMemoryBreakdown RoomGraphCacheBreakdown;
|
||||||
|
};
|
||||||
|
|
||||||
|
VOXELFORGE_API void SetEnabled(bool bEnabled);
|
||||||
|
VOXELFORGE_API void SetMode(EMode Mode, uint32 SampleInterval = DefaultSampleInterval);
|
||||||
|
// Counters are collected only while a diagnostic mode is enabled. In the ordinary clean
|
||||||
|
// path this is a single relaxed load, so the measurement hooks stay dormant.
|
||||||
|
FORCEINLINE bool IsEnabledFast()
|
||||||
|
{
|
||||||
|
return GEnabled.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
FORCEINLINE bool AreCountersEnabled() { return IsEnabledFast(); }
|
||||||
|
FORCEINLINE bool ShouldProbeScopeFast(EBucket Bucket)
|
||||||
|
{
|
||||||
|
const EMode Mode = GMode.load(std::memory_order_relaxed);
|
||||||
|
if (Mode == EMode::Full)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (Mode == EMode::Sampled)
|
||||||
|
{
|
||||||
|
return Bucket == EBucket::GetDensityAt
|
||||||
|
|| Bucket == EBucket::MesherGenerateMesh
|
||||||
|
|| Bucket == EBucket::MesherDensityGrid
|
||||||
|
|| Bucket == EBucket::MesherOther;
|
||||||
|
}
|
||||||
|
if (Mode == EMode::Attribution)
|
||||||
|
{
|
||||||
|
switch (Bucket)
|
||||||
|
{
|
||||||
|
case EBucket::GetDensityAt:
|
||||||
|
case EBucket::MesherGenerateMesh:
|
||||||
|
case EBucket::MesherDensityGrid:
|
||||||
|
case EBucket::MesherCellClassification:
|
||||||
|
case EBucket::MesherGradientNormals:
|
||||||
|
case EBucket::MesherVertexInterpolation:
|
||||||
|
case EBucket::MesherStreamBuilding:
|
||||||
|
case EBucket::MesherOther:
|
||||||
|
case EBucket::RoomGraphBuild:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return GSampledScopeActive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
VOXELFORGE_API bool IsEnabled();
|
||||||
|
VOXELFORGE_API bool IsCycleTimingEnabled();
|
||||||
|
VOXELFORGE_API EMode GetMode();
|
||||||
|
VOXELFORGE_API uint32 GetSampleInterval();
|
||||||
|
VOXELFORGE_API uint64 GetTimerPairCycles();
|
||||||
|
VOXELFORGE_API void CalibrateTimer();
|
||||||
|
VOXELFORGE_API void Reset();
|
||||||
|
VOXELFORGE_API FSnapshot Snapshot();
|
||||||
|
// Worker-local snapshot for a tile profile. GenerateTileResult is single-owner on its worker,
|
||||||
|
// so this avoids attributing another worker's concurrent tile to the current tile diagnostic.
|
||||||
|
VOXELFORGE_API FSnapshot SnapshotCurrentThread();
|
||||||
|
VOXELFORGE_API void AddCounter(ECounter Counter, uint64 Amount = 1);
|
||||||
|
VOXELFORGE_API void AddMeasurement(EBucket Bucket, uint64 Cycles, uint64 Calls = 1);
|
||||||
|
VOXELFORGE_API void RecordCall(EBucket Bucket);
|
||||||
|
VOXELFORGE_API void RecordClassifierCall(bool bSampled);
|
||||||
|
VOXELFORGE_API void AddClassifierStats(const FSnapshot::FClassifierStats& Stats);
|
||||||
|
VOXELFORGE_API bool ShouldSample(EBucket Bucket);
|
||||||
|
VOXELFORGE_API void AddSampledMeasurement(EBucket Bucket, uint64 RawCycles);
|
||||||
|
|
||||||
|
VOXELFORGE_API void SetWorkerTunnelCacheFootprint(
|
||||||
|
uint64 CapacityEntries, uint64 ValidEntries,
|
||||||
|
uint64 StaticBytes, uint64 DynamicBytes,
|
||||||
|
uint64 EntryBytes, uint64 LargestEntryBytes,
|
||||||
|
uint64 LargestWorkerValidEntries,
|
||||||
|
const FCacheMemoryBreakdown& Breakdown);
|
||||||
|
VOXELFORGE_API void SetWorkerRoomGraphCacheFootprint(
|
||||||
|
uint64 CapacityEntries, uint64 ValidEntries,
|
||||||
|
uint64 StaticBytes, uint64 DynamicBytes,
|
||||||
|
uint64 EntryBytes, uint64 LargestEntryBytes,
|
||||||
|
uint64 LargestWorkerValidEntries,
|
||||||
|
const FCacheMemoryBreakdown& Breakdown);
|
||||||
|
VOXELFORGE_API const TCHAR* CounterName(ECounter Counter);
|
||||||
|
VOXELFORGE_API EBucket BucketFromName(const TCHAR* Name);
|
||||||
|
VOXELFORGE_API const TCHAR* BucketName(EBucket Bucket);
|
||||||
|
|
||||||
|
struct FScopeToken
|
||||||
|
{
|
||||||
|
bool bEntered;
|
||||||
|
bool bSampled;
|
||||||
|
bool bBlockTiming;
|
||||||
|
bool bPreviousSampled;
|
||||||
|
bool bPreviousSampledScopeActive;
|
||||||
|
uint64 StartCycles;
|
||||||
|
};
|
||||||
|
|
||||||
|
VOXELFORGE_API FScopeToken BeginScope(EBucket Bucket);
|
||||||
|
VOXELFORGE_API void EndScope(EBucket Bucket, const FScopeToken& Token);
|
||||||
|
|
||||||
|
class FScopedTimer final
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit FScopedTimer(EBucket InBucket)
|
||||||
|
{
|
||||||
|
if (IsEnabledFast() && ShouldProbeScopeFast(InBucket))
|
||||||
|
{
|
||||||
|
Bucket = InBucket;
|
||||||
|
Token = BeginScope(InBucket);
|
||||||
|
bActive = Token.bEntered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
explicit FScopedTimer(const TCHAR* InName)
|
||||||
|
{
|
||||||
|
if (IsEnabledFast())
|
||||||
|
{
|
||||||
|
Bucket = BucketFromName(InName);
|
||||||
|
if (ShouldProbeScopeFast(Bucket))
|
||||||
|
{
|
||||||
|
Token = BeginScope(Bucket);
|
||||||
|
bActive = Token.bEntered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE ~FScopedTimer()
|
||||||
|
{
|
||||||
|
End();
|
||||||
|
}
|
||||||
|
|
||||||
|
// End a range before its lexical scope ends. This lets GetDensityAt split its enclosing
|
||||||
|
// total into disjoint prologue/core/post phases without moving the large cache block.
|
||||||
|
FORCEINLINE void End()
|
||||||
|
{
|
||||||
|
if (!bActive)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EndScope(Bucket, Token);
|
||||||
|
Token.bEntered = false;
|
||||||
|
bActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FScopedTimer(const FScopedTimer&) = delete;
|
||||||
|
FScopedTimer& operator=(const FScopedTimer&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
EBucket Bucket;
|
||||||
|
FScopeToken Token;
|
||||||
|
bool bActive = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -70,6 +70,12 @@ public:
|
|||||||
* the player hasn't crossed a level-0 cell boundary and nothing is dirty. */
|
* the player hasn't crossed a level-0 cell boundary and nothing is dirty. */
|
||||||
void Update(const FVector& PlayerWorldPos);
|
void Update(const FVector& PlayerWorldPos);
|
||||||
|
|
||||||
|
/** Stop/join the dedicated fill worker before a generation/layout mutation. */
|
||||||
|
void PauseForGenerationChange();
|
||||||
|
|
||||||
|
/** Allow the dedicated fill worker to restart lazily on the next Update. */
|
||||||
|
void ResumeAfterGenerationChange();
|
||||||
|
|
||||||
/** A carve/fill touched this VOXEL box (inclusive, voxel coords) → refill the overlapping
|
/** A carve/fill touched this VOXEL box (inclusive, voxel coords) → refill the overlapping
|
||||||
* clipmap cells next Update. GetDensityAt already includes the diff layer, so re-sampling
|
* clipmap cells next Update. GetDensityAt already includes the diff layer, so re-sampling
|
||||||
* picks the edit up. Cheap + local. */
|
* picks the edit up. Cheap + local. */
|
||||||
@@ -217,6 +223,7 @@ private:
|
|||||||
FRunnableThread* FillThread = nullptr;
|
FRunnableThread* FillThread = nullptr;
|
||||||
FEvent* FillWakeEvent = nullptr;
|
FEvent* FillWakeEvent = nullptr;
|
||||||
std::atomic<bool> bFillThreadStop{ false };
|
std::atomic<bool> bFillThreadStop{ false };
|
||||||
|
std::atomic<bool> bGenerationPaused{ false };
|
||||||
|
|
||||||
// CAPTURE-DURING-MESHING (level-0 only): tile coord → CHUNK_SIZE³ R8 captured density. Populated by
|
// CAPTURE-DURING-MESHING (level-0 only): tile coord → CHUNK_SIZE³ R8 captured density. Populated by
|
||||||
// IngestTileCapture (free — the mesher already sampled it), consumed by RecenterLevel(0) to fill
|
// IngestTileCapture (free — the mesher already sampled it), consumed by RecenterLevel(0) to fill
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ struct VOXELFORGE_API FVoxelModification
|
|||||||
{
|
{
|
||||||
GENERATED_BODY()
|
GENERATED_BODY()
|
||||||
|
|
||||||
// World-space center of the brush (endpoint A for Capsule).
|
// Actor-local voxel-space center of the brush (endpoint A for Capsule). The AVoxelWorld helper
|
||||||
|
// converts world centimetres to this space before calling the diff layer.
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification")
|
||||||
FVector Center = FVector::ZeroVector;
|
FVector Center = FVector::ZeroVector;
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ struct VOXELFORGE_API FVoxelModification
|
|||||||
meta = (EditCondition = "Shape == EVoxelBrushShape::Box"))
|
meta = (EditCondition = "Shape == EVoxelBrushShape::Box"))
|
||||||
FVector BoxExtent = FVector(5.0f, 5.0f, 5.0f);
|
FVector BoxExtent = FVector(5.0f, 5.0f, 5.0f);
|
||||||
|
|
||||||
// Capsule second endpoint in world voxel coords (Capsule shape only).
|
// Capsule second endpoint in actor-local voxel coords (Capsule shape only).
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification",
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification",
|
||||||
meta = (EditCondition = "Shape == EVoxelBrushShape::Capsule"))
|
meta = (EditCondition = "Shape == EVoxelBrushShape::Capsule"))
|
||||||
FVector CapsuleEnd = FVector::ZeroVector;
|
FVector CapsuleEnd = FVector::ZeroVector;
|
||||||
@@ -93,7 +94,7 @@ struct VOXELFORGE_API FVoxelModification
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", meta = (ClampMin = "0.1"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", meta = (ClampMin = "0.1"))
|
||||||
float Falloff = 2.0f;
|
float Falloff = 2.0f;
|
||||||
|
|
||||||
// Conservative world-space AABB the brush can affect (used to find overlapping chunks).
|
// Conservative voxel-space AABB the brush can affect (used to find overlapping chunks).
|
||||||
void GetWorldBounds(FVector& OutMin, FVector& OutMax) const
|
void GetWorldBounds(FVector& OutMin, FVector& OutMax) const
|
||||||
{
|
{
|
||||||
switch (Shape)
|
switch (Shape)
|
||||||
@@ -138,6 +139,9 @@ class VOXELFORGE_API UVoxelDiffLayer : public UObject
|
|||||||
GENERATED_BODY()
|
GENERATED_BODY()
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
/** Process-unique identity for worker-local modification snapshots. */
|
||||||
|
uint64 GetCacheLifetimeId() const;
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// BUDGET CONFIGURATION
|
// BUDGET CONFIGURATION
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
@@ -162,6 +166,10 @@ public:
|
|||||||
*/
|
*/
|
||||||
bool CanModify(float Radius) const;
|
bool CanModify(float Radius) const;
|
||||||
|
|
||||||
|
/** Shape-aware budget check used by ApplyModification. Boxes charge their affected outer box and
|
||||||
|
* capsules charge their full swept volume, so a long tunnel cannot be billed as a small sphere. */
|
||||||
|
bool CanModify(const FVoxelModification& Mod) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get remaining modification count (how many more operations the player can do).
|
* Get remaining modification count (how many more operations the player can do).
|
||||||
* Returns -1 if unlimited.
|
* Returns -1 if unlimited.
|
||||||
@@ -183,7 +191,7 @@ public:
|
|||||||
/**
|
/**
|
||||||
* Apply a carve or fill operation.
|
* Apply a carve or fill operation.
|
||||||
*
|
*
|
||||||
* Stores the modification in every chunk the brush sphere overlaps.
|
* Stores the modification in every chunk the brush volume overlaps.
|
||||||
* Returns the list of affected chunk coordinates so the world knows
|
* Returns the list of affected chunk coordinates so the world knows
|
||||||
* which chunks need re-meshing.
|
* which chunks need re-meshing.
|
||||||
*
|
*
|
||||||
@@ -245,8 +253,8 @@ public:
|
|||||||
|
|
||||||
/** True si un chunk modifié intersecte [MinChunk, MaxChunk] (inclusif). Conservatif par
|
/** True si un chunk modifié intersecte [MinChunk, MaxChunk] (inclusif). Conservatif par
|
||||||
* construction : ApplyModification enregistre le mod dans TOUS les chunks que son rayon
|
* construction : ApplyModification enregistre le mod dans TOUS les chunks que son rayon
|
||||||
* touche, donc le test par clé de chunk suffit. Une passe de lecture sur les clés (les
|
* touche, donc le test par clé de chunk suffit. La recherche parcourt directement les clés de
|
||||||
* mondes édités ont peu de chunks modifiés) — utilisé par ClassifyTile, PAS par voxel. */
|
* la plage demandée, pas tout l'historique des chunks — utilisé par ClassifyTile, PAS par voxel. */
|
||||||
bool HasAnyModInChunkRange(const FIntVector& MinChunk, const FIntVector& MaxChunk) const;
|
bool HasAnyModInChunkRange(const FIntVector& MinChunk, const FIntVector& MaxChunk) const;
|
||||||
|
|
||||||
/** Evaluate a mod list at a voxel — the lock-free core shared by GetDensityOffset and the
|
/** Evaluate a mod list at a voxel — the lock-free core shared by GetDensityOffset and the
|
||||||
@@ -269,6 +277,10 @@ public:
|
|||||||
int32 GetModifiedChunkCount() const;
|
int32 GetModifiedChunkCount() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Never reused for the lifetime of the process: a recycled UObject address must not make a
|
||||||
|
// worker-local snapshot from another diff layer look current when its chunk/version match.
|
||||||
|
mutable std::atomic<uint64> CacheLifetimeId{0};
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// STORAGE
|
// STORAGE
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
|
#include "UObject/Object.h"
|
||||||
#include "VoxelTypes.h"
|
#include "VoxelTypes.h"
|
||||||
#include "VoxelStrateTypes.h"
|
#include "VoxelStrateTypes.h"
|
||||||
#include "VoxelBiomeTypes.h"
|
#include "VoxelBiomeTypes.h"
|
||||||
|
|
||||||
#include "VoxelGenerator.generated.h"
|
#include "VoxelGenerator.generated.h"
|
||||||
|
|
||||||
// Forward decls (évite les includes transitifs)
|
// Forward decls (évite les includes transitifs)
|
||||||
@@ -19,6 +21,17 @@ class UVoxelSettings;
|
|||||||
class UVoxelStrateManager;
|
class UVoxelStrateManager;
|
||||||
class UVoxelDiffLayer;
|
class UVoxelDiffLayer;
|
||||||
class UVoxelBiomeDefinition;
|
class UVoxelBiomeDefinition;
|
||||||
|
struct FTunnelCoreWorldEvaluation;
|
||||||
|
|
||||||
|
/** Guards which may be disabled only by the sealed-solid proof's soundness tests. */
|
||||||
|
enum class EVoxelSealedSolidProofGuard : uint8
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
DiffLayer = 1u << 0,
|
||||||
|
Passages = 1u << 1,
|
||||||
|
OriginLanding = 1u << 2,
|
||||||
|
DisturbanceChasms = 1u << 3,
|
||||||
|
};
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// LOD-AWARE OCTAVE REDUCTION (T2.b)
|
// LOD-AWARE OCTAVE REDUCTION (T2.b)
|
||||||
@@ -41,10 +54,34 @@ class UVoxelBiomeDefinition;
|
|||||||
// the same thread, and biome/climate must stay LOD-independent.
|
// the same thread, and biome/climate must stay LOD-independent.
|
||||||
namespace VoxelGenLOD
|
namespace VoxelGenLOD
|
||||||
{
|
{
|
||||||
// NOT VOXELFORGE_API: MSVC forbids dll-interface on thread_local (C2492). Both users
|
// The TLS variables themselves stay module-local because MSVC forbids exporting thread_local
|
||||||
// (generator + mesher) are inside this module, so no export is needed anyway.
|
// data (C2492). These tiny accessors let the editor explorer build a shared LOD grid without
|
||||||
|
// linking directly to the storage in the game module.
|
||||||
extern thread_local int32 OctaveBias;
|
extern thread_local int32 OctaveBias;
|
||||||
|
|
||||||
|
// Sampling step of the tile currently being meshed on this worker. GetDensityAt itself has
|
||||||
|
// no Step parameter because callers outside the mesher are point queries; this TLS is only a
|
||||||
|
// read-only hint for caches that may safely widen their spatial window for coarse tiles.
|
||||||
|
extern thread_local int32 SampleStep;
|
||||||
|
|
||||||
|
// Exact lattice footprint of the tile currently being meshed. The cache-window switch
|
||||||
|
// uses this rather than inferring a region from an individual sample coordinate, so every
|
||||||
|
// sample in one tile shares one deterministic window even at tile/chunk boundaries.
|
||||||
|
extern thread_local FIntVector TileOriginVoxels;
|
||||||
|
extern thread_local int32 TileCellsPerAxis;
|
||||||
|
|
||||||
|
VOXELFORGE_API int32 GetThreadOctaveBias();
|
||||||
|
VOXELFORGE_API void SetThreadOctaveBias(int32 Value);
|
||||||
|
VOXELFORGE_API int32 GetThreadSampleStep();
|
||||||
|
VOXELFORGE_API void SetThreadSampleStep(int32 Value);
|
||||||
|
// Called by the module's game-thread startup before any worker can enter GetDensityAt.
|
||||||
|
VOXELFORGE_API void InitializeConsoleSwitches();
|
||||||
|
VOXELFORGE_API bool IsTileCacheWindowEnabled(bool bFusedPath = true);
|
||||||
|
VOXELFORGE_API bool GetThreadTileCacheWindow(
|
||||||
|
FIntVector& OutOriginVoxels, int32& OutStep, int32& OutCellsPerAxis);
|
||||||
|
VOXELFORGE_API void SetThreadTileContext(
|
||||||
|
const FIntVector& OriginVoxels, int32 CellsPerAxis);
|
||||||
|
|
||||||
// Effective octave count for a per-voxel noise call site.
|
// Effective octave count for a per-voxel noise call site.
|
||||||
// At least 1 octave always survives (the coarse base shape).
|
// At least 1 octave always survives (the coarse base shape).
|
||||||
FORCEINLINE int32 Eff(int32 Octaves) { return FMath::Max(1, Octaves - OctaveBias); }
|
FORCEINLINE int32 Eff(int32 Octaves) { return FMath::Max(1, Octaves - OctaveBias); }
|
||||||
@@ -58,7 +95,7 @@ namespace VoxelGenLOD
|
|||||||
* UVoxelGenerator
|
* UVoxelGenerator
|
||||||
*
|
*
|
||||||
* Objet UObject léger — ne stocke pas de données lourdes.
|
* Objet UObject léger — ne stocke pas de données lourdes.
|
||||||
* Tient juste un pointeur sur les settings (pour le seed) et les services
|
* Tient les paramètres globaux copiés depuis les settings (seed, spine, bord XY) et les services
|
||||||
* dont il a besoin (StrateManager pour les params par chunk, DiffLayer pour
|
* dont il a besoin (StrateManager pour les params par chunk, DiffLayer pour
|
||||||
* les carves du joueur).
|
* les carves du joueur).
|
||||||
*/
|
*/
|
||||||
@@ -73,15 +110,22 @@ public:
|
|||||||
//=========================================================================
|
//=========================================================================
|
||||||
// SEED (source unique: Settings->Seed)
|
// SEED (source unique: Settings->Seed)
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// On copie juste le seed au démarrage pour éviter un déréférencement
|
// Les paramètres globaux sont copiés au démarrage pour éviter un déréférencement par voxel.
|
||||||
// par voxel. Tout le reste vient des params de strate.
|
// Les formes créatives viennent ensuite des params de strate.
|
||||||
int32 Seed = 0;
|
int32 Seed = 0;
|
||||||
|
|
||||||
// Radius (voxels) of the guaranteed open vertical landing column carved at world
|
// Authored radius (voxels) of the guaranteed flat-floored landing room at world XY (0,0) in
|
||||||
// XY (0,0) in every strate — the (0,0) descent spine. Copied from VoxelSettings.
|
// every strate — the old “spine” setting now sizes a room and never creates a vertical bore.
|
||||||
// 0 disables the spine carve.
|
// 0 disables the origin landing rooms. Copied from VoxelSettings.
|
||||||
float OriginSpineRadius = 14.0f;
|
float OriginSpineRadius = 14.0f;
|
||||||
|
|
||||||
|
// Radius (voxels) of the bounded world in actor-space XY, measured from (0,0).
|
||||||
|
// 0 keeps the legacy unbounded behavior and is a true no-op for the edge seal.
|
||||||
|
float WorldRadiusVoxels = 8192.0f;
|
||||||
|
|
||||||
|
// Width (voxels) of the smooth solid ramp at the XY world edge.
|
||||||
|
float EdgeSealThickness = 64.0f;
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// SERVICES (injectés par AVoxelWorld::BeginPlay)
|
// SERVICES (injectés par AVoxelWorld::BeginPlay)
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
@@ -96,8 +140,14 @@ public:
|
|||||||
UPROPERTY()
|
UPROPERTY()
|
||||||
const UVoxelDiffLayer* DiffLayer = nullptr;
|
const UVoxelDiffLayer* DiffLayer = nullptr;
|
||||||
|
|
||||||
void SetStrateManager(const UVoxelStrateManager* InManager) { StrateManager = InManager; }
|
void SetStrateManager(const UVoxelStrateManager* InManager)
|
||||||
void SetDiffLayer(const UVoxelDiffLayer* InDiffLayer) { DiffLayer = InDiffLayer; }
|
{
|
||||||
|
StrateManager = InManager;
|
||||||
|
}
|
||||||
|
void SetDiffLayer(const UVoxelDiffLayer* InDiffLayer)
|
||||||
|
{
|
||||||
|
DiffLayer = InDiffLayer;
|
||||||
|
}
|
||||||
|
|
||||||
// Copie le seed depuis les settings. Appelé au démarrage et lors d'un ChangeSeed.
|
// Copie le seed depuis les settings. Appelé au démarrage et lors d'un ChangeSeed.
|
||||||
void InitializeSettings(const UVoxelSettings* Settings);
|
void InitializeSettings(const UVoxelSettings* Settings);
|
||||||
@@ -116,6 +166,40 @@ public:
|
|||||||
*/
|
*/
|
||||||
float GetDensityAt(float WorldX, float WorldY, float WorldZ) const;
|
float GetDensityAt(float WorldX, float WorldY, float WorldZ) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a worker-local lattice hand-off for the following GetDensityAt calls. The mesher uses
|
||||||
|
* this around one tile's density-grid fill. The first sample in each resolved chunk lets the
|
||||||
|
* generator run the operator stack once in EvalBlock; later samples consume the exact cached
|
||||||
|
* per-sample result while the ordinary post-stack tail still runs in canonical order.
|
||||||
|
*
|
||||||
|
* This is intentionally a scoped service rather than a persistent world cache: its lifetime is
|
||||||
|
* one tile task, it is thread-local, and EndDensityBlock must be called on every exit path.
|
||||||
|
*/
|
||||||
|
void BeginDensityBlock(FIntVector OriginVoxels, int32 Step,
|
||||||
|
int32 SizeX, int32 SizeY, int32 SizeZ) const;
|
||||||
|
void EndDensityBlock() const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prove that the mesher's core lattice is entirely solid from the cheap, structural rules
|
||||||
|
* that own inter-strate gaps and boundary seals. This never samples density. It is intended
|
||||||
|
* for an empty-tile fast path; a false answer is harmless, while a true answer must make every
|
||||||
|
* core lattice vertex negative in the final MC field.
|
||||||
|
*
|
||||||
|
* The proof is deliberately narrower than ClassifyTile: it accepts only a tile made entirely
|
||||||
|
* of gap/seal lattice rows and rejects recipes/composer overrides plus every known air-writing
|
||||||
|
* structural feature. The mesher's +/-1 normal halo is not part of the cell lattice, matching
|
||||||
|
* ClassifyTile and GenerateMesh.
|
||||||
|
*/
|
||||||
|
bool TryProveSealedSolidTile(const FIntVector& OriginVoxels, int32 Step,
|
||||||
|
int32 CellsPerAxis) const;
|
||||||
|
|
||||||
|
#if WITH_DEV_AUTOMATION_TESTS
|
||||||
|
/** Test hook: disable named proof guards and require the brute-force oracle to catch it. */
|
||||||
|
bool TryProveSealedSolidTileForTest(
|
||||||
|
const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis,
|
||||||
|
uint8 DisabledGuards) const;
|
||||||
|
#endif
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Densité pour une strate TunnelNetwork (rooms + tunnels + worm noise).
|
* Densité pour une strate TunnelNetwork (rooms + tunnels + worm noise).
|
||||||
* Utilisée en interne par GetDensityAt quand la strate est de ce type.
|
* Utilisée en interne par GetDensityAt quand la strate est de ce type.
|
||||||
@@ -148,7 +232,22 @@ public:
|
|||||||
*/
|
*/
|
||||||
float GetDensityWithParams(float WorldX, float WorldY, float WorldZ,
|
float GetDensityWithParams(float WorldX, float WorldY, float WorldZ,
|
||||||
const FStrateGenerationParams& Params,
|
const FStrateGenerationParams& Params,
|
||||||
uint32 ParamsFingerprint, uint32 LayoutVersion) const;
|
uint32 ParamsFingerprint, uint32 LayoutVersion,
|
||||||
|
bool bApplyLegacyStructuralPosts = true,
|
||||||
|
// Optional hand-off used by a lowered canonical evaluator. When
|
||||||
|
// non-null, this receives the pre-disturbance TunnelNetwork core;
|
||||||
|
// it does not alter the scalar field result.
|
||||||
|
FTunnelCoreWorldEvaluation* OutTunnelCore = nullptr,
|
||||||
|
// Diagnostics are opt-in so ordinary/reference calls do not pay
|
||||||
|
// for fused-path counters.
|
||||||
|
bool bCollectFusedDiagnostics = false,
|
||||||
|
// Optional same-sample hand-off from EvaluateSDFCached. This is
|
||||||
|
// only a room-shape ownership bit; it does not alter the scalar
|
||||||
|
// result and avoids a duplicate room-index query in native paths.
|
||||||
|
bool* OutRoomOwnsBottom = nullptr,
|
||||||
|
// Optional same-sample warped position. Native GetDensityAt uses
|
||||||
|
// this to feed the core post without applying the cave warp twice.
|
||||||
|
FVector* OutWarpedPosition = nullptr) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Densité pour une strate Slab (FlatPlain / CrystalChamber).
|
* Densité pour une strate Slab (FlatPlain / CrystalChamber).
|
||||||
@@ -158,8 +257,9 @@ public:
|
|||||||
const FSlabGenerationParams& Params) const;
|
const FSlabGenerationParams& Params) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Densité pour une strate Maze — couloirs étroits sur un treillis 3D déterministe.
|
* Densité pour une strate Maze — couloirs sur un treillis 3D déterministe, avec les décisions
|
||||||
* Pas de cache: évalué par voxel sur les quelques cellules voisines.
|
* parent/boucle reconstruites par cellule dans un cache thread-local; le hot path ne parcourt
|
||||||
|
* que les capsules déjà émises.
|
||||||
*/
|
*/
|
||||||
float GetMazeDensity(float WorldX, float WorldY, float WorldZ,
|
float GetMazeDensity(float WorldX, float WorldY, float WorldZ,
|
||||||
const FMazeGenerationParams& Params) const;
|
const FMazeGenerationParams& Params) const;
|
||||||
@@ -313,20 +413,37 @@ public:
|
|||||||
* (OriginVoxels, Step, CellsPerAxis) = les MÊMES arguments que GenerateMesh ; le verdict
|
* (OriginVoxels, Step, CellsPerAxis) = les MÊMES arguments que GenerateMesh ; le verdict
|
||||||
* porte sur le treillis exact que le mesher échantillonnerait (marge ±1 incluse).
|
* porte sur le treillis exact que le mesher échantillonnerait (marge ±1 incluse).
|
||||||
*
|
*
|
||||||
* v1 : ne prouve que les chunks GAP (bedrock) et les strates SurfaceWorld — colonnes
|
* GAP (bedrock), hors-layout (air constant) et SurfaceWorld plient leurs hypothèses par Z ;
|
||||||
* terrain/plafond évaluées par le MÊME ComputeSurfaceColumn que le chemin densité (donc
|
* SurfaceWorld évalue ses colonnes terrain/plafond avec le MÊME ComputeSurfaceColumn que le
|
||||||
* bit-identiques), bandes de seal solides, gardes spine/passages/disturbances/diff.
|
* chemin densité. Un unique slot cave opt-in peut ajouter le verdict conservatif de sa pile,
|
||||||
* Tout autre archétype (intérieur de caves) ⇒ Mixed. Worker-safe (lecture seule +
|
* sous gardes d'archétype et de params bit-identiques. Spine/passages/disturbances/diff restent
|
||||||
* caches thread_local partagés avec GetDensityAt — un verdict Mixed laisse les colonnes
|
* des gardes conservatrices. Worker-safe (lecture seule + caches thread_local partagés avec
|
||||||
* chaudes pour la génération qui suit).
|
* GetDensityAt — un verdict Mixed laisse les colonnes chaudes pour la génération qui suit).
|
||||||
*/
|
*/
|
||||||
EVoxelTileClass ClassifyTile(const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const;
|
EVoxelTileClass ClassifyTile(const FIntVector& OriginVoxels, int32 Step,
|
||||||
|
int32 CellsPerAxis) const;
|
||||||
|
EVoxelTileClass ClassifyTile(const FIntVector& OriginVoxels, int32 Step,
|
||||||
|
int32 CellsPerAxis,
|
||||||
|
struct FVoxelTileClassificationStats* OutStats) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prove the exact constant-air field above/below the strate layout for a mesher lattice.
|
||||||
|
* This is separate from TryProveSealedSolidTile: it never supplies collision support and its
|
||||||
|
* caller records AllAir, not AllSolid. It shares the layout/diff/structural guards with the
|
||||||
|
* production classifier and returns false for every mixed or uncertain box.
|
||||||
|
*/
|
||||||
|
bool TryProveOutOfLayoutAirTile(const FIntVector& OriginVoxels, int32 Step,
|
||||||
|
int32 CellsPerAxis) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/** Identité process-unique du propriétaire des caches `CP_*` thread_local.
|
/** Identité process-unique du propriétaire des caches `CP_*` thread_local.
|
||||||
* Process-unique owner identity for the `CP_*` thread-local cache key. */
|
* Process-unique owner identity for the `CP_*` thread-local cache key. */
|
||||||
uint64 DensityCacheOwnerId = 0;
|
uint64 DensityCacheOwnerId = 0;
|
||||||
|
|
||||||
|
EVoxelTileClass ClassifyTileUncached(
|
||||||
|
const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis,
|
||||||
|
struct FVoxelTileClassificationStats* OutStats) const;
|
||||||
|
|
||||||
/** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */
|
/** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */
|
||||||
int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const;
|
int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const;
|
||||||
|
|
||||||
@@ -358,5 +475,53 @@ private:
|
|||||||
|
|
||||||
/** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */
|
/** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */
|
||||||
void RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ,
|
void RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ,
|
||||||
const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const;
|
const FBiomeContext& Ctx, FChunkBiomeCache& Cache,
|
||||||
|
uint64 OwnerId, uint64 ManagerLifetimeId) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace VoxelGenLOD
|
||||||
|
{
|
||||||
|
// -1 uses the measured path/LOD policy; 0/1 force the spatial broad phase off/on for an A/B
|
||||||
|
// run. The policy affects candidate enumeration only, never cache contents or ordering.
|
||||||
|
VOXELFORGE_API bool ShouldUseSpatialIndex(bool bFusedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional diagnostics for one ClassifyTile call. This is populated only when the caller asks
|
||||||
|
* for it (the streaming profiler does); it is never consulted by the proof and cannot affect a
|
||||||
|
* verdict. Keeping the counters beside the classifier lets the game profile distinguish cache,
|
||||||
|
* interval refinement, and exact-lattice work without sampling a different code path.
|
||||||
|
*/
|
||||||
|
struct FVoxelTileClassificationStats
|
||||||
|
{
|
||||||
|
uint32 RefineNodes = 0;
|
||||||
|
uint32 StackBoxCalls = 0;
|
||||||
|
uint32 WholeMixedNodes = 0;
|
||||||
|
uint32 WholeSolidNodes = 0;
|
||||||
|
uint32 WholeAirNodes = 0;
|
||||||
|
uint32 NeedsFinalFieldNodes = 0;
|
||||||
|
uint32 SplitNodes = 0;
|
||||||
|
uint32 MaxRefinementDepth = 0;
|
||||||
|
uint32 ExactCoreSamples = 0;
|
||||||
|
uint32 ExactFinalSamples = 0;
|
||||||
|
uint32 ExactCoreIntervalChecks = 0;
|
||||||
|
uint32 ExactCoreIntervalDisagreements = 0;
|
||||||
|
uint32 ExactCoreCacheHits = 0;
|
||||||
|
uint32 ExactFinalCacheHits = 0;
|
||||||
|
uint32 ExactCoreLeaves = 0;
|
||||||
|
uint32 ExactFinalLeaves = 0;
|
||||||
|
uint64 StackBoxCycles = 0;
|
||||||
|
uint64 ExactCoreCycles = 0;
|
||||||
|
uint64 ExactFinalCycles = 0;
|
||||||
|
uint32 RoomTailQueries = 0;
|
||||||
|
uint32 RoomTailEvaluated = 0;
|
||||||
|
uint64 RoomTailCycles = 0;
|
||||||
|
uint64 RoomPropagateCycles = 0;
|
||||||
|
uint64 RoomExactPrimitiveCycles = 0;
|
||||||
|
uint64 RoomCacheWindowCycles = 0;
|
||||||
|
int32 RoomNumRooms = 0;
|
||||||
|
int32 RoomNumTunnels = 0;
|
||||||
|
int32 RoomNumRoomFloorJoins = 0;
|
||||||
|
int32 RoomNumPits = 0;
|
||||||
|
int32 RoomNumChimneys = 0;
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// VoxelGeometryHash.h
|
||||||
|
// The compact geometry identity shared by offline export and runtime streaming diagnostics.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "Misc/Crc.h"
|
||||||
|
#include "VoxelTypes.h"
|
||||||
|
|
||||||
|
namespace VoxelForgeGeometryHash
|
||||||
|
{
|
||||||
|
// Keep this deliberately identical to the historical explorer hash. It is a cheap,
|
||||||
|
// deterministic identity for a tile's complete mesh payload; changing it would invalidate
|
||||||
|
// the commandlet's existing canonical evidence.
|
||||||
|
inline FString Compute(const FVoxelMeshData& MeshData)
|
||||||
|
{
|
||||||
|
uint32 Crc = 0;
|
||||||
|
const int32 Counts[] = {
|
||||||
|
MeshData.Vertices.Num(),
|
||||||
|
MeshData.Normals.Num(),
|
||||||
|
MeshData.UVs.Num(),
|
||||||
|
MeshData.Colors.Num(),
|
||||||
|
MeshData.Triangles.Num(),
|
||||||
|
MeshData.NumCeilingTriangles,
|
||||||
|
};
|
||||||
|
Crc = FCrc::MemCrc32(Counts, sizeof(Counts), Crc);
|
||||||
|
if (MeshData.Vertices.Num() > 0)
|
||||||
|
{
|
||||||
|
Crc = FCrc::MemCrc32(
|
||||||
|
MeshData.Vertices.GetData(),
|
||||||
|
MeshData.Vertices.Num() * sizeof(FVector), Crc);
|
||||||
|
}
|
||||||
|
if (MeshData.Normals.Num() > 0)
|
||||||
|
{
|
||||||
|
Crc = FCrc::MemCrc32(
|
||||||
|
MeshData.Normals.GetData(),
|
||||||
|
MeshData.Normals.Num() * sizeof(FVector), Crc);
|
||||||
|
}
|
||||||
|
if (MeshData.UVs.Num() > 0)
|
||||||
|
{
|
||||||
|
Crc = FCrc::MemCrc32(
|
||||||
|
MeshData.UVs.GetData(),
|
||||||
|
MeshData.UVs.Num() * sizeof(FVector2D), Crc);
|
||||||
|
}
|
||||||
|
if (MeshData.Colors.Num() > 0)
|
||||||
|
{
|
||||||
|
Crc = FCrc::MemCrc32(
|
||||||
|
MeshData.Colors.GetData(),
|
||||||
|
MeshData.Colors.Num() * sizeof(FColor), Crc);
|
||||||
|
}
|
||||||
|
if (MeshData.Triangles.Num() > 0)
|
||||||
|
{
|
||||||
|
Crc = FCrc::MemCrc32(
|
||||||
|
MeshData.Triangles.GetData(),
|
||||||
|
MeshData.Triangles.Num() * sizeof(int32), Crc);
|
||||||
|
}
|
||||||
|
return FString::Printf(TEXT("%08X"), Crc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,16 +12,42 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
|
#include <atomic>
|
||||||
#include "VoxelTypes.h" // Pour FVoxelMeshData, CHUNK_SIZE, VOXEL_SIZE, etc.
|
#include "VoxelTypes.h" // Pour FVoxelMeshData, CHUNK_SIZE, VOXEL_SIZE, etc.
|
||||||
#include "VoxelGenerator.h"
|
#include "VoxelGenerator.h"
|
||||||
#include "VoxelMarchingCubesMesher.generated.h"
|
#include "VoxelMarchingCubesMesher.generated.h"
|
||||||
|
|
||||||
|
// A run-scoped, read-only density lattice. The explorer owns the storage and installs the
|
||||||
|
// pointer only while its canonical tiles are being meshed. The lattice includes the one-point
|
||||||
|
// halo needed by every tile, so each world sample is generated once and copied into both adjacent
|
||||||
|
// tile scratch grids. It is deliberately not a UObject or a cache with eviction: ownership,
|
||||||
|
// lifetime, and iteration order remain explicit and deterministic.
|
||||||
|
struct FVoxelSharedDensityGrid
|
||||||
|
{
|
||||||
|
FIntVector OriginVoxels = FIntVector::ZeroValue;
|
||||||
|
int32 Step = 1;
|
||||||
|
int32 Dim = 0;
|
||||||
|
TArray<float> Samples;
|
||||||
|
|
||||||
|
void Reset()
|
||||||
|
{
|
||||||
|
OriginVoxels = FIntVector::ZeroValue;
|
||||||
|
Step = 1;
|
||||||
|
Dim = 0;
|
||||||
|
Samples.Reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
UCLASS(BlueprintType)
|
UCLASS(BlueprintType)
|
||||||
class VOXELFORGE_API UVoxelMarchingCubesMesher : public UObject
|
class VOXELFORGE_API UVoxelMarchingCubesMesher : public UObject
|
||||||
{
|
{
|
||||||
GENERATED_BODY()
|
GENERATED_BODY()
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
// Parse command-line switches on the module's game-thread startup. GenerateMesh is a worker
|
||||||
|
// entry point and therefore only reads the resulting values.
|
||||||
|
static void InitializeConsoleSwitches();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Génère le mesh d'une TUILE de clipmap (voir FVoxelTileKey).
|
* Génère le mesh d'une TUILE de clipmap (voir FVoxelTileKey).
|
||||||
*
|
*
|
||||||
@@ -38,7 +64,9 @@ public:
|
|||||||
* les a déjà calculées). Vidé puis rempli ; reste vide si non éligible.
|
* les a déjà calculées). Vidé puis rempli ; reste vide si non éligible.
|
||||||
* @param BandZMinVox / BandZMaxVox - COUPE DE CONTENU PAR STRATE (optionnel, voxels Z INCLUSIFS,
|
* @param BandZMinVox / BandZMaxVox - COUPE DE CONTENU PAR STRATE (optionnel, voxels Z INCLUSIFS,
|
||||||
* cf. UVoxelSettings::StrateContentCutMinLevel) : seules les cellules dont
|
* cf. UVoxelSettings::StrateContentCutMinLevel) : seules les cellules dont
|
||||||
* l'intervalle Z chevauche la bande sont maillées. Une tuile grossière qui
|
* l'intervalle Z chevauche la bande sont maillées. Le runtime ne passe
|
||||||
|
* jamais de bande aux tuiles LOD0, car elles possèdent la collision du pawn.
|
||||||
|
* Une tuile grossière qui
|
||||||
* chevauche une frontière de strate ne maille que la strate du joueur —
|
* chevauche une frontière de strate ne maille que la strate du joueur —
|
||||||
* supprime les trous d'aliasing (le bouchon seal/gap plus fin que Step
|
* supprime les trous d'aliasing (le bouchon seal/gap plus fin que Step
|
||||||
* tombait entre deux points du treillis) et le mélange de matériaux entre
|
* tombait entre deux points du treillis) et le mélange de matériaux entre
|
||||||
@@ -47,7 +75,8 @@ public:
|
|||||||
*/
|
*/
|
||||||
FVoxelMeshData GenerateMesh(FIntVector OriginVoxels, int32 Step = 1, int32 CellsPerAxis = CHUNK_SIZE,
|
FVoxelMeshData GenerateMesh(FIntVector OriginVoxels, int32 Step = 1, int32 CellsPerAxis = CHUNK_SIZE,
|
||||||
TArray<uint8>* OutCaptureGrid = nullptr,
|
TArray<uint8>* OutCaptureGrid = nullptr,
|
||||||
int32 BandZMinVox = INT32_MIN, int32 BandZMaxVox = INT32_MAX);
|
int32 BandZMinVox = INT32_MIN, int32 BandZMaxVox = INT32_MAX,
|
||||||
|
int64* OutDensitySampleCount = nullptr);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* F18 — FEUILLE de champ lointain (anneau render-distance, cf. UVoxelSettings::bFarSheetRing).
|
* F18 — FEUILLE de champ lointain (anneau render-distance, cf. UVoxelSettings::bFarSheetRing).
|
||||||
@@ -88,6 +117,26 @@ public:
|
|||||||
|
|
||||||
void SetGenerator(const UVoxelGenerator* InGenerator) { Generator = InGenerator; }
|
void SetGenerator(const UVoxelGenerator* InGenerator) { Generator = InGenerator; }
|
||||||
|
|
||||||
|
// AVoxelWorld installs its bShuttingDown flag here. The commandlet path leaves it null.
|
||||||
|
// This is intentionally read-only: workers may observe shutdown, but never mutate world state.
|
||||||
|
void SetShutdownFlag(const std::atomic<bool>* InShutdownFlag) { ShutdownFlag = InShutdownFlag; }
|
||||||
|
|
||||||
|
// Optional run-scoped shared lattice. The caller must keep it alive and immutable until all
|
||||||
|
// GenerateMesh calls finish. A malformed/incomplete lattice is ignored and falls back to
|
||||||
|
// the canonical generator path rather than changing geometry.
|
||||||
|
void SetSharedDensityGrid(const FVoxelSharedDensityGrid* InGrid) { SharedDensityGrid = InGrid; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
FORCEINLINE bool ShouldAbortWork() const
|
||||||
|
{
|
||||||
|
return ShutdownFlag && ShutdownFlag->load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::atomic<bool>* ShutdownFlag = nullptr;
|
||||||
|
const FVoxelSharedDensityGrid* SharedDensityGrid = nullptr;
|
||||||
|
|
||||||
|
public:
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// SETTINGS
|
// SETTINGS
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
@@ -105,6 +154,14 @@ public:
|
|||||||
// l'écart vers un voisin un niveau plus grossier (cellule 2×). Monter si des fissures persistent.
|
// l'écart vers un voisin un niveau plus grossier (cellule 2×). Monter si des fissures persistent.
|
||||||
float SkirtCells = 2.0f;
|
float SkirtCells = 2.0f;
|
||||||
|
|
||||||
|
// Conservative 8x8x8 block classifier. Uniform blocks skip the marching-cubes cell loop;
|
||||||
|
// when every relevant block has the same verdict, the density grid is skipped as well.
|
||||||
|
// ClassifyTile is proof-only, so no samples, resolution, or geometry are removed.
|
||||||
|
// The tile classifier is the cheap, whole-tile proof. Keep the optional 8^3-block refinement
|
||||||
|
// available for focused diagnostics, but leave it disabled in streaming: repeating a proof on
|
||||||
|
// every mixed tile can cost more than the density grid it is meant to avoid.
|
||||||
|
bool bUseBlockEarlyOut = false;
|
||||||
|
|
||||||
// T2.b — LOD-aware octave reduction (opt-in, copied from UVoxelSettings::LODOctaveDrop).
|
// T2.b — LOD-aware octave reduction (opt-in, copied from UVoxelSettings::LODOctaveDrop).
|
||||||
// Octaves dropped from per-voxel volumetric noise PER Step doubling: a tile at Step=S
|
// Octaves dropped from per-voxel volumetric noise PER Step doubling: a tile at Step=S
|
||||||
// drops LODOctaveDrop * log2(S) octaves (see VoxelGenLOD in VoxelGenerator.h).
|
// drops LODOctaveDrop * log2(S) octaves (see VoxelGenLOD in VoxelGenerator.h).
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
|
#include "VoxelTypes.h"
|
||||||
|
|
||||||
#if !defined(VF_NOISE_USE_SIMD)
|
#if !defined(VF_NOISE_USE_SIMD)
|
||||||
#if PLATFORM_ENABLE_VECTORINTRINSICS && PLATFORM_CPU_X86_FAMILY
|
#if PLATFORM_ENABLE_VECTORINTRINSICS && PLATFORM_CPU_X86_FAMILY
|
||||||
@@ -132,6 +133,113 @@ FORCEINLINE float Perlin3D(const FVector& P)
|
|||||||
return Perlin3D((float)P.X, (float)P.Y, (float)P.Z);
|
return Perlin3D((float)P.X, (float)P.Y, (float)P.Z);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hot density callers already own float voxel coordinates. Keeping this overload separate from
|
||||||
|
// FVector avoids constructing UE5's double-precision FVector merely to round the coordinates back
|
||||||
|
// to float at the noise entry point. It is the same scalar formula and remains deterministic on
|
||||||
|
// every build of the module.
|
||||||
|
FORCEINLINE float Perlin3D(const FVector3f& P)
|
||||||
|
{
|
||||||
|
return Perlin3D(P.X, P.Y, P.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// LIPSCHITZ BOUND FOR THE ACTUAL 3D FIELD
|
||||||
|
//=============================================================================
|
||||||
|
// This is deliberately derived from the implementation above, rather than borrowed from a
|
||||||
|
// textbook Perlin constant. GradDot's h&15 table has the twelve UNNORMALIZED gradients
|
||||||
|
//
|
||||||
|
// (±1,±1,0), (±1,0,±1), (0,±1,±1)
|
||||||
|
//
|
||||||
|
// (the four duplicate selectors do not add any vectors). For one partial derivative, write the
|
||||||
|
// four x-edges of the trilinear interpolation as independent gradient pairs. With
|
||||||
|
// d = Fade'(x) = 30 x²(1-x)², each edge contributes the support of the actual gradient set at
|
||||||
|
//
|
||||||
|
// c0 = (1-x-dx, -dy, -dz), c1 = (x-d(1-x), dy, dz).
|
||||||
|
//
|
||||||
|
// The support is max(|a|+|b|, |a|+|c|, |b|+|c|), because every allowed gradient has exactly two
|
||||||
|
// non-zero ±1 components. Taking the four y/z edge weights and the exact fade derivative, a
|
||||||
|
// finite case split over those three support branches has supremum 15/4 on [0,1]^3 (attained at
|
||||||
|
// x=y=z=1/2). This maximization permits every corner hash to choose any member of the actual
|
||||||
|
// set, so it is an upper bound for every hash cell; it is also the supremum of that finite support
|
||||||
|
// relaxation, not the looser 1 + 4*max(Fade') = 8.5 bound used by the unrelated 2D floor relief.
|
||||||
|
//
|
||||||
|
// The vector bound below follows the same per-axis-bound × sqrt(dimension) method used by the
|
||||||
|
// floor-relief proof. Abs is 1-Lipschitz and VOXEL_NOISE_SCALE is positive, so the scaled N1
|
||||||
|
// field has this bound in noise-space coordinates. WormBlockSkip computes its radius after the
|
||||||
|
// anisotropic world->noise transform, so the HBias and VerticalScale factors are included there.
|
||||||
|
static constexpr double Perlin3DPartialAbsBound = 15.0 / 4.0;
|
||||||
|
static constexpr double Perlin3DGradientBound =
|
||||||
|
Perlin3DPartialAbsBound * 1.732050807568877293527446341505872L;
|
||||||
|
static constexpr double WormN1NoiseSpaceLipschitzBound =
|
||||||
|
static_cast<double>(VOXEL_NOISE_SCALE) * Perlin3DGradientBound;
|
||||||
|
|
||||||
|
// A four-point side gives the natural small worker-local blocks for the mesher's 35-point
|
||||||
|
// (32 cells + one-sample normal halo) LOD0 lattice. The cache is only an optimization; a failed
|
||||||
|
// proof falls through to the original per-sample N1/N2 code.
|
||||||
|
static constexpr int32 WormBlockSampleSide = 4;
|
||||||
|
|
||||||
|
// The center value is evaluated with the same float Perlin expression as N1. The endpoint noise
|
||||||
|
// coordinates are already float results of monotone positive/negative affine transforms, so their
|
||||||
|
// min/max box contains every float lattice sample exactly. The 1e-3 scaled-N1 margin covers the
|
||||||
|
// remaining center-evaluation/interpolation rounding with substantial headroom; the proof remains
|
||||||
|
// strict because both the Lipschitz term and this margin are subtracted before comparing with T.
|
||||||
|
static constexpr double WormBlockOutputRoundMargin = 1.0e-3;
|
||||||
|
|
||||||
|
FORCEINLINE bool ProvesScaledAbsPerlin3DAbove(
|
||||||
|
const FVector3f& CenterNoisePosition,
|
||||||
|
const FVector3f& MinNoisePosition,
|
||||||
|
const FVector3f& MaxNoisePosition,
|
||||||
|
float Threshold,
|
||||||
|
double ScaledFieldLipschitzBound)
|
||||||
|
{
|
||||||
|
if (!FMath::IsFinite(CenterNoisePosition.X)
|
||||||
|
|| !FMath::IsFinite(CenterNoisePosition.Y)
|
||||||
|
|| !FMath::IsFinite(CenterNoisePosition.Z)
|
||||||
|
|| !FMath::IsFinite(MinNoisePosition.X)
|
||||||
|
|| !FMath::IsFinite(MinNoisePosition.Y)
|
||||||
|
|| !FMath::IsFinite(MinNoisePosition.Z)
|
||||||
|
|| !FMath::IsFinite(MaxNoisePosition.X)
|
||||||
|
|| !FMath::IsFinite(MaxNoisePosition.Y)
|
||||||
|
|| !FMath::IsFinite(MaxNoisePosition.Z)
|
||||||
|
|| !FMath::IsFinite(Threshold)
|
||||||
|
|| !(Threshold > 0.0f)
|
||||||
|
|| !FMath::IsFinite(ScaledFieldLipschitzBound)
|
||||||
|
|| !(ScaledFieldLipschitzBound > 0.0))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const double Dx = FMath::Max(
|
||||||
|
FMath::Abs(static_cast<double>(MinNoisePosition.X)
|
||||||
|
- static_cast<double>(CenterNoisePosition.X)),
|
||||||
|
FMath::Abs(static_cast<double>(MaxNoisePosition.X)
|
||||||
|
- static_cast<double>(CenterNoisePosition.X)));
|
||||||
|
const double Dy = FMath::Max(
|
||||||
|
FMath::Abs(static_cast<double>(MinNoisePosition.Y)
|
||||||
|
- static_cast<double>(CenterNoisePosition.Y)),
|
||||||
|
FMath::Abs(static_cast<double>(MaxNoisePosition.Y)
|
||||||
|
- static_cast<double>(CenterNoisePosition.Y)));
|
||||||
|
const double Dz = FMath::Max(
|
||||||
|
FMath::Abs(static_cast<double>(MinNoisePosition.Z)
|
||||||
|
- static_cast<double>(CenterNoisePosition.Z)),
|
||||||
|
FMath::Abs(static_cast<double>(MaxNoisePosition.Z)
|
||||||
|
- static_cast<double>(CenterNoisePosition.Z)));
|
||||||
|
const double Radius = FMath::Sqrt(Dx * Dx + Dy * Dy + Dz * Dz);
|
||||||
|
|
||||||
|
// Keep this operation in float and in the same order as the production N1 expression. The
|
||||||
|
// double conversion is only for the conservative comparison below.
|
||||||
|
const float CenterN1 = FMath::Abs(
|
||||||
|
Perlin3D(CenterNoisePosition) * VOXEL_NOISE_SCALE);
|
||||||
|
if (!FMath::IsFinite(CenterN1))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static_cast<double>(CenterN1)
|
||||||
|
- ScaledFieldLipschitzBound * Radius
|
||||||
|
>= static_cast<double>(Threshold) + WormBlockOutputRoundMargin;
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// 4-WIDE BATCH — the SIMD multiplier. Computes 4 independent Perlin samples.
|
// 4-WIDE BATCH — the SIMD multiplier. Computes 4 independent Perlin samples.
|
||||||
// Inputs are 4-element arrays; unused lanes must be zero-filled by the caller
|
// Inputs are 4-element arrays; unused lanes must be zero-filled by the caller
|
||||||
@@ -249,6 +357,33 @@ FORCEINLINE void Perlin3D_x4(const float* Xs, const float* Ys, const float* Zs,
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Three independent channels are the hot cave-warp shape. On x86, evaluate them as three lanes
|
||||||
|
// of the existing four-wide implementation; the unused fourth lane is deliberate. The scalar
|
||||||
|
// fallback keeps the exact three-call behavior on platforms without the SIMD path. This helper is
|
||||||
|
// the owner of the cave-warp channel evaluation so generator, op-stack, and room-fit callers cannot
|
||||||
|
// drift into different noise formulas or channel offsets.
|
||||||
|
FORCEINLINE void Perlin3D_x3(
|
||||||
|
float X0, float Y0, float Z0,
|
||||||
|
float X1, float Y1, float Z1,
|
||||||
|
float X2, float Y2, float Z2,
|
||||||
|
float& Out0, float& Out1, float& Out2)
|
||||||
|
{
|
||||||
|
#if VF_NOISE_USE_SIMD
|
||||||
|
const float Xs[4] = { X0, X1, X2, X0 };
|
||||||
|
const float Ys[4] = { Y0, Y1, Y2, Y0 };
|
||||||
|
const float Zs[4] = { Z0, Z1, Z2, Z0 };
|
||||||
|
float Out[4];
|
||||||
|
Perlin3D_x4(Xs, Ys, Zs, Out);
|
||||||
|
Out0 = Out[0];
|
||||||
|
Out1 = Out[1];
|
||||||
|
Out2 = Out[2];
|
||||||
|
#else
|
||||||
|
Out0 = Perlin3D(X0, Y0, Z0);
|
||||||
|
Out1 = Perlin3D(X1, Y1, Z1);
|
||||||
|
Out2 = Perlin3D(X2, Y2, Z2);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// fBm / Ridged — octaves evaluated 4 at a time through Perlin3D_x4.
|
// fBm / Ridged — octaves evaluated 4 at a time through Perlin3D_x4.
|
||||||
// Accumulation stays scalar in octave order, so the result is independent of
|
// Accumulation stays scalar in octave order, so the result is independent of
|
||||||
|
|||||||
@@ -0,0 +1,706 @@
|
|||||||
|
// Shared dimensions and construction laws for walkable passages and origin landings.
|
||||||
|
//
|
||||||
|
// This header is deliberately free of UObjects. The density path, the operator stack, the
|
||||||
|
// passage generator, and the headless measurements all include the same arithmetic so a geometry
|
||||||
|
// change cannot leave one of those paths with a different room or slope contract.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "VoxelTypes.h"
|
||||||
|
|
||||||
|
namespace VoxelPassageGeometry
|
||||||
|
{
|
||||||
|
// Authored scale and player body used by the §6.5 landing calculation.
|
||||||
|
constexpr float VoxelSizeMeters = 0.25f;
|
||||||
|
constexpr float PlayerRadiusVoxels = 34.0f / 25.0f;
|
||||||
|
constexpr float PlayerHalfHeightVoxels = 88.0f / 25.0f;
|
||||||
|
constexpr float PlayerHeightVoxels = 2.0f * PlayerHalfHeightVoxels;
|
||||||
|
constexpr float HeadroomVoxels = 4.0f; // 1 m
|
||||||
|
constexpr float MinimumTurnFloorWidthVoxels = 12.0f; // 3 m
|
||||||
|
constexpr float MinimumLandingHalfWidthVoxels =
|
||||||
|
MinimumTurnFloorWidthVoxels * 0.5f;
|
||||||
|
constexpr float MinimumRoomHeightVoxels = 12.0f; // 3 m
|
||||||
|
constexpr float LandingFloorThicknessVoxels = 3.0f;
|
||||||
|
constexpr float MaxStepHeightVoxels = 1.8f; // 45 cm at 25 cm/voxel
|
||||||
|
// The A-to-B walk corridor is budgeted as a full player-width rectangle. Its vertical
|
||||||
|
// envelope starts above the legal step and extends one player height above that raised floor.
|
||||||
|
constexpr float WalkCorridorPlayerWidthVoxels = 1.36f;
|
||||||
|
constexpr float WalkCorridorPlayerHeightVoxels = 7.0f;
|
||||||
|
constexpr float WalkCorridorLatticeMarginVoxels = 1.0f;
|
||||||
|
constexpr float SealSafetyMarginVoxels = 4.0f;
|
||||||
|
constexpr float RoomRoundingVoxels = 1.5f;
|
||||||
|
constexpr float LandingCarveBlendVoxels = 4.0f;
|
||||||
|
// The origin landing is reserved for the same straight spine that the vertical-shaft
|
||||||
|
// generator uses: OriginSpineRadius on each side, with a three-voxel / 0.75 m floor ledge
|
||||||
|
// around it after the support patch's one-voxel inset. At the default radius (14 voxels) this
|
||||||
|
// is a 28-voxel / 7 m future shaft diameter.
|
||||||
|
constexpr float OriginShaftFloorClearanceVoxels = 4.0f;
|
||||||
|
// Level tunnel apron outside each room-floor edge. It covers the room/tunnel SDF blend and
|
||||||
|
// keeps the ramp from starting while the route is still over the room's square support patch.
|
||||||
|
constexpr float WalkableTunnelLandingApronVoxels = 6.0f; // 1.5 m
|
||||||
|
// A level transition after a bend lets the route turn before its sloped segment begins.
|
||||||
|
constexpr float WalkableTunnelTurnTransitionVoxels = 6.0f; // 1.5 m
|
||||||
|
// Keep the tunnel-air post a half voxel above the authored support plane. This is a numerical
|
||||||
|
// ownership band: the floor writer owns the plane, while the air writer owns the capsule
|
||||||
|
// volume above it even when two overlapping passage projections differ by a fraction.
|
||||||
|
constexpr float WalkableTunnelFloorAirClearanceVoxels = 0.5f;
|
||||||
|
// The graph-tunnel post owns the interior above its floor after terrain and passage-floor
|
||||||
|
// writers have run. The support-floor predicate is also an exclusion mask for the air post,
|
||||||
|
// so overlapping graph tubes cannot erase one another's walkable support band.
|
||||||
|
constexpr float CaveTunnelAirCoreInsetVoxels = 0.5f;
|
||||||
|
|
||||||
|
// A 15 degree ramp is comfortably below the 44 degree engine walkability ceiling. The
|
||||||
|
// latter is a scramble limit, not a sensible default for a tunnel the player walks through.
|
||||||
|
constexpr float WalkableTunnelMaxGradientDegrees = 15.0f;
|
||||||
|
constexpr float WalkableTunnelMaxGradient = 0.2679491924311227f; // tan(15 degrees)
|
||||||
|
// Player-fit measurements use the engine's 44 degree walkability contract. The 15 degree
|
||||||
|
// value above remains the preferred authored tunnel ramp; this wider bound is only the
|
||||||
|
// fallback threshold at which a room-to-room floor must become a terrace.
|
||||||
|
constexpr float PlayerWalkableFloorMaxGradient = 0.9656887748070738f; // tan(44 degrees)
|
||||||
|
|
||||||
|
// TunnelFloorGentleSlopeThreshold is authored as degrees. Zero is still a legal UI value,
|
||||||
|
// but the construction path uses this small positive floor so a malformed/overly strict
|
||||||
|
// value cannot turn the graph collect window into an unbounded switchback search.
|
||||||
|
constexpr float TunnelFloorGentleSlopeMinimumDegrees = 1.0f;
|
||||||
|
constexpr float TunnelFloorGentleSlopeMaximumDegrees = 90.0f;
|
||||||
|
// The route solver uses at most six half-waves. The canonical tunnel envelope is the default
|
||||||
|
// 360-voxel tunnel reach plus its endpoint correction; round that direct-run envelope up to
|
||||||
|
// 640 voxels, then derive the total cap from the same wave limit. This leaves room above the
|
||||||
|
// proven canonical route while keeping the 0.1-degree collect window finite and bounded.
|
||||||
|
constexpr int32 TunnelFloorMaximumWindWaves = 6;
|
||||||
|
constexpr float TunnelFloorMaximumSingleWaveRunVoxels = 640.0f;
|
||||||
|
constexpr float TunnelFloorMaximumRouteLengthVoxels =
|
||||||
|
static_cast<float>(TunnelFloorMaximumWindWaves)
|
||||||
|
* TunnelFloorMaximumSingleWaveRunVoxels;
|
||||||
|
|
||||||
|
FORCEINLINE float EffectiveTunnelFloorGentleSlopeDegrees(float AuthoredDegrees)
|
||||||
|
{
|
||||||
|
if (!VoxelMath::IsFinite(AuthoredDegrees))
|
||||||
|
{
|
||||||
|
return 44.0f;
|
||||||
|
}
|
||||||
|
return FMath::Clamp(
|
||||||
|
FMath::Max(AuthoredDegrees, TunnelFloorGentleSlopeMinimumDegrees),
|
||||||
|
TunnelFloorGentleSlopeMinimumDegrees,
|
||||||
|
TunnelFloorGentleSlopeMaximumDegrees);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float TunnelFloorGradientFromDegrees(float AuthoredDegrees)
|
||||||
|
{
|
||||||
|
const float Degrees = EffectiveTunnelFloorGentleSlopeDegrees(AuthoredDegrees);
|
||||||
|
float SinValue = 0.0f;
|
||||||
|
float CosValue = 0.0f;
|
||||||
|
VoxelMath::DetSinCos(
|
||||||
|
SinValue, CosValue, Degrees * (PI / 180.0f));
|
||||||
|
// At 90 degrees the mathematical tangent is unbounded. The route solver only needs a
|
||||||
|
// finite comparison, so preserve the ordering while keeping every downstream bound finite.
|
||||||
|
return FMath::Min(
|
||||||
|
FMath::Abs(SinValue) / FMath::Max(FMath::Abs(CosValue), 1.0e-3f),
|
||||||
|
4096.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The walkability contract is about the floor carried by the tube, not only its centreline.
|
||||||
|
// A tapered tube changes that floor by the radius delta, so the construction and the audit
|
||||||
|
// use this same conservative floor delta.
|
||||||
|
FORCEINLINE float TunnelFloorZ(const FVector& Centreline, float Radius)
|
||||||
|
{
|
||||||
|
return Centreline.Z - FMath::Abs(Radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float NativePassageFloorSupportRadius(float StartRadius, float EndRadius)
|
||||||
|
{
|
||||||
|
return FMath::Max(
|
||||||
|
FMath::Min(FMath::Abs(StartRadius), FMath::Abs(EndRadius)) - 0.5f,
|
||||||
|
PlayerRadiusVoxels);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep two switchback floor branches apart far enough that the upper branch's final floor
|
||||||
|
// post cannot enter the lower player's full-width walk corridor. The one-voxel margin covers
|
||||||
|
// the scalar lattice/interpolation boundary; Eval displacement is already followed by the
|
||||||
|
// MC-facing tunnel and native-floor stages before collision density is returned.
|
||||||
|
FORCEINLINE float WalkableTunnelTurnLegSeparation(
|
||||||
|
float StartRadius, float EndRadius)
|
||||||
|
{
|
||||||
|
return NativePassageFloorSupportRadius(StartRadius, EndRadius)
|
||||||
|
+ 0.5f * WalkCorridorPlayerWidthVoxels
|
||||||
|
+ WalkCorridorLatticeMarginVoxels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical-shaft tree links are round air capsules, but the player-fit graph needs a
|
||||||
|
// deterministic support plane to walk between shafts. Keep that plane on the first
|
||||||
|
// seal-safe ledge shared by the whole strate, so every tree edge has the same walk level.
|
||||||
|
// This is shaft-network support, not an origin landing connector.
|
||||||
|
FORCEINLINE float VerticalShaftConnectorFloorThickness(float Radius)
|
||||||
|
{
|
||||||
|
return FMath::Min(
|
||||||
|
LandingFloorThicknessVoxels,
|
||||||
|
FMath::Max(0.5f, FMath::Abs(Radius) * 0.5f));
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float VerticalShaftConnectorFloorZ(
|
||||||
|
float StrateBottomZ,
|
||||||
|
float InteriorBottomZ,
|
||||||
|
float InteriorTopZ,
|
||||||
|
float LedgeSpacing,
|
||||||
|
float LedgeDepth,
|
||||||
|
float ConnectorRadius)
|
||||||
|
{
|
||||||
|
const float FloorThickness = VerticalShaftConnectorFloorThickness(ConnectorRadius);
|
||||||
|
const float MinimumFloor = InteriorBottomZ
|
||||||
|
+ FloorThickness + SealSafetyMarginVoxels;
|
||||||
|
const float MaximumFloor = InteriorTopZ
|
||||||
|
- PlayerHeightVoxels - SealSafetyMarginVoxels;
|
||||||
|
if (!VoxelMath::IsFinite(StrateBottomZ)
|
||||||
|
|| !VoxelMath::IsFinite(InteriorBottomZ)
|
||||||
|
|| !VoxelMath::IsFinite(InteriorTopZ)
|
||||||
|
|| !VoxelMath::IsFinite(LedgeSpacing)
|
||||||
|
|| !VoxelMath::IsFinite(LedgeDepth)
|
||||||
|
|| !VoxelMath::IsFinite(MinimumFloor)
|
||||||
|
|| !VoxelMath::IsFinite(MaximumFloor)
|
||||||
|
|| !(MaximumFloor > MinimumFloor))
|
||||||
|
{
|
||||||
|
return 0.5f * (InteriorBottomZ + InteriorTopZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LedgeSpacing > 0.0f && LedgeDepth > 0.0f)
|
||||||
|
{
|
||||||
|
const float RelativeBottom = InteriorBottomZ - StrateBottomZ;
|
||||||
|
const int32 FirstPeriod = FMath::FloorToInt(RelativeBottom / LedgeSpacing);
|
||||||
|
// The small fixed bound keeps malformed authored values from turning generation into
|
||||||
|
// an unbounded search. Normal shaft strates find the first or second period.
|
||||||
|
constexpr int32 MaxPeriods = 64;
|
||||||
|
for (int32 PeriodOffset = 0; PeriodOffset < MaxPeriods; ++PeriodOffset)
|
||||||
|
{
|
||||||
|
const float PeriodStart = static_cast<float>(FirstPeriod + PeriodOffset)
|
||||||
|
* LedgeSpacing;
|
||||||
|
const float Candidate = StrateBottomZ + PeriodStart + LedgeDepth;
|
||||||
|
if (Candidate > MinimumFloor && Candidate < MaximumFloor)
|
||||||
|
{
|
||||||
|
return Candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return FMath::Clamp(
|
||||||
|
0.5f * (InteriorBottomZ + InteriorTopZ), MinimumFloor, MaximumFloor);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float VerticalShaftConnectorCenterZ(
|
||||||
|
float StrateBottomZ,
|
||||||
|
float InteriorBottomZ,
|
||||||
|
float InteriorTopZ,
|
||||||
|
float LedgeSpacing,
|
||||||
|
float LedgeDepth,
|
||||||
|
float ConnectorRadius)
|
||||||
|
{
|
||||||
|
const float SafeRadius = FMath::Abs(ConnectorRadius);
|
||||||
|
const float FloorThickness = VerticalShaftConnectorFloorThickness(SafeRadius);
|
||||||
|
return VerticalShaftConnectorFloorZ(
|
||||||
|
StrateBottomZ, InteriorBottomZ, InteriorTopZ,
|
||||||
|
LedgeSpacing, LedgeDepth, SafeRadius)
|
||||||
|
+ SafeRadius - FloorThickness;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a flat, inset support band below one horizontal shaft-tree capsule. */
|
||||||
|
FORCEINLINE void VF_ApplyVerticalShaftConnectorFloor(
|
||||||
|
float& Density,
|
||||||
|
float WorldX,
|
||||||
|
float WorldY,
|
||||||
|
float WorldZ,
|
||||||
|
const FVector& Start,
|
||||||
|
const FVector& End,
|
||||||
|
float Radius,
|
||||||
|
float FloorZ,
|
||||||
|
float BaseDensity)
|
||||||
|
{
|
||||||
|
const float SafeRadius = FMath::Abs(Radius);
|
||||||
|
const float FloorThickness = VerticalShaftConnectorFloorThickness(SafeRadius);
|
||||||
|
const float FloorRadius = FMath::Max(0.5f, SafeRadius - 0.5f);
|
||||||
|
if (!(SafeRadius > 0.0f)
|
||||||
|
|| !(BaseDensity > 0.0f)
|
||||||
|
|| !VoxelMath::IsFinite(WorldX)
|
||||||
|
|| !VoxelMath::IsFinite(WorldY)
|
||||||
|
|| !VoxelMath::IsFinite(WorldZ)
|
||||||
|
|| !VoxelMath::IsFinite(FloorZ)
|
||||||
|
|| !VoxelMath::IsFinite(Start.X) || !VoxelMath::IsFinite(Start.Y)
|
||||||
|
|| !VoxelMath::IsFinite(End.X) || !VoxelMath::IsFinite(End.Y))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FVector2D A(Start.X, Start.Y);
|
||||||
|
const FVector2D Delta(End.X - Start.X, End.Y - Start.Y);
|
||||||
|
const float LengthSquared = Delta.SizeSquared();
|
||||||
|
const float T = LengthSquared > KINDA_SMALL_NUMBER
|
||||||
|
? FMath::Clamp(
|
||||||
|
FVector2D::DotProduct(FVector2D(WorldX, WorldY) - A, Delta)
|
||||||
|
/ LengthSquared,
|
||||||
|
0.0f, 1.0f)
|
||||||
|
: 0.0f;
|
||||||
|
const FVector2D Closest = A + Delta * T;
|
||||||
|
if ((FVector2D(WorldX, WorldY) - Closest).SizeSquared()
|
||||||
|
> FMath::Square(FloorRadius))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WorldZ <= FloorZ + KINDA_SMALL_NUMBER
|
||||||
|
&& WorldZ > FloorZ - FloorThickness)
|
||||||
|
{
|
||||||
|
Density = FMath::Max(Density, BaseDensity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyVerticalShaftConnectorAir runs before the generator's shared disturbance pass. Keep
|
||||||
|
// this one-bit, call-local hand-off so a bridge/ridge cannot refill the core it just opened.
|
||||||
|
// It is thread-local state, never a per-voxel cache, and is reset around every density query.
|
||||||
|
FORCEINLINE bool& VerticalShaftConnectorAirMarker()
|
||||||
|
{
|
||||||
|
static thread_local bool bMarked = false;
|
||||||
|
return bMarked;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE void ResetVerticalShaftConnectorAirMarker()
|
||||||
|
{
|
||||||
|
VerticalShaftConnectorAirMarker() = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reassert the walkable air core above one horizontal shaft-tree floor band. */
|
||||||
|
FORCEINLINE void VF_ApplyVerticalShaftConnectorAir(
|
||||||
|
float& Density,
|
||||||
|
float WorldX,
|
||||||
|
float WorldY,
|
||||||
|
float WorldZ,
|
||||||
|
const FVector& Start,
|
||||||
|
const FVector& End,
|
||||||
|
float Radius,
|
||||||
|
float FloorZ,
|
||||||
|
float BaseDensity)
|
||||||
|
{
|
||||||
|
const float SafeRadius = FMath::Abs(Radius);
|
||||||
|
// Leave a one-voxel wall band for the roughness/solid shell, while retaining more than
|
||||||
|
// enough width for the 1.36-voxel player capsule and its support stencil.
|
||||||
|
const float CoreRadius = FMath::Max(0.5f, SafeRadius - 1.0f);
|
||||||
|
if (!(SafeRadius > 0.0f)
|
||||||
|
|| !(BaseDensity > 0.0f)
|
||||||
|
|| !VoxelMath::IsFinite(WorldX)
|
||||||
|
|| !VoxelMath::IsFinite(WorldY)
|
||||||
|
|| !VoxelMath::IsFinite(WorldZ)
|
||||||
|
|| !VoxelMath::IsFinite(FloorZ)
|
||||||
|
|| !VoxelMath::IsFinite(Start.X) || !VoxelMath::IsFinite(Start.Y)
|
||||||
|
|| !VoxelMath::IsFinite(Start.Z)
|
||||||
|
|| !VoxelMath::IsFinite(End.X) || !VoxelMath::IsFinite(End.Y)
|
||||||
|
|| !VoxelMath::IsFinite(End.Z))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The exact floor sample belongs to the air core's lower ownership edge. The floor
|
||||||
|
// writer only owns the three voxels below FloorZ, so clearing from the half-voxel band
|
||||||
|
// above it cannot remove support.
|
||||||
|
if (WorldZ + KINDA_SMALL_NUMBER
|
||||||
|
< FloorZ + WalkableTunnelFloorAirClearanceVoxels)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FVector Delta = End - Start;
|
||||||
|
const float LengthSquared = Delta.SizeSquared();
|
||||||
|
const float T = LengthSquared > KINDA_SMALL_NUMBER
|
||||||
|
? FMath::Clamp(FVector::DotProduct(FVector(WorldX, WorldY, WorldZ) - Start, Delta)
|
||||||
|
/ LengthSquared, 0.0f, 1.0f)
|
||||||
|
: 0.0f;
|
||||||
|
const FVector Closest = Start + Delta * T;
|
||||||
|
if ((FVector(WorldX, WorldY, WorldZ) - Closest).SizeSquared()
|
||||||
|
> FMath::Square(CoreRadius))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal convention: negative is air, positive is solid. This is intentionally a
|
||||||
|
// post-ledge write; a partial shaft shelf must not cap the tree route's player-height
|
||||||
|
// envelope after the capsule has already carved it.
|
||||||
|
VerticalShaftConnectorAirMarker() = true;
|
||||||
|
Density = FMath::Min(Density, -BaseDensity);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float TunnelFloorGradient(
|
||||||
|
const FVector& Start, float StartRadius,
|
||||||
|
const FVector& End, float EndRadius)
|
||||||
|
{
|
||||||
|
const float HorizontalRun = FVector2D(
|
||||||
|
End.X - Start.X, End.Y - Start.Y).Size();
|
||||||
|
if (HorizontalRun <= KINDA_SMALL_NUMBER)
|
||||||
|
{
|
||||||
|
return FMath::Abs(TunnelFloorZ(End, EndRadius)
|
||||||
|
- TunnelFloorZ(Start, StartRadius)) > KINDA_SMALL_NUMBER
|
||||||
|
? FLT_MAX : 0.0f;
|
||||||
|
}
|
||||||
|
return FMath::Abs(TunnelFloorZ(End, EndRadius)
|
||||||
|
- TunnelFloorZ(Start, StartRadius)) / HorizontalRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float RequiredHorizontalRunForFloorDrop(
|
||||||
|
float FirstFloorDrop, float SecondFloorDrop)
|
||||||
|
{
|
||||||
|
return 2.0f * FMath::Max(
|
||||||
|
FMath::Abs(FirstFloorDrop), FMath::Abs(SecondFloorDrop))
|
||||||
|
/ WalkableTunnelMaxGradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a query into the nearest horizontal control segment of a walkable tunnel. */
|
||||||
|
FORCEINLINE bool ProjectWalkableTunnelFloor(
|
||||||
|
const TArray<FVector>& ControlPoints,
|
||||||
|
const TArray<float>& ControlRadii,
|
||||||
|
const FVector& Position,
|
||||||
|
float& OutFloorZ,
|
||||||
|
float& OutSupportRadius,
|
||||||
|
int32* OutSegmentIndex = nullptr)
|
||||||
|
{
|
||||||
|
if (OutSegmentIndex != nullptr)
|
||||||
|
{
|
||||||
|
*OutSegmentIndex = INDEX_NONE;
|
||||||
|
}
|
||||||
|
if (ControlPoints.Num() < 2
|
||||||
|
|| ControlRadii.Num() != ControlPoints.Num())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const double QueryX = Position.X;
|
||||||
|
const double QueryY = Position.Y;
|
||||||
|
const int32 ControlPointCount = ControlPoints.Num();
|
||||||
|
const FVector* ControlPointData = ControlPoints.GetData();
|
||||||
|
const float* ControlRadiusData = ControlRadii.GetData();
|
||||||
|
float BestDistanceSquared = FLT_MAX;
|
||||||
|
float BestHorizontalDistanceSquared = FLT_MAX;
|
||||||
|
bool bFoundSegment = false;
|
||||||
|
for (int32 SegmentIndex = 0;
|
||||||
|
SegmentIndex + 1 < ControlPointCount;
|
||||||
|
++SegmentIndex)
|
||||||
|
{
|
||||||
|
const FVector& A = ControlPointData[SegmentIndex];
|
||||||
|
const FVector& B = ControlPointData[SegmentIndex + 1];
|
||||||
|
const double AXY_X = A.X;
|
||||||
|
const double AXY_Y = A.Y;
|
||||||
|
const double DeltaX = B.X - AXY_X;
|
||||||
|
const double DeltaY = B.Y - AXY_Y;
|
||||||
|
const float LengthSquared = static_cast<float>(
|
||||||
|
DeltaX * DeltaX + DeltaY * DeltaY);
|
||||||
|
if (LengthSquared <= KINDA_SMALL_NUMBER)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const double HorizontalDot = (QueryX - AXY_X) * DeltaX
|
||||||
|
+ (QueryY - AXY_Y) * DeltaY;
|
||||||
|
const float HorizontalT = FMath::Clamp(
|
||||||
|
HorizontalDot / LengthSquared,
|
||||||
|
0.0f, 1.0f);
|
||||||
|
const double ClosestX = AXY_X + DeltaX * HorizontalT;
|
||||||
|
const double ClosestY = AXY_Y + DeltaY * HorizontalT;
|
||||||
|
const double DistanceX = QueryX - ClosestX;
|
||||||
|
const double DistanceY = QueryY - ClosestY;
|
||||||
|
const float HorizontalDistanceSquared = static_cast<float>(
|
||||||
|
DistanceX * DistanceX + DistanceY * DistanceY);
|
||||||
|
|
||||||
|
const float StartRadius = FMath::Abs(ControlRadiusData[SegmentIndex]);
|
||||||
|
const float EndRadius = FMath::Abs(ControlRadiusData[SegmentIndex + 1]);
|
||||||
|
const float StartFloorZ = TunnelFloorZ(A, StartRadius);
|
||||||
|
const float EndFloorZ = TunnelFloorZ(B, EndRadius);
|
||||||
|
const double FloorDeltaZ = static_cast<double>(EndFloorZ - StartFloorZ);
|
||||||
|
const double FullLengthSquared =
|
||||||
|
LengthSquared + FloorDeltaZ * FloorDeltaZ;
|
||||||
|
if (!(FullLengthSquared > KINDA_SMALL_NUMBER))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A switchback may place two different floors at the same XY. Select the segment
|
||||||
|
// nearest to the complete query point on the floor curve, so a lower passage leg
|
||||||
|
// cannot inherit the floor height of a vertically separated upper leg. Keep floor
|
||||||
|
// interpolation horizontal after choosing the branch: FloorZ remains a function of XY.
|
||||||
|
const double FullDot = HorizontalDot
|
||||||
|
+ (static_cast<double>(Position.Z) - StartFloorZ) * FloorDeltaZ;
|
||||||
|
const float SpatialT = FMath::Clamp(
|
||||||
|
FullDot / FullLengthSquared,
|
||||||
|
0.0f, 1.0f);
|
||||||
|
const double SpatialX = AXY_X + DeltaX * SpatialT;
|
||||||
|
const double SpatialY = AXY_Y + DeltaY * SpatialT;
|
||||||
|
const double SpatialZ = StartFloorZ + FloorDeltaZ * SpatialT;
|
||||||
|
const double SpatialDistanceX = QueryX - SpatialX;
|
||||||
|
const double SpatialDistanceY = QueryY - SpatialY;
|
||||||
|
const double SpatialDistanceZ = Position.Z - SpatialZ;
|
||||||
|
const float DistanceSquared = static_cast<float>(
|
||||||
|
SpatialDistanceX * SpatialDistanceX
|
||||||
|
+ SpatialDistanceY * SpatialDistanceY
|
||||||
|
+ SpatialDistanceZ * SpatialDistanceZ);
|
||||||
|
if (DistanceSquared >= BestDistanceSquared)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
OutFloorZ = FMath::Lerp(
|
||||||
|
StartFloorZ,
|
||||||
|
EndFloorZ,
|
||||||
|
HorizontalT);
|
||||||
|
OutSupportRadius = FMath::Max(
|
||||||
|
FMath::Min(StartRadius, EndRadius) - 0.5f,
|
||||||
|
PlayerRadiusVoxels);
|
||||||
|
if (OutSegmentIndex != nullptr)
|
||||||
|
{
|
||||||
|
*OutSegmentIndex = SegmentIndex;
|
||||||
|
}
|
||||||
|
BestDistanceSquared = DistanceSquared;
|
||||||
|
BestHorizontalDistanceSquared = HorizontalDistanceSquared;
|
||||||
|
bFoundSegment = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bFoundSegment
|
||||||
|
&& VoxelMath::IsFinite(OutFloorZ)
|
||||||
|
&& VoxelMath::IsFinite(OutSupportRadius)
|
||||||
|
&& OutSupportRadius > 0.0f
|
||||||
|
&& BestHorizontalDistanceSquared <= FMath::Square(OutSupportRadius);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FOriginLandingGeometry
|
||||||
|
{
|
||||||
|
bool bValid = false;
|
||||||
|
float FloorZ = 0.0f;
|
||||||
|
float CeilingZ = 0.0f;
|
||||||
|
float HalfWidth = 0.0f;
|
||||||
|
float FloorThickness = LandingFloorThicknessVoxels;
|
||||||
|
};
|
||||||
|
|
||||||
|
FORCEINLINE float LandingHalfWidthForRadius(float Radius)
|
||||||
|
{
|
||||||
|
const float SafeRadius = FMath::Max(FMath::Abs(Radius), 1.0f);
|
||||||
|
return FMath::Max(MinimumLandingHalfWidthVoxels, SafeRadius + 2.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float LandingHeightForRadius(float Radius)
|
||||||
|
{
|
||||||
|
const float SafeRadius = FMath::Max(FMath::Abs(Radius), 1.0f);
|
||||||
|
return FMath::Max(
|
||||||
|
FMath::Max(MinimumRoomHeightVoxels, PlayerHeightVoxels + HeadroomVoxels),
|
||||||
|
2.0f * SafeRadius + 2.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the one origin landing in a strate.
|
||||||
|
*
|
||||||
|
* `OriginRadius` is the old spine setting and becomes the room's authored mouth radius. The
|
||||||
|
* room dimensions therefore reuse the §6.5 formula exactly:
|
||||||
|
* half-width = max(7, radius + 2),
|
||||||
|
* height = max(12, capsule height + 4, 2 * radius + 2).
|
||||||
|
* The floor is placed against the top of the safe interior interval. This makes the first
|
||||||
|
* landing the place reached from above, while every lower landing remains sealed above until
|
||||||
|
* progression opens its inter-strate passage.
|
||||||
|
*/
|
||||||
|
FORCEINLINE FOriginLandingGeometry BuildOriginLandingGeometry(
|
||||||
|
float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius)
|
||||||
|
{
|
||||||
|
FOriginLandingGeometry Result;
|
||||||
|
if (!(OriginRadius > 0.0f)
|
||||||
|
|| !VoxelMath::IsFinite(StrateTopZ)
|
||||||
|
|| !VoxelMath::IsFinite(StrateBottomZ)
|
||||||
|
|| !VoxelMath::IsFinite(SealThickness)
|
||||||
|
|| !VoxelMath::IsFinite(OriginRadius))
|
||||||
|
{
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a solid walking ledge around the future shaft, not merely a room whose wall is
|
||||||
|
// tangent to it. The shaft diameter is 2 * OriginRadius; the extra four voxels leave a
|
||||||
|
// three-voxel / 0.75 m floor margin after the support patch's one-voxel inset at the
|
||||||
|
// authored 25 cm scale.
|
||||||
|
Result.HalfWidth = FMath::Max(
|
||||||
|
LandingHalfWidthForRadius(OriginRadius),
|
||||||
|
FMath::Max(FMath::Abs(OriginRadius), 1.0f)
|
||||||
|
+ OriginShaftFloorClearanceVoxels);
|
||||||
|
const float DesiredHeight = LandingHeightForRadius(OriginRadius);
|
||||||
|
const float SafeSeal = FMath::Max(SealThickness, 0.0f);
|
||||||
|
const float InnerBottom = StrateBottomZ + SafeSeal;
|
||||||
|
const float InnerTop = StrateTopZ - SafeSeal;
|
||||||
|
const float MinimumFloor = InnerBottom
|
||||||
|
+ Result.FloorThickness + SealSafetyMarginVoxels;
|
||||||
|
const float MaximumFloor = InnerTop
|
||||||
|
- DesiredHeight - SealSafetyMarginVoxels;
|
||||||
|
if (!VoxelMath::IsFinite(InnerBottom) || !VoxelMath::IsFinite(InnerTop)
|
||||||
|
|| !(InnerTop > InnerBottom)
|
||||||
|
|| !VoxelMath::IsFinite(MinimumFloor)
|
||||||
|
|| !VoxelMath::IsFinite(MaximumFloor)
|
||||||
|
|| MaximumFloor < MinimumFloor)
|
||||||
|
{
|
||||||
|
Result.HalfWidth = 0.0f;
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.FloorZ = MaximumFloor;
|
||||||
|
Result.CeilingZ = Result.FloorZ + DesiredHeight;
|
||||||
|
Result.bValid = true;
|
||||||
|
return Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE bool IntersectsClosedInterval(float MinA, float MaxA,
|
||||||
|
float MinB, float MaxB)
|
||||||
|
{
|
||||||
|
return MaxA >= MinB && MinA <= MaxB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the exact MC lattice has at least one sample in a closed interval. */
|
||||||
|
FORCEINLINE bool LatticeAxisHasSampleInInterval(
|
||||||
|
float Min, float Max, float Origin, int32 Step)
|
||||||
|
{
|
||||||
|
if (Step <= 0 || !VoxelMath::IsFinite(Min) || !VoxelMath::IsFinite(Max)
|
||||||
|
|| !VoxelMath::IsFinite(Origin) || Min > Max)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const float InvStep = 1.0f / static_cast<float>(Step);
|
||||||
|
const int32 First = FMath::CeilToInt(
|
||||||
|
(Min - Origin) * InvStep - 1.0e-4f);
|
||||||
|
const int32 Last = FMath::FloorToInt(
|
||||||
|
(Max - Origin) * InvStep + 1.0e-4f);
|
||||||
|
return First <= Last;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE bool OriginLandingRoomTouchesLattice(
|
||||||
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step,
|
||||||
|
float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius,
|
||||||
|
float ReachScale = 1.0f)
|
||||||
|
{
|
||||||
|
if (!VoxelBox.IsValid) return false;
|
||||||
|
if (!VoxelMath::IsFinite(ReachScale) || ReachScale <= 0.0f)
|
||||||
|
{
|
||||||
|
// This is an invalid proof request, not an empty room. The manager treats a
|
||||||
|
// malformed scale as conservative/unknown before calling this helper.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const FOriginLandingGeometry Geometry = BuildOriginLandingGeometry(
|
||||||
|
StrateTopZ, StrateBottomZ, SealThickness, OriginRadius);
|
||||||
|
if (!Geometry.bValid) return false;
|
||||||
|
const float Pad = LandingCarveBlendVoxels * ReachScale;
|
||||||
|
return LatticeAxisHasSampleInInterval(
|
||||||
|
FMath::Max((float)VoxelBox.Min.X, -Geometry.HalfWidth - Pad),
|
||||||
|
FMath::Min((float)VoxelBox.Max.X, Geometry.HalfWidth + Pad),
|
||||||
|
(float)LatticeOrigin.X, Step)
|
||||||
|
&& LatticeAxisHasSampleInInterval(
|
||||||
|
FMath::Max((float)VoxelBox.Min.Y, -Geometry.HalfWidth - Pad),
|
||||||
|
FMath::Min((float)VoxelBox.Max.Y, Geometry.HalfWidth + Pad),
|
||||||
|
(float)LatticeOrigin.Y, Step)
|
||||||
|
&& LatticeAxisHasSampleInInterval(
|
||||||
|
FMath::Max((float)VoxelBox.Min.Z, Geometry.FloorZ - Pad),
|
||||||
|
FMath::Min((float)VoxelBox.Max.Z, Geometry.CeilingZ + Pad),
|
||||||
|
(float)LatticeOrigin.Z, Step);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE bool OriginLandingFloorTouchesLattice(
|
||||||
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step,
|
||||||
|
float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius)
|
||||||
|
{
|
||||||
|
if (!VoxelBox.IsValid) return false;
|
||||||
|
const FOriginLandingGeometry Geometry = BuildOriginLandingGeometry(
|
||||||
|
StrateTopZ, StrateBottomZ, SealThickness, OriginRadius);
|
||||||
|
if (!Geometry.bValid) return false;
|
||||||
|
|
||||||
|
constexpr float ProofPad = 1.0f;
|
||||||
|
const float FloorHalfWidth = FMath::Max(
|
||||||
|
Geometry.HalfWidth - 1.0f, 0.0f);
|
||||||
|
return LatticeAxisHasSampleInInterval(
|
||||||
|
FMath::Max((float)VoxelBox.Min.X, -FloorHalfWidth - ProofPad),
|
||||||
|
FMath::Min((float)VoxelBox.Max.X, FloorHalfWidth + ProofPad),
|
||||||
|
(float)LatticeOrigin.X, Step)
|
||||||
|
&& LatticeAxisHasSampleInInterval(
|
||||||
|
FMath::Max((float)VoxelBox.Min.Y, -FloorHalfWidth - ProofPad),
|
||||||
|
FMath::Min((float)VoxelBox.Max.Y, FloorHalfWidth + ProofPad),
|
||||||
|
(float)LatticeOrigin.Y, Step)
|
||||||
|
&& LatticeAxisHasSampleInInterval(
|
||||||
|
FMath::Max((float)VoxelBox.Min.Z,
|
||||||
|
Geometry.FloorZ - Geometry.FloorThickness - ProofPad),
|
||||||
|
FMath::Min((float)VoxelBox.Max.Z, Geometry.FloorZ + ProofPad),
|
||||||
|
(float)LatticeOrigin.Z, Step);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conservative influence box for the origin room's air carve. */
|
||||||
|
FORCEINLINE bool OriginLandingRoomTouchesBox(
|
||||||
|
const FBox& VoxelBox,
|
||||||
|
float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius)
|
||||||
|
{
|
||||||
|
if (!VoxelBox.IsValid) return false;
|
||||||
|
const FOriginLandingGeometry Geometry = BuildOriginLandingGeometry(
|
||||||
|
StrateTopZ, StrateBottomZ, SealThickness, OriginRadius);
|
||||||
|
if (!Geometry.bValid) return false;
|
||||||
|
const float Pad = LandingCarveBlendVoxels;
|
||||||
|
return IntersectsClosedInterval(
|
||||||
|
VoxelBox.Min.X, VoxelBox.Max.X,
|
||||||
|
-Geometry.HalfWidth - Pad, Geometry.HalfWidth + Pad)
|
||||||
|
&& IntersectsClosedInterval(
|
||||||
|
VoxelBox.Min.Y, VoxelBox.Max.Y,
|
||||||
|
-Geometry.HalfWidth - Pad, Geometry.HalfWidth + Pad)
|
||||||
|
&& IntersectsClosedInterval(
|
||||||
|
VoxelBox.Min.Z, VoxelBox.Max.Z,
|
||||||
|
Geometry.FloorZ - Pad, Geometry.CeilingZ + Pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conservative influence box for the origin room's guaranteed solid floor. */
|
||||||
|
FORCEINLINE bool OriginLandingFloorTouchesBox(
|
||||||
|
const FBox& VoxelBox,
|
||||||
|
float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius)
|
||||||
|
{
|
||||||
|
if (!VoxelBox.IsValid) return false;
|
||||||
|
const FOriginLandingGeometry Geometry = BuildOriginLandingGeometry(
|
||||||
|
StrateTopZ, StrateBottomZ, SealThickness, OriginRadius);
|
||||||
|
if (!Geometry.bValid) return false;
|
||||||
|
|
||||||
|
// One voxel is a proof margin, not a write margin. It keeps ClassifyTile conservative at
|
||||||
|
// an exact lattice edge and mirrors the existing passage-floor guard.
|
||||||
|
constexpr float ProofPad = 1.0f;
|
||||||
|
const float FloorHalfWidth = FMath::Max(
|
||||||
|
Geometry.HalfWidth - 1.0f, 0.0f);
|
||||||
|
return IntersectsClosedInterval(
|
||||||
|
VoxelBox.Min.X, VoxelBox.Max.X,
|
||||||
|
-FloorHalfWidth - ProofPad, FloorHalfWidth + ProofPad)
|
||||||
|
&& IntersectsClosedInterval(
|
||||||
|
VoxelBox.Min.Y, VoxelBox.Max.Y,
|
||||||
|
-FloorHalfWidth - ProofPad, FloorHalfWidth + ProofPad)
|
||||||
|
&& IntersectsClosedInterval(
|
||||||
|
VoxelBox.Min.Z, VoxelBox.Max.Z,
|
||||||
|
Geometry.FloorZ - Geometry.FloorThickness - ProofPad,
|
||||||
|
Geometry.FloorZ + ProofPad);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed distance for a rounded origin room with a hard flat floor. */
|
||||||
|
FORCEINLINE float OriginLandingRoomSDF(
|
||||||
|
const FVector& Position, const FOriginLandingGeometry& Geometry)
|
||||||
|
{
|
||||||
|
if (!Geometry.bValid || Geometry.CeilingZ <= Geometry.FloorZ)
|
||||||
|
{
|
||||||
|
return FLT_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FVector Center(0.0f, 0.0f,
|
||||||
|
(Geometry.FloorZ + Geometry.CeilingZ) * 0.5f);
|
||||||
|
const float Rounding = RoomRoundingVoxels;
|
||||||
|
const float HalfZ = (Geometry.CeilingZ - Geometry.FloorZ) * 0.5f;
|
||||||
|
const FVector HalfExtent(
|
||||||
|
FMath::Max(Geometry.HalfWidth - Rounding, 0.25f),
|
||||||
|
FMath::Max(Geometry.HalfWidth - Rounding, 0.25f),
|
||||||
|
FMath::Max(HalfZ - Rounding, 0.25f));
|
||||||
|
const FVector D(
|
||||||
|
FMath::Abs(Position.X - Center.X) - HalfExtent.X,
|
||||||
|
FMath::Abs(Position.Y - Center.Y) - HalfExtent.Y,
|
||||||
|
FMath::Abs(Position.Z - Center.Z) - HalfExtent.Z);
|
||||||
|
const FVector Outside(
|
||||||
|
FMath::Max(D.X, 0.0f),
|
||||||
|
FMath::Max(D.Y, 0.0f),
|
||||||
|
FMath::Max(D.Z, 0.0f));
|
||||||
|
const float Inside = FMath::Min(
|
||||||
|
FMath::Max(D.X, FMath::Max(D.Y, D.Z)), 0.0f);
|
||||||
|
const float RoundedBoxSDF = Outside.Size() + Inside - Rounding;
|
||||||
|
|
||||||
|
// Below the floor is rock. Above it the rounded-box air volume is authoritative.
|
||||||
|
return FMath::Max(RoundedBoxSDF, Geometry.FloorZ - Position.Z);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Cookable runtime carrier for one reviewed season manifest.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "Engine/DataAsset.h"
|
||||||
|
#include "VoxelSeasonAsset.generated.h"
|
||||||
|
|
||||||
|
struct FVoxelSeasonManifest;
|
||||||
|
class UVoxelStrateDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JSON remains the reviewed/diffable source artifact. This asset embeds that exact text so
|
||||||
|
* Unreal's cooker can carry it into a packaged build, together with an independently stored digest
|
||||||
|
* used to detect an accidentally stale or edited asset.
|
||||||
|
*/
|
||||||
|
UCLASS(BlueprintType)
|
||||||
|
class VOXELFORGE_API UVoxelSeasonAsset : public UPrimaryDataAsset
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
public:
|
||||||
|
virtual FPrimaryAssetId GetPrimaryAssetId() const override;
|
||||||
|
|
||||||
|
/** Parse and validate the embedded manifest, including both content-hash copies. */
|
||||||
|
bool LoadManifest(FVoxelSeasonManifest& OutManifest, FString& OutReport) const;
|
||||||
|
|
||||||
|
/** Runtime-safe import core used by the editor button and automation. */
|
||||||
|
bool SetManifestJson(const FString& InManifestJson, FString& OutReport);
|
||||||
|
|
||||||
|
int32 GetSeasonNumber() const { return SeasonNumber; }
|
||||||
|
int32 GetSeasonSeed() const { return SeasonSeed; }
|
||||||
|
float GetOriginSpineRadius() const { return OriginSpineRadius; }
|
||||||
|
float GetWorldRadiusVoxels() const { return WorldRadiusVoxels; }
|
||||||
|
const FString& GetManifestContentHash() const { return ManifestContentHash; }
|
||||||
|
|
||||||
|
/** Create the asset in Content Browser, choose a JSON file, then press this button. */
|
||||||
|
UFUNCTION(CallInEditor, Category = "Season")
|
||||||
|
void ImportSeasonManifestJson();
|
||||||
|
|
||||||
|
#if WITH_EDITORONLY_DATA
|
||||||
|
UPROPERTY(EditAnywhere, Category = "Season|Import", meta = (FilePathFilter = "json"))
|
||||||
|
FFilePath SourceManifestJson;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private:
|
||||||
|
UPROPERTY(VisibleAnywhere, Category = "Season")
|
||||||
|
int32 SchemaVersion = 0;
|
||||||
|
|
||||||
|
UPROPERTY(VisibleAnywhere, Category = "Season")
|
||||||
|
int32 SeasonNumber = 0;
|
||||||
|
|
||||||
|
UPROPERTY(VisibleAnywhere, Category = "Season")
|
||||||
|
int32 SeasonSeed = 0;
|
||||||
|
|
||||||
|
UPROPERTY(VisibleAnywhere, Category = "Season")
|
||||||
|
float OriginSpineRadius = 0.0f;
|
||||||
|
|
||||||
|
UPROPERTY(VisibleAnywhere, Category = "Season")
|
||||||
|
float WorldRadiusVoxels = 0.0f;
|
||||||
|
|
||||||
|
UPROPERTY(VisibleAnywhere, Category = "Season")
|
||||||
|
FString ManifestContentHash;
|
||||||
|
|
||||||
|
UPROPERTY(VisibleAnywhere, Category = "Season", meta = (MultiLine = true))
|
||||||
|
FString ManifestJson;
|
||||||
|
|
||||||
|
// Import-derived cook references for fixed/authored content bags named by the JSON. Generated
|
||||||
|
// strates legitimately have no source definition and use deterministic C++ defaults at runtime.
|
||||||
|
UPROPERTY(VisibleAnywhere, Category = "Season")
|
||||||
|
TArray<TSoftObjectPtr<UVoxelStrateDefinition>> ReferencedDefinitions;
|
||||||
|
};
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
// Offline Tier 4c composition plus the runtime-readable, diffable Tier A manifest.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "VoxelStrateComposer.h"
|
||||||
|
#include "VoxelStrateMeasure.h"
|
||||||
|
|
||||||
|
class UVoxelSettings;
|
||||||
|
|
||||||
|
/** Why the provisional season selector put one surviving candidate in a slot. */
|
||||||
|
enum class EVoxelSeasonSelectionReason : uint8
|
||||||
|
{
|
||||||
|
Grounded,
|
||||||
|
Outlier,
|
||||||
|
Fixed,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit, reviewable selection knobs for one season.
|
||||||
|
*
|
||||||
|
* None of these values is a settled definition of quality. They are the first policy Jahni can
|
||||||
|
* change after walking the review page. Hard validation remains separate from this policy.
|
||||||
|
*/
|
||||||
|
struct VOXELFORGE_API FVoxelSeasonSelectionPolicy
|
||||||
|
{
|
||||||
|
// Placeholder mix only: ordinary strates give an outlier contrast. Revisit after the first
|
||||||
|
// season review; this is not a learned or final quality score.
|
||||||
|
float GroundedFraction = 0.75f;
|
||||||
|
float OutlierFraction = 0.25f;
|
||||||
|
|
||||||
|
// Below this measured distance two neighbours look too similar. The selector subtracts a
|
||||||
|
// penalty proportional to the gap; provisional because the useful contrast is visual.
|
||||||
|
double AdjacentSimilarityDistanceThreshold = 0.20;
|
||||||
|
double AdjacentSimilarityPenaltyWeight = 1.0;
|
||||||
|
|
||||||
|
// Rewards an unseen archetype/recipe in the already chosen prefix. These are variety
|
||||||
|
// tie-breakers, not claims that one archetype or recipe is better. Provisional review knobs.
|
||||||
|
double NewArchetypeBonus = 0.10;
|
||||||
|
double NewRecipeBonus = 0.05;
|
||||||
|
|
||||||
|
// GDD §6 says roughly every five levels, and a boss is intermediary rather than terminal.
|
||||||
|
// This interval is therefore an explicit provisional layout knob, not a quality score.
|
||||||
|
int32 BossSlotInterval = 5;
|
||||||
|
|
||||||
|
// Tier 2 hard gate: a surviving air component must retain at least this share. Provisional
|
||||||
|
// only in its threshold; the existence of the gate is non-negotiable for a shippable season.
|
||||||
|
float MinimumLargestComponentShare = 0.50f;
|
||||||
|
|
||||||
|
// Normalizers for the two unbounded measured dimensions in the adjacency distance. Fractions
|
||||||
|
// already live in [0,1]. These physical scales are provisional and visible in the manifest.
|
||||||
|
float FeatureScaleNormalizationVoxels = 256.0f;
|
||||||
|
float ClearanceNormalizationVoxels = 64.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A caller-supplied fixed slot for tests/tools that already have a complete manifest record. */
|
||||||
|
struct VOXELFORGE_API FVoxelSeasonFixedStrate
|
||||||
|
{
|
||||||
|
int32 Seed = 0;
|
||||||
|
int32 HeightInChunks = 8;
|
||||||
|
ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork;
|
||||||
|
FVoxelStrateArchetypeParams Params;
|
||||||
|
FVoxelOpStackRecipe Recipe;
|
||||||
|
FVoxelStrateRegionManifest Regions;
|
||||||
|
bool bUsesRecipe = true;
|
||||||
|
bool bUsesRegions = false;
|
||||||
|
FString SourceDefinitionPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Settings for the offline build-box pass. No member is consulted by normal runtime generation. */
|
||||||
|
struct VOXELFORGE_API FVoxelSeasonCompositionSettings
|
||||||
|
{
|
||||||
|
// Optional live-world settings. TotalStrates, spine/gap values, and FixedStrates are read
|
||||||
|
// from it when present. The pointer is borrowed for the duration of VF_ComposeSeason only.
|
||||||
|
const UVoxelSettings* WorldSettings = nullptr;
|
||||||
|
|
||||||
|
// Optional preloaded corpus. With nullptr the composer loads the project asset corpus on the
|
||||||
|
// build box, exactly like the existing offline composer tests.
|
||||||
|
const FVoxelStrateCorpus* Corpus = nullptr;
|
||||||
|
|
||||||
|
// 24 gives a useful minute-scale automation pass at the default coarse measurement window.
|
||||||
|
// Production can raise this to the overnight budget without changing the policy.
|
||||||
|
int32 CandidateCount = 24;
|
||||||
|
int32 SelectedStrateCount = 10;
|
||||||
|
|
||||||
|
// Generated candidates use one stable height. Fixed authored slots may provide their own
|
||||||
|
// height. World layout bounds are written into every selected parameter family below.
|
||||||
|
int32 StrateHeightInChunks = 8;
|
||||||
|
int32 InterStrateGapChunks = 0;
|
||||||
|
float OriginSpineRadius = 14.0f;
|
||||||
|
float WorldRadiusVoxels = 0.0f;
|
||||||
|
|
||||||
|
// Coarse measurement is deliberately bounded. Tests may reduce the window; production may
|
||||||
|
// spend the overnight budget on a finer pass after the selector has been judged.
|
||||||
|
FVoxelStrateMeasureSettings MeasureSettings;
|
||||||
|
// Player-fit is a separate, explicitly fine pass. SampleStep values other than one are
|
||||||
|
// refused by the metric, never interpreted as an approximate capsule answer.
|
||||||
|
FVoxelStrateMeasureSettings PlayerFitMeasureSettings;
|
||||||
|
FVoxelSeasonSelectionPolicy SelectionPolicy;
|
||||||
|
|
||||||
|
// Explicit composed fixed slots take precedence. When empty, the composer adapts the
|
||||||
|
// existing UVoxelSettings::FixedStrates map, preserving its absolute slot indices.
|
||||||
|
TMap<int32, FVoxelSeasonFixedStrate> FixedStrates;
|
||||||
|
|
||||||
|
// Empty means ProjectSavedDir()/VoxelForge/Seasons. Files are never written to the runtime
|
||||||
|
// content directory by this API.
|
||||||
|
FString OutputDirectory;
|
||||||
|
bool bWriteArtifacts = true;
|
||||||
|
bool bWriteReviewPage = true;
|
||||||
|
bool bIncludeCandidateAudit = true;
|
||||||
|
|
||||||
|
// Informational only; when WorldSettings is present CurrentSeason supplies the default.
|
||||||
|
int32 SeasonNumber = 0;
|
||||||
|
|
||||||
|
FVoxelSeasonCompositionSettings()
|
||||||
|
{
|
||||||
|
// The existing Tier 4b test uses step 4. Season composition defaults to step 8 so the
|
||||||
|
// default automation artifact is measured in minutes, while MaxCells remains bounded.
|
||||||
|
MeasureSettings.SampleStep = 8;
|
||||||
|
MeasureSettings.RadiusInVoxels = 256;
|
||||||
|
MeasureSettings.MaxCells = 8000000;
|
||||||
|
MeasureSettings.MaxRouteRetries = 16;
|
||||||
|
MeasureSettings.HeadroomCells = 2;
|
||||||
|
MeasureSettings.InteriorMarginVoxels = -1;
|
||||||
|
|
||||||
|
PlayerFitMeasureSettings.SampleStep = 1;
|
||||||
|
PlayerFitMeasureSettings.RadiusInVoxels = 64;
|
||||||
|
// A fitted mouth-to-mouth XY window across an 8-chunk (256-voxel) strate can approach
|
||||||
|
// four million cells. Keep the cap finite and below the old multi-gigabyte retry cost.
|
||||||
|
PlayerFitMeasureSettings.MaxCells = 4000000;
|
||||||
|
PlayerFitMeasureSettings.MaxRouteRetries = 16;
|
||||||
|
PlayerFitMeasureSettings.HeadroomCells = 2;
|
||||||
|
PlayerFitMeasureSettings.InteriorMarginVoxels = -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Friendly aliases for callers that use the task's shorter naming.
|
||||||
|
using FVoxelSeasonComposerSettings = FVoxelSeasonCompositionSettings;
|
||||||
|
using FVoxelSeasonComposeSettings = FVoxelSeasonCompositionSettings;
|
||||||
|
|
||||||
|
/** Compact audit row; the full vectors live only for selected strates. */
|
||||||
|
struct VOXELFORGE_API FVoxelSeasonCandidateAudit
|
||||||
|
{
|
||||||
|
int32 CandidateIndex = INDEX_NONE;
|
||||||
|
int32 Seed = 0;
|
||||||
|
ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork;
|
||||||
|
uint32 RecipeHash = 0;
|
||||||
|
double DistanceFromCorpusCentroid = 0.0;
|
||||||
|
bool bPassedHardGates = false;
|
||||||
|
bool bSelected = false;
|
||||||
|
FString RejectionReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Count of one explicit hard-gate or final-placement rejection reason. */
|
||||||
|
struct VOXELFORGE_API FVoxelSeasonRejectionCount
|
||||||
|
{
|
||||||
|
FString Reason;
|
||||||
|
int32 Count = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One ordered Tier A spine entry. */
|
||||||
|
struct VOXELFORGE_API FVoxelSeasonStrate
|
||||||
|
{
|
||||||
|
// DepthIndex is the absolute world-layout slot. CandidateIndex is the offline attempt id.
|
||||||
|
int32 DepthIndex = INDEX_NONE;
|
||||||
|
int32 CandidateIndex = INDEX_NONE;
|
||||||
|
int32 Seed = 0;
|
||||||
|
int32 HeightInChunks = 0;
|
||||||
|
int32 TopWorldZ = 0;
|
||||||
|
int32 BottomWorldZ = 0;
|
||||||
|
|
||||||
|
ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork;
|
||||||
|
FVoxelOpStackRecipe Recipe;
|
||||||
|
FVoxelStrateArchetypeParams Params;
|
||||||
|
// When true this is the complete lateral parent description. Params/Archetype remain the
|
||||||
|
// compatibility identity of region zero; runtime rebuilds use Regions and never blend params.
|
||||||
|
FVoxelStrateRegionManifest Regions;
|
||||||
|
FVoxelStrateMeasuredMetrics Metrics;
|
||||||
|
|
||||||
|
double DistanceFromCorpusCentroid = 0.0;
|
||||||
|
FString SelectionReason;
|
||||||
|
FString SourceDefinitionPath;
|
||||||
|
|
||||||
|
bool bGrounded = false;
|
||||||
|
bool bOutlier = false;
|
||||||
|
bool bBossSlot = false;
|
||||||
|
bool bFixed = false;
|
||||||
|
bool bUsesRecipe = true;
|
||||||
|
bool bUsesRegions = false;
|
||||||
|
|
||||||
|
bool bPassedNonVacuous = false;
|
||||||
|
bool bPassedLargestComponent = false;
|
||||||
|
bool bPassedPrimordialLaw = false;
|
||||||
|
EVoxelConnectivityResult PrimordialLawResult = EVoxelConnectivityResult::OutOfWindow;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored Tier A season artifact.
|
||||||
|
*
|
||||||
|
* Tier B is intentionally absent: its hash-lattice field remains an arithmetic/enumeration API,
|
||||||
|
* not a second stored table. Strates is ordered from the surface down and is the only finite spine
|
||||||
|
* persisted by this task.
|
||||||
|
*/
|
||||||
|
struct VOXELFORGE_API FVoxelSeasonManifest
|
||||||
|
{
|
||||||
|
static constexpr int32 CurrentSchemaVersion = 2;
|
||||||
|
|
||||||
|
bool bValid = false;
|
||||||
|
int32 SchemaVersion = CurrentSchemaVersion;
|
||||||
|
FString Error;
|
||||||
|
|
||||||
|
int32 Season = 0;
|
||||||
|
int32 Seed = 0;
|
||||||
|
uint32 InputCorpusHash = 0;
|
||||||
|
// SHA-1 of the canonical manifest JSON with this field omitted. This detects stale cooked
|
||||||
|
// season assets without requiring the editor-only input corpus on a player's machine.
|
||||||
|
FString ContentHash;
|
||||||
|
int32 CandidateCount = 0;
|
||||||
|
int32 SurvivorCount = 0;
|
||||||
|
int32 SelectedCount = 0;
|
||||||
|
int32 RejectedCount = 0;
|
||||||
|
int32 GroundedSelectedCount = 0;
|
||||||
|
int32 OutlierSelectedCount = 0;
|
||||||
|
int32 FixedSelectedCount = 0;
|
||||||
|
int32 TotalStrates = 0;
|
||||||
|
int32 InterStrateGapChunks = 0;
|
||||||
|
float OriginSpineRadius = 0.0f;
|
||||||
|
float WorldRadiusVoxels = 0.0f;
|
||||||
|
|
||||||
|
FVoxelSeasonSelectionPolicy SelectionPolicy;
|
||||||
|
FString PolicyNote;
|
||||||
|
|
||||||
|
TArray<FVoxelSeasonStrate> Strates;
|
||||||
|
TArray<FVoxelSeasonCandidateAudit> CandidateAudit;
|
||||||
|
TArray<FVoxelSeasonRejectionCount> RejectionCounts;
|
||||||
|
|
||||||
|
// Derived artifact locations; intentionally not serialized into the portable JSON.
|
||||||
|
FString ManifestPath;
|
||||||
|
FString ReviewPagePath;
|
||||||
|
FString SerializedJson;
|
||||||
|
|
||||||
|
bool IsUsable() const
|
||||||
|
{
|
||||||
|
if (!bValid || !Error.IsEmpty() || ContentHash.Len() != 40 || WorldRadiusVoxels != 0.0f
|
||||||
|
|| Strates.Num() != SelectedCount || SelectedCount <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const FVoxelSeasonStrate& Strate : Strates)
|
||||||
|
{
|
||||||
|
// Region composition is intentionally not a shippable season format yet. Keeping
|
||||||
|
// this rejection in the portable manifest contract prevents an editor experiment
|
||||||
|
// from becoming packaged terrain merely because its density evaluator exists.
|
||||||
|
if (Strate.bUsesRegions)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Human-readable enum names used by JSON and the review page. */
|
||||||
|
VOXELFORGE_API const TCHAR* VF_GetVoxelSeasonSelectionReasonName(
|
||||||
|
EVoxelSeasonSelectionReason Reason);
|
||||||
|
|
||||||
|
/** Compose, validate, select, serialize, and (by default) write one season. Editor/build-box only. */
|
||||||
|
#if WITH_EDITOR
|
||||||
|
VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason(
|
||||||
|
int32 SeasonSeed, const FVoxelSeasonCompositionSettings& Settings);
|
||||||
|
|
||||||
|
/** Convenience overload that adapts the existing runtime settings asset. */
|
||||||
|
VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason(
|
||||||
|
int32 SeasonSeed, const UVoxelSettings* WorldSettings);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/** Serialize/read the complete Tier A manifest as stable pretty JSON. */
|
||||||
|
VOXELFORGE_API FString VF_SerializeVoxelSeasonManifest(
|
||||||
|
const FVoxelSeasonManifest& Manifest);
|
||||||
|
/** Parse an in-memory/cooked JSON manifest and verify its embedded content hash. */
|
||||||
|
VOXELFORGE_API bool VF_DeserializeVoxelSeasonManifest(
|
||||||
|
const FString& ManifestJson, FVoxelSeasonManifest& OutManifest, FString& OutReport);
|
||||||
|
/** Stable SHA-1 over the canonical manifest payload (the content_hash field itself is omitted). */
|
||||||
|
VOXELFORGE_API FString VF_ComputeVoxelSeasonManifestContentHash(
|
||||||
|
const FVoxelSeasonManifest& Manifest);
|
||||||
|
VOXELFORGE_API bool VF_SaveVoxelSeasonManifest(
|
||||||
|
const FString& ManifestPath, const FVoxelSeasonManifest& Manifest, FString& OutReport);
|
||||||
|
VOXELFORGE_API bool VF_LoadVoxelSeasonManifest(
|
||||||
|
const FString& ManifestPath, FVoxelSeasonManifest& OutManifest, FString& OutReport);
|
||||||
|
|
||||||
|
/** Rebuild exactly the stack represented by one manifest entry. */
|
||||||
|
VOXELFORGE_API bool VF_BuildSeasonStrateStack(
|
||||||
|
const FVoxelSeasonStrate& Strate,
|
||||||
|
float SpineRadius,
|
||||||
|
const UVoxelStrateManager* StrateManager,
|
||||||
|
FVoxelOpStack& OutStack,
|
||||||
|
FVoxelOpContext& OutContext,
|
||||||
|
FString* OutError = nullptr);
|
||||||
|
|
||||||
|
/** Verbose alias for tests/tools that want the round-trip operation named explicitly. */
|
||||||
|
VOXELFORGE_API bool VF_RebuildVoxelSeasonStrate(
|
||||||
|
const FVoxelSeasonStrate& Strate,
|
||||||
|
float SpineRadius,
|
||||||
|
const UVoxelStrateManager* StrateManager,
|
||||||
|
FVoxelOpStack& OutStack,
|
||||||
|
FVoxelOpContext& OutContext,
|
||||||
|
FString* OutError = nullptr);
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
// IWYU : pointeur seulement (VoxelMaterial). / Pointer-only use.
|
// IWYU : pointeur seulement (VoxelMaterial). / Pointer-only use.
|
||||||
class UMaterialInterface;
|
class UMaterialInterface;
|
||||||
|
class UVoxelSeasonAsset;
|
||||||
|
|
||||||
UCLASS(BlueprintType)
|
UCLASS(BlueprintType)
|
||||||
class UVoxelSettings : public UPrimaryDataAsset
|
class UVoxelSettings : public UPrimaryDataAsset
|
||||||
@@ -21,6 +22,12 @@ class UVoxelSettings : public UPrimaryDataAsset
|
|||||||
|
|
||||||
public:
|
public:
|
||||||
|
|
||||||
|
/** Season seed/radius replace the mutable authored values only when Season is assigned. */
|
||||||
|
int32 GetEffectiveWorldSeed() const;
|
||||||
|
float GetEffectiveOriginSpineRadius() const;
|
||||||
|
float GetEffectiveWorldRadiusVoxels() const;
|
||||||
|
int32 GetEffectiveSeasonNumber() const;
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// STREAMING (distance de vue)
|
// STREAMING (distance de vue)
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
@@ -39,6 +46,20 @@ public:
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming")
|
||||||
int32 MaxConcurrentTasks = 16;
|
int32 MaxConcurrentTasks = 16;
|
||||||
|
|
||||||
|
// Optional hard cap for the generation worker budget. 0 keeps the safe automatic cap of
|
||||||
|
// logical cores minus two; a positive value makes that cap visible and authorable beside the
|
||||||
|
// task budget instead of hiding it in AVoxelWorld. The effective task count is still the
|
||||||
|
// minimum of this value and MaxConcurrentTasks.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0"))
|
||||||
|
int32 MaxGenerationWorkerCores = 0;
|
||||||
|
|
||||||
|
// Keep this many task slots available for collision-critical, edited, and level-0 terrain while
|
||||||
|
// coarse horizon work is in flight. A positive reserve prevents a full far-field queue from
|
||||||
|
// delaying a new near tile because running tasks cannot be preempted. The runtime clamps the
|
||||||
|
// value below the effective task budget; 4 is the conservative default for the ship settings.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0", ClampMax = "16"))
|
||||||
|
int32 NearTaskReserve = 4;
|
||||||
|
|
||||||
// Nombre max de meshes appliqués (upload GPU) par frame. Seuls les vrais applies
|
// Nombre max de meshes appliqués (upload GPU) par frame. Seuls les vrais applies
|
||||||
// comptent (chunks vides/périmés se vident gratuitement). À 32³ chaque apply est léger
|
// comptent (chunks vides/périmés se vident gratuitement). À 32³ chaque apply est léger
|
||||||
// (~0.1 ms) — tunable en live sur l'asset : montez (8-16) si le remplissage traîne,
|
// (~0.1 ms) — tunable en live sur l'asset : montez (8-16) si le remplissage traîne,
|
||||||
@@ -46,6 +67,13 @@ public:
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming")
|
||||||
int32 MaxMeshAppliesPerFrame = 4;
|
int32 MaxMeshAppliesPerFrame = 4;
|
||||||
|
|
||||||
|
// Wall-clock cap for the complete game-thread result drain, including component teardown and
|
||||||
|
// collision configuration. The count cap remains the second guard. A zero value disables this
|
||||||
|
// cap; the default keeps one burst of chunk application below a small frame-time budget without
|
||||||
|
// changing worker resolution or mesh quality.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0.0"))
|
||||||
|
float MaxMeshApplyMilliseconds = 2.0f;
|
||||||
|
|
||||||
// Max tile teardowns (component destroy + content-actor Destroy) per frame. A fast traversal
|
// Max tile teardowns (component destroy + content-actor Destroy) per frame. A fast traversal
|
||||||
// culls a whole shell of tiles at once; doing every destroy in one frame is a game-thread spike
|
// culls a whole shell of tiles at once; doing every destroy in one frame is a game-thread spike
|
||||||
// ("stuff torn down behind you"). This spreads it. The drain auto-scales up if the backlog grows
|
// ("stuff torn down behind you"). This spreads it. The drain auto-scales up if the backlog grows
|
||||||
@@ -74,13 +102,26 @@ public:
|
|||||||
// both strates' materials. From this clip level UP, the mesher only meshes cells inside the
|
// both strates' materials. From this clip level UP, the mesher only meshes cells inside the
|
||||||
// PLAYER's strate Z-band (the other strates are sealed/enclosed ⇒ invisible from here anyway);
|
// PLAYER's strate Z-band (the other strates are sealed/enclosed ⇒ invisible from here anyway);
|
||||||
// loaded coarse tiles re-queue automatically when the band changes (strate transition).
|
// loaded coarse tiles re-queue automatically when the band changes (strate transition).
|
||||||
// Default 0 = cut at EVERY level (tested verdict 2026-07-05: level 0/1 straddler tiles were
|
// Default 1 = cut LOD1 and coarser. LOD0 is deliberately never content-cut because it owns pawn
|
||||||
// the visible mixers — a higher floor left them mixing and looked like "no improvement").
|
// collision: cutting the passage, gap, or next-strate support tile would deadlock a walk at the
|
||||||
// Raise only if the descent/passage transition needs full tiles near the player. 9 = off.
|
// current strate boundary. The runtime clamps legacy/hand-authored values below 1 to 1 as well.
|
||||||
|
// Raise only if a broader near-field view needs full coarse tiles. 9 = off.
|
||||||
// At ultra-coarse levels where one CELL is taller than the band itself, the tile is skipped
|
// At ultra-coarse levels where one CELL is taller than the band itself, the tile is skipped
|
||||||
// entirely (see LoadTile) — cell-granular cutting there could only render garbage.
|
// entirely (see LoadTile) — cell-granular cutting there could only render garbage.
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0", ClampMax = "9"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "1", ClampMax = "9"))
|
||||||
int32 StrateContentCutMinLevel = 0;
|
int32 StrateContentCutMinLevel = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the effective content-cut level used by runtime streaming.
|
||||||
|
*
|
||||||
|
* Existing data assets can contain a serialized legacy value of 0 even though the property now
|
||||||
|
* documents 1 as the minimum. Keep the safety rule in code, at the consumer boundary: level 0
|
||||||
|
* tiles are the collision tiles and must never receive a strate content band.
|
||||||
|
*/
|
||||||
|
int32 GetEffectiveStrateContentCutMinLevel() const
|
||||||
|
{
|
||||||
|
return FMath::Clamp(FMath::Max(StrateContentCutMinLevel, 1), 1, 9);
|
||||||
|
}
|
||||||
|
|
||||||
// Open-world SKY reach: the sky-cap ceiling of an open strate (SurfaceWorld / FloatingIslands)
|
// Open-world SKY reach: the sky-cap ceiling of an open strate (SurfaceWorld / FloatingIslands)
|
||||||
// is FAR, so the ceiling BAND is streamed across a wider horizontal radius = ViewDistanceXY ×
|
// is FAR, so the ceiling BAND is streamed across a wider horizontal radius = ViewDistanceXY ×
|
||||||
@@ -114,8 +155,9 @@ public:
|
|||||||
|
|
||||||
// Coarsest LOD level. 0 = full-res only (no clipmap). Each level up doubles tile size &
|
// Coarsest LOD level. 0 = full-res only (no clipmap). Each level up doubles tile size &
|
||||||
// reach at ~constant cost — raise this to see much farther for cheap. (Level L step = 2^L.)
|
// reach at ~constant cost — raise this to see much farther for cheap. (Level L step = 2^L.)
|
||||||
|
// Ship default: level 5, the measured horizon-distance balance at 768 chunks.
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "8"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "8"))
|
||||||
int32 MaxClipLevel = 4;
|
int32 MaxClipLevel = 5;
|
||||||
|
|
||||||
// How many near levels mesh at FULL resolution (CHUNK_SIZE cells). Levels at or above this
|
// How many near levels mesh at FULL resolution (CHUNK_SIZE cells). Levels at or above this
|
||||||
// use the cheaper CoarseTileCells → MUCH faster gen for the far field (it's far, so the
|
// use the cheaper CoarseTileCells → MUCH faster gen for the far field (it's far, so the
|
||||||
@@ -137,9 +179,9 @@ public:
|
|||||||
// cell must fit inside a strate band — level ≥7 blanks via the too-coarse skip) and buy the
|
// cell must fit inside a strate band — level ≥7 blanks via the too-coarse skip) and buy the
|
||||||
// remaining horizon here. Cost: the extra ring is all same-level tiles — tile/draw/gen count
|
// remaining horizon here. Cost: the extra ring is all same-level tiles — tile/draw/gen count
|
||||||
// grows with (distance / 2^MaxClipLevel)², so each MaxClipLevel step down quadruples the ring.
|
// grows with (distance / 2^MaxClipLevel)², so each MaxClipLevel step down quadruples the ring.
|
||||||
// 0 = off (natural reach, byte-identical streaming).
|
// Ship default: 768 chunks (about 6.1 km at 8 m/chunk). 0 = off (natural reach).
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0"))
|
||||||
int32 RenderDistanceChunks = 0;
|
int32 RenderDistanceChunks = 768;
|
||||||
|
|
||||||
// F18 — the render-distance ring streams per-surface SHEETS instead of MC tiles: in an open
|
// F18 — the render-distance ring streams per-surface SHEETS instead of MC tiles: in an open
|
||||||
// strate the far field is exactly two heightfields (TerrainZ + sky-cap CeilSurf, both already
|
// strate the far field is exactly two heightfields (TerrainZ + sky-cap CeilSurf, both already
|
||||||
@@ -148,10 +190,11 @@ public:
|
|||||||
// sheet spans 2^FarSheetSpanLevels MC-tile footprints per axis). Non-open strates produce
|
// sheet spans 2^FarSheetSpanLevels MC-tile footprints per axis). Non-open strates produce
|
||||||
// empty sheets (their far ring was enclosed rock anyway). Carved features (passages, chasms,
|
// empty sheets (their far ring was enclosed rock anyway). Carved features (passages, chasms,
|
||||||
// spine) don't show at sheet distance. Needs the strate band armed (StrateContentCutMinLevel
|
// spine) don't show at sheet distance. Needs the strate band armed (StrateContentCutMinLevel
|
||||||
// active); in the inter-strate gap the sheet ring blanks until you land. Off = the ring stays
|
// active); in the inter-strate gap the sheet ring blanks until you land. The ship default is OFF:
|
||||||
// MC tiles at level MaxClipLevel (pre-F18 behaviour). Only matters when RenderDistanceChunks > 0.
|
// the ring stays real MC tiles at level MaxClipLevel (pre-F18 behaviour). Only matters when
|
||||||
|
// RenderDistanceChunks > 0.
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap")
|
||||||
bool bFarSheetRing = true;
|
bool bFarSheetRing = false;
|
||||||
|
|
||||||
// Sheet tile size = MaxClipLevel + this many levels (2 → one sheet covers 4×4 MC-tile
|
// Sheet tile size = MaxClipLevel + this many levels (2 → one sheet covers 4×4 MC-tile
|
||||||
// footprints → ~16× fewer far components). Sampling density stays that of the MaxClipLevel
|
// footprints → ~16× fewer far components). Sampling density stays that of the MaxClipLevel
|
||||||
@@ -179,6 +222,28 @@ public:
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "3"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "3"))
|
||||||
int32 LODOctaveDrop = 0;
|
int32 LODOctaveDrop = 0;
|
||||||
|
|
||||||
|
//==========================================================================
|
||||||
|
// WORLD BOUNDS (bounded gigantic world in actor-space XY)
|
||||||
|
//==========================================================================
|
||||||
|
// Radius (voxels) of the bounded world in XY, measured from actor-space (0,0).
|
||||||
|
// 0 = unbounded (legacy behaviour, no edge seal).
|
||||||
|
//
|
||||||
|
// ⚠️ DEFAULTS TO 0 ON PURPOSE, and it is not the value the GDD wants.
|
||||||
|
// Existing DA_Settings / BP_VoxelSettings assets predate this property, so they do NOT serialize
|
||||||
|
// it — they silently inherit whatever C++ default is written here. A non-zero default would
|
||||||
|
// therefore wall in every existing world the next time the editor opens, with nothing in the
|
||||||
|
// asset to show why. The GDD calls for "bounded, but gigantic": ~8192 voxels (2.05 km) is the
|
||||||
|
// intended production value. SET IT IN THE ASSET, deliberately, once.
|
||||||
|
//
|
||||||
|
// FR : 0 par défaut EXPRÈS — les assets existants n'ont pas cette propriété et héritent
|
||||||
|
// silencieusement de la valeur C++. La valeur de production visée (~8192) se règle dans l'asset.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|World Bounds", meta = (ClampMin = "0.0"))
|
||||||
|
float WorldRadiusVoxels = 0.0f;
|
||||||
|
|
||||||
|
// Solid ramp thickness at the radial world edge, in voxels.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|World Bounds", meta = (ClampMin = "0.0"))
|
||||||
|
float EdgeSealThickness = 64.0f;
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// CONTENT — distance-based decoration grid (no LOD pop)
|
// CONTENT — distance-based decoration grid (no LOD pop)
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
@@ -246,12 +311,12 @@ public:
|
|||||||
// frame. A region is applied once ALL its cells have finished marching, in one batched AddInstances
|
// frame. A region is applied once ALL its cells have finished marching, in one batched AddInstances
|
||||||
// per mesh; this throttles that burst. The expensive ray-march runs async on workers. (Named "Cells"
|
// per mesh; this throttles that burst. The expensive ray-march runs async on workers. (Named "Cells"
|
||||||
// for asset back-compat — the apply unit is now a region of DecorationRegionSizeCells^2 cells.)
|
// for asset back-compat — the apply unit is now a region of DecorationRegionSizeCells^2 cells.)
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "1"))
|
||||||
int32 MaxDecorationCellsPerFrame = 2;
|
int32 MaxDecorationCellsPerFrame = 2;
|
||||||
|
|
||||||
// Max decoration ray-march tasks in flight at once. Keeps decoration marching from crowding the
|
// Max decoration ray-march tasks in flight at once. Keeps decoration marching from crowding the
|
||||||
// mesh-gen workers (which are the streaming bottleneck). 0 disables decorations entirely.
|
// mesh-gen workers (which are the streaming bottleneck). 0 disables decorations entirely.
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "0"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0"))
|
||||||
int32 MaxConcurrentDecorationTasks = 4;
|
int32 MaxConcurrentDecorationTasks = 4;
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
@@ -328,6 +393,11 @@ public:
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates")
|
||||||
int32 Seed = 0;
|
int32 Seed = 0;
|
||||||
|
|
||||||
|
// Optional reviewed/cooked season. When set, its manifest owns layout, seed, bounds and
|
||||||
|
// density recipes; the authored StratePool/FixedStrates path remains untouched when unset.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Voxel|Strates")
|
||||||
|
TSoftObjectPtr<UVoxelSeasonAsset> Season;
|
||||||
|
|
||||||
// Numéro de saison (incrementé par ChangeSeed). Purement informatif côté gameplay.
|
// Numéro de saison (incrementé par ChangeSeed). Purement informatif côté gameplay.
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates")
|
||||||
int32 CurrentSeason = 1;
|
int32 CurrentSeason = 1;
|
||||||
@@ -358,10 +428,12 @@ public:
|
|||||||
// SPINE & INTER-STRATE CONNECTIONS (the (0,0) descent axis)
|
// SPINE & INTER-STRATE CONNECTIONS (the (0,0) descent axis)
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
|
|
||||||
// Radius (voxels) of the guaranteed open vertical "landing" column carved at
|
// Authored radius (voxels) of the guaranteed (0,0) landing room in EVERY strate, regardless
|
||||||
// world XY (0,0) in EVERY strate, regardless of archetype. This is the prepared
|
// of archetype. The §6.5 body-sized formulas turn it into a flat-floored room reserved for a
|
||||||
// space the player digs THROUGH the seal into when descending. 0 = disabled.
|
// future straight shaft of diameter 2 * this radius. At the default 14-voxel radius that is
|
||||||
// 10-14 → cozy shaft (default) · 20+ → wide landing chamber
|
// 28 voxels / 7 m, with a three-voxel / 0.75 m floor ledge around it. This setting does not bore the
|
||||||
|
// shaft today: lower seals remain solid until a progression passage is opened.
|
||||||
|
// 10-14 → compact landing room (default) · 20+ → broad landing room
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Spine", meta = (ClampMin = "0.0"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Spine", meta = (ClampMin = "0.0"))
|
||||||
float OriginSpineRadius = 14.0f;
|
float OriginSpineRadius = 14.0f;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// VoxelStackSampler.h
|
||||||
|
// Opt-in raw stack sampling for active VoxelForge generation workers; symbols are resolved offline.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreTypes.h"
|
||||||
|
#include "Containers/UnrealString.h"
|
||||||
|
#include "HAL/CriticalSection.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A diagnostic-only raw stack sampler.
|
||||||
|
*
|
||||||
|
* The generation path only constructs FScopedVoxelStackRegistration at task entry. When the
|
||||||
|
* sampler is disabled that constructor performs one relaxed load of the global enable flag and
|
||||||
|
* returns; it does not touch the registry, allocate, lock, or read a console variable.
|
||||||
|
*
|
||||||
|
* The sampler thread owns a bounded preallocated array of raw program counters. The measured
|
||||||
|
* process never initializes or calls DbgHelp: the raw CSV is the durable sampling artifact and
|
||||||
|
* symbolization is explicitly deferred to an offline consumer.
|
||||||
|
*/
|
||||||
|
class VOXELFORGE_API FVoxelStackSampler
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static constexpr uint32 DefaultIntervalUs = 1000;
|
||||||
|
static constexpr uint32 MinIntervalUs = 1;
|
||||||
|
static constexpr uint32 MaxIntervalUs = 1000000;
|
||||||
|
static constexpr uint32 MaxStackDepth = 64;
|
||||||
|
// Bounded allocation. The implementation uses reservoir replacement after this fills so a
|
||||||
|
// long moving session is represented across its whole run rather than only its first window.
|
||||||
|
static constexpr uint32 MaxStoredSamples = 131072;
|
||||||
|
|
||||||
|
struct FSummary
|
||||||
|
{
|
||||||
|
bool bSupported = false;
|
||||||
|
bool bStarted = false;
|
||||||
|
bool bOutputWritten = false;
|
||||||
|
bool bModuleMapWritten = false;
|
||||||
|
bool bStackWalkingInitialized = false;
|
||||||
|
uint32 IntervalUs = 0;
|
||||||
|
uint32 MaxDepth = 0;
|
||||||
|
uint32 MaxStoredSampleCount = 0;
|
||||||
|
|
||||||
|
uint64 SamplerTicks = 0;
|
||||||
|
uint64 CaptureAttempts = 0;
|
||||||
|
uint64 RegisteredThreadSamples = 0;
|
||||||
|
uint64 RegistrationRaceDrops = 0;
|
||||||
|
uint64 CaptureFailures = 0;
|
||||||
|
uint64 RetainedSamples = 0;
|
||||||
|
uint64 DroppedSamples = 0;
|
||||||
|
uint64 TotalFrames = 0;
|
||||||
|
// Kept for source compatibility with the earlier in-process summary. Raw sessions leave
|
||||||
|
// these zero; the offline summary owns all resolved-frame counts and tables.
|
||||||
|
uint64 ResolvedFrames = 0;
|
||||||
|
uint64 UnknownFrames = 0;
|
||||||
|
uint64 WaitLikeFrames = 0;
|
||||||
|
uint64 SamplesWithResolvedLeaf = 0;
|
||||||
|
uint64 SamplesWithWaitLikeFrame = 0;
|
||||||
|
uint64 UniqueProgramCounters = 0;
|
||||||
|
uint64 InlineSymbols = 0;
|
||||||
|
uint64 MaxActiveRegisteredThreads = 0;
|
||||||
|
uint64 ModuleCount = 0;
|
||||||
|
double RunSeconds = 0.0;
|
||||||
|
|
||||||
|
FString RunLabel;
|
||||||
|
FString RawSamplesPath;
|
||||||
|
FString ModuleMapPath;
|
||||||
|
FString SummaryPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
static FVoxelStackSampler& Get();
|
||||||
|
|
||||||
|
/** Start one sampling session. Returns false when another session is active or setup fails. */
|
||||||
|
bool Start(uint32 IntervalUs, const FString& OutputDirectory, const FString& RunLabel);
|
||||||
|
|
||||||
|
/** Stop and write the bounded raw CSV plus a symbolization-free readable summary. */
|
||||||
|
FSummary StopAndWrite();
|
||||||
|
|
||||||
|
/** Used by the module shutdown path as a final lifecycle backstop. */
|
||||||
|
void Shutdown();
|
||||||
|
|
||||||
|
bool IsRunning() const;
|
||||||
|
|
||||||
|
/** Called only by FScopedVoxelStackRegistration; cheap when disabled. */
|
||||||
|
static bool RegisterCurrentThread(int32 LODLevel = INDEX_NONE);
|
||||||
|
static void DeregisterCurrentThread();
|
||||||
|
static bool IsEnabled();
|
||||||
|
|
||||||
|
private:
|
||||||
|
FVoxelStackSampler() = default;
|
||||||
|
~FVoxelStackSampler() = default;
|
||||||
|
FVoxelStackSampler(const FVoxelStackSampler&) = delete;
|
||||||
|
FVoxelStackSampler& operator=(const FVoxelStackSampler&) = delete;
|
||||||
|
|
||||||
|
struct FImpl;
|
||||||
|
FImpl* Impl = nullptr;
|
||||||
|
mutable FCriticalSection LifecycleMutex;
|
||||||
|
|
||||||
|
bool RegisterThreadInternal(int32 LODLevel);
|
||||||
|
void DeregisterThreadInternal();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Scope a generation task, not an individual density sample. */
|
||||||
|
class FScopedVoxelStackRegistration final
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit FScopedVoxelStackRegistration(int32 InLODLevel = INDEX_NONE)
|
||||||
|
: bRegistered(FVoxelStackSampler::RegisterCurrentThread(InLODLevel))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
~FScopedVoxelStackRegistration()
|
||||||
|
{
|
||||||
|
if (bRegistered)
|
||||||
|
{
|
||||||
|
FVoxelStackSampler::DeregisterCurrentThread();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FScopedVoxelStackRegistration(const FScopedVoxelStackRegistration&) = delete;
|
||||||
|
FScopedVoxelStackRegistration& operator=(const FScopedVoxelStackRegistration&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool bRegistered = false;
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Bounded, opt-in startup trace for the complete streaming path.
|
||||||
|
// The trace is deliberately independent of the shared Unreal log: callers provide an
|
||||||
|
// absolute output path and every record is one JSON object per line.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
|
||||||
|
namespace VoxelForgeStartupTrace
|
||||||
|
{
|
||||||
|
struct VOXELFORGE_API FTileSample
|
||||||
|
{
|
||||||
|
int32 TileX = 0;
|
||||||
|
int32 TileY = 0;
|
||||||
|
int32 TileZ = 0;
|
||||||
|
int32 Level = -1;
|
||||||
|
int32 Verdict = -1; // Mixed=0, AllSolid=1, AllAir=2, -1=not classified (sheet/early path)
|
||||||
|
bool bSealedSolidProof = false;
|
||||||
|
bool bEmpty = true;
|
||||||
|
int32 Vertices = 0;
|
||||||
|
int32 Triangles = 0;
|
||||||
|
uint64 GeometryBytes = 0;
|
||||||
|
// This is worker-result -> game-thread mesh submission. Collision readiness is a later
|
||||||
|
// per-tile RMC completion event recorded separately as collision_ready.
|
||||||
|
double RequestToApplySeconds = 0.0;
|
||||||
|
double QueueWaitSeconds = 0.0;
|
||||||
|
double WorkerQueueSeconds = 0.0;
|
||||||
|
double ResultQueueSeconds = 0.0;
|
||||||
|
double GenerationSeconds = 0.0;
|
||||||
|
double ClassifySeconds = 0.0;
|
||||||
|
double MeshSeconds = 0.0;
|
||||||
|
double StreamSeconds = 0.0;
|
||||||
|
double ApplySeconds = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Starts when -voxel.StartupTraceFile=<absolute path> is present. */
|
||||||
|
VOXELFORGE_API void BeginFromCommandLine();
|
||||||
|
|
||||||
|
VOXELFORGE_API bool IsActive();
|
||||||
|
|
||||||
|
/** FieldsJson is the contents after the type/timestamp object prefix, without braces. */
|
||||||
|
VOXELFORGE_API void RecordEvent(const TCHAR* Type, const FString& FieldsJson = FString());
|
||||||
|
|
||||||
|
VOXELFORGE_API void StageBegin(const TCHAR* Name);
|
||||||
|
VOXELFORGE_API void StageEnd(const TCHAR* Name);
|
||||||
|
|
||||||
|
VOXELFORGE_API void RecordTile(const FTileSample& Sample);
|
||||||
|
|
||||||
|
/** Writes the final bounded summary and closes the file. Safe to call more than once. */
|
||||||
|
VOXELFORGE_API void Finish(const TCHAR* Reason);
|
||||||
|
|
||||||
|
/** RAII helper for stages which have early-return failure paths. */
|
||||||
|
class VOXELFORGE_API FStageScope
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit FStageScope(const TCHAR* InName);
|
||||||
|
~FStageScope();
|
||||||
|
|
||||||
|
FStageScope(const FStageScope&) = delete;
|
||||||
|
FStageScope& operator=(const FStageScope&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
FString Name;
|
||||||
|
bool bActive = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,9 +18,13 @@ DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Sole Slot"), STAT
|
|||||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Boundary Tile"), STAT_VoxelForgeCaveBailNotOpStackBoundaryTile, STATGROUP_VoxelForge, VOXELFORGE_API);
|
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Boundary Tile"), STAT_VoxelForgeCaveBailNotOpStackBoundaryTile, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack No Layout"), STAT_VoxelForgeCaveBailNotOpStackNoLayout, STATGROUP_VoxelForge, VOXELFORGE_API);
|
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack No Layout"), STAT_VoxelForgeCaveBailNotOpStackNoLayout, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Recheck"), STAT_VoxelForgeCaveBailNotOpStackRecheck, STATGROUP_VoxelForge, VOXELFORGE_API);
|
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Recheck"), STAT_VoxelForgeCaveBailNotOpStackRecheck, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Mixed Content"), STAT_VoxelForgeCaveBailMixedContent, STATGROUP_VoxelForge, VOXELFORGE_API);
|
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Out Of Layout"), STAT_VoxelForgeCaveMixOutOfLayout, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||||
|
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Gap"), STAT_VoxelForgeCaveMixGap, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||||
|
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Surface World"), STAT_VoxelForgeCaveMixSurfaceWorld, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||||
|
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Two Cave Slots"), STAT_VoxelForgeCaveBailTwoCaveSlots, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Params"), STAT_VoxelForgeCaveBailParams, STATGROUP_VoxelForge, VOXELFORGE_API);
|
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Params"), STAT_VoxelForgeCaveBailParams, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Stack Verdict"), STAT_VoxelForgeCaveBailStackVerdict, STATGROUP_VoxelForge, VOXELFORGE_API);
|
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Stack Verdict"), STAT_VoxelForgeCaveBailStackVerdict, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||||
|
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Fold Conflict"), STAT_VoxelForgeCaveBailFoldConflict, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Disturbance"), STAT_VoxelForgeCaveBailDisturbance, STATGROUP_VoxelForge, VOXELFORGE_API);
|
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Disturbance"), STAT_VoxelForgeCaveBailDisturbance, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail No Stack"), STAT_VoxelForgeCaveBailNoStack, STATGROUP_VoxelForge, VOXELFORGE_API);
|
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail No Stack"), STAT_VoxelForgeCaveBailNoStack, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Column Memo Hits"), STAT_VoxelForgeColumnMemoHit, STATGROUP_VoxelForge, VOXELFORGE_API);
|
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Column Memo Hits"), STAT_VoxelForgeColumnMemoHit, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||||
|
|||||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user