Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82eb5b1c66 | ||
|
|
52cb3d7a23 | ||
|
|
4d7b470ebc | ||
|
|
c3a1610b22 | ||
|
|
9d088ef96c | ||
|
|
24803ab771 | ||
|
|
786ecc4cdf | ||
|
|
ca0bf767ca | ||
|
|
ec89b29853 | ||
|
|
c6357b573f | ||
|
|
9c70053543 | ||
|
|
3b71d35eba | ||
|
|
e1d87eed26 | ||
|
|
5b15595e4a | ||
|
|
ff52c9b576 | ||
|
|
aa458d3607 | ||
|
|
323fbe3736 | ||
|
|
4290672d1c | ||
|
|
7d654ff861 | ||
|
|
a45ba9bb10 | ||
|
|
a4e214561f | ||
|
|
6cd9e4da76 | ||
|
|
9c6d261d01 | ||
|
|
eb47d72258 | ||
|
|
95bf654fbc | ||
|
|
0065ee68e6 | ||
|
|
ebae71b142 | ||
|
|
81071969fc | ||
|
|
cbbfd46bd3 | ||
|
|
044cd6d9c5 | ||
|
|
b96dd739b8 | ||
|
|
4041d4bb4b | ||
|
|
aeb60bbda3 | ||
|
|
01a50946cb | ||
|
|
4111b967bd | ||
|
|
103fa75e1c | ||
|
|
c743f2abc7 | ||
|
|
5e7e0eb1c1 | ||
|
|
bae1144ee4 | ||
|
|
5c519f5206 | ||
|
|
161565936c | ||
|
|
707bc61553 | ||
|
|
6e52fb48b2 | ||
|
|
c2b8d39573 | ||
|
|
89504ecc97 | ||
|
|
f403af1fc1 | ||
|
|
5e875ae504 | ||
|
|
36d954cf2f | ||
|
|
16554cd426 | ||
|
|
5ec09d1ff1 | ||
|
|
5ea01988c4 |
No files matched your search
@@ -9,12 +9,20 @@
|
||||
!/Source/
|
||||
!/Source/**
|
||||
|
||||
# Plugin runtime shader source.
|
||||
!/Shaders/
|
||||
!/Shaders/**
|
||||
|
||||
# Portable CPU generator and D3D11 viewer.
|
||||
!/Standalone/
|
||||
!/Standalone/**
|
||||
/Standalone/Build/
|
||||
/Standalone/bin/
|
||||
|
||||
# Research and prototype experiments.
|
||||
!/Experiments/
|
||||
!/Experiments/**
|
||||
|
||||
# The guarded single-entry test harness is source, not generated output.
|
||||
!/Tools/
|
||||
!/Tools/**
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# AGENTS.md — VoxelForge (read automatically by Codex)
|
||||
|
||||
You are the implementation agent. The orchestrator (Claude) writes each round's brief, reviews your diff against the
|
||||
code, and commits. The owner decides direction. Be warm and direct; credit what worked; say plainly what didn't.
|
||||
|
||||
Read first, as the task needs: `CURRENT-STATE.md` (what ships, reference numbers, **closed experiments: don't reopen
|
||||
them without new evidence**), then `CODEMAP.md` and `ARCHITECTURE.md`. Round reports live in
|
||||
`Standalone/Research/WorldAssemblerM3/Linux*/REPORT.md`.
|
||||
|
||||
## Standing rules (every round, unless the brief says otherwise)
|
||||
|
||||
- Run as user `jahni`. Write only inside this plugin, plus `/home/jahni/VFHost` and `/home/jahni/UnrealEngine` when
|
||||
the brief asks for a UE build. No installs, no system changes.
|
||||
- **No `git commit`, `push` or `stash`.** Leave changes in the working tree; the orchestrator reviews and commits.
|
||||
- Headless processes and builds: `taskset -c 0-11 prlimit --as=6442450944` (12 P-core threads, 6 GiB). Never use all
|
||||
cores.
|
||||
- Unreal (UBT, the editor, commandlets): `taskset -c 0-11` only, **never `prlimit`** (CoreCLR fails). UE builds use
|
||||
`-NoUBA -MaxParallelActions=4`, never while timing.
|
||||
- Stop any single run that passes 30 minutes. Logs go in `Saved/Linux/l<round>/`.
|
||||
- **Timing:** quiet machine only (check `uptime` first; load < ~1.5, nothing else building). L80 protocol: one warm-up
|
||||
per binary, ≥5 interleaved runs, W8 and W12, all four owner layers. Every number states the window that produced it.
|
||||
- **Same world for everyone:** a seed must give the same world on every machine and worker count (Clang and GCC,
|
||||
W1/W8/W12). Byte-identity with older versions is NOT required. If the output changes: bump
|
||||
`WorldAssemblyEvaluatorVersion`, add baselines, and show captures.
|
||||
- Density sign: negative = solid, positive = air. Core voxels are 25 cm. Never mix metres and voxels without an
|
||||
explicit conversion (v225 shipped a reach bug from exactly that).
|
||||
- The fast tier (`Standalone/Tools/verify/verify.py fast`) must stay green. Don't loosen an audit to make a change pass.
|
||||
|
||||
## How to work
|
||||
|
||||
1. **Count before you build.** Measure where the time or the failure actually is (stack sampler, census, counters).
|
||||
State which parts are measured and which are inferred.
|
||||
2. **Suspect the measurement first.** An alarming or contradictory number is the instrument until a control rules it
|
||||
out (A/A runs, an ablation that verifiably removes the work).
|
||||
3. **No fix without a root cause.** Reproduce it, trace it to its source, fix the cause. A guard that hides the symptom
|
||||
is not a fix.
|
||||
4. **One change at a time,** each with its own check.
|
||||
5. **Subtract before you add.** Prefer removing work to reorganising it. The smallest edit that solves the problem.
|
||||
6. **Attack the premise.** When two fixes sharing one premise fail the same gate, write that premise in one sentence
|
||||
and question it before trying a third. **After three failed fixes, stop:** it is probably the design, not the
|
||||
hypothesis. Report it instead of trying fix #4.
|
||||
7. **Tests must be able to fail.** Name what would make each check fail. Add a negative control when you add an audit.
|
||||
8. **Refactors:** pin the behaviour (hashes), change the structure, prove nothing changed.
|
||||
9. **Verify before you claim.** Don't report a check as passed unless it ran on the final source. Say which checks
|
||||
didn't run and why.
|
||||
10. Changes that speed things up go in the shared layer, so every generator benefits.
|
||||
|
||||
## Report
|
||||
|
||||
Write the round's `REPORT.md`, short and plain:
|
||||
- the result first;
|
||||
- what changed and why;
|
||||
- the measurements, with their windows;
|
||||
- the checks run, and those not run;
|
||||
- remaining risks.
|
||||
|
||||
List any changed Unreal `Source/` files, because the owner compiles them on Windows.
|
||||
@@ -1522,11 +1522,12 @@ collision ring became walkable at 0.632 s. `voxel.StreamingCoarseFirst=1` with
|
||||
residency by about one third on the staged plan, but null-RHI residency does not prove rendered
|
||||
enclosure. Keep coarse-first opt-in until that view is checked in a rendered owner-plan run.
|
||||
|
||||
The separate `voxel.StreamingStartupApplyBoost` switch stays default-off. At `1`, it temporarily allows
|
||||
12 visible mesh submissions / 14 ms while the initial desired set and walkable ring are incomplete,
|
||||
with a 30 second ceiling; at `0`, the authored budget is unchanged. On the matched legacy-order test,
|
||||
the boost changed full-set readiness from 17.412 s to 17.346 s and removed count-cap frames, but did
|
||||
not improve end-to-end residency. Keep the normal 4 / 2 ms guard for play.
|
||||
The separate `voxel.StreamingStartupApplyBoost` switch is default-on in the current code. At `1`, it
|
||||
temporarily allows up to 24 visible mesh submissions / 14 ms while the initial desired set and
|
||||
walkable ring are incomplete, with a 30 second ceiling; at `0`, the authored budget is unchanged.
|
||||
The historical matched S6 run changed full-set readiness from 17.412 s to 17.346 s and removed
|
||||
count-cap frames, but did not improve end-to-end residency. These current defaults are defined in
|
||||
`Source/VoxelForge/Private/VoxelWorld.cpp`; the S6 measurement remains historical context.
|
||||
|
||||
The S6 trace found the shared pool was not saturated: its 10-slot idle estimate was about 132
|
||||
slot-seconds on legacy order and 73 on gated coarse-first, while game-thread tile applies totaled only
|
||||
@@ -1556,8 +1557,8 @@ by its RAII guard when the worker lambda exits. This still counts queued and run
|
||||
results do not consume an admission slot. `voxel.StreamingLegacyPendingResultCap=1` restores the old
|
||||
`PendingTiles` accounting; the test harness's `NearReserveMode=Legacy` also sets
|
||||
`voxel.StreamingDynamicNearReserve=0`, reproducing the S6 path. `StreamingDynamicNearReserve` now
|
||||
defaults to `1`; `StreamingCoarseFirst` remains `0`, and the apply boost remains `0` in the matched
|
||||
measurement.
|
||||
defaults to `1`; `StreamingCoarseFirst` remains `0`. That historical S6 matched measurement disabled
|
||||
the startup apply boost (`voxel.StreamingStartupApplyBoost=0`); the current default is on (§8.27).
|
||||
|
||||
On the 2,578-key owner SurfaceWorld trace, the old-path control reached the full set in 17.527 s,
|
||||
walkable ring in 0.650 s, and full LOD0 in 2.281 s. Dynamic admission reached them in 7.780 s,
|
||||
|
||||
@@ -10,6 +10,7 @@ then wait for his results / pasted errors. Same for in-editor checks — ask for
|
||||
try to run anything.
|
||||
|
||||
## Read first (in order, load only what the task needs)
|
||||
- **[CURRENT-STATE.md](CURRENT-STATE.md) — current shipping snapshot and verification entry point.**
|
||||
- **[CODEMAP.md](CODEMAP.md) — ALWAYS.** Navigation: what it is, data-flow (§2), symbol→`file:line`
|
||||
index (§3), "I want to change X → go here" (§5), conventions & gotchas (§6). Trust **symbol names
|
||||
over line numbers** (lines drift).
|
||||
|
||||
@@ -388,7 +388,7 @@ casse cette arithmetique *structurellement* — `BeginPlay` loggue une **Error**
|
||||
| `AdvanceHeadlessDigMotionTest` / `RecordHeadlessDigMotionFrame` | 3060 / 3217 | Explicit `-voxel.TestDigMotion=1` repro: optional near digs, then repeats underfoot digs and samples `Generator->GetDensityAt` immediately after each edit, on its first subsequent simulated frame, and on every following observation frame. `-voxel.TestDigMotionFieldOverride=1` is a measurement-only prototype that clears `CurrentFloor` and switches CMC to falling when the sampled post-edit density is air; it is inactive by default. The test counts false support, false air and downward velocity beyond one gravity step. `-voxel.TestDigMotionUnderfootDigCount` is clamped to 1–100; `-voxel.TestDigMotionUnderfootIntervalSeconds=0` performs per-frame rapid edits. The accompanying gap audit is scoped to the dig session. |
|
||||
| `MaybeFinishHeadlessStreamingTest` | — | The existing `voxel.TestExitSeconds` diagnostic emits `VoxelForgeStreamingExitDiag` at most every 30 elapsed seconds, including threshold and early-return test flags; no output when the timer is unconfigured. |
|
||||
| `UpdateVisualStalenessAudit` | 5034 | Test-only camera-frustum coverage audit: tracks up to 24 real pre-skirt isosurface points per visible component, then counts frames where no visible tile component covers a previously visible, still-desired point (excluding edited bounds). `voxel.TestVisualGapInjectEarlyHide=1` hides a valid component for three frames and must be caught. `voxel.TestGapAuditLegacyHandoff=1` restores old handoff culling for A/B baselines. |
|
||||
| `ProcessPendingChunks` | 7435 | Drains `CriticalProcessQueue`, `EditedProcessQueue`, `NearProcessQueue`, then `ProcessQueue`; applies under the authored 4-mesh/2 ms budget during normal play and the bounded shared startup budget (12 meshes/14 ms) until the initial desired set and walkable ring are ready. `voxel.StreamingStartupApplyBoost=0` restores the authored budget. `startup_apply_config` traces the actor-read CVar and both budgets; `startup_apply_budget` records activation/end. Reads per-request cancellation before removing its map entry, traces stale/obsolete/aborted discards with epoch and ordinal, then applies via `ApplyTileResult`. Step 1 prequeue still releases here and emits `VFStep1UploadCorpusReleased`. |
|
||||
| `ProcessPendingChunks` | 7435 | Drains `CriticalProcessQueue`, `EditedProcessQueue`, `NearProcessQueue`, then `ProcessQueue`; applies under the authored 4-mesh/2 ms budget during normal play and the default-on bounded startup budget (24 meshes/14 ms, at most 30 s) until the initial desired set and walkable ring are ready. `voxel.StreamingStartupApplyBoost=0` restores the authored budget. `startup_apply_config` traces the actor-read CVar and both budgets; `startup_apply_budget` records activation/end. Reads per-request cancellation before removing its map entry, traces stale/obsolete/aborted discards with epoch and ordinal, then applies via `ApplyTileResult`. Step 1 prequeue still releases here and emits `VFStep1UploadCorpusReleased`. |
|
||||
| `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). |
|
||||
| `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). |
|
||||
@@ -396,7 +396,7 @@ casse cette arithmetique *structurellement* — `BeginPlay` loggue une **Error**
|
||||
| `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. Uses `PreEditDensityCache` when the tile is resident in the bounded local cache, replays the current DiffLayer over that exact procedural lattice, then meshes/applies inline so a carve is visible THIS frame. A cache miss uses normal generation; it captures the unedited lattice if the tile needs meshing, while uniform-tile proofs remain enabled. |
|
||||
| `PreEditDensityCache` / `VoxelForgePreEditDensityCache::BuildEditedGrid` | — | Per-world cache of the pawn's current L0 tile: exact 35³ pre-edit samples, max **171,500 bytes**. It is pruned when the pawn crosses chunk boundaries and cleared on `RegenerateAllChunks`. `BuildEditedGrid` snapshots touched diff chunks once and reproduces `GetDensityAt`'s final `pre-edit - EvaluateMods` operation before the per-call shared-grid mesher path. |
|
||||
| `UpdateChunksAroundPosition` / `GetGenerationAdmissionCount` | 10990 / 10971 | Shared Core/legacy scheduler. Builds the desired set and delta-culls leavers; admits the occupied/support tile plus collision-required LOD0 tiles in the pawn's 3×3 XY ring as `CollisionCritical`. S5 coarse-first admission stays opt-in (`voxel.StreamingCoarseFirst=1`). `voxel.StreamingDynamicNearReserve=1` is now the default: it keeps the authored near reserve while moving or until the stationary walkable ring is ready, then makes those slots available to coarse work. Admission counts submitted queued/running tasks, not completed results waiting in the MPSC result queues; `voxel.StreamingLegacyPendingResultCap=1` restores the old `PendingTiles`-based cap for the negative control. `PendingTiles` still lasts through game-thread apply for duplicate suppression and result bookkeeping. The clipmap stays non-overlapping and load-before-unload / `TransitionHold` reconciliation remains authoritative. Standalone has no mirror of this actor scheduler. §8.10 + §8.26–8.28. |
|
||||
| `UpdateChunksAroundPosition` / `GetGenerationAdmissionCount` | 10990 / 10971 | Shared Core/legacy scheduler. Builds the desired set and delta-culls leavers; admits the occupied/support tile plus collision-required LOD0 tiles in the pawn's 3×3 XY ring as `CollisionCritical`. S5 coarse-first admission stays opt-in (`voxel.StreamingCoarseFirst=1`). `voxel.StreamingDynamicNearReserve=1` is the default: it keeps the authored near reserve while moving or until the stationary walkable ring is ready, then makes those slots available to coarse work. Admission counts submitted queued/running tasks, not completed results waiting in the MPSC result queues; `voxel.StreamingLegacyPendingResultCap=1` restores the old `PendingTiles`-based cap for the negative control. `voxel.StreamingFarApplySupplement=1` is the default L121 lane: after the visible-apply cap, at most one far result is admitted when under half the apply-time budget and startup boost is inactive. `PendingTiles` still lasts through game-thread apply for duplicate suppression and result bookkeeping. The clipmap stays non-overlapping and load-before-unload / `TransitionHold` reconciliation remains authoritative. Standalone has no mirror of this actor scheduler. §8.10 + §8.26–8.28. |
|
||||
| `AdvanceHeadlessCollisionGateStressTest` / `ObserveHeadlessCollisionGateStressTest` / `MaybeFinishHeadlessCollisionGateStressTest` / `MeasureHeadlessCollisionGateStressTerrain` | — | Explicit `-voxel.TestCollisionGateStress=1` harness only. The normal spawn-height cvar offsets from the measured density surface; `voxel.TestCollisionGateStressDeepDrop=1` carves a process-local box shaft from above the top to just above the floor tile, extending along the post-landing route so the pawn does not run into the shaft sidewall, temporarily raises only the transient test DiffLayer brush-radius allowance for that fixture, removes only the local pawn's redundant streaming anchor for this isolated repro, proves every sample in two complete outside-ring level-0 tiles is air before seeding their known-empty status, prewarms the known-empty in-ring column, and asserts that the support walk reaches the in-layout floor outside the aligned level-0 render ring before the pawn drops. The log reports whether the fixed gate retains that otherwise out-of-ring target. Other streaming anchors remain active. The fixture rejects a target or floor outside the layout, a non-air empty-tile proof, or a floor inside the aligned level-0 ring. The `GetDensityAt` column at capsule feet defines the local air/solid surface; the deep-drop column crossing is cached after its first exact scan. 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; deep-drop gate-on also requires horizontal travel during a hold. Result logs include the terminal density column, first crossing below spawn height, hold count/time, and gated horizontal progress. |
|
||||
| `BuildDesiredTiles` | 6482 | Builds `DesiredSorted`+`DesiredStamped` (player clipmap shells), then adds streaming anchors and the pending player's collision-only support column before the leaver sweep. It orders from the true local pawn position and builds `CriticalDesiredTiles` for the unresolved support target, occupied/support and heading tiles. Also rebuilds `DesiredTransitionDescendants` (ancestor → desired descendants, including face/edge-touching replacements) for the reconcile pass. |
|
||||
| `CollisionGateDesiredTiles` / `RequestCollisionGateSupportTile` | 5062 | Retains an unresolved support target and up to three level-0 tiles below it across desired-set rebuilds. The additional keys are hidden collision-only tiles, queued ahead of ordinary streaming, and pruned once resolved or outside the active column. |
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# VoxelForge current state
|
||||
|
||||
As of 2026-10-31. L131 is on `assembler/world` as `8107196`; this L132 worktree is on `wip/l132-dispatch-truth`, starting at `ebae71b`. Current-state claims below point to their code or measured report.
|
||||
|
||||
## What ships today
|
||||
|
||||
- **Core:** evaluator version **227**. Unreal's `AVoxelWorld::bUseVoxelForgeCore` is opt-in and defaults to `false`; the legacy generator remains the default runtime path. [Core version](Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h), [runtime switch](Source/VoxelForge/Public/VoxelWorld.h), [L130 report](Standalone/Research/WorldAssemblerM3/Linux130/REPORT.md).
|
||||
- **Verified owner4 generator dispatch (L132):** SW binds `SurfaceWorld-native` because the owner export contains a matching `SurfaceWorld.Heightfield2D` v2 plan. FI binds `compiled-analytic/FloatingIslands` because its `FloatingIslands.ClusterField` v0 layout has no matching native island plan. TN and VS bind `compiled-analytic/TunnelNetwork` and `compiled-analytic/VerticalShafts`; their legacy recipe IDs provide neither a `NativeCaves`/`NativeLattice` graph recipe nor native field capability. The L132 fast-tier report prints the binding and generated tile-center share at each owner strate. [Dispatch](Standalone/Core/src/ShapeWorldInternal.h), [adapter recipe checks](Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h), [L132 report](Standalone/Research/WorldAssemblerM3/Linux132/REPORT.md).
|
||||
- **Passages:** continuous smooth swept tubes with a continuous floor profile. L125 stamps conservative route slabs and outside sentinels into the tile cache; exact route geometry is still evaluated. All 21,425 route samples passed the open-air walk check; the nine-station floor probe's maximum root offset was 7.884 mm. L130 sets the shared influence reach to **1.0533 m** (0.75 m shell + 0.21 m relief + 0.50 m smooth-max support / 6 + 0.01 m margin), converted once from metres to Core voxels for the proof, feature, fallback, and off-grid bounds. [L125](Standalone/Research/WorldAssemblerM3/Linux125/REPORT.md), [L130](Standalone/Research/WorldAssemblerM3/Linux130/REPORT.md).
|
||||
- **Streaming:** arrival uses a bounded startup apply burst: `voxel.StreamingStartupApplyBoost=1` by default, up to 24 visible meshes or 14 ms per frame for at most 30 seconds, until the initial desired set and walkable ring are ready. `voxel.StreamingFarApplySupplement=1` is also default-on: after the normal visible apply cap, at most one far result applies when under half the apply-time budget; it is inactive during the startup burst. Dynamic near reserve defaults on; coarse-first remains off; the legacy pending-result cap defaults off. The L165/L166 incremental desired-set path now defaults on (`voxel.StreamingIncrementalDesiredSet=1`): Core clipmap keys, overlay membership, and priority order update incrementally, with same-center Core keys cached. No separate overlay or priority incremental switches are required. Audits stay off (`StreamingIncrementalDesiredAuditEvery=0`, `StreamingIncrementalDesiredMaterializationAuditEvery=0`, mismatch injection `=0`). L157 early admission and apply-cap controls stay off (`StreamingFastTravelWaitForAllCriticalTiles=1`, `StreamingFastTravelApplyBudgetMilliseconds=0`). L160 ahead-first and same-center rebuild suppression stay off (`StreamingFastTravelForwardFirst=0`, `StreamingRebuildOnCoreRegionCompletion=1`). Its separate Core desired-cache switch stays off (`StreamingCoreDesiredCacheByCenter=0`); the new incremental path enables its own same-center Core cache. The far-apply lane is Unreal-only. [CVar source](Source/VoxelForge/Private/VoxelWorld.cpp), [L121 report](Standalone/Research/WorldAssemblerM3/Linux121/REPORT.md), [architecture](ARCHITECTURE.md).
|
||||
- **Determinism:** proven on Linux with Clang and GCC at W1/W8/W12. L131's unchanged-output comparison covers owner4's SW/FI/TN/VS views in 24 compiler/worker cases; the canonical owner4/owner2 recipe matrix also passes. Windows determinism is unverified. [L131 checks](Standalone/Research/WorldAssemblerM3/Linux131/REPORT.md), [v227 canonical hashes](Standalone/Tools/verify/baselines/owner-hash/canonical/).
|
||||
|
||||
## Current performance references
|
||||
|
||||
Headless medians in seconds, five interleaved L80-style measurements per version in each cell. Near-ready means the complete LOD0–2 set; whole-view is the full requested view. Values below are the edited L131 binary; the paired pre-cleanup comparison is in the [L131 report](Standalone/Research/WorldAssemblerM3/Linux131/REPORT.md) and [timing summary](Saved/Linux/l131/timing/summary.json).
|
||||
|
||||
| Stratum | Near-ready W8 / W12 | Whole-view W8 / W12 |
|
||||
|---|---:|---:|
|
||||
| S0 | 0.804 / 0.848 | 1.811 / 1.832 |
|
||||
| S1 | 1.014 / 1.002 | 1.993 / 1.886 |
|
||||
| S2 | 1.060 / 1.012 | 1.960 / 1.865 |
|
||||
| S3 | 0.917 / 0.884 | 1.887 / 1.782 |
|
||||
|
||||
Across 40 paired runs per metric, the mean relative change from pre-cleanup L130 was −0.22% near-ready (95% CI −1.02% to +0.57%) and −0.29% whole-view (−1.04% to +0.46%); neither indicates a measurable slowdown. The machine gate was load 0.81 and 99.2% idle. [L80 protocol](Standalone/Research/WorldAssemblerM3/Linux80/REPORT.md), [runner and raw runs](Standalone/Research/WorldAssemblerM3/Linux131/RunL131Timing.py).
|
||||
|
||||
The latest recorded Linux Unreal start-to-LOD0–2 values are L127's one run per stratum: S0 2.456 s, S1 2.231 s, S2 2.514 s, S3 2.221 s. L130 did not rerun UE startup or movement. Five L127 runs at 30 m/s, 12 s moving plus 5 s dwell, had median moving near coverage **40.22%**, far holes **3.46%**, tick p99 **10.80 ms**, and worst tick **13.27 ms**. After stopping, near coverage reached 100% and far holes reached 0%. These are the latest UE references, not v227 UE measurements. [L127 report](Standalone/Research/WorldAssemblerM3/Linux127/REPORT.md), [L127 movement summary](Saved/Linux/l127/ue/movement-pairs-summary.json), [L127 start summaries](Saved/Linux/l127/ue/merge/start-summary.json).
|
||||
|
||||
## Open gaps
|
||||
|
||||
- Multiplayer world lifecycle is unfinished: seed/layout replication, authoritative modification RPCs, and serialized diff replay for late join are still open. See [ARCHITECTURE §9.5 and §9.7](ARCHITECTURE.md).
|
||||
- Determinism has not been verified on Windows. Linux Clang/GCC agreement does not establish MSVC agreement.
|
||||
- NullRHI measures residency and apply timing, not the first presented pixel or rendered enclosure. L127 also found four low-contrast views among 23 otherwise verified captures. [L121](Standalone/Research/WorldAssemblerM3/Linux121/REPORT.md), [L127](Standalone/Research/WorldAssemblerM3/Linux127/REPORT.md).
|
||||
- UE startup/movement numbers above predate L130's v227 reach change; there is no L130/L131 UE movement comparison.
|
||||
|
||||
## How to verify
|
||||
|
||||
- Build standalone Clang or GCC with [`Standalone/BuildLinux.sh`](Standalone/BuildLinux.sh), for example `bash Standalone/BuildLinux.sh --compiler=clang++-19 --variant=l132-clang --fp-contract=off --jobs=4`. The script pins CPUs 0–11 and applies `prlimit --as=6442450944` itself.
|
||||
- Fast tier: `python3 Standalone/Tools/verify/verify.py fast --out Saved/Linux/l132 --overall-timeout 1740`. It includes the L132 four-pose owner density-dispatch report; per-run logs and the summary stay under `Saved/Linux/l132`.
|
||||
- Canonical output baselines are the v227 files in `Standalone/Tools/verify/baselines/owner-hash/canonical/`. L131 also provides reproducible pre-cleanup identity matrices for owner4 tile hashes and owner4/owner2 canonical hashes under `Standalone/Research/WorldAssemblerM3/Linux131/`.
|
||||
- Timing follows L80: quiet machine, no overlapping build, one warm-up per version, five interleaved runs per stratum and worker count, W8/W12. Each headless run uses `taskset -c 0-11`, `prlimit --as=6442450944`, and a 1,740 s timeout. See [`RunL131Timing.py`](Standalone/Research/WorldAssemblerM3/Linux131/RunL131Timing.py).
|
||||
- Linux host build: `/home/jahni/UnrealEngine/Engine/Build/BatchFiles/Linux/Build.sh VFHostEditor Linux Development -Project=/home/jahni/VFHost/VFHost.uproject -NoUBA -MaxParallelActions=4 -Log=/home/jahni/VoxelM/Plugins/VoxelForge/Saved/Linux/l131/ubt.log`. Run Unreal without `prlimit`.
|
||||
|
||||
## Closed experiments
|
||||
|
||||
These rejected speed/streaming proposals stay closed unless new evidence changes their measured failure. Reports and surviving patches are linked in the [L131 inventory](Standalone/Research/WorldAssemblerM3/Linux131/REPORT.md).
|
||||
|
||||
| Line | Result |
|
||||
|---|---|
|
||||
| L116 tunnel chord bound and z cache | Pair count fell 20.8%, but stayed 1.96× L109 and readiness was 15–30% slower. Patch: [`chord-bound-zcolumn-not-merged.patch`](Standalone/Research/WorldAssemblerM3/Linux116/patches/chord-bound-zcolumn-not-merged.patch), commit `89504ec`; see [report](Standalone/Research/WorldAssemblerM3/Linux116/REPORT.md). |
|
||||
| L118 pair pruning / deferred floor work | Pruned 17–18% of candidate pairs but remained 17.2–25.7% behind L109 near-ready. [Report](Standalone/Research/WorldAssemblerM3/Linux118/REPORT.md); patch: [`l118-designs.patch`](Standalone/Research/WorldAssemblerM3/Linux118/l118-designs.patch). |
|
||||
| L126 floor-call filters | Cut floor calls 20–23% without reducing pair counts; all eight cells missed L109. Candidate source was restored and no standalone patch was kept; see [report](Standalone/Research/WorldAssemblerM3/Linux126/REPORT.md) and commit `01a5094`. |
|
||||
| L128 half-open slab ownership | Ownership audit passed, but readiness was 14.8–28.0% behind L109 and moving coverage dropped. Report and patch `l128-slab-ownership.patch` are in archived commit `7022d02`. |
|
||||
| L129 2 m physical reach | v225's metre/Core-voxel bounds mismatch made the correctly bounded candidate do more work; it was slower and failed UE movement gates. Report and patch `l129-reach-2m.patch` are in archived commit `0d1140c`. |
|
||||
| L120 priority split / extra worker slots | Neither met coverage and tick gates; 10 slots reached a 16.67 ms worst tick against 15.43 ms baseline. [Report](Standalone/Research/WorldAssemblerM3/Linux120/REPORT.md); no production patch retained. |
|
||||
| L122 admission relaxation | Early admission missed p99 or far-hole gates; no behavior retained. [Report](Standalone/Research/WorldAssemblerM3/Linux122/REPORT.md); patch: [`l122-trace.patch`](Standalone/Research/WorldAssemblerM3/Linux122/l122-trace.patch). |
|
||||
| L123 lookahead / completion gate | Lookahead failed p99 and worsened near/far coverage; queue-only also failed p99. [Report](Standalone/Research/WorldAssemblerM3/Linux123/REPORT.md); patch: [`l123-queue-lookahead.patch`](Standalone/Research/WorldAssemblerM3/Linux123/l123-queue-lookahead.patch). |
|
||||
| L124 early LOD0 pages | Optimistic gain stayed below 100 ms except negative cases; no publication/scheduling implementation was made. [Report](Standalone/Research/WorldAssemblerM3/Linux124/REPORT.md). |
|
||||
@@ -0,0 +1,201 @@
|
||||
# L162 — Living Plane
|
||||
|
||||
The EGL/OpenGL 4.3 runner builds and passes its llvmpipe correctness and frame checks. The sandbox has no accessible DRM render node, so it did not run or report Intel GPU performance. The runner refuses `--device=gpu` here instead of falling back to software.
|
||||
|
||||
## Implementation
|
||||
|
||||
`LivingPlane.cpp` runs a ping-pong `RG32F` height/velocity field through a runtime-compiled GLSL compute shader. The damped wave update includes a weak foundation spring, a Gaussian sphere push, and an optional slow breathing force. The spring is needed because a persistent local push otherwise accelerates the zero-frequency height mode and sinks the whole plane. The sphere moves across the plane, stops, and bobs. A vertex-displaced mesh and lit sphere render to an EGL pbuffer-backed framebuffer and save as PNGs.
|
||||
|
||||
The timestep is selected from the largest 2D grid eigenfrequency:
|
||||
|
||||
`dt = 1.30 / sqrt(8*c*c/(dx*dx) + foundationStiffness)`
|
||||
|
||||
The resulting spectral step ratio is 1.30, below the explicit update's 2.0 stability limit. The runner warms the compute kernel for eight unmeasured dispatches, then records one `GL_TIME_ELAPSED` query per requested simulation step in `timing.json`.
|
||||
|
||||
The first-step equation audit also caught a GL state error during bring-up: frame rendering left the graphics program active before the next compute dispatch. `Dispatch` now binds the compute program before every step, so the audit fails if a frame silently stops simulation.
|
||||
|
||||
Device selection enumerates EGL devices and uses `EGL_EXT_platform_device` for an accessible Intel render node first. On this host, the known integrated device is Intel on `renderD128`. Automatic mode falls back to a surfaceless Mesa context with `LIBGL_ALWAYS_SOFTWARE=1`; explicit GPU mode fails when no accessible hardware node is present. EGL does not expose Vulkan's integrated/discrete enum, so Intel vendor/render-node identity is the selection heuristic.
|
||||
|
||||
The Unreal handoff is in [UNREAL-MAPPING.md](UNREAL-MAPPING.md). It keeps the prototype render-only and describes the density mapping if the surface later needs to affect voxel queries. No Unreal `Source/` files changed.
|
||||
|
||||
## Measurements and checks
|
||||
|
||||
- Built with `Experiments/LivingPlane/build.sh` using the installed EGL, desktop GL, and libpng development files; no shader compiler or package install was needed.
|
||||
- Final llvmpipe run: 128² field, 360 steps, `dt=9.56814 ms`, spectral ratio 1.30. The first-step equation audit and finite-field/dynamics audit passed. Final field range was −0.139533 m to +0.0366704 m. It wrote 16 128×128 PNGs and a JSON file containing 360 per-step query samples. The frames were inspected; the moving sphere and wake are visible.
|
||||
- Auto-selection smoke check: 32², 12 steps; selected llvmpipe and passed both audits. Breathing smoke check: 64², 180 steps with `--breathing=on`; passed both audits.
|
||||
- Negative device-selection control: `--device=gpu` returned an error because the sandbox has no accessible EGL DRM render node.
|
||||
- Existing fast verification tier passed: 278.9 seconds, ending in `SUMMARY PASS`. Its build and logs are under `Saved/Linux/l162/verify/` and `Saved/Linux/l162/`.
|
||||
- The llvmpipe `GL_TIME_ELAPSED` samples are preserved in JSON but are software-driver query values, not GPU timings. The requested hardware timing pass remains to be run outside the sandbox.
|
||||
- No Unreal build was run; no Unreal `Source/` files changed.
|
||||
|
||||
## Intel GPU pass
|
||||
|
||||
Run these from the plugin root as `jahni`. The frame run writes 512×512 PNGs to `Saved/Linux/l162/gpu-frames-512/`; the two timing runs write `timing.json` with one GPU query per step and skip PNG capture. They still read the field for the first-step and final-state correctness audits.
|
||||
Before the timing runs, check `uptime` and run them on a quiet machine (load below about 1.5, with no other build active).
|
||||
|
||||
```sh
|
||||
uptime
|
||||
taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l162/living-plane --device=gpu --size=512 --steps=1800 --capture-every=12 --out=Saved/Linux/l162/gpu-frames-512
|
||||
taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l162/living-plane --device=gpu --size=512 --steps=1800 --capture-every=0 --out=Saved/Linux/l162/gpu-timing-512
|
||||
taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l162/living-plane --device=gpu --size=1024 --steps=1800 --capture-every=0 --out=Saved/Linux/l162/gpu-timing-1024
|
||||
```
|
||||
|
||||
Encode the captured sequence as MP4 with:
|
||||
|
||||
```sh
|
||||
ffmpeg -y -framerate 30 -i Saved/Linux/l162/gpu-frames-512/frame_%06d.png -c:v libx264 -pix_fmt yuv420p Saved/Linux/l162/living-plane-512.mp4
|
||||
```
|
||||
|
||||
## Files and remaining risk
|
||||
|
||||
The only changed file outside `Experiments/LivingPlane/` is `.gitignore`, which now allows `Experiments/` and its contents. GPU timings and Intel render-node selection remain unverified until the commands above run on the host outside this sandbox.
|
||||
|
||||
## Intel GPU pass (run by the orchestrator outside the sandbox, 2026-11-02)
|
||||
|
||||
- The device was selected correctly: `Mesa Intel(R) Graphics (ADL GT2)`, OpenGL 4.6, `/dev/dri/renderD128`. 512²,
|
||||
1,800 steps, 151 frames; the equation audit passes. MP4: `Saved/Linux/l162/living-plane-512.mp4` (local).
|
||||
- **The `GL_TIME_ELAPSED` per-dispatch values (~0.0001 ms) are not credible** for 262k cells, so they aren't
|
||||
reported as timings.
|
||||
- Wall-time difference between 1,800 and 200 steps, divided by the 1,600 steps (start-up cancels out; this includes
|
||||
submission, so it's an upper bound): **0.080 ms/step at 512²** and **0.292 ms/step at 1024²**.
|
||||
- Visual read: the effect is very subtle in the current render (a slight dip under the sphere, faint rings). It needs
|
||||
a larger push amplitude, grazing light or height colouring, and a closer camera to read.
|
||||
|
||||
## L163 — readable, living plane
|
||||
|
||||
The plane now reads as rolling terrain, and the objects leave visible troughs and wakes. `alive` adds travelling
|
||||
breathing and a viscoelastic imprint that remains after lift-off. The simulation still runs in one GPU dispatch per
|
||||
step; CPU work only supplies the two object states.
|
||||
|
||||
`LivingPlane.cpp` now keeps the dynamic displacement, velocity, and imprint in an `RGBA32F` ping-pong field. The
|
||||
surface shader adds broad static terrain relief, calculates normals from relief plus displacement, and uses grazing
|
||||
light, elevation colours, slope tint, and 7.5 cm contour bands. The camera follows the first sphere at a closer angle.
|
||||
The wake scene rolls the first sphere, pauses and wobbles it, lifts it, then sends a second sphere through the wake.
|
||||
The alive scene runs that same sequence with low-frequency travelling swells and a decaying contact imprint. Its
|
||||
defaults are breathing strength 4.8 m/s², imprint capture 0.15 m/s, recovery time 6 s, and maximum imprint depth
|
||||
0.34 m. These values have command-line overrides.
|
||||
|
||||
The timer query check found no early read or reuse in L162: it allocated a unique query per step, called `glFinish`,
|
||||
then read every result. The saved Intel/Mesa 25.0.7 samples are 0 or 52 ns for most dispatches (512² median 0,
|
||||
p95 52 ns; largest sample 17.552 µs), which cannot be a credible per-dispatch cost. The old usage did not reuse
|
||||
query objects, so lifetime is not the cause; the saved data point to Intel/Mesa's timer-query reporting or timing
|
||||
scope, but the sandbox cannot isolate which. L163 uses a 64-query ring, reads each query before reusing its slot,
|
||||
checks availability after `glFinish`, and records `GL_QUERY_COUNTER_BITS`. On llvmpipe the availability check passed
|
||||
with 64 counter bits and zero waits. The Intel query result still needs the GPU diagnostic command below.
|
||||
|
||||
### Measurements and checks
|
||||
|
||||
- llvmpipe wake: 128², 600 steps (`dt=9.56814 ms`, 5.741 s simulation window), 151 frames at 30 fps. First-step
|
||||
equation audit and timer-query lifetime check passed; final displacement range was −0.177488 m to +0.104854 m.
|
||||
- llvmpipe alive: same grid, steps, and window; equation audit and timer-query lifetime check passed. Final
|
||||
displacement range was −0.200437 m to +0.115904 m, with a −0.111667 m stored imprint.
|
||||
- llvmpipe cost: 512², 1,800 steps, `--queries=off`, no frame capture: **0.767473 ms/step** in one timed
|
||||
simulation-loop window. `timing.json` includes dispatch submission, the step-0 equation readback, and final
|
||||
`glFinish`, but excludes setup and frame capture. This is a software-renderer measurement, not the Intel GPU cost.
|
||||
- Both five-second llvmpipe previews are in `Saved/Linux/l163/llvmpipe-wake.mp4` and
|
||||
`Saved/Linux/l163/llvmpipe-alive.mp4`. They are 128² review previews; use the GPU commands below for the owner
|
||||
videos.
|
||||
- Equation-audit negative control: a temporary copy with its expected first-step height forced to zero exited FAIL
|
||||
at actual −0.0488777 m. The final source passes that same check.
|
||||
- The fast verification tier passed in 278.5 s. Build and logs are under `Saved/Linux/l163/verify/`.
|
||||
- No Unreal `Source/` files changed and no Unreal build ran.
|
||||
|
||||
### Intel GPU run
|
||||
|
||||
Run from the plugin root as `jahni`. First build the runner. For timed runs, check `uptime` and keep the machine
|
||||
quiet. The wall-time field is valid only when `--capture-every=0`; use `--queries=off` for the step-cost measurement.
|
||||
|
||||
```sh
|
||||
bash Experiments/LivingPlane/build.sh
|
||||
uptime
|
||||
taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l163/living-plane --device=gpu --scene=wake --size=512 --steps=2400 --capture-every=16 --queries=off --out=Saved/Linux/l163/gpu-wake-512
|
||||
taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l163/living-plane --device=gpu --scene=alive --size=512 --steps=2400 --capture-every=16 --queries=off --out=Saved/Linux/l163/gpu-alive-512
|
||||
ffmpeg -y -framerate 30 -i Saved/Linux/l163/gpu-wake-512/frame_%06d.png -c:v libx264 -pix_fmt yuv420p Saved/Linux/l163/living-plane-wake-512.mp4
|
||||
ffmpeg -y -framerate 30 -i Saved/Linux/l163/gpu-alive-512/frame_%06d.png -c:v libx264 -pix_fmt yuv420p Saved/Linux/l163/living-plane-alive-512.mp4
|
||||
taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l163/living-plane --device=gpu --scene=alive --size=512 --steps=1800 --capture-every=0 --queries=off --out=Saved/Linux/l163/gpu-timing-512
|
||||
taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l163/living-plane --device=gpu --scene=alive --size=512 --steps=1800 --capture-every=0 --queries=on --out=Saved/Linux/l163/gpu-query-check-512
|
||||
```
|
||||
|
||||
`gpu-timing-512/timing.json` reports the 512² batch wall time per step, including dispatch submission and completion.
|
||||
`gpu-query-check-512/timing.json` contains query counter bits, waits before slot reuse, availability status, and raw
|
||||
per-step query samples. The sandbox has no accessible Intel DRM node, so the current Intel step cost and new query
|
||||
diagnostic remain unmeasured here.
|
||||
|
||||
### L163 Intel GPU pass (run by the orchestrator, 2026-11-02)
|
||||
|
||||
- Intel ADL GT2 (Mesa 25.0.7, `renderD128`): wake and alive scenes, 512², 2,400 steps, 151 frames each; equation
|
||||
audits PASS. Videos (local): `Saved/Linux/l163/living-plane-wake-512.mp4`, `living-plane-alive-512.mp4`.
|
||||
- Step cost, alive scene, 512², 1,800 steps, no capture: **0.172 ms/step** with queries off, 0.152 ms/step with
|
||||
queries on (batch wall time per step).
|
||||
- The Intel `GL_TIME_ELAPSED` median is still 0 with 64 counter bits and the ring/availability checks passing. That
|
||||
looks like a Mesa/Intel reporting issue; use wall time.
|
||||
- **Open issue:** the same scene with the same steps gave different final fields (min −0.637 m with queries off vs
|
||||
−0.327 m with queries on). Something depends on more than the step index (e.g. wall-clock time); investigate.
|
||||
|
||||
## L164 — repeatable field readback and UE runtime actor
|
||||
|
||||
**Result:** the repeatability check passes on llvmpipe, and the new `VoxelForgeLivingPlane` runtime module compiles
|
||||
in the Linux UE 5.7 host. The surface actor is independent of VoxelForge generation and sends no field readback to
|
||||
the CPU.
|
||||
|
||||
### Repeatability finding
|
||||
|
||||
`uTime` is `Step * dt`, and `GetBodiesAt` receives that same value. Neither the object schedule nor the breathing
|
||||
force uses wall time or a rendered-frame counter. Every compute step already issued an image-access barrier before
|
||||
the next ping-pong image read. The defect was on the diagnostic side: the step-zero and final `glGetTexImage`
|
||||
readbacks followed shader image writes without `GL_TEXTURE_UPDATE_BARRIER_BIT`. The Khronos OpenGL specification
|
||||
requires that barrier for `GetTexImage` to observe earlier shader writes ([§7.12](https://registry.khronos.org/OpenGL/specs/gl/glspec44.compatibility.pdf)). Query/capture pacing could therefore affect the reported field values without changing any simulation input. The barrier is now issued before both readbacks.
|
||||
|
||||
`check_repeatability.py` runs the same 512² alive scene for 1,800 steps twice: queries off with no captures, then
|
||||
queries on with captures every 900 steps. The checksum covers every final RGBA32F field value using canonical
|
||||
little-endian float bytes.
|
||||
|
||||
- llvmpipe, queries off/capture off: field range −0.637382 m to +0.109308 m; checksum
|
||||
`1ba08f5dafc01f82`.
|
||||
- llvmpipe, queries on/capture every 900: the same range and checksum; equation and timer-query audits pass; three
|
||||
frames written.
|
||||
- Negative control: changing one saved checksum made the comparison exit 1 with `FAIL field checksum mismatch`.
|
||||
- The llvmpipe runs were each one 1,800-step simulation-loop window. Logs and JSON are under
|
||||
`Saved/Linux/l164/repeatability/`.
|
||||
|
||||
The Intel run is not available in this sandbox. The owner should repeat the checksum comparison on the GPU to
|
||||
confirm that the missing transfer-readback barrier accounts for the earlier Intel min mismatch:
|
||||
|
||||
```sh
|
||||
python3 Experiments/LivingPlane/check_repeatability.py --device=gpu --out=Saved/Linux/l164/gpu-repeatability
|
||||
```
|
||||
|
||||
### Unreal implementation and checks
|
||||
|
||||
The plugin descriptor adds a separate `VoxelForgeLivingPlane` runtime module. Its RDG global shader is in
|
||||
`Shaders/LivingPlane.usf`; module startup maps `/Plugin/VoxelForgeLivingPlane` to the plugin shader directory. The
|
||||
actor owns two UAV-capable RGBA32F render targets, advances them at the stable fixed step, and supplies the current
|
||||
texture to a movable plane mesh material. Its Blueprint/C++ object input includes world position, radius, velocity,
|
||||
base push, and active state. The CPU converts those values to plane-local metres and uploads uniforms; it does not
|
||||
read the field back. The ignore rules now include `Shaders/` so that the shader ships in the worktree diff.
|
||||
`Experiments/LivingPlane/UE-SETUP.md` gives level setup, the full material graph, and object driving steps.
|
||||
|
||||
- Linux UE 5.7 `VFHostEditor Linux Development`: **PASS**, final incremental build compiled
|
||||
`VoxelForgeLivingPlaneActor.cpp` and linked `libUnrealEditor-VoxelForgeLivingPlane.so`. Command used
|
||||
`-NoUBA -MaxParallelActions=4`, `taskset -c 0-11`, and no `prlimit`. Log:
|
||||
`Saved/Linux/l164/ue-build-final.log`. The initial attempt exposed an incorrect RDG texture registration overload;
|
||||
it had already compiled `VoxelForgeLivingPlaneModule.cpp` and the generated module code. The corrected final
|
||||
build passed.
|
||||
- The host `PluginDeps/VoxelForge/Source` and `.uplugin` links were temporarily pointed at this worktree for the
|
||||
build and restored to `/home/jahni/VoxelM/Plugins/VoxelForge` afterward. The temporary shader link was removed.
|
||||
- Standalone build, final repeatability comparison, checksum-mismatch negative control, plugin JSON parse, and
|
||||
`git diff --check` passed.
|
||||
- Not run: fast generation verification (generation sources are untouched), Intel GPU checksum comparison, or a UE
|
||||
editor render/shader-runtime check. The Linux UBT build verifies C++ and module integration, not first-use shader
|
||||
compilation or material appearance.
|
||||
|
||||
New Unreal source and shader files for the owner's Windows build:
|
||||
|
||||
- `Source/VoxelForgeLivingPlane/VoxelForgeLivingPlane.Build.cs`
|
||||
- `Source/VoxelForgeLivingPlane/Public/VoxelForgeLivingPlaneActor.h`
|
||||
- `Source/VoxelForgeLivingPlane/Private/VoxelForgeLivingPlaneActor.cpp`
|
||||
- `Source/VoxelForgeLivingPlane/Private/VoxelForgeLivingPlaneModule.cpp`
|
||||
- `Shaders/LivingPlane.usf`
|
||||
|
||||
No VoxelForge generation module files changed. Remaining risks are the Intel checksum confirmation and first-use UE
|
||||
shader/material rendering, which require the owner's GPU/editor run.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Living Plane in Unreal Engine 5.7
|
||||
|
||||
`VoxelForgeLivingPlane` is a separate runtime module. It does not call the VoxelForge terrain generators and it does not read the simulated texture back to the CPU. The actor owns two transient `RGBA32f` render targets and advances them with an RDG compute pass. The CPU sends object state and fixed-step uniforms only.
|
||||
|
||||
## Add it to a level
|
||||
|
||||
1. Enable the VoxelForge plugin and restart the editor so the `VoxelForgeLivingPlane` runtime module and its shader mapping load.
|
||||
2. Create a material named `M_LivingPlane` using the graph below.
|
||||
3. Place `VoxelForgeLivingPlaneActor` in the level. Assign `M_LivingPlane` to **Plane Material**.
|
||||
4. Keep the actor on the world XY plane with unit actor scale. `Plane Length Meters` controls the mesh width and simulation width; the built-in plane mesh is scaled to that size.
|
||||
5. The actor creates and clears its two GPU state targets on `BeginPlay`. The mesh has collision disabled; this surface is render-only and does not change VoxelForge density or collision queries.
|
||||
|
||||
## Material graph
|
||||
|
||||
Use a Surface, Opaque material. The actor binds these parameters on its dynamic material instance:
|
||||
|
||||
- `LivingField`: Texture Object Parameter. The texture is the current RGBA32f simulation state; **R** is displacement in metres.
|
||||
- `PlaneLengthMeters`: Scalar Parameter.
|
||||
- `FieldSize`: Scalar Parameter, equal to the render target width and height.
|
||||
|
||||
For World Position Offset:
|
||||
|
||||
1. Read `LivingField` at `TextureCoordinate[0]` with a Texture Sample.
|
||||
2. Multiply its **R** output by `100` to convert metres to Unreal centimetres.
|
||||
3. Append `(0, 0)` to that value, making `(0, 0, heightCm)`, and connect it to **World Position Offset**.
|
||||
|
||||
For the displaced normal, sample the height at four UV offsets using the same texture object:
|
||||
|
||||
- Left/right UV: `TexCoord + (-1/FieldSize, 0)` and `TexCoord + (1/FieldSize, 0)`.
|
||||
- Down/up UV: `TexCoord + (0, -1/FieldSize)` and `TexCoord + (0, 1/FieldSize)`.
|
||||
- `dHdx = (Right.R - Left.R) * FieldSize / (2 * PlaneLengthMeters)`.
|
||||
- `dHdy = (Up.R - Down.R) * FieldSize / (2 * PlaneLengthMeters)`.
|
||||
- Make and normalize the tangent-space vector `(-dHdx, -dHdy, 1)`, then connect it to **Normal**.
|
||||
|
||||
The built-in plane's U/V tangent directions align with local X/Y. Use a neutral base colour and roughness around `0.7` to start. The graph assumes a horizontal plane; add a local-to-world transform if the plane needs arbitrary rotation.
|
||||
|
||||
## Drive objects
|
||||
|
||||
`ObjectInputs` has two slots. Each `FVFLivingPlaneObjectInput` contains world position in centimetres, world velocity in centimetres per second, radius in centimetres, base push acceleration in metres per second squared, and an active flag. Blueprint can use **Set Object State** or set the array entry; C++ can call the same method. Set the object's center about one radius above the plane while it contacts it, and mark the slot inactive after lift-off.
|
||||
|
||||
The compute shader uses the radius to scale the Gaussian contact footprint. The original wave equation keeps the L163 wave speed, damping, foundation spring, travelling breathing force, and recovering imprint. The base push acceleration supplies the object force; lateral speed adds `0.25 m/s²` per `1 m/s`, and downward speed adds `0.5 m/s²` per `1 m/s`. Upward velocity does not add force. Object positions and velocities are transformed into the plane's local frame and converted from centimetres to metres before upload.
|
||||
|
||||
Each step uses `dt = 1.30 / sqrt(8*c*c/(dx*dx) + foundationStiffness)`, with `dx = PlaneLengthMeters / FieldSize`. Simulation time passed to breathing is the fixed step index times `dt`. Game-thread delta time only determines how many fixed steps are queued; no GPU field data is read back in the normal path.
|
||||
|
||||
## Build files
|
||||
|
||||
The owner's Windows build needs every added source and shader file:
|
||||
|
||||
- `Source/VoxelForgeLivingPlane/VoxelForgeLivingPlane.Build.cs`
|
||||
- `Source/VoxelForgeLivingPlane/Public/VoxelForgeLivingPlaneActor.h`
|
||||
- `Source/VoxelForgeLivingPlane/Private/VoxelForgeLivingPlaneActor.cpp`
|
||||
- `Source/VoxelForgeLivingPlane/Private/VoxelForgeLivingPlaneModule.cpp`
|
||||
- `Shaders/LivingPlane.usf`
|
||||
|
||||
The `VoxelForge.uplugin` runtime-module entry must ship with them.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Unreal mapping note
|
||||
|
||||
The prototype stores height, vertical velocity, a recovering imprint, and a spare
|
||||
channel in a pair of `RGBA32F` textures. A world-owned Unreal component can keep
|
||||
equivalent transient render graph textures and advance them with a compute pass. It
|
||||
should own simulation time and clear or restore its buffers when the world is recreated.
|
||||
|
||||
The sphere path becomes a world-space interaction event: position, contact radius,
|
||||
and impulse strength are supplied to the compute pass. For multiplayer or deterministic
|
||||
replay, replicate the compact events and simulation tick, or record the resulting field;
|
||||
do not assume that independent GPU floating-point runs will produce byte-identical
|
||||
textures.
|
||||
|
||||
The rendered top surface can remain a render-only displaced mesh. If it must participate
|
||||
in voxel collision or queries, its height must also reach the density evaluator. For a
|
||||
height `h(x,y)`, the plugin's density convention is negative inside solid and positive
|
||||
in air, so the plane's signed density is `z - h(x,y)`. Keep this calculation in metres
|
||||
until the existing voxel/Core boundary converts with the active voxel size (25 cm by
|
||||
default); do not mix metre distances and voxel counts in the wave solver.
|
||||
|
||||
The current sample is an isolated visual field. Wiring this into `AVoxelWorld` would
|
||||
need an explicit owner for simulation lifetime, collision/update cadence, save/load,
|
||||
and the replication policy before it could affect shipped terrain.
|
||||
|
||||
## L164 render-only Unreal implementation
|
||||
|
||||
`Source/VoxelForgeLivingPlane/` now implements this render-only path as a separate
|
||||
runtime module. `AVoxelForgeLivingPlaneActor` owns two UAV-capable RGBA32F render
|
||||
targets, advances the wave, breathing, and imprint equations with an RDG global shader,
|
||||
and binds the current texture to its plane material. Blueprint/C++ supplies object
|
||||
position, radius, velocity, push, and active state. The actor does not call the
|
||||
generation modules and does not read the field back. Level setup and the material graph
|
||||
are in [UE-SETUP.md](UE-SETUP.md).
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PLUGIN_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
OUTPUT_DIR="${PLUGIN_ROOT}/Saved/Linux/l164"
|
||||
CXX="${CXX:-g++}"
|
||||
|
||||
command -v "${CXX}" >/dev/null || { echo "C++ compiler not found: ${CXX}" >&2; exit 2; }
|
||||
pkg-config --exists egl gl libpng || {
|
||||
echo "EGL, desktop OpenGL, and libpng development files are required" >&2
|
||||
exit 2
|
||||
}
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
read -r -a GRAPHICS_FLAGS <<<"$(pkg-config --cflags --libs egl gl libpng)"
|
||||
|
||||
taskset -c 0-11 prlimit --as=6442450944 -- \
|
||||
"${CXX}" -std=c++20 -O2 -g -Wall -Wextra -Wpedantic \
|
||||
"${SCRIPT_DIR}/LivingPlane.cpp" -o "${OUTPUT_DIR}/living-plane" \
|
||||
"${GRAPHICS_FLAGS[@]}"
|
||||
|
||||
echo "built ${OUTPUT_DIR}/living-plane"
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare final field checksums across different query and capture settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
PLUGIN_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def read_checksum(path: pathlib.Path) -> str:
|
||||
try:
|
||||
with path.open(encoding="utf-8") as source:
|
||||
result = json.load(source)
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise RuntimeError(f"could not read {path}: {error}") from error
|
||||
checksum = result.get("field_checksum_fnv1a64")
|
||||
if not isinstance(checksum, str) or len(checksum) != 16:
|
||||
raise RuntimeError(f"{path} has no 16-digit field checksum")
|
||||
return checksum
|
||||
|
||||
|
||||
def compare(left: pathlib.Path, right: pathlib.Path) -> bool:
|
||||
left_checksum = read_checksum(left)
|
||||
right_checksum = read_checksum(right)
|
||||
if left_checksum != right_checksum:
|
||||
print(
|
||||
"FAIL field checksum mismatch: "
|
||||
f"{left}={left_checksum}, {right}={right_checksum}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
print(f"PASS field checksum matches: {left_checksum}")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--binary",
|
||||
type=pathlib.Path,
|
||||
default=PLUGIN_ROOT / "Saved/Linux/l164/living-plane",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
type=pathlib.Path,
|
||||
default=PLUGIN_ROOT / "Saved/Linux/l164/repeatability",
|
||||
)
|
||||
parser.add_argument("--device", choices=("auto", "cpu", "gpu"), default="cpu")
|
||||
parser.add_argument("--size", type=int, default=512)
|
||||
parser.add_argument("--steps", type=int, default=1800)
|
||||
parser.add_argument(
|
||||
"--compare-only",
|
||||
nargs=2,
|
||||
type=pathlib.Path,
|
||||
metavar=("LEFT_JSON", "RIGHT_JSON"),
|
||||
help="compare existing timing.json files; used to exercise the failure path",
|
||||
)
|
||||
options = parser.parse_args()
|
||||
|
||||
if options.compare_only:
|
||||
return 0 if compare(*options.compare_only) else 1
|
||||
|
||||
if not options.binary.is_file():
|
||||
parser.error(f"binary not found: {options.binary}; run Experiments/LivingPlane/build.sh")
|
||||
|
||||
options.out.mkdir(parents=True, exist_ok=True)
|
||||
off_dir = options.out / "queries-off_capture-off"
|
||||
on_dir = options.out / "queries-on_capture-every-900"
|
||||
for directory in (off_dir, on_dir):
|
||||
if directory.exists():
|
||||
shutil.rmtree(directory)
|
||||
|
||||
common = [
|
||||
f"--device={options.device}",
|
||||
"--scene=alive",
|
||||
f"--size={options.size}",
|
||||
f"--steps={options.steps}",
|
||||
]
|
||||
runs = (
|
||||
("--capture-every=0", "--queries=off", off_dir),
|
||||
("--capture-every=900", "--queries=on", on_dir),
|
||||
)
|
||||
for capture, queries, output in runs:
|
||||
command = [
|
||||
"taskset",
|
||||
"-c",
|
||||
"0-11",
|
||||
"prlimit",
|
||||
"--as=6442450944",
|
||||
"--",
|
||||
"timeout",
|
||||
"1740s",
|
||||
str(options.binary.resolve()),
|
||||
*common,
|
||||
capture,
|
||||
queries,
|
||||
f"--out={output}",
|
||||
]
|
||||
print("RUN " + " ".join(command), flush=True)
|
||||
log_path = options.out / f"{output.name}.log"
|
||||
with log_path.open("w", encoding="utf-8") as log_file:
|
||||
try:
|
||||
subprocess.run(
|
||||
command,
|
||||
cwd=PLUGIN_ROOT,
|
||||
check=True,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
print(log_path.read_text(encoding="utf-8"), file=sys.stderr)
|
||||
raise
|
||||
print(log_path.read_text(encoding="utf-8").rstrip())
|
||||
|
||||
left = off_dir / "timing.json"
|
||||
right = on_dir / "timing.json"
|
||||
return 0 if compare(left, right) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||
print(f"FAIL repeatability check: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,85 @@
|
||||
Texture2D<float4> SourceState;
|
||||
RWTexture2D<float4> TargetState;
|
||||
|
||||
int2 GridSize;
|
||||
float Dt;
|
||||
float Dx;
|
||||
float PlaneLength;
|
||||
float WaveSpeed;
|
||||
float Damping;
|
||||
float FoundationStiffness;
|
||||
float TimeSeconds;
|
||||
float2 BreathingSettings;
|
||||
float4 ImprintSettings;
|
||||
float4 ObjectPositionRadius[2];
|
||||
float4 ObjectVelocityPush[2];
|
||||
|
||||
[numthreads(8, 8, 1)]
|
||||
void MainCS(uint3 DispatchThreadId : SV_DispatchThreadID)
|
||||
{
|
||||
int2 Pixel = int2(DispatchThreadId.xy);
|
||||
if (Pixel.x >= GridSize.x || Pixel.y >= GridSize.y)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int2 Lo = int2(0, 0);
|
||||
int2 Hi = GridSize - 1;
|
||||
float4 State = SourceState.Load(int3(Pixel, 0));
|
||||
float Height = State.r;
|
||||
float Velocity = State.g;
|
||||
float Imprint = State.b;
|
||||
float Left = SourceState.Load(int3(max(Pixel + int2(-1, 0), Lo), 0)).r;
|
||||
float Right = SourceState.Load(int3(min(Pixel + int2(1, 0), Hi), 0)).r;
|
||||
float Down = SourceState.Load(int3(max(Pixel + int2(0, -1), Lo), 0)).r;
|
||||
float Up = SourceState.Load(int3(min(Pixel + int2(0, 1), Hi), 0)).r;
|
||||
float Laplacian = (Left + Right + Down + Up - 4.0 * Height) / max(Dx * Dx, 1.0e-8);
|
||||
float2 World = (float2(Pixel) + 0.5) * Dx - PlaneLength * 0.5;
|
||||
|
||||
float Interaction = 0.0;
|
||||
float Contact = 0.0;
|
||||
for (int ObjectIndex = 0; ObjectIndex < 2; ++ObjectIndex)
|
||||
{
|
||||
float4 PositionRadius = ObjectPositionRadius[ObjectIndex];
|
||||
if (PositionRadius.w <= 0.0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float4 VelocityPush = ObjectVelocityPush[ObjectIndex];
|
||||
float Radius = PositionRadius.w;
|
||||
float Sigma = max(Radius * (0.58 / 0.43), Dx);
|
||||
float2 Offset = World - PositionRadius.xy;
|
||||
float Weight = exp(-dot(Offset, Offset) / max(2.0 * Sigma * Sigma, 1.0e-8));
|
||||
float HeightAboveContact = max(PositionRadius.z - Radius, 0.0);
|
||||
float ContactHeight = 1.0 - smoothstep(0.0, max(Radius, Dx), HeightAboveContact);
|
||||
float ImpactSpeed = length(VelocityPush.xy) * 0.25 + max(-VelocityPush.z, 0.0) * 0.5;
|
||||
float Push = max(VelocityPush.w, 0.0) + ImpactSpeed;
|
||||
Interaction -= Push * Weight * ContactHeight;
|
||||
if (Push > 0.0)
|
||||
{
|
||||
Contact = max(Contact, Weight * ContactHeight);
|
||||
}
|
||||
}
|
||||
|
||||
float Breathing = 0.0;
|
||||
if (BreathingSettings.x != 0.0)
|
||||
{
|
||||
float Swell = sin(World.x * 0.48 - TimeSeconds * 0.22)
|
||||
* cos(World.y * 0.42 + TimeSeconds * 0.16);
|
||||
Breathing = BreathingSettings.y * Swell * sin(TimeSeconds * 0.48);
|
||||
}
|
||||
|
||||
float NextImprint = Imprint;
|
||||
if (ImprintSettings.x != 0.0)
|
||||
{
|
||||
NextImprint = clamp(Imprint * exp(-Dt / max(ImprintSettings.z, 1.0e-4))
|
||||
- Contact * ImprintSettings.y * Dt, -ImprintSettings.w, 0.0);
|
||||
}
|
||||
float FoundationTarget = ImprintSettings.x != 0.0 ? Imprint : 0.0;
|
||||
float Acceleration = WaveSpeed * WaveSpeed * Laplacian
|
||||
- FoundationStiffness * (Height - FoundationTarget) + Interaction + Breathing;
|
||||
float NextVelocity = (Velocity + Acceleration * Dt) * exp(-Damping * Dt);
|
||||
float NextHeight = Height + NextVelocity * Dt;
|
||||
TargetState[Pixel] = float4(NextHeight, NextVelocity, NextImprint, 0.0);
|
||||
}
|
||||
@@ -322,6 +322,108 @@ bool FVoxelForgeClipmapNoOverlapTest::RunTest(const FString& Parameters)
|
||||
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);
|
||||
|
||||
static const FIntVector DeltaCenters[] = {
|
||||
FIntVector(-5, -2, -1), FIntVector(-3, 1, 2), FIntVector(-1, -3, 0),
|
||||
FIntVector(0, 0, 0), FIntVector(1, 3, -2), FIntVector(3, -1, 1),
|
||||
FIntVector(5, 2, -3),
|
||||
};
|
||||
static const FIntVector DeltaSteps[] = {
|
||||
FIntVector(-1, 0, 0), FIntVector(1, 0, 0),
|
||||
FIntVector(0, -1, 0), FIntVector(0, 1, 0),
|
||||
FIntVector(0, 0, -1), FIntVector(0, 0, 1),
|
||||
};
|
||||
int32 DeltaCases = 0;
|
||||
int32 DeltaFailures = 0;
|
||||
FString FirstDeltaFailure;
|
||||
for (const FIntVector& Center : DeltaCenters)
|
||||
for (const FSweepSettings& Sweep : SweepSettings)
|
||||
for (int32 ClampCase = 0; ClampCase < 3; ++ClampCase)
|
||||
{
|
||||
FParameters P;
|
||||
P.ClipRadius = Sweep.Radius;
|
||||
P.MaxClipLevel = Sweep.MaxLevel;
|
||||
P.RenderDistanceChunks = Sweep.RenderDistance;
|
||||
|
||||
const auto GetVerticalBounds = [&](int32 CenterZ, int32& OutMinZ, int32& OutMaxZ)
|
||||
{
|
||||
OutMinZ = MIN_int32;
|
||||
OutMaxZ = MAX_int32;
|
||||
if (ClampCase == 1)
|
||||
{
|
||||
OutMinZ = CenterZ - 2;
|
||||
OutMaxZ = CenterZ + 3;
|
||||
}
|
||||
else if (ClampCase == 2)
|
||||
{
|
||||
const int32 Band = VoxelClipmapDesiredTiles::FloorDiv(
|
||||
CenterZ, 1 << Sweep.MaxLevel);
|
||||
const int64 BandStart = static_cast<int64>(Band)
|
||||
* (int64{1} << static_cast<uint32>(Sweep.MaxLevel));
|
||||
OutMinZ = static_cast<int32>(BandStart - 1);
|
||||
OutMaxZ = OutMinZ + 6;
|
||||
}
|
||||
};
|
||||
int32 PreviousMinZ = 0;
|
||||
int32 PreviousMaxZ = 0;
|
||||
GetVerticalBounds(Center.Z, PreviousMinZ, PreviousMaxZ);
|
||||
TArray<FVoxelTileKey> PreviousTiles;
|
||||
VoxelClipmapDesiredTiles::Build(Center, PreviousMinZ, PreviousMaxZ, P, PreviousTiles);
|
||||
|
||||
for (const FIntVector& Step : DeltaSteps)
|
||||
{
|
||||
const FIntVector NextCenter = Center + Step;
|
||||
int32 NextMinZ = 0;
|
||||
int32 NextMaxZ = 0;
|
||||
GetVerticalBounds(NextCenter.Z, NextMinZ, NextMaxZ);
|
||||
TArray<FVoxelTileKey> Entering;
|
||||
TArray<FVoxelTileKey> Leaving;
|
||||
VoxelClipmapDesiredTiles::BuildDelta(
|
||||
Center, PreviousMinZ, PreviousMaxZ,
|
||||
NextCenter, NextMinZ, NextMaxZ, P, Entering, Leaving);
|
||||
|
||||
TSet<FVoxelTileKey> Updated;
|
||||
for (const FVoxelTileKey& Tile : PreviousTiles) Updated.Add(Tile);
|
||||
for (const FVoxelTileKey& Tile : Leaving) Updated.Remove(Tile);
|
||||
for (const FVoxelTileKey& Tile : Entering) Updated.Add(Tile);
|
||||
|
||||
TArray<FVoxelTileKey> FullNextTiles;
|
||||
VoxelClipmapDesiredTiles::Build(
|
||||
NextCenter, NextMinZ, NextMaxZ, P, FullNextTiles);
|
||||
TSet<FVoxelTileKey> FullNextSet;
|
||||
for (const FVoxelTileKey& Tile : FullNextTiles) FullNextSet.Add(Tile);
|
||||
bool bMatches = Updated.Num() == FullNextSet.Num();
|
||||
if (bMatches)
|
||||
{
|
||||
for (const FVoxelTileKey& Tile : Updated)
|
||||
{
|
||||
if (!FullNextSet.Contains(Tile))
|
||||
{
|
||||
bMatches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!bMatches)
|
||||
{
|
||||
++DeltaFailures;
|
||||
if (FirstDeltaFailure.IsEmpty())
|
||||
{
|
||||
FirstDeltaFailure = FString::Printf(
|
||||
TEXT("center=(%d,%d,%d) step=(%d,%d,%d) radius=%d level=%d clamp=%d entering=%d leaving=%d full=%d updated=%d"),
|
||||
Center.X, Center.Y, Center.Z, Step.X, Step.Y, Step.Z,
|
||||
Sweep.Radius, Sweep.MaxLevel, ClampCase,
|
||||
Entering.Num(), Leaving.Num(), FullNextSet.Num(), Updated.Num());
|
||||
}
|
||||
}
|
||||
++DeltaCases;
|
||||
}
|
||||
}
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("clipmap delta cases=%d failures=%d first=%s"), DeltaCases, DeltaFailures,
|
||||
FirstDeltaFailure.IsEmpty() ? TEXT("none") : *FirstDeltaFailure));
|
||||
TestEqual(TEXT("clipmap delta update equals a full rebuild on every tested crossing"),
|
||||
DeltaFailures, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -182,4 +182,218 @@ namespace VoxelClipmapDesiredTiles
|
||||
PreviousRingMax = RingMax;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute the exact key difference between two adjacent clipmap centers without visiting
|
||||
// unchanged ring cells. A coarser ring can change membership when its finer child ring moves,
|
||||
// even when the coarser ring bounds themselves stay fixed; the cover-box delta below includes
|
||||
// those boundary keys as well.
|
||||
FORCEINLINE void BuildDelta(
|
||||
const FIntVector& PreviousCenter, int32 PreviousMinChunkZ, int32 PreviousMaxChunkZ,
|
||||
const FIntVector& Center, int32 MinChunkZ, int32 MaxChunkZ,
|
||||
const FParameters& Parameters,
|
||||
TArray<FVoxelTileKey>& OutEntering, TArray<FVoxelTileKey>& OutLeaving)
|
||||
{
|
||||
struct FBox
|
||||
{
|
||||
FIntVector Min = FIntVector::ZeroValue;
|
||||
FIntVector Max = FIntVector::ZeroValue;
|
||||
bool bValid = false;
|
||||
};
|
||||
OutEntering.Reset();
|
||||
OutLeaving.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);
|
||||
FBox PreviousValid[9];
|
||||
FBox CurrentValid[9];
|
||||
FBox PreviousCovered[9];
|
||||
FBox CurrentCovered[9];
|
||||
const auto BuildRings = [&](const FIntVector& RingCenter, int32 RingMinChunkZ,
|
||||
int32 RingMaxChunkZ, FBox* OutValid, FBox* OutCovered)
|
||||
{
|
||||
FIntVector ChildRingMin = FIntVector::ZeroValue;
|
||||
FIntVector ChildRingMax = FIntVector::ZeroValue;
|
||||
for (int32 Level = 0; Level <= MaxLevel; ++Level)
|
||||
{
|
||||
const int32 Pow = static_cast<int32>(uint32{1} << static_cast<uint32>(Level));
|
||||
const FIntVector CenterLevel = FloorDiv(RingCenter, Pow);
|
||||
const int32 LevelRadius = (Level == MaxLevel) ? OuterRadius : Radius;
|
||||
const int32 RingAlignment = Level < MaxLevel ? 2 : 1;
|
||||
FIntVector RingMin, RingMax;
|
||||
AlignedRingBounds(CenterLevel, LevelRadius, RingAlignment, RingMin, RingMax);
|
||||
FBox Valid;
|
||||
Valid.Min = RingMin;
|
||||
Valid.Max = RingMax;
|
||||
Valid.Min.Z = FMath::Max(Valid.Min.Z, FloorDiv(RingMinChunkZ, Pow));
|
||||
Valid.Max.Z = FMath::Min(Valid.Max.Z, FloorDiv(RingMaxChunkZ, Pow));
|
||||
Valid.bValid = Valid.Min.X <= Valid.Max.X
|
||||
&& Valid.Min.Y <= Valid.Max.Y && Valid.Min.Z <= Valid.Max.Z;
|
||||
OutValid[Level] = Valid;
|
||||
|
||||
FBox Covered;
|
||||
if (Level > 0)
|
||||
{
|
||||
Covered.Min = FIntVector(
|
||||
CeilDiv(ChildRingMin.X, 2), CeilDiv(ChildRingMin.Y, 2),
|
||||
CeilDiv(ChildRingMin.Z, 2));
|
||||
Covered.Max = FIntVector(
|
||||
ClampInt32(FloorDivWide(static_cast<int64>(ChildRingMax.X) - 1, 2)),
|
||||
ClampInt32(FloorDivWide(static_cast<int64>(ChildRingMax.Y) - 1, 2)),
|
||||
ClampInt32(FloorDivWide(static_cast<int64>(ChildRingMax.Z) - 1, 2)));
|
||||
Covered.bValid = Covered.Min.X <= Covered.Max.X
|
||||
&& Covered.Min.Y <= Covered.Max.Y && Covered.Min.Z <= Covered.Max.Z;
|
||||
}
|
||||
OutCovered[Level] = Covered;
|
||||
ChildRingMin = RingMin;
|
||||
ChildRingMax = RingMax;
|
||||
}
|
||||
};
|
||||
BuildRings(PreviousCenter, PreviousMinChunkZ, PreviousMaxChunkZ,
|
||||
PreviousValid, PreviousCovered);
|
||||
BuildRings(Center, MinChunkZ, MaxChunkZ, CurrentValid, CurrentCovered);
|
||||
|
||||
const auto Intersect = [](const FBox& A, const FBox& B)
|
||||
{
|
||||
FBox Result;
|
||||
if (!A.bValid || !B.bValid) return Result;
|
||||
Result.Min = FIntVector(
|
||||
FMath::Max(A.Min.X, B.Min.X), FMath::Max(A.Min.Y, B.Min.Y),
|
||||
FMath::Max(A.Min.Z, B.Min.Z));
|
||||
Result.Max = FIntVector(
|
||||
FMath::Min(A.Max.X, B.Max.X), FMath::Min(A.Max.Y, B.Max.Y),
|
||||
FMath::Min(A.Max.Z, B.Max.Z));
|
||||
Result.bValid = Result.Min.X <= Result.Max.X
|
||||
&& Result.Min.Y <= Result.Max.Y && Result.Min.Z <= Result.Max.Z;
|
||||
return Result;
|
||||
};
|
||||
const auto AppendDifference = [&Intersect](const FBox& A, const FBox& B, auto& OutBoxes)
|
||||
{
|
||||
if (!A.bValid) return;
|
||||
const FBox Common = Intersect(A, B);
|
||||
if (!Common.bValid)
|
||||
{
|
||||
OutBoxes.Add(A);
|
||||
return;
|
||||
}
|
||||
|
||||
// Six disjoint slabs cover A minus Common. The later slabs are narrowed to the
|
||||
// common extent on each already-partitioned axis, so no key is emitted twice.
|
||||
FBox Slab = A;
|
||||
if (A.Min.X < Common.Min.X)
|
||||
{
|
||||
Slab.Max.X = Common.Min.X - 1;
|
||||
Slab.bValid = Slab.Min.X <= Slab.Max.X;
|
||||
if (Slab.bValid) OutBoxes.Add(Slab);
|
||||
}
|
||||
Slab = A;
|
||||
if (Common.Max.X < A.Max.X)
|
||||
{
|
||||
Slab.Min.X = Common.Max.X + 1;
|
||||
Slab.bValid = Slab.Min.X <= Slab.Max.X;
|
||||
if (Slab.bValid) OutBoxes.Add(Slab);
|
||||
}
|
||||
|
||||
Slab = A;
|
||||
Slab.Min.X = Common.Min.X;
|
||||
Slab.Max.X = Common.Max.X;
|
||||
if (A.Min.Y < Common.Min.Y)
|
||||
{
|
||||
Slab.Max.Y = Common.Min.Y - 1;
|
||||
Slab.bValid = Slab.Min.Y <= Slab.Max.Y;
|
||||
if (Slab.bValid) OutBoxes.Add(Slab);
|
||||
}
|
||||
Slab = A;
|
||||
Slab.Min.X = Common.Min.X;
|
||||
Slab.Max.X = Common.Max.X;
|
||||
if (Common.Max.Y < A.Max.Y)
|
||||
{
|
||||
Slab.Min.Y = Common.Max.Y + 1;
|
||||
Slab.bValid = Slab.Min.Y <= Slab.Max.Y;
|
||||
if (Slab.bValid) OutBoxes.Add(Slab);
|
||||
}
|
||||
|
||||
Slab = A;
|
||||
Slab.Min.X = Common.Min.X;
|
||||
Slab.Max.X = Common.Max.X;
|
||||
Slab.Min.Y = Common.Min.Y;
|
||||
Slab.Max.Y = Common.Max.Y;
|
||||
if (A.Min.Z < Common.Min.Z)
|
||||
{
|
||||
Slab.Max.Z = Common.Min.Z - 1;
|
||||
Slab.bValid = Slab.Min.Z <= Slab.Max.Z;
|
||||
if (Slab.bValid) OutBoxes.Add(Slab);
|
||||
}
|
||||
Slab = A;
|
||||
Slab.Min.X = Common.Min.X;
|
||||
Slab.Max.X = Common.Max.X;
|
||||
Slab.Min.Y = Common.Min.Y;
|
||||
Slab.Max.Y = Common.Max.Y;
|
||||
if (Common.Max.Z < A.Max.Z)
|
||||
{
|
||||
Slab.Min.Z = Common.Max.Z + 1;
|
||||
Slab.bValid = Slab.Min.Z <= Slab.Max.Z;
|
||||
if (Slab.bValid) OutBoxes.Add(Slab);
|
||||
}
|
||||
};
|
||||
const auto Contains = [](const FBox& Box, const FIntVector& Point)
|
||||
{
|
||||
return Box.bValid && Point.X >= Box.Min.X && Point.X <= Box.Max.X
|
||||
&& Point.Y >= Box.Min.Y && Point.Y <= Box.Max.Y
|
||||
&& Point.Z >= Box.Min.Z && Point.Z <= Box.Max.Z;
|
||||
};
|
||||
const auto ForEachBox = [](const FBox& Box, const auto& Callback)
|
||||
{
|
||||
if (!Box.bValid) return;
|
||||
for (int64 Z = Box.Min.Z; Z <= Box.Max.Z; ++Z)
|
||||
for (int64 Y = Box.Min.Y; Y <= Box.Max.Y; ++Y)
|
||||
for (int64 X = Box.Min.X; X <= Box.Max.X; ++X)
|
||||
{
|
||||
Callback(FIntVector(static_cast<int32>(X), static_cast<int32>(Y),
|
||||
static_cast<int32>(Z)));
|
||||
}
|
||||
};
|
||||
|
||||
for (int32 Level = 0; Level <= MaxLevel; ++Level)
|
||||
{
|
||||
TArray<FBox, TInlineAllocator<16>> ChangedBoxes;
|
||||
AppendDifference(CurrentValid[Level], PreviousValid[Level], ChangedBoxes);
|
||||
AppendDifference(PreviousValid[Level], CurrentValid[Level], ChangedBoxes);
|
||||
|
||||
if (Level > 0)
|
||||
{
|
||||
const FBox CommonValid = Intersect(CurrentValid[Level], PreviousValid[Level]);
|
||||
if (CommonValid.bValid)
|
||||
{
|
||||
TArray<FBox, TInlineAllocator<16>> CoverDifferences;
|
||||
AppendDifference(CurrentCovered[Level], PreviousCovered[Level], CoverDifferences);
|
||||
AppendDifference(PreviousCovered[Level], CurrentCovered[Level], CoverDifferences);
|
||||
for (const FBox& CoverDifference : CoverDifferences)
|
||||
{
|
||||
const FBox ChangedCover = Intersect(CoverDifference, CommonValid);
|
||||
if (ChangedCover.bValid) ChangedBoxes.Add(ChangedCover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const FBox& ChangedBox : ChangedBoxes)
|
||||
{
|
||||
ForEachBox(ChangedBox, [&](const FIntVector& Coord)
|
||||
{
|
||||
const bool bCurrentMember = Contains(CurrentValid[Level], Coord)
|
||||
&& !Contains(CurrentCovered[Level], Coord);
|
||||
const bool bPreviousMember = Contains(PreviousValid[Level], Coord)
|
||||
&& !Contains(PreviousCovered[Level], Coord);
|
||||
if (bCurrentMember && !bPreviousMember)
|
||||
{
|
||||
OutEntering.Emplace(Coord, Level);
|
||||
}
|
||||
else if (bPreviousMember && !bCurrentMember)
|
||||
{
|
||||
OutLeaving.Emplace(Coord, Level);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,6 +423,7 @@ void RecordApplyFrame(const FApplyFrameSample& Sample)
|
||||
"\"active_tasks\":%d,\"running_workers\":%d,\"task_cap\":%d,"
|
||||
"\"pending_tiles\":%d,\"pending_results_estimate\":%d,"
|
||||
"\"results_drained\":%d,\"apply_results\":%d,\"meshes_applied\":%d,\"max_applies\":%d,"
|
||||
"\"supplemental_far_applies\":%d,"
|
||||
"\"apply_budget_ms\":%s,\"count_limit_reached\":%d,\"time_limit_reached\":%d,"
|
||||
"\"pending_by_lod\":%s,\"apply_results_by_lod\":%s,"
|
||||
"\"meshes_by_lod\":%s,\"apply_ms_by_lod\":%s"),
|
||||
@@ -432,7 +433,8 @@ void RecordApplyFrame(const FApplyFrameSample& Sample)
|
||||
Sample.RunningWorkerTasks, Sample.TaskCap, Sample.PendingTiles,
|
||||
Sample.PendingResultsEstimate, Sample.ResultsDrained, Sample.ApplyResultsByLevelTotal,
|
||||
Sample.MeshesApplied,
|
||||
Sample.MaxApplies, *Number(Sample.ApplyBudgetMilliseconds),
|
||||
Sample.MaxApplies, Sample.SupplementalFarApplies,
|
||||
*Number(Sample.ApplyBudgetMilliseconds),
|
||||
Sample.bCountLimitReached ? 1 : 0, Sample.bTimeLimitReached ? 1 : 0,
|
||||
*PendingByLevel, *ApplyResultsByLevel, *MeshesByLevel, *ApplyMillisecondsByLevel));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace VoxelStreamingAdmissionOrder
|
||||
{
|
||||
template <typename Iterator, typename Less>
|
||||
void StableSortCandidates(Iterator First, Iterator Last, Less Compare)
|
||||
{
|
||||
std::stable_sort(First, Last, Compare);
|
||||
}
|
||||
|
||||
template <typename Range, typename IsAtCapacity, typename SubmitCandidate,
|
||||
typename OnCapacityReached>
|
||||
void AdmitInOrder(const Range& Candidates, IsAtCapacity AtCapacity,
|
||||
SubmitCandidate Submit, OnCapacityReached CapacityReached)
|
||||
{
|
||||
for (const auto& Candidate : Candidates)
|
||||
{
|
||||
if (AtCapacity())
|
||||
{
|
||||
CapacityReached();
|
||||
break;
|
||||
}
|
||||
Submit(Candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ namespace VoxelForgeStartupTrace
|
||||
int32 ResultsDrained = 0;
|
||||
int32 ApplyResultsByLevelTotal = 0;
|
||||
int32 MeshesApplied = 0;
|
||||
int32 SupplementalFarApplies = 0;
|
||||
int32 MaxApplies = 0;
|
||||
double ApplyBudgetMilliseconds = 0.0;
|
||||
bool bCountLimitReached = false;
|
||||
|
||||
@@ -1247,6 +1247,8 @@ public:
|
||||
bool bSampleStacksStarted = false;
|
||||
bool bStartupApplyBoostStarted = false;
|
||||
bool bStartupApplyBoostEnded = false;
|
||||
// Fast-travel admission stays disabled until the initial near LOD and collision set is ready.
|
||||
bool bInitialArrivalSetSettled = false;
|
||||
uint64 StartupApplyBoostStartCycles = 0;
|
||||
// Trace the resolved startup budget once per world. / Tracer une fois le budget de demarrage resolu par monde.
|
||||
bool bStartupApplyConfigTraced = false;
|
||||
@@ -1254,6 +1256,21 @@ public:
|
||||
float StartupTraceSpeedCmPerSecond = 0.0f;
|
||||
bool bSpeedAwareTravelMode = false;
|
||||
bool bSpeedAwareFastMode = false;
|
||||
TArray<FVoxelTileKey> CachedCoreDesiredTiles;
|
||||
TSet<FVoxelTileKey> CachedCoreDesiredTileSet;
|
||||
FIntVector CachedCoreDesiredCenter = FIntVector(MAX_int32, MAX_int32, MAX_int32);
|
||||
bool bHasCachedCoreDesiredTiles = false;
|
||||
TArray<FVoxelTileKey> CachedDesiredCandidateKeys;
|
||||
TSet<FVoxelTileKey> CachedDesiredCandidateSet;
|
||||
TSet<FVoxelTileKey> CachedDesiredBaseVisualTiles;
|
||||
TSet<FVoxelTileKey> CachedDesiredOverlayTiles;
|
||||
FIntVector CachedDesiredMaterializationCenter = FIntVector(MAX_int32, MAX_int32, MAX_int32);
|
||||
bool bHasCachedDesiredMaterialization = false;
|
||||
TArray<FVoxelTileKey> CachedDesiredPriorityOrder;
|
||||
FIntVector CachedDesiredPriorityCenter = FIntVector(MAX_int32, MAX_int32, MAX_int32);
|
||||
bool bHasCachedDesiredPriorityOrder = false;
|
||||
int32 IncrementalDesiredCenterCrossings = 0;
|
||||
int32 IncrementalDesiredMaterializationUpdates = 0;
|
||||
TArray<FVoxelTileKey> DesiredSorted; // critical floor prefix, then true-pawn distance-first
|
||||
TArray<FVoxelTileKey> CriticalDesiredTiles; // occupied/support tile, then heading tile
|
||||
// Ancestor -> desired descendants, rebuilt with DesiredSorted. Reconciliation uses this
|
||||
@@ -1602,7 +1619,7 @@ public:
|
||||
// ONE frame → a game-thread teardown spike. Drained by ProcessUnloadQueue (catch-up scaled).
|
||||
TSet<FVoxelTileKey> PendingUnload;
|
||||
|
||||
void ProcessPendingChunks();
|
||||
void ProcessPendingChunks(uint64 TickStartCycles);
|
||||
void ProcessUnloadQueue(); // budgeted teardown drain (see PendingUnload)
|
||||
void QueuePendingUnload(const FVoxelTileKey& Tile);
|
||||
void HideOverlappingDesiredVisuals(const FVoxelTileKey& RetiredTile);
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
#include "VoxelForgeLivingPlaneActor.h"
|
||||
|
||||
#include "Engine/TextureRenderTarget2D.h"
|
||||
#include "Engine/TextureRenderTarget.h"
|
||||
#include "Engine/StaticMesh.h"
|
||||
#include "Engine/World.h"
|
||||
#include "Materials/MaterialInstanceDynamic.h"
|
||||
#include "Materials/MaterialInterface.h"
|
||||
#include "RenderGraphBuilder.h"
|
||||
#include "RenderGraphUtils.h"
|
||||
#include "RHICommandList.h"
|
||||
#include "RHIResources.h"
|
||||
#include "DataDrivenShaderPlatformInfo.h"
|
||||
#include "GlobalShader.h"
|
||||
#include "ShaderParameterStruct.h"
|
||||
#include "UObject/ConstructorHelpers.h"
|
||||
#include "Components/StaticMeshComponent.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int32 LivingPlaneThreadGroupSize = 8;
|
||||
|
||||
struct FLivingPlaneStepInput
|
||||
{
|
||||
FVector4f ObjectPositionRadius[2] = {FVector4f(0, 0, 0, -1), FVector4f(0, 0, 0, -1)};
|
||||
FVector4f ObjectVelocityPush[2] = {FVector4f(0, 0, 0, 0), FVector4f(0, 0, 0, 0)};
|
||||
float TimeSeconds = 0.0f;
|
||||
};
|
||||
|
||||
class FVFLivingPlaneStepCS final : public FGlobalShader
|
||||
{
|
||||
DECLARE_GLOBAL_SHADER(FVFLivingPlaneStepCS);
|
||||
SHADER_USE_PARAMETER_STRUCT(FVFLivingPlaneStepCS, FGlobalShader);
|
||||
|
||||
BEGIN_SHADER_PARAMETER_STRUCT(FParameters, )
|
||||
SHADER_PARAMETER(FIntPoint, GridSize)
|
||||
SHADER_PARAMETER(float, Dt)
|
||||
SHADER_PARAMETER(float, Dx)
|
||||
SHADER_PARAMETER(float, PlaneLength)
|
||||
SHADER_PARAMETER(float, WaveSpeed)
|
||||
SHADER_PARAMETER(float, Damping)
|
||||
SHADER_PARAMETER(float, FoundationStiffness)
|
||||
SHADER_PARAMETER(float, TimeSeconds)
|
||||
SHADER_PARAMETER(FVector2f, BreathingSettings)
|
||||
SHADER_PARAMETER(FVector4f, ImprintSettings)
|
||||
SHADER_PARAMETER_ARRAY(FVector4f, ObjectPositionRadius, [2])
|
||||
SHADER_PARAMETER_ARRAY(FVector4f, ObjectVelocityPush, [2])
|
||||
SHADER_PARAMETER_RDG_TEXTURE_SRV(Texture2D<float4>, SourceState)
|
||||
SHADER_PARAMETER_RDG_TEXTURE_UAV(RWTexture2D<float4>, TargetState)
|
||||
END_SHADER_PARAMETER_STRUCT()
|
||||
|
||||
public:
|
||||
static bool ShouldCompilePermutation(const FGlobalShaderPermutationParameters& Parameters)
|
||||
{
|
||||
return IsFeatureLevelSupported(Parameters.Platform, ERHIFeatureLevel::SM5);
|
||||
}
|
||||
};
|
||||
|
||||
IMPLEMENT_GLOBAL_SHADER(FVFLivingPlaneStepCS,
|
||||
"/Plugin/VoxelForgeLivingPlane/LivingPlane.usf", "MainCS", SF_Compute)
|
||||
|
||||
UTextureRenderTarget2D* CreateFieldTarget(UObject* Outer, int32 Size, FName Name)
|
||||
{
|
||||
UTextureRenderTarget2D* Target = NewObject<UTextureRenderTarget2D>(Outer, Name, RF_Transient);
|
||||
Target->RenderTargetFormat = RTF_RGBA32f;
|
||||
Target->bCanCreateUAV = true;
|
||||
Target->bAutoGenerateMips = false;
|
||||
Target->ClearColor = FLinearColor::Transparent;
|
||||
Target->Filter = TF_Bilinear;
|
||||
Target->AddressX = TA_Clamp;
|
||||
Target->AddressY = TA_Clamp;
|
||||
Target->InitAutoFormat(Size, Size);
|
||||
Target->UpdateResourceImmediate(true);
|
||||
return Target;
|
||||
}
|
||||
}
|
||||
|
||||
AVoxelForgeLivingPlaneActor::AVoxelForgeLivingPlaneActor()
|
||||
{
|
||||
PrimaryActorTick.bCanEverTick = true;
|
||||
PlaneMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("LivingPlaneMesh"));
|
||||
SetRootComponent(PlaneMesh);
|
||||
PlaneMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
|
||||
PlaneMesh->SetMobility(EComponentMobility::Movable);
|
||||
static ConstructorHelpers::FObjectFinder<UStaticMesh> PlaneAsset(
|
||||
TEXT("/Engine/BasicShapes/Plane.Plane"));
|
||||
if (PlaneAsset.Succeeded())
|
||||
{
|
||||
PlaneMesh->SetStaticMesh(PlaneAsset.Object);
|
||||
}
|
||||
|
||||
ObjectInputs.SetNum(2);
|
||||
}
|
||||
|
||||
void AVoxelForgeLivingPlaneActor::OnConstruction(const FTransform& Transform)
|
||||
{
|
||||
Super::OnConstruction(Transform);
|
||||
if (PlaneMesh)
|
||||
{
|
||||
// /Engine/BasicShapes/Plane is 100 cm square; one Unreal scale unit
|
||||
// therefore corresponds to one metre of authored plane length.
|
||||
PlaneMesh->SetRelativeScale3D(FVector(PlaneLengthMeters, PlaneLengthMeters, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
float AVoxelForgeLivingPlaneActor::GetStableStepSeconds() const
|
||||
{
|
||||
const float Dx = PlaneLengthMeters / static_cast<float>(FMath::Max(GridSize, 16));
|
||||
const float EigenFrequencySquared = 8.0f * WaveSpeedMetersPerSecond * WaveSpeedMetersPerSecond
|
||||
/ FMath::Max(Dx * Dx, 1.0e-8f) + FoundationStiffnessPerSecondSquared;
|
||||
return 1.30f / FMath::Sqrt(FMath::Max(EigenFrequencySquared, 1.0e-8f));
|
||||
}
|
||||
|
||||
void AVoxelForgeLivingPlaneActor::BeginPlay()
|
||||
{
|
||||
Super::BeginPlay();
|
||||
GridSize = FMath::Clamp(GridSize, 16, 2048);
|
||||
PlaneLengthMeters = FMath::Max(PlaneLengthMeters, 1.0f);
|
||||
FixedStepSeconds = GetStableStepSeconds();
|
||||
CreateFieldTargets();
|
||||
|
||||
if (PlaneMaterial && PlaneMesh)
|
||||
{
|
||||
DynamicPlaneMaterial = PlaneMesh->CreateDynamicMaterialInstance(0, PlaneMaterial);
|
||||
if (DynamicPlaneMaterial)
|
||||
{
|
||||
DynamicPlaneMaterial->SetScalarParameterValue(TEXT("PlaneLengthMeters"), PlaneLengthMeters);
|
||||
DynamicPlaneMaterial->SetScalarParameterValue(TEXT("FieldSize"), static_cast<float>(GridSize));
|
||||
}
|
||||
}
|
||||
UpdateMaterialField();
|
||||
}
|
||||
|
||||
void AVoxelForgeLivingPlaneActor::CreateFieldTargets()
|
||||
{
|
||||
FieldTargets[0] = CreateFieldTarget(this, GridSize, TEXT("LivingPlaneStateA"));
|
||||
FieldTargets[1] = CreateFieldTarget(this, GridSize, TEXT("LivingPlaneStateB"));
|
||||
CurrentFieldIndex = 0;
|
||||
SimulationStepCount = 0;
|
||||
SimulationAccumulatorSeconds = 0.0;
|
||||
}
|
||||
|
||||
void AVoxelForgeLivingPlaneActor::SetObjectState(int32 ObjectIndex,
|
||||
const FVFLivingPlaneObjectInput& State)
|
||||
{
|
||||
if (!ObjectInputs.IsValidIndex(ObjectIndex) || ObjectIndex >= 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ObjectInputs[ObjectIndex] = State;
|
||||
}
|
||||
|
||||
void AVoxelForgeLivingPlaneActor::ResetSimulation()
|
||||
{
|
||||
if (!IsInGameThread() || !FieldTargets[0] || !FieldTargets[1])
|
||||
{
|
||||
return;
|
||||
}
|
||||
CreateFieldTargets();
|
||||
FixedStepSeconds = GetStableStepSeconds();
|
||||
UpdateMaterialField();
|
||||
}
|
||||
|
||||
void AVoxelForgeLivingPlaneActor::UpdateMaterialField()
|
||||
{
|
||||
if (DynamicPlaneMaterial && FieldTargets[CurrentFieldIndex])
|
||||
{
|
||||
DynamicPlaneMaterial->SetTextureParameterValue(TEXT("LivingField"), FieldTargets[CurrentFieldIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
void AVoxelForgeLivingPlaneActor::EnqueueSimulationSteps(int32 NumSteps)
|
||||
{
|
||||
if (NumSteps <= 0 || !FieldTargets[0] || !FieldTargets[1])
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FTextureRenderTargetResource* ResourceA = FieldTargets[0]->GameThread_GetRenderTargetResource();
|
||||
FTextureRenderTargetResource* ResourceB = FieldTargets[1]->GameThread_GetRenderTargetResource();
|
||||
if (!ResourceA || !ResourceB)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TArray<FLivingPlaneStepInput> Steps;
|
||||
Steps.Reserve(NumSteps);
|
||||
const FTransform PlaneTransform = GetActorTransform();
|
||||
for (int32 Step = 0; Step < NumSteps; ++Step)
|
||||
{
|
||||
FLivingPlaneStepInput& Input = Steps.AddDefaulted_GetRef();
|
||||
Input.TimeSeconds = static_cast<float>(SimulationStepCount + Step) * FixedStepSeconds;
|
||||
for (int32 ObjectIndex = 0; ObjectIndex < 2; ++ObjectIndex)
|
||||
{
|
||||
if (!ObjectInputs.IsValidIndex(ObjectIndex) || !ObjectInputs[ObjectIndex].bActive)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const FVFLivingPlaneObjectInput& Object = ObjectInputs[ObjectIndex];
|
||||
const FVector LocalPosition = PlaneTransform.InverseTransformPosition(Object.WorldPositionCm);
|
||||
const FVector LocalVelocity = PlaneTransform.InverseTransformVector(Object.WorldVelocityCmPerSecond);
|
||||
const float RadiusMeters = FMath::Max(Object.RadiusCm * 0.01f, 0.001f);
|
||||
Input.ObjectPositionRadius[ObjectIndex] = FVector4f(
|
||||
static_cast<float>(LocalPosition.X * 0.01),
|
||||
static_cast<float>(LocalPosition.Y * 0.01),
|
||||
static_cast<float>(LocalPosition.Z * 0.01),
|
||||
RadiusMeters);
|
||||
Input.ObjectVelocityPush[ObjectIndex] = FVector4f(
|
||||
static_cast<float>(LocalVelocity.X * 0.01),
|
||||
static_cast<float>(LocalVelocity.Y * 0.01),
|
||||
static_cast<float>(LocalVelocity.Z * 0.01),
|
||||
FMath::Max(Object.PushAccelerationMetersPerSecondSquared, 0.0f));
|
||||
}
|
||||
}
|
||||
|
||||
const int32 Resolution = GridSize;
|
||||
const int32 InitialFieldIndex = CurrentFieldIndex;
|
||||
const float Dt = static_cast<float>(FixedStepSeconds);
|
||||
const float Dx = PlaneLengthMeters / static_cast<float>(Resolution);
|
||||
const float PlaneLength = PlaneLengthMeters;
|
||||
const float WaveSpeed = WaveSpeedMetersPerSecond;
|
||||
const float Damping = DampingPerSecond;
|
||||
const float Foundation = FoundationStiffnessPerSecondSquared;
|
||||
const float BreathingStrength = bEnableBreathing ? BreathingStrengthMetersPerSecondSquared : 0.0f;
|
||||
const FVector2f BreathingSettings(bEnableBreathing ? 1.0f : 0.0f, BreathingStrength);
|
||||
const FVector4f ImprintSettings(bEnableImprint ? 1.0f : 0.0f,
|
||||
ImprintCaptureMetersPerSecond, FMath::Max(ImprintRecoverySeconds, 1.0e-4f),
|
||||
FMath::Max(ImprintMaxDepthMeters, 0.0f));
|
||||
|
||||
ENQUEUE_RENDER_COMMAND(VoxelForgeLivingPlaneStep)(
|
||||
[ResourceA, ResourceB, Steps = MoveTemp(Steps), InitialFieldIndex, Resolution, Dt, Dx, PlaneLength,
|
||||
WaveSpeed, Damping, Foundation, BreathingSettings, ImprintSettings]
|
||||
(FRHICommandListImmediate& RHICmdList) mutable
|
||||
{
|
||||
FRDGBuilder GraphBuilder(RHICmdList, RDG_EVENT_NAME("VoxelForgeLivingPlane"));
|
||||
FRDGTextureRef Fields[2] = {
|
||||
RegisterExternalTexture(GraphBuilder, ResourceA->GetRenderTargetTexture().GetReference(), TEXT("LivingPlaneStateA")),
|
||||
RegisterExternalTexture(GraphBuilder, ResourceB->GetRenderTargetTexture().GetReference(), TEXT("LivingPlaneStateB"))};
|
||||
TShaderMapRef<FVFLivingPlaneStepCS> ComputeShader(GetGlobalShaderMap(GMaxRHIFeatureLevel));
|
||||
int32 CurrentIndex = InitialFieldIndex;
|
||||
for (const FLivingPlaneStepInput& StepInput : Steps)
|
||||
{
|
||||
const int32 TargetIndex = 1 - CurrentIndex;
|
||||
FVFLivingPlaneStepCS::FParameters* PassParameters =
|
||||
GraphBuilder.AllocParameters<FVFLivingPlaneStepCS::FParameters>();
|
||||
PassParameters->GridSize = FIntPoint(Resolution, Resolution);
|
||||
PassParameters->Dt = Dt;
|
||||
PassParameters->Dx = Dx;
|
||||
PassParameters->PlaneLength = PlaneLength;
|
||||
PassParameters->WaveSpeed = WaveSpeed;
|
||||
PassParameters->Damping = Damping;
|
||||
PassParameters->FoundationStiffness = Foundation;
|
||||
PassParameters->TimeSeconds = StepInput.TimeSeconds;
|
||||
PassParameters->BreathingSettings = BreathingSettings;
|
||||
PassParameters->ImprintSettings = ImprintSettings;
|
||||
for (int32 ObjectIndex = 0; ObjectIndex < 2; ++ObjectIndex)
|
||||
{
|
||||
PassParameters->ObjectPositionRadius[ObjectIndex] = StepInput.ObjectPositionRadius[ObjectIndex];
|
||||
PassParameters->ObjectVelocityPush[ObjectIndex] = StepInput.ObjectVelocityPush[ObjectIndex];
|
||||
}
|
||||
PassParameters->SourceState = GraphBuilder.CreateSRV(Fields[CurrentIndex]);
|
||||
PassParameters->TargetState = GraphBuilder.CreateUAV(Fields[TargetIndex]);
|
||||
FComputeShaderUtils::AddPass(GraphBuilder,
|
||||
RDG_EVENT_NAME("LivingPlaneStep"),
|
||||
ComputeShader, PassParameters,
|
||||
FIntVector(FMath::DivideAndRoundUp(Resolution, LivingPlaneThreadGroupSize),
|
||||
FMath::DivideAndRoundUp(Resolution, LivingPlaneThreadGroupSize), 1));
|
||||
CurrentIndex = TargetIndex;
|
||||
}
|
||||
GraphBuilder.Execute();
|
||||
});
|
||||
|
||||
CurrentFieldIndex = (InitialFieldIndex + NumSteps) & 1;
|
||||
SimulationStepCount += NumSteps;
|
||||
UpdateMaterialField();
|
||||
}
|
||||
|
||||
void AVoxelForgeLivingPlaneActor::Tick(float DeltaSeconds)
|
||||
{
|
||||
Super::Tick(DeltaSeconds);
|
||||
if (FixedStepSeconds <= 0.0 || !FieldTargets[0] || !FieldTargets[1])
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SimulationAccumulatorSeconds += FMath::Max(static_cast<double>(DeltaSeconds), 0.0);
|
||||
const int32 StepsToRun = FMath::Min(
|
||||
static_cast<int32>(SimulationAccumulatorSeconds / FixedStepSeconds), 32);
|
||||
if (StepsToRun > 0)
|
||||
{
|
||||
SimulationAccumulatorSeconds -= StepsToRun * FixedStepSeconds;
|
||||
EnqueueSimulationSteps(StepsToRun);
|
||||
}
|
||||
}
|
||||
|
||||
void AVoxelForgeLivingPlaneActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
|
||||
{
|
||||
Super::EndPlay(EndPlayReason);
|
||||
DynamicPlaneMaterial = nullptr;
|
||||
FieldTargets[0] = nullptr;
|
||||
FieldTargets[1] = nullptr;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#include "Modules/ModuleManager.h"
|
||||
|
||||
#include "Interfaces/IPluginManager.h"
|
||||
#include "Misc/Paths.h"
|
||||
#include "ShaderCore.h"
|
||||
|
||||
class FVoxelForgeLivingPlaneModule final : public IModuleInterface
|
||||
{
|
||||
public:
|
||||
virtual void StartupModule() override
|
||||
{
|
||||
const TSharedPtr<IPlugin> Plugin = IPluginManager::Get().FindPlugin(TEXT("VoxelForge"));
|
||||
if (!Plugin.IsValid())
|
||||
{
|
||||
UE_LOG(LogTemp, Error, TEXT("VoxelForgeLivingPlane could not find its owning plugin."));
|
||||
return;
|
||||
}
|
||||
|
||||
AddShaderSourceDirectoryMapping(
|
||||
TEXT("/Plugin/VoxelForgeLivingPlane"),
|
||||
FPaths::Combine(Plugin->GetBaseDir(), TEXT("Shaders")));
|
||||
}
|
||||
};
|
||||
|
||||
IMPLEMENT_MODULE(FVoxelForgeLivingPlaneModule, VoxelForgeLivingPlane)
|
||||
@@ -0,0 +1,114 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/Actor.h"
|
||||
#include "VoxelForgeLivingPlaneActor.generated.h"
|
||||
|
||||
class UMaterialInterface;
|
||||
class UMaterialInstanceDynamic;
|
||||
class UStaticMeshComponent;
|
||||
class UTextureRenderTarget2D;
|
||||
|
||||
USTRUCT(BlueprintType)
|
||||
struct FVFLivingPlaneObjectInput
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Object")
|
||||
FVector WorldPositionCm = FVector(0.0, 0.0, 43.0);
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Object")
|
||||
FVector WorldVelocityCmPerSecond = FVector::ZeroVector;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Object", meta=(ClampMin="0.1"))
|
||||
float RadiusCm = 43.0f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Object", meta=(ClampMin="0.0"))
|
||||
float PushAccelerationMetersPerSecondSquared = 36.0f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Object")
|
||||
bool bActive = false;
|
||||
};
|
||||
|
||||
/** A render-only, GPU-stepped heightfield plane. Object state is uploaded; the field is never read back. */
|
||||
UCLASS(BlueprintType, Blueprintable)
|
||||
class VOXELFORGELIVINGPLANE_API AVoxelForgeLivingPlaneActor : public AActor
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
AVoxelForgeLivingPlaneActor();
|
||||
|
||||
virtual void BeginPlay() override;
|
||||
virtual void Tick(float DeltaSeconds) override;
|
||||
virtual void OnConstruction(const FTransform& Transform) override;
|
||||
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category="Living Plane|Object")
|
||||
void SetObjectState(int32 ObjectIndex, const FVFLivingPlaneObjectInput& State);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category="Living Plane")
|
||||
void ResetSimulation();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category="Living Plane")
|
||||
int64 GetSimulationStepCount() const { return SimulationStepCount; }
|
||||
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Living Plane")
|
||||
TObjectPtr<UStaticMeshComponent> PlaneMesh;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Living Plane|Rendering")
|
||||
TObjectPtr<UMaterialInterface> PlaneMaterial;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Simulation", meta=(ClampMin="16", ClampMax="2048"))
|
||||
int32 GridSize = 256;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Simulation", meta=(ClampMin="1.0", ClampMax="100.0"))
|
||||
float PlaneLengthMeters = 12.0f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Simulation", meta=(ClampMin="0.0"))
|
||||
float WaveSpeedMetersPerSecond = 4.5f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Simulation", meta=(ClampMin="0.0"))
|
||||
float DampingPerSecond = 1.35f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Simulation", meta=(ClampMin="0.0"))
|
||||
float FoundationStiffnessPerSecondSquared = 28.0f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Simulation")
|
||||
bool bEnableBreathing = true;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Simulation", meta=(ClampMin="0.0", ClampMax="30.0"))
|
||||
float BreathingStrengthMetersPerSecondSquared = 4.8f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Imprint")
|
||||
bool bEnableImprint = true;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Imprint", meta=(ClampMin="0.0", ClampMax="1.0"))
|
||||
float ImprintCaptureMetersPerSecond = 0.15f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Imprint", meta=(ClampMin="0.1", ClampMax="60.0"))
|
||||
float ImprintRecoverySeconds = 6.0f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Imprint", meta=(ClampMin="0.0", ClampMax="1.0"))
|
||||
float ImprintMaxDepthMeters = 0.34f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Living Plane|Object")
|
||||
TArray<FVFLivingPlaneObjectInput> ObjectInputs;
|
||||
|
||||
private:
|
||||
void CreateFieldTargets();
|
||||
void EnqueueSimulationSteps(int32 NumSteps);
|
||||
void UpdateMaterialField();
|
||||
float GetStableStepSeconds() const;
|
||||
|
||||
UPROPERTY(Transient)
|
||||
TObjectPtr<UTextureRenderTarget2D> FieldTargets[2];
|
||||
|
||||
UPROPERTY(Transient)
|
||||
TObjectPtr<UMaterialInstanceDynamic> DynamicPlaneMaterial;
|
||||
|
||||
int32 CurrentFieldIndex = 0;
|
||||
int64 SimulationStepCount = 0;
|
||||
double SimulationAccumulatorSeconds = 0.0;
|
||||
double FixedStepSeconds = 0.0;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
using UnrealBuildTool;
|
||||
|
||||
public class VoxelForgeLivingPlane : ModuleRules
|
||||
{
|
||||
public VoxelForgeLivingPlane(ReadOnlyTargetRules Target) : base(Target)
|
||||
{
|
||||
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
|
||||
|
||||
if (Target.Platform == UnrealTargetPlatform.Linux)
|
||||
{
|
||||
bUseUnity = false;
|
||||
}
|
||||
|
||||
PublicDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"Core",
|
||||
"CoreUObject",
|
||||
"Engine"
|
||||
});
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"Projects",
|
||||
"RenderCore",
|
||||
"RHI"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <limits>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
namespace VoxelForgeCore::PassageSpline
|
||||
{
|
||||
using FPoint = std::array<double,4>;
|
||||
inline constexpr std::size_t ArcTableSteps = 32;
|
||||
inline constexpr double UpperMouthFloorBlendRadiusMetres = 3.5;
|
||||
|
||||
struct FSegment
|
||||
{
|
||||
FPoint a{},b{};
|
||||
double alongMetres=0.0,lengthMetres=0.0;
|
||||
std::size_t controlSpan=0;
|
||||
double tA=0.0,tB=1.0;
|
||||
double plannedFloorA=0.0,plannedFloorB=0.0;
|
||||
};
|
||||
|
||||
inline double KnotSpan(const FPoint& a,const FPoint& b,double unitsPerMetre)
|
||||
{
|
||||
const double dx=(b[0]-a[0])/unitsPerMetre;
|
||||
const double dy=(b[1]-a[1])/unitsPerMetre;
|
||||
return std::max(1.0e-6,std::sqrt(std::sqrt(dx*dx+dy*dy)));
|
||||
}
|
||||
|
||||
inline FPoint EvaluateRaw(std::span<const FPoint> controls,std::size_t span,
|
||||
double t,double unitsPerMetre)
|
||||
{
|
||||
const auto& p1=controls[span];
|
||||
const auto& p2=controls[span+1];
|
||||
const auto& p0=controls[span==0?0:span-1];
|
||||
const auto& p3=controls[std::min(span+2,controls.size()-1)];
|
||||
const double dt0=KnotSpan(p0,p1,unitsPerMetre);
|
||||
const double dt1=KnotSpan(p1,p2,unitsPerMetre);
|
||||
const double dt2=KnotSpan(p2,p3,unitsPerMetre);
|
||||
const double t2=t*t,t3=t2*t;
|
||||
FPoint result{};
|
||||
for(const std::size_t axis:{0u,1u,3u})
|
||||
{
|
||||
const double m1=dt1*((p1[axis]-p0[axis])/dt0
|
||||
-(p2[axis]-p0[axis])/(dt0+dt1)
|
||||
+(p2[axis]-p1[axis])/dt1);
|
||||
const double m2=dt1*((p2[axis]-p1[axis])/dt1
|
||||
-(p3[axis]-p1[axis])/(dt1+dt2)
|
||||
+(p3[axis]-p2[axis])/dt2);
|
||||
result[axis]=(2.0*t3-3.0*t2+1.0)*p1[axis]
|
||||
+(-2.0*t3+3.0*t2)*p2[axis]
|
||||
+(t3-2.0*t2+t)*m1+(t3-t2)*m2;
|
||||
}
|
||||
result[2]=std::lerp(p1[2],p2[2],t);
|
||||
result[3]=std::clamp(result[3],std::min(p1[3],p2[3]),
|
||||
std::max(p1[3],p2[3]));
|
||||
return result;
|
||||
}
|
||||
|
||||
inline bool ClosestRouteHeightAtXY(std::span<const std::array<double,3>> route,
|
||||
double x,double y,double& height,double& distanceSquared)
|
||||
{
|
||||
if(route.empty())return false;
|
||||
distanceSquared=std::numeric_limits<double>::infinity();
|
||||
const auto consider=[&](const std::array<double,3>& point)
|
||||
{
|
||||
const double dx=x-point[0],dy=y-point[1];
|
||||
const double candidate=dx*dx+dy*dy;
|
||||
if(candidate<distanceSquared)
|
||||
{distanceSquared=candidate;height=point[2];}
|
||||
};
|
||||
if(route.size()==1)
|
||||
{
|
||||
consider(route.front());
|
||||
return true;
|
||||
}
|
||||
for(std::size_t i=1;i<route.size();++i)
|
||||
{
|
||||
const auto& a=route[i-1];
|
||||
const auto& b=route[i];
|
||||
const double dx=b[0]-a[0],dy=b[1]-a[1];
|
||||
const double lengthSquared=dx*dx+dy*dy;
|
||||
const double t=lengthSquared>1.0e-12
|
||||
?std::clamp(((x-a[0])*dx+(y-a[1])*dy)/lengthSquared,0.0,1.0):0.0;
|
||||
const std::array<double,3> point={a[0]+t*dx,a[1]+t*dy,
|
||||
std::lerp(a[2],b[2],t)};
|
||||
consider(point);
|
||||
}
|
||||
return std::isfinite(distanceSquared);
|
||||
}
|
||||
|
||||
inline double BlendUpperMouthFloorAtXY(
|
||||
std::span<const std::array<double,3>> receivingRoute,double x,double y,
|
||||
double plannedFloor,double mouthX,double mouthY,double mouthFloor,
|
||||
double tangentX,double tangentY,
|
||||
double blendRadiusMetres,double playerCenterAboveFloorMetres,
|
||||
double unitsPerMetre=1.0)
|
||||
{
|
||||
if(receivingRoute.empty()||!(blendRadiusMetres>0.0)
|
||||
||!(unitsPerMetre>0.0))return plannedFloor;
|
||||
const double dx=(x-mouthX)/unitsPerMetre;
|
||||
const double dy=(y-mouthY)/unitsPerMetre;
|
||||
if(dx*tangentX+dy*tangentY>=0.0)return plannedFloor;
|
||||
const double dz=(plannedFloor-mouthFloor)/unitsPerMetre;
|
||||
const double distance=std::sqrt(dx*dx+dy*dy+dz*dz);
|
||||
double routeHeight=0.0,routeDistanceSquared=0.0;
|
||||
if(distance>blendRadiusMetres
|
||||
||!ClosestRouteHeightAtXY(receivingRoute,x,y,routeHeight,
|
||||
routeDistanceSquared)
|
||||
||routeDistanceSquared>std::pow(2.0*blendRadiusMetres*unitsPerMetre,2.0))
|
||||
return plannedFloor;
|
||||
const double t=std::clamp(distance/blendRadiusMetres,0.0,1.0);
|
||||
const double blend=t*t*(3.0-2.0*t);
|
||||
const double receivingFloor=routeHeight
|
||||
-playerCenterAboveFloorMetres*unitsPerMetre;
|
||||
return std::lerp(plannedFloor,receivingFloor,blend);
|
||||
}
|
||||
|
||||
inline std::vector<FSegment> BuildSegments(std::span<const FPoint> controls,
|
||||
std::size_t spanCount,double spacingMetres,double unitsPerMetre=1.0,
|
||||
std::span<const double> plannedFloorProfile={},
|
||||
double maximumChordErrorMetres=0.0)
|
||||
{
|
||||
std::vector<FSegment> result;
|
||||
if(controls.size()<2||!(spacingMetres>0.0)||!(unitsPerMetre>0.0))return result;
|
||||
spanCount=std::min(spanCount,controls.size()-1);
|
||||
double alongMetres=0.0;
|
||||
for(std::size_t span=0;span<spanCount;++span)
|
||||
{
|
||||
std::array<double,ArcTableSteps+1> horizontalArc{};
|
||||
auto previous=EvaluateRaw(controls,span,0.0,unitsPerMetre);
|
||||
for(std::size_t step=1;step<=ArcTableSteps;++step)
|
||||
{
|
||||
const auto current=EvaluateRaw(controls,span,
|
||||
static_cast<double>(step)/ArcTableSteps,unitsPerMetre);
|
||||
const double dx=(current[0]-previous[0])/unitsPerMetre;
|
||||
const double dy=(current[1]-previous[1])/unitsPerMetre;
|
||||
horizontalArc[step]=horizontalArc[step-1]+std::sqrt(dx*dx+dy*dy);
|
||||
previous=current;
|
||||
}
|
||||
const double spanLength=horizontalArc.back();
|
||||
const std::size_t baseSegmentCount=std::max<std::size_t>(1,
|
||||
static_cast<std::size_t>(std::ceil(spanLength/spacingMetres)));
|
||||
std::vector<FPoint> points;
|
||||
std::vector<double> parameters;
|
||||
points.reserve(baseSegmentCount+1);
|
||||
parameters.reserve(baseSegmentCount+1);
|
||||
const auto parameterAtArc=[&](double targetArc)
|
||||
{
|
||||
const auto upper=std::lower_bound(horizontalArc.begin()+1,
|
||||
horizontalArc.end(),targetArc);
|
||||
const std::size_t upperIndex=static_cast<std::size_t>(
|
||||
upper-horizontalArc.begin());
|
||||
const std::size_t lowerIndex=upperIndex-1;
|
||||
const double arcSpan=horizontalArc[upperIndex]-horizontalArc[lowerIndex];
|
||||
const double arcT=arcSpan>1.0e-12
|
||||
?std::clamp((targetArc-horizontalArc[lowerIndex])/arcSpan,0.0,1.0):0.0;
|
||||
return (static_cast<double>(lowerIndex)+arcT)/ArcTableSteps;
|
||||
};
|
||||
parameters.push_back(0.0);
|
||||
const double chordError=std::max(0.0,maximumChordErrorMetres);
|
||||
const auto appendAdaptive=[&](auto&& self,double tA,double tB,
|
||||
const FPoint& a,const FPoint& b,unsigned depth)->void
|
||||
{
|
||||
constexpr std::array<double,3> CheckFractions={0.25,0.5,0.75};
|
||||
double maximumError=0.0,polylineLength=0.0;
|
||||
FPoint previous=a;
|
||||
for(const double fraction:CheckFractions)
|
||||
{
|
||||
const FPoint current=EvaluateRaw(controls,span,
|
||||
std::lerp(tA,tB,fraction),unitsPerMetre);
|
||||
const double chordX=std::lerp(a[0],b[0],fraction);
|
||||
const double chordY=std::lerp(a[1],b[1],fraction);
|
||||
const double dx=(current[0]-chordX)/unitsPerMetre;
|
||||
const double dy=(current[1]-chordY)/unitsPerMetre;
|
||||
const double dr=(current[3]-std::lerp(a[3],b[3],fraction))
|
||||
/unitsPerMetre;
|
||||
maximumError=std::max(maximumError,
|
||||
std::sqrt(dx*dx+dy*dy+dr*dr));
|
||||
const double segmentX=(current[0]-previous[0])/unitsPerMetre;
|
||||
const double segmentY=(current[1]-previous[1])/unitsPerMetre;
|
||||
polylineLength+=std::sqrt(segmentX*segmentX+segmentY*segmentY);
|
||||
previous=current;
|
||||
}
|
||||
const double finalX=(b[0]-previous[0])/unitsPerMetre;
|
||||
const double finalY=(b[1]-previous[1])/unitsPerMetre;
|
||||
polylineLength+=std::sqrt(finalX*finalX+finalY*finalY);
|
||||
const bool split=(polylineLength>spacingMetres*(1.0+1.0e-9)
|
||||
||(chordError>0.0&&maximumError>chordError))&&depth<12;
|
||||
if(split)
|
||||
{
|
||||
const double tMid=(tA+tB)*0.5;
|
||||
const FPoint middle=EvaluateRaw(controls,span,tMid,unitsPerMetre);
|
||||
self(self,tA,tMid,a,middle,depth+1);
|
||||
self(self,tMid,tB,middle,b,depth+1);
|
||||
return;
|
||||
}
|
||||
parameters.push_back(tB);
|
||||
};
|
||||
for(std::size_t segment=1;segment<=baseSegmentCount;++segment)
|
||||
{
|
||||
const double targetA=spanLength*static_cast<double>(segment-1)
|
||||
/baseSegmentCount;
|
||||
const double targetB=spanLength*static_cast<double>(segment)
|
||||
/baseSegmentCount;
|
||||
const double tA=parameterAtArc(targetA);
|
||||
const double tB=parameterAtArc(targetB);
|
||||
const FPoint a=EvaluateRaw(controls,span,tA,unitsPerMetre);
|
||||
const FPoint b=EvaluateRaw(controls,span,tB,unitsPerMetre);
|
||||
appendAdaptive(appendAdaptive,tA,tB,a,b,0);
|
||||
}
|
||||
points.reserve(parameters.size());
|
||||
for(const double t:parameters)
|
||||
points.push_back(EvaluateRaw(controls,span,t,unitsPerMetre));
|
||||
std::vector<double> sampledHorizontalArc(points.size(),0.0);
|
||||
for(std::size_t i=1;i<points.size();++i)
|
||||
{
|
||||
const double dx=(points[i][0]-points[i-1][0])/unitsPerMetre;
|
||||
const double dy=(points[i][1]-points[i-1][1])/unitsPerMetre;
|
||||
sampledHorizontalArc[i]=sampledHorizontalArc[i-1]
|
||||
+std::sqrt(dx*dx+dy*dy);
|
||||
}
|
||||
const double sampledSpanLength=sampledHorizontalArc.back();
|
||||
for(std::size_t i=0;i<points.size();++i)
|
||||
{
|
||||
const double progress=sampledSpanLength>1.0e-12
|
||||
?sampledHorizontalArc[i]/sampledSpanLength:parameters[i];
|
||||
points[i][2]=std::lerp(controls[span][2],controls[span+1][2],progress);
|
||||
}
|
||||
for(std::size_t i=1;i<points.size();++i)
|
||||
{
|
||||
const double dx=(points[i][0]-points[i-1][0])/unitsPerMetre;
|
||||
const double dy=(points[i][1]-points[i-1][1])/unitsPerMetre;
|
||||
const double dz=(points[i][2]-points[i-1][2])/unitsPerMetre;
|
||||
const double length=std::sqrt(dx*dx+dy*dy+dz*dz);
|
||||
const auto floorAt=[&](std::size_t index)
|
||||
{
|
||||
if(plannedFloorProfile.size()!=controls.size())
|
||||
return points[index][2];
|
||||
const double progress=sampledSpanLength>1.0e-12
|
||||
?sampledHorizontalArc[index]/sampledSpanLength:parameters[index];
|
||||
return std::lerp(plannedFloorProfile[span],
|
||||
plannedFloorProfile[span+1],progress);
|
||||
};
|
||||
result.push_back({points[i-1],points[i],alongMetres,length,span,
|
||||
parameters[i-1],parameters[i],floorAt(i-1),floorAt(i)});
|
||||
alongMetres+=length;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "VoxelForgeCore/Linux77Profiler.h"
|
||||
#include "VoxelForgeCore/PassageSpline.h"
|
||||
|
||||
// UBT supplies this export/import macro for the runtime module. The standalone target has no
|
||||
// module boundary, so leave it empty there. UBT fournit la macro; le viewer autonome reste neutre.
|
||||
@@ -137,46 +138,44 @@ namespace VoxelForgeCore
|
||||
bool passageComposerProfiled = false;
|
||||
std::uint64_t passageTileFeatureCount = 0;
|
||||
std::uint64_t passageTileRouteSegmentCandidates = 0;
|
||||
std::uint64_t passageTileMouthSegmentCandidates = 0;
|
||||
std::uint64_t resolvedBoundaryBandQueries = 0;
|
||||
std::uint64_t passageComposerQueries = 0;
|
||||
std::uint64_t passageIterations = 0;
|
||||
std::uint64_t passageTileBoundChecks = 0;
|
||||
std::uint64_t passageTileBoundRejects = 0;
|
||||
std::uint64_t passageTileBoundSkippedIterations = 0;
|
||||
std::uint64_t passageUpperMouthFilterChecks = 0;
|
||||
std::uint64_t passageColumnCandidates = 0;
|
||||
std::uint64_t passageColumnDistanceCalls = 0;
|
||||
std::uint64_t passageNegativeBoundChecks = 0;
|
||||
std::uint64_t passageNegativeBoundRejects = 0;
|
||||
std::uint64_t passagePositiveBoundChecks = 0;
|
||||
std::uint64_t passagePositiveBoundRejects = 0;
|
||||
std::uint64_t passageCarveSegmentVisits = 0;
|
||||
std::uint64_t passageSmoothCarveJoins = 0;
|
||||
std::uint64_t passageSupportSegmentVisits = 0;
|
||||
std::uint64_t passageSmoothSupportJoins = 0;
|
||||
std::uint64_t passageSupportCapsuleChecks = 0;
|
||||
std::uint64_t passageLaterBoreChecks = 0;
|
||||
std::uint64_t passageOtherCapsuleChecks = 0;
|
||||
std::uint64_t passageMouthSupportCalls = 0;
|
||||
std::uint64_t passageMouthRouteSegmentChecks = 0;
|
||||
std::uint64_t passageReliefFieldCalls = 0;
|
||||
std::uint64_t passageSmoothstepEvaluations = 0;
|
||||
// L99 tile-local swept-tube base and detail passes. These are populated only
|
||||
// by the opt-in passage composer profile, except the mesh composition itself.
|
||||
std::uint64_t passageSweepRouteSegmentCandidates = 0;
|
||||
std::uint64_t passageSweepSegmentSamplePairs = 0;
|
||||
std::uint64_t passageSweepPrepareNanoseconds = 0;
|
||||
std::uint64_t passageSweepOutsideInfluencePairs = 0;
|
||||
std::uint64_t passageSweepShellSupportSkips = 0;
|
||||
std::uint64_t passageSweepMinimumUpdates = 0;
|
||||
std::uint64_t passageSweepBaseInfluenceSamples = 0;
|
||||
std::uint64_t passageSweepDetailShellSamples = 0;
|
||||
std::uint64_t passageSweepGridSamples = 0;
|
||||
std::uint64_t passageSweepNoInfluenceGridSamples = 0;
|
||||
std::uint64_t passageSweepTouchedSamples = 0;
|
||||
std::uint64_t passageSweepStampedOutsideSamples = 0;
|
||||
std::uint64_t passageSweepCacheValidSamples = 0;
|
||||
std::uint64_t passageSweepCacheValidUniqueSamples = 0;
|
||||
std::uint64_t passageSweepUniqueTouchedSamples = 0;
|
||||
std::uint64_t passageSweepBodyBandSamples = 0;
|
||||
std::uint64_t passageSweepShellBandSamples = 0;
|
||||
std::uint64_t passageSweepUniqueBandSamples = 0;
|
||||
std::uint64_t passageSweepOverlapPairs = 0;
|
||||
std::uint64_t passageSweepContributingPairs = 0;
|
||||
std::uint64_t passageSweepCrossPassageOverlaps = 0;
|
||||
std::uint64_t passageSweepFloorProfileCalls = 0;
|
||||
std::uint64_t passageSweepAllocatedBytes = 0;
|
||||
std::uint64_t passageSweepPrepareScratchBytes = 0;
|
||||
std::uint64_t passageSweepLookups = 0;
|
||||
std::uint64_t passageSweepCacheHits = 0;
|
||||
std::uint64_t passageSweepBaseInfluenceHits = 0;
|
||||
std::uint64_t passageSweepOutOfInfluenceHits = 0;
|
||||
std::uint64_t passageSweepMissingSamples = 0;
|
||||
std::uint64_t passageSweepOffGridMisses = 0;
|
||||
std::uint64_t passageSweepUnpreparedMisses = 0;
|
||||
std::array<std::uint64_t,12> passageSweepMissingLookupsByCaller{};
|
||||
std::array<std::uint64_t,12> passageSweepExactRouteFallbacksByCaller{};
|
||||
std::array<std::uint64_t,12> passageSweepFeatureBoundSkipsByCaller{};
|
||||
@@ -488,7 +487,17 @@ namespace VoxelForgeCore
|
||||
// Version 210 keeps bulk native-cave sampling on the scalar field path at strata edges.
|
||||
// Version 211 makes coordinate biasing in the shared cell hash unsigned, removing signed-overflow UB.
|
||||
// Version 212 balances generated entry routes against existing walk lanes and preserves their shared treads.
|
||||
inline constexpr std::uint32_t WorldAssemblyEvaluatorVersion = 216;
|
||||
// Version 218 sweeps passages around a dense smooth spline and limits exposed shells to a trough.
|
||||
// Version 219 smooths passage XY while mapping each planned height chord over its sampled horizontal arc.
|
||||
// Version 220 adaptively samples passage curves and rounds open-air wall crests.
|
||||
// Version 221 ranks passage segments before floor shaping and preserves their signed exterior distance.
|
||||
// Version 223 projects the floor onto one continuous arc-length profile and groups
|
||||
// dense passage samples into coarse tile candidates. Version 224 keeps the same
|
||||
// continuous floor while relaxing the spline chord tolerance and bounding shell work.
|
||||
// Version 226 unifies the passage influence reach in metres and converts it to core
|
||||
// voxels for every broadphase and proof bound. Version 227 uses the exact smooth-max
|
||||
// support width/6 when deriving that reach.
|
||||
inline constexpr std::uint32_t WorldAssemblyEvaluatorVersion = 227;
|
||||
|
||||
enum class FieldChannel : std::uint32_t
|
||||
{
|
||||
@@ -588,6 +597,62 @@ namespace VoxelForgeCore
|
||||
}
|
||||
}
|
||||
|
||||
inline constexpr std::string_view CoreLiminalFieldRecipeIdV1 = "Core.Liminal.Field";
|
||||
inline constexpr std::uint32_t CoreLiminalFieldRecipeVersionV1 = 1;
|
||||
inline constexpr std::uint32_t CoreLiminalFieldRecipeVersionV2 = 2;
|
||||
inline constexpr std::uint32_t CoreLiminalFieldVariantCountV1 = 4;
|
||||
|
||||
enum class WorldAssemblyLiminalVariantV1 : std::uint32_t
|
||||
{
|
||||
flatFieldMonolith = 0,
|
||||
repeatingHills = 1,
|
||||
roadOverCloudFloor = 2,
|
||||
pillarGrid = 3
|
||||
};
|
||||
|
||||
struct WorldAssemblyLiminalVariantDefinitionV1
|
||||
{
|
||||
WorldAssemblyLiminalVariantV1 variant = WorldAssemblyLiminalVariantV1::flatFieldMonolith;
|
||||
std::string_view name;
|
||||
WorldAssemblySurfaceProfileKindV1 surfaceKind = WorldAssemblySurfaceProfileKindV1::flatField;
|
||||
std::int64_t hillAmplitudeMicrometres = 0;
|
||||
std::int64_t hillWavelengthMicrometres = 0;
|
||||
std::int64_t routeHalfWidthMicrometres = 0;
|
||||
std::int64_t routeHalfThicknessMicrometres = 0;
|
||||
std::int64_t supportSpacingMicrometres = 0;
|
||||
std::int64_t supportRadiusMicrometres = 0;
|
||||
std::int64_t monolithRadiusMicrometres = 0;
|
||||
std::int64_t monolithHeightMicrometres = 0;
|
||||
std::uint32_t pillarRows = 0;
|
||||
std::uint32_t pillarColumns = 0;
|
||||
std::int64_t pillarSpacingMicrometres = 0;
|
||||
std::int64_t pillarRadiusMicrometres = 0;
|
||||
std::int64_t pillarHeightMicrometres = 0;
|
||||
};
|
||||
|
||||
inline constexpr std::array<WorldAssemblyLiminalVariantDefinitionV1,
|
||||
CoreLiminalFieldVariantCountV1> CoreLiminalFieldVariantsV1{{
|
||||
{WorldAssemblyLiminalVariantV1::flatFieldMonolith, "flatField-monolith",
|
||||
WorldAssemblySurfaceProfileKindV1::flatField, 0, 0, 1750000, 500000,
|
||||
36000000, 550000, 1600000, 26000000, 0, 0, 0, 0, 0},
|
||||
{WorldAssemblyLiminalVariantV1::repeatingHills, "periodicHills",
|
||||
WorldAssemblySurfaceProfileKindV1::periodicHills, 2200000, 80000000,
|
||||
1750000, 500000, 36000000, 550000, 0, 0, 0, 0, 0, 0, 0},
|
||||
{WorldAssemblyLiminalVariantV1::roadOverCloudFloor, "road-over-cloud-floor",
|
||||
WorldAssemblySurfaceProfileKindV1::flatField, 0, 0, 2000000, 500000,
|
||||
24000000, 550000, 0, 0, 0, 0, 0, 0, 0},
|
||||
{WorldAssemblyLiminalVariantV1::pillarGrid, "pillar-grid",
|
||||
WorldAssemblySurfaceProfileKindV1::flatField, 0, 0, 1750000, 500000,
|
||||
36000000, 550000, 0, 0, 15, 15, 76000000, 5500000, 40000000}
|
||||
}};
|
||||
|
||||
inline constexpr WorldAssemblyLiminalVariantV1 SelectCoreLiminalFieldVariantV1(
|
||||
std::uint32_t seed) noexcept
|
||||
{
|
||||
return static_cast<WorldAssemblyLiminalVariantV1>(
|
||||
seed % CoreLiminalFieldVariantCountV1);
|
||||
}
|
||||
|
||||
struct WorldAssemblyRecipeV1
|
||||
{
|
||||
std::uint32_t schemaVersion = WorldAssemblySchemaVersion;
|
||||
@@ -645,6 +710,23 @@ namespace VoxelForgeCore
|
||||
std::array<std::int64_t, 4> parametersSubvoxels{};
|
||||
};
|
||||
|
||||
struct WorldAssemblyLiminalFieldPlanV1
|
||||
{
|
||||
std::uint32_t version = 0;
|
||||
WorldAssemblyLiminalVariantV1 variant = WorldAssemblyLiminalVariantV1::flatFieldMonolith;
|
||||
std::uint32_t seed = 0;
|
||||
std::array<std::int64_t, 3> arrivalMouthSubvoxels{};
|
||||
std::array<std::int64_t, 3> departureMouthSubvoxels{};
|
||||
// Fixed distance from each passage player-center anchor to its walk surface.
|
||||
std::int64_t mouthFloorOffsetSubvoxels = 0;
|
||||
std::int64_t floorHeightSubvoxels = 0;
|
||||
std::int64_t routeHalfWidthSubvoxels = 0;
|
||||
std::int64_t routeHalfThicknessSubvoxels = 0;
|
||||
std::int64_t routeOffsetSubvoxels = 0;
|
||||
std::int64_t supportSpacingSubvoxels = 0;
|
||||
std::int64_t supportRadiusSubvoxels = 0;
|
||||
};
|
||||
|
||||
struct WorldAssemblyStratumPlanV1
|
||||
{
|
||||
std::int32_t strateIndex = -1;
|
||||
@@ -669,6 +751,7 @@ namespace VoxelForgeCore
|
||||
std::vector<WorldAssemblyFeatureV1> features;
|
||||
WorldAssemblyRouteConstraintsV1 routeConstraints;
|
||||
std::vector<WorldAssemblyRoutePointV1> route;
|
||||
WorldAssemblyLiminalFieldPlanV1 liminalField;
|
||||
std::uint64_t contentHash = 0;
|
||||
};
|
||||
|
||||
@@ -882,6 +965,10 @@ namespace VoxelForgeCore
|
||||
inline constexpr std::uint32_t ResolvedLayoutStampVocabularyVersionV1 = 1;
|
||||
inline constexpr std::uint32_t ResolvedLayoutStampVocabularyVersionV2 = 2;
|
||||
inline constexpr std::uint32_t ResolvedLayoutStampVocabularyVersionV3 = 3;
|
||||
inline constexpr std::uint32_t ResolvedLayoutStampVocabularyVersionV4 = 4;
|
||||
inline constexpr std::uint32_t ResolvedLayoutStampVocabularyVersionV5 = 5;
|
||||
inline constexpr std::uint32_t ResolvedLayoutStampVocabularyVersionV6 = 6;
|
||||
inline constexpr std::uint32_t ResolvedLayoutStampVocabularyVersionV7 = 7;
|
||||
inline constexpr std::uint32_t ResolvedLayoutCavernGraphVocabularyVersionV1 = 1;
|
||||
inline constexpr std::uint32_t ResolvedLayoutOreVeinVocabularyVersionV1 = 1;
|
||||
enum class ResolvedLayoutValidationModeV3 : std::uint8_t { compatibility, strict };
|
||||
@@ -895,7 +982,11 @@ namespace VoxelForgeCore
|
||||
fillPillar = 3,
|
||||
fillDais = 4,
|
||||
fillMonolith = 5,
|
||||
fillPlatform = 6
|
||||
fillPlatform = 6,
|
||||
fillSweptDeckSegment = 7,
|
||||
fillBox = 8,
|
||||
carveBox = 9,
|
||||
fillSteps = 10
|
||||
};
|
||||
|
||||
struct ResolvedLayoutStampRequestV1
|
||||
@@ -905,6 +996,12 @@ namespace VoxelForgeCore
|
||||
ResolvedSdfStampKindV1 kind = ResolvedSdfStampKindV1::carveEllipsoid;
|
||||
// A preferred center and a bounded deterministic search radius.
|
||||
std::array<std::int64_t, 3> anchorPositionMicrometres{};
|
||||
// Present only for fillSweptDeckSegment; defines its directed centerline.
|
||||
std::array<std::array<std::int64_t, 3>, 2> sweepEndpointsMicrometres{};
|
||||
// V5 permits one continuous swept deck to carry its complete sampled centerline.
|
||||
// V6 also permits a path whose X coordinates are not strictly increasing.
|
||||
// Empty retains the V4 single-segment interpretation.
|
||||
std::vector<std::array<std::int64_t, 3>> sweepPathMicrometres;
|
||||
std::int64_t placementRadiusMicrometres = 0;
|
||||
std::array<std::int64_t, 3> halfExtentsMicrometres{};
|
||||
std::int64_t smoothWidthMicrometres = 0;
|
||||
@@ -912,6 +1009,9 @@ namespace VoxelForgeCore
|
||||
std::int64_t irregularityWavelengthMicrometres = 0;
|
||||
std::int64_t roundingRadiusMicrometres = 0;
|
||||
std::int64_t minimumSpacingMicrometres = 0;
|
||||
// Vocabulary 7: fillSteps divides the X and Z extents into this many
|
||||
// identical treads and risers. Other stamp kinds keep this at zero.
|
||||
std::uint16_t stepCount = 0;
|
||||
// 0 = free placement, 1 = seated on the authored flat profile floor.
|
||||
std::uint8_t attachmentPolicy = 0;
|
||||
std::int64_t floorMicrometres = 0;
|
||||
@@ -924,6 +1024,8 @@ namespace VoxelForgeCore
|
||||
std::int32_t strateIndex = -1;
|
||||
ResolvedSdfStampKindV1 kind = ResolvedSdfStampKindV1::carveEllipsoid;
|
||||
std::array<std::int64_t, 3> positionMicrometres{};
|
||||
std::array<std::array<std::int64_t, 3>, 2> sweepEndpointsMicrometres{};
|
||||
std::vector<std::array<std::int64_t, 3>> sweepPathMicrometres;
|
||||
std::array<std::int64_t, 3> boundsMinimumMicrometres{};
|
||||
std::array<std::int64_t, 3> boundsMaximumMicrometres{};
|
||||
std::array<std::int64_t, 3> halfExtentsMicrometres{};
|
||||
@@ -932,6 +1034,7 @@ namespace VoxelForgeCore
|
||||
std::int64_t irregularityWavelengthMicrometres = 0;
|
||||
std::int64_t roundingRadiusMicrometres = 0;
|
||||
std::int64_t minimumSpacingMicrometres = 0;
|
||||
std::uint16_t stepCount = 0;
|
||||
std::uint8_t attachmentPolicy = 0;
|
||||
std::int64_t floorMicrometres = 0;
|
||||
std::uint64_t irregularitySeed = 0;
|
||||
@@ -1041,7 +1144,6 @@ namespace VoxelForgeCore
|
||||
{
|
||||
bool hasSurfaceLanding = false;
|
||||
bool hasFloatingIslandLanding = false;
|
||||
bool usePassageAnchorForColumns = false;
|
||||
bool hasNativeCaveField = false;
|
||||
bool hasNativeLatticeField = false;
|
||||
};
|
||||
@@ -1052,6 +1154,27 @@ namespace VoxelForgeCore
|
||||
bool surfaceToTunnelLandingSupport = false;
|
||||
};
|
||||
|
||||
enum class ResolvedLayoutLandingPadRoleV1 : std::uint32_t
|
||||
{
|
||||
arrival = 0,
|
||||
departure = 1
|
||||
};
|
||||
|
||||
struct ResolvedLayoutLandingPadV1
|
||||
{
|
||||
std::string stableId;
|
||||
ResolvedLayoutLandingPadRoleV1 role = ResolvedLayoutLandingPadRoleV1::arrival;
|
||||
// Capsule-center position. floorHeightSubvoxels is the supported floor below it.
|
||||
std::array<std::int64_t, 3> positionSubvoxels{};
|
||||
std::int64_t floorHeightSubvoxels = 0;
|
||||
// Unit horizontal travel direction, signed Q16, oriented into the layer route.
|
||||
std::array<std::int32_t, 2> approachDirectionQ16{};
|
||||
// Certified horizontal free radius around the capsule center and support below the floor.
|
||||
std::int64_t clearanceSubvoxels = 0;
|
||||
std::int64_t supportDepthSubvoxels = 0;
|
||||
bool certifiedWalkableWithSupport = false;
|
||||
};
|
||||
|
||||
struct ResolvedLayoutStratumV2
|
||||
{
|
||||
std::string stableId;
|
||||
@@ -1081,6 +1204,7 @@ namespace VoxelForgeCore
|
||||
std::int64_t managerChunkWidthMicrometres = 0;
|
||||
std::int64_t dispatchChunkWidthMicrometres = 0;
|
||||
std::vector<ResolvedLayoutAnchorV2> anchors;
|
||||
std::vector<ResolvedLayoutLandingPadV1> landingPads;
|
||||
ResolvedLayoutStratePassageCapabilitiesV1 passageCapabilities;
|
||||
ResolvedLayoutInterfaceCapabilitiesV1 lowerInterfaceCapabilities;
|
||||
};
|
||||
@@ -1145,28 +1269,6 @@ namespace VoxelForgeCore
|
||||
std::vector<ResolvedLayoutPassagePointV1> controlPoints;
|
||||
};
|
||||
|
||||
struct PassageVoidCrossingAuditV1
|
||||
{
|
||||
std::size_t passageIndex = 0;
|
||||
std::size_t firstSegment = 0;
|
||||
std::size_t lastSegment = 0;
|
||||
std::uint64_t unsupportedSamples = 0;
|
||||
double unsupportedLengthMetres = 0.0;
|
||||
std::array<double, 4> startPointSubvoxels{};
|
||||
std::array<double, 4> endPointSubvoxels{};
|
||||
bool needsColumn = false;
|
||||
bool connectedAtStart = false;
|
||||
bool connectedAtEnd = false;
|
||||
std::array<std::int64_t, 2> columnAxisSubvoxels{};
|
||||
std::int64_t columnTopZSubvoxels = 0;
|
||||
std::int64_t columnBottomZSubvoxels = 0;
|
||||
std::int64_t columnMinimumRadiusSubvoxels = 0;
|
||||
std::int64_t columnNominalRadiusSubvoxels = 0;
|
||||
std::int64_t columnMaximumRadiusSubvoxels = 0;
|
||||
double maximumRouteOffsetMetres = 0.0;
|
||||
double maximumBoreRadiusMetres = 0.0;
|
||||
};
|
||||
|
||||
struct ResolvedLayoutPassageConfigV1
|
||||
{
|
||||
std::int32_t upperStrateIndex = -1;
|
||||
@@ -1242,6 +1344,15 @@ namespace VoxelForgeCore
|
||||
ResolvedLayoutSnapshotV3& layout, std::int32_t seed,
|
||||
const std::vector<ResolvedLayoutPassageConfigV1>& configs,
|
||||
std::string& error);
|
||||
VFCORE_API bool PlanCoreLiminalFieldV1(
|
||||
WorldAssemblyRecipeV1& recipe, WorldAssemblyStratumPlanV1& stratum,
|
||||
const ResolvedLayoutSnapshotV3& layout, std::string& error);
|
||||
VFCORE_API bool PrepareCoreLiminalLandingPadsV2(
|
||||
WorldAssemblyRecipeV1& recipe, WorldAssemblyStratumPlanV1& stratum,
|
||||
ResolvedLayoutSnapshotV3& layout, std::string& error);
|
||||
VFCORE_API bool PlanCoreLiminalFieldV2(
|
||||
WorldAssemblyRecipeV1& recipe, WorldAssemblyStratumPlanV1& stratum,
|
||||
const ResolvedLayoutSnapshotV3& layout, std::string& error);
|
||||
|
||||
struct ResolvedLayoutStampPlacementAuditV1
|
||||
{
|
||||
@@ -1836,10 +1947,6 @@ namespace VoxelForgeCore
|
||||
bool profileMesherStages = false;
|
||||
// Research-only: count passage-composer work per generated tile.
|
||||
bool profilePassageComposer = false;
|
||||
// Research-only field comparison. A nonnegative value clamps the source density
|
||||
// entering passage composition to at least -N voxel units for this tile.
|
||||
// A negative value leaves the process-level diagnostic override in control.
|
||||
double diagnosticPassageSourceFloorVoxels = -1.0;
|
||||
// Research-only field comparison. Zero keeps the unbounded passage carve;
|
||||
// a positive value bounds its signed-distance reach in metres. A negative
|
||||
// value leaves the process-level L93 setting in control.
|
||||
@@ -1932,12 +2039,6 @@ namespace VoxelForgeCore
|
||||
OreMiningYieldV1 measureOreMiningYieldForSphere(
|
||||
Vec3d centerVoxel, double radiusMetres,
|
||||
double sampleSpacingMetres = 0.25) const;
|
||||
// Audit-only view of density-field columns and short spans supporting passage voids.
|
||||
std::vector<PassageVoidCrossingAuditV1> passageVoidCrossings() const;
|
||||
bool isPassageColumnSample(std::size_t passageIndex, std::size_t segmentIndex,
|
||||
Vec3d voxelPosition) const;
|
||||
bool isPassageShortSpanSample(std::size_t passageIndex, std::size_t segmentIndex,
|
||||
Vec3d voxelPosition) const;
|
||||
// Audit-only selector provenance for the same point evaluator; never used by meshing.
|
||||
std::string densityDispatchPathAt(Vec3d voxelPosition) const;
|
||||
// Analytic source distance without the occupancy-preserving near-zero clamp or packed proxy.
|
||||
|
||||
@@ -176,7 +176,7 @@ namespace
|
||||
{
|
||||
const auto value=static_cast<std::uint8_t>(kind);
|
||||
return value>=static_cast<std::uint8_t>(ResolvedSdfStampKindV1::carveEllipsoid)
|
||||
&&value<=static_cast<std::uint8_t>(ResolvedSdfStampKindV1::fillPlatform);
|
||||
&&value<=static_cast<std::uint8_t>(ResolvedSdfStampKindV1::fillSteps);
|
||||
}
|
||||
bool LayoutStampIsMotif(ResolvedSdfStampKindV1 kind)
|
||||
{return kind==ResolvedSdfStampKindV1::fillMonolith
|
||||
@@ -249,6 +249,71 @@ namespace
|
||||
/WorldAssemblySubvoxelsPerVoxel));
|
||||
return result;
|
||||
};
|
||||
const bool liminalRouteStamp=strate->recipeId==CoreLiminalFieldRecipeIdV1
|
||||
&&stamp.stableId.starts_with("core/liminal-field/route/")
|
||||
&&(stamp.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment
|
||||
||stamp.kind==ResolvedSdfStampKindV1::fillPillar);
|
||||
if(liminalRouteStamp)
|
||||
{
|
||||
const auto variant=std::find_if(strate->anchors.begin(),strate->anchors.end(),
|
||||
[](const auto& anchor){return anchor.kind=="liminal_variant"
|
||||
&&anchor.values.size()>=6;});
|
||||
std::vector<const ResolvedLayoutAnchorV2*> points;
|
||||
for(const auto& anchor:strate->anchors)
|
||||
if(anchor.kind=="liminal_route_point"&&anchor.values.size()>=4)
|
||||
points.push_back(&anchor);
|
||||
if(variant==strate->anchors.end()||points.size()<2)
|
||||
{if(reason)*reason="Core.Liminal.Field route stamp has no versioned fixed-mouth route";return false;}
|
||||
const auto routePoint=[&](const ResolvedLayoutAnchorV2& anchor)
|
||||
{
|
||||
std::array<std::int64_t,3> q={anchor.values[0],anchor.values[1],anchor.values[2]};
|
||||
return qToMicrometres(q);
|
||||
};
|
||||
const std::int64_t halfThickness=static_cast<std::int64_t>(std::llround(
|
||||
static_cast<double>(variant->values[5])*coreMicrometres
|
||||
/WorldAssemblySubvoxelsPerVoxel));
|
||||
if(stamp.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment)
|
||||
{
|
||||
const auto first=routePoint(*points.front());
|
||||
const auto last=routePoint(*points.back());
|
||||
if(stamp.sweepPathMicrometres.size()<2
|
||||
||stamp.sweepPathMicrometres.front()[0]!=first[0]
|
||||
||stamp.sweepPathMicrometres.front()[1]!=first[1]
|
||||
||stamp.sweepPathMicrometres.front()[2]!=first[2]-halfThickness
|
||||
||stamp.sweepPathMicrometres.back()[0]!=last[0]
|
||||
||stamp.sweepPathMicrometres.back()[1]!=last[1]
|
||||
||stamp.sweepPathMicrometres.back()[2]!=last[2]-halfThickness)
|
||||
{if(reason)*reason="Core.Liminal.Field deck does not terminate at its fixed passage mouths";return false;}
|
||||
return true;
|
||||
}
|
||||
const auto deck=std::find_if(layout.stamps.begin(),layout.stamps.end(),
|
||||
[&](const auto& existing){return existing.strateIndex==stamp.strateIndex
|
||||
&&existing.stableId=="core/liminal-field/route/deck/000"
|
||||
&&existing.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment;});
|
||||
if(deck==layout.stamps.end()||deck->sweepPathMicrometres.size()<2)
|
||||
{if(reason)*reason="Core.Liminal.Field support has no previously resolved route deck";return false;}
|
||||
double distance=std::numeric_limits<double>::infinity();
|
||||
for(std::size_t i=1;i<deck->sweepPathMicrometres.size();++i)
|
||||
{
|
||||
const auto& a=deck->sweepPathMicrometres[i-1];
|
||||
const auto& b=deck->sweepPathMicrometres[i];
|
||||
const double dx=static_cast<double>(b[0]-a[0]);
|
||||
const double dy=static_cast<double>(b[1]-a[1]);
|
||||
const double lengthSquared=dx*dx+dy*dy;
|
||||
const double t=lengthSquared>0.0?std::clamp(
|
||||
((stamp.positionMicrometres[0]-a[0])*dx
|
||||
+(stamp.positionMicrometres[1]-a[1])*dy)/lengthSquared,
|
||||
0.0,1.0):0.0;
|
||||
const double offsetX=stamp.positionMicrometres[0]-(a[0]+t*dx);
|
||||
const double offsetY=stamp.positionMicrometres[1]-(a[1]+t*dy);
|
||||
distance=std::min(distance,std::hypot(offsetX,offsetY));
|
||||
}
|
||||
const double corridorRadius=deck->halfExtentsMicrometres[1]
|
||||
+stamp.halfExtentsMicrometres[0]+RouteClearanceMicrometres;
|
||||
if(distance>corridorRadius)
|
||||
{if(reason)*reason="Core.Liminal.Field route stamp is outside its versioned route corridor";return false;}
|
||||
return true;
|
||||
}
|
||||
for(const auto& passage:layout.passages)
|
||||
{
|
||||
if(passage.upperStrateIndex!=stamp.strateIndex
|
||||
@@ -548,7 +613,28 @@ namespace
|
||||
for(std::size_t v=0;v<anchor.values.size();++v){if(v)out<<',';out<<anchor.values[v];}
|
||||
out<<"]}";
|
||||
}
|
||||
out<<"]}";
|
||||
out<<']';
|
||||
if(!x.landingPads.empty())
|
||||
{
|
||||
out<<",\"landing_pads\":[";
|
||||
for(std::size_t p=0;p<x.landingPads.size();++p)
|
||||
{
|
||||
if(p)out<<',';const auto& pad=x.landingPads[p];
|
||||
out<<"{\"stable_id\":"<<LayoutEscape(pad.stableId)
|
||||
<<",\"role\":"<<static_cast<std::uint32_t>(pad.role)
|
||||
<<",\"position_q\":["<<pad.positionSubvoxels[0]<<','
|
||||
<<pad.positionSubvoxels[1]<<','<<pad.positionSubvoxels[2]
|
||||
<<"],\"floor_q\":"<<pad.floorHeightSubvoxels
|
||||
<<",\"approach_q16\":["<<pad.approachDirectionQ16[0]<<','
|
||||
<<pad.approachDirectionQ16[1]<<']'
|
||||
<<",\"clearance_q\":"<<pad.clearanceSubvoxels
|
||||
<<",\"support_depth_q\":"<<pad.supportDepthSubvoxels
|
||||
<<",\"certified\":"
|
||||
<<(pad.certifiedWalkableWithSupport?"true":"false")<<'}';
|
||||
}
|
||||
out<<']';
|
||||
}
|
||||
out<<'}';
|
||||
}
|
||||
out<<"],\"passages\":[";
|
||||
for(std::size_t i=0;i<s.passages.size();++i)
|
||||
@@ -598,11 +684,35 @@ namespace
|
||||
<<",\"irregularity_wavelength_um\":"<<stamp.irregularityWavelengthMicrometres
|
||||
<<",\"irregularity_seed\":"<<stamp.irregularitySeed
|
||||
<<",\"yaw_quarter_turns\":"<<static_cast<unsigned>(stamp.yawQuarterTurns);
|
||||
if(stamp.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment)
|
||||
{
|
||||
out<<",\"sweep_endpoints_um\":[["
|
||||
<<stamp.sweepEndpointsMicrometres[0][0]<<','
|
||||
<<stamp.sweepEndpointsMicrometres[0][1]<<','
|
||||
<<stamp.sweepEndpointsMicrometres[0][2]<<"],["
|
||||
<<stamp.sweepEndpointsMicrometres[1][0]<<','
|
||||
<<stamp.sweepEndpointsMicrometres[1][1]<<','
|
||||
<<stamp.sweepEndpointsMicrometres[1][2]<<"]]";
|
||||
if(!stamp.sweepPathMicrometres.empty())
|
||||
{
|
||||
out<<",\"sweep_path_um\":[";
|
||||
for(std::size_t pointIndex=0;pointIndex<stamp.sweepPathMicrometres.size();
|
||||
++pointIndex)
|
||||
{
|
||||
if(pointIndex)out<<',';
|
||||
const auto& point=stamp.sweepPathMicrometres[pointIndex];
|
||||
out<<'['<<point[0]<<','<<point[1]<<','<<point[2]<<']';
|
||||
}
|
||||
out<<']';
|
||||
}
|
||||
}
|
||||
if(s.stampVocabularyVersion>=ResolvedLayoutStampVocabularyVersionV3)
|
||||
out<<",\"rounding_radius_um\":"<<stamp.roundingRadiusMicrometres
|
||||
<<",\"minimum_spacing_um\":"<<stamp.minimumSpacingMicrometres
|
||||
<<",\"attachment_policy\":"<<static_cast<unsigned>(stamp.attachmentPolicy)
|
||||
<<",\"floor_um\":"<<stamp.floorMicrometres;
|
||||
if(stamp.kind==ResolvedSdfStampKindV1::fillSteps)
|
||||
out<<",\"step_count\":"<<stamp.stepCount;
|
||||
out<<'}';
|
||||
}
|
||||
out<<']';
|
||||
@@ -870,6 +980,9 @@ bool ResolvedLayoutSnapshotV3::finalize(std::string& error)
|
||||
for(std::size_t i=0;i<strates.size();++i)
|
||||
{
|
||||
auto& x=strates[i];x.order=static_cast<std::int32_t>(i);
|
||||
std::stable_sort(x.landingPads.begin(),x.landingPads.end(),[](const auto& a,const auto& b)
|
||||
{return a.role!=b.role?static_cast<std::uint32_t>(a.role)
|
||||
<static_cast<std::uint32_t>(b.role):a.stableId<b.stableId;});
|
||||
if(x.stableId.empty())x.stableId=worldIdentity+"/strate/"+std::to_string(x.strateIndex);
|
||||
if(x.content.kind.empty())x.content.kind="content";
|
||||
if(x.ceiling.kind.empty())x.ceiling.kind="ceiling";
|
||||
@@ -888,10 +1001,7 @@ bool ResolvedLayoutSnapshotV3::finalize(std::string& error)
|
||||
else if(IsNativeLatticeFieldStratum(x))
|
||||
x.passageCapabilities.hasNativeLatticeField=true;
|
||||
else if(x.generatorKind=="FloatingIslands")
|
||||
{
|
||||
x.passageCapabilities.hasFloatingIslandLanding=true;
|
||||
x.passageCapabilities.usePassageAnchorForColumns=true;
|
||||
}
|
||||
if(i+1<strates.size())
|
||||
{
|
||||
const auto& lower=strates[i+1];
|
||||
@@ -933,9 +1043,40 @@ bool ResolvedLayoutSnapshotV3::validate(ResolvedLayoutValidationModeV3 mode,
|
||||
const auto add=[&](std::string code,std::string detail)
|
||||
{issues.push_back({std::move(code),std::move(detail)});};
|
||||
const bool strict=mode==ResolvedLayoutValidationModeV3::strict;
|
||||
const std::int64_t playerCenterAboveFloorQ=static_cast<std::int64_t>(std::llround(
|
||||
1.13*WorldAssemblySubvoxelsPerVoxel*1000000.0/coreVoxelSizeMicrometres));
|
||||
for(std::size_t i=0;i<strates.size();++i)
|
||||
{
|
||||
const auto& s=strates[i];
|
||||
std::vector<std::string> landingPadIds;
|
||||
for(const auto& pad:s.landingPads)
|
||||
{
|
||||
if(pad.stableId.empty()
|
||||
||std::find(landingPadIds.begin(),landingPadIds.end(),pad.stableId)
|
||||
!=landingPadIds.end())
|
||||
add("landing_pad_identity",s.stableId+" has an empty or duplicate landing-pad ID");
|
||||
else landingPadIds.push_back(pad.stableId);
|
||||
const auto role=static_cast<std::uint32_t>(pad.role);
|
||||
const std::int64_t directionX=pad.approachDirectionQ16[0];
|
||||
const std::int64_t directionY=pad.approachDirectionQ16[1];
|
||||
const std::int64_t directionLengthSquared=directionX*directionX+directionY*directionY;
|
||||
constexpr std::int64_t UnitQ16Squared=1ll<<32;
|
||||
const bool unitDirection=directionLengthSquared>=UnitQ16Squared*98/100
|
||||
&&directionLengthSquared<=UnitQ16Squared*102/100;
|
||||
const std::int64_t floorMicrometres=pad.floorHeightSubvoxels
|
||||
*static_cast<std::int64_t>(coreVoxelSizeMicrometres)
|
||||
/WorldAssemblySubvoxelsPerVoxel;
|
||||
if(role>static_cast<std::uint32_t>(ResolvedLayoutLandingPadRoleV1::departure)
|
||||
||pad.positionSubvoxels[2]
|
||||
!=pad.floorHeightSubvoxels+playerCenterAboveFloorQ
|
||||
||!s.content.presentInCurrentField
|
||||
||floorMicrometres<s.content.bottomMicrometres
|
||||
||floorMicrometres>=s.content.topMicrometres
|
||||
||!unitDirection||pad.clearanceSubvoxels<=0
|
||||
||pad.supportDepthSubvoxels<=0
|
||||
||!pad.certifiedWalkableWithSupport)
|
||||
add("landing_pad_contract",s.stableId+" publishes an uncertified or malformed landing pad");
|
||||
}
|
||||
const std::int64_t effectiveTransitionWidth=LayoutTransitionLimit(s);
|
||||
if(s.interiorTopMicrometres<=s.interiorBottomMicrometres)
|
||||
add("invalid_interior",s.stableId+" has non-positive content height");
|
||||
@@ -1137,8 +1278,12 @@ bool ResolvedLayoutSnapshotV3::validate(ResolvedLayoutValidationModeV3 mode,
|
||||
}
|
||||
else if(stampVocabularyVersion!=ResolvedLayoutStampVocabularyVersionV1
|
||||
&&stampVocabularyVersion!=ResolvedLayoutStampVocabularyVersionV2
|
||||
&&stampVocabularyVersion!=ResolvedLayoutStampVocabularyVersionV3)
|
||||
add("stamp_vocabulary_version","resolved stamps require supported vocabulary version 1, 2 or 3");
|
||||
&&stampVocabularyVersion!=ResolvedLayoutStampVocabularyVersionV3
|
||||
&&stampVocabularyVersion!=ResolvedLayoutStampVocabularyVersionV4
|
||||
&&stampVocabularyVersion!=ResolvedLayoutStampVocabularyVersionV5
|
||||
&&stampVocabularyVersion!=ResolvedLayoutStampVocabularyVersionV6
|
||||
&&stampVocabularyVersion!=ResolvedLayoutStampVocabularyVersionV7)
|
||||
add("stamp_vocabulary_version","resolved stamps require supported vocabulary version 1 through 7");
|
||||
std::vector<std::string> stampIds;
|
||||
for(const auto& stamp:stamps)
|
||||
{
|
||||
@@ -1148,8 +1293,20 @@ bool ResolvedLayoutSnapshotV3::validate(ResolvedLayoutValidationModeV3 mode,
|
||||
else stampIds.push_back(stamp.stableId);
|
||||
const bool motif=LayoutStampIsMotif(stamp.kind);
|
||||
if(!LayoutStampKindValid(stamp.kind)||stamp.yawQuarterTurns>3
|
||||
||(stamp.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment
|
||||
&&stampVocabularyVersion<ResolvedLayoutStampVocabularyVersionV4)
|
||||
||((stamp.kind==ResolvedSdfStampKindV1::fillBox
|
||||
||stamp.kind==ResolvedSdfStampKindV1::carveBox
|
||||
||stamp.kind==ResolvedSdfStampKindV1::fillSteps)
|
||||
&&stampVocabularyVersion<ResolvedLayoutStampVocabularyVersionV7)
|
||||
||(motif&&stampVocabularyVersion<ResolvedLayoutStampVocabularyVersionV3))
|
||||
add("stamp_kind","stamp has an unsupported kind or orientation");
|
||||
if((stamp.kind==ResolvedSdfStampKindV1::fillSteps
|
||||
&&(stampVocabularyVersion<ResolvedLayoutStampVocabularyVersionV7
|
||||
||stamp.stepCount<2||stamp.stepCount>128
|
||||
||stamp.roundingRadiusMicrometres!=0))
|
||||
||(stamp.kind!=ResolvedSdfStampKindV1::fillSteps&&stamp.stepCount!=0))
|
||||
add("stamp_steps",stamp.stableId+" has an invalid step count or rounding mode");
|
||||
bool validExtents=true;
|
||||
for(const auto extent:stamp.halfExtentsMicrometres)
|
||||
validExtents=validExtents&&extent>=500000&&extent<=100000000;
|
||||
@@ -1162,6 +1319,49 @@ bool ResolvedLayoutSnapshotV3::validate(ResolvedLayoutValidationModeV3 mode,
|
||||
||(stamp.irregularityAmplitudeMicrometres>0
|
||||
&&stamp.irregularityWavelengthMicrometres<=0))
|
||||
add("stamp_parameters",stamp.stableId+" has invalid or unbounded geometry parameters");
|
||||
if(stamp.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment)
|
||||
{
|
||||
double lengthSquared=0.0;
|
||||
for(std::size_t axis=0;axis<3;++axis)
|
||||
{
|
||||
const auto a=stamp.sweepEndpointsMicrometres[0][axis];
|
||||
const auto b=stamp.sweepEndpointsMicrometres[1][axis];
|
||||
if(a<-1000000000000ll||a>1000000000000ll
|
||||
||b<-1000000000000ll||b>1000000000000ll)
|
||||
add("stamp_sweep",stamp.stableId+" has an unbounded endpoint");
|
||||
const double delta=static_cast<double>(stamp.sweepEndpointsMicrometres[1][axis])
|
||||
-stamp.sweepEndpointsMicrometres[0][axis];
|
||||
lengthSquared+=delta*delta;
|
||||
}
|
||||
if(stamp.sweepPathMicrometres.empty()&&(!(lengthSquared>0.0)
|
||||
||std::sqrt(lengthSquared)>2.0*stamp.halfExtentsMicrometres[0]+1.0))
|
||||
add("stamp_sweep",stamp.stableId+" has invalid endpoints or longitudinal extent");
|
||||
if(!stamp.sweepPathMicrometres.empty())
|
||||
{
|
||||
bool validPath=stampVocabularyVersion>=ResolvedLayoutStampVocabularyVersionV5
|
||||
&&stamp.sweepPathMicrometres.size()>=2
|
||||
&&stamp.sweepPathMicrometres.size()<=1025
|
||||
&&stamp.sweepPathMicrometres.front()==stamp.sweepEndpointsMicrometres[0]
|
||||
&&stamp.sweepPathMicrometres.back()==stamp.sweepEndpointsMicrometres[1];
|
||||
for(std::size_t pointIndex=0;pointIndex<stamp.sweepPathMicrometres.size();
|
||||
++pointIndex)
|
||||
{
|
||||
const auto& point=stamp.sweepPathMicrometres[pointIndex];
|
||||
for(const auto coordinate:point)
|
||||
validPath=validPath&&coordinate>=-1000000000000ll
|
||||
&&coordinate<=1000000000000ll;
|
||||
if(pointIndex>0)
|
||||
{
|
||||
const auto& previous=stamp.sweepPathMicrometres[pointIndex-1];
|
||||
validPath=validPath&&previous!=point
|
||||
&&(stampVocabularyVersion>=ResolvedLayoutStampVocabularyVersionV6
|
||||
||previous[0]<point[0]);
|
||||
}
|
||||
}
|
||||
if(!validPath)add("stamp_sweep_path",stamp.stableId
|
||||
+" has an invalid swept-deck path for its vocabulary version");
|
||||
}
|
||||
}
|
||||
if(motif)
|
||||
{
|
||||
const auto minimumExtent=*std::min_element(stamp.halfExtentsMicrometres.begin(),
|
||||
@@ -1189,6 +1389,52 @@ bool ResolvedLayoutSnapshotV3::validate(ResolvedLayoutValidationModeV3 mode,
|
||||
const std::int64_t pad=motif
|
||||
?(stamp.smoothWidthMicrometres+5)/6+stamp.irregularityAmplitudeMicrometres
|
||||
:stamp.smoothWidthMicrometres+stamp.irregularityAmplitudeMicrometres;
|
||||
if(stamp.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment
|
||||
&&stamp.sweepPathMicrometres.empty())
|
||||
{
|
||||
double radiusSquared=0.0;
|
||||
for(const auto extent:stamp.halfExtentsMicrometres)
|
||||
{
|
||||
const double expanded=static_cast<double>(extent+pad);
|
||||
radiusSquared+=expanded*expanded;
|
||||
}
|
||||
const auto radius=static_cast<std::int64_t>(std::ceil(std::sqrt(radiusSquared)));
|
||||
for(std::size_t axis=0;axis<3;++axis)
|
||||
{
|
||||
const auto minimum=std::min(stamp.sweepEndpointsMicrometres[0][axis],
|
||||
stamp.sweepEndpointsMicrometres[1][axis]);
|
||||
const auto maximum=std::max(stamp.sweepEndpointsMicrometres[0][axis],
|
||||
stamp.sweepEndpointsMicrometres[1][axis]);
|
||||
if(stamp.boundsMinimumMicrometres[axis]!=minimum-radius
|
||||
||stamp.boundsMaximumMicrometres[axis]!=maximum+radius)
|
||||
add("stamp_bounds",stamp.stableId+" conservative sweep bounds do not match its parameters");
|
||||
}
|
||||
}
|
||||
else if(stamp.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment)
|
||||
{
|
||||
double radiusSquared=0.0;
|
||||
for(std::size_t axis=1;axis<3;++axis)
|
||||
{
|
||||
const double expanded=static_cast<double>(stamp.halfExtentsMicrometres[axis]+pad);
|
||||
radiusSquared+=expanded*expanded;
|
||||
}
|
||||
const auto radius=static_cast<std::int64_t>(std::ceil(std::sqrt(radiusSquared)));
|
||||
std::array<std::int64_t,3> minimum=stamp.sweepPathMicrometres.front();
|
||||
std::array<std::int64_t,3> maximum=minimum;
|
||||
for(const auto& point:stamp.sweepPathMicrometres)
|
||||
for(std::size_t axis=0;axis<3;++axis)
|
||||
{
|
||||
minimum[axis]=std::min(minimum[axis],point[axis]);
|
||||
maximum[axis]=std::max(maximum[axis],point[axis]);
|
||||
}
|
||||
for(std::size_t axis=0;axis<3;++axis)
|
||||
if(stamp.boundsMinimumMicrometres[axis]!=minimum[axis]-radius
|
||||
||stamp.boundsMaximumMicrometres[axis]!=maximum[axis]+radius)
|
||||
add("stamp_bounds",stamp.stableId
|
||||
+" conservative continuous-sweep bounds do not match its path");
|
||||
}
|
||||
else
|
||||
{
|
||||
const std::array<std::int64_t,3> extents={
|
||||
stamp.halfExtentsMicrometres[stamp.yawQuarterTurns%2?1:0]+pad,
|
||||
stamp.halfExtentsMicrometres[stamp.yawQuarterTurns%2?0:1]+pad,
|
||||
@@ -1199,6 +1445,7 @@ bool ResolvedLayoutSnapshotV3::validate(ResolvedLayoutValidationModeV3 mode,
|
||||
||stamp.boundsMaximumMicrometres[axis]
|
||||
!=stamp.positionMicrometres[axis]+extents[axis])
|
||||
add("stamp_bounds",stamp.stableId+" conservative bounds do not match its parameters");
|
||||
}
|
||||
std::string safetyReason;
|
||||
if(!LayoutStampSafe(*this,stamp,&safetyReason))
|
||||
add("stamp_placement",stamp.stableId+" is unsafe: "+safetyReason);
|
||||
@@ -1464,6 +1711,22 @@ std::optional<ResolvedLayoutSnapshotV3> ResolvedLayoutSnapshotV3::FromJson(
|
||||
for(const auto& a:v.Get("anchors").AsArray())
|
||||
{ResolvedLayoutAnchorV2 p;p.kind=LayoutString(a,"kind");p.stableId=LayoutString(a,"stable_id");p.units=LayoutString(a,"units");
|
||||
for(const auto& value:a.Get("values").AsArray())p.values.push_back(value.Integer());x.anchors.push_back(std::move(p));}
|
||||
for(const auto& value:v.Get("landing_pads").AsArray())
|
||||
{
|
||||
ResolvedLayoutLandingPadV1 pad;pad.stableId=LayoutString(value,"stable_id");
|
||||
pad.role=static_cast<ResolvedLayoutLandingPadRoleV1>(LayoutInt(value,"role"));
|
||||
const auto& position=value.Get("position_q").AsArray();
|
||||
for(std::size_t axis=0;axis<3&&axis<position.size();++axis)
|
||||
pad.positionSubvoxels[axis]=position[axis].Integer();
|
||||
pad.floorHeightSubvoxels=LayoutInt(value,"floor_q");
|
||||
const auto& approach=value.Get("approach_q16").AsArray();
|
||||
for(std::size_t axis=0;axis<2&&axis<approach.size();++axis)
|
||||
pad.approachDirectionQ16[axis]=static_cast<std::int32_t>(approach[axis].Integer());
|
||||
pad.clearanceSubvoxels=LayoutInt(value,"clearance_q");
|
||||
pad.supportDepthSubvoxels=LayoutInt(value,"support_depth_q");
|
||||
pad.certifiedWalkableWithSupport=LayoutBool(value,"certified");
|
||||
x.landingPads.push_back(std::move(pad));
|
||||
}
|
||||
s.strates.push_back(std::move(x));
|
||||
}
|
||||
for(const auto& v:root.Get("passages").AsArray())
|
||||
@@ -1500,6 +1763,21 @@ std::optional<ResolvedLayoutSnapshotV3> ResolvedLayoutSnapshotV3::FromJson(
|
||||
target[axis]=values[axis].Integer();
|
||||
};
|
||||
readTriplet("position_um",stamp.positionMicrometres);
|
||||
const auto& sweepEndpoints=v.Get("sweep_endpoints_um").AsArray();
|
||||
for(std::size_t endpoint=0;endpoint<2&&endpoint<sweepEndpoints.size();++endpoint)
|
||||
{
|
||||
const auto& values=sweepEndpoints[endpoint].AsArray();
|
||||
for(std::size_t axis=0;axis<3&&axis<values.size();++axis)
|
||||
stamp.sweepEndpointsMicrometres[endpoint][axis]=values[axis].Integer();
|
||||
}
|
||||
for(const auto& value:v.Get("sweep_path_um").AsArray())
|
||||
{
|
||||
std::array<std::int64_t,3> point{};
|
||||
const auto& values=value.AsArray();
|
||||
for(std::size_t axis=0;axis<3&&axis<values.size();++axis)
|
||||
point[axis]=values[axis].Integer();
|
||||
stamp.sweepPathMicrometres.push_back(point);
|
||||
}
|
||||
readTriplet("bounds_min_um",stamp.boundsMinimumMicrometres);
|
||||
readTriplet("bounds_max_um",stamp.boundsMaximumMicrometres);
|
||||
readTriplet("half_extents_um",stamp.halfExtentsMicrometres);
|
||||
@@ -1512,6 +1790,7 @@ std::optional<ResolvedLayoutSnapshotV3> ResolvedLayoutSnapshotV3::FromJson(
|
||||
stamp.floorMicrometres=LayoutInt(v,"floor_um");
|
||||
stamp.irregularitySeed=static_cast<std::uint64_t>(LayoutInt(v,"irregularity_seed"));
|
||||
stamp.yawQuarterTurns=static_cast<std::uint8_t>(LayoutInt(v,"yaw_quarter_turns"));
|
||||
stamp.stepCount=static_cast<std::uint16_t>(LayoutInt(v,"step_count"));
|
||||
s.stamps.push_back(std::move(stamp));
|
||||
}
|
||||
for(const auto& v:root.Get("cavern_rooms").AsArray())
|
||||
@@ -1622,7 +1901,31 @@ bool ResolveResolvedLayoutStampsV1(ResolvedLayoutSnapshotV3& layout,std::int32_t
|
||||
std::sort(ordered.begin(),ordered.end(),[](const auto& a,const auto& b)
|
||||
{return a.stableId<b.stableId;});
|
||||
ResolvedLayoutSnapshotV3 candidate=layout;
|
||||
candidate.stampVocabularyVersion=ResolvedLayoutStampVocabularyVersionV1;
|
||||
const bool hasContinuousSweptDeck=std::any_of(ordered.begin(),ordered.end(),[](const auto& request)
|
||||
{return request.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment
|
||||
&&!request.sweepPathMicrometres.empty();});
|
||||
const bool hasNonMonotoneSweptDeck=std::any_of(ordered.begin(),ordered.end(),[](const auto& request)
|
||||
{
|
||||
if(request.kind!=ResolvedSdfStampKindV1::fillSweptDeckSegment
|
||||
||request.sweepPathMicrometres.empty())return false;
|
||||
for(std::size_t i=1;i<request.sweepPathMicrometres.size();++i)
|
||||
if(request.sweepPathMicrometres[i-1][0]>=request.sweepPathMicrometres[i][0])
|
||||
return true;
|
||||
return false;
|
||||
});
|
||||
const bool hasSweptDeck=std::any_of(ordered.begin(),ordered.end(),[](const auto& request)
|
||||
{return request.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment;});
|
||||
const bool hasVocabulary7Stamp=std::any_of(ordered.begin(),ordered.end(),[](const auto& request)
|
||||
{return request.kind==ResolvedSdfStampKindV1::fillBox
|
||||
||request.kind==ResolvedSdfStampKindV1::carveBox
|
||||
||request.kind==ResolvedSdfStampKindV1::fillSteps;});
|
||||
candidate.stampVocabularyVersion=hasVocabulary7Stamp
|
||||
?ResolvedLayoutStampVocabularyVersionV7
|
||||
:hasContinuousSweptDeck
|
||||
?(hasNonMonotoneSweptDeck?ResolvedLayoutStampVocabularyVersionV6
|
||||
:ResolvedLayoutStampVocabularyVersionV5)
|
||||
:hasSweptDeck?ResolvedLayoutStampVocabularyVersionV4
|
||||
:ResolvedLayoutStampVocabularyVersionV1;
|
||||
candidate.stamps.clear();candidate.stamps.reserve(ordered.size());
|
||||
for(std::size_t requestIndex=0;requestIndex<ordered.size();++requestIndex)
|
||||
{
|
||||
@@ -1631,8 +1934,14 @@ bool ResolveResolvedLayoutStampsV1(ResolvedLayoutSnapshotV3& layout,std::int32_t
|
||||
&&ordered[requestIndex-1].stableId==request.stableId))
|
||||
{error="stamp requests require non-empty unique stable IDs";return false;}
|
||||
if(!LayoutStampKindValid(request.kind)||request.yawQuarterTurns>3
|
||||
||(request.kind==ResolvedSdfStampKindV1::fillSteps
|
||||
?(request.stepCount<2||request.stepCount>128
|
||||
||request.roundingRadiusMicrometres!=0)
|
||||
:request.stepCount!=0)
|
||||
||request.placementRadiusMicrometres<0
|
||||
||request.placementRadiusMicrometres>100000000
|
||||
||(request.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment
|
||||
&&request.placementRadiusMicrometres!=0)
|
||||
||request.smoothWidthMicrometres<0||request.smoothWidthMicrometres>10000000
|
||||
||request.irregularityAmplitudeMicrometres<0
|
||||
||request.irregularityAmplitudeMicrometres>10000000
|
||||
@@ -1651,6 +1960,43 @@ bool ResolveResolvedLayoutStampsV1(ResolvedLayoutSnapshotV3& layout,std::int32_t
|
||||
for(const auto coordinate:request.anchorPositionMicrometres)
|
||||
if(coordinate<-1000000000000ll||coordinate>1000000000000ll)
|
||||
{error="stamp request "+request.stableId+" has an unbounded anchor";return false;}
|
||||
if(request.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment
|
||||
&&request.sweepPathMicrometres.empty())
|
||||
{
|
||||
double lengthSquared=0.0;
|
||||
for(std::size_t axis=0;axis<3;++axis)
|
||||
{
|
||||
const auto a=request.sweepEndpointsMicrometres[0][axis];
|
||||
const auto b=request.sweepEndpointsMicrometres[1][axis];
|
||||
if(a<-1000000000000ll||a>1000000000000ll
|
||||
||b<-1000000000000ll||b>1000000000000ll)
|
||||
{error="stamp request "+request.stableId+" has an unbounded sweep endpoint";return false;}
|
||||
const double delta=static_cast<double>(b)-a;
|
||||
lengthSquared+=delta*delta;
|
||||
}
|
||||
if(!(lengthSquared>0.0)
|
||||
||std::sqrt(lengthSquared)>2.0*request.halfExtentsMicrometres[0]+1.0)
|
||||
{error="stamp request "+request.stableId+" has invalid swept-segment extents";return false;}
|
||||
}
|
||||
if(request.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment
|
||||
&&!request.sweepPathMicrometres.empty())
|
||||
{
|
||||
if(request.sweepPathMicrometres.size()<2
|
||||
||request.sweepPathMicrometres.size()>1025
|
||||
||request.sweepPathMicrometres.front()!=request.sweepEndpointsMicrometres[0]
|
||||
||request.sweepPathMicrometres.back()!=request.sweepEndpointsMicrometres[1])
|
||||
{error="stamp request "+request.stableId+" has an invalid continuous deck path";return false;}
|
||||
for(std::size_t pointIndex=0;pointIndex<request.sweepPathMicrometres.size();
|
||||
++pointIndex)
|
||||
{
|
||||
const auto& point=request.sweepPathMicrometres[pointIndex];
|
||||
for(const auto coordinate:point)
|
||||
if(coordinate<-1000000000000ll||coordinate>1000000000000ll)
|
||||
{error="stamp request "+request.stableId+" has an unbounded path point";return false;}
|
||||
if(pointIndex>0&&request.sweepPathMicrometres[pointIndex-1]==point)
|
||||
{error="stamp request "+request.stableId+" path contains a zero-length segment";return false;}
|
||||
}
|
||||
}
|
||||
|
||||
// The shared JSON value type stores numbers as IEEE-754 doubles; keep the
|
||||
// serialized seed in its exact integer range so the canonical round trip holds.
|
||||
@@ -1665,14 +2011,18 @@ bool ResolveResolvedLayoutStampsV1(ResolvedLayoutSnapshotV3& layout,std::int32_t
|
||||
return static_cast<std::int64_t>(bits%span)-radius;
|
||||
};
|
||||
bool placed=false;
|
||||
std::string lastPlacementFailure;
|
||||
for(std::uint32_t attempt=0;attempt<(request.placementRadiusMicrometres==0?1u:512u);++attempt)
|
||||
{
|
||||
ResolvedLayoutStampV1 stamp;stamp.stableId=request.stableId;
|
||||
stamp.strateIndex=request.strateIndex;stamp.kind=request.kind;
|
||||
stamp.sweepEndpointsMicrometres=request.sweepEndpointsMicrometres;
|
||||
stamp.sweepPathMicrometres=request.sweepPathMicrometres;
|
||||
stamp.halfExtentsMicrometres=request.halfExtentsMicrometres;
|
||||
stamp.smoothWidthMicrometres=request.smoothWidthMicrometres;
|
||||
stamp.irregularityAmplitudeMicrometres=request.irregularityAmplitudeMicrometres;
|
||||
stamp.irregularityWavelengthMicrometres=request.irregularityWavelengthMicrometres;
|
||||
stamp.stepCount=request.stepCount;
|
||||
stamp.irregularitySeed=baseSeed;stamp.yawQuarterTurns=request.yawQuarterTurns;
|
||||
std::array<std::int64_t,3> offset{};
|
||||
if(request.placementRadiusMicrometres>0)
|
||||
@@ -1690,6 +2040,52 @@ bool ResolveResolvedLayoutStampsV1(ResolvedLayoutSnapshotV3& layout,std::int32_t
|
||||
stamp.positionMicrometres[axis]=request.anchorPositionMicrometres[axis]+offset[axis];
|
||||
const std::int64_t pad=stamp.smoothWidthMicrometres
|
||||
+stamp.irregularityAmplitudeMicrometres;
|
||||
if(stamp.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment
|
||||
&&stamp.sweepPathMicrometres.empty())
|
||||
{
|
||||
double radiusSquared=0.0;
|
||||
for(const auto extent:stamp.halfExtentsMicrometres)
|
||||
{
|
||||
const double expanded=static_cast<double>(extent+pad);
|
||||
radiusSquared+=expanded*expanded;
|
||||
}
|
||||
const std::int64_t radius=static_cast<std::int64_t>(std::ceil(std::sqrt(radiusSquared)));
|
||||
for(std::size_t axis=0;axis<3;++axis)
|
||||
{
|
||||
const auto minimum=std::min(stamp.sweepEndpointsMicrometres[0][axis],
|
||||
stamp.sweepEndpointsMicrometres[1][axis]);
|
||||
const auto maximum=std::max(stamp.sweepEndpointsMicrometres[0][axis],
|
||||
stamp.sweepEndpointsMicrometres[1][axis]);
|
||||
stamp.boundsMinimumMicrometres[axis]=minimum-radius;
|
||||
stamp.boundsMaximumMicrometres[axis]=maximum+radius;
|
||||
}
|
||||
}
|
||||
else if(stamp.kind==ResolvedSdfStampKindV1::fillSweptDeckSegment)
|
||||
{
|
||||
double radiusSquared=0.0;
|
||||
for(std::size_t axis=1;axis<3;++axis)
|
||||
{
|
||||
const double expanded=static_cast<double>(stamp.halfExtentsMicrometres[axis]+pad);
|
||||
radiusSquared+=expanded*expanded;
|
||||
}
|
||||
const std::int64_t radius=static_cast<std::int64_t>(
|
||||
std::ceil(std::sqrt(radiusSquared)));
|
||||
std::array<std::int64_t,3> minimum=stamp.sweepPathMicrometres.front();
|
||||
std::array<std::int64_t,3> maximum=minimum;
|
||||
for(const auto& point:stamp.sweepPathMicrometres)
|
||||
for(std::size_t axis=0;axis<3;++axis)
|
||||
{
|
||||
minimum[axis]=std::min(minimum[axis],point[axis]);
|
||||
maximum[axis]=std::max(maximum[axis],point[axis]);
|
||||
}
|
||||
for(std::size_t axis=0;axis<3;++axis)
|
||||
{
|
||||
stamp.boundsMinimumMicrometres[axis]=minimum[axis]-radius;
|
||||
stamp.boundsMaximumMicrometres[axis]=maximum[axis]+radius;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
const std::array<std::int64_t,3> extents={
|
||||
stamp.halfExtentsMicrometres[stamp.yawQuarterTurns%2?1:0]+pad,
|
||||
stamp.halfExtentsMicrometres[stamp.yawQuarterTurns%2?0:1]+pad,
|
||||
@@ -1699,11 +2095,15 @@ bool ResolveResolvedLayoutStampsV1(ResolvedLayoutSnapshotV3& layout,std::int32_t
|
||||
stamp.boundsMinimumMicrometres[axis]=stamp.positionMicrometres[axis]-extents[axis];
|
||||
stamp.boundsMaximumMicrometres[axis]=stamp.positionMicrometres[axis]+extents[axis];
|
||||
}
|
||||
if(!LayoutStampSafe(candidate,stamp))continue;
|
||||
}
|
||||
std::string safetyReason;
|
||||
if(!LayoutStampSafe(candidate,stamp,&safetyReason))
|
||||
{lastPlacementFailure=std::move(safetyReason);continue;}
|
||||
candidate.stamps.push_back(std::move(stamp));placed=true;break;
|
||||
}
|
||||
if(!placed)
|
||||
{error="stamp request "+request.stableId+" has no deterministic passage-safe content placement";return false;}
|
||||
{error="stamp request "+request.stableId+" has no deterministic passage-safe content placement"
|
||||
+(lastPlacementFailure.empty()?std::string{}:": "+lastPlacementFailure);return false;}
|
||||
}
|
||||
if(!candidate.finalize(error))return false;
|
||||
std::vector<ResolvedLayoutIssueV2> issues;std::string strictError;
|
||||
@@ -2262,6 +2662,17 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t
|
||||
{return c.upperStrateIndex==strate;});
|
||||
return it==configs.end()?nullptr:&*it;
|
||||
};
|
||||
const auto landingPadFor=[](const ResolvedLayoutStratumV2& stratum,
|
||||
ResolvedLayoutLandingPadRoleV1 role,std::uint32_t ordinal)
|
||||
->const ResolvedLayoutLandingPadV1*
|
||||
{
|
||||
std::vector<const ResolvedLayoutLandingPadV1*> candidates;
|
||||
for(const auto& pad:stratum.landingPads)
|
||||
if(pad.role==role)candidates.push_back(&pad);
|
||||
std::sort(candidates.begin(),candidates.end(),[](const auto* a,const auto* b)
|
||||
{return a->stableId<b->stableId;});
|
||||
return ordinal<candidates.size()?candidates[ordinal]:nullptr;
|
||||
};
|
||||
const auto routeAnchor=[](const ResolvedLayoutStratumV2& s,bool departure,
|
||||
std::array<std::int64_t,3>& out)
|
||||
{
|
||||
@@ -2358,9 +2769,37 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t
|
||||
passage.upperStrateIndex=upper.strateIndex;passage.lowerStrateIndex=lower.strateIndex;
|
||||
passage.placementRadiusSubvoxels=placementRadius;
|
||||
passage.mouthRadiusSubvoxels=mouthRadius;passage.midRadiusSubvoxels=midRadius;
|
||||
const auto* upperDeparturePad=landingPadFor(upper,
|
||||
ResolvedLayoutLandingPadRoleV1::departure,connection);
|
||||
const auto* lowerArrivalPad=landingPadFor(lower,
|
||||
ResolvedLayoutLandingPadRoleV1::arrival,connection);
|
||||
const auto validateLandingPad=[&](const ResolvedLayoutLandingPadV1* pad,
|
||||
std::string_view side)
|
||||
{
|
||||
if(!pad)return true;
|
||||
const std::int64_t requiredClearance=mouthRadius
|
||||
+static_cast<std::int64_t>(std::llround(0.34*qPerMetre));
|
||||
if(!pad->certifiedWalkableWithSupport||pad->clearanceSubvoxels<requiredClearance
|
||||
||pad->supportDepthSubvoxels<static_cast<std::int64_t>(std::llround(0.50*qPerMetre))
|
||||
||!radialWithin(pad->positionSubvoxels,placementRadius)
|
||||
||pad->positionSubvoxels[2]
|
||||
!=pad->floorHeightSubvoxels+playerCenterAboveFloorQ)
|
||||
{
|
||||
error=passage.stableId+" "+std::string(side)
|
||||
+" landing pad is uncertified, unsupported, too tight, or outside PassageConfig.DistanceMax";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
if(!validateLandingPad(upperDeparturePad,"departure")
|
||||
||!validateLandingPad(lowerArrivalPad,"arrival"))return false;
|
||||
std::array<std::int64_t,3> upperMouth{},lowerMouth{};
|
||||
const bool hasUpperRoute=connection==0&&routeAnchor(upper,true,upperMouth);
|
||||
const bool hasLowerRoute=connection==0&&routeAnchor(lower,false,lowerMouth);
|
||||
const bool hasUpperRoute=upperDeparturePad
|
||||
?(upperMouth=upperDeparturePad->positionSubvoxels,true)
|
||||
:connection==0&&routeAnchor(upper,true,upperMouth);
|
||||
const bool hasLowerRoute=lowerArrivalPad
|
||||
?(lowerMouth=lowerArrivalPad->positionSubvoxels,true)
|
||||
:connection==0&&routeAnchor(lower,false,lowerMouth);
|
||||
const bool surfaceGroundDeparture=hasUpperRoute&&upperIsSurfaceWorld;
|
||||
const ResolvedLayoutAnchorV2* passageAnchor=nullptr;
|
||||
if(connection==0)
|
||||
@@ -2409,13 +2848,17 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t
|
||||
|
||||
constexpr std::int64_t LandingExtensionQ=8;
|
||||
std::array<std::int64_t,2> upperTangent{},lowerTangent{};
|
||||
const bool hasUpperTangent=hasUpperRoute
|
||||
&&routeTangent(upper,true,upperTangent);
|
||||
const bool hasUpperTangent=upperDeparturePad
|
||||
?(upperTangent={upperDeparturePad->approachDirectionQ16[0],
|
||||
upperDeparturePad->approachDirectionQ16[1]},true)
|
||||
:hasUpperRoute&&routeTangent(upper,true,upperTangent);
|
||||
const bool hasPassageArrival=std::any_of(lower.anchors.begin(),lower.anchors.end(),
|
||||
[](const auto& anchor){return anchor.kind=="passage_arrival"
|
||||
&&anchor.values.size()>=3;});
|
||||
const bool hasLowerTangent=hasLowerRoute&&!hasPassageArrival
|
||||
&&routeTangent(lower,false,lowerTangent);
|
||||
const bool hasLowerTangent=lowerArrivalPad
|
||||
?(lowerTangent={lowerArrivalPad->approachDirectionQ16[0],
|
||||
lowerArrivalPad->approachDirectionQ16[1]},true)
|
||||
:hasLowerRoute&&!hasPassageArrival&&routeTangent(lower,false,lowerTangent);
|
||||
const bool profiledSurfaceJoin=hasLowerTangent
|
||||
&&std::any_of(lower.anchors.begin(),lower.anchors.end(),
|
||||
[](const auto& anchor){return anchor.kind=="heightfield_profile"
|
||||
|
||||
@@ -1284,103 +1284,104 @@ namespace VoxelForgeCore
|
||||
constexpr double Q=static_cast<double>(WorldAssemblySubvoxelsPerVoxel);
|
||||
const double voxelsPerMetre=coreVoxelSizeMicrons>0
|
||||
?1000000.0/coreVoxelSizeMicrons:4.0;
|
||||
const double bridgeHalfWidthVoxels=3.5*voxelsPerMetre;
|
||||
const double bridgeFootDepthVoxels=(5.0-0.13)*voxelsPerMetre;
|
||||
constexpr double WallReliefMetres=0.21;
|
||||
constexpr double ShellThicknessMetres=0.75;
|
||||
const double tubeExtentMetres=(WallReliefMetres+ShellThicknessMetres)
|
||||
*voxelsPerMetre;
|
||||
const double qPerMetre=Q*voxelsPerMetre;
|
||||
const double tileMinimum[3]={static_cast<double>(minimum.X),
|
||||
static_cast<double>(minimum.Y),static_cast<double>(minimum.Z)};
|
||||
const double tileMaximum[3]={static_cast<double>(maximum.X),
|
||||
static_cast<double>(maximum.Y),static_cast<double>(maximum.Z)};
|
||||
for(const auto& passage:layout.passages)
|
||||
{
|
||||
if(passage.controlPoints.size()<2)continue;
|
||||
const std::size_t routeEnd=std::clamp<std::size_t>(
|
||||
passage.lowerMouthControlIndex,1,passage.controlPoints.size()-1);
|
||||
std::vector<double> routeDistanceMetres(routeEnd+1,0.0);
|
||||
for(std::size_t pointIndex=1;pointIndex<=routeEnd;++pointIndex)
|
||||
{
|
||||
const auto& a=passage.controlPoints[pointIndex-1].positionSubvoxels;
|
||||
const auto& b=passage.controlPoints[pointIndex].positionSubvoxels;
|
||||
const double dx=static_cast<double>(b[0]-a[0])/qPerMetre;
|
||||
const double dy=static_cast<double>(b[1]-a[1])/qPerMetre;
|
||||
const double dz=static_cast<double>(b[2]-a[2])/qPerMetre;
|
||||
routeDistanceMetres[pointIndex]=routeDistanceMetres[pointIndex-1]
|
||||
+std::sqrt(dx*dx+dy*dy+dz*dz);
|
||||
}
|
||||
const double totalRouteMetres=routeDistanceMetres[routeEnd];
|
||||
double minimumRadiusMetres=std::numeric_limits<double>::infinity();
|
||||
double minimumBodyRadiusMetres=minimumRadiusMetres;
|
||||
for(std::size_t pointIndex=0;pointIndex<=routeEnd;++pointIndex)
|
||||
{
|
||||
const double radius=static_cast<double>(
|
||||
passage.controlPoints[pointIndex].radiusSubvoxels)/qPerMetre;
|
||||
minimumRadiusMetres=std::min(minimumRadiusMetres,radius);
|
||||
if(routeDistanceMetres[pointIndex]>=10.0
|
||||
&&totalRouteMetres-routeDistanceMetres[pointIndex]>=10.0)
|
||||
minimumBodyRadiusMetres=std::min(minimumBodyRadiusMetres,radius);
|
||||
}
|
||||
if(!std::isfinite(minimumBodyRadiusMetres))
|
||||
minimumBodyRadiusMetres=minimumRadiusMetres;
|
||||
const double bodyFloorDepthMetres=std::max(0.5,
|
||||
minimumBodyRadiusMetres-0.23);
|
||||
const double upperMouthDepthMetres=std::max(0.0,
|
||||
static_cast<double>(passage.controlPoints.front().positionSubvoxels[2]
|
||||
-passage.upperMouthSubvoxels[2])/qPerMetre+0.90);
|
||||
const double lowerMouthDepthMetres=std::max(0.0,
|
||||
static_cast<double>(passage.controlPoints[routeEnd].positionSubvoxels[2]
|
||||
-passage.lowerMouthSubvoxels[2])/qPerMetre+0.90);
|
||||
const auto routeFloorDepth=[&](double alongMetres,double radiusMetres)
|
||||
{
|
||||
const double localRadiusDepth=std::max(0.0,radiusMetres-0.23);
|
||||
const double mouthDistance=std::max(0.0,std::min(alongMetres,
|
||||
totalRouteMetres-alongMetres));
|
||||
const double linear=std::clamp(mouthDistance/5.0,0.0,1.0);
|
||||
const double ramp=linear*linear*(3.0-2.0*linear);
|
||||
const double target=alongMetres<=totalRouteMetres*0.5
|
||||
?upperMouthDepthMetres:lowerMouthDepthMetres;
|
||||
return std::lerp(std::clamp(target,0.0,localRadiusDepth),
|
||||
localRadiusDepth,ramp);
|
||||
};
|
||||
std::vector<double> effectiveCenterZVoxels(
|
||||
passage.controlPoints.size(),0.0);
|
||||
for(std::size_t pointIndex=0;pointIndex<passage.controlPoints.size();
|
||||
++pointIndex)
|
||||
{
|
||||
const std::size_t radiusIndex=std::min(pointIndex,routeEnd);
|
||||
const double radius=static_cast<double>(
|
||||
passage.controlPoints[radiusIndex].radiusSubvoxels)/qPerMetre;
|
||||
const double localTubeDepth=std::min(bodyFloorDepthMetres,
|
||||
std::max(0.0,radius-0.23));
|
||||
const double routeDepth=pointIndex<=routeEnd
|
||||
?routeFloorDepth(routeDistanceMetres[pointIndex],radius)
|
||||
:std::max(0.0,radius-0.23);
|
||||
effectiveCenterZVoxels[pointIndex]=
|
||||
static_cast<double>(passage.controlPoints[pointIndex]
|
||||
.positionSubvoxels[2])/Q
|
||||
+(-routeDepth+localTubeDepth)*voxelsPerMetre;
|
||||
}
|
||||
for(std::size_t segment=1;segment<passage.controlPoints.size();++segment)
|
||||
{
|
||||
const auto& a=passage.controlPoints[segment-1];
|
||||
const auto& b=passage.controlPoints[segment];
|
||||
const double radius=static_cast<double>(std::max(
|
||||
a.radiusSubvoxels,b.radiusSubvoxels))/Q;
|
||||
const double horizontalExtent=std::max(radius,bridgeHalfWidthVoxels);
|
||||
const double low[3]={
|
||||
std::min(a.positionSubvoxels[0],b.positionSubvoxels[0])/Q-horizontalExtent,
|
||||
std::min(a.positionSubvoxels[1],b.positionSubvoxels[1])/Q-horizontalExtent,
|
||||
std::min(a.positionSubvoxels[2],b.positionSubvoxels[2])/Q-radius
|
||||
-bridgeFootDepthVoxels};
|
||||
const double high[3]={
|
||||
std::max(a.positionSubvoxels[0],b.positionSubvoxels[0])/Q+horizontalExtent,
|
||||
std::max(a.positionSubvoxels[1],b.positionSubvoxels[1])/Q+horizontalExtent,
|
||||
std::max(a.positionSubvoxels[2],b.positionSubvoxels[2])/Q+radius};
|
||||
const double extent=std::max(static_cast<double>(a.radiusSubvoxels),
|
||||
static_cast<double>(b.radiusSubvoxels))/Q+tubeExtentMetres;
|
||||
double low[3]{},high[3]{};
|
||||
for(int axis=0;axis<3;++axis)
|
||||
{
|
||||
const double aPosition=axis==2?effectiveCenterZVoxels[segment-1]
|
||||
:static_cast<double>(a.positionSubvoxels[axis])/Q;
|
||||
const double bPosition=axis==2?effectiveCenterZVoxels[segment]
|
||||
:static_cast<double>(b.positionSubvoxels[axis])/Q;
|
||||
low[axis]=std::min(aPosition,bPosition)-extent;
|
||||
high[axis]=std::max(aPosition,bPosition)+extent;
|
||||
}
|
||||
bool overlap=true;
|
||||
for(int axis=0;axis<3;++axis)
|
||||
overlap&=high[axis]>=tileMinimum[axis]&&low[axis]<=tileMaximum[axis];
|
||||
if(overlap)return true;
|
||||
}
|
||||
|
||||
// L27 can add a long support column around an unsupported passage span. Its
|
||||
// radius can exceed the route and bridge bounds above by the column wall and
|
||||
// flare allowance. The precompile shape-free proof runs before that crossing
|
||||
// plan exists, so conservatively bound every possible column from the resolved
|
||||
// route itself. The center is either the passage anchor or an average of route
|
||||
// samples; in the latter case it lies inside the route's XY bounds.
|
||||
if(passage.controlPoints.size()>=2)
|
||||
{
|
||||
double routeMinimum[3]={std::numeric_limits<double>::infinity(),
|
||||
std::numeric_limits<double>::infinity(),
|
||||
std::numeric_limits<double>::infinity()};
|
||||
double routeMaximum[3]={-std::numeric_limits<double>::infinity(),
|
||||
-std::numeric_limits<double>::infinity(),
|
||||
-std::numeric_limits<double>::infinity()};
|
||||
double maximumBoreRadius=0.0;
|
||||
for(const auto& point:passage.controlPoints)
|
||||
{
|
||||
for(int axis=0;axis<3;++axis)
|
||||
{
|
||||
const double value=static_cast<double>(point.positionSubvoxels[axis])/Q;
|
||||
routeMinimum[axis]=std::min(routeMinimum[axis],value);
|
||||
routeMaximum[axis]=std::max(routeMaximum[axis],value);
|
||||
}
|
||||
maximumBoreRadius=std::max(maximumBoreRadius,
|
||||
static_cast<double>(point.radiusSubvoxels)/Q);
|
||||
}
|
||||
|
||||
double centerMinimumX=routeMinimum[0],centerMaximumX=routeMaximum[0];
|
||||
double centerMinimumY=routeMinimum[1],centerMaximumY=routeMaximum[1];
|
||||
double maximumRouteOffset=std::hypot(
|
||||
routeMaximum[0]-routeMinimum[0],routeMaximum[1]-routeMinimum[1]);
|
||||
const auto upper=std::find_if(layout.strates.begin(),layout.strates.end(),
|
||||
[&passage](const auto& stratum)
|
||||
{return stratum.strateIndex==passage.upperStrateIndex;});
|
||||
if(upper!=layout.strates.end()
|
||||
&&upper->passageCapabilities.usePassageAnchorForColumns)
|
||||
{
|
||||
const auto anchor=std::find_if(upper->anchors.begin(),upper->anchors.end(),
|
||||
[](const auto& value)
|
||||
{return value.kind=="passage_anchor"&&value.values.size()>=2;});
|
||||
if(anchor!=upper->anchors.end())
|
||||
{
|
||||
const double centerX=static_cast<double>(anchor->values[0])/Q;
|
||||
const double centerY=static_cast<double>(anchor->values[1])/Q;
|
||||
centerMinimumX=centerMaximumX=centerX;
|
||||
centerMinimumY=centerMaximumY=centerY;
|
||||
maximumRouteOffset=0.0;
|
||||
for(const auto& point:passage.controlPoints)
|
||||
maximumRouteOffset=std::max(maximumRouteOffset,std::hypot(
|
||||
static_cast<double>(point.positionSubvoxels[0])/Q-centerX,
|
||||
static_cast<double>(point.positionSubvoxels[1])/Q-centerY));
|
||||
}
|
||||
}
|
||||
// The generated maximum column radius is the route offset, bore radius,
|
||||
// and 13.25 m of wall/irregularity/flare allowance.
|
||||
const double columnRadius=maximumRouteOffset+maximumBoreRadius
|
||||
+13.25*voxelsPerMetre;
|
||||
const double columnMinimum[3]={centerMinimumX-columnRadius,
|
||||
centerMinimumY-columnRadius,routeMinimum[2]-6.0*voxelsPerMetre};
|
||||
const double columnMaximum[3]={centerMaximumX+columnRadius,
|
||||
centerMaximumY+columnRadius,routeMaximum[2]+5.0*voxelsPerMetre};
|
||||
bool overlap=true;
|
||||
for(int axis=0;axis<3;++axis)
|
||||
overlap=overlap&&columnMaximum[axis]>=tileMinimum[axis]
|
||||
&&columnMinimum[axis]<=tileMaximum[axis];
|
||||
if(overlap)return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1866,44 +1867,6 @@ namespace VoxelForgeCore
|
||||
static_cast<double>(weighted)/65535.0*sampleVolume});
|
||||
return result;
|
||||
}
|
||||
std::vector<PassageVoidCrossingAuditV1> World::passageVoidCrossings() const
|
||||
{
|
||||
std::vector<PassageVoidCrossingAuditV1> result;
|
||||
if(!impl_||!impl_->shapeReady.load(std::memory_order_acquire)||!impl_->shape)return result;
|
||||
const auto& crossings=impl_->shape->GetPassageVoidCrossings();
|
||||
result.reserve(crossings.size());
|
||||
for(const auto& crossing:crossings)
|
||||
{
|
||||
PassageVoidCrossingAuditV1 value;
|
||||
value.passageIndex=crossing.passageIndex;value.firstSegment=crossing.firstSegment;
|
||||
value.lastSegment=crossing.lastSegment;value.unsupportedSamples=crossing.unsupportedSamples;
|
||||
value.unsupportedLengthMetres=crossing.unsupportedLengthMetres;
|
||||
value.startPointSubvoxels=crossing.startPoint;value.endPointSubvoxels=crossing.endPoint;
|
||||
value.needsColumn=crossing.needsColumn;
|
||||
value.connectedAtStart=crossing.connectedAtStart;
|
||||
value.connectedAtEnd=crossing.connectedAtEnd;
|
||||
value.columnAxisSubvoxels={crossing.centerXSubvoxels,crossing.centerYSubvoxels};
|
||||
value.columnTopZSubvoxels=crossing.topZSubvoxels;
|
||||
value.columnBottomZSubvoxels=crossing.bottomZSubvoxels;
|
||||
value.columnMinimumRadiusSubvoxels=crossing.minimumRadiusSubvoxels;
|
||||
value.columnNominalRadiusSubvoxels=crossing.nominalRadiusSubvoxels;
|
||||
value.columnMaximumRadiusSubvoxels=crossing.maximumRadiusSubvoxels;
|
||||
value.maximumRouteOffsetMetres=crossing.maximumRouteOffsetMetres;
|
||||
value.maximumBoreRadiusMetres=crossing.maximumBoreRadiusMetres;
|
||||
result.push_back(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
bool World::isPassageColumnSample(std::size_t passageIndex,std::size_t segmentIndex,
|
||||
Vec3d position) const
|
||||
{return impl_&&impl_->shapeReady.load(std::memory_order_acquire)&&impl_->shape
|
||||
&&impl_->shape->IsResolvedPassageColumnSample(passageIndex,segmentIndex,
|
||||
{position.x,position.y,position.z});}
|
||||
bool World::isPassageShortSpanSample(std::size_t passageIndex,std::size_t segmentIndex,
|
||||
Vec3d position) const
|
||||
{return impl_&&impl_->shapeReady.load(std::memory_order_acquire)&&impl_->shape
|
||||
&&impl_->shape->IsResolvedPassageShortSpanSample(passageIndex,segmentIndex,
|
||||
{position.x,position.y,position.z});}
|
||||
std::string World::densityDispatchPathAt(Vec3d position) const
|
||||
{ return impl_&&impl_->shapeReady.load(std::memory_order_acquire)&&impl_->shape
|
||||
?impl_->shape->DensityDispatchPathAt(static_cast<float>(position.x),static_cast<float>(position.y),static_cast<float>(position.z))
|
||||
@@ -2672,30 +2635,6 @@ namespace VoxelForgeCore
|
||||
RelevantMinimum=std::min(RelevantMinimum,FeatureMinimum);
|
||||
RelevantMaximum=std::max(RelevantMaximum,FeatureMaximum);
|
||||
}
|
||||
// A resolved passage void column can add solid support above or
|
||||
// below the native heightfield's local Z range. Its finite radial
|
||||
// and vertical bounds must participate before the mesh band is clipped.
|
||||
for(const auto& Crossing:tileShape.GetPassageVoidCrossings())
|
||||
{
|
||||
if(!Crossing.needsColumn||Crossing.maximumRadiusSubvoxels<=0
|
||||
||Crossing.topZSubvoxels<=Crossing.bottomZSubvoxels)continue;
|
||||
const double Radius=static_cast<double>(Crossing.maximumRadiusSubvoxels)
|
||||
/static_cast<double>(SubvoxelScale);
|
||||
const double CenterX=static_cast<double>(Crossing.centerXSubvoxels)
|
||||
/static_cast<double>(SubvoxelScale);
|
||||
const double CenterY=static_cast<double>(Crossing.centerYSubvoxels)
|
||||
/static_cast<double>(SubvoxelScale);
|
||||
if(CenterX+Radius<TileMinimum[0]/static_cast<double>(SubvoxelScale)
|
||||
||CenterX-Radius>TileMaximum[0]/static_cast<double>(SubvoxelScale)
|
||||
||CenterY+Radius<TileMinimum[1]/static_cast<double>(SubvoxelScale)
|
||||
||CenterY-Radius>TileMaximum[1]/static_cast<double>(SubvoxelScale))continue;
|
||||
RelevantMinimum=std::min(RelevantMinimum,
|
||||
static_cast<double>(Crossing.bottomZSubvoxels)
|
||||
/static_cast<double>(SubvoxelScale));
|
||||
RelevantMaximum=std::max(RelevantMaximum,
|
||||
static_cast<double>(Crossing.topZSubvoxels)
|
||||
/static_cast<double>(SubvoxelScale));
|
||||
}
|
||||
// The native heightfield band only covers the owner terrain and its
|
||||
// manifest features. Resolved SDF stamps can carve below that band or
|
||||
// add a floor there, so include every overlapping stamp's bounded Z
|
||||
@@ -2804,7 +2743,6 @@ namespace VoxelForgeCore
|
||||
FStandaloneShapeWorld::FScopedPassageTileQueries passageFeatureScope(
|
||||
tileShape,queryMinimum,queryMaximum,
|
||||
options.profilePassageComposer?&out.diagnostics:nullptr,key.lod,
|
||||
options.diagnosticPassageSourceFloorVoxels,
|
||||
options.diagnosticPassageCarveReachMetres);
|
||||
passageFeatureScope.prepareSweptTubeBase(origin,MeshStep,MeshCells,
|
||||
static_cast<std::uint32_t>(world.Seed));
|
||||
@@ -2904,10 +2842,6 @@ namespace VoxelForgeCore
|
||||
{
|
||||
out.diagnostics.passageTileRouteSegmentCandidates=
|
||||
passageFeatureScope.routeSegmentCandidateCount();
|
||||
for(std::size_t passageIndex=0;
|
||||
passageIndex<impl_->resolvedLayout->passages.size();++passageIndex)
|
||||
out.diagnostics.passageTileMouthSegmentCandidates+=
|
||||
passageFeatureScope.mouthSupportSegments(passageIndex).size();
|
||||
}
|
||||
}
|
||||
FGridFillWorkerContext GridFillWorkerContext;
|
||||
@@ -2955,12 +2889,16 @@ namespace VoxelForgeCore
|
||||
VoxelForgeCore::Heightfield2D::threadColumnCacheStats().columnComputeMilliseconds;
|
||||
const auto MarchingCubesStart=std::chrono::steady_clock::now();
|
||||
FVoxelUniformBlockProofAuditStats BlockProofAudit;
|
||||
FVoxelMeshData mesh=mesher.GenerateMesh(origin,MeshStep,MeshCells,nullptr,
|
||||
FVoxelMeshData mesh;
|
||||
{
|
||||
FStandaloneShapeWorld::FScopedStampSamplingStep stampSamplingStep(MeshStep);
|
||||
mesh=mesher.GenerateMesh(origin,MeshStep,MeshCells,nullptr,
|
||||
MeshBandMinZVoxel,MeshBandMaxZVoxel,&sampleCount,nullptr,nullptr,
|
||||
(options.hashDensity||options.captureDensitySamples)?&densityValues:nullptr,
|
||||
options.profileMesherStages?&McStageTimings:nullptr,
|
||||
options.auditUniformBlockProofs?&BlockProofAudit:nullptr,
|
||||
options.forceFullDensityCapture);
|
||||
}
|
||||
passageFeatureScope.copySweptTubeRuntimeCounts();
|
||||
if(options.auditUniformBlockProofs)
|
||||
{
|
||||
@@ -3173,6 +3111,13 @@ namespace VoxelForgeCore
|
||||
AppendStratumBoundaryFloors(world,farShape,nativeKey,step,mesh);
|
||||
AppendStratumBoundaryCeilings(world,farShape,nativeKey,step,mesh);
|
||||
}
|
||||
const std::uint32_t coarsePillarProxyCount=tileShape.AppendCoarsePillarProxies(
|
||||
mesh,origin,extent,MeshStep);
|
||||
if(coarsePillarProxyCount>0)
|
||||
std::printf("[CoarsePillarProxy] key=(%d,%d,%d,lod%d) sampling_step_vox=%d "
|
||||
"pillars=%u mode=exact_capped_cylinder status=EMITTED\n",
|
||||
key.coordinate.x,key.coordinate.y,key.coordinate.z,key.lod,MeshStep,
|
||||
coarsePillarProxyCount);
|
||||
l77RefineStage.Restore();
|
||||
|
||||
out.densitySampleCount=static_cast<std::uint64_t>(std::max<int64>(0,sampleCount));
|
||||
|
||||
@@ -539,6 +539,38 @@ namespace world_assembly_detail
|
||||
if (squared == 0)
|
||||
{ error = "world assembly route contains a zero-length segment"; return false; }
|
||||
}
|
||||
const auto& liminal = stratum.liminalField;
|
||||
if (liminal.version != 0)
|
||||
{
|
||||
if ((liminal.version != CoreLiminalFieldRecipeVersionV1
|
||||
&& liminal.version != CoreLiminalFieldRecipeVersionV2)
|
||||
|| static_cast<std::uint32_t>(liminal.variant) >= CoreLiminalFieldVariantCountV1
|
||||
|| liminal.seed != effectiveRecipe.seed
|
||||
|| liminal.variant != SelectCoreLiminalFieldVariantV1(liminal.seed)
|
||||
|| stratum.route.size() < 2
|
||||
|| liminal.mouthFloorOffsetSubvoxels <= 0
|
||||
|| stratum.route.front().positionSubvoxels[0] != liminal.arrivalMouthSubvoxels[0]
|
||||
|| stratum.route.front().positionSubvoxels[1] != liminal.arrivalMouthSubvoxels[1]
|
||||
|| stratum.route.front().positionSubvoxels[2]
|
||||
!= liminal.arrivalMouthSubvoxels[2]-liminal.mouthFloorOffsetSubvoxels
|
||||
|| stratum.route.back().positionSubvoxels[0] != liminal.departureMouthSubvoxels[0]
|
||||
|| stratum.route.back().positionSubvoxels[1] != liminal.departureMouthSubvoxels[1]
|
||||
|| stratum.route.back().positionSubvoxels[2]
|
||||
!= liminal.departureMouthSubvoxels[2]-liminal.mouthFloorOffsetSubvoxels
|
||||
|| liminal.routeHalfWidthSubvoxels < stratum.routeConstraints.capsuleRadiusSubvoxels
|
||||
|| liminal.routeHalfThicknessSubvoxels <= 0
|
||||
|| liminal.routeOffsetSubvoxels <= 0
|
||||
|| liminal.supportSpacingSubvoxels <= 0
|
||||
|| liminal.supportRadiusSubvoxels <= 0
|
||||
|| stratum.surfaceProfile.version != 1)
|
||||
{ error = "Core.Liminal.Field plan has invalid version, seed-selected variant, route mouths or dimensions"; return false; }
|
||||
const std::int64_t bottom = static_cast<std::int64_t>(stratum.bottomChunkZ)
|
||||
* layoutChunkWidthSubvoxels;
|
||||
const std::int64_t top = (static_cast<std::int64_t>(stratum.topChunkZ) + 1)
|
||||
* layoutChunkWidthSubvoxels;
|
||||
if (liminal.floorHeightSubvoxels <= bottom || liminal.floorHeightSubvoxels >= top)
|
||||
{ error = "Core.Liminal.Field floor escapes its finite stratum bounds"; return false; }
|
||||
}
|
||||
const auto& constraints = stratum.routeConstraints;
|
||||
if (constraints.version == 0)
|
||||
{
|
||||
@@ -682,6 +714,24 @@ namespace world_assembly_detail
|
||||
for (const auto value : point.positionSubvoxels) HashWord(hash, static_cast<std::uint64_t>(value), 8);
|
||||
HashWord(hash, static_cast<std::uint64_t>(point.radiusSubvoxels), 8);
|
||||
}
|
||||
if (stratum.liminalField.version != 0)
|
||||
{
|
||||
const auto& liminal = stratum.liminalField;
|
||||
HashWord(hash, 0x4c494d494e414c31ull, 8);
|
||||
HashWord(hash, liminal.version, 4);
|
||||
HashWord(hash, static_cast<std::uint32_t>(liminal.variant), 4);
|
||||
HashWord(hash, liminal.seed, 4);
|
||||
for (const auto value : liminal.arrivalMouthSubvoxels)
|
||||
HashWord(hash, static_cast<std::uint64_t>(value), 8);
|
||||
for (const auto value : liminal.departureMouthSubvoxels)
|
||||
HashWord(hash, static_cast<std::uint64_t>(value), 8);
|
||||
for (const auto value : {liminal.mouthFloorOffsetSubvoxels,
|
||||
liminal.floorHeightSubvoxels,
|
||||
liminal.routeHalfWidthSubvoxels, liminal.routeHalfThicknessSubvoxels,
|
||||
liminal.routeOffsetSubvoxels, liminal.supportSpacingSubvoxels,
|
||||
liminal.supportRadiusSubvoxels})
|
||||
HashWord(hash, static_cast<std::uint64_t>(value), 8);
|
||||
}
|
||||
if (stratum.surfaceProfile.version != 0)
|
||||
HashSurfaceProfile(hash, stratum.surfaceProfile);
|
||||
return hash;
|
||||
@@ -1059,6 +1109,30 @@ namespace world_assembly_detail
|
||||
{ error = "world assembly route point is missing a fixed-scale field"; return false; }
|
||||
stratum.route.push_back(point);
|
||||
}
|
||||
const VFJson::Value& liminalValue = item.Get("liminal_field");
|
||||
if (!std::holds_alternative<std::nullptr_t>(liminalValue.Data))
|
||||
{
|
||||
auto& liminal = stratum.liminalField;
|
||||
std::uint32_t variant = 0;
|
||||
if (!liminalValue.IsObject()
|
||||
|| !ReadU32(liminalValue.Get("version"), liminal.version)
|
||||
|| !ReadU32(liminalValue.Get("variant"), variant)
|
||||
|| !ReadU32(liminalValue.Get("seed"), liminal.seed)
|
||||
|| !ReadArray(liminalValue.Get("arrival_mouth_q"),
|
||||
liminal.arrivalMouthSubvoxels.data(), 3)
|
||||
|| !ReadArray(liminalValue.Get("departure_mouth_q"),
|
||||
liminal.departureMouthSubvoxels.data(), 3)
|
||||
|| !ReadInteger(liminalValue.Get("mouth_floor_offset_q"),
|
||||
liminal.mouthFloorOffsetSubvoxels)
|
||||
|| !ReadInteger(liminalValue.Get("floor_height_q"), liminal.floorHeightSubvoxels)
|
||||
|| !ReadInteger(liminalValue.Get("route_half_width_q"), liminal.routeHalfWidthSubvoxels)
|
||||
|| !ReadInteger(liminalValue.Get("route_half_thickness_q"), liminal.routeHalfThicknessSubvoxels)
|
||||
|| !ReadInteger(liminalValue.Get("route_offset_q"), liminal.routeOffsetSubvoxels)
|
||||
|| !ReadInteger(liminalValue.Get("support_spacing_q"), liminal.supportSpacingSubvoxels)
|
||||
|| !ReadInteger(liminalValue.Get("support_radius_q"), liminal.supportRadiusSubvoxels))
|
||||
{ error = "Core.Liminal.Field plan is missing a fixed-scale field"; return false; }
|
||||
liminal.variant = static_cast<WorldAssemblyLiminalVariantV1>(variant);
|
||||
}
|
||||
plan.strates.push_back(std::move(stratum));
|
||||
}
|
||||
return true;
|
||||
@@ -2324,6 +2398,724 @@ bool WorldAssemblyManifest::validate(std::string& error) const
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PlanCoreLiminalFieldV1(WorldAssemblyRecipeV1& recipe,
|
||||
WorldAssemblyStratumPlanV1& stratum,const ResolvedLayoutSnapshotV3& layout,
|
||||
std::string& error)
|
||||
{
|
||||
error.clear();
|
||||
if(layout.coreVoxelSizeMicrometres==0||stratum.strateIndex<0
|
||||
||stratum.topChunkZ<stratum.bottomChunkZ)
|
||||
{error="Core.Liminal.Field needs a finite stratum and resolved Core voxel size";return false;}
|
||||
const auto resolvedStratum=std::find_if(layout.strates.begin(),layout.strates.end(),
|
||||
[&](const auto& value){return value.strateIndex==stratum.strateIndex
|
||||
&&value.generatorKind=="SurfaceWorld";});
|
||||
if(resolvedStratum==layout.strates.end()||!resolvedStratum->content.presentInCurrentField)
|
||||
{error="Core.Liminal.Field must target a resolved SurfaceWorld content band";return false;}
|
||||
const ResolvedLayoutPassageV1* arrival=nullptr;
|
||||
const ResolvedLayoutPassageV1* departure=nullptr;
|
||||
for(const auto& passage:layout.passages)
|
||||
{
|
||||
if(passage.lowerStrateIndex==stratum.strateIndex)
|
||||
{
|
||||
if(arrival){error="Core.Liminal.Field requires one fixed arrival passage mouth";return false;}
|
||||
arrival=&passage;
|
||||
}
|
||||
if(passage.upperStrateIndex==stratum.strateIndex)
|
||||
{
|
||||
if(departure){error="Core.Liminal.Field requires one fixed departure passage mouth";return false;}
|
||||
departure=&passage;
|
||||
}
|
||||
}
|
||||
if(!arrival||!departure)
|
||||
{error="Core.Liminal.Field requires an adjacent passage on both sides";return false;}
|
||||
|
||||
constexpr std::int64_t Q=WorldAssemblySubvoxelsPerVoxel;
|
||||
const std::int64_t coreMicrons=layout.coreVoxelSizeMicrometres;
|
||||
const auto micrometresToQ=[&](std::int64_t micrometres)
|
||||
{
|
||||
const std::int64_t numerator=micrometres*Q;
|
||||
return numerator>=0?(numerator+coreMicrons/2)/coreMicrons
|
||||
:(numerator-coreMicrons/2)/coreMicrons;
|
||||
};
|
||||
const std::int64_t qPerMetre=micrometresToQ(1000000);
|
||||
if(qPerMetre<=0){error="Core.Liminal.Field could not resolve fixed-scale metres";return false;}
|
||||
|
||||
const std::uint32_t seed=recipe.seed;
|
||||
const WorldAssemblyLiminalVariantV1 variant=SelectCoreLiminalFieldVariantV1(seed);
|
||||
const auto& definition=CoreLiminalFieldVariantsV1[static_cast<std::size_t>(variant)];
|
||||
const auto& start=arrival->lowerMouthSubvoxels;
|
||||
const auto& finish=departure->upperMouthSubvoxels;
|
||||
constexpr std::int64_t MaximumMouthCoordinateQ=1000000000ll;
|
||||
if(std::abs(start[0])>MaximumMouthCoordinateQ||std::abs(start[1])>MaximumMouthCoordinateQ
|
||||
||std::abs(start[2])>MaximumMouthCoordinateQ||std::abs(finish[0])>MaximumMouthCoordinateQ
|
||||
||std::abs(finish[1])>MaximumMouthCoordinateQ||std::abs(finish[2])>MaximumMouthCoordinateQ)
|
||||
{error="Core.Liminal.Field passage mouth exceeds the supported fixed-coordinate range";return false;}
|
||||
const std::int64_t mouthPlayerCenterHeight=micrometresToQ(1130000);
|
||||
auto routeStart=start;
|
||||
auto routeFinish=finish;
|
||||
routeStart[2]-=mouthPlayerCenterHeight;
|
||||
routeFinish[2]-=mouthPlayerCenterHeight;
|
||||
const auto findMouthControl=[&](const ResolvedLayoutPassageV1& passage,
|
||||
const std::array<std::int64_t,3>& mouth)
|
||||
{
|
||||
std::size_t found=passage.controlPoints.size();
|
||||
for(std::size_t i=0;i<passage.controlPoints.size();++i)
|
||||
{
|
||||
const auto& point=passage.controlPoints[i];
|
||||
if(point.positionSubvoxels[0]==mouth[0]
|
||||
&&point.positionSubvoxels[1]==mouth[1]
|
||||
&&point.positionSubvoxels[2]-point.radiusSubvoxels
|
||||
+mouthPlayerCenterHeight==mouth[2])
|
||||
{
|
||||
if(found!=passage.controlPoints.size())return passage.controlPoints.size();
|
||||
found=i;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
const std::size_t arrivalControl=findMouthControl(*arrival,start);
|
||||
const std::size_t departureControl=findMouthControl(*departure,finish);
|
||||
if(arrival->controlPoints.size()<2||departure->controlPoints.size()<2
|
||||
||arrivalControl>=arrival->controlPoints.size()
|
||||
||departureControl>=departure->controlPoints.size())
|
||||
{error="Core.Liminal.Field needs exact passage-control bindings at both fixed mouths";return false;}
|
||||
const auto horizontalLengthQ=[](std::int64_t dx,std::int64_t dy)
|
||||
{
|
||||
const auto absX=static_cast<std::uint64_t>(std::abs(dx));
|
||||
const auto absY=static_cast<std::uint64_t>(std::abs(dy));
|
||||
return world_assembly_detail::IntegerSqrt(absX*absX+absY*absY);
|
||||
};
|
||||
const auto roundRatio=[](std::int64_t numerator,std::uint64_t denominator)
|
||||
{
|
||||
if(denominator==0)return std::int64_t{0};
|
||||
const auto positiveDenominator=static_cast<std::int64_t>(denominator);
|
||||
return numerator>=0
|
||||
?(numerator+positiveDenominator/2)/positiveDenominator
|
||||
:-((-numerator+positiveDenominator/2)/positiveDenominator);
|
||||
};
|
||||
const auto passageSplineControls=[](const ResolvedLayoutPassageV1& passage)
|
||||
{
|
||||
std::vector<PassageSpline::FPoint> controls;
|
||||
controls.reserve(passage.controlPoints.size());
|
||||
for(const auto& point:passage.controlPoints)
|
||||
controls.push_back({static_cast<double>(point.positionSubvoxels[0]),
|
||||
static_cast<double>(point.positionSubvoxels[1]),
|
||||
static_cast<double>(point.positionSubvoxels[2]-point.radiusSubvoxels),
|
||||
static_cast<double>(point.radiusSubvoxels)});
|
||||
return controls;
|
||||
};
|
||||
const auto arrivalSpline=passageSplineControls(*arrival);
|
||||
const auto departureSpline=passageSplineControls(*departure);
|
||||
constexpr double MouthTangentProbeParameter=0.5;
|
||||
const std::size_t arrivalSegment=arrivalControl==0?0:arrivalControl-1;
|
||||
const double arrivalAtT=arrivalControl==0?0.0:1.0;
|
||||
const double arrivalNearT=arrivalControl==0?MouthTangentProbeParameter
|
||||
:1.0-MouthTangentProbeParameter;
|
||||
const auto arrivalNear=PassageSpline::EvaluateRaw(arrivalSpline,arrivalSegment,
|
||||
arrivalNearT,static_cast<double>(qPerMetre));
|
||||
const auto arrivalAt=PassageSpline::EvaluateRaw(arrivalSpline,arrivalSegment,
|
||||
arrivalAtT,static_cast<double>(qPerMetre));
|
||||
const std::size_t departureSegment=departureControl+1==departure->controlPoints.size()
|
||||
?departureControl-1:departureControl;
|
||||
const double departureAtT=departureControl+1==departure->controlPoints.size()?1.0:0.0;
|
||||
const double departureNearT=departureControl+1==departure->controlPoints.size()
|
||||
?1.0-MouthTangentProbeParameter:MouthTangentProbeParameter;
|
||||
const auto departureAt=PassageSpline::EvaluateRaw(departureSpline,departureSegment,
|
||||
departureAtT,static_cast<double>(qPerMetre));
|
||||
const auto departureNear=PassageSpline::EvaluateRaw(departureSpline,departureSegment,
|
||||
departureNearT,static_cast<double>(qPerMetre));
|
||||
const auto roundedDelta=[](double startValue,double endValue)
|
||||
{return static_cast<std::int64_t>(std::llround(endValue-startValue));};
|
||||
const std::int64_t arrivalDx=roundedDelta(arrivalNear[0],arrivalAt[0]);
|
||||
const std::int64_t arrivalDy=roundedDelta(arrivalNear[1],arrivalAt[1]);
|
||||
const std::int64_t arrivalFloorDelta=roundedDelta(arrivalNear[2],arrivalAt[2]);
|
||||
const std::uint64_t arrivalLength=horizontalLengthQ(arrivalDx,arrivalDy);
|
||||
const std::int64_t departureDx=roundedDelta(departureAt[0],departureNear[0]);
|
||||
const std::int64_t departureDy=roundedDelta(departureAt[1],departureNear[1]);
|
||||
const std::int64_t departureFloorDelta=roundedDelta(departureAt[2],departureNear[2]);
|
||||
const std::uint64_t departureLength=horizontalLengthQ(departureDx,departureDy);
|
||||
if(arrivalLength==0||departureLength==0)
|
||||
{error="Core.Liminal.Field passage mouth tangent is too short for a stable connector";return false;}
|
||||
const auto extendMouth=[&](const std::array<std::int64_t,3>& mouth,
|
||||
std::int64_t dx,std::int64_t dy,std::int64_t dz,std::uint64_t horizontalLength,
|
||||
std::int64_t distance,bool forward)
|
||||
{
|
||||
const std::int64_t sign=forward?1:-1;
|
||||
return std::array<std::int64_t,3>{
|
||||
mouth[0]+roundRatio(sign*dx*distance,horizontalLength),
|
||||
mouth[1]+roundRatio(sign*dy*distance,horizontalLength),
|
||||
mouth[2]+roundRatio(sign*dz*distance,horizontalLength)};
|
||||
};
|
||||
constexpr std::int64_t MouthConnectorMidMetres=5;
|
||||
constexpr std::int64_t MouthConnectorFullMetres=10;
|
||||
const auto arrivalConnectorMid=extendMouth(routeStart,arrivalDx,arrivalDy,
|
||||
arrivalFloorDelta,arrivalLength,MouthConnectorMidMetres*qPerMetre,true);
|
||||
const auto arrivalConnector=extendMouth(routeStart,arrivalDx,arrivalDy,
|
||||
arrivalFloorDelta,arrivalLength,MouthConnectorFullMetres*qPerMetre,true);
|
||||
const auto departureConnector=extendMouth(routeFinish,departureDx,departureDy,
|
||||
departureFloorDelta,departureLength,MouthConnectorFullMetres*qPerMetre,false);
|
||||
const auto departureConnectorMid=extendMouth(routeFinish,departureDx,departureDy,
|
||||
departureFloorDelta,departureLength,MouthConnectorMidMetres*qPerMetre,false);
|
||||
const std::int64_t bottom=static_cast<std::int64_t>(stratum.bottomChunkZ)
|
||||
*recipe.layoutChunkWidthSubvoxels;
|
||||
const std::int64_t top=(static_cast<std::int64_t>(stratum.topChunkZ)+1)
|
||||
*recipe.layoutChunkWidthSubvoxels;
|
||||
const std::int64_t minRouteFloor=std::min({routeStart[2],routeFinish[2],
|
||||
arrivalConnectorMid[2],arrivalConnector[2],departureConnector[2],
|
||||
departureConnectorMid[2]});
|
||||
const std::int64_t contentBottom=micrometresToQ(
|
||||
resolvedStratum->content.bottomMicrometres);
|
||||
const std::int64_t profileRelief=variant==WorldAssemblyLiminalVariantV1::repeatingHills
|
||||
?2*micrometresToQ(definition.hillAmplitudeMicrometres):0;
|
||||
const std::int64_t floorHeight=std::max(contentBottom+profileRelief+qPerMetre/4,
|
||||
minRouteFloor-8*qPerMetre);
|
||||
if(floorHeight>=minRouteFloor-2*qPerMetre||floorHeight<=bottom||top<=floorHeight+8*qPerMetre)
|
||||
{error="Core.Liminal.Field has no room for its floor and passage route";return false;}
|
||||
|
||||
stratum.hasRecipeOverride=true;
|
||||
stratum.recipeOverride=recipe;
|
||||
WorldAssemblySurfaceProfileV1 profile;
|
||||
profile.version=1;
|
||||
profile.kind=definition.surfaceKind;
|
||||
profile.h0Subvoxels=floorHeight;
|
||||
if(profile.kind==WorldAssemblySurfaceProfileKindV1::periodicHills)
|
||||
{
|
||||
profile.amplitudeXSubvoxels=micrometresToQ(definition.hillAmplitudeMicrometres);
|
||||
profile.amplitudeYSubvoxels=profile.amplitudeXSubvoxels;
|
||||
profile.wavelengthXSubvoxels=micrometresToQ(definition.hillWavelengthMicrometres);
|
||||
profile.wavelengthYSubvoxels=profile.wavelengthXSubvoxels;
|
||||
ResolveWorldAssemblySurfaceProfilePhasesV1(profile,seed);
|
||||
}
|
||||
stratum.recipeOverride.surfaceProfile=profile;
|
||||
stratum.recipeOverride.contentHash=0;
|
||||
stratum.surfaceProfile=profile;
|
||||
stratum.capHeightSubvoxels=top-4*qPerMetre;
|
||||
stratum.capUndulationSubvoxels=0;
|
||||
stratum.capDetailAmplitudeSubvoxels=0;
|
||||
stratum.capRidgeAmplitudeSubvoxels=0;
|
||||
stratum.waterEnabled=false;
|
||||
stratum.waterSurfaceSubvoxels=0;
|
||||
|
||||
WorldAssemblyLiminalFieldPlanV1 liminal;
|
||||
liminal.version=CoreLiminalFieldRecipeVersionV1;
|
||||
liminal.variant=variant;
|
||||
liminal.seed=seed;
|
||||
liminal.arrivalMouthSubvoxels=start;
|
||||
liminal.departureMouthSubvoxels=finish;
|
||||
liminal.floorHeightSubvoxels=floorHeight;
|
||||
liminal.routeHalfWidthSubvoxels=micrometresToQ(definition.routeHalfWidthMicrometres);
|
||||
liminal.routeHalfThicknessSubvoxels=micrometresToQ(definition.routeHalfThicknessMicrometres);
|
||||
liminal.supportSpacingSubvoxels=micrometresToQ(definition.supportSpacingMicrometres);
|
||||
liminal.supportRadiusSubvoxels=micrometresToQ(definition.supportRadiusMicrometres);
|
||||
liminal.mouthFloorOffsetSubvoxels=mouthPlayerCenterHeight;
|
||||
|
||||
constexpr std::int64_t GradeQ16=17560;
|
||||
constexpr std::int64_t MinimumPathOffsetMetres=150;
|
||||
constexpr std::int64_t ConnectorSpanMetres=16;
|
||||
const std::int64_t dx=departureConnector[0]-arrivalConnector[0];
|
||||
const std::int64_t dy=departureConnector[1]-arrivalConnector[1];
|
||||
const std::uint64_t chord=horizontalLengthQ(dx,dy);
|
||||
std::int64_t directionX=dx,directionY=dy;
|
||||
std::uint64_t directionLength=chord;
|
||||
if(directionLength<static_cast<std::uint64_t>(qPerMetre/4))
|
||||
{directionX=Q;directionY=0;directionLength=static_cast<std::uint64_t>(Q);}
|
||||
const std::int64_t normalX=-directionY;
|
||||
const std::int64_t normalY=directionX;
|
||||
const std::int64_t vertical=std::abs(departureConnector[2]-arrivalConnector[2]);
|
||||
const std::int64_t requiredHorizontal=(vertical*65536+GradeQ16-1)/GradeQ16;
|
||||
std::int64_t routeOffset=std::max(MinimumPathOffsetMetres*qPerMetre,
|
||||
(requiredHorizontal+1)/2+20*qPerMetre);
|
||||
if(chord<ConnectorSpanMetres*static_cast<std::uint64_t>(qPerMetre))
|
||||
routeOffset=std::max(routeOffset,MinimumPathOffsetMetres*qPerMetre);
|
||||
liminal.routeOffsetSubvoxels=routeOffset;
|
||||
|
||||
const auto normalOffset=[&](std::int64_t scale)
|
||||
{
|
||||
return std::array<std::int64_t,2>{
|
||||
normalX*scale/static_cast<std::int64_t>(directionLength),
|
||||
normalY*scale/static_cast<std::int64_t>(directionLength)};
|
||||
};
|
||||
const auto directionOffset=[&](std::int64_t scale)
|
||||
{
|
||||
return std::array<std::int64_t,2>{
|
||||
directionX*scale/static_cast<std::int64_t>(directionLength),
|
||||
directionY*scale/static_cast<std::int64_t>(directionLength)};
|
||||
};
|
||||
const auto normal=normalOffset(routeOffset);
|
||||
const auto connector=chord<ConnectorSpanMetres*static_cast<std::uint64_t>(qPerMetre)
|
||||
?directionOffset(ConnectorSpanMetres*qPerMetre):std::array<std::int64_t,2>{0,0};
|
||||
std::vector<std::array<std::int64_t,3>> horizontalPath;
|
||||
horizontalPath.reserve(8);
|
||||
horizontalPath.push_back(routeStart);
|
||||
horizontalPath.push_back(arrivalConnectorMid);
|
||||
horizontalPath.push_back(arrivalConnector);
|
||||
horizontalPath.push_back({arrivalConnector[0]+normal[0],
|
||||
arrivalConnector[1]+normal[1],0});
|
||||
horizontalPath.push_back({departureConnector[0]+normal[0]+connector[0],
|
||||
departureConnector[1]+normal[1]+connector[1],0});
|
||||
horizontalPath.push_back(departureConnector);
|
||||
horizontalPath.push_back(departureConnectorMid);
|
||||
horizontalPath.push_back(routeFinish);
|
||||
|
||||
std::vector<WorldAssemblyRoutePointV1> route;
|
||||
route.reserve(horizontalPath.size());
|
||||
std::vector<std::uint64_t> cumulative(horizontalPath.size(),0);
|
||||
std::uint64_t totalHorizontal=0;
|
||||
for(std::size_t i=1;i<horizontalPath.size();++i)
|
||||
{
|
||||
const std::int64_t sx=horizontalPath[i][0]-horizontalPath[i-1][0];
|
||||
const std::int64_t sy=horizontalPath[i][1]-horizontalPath[i-1][1];
|
||||
const std::uint64_t segment=horizontalLengthQ(sx,sy);
|
||||
if(segment==0){error="Core.Liminal.Field route contains a zero-horizontal segment";return false;}
|
||||
totalHorizontal+=segment;
|
||||
cumulative[i]=totalHorizontal;
|
||||
}
|
||||
if(totalHorizontal<static_cast<std::uint64_t>(requiredHorizontal))
|
||||
{error="Core.Liminal.Field route cannot meet the 15-degree grade bound";return false;}
|
||||
const std::uint64_t centralFirst=horizontalLengthQ(
|
||||
horizontalPath[3][0]-horizontalPath[2][0],
|
||||
horizontalPath[3][1]-horizontalPath[2][1]);
|
||||
const std::uint64_t centralMiddle=horizontalLengthQ(
|
||||
horizontalPath[4][0]-horizontalPath[3][0],
|
||||
horizontalPath[4][1]-horizontalPath[3][1]);
|
||||
const std::uint64_t centralLast=horizontalLengthQ(
|
||||
horizontalPath[5][0]-horizontalPath[4][0],
|
||||
horizontalPath[5][1]-horizontalPath[4][1]);
|
||||
const std::uint64_t centralTotal=centralFirst+centralMiddle+centralLast;
|
||||
if(centralTotal==0)
|
||||
{error="Core.Liminal.Field central route has no horizontal span";return false;}
|
||||
const std::int64_t centralVertical=departureConnector[2]-arrivalConnector[2];
|
||||
horizontalPath[3][2]=arrivalConnector[2]+roundRatio(
|
||||
centralVertical*static_cast<std::int64_t>(centralFirst),centralTotal);
|
||||
horizontalPath[4][2]=arrivalConnector[2]+roundRatio(
|
||||
centralVertical*static_cast<std::int64_t>(centralFirst+centralMiddle),centralTotal);
|
||||
for(std::size_t i=0;i<horizontalPath.size();++i)
|
||||
{
|
||||
auto point=horizontalPath[i];
|
||||
WorldAssemblyRoutePointV1 routePoint;
|
||||
routePoint.positionSubvoxels=point;
|
||||
routePoint.radiusSubvoxels=std::max<std::int64_t>(liminal.routeHalfWidthSubvoxels,
|
||||
micrometresToQ(500000));
|
||||
std::uint64_t id=14695981039346656037ull;
|
||||
const auto add=[&](std::uint64_t value,std::uint32_t bytes)
|
||||
{for(std::uint32_t byte=0;byte<bytes;++byte){id^=static_cast<std::uint8_t>(value>>(byte*8));id*=1099511628211ull;}};
|
||||
add(seed,4);add(static_cast<std::uint32_t>(stratum.strateIndex),4);add(i,4);
|
||||
routePoint.stableFeatureId=id?id:1;
|
||||
route.push_back(routePoint);
|
||||
}
|
||||
stratum.route=std::move(route);
|
||||
stratum.routeConstraints={1,micrometresToQ(340000),micrometresToQ(1760000),
|
||||
GradeQ16,micrometresToQ(125000),micrometresToQ(250000),
|
||||
micrometresToQ(1000000),micrometresToQ(120000),3};
|
||||
stratum.liminalField=liminal;
|
||||
std::string validationError;
|
||||
if(!world_assembly_detail::ValidateSurfaceProfile(stratum.surfaceProfile,seed,validationError)
|
||||
||!world_assembly_detail::ValidateStratum(stratum,recipe,validationError))
|
||||
{error=validationError.empty()?"Core.Liminal.Field produced an invalid plan":validationError;return false;}
|
||||
std::printf("[CoreLiminalFieldPlan] recipe=%.*s version=%u variant=%.*s seed=%u arrival=%s departure=%s route_points=%zu route_offset_m=%.3f route_grade_limit_deg=15.000 status=PASS\n",
|
||||
static_cast<int>(CoreLiminalFieldRecipeIdV1.size()),CoreLiminalFieldRecipeIdV1.data(),
|
||||
liminal.version,static_cast<int>(definition.name.size()),definition.name.data(),seed,
|
||||
arrival->stableId.c_str(),departure->stableId.c_str(),stratum.route.size(),
|
||||
static_cast<double>(routeOffset)/qPerMetre);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::array<std::array<std::int64_t,2>,6> CoreLiminalRouteXYV2(
|
||||
std::int64_t startX,std::int64_t startY,std::int64_t finishX,
|
||||
std::int64_t finishY,std::int64_t qPerMetre)
|
||||
{
|
||||
return {{{{startX,startY}},{{-40*qPerMetre,40*qPerMetre}},
|
||||
{{-40*qPerMetre,-40*qPerMetre}},{{40*qPerMetre,-40*qPerMetre}},
|
||||
{{40*qPerMetre,40*qPerMetre}},{{finishX,finishY}}}};
|
||||
}
|
||||
|
||||
std::vector<WorldAssemblyRoutePointV1> BuildCoreLiminalRouteV2(
|
||||
const WorldAssemblySurfaceProfileV1& profile,
|
||||
const ResolvedLayoutLandingPadV1& arrivalPad,
|
||||
const ResolvedLayoutLandingPadV1& departurePad,
|
||||
const WorldAssemblyLiminalVariantDefinitionV1& definition,
|
||||
std::uint32_t seed,std::int32_t strateIndex,std::int64_t coreMicrons,
|
||||
std::int64_t qPerMetre,
|
||||
std::uint64_t& totalHorizontal,std::string& error)
|
||||
{
|
||||
error.clear();totalHorizontal=0;
|
||||
const auto groundHeightAt=[&](std::int64_t x,std::int64_t y)
|
||||
{
|
||||
if(profile.kind==WorldAssemblySurfaceProfileKindV1::flatField)
|
||||
return profile.h0Subvoxels;
|
||||
const std::int64_t turnX=world_assembly_detail::SurfaceProfileTurnAtCoordinate(
|
||||
x,profile.wavelengthXSubvoxels,profile.phaseXTurnsQ32);
|
||||
const std::int64_t turnY=world_assembly_detail::SurfaceProfileTurnAtCoordinate(
|
||||
y,profile.wavelengthYSubvoxels,profile.phaseYTurnsQ32);
|
||||
return profile.h0Subvoxels
|
||||
+world_assembly_detail::MulQ30(profile.amplitudeXSubvoxels,
|
||||
world_assembly_detail::CosineQ30(turnX))
|
||||
+world_assembly_detail::MulQ30(profile.amplitudeYSubvoxels,
|
||||
world_assembly_detail::CosineQ30(turnY));
|
||||
};
|
||||
const auto horizontalLengthQ=[](std::int64_t dx,std::int64_t dy)
|
||||
{return world_assembly_detail::IntegerSqrt(static_cast<std::uint64_t>(dx*dx+dy*dy));};
|
||||
const auto micrometresToQ=[&](std::int64_t micrometres)
|
||||
{return (micrometres*WorldAssemblySubvoxelsPerVoxel+coreMicrons/2)/coreMicrons;};
|
||||
const auto routeXY=CoreLiminalRouteXYV2(arrivalPad.positionSubvoxels[0],
|
||||
arrivalPad.positionSubvoxels[1],departurePad.positionSubvoxels[0],
|
||||
departurePad.positionSubvoxels[1],qPerMetre);
|
||||
std::vector<WorldAssemblyRoutePointV1> route;
|
||||
const std::int64_t pointSpacing=12*qPerMetre;
|
||||
const std::int64_t routeRadius=std::max<std::int64_t>(
|
||||
micrometresToQ(definition.routeHalfWidthMicrometres),micrometresToQ(500000));
|
||||
const auto routeId=[&](std::size_t ordinal)
|
||||
{
|
||||
std::uint64_t id=14695981039346656037ull;
|
||||
const auto add=[&](std::uint64_t value,std::uint32_t bytes)
|
||||
{for(std::uint32_t byte=0;byte<bytes;++byte)
|
||||
{id^=static_cast<std::uint8_t>(value>>(byte*8));id*=1099511628211ull;}};
|
||||
add(seed,4);add(static_cast<std::uint32_t>(strateIndex),4);add(ordinal,4);
|
||||
return id?id:1;
|
||||
};
|
||||
const auto appendRoutePoint=[&](std::int64_t x,std::int64_t y)
|
||||
{
|
||||
WorldAssemblyRoutePointV1 point;
|
||||
point.positionSubvoxels={x,y,groundHeightAt(x,y)};
|
||||
point.radiusSubvoxels=routeRadius;
|
||||
point.stableFeatureId=routeId(route.size());
|
||||
route.push_back(point);
|
||||
};
|
||||
const auto interpolate=[](std::int64_t start,std::int64_t delta,
|
||||
std::uint64_t step,std::uint64_t steps)
|
||||
{
|
||||
const std::int64_t numerator=delta*static_cast<std::int64_t>(step);
|
||||
const std::int64_t divisor=static_cast<std::int64_t>(steps);
|
||||
return start+(numerator>=0?(numerator+divisor/2)/divisor
|
||||
:(numerator-divisor/2)/divisor);
|
||||
};
|
||||
appendRoutePoint(routeXY.front()[0],routeXY.front()[1]);
|
||||
for(std::size_t segment=1;segment<routeXY.size();++segment)
|
||||
{
|
||||
const std::int64_t dx=routeXY[segment][0]-routeXY[segment-1][0];
|
||||
const std::int64_t dy=routeXY[segment][1]-routeXY[segment-1][1];
|
||||
const std::uint64_t length=horizontalLengthQ(dx,dy);
|
||||
if(length==0){error="Core.Liminal.Field v2 route contains a zero-horizontal segment";return {};}
|
||||
totalHorizontal+=length;
|
||||
const std::uint64_t steps=std::max<std::uint64_t>(1,
|
||||
(length+static_cast<std::uint64_t>(pointSpacing)-1)
|
||||
/static_cast<std::uint64_t>(pointSpacing));
|
||||
for(std::uint64_t step=1;step<=steps;++step)
|
||||
appendRoutePoint(interpolate(routeXY[segment-1][0],dx,step,steps),
|
||||
interpolate(routeXY[segment-1][1],dy,step,steps));
|
||||
}
|
||||
if(route.size()<2||totalHorizontal<300*static_cast<std::uint64_t>(qPerMetre)
|
||||
||route.front().positionSubvoxels[2]!=arrivalPad.floorHeightSubvoxels
|
||||
||route.back().positionSubvoxels[2]!=departurePad.floorHeightSubvoxels)
|
||||
{error="Core.Liminal.Field v2 pad-to-pad route is incomplete or too short";return {};}
|
||||
return route;
|
||||
}
|
||||
|
||||
bool PrepareCoreLiminalLandingPadsV2(WorldAssemblyRecipeV1& recipe,
|
||||
WorldAssemblyStratumPlanV1& stratum,ResolvedLayoutSnapshotV3& layout,
|
||||
std::string& error)
|
||||
{
|
||||
error.clear();
|
||||
if(layout.coreVoxelSizeMicrometres==0||stratum.strateIndex<0
|
||||
||stratum.topChunkZ<stratum.bottomChunkZ)
|
||||
{error="Core.Liminal.Field v2 needs a finite stratum and resolved Core voxel size";return false;}
|
||||
auto resolved=std::find_if(layout.strates.begin(),layout.strates.end(),
|
||||
[&](const auto& value){return value.strateIndex==stratum.strateIndex
|
||||
&&value.generatorKind=="SurfaceWorld";});
|
||||
if(resolved==layout.strates.end()||!resolved->content.presentInCurrentField)
|
||||
{error="Core.Liminal.Field v2 must target a resolved SurfaceWorld content band";return false;}
|
||||
|
||||
constexpr std::int64_t Q=WorldAssemblySubvoxelsPerVoxel;
|
||||
const std::int64_t coreMicrons=layout.coreVoxelSizeMicrometres;
|
||||
const auto micrometresToQ=[&](std::int64_t micrometres)
|
||||
{
|
||||
const std::int64_t numerator=micrometres*Q;
|
||||
return numerator>=0?(numerator+coreMicrons/2)/coreMicrons
|
||||
:(numerator-coreMicrons/2)/coreMicrons;
|
||||
};
|
||||
const std::int64_t qPerMetre=micrometresToQ(1000000);
|
||||
if(qPerMetre<=0){error="Core.Liminal.Field v2 could not resolve fixed-scale metres";return false;}
|
||||
|
||||
const std::uint32_t seed=recipe.seed;
|
||||
const WorldAssemblyLiminalVariantV1 variant=SelectCoreLiminalFieldVariantV1(seed);
|
||||
const auto& definition=CoreLiminalFieldVariantsV1[static_cast<std::size_t>(variant)];
|
||||
const std::int64_t bottom=static_cast<std::int64_t>(stratum.bottomChunkZ)
|
||||
*recipe.layoutChunkWidthSubvoxels;
|
||||
const std::int64_t top=(static_cast<std::int64_t>(stratum.topChunkZ)+1)
|
||||
*recipe.layoutChunkWidthSubvoxels;
|
||||
const std::int64_t contentBottom=micrometresToQ(resolved->content.bottomMicrometres);
|
||||
const std::int64_t profileRelief=variant==WorldAssemblyLiminalVariantV1::repeatingHills
|
||||
?2*micrometresToQ(definition.hillAmplitudeMicrometres):0;
|
||||
const std::int64_t deckSweepRadius=static_cast<std::int64_t>(std::ceil(std::hypot(
|
||||
static_cast<double>(definition.routeHalfWidthMicrometres+250000),
|
||||
static_cast<double>(definition.routeHalfThicknessMicrometres+250000))));
|
||||
const std::int64_t floorClearance=definition.routeHalfThicknessMicrometres
|
||||
+deckSweepRadius+250000;
|
||||
// Keep the field above the lower tunnel's mouth transition so the routed
|
||||
// departure has a clean open section before it reaches the cave wall.
|
||||
constexpr std::int64_t MinimumFieldGroundMarginMicrometres=20000000;
|
||||
const std::int64_t fieldGroundMargin=std::max(floorClearance,
|
||||
MinimumFieldGroundMarginMicrometres);
|
||||
const std::int64_t floorHeight=contentBottom+profileRelief
|
||||
+micrometresToQ(fieldGroundMargin);
|
||||
if(floorHeight<=bottom||top<=floorHeight+8*qPerMetre)
|
||||
{error="Core.Liminal.Field v2 has no room for its supported field and cap";return false;}
|
||||
|
||||
WorldAssemblySurfaceProfileV1 profile;
|
||||
profile.version=1;
|
||||
profile.kind=definition.surfaceKind;
|
||||
profile.h0Subvoxels=floorHeight;
|
||||
if(profile.kind==WorldAssemblySurfaceProfileKindV1::periodicHills)
|
||||
{
|
||||
profile.amplitudeXSubvoxels=micrometresToQ(definition.hillAmplitudeMicrometres);
|
||||
profile.amplitudeYSubvoxels=profile.amplitudeXSubvoxels;
|
||||
profile.wavelengthXSubvoxels=micrometresToQ(definition.hillWavelengthMicrometres);
|
||||
profile.wavelengthYSubvoxels=profile.wavelengthXSubvoxels;
|
||||
ResolveWorldAssemblySurfaceProfilePhasesV1(profile,seed);
|
||||
}
|
||||
if(!world_assembly_detail::ValidateSurfaceProfile(profile,seed,error))return false;
|
||||
|
||||
stratum.hasRecipeOverride=true;
|
||||
stratum.recipeOverride=recipe;
|
||||
stratum.recipeOverride.surfaceProfile=profile;
|
||||
stratum.recipeOverride.contentHash=0;
|
||||
stratum.surfaceProfile=profile;
|
||||
stratum.capHeightSubvoxels=top-4*qPerMetre;
|
||||
stratum.capUndulationSubvoxels=0;
|
||||
stratum.capDetailAmplitudeSubvoxels=0;
|
||||
stratum.capRidgeAmplitudeSubvoxels=0;
|
||||
stratum.waterEnabled=false;
|
||||
stratum.waterSurfaceSubvoxels=0;
|
||||
|
||||
const auto groundHeightAt=[&](std::int64_t x,std::int64_t y)
|
||||
{
|
||||
if(profile.kind==WorldAssemblySurfaceProfileKindV1::flatField)
|
||||
return profile.h0Subvoxels;
|
||||
const std::int64_t turnX=world_assembly_detail::SurfaceProfileTurnAtCoordinate(
|
||||
x,profile.wavelengthXSubvoxels,profile.phaseXTurnsQ32);
|
||||
const std::int64_t turnY=world_assembly_detail::SurfaceProfileTurnAtCoordinate(
|
||||
y,profile.wavelengthYSubvoxels,profile.phaseYTurnsQ32);
|
||||
return profile.h0Subvoxels
|
||||
+world_assembly_detail::MulQ30(profile.amplitudeXSubvoxels,
|
||||
world_assembly_detail::CosineQ30(turnX))
|
||||
+world_assembly_detail::MulQ30(profile.amplitudeYSubvoxels,
|
||||
world_assembly_detail::CosineQ30(turnY));
|
||||
};
|
||||
const auto routeXY=CoreLiminalRouteXYV2(-12*qPerMetre,0,12*qPerMetre,0,qPerMetre);
|
||||
const auto squareRoot=[](std::uint64_t value)
|
||||
{return world_assembly_detail::IntegerSqrt(value);};
|
||||
const auto directionQ16=[&](std::int64_t dx,std::int64_t dy)
|
||||
{
|
||||
const std::uint64_t length=squareRoot(static_cast<std::uint64_t>(dx*dx+dy*dy));
|
||||
if(length==0)return std::array<std::int32_t,2>{{0,0}};
|
||||
const auto rounded=[&](std::int64_t value)
|
||||
{return value>=0?(value+static_cast<std::int64_t>(length/2))
|
||||
/static_cast<std::int64_t>(length)
|
||||
:(value-static_cast<std::int64_t>(length/2))
|
||||
/static_cast<std::int64_t>(length);};
|
||||
return std::array<std::int32_t,2>{{
|
||||
static_cast<std::int32_t>(rounded(dx*65536)),
|
||||
static_cast<std::int32_t>(rounded(dy*65536))}};
|
||||
};
|
||||
const auto arrivalDirection=directionQ16(routeXY[1][0]-routeXY[0][0],
|
||||
routeXY[1][1]-routeXY[0][1]);
|
||||
const auto departureDirection=directionQ16(routeXY.back()[0]-routeXY[routeXY.size()-2][0],
|
||||
routeXY.back()[1]-routeXY[routeXY.size()-2][1]);
|
||||
const std::int64_t playerCenterAboveFloor=micrometresToQ(1130000);
|
||||
const std::int64_t landingClearance=micrometresToQ(6000000);
|
||||
const std::int64_t supportDepth=micrometresToQ(2000000);
|
||||
const auto makePad=[&](std::string id,ResolvedLayoutLandingPadRoleV1 role,
|
||||
const std::array<std::int64_t,2>& xy,const std::array<std::int32_t,2>& direction)
|
||||
{
|
||||
ResolvedLayoutLandingPadV1 pad;
|
||||
pad.stableId=std::move(id);pad.role=role;
|
||||
pad.floorHeightSubvoxels=groundHeightAt(xy[0],xy[1]);
|
||||
pad.positionSubvoxels={xy[0],xy[1],pad.floorHeightSubvoxels+playerCenterAboveFloor};
|
||||
pad.approachDirectionQ16=direction;
|
||||
pad.clearanceSubvoxels=landingClearance;
|
||||
pad.supportDepthSubvoxels=supportDepth;
|
||||
pad.certifiedWalkableWithSupport=true;
|
||||
return pad;
|
||||
};
|
||||
resolved->recipeId=std::string(CoreLiminalFieldRecipeIdV1);
|
||||
resolved->recipeVersion=CoreLiminalFieldRecipeVersionV2;
|
||||
resolved->landingPads.clear();
|
||||
resolved->landingPads.push_back(makePad("core/liminal-field/landing/arrival/000",
|
||||
ResolvedLayoutLandingPadRoleV1::arrival,routeXY.front(),arrivalDirection));
|
||||
resolved->landingPads.push_back(makePad("core/liminal-field/landing/departure/000",
|
||||
ResolvedLayoutLandingPadRoleV1::departure,routeXY.back(),departureDirection));
|
||||
std::uint64_t routeLength=0;
|
||||
stratum.route=BuildCoreLiminalRouteV2(profile,resolved->landingPads[0],
|
||||
resolved->landingPads[1],definition,seed,stratum.strateIndex,coreMicrons,
|
||||
qPerMetre,routeLength,error);
|
||||
if(!error.empty())return false;
|
||||
stratum.routeConstraints={1,micrometresToQ(340000),micrometresToQ(1760000),
|
||||
17560,micrometresToQ(125000),micrometresToQ(250000),
|
||||
micrometresToQ(1000000),micrometresToQ(120000),3};
|
||||
resolved->anchors.erase(std::remove_if(resolved->anchors.begin(),resolved->anchors.end(),
|
||||
[](const auto& anchor){return anchor.kind=="route_point"
|
||||
||anchor.kind=="liminal_route_point"||anchor.kind=="liminal_variant"
|
||||
||anchor.kind=="heightfield_profile";}),resolved->anchors.end());
|
||||
ResolvedLayoutAnchorV2 profileAnchor;
|
||||
profileAnchor.kind="heightfield_profile";
|
||||
profileAnchor.stableId="heightfield-profile/liminal/"
|
||||
+std::to_string(static_cast<std::uint32_t>(profile.kind));
|
||||
profileAnchor.units="profile kind enum";
|
||||
profileAnchor.values={1,static_cast<std::int64_t>(profile.kind)};
|
||||
resolved->anchors.push_back(std::move(profileAnchor));
|
||||
std::uint64_t routeAnchorDistance=0,lastRouteAnchorDistance=0;
|
||||
for(std::size_t pointIndex=0;pointIndex<stratum.route.size();++pointIndex)
|
||||
{
|
||||
if(pointIndex>0)
|
||||
{
|
||||
const auto& previous=stratum.route[pointIndex-1].positionSubvoxels;
|
||||
const auto& current=stratum.route[pointIndex].positionSubvoxels;
|
||||
const auto dx=static_cast<std::uint64_t>(current[0]-previous[0]);
|
||||
const auto dy=static_cast<std::uint64_t>(current[1]-previous[1]);
|
||||
routeAnchorDistance+=world_assembly_detail::IntegerSqrt(dx*dx+dy*dy);
|
||||
}
|
||||
const bool first=pointIndex==0;
|
||||
const bool last=pointIndex+1==stratum.route.size();
|
||||
const bool spaced=routeAnchorDistance-lastRouteAnchorDistance
|
||||
>=static_cast<std::uint64_t>(8*qPerMetre);
|
||||
if(!first&&!last&&!spaced)continue;
|
||||
const auto& point=stratum.route[pointIndex];
|
||||
ResolvedLayoutAnchorV2 routeAnchor;
|
||||
routeAnchor.kind="route_point";
|
||||
routeAnchor.stableId="route/"+std::to_string(pointIndex);
|
||||
routeAnchor.units="1/256 Core voxel";
|
||||
routeAnchor.values={point.positionSubvoxels[0],point.positionSubvoxels[1],
|
||||
point.positionSubvoxels[2]+playerCenterAboveFloor,point.radiusSubvoxels};
|
||||
resolved->anchors.push_back(std::move(routeAnchor));
|
||||
lastRouteAnchorDistance=routeAnchorDistance;
|
||||
}
|
||||
if(!layout.finalize(error))return false;
|
||||
const auto reportPad=[&](const ResolvedLayoutLandingPadV1& pad)
|
||||
{
|
||||
std::printf("[ResolvedLandingPad] strate=%d id=%s role=%s position_m=(%.3f,%.3f,%.3f) "
|
||||
"floor_m=%.3f approach_q16=(%d,%d) clearance_m=%.3f support_m=%.3f "
|
||||
"certified_walkable_with_support=true status=PASS\n",stratum.strateIndex,
|
||||
pad.stableId.c_str(),pad.role==ResolvedLayoutLandingPadRoleV1::arrival?"arrival":"departure",
|
||||
pad.positionSubvoxels[0]/static_cast<double>(qPerMetre),
|
||||
pad.positionSubvoxels[1]/static_cast<double>(qPerMetre),
|
||||
pad.positionSubvoxels[2]/static_cast<double>(qPerMetre),
|
||||
pad.floorHeightSubvoxels/static_cast<double>(qPerMetre),
|
||||
pad.approachDirectionQ16[0],pad.approachDirectionQ16[1],
|
||||
pad.clearanceSubvoxels/static_cast<double>(qPerMetre),
|
||||
pad.supportDepthSubvoxels/static_cast<double>(qPerMetre));
|
||||
};
|
||||
reportPad(resolved->landingPads[0]);reportPad(resolved->landingPads[1]);
|
||||
std::printf("[LiminalPadRoute] points=%zu path_length_m=%.3f spacing_max_m=4.000 "
|
||||
"profile_ground_following=true status=PASS\n",stratum.route.size(),
|
||||
routeLength/static_cast<double>(qPerMetre));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PlanCoreLiminalFieldV2(WorldAssemblyRecipeV1& recipe,
|
||||
WorldAssemblyStratumPlanV1& stratum,const ResolvedLayoutSnapshotV3& layout,
|
||||
std::string& error)
|
||||
{
|
||||
error.clear();
|
||||
if(layout.coreVoxelSizeMicrometres==0||stratum.strateIndex<0
|
||||
||stratum.topChunkZ<stratum.bottomChunkZ)
|
||||
{error="Core.Liminal.Field v2 needs a finite stratum and resolved Core voxel size";return false;}
|
||||
const auto resolved=std::find_if(layout.strates.begin(),layout.strates.end(),
|
||||
[&](const auto& value){return value.strateIndex==stratum.strateIndex
|
||||
&&value.recipeId==CoreLiminalFieldRecipeIdV1
|
||||
&&value.recipeVersion==CoreLiminalFieldRecipeVersionV2;});
|
||||
if(resolved==layout.strates.end()||!resolved->content.presentInCurrentField
|
||||
||stratum.surfaceProfile.version!=1)
|
||||
{error="Core.Liminal.Field v2 needs its prepared profile and resolved landing pads";return false;}
|
||||
const ResolvedLayoutLandingPadV1* arrivalPad=nullptr;
|
||||
const ResolvedLayoutLandingPadV1* departurePad=nullptr;
|
||||
for(const auto& pad:resolved->landingPads)
|
||||
{
|
||||
if(pad.role==ResolvedLayoutLandingPadRoleV1::arrival)
|
||||
{if(arrivalPad){error="Core.Liminal.Field v2 has multiple arrival pads";return false;}arrivalPad=&pad;}
|
||||
if(pad.role==ResolvedLayoutLandingPadRoleV1::departure)
|
||||
{if(departurePad){error="Core.Liminal.Field v2 has multiple departure pads";return false;}departurePad=&pad;}
|
||||
}
|
||||
if(!arrivalPad||!departurePad||!arrivalPad->certifiedWalkableWithSupport
|
||||
||!departurePad->certifiedWalkableWithSupport)
|
||||
{error="Core.Liminal.Field v2 requires certified arrival and departure pads";return false;}
|
||||
|
||||
const ResolvedLayoutPassageV1* arrival=nullptr;
|
||||
const ResolvedLayoutPassageV1* departure=nullptr;
|
||||
for(const auto& passage:layout.passages)
|
||||
{
|
||||
if(passage.lowerStrateIndex==stratum.strateIndex)
|
||||
{if(arrival){error="Core.Liminal.Field v2 requires one arrival passage";return false;}arrival=&passage;}
|
||||
if(passage.upperStrateIndex==stratum.strateIndex)
|
||||
{if(departure){error="Core.Liminal.Field v2 requires one departure passage";return false;}departure=&passage;}
|
||||
}
|
||||
if(!arrival||!departure||arrival->lowerMouthSubvoxels!=arrivalPad->positionSubvoxels
|
||||
||departure->upperMouthSubvoxels!=departurePad->positionSubvoxels)
|
||||
{error="Core.Liminal.Field v2 passage mouths are not bound to the certified layer pads";return false;}
|
||||
|
||||
constexpr std::int64_t Q=WorldAssemblySubvoxelsPerVoxel;
|
||||
const std::int64_t coreMicrons=layout.coreVoxelSizeMicrometres;
|
||||
const auto micrometresToQ=[&](std::int64_t micrometres)
|
||||
{
|
||||
const std::int64_t numerator=micrometres*Q;
|
||||
return numerator>=0?(numerator+coreMicrons/2)/coreMicrons
|
||||
:(numerator-coreMicrons/2)/coreMicrons;
|
||||
};
|
||||
const std::int64_t qPerMetre=micrometresToQ(1000000);
|
||||
const std::int64_t playerCenterAboveFloor=micrometresToQ(1130000);
|
||||
if(qPerMetre<=0||arrivalPad->positionSubvoxels[2]
|
||||
!=arrivalPad->floorHeightSubvoxels+playerCenterAboveFloor
|
||||
||departurePad->positionSubvoxels[2]
|
||||
!=departurePad->floorHeightSubvoxels+playerCenterAboveFloor)
|
||||
{error="Core.Liminal.Field v2 pad floor and capsule-center heights differ";return false;}
|
||||
|
||||
const std::uint32_t seed=recipe.seed;
|
||||
const WorldAssemblyLiminalVariantV1 variant=SelectCoreLiminalFieldVariantV1(seed);
|
||||
const auto& definition=CoreLiminalFieldVariantsV1[static_cast<std::size_t>(variant)];
|
||||
const auto& profile=stratum.surfaceProfile;
|
||||
std::uint64_t totalHorizontal=0;
|
||||
std::vector<WorldAssemblyRoutePointV1> route=BuildCoreLiminalRouteV2(profile,
|
||||
*arrivalPad,*departurePad,definition,seed,stratum.strateIndex,coreMicrons,
|
||||
qPerMetre,totalHorizontal,error);
|
||||
if(!error.empty())return false;
|
||||
if(route.size()!=stratum.route.size())
|
||||
{error="Core.Liminal.Field v2 route differs from the path used to certify its pads";return false;}
|
||||
for(std::size_t index=0;index<route.size();++index)
|
||||
if(route[index].stableFeatureId!=stratum.route[index].stableFeatureId
|
||||
||route[index].positionSubvoxels!=stratum.route[index].positionSubvoxels
|
||||
||route[index].radiusSubvoxels!=stratum.route[index].radiusSubvoxels)
|
||||
{error="Core.Liminal.Field v2 route differs from the path used to certify its pads";return false;}
|
||||
|
||||
WorldAssemblyLiminalFieldPlanV1 liminal;
|
||||
liminal.version=CoreLiminalFieldRecipeVersionV2;
|
||||
liminal.variant=variant;liminal.seed=seed;
|
||||
liminal.arrivalMouthSubvoxels=arrivalPad->positionSubvoxels;
|
||||
liminal.departureMouthSubvoxels=departurePad->positionSubvoxels;
|
||||
liminal.floorHeightSubvoxels=profile.h0Subvoxels;
|
||||
liminal.routeHalfWidthSubvoxels=micrometresToQ(definition.routeHalfWidthMicrometres);
|
||||
liminal.routeHalfThicknessSubvoxels=micrometresToQ(definition.routeHalfThicknessMicrometres);
|
||||
liminal.routeOffsetSubvoxels=150*qPerMetre;
|
||||
liminal.supportSpacingSubvoxels=micrometresToQ(definition.supportSpacingMicrometres);
|
||||
liminal.supportRadiusSubvoxels=micrometresToQ(definition.supportRadiusMicrometres);
|
||||
liminal.mouthFloorOffsetSubvoxels=playerCenterAboveFloor;
|
||||
stratum.route=std::move(route);
|
||||
stratum.routeConstraints={1,micrometresToQ(340000),micrometresToQ(1760000),
|
||||
17560,micrometresToQ(125000),micrometresToQ(250000),
|
||||
micrometresToQ(1000000),micrometresToQ(120000),3};
|
||||
stratum.liminalField=liminal;
|
||||
std::string validationError;
|
||||
if(!world_assembly_detail::ValidateSurfaceProfile(stratum.surfaceProfile,seed,validationError)
|
||||
||!world_assembly_detail::ValidateStratum(stratum,recipe,validationError))
|
||||
{error=validationError.empty()?"Core.Liminal.Field v2 produced an invalid plan":validationError;return false;}
|
||||
std::printf("[CoreLiminalFieldPlan] recipe=%.*s version=%u variant=%.*s seed=%u "
|
||||
"arrival_pad=%s departure_pad=%s route_points=%zu path_length_m=%.3f "
|
||||
"profile_ground_following=true route_grade_limit_deg=15.000 status=PASS\n",
|
||||
static_cast<int>(CoreLiminalFieldRecipeIdV1.size()),CoreLiminalFieldRecipeIdV1.data(),
|
||||
liminal.version,static_cast<int>(definition.name.size()),definition.name.data(),seed,
|
||||
arrivalPad->stableId.c_str(),departurePad->stableId.c_str(),stratum.route.size(),
|
||||
totalHorizontal/static_cast<double>(qPerMetre));
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string WorldAssemblyManifest::ToJson() const
|
||||
{
|
||||
std::string error;
|
||||
@@ -2391,6 +3183,27 @@ std::string WorldAssemblyManifest::ToJson() const
|
||||
<< ",\"minimum_clearance_q\":" << constraints.minimumClearanceSubvoxels
|
||||
<< ",\"minimum_support_probes\":" << constraints.minimumSupportProbes << '}';
|
||||
}
|
||||
if (stratum.liminalField.version != 0)
|
||||
{
|
||||
const auto& liminal = stratum.liminalField;
|
||||
out << ",\"liminal_field\":{\"version\":" << liminal.version
|
||||
<< ",\"variant\":" << static_cast<std::uint32_t>(liminal.variant)
|
||||
<< ",\"seed\":" << liminal.seed << ",\"arrival_mouth_q\":["
|
||||
<< liminal.arrivalMouthSubvoxels[0] << ','
|
||||
<< liminal.arrivalMouthSubvoxels[1] << ','
|
||||
<< liminal.arrivalMouthSubvoxels[2] << "],\"departure_mouth_q\":["
|
||||
<< liminal.departureMouthSubvoxels[0] << ','
|
||||
<< liminal.departureMouthSubvoxels[1] << ','
|
||||
<< liminal.departureMouthSubvoxels[2]
|
||||
<< "],\"mouth_floor_offset_q\":" << liminal.mouthFloorOffsetSubvoxels
|
||||
<< ",\"floor_height_q\":"
|
||||
<< liminal.floorHeightSubvoxels
|
||||
<< ",\"route_half_width_q\":" << liminal.routeHalfWidthSubvoxels
|
||||
<< ",\"route_half_thickness_q\":" << liminal.routeHalfThicknessSubvoxels
|
||||
<< ",\"route_offset_q\":" << liminal.routeOffsetSubvoxels
|
||||
<< ",\"support_spacing_q\":" << liminal.supportSpacingSubvoxels
|
||||
<< ",\"support_radius_q\":" << liminal.supportRadiusSubvoxels << '}';
|
||||
}
|
||||
out << ",\"route\":[";
|
||||
for (std::size_t p = 0; p < stratum.route.size(); ++p)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# VoxelForge — read-only design review: stamp tunnels along the curve instead of searching the curve per sample
|
||||
|
||||
You are reviewing a design idea against real code. **Do not edit, create or delete any file, and do not build or run
|
||||
anything.** Read code and reason. Be thorough; mark each claim as [CODE] (with file:line), [MEASURED] (from a report),
|
||||
or [INFERENCE].
|
||||
|
||||
## Context
|
||||
|
||||
VoxelForge Core (`Standalone/Core`) generates a density field: negative = solid, positive = air, 25 cm voxels, 32³ tiles,
|
||||
LOD0–8 clipmap, marching cubes. Same seed must give the same world on every machine and worker count; byte-identity with
|
||||
older versions is NOT required.
|
||||
|
||||
Inter-strate tunnels ("passages") on branch `wip/l111-halfpipe-spline` (HEAD `707bc61`) are a smooth spline swept tube:
|
||||
- a D-shaped walkable floor with a continuous floor height h(s), max grade 15°;
|
||||
- a rounded crest;
|
||||
- an open-air half-pipe where there is no rock;
|
||||
- a thin solid shell ("plain shell rule": interior always air, a thin shell always solid);
|
||||
- smooth-min where tunnels meet;
|
||||
- mouths that ramp into the ground.
|
||||
|
||||
Key code: `Standalone/Core/src/ShapeWorldInternal.h` (`FScopedPassageTileQueries`, `prepareSweptTubeBase`, around
|
||||
lines 374 and 11436), `Standalone/Core/src/VoxelForgeCore.cpp:~2747`. Reports: `Standalone/Research/WorldAssemblerM3/Linux109`
|
||||
… `Linux119` (REPORT.md each).
|
||||
|
||||
Cost today [MEASURED, L117–L119]: the smooth tunnel is 13–22% slower than the coarse L109 tunnel at LOD0–2 readiness.
|
||||
About 95% of the extra cost is in passage-feature tiles. The causes: sweep pairs (sample × curve segment), exact
|
||||
fallbacks that re-scan segments for off-grid queries, segment visits, per-tile prepare, and smooth-min.
|
||||
|
||||
## The idea to review (owner + orchestrator)
|
||||
|
||||
Today each sample "pulls": it searches for the nearest curve segment and computes a distance. Proposed: "push"/stamp.
|
||||
For each tile, walk the curve in rings (frames: position, forward, up, as with an Unreal spline mesh) and write the
|
||||
tube's signed distance into only the lattice samples near each ring. Only a narrow band of about one voxel around the
|
||||
wall needs an exact value; deep rock only needs "rock" and the tunnel interior only needs "air". Off-grid queries
|
||||
(normals/gradients at seams, gameplay point queries) would read the stamped lattice, interpolating, instead of
|
||||
re-searching the curve. Also: accept curve accuracy at one voxel (25 cm) rather than 2.5–10 cm, and build the curve
|
||||
frames once per route rather than per tile.
|
||||
|
||||
## Questions
|
||||
|
||||
1. Is the cost model right? From the code, what does a passage-feature tile do per sample today, and what would stamping
|
||||
replace? Estimate the work ratio (samples touched × ops) for a typical LOD0 tile and for LOD1–2.
|
||||
2. What breaks or gets harder, concretely in this code?
|
||||
- box verdicts/tile skipping (`ClassifyTile`-style proofs that a tile is all solid/air);
|
||||
- seams between tiles: both neighbours must stamp identical values at shared samples, deterministically;
|
||||
- LOD levels (coarser lattices);
|
||||
- off-grid `DensityAtUnclamped` queries and gradients;
|
||||
- min/smooth-min with the rest of the field and with other tunnels;
|
||||
- the floor h(s), half-pipe, shell, mouths;
|
||||
- walk audits;
|
||||
- thread safety.
|
||||
3. Is "write into the lattice" compatible with how density is evaluated (lattice fill vs point evaluation)? If the
|
||||
pipeline is pull-only, what is the smallest change that gets the push benefit? For example, a per-tile sparse
|
||||
band list built by walking rings, then lookups during fill.
|
||||
4. Is there a simpler or cheaper alternative we're missing? For example, an analytic local frame per sample from the
|
||||
spline parameter, or a coarse per-tile segment window that is already almost free.
|
||||
5. Recommendation: the design you would build, in steps, with the evidence bar (what measurement would prove or kill
|
||||
it). Keep it short.
|
||||
|
||||
Write your answer as plain Markdown, at most ~1,200 words.
|
||||
@@ -0,0 +1,53 @@
|
||||
[INFERENCE] **Build a tighter passage cache first.** Ring stamping is promising, but the code already pushes segment contributions into a tile lattice. The main opportunity is reducing coverage, overlap, and off-grid fallback work—not reversing a purely pull-based architecture.
|
||||
|
||||
References below: `H` = `Standalone/Core/src/ShapeWorldInternal.h`; `C` = `Standalone/Core/src/VoxelForgeCore.cpp`; `M` = `Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp`. Report references mean `Standalone/Research/WorldAssemblerM3/LinuxNNN/REPORT.md`.
|
||||
|
||||
**1. Cost model**
|
||||
|
||||
[CODE] `prepareSweptTubeBase` allocates per-passage `(cells+3)³` sample records, including a one-step halo. It walks candidate bounding boxes, lattice samples, then fine segments; individual segment bounds reject some pairs. Accepted pairs compute projection, deterministic square root, radius, floor profile, and potentially shell distance. A final pass flattens the selected tube and caches relief (`H:11436`, `H:11489`, `H:11521`, `H:11659`).
|
||||
|
||||
[CODE] During density evaluation, aligned samples retrieve that cache, then still perform relief weighting, floor shaping, mouth fades, shell composition, and host-field composition. Misses invoke two hierarchy searches: nearest body/floor and nearby shells—not necessarily a linear whole-route scan (`H:1247`, `H:1346`, `H:1427`, `H:1435`).
|
||||
|
||||
[MEASURED] L119 reports 29.40M preparation pairs and 24.46M segment visits across its four LOD0–2 profiles. Preparation fell 59.8% versus L118, while fallbacks increased 55–58%. L117 attributes about 95% of the tile-worker regression to passage-feature tiles. Thus the diagnosis is sound, but those counters do not establish equivalent costs per operation.
|
||||
|
||||
[INFERENCE] For an illustrative straight crossing, assume exposed surface perimeter \(P=16\) m, tile crossing length \(L=32\Delta\), and exact band ±one lattice step. Unique band samples are approximately \(2PL/\Delta^2\):
|
||||
|
||||
| [INFERENCE] Normal tile | LOD0 | LOD1 | LOD2 |
|
||||
|---|---:|---:|---:|
|
||||
| Spacing Δ | 0.25 m | 0.5 m | 1 m |
|
||||
| Band samples | 4,096 | 2,048 | 1,024 |
|
||||
| Fraction of 35³ prepared grid | 9.6% | 4.8% | 2.4% |
|
||||
|
||||
[INFERENCE] These are geometric estimates, not measured “typical tiles”; clipping, floor/outer-shell surfaces, bends, and crossings change them. If today fraction \(f\) of the grid receives \(k\) segment evaluations, and stamping averages \(m\) contributions per band sample, the expensive-work ratio is approximately `(table fraction × m)/(f × k)`, multiplied by relative operation cost. For `f=0.5, k=2, m=2`, that is 19%, 9.6%, 4.8%. Allocation, classification, host evaluation, and composition remain. Dense rings can make \(m\) large and erase this advantage.
|
||||
|
||||
**2. Concrete difficulties**
|
||||
|
||||
- [CODE] **Skipping:** passage-bound overlap currently vetoes a uniform proof and returns `Mixed` (`H:5684`). [INFERENCE] Retain conservative bounds, expanded for approximation and reconstruction support. “No band samples written” proves neither solid nor air: a thin shell can lie between samples.
|
||||
|
||||
- [CODE] **Seams:** cache coordinates derive from integer world coordinates; nearest ties use segment index (`H:11448`, `H:11564`, `H:11640`). [INFERENCE] Generate rings from route-global parameters, never tile-local stepping. Include every contributor affecting shared samples and halos; use stable ordering and arithmetic. Independently clipping ring generation at tile boundaries risks disagreement.
|
||||
|
||||
- [INFERENCE] **LOD:** distinguish 25 cm curve error from lattice spacing. A fixed 25 cm band is insufficient around 1 m cells; cover interpolation corners and gradient support. A 0.75 m shell can disappear at coarse resolution. Independently reconstructed LOD fields also disagree off-grid; define one canonical point-query field.
|
||||
|
||||
- [CODE] **Off-grid:** `sweptSample` rejects non-lattice coordinates; public point queries call `DensityAtUnclamped` without requiring a tile cache (`H:11721`; `C:1875`). Gradients normally use density-grid values, with unclamped fallback (`M:1972`). [INFERENCE] Interpolation needs all eight valid corners, including outside loaded tiles. It changes gradients and zero crossings; interpolating only passage channels also differs from interpolating the fully composed field.
|
||||
|
||||
- [CODE] **Composition:** passage bodies and shells use separate hard minima; final composition includes host/native-floor exceptions (`H:1496–1519`). The floor corner uses smooth-max, implemented through smooth-min (`H:3493`, `H:6940`). [INFERENCE] Do not assume the measured smooth-min cost is tunnel-to-tunnel joining. Preserve separate void/shell channels and composition order. Arbitrary “rock/air” magnitudes can move interpolated crossings and smooth blends.
|
||||
|
||||
- [CODE] **Shape:** floor selection is independent of minimum round distance; the code explicitly warns that minimizing flattened segment distances makes downhill floors jump (`H:1328`). Floor evaluation uses horizontal projection, authored `h(s)`, and XY mouth blending (`H:3448`). Shells have rounded crests and five-metre mouth fades (`H:1461`, `H:3503`). [INFERENCE] A conventional spline-normal D-section changes this geometry. Stamped isolated rings can scallop; rasterize bounded slabs between frames while preserving gravity-relative floors, shell unions, and interior priority.
|
||||
|
||||
- [MEASURED] **Walkability:** L119’s maximum sampled floor-root offset was 7.884 mm; L113 documented voxel-aligned floor ribs. [INFERENCE] A 25 cm curve tolerance does not authorize 25 cm floor error or guarantee ≤15° grade. Audit reconstructed density and meshes, including mouths, overlaps, support, and headroom—not only planner `h(s)`.
|
||||
|
||||
- [CODE] **Threads:** copied worker scopes share prepared data through a shared pointer; active scope is thread-local (`H:353`, `H:11411`; `C:2854`). [INFERENCE] Finish stamping before publication. Parallel ring writes require deterministic reductions; mutable shared lazy caches introduce synchronization and lifetime problems.
|
||||
|
||||
**3. Smallest useful change**
|
||||
|
||||
[CODE] Preparation already precedes `GenerateMesh`, whose fill calls lattice evaluators (`C:2747`, `C:2892`; `M:1488`). Route segments are already prepared globally with 10 cm chord tolerance and an 8 m spacing cap (`H:4601`).
|
||||
|
||||
[INFERENCE] Replace preparation’s broad pair enumeration with conservative slab-to-grid spans or small spatial bins. Keep indexed lookups during pull-based fill. Store body/floor ownership and shell contributions separately; use explicit outside/interior/band states. A dense index plus compact records may beat a sparse hash map. Additional frames can be route-global, but this does not eliminate existing per-tile rasterization.
|
||||
|
||||
**4. Alternatives and recommendation**
|
||||
|
||||
[MEASURED] L115 already reduced coarse candidates 74.1% without reducing fine pairs. L118’s restriction of fallbacks to prepared spans changed output; L116’s 20.8% pair reduction did not recover readiness.
|
||||
|
||||
[INFERENCE] An analytic local frame still needs a parameter lookup, especially near folds. Prefer globally indexed conservative segment bins, shared by preparation and exact point queries.
|
||||
|
||||
[INFERENCE] Build in three steps: instrument unique samples, overlap, misses by caller, memory, and per-LOD time; implement tighter bins/slabs while preserving geometry; then separately trial 25 cm curve tolerance and canonical interpolation. Accept only reproducible near-readiness gains against L119/L109 plus unchanged walk/seam requirements and cross-compiler/worker determinism. Kill the approach if saved distance work becomes rasterization, memory, or reconstruction cost.
|
||||
@@ -0,0 +1,53 @@
|
||||
# VoxelForge — read-only design review: per-block bit flags (masks) to skip work
|
||||
|
||||
You are reviewing an idea against real code. **Do not edit, create or delete any file, and do not build or run
|
||||
anything.** Read code and reason. Be thorough; mark each claim as [CODE] (with file:line), [MEASURED] (from a report),
|
||||
or [INFERENCE].
|
||||
|
||||
## Context
|
||||
|
||||
VoxelForge Core (`Standalone/Core`) generates a density field: negative = solid, positive = air, 25 cm voxels, 32³ tiles,
|
||||
LOD0–8 clipmap, marching cubes. The production mesher for the standalone path is in `Standalone/Source/...`, and Core is
|
||||
also compiled into Unreal. Same seed must give the same world on every machine and worker count; byte-identity with
|
||||
older versions is NOT required.
|
||||
|
||||
Branch `assembler/world` (HEAD `7022d02`'s parent line, currently `4041d4b`) has the smooth inter-strate tunnels.
|
||||
Readiness target: LOD0–2 near terrain in ~1 s on arrival. Measured today: SW ~0.7–0.8 s, VS ~0.9 s, FI/TN ~1.0 s
|
||||
headless. In Linux UE, start→LOD0–2 is ~2.2–2.5 s. At 30 m/s in UE, near coverage is ~40%.
|
||||
|
||||
Reports to consult: `Standalone/Research/WorldAssemblerM3/Linux1*/REPORT.md`, especially:
|
||||
- L77/L87/L117, cost census;
|
||||
- L124, shape gate and per-strate timings;
|
||||
- L125/L126/L128, tunnel prepare: per-segment work, 67–73% pair overlap, support up to 7.9 m;
|
||||
- L128, where an exact one-owner slab scheme halved pairs but got slower because of per-sample ownership tests.
|
||||
|
||||
Tile skipping already exists at tile level: box verdicts / classify, where all-solid or all-air tiles are skipped.
|
||||
|
||||
## The idea (from the owner)
|
||||
|
||||
Keep bit flags per block (1 bit per voxel, or per small brick of e.g. 4³ or 8³ voxels): for example "certainly deep
|
||||
solid", "certainly deep air", "near a surface", "touched by a tunnel", "already evaluated". Use them to skip work in
|
||||
generation and meshing, and possibly elsewhere (collision, streaming, edits, AI/nav, lighting).
|
||||
|
||||
## Questions
|
||||
|
||||
1. Where does time actually go per tile today, and which of that work a mask could skip? Cover density fill,
|
||||
classification, mesher (marching cubes, normals/gradients, skirts, seam refinement), passage prepare, and composition.
|
||||
Use measured census numbers where they exist.
|
||||
2. Which flags are sound? A flag must be a proof, never a guess. A mask "certainly solid" must be derived from a
|
||||
conservative bound (the bound must be the supremum of what the evaluator produces), or the mesher will delete
|
||||
surfaces. Which bounds already exist in the code (box verdicts, interval bounds) that a brick-level mask could reuse?
|
||||
Name the existing hazards. For example, `Identity` means `Sdf >= T`, and `ClassifyTile` proving below-TerrainZ solid
|
||||
would delete 3D caves.
|
||||
3. Granularity: per-voxel bits vs per-brick (4³/8³) vs per-column. What does each cost to build, versus what it
|
||||
saves? Consider SIMD and cache behaviour. Marching cubes needs only cells whose corner signs differ, and gradients
|
||||
need a halo.
|
||||
4. "Already evaluated" or "checked" flags: where would they be correct (memoisation of identical queries), and where
|
||||
would they be wrong (nearest-of-many queries like the tunnel segments)? Is there repeated identical evaluation today,
|
||||
for example halo samples shared between neighbouring tiles, or skirt/seam re-queries?
|
||||
5. Other uses: player edits (diff layer), collision-only server tiles, streaming priority, nav. Which would a mask
|
||||
genuinely help, given the multiplayer rule (replicate seed + layout + diff, never geometry)?
|
||||
6. Recommendation: the one or two mask designs most likely to pay off, with the expected gain (a range, marked
|
||||
inference), the measurement that would prove or kill each, and the risks. Keep it short.
|
||||
|
||||
Write plain Markdown, at most ~1,200 words.
|
||||
@@ -0,0 +1,83 @@
|
||||
[INFERENCE] **Improve the existing brick-proof system; don’t add a general “checked voxel” mask.** The promising savings are avoided field/composition work and passage preparation, rather than marching-cubes classification.
|
||||
|
||||
References below abbreviate these files: [M = production mesher](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp), [S = ShapeWorldInternal.h](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Core/src/ShapeWorldInternal.h), [C = VoxelForgeCore.cpp](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Core/src/VoxelForgeCore.cpp), [O = VoxelDensityOpStack.cpp](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Source/VoxelForge/Private/VoxelDensityOpStack.cpp). L-numbers identify `Standalone/Research/WorldAssemblerM3/LinuxN/REPORT.md`.
|
||||
|
||||
[INFERENCE] Evidence limitation: Linux128 is absent from this checkout. Its supplied 7.9 m support and slower one-owner result remain unverified here. The reports below span different revisions; their percentages must not be presented as today’s census.
|
||||
|
||||
**1. Where time goes**
|
||||
|
||||
[MEASURED] L77’s exclusive FI/TN LOD0–2 worker account:
|
||||
|
||||
| Work | FI | TN |
|
||||
|---|---:|---:|
|
||||
| Total worker CPU/view | 23.02 s | 21.50 s |
|
||||
| Passage composition | 65.9% | 65.4% |
|
||||
| Support | 14.2% | 13.5% |
|
||||
| Base field | 3.2% | 3.3% |
|
||||
| Bands / dispatch | 2.3% / 3.7% | 2.6% / 3.6% |
|
||||
| MC / skirts / refinement, excluding field work | 1.6% / 0.1% / 7.5% | 1.2% / 0.2% / 8.6% |
|
||||
|
||||
[MEASURED] Grid fill occupied 19.058/17.377 worker-seconds, **overlapping** those phase categories. L117’s later census found support zero; its smooth-tunnel regression concentrated about 95% of added tile CPU in passage-feature tiles. Cell classification stayed flat; gradients became cheaper. Thus L77 identifies historical leverage, not current percentages.
|
||||
|
||||
[MEASURED] L125 preparation averaged 514/130/4.1 ms worker work at LOD0/1/2. LOD0 evaluated 3.11–8.49 million segment/sample pairs with 67–72% overlap, although the exact band occupied only 5–7% of grid samples. L126 reduced floor-profile calls 20–23% without useful readiness improvement.
|
||||
|
||||
[CODE] Brick skipping already exists: configurable block verdicts, required-sample masks, and active-block gradient halos (M:646,1053). MC already rejects cells without sign crossings before interpolation/gradients (M:2186). Gradient memoisation exists (M:1956). Skirts issue separate density queries (M:2491); refinement has its own stage (C:2975).
|
||||
|
||||
[INFERENCE] A pre-evaluation proof can save density, composition, and subsequent MC work. A sign mask constructed after density evaluation saves only downstream scanning. Classification itself costs work; no isolated current classification percentage is established by these reports.
|
||||
|
||||
[MEASURED] L124’s shape gate alone took 179–226 ms, with no near-tile admissions before completion. L87 additionally measured a 107–153 ms far-parent gate and no headless collision wait. Tile masks cannot directly remove these gates or explain the supplied UE/headless gap.
|
||||
|
||||
**2. Sound flags and hazards**
|
||||
|
||||
[INFERENCE] For final density \(D\), solid requires an upper bound covering the evaluator’s supremum, \(U<0\); air requires a valid lower bound \(L\ge0\). “Deep” requires an explicit margin. Unknown must stay unknown. A crossing proves an MC-active cell; uniform sampled signs do not prove an entire continuous volume surface-free.
|
||||
|
||||
[CODE] Reusable proofs include:
|
||||
|
||||
- S:5428: halo-expanded box classification.
|
||||
- S:5974: exact surface-column extrema over the MC lattice.
|
||||
- S:6019: floating-island density intervals.
|
||||
- S:5197,6032: native cave intervals.
|
||||
- S:5949: stamp interval composition with outward numerical padding.
|
||||
- O:6998: operation-specific inactivity from SDF lower bounds.
|
||||
|
||||
[CODE] These proofs have guards: passage overlap returns unknown (S:5687); unsupported stamp composition returns unknown (S:5655); stratum crossings are rejected (S:5900). The mesher distinguishes uniform candidates from `bSkipSafe` certificates (M:715,756), and otherwise validates candidates against actual samples (M:1838).
|
||||
|
||||
[CODE] For gated modifiers, `Identity` can mean `Sdf >= T`, not `abs(Sdf) >= T` (O:1496,6998). Noise bounds cannot assume nominal `[-1,1]`: FBM and unclamped cellular bounds are explicitly discussed at O:848,301.
|
||||
|
||||
[INFERENCE] Additional hazards: confusing internal SDF with final density; certifying base terrain before caves, tunnels, shells, stamps or edits; using lattice proofs for off-grid queries; ignoring rounding or gradient halos. A below-`TerrainZ` solid rule applied to 3D caves deletes caves. “Touched by tunnel” should mean conservative candidate overlap; only **proven untouched** authorizes omission.
|
||||
|
||||
**3. Granularity**
|
||||
|
||||
[INFERENCE] Storage per flag for 32³ cells:
|
||||
|
||||
| Granularity | Storage | Construction/tradeoff |
|
||||
|---|---:|---|
|
||||
| Per cell | 4 KiB | 32,768 decisions; expensive unless derived during necessary work |
|
||||
| 4³ bricks | 64 B | 512 proofs; tighter boundaries, more proof/halo overhead |
|
||||
| 8³ bricks | 8 B | 64 proofs; cheaper, more mixed bricks |
|
||||
| XY columns | 128 B | 1,024 decisions; useful mainly for heightfield-specific proofs |
|
||||
|
||||
[INFERENCE] These exclude corner/halo samples, metadata and additional flags. A column bit cannot represent multiple cave intervals. Brick runs preserve contiguous X sampling and SIMD better than scattered per-sample branches; bit packing alone does not establish a speedup.
|
||||
|
||||
[CODE] The existing implementation uses byte arrays and preserves exact samples around active bricks (M:1053–1083). Replacing densities with arbitrary same-sign constants near active cells would corrupt interpolated positions or normals.
|
||||
|
||||
**4. “Already evaluated”**
|
||||
|
||||
[CODE] Passage preparation precedes meshing (C:2747). Its valid sentinel is initialization, not completion: subsequent segments update floor selection, shell minima and round-distance winners, with deterministic tie-breaking (S:11520,11615,11688).
|
||||
|
||||
[INFERENCE] Therefore first-visitor wins is invalid. Memoisation is valid only for identical queries against identical seed/layout/evaluator/edit revision and query semantics; cache the value, not just a bit.
|
||||
|
||||
[MEASURED] L77 counted 8.6–8.9% halo points, without measuring duplicate coordinates. L125 verified 4,900 shared samples across four neighbour pairs and still recorded substantial off-grid skirt/midpoint fallbacks.
|
||||
|
||||
[CODE] Shared-grid reuse and gradient validity caches already exist (M:1031,1956). [INFERENCE] Skirt/seam queries are reuse candidates only when coordinates and semantics actually match; “same voxel” is insufficient.
|
||||
|
||||
**5. Other uses**
|
||||
|
||||
[INFERENCE] Edit-dirty bricks can localize regeneration, with invalidation expanded through normals, seams and collision dependencies. Collision-only servers can skip certified empty regions, but still need boundary geometry. Streaming can prioritize potentially occupied bricks; nav and lighting need clearance/connectivity or visibility data beyond occupancy. Keep masks derived locally from replicated seed + layout + diff, versioned with that state.
|
||||
|
||||
**6. Recommendation**
|
||||
|
||||
- [INFERENCE] **Tune existing 4³/8³ certificates**, caching proofs and emitting contiguous required-sample runs. Plausible incremental near-ready saving: **0–5%**, with regressions possible. Measure proof time, samples avoided, halo expansion, total worker CPU and UE readiness.
|
||||
- [INFERENCE] **Pass required-region information into passage preparation**, rejecting whole segment/brick regions conservatively. Plausible near-ready saving: **0–10%**, highly uncertain because passage overlap currently vetoes proofs. Kill it if pair reduction fails to reduce preparation and end-to-end time.
|
||||
|
||||
[INFERENCE] Both require seam/off-grid validation, adversarial bound checks, edit invalidation tests, and cross-machine/worker determinism. Preserve canonical tie-breaking; old-version byte identity need not constrain a sound redesign.
|
||||
@@ -0,0 +1,37 @@
|
||||
# VoxelForge — open read-only review: what should be done and focused on now?
|
||||
|
||||
You are an outside reviewer. **Do not edit, create or delete any file, and do not build or run anything.** Read the
|
||||
repository and its reports and reason. Be thorough; mark each claim as [CODE] (with file:line), [MEASURED] (from a
|
||||
report), or [INFERENCE].
|
||||
|
||||
## The project, in the owner's words and goals
|
||||
|
||||
- VoxelForge is a UE5 density-field voxel plugin for a **multiplayer** game. The world is made of stacked "strates"
|
||||
(layers) linked by tunnels. The standalone C++ generator is "Core" (`Standalone/Core`), and it is also compiled into
|
||||
Unreal. Read `CLAUDE.md`, `CODEMAP.md`, `ARCHITECTURE.md`, `GDD.md`, `COMPOSER-NOTES.md` and `fable-idea.md` at the
|
||||
plugin root for the game and design intent. The research reports are in
|
||||
`Standalone/Research/WorldAssemblerM3/Linux*/REPORT.md` and the `Astra*/` reviews.
|
||||
- Owner goals stated over time:
|
||||
- the near terrain (LOD0–2) ready in ~1 s on arrival, then far terrain keeps filling in at low priority;
|
||||
- same seed gives the same world on every machine (byte-identity with older versions is NOT required);
|
||||
- smooth for VR and high-speed travel;
|
||||
- all generators (surface, floating islands, caves, dreamcore/liminal, etc.) working in Core;
|
||||
- the system should eventually *invent* strates and worlds from tagged authored content plus a seed;
|
||||
- keep things simple and avoid over-complication.
|
||||
- The current main line is branch `assembler/world`.
|
||||
|
||||
## Your task
|
||||
|
||||
Look at the whole: code, reports, design docs, and the direction of the last few dozen rounds. Tell the owner what
|
||||
should be done and focused on now, whatever it is. That may be performance, features, generation quality, gameplay
|
||||
foundations, architecture, technical debt, tooling, process, or stopping something. Don't assume the recent focus is
|
||||
the right one; question it if the evidence says so.
|
||||
|
||||
Answer:
|
||||
1. **Where the project actually is**: what works, what's fragile, what's missing for the game to exist.
|
||||
2. **The top 3–5 things to focus on now**, ranked, each with why, a rough size, what "done" looks like, and how to
|
||||
tell it worked.
|
||||
3. **What to stop or deprioritise**, and why.
|
||||
4. **Risks or debts** that will bite later if ignored.
|
||||
|
||||
Write plain Markdown, at most ~1,500 words, readable by a non-specialist owner (explain jargon briefly).
|
||||
@@ -0,0 +1,72 @@
|
||||
[INFERENCE] **Focus now on a small, persistent multiplayer descent, supported by end-to-end Unreal performance work.** VoxelForge has substantial terrain technology. The larger uncertainty is whether it delivers a reliable, interesting shared expedition. Another sequence of tunnel optimizations will not resolve that.
|
||||
|
||||
Read-only review of `assembler/world` at `044cd6d`; no files changed and no builds, tests, or project execution. [CODE] identifies repository evidence; design-document references establish intent, not implementation. [MEASURED] means reported results, not independently reproduced measurements.
|
||||
|
||||
**1. Where the project actually is**
|
||||
|
||||
[CODE] **Core is real and integrated, but generator implementation remains mixed.** Unreal compiles the canonical standalone sources through validated wrappers. Core dispatches to surface, floating-island and native graph evaluators, with a legacy-generator fallback when appropriate. That is useful coverage, but does not mean every family has a complete native implementation. See [VoxelForgeCore.Build.cs:15](/home/jahni/VoxelM/Plugins/VoxelForge/Source/VoxelForgeCore/VoxelForgeCore.Build.cs:15) and [ShapeWorldInternal.h:600](/home/jahni/VoxelM/Plugins/VoxelForge/Standalone/Core/src/ShapeWorldInternal.h:600).
|
||||
|
||||
[MEASURED] **Passage correctness and speed have improved substantially.** L109 simplified tunnels and removed support-restoration machinery, reducing floating-island and tunnel-network near-generation times by approximately 31–38%. L125 reports 21,425/21,425 open route samples, a maximum sampled floor offset of 7.884 mm, and matching cross-compiler/worker hashes. These are meaningful engineering achievements. [L109](Standalone/Research/WorldAssemblerM3/Linux109/REPORT.md), [L125](Standalone/Research/WorldAssemblerM3/Linux125/REPORT.md).
|
||||
|
||||
[MEASURED] **The one-second player experience is not achieved.** L125’s standalone near-ready medians were approximately 0.78–1.03 seconds. L127’s Unreal startup took 2.22–2.51 seconds; at 30 m/s, median near coverage fell from 43.36% to 40.22% after the smooth-tunnel merge. Both versions recovered after stopping. L130 found no measurable timing improvement and skipped Unreal movement. These are different workloads; subtracting their times would not isolate a particular overhead. [L125](Standalone/Research/WorldAssemblerM3/Linux125/REPORT.md), [L127](Standalone/Research/WorldAssemblerM3/Linux127/REPORT.md), [L130](Standalone/Research/WorldAssemblerM3/Linux130/REPORT.md).
|
||||
|
||||
[CODE] **Multiplayer foundations exist; the multiplayer lifecycle remains unfinished here.** There are season hashes, local terrain-edit entry points, and streaming-anchor infrastructure. The architecture explicitly leaves join replication, authoritative edit messages, late-join snapshots and networked season changes open. The edit entry point directly dispatches to local modification paths. [VoxelWorld.h:574](/home/jahni/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelWorld.h:574), [VoxelWorld.cpp:15771](/home/jahni/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:15771), [ARCHITECTURE.md:2408](/home/jahni/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:2408).
|
||||
|
||||
[CODE] **The composer is more than a proposal, but less than the full content vision.** It rolls candidates, measures them and rejects failures. The intended system additionally combines tagged geometry, materials, decorations, creatures and audio; theme coherence remains an open design item. [VoxelSeasonManifest.cpp:2523](/home/jahni/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelSeasonManifest.cpp:2523), [COMPOSER-NOTES.md:1512](/home/jahni/VoxelM/Plugins/VoxelForge/COMPOSER-NOTES.md:1512).
|
||||
|
||||
[INFERENCE] **This repository demonstrates a capable terrain platform, not yet a validated game loop.** I cannot establish what gameplay exists outside the plugin. The GDD itself identifies “Is the descent fun?” as its first unresolved question. [GDD.md:441](/home/jahni/VoxelM/Plugins/VoxelForge/GDD.md:441).
|
||||
|
||||
**2. Ranked priorities**
|
||||
|
||||
All sizes below are [INFERENCE]: rough focused engineering estimates, excluding substantial art production.
|
||||
|
||||
**1 — Deliver one multiplayer expedition that survives reconnecting. Size: 3–6 weeks.**
|
||||
|
||||
[INFERENCE] Use two contrasting strates and their connecting tunnel. Two players descend, find one destination, uncover or collect something, return, and retain their changes after restart. Include a third player joining after terrain has changed. Avoid expanding immediately into the city economy or community backend.
|
||||
|
||||
[INFERENCE] The necessary foundation is a server-approved world identity, ordered authoritative edits, a saved snapshot plus subsequent edits, and collision around separated players. Give edits stable identifiers so retransmission cannot apply them twice. Reuse the existing local edit and anchor systems.
|
||||
|
||||
[INFERENCE] **Done:** host/client edits agree; a late joiner reconstructs the same terrain; restart preserves it; incompatible world versions fail clearly; remote players have ground when away from the host.
|
||||
|
||||
[INFERENCE] **Success:** a complete shared expedition without terrain disagreement, lost progress or collision failures—and players can explain what made them want to descend again. This tests the project’s central assumptions much earlier than a complete RPG would.
|
||||
|
||||
**2 — Close the Unreal arrival and movement gap. Size: 1–3 weeks initially.**
|
||||
|
||||
[MEASURED] L122 found idle generation capacity while admission was blocked; L123 measured approximately 19 ms from region request to game-thread observation despite sub-millisecond median compilation. Simple lookahead and admission relaxations failed their combined coverage/frame-time gates. L121’s modest extra far-apply lane did improve far coverage. [L121](Standalone/Research/WorldAssemblerM3/Linux121/REPORT.md), [L122](Standalone/Research/WorldAssemblerM3/Linux122/REPORT.md), [L123](Standalone/Research/WorldAssemblerM3/Linux123/REPORT.md).
|
||||
|
||||
[INFERENCE] First establish one current-build, rendered Unreal baseline on intended hardware. Measure arrival through **visible terrain and usable collision**, then walking, 30 m/s travel, turns, teleporting and edits. Record near coverage, far gaps, frame-time spikes and memory separately. NullRHI measurements, which omit rendering, cannot certify VR comfort.
|
||||
|
||||
[INFERENCE] Investigate region completion, admission and publication before another broad density rewrite. A game-thread continuation is a candidate experiment, not a proven solution; keep world mutation on its owning thread.
|
||||
|
||||
[INFERENCE] **Done:** approximately one-second LOD0–2 readiness on the agreed hardware, safe collision, steadily filling far terrain, and an explicit movement-coverage target without worsening frame spikes. **Success:** repeated rendered runs meet those limits, including with remote-player collision work. Do not redefine “ready” as only the nearest handful of tiles.
|
||||
|
||||
**3 — Turn generator breadth into playable variety. Size: 2–4 weeks for the first content slice.**
|
||||
|
||||
[INFERENCE] Establish a compact capability matrix for each family: Core dispatch, entrances/exits, interior connectivity, edits, materials/content, far terrain and performance. Include dreamcore/liminal explicitly; motif generation alone does not establish a convincing anomalous place.
|
||||
|
||||
[MEASURED] L58 illustrates the distinction: its lattice route passed reachability checks, but the review described a corkscrew tunnel rather than the intended open vertical shaft. Technical validity and identity are separate acceptance criteria. [L58](Standalone/Research/WorldAssemblerM3/Linux58/REPORT.md).
|
||||
|
||||
[INFERENCE] Extend the existing offline composer with a small tagged library: a grounded cave, an island environment and one liminal identity, each with coherent materials, sound and a destination. Preserve owner review before publishing a season.
|
||||
|
||||
[INFERENCE] **Done:** several seeds produce recognizably different, coherent places with findable progression and usable encounter spaces. **Success:** players navigate them without developer guidance and distinguish their identities without reading labels. Expand families after this works.
|
||||
|
||||
**4 — Consolidate the evidence and remove obsolete complexity incrementally. Size: 2–4 days, then ongoing.**
|
||||
|
||||
[INFERENCE] Maintain one short current-state page: shipped behavior, open defects, active baseline, hardware and next acceptance test. Keep historical reports as history. Remove rejected experimental paths when they no longer serve a specific comparison; extract stable responsibilities when touching oversized files.
|
||||
|
||||
[INFERENCE] **Done:** a contributor can identify the current evaluator, runtime path, multiplayer gaps and performance limits without reconstructing 130 rounds. **Success:** new work no longer repeats rejected experiments or treats old measurements as current.
|
||||
|
||||
**3. Stop or deprioritise**
|
||||
|
||||
- [INFERENCE] **Pause open-ended tunnel micro-optimization.** L125/L126 missed the older speed bar; L130’s reach correction produced no measurable speedup. Keep correctness fixes, but require a current cost account and a bounded experiment before further optimization. [MEASURED: L126](Standalone/Research/WorldAssemblerM3/Linux126/REPORT.md), [L130](Standalone/Research/WorldAssemblerM3/Linux130/REPORT.md).
|
||||
- [INFERENCE] **Do not implement Astra recommendations mechanically.** Astra1’s support-heavy model predates L109’s simplification. Astra2/3 offer hypotheses, not measured wins for today’s code. Re-profile before adopting them.
|
||||
- [INFERENCE] **Defer GPU generation, a mesher replacement, elaborate discovery systems and large-scale backend work.** None currently resolves the missing shared expedition.
|
||||
- [INFERENCE] **Stop requiring old-version byte identity for deliberate generation changes.** Preserve peer agreement within a version and explicit versioned saves; permit reviewed geometry improvements.
|
||||
|
||||
**4. Debts that will bite later**
|
||||
|
||||
[INFERENCE] **Determinism needs a deployment contract.** Linux Clang/GCC and worker-count agreement is strong evidence, not proof across every intended machine. Include Windows/compiler/CPU combinations actually supported. World identity must cover evaluator, content, layout, scale and edit state—not merely seed. [MEASURED: L130](Standalone/Research/WorldAssemblerM3/Linux130/REPORT.md).
|
||||
|
||||
[CODE] **Scale and fallback complexity remain active integration risks.** Core is opt-in, and runtime queries bridge different coordinate conventions. [VoxelWorld.h:300](/home/jahni/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelWorld.h:300), [VoxelWorld.cpp:15612](/home/jahni/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:15612). [INFERENCE] Test edits, content placement and collision across those boundaries before broadening authoring.
|
||||
|
||||
[INFERENCE] **A passing route audit does not prove a whole strate is enjoyable, connected or suitable for six-player combat.** Retain geometric checks, but add actual traversal, destination discovery and encounter-space evaluation. Those are now more valuable than another polished tunnel benchmark.
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure the fixed-depth floor and derived centreline against the authored grade."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
LAYOUT = ROOT / "Saved/Linux/l109/audit-layout-final-stamp.json"
|
||||
OUT = ROOT / "Saved/Linux/l109/floor-ramp-grade-final.json"
|
||||
MOUTH_RAMP_METRES = 5.0
|
||||
|
||||
|
||||
def route_m(layout: dict, passage: dict):
|
||||
scale = layout["core_voxel_size_um"] / 256.0 / 1_000_000.0
|
||||
controls = [(tuple(float(v) * scale for v in row["position_q"]),
|
||||
float(row["radius_q"]) * scale) for row in passage["control_points"]]
|
||||
lower_q = passage["lower_mouth_q"]
|
||||
player_center_above_floor_q = round(
|
||||
1.13 * 256.0 * 1_000_000.0 / layout["core_voxel_size_um"])
|
||||
lower_index = next((index for index, row in enumerate(passage["control_points"])
|
||||
if row["position_q"][0] == lower_q[0]
|
||||
and row["position_q"][1] == lower_q[1]
|
||||
and row["position_q"][2] - row["radius_q"]
|
||||
+ player_center_above_floor_q == lower_q[2]), None)
|
||||
if lower_index is None:
|
||||
raise RuntimeError(f"{passage['stable_id']}: lower mouth control is unbound")
|
||||
return controls[:lower_index + 1]
|
||||
|
||||
|
||||
def maximum_grade(profile):
|
||||
grades = []
|
||||
for (p0, z0), (p1, z1) in zip(profile, profile[1:]):
|
||||
horizontal = math.hypot(p1[0] - p0[0], p1[1] - p0[1])
|
||||
if horizontal > 1.0e-8:
|
||||
grades.append(math.degrees(math.atan2(abs(z1 - z0), horizontal)))
|
||||
return max(grades, default=0.0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
layout = json.loads(LAYOUT.read_text(encoding="utf-8"))
|
||||
rows = []
|
||||
for passage in layout["passages"]:
|
||||
route = route_m(layout, passage)
|
||||
segment_lengths = [math.dist(a[0], b[0]) for a, b in zip(route, route[1:])]
|
||||
prefix = [0.0]
|
||||
for length in segment_lengths:
|
||||
prefix.append(prefix[-1] + length)
|
||||
interior_radii = [radius for distance, (_, radius) in zip(prefix, route)
|
||||
if distance >= 10.0 and prefix[-1] - distance >= 10.0]
|
||||
minimum_body_radius = min(interior_radii or [radius for _, radius in route])
|
||||
body_depth = max(0.5, minimum_body_radius - 0.23)
|
||||
scale = layout["core_voxel_size_um"] / 256.0 / 1_000_000.0
|
||||
upper_target = max(0.0, route[0][0][2]
|
||||
- passage["upper_mouth_q"][2] * scale + 1.13)
|
||||
lower_target = max(0.0, route[-1][0][2]
|
||||
- passage["lower_mouth_q"][2] * scale + 1.13)
|
||||
floor_profile = []
|
||||
raw_fixed_depth_profile = []
|
||||
maximum_centerline_shift = 0.0
|
||||
for segment_index, (a, b, length) in enumerate(
|
||||
zip(route, route[1:], segment_lengths)):
|
||||
steps = max(1, math.ceil(length / 0.05))
|
||||
for step in range(steps + 1):
|
||||
if floor_profile and step == 0:
|
||||
continue
|
||||
t = step / steps
|
||||
position = tuple(a[0][axis] + (b[0][axis] - a[0][axis]) * t
|
||||
for axis in range(3))
|
||||
radius = a[1] + (b[1] - a[1]) * t
|
||||
along = prefix[segment_index] + length * t
|
||||
mouth_distance = max(0.0, min(along, prefix[-1] - along))
|
||||
u = max(0.0, min(1.0, mouth_distance / MOUTH_RAMP_METRES))
|
||||
ramp = u * u * (3.0 - 2.0 * u)
|
||||
local_radius_depth = max(0.0, radius - 0.23)
|
||||
mouth_target = upper_target if along <= prefix[-1] * 0.5 else lower_target
|
||||
mouth_depth = max(0.0, min(radius, mouth_target))
|
||||
route_depth = mouth_depth + (local_radius_depth - mouth_depth) * ramp
|
||||
tube_depth = min(body_depth, local_radius_depth)
|
||||
effective_center = position[2] - route_depth + tube_depth
|
||||
floor_profile.append((position, effective_center - tube_depth))
|
||||
raw_fixed_depth_profile.append((position, position[2] - tube_depth))
|
||||
maximum_centerline_shift = max(maximum_centerline_shift,
|
||||
abs(effective_center - position[2]))
|
||||
rows.append({
|
||||
"passage": passage["stable_id"],
|
||||
"path_m": prefix[-1],
|
||||
"body_floor_depth_m": body_depth,
|
||||
"mouth_blend_m": MOUTH_RAMP_METRES,
|
||||
"preserved_route_floor_max_grade_deg": maximum_grade(floor_profile),
|
||||
"unshifted_fixed_depth_floor_max_grade_deg": maximum_grade(
|
||||
raw_fixed_depth_profile),
|
||||
"maximum_derived_centerline_shift_m": maximum_centerline_shift,
|
||||
})
|
||||
OUT.write_text(json.dumps({
|
||||
"method": "0.05 m route samples; fixed body depth from the minimum interior radius; "
|
||||
"cubic smoothstep mouth blend",
|
||||
"passages": rows,
|
||||
}, indent=2) + "\n", encoding="utf-8")
|
||||
for row in rows:
|
||||
print(f"[L109FloorRamp] {row['passage']} "
|
||||
f"depth_m={row['body_floor_depth_m']:.3f} "
|
||||
f"preserved_grade_deg={row['preserved_route_floor_max_grade_deg']:.3f} "
|
||||
f"unshifted_fixed_depth_grade_deg={row['unshifted_fixed_depth_floor_max_grade_deg']:.3f} "
|
||||
f"max_center_shift_m={row['maximum_derived_centerline_shift_m']:.3f}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,70 @@
|
||||
# Linux 109 — tunnel floor and shell
|
||||
|
||||
Baseline: `assembler/world` HEAD `95cf8de10723f2dffe7b3dfc116ecf350c7114ae`. Candidate source stamp: `9457b9bed7d57a4a387828961f9dd53632ec602eb5ea8f247b087be6ca5cc312` (evaluator v217).
|
||||
|
||||
## Shape
|
||||
|
||||
The per-tile swept tube now uses a horizontal flat floor, a 0.50 m smooth floor-to-wall corner, and a 0.75 m shell. Body floor depth is fixed per passage at the minimum interior radius minus 0.23 m, capped at the local radius. Five-metre cubic blends ramp both mouths into their ground; the lower mouth follows the matching authored native-cave floor where one exists. The route centreline shifts through radius tapers to keep that floor walkable. A small wall relief remains for naturalness.
|
||||
|
||||
The passage interiors are unioned first and always win over every passage shell, including at coiled crossings. The shell writes solid only where the host field is air; rock remains unchanged. There is no rock classification. Open-air shell and interior samples pass in the owner audit.
|
||||
|
||||
## Removed and retained
|
||||
|
||||
Removed route support restoration, deferred support after later bores, support columns, mouth-support restoration, and their floor-specific capsule checks and profiling records. Replaced the old open-air/column audit gates with the shell audit and removed the no-long-bridge and rock-column gates, which measured the deleted support mechanism. Walkability, capsule, headroom, grade, mouth, naturalness, and ripple gates remain. Floor-ripple probes fully masked by another passage's interior air are skipped only when the overlap is explicit; capsule walkability still has to pass.
|
||||
|
||||
Kept the native Lattice field's explicitly authored below-floor substrate because its spine needs it for walkability; this is part of that native field, not route support restoration. The legacy landmark demo retains its compatibility floor-cap path, which is separate from the resolved route sweep. Native Caves and Lattice fast checks pass.
|
||||
|
||||
## Performance
|
||||
|
||||
Five interleaved L80 pairs per cell, after one warmup for each binary; seconds are median `LOD0–2 near-ready / whole-view-ready`. Baseline binary is from `assembler/world` HEAD. Tile counts matched within each cell.
|
||||
|
||||
| Strate | W8 near | W8 whole | W12 near | W12 whole |
|
||||
|---|---:|---:|---:|---:|
|
||||
| SW | 0.680 → 0.737 (+8.3%) | 1.686 → 1.674 (−0.7%) | 0.700 → 0.761 (+8.8%) | 1.643 → 1.644 (+0.1%) |
|
||||
| FI | 1.455 → 0.911 (−37.4%) | 2.419 → 1.772 (−26.8%) | 1.394 → 0.863 (−38.0%) | 2.288 → 1.664 (−27.3%) |
|
||||
| TN | 1.423 → 0.956 (−32.8%) | 2.310 → 1.778 (−23.0%) | 1.294 → 0.895 (−30.9%) | 2.132 → 1.661 (−22.1%) |
|
||||
| VS | 1.008 → 0.789 (−21.8%) | 1.917 → 1.639 (−14.5%) | 0.854 → 0.738 (−13.5%) | 1.716 → 1.541 (−10.2%) |
|
||||
|
||||
There is no broad regression. SW near-ready is about 0.06 s slower; its whole-view time is flat. **Measured:** SW LOD0–2 skirt triangles rise from 133,894 to 196,602, and summed skirt work rises from 455 to 743 ms at W8 and 650 to 1,044 ms at W12. **Inference:** the added open-air shell surface and skirts explain the small SW near-ready cost. FI, TN, and VS improve at both readiness marks.
|
||||
|
||||
## L77 phase census
|
||||
|
||||
Two samples per case. Entries are `share / estimated worker-s`; estimates use weighted L77 samples times `WorldAssemblerLODWorker` CPU, so they are not direct phase timers.
|
||||
|
||||
| Case | Base passage | Base support | Candidate passage | Candidate support |
|
||||
|---|---:|---:|---:|---:|
|
||||
| FI near | 44.62% / 4.108 | 14.84% / 1.366 | 22.90% / 1.133 | 0% / 0 |
|
||||
| FI whole | 29.31% / 4.874 | 8.08% / 1.344 | 10.59% / 1.206 | 0% / 0 |
|
||||
| TN near | 38.57% / 3.318 | 14.74% / 1.267 | 19.43% / 1.019 | 0% / 0 |
|
||||
| TN whole | 24.48% / 3.824 | 7.59% / 1.186 | 9.25% / 1.074 | 0% / 0 |
|
||||
|
||||
The sampled support share disappears. Baseline estimates were 1.19–1.37 worker-s across these cases, below the 1.6–1.9 s expectation.
|
||||
|
||||
## Audits and determinism
|
||||
|
||||
The full-view all-LOD audit passes: 21,425/21,425 canonical passage centreline samples are open. All three interfaces pass capsule and support checks with zero blocked or unsupported samples, zero grade failures, and both mouths bound (2/2). Capsule samples: 266,175 (0–1), 15,120 (1–2), 64,764 (2–3). The shell audit sampled 89,365 floor points, 8,423 wall points, and 10,527 interior points: zero floor, wall, or interior violations; 22,755 open-air shell samples. Naturalness, mouth profile, and ripple summaries pass 3/3, with negative controls detected. Derived floor grade peaks at 14.49°; fast native Caves headroom is 0.30 m and native Lattice headroom is 2.20 m.
|
||||
|
||||
Fast tier: PASS, 259 PASS checks plus 5 diagnostics across 80 runs. The Clang/GCC hash matrix matches at W1/W8/W12 for the owner four-strata layout and both two-strata canonical recipes. The v217 canonical pins were written for Surface World style 1 and Floating Islands style 2.
|
||||
|
||||
Because Core sources are Unreal-compiled, the Linux VFHostEditor build passed with `-NoUBA -MaxParallelActions=4` (27 actions, 67.4 s). Three FI and three SW `after` arrival traces also passed, each settling its full requested LOD0–2 set. Unreal processes had no `prlimit`.
|
||||
|
||||
## Change metrics
|
||||
|
||||
LOD0–2 camera mesh windows; “moved” means a shared global lattice crossing displaced by more than 0.01 cm. Coverage is the paired edge-classifier coverage.
|
||||
|
||||
| Strate | Added | Removed | Moved | Coverage |
|
||||
|---|---:|---:|---:|---:|
|
||||
| SW | 78,201 | 14,530 | 2,758 | 99.75% |
|
||||
| FI | 37,810 | 42,398 | 78,215 | 98.31% |
|
||||
| TN | 44,694 | 48,925 | 48,816 | 98.29% |
|
||||
| VS | 7,055 | 7,096 | 8,790 | 97.38% |
|
||||
|
||||
## Captures
|
||||
|
||||
84 PNGs: before and after, each with both mouths and 25/50/75% stations for all three passages. Each of the nine open-air sections has three views in each set. The [capture index](captures/INDEX.md) lists every image and flags every **OPEN AIR** view. All 84 source meshes and rendered views contain geometry; minimum visible triangles are 55,420 before and 55,484 after. Six before and seven after images met the pixel floor but were flagged low contrast for review.
|
||||
|
||||
Open-air sections (route distance in metres): `0–1`: 7.226–10.217, 13.456–17.443, 23.174–24.918, 1036.913–1489.321; `1–2`: 101.820–104.499; `2–3`: 7.677–34.747, 368.909–376.660, 393.828–396.510, 444.932–447.654.
|
||||
|
||||
## Failed runs
|
||||
|
||||
Early fast runs found a 0.79 m lower-mouth mismatch at native Caves and loss of the native Lattice route substrate; the mouth now matches the authored cave edge floor, and the explicitly authored Lattice floor field is retained. A sentinel check also initially treated the “no native floor” value as support; this was fixed. The first Unreal build stopped before compilation because UnrealBuildTool could not rotate its log under read-only `~/.config`; redirecting `XDG_CONFIG_HOME` to `/tmp` fixed it. Final audits, timings, hash runs, crossings, captures, build, and six traces passed. No run exceeded 30 minutes.
|
||||
@@ -0,0 +1,511 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture before/after passage mouths, route stations, and exposed shell sections."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from array import array
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
RESEARCH = Path(__file__).resolve().parent
|
||||
CAPTURES = RESEARCH / "captures"
|
||||
SAVED = ROOT / "Saved/Linux/l109/captures"
|
||||
DATA = ROOT / "Standalone/data/owner_world.json"
|
||||
BINARIES = {
|
||||
"before": ROOT / "Saved/Linux/l109/bin-baseline/VoxelForgeCoreHeadless-l109-baseline",
|
||||
"after": ROOT / "Saved/Linux/l109/bin-final/VoxelForgeCoreHeadless-l109-clang",
|
||||
}
|
||||
LAYOUTS = {
|
||||
"before": ROOT / "Saved/Linux/l109/audit-layout-baseline.json",
|
||||
"after": ROOT / "Saved/Linux/l109/audit-layout-final-stamp.json",
|
||||
}
|
||||
AUDIT_LOG = ROOT / "Saved/Linux/l109/audit-owner4-final-stamp.log"
|
||||
RENDERERS = {
|
||||
"before": ROOT / "Saved/Linux/l109/bin-baseline/VoxelForgeOffscreenCapture-l109-baseline",
|
||||
"after": ROOT / "Saved/Linux/l109/bin-final/VoxelForgeOffscreenCapture-l109-clang",
|
||||
}
|
||||
SECTION = re.compile(
|
||||
r"^\[PassageOpenAirSection\] id=(\S+) section=(\d+) start_m=([\d.]+) "
|
||||
r"end_m=([\d.]+) samples=(\d+) midpoint_xyz_m=\(([^)]*)\) "
|
||||
r"midpoint_radius_m=([\d.]+)", re.M)
|
||||
DRAWN = re.compile(r"drawn_triangles=(\d+)")
|
||||
QUALITY_METRICS = re.compile(r"stddev_luma=([\d.]+).*coverage_luma_gt12=([\d.]+)")
|
||||
|
||||
|
||||
def vector(text: str) -> tuple[float, float, float]:
|
||||
return tuple(float(value) for value in text.split(","))
|
||||
|
||||
|
||||
def add(a, b):
|
||||
return tuple(x + y for x, y in zip(a, b))
|
||||
|
||||
|
||||
def sub(a, b):
|
||||
return tuple(x - y for x, y in zip(a, b))
|
||||
|
||||
|
||||
def mul(a, scale):
|
||||
return tuple(x * scale for x in a)
|
||||
|
||||
|
||||
def norm(a):
|
||||
length = math.sqrt(sum(x * x for x in a))
|
||||
if length <= 1.0e-9:
|
||||
return (0.0, 1.0, 0.0)
|
||||
return tuple(value / length for value in a)
|
||||
|
||||
|
||||
def angles(camera, target):
|
||||
delta = sub(target, camera)
|
||||
horizontal = math.hypot(delta[0], delta[1])
|
||||
return (math.degrees(math.atan2(delta[0], delta[1])),
|
||||
math.degrees(math.atan2(delta[2], horizontal)))
|
||||
|
||||
|
||||
def fmt(values) -> str:
|
||||
return ",".join(f"{value:.6f}" for value in values)
|
||||
|
||||
|
||||
def mesh_triangle_count(mesh: Path) -> int:
|
||||
"""Read only the VFMSH headers and count indexed triangles."""
|
||||
with mesh.open("rb") as stream:
|
||||
magic = stream.read(8)
|
||||
materials = magic == b"VFMSH002"
|
||||
if magic not in (b"VFMSH001", b"VFMSH002"):
|
||||
raise RuntimeError(f"unsupported mesh scene header: {mesh}")
|
||||
version, _voxel_um, tile_count = struct.unpack("<3I", stream.read(12))
|
||||
if version != (2 if materials else 1):
|
||||
raise RuntimeError(f"mesh scene version mismatch: {mesh}")
|
||||
stream.seek(40, 1)
|
||||
triangles = 0
|
||||
for _ in range(tile_count):
|
||||
stream.seek(16, 1)
|
||||
surface_count, _skirts, vertex_count, index_count, mask_count = struct.unpack(
|
||||
"<5I", stream.read(20))
|
||||
triangles += index_count // 3
|
||||
stream.seek(vertex_count * 24 + (10 * vertex_count if materials else 0)
|
||||
+ index_count * 4 + mask_count, 1)
|
||||
if stream.read(1):
|
||||
raise RuntimeError(f"trailing data in mesh scene: {mesh}")
|
||||
return triangles
|
||||
|
||||
|
||||
def dot(a, b):
|
||||
return sum(x * y for x, y in zip(a, b))
|
||||
|
||||
|
||||
def nearest_surface_anchor(mesh: Path, point, margin_m=200.0) -> dict:
|
||||
"""Return a nearby VFMSH surface vertex and its normal."""
|
||||
best_distance_sq = float("inf")
|
||||
best_position = best_normal = None
|
||||
with mesh.open("rb") as stream:
|
||||
magic = stream.read(8)
|
||||
materials = magic == b"VFMSH002"
|
||||
if magic not in (b"VFMSH001", b"VFMSH002"):
|
||||
raise RuntimeError(f"unsupported mesh scene header: {mesh}")
|
||||
version, voxel_um, tile_count = struct.unpack("<3I", stream.read(12))
|
||||
if version != (2 if materials else 1):
|
||||
raise RuntimeError(f"mesh scene version mismatch: {mesh}")
|
||||
stream.seek(40, 1)
|
||||
for _ in range(tile_count):
|
||||
key = struct.unpack("<4i", stream.read(16))
|
||||
surface_count, _skirts, vertex_count, index_count, mask_count = struct.unpack(
|
||||
"<5I", stream.read(20))
|
||||
extent = (32 << key[3]) * voxel_um / 1_000_000.0
|
||||
origin = (key[0] * extent, key[1] * extent, key[2] * extent)
|
||||
if any(point[axis] < origin[axis] - margin_m or
|
||||
point[axis] > origin[axis] + extent + margin_m for axis in range(3)):
|
||||
stream.seek(vertex_count * 24 + (10 * vertex_count if materials else 0)
|
||||
+ index_count * 4 + mask_count, 1)
|
||||
continue
|
||||
positions = array("f")
|
||||
positions.frombytes(stream.read(vertex_count * 12))
|
||||
normals = array("f")
|
||||
normals.frombytes(stream.read(vertex_count * 12))
|
||||
if materials:
|
||||
stream.seek(vertex_count * 10, 1)
|
||||
stream.seek(index_count * 4 + mask_count, 1)
|
||||
if struct.pack("=I", 1) != struct.pack("<I", 1):
|
||||
positions.byteswap()
|
||||
normals.byteswap()
|
||||
for index in range(min(surface_count, vertex_count)):
|
||||
offset = index * 3
|
||||
position = tuple(float(positions[offset + axis]) for axis in range(3))
|
||||
distance_sq = dot(sub(position, point), sub(position, point))
|
||||
if distance_sq >= best_distance_sq:
|
||||
continue
|
||||
raw_normal = tuple(float(normals[offset + axis]) for axis in range(3))
|
||||
if dot(raw_normal, raw_normal) < 1.0e-10:
|
||||
continue
|
||||
best_distance_sq = distance_sq
|
||||
best_position = position
|
||||
best_normal = norm(raw_normal)
|
||||
if best_position is None:
|
||||
raise RuntimeError(f"no nearby surface vertices in mesh scene: {mesh}")
|
||||
return {"position_m": best_position, "normal": best_normal,
|
||||
"distance_m": math.sqrt(best_distance_sq)}
|
||||
|
||||
|
||||
def sanitize(value: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_-]+", "-", value).strip("-").replace("/", "-")
|
||||
|
||||
|
||||
def route_for(layout: dict, passage: dict) -> list[tuple[tuple[float, float, float], float]]:
|
||||
scale = layout["core_voxel_size_um"] / 256.0 / 1_000_000.0
|
||||
controls = [(tuple(value * scale for value in row["position_q"]),
|
||||
row["radius_q"] * scale) for row in passage["control_points"]]
|
||||
lower_q = passage["lower_mouth_q"]
|
||||
player_center_above_floor_q = round(
|
||||
1.13 * 256.0 * 1_000_000.0 / layout["core_voxel_size_um"])
|
||||
lower_index = next((index for index, row in enumerate(passage["control_points"])
|
||||
if row["position_q"][0] == lower_q[0]
|
||||
and row["position_q"][1] == lower_q[1]
|
||||
and row["position_q"][2] - row["radius_q"]
|
||||
+ player_center_above_floor_q == lower_q[2]), None)
|
||||
if lower_index is None:
|
||||
raise RuntimeError(f"{passage['stable_id']}: lower mouth control is unbound")
|
||||
result = controls[:lower_index + 1]
|
||||
if len(result) < 2:
|
||||
raise RuntimeError(f"{passage['stable_id']}: incomplete route controls")
|
||||
return result
|
||||
|
||||
|
||||
def route_lengths(route):
|
||||
lengths = [math.dist(a[0], b[0]) for a, b in zip(route, route[1:])]
|
||||
prefix = [0.0]
|
||||
for length in lengths:
|
||||
prefix.append(prefix[-1] + length)
|
||||
return lengths, prefix
|
||||
|
||||
|
||||
def at_distance(route, lengths, prefix, distance):
|
||||
distance = max(0.0, min(distance, prefix[-1]))
|
||||
for index, length in enumerate(lengths):
|
||||
if distance <= prefix[index + 1] or index + 1 == len(lengths):
|
||||
t = 0.0 if length <= 1.0e-9 else (distance - prefix[index]) / length
|
||||
a, b = route[index], route[index + 1]
|
||||
return (tuple(a[0][axis] + (b[0][axis] - a[0][axis]) * t
|
||||
for axis in range(3)),
|
||||
a[1] + (b[1] - a[1]) * t)
|
||||
return route[-1]
|
||||
|
||||
|
||||
def tangent_at(route, lengths, prefix, distance):
|
||||
before = at_distance(route, lengths, prefix, max(0.0, distance - 0.25))[0]
|
||||
after = at_distance(route, lengths, prefix, min(prefix[-1], distance + 0.25))[0]
|
||||
return norm(sub(after, before))
|
||||
|
||||
|
||||
def derived_route(route, passage, layout):
|
||||
"""Apply L109's fixed-depth floor offset to raw layout controls."""
|
||||
lengths, prefix = route_lengths(route)
|
||||
total = prefix[-1]
|
||||
body_radii = [radius for index, (_, radius) in enumerate(route)
|
||||
if prefix[index] >= 10.0 and total - prefix[index] >= 10.0]
|
||||
body_depth = max(0.5, min(body_radii or [radius for _, radius in route]) - 0.23)
|
||||
scale = layout["core_voxel_size_um"] / 256.0 / 1_000_000.0
|
||||
upper_z = passage["upper_mouth_q"][2] * scale
|
||||
lower_z = passage["lower_mouth_q"][2] * scale
|
||||
upper_target = max(0.0, route[0][0][2] - upper_z + 1.13)
|
||||
lower_target = max(0.0, route[-1][0][2] - lower_z + 1.13)
|
||||
transformed = []
|
||||
for index, (point, radius) in enumerate(route):
|
||||
local_radius_depth = max(0.0, radius - 0.23)
|
||||
mouth_distance = max(0.0, min(prefix[index], total - prefix[index]))
|
||||
u = max(0.0, min(1.0, mouth_distance / 5.0))
|
||||
ramp = u * u * (3.0 - 2.0 * u)
|
||||
target = upper_target if prefix[index] <= total * 0.5 else lower_target
|
||||
mouth_depth = max(0.0, min(radius, target))
|
||||
route_depth = mouth_depth + (local_radius_depth - mouth_depth) * ramp
|
||||
tube_depth = min(body_depth, local_radius_depth)
|
||||
transformed.append(((point[0], point[1], point[2] - route_depth + tube_depth),
|
||||
radius))
|
||||
return transformed, body_depth
|
||||
|
||||
|
||||
def make_views(layout: dict, audit_sections: list[dict], *, use_derived_shape: bool,
|
||||
include_open_air: bool = True) -> list[dict]:
|
||||
views = []
|
||||
passages = sorted(layout["passages"], key=lambda item: item["stable_id"])
|
||||
for passage in passages:
|
||||
raw_route = route_for(layout, passage)
|
||||
if use_derived_shape:
|
||||
route, body_depth = derived_route(raw_route, passage, layout)
|
||||
else:
|
||||
route = raw_route
|
||||
body_depth = max(point[1] for point in route)
|
||||
def floor_depth(radius: float) -> float:
|
||||
if not use_derived_shape:
|
||||
return radius
|
||||
return min(body_depth, max(0.0, radius - 0.23))
|
||||
lengths, prefix = route_lengths(route)
|
||||
total = prefix[-1]
|
||||
passage_id = passage["stable_id"]
|
||||
safe_id = sanitize(passage_id)
|
||||
|
||||
for end_name, distance, outward_sign in (
|
||||
("upper-mouth", 0.0, -1.0), ("lower-mouth", total, 1.0)):
|
||||
center, radius = at_distance(route, lengths, prefix, distance)
|
||||
tangent = tangent_at(route, lengths, prefix, distance)
|
||||
outward = mul(tangent, outward_sign)
|
||||
floor_z = center[2] - floor_depth(radius)
|
||||
eye = (center[0] + outward[0] * 4.5,
|
||||
center[1] + outward[1] * 4.5,
|
||||
floor_z + 1.55)
|
||||
if end_name == "lower-mouth":
|
||||
eye = add(eye, add(mul(outward, 5.0), (0.0, 0.0, 2.0)))
|
||||
target = (center[0] - outward[0] * 2.0,
|
||||
center[1] - outward[1] * 2.0,
|
||||
floor_z + 1.45)
|
||||
yaw, pitch = angles(eye, target)
|
||||
views.append({"name": f"{safe_id}-{end_name}", "passage": passage_id,
|
||||
"kind": "mouth", "station": end_name,
|
||||
"camera": eye, "target": target, "yaw": yaw, "pitch": pitch,
|
||||
"focus": center, "center_z": center[2]})
|
||||
|
||||
for fraction in (0.25, 0.50, 0.75):
|
||||
distance = total * fraction
|
||||
center, radius = at_distance(route, lengths, prefix, distance)
|
||||
tangent = tangent_at(route, lengths, prefix, distance)
|
||||
depth = floor_depth(radius)
|
||||
floor_z = center[2] - depth
|
||||
horizontal = norm((-tangent[1], tangent[0], 0.0))
|
||||
eye = (center[0] + horizontal[0] * 0.75,
|
||||
center[1] + horizontal[1] * 0.75, floor_z + 1.60)
|
||||
target_center, target_radius = at_distance(route, lengths, prefix,
|
||||
min(total, distance + 14.0))
|
||||
target_depth = floor_depth(target_radius)
|
||||
target = (target_center[0] + horizontal[0] * 2.35,
|
||||
target_center[1] + horizontal[1] * 2.35,
|
||||
target_center[2] - target_depth + 1.80)
|
||||
yaw, pitch = angles(eye, target)
|
||||
views.append({"name": f"{safe_id}-station-{int(fraction * 100)}",
|
||||
"passage": passage_id, "kind": "station",
|
||||
"station": f"{int(fraction * 100)}%", "camera": eye,
|
||||
"target": target, "yaw": yaw, "pitch": pitch,
|
||||
"focus": center, "center_z": center[2],
|
||||
"route_distance_m": distance,
|
||||
"route_length_m": total,
|
||||
"requested_route_distance_m": distance})
|
||||
|
||||
if not include_open_air:
|
||||
continue
|
||||
for section in (item for item in audit_sections if item["passage"] == passage_id):
|
||||
distance = 0.5 * (section["start_m"] + section["end_m"])
|
||||
center, radius = (section["midpoint"], section["radius_m"])
|
||||
tangent = tangent_at(route, lengths, prefix, distance)
|
||||
side = norm((-tangent[1], tangent[0], 0.0))
|
||||
depth = floor_depth(radius)
|
||||
section_id = section["section"]
|
||||
interior_eye = (center[0], center[1], center[2] - depth + 1.25)
|
||||
interior_target = add(interior_eye, mul(tangent, 7.0))
|
||||
side_eye = add(center, add(mul(side, radius + 5.0), (0.0, 0.0, 1.2)))
|
||||
oblique_eye = add(center, add(mul(side, -(radius + 3.5)), (0.0, 0.0, 4.0)))
|
||||
targets = (add(center, (0.0, 0.0, 0.1)),
|
||||
add(center, mul(tangent, 2.0)), interior_target)
|
||||
for view_index, (eye, target) in enumerate(zip(
|
||||
(side_eye, oblique_eye, interior_eye), targets), start=1):
|
||||
yaw, pitch = angles(eye, target)
|
||||
views.append({"name": f"{safe_id}-openair-{section_id}-view-{view_index}",
|
||||
"passage": passage_id, "kind": "open-air",
|
||||
"station": f"section-{section_id}/view-{view_index}",
|
||||
"camera": eye, "target": target, "yaw": yaw,
|
||||
"pitch": pitch, "focus": center,
|
||||
"center_z": center[2], "open_air_section": section_id,
|
||||
"route_distance_m": distance,
|
||||
"requested_route_distance_m": distance,
|
||||
"route_length_m": total})
|
||||
return views
|
||||
|
||||
|
||||
def run_process(name: str, command: list[str], log: Path, *, allow_failure=False) -> str:
|
||||
wrapped = ["taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--",
|
||||
"timeout", "1740s", *map(str, command)]
|
||||
started = time.monotonic()
|
||||
with log.open("w", encoding="utf-8") as stream:
|
||||
result = subprocess.run(wrapped, cwd=ROOT, stdout=stream,
|
||||
stderr=subprocess.STDOUT, check=False, timeout=1740)
|
||||
if result.returncode and not allow_failure:
|
||||
raise RuntimeError(f"{name}: exit={result.returncode} log={log}")
|
||||
print(f"[L109CaptureProcess] name={name} elapsed_s={time.monotonic()-started:.2f} "
|
||||
f"exit={result.returncode} log={log.relative_to(ROOT)}", flush=True)
|
||||
return log.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def capture(variant: str, view: dict, layout: dict) -> dict:
|
||||
variant_saved = SAVED / variant
|
||||
variant_saved.mkdir(parents=True, exist_ok=True)
|
||||
variant_capture = CAPTURES / variant
|
||||
variant_capture.mkdir(parents=True, exist_ok=True)
|
||||
scene = variant_saved / f"{view['name']}.vfmesh"
|
||||
image = variant_capture / f"{view['name']}.png"
|
||||
candidates = [(view["camera"], view["target"], view["focus"],
|
||||
view.get("route_distance_m"), 0.0, "requested")]
|
||||
if variant == "before" and view["kind"] != "mouth":
|
||||
passage = next(item for item in layout["passages"]
|
||||
if item["stable_id"] == view["passage"])
|
||||
route = route_for(layout, passage)
|
||||
lengths, prefix = route_lengths(route)
|
||||
total = prefix[-1]
|
||||
eye_offset = sub(view["camera"], view["focus"])
|
||||
target_offset = sub(view["target"], view["focus"])
|
||||
requested = view["route_distance_m"]
|
||||
for delta in (5, -5, 10, -10, 20, -20, 35, -35, 50, -50, 75, -75,
|
||||
100, -100, 150, -150, 200, -200):
|
||||
distance = max(0.0, min(total, requested + delta))
|
||||
if abs(distance - requested) < 0.01:
|
||||
continue
|
||||
center, _radius = at_distance(route, lengths, prefix, distance)
|
||||
candidates.append((add(center, eye_offset), add(center, target_offset), center,
|
||||
distance, distance - requested, "route-offset"))
|
||||
|
||||
attempt_errors = []
|
||||
for attempt_index, (probe_camera, probe_target, probe_focus, route_distance,
|
||||
route_adjustment, attempt_kind) in enumerate(candidates):
|
||||
suffix = "" if attempt_index == 0 else f"-attempt-{attempt_index:02d}"
|
||||
headless_log = variant_saved / f"{view['name']}{suffix}-mesh.log"
|
||||
render_log = variant_saved / f"{view['name']}{suffix}-render.log"
|
||||
quality_log = variant_saved / f"{view['name']}{suffix}-quality.log"
|
||||
yaw, pitch = angles(probe_camera, probe_target)
|
||||
camera = (*probe_camera, yaw, pitch)
|
||||
export_command = [
|
||||
str(BINARIES[variant]), str(DATA), "--world-assembler-prototype",
|
||||
"--stream-benchmark", "--stream-pipeline", "--workers=12",
|
||||
"--clip-radius=3", "--max-clip-level=0", "--max-level=0", "--force-generate",
|
||||
"--recipe=surface-world", f"--camera={fmt(camera)}",
|
||||
"--render-distance-chunks=1", f"--mesh-export={scene}",
|
||||
]
|
||||
export_output = run_process(f"{variant}-{view['name']}{suffix}-mesh", export_command,
|
||||
headless_log)
|
||||
if "status=PASS" not in export_output or not scene.is_file():
|
||||
raise RuntimeError(f"{variant}-{view['name']}: missing successful scene export")
|
||||
source_triangles = mesh_triangle_count(scene)
|
||||
if source_triangles <= 0:
|
||||
attempt_errors.append(f"{attempt_kind}@{route_distance}: empty mesh")
|
||||
if variant == "after" or view["kind"] == "mouth":
|
||||
break
|
||||
continue
|
||||
|
||||
actual_camera, actual_target = probe_camera, probe_target
|
||||
actual_focus = probe_focus
|
||||
surface_distance = None
|
||||
if attempt_kind == "route-offset":
|
||||
anchor = nearest_surface_anchor(scene, probe_focus)
|
||||
surface_distance = anchor["distance_m"]
|
||||
actual_camera = add(anchor["position_m"], mul(anchor["normal"], 12.0))
|
||||
actual_target = anchor["position_m"]
|
||||
actual_focus = anchor["position_m"]
|
||||
actual_yaw, actual_pitch = angles(actual_camera, actual_target)
|
||||
render_command = [
|
||||
str(RENDERERS[variant]), str(scene), str(image), "--size=1920x1080",
|
||||
f"--camera-m={fmt(actual_camera)}", f"--yaw-deg={actual_yaw:.6f}",
|
||||
f"--pitch-deg={actual_pitch:.6f}", "--shade=readable", "--headlamp=on",
|
||||
"--height-tint=on", f"--height-center-m={actual_focus[2]:.6f}",
|
||||
"--height-range-m=32",
|
||||
]
|
||||
render_output = run_process(f"{variant}-{view['name']}{suffix}-render", render_command,
|
||||
render_log)
|
||||
drawn = DRAWN.search(render_output)
|
||||
if drawn is None or int(drawn.group(1)) <= 0 or not image.is_file():
|
||||
attempt_errors.append(f"{attempt_kind}@{route_distance}: no drawn geometry")
|
||||
if variant == "after" or view["kind"] == "mouth":
|
||||
break
|
||||
continue
|
||||
quality = run_process(f"{variant}-{view['name']}{suffix}-quality", [
|
||||
"python3", "-B", str(ROOT / "Standalone/Tools/verify/capture_quality.py"),
|
||||
str(image)], quality_log, allow_failure=True)
|
||||
quality_status = "pass"
|
||||
if "status=PASS" not in quality:
|
||||
metrics = QUALITY_METRICS.search(quality)
|
||||
coverage = float(metrics.group(2)) if metrics else 0.0
|
||||
if coverage >= 0.001:
|
||||
quality_status = "low_contrast_geometry_visible"
|
||||
else:
|
||||
attempt_errors.append(f"{attempt_kind}@{route_distance}: no visible pixel coverage")
|
||||
if variant == "after" or view["kind"] == "mouth":
|
||||
break
|
||||
continue
|
||||
|
||||
row = {**view, "variant": variant, "capture": str(image.relative_to(RESEARCH)),
|
||||
"scene": str(scene.relative_to(ROOT)), "drawn_triangles": int(drawn.group(1)),
|
||||
"source_triangles": source_triangles, "verified": True,
|
||||
"pixel_quality_status": quality_status,
|
||||
"route_distance_actual_m": route_distance,
|
||||
"route_station_adjustment_m": route_adjustment,
|
||||
"fallback_kind": attempt_kind if attempt_kind != "requested" else None,
|
||||
"fallback_surface_distance_m": surface_distance,
|
||||
"camera_actual_m": actual_camera, "target_actual_m": actual_target,
|
||||
"logs": [str(headless_log.relative_to(ROOT)),
|
||||
str(render_log.relative_to(ROOT)),
|
||||
str(quality_log.relative_to(ROOT))]}
|
||||
print("[L109Capture] " + json.dumps(row, sort_keys=True), flush=True)
|
||||
return row
|
||||
raise RuntimeError(f"{variant}-{view['name']}: no verified image; "
|
||||
+ "; ".join(attempt_errors))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if any(not path.is_file() for path in (*BINARIES.values(), *RENDERERS.values(),
|
||||
*LAYOUTS.values(), AUDIT_LOG)):
|
||||
raise SystemExit("missing L109 capture binary, layout, or audit log")
|
||||
sections = []
|
||||
for passage, section, start, end, samples, midpoint, radius in SECTION.findall(
|
||||
AUDIT_LOG.read_text(encoding="utf-8", errors="replace")):
|
||||
sections.append({"passage": passage, "section": int(section),
|
||||
"start_m": float(start), "end_m": float(end),
|
||||
"samples": int(samples), "midpoint": vector(midpoint),
|
||||
"radius_m": float(radius)})
|
||||
if not sections:
|
||||
raise SystemExit("no open-air sections found in the final passage audit")
|
||||
all_rows = []
|
||||
for variant in ("before", "after"):
|
||||
layout = json.loads(LAYOUTS[variant].read_text(encoding="utf-8"))
|
||||
include_open_air = True
|
||||
views = make_views(layout, sections, use_derived_shape=variant == "after",
|
||||
include_open_air=include_open_air)
|
||||
expected = 5 * len(layout["passages"])
|
||||
if include_open_air:
|
||||
expected += 3 * len(sections)
|
||||
if len(views) != expected:
|
||||
raise RuntimeError(f"unexpected camera count: {len(views)} != {expected}")
|
||||
print(f"[L109CapturePlan] variant={variant} passage_views={5*len(layout['passages'])} "
|
||||
f"open_air_sections={len(sections) if include_open_air else 0} "
|
||||
f"open_air_views={3*len(sections) if include_open_air else 0} "
|
||||
f"total={len(views)}", flush=True)
|
||||
for view in views:
|
||||
all_rows.append(capture(variant, view, layout))
|
||||
CAPTURES.mkdir(parents=True, exist_ok=True)
|
||||
(CAPTURES / "capture-index.json").write_text(
|
||||
json.dumps({"open_air_sections": sections, "captures": all_rows}, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
lines = ["# L109 passage captures", "",
|
||||
"Each PNG was rendered from its own exported mesh. The renderer reported visible "
|
||||
"triangles, and each decoded image met the pixel coverage floor. "
|
||||
"`low_contrast_geometry_visible` marks images that met this floor below the "
|
||||
"preferred contrast target.", ""]
|
||||
for variant in ("before", "after"):
|
||||
lines.extend([f"## {variant.title()}", ""])
|
||||
for row in all_rows:
|
||||
if row["variant"] != variant:
|
||||
continue
|
||||
flag = " **OPEN AIR**" if row["kind"] == "open-air" else ""
|
||||
quality_note = (f"; {row['pixel_quality_status']}"
|
||||
if row["pixel_quality_status"] != "pass" else "")
|
||||
lines.append(f"- [{row['station']}]({variant}/{Path(row['capture']).name}) — "
|
||||
f"{row['passage']}{flag}; {row['drawn_triangles']} visible triangles"
|
||||
f"{quality_note}")
|
||||
lines.append("")
|
||||
(CAPTURES / "INDEX.md").write_text("\n".join(lines), encoding="utf-8")
|
||||
failures = [row for row in all_rows if not row["verified"]]
|
||||
print(f"[L109CaptureSummary] captures={len(all_rows)} verified={len(all_rows)-len(failures)} "
|
||||
f"failed={len(failures)} open_air_sections={len(sections)}", flush=True)
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare LOD0-2 owner mesh edge crossings before and after L109."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
OUT = ROOT / "Saved/Linux/l109/crossings"
|
||||
DATA = ROOT / "Standalone/data/owner_world.json"
|
||||
BINARIES = {
|
||||
"before": ROOT / "Saved/Linux/l109/bin-baseline/VoxelForgeCoreHeadless-l109-baseline",
|
||||
"after": ROOT / "Saved/Linux/l109/bin-final/VoxelForgeCoreHeadless-l109-clang",
|
||||
}
|
||||
CAMERAS = {
|
||||
"SW": (8.0, 16.0, -372.2626953125),
|
||||
"FI": (25.8369140625, 3.19921875, -737.0),
|
||||
"TN": (19.166015625, -14.2001953125, -753.3740234375),
|
||||
"VS": (-20.0625, -5.068359375, -900.353515625),
|
||||
}
|
||||
|
||||
|
||||
def read_exact(stream, size):
|
||||
data = stream.read(size)
|
||||
if len(data) != size:
|
||||
raise RuntimeError("truncated VFMSH export")
|
||||
return data
|
||||
|
||||
|
||||
def crossing_map(path: Path) -> tuple[dict, dict]:
|
||||
crossings = {}
|
||||
meta = {"tiles": 0, "surface_vertices": 0, "classified": 0}
|
||||
with path.open("rb") as stream:
|
||||
magic = read_exact(stream, 8)
|
||||
materials = magic == b"VFMSH002"
|
||||
if magic not in (b"VFMSH001", b"VFMSH002"):
|
||||
raise RuntimeError(f"invalid mesh export {path}")
|
||||
version, voxel_um, tile_count = struct.unpack("<3I", read_exact(stream, 12))
|
||||
if version != (2 if materials else 1):
|
||||
raise RuntimeError("VFMSH version mismatch")
|
||||
read_exact(stream, 40)
|
||||
voxel_m = voxel_um / 1_000_000.0
|
||||
for _ in range(tile_count):
|
||||
key = struct.unpack("<4i", read_exact(stream, 16))
|
||||
surface_count, _skirts, vertex_count, index_count, mask_count = struct.unpack(
|
||||
"<5I", read_exact(stream, 20))
|
||||
positions = struct.iter_unpack("<3f", read_exact(stream, vertex_count * 12))
|
||||
values = list(positions)
|
||||
read_exact(stream, vertex_count * 12) # normals
|
||||
if materials:
|
||||
read_exact(stream, vertex_count * 10)
|
||||
read_exact(stream, index_count * 4 + mask_count)
|
||||
meta["tiles"] += 1
|
||||
meta["surface_vertices"] += min(surface_count, vertex_count)
|
||||
unit = voxel_m * (1 << key[3])
|
||||
for position in values[:min(surface_count, vertex_count)]:
|
||||
grid = tuple(value / unit for value in position)
|
||||
fractions = tuple(abs(value - round(value)) for value in grid)
|
||||
axis = max(range(3), key=fractions.__getitem__)
|
||||
if fractions[axis] < 1.0e-4:
|
||||
continue
|
||||
fixed = [index for index in range(3) if index != axis]
|
||||
if any(fractions[index] > 0.01 for index in fixed):
|
||||
continue
|
||||
edge = [0, 0, 0]
|
||||
for index in fixed:
|
||||
edge[index] = int(round(grid[index]))
|
||||
edge[axis] = math.floor(grid[axis])
|
||||
identity = (key[3], axis, *edge)
|
||||
crossings.setdefault(identity, tuple(float(v) for v in position))
|
||||
meta["classified"] += 1
|
||||
if stream.read(1):
|
||||
raise RuntimeError("trailing data in mesh export")
|
||||
meta["classified_pct"] = (100.0 * meta["classified"] / meta["surface_vertices"]
|
||||
if meta["surface_vertices"] else 0.0)
|
||||
return crossings, meta
|
||||
|
||||
|
||||
def export(strate: str, variant: str) -> Path:
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
mesh = OUT / f"{strate.lower()}-{variant}.vfmesh"
|
||||
log = OUT / f"{strate.lower()}-{variant}.log"
|
||||
camera = ",".join(f"{v:.12f}" for v in CAMERAS[strate]) + ",0,0"
|
||||
command = [
|
||||
"taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--",
|
||||
"timeout", "1740s", str(BINARIES[variant]), str(DATA),
|
||||
"--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline",
|
||||
"--workers=8", "--clip-radius=3", "--max-clip-level=2", "--max-level=2",
|
||||
"--force-generate", "--recipe=surface-world", f"--camera={camera}",
|
||||
"--render-distance-chunks=0",
|
||||
f"--mesh-export={mesh}",
|
||||
]
|
||||
started = time.monotonic()
|
||||
with log.open("w", encoding="utf-8") as stream:
|
||||
result = subprocess.run(command, cwd=ROOT, stdout=stream,
|
||||
stderr=subprocess.STDOUT, timeout=1740, check=False)
|
||||
output = log.read_text(encoding="utf-8", errors="replace")
|
||||
if result.returncode or not mesh.is_file() or "status=PASS" not in output:
|
||||
raise RuntimeError(f"mesh export failed: {strate}/{variant} exit={result.returncode} {log}")
|
||||
return mesh
|
||||
|
||||
|
||||
def main() -> int:
|
||||
summaries = []
|
||||
for strate in CAMERAS:
|
||||
paths = {variant: export(strate, variant) for variant in ("before", "after")}
|
||||
maps = {variant: crossing_map(path) for variant, path in paths.items()}
|
||||
old, old_meta = maps["before"]
|
||||
new, new_meta = maps["after"]
|
||||
old_keys, new_keys = set(old), set(new)
|
||||
shared = old_keys & new_keys
|
||||
moved_cm = [100.0 * math.dist(old[key], new[key]) for key in shared
|
||||
if math.dist(old[key], new[key]) > 0.0001]
|
||||
row = {
|
||||
"strate": strate, "scope": "camera LOD0-2 mesh window; global lattice edge IDs",
|
||||
"added_crossings": len(new_keys - old_keys),
|
||||
"removed_crossings": len(old_keys - new_keys),
|
||||
"shared_crossings": len(shared), "moved_crossings": len(moved_cm),
|
||||
"mean_moved_cm": statistics.mean(moved_cm) if moved_cm else 0.0,
|
||||
"max_moved_cm": max(moved_cm, default=0.0),
|
||||
"before": old_meta, "after": new_meta,
|
||||
"classified_pair_coverage_pct": min(old_meta["classified_pct"],
|
||||
new_meta["classified_pct"]),
|
||||
"exports": {key: str(value.relative_to(ROOT)) for key, value in paths.items()},
|
||||
}
|
||||
summaries.append(row)
|
||||
print("[L109CrossingDelta] " + json.dumps(row, sort_keys=True), flush=True)
|
||||
(OUT / "summary.json").write_text(
|
||||
json.dumps({"measurement": "per-vertex marching-cubes edge classifier; compare shared global lattice edges; positions >0.01cm count as moved",
|
||||
"strata": summaries}, indent=2) + "\n", encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check Clang/GCC hash identity at W1/W8/W12 and repin v217 owner hashes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
OUT = ROOT / "Saved/Linux/l109/hashes"
|
||||
DATASETS = {
|
||||
"owner4": ROOT / "Standalone/data/owner_world.json",
|
||||
"owner2": ROOT / "Standalone/data/regression/owner_world_2strate_l61.json",
|
||||
}
|
||||
BINARIES = {
|
||||
"clang": ROOT / "Saved/Linux/l109/bin-final/VoxelForgeCoreHeadless-l109-clang",
|
||||
"gcc": ROOT / "Saved/Linux/l109/bin-final/VoxelForgeCoreHeadless-l109-gcc",
|
||||
}
|
||||
HASH = re.compile(r"^\[Pipeline(?:FullDetailHash|WholeViewHash|LODHash|ExactQueryHash)\].*$", re.M)
|
||||
STAMP = re.compile(r"^\[VoxelForgeSourceStamp\] sha256=(\S+)$", re.M)
|
||||
|
||||
|
||||
def run(dataset: str, recipe: str, workers: int, compiler: str) -> dict:
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
label = f"{dataset}-{recipe}-w{workers}-{compiler}"
|
||||
log = OUT / f"{label}.log"
|
||||
layout = OUT / f"{label}.layout.json"
|
||||
command = [
|
||||
"taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--",
|
||||
"timeout", "1740s", str(BINARIES[compiler]), str(DATASETS[dataset]),
|
||||
f"--workers={workers}", "--world-assembler-prototype", "--stream-benchmark",
|
||||
"--stream-pipeline", "--force-generate", "--clip-radius=3",
|
||||
"--max-clip-level=8", "--max-level=8", f"--recipe={recipe}",
|
||||
"--stream-hash", f"--resolved-layout-output={layout}",
|
||||
]
|
||||
started = time.monotonic()
|
||||
with log.open("w", encoding="utf-8") as stream:
|
||||
result = subprocess.run(command, cwd=ROOT, stdout=stream,
|
||||
stderr=subprocess.STDOUT, check=False, timeout=1740)
|
||||
output = log.read_text(encoding="utf-8", errors="replace")
|
||||
hashes = HASH.findall(output)
|
||||
if result.returncode or len(hashes) != 12:
|
||||
raise SystemExit(f"hash run failed or incomplete: {label}; exit={result.returncode} "
|
||||
f"hashes={len(hashes)} log={log}")
|
||||
row = {"dataset": dataset, "recipe": recipe, "workers": workers,
|
||||
"compiler": compiler, "exit_code": result.returncode,
|
||||
"hashes": hashes, "source_stamp": (STAMP.search(output).group(1)
|
||||
if STAMP.search(output) else None),
|
||||
"elapsed_s": round(time.monotonic() - started, 3),
|
||||
"log": str(log.relative_to(ROOT))}
|
||||
print("[L109HashRun] " + json.dumps(row, sort_keys=True), flush=True)
|
||||
return row
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if any(not path.is_file() for path in (*DATASETS.values(), *BINARIES.values())):
|
||||
raise SystemExit("missing L109 hash input")
|
||||
rows = []
|
||||
recipes = {"owner4": ("surface-world",),
|
||||
"owner2": ("surface-world", "floating-islands")}
|
||||
for dataset, dataset_recipes in recipes.items():
|
||||
for recipe in dataset_recipes:
|
||||
for workers in (1, 8, 12):
|
||||
for compiler in ("clang", "gcc"):
|
||||
rows.append(run(dataset, recipe, workers, compiler))
|
||||
(OUT / "runs-partial.json").write_text(
|
||||
json.dumps(rows, indent=2) + "\n", encoding="utf-8")
|
||||
groups = {}
|
||||
for dataset, dataset_recipes in recipes.items():
|
||||
for recipe in dataset_recipes:
|
||||
key = f"{dataset}/{recipe}"
|
||||
group = [row for row in rows if row["dataset"] == dataset
|
||||
and row["recipe"] == recipe]
|
||||
reference = group[0]["hashes"]
|
||||
groups[key] = {"all_worker_compiler_hashes_match": all(
|
||||
row["hashes"] == reference for row in group),
|
||||
"source_stamps": sorted({row["source_stamp"] for row in group}),
|
||||
"runs": [{"workers": row["workers"], "compiler": row["compiler"],
|
||||
"log": row["log"]} for row in group]}
|
||||
if not groups[key]["all_worker_compiler_hashes_match"]:
|
||||
raise SystemExit(f"determinism mismatch: {key}")
|
||||
if dataset == "owner2":
|
||||
canonical = next(row for row in group if row["workers"] == 12
|
||||
and row["compiler"] == "clang")
|
||||
recipe_name = "floating-islands" if recipe == "floating-islands" else "surface-world"
|
||||
style = 1 if recipe_name == "surface-world" else 2
|
||||
baseline = (ROOT / "Standalone/Tools/verify/baselines/owner-hash/canonical"
|
||||
/ f"owner-hash-{recipe_name}-style{style}-v217.baseline")
|
||||
baseline.write_text("\n".join(canonical["hashes"]) + "\n",
|
||||
encoding="utf-8")
|
||||
summary = {"runs": rows, "groups": groups,
|
||||
"owner4_all_four_strata": "owner4/surface-world",
|
||||
"owner2_canonical_pins": ["owner2/surface-world",
|
||||
"owner2/floating-islands"]}
|
||||
(OUT / "summary.json").write_text(json.dumps(summary, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Estimate L77 passage/support worker shares before and after L109."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import statistics
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
OUT = ROOT / "Saved/Linux/l109/l77-phase-census"
|
||||
DATA = ROOT / "Standalone/data/owner_world.json"
|
||||
BINARIES = {
|
||||
"base": ROOT / "Saved/Linux/l109/bin-baseline-profile/VoxelForgeCoreHeadless-l109-base-profile",
|
||||
"candidate": ROOT / "Saved/Linux/l109/bin-final-profile/VoxelForgeCoreHeadless-l109-candidate-profile",
|
||||
}
|
||||
CAMERAS = {
|
||||
"FI": (25.8369140625, 3.19921875, -737.0),
|
||||
"TN": (19.166015625, -14.2001953125, -753.3740234375),
|
||||
}
|
||||
WORKER = re.compile(r"^\[WorldAssemblerLODWorker\] (.*)$", re.M)
|
||||
SAMPLE = re.compile(r"^\[L77Sample\] (.*)$", re.M)
|
||||
SAMPLER = re.compile(r"^\[L77Sampler\] action=stop (.*)$", re.M)
|
||||
FIELDS = re.compile(r"([a-zA-Z0-9_]+)=([^ ]+)")
|
||||
|
||||
|
||||
def run(stratum: str, variant: str, replicate: int) -> dict:
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
label = f"{stratum.lower()}-{variant}-r{replicate}"
|
||||
log = OUT / f"{label}.log"
|
||||
camera = ",".join(f"{value:.12f}" for value in CAMERAS[stratum]) + ",0,0"
|
||||
command = [
|
||||
"taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--",
|
||||
"timeout", "1740s", str(BINARIES[variant]), str(DATA),
|
||||
"--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline",
|
||||
"--workers=8", "--clip-radius=3", "--max-clip-level=8", "--max-level=8",
|
||||
f"--camera={camera}", "--profile-lod-work", "--stream-scheduler-policy=3",
|
||||
"--linux77-profile",
|
||||
]
|
||||
started = time.monotonic()
|
||||
with log.open("w", encoding="utf-8") as stream:
|
||||
result = subprocess.run(command, cwd=ROOT, stdout=stream,
|
||||
stderr=subprocess.STDOUT, check=False, timeout=1740)
|
||||
content = log.read_text(encoding="utf-8", errors="replace")
|
||||
sampler_match = SAMPLER.search(content)
|
||||
workers = [dict(FIELDS.findall(match.group(1))) for match in WORKER.finditer(content)]
|
||||
samples = [dict(FIELDS.findall(match.group(1))) for match in SAMPLE.finditer(content)]
|
||||
if result.returncode or sampler_match is None or len(workers) < 9 or not samples:
|
||||
raise SystemExit(f"phase census failed or markers missing: {log}; exit={result.returncode}")
|
||||
sampler = dict(FIELDS.findall(sampler_match.group(1)))
|
||||
if sampler.get("status") != "PASS":
|
||||
raise SystemExit(f"L77 sampler failed: {log}: {sampler}")
|
||||
lod_seconds = {int(row["lod"]): float(row["worker_sum_s"]) for row in workers}
|
||||
|
||||
def estimate(scope: str) -> dict:
|
||||
lods = set(range(3)) if scope == "lod0-2" else set(range(9))
|
||||
eligible = [row for row in samples if int(row["lod"]) in lods]
|
||||
weights = {phase: sum(int(row.get("weight", "1")) for row in eligible
|
||||
if row.get("phase") == phase)
|
||||
for phase in ("passages", "support")}
|
||||
total_weight = sum(int(row.get("weight", "1")) for row in eligible)
|
||||
cpu_seconds = sum(value for lod, value in lod_seconds.items() if lod in lods)
|
||||
result_row = {"worker_cpu_s": round(cpu_seconds, 4),
|
||||
"weighted_samples": total_weight}
|
||||
for phase, weight in weights.items():
|
||||
share = weight / total_weight if total_weight else 0.0
|
||||
result_row[f"{phase}_share_pct"] = round(share * 100.0, 3)
|
||||
result_row[f"{phase}_worker_s_estimate"] = round(cpu_seconds * share, 4)
|
||||
return result_row
|
||||
|
||||
row = {
|
||||
"stratum": stratum, "variant": variant, "replicate": replicate,
|
||||
"exit_code": result.returncode,
|
||||
"sampler": sampler,
|
||||
"lod0_2": estimate("lod0-2"), "whole_view": estimate("whole"),
|
||||
"log": str(log.relative_to(ROOT)),
|
||||
"runner_wall_s": round(time.monotonic() - started, 3),
|
||||
}
|
||||
print("[L109PhaseCensusRun] " + json.dumps(row, sort_keys=True), flush=True)
|
||||
return row
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if any(not path.is_file() for path in BINARIES.values()):
|
||||
raise SystemExit("missing L77 profile binary")
|
||||
rows = [run(stratum, variant, replicate)
|
||||
for stratum in ("FI", "TN")
|
||||
for replicate in (1, 2)
|
||||
for variant in ("base", "candidate")]
|
||||
summary = []
|
||||
for stratum in ("FI", "TN"):
|
||||
for variant in ("base", "candidate"):
|
||||
entry = {"stratum": stratum, "variant": variant, "replicates": 2}
|
||||
group = [row for row in rows if row["stratum"] == stratum
|
||||
and row["variant"] == variant]
|
||||
for scope in ("lod0_2", "whole_view"):
|
||||
for key in ("worker_cpu_s", "passages_share_pct", "passages_worker_s_estimate",
|
||||
"support_share_pct", "support_worker_s_estimate"):
|
||||
values = [row[scope][key] for row in group]
|
||||
entry[f"{scope}_{key}_runs"] = values
|
||||
entry[f"{scope}_{key}_median"] = statistics.median(values)
|
||||
summary.append(entry)
|
||||
(OUT / "summary.json").write_text(
|
||||
json.dumps({"runs": rows, "summary": summary}, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Five interleaved L80 readiness pairs for L109, at W8 and W12."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import statistics
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
OUT = ROOT / "Saved/Linux/l109/timing"
|
||||
DATA = ROOT / "Standalone/data/owner_world.json"
|
||||
BINARIES = {
|
||||
"base": ROOT / "Saved/Linux/l109/bin-baseline/VoxelForgeCoreHeadless-l109-baseline",
|
||||
"candidate": ROOT / "Saved/Linux/l109/bin-final/VoxelForgeCoreHeadless-l109-clang",
|
||||
}
|
||||
CAMERAS = {
|
||||
0: (8.0, 16.0, -372.2626953125),
|
||||
1: (25.8369140625, 3.19921875, -737.0),
|
||||
2: (19.166015625, -14.2001953125, -753.3740234375),
|
||||
3: (-20.0625, -5.068359375, -900.353515625),
|
||||
}
|
||||
PIPELINE = re.compile(r"^\[StreamPipeline\] (.*)$", re.M)
|
||||
FIELDS = re.compile(r"([a-zA-Z0-9_]+)=([^ ]+)")
|
||||
NEAR = re.compile(
|
||||
r"^\[WorldAssemblerNearLODReady\] lods=0-2 tiles=(\d+)/(\d+) cold_ready_s=([0-9.]+)$",
|
||||
re.M,
|
||||
)
|
||||
|
||||
|
||||
def run(strate: int, workers: int, variant: str, phase: str, replicate: int) -> dict:
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
label = f"s{strate}-w{workers}-{variant}-{phase}{replicate}"
|
||||
log = OUT / f"{label}.log"
|
||||
camera = ",".join(f"{v:.12f}" for v in CAMERAS[strate]) + ",0,0"
|
||||
command = [
|
||||
"taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--",
|
||||
"timeout", "1740s", str(BINARIES[variant]), str(DATA),
|
||||
"--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline",
|
||||
f"--workers={workers}", "--clip-radius=3", "--max-clip-level=8", "--max-level=8",
|
||||
f"--camera={camera}", "--profile-lod-work", "--stream-scheduler-policy=3",
|
||||
]
|
||||
start = time.monotonic()
|
||||
with log.open("w", encoding="utf-8") as output:
|
||||
result = subprocess.run(command, cwd=ROOT, stdout=output,
|
||||
stderr=subprocess.STDOUT, check=False, timeout=1740)
|
||||
content = log.read_text(encoding="utf-8", errors="replace")
|
||||
pipeline_match = PIPELINE.search(content)
|
||||
near_match = NEAR.search(content)
|
||||
if result.returncode or pipeline_match is None or near_match is None:
|
||||
raise SystemExit(f"readiness run failed or trace missing: {log}; exit={result.returncode}")
|
||||
pipeline = dict(FIELDS.findall(pipeline_match.group(1)))
|
||||
row = {
|
||||
"strate": strate, "workers": workers, "variant": variant,
|
||||
"phase": phase, "replicate": replicate, "exit_code": result.returncode,
|
||||
"wall_s": round(time.monotonic() - start, 3),
|
||||
"lod02_ready_s": float(near_match.group(3)),
|
||||
"whole_view_ready_s": float(pipeline["whole_view_ready"].removesuffix("s")),
|
||||
"whole_tiles": int(pipeline["whole_tiles"].split("/")[0]),
|
||||
"log": str(log.relative_to(ROOT)),
|
||||
}
|
||||
print("[L109TimingRun] " + json.dumps(row, sort_keys=True), flush=True)
|
||||
return row
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not DATA.is_file() or any(not binary.is_file() for binary in BINARIES.values()):
|
||||
raise SystemExit("missing data or timing binary")
|
||||
rows = []
|
||||
for strate in range(4):
|
||||
for workers in (8, 12):
|
||||
run(strate, workers, "base", "warmup", 0)
|
||||
run(strate, workers, "candidate", "warmup", 0)
|
||||
for replicate in range(1, 6):
|
||||
order = ("base", "candidate") if replicate % 2 else ("candidate", "base")
|
||||
for variant in order:
|
||||
rows.append(run(strate, workers, variant, "measured", replicate))
|
||||
(OUT / "runs-partial.json").write_text(
|
||||
json.dumps(rows, indent=2) + "\n", encoding="utf-8")
|
||||
summary = []
|
||||
for strate in range(4):
|
||||
for workers in (8, 12):
|
||||
item = {"strate": strate, "workers": workers, "pairs": 5}
|
||||
for variant in ("base", "candidate"):
|
||||
group = [row for row in rows if row["strate"] == strate
|
||||
and row["workers"] == workers and row["variant"] == variant]
|
||||
for key in ("lod02_ready_s", "whole_view_ready_s"):
|
||||
values = [row[key] for row in group]
|
||||
item[f"{variant}_{key}"] = values
|
||||
item[f"{variant}_{key}_median"] = statistics.median(values)
|
||||
for key in ("lod02_ready_s", "whole_view_ready_s"):
|
||||
base = item[f"base_{key}_median"]
|
||||
candidate = item[f"candidate_{key}_median"]
|
||||
item[f"{key}_delta_pct"] = round(100 * (candidate / base - 1), 2)
|
||||
summary.append(item)
|
||||
(OUT / "summary.json").write_text(
|
||||
json.dumps({"runs": rows, "summary": summary}, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,484 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run one pinned null-RHI SW/FI arrival for Linux 107."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
HOST = Path("/home/jahni/VFHost")
|
||||
HOST_PLUGIN_SAVED = HOST / "Plugins/VoxelForge/Saved/Linux/l109"
|
||||
HOST_SAVED = HOST / "Saved/Linux/l109"
|
||||
EDITOR = Path("/home/jahni/UnrealEngine/Engine/Binaries/Linux/UnrealEditor-Cmd")
|
||||
OUT = ROOT / "Saved/Linux/l109/unreal-arrival"
|
||||
SPAWNS_M = {
|
||||
"FI": (25.8369140625, 3.19921875, -737.0),
|
||||
"SW": (25.8369140625, 3.19921875, -200.0),
|
||||
}
|
||||
MOVE_START_S = 20.0
|
||||
MOVE_DURATION_S = 12.0
|
||||
EXIT_S = MOVE_START_S + MOVE_DURATION_S + 5.0
|
||||
RUN_TIMEOUT_S = 1740
|
||||
|
||||
|
||||
def read_events(path: Path) -> list[dict]:
|
||||
events = []
|
||||
if path.is_file():
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
if line.startswith("{"):
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return events
|
||||
|
||||
|
||||
def percentile(values: list[float], fraction: float) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
ordered = sorted(values)
|
||||
return ordered[min(len(ordered) - 1, int(round((len(ordered) - 1) * fraction)))]
|
||||
|
||||
|
||||
def sample_unreal_peak_rss() -> tuple[int, int] | None:
|
||||
executable = str(EDITOR).encode()
|
||||
project = str(HOST / "VFHost.uproject").encode()
|
||||
peak: tuple[int, int] | None = None
|
||||
for process in Path("/proc").glob("[0-9]*"):
|
||||
try:
|
||||
command_line = (process / "cmdline").read_bytes()
|
||||
if executable not in command_line or project not in command_line:
|
||||
continue
|
||||
status = (process / "status").read_text(encoding="ascii", errors="ignore")
|
||||
match = re.search(r"^VmHWM:\s+(\d+)\s+kB$", status, re.MULTILINE)
|
||||
if match:
|
||||
rss_kib = int(match.group(1))
|
||||
if peak is None or rss_kib > peak[0]:
|
||||
peak = (rss_kib, int(process.name))
|
||||
except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
|
||||
continue
|
||||
return peak
|
||||
|
||||
|
||||
def endphysics_gap(stdout: Path) -> tuple[float | None, int | None]:
|
||||
release_times: dict[int, dict[int, datetime]] = {}
|
||||
pattern = re.compile(
|
||||
r"^\[(\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}:\d{3})\].*"
|
||||
r"LogTick: tick\s+(\d+)\s+-+\s+Release tick group ([34])")
|
||||
with stdout.open(encoding="utf-8", errors="replace") as stream:
|
||||
for line in stream:
|
||||
match = pattern.match(line)
|
||||
if not match:
|
||||
continue
|
||||
timestamp = datetime.strptime(match.group(1), "%Y.%m.%d-%H.%M.%S:%f")
|
||||
frame = int(match.group(2))
|
||||
group = int(match.group(3))
|
||||
release_times.setdefault(frame, {})[group] = timestamp
|
||||
gaps = [(round((times[4] - times[3]).total_seconds() * 1000.0, 3), frame)
|
||||
for frame, times in release_times.items() if 3 in times and 4 in times]
|
||||
return max(gaps, default=(None, None))
|
||||
|
||||
|
||||
def run(stratum: str, repeat: int, phase: str, ready_depth: int,
|
||||
background_cap: int, worker_reserve: int, helper_background: int,
|
||||
tile_background_low: int, tile_background_normal: int, startup_boost: int,
|
||||
tick_logs: bool, insights: bool) -> dict:
|
||||
label = (f"{phase}-{stratum.lower()}-rd{ready_depth}-bg{background_cap}"
|
||||
f"-reserve{worker_reserve}-helperbg{helper_background}"
|
||||
f"-tilelow{tile_background_low}-tilenormal{tile_background_normal}"
|
||||
f"-ticklog{int(tick_logs)}"
|
||||
f"-boost{startup_boost}-{repeat}")
|
||||
trace = OUT / f"{label}.trace.jsonl"
|
||||
utrace = OUT / f"{label}.utrace"
|
||||
abslog = OUT / f"{label}.log"
|
||||
stdout = OUT / f"{label}.stdout.log"
|
||||
output = HOST_PLUGIN_SAVED / "output" / label
|
||||
userdir = HOST_SAVED / "user" / label
|
||||
zendata = HOST_SAVED / "zen" / label
|
||||
ddc = HOST_SAVED / "DDC"
|
||||
for directory in (OUT, output, userdir, zendata, ddc):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
trace.unlink(missing_ok=True)
|
||||
utrace.unlink(missing_ok=True)
|
||||
spawn_cm = ",".join(f"{value * 100.0:.3f}" for value in SPAWNS_M[stratum])
|
||||
command = [
|
||||
str(EDITOR), str(HOST / "VFHost.uproject"), "-nullrhi", "-unattended",
|
||||
"-nop4", "-nosplash", "-NoSound", "-corelimit=12", "-NoZenAutoLaunch",
|
||||
"-notraceserver", "-nocrashreports",
|
||||
"-DisablePlugins=AndroidFileServer",
|
||||
"-ddc=NoZenLocalFallback", f"-userdir={userdir}", f"-ZenDataPath={zendata}",
|
||||
f"-LocalDataCachePath={ddc}", "-run=VoxelForgeExplore", "-seed=0",
|
||||
"-archetype=TunnelNetwork", f"-slot={1 if stratum == 'FI' else 0}",
|
||||
"-modes=export", "-gameconfig=1", "-coreworldtest=1",
|
||||
"-ownerfourstrateplan=1", "-corevoxelsizecm=25", "-coreislandtest=0",
|
||||
"-corefallbacktest=0", "-coreofftest=0", "-coreplantest=0",
|
||||
"-settingsref=/Game/VoxelForge/DA_Settings.DA_Settings", "-corehybridmesher=0",
|
||||
f"-out={output}", "-voxel.StreamingCoarseFirst=1",
|
||||
"-voxel.StreamingDynamicNearReserve=1",
|
||||
"-voxel.StreamingLegacyPendingResultCap=0",
|
||||
"-voxel.CoreRegionConcurrentAdmission=1",
|
||||
"-voxel.StreamingResultBacklogCap=256",
|
||||
f"-voxel.StreamingReadyQueueDepth={ready_depth}",
|
||||
f"-voxel.StreamingBackgroundWorkerCap={background_cap}",
|
||||
f"-voxel.StreamingBackgroundWorkerReserve={worker_reserve}",
|
||||
f"-voxel.CoreParallelForBackgroundPriority={helper_background}",
|
||||
f"-voxel.StreamingTileBackgroundLow={tile_background_low}",
|
||||
f"-voxel.StreamingTileBackgroundNormal={tile_background_normal}",
|
||||
f"-voxel.TestLogTickFunctions={int(tick_logs)}",
|
||||
"-voxel.CoreColumnCacheDirectMapped=0", "-voxel.CoreMCGradientMemoization=1",
|
||||
"-voxel.StreamingCollisionLookahead=1",
|
||||
f"-voxel.StreamingStartupApplyBoost={startup_boost}",
|
||||
"-voxel.TestMove=1", "-voxel.TestMoveSpeedCmPerSecond=140.0",
|
||||
f"-voxel.TestMoveStartSeconds={MOVE_START_S:.1f}",
|
||||
f"-voxel.TestMoveDurationSeconds={MOVE_DURATION_S:.1f}",
|
||||
f"-voxel.TestExitSeconds={EXIT_S:.1f}", "-voxel.TestMoveDirection=1,0,0",
|
||||
"-voxel.StreamingSchedulerPolicy=3", "-voxel.TestExitOnSteady=0",
|
||||
"-coreworldstreamtrace=1", f"-voxel.StartupTraceSpawnCm={spawn_cm}",
|
||||
"-voxel.StartupTraceUntilEndPlay=1", "-voxel.StartupTraceThroughCrossing=1",
|
||||
"-voxel.StartupTraceMaxTiles=16384", f"-voxel.StartupTraceFile={trace}",
|
||||
f"-abslog={abslog}",
|
||||
]
|
||||
if insights:
|
||||
command += ["-trace=cpu,task,frame", "-statnamedevents",
|
||||
f"-tracefile={utrace}", "-tracefiletrunc"]
|
||||
wrapped = ["taskset", "-c", "0-11", "timeout", f"{RUN_TIMEOUT_S}s", *command]
|
||||
started = time.monotonic()
|
||||
with stdout.open("w", encoding="utf-8") as stream:
|
||||
process = subprocess.Popen(wrapped, cwd=ROOT, stdout=stream,
|
||||
stderr=subprocess.STDOUT)
|
||||
peak_rss = None
|
||||
while process.poll() is None:
|
||||
observed = sample_unreal_peak_rss()
|
||||
if observed and (peak_rss is None or observed[0] > peak_rss[0]):
|
||||
peak_rss = observed
|
||||
time.sleep(0.25)
|
||||
exit_code = process.wait()
|
||||
observed = sample_unreal_peak_rss()
|
||||
if observed and (peak_rss is None or observed[0] > peak_rss[0]):
|
||||
peak_rss = observed
|
||||
|
||||
events = read_events(trace)
|
||||
worker_backend_config = next(
|
||||
(e for e in events if e.get("type") == "worker_backend_config"), {})
|
||||
worker_cap_readback = next(
|
||||
(e for e in events if e.get("type") == "streaming_worker_cap_readback"), {})
|
||||
samples = [e for e in events if e.get("type") == "streaming_sample"]
|
||||
arrival = [e for e in samples if float(e.get("t_s", 0.0)) < MOVE_START_S]
|
||||
full = next((e for e in arrival
|
||||
if sum(e.get("desired_by_lod", [0] * 9)[:3]) > 0
|
||||
and sum(e.get("settled_by_lod", [0] * 9)[:3])
|
||||
>= sum(e.get("desired_by_lod", [0] * 9)[:3])), None)
|
||||
first_admission = next((e for e in events if e.get("type") == "scheduler_admission"
|
||||
and e.get("desired_rebuilt") == 1), None)
|
||||
request_events = [e for e in events if e.get("type") == "request"]
|
||||
initial_request_events = [e for e in request_events if e.get("epoch") == 0]
|
||||
tile_by_ordinal = {e.get("request_ordinal"): e for e in events
|
||||
if e.get("type") == "tile"}
|
||||
collision_count = sum(1 for e in events if e.get("type") == "collision_ready"
|
||||
and e.get("level", 9) <= 2
|
||||
and full and e.get("t_s", 1e30) <= full["t_s"])
|
||||
initial_tiles = [tile_by_ordinal[r.get("request_ordinal")] for r in initial_request_events
|
||||
if full and r.get("t_s", 1e30) <= full["t_s"]
|
||||
and r.get("request_ordinal") in tile_by_ordinal
|
||||
and tile_by_ordinal[r["request_ordinal"]].get("level", 9) <= 2]
|
||||
burst_start_s = float(first_admission["t_s"]) if first_admission else -1.0
|
||||
burst_end_s = float(full["t_s"]) if full else -1.0
|
||||
work_by_frame = {int(e.get("frame", -1)): e for e in events
|
||||
if e.get("type") == "game_thread_work_frame"}
|
||||
work_values = [e for e in work_by_frame.values()
|
||||
if burst_start_s <= float(e.get("t_s", -1.0)) <= burst_end_s]
|
||||
arrival_frame_ids = {int(e.get("frame", -1)) for e in work_values}
|
||||
scheduler_by_frame = {int(e.get("frame", -1)): e for e in events
|
||||
if e.get("type") == "scheduler_admission"}
|
||||
scheduler_values = [e for e in events if e.get("type") == "scheduler_admission"
|
||||
and burst_start_s <= float(e.get("t_s", -1.0)) <= burst_end_s]
|
||||
apply_by_frame = {int(e.get("frame", -1)): e for e in events
|
||||
if e.get("type") == "apply_frame"}
|
||||
commandlet_tick_by_frame = {int(e.get("frame", -1)): e for e in events
|
||||
if e.get("type") == "commandlet_world_tick_frame"}
|
||||
garbage_collect_by_frame = {int(e.get("frame", -1)): e for e in events
|
||||
if e.get("type") == "garbage_collect"}
|
||||
# apply_frame N+1 records the interval containing AVoxelWorld::Tick N. Its apply_ms
|
||||
# belongs to Tick N+1, so use apply_by_frame[N] for the same-tick apply breakdown.
|
||||
near_intervals = [apply_by_frame[frame + 1] for frame in arrival_frame_ids
|
||||
if frame + 1 in apply_by_frame]
|
||||
rmc_by_frame: dict[int, dict[str, float]] = {}
|
||||
for event in events:
|
||||
frame = int(event.get("frame", -1))
|
||||
if event.get("type") != "rmc_submit" or frame not in arrival_frame_ids:
|
||||
continue
|
||||
values = rmc_by_frame.setdefault(frame, {
|
||||
"component_acquire_ms": 0.0,
|
||||
"create_section_group_ms": 0.0,
|
||||
"create_sections_ms": 0.0,
|
||||
"collision_handoff_ms": 0.0,
|
||||
})
|
||||
for key in values:
|
||||
values[key] += float(event.get(key, 0.0))
|
||||
collision_completion_by_frame: dict[int, float] = {}
|
||||
for event in events:
|
||||
frame = int(event.get("frame", -1))
|
||||
if event.get("type") == "collision_completion" and frame in arrival_frame_ids:
|
||||
collision_completion_by_frame[frame] = collision_completion_by_frame.get(frame, 0.0) \
|
||||
+ float(event.get("handler_ms", 0.0))
|
||||
near_requests = [r for r in request_events if full and r.get("t_s", 1e30) <= full["t_s"]
|
||||
and r.get("level", 9) <= 2]
|
||||
initial_lod0_counts = Counter(
|
||||
tuple(r.get("tile", ())) for r in near_requests if r.get("level") == 0)
|
||||
repeated_lod0_keys = [
|
||||
{"tile": list(tile), "requests": count}
|
||||
for tile, count in sorted(initial_lod0_counts.items()) if count > 1]
|
||||
initial_scheduler_frames = [e for e in events
|
||||
if e.get("type") == "scheduler_admission" and full
|
||||
and e.get("t_s", 1e30) <= full["t_s"]]
|
||||
worst_interval = max(near_intervals, key=lambda e: float(e.get("frame_ms", 0.0)), default=None)
|
||||
worst_interval_frame_id = int(worst_interval.get("frame", -1)) if worst_interval else -1
|
||||
worst_work_frame_id = worst_interval_frame_id - 1
|
||||
worst_work = work_by_frame.get(worst_work_frame_id, {})
|
||||
worst_apply = apply_by_frame.get(worst_work_frame_id, {})
|
||||
worst_scheduler = scheduler_by_frame.get(worst_work_frame_id, {})
|
||||
worst_rmc = rmc_by_frame.get(worst_work_frame_id, {})
|
||||
worst_collision_completion_ms = collision_completion_by_frame.get(worst_work_frame_id, 0.0)
|
||||
rmc_values = [rmc_by_frame.get(frame, {}) for frame in arrival_frame_ids]
|
||||
apply_values = [apply_by_frame[frame] for frame in arrival_frame_ids
|
||||
if frame in apply_by_frame]
|
||||
commandlet_tick_values = [commandlet_tick_by_frame[frame] for frame in arrival_frame_ids
|
||||
if frame in commandlet_tick_by_frame]
|
||||
garbage_collect_values = [garbage_collect_by_frame[frame] for frame in arrival_frame_ids
|
||||
if frame in garbage_collect_by_frame]
|
||||
|
||||
def work_stat(name: str, fraction: float | None = None) -> float | None:
|
||||
values = [float(e.get(name, 0.0)) for e in work_values]
|
||||
return percentile(values, fraction) if fraction is not None else max(values, default=0.0)
|
||||
|
||||
def scheduler_stat(name: str, fraction: float | None = None) -> float | None:
|
||||
values = [float(e.get(name, 0.0)) for e in scheduler_values]
|
||||
return percentile(values, fraction) if fraction is not None else max(values, default=0.0)
|
||||
|
||||
def rmc_stat(name: str, fraction: float | None = None) -> float | None:
|
||||
values = [float(e.get(name, 0.0)) for e in rmc_values]
|
||||
return percentile(values, fraction) if fraction is not None else max(values, default=0.0)
|
||||
|
||||
def commandlet_stat(name: str, fraction: float | None = None) -> float | None:
|
||||
values = [float(e.get(name, 0.0)) for e in commandlet_tick_values]
|
||||
return percentile(values, fraction) if fraction is not None else max(values, default=0.0)
|
||||
|
||||
row = {
|
||||
"label": label, "stratum": stratum, "repeat": repeat, "phase": phase,
|
||||
"ready_queue_depth": ready_depth,
|
||||
"requested_background_worker_cap": background_cap,
|
||||
"background_worker_cap": worker_cap_readback.get(
|
||||
"streaming_background_worker_cap", background_cap),
|
||||
"requested_background_worker_reserve": worker_reserve,
|
||||
"background_worker_reserve": worker_backend_config.get(
|
||||
"streaming_background_worker_reserve", worker_cap_readback.get(
|
||||
"background_worker_reserve", worker_reserve)),
|
||||
"requested_core_parallel_for_background_priority": helper_background,
|
||||
"core_parallel_for_background_priority": worker_backend_config.get(
|
||||
"core_parallel_for_background_priority", helper_background),
|
||||
"requested_streaming_tile_background_low": tile_background_low,
|
||||
"streaming_tile_background_low": worker_backend_config.get(
|
||||
"streaming_tile_background_low", tile_background_low),
|
||||
"requested_streaming_tile_background_normal": tile_background_normal,
|
||||
"streaming_tile_background_normal": worker_backend_config.get(
|
||||
"streaming_tile_background_normal", tile_background_normal),
|
||||
"effective_tile_slots": worker_backend_config.get("effective_tile_slots"),
|
||||
"background_worker_threads": worker_backend_config.get("taskgraph_background_threads"),
|
||||
"startup_apply_boost": startup_boost, "insights": insights,
|
||||
"engine_tick_logs": tick_logs,
|
||||
"exit_code": exit_code,
|
||||
"launch_wrapper": "taskset -c 0-11 timeout 1740s (no address-space limit)",
|
||||
"peak_rss_kib": peak_rss[0] if peak_rss else None,
|
||||
"peak_rss_mib": round(peak_rss[0] / 1024.0, 1) if peak_rss else None,
|
||||
"peak_rss_pid": peak_rss[1] if peak_rss else None,
|
||||
"endphysics_to_postphysics_gap_ms_max": None,
|
||||
"endphysics_gap_frame": None,
|
||||
"wall_s": round(time.monotonic() - started, 3),
|
||||
"desired_t_s": next((e.get("t_s") for e in events if e.get("type") == "desired_set"), None),
|
||||
"first_admission_t_s": first_admission.get("t_s") if first_admission else None,
|
||||
"lod02_full_settled_t_s": full.get("t_s") if full else None,
|
||||
"near_settle_from_first_admission_s": round(
|
||||
float(full["t_s"]) - float(first_admission["t_s"]), 4)
|
||||
if full and first_admission else None,
|
||||
"desired_by_lod": full.get("desired_by_lod", [])[:3] if full else None,
|
||||
"settled_by_lod": full.get("settled_by_lod", [])[:3] if full else None,
|
||||
"near_admitted_tiles": len(near_requests),
|
||||
"initial_lod0_admissions": sum(initial_lod0_counts.values()),
|
||||
"repeated_lod0_request_keys_before_settle": repeated_lod0_keys,
|
||||
"peak_result_backlog_before_settle": max(
|
||||
(int(e.get("result_backlog", 0)) for e in initial_scheduler_frames), default=0),
|
||||
"last_admission_t_s": max((e.get("t_s", 0.0) for e in near_requests), default=None),
|
||||
"near_tile_events": len(initial_tiles),
|
||||
"near_worker_seconds": round(sum(e.get("worker_task_s", 0.0) for e in initial_tiles), 4),
|
||||
"near_core_seconds": round(sum(e.get("core_generate_s", 0.0) for e in initial_tiles), 4),
|
||||
"collision_ready_by_settle": collision_count,
|
||||
"burst_frame_count": len(near_intervals),
|
||||
"burst_frame_ms_p95": round(sorted(e.get("frame_ms", 0.0) for e in near_intervals)[
|
||||
min(len(near_intervals) - 1, int(.95 * (len(near_intervals) - 1)))], 3)
|
||||
if near_intervals else None,
|
||||
"burst_frame_ms_max": round(max((e.get("frame_ms", 0.0) for e in near_intervals), default=0.0), 3),
|
||||
"burst_full_world_tick_ms_p99": round(commandlet_stat("world_tick_ms", .99) or 0.0, 3),
|
||||
"burst_full_world_tick_ms_max": round(commandlet_stat("world_tick_ms") or 0.0, 3),
|
||||
"burst_world_pre_actor_ms_p99": round(commandlet_stat("world_pre_actor_ms", .99) or 0.0, 3),
|
||||
"burst_world_pre_actor_ms_max": round(commandlet_stat("world_pre_actor_ms") or 0.0, 3),
|
||||
"burst_world_actor_phase_ms_p99": round(commandlet_stat("world_actor_phase_ms", .99) or 0.0, 3),
|
||||
"burst_world_actor_phase_ms_max": round(commandlet_stat("world_actor_phase_ms") or 0.0, 3),
|
||||
"burst_world_post_actor_ms_p99": round(commandlet_stat("world_post_actor_ms", .99) or 0.0, 3),
|
||||
"burst_world_post_actor_ms_max": round(commandlet_stat("world_post_actor_ms") or 0.0, 3),
|
||||
"burst_commandlet_readiness_check_ms_max": round(
|
||||
commandlet_stat("readiness_check_ms") or 0.0, 3),
|
||||
"burst_commandlet_sleep_ms_p99": round(commandlet_stat("sleep_ms", .99) or 0.0, 3),
|
||||
"burst_commandlet_sleep_ms_max": round(commandlet_stat("sleep_ms") or 0.0, 3),
|
||||
"burst_commandlet_loop_ms_max": round(commandlet_stat("loop_ms") or 0.0, 3),
|
||||
"burst_gc_count": len(garbage_collect_values),
|
||||
"burst_gc_duration_ms_max": round(max(
|
||||
(float(e.get("duration_ms", 0.0)) for e in garbage_collect_values), default=0.0), 3),
|
||||
"burst_max_meshes_per_frame": max((e.get("meshes_applied", 0) for e in apply_values), default=0),
|
||||
"burst_max_apply_ms": round(max((e.get("apply_ms", 0.0) for e in apply_values), default=0.0), 3),
|
||||
"burst_max_admit_per_frame": max((sum(e.get("submitted_by_lod", [0] * 9)[:3])
|
||||
for e in scheduler_values), default=0),
|
||||
"burst_frame_timing_count": len(work_values),
|
||||
"burst_active_generation_workers_p95": round(work_stat("active_generation_workers", .95) or 0.0, 3),
|
||||
"burst_active_generation_workers_max": round(work_stat("active_generation_workers") or 0.0, 3),
|
||||
"burst_active_tile_tasks_max": round(work_stat("active_tile_tasks") or 0.0, 3),
|
||||
"burst_world_tick_ms_p99": round(work_stat("tick_ms", .99) or 0.0, 3),
|
||||
"burst_world_tick_ms_max": round(work_stat("tick_ms") or 0.0, 3),
|
||||
"burst_update_chunks_ms_p99": round(work_stat("update_chunks_ms", .99) or 0.0, 3),
|
||||
"burst_update_chunks_ms_max": round(work_stat("update_chunks_ms") or 0.0, 3),
|
||||
"burst_pending_drain_ms_p99": round(work_stat("pending_drain_ms", .99) or 0.0, 3),
|
||||
"burst_pending_drain_ms_max": round(work_stat("pending_drain_ms") or 0.0, 3),
|
||||
"burst_post_apply_callbacks_ms_p99": round(work_stat("post_apply_callbacks_ms", .99) or 0.0, 3),
|
||||
"burst_post_apply_callbacks_ms_max": round(work_stat("post_apply_callbacks_ms") or 0.0, 3),
|
||||
"burst_visibility_reconcile_ms_p99": round(work_stat("visibility_reconcile_ms", .99) or 0.0, 3),
|
||||
"burst_visibility_reconcile_ms_max": round(work_stat("visibility_reconcile_ms") or 0.0, 3),
|
||||
"burst_collision_probe_ms_max": round(work_stat("collision_probe_ms") or 0.0, 3),
|
||||
"burst_visible_apply_probe_ms_max": round(work_stat("visible_apply_probe_ms") or 0.0, 3),
|
||||
"burst_lod0_ready_ms_max": round(work_stat("lod0_ready_ms") or 0.0, 3),
|
||||
"burst_steady_trace_ms_max": round(work_stat("steady_trace_ms") or 0.0, 3),
|
||||
"burst_modification_ms_max": round(work_stat("modification_ms") or 0.0, 3),
|
||||
"burst_lifecycle_audit_ms_p99": round(work_stat("lifecycle_audit_ms", .99) or 0.0, 3),
|
||||
"burst_lifecycle_audit_ms_max": round(work_stat("lifecycle_audit_ms") or 0.0, 3),
|
||||
"burst_collision_gate_ms_p99": round(work_stat("collision_gate_ms", .99) or 0.0, 3),
|
||||
"burst_collision_gate_ms_max": round(work_stat("collision_gate_ms") or 0.0, 3),
|
||||
"burst_unload_ms_p99": round(work_stat("unload_ms", .99) or 0.0, 3),
|
||||
"burst_unload_ms_max": round(work_stat("unload_ms") or 0.0, 3),
|
||||
"burst_trace_json_ms_p99": round(work_stat("trace_json_ms", .99) or 0.0, 3),
|
||||
"burst_trace_json_ms_max": round(work_stat("trace_json_ms") or 0.0, 3),
|
||||
"burst_desired_rebuild_ms_max": round(scheduler_stat("desired_rebuild_ms") or 0.0, 3),
|
||||
"burst_rmc_create_group_ms_p99": round(rmc_stat("create_section_group_ms", .99) or 0.0, 3),
|
||||
"burst_rmc_create_group_ms_max": round(rmc_stat("create_section_group_ms") or 0.0, 3),
|
||||
"burst_rmc_collision_handoff_ms_max": round(rmc_stat("collision_handoff_ms") or 0.0, 3),
|
||||
"burst_collision_completion_ms_max": round(max(
|
||||
collision_completion_by_frame.values(), default=0.0), 3),
|
||||
"worst_frame_breakdown": {
|
||||
"frame": worst_work_frame_id,
|
||||
"world_tick_frame": worst_work_frame_id,
|
||||
"frame_interval_end_frame": worst_interval_frame_id,
|
||||
"frame_interval_ms": round(float(worst_interval.get("frame_ms", 0.0)), 3)
|
||||
if worst_interval else None,
|
||||
"apply_ms": round(float(worst_apply.get("apply_ms", 0.0)), 3)
|
||||
if worst_apply else None,
|
||||
"result_drain_ms": round(float(worst_apply.get("drain_ms", 0.0)), 3)
|
||||
if worst_apply else None,
|
||||
"outside_world_tick_ms": round(max(0.0, float(worst_interval.get("frame_ms", 0.0))
|
||||
- float(worst_work.get("tick_ms", 0.0))), 3)
|
||||
if worst_interval else None,
|
||||
"outside_full_world_tick_ms": round(max(0.0, float(worst_interval.get("frame_ms", 0.0))
|
||||
- float(commandlet_tick_by_frame.get(worst_work_frame_id, {}).get("world_tick_ms", 0.0))), 3)
|
||||
if worst_interval else None,
|
||||
"full_world_tick_ms": round(float(commandlet_tick_by_frame.get(
|
||||
worst_work_frame_id, {}).get("world_tick_ms", 0.0)), 3),
|
||||
"world_pre_actor_ms": round(float(commandlet_tick_by_frame.get(
|
||||
worst_work_frame_id, {}).get("world_pre_actor_ms", 0.0)), 3),
|
||||
"world_actor_phase_ms": round(float(commandlet_tick_by_frame.get(
|
||||
worst_work_frame_id, {}).get("world_actor_phase_ms", 0.0)), 3),
|
||||
"world_post_actor_ms": round(float(commandlet_tick_by_frame.get(
|
||||
worst_work_frame_id, {}).get("world_post_actor_ms", 0.0)), 3),
|
||||
"commandlet_readiness_check_ms": round(float(commandlet_tick_by_frame.get(
|
||||
worst_work_frame_id, {}).get("readiness_check_ms", 0.0)), 3),
|
||||
"commandlet_sleep_ms": round(float(commandlet_tick_by_frame.get(
|
||||
worst_work_frame_id, {}).get("sleep_ms", 0.0)), 3),
|
||||
"commandlet_loop_ms": round(float(commandlet_tick_by_frame.get(
|
||||
worst_work_frame_id, {}).get("loop_ms", 0.0)), 3),
|
||||
"gc_duration_ms": round(float(garbage_collect_by_frame.get(
|
||||
worst_work_frame_id, {}).get("duration_ms", 0.0)), 3),
|
||||
"world_tick_ms": round(float(worst_work.get("tick_ms", 0.0)), 3),
|
||||
"active_generation_workers": int(worst_work.get("active_generation_workers", 0)),
|
||||
"active_tile_tasks": int(worst_work.get("active_tile_tasks", 0)),
|
||||
"collision_gate_ms": round(float(worst_work.get("collision_gate_ms", 0.0)), 3),
|
||||
"update_chunks_ms": round(float(worst_work.get("update_chunks_ms", 0.0)), 3),
|
||||
"desired_rebuild_ms": round(float(worst_scheduler.get("desired_rebuild_ms", 0.0)), 3),
|
||||
"pending_drain_ms": round(float(worst_work.get("pending_drain_ms", 0.0)), 3),
|
||||
"post_apply_callbacks_ms": round(float(worst_work.get("post_apply_callbacks_ms", 0.0)), 3),
|
||||
"collision_probe_ms": round(float(worst_work.get("collision_probe_ms", 0.0)), 3),
|
||||
"visibility_reconcile_ms": round(float(worst_work.get("visibility_reconcile_ms", 0.0)), 3),
|
||||
"visible_apply_probe_ms": round(float(worst_work.get("visible_apply_probe_ms", 0.0)), 3),
|
||||
"lod0_ready_ms": round(float(worst_work.get("lod0_ready_ms", 0.0)), 3),
|
||||
"unload_ms": round(float(worst_work.get("unload_ms", 0.0)), 3),
|
||||
"steady_trace_ms": round(float(worst_work.get("steady_trace_ms", 0.0)), 3),
|
||||
"modification_ms": round(float(worst_work.get("modification_ms", 0.0)), 3),
|
||||
"lifecycle_audit_ms": round(float(worst_work.get("lifecycle_audit_ms", 0.0)), 3),
|
||||
"trace_json_ms": round(float(worst_work.get("trace_json_ms", 0.0)), 3),
|
||||
"trace_archive_serialize_ms": round(float(
|
||||
worst_work.get("trace_archive_serialize_ms", 0.0)), 3),
|
||||
"collision_completion_ms": round(worst_collision_completion_ms, 3),
|
||||
**{key: round(float(worst_rmc.get(key, 0.0)), 3)
|
||||
for key in ("component_acquire_ms", "create_section_group_ms",
|
||||
"create_sections_ms", "collision_handoff_ms")},
|
||||
},
|
||||
"trace": str(trace.relative_to(ROOT)), "utrace": str(utrace.relative_to(ROOT)),
|
||||
"abslog": str(abslog.relative_to(ROOT)), "stdout": str(stdout.relative_to(ROOT)),
|
||||
}
|
||||
stdout_text = stdout.read_text(encoding="utf-8", errors="replace")
|
||||
gap_ms, gap_frame = endphysics_gap(stdout)
|
||||
row["endphysics_to_postphysics_gap_ms_max"] = gap_ms
|
||||
row["endphysics_gap_frame"] = gap_frame
|
||||
failure = re.search(r"\[VoxelForgeCoreWorldTest\] failed: ([^\r\n]+)", stdout_text)
|
||||
if failure:
|
||||
row["world_test_failure"] = failure.group(1)
|
||||
with (OUT / "runs.jsonl").open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(row, sort_keys=True) + "\n")
|
||||
(OUT / f"{label}.summary.json").write_text(json.dumps(row, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
print("[L109UnrealArrival] " + json.dumps(row, sort_keys=True), flush=True)
|
||||
if exit_code:
|
||||
raise SystemExit(f"Unreal commandlet failed: {stdout}; exit={exit_code}")
|
||||
if not full or len(arrival) < 8:
|
||||
raise SystemExit(f"Unreal arrival trace incomplete: {stdout}; exit={exit_code}")
|
||||
if insights and (not utrace.is_file() or utrace.stat().st_size < 1024):
|
||||
raise SystemExit(f"Unreal Insights trace missing or empty: {utrace}")
|
||||
return row
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--stratum", choices=("FI", "SW"), required=True)
|
||||
parser.add_argument("--repeat", type=int, default=1)
|
||||
parser.add_argument("--phase", choices=("probe", "before", "after", "snapshot"), default="probe")
|
||||
parser.add_argument("--ready-depth", type=int, default=64)
|
||||
parser.add_argument("--background-cap", type=int, choices=(0, 1), default=1)
|
||||
parser.add_argument("--worker-reserve", type=int, choices=(0, 1, 2, 3), default=0)
|
||||
parser.add_argument("--helper-background", type=int, choices=(0, 1), default=1)
|
||||
parser.add_argument("--tile-background-low", type=int, choices=(0, 1), default=1)
|
||||
parser.add_argument("--tile-background-normal", type=int, choices=(0, 1), default=0)
|
||||
parser.add_argument("--startup-boost", type=int, choices=(0, 1), default=1)
|
||||
parser.add_argument("--tick-logs", action="store_true")
|
||||
parser.add_argument("--insights", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if not EDITOR.is_file():
|
||||
raise SystemExit(f"missing Unreal commandlet executable: {EDITOR}")
|
||||
run(args.stratum, args.repeat, args.phase, args.ready_depth,
|
||||
args.background_cap, args.worker_reserve, args.helper_background,
|
||||
args.tile_background_low, args.tile_background_normal, args.startup_boost,
|
||||
args.tick_logs, args.insights)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,93 @@
|
||||
# L109 passage captures
|
||||
|
||||
Each PNG was rendered from its own exported mesh. The renderer reported visible triangles, and each decoded image met the pixel coverage floor. `low_contrast_geometry_visible` marks images that met this floor below the preferred contrast target.
|
||||
|
||||
## Before
|
||||
|
||||
- [upper-mouth](before/passage-0-1-0-upper-mouth.png) — passage/0-1/0; 188440 visible triangles
|
||||
- [lower-mouth](before/passage-0-1-0-lower-mouth.png) — passage/0-1/0; 564520 visible triangles
|
||||
- [25%](before/passage-0-1-0-station-25.png) — passage/0-1/0; 234197 visible triangles
|
||||
- [50%](before/passage-0-1-0-station-50.png) — passage/0-1/0; 315900 visible triangles
|
||||
- [75%](before/passage-0-1-0-station-75.png) — passage/0-1/0; 256359 visible triangles
|
||||
- [section-1/view-1](before/passage-0-1-0-openair-1-view-1.png) — passage/0-1/0 **OPEN AIR**; 211462 visible triangles
|
||||
- [section-1/view-2](before/passage-0-1-0-openair-1-view-2.png) — passage/0-1/0 **OPEN AIR**; 208255 visible triangles
|
||||
- [section-1/view-3](before/passage-0-1-0-openair-1-view-3.png) — passage/0-1/0 **OPEN AIR**; 221239 visible triangles
|
||||
- [section-2/view-1](before/passage-0-1-0-openair-2-view-1.png) — passage/0-1/0 **OPEN AIR**; 218664 visible triangles
|
||||
- [section-2/view-2](before/passage-0-1-0-openair-2-view-2.png) — passage/0-1/0 **OPEN AIR**; 223817 visible triangles
|
||||
- [section-2/view-3](before/passage-0-1-0-openair-2-view-3.png) — passage/0-1/0 **OPEN AIR**; 221239 visible triangles
|
||||
- [section-3/view-1](before/passage-0-1-0-openair-3-view-1.png) — passage/0-1/0 **OPEN AIR**; 224005 visible triangles
|
||||
- [section-3/view-2](before/passage-0-1-0-openair-3-view-2.png) — passage/0-1/0 **OPEN AIR**; 230599 visible triangles; low_contrast_geometry_visible
|
||||
- [section-3/view-3](before/passage-0-1-0-openair-3-view-3.png) — passage/0-1/0 **OPEN AIR**; 229291 visible triangles
|
||||
- [section-4/view-1](before/passage-0-1-0-openair-4-view-1.png) — passage/0-1/0 **OPEN AIR**; 407353 visible triangles
|
||||
- [section-4/view-2](before/passage-0-1-0-openair-4-view-2.png) — passage/0-1/0 **OPEN AIR**; 406732 visible triangles
|
||||
- [section-4/view-3](before/passage-0-1-0-openair-4-view-3.png) — passage/0-1/0 **OPEN AIR**; 363878 visible triangles
|
||||
- [upper-mouth](before/passage-1-2-0-upper-mouth.png) — passage/1-2/0; 576595 visible triangles
|
||||
- [lower-mouth](before/passage-1-2-0-lower-mouth.png) — passage/1-2/0; 555286 visible triangles
|
||||
- [25%](before/passage-1-2-0-station-25.png) — passage/1-2/0; 667602 visible triangles
|
||||
- [50%](before/passage-1-2-0-station-50.png) — passage/1-2/0; 602756 visible triangles
|
||||
- [75%](before/passage-1-2-0-station-75.png) — passage/1-2/0; 620502 visible triangles
|
||||
- [section-1/view-1](before/passage-1-2-0-openair-1-view-1.png) — passage/1-2/0 **OPEN AIR**; 636438 visible triangles
|
||||
- [section-1/view-2](before/passage-1-2-0-openair-1-view-2.png) — passage/1-2/0 **OPEN AIR**; 593960 visible triangles
|
||||
- [section-1/view-3](before/passage-1-2-0-openair-1-view-3.png) — passage/1-2/0 **OPEN AIR**; 622040 visible triangles
|
||||
- [upper-mouth](before/passage-2-3-0-upper-mouth.png) — passage/2-3/0; 59568 visible triangles
|
||||
- [lower-mouth](before/passage-2-3-0-lower-mouth.png) — passage/2-3/0; 294430 visible triangles; low_contrast_geometry_visible
|
||||
- [25%](before/passage-2-3-0-station-25.png) — passage/2-3/0; 55420 visible triangles; low_contrast_geometry_visible
|
||||
- [50%](before/passage-2-3-0-station-50.png) — passage/2-3/0; 458134 visible triangles
|
||||
- [75%](before/passage-2-3-0-station-75.png) — passage/2-3/0; 475978 visible triangles
|
||||
- [section-1/view-1](before/passage-2-3-0-openair-1-view-1.png) — passage/2-3/0 **OPEN AIR**; 104612 visible triangles
|
||||
- [section-1/view-2](before/passage-2-3-0-openair-1-view-2.png) — passage/2-3/0 **OPEN AIR**; 81078 visible triangles
|
||||
- [section-1/view-3](before/passage-2-3-0-openair-1-view-3.png) — passage/2-3/0 **OPEN AIR**; 94962 visible triangles; low_contrast_geometry_visible
|
||||
- [section-2/view-1](before/passage-2-3-0-openair-2-view-1.png) — passage/2-3/0 **OPEN AIR**; 476052 visible triangles
|
||||
- [section-2/view-2](before/passage-2-3-0-openair-2-view-2.png) — passage/2-3/0 **OPEN AIR**; 479198 visible triangles
|
||||
- [section-2/view-3](before/passage-2-3-0-openair-2-view-3.png) — passage/2-3/0 **OPEN AIR**; 479198 visible triangles
|
||||
- [section-3/view-1](before/passage-2-3-0-openair-3-view-1.png) — passage/2-3/0 **OPEN AIR**; 485580 visible triangles
|
||||
- [section-3/view-2](before/passage-2-3-0-openair-3-view-2.png) — passage/2-3/0 **OPEN AIR**; 478866 visible triangles
|
||||
- [section-3/view-3](before/passage-2-3-0-openair-3-view-3.png) — passage/2-3/0 **OPEN AIR**; 490304 visible triangles; low_contrast_geometry_visible
|
||||
- [section-4/view-1](before/passage-2-3-0-openair-4-view-1.png) — passage/2-3/0 **OPEN AIR**; 475662 visible triangles
|
||||
- [section-4/view-2](before/passage-2-3-0-openair-4-view-2.png) — passage/2-3/0 **OPEN AIR**; 496128 visible triangles
|
||||
- [section-4/view-3](before/passage-2-3-0-openair-4-view-3.png) — passage/2-3/0 **OPEN AIR**; 496508 visible triangles; low_contrast_geometry_visible
|
||||
|
||||
## After
|
||||
|
||||
- [upper-mouth](after/passage-0-1-0-upper-mouth.png) — passage/0-1/0; 253282 visible triangles
|
||||
- [lower-mouth](after/passage-0-1-0-lower-mouth.png) — passage/0-1/0; 559326 visible triangles
|
||||
- [25%](after/passage-0-1-0-station-25.png) — passage/0-1/0; 145630 visible triangles
|
||||
- [50%](after/passage-0-1-0-station-50.png) — passage/0-1/0; 430386 visible triangles
|
||||
- [75%](after/passage-0-1-0-station-75.png) — passage/0-1/0; 294374 visible triangles
|
||||
- [section-1/view-1](after/passage-0-1-0-openair-1-view-1.png) — passage/0-1/0 **OPEN AIR**; 289074 visible triangles
|
||||
- [section-1/view-2](after/passage-0-1-0-openair-1-view-2.png) — passage/0-1/0 **OPEN AIR**; 298057 visible triangles
|
||||
- [section-1/view-3](after/passage-0-1-0-openair-1-view-3.png) — passage/0-1/0 **OPEN AIR**; 322309 visible triangles
|
||||
- [section-2/view-1](after/passage-0-1-0-openair-2-view-1.png) — passage/0-1/0 **OPEN AIR**; 283642 visible triangles
|
||||
- [section-2/view-2](after/passage-0-1-0-openair-2-view-2.png) — passage/0-1/0 **OPEN AIR**; 316771 visible triangles
|
||||
- [section-2/view-3](after/passage-0-1-0-openair-2-view-3.png) — passage/0-1/0 **OPEN AIR**; 322309 visible triangles
|
||||
- [section-3/view-1](after/passage-0-1-0-openair-3-view-1.png) — passage/0-1/0 **OPEN AIR**; 262299 visible triangles
|
||||
- [section-3/view-2](after/passage-0-1-0-openair-3-view-2.png) — passage/0-1/0 **OPEN AIR**; 310919 visible triangles; low_contrast_geometry_visible
|
||||
- [section-3/view-3](after/passage-0-1-0-openair-3-view-3.png) — passage/0-1/0 **OPEN AIR**; 363071 visible triangles
|
||||
- [section-4/view-1](after/passage-0-1-0-openair-4-view-1.png) — passage/0-1/0 **OPEN AIR**; 378007 visible triangles
|
||||
- [section-4/view-2](after/passage-0-1-0-openair-4-view-2.png) — passage/0-1/0 **OPEN AIR**; 471132 visible triangles
|
||||
- [section-4/view-3](after/passage-0-1-0-openair-4-view-3.png) — passage/0-1/0 **OPEN AIR**; 382187 visible triangles
|
||||
- [upper-mouth](after/passage-1-2-0-upper-mouth.png) — passage/1-2/0; 570417 visible triangles; low_contrast_geometry_visible
|
||||
- [lower-mouth](after/passage-1-2-0-lower-mouth.png) — passage/1-2/0; 511318 visible triangles
|
||||
- [25%](after/passage-1-2-0-station-25.png) — passage/1-2/0; 661032 visible triangles
|
||||
- [50%](after/passage-1-2-0-station-50.png) — passage/1-2/0; 602996 visible triangles
|
||||
- [75%](after/passage-1-2-0-station-75.png) — passage/1-2/0; 615520 visible triangles
|
||||
- [section-1/view-1](after/passage-1-2-0-openair-1-view-1.png) — passage/1-2/0 **OPEN AIR**; 629052 visible triangles
|
||||
- [section-1/view-2](after/passage-1-2-0-openair-1-view-2.png) — passage/1-2/0 **OPEN AIR**; 588414 visible triangles
|
||||
- [section-1/view-3](after/passage-1-2-0-openair-1-view-3.png) — passage/1-2/0 **OPEN AIR**; 616234 visible triangles
|
||||
- [upper-mouth](after/passage-2-3-0-upper-mouth.png) — passage/2-3/0; 59562 visible triangles
|
||||
- [lower-mouth](after/passage-2-3-0-lower-mouth.png) — passage/2-3/0; 294522 visible triangles; low_contrast_geometry_visible
|
||||
- [25%](after/passage-2-3-0-station-25.png) — passage/2-3/0; 55484 visible triangles; low_contrast_geometry_visible
|
||||
- [50%](after/passage-2-3-0-station-50.png) — passage/2-3/0; 457754 visible triangles
|
||||
- [75%](after/passage-2-3-0-station-75.png) — passage/2-3/0; 475706 visible triangles
|
||||
- [section-1/view-1](after/passage-2-3-0-openair-1-view-1.png) — passage/2-3/0 **OPEN AIR**; 104612 visible triangles
|
||||
- [section-1/view-2](after/passage-2-3-0-openair-1-view-2.png) — passage/2-3/0 **OPEN AIR**; 81078 visible triangles
|
||||
- [section-1/view-3](after/passage-2-3-0-openair-1-view-3.png) — passage/2-3/0 **OPEN AIR**; 94962 visible triangles; low_contrast_geometry_visible
|
||||
- [section-2/view-1](after/passage-2-3-0-openair-2-view-1.png) — passage/2-3/0 **OPEN AIR**; 475688 visible triangles
|
||||
- [section-2/view-2](after/passage-2-3-0-openair-2-view-2.png) — passage/2-3/0 **OPEN AIR**; 479134 visible triangles
|
||||
- [section-2/view-3](after/passage-2-3-0-openair-2-view-3.png) — passage/2-3/0 **OPEN AIR**; 479134 visible triangles
|
||||
- [section-3/view-1](after/passage-2-3-0-openair-3-view-1.png) — passage/2-3/0 **OPEN AIR**; 485216 visible triangles
|
||||
- [section-3/view-2](after/passage-2-3-0-openair-3-view-2.png) — passage/2-3/0 **OPEN AIR**; 479198 visible triangles
|
||||
- [section-3/view-3](after/passage-2-3-0-openair-3-view-3.png) — passage/2-3/0 **OPEN AIR**; 490436 visible triangles; low_contrast_geometry_visible
|
||||
- [section-4/view-1](after/passage-2-3-0-openair-4-view-1.png) — passage/2-3/0 **OPEN AIR**; 475298 visible triangles
|
||||
- [section-4/view-2](after/passage-2-3-0-openair-4-view-2.png) — passage/2-3/0 **OPEN AIR**; 495856 visible triangles
|
||||
- [section-4/view-3](after/passage-2-3-0-openair-4-view-3.png) — passage/2-3/0 **OPEN AIR**; 496236 visible triangles; low_contrast_geometry_visible
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 493 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 505 KiB |
|
After Width: | Height: | Size: 528 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 492 KiB |
|
After Width: | Height: | Size: 513 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 134 KiB |