diff --git a/.gitignore b/.gitignore index fba57e8..2388acb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ !/Source/ !/Source/** +# Plugin runtime shader source. +!/Shaders/ +!/Shaders/** + # Portable CPU generator and D3D11 viewer. !/Standalone/ !/Standalone/** diff --git a/Experiments/LivingPlane/LivingPlane.cpp b/Experiments/LivingPlane/LivingPlane.cpp index 514988b..163773e 100644 --- a/Experiments/LivingPlane/LivingPlane.cpp +++ b/Experiments/LivingPlane/LivingPlane.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -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& 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(Value); + for(unsigned Byte=0;Byte<4;++Byte) + { + Hash^=static_cast((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& Times,const std::vector& Heights, const std::vector& 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(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\": "<(Size)*Size*4); glBindTexture(GL_TEXTURE_2D,Fields[static_cast(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(std::floor( (Bodies[0].X+PlaneLengthMeters*0.5)/Dx)),0,Size-1); @@ -1329,6 +1356,7 @@ void main() std::vector FinalField(static_cast(Size)*Size*4); glBindTexture(GL_TEXTURE_2D,Fields[static_cast(Current)]); + glMemoryBarrier(GL_TEXTURE_UPDATE_BARRIER_BIT); glGetTexImage(GL_TEXTURE_2D,0,GL_RGBA,GL_FLOAT,FinalField.data()); std::vector Heights,Velocities; Heights.reserve(static_cast(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="<0)glDeleteQueries(QueryCount,Queries.data()); glDeleteTextures(2,Fields.data());glDeleteProgram(ComputeProgram);return false;} @@ -1375,6 +1404,8 @@ void main() <<" timer_query_bits="</dev/null || { echo "C++ compiler not found: ${CXX}" >&2; exit 2; } diff --git a/Experiments/LivingPlane/check_repeatability.py b/Experiments/LivingPlane/check_repeatability.py new file mode 100755 index 0000000..adca7eb --- /dev/null +++ b/Experiments/LivingPlane/check_repeatability.py @@ -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) diff --git a/Shaders/LivingPlane.usf b/Shaders/LivingPlane.usf new file mode 100644 index 0000000..cd363e6 --- /dev/null +++ b/Shaders/LivingPlane.usf @@ -0,0 +1,85 @@ +Texture2D SourceState; +RWTexture2D 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); +} diff --git a/Source/VoxelForgeLivingPlane/Private/VoxelForgeLivingPlaneActor.cpp b/Source/VoxelForgeLivingPlane/Private/VoxelForgeLivingPlaneActor.cpp new file mode 100644 index 0000000..b52e24e --- /dev/null +++ b/Source/VoxelForgeLivingPlane/Private/VoxelForgeLivingPlaneActor.cpp @@ -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, SourceState) + SHADER_PARAMETER_RDG_TEXTURE_UAV(RWTexture2D, 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(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(TEXT("LivingPlaneMesh")); + SetRootComponent(PlaneMesh); + PlaneMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision); + PlaneMesh->SetMobility(EComponentMobility::Movable); + static ConstructorHelpers::FObjectFinder 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(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(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 Steps; + Steps.Reserve(NumSteps); + const FTransform PlaneTransform = GetActorTransform(); + for (int32 Step = 0; Step < NumSteps; ++Step) + { + FLivingPlaneStepInput& Input = Steps.AddDefaulted_GetRef(); + Input.TimeSeconds = static_cast(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(LocalPosition.X * 0.01), + static_cast(LocalPosition.Y * 0.01), + static_cast(LocalPosition.Z * 0.01), + RadiusMeters); + Input.ObjectVelocityPush[ObjectIndex] = FVector4f( + static_cast(LocalVelocity.X * 0.01), + static_cast(LocalVelocity.Y * 0.01), + static_cast(LocalVelocity.Z * 0.01), + FMath::Max(Object.PushAccelerationMetersPerSecondSquared, 0.0f)); + } + } + + const int32 Resolution = GridSize; + const int32 InitialFieldIndex = CurrentFieldIndex; + const float Dt = static_cast(FixedStepSeconds); + const float Dx = PlaneLengthMeters / static_cast(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 ComputeShader(GetGlobalShaderMap(GMaxRHIFeatureLevel)); + int32 CurrentIndex = InitialFieldIndex; + for (const FLivingPlaneStepInput& StepInput : Steps) + { + const int32 TargetIndex = 1 - CurrentIndex; + FVFLivingPlaneStepCS::FParameters* PassParameters = + GraphBuilder.AllocParameters(); + 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(DeltaSeconds), 0.0); + const int32 StepsToRun = FMath::Min( + static_cast(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; +} diff --git a/Source/VoxelForgeLivingPlane/Private/VoxelForgeLivingPlaneModule.cpp b/Source/VoxelForgeLivingPlane/Private/VoxelForgeLivingPlaneModule.cpp new file mode 100644 index 0000000..4c33b67 --- /dev/null +++ b/Source/VoxelForgeLivingPlane/Private/VoxelForgeLivingPlaneModule.cpp @@ -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 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) diff --git a/Source/VoxelForgeLivingPlane/Public/VoxelForgeLivingPlaneActor.h b/Source/VoxelForgeLivingPlane/Public/VoxelForgeLivingPlaneActor.h new file mode 100644 index 0000000..d6de290 --- /dev/null +++ b/Source/VoxelForgeLivingPlane/Public/VoxelForgeLivingPlaneActor.h @@ -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 PlaneMesh; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Living Plane|Rendering") + TObjectPtr 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 ObjectInputs; + +private: + void CreateFieldTargets(); + void EnqueueSimulationSteps(int32 NumSteps); + void UpdateMaterialField(); + float GetStableStepSeconds() const; + + UPROPERTY(Transient) + TObjectPtr FieldTargets[2]; + + UPROPERTY(Transient) + TObjectPtr DynamicPlaneMaterial; + + int32 CurrentFieldIndex = 0; + int64 SimulationStepCount = 0; + double SimulationAccumulatorSeconds = 0.0; + double FixedStepSeconds = 0.0; +}; diff --git a/Source/VoxelForgeLivingPlane/VoxelForgeLivingPlane.Build.cs b/Source/VoxelForgeLivingPlane/VoxelForgeLivingPlane.Build.cs new file mode 100644 index 0000000..1695d79 --- /dev/null +++ b/Source/VoxelForgeLivingPlane/VoxelForgeLivingPlane.Build.cs @@ -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" + }); + } +} diff --git a/VoxelForge.uplugin b/VoxelForge.uplugin index b2bf8f8..317b7f5 100644 --- a/VoxelForge.uplugin +++ b/VoxelForge.uplugin @@ -31,6 +31,11 @@ "Type": "Runtime", "LoadingPhase": "Default" }, + { + "Name": "VoxelForgeLivingPlane", + "Type": "Runtime", + "LoadingPhase": "Default" + }, { "Name": "VoxelForgeEditor", "Type": "Editor",