From 5b15595e4a920ae861a8c53db7e406a99ecb0da1 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Mon, 2 Nov 2026 10:35:37 +0100 Subject: [PATCH] experiment: LivingPlane - GPU wave-equation height field reacting to a moving sphere (L162) Standalone EGL/OpenGL 4.3 compute runner (runtime GLSL, no shader compiler needed), ping-pong height/velocity field, damped wave + foundation spring + Gaussian sphere push + optional breathing, vertex-displaced render to PNG. Independent of generation. Intel ADL GT2: ~0.08 ms/step at 512^2, ~0.29 ms/step at 1024^2 (wall-time difference). .gitignore now allows Experiments/. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1 --- .gitignore | 4 + Experiments/LivingPlane/LivingPlane.cpp | 1136 +++++++++++++++++++++ Experiments/LivingPlane/REPORT.md | 62 ++ Experiments/LivingPlane/UNREAL-MAPPING.md | 23 + Experiments/LivingPlane/build.sh | 22 + 5 files changed, 1247 insertions(+) create mode 100644 Experiments/LivingPlane/LivingPlane.cpp create mode 100644 Experiments/LivingPlane/REPORT.md create mode 100644 Experiments/LivingPlane/UNREAL-MAPPING.md create mode 100755 Experiments/LivingPlane/build.sh diff --git a/.gitignore b/.gitignore index 90316d2..fba57e8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ /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..592ba5d --- /dev/null +++ b/Experiments/LivingPlane/LivingPlane.cpp @@ -0,0 +1,1136 @@ +#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 + +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.46; + constexpr double SpherePushMetersPerSecondSquared=14.0; + constexpr double Pi=3.14159265358979323846; + + struct FOptions + { + std::string Device="auto"; + std::filesystem::path Output="Saved/Linux/l162/living-plane"; + int Size=128; + int Steps=360; + int CaptureEvery=1; + bool Breathing=false; + }; + + 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); + float h=imageLoad(uSource,p).r; + float v=imageLoad(uSource,p).g; + 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); + vec2 offset=world-uForcePosition; + float sigma2=max(uForceSigma*uForceSigma,1.0e-8); + float interaction=-uForceStrength*exp(-dot(offset,offset)/(2.0*sigma2)); + float breathing=0.0; + if(uBreathing!=0) + { + float spatial=sin(world.x*0.72)*sin(world.y*0.72); + breathing=2.4*spatial*sin(uTime*1.45); + } + float acceleration=uWaveSpeed*uWaveSpeed*laplacian-uFoundationStiffness*h + +interaction+breathing; + float nextVelocity=(v+acceleration*uDt)*exp(-uDamping*uDt); + float nextHeight=h+nextVelocity*uDt; + imageStore(uTarget,p,vec4(nextHeight,nextVelocity,0.0,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 mat4 uViewProjection; +out vec3 vWorld; +out vec3 vNormal; +out float vHeight; +flat out int vSphere; +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)); + height=texelFetch(uField,texel,0).r; + 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=(texelFetch(uField,right,0).r-texelFetch(uField,left,0).r)/dx; + float dhdy=(texelFetch(uField,up,0).r-texelFetch(uField,down,0).r)/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; + vSphere=uRenderSphere; + 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 vSphere; +uniform vec3 uCameraPosition; +out vec4 outColor; +void main() +{ + vec3 n=normalize(vNormal); + vec3 l=normalize(vec3(-0.45,-0.55,0.72)); + vec3 v=normalize(uCameraPosition-vWorld); + float diffuse=max(dot(n,l),0.0); + float spec=pow(max(dot(reflect(-l,n),v),0.0),vSphere==0?58.0:38.0); + if(vSphere!=0) + { + vec3 base=vec3(0.98,0.43,0.13); + float light=0.24+0.84*diffuse; + outColor=vec4(base*light+vec3(0.65,0.43,0.22)*spec,1.0); + } + else + { + float crest=clamp(0.50+vHeight*0.8,0.0,1.0); + vec3 deep=vec3(0.035,0.22,0.34); + vec3 crestColor=vec3(0.13,0.62,0.70); + vec3 base=mix(deep,crestColor,crest); + float light=0.28+0.88*diffuse; + outColor=vec4(base*light+vec3(0.31,0.62,0.68)*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; }; + + 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) + { + const FVec3 Target={0.0,0.0,0.0}; + 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=100.0; + const double F=1.0/std::tan(42.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{8.8,-12.2,9.4}; + + 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,256); + 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); + + ViewProjection=MakeViewProjection(Width,Height,Camera); + glBindVertexArray(0); + return glGetError()==GL_NO_ERROR; + } + + bool Render(GLuint FieldTexture,const std::filesystem::path& Output, + double SphereX,double SphereY,double SphereZ) + { + 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); + glUniform3f(glGetUniformLocation(Program,"uSphereCenter"), + static_cast(SphereX),static_cast(SphereY),static_cast(SphereZ)); + glUniform1f(glGetUniformLocation(Program,"uSphereRadius"), + static_cast(SphereRadiusMeters)); + glBindVertexArray(SphereVao); + 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 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] [--size=N] [--steps=N] " + "[--out=DIR] [--capture-every=N] [--breathing=on|off]\n" + " --size is the square height-field and PNG resolution.\n" + " --capture-every=0 writes only timing.json. Default is 1.\n"; + std::exit(0); + } + else if(Take("--device",Value))Options.Device=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("--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;} + } + 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;} + 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)]; + } + + 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,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()); + 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)(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*2,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_RG32F,Size,Size,0,GL_RG,GL_FLOAT,Zeroes.data()); + } + + const int CaptureWidth=Size,CaptureHeight=Size; + FRenderResources Render; + if(Options.CaptureEvery>0&&!Render.Initialize(Size,CaptureWidth,CaptureHeight)) + {Render.Destroy();glDeleteTextures(2,Fields.data());glDeleteProgram(ComputeProgram);return false;} + + const GLuint QueryCount=static_cast(Options.Steps); + std::vector Queries(QueryCount); + glGenQueries(static_cast(QueryCount),Queries.data()); + 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 LocationForcePosition=glGetUniformLocation(ComputeProgram,"uForcePosition"); + const GLint LocationForceSigma=glGetUniformLocation(ComputeProgram,"uForceSigma"); + const GLint LocationForceStrength=glGetUniformLocation(ComputeProgram,"uForceStrength"); + const GLint LocationBreathing=glGetUniformLocation(ComputeProgram,"uBreathing"); + if(LocationGrid<0||LocationDt<0||LocationDx<0||LocationPlane<0||LocationWaveSpeed<0 + ||LocationDamping<0||LocationFoundation<0||LocationTime<0 + ||LocationForcePosition<0||LocationForceSigma<0 + ||LocationForceStrength<0||LocationBreathing<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); + + int Current=0; + const auto Dispatch=[&](int Target,double Time,double SphereX,double SphereY, + double ForceStrength,GLuint Query) + { + glUseProgram(ComputeProgram); + glUniform1f(LocationTime,static_cast(Time)); + glUniform2f(LocationForcePosition,static_cast(SphereX),static_cast(SphereY)); + glUniform1f(LocationForceStrength,static_cast(ForceStrength)); + glBindImageTexture(0,Fields[static_cast(Current)],0,GL_FALSE,0, + GL_READ_ONLY,GL_RG32F); + glBindImageTexture(1,Fields[static_cast(Target)],0,GL_FALSE,0, + GL_WRITE_ONLY,GL_RG32F); + 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 with zero forcing before timing. + for(int Warm=0;Warm<8;++Warm) + Dispatch(1-Current,-8.0*Dt,0.0,0.0,0.0,0); + glFinish(); + for(const GLuint Field:Fields) + { + glBindTexture(GL_TEXTURE_2D,Field); + glTexSubImage2D(GL_TEXTURE_2D,0,0,0,Size,Size,GL_RG,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 SphereX,double SphereY,double SphereZ)->bool + { + if(!RenderPtr)return true; + char Filename[32]{}; + std::snprintf(Filename,sizeof(Filename),"frame_%06d.png",FrameIndex++); + if(!Render.Render(Fields[static_cast(Current)], + Options.Output/Filename,SphereX,SphereY,SphereZ))return false; + ++FramesWritten; + return true; + }; + + const int MoveSteps=std::max(2,static_cast(std::lround(Options.Steps*0.58))); + const auto SphereAt=[&](int Step) + { + const double MoveT=std::clamp(static_cast(Step)/std::max(1,MoveSteps-1),0.0,1.0); + const double Smooth=MoveT*MoveT*(3.0-2.0*MoveT); + const double X=-4.8+9.6*Smooth; + const bool Rest=Step>=MoveSteps; + const double RestTime=Rest?static_cast(Step-MoveSteps)*Dt:0.0; + const double Bob=Rest?std::sin(RestTime*2.0*Pi*1.25):0.0; + const double Z=0.36+0.08*Bob; + const double Force=SpherePushMetersPerSecondSquared*(1.0-0.32*Bob); + return std::array{X,0.0,Z,Force}; + }; + + const auto Initial=SphereAt(0); + if(!SaveFrame(Initial[0],Initial[1],Initial[2])) + {Render.Destroy();glDeleteQueries(static_cast(Queries.size()),Queries.data()); + glDeleteTextures(2,Fields.data());glDeleteProgram(ComputeProgram);return false;} + + std::vector FirstField; + bool FirstStepAudit=false; + for(int Step=0;Step(Step)*Dt,Sphere[0],Sphere[1],Sphere[3], + Queries[static_cast(Step)]); + if(Step==0) + { + glFinish(); + FirstField.resize(static_cast(Size)*Size*2); + glBindTexture(GL_TEXTURE_2D,Fields[static_cast(Current)]); + glGetTexImage(GL_TEXTURE_2D,0,GL_RG,GL_FLOAT,FirstField.data()); + const int X=std::clamp(static_cast(std::floor( + (Sphere[0]+PlaneLengthMeters*0.5)/Dx)),0,Size-1); + const int Y=std::clamp(static_cast(std::floor( + (Sphere[1]+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; + const double Distance2=(CellX-Sphere[0])*(CellX-Sphere[0]) + +(CellY-Sphere[1])*(CellY-Sphere[1]); + const double Force=-Sphere[3]*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)*2; + 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 " + <(Queries.size()),Queries.data()); + glDeleteTextures(2,Fields.data());glDeleteProgram(ComputeProgram);return false;} + } + } + glFinish(); + + std::vector Times(static_cast(Options.Steps)); + for(int Step=0;Step(Step)],GL_QUERY_RESULT, + &Times[static_cast(Step)]); + std::vector FinalField(static_cast(Size)*Size*2); + glBindTexture(GL_TEXTURE_2D,Fields[static_cast(Current)]); + glGetTexImage(GL_TEXTURE_2D,0,GL_RG,GL_FLOAT,FinalField.data()); + std::vector Heights,Velocities; + Heights.reserve(static_cast(Size)*Size); + Velocities.reserve(static_cast(Size)*Size); + bool Finite=true; + for(std::size_t I=0;I(Queries.size()),Queries.data()); + glDeleteTextures(2,Fields.data());glDeleteProgram(ComputeProgram);return false;} + + double MeanNs=0.0; + for(const GLuint64 Ns:Times)MeanNs+=static_cast(Ns); + MeanNs/=Times.size(); + std::cout<<"[LivingPlane] size="<(Queries.size()),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..014ee66 --- /dev/null +++ b/Experiments/LivingPlane/REPORT.md @@ -0,0 +1,62 @@ +# 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. diff --git a/Experiments/LivingPlane/UNREAL-MAPPING.md b/Experiments/LivingPlane/UNREAL-MAPPING.md new file mode 100644 index 0000000..9f313ad --- /dev/null +++ b/Experiments/LivingPlane/UNREAL-MAPPING.md @@ -0,0 +1,23 @@ +# Unreal mapping note + +The prototype stores a two-channel height and vertical-velocity field in a pair of +`RG32F` textures. A world-owned Unreal component could keep equivalent transient +render graph textures and advance them with a compute pass. It should own simulation +time and clear or restore its buffers when the world is recreated. + +The 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. diff --git a/Experiments/LivingPlane/build.sh b/Experiments/LivingPlane/build.sh new file mode 100755 index 0000000..a1df899 --- /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/l162" +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"