experiment: LivingPlane repeatable + standalone UE runtime module (L164)

Readback barrier fix (GL_TEXTURE_UPDATE_BARRIER_BIT before glGetTexImage); check_repeatability.py compares full-field
checksums across query/capture settings - Intel GPU PASS (3b9d45905c7aafff), llvmpipe PASS, mismatch control fails.
New runtime module VoxelForgeLivingPlane (separate from generation): RDG global shader (Shaders/LivingPlane.usf),
ping-pong RGBA32F render targets, actor with WPO plane and object inputs, no CPU readback. Compiles in Linux UE 5.7.
Windows build: Source/VoxelForgeLivingPlane/*, Shaders/LivingPlane.usf, VoxelForge.uplugin. Setup: UE-SETUP.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1
This commit is contained in:
Fr0zkaandClaude Opus 5.5 committed 2026-11-02 11:10:43 +01:00
1 parent e1d87eed26
commit 3b71d35eba
13 files changed
+868 -8

No files matched your search

+34 -3
View File
@@ -7,6 +7,7 @@
#include <algorithm>
#include <array>
#include <bit>
#include <cerrno>
#include <chrono>
#include <cctype>
@@ -17,6 +18,7 @@
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <limits>
#include <numeric>
@@ -48,7 +50,7 @@ namespace
struct FOptions
{
std::string Device="auto";
std::filesystem::path Output="Saved/Linux/l163/living-plane";
std::filesystem::path Output="Saved/Linux/l164/living-plane";
std::string Scene="wake";
int Size=128;
int Steps=360;
@@ -1011,12 +1013,29 @@ void main()
return Values[std::min(I,Values.size()-1)];
}
std::uint64_t FieldChecksum(const std::vector<float>& Field)
{
// Hash float bit patterns in canonical little-endian byte order so the
// result does not depend on the host's native byte order.
std::uint64_t Hash=14695981039346656037ull;
for(const float Value:Field)
{
const std::uint32_t Bits=std::bit_cast<std::uint32_t>(Value);
for(unsigned Byte=0;Byte<4;++Byte)
{
Hash^=static_cast<std::uint8_t>((Bits>>(Byte*8))&0xffu);
Hash*=1099511628211ull;
}
}
return Hash;
}
bool WriteTimingJson(const FOptions& Options,const FContext& Context,
std::string_view Renderer,std::string_view Vendor,std::string_view Version,
double Dt,const std::vector<GLuint64>& Times,const std::vector<double>& Heights,
const std::vector<double>& Velocities,std::size_t Frames,int TimerQueryBits,
int TimerQueryWaits,bool TimerResultsAvailable,double DispatchWallMsPerStep,
bool FirstStepAudit,bool DynamicsAudit)
std::uint64_t FieldChecksumValue,bool FirstStepAudit,bool DynamicsAudit)
{
std::error_code Error;
std::filesystem::create_directories(Options.Output,Error);
@@ -1035,6 +1054,9 @@ void main()
double HeightSquare=0.0;
for(const double H:Heights)HeightSquare+=H*H;
const double HeightRms=std::sqrt(HeightSquare/Heights.size());
char FieldChecksumHex[17]{};
std::snprintf(FieldChecksumHex,sizeof(FieldChecksumHex),"%016llx",
static_cast<unsigned long long>(FieldChecksumValue));
const double Dx=PlaneLengthMeters/Options.Size;
const double SpectralStep=Dt*std::sqrt(8.0*WaveSpeedMetersPerSecond*
WaveSpeedMetersPerSecond/(Dx*Dx)+FoundationStiffnessPerSecondSquared);
@@ -1087,6 +1109,7 @@ void main()
<<" \"field_max_m\": "<<HeightMax<<",\n"
<<" \"field_mean_m\": "<<HeightMean<<",\n"
<<" \"field_rms_m\": "<<HeightRms<<",\n"
<<" \"field_checksum_fnv1a64\": \""<<FieldChecksumHex<<"\",\n"
<<" \"max_abs_velocity_m_per_s\": "<<std::abs(VelocityMax)<<",\n"
<<" \"gl_time_elapsed_ms_per_step\": [";
for(std::size_t I=0;I<Times.size();++I)
@@ -1284,6 +1307,10 @@ void main()
glFinish();
FirstField.resize(static_cast<std::size_t>(Size)*Size*4);
glBindTexture(GL_TEXTURE_2D,Fields[static_cast<std::size_t>(Current)]);
// glGetTexImage is a texture-update access. The image/fetch
// barriers used for simulation and rendering do not cover this
// readback visibility path.
glMemoryBarrier(GL_TEXTURE_UPDATE_BARRIER_BIT);
glGetTexImage(GL_TEXTURE_2D,0,GL_RGBA,GL_FLOAT,FirstField.data());
const int X=std::clamp(static_cast<int>(std::floor(
(Bodies[0].X+PlaneLengthMeters*0.5)/Dx)),0,Size-1);
@@ -1329,6 +1356,7 @@ void main()
std::vector<float> FinalField(static_cast<std::size_t>(Size)*Size*4);
glBindTexture(GL_TEXTURE_2D,Fields[static_cast<std::size_t>(Current)]);
glMemoryBarrier(GL_TEXTURE_UPDATE_BARRIER_BIT);
glGetTexImage(GL_TEXTURE_2D,0,GL_RGBA,GL_FLOAT,FinalField.data());
std::vector<double> Heights,Velocities;
Heights.reserve(static_cast<std::size_t>(Size)*Size);
@@ -1346,6 +1374,7 @@ void main()
const auto [HeightMinIt,HeightMaxIt]=std::minmax_element(Heights.begin(),Heights.end());
const bool DynamicsAudit=Finite&&*HeightMinIt<-1.0e-7&&*HeightMaxIt-*HeightMinIt<2.0
&&(!Options.Imprint||ImprintMin>=-Options.ImprintMaxDepthMeters-1.0e-5);
const std::uint64_t FinalFieldChecksum=FieldChecksum(FinalField);
const bool Good=FirstStepAudit&&DynamicsAudit&&QueryAudit&&glGetError()==GL_NO_ERROR;
if(!DynamicsAudit)
std::cerr<<"[LivingPlane] field audit failed: finite="<<Finite<<" min="
@@ -1354,7 +1383,7 @@ void main()
std::cerr<<"[LivingPlane] timer-query result availability audit failed after glFinish\n";
if(!WriteTimingJson(Options,Context,Renderer,Vendor,Version,Dt,Times,Heights,Velocities,
FramesWritten,TimerQueryBits,TimerQueryWaits,TimerResultsAvailable,
DispatchWallMsPerStep,FirstStepAudit,DynamicsAudit))
DispatchWallMsPerStep,FinalFieldChecksum,FirstStepAudit,DynamicsAudit))
{Render.Destroy();if(QueryCount>0)glDeleteQueries(QueryCount,Queries.data());
glDeleteTextures(2,Fields.data());glDeleteProgram(ComputeProgram);return false;}
@@ -1375,6 +1404,8 @@ void main()
<<" timer_query_bits="<<TimerQueryBits<<" timer_query_waits="<<TimerQueryWaits;
std::cout<<" field_min_m="<<*HeightMinIt<<" field_max_m="<<*HeightMaxIt
<<" imprint_min_m="<<ImprintMin<<" frames="<<FramesWritten
<<" field_checksum_fnv1a64="<<std::hex<<std::setw(16)<<std::setfill('0')
<<FinalFieldChecksum<<std::dec<<std::setfill(' ')
<<" equation_audit="<<(FirstStepAudit?"PASS":"FAIL")
<<" timer_query_audit="<<(Options.TimerQueries
?(QueryAudit?"PASS":"FAIL"):"NOT_RUN")
+68
View File
@@ -131,3 +131,71 @@ diagnostic remain unmeasured here.
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.
+55
View File
@@ -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.
+14 -4
View File
@@ -1,9 +1,9 @@
# Unreal mapping note
The prototype stores a two-channel height and vertical-velocity field in a pair of
`RG32F` textures. A world-owned Unreal component could 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 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
@@ -21,3 +21,13 @@ 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).
+1 -1
View File
@@ -3,7 +3,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
OUTPUT_DIR="${PLUGIN_ROOT}/Saved/Linux/l163"
OUTPUT_DIR="${PLUGIN_ROOT}/Saved/Linux/l164"
CXX="${CXX:-g++}"
command -v "${CXX}" >/dev/null || { echo "C++ compiler not found: ${CXX}" >&2; exit 2; }
+132
View File
@@ -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)