diff --git a/.gitignore b/.gitignore index 90316d2..2388acb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,12 +9,20 @@ !/Source/ !/Source/** +# Plugin runtime shader source. +!/Shaders/ +!/Shaders/** + # Portable CPU generator and D3D11 viewer. !/Standalone/ !/Standalone/** /Standalone/Build/ /Standalone/bin/ +# Research and prototype experiments. +!/Experiments/ +!/Experiments/** + # The guarded single-entry test harness is source, not generated output. !/Tools/ !/Tools/** diff --git a/Experiments/LivingPlane/LivingPlane.cpp b/Experiments/LivingPlane/LivingPlane.cpp new file mode 100644 index 0000000..163773e --- /dev/null +++ b/Experiments/LivingPlane/LivingPlane.cpp @@ -0,0 +1,1428 @@ +#define GL_GLEXT_PROTOTYPES 1 + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + constexpr double PlaneLengthMeters=12.0; + constexpr double WaveSpeedMetersPerSecond=4.5; + constexpr double DampingPerSecond=1.35; + constexpr double FoundationStiffnessPerSecondSquared=28.0; + constexpr double SphereRadiusMeters=0.43; + constexpr double SphereSigmaMeters=0.58; + constexpr double SpherePushMetersPerSecondSquared=36.0; + constexpr GLuint TimerQueryRingSize=64; + constexpr double Pi=3.14159265358979323846; + + struct FBodyState + { + double X=0.0,Y=0.0,Z=0.0,Force=0.0; + }; + + struct FOptions + { + std::string Device="auto"; + std::filesystem::path Output="Saved/Linux/l164/living-plane"; + std::string Scene="wake"; + int Size=128; + int Steps=360; + int CaptureEvery=1; + bool TimerQueries=true; + bool Breathing=false; + bool BreathingConfigured=false; + bool Imprint=false; + bool ImprintConfigured=false; + double BreathingStrength=4.8; + double ImprintCaptureMetersPerSecond=0.15; + double ImprintRecoverySeconds=6.0; + double ImprintMaxDepthMeters=0.34; + }; + + struct FDeviceCandidate + { + EGLDeviceEXT Handle=EGL_NO_DEVICE_EXT; + std::string Vendor; + std::string Extensions; + std::string RenderNode; + int Score=0; + }; + + struct FContext + { + EGLDisplay Display=EGL_NO_DISPLAY; + EGLSurface Surface=EGL_NO_SURFACE; + EGLContext Context=EGL_NO_CONTEXT; + EGLDeviceEXT Device=EGL_NO_DEVICE_EXT; + std::string DeviceLabel="surfaceless Mesa"; + std::string RenderNode; + std::string ExecutionDevice; + std::string Error; + + ~FContext() { Reset(); } + + void Reset() + { + if(Display!=EGL_NO_DISPLAY) + { + eglMakeCurrent(Display,EGL_NO_SURFACE,EGL_NO_SURFACE,EGL_NO_CONTEXT); + if(Context!=EGL_NO_CONTEXT)eglDestroyContext(Display,Context); + if(Surface!=EGL_NO_SURFACE)eglDestroySurface(Display,Surface); + eglTerminate(Display); + } + Display=EGL_NO_DISPLAY; + Surface=EGL_NO_SURFACE; + Context=EGL_NO_CONTEXT; + } + + bool TryDisplay(EGLDisplay Candidate,const std::string& Label) + { + Reset(); + if(Candidate==EGL_NO_DISPLAY) + {Error="eglGetPlatformDisplay returned EGL_NO_DISPLAY";return false;} + Display=Candidate; + EGLint Major=0,Minor=0; + if(eglInitialize(Display,&Major,&Minor)!=EGL_TRUE) + {Error="eglInitialize failed: "+EglError();Reset();return false;} + if(eglBindAPI(EGL_OPENGL_API)!=EGL_TRUE) + {Error="eglBindAPI(EGL_OPENGL_API) failed: "+EglError();Reset();return false;} + + const EGLint ConfigAttributes[]={EGL_SURFACE_TYPE,EGL_PBUFFER_BIT, + EGL_RENDERABLE_TYPE,EGL_OPENGL_BIT,EGL_RED_SIZE,8,EGL_GREEN_SIZE,8, + EGL_BLUE_SIZE,8,EGL_ALPHA_SIZE,8,EGL_DEPTH_SIZE,24,EGL_NONE}; + EGLConfig Config{}; + EGLint ConfigCount=0; + if(eglChooseConfig(Display,ConfigAttributes,&Config,1,&ConfigCount)!=EGL_TRUE + ||ConfigCount==0) + {Error="no desktop OpenGL pbuffer config: "+EglError();Reset();return false;} + + const EGLint SurfaceAttributes[]={EGL_WIDTH,16,EGL_HEIGHT,16,EGL_NONE}; + Surface=eglCreatePbufferSurface(Display,Config,SurfaceAttributes); + const EGLint ContextAttributes[]={EGL_CONTEXT_MAJOR_VERSION_KHR,4, + EGL_CONTEXT_MINOR_VERSION_KHR,3,EGL_CONTEXT_OPENGL_PROFILE_MASK_KHR, + EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT_KHR,EGL_NONE}; + Context=eglCreateContext(Display,Config,EGL_NO_CONTEXT,ContextAttributes); + if(Surface==EGL_NO_SURFACE||Context==EGL_NO_CONTEXT + ||eglMakeCurrent(Display,Surface,Surface,Context)!=EGL_TRUE) + {Error="OpenGL 4.3 context creation failed: "+EglError();Reset();return false;} + + GLint GlMajor=0,GlMinor=0; + glGetIntegerv(GL_MAJOR_VERSION,&GlMajor); + glGetIntegerv(GL_MINOR_VERSION,&GlMinor); + if(GlMajor<4||(GlMajor==4&&GlMinor<3)) + {Error="driver returned OpenGL "+std::to_string(GlMajor)+"."+ + std::to_string(GlMinor)+", need 4.3";Reset();return false;} + + const auto* Renderer=reinterpret_cast(glGetString(GL_RENDERER)); + if(!Renderer) + {Error="glGetString(GL_RENDERER) returned null";Reset();return false;} + if(IsSoftwareRenderer(Renderer))ExecutionDevice="cpu"; + else ExecutionDevice="gpu"; + DeviceLabel=Label; + return true; + } + + static std::string EglError() + { + char Text[24]{}; + std::snprintf(Text,sizeof(Text),"0x%04x",eglGetError()); + return Text; + } + + static std::string Lower(std::string_view Input) + { + std::string Result(Input); + std::transform(Result.begin(),Result.end(),Result.begin(),[](unsigned char C) + {return static_cast(std::tolower(C));}); + return Result; + } + + static bool IsSoftwareRenderer(std::string_view Renderer) + { + const std::string LowerRenderer=Lower(Renderer); + return LowerRenderer.find("llvmpipe")!=std::string::npos + ||LowerRenderer.find("softpipe")!=std::string::npos + ||LowerRenderer.find("software rasterizer")!=std::string::npos; + } + }; + + std::string JsonEscape(std::string_view Text) + { + std::string Result; + for(const unsigned char C:Text) + { + switch(C) + { + case '"':Result+="\\\"";break; + case '\\':Result+="\\\\";break; + case '\n':Result+="\\n";break; + case '\r':Result+="\\r";break; + case '\t':Result+="\\t";break; + default:if(C<0x20)Result+=' ';else Result+=static_cast(C);break; + } + } + return Result; + } + + bool HasExtension(std::string_view Extensions,std::string_view Wanted) + { + std::size_t At=0; + while(At FindHardwareDevices(std::string& Error) + { + using TQueryDevices=PFNEGLQUERYDEVICESEXTPROC; + using TQueryDeviceString=PFNEGLQUERYDEVICESTRINGEXTPROC; + using TGetPlatformDisplay=PFNEGLGETPLATFORMDISPLAYEXTPROC; + auto QueryDevices=reinterpret_cast(eglGetProcAddress("eglQueryDevicesEXT")); + auto QueryDeviceString=reinterpret_cast( + eglGetProcAddress("eglQueryDeviceStringEXT")); + auto GetPlatformDisplay=reinterpret_cast( + eglGetProcAddress("eglGetPlatformDisplayEXT")); + const auto* ClientExtensions=reinterpret_cast( + eglQueryString(EGL_NO_DISPLAY,EGL_EXTENSIONS)); + if(!QueryDevices||!QueryDeviceString||!GetPlatformDisplay||!ClientExtensions + ||!HasExtension(ClientExtensions,"EGL_EXT_device_enumeration") + ||!HasExtension(ClientExtensions,"EGL_EXT_platform_device")) + {Error="EGL device enumeration/platform-device extensions are unavailable";return {};} + + EGLint Count=0; + if(QueryDevices(0,nullptr,&Count)!=EGL_TRUE||Count<=0) + {Error="eglQueryDevicesEXT found no EGL devices";return {};} + std::vector Devices(static_cast(Count)); + if(QueryDevices(Count,Devices.data(),&Count)!=EGL_TRUE) + {Error="eglQueryDevicesEXT failed while listing devices";return {};} + + std::vector Candidates; + for(EGLint I=0;I(I)]; + const auto* Vendor=QueryDeviceString(Candidate.Handle,EGL_VENDOR); + const auto* Extensions=QueryDeviceString(Candidate.Handle,EGL_EXTENSIONS); + if(Vendor)Candidate.Vendor=reinterpret_cast(Vendor); + if(Extensions)Candidate.Extensions=reinterpret_cast(Extensions); + if(!HasExtension(Candidate.Extensions,"EGL_EXT_device_drm_render_node"))continue; + const auto* Node=QueryDeviceString(Candidate.Handle,EGL_DRM_RENDER_NODE_FILE_EXT); + if(Node)Candidate.RenderNode=reinterpret_cast(Node); + if(Candidate.RenderNode.empty() + ||access(Candidate.RenderNode.c_str(),R_OK|W_OK)!=0)continue; + + const std::string VendorLower=FContext::Lower(Candidate.Vendor); + const bool IsIntel=VendorLower.find("intel")!=std::string::npos + ||Candidate.RenderNode.find("renderD128")!=std::string::npos; + // This host's integrated Alder Lake device is exposed by Mesa as Intel + // on renderD128. EGL does not expose Vulkan's physical-device-type enum. + Candidate.Score=IsIntel?100:50; + Candidates.push_back(std::move(Candidate)); + } + std::stable_sort(Candidates.begin(),Candidates.end(),[](const auto& A,const auto& B) + {return A.Score>B.Score;}); + if(Candidates.empty()&&Error.empty()) + Error="no accessible EGL DRM render node was found"; + return Candidates; + } + + bool CreateContext(const std::string& Request,FContext& Out) + { + if(Request=="cpu") + { + setenv("LIBGL_ALWAYS_SOFTWARE","1",1); + const auto GetPlatformDisplay=reinterpret_cast( + eglGetProcAddress("eglGetPlatformDisplayEXT")); +#ifdef EGL_PLATFORM_SURFACELESS_MESA + EGLDisplay Display=EGL_NO_DISPLAY; + if(GetPlatformDisplay)Display=GetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA, + EGL_DEFAULT_DISPLAY,nullptr); + if(Display==EGL_NO_DISPLAY)Display=eglGetDisplay(EGL_DEFAULT_DISPLAY); + if(!Out.TryDisplay(Display,"Mesa surfaceless (LIBGL_ALWAYS_SOFTWARE=1)")) + {std::cerr<<"[LivingPlane] CPU context failed: "<( + eglGetProcAddress("eglGetPlatformDisplayEXT")); + for(const auto& Candidate:Candidates) + { + // Automatic selection is intentionally limited to this host's + // integrated Intel target; a discrete-only host falls back to CPU. + if(Request=="auto"&&Candidate.Score<100)continue; + EGLDisplay Display=GetPlatformDisplay(EGL_PLATFORM_DEVICE_EXT, + Candidate.Handle,nullptr); + const std::string Label=Candidate.Vendor+" "+Candidate.RenderNode; + if(Out.TryDisplay(Display,Label)) + { + Out.Device=Candidate.Handle; + Out.RenderNode=Candidate.RenderNode; + if(Out.ExecutionDevice=="gpu")return true; + Out.Reset(); + Out.Error="hardware EGL device produced a software renderer"; + } + } + if(Request=="gpu") + {std::cerr<<"[LivingPlane] --device=gpu context failed: "<( + eglGetProcAddress("eglGetPlatformDisplayEXT")); + EGLDisplay SoftwareDisplay=EGL_NO_DISPLAY; +#ifdef EGL_PLATFORM_SURFACELESS_MESA + if(GetPlatformDisplay)SoftwareDisplay=GetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA, + EGL_DEFAULT_DISPLAY,nullptr); +#endif + if(SoftwareDisplay==EGL_NO_DISPLAY)SoftwareDisplay=eglGetDisplay(EGL_DEFAULT_DISPLAY); + if(!Out.TryDisplay(SoftwareDisplay,"Mesa surfaceless (LIBGL_ALWAYS_SOFTWARE=1)")) + {std::cerr<<"[LivingPlane] llvmpipe fallback failed: "<(std::max(Length,1)),0); + glGetShaderInfoLog(Shader,Length,nullptr,Log.data()); + std::cerr<<"[LivingPlane] shader compilation failed:\n"< Shaders) + { + const GLuint Program=glCreateProgram(); + for(const GLuint Shader:Shaders)glAttachShader(Program,Shader); + glLinkProgram(Program); + GLint Success=GL_FALSE; + glGetProgramiv(Program,GL_LINK_STATUS,&Success); + if(Success==GL_FALSE) + { + GLint Length=0; + glGetProgramiv(Program,GL_INFO_LOG_LENGTH,&Length); + std::string Log(static_cast(std::max(Length,1)),0); + glGetProgramInfoLog(Program,Length,nullptr,Log.data()); + std::cerr<<"[LivingPlane] program link failed:\n"<=uGridSize||p.y>=uGridSize)return; + ivec2 lo=ivec2(0); + ivec2 hi=ivec2(uGridSize-1); + vec4 state=imageLoad(uSource,p); + float h=state.r; + float v=state.g; + float imprint=state.b; + float l=imageLoad(uSource,ivec2(max(p.x-1,lo.x),p.y)).r; + float r=imageLoad(uSource,ivec2(min(p.x+1,hi.x),p.y)).r; + float d=imageLoad(uSource,ivec2(p.x,max(p.y-1,lo.y))).r; + float u=imageLoad(uSource,ivec2(p.x,min(p.y+1,hi.y))).r; + float laplacian=(l+r+d+u-4.0*h)/(uDx*uDx); + vec2 world=(vec2(p)+vec2(0.5))*uDx-vec2(uPlaneLength*0.5); + float sigma2=max(uForceSigma*uForceSigma,1.0e-8); + float interaction=0.0; + float contact=0.0; + for(int i=0;i<2;++i) + { + vec2 offset=world-uForcePositions[i]; + float weight=exp(-dot(offset,offset)/(2.0*sigma2)); + interaction-=uForceStrengths[i]*weight; + if(uForceStrengths[i]>0.0)contact=max(contact,weight); + } + float breathing=0.0; + if(uBreathing!=0) + { + float swell=sin(world.x*0.48-uTime*0.22) + *cos(world.y*0.42+uTime*0.16); + breathing=uBreathingStrength*swell*sin(uTime*0.48); + } + float nextImprint=imprint; + if(uImprint!=0) + { + nextImprint=clamp(imprint*exp(-uDt/max(uImprintRecoverySeconds,1.0e-4)) + -contact*uImprintCaptureRate*uDt,-uImprintMaxDepth,0.0); + } + float foundationTarget=uImprint!=0?imprint:0.0; + float acceleration=uWaveSpeed*uWaveSpeed*laplacian + -uFoundationStiffness*(h-foundationTarget) + +interaction+breathing; + float nextVelocity=(v+acceleration*uDt)*exp(-uDamping*uDt); + float nextHeight=h+nextVelocity*uDt; + imageStore(uTarget,p,vec4(nextHeight,nextVelocity,nextImprint,0.0)); +})GLSL"; + const GLuint Shader=CompileShader(GL_COMPUTE_SHADER,Source); + if(!Shader)return 0; + const std::array Shaders={Shader}; + const GLuint Program=LinkProgram(Shaders); + glDeleteShader(Shader); + return Program; + } + + GLuint MakeRenderProgram() + { + static constexpr char VertexSource[]=R"GLSL(#version 430 core +layout(location=0) in vec3 aPosition; +layout(location=1) in vec2 aUv; +layout(location=2) in vec3 aNormal; +uniform sampler2D uField; +uniform int uGridSize; +uniform float uPlaneLength; +uniform int uRenderSphere; +uniform vec3 uSphereCenter; +uniform float uSphereRadius; +uniform int uSphereMaterial; +uniform mat4 uViewProjection; +out vec3 vWorld; +out vec3 vNormal; +out float vHeight; +flat out int vSphereMaterial; +float TerrainHeight(vec2 p) +{ + return 0.105*sin(p.x*0.62+p.y*0.13)*cos(p.y*0.57) + +0.045*sin(p.x*1.04-p.y*0.78); +} +void main() +{ + vec3 world; + vec3 normal; + float height; + if(uRenderSphere==0) + { + ivec2 texel=clamp(ivec2(round(aUv*float(uGridSize-1))),ivec2(0), + ivec2(uGridSize-1)); + ivec2 left=ivec2(max(texel.x-1,0),texel.y); + ivec2 right=ivec2(min(texel.x+1,uGridSize-1),texel.y); + ivec2 down=ivec2(texel.x,max(texel.y-1,0)); + ivec2 up=ivec2(texel.x,min(texel.y+1,uGridSize-1)); + float sampleDx=uPlaneLength/float(uGridSize); + vec2 leftWorld=(vec2(left)+vec2(0.5))*sampleDx-vec2(uPlaneLength*0.5); + vec2 rightWorld=(vec2(right)+vec2(0.5))*sampleDx-vec2(uPlaneLength*0.5); + vec2 downWorld=(vec2(down)+vec2(0.5))*sampleDx-vec2(uPlaneLength*0.5); + vec2 upWorld=(vec2(up)+vec2(0.5))*sampleDx-vec2(uPlaneLength*0.5); + float leftHeight=texelFetch(uField,left,0).r+TerrainHeight(leftWorld); + float rightHeight=texelFetch(uField,right,0).r+TerrainHeight(rightWorld); + float downHeight=texelFetch(uField,down,0).r+TerrainHeight(downWorld); + float upHeight=texelFetch(uField,up,0).r+TerrainHeight(upWorld); + height=texelFetch(uField,texel,0).r+TerrainHeight( + (vec2(texel)+vec2(0.5))*sampleDx-vec2(uPlaneLength*0.5)); + float dx=max(float(right.x-left.x)*uPlaneLength/float(uGridSize),1.0e-6); + float dy=max(float(up.y-down.y)*uPlaneLength/float(uGridSize),1.0e-6); + float dhdx=(rightHeight-leftHeight)/dx; + float dhdy=(upHeight-downHeight)/dy; + world=vec3((aUv-vec2(0.5))*uPlaneLength,height); + normal=normalize(vec3(-dhdx,-dhdy,1.0)); + } + else + { + world=uSphereCenter+aPosition*uSphereRadius; + normal=normalize(aNormal); + height=world.z; + } + vWorld=world; + vNormal=normal; + vHeight=height; + vSphereMaterial=uRenderSphere==0?0:uSphereMaterial; + gl_Position=uViewProjection*vec4(world,1.0); +})GLSL"; + static constexpr char FragmentSource[]=R"GLSL(#version 430 core +in vec3 vWorld; +in vec3 vNormal; +in float vHeight; +flat in int vSphereMaterial; +uniform vec3 uCameraPosition; +out vec4 outColor; +void main() +{ + vec3 n=normalize(vNormal); + vec3 l=normalize(vec3(-0.62,-0.54,0.32)); + vec3 v=normalize(uCameraPosition-vWorld); + float diffuse=max(dot(n,l),0.0); + float slope=1.0-clamp(n.z,0.0,1.0); + float spec=pow(max(dot(reflect(-l,n),v),0.0),vSphereMaterial==0?72.0:38.0); + if(vSphereMaterial!=0) + { + vec3 base=vSphereMaterial==1?vec3(0.96,0.34,0.10):vec3(0.18,0.70,0.92); + float light=0.24+0.84*diffuse; + outColor=vec4(base*light+vec3(0.65,0.43,0.22)*spec,1.0); + } + else + { + float low= smoothstep(-0.46,-0.08,vHeight); + float high=smoothstep(0.02,0.32,vHeight); + vec3 base=mix(vec3(0.025,0.075,0.13),vec3(0.055,0.30,0.31),low); + base=mix(base,vec3(0.28,0.46,0.28),high*0.82); + float contourPhase=fract((vHeight+0.60)/0.075); + float contourDistance=min(contourPhase,1.0-contourPhase); + float contour=smoothstep(0.025,0.16,contourDistance); + base=mix(base*0.35,base,contour); + base+=vec3(0.20,0.22,0.12)*smoothstep(0.08,0.36,slope); + float light=0.34+0.98*diffuse; + outColor=vec4(base*light+vec3(0.30,0.52,0.48)*spec,1.0); + } +})GLSL"; + const GLuint VS=CompileShader(GL_VERTEX_SHADER,VertexSource); + const GLuint FS=CompileShader(GL_FRAGMENT_SHADER,FragmentSource); + if(!VS||!FS) + { + if(VS)glDeleteShader(VS); + if(FS)glDeleteShader(FS); + return 0; + } + const std::array Shaders={VS,FS}; + const GLuint Program=LinkProgram(Shaders); + glDeleteShader(VS); + glDeleteShader(FS); + return Program; + } + + struct FVec3 { double X=0.0,Y=0.0,Z=0.0; }; + + double TerrainHeight(double X,double Y) + { + return 0.105*std::sin(X*0.62+Y*0.13)*std::cos(Y*0.57) + +0.045*std::sin(X*1.04-Y*0.78); + } + + double Smooth01(double T) + { + T=std::clamp(T,0.0,1.0); + return T*T*(3.0-2.0*T); + } + + std::array GetBodiesAt(double Time) + { + constexpr double RollEnd=1.62; + constexpr double RestEnd=2.55; + constexpr double LiftEnd=2.95; + constexpr double SecondaryStart=2.70; + constexpr double SecondaryEnd=5.55; + + FBodyState Primary; + Primary.Y=-0.15; + if(Time=SecondaryStart&&Time<=SecondaryEnd) + Secondary.Force=SpherePushMetersPerSecondSquared*0.78; + return {Primary,Secondary}; + } + + FVec3 Sub(FVec3 A,FVec3 B){return {A.X-B.X,A.Y-B.Y,A.Z-B.Z};} + FVec3 Cross(FVec3 A,FVec3 B) + {return {A.Y*B.Z-A.Z*B.Y,A.Z*B.X-A.X*B.Z,A.X*B.Y-A.Y*B.X};} + double Dot(FVec3 A,FVec3 B){return A.X*B.X+A.Y*B.Y+A.Z*B.Z;} + FVec3 Normalize(FVec3 V) + { + const double Length=std::sqrt(Dot(V,V)); + return Length>1.0e-12?FVec3{V.X/Length,V.Y/Length,V.Z/Length}:FVec3{}; + } + + std::array MakeViewProjection(int Width,int Height,FVec3 Camera,FVec3 Target) + { + const FVec3 Forward=Normalize(Sub(Target,Camera)); + const FVec3 Side=Normalize(Cross(Forward,{0.0,0.0,1.0})); + const FVec3 Up=Cross(Side,Forward); + std::array View{}; + View[0]=static_cast(Side.X);View[4]=static_cast(Side.Y); + View[8]=static_cast(Side.Z);View[12]=static_cast(-Dot(Side,Camera)); + View[1]=static_cast(Up.X);View[5]=static_cast(Up.Y); + View[9]=static_cast(Up.Z);View[13]=static_cast(-Dot(Up,Camera)); + View[2]=static_cast(-Forward.X);View[6]=static_cast(-Forward.Y); + View[10]=static_cast(-Forward.Z);View[14]=static_cast(Dot(Forward,Camera)); + View[15]=1.0f; + + const double Near=0.1,Far=60.0; + const double F=1.0/std::tan(39.0*Pi/360.0); + const double Aspect=static_cast(Width)/static_cast(Height); + std::array Projection{}; + Projection[0]=static_cast(F/Aspect); + Projection[5]=static_cast(F); + Projection[10]=static_cast((Far+Near)/(Near-Far)); + Projection[11]=-1.0f; + Projection[14]=static_cast((2.0*Far*Near)/(Near-Far)); + + std::array Result{}; + for(int Column=0;Column<4;++Column)for(int Row=0;Row<4;++Row) + for(int K=0;K<4;++K) + Result[static_cast(Column*4+Row)]+= + Projection[static_cast(K*4+Row)] + *View[static_cast(Column*4+K)]; + return Result; + } + + struct FGridVertex { float U=0.0f,V=0.0f; }; + struct FSphereVertex { float Position[3]{},Normal[3]{}; }; + + void MakeGrid(int Segments,std::vector& Vertices, + std::vector& Indices) + { + const int Side=Segments+1; + Vertices.reserve(static_cast(Side)*Side); + for(int Y=0;Y<=Segments;++Y)for(int X=0;X<=Segments;++X) + Vertices.push_back({static_cast(X)/Segments,static_cast(Y)/Segments}); + Indices.reserve(static_cast(Segments)*Segments*6); + for(int Y=0;Y(Y*Side+X); + const std::uint32_t B=A+1; + const std::uint32_t D=A+static_cast(Side); + const std::uint32_t C=D+1; + Indices.insert(Indices.end(),{A,B,C,A,C,D}); + } + } + + void MakeSphere(std::vector& Vertices, + std::vector& Indices) + { + constexpr int Rings=24,Segments=40; + for(int Ring=0;Ring<=Rings;++Ring) + { + const double Theta=Pi*static_cast(Ring)/Rings; + for(int Segment=0;Segment<=Segments;++Segment) + { + const double Phi=2.0*Pi*static_cast(Segment)/Segments; + const float X=static_cast(std::sin(Theta)*std::cos(Phi)); + const float Y=static_cast(std::sin(Theta)*std::sin(Phi)); + const float Z=static_cast(std::cos(Theta)); + Vertices.push_back({{X,Y,Z},{X,Y,Z}}); + } + } + const int Side=Segments+1; + for(int Ring=0;Ring(Ring*Side+Segment); + const std::uint32_t B=A+static_cast(Side); + const std::uint32_t C=B+1; + const std::uint32_t D=A+1; + Indices.insert(Indices.end(),{A,B,C,A,C,D}); + } + } + + bool WritePng(const std::filesystem::path& Path,int Width,int Height, + const std::vector& Pixels) + { + std::error_code Error; + std::filesystem::create_directories(Path.parent_path(),Error); + if(Error)return false; + FILE* File=std::fopen(Path.c_str(),"wb"); + if(!File)return false; + png_structp Png=png_create_write_struct(PNG_LIBPNG_VER_STRING,nullptr,nullptr,nullptr); + png_infop Info=Png?png_create_info_struct(Png):nullptr; + if(!Png||!Info) + {if(Png)png_destroy_write_struct(&Png,nullptr);std::fclose(File);return false;} + if(setjmp(png_jmpbuf(Png))) + {png_destroy_write_struct(&Png,&Info);std::fclose(File);return false;} + png_init_io(Png,File); + png_set_IHDR(Png,Info,static_cast(Width),static_cast(Height), + 8,PNG_COLOR_TYPE_RGBA,PNG_INTERLACE_NONE,PNG_COMPRESSION_TYPE_DEFAULT, + PNG_FILTER_TYPE_DEFAULT); + png_write_info(Png,Info); + const std::size_t Stride=static_cast(Width)*4; + for(int Y=Height-1;Y>=0;--Y) + png_write_row(Png,const_cast(Pixels.data()+static_cast(Y)*Stride)); + png_write_end(Png,nullptr); + png_destroy_write_struct(&Png,&Info); + return std::fclose(File)==0; + } + + struct FRenderResources + { + GLuint Program=0,Framebuffer=0,ColorTexture=0,DepthBuffer=0; + GLuint GridVao=0,GridVbo=0,GridEbo=0,SphereVao=0,SphereVbo=0,SphereEbo=0; + GLsizei GridIndexCount=0,SphereIndexCount=0; + int Width=0,Height=0,GridSize=0; + std::array ViewProjection{}; + FVec3 Camera{}; + + void Destroy() + { + if(SphereEbo)glDeleteBuffers(1,&SphereEbo); + if(SphereVbo)glDeleteBuffers(1,&SphereVbo); + if(SphereVao)glDeleteVertexArrays(1,&SphereVao); + if(GridEbo)glDeleteBuffers(1,&GridEbo); + if(GridVbo)glDeleteBuffers(1,&GridVbo); + if(GridVao)glDeleteVertexArrays(1,&GridVao); + if(DepthBuffer)glDeleteRenderbuffers(1,&DepthBuffer); + if(ColorTexture)glDeleteTextures(1,&ColorTexture); + if(Framebuffer)glDeleteFramebuffers(1,&Framebuffer); + if(Program)glDeleteProgram(Program); + *this=FRenderResources{}; + } + + bool Initialize(int Size,int InWidth,int InHeight) + { + Width=InWidth;Height=InHeight;GridSize=Size; + Program=MakeRenderProgram(); + if(!Program)return false; + glGenTextures(1,&ColorTexture); + glBindTexture(GL_TEXTURE_2D,ColorTexture); + glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,Width,Height,0,GL_RGBA,GL_UNSIGNED_BYTE,nullptr); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR); + glGenRenderbuffers(1,&DepthBuffer); + glBindRenderbuffer(GL_RENDERBUFFER,DepthBuffer); + glRenderbufferStorage(GL_RENDERBUFFER,GL_DEPTH_COMPONENT24,Width,Height); + glGenFramebuffers(1,&Framebuffer); + glBindFramebuffer(GL_FRAMEBUFFER,Framebuffer); + glFramebufferTexture2D(GL_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,ColorTexture,0); + glFramebufferRenderbuffer(GL_FRAMEBUFFER,GL_DEPTH_ATTACHMENT,GL_RENDERBUFFER,DepthBuffer); + if(glCheckFramebufferStatus(GL_FRAMEBUFFER)!=GL_FRAMEBUFFER_COMPLETE) + {std::cerr<<"[LivingPlane] offscreen framebuffer is incomplete\n";return false;} + + const int Segments=std::min(Size,512); + std::vector GridVertices; + std::vector GridIndices; + MakeGrid(Segments,GridVertices,GridIndices); + GridIndexCount=static_cast(GridIndices.size()); + glGenVertexArrays(1,&GridVao);glBindVertexArray(GridVao); + glGenBuffers(1,&GridVbo);glBindBuffer(GL_ARRAY_BUFFER,GridVbo); + glBufferData(GL_ARRAY_BUFFER,static_cast(GridVertices.size()*sizeof(FGridVertex)), + GridVertices.data(),GL_STATIC_DRAW); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1,2,GL_FLOAT,GL_FALSE,sizeof(FGridVertex),nullptr); + glGenBuffers(1,&GridEbo);glBindBuffer(GL_ELEMENT_ARRAY_BUFFER,GridEbo); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, + static_cast(GridIndices.size()*sizeof(std::uint32_t)), + GridIndices.data(),GL_STATIC_DRAW); + + std::vector SphereVertices; + std::vector SphereIndices; + MakeSphere(SphereVertices,SphereIndices); + SphereIndexCount=static_cast(SphereIndices.size()); + glGenVertexArrays(1,&SphereVao);glBindVertexArray(SphereVao); + glGenBuffers(1,&SphereVbo);glBindBuffer(GL_ARRAY_BUFFER,SphereVbo); + glBufferData(GL_ARRAY_BUFFER, + static_cast(SphereVertices.size()*sizeof(FSphereVertex)), + SphereVertices.data(),GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0,3,GL_FLOAT,GL_FALSE,sizeof(FSphereVertex), + reinterpret_cast(offsetof(FSphereVertex,Position))); + glEnableVertexAttribArray(2); + glVertexAttribPointer(2,3,GL_FLOAT,GL_FALSE,sizeof(FSphereVertex), + reinterpret_cast(offsetof(FSphereVertex,Normal))); + glGenBuffers(1,&SphereEbo);glBindBuffer(GL_ELEMENT_ARRAY_BUFFER,SphereEbo); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, + static_cast(SphereIndices.size()*sizeof(std::uint32_t)), + SphereIndices.data(),GL_STATIC_DRAW); + + glBindVertexArray(0); + return glGetError()==GL_NO_ERROR; + } + + bool Render(GLuint FieldTexture,const std::filesystem::path& Output, + const FBodyState& Primary,const FBodyState& Secondary) + { + const FVec3 Target={Primary.X,Primary.Y,0.0}; + Camera={Primary.X+2.65,Primary.Y-4.45,3.65}; + ViewProjection=MakeViewProjection(Width,Height,Camera,Target); + glBindFramebuffer(GL_FRAMEBUFFER,Framebuffer); + glViewport(0,0,Width,Height); + glDisable(GL_CULL_FACE); + glEnable(GL_DEPTH_TEST); + glDepthFunc(GL_LEQUAL); + glClearColor(0.018f,0.055f,0.095f,1.0f); + glClearDepth(1.0); + glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT); + glUseProgram(Program); + glUniform1i(glGetUniformLocation(Program,"uField"),0); + glUniform1i(glGetUniformLocation(Program,"uGridSize"),GridSize); + glUniform1f(glGetUniformLocation(Program,"uPlaneLength"), + static_cast(PlaneLengthMeters)); + glUniformMatrix4fv(glGetUniformLocation(Program,"uViewProjection"),1,GL_FALSE, + ViewProjection.data()); + glUniform3f(glGetUniformLocation(Program,"uCameraPosition"), + static_cast(Camera.X),static_cast(Camera.Y),static_cast(Camera.Z)); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D,FieldTexture); + glUniform1i(glGetUniformLocation(Program,"uRenderSphere"),0); + glBindVertexArray(GridVao); + glDrawElements(GL_TRIANGLES,GridIndexCount,GL_UNSIGNED_INT,nullptr); + glUniform1i(glGetUniformLocation(Program,"uRenderSphere"),1); + glUniform1f(glGetUniformLocation(Program,"uSphereRadius"), + static_cast(SphereRadiusMeters)); + glBindVertexArray(SphereVao); + glUniform1i(glGetUniformLocation(Program,"uSphereMaterial"),1); + glUniform3f(glGetUniformLocation(Program,"uSphereCenter"), + static_cast(Primary.X),static_cast(Primary.Y), + static_cast(Primary.Z)); + glDrawElements(GL_TRIANGLES,SphereIndexCount,GL_UNSIGNED_INT,nullptr); + glUniform1i(glGetUniformLocation(Program,"uSphereMaterial"),2); + glUniform3f(glGetUniformLocation(Program,"uSphereCenter"), + static_cast(Secondary.X),static_cast(Secondary.Y), + static_cast(Secondary.Z)); + glDrawElements(GL_TRIANGLES,SphereIndexCount,GL_UNSIGNED_INT,nullptr); + glFinish(); + std::vector Pixels(static_cast(Width)*Height*4); + glPixelStorei(GL_PACK_ALIGNMENT,1); + glReadPixels(0,0,Width,Height,GL_RGBA,GL_UNSIGNED_BYTE,Pixels.data()); + if(glGetError()!=GL_NO_ERROR||!WritePng(Output,Width,Height,Pixels)) + {std::cerr<<"[LivingPlane] frame render/write failed: "<::min() + ||Parsed>std::numeric_limits::max())return false; + Value=static_cast(Parsed); + return true; + } + + bool ParseDouble(std::string_view Text,double& Value) + { + if(Text.empty())return false; + char* End=nullptr; + errno=0; + const std::string Copy(Text); + const double Parsed=std::strtod(Copy.c_str(),&End); + if(errno||!End||*End!='\0'||!std::isfinite(Parsed))return false; + Value=Parsed; + return true; + } + + bool ParseOptions(int Argc,char** Argv,FOptions& Options) + { + for(int I=1;Ibool + { + if(Arg.starts_with(Key)&&Arg.size()>Key.size()&&Arg[Key.size()]=='=') + {Value=Arg.substr(Key.size()+1);return true;} + return false; + }; + std::string_view Value; + if(Arg=="--help"||Arg=="-h") + { + std::cout<<"Usage: living-plane [--device=auto|gpu|cpu] [--scene=wake|alive] " + "[--size=N] [--steps=N] [--out=DIR] [--capture-every=N]\n" + " [--queries=on|off] [--breathing=on|off] [--imprint=on|off]\n" + " [--breathing-strength=MPS2] [--imprint-capture=MPS] " + "[--imprint-recovery=SECONDS] [--imprint-depth=M]\n" + " --capture-every=0 writes only timing.json. --queries=off measures " + "batched dispatch wall time without per-step query overhead.\n"; + std::exit(0); + } + else if(Take("--device",Value))Options.Device=Value; + else if(Take("--scene",Value))Options.Scene=Value; + else if(Take("--size",Value)) + {if(!ParseInt(Value,Options.Size)){std::cerr<<"invalid --size\n";return false;}} + else if(Take("--steps",Value)) + {if(!ParseInt(Value,Options.Steps)){std::cerr<<"invalid --steps\n";return false;}} + else if(Take("--capture-every",Value)) + {if(!ParseInt(Value,Options.CaptureEvery)){std::cerr<<"invalid --capture-every\n";return false;}} + else if(Take("--out",Value))Options.Output=Value; + else if(Take("--queries",Value)) + { + if(Value=="on")Options.TimerQueries=true; + else if(Value=="off")Options.TimerQueries=false; + else {std::cerr<<"--queries must be on or off\n";return false;} + } + else if(Take("--breathing",Value)) + { + if(Value=="on")Options.Breathing=true; + else if(Value=="off")Options.Breathing=false; + else {std::cerr<<"--breathing must be on or off\n";return false;} + Options.BreathingConfigured=true; + } + else if(Take("--imprint",Value)) + { + if(Value=="on")Options.Imprint=true; + else if(Value=="off")Options.Imprint=false; + else {std::cerr<<"--imprint must be on or off\n";return false;} + Options.ImprintConfigured=true; + } + else if(Take("--breathing-strength",Value)) + { + if(!ParseDouble(Value,Options.BreathingStrength)) + {std::cerr<<"invalid --breathing-strength\n";return false;} + } + else if(Take("--imprint-capture",Value)) + { + if(!ParseDouble(Value,Options.ImprintCaptureMetersPerSecond)) + {std::cerr<<"invalid --imprint-capture\n";return false;} + } + else if(Take("--imprint-recovery",Value)) + { + if(!ParseDouble(Value,Options.ImprintRecoverySeconds)) + {std::cerr<<"invalid --imprint-recovery\n";return false;} + } + else if(Take("--imprint-depth",Value)) + { + if(!ParseDouble(Value,Options.ImprintMaxDepthMeters)) + {std::cerr<<"invalid --imprint-depth\n";return false;} + } + else {std::cerr<<"unknown option: "<4096) + {std::cerr<<"--size must be in [16,4096]\n";return false;} + if(Options.Steps<1||Options.Steps>100000) + {std::cerr<<"--steps must be in [1,100000]\n";return false;} + if(Options.CaptureEvery<0||Options.CaptureEvery>100000) + {std::cerr<<"--capture-every must be in [0,100000]\n";return false;} + if(Options.BreathingStrength<0.0||Options.BreathingStrength>30.0 + ||Options.ImprintCaptureMetersPerSecond<0.0 + ||Options.ImprintCaptureMetersPerSecond>1.0 + ||Options.ImprintRecoverySeconds<0.1||Options.ImprintRecoverySeconds>60.0 + ||Options.ImprintMaxDepthMeters<0.0||Options.ImprintMaxDepthMeters>1.0) + {std::cerr<<"alive-mode parameters are outside their supported ranges\n";return false;} + return true; + } + + double Quantile(std::vector Values,double Q) + { + if(Values.empty())return 0.0; + std::sort(Values.begin(),Values.end()); + const std::size_t I=static_cast(std::ceil(Q*Values.size())-1.0); + 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, + std::uint64_t FieldChecksumValue,bool FirstStepAudit,bool DynamicsAudit) + { + std::error_code Error; + std::filesystem::create_directories(Options.Output,Error); + if(Error){std::cerr<<"cannot create output directory: "< Ms; + Ms.reserve(Times.size()); + for(const GLuint64 Ns:Times)Ms.push_back(static_cast(Ns)*1.0e-6); + const double Mean=Ms.empty()?0.0:std::accumulate(Ms.begin(),Ms.end(),0.0)/Ms.size(); + const double Min=Ms.empty()?0.0:*std::min_element(Ms.begin(),Ms.end()); + const double Max=Ms.empty()?0.0:*std::max_element(Ms.begin(),Ms.end()); + const double HeightMin=*std::min_element(Heights.begin(),Heights.end()); + const double HeightMax=*std::max_element(Heights.begin(),Heights.end()); + const double HeightMean=std::accumulate(Heights.begin(),Heights.end(),0.0)/Heights.size(); + 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); + const double VelocityMax=*std::max_element(Velocities.begin(),Velocities.end(), + [](double A,double B){return std::abs(A)(TimerQueryRingSize,Times.size()):0)<<",\n" + <<" \"timer_query_counter_bits\": "<=0.0)Json<(Times[I])*1.0e-6; + } + Json<<"\n ],\n \"gl_time_elapsed_summary_ms\": {\n" + <<" \"mean\": "<(Json); + } + + bool Run(const FOptions& Options) + { + FContext Context; + if(!CreateContext(Options.Device,Context))return false; + const auto* RendererPtr=reinterpret_cast(glGetString(GL_RENDERER)); + const auto* VendorPtr=reinterpret_cast(glGetString(GL_VENDOR)); + const auto* VersionPtr=reinterpret_cast(glGetString(GL_VERSION)); + if(!RendererPtr||!VendorPtr||!VersionPtr) + {std::cerr<<"[LivingPlane] OpenGL identity query failed\n";return false;} + const std::string Renderer=RendererPtr,Vendor=VendorPtr,Version=VersionPtr; + if(Options.Device=="gpu"&&Context.ExecutionDevice!="gpu") + {std::cerr<<"[LivingPlane] refused software renderer for requested GPU run: " + < Fields{}; + glGenTextures(2,Fields.data()); + const std::vector Zeroes(static_cast(Size)*Size*4,0.0f); + for(const GLuint Field:Fields) + { + glBindTexture(GL_TEXTURE_2D,Field); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA32F,Size,Size,0,GL_RGBA,GL_FLOAT,Zeroes.data()); + } + + FRenderResources Render; + if(Options.CaptureEvery>0&&!Render.Initialize(Size,Size,Size)) + {Render.Destroy();glDeleteTextures(2,Fields.data());glDeleteProgram(ComputeProgram);return false;} + + const int QueryCount=Options.TimerQueries + ?std::min(static_cast(TimerQueryRingSize),Options.Steps):0; + std::vector Queries(static_cast(QueryCount)); + if(QueryCount>0)glGenQueries(QueryCount,Queries.data()); + GLint TimerQueryBits=0; + if(Options.TimerQueries)glGetQueryiv(GL_TIME_ELAPSED,GL_QUERY_COUNTER_BITS,&TimerQueryBits); + + const GLint LocationGrid=glGetUniformLocation(ComputeProgram,"uGridSize"); + const GLint LocationDt=glGetUniformLocation(ComputeProgram,"uDt"); + const GLint LocationDx=glGetUniformLocation(ComputeProgram,"uDx"); + const GLint LocationPlane=glGetUniformLocation(ComputeProgram,"uPlaneLength"); + const GLint LocationWaveSpeed=glGetUniformLocation(ComputeProgram,"uWaveSpeed"); + const GLint LocationDamping=glGetUniformLocation(ComputeProgram,"uDamping"); + const GLint LocationFoundation=glGetUniformLocation(ComputeProgram,"uFoundationStiffness"); + const GLint LocationTime=glGetUniformLocation(ComputeProgram,"uTime"); + const GLint LocationForcePositions=glGetUniformLocation(ComputeProgram,"uForcePositions[0]"); + const GLint LocationForceSigma=glGetUniformLocation(ComputeProgram,"uForceSigma"); + const GLint LocationForceStrengths=glGetUniformLocation(ComputeProgram,"uForceStrengths[0]"); + const GLint LocationBreathing=glGetUniformLocation(ComputeProgram,"uBreathing"); + const GLint LocationBreathingStrength=glGetUniformLocation(ComputeProgram,"uBreathingStrength"); + const GLint LocationImprint=glGetUniformLocation(ComputeProgram,"uImprint"); + const GLint LocationImprintCapture=glGetUniformLocation(ComputeProgram,"uImprintCaptureRate"); + const GLint LocationImprintRecovery=glGetUniformLocation(ComputeProgram,"uImprintRecoverySeconds"); + const GLint LocationImprintDepth=glGetUniformLocation(ComputeProgram,"uImprintMaxDepth"); + if(LocationGrid<0||LocationDt<0||LocationDx<0||LocationPlane<0||LocationWaveSpeed<0 + ||LocationDamping<0||LocationFoundation<0||LocationTime<0 + ||LocationForcePositions<0||LocationForceSigma<0||LocationForceStrengths<0 + ||LocationBreathing<0||LocationBreathingStrength<0||LocationImprint<0 + ||LocationImprintCapture<0||LocationImprintRecovery<0||LocationImprintDepth<0) + {std::cerr<<"[LivingPlane] missing compute shader uniforms\n";return false;} + + glUseProgram(ComputeProgram); + glUniform1i(LocationGrid,Size); + glUniform1f(LocationDt,static_cast(Dt)); + glUniform1f(LocationDx,static_cast(Dx)); + glUniform1f(LocationPlane,static_cast(PlaneLengthMeters)); + glUniform1f(LocationWaveSpeed,static_cast(WaveSpeedMetersPerSecond)); + glUniform1f(LocationDamping,static_cast(DampingPerSecond)); + glUniform1f(LocationFoundation,static_cast(FoundationStiffnessPerSecondSquared)); + glUniform1f(LocationForceSigma,static_cast(SphereSigmaMeters)); + glUniform1i(LocationBreathing,Options.Breathing?1:0); + glUniform1f(LocationBreathingStrength,static_cast(Options.BreathingStrength)); + glUniform1i(LocationImprint,Options.Imprint?1:0); + glUniform1f(LocationImprintCapture, + static_cast(Options.ImprintCaptureMetersPerSecond)); + glUniform1f(LocationImprintRecovery,static_cast(Options.ImprintRecoverySeconds)); + glUniform1f(LocationImprintDepth,static_cast(Options.ImprintMaxDepthMeters)); + + int Current=0; + const auto Dispatch=[&](int Target,double Time, + const std::array& Bodies,GLuint Query) + { + glUseProgram(ComputeProgram); + glUniform1f(LocationTime,static_cast(Time)); + const std::array Positions={ + static_cast(Bodies[0].X),static_cast(Bodies[0].Y), + static_cast(Bodies[1].X),static_cast(Bodies[1].Y)}; + const std::array Strengths={ + static_cast(Bodies[0].Force),static_cast(Bodies[1].Force)}; + glUniform2fv(LocationForcePositions,2,Positions.data()); + glUniform1fv(LocationForceStrengths,2,Strengths.data()); + glBindImageTexture(0,Fields[static_cast(Current)],0,GL_FALSE,0, + GL_READ_ONLY,GL_RGBA32F); + glBindImageTexture(1,Fields[static_cast(Target)],0,GL_FALSE,0, + GL_WRITE_ONLY,GL_RGBA32F); + if(Query)glBeginQuery(GL_TIME_ELAPSED,Query); + glDispatchCompute(static_cast((Size+15)/16), + static_cast((Size+15)/16),1); + if(Query)glEndQuery(GL_TIME_ELAPSED); + glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT|GL_TEXTURE_FETCH_BARRIER_BIT); + Current=Target; + }; + + // Warm the runtime-compiled compute kernel before measurement. + const std::array NoBodies{}; + for(int Warm=0;Warm<8;++Warm)Dispatch(1-Current,-8.0*Dt,NoBodies,0); + glFinish(); + for(const GLuint Field:Fields) + { + glBindTexture(GL_TEXTURE_2D,Field); + glTexSubImage2D(GL_TEXTURE_2D,0,0,0,Size,Size,GL_RGBA,GL_FLOAT,Zeroes.data()); + } + glMemoryBarrier(GL_TEXTURE_UPDATE_BARRIER_BIT|GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); + Current=0; + + FRenderResources* RenderPtr=Options.CaptureEvery>0?&Render:nullptr; + std::size_t FramesWritten=0; + int FrameIndex=0; + auto SaveFrame=[&](double Time)->bool + { + if(!RenderPtr)return true; + char Filename[32]{}; + std::snprintf(Filename,sizeof(Filename),"frame_%06d.png",FrameIndex++); + const auto Bodies=GetBodiesAt(Time); + if(!Render.Render(Fields[static_cast(Current)], + Options.Output/Filename,Bodies[0],Bodies[1]))return false; + ++FramesWritten; + return true; + }; + + if(!SaveFrame(0.0)) + {Render.Destroy();if(QueryCount>0)glDeleteQueries(QueryCount,Queries.data()); + glDeleteTextures(2,Fields.data());glDeleteProgram(ComputeProgram);return false;} + + std::vector Times(Options.TimerQueries + ?static_cast(Options.Steps):0); + int TimerQueryWaits=0; + bool TimerResultsAvailable=Options.TimerQueries; + auto ReadTimerQuery=[&](int QueryStep) + { + const GLuint Query=Queries[static_cast(QueryStep%QueryCount)]; + GLint Available=GL_FALSE; + glGetQueryObjectiv(Query,GL_QUERY_RESULT_AVAILABLE,&Available); + if(Available!=GL_TRUE)++TimerQueryWaits; + glGetQueryObjectui64v(Query,GL_QUERY_RESULT, + &Times[static_cast(QueryStep)]); + glGetQueryObjectiv(Query,GL_QUERY_RESULT_AVAILABLE,&Available); + TimerResultsAvailable=TimerResultsAvailable&&Available==GL_TRUE; + }; + + std::vector FirstField; + bool FirstStepAudit=false; + const auto WallStart=std::chrono::steady_clock::now(); + for(int Step=0;Step0&&Step>=QueryCount)ReadTimerQuery(Step-QueryCount); + const double Time=static_cast(Step)*Dt; + const auto Bodies=GetBodiesAt(Time); + const int Target=1-Current; + const GLuint Query=QueryCount>0 + ?Queries[static_cast(Step%QueryCount)]:0; + Dispatch(Target,Time,Bodies,Query); + if(Step==0) + { + glFinish(); + FirstField.resize(static_cast(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); + const int Y=std::clamp(static_cast(std::floor( + (Bodies[0].Y+PlaneLengthMeters*0.5)/Dx)),0,Size-1); + const double CellX=(X+0.5)*Dx-PlaneLengthMeters*0.5; + const double CellY=(Y+0.5)*Dx-PlaneLengthMeters*0.5; + double Force=0.0; + for(const FBodyState& Body:Bodies) + { + const double Distance2=(CellX-Body.X)*(CellX-Body.X) + +(CellY-Body.Y)*(CellY-Body.Y); + Force-=Body.Force*std::exp(-Distance2/ + (2.0*SphereSigmaMeters*SphereSigmaMeters)); + } + const double Expected=Force*Dt*Dt*std::exp(-DampingPerSecond*Dt); + const std::size_t Index=(static_cast(Y)*Size+X)*4; + const double Actual=FirstField[Index]; + const double Tolerance=std::max(2.0e-7,std::abs(Expected)*2.5e-3); + FirstStepAudit=std::isfinite(Actual)&&std::abs(Actual-Expected)<=Tolerance; + if(!FirstStepAudit) + std::cerr<<"[LivingPlane] first-step equation audit failed: expected " + <(Step+1)*Dt)) + {Render.Destroy();if(QueryCount>0)glDeleteQueries(QueryCount,Queries.data()); + glDeleteTextures(2,Fields.data());glDeleteProgram(ComputeProgram);return false;} + } + } + glFinish(); + const auto WallEnd=std::chrono::steady_clock::now(); + const double WallMs=std::chrono::duration(WallEnd-WallStart).count(); + const double DispatchWallMsPerStep=Options.CaptureEvery==0 + ?WallMs/Options.Steps:-1.0; + + if(QueryCount>0) + for(int Step=std::max(0,Options.Steps-QueryCount);Step 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); + Velocities.reserve(static_cast(Size)*Size); + bool Finite=true; + double ImprintMin=0.0; + for(std::size_t I=0;I(FinalField[I+2])); + } + 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;} + + const std::vector QueryMs=[&] + { + std::vector Result; + Result.reserve(Times.size()); + for(const GLuint64 Ns:Times)Result.push_back(static_cast(Ns)*1.0e-6); + return Result; + }(); + std::cout<<"[LivingPlane] scene="<0)glDeleteQueries(QueryCount,Queries.data()); + glDeleteTextures(2,Fields.data()); + glDeleteProgram(ComputeProgram); + return Good; + } +} + +int main(int Argc,char** Argv) +{ + FOptions Options; + if(!ParseOptions(Argc,Argv,Options))return 2; + if(!Run(Options))return 1; + return 0; +} diff --git a/Experiments/LivingPlane/REPORT.md b/Experiments/LivingPlane/REPORT.md new file mode 100644 index 0000000..0a35448 --- /dev/null +++ b/Experiments/LivingPlane/REPORT.md @@ -0,0 +1,201 @@ +# L162 — Living Plane + +The EGL/OpenGL 4.3 runner builds and passes its llvmpipe correctness and frame checks. The sandbox has no accessible DRM render node, so it did not run or report Intel GPU performance. The runner refuses `--device=gpu` here instead of falling back to software. + +## Implementation + +`LivingPlane.cpp` runs a ping-pong `RG32F` height/velocity field through a runtime-compiled GLSL compute shader. The damped wave update includes a weak foundation spring, a Gaussian sphere push, and an optional slow breathing force. The spring is needed because a persistent local push otherwise accelerates the zero-frequency height mode and sinks the whole plane. The sphere moves across the plane, stops, and bobs. A vertex-displaced mesh and lit sphere render to an EGL pbuffer-backed framebuffer and save as PNGs. + +The timestep is selected from the largest 2D grid eigenfrequency: + +`dt = 1.30 / sqrt(8*c*c/(dx*dx) + foundationStiffness)` + +The resulting spectral step ratio is 1.30, below the explicit update's 2.0 stability limit. The runner warms the compute kernel for eight unmeasured dispatches, then records one `GL_TIME_ELAPSED` query per requested simulation step in `timing.json`. + +The first-step equation audit also caught a GL state error during bring-up: frame rendering left the graphics program active before the next compute dispatch. `Dispatch` now binds the compute program before every step, so the audit fails if a frame silently stops simulation. + +Device selection enumerates EGL devices and uses `EGL_EXT_platform_device` for an accessible Intel render node first. On this host, the known integrated device is Intel on `renderD128`. Automatic mode falls back to a surfaceless Mesa context with `LIBGL_ALWAYS_SOFTWARE=1`; explicit GPU mode fails when no accessible hardware node is present. EGL does not expose Vulkan's integrated/discrete enum, so Intel vendor/render-node identity is the selection heuristic. + +The Unreal handoff is in [UNREAL-MAPPING.md](UNREAL-MAPPING.md). It keeps the prototype render-only and describes the density mapping if the surface later needs to affect voxel queries. No Unreal `Source/` files changed. + +## Measurements and checks + +- Built with `Experiments/LivingPlane/build.sh` using the installed EGL, desktop GL, and libpng development files; no shader compiler or package install was needed. +- Final llvmpipe run: 128² field, 360 steps, `dt=9.56814 ms`, spectral ratio 1.30. The first-step equation audit and finite-field/dynamics audit passed. Final field range was −0.139533 m to +0.0366704 m. It wrote 16 128×128 PNGs and a JSON file containing 360 per-step query samples. The frames were inspected; the moving sphere and wake are visible. +- Auto-selection smoke check: 32², 12 steps; selected llvmpipe and passed both audits. Breathing smoke check: 64², 180 steps with `--breathing=on`; passed both audits. +- Negative device-selection control: `--device=gpu` returned an error because the sandbox has no accessible EGL DRM render node. +- Existing fast verification tier passed: 278.9 seconds, ending in `SUMMARY PASS`. Its build and logs are under `Saved/Linux/l162/verify/` and `Saved/Linux/l162/`. +- The llvmpipe `GL_TIME_ELAPSED` samples are preserved in JSON but are software-driver query values, not GPU timings. The requested hardware timing pass remains to be run outside the sandbox. +- No Unreal build was run; no Unreal `Source/` files changed. + +## Intel GPU pass + +Run these from the plugin root as `jahni`. The frame run writes 512×512 PNGs to `Saved/Linux/l162/gpu-frames-512/`; the two timing runs write `timing.json` with one GPU query per step and skip PNG capture. They still read the field for the first-step and final-state correctness audits. +Before the timing runs, check `uptime` and run them on a quiet machine (load below about 1.5, with no other build active). + +```sh +uptime +taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l162/living-plane --device=gpu --size=512 --steps=1800 --capture-every=12 --out=Saved/Linux/l162/gpu-frames-512 +taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l162/living-plane --device=gpu --size=512 --steps=1800 --capture-every=0 --out=Saved/Linux/l162/gpu-timing-512 +taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l162/living-plane --device=gpu --size=1024 --steps=1800 --capture-every=0 --out=Saved/Linux/l162/gpu-timing-1024 +``` + +Encode the captured sequence as MP4 with: + +```sh +ffmpeg -y -framerate 30 -i Saved/Linux/l162/gpu-frames-512/frame_%06d.png -c:v libx264 -pix_fmt yuv420p Saved/Linux/l162/living-plane-512.mp4 +``` + +## Files and remaining risk + +The only changed file outside `Experiments/LivingPlane/` is `.gitignore`, which now allows `Experiments/` and its contents. GPU timings and Intel render-node selection remain unverified until the commands above run on the host outside this sandbox. + +## Intel GPU pass (run by the orchestrator outside the sandbox, 2026-11-02) + +- The device was selected correctly: `Mesa Intel(R) Graphics (ADL GT2)`, OpenGL 4.6, `/dev/dri/renderD128`. 512², + 1,800 steps, 151 frames; the equation audit passes. MP4: `Saved/Linux/l162/living-plane-512.mp4` (local). +- **The `GL_TIME_ELAPSED` per-dispatch values (~0.0001 ms) are not credible** for 262k cells, so they aren't + reported as timings. +- Wall-time difference between 1,800 and 200 steps, divided by the 1,600 steps (start-up cancels out; this includes + submission, so it's an upper bound): **0.080 ms/step at 512²** and **0.292 ms/step at 1024²**. +- Visual read: the effect is very subtle in the current render (a slight dip under the sphere, faint rings). It needs + a larger push amplitude, grazing light or height colouring, and a closer camera to read. + +## L163 — readable, living plane + +The plane now reads as rolling terrain, and the objects leave visible troughs and wakes. `alive` adds travelling +breathing and a viscoelastic imprint that remains after lift-off. The simulation still runs in one GPU dispatch per +step; CPU work only supplies the two object states. + +`LivingPlane.cpp` now keeps the dynamic displacement, velocity, and imprint in an `RGBA32F` ping-pong field. The +surface shader adds broad static terrain relief, calculates normals from relief plus displacement, and uses grazing +light, elevation colours, slope tint, and 7.5 cm contour bands. The camera follows the first sphere at a closer angle. +The wake scene rolls the first sphere, pauses and wobbles it, lifts it, then sends a second sphere through the wake. +The alive scene runs that same sequence with low-frequency travelling swells and a decaying contact imprint. Its +defaults are breathing strength 4.8 m/s², imprint capture 0.15 m/s, recovery time 6 s, and maximum imprint depth +0.34 m. These values have command-line overrides. + +The timer query check found no early read or reuse in L162: it allocated a unique query per step, called `glFinish`, +then read every result. The saved Intel/Mesa 25.0.7 samples are 0 or 52 ns for most dispatches (512² median 0, +p95 52 ns; largest sample 17.552 µs), which cannot be a credible per-dispatch cost. The old usage did not reuse +query objects, so lifetime is not the cause; the saved data point to Intel/Mesa's timer-query reporting or timing +scope, but the sandbox cannot isolate which. L163 uses a 64-query ring, reads each query before reusing its slot, +checks availability after `glFinish`, and records `GL_QUERY_COUNTER_BITS`. On llvmpipe the availability check passed +with 64 counter bits and zero waits. The Intel query result still needs the GPU diagnostic command below. + +### Measurements and checks + +- llvmpipe wake: 128², 600 steps (`dt=9.56814 ms`, 5.741 s simulation window), 151 frames at 30 fps. First-step + equation audit and timer-query lifetime check passed; final displacement range was −0.177488 m to +0.104854 m. +- llvmpipe alive: same grid, steps, and window; equation audit and timer-query lifetime check passed. Final + displacement range was −0.200437 m to +0.115904 m, with a −0.111667 m stored imprint. +- llvmpipe cost: 512², 1,800 steps, `--queries=off`, no frame capture: **0.767473 ms/step** in one timed + simulation-loop window. `timing.json` includes dispatch submission, the step-0 equation readback, and final + `glFinish`, but excludes setup and frame capture. This is a software-renderer measurement, not the Intel GPU cost. +- Both five-second llvmpipe previews are in `Saved/Linux/l163/llvmpipe-wake.mp4` and + `Saved/Linux/l163/llvmpipe-alive.mp4`. They are 128² review previews; use the GPU commands below for the owner + videos. +- Equation-audit negative control: a temporary copy with its expected first-step height forced to zero exited FAIL + at actual −0.0488777 m. The final source passes that same check. +- The fast verification tier passed in 278.5 s. Build and logs are under `Saved/Linux/l163/verify/`. +- No Unreal `Source/` files changed and no Unreal build ran. + +### Intel GPU run + +Run from the plugin root as `jahni`. First build the runner. For timed runs, check `uptime` and keep the machine +quiet. The wall-time field is valid only when `--capture-every=0`; use `--queries=off` for the step-cost measurement. + +```sh +bash Experiments/LivingPlane/build.sh +uptime +taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l163/living-plane --device=gpu --scene=wake --size=512 --steps=2400 --capture-every=16 --queries=off --out=Saved/Linux/l163/gpu-wake-512 +taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l163/living-plane --device=gpu --scene=alive --size=512 --steps=2400 --capture-every=16 --queries=off --out=Saved/Linux/l163/gpu-alive-512 +ffmpeg -y -framerate 30 -i Saved/Linux/l163/gpu-wake-512/frame_%06d.png -c:v libx264 -pix_fmt yuv420p Saved/Linux/l163/living-plane-wake-512.mp4 +ffmpeg -y -framerate 30 -i Saved/Linux/l163/gpu-alive-512/frame_%06d.png -c:v libx264 -pix_fmt yuv420p Saved/Linux/l163/living-plane-alive-512.mp4 +taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l163/living-plane --device=gpu --scene=alive --size=512 --steps=1800 --capture-every=0 --queries=off --out=Saved/Linux/l163/gpu-timing-512 +taskset -c 0-11 prlimit --as=6442450944 -- timeout 1740s ./Saved/Linux/l163/living-plane --device=gpu --scene=alive --size=512 --steps=1800 --capture-every=0 --queries=on --out=Saved/Linux/l163/gpu-query-check-512 +``` + +`gpu-timing-512/timing.json` reports the 512² batch wall time per step, including dispatch submission and completion. +`gpu-query-check-512/timing.json` contains query counter bits, waits before slot reuse, availability status, and raw +per-step query samples. The sandbox has no accessible Intel DRM node, so the current Intel step cost and new query +diagnostic remain unmeasured here. + +### L163 Intel GPU pass (run by the orchestrator, 2026-11-02) + +- Intel ADL GT2 (Mesa 25.0.7, `renderD128`): wake and alive scenes, 512², 2,400 steps, 151 frames each; equation + audits PASS. Videos (local): `Saved/Linux/l163/living-plane-wake-512.mp4`, `living-plane-alive-512.mp4`. +- Step cost, alive scene, 512², 1,800 steps, no capture: **0.172 ms/step** with queries off, 0.152 ms/step with + queries on (batch wall time per step). +- The Intel `GL_TIME_ELAPSED` median is still 0 with 64 counter bits and the ring/availability checks passing. That + looks like a Mesa/Intel reporting issue; use wall time. +- **Open issue:** the same scene with the same steps gave different final fields (min −0.637 m with queries off vs + −0.327 m with queries on). Something depends on more than the step index (e.g. wall-clock time); investigate. + +## L164 — repeatable field readback and UE runtime actor + +**Result:** the repeatability check passes on llvmpipe, and the new `VoxelForgeLivingPlane` runtime module compiles +in the Linux UE 5.7 host. The surface actor is independent of VoxelForge generation and sends no field readback to +the CPU. + +### Repeatability finding + +`uTime` is `Step * dt`, and `GetBodiesAt` receives that same value. Neither the object schedule nor the breathing +force uses wall time or a rendered-frame counter. Every compute step already issued an image-access barrier before +the next ping-pong image read. The defect was on the diagnostic side: the step-zero and final `glGetTexImage` +readbacks followed shader image writes without `GL_TEXTURE_UPDATE_BARRIER_BIT`. The Khronos OpenGL specification +requires that barrier for `GetTexImage` to observe earlier shader writes ([§7.12](https://registry.khronos.org/OpenGL/specs/gl/glspec44.compatibility.pdf)). Query/capture pacing could therefore affect the reported field values without changing any simulation input. The barrier is now issued before both readbacks. + +`check_repeatability.py` runs the same 512² alive scene for 1,800 steps twice: queries off with no captures, then +queries on with captures every 900 steps. The checksum covers every final RGBA32F field value using canonical +little-endian float bytes. + +- llvmpipe, queries off/capture off: field range −0.637382 m to +0.109308 m; checksum + `1ba08f5dafc01f82`. +- llvmpipe, queries on/capture every 900: the same range and checksum; equation and timer-query audits pass; three + frames written. +- Negative control: changing one saved checksum made the comparison exit 1 with `FAIL field checksum mismatch`. +- The llvmpipe runs were each one 1,800-step simulation-loop window. Logs and JSON are under + `Saved/Linux/l164/repeatability/`. + +The Intel run is not available in this sandbox. The owner should repeat the checksum comparison on the GPU to +confirm that the missing transfer-readback barrier accounts for the earlier Intel min mismatch: + +```sh +python3 Experiments/LivingPlane/check_repeatability.py --device=gpu --out=Saved/Linux/l164/gpu-repeatability +``` + +### Unreal implementation and checks + +The plugin descriptor adds a separate `VoxelForgeLivingPlane` runtime module. Its RDG global shader is in +`Shaders/LivingPlane.usf`; module startup maps `/Plugin/VoxelForgeLivingPlane` to the plugin shader directory. The +actor owns two UAV-capable RGBA32F render targets, advances them at the stable fixed step, and supplies the current +texture to a movable plane mesh material. Its Blueprint/C++ object input includes world position, radius, velocity, +base push, and active state. The CPU converts those values to plane-local metres and uploads uniforms; it does not +read the field back. The ignore rules now include `Shaders/` so that the shader ships in the worktree diff. +`Experiments/LivingPlane/UE-SETUP.md` gives level setup, the full material graph, and object driving steps. + +- Linux UE 5.7 `VFHostEditor Linux Development`: **PASS**, final incremental build compiled + `VoxelForgeLivingPlaneActor.cpp` and linked `libUnrealEditor-VoxelForgeLivingPlane.so`. Command used + `-NoUBA -MaxParallelActions=4`, `taskset -c 0-11`, and no `prlimit`. Log: + `Saved/Linux/l164/ue-build-final.log`. The initial attempt exposed an incorrect RDG texture registration overload; + it had already compiled `VoxelForgeLivingPlaneModule.cpp` and the generated module code. The corrected final + build passed. +- The host `PluginDeps/VoxelForge/Source` and `.uplugin` links were temporarily pointed at this worktree for the + build and restored to `/home/jahni/VoxelM/Plugins/VoxelForge` afterward. The temporary shader link was removed. +- Standalone build, final repeatability comparison, checksum-mismatch negative control, plugin JSON parse, and + `git diff --check` passed. +- Not run: fast generation verification (generation sources are untouched), Intel GPU checksum comparison, or a UE + editor render/shader-runtime check. The Linux UBT build verifies C++ and module integration, not first-use shader + compilation or material appearance. + +New Unreal source and shader files for the owner's Windows build: + +- `Source/VoxelForgeLivingPlane/VoxelForgeLivingPlane.Build.cs` +- `Source/VoxelForgeLivingPlane/Public/VoxelForgeLivingPlaneActor.h` +- `Source/VoxelForgeLivingPlane/Private/VoxelForgeLivingPlaneActor.cpp` +- `Source/VoxelForgeLivingPlane/Private/VoxelForgeLivingPlaneModule.cpp` +- `Shaders/LivingPlane.usf` + +No VoxelForge generation module files changed. Remaining risks are the Intel checksum confirmation and first-use UE +shader/material rendering, which require the owner's GPU/editor run. diff --git a/Experiments/LivingPlane/UE-SETUP.md b/Experiments/LivingPlane/UE-SETUP.md new file mode 100644 index 0000000..72b9d5a --- /dev/null +++ b/Experiments/LivingPlane/UE-SETUP.md @@ -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. diff --git a/Experiments/LivingPlane/UNREAL-MAPPING.md b/Experiments/LivingPlane/UNREAL-MAPPING.md new file mode 100644 index 0000000..ff27bd4 --- /dev/null +++ b/Experiments/LivingPlane/UNREAL-MAPPING.md @@ -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). diff --git a/Experiments/LivingPlane/build.sh b/Experiments/LivingPlane/build.sh new file mode 100755 index 0000000..dc42559 --- /dev/null +++ b/Experiments/LivingPlane/build.sh @@ -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" 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",