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:
1 parent
e1d87eed26
commit
3b71d35eba
13 files changed
+868
-8
No files matched your search
@@ -9,6 +9,10 @@
|
||||
!/Source/
|
||||
!/Source/**
|
||||
|
||||
# Plugin runtime shader source.
|
||||
!/Shaders/
|
||||
!/Shaders/**
|
||||
|
||||
# Portable CPU generator and D3D11 viewer.
|
||||
!/Standalone/
|
||||
!/Standalone/**
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@
|
||||
"Type": "Runtime",
|
||||
"LoadingPhase": "Default"
|
||||
},
|
||||
{
|
||||
"Name": "VoxelForgeLivingPlane",
|
||||
"Type": "Runtime",
|
||||
"LoadingPhase": "Default"
|
||||
},
|
||||
{
|
||||
"Name": "VoxelForgeEditor",
|
||||
"Type": "Editor",
|
||||
|
||||
Reference in new issue
Block a user