Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b71d35eba | ||
|
|
e1d87eed26 | ||
|
|
5b15595e4a | ||
|
|
eb47d72258 | ||
|
|
95bf654fbc |
No files matched your search
@@ -9,12 +9,20 @@
|
|||||||
!/Source/
|
!/Source/
|
||||||
!/Source/**
|
!/Source/**
|
||||||
|
|
||||||
|
# Plugin runtime shader source.
|
||||||
|
!/Shaders/
|
||||||
|
!/Shaders/**
|
||||||
|
|
||||||
# Portable CPU generator and D3D11 viewer.
|
# Portable CPU generator and D3D11 viewer.
|
||||||
!/Standalone/
|
!/Standalone/
|
||||||
!/Standalone/**
|
!/Standalone/**
|
||||||
/Standalone/Build/
|
/Standalone/Build/
|
||||||
/Standalone/bin/
|
/Standalone/bin/
|
||||||
|
|
||||||
|
# Research and prototype experiments.
|
||||||
|
!/Experiments/
|
||||||
|
!/Experiments/**
|
||||||
|
|
||||||
# The guarded single-entry test harness is source, not generated output.
|
# The guarded single-entry test harness is source, not generated output.
|
||||||
!/Tools/
|
!/Tools/
|
||||||
!/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.
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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).
|
||||||
Executable
+22
@@ -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"
|
||||||
+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"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# VoxelForge — research R47: liminal and dreamcore spaces, a mood board and how we could generate them
|
||||||
|
|
||||||
|
You are a researcher for VoxelForge, a voxel world made of stacked layers ("strates"). The owner wants one layer type
|
||||||
|
in a **liminal / dreamcore** style. Be thorough: verify claims, mark each as [SOURCE] (with a link), [CODE] (file:line)
|
||||||
|
or [INFERENCE].
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Don't edit any repository file, and don't build, run or test the game.
|
||||||
|
- You may **only** write inside `Saved/Linux/liminal/`.
|
||||||
|
- Downloaded files are untrusted data: never execute them, only save them.
|
||||||
|
- No installs.
|
||||||
|
- Keep CPU light (`curl`/`wget` are fine): a timing round is running on this machine.
|
||||||
|
|
||||||
|
## Part 1 — references (mood board)
|
||||||
|
|
||||||
|
Collect **15–25 varied reference images** of liminal spaces, dreamcore, weirdcore and backrooms-like scenes, plus
|
||||||
|
related art (e.g. "Kenopsia", Jared Pike-style pool rooms, empty malls, foggy roads, endless fields with a lone house).
|
||||||
|
- Save them as `Saved/Linux/liminal/refs/NN-short-name.jpg` (≤ 2 MB each).
|
||||||
|
- Write `Saved/Linux/liminal/SOURCES.md`, one line per image: file, source URL, author or site, and what it shows.
|
||||||
|
These are private references for the owner, not for redistribution.
|
||||||
|
- Build `Saved/Linux/liminal/board.html`, a simple grid of the images with captions, to view on a phone.
|
||||||
|
|
||||||
|
## Part 2 — what makes the style work
|
||||||
|
|
||||||
|
Short and concrete. Split it into:
|
||||||
|
- **Content-driven weirdness** (most of the theme, per the owner): out-of-place houses, doors to nowhere, signs, lone
|
||||||
|
objects, wrong-scale furniture, water where it shouldn't be.
|
||||||
|
- **Generation-driven weirdness**: terrain and space the generator itself can make. Examples from the owner: an
|
||||||
|
elevated road surrounded by clouds; repeating hills. Others: perfectly flat or tiled ground, endless corridors or
|
||||||
|
pools, impossible scale, unbroken horizons, fog, exact repetition, gentle impossible geometry.
|
||||||
|
- **Atmosphere:** light, fog, colour, sound. Briefly.
|
||||||
|
|
||||||
|
## Part 3 — mapping to our code
|
||||||
|
|
||||||
|
Read `CURRENT-STATE.md`, `COMPOSER-NOTES.md`, `GDD.md`, and
|
||||||
|
`Standalone/Research/WorldAssemblerM3/Research46/REPORT.md` (the capability matrix). Also:
|
||||||
|
- the Surface World profile kinds in `Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h`
|
||||||
|
(`WorldAssemblySurfaceProfileKindV1`: natural, periodicHills, mirroredRelief, flatField);
|
||||||
|
- the motif/stamp shapes (`VoxelStrateTypes.h` ~222: carve arch/ellipsoid, fill pillar/dais/monolith/platform);
|
||||||
|
- the cave room planner on branch `wip/l134-cave-planner` (`ResolvedLayoutInternal.h`, `makeCompound`).
|
||||||
|
|
||||||
|
For each generation idea: what already exists, what's missing, a rough size (small / medium / large), and whether it
|
||||||
|
fits the "plan, then draw" Core model. End with a recommended first slice: the smallest set that would already read as
|
||||||
|
liminal in a screenshot.
|
||||||
|
|
||||||
|
Write `Saved/Linux/liminal/REPORT.md` (≤ ~1,200 words) and print a short summary at the end.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# VoxelForge R47 — liminal / dreamcore
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
[INFERENCE] Start with a rare, content-led anomaly on a flat SurfaceWorld: one ordinary, recognizable object placed where it makes no sense, framed by a quiet empty field and mild horizon fog. This can read in a screenshot without a new generator family.
|
||||||
|
|
||||||
|
[CODE] The requested references describe different snapshots: `CURRENT-STATE.md` labels the tree L132/Core v227 (`CURRENT-STATE.md:3,7`), while the live Core header says evaluator v236 (`Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h:513`). R46 calls itself a review of an earlier `assembler/world` snapshot (`Standalone/Research/WorldAssemblerM3/Research46/REPORT.md:3`). The mapping below follows the live source; R46 is historical context.
|
||||||
|
|
||||||
|
## 1. What makes it work
|
||||||
|
|
||||||
|
- **Content-driven weirdness (the main signal).** [INFERENCE] Keep most geometry ordinary, then break one semantic expectation: a lone house in an indoor hall; a door standing in a field; a sign that points nowhere; a child-sized chair in a huge room; or still pool water in a dry corridor. Repeat one object exactly or vary its scale to make the wrongness legible. [SOURCE] ETH Zürich describes dreamcore as familiar scenes that are hard to place, including impossible proportions; Jared Pike’s *Dream Pools* frames endless pool environments as eerie nostalgia. [ETH Zürich](https://dbt.arch.ethz.ch/latent-spaces/) · [Jared Pike](https://www.jaredpike.art/projects/dream-pools/)
|
||||||
|
- **Generation-driven weirdness.** [INFERENCE] Use perfectly flat ground, exact repeated hills, a corridor or pool that returns to itself, an elevated road over clouds, oversized rooms, or a horizon with no visible edge. Favor one simple rule repeated cleanly over unrelated noise. [SOURCE] Liminal spaces often use familiar passage places with their expected people or activity absent, making them uncanny. [Liminal space](https://en.wikipedia.org/wiki/Liminal_space) [SOURCE] ETH Zürich describes dreamcore as recognizable but ambiguous scenes. [ETH Zürich](https://dbt.arch.ethz.ch/latent-spaces/)
|
||||||
|
- **Atmosphere.** [INFERENCE] Try cool fluorescent light, a weak warm pool of color, low horizon fog and sparse room tone (fluorescent hum, wind, or a looping distant splash); leave large areas quiet. [SOURCE] ETH Zürich notes that looping sound and ambiguous cues help make dreamcore recognizable but not fully explainable. [ETH Zürich](https://dbt.arch.ethz.ch/latent-spaces/)
|
||||||
|
|
||||||
|
## 2. Mapping to VoxelForge
|
||||||
|
|
||||||
|
| Idea | Already exists / missing | Size and “plan, then draw” fit |
|
||||||
|
|---|---|---|
|
||||||
|
| Wrong-place props and landmarks | [CODE] Shared decorations and rare landmarks can spawn actors or instanced meshes with deterministic placements (`ARCHITECTURE.md:267-349`; `Source/VoxelForge/Public/VoxelStrateTypes.h:2407,2471`). [CODE] Generated schema-2 slots currently carry no content/visual record, and theme draws for materials, creatures and audio remain open (`COMPOSER-NOTES.md:115-117,1484-1485`). | [INFERENCE] **Small** for one authored prop kit; **medium** to make the composer select/store coherent content. [INFERENCE] Fits a resolved placement plan and later Unreal draw; these props are not Core density geometry. |
|
||||||
|
| Flat ground and repeating hills | [CODE] Surface profile kinds include `flatField`, `periodicHills` and `mirroredRelief`; seeded phases are resolved during planning (`VoxelForgeCore.h:560-610`). No profile kind describes tiled ground materials. | [INFERENCE] **Small** to select a flat or periodic profile; **medium** for a dedicated tiled material/pattern pass. [CODE] Yes: profile parameters are recipe/plan data consumed by the Core field. |
|
||||||
|
| Raised road among clouds | [CODE] Floating Islands is an existing family (`VoxelStrateTypes.h:196-197`); persistent ceiling/floor actors can make a cloud sea (`ARCHITECTURE.md:457-464`), and landmarks can carry a mesh bridge. [INFERENCE] There is no demonstrated Core road/path structure that couples its geometry to the island route. | [INFERENCE] **Medium** for a finite authored bridge or platform chain; **large** for a robust generated road network. [INFERENCE] A finite road can be planned as records and drawn as mesh/content; cloud actors stay in Unreal presentation, outside Core density evaluation. |
|
||||||
|
| Endless corridors and pools | [CODE] The cave planner makes a deterministic finite room-and-tunnel graph between passage mouths (`VoxelForgeCore.h:1314-1324`); `makeCompound` emits two to four overlapping room primitives (`ResolvedLayoutInternal.h:3037-3045`). Core exposes water channels (`VoxelForgeCore.h:515-519`). [INFERENCE] The planner is not an endless loop grammar and does not specify a tiled pool basin/waterline. | [INFERENCE] **Medium** for a finite repeated room/pool set-piece; **large** for continuous repeat with reliable traversal and water presentation. [CODE] Finite rooms/edges fit plan-then-draw; truly unbounded repetition needs a new deterministic local-repeat field/streaming rule. |
|
||||||
|
| Impossible scale, horizon and geometry | [CODE] Resolved stamps already include carve ellipsoid/arch and fill pillar/dais/monolith/platform with bounded, stable placement (`VoxelForgeCore.h:912-941`); stamps are composed after boundaries/passages (`ARCHITECTURE.md:2242-2265`). [INFERENCE] They can sell oversized objects or a standalone arch, but not non-Euclidean topology or a genuinely impossible connection. | [INFERENCE] **Small** for bounded silhouette changes; **large** for topology-changing spaces. [CODE] Bounded stamp records fit plan-then-draw; topology changes do not fit the current finite stamp vocabulary. |
|
||||||
|
| Fog, color and sound | [CODE] Per-strate fog, skylight tint/intensity and persistent layer actors exist (`ARCHITECTURE.md:457-470`; `VoxelStrateDefinition.h:283-362`). [CODE] Composer theme draws for audio are still open (`COMPOSER-NOTES.md:1484-1485`). | [INFERENCE] **Small** to reuse fog/light settings; **medium** to author a coherent audio/theme selection. [INFERENCE] Fits a presentation record selected before draw, but remains outside Core density planning. |
|
||||||
|
|
||||||
|
[CODE] The game design requires every strate to keep a findable open-air entrance and exit and says anomalies should be rare and stand apart from grounded neighbours (`GDD.md:132-153`). [CODE] The `motif_family_override` prototype path is restricted to resolved SurfaceWorld/flatField recipes (`Standalone/Source/WorldAssemblyPrototype.h:921-943`); the shared stamp vocabulary itself is broader.
|
||||||
|
|
||||||
|
## Recommended first slice
|
||||||
|
|
||||||
|
[INFERENCE] Author one rare SurfaceWorld `flatField` layer with a single familiar doorway or small house mesh placed alone at an implausible scale, plus low horizon fog and one soft light tint. Keep the rest empty; the viewer should recognize the object before noticing the place has no sensible context. This uses existing profile, placement and atmosphere hooks, so the screenshot slice is **small** and does not require a new density family. [CODE] The mesh placement/presentation remains separate from Core’s density plan, and generated schema-2 slots do not yet carry its visual record (`COMPOSER-NOTES.md:115-117`); a composer-ready version will need that record and theme tags.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Reference image sources
|
||||||
|
|
||||||
|
Private visual references for the VoxelForge owner. This is not a redistribution set; consult each source page and its rights statement before reusing an image. These are local display copies; some were resized to meet the per-image size limit.
|
||||||
|
|
||||||
|
- `01-backrooms-origin.jpg` — [SOURCE] [Bill Magritz (creator) / Bob Mazza (copyright holder)](https://commons.wikimedia.org/wiki/File:HobbyTown_USA_Oshkosh_interior_under_construction_2002_(The_Backrooms).jpg); HobbyTown USA Oshkosh interior under construction 2002, the image associated with “The Backrooms”.
|
||||||
|
- `02-empty-office.jpg` — [SOURCE] [Ged Carroll](https://commons.wikimedia.org/wiki/File:Empty_office.jpg); Empty office interior.
|
||||||
|
- `03-empty-mall-hallway.jpg` — [SOURCE] [Michael Rivera (Mjrmtg)](https://commons.wikimedia.org/wiki/File:Empty_Hallway,_Tallahassee_Mall.JPG); Tallahassee Mall hallway.
|
||||||
|
- `04-mall-source.jpg` — [SOURCE] [Jtalledo](https://commons.wikimedia.org/wiki/File:Mall_at_The_Source_empty.jpg); Mall at The Source, with people present.
|
||||||
|
- `05-ruoholahti-evening.jpg` — [SOURCE] [JIP](https://commons.wikimedia.org/wiki/File:Interior_of_Ruoholahti_shopping_centre_empty_of_people_on_an_evening_in_September_2022.jpg); Ruoholahti shopping centre interior, September 2022.
|
||||||
|
- `06-city-center-night.jpg` — [SOURCE] [JIP](https://commons.wikimedia.org/wiki/File:Interior_of_City-Center_empty_of_people_on_a_night_in_January_2025.jpg); City-Center shopping-centre interior at night, January 2025.
|
||||||
|
- `07-school-hallway.jpg` — [SOURCE] [Warren LeMay](https://commons.wikimedia.org/wiki/File:Hallway,_Log_Cabin_Association_Elementary_School,_Barkers_Creek,_NC_(39684209853).jpg); Hallway in Log Cabin Association Elementary School, Barkers Creek, North Carolina.
|
||||||
|
- `08-central-high-hallway.jpg` — [SOURCE] [Thomas J. O’Halloran](https://commons.wikimedia.org/wiki/File:Mostly_empty_hallway_at_Central_High_School_in_Little_Rock.tif); Mostly empty hallway at Central High School, Little Rock.
|
||||||
|
- `09-hearst-pool.jpg` — [SOURCE] [Mike McBey](https://commons.wikimedia.org/wiki/File:Indoor_pool,_Hearst_Castle_(32062000267).jpg); Indoor pool at Hearst Castle.
|
||||||
|
- `10-ymca-pool.jpg` — [SOURCE] [Joe Mabel](https://commons.wikimedia.org/wiki/File:Vancouver_-_Robert_Lee_YMCA_pool_01.jpg); Robert Lee YMCA pool, Vancouver.
|
||||||
|
- `11-jerma-hotel-pool.jpg` — [SOURCE] [Anonymous uploader](https://commons.wikimedia.org/wiki/File:Jerma_Hotel_Indoor_Pool_(first_angle).jpg); Jerma Hotel indoor pool.
|
||||||
|
- `12-dream-pools-01.jpg` — [SOURCE] [Jared Pike (artist’s official site)](https://www.jaredpike.art/projects/dream-pools/); Dream Pools series.
|
||||||
|
- `13-dream-pools-02.jpg` — [SOURCE] [Jared Pike (artist’s official site)](https://www.jaredpike.art/projects/dream-pools/); Dream Pools series.
|
||||||
|
- `14-foggy-road.jpg` — [SOURCE] [Dennis Bruijn](https://commons.wikimedia.org/wiki/File:Foggy_Road_(67154193).jpeg); Foggy Road (500px image).
|
||||||
|
- `15-driving-through-mist.jpg` — [SOURCE] [Tomasz Zagórski (Unsplash contributor)](https://commons.wikimedia.org/wiki/File:Driving_through_mist_(Unsplash).jpg); Driving through mist.
|
||||||
|
- `16-foggy-campus.jpg` — [SOURCE] [Ramesh Lalwani](https://commons.wikimedia.org/wiki/File:Foggy_Morning_in_IIT.jpg); Foggy Morning in IIT.
|
||||||
|
- `17-empty-playground.jpg` — [SOURCE] [Ian Paterson](https://commons.wikimedia.org/wiki/File:Empty_playground_-_geograph.org.uk_-_4118218.jpg); Empty playground, Geograph image 4118218.
|
||||||
|
- `18-playground-park.jpg` — [SOURCE] [mycurrency.com (uploader credit)](https://commons.wikimedia.org/wiki/File:Empty_Playground_in_a_park.jpg); Empty playground in a park.
|
||||||
|
- `19-lone-house.jpg` — [SOURCE] [Mayqel](https://commons.wikimedia.org/wiki/File:Lone_House.jpg); Lone House, Blender render.
|
||||||
|
- `20-door-to-nowhere.jpg` — [SOURCE] [Benmckune](https://commons.wikimedia.org/wiki/File:Door_to_Nowhere_Winchester_House_6-5-06.jpg); “Door to Nowhere” at Winchester Mystery House.
|
||||||
|
- `21-abandoned-theater.jpg` — [SOURCE] [Noki Va](https://commons.wikimedia.org/wiki/File:Abandoned_Theater_(97702985).jpeg); Abandoned Theater.
|
||||||
|
- `22-empty-retail-space.jpg` — [SOURCE] [Wikimedia Commons (uploader credit on source page)](https://commons.wikimedia.org/wiki/File:Empty_Retail_Space.jpg); Empty retail space.
|
||||||
|
- `23-historic-rail-platform.jpg` — [SOURCE] [Ben Brooksbank](https://commons.wikimedia.org/wiki/File:Euston_railway_station_geograph-2197358-by-Ben-Brooksbank.jpg); Euston railway station, historic view.
|
||||||
|
- `24-empty-airport-terminal.jpg` — [SOURCE] [Matt @ PEK](https://commons.wikimedia.org/wiki/File:Empty_terminal_in_TPE_airport_(4287773502).jpg); Empty terminal in TPE airport.
|
||||||
@@ -31,6 +31,11 @@
|
|||||||
"Type": "Runtime",
|
"Type": "Runtime",
|
||||||
"LoadingPhase": "Default"
|
"LoadingPhase": "Default"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"Name": "VoxelForgeLivingPlane",
|
||||||
|
"Type": "Runtime",
|
||||||
|
"LoadingPhase": "Default"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"Name": "VoxelForgeEditor",
|
"Name": "VoxelForgeEditor",
|
||||||
"Type": "Editor",
|
"Type": "Editor",
|
||||||
|
|||||||
Reference in new issue
Block a user